Fix OpenAPI docs, add image import, file preview modal, keyboard nav

- Fix OpenAPI YAML: correct components/schemas indentation (was inside paths:)
- Downgrade openapi version 3.1.0 → 3.0.3 for ReDoc 2.5.2 compatibility
- Add /import/images/<path> endpoint with worker-mode background download
- Add source_url field to FileUpload model (migration 0044)
- Add file preview modal with image/video/audio/unsupported cases
- Add arrow key navigation + prev/next buttons + file counter in modal
- Add /import/images/{image_url} to OpenAPI spec with source_url field

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-03-22 15:11:44 +11:00
co-authored by Copilot
parent 8777aabdc9
commit 64871ab7e0
7 changed files with 346 additions and 3 deletions
+4 -1
View File
@@ -4,7 +4,7 @@ from django.conf import settings
from django.conf.urls.static import static
from django.views.static import serve
from links.views import LinkDetailView, LinkUpdateView, CustomLinkView
from links.file_views import PublicFileView
from links.file_views import PublicFileView, import_image_view
from django.urls import path, include, re_path
from django.conf.urls.i18n import i18n_patterns
@@ -31,6 +31,9 @@ urlpatterns = [
path('ui/netscan/', include('netscan.urls')),
path('ui/files/', include('links.file_urls')),
# Import external image by URL — /import/images/<path:image_url>
path('import/images/<path:image_url>', import_image_view, name='import-image'),
# Public file access — /public/files/{uuid}-{filename}
re_path(
r'^public/files/(?P<pk>[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})-(?P<filename>.+)$',
+57
View File
@@ -205,3 +205,60 @@ class FileUploadViewSet(viewsets.ModelViewSet):
response = FileResponse(open(record.file_path, 'rb'), content_type=record.mime_type)
response['Content-Disposition'] = disposition
return response
def import_image_view(request, image_url):
"""Proxy-and-cache an external image via /import/images/<path:image_url>.
The image_url path component has no scheme (e.g. 'example.com/path/img.jpg').
On first request the view:
1. Creates a FileUpload stub in the database (is_public=True, source_url set).
2. Fires a background thread to download and save the file.
3. Immediately redirects to the original URL so the image is visible right away.
On subsequent requests, once the file is saved locally, the view redirects to
the stored public URL instead — no more dependency on the original host.
"""
import hashlib
import posixpath
from threading import Thread
from .tasks import download_and_save_image
# Build the canonical source URL (https preferred)
source_url = f'https://{image_url}'
url_hash = hashlib.sha256(source_url.encode()).hexdigest()[:20]
filename = posixpath.basename(image_url.split('?')[0]) or f'image_{url_hash}'
# Derive extension from filename; fall back to .jpg for bare names
_, ext = posixpath.splitext(filename)
if not ext:
ext = '.jpg'
filename = f'{filename}{ext}'
stored_name = f'import_{url_hash}{ext}'
# Try to find an existing record for this URL (idempotent)
record = FileUpload.objects.filter(source_url=source_url).first()
if record is None:
# Create the stub immediately so we have a stable public URL
record = FileUpload.objects.create(
name=filename,
stored_name=stored_name,
mime_type=f'image/{ext.lstrip(".") or "jpeg"}',
size=0,
is_public=True,
source_url=source_url,
)
logger.info(f"import_image_view: created FileUpload {record.pk} for {source_url}")
# If the file is already on disk, serve from local storage
if os.path.exists(record.file_path):
return redirect(record.public_url)
# File not yet saved — kick off (or re-kick) the background download
thread = Thread(target=download_and_save_image, args=(str(record.pk),), daemon=True)
thread.start()
# Redirect to the original URL as a temporary placeholder while download runs
return redirect(source_url)
@@ -0,0 +1,18 @@
# Generated by Django 5.2.11 on 2026-03-22 03:46
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('links', '0043_add_screenshot_job_timeout_to_sitesettings'),
]
operations = [
migrations.AddField(
model_name='fileupload',
name='source_url',
field=models.URLField(blank=True, db_index=True, max_length=2048, null=True, verbose_name='Source URL'),
),
]
+1
View File
@@ -350,6 +350,7 @@ class FileUpload(models.Model):
is_public = models.BooleanField(_('Is Public'), default=False, db_index=True)
public_token = models.CharField(_('Public Token'), max_length=64, unique=True, null=True, blank=True)
expires_at = models.DateTimeField(_('Expires At'), null=True, blank=True)
source_url = models.URLField(_('Source URL'), max_length=2048, null=True, blank=True, db_index=True)
download_count = models.PositiveIntegerField(_('Download Count'), default=0)
created_at = models.DateTimeField(_('Created At'), auto_now_add=True)
updated_at = models.DateTimeField(_('Updated At'), auto_now=True)
+56
View File
@@ -444,3 +444,59 @@ def fetch_webpage_content_from_crawl4ai(page_id, retry_count=0):
logger.info(f"Scheduled Crawl4AI retry {retry_count + 1} for page {page_id} in {retry_delay} seconds")
except Exception as e:
logger.error(f"Unexpected error fetching webpage content for page {page_id}: {e}", exc_info=True)
def download_and_save_image(file_upload_id):
"""Background task: download an external image and save it to FILE_UPLOADS_FOLDER.
The FileUpload record must already exist with source_url set. This task fills in
the actual file content, size, and mime_type once the download completes.
"""
from .models import FileUpload
try:
record = FileUpload.objects.get(pk=file_upload_id)
except FileUpload.DoesNotExist:
logger.error(f"download_and_save_image: FileUpload {file_upload_id} not found")
return
source_url = record.source_url
if not source_url:
logger.error(f"download_and_save_image: FileUpload {file_upload_id} has no source_url")
return
dest_path = record.file_path
if os.path.exists(dest_path):
logger.info(f"download_and_save_image: {dest_path} already exists, skipping download")
return
logger.info(f"download_and_save_image: downloading {source_url}{dest_path}")
try:
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 '
'(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
}
response = requests.get(source_url, headers=headers, timeout=60, stream=True)
response.raise_for_status()
os.makedirs(os.path.dirname(dest_path), exist_ok=True)
content = response.content
with open(dest_path, 'wb') as f:
f.write(content)
mime_type = response.headers.get('Content-Type', 'application/octet-stream').split(';')[0].strip()
FileUpload.objects.filter(pk=file_upload_id).update(
size=len(content),
mime_type=mime_type,
)
logger.info(f"download_and_save_image: saved {len(content)} bytes for FileUpload {file_upload_id}")
except Exception as exc:
logger.error(f"download_and_save_image: failed for {source_url}: {exc}", exc_info=True)
# Clean up partial file if it exists
if os.path.exists(dest_path):
try:
os.remove(dest_path)
except OSError:
pass
+151 -1
View File
@@ -73,7 +73,7 @@
<td class="px-4 py-3">
<a href="{{ file.download_url }}"
class="font-medium text-blue-600 hover:text-blue-800 hover:underline flex items-center max-w-xs"
{% if file.is_image %}target="_blank"{% endif %}>
@click.prevent="window.dispatchEvent(new CustomEvent('open-preview', {detail: {name: '{{ file.name|escapejs }}', mimeType: '{{ file.mime_type }}', url: '{{ file.download_url }}'}}))">
<svg class="w-4 h-4 mr-2 text-gray-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{% if file.is_image %}
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>
@@ -321,6 +321,112 @@
</div>
</div><!-- /HUD -->
<!-- ── File Preview Modal ─────────────────────────────────────── -->
<div x-show="previewModal.open" x-cloak
class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4"
@click.self="closePreview()"
@keydown.escape.window="closePreview()">
<!-- Modal: auto-sizes to content, never exceeds 92vw × 92vh -->
<div class="relative bg-white rounded-xl shadow-2xl flex flex-col overflow-hidden"
style="max-width:min(92vw,1280px); max-height:92vh; width:max-content; min-width:300px;">
<!-- Header -->
<div class="flex items-center justify-between px-4 py-3 border-b border-gray-100 flex-shrink-0 w-full box-border">
<h3 class="text-sm font-semibold text-gray-800 truncate mr-4 min-w-0" x-text="previewModal.name"></h3>
<button @click="closePreview()" class="text-gray-400 hover:text-gray-600 flex-shrink-0 p-0.5 rounded hover:bg-gray-100">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<!-- Body: flex-1 + min-h-0 lets it shrink properly; overflow-auto for scroll safety -->
<div class="flex-1 min-h-0 overflow-auto flex items-center justify-center bg-gray-50 relative">
<!-- Prev button -->
<button x-show="fileList.length > 1"
@click="prevFile()"
class="absolute left-2 z-10 bg-black/40 hover:bg-black/60 text-white rounded-full p-2 transition-colors"
title="Previous (←)">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
</svg>
</button>
<!-- Next button -->
<button x-show="fileList.length > 1"
@click="nextFile()"
class="absolute right-2 z-10 bg-black/40 hover:bg-black/60 text-white rounded-full p-2 transition-colors"
title="Next (→)">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</button>
<!-- Image: respects natural dimensions, capped at viewport -->
<template x-if="previewModal.open && previewModal.mimeType.startsWith('image/')">
<img :src="previewModal.url" :alt="previewModal.name"
style="display:block; max-width:min(88vw,1200px); max-height:calc(92vh - 96px); width:auto; height:auto; object-fit:contain;">
</template>
<!-- Video -->
<template x-if="previewModal.open && previewModal.mimeType.startsWith('video/')">
<video controls
style="display:block; max-width:min(88vw,1200px); max-height:calc(92vh - 96px); width:auto; height:auto;"
:src="previewModal.url"></video>
</template>
<!-- Audio -->
<template x-if="previewModal.open && previewModal.mimeType.startsWith('audio/')">
<div class="text-center space-y-6 py-10 px-8" style="width:360px;">
<svg class="w-16 h-16 text-gray-300 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"/>
</svg>
<p class="text-sm text-gray-600 font-medium" x-text="previewModal.name"></p>
<audio controls class="w-full" :src="previewModal.url"></audio>
</div>
</template>
<!-- Unsupported -->
<template x-if="previewModal.open && !previewModal.mimeType.startsWith('image/') && !previewModal.mimeType.startsWith('video/') && !previewModal.mimeType.startsWith('audio/')">
<div class="text-center space-y-4 py-12 px-10" style="width:420px;">
<svg class="w-16 h-16 text-gray-200 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
<p class="text-base font-semibold text-gray-700">{% trans "Unfortunately, we can't preview this file." %}</p>
<p class="text-sm text-gray-400">{% trans "But you can click the link below to download it." %}</p>
<a :href="previewModal.url" :download="previewModal.name"
class="inline-flex items-center px-5 py-2.5 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 transition-colors mt-2">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
</svg>
<span x-text="previewModal.name"></span>
</a>
</div>
</template>
</div>
<!-- Footer -->
<div class="px-4 py-2.5 border-t border-gray-100 flex items-center justify-between flex-shrink-0 bg-white w-full box-border">
<div class="flex items-center gap-3">
<span class="text-xs text-gray-400" x-text="previewModal.mimeType"></span>
<span x-show="fileList.length > 1" class="text-xs text-gray-400"
x-text="(currentIndex + 1) + ' / ' + fileList.length"></span>
</div>
<a :href="previewModal.url" :download="previewModal.name"
class="inline-flex items-center text-sm text-blue-600 hover:text-blue-800">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
</svg>
{% trans "Download" %}
</a>
</div>
</div>
</div>
</div><!-- /Alpine scope -->
<script>
@@ -332,8 +438,25 @@ function fileManager() {
toastVisible: false,
_toastTimer: null,
expiryModal: { open: false, pk: '', expiryInput: '' },
previewModal: { open: false, name: '', mimeType: '', url: '' },
fileList: [],
currentIndex: -1,
init() {
window.addEventListener('open-preview', e => this.openPreview(e.detail));
// Load file list for keyboard navigation
try {
this.fileList = JSON.parse(document.getElementById('file-list-data').textContent);
} catch(e) { this.fileList = []; }
// Arrow key navigation
window.addEventListener('keydown', e => {
if (!this.previewModal.open) return;
if (e.key === 'ArrowRight') { e.preventDefault(); this.nextFile(); }
else if (e.key === 'ArrowLeft') { e.preventDefault(); this.prevFile(); }
});
const overlay = document.getElementById('dragOverlay');
const input = document.getElementById('globalFileInput');
let dragCounter = 0;
@@ -404,6 +527,28 @@ function fileManager() {
setTimeout(() => { this.uploads = []; }, 800);
},
// ── Preview modal ────────────────────────────────────────────
openPreview({ name, mimeType, url }) {
this.currentIndex = this.fileList.findIndex(f => f.url === url);
this.previewModal = { open: true, name, mimeType, url };
},
prevFile() {
if (this.fileList.length < 2) return;
this.currentIndex = (this.currentIndex - 1 + this.fileList.length) % this.fileList.length;
const f = this.fileList[this.currentIndex];
this.previewModal = { open: true, name: f.name, mimeType: f.mimeType, url: f.url };
},
nextFile() {
if (this.fileList.length < 2) return;
this.currentIndex = (this.currentIndex + 1) % this.fileList.length;
const f = this.fileList[this.currentIndex];
this.previewModal = { open: true, name: f.name, mimeType: f.mimeType, url: f.url };
},
closePreview() {
this.previewModal.open = false;
document.querySelectorAll('video, audio').forEach(el => el.pause());
},
// ── Expiry modal ─────────────────────────────────────────────
openExpiry({ pk, expiresAt }) {
this.expiryModal.pk = pk;
@@ -470,4 +615,9 @@ function getCsrfToken() {
return document.cookie.split(';').find(c => c.trim().startsWith('csrftoken='))?.split('=')[1] || '';
}
</script>
<script id="file-list-data" type="application/json">
[{% for file in files %}{"name":"{{ file.name|escapejs }}","mimeType":"{{ file.mime_type|escapejs }}","url":"{{ file.download_url|escapejs }}"}{% if not forloop.last %},{% endif %}{% endfor %}]
</script>
{% endblock %}
+59 -1
View File
@@ -1,4 +1,4 @@
openapi: 3.1.0
openapi: 3.0.3
info:
title: GoLinks API
description: API documentation for GoLinks service
@@ -396,6 +396,56 @@ paths:
description: File content (inline for images, attachment for others)
'404':
description: File not found, is private, or link has expired
/import/images/{image_url}:
get:
summary: Import and cache an external image
description: |
Imports an external image by URL and saves it to the local file store.
The `image_url` path parameter is the image URL **without** the `https://` scheme prefix
(e.g. `example.com/path/to/image.jpg`). The server always tries `https://` first.
**Worker mode** — this endpoint is non-blocking:
- On the **first request** for a given URL the server creates a `FileUpload` record
(marked `is_public: true`) and kicks off a background thread to download and save the file.
The response immediately redirects (HTTP 302) to the original `https://` source URL so the
image is visible right away.
- On **subsequent requests**, once the background download has completed, the response
redirects to the locally-saved public file URL (`/public/files/{uuid}-{filename}`) so the
original host is no longer needed.
**Idempotent** — the same external URL always resolves to the same `FileUpload` record;
the file is only downloaded once.
**Use in Markdown / posts:**
```
![Alt text](http://your-domain/import/images/example.com/path/to/image.jpg)
```
tags:
- Files
parameters:
- in: path
name: image_url
required: true
schema:
type: string
description: |
External image URL without the scheme prefix.
Example: `graziamagazine.com/wp-content/uploads/2024/12/Elle-Fanning-Pigtails-scaled.jpg`
example: graziamagazine.com/wp-content/uploads/2024/12/Elle-Fanning-Pigtails-scaled.jpg
responses:
'302':
description: |
Redirect to either the original source URL (while background download is in progress)
or the locally-saved public file URL (once download has completed).
headers:
Location:
schema:
type: string
description: URL to redirect to (original source or local `/public/files/…`)
components:
schemas:
Page:
type: object
@@ -633,6 +683,14 @@ paths:
download_count:
type: integer
readOnly: true
source_url:
type: string
format: uri
nullable: true
description: |
Original external URL this file was imported from via `/import/images/…`.
`null` for files uploaded directly.
example: https://example.com/path/to/image.jpg
created_at:
type: string
format: date-time