diff --git a/src-ui/messages.xlf b/src-ui/messages.xlf index 410f39a96..d324f384f 100644 --- a/src-ui/messages.xlf +++ b/src-ui/messages.xlf @@ -297,11 +297,11 @@ src/app/components/app-frame/app-frame.component.html - 87 + 88 src/app/components/app-frame/app-frame.component.html - 89 + 90 src/app/components/dashboard/dashboard.component.html @@ -324,11 +324,11 @@ src/app/components/app-frame/app-frame.component.html - 94 + 95 src/app/components/app-frame/app-frame.component.html - 96 + 97 src/app/components/common/share-link-bundle-dialog/share-link-bundle-dialog.component.html @@ -375,15 +375,15 @@ src/app/components/app-frame/app-frame.component.html - 54 + 55 src/app/components/app-frame/app-frame.component.html - 273 + 274 src/app/components/app-frame/app-frame.component.html - 275 + 276 @@ -728,11 +728,11 @@ src/app/components/app-frame/app-frame.component.html - 308 + 309 src/app/components/app-frame/app-frame.component.html - 311 + 312 @@ -1139,11 +1139,11 @@ src/app/components/app-frame/app-frame.component.html - 233 + 234 src/app/components/app-frame/app-frame.component.html - 235 + 236 src/app/components/manage/saved-views/saved-views.component.html @@ -1700,7 +1700,7 @@ src/app/components/app-frame/app-frame.component.html - 204 + 205 src/app/components/common/input/tags/tags.component.ts @@ -1782,15 +1782,15 @@ src/app/components/app-frame/app-frame.component.ts - 156 + 164 src/app/components/app-frame/app-frame.component.ts - 230 + 238 src/app/components/app-frame/app-frame.component.ts - 255 + 263 @@ -1801,11 +1801,11 @@ src/app/components/app-frame/app-frame.component.html - 296 + 297 src/app/components/app-frame/app-frame.component.html - 298 + 299 @@ -2224,11 +2224,11 @@ src/app/components/app-frame/app-frame.component.html - 256 + 257 src/app/components/app-frame/app-frame.component.html - 259 + 260 @@ -2581,11 +2581,11 @@ src/app/components/app-frame/app-frame.component.html - 287 + 288 src/app/components/app-frame/app-frame.component.html - 289 + 290 @@ -2897,90 +2897,90 @@ Logged in as src/app/components/app-frame/app-frame.component.html - 46 + 47 My Profile src/app/components/app-frame/app-frame.component.html - 50 + 51 Logout src/app/components/app-frame/app-frame.component.html - 57 + 58 Documentation src/app/components/app-frame/app-frame.component.html - 62 + 63 src/app/components/app-frame/app-frame.component.html - 317 + 318 src/app/components/app-frame/app-frame.component.html - 320 + 321 Saved views src/app/components/app-frame/app-frame.component.html - 104 + 105 src/app/components/app-frame/app-frame.component.html - 134 + 135 Open documents src/app/components/app-frame/app-frame.component.html - 143 + 144 Close all src/app/components/app-frame/app-frame.component.html - 163 + 164 src/app/components/app-frame/app-frame.component.html - 165 + 166 Manage src/app/components/app-frame/app-frame.component.html - 174 + 175 Attributes src/app/components/app-frame/app-frame.component.html - 181 + 182 src/app/components/app-frame/app-frame.component.html - 183 + 184 Correspondents src/app/components/app-frame/app-frame.component.html - 209 + 210 src/app/components/dashboard/widgets/statistics-widget/statistics-widget.component.html @@ -2995,7 +2995,7 @@ Document types src/app/components/app-frame/app-frame.component.html - 214 + 215 src/app/components/manage/document-attributes/document-attributes.component.ts @@ -3006,7 +3006,7 @@ Storage paths src/app/components/app-frame/app-frame.component.html - 219 + 220 src/app/components/manage/document-attributes/document-attributes.component.ts @@ -3017,7 +3017,7 @@ Custom fields src/app/components/app-frame/app-frame.component.html - 224 + 225 src/app/components/document-list/bulk-editor/bulk-editor.component.html @@ -3040,11 +3040,11 @@ Workflows src/app/components/app-frame/app-frame.component.html - 242 + 243 src/app/components/app-frame/app-frame.component.html - 244 + 245 src/app/components/manage/workflows/workflows.component.html @@ -3055,92 +3055,92 @@ Mail src/app/components/app-frame/app-frame.component.html - 249 + 250 src/app/components/app-frame/app-frame.component.html - 252 + 253 Administration src/app/components/app-frame/app-frame.component.html - 267 + 268 Configuration src/app/components/app-frame/app-frame.component.html - 280 + 281 src/app/components/app-frame/app-frame.component.html - 282 + 283 GitHub src/app/components/app-frame/app-frame.component.html - 327 + 328 is available. src/app/components/app-frame/app-frame.component.html - 336,337 + 337,338 Click to view. src/app/components/app-frame/app-frame.component.html - 337 + 338 Paperless-ngx can automatically check for updates src/app/components/app-frame/app-frame.component.html - 341 + 342 How does this work? src/app/components/app-frame/app-frame.component.html - 348,350 + 349,351 Update available src/app/components/app-frame/app-frame.component.html - 361 + 362 Sidebar views updated src/app/components/app-frame/app-frame.component.ts - 343 + 383 Error updating sidebar views src/app/components/app-frame/app-frame.component.ts - 346 + 386 An error occurred while saving update checking settings. src/app/components/app-frame/app-frame.component.ts - 367 + 407 @@ -11187,21 +11187,21 @@ Successfully completed one-time migratration of settings to the database! src/app/services/settings.service.ts - 609 + 635 Unable to migrate settings to the database, please try saving manually. src/app/services/settings.service.ts - 610 + 636 You can restart the tour from the settings page. src/app/services/settings.service.ts - 683 + 708 diff --git a/src-ui/src/app/components/app-frame/app-frame.component.html b/src-ui/src/app/components/app-frame/app-frame.component.html index d876e28ea..11a4aefe2 100644 --- a/src-ui/src/app/components/app-frame/app-frame.component.html +++ b/src-ui/src/app/components/app-frame/app-frame.component.html @@ -1,7 +1,7 @@
-
+
@@ -378,7 +379,7 @@
-
diff --git a/src-ui/src/app/components/app-frame/app-frame.component.scss b/src-ui/src/app/components/app-frame/app-frame.component.scss index 77bf9bf21..c069eeefc 100644 --- a/src-ui/src/app/components/app-frame/app-frame.component.scss +++ b/src-ui/src/app/components/app-frame/app-frame.component.scss @@ -44,6 +44,23 @@ .sidebar { top: 3.5rem; } + + .search-container { + max-height: 4.5rem; + overflow: hidden; + transition: max-height .2s ease, opacity .2s ease, padding-top .2s ease, padding-bottom .2s ease; + + &.mobile-hidden { + max-height: 0; + opacity: 0; + padding-top: 0 !important; + padding-bottom: 0 !important; + } + } + + main.mobile-search-hidden { + padding-top: 56px; + } } main { diff --git a/src-ui/src/app/components/app-frame/app-frame.component.spec.ts b/src-ui/src/app/components/app-frame/app-frame.component.spec.ts index 931f46254..1341c6e5a 100644 --- a/src-ui/src/app/components/app-frame/app-frame.component.spec.ts +++ b/src-ui/src/app/components/app-frame/app-frame.component.spec.ts @@ -293,6 +293,59 @@ describe('AppFrameComponent', () => { expect(component.isMenuCollapsed).toBeTruthy() }) + it('should hide mobile search when scrolling down and show it when scrolling up', () => { + Object.defineProperty(globalThis, 'innerWidth', { + value: 767, + }) + + component.ngOnInit() + + Object.defineProperty(globalThis, 'scrollY', { + configurable: true, + value: 40, + }) + component.onWindowScroll() + expect(component.mobileSearchHidden).toBe(true) + + Object.defineProperty(globalThis, 'scrollY', { + configurable: true, + value: 0, + }) + component.onWindowScroll() + expect(component.mobileSearchHidden).toBe(false) + }) + + it('should keep mobile search visible on desktop scroll or resize', () => { + Object.defineProperty(globalThis, 'innerWidth', { + value: 1024, + }) + component.ngOnInit() + component.mobileSearchHidden = true + + component.onWindowScroll() + + expect(component.mobileSearchHidden).toBe(false) + + component.mobileSearchHidden = true + component.onWindowResize() + }) + + it('should keep mobile search visible while the mobile menu is expanded', () => { + Object.defineProperty(globalThis, 'innerWidth', { + value: 767, + }) + component.ngOnInit() + component.isMenuCollapsed = false + + Object.defineProperty(globalThis, 'scrollY', { + configurable: true, + value: 40, + }) + component.onWindowScroll() + + expect(component.mobileSearchHidden).toBe(false) + }) + it('should support close document & navigate on close current doc', () => { const closeSpy = jest.spyOn(openDocumentsService, 'closeDocument') closeSpy.mockReturnValue(of(true)) diff --git a/src-ui/src/app/components/app-frame/app-frame.component.ts b/src-ui/src/app/components/app-frame/app-frame.component.ts index 5218d829c..7989eb3e1 100644 --- a/src-ui/src/app/components/app-frame/app-frame.component.ts +++ b/src-ui/src/app/components/app-frame/app-frame.component.ts @@ -51,6 +51,8 @@ import { ComponentWithPermissions } from '../with-permissions/with-permissions.c import { GlobalSearchComponent } from './global-search/global-search.component' import { ToastsDropdownComponent } from './toasts-dropdown/toasts-dropdown.component' +const SCROLL_THRESHOLD = 16 + @Component({ selector: 'pngx-app-frame', templateUrl: './app-frame.component.html', @@ -94,6 +96,10 @@ export class AppFrameComponent slimSidebarAnimating: boolean = false + public mobileSearchHidden: boolean = false + + private lastScrollY: number = 0 + constructor() { super() const permissionsService = this.permissionsService @@ -111,6 +117,8 @@ export class AppFrameComponent } ngOnInit(): void { + this.lastScrollY = window.scrollY + if (this.settingsService.get(SETTINGS_KEYS.UPDATE_CHECKING_ENABLED)) { this.checkForUpdates() } @@ -263,6 +271,38 @@ export class AppFrameComponent return this.settingsService.get(SETTINGS_KEYS.AI_ENABLED) } + @HostListener('window:resize') + onWindowResize(): void { + if (!this.isMobileViewport()) { + this.mobileSearchHidden = false + } + } + + @HostListener('window:scroll') + onWindowScroll(): void { + const currentScrollY = window.scrollY + + if (!this.isMobileViewport() || this.isMenuCollapsed === false) { + this.mobileSearchHidden = false + this.lastScrollY = currentScrollY + return + } + + const delta = currentScrollY - this.lastScrollY + + if (currentScrollY <= 0 || delta < -SCROLL_THRESHOLD) { + this.mobileSearchHidden = false + } else if (currentScrollY > SCROLL_THRESHOLD && delta > SCROLL_THRESHOLD) { + this.mobileSearchHidden = true + } + + this.lastScrollY = currentScrollY + } + + private isMobileViewport(): boolean { + return window.innerWidth < 768 + } + closeMenu() { this.isMenuCollapsed = true } diff --git a/src-ui/src/app/components/document-list/document-list.component.scss b/src-ui/src/app/components/document-list/document-list.component.scss index 0e10b83da..06f4c4531 100644 --- a/src-ui/src/app/components/document-list/document-list.component.scss +++ b/src-ui/src/app/components/document-list/document-list.component.scss @@ -56,13 +56,20 @@ $paperless-card-breakpoints: ( .sticky-top { z-index: 990; // below main navbar - top: calc(7rem - 2px); // height of navbar (mobile) + top: calc(7rem - 2px); // height of navbar + search row (mobile) + transition: top 0.2s ease; @media (min-width: 580px) { top: 3.5rem; // height of navbar } } +@media (max-width: 579.98px) { + :host-context(main.mobile-search-hidden) .sticky-top { + top: calc(3.5rem - 2px); // height of navbar only when search is hidden + } +} + .table .form-check { padding: 0.2rem; min-height: 0; diff --git a/src-ui/src/app/services/document-list-view.service.spec.ts b/src-ui/src/app/services/document-list-view.service.spec.ts index 6258c42b2..da3d06444 100644 --- a/src-ui/src/app/services/document-list-view.service.spec.ts +++ b/src-ui/src/app/services/document-list-view.service.spec.ts @@ -21,6 +21,7 @@ import { FILTER_HAS_TAGS_ANY, } from '../data/filter-rule-type' import { SavedView } from '../data/saved-view' +import { DOCUMENT_LIST_SERVICE } from '../data/storage-keys' import { SETTINGS_KEYS } from '../data/ui-settings' import { PermissionsGuard } from '../guards/permissions.guard' import { DocumentListViewService } from './document-list-view.service' @@ -248,6 +249,29 @@ describe('DocumentListViewService', () => { expect(documentListViewService.sortReverse).toBeTruthy() }) + it('restores only known list view state fields from local storage', () => { + try { + localStorage.setItem( + DOCUMENT_LIST_SERVICE.CURRENT_VIEW_CONFIG, + '{"currentPage":3,"sortField":"title","sortReverse":false,"__proto__":{"polluted":true},"injected":"ignored"}' + ) + + const restoredService = TestBed.runInInjectionContext( + () => new DocumentListViewService() + ) + + expect(restoredService.currentPage).toEqual(3) + expect(restoredService.sortField).toEqual('title') + expect(restoredService.sortReverse).toBeFalsy() + expect( + (restoredService as any).activeListViewState.injected + ).toBeUndefined() + expect(({} as any).polluted).toBeUndefined() + } finally { + localStorage.removeItem(DOCUMENT_LIST_SERVICE.CURRENT_VIEW_CONFIG) + } + }) + it('should load from query params', () => { expect(documentListViewService.currentPage).toEqual(1) const page = 2 diff --git a/src-ui/src/app/services/document-list-view.service.ts b/src-ui/src/app/services/document-list-view.service.ts index 6989db8ed..f2460add2 100644 --- a/src-ui/src/app/services/document-list-view.service.ts +++ b/src-ui/src/app/services/document-list-view.service.ts @@ -24,6 +24,20 @@ const LIST_DEFAULT_DISPLAY_FIELDS: DisplayField[] = DEFAULT_DISPLAY_FIELDS.map( (f) => f.id ).filter((f) => f !== DisplayField.ADDED) +const RESTORABLE_LIST_VIEW_STATE_KEYS: (keyof ListViewState)[] = [ + 'title', + 'documents', + 'currentPage', + 'collectionSize', + 'sortField', + 'sortReverse', + 'filterRules', + 'selected', + 'pageSize', + 'displayMode', + 'displayFields', +] + /** * Captures the current state of the list view. */ @@ -112,6 +126,32 @@ export class DocumentListViewService { private displayFieldsInitialized: boolean = false + private restoreListViewState(savedState: unknown): ListViewState { + const newState = this.defaultListViewState() + + if ( + !savedState || + typeof savedState !== 'object' || + Array.isArray(savedState) + ) { + return newState + } + + const parsedState = savedState as Partial< + Record + > + const mutableState = newState as Record + + for (const key of RESTORABLE_LIST_VIEW_STATE_KEYS) { + const value = parsedState[key] + if (value != null) { + mutableState[key] = value + } + } + + return newState + } + get activeSavedViewId() { return this._activeSavedViewId } @@ -127,14 +167,7 @@ export class DocumentListViewService { if (documentListViewConfigJson) { try { let savedState: ListViewState = JSON.parse(documentListViewConfigJson) - // Remove null elements from the restored state - Object.keys(savedState).forEach((k) => { - if (savedState[k] == null) { - delete savedState[k] - } - }) - // only use restored state attributes instead of defaults if they are not null - let newState = Object.assign(this.defaultListViewState(), savedState) + let newState = this.restoreListViewState(savedState) this.listViewStates.set(null, newState) } catch (e) { localStorage.removeItem(DOCUMENT_LIST_SERVICE.CURRENT_VIEW_CONFIG) diff --git a/src-ui/src/app/services/settings.service.spec.ts b/src-ui/src/app/services/settings.service.spec.ts index 22ae3e504..5190bc549 100644 --- a/src-ui/src/app/services/settings.service.spec.ts +++ b/src-ui/src/app/services/settings.service.spec.ts @@ -166,6 +166,23 @@ describe('SettingsService', () => { expect(settingsService.get(SETTINGS_KEYS.THEME_COLOR)).toEqual('#9fbf2f') }) + it('ignores unsafe top-level keys from loaded settings', () => { + const req = httpTestingController.expectOne( + `${environment.apiBaseUrl}ui_settings/` + ) + const payload = JSON.parse( + JSON.stringify(ui_settings).replace( + '"settings":{', + '"settings":{"__proto__":{"polluted":"yes"},' + ) + ) + payload.settings.app_title = 'Safe Title' + req.flush(payload) + + expect(settingsService.get(SETTINGS_KEYS.APP_TITLE)).toEqual('Safe Title') + expect(({} as any).polluted).toBeUndefined() + }) + it('correctly allows updating settings of various types', () => { const req = httpTestingController.expectOne( `${environment.apiBaseUrl}ui_settings/` diff --git a/src-ui/src/app/services/settings.service.ts b/src-ui/src/app/services/settings.service.ts index f006cae12..8768a2058 100644 --- a/src-ui/src/app/services/settings.service.ts +++ b/src-ui/src/app/services/settings.service.ts @@ -276,6 +276,8 @@ const ISO_LANGUAGE_OPTION: LanguageOption = { dateInputFormat: 'yyyy-mm-dd', } +const UNSAFE_OBJECT_KEYS = new Set(['__proto__', 'prototype', 'constructor']) + @Injectable({ providedIn: 'root', }) @@ -291,7 +293,7 @@ export class SettingsService { protected baseUrl: string = environment.apiBaseUrl + 'ui_settings/' - private settings: Object = {} + private settings: Record = {} currentUser: User public settingsSaved: EventEmitter = new EventEmitter() @@ -320,6 +322,21 @@ export class SettingsService { this._renderer = rendererFactory.createRenderer(null, null) } + private isSafeObjectKey(key: string): boolean { + return !UNSAFE_OBJECT_KEYS.has(key) + } + + private assignSafeSettings(source: Record) { + if (!source || typeof source !== 'object' || Array.isArray(source)) { + return + } + + for (const key of Object.keys(source)) { + if (!this.isSafeObjectKey(key)) continue + this.settings[key] = source[key] + } + } + // this is called by the app initializer in app.module public initializeSettings(): Observable { return this.http.get(this.baseUrl).pipe( @@ -338,7 +355,7 @@ export class SettingsService { }) }), tap((uisettings) => { - Object.assign(this.settings, uisettings.settings) + this.assignSafeSettings(uisettings.settings) if (this.get(SETTINGS_KEYS.APP_TITLE)?.length) { environment.appTitle = this.get(SETTINGS_KEYS.APP_TITLE) } @@ -533,7 +550,11 @@ export class SettingsService { let settingObj = this.settings keys.forEach((keyPart, index) => { keyPart = keyPart.replace(/-/g, '_') - if (!settingObj.hasOwnProperty(keyPart)) return + if ( + !this.isSafeObjectKey(keyPart) || + !Object.prototype.hasOwnProperty.call(settingObj, keyPart) + ) + return if (index == keys.length - 1) value = settingObj[keyPart] else settingObj = settingObj[keyPart] }) @@ -579,7 +600,9 @@ export class SettingsService { const keys = key.replace('general-settings:', '').split(':') keys.forEach((keyPart, index) => { keyPart = keyPart.replace(/-/g, '_') - if (!settingObj.hasOwnProperty(keyPart)) settingObj[keyPart] = {} + if (!this.isSafeObjectKey(keyPart)) return + if (!Object.prototype.hasOwnProperty.call(settingObj, keyPart)) + settingObj[keyPart] = {} if (index == keys.length - 1) settingObj[keyPart] = value else settingObj = settingObj[keyPart] }) @@ -602,7 +625,10 @@ export class SettingsService { maybeMigrateSettings() { if ( - !this.settings.hasOwnProperty('documentListSize') && + !Object.prototype.hasOwnProperty.call( + this.settings, + 'documentListSize' + ) && localStorage.getItem(SETTINGS_KEYS.DOCUMENT_LIST_SIZE) ) { // lets migrate @@ -610,8 +636,7 @@ export class SettingsService { const errorMessage = $localize`Unable to migrate settings to the database, please try saving manually.` try { - for (const setting in SETTINGS_KEYS) { - const key = SETTINGS_KEYS[setting] + for (const key of Object.values(SETTINGS_KEYS)) { const value = localStorage.getItem(key) this.set(key, value) } diff --git a/src/documents/consumer.py b/src/documents/consumer.py index 809d6c647..6ae5914b7 100644 --- a/src/documents/consumer.py +++ b/src/documents/consumer.py @@ -1,6 +1,6 @@ import datetime -import hashlib import os +import shutil import tempfile from enum import StrEnum from pathlib import Path @@ -46,6 +46,7 @@ from documents.signals import document_consumption_started from documents.signals import document_updated from documents.signals.handlers import run_workflows from documents.templating.workflows import parse_w_workflow_placeholders +from documents.utils import compute_checksum from documents.utils import copy_basic_file_stats from documents.utils import copy_file_with_basic_stats from documents.utils import run_subprocess @@ -196,9 +197,7 @@ class ConsumerPlugin( version_doc = Document( root_document=root_doc_frozen, version_index=next_version_index + 1, - checksum=hashlib.md5( - file_for_checksum.read_bytes(), - ).hexdigest(), + checksum=compute_checksum(file_for_checksum), content=text or "", page_count=page_count, mime_type=mime_type, @@ -338,18 +337,15 @@ class ConsumerPlugin( Return the document object if it was successfully created. """ - tempdir = None + # Preflight has already run including progress update to 0% + self.log.info(f"Consuming {self.filename}") - try: - # Preflight has already run including progress update to 0% - self.log.info(f"Consuming {self.filename}") - - # For the actual work, copy the file into a tempdir - tempdir = tempfile.TemporaryDirectory( - prefix="paperless-ngx", - dir=settings.SCRATCH_DIR, - ) - self.working_copy = Path(tempdir.name) / Path(self.filename) + # For the actual work, copy the file into a tempdir + with tempfile.TemporaryDirectory( + prefix="paperless-ngx", + dir=settings.SCRATCH_DIR, + ) as tmpdir: + self.working_copy = Path(tmpdir) / Path(self.filename) copy_file_with_basic_stats(self.input_doc.original_file, self.working_copy) self.unmodified_original = None @@ -381,7 +377,7 @@ class ConsumerPlugin( self.log.debug(f"Detected mime type after qpdf: {mime_type}") # Save the original file for later self.unmodified_original = ( - Path(tempdir.name) / Path("uo") / Path(self.filename) + Path(tmpdir) / Path("uo") / Path(self.filename) ) self.unmodified_original.parent.mkdir(exist_ok=True) copy_file_with_basic_stats( @@ -400,7 +396,6 @@ class ConsumerPlugin( ) ) if not parser_class: - tempdir.cleanup() self._fail( ConsumerStatusShortMessage.UNSUPPORTED_TYPE, f"Unsupported mime type {mime_type}", @@ -415,281 +410,275 @@ class ConsumerPlugin( ) self.run_pre_consume_script() - except: - if tempdir: - tempdir.cleanup() - raise - # This doesn't parse the document yet, but gives us a parser. - with parser_class() as document_parser: - document_parser.configure( - ParserContext(mailrule_id=self.input_doc.mailrule_id), - ) - - self.log.debug(f"Parser: {document_parser.name} v{document_parser.version}") - - # Parse the document. This may take some time. - - text = None - date = None - thumbnail = None - archive_path = None - page_count = None - - try: - self._send_progress( - 20, - 100, - ProgressStatusOptions.WORKING, - ConsumerStatusShortMessage.PARSING_DOCUMENT, + # This doesn't parse the document yet, but gives us a parser. + with parser_class() as document_parser: + document_parser.configure( + ParserContext(mailrule_id=self.input_doc.mailrule_id), ) - self.log.debug(f"Parsing {self.filename}...") - document_parser.parse(self.working_copy, mime_type) - - self.log.debug(f"Generating thumbnail for {self.filename}...") - self._send_progress( - 70, - 100, - ProgressStatusOptions.WORKING, - ConsumerStatusShortMessage.GENERATING_THUMBNAIL, + self.log.debug( + f"Parser: {document_parser.name} v{document_parser.version}", ) - thumbnail = document_parser.get_thumbnail(self.working_copy, mime_type) - text = document_parser.get_text() - date = document_parser.get_date() - if date is None: + # Parse the document. This may take some time. + + text = None + date = None + thumbnail = None + archive_path = None + page_count = None + + try: self._send_progress( - 90, + 20, 100, ProgressStatusOptions.WORKING, - ConsumerStatusShortMessage.PARSE_DATE, + ConsumerStatusShortMessage.PARSING_DOCUMENT, ) - with get_date_parser() as date_parser: - date = next(date_parser.parse(self.filename, text), None) - archive_path = document_parser.get_archive_path() - page_count = document_parser.get_page_count( - self.working_copy, - mime_type, - ) + self.log.debug(f"Parsing {self.filename}...") - except ParseError as e: - if tempdir: - tempdir.cleanup() - self._fail( - str(e), - f"Error occurred while consuming document {self.filename}: {e}", - exc_info=True, - exception=e, - ) - except Exception as e: - if tempdir: - tempdir.cleanup() - self._fail( - str(e), - f"Unexpected error while consuming document {self.filename}: {e}", - exc_info=True, - exception=e, - ) + document_parser.parse(self.working_copy, mime_type) - # Prepare the document classifier. + self.log.debug(f"Generating thumbnail for {self.filename}...") + self._send_progress( + 70, + 100, + ProgressStatusOptions.WORKING, + ConsumerStatusShortMessage.GENERATING_THUMBNAIL, + ) + thumbnail = document_parser.get_thumbnail( + self.working_copy, + mime_type, + ) - # TODO: I don't really like to do this here, but this way we avoid - # reloading the classifier multiple times, since there are multiple - # post-consume hooks that all require the classifier. - - classifier = load_classifier() - - self._send_progress( - 95, - 100, - ProgressStatusOptions.WORKING, - ConsumerStatusShortMessage.SAVE_DOCUMENT, - ) - # now that everything is done, we can start to store the document - # in the system. This will be a transaction and reasonably fast. - try: - with transaction.atomic(): - # store the document. - if self.input_doc.root_document_id: - # If this is a new version of an existing document, we need - # to make sure we're not creating a new document, but updating - # the existing one. - root_doc = Document.objects.get( - pk=self.input_doc.root_document_id, + text = document_parser.get_text() + date = document_parser.get_date() + if date is None: + self._send_progress( + 90, + 100, + ProgressStatusOptions.WORKING, + ConsumerStatusShortMessage.PARSE_DATE, ) - original_document = self._create_version_from_root( - root_doc, - text=text, - page_count=page_count, - mime_type=mime_type, - ) - actor = None + with get_date_parser() as date_parser: + date = next(date_parser.parse(self.filename, text), None) + archive_path = document_parser.get_archive_path() + page_count = document_parser.get_page_count( + self.working_copy, + mime_type, + ) - # Save the new version, potentially creating an audit log entry for the version addition if enabled. - if ( - settings.AUDIT_LOG_ENABLED - and self.metadata.actor_id is not None - ): - actor = User.objects.filter( - pk=self.metadata.actor_id, - ).first() - if actor is not None: - from auditlog.context import ( # type: ignore[import-untyped] - set_actor, - ) + except ParseError as e: + self._fail( + str(e), + f"Error occurred while consuming document {self.filename}: {e}", + exc_info=True, + exception=e, + ) + except Exception as e: + self._fail( + str(e), + f"Unexpected error while consuming document {self.filename}: {e}", + exc_info=True, + exception=e, + ) - with set_actor(actor): + # Prepare the document classifier. + + # TODO: I don't really like to do this here, but this way we avoid + # reloading the classifier multiple times, since there are multiple + # post-consume hooks that all require the classifier. + + classifier = load_classifier() + + self._send_progress( + 95, + 100, + ProgressStatusOptions.WORKING, + ConsumerStatusShortMessage.SAVE_DOCUMENT, + ) + # now that everything is done, we can start to store the document + # in the system. This will be a transaction and reasonably fast. + try: + with transaction.atomic(): + # store the document. + if self.input_doc.root_document_id: + # If this is a new version of an existing document, we need + # to make sure we're not creating a new document, but updating + # the existing one. + root_doc = Document.objects.get( + pk=self.input_doc.root_document_id, + ) + original_document = self._create_version_from_root( + root_doc, + text=text, + page_count=page_count, + mime_type=mime_type, + ) + actor = None + + # Save the new version, potentially creating an audit log entry for the version addition if enabled. + if ( + settings.AUDIT_LOG_ENABLED + and self.metadata.actor_id is not None + ): + actor = User.objects.filter( + pk=self.metadata.actor_id, + ).first() + if actor is not None: + from auditlog.context import ( # type: ignore[import-untyped] + set_actor, + ) + + with set_actor(actor): + original_document.save() + else: original_document.save() else: original_document.save() + + # Create a log entry for the version addition, if enabled + if settings.AUDIT_LOG_ENABLED: + from auditlog.models import ( # type: ignore[import-untyped] + LogEntry, + ) + + LogEntry.objects.log_create( + instance=root_doc, + changes={ + "Version Added": ["None", original_document.id], + }, + action=LogEntry.Action.UPDATE, + actor=actor, + additional_data={ + "reason": "Version added", + "version_id": original_document.id, + }, + ) + document = original_document else: - original_document.save() - - # Create a log entry for the version addition, if enabled - if settings.AUDIT_LOG_ENABLED: - from auditlog.models import ( # type: ignore[import-untyped] - LogEntry, + document = self._store( + text=text, + date=date, + page_count=page_count, + mime_type=mime_type, ) - LogEntry.objects.log_create( - instance=root_doc, - changes={ - "Version Added": ["None", original_document.id], - }, - action=LogEntry.Action.UPDATE, - actor=actor, - additional_data={ - "reason": "Version added", - "version_id": original_document.id, - }, - ) - document = original_document - else: - document = self._store( - text=text, - date=date, - page_count=page_count, - mime_type=mime_type, - ) + # If we get here, it was successful. Proceed with post-consume + # hooks. If they fail, nothing will get changed. - # If we get here, it was successful. Proceed with post-consume - # hooks. If they fail, nothing will get changed. - - document_consumption_finished.send( - sender=self.__class__, - document=document, - logging_group=self.logging_group, - classifier=classifier, - original_file=self.unmodified_original - if self.unmodified_original - else self.working_copy, - ) - - # After everything is in the database, copy the files into - # place. If this fails, we'll also rollback the transaction. - with FileLock(settings.MEDIA_LOCK): - generated_filename = generate_unique_filename(document) - if ( - len(str(generated_filename)) - > Document.MAX_STORED_FILENAME_LENGTH - ): - self.log.warning( - "Generated source filename exceeds db path limit, falling back to default naming", - ) - generated_filename = generate_filename( - document, - use_format=False, - ) - document.filename = generated_filename - create_source_path_directory(document.source_path) - - self._write( - self.unmodified_original - if self.unmodified_original is not None + document_consumption_finished.send( + sender=self.__class__, + document=document, + logging_group=self.logging_group, + classifier=classifier, + original_file=self.unmodified_original + if self.unmodified_original else self.working_copy, - document.source_path, ) - self._write( - thumbnail, - document.thumbnail_path, - ) - - if archive_path and Path(archive_path).is_file(): - generated_archive_filename = generate_unique_filename( - document, - archive_filename=True, - ) + # After everything is in the database, copy the files into + # place. If this fails, we'll also rollback the transaction. + with FileLock(settings.MEDIA_LOCK): + generated_filename = generate_unique_filename(document) if ( - len(str(generated_archive_filename)) + len(str(generated_filename)) > Document.MAX_STORED_FILENAME_LENGTH ): self.log.warning( - "Generated archive filename exceeds db path limit, falling back to default naming", + "Generated source filename exceeds db path limit, falling back to default naming", ) - generated_archive_filename = generate_filename( + generated_filename = generate_filename( document, - archive_filename=True, use_format=False, ) - document.archive_filename = generated_archive_filename - create_source_path_directory(document.archive_path) + document.filename = generated_filename + create_source_path_directory(document.source_path) + self._write( - archive_path, - document.archive_path, + self.unmodified_original + if self.unmodified_original is not None + else self.working_copy, + document.source_path, ) - with Path(archive_path).open("rb") as f: - document.archive_checksum = hashlib.md5( - f.read(), - ).hexdigest() + self._write( + thumbnail, + document.thumbnail_path, + ) - # Don't save with the lock active. Saving will cause the file - # renaming logic to acquire the lock as well. - # This triggers things like file renaming - document.save() + if archive_path and Path(archive_path).is_file(): + generated_archive_filename = generate_unique_filename( + document, + archive_filename=True, + ) + if ( + len(str(generated_archive_filename)) + > Document.MAX_STORED_FILENAME_LENGTH + ): + self.log.warning( + "Generated archive filename exceeds db path limit, falling back to default naming", + ) + generated_archive_filename = generate_filename( + document, + archive_filename=True, + use_format=False, + ) + document.archive_filename = generated_archive_filename + create_source_path_directory(document.archive_path) + self._write( + archive_path, + document.archive_path, + ) - if document.root_document_id: - document_updated.send( - sender=self.__class__, - document=document.root_document, - ) + document.archive_checksum = compute_checksum( + document.archive_path, + ) - # Delete the file only if it was successfully consumed - self.log.debug( - f"Deleting original file {self.input_doc.original_file}", - ) - self.input_doc.original_file.unlink() - self.log.debug(f"Deleting working copy {self.working_copy}") - self.working_copy.unlink() - if self.unmodified_original is not None: # pragma: no cover + # Don't save with the lock active. Saving will cause the file + # renaming logic to acquire the lock as well. + # This triggers things like file renaming + document.save() + + if document.root_document_id: + document_updated.send( + sender=self.__class__, + document=document.root_document, + ) + + # Delete the file only if it was successfully consumed self.log.debug( - f"Deleting unmodified original file {self.unmodified_original}", + f"Deleting original file {self.input_doc.original_file}", ) - self.unmodified_original.unlink() + self.input_doc.original_file.unlink() + self.log.debug(f"Deleting working copy {self.working_copy}") + self.working_copy.unlink() + if self.unmodified_original is not None: # pragma: no cover + self.log.debug( + f"Deleting unmodified original file {self.unmodified_original}", + ) + self.unmodified_original.unlink() - # https://github.com/jonaswinkler/paperless-ng/discussions/1037 - shadow_file = ( - Path(self.input_doc.original_file).parent - / f"._{Path(self.input_doc.original_file).name}" + # https://github.com/jonaswinkler/paperless-ng/discussions/1037 + shadow_file = ( + Path(self.input_doc.original_file).parent + / f"._{Path(self.input_doc.original_file).name}" + ) + + if Path(shadow_file).is_file(): + self.log.debug(f"Deleting shadow file {shadow_file}") + Path(shadow_file).unlink() + + except Exception as e: + self._fail( + str(e), + f"The following error occurred while storing document " + f"{self.filename} after parsing: {e}", + exc_info=True, + exception=e, ) - if Path(shadow_file).is_file(): - self.log.debug(f"Deleting shadow file {shadow_file}") - Path(shadow_file).unlink() - - except Exception as e: - self._fail( - str(e), - f"The following error occurred while storing document " - f"{self.filename} after parsing: {e}", - exc_info=True, - exception=e, - ) - finally: - tempdir.cleanup() - self.run_post_consume_script(document) self.log.info(f"Document {document} consumption finished") @@ -785,7 +774,7 @@ class ConsumerPlugin( title=title[:127], content=text, mime_type=mime_type, - checksum=hashlib.md5(file_for_checksum.read_bytes()).hexdigest(), + checksum=compute_checksum(file_for_checksum), created=create_date, modified=create_date, page_count=page_count, @@ -833,7 +822,7 @@ class ConsumerPlugin( self.metadata.view_users is not None or self.metadata.view_groups is not None or self.metadata.change_users is not None - or self.metadata.change_users is not None + or self.metadata.change_groups is not None ): permissions = { "view": { @@ -866,7 +855,7 @@ class ConsumerPlugin( Path(source).open("rb") as read_file, Path(target).open("wb") as write_file, ): - write_file.write(read_file.read()) + shutil.copyfileobj(read_file, write_file) # Attempt to copy file's original stats, but it's ok if we can't try: @@ -902,10 +891,9 @@ class ConsumerPreflightPlugin( def pre_check_duplicate(self) -> None: """ - Using the MD5 of the file, check this exact file doesn't already exist + Using the SHA256 of the file, check this exact file doesn't already exist """ - with Path(self.input_doc.original_file).open("rb") as f: - checksum = hashlib.md5(f.read()).hexdigest() + checksum = compute_checksum(Path(self.input_doc.original_file)) existing_doc = Document.global_objects.filter( Q(checksum=checksum) | Q(archive_checksum=checksum), ) diff --git a/src/documents/management/commands/document_exporter.py b/src/documents/management/commands/document_exporter.py index b8ccca0ab..cd1cee6b3 100644 --- a/src/documents/management/commands/document_exporter.py +++ b/src/documents/management/commands/document_exporter.py @@ -56,6 +56,7 @@ from documents.models import WorkflowTrigger from documents.settings import EXPORTER_ARCHIVE_NAME from documents.settings import EXPORTER_FILE_NAME from documents.settings import EXPORTER_THUMBNAIL_NAME +from documents.utils import compute_checksum from documents.utils import copy_file_with_basic_stats from paperless import version from paperless.models import ApplicationConfiguration @@ -693,7 +694,7 @@ class Command(CryptMixin, PaperlessCommand): source_stat = source.stat() target_stat = target.stat() if self.compare_checksums and source_checksum: - target_checksum = hashlib.md5(target.read_bytes()).hexdigest() + target_checksum = compute_checksum(target) perform_copy = target_checksum != source_checksum elif ( source_stat.st_mtime != target_stat.st_mtime diff --git a/src/documents/migrations/0016_sha256_checksums.py b/src/documents/migrations/0016_sha256_checksums.py new file mode 100644 index 000000000..de1c6c596 --- /dev/null +++ b/src/documents/migrations/0016_sha256_checksums.py @@ -0,0 +1,130 @@ +import hashlib +import logging +from pathlib import Path + +from django.conf import settings +from django.db import migrations +from django.db import models + +logger = logging.getLogger("paperless.migrations") + +_CHUNK_SIZE = 65536 # 64 KiB — avoids loading entire files into memory +_BATCH_SIZE = 500 # documents per bulk_update call +_PROGRESS_INTERVAL = 500 # log a progress line every N documents + + +def _sha256(path: Path) -> str: + h = hashlib.sha256() + with path.open("rb") as fh: + while chunk := fh.read(_CHUNK_SIZE): + h.update(chunk) + return h.hexdigest() + + +def recompute_checksums(apps, schema_editor): + """Recompute all document checksums from MD5 to SHA256.""" + Document = apps.get_model("documents", "Document") + + total = Document.objects.count() + if total == 0: + return + + logger.info("Recomputing SHA-256 checksums for %d document(s)...", total) + + batch: list = [] + processed = 0 + + for doc in Document.objects.only( + "pk", + "filename", + "checksum", + "archive_filename", + "archive_checksum", + ).iterator(chunk_size=_BATCH_SIZE): + updated_fields: list[str] = [] + + # Reconstruct source path the same way Document.source_path does + fname = str(doc.filename) if doc.filename else f"{doc.pk:07}.pdf" + source_path = (settings.ORIGINALS_DIR / Path(fname)).resolve() + + if source_path.exists(): + doc.checksum = _sha256(source_path) + updated_fields.append("checksum") + else: + logger.warning( + "Document %s: original file %s not found, checksum not updated.", + doc.pk, + source_path, + ) + + # Mirror Document.has_archive_version: archive_filename is not None + if doc.archive_filename is not None: + archive_path = ( + settings.ARCHIVE_DIR / Path(str(doc.archive_filename)) + ).resolve() + if archive_path.exists(): + doc.archive_checksum = _sha256(archive_path) + updated_fields.append("archive_checksum") + else: + logger.warning( + "Document %s: archive file %s not found, checksum not updated.", + doc.pk, + archive_path, + ) + + if updated_fields: + batch.append(doc) + + processed += 1 + + if len(batch) >= _BATCH_SIZE: + Document.objects.bulk_update(batch, ["checksum", "archive_checksum"]) + batch.clear() + + if processed % _PROGRESS_INTERVAL == 0: + logger.info( + "SHA-256 checksum progress: %d/%d (%d%%)", + processed, + total, + processed * 100 // total, + ) + + if batch: + Document.objects.bulk_update(batch, ["checksum", "archive_checksum"]) + + logger.info( + "SHA-256 checksum recomputation complete: %d document(s) processed.", + total, + ) + + +class Migration(migrations.Migration): + dependencies = [ + ("documents", "0015_document_version_index_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="document", + name="checksum", + field=models.CharField( + editable=False, + help_text="The checksum of the original document.", + max_length=64, + verbose_name="checksum", + ), + ), + migrations.AlterField( + model_name="document", + name="archive_checksum", + field=models.CharField( + blank=True, + editable=False, + help_text="The checksum of the archived document.", + max_length=64, + null=True, + verbose_name="archive checksum", + ), + ), + migrations.RunPython(recompute_checksums, migrations.RunPython.noop), + ] diff --git a/src/documents/models.py b/src/documents/models.py index 6147ac001..08f995ff6 100644 --- a/src/documents/models.py +++ b/src/documents/models.py @@ -216,14 +216,14 @@ class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager- checksum = models.CharField( _("checksum"), - max_length=32, + max_length=64, editable=False, help_text=_("The checksum of the original document."), ) archive_checksum = models.CharField( _("archive checksum"), - max_length=32, + max_length=64, editable=False, blank=True, null=True, diff --git a/src/documents/sanity_checker.py b/src/documents/sanity_checker.py index ef0d37e7d..b53ed8cfb 100644 --- a/src/documents/sanity_checker.py +++ b/src/documents/sanity_checker.py @@ -11,7 +11,6 @@ is an identity function that adds no overhead. from __future__ import annotations -import hashlib import logging import uuid from collections import defaultdict @@ -30,6 +29,7 @@ from django.utils import timezone from documents.models import Document from documents.models import PaperlessTask +from documents.utils import compute_checksum from paperless.config import GeneralConfig logger = logging.getLogger("paperless.sanity_checker") @@ -218,7 +218,7 @@ def _check_original( present_files.discard(source_path) try: - checksum = hashlib.md5(source_path.read_bytes()).hexdigest() + checksum = compute_checksum(source_path) except OSError as e: messages.error(doc.pk, f"Cannot read original file of document: {e}") else: @@ -255,7 +255,7 @@ def _check_archive( present_files.discard(archive_path) try: - checksum = hashlib.md5(archive_path.read_bytes()).hexdigest() + checksum = compute_checksum(archive_path) except OSError as e: messages.error( doc.pk, diff --git a/src/documents/tasks.py b/src/documents/tasks.py index 751990c62..adf1f016c 100644 --- a/src/documents/tasks.py +++ b/src/documents/tasks.py @@ -1,5 +1,4 @@ import datetime -import hashlib import logging import shutil import uuid @@ -53,14 +52,15 @@ from documents.models import Tag from documents.models import WorkflowRun from documents.models import WorkflowTrigger from documents.plugins.base import ConsumeTaskPlugin -from documents.plugins.base import ProgressManager from documents.plugins.base import StopConsumeTaskError +from documents.plugins.helpers import ProgressManager from documents.plugins.helpers import ProgressStatusOptions from documents.sanity_checker import SanityCheckFailedException from documents.signals import document_updated from documents.signals.handlers import cleanup_document_deletion from documents.signals.handlers import run_workflows from documents.signals.handlers import send_websocket_document_updated +from documents.utils import compute_checksum from documents.workflows.utils import get_workflows_for_trigger from paperless.config import AIConfig from paperless.parsers import ParserContext @@ -328,8 +328,7 @@ def update_document_content_maybe_archive_file(document_id) -> None: with transaction.atomic(): oldDocument = Document.objects.get(pk=document.pk) if parser.get_archive_path(): - with Path(parser.get_archive_path()).open("rb") as f: - checksum = hashlib.md5(f.read()).hexdigest() + checksum = compute_checksum(parser.get_archive_path()) # I'm going to save first so that in case the file move # fails, the database is rolled back. # We also don't use save() since that triggers the filehandling @@ -533,13 +532,13 @@ def check_scheduled_workflows() -> None: id__in=matched_ids, ) - if documents.count() > 0: + if documents.exists(): documents = prefilter_documents_by_workflowtrigger( documents, trigger, ) - if documents.count() > 0: + if documents.exists(): logger.debug( f"Found {documents.count()} documents for trigger {trigger}", ) diff --git a/src/documents/templates/account/login.html b/src/documents/templates/account/login.html index 767c21d7c..c66fa6e44 100644 --- a/src/documents/templates/account/login.html +++ b/src/documents/templates/account/login.html @@ -9,7 +9,7 @@

{% translate "Please sign in." %} {% if ACCOUNT_ALLOW_SIGNUPS %} -
{% blocktrans %}Don't have an account yet? Sign up{% endblocktrans %} +
{% translate "Don't have an account yet?" %} {% translate "Sign up" %} {% endif %}

{% endblock form_top_content %} @@ -25,12 +25,12 @@ {% translate "Username" as i18n_username %} {% translate "Password" as i18n_password %}
- - + +
- - + +
diff --git a/src/documents/templates/account/password_reset.html b/src/documents/templates/account/password_reset.html index 019babd52..64629c76d 100644 --- a/src/documents/templates/account/password_reset.html +++ b/src/documents/templates/account/password_reset.html @@ -14,8 +14,8 @@ {% endif %} {% translate "Email" as i18n_email %}
- - + +
diff --git a/src/documents/templates/account/password_reset_from_key.html b/src/documents/templates/account/password_reset_from_key.html index a9b2c3406..bc06ee85c 100644 --- a/src/documents/templates/account/password_reset_from_key.html +++ b/src/documents/templates/account/password_reset_from_key.html @@ -17,12 +17,12 @@ {% translate "New Password" as i18n_new_password1 %} {% translate "Confirm Password" as i18n_new_password2 %}
- - + +
- - + +
diff --git a/src/documents/templates/account/password_reset_from_key_done.html b/src/documents/templates/account/password_reset_from_key_done.html index 420901a6c..4909ea9be 100644 --- a/src/documents/templates/account/password_reset_from_key_done.html +++ b/src/documents/templates/account/password_reset_from_key_done.html @@ -11,5 +11,5 @@ {% block form_content %} {% url 'account_login' as login_url %} -

{% blocktranslate %}Your new password has been set. You can now log in{% endblocktranslate %}.

+

{% translate "Your new password has been set. You can now" %} {% translate "log in" %}.

{% endblock form_content %} diff --git a/src/documents/templates/account/signup.html b/src/documents/templates/account/signup.html index 9ab79d3df..000cd6366 100644 --- a/src/documents/templates/account/signup.html +++ b/src/documents/templates/account/signup.html @@ -8,7 +8,7 @@ {% block form_top_content %} {% if not FIRST_INSTALL %}

- {% blocktrans %}Already have an account? Sign in{% endblocktrans %} + {% translate "Already have an account?" %} {% translate "Sign in" %}

{% endif %} {% endblock form_top_content %} @@ -16,7 +16,7 @@ {% block form_content %} {% if FIRST_INSTALL %}

- {% blocktrans %}Note: This is the first user account for this installation and will be granted superuser privileges.{% endblocktrans %} + {% translate "Note: This is the first user account for this installation and will be granted superuser privileges." %}

{% endif %} {% translate "Username" as i18n_username %} @@ -24,20 +24,20 @@ {% translate "Password" as i18n_password1 %} {% translate "Password (again)" as i18n_password2 %}
- - + +
- - + +
- - + +
- - + +
diff --git a/src/documents/templates/mfa/authenticate.html b/src/documents/templates/mfa/authenticate.html index cfc29da2b..e86565189 100644 --- a/src/documents/templates/mfa/authenticate.html +++ b/src/documents/templates/mfa/authenticate.html @@ -9,15 +9,15 @@ {% block form_top_content %}

- {% blocktranslate %}Your account is protected by two-factor authentication. Please enter an authenticator code:{% endblocktranslate %} + {% translate "Your account is protected by two-factor authentication. Please enter an authenticator code:" %}

{% endblock form_top_content %} {% block form_content %} {% translate "Code" as i18n_code %}
- - + +
diff --git a/src/documents/templates/socialaccount/authentication_error.html b/src/documents/templates/socialaccount/authentication_error.html index 6450a1678..8891f924e 100644 --- a/src/documents/templates/socialaccount/authentication_error.html +++ b/src/documents/templates/socialaccount/authentication_error.html @@ -7,5 +7,5 @@ {% block form_content %} {% url 'account_login' as login_url %} -

{% blocktranslate %}An error occurred while attempting to login via your social network account. Back to the login page{% endblocktranslate %}

+

{% translate "An error occurred while attempting to login via your social network account. Back to the" %} {% translate "login page" %}

{% endblock form_content %} diff --git a/src/documents/templates/socialaccount/login.html b/src/documents/templates/socialaccount/login.html index 70c71ced2..4e2e595a0 100644 --- a/src/documents/templates/socialaccount/login.html +++ b/src/documents/templates/socialaccount/login.html @@ -7,7 +7,9 @@ {% block form_content %}

- {% blocktrans with provider.name as provider %}You are about to connect a new third-party account from {{ provider }}.{% endblocktrans %} + {% filter force_escape %} + {% blocktrans with provider=provider.name %}You are about to connect a new third-party account from {{ provider }}.{% endblocktrans %} + {% endfilter %}

diff --git a/src/documents/templates/socialaccount/signup.html b/src/documents/templates/socialaccount/signup.html index 01f70a524..cf84ea935 100644 --- a/src/documents/templates/socialaccount/signup.html +++ b/src/documents/templates/socialaccount/signup.html @@ -7,18 +7,20 @@ {% block form_content %}

- {% blocktrans with provider_name=account.get_provider.name %}You are about to use your {{provider_name}} account to login.{% endblocktrans %} - {% blocktrans %}As a final step, please complete the following form:{% endblocktrans %} + {% filter force_escape %} + {% blocktrans with provider_name=account.get_provider.name %}You are about to use your {{ provider_name }} account to login.{% endblocktrans %} + {% endfilter %} + {% translate "As a final step, please complete the following form:" %}

{% translate "Username" as i18n_username %} {% translate "Email (optional)" as i18n_email %}
- - + +
- - + +
{% if redirect_field_value %} diff --git a/src/documents/tests/conftest.py b/src/documents/tests/conftest.py index a33771fd1..7e75b9194 100644 --- a/src/documents/tests/conftest.py +++ b/src/documents/tests/conftest.py @@ -82,8 +82,8 @@ def sample_doc( return DocumentFactory( title="test", - checksum="42995833e01aea9b3edee44bbfdd7ce1", - archive_checksum="62acb0bcbfbcaa62ca6ad3668e4e404b", + checksum="1093cf6e32adbd16b06969df09215d42c4a3a8938cc18b39455953f08d1ff2ab", + archive_checksum="706124ecde3c31616992fa979caed17a726b1c9ccdba70e82a4ff796cea97ccf", content="test content", pk=1, filename="0000001.pdf", diff --git a/src/documents/tests/factories.py b/src/documents/tests/factories.py index d1d88587c..b0fd68428 100644 --- a/src/documents/tests/factories.py +++ b/src/documents/tests/factories.py @@ -60,7 +60,7 @@ class DocumentFactory(DjangoModelFactory): model = Document title = factory.Faker("sentence", nb_words=4) - checksum = factory.Faker("md5") + checksum = factory.Faker("sha256") content = factory.Faker("paragraph") correspondent = None document_type = None diff --git a/src/documents/tests/test_consumer.py b/src/documents/tests/test_consumer.py index df4c7d9c4..165b27e0b 100644 --- a/src/documents/tests/test_consumer.py +++ b/src/documents/tests/test_consumer.py @@ -261,8 +261,14 @@ class TestConsumer( self.assertIsFile(document.archive_path) - self.assertEqual(document.checksum, "42995833e01aea9b3edee44bbfdd7ce1") - self.assertEqual(document.archive_checksum, "62acb0bcbfbcaa62ca6ad3668e4e404b") + self.assertEqual( + document.checksum, + "1093cf6e32adbd16b06969df09215d42c4a3a8938cc18b39455953f08d1ff2ab", + ) + self.assertEqual( + document.archive_checksum, + "706124ecde3c31616992fa979caed17a726b1c9ccdba70e82a4ff796cea97ccf", + ) self.assertIsNotFile(filename) diff --git a/src/documents/tests/test_management_exporter.py b/src/documents/tests/test_management_exporter.py index 9307bab45..fb9effa0e 100644 --- a/src/documents/tests/test_management_exporter.py +++ b/src/documents/tests/test_management_exporter.py @@ -63,8 +63,8 @@ class TestExportImport( self.d1 = Document.objects.create( content="Content", - checksum="42995833e01aea9b3edee44bbfdd7ce1", - archive_checksum="62acb0bcbfbcaa62ca6ad3668e4e404b", + checksum="1093cf6e32adbd16b06969df09215d42c4a3a8938cc18b39455953f08d1ff2ab", + archive_checksum="706124ecde3c31616992fa979caed17a726b1c9ccdba70e82a4ff796cea97ccf", title="wow1", filename="0000001.pdf", mime_type="application/pdf", @@ -72,21 +72,21 @@ class TestExportImport( ) self.d2 = Document.objects.create( content="Content", - checksum="9c9691e51741c1f4f41a20896af31770", + checksum="550d1bae0f746d4f7c6be07054eb20cc2f11988a58ef64ceae45e98f85e92a5b", title="wow2", filename="0000002.pdf", mime_type="application/pdf", ) self.d3 = Document.objects.create( content="Content", - checksum="d38d7ed02e988e072caf924e0f3fcb76", + checksum="f1ba6b7ff8548214a75adec228f5468a14fe187f445bc0b9485cbf1c35b15915", title="wow2", filename="0000003.pdf", mime_type="application/pdf", ) self.d4 = Document.objects.create( content="Content", - checksum="82186aaa94f0b98697d704b90fd1c072", + checksum="a81b16b6b313cfd7e60eb7b12598d1343b58622b4030cfa19a2724a02e98db1b", title="wow_dec", filename="0000004.pdf", mime_type="application/pdf", @@ -239,7 +239,7 @@ class TestExportImport( ) with Path(fname).open("rb") as f: - checksum = hashlib.md5(f.read()).hexdigest() + checksum = hashlib.sha256(f.read()).hexdigest() self.assertEqual(checksum, element["fields"]["checksum"]) # Generated field "content_length" should not be exported, @@ -253,7 +253,7 @@ class TestExportImport( self.assertIsFile(fname) with Path(fname).open("rb") as f: - checksum = hashlib.md5(f.read()).hexdigest() + checksum = hashlib.sha256(f.read()).hexdigest() self.assertEqual(checksum, element["fields"]["archive_checksum"]) elif element["model"] == "documents.note": diff --git a/src/documents/tests/test_management_importer.py b/src/documents/tests/test_management_importer.py index ef20cf895..a890718a1 100644 --- a/src/documents/tests/test_management_importer.py +++ b/src/documents/tests/test_management_importer.py @@ -277,8 +277,8 @@ class TestCommandImport( Document.objects.create( content="Content", - checksum="42995833e01aea9b3edee44bbfdd7ce1", - archive_checksum="62acb0bcbfbcaa62ca6ad3668e4e404b", + checksum="1093cf6e32adbd16b06969df09215d42c4a3a8938cc18b39455953f08d1ff2ab", + archive_checksum="706124ecde3c31616992fa979caed17a726b1c9ccdba70e82a4ff796cea97ccf", title="wow1", filename="0000001.pdf", mime_type="application/pdf", diff --git a/src/documents/tests/test_migration_sha256_checksums.py b/src/documents/tests/test_migration_sha256_checksums.py new file mode 100644 index 000000000..4a53b724c --- /dev/null +++ b/src/documents/tests/test_migration_sha256_checksums.py @@ -0,0 +1,132 @@ +import hashlib +import shutil +import tempfile +from pathlib import Path + +from django.conf import settings +from django.db import connection +from django.test import override_settings + +from documents.tests.utils import TestMigrations + + +def _sha256(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +class TestSha256ChecksumDataMigration(TestMigrations): + """recompute_checksums correctly updates document checksums from MD5 to SHA256.""" + + migrate_from = "0015_document_version_index_and_more" + migrate_to = "0016_sha256_checksums" + reset_sequences = True + + ORIGINAL_CONTENT = b"original file content for sha256 migration test" + ARCHIVE_CONTENT = b"archive file content for sha256 migration test" + + def setUpBeforeMigration(self, apps) -> None: + self._originals_dir = Path(tempfile.mkdtemp()) + self._archive_dir = Path(tempfile.mkdtemp()) + self._settings_override = override_settings( + ORIGINALS_DIR=self._originals_dir, + ARCHIVE_DIR=self._archive_dir, + ) + self._settings_override.enable() + Document = apps.get_model("documents", "Document") + + # doc1: original file present, no archive + (settings.ORIGINALS_DIR / "doc1.txt").write_bytes(self.ORIGINAL_CONTENT) + self.doc1_id = Document.objects.create( + title="Doc 1", + mime_type="text/plain", + filename="doc1.txt", + checksum="a" * 32, + ).pk + + # doc2: original and archive both present + (settings.ORIGINALS_DIR / "doc2.txt").write_bytes(self.ORIGINAL_CONTENT) + (settings.ARCHIVE_DIR / "doc2.pdf").write_bytes(self.ARCHIVE_CONTENT) + self.doc2_id = Document.objects.create( + title="Doc 2", + mime_type="text/plain", + filename="doc2.txt", + checksum="b" * 32, + archive_filename="doc2.pdf", + archive_checksum="c" * 32, + ).pk + + # doc3: original file missing — checksum must stay unchanged + self.doc3_id = Document.objects.create( + title="Doc 3", + mime_type="text/plain", + filename="missing_original.txt", + checksum="d" * 32, + ).pk + + # doc4: original present, archive_filename set but archive file missing + (settings.ORIGINALS_DIR / "doc4.txt").write_bytes(self.ORIGINAL_CONTENT) + self.doc4_id = Document.objects.create( + title="Doc 4", + mime_type="text/plain", + filename="doc4.txt", + checksum="e" * 32, + archive_filename="missing_archive.pdf", + archive_checksum="f" * 32, + ).pk + + # doc5: original present, archive_filename is None — archive_checksum must stay null + (settings.ORIGINALS_DIR / "doc5.txt").write_bytes(self.ORIGINAL_CONTENT) + self.doc5_id = Document.objects.create( + title="Doc 5", + mime_type="text/plain", + filename="doc5.txt", + checksum="0" * 32, + archive_filename=None, + archive_checksum=None, + ).pk + + def _fixture_teardown(self) -> None: + super()._fixture_teardown() + # Django's SQLite backend returns [] from sequence_reset_sql(), so + # reset_sequences=True flushes rows but never clears sqlite_sequence. + # Explicitly delete the entry so subsequent tests start from pk=1. + if connection.vendor == "sqlite": + with connection.cursor() as cursor: + cursor.execute( + "DELETE FROM sqlite_sequence WHERE name='documents_document'", + ) + + def tearDown(self) -> None: + super().tearDown() + self._settings_override.disable() + shutil.rmtree(self._originals_dir, ignore_errors=True) + shutil.rmtree(self._archive_dir, ignore_errors=True) + + def test_original_checksum_updated_to_sha256_when_file_exists(self) -> None: + Document = self.apps.get_model("documents", "Document") + doc = Document.objects.get(pk=self.doc1_id) + self.assertEqual(doc.checksum, _sha256(self.ORIGINAL_CONTENT)) + + def test_both_checksums_updated_when_original_and_archive_exist(self) -> None: + Document = self.apps.get_model("documents", "Document") + doc = Document.objects.get(pk=self.doc2_id) + self.assertEqual(doc.checksum, _sha256(self.ORIGINAL_CONTENT)) + self.assertEqual(doc.archive_checksum, _sha256(self.ARCHIVE_CONTENT)) + + def test_checksum_unchanged_when_original_file_missing(self) -> None: + Document = self.apps.get_model("documents", "Document") + doc = Document.objects.get(pk=self.doc3_id) + self.assertEqual(doc.checksum, "d" * 32) + + def test_archive_checksum_unchanged_when_archive_file_missing(self) -> None: + Document = self.apps.get_model("documents", "Document") + doc = Document.objects.get(pk=self.doc4_id) + # Original was updated (file exists) + self.assertEqual(doc.checksum, _sha256(self.ORIGINAL_CONTENT)) + # Archive was not updated (file missing) + self.assertEqual(doc.archive_checksum, "f" * 32) + + def test_archive_checksum_stays_null_when_no_archive_filename(self) -> None: + Document = self.apps.get_model("documents", "Document") + doc = Document.objects.get(pk=self.doc5_id) + self.assertIsNone(doc.archive_checksum) diff --git a/src/documents/utils.py b/src/documents/utils.py index 2b6a60749..975185a5f 100644 --- a/src/documents/utils.py +++ b/src/documents/utils.py @@ -1,3 +1,4 @@ +import hashlib import logging import shutil from os import utime @@ -128,3 +129,28 @@ def get_boolean(boolstr: str) -> bool: Return a boolean value from a string representation. """ return bool(boolstr.lower() in ("yes", "y", "1", "t", "true")) + + +def compute_checksum(path: Path, chunk_size: int = 65536) -> str: + """ + Compute the SHA-256 checksum of a file. + + Reads the file in chunks to avoid loading the entire file into memory. + + Args: + path (Path): Path to the file to hash. + chunk_size (int, optional): Number of bytes to read per chunk. + Defaults to 65536. + + Returns: + str: Hexadecimal SHA-256 digest of the file contents. + + Raises: + FileNotFoundError: If the file does not exist. + OSError: If the file cannot be read. + """ + h = hashlib.sha256() + with path.open("rb") as f: + while chunk := f.read(chunk_size): + h.update(chunk) + return h.hexdigest() diff --git a/src/documents/views.py b/src/documents/views.py index 0716ce66d..600acf078 100644 --- a/src/documents/views.py +++ b/src/documents/views.py @@ -2027,7 +2027,10 @@ class UnifiedSearchViewSet(DocumentViewSet): except NotFound: raise except PermissionDenied as e: - return HttpResponseForbidden(str(e.detail)) + invalid_more_like_id_message = _("Invalid more_like_id") + if str(e.detail) == str(invalid_more_like_id_message): + return HttpResponseForbidden(invalid_more_like_id_message) + return HttpResponseForbidden(_("Insufficient permissions.")) except Exception as e: logger.warning(f"An error occurred listing search results: {e!s}") return HttpResponseBadRequest( diff --git a/src/documents/workflows/actions.py b/src/documents/workflows/actions.py index 46d9f5c4a..9744048e5 100644 --- a/src/documents/workflows/actions.py +++ b/src/documents/workflows/actions.py @@ -282,7 +282,7 @@ def execute_password_removal_action( passwords = action.passwords if not passwords: logger.warning( - "Password removal action %s has no passwords configured", + "Workflow action %s has no configured unlock values", action.pk, extra={"group": logging_group}, ) @@ -321,22 +321,23 @@ def execute_password_removal_action( user=document.owner, ) logger.info( - "Removed password from document %s using workflow action %s", + "Unlocked document %s using workflow action %s", document.pk, action.pk, extra={"group": logging_group}, ) return - except ValueError as e: + except ValueError: logger.warning( - "Password removal failed for document %s with supplied password: %s", + "Workflow action %s could not unlock document %s with one configured value", + action.pk, document.pk, - e, extra={"group": logging_group}, ) logger.error( - "Password removal failed for document %s after trying all provided passwords", + "Workflow action %s could not unlock document %s with any configured value", + action.pk, document.pk, extra={"group": logging_group}, ) diff --git a/src/locale/en_US/LC_MESSAGES/django.po b/src/locale/en_US/LC_MESSAGES/django.po index ceb207dfa..36caa615f 100644 --- a/src/locale/en_US/LC_MESSAGES/django.po +++ b/src/locale/en_US/LC_MESSAGES/django.po @@ -2,7 +2,7 @@ msgid "" msgstr "" "Project-Id-Version: paperless-ngx\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-03-22 13:54+0000\n" +"POT-Creation-Date: 2026-03-26 14:37+0000\n" "PO-Revision-Date: 2022-02-17 04:17\n" "Last-Translator: \n" "Language-Team: English\n" @@ -1301,7 +1301,7 @@ msgstr "" #: documents/serialisers.py:463 documents/serialisers.py:815 #: documents/serialisers.py:2501 documents/views.py:1990 -#: paperless_mail/serialisers.py:143 +#: documents/views.py:2033 paperless_mail/serialisers.py:143 msgid "Insufficient permissions." msgstr "" @@ -1341,7 +1341,7 @@ msgstr "" msgid "Duplicate document identifiers are not allowed." msgstr "" -#: documents/serialisers.py:2587 documents/views.py:3596 +#: documents/serialisers.py:2587 documents/views.py:3599 #, python-format msgid "Documents not found: %(ids)s" msgstr "" @@ -1383,13 +1383,18 @@ msgid "Please sign in." msgstr "" #: documents/templates/account/login.html:12 -#, python-format -msgid "Don't have an account yet? Sign up" +msgid "Don't have an account yet?" +msgstr "" + +#: documents/templates/account/login.html:12 +#: documents/templates/account/signup.html:43 +#: documents/templates/socialaccount/signup.html:29 +msgid "Sign up" msgstr "" #: documents/templates/account/login.html:25 #: documents/templates/account/signup.html:22 -#: documents/templates/socialaccount/signup.html:13 +#: documents/templates/socialaccount/signup.html:15 msgid "Username" msgstr "" @@ -1399,6 +1404,7 @@ msgid "Password" msgstr "" #: documents/templates/account/login.html:36 +#: documents/templates/account/signup.html:11 #: documents/templates/mfa/authenticate.html:23 msgid "Sign in" msgstr "" @@ -1477,10 +1483,11 @@ msgid "Password reset complete." msgstr "" #: documents/templates/account/password_reset_from_key_done.html:14 -#, python-format -msgid "" -"Your new password has been set. You can now log " -"in" +msgid "Your new password has been set. You can now" +msgstr "" + +#: documents/templates/account/password_reset_from_key_done.html:14 +msgid "log in" msgstr "" #: documents/templates/account/signup.html:5 @@ -1488,8 +1495,7 @@ msgid "Paperless-ngx sign up" msgstr "" #: documents/templates/account/signup.html:11 -#, python-format -msgid "Already have an account? Sign in" +msgid "Already have an account?" msgstr "" #: documents/templates/account/signup.html:19 @@ -1499,7 +1505,7 @@ msgid "" msgstr "" #: documents/templates/account/signup.html:23 -#: documents/templates/socialaccount/signup.html:14 +#: documents/templates/socialaccount/signup.html:16 msgid "Email (optional)" msgstr "" @@ -1507,11 +1513,6 @@ msgstr "" msgid "Password (again)" msgstr "" -#: documents/templates/account/signup.html:43 -#: documents/templates/socialaccount/signup.html:27 -msgid "Sign up" -msgstr "" - #: documents/templates/index.html:61 msgid "Paperless-ngx is loading..." msgstr "" @@ -1556,18 +1557,21 @@ msgid "Paperless-ngx social account sign in" msgstr "" #: documents/templates/socialaccount/authentication_error.html:10 -#, python-format msgid "" "An error occurred while attempting to login via your social network account. " -"Back to the login page" +"Back to the" msgstr "" -#: documents/templates/socialaccount/login.html:10 +#: documents/templates/socialaccount/authentication_error.html:10 +msgid "login page" +msgstr "" + +#: documents/templates/socialaccount/login.html:11 #, python-format msgid "You are about to connect a new third-party account from %(provider)s." msgstr "" -#: documents/templates/socialaccount/login.html:13 +#: documents/templates/socialaccount/login.html:15 msgid "Continue" msgstr "" @@ -1575,12 +1579,12 @@ msgstr "" msgid "Paperless-ngx social account sign up" msgstr "" -#: documents/templates/socialaccount/signup.html:10 +#: documents/templates/socialaccount/signup.html:11 #, python-format msgid "You are about to use your %(provider_name)s account to login." msgstr "" -#: documents/templates/socialaccount/signup.html:11 +#: documents/templates/socialaccount/signup.html:13 msgid "As a final step, please complete the following form:" msgstr "" @@ -1605,24 +1609,24 @@ msgstr "" msgid "Unable to parse URI {value}" msgstr "" -#: documents/views.py:1983 +#: documents/views.py:1983 documents/views.py:2030 msgid "Invalid more_like_id" msgstr "" -#: documents/views.py:3608 +#: documents/views.py:3611 #, python-format msgid "Insufficient permissions to share document %(id)s." msgstr "" -#: documents/views.py:3651 +#: documents/views.py:3654 msgid "Bundle is already being processed." msgstr "" -#: documents/views.py:3708 +#: documents/views.py:3711 msgid "The share link bundle is still being prepared. Please try again later." msgstr "" -#: documents/views.py:3718 +#: documents/views.py:3721 msgid "The share link bundle is unavailable." msgstr "" diff --git a/src/paperless_mail/templates/email_msg_template.html b/src/paperless_mail/templates/email_msg_template.html index a22666957..9df67e997 100644 --- a/src/paperless_mail/templates/email_msg_template.html +++ b/src/paperless_mail/templates/email_msg_template.html @@ -1,4 +1,3 @@ -{% autoescape off %} @@ -13,36 +12,34 @@
-
{{ date }}
+
{{ date|safe }}
{{ from_label }}
-
{{ from }}
+
{{ from|safe }}
{{ subject_label }}
-
{{ subject }}
+
{{ subject|safe }}
{{ to_label }}
-
{{ to }}
+
{{ to|safe }}
{{ cc_label }}
-
{{ cc }}
+
{{ cc|safe }}
{{ bcc_label }}
-
{{ bcc }}
+
{{ bcc|safe }}
{{ attachments_label }}
-
{{ attachments }}
+
{{ attachments|safe }}
-
{{ content }}
+
{{ content|safe }}
- -{% endautoescape %} diff --git a/src/paperless_mail/tests/test_mail_oauth.py b/src/paperless_mail/tests/test_mail_oauth.py index 651094673..d94e7947b 100644 --- a/src/paperless_mail/tests/test_mail_oauth.py +++ b/src/paperless_mail/tests/test_mail_oauth.py @@ -191,7 +191,10 @@ class TestMailOAuth( ).exists(), ) - self.assertIn("Error getting access token: test_error", cm.output[0]) + self.assertIn( + "Error getting access token from OAuth provider", + cm.output[0], + ) def test_oauth_callback_view_insufficient_permissions(self) -> None: """ diff --git a/src/paperless_mail/views.py b/src/paperless_mail/views.py index b8ac2c485..2593797f3 100644 --- a/src/paperless_mail/views.py +++ b/src/paperless_mail/views.py @@ -138,13 +138,16 @@ class MailAccountViewSet(ModelViewSet, PassUserMixin): existing_account.refresh_from_db() account.password = existing_account.password else: + logger.error( + "Mail account connectivity test failed: Unable to refresh oauth token", + ) raise MailError("Unable to refresh oauth token") mailbox_login(M, account) return Response({"success": True}) - except MailError as e: + except MailError: logger.error( - f"Mail account {account} test failed: {e}", + "Mail account connectivity test failed", ) return HttpResponseBadRequest("Unable to connect to server") @@ -218,7 +221,7 @@ class OauthCallbackView(GenericAPIView): if code is None: logger.error( - f"Invalid oauth callback request, code: {code}, scope: {scope}", + "Invalid oauth callback request: missing code", ) return HttpResponseBadRequest("Invalid request, see logs for more detail") @@ -229,7 +232,7 @@ class OauthCallbackView(GenericAPIView): state = request.query_params.get("state", "") if not oauth_manager.validate_state(state): logger.error( - f"Invalid oauth callback request received state: {state}, expected: {oauth_manager.state}", + "Invalid oauth callback request: state validation failed", ) return HttpResponseBadRequest("Invalid request, see logs for more detail") @@ -276,8 +279,8 @@ class OauthCallbackView(GenericAPIView): return HttpResponseRedirect( f"{oauth_manager.oauth_redirect_url}?oauth_success=1&account_id={account.pk}", ) - except GetAccessTokenError as e: - logger.error(f"Error getting access token: {e}") + except GetAccessTokenError: + logger.error("Error getting access token from OAuth provider") return HttpResponseRedirect( f"{oauth_manager.oauth_redirect_url}?oauth_success=0", ) diff --git a/uv.lock b/uv.lock index aa763f617..53cbeb689 100644 --- a/uv.lock +++ b/uv.lock @@ -361,31 +361,31 @@ wheels = [ [[package]] name = "cbor2" -version = "5.8.0" +version = "5.9.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d9/8e/8b4fdde28e42ffcd741a37f4ffa9fb59cd4fe01625b544dfcfd9ccb54f01/cbor2-5.8.0.tar.gz", hash = "sha256:b19c35fcae9688ac01ef75bad5db27300c2537eb4ee00ed07e05d8456a0d4931", size = 107825, upload-time = "2025-12-30T18:44:22.455Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/cb/09939728be094d155b5d4ac262e39877875f5f7e36eea66beb359f647bd0/cbor2-5.9.0.tar.gz", hash = "sha256:85c7a46279ac8f226e1059275221e6b3d0e370d2bb6bd0500f9780781615bcea", size = 111231, upload-time = "2026-03-22T15:56:50.638Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/88/4b/623435ef9b98e86b6956a41863d39ff4fe4d67983948b5834f55499681dd/cbor2-5.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:18ac191640093e6c7fbcb174c006ffec4106c3d8ab788e70272c1c4d933cbe11", size = 69875, upload-time = "2025-12-30T18:43:35.888Z" }, - { url = "https://files.pythonhosted.org/packages/58/17/f664201080b2a7d0f57c16c8e9e5922013b92f202e294863ec7e75b7ff7f/cbor2-5.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fddee9103a17d7bed5753f0c7fc6663faa506eb953e50d8287804eccf7b048e6", size = 268316, upload-time = "2025-12-30T18:43:37.161Z" }, - { url = "https://files.pythonhosted.org/packages/d0/e1/072745b4ff01afe9df2cd627f8fc51a1acedb5d3d1253765625d2929db91/cbor2-5.8.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8d2ea26fad620aba5e88d7541be8b10c5034a55db9a23809b7cb49f36803f05b", size = 258874, upload-time = "2025-12-30T18:43:38.878Z" }, - { url = "https://files.pythonhosted.org/packages/a7/10/61c262b886d22b62c56e8aac6d10fa06d0953c997879ab882a31a624952b/cbor2-5.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:de68b4b310b072b082d317adc4c5e6910173a6d9455412e6183d72c778d1f54c", size = 261971, upload-time = "2025-12-30T18:43:40.401Z" }, - { url = "https://files.pythonhosted.org/packages/7e/42/b7862f5e64364b10ad120ea53e87ec7e891fb268cb99c572348e647cf7e9/cbor2-5.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:418d2cf0e03e90160fa1474c05a40fe228bbb4a92d1628bdbbd13a48527cb34d", size = 254151, upload-time = "2025-12-30T18:43:41.938Z" }, - { url = "https://files.pythonhosted.org/packages/2f/4f/3a16e3e8fd7e5fd86751a4f1aad218a8d19a96e75ec3989c3e95a8fe1d8f/cbor2-5.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4b3f91fa699a5ce22470e973601c62dd9d55dc3ca20ee446516ac075fcab27c9", size = 70270, upload-time = "2025-12-30T18:43:46.005Z" }, - { url = "https://files.pythonhosted.org/packages/38/81/0d0cf0796fe8081492a61c45278f03def21a929535a492dd97c8438f5dbe/cbor2-5.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:518c118a5e00001854adb51f3164e647aa99b6a9877d2a733a28cb5c0a4d6857", size = 286242, upload-time = "2025-12-30T18:43:47.026Z" }, - { url = "https://files.pythonhosted.org/packages/7b/a9/fdab6c10190cfb8d639e01f2b168f2406fc847a2a6bc00e7de78c3381d0a/cbor2-5.8.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cff2a1999e49cd51c23d1b6786a012127fd8f722c5946e82bd7ab3eb307443f3", size = 285412, upload-time = "2025-12-30T18:43:48.563Z" }, - { url = "https://files.pythonhosted.org/packages/31/59/746a8e630996217a3afd523f583fcf7e3d16640d63f9a03f0f4e4f74b5b1/cbor2-5.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4c4492160212374973cdc14e46f0565f2462721ef922b40f7ea11e7d613dfb2a", size = 278041, upload-time = "2025-12-30T18:43:49.92Z" }, - { url = "https://files.pythonhosted.org/packages/0f/a3/f3bbeb6dedd45c6e0cddd627ea790dea295eaf82c83f0e2159b733365ebd/cbor2-5.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:546c7c7c4c6bcdc54a59242e0e82cea8f332b17b4465ae628718fef1fce401ca", size = 278185, upload-time = "2025-12-30T18:43:51.192Z" }, - { url = "https://files.pythonhosted.org/packages/a6/0d/5a3f20bafaefeb2c1903d961416f051c0950f0d09e7297a3aa6941596b29/cbor2-5.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6d8d104480845e2f28c6165b4c961bbe58d08cb5638f368375cfcae051c28015", size = 70332, upload-time = "2025-12-30T18:43:54.694Z" }, - { url = "https://files.pythonhosted.org/packages/57/66/177a3f089e69db69c987453ab4934086408c3338551e4984734597be9f80/cbor2-5.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:43efee947e5ab67d406d6e0dc61b5dee9d2f5e89ae176f90677a3741a20ca2e7", size = 285985, upload-time = "2025-12-30T18:43:55.733Z" }, - { url = "https://files.pythonhosted.org/packages/b7/8e/9e17b8e4ed80a2ce97e2dfa5915c169dbb31599409ddb830f514b57f96cc/cbor2-5.8.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:be7ae582f50be539e09c134966d0fd63723fc4789b8dff1f6c2e3f24ae3eaf32", size = 285173, upload-time = "2025-12-30T18:43:57.321Z" }, - { url = "https://files.pythonhosted.org/packages/cc/33/9f92e107d78f88ac22723ac15d0259d220ba98c1d855e51796317f4c4114/cbor2-5.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:50f5c709561a71ea7970b4cd2bf9eda4eccacc0aac212577080fdfe64183e7f5", size = 278395, upload-time = "2025-12-30T18:43:58.497Z" }, - { url = "https://files.pythonhosted.org/packages/2f/3f/46b80050a4a35ce5cf7903693864a9fdea7213567dc8faa6e25cb375c182/cbor2-5.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a6790ecc73aa93e76d2d9076fc42bf91a9e69f2295e5fa702e776dbe986465bd", size = 278330, upload-time = "2025-12-30T18:43:59.656Z" }, - { url = "https://files.pythonhosted.org/packages/4b/0c/0654233d7543ac8a50f4785f172430ddc97538ba418eb305d6e529d1a120/cbor2-5.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ad72381477133046ce217617d839ea4e9454f8b77d9a6351b229e214102daeb7", size = 70710, upload-time = "2025-12-30T18:44:03.209Z" }, - { url = "https://files.pythonhosted.org/packages/84/62/4671d24e557d7f5a74a01b422c538925140c0495e57decde7e566f91d029/cbor2-5.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6da25190fad3434ce99876b11d4ca6b8828df6ca232cf7344cd14ae1166fb718", size = 285005, upload-time = "2025-12-30T18:44:05.109Z" }, - { url = "https://files.pythonhosted.org/packages/87/85/0c67d763a08e848c9a80d7e4723ba497cce676f41bc7ca1828ae90a0a872/cbor2-5.8.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c13919e3a24c5a6d286551fa288848a4cedc3e507c58a722ccd134e461217d99", size = 282435, upload-time = "2025-12-30T18:44:06.465Z" }, - { url = "https://files.pythonhosted.org/packages/b2/01/0650972b4dbfbebcfbe37cbba7fc3cd9019a8da6397ab3446e07175e342b/cbor2-5.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f8c40d32e5972047a777f9bf730870828f3cf1c43b3eb96fd0429c57a1d3b9e6", size = 277493, upload-time = "2025-12-30T18:44:07.609Z" }, - { url = "https://files.pythonhosted.org/packages/b3/6c/7704a4f32adc7f10f3b41ec067f500a4458f7606397af5e4cf2d368fd288/cbor2-5.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7627894bc0b3d5d0807f31e3107e11b996205470c4429dc2bb4ef8bfe7f64e1e", size = 276085, upload-time = "2025-12-30T18:44:09.021Z" }, - { url = "https://files.pythonhosted.org/packages/d6/4f/101071f880b4da05771128c0b89f41e334cff044dee05fb013c8f4be661c/cbor2-5.8.0-py3-none-any.whl", hash = "sha256:3727d80f539567b03a7aa11890e57798c67092c38df9e6c23abb059e0f65069c", size = 24374, upload-time = "2025-12-30T18:44:21.476Z" }, + { url = "https://files.pythonhosted.org/packages/43/aa/317c7118b8dda4c9563125c1a12c70c5b41e36677964a49c72b1aac061ec/cbor2-5.9.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0485d3372fc832c5e16d4eb45fa1a20fc53e806e6c29a1d2b0d3e176cedd52b9", size = 70578, upload-time = "2026-03-22T15:56:03.835Z" }, + { url = "https://files.pythonhosted.org/packages/31/43/fe29b1f897770011a5e7497f4523c2712282ee4a6cbf775ea6383fb7afb9/cbor2-5.9.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a9d6e4e0f988b0e766509a8071975a8ee99f930e14a524620bf38083106158d2", size = 268738, upload-time = "2026-03-22T15:56:05.222Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1a/e494568f3d8aafbcdfe361df44c3bcf5cdab5183e25ea08e3d3f9fcf4075/cbor2-5.9.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5326336f633cc89dfe543c78829c16c3a6449c2c03277d1ddba99086c3323363", size = 262571, upload-time = "2026-03-22T15:56:06.411Z" }, + { url = "https://files.pythonhosted.org/packages/42/2e/92acd6f87382fd44a34d9d7e85cc45372e6ba664040b72d1d9df648b25d0/cbor2-5.9.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5e702b02d42a5ace45425b595ffe70fe35aebaf9a3cdfdc2c758b6189c744422", size = 262356, upload-time = "2026-03-22T15:56:08.236Z" }, + { url = "https://files.pythonhosted.org/packages/3f/68/52c039a28688baeeb78b0be7483855e6c66ea05884a937444deede0c87b8/cbor2-5.9.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:2372d357d403e7912f104ff085950ffc82a5854d6d717f1ca1ce16a40a0ef5a7", size = 257604, upload-time = "2026-03-22T15:56:09.835Z" }, + { url = "https://files.pythonhosted.org/packages/ee/39/72d8a5a4b06565561ec28f4fcb41aff7bb77f51705c01f00b8254a2aca4f/cbor2-5.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1f223dffb1bcdd2764665f04c1152943d9daa4bc124a576cd8dee1cad4264313", size = 71223, upload-time = "2026-03-22T15:56:13.68Z" }, + { url = "https://files.pythonhosted.org/packages/09/fd/7ddf3d3153b54c69c3be77172b8d9aa3a9d74f62a7fbde614d53eaeed9a4/cbor2-5.9.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae6c706ac1d85a0b3cb3395308fd0c4d55e3202b4760773675957e93cdff45fc", size = 287865, upload-time = "2026-03-22T15:56:14.813Z" }, + { url = "https://files.pythonhosted.org/packages/db/9d/7ede2cc42f9bb4260492e7d29d2aab781eacbbcfb09d983de1e695077199/cbor2-5.9.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4cd43d8fc374b31643b2830910f28177a606a7bc84975a62675dd3f2e320fc7b", size = 288246, upload-time = "2026-03-22T15:56:16.113Z" }, + { url = "https://files.pythonhosted.org/packages/ce/9d/588ebc7c5bc5843f609b05fe07be8575c7dec987735b0bbc908ac9c1264a/cbor2-5.9.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4aa07b392cc3d76fb31c08a46a226b58c320d1c172ff3073e864409ced7bc50f", size = 280214, upload-time = "2026-03-22T15:56:17.519Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a1/6fc8f4b15c6a27e7fbb7966c30c2b4b18c274a3221fa2f5e6235502d34bc/cbor2-5.9.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:971d425b3a23b75953d8853d5f9911bdeefa09d759ee3b5e6b07b5ff3cbd9073", size = 282162, upload-time = "2026-03-22T15:56:18.975Z" }, + { url = "https://files.pythonhosted.org/packages/81/c5/4901e21a8afe9448fd947b11e8f383903207cd6dd0800e5f5a386838de5b/cbor2-5.9.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fbb06f34aa645b4deca66643bba3d400d20c15312d1fe88d429be60c1ab50f27", size = 71284, upload-time = "2026-03-22T15:56:22.836Z" }, + { url = "https://files.pythonhosted.org/packages/1b/10/df643a381aebc3f05486de4813662bc58accb640fc3275cb276a75e89694/cbor2-5.9.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac684fe195c39821fca70d18afbf748f728aefbfbf88456018d299e559b8cae0", size = 287682, upload-time = "2026-03-22T15:56:24.024Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0c/8aa6b766059ae4a0ca1ec3ff96fe3823a69a7be880dba2e249f7fbe2700b/cbor2-5.9.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2a54fbb32cb828c214f7f333a707e4aec61182e7efdc06ea5d9596d3ecee624a", size = 288009, upload-time = "2026-03-22T15:56:25.305Z" }, + { url = "https://files.pythonhosted.org/packages/74/07/6236bc25c183a9cf7e8062e5dddf9eae9b0b14ebf14a58a69fe5a1e872c6/cbor2-5.9.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4753a6d1bc71054d9179557bc65740860f185095ccb401d46637fff028a5b3ec", size = 280437, upload-time = "2026-03-22T15:56:26.479Z" }, + { url = "https://files.pythonhosted.org/packages/4e/0a/84328d23c3c68874ac6497edb9b1900579a1028efa54734df3f1762bbc15/cbor2-5.9.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:380e534482b843e43442b87d8777a7bf9bed20cb7526f89b780c3400f617304b", size = 282247, upload-time = "2026-03-22T15:56:28.644Z" }, + { url = "https://files.pythonhosted.org/packages/08/7d/9ccc36d10ef96e6038e48046ebe1ce35a1e7814da0e1e204d09e6ef09b8d/cbor2-5.9.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23606d31ba1368bd1b6602e3020ee88fe9523ca80e8630faf6b2fc904fd84560", size = 71500, upload-time = "2026-03-22T15:56:31.876Z" }, + { url = "https://files.pythonhosted.org/packages/70/e1/a6cca2cc72e13f00030c6a649f57ae703eb2c620806ab70c40db8eab33fa/cbor2-5.9.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0322296b9d52f55880e300ba8ba09ecf644303b99b51138bbb1c0fb644fa7c3e", size = 286953, upload-time = "2026-03-22T15:56:33.292Z" }, + { url = "https://files.pythonhosted.org/packages/08/3c/24cd5ef488a957d90e016f200a3aad820e4c2f85edd61c9fe4523007a1ee/cbor2-5.9.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:422817286c1d0ce947fb2f7eca9212b39bddd7231e8b452e2d2cc52f15332dba", size = 285454, upload-time = "2026-03-22T15:56:34.703Z" }, + { url = "https://files.pythonhosted.org/packages/a4/35/dca96818494c0ba47cdd73e8d809b27fa91f8fa0ce32a068a09237687454/cbor2-5.9.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9a4907e0c3035bb8836116854ed8e56d8aef23909d601fa59706320897ec2551", size = 279441, upload-time = "2026-03-22T15:56:35.888Z" }, + { url = "https://files.pythonhosted.org/packages/a4/44/d3362378b16e53cf7e535a3f5aed8476e2109068154e24e31981ef5bde9e/cbor2-5.9.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:fb7afe77f8d269e42d7c4b515c6fd14f1ccc0625379fb6829b269f493d16eddd", size = 279673, upload-time = "2026-03-22T15:56:37.08Z" }, + { url = "https://files.pythonhosted.org/packages/42/ff/b83492b096fbef26e9cb62c1a4bf2d3cef579ea7b33138c6c37c4ae66f67/cbor2-5.9.0-py3-none-any.whl", hash = "sha256:27695cbd70c90b8de5c4a284642c2836449b14e2c2e07e3ffe0744cb7669a01b", size = 24627, upload-time = "2026-03-22T15:56:48.847Z" }, ] [[package]] @@ -4211,7 +4211,7 @@ wheels = [ [[package]] name = "requests" -version = "2.32.5" +version = "2.33.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, @@ -4219,9 +4219,9 @@ dependencies = [ { name = "idna", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "urllib3", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +sdist = { url = "https://files.pythonhosted.org/packages/34/64/8860370b167a9721e8956ae116825caff829224fbca0ca6e7bf8ddef8430/requests-2.33.0.tar.gz", hash = "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652", size = 134232, upload-time = "2026-03-25T15:10:41.586Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, + { url = "https://files.pythonhosted.org/packages/56/5d/c814546c2333ceea4ba42262d8c4d55763003e767fa169adc693bd524478/requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b", size = 65017, upload-time = "2026-03-25T15:10:40.382Z" }, ] [[package]]