From 87367a6e358c767dec50056535d4982099fa0faf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Kr=C3=BChlmann?= Date: Tue, 28 Apr 2026 14:47:05 +0200 Subject: [PATCH] feat: add copyright report UI (public report page, admin flag button, sysadmin review) - /report: public three-step page to find a broadcaster, pick an asset, and submit a DMCA-style claim; uses asset.url fallback for images without previews - Admin console: flag button opens report modal for the selected asset - Settings page: sysadmin copyright reports section with status/broadcaster filters, paginated table, and review modal with action radio buttons - Footer on index.html links to /report --- .../imgfloat/controller/ViewController.java | 8 + src/main/resources/static/js/admin.js | 1 + src/main/resources/static/js/admin/console.js | 4 + .../resources/static/js/copyright-reports.js | 202 ++++++++++++++ src/main/resources/static/js/report-page.js | 252 ++++++++++++++++++ src/main/resources/static/js/report.js | 119 +++++++++ src/main/resources/templates/admin.html | 73 ++++- src/main/resources/templates/index.html | 1 + src/main/resources/templates/report.html | 157 +++++++++++ src/main/resources/templates/settings.html | 72 +++++ 10 files changed, 879 insertions(+), 10 deletions(-) create mode 100644 src/main/resources/static/js/copyright-reports.js create mode 100644 src/main/resources/static/js/report-page.js create mode 100644 src/main/resources/static/js/report.js create mode 100644 src/main/resources/templates/report.html diff --git a/src/main/java/dev/kruhlmann/imgfloat/controller/ViewController.java b/src/main/java/dev/kruhlmann/imgfloat/controller/ViewController.java index 94455a7..2afc1d1 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/controller/ViewController.java +++ b/src/main/java/dev/kruhlmann/imgfloat/controller/ViewController.java @@ -107,6 +107,14 @@ public class ViewController { return "cookies"; } + @org.springframework.web.bind.annotation.GetMapping("/report") + public String reportView(Model model) { + LOG.info("Rendering copyright report page"); + addStagingAttribute(model); + addVersionAttributes(model); + return "report"; + } + @org.springframework.web.bind.annotation.GetMapping("/settings") public String settingsView(OAuth2AuthenticationToken oauthToken, Model model) { String sessionUsername = OauthSessionUser.from(oauthToken).login(); diff --git a/src/main/resources/static/js/admin.js b/src/main/resources/static/js/admin.js index 65f0825..075e638 100644 --- a/src/main/resources/static/js/admin.js +++ b/src/main/resources/static/js/admin.js @@ -1,5 +1,6 @@ import { createAdminConsole } from "./admin/console.js"; import { createCustomAssetModal } from "./customAssets.js"; +import "./report.js"; let adminConsole; const customAssetModal = createCustomAssetModal({ diff --git a/src/main/resources/static/js/admin/console.js b/src/main/resources/static/js/admin/console.js index f6f0985..67b7b58 100644 --- a/src/main/resources/static/js/admin/console.js +++ b/src/main/resources/static/js/admin/console.js @@ -77,6 +77,7 @@ export function createAdminConsole({ const selectedEditBtn = document.getElementById("selected-asset-edit"); const selectedVisibilityBtn = document.getElementById("selected-asset-visibility"); const selectedDeleteBtn = document.getElementById("selected-asset-delete"); + const selectedReportBtn = document.getElementById("selected-asset-report"); const assetActionRow = document.getElementById("asset-actions"); const assetActionButtons = Array.from(assetActionRow?.querySelectorAll("button") ?? []); const canvasResolutionLabel = document.getElementById("canvas-resolution"); @@ -2185,6 +2186,9 @@ export function createAdminConsole({ selectedDeleteBtn.disabled = !asset; selectedDeleteBtn.title = asset ? "Delete asset" : "Delete asset"; } + if (selectedReportBtn) { + selectedReportBtn.disabled = !asset; + } } function ensureDurationMetadata(asset) { diff --git a/src/main/resources/static/js/copyright-reports.js b/src/main/resources/static/js/copyright-reports.js new file mode 100644 index 0000000..be6c12d --- /dev/null +++ b/src/main/resources/static/js/copyright-reports.js @@ -0,0 +1,202 @@ +/** + * Sysadmin copyright reports management. + * Loaded on the /settings page only. + */ + +(function () { + const loadBtn = document.getElementById("copyright-load-btn"); + if (!loadBtn) return; + + const statusFilter = document.getElementById("copyright-status-filter"); + const broadcasterFilter = document.getElementById("copyright-broadcaster-filter"); + const tbody = document.getElementById("copyright-reports-body"); + const table = document.getElementById("copyright-reports-table"); + const placeholder = document.getElementById("copyright-reports-placeholder"); + const pagination = document.getElementById("copyright-pagination"); + const prevPageBtn = document.getElementById("copyright-prev-page"); + const nextPageBtn = document.getElementById("copyright-next-page"); + const pageIndicator = document.getElementById("copyright-page-indicator"); + + const reviewModal = document.getElementById("copyright-review-modal"); + const reviewDetail = document.getElementById("copyright-review-detail"); + const reviewForm = document.getElementById("copyright-review-form"); + const reviewNotes = document.getElementById("copyright-review-notes"); + const reviewError = document.getElementById("copyright-review-error"); + const reviewCloseBtn = document.getElementById("copyright-review-close"); + const reviewCancelBtn = document.getElementById("copyright-review-cancel"); + + let currentPage = 0; + let totalPages = 0; + let pendingReportId = null; + + function csrfHeaders() { + const token = document.querySelector("meta[name='_csrf']")?.content ?? ""; + const header = document.querySelector("meta[name='_csrf_header']")?.content ?? "X-XSRF-TOKEN"; + return { [header]: token }; + } + + function formatDate(isoStr) { + if (!isoStr) return "—"; + return new Date(isoStr).toLocaleString(); + } + + function statusBadge(status) { + const classes = { PENDING: "badge soft", DISMISSED: "badge outline", RESOLVED: "badge" }; + return `${status}`; + } + + async function loadReports(page = 0) { + const status = statusFilter.value; + const broadcaster = broadcasterFilter.value.trim(); + const params = new URLSearchParams({ page, size: 20 }); + if (status) params.set("status", status); + if (broadcaster) params.set("broadcaster", broadcaster); + + const resp = await fetch(`/api/copyright-reports?${params}`, { + headers: { ...csrfHeaders() }, + }); + if (!resp.ok) { + placeholder.textContent = "Failed to load reports."; + placeholder.classList.remove("hidden"); + table.classList.add("hidden"); + pagination.classList.add("hidden"); + return; + } + const data = await resp.json(); + renderTable(data); + currentPage = data.page; + totalPages = data.totalPages; + updatePagination(data); + } + + function renderTable(data) { + tbody.innerHTML = ""; + if (!data.content || data.content.length === 0) { + placeholder.textContent = "No reports found."; + placeholder.classList.remove("hidden"); + table.classList.add("hidden"); + pagination.classList.add("hidden"); + return; + } + placeholder.classList.add("hidden"); + table.classList.remove("hidden"); + + for (const r of data.content) { + const tr = document.createElement("tr"); + tr.innerHTML = ` + ${escHtml(r.broadcaster)} + ${escHtml(r.assetId.slice(0, 8))}… + ${escHtml(r.claimantName)} <${escHtml(r.claimantEmail)}> + ${statusBadge(r.status)} + ${formatDate(r.createdAt)} + + + + `; + tbody.appendChild(tr); + } + + tbody.querySelectorAll(".review-btn").forEach((btn) => { + btn.addEventListener("click", () => openReviewModal(btn.dataset.id, data.content.find((r) => r.id === btn.dataset.id))); + }); + } + + function updatePagination(data) { + if (data.totalPages <= 1) { + pagination.classList.add("hidden"); + return; + } + pagination.classList.remove("hidden"); + pageIndicator.textContent = `Page ${data.page + 1} of ${data.totalPages}`; + prevPageBtn.disabled = data.page === 0; + nextPageBtn.disabled = data.page >= data.totalPages - 1; + } + + function openReviewModal(reportId, report) { + pendingReportId = reportId; + reviewForm.reset(); + hideReviewError(); + reviewDetail.innerHTML = ` +
+
Broadcaster
${escHtml(report.broadcaster)}
+
Asset ID
${escHtml(report.assetId)}
+
Claimant
${escHtml(report.claimantName)} <${escHtml(report.claimantEmail)}>
+
Original work
${escHtml(report.originalWorkDescription)}
+
How it infringes
${escHtml(report.infringingDescription)}
+
Submitted
${formatDate(report.createdAt)}
+
+ `; + reviewModal.classList.remove("hidden"); + } + + function closeReviewModal() { + reviewModal.classList.add("hidden"); + pendingReportId = null; + } + + function showReviewError(msg) { + reviewError.textContent = msg; + reviewError.classList.remove("hidden"); + } + + function hideReviewError() { + reviewError.textContent = ""; + reviewError.classList.add("hidden"); + } + + reviewForm.addEventListener("submit", async (e) => { + e.preventDefault(); + hideReviewError(); + const action = reviewForm.querySelector("input[name='review-action']:checked")?.value; + if (!action) { + showReviewError("Please select an action."); + return; + } + const submitBtn = reviewForm.querySelector("button[type=submit]"); + submitBtn.disabled = true; + try { + const resp = await fetch(`/api/copyright-reports/${pendingReportId}/review`, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...csrfHeaders(), + }, + body: JSON.stringify({ action, resolutionNotes: reviewNotes.value.trim() || null }), + }); + if (!resp.ok) { + let msg = "Failed to submit review."; + try { const d = await resp.json(); if (d?.message) msg = d.message; } catch (_) {} + showReviewError(msg); + return; + } + closeReviewModal(); + if (typeof window.showToast === "function") { + window.showToast("Report actioned successfully.", "success"); + } + loadReports(currentPage); + } catch (err) { + showReviewError(err.message); + } finally { + submitBtn.disabled = false; + } + }); + + loadBtn.addEventListener("click", () => loadReports(0)); + prevPageBtn.addEventListener("click", () => loadReports(currentPage - 1)); + nextPageBtn.addEventListener("click", () => loadReports(currentPage + 1)); + reviewCloseBtn.addEventListener("click", closeReviewModal); + reviewCancelBtn.addEventListener("click", closeReviewModal); + reviewModal.addEventListener("click", (e) => { if (e.target === reviewModal) closeReviewModal(); }); + + function escHtml(str) { + if (!str) return ""; + return String(str) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + } +})(); diff --git a/src/main/resources/static/js/report-page.js b/src/main/resources/static/js/report-page.js new file mode 100644 index 0000000..5d57b29 --- /dev/null +++ b/src/main/resources/static/js/report-page.js @@ -0,0 +1,252 @@ +/** + * Report copyright infringement page logic. + * Three-step flow: search broadcaster → pick asset → submit DMCA form. + */ +(function () { + // ── Elements ────────────────────────────────────────────────────────────── + const stepBroadcaster = document.getElementById("step-broadcaster"); + const stepAsset = document.getElementById("step-asset"); + const stepForm = document.getElementById("step-form"); + const stepDone = document.getElementById("step-done"); + + const broadcasterInput = document.getElementById("broadcaster-search-input"); + const broadcasterBtn = document.getElementById("broadcaster-search-btn"); + const broadcasterError = document.getElementById("broadcaster-error"); + + const assetGrid = document.getElementById("asset-grid"); + const assetError = document.getElementById("asset-error"); + + const selectedAssetPreview = document.getElementById("selected-asset-preview"); + const dmcaForm = document.getElementById("dmca-form"); + const formError = document.getElementById("form-error"); + const submitBtn = document.getElementById("submit-btn"); + const backToAssetBtn = document.getElementById("back-to-asset-btn"); + + let selectedAssetId = null; + let currentBroadcaster = null; + + // ── Helpers ─────────────────────────────────────────────────────────────── + function show(el) { el.classList.remove("hidden"); } + function hide(el) { el.classList.add("hidden"); } + + function showError(el, msg) { + el.textContent = msg; + show(el); + } + + function hideError(el) { + el.textContent = ""; + hide(el); + } + + function csrfHeaders() { + const token = document.querySelector("meta[name='_csrf']")?.content ?? ""; + const header = document.querySelector("meta[name='_csrf_header']")?.content ?? "X-XSRF-TOKEN"; + return { [header]: token }; + } + + function escHtml(str) { + if (!str) return ""; + return String(str) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + } + + function assetTypeName(type) { + const names = { IMAGE: "Image", VIDEO: "Video", AUDIO: "Audio", SCRIPT: "Script", MODEL: "3D model", OTHER: "File" }; + return names[type] ?? type ?? "Asset"; + } + + // ── Step 1 – broadcaster search ─────────────────────────────────────────── + async function searchBroadcaster() { + const query = broadcasterInput.value.trim(); + if (!query) { + showError(broadcasterError, "Please enter a broadcaster username."); + return; + } + hideError(broadcasterError); + broadcasterBtn.disabled = true; + broadcasterBtn.textContent = "Searching…"; + try { + const resp = await fetch(`/api/channels?q=${encodeURIComponent(query)}`); + if (!resp.ok) throw new Error("Search failed"); + const channels = await resp.json(); + // Find an exact match first, then fall back to first result + const exact = channels.find(c => c.toLowerCase() === query.toLowerCase()); + const broadcaster = exact ?? (channels.length > 0 ? channels[0] : null); + if (!broadcaster) { + showError(broadcasterError, `No broadcaster found matching "${escHtml(query)}". Check the spelling and try again.`); + return; + } + await loadAssets(broadcaster); + } catch (err) { + showError(broadcasterError, "Could not search for broadcaster. Please try again."); + } finally { + broadcasterBtn.disabled = false; + broadcasterBtn.textContent = "Search"; + } + } + + broadcasterBtn.addEventListener("click", searchBroadcaster); + broadcasterInput.addEventListener("keydown", (e) => { + if (e.key === "Enter") { e.preventDefault(); searchBroadcaster(); } + }); + + // ── Step 2 – asset picker ───────────────────────────────────────────────── + async function loadAssets(broadcaster) { + hideError(assetError); + assetGrid.innerHTML = '

Loading assets…

'; + currentBroadcaster = broadcaster; + + try { + const resp = await fetch(`/api/channels/${encodeURIComponent(broadcaster)}/assets/visible`); + if (!resp.ok) throw new Error("Failed to load assets"); + const assets = await resp.json(); + + if (!assets || assets.length === 0) { + assetGrid.innerHTML = '

This broadcaster has no public assets.

'; + show(stepAsset); + return; + } + + assetGrid.innerHTML = ""; + for (const asset of assets) { + const card = buildAssetCard(asset, broadcaster); + assetGrid.appendChild(card); + } + + hide(stepBroadcaster); + show(stepAsset); + stepAsset.scrollIntoView({ behavior: "smooth", block: "start" }); + } catch (err) { + showError(assetError, "Could not load assets for this broadcaster. Please try again."); + } + } + + function buildAssetCard(asset, broadcaster) { + const card = document.createElement("button"); + card.type = "button"; + card.className = "report-asset-card"; + card.dataset.assetId = asset.id; + + const imgSrc = asset.previewUrl || (asset.assetType === "IMAGE" ? asset.url : null); + + let mediaHtml = ""; + if (imgSrc) { + mediaHtml = `${escHtml(asset.name)}`; + } else if (asset.assetType === "VIDEO") { + mediaHtml = `
`; + } else if (asset.assetType === "AUDIO") { + mediaHtml = `
`; + } else if (asset.assetType === "SCRIPT") { + mediaHtml = `
`; + } else { + mediaHtml = `
`; + } + + card.innerHTML = ` + ${mediaHtml} + ${escHtml(asset.name || assetTypeName(asset.assetType))} + ${escHtml(assetTypeName(asset.assetType))} + `; + + card.addEventListener("click", () => selectAsset(asset, broadcaster)); + return card; + } + + function selectAsset(asset, broadcaster) { + selectedAssetId = asset.id; + + const imgSrc = asset.previewUrl || (asset.assetType === "IMAGE" ? asset.url : null); + const thumb = imgSrc + ? `${escHtml(asset.name)}` + : `
`; + + selectedAssetPreview.innerHTML = ` +
+ ${thumb} +
+

${escHtml(asset.name || assetTypeName(asset.assetType))}

+

Broadcaster: ${escHtml(broadcaster)} — Type: ${escHtml(assetTypeName(asset.assetType))}

+
+
+ `; + + hide(stepAsset); + show(stepForm); + stepForm.scrollIntoView({ behavior: "smooth", block: "start" }); + } + + backToAssetBtn.addEventListener("click", () => { + selectedAssetId = null; + hide(stepForm); + show(stepAsset); + stepAsset.scrollIntoView({ behavior: "smooth", block: "start" }); + }); + + // ── Step 3 – DMCA form submit ───────────────────────────────────────────── + dmcaForm.addEventListener("submit", async (e) => { + e.preventDefault(); + hideError(formError); + + const claimantName = document.getElementById("claimant-name").value.trim(); + const claimantEmail = document.getElementById("claimant-email").value.trim(); + const originalWork = document.getElementById("original-work").value.trim(); + const infringing = document.getElementById("infringing-description").value.trim(); + const goodFaith = document.getElementById("good-faith").checked; + + if (!claimantName || !claimantEmail || !originalWork || !infringing) { + showError(formError, "All fields are required."); + return; + } + if (!goodFaith) { + showError(formError, "You must confirm the good faith declaration."); + return; + } + if (!selectedAssetId) { + showError(formError, "No asset selected. Please go back and select an asset."); + return; + } + + submitBtn.disabled = true; + submitBtn.textContent = "Submitting…"; + + try { + const resp = await fetch(`/api/assets/${encodeURIComponent(selectedAssetId)}/copyright-reports`, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...csrfHeaders(), + }, + body: JSON.stringify({ + claimantName, + claimantEmail, + originalWorkDescription: originalWork, + infringingDescription: infringing, + goodFaithDeclaration: true, + }), + }); + + if (!resp.ok) { + let msg = "Failed to submit your report. Please try again."; + try { + const data = await resp.json(); + if (data?.message) msg = data.message; + } catch (_) {} + showError(formError, msg); + return; + } + + hide(stepForm); + show(stepDone); + stepDone.scrollIntoView({ behavior: "smooth", block: "start" }); + } catch (err) { + showError(formError, "A network error occurred. Please check your connection and try again."); + } finally { + submitBtn.disabled = false; + submitBtn.textContent = "Submit report"; + } + }); +})(); diff --git a/src/main/resources/static/js/report.js b/src/main/resources/static/js/report.js new file mode 100644 index 0000000..853d6dd --- /dev/null +++ b/src/main/resources/static/js/report.js @@ -0,0 +1,119 @@ +/** + * Copyright infringement report form logic. + * Handles opening the modal, form validation, and submission. + */ + +const modal = document.getElementById("copyright-report-modal"); +const form = document.getElementById("copyright-report-form"); +const errorEl = document.getElementById("copyright-report-error"); +const closeBtn = document.getElementById("copyright-report-close"); +const cancelBtn = document.getElementById("copyright-report-cancel"); +const reportBtn = document.getElementById("selected-asset-report"); + +let pendingAssetId = null; + +function openReportModal(assetId) { + pendingAssetId = assetId; + form.reset(); + hideError(); + modal.classList.remove("hidden"); +} + +function closeReportModal() { + modal.classList.add("hidden"); + pendingAssetId = null; +} + +function showError(message) { + errorEl.textContent = message; + errorEl.classList.remove("hidden"); +} + +function hideError() { + errorEl.textContent = ""; + errorEl.classList.add("hidden"); +} + +async function submitReport(assetId, payload) { + const csrfToken = document.querySelector("meta[name='_csrf']")?.content ?? ""; + const csrfHeader = document.querySelector("meta[name='_csrf_header']")?.content ?? "X-XSRF-TOKEN"; + const response = await fetch(`/api/assets/${encodeURIComponent(assetId)}/copyright-reports`, { + method: "POST", + headers: { + "Content-Type": "application/json", + [csrfHeader]: csrfToken, + }, + body: JSON.stringify(payload), + }); + if (!response.ok) { + let msg = "Failed to submit report. Please try again."; + try { + const data = await response.json(); + if (data?.message) msg = data.message; + } catch (_) {} + throw new Error(msg); + } + return response.json(); +} + +form.addEventListener("submit", async (e) => { + e.preventDefault(); + hideError(); + + const claimantName = document.getElementById("copyright-claimant-name").value.trim(); + const claimantEmail = document.getElementById("copyright-claimant-email").value.trim(); + const originalWork = document.getElementById("copyright-original-work").value.trim(); + const infringing = document.getElementById("copyright-infringing").value.trim(); + const goodFaith = document.getElementById("copyright-good-faith").checked; + + if (!claimantName || !claimantEmail || !originalWork || !infringing) { + showError("All fields are required."); + return; + } + if (!goodFaith) { + showError("You must confirm the good faith declaration."); + return; + } + if (!pendingAssetId) { + showError("No asset selected."); + return; + } + + const submitBtn = form.querySelector("button[type=submit]"); + submitBtn.disabled = true; + try { + await submitReport(pendingAssetId, { + claimantName, + claimantEmail, + originalWorkDescription: originalWork, + infringingDescription: infringing, + goodFaithDeclaration: true, + }); + closeReportModal(); + if (typeof window.showToast === "function") { + window.showToast("Copyright report submitted successfully.", "success"); + } + } catch (err) { + showError(err.message); + } finally { + submitBtn.disabled = false; + } +}); + +closeBtn.addEventListener("click", closeReportModal); +cancelBtn.addEventListener("click", closeReportModal); +modal.addEventListener("click", (e) => { + if (e.target === modal) closeReportModal(); +}); + +if (reportBtn) { + reportBtn.addEventListener("click", () => { + const assetIdEl = document.getElementById("selected-asset-id"); + const assetId = assetIdEl?.textContent?.trim(); + if (assetId) { + openReportModal(assetId); + } + }); +} + +export { openReportModal, closeReportModal }; diff --git a/src/main/resources/templates/admin.html b/src/main/resources/templates/admin.html index c22449a..13a5dd1 100644 --- a/src/main/resources/templates/admin.html +++ b/src/main/resources/templates/admin.html @@ -331,16 +331,27 @@ + id="selected-asset-delete" + class="secondary danger" + type="button" + title="Delete asset" + disabled + data-audio-enabled="true" + data-code-enabled="true" + > + + + @@ -538,6 +549,48 @@ + + + diff --git a/src/main/resources/templates/settings.html b/src/main/resources/templates/settings.html index 0c75ff3..e2987da 100644 --- a/src/main/resources/templates/settings.html +++ b/src/main/resources/templates/settings.html @@ -245,9 +245,80 @@ + +
+
+
+

Copyright reports

+

Review and action DMCA-style infringement reports.

+
+
+
+ + + +
+ +
+ +