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

158
api/app.ts Normal file
View File

@@ -0,0 +1,158 @@
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { csrf } from 'hono/csrf';
import { etag, RETAINED_304_HEADERS } from 'hono/etag';
import { HTTPException } from 'hono/http-exception';
import { logger } from 'hono/logger';
import { requestId } from 'hono/request-id';
import { secureHeaders } from 'hono/secure-headers';
import { timeout } from 'hono/timeout';
import { trimTrailingSlash } from 'hono/trailing-slash';
import { ZodError } from 'zod';
import { auth } from './auth';
import config from './config';
import startJobs from './jobs';
import prisma from './lib/db';
import ratelimit from './middlewares/ratelimit';
import routes from './routes';
// Initialize Hono app
const app = new Hono<{
Variables: {
user: typeof auth.$Infer.Session.user | null;
session: typeof auth.$Infer.Session.session | null;
};
}>();
// Global error handler
app.onError((err, c) => {
const requestId = c.get('requestId') || 'unknown';
// Handle Zod validation errors
if (err instanceof ZodError) {
console.error(`[${requestId}] Validation error:`, err.flatten());
return c.json(
{
error: 'Validation failed',
details: err.flatten().fieldErrors,
},
400
);
}
// Handle HTTP exceptions (thrown by Hono or middleware)
if (err instanceof HTTPException) {
console.error(`[${requestId}] HTTP exception:`, {
status: err.status,
message: err.message,
});
return c.json({ error: err.message }, err.status);
}
// Handle all other errors
console.error(`[${requestId}] Unhandled error:`, {
error: err.message,
stack: err.stack,
});
// Don't expose internal error details in production
return c.json({ error: 'Internal server error' }, 500);
});
// Handle 404 - route not found
app.notFound((c) => {
return c.json({ error: 'Not found' }, 404);
});
// Start the background jobs
startJobs();
// Add the middlewares
// More middlewares can be found here:
// https://hono.dev/docs/middleware/builtin/basic-auth
app.use(secureHeaders());
app.use(logger());
app.use(trimTrailingSlash());
app.use(`/*`, requestId());
app.use(`/*`, timeout(15 * 1000)); // 15 seconds timeout to the API calls
app.use(ratelimit);
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/ETag
app.use(
`/*`,
etag({
retainedHeaders: ['x-message', ...RETAINED_304_HEADERS],
})
);
// Configure CORS with trusted origins
const trustedOrigins = config.get<string[]>('trustedOrigins', []);
app.use(
`/*`,
cors({
origin: trustedOrigins,
credentials: true,
})
);
// Configure CSRF protection (exclude auth routes for OAuth callbacks)
app.use('/*', async (c, next) => {
// Skip CSRF for auth routes (OAuth callbacks come from external origins)
if (c.req.path.startsWith('/auth/')) {
return next();
}
return csrf({
origin: trustedOrigins,
})(c, next);
});
// Custom middlewares
app.use('*', async (c, next) => {
const session = await auth.api.getSession({ headers: c.req.raw.headers });
if (!session) {
c.set('user', null);
c.set('session', null);
return next();
}
c.set('user', session.user);
c.set('session', session.session);
return next();
});
// Add the routes
app.on(['POST', 'GET'], `/auth/*`, (c) => {
return auth.handler(c.req.raw);
});
// Add the application routes
app.route('/', routes);
// https://hono.dev/docs/guides/rpc#rpc
export type AppType = typeof routes;
export default app;
// Handle graceful shutdown
process.on('SIGINT', async () => {
await prisma.$disconnect();
console.info('Disconnected from database');
process.exit(0);
});
// Handle uncaught exceptions
process.on('uncaughtException', (error: Error) => {
console.error('Uncaught Exception', {
error: error.message,
stack: error.stack,
});
process.exit(1);
});
// Handle unhandled promise rejections
process.on('unhandledRejection', (reason: unknown) => {
console.error('Unhandled Rejection', { reason });
process.exit(1);
});

176
api/auth.ts Normal file
View File

@@ -0,0 +1,176 @@
import { betterAuth } from 'better-auth';
import { prismaAdapter } from 'better-auth/adapters/prisma';
import { APIError } from 'better-auth/api';
import { admin, twoFactor, username } from 'better-auth/plugins';
import { genericOAuth } from 'better-auth/plugins/generic-oauth';
import { randomBytes } from 'crypto';
import config, { type SocialProviderConfig } from './config';
import prisma from './lib/db';
import { validatePassword } from './validations/password';
// Generate a unique username from email
const generateUsernameFromEmail = (email: string): string => {
const localPart = email.split('@')[0] || 'user';
// Sanitize: only keep alphanumeric characters and underscores
const sanitized = localPart.replace(/[^a-zA-Z0-9_]/g, '_').toLowerCase();
// Add random suffix to ensure uniqueness (cryptographically secure)
const randomSuffix = randomBytes(4).toString('hex').substring(0, 6);
return `${sanitized}_${randomSuffix}`;
};
// Build better-auth social providers configuration dynamically
const buildBetterAuthSocialProviders = () => {
const providers = config.getSocialProviders();
const betterAuthProviders: Record<
string,
{
clientId: string;
clientSecret: string;
tenantId?: string;
issuer?: string;
mapProfileToUser?: (profile: { email?: string; name?: string }) => { username: string };
}
> = {};
for (const [provider, providerConfig] of Object.entries(providers)) {
const typedConfig = providerConfig as SocialProviderConfig;
betterAuthProviders[provider] = {
clientId: typedConfig.clientId,
clientSecret: typedConfig.clientSecret,
...(typedConfig.tenantId && { tenantId: typedConfig.tenantId }),
...(typedConfig.issuer && { issuer: typedConfig.issuer }),
mapProfileToUser: (profile) => ({
username: generateUsernameFromEmail(profile.email || profile.name || 'user'),
}),
};
}
return betterAuthProviders;
};
// Build better-auth plugins array
const buildPlugins = () => {
const plugins: any[] = [username(), admin(), twoFactor()];
const genericProviders = config.getGenericOAuthProviders();
if (genericProviders.length > 0) {
plugins.push(
genericOAuth({
config: genericProviders.map((provider) => ({
...provider,
// Map profile to include username
mapProfileToUser: (profile: any) => ({
username: generateUsernameFromEmail(
profile.email || profile.name || 'user'
),
}),
})),
})
);
}
return plugins;
};
export const auth = betterAuth({
appName: 'Hemmelig',
baseURL: process.env.BETTER_AUTH_URL || 'http://localhost:3000',
database: prismaAdapter(prisma, {
provider: 'sqlite',
}),
emailAndPassword: {
enabled: true,
// Set to 1 so better-auth doesn't reject weak current passwords during password change.
// Password strength for new passwords is enforced by our Zod schema (updatePasswordSchema)
// and for sign-up by the before hook below.
minPasswordLength: 1,
},
socialProviders: buildBetterAuthSocialProviders(),
account: {
accountLinking: {
enabled: true,
trustedProviders: [
'gitlab',
'github',
'google',
'microsoft',
'discord',
'apple',
'twitter',
// Add all generic OAuth provider IDs as trusted
...config.getGenericOAuthProviders().map((p) => p.providerId),
],
},
},
plugins: buildPlugins(),
trustedOrigins: config.get('trustedOrigins'),
hooks: {
before: async (context) => {
// Only apply validation to email/password sign-up
if (context.path !== '/sign-up/email') {
return;
}
const body = context.body as { email?: string; password?: string };
const email = body?.email;
const password = body?.password;
if (!email) {
return;
}
// Validate password strength for sign-up
if (password) {
const passwordError = validatePassword(password);
if (passwordError) {
throw new APIError('BAD_REQUEST', { message: passwordError });
}
}
// Get instance settings
const settings = await prisma.instanceSettings.findFirst({
select: { allowedEmailDomains: true, disableEmailPasswordSignup: true },
});
// Check if email/password signup is disabled
if (settings?.disableEmailPasswordSignup) {
throw new APIError('FORBIDDEN', {
message: 'Email/password registration is disabled. Please use social login.',
});
}
const allowedDomains = settings?.allowedEmailDomains?.trim();
// If no domains configured, allow all
if (!allowedDomains) {
return;
}
// Parse comma-separated domains
const domains = allowedDomains
.split(',')
.map((d) => d.trim().toLowerCase())
.filter((d) => d.length > 0);
if (domains.length === 0) {
return;
}
// Extract domain from email
const emailDomain = email.split('@')[1]?.toLowerCase();
if (!emailDomain || !domains.includes(emailDomain)) {
throw new APIError('FORBIDDEN', {
message: 'Email domain not allowed',
});
}
},
},
});
// Export enabled social providers for frontend consumption
export const getEnabledSocialProviders = (): string[] => {
const standardProviders = Object.keys(config.getSocialProviders());
const genericProviders = config.getGenericOAuthProviders().map((p) => p.providerId);
return [...standardProviders, ...genericProviders];
};

