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:
2026-02-24 09:30:19 +01:00
commit bc9f96cbd4
268 changed files with 45773 additions and 0 deletions

130
api/routes/account.ts Normal file
View File

@@ -0,0 +1,130 @@
import { zValidator } from '@hono/zod-validator';
import { Hono } from 'hono';
import { auth } from '../auth';
import prisma from '../lib/db';
import { handleNotFound } from '../lib/utils';
import { authMiddleware } from '../middlewares/auth';
import { updateAccountSchema, updatePasswordSchema } from '../validations/account';
const app = new Hono<{
Variables: {
user: typeof auth.$Infer.Session.user | null;
};
}>();
// Get user account information
app.get('/', authMiddleware, async (c) => {
const user = c.get('user');
if (!user) {
return c.json({ error: 'Unauthorized' }, 401);
}
return c.json({
username: user.username,
email: user.email,
});
});
// Update user account information
app.put('/', authMiddleware, zValidator('json', updateAccountSchema), async (c) => {
const user = c.get('user');
const { username, email } = c.req.valid('json');
if (!user) {
return c.json({ error: 'Unauthorized' }, 401);
}
try {
// Check if username is taken by another user
if (username) {
const existingUser = await prisma.user.findUnique({
where: { username },
select: { id: true },
});
if (existingUser && existingUser.id !== user.id) {
return c.json({ error: 'Username is already taken' }, 409);
}
}
// Check if email is taken by another user
if (email) {
const existingEmail = await prisma.user.findFirst({
where: { email },
select: { id: true },
});
if (existingEmail && existingEmail.id !== user.id) {
return c.json({ error: 'Email is already taken' }, 409);
}
}
const updatedUser = await prisma.user.update({
where: { id: user.id },
data: {
username,
email,
},
});
return c.json({
username: updatedUser.username,
email: updatedUser.email,
});
} catch (error) {
console.error('Failed to update account:', error);
return handleNotFound(error as Error & { code?: string }, c);
}
});
// Update user password
app.put('/password', authMiddleware, zValidator('json', updatePasswordSchema), async (c) => {
const user = c.get('user');
const { currentPassword, newPassword } = c.req.valid('json');
if (!user) {
return c.json({ error: 'Unauthorized' }, 401);
}
try {
// Use better-auth's changePassword API
const result = await auth.api.changePassword({
body: {
currentPassword,
newPassword,
},
headers: c.req.raw.headers,
});
if (!result) {
return c.json({ error: 'Failed to change password' }, 500);
}
return c.json({ message: 'Password updated successfully' });
} catch (error) {
console.error('Failed to update password:', error);
const message = error instanceof Error ? error.message : 'Failed to update password';
return c.json({ error: message }, 500);
}
});
// Delete user account
app.delete('/', authMiddleware, async (c) => {
const user = c.get('user');
if (!user) {
return c.json({ error: 'Unauthorized' }, 401);
}
try {
await prisma.user.delete({
where: { id: user.id },
});
return c.json({ message: 'Account deleted successfully' });
} catch (error) {
console.error('Failed to delete account:', error);
return handleNotFound(error as Error & { code?: string }, c);
}
});
export default app;

253
api/routes/analytics.ts Normal file
View File

