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:
158
api/app.ts
Normal file
158
api/app.ts
Normal 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
176
api/auth.ts
Normal 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
255
api/config.ts
Normal 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
67
api/jobs/expired.ts
Normal 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
14
api/jobs/index.ts
Normal 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
62
api/lib/analytics.ts
Normal 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
85
api/lib/constants.ts
Normal 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
19
api/lib/db.ts
Normal 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
77
api/lib/files.ts
Normal 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
49
api/lib/password.ts
Normal 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
40
api/lib/settings.ts
Normal 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
130
api/lib/utils.ts
Normal 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
106
api/lib/webhook.ts
Normal 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
91
api/middlewares/auth.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
33
api/middlewares/ip-restriction.ts
Normal file
33
api/middlewares/ip-restriction.ts
Normal 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();
|
||||
};
|
||||
32
api/middlewares/ratelimit.ts
Normal file
32
api/middlewares/ratelimit.ts
Normal 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
1568
api/openapi.ts
Normal file
File diff suppressed because it is too large
Load Diff
48
api/routes.ts
Normal file
48
api/routes.ts
Normal 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
130
api/routes/account.ts
Normal 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
253
api/routes/analytics.ts
Normal 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
156
api/routes/api-keys.ts
Normal 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
134
api/routes/files.ts
Normal 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
131
api/routes/health.ts
Normal 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
169
api/routes/instance.ts
Normal 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
151
api/routes/invites.ts
Normal 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
159
api/routes/metrics.ts
Normal 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;
|
||||
455
api/routes/secret-requests.ts
Normal file
455
api/routes/secret-requests.ts
Normal file
@@ -0,0 +1,455 @@
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { createHmac, randomBytes, timingSafeEqual } from 'crypto';
|
||||
import { Hono } from 'hono';
|
||||
import { auth } from '../auth';
|
||||
import prisma from '../lib/db';
|
||||
import { isPublicUrl } from '../lib/utils';
|
||||
import { authMiddleware } from '../middlewares/auth';
|
||||
import {
|
||||
createSecretRequestSchema,
|
||||
processSecretRequestsQueryParams,
|
||||
secretRequestIdParamSchema,
|
||||
secretRequestsQuerySchema,
|
||||
secretRequestTokenQuerySchema,
|
||||
submitSecretRequestSchema,
|
||||
} from '../validations/secret-requests';
|
||||
|
||||
// Webhook payload for secret request fulfillment
|
||||
interface SecretRequestWebhookPayload {
|
||||
event: 'secret_request.fulfilled';
|
||||
timestamp: string;
|
||||
request: {
|
||||
id: string;
|
||||
title: string;
|
||||
createdAt: string;
|
||||
fulfilledAt: string;
|
||||
};
|
||||
secret: {
|
||||
id: string;
|
||||
maxViews: number;
|
||||
expiresAt: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Send webhook notification when a secret request is fulfilled
|
||||
async function sendSecretRequestWebhook(
|
||||
webhookUrl: string,
|
||||
webhookSecret: string,
|
||||
payload: SecretRequestWebhookPayload
|
||||
): Promise<void> {
|
||||
try {
|
||||
const timestamp = Math.floor(Date.now() / 1000);
|
||||
const payloadString = JSON.stringify(payload);
|
||||
const signedPayload = `${timestamp}.${payloadString}`;
|
||||
const signature = createHmac('sha256', webhookSecret).update(signedPayload).digest('hex');
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Hemmelig-Event': 'secret_request.fulfilled',
|
||||
'X-Hemmelig-Signature': `sha256=${signature}`,
|
||||
'X-Hemmelig-Timestamp': timestamp.toString(),
|
||||
'X-Hemmelig-Request-Id': payload.request.id,
|
||||
'User-Agent': 'Hemmelig-Webhook/1.0',
|
||||
};
|
||||
|
||||
// Retry with exponential backoff
|
||||
const maxRetries = 3;
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
try {
|
||||
const response = await fetch(webhookUrl, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: payloadString,
|
||||
signal: AbortSignal.timeout(5000), // 5 second timeout to prevent slow-loris
|
||||
redirect: 'error', // Prevent SSRF via open redirects
|
||||
});
|
||||
|
||||
if (response.ok) return;
|
||||
|
||||
// Don't retry for client errors (4xx)
|
||||
if (response.status >= 400 && response.status < 500) {
|
||||
console.error(`Secret request webhook delivery failed: ${response.status}`);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
if (attempt === maxRetries - 1) {
|
||||
console.error('Secret request webhook delivery failed after retries:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Exponential backoff: 1s, 2s, 4s
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000 * Math.pow(2, attempt)));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error preparing secret request webhook:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Secure token comparison - constant time for all inputs
|
||||
function validateToken(provided: string, stored: string): boolean {
|
||||
try {
|
||||
// Pad to same length to prevent timing leaks from length comparison
|
||||
const providedBuf = Buffer.alloc(32);
|
||||
const storedBuf = Buffer.alloc(32);
|
||||
|
||||
const providedBytes = Buffer.from(provided, 'hex');
|
||||
const storedBytes = Buffer.from(stored, 'hex');
|
||||
|
||||
// Only copy valid bytes, rest stays as zeros
|
||||
if (providedBytes.length === 32) providedBytes.copy(providedBuf);
|
||||
if (storedBytes.length === 32) storedBytes.copy(storedBuf);
|
||||
|
||||
// Always do the comparison, even if lengths were wrong
|
||||
const match = timingSafeEqual(providedBuf, storedBuf);
|
||||
|
||||
// Only return true if lengths were correct AND content matches
|
||||
return providedBytes.length === 32 && storedBytes.length === 32 && match;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const app = new Hono<{
|
||||
Variables: {
|
||||
user: typeof auth.$Infer.Session.user | null;
|
||||
};
|
||||
}>()
|
||||
// List user's secret requests (authenticated)
|
||||
.get('/', authMiddleware, zValidator('query', secretRequestsQuerySchema), async (c) => {
|
||||
try {
|
||||
const user = c.get('user')!; // authMiddleware guarantees user exists
|
||||
|
||||
const validatedQuery = c.req.valid('query');
|
||||
const { skip, take, status } = processSecretRequestsQueryParams(validatedQuery);
|
||||
|
||||
const whereClause: { userId: string; status?: string } = { userId: user.id };
|
||||
if (status && status !== 'all') {
|
||||
whereClause.status = status;
|
||||
}
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
prisma.secretRequest.findMany({
|
||||
where: whereClause,
|
||||
skip,
|
||||
take,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
description: true,
|
||||
status: true,
|
||||
maxViews: true,
|
||||
expiresIn: true,
|
||||
webhookUrl: true,
|
||||
createdAt: true,
|
||||
expiresAt: true,
|
||||
fulfilledAt: true,
|
||||
secretId: true,
|
||||
},
|
||||
}),
|
||||
prisma.secretRequest.count({ where: whereClause }),
|
||||
]);
|
||||
|
||||
return c.json({
|
||||
data: items,
|
||||
meta: {
|
||||
total,
|
||||
skip,
|
||||
take,
|
||||
page: Math.floor(skip / take) + 1,
|
||||
totalPages: Math.ceil(total / take),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to retrieve secret requests:', error);
|
||||
return c.json({ error: 'Failed to retrieve secret requests' }, 500);
|
||||
}
|
||||
})
|
||||
// Create new secret request (authenticated)
|
||||
.post('/', authMiddleware, zValidator('json', createSecretRequestSchema), async (c) => {
|
||||
try {
|
||||
const user = c.get('user')!; // authMiddleware guarantees user exists
|
||||
|
||||
const data = c.req.valid('json');
|
||||
|
||||
if (data.webhookUrl && !(await isPublicUrl(data.webhookUrl))) {
|
||||
return c.json(
|
||||
{ error: 'Webhook URL cannot point to private/internal addresses' },
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
// Generate secure token (64 hex chars = 32 bytes)
|
||||
const token = randomBytes(32).toString('hex');
|
||||
|
||||
// Generate webhook secret if webhook URL is provided
|
||||
const webhookSecret = data.webhookUrl ? randomBytes(32).toString('hex') : null;
|
||||
|
||||
const request = await prisma.secretRequest.create({
|
||||
data: {
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
maxViews: data.maxViews,
|
||||
expiresIn: data.expiresIn,
|
||||
allowedIp: data.allowedIp,
|
||||
preventBurn: data.preventBurn,
|
||||
webhookUrl: data.webhookUrl,
|
||||
webhookSecret,
|
||||
token,
|
||||
userId: user.id,
|
||||
expiresAt: new Date(Date.now() + data.validFor * 1000),
|
||||
},
|
||||
});
|
||||
|
||||
// Get the base URL from the request
|
||||
const origin = c.req.header('origin') || process.env.HEMMELIG_BASE_URL || '';
|
||||
|
||||
return c.json(
|
||||
{
|
||||
id: request.id,
|
||||
creatorLink: `${origin}/request/${request.id}?token=${token}`,
|
||||
webhookSecret, // Return once so requester can configure their webhook receiver
|
||||
expiresAt: request.expiresAt,
|
||||
},
|
||||
201
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to create secret request:', error);
|
||||
return c.json({ error: 'Failed to create secret request' }, 500);
|
||||
}
|
||||
})
|
||||
// Get single secret request details (authenticated, owner only)
|
||||
.get('/:id', authMiddleware, zValidator('param', secretRequestIdParamSchema), async (c) => {
|
||||
try {
|
||||
const user = c.get('user')!;
|
||||
const { id } = c.req.valid('param');
|
||||
|
||||
const request = await prisma.secretRequest.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
description: true,
|
||||
status: true,
|
||||
maxViews: true,
|
||||
expiresIn: true,
|
||||
preventBurn: true,
|
||||
webhookUrl: true,
|
||||
token: true,
|
||||
createdAt: true,
|
||||
expiresAt: true,
|
||||
fulfilledAt: true,
|
||||
secretId: true,
|
||||
userId: true,
|
||||
allowedIp: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!request) {
|
||||
return c.json({ error: 'Secret request not found' }, 404);
|
||||
}
|
||||
|
||||
if (request.userId !== user.id) {
|
||||
return c.json({ error: 'Forbidden' }, 403);
|
||||
}
|
||||
|
||||
// Get the base URL from the request
|
||||
const origin = c.req.header('origin') || process.env.HEMMELIG_BASE_URL || '';
|
||||
|
||||
return c.json({
|
||||
...request,
|
||||
creatorLink: `${origin}/request/${request.id}?token=${request.token}`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to retrieve secret request:', error);
|
||||
return c.json({ error: 'Failed to retrieve secret request' }, 500);
|
||||
}
|
||||
})
|
||||
// Cancel/delete secret request (authenticated, owner only)
|
||||
.delete('/:id', authMiddleware, zValidator('param', secretRequestIdParamSchema), async (c) => {
|
||||
try {
|
||||
const user = c.get('user')!;
|
||||
const { id } = c.req.valid('param');
|
||||
|
||||
const request = await prisma.secretRequest.findUnique({
|
||||
where: { id },
|
||||
select: { userId: true, status: true },
|
||||
});
|
||||
|
||||
if (!request) {
|
||||
return c.json({ error: 'Secret request not found' }, 404);
|
||||
}
|
||||
|
||||
if (request.userId !== user.id) {
|
||||
return c.json({ error: 'Forbidden' }, 403);
|
||||
}
|
||||
|
||||
// Only allow cancellation of pending requests
|
||||
if (request.status !== 'pending') {
|
||||
return c.json({ error: 'Can only cancel pending requests' }, 400);
|
||||
}
|
||||
|
||||
await prisma.secretRequest.update({
|
||||
where: { id },
|
||||
data: { status: 'cancelled' },
|
||||
});
|
||||
|
||||
return c.json({ success: true, message: 'Secret request cancelled' });
|
||||
} catch (error) {
|
||||
console.error('Failed to cancel secret request:', error);
|
||||
return c.json({ error: 'Failed to cancel secret request' }, 500);
|
||||
}
|
||||
})
|
||||
// Get request info for Creator (public, requires token)
|
||||
.get(
|
||||
'/:id/info',
|
||||
zValidator('param', secretRequestIdParamSchema),
|
||||
zValidator('query', secretRequestTokenQuerySchema),
|
||||
async (c) => {
|
||||
try {
|
||||
const { id } = c.req.valid('param');
|
||||
const { token } = c.req.valid('query');
|
||||
|
||||
const request = await prisma.secretRequest.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
description: true,
|
||||
status: true,
|
||||
expiresAt: true,
|
||||
token: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!request || !validateToken(token, request.token)) {
|
||||
return c.json({ error: 'Invalid or expired request' }, 404);
|
||||
}
|
||||
|
||||
if (request.status !== 'pending') {
|
||||
return c.json({ error: 'Request already fulfilled or expired' }, 410);
|
||||
}
|
||||
|
||||
if (new Date() > request.expiresAt) {
|
||||
// Update status to expired
|
||||
await prisma.secretRequest.update({
|
||||
where: { id },
|
||||
data: { status: 'expired' },
|
||||
});
|
||||
return c.json({ error: 'Request has expired' }, 410);
|
||||
}
|
||||
|
||||
return c.json({
|
||||
id: request.id,
|
||||
title: request.title,
|
||||
description: request.description,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to retrieve secret request info:', error);
|
||||
return c.json({ error: 'Failed to retrieve request info' }, 500);
|
||||
}
|
||||
}
|
||||
)
|
||||
// Submit encrypted secret for request (public, requires token)
|
||||
.post(
|
||||
'/:id/submit',
|
||||
zValidator('param', secretRequestIdParamSchema),
|
||||
zValidator('query', secretRequestTokenQuerySchema),
|
||||
zValidator('json', submitSecretRequestSchema),
|
||||
async (c) => {
|
||||
try {
|
||||
const { id } = c.req.valid('param');
|
||||
const { token } = c.req.valid('query');
|
||||
const { secret, title, salt } = c.req.valid('json');
|
||||
|
||||
// Use interactive transaction to prevent race conditions
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
const request = await tx.secretRequest.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!request || !validateToken(token, request.token)) {
|
||||
return { error: 'Invalid request', status: 404 };
|
||||
}
|
||||
|
||||
if (request.status !== 'pending') {
|
||||
return { error: 'Request already fulfilled', status: 410 };
|
||||
}
|
||||
|
||||
if (new Date() > request.expiresAt) {
|
||||
await tx.secretRequest.update({
|
||||
where: { id },
|
||||
data: { status: 'expired' },
|
||||
});
|
||||
return { error: 'Request has expired', status: 410 };
|
||||
}
|
||||
|
||||
// Calculate expiration time for the secret
|
||||
const secretExpiresAt = new Date(Date.now() + request.expiresIn * 1000);
|
||||
|
||||
// Create secret and update request atomically
|
||||
const createdSecret = await tx.secrets.create({
|
||||
data: {
|
||||
secret: Buffer.from(secret),
|
||||
title: title ? Buffer.from(title) : Buffer.from([]),
|
||||
salt,
|
||||
views: request.maxViews,
|
||||
ipRange: request.allowedIp,
|
||||
isBurnable: !request.preventBurn,
|
||||
expiresAt: secretExpiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.secretRequest.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: 'fulfilled',
|
||||
fulfilledAt: new Date(),
|
||||
secretId: createdSecret.id,
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true, createdSecret, request, secretExpiresAt };
|
||||
});
|
||||
|
||||
if ('error' in result) {
|
||||
return c.json({ error: result.error }, result.status as 404 | 410);
|
||||
}
|
||||
|
||||
const { createdSecret, request, secretExpiresAt } = result;
|
||||
|
||||
// Send webhook notification (async, don't block response)
|
||||
if (request.webhookUrl && request.webhookSecret) {
|
||||
const webhookPayload: SecretRequestWebhookPayload = {
|
||||
event: 'secret_request.fulfilled',
|
||||
timestamp: new Date().toISOString(),
|
||||
request: {
|
||||
id: request.id,
|
||||
title: request.title,
|
||||
createdAt: request.createdAt.toISOString(),
|
||||
fulfilledAt: new Date().toISOString(),
|
||||
},
|
||||
secret: {
|
||||
id: createdSecret.id,
|
||||
maxViews: request.maxViews,
|
||||
expiresAt: secretExpiresAt.toISOString(),
|
||||
},
|
||||
};
|
||||
|
||||
sendSecretRequestWebhook(
|
||||
request.webhookUrl,
|
||||
request.webhookSecret,
|
||||
webhookPayload
|
||||
).catch(console.error);
|
||||
}
|
||||
|
||||
// Return secret ID (client will construct full URL with decryption key)
|
||||
return c.json({ secretId: createdSecret.id }, 201);
|
||||
} catch (error) {
|
||||
console.error('Failed to submit secret for request:', error);
|
||||
return c.json({ error: 'Failed to submit secret' }, 500);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export default app;
|
||||
343
api/routes/secrets.ts
Normal file
343
api/routes/secrets.ts
Normal 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
82
api/routes/setup.ts
Normal 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
89
api/routes/user.ts
Normal 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);
|
||||
}
|
||||
);
|
||||
34
api/validations/account.ts
Normal file
34
api/validations/account.ts
Normal 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'],
|
||||
});
|
||||
60
api/validations/instance.ts
Normal file
60
api/validations/instance.ts
Normal 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(),
|
||||
});
|
||||
45
api/validations/password.ts
Normal file
45
api/validations/password.ts
Normal 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);
|
||||
122
api/validations/secret-requests.ts
Normal file
122
api/validations/secret-requests.ts
Normal 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
120
api/validations/secrets.ts
Normal 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
22
api/validations/user.ts
Normal 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(),
|
||||
});
|
||||
Reference in New Issue
Block a user