diff options
| author | ertopogo <erwin.t.pombett@gmail.com> | 2026-03-13 00:33:28 +0100 |
|---|---|---|
| committer | ertopogo <erwin.t.pombett@gmail.com> | 2026-03-13 00:33:28 +0100 |
| commit | b34873f98052ac5fb4bf6731a25730075796d764 (patch) | |
| tree | 0b27ef2996894287aaf382b43956d6cf45352e94 /viewer-bff | |
Diffstat (limited to 'viewer-bff')
| -rw-r--r-- | viewer-bff/.dockerignore | 2 | ||||
| -rw-r--r-- | viewer-bff/Dockerfile | 13 | ||||
| -rw-r--r-- | viewer-bff/package.json | 16 | ||||
| -rw-r--r-- | viewer-bff/public/app.js | 122 | ||||
| -rw-r--r-- | viewer-bff/public/index.html | 43 | ||||
| -rw-r--r-- | viewer-bff/public/styles.css | 93 | ||||
| -rw-r--r-- | viewer-bff/server.js | 115 |
7 files changed, 404 insertions, 0 deletions
diff --git a/viewer-bff/.dockerignore b/viewer-bff/.dockerignore new file mode 100644 index 0000000..3abe3d4 --- /dev/null +++ b/viewer-bff/.dockerignore @@ -0,0 +1,2 @@ +node_modules
+npm-debug.log
diff --git a/viewer-bff/Dockerfile b/viewer-bff/Dockerfile new file mode 100644 index 0000000..af28598 --- /dev/null +++ b/viewer-bff/Dockerfile @@ -0,0 +1,13 @@ +FROM node:20-alpine
+
+WORKDIR /app
+
+COPY package.json ./
+RUN npm install --omit=dev
+
+COPY . .
+
+ENV PORT=8082
+EXPOSE 8082
+
+CMD ["npm", "start"]
diff --git a/viewer-bff/package.json b/viewer-bff/package.json new file mode 100644 index 0000000..489d044 --- /dev/null +++ b/viewer-bff/package.json @@ -0,0 +1,16 @@ +{
+ "name": "viewer-bff",
+ "version": "0.1.0",
+ "private": true,
+ "description": "BFF Node/Express pour viewer medias securise",
+ "main": "server.js",
+ "scripts": {
+ "start": "node server.js"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "dependencies": {
+ "express": "^4.21.2"
+ }
+}
diff --git a/viewer-bff/public/app.js b/viewer-bff/public/app.js new file mode 100644 index 0000000..ecbe3fb --- /dev/null +++ b/viewer-bff/public/app.js @@ -0,0 +1,122 @@ +const tokenInput = document.getElementById("tokenInput");
+const objectKeysInput = document.getElementById("objectKeysInput");
+const permissionsOutput = document.getElementById("permissionsOutput");
+const gallery = document.getElementById("gallery");
+
+const loadPermissionsBtn = document.getElementById("loadPermissionsBtn");
+const buildGalleryBtn = document.getElementById("buildGalleryBtn");
+
+let currentPermissions = null;
+
+function getToken() {
+ return tokenInput.value.trim();
+}
+
+function parseObjectKeys() {
+ return objectKeysInput.value
+ .split("\n")
+ .map((line) => line.trim())
+ .filter(Boolean);
+}
+
+function isAllowedByPermissions(objectKey, permissions) {
+ if (!permissions) return false;
+ if (permissions.allowAll) return true;
+ const prefixes = permissions.allowedPrefixes || [];
+ return prefixes.some((prefix) => objectKey.startsWith(prefix));
+}
+
+async function callJson(url, options = {}) {
+ const token = getToken();
+ const headers = {
+ "Content-Type": "application/json",
+ ...(options.headers || {})
+ };
+ if (token) {
+ headers.Authorization = `Bearer ${token}`;
+ }
+
+ const response = await fetch(url, { ...options, headers });
+ const payload = await response.json().catch(() => ({}));
+ if (!response.ok) {
+ throw new Error(payload.message || `HTTP ${response.status}`);
+ }
+ return payload;
+}
+
+loadPermissionsBtn.addEventListener("click", async () => {
+ try {
+ const perms = await callJson("/api/me/permissions");
+ currentPermissions = perms;
+ permissionsOutput.textContent = JSON.stringify(perms, null, 2);
+ } catch (error) {
+ permissionsOutput.textContent = `Erreur: ${error.message}`;
+ }
+});
+
+buildGalleryBtn.addEventListener("click", async () => {
+ gallery.innerHTML = "";
+ const keys = parseObjectKeys();
+
+ if (!currentPermissions) {
+ permissionsOutput.textContent = "Charger d'abord les permissions.";
+ return;
+ }
+
+ if (!keys.length) {
+ permissionsOutput.textContent = "Ajouter au moins une objectKey.";
+ return;
+ }
+
+ for (const objectKey of keys) {
+ const card = document.createElement("article");
+ card.className = "card";
+
+ const img = document.createElement("img");
+ img.className = "thumb";
+ img.alt = objectKey;
+
+ const keyP = document.createElement("p");
+ keyP.className = "key";
+ keyP.textContent = objectKey;
+
+ const openBtn = document.createElement("button");
+ openBtn.textContent = "Ouvrir";
+ openBtn.disabled = !isAllowedByPermissions(objectKey, currentPermissions);
+
+ openBtn.addEventListener("click", async () => {
+ try {
+ const presign = await callJson("/api/media/presign", {
+ method: "POST",
+ body: JSON.stringify({ objectKey })
+ });
+ const signedUrl = presign.url;
+ img.src = signedUrl;
+ window.open(signedUrl, "_blank", "noopener,noreferrer");
+ } catch (error) {
+ alert(`Presign refuse: ${error.message}`);
+ }
+ });
+
+ if (!openBtn.disabled) {
+ // Previsualisation opportuniste pour les objets autorises.
+ callJson("/api/media/presign", {
+ method: "POST",
+ body: JSON.stringify({ objectKey })
+ })
+ .then((presign) => {
+ img.src = presign.url;
+ })
+ .catch(() => {
+ img.alt = "Previsualisation indisponible";
+ });
+ } else {
+ img.alt = "Acces refuse (roles)";
+ }
+
+ card.appendChild(img);
+ card.appendChild(keyP);
+ card.appendChild(openBtn);
+ gallery.appendChild(card);
+ }
+});
diff --git a/viewer-bff/public/index.html b/viewer-bff/public/index.html new file mode 100644 index 0000000..8112bfa --- /dev/null +++ b/viewer-bff/public/index.html @@ -0,0 +1,43 @@ +<!doctype html>
+<html lang="fr">
+ <head>
+ <meta charset="UTF-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+ <title>Viewer Medias (POC)</title>
+ <link rel="stylesheet" href="/styles.css" />
+ </head>
+ <body>
+ <main class="container">
+ <h1>Viewer medias securise (POC)</h1>
+
+ <section class="panel">
+ <h2>1) Token utilisateur</h2>
+ <textarea
+ id="tokenInput"
+ placeholder="Coller ici le JWT (Bearer token)"
+ rows="5"
+ ></textarea>
+ <button id="loadPermissionsBtn">Charger permissions</button>
+ <pre id="permissionsOutput">Aucune permission chargee.</pre>
+ </section>
+
+ <section class="panel">
+ <h2>2) Cles objet a visualiser</h2>
+ <p class="hint">
+ Une cle par ligne, exemple: <code>photos/equipeA/2026/03/img001.jpg</code>
+ </p>
+ <textarea id="objectKeysInput" rows="8"></textarea>
+ <div class="actions">
+ <button id="buildGalleryBtn">Construire la galerie</button>
+ </div>
+ </section>
+
+ <section class="panel">
+ <h2>3) Galerie</h2>
+ <div id="gallery" class="gallery"></div>
+ </section>
+ </main>
+
+ <script src="/app.js"></script>
+ </body>
+</html>
diff --git a/viewer-bff/public/styles.css b/viewer-bff/public/styles.css new file mode 100644 index 0000000..5417318 --- /dev/null +++ b/viewer-bff/public/styles.css @@ -0,0 +1,93 @@ +* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ font-family: Arial, sans-serif;
+ background: #0f172a;
+ color: #e2e8f0;
+}
+
+.container {
+ max-width: 1100px;
+ margin: 0 auto;
+ padding: 24px;
+}
+
+h1 {
+ margin: 0 0 20px;
+}
+
+.panel {
+ background: #111827;
+ border: 1px solid #334155;
+ border-radius: 8px;
+ padding: 16px;
+ margin-bottom: 16px;
+}
+
+textarea {
+ width: 100%;
+ background: #0b1220;
+ color: #e2e8f0;
+ border: 1px solid #334155;
+ border-radius: 6px;
+ padding: 10px;
+}
+
+button {
+ margin-top: 10px;
+ background: #2563eb;
+ border: none;
+ color: white;
+ border-radius: 6px;
+ padding: 8px 12px;
+ cursor: pointer;
+}
+
+button:hover {
+ background: #1d4ed8;
+}
+
+.hint {
+ margin-top: 0;
+ color: #94a3b8;
+}
+
+#permissionsOutput {
+ white-space: pre-wrap;
+ background: #0b1220;
+ border: 1px solid #334155;
+ border-radius: 6px;
+ padding: 10px;
+ min-height: 50px;
+}
+
+.gallery {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
+ gap: 14px;
+}
+
+.card {
+ background: #0b1220;
+ border: 1px solid #334155;
+ border-radius: 8px;
+ padding: 10px;
+}
+
+.thumb {
+ width: 100%;
+ height: 140px;
+ object-fit: cover;
+ border-radius: 6px;
+ background: #1e293b;
+}
+
+.key {
+ margin: 8px 0;
+ font-size: 12px;
+ word-break: break-all;
+ color: #cbd5e1;
+}
diff --git a/viewer-bff/server.js b/viewer-bff/server.js new file mode 100644 index 0000000..30ef81a --- /dev/null +++ b/viewer-bff/server.js @@ -0,0 +1,115 @@ +const express = require("express");
+const path = require("path");
+
+const app = express();
+
+const PORT = process.env.PORT || 8082;
+const MEDIA_API_BASE_URL =
+ process.env.MEDIA_API_BASE_URL || "http://media-access-api:8081";
+const CORS_ALLOWED_ORIGIN = process.env.CORS_ALLOWED_ORIGIN || "*";
+
+app.use(express.json({ limit: "1mb" }));
+
+app.use((req, res, next) => {
+ res.setHeader("Access-Control-Allow-Origin", CORS_ALLOWED_ORIGIN);
+ res.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS");
+ res.setHeader("Access-Control-Allow-Headers", "Authorization,Content-Type");
+ if (req.method === "OPTIONS") {
+ return res.sendStatus(204);
+ }
+ return next();
+});
+
+app.use(express.static(path.join(__dirname, "public")));
+
+app.get("/health", (_req, res) => {
+ res.json({
+ status: "ok",
+ service: "viewer-bff",
+ mediaApiBaseUrl: MEDIA_API_BASE_URL
+ });
+});
+
+function getBearerToken(req) {
+ const authHeader = req.headers.authorization || "";
+ if (!authHeader.startsWith("Bearer ")) {
+ return null;
+ }
+ return authHeader.slice("Bearer ".length).trim();
+}
+
+async function proxyJson(req, res, targetPath, method = "GET", body) {
+ const token = getBearerToken(req);
+ if (!token) {
+ return res.status(401).json({
+ error: "missing_token",
+ message: "Header Authorization Bearer requis"
+ });
+ }
+
+ try {
+ const response = await fetch(`${MEDIA_API_BASE_URL}${targetPath}`, {
+ method,
+ headers: {
+ Authorization: `Bearer ${token}`,
+ "Content-Type": "application/json"
+ },
+ body: body ? JSON.stringify(body) : undefined
+ });
+
+ const text = await response.text();
+ let payload = {};
+ if (text) {
+ try {
+ payload = JSON.parse(text);
+ } catch (_err) {
+ payload = { raw: text };
+ }
+ }
+
+ if (!response.ok) {
+ return res.status(response.status).json({
+ error: "media_api_error",
+ status: response.status,
+ message: payload.message || "Erreur retour media-access-api",
+ details: payload
+ });
+ }
+
+ return res.status(response.status).json(payload);
+ } catch (error) {
+ return res.status(502).json({
+ error: "media_api_unreachable",
+ message: "Impossible de joindre media-access-api",
+ details: error.message
+ });
+ }
+}
+
+app.get("/api/me/permissions", async (req, res) => {
+ return proxyJson(req, res, "/v1/permissions", "GET");
+});
+
+app.post("/api/media/presign", async (req, res) => {
+ const objectKey = req.body?.objectKey;
+ if (!objectKey || typeof objectKey !== "string") {
+ return res.status(400).json({
+ error: "invalid_payload",
+ message: "Champ objectKey requis"
+ });
+ }
+
+ return proxyJson(req, res, "/v1/presign", "POST", { objectKey });
+});
+
+app.get("*", (req, res) => {
+ if (req.path.startsWith("/api/")) {
+ return res.status(404).json({ error: "not_found" });
+ }
+ return res.sendFile(path.join(__dirname, "public", "index.html"));
+});
+
+app.listen(PORT, () => {
+ // eslint-disable-next-line no-console
+ console.log(`viewer-bff listening on port ${PORT}`);
+});
|
