You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
245 lines
6.4 KiB
245 lines
6.4 KiB
|
5 months ago
|
import { config } from './env.mjs';
|
||
|
|
|
||
|
|
export class ApiError extends Error {
|
||
|
|
constructor(status, code, message, details) {
|
||
|
|
super(message);
|
||
|
|
this.name = 'ApiError';
|
||
|
|
this.status = status;
|
||
|
|
this.code = code;
|
||
|
|
this.details = details;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
export function ok(body, options = {}) {
|
||
|
|
return {
|
||
|
|
status: options.status || 200,
|
||
|
|
headers: options.headers || {},
|
||
|
|
cookies: options.cookies || [],
|
||
|
|
body
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
export function created(body, options = {}) {
|
||
|
|
return ok(body, { ...options, status: 201 });
|
||
|
|
}
|
||
|
|
|
||
|
|
export function noContent(options = {}) {
|
||
|
|
return {
|
||
|
|
status: 204,
|
||
|
|
headers: options.headers || {},
|
||
|
|
cookies: options.cookies || [],
|
||
|
|
body: undefined
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
export function fail(status, code, message, details) {
|
||
|
|
throw new ApiError(status, code, message, details);
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function readJson(request) {
|
||
|
|
const text = await request.text();
|
||
|
|
if (!text.trim()) return {};
|
||
|
|
try {
|
||
|
|
const value = JSON.parse(text);
|
||
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||
|
|
fail(400, 'invalid_body', 'Body must be a JSON object.');
|
||
|
|
}
|
||
|
|
return value;
|
||
|
|
} catch (error) {
|
||
|
|
if (error instanceof ApiError) throw error;
|
||
|
|
fail(400, 'invalid_json', 'Body must be valid JSON.');
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function readFormData(request) {
|
||
|
|
try {
|
||
|
|
return await request.formData();
|
||
|
|
} catch {
|
||
|
|
fail(
|
||
|
|
400,
|
||
|
|
'invalid_multipart',
|
||
|
|
'Multipart form-data body is invalid. When sending FormData from the browser, do not set Content-Type manually.'
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
export function text(value, fallback = '') {
|
||
|
|
return typeof value === 'string' ? value.trim() : fallback;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function optionalText(value, max = 0) {
|
||
|
|
const next = text(value);
|
||
|
|
if (!next) return '';
|
||
|
|
return max > 0 ? next.slice(0, max) : next;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function bool(value, fallback = false) {
|
||
|
|
return typeof value === 'boolean' ? value : fallback;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function int(value, fallback, { min = Number.MIN_SAFE_INTEGER, max = Number.MAX_SAFE_INTEGER } = {}) {
|
||
|
|
if (value == null || value === '') return fallback;
|
||
|
|
const next = typeof value === 'number' ? value : Number(value);
|
||
|
|
if (!Number.isInteger(next)) return fallback;
|
||
|
|
return Math.min(max, Math.max(min, next));
|
||
|
|
}
|
||
|
|
|
||
|
|
export function stringArray(value, maxItems = 50, maxLength = 80) {
|
||
|
|
if (!Array.isArray(value)) return [];
|
||
|
|
return value
|
||
|
|
.map((item) => text(item))
|
||
|
|
.filter(Boolean)
|
||
|
|
.slice(0, maxItems)
|
||
|
|
.map((item) => item.slice(0, maxLength));
|
||
|
|
}
|
||
|
|
|
||
|
|
export function requireString(value, name, max = 0) {
|
||
|
|
const next = optionalText(value, max);
|
||
|
|
if (!next) fail(400, 'missing_field', `${name} is required.`, { field: name });
|
||
|
|
return next;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function parseCookies(header) {
|
||
|
|
const cookies = new Map();
|
||
|
|
if (!header) return cookies;
|
||
|
|
for (const part of header.split(';')) {
|
||
|
|
const eq = part.indexOf('=');
|
||
|
|
if (eq === -1) continue;
|
||
|
|
const name = part.slice(0, eq).trim();
|
||
|
|
const value = part.slice(eq + 1).trim();
|
||
|
|
if (name) cookies.set(name, decodeURIComponent(value));
|
||
|
|
}
|
||
|
|
return cookies;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function sessionCookie(token) {
|
||
|
|
return serializeCookie(config.cookieName, token, {
|
||
|
|
httpOnly: true,
|
||
|
|
sameSite: 'Lax',
|
||
|
|
secure: config.secureCookies,
|
||
|
|
path: '/',
|
||
|
|
maxAge: 60 * 60 * 24 * 7
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
export function clearSessionCookie() {
|
||
|
|
return serializeCookie(config.cookieName, '', {
|
||
|
|
httpOnly: true,
|
||
|
|
sameSite: 'Lax',
|
||
|
|
secure: config.secureCookies,
|
||
|
|
path: '/',
|
||
|
|
maxAge: 0
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
export function getBearerToken(request) {
|
||
|
|
const authorization = request.headers.get('authorization') || '';
|
||
|
|
if (!authorization.toLowerCase().startsWith('bearer ')) return '';
|
||
|
|
return authorization.slice(7).trim();
|
||
|
|
}
|
||
|
|
|
||
|
|
export function getSessionToken(request) {
|
||
|
|
return getBearerToken(request) || parseCookies(request.headers.get('cookie')).get(config.cookieName) || '';
|
||
|
|
}
|
||
|
|
|
||
|
|
export function handleError(error) {
|
||
|
|
if (error instanceof ApiError) {
|
||
|
|
return ok(
|
||
|
|
{
|
||
|
|
ok: false,
|
||
|
|
error: {
|
||
|
|
code: error.code,
|
||
|
|
message: error.message,
|
||
|
|
details: error.details
|
||
|
|
}
|
||
|
|
},
|
||
|
|
{ status: error.status }
|
||
|
|
);
|
||
|
|
}
|
||
|
|
if (error?.name === 'PocketBaseError') {
|
||
|
|
return ok(
|
||
|
|
{
|
||
|
|
ok: false,
|
||
|
|
error: {
|
||
|
|
code: error.code || 'pocketbase_error',
|
||
|
|
message: error.message,
|
||
|
|
details: error.data
|
||
|
|
}
|
||
|
|
},
|
||
|
|
{ status: error.status || 502 }
|
||
|
|
);
|
||
|
|
}
|
||
|
|
console.error(error);
|
||
|
|
return ok(
|
||
|
|
{
|
||
|
|
ok: false,
|
||
|
|
error: {
|
||
|
|
code: 'internal_error',
|
||
|
|
message: 'Unexpected dating server error.'
|
||
|
|
}
|
||
|
|
},
|
||
|
|
{ status: 500 }
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
export function applyCors(request, response) {
|
||
|
|
const origin = request.headers.get('origin');
|
||
|
|
const headers = { ...response.headers };
|
||
|
|
if (origin && config.allowedOrigins.includes(origin)) {
|
||
|
|
headers['access-control-allow-origin'] = origin;
|
||
|
|
headers['access-control-allow-credentials'] = 'true';
|
||
|
|
headers['vary'] = appendVary(headers.vary, 'Origin');
|
||
|
|
}
|
||
|
|
return { ...response, headers };
|
||
|
|
}
|
||
|
|
|
||
|
|
export function corsPreflight(request) {
|
||
|
|
const origin = request.headers.get('origin');
|
||
|
|
const headers = {
|
||
|
|
'access-control-allow-methods': 'GET,POST,PUT,PATCH,DELETE,OPTIONS',
|
||
|
|
'access-control-allow-headers': 'content-type,authorization',
|
||
|
|
'access-control-max-age': '600'
|
||
|
|
};
|
||
|
|
if (origin && config.allowedOrigins.includes(origin)) {
|
||
|
|
headers['access-control-allow-origin'] = origin;
|
||
|
|
headers['access-control-allow-credentials'] = 'true';
|
||
|
|
headers.vary = 'Origin';
|
||
|
|
}
|
||
|
|
return noContent({ headers });
|
||
|
|
}
|
||
|
|
|
||
|
|
export function writeResponse(nodeResponse, response) {
|
||
|
|
for (const [name, value] of Object.entries(response.headers || {})) {
|
||
|
|
if (value != null) nodeResponse.setHeader(name, value);
|
||
|
|
}
|
||
|
|
if (response.cookies?.length) {
|
||
|
|
nodeResponse.setHeader('set-cookie', response.cookies);
|
||
|
|
}
|
||
|
|
if (response.body === undefined) {
|
||
|
|
nodeResponse.writeHead(response.status);
|
||
|
|
nodeResponse.end();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const body = JSON.stringify(response.body);
|
||
|
|
nodeResponse.setHeader('content-type', 'application/json; charset=utf-8');
|
||
|
|
nodeResponse.setHeader('content-length', Buffer.byteLength(body));
|
||
|
|
nodeResponse.writeHead(response.status);
|
||
|
|
nodeResponse.end(body);
|
||
|
|
}
|
||
|
|
|
||
|
|
function serializeCookie(name, value, options) {
|
||
|
|
const parts = [`${name}=${encodeURIComponent(value)}`];
|
||
|
|
if (options.maxAge != null) parts.push(`Max-Age=${options.maxAge}`);
|
||
|
|
if (options.path) parts.push(`Path=${options.path}`);
|
||
|
|
if (options.httpOnly) parts.push('HttpOnly');
|
||
|
|
if (options.sameSite) parts.push(`SameSite=${options.sameSite}`);
|
||
|
|
if (options.secure) parts.push('Secure');
|
||
|
|
return parts.join('; ');
|
||
|
|
}
|
||
|
|
|
||
|
|
function appendVary(value, item) {
|
||
|
|
if (!value) return item;
|
||
|
|
const parts = value.split(',').map((part) => part.trim().toLowerCase());
|
||
|
|
return parts.includes(item.toLowerCase()) ? value : `${value}, ${item}`;
|
||
|
|
}
|