Compare commits

...

2 Commits

Author SHA1 Message Date
Olivier 'reivilibre
92b00934a2 Replace config: use Valibot and allow multiple forges of one type
Signed-off-by: Olivier 'reivilibre <git.contact@librepush.net>
2025-09-30 22:44:57 +01:00
Olivier 'reivilibre
bff741c7df Disable 'noNonNullAssertion' lint
Signed-off-by: Olivier 'reivilibre <git.contact@librepush.net>
2025-09-30 22:44:57 +01:00
17 changed files with 238 additions and 170 deletions

View File

@ -8,9 +8,10 @@ DB_PASSWORD=
DB_HOST=localhost
DB_NAME=database
GITEA_URL=http://localhost:3001
GITEA_CLIENT_ID=
GITEA_CLIENT_SECRET=
SESSION_SECRET=your_session_secret_here
GITEA_REDIRECT_URI=http://localhost:3000/auth/callback
FORGE_TYPES=gitea
FORGES_1_TYPE=gitea
FORGES_1_URL=http://localhost:3001
FORGES_1_CLIENTID=
FORGES_1_CLIENTSECRET=
FORGES_1_REDIRECTURI=http://localhost:3002/auth/callback

View File

@ -16,7 +16,10 @@
"enabled": true,
"includes": ["src/**", "!src/types/openapi.d.ts"],
"rules": {
"recommended": true
"recommended": true,
"style": {
"noNonNullAssertion": "off"
}
}
},
"javascript": {

View File

@ -13,9 +13,9 @@ export async function listAvailableRepos(
): Promise<ExpressResponse> {
try {
const userId = req.session?.userId;
const forgeType = req.session?.forgeType;
const forgeId = req.session?.forgeId;
if (!userId || !forgeType) {
if (!userId || forgeId === undefined) {
return res.status(401).json({ error: "Not authenticated" });
}
@ -24,7 +24,7 @@ export async function listAvailableRepos(
return res.status(401).json({ error: "Missing or expired access token" });
}
const forge = createForge(forgeType);
const forge = createForge(forgeId);
const repos = await forge.listRepositories(accessToken);
return res.json(repos);

View File

@ -1,20 +1,14 @@
import { Pool } from "pg";
import { config } from "./env";
import { config } from "./index";
let pool: Pool;
const { DB_USER, DB_PASSWORD, DB_NAME } = config;
if (!DB_USER || !DB_PASSWORD || !DB_NAME) {
throw new Error("DB_USER, DB_PASSWORD, and DB_NAME must be set");
}
pool = new Pool({
user: config.DB_USER,
host: config.DB_HOST,
database: config.DB_NAME,
password: config.DB_PASSWORD,
port: config.DB_PORT,
user: config.db.user,
host: config.db.host,
database: config.db.name,
password: config.db.password,
port: config.db.port,
});
const connectDB = async () => {

View File

@ -1,55 +0,0 @@
type EnvMode = "development" | "production" | "test";
interface ProcessEnv {
NODE_ENV?: EnvMode;
APP_PORT?: string;
DB_USER: string;
DB_PASSWORD: string;
DB_HOST?: string;
DB_NAME: string;
DB_PORT?: string;
GITEA_URL?: string;
GITEA_CLIENT_ID?: string;
GITEA_CLIENT_SECRET?: string;
GITEA_REDIRECT_URI?: string;
FORGE_TYPES?: string; // comma-separated list of enabled forges
SESSION_SECRET: string;
}
const env = process.env as unknown as ProcessEnv;
function requiredEnv(key: keyof ProcessEnv): string {
const value = env[key];
if (!value) {
throw new Error(`Missing required environment variable: ${key}`);
}
return value;
}
export const config = {
NODE_ENV: env.NODE_ENV ?? "development",
APP_PORT: env.APP_PORT ? Number(env.APP_PORT) : 3000,
DB_HOST: env.DB_HOST ?? "localhost",
DB_NAME: requiredEnv("DB_NAME"),
DB_USER: requiredEnv("DB_USER"),
DB_PASSWORD: requiredEnv("DB_PASSWORD"),
DB_PORT: env.DB_PORT ? Number(env.DB_PORT) : 5432,
GITEA_URL: env.GITEA_URL ?? "http://localhost:3001",
GITEA_CLIENT_ID: env.GITEA_CLIENT_ID,
GITEA_CLIENT_SECRET: env.GITEA_CLIENT_SECRET,
GITEA_REDIRECT_URI:
env.GITEA_REDIRECT_URI ?? "http://localhost:3000/auth/callback",
FORGE_TYPES: (env.FORGE_TYPES ?? "gitea")
.split(",")
.map((f) => f.trim().toLowerCase()),
SESSION_SECRET: requiredEnv("SESSION_SECRET"),
IS_PRODUCTION: env.NODE_ENV === "production",
};
export const isProduction = config.NODE_ENV === "production";

View File

@ -0,0 +1,22 @@
import * as v from "valibot";
import { CONFIG_SCHEMA_KEYS, type Config, ConfigSchema } from "./schema";
import { transformEnvToConfig } from "./transformer";
function createConfig(): Config {
const rawConfig = transformEnvToConfig(
process.env as Record<string, string>,
CONFIG_SCHEMA_KEYS,
);
const result = v.safeParse(ConfigSchema, rawConfig);
if (!result.success) {
const issueStrings = result.issues.map(
(issue) => `- ${v.getDotPath(issue)}: ${issue.message}`,
);
throw new Error(`Invalid configuration:\n${issueStrings.join("\n")}`);
}
return result.output;
}
export const config = createConfig();

View File

@ -0,0 +1,76 @@
import * as v from "valibot";
const ForgeInstanceSchema = v.object({
type: v.picklist(["gitea", "github", "gitlab"]),
url: v.string(),
clientid: v.string(),
clientsecret: v.string(),
redirecturi: v.string(),
});
// Convert `{"1": {...}, "2": {...}}` to a `Map<number, { ... }>`
const ForgesSchema = v.pipe(
v.record(v.pipe(v.string(), v.digits()), ForgeInstanceSchema),
v.transform(
(obj) => new Map(Object.entries(obj).map(([k, v]) => [Number(k), v])),
),
);
// All these database configuration fields are optional;
// the Postgres client driver has defaults.
const DatabaseConfigSchema = v.object({
host: v.optional(v.string()),
port: v.optional(
v.pipe(v.string(), v.digits(), v.transform(Number), v.integer()),
),
name: v.optional(v.string()),
user: v.optional(v.string()),
password: v.optional(v.string()),
});
const AppConfigSchema = v.object({
port: v.pipe(
v.optional(v.string(), "3000"),
v.transform(Number),
v.integer(),
),
});
const SessionConfigSchema = v.object({
secret: v.string(),
});
export const ConfigSchema = v.object({
app: optionalObject(AppConfigSchema),
db: optionalObject(DatabaseConfigSchema),
forges: v.optional(ForgesSchema, {}),
session: SessionConfigSchema,
node: v.object({
env: v.picklist(["production", "development", "test"]),
}),
});
// Keys included in the config that should be parsed
// from environment variables.
export const CONFIG_SCHEMA_KEYS = [
"app",
"db",
"forges",
"session",
"node",
] satisfies (keyof Config)[];
export type Config = v.InferOutput<typeof ConfigSchema>;
export type DatabaseConfig = v.InferOutput<typeof DatabaseConfigSchema>;
export type ForgesConfig = v.InferOutput<typeof ForgesSchema>;
export type ForgeInstanceConfig = v.InferOutput<typeof ForgeInstanceSchema>;
// Wrap a `v.object` schema so that if the object isn't supplied,
// it gets filled in with defaults.
function optionalObject<
TEntries extends v.ObjectEntries,
TMessage extends v.ErrorMessage<v.ObjectIssue> | undefined,
>(sch: v.ObjectSchema<TEntries, TMessage>) {
const defaultObject = v.getDefaults(sch);
return v.optional(sch, defaultObject!);
}

View File

@ -0,0 +1,47 @@
/**
* Transforms environment variables into a hierarchical (nested) configuration object.
* Environment variables are expected to have underscore-delimited keys.
* Keys are lowercased.
*
* Only variables with root keys included in the allowedKeys array are processed.
*
* For example, the environment variable `DATABASE_HOST=localhost` would be
* transformed into `{ database: { host: "localhost" } }`
* as long as "database" is in the allowedKeys.
*/
export function transformEnvToConfig<Keys extends string[]>(
env: Record<string, string>,
allowedKeys: Keys,
): Record<(typeof allowedKeys)[number], unknown> {
const config: Record<string, unknown> = {};
for (const [keyPath, value] of Object.entries(env)) {
const keyPathParts = keyPath.toLowerCase().split("_");
if (!allowedKeys.includes(keyPathParts[0]!) || keyPathParts.length < 2) {
// Not an env var we care about.
continue;
}
// Start at the top-level config map, then
// descend into the deeper layers
// (following keyPath)
let current: Record<string, unknown> = config;
for (const part of keyPathParts.slice(0, -1)) {
if (!(part in current)) {
current[part] = {};
}
if (typeof current[part] !== "object") {
throw new Error(
`Mixed config types: can't descend into ${part} on ${keyPath}, have: ${current[part]}`,
);
}
current = current[part] as Record<string, unknown>;
}
// then set the value.
const lastPart = keyPathParts[keyPathParts.length - 1]!;
current[lastPart] = value;
}
return config;
}

View File

@ -1,16 +1,7 @@
import { pool } from "../../config/db";
import { FORGE_IDS } from "../../services/forges/constants";
import type { User } from "../models/user";
class UserRepository {
private resolveForgeId(forgeType: string): number {
const forgeId = FORGE_IDS[forgeType];
if (forgeId === undefined) {
throw new Error(`Unsupported forge type: ${forgeType}`);
}
return forgeId;
}
/**
* Find a user by internal Molci ID
*/
@ -52,13 +43,11 @@ class UserRepository {
}
async getOrCreateUser(
forgeType: string,
forgeId: number,
forgeUserId: string,
access_token?: string,
token_expires_at?: Date,
): Promise<User> {
const forgeId = this.resolveForgeId(forgeType);
const result = await pool.query(
`INSERT INTO users (forge_id, forge_user_id, access_token, token_expires_at)
VALUES ($1, $2, $3, $4)

View File

@ -1,36 +1,42 @@
/** biome-ignore-all lint/complexity/useLiteralKeys: <oli said it was ok> */
import { type Request, type Response, Router } from "express";
import { config } from "../config/env";
import { createForge, listAvailableForges } from "../services/forges";
import { config } from "../config";
import { createForge, listAvailableForgeIds } from "../services/forges";
import { getOrCreateUser } from "../services/user";
const authRouter = Router();
authRouter.get("/providers", (_req, res) => {
res.json({ providers: listAvailableForges() });
res.json({ providers: listAvailableForgeIds() });
});
authRouter.get("/login/:forgeType", (req: Request, res: Response) => {
const forgeType = req.params["forgeType"];
if (!forgeType) {
res.status(400).json({ error: "Missing forgeType" });
authRouter.get("/login/:forgeId", (req: Request, res: Response) => {
const forgeIdParam = req.params["forgeId"];
if (!forgeIdParam) {
res.status(400).json({ error: "Missing forgeId" });
return;
}
const forgeId = parseInt(forgeIdParam, 10);
if (Number.isNaN(forgeId)) {
res.status(400).json({ error: "Invalid forgeId" });
return;
}
const isProduction = config.node.env === "production";
try {
const forge = createForge(forgeType);
const forge = createForge(forgeId);
const authData = forge.getAuthorizationUrl();
req.session.codeVerifier = authData.codeVerifier;
req.session.forgeType = forgeType;
req.session.forgeId = forgeId;
const cookieName = config.IS_PRODUCTION
? "__Host-oauth_state"
: "oauth_state";
const cookieName = isProduction ? "__Host-oauth_state" : "oauth_state";
// Store OAuth state in a cookie
res.cookie(cookieName, authData.state, {
secure: config.IS_PRODUCTION,
secure: isProduction,
path: "/",
httpOnly: true,
maxAge: 10 * 60 * 1000, // 10 minutes
@ -45,18 +51,17 @@ authRouter.get("/login/:forgeType", (req: Request, res: Response) => {
});
authRouter.get("/callback", async (req: Request, res: Response) => {
const forgeType = req.session.forgeType;
const forgeId = req.session.forgeId;
const codeVerifier = req.session.codeVerifier;
const state = req.query["state"] as string;
const code = req.query["code"] as string;
const isProduction = config.node.env === "production";
const cookies = parseCookies(req);
const cookieName = config.IS_PRODUCTION
? "__Host-oauth_state"
: "oauth_state";
const cookieName = isProduction ? "__Host-oauth_state" : "oauth_state";
const stateFromCookie = cookies[cookieName];
if (!forgeType || !state || !code || !codeVerifier) {
if (forgeId === undefined || !state || !code || !codeVerifier) {
res.status(400).json({ error: "Missing OAuth callback parameters" });
return;
}
@ -67,21 +72,21 @@ authRouter.get("/callback", async (req: Request, res: Response) => {
}
try {
const forge = createForge(forgeType);
const forge = createForge(forgeId);
const { accessToken, accessTokenExpiresAt } =
await forge.exchangeCodeForToken(code, codeVerifier);
const forgeUser = await forge.getUserInfo(accessToken);
const internalUser = await getOrCreateUser(
forgeType,
forgeId,
forgeUser.id.toString(),
accessToken,
accessTokenExpiresAt,
);
req.session.userId = internalUser.user_id;
req.session.forgeType = forgeType;
req.session.forgeId = forgeId;
// Clear OAuth session data
delete req.session.codeVerifier;
@ -110,9 +115,8 @@ authRouter.post("/logout", (req: Request, res: Response) => {
return;
}
res.clearCookie("__Host-SessionID", { path: "/" });
const cookieName = config.IS_PRODUCTION
? "__Host-oauth_state"
: "oauth_state";
const isProduction = config.node.env === "production";
const cookieName = isProduction ? "__Host-oauth_state" : "oauth_state";
res.clearCookie(cookieName, { path: "/" });
res.json({ success: true });
});

View File

@ -1,8 +1,8 @@
import express from "express";
import session from "express-session";
import { createOpenAPIBackend, createOpenAPIMiddleware } from "./api/openapi";
import { config } from "./config";
import { connectDB } from "./config/db";
import { config, isProduction } from "./config/env";
import authRouter from "./routes/auth";
import { seedForges } from "./util/seedforges";
@ -13,18 +13,17 @@ async function startServer() {
const app = express();
app.use(express.json());
const sessionCookieName = config.IS_PRODUCTION
? "__Host-SessionID"
: "sessionID";
const isProduction = config.node.env === "production";
const sessionCookieName = isProduction ? "__Host-SessionID" : "sessionID";
app.use(
session({
name: sessionCookieName,
secret: config.SESSION_SECRET,
secret: config.session.secret,
resave: false,
saveUninitialized: false,
cookie: {
secure: config.IS_PRODUCTION, // false in dev
secure: isProduction, // false in dev
httpOnly: true,
sameSite: "lax",
maxAge: 24 * 60 * 60 * 1000,
@ -37,7 +36,7 @@ async function startServer() {
const openapi = createOpenAPIBackend();
app.use(createOpenAPIMiddleware(openapi));
const PORT = config.APP_PORT;
const PORT = config.app.port;
app.listen(PORT, () => {
console.log(
`Server running in ${isProduction ? "production" : "development"} mode on port ${PORT}`,

View File

@ -1,5 +0,0 @@
export const FORGE_IDS: Record<string, number> = {
gitea: 0,
github: 1,
gitlab: 2,
};

View File

@ -1,26 +1,34 @@
import * as arctic from "arctic";
import { config } from "../../config/env";
import type { ForgeInstanceConfig } from "../../config/schema";
import type { Forge } from "../../types/forge";
import type { ForgeUser } from "../../types/forgeuser";
import type { GiteaRepo } from "../../types/gitearepo";
import type { Repository } from "../../types/openapi";
export class GiteaForge implements Forge {
private gitea: arctic.Gitea;
private forgeId: number;
private baseUrl: string;
constructor() {
if (!config.GITEA_CLIENT_ID || !config.GITEA_CLIENT_SECRET) {
constructor(forgeId: number, config: ForgeInstanceConfig) {
this.forgeId = forgeId;
this.baseUrl = config.url;
if (!config.clientid || !config.clientsecret) {
throw new Error("Gitea OAuth2 credentials not configured");
}
this.gitea = new arctic.Gitea(
config.GITEA_URL,
config.GITEA_CLIENT_ID,
config.GITEA_CLIENT_SECRET,
config.GITEA_REDIRECT_URI,
config.url,
config.clientid,
config.clientsecret,
config.redirecturi,
);
}
getForgeId(): number {
return this.forgeId;
}
getAuthorizationUrl() {
const state = arctic.generateState();
const codeVerifier = arctic.generateCodeVerifier();
@ -51,7 +59,7 @@ export class GiteaForge implements Forge {
}
async getUserInfo(accessToken: string): Promise<ForgeUser> {
const res = await fetch(`${config.GITEA_URL}/api/v1/user`, {
const res = await fetch(`${this.baseUrl}/api/v1/user`, {
headers: { Authorization: `token ${accessToken}` },
});
if (!res.ok) throw new Error(`Failed to fetch user: ${res.status}`);
@ -60,7 +68,7 @@ export class GiteaForge implements Forge {
}
async listRepositories(accessToken: string): Promise<Repository[]> {
const res = await fetch(`${config.GITEA_URL}/api/v1/user/repos`, {
const res = await fetch(`${this.baseUrl}/api/v1/user/repos`, {
headers: { Authorization: `token ${accessToken}` },
});
if (!res.ok) {

View File

@ -1,31 +1,33 @@
import { config } from "../../config/env";
import { config } from "../../config";
import type { Forge } from "../../types/forge";
import { GiteaForge } from "./gitea";
/**
* Returns a Forge implementation for the given forgeType.
* Returns a Forge implementation for the given forge ID.
* Throws an error if the forge is not enabled or not implemented.
*/
export function createForge(forgeType: string): Forge {
if (!config.FORGE_TYPES.includes(forgeType)) {
throw new Error(`Unsupported forge type: ${forgeType}`);
export function createForge(forgeId: number): Forge {
const forgeConfig = config.forges.get(forgeId);
if (!forgeConfig) {
throw new Error(`Forge with ID ${forgeId} not found`);
}
switch (forgeType) {
switch (forgeConfig.type) {
case "gitea":
return new GiteaForge();
return new GiteaForge(forgeId, forgeConfig);
case "github":
throw new Error("GitHubForge not implemented yet");
case "gitlab":
throw new Error("GitLabForge not implemented yet");
default:
throw new Error(`Unsupported forge type: ${forgeType}`);
throw new Error(`Unsupported forge type: ${forgeConfig.type}`);
}
}
/**
* Returns the list of available forge types from config.
* Returns the list of available forge IDs from config.
*/
export function listAvailableForges(): string[] {
return [...config.FORGE_TYPES];
export function listAvailableForgeIds(): number[] {
return Array.from(config.forges.keys());
}

View File

@ -27,13 +27,13 @@ export const insertUser = (
};
export const getOrCreateUser = (
forgeType: string,
forgeId: number,
forgeUserId: string,
access_token?: string,
token_expires_at?: Date,
): Promise<User> => {
return userRepository.getOrCreateUser(
forgeType,
forgeId,
forgeUserId,
access_token,
token_expires_at,

View File

@ -3,7 +3,7 @@ import "express-session";
declare module "express-session" {
interface SessionData {
userId?: number; // internal DB ID
forgeType?: string; // e.g., "gitea" or "github"
forgeId?: number; // numeric forge ID
codeVerifier?: string; // PKCE code verifier for OAuth
}
}

View File

@ -1,36 +1,19 @@
import { config } from "../config";
import { pool } from "../config/db";
import { config } from "../config/env";
import { FORGE_IDS } from "../services/forges/constants";
export async function seedForges(): Promise<void> {
for (const forgeType of config.FORGE_TYPES) {
const forgeId = FORGE_IDS[forgeType];
if (forgeId === undefined) {
console.warn(`Skipping unknown forge type: ${forgeType}`);
continue;
}
let baseUrl: string;
switch (forgeType) {
case "gitea":
baseUrl = config.GITEA_URL;
break;
default:
console.warn(`Forge type ${forgeType} not supported`);
continue;
}
for (const [forgeId, forgeConfig] of config.forges) {
try {
await pool.query(
`INSERT INTO forges (forge_id, display_name, base_url)
VALUES ($1, $2, $3)
ON CONFLICT (forge_id)
DO UPDATE SET base_url = EXCLUDED.base_url`,
[forgeId, forgeType, baseUrl],
[forgeId, forgeConfig.type, forgeConfig.url],
);
console.log(`Forge seeded: ${forgeType} (${baseUrl})`);
console.log(`Forge seeded: ${forgeConfig.type} (${forgeConfig.url})`);
} catch (err) {
console.error(`Failed to seed forge ${forgeType}:`, err);
console.error(`Failed to seed forge ${forgeConfig.type}:`, err);
throw err;
}
}