Voeg 28 herkenbare kleurplaten en creatieve niveaus toe (v0.4.66-beta) #4
16 changed files with 1331 additions and 18 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 ./
|
||||||
|
|
|
||||||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
||||||
0.3.54-beta
|
0.3.55-beta
|
||||||
|
|
|
||||||
|
|
@ -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
130
db/012_image_catalog.sql
Normal 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;
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
33
public/css/image-catalog.css
Normal file
33
public/css/image-catalog.css
Normal 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}}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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=>{
|
||||||
|
|
|
||||||
|
|
@ -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:"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"],
|
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
360
public/js/image-catalog.js
Normal 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&¤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();
|
||||||
577
src/images.js
Normal file
577
src/images.js
Normal 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 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 };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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
168
test/images.test.js
Normal 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 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\)/);
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue