Files
paste.es/api/routes/secrets.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

344 lines
12 KiB
TypeScript

import { zValidator } from '@hono/zod-validator';
import { Hono } from 'hono';
import { auth } from '../auth';
import config from '../config';
import prisma from '../lib/db';
import { compare, hash } from '../lib/password';
import { getInstanceSettings } from '../lib/settings';
import { handleNotFound } from '../lib/utils';
import { sendWebhook } from '../lib/webhook';
import { apiKeyOrAuthMiddleware } from '../middlewares/auth';
import { ipRestriction } from '../middlewares/ip-restriction';
import {
createSecretsSchema,
getSecretSchema,
processSecretsQueryParams,
secretsIdParamSchema,
secretsQuerySchema,
} from '../validations/secrets';
interface SecretCreateData {
salt: string;
secret: Uint8Array;
title?: Uint8Array | null;
password: string | null;
expiresAt: Date;
views?: number;
isBurnable?: boolean;
ipRange?: string | null;
files?: { connect: { id: string }[] };
userId?: string;
}
const app = new Hono<{
Variables: {
user: typeof auth.$Infer.Session.user | null;
};
}>()
.get('/', apiKeyOrAuthMiddleware, zValidator('query', secretsQuerySchema), async (c) => {
try {
const user = c.get('user');
if (!user) {
return c.json({ error: 'Unauthorized' }, 401);
}
const validatedQuery = c.req.valid('query');
const options = processSecretsQueryParams(validatedQuery);
const whereClause = { userId: user.id };
const [items, total] = await Promise.all([
prisma.secrets.findMany({
where: whereClause,
skip: options.skip,
take: options.take,
orderBy: { createdAt: 'desc' },
select: {
id: true,
createdAt: true,
expiresAt: true,
views: true,
password: true,
ipRange: true,
isBurnable: true,
_count: {
select: { files: true },
},
},
}),
prisma.secrets.count({ where: whereClause }),
]);
const formattedItems = items.map((item) => ({
id: item.id,
createdAt: item.createdAt,
expiresAt: item.expiresAt,
views: item.views,
isPasswordProtected: !!item.password,
ipRange: item.ipRange,
isBurnable: item.isBurnable,
fileCount: item._count.files,
}));
return c.json({
data: formattedItems,
meta: {
total,
skip: options.skip,
take: options.take,
page: Math.floor(options.skip / options.take) + 1,
totalPages: Math.ceil(total / options.take),
},
});
} catch (error) {
console.error('Failed to retrieve secrets:', error);
return c.json(
{
error: 'Failed to retrieve secrets',
},
500
);
}
})
.post(
'/:id',
zValidator('param', secretsIdParamSchema),
zValidator('json', getSecretSchema),
ipRestriction,
async (c) => {
try {
const { id } = c.req.valid('param');
const data = c.req.valid('json');
// Atomically retrieve secret and consume view in a single transaction
const result = await prisma.$transaction(async (tx) => {
const item = await tx.secrets.findUnique({
where: { id },
select: {
id: true,
secret: true,
title: true,
ipRange: true,
views: true,
expiresAt: true,
createdAt: true,
isBurnable: true,
password: true,
salt: true,
files: {
select: { id: true, filename: true },
},
},
});
if (!item) {
return { error: 'Secret not found', status: 404 as const };
}
// Check if secret has no views remaining (already consumed)
if (item.views !== null && item.views <= 0) {
return { error: 'Secret not found', status: 404 as const };
}
// Verify password if required
if (item.password) {
const isValidPassword = await compare(data.password!, item.password);
if (!isValidPassword) {
return { error: 'Invalid password', status: 401 as const };
}
}
// Consume the view atomically with retrieval
const newViews = item.views! - 1;
// If burnable and last view, delete the secret after returning data
if (item.isBurnable && newViews <= 0) {
await tx.secrets.delete({ where: { id } });
// Send webhook for burned secret
sendWebhook('secret.burned', {
secretId: id,
hasPassword: !!item.password,
hasIpRestriction: !!item.ipRange,
});
} else {
// Decrement views
await tx.secrets.update({
where: { id },
data: { views: newViews },
});
// Send webhook for viewed secret
sendWebhook('secret.viewed', {
secretId: id,
hasPassword: !!item.password,
hasIpRestriction: !!item.ipRange,
viewsRemaining: newViews,
});
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { password: _password, ...itemWithoutPassword } = item;
return {
...itemWithoutPassword,
views: newViews,
burned: item.isBurnable && newViews <= 0,
};
});
if ('error' in result) {
return c.json({ error: result.error }, result.status);
}
return c.json(result);
} catch (error) {
console.error(`Failed to retrieve item ${c.req.param('id')}:`, error);
return c.json(
{
error: 'Failed to retrieve item',
},
500
);
}
}
)
.get('/:id/check', zValidator('param', secretsIdParamSchema), ipRestriction, async (c) => {
try {
const { id } = c.req.valid('param');
const item = await prisma.secrets.findUnique({
where: { id },
select: {
id: true,
views: true,
title: true,
password: true,
},
});
if (!item) {
return c.json({ error: 'Secret not found' }, 404);
}
// Check if secret has no views remaining (already consumed)
if (item.views !== null && item.views <= 0) {
return c.json({ error: 'Secret not found' }, 404);
}
return c.json({
views: item.views,
title: item.title,
isPasswordProtected: !!item.password,
});
} catch (error) {
console.error(`Failed to check secret ${c.req.param('id')}:`, error);
return c.json(
{
error: 'Failed to check secret',
},
500
);
}
})
.post('/', zValidator('json', createSecretsSchema), async (c) => {
try {
const user = c.get('user');
// Check if only registered users can create secrets
// In managed mode, use environment-based settings; otherwise use database
const settings = config.isManaged()
? config.getManagedSettings()
: await getInstanceSettings();
if (settings?.requireRegisteredUser && !user) {
return c.json({ error: 'Only registered users can create secrets' }, 401);
}
const validatedData = c.req.valid('json');
// Enforce dynamic maxSecretSize from instance settings (in KB)
const maxSizeKB = settings?.maxSecretSize ?? 1024;
const maxSizeBytes = maxSizeKB * 1024;
if (validatedData.secret.length > maxSizeBytes) {
return c.json({ error: `Secret exceeds maximum size of ${maxSizeKB} KB` }, 413);
}
const { expiresAt, password, fileIds, salt, title, ...rest } = validatedData;
const data: SecretCreateData = {
...rest,
salt,
// Title is required by the database, default to empty Uint8Array if not provided
title: title ?? new Uint8Array(0),
password: password ? await hash(password) : null,
expiresAt: new Date(Date.now() + expiresAt * 1000),
...(fileIds && {
files: { connect: fileIds.map((id: string) => ({ id })) },
}),
};
if (user) {
data.userId = user.id;
}
const item = await prisma.secrets.create({ data });
return c.json({ id: item.id }, 201);
} catch (error: unknown) {
console.error('Failed to create secrets:', error);
if (error && typeof error === 'object' && 'code' in error && error.code === 'P2002') {
const prismaError = error as { meta?: { target?: string } };
return c.json(
{
error: 'Could not create secrets',
details: prismaError.meta?.target,
},
409
);
}
return c.json(
{
error: 'Failed to create secret',
},
500
);
}
})
.delete('/:id', zValidator('param', secretsIdParamSchema), async (c) => {
try {
const { id } = c.req.valid('param');
// Use transaction to prevent race conditions
const secret = await prisma.$transaction(async (tx) => {
// Get secret info before deleting for webhook
const secretData = await tx.secrets.findUnique({
where: { id },
select: { id: true, password: true, ipRange: true },
});
await tx.secrets.delete({ where: { id } });
return secretData;
});
// Send webhook for manually burned secret
if (secret) {
sendWebhook('secret.burned', {
secretId: id,
hasPassword: !!secret.password,
hasIpRestriction: !!secret.ipRange,
});
}
return c.json({
success: true,
message: 'Secret deleted successfully',
});
} catch (error) {
console.error(`Failed to delete secret ${c.req.param('id')}:`, error);
return handleNotFound(error as Error & { code?: string }, c);
}
});
export default app;