Compare commits

..

7 Commits

Author SHA1 Message Date
shamoon
d6cfd87cc0 Revert "Documentation: add note about logo file visibility and exif data"
This reverts commit 43b4f36026.
2025-09-21 16:08:02 -07:00
shamoon
7a287e7479 Merge branch 'main' into fix-strip-exif 2025-09-21 16:07:56 -07:00
shamoon
43b4f36026 Documentation: add note about logo file visibility and exif data 2025-09-21 16:07:29 -07:00
shamoon
76a81adcb5 Fix: remove extraneous exif from logo images 2025-09-21 09:52:18 -07:00
shamoon
6b868a5ecb Fix: restore str celery beat schedule filename (#10893) 2025-09-20 18:54:56 -07:00
shamoon
3e4aa87cc5 Fix formatting 2025-09-12 15:55:22 -07:00
shamoon
fc95d42b35 Documentation: add guidance for feature PRs in CONTRIBUTING.md 2025-09-12 15:51:49 -07:00
7 changed files with 298 additions and 123 deletions

View File

@@ -2,9 +2,11 @@
If you feel like contributing to the project, please do! Bug fixes and improvements are always welcome. If you feel like contributing to the project, please do! Bug fixes and improvements are always welcome.
⚠️ Please note: Pull requests that implement a new feature or enhancement _should almost always target an existing feature request_ with evidence of community interest and discussion. This is in order to balance the work of implementing and maintaining new features / enhancements. Pull requests that are opened without meeting this requirement may not be merged.
If you want to implement something big: If you want to implement something big:
- Please start a discussion about that in the issues! Maybe something similar is already in development and we can make it happen together. - As above, please start with a discussion! Maybe something similar is already in development and we can make it happen together.
- When making additions to the project, consider if the majority of users will benefit from your change. If not, you're probably better of forking the project. - When making additions to the project, consider if the majority of users will benefit from your change. If not, you're probably better of forking the project.
- Also consider if your change will get in the way of other users. A good change is a change that enhances the experience of some users who want that change and does not affect users who do not care about the change. - Also consider if your change will get in the way of other users. A good change is a change that enhances the experience of some users who want that change and does not affect users who do not care about the change.
- Please see the [paperless-ngx merge process](#merging-prs) below. - Please see the [paperless-ngx merge process](#merging-prs) below.

View File

@@ -166,13 +166,10 @@
</div> </div>
<div class="nav-group mt-3 mb-1"> <div class="nav-group mt-3 mb-1">
<h6 class="sidebar-heading px-3 text-muted d-flex align-items-center"> <h6 class="sidebar-heading px-3 text-muted">
<span i18n>Manage</span> <span i18n>Manage</span>
<button class="btn btn-link p-2 py-0" (click)="manageCollapse.toggle()">
<i-bs width="0.9em" height="0.9em" [name]="isManageMenuCollapsed ? 'chevron-down' : 'chevron-up'"></i-bs>
</button>
</h6> </h6>
<ul class="nav flex-column mb-2" #manageCollapse="ngbCollapse" [(ngbCollapse)]="isManageMenuCollapsed"> <ul class="nav flex-column mb-2">
<li class="nav-item app-link" <li class="nav-item app-link"
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Correspondent }"> *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Correspondent }">
<a class="nav-link" routerLink="correspondents" routerLinkActive="active" (click)="closeMenu()" <a class="nav-link" routerLink="correspondents" routerLinkActive="active" (click)="closeMenu()"
@@ -246,124 +243,117 @@
</div> </div>
<div class="nav-group mt-auto mb-1"> <div class="nav-group mt-auto mb-1">
<h6 class="sidebar-heading px-3 pt-4 text-muted d-flex align-items-center"> <h6 class="sidebar-heading px-3 pt-4 text-muted">
<span i18n>Administration</span> <span i18n>Administration</span>
<button class="btn btn-link p-2 py-0" (click)="adminCollapse.toggle()">
<i-bs width="0.9em" height="0.9em" [name]="isAdminMenuCollapsed ? 'chevron-down' : 'chevron-up'"></i-bs>
</button>
</h6> </h6>
<div class="mb-2"> <ul class="nav flex-column mb-2">
<ul class="nav flex-column" #adminCollapse="ngbCollapse" [(ngbCollapse)]="isAdminMenuCollapsed"> <li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.UISettings }"
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.UISettings }" tourAnchor="tour.settings">
tourAnchor="tour.settings"> <a class="nav-link" routerLink="settings" routerLinkActive="active" (click)="closeMenu()"
<a class="nav-link" routerLink="settings" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Settings" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
ngbPopover="Settings" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim"> <i-bs class="me-1" name="gear"></i-bs><span>&nbsp;<ng-container i18n>Settings</ng-container></span>
<i-bs class="me-1" name="gear"></i-bs><span>&nbsp;<ng-container i18n>Settings</ng-container></span> </a>
</a> </li>
</li> <li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.AppConfig }">
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.AppConfig }"> <a class="nav-link" routerLink="config" routerLinkActive="active" (click)="closeMenu()"
<a class="nav-link" routerLink="config" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Configuration" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
ngbPopover="Configuration" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim"> <i-bs class="me-1" name="sliders2-vertical"></i-bs><span>&nbsp;<ng-container i18n>Configuration</ng-container></span>
<i-bs class="me-1" name="sliders2-vertical"></i-bs><span>&nbsp;<ng-container i18n>Configuration</ng-container></span> </a>
</a> </li>
</li> <li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.User }">
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.User }"> <a class="nav-link" routerLink="usersgroups" routerLinkActive="active" (click)="closeMenu()"
<a class="nav-link" routerLink="usersgroups" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Users & Groups" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
ngbPopover="Users & Groups" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim"> <i-bs class="me-1" name="people"></i-bs><span>&nbsp;<ng-container i18n>Users & Groups</ng-container></span>
<i-bs class="me-1" name="people"></i-bs><span>&nbsp;<ng-container i18n>Users & Groups</ng-container></span> </a>
</a> </li>
</li> <li class="nav-item app-link"
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.PaperlessTask }"
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.PaperlessTask }" tourAnchor="tour.file-tasks">
tourAnchor="tour.file-tasks"> <a class="nav-link" routerLink="tasks" routerLinkActive="active" (click)="closeMenu()"
<a class="nav-link" routerLink="tasks" routerLinkActive="active" (click)="closeMenu()" ngbPopover="File Tasks" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
ngbPopover="File Tasks" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim"> <i-bs class="me-1" name="list-task"></i-bs><span>&nbsp;<ng-container i18n>File Tasks</ng-container>@if (tasksService.failedFileTasks.length > 0) {
<i-bs class="me-1" name="list-task"></i-bs><span>&nbsp;<ng-container i18n>File Tasks</ng-container>@if (tasksService.failedFileTasks.length > 0) { <span><span class="badge bg-danger ms-2 d-inline">{{tasksService.failedFileTasks.length}}</span></span>
<span><span class="badge bg-danger ms-2 d-inline">{{tasksService.failedFileTasks.length}}</span></span> }</span>
}</span> @if (tasksService.failedFileTasks.length > 0 && slimSidebarEnabled) {
@if (tasksService.failedFileTasks.length > 0 && slimSidebarEnabled) { <span class="badge bg-danger position-absolute top-0 end-0 d-none d-md-block">{{tasksService.failedFileTasks.length}}</span>
<span class="badge bg-danger position-absolute top-0 end-0 d-none d-md-block">{{tasksService.failedFileTasks.length}}</span> }
} </a>
</a> </li>
</li> @if (permissionsService.isAdmin()) {
@if (permissionsService.isAdmin()) { <li class="nav-item app-link">
<li class="nav-item app-link"> <a class="nav-link" routerLink="logs" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Logs"
<a class="nav-link" routerLink="logs" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Logs"
i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body"
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-1" name="text-left"></i-bs><span>&nbsp;<ng-container i18n>Logs</ng-container></span>
</a>
</li>
}
</ul>
<ul class="nav flex-column">
<li class="nav-item mt-2" tourAnchor="tour.outro">
<a class="px-3 py-2 text-muted small d-flex align-items-center flex-wrap text-decoration-none"
target="_blank" rel="noopener noreferrer" href="https://docs.paperless-ngx.com" ngbPopover="Documentation"
i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body"
triggers="mouseenter:mouseleave" popoverClass="popover-slim"> triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="d-flex" name="question-circle"></i-bs><span class="ms-1">&nbsp;<ng-container i18n>Documentation</ng-container></span> <i-bs class="me-1" name="text-left"></i-bs><span>&nbsp;<ng-container i18n>Logs</ng-container></span>
</a> </a>
</li> </li>
<li class="nav-item" [class.visually-hidden]="slimSidebarEnabled"> }
<div class="px-3 py-0 text-muted small d-flex align-items-center flex-wrap"> <li class="nav-item mt-2" tourAnchor="tour.outro">
<div class="me-3"> <a class="px-3 py-2 text-muted small d-flex align-items-center flex-wrap text-decoration-none"
<a class="text-muted text-decoration-none" target="_blank" rel="noopener noreferrer" target="_blank" rel="noopener noreferrer" href="https://docs.paperless-ngx.com" ngbPopover="Documentation"
href="https://github.com/paperless-ngx/paperless-ngx" ngbPopover="GitHub" i18n-ngbPopover i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body"
[disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
triggers="mouseenter:mouseleave" popoverClass="popover-slim"> <i-bs class="d-flex" name="question-circle"></i-bs><span class="ms-1">&nbsp;<ng-container i18n>Documentation</ng-container></span>
{{ versionString }} </a>
</a> </li>
</div> <li class="nav-item" [class.visually-hidden]="slimSidebarEnabled">
@if (!settingsService.updateCheckingIsSet || appRemoteVersion) { <div class="px-3 py-0 text-muted small d-flex align-items-center flex-wrap">
<div class="version-check"> <div class="me-3">
<ng-template #updateAvailablePopContent> <a class="text-muted text-decoration-none" target="_blank" rel="noopener noreferrer"
<span class="small">Paperless-ngx {{ appRemoteVersion.version }} <ng-container i18n>is href="https://github.com/paperless-ngx/paperless-ngx" ngbPopover="GitHub" i18n-ngbPopover
available.</ng-container><br /><ng-container i18n>Click to view.</ng-container></span> [disablePopover]="!slimSidebarEnabled" placement="end" container="body"
</ng-template> triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<ng-template #updateCheckingNotEnabledPopContent> {{ versionString }}
<p class="small mb-2"> </a>
<ng-container i18n>Paperless-ngx can automatically check for updates</ng-container> </div>
</p> @if (!settingsService.updateCheckingIsSet || appRemoteVersion) {
<div class="btn-group btn-group-xs flex-fill w-100"> <div class="version-check">
<button class="btn btn-outline-primary" (click)="setUpdateChecking(true)">Enable</button> <ng-template #updateAvailablePopContent>
<button class="btn btn-outline-secondary" (click)="setUpdateChecking(false)">Disable</button> <span class="small">Paperless-ngx {{ appRemoteVersion.version }} <ng-container i18n>is
</div> available.</ng-container><br /><ng-container i18n>Click to view.</ng-container></span>
<p class="small mb-0 mt-2"> </ng-template>
<a class="small text-decoration-none fst-italic" routerLink="/settings" fragment="update-checking" i18n> <ng-template #updateCheckingNotEnabledPopContent>
How does this work? <p class="small mb-2">
</a> <ng-container i18n>Paperless-ngx can automatically check for updates</ng-container>
</p> </p>
</ng-template> <div class="btn-group btn-group-xs flex-fill w-100">
@if (settingsService.updateCheckingIsSet) { <button class="btn btn-outline-primary" (click)="setUpdateChecking(true)">Enable</button>
@if (appRemoteVersion.update_available) { <button class="btn btn-outline-secondary" (click)="setUpdateChecking(false)">Disable</button>
<a class="small text-decoration-none" target="_blank" rel="noopener noreferrer" </div>
href="https://github.com/paperless-ngx/paperless-ngx/releases" <p class="small mb-0 mt-2">
[ngbPopover]="updateAvailablePopContent" popoverClass="shadow" triggers="mouseenter:mouseleave" <a class="small text-decoration-none fst-italic" routerLink="/settings" fragment="update-checking" i18n>
container="body"> How does this work?
<i-bs width="1.2em" height="1.2em" name="info-circle"></i-bs> </a>
@if (appRemoteVersion?.update_available) { </p>
&nbsp;<ng-container i18n>Update available</ng-container> </ng-template>
} @if (settingsService.updateCheckingIsSet) {
</a> @if (appRemoteVersion.update_available) {
} <a class="small text-decoration-none" target="_blank" rel="noopener noreferrer"
} @else { href="https://github.com/paperless-ngx/paperless-ngx/releases"
<a *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.UISettings }" class="small text-decoration-none" routerLink="/settings" fragment="update-checking" [ngbPopover]="updateAvailablePopContent" popoverClass="shadow" triggers="mouseenter:mouseleave"
[ngbPopover]="updateCheckingNotEnabledPopContent" popoverClass="shadow" triggers="mouseenter"
container="body"> container="body">
<i-bs width="1.2em" height="1.2em" name="info-circle"></i-bs> <i-bs width="1.2em" height="1.2em" name="info-circle"></i-bs>
@if (appRemoteVersion?.update_available) {
&nbsp;<ng-container i18n>Update available</ng-container>
}
</a> </a>
} }
</div> } @else {
} <a *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.UISettings }" class="small text-decoration-none" routerLink="/settings" fragment="update-checking"
</div> [ngbPopover]="updateCheckingNotEnabledPopContent" popoverClass="shadow" triggers="mouseenter"
</li> container="body">
</ul> <i-bs width="1.2em" height="1.2em" name="info-circle"></i-bs>
</div> </a>
}
</div>
}
</div>
</li>
</ul>
</div> </div>
</div> </div>
</nav> </nav>