@@ -0,0 +1,253 @@
import { zValidator } from '@hono/zod-validator';
import { Hono } from 'hono';
import { isbot } from 'isbot';
import { z } from 'zod';
import {
calculatePercentage,
createVisitorId,
getStartDateForTimeRange,
isAnalyticsEnabled,
isValidAnalyticsPath,
} from '../lib/analytics';
import prisma from '../lib/db';
import { getClientIp } from '../lib/utils';
import { authMiddleware, checkAdmin } from '../middlewares/auth';
const app = new Hono();
const trackSchema = z.object({
path: z.string().max(255),
});
const timeRangeSchema = z.object({
timeRange: z.enum(['7d', '14d', '30d']).default('30d'),
});
// POST /api/analytics/track - Public endpoint for visitor tracking
app.post('/track', zValidator('json', trackSchema), async (c) => {
if (!isAnalyticsEnabled()) {
return c.json({ success: false }, 403);
}
const userAgent = c.req.header('user-agent') || '';
if (isbot(userAgent)) {
return c.json({ success: false }, 403);
}
try {
const { path } = c.req.valid('json');
if (!isValidAnalyticsPath(path)) {
return c.json({ error: 'Invalid path format' }, 400);
}
const uniqueId = createVisitorId(getClientIp(c), userAgent);
await prisma.visitorAnalytics.create({
data: { path, uniqueId },
});
return c.json({ success: true }, 201);
} catch (error) {
console.error('Analytics tracking error:', error);
return c.json({ error: 'Failed to track analytics' }, 500);
}
});
// GET /api/analytics - Secret analytics (admin only)
app.get('/', authMiddleware, checkAdmin, zValidator('query', timeRangeSchema), async (c) => {
const { timeRange } = c.req.valid('query');
const now = new Date();
const startDate = getStartDateForTimeRange(timeRange);
try {
// Use aggregations for basic counts - much more efficient than loading all records
const [aggregates, activeCount, typesCounts, dailyStats, secretRequestStats] =
await Promise.all([
// Get total count and sum of views
prisma.secrets.aggregate({
where: { createdAt: { gte: startDate } },
_count: true,
_sum: { views: true },
}),
// Count active (non-expired) secrets
prisma.secrets.count({
where: {
createdAt: { gte: startDate },
expiresAt: { gt: now },
},
}),
// Get counts for secret types in parallel
Promise.all([
prisma.secrets.count({
where: { createdAt: { gte: startDate }, password: { not: null } },
}),
prisma.secrets.count({
where: {
createdAt: { gte: startDate },
ipRange: { not: null },
NOT: { ipRange: '' },
},
}),
prisma.secrets.count({
where: { createdAt: { gte: startDate }, isBurnable: true },
}),
]),
// For daily stats, we still need individual records but only select minimal fields
prisma.secrets.findMany({
where: { createdAt: { gte: startDate } },
select: {
createdAt: true,
views: true,
expiresAt: true,
},
}),
// Secret request statistics
Promise.all([
prisma.secretRequest.count({
where: { createdAt: { gte: startDate } },
}),
prisma.secretRequest.count({
where: { createdAt: { gte: startDate }, status: 'fulfilled' },
}),
]),
]);
const totalSecrets = aggregates._count;
const totalViews = aggregates._sum.views || 0;
const activeSecrets = activeCount;
const expiredSecrets = totalSecrets - activeSecrets;
const averageViews = totalSecrets > 0 ? totalViews / totalSecrets : 0;
const [passwordProtected, ipRestricted, burnable] = typesCounts;
const [totalSecretRequests, fulfilledSecretRequests] = secretRequestStats;
// Process daily stats from minimal data
const dailyStatsMap = dailyStats.reduce(
(acc, secret) => {
const date = secret.createdAt.toISOString().split('T')[0];
if (!acc[date]) {
acc[date] = { date, secrets: 0, views: 0 };
}
acc[date].secrets++;
acc[date].views += secret.views || 0;
return acc;
},
{} as Record<string, { date: string; secrets: number; views: number }>
);
// Calculate expiration stats from minimal data
const expirationDurations = dailyStats.map(
(s) => (s.expiresAt.getTime() - s.createdAt.getTime()) / (1000 * 60 * 60)
);
const oneHour = expirationDurations.filter((d) => d <= 1).length;
const oneDay = expirationDurations.filter((d) => d > 1 && d <= 24).length;
const oneWeekPlus = expirationDurations.filter((d) => d > 24).length;
return c.json({
totalSecrets,
totalViews,
activeSecrets,
expiredSecrets,
averageViews: parseFloat(averageViews.toFixed(2)),
dailyStats: Object.values(dailyStatsMap),
secretTypes: {
passwordProtected: calculatePercentage(passwordProtected, totalSecrets),
ipRestricted: calculatePercentage(ipRestricted, totalSecrets),
burnable: calculatePercentage(burnable, totalSecrets),
},
expirationStats: {
oneHour: calculatePercentage(oneHour, totalSecrets),
oneDay: calculatePercentage(oneDay, totalSecrets),
oneWeekPlus: calculatePercentage(oneWeekPlus, totalSecrets),
},
secretRequests: {
total: totalSecretRequests,
fulfilled: fulfilledSecretRequests,
},
});
} catch (error) {
console.error('Failed to fetch analytics data:', error);
return c.json({ error: 'Failed to fetch analytics data' }, 500);
}
});
// GET /api/analytics/visitors - Visitor analytics data (admin only)
app.get('/visitors', authMiddleware, checkAdmin, async (c) => {
try {
const analytics = await prisma.visitorAnalytics.findMany({
orderBy: { timestamp: 'desc' },
take: 1000,
});
return c.json(analytics);
} catch (error) {
console.error('Analytics retrieval error:', error);
return c.json({ error: 'Failed to retrieve analytics' }, 500);
}
});
// GET /api/analytics/visitors/unique - Aggregated unique visitor data (admin only)
app.get('/visitors/unique', authMiddleware, checkAdmin, async (c) => {
try {
const aggregatedData = await prisma.visitorAnalytics.groupBy({
by: ['uniqueId', 'path'],
_count: { uniqueId: true },
orderBy: { _count: { uniqueId: 'desc' } },
});
return c.json(aggregatedData);
} catch (error) {
console.error('Aggregated analytics retrieval error:', error);
return c.json({ error: 'Failed to retrieve aggregated analytics' }, 500);
}
});
// GET /api/analytics/visitors/daily - Daily visitor statistics (admin only)
app.get(
'/visitors/daily',
authMiddleware,
checkAdmin,
zValidator('query', timeRangeSchema),
async (c) => {
try {
const { timeRange } = c.req.valid('query');
const startDate = getStartDateForTimeRange(timeRange);
// Use raw SQL for efficient database-level aggregation
// This avoids loading all records into memory for high-traffic instances
const aggregatedData = await prisma.$queryRaw<
Array<{
date: string;
unique_visitors: bigint;
total_visits: bigint;
paths: string;
}>
>`
SELECT
DATE(timestamp) as date,
COUNT(DISTINCT uniqueId) as unique_visitors,
COUNT(*) as total_visits,
GROUP_CONCAT(DISTINCT path) as paths
FROM visitor_analytics
WHERE timestamp >= ${startDate}
GROUP BY DATE(timestamp)
ORDER BY date ASC
`;
// Convert BigInt to number for JSON serialization
const result = aggregatedData.map((row) => ({
date: row.date,
unique_visitors: Number(row.unique_visitors),
total_visits: Number(row.total_visits),
paths: row.paths || '',
}));
return c.json(result);
} catch (error) {
console.error('Daily analytics retrieval error:', error);
return c.json({ error: 'Failed to retrieve daily analytics' }, 500);
}
}
);
export default app;

