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:
143
plugins/gitea/src/index.ts
Normal file
143
plugins/gitea/src/index.ts
Normal 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' },
|
||||
];
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user