Repository: sissbruecker/linkding
Pull request #1003 (2025-03-05 19:33:52)

Add bookmark assets API
Closes https://github.com/sissbruecker/linkding/issues/718


Patch:
diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml
index 98be7fe..38de293 100644
--- a/.github/workflows/build.yaml
+++ b/.github/workflows/build.yaml
@@ -3,24 +3,45 @@ name: build
 on: workflow_dispatch
 
 jobs:
-  docker:
+  build:
     runs-on: ubuntu-latest
     steps:
-      -
-        name: Checkout
+      - name: Login to Docker Hub
+        uses: docker/login-action@v3
+        with:
+          username: ${{ vars.DOCKERHUB_USERNAME }}
+          password: ${{ secrets.DOCKERHUB_TOKEN }}
+      - name: Checkout
         uses: actions/checkout@v4
-      -
-        name: Set up QEMU
+      - name: Set up QEMU
         uses: docker/setup-qemu-action@v3
-      -
-        name: Set up Docker Buildx
+      - name: Set up Docker Buildx
         uses: docker/setup-buildx-action@v3
-      -
-        name: Build and push
+      - name: Build latest
         uses: docker/build-push-action@v6
         with:
           file: ./docker/default.Dockerfile
           platforms: linux/amd64,linux/arm64,linux/arm/v7
-          push: false
           tags: sissbruecker/linkding:test
           target: linkding
+      - name: Build latest-alpine
+        uses: docker/build-push-action@v6
+        with:
+          file: ./docker/alpine.Dockerfile
+          platforms: linux/amd64,linux/arm64,linux/arm/v7
+          tags: sissbruecker/linkding:test-alpine
+          target: linkding
+      - name: Build latest-plus
+        uses: docker/build-push-action@v6
+        with:
+          file: ./docker/default.Dockerfile
+          platforms: linux/amd64,linux/arm64,linux/arm/v7
+          tags: sissbruecker/linkding:test
+          target: linkding-plus
+      - name: Build latest-plus-alpine
+        uses: docker/build-push-action@v6
+        with:
+          file: ./docker/alpine.Dockerfile
+          platforms: linux/amd64,linux/arm64,linux/arm/v7
+          tags: sissbruecker/linkding:test-plus-alpine
+          target: linkding-plus
diff --git a/bookmarks/api/routes.py b/bookmarks/api/routes.py
index 0b9b5b8..666729d 100644
--- a/bookmarks/api/routes.py
+++ b/bookmarks/api/routes.py
@@ -1,18 +1,23 @@
+import gzip
 import logging
+import os
 
+from django.conf import settings
+from django.http import FileResponse, Http404
 from rest_framework import viewsets, mixins, status
 from rest_framework.decorators import action
 from rest_framework.permissions import AllowAny
 from rest_framework.response import Response
-from rest_framework.routers import DefaultRouter
+from rest_framework.routers import SimpleRouter, DefaultRouter
 
 from bookmarks import queries
 from bookmarks.api.serializers import (
     BookmarkSerializer,
+    BookmarkAssetSerializer,
     TagSerializer,
     UserProfileSerializer,
 )
-from bookmarks.models import Bookmark, BookmarkSearch, Tag, User
+from bookmarks.models import Bookmark, BookmarkAsset, BookmarkSearch, Tag, User
 from bookmarks.services import assets, bookmarks, auto_tagging, website_loader
 
 logger = logging.getLogger(__name__)