156
api/routes/api-keys.ts Normal file
View File

@@ -0,0 +1,156 @@
import { zValidator } from '@hono/zod-validator';
import { createHash, randomBytes } from 'crypto';
import { Hono } from 'hono';
import { z } from 'zod';
import { auth } from '../auth';
import prisma from '../lib/db';
import { handleNotFound } from '../lib/utils';
import { sendWebhook } from '../lib/webhook';
import { authMiddleware } from '../middlewares/auth';
const createApiKeySchema = z.object({
name: z.string().min(1).max(100),
expiresInDays: z.number().int().min(1).max(365).optional(),
});
const deleteApiKeySchema = z.object({
id: z.string(),
});
function hashApiKey(key: string): string {
return createHash('sha256').update(key).digest('hex');
}
function generateApiKey(): string {
const prefix = 'hemmelig';
const key = randomBytes(24).toString('base64url');
return `${prefix}_${key}`;
}
const app = new Hono<{
Variables: {
user: typeof auth.$Infer.Session.user | null;
};
}>()
.use(authMiddleware)
.get('/', async (c) => {
const user = c.get('user');
if (!user) {
return c.json({ error: 'Unauthorized' }, 401);
}
try {
const apiKeys = await prisma.apiKey.findMany({
where: { userId: user.id },
select: {
id: true,
name: true,
keyPrefix: true,
lastUsedAt: true,
expiresAt: true,
createdAt: true,
},
orderBy: { createdAt: 'desc' },
});
return c.json(apiKeys);
} catch (error) {
console.error('Failed to list API keys:', error);
return c.json({ error: 'Failed to list API keys' }, 500);
}
})
.post('/', zValidator('json', createApiKeySchema), async (c) => {
const user = c.get('user');
if (!user) {
return c.json({ error: 'Unauthorized' }, 401);
}
const { name, expiresInDays } = c.req.valid('json');
try {
// Check API key limit (max 5 per user)
const existingCount = await prisma.apiKey.count({
where: { userId: user.id },
});
if (existingCount >= 5) {
return c.json({ error: 'Maximum API key limit reached (5)' }, 400);
}
const rawKey = generateApiKey();
const keyHash = hashApiKey(rawKey);
const keyPrefix = rawKey.substring(0, 16);
const expiresAt = expiresInDays
? new Date(Date.now() + expiresInDays * 24 * 60 * 60 * 1000)
: null;
const apiKey = await prisma.apiKey.create({
data: {
name,
keyHash,
keyPrefix,
userId: user.id,
expiresAt,
},
select: {
id: true,
name: true,
keyPrefix: true,
expiresAt: true,
createdAt: true,
},
});
// Send webhook for API key creation
sendWebhook('apikey.created', {
apiKeyId: apiKey.id,
name: apiKey.name,
expiresAt: apiKey.expiresAt?.toISOString() || null,
userId: user.id,
});
// Return the raw key only once - it cannot be retrieved again
return c.json(
{
...apiKey,
key: rawKey,
},
201
);
} catch (error) {
console.error('Failed to create API key:', error);
return c.json({ error: 'Failed to create API key' }, 500);
}
})
.delete('/:id', zValidator('param', deleteApiKeySchema), async (c) => {
const user = c.get('user');
if (!user) {
return c.json({ error: 'Unauthorized' }, 401);
}
const { id } = c.req.valid('param');
try {
// Ensure the API key belongs to the user
const apiKey = await prisma.apiKey.findFirst({
where: { id, userId: user.id },
});
if (!apiKey) {
return c.json({ error: 'API key not found' }, 404);
}
await prisma.apiKey.delete({ where: { id } });
return c.json({ success: true });
} catch (error) {
console.error('Failed to delete API key:', error);
return handleNotFound(error as Error & { code?: string }, c);
}
});
export default app;
// Export helper for middleware
export { hashApiKey };