255
api/config.ts Normal file
View File

@@ -0,0 +1,255 @@
import dlv from 'dlv';
const isProduction = process.env.NODE_ENV === 'production';
// Helper to parse boolean from env, returns undefined if not set
const parseBoolean = (value: string | undefined): boolean | undefined => {
if (value === undefined || value === null || value === '') return undefined;
return value.toLowerCase() === 'true';
};
// Helper to parse integer from env, returns undefined if not set
const parseInteger = (value: string | undefined): number | undefined => {
if (value === undefined || value === null || value === '') return undefined;
const parsed = parseInt(value, 10);
return isNaN(parsed) ? undefined : parsed;
};
// Social provider configuration type
export interface SocialProviderConfig {
clientId: string;
clientSecret: string;
tenantId?: string; // For Microsoft/Azure AD
issuer?: string; // For self-hosted instances (e.g., GitLab)
}
// Generic OAuth provider configuration type (for better-auth genericOAuth plugin)
export interface GenericOAuthProviderConfig {
providerId: string;
discoveryUrl?: string;
authorizationUrl?: string;
tokenUrl?: string;
userInfoUrl?: string;
clientId: string;
clientSecret: string;
scopes?: string[];
pkce?: boolean;
}
// Build social providers config dynamically from env vars
const buildSocialProviders = () => {
const providers: Record<string, SocialProviderConfig> = {};
// GitHub
if (process.env.HEMMELIG_AUTH_GITHUB_ID && process.env.HEMMELIG_AUTH_GITHUB_SECRET) {
providers.github = {
clientId: process.env.HEMMELIG_AUTH_GITHUB_ID,
clientSecret: process.env.HEMMELIG_AUTH_GITHUB_SECRET,
};
}
// Google
if (process.env.HEMMELIG_AUTH_GOOGLE_ID && process.env.HEMMELIG_AUTH_GOOGLE_SECRET) {
providers.google = {
clientId: process.env.HEMMELIG_AUTH_GOOGLE_ID,
clientSecret: process.env.HEMMELIG_AUTH_GOOGLE_SECRET,
};
}
// Microsoft (Azure AD)
if (process.env.HEMMELIG_AUTH_MICROSOFT_ID && process.env.HEMMELIG_AUTH_MICROSOFT_SECRET) {
providers.microsoft = {
clientId: process.env.HEMMELIG_AUTH_MICROSOFT_ID,
clientSecret: process.env.HEMMELIG_AUTH_MICROSOFT_SECRET,
tenantId: process.env.HEMMELIG_AUTH_MICROSOFT_TENANT_ID,
};
}
// Discord
if (process.env.HEMMELIG_AUTH_DISCORD_ID && process.env.HEMMELIG_AUTH_DISCORD_SECRET) {
providers.discord = {
clientId: process.env.HEMMELIG_AUTH_DISCORD_ID,
clientSecret: process.env.HEMMELIG_AUTH_DISCORD_SECRET,
};
}
// GitLab
if (process.env.HEMMELIG_AUTH_GITLAB_ID && process.env.HEMMELIG_AUTH_GITLAB_SECRET) {
providers.gitlab = {
clientId: process.env.HEMMELIG_AUTH_GITLAB_ID,
clientSecret: process.env.HEMMELIG_AUTH_GITLAB_SECRET,
issuer: process.env.HEMMELIG_AUTH_GITLAB_ISSUER,
};
}
// Apple
if (process.env.HEMMELIG_AUTH_APPLE_ID && process.env.HEMMELIG_AUTH_APPLE_SECRET) {
providers.apple = {
clientId: process.env.HEMMELIG_AUTH_APPLE_ID,
clientSecret: process.env.HEMMELIG_AUTH_APPLE_SECRET,
};
}
// Twitter/X
if (process.env.HEMMELIG_AUTH_TWITTER_ID && process.env.HEMMELIG_AUTH_TWITTER_SECRET) {
providers.twitter = {
clientId: process.env.HEMMELIG_AUTH_TWITTER_ID,
clientSecret: process.env.HEMMELIG_AUTH_TWITTER_SECRET,
};
}
return providers;
};
// Build generic OAuth providers from JSON env var
const buildGenericOAuthProviders = (): GenericOAuthProviderConfig[] => {
const genericOAuthEnv = process.env.HEMMELIG_AUTH_GENERIC_OAUTH;
if (!genericOAuthEnv) {
return [];
}
try {
const parsed = JSON.parse(genericOAuthEnv);
if (!Array.isArray(parsed)) {
console.error('HEMMELIG_AUTH_GENERIC_OAUTH must be a JSON array');
return [];
}
// Validate each provider config
return parsed.filter((provider: any) => {
if (!provider.providerId || !provider.clientId || !provider.clientSecret) {
console.error(
`Invalid generic OAuth provider config: missing required fields (providerId, clientId, or clientSecret)`
);
return false;
}
// Must have either discoveryUrl OR all three URLs (authorization, token, userInfo)
const hasDiscoveryUrl = !!provider.discoveryUrl;
const hasManualUrls = !!(
provider.authorizationUrl &&
provider.tokenUrl &&
provider.userInfoUrl
);
if (!hasDiscoveryUrl && !hasManualUrls) {
console.error(
`Invalid generic OAuth provider config for "${provider.providerId}": must provide either discoveryUrl OR all of (authorizationUrl, tokenUrl, userInfoUrl)`
);
return false;
}
return true;
}) as GenericOAuthProviderConfig[];
} catch (error) {
console.error('Failed to parse HEMMELIG_AUTH_GENERIC_OAUTH:', error);
return [];
}
};
const socialProviders = buildSocialProviders();
const genericOAuthProviders = buildGenericOAuthProviders();
// Managed mode: all settings are controlled via environment variables
const isManaged = parseBoolean(process.env.HEMMELIG_MANAGED) ?? false;
// Managed mode settings (only used when HEMMELIG_MANAGED=true)
const managedSettings = isManaged
? {
// General settings
instanceName: process.env.HEMMELIG_INSTANCE_NAME ?? '',
instanceDescription: process.env.HEMMELIG_INSTANCE_DESCRIPTION ?? '',
instanceLogo: process.env.HEMMELIG_INSTANCE_LOGO ?? '',
allowRegistration: parseBoolean(process.env.HEMMELIG_ALLOW_REGISTRATION) ?? true,
requireEmailVerification:
parseBoolean(process.env.HEMMELIG_REQUIRE_EMAIL_VERIFICATION) ?? false,
defaultSecretExpiration:
parseInteger(process.env.HEMMELIG_DEFAULT_SECRET_EXPIRATION) ?? 72,
maxSecretSize: parseInteger(process.env.HEMMELIG_MAX_SECRET_SIZE) ?? 1024,
importantMessage: process.env.HEMMELIG_IMPORTANT_MESSAGE ?? '',
// Security settings
allowPasswordProtection:
parseBoolean(process.env.HEMMELIG_ALLOW_PASSWORD_PROTECTION) ?? true,
allowIpRestriction: parseBoolean(process.env.HEMMELIG_ALLOW_IP_RESTRICTION) ?? true,
enableRateLimiting: parseBoolean(process.env.HEMMELIG_ENABLE_RATE_LIMITING) ?? true,
rateLimitRequests: parseInteger(process.env.HEMMELIG_RATE_LIMIT_REQUESTS) ?? 100,
rateLimitWindow: parseInteger(process.env.HEMMELIG_RATE_LIMIT_WINDOW) ?? 60,
// Organization settings
requireInviteCode: parseBoolean(process.env.HEMMELIG_REQUIRE_INVITE_CODE) ?? false,
allowedEmailDomains: process.env.HEMMELIG_ALLOWED_EMAIL_DOMAINS ?? '',
requireRegisteredUser:
parseBoolean(process.env.HEMMELIG_REQUIRE_REGISTERED_USER) ?? false,
disableEmailPasswordSignup:
parseBoolean(process.env.HEMMELIG_DISABLE_EMAIL_PASSWORD_SIGNUP) ?? false,
// Webhook settings
webhookEnabled: parseBoolean(process.env.HEMMELIG_WEBHOOK_ENABLED) ?? false,
webhookUrl: process.env.HEMMELIG_WEBHOOK_URL ?? '',
webhookSecret: process.env.HEMMELIG_WEBHOOK_SECRET ?? '',
webhookOnView: parseBoolean(process.env.HEMMELIG_WEBHOOK_ON_VIEW) ?? true,
webhookOnBurn: parseBoolean(process.env.HEMMELIG_WEBHOOK_ON_BURN) ?? true,
// Metrics settings
metricsEnabled: parseBoolean(process.env.HEMMELIG_METRICS_ENABLED) ?? false,
metricsSecret: process.env.HEMMELIG_METRICS_SECRET ?? '',
// File upload settings
allowFileUploads: parseBoolean(process.env.HEMMELIG_ALLOW_FILE_UPLOADS) ?? true,
}
: null;
const config = {
server: {
port: Number(process.env.HEMMELIG_PORT) || 3000,
},
trustedOrigins: [
...(!isProduction ? ['http://localhost:5173'] : []),
process.env.HEMMELIG_TRUSTED_ORIGIN || '',
].filter(Boolean),
general: {
instanceName: process.env.HEMMELIG_INSTANCE_NAME,
instanceDescription: process.env.HEMMELIG_INSTANCE_DESCRIPTION,
instanceLogo: process.env.HEMMELIG_INSTANCE_LOGO,
allowRegistration: parseBoolean(process.env.HEMMELIG_ALLOW_REGISTRATION),
},
security: {
allowPasswordProtection: parseBoolean(process.env.HEMMELIG_ALLOW_PASSWORD_PROTECTION),
allowIpRestriction: parseBoolean(process.env.HEMMELIG_ALLOW_IP_RESTRICTION),
},
analytics: {
enabled: parseBoolean(process.env.HEMMELIG_ANALYTICS_ENABLED) ?? true,
hmacSecret:
process.env.HEMMELIG_ANALYTICS_HMAC_SECRET || 'default-analytics-secret-change-me',
},
socialProviders,
};
if (!process.env.HEMMELIG_ANALYTICS_HMAC_SECRET && config.analytics.enabled) {
console.warn(
'WARNING: HEMMELIG_ANALYTICS_HMAC_SECRET is not set. Analytics visitor IDs are generated ' +
'with a default secret, making them predictable. Set a random secret for production use.'
);
}
/**
* A type-safe utility to get a value from the configuration.
* Its return type is inferred from the type of the default value.
* @param path The dot-notation path to the config value (e.g., 'server.port').
* @param defaultValue A default value to return if the path is not found.
* @returns The found configuration value or the default value.
*/
function get<T>(path: string, defaultValue?: T): T {
return dlv(config, path, defaultValue) as T;
}
// Export the get function and social providers helper
export default {
get,
getSocialProviders: () => config.socialProviders,
getGenericOAuthProviders: () => genericOAuthProviders,
isManaged: () => isManaged,
getManagedSettings: () => managedSettings,
};

