This commit is contained in:
2026-02-16 19:34:35 +00:00
commit 9ab0940ca4
784 changed files with 41710 additions and 0 deletions
+32
View File
@@ -0,0 +1,32 @@
import type { MiddlewareHandler } from 'hono';
import * as errors from '../custom-errors';
export const bodyParser: () => MiddlewareHandler = () => async (ctx, next) => {
if (!['POST', 'PUT', 'PATCH'].includes(ctx.req.method.toUpperCase())) {
ctx.set('body', {});
return next();
}
const contentType = ctx.req.header('Content-Type') || '';
try {
if (contentType.includes('application/json')) {
const text = await ctx.req.text();
const body = text ? JSON.parse(text) : {};
ctx.set('body', body);
} else if (contentType.includes('application/x-www-form-urlencoded')) {
const body = await ctx.req.parseBody();
ctx.set('body', body);
} else if (contentType.includes('multipart/form-data')) {
const body = await ctx.req.parseBody({ all: true });
ctx.set('body', body);
} else {
// No recognized content type, set empty body
ctx.set('body', {});
}
} catch (ex) {
// throw errors.BAD_REQUEST('Invalid request body');
}
return next();
};
+5
View File
@@ -0,0 +1,5 @@
export * from './body-parser';
export * from './user-middleware';
export * from './origin-middleware';
export * from './origin-validation';
export * from './rate-limiter';
@@ -0,0 +1,18 @@
import type { MiddlewareHandler } from 'hono';
export const originMiddleware: MiddlewareHandler = function (ctx, next) {
let origin = ctx.req.header('origin');
if (!origin) {
const referer = ctx.req.header('referer');
if (referer) {
try {
const url = new URL(referer);
origin = url.origin;
} catch {}
}
}
ctx.set('origin', origin);
return next();
};
@@ -0,0 +1,51 @@
import type { MiddlewareHandler } from 'hono';
import * as errors from '../custom-errors';
const { PUBLIC_BUILD_ENV } = process.env;
const ALLOWED_ORIGINS: Record<string, string[]> = {
staging: ['https://staging.officer.dev'],
production: ['https://app.officer.dev', 'https://edge.officer.dev'],
};
export function isOriginAllowed(origin: string | undefined, host?: string): boolean {
// Dev environment: allow any origin
if (!PUBLIC_BUILD_ENV || PUBLIC_BUILD_ENV === 'dev' || PUBLIC_BUILD_ENV === 'development') {
return true;
}
// If origin is present, validate it
if (origin) {
// Chrome extension check
if (origin.startsWith('chrome-extension://')) {
if (PUBLIC_BUILD_ENV === 'staging') {
return true; // Allow any extension in staging
}
if (PUBLIC_BUILD_ENV === 'production') {
return origin === 'chrome-extension://fjooappefigppfjolhadliepialebodg';
}
}
// Web origin check
const allowed = ALLOWED_ORIGINS[PUBLIC_BUILD_ENV];
return !!allowed?.includes(origin);
}
// No origin header: allow same-origin requests by checking Host header
// This handles cases where browsers don't send Origin for same-origin requests
if (host) {
const allowed = ALLOWED_ORIGINS[PUBLIC_BUILD_ENV];
return !!allowed?.some((o) => o.endsWith(host));
}
return false;
}
export const originValidationMiddleware: MiddlewareHandler = function (ctx, next) {
const origin = ctx.get('origin') as string | undefined;
const host = ctx.req.header('host');
if (!isOriginAllowed(origin, host)) {
throw errors.FORBIDDEN('Invalid origin');
}
return next();
};
+105
View File
@@ -0,0 +1,105 @@
import type { Context, MiddlewareHandler } from 'hono';
import * as errors from '../custom-errors';
type RateLimitEntry = {
count: number;
resetAt: number;
};
type RateLimiterOptions = {
windowMs: number;
max: number;
keyGenerator?: (ctx: Context) => string;
message?: string;
};
const stores = new Map<string, Map<string, RateLimitEntry>>();
function cleanupStore(store: Map<string, RateLimitEntry>) {
const now = Date.now();
for (const [key, entry] of store) {
if (entry.resetAt <= now) {
store.delete(key);
}
}
}
const { PUBLIC_BUILD_ENV } = process.env;
const isProduction = PUBLIC_BUILD_ENV === 'production' || PUBLIC_BUILD_ENV === 'staging';
export function rateLimiter(options: RateLimiterOptions): MiddlewareHandler {
const {
windowMs,
max,
keyGenerator = (ctx) => ctx.req.header('x-forwarded-for') || 'unknown',
message = 'Too many requests, please try again later',
} = options;
const storeId = `${windowMs}-${max}-${Math.random()}`;
const store = new Map<string, RateLimitEntry>();
stores.set(storeId, store);
// Cleanup every minute
setInterval(() => cleanupStore(store), 60_000);
return async (ctx, next) => {
if (!isProduction) return next();
const key = keyGenerator(ctx);
const now = Date.now();
const entry = store.get(key);
if (entry && entry.resetAt > now) {
if (entry.count >= max) {
const retryAfter = Math.ceil((entry.resetAt - now) / 1000);
throw errors.TOO_MANY_REQUESTS(message, retryAfter);
}
entry.count++;
} else {
store.set(key, {
count: 1,
resetAt: now + windowMs,
});
}
await next();
};
}
// Pre-configured rate limiters for common use cases
export const signinRateLimiter = rateLimiter({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5,
keyGenerator: (ctx) => {
const body = ctx.get('body');
return body?.email || ctx.req.header('x-forwarded-for') || 'unknown';
},
message: 'Too many sign in attempts, please try again in 15 minutes',
});
export const forgotPasswordRateLimiter = rateLimiter({
windowMs: 60 * 60 * 1000, // 1 hour
max: 3,
keyGenerator: (ctx) => {
const body = ctx.get('body');
return body?.email || ctx.req.header('x-forwarded-for') || 'unknown';
},
message: 'Too many password reset requests, please try again in 1 hour',
});
export const signupRateLimiter = rateLimiter({
windowMs: 60 * 60 * 1000, // 1 hour
max: 5,
keyGenerator: (ctx) => ctx.req.header('x-forwarded-for') || 'unknown',
message: 'Too many sign up attempts, please try again in 1 hour',
});
export const passkeyRateLimiter = rateLimiter({
windowMs: 60 * 1000, // 1 minute
max: 10,
keyGenerator: (ctx) => {
const email = ctx.req.param('email');
return email || ctx.req.header('x-forwarded-for') || 'unknown';
},
message: 'Too many passkey requests, please try again in 1 minute',
});
@@ -0,0 +1,79 @@
import type { MiddlewareHandler } from 'hono';
import { verify } from '@@/jwt';
import * as errors from '@@/custom-errors';
import { isOriginAllowed } from './origin-validation';
import { officerdb, eq, Users, TokenBlacklist } from 'officerdb';
// Role permissions: which HTTP methods each role can use
// Roles not listed here are denied by default (fail-safe)
const ROLE_PERMISSIONS: Record<string, string[]> = {
Member: ['GET'],
Admin: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
Owner: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
'Super Admin': ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
};
function isMethodAllowed(role: string | null, method: string): boolean {
if (!role) return false;
const allowedMethods = ROLE_PERMISSIONS[role];
if (!allowedMethods) return false; // Unknown role = no access
return allowedMethods.includes(method);
}
export const userMiddleware: MiddlewareHandler = async function (ctx, next) {
const { authorization } = ctx.req.header();
// Support token in query param for media elements (<audio>, <video>, <img>)
// that cannot send Authorization headers
let token: string | undefined;
if (authorization) {
[, token] = authorization.split(' ');
} else {
token = ctx.req.query('token') || undefined;
}
if (!token) throw errors.UNAUTHORIZED();
// Validate origin for authenticated routes
const origin = ctx.get('origin') as string | undefined;
const host = ctx.req.header('host');
if (!isOriginAllowed(origin, host)) {
throw errors.FORBIDDEN('Invalid origin');
}
try {
const user = await verify(token);
if (!user) throw errors.UNAUTHORIZED();
// Check if token is blacklisted (explicit signout)
if (user.jti) {
const blacklisted = await officerdb.query.TokenBlacklist.findFirst({
where: eq(TokenBlacklist.jti, user.jti),
});
if (blacklisted) throw errors.UNAUTHORIZED();
}
// Check if token was issued before password change
if (user.iat && user.id) {
const dbUser = await officerdb.query.Users.findFirst({
where: eq(Users.id, user.id),
columns: { passwordChangedAt: true },
});
if (dbUser?.passwordChangedAt) {
// iat is in seconds, passwordChangedAt is in milliseconds
const tokenIssuedAt = user.iat * 1000;
if (tokenIssuedAt < dbUser.passwordChangedAt) {
throw errors.UNAUTHORIZED();
}
}
}
if (!isMethodAllowed(user.role, ctx.req.method)) {
throw errors.FORBIDDEN('Insufficient permissions');
}
ctx.set('user', user);
return next();
} catch (ex) {
if (ex instanceof errors.CustomError) throw ex;
throw errors.UNAUTHORIZED();
}
};