134
api/routes/files.ts Normal file
View File

@@ -0,0 +1,134 @@
import { zValidator } from '@hono/zod-validator';
import { createReadStream, createWriteStream } from 'fs';
import { Hono } from 'hono';
import { stream } from 'hono/streaming';
import { nanoid } from 'nanoid';
import { Readable } from 'stream';
import { pipeline } from 'stream/promises';
import { z } from 'zod';
import config from '../config';
import prisma from '../lib/db';
import { generateSafeFilePath, getMaxFileSize, isPathSafe } from '../lib/files';
import { getInstanceSettings } from '../lib/settings';
const files = new Hono();
const fileIdParamSchema = z.object({
id: z.string(),
});
files.get('/:id', zValidator('param', fileIdParamSchema), async (c) => {
const { id } = c.req.valid('param');
try {
// Fetch file with its associated secrets to verify access
const file = await prisma.file.findUnique({
where: { id },
include: {
secrets: {
select: {
id: true,
views: true,
expiresAt: true,
},
},
},
});
if (!file) {
return c.json({ error: 'File not found' }, 404);
}
// Security: Verify the file is associated with at least one valid (non-expired, has views) secret
// This prevents direct file access without going through the secret viewing flow
const hasValidSecret = file.secrets.some((secret) => {
const now = new Date();
const hasViewsRemaining = secret.views === null || secret.views > 0;
const notExpired = secret.expiresAt > now;
return hasViewsRemaining && notExpired;
});
if (!hasValidSecret) {
return c.json({ error: 'File not found' }, 404);
}
// Validate path is within upload directory to prevent path traversal
if (!isPathSafe(file.path)) {
console.error(`Path traversal attempt detected: ${file.path}`);
return c.json({ error: 'File not found' }, 404);
}
// Stream the file instead of loading it entirely into memory
const nodeStream = createReadStream(file.path);
const webStream = Readable.toWeb(nodeStream) as ReadableStream;
return stream(c, async (s) => {
s.onAbort(() => {
nodeStream.destroy();
});
await s.pipe(webStream);
});
} catch (error) {
console.error('Failed to download file:', error);
return c.json({ error: 'Failed to download file' }, 500);
}
});
files.post('/', async (c) => {
try {
// Check if file uploads are allowed
let allowFileUploads = true;
if (config.isManaged()) {
const managedSettings = config.getManagedSettings();
allowFileUploads = managedSettings?.allowFileUploads ?? true;
} else {
const instanceSettings = await getInstanceSettings();
allowFileUploads = instanceSettings?.allowFileUploads ?? true;
}
if (!allowFileUploads) {
return c.json({ error: 'File uploads are disabled on this instance.' }, 403);
}
const body = await c.req.parseBody();
const file = body['file'];
if (!(file instanceof File)) {
return c.json({ error: 'File is required and must be a file.' }, 400);
}
const maxFileSize = getMaxFileSize();
if (file.size > maxFileSize) {
return c.json(
{ error: `File size exceeds the limit of ${maxFileSize / 1024 / 1024}MB.` },
413
);
}
const id = nanoid();
const safePath = generateSafeFilePath(id, file.name);
if (!safePath) {
console.error(`Path traversal attempt in upload: ${file.name}`);
return c.json({ error: 'Invalid filename' }, 400);
}
// Stream the file to disk instead of loading it entirely into memory
const webStream = file.stream();
const nodeStream = Readable.fromWeb(webStream as import('stream/web').ReadableStream);
const writeStream = createWriteStream(safePath.path);
await pipeline(nodeStream, writeStream);
const newFile = await prisma.file.create({
data: { id, filename: safePath.filename, path: safePath.path },
});
return c.json({ id: newFile.id }, 201);
} catch (error) {
console.error('Failed to upload file:', error);
return c.json({ error: 'Failed to upload file' }, 500);
}
});
export default files;

131
api/routes/health.ts Normal file
View File