67
api/jobs/expired.ts Normal file
View File

@@ -0,0 +1,67 @@
import { unlink } from 'fs/promises';
import prisma from '../lib/db';
export const deleteExpiredSecrets = async () => {
try {
const now = new Date();
await prisma.secrets.deleteMany({
where: {
OR: [
{
expiresAt: {
lte: now,
},
},
{
views: 0,
},
],
},
});
} catch (error) {
console.error('Error deleting expired secrets:', error);
}
};
export const deleteOrphanedFiles = async () => {
try {
// Find files that are not associated with any secret
const orphanedFiles = await prisma.file.findMany({
where: {
secrets: {
none: {},
},
},
});
if (orphanedFiles.length === 0) {
return;
}
// Delete files from disk in parallel for better performance
const deleteResults = await Promise.allSettled(
orphanedFiles.map((file) => unlink(file.path))
);
// Log any failures (file may already be deleted or inaccessible)
deleteResults.forEach((result, index) => {
if (result.status === 'rejected') {
console.error(
`Failed to delete file from disk: ${orphanedFiles[index].path}`,
result.reason
);
}
});
// Delete orphaned file records from database
await prisma.file.deleteMany({
where: {
id: {
in: orphanedFiles.map((f) => f.id),
},
},
});
} catch (error) {
console.error('Error deleting orphaned files:', error);
}
};

14
api/jobs/index.ts Normal file
View File

