feat: rebrand Hemmelig to paste.es for cloudhost.es

- Set Spanish as default language with ephemeral/encrypted privacy focus
- Translate all user-facing strings and legal pages to Spanish
- Replace Norwegian flag with Spanish flag in footer
- Remove Hemmelig/terces.cloud links, add cloudhost.es sponsorship
- Rewrite PrivacyPage: zero data collection, ephemeral design emphasis
- Rewrite TermsPage: Spanish law, RGPD, paste.es/CloudHost.es references
- Update PWA manifest, HTML meta tags, package.json branding
- Rename webhook headers to X-Paste-Event / X-Paste-Signature
- Update API docs title and contact to paste.es / cloudhost.es

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-24 09:30:19 +01:00
commit bc9f96cbd4
268 changed files with 45773 additions and 0 deletions

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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