feat: voeg afbeeldingscatalogus en opslagquota toe
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 27s

This commit is contained in:
Ramon 2026-07-15 23:45:17 +02:00
parent 3ad7c80bad
commit 0d644ba41d
16 changed files with 1331 additions and 18 deletions

1
.gitignore vendored
View file

@ -8,6 +8,7 @@ npm-debug.log*
# Postgres data volume bij lokaal draaien # Postgres data volume bij lokaal draaien
pgdata/ pgdata/
storage/
# OS / editor # OS / editor
.DS_Store .DS_Store

View file

@ -13,7 +13,7 @@ ENV NODE_ENV=production
WORKDIR /app WORKDIR /app
# Draai als non-root gebruiker # 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 --from=deps /app/node_modules ./node_modules
COPY --chown=app:app package.json ./ COPY --chown=app:app package.json ./

View file

@ -1 +1 @@
0.3.54-beta 0.3.55-beta

View file

@ -22,6 +22,8 @@ services:
COOKIE_SECURE: "false" COOKIE_SECURE: "false"
ports: ports:
- "3000:3000" - "3000:3000"
volumes:
- image_data:/app/storage/images
restart: unless-stopped restart: unless-stopped
db: db:
@ -43,3 +45,4 @@ services:
volumes: volumes:
pgdata: pgdata:
image_data:

130
db/012_image_catalog.sql Normal file
View file

@ -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;

View file

@ -28,6 +28,8 @@ services:
ports: ports:
# Alleen op localhost van de VM; nginx zit ervoor als reverse proxy # Alleen op localhost van de VM; nginx zit ervoor als reverse proxy
- "127.0.0.1:${APP_PORT:-3000}:3000" - "127.0.0.1:${APP_PORT:-3000}:3000"
volumes:
- image_data:/app/storage/images
restart: unless-stopped restart: unless-stopped
web: web:
@ -60,3 +62,4 @@ services:
volumes: volumes:
pgdata: pgdata:
image_data:

View file

