mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-04-12 02:58:52 +00:00
Compare commits
8 Commits
feature-se
...
chore/add-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
57e6099b82 | ||
|
|
f4e14b821e | ||
|
|
999a61bc9e | ||
|
|
054e56941d | ||
|
|
269113e6f4 | ||
|
|
fdd5e3ecb2 | ||
|
|
df3b656352 | ||
|
|
51e721733f |
1
.github/workflows/ci-backend.yml
vendored
1
.github/workflows/ci-backend.yml
vendored
@@ -165,6 +165,7 @@ jobs:
|
||||
contents: read
|
||||
env:
|
||||
DEFAULT_PYTHON: "3.12"
|
||||
PAPERLESS_SECRET_KEY: "ci-typing-not-a-real-secret"
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
2
.github/workflows/ci-release.yml
vendored
2
.github/workflows/ci-release.yml
vendored
@@ -88,6 +88,7 @@ jobs:
|
||||
uv export --quiet --no-dev --all-extras --format requirements-txt --output-file requirements.txt
|
||||
- name: Compile messages
|
||||
env:
|
||||
PAPERLESS_SECRET_KEY: "ci-release-not-a-real-secret"
|
||||
PYTHON_VERSION: ${{ steps.setup-python.outputs.python-version }}
|
||||
run: |
|
||||
cd src/
|
||||
@@ -96,6 +97,7 @@ jobs:
|
||||
manage.py compilemessages
|
||||
- name: Collect static files
|
||||
env:
|
||||
PAPERLESS_SECRET_KEY: "ci-release-not-a-real-secret"
|
||||
PYTHON_VERSION: ${{ steps.setup-python.outputs.python-version }}
|
||||
run: |
|
||||
cd src/
|
||||
|
||||
2
.github/workflows/translate-strings.yml
vendored
2
.github/workflows/translate-strings.yml
vendored
@@ -36,6 +36,8 @@ jobs:
|
||||
--group dev \
|
||||
--frozen
|
||||
- name: Generate backend translation strings
|
||||
env:
|
||||
PAPERLESS_SECRET_KEY: "ci-translate-not-a-real-secret"
|
||||
run: cd src/ && uv run manage.py makemessages -l en_US -i "samples*"
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -79,6 +79,7 @@ virtualenv
|
||||
/docker-compose.env
|
||||
/docker-compose.yml
|
||||
.ruff_cache/
|
||||
.mypy_cache/
|
||||
|
||||
# Used for development
|
||||
scripts/import-for-development
|
||||
@@ -111,4 +112,6 @@ celerybeat-schedule*
|
||||
|
||||
# ignore pnpm package store folder created when setting up the devcontainer
|
||||
.pnpm-store/
|
||||
|
||||
# Git worktree local folder
|
||||
.worktrees
|
||||
|
||||
1958
.mypy-baseline.txt
1958
.mypy-baseline.txt
File diff suppressed because it is too large
Load Diff
12316
.pyrefly-baseline.json
12316
.pyrefly-baseline.json
File diff suppressed because one or more lines are too long
79
SECURITY.md
79
SECURITY.md
@@ -2,8 +2,83 @@
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
The Paperless-ngx team and community take security bugs seriously. We appreciate your efforts to responsibly disclose your findings, and will make every effort to acknowledge your contributions.
|
||||
The Paperless-ngx team and community take security issues seriously. We appreciate good-faith reports and will make every effort to review legitimate findings responsibly.
|
||||
|
||||
To report a security issue, please use the GitHub Security Advisory ["Report a Vulnerability"](https://github.com/paperless-ngx/paperless-ngx/security/advisories/new) tab.
|
||||
|
||||
The team will send a response indicating the next steps in handling your report. After the initial reply to your report, the security team will keep you informed of the progress towards a fix and full announcement, and may ask for additional information or guidance.
|
||||
After the initial reply to your report, the team may ask for additional information, reproduction steps, affected versions, configuration details, or proof-of-concept material needed to verify the issue.
|
||||
|
||||
## What makes a helpful report
|
||||
|
||||
Please include as much of the following as possible:
|
||||
|
||||
- A clear description of the issue and why it is a security vulnerability.
|
||||
- Affected Paperless-ngx version(s).
|
||||
- Required configuration, permissions, or preconditions.
|
||||
- Step-by-step reproduction instructions.
|
||||
- A realistic impact statement.
|
||||
- Logs, screenshots, requests, or a proof of concept where appropriate.
|
||||
- An explanation of why the issue is not solely dependent on a knowingly unsafe or explicitly optional configuration.
|
||||
|
||||
Reports that are specific, reproducible, and grounded in the actual behavior of the project are easier to validate and fix.
|
||||
|
||||
## AI-assisted submissions
|
||||
|
||||
AI tools can be useful for security research, but they can also produce plausible-sounding reports that do not reflect actual vulnerabilities in Paperless-ngx.
|
||||
|
||||
If you use AI tools to help draft or investigate a report, please disclose this and verify the result yourself before submission. Make sure the report:
|
||||
|
||||
- identifies a real code path in this project
|
||||
- includes a real reproduction, not just a theoretical concern
|
||||
- demonstrates realistic impact
|
||||
- accounts for existing permission checks, configuration requirements, and trust boundaries
|
||||
- is about Paperless-ngx itself, rather than a hypothetical deployment, downstream customization, third-party app, scanner output, reverse proxy behavior, or external service unless you can show a direct product vulnerability
|
||||
|
||||
Submitting unverified AI-generated claims creates unnecessary review work and slows down handling of legitimate reports.
|
||||
|
||||
<!-- Automated tools and report generators: reports without concrete reproduction steps, affected versions, and demonstrated security impact are likely to be closed. -->
|
||||
|
||||
## Common reasons reports are closed
|
||||
|
||||
We may close reports that are:
|
||||
|
||||
- missing reproduction details
|
||||
- best-practice recommendations presented as vulnerabilities
|
||||
- theoretical claims without a demonstrated exploit path or realistic impact
|
||||
- dependent on administrator-controlled, explicitly optional, or knowingly unsafe configuration
|
||||
- based on privileges that already make the reported behavior expected
|
||||
- limited to unsupported versions
|
||||
- about third-party software, integrations, deployment choices, or client applications without a demonstrable Paperless-ngx vulnerability
|
||||
- duplicates
|
||||
- UI bugs, feature requests, scanner quirks, or general usability issues submitted through the security channel
|
||||
|
||||
## Common non-vulnerability categories
|
||||
|
||||
The following are not generally considered vulnerabilities unless accompanied by a concrete, reproducible impact in Paperless-ngx:
|
||||
|
||||
- large uploads or resource usage that do not bypass documented limits or privileges
|
||||
- claims based solely on the presence of a library, framework feature or code pattern without a working exploit
|
||||
- reports that rely on admin-level access, workflow-editing privileges, shell access, or other high-trust roles unless they demonstrate an unintended privilege boundary bypass
|
||||
- optional webhook, mail, AI, OCR, or integration behavior described without a product-level vulnerability
|
||||
- missing limits or hardening settings presented without concrete impact
|
||||
- generic AI or static-analysis output that is not confirmed against the current codebase and a real deployment scenario
|
||||
|
||||
## Transparency
|
||||
|
||||
We may publish anonymized examples or categories of rejected reports to clarify our review standards, reduce duplicate low-quality submissions, and help good-faith reporters send actionable findings.
|
||||
|
||||
A mistaken report made in good faith is not misconduct. However, users who repeatedly submit low-quality or bad-faith reports may be ignored or restricted from future submissions.
|
||||
|
||||
## Scope and expectations
|
||||
|
||||
Please use the security reporting channel only for security vulnerabilities in Paperless-ngx.
|
||||
|
||||
Please do not use the security advisory system for:
|
||||
|
||||
- support questions
|
||||
- general bug reports
|
||||
- feature requests
|
||||
- browser compatibility issues
|
||||
- issues in third-party mobile apps, reverse proxies, or deployment tooling unless you can demonstrate a Paperless-ngx vulnerability
|
||||
|
||||
The team will review reports as time permits, but submission does not guarantee that a report is valid, in scope, or will result in a fix. Reports that do not describe a reproducible product-level issue may be closed without extended back-and-forth.
|
||||
|
||||
@@ -100,7 +100,7 @@ logger = logging.getLogger("paperless.serializers")
|
||||
|
||||
|
||||
# https://www.django-rest-framework.org/api-guide/serializers/#example
|
||||
class DynamicFieldsModelSerializer(serializers.ModelSerializer):
|
||||
class DynamicFieldsModelSerializer(serializers.ModelSerializer[Any]):
|
||||
"""
|
||||
A ModelSerializer that takes an additional `fields` argument that
|
||||
controls which fields should be displayed.
|
||||
@@ -121,7 +121,7 @@ class DynamicFieldsModelSerializer(serializers.ModelSerializer):
|
||||
self.fields.pop(field_name)
|
||||
|
||||
|
||||
class MatchingModelSerializer(serializers.ModelSerializer):
|
||||
class MatchingModelSerializer(serializers.ModelSerializer[Any]):
|
||||
document_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
def get_slug(self, obj) -> str:
|
||||
@@ -261,7 +261,7 @@ class SetPermissionsSerializer(serializers.DictField):
|
||||
|
||||
class OwnedObjectSerializer(
|
||||
SerializerWithPerms,
|
||||
serializers.ModelSerializer,
|
||||
serializers.ModelSerializer[Any],
|
||||
SetPermissionsMixin,
|
||||
):
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
@@ -469,7 +469,7 @@ class OwnedObjectSerializer(
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
|
||||
class OwnedObjectListSerializer(serializers.ListSerializer):
|
||||
class OwnedObjectListSerializer(serializers.ListSerializer[Any]):
|
||||
def to_representation(self, documents):
|
||||
self.child.context["shared_object_pks"] = self.child.get_shared_object_pks(
|
||||
documents,
|
||||
@@ -682,27 +682,27 @@ class TagSerializer(MatchingModelSerializer, OwnedObjectSerializer):
|
||||
return super().validate(attrs)
|
||||
|
||||
|
||||
class CorrespondentField(serializers.PrimaryKeyRelatedField):
|
||||
class CorrespondentField(serializers.PrimaryKeyRelatedField[Correspondent]):
|
||||
def get_queryset(self):
|
||||
return Correspondent.objects.all()
|
||||
|
||||
|
||||
class TagsField(serializers.PrimaryKeyRelatedField):
|
||||
class TagsField(serializers.PrimaryKeyRelatedField[Tag]):
|
||||
def get_queryset(self):
|
||||
return Tag.objects.all()
|
||||
|
||||
|
||||
class DocumentTypeField(serializers.PrimaryKeyRelatedField):
|
||||
class DocumentTypeField(serializers.PrimaryKeyRelatedField[DocumentType]):
|
||||
def get_queryset(self):
|
||||
return DocumentType.objects.all()
|
||||
|
||||
|
||||
class StoragePathField(serializers.PrimaryKeyRelatedField):
|
||||
class StoragePathField(serializers.PrimaryKeyRelatedField[StoragePath]):
|
||||
def get_queryset(self):
|
||||
return StoragePath.objects.all()
|
||||
|
||||
|
||||
class CustomFieldSerializer(serializers.ModelSerializer):
|
||||
class CustomFieldSerializer(serializers.ModelSerializer[CustomField]):
|
||||
data_type = serializers.ChoiceField(
|
||||
choices=CustomField.FieldDataType,
|
||||
read_only=False,
|
||||
@@ -816,7 +816,7 @@ def validate_documentlink_targets(user, doc_ids):
|
||||
)
|
||||
|
||||
|
||||
class CustomFieldInstanceSerializer(serializers.ModelSerializer):
|
||||
class CustomFieldInstanceSerializer(serializers.ModelSerializer[CustomFieldInstance]):
|
||||
field = serializers.PrimaryKeyRelatedField(queryset=CustomField.objects.all())
|
||||
value = ReadWriteSerializerMethodField(allow_null=True)
|
||||
|
||||
@@ -922,14 +922,14 @@ class CustomFieldInstanceSerializer(serializers.ModelSerializer):
|
||||
]
|
||||
|
||||
|
||||
class BasicUserSerializer(serializers.ModelSerializer):
|
||||
class BasicUserSerializer(serializers.ModelSerializer[User]):
|
||||
# Different than paperless.serializers.UserSerializer
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ["id", "username", "first_name", "last_name"]
|
||||
|
||||
|
||||
class NotesSerializer(serializers.ModelSerializer):
|
||||
class NotesSerializer(serializers.ModelSerializer[Note]):
|
||||
user = BasicUserSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
@@ -1256,7 +1256,7 @@ class DocumentSerializer(
|
||||
list_serializer_class = OwnedObjectListSerializer
|
||||
|
||||
|
||||
class SearchResultListSerializer(serializers.ListSerializer):
|
||||
class SearchResultListSerializer(serializers.ListSerializer[Document]):
|
||||
def to_representation(self, hits):
|
||||
document_ids = [hit["id"] for hit in hits]
|
||||
# Fetch all Document objects in the list in one SQL query.
|
||||
@@ -1313,7 +1313,7 @@ class SearchResultSerializer(DocumentSerializer):
|
||||
list_serializer_class = SearchResultListSerializer
|
||||
|
||||
|
||||
class SavedViewFilterRuleSerializer(serializers.ModelSerializer):
|
||||
class SavedViewFilterRuleSerializer(serializers.ModelSerializer[SavedViewFilterRule]):
|
||||
class Meta:
|
||||
model = SavedViewFilterRule
|
||||
fields = ["rule_type", "value"]
|
||||
@@ -2401,7 +2401,7 @@ class StoragePathSerializer(MatchingModelSerializer, OwnedObjectSerializer):
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
|
||||
class UiSettingsViewSerializer(serializers.ModelSerializer):
|
||||
class UiSettingsViewSerializer(serializers.ModelSerializer[UiSettings]):
|
||||
settings = serializers.DictField(required=False, allow_null=True)
|
||||
|
||||
class Meta:
|
||||
@@ -2760,7 +2760,7 @@ class BulkEditObjectsSerializer(SerializerWithPerms, SetPermissionsMixin):
|
||||
return attrs
|
||||
|
||||
|
||||
class WorkflowTriggerSerializer(serializers.ModelSerializer):
|
||||
class WorkflowTriggerSerializer(serializers.ModelSerializer[WorkflowTrigger]):
|
||||
id = serializers.IntegerField(required=False, allow_null=True)
|
||||
sources = fields.MultipleChoiceField(
|
||||
choices=WorkflowTrigger.DocumentSourceChoices.choices,
|
||||
@@ -2870,7 +2870,7 @@ class WorkflowTriggerSerializer(serializers.ModelSerializer):
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
|
||||
class WorkflowActionEmailSerializer(serializers.ModelSerializer):
|
||||
class WorkflowActionEmailSerializer(serializers.ModelSerializer[WorkflowActionEmail]):
|
||||
id = serializers.IntegerField(allow_null=True, required=False)
|
||||
|
||||
class Meta:
|
||||
@@ -2884,7 +2884,9 @@ class WorkflowActionEmailSerializer(serializers.ModelSerializer):
|
||||
]
|
||||
|
||||
|
||||
class WorkflowActionWebhookSerializer(serializers.ModelSerializer):
|
||||
class WorkflowActionWebhookSerializer(
|
||||
serializers.ModelSerializer[WorkflowActionWebhook],
|
||||
):
|
||||
id = serializers.IntegerField(allow_null=True, required=False)
|
||||
|
||||
def validate_url(self, url):
|
||||
@@ -2905,7 +2907,7 @@ class WorkflowActionWebhookSerializer(serializers.ModelSerializer):
|
||||
]
|
||||
|
||||
|
||||
class WorkflowActionSerializer(serializers.ModelSerializer):
|
||||
class WorkflowActionSerializer(serializers.ModelSerializer[WorkflowAction]):
|
||||
id = serializers.IntegerField(required=False, allow_null=True)
|
||||
assign_correspondent = CorrespondentField(allow_null=True, required=False)
|
||||
assign_tags = TagsField(many=True, allow_null=True, required=False)
|
||||
@@ -3027,7 +3029,7 @@ class WorkflowActionSerializer(serializers.ModelSerializer):
|
||||
return attrs
|
||||
|
||||
|
||||
class WorkflowSerializer(serializers.ModelSerializer):
|
||||
class WorkflowSerializer(serializers.ModelSerializer[Workflow]):
|
||||
order = serializers.IntegerField(required=False)
|
||||
|
||||
triggers = WorkflowTriggerSerializer(many=True)
|
||||
|
||||
@@ -6,6 +6,8 @@ from unittest.mock import patch
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.test import override_settings
|
||||
from PIL import Image
|
||||
from PIL.PngImagePlugin import PngInfo
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
@@ -201,6 +203,156 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
|
||||
)
|
||||
self.assertFalse(Path(old_logo.path).exists())
|
||||
|
||||
def test_api_strips_exif_data_from_uploaded_logo(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A JPEG logo upload containing EXIF metadata
|
||||
WHEN:
|
||||
- Uploaded via PATCH to app config
|
||||
THEN:
|
||||
- Stored logo image has EXIF metadata removed
|
||||
"""
|
||||
image = Image.new("RGB", (12, 12), "blue")
|
||||
exif = Image.Exif()
|
||||
exif[315] = "Paperless Test Author"
|
||||
|
||||
logo = BytesIO()
|
||||
image.save(logo, format="JPEG", exif=exif)
|
||||
logo.seek(0)
|
||||
|
||||
response = self.client.patch(
|
||||
f"{self.ENDPOINT}1/",
|
||||
{
|
||||
"app_logo": SimpleUploadedFile(
|
||||
name="logo-with-exif.jpg",
|
||||
content=logo.getvalue(),
|
||||
content_type="image/jpeg",
|
||||
),
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
config = ApplicationConfiguration.objects.first()
|
||||
with Image.open(config.app_logo.path) as stored_logo:
|
||||
stored_exif = stored_logo.getexif()
|
||||
|
||||
self.assertEqual(len(stored_exif), 0)
|
||||
|
||||
def test_api_strips_png_metadata_from_uploaded_logo(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A PNG logo upload containing text metadata
|
||||
WHEN:
|
||||
- Uploaded via PATCH to app config
|
||||
THEN:
|
||||
- Stored logo image has metadata removed
|
||||
"""
|
||||
image = Image.new("RGB", (12, 12), "green")
|
||||
pnginfo = PngInfo()
|
||||
pnginfo.add_text("Author", "Paperless Test Author")
|
||||
|
||||
logo = BytesIO()
|
||||
image.save(logo, format="PNG", pnginfo=pnginfo)
|
||||
logo.seek(0)
|
||||
|
||||
response = self.client.patch(
|
||||
f"{self.ENDPOINT}1/",
|
||||
{
|
||||
"app_logo": SimpleUploadedFile(
|
||||
name="logo-with-metadata.png",
|
||||
content=logo.getvalue(),
|
||||
content_type="image/png",
|
||||
),
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
config = ApplicationConfiguration.objects.first()
|
||||
with Image.open(config.app_logo.path) as stored_logo:
|
||||
stored_text = stored_logo.text
|
||||
|
||||
self.assertEqual(stored_text, {})
|
||||
|
||||
def test_api_accepts_valid_gif_logo(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A valid GIF logo upload
|
||||
WHEN:
|
||||
- Uploaded via PATCH to app config
|
||||
THEN:
|
||||
- Upload succeeds
|
||||
"""
|
||||
image = Image.new("RGB", (12, 12), "red")
|
||||
|
||||
logo = BytesIO()
|
||||
image.save(logo, format="GIF", comment=b"Paperless Test Comment")
|
||||
logo.seek(0)
|
||||
|
||||
response = self.client.patch(
|
||||
f"{self.ENDPOINT}1/",
|
||||
{
|
||||
"app_logo": SimpleUploadedFile(
|
||||
name="logo.gif",
|
||||
content=logo.getvalue(),
|
||||
content_type="image/gif",
|
||||
),
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
def test_api_rejects_invalid_raster_logo(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A file named as a JPEG but containing non-image payload data
|
||||
WHEN:
|
||||
- Uploaded via PATCH to app config
|
||||
THEN:
|
||||
- Upload is rejected with 400
|
||||
"""
|
||||
response = self.client.patch(
|
||||
f"{self.ENDPOINT}1/",
|
||||
{
|
||||
"app_logo": SimpleUploadedFile(
|
||||
name="not-an-image.jpg",
|
||||
content=b"<script>alert('xss')</script>",
|
||||
content_type="image/jpeg",
|
||||
),
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertIn("invalid logo image", str(response.data).lower())
|
||||
|
||||
@override_settings(MAX_IMAGE_PIXELS=100)
|
||||
def test_api_rejects_logo_exceeding_max_image_pixels(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A raster logo larger than the configured MAX_IMAGE_PIXELS limit
|
||||
WHEN:
|
||||
- Uploaded via PATCH to app config
|
||||
THEN:
|
||||
- Upload is rejected with 400
|
||||
"""
|
||||
image = Image.new("RGB", (12, 12), "purple")
|
||||
logo = BytesIO()
|
||||
image.save(logo, format="PNG")
|
||||
logo.seek(0)
|
||||
|
||||
response = self.client.patch(
|
||||
f"{self.ENDPOINT}1/",
|
||||
{
|
||||
"app_logo": SimpleUploadedFile(
|
||||
name="too-large.png",
|
||||
content=logo.getvalue(),
|
||||
content_type="image/png",
|
||||
),
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertIn(
|
||||
"uploaded logo exceeds the maximum allowed image size",
|
||||
str(response.data).lower(),
|
||||
)
|
||||
|
||||
def test_api_rejects_malicious_svg_logo(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
|
||||
@@ -18,6 +18,7 @@ from django.contrib.auth.models import Permission
|
||||
from django.contrib.auth.models import User
|
||||
from django.core import mail
|
||||
from django.core.cache import cache
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.db import DataError
|
||||
from django.test import override_settings
|
||||
from django.utils import timezone
|
||||
@@ -1377,6 +1378,79 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
|
||||
self.assertIsNone(overrides.document_type_id)
|
||||
self.assertIsNone(overrides.tag_ids)
|
||||
|
||||
def test_upload_with_path_traversal_filename_is_reduced_to_basename(self) -> None:
|
||||
self.consume_file_mock.return_value = celery.result.AsyncResult(
|
||||
id=str(uuid.uuid4()),
|
||||
)
|
||||
|
||||
payload = SimpleUploadedFile(
|
||||
"../../outside.pdf",
|
||||
(Path(__file__).parent / "samples" / "simple.pdf").read_bytes(),
|
||||
content_type="application/pdf",
|
||||
)
|
||||
|
||||
response = self.client.post(
|
||||
"/api/documents/post_document/",
|
||||
{"document": payload},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.consume_file_mock.assert_called_once()
|
||||
|
||||
input_doc, overrides = self.get_last_consume_delay_call_args()
|
||||
|
||||
self.assertEqual(input_doc.original_file.name, "outside.pdf")
|
||||
self.assertEqual(overrides.filename, "outside.pdf")
|
||||
self.assertNotIn("..", input_doc.original_file.name)
|
||||
self.assertNotIn("..", overrides.filename)
|
||||
self.assertTrue(
|
||||
input_doc.original_file.resolve(strict=False).is_relative_to(
|
||||
Path(settings.SCRATCH_DIR).resolve(strict=False),
|
||||
),
|
||||
)
|
||||
|
||||
def test_upload_with_path_traversal_content_disposition_filename_is_reduced_to_basename(
|
||||
self,
|
||||
) -> None:
|
||||
self.consume_file_mock.return_value = celery.result.AsyncResult(
|
||||
id=str(uuid.uuid4()),
|
||||
)
|
||||
|
||||
pdf_bytes = (Path(__file__).parent / "samples" / "simple.pdf").read_bytes()
|
||||
boundary = "paperless-boundary"
|
||||
payload = (
|
||||
(
|
||||
f"--{boundary}\r\n"
|
||||
'Content-Disposition: form-data; name="document"; '
|
||||
'filename="../../outside.pdf"\r\n'
|
||||
"Content-Type: application/pdf\r\n\r\n"
|
||||
).encode()
|
||||
+ pdf_bytes
|
||||
+ f"\r\n--{boundary}--\r\n".encode()
|
||||
)
|
||||
|
||||
response = self.client.generic(
|
||||
"POST",
|
||||
"/api/documents/post_document/",
|
||||
payload,
|
||||
content_type=f"multipart/form-data; boundary={boundary}",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.consume_file_mock.assert_called_once()
|
||||
|
||||
input_doc, overrides = self.get_last_consume_delay_call_args()
|
||||
|
||||
self.assertEqual(input_doc.original_file.name, "outside.pdf")
|
||||
self.assertEqual(overrides.filename, "outside.pdf")
|
||||
self.assertNotIn("..", input_doc.original_file.name)
|
||||
self.assertNotIn("..", overrides.filename)
|
||||
self.assertTrue(
|
||||
input_doc.original_file.resolve(strict=False).is_relative_to(
|
||||
Path(settings.SCRATCH_DIR).resolve(strict=False),
|
||||
),
|
||||
)
|
||||
|
||||
def test_document_filters_use_latest_version_content(self) -> None:
|
||||
root = Document.objects.create(
|
||||
title="versioned root",
|
||||
|
||||
@@ -291,7 +291,7 @@ class IndexView(TemplateView):
|
||||
return context
|
||||
|
||||
|
||||
class PassUserMixin(GenericAPIView):
|
||||
class PassUserMixin(GenericAPIView[Any]):
|
||||
"""
|
||||
Pass a user object to serializer
|
||||
"""
|
||||
@@ -457,7 +457,10 @@ class PermissionsAwareDocumentCountMixin(BulkPermissionMixin, PassUserMixin):
|
||||
|
||||
|
||||
@extend_schema_view(**generate_object_with_permissions_schema(CorrespondentSerializer))
|
||||
class CorrespondentViewSet(PermissionsAwareDocumentCountMixin, ModelViewSet):
|
||||
class CorrespondentViewSet(
|
||||
PermissionsAwareDocumentCountMixin,
|
||||
ModelViewSet[Correspondent],
|
||||
):
|
||||
model = Correspondent
|
||||
|
||||
queryset = Correspondent.objects.select_related("owner").order_by(Lower("name"))
|
||||
@@ -494,7 +497,7 @@ class CorrespondentViewSet(PermissionsAwareDocumentCountMixin, ModelViewSet):
|
||||
|
||||
|
||||
@extend_schema_view(**generate_object_with_permissions_schema(TagSerializer))
|
||||
class TagViewSet(PermissionsAwareDocumentCountMixin, ModelViewSet):
|
||||
class TagViewSet(PermissionsAwareDocumentCountMixin, ModelViewSet[Tag]):
|
||||
model = Tag
|
||||
serializer_class = TagSerializer
|
||||
document_count_through = Document.tags.through
|
||||
@@ -573,7 +576,10 @@ class TagViewSet(PermissionsAwareDocumentCountMixin, ModelViewSet):
|
||||
|
||||
|
||||
@extend_schema_view(**generate_object_with_permissions_schema(DocumentTypeSerializer))
|
||||
class DocumentTypeViewSet(PermissionsAwareDocumentCountMixin, ModelViewSet):
|
||||
class DocumentTypeViewSet(
|
||||
PermissionsAwareDocumentCountMixin,
|
||||
ModelViewSet[DocumentType],
|
||||
):
|
||||
model = DocumentType
|
||||
|
||||
queryset = DocumentType.objects.select_related("owner").order_by(Lower("name"))
|
||||
@@ -808,7 +814,7 @@ class DocumentViewSet(
|
||||
UpdateModelMixin,
|
||||
DestroyModelMixin,
|
||||
ListModelMixin,
|
||||
GenericViewSet,
|
||||
GenericViewSet[Document],
|
||||
):
|
||||
model = Document
|
||||
queryset = Document.objects.all()
|
||||
@@ -1952,7 +1958,7 @@ class ChatStreamingSerializer(serializers.Serializer):
|
||||
],
|
||||
name="dispatch",
|
||||
)
|
||||
class ChatStreamingView(GenericAPIView):
|
||||
class ChatStreamingView(GenericAPIView[Any]):
|
||||
permission_classes = (IsAuthenticated,)
|
||||
serializer_class = ChatStreamingSerializer
|
||||
|
||||
@@ -2278,7 +2284,7 @@ class LogViewSet(ViewSet):
|
||||
|
||||
|
||||
@extend_schema_view(**generate_object_with_permissions_schema(SavedViewSerializer))
|
||||
class SavedViewViewSet(BulkPermissionMixin, PassUserMixin, ModelViewSet):
|
||||
class SavedViewViewSet(BulkPermissionMixin, PassUserMixin, ModelViewSet[SavedView]):
|
||||
model = SavedView
|
||||
|
||||
queryset = SavedView.objects.select_related("owner").prefetch_related(
|
||||
@@ -2756,7 +2762,7 @@ class RemovePasswordDocumentsView(DocumentOperationPermissionMixin):
|
||||
},
|
||||
),
|
||||
)
|
||||
class PostDocumentView(GenericAPIView):
|
||||
class PostDocumentView(GenericAPIView[Any]):
|
||||
permission_classes = (IsAuthenticated,)
|
||||
serializer_class = PostDocumentSerializer
|
||||
parser_classes = (parsers.MultiPartParser,)
|
||||
@@ -2877,7 +2883,7 @@ class PostDocumentView(GenericAPIView):
|
||||
},
|
||||
),
|
||||
)
|
||||
class SelectionDataView(GenericAPIView):
|
||||
class SelectionDataView(GenericAPIView[Any]):
|
||||
permission_classes = (IsAuthenticated,)
|
||||
serializer_class = DocumentListSerializer
|
||||
parser_classes = (parsers.MultiPartParser, parsers.JSONParser)
|
||||
@@ -2981,7 +2987,7 @@ class SelectionDataView(GenericAPIView):
|
||||
},
|
||||
),
|
||||
)
|
||||
class SearchAutoCompleteView(GenericAPIView):
|
||||
class SearchAutoCompleteView(GenericAPIView[Any]):
|
||||
permission_classes = (IsAuthenticated,)
|
||||
|
||||
def get(self, request, format=None):
|
||||
@@ -3262,7 +3268,7 @@ class GlobalSearchView(PassUserMixin):
|
||||
},
|
||||
),
|
||||
)
|
||||
class StatisticsView(GenericAPIView):
|
||||
class StatisticsView(GenericAPIView[Any]):
|
||||
permission_classes = (IsAuthenticated,)
|
||||
|
||||
def get(self, request, format=None):
|
||||
@@ -3364,7 +3370,7 @@ class StatisticsView(GenericAPIView):
|
||||
)
|
||||
|
||||
|
||||
class BulkDownloadView(DocumentSelectionMixin, GenericAPIView):
|
||||
class BulkDownloadView(DocumentSelectionMixin, GenericAPIView[Any]):
|
||||
permission_classes = (IsAuthenticated,)
|
||||
serializer_class = BulkDownloadSerializer
|
||||
parser_classes = (parsers.JSONParser,)
|
||||
@@ -3417,7 +3423,7 @@ class BulkDownloadView(DocumentSelectionMixin, GenericAPIView):
|
||||
|
||||
|
||||
@extend_schema_view(**generate_object_with_permissions_schema(StoragePathSerializer))
|
||||
class StoragePathViewSet(PermissionsAwareDocumentCountMixin, ModelViewSet):
|
||||
class StoragePathViewSet(PermissionsAwareDocumentCountMixin, ModelViewSet[StoragePath]):
|
||||
model = StoragePath
|
||||
|
||||
queryset = StoragePath.objects.select_related("owner").order_by(
|
||||
@@ -3481,7 +3487,7 @@ class StoragePathViewSet(PermissionsAwareDocumentCountMixin, ModelViewSet):
|
||||
return Response(result)
|
||||
|
||||
|
||||
class UiSettingsView(GenericAPIView):
|
||||
class UiSettingsView(GenericAPIView[Any]):
|
||||
queryset = UiSettings.objects.all()
|
||||
permission_classes = (IsAuthenticated, PaperlessObjectPermissions)
|
||||
serializer_class = UiSettingsViewSerializer
|
||||
@@ -3579,7 +3585,7 @@ class UiSettingsView(GenericAPIView):
|
||||
},
|
||||
),
|
||||
)
|
||||
class RemoteVersionView(GenericAPIView):
|
||||
class RemoteVersionView(GenericAPIView[Any]):
|
||||
cache_key = "remote_version_view_latest_release"
|
||||
|
||||
def get(self, request, format=None):
|
||||
@@ -3656,7 +3662,7 @@ class RemoteVersionView(GenericAPIView):
|
||||
),
|
||||
],
|
||||
)
|
||||
class TasksViewSet(ReadOnlyModelViewSet):
|
||||
class TasksViewSet(ReadOnlyModelViewSet[PaperlessTask]):
|
||||
permission_classes = (IsAuthenticated, PaperlessObjectPermissions)
|
||||
serializer_class = TasksViewSerializer
|
||||
filter_backends = (
|
||||
@@ -3730,7 +3736,7 @@ class TasksViewSet(ReadOnlyModelViewSet):
|
||||
)
|
||||
|
||||
|
||||
class ShareLinkViewSet(ModelViewSet, PassUserMixin):
|
||||
class ShareLinkViewSet(PassUserMixin, ModelViewSet[ShareLink]):
|
||||
model = ShareLink
|
||||
|
||||
queryset = ShareLink.objects.all()
|
||||
@@ -3747,7 +3753,7 @@ class ShareLinkViewSet(ModelViewSet, PassUserMixin):
|
||||
ordering_fields = ("created", "expiration", "document")
|
||||
|
||||
|
||||
class ShareLinkBundleViewSet(ModelViewSet, PassUserMixin):
|
||||
class ShareLinkBundleViewSet(PassUserMixin, ModelViewSet[ShareLinkBundle]):
|
||||
model = ShareLinkBundle
|
||||
|
||||
queryset = ShareLinkBundle.objects.all()
|
||||
@@ -4104,7 +4110,7 @@ class BulkEditObjectsView(PassUserMixin):
|
||||
return Response({"result": "OK"})
|
||||
|
||||
|
||||
class WorkflowTriggerViewSet(ModelViewSet):
|
||||
class WorkflowTriggerViewSet(ModelViewSet[WorkflowTrigger]):
|
||||
permission_classes = (IsAuthenticated, PaperlessObjectPermissions)
|
||||
|
||||
serializer_class = WorkflowTriggerSerializer
|
||||
@@ -4122,7 +4128,7 @@ class WorkflowTriggerViewSet(ModelViewSet):
|
||||
return super().partial_update(request, *args, **kwargs)
|
||||
|
||||
|
||||
class WorkflowActionViewSet(ModelViewSet):
|
||||
class WorkflowActionViewSet(ModelViewSet[WorkflowAction]):
|
||||
permission_classes = (IsAuthenticated, PaperlessObjectPermissions)
|
||||
|
||||
serializer_class = WorkflowActionSerializer
|
||||
@@ -4147,7 +4153,7 @@ class WorkflowActionViewSet(ModelViewSet):
|
||||
return super().partial_update(request, *args, **kwargs)
|
||||
|
||||
|
||||
class WorkflowViewSet(ModelViewSet):
|
||||
class WorkflowViewSet(ModelViewSet[Workflow]):
|
||||
permission_classes = (IsAuthenticated, PaperlessObjectPermissions)
|
||||
|
||||
serializer_class = WorkflowSerializer
|
||||
@@ -4165,7 +4171,7 @@ class WorkflowViewSet(ModelViewSet):
|
||||
)
|
||||
|
||||
|
||||
class CustomFieldViewSet(PermissionsAwareDocumentCountMixin, ModelViewSet):
|
||||
class CustomFieldViewSet(PermissionsAwareDocumentCountMixin, ModelViewSet[CustomField]):
|
||||
permission_classes = (IsAuthenticated, PaperlessObjectPermissions)
|
||||
|
||||
serializer_class = CustomFieldSerializer
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
from io import BytesIO
|
||||
|
||||
import magic
|
||||
from allauth.mfa.adapter import get_adapter as get_mfa_adapter
|
||||
@@ -11,13 +12,16 @@ from django.contrib.auth.models import Group
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.auth.password_validation import validate_password
|
||||
from django.core.files.uploadedfile import InMemoryUploadedFile
|
||||
from django.core.files.uploadedfile import UploadedFile
|
||||
from PIL import Image
|
||||
from rest_framework import serializers
|
||||
from rest_framework.authtoken.serializers import AuthTokenSerializer
|
||||
|
||||
from paperless.models import ApplicationConfiguration
|
||||
from paperless.network import validate_outbound_http_url
|
||||
from paperless.validators import reject_dangerous_svg
|
||||
from paperless.validators import validate_raster_image
|
||||
from paperless_mail.serialisers import ObfuscatedPasswordField
|
||||
|
||||
logger = logging.getLogger("paperless.settings")
|
||||
@@ -70,7 +74,7 @@ class PaperlessAuthTokenSerializer(AuthTokenSerializer):
|
||||
return attrs
|
||||
|
||||
|
||||
class UserSerializer(PasswordValidationMixin, serializers.ModelSerializer):
|
||||
class UserSerializer(PasswordValidationMixin, serializers.ModelSerializer[User]):
|
||||
password = ObfuscatedPasswordField(required=False)
|
||||
user_permissions = serializers.SlugRelatedField(
|
||||
many=True,
|
||||
@@ -138,7 +142,7 @@ class UserSerializer(PasswordValidationMixin, serializers.ModelSerializer):
|
||||
return user
|
||||
|
||||
|
||||
class GroupSerializer(serializers.ModelSerializer):
|
||||
class GroupSerializer(serializers.ModelSerializer[Group]):
|
||||
permissions = serializers.SlugRelatedField(
|
||||
many=True,
|
||||
queryset=Permission.objects.exclude(content_type__app_label="admin"),
|
||||
@@ -154,7 +158,7 @@ class GroupSerializer(serializers.ModelSerializer):
|
||||
)
|
||||
|
||||
|
||||
class SocialAccountSerializer(serializers.ModelSerializer):
|
||||
class SocialAccountSerializer(serializers.ModelSerializer[SocialAccount]):
|
||||
name = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
@@ -172,7 +176,7 @@ class SocialAccountSerializer(serializers.ModelSerializer):
|
||||
return "Unknown App"
|
||||
|
||||
|
||||
class ProfileSerializer(PasswordValidationMixin, serializers.ModelSerializer):
|
||||
class ProfileSerializer(PasswordValidationMixin, serializers.ModelSerializer[User]):
|
||||
email = serializers.EmailField(allow_blank=True, required=False)
|
||||
password = ObfuscatedPasswordField(required=False, allow_null=False)
|
||||
auth_token = serializers.SlugRelatedField(read_only=True, slug_field="key")
|
||||
@@ -205,7 +209,9 @@ class ProfileSerializer(PasswordValidationMixin, serializers.ModelSerializer):
|
||||
)
|
||||
|
||||
|
||||
class ApplicationConfigurationSerializer(serializers.ModelSerializer):
|
||||
class ApplicationConfigurationSerializer(
|
||||
serializers.ModelSerializer[ApplicationConfiguration],
|
||||
):
|
||||
user_args = serializers.JSONField(binary=True, allow_null=True)
|
||||
barcode_tag_mapping = serializers.JSONField(binary=True, allow_null=True)
|
||||
llm_api_key = ObfuscatedPasswordField(
|
||||
@@ -233,9 +239,40 @@ class ApplicationConfigurationSerializer(serializers.ModelSerializer):
|
||||
instance.app_logo.delete()
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
def _sanitize_raster_image(self, file: UploadedFile) -> UploadedFile:
|
||||
try:
|
||||
data = BytesIO()
|
||||
image = Image.open(file)
|
||||
image.save(data, format=image.format)
|
||||
data.seek(0)
|
||||
|
||||
return InMemoryUploadedFile(
|
||||
file=data,
|
||||
field_name=file.field_name,
|
||||
name=file.name,
|
||||
content_type=file.content_type,
|
||||
size=data.getbuffer().nbytes,
|
||||
charset=getattr(file, "charset", None),
|
||||
)
|
||||
finally:
|
||||
image.close()
|
||||
|
||||
def validate_app_logo(self, file: UploadedFile):
|
||||
if file and magic.from_buffer(file.read(2048), mime=True) == "image/svg+xml":
|
||||
reject_dangerous_svg(file)
|
||||
"""
|
||||
Validates and sanitizes the uploaded app logo image. Model field already restricts to
|
||||
jpg/png/gif/svg.
|
||||
"""
|
||||
if file:
|
||||
mime_type = magic.from_buffer(file.read(2048), mime=True)
|
||||
|
||||
if mime_type == "image/svg+xml":
|
||||
reject_dangerous_svg(file)
|
||||
else:
|
||||
validate_raster_image(file)
|
||||
|
||||
if mime_type in {"image/jpeg", "image/png"}:
|
||||
file = self._sanitize_raster_image(file)
|
||||
|
||||
return file
|
||||
|
||||
def validate_llm_endpoint(self, value: str | None) -> str | None:
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
from io import BytesIO
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.files.uploadedfile import UploadedFile
|
||||
from lxml import etree
|
||||
from PIL import Image
|
||||
|
||||
ALLOWED_SVG_TAGS: set[str] = {
|
||||
# Basic shapes
|
||||
@@ -254,3 +258,30 @@ def reject_dangerous_svg(file: UploadedFile) -> None:
|
||||
raise ValidationError(
|
||||
f"URI scheme not allowed in {attr_name}: must be #anchor, relative path, or data:image/*",
|
||||
)
|
||||
|
||||
|
||||
def validate_raster_image(file: UploadedFile) -> None:
|
||||
"""
|
||||
Validates that the uploaded file is a valid raster image (JPEG, PNG, etc.)
|
||||
and does not exceed maximum pixel limits.
|
||||
Raises ValidationError if the image is invalid or exceeds the allowed size.
|
||||
"""
|
||||
|
||||
file.seek(0)
|
||||
image_data = file.read()
|
||||
try:
|
||||
with Image.open(BytesIO(image_data)) as image:
|
||||
image.verify()
|
||||
|
||||
if (
|
||||
settings.MAX_IMAGE_PIXELS is not None
|
||||
and settings.MAX_IMAGE_PIXELS > 0
|
||||
and image.width * image.height > settings.MAX_IMAGE_PIXELS
|
||||
):
|
||||
raise ValidationError(
|
||||
"Uploaded logo exceeds the maximum allowed image size.",
|
||||
)
|
||||
if image.format is None: # pragma: no cover
|
||||
raise ValidationError("Invalid logo image.")
|
||||
except (OSError, Image.DecompressionBombError) as e:
|
||||
raise ValidationError("Invalid logo image.") from e
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from collections import OrderedDict
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from allauth.mfa import signals
|
||||
from allauth.mfa.adapter import get_adapter as get_mfa_adapter
|
||||
@@ -114,7 +115,7 @@ class FaviconView(View):
|
||||
return HttpResponseNotFound("favicon.ico not found")
|
||||
|
||||
|
||||
class UserViewSet(ModelViewSet):
|
||||
class UserViewSet(ModelViewSet[User]):
|
||||
_BOOL_NOT_PROVIDED = object()
|
||||
model = User
|
||||
|
||||
@@ -216,7 +217,7 @@ class UserViewSet(ModelViewSet):
|
||||
return HttpResponseNotFound("TOTP not found")
|
||||
|
||||
|
||||
class GroupViewSet(ModelViewSet):
|
||||
class GroupViewSet(ModelViewSet[Group]):
|
||||
model = Group
|
||||
|
||||
queryset = Group.objects.order_by(Lower("name"))
|
||||
@@ -229,7 +230,7 @@ class GroupViewSet(ModelViewSet):
|
||||
ordering_fields = ("name",)
|
||||
|
||||
|
||||
class ProfileView(GenericAPIView):
|
||||
class ProfileView(GenericAPIView[Any]):
|
||||
"""
|
||||
User profile view, only available when logged in
|
||||
"""
|
||||
@@ -288,7 +289,7 @@ class ProfileView(GenericAPIView):
|
||||
},
|
||||
),
|
||||
)
|
||||
class TOTPView(GenericAPIView):
|
||||
class TOTPView(GenericAPIView[Any]):
|
||||
"""
|
||||
TOTP views
|
||||
"""
|
||||
@@ -368,7 +369,7 @@ class TOTPView(GenericAPIView):
|
||||
},
|
||||
),
|
||||
)
|
||||
class GenerateAuthTokenView(GenericAPIView):
|
||||
class GenerateAuthTokenView(GenericAPIView[Any]):
|
||||
"""
|
||||
Generates (or re-generates) an auth token, requires a logged in user
|
||||
unlike the default DRF endpoint
|
||||
@@ -397,7 +398,7 @@ class GenerateAuthTokenView(GenericAPIView):
|
||||
},
|
||||
),
|
||||
)
|
||||
class ApplicationConfigurationViewSet(ModelViewSet):
|
||||
class ApplicationConfigurationViewSet(ModelViewSet[ApplicationConfiguration]):
|
||||
model = ApplicationConfiguration
|
||||
|
||||
queryset = ApplicationConfiguration.objects
|
||||
@@ -450,7 +451,7 @@ class ApplicationConfigurationViewSet(ModelViewSet):
|
||||
},
|
||||
),
|
||||
)
|
||||
class DisconnectSocialAccountView(GenericAPIView):
|
||||
class DisconnectSocialAccountView(GenericAPIView[Any]):
|
||||
"""
|
||||
Disconnects a social account provider from the user account
|
||||
"""
|
||||
@@ -476,7 +477,7 @@ class DisconnectSocialAccountView(GenericAPIView):
|
||||
},
|
||||
),
|
||||
)
|
||||
class SocialAccountProvidersView(GenericAPIView):
|
||||
class SocialAccountProvidersView(GenericAPIView[Any]):
|
||||
"""
|
||||
List of social account providers
|
||||
"""
|
||||
|
||||
@@ -57,7 +57,7 @@ class MailAccountSerializer(OwnedObjectSerializer):
|
||||
return instance
|
||||
|
||||
|
||||
class AccountField(serializers.PrimaryKeyRelatedField):
|
||||
class AccountField(serializers.PrimaryKeyRelatedField[MailAccount]):
|
||||
def get_queryset(self):
|
||||
return MailAccount.objects.all().order_by("-id")
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import datetime
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
from django.http import HttpResponseBadRequest
|
||||
from django.http import HttpResponseForbidden
|
||||
@@ -65,7 +66,7 @@ from paperless_mail.tasks import process_mail_accounts
|
||||
},
|
||||
),
|
||||
)
|
||||
class MailAccountViewSet(ModelViewSet, PassUserMixin):
|
||||
class MailAccountViewSet(PassUserMixin, ModelViewSet[MailAccount]):
|
||||
model = MailAccount
|
||||
|
||||
queryset = MailAccount.objects.all().order_by("pk")
|
||||
@@ -159,7 +160,7 @@ class MailAccountViewSet(ModelViewSet, PassUserMixin):
|
||||
return Response({"result": "OK"})
|
||||
|
||||
|
||||
class ProcessedMailViewSet(ReadOnlyModelViewSet, PassUserMixin):
|
||||
class ProcessedMailViewSet(PassUserMixin, ReadOnlyModelViewSet[ProcessedMail]):
|
||||
permission_classes = (IsAuthenticated, PaperlessObjectPermissions)
|
||||
serializer_class = ProcessedMailSerializer
|
||||
pagination_class = StandardPagination
|
||||
@@ -187,7 +188,7 @@ class ProcessedMailViewSet(ReadOnlyModelViewSet, PassUserMixin):
|
||||
return Response({"result": "OK", "deleted_mail_ids": mail_ids})
|
||||
|
||||
|
||||
class MailRuleViewSet(ModelViewSet, PassUserMixin):
|
||||
class MailRuleViewSet(PassUserMixin, ModelViewSet[MailRule]):
|
||||
model = MailRule
|
||||
|
||||
queryset = MailRule.objects.all().order_by("order")
|
||||
@@ -203,7 +204,7 @@ class MailRuleViewSet(ModelViewSet, PassUserMixin):
|
||||
responses={200: None},
|
||||
),
|
||||
)
|
||||
class OauthCallbackView(GenericAPIView):
|
||||
class OauthCallbackView(GenericAPIView[Any]):
|
||||
permission_classes = (IsAuthenticated,)
|
||||
|
||||
def get(self, request, format=None):
|
||||
|
||||
Reference in New Issue
Block a user