From 7c9ab8c0b6ac7d6910ac8e4f98ca736a2e0e9ee7 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Thu, 14 Sep 2023 13:32:43 -0700 Subject: [PATCH] Feature: Share links (#3996) * Implement share links Basic implementation of share links Make certain share link fields not editable, automatically grant permissions on migrate Updated styling, error messages from expired / deleted links frontend code linting, reversable sharelink migration testing coverage Update translation strings No links message * Consolidate file response methods * improvements to share links on mobile devices * Refactor share links file_version * Add docs for share links * Apply suggestions from code review * When filtering share links, use the timezone aware now() * Removes extra call to setup directories for usage in testing * FIx copied badge display on some browsers * Move copy to ngx-clipboard library --------- Co-authored-by: Trenton H <797416+stumpylog@users.noreply.github.com> --- docs/usage.md | 13 + src-ui/messages.xlf | 360 +++++++---- src-ui/package-lock.json | 29 + src-ui/package.json | 1 + src-ui/setup-jest.ts | 1 + src-ui/src/app/app.module.ts | 2 + ...mail-account-edit-dialog.component.spec.ts | 1 - .../share-links-dropdown.component.html | 61 ++ .../share-links-dropdown.component.scss | 14 + .../share-links-dropdown.component.spec.ts | 195 ++++++ .../share-links-dropdown.component.ts | 149 +++++ .../common/toasts/toasts.component.spec.ts | 9 +- .../common/toasts/toasts.component.ts | 8 +- .../document-detail.component.html | 38 +- .../document-detail.component.spec.ts | 2 + .../document-detail.component.ts | 1 + src-ui/src/app/data/paperless-share-link.ts | 18 + .../app/services/permissions.service.spec.ts | 4 + .../src/app/services/permissions.service.ts | 1 + .../rest/abstract-paperless-service.ts | 2 +- .../services/rest/share-link.service.spec.ts | 42 ++ .../app/services/rest/share-link.service.ts | 36 ++ src/documents/admin.py | 8 + src/documents/filters.py | 10 + src/documents/migrations/1038_sharelink.py | 126 ++++ src/documents/models.py | 60 ++ src/documents/serialisers.py | 19 + .../templates/registration/logged_out.html | 2 +- .../templates/registration/login.html | 16 +- src/documents/tests/test_api.py | 115 ++++ .../tests/test_management_exporter.py | 16 +- src/documents/tests/test_views.py | 92 ++- src/documents/views.py | 140 +++- src/locale/en_US/LC_MESSAGES/django.po | 597 ++++++++++-------- src/paperless/urls.py | 6 +- 35 files changed, 1740 insertions(+), 454 deletions(-) create mode 100644 src-ui/src/app/components/common/share-links-dropdown/share-links-dropdown.component.html create mode 100644 src-ui/src/app/components/common/share-links-dropdown/share-links-dropdown.component.scss create mode 100644 src-ui/src/app/components/common/share-links-dropdown/share-links-dropdown.component.spec.ts create mode 100644 src-ui/src/app/components/common/share-links-dropdown/share-links-dropdown.component.ts create mode 100644 src-ui/src/app/data/paperless-share-link.ts create mode 100644 src-ui/src/app/services/rest/share-link.service.spec.ts create mode 100644 src-ui/src/app/services/rest/share-link.service.ts create mode 100644 src/documents/migrations/1038_sharelink.py diff --git a/docs/usage.md b/docs/usage.md index 9f561ee01..e7e1bede4 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -545,3 +545,16 @@ Paperless-ngx consists of the following components: - Optional: A database server. Paperless supports PostgreSQL, MariaDB and SQLite for storing its data. + +## Share Links + +Paperless-ngx added the abiltiy to create shareable links to files in version 2.0. You can find the button for this on the document detail screen. + +- Share links do not require a user to login and thus link directly to a file. +- Links are unique and are of the form `{paperless-url}/share/{randomly-generated-slug}`. +- Links can optionally have an expiration time set. +- After a link expires or is deleted users will be redirected to the regular paperless-ngx login. + +!!! tip + + If your paperless-ngx instance is behind a reverse-proxy you may want to create an exception to bypass any authentication layers that are part of your setup in order to make links truly publicly-accessible. Of course, do so with caution. diff --git a/src-ui/messages.xlf b/src-ui/messages.xlf index e7c04174b..cc23dd26c 100644 --- a/src-ui/messages.xlf +++ b/src-ui/messages.xlf @@ -319,7 +319,7 @@ src/app/components/document-detail/document-detail.component.html - 55 + 65 @@ -1073,7 +1073,7 @@ src/app/components/document-detail/document-detail.component.html - 198 + 208 src/app/components/document-list/save-view-config-dialog/save-view-config-dialog.component.html @@ -1142,7 +1142,7 @@ src/app/components/document-detail/document-detail.component.html - 182 + 192 src/app/components/document-list/bulk-editor/bulk-editor.component.html @@ -1549,6 +1549,10 @@ src/app/components/common/permissions-select/permissions-select.component.html 9 + + src/app/components/common/share-links-dropdown/share-links-dropdown.component.html + 33 + src/app/components/document-detail/document-detail.component.html 11 @@ -2240,6 +2244,135 @@ 20 + + Share Links + + src/app/components/common/share-links-dropdown/share-links-dropdown.component.html + 6 + + + src/app/components/common/share-links-dropdown/share-links-dropdown.component.ts + 25 + + + + No existing links + + src/app/components/common/share-links-dropdown/share-links-dropdown.component.html + 10,12 + + + + Copy + + src/app/components/common/share-links-dropdown/share-links-dropdown.component.html + 23 + + + + Share + + src/app/components/common/share-links-dropdown/share-links-dropdown.component.html + 28 + + + + Copied! + + src/app/components/common/share-links-dropdown/share-links-dropdown.component.html + 36 + + + + Share archive version + + src/app/components/common/share-links-dropdown/share-links-dropdown.component.html + 42 + + + + Create + + src/app/components/common/share-links-dropdown/share-links-dropdown.component.html + 55 + + + src/app/components/manage/management-list/management-list.component.html + 2 + + + src/app/components/manage/management-list/management-list.component.html + 2 + + + src/app/components/manage/management-list/management-list.component.html + 2 + + + src/app/components/manage/management-list/management-list.component.html + 2 + + + + 1 day + + src/app/components/common/share-links-dropdown/share-links-dropdown.component.ts + 18 + + + src/app/components/common/share-links-dropdown/share-links-dropdown.component.ts + 85 + + + + 7 days + + src/app/components/common/share-links-dropdown/share-links-dropdown.component.ts + 19 + + + + 30 days + + src/app/components/common/share-links-dropdown/share-links-dropdown.component.ts + 20 + + + + Never + + src/app/components/common/share-links-dropdown/share-links-dropdown.component.ts + 21 + + + + Error retrieving links + + src/app/components/common/share-links-dropdown/share-links-dropdown.component.ts + 69 + + + + days + + src/app/components/common/share-links-dropdown/share-links-dropdown.component.ts + 85 + + + + Error deleting link + + src/app/components/common/share-links-dropdown/share-links-dropdown.component.ts + 112 + + + + Error creating link + + src/app/components/common/share-links-dropdown/share-links-dropdown.component.ts + 140 + + Status @@ -2310,7 +2443,7 @@ src/app/components/document-detail/document-detail.component.html - 75 + 85 src/app/components/document-list/document-list.component.html @@ -2340,7 +2473,7 @@ src/app/components/document-detail/document-detail.component.html - 19 + 18 src/app/components/document-list/bulk-editor/bulk-editor.component.html @@ -2538,14 +2671,65 @@ Download original src/app/components/document-detail/document-detail.component.html - 25 + 24 + + + + Actions + + src/app/components/document-detail/document-detail.component.html + 34 + + + src/app/components/document-list/bulk-editor/bulk-editor.component.html + 86 + + + src/app/components/manage/management-list/management-list.component.html + 23 + + + src/app/components/manage/management-list/management-list.component.html + 23 + + + src/app/components/manage/management-list/management-list.component.html + 23 + + + src/app/components/manage/management-list/management-list.component.html + 23 + + + src/app/components/manage/settings/settings.component.html + 221 + + + src/app/components/manage/settings/settings.component.html + 259 + + + src/app/components/manage/settings/settings.component.html + 296 + + + src/app/components/manage/settings/settings.component.html + 347 + + + src/app/components/manage/settings/settings.component.html + 382 + + + src/app/components/manage/tasks/tasks.component.html + 44 Redo OCR src/app/components/document-detail/document-detail.component.html - 34 + 40 src/app/components/document-list/bulk-editor/bulk-editor.component.html @@ -2556,7 +2740,7 @@ More like this src/app/components/document-detail/document-detail.component.html - 40 + 46 src/app/components/document-list/document-card-large/document-card-large.component.html @@ -2567,7 +2751,7 @@ Close src/app/components/document-detail/document-detail.component.html - 43 + 53 src/app/guards/dirty-saved-view.guard.ts @@ -2578,35 +2762,35 @@ Previous src/app/components/document-detail/document-detail.component.html - 50 + 60 Details src/app/components/document-detail/document-detail.component.html - 72 + 82 Archive serial number src/app/components/document-detail/document-detail.component.html - 76 + 86 Date created src/app/components/document-detail/document-detail.component.html - 77 + 87 Correspondent src/app/components/document-detail/document-detail.component.html - 79 + 89 src/app/components/document-list/bulk-editor/bulk-editor.component.html @@ -2629,7 +2813,7 @@ Document type src/app/components/document-detail/document-detail.component.html - 81 + 91 src/app/components/document-list/bulk-editor/bulk-editor.component.html @@ -2652,7 +2836,7 @@ Storage path src/app/components/document-detail/document-detail.component.html - 83 + 93 src/app/components/document-list/bulk-editor/bulk-editor.component.html @@ -2671,21 +2855,21 @@ Default src/app/components/document-detail/document-detail.component.html - 84 + 94 Content src/app/components/document-detail/document-detail.component.html - 91 + 101 Metadata src/app/components/document-detail/document-detail.component.html - 100 + 110 src/app/components/document-detail/metadata-collapse/metadata-collapse.component.ts @@ -2696,173 +2880,173 @@ Date modified src/app/components/document-detail/document-detail.component.html - 106 + 116 Date added src/app/components/document-detail/document-detail.component.html - 110 + 120 Media filename src/app/components/document-detail/document-detail.component.html - 114 + 124 Original filename src/app/components/document-detail/document-detail.component.html - 118 + 128 Original MD5 checksum src/app/components/document-detail/document-detail.component.html - 122 + 132 Original file size src/app/components/document-detail/document-detail.component.html - 126 + 136 Original mime type src/app/components/document-detail/document-detail.component.html - 130 + 140 Archive MD5 checksum src/app/components/document-detail/document-detail.component.html - 134 + 144 Archive file size src/app/components/document-detail/document-detail.component.html - 138 + 148 Original document metadata src/app/components/document-detail/document-detail.component.html - 144 + 154 Archived document metadata src/app/components/document-detail/document-detail.component.html - 145 + 155 Preview src/app/components/document-detail/document-detail.component.html - 151 + 161 Enter Password src/app/components/document-detail/document-detail.component.html - 167 + 177 src/app/components/document-detail/document-detail.component.html - 218 + 228 Notes src/app/components/document-detail/document-detail.component.html - 175,176 + 185,186 Discard src/app/components/document-detail/document-detail.component.html - 194 + 204 Save & next src/app/components/document-detail/document-detail.component.html - 196 + 206 Save & close src/app/components/document-detail/document-detail.component.html - 197 + 207 An error occurred loading content: src/app/components/document-detail/document-detail.component.ts - 252,254 + 253,255 Error retrieving metadata src/app/components/document-detail/document-detail.component.ts - 397 + 398 Error retrieving suggestions. src/app/components/document-detail/document-detail.component.ts - 418 + 419 Document saved successfully. src/app/components/document-detail/document-detail.component.ts - 531 + 532 src/app/components/document-detail/document-detail.component.ts - 539 + 540 Error saving document src/app/components/document-detail/document-detail.component.ts - 543 + 544 src/app/components/document-detail/document-detail.component.ts - 584 + 585 Confirm delete src/app/components/document-detail/document-detail.component.ts - 610 + 611 src/app/components/manage/management-list/management-list.component.ts @@ -2873,35 +3057,35 @@ Do you really want to delete document ""? src/app/components/document-detail/document-detail.component.ts - 611 + 612 The files for this document will be deleted permanently. This operation cannot be undone. src/app/components/document-detail/document-detail.component.ts - 612 + 613 Delete document src/app/components/document-detail/document-detail.component.ts - 614 + 615 Error deleting document src/app/components/document-detail/document-detail.component.ts - 633 + 634 Redo OCR confirm src/app/components/document-detail/document-detail.component.ts - 653 + 654 src/app/components/document-list/bulk-editor/bulk-editor.component.ts @@ -2912,14 +3096,14 @@ This operation will permanently redo OCR for this document. src/app/components/document-detail/document-detail.component.ts - 654 + 655 This operation cannot be undone. src/app/components/document-detail/document-detail.component.ts - 655 + 656 src/app/components/document-list/bulk-editor/bulk-editor.component.ts @@ -2950,7 +3134,7 @@ Proceed src/app/components/document-detail/document-detail.component.ts - 657 + 658 src/app/components/document-list/bulk-editor/bulk-editor.component.ts @@ -2977,14 +3161,14 @@ Redo OCR operation will begin in the background. Close and re-open or reload this document after the operation has completed to see new content. src/app/components/document-detail/document-detail.component.ts - 665 + 666 Error executing operation src/app/components/document-detail/document-detail.component.ts - 676 + 677 @@ -3045,53 +3229,6 @@ 52 - - Actions - - src/app/components/document-list/bulk-editor/bulk-editor.component.html - 86 - - - src/app/components/manage/management-list/management-list.component.html - 23 - - - src/app/components/manage/management-list/management-list.component.html - 23 - - - src/app/components/manage/management-list/management-list.component.html - 23 - - - src/app/components/manage/management-list/management-list.component.html - 23 - - - src/app/components/manage/settings/settings.component.html - 221 - - - src/app/components/manage/settings/settings.component.html - 259 - - - src/app/components/manage/settings/settings.component.html - 296 - - - src/app/components/manage/settings/settings.component.html - 347 - - - src/app/components/manage/settings/settings.component.html - 382 - - - src/app/components/manage/tasks/tasks.component.html - 44 - - Include: @@ -3945,25 +4082,6 @@ 44 - - Create - - src/app/components/manage/management-list/management-list.component.html - 2 - - - src/app/components/manage/management-list/management-list.component.html - 2 - - - src/app/components/manage/management-list/management-list.component.html - 2 - - - src/app/components/manage/management-list/management-list.component.html - 2 - - Filter by: diff --git a/src-ui/package-lock.json b/src-ui/package-lock.json index d6c048877..7c3daeb98 100644 --- a/src-ui/package-lock.json +++ b/src-ui/package-lock.json @@ -24,6 +24,7 @@ "file-saver": "^2.0.5", "mime-names": "^1.0.0", "ng2-pdf-viewer": "^10.0.0", + "ngx-clipboard": "^16.0.0", "ngx-color": "^9.0.0", "ngx-cookie-service": "^16.0.1", "ngx-file-drop": "^16.0.0", @@ -14404,6 +14405,19 @@ "pdfjs-dist": "~2.16.105" } }, + "node_modules/ngx-clipboard": { + "version": "16.0.0", + "resolved": "https://registry.npmjs.org/ngx-clipboard/-/ngx-clipboard-16.0.0.tgz", + "integrity": "sha512-rZ/Eo1PqiKMiyF8tdjhmUkoUu68f7OzBJ7YH1YFeh2RAaNrerTaW8XfFOzppSckjFQqA1fwGSYuTTJlDhDag5w==", + "dependencies": { + "ngx-window-token": ">=7.0.0", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@angular/common": ">=13.0.0", + "@angular/core": ">=13.0.0" + } + }, "node_modules/ngx-color": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/ngx-color/-/ngx-color-9.0.0.tgz", @@ -14474,6 +14488,21 @@ "@ng-bootstrap/ng-bootstrap": "^15.0.0" } }, + "node_modules/ngx-window-token": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/ngx-window-token/-/ngx-window-token-7.0.0.tgz", + "integrity": "sha512-5+XfRVSY7Dciu8xyCNMkOlH2UfwR9W2P1Pirz7caaZgOZDjFbL8aEO2stjfJJm2FFf1D6dlVHNzhLWGk9HGkqA==", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": "^14.20.0 || ^16.13.0 || >=18.10.0" + }, + "peerDependencies": { + "@angular/common": ">=13.0.0", + "@angular/core": ">=13.0.0" + } + }, "node_modules/nice-napi": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/nice-napi/-/nice-napi-1.0.2.tgz", diff --git a/src-ui/package.json b/src-ui/package.json index 76b44b85f..42ff4d792 100644 --- a/src-ui/package.json +++ b/src-ui/package.json @@ -26,6 +26,7 @@ "file-saver": "^2.0.5", "mime-names": "^1.0.0", "ng2-pdf-viewer": "^10.0.0", + "ngx-clipboard": "^16.0.0", "ngx-color": "^9.0.0", "ngx-cookie-service": "^16.0.1", "ngx-file-drop": "^16.0.0", diff --git a/src-ui/setup-jest.ts b/src-ui/setup-jest.ts index 65004742b..c0dfad9f9 100644 --- a/src-ui/setup-jest.ts +++ b/src-ui/setup-jest.ts @@ -86,6 +86,7 @@ Object.defineProperty(navigator, 'clipboard', { writeText: async () => {}, }, }) +Object.defineProperty(navigator, 'canShare', { value: () => true }) Object.defineProperty(window, 'ResizeObserver', { value: mock() }) HTMLCanvasElement.prototype.getContext = < diff --git a/src-ui/src/app/app.module.ts b/src-ui/src/app/app.module.ts index aac7a5238..f46c06cb9 100644 --- a/src-ui/src/app/app.module.ts +++ b/src-ui/src/app/app.module.ts @@ -94,6 +94,7 @@ import { PermissionsFilterDropdownComponent } from './components/common/permissi import { UsernamePipe } from './pipes/username.pipe' import { LogoComponent } from './components/common/logo/logo.component' import { IsNumberPipe } from './pipes/is-number.pipe' +import { ShareLinksDropdownComponent } from './components/common/share-links-dropdown/share-links-dropdown.component' import localeAf from '@angular/common/locales/af' import localeAr from '@angular/common/locales/ar' @@ -231,6 +232,7 @@ function initializeApp(settings: SettingsService) { UsernamePipe, LogoComponent, IsNumberPipe, + ShareLinksDropdownComponent, ], imports: [ BrowserModule, diff --git a/src-ui/src/app/components/common/edit-dialog/mail-account-edit-dialog/mail-account-edit-dialog.component.spec.ts b/src-ui/src/app/components/common/edit-dialog/mail-account-edit-dialog/mail-account-edit-dialog.component.spec.ts index 93fa7f0fd..1a35fb5ef 100644 --- a/src-ui/src/app/components/common/edit-dialog/mail-account-edit-dialog/mail-account-edit-dialog.component.spec.ts +++ b/src-ui/src/app/components/common/edit-dialog/mail-account-edit-dialog/mail-account-edit-dialog.component.spec.ts @@ -1,7 +1,6 @@ import { ComponentFixture, TestBed, - discardPeriodicTasks, fakeAsync, tick, } from '@angular/core/testing' diff --git a/src-ui/src/app/components/common/share-links-dropdown/share-links-dropdown.component.html b/src-ui/src/app/components/common/share-links-dropdown/share-links-dropdown.component.html new file mode 100644 index 000000000..15b3ce64c --- /dev/null +++ b/src-ui/src/app/components/common/share-links-dropdown/share-links-dropdown.component.html @@ -0,0 +1,61 @@ + + + + + + Share Links + + + + + No existing links + + + + + + {{ getDaysRemaining(link) }} + + + + + + Copy + + + + + Share + + + + + Delete + + + Copied! + + + + + + Share archive version + + + + Expires: + + {{ option.label }} + + + + + + + Create + + + + + + diff --git a/src-ui/src/app/components/common/share-links-dropdown/share-links-dropdown.component.scss b/src-ui/src/app/components/common/share-links-dropdown/share-links-dropdown.component.scss new file mode 100644 index 000000000..47e19d871 --- /dev/null +++ b/src-ui/src/app/components/common/share-links-dropdown/share-links-dropdown.component.scss @@ -0,0 +1,14 @@ +.share-links-dropdown { + min-width: 350px; + + // correct position on mobile + @media (max-width: 575.98px) { + &.show { + margin-left: -175px !important; + } + } +} + +.copied-badge { + right: 7.5em; +} diff --git a/src-ui/src/app/components/common/share-links-dropdown/share-links-dropdown.component.spec.ts b/src-ui/src/app/components/common/share-links-dropdown/share-links-dropdown.component.spec.ts new file mode 100644 index 000000000..c230fa870 --- /dev/null +++ b/src-ui/src/app/components/common/share-links-dropdown/share-links-dropdown.component.spec.ts @@ -0,0 +1,195 @@ +import { + HttpTestingController, + HttpClientTestingModule, +} from '@angular/common/http/testing' +import { + ComponentFixture, + TestBed, + fakeAsync, + tick, +} from '@angular/core/testing' +import { FormsModule, ReactiveFormsModule } from '@angular/forms' +import { of, throwError } from 'rxjs' +import { + PaperlessFileVersion, + PaperlessShareLink, +} from 'src/app/data/paperless-share-link' +import { ShareLinkService } from 'src/app/services/rest/share-link.service' +import { ToastService } from 'src/app/services/toast.service' +import { environment } from 'src/environments/environment' +import { ShareLinksDropdownComponent } from './share-links-dropdown.component' +import { ClipboardService } from 'ngx-clipboard' + +describe('ShareLinksDropdownComponent', () => { + let component: ShareLinksDropdownComponent + let fixture: ComponentFixture + let shareLinkService: ShareLinkService + let toastService: ToastService + let httpController: HttpTestingController + let clipboardService: ClipboardService + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [ShareLinksDropdownComponent], + imports: [HttpClientTestingModule, FormsModule, ReactiveFormsModule], + }) + + fixture = TestBed.createComponent(ShareLinksDropdownComponent) + shareLinkService = TestBed.inject(ShareLinkService) + toastService = TestBed.inject(ToastService) + httpController = TestBed.inject(HttpTestingController) + clipboardService = TestBed.inject(ClipboardService) + + component = fixture.componentInstance + fixture.detectChanges() + }) + + it('should support refresh to retrieve links', () => { + const getSpy = jest.spyOn(shareLinkService, 'getLinksForDocument') + component.documentId = 99 + + const now = new Date() + const expiration7days = new Date() + expiration7days.setDate(now.getDate() + 7) + + getSpy.mockReturnValue( + of([ + { + id: 1, + slug: '1234slug', + created: now.toISOString(), + document: 99, + file_version: PaperlessFileVersion.Archive, + expiration: expiration7days.toISOString(), + }, + { + id: 1, + slug: '1234slug', + created: now.toISOString(), + document: 99, + file_version: PaperlessFileVersion.Original, + expiration: null, + }, + ]) + ) + + component.refresh() + expect(getSpy).toHaveBeenCalled() + + fixture.detectChanges() + + expect(component.shareLinks).toHaveLength(2) + }) + + it('should show error on refresh if needed', () => { + const toastSpy = jest.spyOn(toastService, 'showError') + jest + .spyOn(shareLinkService, 'getLinksForDocument') + .mockReturnValueOnce(throwError(() => new Error('Unable to get links'))) + component.documentId = 99 + + component.refresh() + fixture.detectChanges() + expect(toastSpy).toHaveBeenCalled() + }) + + it('should support link creation then refresh & copy url', fakeAsync(() => { + const createSpy = jest.spyOn(shareLinkService, 'createLinkForDocument') + component.documentId = 99 + component.expirationDays = 7 + component.archiveVersion = false + + const expiration = new Date() + expiration.setDate(expiration.getDate() + 7) + + const copySpy = jest.spyOn(clipboardService, 'copy') + const refreshSpy = jest.spyOn(component, 'refresh') + + component.createLink() + expect(createSpy).toHaveBeenCalledWith(99, 'original', expiration) + + httpController.expectOne(`${environment.apiBaseUrl}share_links/`).flush({ + id: 1, + slug: '1234slug', + document: 99, + expiration: expiration.toISOString(), + }) + fixture.detectChanges() + tick(3000) + + expect(copySpy).toHaveBeenCalled() + expect(refreshSpy).toHaveBeenCalled() + })) + + it('should show error on link creation if needed', () => { + component.documentId = 99 + component.expirationDays = 7 + + const expiration = new Date() + expiration.setDate(expiration.getDate() + 7) + + const toastSpy = jest.spyOn(toastService, 'showError') + + component.createLink() + + httpController + .expectOne(`${environment.apiBaseUrl}share_links/`) + .flush( + { error: 'Share link error' }, + { status: 500, statusText: 'error' } + ) + fixture.detectChanges() + + expect(toastSpy).toHaveBeenCalled() + }) + + it('should support delete links & refresh', () => { + const deleteSpy = jest.spyOn(shareLinkService, 'delete') + deleteSpy.mockReturnValue(of(true)) + const refreshSpy = jest.spyOn(component, 'refresh') + + component.delete({ id: 12 } as PaperlessShareLink) + fixture.detectChanges() + expect(deleteSpy).toHaveBeenCalledWith({ id: 12 }) + expect(refreshSpy).toHaveBeenCalled() + }) + + it('should show error on delete if needed', () => { + const toastSpy = jest.spyOn(toastService, 'showError') + jest + .spyOn(shareLinkService, 'delete') + .mockReturnValueOnce(throwError(() => new Error('Unable to delete link'))) + component.delete(null) + fixture.detectChanges() + expect(toastSpy).toHaveBeenCalled() + }) + + it('should format days remaining', () => { + const now = new Date() + const expiration7days = new Date() + expiration7days.setDate(now.getDate() + 7) + const expiration1day = new Date() + expiration1day.setDate(now.getDate() + 1) + + expect( + component.getDaysRemaining({ + expiration: expiration7days.toISOString(), + } as PaperlessShareLink) + ).toEqual('7 days') + expect( + component.getDaysRemaining({ + expiration: expiration1day.toISOString(), + } as PaperlessShareLink) + ).toEqual('1 day') + }) + + // coverage + it('should support share', () => { + const link = { slug: '12345slug' } as PaperlessShareLink + if (!('share' in navigator)) + Object.defineProperty(navigator, 'share', { value: (obj: any) => {} }) + // const navigatorSpy = jest.spyOn(navigator, 'share') + component.share(link) + // expect(navigatorSpy).toHaveBeenCalledWith({ url: component.getShareUrl(link) }) + }) +}) diff --git a/src-ui/src/app/components/common/share-links-dropdown/share-links-dropdown.component.ts b/src-ui/src/app/components/common/share-links-dropdown/share-links-dropdown.component.ts new file mode 100644 index 000000000..1eb43fa42 --- /dev/null +++ b/src-ui/src/app/components/common/share-links-dropdown/share-links-dropdown.component.ts @@ -0,0 +1,149 @@ +import { Component, Input, OnInit } from '@angular/core' +import { first } from 'rxjs' +import { + PaperlessShareLink, + PaperlessFileVersion, +} from 'src/app/data/paperless-share-link' +import { ShareLinkService } from 'src/app/services/rest/share-link.service' +import { ToastService } from 'src/app/services/toast.service' +import { environment } from 'src/environments/environment' +import { ClipboardService } from 'ngx-clipboard' + +@Component({ + selector: 'app-share-links-dropdown', + templateUrl: './share-links-dropdown.component.html', + styleUrls: ['./share-links-dropdown.component.scss'], +}) +export class ShareLinksDropdownComponent implements OnInit { + EXPIRATION_OPTIONS = [ + { label: $localize`1 day`, value: 1 }, + { label: $localize`7 days`, value: 7 }, + { label: $localize`30 days`, value: 30 }, + { label: $localize`Never`, value: null }, + ] + + @Input() + title = $localize`Share Links` + + _documentId: number + + @Input() + set documentId(id: number) { + if (id !== undefined) { + this._documentId = id + this.refresh() + } + } + + @Input() + disabled: boolean = false + + shareLinks: PaperlessShareLink[] + + loading: boolean = false + + copied: number + + expirationDays: number = 7 + + archiveVersion: boolean = true + + constructor( + private shareLinkService: ShareLinkService, + private toastService: ToastService, + private clipboardService: ClipboardService + ) {} + + ngOnInit(): void { + if (this._documentId !== undefined) this.refresh() + } + + refresh() { + if (this._documentId === undefined) return + this.loading = true + this.shareLinkService + .getLinksForDocument(this._documentId) + .pipe(first()) + .subscribe({ + next: (results) => { + this.loading = false + this.shareLinks = results + }, + error: (e) => { + this.toastService.showError( + $localize`Error retrieving links`, + 10000, + e + ) + }, + }) + } + + getShareUrl(link: PaperlessShareLink): string { + return `${environment.apiBaseUrl.replace('api', 'share')}${link.slug}` + } + + getDaysRemaining(link: PaperlessShareLink): string { + const days: number = Math.ceil( + (Date.parse(link.expiration) - Date.now()) / (1000 * 60 * 60 * 24) + ) + return days === 1 ? $localize`1 day` : $localize`${days} days` + } + + copy(link: PaperlessShareLink) { + this.clipboardService.copy(this.getShareUrl(link)) + this.copied = link.id + setTimeout(() => { + this.copied = null + }, 3000) + } + + canShare(link: PaperlessShareLink): boolean { + return ( + navigator?.canShare && navigator.canShare({ url: this.getShareUrl(link) }) + ) + } + + share(link: PaperlessShareLink) { + navigator.share({ url: this.getShareUrl(link) }) + } + + delete(link: PaperlessShareLink) { + this.shareLinkService.delete(link).subscribe({ + next: () => { + this.refresh() + }, + error: (e) => { + this.toastService.showError($localize`Error deleting link`, 10000, e) + }, + }) + } + + createLink() { + let expiration + if (this.expirationDays) { + expiration = new Date() + expiration.setDate(expiration.getDate() + this.expirationDays) + } + this.loading = true + this.shareLinkService + .createLinkForDocument( + this._documentId, + this.archiveVersion + ? PaperlessFileVersion.Archive + : PaperlessFileVersion.Original, + expiration + ) + .subscribe({ + next: (result) => { + this.loading = false + this.copy(result) + this.refresh() + }, + error: (e) => { + this.loading = false + this.toastService.showError($localize`Error creating link`, 10000, e) + }, + }) + } +} diff --git a/src-ui/src/app/components/common/toasts/toasts.component.spec.ts b/src-ui/src/app/components/common/toasts/toasts.component.spec.ts index 4dd85305f..32cb15085 100644 --- a/src-ui/src/app/components/common/toasts/toasts.component.spec.ts +++ b/src-ui/src/app/components/common/toasts/toasts.component.spec.ts @@ -10,6 +10,7 @@ import { ComponentFixture } from '@angular/core/testing' import { HttpClientTestingModule } from '@angular/common/http/testing' import { of } from 'rxjs' import { NgbModule } from '@ng-bootstrap/ng-bootstrap' +import { ClipboardService } from 'ngx-clipboard' const toasts = [ { @@ -41,6 +42,7 @@ describe('ToastsComponent', () => { let component: ToastsComponent let fixture: ComponentFixture let toastService: ToastService + let clipboardService: ClipboardService beforeEach(async () => { TestBed.configureTestingModule({ @@ -57,9 +59,10 @@ describe('ToastsComponent', () => { }).compileComponents() fixture = TestBed.createComponent(ToastsComponent) - component = fixture.componentInstance - toastService = TestBed.inject(ToastService) + clipboardService = TestBed.inject(ClipboardService) + + component = fixture.componentInstance fixture.detectChanges() }) @@ -114,7 +117,7 @@ describe('ToastsComponent', () => { 'Error 2 message details' ) - const copySpy = jest.spyOn(navigator.clipboard, 'writeText') + const copySpy = jest.spyOn(clipboardService, 'copy') component.copyError(toasts[2].error) expect(copySpy).toHaveBeenCalled() diff --git a/src-ui/src/app/components/common/toasts/toasts.component.ts b/src-ui/src/app/components/common/toasts/toasts.component.ts index aa304ac4d..bdabe33ee 100644 --- a/src-ui/src/app/components/common/toasts/toasts.component.ts +++ b/src-ui/src/app/components/common/toasts/toasts.component.ts @@ -1,6 +1,7 @@ import { Component, OnDestroy, OnInit } from '@angular/core' import { Subscription } from 'rxjs' import { Toast, ToastService } from 'src/app/services/toast.service' +import { ClipboardService } from 'ngx-clipboard' @Component({ selector: 'app-toasts', @@ -8,7 +9,10 @@ import { Toast, ToastService } from 'src/app/services/toast.service' styleUrls: ['./toasts.component.scss'], }) export class ToastsComponent implements OnInit, OnDestroy { - constructor(private toastService: ToastService) {} + constructor( + private toastService: ToastService, + private clipboardService: ClipboardService + ) {} private subscription: Subscription @@ -45,7 +49,7 @@ export class ToastsComponent implements OnInit, OnDestroy { } public copyError(error: any) { - navigator.clipboard.writeText(JSON.stringify(error)) + this.clipboardService.copy(JSON.stringify(error)) this.copied = true setTimeout(() => { this.copied = false diff --git a/src-ui/src/app/components/document-detail/document-detail.component.html b/src-ui/src/app/components/document-detail/document-detail.component.html index 41e7a78d1..6b91438f9 100644 --- a/src-ui/src/app/components/document-detail/document-detail.component.html +++ b/src-ui/src/app/components/document-detail/document-detail.component.html @@ -5,16 +5,15 @@ of {{previewNumPages}} - + Delete - - + Download @@ -25,20 +24,31 @@ Download original - - - - - Redo OCR - + + + + + + Actions + + + + + + Redo OCR + - - - - More like this - + + + + More like this + + + + + diff --git a/src-ui/src/app/components/document-detail/document-detail.component.spec.ts b/src-ui/src/app/components/document-detail/document-detail.component.spec.ts index 81f68f9d5..3d3e34069 100644 --- a/src-ui/src/app/components/document-detail/document-detail.component.spec.ts +++ b/src-ui/src/app/components/document-detail/document-detail.component.spec.ts @@ -66,6 +66,7 @@ import { TextComponent } from '../common/input/text/text.component' import { PageHeaderComponent } from '../common/page-header/page-header.component' import { DocumentNotesComponent } from '../document-notes/document-notes.component' import { DocumentDetailComponent } from './document-detail.component' +import { ShareLinksDropdownComponent } from '../common/share-links-dropdown/share-links-dropdown.component' const doc: PaperlessDocument = { id: 3, @@ -134,6 +135,7 @@ describe('DocumentDetailComponent', () => { ConfirmDialogComponent, PdfViewerComponent, SafeUrlPipe, + ShareLinksDropdownComponent, ], providers: [ DocumentTitlePipe, diff --git a/src-ui/src/app/components/document-detail/document-detail.component.ts b/src-ui/src/app/components/document-detail/document-detail.component.ts index b75e0783f..056df5c2e 100644 --- a/src-ui/src/app/components/document-detail/document-detail.component.ts +++ b/src-ui/src/app/components/document-detail/document-detail.component.ts @@ -63,6 +63,7 @@ import { EditDialogMode } from '../common/edit-dialog/edit-dialog.component' import { ObjectWithId } from 'src/app/data/object-with-id' import { FilterRule } from 'src/app/data/filter-rule' import { ISODateAdapter } from 'src/app/utils/ngb-iso-date-adapter' +import { ShareLinksDropdownComponent } from '../common/share-links-dropdown/share-links-dropdown.component' enum DocumentDetailNavIDs { Details = 1, diff --git a/src-ui/src/app/data/paperless-share-link.ts b/src-ui/src/app/data/paperless-share-link.ts new file mode 100644 index 000000000..03dab58c9 --- /dev/null +++ b/src-ui/src/app/data/paperless-share-link.ts @@ -0,0 +1,18 @@ +import { ObjectWithPermissions } from './object-with-permissions' + +export enum PaperlessFileVersion { + Archive = 'archive', + Original = 'original', +} + +export interface PaperlessShareLink extends ObjectWithPermissions { + created: string // Date + + expiration?: string // Date + + slug: string + + document: number // PaperlessDocument + + file_version: string +} diff --git a/src-ui/src/app/services/permissions.service.spec.ts b/src-ui/src/app/services/permissions.service.spec.ts index 080326b17..9e7c43df4 100644 --- a/src-ui/src/app/services/permissions.service.spec.ts +++ b/src-ui/src/app/services/permissions.service.spec.ts @@ -248,6 +248,10 @@ describe('PermissionsService', () => { 'view_log', 'view_comment', 'change_frontendsettings', + 'add_sharelink', + 'view_sharelink', + 'change_sharelink', + 'delete_sharelink', ], { username: 'testuser', diff --git a/src-ui/src/app/services/permissions.service.ts b/src-ui/src/app/services/permissions.service.ts index a14e4bc1f..1f4df3748 100644 --- a/src-ui/src/app/services/permissions.service.ts +++ b/src-ui/src/app/services/permissions.service.ts @@ -24,6 +24,7 @@ export enum PermissionType { User = '%s_user', Group = '%s_group', Admin = '%s_logentry', + ShareLink = '%s_sharelink', } @Injectable({ diff --git a/src-ui/src/app/services/rest/abstract-paperless-service.ts b/src-ui/src/app/services/rest/abstract-paperless-service.ts index 1772e17d8..14735b1ad 100644 --- a/src-ui/src/app/services/rest/abstract-paperless-service.ts +++ b/src-ui/src/app/services/rest/abstract-paperless-service.ts @@ -10,7 +10,7 @@ export abstract class AbstractPaperlessService { constructor( protected http: HttpClient, - private resourceName: string + protected resourceName: string ) {} protected getResourceUrl(id: number = null, action: string = null): string { diff --git a/src-ui/src/app/services/rest/share-link.service.spec.ts b/src-ui/src/app/services/rest/share-link.service.spec.ts new file mode 100644 index 000000000..d6c85ec07 --- /dev/null +++ b/src-ui/src/app/services/rest/share-link.service.spec.ts @@ -0,0 +1,42 @@ +import { HttpTestingController } from '@angular/common/http/testing' +import { TestBed } from '@angular/core/testing' +import { Subscription } from 'rxjs' +import { environment } from 'src/environments/environment' +import { commonAbstractPaperlessServiceTests } from './abstract-paperless-service.spec' +import { ShareLinkService } from './share-link.service' + +let httpTestingController: HttpTestingController +let service: ShareLinkService +let subscription: Subscription +const endpoint = 'share_links' + +// run common tests +commonAbstractPaperlessServiceTests(endpoint, ShareLinkService) + +describe(`Additional service tests for ShareLinkService`, () => { + beforeEach(() => { + // Dont need to setup again + + httpTestingController = TestBed.inject(HttpTestingController) + service = TestBed.inject(ShareLinkService) + }) + + afterEach(() => { + subscription?.unsubscribe() + httpTestingController.verify() + }) + + it('should support creating link for document', () => { + subscription = service.createLinkForDocument(0).subscribe() + httpTestingController + .expectOne(`${environment.apiBaseUrl}${endpoint}/`) + .flush({}) + }) + + it('should support get links for a document', () => { + subscription = service.getLinksForDocument(0).subscribe() + httpTestingController + .expectOne(`${environment.apiBaseUrl}documents/0/${endpoint}/`) + .flush({}) + }) +}) diff --git a/src-ui/src/app/services/rest/share-link.service.ts b/src-ui/src/app/services/rest/share-link.service.ts new file mode 100644 index 000000000..f69b54844 --- /dev/null +++ b/src-ui/src/app/services/rest/share-link.service.ts @@ -0,0 +1,36 @@ +import { Injectable } from '@angular/core' +import { + PaperlessShareLink, + PaperlessFileVersion, +} from 'src/app/data/paperless-share-link' +import { AbstractNameFilterService } from './abstract-name-filter-service' +import { HttpClient } from '@angular/common/http' +import { Observable } from 'rxjs' + +@Injectable({ + providedIn: 'root', +}) +export class ShareLinkService extends AbstractNameFilterService { + constructor(http: HttpClient) { + super(http, 'share_links') + } + + getLinksForDocument(documentId: number): Observable { + return this.http.get( + `${this.baseUrl}documents/${documentId}/${this.resourceName}/` + ) + } + + createLinkForDocument( + documentId: number, + file_version: PaperlessFileVersion = PaperlessFileVersion.Archive, + expiration: Date = null + ) { + this.clearCache() + return this.http.post(this.getResourceUrl(), { + document: documentId, + file_version, + expiration: expiration?.toISOString(), + }) + } +} diff --git a/src/documents/admin.py b/src/documents/admin.py index cc1b43dc1..096dea528 100644 --- a/src/documents/admin.py +++ b/src/documents/admin.py @@ -8,6 +8,7 @@ from .models import Note from .models import PaperlessTask from .models import SavedView from .models import SavedViewFilterRule +from .models import ShareLink from .models import StoragePath from .models import Tag @@ -132,6 +133,12 @@ class NotesAdmin(GuardedModelAdmin): list_display_links = ("created",) +class ShareLinksAdmin(GuardedModelAdmin): + list_display = ("created", "expiration", "document") + list_filter = ("created", "expiration", "owner") + list_display_links = ("created",) + + admin.site.register(Correspondent, CorrespondentAdmin) admin.site.register(Tag, TagAdmin) admin.site.register(DocumentType, DocumentTypeAdmin) @@ -140,3 +147,4 @@ admin.site.register(SavedView, SavedViewAdmin) admin.site.register(StoragePath, StoragePathAdmin) admin.site.register(PaperlessTask, TaskAdmin) admin.site.register(Note, NotesAdmin) +admin.site.register(ShareLink, ShareLinksAdmin) diff --git a/src/documents/filters.py b/src/documents/filters.py index c7f35a6d5..e700ef867 100644 --- a/src/documents/filters.py +++ b/src/documents/filters.py @@ -8,6 +8,7 @@ from .models import Correspondent from .models import Document from .models import DocumentType from .models import Log +from .models import ShareLink from .models import StoragePath from .models import Tag @@ -149,6 +150,15 @@ class StoragePathFilterSet(FilterSet): } +class ShareLinkFilterSet(FilterSet): + class Meta: + model = ShareLink + fields = { + "created": DATE_KWARGS, + "expiration": DATE_KWARGS, + } + + class ObjectOwnedOrGrantedPermissionsFilter(ObjectPermissionsFilter): """ A filter backend that limits results to those where the requesting user diff --git a/src/documents/migrations/1038_sharelink.py b/src/documents/migrations/1038_sharelink.py new file mode 100644 index 000000000..fa2860b6f --- /dev/null +++ b/src/documents/migrations/1038_sharelink.py @@ -0,0 +1,126 @@ +# Generated by Django 4.1.10 on 2023-08-14 14:51 + +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.contrib.auth.management import create_permissions +from django.contrib.auth.models import Group +from django.contrib.auth.models import Permission +from django.contrib.auth.models import User +from django.db import migrations +from django.db import models +from django.db.models import Q + + +def add_sharelink_permissions(apps, schema_editor): + # create permissions without waiting for post_migrate signal + for app_config in apps.get_app_configs(): + app_config.models_module = True + create_permissions(app_config, apps=apps, verbosity=0) + app_config.models_module = None + + add_permission = Permission.objects.get(codename="add_document") + sharelink_permissions = Permission.objects.filter(codename__contains="sharelink") + + for user in User.objects.filter(Q(user_permissions=add_permission)).distinct(): + user.user_permissions.add(*sharelink_permissions) + + for group in Group.objects.filter(Q(permissions=add_permission)).distinct(): + group.permissions.add(*sharelink_permissions) + + +def remove_sharelink_permissions(apps, schema_editor): + sharelink_permissions = Permission.objects.filter(codename__contains="sharelink") + + for user in User.objects.all(): + user.user_permissions.remove(*sharelink_permissions) + + for group in Group.objects.all(): + group.permissions.remove(*sharelink_permissions) + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("documents", "1037_webp_encrypted_thumbnail_conversion"), + ] + + operations = [ + migrations.CreateModel( + name="ShareLink", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "created", + models.DateTimeField( + blank=True, + db_index=True, + default=django.utils.timezone.now, + editable=False, + verbose_name="created", + ), + ), + ( + "expiration", + models.DateTimeField( + blank=True, + db_index=True, + null=True, + verbose_name="expiration", + ), + ), + ( + "slug", + models.SlugField( + blank=True, + editable=False, + unique=True, + verbose_name="slug", + ), + ), + ( + "file_version", + models.CharField( + choices=[("archive", "Archive"), ("original", "Original")], + default="archive", + max_length=50, + ), + ), + ( + "document", + models.ForeignKey( + blank=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="share_links", + to="documents.document", + verbose_name="document", + ), + ), + ( + "owner", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="share_links", + to=settings.AUTH_USER_MODEL, + verbose_name="owner", + ), + ), + ], + options={ + "verbose_name": "share link", + "verbose_name_plural": "share links", + "ordering": ("created",), + }, + ), + migrations.RunPython(add_sharelink_permissions, remove_sharelink_permissions), + ] diff --git a/src/documents/models.py b/src/documents/models.py index e3f14a8b2..b7c188d34 100644 --- a/src/documents/models.py +++ b/src/documents/models.py @@ -675,3 +675,63 @@ class Note(models.Model): def __str__(self): return self.note + + +class ShareLink(models.Model): + class FileVersion(models.TextChoices): + ARCHIVE = ("archive", _("Archive")) + ORIGINAL = ("original", _("Original")) + + created = models.DateTimeField( + _("created"), + default=timezone.now, + db_index=True, + blank=True, + editable=False, + ) + + expiration = models.DateTimeField( + _("expiration"), + blank=True, + null=True, + db_index=True, + ) + + slug = models.SlugField( + _("slug"), + db_index=True, + unique=True, + blank=True, + editable=False, + ) + + document = models.ForeignKey( + Document, + blank=True, + related_name="share_links", + on_delete=models.CASCADE, + verbose_name=_("document"), + ) + + file_version = models.CharField( + max_length=50, + choices=FileVersion.choices, + default=FileVersion.ARCHIVE, + ) + + owner = models.ForeignKey( + User, + blank=True, + null=True, + related_name="share_links", + on_delete=models.SET_NULL, + verbose_name=_("owner"), + ) + + class Meta: + ordering = ("created",) + verbose_name = _("share link") + verbose_name_plural = _("share links") + + def __str__(self): + return f"Share Link for {self.document.title}" diff --git a/src/documents/serialisers.py b/src/documents/serialisers.py index 01e49ea8f..321a34ccb 100644 --- a/src/documents/serialisers.py +++ b/src/documents/serialisers.py @@ -8,6 +8,7 @@ from celery import states from django.conf import settings from django.contrib.auth.models import Group from django.contrib.auth.models import User +from django.utils.crypto import get_random_string from django.utils.text import slugify from django.utils.translation import gettext as _ from guardian.core import ObjectPermissionChecker @@ -26,6 +27,7 @@ from .models import MatchingModel from .models import PaperlessTask from .models import SavedView from .models import SavedViewFilterRule +from .models import ShareLink from .models import StoragePath from .models import Tag from .models import UiSettings @@ -941,3 +943,20 @@ class AcknowledgeTasksViewSerializer(serializers.Serializer): def validate_tasks(self, tasks): self._validate_task_id_list(tasks) return tasks + + +class ShareLinkSerializer(OwnedObjectSerializer): + class Meta: + model = ShareLink + fields = ( + "id", + "created", + "expiration", + "slug", + "document", + "file_version", + ) + + def create(self, validated_data): + validated_data["slug"] = get_random_string(50) + return super().create(validated_data) diff --git a/src/documents/templates/registration/logged_out.html b/src/documents/templates/registration/logged_out.html index 2f8a571ca..7d7491e3f 100644 --- a/src/documents/templates/registration/logged_out.html +++ b/src/documents/templates/registration/logged_out.html @@ -8,7 +8,7 @@ - + {% translate "Paperless-ngx signed out" %} diff --git a/src/documents/templates/registration/login.html b/src/documents/templates/registration/login.html index 47330302f..c4c936182 100644 --- a/src/documents/templates/registration/login.html +++ b/src/documents/templates/registration/login.html @@ -8,7 +8,7 @@ - + {% translate "Paperless-ngx sign in" %} @@ -40,9 +40,17 @@ {% translate "Please sign in." %} {% if form.errors %} - - {% translate "Your username and password didn't match. Please try again." %} - + + {% translate "Your username and password didn't match. Please try again." %} + + {% elif request.GET.sharelink_notfound %} + + {% translate "Share link was not found." %} + + {% elif request.GET.sharelink_expired %} + + {% translate "Share link has expired." %} + {% endif %} {% translate "Username" as i18n_username %} {% translate "Password" as i18n_password %} diff --git a/src/documents/tests/test_api.py b/src/documents/tests/test_api.py index 1b056b34f..f9c6da0a8 100644 --- a/src/documents/tests/test_api.py +++ b/src/documents/tests/test_api.py @@ -15,6 +15,7 @@ from unittest.mock import MagicMock import celery import pytest +from dateutil import parser from dateutil.relativedelta import relativedelta from django.conf import settings from django.contrib.auth.models import Group @@ -37,6 +38,7 @@ from documents.models import MatchingModel from documents.models import Note from documents.models import PaperlessTask from documents.models import SavedView +from documents.models import ShareLink from documents.models import StoragePath from documents.models import Tag from documents.tests.utils import DirectoriesMixin @@ -2558,6 +2560,119 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase): ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + def test_create_share_links(self): + """ + GIVEN: + - Existing document + WHEN: + - API request is made to generate a share_link + - API request is made to view share_links on incorrect doc pk + - Invalid method request is made to view share_links doc + THEN: + - Link is created with a slug and associated with document + - 404 + - Error + """ + doc = Document.objects.create( + title="test", + mime_type="application/pdf", + content="this is a document which will have notes added", + ) + # never expires + resp = self.client.post( + "/api/share_links/", + data={ + "document": doc.pk, + }, + ) + self.assertEqual(resp.status_code, status.HTTP_201_CREATED) + + resp = self.client.post( + "/api/share_links/", + data={ + "expiration": (timezone.now() + timedelta(days=7)).isoformat(), + "document": doc.pk, + "file_version": "original", + }, + ) + self.assertEqual(resp.status_code, status.HTTP_201_CREATED) + + response = self.client.get( + f"/api/documents/{doc.pk}/share_links/", + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + + resp_data = response.json() + + self.assertEqual(len(resp_data), 2) + + self.assertGreater(len(resp_data[1]["slug"]), 0) + self.assertIsNone(resp_data[1]["expiration"]) + self.assertEqual( + (parser.isoparse(resp_data[0]["expiration"]) - timezone.now()).days, + 6, + ) + + sl1 = ShareLink.objects.get(slug=resp_data[1]["slug"]) + self.assertEqual(str(sl1), f"Share Link for {doc.title}") + + response = self.client.post( + f"/api/documents/{doc.pk}/share_links/", + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED) + + response = self.client.get( + "/api/documents/99/share_links/", + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_share_links_permissions_aware(self): + """ + GIVEN: + - Existing document owned by user2 but with granted view perms for user1 + WHEN: + - API request is made by user1 to view share links + THEN: + - Links only shown if user has permissions + """ + user1 = User.objects.create_user(username="test1") + user1.user_permissions.add(*Permission.objects.all()) + user1.save() + + user2 = User.objects.create_user(username="test2") + user2.save() + + doc = Document.objects.create( + title="test", + mime_type="application/pdf", + content="this is a document which will have share links added", + ) + doc.owner = user2 + doc.save() + + self.client.force_authenticate(user1) + + resp = self.client.get( + f"/api/documents/{doc.pk}/share_links/", + format="json", + ) + self.assertEqual(resp.content, b"Insufficient permissions") + self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN) + + assign_perm("change_document", user1, doc) + + resp = self.client.get( + f"/api/documents/{doc.pk}/share_links/", + format="json", + ) + self.assertEqual(resp.status_code, status.HTTP_200_OK) + class TestDocumentApiV2(DirectoriesMixin, APITestCase): def setUp(self): diff --git a/src/documents/tests/test_management_exporter.py b/src/documents/tests/test_management_exporter.py index 4da93ee50..d7bc1000a 100644 --- a/src/documents/tests/test_management_exporter.py +++ b/src/documents/tests/test_management_exporter.py @@ -153,7 +153,7 @@ class TestExportImport(DirectoriesMixin, FileSystemAssertsMixin, TestCase): manifest = self._do_export(use_filename_format=use_filename_format) - self.assertEqual(len(manifest), 149) + self.assertEqual(len(manifest), 154) # dont include consumer or AnonymousUser users self.assertEqual( @@ -247,7 +247,7 @@ class TestExportImport(DirectoriesMixin, FileSystemAssertsMixin, TestCase): self.assertEqual(Document.objects.get(id=self.d4.id).title, "wow_dec") self.assertEqual(GroupObjectPermission.objects.count(), 1) self.assertEqual(UserObjectPermission.objects.count(), 1) - self.assertEqual(Permission.objects.count(), 108) + self.assertEqual(Permission.objects.count(), 112) messages = check_sanity() # everything is alright after the test self.assertEqual(len(messages), 0) @@ -676,15 +676,15 @@ class TestExportImport(DirectoriesMixin, FileSystemAssertsMixin, TestCase): os.path.join(self.dirs.media_dir, "documents"), ) - self.assertEqual(ContentType.objects.count(), 27) - self.assertEqual(Permission.objects.count(), 108) + self.assertEqual(ContentType.objects.count(), 28) + self.assertEqual(Permission.objects.count(), 112) manifest = self._do_export() with paperless_environment(): self.assertEqual( len(list(filter(lambda e: e["model"] == "auth.permission", manifest))), - 108, + 112, ) # add 1 more to db to show objects are not re-created by import Permission.objects.create( @@ -692,7 +692,7 @@ class TestExportImport(DirectoriesMixin, FileSystemAssertsMixin, TestCase): codename="test_perm", content_type_id=1, ) - self.assertEqual(Permission.objects.count(), 109) + self.assertEqual(Permission.objects.count(), 113) # will cause an import error self.user.delete() @@ -701,5 +701,5 @@ class TestExportImport(DirectoriesMixin, FileSystemAssertsMixin, TestCase): with self.assertRaises(IntegrityError): call_command("document_importer", "--no-progress-bar", self.target) - self.assertEqual(ContentType.objects.count(), 27) - self.assertEqual(Permission.objects.count(), 109) + self.assertEqual(ContentType.objects.count(), 28) + self.assertEqual(Permission.objects.count(), 113) diff --git a/src/documents/tests/test_views.py b/src/documents/tests/test_views.py index d84d7759b..ca1db5782 100644 --- a/src/documents/tests/test_views.py +++ b/src/documents/tests/test_views.py @@ -1,31 +1,23 @@ -import shutil +import os import tempfile +from datetime import timedelta from django.conf import settings +from django.contrib.auth.models import Permission from django.contrib.auth.models import User from django.test import TestCase -from django.test import override_settings +from django.utils import timezone from rest_framework import status +from documents.models import Document +from documents.models import ShareLink +from documents.tests.utils import DirectoriesMixin -class TestViews(TestCase): - @classmethod - def setUpClass(cls): - # Provide a dummy static dir to silence whitenoise warnings - cls.static_dir = tempfile.mkdtemp() - - cls.override = override_settings( - STATIC_ROOT=cls.static_dir, - ) - cls.override.enable() - - @classmethod - def tearDownClass(cls): - shutil.rmtree(cls.static_dir, ignore_errors=True) - cls.override.disable() +class TestViews(DirectoriesMixin, TestCase): def setUp(self) -> None: self.user = User.objects.create_user("testuser") + super().setUp() def test_login_redirect(self): response = self.client.get("/") @@ -74,3 +66,69 @@ class TestViews(TestCase): response.context_data["main_js"], f"frontend/{language_actual}/main.js", ) + + def test_share_link_views(self): + """ + GIVEN: + - Share link created + WHEN: + - Valid request for share link is made + - Invalid request for share link is made + - Request for expired share link is made + THEN: + - Document is returned without need for login + - User is redirected to login with error + - User is redirected to login with error + """ + + _, filename = tempfile.mkstemp(dir=self.dirs.originals_dir) + + content = b"This is a test" + + with open(filename, "wb") as f: + f.write(content) + + doc = Document.objects.create( + title="none", + filename=os.path.basename(filename), + mime_type="application/pdf", + ) + + sharelink_permissions = Permission.objects.filter( + codename__contains="sharelink", + ) + self.user.user_permissions.add(*sharelink_permissions) + self.user.save() + + self.client.force_login(self.user) + + self.client.post( + "/api/share_links/", + { + "document": doc.pk, + "file_version": "original", + }, + ) + sl1 = ShareLink.objects.get(document=doc) + + self.client.logout() + + # Valid + response = self.client.get(f"/share/{sl1.slug}") + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.content, content) + + # Invalid + response = self.client.get("/share/123notaslug", follow=True) + response.render() + self.assertEqual(response.request["PATH_INFO"], "/accounts/login/") + self.assertContains(response, b"Share link was not found") + + # Expired + sl1.expiration = timezone.now() - timedelta(days=1) + sl1.save() + + response = self.client.get(f"/share/{sl1.slug}", follow=True) + response.render() + self.assertEqual(response.request["PATH_INFO"], "/accounts/login/") + self.assertContains(response, b"Share link has expired") diff --git a/src/documents/views.py b/src/documents/views.py index b04b87243..856b27e27 100644 --- a/src/documents/views.py +++ b/src/documents/views.py @@ -27,9 +27,12 @@ from django.http import Http404 from django.http import HttpResponse from django.http import HttpResponseBadRequest from django.http import HttpResponseForbidden +from django.http import HttpResponseRedirect from django.shortcuts import get_object_or_404 +from django.utils import timezone from django.utils.decorators import method_decorator from django.utils.translation import get_language +from django.views import View from django.views.decorators.cache import cache_control from django.views.generic import TemplateView from django_filters.rest_framework import DjangoFilterBackend @@ -75,6 +78,7 @@ from .data_models import DocumentSource from .filters import CorrespondentFilterSet from .filters import DocumentFilterSet from .filters import DocumentTypeFilterSet +from .filters import ShareLinkFilterSet from .filters import StoragePathFilterSet from .filters import TagFilterSet from .matching import match_correspondents @@ -87,6 +91,7 @@ from .models import DocumentType from .models import Note from .models import PaperlessTask from .models import SavedView +from .models import ShareLink from .models import StoragePath from .models import Tag from .parsers import get_parser_class_for_mime_type @@ -100,6 +105,7 @@ from .serialisers import DocumentSerializer from .serialisers import DocumentTypeSerializer from .serialisers import PostDocumentSerializer from .serialisers import SavedViewSerializer +from .serialisers import ShareLinkSerializer from .serialisers import StoragePathSerializer from .serialisers import TagSerializer from .serialisers import TagSerializerVersion1 @@ -312,38 +318,12 @@ class DocumentViewSet( doc, ): return HttpResponseForbidden("Insufficient permissions") - if not self.original_requested(request) and doc.has_archive_version: - file_handle = doc.archive_file - filename = doc.get_public_filename(archive=True) - mime_type = "application/pdf" - else: - file_handle = doc.source_file - filename = doc.get_public_filename() - mime_type = doc.mime_type - # Support browser previewing csv files by using text mime type - if mime_type in {"application/csv", "text/csv"} and disposition == "inline": - mime_type = "text/plain" - - if doc.storage_type == Document.STORAGE_TYPE_GPG: - file_handle = GnuPG.decrypted(file_handle) - - response = HttpResponse(file_handle, content_type=mime_type) - # Firefox is not able to handle unicode characters in filename field - # RFC 5987 addresses this issue - # see https://datatracker.ietf.org/doc/html/rfc5987#section-4.2 - # Chromium cannot handle commas in the filename - filename_normalized = normalize("NFKD", filename.replace(",", "_")).encode( - "ascii", - "ignore", + return serve_file( + doc=doc, + use_archive=not self.original_requested(request) + and doc.has_archive_version, + disposition=disposition, ) - filename_encoded = quote(filename) - content_disposition = ( - f"{disposition}; " - f'filename="{filename_normalized}"; ' - f"filename*=utf-8''{filename_encoded}" - ) - response["Content-Disposition"] = content_disposition - return response def get_metadata(self, file, mime_type): if not os.path.isfile(file): @@ -574,6 +554,35 @@ class DocumentViewSet( }, ) + @action(methods=["get"], detail=True) + def share_links(self, request, pk=None): + currentUser = request.user + try: + doc = Document.objects.get(pk=pk) + if currentUser is not None and not has_perms_owner_aware( + currentUser, + "change_document", + doc, + ): + return HttpResponseForbidden("Insufficient permissions") + except Document.DoesNotExist: + raise Http404 + + if request.method == "GET": + now = timezone.now() + links = [ + { + "id": c.id, + "created": c.created, + "expiration": c.expiration, + "slug": c.slug, + } + for c in ShareLink.objects.filter(document=doc) + .exclude(expiration__lt=now) + .order_by("-created") + ] + return Response(links) + class SearchResultSerializer(DocumentSerializer, PassUserMixin): def to_representation(self, instance): @@ -1127,3 +1136,72 @@ class AcknowledgeTasksView(GenericAPIView): return Response({"result": result}) except Exception: return HttpResponseBadRequest() + + +class ShareLinkViewSet(ModelViewSet, PassUserMixin): + model = ShareLink + + queryset = ShareLink.objects.all() + + serializer_class = ShareLinkSerializer + pagination_class = StandardPagination + permission_classes = (IsAuthenticated, PaperlessObjectPermissions) + filter_backends = ( + DjangoFilterBackend, + OrderingFilter, + ObjectOwnedOrGrantedPermissionsFilter, + ) + filterset_class = ShareLinkFilterSet + ordering_fields = ("created", "expiration", "document") + + +class SharedLinkView(View): + authentication_classes = [] + permission_classes = [] + + def get(self, request, slug): + share_link = ShareLink.objects.filter(slug=slug).first() + if share_link is None: + return HttpResponseRedirect("/accounts/login/?sharelink_notfound=1") + if share_link.expiration is not None and share_link.expiration < timezone.now(): + return HttpResponseRedirect("/accounts/login/?sharelink_expired=1") + return serve_file( + doc=share_link.document, + use_archive=share_link.file_version == "archive", + disposition="inline", + ) + + +def serve_file(doc: Document, use_archive: bool, disposition: str): + if use_archive: + file_handle = doc.archive_file + filename = doc.get_public_filename(archive=True) + mime_type = "application/pdf" + else: + file_handle = doc.source_file + filename = doc.get_public_filename() + mime_type = doc.mime_type + # Support browser previewing csv files by using text mime type + if mime_type in {"application/csv", "text/csv"} and disposition == "inline": + mime_type = "text/plain" + + if doc.storage_type == Document.STORAGE_TYPE_GPG: + file_handle = GnuPG.decrypted(file_handle) + + response = HttpResponse(file_handle, content_type=mime_type) + # Firefox is not able to handle unicode characters in filename field + # RFC 5987 addresses this issue + # see https://datatracker.ietf.org/doc/html/rfc5987#section-4.2 + # Chromium cannot handle commas in the filename + filename_normalized = normalize("NFKD", filename.replace(",", "_")).encode( + "ascii", + "ignore", + ) + filename_encoded = quote(filename) + content_disposition = ( + f"{disposition}; " + f'filename="{filename_normalized}"; ' + f"filename*=utf-8''{filename_encoded}" + ) + response["Content-Disposition"] = content_disposition + return response diff --git a/src/locale/en_US/LC_MESSAGES/django.po b/src/locale/en_US/LC_MESSAGES/django.po index 7c0217bef..810ac3464 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: 2023-03-03 19:15+0000\n" +"POT-Creation-Date: 2023-08-15 00:55-0700\n" "PO-Revision-Date: 2022-02-17 04:17\n" "Last-Translator: \n" "Language-Team: English\n" @@ -17,494 +17,559 @@ msgstr "" "X-Crowdin-File: /dev/src/locale/en_US/LC_MESSAGES/django.po\n" "X-Crowdin-File-ID: 14\n" -#: documents/apps.py:9 +#: documents/apps.py:8 msgid "Documents" msgstr "" -#: documents/models.py:36 -msgid "None" -msgstr "" - -#: documents/models.py:37 -msgid "Any word" -msgstr "" - -#: documents/models.py:38 -msgid "All words" -msgstr "" - -#: documents/models.py:39 -msgid "Exact match" -msgstr "" - -#: documents/models.py:40 -msgid "Regular expression" -msgstr "" - -#: documents/models.py:41 -msgid "Fuzzy word" -msgstr "" - -#: documents/models.py:42 -msgid "Automatic" -msgstr "" - -#: documents/models.py:45 documents/models.py:394 paperless_mail/models.py:17 -#: paperless_mail/models.py:89 -msgid "name" -msgstr "" - -#: documents/models.py:47 -msgid "match" -msgstr "" - -#: documents/models.py:50 -msgid "matching algorithm" -msgstr "" - -#: documents/models.py:55 -msgid "is insensitive" -msgstr "" - -#: documents/models.py:71 +#: documents/models.py:33 documents/models.py:729 msgid "owner" msgstr "" -#: documents/models.py:81 documents/models.py:136 +#: documents/models.py:50 +msgid "None" +msgstr "" + +#: documents/models.py:51 +msgid "Any word" +msgstr "" + +#: documents/models.py:52 +msgid "All words" +msgstr "" + +#: documents/models.py:53 +msgid "Exact match" +msgstr "" + +#: documents/models.py:54 +msgid "Regular expression" +msgstr "" + +#: documents/models.py:55 +msgid "Fuzzy word" +msgstr "" + +#: documents/models.py:56 +msgid "Automatic" +msgstr "" + +#: documents/models.py:59 documents/models.py:399 paperless_mail/models.py:18 +#: paperless_mail/models.py:92 +msgid "name" +msgstr "" + +#: documents/models.py:61 +msgid "match" +msgstr "" + +#: documents/models.py:64 +msgid "matching algorithm" +msgstr "" + +#: documents/models.py:69 +msgid "is insensitive" +msgstr "" + +#: documents/models.py:92 documents/models.py:144 msgid "correspondent" msgstr "" -#: documents/models.py:82 +#: documents/models.py:93 msgid "correspondents" msgstr "" -#: documents/models.py:87 +#: documents/models.py:97 msgid "color" msgstr "" -#: documents/models.py:90 +#: documents/models.py:100 msgid "is inbox tag" msgstr "" -#: documents/models.py:93 +#: documents/models.py:103 msgid "" "Marks this tag as an inbox tag: All newly consumed documents will be tagged " "with inbox tags." msgstr "" -#: documents/models.py:99 +#: documents/models.py:109 msgid "tag" msgstr "" -#: documents/models.py:100 documents/models.py:174 +#: documents/models.py:110 documents/models.py:182 msgid "tags" msgstr "" -#: documents/models.py:105 documents/models.py:156 +#: documents/models.py:115 documents/models.py:164 msgid "document type" msgstr "" -#: documents/models.py:106 +#: documents/models.py:116 msgid "document types" msgstr "" -#: documents/models.py:111 +#: documents/models.py:121 msgid "path" msgstr "" -#: documents/models.py:117 documents/models.py:145 +#: documents/models.py:126 documents/models.py:153 msgid "storage path" msgstr "" -#: documents/models.py:118 +#: documents/models.py:127 msgid "storage paths" msgstr "" -#: documents/models.py:126 +#: documents/models.py:134 msgid "Unencrypted" msgstr "" -#: documents/models.py:127 +#: documents/models.py:135 msgid "Encrypted with GNU Privacy Guard" msgstr "" -#: documents/models.py:148 +#: documents/models.py:156 msgid "title" msgstr "" -#: documents/models.py:160 documents/models.py:624 +#: documents/models.py:168 documents/models.py:642 msgid "content" msgstr "" -#: documents/models.py:163 +#: documents/models.py:171 msgid "" "The raw, text-only data of the document. This field is primarily used for " "searching." msgstr "" -#: documents/models.py:168 +#: documents/models.py:176 msgid "mime type" msgstr "" -#: documents/models.py:178 +#: documents/models.py:186 msgid "checksum" msgstr "" -#: documents/models.py:182 +#: documents/models.py:190 msgid "The checksum of the original document." msgstr "" -#: documents/models.py:186 +#: documents/models.py:194 msgid "archive checksum" msgstr "" -#: documents/models.py:191 +#: documents/models.py:199 msgid "The checksum of the archived document." msgstr "" -#: documents/models.py:194 documents/models.py:376 documents/models.py:630 +#: documents/models.py:202 documents/models.py:382 documents/models.py:648 +#: documents/models.py:687 msgid "created" msgstr "" -#: documents/models.py:197 +#: documents/models.py:205 msgid "modified" msgstr "" -#: documents/models.py:204 +#: documents/models.py:212 msgid "storage type" msgstr "" -#: documents/models.py:212 +#: documents/models.py:220 msgid "added" msgstr "" -#: documents/models.py:219 +#: documents/models.py:227 msgid "filename" msgstr "" -#: documents/models.py:225 +#: documents/models.py:233 msgid "Current filename in storage" msgstr "" -#: documents/models.py:229 +#: documents/models.py:237 msgid "archive filename" msgstr "" -#: documents/models.py:235 +#: documents/models.py:243 msgid "Current archive filename in storage" msgstr "" -#: documents/models.py:239 +#: documents/models.py:247 msgid "original filename" msgstr "" -#: documents/models.py:245 +#: documents/models.py:253 msgid "The original name of the file when it was uploaded" msgstr "" -#: documents/models.py:252 +#: documents/models.py:260 msgid "archive serial number" msgstr "" -#: documents/models.py:262 +#: documents/models.py:270 msgid "The position of this document in your physical document archive." msgstr "" -#: documents/models.py:268 documents/models.py:641 +#: documents/models.py:276 documents/models.py:659 documents/models.py:714 msgid "document" msgstr "" -#: documents/models.py:269 +#: documents/models.py:277 msgid "documents" msgstr "" -#: documents/models.py:359 +#: documents/models.py:365 msgid "debug" msgstr "" -#: documents/models.py:360 +#: documents/models.py:366 msgid "information" msgstr "" -#: documents/models.py:361 +#: documents/models.py:367 msgid "warning" msgstr "" -#: documents/models.py:362 paperless_mail/models.py:276 +#: documents/models.py:368 paperless_mail/models.py:287 msgid "error" msgstr "" -#: documents/models.py:363 +#: documents/models.py:369 msgid "critical" msgstr "" -#: documents/models.py:366 +#: documents/models.py:372 msgid "group" msgstr "" -#: documents/models.py:368 +#: documents/models.py:374 msgid "message" msgstr "" -#: documents/models.py:371 +#: documents/models.py:377 msgid "level" msgstr "" -#: documents/models.py:380 +#: documents/models.py:386 msgid "log" msgstr "" -#: documents/models.py:381 +#: documents/models.py:387 msgid "logs" msgstr "" -#: documents/models.py:391 documents/models.py:446 +#: documents/models.py:396 documents/models.py:461 msgid "saved view" msgstr "" -#: documents/models.py:392 +#: documents/models.py:397 msgid "saved views" msgstr "" -#: documents/models.py:397 +#: documents/models.py:402 msgid "show on dashboard" msgstr "" -#: documents/models.py:400 +#: documents/models.py:405 msgid "show in sidebar" msgstr "" -#: documents/models.py:404 +#: documents/models.py:409 msgid "sort field" msgstr "" -#: documents/models.py:409 +#: documents/models.py:414 msgid "sort reverse" msgstr "" -#: documents/models.py:414 +#: documents/models.py:419 msgid "title contains" msgstr "" -#: documents/models.py:415 +#: documents/models.py:420 msgid "content contains" msgstr "" -#: documents/models.py:416 +#: documents/models.py:421 msgid "ASN is" msgstr "" -#: documents/models.py:417 +#: documents/models.py:422 msgid "correspondent is" msgstr "" -#: documents/models.py:418 +#: documents/models.py:423 msgid "document type is" msgstr "" -#: documents/models.py:419 +#: documents/models.py:424 msgid "is in inbox" msgstr "" -#: documents/models.py:420 +#: documents/models.py:425 msgid "has tag" msgstr "" -#: documents/models.py:421 +#: documents/models.py:426 msgid "has any tag" msgstr "" -#: documents/models.py:422 +#: documents/models.py:427 msgid "created before" msgstr "" -#: documents/models.py:423 +#: documents/models.py:428 msgid "created after" msgstr "" -#: documents/models.py:424 +#: documents/models.py:429 msgid "created year is" msgstr "" -#: documents/models.py:425 +#: documents/models.py:430 msgid "created month is" msgstr "" -#: documents/models.py:426 +#: documents/models.py:431 msgid "created day is" msgstr "" -#: documents/models.py:427 +#: documents/models.py:432 msgid "added before" msgstr "" -#: documents/models.py:428 +#: documents/models.py:433 msgid "added after" msgstr "" -#: documents/models.py:429 +#: documents/models.py:434 msgid "modified before" msgstr "" -#: documents/models.py:430 +#: documents/models.py:435 msgid "modified after" msgstr "" -#: documents/models.py:431 +#: documents/models.py:436 msgid "does not have tag" msgstr "" -#: documents/models.py:432 +#: documents/models.py:437 msgid "does not have ASN" msgstr "" -#: documents/models.py:433 +#: documents/models.py:438 msgid "title or content contains" msgstr "" -#: documents/models.py:434 +#: documents/models.py:439 msgid "fulltext query" msgstr "" -#: documents/models.py:435 +#: documents/models.py:440 msgid "more like this" msgstr "" -#: documents/models.py:436 +#: documents/models.py:441 msgid "has tags in" msgstr "" -#: documents/models.py:437 +#: documents/models.py:442 msgid "ASN greater than" msgstr "" -#: documents/models.py:438 +#: documents/models.py:443 msgid "ASN less than" msgstr "" -#: documents/models.py:439 +#: documents/models.py:444 msgid "storage path is" msgstr "" +#: documents/models.py:445 +msgid "has correspondent in" +msgstr "" + +#: documents/models.py:446 +msgid "does not have correspondent in" +msgstr "" + +#: documents/models.py:447 +msgid "has document type in" +msgstr "" + +#: documents/models.py:448 +msgid "does not have document type in" +msgstr "" + #: documents/models.py:449 -msgid "rule type" +msgid "has storage path in" +msgstr "" + +#: documents/models.py:450 +msgid "does not have storage path in" msgstr "" #: documents/models.py:451 -msgid "value" +msgid "owner is" +msgstr "" + +#: documents/models.py:452 +msgid "has owner in" +msgstr "" + +#: documents/models.py:453 +msgid "does not have owner" msgstr "" #: documents/models.py:454 +msgid "does not have owner in" +msgstr "" + +#: documents/models.py:464 +msgid "rule type" +msgstr "" + +#: documents/models.py:466 +msgid "value" +msgstr "" + +#: documents/models.py:469 msgid "filter rule" msgstr "" -#: documents/models.py:455 +#: documents/models.py:470 msgid "filter rules" msgstr "" -#: documents/models.py:563 +#: documents/models.py:578 msgid "Task ID" msgstr "" -#: documents/models.py:564 +#: documents/models.py:579 msgid "Celery ID for the Task that was run" msgstr "" -#: documents/models.py:569 +#: documents/models.py:584 msgid "Acknowledged" msgstr "" -#: documents/models.py:570 +#: documents/models.py:585 msgid "If the task is acknowledged via the frontend or API" msgstr "" -#: documents/models.py:576 +#: documents/models.py:591 msgid "Task Filename" msgstr "" -#: documents/models.py:577 +#: documents/models.py:592 msgid "Name of the file which the Task was run for" msgstr "" -#: documents/models.py:583 +#: documents/models.py:598 msgid "Task Name" msgstr "" -#: documents/models.py:584 +#: documents/models.py:599 msgid "Name of the Task which was run" msgstr "" -#: documents/models.py:591 +#: documents/models.py:606 msgid "Task State" msgstr "" -#: documents/models.py:592 +#: documents/models.py:607 msgid "Current state of the task being run" msgstr "" -#: documents/models.py:597 +#: documents/models.py:612 msgid "Created DateTime" msgstr "" -#: documents/models.py:598 +#: documents/models.py:613 msgid "Datetime field when the task result was created in UTC" msgstr "" -#: documents/models.py:603 +#: documents/models.py:618 msgid "Started DateTime" msgstr "" -#: documents/models.py:604 +#: documents/models.py:619 msgid "Datetime field when the task was started in UTC" msgstr "" -#: documents/models.py:609 +#: documents/models.py:624 msgid "Completed DateTime" msgstr "" -#: documents/models.py:610 +#: documents/models.py:625 msgid "Datetime field when the task was completed in UTC" msgstr "" -#: documents/models.py:615 +#: documents/models.py:630 msgid "Result Data" msgstr "" -#: documents/models.py:617 +#: documents/models.py:632 msgid "The data returned by the task" msgstr "" -#: documents/models.py:626 -msgid "Comment for the document" +#: documents/models.py:644 +msgid "Note for the document" msgstr "" -#: documents/models.py:650 +#: documents/models.py:668 msgid "user" msgstr "" -#: documents/models.py:655 -msgid "comment" +#: documents/models.py:673 +msgid "note" msgstr "" -#: documents/models.py:656 -msgid "comments" +#: documents/models.py:674 +msgid "notes" msgstr "" -#: documents/serialisers.py:80 +#: documents/models.py:682 +msgid "Archive" +msgstr "" + +#: documents/models.py:683 +msgid "Original" +msgstr "" + +#: documents/models.py:695 +msgid "expiration" +msgstr "" + +#: documents/models.py:702 +msgid "slug" +msgstr "" + +#: documents/models.py:734 +msgid "share link" +msgstr "" + +#: documents/models.py:735 +msgid "share links" +msgstr "" + +#: documents/serialisers.py:100 #, python-format msgid "Invalid regular expression: %(error)s" msgstr "" -#: documents/serialisers.py:320 +#: documents/serialisers.py:375 msgid "Invalid color." msgstr "" -#: documents/serialisers.py:700 +#: documents/serialisers.py:752 #, python-format msgid "File type %(type)s not supported" msgstr "" -#: documents/serialisers.py:794 +#: documents/serialisers.py:848 msgid "Invalid variable detected." msgstr "" @@ -544,394 +609,426 @@ msgstr "" msgid "Your username and password didn't match. Please try again." msgstr "" -#: documents/templates/registration/login.html:67 +#: documents/templates/registration/login.html:68 +msgid "Share link was not found." +msgstr "" + +#: documents/templates/registration/login.html:72 +msgid "Share link has expired." +msgstr "" + +#: documents/templates/registration/login.html:75 msgid "Username" msgstr "" -#: documents/templates/registration/login.html:68 +#: documents/templates/registration/login.html:76 msgid "Password" msgstr "" -#: documents/templates/registration/login.html:73 +#: documents/templates/registration/login.html:81 msgid "Sign in" msgstr "" -#: paperless/apps.py:9 +#: paperless/apps.py:10 msgid "Paperless" msgstr "" -#: paperless/settings.py:521 +#: paperless/settings.py:565 msgid "English (US)" msgstr "" -#: paperless/settings.py:522 +#: paperless/settings.py:566 msgid "Arabic" msgstr "" -#: paperless/settings.py:523 +#: paperless/settings.py:567 msgid "Belarusian" msgstr "" -#: paperless/settings.py:524 +#: paperless/settings.py:568 +msgid "Catalan" +msgstr "" + +#: paperless/settings.py:569 msgid "Czech" msgstr "" -#: paperless/settings.py:525 +#: paperless/settings.py:570 msgid "Danish" msgstr "" -#: paperless/settings.py:526 +#: paperless/settings.py:571 msgid "German" msgstr "" -#: paperless/settings.py:527 +#: paperless/settings.py:572 msgid "English (GB)" msgstr "" -#: paperless/settings.py:528 +#: paperless/settings.py:573 msgid "Spanish" msgstr "" -#: paperless/settings.py:529 +#: paperless/settings.py:574 +msgid "Finnish" +msgstr "" + +#: paperless/settings.py:575 msgid "French" msgstr "" -#: paperless/settings.py:530 +#: paperless/settings.py:576 msgid "Italian" msgstr "" -#: paperless/settings.py:531 +#: paperless/settings.py:577 msgid "Luxembourgish" msgstr "" -#: paperless/settings.py:532 +#: paperless/settings.py:578 msgid "Dutch" msgstr "" -#: paperless/settings.py:533 +#: paperless/settings.py:579 msgid "Polish" msgstr "" -#: paperless/settings.py:534 +#: paperless/settings.py:580 msgid "Portuguese (Brazil)" msgstr "" -#: paperless/settings.py:535 +#: paperless/settings.py:581 msgid "Portuguese" msgstr "" -#: paperless/settings.py:536 +#: paperless/settings.py:582 msgid "Romanian" msgstr "" -#: paperless/settings.py:537 +#: paperless/settings.py:583 msgid "Russian" msgstr "" -#: paperless/settings.py:538 +#: paperless/settings.py:584 +msgid "Slovak" +msgstr "" + +#: paperless/settings.py:585 msgid "Slovenian" msgstr "" -#: paperless/settings.py:539 +#: paperless/settings.py:586 msgid "Serbian" msgstr "" -#: paperless/settings.py:540 +#: paperless/settings.py:587 msgid "Swedish" msgstr "" -#: paperless/settings.py:541 +#: paperless/settings.py:588 msgid "Turkish" msgstr "" -#: paperless/settings.py:542 +#: paperless/settings.py:589 +msgid "Ukrainian" +msgstr "" + +#: paperless/settings.py:590 msgid "Chinese Simplified" msgstr "" -#: paperless/urls.py:169 +#: paperless/urls.py:176 msgid "Paperless-ngx administration" msgstr "" -#: paperless_mail/admin.py:30 +#: paperless_mail/admin.py:41 msgid "Authentication" msgstr "" -#: paperless_mail/admin.py:31 +#: paperless_mail/admin.py:44 msgid "Advanced settings" msgstr "" -#: paperless_mail/admin.py:48 +#: paperless_mail/admin.py:60 msgid "Filter" msgstr "" -#: paperless_mail/admin.py:51 +#: paperless_mail/admin.py:63 msgid "" "Paperless will only process mails that match ALL of the filters given below." msgstr "" -#: paperless_mail/admin.py:66 +#: paperless_mail/admin.py:79 msgid "Actions" msgstr "" -#: paperless_mail/admin.py:69 +#: paperless_mail/admin.py:82 msgid "" "The action applied to the mail. This action is only performed when the mail " "body or attachments were consumed from the mail." msgstr "" -#: paperless_mail/admin.py:77 +#: paperless_mail/admin.py:90 msgid "Metadata" msgstr "" -#: paperless_mail/admin.py:80 +#: paperless_mail/admin.py:93 msgid "" "Assign metadata to documents consumed from this rule automatically. If you " "do not assign tags, types or correspondents here, paperless will still " "process all matching rules that you have defined." msgstr "" -#: paperless_mail/apps.py:10 +#: paperless_mail/apps.py:11 msgid "Paperless mail" msgstr "" -#: paperless_mail/models.py:9 +#: paperless_mail/models.py:10 msgid "mail account" msgstr "" -#: paperless_mail/models.py:10 +#: paperless_mail/models.py:11 msgid "mail accounts" msgstr "" -#: paperless_mail/models.py:13 +#: paperless_mail/models.py:14 msgid "No encryption" msgstr "" -#: paperless_mail/models.py:14 +#: paperless_mail/models.py:15 msgid "Use SSL" msgstr "" -#: paperless_mail/models.py:15 +#: paperless_mail/models.py:16 msgid "Use STARTTLS" msgstr "" -#: paperless_mail/models.py:19 +#: paperless_mail/models.py:20 msgid "IMAP server" msgstr "" -#: paperless_mail/models.py:22 +#: paperless_mail/models.py:23 msgid "IMAP port" msgstr "" -#: paperless_mail/models.py:26 +#: paperless_mail/models.py:27 msgid "" "This is usually 143 for unencrypted and STARTTLS connections, and 993 for " "SSL connections." msgstr "" -#: paperless_mail/models.py:32 +#: paperless_mail/models.py:33 msgid "IMAP security" msgstr "" -#: paperless_mail/models.py:37 +#: paperless_mail/models.py:38 msgid "username" msgstr "" -#: paperless_mail/models.py:39 +#: paperless_mail/models.py:40 msgid "password" msgstr "" #: paperless_mail/models.py:42 +msgid "Is token authentication" +msgstr "" + +#: paperless_mail/models.py:45 msgid "character set" msgstr "" -#: paperless_mail/models.py:46 +#: paperless_mail/models.py:49 msgid "" "The character set to use when communicating with the mail server, such as " "'UTF-8' or 'US-ASCII'." msgstr "" -#: paperless_mail/models.py:57 +#: paperless_mail/models.py:60 msgid "mail rule" msgstr "" -#: paperless_mail/models.py:58 +#: paperless_mail/models.py:61 msgid "mail rules" msgstr "" -#: paperless_mail/models.py:61 paperless_mail/models.py:69 +#: paperless_mail/models.py:64 paperless_mail/models.py:72 msgid "Only process attachments." msgstr "" -#: paperless_mail/models.py:62 +#: paperless_mail/models.py:65 msgid "Process full Mail (with embedded attachments in file) as .eml" msgstr "" -#: paperless_mail/models.py:64 +#: paperless_mail/models.py:67 msgid "" "Process full Mail (with embedded attachments in file) as .eml + process " "attachments as separate documents" msgstr "" -#: paperless_mail/models.py:70 +#: paperless_mail/models.py:73 msgid "Process all files, including 'inline' attachments." msgstr "" -#: paperless_mail/models.py:73 +#: paperless_mail/models.py:76 msgid "Delete" msgstr "" -#: paperless_mail/models.py:74 +#: paperless_mail/models.py:77 msgid "Move to specified folder" msgstr "" -#: paperless_mail/models.py:75 +#: paperless_mail/models.py:78 msgid "Mark as read, don't process read mails" msgstr "" -#: paperless_mail/models.py:76 +#: paperless_mail/models.py:79 msgid "Flag the mail, don't process flagged mails" msgstr "" -#: paperless_mail/models.py:77 +#: paperless_mail/models.py:80 msgid "Tag the mail with specified tag, don't process tagged mails" msgstr "" -#: paperless_mail/models.py:80 +#: paperless_mail/models.py:83 msgid "Use subject as title" msgstr "" -#: paperless_mail/models.py:81 +#: paperless_mail/models.py:84 msgid "Use attachment filename as title" msgstr "" -#: paperless_mail/models.py:84 +#: paperless_mail/models.py:87 msgid "Do not assign a correspondent" msgstr "" -#: paperless_mail/models.py:85 +#: paperless_mail/models.py:88 msgid "Use mail address" msgstr "" -#: paperless_mail/models.py:86 +#: paperless_mail/models.py:89 msgid "Use name (or mail address if not available)" msgstr "" -#: paperless_mail/models.py:87 +#: paperless_mail/models.py:90 msgid "Use correspondent selected below" msgstr "" -#: paperless_mail/models.py:91 +#: paperless_mail/models.py:94 msgid "order" msgstr "" -#: paperless_mail/models.py:97 +#: paperless_mail/models.py:100 msgid "account" msgstr "" -#: paperless_mail/models.py:101 paperless_mail/models.py:231 +#: paperless_mail/models.py:104 paperless_mail/models.py:242 msgid "folder" msgstr "" -#: paperless_mail/models.py:105 +#: paperless_mail/models.py:108 msgid "" "Subfolders must be separated by a delimiter, often a dot ('.') or slash " "('/'), but it varies by mail server." msgstr "" -#: paperless_mail/models.py:111 +#: paperless_mail/models.py:114 msgid "filter from" msgstr "" -#: paperless_mail/models.py:117 +#: paperless_mail/models.py:121 +msgid "filter to" +msgstr "" + +#: paperless_mail/models.py:128 msgid "filter subject" msgstr "" -#: paperless_mail/models.py:123 +#: paperless_mail/models.py:135 msgid "filter body" msgstr "" -#: paperless_mail/models.py:130 +#: paperless_mail/models.py:142 msgid "filter attachment filename" msgstr "" -#: paperless_mail/models.py:135 +#: paperless_mail/models.py:147 msgid "" "Only consume documents which entirely match this filename if specified. " "Wildcards such as *.pdf or *invoice* are allowed. Case insensitive." msgstr "" -#: paperless_mail/models.py:142 +#: paperless_mail/models.py:154 msgid "maximum age" msgstr "" -#: paperless_mail/models.py:144 +#: paperless_mail/models.py:156 msgid "Specified in days." msgstr "" -#: paperless_mail/models.py:148 +#: paperless_mail/models.py:160 msgid "attachment type" msgstr "" -#: paperless_mail/models.py:152 +#: paperless_mail/models.py:164 msgid "" "Inline attachments include embedded images, so it's best to combine this " "option with a filename filter." msgstr "" -#: paperless_mail/models.py:158 +#: paperless_mail/models.py:170 msgid "consumption scope" msgstr "" -#: paperless_mail/models.py:164 +#: paperless_mail/models.py:176 msgid "action" msgstr "" -#: paperless_mail/models.py:170 +#: paperless_mail/models.py:182 msgid "action parameter" msgstr "" -#: paperless_mail/models.py:175 +#: paperless_mail/models.py:187 msgid "" "Additional parameter for the action selected above, i.e., the target folder " "of the move to folder action. Subfolders must be separated by dots." msgstr "" -#: paperless_mail/models.py:183 +#: paperless_mail/models.py:195 msgid "assign title from" msgstr "" -#: paperless_mail/models.py:191 +#: paperless_mail/models.py:203 msgid "assign this tag" msgstr "" -#: paperless_mail/models.py:199 +#: paperless_mail/models.py:211 msgid "assign this document type" msgstr "" -#: paperless_mail/models.py:203 +#: paperless_mail/models.py:215 msgid "assign correspondent from" msgstr "" -#: paperless_mail/models.py:213 +#: paperless_mail/models.py:225 msgid "assign this correspondent" msgstr "" -#: paperless_mail/models.py:239 +#: paperless_mail/models.py:250 msgid "uid" msgstr "" -#: paperless_mail/models.py:247 +#: paperless_mail/models.py:258 msgid "subject" msgstr "" -#: paperless_mail/models.py:255 +#: paperless_mail/models.py:266 msgid "received" msgstr "" -#: paperless_mail/models.py:262 +#: paperless_mail/models.py:273 msgid "processed" msgstr "" -#: paperless_mail/models.py:268 +#: paperless_mail/models.py:279 msgid "status" msgstr "" diff --git a/src/paperless/urls.py b/src/paperless/urls.py index c2b72d7b9..5d24478aa 100644 --- a/src/paperless/urls.py +++ b/src/paperless/urls.py @@ -22,6 +22,8 @@ from documents.views import RemoteVersionView from documents.views import SavedViewViewSet from documents.views import SearchAutoCompleteView from documents.views import SelectionDataView +from documents.views import SharedLinkView +from documents.views import ShareLinkViewSet from documents.views import StatisticsView from documents.views import StoragePathViewSet from documents.views import TagViewSet @@ -49,6 +51,7 @@ api_router.register(r"users", UserViewSet, basename="users") api_router.register(r"groups", GroupViewSet, basename="groups") api_router.register(r"mail_accounts", MailAccountViewSet) api_router.register(r"mail_rules", MailRuleViewSet) +api_router.register(r"share_links", ShareLinkViewSet) urlpatterns = [ @@ -110,6 +113,7 @@ urlpatterns = [ ], ), ), + re_path(r"share/(?P\w+)/?$", SharedLinkView.as_view()), re_path(r"^favicon.ico$", FaviconView.as_view(), name="favicon"), re_path(r"admin/", admin.site.urls), re_path( @@ -155,7 +159,7 @@ urlpatterns = [ # TODO: with localization, this is even worse! :/ # login, logout path("accounts/", include("django.contrib.auth.urls")), - # Root of the Frontent + # Root of the Frontend re_path(r".*", login_required(IndexView.as_view()), name="base"), ]
{% translate "Please sign in." %}