@ -10,7 +10,7 @@ server {
server_tokens off; server_tokens off;
# Wat groter zodat grote borden/afbeeldingen niet geweigerd worden # Wat groter zodat grote borden/afbeeldingen niet geweigerd worden
client_max_body_size 25m; client_max_body_size 55m;
location / { location / {
proxy_pass http://app:3000; proxy_pass http://app:3000;

View file

@ -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}}

View file

@ -7,6 +7,7 @@
<link rel="icon" type="image/svg+xml" href="img/logo.svg"> <link rel="icon" type="image/svg+xml" href="img/logo.svg">
<link rel="preload" href="fonts/quicksand.woff2" as="font" type="font/woff2" crossorigin> <link rel="preload" href="fonts/quicksand.woff2" as="font" type="font/woff2" crossorigin>
<link rel="stylesheet" href="css/teach.css"> <link rel="stylesheet" href="css/teach.css">
<link rel="stylesheet" href="css/image-catalog.css">
</head> </head>
<body> <body>
@ -20,6 +21,7 @@
<button class="tbtn ghost" id="zoomPct">100%</button> <button class="tbtn ghost" id="zoomPct">100%</button>
<button class="tbtn ghost" id="btnZoomIn">A</button> <button class="tbtn ghost" id="btnZoomIn">A</button>
<div class="spacer"></div> <div class="spacer"></div>
<button class="tbtn ghost" id="btnImages">🖼️</button>
<button class="tbtn ghost" id="btnFolders">📁</button> <button class="tbtn ghost" id="btnFolders">📁</button>
<button class="tbtn ghost" id="btnSave">💾 <span data-i18n="saveBoard"></span></button> <button class="tbtn ghost" id="btnSave">💾 <span data-i18n="saveBoard"></span></button>
<button class="tbtn ghost" id="btnUser">⚙️ <span id="userLbl"></span></button> <button class="tbtn ghost" id="btnUser">⚙️ <span id="userLbl"></span></button>
@ -267,6 +269,7 @@
<script src="js/board.js"></script> <script src="js/board.js"></script>
<script src="js/pupil.js"></script> <script src="js/pupil.js"></script>
<script src="js/admin.js"></script> <script src="js/admin.js"></script>
<script src="js/image-catalog.js"></script>
<script src="js/app.js"></script> <script src="js/app.js"></script>
</body> </body>
</html> </html>

View file

@ -119,7 +119,7 @@
(#adminTabsSlot in core.js/index.html) - geen geneste tabbalk meer */ (#adminTabsSlot in core.js/index.html) - geen geneste tabbalk meer */
const adminTabDefs = ()=>{ const adminTabDefs = ()=>{
const defs = []; 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(["klassen", T("amClasses")]);
defs.push(["gebruikers", T("amUsers")]); defs.push(["gebruikers", T("amUsers")]);
defs.push(["toewijzingen", T("amAssignments")]); defs.push(["toewijzingen", T("amAssignments")]);
@ -922,7 +922,7 @@
/* het Systeem-tabblad is school-onafhankelijk: daar is de schoolkiezer /* het Systeem-tabblad is school-onafhankelijk: daar is de schoolkiezer
alleen maar verwarrend, dus die verbergen we er */ alleen maar verwarrend, dus die verbergen we er */
const sb = modal.querySelector(".am-schoolbar"); 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 /* vaste contextbalk voor de systeemmanager: op elk beheer-tabblad direct
@ -952,7 +952,7 @@
const tabDefs = adminTabDefs(); const tabDefs = adminTabDefs();
if(!tabDefs.some(([t])=>t===adminTab)) adminTab = tabDefs[0][0]; 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])=>{ tabDefs.forEach(([t])=>{
const panel = h("div","am-tabpanel"); const panel = h("div","am-tabpanel");
panel.dataset.panel = t; panel.dataset.panel = t;

View file

@ -881,16 +881,20 @@ fabMenu.querySelector('[data-f="mind"]').addEventListener("click", ()=>{
const def = REGISTRY.find(d=>d.id==="mind"); const def = REGISTRY.find(d=>d.id==="mind");
makeWidget(def, { x:0, y:0, w:board.clientWidth, h:board.clientHeight, bare:true }); makeWidget(def, { x:0, y:0, w:board.clientWidth, h:board.clientHeight, bare:true });
}); });
/* image upload onto the board */ /* image catalogue + upload onto the board */
const imgInput = document.createElement("input");
imgInput.type = "file"; imgInput.accept = "image/*"; imgInput.style.display = "none";
document.body.appendChild(imgInput);
fabMenu.querySelector('[data-f="img"]').addEventListener("click", ()=>{ fabMenu.querySelector('[data-f="img"]').addEventListener("click", ()=>{
fabMenu.classList.remove("open"); fab.classList.remove("open"); fabMenu.classList.remove("open"); fab.classList.remove("open");
imgInput.click(); openImageCatalog(src=>addBoardImage(src,80,90,320));
}); });
function loadImgFile(f, x, y){ 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(); const img = new Image();
img.onload = ()=>{ img.onload = ()=>{
const c = document.createElement("canvas"); const c = document.createElement("canvas");
@ -902,11 +906,6 @@ function loadImgFile(f, x, y){
}; };
img.src = URL.createObjectURL(f); 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 */ /* drag & drop images from other websites or the computer onto the board */
board.addEventListener("dragover", e=>{ e.preventDefault(); }); board.addEventListener("dragover", e=>{ e.preventDefault(); });
board.addEventListener("drop", e=>{ board.addEventListener("drop", e=>{

View file

@ -2,7 +2,7 @@
"use strict"; "use strict";
/* version shown until /api/version resolves (or if the fetch fails, e.g. offline). /* 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. */ 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(){ (function(){
const tag = document.getElementById("verTag"); const tag = document.getElementById("verTag");
tag.textContent = "v"+VERSION; tag.textContent = "v"+VERSION;
@ -159,6 +159,23 @@ const I18N = {
pictoCredit:"Picto's: <a href='https://arasaac.org' target='_blank'>ARASAAC</a> (CC BY-NC-SA, Gobierno de Aragón). Ook leuk: <a href='https://www.sclera.be' target='_blank'>sclera.be</a> (gratis download, dan via Upload toevoegen).", pictoCredit:"Picto's: <a href='https://arasaac.org' target='_blank'>ARASAAC</a> (CC BY-NC-SA, Gobierno de Aragón). Ook leuk: <a href='https://www.sclera.be' target='_blank'>sclera.be</a> (gratis download, dan via Upload toevoegen).",
upload:"📷 Kies een afbeelding…", upload:"📷 Kies een afbeelding…",
upHint:"Tip: vierkant plaatje, witte of doorzichtige achtergrond, één duidelijk voorwerp, géén tekst in het plaatje.", 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:"Themas",
imgNewTheme:"Nieuw thema…", imgAddTheme:"Thema toevoegen", imgEdit:"Afbeelding bewerken", imgName:"Naam",
imgAllThemes:"Alle themas", 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"], cats:["Dieren","Eten","Natuur","Vervoer","Spullen","Lichaam"],
notesPh:"Schrijf hier…", notesPh:"Schrijf hier…",
start:"Start", pause:"Pauze", reset:"Reset", min:"min", start:"Start", pause:"Pauze", reset:"Reset", min:"min",
@ -367,6 +384,23 @@ const I18N = {
pictoCredit:"Pictos: <a href='https://arasaac.org' target='_blank'>ARASAAC</a> (CC BY-NC-SA, Gobierno de Aragón). Also nice: <a href='https://www.sclera.be' target='_blank'>sclera.be</a> (free download, then add via Upload).", pictoCredit:"Pictos: <a href='https://arasaac.org' target='_blank'>ARASAAC</a> (CC BY-NC-SA, Gobierno de Aragón). Also nice: <a href='https://www.sclera.be' target='_blank'>sclera.be</a> (free download, then add via Upload).",
upload:"📷 Choose an image…", upload:"📷 Choose an image…",
upHint:"Tip: square picture, white or transparent background, one clear object, no text in the picture.", 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"], cats:["Animals","Food","Nature","Transport","Things","Body"],
notesPh:"Write here…", notesPh:"Write here…",
start:"Start", pause:"Pause", reset:"Reset", min:"min", start:"Start", pause:"Pause", reset:"Reset", min:"min",

360
public/js/image-catalog.js Normal file
View file

@ -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=`
<div class="modal image-catalog-modal">
<div class="gallery-head"><h2 class="ic-title"></h2><button class="wclose ic-close" type="button"></button></div>
<div class="ic-tools">
<input class="ic-search" type="search" autocomplete="off">
<select class="ic-theme-filter"></select>
<select class="ic-scope-filter"></select>
<select class="ic-folder-filter"></select>
<button class="tbtn ghost ic-folders" type="button"></button>
</div>
<div class="ic-quota"></div>
<details class="ic-add">
<summary></summary>
<div class="ic-add-grid">
<label class="ic-field"><span class="ic-upload-folder-label"></span><select class="ic-upload-folder"></select></label>
<label class="ic-field"><span class="ic-upload-themes-label"></span><select class="ic-upload-themes" multiple></select></label>
<label class="ed-upload ic-upload"><span></span><input type="file" accept="image/png,image/jpeg,image/gif,image/webp,image/avif" multiple></label>
</div>
<div class="ic-theme-new"><input type="text" maxlength="80"><button class="tbtn ghost" type="button"></button></div>
</details>
<div class="ic-status" role="status"></div>
<div class="ic-grid"></div>
<section class="ic-editor" hidden>
<h3></h3>
<div class="ic-editor-grid">
<label class="ic-field"><span class="ic-name-label"></span><input class="ic-edit-name" maxlength="80"></label>
<label class="ic-field"><span class="ic-folder-label"></span><input class="ic-edit-folder" maxlength="200"></label>
<label class="ic-field"><span class="ic-themes-label"></span><select class="ic-edit-themes" multiple></select></label>
</div>
<div class="ic-editor-actions"><button class="tbtn ic-save" type="button"></button><button class="tbtn danger ic-delete" type="button"></button><button class="tbtn ghost ic-cancel" type="button"></button></div>
</section>
</div>`;
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&&currentUser.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();

577
src/images.js Normal file
View file

@ -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 themas');
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 themas');
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 };
});
}

View file

@ -7,6 +7,7 @@ import fastifyHelmet from '@fastify/helmet';
import fastifyRateLimit from '@fastify/rate-limit'; import fastifyRateLimit from '@fastify/rate-limit';
import pg from 'pg'; import pg from 'pg';
import api, { bootstrapSuper } from './api.js'; import api, { bootstrapSuper } from './api.js';
import imageApi from './images.js';
import { runMigrations } from './migrate.js'; import { runMigrations } from './migrate.js';
const __dirname = dirname(fileURLToPath(import.meta.url)); const __dirname = dirname(fileURLToPath(import.meta.url));
@ -80,6 +81,7 @@ app.addHook('onSend', async (_req, reply) => {
// --- API --------------------------------------------------------------------- // --- API ---------------------------------------------------------------------
// Inloggen, rollen, gebruikersbeheer en per-gebruiker data. Zie src/api.js. // Inloggen, rollen, gebruikersbeheer en per-gebruiker data. Zie src/api.js.
app.register(api, { prefix: '/api' }); app.register(api, { prefix: '/api' });
app.register(imageApi, { prefix: '/api' });
// --- Static frontend --------------------------------------------------------- // --- Static frontend ---------------------------------------------------------
// Serveert public/index.html (de digibord-app) op / // Serveert public/index.html (de digibord-app) op /

168
test/images.test.js Normal file
View file

@ -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('<svg onload="alert(1)"></svg>')), 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 themas 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\)/);
});