feat(sharing): add document sharing with expiring links

- Add SharedLink model with token, expiry, view limit, password hash
- Add migration 025_add_shared_links
- Add API endpoints: create, list, revoke (auth) + public info/download
- Add management UI at /shared-links with revoke controls
- Add public share landing page at /share/{token}
- Add Share button on file_view.html
- Add Shared Links to user dropdown in common.js
- Write 35 unit tests covering all scenarios
- Update UserGuide.md with sharing documentation

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-03-08 21:51:30 +00:00
parent 93b3959d2f
commit 0f91b8bb7c
13 changed files with 2170 additions and 0 deletions
+15
View File
@@ -212,6 +212,9 @@ function _makeMenuLink(href, iconClass, label, extraClasses = '') {
linksDiv.appendChild(
_makeMenuLink('/api-tokens', 'fas fa-key text-yellow-500', 'API Tokens', 'text-gray-700')
);
linksDiv.appendChild(
_makeMenuLink('/shared-links', 'fas fa-share-alt text-blue-400', 'Shared Links', 'text-gray-700')
);
// Divider + Sign Out
const divider = document.createElement('div');
@@ -293,6 +296,18 @@ function _makeMenuLink(href, iconClass, label, extraClasses = '') {
tokensLink.appendChild(document.createTextNode('API Tokens'));
mobileAuthSection.appendChild(tokensLink);
// Shared Links link
const sharedLinksLink = document.createElement('a');
sharedLinksLink.href = '/shared-links';
sharedLinksLink.className =
'flex items-center px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50';
const sharedLinksIcon = document.createElement('i');
sharedLinksIcon.className = 'fas fa-share-alt mr-2 text-blue-400';
sharedLinksIcon.setAttribute('aria-hidden', 'true');
sharedLinksLink.appendChild(sharedLinksIcon);
sharedLinksLink.appendChild(document.createTextNode('Shared Links'));
mobileAuthSection.appendChild(sharedLinksLink);
// Logout link
const logoutLink = document.createElement('a');
logoutLink.href = '/logout';
+3
View File
@@ -341,6 +341,9 @@
<i class="fas fa-external-link-alt" aria-hidden="true"></i> Open original
</a>
{% endif %}
<a href="/shared-links?file_id={{ file.id }}" class="action-btn btn-secondary">
<i class="fas fa-share-alt" aria-hidden="true"></i> Share
</a>
</div>
</div>
</div>
+232
View File
@@ -0,0 +1,232 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Shared Document DocuElevate</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- Tailwind CSS -->
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet" />
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
crossorigin="anonymous" referrerpolicy="no-referrer" />
<style>
body { font-family: system-ui, sans-serif; }
</style>
</head>
<body class="bg-gray-50 min-h-screen flex flex-col items-center justify-center p-4">
<div class="w-full max-w-md">
<!-- Card -->
<div class="bg-white rounded-2xl shadow-lg p-8 space-y-6" id="share-card">
<!-- Brand header -->
<div class="flex items-center justify-center gap-2 mb-2">
<i class="fas fa-file-alt text-blue-500 text-xl" aria-hidden="true"></i>
<span class="text-lg font-bold text-gray-700">DocuElevate</span>
</div>
<!-- Loading state -->
<div id="loading-state" class="text-center py-4">
<i class="fas fa-spinner fa-spin text-blue-400 text-2xl" aria-hidden="true"></i>
<p class="mt-2 text-sm text-gray-500">Loading link…</p>
</div>
<!-- Valid link state (hidden initially) -->
<div id="valid-state" class="hidden space-y-5">
<div class="text-center">
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-blue-100 mb-4">
<i class="fas fa-file-download text-blue-500 text-2xl" aria-hidden="true"></i>
</div>
<h1 class="text-xl font-bold text-gray-900" id="file-title">Document</h1>
<p class="text-sm text-gray-500 mt-1" id="link-label"></p>
</div>
<!-- Expiry / view info -->
<div id="meta-info" class="bg-gray-50 rounded-lg p-3 text-sm text-gray-600 space-y-1"></div>
<!-- Password gate -->
<div id="password-section" class="hidden space-y-3">
<label for="pwd-input" class="block text-sm font-medium text-gray-700">
<i class="fas fa-lock text-yellow-500 mr-1" aria-hidden="true"></i>
This link is password-protected
</label>
<div class="flex gap-2">
<input
id="pwd-input"
type="password"
placeholder="Enter password"
autocomplete="current-password"
class="flex-1 px-4 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
id="pwd-submit"
type="button"
onclick="downloadWithPassword()"
class="px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
style="min-height:40px;min-width:44px;"
>
Download
</button>
</div>
<p id="pwd-error" class="text-xs text-red-600 hidden" role="alert">Incorrect password. Please try again.</p>
</div>
<!-- Direct download button (shown when no password) -->
<div id="download-section" class="hidden">
<a
id="download-btn"
href="#"
class="flex items-center justify-center gap-2 w-full px-5 py-3 bg-blue-600 text-white font-medium rounded-lg
hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors"
style="min-height:48px;"
>
<i class="fas fa-download" aria-hidden="true"></i>
Download
</a>
</div>
</div>
<!-- Expired / invalid state (hidden initially) -->
<div id="invalid-state" class="hidden text-center space-y-3">
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-red-100">
<i class="fas fa-link-slash text-red-500 text-2xl" aria-hidden="true"></i>
</div>
<h1 class="text-xl font-bold text-gray-900">Link Unavailable</h1>
<p id="invalid-reason" class="text-sm text-gray-500">This link has expired or been revoked.</p>
</div>
</div>
<p class="text-center text-xs text-gray-400 mt-6">
Shared via <a href="/" class="underline hover:text-gray-600">DocuElevate</a>
</p>
</div>
<script>
const TOKEN = {{ token | tojson }};
const INFO_URL = `/api/share/${TOKEN}/info`;
const DOWNLOAD_URL = `/api/share/${TOKEN}/download`;
async function loadLinkInfo() {
try {
const resp = await fetch(INFO_URL);
const data = await resp.json();
document.getElementById('loading-state').classList.add('hidden');
if (!resp.ok || !data.is_valid) {
showInvalid(data.is_valid === false ? null : data.detail);
return;
}
showValid(data);
} catch (err) {
document.getElementById('loading-state').classList.add('hidden');
showInvalid('Could not load link information.');
}
}
function showValid(data) {
document.getElementById('valid-state').classList.remove('hidden');
// File name / title
const titleEl = document.getElementById('file-title');
titleEl.textContent = data.original_filename || 'Document';
// Label
const labelEl = document.getElementById('link-label');
if (data.label) {
labelEl.textContent = data.label;
} else {
labelEl.classList.add('hidden');
}
// Meta info
const metaEl = document.getElementById('meta-info');
const rows = [];
if (data.expires_at) {
const d = new Date(data.expires_at);
rows.push(`<div><i class="fas fa-clock mr-1 text-gray-400" aria-hidden="true"></i> Expires: ${d.toLocaleDateString(undefined, {year:'numeric',month:'short',day:'numeric'})} ${d.toLocaleTimeString(undefined, {hour:'2-digit',minute:'2-digit'})}</div>`);
}
if (data.max_views) {
const remaining = data.max_views - data.view_count;
rows.push(`<div><i class="fas fa-download mr-1 text-gray-400" aria-hidden="true"></i> ${remaining} download${remaining !== 1 ? 's' : ''} remaining</div>`);
}
if (rows.length > 0) {
metaEl.innerHTML = rows.join('');
} else {
metaEl.classList.add('hidden');
}
if (data.has_password) {
document.getElementById('password-section').classList.remove('hidden');
} else {
const dlBtn = document.getElementById('download-btn');
dlBtn.href = DOWNLOAD_URL;
document.getElementById('download-section').classList.remove('hidden');
}
}
function showInvalid(reason) {
const el = document.getElementById('invalid-state');
el.classList.remove('hidden');
if (reason) {
document.getElementById('invalid-reason').textContent = reason;
}
}
function downloadWithPassword() {
const pwd = document.getElementById('pwd-input').value;
const errEl = document.getElementById('pwd-error');
if (!pwd) {
errEl.classList.remove('hidden');
errEl.textContent = 'Please enter the password.';
return;
}
// Attempt download — if password is wrong the server returns 403.
const url = `${DOWNLOAD_URL}?password=${encodeURIComponent(pwd)}`;
// Use a hidden iframe trick to detect errors vs. successful binary downloads.
fetch(url)
.then(async (resp) => {
if (resp.ok) {
errEl.classList.add('hidden');
// Trigger download via blob URL.
const blob = await resp.blob();
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
const disposition = resp.headers.get('content-disposition') || '';
const match = disposition.match(/filename="([^"]+)"/);
a.download = match ? match[1] : 'document';
document.body.appendChild(a);
a.click();
setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 1000);
} else if (resp.status === 403) {
errEl.classList.remove('hidden');
errEl.textContent = 'Incorrect password. Please try again.';
} else {
errEl.classList.remove('hidden');
errEl.textContent = 'Download failed. The link may have expired.';
}
})
.catch(() => {
errEl.classList.remove('hidden');
errEl.textContent = 'Network error. Please try again.';
});
}
// Allow pressing Enter in the password field.
document.addEventListener('DOMContentLoaded', function () {
const pwdInput = document.getElementById('pwd-input');
if (pwdInput) {
pwdInput.addEventListener('keydown', function (e) {
if (e.key === 'Enter') downloadWithPassword();
});
}
loadLinkInfo();
});
</script>
</body>
</html>
+451
View File
@@ -0,0 +1,451 @@
{% extends "base.html" %}
{% block title %}Shared Links DocuElevate{% endblock %}
{% block content %}
<div x-data="sharedLinks()" x-init="init(); loadLinks()" class="container mx-auto px-4 py-8 max-w-5xl">
<header class="mb-8">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
<i class="fas fa-share-alt text-blue-500" aria-hidden="true"></i>
Shared Links
</h1>
<p class="mt-2 text-gray-600 dark:text-gray-400 text-sm leading-relaxed max-w-2xl">
Share documents with anyone via a time-limited or view-limited link.
Recipients do not need a DocuElevate account. Links can be password-protected
and revoked at any time.
</p>
</header>
<!-- Create link section -->
<section class="bg-white dark:bg-gray-800 shadow rounded-lg p-6 mb-6" aria-labelledby="create-link-heading">
<h2 id="create-link-heading" class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Create New Shared Link</h2>
<form @submit.prevent="createLink()" class="space-y-4">
<!-- File ID -->
<div class="flex flex-col sm:flex-row gap-3 items-start sm:items-end">
<div class="flex-1">
<label for="file-id-input" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
File ID <span class="text-red-500" aria-hidden="true">*</span>
</label>
<input
id="file-id-input"
type="number"
x-model.number="newLink.file_id"
placeholder="e.g. 42"
required
min="1"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm
focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white text-sm"
aria-required="true"
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Find the file ID on the <a href="/files" class="underline hover:text-blue-600">Files</a> page or in the document detail URL.
</p>
</div>
<!-- Label (optional) -->
<div class="flex-1">
<label for="link-label-input" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Label <span class="text-gray-400 font-normal">(optional)</span>
</label>
<input
id="link-label-input"
type="text"
x-model="newLink.label"
placeholder="e.g. Shared with Bob"
maxlength="255"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm
focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white text-sm"
/>
</div>
</div>
<!-- Expiry + max views row -->
<div class="flex flex-col sm:flex-row gap-4">
<!-- Expiry -->
<div class="flex-1">
<label for="expires-select" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Expiry
</label>
<select
id="expires-select"
x-model.number="newLink.expires_in_hours"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm
focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white text-sm"
>
<option :value="null">Never</option>
<option value="1">1 hour</option>
<option value="6">6 hours</option>
<option value="12">12 hours</option>
<option value="24">24 hours (1 day)</option>
<option value="72">3 days</option>
<option value="168">7 days</option>
<option value="336">14 days</option>
<option value="720">30 days</option>
</select>
</div>
<!-- Max views -->
<div class="flex-1">
<label for="max-views-input" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Max downloads <span class="text-gray-400 font-normal">(optional)</span>
</label>
<input
id="max-views-input"
type="number"
x-model.number="newLink.max_views"
placeholder="Unlimited"
min="1"
max="10000"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm
focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white text-sm"
/>
</div>
<!-- Password -->
<div class="flex-1">
<label for="link-password-input" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Password <span class="text-gray-400 font-normal">(optional)</span>
</label>
<input
id="link-password-input"
type="password"
x-model="newLink.password"
placeholder="Leave blank for no password"
maxlength="128"
autocomplete="new-password"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm
focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white text-sm"
/>
</div>
</div>
<!-- Error message -->
<template x-if="createError">
<p class="text-sm text-red-600 dark:text-red-400" role="alert" x-text="createError"></p>
</template>
<div class="flex justify-end">
<button
type="submit"
:disabled="creating || !newLink.file_id"
class="inline-flex items-center px-5 py-2 bg-blue-600 text-white text-sm font-medium rounded-md
hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50
transition-colors"
style="min-height:40px; min-width:44px;"
>
<i class="fas fa-share-alt mr-2" aria-hidden="true"></i>
<span x-text="creating ? 'Creating…' : 'Create Link'"></span>
</button>
</div>
</form>
<!-- Newly created link display -->
<template x-if="newlyCreatedLink">
<div class="mt-4 bg-green-50 dark:bg-green-900/30 border border-green-300 dark:border-green-700 rounded-lg p-4" role="alert">
<div class="flex items-start gap-3">
<i class="fas fa-check-circle text-green-600 dark:text-green-400 mt-0.5 text-lg" aria-hidden="true"></i>
<div class="flex-1">
<p class="font-semibold text-green-800 dark:text-green-200 text-sm">Shared link created!</p>
<p class="text-green-700 dark:text-green-300 text-xs mt-1">
Copy and send this link to the recipient.
</p>
<div class="mt-3 flex items-center gap-2">
<code
class="flex-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded px-3 py-2
text-sm font-mono text-gray-900 dark:text-gray-100 select-all break-all"
x-text="newlyCreatedLink.share_url"
></code>
<button
type="button"
@click="copyLink()"
class="inline-flex items-center px-3 py-2 bg-gray-100 dark:bg-gray-700 border border-gray-300
dark:border-gray-600 rounded-md text-sm font-medium text-gray-700 dark:text-gray-200
hover:bg-gray-200 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500
transition-colors"
style="min-height:40px; min-width:44px;"
:aria-label="copied ? 'Copied!' : 'Copy link to clipboard'"
>
<i :class="copied ? 'fas fa-check text-green-600' : 'fas fa-copy'" aria-hidden="true"></i>
<span class="ml-1 hidden sm:inline" x-text="copied ? 'Copied!' : 'Copy'"></span>
</button>
</div>
</div>
</div>
</div>
</template>
</section>
<!-- Active links table -->
<section class="bg-white dark:bg-gray-800 shadow rounded-lg p-6" aria-labelledby="links-heading">
<div class="flex items-center justify-between mb-4">
<h2 id="links-heading" class="text-lg font-semibold text-gray-900 dark:text-white">
Your Shared Links
<span
x-show="links.length > 0"
class="ml-2 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200"
x-text="links.length"
aria-label="number of links"
></span>
</h2>
<button
@click="loadLinks()"
type="button"
class="text-sm text-blue-600 hover:underline focus:outline-none focus:ring-2 focus:ring-blue-500 rounded"
aria-label="Refresh shared links list"
>
<i class="fas fa-sync-alt mr-1" aria-hidden="true"></i>Refresh
</button>
</div>
<!-- Loading state -->
<template x-if="loading">
<p class="text-sm text-gray-400 dark:text-gray-500 py-4 text-center">
<i class="fas fa-spinner fa-spin mr-2" aria-hidden="true"></i>Loading…
</p>
</template>
<!-- Empty state -->
<template x-if="!loading && links.length === 0">
<div class="text-center py-10 text-gray-400 dark:text-gray-500">
<i class="fas fa-share-alt text-3xl mb-3" aria-hidden="true"></i>
<p class="text-sm">No shared links yet. Create one above to get started.</p>
</div>
</template>
<!-- Links list -->
<template x-if="!loading && links.length > 0">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700 text-sm" aria-label="Shared links">
<thead>
<tr class="text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
<th scope="col" class="pb-3 pr-4">File / Label</th>
<th scope="col" class="pb-3 pr-4">Link</th>
<th scope="col" class="pb-3 pr-4">Expiry</th>
<th scope="col" class="pb-3 pr-4">Views</th>
<th scope="col" class="pb-3 pr-4">Status</th>
<th scope="col" class="pb-3">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 dark:divide-gray-700">
<template x-for="link in links" :key="link.id">
<tr class="hover:bg-gray-50 dark:hover:bg-gray-750">
<!-- File / Label -->
<td class="py-3 pr-4 max-w-xs">
<p class="font-medium text-gray-800 dark:text-gray-200 truncate" x-text="link.original_filename || ('File #' + link.file_id)"></p>
<p x-show="link.label" class="text-xs text-gray-500 dark:text-gray-400 truncate" x-text="link.label"></p>
<p x-show="link.has_password" class="text-xs text-yellow-600 dark:text-yellow-400 mt-0.5">
<i class="fas fa-lock text-xs" aria-hidden="true"></i> Password protected
</p>
</td>
<!-- Link -->
<td class="py-3 pr-4">
<div class="flex items-center gap-1">
<code class="text-xs text-blue-600 dark:text-blue-400 truncate max-w-[140px]" x-text="link.share_url"></code>
<button
type="button"
@click="copyUrl(link.share_url)"
class="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 focus:outline-none focus:ring-1 focus:ring-blue-500 rounded"
aria-label="Copy link"
title="Copy link"
style="min-height:32px;min-width:32px;"
>
<i class="fas fa-copy text-xs" aria-hidden="true"></i>
</button>
<a
:href="link.share_url"
target="_blank"
rel="noopener noreferrer"
class="p-1 text-gray-400 hover:text-blue-600 focus:outline-none focus:ring-1 focus:ring-blue-500 rounded"
aria-label="Open shared link"
title="Open in new tab"
style="min-height:32px;min-width:32px;"
>
<i class="fas fa-external-link-alt text-xs" aria-hidden="true"></i>
</a>
</div>
</td>
<!-- Expiry -->
<td class="py-3 pr-4 whitespace-nowrap text-gray-600 dark:text-gray-400">
<span x-text="formatExpiry(link)"></span>
</td>
<!-- Views -->
<td class="py-3 pr-4 whitespace-nowrap text-gray-600 dark:text-gray-400">
<span x-text="link.view_count"></span>
<span x-show="link.max_views" x-text="' / ' + link.max_views"></span>
</td>
<!-- Status badge -->
<td class="py-3 pr-4 whitespace-nowrap">
<template x-if="!link.is_active">
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300">
Revoked
</span>
</template>
<template x-if="link.is_active && isExpired(link)">
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300">
Expired
</span>
</template>
<template x-if="link.is_active && isViewLimitReached(link)">
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-300">
Limit reached
</span>
</template>
<template x-if="link.is_active && !isExpired(link) && !isViewLimitReached(link)">
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300">
Active
</span>
</template>
</td>
<!-- Actions -->
<td class="py-3 whitespace-nowrap">
<button
x-show="link.is_active"
type="button"
@click="revokeLink(link.id)"
class="inline-flex items-center px-3 py-1.5 text-xs font-medium text-red-600 dark:text-red-400
border border-red-300 dark:border-red-700 rounded hover:bg-red-50 dark:hover:bg-red-900/20
focus:outline-none focus:ring-2 focus:ring-red-500 transition-colors"
style="min-height:32px;min-width:44px;"
:aria-label="'Revoke shared link for ' + (link.original_filename || 'file')"
>
<i class="fas fa-ban mr-1" aria-hidden="true"></i> Revoke
</button>
<span x-show="!link.is_active" class="text-xs text-gray-400 dark:text-gray-500"></span>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</template>
</section>
</div>
<script>
function sharedLinks() {
return {
links: [],
loading: false,
creating: false,
createError: null,
newlyCreatedLink: null,
copied: false,
newLink: {
file_id: null,
label: '',
expires_in_hours: null,
max_views: null,
password: '',
},
init() {
// Pre-fill file_id from URL query parameter ?file_id=N
const params = new URLSearchParams(window.location.search);
const fid = params.get('file_id');
if (fid) this.newLink.file_id = parseInt(fid, 10);
},
async loadLinks() {
this.loading = true;
try {
const resp = await fetch('/api/shared-links/', {
headers: { 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content || '' },
});
if (!resp.ok) throw new Error('Failed to load links');
this.links = await resp.json();
} catch (err) {
console.error('Failed to load shared links', err);
} finally {
this.loading = false;
}
},
async createLink() {
this.creating = true;
this.createError = null;
this.newlyCreatedLink = null;
try {
const body = {
file_id: this.newLink.file_id,
};
if (this.newLink.label && this.newLink.label.trim()) body.label = this.newLink.label.trim();
if (this.newLink.expires_in_hours) body.expires_in_hours = parseInt(this.newLink.expires_in_hours);
if (this.newLink.max_views) body.max_views = parseInt(this.newLink.max_views);
if (this.newLink.password && this.newLink.password.trim()) body.password = this.newLink.password;
const resp = await fetch('/api/shared-links/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content || '',
},
body: JSON.stringify(body),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
this.createError = err.detail || 'Failed to create link';
return;
}
const data = await resp.json();
this.newlyCreatedLink = data;
this.newLink = { file_id: null, label: '', expires_in_hours: null, max_views: null, password: '' };
await this.loadLinks();
} finally {
this.creating = false;
}
},
async revokeLink(id) {
if (!confirm('Revoke this link? Recipients will no longer be able to use it.')) return;
try {
const resp = await fetch(`/api/shared-links/${id}`, {
method: 'DELETE',
headers: { 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content || '' },
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
alert(err.detail || 'Failed to revoke link');
return;
}
await this.loadLinks();
} catch (err) {
alert('Error revoking link');
}
},
copyLink() {
if (!this.newlyCreatedLink) return;
navigator.clipboard.writeText(this.newlyCreatedLink.share_url).then(() => {
this.copied = true;
setTimeout(() => { this.copied = false; }, 2000);
});
},
copyUrl(url) {
navigator.clipboard.writeText(url).then(() => {
// Brief visual feedback handled by the button icon change.
});
},
formatExpiry(link) {
if (!link.expires_at) return 'Never';
const d = new Date(link.expires_at);
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }) +
' ' + d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
},
isExpired(link) {
if (!link.expires_at) return false;
return new Date(link.expires_at) < new Date();
},
isViewLimitReached(link) {
return link.max_views !== null && link.view_count >= link.max_views;
},
};
}
</script>
{% endblock %}