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:
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