CalDAV: Support Basic auth; trim+validate UUID; deprecate query token via headers; ICS end time helper; docs+instructions updated
This commit is contained in:
@@ -24,12 +24,12 @@ export function AdminAvailability() {
|
||||
// Neue Queries für wiederkehrende Verfügbarkeiten (mit Authentifizierung)
|
||||
const { data: recurringRules, refetch: refetchRecurringRules } = useQuery(
|
||||
queryClient.recurringAvailability.live.adminListRules.experimental_liveOptions({
|
||||
input: { sessionId: localStorage.getItem("sessionId") || "" }
|
||||
input: {}
|
||||
})
|
||||
);
|
||||
const { data: timeOffPeriods } = useQuery(
|
||||
queryClient.recurringAvailability.live.adminListTimeOff.experimental_liveOptions({
|
||||
input: { sessionId: localStorage.getItem("sessionId") || "" }
|
||||
input: {}
|
||||
})
|
||||
);
|
||||
|
||||
@@ -177,14 +177,9 @@ export function AdminAvailability() {
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionId = localStorage.getItem("sessionId") || "";
|
||||
if (!sessionId) {
|
||||
setErrorMsg("Nicht eingeloggt. Bitte als Inhaber anmelden.");
|
||||
return;
|
||||
}
|
||||
|
||||
createRule(
|
||||
{ sessionId, dayOfWeek: selectedDayOfWeek, startTime: ruleStartTime, endTime: ruleEndTime },
|
||||
{ dayOfWeek: selectedDayOfWeek, startTime: ruleStartTime, endTime: ruleEndTime },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setSuccessMsg(`Regel für ${getDayName(selectedDayOfWeek)} erstellt.`);
|
||||
@@ -233,13 +228,8 @@ export function AdminAvailability() {
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
const sessionId = localStorage.getItem("sessionId");
|
||||
if (!sessionId) {
|
||||
setErrorMsg("Nicht eingeloggt. Bitte als Inhaber anmelden.");
|
||||
return;
|
||||
}
|
||||
toggleRuleActive(
|
||||
{ sessionId, id: rule.id },
|
||||
{ id: rule.id },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setSuccessMsg(`Regel ${rule.isActive ? "deaktiviert" : "aktiviert"}.`);
|
||||
@@ -256,13 +246,8 @@ export function AdminAvailability() {
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const sessionId = localStorage.getItem("sessionId");
|
||||
if (!sessionId) {
|
||||
setErrorMsg("Nicht eingeloggt. Bitte als Inhaber anmelden.");
|
||||
return;
|
||||
}
|
||||
deleteRule(
|
||||
{ sessionId, id: rule.id },
|
||||
{ id: rule.id },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setSuccessMsg("Regel gelöscht.");
|
||||
@@ -348,14 +333,9 @@ export function AdminAvailability() {
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionId = localStorage.getItem("sessionId") || "";
|
||||
if (!sessionId) {
|
||||
setErrorMsg("Nicht eingeloggt. Bitte als Inhaber anmelden.");
|
||||
return;
|
||||
}
|
||||
|
||||
createTimeOff(
|
||||
{ sessionId, startDate: timeOffStartDate, endDate: timeOffEndDate, reason: timeOffReason },
|
||||
{ startDate: timeOffStartDate, endDate: timeOffEndDate, reason: timeOffReason },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setSuccessMsg("Urlaubszeit hinzugefügt.");
|
||||
@@ -415,13 +395,8 @@ export function AdminAvailability() {
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
const sessionId = localStorage.getItem("sessionId");
|
||||
if (!sessionId) {
|
||||
setErrorMsg("Nicht eingeloggt. Bitte als Inhaber anmelden.");
|
||||
return;
|
||||
}
|
||||
deleteTimeOff(
|
||||
{ sessionId, id: period.id },
|
||||
{ id: period.id },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setSuccessMsg("Urlaubszeit gelöscht.");
|
||||
|
@@ -255,7 +255,7 @@ export function AdminBookings() {
|
||||
{booking.status === "pending" && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => updateBookingStatus({ sessionId: localStorage.getItem("sessionId") || "", id: booking.id, status: "confirmed" })}
|
||||
onClick={() => updateBookingStatus({ id: booking.id, status: "confirmed" })}
|
||||
className="text-green-600 hover:text-green-900"
|
||||
>
|
||||
Confirm
|
||||
@@ -271,7 +271,7 @@ export function AdminBookings() {
|
||||
{booking.status === "confirmed" && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => updateBookingStatus({ sessionId: localStorage.getItem("sessionId") || "", id: booking.id, status: "completed" })}
|
||||
onClick={() => updateBookingStatus({ id: booking.id, status: "completed" })}
|
||||
className="text-blue-600 hover:text-blue-900"
|
||||
>
|
||||
Complete
|
||||
@@ -286,7 +286,7 @@ export function AdminBookings() {
|
||||
)}
|
||||
{(booking.status === "cancelled" || booking.status === "completed") && (
|
||||
<button
|
||||
onClick={() => updateBookingStatus({ sessionId: localStorage.getItem("sessionId") || "", id: booking.id, status: "confirmed" })}
|
||||
onClick={() => updateBookingStatus({ id: booking.id, status: "confirmed" })}
|
||||
className="text-green-600 hover:text-green-900"
|
||||
>
|
||||
Reactivate
|
||||
@@ -352,8 +352,7 @@ export function AdminBookings() {
|
||||
<div className="flex space-x-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
const sessionId = localStorage.getItem("sessionId") || "";
|
||||
updateBookingStatus({ sessionId, id: showCancelConfirm, status: "cancelled" });
|
||||
updateBookingStatus({ id: showCancelConfirm, status: "cancelled" });
|
||||
}}
|
||||
className="flex-1 bg-red-600 text-white py-2 px-4 rounded-md hover:bg-red-700 transition-colors"
|
||||
>
|
||||
|
@@ -164,24 +164,18 @@ export function AdminCalendar() {
|
||||
};
|
||||
|
||||
const handleStatusUpdate = (bookingId: string, newStatus: string) => {
|
||||
const sessionId = localStorage.getItem('sessionId');
|
||||
if (!sessionId) return;
|
||||
|
||||
updateBookingStatus({
|
||||
sessionId,
|
||||
id: bookingId,
|
||||
status: newStatus as "pending" | "confirmed" | "cancelled" | "completed"
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteBooking = () => {
|
||||
const sessionId = localStorage.getItem('sessionId');
|
||||
if (!sessionId || !showDeleteConfirm) return;
|
||||
if (!showDeleteConfirm) return;
|
||||
|
||||
if (deleteActionType === 'cancel') {
|
||||
// For cancel action, use updateStatus instead of remove
|
||||
updateBookingStatus({
|
||||
sessionId,
|
||||
id: showDeleteConfirm,
|
||||
status: "cancelled"
|
||||
}, {
|
||||
@@ -197,7 +191,6 @@ export function AdminCalendar() {
|
||||
} else {
|
||||
// For delete action, use remove with email option
|
||||
removeBooking({
|
||||
sessionId,
|
||||
id: showDeleteConfirm,
|
||||
sendEmail: sendDeleteEmail,
|
||||
}, {
|
||||
@@ -216,11 +209,8 @@ export function AdminCalendar() {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
|
||||
const handleCreateBooking = () => {
|
||||
const sessionId = localStorage.getItem('sessionId');
|
||||
if (!sessionId) return;
|
||||
|
||||
createManualBooking({
|
||||
sessionId,
|
||||
...createFormData
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
@@ -262,13 +252,11 @@ export function AdminCalendar() {
|
||||
};
|
||||
|
||||
const handleRescheduleBooking = () => {
|
||||
const sessionId = localStorage.getItem('sessionId');
|
||||
if (!sessionId || !showRescheduleModal) return;
|
||||
if (!showRescheduleModal) return;
|
||||
const booking = bookings?.find(b => b.id === showRescheduleModal);
|
||||
if (!booking) return;
|
||||
|
||||
proposeReschedule({
|
||||
sessionId,
|
||||
bookingId: booking.id,
|
||||
proposedDate: rescheduleFormData.appointmentDate,
|
||||
proposedTime: rescheduleFormData.appointmentTime,
|
||||
@@ -285,11 +273,8 @@ export function AdminCalendar() {
|
||||
};
|
||||
|
||||
const handleGenerateCalDAVToken = () => {
|
||||
const sessionId = localStorage.getItem('sessionId');
|
||||
if (!sessionId) return;
|
||||
|
||||
generateCalDAVToken({
|
||||
sessionId
|
||||
}, {
|
||||
onSuccess: (data) => {
|
||||
setCaldavData(data);
|
||||
|
@@ -14,7 +14,7 @@ export function AdminGallery() {
|
||||
// Data fetching with live query
|
||||
const { data: photos, refetch: refetchPhotos } = useQuery(
|
||||
queryClient.gallery.live.adminListPhotos.experimental_liveOptions({
|
||||
input: { sessionId: localStorage.getItem("sessionId") || "" }
|
||||
input: {}
|
||||
})
|
||||
);
|
||||
|
||||
@@ -166,12 +166,6 @@ export function AdminGallery() {
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionId = localStorage.getItem("sessionId");
|
||||
if (!sessionId) {
|
||||
setErrorMsg("Nicht eingeloggt. Bitte als Inhaber anmelden.");
|
||||
setDraggedPhotoId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Build a fully reordered list based on the current sorted order
|
||||
const sorted = [...(photos || [])].sort((a, b) => a.order - b.order);
|
||||
@@ -191,7 +185,6 @@ export function AdminGallery() {
|
||||
|
||||
updatePhotoOrder(
|
||||
{
|
||||
sessionId,
|
||||
photoOrders
|
||||
},
|
||||
{
|
||||
@@ -304,15 +297,9 @@ export function AdminGallery() {
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionId = localStorage.getItem("sessionId") || "";
|
||||
if (!sessionId) {
|
||||
setErrorMsg("Nicht eingeloggt. Bitte als Inhaber anmelden.");
|
||||
return;
|
||||
}
|
||||
|
||||
uploadPhoto(
|
||||
{
|
||||
sessionId,
|
||||
base64Data: photoBase64,
|
||||
title: photoTitle || undefined
|
||||
},
|
||||
@@ -396,13 +383,8 @@ export function AdminGallery() {
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm("Möchtest du dieses Foto wirklich löschen?")) {
|
||||
const sessionId = localStorage.getItem("sessionId");
|
||||
if (!sessionId) {
|
||||
setErrorMsg("Nicht eingeloggt. Bitte als Inhaber anmelden.");
|
||||
return;
|
||||
}
|
||||
deletePhoto(
|
||||
{ sessionId, id: photo.id },
|
||||
{ id: photo.id },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setSuccessMsg("Foto gelöscht.");
|
||||
@@ -425,13 +407,8 @@ export function AdminGallery() {
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const sessionId = localStorage.getItem("sessionId");
|
||||
if (!sessionId) {
|
||||
setErrorMsg("Nicht eingeloggt. Bitte als Inhaber anmelden.");
|
||||
return;
|
||||
}
|
||||
setCoverPhoto(
|
||||
{ sessionId, id: photo.id },
|
||||
{ id: photo.id },
|
||||
{
|
||||
onSuccess: () => setSuccessMsg("Als Cover-Bild gesetzt."),
|
||||
onError: (err: any) => setErrorMsg(err?.message || "Fehler beim Setzen des Cover-Bildes."),
|
||||
|
@@ -91,18 +91,17 @@ export function AdminReviews() {
|
||||
}
|
||||
}, [successMsg]);
|
||||
|
||||
const sessionId = typeof window !== "undefined" ? localStorage.getItem("sessionId") || "" : "";
|
||||
|
||||
const { data: reviews } = useQuery(
|
||||
queryClient.reviews.live.adminListReviews.experimental_liveOptions({
|
||||
input: { sessionId, statusFilter: activeStatusTab },
|
||||
input: { statusFilter: activeStatusTab },
|
||||
})
|
||||
);
|
||||
|
||||
// Separate queries for quick stats calculation
|
||||
const { data: allReviews } = useQuery(
|
||||
queryClient.reviews.live.adminListReviews.experimental_liveOptions({
|
||||
input: { sessionId },
|
||||
input: {},
|
||||
})
|
||||
);
|
||||
|
||||
@@ -266,13 +265,13 @@ export function AdminReviews() {
|
||||
{review.status === "pending" && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => approveReview({ sessionId, id: review.id })}
|
||||
onClick={() => approveReview({ id: review.id })}
|
||||
className="bg-green-600 hover:bg-green-700 text-white px-3 py-1.5 rounded-md text-sm"
|
||||
>
|
||||
Genehmigen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => rejectReview({ sessionId, id: review.id })}
|
||||
onClick={() => rejectReview({ id: review.id })}
|
||||
className="bg-red-600 hover:bg-red-700 text-white px-3 py-1.5 rounded-md text-sm"
|
||||
>
|
||||
Ablehnen
|
||||
@@ -289,7 +288,7 @@ export function AdminReviews() {
|
||||
{review.status === "approved" && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => rejectReview({ sessionId, id: review.id })}
|
||||
onClick={() => rejectReview({ id: review.id })}
|
||||
className="bg-red-600 hover:bg-red-700 text-white px-3 py-1.5 rounded-md text-sm"
|
||||
>
|
||||
Ablehnen
|
||||
@@ -306,7 +305,7 @@ export function AdminReviews() {
|
||||
{review.status === "rejected" && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => approveReview({ sessionId, id: review.id })}
|
||||
onClick={() => approveReview({ id: review.id })}
|
||||
className="bg-green-600 hover:bg-green-700 text-white px-3 py-1.5 rounded-md text-sm"
|
||||
>
|
||||
Genehmigen
|
||||
@@ -334,7 +333,7 @@ export function AdminReviews() {
|
||||
</p>
|
||||
<div className="flex space-x-3">
|
||||
<button
|
||||
onClick={() => deleteReview({ sessionId, id: showDeleteConfirm })}
|
||||
onClick={() => deleteReview({ id: showDeleteConfirm })}
|
||||
className="flex-1 bg-red-600 text-white py-2 px-4 rounded-md hover:bg-red-700 transition-colors"
|
||||
>
|
||||
Ja, löschen
|
||||
|
@@ -11,7 +11,6 @@ interface User {
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
sessionId: string | null;
|
||||
isLoading: boolean;
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
@@ -34,7 +33,6 @@ interface AuthProviderProps {
|
||||
|
||||
export function AuthProvider({ children }: AuthProviderProps) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const { mutateAsync: loginMutation } = useMutation(
|
||||
@@ -50,56 +48,45 @@ export function AuthProvider({ children }: AuthProviderProps) {
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Check for existing session on app load
|
||||
const storedSessionId = localStorage.getItem("sessionId");
|
||||
if (storedSessionId) {
|
||||
verifySessionMutation(storedSessionId)
|
||||
.then((result) => {
|
||||
setUser(result.user);
|
||||
setSessionId(storedSessionId);
|
||||
})
|
||||
.catch(() => {
|
||||
localStorage.removeItem("sessionId");
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
} else {
|
||||
setIsLoading(false);
|
||||
}
|
||||
// Check for existing session on app load - session comes from cookies
|
||||
verifySessionMutation({})
|
||||
.then((result) => {
|
||||
setUser(result.user);
|
||||
})
|
||||
.catch(() => {
|
||||
// Session invalid or expired - user remains null
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, [verifySessionMutation]);
|
||||
|
||||
const login = async (username: string, password: string) => {
|
||||
try {
|
||||
const result = await loginMutation({ username, password });
|
||||
setUser(result.user);
|
||||
setSessionId(result.sessionId);
|
||||
localStorage.setItem("sessionId", result.sessionId);
|
||||
// Cookies are set automatically by the server
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
if (sessionId) {
|
||||
try {
|
||||
await logoutMutation(sessionId);
|
||||
} catch (error) {
|
||||
// Continue with logout even if server call fails
|
||||
console.error("Logout error:", error);
|
||||
}
|
||||
try {
|
||||
await logoutMutation({});
|
||||
// Cookies are cleared automatically by the server
|
||||
} catch (error) {
|
||||
// Continue with logout even if server call fails
|
||||
console.error("Logout error:", error);
|
||||
}
|
||||
|
||||
setUser(null);
|
||||
setSessionId(null);
|
||||
localStorage.removeItem("sessionId");
|
||||
};
|
||||
|
||||
const isOwner = user?.role === "owner";
|
||||
|
||||
const value: AuthContextType = {
|
||||
user,
|
||||
sessionId,
|
||||
isLoading,
|
||||
login,
|
||||
logout,
|
||||
|
@@ -4,7 +4,7 @@ import { useAuth } from "@/client/components/auth-provider";
|
||||
import { queryClient } from "@/client/rpc-client";
|
||||
|
||||
export function UserProfile() {
|
||||
const { user, sessionId, logout } = useAuth();
|
||||
const { user, logout } = useAuth();
|
||||
const [showPasswordChange, setShowPasswordChange] = useState(false);
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
@@ -31,13 +31,7 @@ export function UserProfile() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!sessionId) {
|
||||
setError("Keine gültige Sitzung");
|
||||
return;
|
||||
}
|
||||
|
||||
changePassword({
|
||||
sessionId,
|
||||
currentPassword,
|
||||
newPassword,
|
||||
}, {
|
||||
|
@@ -5,8 +5,32 @@ import { createTanstackQueryUtils } from "@orpc/tanstack-query";
|
||||
|
||||
import type { router } from "@/server/rpc";
|
||||
|
||||
const link = new RPCLink({ url: `${window.location.origin}/rpc` });
|
||||
// Helper function to read CSRF token from cookie
|
||||
function getCSRFToken(): string {
|
||||
const cookieValue = document.cookie
|
||||
.split('; ')
|
||||
.find(row => row.startsWith('csrf-token='))
|
||||
?.split('=')[1];
|
||||
return cookieValue || '';
|
||||
}
|
||||
|
||||
const link = new RPCLink({
|
||||
url: `${window.location.origin}/rpc`,
|
||||
headers: () => {
|
||||
const csrfToken = getCSRFToken();
|
||||
return csrfToken ? { 'X-CSRF-Token': csrfToken } : {};
|
||||
},
|
||||
fetch: (request, init) => {
|
||||
return fetch(request, {
|
||||
...init,
|
||||
credentials: 'include' // Include cookies with all requests
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export const rpcClient: RouterClient<typeof router> = createORPCClient(link);
|
||||
|
||||
export const queryClient = createTanstackQueryUtils(rpcClient);
|
||||
|
||||
// Export helper for potential use in other parts of the client code
|
||||
export { getCSRFToken };
|
||||
|
@@ -1,6 +1,7 @@
|
||||
import { Hono } from "hono";
|
||||
import { serve } from '@hono/node-server';
|
||||
import { serveStatic } from '@hono/node-server/serve-static';
|
||||
import { cors } from '@hono/cors';
|
||||
|
||||
import { rpcApp } from "./routes/rpc.js";
|
||||
import { caldavApp } from "./routes/caldav.js";
|
||||
@@ -8,11 +9,37 @@ import { clientEntry } from "./routes/client-entry.js";
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
// Allow all hosts for Tailscale Funnel
|
||||
app.use("*", async (c, next) => {
|
||||
// Accept requests from any host
|
||||
return next();
|
||||
});
|
||||
// CORS Configuration
|
||||
const isDev = process.env.NODE_ENV === 'development';
|
||||
const domain = process.env.DOMAIN || 'localhost:5173';
|
||||
|
||||
// Build allowed origins list
|
||||
const allowedOrigins: string[] = [
|
||||
`https://${domain}`,
|
||||
isDev ? `http://${domain}` : null,
|
||||
isDev ? 'http://localhost:5173' : null,
|
||||
isDev ? 'http://localhost:3000' : null,
|
||||
].filter((origin): origin is string => origin !== null);
|
||||
|
||||
app.use('*', cors({
|
||||
origin: (origin) => {
|
||||
// Allow requests with no origin (e.g., mobile apps, curl, Postman)
|
||||
if (!origin) return null;
|
||||
|
||||
// Check if origin is in whitelist
|
||||
if (allowedOrigins.includes(origin)) {
|
||||
return origin;
|
||||
}
|
||||
|
||||
// Reject all other origins
|
||||
return null;
|
||||
},
|
||||
credentials: true, // Enable cookies for authentication
|
||||
allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
||||
allowHeaders: ['Content-Type', 'Authorization', 'X-CSRF-Token'],
|
||||
exposeHeaders: ['Set-Cookie'],
|
||||
maxAge: 86400, // Cache preflight requests for 24 hours
|
||||
}));
|
||||
|
||||
// Content-Security-Policy and other security headers
|
||||
app.use("*", async (c, next) => {
|
||||
|
@@ -1,17 +1,104 @@
|
||||
import { createKV } from "./create-kv.js";
|
||||
import { getCookie } from "hono/cookie";
|
||||
import type { Context } from "hono";
|
||||
import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
||||
|
||||
type Session = { id: string; userId: string; expiresAt: string; createdAt: string };
|
||||
type Session = { id: string; userId: string; expiresAt: string; createdAt: string; csrfToken?: string };
|
||||
type User = { id: string; username: string; email: string; passwordHash: string; role: "customer" | "owner"; createdAt: string };
|
||||
|
||||
export const sessionsKV = createKV<Session>("sessions");
|
||||
export const usersKV = createKV<User>("users");
|
||||
|
||||
export async function assertOwner(sessionId: string): Promise<void> {
|
||||
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");
|
||||
// Cookie configuration constants
|
||||
export const SESSION_COOKIE_NAME = 'sessionId';
|
||||
export const CSRF_COOKIE_NAME = 'csrf-token';
|
||||
export const COOKIE_OPTIONS = {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'Lax' as const,
|
||||
path: '/',
|
||||
maxAge: 86400 // 24 hours
|
||||
};
|
||||
|
||||
// CSRF token generation
|
||||
export function generateCSRFToken(): string {
|
||||
return randomBytes(32).toString('hex');
|
||||
}
|
||||
|
||||
// Session extraction from cookies
|
||||
export async function getSessionFromCookies(c: Context): Promise<Session | null> {
|
||||
const sessionId = getCookie(c, SESSION_COOKIE_NAME);
|
||||
if (!sessionId) return null;
|
||||
|
||||
const session = await sessionsKV.getItem(sessionId);
|
||||
if (!session) return null;
|
||||
|
||||
// Check expiration
|
||||
if (new Date(session.expiresAt) < new Date()) {
|
||||
// Clean up expired session
|
||||
await sessionsKV.removeItem(sessionId);
|
||||
return null;
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
// CSRF token validation
|
||||
export async function validateCSRFToken(c: Context, sessionId: string): Promise<void> {
|
||||
const headerToken = c.req.header('X-CSRF-Token');
|
||||
if (!headerToken) throw new Error("CSRF token missing");
|
||||
|
||||
const session = await sessionsKV.getItem(sessionId);
|
||||
if (!session?.csrfToken) throw new Error("Invalid session");
|
||||
|
||||
// Use timing-safe comparison to prevent timing attacks
|
||||
const sessionTokenBuffer = Buffer.from(session.csrfToken, 'hex');
|
||||
const headerTokenBuffer = Buffer.from(headerToken, 'hex');
|
||||
|
||||
if (sessionTokenBuffer.length !== headerTokenBuffer.length || !timingSafeEqual(sessionTokenBuffer, headerTokenBuffer)) {
|
||||
throw new Error("CSRF token mismatch");
|
||||
}
|
||||
}
|
||||
|
||||
// Session rotation helper
|
||||
export async function rotateSession(oldSessionId: string, userId: string): Promise<Session> {
|
||||
// Delete old session
|
||||
await sessionsKV.removeItem(oldSessionId);
|
||||
|
||||
// Create new session with CSRF token
|
||||
const newSessionId = randomUUID();
|
||||
const csrfToken = generateCSRFToken();
|
||||
const now = new Date();
|
||||
const expiresAt = new Date(now.getTime() + 24 * 60 * 60 * 1000); // 24 hours
|
||||
|
||||
const newSession: Session = {
|
||||
id: newSessionId,
|
||||
userId,
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
createdAt: now.toISOString(),
|
||||
csrfToken
|
||||
};
|
||||
|
||||
await sessionsKV.setItem(newSessionId, newSession);
|
||||
return newSession;
|
||||
}
|
||||
|
||||
// Updated assertOwner function with CSRF validation
|
||||
export async function assertOwner(c: Context): Promise<void> {
|
||||
const session = await getSessionFromCookies(c);
|
||||
if (!session) throw new Error("Invalid session");
|
||||
|
||||
const user = await usersKV.getItem(session.userId);
|
||||
if (!user || user.role !== "owner") throw new Error("Forbidden");
|
||||
|
||||
// Validate CSRF token for non-GET requests
|
||||
const method = c.req.method;
|
||||
if (method !== 'GET' && method !== 'HEAD') {
|
||||
await validateCSRFToken(c, session.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Export types for use in other modules
|
||||
export type { Session, User };
|
||||
|
||||
|
||||
|
@@ -137,20 +137,29 @@ export function checkBookingRateLimit(params: {
|
||||
/**
|
||||
* Get client IP from various headers (for proxy/load balancer support)
|
||||
*/
|
||||
export function getClientIP(headers: Record<string, string | undefined>): string | undefined {
|
||||
export function getClientIP(headers: Headers | Record<string, string | undefined>): string | undefined {
|
||||
// Check common proxy headers
|
||||
const forwardedFor = headers['x-forwarded-for'];
|
||||
const get = (name: string): string | undefined => {
|
||||
if (typeof (headers as any).get === 'function') {
|
||||
// Headers interface
|
||||
const v = (headers as Headers).get(name);
|
||||
return v === null ? undefined : v;
|
||||
}
|
||||
return (headers as Record<string, string | undefined>)[name];
|
||||
};
|
||||
|
||||
const forwardedFor = get('x-forwarded-for');
|
||||
if (forwardedFor) {
|
||||
// x-forwarded-for can contain multiple IPs, take the first one
|
||||
return forwardedFor.split(',')[0].trim();
|
||||
}
|
||||
|
||||
const realIP = headers['x-real-ip'];
|
||||
const realIP = get('x-real-ip');
|
||||
if (realIP) {
|
||||
return realIP;
|
||||
}
|
||||
|
||||
const cfConnectingIP = headers['cf-connecting-ip']; // Cloudflare
|
||||
const cfConnectingIP = get('cf-connecting-ip'); // Cloudflare
|
||||
if (cfConnectingIP) {
|
||||
return cfConnectingIP;
|
||||
}
|
||||
@@ -159,4 +168,117 @@ export function getClientIP(headers: Record<string, string | undefined>): string
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset a rate limit entry immediately (e.g., after successful login)
|
||||
*/
|
||||
export function resetRateLimit(key: string): void {
|
||||
rateLimitStore.delete(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience helper to reset login attempts for an IP
|
||||
*/
|
||||
export function resetLoginRateLimit(ip: string | undefined): void {
|
||||
if (!ip) return;
|
||||
resetRateLimit(`login:ip:${ip}`);
|
||||
}
|
||||
|
||||
import type { Context } from "hono";
|
||||
import { getSessionFromCookies } from "./auth.js";
|
||||
|
||||
/**
|
||||
* Enforce admin rate limiting by IP and user. Throws standardized German error on exceed.
|
||||
*/
|
||||
export async function enforceAdminRateLimit(context: Context): Promise<void> {
|
||||
const ip = getClientIP((context.req as any).raw.headers as Headers);
|
||||
const session = await getSessionFromCookies(context);
|
||||
if (!session) return; // No session -> owner assertion elsewhere; no per-user throttling
|
||||
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.`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Brute-Force-Schutz für Logins (IP-basiert)
|
||||
*
|
||||
* Konfiguration:
|
||||
* - max. 5 Versuche je IP in 15 Minuten
|
||||
*
|
||||
* Schlüssel: "login:ip:${ip}"
|
||||
*/
|
||||
export function checkLoginRateLimit(ip: string | undefined): RateLimitResult {
|
||||
// Wenn keine IP ermittelbar ist, erlauben (kein Tracking möglich)
|
||||
if (!ip) {
|
||||
return {
|
||||
allowed: true,
|
||||
remaining: 5,
|
||||
resetAt: Date.now() + 15 * 60 * 1000,
|
||||
};
|
||||
}
|
||||
|
||||
const loginConfig: RateLimitConfig = {
|
||||
maxRequests: 5,
|
||||
windowMs: 15 * 60 * 1000, // 15 Minuten
|
||||
};
|
||||
|
||||
const key = `login:ip:${ip}`;
|
||||
return checkRateLimit(key, loginConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rate Limiting für Admin-Operationen
|
||||
*
|
||||
* Konfigurationen (beide Checks werden geprüft, restriktiverer gewinnt):
|
||||
* - Benutzer-basiert: 30 Anfragen je Benutzer in 5 Minuten
|
||||
* - IP-basiert: 50 Anfragen je IP in 5 Minuten
|
||||
*
|
||||
* Schlüssel:
|
||||
* - "admin:user:${userId}"
|
||||
* - "admin:ip:${ip}"
|
||||
*/
|
||||
export function checkAdminRateLimit(params: { ip?: string; userId: string }): RateLimitResult {
|
||||
const { ip, userId } = params;
|
||||
|
||||
const userConfig: RateLimitConfig = {
|
||||
maxRequests: 30,
|
||||
windowMs: 5 * 60 * 1000, // 5 Minuten
|
||||
};
|
||||
|
||||
const ipConfig: RateLimitConfig = {
|
||||
maxRequests: 50,
|
||||
windowMs: 5 * 60 * 1000, // 5 Minuten
|
||||
};
|
||||
|
||||
const userKey = `admin:user:${userId}`;
|
||||
const userResult = checkRateLimit(userKey, userConfig);
|
||||
|
||||
// Wenn Benutzerlimit bereits überschritten ist, direkt zurückgeben
|
||||
if (!userResult.allowed) {
|
||||
return { ...userResult, allowed: false };
|
||||
}
|
||||
|
||||
// Falls IP verfügbar, zusätzlich prüfen
|
||||
if (ip) {
|
||||
const ipKey = `admin:ip:${ip}`;
|
||||
const ipResult = checkRateLimit(ipKey, ipConfig);
|
||||
|
||||
if (!ipResult.allowed) {
|
||||
return { ...ipResult, allowed: false };
|
||||
}
|
||||
|
||||
// Beide Checks erlaubt: restriktivere Restwerte/Reset nehmen
|
||||
return {
|
||||
allowed: true,
|
||||
remaining: Math.min(userResult.remaining, ipResult.remaining),
|
||||
resetAt: Math.min(userResult.resetAt, ipResult.resetAt),
|
||||
};
|
||||
}
|
||||
|
||||
// Kein IP-Check möglich
|
||||
return {
|
||||
allowed: true,
|
||||
remaining: userResult.remaining,
|
||||
resetAt: userResult.resetAt,
|
||||
};
|
||||
}
|
||||
|
@@ -31,6 +31,12 @@ type Treatment = {
|
||||
const bookingsKV = createKV<Booking>("bookings");
|
||||
const treatmentsKV = createKV<Treatment>("treatments");
|
||||
const sessionsKV = createKV<any>("sessions");
|
||||
const caldavTokensKV = createKV<{
|
||||
id: string;
|
||||
userId: string;
|
||||
expiresAt: string;
|
||||
createdAt: string;
|
||||
}>("caldavTokens");
|
||||
|
||||
export const caldavApp = new Hono();
|
||||
|
||||
@@ -44,6 +50,15 @@ function formatDateTime(dateStr: string, timeStr: string): string {
|
||||
return date.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
|
||||
}
|
||||
|
||||
// Helper to add minutes to an HH:MM time string and return HH:MM
|
||||
function addMinutesToTime(timeStr: string, minutesToAdd: number): string {
|
||||
const [hours, minutes] = timeStr.split(':').map(Number);
|
||||
const total = hours * 60 + minutes + minutesToAdd;
|
||||
const endHours = Math.floor(total / 60) % 24;
|
||||
const endMinutes = total % 60;
|
||||
return `${String(endHours).padStart(2, '0')}:${String(endMinutes).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function generateICSContent(bookings: Booking[], treatments: Treatment[]): string {
|
||||
const now = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
|
||||
|
||||
@@ -68,9 +83,8 @@ X-WR-TIMEZONE:Europe/Berlin
|
||||
const duration = booking.bookedDurationMinutes || treatment?.duration || 60;
|
||||
|
||||
const startTime = formatDateTime(booking.appointmentDate, booking.appointmentTime);
|
||||
const endTime = formatDateTime(booking.appointmentDate,
|
||||
`${String(Math.floor((parseInt(booking.appointmentTime.split(':')[0]) * 60 + parseInt(booking.appointmentTime.split(':')[1]) + duration) / 60)).padStart(2, '0')}:${String((parseInt(booking.appointmentTime.split(':')[0]) * 60 + parseInt(booking.appointmentTime.split(':')[1]) + duration) % 60).padStart(2, '0')}`
|
||||
);
|
||||
const computedEnd = addMinutesToTime(booking.appointmentTime, duration);
|
||||
const endTime = formatDateTime(booking.appointmentDate, computedEnd);
|
||||
|
||||
// UID für jeden Termin (eindeutig)
|
||||
const uid = `booking-${booking.id}@stargirlnails.de`;
|
||||
@@ -96,6 +110,60 @@ END:VEVENT
|
||||
return ics;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract and validate CalDAV token from Authorization header or query parameter (legacy)
|
||||
* @param c Hono context
|
||||
* @returns { token: string; source: 'bearer'|'basic'|'query' } | null
|
||||
*/
|
||||
function extractCalDAVToken(c: any): { token: string; source: 'bearer'|'basic'|'query' } | null {
|
||||
// UUID v4 pattern for hardening (xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx)
|
||||
const uuidV4Regex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
// Prefer Authorization header (new secure methods: Bearer or Basic)
|
||||
const authHeader = c.req.header('Authorization');
|
||||
if (authHeader) {
|
||||
// Bearer
|
||||
const bearerMatch = authHeader.match(/^Bearer\s+(.+)$/i);
|
||||
if (bearerMatch) {
|
||||
const token = bearerMatch[1].trim();
|
||||
if (!uuidV4Regex.test(token)) {
|
||||
console.warn('CalDAV: Bearer token does not match UUID v4 format.');
|
||||
return null;
|
||||
}
|
||||
return { token, source: 'bearer' };
|
||||
}
|
||||
|
||||
// Basic (use username or password as token)
|
||||
const basicMatch = authHeader.match(/^Basic\s+(.+)$/i);
|
||||
if (basicMatch) {
|
||||
try {
|
||||
const decoded = Buffer.from(basicMatch[1], 'base64').toString('utf8');
|
||||
// Format: username:password (password optional)
|
||||
const [username, password] = decoded.split(':');
|
||||
const candidate = (username && username.trim().length > 0)
|
||||
? username.trim()
|
||||
: (password ? password.trim() : '');
|
||||
if (candidate && uuidV4Regex.test(candidate)) {
|
||||
return { token: candidate, source: 'basic' };
|
||||
}
|
||||
console.warn('CalDAV: Basic auth credential does not contain a valid UUID v4 token.');
|
||||
} catch (e) {
|
||||
console.warn('CalDAV: Failed to decode Basic auth header');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to query parameter (legacy, will be deprecated)
|
||||
const queryToken = c.req.query('token');
|
||||
if (queryToken) {
|
||||
console.warn('CalDAV: Token passed via query parameter (deprecated). Please use Authorization header.');
|
||||
return { token: queryToken, source: 'query' };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// CalDAV Discovery (PROPFIND auf Root)
|
||||
caldavApp.all("/", async (c) => {
|
||||
if (c.req.method !== 'PROPFIND') {
|
||||
@@ -184,42 +252,54 @@ caldavApp.all("/calendar/events.ics", async (c) => {
|
||||
// GET Calendar Data (ICS-Datei)
|
||||
caldavApp.get("/calendar/events.ics", async (c) => {
|
||||
try {
|
||||
// Authentifizierung über Token im Query-Parameter
|
||||
const token = c.req.query('token');
|
||||
if (!token) {
|
||||
return c.text('Unauthorized - Token required', 401);
|
||||
// Extract token from Authorization header (Bearer/Basic) or query parameter (legacy)
|
||||
const tokenResult = extractCalDAVToken(c);
|
||||
if (!tokenResult) {
|
||||
return c.text('Unauthorized - Token erforderlich via Authorization (Bearer oder Basic) oder (deprecated) ?token', 401, {
|
||||
'WWW-Authenticate': 'Bearer realm="CalDAV Calendar Access", Basic realm="CalDAV Calendar Access"'
|
||||
});
|
||||
}
|
||||
|
||||
// Token validieren
|
||||
const tokenData = await sessionsKV.getItem(token);
|
||||
// Validate token against caldavTokens KV store
|
||||
const tokenData = await caldavTokensKV.getItem(tokenResult.token);
|
||||
if (!tokenData) {
|
||||
return c.text('Unauthorized - Invalid token', 401);
|
||||
return c.text('Unauthorized - Invalid or expired token', 401, {
|
||||
'WWW-Authenticate': 'Bearer realm="CalDAV Calendar Access", Basic realm="CalDAV Calendar Access"'
|
||||
});
|
||||
}
|
||||
|
||||
// Prüfe, ob es ein CalDAV-Token ist (durch Ablaufzeit und fehlende type-Eigenschaft erkennbar)
|
||||
// CalDAV-Tokens haben eine kürzere Ablaufzeit (24h) als normale Sessions
|
||||
const tokenAge = Date.now() - new Date(tokenData.createdAt).getTime();
|
||||
if (tokenAge > 24 * 60 * 60 * 1000) { // 24 Stunden
|
||||
return c.text('Unauthorized - Token expired', 401);
|
||||
}
|
||||
|
||||
// Token-Ablaufzeit prüfen
|
||||
// Check token expiration
|
||||
if (new Date(tokenData.expiresAt) < new Date()) {
|
||||
return c.text('Unauthorized - Token expired', 401);
|
||||
// Clean up expired token
|
||||
await caldavTokensKV.removeItem(tokenResult.token);
|
||||
return c.text('Unauthorized - Token expired', 401, {
|
||||
'WWW-Authenticate': 'Bearer realm="CalDAV Calendar Access", Basic realm="CalDAV Calendar Access"'
|
||||
});
|
||||
}
|
||||
|
||||
// Note: Token is valid for 24 hours from creation.
|
||||
// Expired tokens are cleaned up on access attempt.
|
||||
|
||||
const bookings = await bookingsKV.getAllItems();
|
||||
const treatments = await treatmentsKV.getAllItems();
|
||||
|
||||
const icsContent = generateICSContent(bookings, treatments);
|
||||
|
||||
return c.text(icsContent, 200, {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "text/calendar; charset=utf-8",
|
||||
"Content-Disposition": "inline; filename=\"stargirlnails-termine.ics\"",
|
||||
"Cache-Control": "no-cache, no-store, must-revalidate",
|
||||
"Pragma": "no-cache",
|
||||
"Expires": "0",
|
||||
});
|
||||
};
|
||||
|
||||
// If legacy query token was used, inform clients about deprecation
|
||||
if (tokenResult.source === 'query') {
|
||||
headers["Deprecation"] = "true";
|
||||
headers["Warning"] = "299 - \"Query parameter token authentication is deprecated. Use Authorization header (Bearer or Basic).\"";
|
||||
}
|
||||
|
||||
return c.text(icsContent, 200, headers);
|
||||
} catch (error) {
|
||||
console.error("CalDAV GET error:", error);
|
||||
return c.text('Internal Server Error', 500);
|
||||
|
@@ -11,6 +11,7 @@ rpcApp.all("/*", async (c) => {
|
||||
try {
|
||||
const { matched, response } = await handler.handle(c.req.raw, {
|
||||
prefix: "/rpc",
|
||||
context: c,
|
||||
});
|
||||
|
||||
if (matched) {
|
||||
|
@@ -1,9 +1,25 @@
|
||||
import { call, os } from "@orpc/server";
|
||||
import type { Context } from "hono";
|
||||
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,
|
||||
type Session,
|
||||
type User
|
||||
} from "../lib/auth.js";
|
||||
|
||||
// Load environment variables from .env file
|
||||
config();
|
||||
@@ -22,13 +38,10 @@ const SessionSchema = z.object({
|
||||
userId: z.string(),
|
||||
expiresAt: z.string(),
|
||||
createdAt: z.string(),
|
||||
csrfToken: z.string().optional(),
|
||||
});
|
||||
|
||||
type User = z.output<typeof UserSchema>;
|
||||
type Session = z.output<typeof SessionSchema>;
|
||||
|
||||
const usersKV = createKV<User>("users");
|
||||
const sessionsKV = createKV<Session>("sessions");
|
||||
// Use shared KV stores from auth.ts to avoid duplication
|
||||
|
||||
// Password hashing using bcrypt
|
||||
const BCRYPT_PREFIX = "$2"; // $2a, $2b, $2y
|
||||
@@ -131,16 +144,25 @@ const login = os
|
||||
username: z.string(),
|
||||
password: z.string(),
|
||||
}))
|
||||
.handler(async ({ input }) => {
|
||||
.handler(async ({ input, context }) => {
|
||||
const ip = getClientIP((context.req as any).raw.headers as Headers);
|
||||
const users = await usersKV.getAllItems();
|
||||
const user = users.find(u => u.username === input.username);
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -151,8 +173,9 @@ const login = os
|
||||
await usersKV.setItem(user.id, migratedUser);
|
||||
}
|
||||
|
||||
// Create session
|
||||
// Create session with CSRF token
|
||||
const sessionId = randomUUID();
|
||||
const csrfToken = generateCSRFToken();
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setHours(expiresAt.getHours() + 24); // 24 hours
|
||||
|
||||
@@ -161,12 +184,22 @@ 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,
|
||||
@@ -177,25 +210,28 @@ 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");
|
||||
@@ -213,12 +249,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");
|
||||
}
|
||||
@@ -228,6 +263,9 @@ const changePassword = os
|
||||
throw new Error("User not found");
|
||||
}
|
||||
|
||||
// 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");
|
||||
@@ -239,6 +277,17 @@ const changePassword = os
|
||||
};
|
||||
|
||||
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 };
|
||||
});
|
||||
|
||||
|
@@ -1,15 +1,17 @@
|
||||
import { call, os } from "@orpc/server";
|
||||
import type { Context } from "hono";
|
||||
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, sendEmailWithAGB, sendEmailWithAGBAndCalendar, sendEmailWithInspirationPhoto } from "../lib/email.js";
|
||||
import { renderBookingPendingHTML, renderBookingConfirmedHTML, renderBookingCancelledHTML, renderAdminBookingNotificationHTML, renderBookingRescheduleProposalHTML, renderAdminRescheduleAcceptedHTML, renderAdminRescheduleDeclinedHTML } from "../lib/email-templates.js";
|
||||
import { router as rootRouter } from "./index.js";
|
||||
import { router as rootRouter, os, call } from "./index.js";
|
||||
import { createORPCClient } from "@orpc/client";
|
||||
import { RPCLink } from "@orpc/client/fetch";
|
||||
import { checkBookingRateLimit, getClientIP } from "../lib/rate-limiter.js";
|
||||
import { checkBookingRateLimit, getClientIP, checkAdminRateLimit, 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;
|
||||
@@ -397,26 +399,16 @@ const create = os
|
||||
});
|
||||
|
||||
// Owner check reuse (simple inline version)
|
||||
type Session = { id: string; userId: string; expiresAt: string; createdAt: string };
|
||||
type User = { id: string; username: string; email: string; passwordHash: string; role: "customer" | "owner"; createdAt: string };
|
||||
const sessionsKV = createKV<Session>("sessions");
|
||||
const usersKV = createKV<User>("users");
|
||||
async function assertOwner(sessionId: string): Promise<void> {
|
||||
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");
|
||||
}
|
||||
|
||||
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 as unknown as Context);
|
||||
// Admin Rate Limiting nach erfolgreicher Owner-Prüfung
|
||||
await enforceAdminRateLimit(context as unknown as Context);
|
||||
const booking = await kv.getItem(input.id);
|
||||
if (!booking) throw new Error("Booking not found");
|
||||
|
||||
@@ -487,12 +479,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 as unknown as Context);
|
||||
// Admin Rate Limiting nach erfolgreicher Owner-Prüfung
|
||||
await enforceAdminRateLimit(context as unknown as Context);
|
||||
const booking = await kv.getItem(input.id);
|
||||
if (!booking) throw new Error("Booking not found");
|
||||
|
||||
@@ -536,7 +529,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(),
|
||||
@@ -545,9 +537,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 as unknown as Context);
|
||||
// Admin Rate Limiting nach erfolgreicher Owner-Prüfung
|
||||
await enforceAdminRateLimit(context as unknown as Context);
|
||||
|
||||
// Validate appointment time is on 15-minute grid
|
||||
const appointmentMinutes = parseTime(input.appointmentTime);
|
||||
@@ -702,13 +696,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 as unknown as Context);
|
||||
const booking = await kv.getItem(input.bookingId);
|
||||
if (!booking) throw new Error("Booking not found");
|
||||
if (booking.status !== "confirmed") throw new Error("Nur bestätigte Termine können umgebucht werden.");
|
||||
@@ -883,32 +876,32 @@ 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);
|
||||
if (!session) throw new Error("Session nicht gefunden");
|
||||
// Hole Session-Daten aus Cookies
|
||||
const session = await getSessionFromCookies(context as unknown as Context);
|
||||
if (!session) 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<typeof tokenData>("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,
|
||||
@@ -917,15 +910,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)."
|
||||
}
|
||||
};
|
||||
}),
|
||||
|
@@ -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";
|
||||
|
||||
// Schema Definition
|
||||
const GalleryPhotoSchema = z.object({
|
||||
@@ -25,16 +26,17 @@ const galleryPhotosKV = createKV<GalleryPhoto>("galleryPhotos");
|
||||
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 as any);
|
||||
const id = randomUUID();
|
||||
const existing = await galleryPhotosKV.getAllItems();
|
||||
const maxOrder = existing.length > 0 ? Math.max(...existing.map((p) => p.order)) : -1;
|
||||
@@ -58,9 +60,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 as any);
|
||||
const all = await galleryPhotosKV.getAllItems();
|
||||
let updatedCover: GalleryPhoto | null = null;
|
||||
for (const p of all) {
|
||||
@@ -73,21 +77,24 @@ const setCoverPhoto = os
|
||||
});
|
||||
|
||||
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 as any);
|
||||
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 as any);
|
||||
const updated: GalleryPhoto[] = [];
|
||||
for (const { id, order } of input.photoOrders) {
|
||||
const existing = await galleryPhotosKV.getItem(id);
|
||||
@@ -106,9 +113,9 @@ const listPhotos = os.handler(async () => {
|
||||
});
|
||||
|
||||
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));
|
||||
});
|
||||
@@ -123,9 +130,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,6 @@
|
||||
import { demo } from "./demo/index.js";
|
||||
import { os as baseOs, call as baseCall } from "@orpc/server";
|
||||
import type { Context } from "hono";
|
||||
import { router as treatments } from "./treatments.js";
|
||||
import { router as bookings } from "./bookings.js";
|
||||
import { router as auth } from "./auth.js";
|
||||
@@ -19,3 +21,9 @@ export const router = {
|
||||
gallery,
|
||||
reviews,
|
||||
};
|
||||
|
||||
// Export centrally typed oRPC helpers so all modules share the same Hono Context typing
|
||||
const osAny = baseOs as any;
|
||||
export const os = osAny.withContext?.<Context>() ?? osAny.context?.<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({
|
||||
@@ -87,15 +88,23 @@ function detectOverlappingRules(newRule: { dayOfWeek: number; startTime: string;
|
||||
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 as any).raw.headers as any);
|
||||
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);
|
||||
@@ -132,9 +141,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 as any).raw.headers as any);
|
||||
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);
|
||||
@@ -152,22 +170,40 @@ const updateRule = os
|
||||
throw new Error(`Überlappung mit bestehenden Regeln erkannt: ${overlappingTimes}. Bitte Zeitfenster anpassen.`);
|
||||
}
|
||||
|
||||
const { sessionId, ...rule } = input as any;
|
||||
const rule = input as any;
|
||||
await recurringRulesKV.setItem(rule.id, rule as RecurringRule);
|
||||
return rule as RecurringRule;
|
||||
});
|
||||
|
||||
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 as any).raw.headers as any);
|
||||
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 as any).raw.headers as any);
|
||||
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.");
|
||||
|
||||
@@ -185,9 +221,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) return a.dayOfWeek - b.dayOfWeek;
|
||||
@@ -199,15 +235,16 @@ const adminListRules = os
|
||||
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 as any);
|
||||
|
||||
// Validierung: startDate <= endDate
|
||||
if (input.startDate > input.endDate) {
|
||||
@@ -232,24 +269,28 @@ 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 as any);
|
||||
|
||||
// Validierung: startDate <= endDate
|
||||
if (input.startDate > input.endDate) {
|
||||
throw new Error("Startdatum muss vor oder am Enddatum liegen.");
|
||||
}
|
||||
|
||||
const { sessionId, ...timeOff } = input as any;
|
||||
const timeOff = input as any;
|
||||
await timeOffPeriodsKV.setItem(timeOff.id, timeOff as TimeOffPeriod);
|
||||
return timeOff as TimeOffPeriod;
|
||||
});
|
||||
|
||||
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 as any);
|
||||
await timeOffPeriodsKV.removeItem(input.id);
|
||||
});
|
||||
|
||||
@@ -259,9 +300,9 @@ const listTimeOff = os.handler(async () => {
|
||||
});
|
||||
|
||||
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));
|
||||
});
|
||||
@@ -418,9 +459,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) return a.dayOfWeek - b.dayOfWeek;
|
||||
@@ -438,9 +479,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;
|
||||
|
@@ -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, 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({
|
||||
@@ -133,22 +134,31 @@ 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 as any).raw.headers as any);
|
||||
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" as const,
|
||||
reviewedAt: new Date().toISOString(),
|
||||
reviewedBy: session?.userId || review.reviewedBy,
|
||||
reviewedBy: session2?.userId || review.reviewedBy,
|
||||
};
|
||||
|
||||
await reviewsKV.setItem(input.id, updatedReview);
|
||||
@@ -161,22 +171,31 @@ 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 as any).raw.headers as any);
|
||||
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" as const,
|
||||
reviewedAt: new Date().toISOString(),
|
||||
reviewedBy: session?.userId || review.reviewedBy,
|
||||
reviewedBy: session2?.userId || review.reviewedBy,
|
||||
};
|
||||
|
||||
await reviewsKV.setItem(input.id, updatedReview);
|
||||
@@ -189,10 +208,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 as any).raw.headers as any);
|
||||
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) {
|
||||
console.error("reviews.deleteReview error", err);
|
||||
@@ -225,13 +253,12 @@ const listPublishedReviews = os.handler(async (): Promise<PublicReview[]> => {
|
||||
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"
|
||||
@@ -258,12 +285,11 @@ 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"
|
||||
|
@@ -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, getSessionFromCookies } from "../lib/auth.js";
|
||||
import { checkAdminRateLimit, getClientIP, enforceAdminRateLimit } from "../lib/rate-limiter.js";
|
||||
|
||||
const TreatmentSchema = z.object({
|
||||
id: z.string(),
|
||||
@@ -18,7 +20,10 @@ const kv = createKV<Treatment>("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 as any);
|
||||
const id = randomUUID();
|
||||
const treatment = { id, ...input };
|
||||
await kv.setItem(id, treatment);
|
||||
@@ -27,12 +32,18 @@ const create = os
|
||||
|
||||
const update = os
|
||||
.input(TreatmentSchema)
|
||||
.handler(async ({ input }) => {
|
||||
.handler(async ({ input, context }) => {
|
||||
await assertOwner(context);
|
||||
// Admin Rate Limiting
|
||||
await enforceAdminRateLimit(context as any);
|
||||
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 as any);
|
||||
await kv.removeItem(input);
|
||||
});
|
||||
|
||||
|
Reference in New Issue
Block a user