@@ -127,6 +132,11 @@ class BookmarkViewSet(
 
     @action(methods=["post"], detail=False)
     def singlefile(self, request):
+        if settings.LD_DISABLE_ASSET_UPLOAD:
+            return Response(
+                {"error": "Asset upload is disabled."},
+                status=status.HTTP_403_FORBIDDEN,
+            )
         url = request.data.get("url")
         file = request.FILES.get("file")
 
@@ -153,6 +163,86 @@ class BookmarkViewSet(
         )
 
 
+class BookmarkAssetViewSet(
+    viewsets.GenericViewSet,
+    mixins.ListModelMixin,
+    mixins.RetrieveModelMixin,
+    mixins.DestroyModelMixin,
+):
+    serializer_class = BookmarkAssetSerializer
+
+    def get_queryset(self):
+        user = self.request.user
+        bookmark_id = self.kwargs["bookmark_id"]
+        if not Bookmark.objects.filter(id=bookmark_id, owner=user).exists():
+            raise Http404("Bookmark does not exist")
+        return BookmarkAsset.objects.filter(
+            bookmark_id=bookmark_id, bookmark__owner=user
+        )
+
+    def get_serializer_context(self):
+        return {"user": self.request.user}
+
+    @action(detail=True, methods=["get"], url_path="download")
+    def download(self, request, bookmark_id, pk):
+        asset = self.get_object()
+        try:
+            file_path = os.path.join(settings.LD_ASSET_FOLDER, asset.file)
+            content_type = asset.content_type
+            file_stream = (
+                gzip.GzipFile(file_path, mode="rb")
+                if asset.gzip
+                else open(file_path, "rb")
+            )
+            file_name = (
+                f"{asset.display_name}.html"
+                if asset.asset_type == BookmarkAsset.TYPE_SNAPSHOT
+                else asset.display_name
+            )
+            response = FileResponse(file_stream, content_type=content_type)
+            response["Content-Disposition"] = f'attachment; filename="{file_name}"'
+            return response
+        except FileNotFoundError:
+            raise Http404("Asset file does not exist")
+        except Exception as e:
+            logger.error(
+                f"Failed to download asset. bookmark_id={bookmark_id}, asset_id={pk}",
+                exc_info=e,
+            )
+            return Response(status=status.HTTP_500_INTERNAL_SERVER_ERROR)
+
+    @action(methods=["post"], detail=False)
+    def upload(self, request, bookmark_id):
+        if settings.LD_DISABLE_ASSET_UPLOAD:
+            return Response(
+                {"error": "Asset upload is disabled."},
+                status=status.HTTP_403_FORBIDDEN,
+            )
+        bookmark = Bookmark.objects.filter(id=bookmark_id, owner=request.user).first()
+        if not bookmark:
+            raise Http404("Bookmark does not exist")
+
+        upload_file = request.FILES.get("file")
+        if not upload_file:
+            return Response(
+                {"error": "No file provided."}, status=status.HTTP_400_BAD_REQUEST
+            )
+
+        try:
+            asset = assets.upload_asset(bookmark, upload_file)
+            serializer = self.get_serializer(asset)
+            return Response(serializer.data, status=status.HTTP_201_CREATED)
+        except Exception as e:
+            logger.error(
+                f"Failed to upload asset file. bookmark_id={bookmark_id}, file={upload_file.name}",
+                exc_info=e,
+            )
+            return Response(
+                {"error": "Failed to upload asset."},
+                status=status.HTTP_500_INTERNAL_SERVER_ERROR,
+            )
+
+
 class TagViewSet(
     viewsets.GenericViewSet,
     mixins.ListModelMixin,
@@ -175,7 +265,19 @@ class UserViewSet(viewsets.GenericViewSet):
         return Response(UserProfileSerializer(request.user.profile).data)
 
 
-router = DefaultRouter()
-router.register(r"bookmarks", BookmarkViewSet, basename="bookmark")
-router.register(r"tags", TagViewSet, basename="tag")
-router.register(r"user", UserViewSet, basename="user")
+# DRF routers do not support nested view sets such as /bookmarks/<id>/assets/<id>/
+# Instead create separate routers for each view set and manually register them in urls.py
+# The default router is only used to allow reversing a URL for the API root
+default_router = DefaultRouter()
+
+bookmark_router = SimpleRouter()
+bookmark_router.register("", BookmarkViewSet, basename="bookmark")
+
+tag_router = SimpleRouter()
+tag_router.register("", TagViewSet, basename="tag")
+
+user_router = SimpleRouter()
+user_router.register("", UserViewSet, basename="user")
+
+bookmark_asset_router = SimpleRouter()
+bookmark_asset_router.register("", BookmarkAssetViewSet, basename="bookmark_asset")
diff --git a/bookmarks/api/serializers.py b/bookmarks/api/serializers.py
index a464a85..905cdbc 100644
--- a/bookmarks/api/serializers.py
+++ b/bookmarks/api/serializers.py
@@ -3,7 +3,7 @@ from django.templatetags.static import static
 from rest_framework import serializers
 from rest_framework.serializers import ListSerializer
 
-from bookmarks.models import Bookmark, Tag, build_tag_string, UserProfile
+from bookmarks.models import Bookmark, BookmarkAsset, Tag, build_tag_string, UserProfile
 from bookmarks.services import bookmarks
 from bookmarks.services.tags import get_or_create_tag
 from bookmarks.services.wayback import generate_fallback_webarchive_url
@@ -143,6 +143,21 @@ class BookmarkSerializer(serializers.ModelSerializer):
         return attrs
 
 
+class BookmarkAssetSerializer(serializers.ModelSerializer):
+    class Meta:
+        model = BookmarkAsset
+        fields = [
+            "id",
+            "bookmark",
+            "date_created",
+            "file_size",
+            "asset_type",
+            "content_type",
+            "display_name",
+            "status",
+        ]
+
+
 class TagSerializer(serializers.ModelSerializer):
     class Meta:
         model = Tag
diff --git a/bookmarks/templates/bookmarks/details/assets.html b/bookmarks/templates/bookmarks/details/assets.html
index ee59119..70a5c92 100644
--- a/bookmarks/templates/bookmarks/details/assets.html
+++ b/bookmarks/templates/bookmarks/details/assets.html
@@ -38,9 +38,11 @@
                 {% if details.has_pending_assets %}disabled{% endif %}>Create HTML snapshot
         </button>
       {% endif %}
-      <button ld-upload-button id="upload-asset" name="upload_asset" value="{{ details.bookmark.id }}" type="submit"
-              class="btn btn-sm">Upload file
-      </button>
+      {% if details.uploads_enabled %}
+        <button ld-upload-button id="upload-asset" name="upload_asset" value="{{ details.bookmark.id }}" type="submit"
+                class="btn btn-sm">Upload file
+        </button>
+      {% endif %}
       <input id="upload-asset-file" name="upload_asset_file" type="file" class="d-hide">
     </div>
   {% endif %}
diff --git a/bookmarks/urls.py b/bookmarks/urls.py
index 0372e19..16a7392 100644
--- a/bookmarks/urls.py
+++ b/bookmarks/urls.py
@@ -2,7 +2,7 @@ from django.urls import path, include
 from django.urls import re_path
 
 from bookmarks import views
-from bookmarks.api.routes import router
+from bookmarks.api import routes as api_routes
 from bookmarks.feeds import (
     AllBookmarksFeed,
     UnreadBookmarksFeed,
@@ -55,7 +55,14 @@ urlpatterns = [
     # Toasts
     path("toasts/acknowledge", views.toasts.acknowledge, name="toasts.acknowledge"),
     # API
-    path("api/", include(router.urls), name="api"),
+    path("api/", include(api_routes.default_router.urls)),
+    path("api/bookmarks/", include(api_routes.bookmark_router.urls)),
+    path(
+        "api/bookmarks/<int:bookmark_id>/assets/",
+        include(api_routes.bookmark_asset_router.urls),
+    ),
+    path("api/tags/", include(api_routes.tag_router.urls)),
+    path("api/user/", include(api_routes.user_router.urls)),
     # Feeds
     path("feeds/<str:feed_key>/all", AllBookmarksFeed(), name="feeds.all"),
     path("feeds/<str:feed_key>/unread", UnreadBookmarksFeed(), name="feeds.unread"),
diff --git a/bookmarks/views/bookmarks.py b/bookmarks/views/bookmarks.py
index 6f5610b..52108f4 100644
--- a/bookmarks/views/bookmarks.py
+++ b/bookmarks/views/bookmarks.py
@@ -1,5 +1,6 @@
 import urllib.parse
 
+from django.conf import settings
 from django.contrib.auth.decorators import login_required
 from django.db.models import QuerySet
 from django.http import (
@@ -278,6 +279,9 @@ def create_html_snapshot(request, bookmark_id: int):
 
 
 def upload_asset(request, bookmark_id: int):
+    if settings.LD_DISABLE_ASSET_UPLOAD:
+        return HttpResponseForbidden("Asset upload is disabled")
+
     try:
         bookmark = Bookmark.objects.get(pk=bookmark_id, owner=request.user)
     except Bookmark.DoesNotExist:
@@ -285,7 +289,7 @@ def upload_asset(request, bookmark_id: int):
 
     file = request.FILES.get("upload_asset_file")
     if not file:
-        raise ValueError("No file uploaded")
+        return HttpResponseBadRequest("No file provided")
 
     asset_actions.upload_asset(bookmark, file)
 
@@ -315,7 +319,10 @@ def update_state(request, bookmark_id: int):
 def index_action(request):
     search = BookmarkSearch.from_request(request.GET)
     query = queries.query_bookmarks(request.user, request.user_profile, search)
-    handle_action(request, query)
+
+    response = handle_action(request, query)
+    if response:
+        return response
 
     if turbo.accept(request):
         return partials.active_bookmark_update(request)
@@ -327,7 +334,10 @@ def index_action(request):
 def archived_action(request):
     search = BookmarkSearch.from_request(request.GET)
     query = queries.query_archived_bookmarks(request.user, request.user_profile, search)
-    handle_action(request, query)
+
+    response = handle_action(request, query)
+    if response:
+        return response
 
     if turbo.accept(request):
         return partials.archived_bookmark_update(request)
@@ -340,7 +350,9 @@ def shared_action(request):
     if "bulk_execute" in request.POST:
         return HttpResponseBadRequest("View does not support bulk actions")
 
-    handle_action(request)
+    response = handle_action(request)
+    if response:
+        return response
 
     if turbo.accept(request):
         return partials.shared_bookmark_update(request)
@@ -351,25 +363,25 @@ def shared_action(request):
 def handle_action(request, query: QuerySet[Bookmark] = None):
     # Single bookmark actions
     if "archive" in request.POST:
-        archive(request, request.POST["archive"])
+        return archive(request, request.POST["archive"])
     if "unarchive" in request.POST:
-        unarchive(request, request.POST["unarchive"])
+        return unarchive(request, request.POST["unarchive"])
     if "remove" in request.POST:
-        remove(request, request.POST["remove"])
+        return remove(request, request.POST["remove"])
     if "mark_as_read" in request.POST:
-        mark_as_read(request, request.POST["mark_as_read"])
+        return mark_as_read(request, request.POST["mark_as_read"])
     if "unshare" in request.POST:
-        unshare(request, request.POST["unshare"])
+        return unshare(request, request.POST["unshare"])
     if "create_html_snapshot" in request.POST:
-        create_html_snapshot(request, request.POST["create_html_snapshot"])
+        return create_html_snapshot(request, request.POST["create_html_snapshot"])
     if "upload_asset" in request.POST:
-        upload_asset(request, request.POST["upload_asset"])
+        return upload_asset(request, request.POST["upload_asset"])
     if "remove_asset" in request.POST:
-        remove_asset(request, request.POST["remove_asset"])
+        return remove_asset(request, request.POST["remove_asset"])
 
     # State updates
     if "update_state" in request.POST:
-        update_state(request, request.POST["update_state"])
+        return update_state(request, request.POST["update_state"])
 
     # Bulk actions
     if "bulk_execute" in request.POST:
@@ -387,25 +399,25 @@ def handle_action(request, query: QuerySet[Bookmark] = None):
             bookmark_ids = request.POST.getlist("bookmark_id")
 
         if "bulk_archive" == bulk_action:
-            archive_bookmarks(bookmark_ids, request.user)
+            return archive_bookmarks(bookmark_ids, request.user)
         if "bulk_unarchive" == bulk_action:
-            unarchive_bookmarks(bookmark_ids, request.user)
+            return unarchive_bookmarks(bookmark_ids, request.user)
         if "bulk_delete" == bulk_action:
-            delete_bookmarks(bookmark_ids, request.user)
+            return delete_bookmarks(bookmark_ids, request.user)
         if "bulk_tag" == bulk_action:
             tag_string = convert_tag_string(request.POST["bulk_tag_string"])
-            tag_bookmarks(bookmark_ids, tag_string, request.user)
+            return tag_bookmarks(bookmark_ids, tag_string, request.user)
         if "bulk_untag" == bulk_action:
             tag_string = convert_tag_string(request.POST["bulk_tag_string"])
-            untag_bookmarks(bookmark_ids, tag_string, request.user)
+            return untag_bookmarks(bookmark_ids, tag_string, request.user)
         if "bulk_read" == bulk_action:
-            mark_bookmarks_as_read(bookmark_ids, request.user)
+            return mark_bookmarks_as_read(bookmark_ids, request.user)
         if "bulk_unread" == bulk_action:
-            mark_bookmarks_as_unread(bookmark_ids, request.user)
+            return mark_bookmarks_as_unread(bookmark_ids, request.user)
         if "bulk_share" == bulk_action:
-            share_bookmarks(bookmark_ids, request.user)
+            return share_bookmarks(bookmark_ids, request.user)
         if "bulk_unshare" == bulk_action:
-            unshare_bookmarks(bookmark_ids, request.user)
+            return unshare_bookmarks(bookmark_ids, request.user)
 
 
 @login_required
diff --git a/bookmarks/views/contexts.py b/bookmarks/views/contexts.py
index 7ddcca9..cf1ab53 100644
--- a/bookmarks/views/contexts.py
+++ b/bookmarks/views/contexts.py
@@ -399,6 +399,7 @@ class BookmarkDetailsContext:
         self.preview_image_enabled = user_profile.enable_preview_images
         self.show_link_icons = user_profile.enable_favicons and bookmark.favicon_file
         self.snapshots_enabled = settings.LD_ENABLE_SNAPSHOTS
+        self.uploads_enabled = not settings.LD_DISABLE_ASSET_UPLOAD
 
         self.web_archive_snapshot_url = bookmark.web_archive_snapshot_url
         if not self.web_archive_snapshot_url:
diff --git a/docs/src/content/docs/api.md b/docs/src/content/docs/api.md
index 06f640a..be066b3 100644
--- a/docs/src/content/docs/api.md
+++ b/docs/src/content/docs/api.md
@@ -7,7 +7,8 @@ The application provides a REST API that can be used by 3rd party applications t
 
 ## Authentication
 
-All requests against the API must be authorized using an authorization token. The application automatically generates an API token for each user, which can be accessed through the *Settings* page.
+All requests against the API must be authorized using an authorization token. The application automatically generates an
+API token for each user, which can be accessed through the *Settings* page.
 
 The token needs to be passed as `Authorization` header in the HTTP request:
 
@@ -91,9 +92,11 @@ Retrieves a single bookmark by ID.
 GET /api/bookmarks/check/?url=https%3A%2F%2Fexample.com
 ```
 
-Allows to check if a URL is already bookmarked. If the URL is already bookmarked, the `bookmark` property in the response holds the bookmark data, otherwise it is `null`.
+Allows to check if a URL is already bookmarked. If the URL is already bookmarked, the `bookmark` property in the
+response holds the bookmark data, otherwise it is `null`.
 
-Also returns a `metadata` property that contains metadata scraped from the website. Finally, the `auto_tags` property contains the tag names that would be automatically added when creating a bookmark for that URL.
+Also returns a `metadata` property that contains metadata scraped from the website. Finally, the `auto_tags` property
+contains the tag names that would be automatically added when creating a bookmark for that URL.
 
 Example response:
 
@@ -127,9 +130,13 @@ POST /api/bookmarks/
 Creates a new bookmark. Tags are simply assigned using their names. Including
 `is_archived: true` saves a bookmark directly to the archive.
 
-If the provided URL is already bookmarked, this silently updates the existing bookmark instead of creating a new one. If you are implementing a user interface, consider notifying users about this behavior. You can use the `/check` endpoint to check if a URL is already bookmarked and at the same time get the existing bookmark data. This behavior may change in the future to return an error instead.
+If the provided URL is already bookmarked, this silently updates the existing bookmark instead of creating a new one. If
+you are implementing a user interface, consider notifying users about this behavior. You can use the `/check` endpoint
+to check if a URL is already bookmarked and at the same time get the existing bookmark data. This behavior may change in
+the future to return an error instead.
 
-If the title and description are not provided or empty, the application automatically tries to scrape them from the bookmarked website. This behavior can be disabled by adding the `disable_scraping` query parameter to the API request.
+If the title and description are not provided or empty, the application automatically tries to scrape them from the
+bookmarked website. This behavior can be disabled by adding the `disable_scraping` query parameter to the API request.
 
 Example payload:
 
@@ -202,6 +209,96 @@ DELETE /api/bookmarks/<id>/
 
 Deletes a bookmark by ID.
 
+### Bookmark Assets
+
+**List**
+
+```
+GET /api/bookmarks/<bookmark_id>/assets/
+```
+
+List assets for a specific bookmark.
+
+Example response:
+
+```json
+{
+  "count": 2,
+  "next": null,
+  "previous": null,
+  "results": [
+    {
+      "id": 1,
+      "bookmark": 1,
+      "asset_type": "snapshot",
+      "date_created": "2023-10-01T12:00:00Z",
+      "content_type": "text/html",
+      "display_name": "HTML snapshot from 10/01/2023",
+      "status": "complete",
+      "gzip": true
+    },
+    {
+      "id": 2,
+      "bookmark": 1,
+      "asset_type": "upload",
+      "date_created": "2023-10-01T12:05:00Z",
+      "content_type": "image/png",
+      "display_name": "example.png",
+      "status": "complete",
+      "gzip": false
+    }
+  ]
+}
+```
+
+**Retrieve**
+
+```
+GET /api/bookmarks/<bookmark_id>/assets/<id>/
+```
+
+Retrieves a single asset by ID for a specific bookmark.
+
+**Download**
+
+```
+GET /api/bookmarks/<bookmark_id>/assets/<id>/download/
+```
+
+Downloads the asset file.
+
+**Upload**
+
+```
+POST /api/bookmarks/<bookmark_id>/assets/upload/
+```
+
+Uploads a new asset for a specific bookmark. The request must be a `multipart/form-data` request with a single part
+named `file` containing the file to upload.
+
+Example response:
+
+```json
+{
+  "id": 3,
+  "bookmark": 1,
+  "asset_type": "upload",
+  "date_created": "2023-10-01T12:10:00Z",
+  "content_type": "application/pdf",
+  "display_name": "example.pdf",
+  "status": "complete",
+  "gzip": false
+}
+```
+
+**Delete**
+
+```
+DELETE /api/bookmarks/<bookmark_id>/assets/<id>/
+```
+
+Deletes an asset by ID for a specific bookmark.
+
 ### Tags
 
 **List**
diff --git a/docs/src/content/docs/options.md b/docs/src/content/docs/options.md
index 66989f3..df10b53 100644
--- a/docs/src/content/docs/options.md
+++ b/docs/src/content/docs/options.md
@@ -55,6 +55,12 @@ Values: `True`, `False` | Default = `False`
 Completely disables URL validation for bookmarks.
 This can be useful if you intend to store non fully qualified domain name URLs, such as network paths, or you want to store URLs that use another protocol than `http` or `https`.
 
+### `LD_REQUEST_MAX_CONTENT_LENGTH`
+
+Values: `Integer` as bytes | Default = `None`
+
+Configures the maximum content length for POST requests in the uwsgi application server. This can be used to prevent uploading large files that might cause the server to run out of memory. By default, the server does not limit the content length.
+
 ### `LD_REQUEST_TIMEOUT`
 
 Values: `Integer` as seconds | Default = `60`
diff --git a/siteroot/settings/base.py b/siteroot/settings/base.py
index 8324d85..4bfc093 100644
--- a/siteroot/settings/base.py
+++ b/siteroot/settings/base.py
@@ -290,6 +290,11 @@ LD_ENABLE_SNAPSHOTS = os.getenv("LD_ENABLE_SNAPSHOTS", False) in (
     "True",
     "1",
 )
+LD_DISABLE_ASSET_UPLOAD = os.getenv("LD_DISABLE_ASSET_UPLOAD", False) in (
+    True,
+    "True",
+    "1",
+)
 LD_SINGLEFILE_PATH = os.getenv("LD_SINGLEFILE_PATH", "single-file")
 LD_SINGLEFILE_UBLOCK_OPTIONS = os.getenv(
     "LD_SINGLEFILE_UBLOCK_OPTIONS",
diff --git a/uwsgi.ini b/uwsgi.ini
index 6e2d752..4c2ae7f 100644
--- a/uwsgi.ini
+++ b/uwsgi.ini
@@ -28,6 +28,10 @@ socket-timeout = %(_)
 harakiri = %(_)
 endif =
 
+if-env = LD_REQUEST_MAX_CONTENT_LENGTH
+limit-post = %(_)
+endif =
+
 if-env = LD_LOG_X_FORWARDED_FOR
 log-x-forwarded-for = %(_)
 endif =


Test patch:
diff --git a/bookmarks/tests/helpers.py b/bookmarks/tests/helpers.py
index bc6539e..7fb760d 100644
--- a/bookmarks/tests/helpers.py
+++ b/bookmarks/tests/helpers.py
@@ -1,14 +1,21 @@
-import random
+import gzip
 import logging
+import os
+import random
+import shutil
+import tempfile
 from datetime import datetime
 from typing import List
 from unittest import TestCase
 
 from bs4 import BeautifulSoup
+from django.conf import settings
 from django.contrib.auth.models import User
+from django.test import override_settings
 from django.utils import timezone
 from django.utils.crypto import get_random_string
 from rest_framework import status
+from rest_framework.authtoken.models import Token
 from rest_framework.test import APITestCase
 
 from bookmarks.models import Bookmark, BookmarkAsset, Tag
@@ -17,6 +24,16 @@ from bookmarks.models import Bookmark, BookmarkAsset, Tag
 class BookmarkFactoryMixin:
     user = None
 
+    def setup_temp_assets_dir(self):
+        self.assets_dir = tempfile.mkdtemp()
+        self.settings_override = override_settings(LD_ASSET_FOLDER=self.assets_dir)
+        self.settings_override.enable()
+        self.addCleanup(self.cleanup_temp_assets_dir)
+
+    def cleanup_temp_assets_dir(self):
+        shutil.rmtree(self.assets_dir)
+        self.settings_override.disable()
+
     def get_or_create_test_user(self):
         if self.user is None:
             self.user = User.objects.create_user(
@@ -182,6 +199,24 @@ class BookmarkFactoryMixin:
         asset.save()
         return asset
 
+    def setup_asset_file(self, asset: BookmarkAsset, file_content: str = "test"):
+        filepath = os.path.join(settings.LD_ASSET_FOLDER, asset.file)
+        if asset.gzip:
+            with gzip.open(filepath, "wb") as f:
+                f.write(file_content.encode())
+        else:
+            with open(filepath, "w") as f:
+                f.write(file_content)
+
+    def read_asset_file(self, asset: BookmarkAsset):
+        filepath = os.path.join(settings.LD_ASSET_FOLDER, asset.file)
+        with open(filepath, "rb") as f:
+            return f.read()
+
+    def has_asset_file(self, asset: BookmarkAsset):
+        filepath = os.path.join(settings.LD_ASSET_FOLDER, asset.file)
+        return os.path.exists(filepath)
+
     def setup_tag(self, user: User = None, name: str = ""):
         if user is None:
             user = self.get_or_create_test_user()
@@ -290,6 +325,12 @@ class TagCloudTestMixin(TestCase, HtmlTestMixin):
 
 
 class LinkdingApiTestCase(APITestCase):
+    def authenticate(self):
+        self.api_token = Token.objects.get_or_create(
+            user=self.get_or_create_test_user()
+        )[0]
+        self.client.credentials(HTTP_AUTHORIZATION="Token " + self.api_token.key)
+
     def get(self, url, expected_status_code=status.HTTP_200_OK):
         response = self.client.get(url)
         self.assertEqual(response.status_code, expected_status_code)
diff --git a/bookmarks/tests/test_assets_service.py b/bookmarks/tests/test_assets_service.py
index 22e0064..52faa02 100644
--- a/bookmarks/tests/test_assets_service.py
+++ b/bookmarks/tests/test_assets_service.py
@@ -1,12 +1,10 @@
 import datetime
 import gzip
 import os
-import shutil
-import tempfile
 from unittest import mock
 
 from django.core.files.uploadedfile import SimpleUploadedFile
-from django.test import TestCase, override_settings
+from django.test import TestCase
 from django.utils import timezone
 
 from bookmarks.models import BookmarkAsset
@@ -17,12 +15,9 @@ from bookmarks.tests.helpers import BookmarkFactoryMixin, disable_logging
 class AssetServiceTestCase(TestCase, BookmarkFactoryMixin):
 
     def setUp(self) -> None:
+        self.setup_temp_assets_dir()
         self.get_or_create_test_user()
 
-        self.temp_dir = tempfile.mkdtemp()
-        self.settings_override = override_settings(LD_ASSET_FOLDER=self.temp_dir)
-        self.settings_override.enable()
-
         self.html_content = "<html><body><h1>Hello, World!</h1></body></html>"
         self.mock_singlefile_create_snapshot_patcher = mock.patch(
             "bookmarks.services.singlefile.create_snapshot",
@@ -35,12 +30,11 @@ class AssetServiceTestCase(TestCase, BookmarkFactoryMixin):
         )
 
     def tearDown(self) -> None:
-        shutil.rmtree(self.temp_dir)
         self.mock_singlefile_create_snapshot_patcher.stop()
 
     def get_saved_snapshot_file(self):
         # look up first file in the asset folder
-        files = os.listdir(self.temp_dir)
+        files = os.listdir(self.assets_dir)
         if files:
             return files[0]
 
@@ -70,9 +64,9 @@ class AssetServiceTestCase(TestCase, BookmarkFactoryMixin):
         assets.create_snapshot(asset)
 
         expected_temp_filename = "snapshot_2023-08-11_214511_https___example.com.tmp"
-        expected_temp_filepath = os.path.join(self.temp_dir, expected_temp_filename)
+        expected_temp_filepath = os.path.join(self.assets_dir, expected_temp_filename)
         expected_filename = "snapshot_2023-08-11_214511_https___example.com.html.gz"
-        expected_filepath = os.path.join(self.temp_dir, expected_filename)
+        expected_filepath = os.path.join(self.assets_dir, expected_filename)
 
         # should call singlefile.create_snapshot with the correct arguments
         self.mock_singlefile_create_snapshot.assert_called_once_with(
@@ -137,7 +131,7 @@ class AssetServiceTestCase(TestCase, BookmarkFactoryMixin):
         self.assertTrue(saved_file_name.endswith("_https___example.com.html.gz"))
 
         # gzip file should contain the correct content
-        with gzip.open(os.path.join(self.temp_dir, saved_file_name), "rb") as gz_file:
+        with gzip.open(os.path.join(self.assets_dir, saved_file_name), "rb") as gz_file:
             self.assertEqual(gz_file.read().decode(), self.html_content)
 
         # should create asset
@@ -195,7 +189,7 @@ class AssetServiceTestCase(TestCase, BookmarkFactoryMixin):
         self.assertTrue(saved_file_name.endswith("_test_file.txt"))
 
         # file should contain the correct content
-        with open(os.path.join(self.temp_dir, saved_file_name), "rb") as file:
+        with open(os.path.join(self.assets_dir, saved_file_name), "rb") as file:
             self.assertEqual(file.read(), file_content)
 
         # should create asset
diff --git a/bookmarks/tests/test_bookmark_action_view.py b/bookmarks/tests/test_bookmark_action_view.py
index ba8f725..111e38e 100644
--- a/bookmarks/tests/test_bookmark_action_view.py
+++ b/bookmarks/tests/test_bookmark_action_view.py
@@ -230,6 +230,27 @@ class BookmarkActionViewTestCase(
 
             mock_upload_asset.assert_not_called()
 
+    @override_settings(LD_DISABLE_ASSET_UPLOAD=True)
+    def test_upload_asset_disabled(self):
+        bookmark = self.setup_bookmark()
+        file_content = b"file content"
+        upload_file = SimpleUploadedFile("test.txt", file_content)
+
+        response = self.client.post(
+            reverse("bookmarks:index.action"),
+            {"upload_asset": bookmark.id, "upload_asset_file": upload_file},
+        )
+        self.assertEqual(response.status_code, 403)
+
+    def test_upload_asset_without_file(self):
+        bookmark = self.setup_bookmark()
+
+        response = self.client.post(
+            reverse("bookmarks:index.action"),
+            {"upload_asset": bookmark.id},
+        )
+        self.assertEqual(response.status_code, 400)
+
     def test_remove_asset(self):
         bookmark = self.setup_bookmark()
         asset = self.setup_asset(bookmark)
diff --git a/bookmarks/tests/test_bookmark_asset_view.py b/bookmarks/tests/test_bookmark_asset_view.py
index d19aae4..9e2533a 100644
--- a/bookmarks/tests/test_bookmark_asset_view.py
+++ b/bookmarks/tests/test_bookmark_asset_view.py
@@ -11,19 +11,11 @@ from bookmarks.tests.helpers import (
 
 class BookmarkAssetViewTestCase(TestCase, BookmarkFactoryMixin):
     def setUp(self) -> None:
+        self.setup_temp_assets_dir()
         user = self.get_or_create_test_user()
         self.client.force_login(user)
 
-    def tearDown(self):
-        temp_files = [
-            f for f in os.listdir(settings.LD_ASSET_FOLDER) if f.startswith("temp")
-        ]
-        for temp_file in temp_files:
-            os.remove(os.path.join(settings.LD_ASSET_FOLDER, temp_file))
-
     def setup_asset_file(self, filename):
-        if not os.path.exists(settings.LD_ASSET_FOLDER):
-            os.makedirs(settings.LD_ASSET_FOLDER)
         filepath = os.path.join(settings.LD_ASSET_FOLDER, filename)
         with open(filepath, "w") as f:
             f.write("test")
diff --git a/bookmarks/tests/test_bookmark_assets.py b/bookmarks/tests/test_bookmark_assets.py
index 4161b11..87c5119 100644
--- a/bookmarks/tests/test_bookmark_assets.py
+++ b/bookmarks/tests/test_bookmark_assets.py
@@ -1,9 +1,7 @@
 import os
-import shutil
-import tempfile
 
 from django.conf import settings
-from django.test import TestCase, override_settings
+from django.test import TestCase
 
 from bookmarks.services import bookmarks
 from bookmarks.tests.helpers import BookmarkFactoryMixin
@@ -11,13 +9,7 @@ from bookmarks.tests.helpers import BookmarkFactoryMixin
 
 class BookmarkAssetsTestCase(TestCase, BookmarkFactoryMixin):
     def setUp(self):
-        self.temp_dir = tempfile.mkdtemp()
-        self.override = override_settings(LD_ASSET_FOLDER=self.temp_dir)
-        self.override.enable()
-
-    def tearDown(self):
-        self.override.disable()
-        shutil.rmtree(self.temp_dir)
+        self.setup_temp_assets_dir()
 
     def setup_asset_file(self, filename):
         filepath = os.path.join(settings.LD_ASSET_FOLDER, filename)
diff --git a/bookmarks/tests/test_bookmark_assets_api.py b/bookmarks/tests/test_bookmark_assets_api.py
new file mode 100644
index 0000000..4c71542
--- /dev/null
+++ b/bookmarks/tests/test_bookmark_assets_api.py
@@ -0,0 +1,340 @@
+import io
+
+from django.core.files.uploadedfile import SimpleUploadedFile
+from django.test import override_settings
+from django.urls import reverse
+from rest_framework import status
+
+from bookmarks.models import BookmarkAsset
+from bookmarks.tests.helpers import LinkdingApiTestCase, BookmarkFactoryMixin
+
+
+class BookmarkAssetsApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
+    def setUp(self):
+        self.setup_temp_assets_dir()
+
+    def assertAsset(self, asset: BookmarkAsset, data: dict):
+        self.assertEqual(asset.id, data["id"])
+        self.assertEqual(asset.bookmark.id, data["bookmark"])
+        self.assertEqual(
+            asset.date_created.isoformat().replace("+00:00", "Z"), data["date_created"]
+        )
+        self.assertEqual(asset.file_size, data["file_size"])
+        self.assertEqual(asset.asset_type, data["asset_type"])
+        self.assertEqual(asset.content_type, data["content_type"])
+        self.assertEqual(asset.display_name, data["display_name"])
+        self.assertEqual(asset.status, data["status"])
+
+    def test_asset_list(self):
+        self.authenticate()
+
+        bookmark1 = self.setup_bookmark(url="https://example1.com")
+        bookmark1_assets = [
+            self.setup_asset(bookmark=bookmark1),
+            self.setup_asset(bookmark=bookmark1),
+            self.setup_asset(bookmark=bookmark1),
+        ]
+
+        bookmark2 = self.setup_bookmark(url="https://example2.com")
+        bookmark2_assets = [
+            self.setup_asset(bookmark=bookmark2),
+            self.setup_asset(bookmark=bookmark2),
+            self.setup_asset(bookmark=bookmark2),
+        ]
+
+        url = reverse(
+            "bookmarks:bookmark_asset-list", kwargs={"bookmark_id": bookmark1.id}
+        )
+        response = self.get(url, expected_status_code=status.HTTP_200_OK)
+        self.assertEqual(len(response.data["results"]), 3)
+        self.assertAsset(bookmark1_assets[0], response.data["results"][0])
+        self.assertAsset(bookmark1_assets[1], response.data["results"][1])
+        self.assertAsset(bookmark1_assets[2], response.data["results"][2])
+
+        url = reverse(
+            "bookmarks:bookmark_asset-list", kwargs={"bookmark_id": bookmark2.id}
+        )
+        response = self.get(url, expected_status_code=status.HTTP_200_OK)
+        self.assertEqual(len(response.data["results"]), 3)
+        self.assertAsset(bookmark2_assets[0], response.data["results"][0])
+        self.assertAsset(bookmark2_assets[1], response.data["results"][1])
+        self.assertAsset(bookmark2_assets[2], response.data["results"][2])
+
+    def test_asset_list_only_returns_assets_for_own_bookmarks(self):
+        self.authenticate()
+
+        other_user = self.setup_user()
+        bookmark = self.setup_bookmark(user=other_user)
+        self.setup_asset(bookmark=bookmark)
+
+        url = reverse(
+            "bookmarks:bookmark_asset-list", kwargs={"bookmark_id": bookmark.id}
+        )
+        self.get(url, expected_status_code=status.HTTP_404_NOT_FOUND)
+
+    def test_asset_list_requires_authentication(self):
+        bookmark = self.setup_bookmark()
+        url = reverse(
+            "bookmarks:bookmark_asset-list", kwargs={"bookmark_id": bookmark.id}
+        )
+        self.get(url, expected_status_code=status.HTTP_401_UNAUTHORIZED)
+
+    def test_asset_detail(self):
+        self.authenticate()
+
+        bookmark = self.setup_bookmark()
+        asset = self.setup_asset(
+            bookmark=bookmark,
+            asset_type=BookmarkAsset.TYPE_UPLOAD,
+            file="cats.png",
+            file_size=1234,
+            content_type="image/png",
+            display_name="cats.png",
+            status=BookmarkAsset.STATUS_PENDING,
+            gzip=False,
+        )
+        url = reverse(
+            "bookmarks:bookmark_asset-detail",
+            kwargs={"bookmark_id": asset.bookmark.id, "pk": asset.id},
+        )
+        response = self.get(url, expected_status_code=status.HTTP_200_OK)
+        self.assertAsset(asset, response.data)
+
+    def test_asset_detail_only_returns_asset_for_own_bookmarks(self):
+        self.authenticate()
+
+        other_user = self.setup_user()
+        bookmark = self.setup_bookmark(user=other_user)
+        asset = self.setup_asset(bookmark=bookmark)
+
+        url = reverse(
+            "bookmarks:bookmark_asset-detail",
+            kwargs={"bookmark_id": asset.bookmark.id, "pk": asset.id},
+        )
+        self.get(url, expected_status_code=status.HTTP_404_NOT_FOUND)
+
+    def test_asset_detail_requires_authentication(self):
+        bookmark = self.setup_bookmark()
+        asset = self.setup_asset(bookmark=bookmark)
+        url = reverse(
+            "bookmarks:bookmark_asset-detail",
+            kwargs={"bookmark_id": asset.bookmark.id, "pk": asset.id},
+        )
+        self.get(url, expected_status_code=status.HTTP_401_UNAUTHORIZED)
+
+    def test_asset_download_with_snapshot_asset(self):
+        self.authenticate()
+
+        file_content = """
+            <html>
+            <head>
+                <title>Test</title>
+            </head>
+            <body>
+                <h1>Test</h1>
+            </body>
+        """
+        bookmark = self.setup_bookmark()
+        asset = self.setup_asset(
+            bookmark=bookmark,
+            asset_type=BookmarkAsset.TYPE_SNAPSHOT,
+            display_name="Snapshot from today",
+            content_type="text/html",
+            gzip=True,
+        )
+        self.setup_asset_file(asset=asset, file_content=file_content)
+
+        url = reverse(
+            "bookmarks:bookmark_asset-download",
+            kwargs={"bookmark_id": asset.bookmark.id, "pk": asset.id},
+        )
+        response = self.get(url, expected_status_code=status.HTTP_200_OK)
+
+        self.assertEqual(response["Content-Type"], "text/html")
+        self.assertEqual(
+            response["Content-Disposition"],
+            'attachment; filename="Snapshot from today.html"',
+        )
+        content = b"".join(response.streaming_content).decode("utf-8")
+        self.assertEqual(content, file_content)
+
+    def test_asset_download_with_uploaded_asset(self):
+        self.authenticate()
+
+        file_content = "some file content"
+        bookmark = self.setup_bookmark()
+        asset = self.setup_asset(
+            bookmark=bookmark,
+            asset_type=BookmarkAsset.TYPE_UPLOAD,
+            display_name="cats.png",
+            content_type="image/png",
+            gzip=False,
+        )
+        self.setup_asset_file(asset=asset, file_content=file_content)
+
+        url = reverse(
+            "bookmarks:bookmark_asset-download",
+            kwargs={"bookmark_id": asset.bookmark.id, "pk": asset.id},
+        )
+        response = self.get(url, expected_status_code=status.HTTP_200_OK)
+
+        self.assertEqual(response["Content-Type"], "image/png")
+        self.assertEqual(
+            response["Content-Disposition"],
+            'attachment; filename="cats.png"',
+        )
+        content = b"".join(response.streaming_content).decode("utf-8")
+        self.assertEqual(content, file_content)
+
+    def test_asset_download_with_missing_file(self):
+        self.authenticate()
+
+        bookmark = self.setup_bookmark()
+        asset = self.setup_asset(
+            bookmark=bookmark,
+            asset_type=BookmarkAsset.TYPE_UPLOAD,
+            display_name="cats.png",
+            content_type="image/png",
+            gzip=False,
+        )
+
+        url = reverse(
+            "bookmarks:bookmark_asset-download",
+            kwargs={"bookmark_id": asset.bookmark.id, "pk": asset.id},
+        )
+        self.get(url, expected_status_code=status.HTTP_404_NOT_FOUND)
+
+    def test_asset_download_only_returns_asset_for_own_bookmarks(self):
+        self.authenticate()
+
+        other_user = self.setup_user()
+        bookmark = self.setup_bookmark(user=other_user)
+        asset = self.setup_asset(bookmark=bookmark)
+
+        url = reverse(
+            "bookmarks:bookmark_asset-download",
+            kwargs={"bookmark_id": asset.bookmark.id, "pk": asset.id},
+        )
+        self.get(url, expected_status_code=status.HTTP_404_NOT_FOUND)
+
+    def test_asset_download_requires_authentication(self):
+        bookmark = self.setup_bookmark()
+        asset = self.setup_asset(bookmark=bookmark)
+        url = reverse(
+            "bookmarks:bookmark_asset-download",
+            kwargs={"bookmark_id": asset.bookmark.id, "pk": asset.id},
+        )
+        self.get(url, expected_status_code=status.HTTP_401_UNAUTHORIZED)
+
+    def create_upload_body(self):
+        url = "https://example.com"
+        file_content = b"dummy content"
+        file = io.BytesIO(file_content)
+        file.name = "snapshot.html"
+
+        return {"url": url, "file": file}
+
+    def test_upload_asset(self):
+        self.authenticate()
+
+        bookmark = self.setup_bookmark()
+        url = reverse(
+            "bookmarks:bookmark_asset-upload", kwargs={"bookmark_id": bookmark.id}
+        )
+        file_content = b"test file content"
+        file_name = "test.txt"
+        file = SimpleUploadedFile(file_name, file_content, content_type="text/plain")
+
+        response = self.client.post(url, {"file": file}, format="multipart")
+        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+
+        asset = BookmarkAsset.objects.get(id=response.data["id"])
+        self.assertEqual(asset.bookmark, bookmark)
+        self.assertEqual(asset.display_name, file_name)
+        self.assertEqual(asset.asset_type, BookmarkAsset.TYPE_UPLOAD)
+        self.assertEqual(asset.content_type, "text/plain")
+        self.assertEqual(asset.file_size, len(file_content))
+        self.assertFalse(asset.gzip)
+
+        content = self.read_asset_file(asset)
+        self.assertEqual(content, file_content)
+
+    def test_upload_asset_with_missing_file(self):
+        self.authenticate()
+
+        bookmark = self.setup_bookmark()
+        url = reverse(
+            "bookmarks:bookmark_asset-upload", kwargs={"bookmark_id": bookmark.id}
+        )
+
+        response = self.client.post(url, {}, format="multipart")
+        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
+
+    def test_upload_asset_only_works_for_own_bookmarks(self):
+        self.authenticate()
+
+        other_user = self.setup_user()
+        bookmark = self.setup_bookmark(user=other_user)
+        url = reverse(
+            "bookmarks:bookmark_asset-upload", kwargs={"bookmark_id": bookmark.id}
+        )
+
+        response = self.client.post(url, {}, format="multipart")
+        self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
+
+    def test_upload_asset_requires_authentication(self):
+        bookmark = self.setup_bookmark()
+        url = reverse(
+            "bookmarks:bookmark_asset-upload", kwargs={"bookmark_id": bookmark.id}
+        )
+
+        response = self.client.post(url, {}, format="multipart")
+        self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
+
+    @override_settings(LD_DISABLE_ASSET_UPLOAD=True)
+    def test_upload_asset_disabled(self):
+        self.authenticate()
+        bookmark = self.setup_bookmark()
+        url = reverse(
+            "bookmarks:bookmark_asset-upload", kwargs={"bookmark_id": bookmark.id}
+        )
+        response = self.client.post(url, {}, format="multipart")
+        self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
+
+    def test_delete_asset(self):
+        self.authenticate()
+
+        bookmark = self.setup_bookmark()
+        asset = self.setup_asset(bookmark=bookmark)
+        self.setup_asset_file(asset=asset)
+
+        url = reverse(
+            "bookmarks:bookmark_asset-detail",
+            kwargs={"bookmark_id": asset.bookmark.id, "pk": asset.id},
+        )
+        self.delete(url, expected_status_code=status.HTTP_204_NO_CONTENT)
+
+        self.assertFalse(BookmarkAsset.objects.filter(id=asset.id).exists())
+        self.assertFalse(self.has_asset_file(asset))
+
+    def test_delete_asset_only_works_for_own_bookmarks(self):
+        self.authenticate()
+
+        other_user = self.setup_user()
+        bookmark = self.setup_bookmark(user=other_user)
+        asset = self.setup_asset(bookmark=bookmark)
+
+        url = reverse(
+            "bookmarks:bookmark_asset-detail",
+            kwargs={"bookmark_id": asset.bookmark.id, "pk": asset.id},
+        )
+        self.delete(url, expected_status_code=status.HTTP_404_NOT_FOUND)
+
+    def test_delete_asset_requires_authentication(self):
+        bookmark = self.setup_bookmark()
+        asset = self.setup_asset(bookmark=bookmark)
+        url = reverse(
+            "bookmarks:bookmark_asset-detail",
+            kwargs={"bookmark_id": asset.bookmark.id, "pk": asset.id},
+        )
+        self.delete(url, expected_status_code=status.HTTP_401_UNAUTHORIZED)
diff --git a/bookmarks/tests/test_bookmark_details_modal.py b/bookmarks/tests/test_bookmark_details_modal.py
index 12841d4..487812f 100644
--- a/bookmarks/tests/test_bookmark_details_modal.py
+++ b/bookmarks/tests/test_bookmark_details_modal.py
@@ -668,6 +668,18 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
         self.assertIsNone(create_snapshot)
         self.assertIsNotNone(upload_asset)
 
+    @override_settings(LD_DISABLE_ASSET_UPLOAD=True)
+    def test_asset_list_actions_visibility_with_uploads_disabled(self):
+        bookmark = self.setup_bookmark()
+
+        soup = self.get_index_details_modal(bookmark)
+        create_snapshot = soup.find(
+            "button", {"type": "submit", "name": "create_html_snapshot"}
+        )
+        upload_asset = soup.find("button", {"type": "submit", "name": "upload_asset"})
+        self.assertIsNone(create_snapshot)
+        self.assertIsNone(upload_asset)
+
     def test_asset_without_file(self):
         bookmark = self.setup_bookmark()
         asset = self.setup_asset(bookmark)
diff --git a/bookmarks/tests/test_bookmarks_api.py b/bookmarks/tests/test_bookmarks_api.py
index 142c3eb..7869345 100644
--- a/bookmarks/tests/test_bookmarks_api.py
+++ b/bookmarks/tests/test_bookmarks_api.py
@@ -5,6 +5,7 @@ from collections import OrderedDict
 from unittest.mock import patch, ANY
 
 from django.contrib.auth.models import User
+from django.test import override_settings
 from django.urls import reverse
 from django.utils import timezone
 from rest_framework import status
@@ -1285,3 +1286,13 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
         self.assertEqual(
             response.data["error"], "Both 'url' and 'file' parameters are required."
         )
+
+    @override_settings(LD_DISABLE_ASSET_UPLOAD=True)
+    def test_singlefile_upload_disabled(self):
+        self.authenticate()
+        self.client.post(
+            reverse("bookmarks:bookmark-singlefile"),
+            self.create_singlefile_upload_body(),
+            format="multipart",
+            expected_status_code=status.HTTP_403_FORBIDDEN,
+        )