@@ -0,0 +1,14 @@
import { Cron } from 'croner';
import { deleteExpiredSecrets, deleteOrphanedFiles } from './expired';
// https://crontab.guru
export default function startJobs() {
// This function can be used to start any other jobs in the future
console.log('Job scheduler initialized.');
// Running every minute
new Cron('* * * * *', async () => {
await deleteExpiredSecrets();
await deleteOrphanedFiles();
});
}

62
api/lib/analytics.ts Normal file
View File

@@ -0,0 +1,62 @@
import { createHmac } from 'crypto';
import config from '../config';
const analyticsConfig = config.get('analytics') as { enabled: boolean; hmacSecret: string };
/**
* Creates a unique, anonymous visitor ID using HMAC-SHA256.
* This ensures privacy by never storing the raw IP address.
*/
export function createVisitorId(ip: string, userAgent: string): string {
return createHmac('sha256', analyticsConfig.hmacSecret)
.update(ip + userAgent)
.digest('hex');
}
/**
* Validates that a path is safe for analytics tracking.
* Prevents injection of malicious paths.
*/
export function isValidAnalyticsPath(path: string): boolean {
const pathRegex = /^\/[a-zA-Z0-9\-?=&/#]*$/;
return pathRegex.test(path) && path.length <= 255;
}
/**
* Checks if analytics tracking is enabled.
*/
export function isAnalyticsEnabled(): boolean {
return analyticsConfig.enabled;
}
/**
* Calculates the start date for a given time range.
* @param timeRange - Time range string (7d, 14d, 30d)
* @returns Start date for the query
*/
export function getStartDateForTimeRange(timeRange: '7d' | '14d' | '30d'): Date {
const now = new Date();
const startDate = new Date();
switch (timeRange) {
case '7d':
startDate.setDate(now.getDate() - 7);
break;
case '14d':
startDate.setDate(now.getDate() - 14);
break;
case '30d':
startDate.setDate(now.getDate() - 30);
break;
}
return startDate;
}
/**
* Calculates percentage with fixed decimal places, returning 0 if total is 0.
*/
export function calculatePercentage(value: number, total: number, decimals = 2): number {
if (total === 0) return 0;
return parseFloat(((value / total) * 100).toFixed(decimals));
}

85
api/lib/constants.ts Normal file
View File

@@ -0,0 +1,85 @@
/**
* Time constants in milliseconds
*/
export const TIME = {
/** One second in milliseconds */
SECOND_MS: 1000,
/** One minute in milliseconds */
MINUTE_MS: 60 * 1000,
/** One hour in milliseconds */
HOUR_MS: 60 * 60 * 1000,
/** One day in milliseconds */
DAY_MS: 24 * 60 * 60 * 1000,
} as const;
/**
* Secret-related constants
*/
export const SECRET = {
/** Grace period for file downloads after last view (5 minutes) */
FILE_DOWNLOAD_GRACE_PERIOD_MS: 5 * TIME.MINUTE_MS,
} as const;
/**
* File upload constants
*/
export const FILE = {
/** Default max file size in KB (10MB) */
DEFAULT_MAX_SIZE_KB: 10240,
} as const;
/**
* Valid secret expiration times in seconds
*/
export const EXPIRATION_TIMES_SECONDS = [
2419200, // 28 days
1209600, // 14 days
604800, // 7 days
259200, // 3 days
86400, // 1 day
43200, // 12 hours
14400, // 4 hours
3600, // 1 hour
1800, // 30 minutes
300, // 5 minutes
] as const;
/**
* Instance settings fields - public (safe for all users)
*/
export const PUBLIC_SETTINGS_FIELDS = {
instanceName: true,
instanceDescription: true,
instanceLogo: true,
allowRegistration: true,
defaultSecretExpiration: true,
maxSecretSize: true,
allowPasswordProtection: true,
allowIpRestriction: true,
allowFileUploads: true,
requireRegisteredUser: true,
importantMessage: true,
disableEmailPasswordSignup: true,
} as const;
/**
* Instance settings fields - admin only (all fields)
*/
export const ADMIN_SETTINGS_FIELDS = {
...PUBLIC_SETTINGS_FIELDS,
requireEmailVerification: true,
enableRateLimiting: true,
rateLimitRequests: true,
rateLimitWindow: true,
requireInviteCode: true,
allowedEmailDomains: true,
disableEmailPasswordSignup: true,
webhookEnabled: true,
webhookUrl: true,
webhookSecret: true,
webhookOnView: true,
webhookOnBurn: true,
importantMessage: true,
metricsEnabled: true,
metricsSecret: true,
} as const;

19
api/lib/db.ts Normal file
View File

@@ -0,0 +1,19 @@
import { PrismaBetterSqlite3 } from '@prisma/adapter-better-sqlite3';
import { PrismaClient } from '../../prisma/generated/prisma/client.js';
const prismaClientSingleton = () => {
const adapter = new PrismaBetterSqlite3({
url: process.env.DATABASE_URL || 'file:./database/hemmelig.db',
});
return new PrismaClient({ adapter });
};
declare global {
var prisma: undefined | ReturnType<typeof prismaClientSingleton>;
}
const db = globalThis.prisma ?? prismaClientSingleton();
export default db;
if (process.env.NODE_ENV !== 'production') globalThis.prisma = db;

77
api/lib/files.ts Normal file
View File

@@ -0,0 +1,77 @@
import { mkdir } from 'fs/promises';
import { basename, join, resolve } from 'path';
import { FILE } from './constants';
import settingsCache from './settings';
/** Upload directory path */
export const UPLOAD_DIR = resolve(process.cwd(), 'uploads');
/**
* Sanitizes a filename by removing path traversal sequences and directory separators.
* Returns only the base filename to prevent directory escape attacks.
*/
export function sanitizeFilename(filename: string): string {
// Get only the base filename, stripping any directory components
const base = basename(filename);
// Remove any remaining null bytes or other dangerous characters
return base.replace(/[\x00-\x1f]/g, '');
}
/**
* Validates that a file path is safely within the upload directory.
* Prevents path traversal attacks by checking the resolved absolute path.
*/
export function isPathSafe(filePath: string): boolean {
const resolvedPath = resolve(filePath);
return resolvedPath.startsWith(UPLOAD_DIR + '/') || resolvedPath === UPLOAD_DIR;
}
/**
* Gets max file size from instance settings (in KB), converted to bytes.
* Defaults to 10MB if not configured.
*/
export function getMaxFileSize(): number {
const settings = settingsCache.get('instanceSettings');
const maxSecretSizeKB = settings?.maxSecretSize ?? FILE.DEFAULT_MAX_SIZE_KB;
return maxSecretSizeKB * 1024; // Convert KB to bytes
}
/**
* Ensures the upload directory exists, creating it if necessary.
*/
export async function ensureUploadDir(): Promise<void> {
try {
await mkdir(UPLOAD_DIR, { recursive: true });
} catch (error) {
console.error('Failed to create upload directory:', error);
}
}
/**
* Generates a safe file path within the upload directory.
* @param id - Unique identifier for the file
* @param originalFilename - Original filename to sanitize
* @returns Object with sanitized filename and full path, or null if invalid
*/
export function generateSafeFilePath(
id: string,
originalFilename: string
): { filename: string; path: string } | null {
const safeFilename = sanitizeFilename(originalFilename);
if (!safeFilename) {
return null;
}
const filename = `${id}-${safeFilename}`;
const path = join(UPLOAD_DIR, filename);
// Verify path is safe
if (!isPathSafe(path)) {
return null;
}
return { filename, path };
}
// Initialize upload directory on module load
ensureUploadDir();

49
api/lib/password.ts Normal file
View File

@@ -0,0 +1,49 @@
import * as argon2 from 'argon2';
/**
* Hashes a password using Argon2id.
* Uses Bun.password if available, otherwise falls back to argon2 npm package.
* @param password The plain-text password to hash.
* @returns A promise that resolves to the hashed password.
* @throws Will throw an error if hashing fails.
*/
export async function hash(password: string): Promise<string> {
try {
// Try Bun's native password hashing first (uses Argon2)
if (typeof Bun !== 'undefined' && Bun.password) {
return await Bun.password.hash(password);
}
// Fallback to argon2 npm package (Argon2id)
return await argon2.hash(password, {
type: argon2.argon2id,
memoryCost: 65536, // 64 MB
timeCost: 3,
parallelism: 4,
});
} catch (error) {
console.error('Error during password hashing:', error);
throw new Error('Error hashing the password.');
}
}
/**
* Compares a plain-text password with a hash.
* @param password The plain-text password to compare.
* @param storedHash The hash to compare against.
* @returns A promise that resolves to true if the password matches the hash, otherwise false.
*/
export async function compare(password: string, storedHash: string): Promise<boolean> {
try {
// Try Bun's native password verification first
if (typeof Bun !== 'undefined' && Bun.password) {
return await Bun.password.verify(password, storedHash);
}
// Fallback to argon2 npm package
return await argon2.verify(storedHash, password);
} catch (error) {
console.error('Error during password comparison:', error);
return false;
}
}

40
api/lib/settings.ts Normal file
View File

@@ -0,0 +1,40 @@
import prisma from './db';
const settingsCache = new Map();
/**
* Gets instance settings, fetching from database if not cached.
* Use this utility to avoid duplicating the cache-check pattern.
*/
export async function getInstanceSettings() {
let cachedSettings = settingsCache.get('instanceSettings');
if (!cachedSettings) {
try {
cachedSettings = await prisma.instanceSettings.findFirst();
if (cachedSettings) {
settingsCache.set('instanceSettings', cachedSettings);
}
} catch {
// Table may not exist yet (fresh database)
return null;
}
}
return cachedSettings;
}
/**
* Updates the cached instance settings.
* Call this after modifying settings in the database.
*/
export function setCachedInstanceSettings(settings: unknown) {
settingsCache.set('instanceSettings', settings);
}
/**
* Gets the raw settings cache (for direct access when needed).
*/
export function getSettingsCache() {
return settingsCache;
}
export default settingsCache;

130
api/lib/utils.ts Normal file
View File

@@ -0,0 +1,130 @@
import dns from 'dns/promises';
import { type Context } from 'hono';
import { isIP } from 'is-ip';
/**
* Handle not found error from Prisma
* @param error Error from Prisma operation
* @param c Hono context
* @returns JSON error response
*/
export const handleNotFound = (error: Error & { code?: string }, c: Context) => {
// Handle record not found error (Prisma P2025)
if (error?.code === 'P2025') {
return c.json({ error: 'Not found' }, 404);
}
// Handle other errors
return c.json(
{
error: 'Failed to process the operation',
},
500
);
};
/**
* Get client IP from request headers
* @param c Hono context
* @returns Client IP address
*/
export const getClientIp = (c: Context): string => {
const forwardedFor = c.req.header('x-forwarded-for');
if (forwardedFor) {
return forwardedFor.split(',')[0].trim();
}
return (
c.req.header('x-real-ip') ||
c.req.header('cf-connecting-ip') ||
c.req.header('client-ip') ||
c.req.header('x-client-ip') ||
c.req.header('x-cluster-client-ip') ||
c.req.header('forwarded-for') ||
c.req.header('forwarded') ||
c.req.header('via') ||
'127.0.0.1'
);
};
// Patterns for private/internal IP addresses
const privateIpPatterns = [
// Localhost variants
/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/,
/^0\.0\.0\.0$/,
// Private IPv4 ranges
/^10\.\d{1,3}\.\d{1,3}\.\d{1,3}$/,
/^192\.168\.\d{1,3}\.\d{1,3}$/,
/^172\.(1[6-9]|2\d|3[0-1])\.\d{1,3}\.\d{1,3}$/,
// Link-local IPv4
/^169\.254\.\d{1,3}\.\d{1,3}$/,
// IPv6 localhost
/^::1$/,
/^\[::1\]$/,
// IPv6 link-local
/^fe80:/i,
// IPv6 private (unique local addresses)
/^fc00:/i,
/^fd[0-9a-f]{2}:/i,
];
// Patterns for special domains that should always be blocked
const blockedHostnamePatterns = [
/^localhost$/,
/\.local$/,
/\.internal$/,
/\.localhost$/,
/\.localdomain$/,
];
/**
* Check if an IP address is private/internal
* @param ip IP address to check
* @returns true if IP is private/internal
*/
const isPrivateIp = (ip: string): boolean => {
return privateIpPatterns.some((pattern) => pattern.test(ip));
};
/**
* Check if a URL points to a private/internal address (SSRF protection)
* Resolves DNS to check actual IP addresses, preventing DNS rebinding attacks.
* @param url URL string to validate
* @returns Promise<true> if URL is safe (not internal), Promise<false> if it's a private/internal address
*/
export const isPublicUrl = async (url: string): Promise<boolean> => {
try {
const parsed = new URL(url);
const hostname = parsed.hostname.toLowerCase();
// Block special domain patterns (e.g., .local, .localhost)
if (blockedHostnamePatterns.some((pattern) => pattern.test(hostname))) {
return false;
}
// If hostname is already an IP address, check it directly
if (isIP(hostname)) {
return !isPrivateIp(hostname);
}
// Resolve DNS to get actual IP addresses
let addresses: string[] = [];
try {
const ipv4Addresses = await dns.resolve4(hostname).catch(() => []);
const ipv6Addresses = await dns.resolve6(hostname).catch(() => []);
addresses = [...ipv4Addresses, ...ipv6Addresses];
} catch {
// DNS resolution failed - reject for safety
return false;
}
// Require at least one resolvable address
if (addresses.length === 0) {
return false;
}
// Check all resolved IPs - reject if ANY resolve to private addresses
return !addresses.some((ip) => isPrivateIp(ip));
} catch {
return false;
}
};

106
api/lib/webhook.ts Normal file
View File

@@ -0,0 +1,106 @@
import { createHmac } from 'crypto';
import config from '../config';
import { getInstanceSettings } from './settings';
export type WebhookEvent = 'secret.viewed' | 'secret.burned' | 'apikey.created';
interface SecretWebhookData {
secretId: string;
hasPassword: boolean;
hasIpRestriction: boolean;
viewsRemaining?: number;
}
interface ApiKeyWebhookData {
apiKeyId: string;
name: string;
expiresAt: string | null;
userId: string;
}
interface WebhookPayload {
event: WebhookEvent;
timestamp: string;
data: SecretWebhookData | ApiKeyWebhookData;
}
function signPayload(payload: string, secret: string): string {
return createHmac('sha256', secret).update(payload).digest('hex');
}
async function sendWithRetry(
url: string,
headers: Record<string, string>,
body: string
): Promise<void> {
const maxRetries = 3;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const response = await fetch(url, {
method: 'POST',
headers,
body,
signal: AbortSignal.timeout(5000),
redirect: 'error',
});
if (response.ok) return;
if (response.status >= 400 && response.status < 500) {
console.error(`Webhook delivery failed: ${response.status}`);
return;
}
} catch (error) {
if (attempt === maxRetries - 1) {
console.error('Webhook delivery failed after retries:', error);
return;
}
}
await new Promise((resolve) => setTimeout(resolve, 1000 * Math.pow(2, attempt)));
}
}
export function sendWebhook(event: WebhookEvent, data: WebhookPayload['data']): void {
(async () => {
try {
const settings = config.isManaged()
? config.getManagedSettings()
: await getInstanceSettings();
if (!settings?.webhookEnabled || !settings.webhookUrl) {
return;
}
if (event === 'secret.viewed' && !settings.webhookOnView) {
return;
}
if (event === 'secret.burned' && !settings.webhookOnBurn) {
return;
}
const payload: WebhookPayload = {
event,
timestamp: new Date().toISOString(),
data,
};
const payloadString = JSON.stringify(payload);
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'X-Paste-Event': event,
'User-Agent': 'Paste-ES-Webhook/1.0',
};
if (settings.webhookSecret) {
const signature = signPayload(payloadString, settings.webhookSecret);
headers['X-Paste-Signature'] = `sha256=${signature}`;
}
await sendWithRetry(settings.webhookUrl, headers, payloadString);
} catch (error) {
console.error('Error preparing webhook:', error);
}
})();
}

