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