diff --git a/.gitignore b/.gitignore index 23be534..ead42a9 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,7 @@ vite.config.js.timestamp-* vite.config.ts.timestamp-* # SQLite *.db + +# Persistent local data / uploads +/data/ + diff --git a/src/hooks.server.ts b/src/hooks.server.ts index fb1d3a5..708ed94 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -4,7 +4,7 @@ import { eq } from 'drizzle-orm'; import { redirect, type Handle } from '@sveltejs/kit'; export const handle: Handle = async ({ event, resolve }) => { - const sessionId = event.cookies.get('session'); + const sessionId = event.cookies.get('__Host-session'); event.locals.user = null; if (sessionId) { @@ -33,11 +33,11 @@ export const handle: Handle = async ({ event, resolve }) => { } else { // Session expired, clean up db.delete(schema.sessions).where(eq(schema.sessions.id, sessionId)).run(); - event.cookies.delete('session', { path: '/' }); + event.cookies.delete('__Host-session', { path: '/' }); } } else { // Invalid session cookie - event.cookies.delete('session', { path: '/' }); + event.cookies.delete('__Host-session', { path: '/' }); } } catch (err) { console.error('Error in session auth hook:', err); diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts index c687261..06ae28a 100644 --- a/src/lib/server/db/schema.ts +++ b/src/lib/server/db/schema.ts @@ -66,4 +66,16 @@ export const settings = sqliteTable('settings', { updatedAt: integer('updated_at').notNull().$defaultFn(() => Date.now()) }); +export const beneficiaryDocuments = sqliteTable('beneficiary_documents', { + id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()), + beneficiaryId: text('beneficiary_id') + .notNull() + .references(() => beneficiaries.id, { onDelete: 'cascade' }), + filePath: text('file_path').notNull(), + originalName: text('original_name').notNull(), + mimeType: text('mime_type').notNull(), + createdAt: integer('created_at').notNull().$defaultFn(() => Date.now()) +}); + + diff --git a/src/routes/admin/beneficiarios/[id]/+page.server.ts b/src/routes/admin/beneficiarios/[id]/+page.server.ts index 6a6bb6f..8a718a8 100644 --- a/src/routes/admin/beneficiarios/[id]/+page.server.ts +++ b/src/routes/admin/beneficiarios/[id]/+page.server.ts @@ -1,8 +1,10 @@ import { db } from '$lib/server/db'; import * as schema from '$lib/server/db/schema'; -import { eq, and, ne } from 'drizzle-orm'; +import { eq, and, ne, desc } from 'drizzle-orm'; import { error, fail, redirect } from '@sveltejs/kit'; import type { Actions, PageServerLoad } from './$types'; +import fs from 'fs'; +import path from 'path'; export const load: PageServerLoad = async ({ params }) => { const id = params.id; @@ -18,8 +20,17 @@ export const load: PageServerLoad = async ({ params }) => { throw error(404, 'Beneficiário não encontrado'); } + // Carregar os documentos associados ordenados por data de criação descrescente + const documents = db + .select() + .from(schema.beneficiaryDocuments) + .where(eq(schema.beneficiaryDocuments.beneficiaryId, id)) + .orderBy(desc(schema.beneficiaryDocuments.createdAt)) + .all(); + return { - beneficiary + beneficiary, + documents }; } catch (err) { if (err && typeof err === 'object' && 'status' in err && err.status === 404) { @@ -31,7 +42,7 @@ export const load: PageServerLoad = async ({ params }) => { }; export const actions: Actions = { - default: async ({ params, request }) => { + update: async ({ params, request }) => { const id = params.id; const data = await request.formData(); const numberStr = data.get('number')?.toString().trim(); @@ -117,5 +128,126 @@ export const actions: Actions = { // Redirect on success throw redirect(303, `/admin/beneficiarios?success=${encodeURIComponent('Alterações guardadas com sucesso.')}`); + }, + + uploadDocument: async ({ params, request, locals }) => { + // Validar permissões adicionais + const user = locals.user; + if (!user || (user.role !== 'admin' && user.role !== 'shift_manager')) { + return fail(403, { error: 'Não autorizado' }); + } + + const id = params.id; + const data = await request.formData(); + const file = data.get('document') as File; + + if (!file || file.size === 0) { + return fail(400, { error: 'Nenhum ficheiro foi carregado.' }); + } + + // 1. Limite de tamanho (5MB) + const MAX_SIZE = 5 * 1024 * 1024; + if (file.size > MAX_SIZE) { + return fail(400, { error: 'O ficheiro excede o limite máximo de 5MB.' }); + } + + // 2. Validar tipo MIME + const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp']; + if (!ALLOWED_TYPES.includes(file.type)) { + return fail(400, { error: 'Apenas são permitidas imagens (JPEG, PNG, WebP).' }); + } + + try { + // Criar diretório se não existir + const dirPath = 'data/documents'; + fs.mkdirSync(dirPath, { recursive: true }); + + // Gerar nome único seguro + const fileId = crypto.randomUUID(); + let ext = 'jpg'; + if (file.type === 'image/png') ext = 'png'; + else if (file.type === 'image/webp') ext = 'webp'; + + const fileName = `${fileId}.${ext}`; + const filePath = path.join(dirPath, fileName); + + // Gravar ficheiro no disco + const arrayBuffer = await file.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + fs.writeFileSync(filePath, buffer); + + // Guardar metadados na BD + db.insert(schema.beneficiaryDocuments) + .values({ + id: fileId, + beneficiaryId: id, + filePath, + originalName: file.name || 'documento.jpg', + mimeType: file.type, + createdAt: Date.now() + }) + .run(); + + return { success: true }; + } catch (err) { + console.error('Error uploading document:', err); + return fail(500, { error: 'Erro ao guardar o documento no servidor.' }); + } + }, + + deleteDocument: async ({ params, request, locals }) => { + // Validar permissões adicionais + const user = locals.user; + if (!user || (user.role !== 'admin' && user.role !== 'shift_manager')) { + return fail(403, { error: 'Não autorizado' }); + } + + const id = params.id; + const data = await request.formData(); + const docId = data.get('docId')?.toString().trim(); + + if (!docId) { + return fail(400, { error: 'Identificador do documento em falta.' }); + } + + try { + // Buscar o registo para obter o caminho do ficheiro + const doc = db + .select() + .from(schema.beneficiaryDocuments) + .where(eq(schema.beneficiaryDocuments.id, docId)) + .get(); + + if (!doc) { + return fail(404, { error: 'Documento não encontrado.' }); + } + + // Garantir que o documento pertence a este beneficiário + if (doc.beneficiaryId !== id) { + return fail(403, { error: 'Acesso negado.' }); + } + + // Eliminar do disco + const allowedDir = path.resolve('data/documents'); + const absolutePath = path.resolve(doc.filePath); + + if (absolutePath.startsWith(allowedDir + path.sep)) { + if (fs.existsSync(absolutePath)) { + fs.unlinkSync(absolutePath); + } + } else { + return fail(400, { error: 'Caminho de ficheiro inválido.' }); + } + + // Eliminar da BD + db.delete(schema.beneficiaryDocuments) + .where(eq(schema.beneficiaryDocuments.id, docId)) + .run(); + + return { success: true }; + } catch (err) { + console.error('Error deleting document:', err); + return fail(500, { error: 'Erro ao eliminar o documento.' }); + } } }; diff --git a/src/routes/admin/beneficiarios/[id]/+page.svelte b/src/routes/admin/beneficiarios/[id]/+page.svelte index 6553cc9..ef5ae08 100644 --- a/src/routes/admin/beneficiarios/[id]/+page.svelte +++ b/src/routes/admin/beneficiarios/[id]/+page.svelte @@ -1,11 +1,16 @@ @@ -24,6 +29,17 @@ Atualizar informações do beneficiário #{beneficiary.number} + {#if successMessage} + + + + + + {successMessage} + + + {/if} + {#if form?.error} {form.error} @@ -31,7 +47,7 @@ {/if} - { + { isLoading = true; return async ({ update }) => { isLoading = false; @@ -141,4 +157,189 @@ + + + + + + Documentos do Beneficiário + Fotografias e cópias digitais dos documentos do agregado + + + + + { + isUploading = true; + uploadError = null; + return async ({ result, update }) => { + isUploading = false; + if (result.type === 'failure') { + uploadError = result.data?.error?.toString() || 'Erro ao carregar ficheiro.'; + } else { + uploadError = null; + } + await update({ reset: false }); + }; + }} + > + + + + + + {#if isUploading} + + A carregar... + {:else} + Tirar Foto / Adicionar + {/if} + { + const files = e.currentTarget.files; + if (files && files.length > 0) { + e.currentTarget.form?.requestSubmit(); + } + }} + /> + + + + + + {#if uploadError} + + {uploadError} + + {/if} + + + {#if !data.documents || data.documents.length === 0} + + + + + + Nenhum documento registado para este beneficiário. + + {:else} + + {#each data.documents as doc} + {@const docUrl = `/admin/beneficiarios/${beneficiary.id}/documentos/${doc.id}`} + + + selectedDocForLightbox = doc} + > + + + + + + {/each} + + {/if} + + + +{#if selectedDocForLightbox} + + + + selectedDocForLightbox = null} + onkeydown={(e) => e.key === 'Escape' && (selectedDocForLightbox = null)} + > + + + e.stopPropagation()} + > + + + {selectedDocForLightbox.originalName} + selectedDocForLightbox = null} + aria-label="Fechar" + > + + + + + + + +{/if} + + diff --git a/src/routes/admin/beneficiarios/[id]/documentos/[docId]/+server.ts b/src/routes/admin/beneficiarios/[id]/documentos/[docId]/+server.ts new file mode 100644 index 0000000..138f62f --- /dev/null +++ b/src/routes/admin/beneficiarios/[id]/documentos/[docId]/+server.ts @@ -0,0 +1,66 @@ +import { db } from '$lib/server/db'; +import * as schema from '$lib/server/db/schema'; +import { eq, and } from 'drizzle-orm'; +import { error, type RequestEvent } from '@sveltejs/kit'; +import fs from 'fs'; +import path from 'path'; + +export const GET = async (event: RequestEvent) => { + const { params, locals } = event; + + const { id: beneficiaryId, docId } = params as any; + + if (!beneficiaryId || !docId) { + throw error(400, 'Parâmetros em falta'); + } + + // 1. Verificar autenticação e permissões (apenas admin e shift_manager) + const user = locals.user; + if (!user || (user.role !== 'admin' && user.role !== 'shift_manager')) { + throw error(403, 'Acesso não autorizado'); + } + + + // 2. Proteção contra Path Traversal + const safeDocId = path.basename(docId); + + // 3. Consultar metadados do documento na base de dados + // Garante que o documento existe e que pertence ao beneficiário especificado no URL + const doc = db + .select() + .from(schema.beneficiaryDocuments) + .where( + and( + eq(schema.beneficiaryDocuments.id, safeDocId), + eq(schema.beneficiaryDocuments.beneficiaryId, beneficiaryId) + ) + ) + .get(); + + if (!doc) { + throw error(404, 'Documento não encontrado'); + } + + // 4. Resolver o caminho do ficheiro de forma segura e validar limites de diretório + const allowedDir = path.resolve('data/documents'); + const absolutePath = path.resolve(doc.filePath); + + if (!absolutePath.startsWith(allowedDir + path.sep)) { + throw error(400, 'Acesso a diretório inválido'); + } + + if (!fs.existsSync(absolutePath)) { + throw error(404, 'Ficheiro não encontrado no servidor'); + } + + // 5. Ler o ficheiro e retornar com cabeçalhos de segurança + const fileBuffer = fs.readFileSync(absolutePath); + + return new Response(fileBuffer, { + headers: { + 'Content-Type': doc.mimeType, + 'X-Content-Type-Options': 'nosniff', + 'Cache-Control': 'private, max-age=3600' + } + }); +}; diff --git a/src/routes/admin/beneficiarios/novo/+page.server.ts b/src/routes/admin/beneficiarios/novo/+page.server.ts index 685c61b..dc75cc3 100644 --- a/src/routes/admin/beneficiarios/novo/+page.server.ts +++ b/src/routes/admin/beneficiarios/novo/+page.server.ts @@ -63,26 +63,35 @@ export const actions: Actions = { }); } - // Insert beneficiary - db.insert(schema.beneficiaries) + // Insert beneficiary and return the created ID + const newBeneficiary = db.insert(schema.beneficiaries) .values({ number, name, contact, householdSize, observations, - status: 'ativo' + status: 'ativo', + isParent: true }) - .run(); + .returning({ id: schema.beneficiaries.id }) + .get(); + + if (!newBeneficiary || !newBeneficiary.id) { + throw new Error('Falha ao obter o ID do beneficiário registado.'); + } + + // Redirect on success to the edit page of the newly created beneficiary + throw redirect(303, `/admin/beneficiarios/${newBeneficiary.id}?success=${encodeURIComponent('Beneficiário criado com sucesso.')}`); } catch (err) { + if (err && typeof err === 'object' && 'status' in err) { + throw err; // Let SvelteKit redirect bubble up + } console.error('Error creating beneficiary:', err); return fail(500, { success: false, error: 'Ocorreu um erro ao guardar o beneficiário. Tente novamente.' }); } - - // Redirect on success - throw redirect(303, `/admin/beneficiarios?success=${encodeURIComponent('Beneficiário guardado com sucesso.')}`); } }; diff --git a/src/routes/admin/utilizadores/[id]/+page.server.ts b/src/routes/admin/utilizadores/[id]/+page.server.ts index e4b7886..e3e1bfc 100644 --- a/src/routes/admin/utilizadores/[id]/+page.server.ts +++ b/src/routes/admin/utilizadores/[id]/+page.server.ts @@ -70,10 +70,10 @@ export const actions: Actions = { error: 'As palavras-passe introduzidas não coincidem.' }); } - if (password!.length < 4) { + if (password!.length < 8) { return fail(400, { success: false, - error: 'A palavra-passe deve ter pelo menos 4 caracteres.' + error: 'A palavra-passe deve ter pelo menos 8 caracteres.' }); } } @@ -110,6 +110,11 @@ export const actions: Actions = { }) .where(eq(schema.users.id, id)) .run(); + + // Invalidate all active sessions for this user + db.delete(schema.sessions) + .where(eq(schema.sessions.userId, id)) + .run(); } else { db.update(schema.users) .set({ diff --git a/src/routes/admin/utilizadores/novo/+page.server.ts b/src/routes/admin/utilizadores/novo/+page.server.ts index 1e155ef..2e47f7c 100644 --- a/src/routes/admin/utilizadores/novo/+page.server.ts +++ b/src/routes/admin/utilizadores/novo/+page.server.ts @@ -44,13 +44,13 @@ export const actions: Actions = { }); } - if (password.length < 4) { + if (password.length < 8) { return fail(400, { success: false, name, username, role, - error: 'A palavra-passe deve ter pelo menos 4 caracteres.' + error: 'A palavra-passe deve ter pelo menos 8 caracteres.' }); } diff --git a/src/routes/login/+page.server.ts b/src/routes/login/+page.server.ts index 6139fb7..c6bbf11 100644 --- a/src/routes/login/+page.server.ts +++ b/src/routes/login/+page.server.ts @@ -64,11 +64,11 @@ export const actions: Actions = { .run(); // Set session cookie - cookies.set('session', sessionId, { + cookies.set('__Host-session', sessionId, { path: '/', httpOnly: true, sameSite: 'lax', - secure: !dev, + secure: true, maxAge: 60 * 60 * 24 * 7 // 7 days in seconds }); diff --git a/src/routes/logout/+page.server.ts b/src/routes/logout/+page.server.ts index 8dc9955..f427c27 100644 --- a/src/routes/logout/+page.server.ts +++ b/src/routes/logout/+page.server.ts @@ -6,7 +6,7 @@ import type { Actions } from './$types'; export const actions: Actions = { default: async ({ cookies }) => { - const sessionId = cookies.get('session'); + const sessionId = cookies.get('__Host-session'); if (sessionId) { try { @@ -17,7 +17,7 @@ export const actions: Actions = { } // Clear cookie - cookies.delete('session', { path: '/' }); + cookies.delete('__Host-session', { path: '/' }); } throw redirect(303, '/login');
Atualizar informações do beneficiário #{beneficiary.number}
Fotografias e cópias digitais dos documentos do agregado
Nenhum documento registado para este beneficiário.