mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-07-28 18:24:38 -05:00
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:
@@ -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)
|
||||
|
@@ -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
|
||||
|
126
src/documents/migrations/1038_sharelink.py
Normal file
126
src/documents/migrations/1038_sharelink.py
Normal 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),
|
||||
]
|
@@ -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}"
|
||||
|
@@ -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)
|
||||
|
@@ -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>
|
||||
|
@@ -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 %}
|
||||
|
@@ -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):
|
||||
|
@@ -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)
|
||||
|
@@ -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")
|
||||
|
@@ -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
|
||||
|
Reference in New Issue
Block a user