mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-03-21 00:15:57 +00:00
Compare commits
2 Commits
v2.20.12
...
fix-mail-c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90deeb8285 | ||
|
|
75e9fe823f |
@@ -2,17 +2,6 @@
|
||||
# shellcheck shell=bash
|
||||
declare -r log_prefix="[init-user]"
|
||||
|
||||
# When the container is started as a non-root user (e.g. via `user: 999:999`
|
||||
# in Docker Compose), usermod/groupmod require root and are meaningless.
|
||||
# USERMAP_* variables only apply to the root-started path.
|
||||
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
|
||||
if [[ -n "${USERMAP_UID}" || -n "${USERMAP_GID}" ]]; then
|
||||
echo "${log_prefix} WARNING: USERMAP_UID/USERMAP_GID are set but have no effect when the container is started as a non-root user"
|
||||
fi
|
||||
echo "${log_prefix} Running as non-root user ($(id --user):$(id --group)), skipping UID/GID remapping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
declare -r usermap_original_uid=$(id -u paperless)
|
||||
declare -r usermap_original_gid=$(id -g paperless)
|
||||
declare -r usermap_new_uid=${USERMAP_UID:-$usermap_original_uid}
|
||||
|
||||
@@ -1,29 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## paperless-ngx 2.20.11
|
||||
|
||||
### Security
|
||||
|
||||
- Resolve [GHSA-59xh-5vwx-4c4q](https://github.com/paperless-ngx/paperless-ngx/security/advisories/GHSA-59xh-5vwx-4c4q)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fix: correct dropdown list active color in dark mode [@shamoon](https://github.com/shamoon) ([#12328](https://github.com/paperless-ngx/paperless-ngx/pull/12328))
|
||||
- Fixhancement: clear descendant selections in dropdown when parent toggled [@shamoon](https://github.com/shamoon) ([#12326](https://github.com/paperless-ngx/paperless-ngx/pull/12326))
|
||||
- Fix: prevent wrapping with larger amounts of tags on small cards, reset moreTags setting to correct count [@shamoon](https://github.com/shamoon) ([#12302](https://github.com/paperless-ngx/paperless-ngx/pull/12302))
|
||||
- Fix: prevent stale db filename during workflow actions [@shamoon](https://github.com/shamoon) ([#12289](https://github.com/paperless-ngx/paperless-ngx/pull/12289))
|
||||
|
||||
### All App Changes
|
||||
|
||||
<details>
|
||||
<summary>4 changes</summary>
|
||||
|
||||
- Fix: correct dropdown list active color in dark mode [@shamoon](https://github.com/shamoon) ([#12328](https://github.com/paperless-ngx/paperless-ngx/pull/12328))
|
||||
- Fixhancement: clear descendant selections in dropdown when parent toggled [@shamoon](https://github.com/shamoon) ([#12326](https://github.com/paperless-ngx/paperless-ngx/pull/12326))
|
||||
- Fix: prevent wrapping with larger amounts of tags on small cards, reset moreTags setting to correct count [@shamoon](https://github.com/shamoon) ([#12302](https://github.com/paperless-ngx/paperless-ngx/pull/12302))
|
||||
- Fix: prevent stale db filename during workflow actions [@shamoon](https://github.com/shamoon) ([#12289](https://github.com/paperless-ngx/paperless-ngx/pull/12289))
|
||||
</details>
|
||||
|
||||
## paperless-ngx 2.20.10
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -140,17 +140,24 @@ a [superuser](usage.md#superusers) account.
|
||||
|
||||
!!! warning
|
||||
|
||||
It is not possible to run the container rootless if additional languages are specified via `PAPERLESS_OCR_LANGUAGES`.
|
||||
It is currently not possible to run the container rootless if additional languages are specified via `PAPERLESS_OCR_LANGUAGES`.
|
||||
|
||||
If you want to run Paperless as a rootless container, set `user:` in `docker-compose.yml` to the UID and GID of your host user (use `id -u` and `id -g` to find these values). The container process starts directly as that user with no internal privilege remapping:
|
||||
If you want to run Paperless as a rootless container, make this
|
||||
change in `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
webserver:
|
||||
image: ghcr.io/paperless-ngx/paperless-ngx:latest
|
||||
user: '1000:1000'
|
||||
```
|
||||
- Set the `user` running the container to map to the `paperless`
|
||||
user in the container. This value (`user_id` below) should be
|
||||
the same ID that `USERMAP_UID` and `USERMAP_GID` are set to in
|
||||
`docker-compose.env`. See `USERMAP_UID` and `USERMAP_GID`
|
||||
[here](configuration.md#docker).
|
||||
|
||||
Do not combine this with `USERMAP_UID` or `USERMAP_GID`, which are intended for the non-rootless case described in step 3.
|
||||
Your entry for Paperless should contain something like:
|
||||
|
||||
> ```
|
||||
> webserver:
|
||||
> image: ghcr.io/paperless-ngx/paperless-ngx:latest
|
||||
> user: <user_id>
|
||||
> ```
|
||||
|
||||
**File systems without inotify support (e.g. NFS)**
|
||||
|
||||
@@ -498,8 +505,9 @@ installation. Keep these points in mind:
|
||||
- Read the [changelog](changelog.md) and
|
||||
take note of breaking changes.
|
||||
- Decide whether to stay on SQLite or migrate to PostgreSQL.
|
||||
Both work fine with Paperless-ngx.
|
||||
However, if you already have a database server running
|
||||
See [documentation](#sqlite_to_psql) for details on moving data
|
||||
from SQLite to PostgreSQL. Both work fine with
|
||||
Paperless. However, if you already have a database server running
|
||||
for other services, you might as well use it for Paperless as well.
|
||||
- The task scheduler of Paperless, which is used to execute periodic
|
||||
tasks such as email checking and maintenance, requires a
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "paperless-ngx"
|
||||
version = "2.20.12"
|
||||
version = "2.20.10"
|
||||
description = "A community-supported supercharged document management system: scan, index and archive all your physical documents"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paperless-ngx-ui",
|
||||
"version": "2.20.12",
|
||||
"version": "2.20.10",
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"ng": "ng",
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
import { HttpErrorResponse, HttpRequest } from '@angular/common/http'
|
||||
import { TestBed } from '@angular/core/testing'
|
||||
import { throwError } from 'rxjs'
|
||||
import * as navUtils from '../utils/navigation'
|
||||
import { AuthExpiryInterceptor } from './auth-expiry.interceptor'
|
||||
|
||||
describe('AuthExpiryInterceptor', () => {
|
||||
let interceptor: AuthExpiryInterceptor
|
||||
let dateNowSpy: jest.SpiedFunction<typeof Date.now>
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [AuthExpiryInterceptor],
|
||||
})
|
||||
|
||||
interceptor = TestBed.inject(AuthExpiryInterceptor)
|
||||
dateNowSpy = jest.spyOn(Date, 'now').mockReturnValue(1000)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('reloads when an API request returns 401', () => {
|
||||
const reloadSpy = jest
|
||||
.spyOn(navUtils, 'locationReload')
|
||||
.mockImplementation(() => {})
|
||||
|
||||
interceptor
|
||||
.intercept(new HttpRequest('GET', '/api/documents/'), {
|
||||
handle: (_request) =>
|
||||
throwError(
|
||||
() =>
|
||||
new HttpErrorResponse({
|
||||
status: 401,
|
||||
url: '/api/documents/',
|
||||
})
|
||||
),
|
||||
})
|
||||
.subscribe({
|
||||
error: () => undefined,
|
||||
})
|
||||
|
||||
expect(reloadSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not reload for non-401 errors', () => {
|
||||
const reloadSpy = jest
|
||||
.spyOn(navUtils, 'locationReload')
|
||||
.mockImplementation(() => {})
|
||||
|
||||
interceptor
|
||||
.intercept(new HttpRequest('GET', '/api/documents/'), {
|
||||
handle: (_request) =>
|
||||
throwError(
|
||||
() =>
|
||||
new HttpErrorResponse({
|
||||
status: 500,
|
||||
url: '/api/documents/',
|
||||
})
|
||||
),
|
||||
})
|
||||
.subscribe({
|
||||
error: () => undefined,
|
||||
})
|
||||
|
||||
expect(reloadSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not reload for non-api 401 responses', () => {
|
||||
const reloadSpy = jest
|
||||
.spyOn(navUtils, 'locationReload')
|
||||
.mockImplementation(() => {})
|
||||
|
||||
interceptor
|
||||
.intercept(new HttpRequest('GET', '/accounts/profile/'), {
|
||||
handle: (_request) =>
|
||||
throwError(
|
||||
() =>
|
||||
new HttpErrorResponse({
|
||||
status: 401,
|
||||
url: '/accounts/profile/',
|
||||
})
|
||||
),
|
||||
})
|
||||
.subscribe({
|
||||
error: () => undefined,
|
||||
})
|
||||
|
||||
expect(reloadSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reloads only once even with multiple API 401 responses', () => {
|
||||
const reloadSpy = jest
|
||||
.spyOn(navUtils, 'locationReload')
|
||||
.mockImplementation(() => {})
|
||||
|
||||
const request = new HttpRequest('GET', '/api/documents/')
|
||||
const handler = {
|
||||
handle: (_request) =>
|
||||
throwError(
|
||||
() =>
|
||||
new HttpErrorResponse({
|
||||
status: 401,
|
||||
url: '/api/documents/',
|
||||
})
|
||||
),
|
||||
}
|
||||
|
||||
interceptor.intercept(request, handler).subscribe({
|
||||
error: () => undefined,
|
||||
})
|
||||
interceptor.intercept(request, handler).subscribe({
|
||||
error: () => undefined,
|
||||
})
|
||||
|
||||
expect(reloadSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('retries reload after cooldown for repeated API 401 responses', () => {
|
||||
const reloadSpy = jest
|
||||
.spyOn(navUtils, 'locationReload')
|
||||
.mockImplementation(() => {})
|
||||
|
||||
dateNowSpy
|
||||
.mockReturnValueOnce(1000)
|
||||
.mockReturnValueOnce(2500)
|
||||
.mockReturnValueOnce(3501)
|
||||
|
||||
const request = new HttpRequest('GET', '/api/documents/')
|
||||
const handler = {
|
||||
handle: (_request) =>
|
||||
throwError(
|
||||
() =>
|
||||
new HttpErrorResponse({
|
||||
status: 401,
|
||||
url: '/api/documents/',
|
||||
})
|
||||
),
|
||||
}
|
||||
|
||||
interceptor.intercept(request, handler).subscribe({
|
||||
error: () => undefined,
|
||||
})
|
||||
interceptor.intercept(request, handler).subscribe({
|
||||
error: () => undefined,
|
||||
})
|
||||
interceptor.intercept(request, handler).subscribe({
|
||||
error: () => undefined,
|
||||
})
|
||||
|
||||
expect(reloadSpy).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
@@ -1,38 +0,0 @@
|
||||
import {
|
||||
HttpErrorResponse,
|
||||
HttpEvent,
|
||||
HttpHandler,
|
||||
HttpInterceptor,
|
||||
HttpRequest,
|
||||
} from '@angular/common/http'
|
||||
import { Injectable } from '@angular/core'
|
||||
import { catchError, Observable, throwError } from 'rxjs'
|
||||
import { locationReload } from '../utils/navigation'
|
||||
|
||||
@Injectable()
|
||||
export class AuthExpiryInterceptor implements HttpInterceptor {
|
||||
private lastReloadAttempt = Number.NEGATIVE_INFINITY
|
||||
|
||||
intercept(
|
||||
request: HttpRequest<unknown>,
|
||||
next: HttpHandler
|
||||
): Observable<HttpEvent<unknown>> {
|
||||
return next.handle(request).pipe(
|
||||
catchError((error: unknown) => {
|
||||
if (
|
||||
error instanceof HttpErrorResponse &&
|
||||
error.status === 401 &&
|
||||
request.url.includes('/api/')
|
||||
) {
|
||||
const now = Date.now()
|
||||
if (now - this.lastReloadAttempt >= 2000) {
|
||||
this.lastReloadAttempt = now
|
||||
locationReload()
|
||||
}
|
||||
}
|
||||
|
||||
return throwError(() => error)
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ export const environment = {
|
||||
apiVersion: '9', // match src/paperless/settings.py
|
||||
appTitle: 'Paperless-ngx',
|
||||
tag: 'prod',
|
||||
version: '2.20.12',
|
||||
version: '2.20.10',
|
||||
webSocketHost: window.location.host,
|
||||
webSocketProtocol: window.location.protocol == 'https:' ? 'wss:' : 'ws:',
|
||||
webSocketBaseUrl: base_url.pathname + 'ws/',
|
||||
|
||||
@@ -147,7 +147,6 @@ import { DirtyDocGuard } from './app/guards/dirty-doc.guard'
|
||||
import { DirtySavedViewGuard } from './app/guards/dirty-saved-view.guard'
|
||||
import { PermissionsGuard } from './app/guards/permissions.guard'
|
||||
import { ApiVersionInterceptor } from './app/interceptors/api-version.interceptor'
|
||||
import { AuthExpiryInterceptor } from './app/interceptors/auth-expiry.interceptor'
|
||||
import { CsrfInterceptor } from './app/interceptors/csrf.interceptor'
|
||||
import { DocumentTitlePipe } from './app/pipes/document-title.pipe'
|
||||
import { FilterPipe } from './app/pipes/filter.pipe'
|
||||
@@ -391,11 +390,6 @@ bootstrapApplication(AppComponent, {
|
||||
useClass: ApiVersionInterceptor,
|
||||
multi: true,
|
||||
},
|
||||
{
|
||||
provide: HTTP_INTERCEPTORS,
|
||||
useClass: AuthExpiryInterceptor,
|
||||
multi: true,
|
||||
},
|
||||
FilterPipe,
|
||||
DocumentTitlePipe,
|
||||
{ provide: NgbDateAdapter, useClass: ISODateAdapter },
|
||||
|
||||
@@ -153,11 +153,6 @@ $form-check-radio-checked-bg-image-dark: url("data:image/svg+xml,<svg xmlns='htt
|
||||
--bs-list-group-action-active-color: var(--bs-body-color);
|
||||
--bs-list-group-action-active-bg: var(--pngx-bg-darker);
|
||||
}
|
||||
|
||||
.form-control:hover::file-selector-button {
|
||||
background-color:var(--pngx-bg-dark) !important
|
||||
}
|
||||
|
||||
.search-container {
|
||||
input, input:focus, i-bs[name="search"] , ::placeholder {
|
||||
color: var(--pngx-primary-text-contrast) !important;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
@@ -392,14 +391,6 @@ class CannotMoveFilesException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _path_matches_checksum(path: Path, checksum: str | None) -> bool:
|
||||
if checksum is None or not path.is_file():
|
||||
return False
|
||||
|
||||
with path.open("rb") as f:
|
||||
return hashlib.md5(f.read()).hexdigest() == checksum
|
||||
|
||||
|
||||
def _filename_template_uses_custom_fields(doc: Document) -> bool:
|
||||
template = None
|
||||
if doc.storage_path is not None:
|
||||
@@ -470,12 +461,10 @@ def update_filename_and_move_files(
|
||||
old_filename = instance.filename
|
||||
old_source_path = instance.source_path
|
||||
move_original = False
|
||||
original_already_moved = False
|
||||
|
||||
old_archive_filename = instance.archive_filename
|
||||
old_archive_path = instance.archive_path
|
||||
move_archive = False
|
||||
archive_already_moved = False
|
||||
|
||||
candidate_filename = generate_filename(instance)
|
||||
if len(str(candidate_filename)) > Document.MAX_STORED_FILENAME_LENGTH:
|
||||
@@ -496,23 +485,14 @@ def update_filename_and_move_files(
|
||||
candidate_source_path.exists()
|
||||
and candidate_source_path != old_source_path
|
||||
):
|
||||
if not old_source_path.is_file() and _path_matches_checksum(
|
||||
candidate_source_path,
|
||||
instance.checksum,
|
||||
):
|
||||
new_filename = candidate_filename
|
||||
original_already_moved = True
|
||||
else:
|
||||
# Only fall back to unique search when there is an actual conflict
|
||||
new_filename = generate_unique_filename(instance)
|
||||
# Only fall back to unique search when there is an actual conflict
|
||||
new_filename = generate_unique_filename(instance)
|
||||
else:
|
||||
new_filename = candidate_filename
|
||||
|
||||
# Need to convert to string to be able to save it to the db
|
||||
instance.filename = str(new_filename)
|
||||
move_original = (
|
||||
old_filename != instance.filename and not original_already_moved
|
||||
)
|
||||
move_original = old_filename != instance.filename
|
||||
|
||||
if instance.has_archive_version:
|
||||
archive_candidate = generate_filename(instance, archive_filename=True)
|
||||
@@ -533,38 +513,24 @@ def update_filename_and_move_files(
|
||||
archive_candidate_path.exists()
|
||||
and archive_candidate_path != old_archive_path
|
||||
):
|
||||
if not old_archive_path.is_file() and _path_matches_checksum(
|
||||
archive_candidate_path,
|
||||
instance.archive_checksum,
|
||||
):
|
||||
new_archive_filename = archive_candidate
|
||||
archive_already_moved = True
|
||||
else:
|
||||
new_archive_filename = generate_unique_filename(
|
||||
instance,
|
||||
archive_filename=True,
|
||||
)
|
||||
new_archive_filename = generate_unique_filename(
|
||||
instance,
|
||||
archive_filename=True,
|
||||
)
|
||||
else:
|
||||
new_archive_filename = archive_candidate
|
||||
|
||||
instance.archive_filename = str(new_archive_filename)
|
||||
|
||||
move_archive = (
|
||||
old_archive_filename != instance.archive_filename
|
||||
and not archive_already_moved
|
||||
)
|
||||
move_archive = old_archive_filename != instance.archive_filename
|
||||
else:
|
||||
move_archive = False
|
||||
|
||||
if not move_original and not move_archive:
|
||||
updates = {"modified": timezone.now()}
|
||||
if old_filename != instance.filename:
|
||||
updates["filename"] = instance.filename
|
||||
if old_archive_filename != instance.archive_filename:
|
||||
updates["archive_filename"] = instance.archive_filename
|
||||
|
||||
# Don't save() here to prevent infinite recursion.
|
||||
Document.objects.filter(pk=instance.pk).update(**updates)
|
||||
# Just update modified. Also, don't save() here to prevent infinite recursion.
|
||||
Document.objects.filter(pk=instance.pk).update(
|
||||
modified=timezone.now(),
|
||||
)
|
||||
return
|
||||
|
||||
if move_original:
|
||||
@@ -867,25 +833,10 @@ def run_workflows(
|
||||
if not use_overrides:
|
||||
# limit title to 128 characters
|
||||
document.title = document.title[:128]
|
||||
# Save only the fields that workflow actions can set directly.
|
||||
# Deliberately excludes filename and archive_filename — those are
|
||||
# managed exclusively by update_filename_and_move_files via the
|
||||
# post_save signal. Writing stale in-memory values here would revert
|
||||
# a concurrent update_filename_and_move_files DB write, leaving the
|
||||
# DB pointing at the old path while the file is already at the new
|
||||
# one (see: https://github.com/paperless-ngx/paperless-ngx/issues/12386).
|
||||
# modified has auto_now=True but is not auto-added when update_fields
|
||||
# is specified, so it must be listed explicitly.
|
||||
document.save(
|
||||
update_fields=[
|
||||
"title",
|
||||
"correspondent",
|
||||
"document_type",
|
||||
"storage_path",
|
||||
"owner",
|
||||
"modified",
|
||||
],
|
||||
)
|
||||
# Make sure the filename and archive filename are accurate
|
||||
document.refresh_from_db(fields=["filename", "archive_filename"])
|
||||
# save first before setting tags
|
||||
document.save()
|
||||
document.tags.set(doc_tag_ids)
|
||||
|
||||
WorkflowRun.objects.create(
|
||||
|
||||
@@ -888,19 +888,6 @@ class TestApiUser(DirectoriesMixin, APITestCase):
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
|
||||
response = self.client.post(
|
||||
f"{self.ENDPOINT}",
|
||||
json.dumps(
|
||||
{
|
||||
"username": "user4",
|
||||
"is_superuser": "true",
|
||||
},
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
|
||||
self.client.force_authenticate(user2)
|
||||
|
||||
response = self.client.patch(
|
||||
@@ -933,65 +920,6 @@ class TestApiUser(DirectoriesMixin, APITestCase):
|
||||
returned_user1 = User.objects.get(pk=user1.pk)
|
||||
self.assertEqual(returned_user1.is_superuser, False)
|
||||
|
||||
def test_only_superusers_can_create_or_alter_staff_status(self):
|
||||
"""
|
||||
GIVEN:
|
||||
- Existing user account
|
||||
WHEN:
|
||||
- API request is made to add a user account with staff status
|
||||
- API request is made to change staff status
|
||||
THEN:
|
||||
- Only superusers can change staff status
|
||||
"""
|
||||
|
||||
user1 = User.objects.create_user(username="user1")
|
||||
user1.user_permissions.add(*Permission.objects.all())
|
||||
user2 = User.objects.create_superuser(username="user2")
|
||||
|
||||
self.client.force_authenticate(user1)
|
||||
|
||||
response = self.client.patch(
|
||||
f"{self.ENDPOINT}{user1.pk}/",
|
||||
json.dumps(
|
||||
{
|
||||
"is_staff": "true",
|
||||
},
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
|
||||
response = self.client.post(
|
||||
f"{self.ENDPOINT}",
|
||||
json.dumps(
|
||||
{
|
||||
"username": "user3",
|
||||
"is_staff": 1,
|
||||
},
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
|
||||
self.client.force_authenticate(user2)
|
||||
|
||||
response = self.client.patch(
|
||||
f"{self.ENDPOINT}{user1.pk}/",
|
||||
json.dumps(
|
||||
{
|
||||
"is_staff": True,
|
||||
},
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
returned_user1 = User.objects.get(pk=user1.pk)
|
||||
self.assertEqual(returned_user1.is_staff, True)
|
||||
|
||||
|
||||
class TestApiGroup(DirectoriesMixin, APITestCase):
|
||||
ENDPOINT = "/api/groups/"
|
||||
|
||||
@@ -1357,83 +1357,6 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
|
||||
self.assertEqual(results["custom_fields"][0]["id"], custom_field1.id)
|
||||
self.assertEqual(results["workflows"][0]["id"], workflow1.id)
|
||||
|
||||
def test_global_search_filters_owned_mail_objects(self):
|
||||
user1 = User.objects.create_user("mail-search-user")
|
||||
user2 = User.objects.create_user("other-mail-search-user")
|
||||
user1.user_permissions.add(
|
||||
Permission.objects.get(codename="view_mailaccount"),
|
||||
Permission.objects.get(codename="view_mailrule"),
|
||||
)
|
||||
|
||||
own_account = MailAccount.objects.create(
|
||||
name="bank owned account",
|
||||
username="owner@example.com",
|
||||
password="secret",
|
||||
imap_server="imap.owner.example.com",
|
||||
imap_port=993,
|
||||
imap_security=MailAccount.ImapSecurity.SSL,
|
||||
character_set="UTF-8",
|
||||
owner=user1,
|
||||
)
|
||||
other_account = MailAccount.objects.create(
|
||||
name="bank other account",
|
||||
username="other@example.com",
|
||||
password="secret",
|
||||
imap_server="imap.other.example.com",
|
||||
imap_port=993,
|
||||
imap_security=MailAccount.ImapSecurity.SSL,
|
||||
character_set="UTF-8",
|
||||
owner=user2,
|
||||
)
|
||||
unowned_account = MailAccount.objects.create(
|
||||
name="bank shared account",
|
||||
username="shared@example.com",
|
||||
password="secret",
|
||||
imap_server="imap.shared.example.com",
|
||||
imap_port=993,
|
||||
imap_security=MailAccount.ImapSecurity.SSL,
|
||||
character_set="UTF-8",
|
||||
)
|
||||
own_rule = MailRule.objects.create(
|
||||
name="bank owned rule",
|
||||
account=own_account,
|
||||
action=MailRule.MailAction.MOVE,
|
||||
owner=user1,
|
||||
)
|
||||
other_rule = MailRule.objects.create(
|
||||
name="bank other rule",
|
||||
account=other_account,
|
||||
action=MailRule.MailAction.MOVE,
|
||||
owner=user2,
|
||||
)
|
||||
unowned_rule = MailRule.objects.create(
|
||||
name="bank shared rule",
|
||||
account=unowned_account,
|
||||
action=MailRule.MailAction.MOVE,
|
||||
)
|
||||
|
||||
self.client.force_authenticate(user1)
|
||||
|
||||
response = self.client.get("/api/search/?query=bank")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertCountEqual(
|
||||
[account["id"] for account in response.data["mail_accounts"]],
|
||||
[own_account.id, unowned_account.id],
|
||||
)
|
||||
self.assertCountEqual(
|
||||
[rule["id"] for rule in response.data["mail_rules"]],
|
||||
[own_rule.id, unowned_rule.id],
|
||||
)
|
||||
self.assertNotIn(
|
||||
other_account.id,
|
||||
[account["id"] for account in response.data["mail_accounts"]],
|
||||
)
|
||||
self.assertNotIn(
|
||||
other_rule.id,
|
||||
[rule["id"] for rule in response.data["mail_rules"]],
|
||||
)
|
||||
|
||||
def test_global_search_bad_request(self):
|
||||
"""
|
||||
WHEN:
|
||||
|
||||
@@ -57,18 +57,11 @@ class TestSystemStatus(APITestCase):
|
||||
"""
|
||||
response = self.client.get(self.ENDPOINT)
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
self.assertEqual(response["WWW-Authenticate"], "Token")
|
||||
normal_user = User.objects.create_user(username="normal_user")
|
||||
self.client.force_login(normal_user)
|
||||
response = self.client.get(self.ENDPOINT)
|
||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
|
||||
def test_system_status_with_bad_basic_auth_challenges(self) -> None:
|
||||
self.client.credentials(HTTP_AUTHORIZATION="Basic invalid")
|
||||
response = self.client.get(self.ENDPOINT)
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
self.assertEqual(response["WWW-Authenticate"], 'Basic realm="api"')
|
||||
|
||||
def test_system_status_container_detection(self):
|
||||
"""
|
||||
GIVEN:
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import datetime
|
||||
import hashlib
|
||||
import logging
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
@@ -167,52 +166,6 @@ class TestFileHandling(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
||||
)
|
||||
self.assertEqual(document.filename, "none/none.pdf")
|
||||
|
||||
@override_settings(FILENAME_FORMAT=None)
|
||||
def test_stale_save_recovers_already_moved_files(self) -> None:
|
||||
old_storage_path = StoragePath.objects.create(
|
||||
name="old-path",
|
||||
path="old/{{title}}",
|
||||
)
|
||||
new_storage_path = StoragePath.objects.create(
|
||||
name="new-path",
|
||||
path="new/{{title}}",
|
||||
)
|
||||
original_bytes = b"original"
|
||||
archive_bytes = b"archive"
|
||||
|
||||
doc = Document.objects.create(
|
||||
title="document",
|
||||
mime_type="application/pdf",
|
||||
checksum=hashlib.md5(original_bytes).hexdigest(),
|
||||
archive_checksum=hashlib.md5(archive_bytes).hexdigest(),
|
||||
filename="old/document.pdf",
|
||||
archive_filename="old/document.pdf",
|
||||
storage_path=old_storage_path,
|
||||
)
|
||||
create_source_path_directory(doc.source_path)
|
||||
doc.source_path.write_bytes(original_bytes)
|
||||
create_source_path_directory(doc.archive_path)
|
||||
doc.archive_path.write_bytes(archive_bytes)
|
||||
|
||||
stale_doc = Document.objects.get(pk=doc.pk)
|
||||
fresh_doc = Document.objects.get(pk=doc.pk)
|
||||
fresh_doc.storage_path = new_storage_path
|
||||
fresh_doc.save()
|
||||
doc.refresh_from_db()
|
||||
self.assertEqual(doc.filename, "new/document.pdf")
|
||||
self.assertEqual(doc.archive_filename, "new/document.pdf")
|
||||
|
||||
stale_doc.storage_path = new_storage_path
|
||||
stale_doc.save()
|
||||
|
||||
doc.refresh_from_db()
|
||||
self.assertEqual(doc.filename, "new/document.pdf")
|
||||
self.assertEqual(doc.archive_filename, "new/document.pdf")
|
||||
self.assertIsFile(doc.source_path)
|
||||
self.assertIsFile(doc.archive_path)
|
||||
self.assertIsNotFile(settings.ORIGINALS_DIR / "old" / "document.pdf")
|
||||
self.assertIsNotFile(settings.ARCHIVE_DIR / "old" / "document.pdf")
|
||||
|
||||
@override_settings(FILENAME_FORMAT="{correspondent}/{correspondent}")
|
||||
def test_document_delete(self):
|
||||
document = Document()
|
||||
|
||||
@@ -956,64 +956,6 @@ class TestWorkflows(
|
||||
self.assertEqual(Path(doc.filename), expected_filename)
|
||||
self.assertTrue(doc.source_path.is_file())
|
||||
|
||||
def test_workflow_document_updated_does_not_overwrite_filename(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- A document whose filename has been updated in the DB by a concurrent
|
||||
bulk_update_documents task (simulating update_filename_and_move_files
|
||||
completing and writing the new filename to the DB)
|
||||
- A stale in-memory document instance still holding the old filename
|
||||
- An active DOCUMENT_UPDATED workflow
|
||||
WHEN:
|
||||
- run_workflows is called with the stale in-memory instance
|
||||
(as would happen in the second concurrent bulk_update_documents task)
|
||||
THEN:
|
||||
- The DB filename is NOT overwritten with the stale in-memory value
|
||||
(regression test for GH #12386 — the race window between
|
||||
refresh_from_db and document.save in run_workflows)
|
||||
"""
|
||||
trigger = WorkflowTrigger.objects.create(
|
||||
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
|
||||
)
|
||||
action = WorkflowAction.objects.create(
|
||||
type=WorkflowAction.WorkflowActionType.ASSIGNMENT,
|
||||
assign_title="Updated by workflow",
|
||||
)
|
||||
workflow = Workflow.objects.create(name="Race condition test workflow", order=0)
|
||||
workflow.triggers.add(trigger)
|
||||
workflow.actions.add(action)
|
||||
workflow.save()
|
||||
|
||||
doc = Document.objects.create(
|
||||
title="race condition test",
|
||||
mime_type="application/pdf",
|
||||
checksum="racecondition123",
|
||||
original_filename="old.pdf",
|
||||
filename="old/path/old.pdf",
|
||||
)
|
||||
|
||||
# Simulate BUD-1 completing update_filename_and_move_files:
|
||||
# the DB now holds the new filename while BUD-2's in-memory instance is stale.
|
||||
new_filename = "new/path/new.pdf"
|
||||
Document.global_objects.filter(pk=doc.pk).update(filename=new_filename)
|
||||
|
||||
# The stale instance still has filename="old/path/old.pdf" in memory.
|
||||
# Mock refresh_from_db so the stale value persists through run_workflows,
|
||||
# replicating the race window between refresh and save.
|
||||
# Mock update_filename_and_move_files to prevent file-not-found errors
|
||||
# since we are only testing DB state here.
|
||||
with (
|
||||
mock.patch(
|
||||
"documents.signals.handlers.update_filename_and_move_files",
|
||||
),
|
||||
mock.patch.object(Document, "refresh_from_db"),
|
||||
):
|
||||
run_workflows(WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, doc)
|
||||
|
||||
# The DB filename must not have been reverted to the stale old value.
|
||||
doc.refresh_from_db()
|
||||
self.assertEqual(doc.filename, new_filename)
|
||||
|
||||
def test_document_added_workflow(self):
|
||||
trigger = WorkflowTrigger.objects.create(
|
||||
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
||||
|
||||
@@ -1038,7 +1038,6 @@ class DocumentViewSet(
|
||||
methods=["get", "post", "delete"],
|
||||
detail=True,
|
||||
permission_classes=[PaperlessNotePermissions],
|
||||
pagination_class=None,
|
||||
filter_backends=[],
|
||||
)
|
||||
def notes(self, request, pk=None):
|
||||
@@ -2112,21 +2111,13 @@ class GlobalSearchView(PassUserMixin):
|
||||
)
|
||||
groups = groups[:OBJECT_LIMIT]
|
||||
mail_rules = (
|
||||
get_objects_for_user_owner_aware(
|
||||
request.user,
|
||||
"view_mailrule",
|
||||
MailRule,
|
||||
).filter(name__icontains=query)
|
||||
MailRule.objects.filter(name__icontains=query)
|
||||
if request.user.has_perm("paperless_mail.view_mailrule")
|
||||
else []
|
||||
)
|
||||
mail_rules = mail_rules[:OBJECT_LIMIT]
|
||||
mail_accounts = (
|
||||
get_objects_for_user_owner_aware(
|
||||
request.user,
|
||||
"view_mailaccount",
|
||||
MailAccount,
|
||||
).filter(name__icontains=query)
|
||||
MailAccount.objects.filter(name__icontains=query)
|
||||
if request.user.has_perm("paperless_mail.view_mailaccount")
|
||||
else []
|
||||
)
|
||||
|
||||
@@ -83,11 +83,3 @@ class PaperlessBasicAuthentication(authentication.BasicAuthentication):
|
||||
raise exceptions.AuthenticationFailed("MFA required")
|
||||
|
||||
return user_tuple
|
||||
|
||||
def authenticate_header(self, request):
|
||||
auth_header = request.META.get("HTTP_AUTHORIZATION", "")
|
||||
if auth_header.lower().startswith("basic "):
|
||||
return super().authenticate_header(request)
|
||||
|
||||
# Still 401 for anonymous API access
|
||||
return authentication.TokenAuthentication.keyword
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from typing import Final
|
||||
|
||||
__version__: Final[tuple[int, int, int]] = (2, 20, 12)
|
||||
__version__: Final[tuple[int, int, int]] = (2, 20, 10)
|
||||
# Version string like X.Y.Z
|
||||
__full_version_str__: Final[str] = ".".join(map(str, __version__))
|
||||
# Version string like X.Y
|
||||
|
||||
@@ -25,8 +25,6 @@ from drf_spectacular.utils import extend_schema_view
|
||||
from rest_framework.authtoken.models import Token
|
||||
from rest_framework.authtoken.views import ObtainAuthToken
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.fields import BooleanField
|
||||
from rest_framework.filters import OrderingFilter
|
||||
from rest_framework.generics import GenericAPIView
|
||||
from rest_framework.pagination import PageNumberPagination
|
||||
@@ -105,7 +103,6 @@ class FaviconView(View):
|
||||
|
||||
|
||||
class UserViewSet(ModelViewSet):
|
||||
_BOOL_NOT_PROVIDED = object()
|
||||
model = User
|
||||
|
||||
queryset = User.objects.exclude(
|
||||
@@ -119,65 +116,27 @@ class UserViewSet(ModelViewSet):
|
||||
filterset_class = UserFilterSet
|
||||
ordering_fields = ("username",)
|
||||
|
||||
@staticmethod
|
||||
def _parse_requested_bool(data, key: str):
|
||||
if key not in data:
|
||||
return UserViewSet._BOOL_NOT_PROVIDED
|
||||
try:
|
||||
return BooleanField().to_internal_value(data.get(key))
|
||||
except ValidationError:
|
||||
# Let serializer validation report invalid values as 400 responses
|
||||
return UserViewSet._BOOL_NOT_PROVIDED
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
requested_is_superuser = self._parse_requested_bool(
|
||||
request.data,
|
||||
"is_superuser",
|
||||
)
|
||||
requested_is_staff = self._parse_requested_bool(request.data, "is_staff")
|
||||
|
||||
if not request.user.is_superuser:
|
||||
if requested_is_superuser is True:
|
||||
return HttpResponseForbidden(
|
||||
"Superuser status can only be granted by a superuser",
|
||||
)
|
||||
if requested_is_staff is True:
|
||||
return HttpResponseForbidden(
|
||||
"Staff status can only be granted by a superuser",
|
||||
)
|
||||
|
||||
if not request.user.is_superuser and request.data.get("is_superuser") is True:
|
||||
return HttpResponseForbidden(
|
||||
"Superuser status can only be granted by a superuser",
|
||||
)
|
||||
return super().create(request, *args, **kwargs)
|
||||
|
||||
def update(self, request, *args, **kwargs):
|
||||
user_to_update: User = self.get_object()
|
||||
|
||||
if not request.user.is_superuser and user_to_update.is_superuser:
|
||||
return HttpResponseForbidden(
|
||||
"Superusers can only be modified by other superusers",
|
||||
)
|
||||
|
||||
requested_is_superuser = self._parse_requested_bool(
|
||||
request.data,
|
||||
"is_superuser",
|
||||
)
|
||||
requested_is_staff = self._parse_requested_bool(request.data, "is_staff")
|
||||
|
||||
if (
|
||||
not request.user.is_superuser
|
||||
and requested_is_superuser is not self._BOOL_NOT_PROVIDED
|
||||
and requested_is_superuser != user_to_update.is_superuser
|
||||
and request.data.get("is_superuser") is not None
|
||||
and request.data.get("is_superuser") != user_to_update.is_superuser
|
||||
):
|
||||
return HttpResponseForbidden(
|
||||
"Superuser status can only be changed by a superuser",
|
||||
)
|
||||
if (
|
||||
not request.user.is_superuser
|
||||
and requested_is_staff is not self._BOOL_NOT_PROVIDED
|
||||
and requested_is_staff != user_to_update.is_staff
|
||||
):
|
||||
return HttpResponseForbidden(
|
||||
"Staff status can only be changed by a superuser",
|
||||
)
|
||||
return super().update(request, *args, **kwargs)
|
||||
|
||||
@extend_schema(
|
||||
|
||||
@@ -472,6 +472,7 @@ class MailAccountHandler(LoggingMixin):
|
||||
name=name,
|
||||
defaults={
|
||||
"match": name,
|
||||
"matching_algorithm": Correspondent.MATCH_AUTO,
|
||||
},
|
||||
)[0]
|
||||
except DatabaseError as e:
|
||||
|
||||
@@ -448,7 +448,7 @@ class TestMail(
|
||||
c = handler._get_correspondent(message, rule)
|
||||
self.assertIsNotNone(c)
|
||||
self.assertEqual(c.name, "someone@somewhere.com")
|
||||
self.assertEqual(c.matching_algorithm, MatchingModel.MATCH_ANY)
|
||||
self.assertEqual(c.matching_algorithm, MatchingModel.MATCH_AUTO)
|
||||
self.assertEqual(c.match, "someone@somewhere.com")
|
||||
c = handler._get_correspondent(message2, rule)
|
||||
self.assertIsNotNone(c)
|
||||
|
||||
Reference in New Issue
Block a user