diff --git a/docs/advanced_usage.md b/docs/advanced_usage.md index 89530db7f..957d5287e 100644 --- a/docs/advanced_usage.md +++ b/docs/advanced_usage.md @@ -589,6 +589,12 @@ case, Paperless will remove the staging copy as well as the scan, and give you a message asking you to restart the process from scratch, by scanning the odd pages again, followed by the even pages. +It's important that the scan files get consumed in the correct order, and one at a time. +You therefore need to make sure that Paperless is running while you upload the files into +the directory; and if you're using [polling](/configuration#polling), make sure that +`CONSUMER_POLLING` is set to a value lower than it takes for the second scan to appear, +like 5-10 or even lower. + Another thing that might happen is that you start a double sided scan, but then forget to upload the second file. To avoid collating the wrong documents if you then come back a day later to scan a new double-sided document, Paperless will only keep an "odd numbered @@ -597,11 +603,11 @@ scan a completely new "odd numbered pages" one. The old staging file will get di ### Interaction with "subdirs as tags" -The collation feature can be used together with the "subdirs as tags" feature (but this is not -a requirement). Just create a correctly named double-sided subdir in the hierachy and upload -your scans there. For example, both `double-sided/foo/bar` as well as `foo/bar/double-sided` will -cause the collated document to be treated as if it were uploaded into `foo/bar` and receive both -`foo` and `bar` tags, but not `double-sided`. +The collation feature can be used together with the [subdirs as tags](/configuration#consume_config) +feature (but this is not a requirement). Just create a correctly named double-sided subdir +in the hierachy and upload your scans there. For example, both `double-sided/foo/bar` as +well as `foo/bar/double-sided` will cause the collated document to be treated as if it +were uploaded into `foo/bar` and receive both `foo` and `bar` tags, but not `double-sided`. ### Interaction with document splitting diff --git a/docs/configuration.md b/docs/configuration.md index 0ed2218a6..74486660f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -35,6 +35,12 @@ matcher. Defaults to `redis://localhost:6379`. +`PAPERLESS_REDIS_PREFIX=<prefix>` + +: Prefix to be used in Redis for keys and channels. Useful for sharing one Redis server among multiple Paperless instances. + + Defaults to no prefix. + ### Database `PAPERLESS_DBENGINE=<engine_name>` @@ -495,6 +501,19 @@ HTTP header/value expected by Django, eg `'["HTTP_X_FORWARDED_PROTO", "https"]'` Settings this value has security implications. Read the Django documentation and be sure you understand its usage before setting it. +`PAPERLESS_EMAIL_CERTIFICATE_FILE=<path>` + +: Configures an additional SSL certificate file containing a [certificate](https://docs.python.org/3/library/ssl.html#certificates) +or certificate chain which should be trusted for validating SSL connections against mail providers. +This is for use with self-signed certificates against local IMAP servers. + + Defaults to None. + +!!! warning + + Settings this value has security implications for the security of your email. + Understand what it does and be sure you need to before setting. + ## OCR settings {#ocr} Paperless uses [OCRmyPDF](https://ocrmypdf.readthedocs.io/en/latest/) diff --git a/src-ui/messages.xlf b/src-ui/messages.xlf index 865591166..1d07e98e4 100644 --- a/src-ui/messages.xlf +++ b/src-ui/messages.xlf @@ -723,7 +723,7 @@ </context-group> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> - <context context-type="linenumber">600</context> + <context context-type="linenumber">648</context> </context-group> </trans-unit> <trans-unit id="2526035785704676448" datatype="html"> @@ -2913,19 +2913,19 @@ </context-group> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> - <context context-type="linenumber">711</context> + <context context-type="linenumber">759</context> </context-group> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> - <context context-type="linenumber">771</context> + <context context-type="linenumber">819</context> </context-group> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> - <context context-type="linenumber">838</context> + <context context-type="linenumber">886</context> </context-group> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> - <context context-type="linenumber">901</context> + <context context-type="linenumber">949</context> </context-group> </trans-unit> <trans-unit id="1181910457994920507" datatype="html"> @@ -2940,19 +2940,19 @@ </context-group> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> - <context context-type="linenumber">713</context> + <context context-type="linenumber">761</context> </context-group> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> - <context context-type="linenumber">773</context> + <context context-type="linenumber">821</context> </context-group> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> - <context context-type="linenumber">840</context> + <context context-type="linenumber">888</context> </context-group> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> - <context context-type="linenumber">903</context> + <context context-type="linenumber">951</context> </context-group> </trans-unit> <trans-unit id="5729001209753056399" datatype="html"> @@ -4489,235 +4489,263 @@ <context context-type="linenumber">372</context> </context-group> </trans-unit> + <trans-unit id="3066660568529853846" datatype="html"> + <source>Error retrieving groups</source> + <context-group purpose="location"> + <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> + <context context-type="linenumber">278</context> + </context-group> + </trans-unit> + <trans-unit id="1235706724900303689" datatype="html"> + <source>Error retrieving users</source> + <context-group purpose="location"> + <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> + <context context-type="linenumber">287</context> + </context-group> + </trans-unit> + <trans-unit id="5241231471117657636" datatype="html"> + <source>Error retrieving mail rules</source> + <context-group purpose="location"> + <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> + <context context-type="linenumber">314</context> + </context-group> + </trans-unit> + <trans-unit id="3178554336792037159" datatype="html"> + <source>Error retrieving mail accounts</source> + <context-group purpose="location"> + <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> + <context context-type="linenumber">323</context> + </context-group> + </trans-unit> <trans-unit id="5610279464668232148" datatype="html"> <source>Saved view "<x id="PH" equiv-text="savedView.name"/>" deleted.</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> - <context context-type="linenumber">482</context> + <context context-type="linenumber">530</context> </context-group> </trans-unit> <trans-unit id="3891152409365583719" datatype="html"> <source>Settings saved</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> - <context context-type="linenumber">584</context> + <context context-type="linenumber">632</context> </context-group> </trans-unit> <trans-unit id="7217000812750597833" datatype="html"> <source>Settings were saved successfully.</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> - <context context-type="linenumber">585</context> + <context context-type="linenumber">633</context> </context-group> </trans-unit> <trans-unit id="525012668859298131" datatype="html"> <source>Settings were saved successfully. Reload is required to apply some changes.</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> - <context context-type="linenumber">589</context> + <context context-type="linenumber">637</context> </context-group> </trans-unit> <trans-unit id="8491974984518503778" datatype="html"> <source>Reload now</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> - <context context-type="linenumber">590</context> + <context context-type="linenumber">638</context> </context-group> </trans-unit> <trans-unit id="6839066544204061364" datatype="html"> <source>Use system language</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> - <context context-type="linenumber">609</context> + <context context-type="linenumber">657</context> </context-group> </trans-unit> <trans-unit id="7729897675462249787" datatype="html"> <source>Use date format of display language</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> - <context context-type="linenumber">616</context> + <context context-type="linenumber">664</context> </context-group> </trans-unit> <trans-unit id="5260584511980773458" datatype="html"> <source>Error while storing settings on server.</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> - <context context-type="linenumber">636</context> + <context context-type="linenumber">684</context> </context-group> </trans-unit> <trans-unit id="4510369340305901516" datatype="html"> <source>Password has been changed, you will be logged out momentarily.</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> - <context context-type="linenumber">679</context> + <context context-type="linenumber">727</context> </context-group> </trans-unit> <trans-unit id="2753185112875184719" datatype="html"> <source>Saved user "<x id="PH" equiv-text="newUser.username"/>".</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> - <context context-type="linenumber">686</context> + <context context-type="linenumber">734</context> </context-group> </trans-unit> <trans-unit id="3471101514724661554" datatype="html"> <source>Error saving user.</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> - <context context-type="linenumber">698</context> + <context context-type="linenumber">746</context> </context-group> </trans-unit> <trans-unit id="5565868288871970148" datatype="html"> <source>Confirm delete user account</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> - <context context-type="linenumber">709</context> + <context context-type="linenumber">757</context> </context-group> </trans-unit> <trans-unit id="8133663925694885325" datatype="html"> <source>This operation will permanently delete this user account.</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> - <context context-type="linenumber">710</context> + <context context-type="linenumber">758</context> </context-group> </trans-unit> <trans-unit id="857903183180440990" datatype="html"> <source>Deleted user</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> - <context context-type="linenumber">719</context> + <context context-type="linenumber">767</context> </context-group> </trans-unit> <trans-unit id="1942566571910298572" datatype="html"> <source>Error deleting user.</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> - <context context-type="linenumber">727</context> + <context context-type="linenumber">775</context> </context-group> </trans-unit> <trans-unit id="5766640174051730159" datatype="html"> <source>Saved group "<x id="PH" equiv-text="newGroup.name"/>".</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> - <context context-type="linenumber">748</context> + <context context-type="linenumber">796</context> </context-group> </trans-unit> <trans-unit id="8382042988405122578" datatype="html"> <source>Error saving group.</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> - <context context-type="linenumber">758</context> + <context context-type="linenumber">806</context> </context-group> </trans-unit> <trans-unit id="6538873300613683004" datatype="html"> <source>Confirm delete user group</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> - <context context-type="linenumber">769</context> + <context context-type="linenumber">817</context> </context-group> </trans-unit> <trans-unit id="7710984639498518244" datatype="html"> <source>This operation will permanently delete this user group.</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> - <context context-type="linenumber">770</context> + <context context-type="linenumber">818</context> </context-group> </trans-unit> <trans-unit id="6834066329827670963" datatype="html"> <source>Deleted group</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> - <context context-type="linenumber">779</context> + <context context-type="linenumber">827</context> </context-group> </trans-unit> <trans-unit id="8850738980935204840" datatype="html"> <source>Error deleting group.</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> - <context context-type="linenumber">787</context> + <context context-type="linenumber">835</context> </context-group> </trans-unit> <trans-unit id="6327501535846658797" datatype="html"> <source>Saved account "<x id="PH" equiv-text="newMailAccount.name"/>".</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> - <context context-type="linenumber">813</context> + <context context-type="linenumber">861</context> </context-group> </trans-unit> <trans-unit id="8067594003836508139" datatype="html"> <source>Error saving account.</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> - <context context-type="linenumber">825</context> + <context context-type="linenumber">873</context> </context-group> </trans-unit> <trans-unit id="5641934153807844674" datatype="html"> <source>Confirm delete mail account</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> - <context context-type="linenumber">836</context> + <context context-type="linenumber">884</context> </context-group> </trans-unit> <trans-unit id="7176985344323395435" datatype="html"> <source>This operation will permanently delete this mail account.</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> - <context context-type="linenumber">837</context> + <context context-type="linenumber">885</context> </context-group> </trans-unit> <trans-unit id="4233826387148482123" datatype="html"> <source>Deleted mail account</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> - <context context-type="linenumber">846</context> + <context context-type="linenumber">894</context> </context-group> </trans-unit> <trans-unit id="6202503362522392111" datatype="html"> <source>Error deleting mail account.</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> - <context context-type="linenumber">855</context> + <context context-type="linenumber">903</context> </context-group> </trans-unit> <trans-unit id="123368655395433699" datatype="html"> <source>Saved rule "<x id="PH" equiv-text="newMailRule.name"/>".</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> - <context context-type="linenumber">876</context> + <context context-type="linenumber">924</context> </context-group> </trans-unit> <trans-unit id="8951124554918814321" datatype="html"> <source>Error saving rule.</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> - <context context-type="linenumber">888</context> + <context context-type="linenumber">936</context> </context-group> </trans-unit> <trans-unit id="3896080636020672118" datatype="html"> <source>Confirm delete mail rule</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> - <context context-type="linenumber">899</context> + <context context-type="linenumber">947</context> </context-group> </trans-unit> <trans-unit id="2250372580580310337" datatype="html"> <source>This operation will permanently delete this mail rule.</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> - <context context-type="linenumber">900</context> + <context context-type="linenumber">948</context> </context-group> </trans-unit> <trans-unit id="9077981247971516916" datatype="html"> <source>Deleted mail rule</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> - <context context-type="linenumber">909</context> + <context context-type="linenumber">957</context> </context-group> </trans-unit> <trans-unit id="2033194641751367552" datatype="html"> <source>Error deleting mail rule.</source> <context-group purpose="location"> <context context-type="sourcefile">src/app/components/manage/settings/settings.component.ts</context> - <context context-type="linenumber">918</context> + <context context-type="linenumber">966</context> </context-group> </trans-unit> <trans-unit id="5101757640976222639" datatype="html"> diff --git a/src-ui/src/app/components/common/input/tags/tags.component.html b/src-ui/src/app/components/common/input/tags/tags.component.html index eba8ef218..497a62335 100644 --- a/src-ui/src/app/components/common/input/tags/tags.component.html +++ b/src-ui/src/app/components/common/input/tags/tags.component.html @@ -2,7 +2,7 @@ <label class="form-label" for="tags" i18n>Tags</label> <div class="input-group flex-nowrap"> - <ng-select name="tags" [items]="tags" bindLabel="name" bindValue="id" [(ngModel)]="value" + <ng-select #tagSelect name="tags" [items]="tags" bindLabel="name" bindValue="id" [(ngModel)]="value" [disabled]="disabled" [multiple]="true" [closeOnSelect]="false" @@ -11,11 +11,7 @@ [addTag]="allowCreate ? createTagRef : false" addTagText="Add tag" i18n-addTagText - (change)="onChange(value)" - (search)="onSearch($event)" - (focus)="clearLastSearchTerm()" - (clear)="clearLastSearchTerm()" - (blur)="onBlur()"> + (change)="onChange(value)"> <ng-template ng-label-tmp let-item="item"> <span class="tag-wrap tag-wrap-delete" (mousedown)="removeTag($event, item.id)"> diff --git a/src-ui/src/app/components/common/input/tags/tags.component.spec.ts b/src-ui/src/app/components/common/input/tags/tags.component.spec.ts index f3ea05d5d..85c492aba 100644 --- a/src-ui/src/app/components/common/input/tags/tags.component.spec.ts +++ b/src-ui/src/app/components/common/input/tags/tags.component.spec.ts @@ -15,16 +15,28 @@ import { DEFAULT_MATCHING_ALGORITHM, MATCH_ALL, } from 'src/app/data/matching-model' -import { NgSelectModule } from '@ng-select/ng-select' +import { NgSelectComponent, NgSelectModule } from '@ng-select/ng-select' import { RouterTestingModule } from '@angular/router/testing' import { HttpClientTestingModule } from '@angular/common/http/testing' import { of } from 'rxjs' import { TagService } from 'src/app/services/rest/tag.service' import { + NgbAccordionModule, NgbModal, NgbModalModule, NgbModalRef, + NgbPopoverModule, } from '@ng-bootstrap/ng-bootstrap' +import { TagEditDialogComponent } from '../../edit-dialog/tag-edit-dialog/tag-edit-dialog.component' +import { CheckComponent } from '../check/check.component' +import { IfOwnerDirective } from 'src/app/directives/if-owner.directive' +import { TextComponent } from '../text/text.component' +import { ColorComponent } from '../color/color.component' +import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' +import { PermissionsFormComponent } from '../permissions/permissions-form/permissions-form.component' +import { SelectComponent } from '../select/select.component' +import { ColorSliderModule } from 'ngx-color/slider' +import { By } from '@angular/platform-browser' const tags: PaperlessTag[] = [ { @@ -56,12 +68,32 @@ describe('TagsComponent', () => { beforeEach(async () => { TestBed.configureTestingModule({ - declarations: [TagsComponent], + declarations: [ + TagsComponent, + TagEditDialogComponent, + TextComponent, + ColorComponent, + IfOwnerDirective, + SelectComponent, + TextComponent, + PermissionsFormComponent, + ColorComponent, + CheckComponent, + ], providers: [ { provide: TagService, useValue: { - listAll: () => of(tags), + listAll: () => + of({ + results: tags, + }), + create: () => + of({ + name: 'bar', + id: 99, + color: '#fff000', + }), }, }, ], @@ -72,6 +104,8 @@ describe('TagsComponent', () => { RouterTestingModule, HttpClientTestingModule, NgbModalModule, + NgbAccordionModule, + NgbPopoverModule, ], }).compileComponents() @@ -85,7 +119,7 @@ describe('TagsComponent', () => { }) it('should support suggestions', () => { - expect(component.value).toBeUndefined() + expect(component.value).toHaveLength(0) component.value = [] component.tags = tags component.suggestions = [1, 2] @@ -107,19 +141,19 @@ describe('TagsComponent', () => { it('should support create new using last search term and open a modal', () => { let activeInstances: NgbModalRef[] modalService.activeInstances.subscribe((v) => (activeInstances = v)) - component.onSearch({ term: 'bar' }) + component.select.searchTerm = 'foobar' component.createTag() expect(modalService.hasOpenModals()).toBeTruthy() - expect(activeInstances[0].componentInstance.object.name).toEqual('bar') + expect(activeInstances[0].componentInstance.object.name).toEqual('foobar') + const editDialog = activeInstances[0] + .componentInstance as TagEditDialogComponent + editDialog.save() // create is mocked + fixture.detectChanges() + fixture.whenStable().then(() => { + expect(fixture.debugElement.nativeElement.textContent).toContain('foobar') + }) }) - it('should clear search term on blur after delay', fakeAsync(() => { - const clearSpy = jest.spyOn(component, 'clearLastSearchTerm') - component.onBlur() - tick(3000) - expect(clearSpy).toHaveBeenCalled() - })) - it('support remove tags', () => { component.tags = tags component.value = [1, 2] @@ -132,6 +166,7 @@ describe('TagsComponent', () => { }) it('should get tags', () => { + component.tags = null expect(component.getTag(2)).toBeNull() component.tags = tags expect(component.getTag(2)).toEqual(tags[1]) diff --git a/src-ui/src/app/components/common/input/tags/tags.component.ts b/src-ui/src/app/components/common/input/tags/tags.component.ts index 4fb0151b6..b6bfddb3c 100644 --- a/src-ui/src/app/components/common/input/tags/tags.component.ts +++ b/src-ui/src/app/components/common/input/tags/tags.component.ts @@ -5,6 +5,7 @@ import { Input, OnInit, Output, + ViewChild, } from '@angular/core' import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' import { NgbModal } from '@ng-bootstrap/ng-bootstrap' @@ -12,6 +13,8 @@ import { PaperlessTag } from 'src/app/data/paperless-tag' import { TagEditDialogComponent } from '../../edit-dialog/tag-edit-dialog/tag-edit-dialog.component' import { TagService } from 'src/app/services/rest/tag.service' import { EditDialogMode } from '../../edit-dialog/edit-dialog.component' +import { first, firstValueFrom, tap } from 'rxjs' +import { NgSelectComponent } from '@ng-select/ng-select' @Component({ providers: [ @@ -74,14 +77,14 @@ export class TagsComponent implements OnInit, ControlValueAccessor { @Output() filterDocuments = new EventEmitter<PaperlessTag[]>() - value: number[] + @ViewChild('tagSelect') select: NgSelectComponent - tags: PaperlessTag[] + value: number[] = [] + + tags: PaperlessTag[] = [] public createTagRef: (name) => void - private _lastSearchTerm: string - getTag(id: number) { if (this.tags) { return this.tags.find((tag) => tag.id == id) @@ -111,15 +114,20 @@ export class TagsComponent implements OnInit, ControlValueAccessor { }) modal.componentInstance.dialogMode = EditDialogMode.CREATE if (name) modal.componentInstance.object = { name: name } - else if (this._lastSearchTerm) - modal.componentInstance.object = { name: this._lastSearchTerm } - modal.componentInstance.succeeded.subscribe((newTag) => { - this.tagService.listAll().subscribe((tags) => { - this.tags = tags.results - this.value = [...this.value, newTag.id] - this.onChange(this.value) - }) - }) + else if (this.select.searchTerm) + modal.componentInstance.object = { name: this.select.searchTerm } + this.select.searchTerm = null + this.select.detectChanges() + return firstValueFrom( + (modal.componentInstance as TagEditDialogComponent).succeeded.pipe( + first(), + tap(() => { + this.tagService.listAll().subscribe((tags) => { + this.tags = tags.results + }) + }) + ) + ) } getSuggestions() { @@ -137,20 +145,6 @@ export class TagsComponent implements OnInit, ControlValueAccessor { this.onChange(this.value) } - clearLastSearchTerm() { - this._lastSearchTerm = null - } - - onSearch($event) { - this._lastSearchTerm = $event.term - } - - onBlur() { - setTimeout(() => { - this.clearLastSearchTerm() - }, 3000) - } - get hasPrivate(): boolean { return this.value.some( (t) => this.tags?.find((t2) => t2.id === t) === undefined diff --git a/src-ui/src/app/components/manage/settings/settings.component.html b/src-ui/src/app/components/manage/settings/settings.component.html index 5090d531d..8b0132902 100644 --- a/src-ui/src/app/components/manage/settings/settings.component.html +++ b/src-ui/src/app/components/manage/settings/settings.component.html @@ -243,7 +243,7 @@ <ng-container *appIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailAccount }"> <h4> <ng-container i18n>Mail accounts</ng-container> - <button type="button" class="btn btn-sm btn-primary ms-4" (click)="editMailAccount()"> + <button type="button" class="btn btn-sm btn-primary ms-4" (click)="editMailAccount()" *appIfPermissions="{ action: PermissionAction.Add, type: PermissionType.MailAccount }"> <svg class="sidebaricon me-1" fill="currentColor"> <use xlink:href="assets/bootstrap-icons.svg#plus-circle" /> </svg> @@ -262,7 +262,7 @@ <li *ngFor="let account of mailAccounts" class="list-group-item" [formGroupName]="account.id"> <div class="row"> - <div class="col d-flex align-items-center"><button class="btn btn-link p-0" type="button" (click)="editMailAccount(account)">{{account.name}}</button></div> + <div class="col d-flex align-items-center"><button class="btn btn-link p-0" type="button" (click)="editMailAccount(account)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.MailAccount)">{{account.name}}</button></div> <div class="col d-flex align-items-center">{{account.imap_server}}</div> <div class="col"> <div class="btn-group"> @@ -280,7 +280,7 @@ <ng-container *appIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailRule }"> <h4 class="mt-4"> <ng-container i18n>Mail rules</ng-container> - <button type="button" class="btn btn-sm btn-primary ms-4" (click)="editMailRule()"> + <button type="button" class="btn btn-sm btn-primary ms-4" (click)="editMailRule()" *appIfPermissions="{ action: PermissionAction.Add, type: PermissionType.MailRule }"> <svg class="sidebaricon me-1" fill="currentColor"> <use xlink:href="assets/bootstrap-icons.svg#plus-circle" /> </svg> @@ -299,7 +299,7 @@ <li *ngFor="let rule of mailRules" class="list-group-item" [formGroupName]="rule.id"> <div class="row"> - <div class="col d-flex align-items-center"><button class="btn btn-link p-0" type="button" (click)="editMailRule(rule)">{{rule.name}}</button></div> + <div class="col d-flex align-items-center"><button class="btn btn-link p-0" type="button" (click)="editMailRule(rule)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.MailRule)">{{rule.name}}</button></div> <div class="col d-flex align-items-center">{{(mailAccountService.getCached(rule.account) | async)?.name}}</div> <div class="col"> <div class="btn-group"> @@ -323,7 +323,7 @@ </ng-template> </li> - <li [ngbNavItem]="SettingsNavIDs.UsersGroups" *appIfPermissions="{ action: PermissionAction.Add, type: PermissionType.User }" (mouseover)="maybeInitializeTab(SettingsNavIDs.UsersGroups)" (focusin)="maybeInitializeTab(SettingsNavIDs.UsersGroups)"> + <li [ngbNavItem]="SettingsNavIDs.UsersGroups" *appIfPermissions="{ action: PermissionAction.View, type: PermissionType.User }" (mouseover)="maybeInitializeTab(SettingsNavIDs.UsersGroups)" (focusin)="maybeInitializeTab(SettingsNavIDs.UsersGroups)"> <a ngbNavLink i18n>Users & Groups</a> <ng-template ngbNavContent> @@ -334,7 +334,7 @@ <svg class="sidebaricon me-1" fill="currentColor"> <use xlink:href="assets/bootstrap-icons.svg#plus-circle" /> </svg> - <ng-container i18n>Add User</ng-container> + <ng-container *appIfPermissions="{ action: PermissionAction.Add, type: PermissionType.User }" i18n>Add User</ng-container> </button> </h4> <ul class="list-group" formGroupName="usersGroup"> @@ -350,13 +350,13 @@ <li *ngFor="let user of users" class="list-group-item" [formGroupName]="user.id"> <div class="row"> - <div class="col d-flex align-items-center"><button class="btn btn-link p-0" type="button" (click)="editUser(user)">{{user.username}}</button></div> + <div class="col d-flex align-items-center"><button class="btn btn-link p-0" type="button" (click)="editUser(user)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.User)">{{user.username}}</button></div> <div class="col d-flex align-items-center">{{user.first_name}} {{user.last_name}}</div> <div class="col d-flex align-items-center">{{user.groups?.map(getGroupName, this).join(', ')}}</div> <div class="col"> <div class="btn-group"> - <button class="btn btn-sm btn-primary" type="button" (click)="editUser(user)" i18n>Edit</button> - <button class="btn btn-sm btn-outline-danger" type="button" (click)="deleteUser(user)" i18n>Delete</button> + <button class="btn btn-sm btn-primary" type="button" (click)="editUser(user)" *appIfPermissions="{ action: PermissionAction.Change, type: PermissionType.User }" i18n>Edit</button> + <button class="btn btn-sm btn-outline-danger" type="button" (click)="deleteUser(user)" *appIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.User }" i18n>Delete</button> </div> </div> </div> @@ -369,7 +369,7 @@ <svg class="sidebaricon me-1" fill="currentColor"> <use xlink:href="assets/bootstrap-icons.svg#plus-circle" /> </svg> - <ng-container i18n>Add Group</ng-container> + <ng-container *appIfPermissions="{ action: PermissionAction.Add, type: PermissionType.Group }" i18n>Add Group</ng-container> </button> </h4> <ul *ngIf="groups.length > 0" class="list-group" formGroupName="groupsGroup"> @@ -385,13 +385,13 @@ <li *ngFor="let group of groups" class="list-group-item" [formGroupName]="group.id"> <div class="row"> - <div class="col d-flex align-items-center"><button class="btn btn-link p-0" type="button" (click)="editGroup(group)">{{group.name}}</button></div> + <div class="col d-flex align-items-center"><button class="btn btn-link p-0" type="button" (click)="editGroup(group)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.Group)">{{group.name}}</button></div> <div class="col"></div> <div class="col"></div> <div class="col"> <div class="btn-group"> - <button class="btn btn-sm btn-primary" type="button" (click)="editGroup(group)" i18n>Edit</button> - <button class="btn btn-sm btn-outline-danger" type="button" (click)="deleteGroup(group)" i18n>Delete</button> + <button class="btn btn-sm btn-primary" type="button" (click)="editGroup(group)" *appIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Group }" i18n>Edit</button> + <button class="btn btn-sm btn-outline-danger" type="button" (click)="deleteGroup(group)" *appIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Group }" i18n>Delete</button> </div> </div> </div> diff --git a/src-ui/src/app/components/manage/settings/settings.component.spec.ts b/src-ui/src/app/components/manage/settings/settings.component.spec.ts index c4a9d4a4b..fb8f0a7f4 100644 --- a/src-ui/src/app/components/manage/settings/settings.component.spec.ts +++ b/src-ui/src/app/components/manage/settings/settings.component.spec.ts @@ -15,6 +15,7 @@ import { NgbModule, NgbNavLink, NgbModalRef, + NgbAlertModule, } from '@ng-bootstrap/ng-bootstrap' import { of, throwError } from 'rxjs' import { routes } from 'src/app/app-routing.module' @@ -42,6 +43,13 @@ import { CheckComponent } from '../../common/input/check/check.component' import { ColorComponent } from '../../common/input/color/color.component' import { PageHeaderComponent } from '../../common/page-header/page-header.component' import { SettingsComponent } from './settings.component' +import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe' +import { SelectComponent } from '../../common/input/select/select.component' +import { TextComponent } from '../../common/input/text/text.component' +import { PasswordComponent } from '../../common/input/password/password.component' +import { NumberComponent } from '../../common/input/number/number.component' +import { TagsComponent } from '../../common/input/tags/tags.component' +import { NgSelectModule } from '@ng-select/ng-select' const savedViews = [ { id: 1, name: 'view1' }, @@ -90,6 +98,14 @@ describe('SettingsComponent', () => { ConfirmDialogComponent, CheckComponent, ColorComponent, + SafeHtmlPipe, + SelectComponent, + TextComponent, + PasswordComponent, + NumberComponent, + TagsComponent, + MailAccountEditDialogComponent, + MailRuleEditDialogComponent, ], providers: [CustomDatePipe, DatePipe, PermissionsGuard], imports: [ @@ -98,6 +114,8 @@ describe('SettingsComponent', () => { RouterTestingModule.withRoutes(routes), FormsModule, ReactiveFormsModule, + NgbAlertModule, + NgSelectModule, ], }).compileComponents() @@ -116,52 +134,66 @@ describe('SettingsComponent', () => { jest .spyOn(permissionsService, 'currentUserOwnsObject') .mockReturnValue(true) - jest.spyOn(userService, 'listAll').mockReturnValue( - of({ - all: users.map((u) => u.id), - count: users.length, - results: users.concat([]), - }) - ) groupService = TestBed.inject(GroupService) - jest.spyOn(groupService, 'listAll').mockReturnValue( - of({ - all: groups.map((g) => g.id), - count: groups.length, - results: groups.concat([]), - }) - ) savedViewService = TestBed.inject(SavedViewService) - jest.spyOn(savedViewService, 'listAll').mockReturnValue( - of({ - all: savedViews.map((v) => v.id), - count: savedViews.length, - results: (savedViews as PaperlessSavedView[]).concat([]), - }) - ) mailAccountService = TestBed.inject(MailAccountService) - jest.spyOn(mailAccountService, 'listAll').mockReturnValue( - of({ - all: mailAccounts.map((a) => a.id), - count: mailAccounts.length, - results: (mailAccounts as PaperlessMailAccount[]).concat([]), - }) - ) mailRuleService = TestBed.inject(MailRuleService) - jest.spyOn(mailRuleService, 'listAll').mockReturnValue( - of({ - all: mailRules.map((r) => r.id), - count: mailRules.length, - results: (mailRules as PaperlessMailRule[]).concat([]), - }) - ) + }) + + function completeSetup(excludeService = null) { + if (excludeService !== userService) { + jest.spyOn(userService, 'listAll').mockReturnValue( + of({ + all: users.map((u) => u.id), + count: users.length, + results: users.concat([]), + }) + ) + } + if (excludeService !== groupService) { + jest.spyOn(groupService, 'listAll').mockReturnValue( + of({ + all: groups.map((g) => g.id), + count: groups.length, + results: groups.concat([]), + }) + ) + } + if (excludeService !== savedViewService) { + jest.spyOn(savedViewService, 'listAll').mockReturnValue( + of({ + all: savedViews.map((v) => v.id), + count: savedViews.length, + results: (savedViews as PaperlessSavedView[]).concat([]), + }) + ) + } + if (excludeService !== mailAccountService) { + jest.spyOn(mailAccountService, 'listAll').mockReturnValue( + of({ + all: mailAccounts.map((a) => a.id), + count: mailAccounts.length, + results: (mailAccounts as PaperlessMailAccount[]).concat([]), + }) + ) + } + if (excludeService !== mailRuleService) { + jest.spyOn(mailRuleService, 'listAll').mockReturnValue( + of({ + all: mailRules.map((r) => r.id), + count: mailRules.length, + results: (mailRules as PaperlessMailRule[]).concat([]), + }) + ) + } fixture = TestBed.createComponent(SettingsComponent) component = fixture.componentInstance fixture.detectChanges() - }) + } it('should support tabbed settings & change URL, prevent navigation if dirty confirmation rejected', () => { + completeSetup() const navigateSpy = jest.spyOn(router, 'navigate') const tabButtons = fixture.debugElement.queryAll(By.directive(NgbNavLink)) tabButtons[1].nativeElement.dispatchEvent(new MouseEvent('click')) @@ -187,6 +219,7 @@ describe('SettingsComponent', () => { }) it('should support direct link to tab by URL, scroll if needed', () => { + completeSetup() jest .spyOn(activatedRoute, 'paramMap', 'get') .mockReturnValue(of(convertToParamMap({ section: 'mail' }))) @@ -199,6 +232,7 @@ describe('SettingsComponent', () => { }) it('should lazy load tab data', () => { + completeSetup() const tabButtons = fixture.debugElement.queryAll(By.directive(NgbNavLink)) expect(component.savedViews).toBeUndefined() @@ -221,6 +255,7 @@ describe('SettingsComponent', () => { }) it('should support save saved views, show error', () => { + completeSetup() component.maybeInitializeTab(3) // SavedViews const toastErrorSpy = jest.spyOn(toastService, 'showError') @@ -248,6 +283,7 @@ describe('SettingsComponent', () => { }) it('should support save local settings updating appearance settings and calling API, show error', () => { + completeSetup() const toastErrorSpy = jest.spyOn(toastService, 'showError') const toastSpy = jest.spyOn(toastService, 'show') const storeSpy = jest.spyOn(settingsService, 'storeSettings') @@ -275,6 +311,7 @@ describe('SettingsComponent', () => { }) it('should offer reload if settings changes require', () => { + completeSetup() let toast: Toast toastService.getToasts().subscribe((t) => (toast = t[0])) component.initialize(true) // reset @@ -288,6 +325,7 @@ describe('SettingsComponent', () => { }) it('should allow setting theme color, visually apply change immediately but not save', () => { + completeSetup() const appearanceSpy = jest.spyOn( settingsService, 'updateAppearanceSettings' @@ -304,6 +342,7 @@ describe('SettingsComponent', () => { }) it('should support delete saved view', () => { + completeSetup() component.maybeInitializeTab(3) // SavedViews const toastSpy = jest.spyOn(toastService, 'showInfo') const deleteSpy = jest.spyOn(savedViewService, 'delete') @@ -316,6 +355,7 @@ describe('SettingsComponent', () => { }) it('should support edit / create user, show error if needed', () => { + completeSetup() let modal: NgbModalRef modalService.activeInstances.subscribe((refs) => (modal = refs[0])) component.editUser(users[0]) @@ -332,6 +372,7 @@ describe('SettingsComponent', () => { }) it('should support delete user, show error if needed', () => { + completeSetup() let modal: NgbModalRef modalService.activeInstances.subscribe((refs) => (modal = refs[0])) component.deleteUser(users[0]) @@ -352,6 +393,7 @@ describe('SettingsComponent', () => { }) it('should logout current user if password changed, after delay', fakeAsync(() => { + completeSetup() let modal: NgbModalRef modalService.activeInstances.subscribe((refs) => (modal = refs[0])) component.editUser(users[0]) @@ -371,6 +413,7 @@ describe('SettingsComponent', () => { })) it('should support edit / create group, show error if needed', () => { + completeSetup() let modal: NgbModalRef modalService.activeInstances.subscribe((refs) => (modal = refs[0])) component.editGroup(groups[0]) @@ -386,6 +429,7 @@ describe('SettingsComponent', () => { }) it('should support delete group, show error if needed', () => { + completeSetup() let modal: NgbModalRef modalService.activeInstances.subscribe((refs) => (modal = refs[0])) component.deleteGroup(users[0]) @@ -406,12 +450,71 @@ describe('SettingsComponent', () => { }) it('should get group name', () => { + completeSetup() component.maybeInitializeTab(5) // UsersGroups expect(component.getGroupName(1)).toEqual(groups[0].name) expect(component.getGroupName(11)).toEqual('') }) + it('should show errors on load if load mailAccounts failure', () => { + const toastErrorSpy = jest.spyOn(toastService, 'showError') + jest + .spyOn(mailAccountService, 'listAll') + .mockImplementation(() => + throwError(() => new Error('failed to load mail accounts')) + ) + completeSetup(mailAccountService) + const tabButtons = fixture.debugElement.queryAll(By.directive(NgbNavLink)) + tabButtons[3].nativeElement.dispatchEvent(new MouseEvent('click')) // mail tab + fixture.detectChanges() + expect(toastErrorSpy).toBeCalled() + }) + + it('should show errors on load if load mailRules failure', () => { + const toastErrorSpy = jest.spyOn(toastService, 'showError') + jest + .spyOn(mailRuleService, 'listAll') + .mockImplementation(() => + throwError(() => new Error('failed to load mail rules')) + ) + completeSetup(mailRuleService) + const tabButtons = fixture.debugElement.queryAll(By.directive(NgbNavLink)) + tabButtons[3].nativeElement.dispatchEvent(new MouseEvent('click')) // mail tab + fixture.detectChanges() + // tabButtons[4].nativeElement.dispatchEvent(new MouseEvent('click')) + expect(toastErrorSpy).toBeCalled() + }) + + it('should show errors on load if load users failure', () => { + const toastErrorSpy = jest.spyOn(toastService, 'showError') + jest + .spyOn(userService, 'listAll') + .mockImplementation(() => + throwError(() => new Error('failed to load users')) + ) + completeSetup(userService) + const tabButtons = fixture.debugElement.queryAll(By.directive(NgbNavLink)) + tabButtons[4].nativeElement.dispatchEvent(new MouseEvent('click')) // users tab + fixture.detectChanges() + expect(toastErrorSpy).toBeCalled() + }) + + it('should show errors on load if load groups failure', () => { + const toastErrorSpy = jest.spyOn(toastService, 'showError') + jest + .spyOn(groupService, 'listAll') + .mockImplementation(() => + throwError(() => new Error('failed to load groups')) + ) + completeSetup(groupService) + const tabButtons = fixture.debugElement.queryAll(By.directive(NgbNavLink)) + tabButtons[4].nativeElement.dispatchEvent(new MouseEvent('click')) // users tab + fixture.detectChanges() + expect(toastErrorSpy).toBeCalled() + }) + it('should support edit / create mail account, show error if needed', () => { + completeSetup() let modal: NgbModalRef modalService.activeInstances.subscribe((refs) => (modal = refs[0])) component.editMailAccount(mailAccounts[0] as PaperlessMailAccount) @@ -427,6 +530,7 @@ describe('SettingsComponent', () => { }) it('should support delete mail account, show error if needed', () => { + completeSetup() let modal: NgbModalRef modalService.activeInstances.subscribe((refs) => (modal = refs[0])) component.deleteMailAccount(mailAccounts[0] as PaperlessMailAccount) @@ -447,6 +551,7 @@ describe('SettingsComponent', () => { }) it('should support edit / create mail rule, show error if needed', () => { + completeSetup() let modal: NgbModalRef modalService.activeInstances.subscribe((refs) => (modal = refs[0])) component.editMailRule(mailRules[0] as PaperlessMailRule) @@ -462,6 +567,7 @@ describe('SettingsComponent', () => { }) it('should support delete mail rule, show error if needed', () => { + completeSetup() let modal: NgbModalRef modalService.activeInstances.subscribe((refs) => (modal = refs[0])) component.deleteMailRule(mailRules[0] as PaperlessMailRule) diff --git a/src-ui/src/app/components/manage/settings/settings.component.ts b/src-ui/src/app/components/manage/settings/settings.component.ts index a49f2dd21..785e5d347 100644 --- a/src-ui/src/app/components/manage/settings/settings.component.ts +++ b/src-ui/src/app/components/manage/settings/settings.component.ts @@ -146,7 +146,7 @@ export class SettingsComponent private groupsService: GroupService, private router: Router, private modalService: NgbModal, - private permissionsService: PermissionsService + public permissionsService: PermissionsService ) { super() this.settings.settingsSaved.subscribe(() => { @@ -259,25 +259,73 @@ export class SettingsComponent navID == SettingsNavIDs.UsersGroups && (!this.users || !this.groups) ) { - this.usersService.listAll().subscribe((r) => { - this.users = r.results - this.groupsService.listAll().subscribe((r) => { - this.groups = r.results - this.initialize(false) + this.usersService + .listAll() + .pipe(first()) + .subscribe({ + next: (r) => { + this.users = r.results + this.groupsService + .listAll() + .pipe(first()) + .subscribe({ + next: (r) => { + this.groups = r.results + this.initialize(false) + }, + error: (e) => { + this.toastService.showError( + $localize`Error retrieving groups`, + 10000, + JSON.stringify(e) + ) + }, + }) + }, + error: (e) => { + this.toastService.showError( + $localize`Error retrieving users`, + 10000, + JSON.stringify(e) + ) + }, }) - }) } else if ( navID == SettingsNavIDs.Mail && (!this.mailAccounts || !this.mailRules) ) { - this.mailAccountService.listAll().subscribe((r) => { - this.mailAccounts = r.results + this.mailAccountService + .listAll() + .pipe(first()) + .subscribe({ + next: (r) => { + this.mailAccounts = r.results - this.mailRuleService.listAll().subscribe((r) => { - this.mailRules = r.results - this.initialize(false) + this.mailRuleService + .listAll() + .pipe(first()) + .subscribe({ + next: (r) => { + this.mailRules = r.results + this.initialize(false) + }, + error: (e) => { + this.toastService.showError( + $localize`Error retrieving mail rules`, + 10000, + JSON.stringify(e) + ) + }, + }) + }, + error: (e) => { + this.toastService.showError( + $localize`Error retrieving mail accounts`, + 10000, + JSON.stringify(e) + ) + }, }) - }) } } diff --git a/src-ui/src/app/services/toast.service.ts b/src-ui/src/app/services/toast.service.ts index 2d11d663e..ef282c522 100644 --- a/src-ui/src/app/services/toast.service.ts +++ b/src-ui/src/app/services/toast.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@angular/core' -import { Subject, zip } from 'rxjs' +import { Subject } from 'rxjs' export interface Toast { title: string diff --git a/src-ui/src/environments/environment.prod.ts b/src-ui/src/environments/environment.prod.ts index a36d733c3..3d1d968bf 100644 --- a/src-ui/src/environments/environment.prod.ts +++ b/src-ui/src/environments/environment.prod.ts @@ -5,7 +5,7 @@ export const environment = { apiBaseUrl: document.baseURI + 'api/', apiVersion: '3', appTitle: 'Paperless-ngx', - version: '1.17.1', + version: '1.17.1-dev', webSocketHost: window.location.host, webSocketProtocol: window.location.protocol == 'https:' ? 'wss:' : 'ws:', webSocketBaseUrl: base_url.pathname + 'ws/', diff --git a/src-ui/src/theme.scss b/src-ui/src/theme.scss index d90afa6c1..33748c81b 100644 --- a/src-ui/src/theme.scss +++ b/src-ui/src/theme.scss @@ -80,6 +80,9 @@ $form-check-radio-checked-bg-image-dark: url("data:image/svg+xml,<svg xmlns='htt .btn { --bs-btn-disabled-opacity: 0.35; } + .btn.btn-link { + --bs-btn-disabled-opacity: 0.85; + } .btn-primary { &:hover, &:focus, &.active, &:active { diff --git a/src/documents/tests/test_api.py b/src/documents/tests/test_api.py index d788cf6a4..88180d4d8 100644 --- a/src/documents/tests/test_api.py +++ b/src/documents/tests/test_api.py @@ -3453,6 +3453,110 @@ class TestBulkEdit(DirectoriesMixin, APITestCase): self.assertCountEqual(args[0], [self.doc2.id, self.doc3.id]) self.assertEqual(len(kwargs["set_permissions"]["view"]["users"]), 2) + @mock.patch("documents.serialisers.bulk_edit.set_permissions") + def test_insufficient_permissions_ownership(self, m): + """ + GIVEN: + - Documents owned by user other than logged in user + WHEN: + - set_permissions bulk edit API endpoint is called + THEN: + - User is not able to change permissions + """ + m.return_value = "OK" + self.doc1.owner = User.objects.get(username="temp_admin") + self.doc1.save() + user1 = User.objects.create(username="user1") + self.client.force_authenticate(user=user1) + + permissions = { + "owner": user1.id, + } + + response = self.client.post( + "/api/documents/bulk_edit/", + json.dumps( + { + "documents": [self.doc1.id, self.doc2.id, self.doc3.id], + "method": "set_permissions", + "parameters": {"set_permissions": permissions}, + }, + ), + content_type="application/json", + ) + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + m.assert_not_called() + self.assertEqual(response.content, b"Insufficient permissions") + + response = self.client.post( + "/api/documents/bulk_edit/", + json.dumps( + { + "documents": [self.doc2.id, self.doc3.id], + "method": "set_permissions", + "parameters": {"set_permissions": permissions}, + }, + ), + content_type="application/json", + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + m.assert_called_once() + + @mock.patch("documents.serialisers.bulk_edit.set_storage_path") + def test_insufficient_permissions_edit(self, m): + """ + GIVEN: + - Documents for which current user only has view permissions + WHEN: + - API is called + THEN: + - set_storage_path is only called if user can edit all docs + """ + m.return_value = "OK" + self.doc1.owner = User.objects.get(username="temp_admin") + self.doc1.save() + user1 = User.objects.create(username="user1") + assign_perm("view_document", user1, self.doc1) + self.client.force_authenticate(user=user1) + + response = self.client.post( + "/api/documents/bulk_edit/", + json.dumps( + { + "documents": [self.doc1.id, self.doc2.id, self.doc3.id], + "method": "set_storage_path", + "parameters": {"storage_path": self.sp1.id}, + }, + ), + content_type="application/json", + ) + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + m.assert_not_called() + self.assertEqual(response.content, b"Insufficient permissions") + + assign_perm("change_document", user1, self.doc1) + + response = self.client.post( + "/api/documents/bulk_edit/", + json.dumps( + { + "documents": [self.doc1.id, self.doc2.id, self.doc3.id], + "method": "set_storage_path", + "parameters": {"storage_path": self.sp1.id}, + }, + ), + content_type="application/json", + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + + m.assert_called_once() + class TestBulkDownload(DirectoriesMixin, APITestCase): ENDPOINT = "/api/documents/bulk_download/" diff --git a/src/documents/views.py b/src/documents/views.py index d57ad4eea..b04b87243 100644 --- a/src/documents/views.py +++ b/src/documents/views.py @@ -54,6 +54,7 @@ from rest_framework.viewsets import ModelViewSet from rest_framework.viewsets import ReadOnlyModelViewSet from rest_framework.viewsets import ViewSet +from documents import bulk_edit from documents.filters import ObjectOwnedOrGrantedPermissionsFilter from documents.permissions import PaperlessAdminPermissions from documents.permissions import PaperlessObjectPermissions @@ -694,7 +695,7 @@ class SavedViewViewSet(ModelViewSet, PassUserMixin): serializer.save(owner=self.request.user) -class BulkEditView(GenericAPIView): +class BulkEditView(GenericAPIView, PassUserMixin): permission_classes = (IsAuthenticated,) serializer_class = BulkEditSerializer parser_classes = (parsers.JSONParser,) @@ -703,10 +704,25 @@ class BulkEditView(GenericAPIView): serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) + user = self.request.user method = serializer.validated_data.get("method") parameters = serializer.validated_data.get("parameters") documents = serializer.validated_data.get("documents") + if not user.is_superuser: + document_objs = Document.objects.filter(pk__in=documents) + has_perms = ( + all((doc.owner == user or doc.owner is None) for doc in document_objs) + if method == bulk_edit.set_permissions + else all( + has_perms_owner_aware(user, "change_document", doc) + for doc in document_objs + ) + ) + + if not has_perms: + return HttpResponseForbidden("Insufficient permissions") + try: # TODO: parameter validation result = method(documents, **parameters) diff --git a/src/paperless/checks.py b/src/paperless/checks.py index cda14baad..d3009d036 100644 --- a/src/paperless/checks.py +++ b/src/paperless/checks.py @@ -177,6 +177,23 @@ def settings_values_check(app_configs, **kwargs): ) return msgs + def _email_certificate_validate(): + msgs = [] + # Existence checks + if ( + settings.EMAIL_CERTIFICATE_FILE is not None + and not settings.EMAIL_CERTIFICATE_FILE.is_file() + ): + msgs.append( + Error( + f"Email cert {settings.EMAIL_CERTIFICATE_FILE} is not a file", + ), + ) + return msgs + return ( - _ocrmypdf_settings_check() + _timezone_validate() + _barcode_scanner_validate() + _ocrmypdf_settings_check() + + _timezone_validate() + + _barcode_scanner_validate() + + _email_certificate_validate() ) diff --git a/src/paperless/settings.py b/src/paperless/settings.py index b33d7fb7d..6b2ea56b2 100644 --- a/src/paperless/settings.py +++ b/src/paperless/settings.py @@ -67,11 +67,20 @@ def __get_float(key: str, default: float) -> float: return float(os.getenv(key, default)) -def __get_path(key: str, default: Union[PathLike, str]) -> Path: +def __get_path( + key: str, + default: Optional[Union[PathLike, str]] = None, +) -> Optional[Path]: """ - Return a normalized, absolute path based on the environment variable or a default + Return a normalized, absolute path based on the environment variable or a default, + if provided. If not set and no default, returns None """ - return Path(os.environ.get(key, default)).resolve() + if key in os.environ: + return Path(os.environ[key]).resolve() + elif default is not None: + return Path(default).resolve() + else: + return None def __get_list( @@ -364,6 +373,7 @@ CHANNEL_LAYERS = { "hosts": [_CHANNELS_REDIS_URL], "capacity": 2000, # default 100 "expiry": 15, # default 60 + "prefix": os.getenv("PAPERLESS_REDIS_PREFIX", ""), }, }, } @@ -476,6 +486,8 @@ CSRF_COOKIE_NAME = f"{COOKIE_PREFIX}csrftoken" SESSION_COOKIE_NAME = f"{COOKIE_PREFIX}sessionid" LANGUAGE_COOKIE_NAME = f"{COOKIE_PREFIX}django_language" +EMAIL_CERTIFICATE_FILE = __get_path("PAPERLESS_EMAIL_CERTIFICATE_FILE") + ############################################################################### # Database # @@ -679,6 +691,9 @@ CELERY_TASK_SEND_SENT_EVENT = True CELERY_SEND_TASK_SENT_EVENT = True CELERY_BROKER_CONNECTION_RETRY = True CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP = True +CELERY_BROKER_TRANSPORT_OPTIONS = { + "global_keyprefix": os.getenv("PAPERLESS_REDIS_PREFIX", ""), +} CELERY_TASK_TRACK_STARTED = True CELERY_TASK_TIME_LIMIT: Final[int] = __get_int("PAPERLESS_WORKER_TIMEOUT", 1800) diff --git a/src/paperless/tests/test_checks.py b/src/paperless/tests/test_checks.py index cd706c532..6aac1a4c6 100644 --- a/src/paperless/tests/test_checks.py +++ b/src/paperless/tests/test_checks.py @@ -1,9 +1,11 @@ import os +from pathlib import Path from django.test import TestCase from django.test import override_settings from documents.tests.utils import DirectoriesMixin +from documents.tests.utils import FileSystemAssertsMixin from paperless.checks import binaries_check from paperless.checks import debug_mode_check from paperless.checks import paths_check @@ -57,7 +59,7 @@ class TestChecks(DirectoriesMixin, TestCase): self.assertEqual(len(debug_mode_check(None)), 1) -class TestSettingsChecks(DirectoriesMixin, TestCase): +class TestSettingsChecksAgainstDefaults(DirectoriesMixin, TestCase): def test_all_valid(self): """ GIVEN: @@ -70,6 +72,8 @@ class TestSettingsChecks(DirectoriesMixin, TestCase): msgs = settings_values_check(None) self.assertEqual(len(msgs), 0) + +class TestOcrSettingsChecks(DirectoriesMixin, TestCase): @override_settings(OCR_OUTPUT_TYPE="notapdf") def test_invalid_output_type(self): """ @@ -160,6 +164,8 @@ class TestSettingsChecks(DirectoriesMixin, TestCase): self.assertIn('OCR clean mode "cleanme"', msg.msg) + +class TestTimezoneSettingsChecks(DirectoriesMixin, TestCase): @override_settings(TIME_ZONE="TheMoon\\MyCrater") def test_invalid_timezone(self): """ @@ -178,6 +184,8 @@ class TestSettingsChecks(DirectoriesMixin, TestCase): self.assertIn('Timezone "TheMoon\\MyCrater"', msg.msg) + +class TestBarcodeSettingsChecks(DirectoriesMixin, TestCase): @override_settings(CONSUMER_BARCODE_SCANNER="Invalid") def test_barcode_scanner_invalid(self): msgs = settings_values_check(None) @@ -200,3 +208,26 @@ class TestSettingsChecks(DirectoriesMixin, TestCase): def test_barcode_scanner_valid(self): msgs = settings_values_check(None) self.assertEqual(len(msgs), 0) + + +class TestEmailCertSettingsChecks(DirectoriesMixin, FileSystemAssertsMixin, TestCase): + @override_settings(EMAIL_CERTIFICATE_FILE=Path("/tmp/not_actually_here.pem")) + def test_not_valid_file(self): + """ + GIVEN: + - Default settings + - Email certificate is set + WHEN: + - Email certificate file doesn't exist + THEN: + - system check error reported for email certificate + """ + self.assertIsNotFile("/tmp/not_actually_here.pem") + + msgs = settings_values_check(None) + + self.assertEqual(len(msgs), 1) + + msg = msgs[0] + + self.assertIn("Email cert /tmp/not_actually_here.pem is not a file", msg.msg) diff --git a/src/paperless_mail/mail.py b/src/paperless_mail/mail.py index a0bda19ba..8b41ebacf 100644 --- a/src/paperless_mail/mail.py +++ b/src/paperless_mail/mail.py @@ -395,12 +395,16 @@ def get_mailbox(server, port, security) -> MailBox: """ Returns the correct MailBox instance for the given configuration. """ + ssl_context = ssl.create_default_context() + if settings.EMAIL_CERTIFICATE_FILE is not None: # pragma: nocover + ssl_context.load_verify_locations(cafile=settings.EMAIL_CERTIFICATE_FILE) + if security == MailAccount.ImapSecurity.NONE: mailbox = MailBoxUnencrypted(server, port) elif security == MailAccount.ImapSecurity.STARTTLS: - mailbox = MailBoxTls(server, port, ssl_context=ssl.create_default_context()) + mailbox = MailBoxTls(server, port, ssl_context=ssl_context) elif security == MailAccount.ImapSecurity.SSL: - mailbox = MailBox(server, port, ssl_context=ssl.create_default_context()) + mailbox = MailBox(server, port, ssl_context=ssl_context) else: raise NotImplementedError("Unknown IMAP security") # pragma: nocover return mailbox diff --git a/src/paperless_mail/parsers.py b/src/paperless_mail/parsers.py index 4365d21a4..da9259a69 100644 --- a/src/paperless_mail/parsers.py +++ b/src/paperless_mail/parsers.py @@ -215,7 +215,11 @@ class MailDocumentParser(DocumentParser): file_multi_part[2], ) - response = httpx.post(url_merge, files=pdf_collection, timeout=30.0) + response = httpx.post( + url_merge, + files=pdf_collection, + timeout=settings.CELERY_TASK_TIME_LIMIT, + ) response.raise_for_status() # ensure we notice bad responses archive_path.write_bytes(response.content) @@ -330,7 +334,7 @@ class MailDocumentParser(DocumentParser): files=files, headers=headers, data=data, - timeout=30.0, + timeout=settings.CELERY_TASK_TIME_LIMIT, ) response.raise_for_status() # ensure we notice bad responses except Exception as err: @@ -409,7 +413,12 @@ class MailDocumentParser(DocumentParser): file_multi_part[2], ) - response = httpx.post(url, files=files, data=data, timeout=30.0) + response = httpx.post( + url, + files=files, + data=data, + timeout=settings.CELERY_TASK_TIME_LIMIT, + ) response.raise_for_status() # ensure we notice bad responses except Exception as err: raise ParseError(f"Error while converting document to PDF: {err}") from err diff --git a/src/paperless_tika/parsers.py b/src/paperless_tika/parsers.py index b6a9dd621..402a37215 100644 --- a/src/paperless_tika/parsers.py +++ b/src/paperless_tika/parsers.py @@ -100,7 +100,7 @@ class TikaDocumentParser(DocumentParser): files=files, headers=headers, data=data, - timeout=30.0, + timeout=settings.CELERY_TASK_TIME_LIMIT, ) response.raise_for_status() # ensure we notice bad responses except Exception as err: