mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-09-02 07:57:15 +00:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ec596bc8a | ||
|
|
5c5b1ee6b5 | ||
|
|
08f2f4bfe2 | ||
|
|
f993462973 | ||
|
|
ae70b8d60f | ||
|
|
38db6b51db | ||
|
|
31e9f4272c | ||
|
|
b8659c1af3 | ||
|
|
741115b36b |
+1
-1
@@ -47,7 +47,7 @@ dependencies = [
|
||||
"httpx-oauth~=0.17",
|
||||
"ijson>=3.5.1",
|
||||
"imap-tools~=1.14.0",
|
||||
"jinja2~=3.1.5",
|
||||
"jinja2~=3.1.6",
|
||||
"langdetect~=1.0.9",
|
||||
"llama-index-core>=0.14.23",
|
||||
"llama-index-embeddings-huggingface>=0.6.1",
|
||||
|
||||
@@ -111,7 +111,7 @@
|
||||
</h6>
|
||||
<ul class="nav flex-column mb-2" cdkDropList (cdkDropListDropped)="onDrop($event)">
|
||||
@for (view of savedViewService.sidebarViews; track view.id) {
|
||||
<li class="nav-item w-100 app-link" cdkDrag [cdkDragDisabled]="!settingsService.organizingSidebarSavedViews() || !canSaveSettings"
|
||||
<li class="nav-item app-link" cdkDrag [cdkDragDisabled]="!settingsService.organizingSidebarSavedViews() || !canSaveSettings"
|
||||
cdkDragPreviewContainer="parent" cdkDragPreviewClass="navItemDrag" (cdkDragStarted)="onDragStart($event)"
|
||||
(cdkDragEnded)="onDragEnd($event)">
|
||||
<a class="nav-link" routerLink="view/{{view.id}}"
|
||||
@@ -128,7 +128,7 @@
|
||||
}
|
||||
</a>
|
||||
@if (settingsService.organizingSidebarSavedViews() && canSaveSettings) {
|
||||
<div class="position-absolute end-0 top-0 px-3 py-2" [class.me-n3]="slimSidebarEnabled" cdkDragHandle>
|
||||
<div class="position-absolute end-0 top-0 px-1 py-2" [class.me-n2]="slimSidebarEnabled" cdkDragHandle>
|
||||
<i-bs name="grip-vertical"></i-bs>
|
||||
</div>
|
||||
}
|
||||
@@ -332,7 +332,7 @@
|
||||
</li>
|
||||
<li class="nav-item" [class.visually-hidden]="slimSidebarEnabled">
|
||||
<div class="text-muted small d-flex align-items-center flex-wrap nav-label">
|
||||
<div class="me-3">
|
||||
<div class="me-2">
|
||||
<a class="text-muted text-decoration-none" target="_blank" rel="noopener noreferrer"
|
||||
href="https://github.com/paperless-ngx/paperless-ngx" ngbPopover="GitHub" i18n-ngbPopover
|
||||
[disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body"
|
||||
@@ -341,7 +341,7 @@
|
||||
</a>
|
||||
</div>
|
||||
@if (!settingsService.updateCheckingIsSet || appRemoteVersion()) {
|
||||
<div class="version-check">
|
||||
<div class="version-check d-flex align-items-center">
|
||||
<ng-template #updateAvailablePopContent>
|
||||
<span class="small">Paperless-ngx {{ appRemoteVersion().version }} <ng-container i18n>is
|
||||
available.</ng-container><br /><ng-container i18n>Click to view.</ng-container></span>
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
@if (useDropdown) {
|
||||
<div class="btn-group w-100" role="group" ngbDropdown #dropdown="ngbDropdown" (openChange)="onOpenChange($event)" [popperOptions]="popperOptions">
|
||||
<button class="btn btn-sm btn-outline-primary" id="dropdown_toggle" ngbDropdownToggle [disabled]="disabled" [aria-label]="title">
|
||||
<button class="btn btn-sm" [ngClass]="!editing && isActive ? 'btn-primary' : 'btn-outline-primary'" id="dropdown_toggle" ngbDropdownToggle [disabled]="disabled" [aria-label]="title">
|
||||
<i-bs name="{{icon}}"></i-bs><div class="d-none d-sm-inline ms-1">{{title}}</div>
|
||||
@if (isActive) {
|
||||
<pngx-clearable-badge [selected]="isActive" (cleared)="reset()"></pngx-clearable-badge>
|
||||
|
||||
+7
-4
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
getLocaleNumberSymbol,
|
||||
NgClass,
|
||||
NgTemplateOutlet,
|
||||
NumberSymbol,
|
||||
} from '@angular/common'
|
||||
@@ -48,25 +49,26 @@ import { ClearableBadgeComponent } from '../clearable-badge/clearable-badge.comp
|
||||
import { DocumentLinkComponent } from '../input/document-link/document-link.component'
|
||||
|
||||
export class CustomFieldQueriesModel {
|
||||
private _queries: CustomFieldQueryElement[] = []
|
||||
private readonly _queries = signal<CustomFieldQueryElement[]>([])
|
||||
private rootSubscriptions: Subscription[] = []
|
||||
|
||||
public readonly changed = new Subject<CustomFieldQueriesModel>()
|
||||
|
||||
public get queries(): CustomFieldQueryElement[] {
|
||||
return this._queries
|
||||
return this._queries()
|
||||
}
|
||||
|
||||
public set queries(value: CustomFieldQueryElement[]) {
|
||||
this.teardownRootSubscriptions()
|
||||
this._queries = value ?? []
|
||||
for (const element of this._queries) {
|
||||
const queries = value ?? []
|
||||
for (const element of queries) {
|
||||
this.rootSubscriptions.push(
|
||||
element.changed.subscribe(() => {
|
||||
this.changed.next(this)
|
||||
})
|
||||
)
|
||||
}
|
||||
this._queries.set(queries)
|
||||
}
|
||||
|
||||
public clear(fireEvent = true) {
|
||||
@@ -209,6 +211,7 @@ export class CustomFieldQueriesModel {
|
||||
DocumentLinkComponent,
|
||||
ReactiveFormsModule,
|
||||
NgbDatepickerModule,
|
||||
NgClass,
|
||||
NgTemplateOutlet,
|
||||
NgSelectModule,
|
||||
NgxBootstrapIconsModule,
|
||||
|
||||
@@ -116,7 +116,7 @@
|
||||
|
||||
</pngx-page-header>
|
||||
|
||||
<div class="row sticky-top py-3 mt-n2 mt-md-n3 bg-body">
|
||||
<div class="row sticky-top py-3 mt-n2 mt-md-n3 bg-body rounded shadow-sm">
|
||||
<pngx-filter-editor [hidden]="isBulkEditing" [disabled]="isBulkEditing" [filterRules]="list.filterRules" (filterRulesChange)="onFilterRulesChange($event)" (resetFilterRules)="onFilterRulesReset($event)" [unmodifiedFilterRules]="unmodifiedFilterRules()" [selectionData]="list.selectionData" #filterEditor></pngx-filter-editor>
|
||||
<pngx-bulk-editor [hidden]="!isBulkEditing" [disabled]="!isBulkEditing"></pngx-bulk-editor>
|
||||
</div>
|
||||
|
||||
@@ -1034,6 +1034,49 @@ describe('FilterEditorComponent', () => {
|
||||
).toEqual([42, CustomFieldQueryOperator.Exists, 'true'])
|
||||
})
|
||||
|
||||
it('should reflect ingested custom field query rules in the dropdown toggle', () => {
|
||||
const dropdown = fixture.debugElement.query(
|
||||
By.css('pngx-custom-fields-query-dropdown')
|
||||
)
|
||||
expect(
|
||||
dropdown.nativeElement.querySelector('pngx-clearable-badge')
|
||||
).toBeNull()
|
||||
|
||||
// switching to a view with a custom field query
|
||||
component.filterRules = [
|
||||
{
|
||||
rule_type: FILTER_CUSTOM_FIELDS_QUERY,
|
||||
value: '["OR",[[42,"exists","true"]]]',
|
||||
},
|
||||
]
|
||||
fixture.detectChanges()
|
||||
expect(
|
||||
dropdown.nativeElement.querySelector('pngx-clearable-badge')
|
||||
).not.toBeNull()
|
||||
expect(
|
||||
dropdown.nativeElement
|
||||
.querySelector('#dropdown_toggle')
|
||||
.classList.contains('btn-primary')
|
||||
).toBeTruthy()
|
||||
|
||||
// and back to a view without one
|
||||
component.filterRules = [
|
||||
{
|
||||
rule_type: FILTER_HAS_TAGS_ALL,
|
||||
value: '19',
|
||||
},
|
||||
]
|
||||
fixture.detectChanges()
|
||||
expect(
|
||||
dropdown.nativeElement.querySelector('pngx-clearable-badge')
|
||||
).toBeNull()
|
||||
expect(
|
||||
dropdown.nativeElement
|
||||
.querySelector('#dropdown_toggle')
|
||||
.classList.contains('btn-primary')
|
||||
).toBeFalsy()
|
||||
})
|
||||
|
||||
it('should ingest filter rules for owner', () => {
|
||||
expect(component.permissionsSelectionModel.ownerFilter).toEqual(
|
||||
OwnerFilterType.NONE
|
||||
|
||||
+5
-16
@@ -47,6 +47,8 @@ $grid-breakpoints: (
|
||||
);
|
||||
|
||||
:root {
|
||||
--bs-border-radius: #{$border-radius};
|
||||
|
||||
@each $name, $value in $grid-breakpoints {
|
||||
--bs-breakpoint-#{$name}: #{$value};
|
||||
}
|
||||
@@ -78,19 +80,12 @@ body {
|
||||
}
|
||||
|
||||
.btn {
|
||||
--bs-btn-border-radius: .425rem;
|
||||
--bs-border-radius-sm: .425rem;
|
||||
--bs-border-radius-sm: #{$border-radius};
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-control,
|
||||
.form-select,
|
||||
.input-group-text {
|
||||
border-radius: .425rem;
|
||||
}
|
||||
|
||||
.pagination, .input-group {
|
||||
--bs-border-radius-sm: .425rem;
|
||||
--bs-border-radius-sm: #{$border-radius};
|
||||
}
|
||||
|
||||
@media(min-width: 768px) {
|
||||
@@ -689,10 +684,6 @@ table.table {
|
||||
--bs-toast-max-width: var(--pngx-toast-max-width);
|
||||
}
|
||||
|
||||
.alert {
|
||||
--bs-border-radius: .425rem;
|
||||
}
|
||||
|
||||
.alert-primary {
|
||||
--bs-alert-color: var(--bs-primary);
|
||||
--bs-alert-bg: var(--pngx-primary-faded);
|
||||
@@ -824,8 +815,6 @@ code {
|
||||
--bs-accordion-bg: var(--bs-light);
|
||||
--bs-accordion-active-color: var(--bs-primary);
|
||||
--bs-accordion-active-bg: var(--pngx-bg-alt);
|
||||
--bs-border-radius: .425rem;
|
||||
--bs-accordion-inner-border-radius: calc(.425rem - 1px);
|
||||
}
|
||||
|
||||
.accordion-button::after {
|
||||
@@ -849,7 +838,7 @@ code {
|
||||
}
|
||||
|
||||
/* Animate items as they're being sorted. */
|
||||
.cdk-drop-list-dragging .cdk-drag {
|
||||
.cdk-drop-list-dragging .cdk-drag:not(.cdk-drag-preview) {
|
||||
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
|
||||
@@ -113,6 +113,7 @@ $form-check-radio-checked-bg-image-dark: url("data:image/svg+xml,%3csvg xmlns='h
|
||||
--bs-tertiary-bg: var(--pngx-bg-darker);
|
||||
--bs-dark-border-subtle: var(--pngx-bg-darker);
|
||||
--bs-border-color-translucent: rgba(0, 0, 0, .175); // override bs
|
||||
--bs-box-shadow-sm: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.15); // slightly darker than bs default
|
||||
|
||||
.text-dark, .text-light {
|
||||
color: var(--bs-body-color) !important;
|
||||
|
||||
@@ -16,6 +16,9 @@ from django.core.cache import cache
|
||||
from django.core.cache import caches
|
||||
|
||||
from documents.models import Document
|
||||
from paperless.signed_pickle import SignedPickleError
|
||||
from paperless.signed_pickle import signed_pickle_dumps
|
||||
from paperless.signed_pickle import signed_pickle_loads
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from django.core.cache.backends.base import BaseCache
|
||||
@@ -118,9 +121,11 @@ class StoredLRUCache(LRUCache):
|
||||
serialized_data = self._backend.get(self._backend_key)
|
||||
try:
|
||||
self._data = (
|
||||
pickle.loads(serialized_data) if serialized_data else OrderedDict()
|
||||
signed_pickle_loads(serialized_data)
|
||||
if serialized_data
|
||||
else OrderedDict()
|
||||
)
|
||||
except pickle.PickleError:
|
||||
except (SignedPickleError, pickle.PickleError):
|
||||
logger.warning(
|
||||
"Cache exists in backend but could not be read (possibly invalid format)",
|
||||
)
|
||||
@@ -132,7 +137,7 @@ class StoredLRUCache(LRUCache):
|
||||
"""
|
||||
self._backend.set(
|
||||
self._backend_key,
|
||||
pickle.dumps(self._data),
|
||||
signed_pickle_dumps(self._data),
|
||||
self.backend_ttl,
|
||||
)
|
||||
|
||||
|
||||
@@ -28,6 +28,9 @@ from documents.caching import CLASSIFIER_VERSION_KEY
|
||||
from documents.caching import StoredLRUCache
|
||||
from documents.models import Document
|
||||
from documents.models import MatchingModel
|
||||
from paperless.signed_pickle import SignedPickleError
|
||||
from paperless.signed_pickle import signed_pickle_dumps
|
||||
from paperless.signed_pickle import signed_pickle_loads
|
||||
|
||||
logger = logging.getLogger("paperless.classifier")
|
||||
|
||||
@@ -527,10 +530,17 @@ class DocumentClassifier:
|
||||
serialized_result = read_cache.get(key)
|
||||
if serialized_result is None:
|
||||
result = self.data_vectorizer.transform([self.preprocess_content(content)])
|
||||
read_cache.set(key, pickle.dumps(result), CACHE_5_MINUTES)
|
||||
read_cache.set(key, signed_pickle_dumps(result), CACHE_5_MINUTES)
|
||||
else:
|
||||
read_cache.touch(key, CACHE_5_MINUTES)
|
||||
result = pickle.loads(serialized_result)
|
||||
try:
|
||||
result = signed_pickle_loads(serialized_result)
|
||||
except SignedPickleError:
|
||||
result = self.data_vectorizer.transform(
|
||||
[self.preprocess_content(content)],
|
||||
)
|
||||
read_cache.set(key, signed_pickle_dumps(result), CACHE_5_MINUTES)
|
||||
else:
|
||||
read_cache.touch(key, CACHE_5_MINUTES)
|
||||
return result
|
||||
|
||||
def predict_correspondent(self, content: str) -> int | None:
|
||||
|
||||
@@ -462,7 +462,11 @@ class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-
|
||||
"""
|
||||
Returns a sanitized filename for the document, not including any paths.
|
||||
"""
|
||||
result = str(self)
|
||||
# Root owns metadata for all versions
|
||||
context_document = (
|
||||
self.root_document if self.root_document_id is not None else self
|
||||
)
|
||||
result = str(context_document)
|
||||
|
||||
if counter:
|
||||
result += f"_{counter:02}"
|
||||
|
||||
@@ -102,6 +102,7 @@ class TestApiObjects(DirectoriesMixin, APITestCase):
|
||||
- API is called
|
||||
THEN:
|
||||
- Last correspondence date is returned only if requested for list, and for detail
|
||||
- The date is scoped to documents the requesting user may view
|
||||
"""
|
||||
|
||||
Document.objects.create(
|
||||
@@ -145,6 +146,32 @@ class TestApiObjects(DirectoriesMixin, APITestCase):
|
||||
response.data["last_correspondence"],
|
||||
)
|
||||
|
||||
# A newer document owned by another user must not leak through the
|
||||
# aggregate for a non-superuser who cannot view it
|
||||
other = User.objects.create_user(username="other")
|
||||
Document.objects.create(
|
||||
mime_type="application/pdf",
|
||||
correspondent=self.c1,
|
||||
created=datetime.date(2023, 6, 1),
|
||||
checksum="hidden",
|
||||
owner=other,
|
||||
)
|
||||
|
||||
user = User.objects.create_user(username="regular")
|
||||
user.user_permissions.add(
|
||||
Permission.objects.get(codename="view_correspondent"),
|
||||
)
|
||||
self.client.force_authenticate(user=user)
|
||||
|
||||
response = self.client.get("/api/correspondents/?last_correspondence=true")
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
result = next(r for r in response.data["results"] if r["id"] == self.c1.id)
|
||||
self.assertIn("2022-01-02", result["last_correspondence"])
|
||||
|
||||
response = self.client.get(f"/api/correspondents/{self.c1.id}/")
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertIn("2022-01-02", response.data["last_correspondence"])
|
||||
|
||||
def test_paginated_objects_include_all_only_for_legacy_version(self) -> None:
|
||||
response_v10 = self.client.get("/api/correspondents/")
|
||||
self.assertEqual(response_v10.status_code, status.HTTP_200_OK)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import pickle
|
||||
|
||||
from documents.caching import StoredLRUCache
|
||||
from paperless.signed_pickle import HMAC_SIZE
|
||||
from paperless.signed_pickle import signed_pickle_dumps
|
||||
from paperless.signed_pickle import signed_pickle_loads
|
||||
|
||||
|
||||
def test_lru_cache_entries() -> None:
|
||||
@@ -42,4 +43,16 @@ def test_stored_lru_cache_key_ttl(mocker) -> None:
|
||||
key, data, timeout = mock_backend.set.call_args[0]
|
||||
assert key == "test_key"
|
||||
assert timeout == 321
|
||||
assert pickle.loads(data) == {"x": "X", "y": "Y"}
|
||||
assert signed_pickle_loads(data) == {"x": "X", "y": "Y"}
|
||||
|
||||
|
||||
def test_stored_lru_cache_rejects_tampered_data(mocker) -> None:
|
||||
serialized_data = bytearray(signed_pickle_dumps({"x": "X"}))
|
||||
serialized_data[HMAC_SIZE] ^= 0xFF
|
||||
mock_backend = mocker.Mock()
|
||||
mock_backend.get.return_value = bytes(serialized_data)
|
||||
cache = StoredLRUCache("test_key", backend=mock_backend)
|
||||
|
||||
cache.load()
|
||||
|
||||
assert cache.get("x") is None
|
||||
|
||||
@@ -19,6 +19,8 @@ from documents.models import MatchingModel
|
||||
from documents.models import StoragePath
|
||||
from documents.models import Tag
|
||||
from documents.tests.utils import DirectoriesMixin
|
||||
from paperless.signed_pickle import HMAC_SIZE
|
||||
from paperless.signed_pickle import signed_pickle_dumps
|
||||
|
||||
|
||||
def dummy_preprocess(content: str, **kwargs):
|
||||
@@ -265,6 +267,27 @@ class TestClassifier(DirectoriesMixin, TestCase):
|
||||
self.assertEqual(mock_preprocess_content.call_count, 2)
|
||||
self.assertEqual(mock_transform.call_count, 2)
|
||||
|
||||
def test_vectorize_recomputes_tampered_cache_entry(self) -> None:
|
||||
cached = bytearray(signed_pickle_dumps(["cached vector"]))
|
||||
cached[HMAC_SIZE] ^= 0xFF
|
||||
self.classifier.data_vectorizer = mock.Mock()
|
||||
self.classifier.data_vectorizer.transform.return_value = ["fresh vector"]
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"documents.classifier.read_cache.get",
|
||||
return_value=bytes(cached),
|
||||
),
|
||||
mock.patch("documents.classifier.read_cache.set") as cache_set,
|
||||
mock.patch("documents.classifier.read_cache.touch") as cache_touch,
|
||||
):
|
||||
result = self.classifier._vectorize("content")
|
||||
|
||||
self.assertEqual(result, ["fresh vector"])
|
||||
self.classifier.data_vectorizer.transform.assert_called_once()
|
||||
cache_set.assert_called_once()
|
||||
cache_touch.assert_not_called()
|
||||
|
||||
def test_no_retrain_if_no_change(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
|
||||
@@ -156,6 +156,40 @@ class TestDocument(TestCase):
|
||||
)
|
||||
self.assertEqual(doc.get_public_filename(), "2020-12-25 test")
|
||||
|
||||
def test_version_file_name_uses_root_document_metadata(self) -> None:
|
||||
root_correspondent = Correspondent.objects.create(name="Root correspondent")
|
||||
version_correspondent = Correspondent.objects.create(
|
||||
name="Version correspondent",
|
||||
)
|
||||
root = Document.objects.create(
|
||||
mime_type="application/pdf",
|
||||
title="Root title",
|
||||
created=date(2020, 12, 25),
|
||||
correspondent=root_correspondent,
|
||||
)
|
||||
version = Document.objects.create(
|
||||
mime_type="application/pdf",
|
||||
title="Version title",
|
||||
created=date(1990, 1, 1),
|
||||
correspondent=version_correspondent,
|
||||
root_document=root,
|
||||
version_index=1,
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
version.get_public_filename(),
|
||||
"2020-12-25 Root correspondent Root title.pdf",
|
||||
)
|
||||
|
||||
root.title = "Updated root title"
|
||||
root.save(update_fields=("title",))
|
||||
version.refresh_from_db()
|
||||
|
||||
self.assertEqual(
|
||||
version.get_public_filename(),
|
||||
"2020-12-25 Root correspondent Updated root title.pdf",
|
||||
)
|
||||
|
||||
def test_suggestion_content_uses_latest_version_content_for_root_documents(
|
||||
self,
|
||||
) -> None:
|
||||
|
||||
@@ -192,6 +192,50 @@ class ShareLinkBundleAPITests(DirectoriesMixin, APITestCase):
|
||||
self.assertEqual(response.status_code, status.HTTP_302_FOUND)
|
||||
self.assertIn("sharelink_notfound=1", response["Location"])
|
||||
|
||||
def test_share_link_missing_file_redirects(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A share link whose document file is missing from disk
|
||||
WHEN:
|
||||
- The public share link is requested anonymously
|
||||
THEN:
|
||||
- The user is redirected to login instead of a 500 error
|
||||
"""
|
||||
doc = DocumentFactory.create(filename="missing-original.pdf")
|
||||
share_link = ShareLink.objects.create(
|
||||
slug="missingfilelink",
|
||||
document=doc,
|
||||
file_version=ShareLink.FileVersion.ORIGINAL,
|
||||
)
|
||||
|
||||
self.client.logout()
|
||||
response = self.client.get(f"/share/{share_link.slug}/")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_302_FOUND)
|
||||
self.assertIn("sharelink_notfound=1", response["Location"])
|
||||
|
||||
def test_download_ready_bundle_missing_file_returns_503(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A READY bundle whose zip file is missing from disk
|
||||
WHEN:
|
||||
- The public share link is requested anonymously
|
||||
THEN:
|
||||
- A 503 is returned instead of a 500 error
|
||||
"""
|
||||
bundle = ShareLinkBundle.objects.create(
|
||||
slug="missingbundlefile",
|
||||
file_version=ShareLink.FileVersion.ARCHIVE,
|
||||
status=ShareLinkBundle.Status.READY,
|
||||
file_path="bundles/gone.zip",
|
||||
)
|
||||
bundle.documents.set([self.document])
|
||||
|
||||
self.client.logout()
|
||||
response = self.client.get(f"/share/{bundle.slug}/")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_503_SERVICE_UNAVAILABLE)
|
||||
|
||||
|
||||
class ShareLinkBundleTaskTests(DirectoriesMixin, APITestCase):
|
||||
def setUp(self) -> None:
|
||||
|
||||
+26
-9
@@ -577,13 +577,19 @@ class CorrespondentViewSet(
|
||||
def list(self, request, *args, **kwargs):
|
||||
if request.query_params.get("last_correspondence", None):
|
||||
self.queryset = self.queryset.annotate(
|
||||
last_correspondence=Max("documents__created"),
|
||||
last_correspondence=Max(
|
||||
"documents__created",
|
||||
filter=self.get_document_count_filter(),
|
||||
),
|
||||
)
|
||||
return super().list(request, *args, **kwargs)
|
||||
|
||||
def retrieve(self, request, *args, **kwargs):
|
||||
self.queryset = self.queryset.annotate(
|
||||
last_correspondence=Max("documents__created"),
|
||||
last_correspondence=Max(
|
||||
"documents__created",
|
||||
filter=self.get_document_count_filter(),
|
||||
),
|
||||
)
|
||||
return super().retrieve(request, *args, **kwargs)
|
||||
|
||||
@@ -4573,6 +4579,10 @@ class ShareLinkViewSet(
|
||||
class ShareLinkBundleViewSet(PassUserMixin, ModelViewSet[ShareLinkBundle]):
|
||||
model = ShareLinkBundle
|
||||
|
||||
# Bundles are immutable once created; rebuild via the dedicated action
|
||||
# rather than PUT/PATCH.
|
||||
http_method_names = ["get", "post", "delete", "head", "options"]
|
||||
|
||||
queryset = ShareLinkBundle.objects.all()
|
||||
|
||||
serializer_class = ShareLinkBundleSerializer
|
||||
@@ -4707,12 +4717,15 @@ class SharedLinkView(View):
|
||||
and share_link.expiration < timezone.now()
|
||||
):
|
||||
return HttpResponseRedirect("/accounts/login/?sharelink_expired=1")
|
||||
return serve_file(
|
||||
doc=share_link.document,
|
||||
use_archive=share_link.file_version == ShareLink.FileVersion.ARCHIVE
|
||||
and share_link.document.has_archive_version,
|
||||
disposition="inline",
|
||||
)
|
||||
try:
|
||||
return serve_file(
|
||||
doc=share_link.document,
|
||||
use_archive=share_link.file_version == ShareLink.FileVersion.ARCHIVE
|
||||
and share_link.document.has_archive_version,
|
||||
disposition="inline",
|
||||
)
|
||||
except FileNotFoundError:
|
||||
return HttpResponseRedirect("/accounts/login/?sharelink_notfound=1")
|
||||
|
||||
bundle = ShareLinkBundle.objects.filter(slug=slug).first()
|
||||
if bundle is None:
|
||||
@@ -4734,7 +4747,11 @@ class SharedLinkView(View):
|
||||
|
||||
file_path = bundle.absolute_file_path
|
||||
|
||||
if bundle.status == ShareLinkBundle.Status.FAILED or file_path is None:
|
||||
if (
|
||||
bundle.status == ShareLinkBundle.Status.FAILED
|
||||
or file_path is None
|
||||
or not file_path.exists()
|
||||
):
|
||||
return HttpResponse(
|
||||
_(
|
||||
"The share link bundle is unavailable.",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+3
-31
@@ -1,12 +1,12 @@
|
||||
import hmac
|
||||
import os
|
||||
import pickle
|
||||
from hashlib import sha256
|
||||
|
||||
from celery import Celery
|
||||
from celery.signals import worker_process_init
|
||||
from kombu.serialization import register
|
||||
|
||||
from paperless.signed_pickle import signed_pickle_dumps
|
||||
from paperless.signed_pickle import signed_pickle_loads
|
||||
|
||||
# Set the default Django settings module for the 'celery' program.
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "paperless.settings")
|
||||
|
||||
@@ -18,34 +18,6 @@ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "paperless.settings")
|
||||
# on the worker side using Django's SECRET_KEY.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
HMAC_SIZE = 32 # SHA-256 digest length
|
||||
|
||||
|
||||
def _get_signing_key() -> bytes:
|
||||
from django.conf import settings
|
||||
|
||||
return settings.SECRET_KEY.encode()
|
||||
|
||||
|
||||
def signed_pickle_dumps(obj: object) -> bytes:
|
||||
data = pickle.dumps(obj, protocol=pickle.HIGHEST_PROTOCOL)
|
||||
signature = hmac.new(_get_signing_key(), data, sha256).digest()
|
||||
return signature + data
|
||||
|
||||
|
||||
def signed_pickle_loads(payload: bytes) -> object:
|
||||
if len(payload) < HMAC_SIZE:
|
||||
msg = "Signed-pickle payload too short"
|
||||
raise ValueError(msg)
|
||||
signature = payload[:HMAC_SIZE]
|
||||
data = payload[HMAC_SIZE:]
|
||||
expected = hmac.new(_get_signing_key(), data, sha256).digest()
|
||||
if not hmac.compare_digest(signature, expected):
|
||||
msg = "Signed-pickle HMAC verification failed — message may have been tampered with"
|
||||
raise ValueError(msg)
|
||||
return pickle.loads(data)
|
||||
|
||||
|
||||
register(
|
||||
"signed-pickle",
|
||||
signed_pickle_dumps,
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import pickle
|
||||
from hashlib import sha256
|
||||
from typing import Any
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
HMAC_SIZE = sha256().digest_size
|
||||
|
||||
|
||||
class SignedPickleError(ValueError):
|
||||
"""Raised when a signed pickle payload cannot be authenticated."""
|
||||
|
||||
|
||||
def _get_signing_key() -> bytes:
|
||||
return settings.SECRET_KEY.encode()
|
||||
|
||||
|
||||
def signed_pickle_dumps(obj: object) -> bytes:
|
||||
data = pickle.dumps(obj, protocol=pickle.HIGHEST_PROTOCOL)
|
||||
signature = hmac.new(_get_signing_key(), data, sha256).digest()
|
||||
return signature + data
|
||||
|
||||
|
||||
def signed_pickle_loads(payload: bytes) -> Any:
|
||||
if len(payload) <= HMAC_SIZE:
|
||||
msg = "Signed-pickle payload too short"
|
||||
raise SignedPickleError(msg)
|
||||
|
||||
signature = payload[:HMAC_SIZE]
|
||||
data = payload[HMAC_SIZE:]
|
||||
expected = hmac.new(_get_signing_key(), data, sha256).digest()
|
||||
if not hmac.compare_digest(signature, expected):
|
||||
msg = "Signed-pickle HMAC verification failed; payload may have been tampered with"
|
||||
raise SignedPickleError(msg)
|
||||
|
||||
return pickle.loads(data)
|
||||
@@ -6,9 +6,9 @@ from pathlib import Path
|
||||
import pytest
|
||||
from django.test import override_settings
|
||||
|
||||
from paperless.celery import HMAC_SIZE
|
||||
from paperless.celery import signed_pickle_dumps
|
||||
from paperless.celery import signed_pickle_loads
|
||||
from paperless.signed_pickle import HMAC_SIZE
|
||||
|
||||
|
||||
class TestSignedPickleSerializer:
|
||||
|
||||
@@ -295,7 +295,7 @@ urlpatterns = [
|
||||
],
|
||||
),
|
||||
),
|
||||
re_path(r"share/(?P<slug>\w+)/?$", SharedLinkView.as_view()),
|
||||
re_path(r"^share/(?P<slug>\w+)/?$", SharedLinkView.as_view()),
|
||||
re_path(r"^favicon.ico$", FaviconView.as_view(), name="favicon"),
|
||||
re_path(r"admin/", admin.site.urls),
|
||||
re_path(
|
||||
|
||||
@@ -4,11 +4,11 @@ requires-python = ">=3.11"
|
||||
resolution-markers = [
|
||||
"python_full_version >= '3.15' and sys_platform == 'darwin'",
|
||||
"python_full_version >= '3.15' and sys_platform == 'linux'",
|
||||
"python_full_version == '3.14.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
|
||||
"python_full_version == '3.14.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||
"python_full_version >= '3.12' and python_full_version < '3.15' and sys_platform == 'darwin'",
|
||||
"python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
|
||||
"python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||
"python_full_version == '3.14.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
|
||||
"python_full_version == '3.14.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||
"(python_full_version >= '3.12' and python_full_version < '3.15' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.13.*' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.13.*' and platform_machine == 'x86_64' and sys_platform == 'linux')",
|
||||
"python_full_version < '3.12' and sys_platform == 'darwin'",
|
||||
"python_full_version < '3.12' and sys_platform == 'linux'",
|
||||
@@ -2718,7 +2718,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "nltk"
|
||||
version = "3.10.0"
|
||||
version = "3.10.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
@@ -2727,9 +2727,9 @@ dependencies = [
|
||||
{ name = "regex" },
|
||||
{ name = "tqdm" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/96/02/df4f105b28a7c16b0e41423bc09cf0f1b8a305df4ef0b10ca74a2e4c648c/nltk-3.10.0.tar.gz", hash = "sha256:4fbac1d98203cbcd1b5d94a2877fb822300072d80604a5e7fae49d2c5f84e8c1", size = 3089244, upload-time = "2026-07-08T02:39:13.562Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e0/e6/fe51d2bb1a3b446f59c5c8165999a9fee208bc346af90a7cbf7657bc0d75/nltk-3.10.3.tar.gz", hash = "sha256:bb9327a461c3811c2fa4900e03840401f2126adfb30c0072827c433bd2444ea4", size = 5137152, upload-time = "2026-08-12T23:46:37.258Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/89/a0b0f35e2820d6a99d75ea1c11977ee6d5c9e6658eceb45b0c7620881faa/nltk-3.10.0-py3-none-any.whl", hash = "sha256:54ff84d4916d3ef127e8953bee0023f6a6b320b75d634a19e06ef056d3d244bf", size = 1716144, upload-time = "2026-07-08T02:39:09.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/6d/ebd2af4640b12168fdf0cb74b6118df2f32a2f62ec7e0c06fbfd80706639/nltk-3.10.3-py3-none-any.whl", hash = "sha256:ff9598a8e20518ee0d557745890cc4435b9578489e2dcbc69c4f81fa060caf7c", size = 1798643, upload-time = "2026-08-12T23:44:13.478Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3053,7 +3053,7 @@ requires-dist = [
|
||||
{ name = "httpx-oauth", specifier = "~=0.17" },
|
||||
{ name = "ijson", specifier = ">=3.5.1" },
|
||||
{ name = "imap-tools", specifier = "~=1.14.0" },
|
||||
{ name = "jinja2", specifier = "~=3.1.5" },
|
||||
{ name = "jinja2", specifier = "~=3.1.6" },
|
||||
{ name = "langdetect", specifier = "~=1.0.9" },
|
||||
{ name = "llama-index-core", specifier = ">=0.14.23" },
|
||||
{ name = "llama-index-embeddings-huggingface", specifier = ">=0.6.1" },
|
||||
@@ -5014,10 +5014,10 @@ version = "2.13.0+cpu"
|
||||
source = { registry = "https://download.pytorch.org/whl/cpu" }
|
||||
resolution-markers = [
|
||||
"python_full_version >= '3.15' and sys_platform == 'linux'",
|
||||
"python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
|
||||
"python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||
"python_full_version == '3.14.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
|
||||
"python_full_version == '3.14.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||
"python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'linux'",
|
||||
"python_full_version == '3.12.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||
"(python_full_version >= '3.12' and python_full_version < '3.15' and platform_machine != 'aarch64' and platform_machine != 'x86_64' and sys_platform == 'linux') or (python_full_version == '3.13.*' and platform_machine == 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.13.*' and platform_machine == 'x86_64' and sys_platform == 'linux')",
|
||||
"python_full_version < '3.12' and sys_platform == 'linux'",
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user