344 lines
12 KiB
TypeScript
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;
|