mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-09-22 00:52:42 -05:00
Compare commits
7 Commits
feature-co
...
fix-strip-
Author | SHA1 | Date | |
---|---|---|---|
![]() |
d6cfd87cc0 | ||
![]() |
7a287e7479 | ||
![]() |
43b4f36026 | ||
![]() |
76a81adcb5 | ||
![]() |
6b868a5ecb | ||
![]() |
3e4aa87cc5 | ||
![]() |
fc95d42b35 |
@@ -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.
|
||||||
|
@@ -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> <ng-container i18n>Settings</ng-container></span>
|
||||||
<i-bs class="me-1" name="gear"></i-bs><span> <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> <ng-container i18n>Configuration</ng-container></span>
|
||||||
<i-bs class="me-1" name="sliders2-vertical"></i-bs><span> <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> <ng-container i18n>Users & Groups</ng-container></span>
|
||||||
<i-bs class="me-1" name="people"></i-bs><span> <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> <ng-container i18n>File Tasks</ng-container>@if (tasksService.failedFileTasks.length > 0) {
|
||||||
<i-bs class="me-1" name="list-task"></i-bs><span> <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> <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"> <ng-container i18n>Documentation</ng-container></span>
|
<i-bs class="me-1" name="text-left"></i-bs><span> <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"> <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>
|
||||||
<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) {
|
||||||
|
<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>
|
||||||
|
@@ -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
|
||||||
|
|
||||||
|
@@ -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,
|
||||||
|
@@ -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:
|
||||||
|
@@ -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
|
||||||
|
@@ -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.
|
||||||
|
Reference in New Issue
Block a user