View File

@@ -89,8 +89,6 @@ export class AppFrameComponent
appRemoteVersion: AppRemoteVersion appRemoteVersion: AppRemoteVersion
isMenuCollapsed: boolean = true isMenuCollapsed: boolean = true
isManageMenuCollapsed: boolean = false
isAdminMenuCollapsed: boolean = false
slimSidebarAnimating: boolean = false slimSidebarAnimating: boolean = false

View File

@@ -55,9 +55,7 @@ import {
checkLg, checkLg,
chevronDoubleLeft, chevronDoubleLeft,
chevronDoubleRight, chevronDoubleRight,
chevronDown,
chevronRight, chevronRight,
chevronUp,
clipboard, clipboard,
clipboardCheck, clipboardCheck,
clipboardCheckFill, clipboardCheckFill,
@@ -269,9 +267,7 @@ const icons = {
checkLg, checkLg,
chevronDoubleLeft, chevronDoubleLeft,
chevronDoubleRight, chevronDoubleRight,
chevronDown,
chevronRight, chevronRight,
chevronUp,
clipboard, clipboard,
clipboardCheck, clipboardCheck,
clipboardCheckFill, clipboardCheckFill,

View File

@@ -1,4 +1,6 @@
import json import json
from fractions import Fraction
from io import BytesIO
from pathlib import Path from pathlib import Path
from django.contrib.auth.models import User from django.contrib.auth.models import User
@@ -6,6 +8,11 @@ from django.core.files.uploadedfile import SimpleUploadedFile
from rest_framework import status from rest_framework import status
from rest_framework.test import APITestCase from rest_framework.test import APITestCase
try:
from PIL import Image
except ModuleNotFoundError: # pragma: no cover - Pillow is required in production
Image = None # type: ignore[assignment]
from documents.tests.utils import DirectoriesMixin from documents.tests.utils import DirectoriesMixin
from paperless.models import ApplicationConfiguration from paperless.models import ApplicationConfiguration
from paperless.models import ColorConvertChoices from paperless.models import ColorConvertChoices
@@ -190,6 +197,74 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
) )
self.assertFalse(Path(old_logo.path).exists()) self.assertFalse(Path(old_logo.path).exists())
def test_api_strips_metadata_from_logo_upload(self):
"""
GIVEN:
- An image file containing EXIF metadata including GPS coordinates
WHEN:
- Uploaded via PATCH to app config
THEN:
- Stored logo no longer contains EXIF metadata
"""
if Image is None:
self.skipTest("Pillow is not installed")
if not hasattr(Image, "Exif"):
self.skipTest("Current Pillow version cannot create EXIF metadata")
assert Image is not None
exif = Image.Exif()
exif[0x010E] = "Test description" # ImageDescription
exif[0x8825] = {
1: "N", # GPSLatitudeRef
2: (Fraction(51, 1), Fraction(30, 1), Fraction(0, 1)),
3: "E", # GPSLongitudeRef
4: (Fraction(0, 1), Fraction(7, 1), Fraction(0, 1)),
}
buffer = BytesIO()
Image.new("RGB", (8, 8), "white").save(buffer, format="JPEG", exif=exif)
buffer.seek(0)
with Image.open(BytesIO(buffer.getvalue())) as uploaded_image:
self.assertGreater(len(uploaded_image.getexif()), 0)
response = self.client.patch(
f"{self.ENDPOINT}1/",
{
"app_logo": SimpleUploadedFile(
name="with_exif.jpg",
content=buffer.getvalue(),
content_type="image/jpeg",
),
},
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
config = ApplicationConfiguration.objects.first()
stored_logo = Path(config.app_logo.path)
self.assertTrue(stored_logo.exists())
with Image.open(stored_logo) as sanitized:
sanitized_exif = sanitized.getexif()
self.assertNotEqual(sanitized_exif.get(0x010E), "Test description")
gps_ifd = None
if hasattr(sanitized_exif, "get_ifd"):
try:
gps_ifd = sanitized_exif.get_ifd(0x8825)
except KeyError:
gps_ifd = None
else:
gps_ifd = sanitized_exif.get(0x8825)
if gps_ifd is not None:
self.assertEqual(len(gps_ifd), 0, "GPS metadata should be cleared")
self.assertNotIn("exif", sanitized.info)
def test_api_rejects_malicious_svg_logo(self): def test_api_rejects_malicious_svg_logo(self):
""" """
GIVEN: GIVEN:

View File

@@ -1,4 +1,5 @@
import logging import logging
from io import BytesIO
import magic import magic
from allauth.mfa.adapter import get_adapter as get_mfa_adapter from allauth.mfa.adapter import get_adapter as get_mfa_adapter
@@ -9,6 +10,10 @@ from allauth.socialaccount.models import SocialApp
from django.contrib.auth.models import Group from django.contrib.auth.models import Group
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.core.files.uploadedfile import SimpleUploadedFile
from PIL import Image
from PIL import ImageOps
from PIL import UnidentifiedImageError
from rest_framework import serializers from rest_framework import serializers
from rest_framework.authtoken.serializers import AuthTokenSerializer from rest_framework.authtoken.serializers import AuthTokenSerializer
@@ -19,6 +24,102 @@ from paperless_mail.serialisers import ObfuscatedPasswordField
logger = logging.getLogger("paperless.settings") logger = logging.getLogger("paperless.settings")
def strip_image_metadata(uploaded_file, mime_type: str | None):
"""Return a copy of ``uploaded_file`` with EXIF/ICC metadata removed."""
if uploaded_file is None:
return uploaded_file
original_position = uploaded_file.tell() if hasattr(uploaded_file, "tell") else None
image = None
sanitized = None
try:
if hasattr(uploaded_file, "seek"):
uploaded_file.seek(0)
image = Image.open(uploaded_file)
image.load()
except (UnidentifiedImageError, OSError):
if hasattr(uploaded_file, "seek") and original_position is not None:
uploaded_file.seek(original_position)
return uploaded_file
try:
image_format = (image.format or "").upper()
image = ImageOps.exif_transpose(image)
if image_format not in {"JPEG", "JPG", "PNG"}:
if hasattr(uploaded_file, "seek") and original_position is not None:
uploaded_file.seek(original_position)
return uploaded_file
if hasattr(image, "info"):
image.info.pop("exif", None)
image.info.pop("icc_profile", None)
image.info.pop("comment", None)
if image_format in {"JPEG", "JPG"}:
sanitized = image.convert("RGB")
save_kwargs = {
"format": "JPEG",
"quality": 95,
"subsampling": 0,
"optimize": True,
"exif": b"",
}
else: # PNG
target_mode = (
"RGBA"
if ("A" in image.mode or image.info.get("transparency"))
else "RGB"
)
sanitized = image.convert(target_mode)
save_kwargs = {
"format": "PNG",
"optimize": True,
}
buffer = BytesIO()
try:
sanitized.save(buffer, **save_kwargs)
except (OSError, ValueError):
buffer = BytesIO()
if image_format in {"JPEG", "JPG"}:
sanitized.save(
buffer,
format="JPEG",
quality=90,
subsampling=0,
exif=b"",
)
else:
sanitized.save(
buffer,
format="PNG",
)
buffer.seek(0)
if hasattr(uploaded_file, "close"):
try:
uploaded_file.close()
except Exception:
pass
content_type = getattr(uploaded_file, "content_type", None) or mime_type
return SimpleUploadedFile(
name=getattr(uploaded_file, "name", "logo"),
content=buffer.getvalue(),
content_type=content_type,
)
finally:
if sanitized is not None:
sanitized.close()
if image is not None:
image.close()
class PaperlessAuthTokenSerializer(AuthTokenSerializer): class PaperlessAuthTokenSerializer(AuthTokenSerializer):
code = serializers.CharField( code = serializers.CharField(
label="MFA Code", label="MFA Code",
@@ -209,9 +310,22 @@ class ApplicationConfigurationSerializer(serializers.ModelSerializer):
return super().update(instance, validated_data) return super().update(instance, validated_data)
def validate_app_logo(self, file): def validate_app_logo(self, file):
if file and magic.from_buffer(file.read(2048), mime=True) == "image/svg+xml": if not file:
return file
if hasattr(file, "seek"):
file.seek(0)
mime_type = magic.from_buffer(file.read(2048), mime=True)
if hasattr(file, "seek"):
file.seek(0)
if mime_type == "image/svg+xml":
reject_dangerous_svg(file) reject_dangerous_svg(file)
return file if hasattr(file, "seek"):
file.seek(0)
return file
return strip_image_metadata(file, mime_type)
class Meta: class Meta:
model = ApplicationConfiguration model = ApplicationConfiguration

View File

@@ -922,7 +922,7 @@ CELERY_ACCEPT_CONTENT = ["application/json", "application/x-python-serialize"]
CELERY_BEAT_SCHEDULE = _parse_beat_schedule() CELERY_BEAT_SCHEDULE = _parse_beat_schedule()
# https://docs.celeryq.dev/en/stable/userguide/configuration.html#beat-schedule-filename # https://docs.celeryq.dev/en/stable/userguide/configuration.html#beat-schedule-filename
CELERY_BEAT_SCHEDULE_FILENAME = DATA_DIR / "celerybeat-schedule.db" CELERY_BEAT_SCHEDULE_FILENAME = str(DATA_DIR / "celerybeat-schedule.db")
# Cachalot: Database read cache. # Cachalot: Database read cache.