Feature: Share links (#3996)

* Implement share links

Basic implementation of share links

Make certain share link fields not editable, automatically grant permissions on migrate

Updated styling, error messages from expired / deleted links

frontend code linting, reversable sharelink migration

testing coverage

Update translation strings

No links message

* Consolidate file response methods

* improvements to share links on mobile devices

* Refactor share links file_version

* Add docs for share links

* Apply suggestions from code review

* When filtering share links, use the timezone aware now()

* Removes extra call to setup directories for usage in testing

* FIx copied badge display on some browsers

* Move copy to ngx-clipboard library

---------

Co-authored-by: Trenton H <797416+stumpylog@users.noreply.github.com>
This commit is contained in:
shamoon
2023-09-14 13:32:43 -07:00
committed by GitHub
parent 3a36d9b1ae
commit 7c9ab8c0b6
35 changed files with 1740 additions and 454 deletions

View File

@@ -8,6 +8,7 @@ from .models import Note
from .models import PaperlessTask
from .models import SavedView
from .models import SavedViewFilterRule
from .models import ShareLink
from .models import StoragePath
from .models import Tag
@@ -132,6 +133,12 @@ class NotesAdmin(GuardedModelAdmin):
list_display_links = ("created",)
class ShareLinksAdmin(GuardedModelAdmin):
list_display = ("created", "expiration", "document")
list_filter = ("created", "expiration", "owner")
list_display_links = ("created",)
admin.site.register(Correspondent, CorrespondentAdmin)
admin.site.register(Tag, TagAdmin)
admin.site.register(DocumentType, DocumentTypeAdmin)
@@ -140,3 +147,4 @@ admin.site.register(SavedView, SavedViewAdmin)
admin.site.register(StoragePath, StoragePathAdmin)
admin.site.register(PaperlessTask, TaskAdmin)
admin.site.register(Note, NotesAdmin)
admin.site.register(ShareLink, ShareLinksAdmin)

View File

@@ -8,6 +8,7 @@ from .models import Correspondent
from .models import Document
from .models import DocumentType
from .models import Log
from .models import ShareLink
from .models import StoragePath
from .models import Tag
@@ -149,6 +150,15 @@ class StoragePathFilterSet(FilterSet):
}
class ShareLinkFilterSet(FilterSet):
class Meta:
model = ShareLink
fields = {
"created": DATE_KWARGS,
"expiration": DATE_KWARGS,
}
class ObjectOwnedOrGrantedPermissionsFilter(ObjectPermissionsFilter):
"""
A filter backend that limits results to those where the requesting user

View File

@@ -0,0 +1,126 @@
# Generated by Django 4.1.10 on 2023-08-14 14:51
import django.db.models.deletion
import django.utils.timezone
from django.conf import settings
from django.contrib.auth.management import create_permissions
from django.contrib.auth.models import Group
from django.contrib.auth.models import Permission
from django.contrib.auth.models import User
from django.db import migrations
from django.db import models
from django.db.models import Q
def add_sharelink_permissions(apps, schema_editor):
# create permissions without waiting for post_migrate signal
for app_config in apps.get_app_configs():
app_config.models_module = True
create_permissions(app_config, apps=apps, verbosity=0)
app_config.models_module = None
add_permission = Permission.objects.get(codename="add_document")
sharelink_permissions = Permission.objects.filter(codename__contains="sharelink")
for user in User.objects.filter(Q(user_permissions=add_permission)).distinct():
user.user_permissions.add(*sharelink_permissions)
for group in Group.objects.filter(Q(permissions=add_permission)).distinct():
group.permissions.add(*sharelink_permissions)
def remove_sharelink_permissions(apps, schema_editor):
sharelink_permissions = Permission.objects.filter(codename__contains="sharelink")
for user in User.objects.all():
user.user_permissions.remove(*sharelink_permissions)
for group in Group.objects.all():
group.permissions.remove(*sharelink_permissions)
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("documents", "1037_webp_encrypted_thumbnail_conversion"),
]
operations = [
migrations.CreateModel(
name="ShareLink",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"created",
models.DateTimeField(
blank=True,
db_index=True,
default=django.utils.timezone.now,
editable=False,
verbose_name="created",
),
),
(
"expiration",
models.DateTimeField(
blank=True,
db_index=True,
null=True,
verbose_name="expiration",
),
),
(
"slug",
models.SlugField(
blank=True,
editable=False,
unique=True,
verbose_name="slug",
),
),
(
"file_version",
models.CharField(
choices=[("archive", "Archive"), ("original", "Original")],
default="archive",
max_length=50,
),
),
(
"document",
models.ForeignKey(
blank=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="share_links",
to="documents.document",
verbose_name="document",
),
),
(
"owner",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="share_links",
to=settings.AUTH_USER_MODEL,
verbose_name="owner",
),
),
],
options={
"verbose_name": "share link",
"verbose_name_plural": "share links",
"ordering": ("created",),
},
),
migrations.RunPython(add_sharelink_permissions, remove_sharelink_permissions),
]

View File

@@ -675,3 +675,63 @@ class Note(models.Model):
def __str__(self):
return self.note
class ShareLink(models.Model):
class FileVersion(models.TextChoices):
ARCHIVE = ("archive", _("Archive"))
ORIGINAL = ("original", _("Original"))
created = models.DateTimeField(
_("created"),
default=timezone.now,
db_index=True,
blank=True,
editable=False,
)
expiration = models.DateTimeField(
_("expiration"),
blank=True,
null=True,
db_index=True,
)
slug = models.SlugField(
_("slug"),
db_index=True,
unique=True,
blank=True,
editable=False,
)
document = models.ForeignKey(
Document,
blank=True,
related_name="share_links",
on_delete=models.CASCADE,
verbose_name=_("document"),
)
file_version = models.CharField(
max_length=50,
choices=FileVersion.choices,
default=FileVersion.ARCHIVE,
)
owner = models.ForeignKey(
User,
blank=True,
null=True,
related_name="share_links",
on_delete=models.SET_NULL,
verbose_name=_("owner"),
)
class Meta:
ordering = ("created",)
verbose_name = _("share link")
verbose_name_plural = _("share links")
def __str__(self):
return f"Share Link for {self.document.title}"

View File

@@ -8,6 +8,7 @@ from celery import states
from django.conf import settings
from django.contrib.auth.models import Group
from django.contrib.auth.models import User
from django.utils.crypto import get_random_string
from django.utils.text import slugify
from django.utils.translation import gettext as _
from guardian.core import ObjectPermissionChecker
@@ -26,6 +27,7 @@ from .models import MatchingModel
from .models import PaperlessTask
from .models import SavedView
from .models import SavedViewFilterRule
from .models import ShareLink
from .models import StoragePath
from .models import Tag
from .models import UiSettings
@@ -941,3 +943,20 @@ class AcknowledgeTasksViewSerializer(serializers.Serializer):
def validate_tasks(self, tasks):
self._validate_task_id_list(tasks)
return tasks
class ShareLinkSerializer(OwnedObjectSerializer):
class Meta:
model = ShareLink
fields = (
"id",
"created",
"expiration",
"slug",
"document",
"file_version",
)
def create(self, validated_data):
validated_data["slug"] = get_random_string(50)
return super().create(validated_data)

View File

@@ -8,7 +8,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="description" content="Paperless-ngx Signed Out">
<meta name="author" content="The Paperless-ngx Team">
<meta name="author" content="Paperless-ngx project and contributors">
<meta name="robots" content="noindex,nofollow">
<title>{% translate "Paperless-ngx signed out" %}</title>

View File

@@ -8,7 +8,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="description" content="Paperless-ngx Sign In">
<meta name="author" content="The Paperless-ngx Team">
<meta name="author" content="Paperless-ngx project and contributors">
<meta name="robots" content="noindex,nofollow">
<title>{% translate "Paperless-ngx sign in" %}</title>
@@ -40,9 +40,17 @@
</svg>
<p>{% translate "Please sign in." %}</p>
{% if form.errors %}
<div class="alert alert-danger" role="alert">
{% translate "Your username and password didn't match. Please try again." %}
</div>
<div class="alert alert-danger" role="alert">
{% translate "Your username and password didn't match. Please try again." %}
</div>
{% elif request.GET.sharelink_notfound %}
<div class="alert alert-danger" role="alert">
{% translate "Share link was not found." %}
</div>
{% elif request.GET.sharelink_expired %}
<div class="alert alert-danger" role="alert">
{% translate "Share link has expired." %}
</div>
{% endif %}
{% translate "Username" as i18n_username %}
{% translate "Password" as i18n_password %}

View File

@@ -15,6 +15,7 @@ from unittest.mock import MagicMock
import celery
import pytest
from dateutil import parser
from dateutil.relativedelta import relativedelta
from django.conf import settings
from django.contrib.auth.models import Group
@@ -37,6 +38,7 @@ from documents.models import MatchingModel
from documents.models import Note
from documents.models import PaperlessTask
from documents.models import SavedView
from documents.models import ShareLink
from documents.models import StoragePath
from documents.models import Tag
from documents.tests.utils import DirectoriesMixin
@@ -2558,6 +2560,119 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
def test_create_share_links(self):
"""
GIVEN:
- Existing document
WHEN:
- API request is made to generate a share_link
- API request is made to view share_links on incorrect doc pk
- Invalid method request is made to view share_links doc
THEN:
- Link is created with a slug and associated with document
- 404
- Error
"""
doc = Document.objects.create(
title="test",
mime_type="application/pdf",
content="this is a document which will have notes added",
)
# never expires
resp = self.client.post(
"/api/share_links/",
data={
"document": doc.pk,
},
)
self.assertEqual(resp.status_code, status.HTTP_201_CREATED)
resp = self.client.post(
"/api/share_links/",
data={
"expiration": (timezone.now() + timedelta(days=7)).isoformat(),
"document": doc.pk,
"file_version": "original",
},
)
self.assertEqual(resp.status_code, status.HTTP_201_CREATED)
response = self.client.get(
f"/api/documents/{doc.pk}/share_links/",
format="json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
resp_data = response.json()
self.assertEqual(len(resp_data), 2)
self.assertGreater(len(resp_data[1]["slug"]), 0)
self.assertIsNone(resp_data[1]["expiration"])
self.assertEqual(
(parser.isoparse(resp_data[0]["expiration"]) - timezone.now()).days,
6,
)
sl1 = ShareLink.objects.get(slug=resp_data[1]["slug"])
self.assertEqual(str(sl1), f"Share Link for {doc.title}")
response = self.client.post(
f"/api/documents/{doc.pk}/share_links/",
format="json",
)
self.assertEqual(response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED)
response = self.client.get(
"/api/documents/99/share_links/",
format="json",
)
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
def test_share_links_permissions_aware(self):
"""
GIVEN:
- Existing document owned by user2 but with granted view perms for user1
WHEN:
- API request is made by user1 to view share links
THEN:
- Links only shown if user has permissions
"""
user1 = User.objects.create_user(username="test1")
user1.user_permissions.add(*Permission.objects.all())
user1.save()
user2 = User.objects.create_user(username="test2")
user2.save()
doc = Document.objects.create(
title="test",
mime_type="application/pdf",
content="this is a document which will have share links added",
)
doc.owner = user2
doc.save()
self.client.force_authenticate(user1)
resp = self.client.get(
f"/api/documents/{doc.pk}/share_links/",
format="json",
)
self.assertEqual(resp.content, b"Insufficient permissions")
self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN)
assign_perm("change_document", user1, doc)
resp = self.client.get(
f"/api/documents/{doc.pk}/share_links/",
format="json",
)
self.assertEqual(resp.status_code, status.HTTP_200_OK)
class TestDocumentApiV2(DirectoriesMixin, APITestCase):
def setUp(self):

View File

@@ -153,7 +153,7 @@ class TestExportImport(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
manifest = self._do_export(use_filename_format=use_filename_format)
self.assertEqual(len(manifest), 149)
self.assertEqual(len(manifest), 154)
# dont include consumer or AnonymousUser users
self.assertEqual(
@@ -247,7 +247,7 @@ class TestExportImport(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
self.assertEqual(Document.objects.get(id=self.d4.id).title, "wow_dec")
self.assertEqual(GroupObjectPermission.objects.count(), 1)
self.assertEqual(UserObjectPermission.objects.count(), 1)
self.assertEqual(Permission.objects.count(), 108)
self.assertEqual(Permission.objects.count(), 112)
messages = check_sanity()
# everything is alright after the test
self.assertEqual(len(messages), 0)
@@ -676,15 +676,15 @@ class TestExportImport(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
os.path.join(self.dirs.media_dir, "documents"),
)
self.assertEqual(ContentType.objects.count(), 27)
self.assertEqual(Permission.objects.count(), 108)
self.assertEqual(ContentType.objects.count(), 28)
self.assertEqual(Permission.objects.count(), 112)
manifest = self._do_export()
with paperless_environment():
self.assertEqual(
len(list(filter(lambda e: e["model"] == "auth.permission", manifest))),
108,
112,
)
# add 1 more to db to show objects are not re-created by import
Permission.objects.create(
@@ -692,7 +692,7 @@ class TestExportImport(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
codename="test_perm",
content_type_id=1,
)
self.assertEqual(Permission.objects.count(), 109)
self.assertEqual(Permission.objects.count(), 113)
# will cause an import error
self.user.delete()
@@ -701,5 +701,5 @@ class TestExportImport(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
with self.assertRaises(IntegrityError):
call_command("document_importer", "--no-progress-bar", self.target)
self.assertEqual(ContentType.objects.count(), 27)
self.assertEqual(Permission.objects.count(), 109)
self.assertEqual(ContentType.objects.count(), 28)
self.assertEqual(Permission.objects.count(), 113)

View File

@@ -1,31 +1,23 @@
import shutil
import os
import tempfile
from datetime import timedelta
from django.conf import settings
from django.contrib.auth.models import Permission
from django.contrib.auth.models import User
from django.test import TestCase
from django.test import override_settings
from django.utils import timezone
from rest_framework import status
from documents.models import Document
from documents.models import ShareLink
from documents.tests.utils import DirectoriesMixin
class TestViews(TestCase):
@classmethod
def setUpClass(cls):
# Provide a dummy static dir to silence whitenoise warnings
cls.static_dir = tempfile.mkdtemp()
cls.override = override_settings(
STATIC_ROOT=cls.static_dir,
)
cls.override.enable()
@classmethod
def tearDownClass(cls):
shutil.rmtree(cls.static_dir, ignore_errors=True)
cls.override.disable()
class TestViews(DirectoriesMixin, TestCase):
def setUp(self) -> None:
self.user = User.objects.create_user("testuser")
super().setUp()
def test_login_redirect(self):
response = self.client.get("/")
@@ -74,3 +66,69 @@ class TestViews(TestCase):
response.context_data["main_js"],
f"frontend/{language_actual}/main.js",
)
def test_share_link_views(self):
"""
GIVEN:
- Share link created
WHEN:
- Valid request for share link is made
- Invalid request for share link is made
- Request for expired share link is made
THEN:
- Document is returned without need for login
- User is redirected to login with error
- User is redirected to login with error
"""
_, filename = tempfile.mkstemp(dir=self.dirs.originals_dir)
content = b"This is a test"
with open(filename, "wb") as f:
f.write(content)
doc = Document.objects.create(
title="none",
filename=os.path.basename(filename),
mime_type="application/pdf",
)
sharelink_permissions = Permission.objects.filter(
codename__contains="sharelink",
)
self.user.user_permissions.add(*sharelink_permissions)
self.user.save()
self.client.force_login(self.user)
self.client.post(
"/api/share_links/",
{
"document": doc.pk,
"file_version": "original",
},
)
sl1 = ShareLink.objects.get(document=doc)
self.client.logout()
# Valid
response = self.client.get(f"/share/{sl1.slug}")
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.content, content)
# Invalid
response = self.client.get("/share/123notaslug", follow=True)
response.render()
self.assertEqual(response.request["PATH_INFO"], "/accounts/login/")
self.assertContains(response, b"Share link was not found")
# Expired
sl1.expiration = timezone.now() - timedelta(days=1)
sl1.save()
response = self.client.get(f"/share/{sl1.slug}", follow=True)
response.render()
self.assertEqual(response.request["PATH_INFO"], "/accounts/login/")
self.assertContains(response, b"Share link has expired")

View File

@@ -27,9 +27,12 @@ from django.http import Http404
from django.http import HttpResponse
from django.http import HttpResponseBadRequest
from django.http import HttpResponseForbidden
from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404
from django.utils import timezone
from django.utils.decorators import method_decorator
from django.utils.translation import get_language
from django.views import View
from django.views.decorators.cache import cache_control
from django.views.generic import TemplateView
from django_filters.rest_framework import DjangoFilterBackend
@@ -75,6 +78,7 @@ from .data_models import DocumentSource
from .filters import CorrespondentFilterSet
from .filters import DocumentFilterSet
from .filters import DocumentTypeFilterSet
from .filters import ShareLinkFilterSet
from .filters import StoragePathFilterSet
from .filters import TagFilterSet
from .matching import match_correspondents
@@ -87,6 +91,7 @@ from .models import DocumentType
from .models import Note
from .models import PaperlessTask
from .models import SavedView
from .models import ShareLink
from .models import StoragePath
from .models import Tag
from .parsers import get_parser_class_for_mime_type
@@ -100,6 +105,7 @@ from .serialisers import DocumentSerializer
from .serialisers import DocumentTypeSerializer
from .serialisers import PostDocumentSerializer
from .serialisers import SavedViewSerializer
from .serialisers import ShareLinkSerializer
from .serialisers import StoragePathSerializer
from .serialisers import TagSerializer
from .serialisers import TagSerializerVersion1
@@ -312,38 +318,12 @@ class DocumentViewSet(
doc,
):
return HttpResponseForbidden("Insufficient permissions")
if not self.original_requested(request) and doc.has_archive_version:
file_handle = doc.archive_file
filename = doc.get_public_filename(archive=True)
mime_type = "application/pdf"
else:
file_handle = doc.source_file
filename = doc.get_public_filename()
mime_type = doc.mime_type
# Support browser previewing csv files by using text mime type
if mime_type in {"application/csv", "text/csv"} and disposition == "inline":
mime_type = "text/plain"
if doc.storage_type == Document.STORAGE_TYPE_GPG:
file_handle = GnuPG.decrypted(file_handle)
response = HttpResponse(file_handle, content_type=mime_type)
# Firefox is not able to handle unicode characters in filename field
# RFC 5987 addresses this issue
# see https://datatracker.ietf.org/doc/html/rfc5987#section-4.2
# Chromium cannot handle commas in the filename
filename_normalized = normalize("NFKD", filename.replace(",", "_")).encode(
"ascii",
"ignore",
return serve_file(
doc=doc,
use_archive=not self.original_requested(request)
and doc.has_archive_version,
disposition=disposition,
)
filename_encoded = quote(filename)
content_disposition = (
f"{disposition}; "
f'filename="{filename_normalized}"; '
f"filename*=utf-8''{filename_encoded}"
)
response["Content-Disposition"] = content_disposition
return response
def get_metadata(self, file, mime_type):
if not os.path.isfile(file):
@@ -574,6 +554,35 @@ class DocumentViewSet(
},
)
@action(methods=["get"], detail=True)
def share_links(self, request, pk=None):
currentUser = request.user
try:
doc = Document.objects.get(pk=pk)
if currentUser is not None and not has_perms_owner_aware(
currentUser,
"change_document",
doc,
):
return HttpResponseForbidden("Insufficient permissions")
except Document.DoesNotExist:
raise Http404
if request.method == "GET":
now = timezone.now()
links = [
{
"id": c.id,
"created": c.created,
"expiration": c.expiration,
"slug": c.slug,
}
for c in ShareLink.objects.filter(document=doc)
.exclude(expiration__lt=now)
.order_by("-created")
]
return Response(links)
class SearchResultSerializer(DocumentSerializer, PassUserMixin):
def to_representation(self, instance):
@@ -1127,3 +1136,72 @@ class AcknowledgeTasksView(GenericAPIView):
return Response({"result": result})
except Exception:
return HttpResponseBadRequest()
class ShareLinkViewSet(ModelViewSet, PassUserMixin):
model = ShareLink
queryset = ShareLink.objects.all()
serializer_class = ShareLinkSerializer
pagination_class = StandardPagination
permission_classes = (IsAuthenticated, PaperlessObjectPermissions)
filter_backends = (
DjangoFilterBackend,
OrderingFilter,
ObjectOwnedOrGrantedPermissionsFilter,
)
filterset_class = ShareLinkFilterSet
ordering_fields = ("created", "expiration", "document")
class SharedLinkView(View):
authentication_classes = []
permission_classes = []
def get(self, request, slug):
share_link = ShareLink.objects.filter(slug=slug).first()
if share_link is None:
return HttpResponseRedirect("/accounts/login/?sharelink_notfound=1")
if share_link.expiration is not None and share_link.expiration < timezone.now():
return HttpResponseRedirect("/accounts/login/?sharelink_expired=1")
return serve_file(
doc=share_link.document,
use_archive=share_link.file_version == "archive",
disposition="inline",
)
def serve_file(doc: Document, use_archive: bool, disposition: str):
if use_archive:
file_handle = doc.archive_file
filename = doc.get_public_filename(archive=True)
mime_type = "application/pdf"
else:
file_handle = doc.source_file
filename = doc.get_public_filename()
mime_type = doc.mime_type
# Support browser previewing csv files by using text mime type
if mime_type in {"application/csv", "text/csv"} and disposition == "inline":
mime_type = "text/plain"
if doc.storage_type == Document.STORAGE_TYPE_GPG:
file_handle = GnuPG.decrypted(file_handle)
response = HttpResponse(file_handle, content_type=mime_type)
# Firefox is not able to handle unicode characters in filename field
# RFC 5987 addresses this issue
# see https://datatracker.ietf.org/doc/html/rfc5987#section-4.2
# Chromium cannot handle commas in the filename
filename_normalized = normalize("NFKD", filename.replace(",", "_")).encode(
"ascii",
"ignore",
)
filename_encoded = quote(filename)
content_disposition = (
f"{disposition}; "
f'filename="{filename_normalized}"; '
f"filename*=utf-8''{filename_encoded}"
)
response["Content-Disposition"] = content_disposition
return response