@@ -0,0 +1,131 @@
import { constants } from 'fs';
import { access, unlink, writeFile } from 'fs/promises';
import { Hono } from 'hono';
import { join } from 'path';
import prisma from '../lib/db';
import { UPLOAD_DIR } from '../lib/files';
const app = new Hono();
type CheckStatus = 'healthy' | 'unhealthy';
type CheckResult = {
status: CheckStatus;
latency_ms?: number;
error?: string;
[key: string]: unknown;
};
type HealthResponse = {
status: CheckStatus;
timestamp: string;
checks: {
database: CheckResult;
storage: CheckResult;
memory: CheckResult;
};
};
/**
* Check database connectivity by executing a simple query
*/
async function checkDatabase(): Promise<CheckResult> {
const start = Date.now();
try {
await prisma.$queryRaw`SELECT 1`;
return {
status: 'healthy',
latency_ms: Date.now() - start,
};
} catch (error) {
return {
status: 'unhealthy',
latency_ms: Date.now() - start,
error: error instanceof Error ? error.message : 'Database connection failed',
};
}
}
/**
* Check file storage is accessible and writable
*/
async function checkStorage(): Promise<CheckResult> {
const testFile = join(UPLOAD_DIR, `.health-check-${Date.now()}`);
try {
// Check directory exists and is accessible
await access(UPLOAD_DIR, constants.R_OK | constants.W_OK);
// Try to write and delete a test file
await writeFile(testFile, 'health-check');
await unlink(testFile);
return {
status: 'healthy',
};
} catch (error) {
return {
status: 'unhealthy',
error: error instanceof Error ? error.message : 'Storage check failed',
};
}
}
/**
* Check memory usage is within acceptable bounds
* Note: heapUsed/heapTotal ratio is often high (90%+) in normal Node.js operation
* since the heap grows dynamically. We use RSS-based threshold instead.
*/
function checkMemory(): CheckResult {
const memUsage = process.memoryUsage();
const heapUsedMB = Math.round(memUsage.heapUsed / 1024 / 1024);
const heapTotalMB = Math.round(memUsage.heapTotal / 1024 / 1024);
const rssMB = Math.round(memUsage.rss / 1024 / 1024);
// Consider unhealthy if RSS exceeds 1GB (reasonable default for most deployments)
const RSS_THRESHOLD_MB = 1024;
const isHealthy = rssMB < RSS_THRESHOLD_MB;
return {
status: isHealthy ? 'healthy' : 'unhealthy',
heap_used_mb: heapUsedMB,
heap_total_mb: heapTotalMB,
rss_mb: rssMB,
rss_threshold_mb: RSS_THRESHOLD_MB,
};
}
/**
* GET /health/live - Liveness probe
* Simple check to verify the process is running
*/
app.get('/live', (c) => {
return c.json({ status: 'healthy', timestamp: new Date().toISOString() });
});
/**
* GET /health/ready - Readiness probe
* Comprehensive check of all dependencies
*/
app.get('/ready', async (c) => {
const [database, storage] = await Promise.all([checkDatabase(), checkStorage()]);
const memory = checkMemory();
const checks = { database, storage, memory };
const overallStatus: CheckStatus = Object.values(checks).every(
(check) => check.status === 'healthy'
)
? 'healthy'
: 'unhealthy';
const response: HealthResponse = {
status: overallStatus,
timestamp: new Date().toISOString(),
checks,
};
return c.json(response, overallStatus === 'healthy' ? 200 : 503);
});
export default app;

169
api/routes/instance.ts Normal file
View File

@@ -0,0 +1,169 @@
import { zValidator } from '@hono/zod-validator';
import { Hono } from 'hono';
import config from '../config';
import { ADMIN_SETTINGS_FIELDS, PUBLIC_SETTINGS_FIELDS } from '../lib/constants';
import prisma from '../lib/db';
import settingsCache, { setCachedInstanceSettings } from '../lib/settings';
import { handleNotFound, isPublicUrl } from '../lib/utils';
import { authMiddleware, checkAdmin } from '../middlewares/auth';
import { instanceSettingsSchema } from '../validations/instance';
const app = new Hono();
// GET /api/instance/managed - check if instance is in managed mode
app.get('/managed', async (c) => {
return c.json({ managed: config.isManaged() });
});
// GET /api/instance/settings/public - public settings for all users
app.get('/settings/public', async (c) => {
try {
// In managed mode, return settings from environment variables
if (config.isManaged()) {
const managedSettings = config.getManagedSettings();
const publicSettings = Object.fromEntries(
Object.entries(managedSettings || {}).filter(
([key]) => key in PUBLIC_SETTINGS_FIELDS
)
);
return c.json(publicSettings);
}
let dbSettings = await prisma.instanceSettings.findFirst({
select: PUBLIC_SETTINGS_FIELDS,
});
if (!dbSettings) {
const initialData = {
...Object.fromEntries(
Object.entries(config.get('general')).filter(([, v]) => v !== undefined)
),
...Object.fromEntries(
Object.entries(config.get('security')).filter(([, v]) => v !== undefined)
),
};
dbSettings = await prisma.instanceSettings.create({
data: initialData,
select: PUBLIC_SETTINGS_FIELDS,
});
}
const configSettings = {
...config.get('general'),
...config.get('security'),
};
const filteredConfigSettings = Object.fromEntries(
Object.entries(configSettings).filter(
([key, value]) => value !== undefined && key in PUBLIC_SETTINGS_FIELDS
)
);
const finalSettings = {
...dbSettings,
...filteredConfigSettings,
};
return c.json(finalSettings);
} catch (error) {
console.error('Failed to fetch public instance settings:', error);
return c.json({ error: 'Failed to fetch instance settings' }, 500);
}
});
// GET /api/instance/settings - admin only
app.get('/settings', authMiddleware, checkAdmin, async (c) => {
try {
// In managed mode, return settings from environment variables
if (config.isManaged()) {
const managedSettings = config.getManagedSettings();
return c.json(managedSettings);
}
let dbSettings = await prisma.instanceSettings.findFirst({ select: ADMIN_SETTINGS_FIELDS });
if (!dbSettings) {
const initialData = {
...Object.fromEntries(
Object.entries(config.get('general')).filter(([, v]) => v !== undefined)
),
...Object.fromEntries(
Object.entries(config.get('security')).filter(([, v]) => v !== undefined)
),
};
dbSettings = await prisma.instanceSettings.create({
data: initialData,
select: ADMIN_SETTINGS_FIELDS,
});
}
const configSettings = {
...config.get('general'),
...config.get('security'),
};
const filteredConfigSettings = Object.fromEntries(
Object.entries(configSettings).filter(([, value]) => value !== undefined)
);
const finalSettings = {
...dbSettings,
...filteredConfigSettings,
};
return c.json(finalSettings);
} catch (error) {
console.error('Failed to fetch instance settings:', error);
return c.json({ error: 'Failed to fetch instance settings' }, 500);
}
});
// PUT /api/instance/settings
app.put(
'/settings',
authMiddleware,
checkAdmin,
zValidator('json', instanceSettingsSchema),
async (c) => {
// Block updates in managed mode
if (config.isManaged()) {
return c.json(
{ error: 'Instance is in managed mode. Settings cannot be modified.' },
403
);
}
const body = c.req.valid('json');
if (body.webhookUrl && body.webhookUrl !== '' && !(await isPublicUrl(body.webhookUrl))) {
return c.json({ error: 'Webhook URL cannot point to private/internal addresses' }, 400);
}
try {
const settings = await prisma.instanceSettings.findFirst();
if (!settings) {
return c.json({ error: 'Instance settings not found' }, 404);
}
const updatedSettings = await prisma.instanceSettings.update({
where: { id: settings.id },
data: body,
select: ADMIN_SETTINGS_FIELDS,
});
const currentSettings = settingsCache.get('instanceSettings');
setCachedInstanceSettings({
...currentSettings,
...updatedSettings,
});
return c.json(updatedSettings);
} catch (error) {
console.error('Failed to update instance settings:', error);
return handleNotFound(error as Error & { code?: string }, c);
}
}
);
export default app;

