mirror of
https://github.com/wahyd4/links.git
synced 2026-08-09 05:06:16 +10:00
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:
+4
-1
@@ -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>.+)$',
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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:**
|
||||
```
|
||||

|
||||
```
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user