initial project setup

Fastify + Prisma backend, React + Vite frontend, Docker deployment.
Multi-board feedback platform with anonymous cookie auth, passkey
upgrade path, ALTCHA spam protection, plugin system, and full
privacy-first architecture.
This commit is contained in:
2026-03-19 18:05:16 +02:00
commit f07eddf29e
77 changed files with 7031 additions and 0 deletions

143
plugins/gitea/src/index.ts Normal file
View File

@@ -0,0 +1,143 @@
import type { FastifyInstance } from 'fastify';
import crypto from 'node:crypto';
interface GiteaConfig {
url: string;
apiToken: string;
webhookSecret: string;
syncOnStartup: boolean;
syncCron: string;
}
function getConfig(): GiteaConfig {
const url = process.env.PLUGIN_GITEA_URL;
const apiToken = process.env.PLUGIN_GITEA_API_TOKEN;
const webhookSecret = process.env.PLUGIN_GITEA_WEBHOOK_SECRET;
if (!url || !apiToken || !webhookSecret) {
throw new Error('Missing required PLUGIN_GITEA_* env vars');
}
return {
url: url.replace(/\/$/, ''),
apiToken,
webhookSecret,
syncOnStartup: process.env.PLUGIN_GITEA_SYNC_ON_STARTUP !== 'false',
syncCron: process.env.PLUGIN_GITEA_SYNC_CRON || '0 */6 * * *',
};
}
async function fetchRepos(config: GiteaConfig) {
const repos: Array<{ id: number; name: string; full_name: string; html_url: string; description: string }> = [];
let page = 1;
while (true) {
const res = await fetch(
`${config.url}/api/v1/repos/search?token=${config.apiToken}&limit=50&page=${page}`
);
if (!res.ok) break;
const body = await res.json() as { data: typeof repos };
if (!body.data?.length) break;
repos.push(...body.data);
if (body.data.length < 50) break;
page++;
}
return repos;
}
function verifyWebhookSignature(payload: string, signature: string, secret: string): boolean {
const expected = crypto.createHmac('sha256', secret).update(payload).digest('hex');
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}
export const giteaPlugin = {
name: 'gitea',
version: '0.1.0',
onRegister(app: FastifyInstance) {
const config = getConfig();
app.post('/api/v1/plugins/gitea/webhook', async (req, reply) => {
const sig = req.headers['x-gitea-signature'] as string;
const rawBody = JSON.stringify(req.body);
if (!sig || !verifyWebhookSignature(rawBody, sig, config.webhookSecret)) {
return reply.status(401).send({ error: 'invalid signature' });
}
const event = req.headers['x-gitea-event'] as string;
const body = req.body as Record<string, unknown>;
if (event === 'repository' && body.action === 'created') {
const repo = body.repository as { id: number; name: string; html_url: string; description: string };
const slug = repo.name.toLowerCase().replace(/[^a-z0-9-]/g, '-');
const { prisma } = app as unknown as { prisma: { board: { upsert: Function } } };
await prisma.board.upsert({
where: { slug },
create: {
slug,
name: repo.name,
description: repo.description || null,
externalUrl: repo.html_url,
},
update: {},
});
}
if (event === 'repository' && body.action === 'deleted') {
const repo = body.repository as { name: string };
const slug = repo.name.toLowerCase().replace(/[^a-z0-9-]/g, '-');
const { prisma } = app as unknown as { prisma: { board: { updateMany: Function } } };
await prisma.board.updateMany({
where: { slug },
data: { isArchived: true },
});
}
return reply.status(200).send({ ok: true });
});
app.get('/api/v1/plugins/gitea/sync-status', async (_req, reply) => {
return reply.send({ status: 'ok', lastSync: new Date().toISOString() });
});
app.post('/api/v1/plugins/gitea/sync', async (_req, reply) => {
const repos = await fetchRepos(config);
const { prisma } = app as unknown as { prisma: { board: { upsert: Function } } };
for (const repo of repos) {
const slug = repo.name.toLowerCase().replace(/[^a-z0-9-]/g, '-');
await prisma.board.upsert({
where: { slug },
create: {
slug,
name: repo.name,
description: repo.description || null,
externalUrl: repo.html_url,
},
update: { externalUrl: repo.html_url },
});
}
return reply.send({ synced: repos.length });
});
},
async onStartup() {
const config = getConfig();
if (!config.syncOnStartup) return;
// initial sync handled by the route handler logic
// in production this would call fetchRepos and sync
},
getAdminRoutes() {
return [
{ path: '/admin/gitea', label: 'Gitea Sync', icon: 'git-branch' },
];
},
};