91
api/middlewares/auth.ts Normal file
View File

@@ -0,0 +1,91 @@
import { createHash } from 'crypto';
import { createMiddleware } from 'hono/factory';
import { auth } from '../auth';
import prisma from '../lib/db';
type Env = {
Variables: {
user: typeof auth.$Infer.Session.user | null;
session: typeof auth.$Infer.Session.session | null;
};
};
export const authMiddleware = createMiddleware<Env>(async (c, next) => {
const user = c.get('user');
if (!user) {
return c.json({ error: 'Unauthorized' }, 401);
}
await next();
});
export const checkAdmin = createMiddleware<Env>(async (c, next) => {
const sessionUser = c.get('user');
if (!sessionUser) {
return c.json({ error: 'Forbidden' }, 403);
}
const user = await prisma.user.findUnique({
where: { id: sessionUser.id },
select: { role: true },
});
if (!user || user.role !== 'admin') {
return c.json({ error: 'Forbidden' }, 403);
}
await next();
});
// Middleware that accepts either session auth OR API key auth
export const apiKeyOrAuthMiddleware = createMiddleware<Env>(async (c, next) => {
// First check if user is already authenticated via session
const sessionUser = c.get('user');
if (sessionUser) {
return next();
}
// Check for API key in Authorization header
const authHeader = c.req.header('Authorization');
if (!authHeader?.startsWith('Bearer ')) {
return c.json({ error: 'Unauthorized' }, 401);
}
const apiKey = authHeader.substring(7);
if (!apiKey.startsWith('hemmelig_')) {
return c.json({ error: 'Invalid API key format' }, 401);
}
try {
const keyHash = createHash('sha256').update(apiKey).digest('hex');
const apiKeyRecord = await prisma.apiKey.findUnique({
where: { keyHash },
include: { user: true },
});
if (!apiKeyRecord) {
return c.json({ error: 'Invalid API key' }, 401);
}
// Check if key is expired
if (apiKeyRecord.expiresAt && new Date() > apiKeyRecord.expiresAt) {
return c.json({ error: 'API key has expired' }, 401);
}
// Update last used timestamp (fire and forget)
prisma.apiKey
.update({
where: { id: apiKeyRecord.id },
data: { lastUsedAt: new Date() },
})
.catch(() => {});
// Set user from API key
c.set('user', apiKeyRecord.user as typeof auth.$Infer.Session.user);
c.set('session', null);
return next();
} catch (error) {
console.error('API key auth error:', error);
return c.json({ error: 'Authentication failed' }, 401);
}
});

