diff --git a/backend/libs/handlers/admin.go b/backend/libs/handlers/admin.go index a7d2206..6d9a133 100644 --- a/backend/libs/handlers/admin.go +++ b/backend/libs/handlers/admin.go @@ -140,6 +140,33 @@ func (a *App) AdminDeleteBox(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/admin/files", http.StatusSeeOther) } +func (a *App) AdminViewBox(w http.ResponseWriter, r *http.Request) { + if !a.requireAdmin(w, r) { + return + } + + box, err := a.uploadService.GetBox(r.PathValue("boxID")) + if err != nil { + http.NotFound(w, r) + return + } + + if a.uploadService.IsProtected(box) { + http.SetCookie(w, &http.Cookie{ + Name: unlockCookieName(box.ID), + Value: a.uploadService.UnlockToken(box), + Path: "/d/" + box.ID, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + Secure: r.TLS != nil, + Expires: box.ExpiresAt, + }) + a.logger.Info("admin bypassed box password", "source", "admin", "severity", "user_activity", "code", 2302, "box_id", box.ID) + } + + http.Redirect(w, r, "/d/"+box.ID, http.StatusSeeOther) +} + func (a *App) renderAdminLogin(w http.ResponseWriter, status int, message string) { a.renderer.Render(w, status, "admin_login.html", web.PageData{ Title: "Admin login", diff --git a/backend/libs/handlers/app.go b/backend/libs/handlers/app.go index 65c8987..4fa9283 100644 --- a/backend/libs/handlers/app.go +++ b/backend/libs/handlers/app.go @@ -32,6 +32,7 @@ func (a *App) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("POST /admin/logout", a.AdminLogout) mux.HandleFunc("GET /admin", a.AdminDashboard) mux.HandleFunc("GET /admin/files", a.AdminFiles) + mux.HandleFunc("GET /admin/boxes/{boxID}/view", a.AdminViewBox) mux.HandleFunc("POST /admin/boxes/{boxID}/delete", a.AdminDeleteBox) mux.HandleFunc("GET /d/{boxID}", a.DownloadPage) mux.HandleFunc("POST /d/{boxID}/unlock", a.UnlockBox) diff --git a/backend/static/css/app.css b/backend/static/css/app.css index 00cb6c4..ade26d0 100644 --- a/backend/static/css/app.css +++ b/backend/static/css/app.css @@ -587,6 +587,16 @@ code { text-decoration: none; } +.file-actions { + display: inline-flex; + align-items: center; + gap: 0.5rem; +} + +.preview-action [hidden] { + display: none; +} + .file-browser.is-thumbs { grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr)); } @@ -606,10 +616,91 @@ code { width: 100%; } +.file-browser.is-thumbs .file-actions { + width: 100%; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + .file-browser.images-only .file-card:not([data-kind="image"]) { display: none; } +.context-menu { + position: fixed; + z-index: 30; + width: 10.75rem; + overflow: hidden; + border: 1px solid var(--border); + border-radius: calc(var(--radius) - 0.125rem); + background: color-mix(in srgb, var(--card) 96%, #000); + box-shadow: 0 18px 48px rgba(0, 0, 0, 0.46); + padding: 0.4rem; +} + +.context-menu[hidden] { + display: none; +} + +.context-menu button { + width: 100%; + min-height: 2.05rem; + justify-content: flex-start; + border-radius: calc(var(--radius) - 0.25rem); + padding: 0.42rem 0.5rem; + color: var(--foreground); + font-size: 0.8rem; +} + +.context-menu button:hover, +.context-menu button:focus-visible, +.context-menu button.is-copied { + background: var(--accent); +} + +.context-menu-top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + padding: 0.1rem 0.1rem 0.2rem 0.45rem; +} + +.context-menu-top small { + color: color-mix(in srgb, var(--muted-foreground) 74%, transparent); + font-size: 0.72rem; + font-weight: 600; +} + +.context-menu-icons { + display: inline-flex; + align-items: center; + gap: 0.2rem; +} + +.context-menu-icons button { + width: 1.9rem; + min-height: 1.9rem; + padding: 0; + justify-content: center; +} + +.context-menu hr { + height: 1px; + margin: 0.35rem 0.2rem; + border: 0; + background: var(--border); +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; +} + .unlock-form { margin: 1rem auto 0; display: grid; diff --git a/backend/static/js/app.js b/backend/static/js/app.js index d98a935..0e3c457 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -15,6 +15,10 @@ const fileBrowser = document.querySelector("[data-file-browser]"); const viewButtons = document.querySelectorAll("[data-view-button]"); const previewImages = document.querySelector("[data-preview-images]"); + const previewActions = document.querySelectorAll("[data-preview-action]"); + const fileContextMenu = document.querySelector("[data-file-context-menu]"); + let ctrlCopyMode = false; + let contextFile = null; if (fileBrowser) { viewButtons.forEach((button) => { @@ -34,6 +38,80 @@ } } + if (fileBrowser && fileContextMenu) { + fileBrowser.addEventListener("contextmenu", (event) => { + const card = event.target.closest("[data-file-context]"); + if (!card) { + return; + } + + event.preventDefault(); + contextFile = { + previewURL: card.dataset.previewUrl, + viewURL: card.dataset.viewUrl, + downloadURL: card.dataset.downloadUrl, + fileName: card.dataset.fileName, + }; + showContextMenu(event.clientX, event.clientY); + }); + + fileContextMenu.addEventListener("click", async (event) => { + const button = event.target.closest("[data-context-action]"); + if (!button || !contextFile) { + return; + } + + const shouldHide = await runContextAction(button.dataset.contextAction, contextFile); + if (shouldHide !== false) { + hideContextMenu(); + } + }); + + document.addEventListener("click", (event) => { + if (!fileContextMenu.contains(event.target)) { + hideContextMenu(); + } + }); + + document.addEventListener("keydown", (event) => { + if (event.key === "Escape") { + hideContextMenu(); + } + }); + + window.addEventListener("resize", hideContextMenu); + window.addEventListener("scroll", hideContextMenu, true); + } + + if (previewActions.length > 0) { + previewActions.forEach((button) => { + button.addEventListener("click", async (event) => { + if (!event.ctrlKey && !ctrlCopyMode) { + return; + } + + event.preventDefault(); + await copyPreviewLink(button); + }); + }); + + window.addEventListener("keydown", (event) => { + if (event.key === "Control") { + setPreviewCopyMode(true); + } + }); + + window.addEventListener("keyup", (event) => { + if (event.key === "Control") { + setPreviewCopyMode(false); + } + }); + + window.addEventListener("blur", () => { + setPreviewCopyMode(false); + }); + } + if (!form || !dropZone || !fileInput) { return; } @@ -267,7 +345,7 @@ if (!text) { return; } - await navigator.clipboard.writeText(text); + await writeClipboard(text); const previous = button.textContent; button.textContent = copiedLabel; setTimeout(() => { @@ -275,6 +353,102 @@ }, 1400); } + async function copyPreviewLink(button) { + await writeClipboard(button.href); + const label = button.querySelector("[data-preview-label]"); + if (!label) { + return; + } + + label.textContent = "Copied"; + setTimeout(() => { + label.textContent = ctrlCopyMode ? button.dataset.copyLabel || "Copy link" : button.dataset.viewLabel || "View"; + }, 1200); + } + + function setPreviewCopyMode(enabled) { + ctrlCopyMode = enabled; + previewActions.forEach((button) => { + const label = button.querySelector("[data-preview-label]"); + const viewIcon = button.querySelector("[data-preview-view-icon]"); + const copyIcon = button.querySelector("[data-preview-copy-icon]"); + if (label) { + label.textContent = enabled ? button.dataset.copyLabel || "Copy link" : button.dataset.viewLabel || "View"; + } + if (viewIcon) { + viewIcon.hidden = enabled; + } + if (copyIcon) { + copyIcon.hidden = !enabled; + } + }); + } + + async function runContextAction(action, file) { + if (action === "preview") { + openInNewTab(file.previewURL); + return true; + } + if (action === "view") { + openInNewTab(file.viewURL); + return true; + } + if (action === "copy-preview") { + await writeClipboard(file.previewURL); + return true; + } + if (action === "copy-download") { + await writeClipboard(file.downloadURL); + return true; + } + if (action === "download") { + openInNewTab(file.downloadURL); + } + return true; + } + + function showContextMenu(x, y) { + fileContextMenu.hidden = false; + fileContextMenu.style.left = "0px"; + fileContextMenu.style.top = "0px"; + + const rect = fileContextMenu.getBoundingClientRect(); + const margin = 8; + const left = Math.min(x, window.innerWidth - rect.width - margin); + const top = Math.min(y, window.innerHeight - rect.height - margin); + fileContextMenu.style.left = `${Math.max(margin, left)}px`; + fileContextMenu.style.top = `${Math.max(margin, top)}px`; + } + + function hideContextMenu() { + if (!fileContextMenu || fileContextMenu.hidden) { + return; + } + fileContextMenu.hidden = true; + contextFile = null; + } + + function openInNewTab(url) { + window.open(url, "_blank", "noopener,noreferrer"); + } + + async function writeClipboard(text) { + if (navigator.clipboard && window.isSecureContext) { + await navigator.clipboard.writeText(text); + return; + } + + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.setAttribute("readonly", ""); + textarea.style.position = "fixed"; + textarea.style.opacity = "0"; + document.body.append(textarea); + textarea.select(); + document.execCommand("copy"); + textarea.remove(); + } + function formatDate(value) { const date = new Date(value); if (Number.isNaN(date.getTime())) { diff --git a/backend/templates/pages/admin.html b/backend/templates/pages/admin.html index 61892f5..3502859 100644 --- a/backend/templates/pages/admin.html +++ b/backend/templates/pages/admin.html @@ -77,7 +77,7 @@ {{if .Protected}}protected{{end}}
{{.Data.ExpiresLabel}}
{{end}}