import gzip import logging import os from django.conf import settings from django.http import Http404, StreamingHttpResponse 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 SimpleRouter, DefaultRouter from bookmarks import queries from bookmarks.api.serializers import ( BookmarkSerializer, BookmarkAssetSerializer, TagSerializer, UserProfileSerializer, BookmarkBundleSerializer, ) from bookmarks.models import ( Bookmark, BookmarkAsset, BookmarkSearch, Tag, User, BookmarkBundle, ) from bookmarks.services import assets, bookmarks, auto_tagging, website_loader from bookmarks.type_defs import HttpRequest from bookmarks.views import access logger = logging.getLogger(__name__) class BookmarkViewSet( viewsets.GenericViewSet, mixins.ListModelMixin, mixins.RetrieveModelMixin, mixins.CreateModelMixin, mixins.UpdateModelMixin, mixins.DestroyModelMixin, ): request: HttpRequest serializer_class = BookmarkSerializer def get_permissions(self): # Allow unauthenticated access to shared bookmarks. # The shared action should still filter bookmarks so that # unauthenticated users only see bookmarks from users that have public # sharing explicitly enabled if self.action == "shared": return [AllowAny()] # Otherwise use default permissions which should require authentication return super().get_permissions() def get_queryset(self): # Provide filtered queryset for list actions user = self.request.user search = BookmarkSearch.from_request(self.request, self.request.GET) if self.action == "list": return queries.query_bookmarks(user, user.profile, search) elif self.action == "archived": return queries.query_archived_bookmarks(user, user.profile, search) elif self.action == "shared": user = User.objects.filter(username=search.user).first() public_only = not self.request.user.is_authenticated return queries.query_shared_bookmarks( user, self.request.user_profile, search, public_only ) # For single entity actions return user owned bookmarks return Bookmark.objects.all().filter(owner=user) def get_serializer_context(self): disable_scraping = "disable_scraping" in self.request.GET disable_html_snapshot = "disable_html_snapshot" in self.request.GET return { "request": self.request, "user": self.request.user, "disable_scraping": disable_scraping, "disable_html_snapshot": disable_html_snapshot, } @action(methods=["get"], detail=False) def archived(self, request: HttpRequest): return self.list(request) @action(methods=["get"], detail=False) def shared(self, request: HttpRequest): return self.list(request) @action(methods=["post"], detail=True) def archive(self, request: HttpRequest, pk): bookmark = self.get_object() bookmarks.archive_bookmark(bookmark) return Response(status=status.HTTP_204_NO_CONTENT) @action(methods=["post"], detail=True) def unarchive(self, request: HttpRequest, pk): bookmark = self.get_object() bookmarks.unarchive_bookmark(bookmark) return Response(status=status.HTTP_204_NO_CONTENT) @action(methods=["get"], detail=False) def check(self, request: HttpRequest): url = request.GET.get("url") ignore_cache = request.GET.get("ignore_cache", False) in ["true"] bookmark = Bookmark.objects.filter(owner=request.user, url=url).first() existing_bookmark_data = ( self.get_serializer(bookmark).data if bookmark else None ) metadata = website_loader.load_website_metadata(url, ignore_cache=ignore_cache) # Return tags that would be automatically applied to the bookmark profile = request.user.profile auto_tags = [] if profile.auto_tagging_rules: try: auto_tags = auto_tagging.get_tags(profile.auto_tagging_rules, url) except Exception as e: logger.error( f"Failed to auto-tag bookmark. url={url}", exc_info=e, ) return Response( { "bookmark": existing_bookmark_data, "metadata": metadata.to_dict(), "auto_tags": auto_tags, }, status=status.HTTP_200_OK, ) @action(methods=["post"], detail=False) def singlefile(self, request: HttpRequest): if settings.LD_DISABLE_ASSET_UPLOAD: return Response( {"error": "Asset upload is disabled."}, status=status.HTTP_403_FORBIDDEN, ) url = request.POST.get("url") file = request.FILES.get("file") if not url or not file: return Response( {"error": "Both 'url' and 'file' parameters are required."}, status=status.HTTP_400_BAD_REQUEST, ) bookmark = Bookmark.objects.filter(owner=request.user, url=url).first() if not bookmark: bookmark = Bookmark(url=url) bookmark = bookmarks.create_bookmark( bookmark, "", request.user, disable_html_snapshot=True ) bookmarks.enhance_with_website_metadata(bookmark) assets.upload_snapshot(bookmark, file.read()) return Response( {"message": "Snapshot uploaded successfully."}, status=status.HTTP_201_CREATED, ) class BookmarkAssetViewSet( viewsets.GenericViewSet, mixins.ListModelMixin, mixins.RetrieveModelMixin, mixins.DestroyModelMixin, ): request: HttpRequest serializer_class = BookmarkAssetSerializer def get_queryset(self): user = self.request.user # limit access to assets to the owner of the bookmark for now bookmark = access.bookmark_write(self.request, self.kwargs["bookmark_id"]) 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: HttpRequest, 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 = StreamingHttpResponse(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: HttpRequest, bookmark_id): if settings.LD_DISABLE_ASSET_UPLOAD: return Response( {"error": "Asset upload is disabled."}, status=status.HTTP_403_FORBIDDEN, ) bookmark = access.bookmark_write(request, bookmark_id) 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, ) def perform_destroy(self, instance): assets.remove_asset(instance) class TagViewSet( viewsets.GenericViewSet, mixins.ListModelMixin, mixins.RetrieveModelMixin, mixins.CreateModelMixin, ): request: HttpRequest serializer_class = TagSerializer def get_queryset(self): user = self.request.user return Tag.objects.all().filter(owner=user) def get_serializer_context(self): return {"user": self.request.user} class UserViewSet(viewsets.GenericViewSet): @action(methods=["get"], detail=False) def profile(self, request: HttpRequest): return Response(UserProfileSerializer(request.user.profile).data) class BookmarkBundleViewSet( viewsets.GenericViewSet, mixins.ListModelMixin, mixins.RetrieveModelMixin, mixins.CreateModelMixin, mixins.UpdateModelMixin, mixins.DestroyModelMixin, ): request: HttpRequest serializer_class = BookmarkBundleSerializer def get_queryset(self): user = self.request.user return BookmarkBundle.objects.filter(owner=user).order_by("order") def get_serializer_context(self): return {"user": self.request.user} # DRF routers do not support nested view sets such as /bookmarks//assets// # 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") bundle_router = SimpleRouter() bundle_router.register("", BookmarkBundleViewSet, basename="bundle") bookmark_asset_router = SimpleRouter() bookmark_asset_router.register("", BookmarkAssetViewSet, basename="bookmark_asset")