mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-09-01 23:47:15 +00:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b6f59b287 | ||
|
|
5c5b1ee6b5 | ||
|
|
08f2f4bfe2 | ||
|
|
f993462973 | ||
|
|
ae70b8d60f | ||
|
|
38db6b51db | ||
|
|
31e9f4272c | ||
|
|
b8659c1af3 | ||
|
|
741115b36b | ||
|
|
1211db5cbb |
@@ -63,7 +63,7 @@ jobs:
|
||||
- name: Detect changes
|
||||
id: filter
|
||||
if: steps.force.outputs.run_all != 'true'
|
||||
uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
|
||||
uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3
|
||||
with:
|
||||
base: ${{ steps.range.outputs.base }}
|
||||
ref: ${{ steps.range.outputs.ref }}
|
||||
@@ -100,7 +100,7 @@ jobs:
|
||||
with:
|
||||
python-version: "${{ matrix.python-version }}"
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
with:
|
||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||
enable-cache: true
|
||||
@@ -178,7 +178,7 @@ jobs:
|
||||
with:
|
||||
python-version: "${{ env.DEFAULT_PYTHON }}"
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
with:
|
||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||
enable-cache: true
|
||||
|
||||
@@ -106,9 +106,9 @@ jobs:
|
||||
echo "repository=${repo_name}"
|
||||
echo "name=${repo_name}" >> $GITHUB_OUTPUT
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -182,22 +182,22 @@ jobs:
|
||||
echo "Downloaded digests:"
|
||||
ls -la /tmp/digests/
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Login to Docker Hub
|
||||
if: needs.build-arch.outputs.push-external == 'true'
|
||||
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Login to Quay.io
|
||||
if: needs.build-arch.outputs.push-external == 'true'
|
||||
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: quay.io
|
||||
username: ${{ secrets.QUAY_USERNAME }}
|
||||
|
||||
@@ -50,7 +50,7 @@ jobs:
|
||||
- name: Detect changes
|
||||
id: filter
|
||||
if: steps.force.outputs.run_all != 'true'
|
||||
uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
|
||||
uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3
|
||||
with:
|
||||
base: ${{ steps.range.outputs.base }}
|
||||
ref: ${{ steps.range.outputs.ref }}
|
||||
@@ -78,7 +78,7 @@ jobs:
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
with:
|
||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||
enable-cache: true
|
||||
|
||||
@@ -60,7 +60,7 @@ jobs:
|
||||
- name: Detect changes
|
||||
id: filter
|
||||
if: steps.force.outputs.run_all != 'true'
|
||||
uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
|
||||
uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3
|
||||
with:
|
||||
base: ${{ steps.range.outputs.base }}
|
||||
ref: ${{ steps.range.outputs.ref }}
|
||||
@@ -81,7 +81,7 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
with:
|
||||
package_json_file: src-ui/package.json
|
||||
- name: Use Node.js 24
|
||||
@@ -113,7 +113,7 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
with:
|
||||
package_json_file: src-ui/package.json
|
||||
- name: Use Node.js 24
|
||||
@@ -152,7 +152,7 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
with:
|
||||
package_json_file: src-ui/package.json
|
||||
- name: Use Node.js 24
|
||||
@@ -201,7 +201,7 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
with:
|
||||
package_json_file: src-ui/package.json
|
||||
- name: Use Node.js 24
|
||||
@@ -216,7 +216,7 @@ jobs:
|
||||
with:
|
||||
python-version: '3.12'
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
with:
|
||||
version: '0.12.x'
|
||||
enable-cache: false
|
||||
@@ -255,7 +255,7 @@ jobs:
|
||||
fetch-depth: 2
|
||||
persist-credentials: false
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
with:
|
||||
package_json_file: src-ui/package.json
|
||||
- name: Use Node.js 24
|
||||
|
||||
@@ -25,4 +25,4 @@ jobs:
|
||||
with:
|
||||
python-version: "3.14"
|
||||
- name: Run prek
|
||||
uses: j178/prek-action@5337cb91e0fa35a7ff31b9ca345126d8bbbcdf16 # v2.0.6
|
||||
uses: j178/prek-action@4e14d07f9231acabce116ccfca13b13dd9755ece # v3.0.0
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
statuses: read
|
||||
steps:
|
||||
- name: Wait for Docker build
|
||||
uses: lewagon/wait-on-check-action@2271c86c146b96545b4e871b855e10ffa6f50773 # v1.9.0
|
||||
uses: lewagon/wait-on-check-action@369769072fe522a3a8a85c03c96af1e5242a1994 # v1.9.1
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
check-name: 'Merge and Push Manifest'
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
# ---- Frontend Build ----
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
with:
|
||||
package_json_file: src-ui/package.json
|
||||
- name: Use Node.js 24
|
||||
@@ -59,7 +59,7 @@ jobs:
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
with:
|
||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||
enable-cache: false
|
||||
@@ -171,7 +171,7 @@ jobs:
|
||||
fi
|
||||
- name: Create release and changelog
|
||||
id: create-release
|
||||
uses: release-drafter/release-drafter@eada3c96a64734dd381cfbda23511034e328ddb0 # v7.6.0
|
||||
uses: release-drafter/release-drafter@34d80673e067bdc0c24568d3af899c216adcfaa9 # v7.7.0
|
||||
with:
|
||||
name: Paperless-ngx ${{ steps.get-version.outputs.version }}
|
||||
tag: ${{ steps.get-version.outputs.version }}
|
||||
@@ -182,7 +182,7 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Upload release archive
|
||||
uses: shogo82148/actions-upload-release-asset@394b3c11c3cfc038b5396ad265c074065cf875c3 # v1.10.2
|
||||
uses: shogo82148/actions-upload-release-asset@aaba0f56bdbc1071f4af234d5cb16055e8a400de # v1.10.4
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
upload_url: ${{ steps.create-release.outputs.upload_url }}
|
||||
@@ -212,7 +212,7 @@ jobs:
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
with:
|
||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||
enable-cache: false
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Run zizmor
|
||||
uses: zizmorcore/zizmor-action@6fc4b006235f201fdab3722e17240ab420d580e5 # v0.6.1
|
||||
uses: zizmorcore/zizmor-action@3dc1ecc9bcb9e94e9b2c709687979e1298497054 # v0.6.2
|
||||
semgrep:
|
||||
name: Semgrep CE
|
||||
runs-on: ubuntu-24.04
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
- name: Run Semgrep
|
||||
run: semgrep scan --config auto --sarif-output results.sarif
|
||||
- name: Upload results to GitHub code scanning
|
||||
uses: github/codeql-action/upload-sarif@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
|
||||
uses: github/codeql-action/upload-sarif@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8
|
||||
if: always()
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
|
||||
uses: github/codeql-action/init@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
@@ -47,4 +47,4 @@ jobs:
|
||||
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||
# queries: ./path/to/local/query, your-org/your-repo/queries@main
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
|
||||
uses: github/codeql-action/analyze@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
token: ${{ secrets.PNGX_BOT_PAT }}
|
||||
persist-credentials: false
|
||||
- name: crowdin action
|
||||
uses: crowdin/github-action@c7af9bc98b01694653031fef2a0dc6c7888ce9bc # v2.17.0
|
||||
uses: crowdin/github-action@8f01d54f70f1713ee3f09d82c2bbb2daeac28689 # v2.17.1
|
||||
with:
|
||||
upload_translations: false
|
||||
download_translations: true
|
||||
|
||||
@@ -19,6 +19,6 @@ jobs:
|
||||
if: github.event_name == 'pull_request_target' && (github.event.action == 'opened' || github.event.action == 'reopened') && github.event.pull_request.user.login != 'dependabot'
|
||||
steps:
|
||||
- name: Label PR with release-drafter
|
||||
uses: release-drafter/release-drafter@eada3c96a64734dd381cfbda23511034e328ddb0 # v7.6.0
|
||||
uses: release-drafter/release-drafter@34d80673e067bdc0c24568d3af899c216adcfaa9 # v7.7.0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.0
|
||||
- uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0
|
||||
with:
|
||||
days-before-stale: 7
|
||||
days-before-close: 14
|
||||
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -qq --no-install-recommends gettext
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
|
||||
with:
|
||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||
enable-cache: true
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
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@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
with:
|
||||
package_json_file: src-ui/package.json
|
||||
- name: Use Node.js 24
|
||||
|
||||
@@ -1,5 +1,26 @@
|
||||
# Changelog
|
||||
|
||||
## paperless-ngx 3.1.2
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fix: fix dark mode select disabled color, ensure disabled cursor on display mode dropdown [@shamoon](https://github.com/shamoon) ([#13881](https://github.com/paperless-ngx/paperless-ngx/pull/13881))
|
||||
- Fix: add disable to the drag-drop list component [@shamoon](https://github.com/shamoon) ([#13880](https://github.com/paperless-ngx/paperless-ngx/pull/13880))
|
||||
|
||||
### Documentation
|
||||
|
||||
- Chore: update screenshots for v3+ [@shamoon](https://github.com/shamoon) ([#13883](https://github.com/paperless-ngx/paperless-ngx/pull/13883))
|
||||
|
||||
### All App Changes
|
||||
|
||||
<details>
|
||||
<summary>2 changes</summary>
|
||||
|
||||
- Fix: fix dark mode select disabled color, ensure disabled cursor on display mode dropdown [@shamoon](https://github.com/shamoon) ([#13881](https://github.com/paperless-ngx/paperless-ngx/pull/13881))
|
||||
- Fix: add disable to the drag-drop list component [@shamoon](https://github.com/shamoon) ([#13880](https://github.com/paperless-ngx/paperless-ngx/pull/13880))
|
||||
|
||||
</details>
|
||||
|
||||
## paperless-ngx 3.1.1
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
+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(
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user