151
api/routes/invites.ts Normal file
View File

@@ -0,0 +1,151 @@
import { zValidator } from '@hono/zod-validator';
import { Hono } from 'hono';
import { nanoid } from 'nanoid';
import { z } from 'zod';
import { auth } from '../auth';
import { TIME } from '../lib/constants';
import prisma from '../lib/db';
import { handleNotFound } from '../lib/utils';
import { authMiddleware, checkAdmin } from '../middlewares/auth';
const createInviteSchema = z.object({
maxUses: z.number().int().min(1).max(100).optional().default(1),
expiresInDays: z.number().int().min(1).max(365).optional(),
});
const codeSchema = z.object({ code: z.string() });
// Public route for validating invite codes (no auth required)
export const invitePublicRoute = new Hono()
.post('/validate', zValidator('json', codeSchema), async (c) => {
const { code } = c.req.valid('json');
try {
const invite = await prisma.inviteCode.findUnique({
where: { code: code.toUpperCase() },
});
if (!invite || !invite.isActive) {
return c.json({ error: 'Invalid invite code' }, 400);
}
if (invite.expiresAt && new Date() > invite.expiresAt) {
return c.json({ error: 'Invite code has expired' }, 400);
}
if (invite.maxUses && invite.uses >= invite.maxUses) {
return c.json({ error: 'Invite code has reached maximum uses' }, 400);
}
return c.json({ valid: true });
} catch (error) {
console.error('Failed to validate invite code:', error);
return c.json({ error: 'Failed to validate invite code' }, 500);
}
})
.post('/use', zValidator('json', z.object({ code: z.string() })), async (c) => {
const { code } = c.req.valid('json');
const user = c.get('user');
if (!user) {
return c.json({ error: 'Unauthorized' }, 401);
}
const userId = user.id;
try {
const invite = await prisma.inviteCode.findUnique({
where: { code: code.toUpperCase() },
});
if (!invite || !invite.isActive) {
return c.json({ error: 'Invalid invite code' }, 400);
}
if (invite.expiresAt && new Date() > invite.expiresAt) {
return c.json({ error: 'Invite code has expired' }, 400);
}
if (invite.maxUses && invite.uses >= invite.maxUses) {
return c.json({ error: 'Invite code has reached maximum uses' }, 400);
}
await prisma.$transaction([
prisma.inviteCode.update({
where: { id: invite.id },
data: { uses: { increment: 1 } },
}),
prisma.user.update({
where: { id: userId },
data: { inviteCodeUsed: code.toUpperCase() },
}),
]);
return c.json({ success: true });
} catch (error) {
console.error('Failed to use invite code:', error);
return c.json({ error: 'Failed to use invite code' }, 500);
}
});
// Protected routes for admin invite management
export const inviteRoute = new Hono<{
Variables: {
user: typeof auth.$Infer.Session.user | null;
};
}>()
.use(authMiddleware)
.use(checkAdmin)
.get('/', async (c) => {
try {
const invites = await prisma.inviteCode.findMany({
orderBy: { createdAt: 'desc' },
});
return c.json(invites);
} catch (error) {
console.error('Failed to list invite codes:', error);
return c.json({ error: 'Failed to list invite codes' }, 500);
}
})
.post('/', zValidator('json', createInviteSchema), async (c) => {
const { maxUses, expiresInDays } = c.req.valid('json');
const user = c.get('user');
if (!user) {
return c.json({ error: 'Unauthorized' }, 401);
}
try {
const code = nanoid(12).toUpperCase();
const expiresAt = expiresInDays
? new Date(Date.now() + expiresInDays * TIME.DAY_MS)
: null;
const invite = await prisma.inviteCode.create({
data: {
code,
maxUses,
expiresAt,
createdBy: user.id,
},
});
return c.json(invite, 201);
} catch (error) {
console.error('Failed to create invite code:', error);
return c.json({ error: 'Failed to create invite code' }, 500);
}
})
.delete('/:id', zValidator('param', z.object({ id: z.string() })), async (c) => {
const { id } = c.req.valid('param');
try {
await prisma.inviteCode.update({
where: { id },
data: { isActive: false },
});
return c.json({ success: true });
} catch (error) {
console.error(`Failed to delete invite code ${id}:`, error);
return handleNotFound(error as Error & { code?: string }, c);
}
});

