Compare commits

..

No commits in common. "main" and "v1.129.2" have entirely different histories.

6 changed files with 10 additions and 56 deletions

View File

@ -2,33 +2,6 @@
All notable changes to this project are documented in this file. All notable changes to this project are documented in this file.
## [1.129.3] - 2026-09-15
### Fixed
- **"Failed to update event" bei Outlook-Terminen** ("IDX14100: JWT is not well formed"
in den Server-Logs). Die Verschlüsselung von `accessToken`/`refreshToken` in
`CalendarConnection` (siehe [1.x] Security-Härtung) läuft als Prisma-Client-Extension
ausschließlich auf direkten `calendarConnection`-Queries — Next.js/Prisma-Extensions
fangen keine verschachtelten `include`-Relationen ab, die über ein anderes Modell
geladen werden. Vier Routen luden Verbindungen aber genau so:
`prisma.user.findUnique({ include: { calendarConnections: true } })`. Für Apple/Synology
(statisches App-Passwort, nie neu geschrieben) blieb das unbemerkt, weil deren Zeilen nie
verschlüsselt wurden. Outlook-Tokens werden dagegen bei jedem Refresh neu geschrieben und
damit verschlüsselt — ab dann schickte die App den Chiffretext statt des echten Tokens an
Microsoft Graph, die Anfrage lief mit 401 auf.
- Betroffen und korrigiert: `api/calendar/events/route.ts` (Termin erstellen/ändern/
löschen — der hier gemeldete Fehler), `api/calendar/sync/route.ts` (normale Anzeige-
Synchronisation), `api/calendar/background-sync/route.ts` (Hintergrund-Sync),
`api/calendar/connections/route.ts` (Verbindungsliste, inkl. Apple/Synology-
Farbaktualisierung im Hintergrund). Alle vier laden `CalendarConnection` jetzt per
direktem `calendarConnection.findMany`/`findFirst` statt über `user`-`include`.
Verifiziert nicht nur mit Typecheck/Build: Wegwerf-Postgres (Docker) aufgesetzt, eine
`CalendarConnection` mit echtem Prisma-Client (inkl. Verschlüsselungs-Extension)
angelegt — Rohwert in der DB trägt das `enc1:`-Präfix. Der alte `include`-Pfad lieferte
exakt den Chiffretext zurück (Reproduktion des Produktionsfehlers), der neue direkte
Query-Pfad das korrekte Klartext-Token.
## [1.129.2] - 2026-09-14 ## [1.129.2] - 2026-09-14
### Fixed ### Fixed

View File

@ -1,6 +1,6 @@
{ {
"name": "my-weekly-todo-list", "name": "my-weekly-todo-list",
"version": "1.129.3", "version": "1.129.2",
"description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view", "description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {

View File

@ -16,17 +16,10 @@ export async function POST(request: NextRequest) {
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { email: session.user.email }, where: { email: session.user.email },
select: { id: true }, include: { calendarConnections: true },
}); });
if (!user) { if (!user || user.calendarConnections.length === 0) {
return NextResponse.json({ queued: 0 });
}
// Queried directly (not via user's `include`) so the accessToken/refreshToken
// decryption hook in lib/prisma.ts runs — see comment in calendar/sync/route.ts.
const connections = await prisma.calendarConnection.findMany({ where: { userId: user.id } });
if (connections.length === 0) {
return NextResponse.json({ queued: 0 }); return NextResponse.json({ queued: 0 });
} }
@ -35,7 +28,7 @@ export async function POST(request: NextRequest) {
const tMax = timeMax ? new Date(timeMax) : new Date(now.getTime() + 14 * 24 * 60 * 60 * 1000); const tMax = timeMax ? new Date(timeMax) : new Date(now.getTime() + 14 * 24 * 60 * 60 * 1000);
const staleChecks = await Promise.all( const staleChecks = await Promise.all(
connections.map(async conn => ({ user.calendarConnections.map(async conn => ({
conn, conn,
stale: forceRefresh || await isCacheStale(conn.id, tMin), stale: forceRefresh || await isCacheStale(conn.id, tMin),
})) }))

View File

@ -21,7 +21,7 @@ export async function GET(request: NextRequest) {
// Find user by email // Find user by email
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { email: session.user.email }, where: { email: session.user.email },
select: { id: true } include: { calendarConnections: true }
}); });
if (!user) { if (!user) {
@ -31,13 +31,9 @@ export async function GET(request: NextRequest) {
); );
} }
// Queried directly (not via user's `include`) so the accessToken/refreshToken
// decryption hook in lib/prisma.ts runs — see comment in calendar/sync/route.ts.
const rawConnections = await prisma.calendarConnection.findMany({ where: { userId: user.id } });
// Return calendar connections (without sensitive tokens) // Return calendar connections (without sensitive tokens)
// Prune deleted calendars from providers that support live listing // Prune deleted calendars from providers that support live listing
const connections = rawConnections.map((conn) => { const connections = user.calendarConnections.map((conn) => {
let calendars = conn.calendars as any[] | null; let calendars = conn.calendars as any[] | null;
// For Apple connections, filter out VTODO/Reminders collections // For Apple connections, filter out VTODO/Reminders collections

View File

@ -10,16 +10,12 @@ import { upsertCachedEvent, deleteCachedEvent } from '@/lib/calendar-cache';
async function findConnectionForCalendar(email: string, calendarId: string) { async function findConnectionForCalendar(email: string, calendarId: string) {
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { email }, where: { email },
select: { id: true } include: { calendarConnections: true }
}); });
if (!user) return null; if (!user) return null;
// Queried directly (not via user's `include`) so the accessToken/refreshToken for (const conn of user.calendarConnections) {
// decryption hook in lib/prisma.ts runs — see comment in calendar/sync/route.ts.
const connections = await prisma.calendarConnection.findMany({ where: { userId: user.id } });
for (const conn of connections) {
if (conn.calendars && Array.isArray(conn.calendars)) { if (conn.calendars && Array.isArray(conn.calendars)) {
const calendars = conn.calendars as any[]; const calendars = conn.calendars as any[];
if (calendars.some(c => c.id === calendarId)) { if (calendars.some(c => c.id === calendarId)) {

View File

@ -23,18 +23,14 @@ export async function POST(request: NextRequest) {
// Look up by email — session ID can be stale after DB restore/migration // Look up by email — session ID can be stale after DB restore/migration
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { email: session.user.email }, where: { email: session.user.email },
select: { id: true }, include: { calendarConnections: true },
}); });
if (!user) { if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 }); return NextResponse.json({ error: 'User not found' }, { status: 404 });
} }
// calendarConnection must be queried directly (not via user's `include`) so the let connections = user.calendarConnections;
// accessToken/refreshToken decryption hook in lib/prisma.ts actually runs — Prisma
// client extensions only intercept top-level queries on the model, not nested
// relation loads reached through another model's `include`.
let connections = await prisma.calendarConnection.findMany({ where: { userId: user.id } });
if (connectionId) { if (connectionId) {
connections = connections.filter(c => c.id === connectionId); connections = connections.filter(c => c.id === connectionId);
} }