Add bookmark assets API (#1003)

* Add list, details and download endpoints

* Avoid using multiple DefaultRoute instances

* Add upload endpoint

* Add docs

* Allow configuring max request content length

* Add option for disabling uploads

* Remove gzip field

* Add delete endpoint
This commit is contained in:
Sascha Ißbrücker
2025-03-06 09:09:53 +01:00
committed by GitHub
parent b21812c30a
commit 8a3572ba4b
18 changed files with 726 additions and 72 deletions

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View File

@@ -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")

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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,
)