159
api/routes/metrics.ts Normal file
View File

@@ -0,0 +1,159 @@
import { timingSafeEqual } from 'crypto';
import { Hono } from 'hono';
import { collectDefaultMetrics, Gauge, Histogram, register, Registry } from 'prom-client';
import config from '../config';
import prisma from '../lib/db';
import { getInstanceSettings } from '../lib/settings';
const app = new Hono();
// Create a custom registry
const metricsRegistry = new Registry();
// Collect default Node.js metrics (memory, CPU, event loop, etc.)
collectDefaultMetrics({ register: metricsRegistry });
// Custom application metrics
const activeSecretsGauge = new Gauge({
name: 'hemmelig_secrets_active_count',
help: 'Current number of active (unexpired) secrets',
registers: [metricsRegistry],
});
const totalUsersGauge = new Gauge({
name: 'hemmelig_users_total',
help: 'Total number of registered users',
registers: [metricsRegistry],
});
const visitorsUnique30dGauge = new Gauge({
name: 'hemmelig_visitors_unique_30d',
help: 'Unique visitors in the last 30 days',
registers: [metricsRegistry],
});
const visitorsViews30dGauge = new Gauge({
name: 'hemmelig_visitors_views_30d',
help: 'Total page views in the last 30 days',
registers: [metricsRegistry],
});
const httpRequestDuration = new Histogram({
name: 'hemmelig_http_request_duration_seconds',
help: 'Duration of HTTP requests in seconds',
labelNames: ['method', 'route', 'status_code'],
buckets: [0.01, 0.05, 0.1, 0.5, 1, 2, 5],
registers: [metricsRegistry],
});
// Function to update gauge metrics from database
async function updateGaugeMetrics() {
try {
const now = new Date();
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
// Count active secrets (not expired)
const activeSecrets = await prisma.secrets.count({
where: {
expiresAt: {
gt: now,
},
},
});
activeSecretsGauge.set(activeSecrets);
// Count total users
const totalUsers = await prisma.user.count();
totalUsersGauge.set(totalUsers);
// Get visitor stats for the last 30 days
const visitorStats = await prisma.$queryRaw<
Array<{ unique_visitors: bigint; total_views: bigint }>
>`
SELECT
COUNT(DISTINCT uniqueId) as unique_visitors,
COUNT(*) as total_views
FROM visitor_analytics
WHERE timestamp >= ${thirtyDaysAgo}
`;
if (visitorStats.length > 0) {
visitorsUnique30dGauge.set(Number(visitorStats[0].unique_visitors));
visitorsViews30dGauge.set(Number(visitorStats[0].total_views));
}
} catch (error) {
console.error('Failed to update metrics gauges:', error);
}
}
// Helper function to verify Bearer token using constant-time comparison
function verifyBearerToken(authHeader: string | undefined, expectedSecret: string): boolean {
if (!authHeader || !expectedSecret) {
return false;
}
const parts = authHeader.split(' ');
if (parts.length !== 2 || parts[0] !== 'Bearer') {
return false;
}
const provided = Buffer.from(parts[1]);
const expected = Buffer.from(expectedSecret);
// Pad to same length to prevent timing leaks on token length
const maxLen = Math.max(provided.length, expected.length);
const paddedProvided = Buffer.alloc(maxLen);
const paddedExpected = Buffer.alloc(maxLen);
provided.copy(paddedProvided);
expected.copy(paddedExpected);
// Constant-time comparison to prevent timing attacks
return timingSafeEqual(paddedProvided, paddedExpected) && provided.length === expected.length;
}
// GET /api/metrics - Prometheus metrics endpoint
app.get('/', async (c) => {
try {
// In managed mode, use environment-based settings; otherwise use database
const settings = config.isManaged()
? config.getManagedSettings()
: await getInstanceSettings();
// Check if metrics are enabled
if (!settings?.metricsEnabled) {
return c.json({ error: 'Metrics endpoint is disabled' }, 404);
}
// Verify authentication if secret is configured
if (settings.metricsSecret) {
const authHeader = c.req.header('Authorization');
if (!verifyBearerToken(authHeader, settings.metricsSecret)) {
return c.json({ error: 'Unauthorized' }, 401);
}
}
// Update gauge metrics before returning
await updateGaugeMetrics();
// Get metrics in Prometheus format
const metrics = await metricsRegistry.metrics();
return c.text(metrics, 200, {
'Content-Type': register.contentType,
});
} catch (error) {
console.error('Failed to generate metrics:', error);
return c.json({ error: 'Failed to generate metrics' }, 500);
}
});
export function observeHttpRequest(
method: string,
route: string,
statusCode: number,
duration: number
) {
httpRequestDuration.labels(method, route, String(statusCode)).observe(duration);
}
export default app;