View File

@@ -0,0 +1,33 @@
import { Context, Next } from 'hono';
import ipRangeCheck from 'ip-range-check';
import prisma from '../lib/db';
import { getClientIp } from '../lib/utils';
export const ipRestriction = async (c: Context, next: Next) => {
const { id } = c.req.param();
const item = await prisma.secrets.findUnique({
where: { id },
select: {
ipRange: true,
},
});
// If no restriction is configured, move on
if (!item?.ipRange) {
return next();
}
const ip = getClientIp(c);
if (!ip) {
return c.json({ error: 'Could not identify client IP' }, 400);
}
// The core logic is now a single, clean line
if (!ipRangeCheck(ip, item.ipRange)) {
return c.json({ error: 'Access restricted by IP' }, 403);
}
await next();
};

View File

@@ -0,0 +1,32 @@
import { Context, Next } from 'hono';
import { rateLimiter } from 'hono-rate-limiter';
import settingsCache from '../lib/settings';
import { getClientIp } from '../lib/utils';
let rateLimitInstance: ReturnType<typeof rateLimiter> | null = null;
const ratelimit = async (c: Context, next: Next) => {
const instanceSettings = settingsCache.get('instanceSettings');
if (instanceSettings?.enableRateLimiting) {
if (rateLimitInstance === null) {
rateLimitInstance = rateLimiter({
windowMs: instanceSettings.rateLimitWindow * 1000, // Convert seconds to milliseconds
limit: instanceSettings.rateLimitRequests,
standardHeaders: true,
keyGenerator: (c) => getClientIp(c) || 'anonymous',
});
}
return rateLimitInstance(c, next);
}
// If rate limiting is disabled, ensure the limiter is cleared
if (rateLimitInstance !== null) {
rateLimitInstance = null;
}
await next();
};
export default ratelimit;

1568
api/openapi.ts Normal file

File diff suppressed because it is too large Load Diff

48
api/routes.ts Normal file
View File

