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:
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);
|
||||
}
|
||||
})();
|
||||
}
|
||||
Reference in New Issue
Block a user