security hardening, team invites, granular locking, view counts, board subscriptions, scheduled changelog, mentions, recovery codes, accessibility and hover states

This commit is contained in:
2026-03-21 17:37:01 +02:00
parent f07eddf29e
commit 5ba25fb956
142 changed files with 30397 additions and 2287 deletions

View File

@@ -1,5 +1,6 @@
import type { FastifyInstance } from 'fastify';
import crypto from 'node:crypto';
import { Readable } from 'node:stream';
interface GiteaConfig {
url: string;
@@ -33,7 +34,8 @@ async function fetchRepos(config: GiteaConfig) {
while (true) {
const res = await fetch(
`${config.url}/api/v1/repos/search?token=${config.apiToken}&limit=50&page=${page}`
`${config.url}/api/v1/repos/search?limit=50&page=${page}`,
{ headers: { Authorization: `token ${config.apiToken}` } }
);
if (!res.ok) break;
@@ -48,8 +50,9 @@ async function fetchRepos(config: GiteaConfig) {
return repos;
}
function verifyWebhookSignature(payload: string, signature: string, secret: string): boolean {
function verifyWebhookSignature(payload: Buffer | string, signature: string, secret: string): boolean {
const expected = crypto.createHmac('sha256', secret).update(payload).digest('hex');
if (signature.length !== expected.length) return false;
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}
@@ -60,9 +63,19 @@ export const giteaPlugin = {
onRegister(app: FastifyInstance) {
const config = getConfig();
app.post('/api/v1/plugins/gitea/webhook', async (req, reply) => {
app.post('/api/v1/plugins/gitea/webhook', {
preParsing: async (req, _reply, payload) => {
const chunks: Buffer[] = [];
for await (const chunk of payload) {
chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
}
const raw = Buffer.concat(chunks);
(req as any).rawBody = raw;
return Readable.from(raw);
},
}, async (req, reply) => {
const sig = req.headers['x-gitea-signature'] as string;
const rawBody = JSON.stringify(req.body);
const rawBody: Buffer = (req as any).rawBody;
if (!sig || !verifyWebhookSignature(rawBody, sig, config.webhookSecret)) {
return reply.status(401).send({ error: 'invalid signature' });
@@ -102,11 +115,11 @@ export const giteaPlugin = {
return reply.status(200).send({ ok: true });
});
app.get('/api/v1/plugins/gitea/sync-status', async (_req, reply) => {
app.get('/api/v1/plugins/gitea/sync-status', { preHandler: [app.requireAdmin] }, async (_req, reply) => {
return reply.send({ status: 'ok', lastSync: new Date().toISOString() });
});
app.post('/api/v1/plugins/gitea/sync', async (_req, reply) => {
app.post('/api/v1/plugins/gitea/sync', { preHandler: [app.requireAdmin], config: { rateLimit: { max: 2, timeWindow: '1 minute' } } }, async (_req, reply) => {
const repos = await fetchRepos(config);
const { prisma } = app as unknown as { prisma: { board: { upsert: Function } } };
@@ -137,7 +150,7 @@ export const giteaPlugin = {
getAdminRoutes() {
return [
{ path: '/admin/gitea', label: 'Gitea Sync', icon: 'git-branch' },
{ path: '/admin/gitea', label: 'Gitea Sync', component: 'gitea-sync' },
];
},
};