@@ -0,0 +1,48 @@
import { Hono } from 'hono';
import { getEnabledSocialProviders } from './auth';
import openapi from './openapi';
import accountRoute from './routes/account';
import analyticsRoute from './routes/analytics';
import apiKeysRoute from './routes/api-keys';
import filesRoute from './routes/files';
import healthRoute from './routes/health';
import instanceRoute from './routes/instance';
import { invitePublicRoute, inviteRoute } from './routes/invites';
import metricsRoute from './routes/metrics';
import secretRequestsRoute from './routes/secret-requests';
import secretsRoute from './routes/secrets';
import setupRoute from './routes/setup';
import { userRoute } from './routes/user';
// Create a new router
const routes = new Hono()
.route('/secrets', secretsRoute)
.route('/secret-requests', secretRequestsRoute)
.route('/account', accountRoute)
.route('/files', filesRoute)
.route('/user', userRoute)
.route('/instance', instanceRoute)
.route('/analytics', analyticsRoute)
.route('/invites/public', invitePublicRoute)
.route('/invites', inviteRoute)
.route('/setup', setupRoute)
.route('/api-keys', apiKeysRoute)
.route('/metrics', metricsRoute)
.route('/health', healthRoute)
.route('/', openapi)
// Legacy liveness endpoint (kept for backwards compatibility)
.get('/healthz', (c) => c.json({ status: 'healthy', timestamp: new Date().toISOString() }))
.get('/config/social-providers', (c) => {
const providers = getEnabledSocialProviders();
const baseUrl = process.env.HEMMELIG_BASE_URL || c.req.header('origin') || '';
const callbackBaseUrl = baseUrl ? `${baseUrl}/api/auth/callback` : '';
return c.json({
providers,
callbackBaseUrl,
});
});
export default routes;
export type AppType = typeof routes;

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);
}
);

View File

@@ -0,0 +1,34 @@
import { z } from 'zod';
import { passwordSchema } from './password';
const sanitizeString = (str: string) => str.trim().replace(/[\x00-\x1F\x7F]/g, '');
const usernameSchema = z
.string()
.transform(sanitizeString)
.pipe(
z
.string()
.min(3, 'Username must be at least 3 characters')
.max(50, 'Username must be at most 50 characters')
.regex(
/^[a-zA-Z0-9_-]+$/,
'Username can only contain letters, numbers, underscores, and hyphens'
)
);
export const updateAccountSchema = z.object({
username: usernameSchema,
email: z.string().email('Invalid email address'),
});
export const updatePasswordSchema = z
.object({
currentPassword: z.string().min(1, 'Current password is required'),
newPassword: passwordSchema,
confirmPassword: z.string(),
})
.refine((data) => data.newPassword === data.confirmPassword, {
message: "Passwords don't match",
path: ['confirmPassword'],
});

View File

@@ -0,0 +1,60 @@
import { z } from 'zod';
const sanitizeString = (str: string) => str.trim().replace(/[\x00-\x1F\x7F]/g, '');
const sanitizedString = (maxLength: number) =>
z.string().transform(sanitizeString).pipe(z.string().max(maxLength));
// Max logo size: 512KB in base64 (which is ~683KB as base64 string)
const MAX_LOGO_BASE64_LENGTH = 700000;
export const instanceSettingsSchema = z.object({
instanceName: sanitizedString(100).optional(),
instanceDescription: sanitizedString(500).optional(),
instanceLogo: z
.string()
.max(MAX_LOGO_BASE64_LENGTH, 'Logo must be smaller than 512KB')
.refine(
(val) => {
if (!val || val === '') return true;
// Check if it's a valid base64 data URL for images
return /^data:image\/(png|jpeg|jpg|gif|svg\+xml|webp);base64,/.test(val);
},
{ message: 'Logo must be a valid image (PNG, JPEG, GIF, SVG, or WebP)' }
)
.optional(),
allowRegistration: z.boolean().optional(),
requireEmailVerification: z.boolean().optional(),
maxSecretsPerUser: z.number().int().min(1).optional(),
defaultSecretExpiration: z.number().int().min(1).optional(),
maxSecretSize: z.number().int().min(1).optional(),
allowPasswordProtection: z.boolean().optional(),
allowIpRestriction: z.boolean().optional(),
allowFileUploads: z.boolean().optional(),
maxPasswordAttempts: z.number().int().min(1).optional(),
sessionTimeout: z.number().int().min(1).optional(),
enableRateLimiting: z.boolean().optional(),
rateLimitRequests: z.number().int().min(1).optional(),
rateLimitWindow: z.number().int().min(1).optional(),
// Organization features
requireInviteCode: z.boolean().optional(),
allowedEmailDomains: sanitizedString(500).optional(),
requireRegisteredUser: z.boolean().optional(),
disableEmailPasswordSignup: z.boolean().optional(),
// Webhook notifications
webhookEnabled: z.boolean().optional(),
webhookUrl: z.string().url().optional().or(z.literal('')),
webhookSecret: sanitizedString(200).optional(),
webhookOnView: z.boolean().optional(),
webhookOnBurn: z.boolean().optional(),
// Important message alert
importantMessage: sanitizedString(1000).optional(),
// Prometheus metrics
metricsEnabled: z.boolean().optional(),
metricsSecret: sanitizedString(200).optional(),
});

View File

@@ -0,0 +1,45 @@
import { z } from 'zod';
/**
* Shared password strength rules.
* Used by Zod schemas and the better-auth sign-up hook.
*/
export const PASSWORD_RULES = {
minLength: 8,
patterns: [
{ regex: /[a-z]/, message: 'Password must contain at least one lowercase letter' },
{ regex: /[A-Z]/, message: 'Password must contain at least one uppercase letter' },
{ regex: /[0-9]/, message: 'Password must contain at least one number' },
],
} as const;
/**
* Validates a password against strength rules.
* Returns the first error message found, or null if valid.
*/
export function validatePassword(password: string): string | null {
if (password.length < PASSWORD_RULES.minLength) {
return `Password must be at least ${PASSWORD_RULES.minLength} characters`;
}
for (const { regex, message } of PASSWORD_RULES.patterns) {
if (!regex.test(password)) {
return message;
}
}
return null;
}
/**
* Zod schema for validating new password strength.
*/
export const passwordSchema = z
.string()
.min(
PASSWORD_RULES.minLength,
`Password must be at least ${PASSWORD_RULES.minLength} characters`
)
.regex(PASSWORD_RULES.patterns[0].regex, PASSWORD_RULES.patterns[0].message)
.regex(PASSWORD_RULES.patterns[1].regex, PASSWORD_RULES.patterns[1].message)
.regex(PASSWORD_RULES.patterns[2].regex, PASSWORD_RULES.patterns[2].message);

View File

