Files
paste.es/api/routes/secret-requests.ts
Malin bc9f96cbd4 feat: rebrand Hemmelig to paste.es for cloudhost.es
- Set Spanish as default language with ephemeral/encrypted privacy focus
- Translate all user-facing strings and legal pages to Spanish
- Replace Norwegian flag with Spanish flag in footer
- Remove Hemmelig/terces.cloud links, add cloudhost.es sponsorship
- Rewrite PrivacyPage: zero data collection, ephemeral design emphasis
- Rewrite TermsPage: Spanish law, RGPD, paste.es/CloudHost.es references
- Update PWA manifest, HTML meta tags, package.json branding
- Rename webhook headers to X-Paste-Event / X-Paste-Signature
- Update API docs title and contact to paste.es / cloudhost.es

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 09:30:19 +01:00

456 lines
17 KiB
TypeScript

import { zValidator } from '@hono/zod-validator';
import { createHmac, randomBytes, timingSafeEqual } from 'crypto';
import { Hono } from 'hono';
import { auth } from '../auth';
import prisma from '../lib/db';
import { isPublicUrl } from '../lib/utils';
import { authMiddleware } from '../middlewares/auth';
import {
createSecretRequestSchema,
processSecretRequestsQueryParams,
secretRequestIdParamSchema,
secretRequestsQuerySchema,
secretRequestTokenQuerySchema,
submitSecretRequestSchema,
} from '../validations/secret-requests';
// Webhook payload for secret request fulfillment
interface SecretRequestWebhookPayload {
event: 'secret_request.fulfilled';
timestamp: string;
request: {
id: string;
title: string;
createdAt: string;
fulfilledAt: string;
};
secret: {
id: string;
maxViews: number;
expiresAt: string;
};
}
// Send webhook notification when a secret request is fulfilled
async function sendSecretRequestWebhook(
webhookUrl: string,
webhookSecret: string,
payload: SecretRequestWebhookPayload
): Promise<void> {
try {
const timestamp = Math.floor(Date.now() / 1000);
const payloadString = JSON.stringify(payload);
const signedPayload = `${timestamp}.${payloadString}`;
const signature = createHmac('sha256', webhookSecret).update(signedPayload).digest('hex');
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'X-Hemmelig-Event': 'secret_request.fulfilled',
'X-Hemmelig-Signature': `sha256=${signature}`,
'X-Hemmelig-Timestamp': timestamp.toString(),
'X-Hemmelig-Request-Id': payload.request.id,
'User-Agent': 'Hemmelig-Webhook/1.0',
};
// Retry with exponential backoff
const maxRetries = 3;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const response = await fetch(webhookUrl, {
method: 'POST',
headers,
body: payloadString,
signal: AbortSignal.timeout(5000), // 5 second timeout to prevent slow-loris
redirect: 'error', // Prevent SSRF via open redirects
});
if (response.ok) return;
// Don't retry for client errors (4xx)
if (response.status >= 400 && response.status < 500) {
console.error(`Secret request webhook delivery failed: ${response.status}`);
return;
}
} catch (error) {
if (attempt === maxRetries - 1) {
console.error('Secret request webhook delivery failed after retries:', error);
}
}
// Exponential backoff: 1s, 2s, 4s
await new Promise((resolve) => setTimeout(resolve, 1000 * Math.pow(2, attempt)));
}
} catch (error) {
console.error('Error preparing secret request webhook:', error);
}
}
// Secure token comparison - constant time for all inputs
function validateToken(provided: string, stored: string): boolean {
try {
// Pad to same length to prevent timing leaks from length comparison
const providedBuf = Buffer.alloc(32);
const storedBuf = Buffer.alloc(32);
const providedBytes = Buffer.from(provided, 'hex');
const storedBytes = Buffer.from(stored, 'hex');
// Only copy valid bytes, rest stays as zeros
if (providedBytes.length === 32) providedBytes.copy(providedBuf);
if (storedBytes.length === 32) storedBytes.copy(storedBuf);
// Always do the comparison, even if lengths were wrong
const match = timingSafeEqual(providedBuf, storedBuf);
// Only return true if lengths were correct AND content matches
return providedBytes.length === 32 && storedBytes.length === 32 && match;
} catch {
return false;
}
}
const app = new Hono<{
Variables: {
user: typeof auth.$Infer.Session.user | null;
};
}>()
// List user's secret requests (authenticated)
.get('/', authMiddleware, zValidator('query', secretRequestsQuerySchema), async (c) => {
try {
const user = c.get('user')!; // authMiddleware guarantees user exists
const validatedQuery = c.req.valid('query');
const { skip, take, status } = processSecretRequestsQueryParams(validatedQuery);
const whereClause: { userId: string; status?: string } = { userId: user.id };
if (status && status !== 'all') {
whereClause.status = status;
}
const [items, total] = await Promise.all([
prisma.secretRequest.findMany({
where: whereClause,
skip,
take,
orderBy: { createdAt: 'desc' },
select: {
id: true,
title: true,
description: true,
status: true,
maxViews: true,
expiresIn: true,
webhookUrl: true,
createdAt: true,
expiresAt: true,
fulfilledAt: true,
secretId: true,
},
}),
prisma.secretRequest.count({ where: whereClause }),
]);
return c.json({
data: items,
meta: {
total,
skip,
take,
page: Math.floor(skip / take) + 1,
totalPages: Math.ceil(total / take),
},
});
} catch (error) {
console.error('Failed to retrieve secret requests:', error);
return c.json({ error: 'Failed to retrieve secret requests' }, 500);
}
})
// Create new secret request (authenticated)
.post('/', authMiddleware, zValidator('json', createSecretRequestSchema), async (c) => {
try {
const user = c.get('user')!; // authMiddleware guarantees user exists
const data = c.req.valid('json');
if (data.webhookUrl && !(await isPublicUrl(data.webhookUrl))) {
return c.json(
{ error: 'Webhook URL cannot point to private/internal addresses' },
400
);
}
// Generate secure token (64 hex chars = 32 bytes)
const token = randomBytes(32).toString('hex');
// Generate webhook secret if webhook URL is provided
const webhookSecret = data.webhookUrl ? randomBytes(32).toString('hex') : null;
const request = await prisma.secretRequest.create({
data: {
title: data.title,
description: data.description,
maxViews: data.maxViews,
expiresIn: data.expiresIn,
allowedIp: data.allowedIp,
preventBurn: data.preventBurn,
webhookUrl: data.webhookUrl,
webhookSecret,
token,
userId: user.id,
expiresAt: new Date(Date.now() + data.validFor * 1000),
},
});
// Get the base URL from the request
const origin = c.req.header('origin') || process.env.HEMMELIG_BASE_URL || '';
return c.json(
{
id: request.id,
creatorLink: `${origin}/request/${request.id}?token=${token}`,
webhookSecret, // Return once so requester can configure their webhook receiver
expiresAt: request.expiresAt,
},
201
);
} catch (error) {
console.error('Failed to create secret request:', error);
return c.json({ error: 'Failed to create secret request' }, 500);
}
})
// Get single secret request details (authenticated, owner only)
.get('/:id', authMiddleware, zValidator('param', secretRequestIdParamSchema), async (c) => {
try {
const user = c.get('user')!;
const { id } = c.req.valid('param');
const request = await prisma.secretRequest.findUnique({
where: { id },
select: {
id: true,
title: true,
description: true,
status: true,
maxViews: true,
expiresIn: true,
preventBurn: true,
webhookUrl: true,
token: true,
createdAt: true,
expiresAt: true,
fulfilledAt: true,
secretId: true,
userId: true,
allowedIp: true,
},
});
if (!request) {
return c.json({ error: 'Secret request not found' }, 404);
}
if (request.userId !== user.id) {
return c.json({ error: 'Forbidden' }, 403);
}
// Get the base URL from the request
const origin = c.req.header('origin') || process.env.HEMMELIG_BASE_URL || '';
return c.json({
...request,
creatorLink: `${origin}/request/${request.id}?token=${request.token}`,
});
} catch (error) {
console.error('Failed to retrieve secret request:', error);
return c.json({ error: 'Failed to retrieve secret request' }, 500);
}
})
// Cancel/delete secret request (authenticated, owner only)
.delete('/:id', authMiddleware, zValidator('param', secretRequestIdParamSchema), async (c) => {
try {
const user = c.get('user')!;
const { id } = c.req.valid('param');
const request = await prisma.secretRequest.findUnique({
where: { id },
select: { userId: true, status: true },
});
if (!request) {
return c.json({ error: 'Secret request not found' }, 404);
}
if (request.userId !== user.id) {
return c.json({ error: 'Forbidden' }, 403);
}
// Only allow cancellation of pending requests
if (request.status !== 'pending') {
return c.json({ error: 'Can only cancel pending requests' }, 400);
}
await prisma.secretRequest.update({
where: { id },
data: { status: 'cancelled' },
});
return c.json({ success: true, message: 'Secret request cancelled' });
} catch (error) {
console.error('Failed to cancel secret request:', error);
return c.json({ error: 'Failed to cancel secret request' }, 500);
}
})
// Get request info for Creator (public, requires token)
.get(
'/:id/info',
zValidator('param', secretRequestIdParamSchema),
zValidator('query', secretRequestTokenQuerySchema),
async (c) => {
try {
const { id } = c.req.valid('param');
const { token } = c.req.valid('query');
const request = await prisma.secretRequest.findUnique({
where: { id },
select: {
id: true,
title: true,
description: true,
status: true,
expiresAt: true,
token: true,
},
});
if (!request || !validateToken(token, request.token)) {
return c.json({ error: 'Invalid or expired request' }, 404);
}
if (request.status !== 'pending') {
return c.json({ error: 'Request already fulfilled or expired' }, 410);
}
if (new Date() > request.expiresAt) {
// Update status to expired
await prisma.secretRequest.update({
where: { id },
data: { status: 'expired' },
});
return c.json({ error: 'Request has expired' }, 410);
}
return c.json({
id: request.id,
title: request.title,
description: request.description,
});
} catch (error) {
console.error('Failed to retrieve secret request info:', error);
return c.json({ error: 'Failed to retrieve request info' }, 500);
}
}
)
// Submit encrypted secret for request (public, requires token)
.post(
'/:id/submit',
zValidator('param', secretRequestIdParamSchema),
zValidator('query', secretRequestTokenQuerySchema),
zValidator('json', submitSecretRequestSchema),
async (c) => {
try {
const { id } = c.req.valid('param');
const { token } = c.req.valid('query');
const { secret, title, salt } = c.req.valid('json');
// Use interactive transaction to prevent race conditions
const result = await prisma.$transaction(async (tx) => {
const request = await tx.secretRequest.findUnique({
where: { id },
});
if (!request || !validateToken(token, request.token)) {
return { error: 'Invalid request', status: 404 };
}
if (request.status !== 'pending') {
return { error: 'Request already fulfilled', status: 410 };
}
if (new Date() > request.expiresAt) {
await tx.secretRequest.update({
where: { id },
data: { status: 'expired' },
});
return { error: 'Request has expired', status: 410 };
}
// Calculate expiration time for the secret
const secretExpiresAt = new Date(Date.now() + request.expiresIn * 1000);
// Create secret and update request atomically
const createdSecret = await tx.secrets.create({
data: {
secret: Buffer.from(secret),
title: title ? Buffer.from(title) : Buffer.from([]),
salt,
views: request.maxViews,
ipRange: request.allowedIp,
isBurnable: !request.preventBurn,
expiresAt: secretExpiresAt,
},
});
await tx.secretRequest.update({
where: { id },
data: {
status: 'fulfilled',
fulfilledAt: new Date(),
secretId: createdSecret.id,
},
});
return { success: true, createdSecret, request, secretExpiresAt };
});
if ('error' in result) {
return c.json({ error: result.error }, result.status as 404 | 410);
}
const { createdSecret, request, secretExpiresAt } = result;
// Send webhook notification (async, don't block response)
if (request.webhookUrl && request.webhookSecret) {
const webhookPayload: SecretRequestWebhookPayload = {
event: 'secret_request.fulfilled',
timestamp: new Date().toISOString(),
request: {
id: request.id,
title: request.title,
createdAt: request.createdAt.toISOString(),
fulfilledAt: new Date().toISOString(),
},
secret: {
id: createdSecret.id,
maxViews: request.maxViews,
expiresAt: secretExpiresAt.toISOString(),
},
};
sendSecretRequestWebhook(
request.webhookUrl,
request.webhookSecret,
webhookPayload
).catch(console.error);
}
// Return secret ID (client will construct full URL with decryption key)
return c.json({ secretId: createdSecret.id }, 201);
} catch (error) {
console.error('Failed to submit secret for request:', error);
return c.json({ error: 'Failed to submit secret' }, 500);
}
}
);
export default app;