chore(docker): .dockerignore angepasst; lokale Build-Schritte in Rebuild-Skripten; Doku/README zu production vs production-prebuilt aktualisiert
This commit is contained in:
@@ -1,8 +1,11 @@
|
||||
import { os } from "@orpc/server";
|
||||
import { z } from "zod";
|
||||
import { randomUUID } from "crypto";
|
||||
import { createKV } from "../lib/create-kv.js";
|
||||
import { config } from "dotenv";
|
||||
import bcrypt from "bcrypt";
|
||||
import { setCookie } from "hono/cookie";
|
||||
import { checkLoginRateLimit, getClientIP, resetLoginRateLimit } from "../lib/rate-limiter.js";
|
||||
import { generateCSRFToken, getSessionFromCookies, validateCSRFToken, rotateSession, COOKIE_OPTIONS, SESSION_COOKIE_NAME, CSRF_COOKIE_NAME, sessionsKV, usersKV } from "../lib/auth.js";
|
||||
// Load environment variables from .env file
|
||||
config();
|
||||
const UserSchema = z.object({
|
||||
@@ -18,18 +21,63 @@ const SessionSchema = z.object({
|
||||
userId: z.string(),
|
||||
expiresAt: z.string(),
|
||||
createdAt: z.string(),
|
||||
csrfToken: z.string().optional(),
|
||||
});
|
||||
const usersKV = createKV("users");
|
||||
const sessionsKV = createKV("sessions");
|
||||
// Simple password hashing (in production, use bcrypt or similar)
|
||||
const hashPassword = (password) => {
|
||||
return Buffer.from(password).toString('base64');
|
||||
// Use shared KV stores from auth.ts to avoid duplication
|
||||
// Password hashing using bcrypt
|
||||
const BCRYPT_PREFIX = "$2"; // $2a, $2b, $2y
|
||||
const isBase64Hash = (hash) => {
|
||||
if (hash.startsWith(BCRYPT_PREFIX))
|
||||
return false;
|
||||
try {
|
||||
const decoded = Buffer.from(hash, 'base64');
|
||||
// If re-encoding yields the same string and the decoded buffer is valid UTF-8, treat as base64
|
||||
const reencoded = decoded.toString('base64');
|
||||
// Additionally ensure that decoding does not produce too short/empty unless original was empty
|
||||
return reencoded === hash && decoded.toString('utf8').length > 0;
|
||||
}
|
||||
catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const verifyPassword = (password, hash) => {
|
||||
return hashPassword(password) === hash;
|
||||
const hashPassword = async (password) => {
|
||||
return bcrypt.hash(password, 10);
|
||||
};
|
||||
const verifyPassword = async (password, hash) => {
|
||||
if (hash.startsWith(BCRYPT_PREFIX)) {
|
||||
return bcrypt.compare(password, hash);
|
||||
}
|
||||
if (isBase64Hash(hash)) {
|
||||
const base64OfPassword = Buffer.from(password).toString('base64');
|
||||
return base64OfPassword === hash;
|
||||
}
|
||||
// Unknown format -> fail closed
|
||||
return false;
|
||||
};
|
||||
// Export hashPassword for external use (e.g., generating hashes for .env)
|
||||
export const generatePasswordHash = hashPassword;
|
||||
// Migrate all legacy Base64 password hashes to bcrypt on server startup
|
||||
const migrateLegacyHashesOnStartup = async () => {
|
||||
const users = await usersKV.getAllItems();
|
||||
let migratedCount = 0;
|
||||
for (const user of users) {
|
||||
if (isBase64Hash(user.passwordHash)) {
|
||||
try {
|
||||
const plaintext = Buffer.from(user.passwordHash, 'base64').toString('utf8');
|
||||
const bcryptHash = await hashPassword(plaintext);
|
||||
const updatedUser = { ...user, passwordHash: bcryptHash };
|
||||
await usersKV.setItem(user.id, updatedUser);
|
||||
migratedCount += 1;
|
||||
}
|
||||
catch {
|
||||
// ignore individual failures; continue with others
|
||||
}
|
||||
}
|
||||
}
|
||||
if (migratedCount > 0) {
|
||||
console.log(`🔄 Migrated ${migratedCount} legacy Base64 password hash(es) to bcrypt at startup.`);
|
||||
}
|
||||
};
|
||||
// Initialize default owner account
|
||||
const initializeOwner = async () => {
|
||||
const existingUsers = await usersKV.getAllItems();
|
||||
@@ -37,7 +85,12 @@ const initializeOwner = async () => {
|
||||
const ownerId = randomUUID();
|
||||
// Get admin credentials from environment variables
|
||||
const adminUsername = process.env.ADMIN_USERNAME || "owner";
|
||||
const adminPasswordHash = process.env.ADMIN_PASSWORD_HASH || hashPassword("admin123");
|
||||
let adminPasswordHash = process.env.ADMIN_PASSWORD_HASH || await hashPassword("admin123");
|
||||
// If provided hash looks like legacy Base64, decode to plaintext and re-hash with bcrypt
|
||||
if (process.env.ADMIN_PASSWORD_HASH && isBase64Hash(process.env.ADMIN_PASSWORD_HASH)) {
|
||||
const plaintext = Buffer.from(process.env.ADMIN_PASSWORD_HASH, 'base64').toString('utf8');
|
||||
adminPasswordHash = await hashPassword(plaintext);
|
||||
}
|
||||
const adminEmail = process.env.ADMIN_EMAIL || "owner@stargirlnails.de";
|
||||
const owner = {
|
||||
id: ownerId,
|
||||
@@ -51,21 +104,48 @@ const initializeOwner = async () => {
|
||||
console.log(`✅ Admin account created: username="${adminUsername}", email="${adminEmail}"`);
|
||||
}
|
||||
};
|
||||
// Initialize on module load
|
||||
initializeOwner();
|
||||
// Initialize on module load: first migrate legacy hashes, then ensure owner exists
|
||||
(async () => {
|
||||
try {
|
||||
await migrateLegacyHashesOnStartup();
|
||||
}
|
||||
finally {
|
||||
await initializeOwner();
|
||||
}
|
||||
})();
|
||||
const login = os
|
||||
.input(z.object({
|
||||
username: z.string(),
|
||||
password: z.string(),
|
||||
}))
|
||||
.handler(async ({ input }) => {
|
||||
.handler(async ({ input, context }) => {
|
||||
const ip = getClientIP(context.req.raw.headers);
|
||||
const users = await usersKV.getAllItems();
|
||||
const user = users.find(u => u.username === input.username);
|
||||
if (!user || !verifyPassword(input.password, user.passwordHash)) {
|
||||
if (!user) {
|
||||
const rl = checkLoginRateLimit(ip);
|
||||
if (!rl.allowed) {
|
||||
throw new Error(`Zu viele Login-Versuche. Bitte versuche es in ${rl.retryAfterSeconds} Sekunden erneut.`);
|
||||
}
|
||||
throw new Error("Invalid credentials");
|
||||
}
|
||||
// Create session
|
||||
const isValid = await verifyPassword(input.password, user.passwordHash);
|
||||
if (!isValid) {
|
||||
const rl = checkLoginRateLimit(ip);
|
||||
if (!rl.allowed) {
|
||||
throw new Error(`Zu viele Login-Versuche. Bitte versuche es in ${rl.retryAfterSeconds} Sekunden erneut.`);
|
||||
}
|
||||
throw new Error("Invalid credentials");
|
||||
}
|
||||
// Seamless migration: if stored hash is legacy Base64, upgrade to bcrypt
|
||||
if (isBase64Hash(user.passwordHash)) {
|
||||
const migratedHash = await hashPassword(input.password);
|
||||
const migratedUser = { ...user, passwordHash: migratedHash };
|
||||
await usersKV.setItem(user.id, migratedUser);
|
||||
}
|
||||
// Create session with CSRF token
|
||||
const sessionId = randomUUID();
|
||||
const csrfToken = generateCSRFToken();
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setHours(expiresAt.getHours() + 24); // 24 hours
|
||||
const session = {
|
||||
@@ -73,10 +153,19 @@ const login = os
|
||||
userId: user.id,
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
createdAt: new Date().toISOString(),
|
||||
csrfToken,
|
||||
};
|
||||
await sessionsKV.setItem(sessionId, session);
|
||||
// Optional: Reset login attempts on successful login
|
||||
resetLoginRateLimit(ip);
|
||||
// Set cookies in response
|
||||
setCookie(context, SESSION_COOKIE_NAME, sessionId, COOKIE_OPTIONS);
|
||||
setCookie(context, CSRF_COOKIE_NAME, csrfToken, {
|
||||
...COOKIE_OPTIONS,
|
||||
httpOnly: false, // CSRF token needs to be readable by JavaScript
|
||||
});
|
||||
// Return only user object (no sessionId in response)
|
||||
return {
|
||||
sessionId,
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
@@ -86,22 +175,24 @@ const login = os
|
||||
};
|
||||
});
|
||||
const logout = os
|
||||
.input(z.string()) // sessionId
|
||||
.handler(async ({ input }) => {
|
||||
await sessionsKV.removeItem(input);
|
||||
.input(z.object({})) // No input needed - session comes from cookies
|
||||
.handler(async ({ context }) => {
|
||||
const session = await getSessionFromCookies(context);
|
||||
if (session) {
|
||||
await sessionsKV.removeItem(session.id);
|
||||
}
|
||||
// Clear both cookies with correct options
|
||||
setCookie(context, SESSION_COOKIE_NAME, '', { ...COOKIE_OPTIONS, maxAge: 0 });
|
||||
setCookie(context, CSRF_COOKIE_NAME, '', { ...COOKIE_OPTIONS, httpOnly: false, maxAge: 0 });
|
||||
return { success: true };
|
||||
});
|
||||
const verifySession = os
|
||||
.input(z.string()) // sessionId
|
||||
.handler(async ({ input }) => {
|
||||
const session = await sessionsKV.getItem(input);
|
||||
.input(z.object({})) // No input needed - session comes from cookies
|
||||
.handler(async ({ context }) => {
|
||||
const session = await getSessionFromCookies(context);
|
||||
if (!session) {
|
||||
throw new Error("Invalid session");
|
||||
}
|
||||
if (new Date(session.expiresAt) < new Date()) {
|
||||
await sessionsKV.removeItem(input);
|
||||
throw new Error("Session expired");
|
||||
}
|
||||
const user = await usersKV.getItem(session.userId);
|
||||
if (!user) {
|
||||
throw new Error("User not found");
|
||||
@@ -117,12 +208,11 @@ const verifySession = os
|
||||
});
|
||||
const changePassword = os
|
||||
.input(z.object({
|
||||
sessionId: z.string(),
|
||||
currentPassword: z.string(),
|
||||
newPassword: z.string(),
|
||||
}))
|
||||
.handler(async ({ input }) => {
|
||||
const session = await sessionsKV.getItem(input.sessionId);
|
||||
.handler(async ({ input, context }) => {
|
||||
const session = await getSessionFromCookies(context);
|
||||
if (!session) {
|
||||
throw new Error("Invalid session");
|
||||
}
|
||||
@@ -130,14 +220,25 @@ const changePassword = os
|
||||
if (!user) {
|
||||
throw new Error("User not found");
|
||||
}
|
||||
if (!verifyPassword(input.currentPassword, user.passwordHash)) {
|
||||
// Validate CSRF token for password change
|
||||
await validateCSRFToken(context, session.id);
|
||||
const currentOk = await verifyPassword(input.currentPassword, user.passwordHash);
|
||||
if (!currentOk) {
|
||||
throw new Error("Current password is incorrect");
|
||||
}
|
||||
const updatedUser = {
|
||||
...user,
|
||||
passwordHash: hashPassword(input.newPassword),
|
||||
passwordHash: await hashPassword(input.newPassword),
|
||||
};
|
||||
await usersKV.setItem(user.id, updatedUser);
|
||||
// Implement session rotation after password change
|
||||
const newSession = await rotateSession(session.id, user.id);
|
||||
// Set new session and CSRF cookies
|
||||
setCookie(context, SESSION_COOKIE_NAME, newSession.id, COOKIE_OPTIONS);
|
||||
setCookie(context, CSRF_COOKIE_NAME, newSession.csrfToken, {
|
||||
...COOKIE_OPTIONS,
|
||||
httpOnly: false,
|
||||
});
|
||||
return { success: true };
|
||||
});
|
||||
export const router = {
|
||||
|
@@ -1,13 +1,19 @@
|
||||
import { call, os } from "@orpc/server";
|
||||
import { z } from "zod";
|
||||
import { randomUUID } from "crypto";
|
||||
import { createKV } from "../lib/create-kv.js";
|
||||
import { sanitizeText, sanitizeHtml, sanitizePhone } from "../lib/sanitize.js";
|
||||
import { sendEmail, sendEmailWithAGBAndCalendar, sendEmailWithInspirationPhoto } from "../lib/email.js";
|
||||
import { renderBookingPendingHTML, renderBookingConfirmedHTML, renderBookingCancelledHTML, renderAdminBookingNotificationHTML, renderBookingRescheduleProposalHTML, renderAdminRescheduleAcceptedHTML, renderAdminRescheduleDeclinedHTML } from "../lib/email-templates.js";
|
||||
import { os as baseOs, call as baseCall } from "@orpc/server";
|
||||
const osAny = baseOs;
|
||||
const os = (osAny.withContext ? osAny.withContext() : (osAny.context ? osAny.context() : baseOs));
|
||||
const call = baseCall;
|
||||
import { createORPCClient } from "@orpc/client";
|
||||
import { RPCLink } from "@orpc/client/fetch";
|
||||
import { checkBookingRateLimit } from "../lib/rate-limiter.js";
|
||||
import { checkBookingRateLimit, enforceAdminRateLimit } from "../lib/rate-limiter.js";
|
||||
import { validateEmail } from "../lib/email-validator.js";
|
||||
import { assertOwner, getSessionFromCookies } from "../lib/auth.js";
|
||||
// Using centrally typed os and call from rpc/index
|
||||
// Create a server-side client to call other RPC endpoints
|
||||
const serverPort = process.env.PORT ? parseInt(process.env.PORT) : 3000;
|
||||
const link = new RPCLink({ url: `http://localhost:${serverPort}/rpc` });
|
||||
@@ -188,10 +194,21 @@ const create = os
|
||||
await validateBookingAgainstRules(input.appointmentDate, input.appointmentTime, treatment.duration);
|
||||
// Check for booking conflicts
|
||||
await checkBookingConflicts(input.appointmentDate, input.appointmentTime, treatment.duration);
|
||||
// Sanitize user-provided fields before storage
|
||||
const sanitizedName = sanitizeText(input.customerName);
|
||||
const sanitizedPhone = input.customerPhone ? sanitizePhone(input.customerPhone) : undefined;
|
||||
const sanitizedNotes = input.notes ? sanitizeHtml(input.notes) : undefined;
|
||||
const id = randomUUID();
|
||||
const booking = {
|
||||
id,
|
||||
...input,
|
||||
treatmentId: input.treatmentId,
|
||||
customerName: sanitizedName,
|
||||
customerEmail: input.customerEmail,
|
||||
customerPhone: sanitizedPhone,
|
||||
appointmentDate: input.appointmentDate,
|
||||
appointmentTime: input.appointmentTime,
|
||||
notes: sanitizedNotes,
|
||||
inspirationPhoto: input.inspirationPhoto,
|
||||
bookedDurationMinutes: treatment.duration, // Snapshot treatment duration
|
||||
status: "pending",
|
||||
createdAt: new Date().toISOString()
|
||||
@@ -206,7 +223,7 @@ const create = os
|
||||
const formattedDate = formatDateGerman(input.appointmentDate);
|
||||
const homepageUrl = generateUrl();
|
||||
const html = await renderBookingPendingHTML({
|
||||
name: input.customerName,
|
||||
name: sanitizedName,
|
||||
date: input.appointmentDate,
|
||||
time: input.appointmentTime,
|
||||
statusUrl: bookingUrl
|
||||
@@ -214,7 +231,7 @@ const create = os
|
||||
await sendEmail({
|
||||
to: input.customerEmail,
|
||||
subject: "Deine Terminanfrage ist eingegangen",
|
||||
text: `Hallo ${input.customerName},\n\nwir haben deine Anfrage für ${formattedDate} um ${input.appointmentTime} erhalten. Wir bestätigen deinen Termin in Kürze. Du erhältst eine weitere E-Mail, sobald der Termin bestätigt ist.\n\nTermin-Status ansehen: ${bookingUrl}\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nLiebe Grüße\nStargirlnails Kiel`,
|
||||
text: `Hallo ${sanitizedName},\n\nwir haben deine Anfrage für ${formattedDate} um ${input.appointmentTime} erhalten. Wir bestätigen deinen Termin in Kürze. Du erhältst eine weitere E-Mail, sobald der Termin bestätigt ist.\n\nTermin-Status ansehen: ${bookingUrl}\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nLiebe Grüße\nStargirlnails Kiel`,
|
||||
html,
|
||||
}).catch(() => { });
|
||||
})();
|
||||
@@ -227,37 +244,37 @@ const create = os
|
||||
const treatment = allTreatments.find(t => t.id === input.treatmentId);
|
||||
const treatmentName = treatment?.name || "Unbekannte Behandlung";
|
||||
const adminHtml = await renderAdminBookingNotificationHTML({
|
||||
name: input.customerName,
|
||||
name: sanitizedName,
|
||||
date: input.appointmentDate,
|
||||
time: input.appointmentTime,
|
||||
treatment: treatmentName,
|
||||
phone: input.customerPhone || "Nicht angegeben",
|
||||
notes: input.notes,
|
||||
phone: sanitizedPhone || "Nicht angegeben",
|
||||
notes: sanitizedNotes,
|
||||
hasInspirationPhoto: !!input.inspirationPhoto
|
||||
});
|
||||
const homepageUrl = generateUrl();
|
||||
const adminText = `Neue Buchungsanfrage eingegangen:\n\n` +
|
||||
`Name: ${input.customerName}\n` +
|
||||
`Telefon: ${input.customerPhone || "Nicht angegeben"}\n` +
|
||||
`Name: ${sanitizedName}\n` +
|
||||
`Telefon: ${sanitizedPhone || "Nicht angegeben"}\n` +
|
||||
`Behandlung: ${treatmentName}\n` +
|
||||
`Datum: ${formatDateGerman(input.appointmentDate)}\n` +
|
||||
`Uhrzeit: ${input.appointmentTime}\n` +
|
||||
`${input.notes ? `Notizen: ${input.notes}\n` : ''}` +
|
||||
`${sanitizedNotes ? `Notizen: ${sanitizedNotes}\n` : ''}` +
|
||||
`Inspiration-Foto: ${input.inspirationPhoto ? 'Im Anhang verfügbar' : 'Kein Foto hochgeladen'}\n\n` +
|
||||
`Zur Website: ${homepageUrl}\n\n` +
|
||||
`Bitte logge dich in das Admin-Panel ein, um die Buchung zu bearbeiten.`;
|
||||
if (input.inspirationPhoto) {
|
||||
await sendEmailWithInspirationPhoto({
|
||||
to: process.env.ADMIN_EMAIL,
|
||||
subject: `Neue Buchungsanfrage - ${input.customerName}`,
|
||||
subject: `Neue Buchungsanfrage - ${sanitizedName}`,
|
||||
text: adminText,
|
||||
html: adminHtml,
|
||||
}, input.inspirationPhoto, input.customerName).catch(() => { });
|
||||
}, input.inspirationPhoto, sanitizedName).catch(() => { });
|
||||
}
|
||||
else {
|
||||
await sendEmail({
|
||||
to: process.env.ADMIN_EMAIL,
|
||||
subject: `Neue Buchungsanfrage - ${input.customerName}`,
|
||||
subject: `Neue Buchungsanfrage - ${sanitizedName}`,
|
||||
text: adminText,
|
||||
html: adminHtml,
|
||||
}).catch(() => { });
|
||||
@@ -271,26 +288,16 @@ const create = os
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
const sessionsKV = createKV("sessions");
|
||||
const usersKV = createKV("users");
|
||||
async function assertOwner(sessionId) {
|
||||
const session = await sessionsKV.getItem(sessionId);
|
||||
if (!session)
|
||||
throw new Error("Invalid session");
|
||||
if (new Date(session.expiresAt) < new Date())
|
||||
throw new Error("Session expired");
|
||||
const user = await usersKV.getItem(session.userId);
|
||||
if (!user || user.role !== "owner")
|
||||
throw new Error("Forbidden");
|
||||
}
|
||||
// Owner check reuse (simple inline version)
|
||||
const updateStatus = os
|
||||
.input(z.object({
|
||||
sessionId: z.string(),
|
||||
id: z.string(),
|
||||
status: z.enum(["pending", "confirmed", "cancelled", "completed"])
|
||||
}))
|
||||
.handler(async ({ input }) => {
|
||||
await assertOwner(input.sessionId);
|
||||
.handler(async ({ input, context }) => {
|
||||
await assertOwner(context);
|
||||
// Admin Rate Limiting nach erfolgreicher Owner-Prüfung
|
||||
await enforceAdminRateLimit(context);
|
||||
const booking = await kv.getItem(input.id);
|
||||
if (!booking)
|
||||
throw new Error("Booking not found");
|
||||
@@ -323,7 +330,7 @@ const updateStatus = os
|
||||
await sendEmailWithAGBAndCalendar({
|
||||
to: booking.customerEmail,
|
||||
subject: "Dein Termin wurde bestätigt - AGB im Anhang",
|
||||
text: `Hallo ${booking.customerName},\n\nwir haben deinen Termin am ${formattedDate} um ${booking.appointmentTime} bestätigt.\n\nWichtiger Hinweis: Die Allgemeinen Geschäftsbedingungen (AGB) findest du im Anhang dieser E-Mail. Bitte lies sie vor deinem Termin durch.\n\nTermin-Status ansehen und verwalten: ${bookingUrl}\nFalls du den Termin stornieren möchtest, kannst du das über den obigen Link tun.\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nBis bald!\nStargirlnails Kiel`,
|
||||
text: `Hallo ${sanitizeText(booking.customerName)},\n\nwir haben deinen Termin am ${formattedDate} um ${booking.appointmentTime} bestätigt.\n\nWichtiger Hinweis: Die Allgemeinen Geschäftsbedingungen (AGB) findest du im Anhang dieser E-Mail. Bitte lies sie vor deinem Termin durch.\n\nTermin-Status ansehen und verwalten: ${bookingUrl}\nFalls du den Termin stornieren möchtest, kannst du das über den obigen Link tun.\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nBis bald!\nStargirlnails Kiel`,
|
||||
html,
|
||||
bcc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined,
|
||||
}, {
|
||||
@@ -343,7 +350,7 @@ const updateStatus = os
|
||||
await sendEmail({
|
||||
to: booking.customerEmail,
|
||||
subject: "Dein Termin wurde abgesagt",
|
||||
text: `Hallo ${booking.customerName},\n\nleider wurde dein Termin am ${formattedDate} um ${booking.appointmentTime} abgesagt. Bitte buche einen neuen Termin.\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nLiebe Grüße\nStargirlnails Kiel`,
|
||||
text: `Hallo ${sanitizeText(booking.customerName)},\n\nleider wurde dein Termin am ${formattedDate} um ${booking.appointmentTime} abgesagt. Bitte buche einen neuen Termin.\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nLiebe Grüße\nStargirlnails Kiel`,
|
||||
html,
|
||||
bcc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined,
|
||||
});
|
||||
@@ -357,12 +364,13 @@ const updateStatus = os
|
||||
});
|
||||
const remove = os
|
||||
.input(z.object({
|
||||
sessionId: z.string(),
|
||||
id: z.string(),
|
||||
sendEmail: z.boolean().optional().default(false)
|
||||
}))
|
||||
.handler(async ({ input }) => {
|
||||
await assertOwner(input.sessionId);
|
||||
.handler(async ({ input, context }) => {
|
||||
await assertOwner(context);
|
||||
// Admin Rate Limiting nach erfolgreicher Owner-Prüfung
|
||||
await enforceAdminRateLimit(context);
|
||||
const booking = await kv.getItem(input.id);
|
||||
if (!booking)
|
||||
throw new Error("Booking not found");
|
||||
@@ -388,7 +396,7 @@ const remove = os
|
||||
await sendEmail({
|
||||
to: booking.customerEmail,
|
||||
subject: "Dein Termin wurde abgesagt",
|
||||
text: `Hallo ${booking.customerName},\n\nleider wurde dein Termin am ${formattedDate} um ${booking.appointmentTime} abgesagt. Bitte buche einen neuen Termin.\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nLiebe Grüße\nStargirlnails Kiel`,
|
||||
text: `Hallo ${sanitizeText(booking.customerName)},\n\nleider wurde dein Termin am ${formattedDate} um ${booking.appointmentTime} abgesagt. Bitte buche einen neuen Termin.\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nLiebe Grüße\nStargirlnails Kiel`,
|
||||
html,
|
||||
bcc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined,
|
||||
});
|
||||
@@ -402,7 +410,6 @@ const remove = os
|
||||
// Admin-only manual booking creation (immediately confirmed)
|
||||
const createManual = os
|
||||
.input(z.object({
|
||||
sessionId: z.string(),
|
||||
treatmentId: z.string(),
|
||||
customerName: z.string().min(2, "Name muss mindestens 2 Zeichen lang sein"),
|
||||
customerEmail: z.string().email("Ungültige E-Mail-Adresse").optional(),
|
||||
@@ -411,9 +418,11 @@ const createManual = os
|
||||
appointmentTime: z.string(),
|
||||
notes: z.string().optional(),
|
||||
}))
|
||||
.handler(async ({ input }) => {
|
||||
.handler(async ({ input, context }) => {
|
||||
// Admin authentication
|
||||
await assertOwner(input.sessionId);
|
||||
await assertOwner(context);
|
||||
// Admin Rate Limiting nach erfolgreicher Owner-Prüfung
|
||||
await enforceAdminRateLimit(context);
|
||||
// Validate appointment time is on 15-minute grid
|
||||
const appointmentMinutes = parseTime(input.appointmentTime);
|
||||
if (appointmentMinutes % 15 !== 0) {
|
||||
@@ -441,16 +450,20 @@ const createManual = os
|
||||
await validateBookingAgainstRules(input.appointmentDate, input.appointmentTime, treatment.duration);
|
||||
// Check for booking conflicts
|
||||
await checkBookingConflicts(input.appointmentDate, input.appointmentTime, treatment.duration);
|
||||
// Sanitize user-provided fields before storage (admin manual booking)
|
||||
const sanitizedName = sanitizeText(input.customerName);
|
||||
const sanitizedPhone = input.customerPhone ? sanitizePhone(input.customerPhone) : undefined;
|
||||
const sanitizedNotes = input.notes ? sanitizeHtml(input.notes) : undefined;
|
||||
const id = randomUUID();
|
||||
const booking = {
|
||||
id,
|
||||
treatmentId: input.treatmentId,
|
||||
customerName: input.customerName,
|
||||
customerName: sanitizedName,
|
||||
customerEmail: input.customerEmail,
|
||||
customerPhone: input.customerPhone,
|
||||
customerPhone: sanitizedPhone,
|
||||
appointmentDate: input.appointmentDate,
|
||||
appointmentTime: input.appointmentTime,
|
||||
notes: input.notes,
|
||||
notes: sanitizedNotes,
|
||||
bookedDurationMinutes: treatment.duration,
|
||||
status: "confirmed",
|
||||
createdAt: new Date().toISOString()
|
||||
@@ -467,7 +480,7 @@ const createManual = os
|
||||
const formattedDate = formatDateGerman(input.appointmentDate);
|
||||
const homepageUrl = generateUrl();
|
||||
const html = await renderBookingConfirmedHTML({
|
||||
name: input.customerName,
|
||||
name: sanitizedName,
|
||||
date: input.appointmentDate,
|
||||
time: input.appointmentTime,
|
||||
cancellationUrl: bookingUrl,
|
||||
@@ -476,13 +489,13 @@ const createManual = os
|
||||
await sendEmailWithAGBAndCalendar({
|
||||
to: input.customerEmail,
|
||||
subject: "Dein Termin wurde bestätigt - AGB im Anhang",
|
||||
text: `Hallo ${input.customerName},\n\nwir haben deinen Termin am ${formattedDate} um ${input.appointmentTime} bestätigt.\n\nWichtiger Hinweis: Die Allgemeinen Geschäftsbedingungen (AGB) findest du im Anhang dieser E-Mail. Bitte lies sie vor deinem Termin durch.\n\nTermin-Status ansehen und verwalten: ${bookingUrl}\nFalls du den Termin stornieren möchtest, kannst du das über den obigen Link tun.\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nBis bald!\nStargirlnails Kiel`,
|
||||
text: `Hallo ${sanitizedName},\n\nwir haben deinen Termin am ${formattedDate} um ${input.appointmentTime} bestätigt.\n\nWichtiger Hinweis: Die Allgemeinen Geschäftsbedingungen (AGB) findest du im Anhang dieser E-Mail. Bitte lies sie vor deinem Termin durch.\n\nTermin-Status ansehen und verwalten: ${bookingUrl}\nFalls du den Termin stornieren möchtest, kannst du das über den obigen Link tun.\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nBis bald!\nStargirlnails Kiel`,
|
||||
html,
|
||||
}, {
|
||||
date: input.appointmentDate,
|
||||
time: input.appointmentTime,
|
||||
durationMinutes: treatment.duration,
|
||||
customerName: input.customerName,
|
||||
customerName: sanitizedName,
|
||||
treatmentName: treatment.name
|
||||
});
|
||||
}
|
||||
@@ -537,13 +550,12 @@ export const router = {
|
||||
// Admin proposes a reschedule for a confirmed booking
|
||||
proposeReschedule: os
|
||||
.input(z.object({
|
||||
sessionId: z.string(),
|
||||
bookingId: z.string(),
|
||||
proposedDate: z.string(),
|
||||
proposedTime: z.string(),
|
||||
}))
|
||||
.handler(async ({ input }) => {
|
||||
await assertOwner(input.sessionId);
|
||||
.handler(async ({ input, context }) => {
|
||||
await assertOwner(context);
|
||||
const booking = await kv.getItem(input.bookingId);
|
||||
if (!booking)
|
||||
throw new Error("Booking not found");
|
||||
@@ -704,28 +716,28 @@ export const router = {
|
||||
}),
|
||||
// CalDAV Token für Admin generieren
|
||||
generateCalDAVToken: os
|
||||
.input(z.object({ sessionId: z.string() }))
|
||||
.handler(async ({ input }) => {
|
||||
await assertOwner(input.sessionId);
|
||||
.input(z.object({}))
|
||||
.handler(async ({ input, context }) => {
|
||||
await assertOwner(context);
|
||||
// Generiere einen sicheren Token für CalDAV-Zugriff
|
||||
const token = randomUUID();
|
||||
// Hole Session-Daten für Token-Erstellung
|
||||
const session = await sessionsKV.getItem(input.sessionId);
|
||||
// Hole Session-Daten aus Cookies
|
||||
const session = await getSessionFromCookies(context);
|
||||
if (!session)
|
||||
throw new Error("Session nicht gefunden");
|
||||
throw new Error("Invalid session");
|
||||
// Speichere Token mit Ablaufzeit (24 Stunden)
|
||||
const tokenData = {
|
||||
id: token,
|
||||
sessionId: input.sessionId,
|
||||
userId: session.userId, // Benötigt für Session-Typ
|
||||
userId: session.userId,
|
||||
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), // 24 Stunden
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
// Verwende den sessionsKV Store für Token-Speicherung
|
||||
await sessionsKV.setItem(token, tokenData);
|
||||
// Dedizierten KV-Store für CalDAV-Token verwenden
|
||||
const caldavTokensKV = createKV("caldavTokens");
|
||||
await caldavTokensKV.setItem(token, tokenData);
|
||||
const domain = process.env.DOMAIN || 'localhost:3000';
|
||||
const protocol = domain.includes('localhost') ? 'http' : 'https';
|
||||
const caldavUrl = `${protocol}://${domain}/caldav/calendar/events.ics?token=${token}`;
|
||||
const caldavUrl = `${protocol}://${domain}/caldav/calendar/events.ics`;
|
||||
return {
|
||||
token,
|
||||
caldavUrl,
|
||||
@@ -733,15 +745,44 @@ export const router = {
|
||||
instructions: {
|
||||
title: "CalDAV-Kalender abonnieren",
|
||||
steps: [
|
||||
"Kopiere die CalDAV-URL unten",
|
||||
"Füge sie in deiner Kalender-App als Abonnement hinzu:",
|
||||
"- Outlook: Datei → Konto hinzufügen → Internetkalender",
|
||||
"- Google Calendar: Andere Kalender hinzufügen → Von URL",
|
||||
"- Apple Calendar: Abonnement → Neue Abonnements",
|
||||
"- Thunderbird: Kalender hinzufügen → Im Netzwerk",
|
||||
"Der Kalender wird automatisch aktualisiert"
|
||||
"⚠️ WICHTIG: Der Token darf NICHT in der URL stehen, sondern im Authorization-Header!",
|
||||
"",
|
||||
"📋 Dein CalDAV-Token (kopieren):",
|
||||
token,
|
||||
"",
|
||||
"🔗 CalDAV-URL (ohne Token):",
|
||||
caldavUrl,
|
||||
"",
|
||||
"📱 Einrichtung nach Kalender-App:",
|
||||
"",
|
||||
"🍎 Apple Calendar (macOS/iOS):",
|
||||
"- Leider keine native Unterstützung für Authorization-Header",
|
||||
"- Alternative: Verwende eine CalDAV-Bridge oder importiere die ICS-Datei manuell",
|
||||
"",
|
||||
"📧 Outlook:",
|
||||
"- Datei → Kontoeinstellungen → Internetkalender",
|
||||
"- URL eingeben (ohne Token)",
|
||||
"- Erweiterte Einstellungen → Benutzerdefinierte Header hinzufügen:",
|
||||
" Authorization: Bearer <DEIN_TOKEN>",
|
||||
"",
|
||||
"🌐 Google Calendar:",
|
||||
"- Andere Kalender → Von URL hinzufügen",
|
||||
"- Hinweis: Google Calendar unterstützt keine Authorization-Header",
|
||||
"- Alternative: Verwende Google Apps Script oder importiere manuell",
|
||||
"",
|
||||
"🦅 Thunderbird:",
|
||||
"- Kalender → Neuer Kalender → Im Netzwerk",
|
||||
"- Format: CalDAV",
|
||||
"- URL eingeben",
|
||||
"- Anmeldung: Basic Auth mit Token als Benutzername (Passwort leer/optional)",
|
||||
"",
|
||||
"💻 cURL-Beispiel zum Testen:",
|
||||
`# Bearer\ncurl -H "Authorization: Bearer ${token}" ${caldavUrl}\n\n# Basic (Token als Benutzername, Passwort leer)\ncurl -H "Authorization: Basic $(printf \"%s\" \"${token}:\" | base64)" ${caldavUrl}`,
|
||||
"",
|
||||
"⏰ Token-Gültigkeit: 24 Stunden",
|
||||
"🔄 Bei Bedarf kannst du jederzeit einen neuen Token generieren."
|
||||
],
|
||||
note: "Dieser Token ist 24 Stunden gültig. Bei Bedarf kannst du einen neuen Token generieren."
|
||||
note: "Aus Sicherheitsgründen wird der Token NICHT in der URL übertragen. Verwende den Authorization-Header: Bearer oder Basic (Token als Benutzername)."
|
||||
}
|
||||
};
|
||||
}),
|
||||
|
@@ -3,6 +3,7 @@ import { z } from "zod";
|
||||
import { randomUUID } from "crypto";
|
||||
import { createKV } from "../lib/create-kv.js";
|
||||
import { assertOwner } from "../lib/auth.js";
|
||||
import { enforceAdminRateLimit } from "../lib/rate-limiter.js";
|
||||
// Schema Definition
|
||||
const GalleryPhotoSchema = z.object({
|
||||
id: z.string(),
|
||||
@@ -18,15 +19,16 @@ const galleryPhotosKV = createKV("galleryPhotos");
|
||||
// CRUD Endpoints
|
||||
const uploadPhoto = os
|
||||
.input(z.object({
|
||||
sessionId: z.string(),
|
||||
base64Data: z
|
||||
.string()
|
||||
.regex(/^data:image\/(png|jpe?g|webp|gif);base64,/i, 'Unsupported image format'),
|
||||
title: z.string().optional().default(""),
|
||||
}))
|
||||
.handler(async ({ input }) => {
|
||||
.handler(async ({ input, context }) => {
|
||||
try {
|
||||
await assertOwner(input.sessionId);
|
||||
await assertOwner(context);
|
||||
// Admin Rate Limiting
|
||||
await enforceAdminRateLimit(context);
|
||||
const id = randomUUID();
|
||||
const existing = await galleryPhotosKV.getAllItems();
|
||||
const maxOrder = existing.length > 0 ? Math.max(...existing.map((p) => p.order)) : -1;
|
||||
@@ -48,9 +50,11 @@ const uploadPhoto = os
|
||||
}
|
||||
});
|
||||
const setCoverPhoto = os
|
||||
.input(z.object({ sessionId: z.string(), id: z.string() }))
|
||||
.handler(async ({ input }) => {
|
||||
await assertOwner(input.sessionId);
|
||||
.input(z.object({ id: z.string() }))
|
||||
.handler(async ({ input, context }) => {
|
||||
await assertOwner(context);
|
||||
// Admin Rate Limiting
|
||||
await enforceAdminRateLimit(context);
|
||||
const all = await galleryPhotosKV.getAllItems();
|
||||
let updatedCover = null;
|
||||
for (const p of all) {
|
||||
@@ -63,18 +67,21 @@ const setCoverPhoto = os
|
||||
return updatedCover;
|
||||
});
|
||||
const deletePhoto = os
|
||||
.input(z.object({ sessionId: z.string(), id: z.string() }))
|
||||
.handler(async ({ input }) => {
|
||||
await assertOwner(input.sessionId);
|
||||
.input(z.object({ id: z.string() }))
|
||||
.handler(async ({ input, context }) => {
|
||||
await assertOwner(context);
|
||||
// Admin Rate Limiting
|
||||
await enforceAdminRateLimit(context);
|
||||
await galleryPhotosKV.removeItem(input.id);
|
||||
});
|
||||
const updatePhotoOrder = os
|
||||
.input(z.object({
|
||||
sessionId: z.string(),
|
||||
photoOrders: z.array(z.object({ id: z.string(), order: z.number().int() })),
|
||||
}))
|
||||
.handler(async ({ input }) => {
|
||||
await assertOwner(input.sessionId);
|
||||
.handler(async ({ input, context }) => {
|
||||
await assertOwner(context);
|
||||
// Admin Rate Limiting
|
||||
await enforceAdminRateLimit(context);
|
||||
const updated = [];
|
||||
for (const { id, order } of input.photoOrders) {
|
||||
const existing = await galleryPhotosKV.getItem(id);
|
||||
@@ -92,9 +99,9 @@ const listPhotos = os.handler(async () => {
|
||||
return all.sort((a, b) => (a.order - b.order) || a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
|
||||
});
|
||||
const adminListPhotos = os
|
||||
.input(z.object({ sessionId: z.string() }))
|
||||
.handler(async ({ input }) => {
|
||||
await assertOwner(input.sessionId);
|
||||
.input(z.object({}))
|
||||
.handler(async ({ context }) => {
|
||||
await assertOwner(context);
|
||||
const all = await galleryPhotosKV.getAllItems();
|
||||
return all.sort((a, b) => (a.order - b.order) || a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
|
||||
});
|
||||
@@ -107,9 +114,9 @@ const live = {
|
||||
}
|
||||
}),
|
||||
adminListPhotos: os
|
||||
.input(z.object({ sessionId: z.string() }))
|
||||
.handler(async function* ({ input, signal }) {
|
||||
await assertOwner(input.sessionId);
|
||||
.input(z.object({}))
|
||||
.handler(async function* ({ context, signal }) {
|
||||
await assertOwner(context);
|
||||
const all = await galleryPhotosKV.getAllItems();
|
||||
const sorted = all.sort((a, b) => (a.order - b.order) || a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
|
||||
yield sorted;
|
||||
|
@@ -1,4 +1,5 @@
|
||||
import { demo } from "./demo/index.js";
|
||||
import { os as baseOs, call as baseCall } from "@orpc/server";
|
||||
import { router as treatments } from "./treatments.js";
|
||||
import { router as bookings } from "./bookings.js";
|
||||
import { router as auth } from "./auth.js";
|
||||
@@ -18,3 +19,7 @@ export const router = {
|
||||
gallery,
|
||||
reviews,
|
||||
};
|
||||
// Export centrally typed oRPC helpers so all modules share the same Hono Context typing
|
||||
const osAny = baseOs;
|
||||
export const os = osAny.withContext?.() ?? osAny.context?.() ?? baseOs;
|
||||
export const call = baseCall;
|
||||
|
@@ -2,7 +2,8 @@ import { call, os } from "@orpc/server";
|
||||
import { z } from "zod";
|
||||
import { randomUUID } from "crypto";
|
||||
import { createKV } from "../lib/create-kv.js";
|
||||
import { assertOwner } from "../lib/auth.js";
|
||||
import { assertOwner, getSessionFromCookies } from "../lib/auth.js";
|
||||
import { checkAdminRateLimit, getClientIP, enforceAdminRateLimit } from "../lib/rate-limiter.js";
|
||||
// Datenmodelle
|
||||
const RecurringRuleSchema = z.object({
|
||||
id: z.string(),
|
||||
@@ -67,14 +68,22 @@ function detectOverlappingRules(newRule, existingRules) {
|
||||
// CRUD-Endpoints für Recurring Rules
|
||||
const createRule = os
|
||||
.input(z.object({
|
||||
sessionId: z.string(),
|
||||
dayOfWeek: z.number().int().min(0).max(6),
|
||||
startTime: z.string().regex(/^\d{2}:\d{2}$/),
|
||||
endTime: z.string().regex(/^\d{2}:\d{2}$/),
|
||||
}).passthrough())
|
||||
.handler(async ({ input }) => {
|
||||
.handler(async ({ input, context }) => {
|
||||
try {
|
||||
await assertOwner(input.sessionId);
|
||||
await assertOwner(context);
|
||||
// Admin Rate Limiting
|
||||
const ip = getClientIP(context.req.raw.headers);
|
||||
const session = await getSessionFromCookies(context);
|
||||
if (session) {
|
||||
const result = checkAdminRateLimit({ ip, userId: session.userId });
|
||||
if (!result.allowed) {
|
||||
throw new Error(`Zu viele Admin-Anfragen. Bitte versuche es in ${result.retryAfterSeconds} Sekunden erneut.`);
|
||||
}
|
||||
}
|
||||
// Validierung: startTime < endTime
|
||||
const startMinutes = parseTime(input.startTime);
|
||||
const endMinutes = parseTime(input.endTime);
|
||||
@@ -106,9 +115,18 @@ const createRule = os
|
||||
}
|
||||
});
|
||||
const updateRule = os
|
||||
.input(RecurringRuleSchema.extend({ sessionId: z.string() }).passthrough())
|
||||
.handler(async ({ input }) => {
|
||||
await assertOwner(input.sessionId);
|
||||
.input(RecurringRuleSchema.passthrough())
|
||||
.handler(async ({ input, context }) => {
|
||||
await assertOwner(context);
|
||||
// Admin Rate Limiting
|
||||
const ip = getClientIP(context.req.raw.headers);
|
||||
const session = await getSessionFromCookies(context);
|
||||
if (session) {
|
||||
const result = checkAdminRateLimit({ ip, userId: session.userId });
|
||||
if (!result.allowed) {
|
||||
throw new Error(`Zu viele Admin-Anfragen. Bitte versuche es in ${result.retryAfterSeconds} Sekunden erneut.`);
|
||||
}
|
||||
}
|
||||
// Validierung: startTime < endTime
|
||||
const startMinutes = parseTime(input.startTime);
|
||||
const endMinutes = parseTime(input.endTime);
|
||||
@@ -122,20 +140,38 @@ const updateRule = os
|
||||
const overlappingTimes = overlappingRules.map(rule => `${rule.startTime}-${rule.endTime}`).join(", ");
|
||||
throw new Error(`Überlappung mit bestehenden Regeln erkannt: ${overlappingTimes}. Bitte Zeitfenster anpassen.`);
|
||||
}
|
||||
const { sessionId, ...rule } = input;
|
||||
const rule = input;
|
||||
await recurringRulesKV.setItem(rule.id, rule);
|
||||
return rule;
|
||||
});
|
||||
const deleteRule = os
|
||||
.input(z.object({ sessionId: z.string(), id: z.string() }))
|
||||
.handler(async ({ input }) => {
|
||||
await assertOwner(input.sessionId);
|
||||
.input(z.object({ id: z.string() }))
|
||||
.handler(async ({ input, context }) => {
|
||||
await assertOwner(context);
|
||||
// Admin Rate Limiting
|
||||
const ip = getClientIP(context.req.raw.headers);
|
||||
const session = await getSessionFromCookies(context);
|
||||
if (session) {
|
||||
const result = checkAdminRateLimit({ ip, userId: session.userId });
|
||||
if (!result.allowed) {
|
||||
throw new Error(`Zu viele Admin-Anfragen. Bitte versuche es in ${result.retryAfterSeconds} Sekunden erneut.`);
|
||||
}
|
||||
}
|
||||
await recurringRulesKV.removeItem(input.id);
|
||||
});
|
||||
const toggleRuleActive = os
|
||||
.input(z.object({ sessionId: z.string(), id: z.string() }))
|
||||
.handler(async ({ input }) => {
|
||||
await assertOwner(input.sessionId);
|
||||
.input(z.object({ id: z.string() }))
|
||||
.handler(async ({ input, context }) => {
|
||||
await assertOwner(context);
|
||||
// Admin Rate Limiting
|
||||
const ip = getClientIP(context.req.raw.headers);
|
||||
const session = await getSessionFromCookies(context);
|
||||
if (session) {
|
||||
const result = checkAdminRateLimit({ ip, userId: session.userId });
|
||||
if (!result.allowed) {
|
||||
throw new Error(`Zu viele Admin-Anfragen. Bitte versuche es in ${result.retryAfterSeconds} Sekunden erneut.`);
|
||||
}
|
||||
}
|
||||
const rule = await recurringRulesKV.getItem(input.id);
|
||||
if (!rule)
|
||||
throw new Error("Regel nicht gefunden.");
|
||||
@@ -152,9 +188,9 @@ const listRules = os.handler(async () => {
|
||||
});
|
||||
});
|
||||
const adminListRules = os
|
||||
.input(z.object({ sessionId: z.string() }))
|
||||
.handler(async ({ input }) => {
|
||||
await assertOwner(input.sessionId);
|
||||
.input(z.object({}))
|
||||
.handler(async ({ context }) => {
|
||||
await assertOwner(context);
|
||||
const allRules = await recurringRulesKV.getAllItems();
|
||||
return allRules.sort((a, b) => {
|
||||
if (a.dayOfWeek !== b.dayOfWeek)
|
||||
@@ -165,14 +201,15 @@ const adminListRules = os
|
||||
// CRUD-Endpoints für Time-Off Periods
|
||||
const createTimeOff = os
|
||||
.input(z.object({
|
||||
sessionId: z.string(),
|
||||
startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
reason: z.string(),
|
||||
}))
|
||||
.handler(async ({ input }) => {
|
||||
.handler(async ({ input, context }) => {
|
||||
try {
|
||||
await assertOwner(input.sessionId);
|
||||
await assertOwner(context);
|
||||
// Admin Rate Limiting direkt nach Owner-Check
|
||||
await enforceAdminRateLimit(context);
|
||||
// Validierung: startDate <= endDate
|
||||
if (input.startDate > input.endDate) {
|
||||
throw new Error("Startdatum muss vor oder am Enddatum liegen.");
|
||||
@@ -194,21 +231,25 @@ const createTimeOff = os
|
||||
}
|
||||
});
|
||||
const updateTimeOff = os
|
||||
.input(TimeOffPeriodSchema.extend({ sessionId: z.string() }).passthrough())
|
||||
.handler(async ({ input }) => {
|
||||
await assertOwner(input.sessionId);
|
||||
.input(TimeOffPeriodSchema.passthrough())
|
||||
.handler(async ({ input, context }) => {
|
||||
await assertOwner(context);
|
||||
// Admin Rate Limiting direkt nach Owner-Check
|
||||
await enforceAdminRateLimit(context);
|
||||
// Validierung: startDate <= endDate
|
||||
if (input.startDate > input.endDate) {
|
||||
throw new Error("Startdatum muss vor oder am Enddatum liegen.");
|
||||
}
|
||||
const { sessionId, ...timeOff } = input;
|
||||
const timeOff = input;
|
||||
await timeOffPeriodsKV.setItem(timeOff.id, timeOff);
|
||||
return timeOff;
|
||||
});
|
||||
const deleteTimeOff = os
|
||||
.input(z.object({ sessionId: z.string(), id: z.string() }))
|
||||
.handler(async ({ input }) => {
|
||||
await assertOwner(input.sessionId);
|
||||
.input(z.object({ id: z.string() }))
|
||||
.handler(async ({ input, context }) => {
|
||||
await assertOwner(context);
|
||||
// Admin Rate Limiting direkt nach Owner-Check
|
||||
await enforceAdminRateLimit(context);
|
||||
await timeOffPeriodsKV.removeItem(input.id);
|
||||
});
|
||||
const listTimeOff = os.handler(async () => {
|
||||
@@ -216,9 +257,9 @@ const listTimeOff = os.handler(async () => {
|
||||
return allTimeOff.sort((a, b) => a.startDate.localeCompare(b.startDate));
|
||||
});
|
||||
const adminListTimeOff = os
|
||||
.input(z.object({ sessionId: z.string() }))
|
||||
.handler(async ({ input }) => {
|
||||
await assertOwner(input.sessionId);
|
||||
.input(z.object({}))
|
||||
.handler(async ({ context }) => {
|
||||
await assertOwner(context);
|
||||
const allTimeOff = await timeOffPeriodsKV.getAllItems();
|
||||
return allTimeOff.sort((a, b) => a.startDate.localeCompare(b.startDate));
|
||||
});
|
||||
@@ -341,9 +382,9 @@ const live = {
|
||||
}
|
||||
}),
|
||||
adminListRules: os
|
||||
.input(z.object({ sessionId: z.string() }))
|
||||
.handler(async function* ({ input, signal }) {
|
||||
await assertOwner(input.sessionId);
|
||||
.input(z.object({}))
|
||||
.handler(async function* ({ context, signal }) {
|
||||
await assertOwner(context);
|
||||
const allRules = await recurringRulesKV.getAllItems();
|
||||
const sortedRules = allRules.sort((a, b) => {
|
||||
if (a.dayOfWeek !== b.dayOfWeek)
|
||||
@@ -362,9 +403,9 @@ const live = {
|
||||
}
|
||||
}),
|
||||
adminListTimeOff: os
|
||||
.input(z.object({ sessionId: z.string() }))
|
||||
.handler(async function* ({ input, signal }) {
|
||||
await assertOwner(input.sessionId);
|
||||
.input(z.object({}))
|
||||
.handler(async function* ({ context, signal }) {
|
||||
await assertOwner(context);
|
||||
const allTimeOff = await timeOffPeriodsKV.getAllItems();
|
||||
const sortedTimeOff = allTimeOff.sort((a, b) => a.startDate.localeCompare(b.startDate));
|
||||
yield sortedTimeOff;
|
||||
|
@@ -1,7 +1,8 @@
|
||||
import { call, os } from "@orpc/server";
|
||||
import { z } from "zod";
|
||||
import { createKV } from "../lib/create-kv.js";
|
||||
import { assertOwner, sessionsKV } from "../lib/auth.js";
|
||||
import { assertOwner, getSessionFromCookies } from "../lib/auth.js";
|
||||
import { checkAdminRateLimit, getClientIP } from "../lib/rate-limiter.js";
|
||||
// Schema Definition
|
||||
const ReviewSchema = z.object({
|
||||
id: z.string(),
|
||||
@@ -75,20 +76,29 @@ const submitReview = os
|
||||
});
|
||||
// Admin Endpoint: approveReview
|
||||
const approveReview = os
|
||||
.input(z.object({ sessionId: z.string(), id: z.string() }))
|
||||
.handler(async ({ input }) => {
|
||||
.input(z.object({ id: z.string() }))
|
||||
.handler(async ({ input, context }) => {
|
||||
try {
|
||||
await assertOwner(input.sessionId);
|
||||
await assertOwner(context);
|
||||
// Admin Rate Limiting
|
||||
const ip = getClientIP(context.req.raw.headers);
|
||||
const session = await getSessionFromCookies(context);
|
||||
if (session) {
|
||||
const result = checkAdminRateLimit({ ip, userId: session.userId });
|
||||
if (!result.allowed) {
|
||||
throw new Error(`Zu viele Admin-Anfragen. Bitte versuche es in ${result.retryAfterSeconds} Sekunden erneut.`);
|
||||
}
|
||||
}
|
||||
const review = await reviewsKV.getItem(input.id);
|
||||
if (!review) {
|
||||
throw new Error("Bewertung nicht gefunden");
|
||||
}
|
||||
const session = await sessionsKV.getItem(input.sessionId).catch(() => undefined);
|
||||
const session2 = await getSessionFromCookies(context);
|
||||
const updatedReview = {
|
||||
...review,
|
||||
status: "approved",
|
||||
reviewedAt: new Date().toISOString(),
|
||||
reviewedBy: session?.userId || review.reviewedBy,
|
||||
reviewedBy: session2?.userId || review.reviewedBy,
|
||||
};
|
||||
await reviewsKV.setItem(input.id, updatedReview);
|
||||
return updatedReview;
|
||||
@@ -100,20 +110,29 @@ const approveReview = os
|
||||
});
|
||||
// Admin Endpoint: rejectReview
|
||||
const rejectReview = os
|
||||
.input(z.object({ sessionId: z.string(), id: z.string() }))
|
||||
.handler(async ({ input }) => {
|
||||
.input(z.object({ id: z.string() }))
|
||||
.handler(async ({ input, context }) => {
|
||||
try {
|
||||
await assertOwner(input.sessionId);
|
||||
await assertOwner(context);
|
||||
// Admin Rate Limiting
|
||||
const ip = getClientIP(context.req.raw.headers);
|
||||
const session = await getSessionFromCookies(context);
|
||||
if (session) {
|
||||
const result = checkAdminRateLimit({ ip, userId: session.userId });
|
||||
if (!result.allowed) {
|
||||
throw new Error(`Zu viele Admin-Anfragen. Bitte versuche es in ${result.retryAfterSeconds} Sekunden erneut.`);
|
||||
}
|
||||
}
|
||||
const review = await reviewsKV.getItem(input.id);
|
||||
if (!review) {
|
||||
throw new Error("Bewertung nicht gefunden");
|
||||
}
|
||||
const session = await sessionsKV.getItem(input.sessionId).catch(() => undefined);
|
||||
const session2 = await getSessionFromCookies(context);
|
||||
const updatedReview = {
|
||||
...review,
|
||||
status: "rejected",
|
||||
reviewedAt: new Date().toISOString(),
|
||||
reviewedBy: session?.userId || review.reviewedBy,
|
||||
reviewedBy: session2?.userId || review.reviewedBy,
|
||||
};
|
||||
await reviewsKV.setItem(input.id, updatedReview);
|
||||
return updatedReview;
|
||||
@@ -125,10 +144,19 @@ const rejectReview = os
|
||||
});
|
||||
// Admin Endpoint: deleteReview
|
||||
const deleteReview = os
|
||||
.input(z.object({ sessionId: z.string(), id: z.string() }))
|
||||
.handler(async ({ input }) => {
|
||||
.input(z.object({ id: z.string() }))
|
||||
.handler(async ({ input, context }) => {
|
||||
try {
|
||||
await assertOwner(input.sessionId);
|
||||
await assertOwner(context);
|
||||
// Admin Rate Limiting
|
||||
const ip = getClientIP(context.req.raw.headers);
|
||||
const session = await getSessionFromCookies(context);
|
||||
if (session) {
|
||||
const result = checkAdminRateLimit({ ip, userId: session.userId });
|
||||
if (!result.allowed) {
|
||||
throw new Error(`Zu viele Admin-Anfragen. Bitte versuche es in ${result.retryAfterSeconds} Sekunden erneut.`);
|
||||
}
|
||||
}
|
||||
await reviewsKV.removeItem(input.id);
|
||||
}
|
||||
catch (err) {
|
||||
@@ -160,12 +188,11 @@ const listPublishedReviews = os.handler(async () => {
|
||||
// Admin Endpoint: adminListReviews
|
||||
const adminListReviews = os
|
||||
.input(z.object({
|
||||
sessionId: z.string(),
|
||||
statusFilter: z.enum(["all", "pending", "approved", "rejected"]).optional().default("all"),
|
||||
}))
|
||||
.handler(async ({ input }) => {
|
||||
.handler(async ({ input, context }) => {
|
||||
try {
|
||||
await assertOwner(input.sessionId);
|
||||
await assertOwner(context);
|
||||
const allReviews = await reviewsKV.getAllItems();
|
||||
const filtered = input.statusFilter === "all"
|
||||
? allReviews
|
||||
@@ -188,11 +215,10 @@ const live = {
|
||||
}),
|
||||
adminListReviews: os
|
||||
.input(z.object({
|
||||
sessionId: z.string(),
|
||||
statusFilter: z.enum(["all", "pending", "approved", "rejected"]).optional().default("all"),
|
||||
}))
|
||||
.handler(async function* ({ input, signal }) {
|
||||
await assertOwner(input.sessionId);
|
||||
.handler(async function* ({ input, context, signal }) {
|
||||
await assertOwner(context);
|
||||
const allReviews = await reviewsKV.getAllItems();
|
||||
const filtered = input.statusFilter === "all"
|
||||
? allReviews
|
||||
|
@@ -2,6 +2,8 @@ import { call, os } from "@orpc/server";
|
||||
import { z } from "zod";
|
||||
import { randomUUID } from "crypto";
|
||||
import { createKV } from "../lib/create-kv.js";
|
||||
import { assertOwner } from "../lib/auth.js";
|
||||
import { enforceAdminRateLimit } from "../lib/rate-limiter.js";
|
||||
const TreatmentSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
@@ -13,7 +15,10 @@ const TreatmentSchema = z.object({
|
||||
const kv = createKV("treatments");
|
||||
const create = os
|
||||
.input(TreatmentSchema.omit({ id: true }))
|
||||
.handler(async ({ input }) => {
|
||||
.handler(async ({ input, context }) => {
|
||||
await assertOwner(context);
|
||||
// Admin Rate Limiting nach erfolgreicher Owner-Prüfung
|
||||
await enforceAdminRateLimit(context);
|
||||
const id = randomUUID();
|
||||
const treatment = { id, ...input };
|
||||
await kv.setItem(id, treatment);
|
||||
@@ -21,11 +26,17 @@ const create = os
|
||||
});
|
||||
const update = os
|
||||
.input(TreatmentSchema)
|
||||
.handler(async ({ input }) => {
|
||||
.handler(async ({ input, context }) => {
|
||||
await assertOwner(context);
|
||||
// Admin Rate Limiting
|
||||
await enforceAdminRateLimit(context);
|
||||
await kv.setItem(input.id, input);
|
||||
return input;
|
||||
});
|
||||
const remove = os.input(z.string()).handler(async ({ input }) => {
|
||||
const remove = os.input(z.string()).handler(async ({ input, context }) => {
|
||||
await assertOwner(context);
|
||||
// Admin Rate Limiting
|
||||
await enforceAdminRateLimit(context);
|
||||
await kv.removeItem(input);
|
||||
});
|
||||
const list = os.handler(async () => {
|
||||
|
Reference in New Issue
Block a user