Initial commit (unpacked platform)
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import { schema, type Database } from "@app/database";
|
||||
import { loadLocalePreferences } from "./localeContext.js";
|
||||
|
||||
/**
|
||||
* Innehållsspråk (i18n-spec §11–14): svensk källtext är sanningen, publicerade
|
||||
* översättningar är vyer. Upplösning: exakt tagg ("en-US") → primärt språk
|
||||
* ("en") → svensk källa. Endast status=published serveras till användare.
|
||||
*/
|
||||
|
||||
/** "en-US" -> ["en-US", "en"], "sv" -> ["sv"]. */
|
||||
export function languageCandidates(languageTag: string): string[] {
|
||||
const primary = languageTag.split("-")[0] ?? languageTag;
|
||||
return primary === languageTag ? [languageTag] : [languageTag, primary];
|
||||
}
|
||||
|
||||
export async function userLanguageTag(db: Database, userId: string): Promise<string> {
|
||||
const prefs = await loadLocalePreferences(db, userId);
|
||||
return prefs.languageTag;
|
||||
}
|
||||
|
||||
export interface ResolvedRecipeText {
|
||||
language: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
storageGuidance: string | null;
|
||||
/** stepNumber -> { instruction, tip } */
|
||||
steps: Map<number, { instruction: string; tip: string | null }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hämtar publicerad receptöversättning för användarens språk, eller null om
|
||||
* svensk källa ska användas. En DB-runda för texterna + en för stegen.
|
||||
*/
|
||||
export async function resolveRecipeTranslation(
|
||||
db: Database,
|
||||
recipeId: string,
|
||||
languageTag: string,
|
||||
): Promise<ResolvedRecipeText | null> {
|
||||
const candidates = languageCandidates(languageTag);
|
||||
if (candidates.includes("sv")) return null;
|
||||
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(schema.recipeTranslations)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.recipeTranslations.recipeId, recipeId),
|
||||
inArray(schema.recipeTranslations.languageTag, candidates),
|
||||
eq(schema.recipeTranslations.status, "published"),
|
||||
),
|
||||
);
|
||||
if (rows.length === 0) return null;
|
||||
const best =
|
||||
candidates.map((c) => rows.find((r) => r.languageTag === c)).find(Boolean) ?? rows[0]!;
|
||||
|
||||
const stepRows = await db
|
||||
.select()
|
||||
.from(schema.recipeStepTranslations)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.recipeStepTranslations.recipeId, recipeId),
|
||||
eq(schema.recipeStepTranslations.languageTag, best.languageTag),
|
||||
),
|
||||
);
|
||||
return {
|
||||
language: best.languageTag,
|
||||
title: best.title,
|
||||
description: best.description,
|
||||
storageGuidance: best.storageGuidance,
|
||||
steps: new Map(stepRows.map((s) => [s.stepNumber, { instruction: s.instruction, tip: s.tip }])),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Ingrediensnamn för användarens språk: Map ingredientId -> namn.
|
||||
* Saknade id:n behåller svenskt namn (fallback sker hos anroparen).
|
||||
*/
|
||||
export async function resolveIngredientNames(
|
||||
db: Database,
|
||||
ingredientIds: string[],
|
||||
languageTag: string,
|
||||
): Promise<Map<string, string>> {
|
||||
const candidates = languageCandidates(languageTag);
|
||||
if (candidates.includes("sv") || ingredientIds.length === 0) return new Map();
|
||||
const rows = await db
|
||||
.select({
|
||||
ingredientId: schema.ingredientTranslations.ingredientId,
|
||||
languageTag: schema.ingredientTranslations.languageTag,
|
||||
name: schema.ingredientTranslations.name,
|
||||
})
|
||||
.from(schema.ingredientTranslations)
|
||||
.where(
|
||||
and(
|
||||
inArray(schema.ingredientTranslations.ingredientId, ingredientIds),
|
||||
inArray(schema.ingredientTranslations.languageTag, candidates),
|
||||
eq(schema.ingredientTranslations.status, "published"),
|
||||
),
|
||||
);
|
||||
const out = new Map<string, string>();
|
||||
for (const candidate of [...candidates].reverse()) {
|
||||
for (const r of rows) if (r.languageTag === candidate) out.set(r.ingredientId, r.name);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { and, desc, eq } from "drizzle-orm";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { schema, type Database } from "@app/database";
|
||||
import type { Entitlements } from "@app/shared-types";
|
||||
import {
|
||||
computeEntitlements,
|
||||
signEntitlementToken,
|
||||
type SubscriptionSnapshot,
|
||||
type TrialSnapshot,
|
||||
} from "@app/subscriptions";
|
||||
import { currentMonth } from "./helpers.js";
|
||||
import { errors } from "./errors.js";
|
||||
|
||||
/** Ladda och beräkna entitlements för en användare. Backend = source of truth (spec §61.14). */
|
||||
export async function loadEntitlements(db: Database, userId: string): Promise<Entitlements> {
|
||||
const [subRow] = await db
|
||||
.select()
|
||||
.from(schema.subscriptions)
|
||||
.where(eq(schema.subscriptions.userId, userId))
|
||||
.orderBy(desc(schema.subscriptions.updatedAt))
|
||||
.limit(1);
|
||||
|
||||
const [trialRow] = await db
|
||||
.select()
|
||||
.from(schema.trials)
|
||||
.where(eq(schema.trials.userId, userId))
|
||||
.limit(1);
|
||||
|
||||
const [usageRow] = await db
|
||||
.select()
|
||||
.from(schema.aiUsageCounters)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.aiUsageCounters.userId, userId),
|
||||
eq(schema.aiUsageCounters.month, currentMonth()),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
const subscription: SubscriptionSnapshot | null = subRow
|
||||
? {
|
||||
plan: subRow.plan,
|
||||
status: subRow.status,
|
||||
expiresAt: subRow.expiresAt,
|
||||
gracePeriodExpiresAt: subRow.gracePeriodExpiresAt,
|
||||
}
|
||||
: null;
|
||||
const trial: TrialSnapshot | null = trialRow
|
||||
? { startedAt: trialRow.startedAt, endsAt: trialRow.endsAt }
|
||||
: null;
|
||||
|
||||
return computeEntitlements(subscription, trial, {
|
||||
aiScansUsedThisMonth: usageRow?.aiScans ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
/** Entitlements + signerad offline-token (spec §44). */
|
||||
export async function loadEntitlementsWithToken(
|
||||
app: FastifyInstance,
|
||||
userId: string,
|
||||
): Promise<Entitlements> {
|
||||
const ent = await loadEntitlements(app.db, userId);
|
||||
ent.signedToken = signEntitlementToken(userId, ent, app.config.ENTITLEMENT_SIGNING_SECRET, {
|
||||
ttlHours: app.config.ENTITLEMENT_TTL_HOURS,
|
||||
graceDays: app.config.ENTITLEMENT_GRACE_DAYS,
|
||||
});
|
||||
return ent;
|
||||
}
|
||||
|
||||
/** Kräv AI-kvot och räkna upp användningen atomiskt. */
|
||||
export async function consumeAiScan(db: Database, userId: string): Promise<void> {
|
||||
const ent = await loadEntitlements(db, userId);
|
||||
if (ent.aiScansUsedThisMonth >= ent.aiScansPerMonth) {
|
||||
throw errors.quotaExceeded(
|
||||
`Månadens AI-skanningar är slut (${ent.aiScansPerMonth} st). Uppgradera för fler.`,
|
||||
);
|
||||
}
|
||||
const month = currentMonth();
|
||||
await db
|
||||
.insert(schema.aiUsageCounters)
|
||||
.values({ userId, month, aiScans: 1 })
|
||||
.onConflictDoUpdate({
|
||||
target: [schema.aiUsageCounters.userId, schema.aiUsageCounters.month],
|
||||
set: { aiScans: (await import("drizzle-orm")).sql`${schema.aiUsageCounters.aiScans} + 1` },
|
||||
});
|
||||
}
|
||||
|
||||
/** Kräv en premiumfunktion (t.ex. veckoplanering). */
|
||||
export async function requireFeature(
|
||||
db: Database,
|
||||
userId: string,
|
||||
feature: "weekPlanning" | "advancedNutrition" | "communityPublish",
|
||||
labelSv: string,
|
||||
): Promise<void> {
|
||||
const ent = await loadEntitlements(db, userId);
|
||||
if (!ent[feature]) {
|
||||
throw errors.paymentRequired(
|
||||
`${labelSv} ingår i Premium. Starta din provperiod eller uppgradera.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { ZodType } from "zod";
|
||||
|
||||
/** Applikationsfel med HTTP-status och maskinläsbar kod. */
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
public readonly statusCode: number,
|
||||
public readonly code: string,
|
||||
message: string,
|
||||
public readonly details?: unknown,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
}
|
||||
}
|
||||
|
||||
export const errors = {
|
||||
badRequest: (msg: string, details?: unknown) => new ApiError(400, "BAD_REQUEST", msg, details),
|
||||
unauthorized: (msg = "Ogiltig eller saknad autentisering") =>
|
||||
new ApiError(401, "UNAUTHORIZED", msg),
|
||||
forbidden: (msg = "Åtkomst nekad") => new ApiError(403, "FORBIDDEN", msg),
|
||||
notFound: (msg = "Resursen finns inte") => new ApiError(404, "NOT_FOUND", msg),
|
||||
conflict: (msg: string) => new ApiError(409, "CONFLICT", msg),
|
||||
quotaExceeded: (msg: string) => new ApiError(429, "QUOTA_EXCEEDED", msg),
|
||||
paymentRequired: (msg: string) => new ApiError(402, "PREMIUM_REQUIRED", msg),
|
||||
internal: (msg = "Internt fel") => new ApiError(500, "INTERNAL", msg),
|
||||
};
|
||||
|
||||
/**
|
||||
* Validera indata mot ett Zod-schema. Kastar 400 med detaljer vid fel.
|
||||
* Medvetet vald i stället för type-provider: färre rörliga delar i
|
||||
* major-versionsgränser, samma säkerhet (se docs/beslutslogg.md D-011).
|
||||
*/
|
||||
export function parse<T>(schema: ZodType<T>, data: unknown, what = "indata"): T {
|
||||
const result = schema.safeParse(data);
|
||||
if (!result.success) {
|
||||
throw new ApiError(400, "VALIDATION_ERROR", `Ogiltig ${what}`, result.error.issues);
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import type { Database } from "@app/database";
|
||||
import { schema } from "@app/database";
|
||||
import type { EventType } from "@app/shared-types";
|
||||
import type { NewDomainEvent } from "@app/events";
|
||||
import { errors } from "./errors.js";
|
||||
|
||||
/** Slumpad, läsbar inbjudningskod utan lättförväxlade tecken. */
|
||||
export function generateInviteCode(): string {
|
||||
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||
const bytes = randomBytes(8);
|
||||
let code = "";
|
||||
for (const b of bytes) code += alphabet[b % alphabet.length];
|
||||
return code;
|
||||
}
|
||||
|
||||
export function sha256(input: string): string {
|
||||
return createHash("sha256").update(input).digest("hex");
|
||||
}
|
||||
|
||||
export function todayIso(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function currentMonth(): string {
|
||||
return new Date().toISOString().slice(0, 7);
|
||||
}
|
||||
|
||||
/** Verifiera hushållsmedlemskap – enda vägen till hushållsdata (spec §56). */
|
||||
export async function requireMembership(
|
||||
db: Database,
|
||||
householdId: string,
|
||||
userId: string,
|
||||
): Promise<{ role: string; portionFactor: number }> {
|
||||
const [member] = await db
|
||||
.select()
|
||||
.from(schema.householdMembers)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.householdMembers.householdId, householdId),
|
||||
eq(schema.householdMembers.userId, userId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (!member) throw errors.forbidden("Du är inte medlem i det här hushållet.");
|
||||
return { role: member.role, portionFactor: member.portionFactor };
|
||||
}
|
||||
|
||||
/** Hämta användarens aktiva hushåll (första medlemskapet) eller null. */
|
||||
export async function getActiveHouseholdId(db: Database, userId: string): Promise<string | null> {
|
||||
const [member] = await db
|
||||
.select({ householdId: schema.householdMembers.householdId })
|
||||
.from(schema.householdMembers)
|
||||
.where(eq(schema.householdMembers.userId, userId))
|
||||
.orderBy(schema.householdMembers.joinedAt)
|
||||
.limit(1);
|
||||
return member?.householdId ?? null;
|
||||
}
|
||||
|
||||
/** Som ovan men kastar 404 om användaren saknar hushåll. */
|
||||
export async function requireActiveHousehold(db: Database, userId: string): Promise<string> {
|
||||
const id = await getActiveHouseholdId(db, userId);
|
||||
if (!id) {
|
||||
throw errors.notFound("Du har inget hushåll ännu. Skapa ett under Hemma → Hushåll.");
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
/** Skriv domänhändelse till outboxen (spec §55) – i samma transaktion när tx skickas in. */
|
||||
export async function emitEvent<T extends EventType>(
|
||||
db: Database,
|
||||
event: NewDomainEvent<T>,
|
||||
): Promise<void> {
|
||||
await db.insert(schema.domainEvents).values({
|
||||
type: event.type,
|
||||
userId: event.userId ?? null,
|
||||
householdId: event.householdId ?? null,
|
||||
payload: event.payload as Record<string, unknown>,
|
||||
correlationId: event.correlationId ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
/** Audit-logg (spec §56). */
|
||||
export async function audit(
|
||||
db: Database,
|
||||
entry: {
|
||||
actorUserId?: string | undefined;
|
||||
actorType?: "user" | "admin" | "system" | "worker";
|
||||
action: string;
|
||||
targetType?: string;
|
||||
targetId?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
ip?: string | undefined;
|
||||
correlationId?: string | undefined;
|
||||
},
|
||||
): Promise<void> {
|
||||
await db.insert(schema.auditLogs).values({
|
||||
actorUserId: entry.actorUserId ?? null,
|
||||
actorType: entry.actorType ?? "user",
|
||||
action: entry.action,
|
||||
targetType: entry.targetType ?? null,
|
||||
targetId: entry.targetId ?? null,
|
||||
metadata: entry.metadata ?? null,
|
||||
ip: entry.ip ?? null,
|
||||
correlationId: entry.correlationId ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
export function newCorrelationId(): string {
|
||||
return randomUUID();
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { schema, type Database } from "@app/database";
|
||||
import {
|
||||
DEFAULT_LOCALE_PREFERENCES,
|
||||
toLocaleContext,
|
||||
type LocaleContext,
|
||||
type UserLocalePreferences,
|
||||
} from "@app/shared-types";
|
||||
|
||||
/** Läs användarens locale-preferenser med SE-defaults som fallback (i18n-spec §6). */
|
||||
export async function loadLocalePreferences(
|
||||
db: Database,
|
||||
userId: string,
|
||||
): Promise<UserLocalePreferences> {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(schema.userLocalePreferences)
|
||||
.where(eq(schema.userLocalePreferences.userId, userId))
|
||||
.limit(1);
|
||||
if (!row) return { ...DEFAULT_LOCALE_PREFERENCES };
|
||||
return {
|
||||
languageTag: row.languageTag,
|
||||
regionCode: row.regionCode,
|
||||
timeZone: row.timeZone,
|
||||
measurementSystem: row.measurementSystem,
|
||||
temperatureUnit: row.temperatureUnit,
|
||||
currencyCode: row.currencyCode,
|
||||
firstDayOfWeek: row.firstDayOfWeek,
|
||||
use24HourTime: row.use24HourTime,
|
||||
};
|
||||
}
|
||||
|
||||
/** LocaleContext till varje AAMOS-anrop (i18n-spec §22). */
|
||||
export async function getLocaleContext(db: Database, userId: string): Promise<LocaleContext> {
|
||||
return toLocaleContext(await loadLocalePreferences(db, userId));
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
import nodemailer, { type Transporter } from "nodemailer";
|
||||
import { BRAND } from "@app/shared-types";
|
||||
|
||||
/**
|
||||
* E-postutskick med utbytbar transport.
|
||||
*
|
||||
* - EMAIL_MODE=log (dev/test): skriver mejlet till loggen i stället för att skicka.
|
||||
* - EMAIL_MODE=smtp (produktion): kopplas till vald leverantör (SES/Postmark/Resend)
|
||||
* i lanseringsfasen – transporten är en funktion, leverantörsbytet är en fil.
|
||||
*
|
||||
* Innehållet byggs som mallnyckel + variabler per språk (i18n-spec §26) –
|
||||
* aldrig inbakad text i anropskoden.
|
||||
*/
|
||||
|
||||
export interface MailMessage {
|
||||
to: string;
|
||||
subject: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface Mailer {
|
||||
send(message: MailMessage): Promise<void>;
|
||||
}
|
||||
|
||||
class LogMailer implements Mailer {
|
||||
async send(message: MailMessage): Promise<void> {
|
||||
console.log(
|
||||
`[mailer:log] till=${message.to} ämne="${message.subject}"\n${message.text
|
||||
.split("\n")
|
||||
.map((l) => ` | ${l}`)
|
||||
.join("\n")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export interface SmtpSettings {
|
||||
host: string;
|
||||
port: number;
|
||||
secure: boolean;
|
||||
user: string;
|
||||
pass: string;
|
||||
from: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Riktig SMTP via nodemailer. Leverantörsoberoende: fungerar med SES,
|
||||
* Postmark, Resend, Brevo, egen mailserver – allt som talar standard-SMTP.
|
||||
* Verifierad med riktig SMTP-rundtur (HELO → MAIL FROM → DATA) mot lokal server.
|
||||
*/
|
||||
class SmtpMailer implements Mailer {
|
||||
private readonly transporter: Transporter;
|
||||
private readonly from: string;
|
||||
|
||||
constructor(settings: SmtpSettings) {
|
||||
if (!settings.host) {
|
||||
// Högljutt fel i stället för tyst tappade mejl.
|
||||
throw new Error("EMAIL_MODE=smtp kräver SMTP_HOST (samt SMTP_PORT/USER/PASS/MAIL_FROM).");
|
||||
}
|
||||
if (!settings.from) {
|
||||
throw new Error("EMAIL_MODE=smtp kräver MAIL_FROM (avsändaradress).");
|
||||
}
|
||||
this.from = settings.from;
|
||||
this.transporter = nodemailer.createTransport({
|
||||
host: settings.host,
|
||||
port: settings.port,
|
||||
secure: settings.secure, // true = SMTPS (465); false = STARTTLS/klartext (587/25)
|
||||
...(settings.user ? { auth: { user: settings.user, pass: settings.pass } } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/** Verifiera anslutning + inloggning vid uppstart. */
|
||||
async verify(): Promise<void> {
|
||||
await this.transporter.verify();
|
||||
}
|
||||
|
||||
async send(message: MailMessage): Promise<void> {
|
||||
await this.transporter.sendMail({
|
||||
from: this.from,
|
||||
to: message.to,
|
||||
subject: message.subject,
|
||||
text: message.text,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function createMailer(mode: string, smtp?: SmtpSettings): Mailer {
|
||||
if (mode === "smtp") {
|
||||
if (!smtp) throw new Error("EMAIL_MODE=smtp kräver SMTP-inställningar.");
|
||||
return new SmtpMailer(smtp);
|
||||
}
|
||||
return new LogMailer();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mallar (nyckel + variabler, per språk – samma princip som notismallarna)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type MailTemplate = (vars: Record<string, string>) => { subject: string; text: string };
|
||||
|
||||
const TEMPLATES: Record<string, Record<string, MailTemplate>> = {
|
||||
sv: {
|
||||
"auth.verify_email": (v) => ({
|
||||
subject: `Bekräfta din e-postadress – ${BRAND.name}`,
|
||||
text:
|
||||
`Välkommen till ${BRAND.name}!\n\n` +
|
||||
`Bekräfta att ${v.email} är din adress genom att öppna länken i appen:\n${v.link}\n\n` +
|
||||
`Länken gäller i 24 timmar. Appen fungerar även utan bekräftelse, men vissa ` +
|
||||
`funktioner (t.ex. lösenordsåterställning) blir säkrare med verifierad adress.\n\n${BRAND.name}`,
|
||||
}),
|
||||
"auth.password_reset": (v) => ({
|
||||
subject: `Återställ ditt lösenord – ${BRAND.name}`,
|
||||
text:
|
||||
`Hej!\n\nDu (eller någon annan) har begärt att återställa lösenordet för ${v.email}.\n\n` +
|
||||
`Öppna länken i appen inom 30 minuter:\n${v.link}\n\n` +
|
||||
`Om du inte begärde detta kan du ignorera mejlet – lösenordet ändras inte.\n\n${BRAND.name}`,
|
||||
}),
|
||||
},
|
||||
en: {
|
||||
"auth.verify_email": (v) => ({
|
||||
subject: `Confirm your email – ${BRAND.name}`,
|
||||
text:
|
||||
`Welcome to ${BRAND.name}!\n\n` +
|
||||
`Confirm that ${v.email} is your address by opening this link in the app:\n${v.link}\n\n` +
|
||||
`The link is valid for 24 hours. The app works without confirmation, but some ` +
|
||||
`features (like password reset) are safer with a verified address.\n\n${BRAND.name}`,
|
||||
}),
|
||||
"auth.password_reset": (v) => ({
|
||||
subject: `Reset your password – ${BRAND.name}`,
|
||||
text:
|
||||
`Hi!\n\nA password reset was requested for ${v.email}.\n\n` +
|
||||
`Open this link in the app within 30 minutes:\n${v.link}\n\n` +
|
||||
`If you didn't request this, you can ignore this email – your password stays unchanged.\n\n${BRAND.name}`,
|
||||
}),
|
||||
},
|
||||
es: {
|
||||
"auth.verify_email": (v) => ({
|
||||
subject: `Confirma tu correo – ${BRAND.name}`,
|
||||
text:
|
||||
`¡Bienvenido a ${BRAND.name}!\n\n` +
|
||||
`Confirma que ${v.email} es tu dirección abriendo este enlace en la app:\n${v.link}\n\n` +
|
||||
`El enlace es válido durante 24 horas. La app funciona sin confirmación, pero algunas ` +
|
||||
`funciones (como restablecer la contraseña) son más seguras con la dirección verificada.\n\n${BRAND.name}`,
|
||||
}),
|
||||
"auth.password_reset": (v) => ({
|
||||
subject: `Restablece tu contraseña – ${BRAND.name}`,
|
||||
text:
|
||||
`¡Hola!\n\nSe ha solicitado restablecer la contraseña de ${v.email}.\n\n` +
|
||||
`Abre este enlace en la app en un plazo de 30 minutos:\n${v.link}\n\n` +
|
||||
`Si no lo solicitaste, ignora este correo: tu contraseña no cambiará.\n\n${BRAND.name}`,
|
||||
}),
|
||||
},
|
||||
it: {
|
||||
"auth.verify_email": (v) => ({
|
||||
subject: `Conferma la tua email – ${BRAND.name}`,
|
||||
text:
|
||||
`Benvenuto su ${BRAND.name}!\n\n` +
|
||||
`Conferma che ${v.email} è il tuo indirizzo aprendo questo link nell'app:\n${v.link}\n\n` +
|
||||
`Il link è valido per 24 ore. L'app funziona anche senza conferma, ma alcune ` +
|
||||
`funzioni (come il ripristino della password) sono più sicure con l'indirizzo verificato.\n\n${BRAND.name}`,
|
||||
}),
|
||||
"auth.password_reset": (v) => ({
|
||||
subject: `Reimposta la tua password – ${BRAND.name}`,
|
||||
text:
|
||||
`Ciao!\n\nÈ stato richiesto il ripristino della password per ${v.email}.\n\n` +
|
||||
`Apri questo link nell'app entro 30 minuti:\n${v.link}\n\n` +
|
||||
`Se non l'hai richiesto, ignora questa email: la password non cambierà.\n\n${BRAND.name}`,
|
||||
}),
|
||||
},
|
||||
de: {
|
||||
"auth.verify_email": (v) => ({
|
||||
subject: `Bestätige deine E-Mail-Adresse – ${BRAND.name}`,
|
||||
text:
|
||||
`Willkommen bei ${BRAND.name}!\n\n` +
|
||||
`Bestätige, dass ${v.email} deine Adresse ist, indem du diesen Link in der App öffnest:\n${v.link}\n\n` +
|
||||
`Der Link ist 24 Stunden gültig. Die App funktioniert auch ohne Bestätigung, aber manche ` +
|
||||
`Funktionen (z. B. Passwort-Zurücksetzen) sind mit verifizierter Adresse sicherer.\n\n${BRAND.name}`,
|
||||
}),
|
||||
"auth.password_reset": (v) => ({
|
||||
subject: `Setze dein Passwort zurück – ${BRAND.name}`,
|
||||
text:
|
||||
`Hallo!\n\nFür ${v.email} wurde ein Passwort-Reset angefordert.\n\n` +
|
||||
`Öffne diesen Link innerhalb von 30 Minuten in der App:\n${v.link}\n\n` +
|
||||
`Falls du das nicht warst, ignoriere diese E-Mail – dein Passwort bleibt unverändert.\n\n${BRAND.name}`,
|
||||
}),
|
||||
},
|
||||
fr: {
|
||||
"auth.verify_email": (v) => ({
|
||||
subject: `Confirmez votre e-mail – ${BRAND.name}`,
|
||||
text:
|
||||
`Bienvenue sur ${BRAND.name} !\n\n` +
|
||||
`Confirmez que ${v.email} est bien votre adresse en ouvrant ce lien dans l'app :\n${v.link}\n\n` +
|
||||
`Le lien est valable 24 heures. L'app fonctionne sans confirmation, mais certaines ` +
|
||||
`fonctions (comme la réinitialisation du mot de passe) sont plus sûres avec une adresse vérifiée.\n\n${BRAND.name}`,
|
||||
}),
|
||||
"auth.password_reset": (v) => ({
|
||||
subject: `Réinitialisez votre mot de passe – ${BRAND.name}`,
|
||||
text:
|
||||
`Bonjour !\n\nUne réinitialisation du mot de passe a été demandée pour ${v.email}.\n\n` +
|
||||
`Ouvrez ce lien dans l'app sous 30 minutes :\n${v.link}\n\n` +
|
||||
`Si vous n'êtes pas à l'origine de cette demande, ignorez cet e-mail : le mot de passe ne changera pas.\n\n${BRAND.name}`,
|
||||
}),
|
||||
},
|
||||
da: {
|
||||
"auth.verify_email": (v) => ({
|
||||
subject: `Bekræft din e-mailadresse – ${BRAND.name}`,
|
||||
text:
|
||||
`Velkommen til ${BRAND.name}!\n\n` +
|
||||
`Bekræft, at ${v.email} er din adresse ved at åbne linket i appen:\n${v.link}\n\n` +
|
||||
`Linket gælder i 24 timer. Appen fungerer også uden bekræftelse, men nogle funktioner (fx nulstilling af adgangskode) er sikrere med bekræftet adresse.\n\n${BRAND.name}`,
|
||||
}),
|
||||
"auth.password_reset": (v) => ({
|
||||
subject: `Nulstil din adgangskode – ${BRAND.name}`,
|
||||
text:
|
||||
`Hej!\n\nDer er anmodet om nulstilling af adgangskoden for ${v.email}.\n\n` +
|
||||
`Åbn linket i appen inden for 30 minutter:\n${v.link}\n\n` +
|
||||
`Hvis du ikke har anmodet om dette, kan du ignorere denne mail – adgangskoden ændres ikke.\n\n${BRAND.name}`,
|
||||
}),
|
||||
},
|
||||
nb: {
|
||||
"auth.verify_email": (v) => ({
|
||||
subject: `Bekreft e-postadressen din – ${BRAND.name}`,
|
||||
text:
|
||||
`Velkommen til ${BRAND.name}!\n\n` +
|
||||
`Bekreft at ${v.email} er adressen din ved å åpne lenken i appen:\n${v.link}\n\n` +
|
||||
`Lenken gjelder i 24 timer. Appen fungerer også uten bekreftelse, men noen funksjoner (f.eks. tilbakestilling av passord) er tryggere med bekreftet adresse.\n\n${BRAND.name}`,
|
||||
}),
|
||||
"auth.password_reset": (v) => ({
|
||||
subject: `Tilbakestill passordet ditt – ${BRAND.name}`,
|
||||
text:
|
||||
`Hei!\n\nDet er bedt om tilbakestilling av passordet for ${v.email}.\n\n` +
|
||||
`Åpne lenken i appen innen 30 minutter:\n${v.link}\n\n` +
|
||||
`Hvis du ikke ba om dette, kan du ignorere denne e-posten – passordet endres ikke.\n\n${BRAND.name}`,
|
||||
}),
|
||||
},
|
||||
fi: {
|
||||
"auth.verify_email": (v) => ({
|
||||
subject: `Vahvista sähköpostiosoitteesi – ${BRAND.name}`,
|
||||
text:
|
||||
`Tervetuloa palveluun ${BRAND.name}!\n\n` +
|
||||
`Vahvista, että ${v.email} on osoitteesi avaamalla linkki sovelluksessa:\n${v.link}\n\n` +
|
||||
`Linkki on voimassa 24 tuntia. Sovellus toimii ilman vahvistustakin, mutta jotkin toiminnot (kuten salasanan nollaus) ovat turvallisempia vahvistetulla osoitteella.\n\n${BRAND.name}`,
|
||||
}),
|
||||
"auth.password_reset": (v) => ({
|
||||
subject: `Nollaa salasanasi – ${BRAND.name}`,
|
||||
text:
|
||||
`Hei!\n\nOsoitteelle ${v.email} on pyydetty salasanan nollausta.\n\n` +
|
||||
`Avaa linkki sovelluksessa 30 minuutin kuluessa:\n${v.link}\n\n` +
|
||||
`Jos et pyytänyt tätä, voit ohittaa viestin – salasana ei muutu.\n\n${BRAND.name}`,
|
||||
}),
|
||||
},
|
||||
nl: {
|
||||
"auth.verify_email": (v) => ({
|
||||
subject: `Bevestig je e-mailadres – ${BRAND.name}`,
|
||||
text:
|
||||
`Welkom bij ${BRAND.name}!\n\n` +
|
||||
`Bevestig dat ${v.email} jouw adres is door deze link in de app te openen:\n${v.link}\n\n` +
|
||||
`De link is 24 uur geldig. De app werkt ook zonder bevestiging, maar sommige functies (zoals wachtwoordherstel) zijn veiliger met een bevestigd adres.\n\n${BRAND.name}`,
|
||||
}),
|
||||
"auth.password_reset": (v) => ({
|
||||
subject: `Stel je wachtwoord opnieuw in – ${BRAND.name}`,
|
||||
text:
|
||||
`Hoi!\n\nEr is een wachtwoordherstel aangevraagd voor ${v.email}.\n\n` +
|
||||
`Open deze link binnen 30 minuten in de app:\n${v.link}\n\n` +
|
||||
`Als jij dit niet was, kun je deze e-mail negeren – het wachtwoord verandert niet.\n\n${BRAND.name}`,
|
||||
}),
|
||||
},
|
||||
pl: {
|
||||
"auth.verify_email": (v) => ({
|
||||
subject: `Potwierdź swój adres e-mail – ${BRAND.name}`,
|
||||
text:
|
||||
`Witamy w ${BRAND.name}!\n\n` +
|
||||
`Potwierdź, że ${v.email} to Twój adres, otwierając link w aplikacji:\n${v.link}\n\n` +
|
||||
`Link jest ważny 24 godziny. Aplikacja działa bez potwierdzenia, ale niektóre funkcje (np. resetowanie hasła) są bezpieczniejsze ze zweryfikowanym adresem.\n\n${BRAND.name}`,
|
||||
}),
|
||||
"auth.password_reset": (v) => ({
|
||||
subject: `Zresetuj swoje hasło – ${BRAND.name}`,
|
||||
text:
|
||||
`Cześć!\n\nPoproszono o zresetowanie hasła dla ${v.email}.\n\n` +
|
||||
`Otwórz link w aplikacji w ciągu 30 minut:\n${v.link}\n\n` +
|
||||
`Jeśli to nie Ty, zignoruj tę wiadomość – hasło się nie zmieni.\n\n${BRAND.name}`,
|
||||
}),
|
||||
},
|
||||
pt: {
|
||||
"auth.verify_email": (v) => ({
|
||||
subject: `Confirme o seu e-mail – ${BRAND.name}`,
|
||||
text:
|
||||
`Bem-vindo ao ${BRAND.name}!\n\n` +
|
||||
`Confirme que ${v.email} é o seu endereço abrindo este link na app:\n${v.link}\n\n` +
|
||||
`O link é válido por 24 horas. A app funciona sem confirmação, mas algumas funções (como repor a palavra-passe) são mais seguras com o endereço verificado.\n\n${BRAND.name}`,
|
||||
}),
|
||||
"auth.password_reset": (v) => ({
|
||||
subject: `Reponha a sua palavra-passe – ${BRAND.name}`,
|
||||
text:
|
||||
`Olá!\n\nFoi pedida a reposição da palavra-passe de ${v.email}.\n\n` +
|
||||
`Abra este link na app no prazo de 30 minutos:\n${v.link}\n\n` +
|
||||
`Se não fez este pedido, ignore este e-mail – a palavra-passe não será alterada.\n\n${BRAND.name}`,
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
export function renderMail(
|
||||
templateKey: string,
|
||||
languageTag: string,
|
||||
vars: Record<string, string>,
|
||||
): { subject: string; text: string } {
|
||||
const lang = languageTag.split("-")[0] ?? "sv";
|
||||
const template = TEMPLATES[lang]?.[templateKey] ?? TEMPLATES.sv?.[templateKey];
|
||||
if (!template) throw new Error(`Okänd mejlmall: ${templateKey}`);
|
||||
return template(vars);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { randomBytes, scrypt, timingSafeEqual } from "node:crypto";
|
||||
|
||||
/**
|
||||
* Lösenordshantering med scrypt ur node:crypto – ingen native-dependency,
|
||||
* OWASP-rekommenderade parametrar (N=2^15, r=8, p=1, 32 byte).
|
||||
* Format: scrypt$N$r$p$salt_b64$hash_b64 (självbeskrivande → parametrar kan höjas).
|
||||
*/
|
||||
|
||||
const N = 32_768;
|
||||
const R = 8;
|
||||
const P = 1;
|
||||
const KEYLEN = 32;
|
||||
|
||||
function scryptAsync(
|
||||
password: string,
|
||||
salt: Buffer,
|
||||
n: number,
|
||||
r: number,
|
||||
p: number,
|
||||
): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
scrypt(password, salt, KEYLEN, { N: n, r, p, maxmem: 128 * n * r * 2 }, (err, key) => {
|
||||
if (err) reject(err);
|
||||
else resolve(key);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
const salt = randomBytes(16);
|
||||
const hash = await scryptAsync(password, salt, N, R, P);
|
||||
return `scrypt$${N}$${R}$${P}$${salt.toString("base64")}$${hash.toString("base64")}`;
|
||||
}
|
||||
|
||||
export async function verifyPassword(password: string, stored: string): Promise<boolean> {
|
||||
const parts = stored.split("$");
|
||||
if (parts.length !== 6 || parts[0] !== "scrypt") return false;
|
||||
const n = Number(parts[1]);
|
||||
const r = Number(parts[2]);
|
||||
const p = Number(parts[3]);
|
||||
const salt = Buffer.from(parts[4]!, "base64");
|
||||
const expected = Buffer.from(parts[5]!, "base64");
|
||||
if (!Number.isFinite(n) || !Number.isFinite(r) || !Number.isFinite(p)) return false;
|
||||
try {
|
||||
const actual = await scryptAsync(password, salt, n, r, p);
|
||||
return actual.length === expected.length && timingSafeEqual(actual, expected);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
||||
|
||||
/**
|
||||
* TOTP (RFC 6238) utan externa beroenden – HMAC-SHA1, 6 siffror, 30 s steg.
|
||||
* Används för admin-2FA. Inga tredjepartspaket i autentiseringskedjan
|
||||
* (samma princip som scrypt-valet för lösenord).
|
||||
*/
|
||||
|
||||
const BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||
|
||||
export function base32Encode(buf: Buffer): string {
|
||||
let bits = 0;
|
||||
let value = 0;
|
||||
let out = "";
|
||||
for (const byte of buf) {
|
||||
value = (value << 8) | byte;
|
||||
bits += 8;
|
||||
while (bits >= 5) {
|
||||
out += BASE32_ALPHABET[(value >>> (bits - 5)) & 31];
|
||||
bits -= 5;
|
||||
}
|
||||
}
|
||||
if (bits > 0) out += BASE32_ALPHABET[(value << (5 - bits)) & 31];
|
||||
return out;
|
||||
}
|
||||
|
||||
export function base32Decode(s: string): Buffer {
|
||||
const clean = s.toUpperCase().replace(/=+$/, "").replace(/\s/g, "");
|
||||
let bits = 0;
|
||||
let value = 0;
|
||||
const out: number[] = [];
|
||||
for (const ch of clean) {
|
||||
const idx = BASE32_ALPHABET.indexOf(ch);
|
||||
if (idx === -1) throw new Error("Ogiltig base32");
|
||||
value = (value << 5) | idx;
|
||||
bits += 5;
|
||||
if (bits >= 8) {
|
||||
out.push((value >>> (bits - 8)) & 0xff);
|
||||
bits -= 8;
|
||||
}
|
||||
}
|
||||
return Buffer.from(out);
|
||||
}
|
||||
|
||||
/** Nytt slumpat TOTP-secret (160 bitar enligt RFC 4226-rekommendation). */
|
||||
export function generateTotpSecret(): string {
|
||||
return base32Encode(randomBytes(20));
|
||||
}
|
||||
|
||||
function hotp(secretBase32: string, counter: number): string {
|
||||
const key = base32Decode(secretBase32);
|
||||
const msg = Buffer.alloc(8);
|
||||
msg.writeBigUInt64BE(BigInt(counter));
|
||||
const digest = createHmac("sha1", key).update(msg).digest();
|
||||
const offset = digest[digest.length - 1]! & 0x0f;
|
||||
const code =
|
||||
((digest[offset]! & 0x7f) << 24) |
|
||||
(digest[offset + 1]! << 16) |
|
||||
(digest[offset + 2]! << 8) |
|
||||
digest[offset + 3]!;
|
||||
return String(code % 1_000_000).padStart(6, "0");
|
||||
}
|
||||
|
||||
export function totpCode(secretBase32: string, atMs = Date.now(), stepSeconds = 30): string {
|
||||
return hotp(secretBase32, Math.floor(atMs / 1000 / stepSeconds));
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifiera med ±1 stegs fönster (klockdrift). Konstanttidsjämförelse.
|
||||
* Returnerar det matchade steget (för engångsskydd) eller null.
|
||||
*/
|
||||
export function verifyTotp(
|
||||
secretBase32: string,
|
||||
code: string,
|
||||
atMs = Date.now(),
|
||||
stepSeconds = 30,
|
||||
): number | null {
|
||||
const normalized = code.replace(/\s/g, "");
|
||||
if (!/^\d{6}$/.test(normalized)) return null;
|
||||
const step = Math.floor(atMs / 1000 / stepSeconds);
|
||||
for (const candidate of [step, step - 1, step + 1]) {
|
||||
const expected = hotp(secretBase32, candidate);
|
||||
if (timingSafeEqual(Buffer.from(expected), Buffer.from(normalized))) return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** otpauth-URL för autentiseringsappar (Google Authenticator, 1Password …). */
|
||||
export function otpauthUrl(secretBase32: string, accountName: string, issuer: string): string {
|
||||
const label = encodeURIComponent(`${issuer}:${accountName}`);
|
||||
return `otpauth://totp/${label}?secret=${secretBase32}&issuer=${encodeURIComponent(issuer)}&algorithm=SHA1&digits=6&period=30`;
|
||||
}
|
||||
Reference in New Issue
Block a user