View 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;

343
api/routes/secrets.ts Normal file
View File

@@ -0,0 +1,343 @@
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;

82
api/routes/setup.ts Normal file
View File

@@ -0,0 +1,82 @@
import { zValidator } from '@hono/zod-validator';
import { Hono } from 'hono';
import { z } from 'zod';
import { auth } from '../auth';
import prisma from '../lib/db';
import { passwordSchema } from '../validations/password';
const setupSchema = z.object({
email: z.string().email(),
password: passwordSchema,
username: z.string().min(3).max(32),
name: z.string().min(1).max(100),
});
const app = new Hono()
// Check if setup is needed (no users exist)
.get('/status', async (c) => {
try {
const userCount = await prisma.user.count();
return c.json({
needsSetup: userCount === 0,
});
} catch (error) {
console.error('Failed to check setup status:', error);
return c.json({ error: 'Failed to check setup status' }, 500);
}
})
// Complete initial setup - create first admin user
.post('/complete', zValidator('json', setupSchema), async (c) => {
try {
// Check if any users already exist
const userCount = await prisma.user.count();
if (userCount > 0) {
return c.json({ error: 'Setup already completed' }, 403);
}
const { email, password, username, name } = c.req.valid('json');
// Create the admin user using better-auth
const result = await auth.api.signUpEmail({
body: {
email,
password,
name,
username,
},
});
if (!result.user) {
return c.json({ error: 'Failed to create admin user' }, 500);
}
// Update user to be admin
await prisma.user.update({
where: { id: result.user.id },
data: { role: 'admin' },
});
// Create initial instance settings if not exists
const existingSettings = await prisma.instanceSettings.findFirst();
if (!existingSettings) {
await prisma.instanceSettings.create({
data: {},
});
}
return c.json({
success: true,
message: 'Setup completed successfully',
});
} catch (error) {
console.error('Failed to complete setup:', error);
return c.json(
{
error: 'Failed to complete setup',
},
500
);
}
});
export default app;

89
api/routes/user.ts Normal file
View File

@@ -0,0 +1,89 @@
import { zValidator } from '@hono/zod-validator';
import { Hono } from 'hono';
import { z } from 'zod';
import prisma from '../lib/db';
import { checkAdmin } from '../middlewares/auth';
import { updateUserSchema } from '../validations/user';
export const userRoute = new Hono()
.use(checkAdmin)
.get(
'/',
zValidator(
'query',
z.object({
page: z.coerce.number().min(1).default(1),
pageSize: z.coerce.number().min(1).max(100).default(10),
search: z.string().max(100).optional(),
})
),
async (c) => {
const { page, pageSize, search } = c.req.valid('query');
const skip = (page - 1) * pageSize;
const where = search
? {
OR: [
{ username: { contains: search } },
{ email: { contains: search } },
{ name: { contains: search } },
],
}
: {};
const [users, total] = await Promise.all([
prisma.user.findMany({
where,
skip,
take: pageSize,
orderBy: { createdAt: 'desc' },
select: {
id: true,
username: true,
email: true,
role: true,
banned: true,
createdAt: true,
},
}),
prisma.user.count({ where }),
]);
return c.json({
users,
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize),
});
}
)
.put(
'/:id',
zValidator('param', z.object({ id: z.string() })),
zValidator('json', updateUserSchema),
async (c) => {
const { id } = c.req.valid('param');
const { username, email } = c.req.valid('json');
const data = {
...(username && { username }),
...(email && { email }),
};
const user = await prisma.user.update({
where: { id },
data,
select: {
id: true,
username: true,
email: true,
role: true,
banned: true,
createdAt: true,
},
});
return c.json(user);
}
);