@@ -0,0 +1,122 @@
import isCidr from 'is-cidr';
import { isIP } from 'is-ip';
import { z } from 'zod';
import { EXPIRATION_TIMES_SECONDS } from '../lib/constants';
// Valid durations for request validity (how long the creator link is active)
export const REQUEST_VALIDITY_SECONDS = [
2592000, // 30 days
1209600, // 14 days
604800, // 7 days
259200, // 3 days
86400, // 1 day
43200, // 12 hours
3600, // 1 hour
] as const;
export const createSecretRequestSchema = z.object({
title: z.string().min(1).max(200),
description: z.string().max(1000).optional(),
maxViews: z.number().int().min(1).max(9999).default(1),
expiresIn: z
.number()
.refine(
(val) =>
EXPIRATION_TIMES_SECONDS.includes(val as (typeof EXPIRATION_TIMES_SECONDS)[number]),
{
message: 'Invalid expiration time for secret',
}
),
validFor: z
.number()
.refine(
(val) =>
REQUEST_VALIDITY_SECONDS.includes(val as (typeof REQUEST_VALIDITY_SECONDS)[number]),
{
message: 'Invalid validity period for request',
}
),
allowedIp: z
.string()
.refine((val) => isCidr(val) || isIP(val), {
message: 'Must be a valid IPv4, IPv6, or CIDR',
})
.nullable()
.optional(),
preventBurn: z.boolean().default(false),
webhookUrl: z.string().url().optional(),
});
export const secretRequestIdParamSchema = z.object({
id: z.string().uuid(),
});
export const secretRequestTokenQuerySchema = z.object({
token: z.string().length(64),
});
// Max encrypted secret size: 1MB (1,048,576 bytes)
const MAX_SECRET_SIZE = 1024 * 1024;
// Min encrypted secret size: 28 bytes (12 IV + 16 minimum ciphertext with auth tag)
const MIN_SECRET_SIZE = 28;
// Max encrypted title size: 1KB (1,024 bytes)
const MAX_TITLE_SIZE = 1024;
export const submitSecretRequestSchema = z.object({
secret: z
.preprocess((arg) => {
if (arg && typeof arg === 'object' && !Array.isArray(arg)) {
const values = Object.values(arg);
return new Uint8Array(values as number[]);
}
return arg;
}, z.instanceof(Uint8Array))
.refine((arr) => arr.length >= MIN_SECRET_SIZE, {
message: 'Secret data is too small to be valid encrypted content',
})
.refine((arr) => arr.length <= MAX_SECRET_SIZE, {
message: `Secret exceeds maximum size of ${MAX_SECRET_SIZE} bytes`,
}),
title: z
.preprocess((arg) => {
if (arg && typeof arg === 'object' && !Array.isArray(arg)) {
const values = Object.values(arg);
return new Uint8Array(values as number[]);
}
return arg;
}, z.instanceof(Uint8Array))
.refine((arr) => arr.length <= MAX_TITLE_SIZE, {
message: `Title exceeds maximum size of ${MAX_TITLE_SIZE} bytes`,
})
.optional()
.nullable(),
salt: z.string().min(16).max(64),
});
export const secretRequestsQuerySchema = z.object({
page: z
.string()
.optional()
.refine((val) => val === undefined || /^\d+$/.test(val), {
message: 'Page must be a positive integer string',
}),
limit: z
.string()
.optional()
.refine((val) => val === undefined || /^\d+$/.test(val), {
message: 'Limit must be a positive integer string',
}),
status: z.enum(['all', 'pending', 'fulfilled', 'expired', 'cancelled']).optional(),
});
export const processSecretRequestsQueryParams = (
query: z.infer<typeof secretRequestsQuerySchema>
) => {
const page = query.page ? parseInt(query.page, 10) : undefined;
const limit = query.limit ? parseInt(query.limit, 10) : undefined;
const take = limit && limit > 0 && limit <= 100 ? limit : 10;
const skip = page && page > 0 ? (page - 1) * take : 0;
return { skip, take, status: query.status };
};

120
api/validations/secrets.ts Normal file
View File

@@ -0,0 +1,120 @@
import isCidr from 'is-cidr';
import { isIP } from 'is-ip';
import { z } from 'zod';
import { EXPIRATION_TIMES_SECONDS } from '../lib/constants';
// Hard ceiling for encrypted payloads at parse time (prevents memory exhaustion).
// Configurable via env var in KB, defaults to 1024 KB (1MB).
const MAX_ENCRYPTED_PAYLOAD_KB = parseInt(
process.env.HEMMELIG_MAX_ENCRYPTED_PAYLOAD_SIZE || '1024',
10
);
export const MAX_ENCRYPTED_SIZE = MAX_ENCRYPTED_PAYLOAD_KB * 1024;
// Schema for URL parameters (expecting string from URL)
export const secretsIdParamSchema = z.object({
id: z.string(),
});
// Schema for query parameters (expecting strings from URL)
export const secretsQuerySchema = z.object({
page: z
.string()
.optional()
.refine((val) => val === undefined || /^\d+$/.test(val), {
message: 'Page must be a positive integer string',
}),
limit: z
.string()
.optional()
.refine((val) => val === undefined || /^\d+$/.test(val), {
message: 'Limit must be a positive integer string',
}),
});
const jsonToUint8ArraySchema = z.preprocess(
(arg) => {
if (arg && typeof arg === 'object' && !Array.isArray(arg)) {
const values = Object.values(arg);
if (values.length > MAX_ENCRYPTED_SIZE) {
return arg; // Let the refine below catch the size error
}
return new Uint8Array(values);
}
return arg;
},
z.instanceof(Uint8Array).refine((arr) => arr.length <= MAX_ENCRYPTED_SIZE, {
message: `Encrypted payload exceeds maximum size of ${MAX_ENCRYPTED_PAYLOAD_KB} KB`,
})
);
const secretSchema = {
salt: z.string(),
secret: jsonToUint8ArraySchema,
title: jsonToUint8ArraySchema.optional().nullable(),
password: z.string().optional(),
expiresAt: z
.number()
.refine(
(val) =>
EXPIRATION_TIMES_SECONDS.includes(val as (typeof EXPIRATION_TIMES_SECONDS)[number]),
{
message: 'Invalid expiration time',
}
),
views: z.number().int().min(1).max(9999).optional(),
isBurnable: z.boolean().default(true).optional(),
ipRange: z
.string()
.refine((val) => isCidr(val) || isIP(val), {
message: 'Must be a valid IPv4, IPv6, or CIDR',
})
.nullable()
.optional(),
fileIds: z.array(z.string()).optional(),
};
export const createSecretsSchema = z.object(secretSchema);
export const getSecretSchema = z.object({
password: z.string().optional(),
});
const internalQueryParamsSchema = z.object({
skip: z.number().int().min(0).optional(),
take: z.number().int().min(1).max(100).optional(),
page: z.number().int().min(1).optional(),
limit: z.number().int().min(1).max(100).optional(),
});
interface ProcessedSecretsQueryParams {
skip: number;
take: number;
}
export const processSecretsQueryParams = (
query: z.infer<typeof secretsQuerySchema>
): ProcessedSecretsQueryParams => {
const page = query.page ? parseInt(query.page, 10) : undefined;
const limit = query.limit ? parseInt(query.limit, 10) : undefined;
const take = limit && limit > 0 && limit <= 100 ? limit : 10; // Guaranteed number
const skip = page && page > 0 ? (page - 1) * take : 0; // Guaranteed number
// Optional: Validate other params if needed, but we already have skip/take
const parseResult = internalQueryParamsSchema.safeParse({
skip,
take,
page,
limit,
});
if (!parseResult.success) {
// Log error but return defaults for pagination
console.error('secrets query parameter processing error:', parseResult.error);
return { skip: 0, take: 10 };
}
return { skip, take };
};

22
api/validations/user.ts Normal file
View File

@@ -0,0 +1,22 @@
import { z } from 'zod';
const sanitizeString = (str: string) => str.trim().replace(/[\x00-\x1F\x7F]/g, '');
const usernameSchema = z
.string()
.transform(sanitizeString)
.pipe(
z
.string()
.min(3, 'Username must be at least 3 characters')
.max(50, 'Username must be at most 50 characters')
.regex(
/^[a-zA-Z0-9_-]+$/,
'Username can only contain letters, numbers, underscores, and hyphens'
)
);
export const updateUserSchema = z.object({
username: usernameSchema.optional(),
email: z.string().email().optional(),
});