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>
This commit is contained in:
455
api/routes/secret-requests.ts
Normal file
455
api/routes/secret-requests.ts
Normal file
@@ -0,0 +1,455 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user