Refactor content structure and add basic authentication utilities

- Moved event and gallery data to JSON files for cleaner content management.
- Added session management utilities with CSRF protection.
- Integrated OAuth-based login and logout APIs.
- Updated dependencies, including Astro and introduced dotenv-cli.
- Enhanced package.json with local environment support.
This commit is contained in:
2025-11-08 17:02:51 +01:00
parent 1f94bbca15
commit 761ab5d5b5
24 changed files with 1374 additions and 549 deletions

25
src/lib/csrf.ts Normal file
View File

@ -0,0 +1,25 @@
import crypto from 'node:crypto';
import { env } from './env';
export function createCsrfToken(): string {
const raw = crypto.randomBytes(16).toString('base64url');
const sig = sign(raw);
return `${raw}.${sig}`;
}
export function verifyCsrfToken(token: string | null | undefined): boolean {
if (!token) return false;
const [raw, sig] = token.split('.');
if (!raw || !sig) return false;
const expected = sign(raw);
try {
return crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
} catch {
return false;
}
}
function sign(input: string): string {
if (!env.CSRF_SECRET) throw new Error('CSRF_SECRET missing');
return crypto.createHmac('sha256', env.CSRF_SECRET).update(input).digest('hex');
}

29
src/lib/env.ts Normal file
View File

@ -0,0 +1,29 @@
export const env = {
OAUTH_PROVIDER: process.env.OAUTH_PROVIDER,
OAUTH_CLIENT_ID: process.env.OAUTH_CLIENT_ID,
OAUTH_CLIENT_SECRET: process.env.OAUTH_CLIENT_SECRET,
OAUTH_AUTHORIZE_URL: process.env.OAUTH_AUTHORIZE_URL,
OAUTH_TOKEN_URL: process.env.OAUTH_TOKEN_URL,
OAUTH_USERINFO_URL: process.env.OAUTH_USERINFO_URL,
OAUTH_ALLOWED_USERS: process.env.OAUTH_ALLOWED_USERS,
OAUTH_ALLOWED_ORG: process.env.OAUTH_ALLOWED_ORG,
PUBLIC_BASE_URL: process.env.PUBLIC_BASE_URL,
GITEA_BASE: process.env.GITEA_BASE,
GITEA_OWNER: process.env.GITEA_OWNER,
GITEA_REPO: process.env.GITEA_REPO,
GITEA_TOKEN: process.env.GITEA_TOKEN,
GIT_BRANCH: process.env.GIT_BRANCH || 'main',
SESSION_SECRET: process.env.SESSION_SECRET,
CSRF_SECRET: process.env.CSRF_SECRET,
};
export function getBaseUrlFromRequest(req: Request): string {
try {
if (env.PUBLIC_BASE_URL) return new URL(env.PUBLIC_BASE_URL).toString().replace(/\/$/, '');
} catch {}
const url = new URL(req.url);
url.pathname = '';
url.search = '';
url.hash = '';
return url.toString().replace(/\/$/, '');
}

87
src/lib/session.ts Normal file
View File

@ -0,0 +1,87 @@
import crypto from 'node:crypto';
import { env } from './env';
const SESSION_COOKIE = 'gp_session';
const STATE_COOKIE = 'oauth_state';
const CSRF_COOKIE = 'gp_csrf';
export type SessionData = {
user?: {
id: number;
username: string;
email?: string;
displayName?: string;
};
};
function hmac(value: string) {
if (!env.SESSION_SECRET) throw new Error('SESSION_SECRET missing');
return crypto.createHmac('sha256', env.SESSION_SECRET).update(value).digest('hex');
}
export function encodeSession(data: SessionData): string {
const json = JSON.stringify(data);
const b64 = Buffer.from(json, 'utf-8').toString('base64url');
const sig = hmac(b64);
return `${b64}.${sig}`;
}
export function decodeSession(token?: string | null): SessionData | null {
if (!token) return null;
const parts = token.split('.');
if (parts.length !== 2) return null;
const [b64, sig] = parts;
const expected = hmac(b64);
// timing-safe compare
if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) return null;
try {
return JSON.parse(Buffer.from(b64, 'base64url').toString('utf-8')) as SessionData;
} catch {
return null;
}
}
export function cookieAttrs({ httpOnly = true, maxAge, path = '/', sameSite = 'Lax', secure }: { httpOnly?: boolean; maxAge?: number; path?: string; sameSite?: 'Lax'|'Strict'|'None'; secure?: boolean } = {}) {
const attrs = [`Path=${path}`, `SameSite=${sameSite}`];
if (httpOnly) attrs.push('HttpOnly');
const isProd = process.env.NODE_ENV === 'production';
const useSecure = secure ?? isProd;
if (useSecure) attrs.push('Secure');
if (typeof maxAge === 'number') attrs.push(`Max-Age=${maxAge}`);
return attrs.join('; ');
}
export function setSessionCookie(data: SessionData, maxAgeDays = 7): string {
const token = encodeSession(data);
const maxAge = maxAgeDays * 24 * 60 * 60;
return `${SESSION_COOKIE}=${token}; ${cookieAttrs({ maxAge })}`;
}
export function clearSessionCookie(): string {
return `${SESSION_COOKIE}=; ${cookieAttrs({})}; Max-Age=0`;
}
export function getSessionFromRequest(req: Request): SessionData | null {
const cookie = parseCookies(req.headers.get('cookie'))[SESSION_COOKIE];
return decodeSession(cookie);
}
export function setTempCookie(name: string, value: string, maxAgeSeconds = 600): string {
return `${name}=${value}; ${cookieAttrs({ maxAge: maxAgeSeconds })}`;
}
export function clearCookie(name: string): string {
return `${name}=; ${cookieAttrs({})}; Max-Age=0`;
}
export function parseCookies(header: string | null | undefined): Record<string, string> {
const out: Record<string, string> = {};
if (!header) return out;
for (const part of header.split(';')) {
const [k, ...rest] = part.trim().split('=');
out[k] = decodeURIComponent(rest.join('='));
}
return out;
}
export { SESSION_COOKIE, STATE_COOKIE, CSRF_COOKIE };