diff --git a/core/urls.py b/core/urls.py index e887865..5fe99a1 100644 --- a/core/urls.py +++ b/core/urls.py @@ -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('import/images/', import_image_view, name='import-image'), + # Public file access — /public/files/{uuid}-{filename} re_path( r'^public/files/(?P[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})-(?P.+)$', diff --git a/links/file_views.py b/links/file_views.py index 99d2a9a..5ddc232 100644 --- a/links/file_views.py +++ b/links/file_views.py @@ -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/. + + 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) diff --git a/links/migrations/0044_add_source_url_to_fileupload.py b/links/migrations/0044_add_source_url_to_fileupload.py new file mode 100644 index 0000000..ffe2275 --- /dev/null +++ b/links/migrations/0044_add_source_url_to_fileupload.py @@ -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'), + ), + ] diff --git a/links/models.py b/links/models.py index 0aa3999..fa9a414 100644 --- a/links/models.py +++ b/links/models.py @@ -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) diff --git a/links/tasks.py b/links/tasks.py index 0e2ac1b..de934d3 100644 --- a/links/tasks.py +++ b/links/tasks.py @@ -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 diff --git a/links/templates/links/files/list.html b/links/templates/links/files/list.html index f590f55..e2b55d9 100644 --- a/links/templates/links/files/list.html +++ b/links/templates/links/files/list.html @@ -73,7 +73,7 @@ + @click.prevent="window.dispatchEvent(new CustomEvent('open-preview', {detail: {name: '{{ file.name|escapejs }}', mimeType: '{{ file.mime_type }}', url: '{{ file.download_url }}'}}))"> {% if file.is_image %} @@ -321,6 +321,112 @@ + + + + + + {% endblock %} diff --git a/static/openapi.yaml b/static/openapi.yaml index e30e958..1093137 100644 --- a/static/openapi.yaml +++ b/static/openapi.yaml @@ -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