diff --git a/.gitignore b/.gitignore index 4c12cea..f54cbf6 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ npm-debug.log* # Postgres data volume bij lokaal draaien pgdata/ +storage/ # OS / editor .DS_Store diff --git a/Dockerfile b/Dockerfile index b2b6aba..65d5366 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,7 +13,7 @@ ENV NODE_ENV=production WORKDIR /app # Draai als non-root gebruiker -RUN addgroup -S app && adduser -S app -G app +RUN addgroup -S app && adduser -S app -G app && mkdir -p /app/storage/images && chown -R app:app /app/storage COPY --chown=app:app --from=deps /app/node_modules ./node_modules COPY --chown=app:app package.json ./ diff --git a/VERSION b/VERSION index 989d52f..1ea7270 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.3.54-beta +0.3.55-beta diff --git a/compose.yaml b/compose.yaml index 4764b57..b8f0e71 100644 --- a/compose.yaml +++ b/compose.yaml @@ -22,6 +22,8 @@ services: COOKIE_SECURE: "false" ports: - "3000:3000" + volumes: + - image_data:/app/storage/images restart: unless-stopped db: @@ -43,3 +45,4 @@ services: volumes: pgdata: + image_data: diff --git a/db/012_image_catalog.sql b/db/012_image_catalog.sql new file mode 100644 index 0000000..cd026e3 --- /dev/null +++ b/db/012_image_catalog.sql @@ -0,0 +1,130 @@ +-- v0.3.55-beta: doorzoekbare afbeeldingscatalogus met meervoudige thema's, +-- map-paden, eigenaarschap en instelbare opslagquota. + +ALTER TABLE users + ADD COLUMN IF NOT EXISTS image_quota_bytes BIGINT + CHECK (image_quota_bytes IS NULL OR image_quota_bytes >= -1); +ALTER TABLE schools + ADD COLUMN IF NOT EXISTS image_quota_bytes BIGINT + CHECK (image_quota_bytes IS NULL OR image_quota_bytes >= -1); + +CREATE TABLE IF NOT EXISTS image_themes ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + key TEXT UNIQUE, + scope TEXT NOT NULL CHECK (scope IN ('global','school','personal')), + school_id BIGINT REFERENCES schools(id) ON DELETE CASCADE, + owner_id BIGINT REFERENCES users(id) ON DELETE CASCADE, + folder TEXT NOT NULL DEFAULT '', + name_nl TEXT NOT NULL, + name_en TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CHECK ( + (scope = 'global' AND school_id IS NULL) OR + (scope = 'school' AND school_id IS NOT NULL AND owner_id IS NOT NULL) OR + (scope = 'personal' AND owner_id IS NOT NULL) + ) +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_image_themes_global_name + ON image_themes (lower(name_nl)) WHERE scope = 'global'; +CREATE UNIQUE INDEX IF NOT EXISTS idx_image_themes_school_name + ON image_themes (school_id, lower(name_nl)) WHERE scope = 'school'; +CREATE UNIQUE INDEX IF NOT EXISTS idx_image_themes_personal_name + ON image_themes (owner_id, lower(name_nl)) WHERE scope = 'personal'; + +CREATE TABLE IF NOT EXISTS image_folders ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + scope TEXT NOT NULL CHECK (scope IN ('global','school','personal')), + school_id BIGINT REFERENCES schools(id) ON DELETE CASCADE, + owner_id BIGINT REFERENCES users(id) ON DELETE CASCADE, + path TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CHECK ( + (scope = 'global' AND school_id IS NULL) OR + (scope = 'school' AND school_id IS NOT NULL AND owner_id IS NOT NULL) OR + (scope = 'personal' AND owner_id IS NOT NULL) + ) +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_image_folders_global_path + ON image_folders (path) WHERE scope = 'global'; +CREATE UNIQUE INDEX IF NOT EXISTS idx_image_folders_school_path + ON image_folders (school_id, path) WHERE scope = 'school'; +CREATE UNIQUE INDEX IF NOT EXISTS idx_image_folders_personal_path + ON image_folders (owner_id, path) WHERE scope = 'personal'; + +CREATE TABLE IF NOT EXISTS image_assets ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + key TEXT NOT NULL UNIQUE, + scope TEXT NOT NULL CHECK (scope IN ('global','school','personal')), + school_id BIGINT REFERENCES schools(id) ON DELETE CASCADE, + owner_id BIGINT REFERENCES users(id) ON DELETE CASCADE, + folder TEXT NOT NULL DEFAULT '', + name TEXT NOT NULL, + original_name TEXT, + mime_type TEXT NOT NULL, + size_bytes BIGINT NOT NULL DEFAULT 0 CHECK (size_bytes >= 0), + storage_key TEXT UNIQUE, + public_path TEXT UNIQUE, + tags TEXT[] NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CHECK ((storage_key IS NULL) <> (public_path IS NULL)), + CHECK ( + (scope = 'global' AND school_id IS NULL) OR + (scope = 'school' AND school_id IS NOT NULL AND owner_id IS NOT NULL) OR + (scope = 'personal' AND owner_id IS NOT NULL) + ) +); +CREATE INDEX IF NOT EXISTS idx_image_assets_owner ON image_assets(owner_id); +CREATE INDEX IF NOT EXISTS idx_image_assets_school ON image_assets(school_id); +CREATE INDEX IF NOT EXISTS idx_image_assets_folder ON image_assets(scope, folder); + +CREATE TABLE IF NOT EXISTS image_asset_themes ( + asset_id BIGINT NOT NULL REFERENCES image_assets(id) ON DELETE CASCADE, + theme_id BIGINT NOT NULL REFERENCES image_themes(id) ON DELETE CASCADE, + PRIMARY KEY (asset_id, theme_id) +); +CREATE INDEX IF NOT EXISTS idx_image_asset_themes_theme ON image_asset_themes(theme_id); + +INSERT INTO image_themes (key, scope, name_nl, name_en) +VALUES + ('builtin-money', 'global', 'Geld', 'Money'), + ('builtin-coins', 'global', 'Munten', 'Coins'), + ('builtin-banknotes', 'global', 'Briefgeld', 'Banknotes') +ON CONFLICT (key) DO NOTHING; + +INSERT INTO image_folders (scope, path) +VALUES ('global','Rekenen'), ('global','Rekenen/Geld'), + ('global','Rekenen/Geld/Munten'), ('global','Rekenen/Geld/Briefgeld') +ON CONFLICT DO NOTHING; + +INSERT INTO image_assets (key, scope, folder, name, mime_type, public_path, tags) +VALUES + ('builtin-money-coin-1', 'global', 'Rekenen/Geld/Munten', '1 cent', 'image/png', '/img/money/coin-1.png', ARRAY['geld','money','munt','coin','cent']), + ('builtin-money-coin-2', 'global', 'Rekenen/Geld/Munten', '2 cent', 'image/png', '/img/money/coin-2.png', ARRAY['geld','money','munt','coin','cent']), + ('builtin-money-coin-5', 'global', 'Rekenen/Geld/Munten', '5 cent', 'image/png', '/img/money/coin-5.png', ARRAY['geld','money','munt','coin','cent']), + ('builtin-money-coin-10', 'global', 'Rekenen/Geld/Munten', '10 cent', 'image/png', '/img/money/coin-10.png', ARRAY['geld','money','munt','coin','cent']), + ('builtin-money-coin-20', 'global', 'Rekenen/Geld/Munten', '20 cent', 'image/png', '/img/money/coin-20.png', ARRAY['geld','money','munt','coin','cent']), + ('builtin-money-coin-50', 'global', 'Rekenen/Geld/Munten', '50 cent', 'image/png', '/img/money/coin-50.png', ARRAY['geld','money','munt','coin','cent']), + ('builtin-money-coin-100', 'global', 'Rekenen/Geld/Munten', '1 euro', 'image/png', '/img/money/coin-100.png', ARRAY['geld','money','munt','coin','euro']), + ('builtin-money-coin-200', 'global', 'Rekenen/Geld/Munten', '2 euro', 'image/png', '/img/money/coin-200.png', ARRAY['geld','money','munt','coin','euro']), + ('builtin-money-note-500', 'global', 'Rekenen/Geld/Briefgeld', '5 euro', 'image/jpeg', '/img/money/note-500.jpg', ARRAY['geld','money','briefgeld','banknote','euro']), + ('builtin-money-note-1000', 'global', 'Rekenen/Geld/Briefgeld', '10 euro', 'image/jpeg', '/img/money/note-1000.jpg', ARRAY['geld','money','briefgeld','banknote','euro']), + ('builtin-money-note-2000', 'global', 'Rekenen/Geld/Briefgeld', '20 euro', 'image/jpeg', '/img/money/note-2000.jpg', ARRAY['geld','money','briefgeld','banknote','euro']), + ('builtin-money-note-5000', 'global', 'Rekenen/Geld/Briefgeld', '50 euro', 'image/jpeg', '/img/money/note-5000.jpg', ARRAY['geld','money','briefgeld','banknote','euro']) +ON CONFLICT (key) DO NOTHING; + +INSERT INTO image_asset_themes (asset_id, theme_id) +SELECT a.id, t.id +FROM image_assets a +JOIN image_themes t ON t.key = 'builtin-money' +WHERE a.key LIKE 'builtin-money-%' +ON CONFLICT DO NOTHING; + +INSERT INTO image_asset_themes (asset_id, theme_id) +SELECT a.id, t.id +FROM image_assets a +JOIN image_themes t ON t.key = CASE + WHEN a.key LIKE 'builtin-money-coin-%' THEN 'builtin-coins' + ELSE 'builtin-banknotes' +END +WHERE a.key LIKE 'builtin-money-%' +ON CONFLICT DO NOTHING; diff --git a/deploy/compose.deploy.yaml b/deploy/compose.deploy.yaml index 8baf432..c796f0c 100644 --- a/deploy/compose.deploy.yaml +++ b/deploy/compose.deploy.yaml @@ -28,6 +28,8 @@ services: ports: # Alleen op localhost van de VM; nginx zit ervoor als reverse proxy - "127.0.0.1:${APP_PORT:-3000}:3000" + volumes: + - image_data:/app/storage/images restart: unless-stopped web: @@ -60,3 +62,4 @@ services: volumes: pgdata: + image_data: diff --git a/deploy/nginx.conf b/deploy/nginx.conf index 4d42117..319bed8 100644 --- a/deploy/nginx.conf +++ b/deploy/nginx.conf @@ -10,7 +10,7 @@ server { server_tokens off; # Wat groter zodat grote borden/afbeeldingen niet geweigerd worden - client_max_body_size 25m; + client_max_body_size 55m; location / { proxy_pass http://app:3000; diff --git a/public/css/image-catalog.css b/public/css/image-catalog.css new file mode 100644 index 0000000..2995342 --- /dev/null +++ b/public/css/image-catalog.css @@ -0,0 +1,33 @@ +.image-catalog-modal{width:min(1080px,96vw);max-height:94vh} +.ic-tools{display:grid;grid-template-columns:minmax(180px,1.5fr) repeat(3,minmax(130px,.8fr)) auto;gap:8px;position:sticky;top:-24px;z-index:3;background:var(--surface);padding:8px 0 10px} +.ic-tools input,.ic-tools select,.ic-add select,.ic-add input,.ic-editor input,.ic-editor select{min-width:0;width:100%;border:1px solid var(--line);border-radius:10px;padding:9px 10px;background:var(--surface);color:var(--ink);font:inherit} +.ic-quota{padding:8px 12px;margin-bottom:10px;border-radius:10px;background:var(--accent-soft);color:var(--accent-ink);font-size:13px;font-weight:800} +.ic-add{border:1px solid var(--line);border-radius:12px;padding:10px 12px;margin-bottom:10px;background:var(--surface-2)} +.ic-add summary{cursor:pointer;font-weight:900} +.ic-add-grid,.ic-editor-grid{display:grid;grid-template-columns:1fr 1fr 1.2fr;gap:10px;margin-top:10px;align-items:end} +.ic-field{display:flex;flex-direction:column;gap:4px;font-size:12px;font-weight:800;color:var(--muted)} +.ic-field select[multiple]{min-height:88px} +.ic-upload{display:flex;align-items:center;justify-content:center;min-height:44px;margin:0;cursor:pointer;text-align:center;color:var(--ink)} +.ic-theme-new{display:flex;gap:8px;margin-top:10px}.ic-theme-new input{flex:1} +.ic-status{min-height:20px;font-size:13px;font-weight:800;color:var(--red);padding:2px 0}.ic-status.ok{color:var(--green)} +.ic-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(155px,1fr));gap:10px} +.ic-card{position:relative;min-width:0;border:1px solid var(--line);border-radius:14px;background:var(--surface);overflow:hidden;transition:border-color .15s,box-shadow .15s} +.ic-card:hover{border-color:var(--accent);box-shadow:var(--shadow-1)} +.ic-choose{display:grid;grid-template-rows:120px auto auto;width:100%;height:100%;gap:5px;padding:10px;border:0;background:transparent;color:var(--ink);font:inherit;text-align:left;cursor:pointer} +.ic-choose img{width:100%;height:120px;object-fit:contain;border-radius:9px;background:#fff} +.ic-choose strong{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.ic-choose span{font-size:11px;color:var(--muted);min-height:28px;overflow:hidden} +.ic-card-edit{position:absolute;right:7px;top:7px;width:32px;height:32px;border:1px solid var(--line);border-radius:9px;background:var(--surface);cursor:pointer;box-shadow:var(--shadow-1)} +.ic-editor{position:sticky;bottom:-24px;z-index:4;margin-top:12px;padding:14px;border:2px solid var(--accent);border-radius:14px;background:var(--surface);box-shadow:var(--shadow-2)} +.ic-editor h3{margin:0}.ic-editor-actions{display:flex;gap:8px;justify-content:flex-end;margin-top:10px} +.ic-explorer-thumb{width:64px;height:56px;object-fit:contain;border-radius:8px;background:#fff} +.img-quota-admin{display:flex;flex-direction:column;gap:7px}.img-quota-row{display:grid;grid-template-columns:minmax(180px,1fr) 130px 130px auto;align-items:center} +.img-quota-users{display:flex;flex-direction:column;gap:6px;margin-top:8px} +body.hc .ic-card,body.hc .ic-add,body.hc .ic-tools input,body.hc .ic-tools select,body.hc .ic-editor{border-color:#000} +@media(max-width:760px){ + .image-catalog-modal{width:100vw;max-height:100vh;height:100vh;border-radius:0;padding:16px} + .ic-tools{top:-16px;grid-template-columns:1fr 1fr;padding-top:4px}.ic-search,.ic-folders{grid-column:1/-1} + .ic-add-grid,.ic-editor-grid{grid-template-columns:1fr}.ic-grid{grid-template-columns:repeat(2,minmax(0,1fr))} + .ic-choose{grid-template-rows:96px auto auto}.ic-choose img{height:96px} + .img-quota-row{grid-template-columns:1fr 1fr}.img-quota-row .am-name{grid-column:1/-1} +} +@media(max-width:420px){.ic-grid{grid-template-columns:1fr}.ic-editor-actions{flex-wrap:wrap}.ic-editor-actions .tbtn{flex:1}} diff --git a/public/index.html b/public/index.html index 3cbf74e..099f97e 100644 --- a/public/index.html +++ b/public/index.html @@ -7,6 +7,7 @@ + @@ -20,6 +21,7 @@
+ @@ -267,6 +269,7 @@ + diff --git a/public/js/admin.js b/public/js/admin.js index 8c493ff..bb501f7 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -119,7 +119,7 @@ (#adminTabsSlot in core.js/index.html) - geen geneste tabbalk meer */ const adminTabDefs = ()=>{ const defs = []; - if(currentUser && currentUser.role==="super") defs.push(["scholen", T("amOverarching")]); + if(currentUser && currentUser.role==="super"){ defs.push(["scholen", T("amOverarching")]); defs.push(["opslag", T("imgStorageAdmin")]); } defs.push(["klassen", T("amClasses")]); defs.push(["gebruikers", T("amUsers")]); defs.push(["toewijzingen", T("amAssignments")]); @@ -922,7 +922,7 @@ /* het Systeem-tabblad is school-onafhankelijk: daar is de schoolkiezer alleen maar verwarrend, dus die verbergen we er */ const sb = modal.querySelector(".am-schoolbar"); - if(sb) sb.style.display = tab === "scholen" ? "none" : ""; + if(sb) sb.style.display = ["scholen","opslag"].includes(tab) ? "none" : ""; } /* vaste contextbalk voor de systeemmanager: op elk beheer-tabblad direct @@ -952,7 +952,7 @@ const tabDefs = adminTabDefs(); if(!tabDefs.some(([t])=>t===adminTab)) adminTab = tabDefs[0][0]; - const panels = { scholen: renderScholenPanel, klassen: renderKlassenPanel, gebruikers: renderGebruikersPanel, toewijzingen: renderToewijzingenPanel, voortgang: renderVoortgangPanel }; + const panels = { scholen: renderScholenPanel, klassen: renderKlassenPanel, gebruikers: renderGebruikersPanel, toewijzingen: renderToewijzingenPanel, voortgang: renderVoortgangPanel, opslag: renderImageStoragePanel }; tabDefs.forEach(([t])=>{ const panel = h("div","am-tabpanel"); panel.dataset.panel = t; diff --git a/public/js/board.js b/public/js/board.js index c206403..ff191f6 100644 --- a/public/js/board.js +++ b/public/js/board.js @@ -881,16 +881,20 @@ fabMenu.querySelector('[data-f="mind"]').addEventListener("click", ()=>{ const def = REGISTRY.find(d=>d.id==="mind"); makeWidget(def, { x:0, y:0, w:board.clientWidth, h:board.clientHeight, bare:true }); }); -/* image upload onto the board */ -const imgInput = document.createElement("input"); -imgInput.type = "file"; imgInput.accept = "image/*"; imgInput.style.display = "none"; -document.body.appendChild(imgInput); +/* image catalogue + upload onto the board */ fabMenu.querySelector('[data-f="img"]').addEventListener("click", ()=>{ fabMenu.classList.remove("open"); fab.classList.remove("open"); - imgInput.click(); + openImageCatalog(src=>addBoardImage(src,80,90,320)); }); function loadImgFile(f, x, y){ - const keepPng = /png|gif|svg/.test(f.type); + if(currentUser && typeof uploadImageToCatalog==="function"){ + uploadImageToCatalog(f).then(image=>addBoardImage(image.src,x,y,320)) + .catch(err=>alert(err.message||T("imgUploadFailed"))); + return; + } + /* gasten kunnen nog lokaal werken; hun niet-opgeslagen bord krijgt een + tijdelijke, verkleinde data-URL en gebruikt geen serverquota. */ + const keepPng = /png|gif/.test(f.type); const img = new Image(); img.onload = ()=>{ const c = document.createElement("canvas"); @@ -902,11 +906,6 @@ function loadImgFile(f, x, y){ }; img.src = URL.createObjectURL(f); } -imgInput.addEventListener("change", ()=>{ - const f = imgInput.files[0]; if(!f) return; - loadImgFile(f, 80, 90); - imgInput.value = ""; -}); /* drag & drop images from other websites or the computer onto the board */ board.addEventListener("dragover", e=>{ e.preventDefault(); }); board.addEventListener("drop", e=>{ diff --git a/public/js/core.js b/public/js/core.js index 0e142e1..68abe38 100644 --- a/public/js/core.js +++ b/public/js/core.js @@ -2,7 +2,7 @@ "use strict"; /* version — shown until /api/version resolves (or if the fetch fails, e.g. offline). Kept in sync by hand with the VERSION file at the repo root on every release. */ -const VERSION = "0.3.54-beta"; +const VERSION = "0.3.55-beta"; (function(){ const tag = document.getElementById("verTag"); tag.textContent = "v"+VERSION; @@ -159,6 +159,23 @@ const I18N = { pictoCredit:"Picto's: ARASAAC (CC BY-NC-SA, Gobierno de Aragón). Ook leuk: sclera.be (gratis download, dan via Upload toevoegen).", upload:"📷 Kies een afbeelding…", upHint:"Tip: vierkant plaatje, witte of doorzichtige achtergrond, één duidelijk voorwerp, géén tekst in het plaatje.", + save:"Opslaan", cancel:"Annuleren", + imgCatalog:"Afbeeldingscatalogus", imgSearch:"Zoek op naam, map of thema…", + imgFolders:"Afbeeldingen en mappen", imgUpload:"Afbeeldingen uploaden", + imgChooseFiles:"Kies één of meer afbeeldingen…", imgFolder:"Map", imgThemes:"Thema’s", + imgNewTheme:"Nieuw thema…", imgAddTheme:"Thema toevoegen", imgEdit:"Afbeelding bewerken", imgName:"Naam", + imgAllThemes:"Alle thema’s", imgAllScopes:"Alle bronnen", imgAllFolders:"Alle mappen", imgFolderRoot:"Hoofdmap", + imgScopeGlobal:"Systeem", imgScopeSchool:"School", imgScopePersonal:"Mijn afbeeldingen", + imgQuotaUsed:"{used} gebruikt", imgQuotaOf:"limiet {limit}", imgSchoolQuota:"school: {used} van {limit}", + imgUnlimited:"Onbeperkt", imgNoResults:"Geen afbeeldingen gevonden.", imgNoManage:"Je mag dit onderdeel niet beheren.", + imgLoginRequired:"Log in om de afbeeldingscatalogus te gebruiken.", imgInvalidFile:"Kies een geldige afbeelding.", + imgFileTooLarge:"Een afbeelding mag maximaal 50 MB groot zijn.", imgUploadFailed:"Uploaden is mislukt.", + imgUploading:"Afbeeldingen uploaden…", imgUploaded:"Afbeeldingen toegevoegd ✓", imgUntitled:"Afbeelding", + imgLoading:"Afbeeldingen laden…", imgSaved:"Afbeelding opgeslagen ✓", imgDeleted:"Afbeelding verwijderd ✓", + imgThemeAdded:"Thema toegevoegd ✓", imgStorageAdmin:"Opslaglimieten afbeeldingen", + imgQuotaUsage:"{used} gebruikt · {limit}", imgQuotaDefault:"Standaard", imgQuotaCustom:"Aangepast", + imgQuotaMb:"limiet in MB", imgQuotaInvalid:"Vul een geldige limiet in.", imgSchools:"Scholen", + imgUsers:"Gebruikers", imgSearchUsers:"Zoek gebruiker…", cats:["Dieren","Eten","Natuur","Vervoer","Spullen","Lichaam"], notesPh:"Schrijf hier…", start:"Start", pause:"Pauze", reset:"Reset", min:"min", @@ -367,6 +384,23 @@ const I18N = { pictoCredit:"Pictos: ARASAAC (CC BY-NC-SA, Gobierno de Aragón). Also nice: sclera.be (free download, then add via Upload).", upload:"📷 Choose an image…", upHint:"Tip: square picture, white or transparent background, one clear object, no text in the picture.", + save:"Save", cancel:"Cancel", + imgCatalog:"Image catalogue", imgSearch:"Search by name, folder or theme…", + imgFolders:"Images and folders", imgUpload:"Upload images", + imgChooseFiles:"Choose one or more images…", imgFolder:"Folder", imgThemes:"Themes", + imgNewTheme:"New theme…", imgAddTheme:"Add theme", imgEdit:"Edit image", imgName:"Name", + imgAllThemes:"All themes", imgAllScopes:"All sources", imgAllFolders:"All folders", imgFolderRoot:"Root folder", + imgScopeGlobal:"System", imgScopeSchool:"School", imgScopePersonal:"My images", + imgQuotaUsed:"{used} used", imgQuotaOf:"limit {limit}", imgSchoolQuota:"school: {used} of {limit}", + imgUnlimited:"Unlimited", imgNoResults:"No images found.", imgNoManage:"You cannot manage this item.", + imgLoginRequired:"Log in to use the image catalogue.", imgInvalidFile:"Choose a valid image.", + imgFileTooLarge:"An image may be at most 50 MB.", imgUploadFailed:"Upload failed.", + imgUploading:"Uploading images…", imgUploaded:"Images added ✓", imgUntitled:"Image", + imgLoading:"Loading images…", imgSaved:"Image saved ✓", imgDeleted:"Image deleted ✓", + imgThemeAdded:"Theme added ✓", imgStorageAdmin:"Image storage limits", + imgQuotaUsage:"{used} used · {limit}", imgQuotaDefault:"Default", imgQuotaCustom:"Custom", + imgQuotaMb:"limit in MB", imgQuotaInvalid:"Enter a valid limit.", imgSchools:"Schools", + imgUsers:"Users", imgSearchUsers:"Search user…", cats:["Animals","Food","Nature","Transport","Things","Body"], notesPh:"Write here…", start:"Start", pause:"Pause", reset:"Reset", min:"min", diff --git a/public/js/image-catalog.js b/public/js/image-catalog.js new file mode 100644 index 0000000..a0dfad3 --- /dev/null +++ b/public/js/image-catalog.js @@ -0,0 +1,360 @@ +/* teach - doorzoekbare afbeeldingscatalogus voor whiteboard en plaatjeskiezers. + Bestanden staan op de server; op het bord bewaren we alleen een stabiele URL. */ +"use strict"; +let IMAGE_CATALOG = {images:[],themes:[],folders:[],quota:{},uploadScope:"personal"}; +let imageCatalogWrap = null, imageCatalogSelect = null, imageCatalogDeleteArmed = false; + +const imageBytes = value=>{ + const n=Number(value||0); + if(n<1024*1024)return Math.max(0,Math.round(n/1024))+" KB"; + return (n/(1024*1024)).toLocaleString(LANG,{maximumFractionDigits:1})+" MB"; +}; +const imageScopeName = scope=>T(scope==="global"?"imgScopeGlobal":scope==="school"?"imgScopeSchool":"imgScopePersonal"); +const imageThemeName = theme=>LANG==="nl"?theme.nameNl:theme.nameEn; +const imageFileName = file=>(file.name||T("imgUntitled")).replace(/\.[^.]+$/,"").slice(0,80); + +async function uploadImageToCatalog(file, options={}){ + if(!currentUser)throw new Error(T("imgLoginRequired")); + if(!file||!file.size)throw new Error(T("imgInvalidFile")); + if(file.size>50*1024*1024)throw new Error(T("imgFileTooLarge")); + const params=new URLSearchParams({ + name:(options.name||imageFileName(file)).slice(0,80), + folder:options.folder||"", + themes:(options.themeIds||[]).join(",") + }); + const response=await fetch("/api/images?"+params,{ + method:"POST",credentials:"same-origin", + headers:{"Content-Type":file.type||"application/octet-stream","x-file-name":encodeURIComponent(file.name||"")}, + body:file + }); + const json=await response.json().catch(()=>({})); + if(!response.ok)throw new Error(json.error||T("imgUploadFailed")); + return json.image; +} + +async function loadImageCatalog(){ + IMAGE_CATALOG=await api("/images"); + return IMAGE_CATALOG; +} + +function ensureImageCatalog(){ + if(imageCatalogWrap)return imageCatalogWrap; + const wrap=document.createElement("div"); + wrap.id="imageCatalogWrap";wrap.className="modalwrap";wrap.setAttribute("role","dialog");wrap.setAttribute("aria-modal","true"); + wrap.innerHTML=` + `; + document.body.append(wrap);imageCatalogWrap=wrap; + const q=s=>wrap.querySelector(s); + q(".ic-close").onclick=closeImageCatalog; + wrap.onclick=e=>{if(e.target===wrap)closeImageCatalog()}; + q(".ic-search").oninput=renderImageCatalogGrid; + q(".ic-theme-filter").onchange=renderImageCatalogGrid; + q(".ic-scope-filter").onchange=renderImageCatalogGrid; + q(".ic-folder-filter").onchange=renderImageCatalogGrid; + q(".ic-folders").onclick=openImageFolderExplorer; + q(".ic-cancel").onclick=()=>{q(".ic-editor").hidden=true;imageCatalogDeleteArmed=false}; + q(".ic-save").onclick=saveCatalogImage; + q(".ic-delete").onclick=deleteCatalogImage; + q(".ic-theme-new button").onclick=createCatalogTheme; + q(".ic-upload input").onchange=async e=>{ + const files=[...e.target.files];e.target.value=""; + if(!files.length)return; + const folder=q(".ic-upload-folder").value; + const themeIds=[...q(".ic-upload-themes").selectedOptions].map(o=>+o.value); + setImageCatalogStatus(T("imgUploading")); + try{ + for(const file of files)await uploadImageToCatalog(file,{folder,themeIds}); + await loadImageCatalog();renderImageCatalog();setImageCatalogStatus(T("imgUploaded"),true); + }catch(err){setImageCatalogStatus(err.message)} + }; + return wrap; +} + +function setImageCatalogStatus(text,ok=false){ + if(!imageCatalogWrap)return; + const status=imageCatalogWrap.querySelector(".ic-status"); + status.textContent=text||"";status.classList.toggle("ok",!!ok); +} +function closeImageCatalog(){ + if(imageCatalogWrap)imageCatalogWrap.classList.remove("open"); + imageCatalogSelect=null;imageCatalogDeleteArmed=false; +} +function catalogOwnFolders(){ + const scope=IMAGE_CATALOG.uploadScope; + const paths=new Set([""]); + IMAGE_CATALOG.folders.filter(f=>f.scope===scope&&f.canManage).forEach(f=>paths.add(f.path)); + IMAGE_CATALOG.images.filter(i=>i.scope===scope&&i.canManage&&i.folder).forEach(i=>paths.add(i.folder)); + return [...paths].sort((a,b)=>a.localeCompare(b,LANG)); +} +function fillImageSelect(select,items,value,label){ + select.innerHTML=""; + if(label!=null)select.append(new Option(label,"")); + items.forEach(item=>select.append(new Option(item.label,item.value))); + if([...select.options].some(o=>o.value===String(value)))select.value=String(value); +} +function fillThemeSelect(select,selected=[],includeAll=false,asset=null){ + const current=new Set((selected||[]).map(Number));select.innerHTML=""; + if(includeAll)select.append(new Option(T("imgAllThemes"),"")); + IMAGE_CATALOG.themes.filter(theme=>!asset||theme.scope==="global" + ||(asset.scope==="school"&&theme.scope==="school") + ||(asset.scope==="personal"&&theme.scope==="personal")) + .forEach(theme=>{ + const option=new Option(imageThemeName(theme),theme.id); + option.selected=current.has(theme.id);select.append(option); + }); +} +function applyImageCatalogLabels(){ + const q=s=>imageCatalogWrap.querySelector(s); + q(".ic-title").textContent=T("imgCatalog"); + q(".ic-close").title=T("close"); + q(".ic-search").placeholder=T("imgSearch"); + q(".ic-folders").textContent="🗂 "+T("imgFolders"); + q(".ic-add summary").textContent="+ "+T("imgUpload"); + q(".ic-upload span").textContent=T("imgChooseFiles"); + q(".ic-upload-folder-label").textContent=T("imgFolder"); + q(".ic-upload-themes-label").textContent=T("imgThemes"); + q(".ic-theme-new input").placeholder=T("imgNewTheme"); + q(".ic-theme-new button").textContent=T("imgAddTheme"); + q(".ic-editor h3").textContent=T("imgEdit"); + q(".ic-name-label").textContent=T("imgName"); + q(".ic-folder-label").textContent=T("imgFolder"); + q(".ic-themes-label").textContent=T("imgThemes"); + q(".ic-save").textContent=T("save"); + q(".ic-delete").textContent=T("exDelete"); + q(".ic-cancel").textContent=T("cancel"); +} +function renderImageCatalog(){ + const q=s=>imageCatalogWrap.querySelector(s); + applyImageCatalogLabels(); + const themeValue=q(".ic-theme-filter").value,scopeValue=q(".ic-scope-filter").value,folderValue=q(".ic-folder-filter").value; + fillThemeSelect(q(".ic-theme-filter"),[],true); + fillImageSelect(q(".ic-scope-filter"),[ + {value:"global",label:imageScopeName("global")}, + {value:"school",label:imageScopeName("school")}, + {value:"personal",label:imageScopeName("personal")} + ],scopeValue,T("imgAllScopes")); + const allFolders=[...new Set(IMAGE_CATALOG.images.map(i=>i.folder).concat(IMAGE_CATALOG.folders.map(f=>f.path)).filter(Boolean))] + .sort((a,b)=>a.localeCompare(b,LANG)).map(path=>({value:path,label:path})); + fillImageSelect(q(".ic-folder-filter"),allFolders,folderValue,T("imgAllFolders")); + if(themeValue&&[...q(".ic-theme-filter").options].some(o=>o.value===themeValue))q(".ic-theme-filter").value=themeValue; + const ownFolders=catalogOwnFolders().map(path=>({value:path,label:path||T("imgFolderRoot")})); + fillImageSelect(q(".ic-upload-folder"),ownFolders,q(".ic-upload-folder").value); + fillThemeSelect(q(".ic-upload-themes"),[...q(".ic-upload-themes").selectedOptions].map(o=>+o.value),false,{scope:IMAGE_CATALOG.uploadScope}); + const quota=IMAGE_CATALOG.quota||{},limit=quota.limitBytes; + let text=T("imgQuotaUsed").replace("{used}",imageBytes(quota.usedBytes)); + text+=" · "+(limit==null?T("imgUnlimited"):T("imgQuotaOf").replace("{limit}",imageBytes(limit))); + if(quota.schoolLimitBytes!=null)text+=" · "+T("imgSchoolQuota").replace("{used}",imageBytes(quota.schoolUsedBytes)).replace("{limit}",imageBytes(quota.schoolLimitBytes)); + q(".ic-quota").textContent=text; + renderImageCatalogGrid(); +} +function renderImageCatalogGrid(){ + if(!imageCatalogWrap)return; + const q=s=>imageCatalogWrap.querySelector(s),grid=q(".ic-grid"); + const search=q(".ic-search").value.trim().toLowerCase(),theme=+q(".ic-theme-filter").value||null; + const scope=q(".ic-scope-filter").value,folder=q(".ic-folder-filter").value; + const themeNames=new Map(IMAGE_CATALOG.themes.map(t=>[t.id,imageThemeName(t)])); + const images=IMAGE_CATALOG.images.filter(image=>{ + const hay=[image.name,image.folder,...(image.tags||[]),...image.themes.map(id=>themeNames.get(id)||"")].join(" ").toLowerCase(); + return(!search||hay.includes(search))&&(!theme||image.themes.includes(theme)) + &&(!scope||image.scope===scope)&&(!folder||image.folder===folder||image.folder.startsWith(folder+"/")); + }); + grid.innerHTML=""; + images.forEach(image=>{ + const card=document.createElement("article");card.className="ic-card"; + const choose=document.createElement("button");choose.type="button";choose.className="ic-choose"; + const img=document.createElement("img");img.src=image.src;img.alt=image.name;img.loading="lazy"; + const name=document.createElement("strong");name.textContent=image.name; + const meta=document.createElement("span");meta.textContent=[imageScopeName(image.scope),image.folder].filter(Boolean).join(" · "); + choose.append(img,name,meta);choose.onclick=()=>{if(imageCatalogSelect){imageCatalogSelect(image.src,image);closeImageCatalog()}else if(image.canManage)openCatalogImageEditor(image)}; + card.append(choose); + if(image.canManage){ + const edit=document.createElement("button");edit.type="button";edit.className="ic-card-edit";edit.textContent="✏"; + edit.title=T("imgEdit");edit.onclick=()=>openCatalogImageEditor(image);card.append(edit); + } + grid.append(card); + }); + if(!images.length){const empty=document.createElement("p");empty.className="gallery-empty";empty.textContent=T("imgNoResults");grid.append(empty)} +} +function openCatalogImageEditor(image){ + const q=s=>imageCatalogWrap.querySelector(s),editor=q(".ic-editor"); + editor.hidden=false;editor.dataset.id=image.id;q(".ic-edit-name").value=image.name;q(".ic-edit-folder").value=image.folder; + fillThemeSelect(q(".ic-edit-themes"),image.themes,false,image); + q(".ic-delete").hidden=!image.canDelete;q(".ic-delete").textContent=T("exDelete"); + imageCatalogDeleteArmed=false;editor.scrollIntoView({block:"nearest"}); +} +async function saveCatalogImage(){ + const q=s=>imageCatalogWrap.querySelector(s),editor=q(".ic-editor"),id=+editor.dataset.id; + const themeIds=[...q(".ic-edit-themes").selectedOptions].map(o=>+o.value); + try{ + await api("/images/"+id,{method:"PATCH",body:{name:q(".ic-edit-name").value,folder:q(".ic-edit-folder").value,themeIds}}); + await loadImageCatalog();editor.hidden=true;renderImageCatalog();setImageCatalogStatus(T("imgSaved"),true); + }catch(err){setImageCatalogStatus(err.message)} +} +async function deleteCatalogImage(){ + const q=s=>imageCatalogWrap.querySelector(s),button=q(".ic-delete"); + if(!imageCatalogDeleteArmed){imageCatalogDeleteArmed=true;button.textContent="⚠ "+T("exConfirm");return} + try{ + await api("/images/"+q(".ic-editor").dataset.id,{method:"DELETE"}); + await loadImageCatalog();q(".ic-editor").hidden=true;renderImageCatalog();setImageCatalogStatus(T("imgDeleted"),true); + }catch(err){setImageCatalogStatus(err.message)} + imageCatalogDeleteArmed=false; +} +async function createCatalogTheme(){ + const input=imageCatalogWrap.querySelector(".ic-theme-new input"),name=input.value.trim(); + if(!name)return; + try{ + await api("/images/themes",{method:"POST",body:{name}}); + input.value="";await loadImageCatalog();renderImageCatalog();setImageCatalogStatus(T("imgThemeAdded"),true); + }catch(err){setImageCatalogStatus(err.message)} +} + +function imageScopeBranches(){ + const scopes=["global"]; + if(currentUser&¤tUser.schoolId!=null)scopes.push("school"); + scopes.push("personal"); + return scopes.map(scope=>({scope,name:(scope==="global"?"🌐 ":scope==="school"?"🏫 ":"👤 ")+imageScopeName(scope)})); +} +function openImageFolderExplorer(){ + const branches=imageScopeBranches(),branchFor=path=>branches.find(b=>b.name===path[0]); + const join=path=>path.join("/"); + const sameScope=(item,branch)=>item.scope===branch.scope; + const adapter={ + title:()=>T("imgFolders"),allowRootItems:false, + list(path){ + if(!path.length)return{folders:branches.map(b=>({name:b.name,count:IMAGE_CATALOG.images.filter(i=>sameScope(i,b)).length,fixed:true})),items:[]}; + const branch=branchFor(path);if(!branch)return{folders:[],items:[]}; + const rel=join(path.slice(1)),prefix=rel?rel+"/":"",folderNames=new Map(); + const paths=IMAGE_CATALOG.folders.filter(f=>sameScope(f,branch)).map(f=>f.path) + .concat(IMAGE_CATALOG.images.filter(i=>sameScope(i,branch)).map(i=>i.folder)).filter(Boolean); + paths.forEach(full=>{if(rel&&full!==rel&&!full.startsWith(prefix))return;const rest=rel?full.slice(prefix.length):full;if(!rest)return;const seg=rest.split("/")[0],child=prefix+seg;folderNames.set(seg,IMAGE_CATALOG.images.filter(i=>sameScope(i,branch)&&(i.folder===child||i.folder.startsWith(child+"/"))).length)}); + const items=IMAGE_CATALOG.images.filter(i=>sameScope(i,branch)&&i.folder===rel).map(image=>({ + key:image.id,name:image.name,movable:image.canManage,deletable:image.canDelete, + tile(){const im=document.createElement("img");im.className="ic-explorer-thumb";im.src=image.src;im.alt="";im.loading="lazy";return im} + })); + return{folders:[...folderNames].map(([name,count])=>({name,count})),items}; + }, + async createFolder(path,name){ + const branch=branchFor(path);if(!branch||branch.scope!==IMAGE_CATALOG.uploadScope)return T("imgNoManage"); + try{await api("/images/folders",{method:"POST",body:{path:join([...path.slice(1),name])}});await loadImageCatalog();return null}catch(err){return err.message} + }, + async renameFolder(path,oldName,newName){return this.moveFolder([...path,oldName],path,newName)}, + async moveFolder(srcPath,targetPath,newName){ + const source=branchFor(srcPath),target=branchFor(targetPath); + if(!source||!target||source.scope!==target.scope)return T("exWrongBranch"); + const from=join(srcPath.slice(1)),folder=IMAGE_CATALOG.folders.find(f=>f.scope===source.scope&&f.path===from); + if(!folder||!folder.canManage)return T("imgNoManage"); + const to=join([...targetPath.slice(1),newName||srcPath.at(-1)]); + try{await api("/images/folders/"+folder.id,{method:"PATCH",body:{path:to}});await loadImageCatalog();return null}catch(err){return err.message} + }, + async deleteFolder(path,name){ + const branch=branchFor(path),full=join([...path.slice(1),name]); + const folder=branch&&IMAGE_CATALOG.folders.find(f=>f.scope===branch.scope&&f.path===full); + if(!folder||!folder.canManage)return T("imgNoManage"); + try{await api("/images/folders/"+folder.id,{method:"DELETE"});await loadImageCatalog();return null}catch(err){return err.message} + }, + async renameItem(id,name){ + try{await api("/images/"+id,{method:"PATCH",body:{name}});await loadImageCatalog();return null}catch(err){return err.message} + }, + async moveItem(id,targetPath){ + const image=IMAGE_CATALOG.images.find(i=>i.id===+id),branch=branchFor(targetPath); + if(!image||!branch||image.scope!==branch.scope||!image.canManage)return T("imgNoManage"); + try{await api("/images/"+id,{method:"PATCH",body:{folder:join(targetPath.slice(1))}});await loadImageCatalog();return null}catch(err){return err.message} + }, + async deleteItem(id){ + const image=IMAGE_CATALOG.images.find(i=>i.id===+id);if(!image?.canDelete)return T("imgNoManage"); + try{await api("/images/"+id,{method:"DELETE"});await loadImageCatalog();return null}catch(err){return err.message} + }, + openItem(id){ + const image=IMAGE_CATALOG.images.find(i=>i.id===+id);if(!image)return T("imgNoResults"); + if(imageCatalogSelect)imageCatalogSelect(image.src,image);closeImageCatalog();return null; + } + }; + imageCatalogWrap.style.visibility="hidden"; + openExplorer(adapter,()=>{imageCatalogWrap.style.visibility="";if(imageCatalogWrap.classList.contains("open"))renderImageCatalog()}); +} + +async function openImageCatalog(onSelect){ + if(!currentUser){alert(T("imgLoginRequired"));return} + const wrap=ensureImageCatalog();imageCatalogSelect=onSelect||null;setImageCatalogStatus(T("imgLoading")); + wrap.classList.add("open"); + try{await loadImageCatalog();renderImageCatalog();setImageCatalogStatus("")} + catch(err){setImageCatalogStatus(err.message)} +} + +/* Systeembeheer: limieten per gebruiker en school. Wordt door admin.js als + extra Instellingen-tab aangeroepen. */ +function renderImageStoragePanel(panel){ + panel.innerHTML=""; + const heading=document.createElement("div");heading.className="am-h3";heading.textContent=T("imgStorageAdmin"); + const status=document.createElement("div");status.className="am-msg";panel.append(heading,status); + const host=document.createElement("div");host.className="img-quota-admin";host.textContent=T("imgLoading");panel.append(host); + const fmt=value=>value==null?T("imgUnlimited"):imageBytes(value); + const editor=(item,type)=>{ + const row=document.createElement("div");row.className="am-row img-quota-row"; + const info=document.createElement("div");info.className="am-name";info.textContent=item.name||item.displayName; + const use=document.createElement("span");use.className="am-role";use.textContent=T("imgQuotaUsage").replace("{used}",imageBytes(item.usedBytes)).replace("{limit}",fmt(item.limitBytes)); + info.append(document.createElement("br"),use); + const mode=document.createElement("select");mode.className="am-sel"; + [["default","imgQuotaDefault"],["custom","imgQuotaCustom"],["unlimited","imgUnlimited"]].forEach(([v,k])=>mode.add(new Option(T(k),v))); + mode.value=item.overrideBytes==null?"default":item.overrideBytes===-1?"unlimited":"custom"; + const amount=document.createElement("input");amount.className="am-inp";amount.type="number";amount.min="0";amount.max=String(10*1024);amount.step="10"; + amount.value=item.overrideBytes!=null&&item.overrideBytes>=0?Math.round(item.overrideBytes/(1024*1024)):""; + amount.placeholder=T("imgQuotaMb");amount.hidden=mode.value!=="custom";mode.onchange=()=>amount.hidden=mode.value!=="custom"; + const save=document.createElement("button");save.className="am-btn";save.type="button";save.textContent=T("save"); + save.onclick=async()=>{ + const limitBytes=mode.value==="default"?null:mode.value==="unlimited"?-1:Math.round(Number(amount.value)*1024*1024); + if(mode.value==="custom"&&(!Number.isFinite(limitBytes)||limitBytes<0)){status.textContent=T("imgQuotaInvalid");return} + try{await api("/images/admin/"+type+"/"+item.id+"/quota",{method:"PATCH",body:{limitBytes}});renderImageStoragePanel(panel)} + catch(err){status.textContent=err.message} + }; + row.append(info,mode,amount,save);return row; + }; + (async()=>{ + try{ + const data=await api("/images/admin/quotas");host.innerHTML=""; + const schoolTitle=document.createElement("div");schoolTitle.className="am-group";schoolTitle.textContent=T("imgSchools"); + host.append(schoolTitle,...data.schools.map(s=>editor({...s,name:s.name},"schools"))); + const userTitle=document.createElement("div");userTitle.className="am-group";userTitle.textContent=T("imgUsers"); + const filter=document.createElement("input");filter.className="am-inp";filter.type="search";filter.placeholder=T("imgSearchUsers"); + const users=document.createElement("div");users.className="img-quota-users"; + const draw=()=>{const term=filter.value.trim().toLowerCase();users.innerHTML="";data.users.filter(u=>(u.displayName+" "+u.username).toLowerCase().includes(term)).forEach(u=>users.append(editor(u,"users")))}; + filter.oninput=draw;host.append(userTitle,filter,users);draw(); + }catch(err){host.textContent="";status.textContent=err.message} + })(); +} + +const imageToolbarButton=document.getElementById("btnImages"); +if(imageToolbarButton)imageToolbarButton.addEventListener("click",()=>openImageCatalog()); +function refreshImageToolbar(){if(imageToolbarButton)imageToolbarButton.title=T("imgCatalog")} +document.addEventListener("langchange",()=>{refreshImageToolbar();if(imageCatalogWrap?.classList.contains("open"))renderImageCatalog()}); +refreshImageToolbar(); diff --git a/src/images.js b/src/images.js new file mode 100644 index 0000000..9e9b986 --- /dev/null +++ b/src/images.js @@ -0,0 +1,577 @@ +// Beveiligde afbeeldingscatalogus: bestanden op disk, metadata in PostgreSQL. +// Afbeeldingen kunnen in meerdere thema's staan en gebruiken dezelfde +// slash-paden als de bestaande mappenverkenner. +import { createReadStream } from 'node:fs'; +import { access, mkdir, unlink, writeFile } from 'node:fs/promises'; +import { randomUUID } from 'node:crypto'; +import { join } from 'node:path'; +import { userFromRequest } from './auth.js'; + +export const IMAGE_MB = 1024 * 1024; +export const MAX_IMAGE_FILE_BYTES = 50 * IMAGE_MB; +export const DEFAULT_IMAGE_QUOTAS = Object.freeze({ + super: null, + admin: 400 * IMAGE_MB, + default: 200 * IMAGE_MB, +}); + +const SUPPORTED_IMAGE_TYPES = [ + 'image/png', 'image/jpeg', 'image/webp', 'image/gif', 'image/avif', +]; + +const rolesOf = (user) => user?.allRoles || [user?.role].filter(Boolean); +export const defaultImageQuota = (userOrRole) => { + const roles = typeof userOrRole === 'string' ? [userOrRole] : rolesOf(userOrRole); + if (roles.includes('super')) return null; + if (roles.includes('admin')) return DEFAULT_IMAGE_QUOTAS.admin; + return DEFAULT_IMAGE_QUOTAS.default; +}; +export const imageScopeForUser = (user) => { + const roles = rolesOf(user); + if (roles.includes('super')) return 'global'; + if (roles.includes('admin')) return 'school'; + return 'personal'; +}; +export const canSeeImage = (user, item) => { + if (!user || !item) return false; + if (rolesOf(user).includes('super') || item.scope === 'global') return true; + if (item.scope === 'school') + return item.school_id != null && Number(item.school_id) === Number(user.school_id); + return item.scope === 'personal' && Number(item.owner_id) === Number(user.id); +}; +export const canManageImage = (user, item) => { + if (!user || !item) return false; + const roles = rolesOf(user); + if (roles.includes('super')) return true; + if (item.scope === 'school') + return roles.includes('admin') && Number(item.school_id) === Number(user.school_id); + return item.scope === 'personal' && Number(item.owner_id) === Number(user.id); +}; + +export function normaliseImageFolder(value, allowEmpty = true) { + if (value == null || value === '') return allowEmpty ? '' : null; + if (typeof value !== 'string' || value.length > 200 || value.includes('\\') + || value.includes('//') || /[\x00-\x1f]/.test(value)) return null; + const path = value.trim().replace(/^\/+|\/+$/g, ''); + if ((!path && !allowEmpty) || path.split('/').some((part) => + !part || part === '.' || part === '..' || part.length > 40)) return null; + return path; +} +const validName = (value) => + typeof value === 'string' && !!value.trim() && value.trim().length <= 80; +const numericIds = (value) => Array.isArray(value) + ? [...new Set(value.map(Number).filter((id) => Number.isInteger(id) && id > 0))].slice(0, 30) + : []; +const quotaOverride = (value, fallback) => { + if (value == null) return fallback; + const n = Number(value); + return n === -1 ? null : n; +}; + +export function detectImageType(buffer) { + if (!Buffer.isBuffer(buffer) || buffer.length < 12) return null; + if (buffer.subarray(0, 8).equals(Buffer.from([0x89,0x50,0x4e,0x47,0x0d,0x0a,0x1a,0x0a]))) + return { mime: 'image/png', ext: 'png' }; + if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) + return { mime: 'image/jpeg', ext: 'jpg' }; + const head = buffer.subarray(0, 12).toString('ascii'); + if (head.startsWith('GIF87a') || head.startsWith('GIF89a')) + return { mime: 'image/gif', ext: 'gif' }; + if (head.startsWith('RIFF') && head.slice(8, 12) === 'WEBP') + return { mime: 'image/webp', ext: 'webp' }; + if (buffer.subarray(4, 12).toString('ascii').startsWith('ftypavi')) + return { mime: 'image/avif', ext: 'avif' }; + return null; +} + +const visibleSql = (user, alias = '') => { + const p = alias ? alias + '.' : ''; + if (rolesOf(user).includes('super')) return { sql: 'TRUE', params: [] }; + if (user.school_id != null) return { + sql: `(${p}scope = 'global' OR (${p}scope = 'school' AND ${p}school_id = $1) OR (${p}scope = 'personal' AND ${p}owner_id = $2))`, + params: [user.school_id, user.id], + }; + return { + sql: `(${p}scope = 'global' OR (${p}scope = 'personal' AND ${p}owner_id = $1))`, + params: [user.id], + }; +}; +const contextSql = (item, column = 'folder') => { + if (item.scope === 'global') return { sql: `scope = 'global'`, params: [] }; + if (item.scope === 'school') return { + sql: `scope = 'school' AND school_id = $1`, params: [item.school_id], + }; + return { + sql: `scope = 'personal' AND owner_id = $1`, params: [item.owner_id], + }; +}; +const compatibleTheme = (asset, theme) => { + if (theme.scope === 'global') return true; + if (asset.scope === 'school') + return theme.scope === 'school' && Number(theme.school_id) === Number(asset.school_id); + if (asset.scope === 'personal') + return theme.scope === 'personal' && Number(theme.owner_id) === Number(asset.owner_id); + return false; +}; +const safeStorageKey = (value) => + typeof value === 'string' && /^[0-9a-f-]{36}\.(png|jpg|gif|webp|avif)$/.test(value); + +async function quotaStatus(db, user, lock = false) { + const userRow = (await db.query( + `SELECT id, role, school_id, image_quota_bytes FROM users WHERE id = $1${lock ? ' FOR UPDATE' : ''}`, + [user.id])).rows[0]; + const used = Number((await db.query( + 'SELECT COALESCE(sum(size_bytes),0)::bigint AS used FROM image_assets WHERE owner_id = $1', + [user.id])).rows[0]?.used || 0); + const fallback = defaultImageQuota(user); + const userLimit = quotaOverride(userRow?.image_quota_bytes, fallback); + let schoolUsed = 0, schoolLimit = null; + if (userRow?.school_id != null) { + const school = (await db.query( + `SELECT id, image_quota_bytes FROM schools WHERE id = $1${lock ? ' FOR UPDATE' : ''}`, + [userRow.school_id])).rows[0]; + schoolUsed = Number((await db.query( + 'SELECT COALESCE(sum(size_bytes),0)::bigint AS used FROM image_assets WHERE school_id = $1', + [userRow.school_id])).rows[0]?.used || 0); + schoolLimit = quotaOverride(school?.image_quota_bytes, null); + } + return { + usedBytes: used, + limitBytes: userLimit, + schoolUsedBytes: schoolUsed, + schoolLimitBytes: schoolLimit, + }; +} + +const describeAsset = (user, row) => ({ + id: Number(row.id), + name: row.name, + folder: row.folder || '', + scope: row.scope, + ownerName: row.owner_name || null, + sizeBytes: Number(row.size_bytes || 0), + mimeType: row.mime_type, + themes: (row.theme_ids || []).map(Number), + tags: row.tags || [], + src: row.public_path || `/api/images/${row.id}/content`, + builtin: !!row.public_path, + canManage: canManageImage(user, row), + canDelete: canManageImage(user, row) && !row.public_path, +}); +const describeTheme = (user, row) => ({ + id: Number(row.id), + nameNl: row.name_nl, + nameEn: row.name_en, + folder: row.folder || '', + scope: row.scope, + builtin: !!row.key, + canManage: canManageImage(user, row) && !row.key, +}); +const describeFolder = (user, row) => ({ + id: Number(row.id), + path: row.path, + scope: row.scope, + canManage: canManageImage(user, row), +}); + +export default async function imageApi(app, options = {}) { + const pool = app.pg; + const storageDir = options.storageDir || process.env.IMAGE_STORAGE_DIR + || join(process.cwd(), 'storage', 'images'); + const resolveUser = options.resolveUser || ((req) => userFromRequest(pool, req)); + await mkdir(storageDir, { recursive: true }); + + app.decorateRequest('imageUser', null); + app.addContentTypeParser(SUPPORTED_IMAGE_TYPES, { + parseAs: 'buffer', bodyLimit: MAX_IMAGE_FILE_BYTES, + }, (_req, body, done) => done(null, body)); + app.addHook('preHandler', async (req, reply) => { + if (!['GET','HEAD','OPTIONS'].includes(req.method) && req.headers.origin) { + const expectedOrigin = `${req.protocol}://${req.headers.host}`; + if (req.headers.origin !== expectedOrigin) + return reply.code(403).send({ error: 'ongeldige origin' }); + } + req.imageUser = await resolveUser(req); + }); + app.setErrorHandler((err, req, reply) => { + if (reply.sent) return; + if (reply.statusCode >= 400 && reply.statusCode < 500) + return reply.send({ error: err.message }); + req.log.error(err); + return reply.code(500).send({ error: 'serverfout' }); + }); + const fail = (reply, code, message) => reply.code(code).send({ error: message }); + const needUser = (req, reply) => { + if (!req.imageUser) { fail(reply, 401, 'niet ingelogd'); return false; } + return true; + }; + + app.get('/images', async (req, reply) => { + if (!needUser(req, reply)) return; + const user = req.imageUser; + const av = visibleSql(user, 'a'); + const tv = visibleSql(user, 't'); + const fv = visibleSql(user, 'f'); + const images = (await pool.query( + `SELECT a.*, u.display_name AS owner_name, + COALESCE(array_agg(at.theme_id) FILTER (WHERE at.theme_id IS NOT NULL), '{}') AS theme_ids + FROM image_assets a + LEFT JOIN users u ON u.id = a.owner_id + LEFT JOIN image_asset_themes at ON at.asset_id = a.id + WHERE ${av.sql} + GROUP BY a.id, u.display_name + ORDER BY a.folder, lower(a.name)`, av.params)).rows; + const themes = (await pool.query( + `SELECT t.* FROM image_themes t WHERE ${tv.sql} ORDER BY lower(t.name_nl)`, + tv.params)).rows; + const folders = (await pool.query( + `SELECT f.* FROM image_folders f WHERE ${fv.sql} ORDER BY f.path`, + fv.params)).rows; + return { + images: images.map((row) => describeAsset(user, row)), + themes: themes.map((row) => describeTheme(user, row)), + folders: folders.map((row) => describeFolder(user, row)), + quota: await quotaStatus(pool, user), + uploadScope: imageScopeForUser(user), + }; + }); + + app.get('/images/:id/content', async (req, reply) => { + if (!needUser(req, reply)) return; + const row = (await pool.query('SELECT * FROM image_assets WHERE id = $1', [req.params.id])).rows[0]; + if (!row || !canSeeImage(req.imageUser, row)) return fail(reply, 404, 'afbeelding onbekend'); + if (row.public_path) return reply.redirect(row.public_path); + if (!safeStorageKey(row.storage_key)) return fail(reply, 404, 'bestand onbekend'); + const path = join(storageDir, row.storage_key); + try { await access(path); } catch { return fail(reply, 404, 'bestand ontbreekt'); } + reply.type(row.mime_type); + reply.header('Cache-Control', 'private, max-age=3600'); + return reply.send(createReadStream(path)); + }); + + app.post('/images', { bodyLimit: MAX_IMAGE_FILE_BYTES }, async (req, reply) => { + if (!needUser(req, reply)) return; + if (!Buffer.isBuffer(req.body) || !req.body.length) return fail(reply, 400, 'afbeelding verplicht'); + const type = detectImageType(req.body); + if (!type) return fail(reply, 415, 'alleen PNG, JPEG, GIF, WebP of AVIF'); + const name = typeof req.query?.name === 'string' ? req.query.name : ''; + const folder = normaliseImageFolder(req.query?.folder || ''); + if (!validName(name) || folder == null) return fail(reply, 400, 'ongeldige naam of map'); + const themeIds = String(req.query?.themes || '').split(',').filter(Boolean).map(Number); + if (themeIds.some((id) => !Number.isInteger(id) || id <= 0) || themeIds.length > 30) + return fail(reply, 400, 'ongeldige thema’s'); + let originalName = ''; + try { originalName = decodeURIComponent(req.headers['x-file-name'] || ''); } catch {} + originalName = originalName.slice(0, 160); + const user = req.imageUser; + const scope = imageScopeForUser(user); + if (scope === 'school' && user.school_id == null) return fail(reply, 400, 'geen school gekoppeld'); + const schoolId = scope === 'school' || scope === 'personal' ? user.school_id : null; + const storageKey = `${randomUUID()}.${type.ext}`; + const filePath = join(storageDir, storageKey); + const client = await pool.connect(); + let fileWritten = false; + try { + await client.query('BEGIN'); + const quota = await quotaStatus(client, user, true); + if (quota.limitBytes != null && quota.usedBytes + req.body.length > quota.limitBytes) { + await client.query('ROLLBACK'); + return fail(reply, 413, 'persoonlijke opslaglimiet bereikt'); + } + if (schoolId != null && quota.schoolLimitBytes != null + && quota.schoolUsedBytes + req.body.length > quota.schoolLimitBytes) { + await client.query('ROLLBACK'); + return fail(reply, 413, 'opslaglimiet van de school bereikt'); + } + let themes = []; + if (themeIds.length) { + themes = (await client.query('SELECT * FROM image_themes WHERE id = ANY($1)', [themeIds])).rows; + const probe = { scope, school_id: schoolId, owner_id: user.id }; + if (themes.length !== themeIds.length + || themes.some((theme) => !canSeeImage(user, theme) || !compatibleTheme(probe, theme))) { + await client.query('ROLLBACK'); + return fail(reply, 400, 'thema niet beschikbaar voor deze afbeelding'); + } + } + await writeFile(filePath, req.body, { flag: 'wx' }); + fileWritten = true; + const inserted = (await client.query( + `INSERT INTO image_assets + (key, scope, school_id, owner_id, folder, name, original_name, mime_type, size_bytes, storage_key) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) RETURNING *`, + [randomUUID(), scope, schoolId, user.id, folder, name.trim(), originalName, + type.mime, req.body.length, storageKey])).rows[0]; + if (folder) await client.query( + `INSERT INTO image_folders (scope, school_id, owner_id, path) + VALUES ($1,$2,$3,$4) ON CONFLICT DO NOTHING`, + [scope, schoolId, user.id, folder]); + for (const themeId of themeIds) + await client.query( + 'INSERT INTO image_asset_themes (asset_id, theme_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', + [inserted.id, themeId]); + await client.query('COMMIT'); + return { image: describeAsset(user, { ...inserted, theme_ids: themeIds }) }; + } catch (err) { + await client.query('ROLLBACK').catch(() => {}); + if (fileWritten) await unlink(filePath).catch(() => {}); + throw err; + } finally { + client.release(); + } + }); + + app.patch('/images/:id', async (req, reply) => { + if (!needUser(req, reply)) return; + const item = (await pool.query('SELECT * FROM image_assets WHERE id = $1', [req.params.id])).rows[0]; + if (!item || !canSeeImage(req.imageUser, item)) return fail(reply, 404, 'afbeelding onbekend'); + if (!canManageImage(req.imageUser, item)) return fail(reply, 403, 'geen rechten'); + const body = req.body || {}; + if (body.name === undefined && body.folder === undefined && body.themeIds === undefined) + return fail(reply, 400, 'niets te wijzigen'); + if (body.name !== undefined && !validName(body.name)) return fail(reply, 400, 'ongeldige naam'); + const folder = body.folder === undefined ? undefined : normaliseImageFolder(body.folder); + if (folder === null) return fail(reply, 400, 'ongeldige map'); + const themeIds = body.themeIds === undefined ? null : numericIds(body.themeIds); + if (body.themeIds !== undefined && themeIds.length !== new Set(body.themeIds.map(Number)).size) + return fail(reply, 400, 'ongeldige thema’s'); + const client = await pool.connect(); + try { + await client.query('BEGIN'); + if (body.name !== undefined) + await client.query('UPDATE image_assets SET name = $1 WHERE id = $2', [body.name.trim(), item.id]); + if (folder !== undefined) { + await client.query('UPDATE image_assets SET folder = $1 WHERE id = $2', [folder, item.id]); + if (folder) await client.query( + `INSERT INTO image_folders (scope, school_id, owner_id, path) + VALUES ($1,$2,$3,$4) ON CONFLICT DO NOTHING`, + [item.scope, item.school_id, item.owner_id, folder]); + } + if (themeIds) { + const themes = themeIds.length + ? (await client.query('SELECT * FROM image_themes WHERE id = ANY($1)', [themeIds])).rows : []; + if (themes.length !== themeIds.length + || themes.some((theme) => !canSeeImage(req.imageUser, theme) || !compatibleTheme(item, theme))) { + await client.query('ROLLBACK'); + return fail(reply, 400, 'thema niet beschikbaar voor deze afbeelding'); + } + await client.query('DELETE FROM image_asset_themes WHERE asset_id = $1', [item.id]); + for (const themeId of themeIds) + await client.query('INSERT INTO image_asset_themes (asset_id, theme_id) VALUES ($1,$2)', [item.id, themeId]); + } + await client.query('COMMIT'); + return { ok: true }; + } catch (err) { + await client.query('ROLLBACK').catch(() => {}); + throw err; + } finally { + client.release(); + } + }); + + app.delete('/images/:id', async (req, reply) => { + if (!needUser(req, reply)) return; + const item = (await pool.query('SELECT * FROM image_assets WHERE id = $1', [req.params.id])).rows[0]; + if (!item || !canSeeImage(req.imageUser, item)) return fail(reply, 404, 'afbeelding onbekend'); + if (!canManageImage(req.imageUser, item)) return fail(reply, 403, 'geen rechten'); + if (item.public_path) return fail(reply, 403, 'ingebouwde afbeelding kan niet worden verwijderd'); + await pool.query('DELETE FROM image_assets WHERE id = $1', [item.id]); + if (safeStorageKey(item.storage_key)) + await unlink(join(storageDir, item.storage_key)).catch(() => {}); + return { ok: true }; + }); + + app.post('/images/themes', async (req, reply) => { + if (!needUser(req, reply)) return; + const { name, folder: rawFolder } = req.body || {}; + const folder = normaliseImageFolder(rawFolder || ''); + if (!validName(name) || folder == null) return fail(reply, 400, 'ongeldig thema'); + const user = req.imageUser, scope = imageScopeForUser(user); + if (scope === 'school' && user.school_id == null) return fail(reply, 400, 'geen school gekoppeld'); + const schoolId = scope === 'global' ? null : user.school_id; + try { + const row = (await pool.query( + `INSERT INTO image_themes (scope, school_id, owner_id, folder, name_nl, name_en) + VALUES ($1,$2,$3,$4,$5,$5) RETURNING *`, + [scope, schoolId, user.id, folder, name.trim()])).rows[0]; + return { theme: describeTheme(user, row) }; + } catch (err) { + if (err.code === '23505') return fail(reply, 409, 'thema bestaat al'); + throw err; + } + }); + + app.patch('/images/themes/:id', async (req, reply) => { + if (!needUser(req, reply)) return; + const item = (await pool.query('SELECT * FROM image_themes WHERE id = $1', [req.params.id])).rows[0]; + if (!item || !canSeeImage(req.imageUser, item)) return fail(reply, 404, 'thema onbekend'); + if (item.key || !canManageImage(req.imageUser, item)) return fail(reply, 403, 'geen rechten'); + const { name, folder: rawFolder } = req.body || {}; + const folder = rawFolder === undefined ? undefined : normaliseImageFolder(rawFolder); + if (name !== undefined && !validName(name)) return fail(reply, 400, 'ongeldige naam'); + if (folder === null) return fail(reply, 400, 'ongeldige map'); + if (name === undefined && folder === undefined) return fail(reply, 400, 'niets te wijzigen'); + try { + if (name !== undefined) + await pool.query('UPDATE image_themes SET name_nl = $1, name_en = $1 WHERE id = $2', [name.trim(), item.id]); + if (folder !== undefined) + await pool.query('UPDATE image_themes SET folder = $1 WHERE id = $2', [folder, item.id]); + return { ok: true }; + } catch (err) { + if (err.code === '23505') return fail(reply, 409, 'thema bestaat al'); + throw err; + } + }); + + app.delete('/images/themes/:id', async (req, reply) => { + if (!needUser(req, reply)) return; + const item = (await pool.query('SELECT * FROM image_themes WHERE id = $1', [req.params.id])).rows[0]; + if (!item || !canSeeImage(req.imageUser, item)) return fail(reply, 404, 'thema onbekend'); + if (item.key || !canManageImage(req.imageUser, item)) return fail(reply, 403, 'geen rechten'); + await pool.query('DELETE FROM image_themes WHERE id = $1', [item.id]); + return { ok: true }; + }); + + app.post('/images/folders', async (req, reply) => { + if (!needUser(req, reply)) return; + const path = normaliseImageFolder(req.body?.path, false); + if (!path) return fail(reply, 400, 'ongeldige map'); + const user = req.imageUser, scope = imageScopeForUser(user); + if (scope === 'school' && user.school_id == null) return fail(reply, 400, 'geen school gekoppeld'); + const schoolId = scope === 'global' ? null : user.school_id; + try { + const row = (await pool.query( + `INSERT INTO image_folders (scope, school_id, owner_id, path) + VALUES ($1,$2,$3,$4) RETURNING *`, + [scope, schoolId, user.id, path])).rows[0]; + return { folder: describeFolder(user, row) }; + } catch (err) { + if (err.code === '23505') return fail(reply, 409, 'map bestaat al'); + throw err; + } + }); + + app.patch('/images/folders/:id', async (req, reply) => { + if (!needUser(req, reply)) return; + const item = (await pool.query('SELECT * FROM image_folders WHERE id = $1', [req.params.id])).rows[0]; + if (!item || !canSeeImage(req.imageUser, item)) return fail(reply, 404, 'map onbekend'); + if (!canManageImage(req.imageUser, item)) return fail(reply, 403, 'geen rechten'); + const to = normaliseImageFolder(req.body?.path, false); + if (!to) return fail(reply, 400, 'ongeldige map'); + const from = item.path; + if (to === from || to.startsWith(from + '/')) return fail(reply, 400, 'map kan niet in zichzelf'); + const client = await pool.connect(); + try { + await client.query('BEGIN'); + const ctx = contextSql(item); + const descendants = (await client.query( + `SELECT id, path FROM image_folders WHERE ${ctx.sql} + AND (path = $${ctx.params.length + 1} OR path LIKE $${ctx.params.length + 2})`, + [...ctx.params, from, from + '/%'])).rows; + const assets = (await client.query( + `SELECT id, folder AS path FROM image_assets WHERE ${ctx.sql} + AND (folder = $${ctx.params.length + 1} OR folder LIKE $${ctx.params.length + 2})`, + [...ctx.params, from, from + '/%'])).rows; + const themes = (await client.query( + `SELECT id, folder AS path FROM image_themes WHERE ${ctx.sql} + AND (folder = $${ctx.params.length + 1} OR folder LIKE $${ctx.params.length + 2})`, + [...ctx.params, from, from + '/%'])).rows; + for (const row of descendants) + await client.query('UPDATE image_folders SET path = $1 WHERE id = $2', [to + row.path.slice(from.length), row.id]); + for (const row of assets) + await client.query('UPDATE image_assets SET folder = $1 WHERE id = $2', [to + row.path.slice(from.length), row.id]); + for (const row of themes) + await client.query('UPDATE image_themes SET folder = $1 WHERE id = $2', [to + row.path.slice(from.length), row.id]); + await client.query('COMMIT'); + return { ok: true }; + } catch (err) { + await client.query('ROLLBACK').catch(() => {}); + if (err.code === '23505') return fail(reply, 409, 'map bestaat al'); + throw err; + } finally { + client.release(); + } + }); + + app.delete('/images/folders/:id', async (req, reply) => { + if (!needUser(req, reply)) return; + const item = (await pool.query('SELECT * FROM image_folders WHERE id = $1', [req.params.id])).rows[0]; + if (!item || !canSeeImage(req.imageUser, item)) return fail(reply, 404, 'map onbekend'); + if (!canManageImage(req.imageUser, item)) return fail(reply, 403, 'geen rechten'); + const ctx = contextSql(item); + const count = Number((await pool.query( + `SELECT + (SELECT count(*) FROM image_folders WHERE ${ctx.sql} + AND path LIKE $${ctx.params.length + 1}) + + (SELECT count(*) FROM image_assets WHERE ${ctx.sql} + AND (folder = $${ctx.params.length + 2} OR folder LIKE $${ctx.params.length + 1})) AS n`, + [...ctx.params, item.path + '/%', item.path])).rows[0]?.n || 0); + if (count) return fail(reply, 409, 'map is niet leeg'); + await pool.query('DELETE FROM image_folders WHERE id = $1', [item.id]); + return { ok: true }; + }); + + app.get('/images/admin/quotas', async (req, reply) => { + if (!needUser(req, reply)) return; + if (!rolesOf(req.imageUser).includes('super')) return fail(reply, 403, 'geen rechten'); + const schools = (await pool.query( + `SELECT s.id, s.name, s.image_quota_bytes, + COALESCE(x.used,0)::bigint AS used + FROM schools s + LEFT JOIN (SELECT school_id, sum(size_bytes) AS used FROM image_assets + WHERE school_id IS NOT NULL GROUP BY school_id) x ON x.school_id = s.id + ORDER BY lower(s.name)`)).rows; + const users = (await pool.query( + `SELECT u.id, u.username, u.display_name, u.role, u.school_id, u.image_quota_bytes, + COALESCE(x.used,0)::bigint AS used, + COALESCE(r.roles, '{}') AS extra_roles + FROM users u + LEFT JOIN (SELECT owner_id, sum(size_bytes) AS used FROM image_assets + WHERE owner_id IS NOT NULL GROUP BY owner_id) x ON x.owner_id = u.id + LEFT JOIN (SELECT user_id, array_agg(role) AS roles FROM user_roles GROUP BY user_id) r ON r.user_id = u.id + ORDER BY u.school_id NULLS FIRST, lower(u.display_name)`)).rows; + return { + schools: schools.map((row) => ({ + id: Number(row.id), name: row.name, usedBytes: Number(row.used), + overrideBytes: row.image_quota_bytes == null ? null : Number(row.image_quota_bytes), + limitBytes: quotaOverride(row.image_quota_bytes, null), + })), + users: users.map((row) => { + const user = { role: row.role, allRoles: [row.role, ...(row.extra_roles || [])] }; + return { + id: Number(row.id), username: row.username, + displayName: row.display_name || row.username, role: row.role, + schoolId: row.school_id == null ? null : Number(row.school_id), + usedBytes: Number(row.used), + overrideBytes: row.image_quota_bytes == null ? null : Number(row.image_quota_bytes), + defaultBytes: defaultImageQuota(user), + limitBytes: quotaOverride(row.image_quota_bytes, defaultImageQuota(user)), + }; + }), + }; + }); + + const validQuota = (value) => value === null || value === -1 + || (Number.isInteger(value) && value >= 0 && value <= 10 * 1024 * IMAGE_MB); + app.patch('/images/admin/users/:id/quota', async (req, reply) => { + if (!needUser(req, reply)) return; + if (!rolesOf(req.imageUser).includes('super')) return fail(reply, 403, 'geen rechten'); + const value = req.body?.limitBytes; + if (!validQuota(value)) return fail(reply, 400, 'ongeldige limiet'); + const row = (await pool.query( + 'UPDATE users SET image_quota_bytes = $1 WHERE id = $2 RETURNING id', + [value, req.params.id])).rows[0]; + if (!row) return fail(reply, 404, 'gebruiker onbekend'); + return { ok: true }; + }); + app.patch('/images/admin/schools/:id/quota', async (req, reply) => { + if (!needUser(req, reply)) return; + if (!rolesOf(req.imageUser).includes('super')) return fail(reply, 403, 'geen rechten'); + const value = req.body?.limitBytes; + if (!validQuota(value)) return fail(reply, 400, 'ongeldige limiet'); + const row = (await pool.query( + 'UPDATE schools SET image_quota_bytes = $1 WHERE id = $2 RETURNING id', + [value, req.params.id])).rows[0]; + if (!row) return fail(reply, 404, 'school onbekend'); + return { ok: true }; + }); +} diff --git a/src/server.js b/src/server.js index bf0b287..67be936 100644 --- a/src/server.js +++ b/src/server.js @@ -7,6 +7,7 @@ import fastifyHelmet from '@fastify/helmet'; import fastifyRateLimit from '@fastify/rate-limit'; import pg from 'pg'; import api, { bootstrapSuper } from './api.js'; +import imageApi from './images.js'; import { runMigrations } from './migrate.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -80,6 +81,7 @@ app.addHook('onSend', async (_req, reply) => { // --- API --------------------------------------------------------------------- // Inloggen, rollen, gebruikersbeheer en per-gebruiker data. Zie src/api.js. app.register(api, { prefix: '/api' }); +app.register(imageApi, { prefix: '/api' }); // --- Static frontend --------------------------------------------------------- // Serveert public/index.html (de digibord-app) op / diff --git a/test/images.test.js b/test/images.test.js new file mode 100644 index 0000000..bd2cae8 --- /dev/null +++ b/test/images.test.js @@ -0,0 +1,168 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import Fastify from 'fastify'; +import imageApi, { + DEFAULT_IMAGE_QUOTAS, canManageImage, canSeeImage, defaultImageQuota, + detectImageType, imageScopeForUser, normaliseImageFolder, +} from '../src/images.js'; + +const superUser = { id: 1, role: 'super', school_id: null, allRoles: ['super'] }; +const admin = { id: 2, role: 'admin', school_id: 7, allRoles: ['admin'] }; +const teacher = { id: 3, role: 'teacher', school_id: 7, allRoles: ['teacher'] }; +const parent = { id: 4, role: 'parent', school_id: 7, allRoles: ['parent'] }; +const pupil = { id: 5, role: 'pupil', school_id: 7, allRoles: ['pupil'] }; + +test('afbeeldingsquota en uploadscope volgen alle rollen, inclusief ouders', () => { + assert.equal(defaultImageQuota(superUser), null); + assert.equal(defaultImageQuota(admin), 400 * 1024 * 1024); + for (const user of [teacher, parent, pupil]) + assert.equal(defaultImageQuota(user), 200 * 1024 * 1024); + assert.equal(DEFAULT_IMAGE_QUOTAS.default, 200 * 1024 * 1024); + assert.equal(imageScopeForUser(superUser), 'global'); + assert.equal(imageScopeForUser(admin), 'school'); + assert.equal(imageScopeForUser(teacher), 'personal'); + assert.equal(imageScopeForUser(parent), 'personal'); + assert.equal(imageScopeForUser(pupil), 'personal'); +}); + +test('verwijderen volgt systeem-, school- en persoonlijk eigenaarschap', () => { + const global = { scope: 'global', owner_id: 1, school_id: null }; + const school = { scope: 'school', owner_id: 8, school_id: 7 }; + const personal = { scope: 'personal', owner_id: teacher.id, school_id: 7 }; + for (const owner of [teacher, parent, pupil]) + assert.equal(canManageImage(owner, { ...personal, owner_id: owner.id }), true); + assert.equal(canManageImage(superUser, global), true); + assert.equal(canManageImage(admin, global), false); + assert.equal(canManageImage(admin, school), true); + assert.equal(canManageImage(teacher, school), false); + assert.equal(canManageImage(teacher, personal), true); + assert.equal(canManageImage(parent, personal), false); + assert.equal(canSeeImage(teacher, global), true); + assert.equal(canSeeImage(teacher, school), true); + assert.equal(canSeeImage(parent, personal), false); +}); + +test('alleen echte veilige rasterafbeeldingen en geldige map-paden worden geaccepteerd', () => { + const png = Buffer.from([0x89,0x50,0x4e,0x47,0x0d,0x0a,0x1a,0x0a,0,0,0,0]); + const jpg = Buffer.from([0xff,0xd8,0xff,0xe0,0,0,0,0,0,0,0,0]); + const webp = Buffer.from('RIFF1234WEBP'); + assert.deepEqual(detectImageType(png), { mime: 'image/png', ext: 'png' }); + assert.deepEqual(detectImageType(jpg), { mime: 'image/jpeg', ext: 'jpg' }); + assert.deepEqual(detectImageType(webp), { mime: 'image/webp', ext: 'webp' }); + assert.equal(detectImageType(Buffer.from('')), null); + assert.equal(normaliseImageFolder('Rekenen/Geld/Munten'), 'Rekenen/Geld/Munten'); + assert.equal(normaliseImageFolder('../privé'), null); + assert.equal(normaliseImageFolder('dubbel//pad'), null); + assert.equal(normaliseImageFolder(''), ''); +}); + +async function appFor(user, pool) { + const storageDir = await mkdtemp(join(tmpdir(), 'teach-images-')); + const app = Fastify(); + app.decorate('pg', pool); + await app.register(imageApi, { prefix: '/api', storageDir, resolveUser: async () => user }); + await app.ready(); + return { app, storageDir }; +} + +test('persoonlijke upload kan alleen door eigenaar of systeemmanager worden verwijderd', async () => { + const calls = []; + const item = { id: 9, scope: 'personal', owner_id: teacher.id, school_id: 7, + storage_key: '00000000-0000-4000-8000-000000000000.png', public_path: null }; + const pool = { async query(sql, params=[]) { + calls.push({sql,params}); + if (sql.includes('SELECT * FROM image_assets')) return { rows: [item] }; + return { rows: [] }; + }}; + const { app, storageDir } = await appFor(teacher, pool); + const own = await app.inject({ method: 'DELETE', url: '/api/images/9' }); + assert.equal(own.statusCode, 200, own.body); + assert.ok(calls.some(call => call.sql.includes('DELETE FROM image_assets'))); + await app.close();await rm(storageDir,{recursive:true,force:true}); + + const otherPool = { async query(sql) { + if (sql.includes('SELECT * FROM image_assets')) return { rows: [{...item,owner_id:99}] }; + return { rows: [] }; + }}; + const other = await appFor(teacher, otherPool); + const denied = await other.app.inject({ method: 'DELETE', url: '/api/images/9' }); + assert.equal(denied.statusCode, 404); + await other.app.close();await rm(other.storageDir,{recursive:true,force:true}); +}); + +test('schoolbeheerder uploadt voor school en de persoonlijke 200MB-grens wordt atomair bewaakt', async () => { + const png = Buffer.from([0x89,0x50,0x4e,0x47,0x0d,0x0a,0x1a,0x0a,0,0,0,0]); + const calls = []; + const client = { + async query(sql, params=[]) { + calls.push({sql,params}); + if (sql.includes('SELECT id, role, school_id, image_quota_bytes FROM users')) + return { rows: [{id:admin.id,role:'admin',school_id:7,image_quota_bytes:null}] }; + if (sql.includes('sum(size_bytes)') && sql.includes('owner_id')) return { rows: [{used:0}] }; + if (sql.includes('SELECT id, image_quota_bytes FROM schools')) return { rows: [{id:7,image_quota_bytes:null}] }; + if (sql.includes('sum(size_bytes)') && sql.includes('school_id')) return { rows: [{used:0}] }; + if (sql.includes('SELECT * FROM image_themes')) return { rows: [ + {id:11,scope:'global',school_id:null,owner_id:1}, + {id:12,scope:'school',school_id:7,owner_id:admin.id}, + ] }; + if (sql.includes('INSERT INTO image_assets')) return { rows: [{ + id:44,scope:'school',school_id:7,owner_id:admin.id,folder:'',name:'Test', + mime_type:'image/png',size_bytes:png.length,storage_key:params[9],public_path:null, + }] }; + return { rows: [] }; + }, + release() {}, + }; + const pool = { connect: async()=>client, query: (...args)=>client.query(...args) }; + const { app, storageDir } = await appFor(admin,pool); + const uploaded = await app.inject({method:'POST',url:'/api/images?name=Test&themes=11,12', + headers:{'content-type':'image/png'},payload:png}); + assert.equal(uploaded.statusCode,200,uploaded.body); + const insert=calls.find(call=>call.sql.includes('INSERT INTO image_assets')); + assert.equal(insert.params[1],'school'); + assert.equal(insert.params[2],7); + assert.equal(insert.params[3],admin.id); + assert.equal(calls.filter(call=>call.sql.includes('INSERT INTO image_asset_themes')).length,2); + await app.close();await rm(storageDir,{recursive:true,force:true}); + + const limitedCalls=[]; + const limitedClient={ + async query(sql){ + limitedCalls.push(sql); + if(sql.includes('SELECT id, role, school_id, image_quota_bytes FROM users')) + return{rows:[{id:teacher.id,role:'teacher',school_id:null,image_quota_bytes:null}]}; + if(sql.includes('sum(size_bytes)'))return{rows:[{used:200*1024*1024-4}]}; + return{rows:[]}; + },release(){} + }; + const limited=await appFor(teacher,{connect:async()=>limitedClient,query:(...args)=>limitedClient.query(...args)}); + const over=await limited.app.inject({method:'POST',url:'/api/images?name=Teveel', + headers:{'content-type':'image/png'},payload:png}); + assert.equal(over.statusCode,413,over.body); + assert.ok(!limitedCalls.some(sql=>sql.includes('INSERT INTO image_assets'))); + await limited.app.close();await rm(limited.storageDir,{recursive:true,force:true}); +}); + +test('migratie seedt geldafbeeldingen in meerdere thema’s en UI koppelt catalogus aan het bord', async () => { + const [migration,server,index,board,catalog,css] = await Promise.all([ + readFile('db/012_image_catalog.sql','utf8'),readFile('src/server.js','utf8'), + readFile('public/index.html','utf8'),readFile('public/js/board.js','utf8'), + readFile('public/js/image-catalog.js','utf8'),readFile('public/css/image-catalog.css','utf8'), + ]); + assert.equal((migration.split("/img/money/").length-1),12); + for(const theme of ['builtin-money','builtin-coins','builtin-banknotes'])assert.match(migration,new RegExp(theme)); + assert.match(migration,/CREATE TABLE IF NOT EXISTS image_asset_themes/); + assert.match(migration,/ADD COLUMN IF NOT EXISTS image_quota_bytes/g); + assert.match(server,/app\.register\(imageApi, \{ prefix: '\/api' \}\)/); + assert.match(index,/css\/image-catalog\.css/); + assert.ok(index.includes('js/image-catalog.js')); + assert.match(index,/id="btnImages"/); + assert.match(board,/openImageCatalog\(src=>addBoardImage/); + assert.match(board,/uploadImageToCatalog\(f\)/); + for(const feature of ['ic-search','ic-theme-filter','ic-folder-filter','openImageFolderExplorer','renderImageStoragePanel']) + assert.ok(catalog.includes(feature),feature); + assert.match(css,/@media\(max-width:760px\)/); +});