Configuration: use Valibot and allow multiple forges of one type #1
@ -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
|
||||
|
||||
@ -16,7 +16,10 @@
|
||||
"enabled": true,
|
||||
"includes": ["src/**", "!src/types/openapi.d.ts"],
|
||||
"rules": {
|
||||
"recommended": true
|
||||
"recommended": true,
|
||||
"style": {
|
||||
"noNonNullAssertion": "off"
|
||||
}
|
||||
}
|
||||
},
|
||||
"javascript": {
|
||||
|
||||
@ -36,6 +36,7 @@
|
||||
"express-session": "^1.18.0",
|
||||
"openapi-backend": "^5.15.0",
|
||||
"pg": "^8.16.3",
|
||||
"postgres-migrations": "^5.3.0"
|
||||
"postgres-migrations": "^5.3.0",
|
||||
"valibot": "^1.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
15
server/pnpm-lock.yaml
generated
15
server/pnpm-lock.yaml
generated
@ -29,6 +29,9 @@ importers:
|
||||
postgres-migrations:
|
||||
specifier: ^5.3.0
|
||||
version: 5.3.0
|
||||
valibot:
|
||||
specifier: ^1.1.0
|
||||
version: 1.1.0(typescript@5.9.2)
|
||||
devDependencies:
|
||||
'@biomejs/biome':
|
||||
specifier: 2.2.3
|
||||
@ -2063,6 +2066,14 @@ packages:
|
||||
resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==}
|
||||
engines: {node: '>=10.12.0'}
|
||||
|
||||
valibot@1.1.0:
|
||||
resolution: {integrity: sha512-Nk8lX30Qhu+9txPYTwM0cFlWLdPFsFr6LblzqIySfbZph9+BFsAHsNvHOymEviUepeIW6KFHzpX8TKhbptBXXw==}
|
||||
peerDependencies:
|
||||
typescript: '>=5'
|
||||
peerDependenciesMeta:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
vary@1.1.2:
|
||||
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@ -4348,6 +4359,10 @@ snapshots:
|
||||
'@types/istanbul-lib-coverage': 2.0.6
|
||||
convert-source-map: 2.0.0
|
||||
|
||||
valibot@1.1.0(typescript@5.9.2):
|
||||
optionalDependencies:
|
||||
typescript: 5.9.2
|
||||
|
||||
vary@1.1.2: {}
|
||||
|
||||
walker@1.0.8:
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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";
|
||||
22
server/src/config/index.ts
Normal file
22
server/src/config/index.ts
Normal 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();
|
||||
76
server/src/config/schema.ts
Normal file
76
server/src/config/schema.ts
Normal 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!);
|
||||
}
|
||||
47
server/src/config/transformer.ts
Normal file
47
server/src/config/transformer.ts
Normal 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;
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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 });
|
||||
});
|
||||
|
||||
@ -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}`,
|
||||
|
||||
@ -1,5 +0,0 @@
|
||||
export const FORGE_IDS: Record<string, number> = {
|
||||
gitea: 0,
|
||||
github: 1,
|
||||
gitlab: 2,
|
||||
};
|
||||
@ -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) {
|
||||
|
||||
@ -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());
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
2
server/src/types/express-session.d.ts
vendored
2
server/src/types/express-session.d.ts
vendored
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user
I guess this should include more information about each provider at some point, for example the name, icon and maybe URL, so that the frontend can let you choose between them.
Agreed.