Compare commits
2 Commits
698a6c7e19
...
92b00934a2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
92b00934a2 | ||
|
|
bff741c7df |
@ -8,9 +8,10 @@ DB_PASSWORD=
|
|||||||
DB_HOST=localhost
|
DB_HOST=localhost
|
||||||
DB_NAME=database
|
DB_NAME=database
|
||||||
|
|
||||||
GITEA_URL=http://localhost:3001
|
|
||||||
GITEA_CLIENT_ID=
|
|
||||||
GITEA_CLIENT_SECRET=
|
|
||||||
SESSION_SECRET=your_session_secret_here
|
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,
|
"enabled": true,
|
||||||
"includes": ["src/**", "!src/types/openapi.d.ts"],
|
"includes": ["src/**", "!src/types/openapi.d.ts"],
|
||||||
"rules": {
|
"rules": {
|
||||||
"recommended": true
|
"recommended": true,
|
||||||
|
"style": {
|
||||||
|
"noNonNullAssertion": "off"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"javascript": {
|
"javascript": {
|
||||||
|
|||||||
@ -13,9 +13,9 @@ export async function listAvailableRepos(
|
|||||||
): Promise<ExpressResponse> {
|
): Promise<ExpressResponse> {
|
||||||
try {
|
try {
|
||||||
const userId = req.session?.userId;
|
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" });
|
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" });
|
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);
|
const repos = await forge.listRepositories(accessToken);
|
||||||
|
|
||||||
return res.json(repos);
|
return res.json(repos);
|
||||||
|
|||||||
@ -1,20 +1,14 @@
|
|||||||
import { Pool } from "pg";
|
import { Pool } from "pg";
|
||||||
import { config } from "./env";
|
import { config } from "./index";
|
||||||
|
|
||||||
let pool: Pool;
|
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({
|
pool = new Pool({
|
||||||
user: config.DB_USER,
|
user: config.db.user,
|
||||||
host: config.DB_HOST,
|
host: config.db.host,
|
||||||
database: config.DB_NAME,
|
database: config.db.name,
|
||||||
password: config.DB_PASSWORD,
|
password: config.db.password,
|
||||||
port: config.DB_PORT,
|
port: config.db.port,
|
||||||
});
|
});
|
||||||
|
|
||||||
const connectDB = async () => {
|
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 { pool } from "../../config/db";
|
||||||
import { FORGE_IDS } from "../../services/forges/constants";
|
|
||||||
import type { User } from "../models/user";
|
import type { User } from "../models/user";
|
||||||
|
|
||||||
class UserRepository {
|
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
|
* Find a user by internal Molci ID
|
||||||
*/
|
*/
|
||||||
@ -52,13 +43,11 @@ class UserRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getOrCreateUser(
|
async getOrCreateUser(
|
||||||
forgeType: string,
|
forgeId: number,
|
||||||
forgeUserId: string,
|
forgeUserId: string,
|
||||||
access_token?: string,
|
access_token?: string,
|
||||||
token_expires_at?: Date,
|
token_expires_at?: Date,
|
||||||
): Promise<User> {
|
): Promise<User> {
|
||||||
const forgeId = this.resolveForgeId(forgeType);
|
|
||||||
|
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`INSERT INTO users (forge_id, forge_user_id, access_token, token_expires_at)
|
`INSERT INTO users (forge_id, forge_user_id, access_token, token_expires_at)
|
||||||
VALUES ($1, $2, $3, $4)
|
VALUES ($1, $2, $3, $4)
|
||||||
|
|||||||
@ -1,36 +1,42 @@
|
|||||||
/** biome-ignore-all lint/complexity/useLiteralKeys: <oli said it was ok> */
|
/** biome-ignore-all lint/complexity/useLiteralKeys: <oli said it was ok> */
|
||||||
import { type Request, type Response, Router } from "express";
|
import { type Request, type Response, Router } from "express";
|
||||||
import { config } from "../config/env";
|
import { config } from "../config";
|
||||||
import { createForge, listAvailableForges } from "../services/forges";
|
import { createForge, listAvailableForgeIds } from "../services/forges";
|
||||||
import { getOrCreateUser } from "../services/user";
|
import { getOrCreateUser } from "../services/user";
|
||||||
|
|
||||||
const authRouter = Router();
|
const authRouter = Router();
|
||||||
|
|
||||||
authRouter.get("/providers", (_req, res) => {
|
authRouter.get("/providers", (_req, res) => {
|
||||||
res.json({ providers: listAvailableForges() });
|
res.json({ providers: listAvailableForgeIds() });
|
||||||
});
|
});
|
||||||
|
|
||||||
authRouter.get("/login/:forgeType", (req: Request, res: Response) => {
|
authRouter.get("/login/:forgeId", (req: Request, res: Response) => {
|
||||||
const forgeType = req.params["forgeType"];
|
const forgeIdParam = req.params["forgeId"];
|
||||||
if (!forgeType) {
|
if (!forgeIdParam) {
|
||||||
res.status(400).json({ error: "Missing forgeType" });
|
res.status(400).json({ error: "Missing forgeId" });
|
||||||
return;
|
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 {
|
try {
|
||||||
const forge = createForge(forgeType);
|
const forge = createForge(forgeId);
|
||||||
const authData = forge.getAuthorizationUrl();
|
const authData = forge.getAuthorizationUrl();
|
||||||
|
|
||||||
req.session.codeVerifier = authData.codeVerifier;
|
req.session.codeVerifier = authData.codeVerifier;
|
||||||
req.session.forgeType = forgeType;
|
req.session.forgeId = forgeId;
|
||||||
|
|
||||||
const cookieName = config.IS_PRODUCTION
|
const cookieName = isProduction ? "__Host-oauth_state" : "oauth_state";
|
||||||
? "__Host-oauth_state"
|
|
||||||
: "oauth_state";
|
|
||||||
|
|
||||||
// Store OAuth state in a cookie
|
// Store OAuth state in a cookie
|
||||||
res.cookie(cookieName, authData.state, {
|
res.cookie(cookieName, authData.state, {
|
||||||
secure: config.IS_PRODUCTION,
|
secure: isProduction,
|
||||||
path: "/",
|
path: "/",
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
maxAge: 10 * 60 * 1000, // 10 minutes
|
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) => {
|
authRouter.get("/callback", async (req: Request, res: Response) => {
|
||||||
const forgeType = req.session.forgeType;
|
const forgeId = req.session.forgeId;
|
||||||
const codeVerifier = req.session.codeVerifier;
|
const codeVerifier = req.session.codeVerifier;
|
||||||
const state = req.query["state"] as string;
|
const state = req.query["state"] as string;
|
||||||
const code = req.query["code"] as string;
|
const code = req.query["code"] as string;
|
||||||
|
|
||||||
|
const isProduction = config.node.env === "production";
|
||||||
const cookies = parseCookies(req);
|
const cookies = parseCookies(req);
|
||||||
const cookieName = config.IS_PRODUCTION
|
const cookieName = isProduction ? "__Host-oauth_state" : "oauth_state";
|
||||||
? "__Host-oauth_state"
|
|
||||||
: "oauth_state";
|
|
||||||
const stateFromCookie = cookies[cookieName];
|
const stateFromCookie = cookies[cookieName];
|
||||||
|
|
||||||
if (!forgeType || !state || !code || !codeVerifier) {
|
if (forgeId === undefined || !state || !code || !codeVerifier) {
|
||||||
res.status(400).json({ error: "Missing OAuth callback parameters" });
|
res.status(400).json({ error: "Missing OAuth callback parameters" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -67,21 +72,21 @@ authRouter.get("/callback", async (req: Request, res: Response) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const forge = createForge(forgeType);
|
const forge = createForge(forgeId);
|
||||||
const { accessToken, accessTokenExpiresAt } =
|
const { accessToken, accessTokenExpiresAt } =
|
||||||
await forge.exchangeCodeForToken(code, codeVerifier);
|
await forge.exchangeCodeForToken(code, codeVerifier);
|
||||||
|
|
||||||
const forgeUser = await forge.getUserInfo(accessToken);
|
const forgeUser = await forge.getUserInfo(accessToken);
|
||||||
|
|
||||||
const internalUser = await getOrCreateUser(
|
const internalUser = await getOrCreateUser(
|
||||||
forgeType,
|
forgeId,
|
||||||
forgeUser.id.toString(),
|
forgeUser.id.toString(),
|
||||||
accessToken,
|
accessToken,
|
||||||
accessTokenExpiresAt,
|
accessTokenExpiresAt,
|
||||||
);
|
);
|
||||||
|
|
||||||
req.session.userId = internalUser.user_id;
|
req.session.userId = internalUser.user_id;
|
||||||
req.session.forgeType = forgeType;
|
req.session.forgeId = forgeId;
|
||||||
|
|
||||||
// Clear OAuth session data
|
// Clear OAuth session data
|
||||||
delete req.session.codeVerifier;
|
delete req.session.codeVerifier;
|
||||||
@ -110,9 +115,8 @@ authRouter.post("/logout", (req: Request, res: Response) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
res.clearCookie("__Host-SessionID", { path: "/" });
|
res.clearCookie("__Host-SessionID", { path: "/" });
|
||||||
const cookieName = config.IS_PRODUCTION
|
const isProduction = config.node.env === "production";
|
||||||
? "__Host-oauth_state"
|
const cookieName = isProduction ? "__Host-oauth_state" : "oauth_state";
|
||||||
: "oauth_state";
|
|
||||||
res.clearCookie(cookieName, { path: "/" });
|
res.clearCookie(cookieName, { path: "/" });
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
import express from "express";
|
import express from "express";
|
||||||
import session from "express-session";
|
import session from "express-session";
|
||||||
import { createOpenAPIBackend, createOpenAPIMiddleware } from "./api/openapi";
|
import { createOpenAPIBackend, createOpenAPIMiddleware } from "./api/openapi";
|
||||||
|
import { config } from "./config";
|
||||||
import { connectDB } from "./config/db";
|
import { connectDB } from "./config/db";
|
||||||
import { config, isProduction } from "./config/env";
|
|
||||||
import authRouter from "./routes/auth";
|
import authRouter from "./routes/auth";
|
||||||
import { seedForges } from "./util/seedforges";
|
import { seedForges } from "./util/seedforges";
|
||||||
|
|
||||||
@ -13,18 +13,17 @@ async function startServer() {
|
|||||||
const app = express();
|
const app = express();
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
|
||||||
const sessionCookieName = config.IS_PRODUCTION
|
const isProduction = config.node.env === "production";
|
||||||
? "__Host-SessionID"
|
const sessionCookieName = isProduction ? "__Host-SessionID" : "sessionID";
|
||||||
: "sessionID";
|
|
||||||
|
|
||||||
app.use(
|
app.use(
|
||||||
session({
|
session({
|
||||||
name: sessionCookieName,
|
name: sessionCookieName,
|
||||||
secret: config.SESSION_SECRET,
|
secret: config.session.secret,
|
||||||
resave: false,
|
resave: false,
|
||||||
saveUninitialized: false,
|
saveUninitialized: false,
|
||||||
cookie: {
|
cookie: {
|
||||||
secure: config.IS_PRODUCTION, // false in dev
|
secure: isProduction, // false in dev
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
sameSite: "lax",
|
sameSite: "lax",
|
||||||
maxAge: 24 * 60 * 60 * 1000,
|
maxAge: 24 * 60 * 60 * 1000,
|
||||||
@ -37,7 +36,7 @@ async function startServer() {
|
|||||||
const openapi = createOpenAPIBackend();
|
const openapi = createOpenAPIBackend();
|
||||||
app.use(createOpenAPIMiddleware(openapi));
|
app.use(createOpenAPIMiddleware(openapi));
|
||||||
|
|
||||||
const PORT = config.APP_PORT;
|
const PORT = config.app.port;
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
console.log(
|
console.log(
|
||||||
`Server running in ${isProduction ? "production" : "development"} mode on port ${PORT}`,
|
`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 * as arctic from "arctic";
|
||||||
import { config } from "../../config/env";
|
import type { ForgeInstanceConfig } from "../../config/schema";
|
||||||
import type { Forge } from "../../types/forge";
|
import type { Forge } from "../../types/forge";
|
||||||
import type { ForgeUser } from "../../types/forgeuser";
|
import type { ForgeUser } from "../../types/forgeuser";
|
||||||
import type { GiteaRepo } from "../../types/gitearepo";
|
import type { GiteaRepo } from "../../types/gitearepo";
|
||||||
import type { Repository } from "../../types/openapi";
|
import type { Repository } from "../../types/openapi";
|
||||||
|
|
||||||
export class GiteaForge implements Forge {
|
export class GiteaForge implements Forge {
|
||||||
private gitea: arctic.Gitea;
|
private gitea: arctic.Gitea;
|
||||||
|
private forgeId: number;
|
||||||
|
private baseUrl: string;
|
||||||
|
|
||||||
constructor() {
|
constructor(forgeId: number, config: ForgeInstanceConfig) {
|
||||||
if (!config.GITEA_CLIENT_ID || !config.GITEA_CLIENT_SECRET) {
|
this.forgeId = forgeId;
|
||||||
|
this.baseUrl = config.url;
|
||||||
|
|
||||||
|
if (!config.clientid || !config.clientsecret) {
|
||||||
throw new Error("Gitea OAuth2 credentials not configured");
|
throw new Error("Gitea OAuth2 credentials not configured");
|
||||||
}
|
}
|
||||||
|
|
||||||
this.gitea = new arctic.Gitea(
|
this.gitea = new arctic.Gitea(
|
||||||
config.GITEA_URL,
|
config.url,
|
||||||
config.GITEA_CLIENT_ID,
|
config.clientid,
|
||||||
config.GITEA_CLIENT_SECRET,
|
config.clientsecret,
|
||||||
config.GITEA_REDIRECT_URI,
|
config.redirecturi,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getForgeId(): number {
|
||||||
|
return this.forgeId;
|
||||||
|
}
|
||||||
|
|
||||||
getAuthorizationUrl() {
|
getAuthorizationUrl() {
|
||||||
const state = arctic.generateState();
|
const state = arctic.generateState();
|
||||||
const codeVerifier = arctic.generateCodeVerifier();
|
const codeVerifier = arctic.generateCodeVerifier();
|
||||||
@ -51,7 +59,7 @@ export class GiteaForge implements Forge {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getUserInfo(accessToken: string): Promise<ForgeUser> {
|
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}` },
|
headers: { Authorization: `token ${accessToken}` },
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(`Failed to fetch user: ${res.status}`);
|
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[]> {
|
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}` },
|
headers: { Authorization: `token ${accessToken}` },
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
|||||||
@ -1,31 +1,33 @@
|
|||||||
import { config } from "../../config/env";
|
import { config } from "../../config";
|
||||||
import type { Forge } from "../../types/forge";
|
import type { Forge } from "../../types/forge";
|
||||||
import { GiteaForge } from "./gitea";
|
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.
|
* Throws an error if the forge is not enabled or not implemented.
|
||||||
*/
|
*/
|
||||||
export function createForge(forgeType: string): Forge {
|
export function createForge(forgeId: number): Forge {
|
||||||
if (!config.FORGE_TYPES.includes(forgeType)) {
|
const forgeConfig = config.forges.get(forgeId);
|
||||||
throw new Error(`Unsupported forge type: ${forgeType}`);
|
|
||||||
|
if (!forgeConfig) {
|
||||||
|
throw new Error(`Forge with ID ${forgeId} not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (forgeType) {
|
switch (forgeConfig.type) {
|
||||||
case "gitea":
|
case "gitea":
|
||||||
return new GiteaForge();
|
return new GiteaForge(forgeId, forgeConfig);
|
||||||
case "github":
|
case "github":
|
||||||
throw new Error("GitHubForge not implemented yet");
|
throw new Error("GitHubForge not implemented yet");
|
||||||
case "gitlab":
|
case "gitlab":
|
||||||
throw new Error("GitLabForge not implemented yet");
|
throw new Error("GitLabForge not implemented yet");
|
||||||
default:
|
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[] {
|
export function listAvailableForgeIds(): number[] {
|
||||||
return [...config.FORGE_TYPES];
|
return Array.from(config.forges.keys());
|
||||||
}
|
}
|
||||||
|
|||||||
@ -27,13 +27,13 @@ export const insertUser = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const getOrCreateUser = (
|
export const getOrCreateUser = (
|
||||||
forgeType: string,
|
forgeId: number,
|
||||||
forgeUserId: string,
|
forgeUserId: string,
|
||||||
access_token?: string,
|
access_token?: string,
|
||||||
token_expires_at?: Date,
|
token_expires_at?: Date,
|
||||||
): Promise<User> => {
|
): Promise<User> => {
|
||||||
return userRepository.getOrCreateUser(
|
return userRepository.getOrCreateUser(
|
||||||
forgeType,
|
forgeId,
|
||||||
forgeUserId,
|
forgeUserId,
|
||||||
access_token,
|
access_token,
|
||||||
token_expires_at,
|
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" {
|
declare module "express-session" {
|
||||||
interface SessionData {
|
interface SessionData {
|
||||||
userId?: number; // internal DB ID
|
userId?: number; // internal DB ID
|
||||||
forgeType?: string; // e.g., "gitea" or "github"
|
forgeId?: number; // numeric forge ID
|
||||||
codeVerifier?: string; // PKCE code verifier for OAuth
|
codeVerifier?: string; // PKCE code verifier for OAuth
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,36 +1,19 @@
|
|||||||
|
import { config } from "../config";
|
||||||
import { pool } from "../config/db";
|
import { pool } from "../config/db";
|
||||||
import { config } from "../config/env";
|
|
||||||
import { FORGE_IDS } from "../services/forges/constants";
|
|
||||||
|
|
||||||
export async function seedForges(): Promise<void> {
|
export async function seedForges(): Promise<void> {
|
||||||
for (const forgeType of config.FORGE_TYPES) {
|
for (const [forgeId, forgeConfig] of config.forges) {
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await pool.query(
|
await pool.query(
|
||||||
`INSERT INTO forges (forge_id, display_name, base_url)
|
`INSERT INTO forges (forge_id, display_name, base_url)
|
||||||
VALUES ($1, $2, $3)
|
VALUES ($1, $2, $3)
|
||||||
ON CONFLICT (forge_id)
|
ON CONFLICT (forge_id)
|
||||||
DO UPDATE SET base_url = EXCLUDED.base_url`,
|
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) {
|
} catch (err) {
|
||||||
console.error(`Failed to seed forge ${forgeType}:`, err);
|
console.error(`Failed to seed forge ${forgeConfig.type}:`, err);
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user