mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-04-02 13:45:10 -05:00
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>
This commit is contained in:
parent
3a36d9b1ae
commit
7c9ab8c0b6
@ -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.
|
||||
|
@ -319,7 +319,7 @@
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
||||
<context context-type="linenumber">55</context>
|
||||
<context context-type="linenumber">65</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="1241348629231510663" datatype="html">
|
||||
@ -1073,7 +1073,7 @@
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
||||
<context context-type="linenumber">198</context>
|
||||
<context context-type="linenumber">208</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/save-view-config-dialog/save-view-config-dialog.component.html</context>
|
||||
@ -1142,7 +1142,7 @@
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
||||
<context context-type="linenumber">182</context>
|
||||
<context context-type="linenumber">192</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
|
||||
@ -1549,6 +1549,10 @@
|
||||
<context context-type="sourcefile">src/app/components/common/permissions-select/permissions-select.component.html</context>
|
||||
<context context-type="linenumber">9</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/share-links-dropdown/share-links-dropdown.component.html</context>
|
||||
<context context-type="linenumber">33</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
||||
<context context-type="linenumber">11</context>
|
||||
@ -2240,6 +2244,135 @@
|
||||
<context context-type="linenumber">20</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="686374493515618129" datatype="html">
|
||||
<source>Share Links</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/share-links-dropdown/share-links-dropdown.component.html</context>
|
||||
<context context-type="linenumber">6</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/share-links-dropdown/share-links-dropdown.component.ts</context>
|
||||
<context context-type="linenumber">25</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="6617773613987957957" datatype="html">
|
||||
<source> No existing links </source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/share-links-dropdown/share-links-dropdown.component.html</context>
|
||||
<context context-type="linenumber">10,12</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="4323470180912194028" datatype="html">
|
||||
<source>Copy</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/share-links-dropdown/share-links-dropdown.component.html</context>
|
||||
<context context-type="linenumber">23</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="7419704019640008953" datatype="html">
|
||||
<source>Share</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/share-links-dropdown/share-links-dropdown.component.html</context>
|
||||
<context context-type="linenumber">28</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="5392341774767336507" datatype="html">
|
||||
<source>Copied!</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/share-links-dropdown/share-links-dropdown.component.html</context>
|
||||
<context context-type="linenumber">36</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="6811921365829755679" datatype="html">
|
||||
<source>Share archive version</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/share-links-dropdown/share-links-dropdown.component.html</context>
|
||||
<context context-type="linenumber">42</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="5674286808255988565" datatype="html">
|
||||
<source>Create</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/share-links-dropdown/share-links-dropdown.component.html</context>
|
||||
<context context-type="linenumber">55</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context>
|
||||
<context context-type="linenumber">2</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context>
|
||||
<context context-type="linenumber">2</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context>
|
||||
<context context-type="linenumber">2</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context>
|
||||
<context context-type="linenumber">2</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="4776429682428363094" datatype="html">
|
||||
<source>1 day</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/share-links-dropdown/share-links-dropdown.component.ts</context>
|
||||
<context context-type="linenumber">18</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/share-links-dropdown/share-links-dropdown.component.ts</context>
|
||||
<context context-type="linenumber">85</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="8542568275115626925" datatype="html">
|
||||
<source>7 days</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/share-links-dropdown/share-links-dropdown.component.ts</context>
|
||||
<context context-type="linenumber">19</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="7152095234138763013" datatype="html">
|
||||
<source>30 days</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/share-links-dropdown/share-links-dropdown.component.ts</context>
|
||||
<context context-type="linenumber">20</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="8372007266188249803" datatype="html">
|
||||
<source>Never</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/share-links-dropdown/share-links-dropdown.component.ts</context>
|
||||
<context context-type="linenumber">21</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="3429210839568770054" datatype="html">
|
||||
<source>Error retrieving links</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/share-links-dropdown/share-links-dropdown.component.ts</context>
|
||||
<context context-type="linenumber">69</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="3242255798983858463" datatype="html">
|
||||
<source><x id="PH" equiv-text="days"/> days</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/share-links-dropdown/share-links-dropdown.component.ts</context>
|
||||
<context context-type="linenumber">85</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="2897042887615940599" datatype="html">
|
||||
<source>Error deleting link</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/share-links-dropdown/share-links-dropdown.component.ts</context>
|
||||
<context context-type="linenumber">112</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="8400747326190565173" datatype="html">
|
||||
<source>Error creating link</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/common/share-links-dropdown/share-links-dropdown.component.ts</context>
|
||||
<context context-type="linenumber">140</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="5611592591303869712" datatype="html">
|
||||
<source>Status</source>
|
||||
<context-group purpose="location">
|
||||
@ -2310,7 +2443,7 @@
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
||||
<context context-type="linenumber">75</context>
|
||||
<context context-type="linenumber">85</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
|
||||
@ -2340,7 +2473,7 @@
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
||||
<context context-type="linenumber">19</context>
|
||||
<context context-type="linenumber">18</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
|
||||
@ -2538,14 +2671,65 @@
|
||||
<source>Download original</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
||||
<context context-type="linenumber">25</context>
|
||||
<context context-type="linenumber">24</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="3193976279273491157" datatype="html">
|
||||
<source>Actions</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
||||
<context context-type="linenumber">34</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
|
||||
<context context-type="linenumber">86</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context>
|
||||
<context context-type="linenumber">23</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context>
|
||||
<context context-type="linenumber">23</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context>
|
||||
<context context-type="linenumber">23</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context>
|
||||
<context context-type="linenumber">23</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.html</context>
|
||||
<context context-type="linenumber">221</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.html</context>
|
||||
<context context-type="linenumber">259</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.html</context>
|
||||
<context context-type="linenumber">296</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.html</context>
|
||||
<context context-type="linenumber">347</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.html</context>
|
||||
<context context-type="linenumber">382</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/manage/tasks/tasks.component.html</context>
|
||||
<context context-type="linenumber">44</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="8312409092917397847" datatype="html">
|
||||
<source>Redo OCR</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
||||
<context context-type="linenumber">34</context>
|
||||
<context context-type="linenumber">40</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
|
||||
@ -2556,7 +2740,7 @@
|
||||
<source>More like this</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
||||
<context context-type="linenumber">40</context>
|
||||
<context context-type="linenumber">46</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/document-card-large/document-card-large.component.html</context>
|
||||
@ -2567,7 +2751,7 @@
|
||||
<source>Close</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
||||
<context context-type="linenumber">43</context>
|
||||
<context context-type="linenumber">53</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/guards/dirty-saved-view.guard.ts</context>
|
||||
@ -2578,35 +2762,35 @@
|
||||
<source>Previous</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
||||
<context context-type="linenumber">50</context>
|
||||
<context context-type="linenumber">60</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="5028777105388019087" datatype="html">
|
||||
<source>Details</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
||||
<context context-type="linenumber">72</context>
|
||||
<context context-type="linenumber">82</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="1379170675585571971" datatype="html">
|
||||
<source>Archive serial number</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
||||
<context context-type="linenumber">76</context>
|
||||
<context context-type="linenumber">86</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="5114742157723900905" datatype="html">
|
||||
<source>Date created</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
||||
<context context-type="linenumber">77</context>
|
||||
<context context-type="linenumber">87</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="2691296884221415710" datatype="html">
|
||||
<source>Correspondent</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
||||
<context context-type="linenumber">79</context>
|
||||
<context context-type="linenumber">89</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
|
||||
@ -2629,7 +2813,7 @@
|
||||
<source>Document type</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
||||
<context context-type="linenumber">81</context>
|
||||
<context context-type="linenumber">91</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
|
||||
@ -2652,7 +2836,7 @@
|
||||
<source>Storage path</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
||||
<context context-type="linenumber">83</context>
|
||||
<context context-type="linenumber">93</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
|
||||
@ -2671,21 +2855,21 @@
|
||||
<source>Default</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
||||
<context context-type="linenumber">84</context>
|
||||
<context context-type="linenumber">94</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="6205355627445317276" datatype="html">
|
||||
<source>Content</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
||||
<context context-type="linenumber">91</context>
|
||||
<context context-type="linenumber">101</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="218403386307979629" datatype="html">
|
||||
<source>Metadata</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
||||
<context context-type="linenumber">100</context>
|
||||
<context context-type="linenumber">110</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/metadata-collapse/metadata-collapse.component.ts</context>
|
||||
@ -2696,173 +2880,173 @@
|
||||
<source>Date modified</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
||||
<context context-type="linenumber">106</context>
|
||||
<context context-type="linenumber">116</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="6392918669949841614" datatype="html">
|
||||
<source>Date added</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
||||
<context context-type="linenumber">110</context>
|
||||
<context context-type="linenumber">120</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="146828917013192897" datatype="html">
|
||||
<source>Media filename</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
||||
<context context-type="linenumber">114</context>
|
||||
<context context-type="linenumber">124</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="4500855521601039868" datatype="html">
|
||||
<source>Original filename</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
||||
<context context-type="linenumber">118</context>
|
||||
<context context-type="linenumber">128</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="7985558498848210210" datatype="html">
|
||||
<source>Original MD5 checksum</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
||||
<context context-type="linenumber">122</context>
|
||||
<context context-type="linenumber">132</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="5888243105821763422" datatype="html">
|
||||
<source>Original file size</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
||||
<context context-type="linenumber">126</context>
|
||||
<context context-type="linenumber">136</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="2696647325713149563" datatype="html">
|
||||
<source>Original mime type</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
||||
<context context-type="linenumber">130</context>
|
||||
<context context-type="linenumber">140</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="342875990758166588" datatype="html">
|
||||
<source>Archive MD5 checksum</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
||||
<context context-type="linenumber">134</context>
|
||||
<context context-type="linenumber">144</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="6033581412811562084" datatype="html">
|
||||
<source>Archive file size</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
||||
<context context-type="linenumber">138</context>
|
||||
<context context-type="linenumber">148</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="6992781481378431874" datatype="html">
|
||||
<source>Original document metadata</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
||||
<context context-type="linenumber">144</context>
|
||||
<context context-type="linenumber">154</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="2846565152091361585" datatype="html">
|
||||
<source>Archived document metadata</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
||||
<context context-type="linenumber">145</context>
|
||||
<context context-type="linenumber">155</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="1295614462098694869" datatype="html">
|
||||
<source>Preview</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
||||
<context context-type="linenumber">151</context>
|
||||
<context context-type="linenumber">161</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="8191371354890763172" datatype="html">
|
||||
<source>Enter Password</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
||||
<context context-type="linenumber">167</context>
|
||||
<context context-type="linenumber">177</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
||||
<context context-type="linenumber">218</context>
|
||||
<context context-type="linenumber">228</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="8460995830263484763" datatype="html">
|
||||
<source>Notes <x id="START_TAG_SPAN" ctype="x-span" equiv-text="<span *ngIf="document?.notes.length" class="badge text-bg-secondary ms-1">"/><x id="INTERPOLATION" equiv-text="ngth}}"/><x id="CLOSE_TAG_SPAN" ctype="x-span" equiv-text="</a>"/></source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
||||
<context context-type="linenumber">175,176</context>
|
||||
<context context-type="linenumber">185,186</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="3823219296477075982" datatype="html">
|
||||
<source>Discard</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
||||
<context context-type="linenumber">194</context>
|
||||
<context context-type="linenumber">204</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="5129524307369213584" datatype="html">
|
||||
<source>Save & next</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
||||
<context context-type="linenumber">196</context>
|
||||
<context context-type="linenumber">206</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="4910102545766233758" datatype="html">
|
||||
<source>Save & close</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
|
||||
<context context-type="linenumber">197</context>
|
||||
<context context-type="linenumber">207</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="2218903673684131427" datatype="html">
|
||||
<source>An error occurred loading content: <x id="PH" equiv-text="err.message ?? err.toString()"/></source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">252,254</context>
|
||||
<context context-type="linenumber">253,255</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="5758784066858623886" datatype="html">
|
||||
<source>Error retrieving metadata</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">397</context>
|
||||
<context context-type="linenumber">398</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="3456881259945295697" datatype="html">
|
||||
<source>Error retrieving suggestions.</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">418</context>
|
||||
<context context-type="linenumber">419</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="8348337312757497317" datatype="html">
|
||||
<source>Document saved successfully.</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">531</context>
|
||||
<context context-type="linenumber">532</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">539</context>
|
||||
<context context-type="linenumber">540</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="448882439049417053" datatype="html">
|
||||
<source>Error saving document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">543</context>
|
||||
<context context-type="linenumber">544</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">584</context>
|
||||
<context context-type="linenumber">585</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="9021887951960049161" datatype="html">
|
||||
<source>Confirm delete</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">610</context>
|
||||
<context context-type="linenumber">611</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context>
|
||||
@ -2873,35 +3057,35 @@
|
||||
<source>Do you really want to delete document "<x id="PH" equiv-text="this.document.title"/>"?</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">611</context>
|
||||
<context context-type="linenumber">612</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="6691075929777935948" datatype="html">
|
||||
<source>The files for this document will be deleted permanently. This operation cannot be undone.</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">612</context>
|
||||
<context context-type="linenumber">613</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="719892092227206532" datatype="html">
|
||||
<source>Delete document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">614</context>
|
||||
<context context-type="linenumber">615</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="7295637485862454066" datatype="html">
|
||||
<source>Error deleting document</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">633</context>
|
||||
<context context-type="linenumber">634</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="7362691899087997122" datatype="html">
|
||||
<source>Redo OCR confirm</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">653</context>
|
||||
<context context-type="linenumber">654</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||
@ -2912,14 +3096,14 @@
|
||||
<source>This operation will permanently redo OCR for this document.</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">654</context>
|
||||
<context context-type="linenumber">655</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="5641451190833696892" datatype="html">
|
||||
<source>This operation cannot be undone.</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">655</context>
|
||||
<context context-type="linenumber">656</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||
@ -2950,7 +3134,7 @@
|
||||
<source>Proceed</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">657</context>
|
||||
<context context-type="linenumber">658</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
|
||||
@ -2977,14 +3161,14 @@
|
||||
<source>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.</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">665</context>
|
||||
<context context-type="linenumber">666</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="4409560272830824468" datatype="html">
|
||||
<source>Error executing operation</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
|
||||
<context context-type="linenumber">676</context>
|
||||
<context context-type="linenumber">677</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="6857598786757174736" datatype="html">
|
||||
@ -3045,53 +3229,6 @@
|
||||
<context context-type="linenumber">52</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="3193976279273491157" datatype="html">
|
||||
<source>Actions</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.html</context>
|
||||
<context context-type="linenumber">86</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context>
|
||||
<context context-type="linenumber">23</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context>
|
||||
<context context-type="linenumber">23</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context>
|
||||
<context context-type="linenumber">23</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context>
|
||||
<context context-type="linenumber">23</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.html</context>
|
||||
<context context-type="linenumber">221</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.html</context>
|
||||
<context context-type="linenumber">259</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.html</context>
|
||||
<context context-type="linenumber">296</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.html</context>
|
||||
<context context-type="linenumber">347</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/manage/settings/settings.component.html</context>
|
||||
<context context-type="linenumber">382</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/manage/tasks/tasks.component.html</context>
|
||||
<context context-type="linenumber">44</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="1015374532025907183" datatype="html">
|
||||
<source>Include:</source>
|
||||
<context-group purpose="location">
|
||||
@ -3945,25 +4082,6 @@
|
||||
<context context-type="linenumber">44</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="5674286808255988565" datatype="html">
|
||||
<source>Create</source>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context>
|
||||
<context context-type="linenumber">2</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context>
|
||||
<context context-type="linenumber">2</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context>
|
||||
<context context-type="linenumber">2</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context>
|
||||
<context context-type="linenumber">2</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="4010735610815226758" datatype="html">
|
||||
<source>Filter by:</source>
|
||||
<context-group purpose="location">
|
||||
|
29
src-ui/package-lock.json
generated
29
src-ui/package-lock.json
generated
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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 = <
|
||||
|
@ -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,
|
||||
|
@ -1,7 +1,6 @@
|
||||
import {
|
||||
ComponentFixture,
|
||||
TestBed,
|
||||
discardPeriodicTasks,
|
||||
fakeAsync,
|
||||
tick,
|
||||
} from '@angular/core/testing'
|
||||
|
@ -0,0 +1,61 @@
|
||||
<div ngbDropdown>
|
||||
<button class="btn btn-sm btn-outline-primary me-2" id="shareLinksDropdown" [disabled]="disabled" ngbDropdownToggle>
|
||||
<svg class="toolbaricon" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#link" />
|
||||
</svg>
|
||||
<div class="d-none d-sm-inline"> <ng-container i18n>Share Links</ng-container></div>
|
||||
</button>
|
||||
<div ngbDropdownMenu aria-labelledby="shareLinksDropdown" class="shadow share-links-dropdown">
|
||||
<ul class="list-group list-group-flush">
|
||||
<li *ngIf="!shareLinks || shareLinks.length === 0" class="list-group-item fst-italic small text-center text-secondary" i18n>
|
||||
No existing links
|
||||
</li>
|
||||
<li class="list-group-item" *ngFor="let link of shareLinks">
|
||||
<div class="input-group input-group-sm w-100">
|
||||
<input type="text" class="form-control" aria-label="Share link" [value]="getShareUrl(link)" readonly>
|
||||
<span *ngIf="link.expiration" class="input-group-text">
|
||||
{{ getDaysRemaining(link) }}
|
||||
</span>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" (click)="copy(link)">
|
||||
<svg class="buttonicon" fill="currentColor">
|
||||
<use *ngIf="copied !== link.id" xlink:href="assets/bootstrap-icons.svg#clipboard-fill" />
|
||||
<use *ngIf="copied === link.id" xlink:href="assets/bootstrap-icons.svg#clipboard-check-fill" />
|
||||
</svg><span class="visually-hidden" i18n>Copy</span>
|
||||
</button>
|
||||
<button *ngIf="canShare(link)" type="button" class="btn btn-sm btn-outline-primary" (click)="share(link)">
|
||||
<svg class="buttonicon" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#box-arrow-up" />
|
||||
</svg><span class="visually-hidden" i18n>Share</span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger" (click)="delete(link)">
|
||||
<svg class="buttonicon" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#trash" />
|
||||
</svg><span class="visually-hidden" i18n>Delete</span>
|
||||
</button>
|
||||
</div>
|
||||
<span class="badge copied-badge bg-primary small fade ms-4 position-absolute top-50 translate-middle-y pe-none z-3" [class.show]="copied === link.id" i18n>Copied!</span>
|
||||
</li>
|
||||
<li class="list-group-item pt-3 pb-2">
|
||||
<div class="input-group input-group-sm w-100">
|
||||
<div class="form-check form-switch ms-auto">
|
||||
<input class="form-check-input" type="checkbox" role="switch" id="versionSwitch" [(ngModel)]="archiveVersion">
|
||||
<label class="form-check-label small" for="versionSwitch" i18n>Share archive version</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-group input-group-sm w-100 mt-2">
|
||||
<label class="input-group-text" for="addLink">Expires:</label>
|
||||
<select class="form-select form-select-sm" [(ngModel)]="expirationDays">
|
||||
<option *ngFor="let option of EXPIRATION_OPTIONS" [ngValue]="option.value">{{ option.label }}</option>
|
||||
</select>
|
||||
<button class="btn btn-sm btn-outline-primary ms-auto" type="button" (click)="createLink()" [disabled]="loading">
|
||||
<div *ngIf="loading" class="spinner-border spinner-border-sm me-2" role="status"></div>
|
||||
<svg *ngIf="!loading" class="buttonicon me-1" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#plus" />
|
||||
</svg>
|
||||
<ng-container i18n>Create</ng-container>
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
@ -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;
|
||||
}
|
@ -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<ShareLinksDropdownComponent>
|
||||
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) })
|
||||
})
|
||||
})
|
@ -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)
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
@ -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<ToastsComponent>
|
||||
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()
|
||||
|
||||
|
@ -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
|
||||
|
@ -5,16 +5,15 @@
|
||||
<div class="input-group-text" i18n>of {{previewNumPages}}</div>
|
||||
</div>
|
||||
|
||||
<button type="button" class="btn btn-sm btn-outline-danger me-2 ms-auto" (click)="delete()" [disabled]="!userIsOwner" *appIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Document }">
|
||||
<button type="button" class="btn btn-sm btn-outline-danger me-4" (click)="delete()" [disabled]="!userIsOwner" *appIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Document }">
|
||||
<svg class="buttonicon" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#trash" />
|
||||
</svg><span class="d-none d-lg-inline ps-1" i18n>Delete</span>
|
||||
</button>
|
||||
|
||||
<div class="btn-group me-2">
|
||||
|
||||
<a [href]="downloadUrl" class="btn btn-sm btn-outline-primary">
|
||||
<svg class="buttonicon" fill="currentColor">
|
||||
<svg class="buttonicon me-md-1" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#download" />
|
||||
</svg><span class="d-none d-lg-inline ps-1" i18n>Download</span>
|
||||
</a>
|
||||
@ -25,20 +24,31 @@
|
||||
<a ngbDropdownItem [href]="downloadOriginalUrl" i18n>Download original</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<button type="button" class="btn btn-sm btn-outline-primary me-2" (click)="redoOcr()" [disabled]="!userCanEdit">
|
||||
<svg class="buttonicon" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#arrow-counterclockwise" />
|
||||
</svg><span class="d-none d-lg-inline ps-1" i18n>Redo OCR</span>
|
||||
</button>
|
||||
<div ngbDropdown>
|
||||
<button class="btn btn-sm btn-outline-primary me-2" id="actionsDropdown" ngbDropdownToggle>
|
||||
<svg class="toolbaricon" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#three-dots" />
|
||||
</svg>
|
||||
<div class="d-none d-sm-inline"> <ng-container i18n>Actions</ng-container></div>
|
||||
</button>
|
||||
<div ngbDropdownMenu aria-labelledby="actionsDropdown" class="shadow">
|
||||
<button ngbDropdownItem (click)="redoOcr()" [disabled]="!userCanEdit">
|
||||
<svg class="buttonicon-sm" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#arrow-counterclockwise" />
|
||||
</svg><span class="ps-1" i18n>Redo OCR</span>
|
||||
</button>
|
||||
|
||||
<button type="button" class="btn btn-sm btn-outline-primary me-2" (click)="moreLike()">
|
||||
<svg class="buttonicon" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#diagram-3" />
|
||||
</svg><span class="d-none d-lg-inline ps-1" i18n>More like this</span>
|
||||
</button>
|
||||
<button ngbDropdownItem (click)="moreLike()">
|
||||
<svg class="buttonicon-sm" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#diagram-3" />
|
||||
</svg><span class="ps-1" i18n>More like this</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<app-share-links-dropdown [documentId]="documentId" [disabled]="!userIsOwner" *appIfPermissions="{ action: PermissionAction.Add, type: PermissionType.ShareLink }"></app-share-links-dropdown>
|
||||
|
||||
<button type="button" class="btn btn-sm btn-outline-primary me-2" i18n-title title="Close" (click)="close()">
|
||||
<svg class="buttonicon" fill="currentColor">
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
18
src-ui/src/app/data/paperless-share-link.ts
Normal file
18
src-ui/src/app/data/paperless-share-link.ts
Normal file
@ -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
|
||||
}
|
@ -248,6 +248,10 @@ describe('PermissionsService', () => {
|
||||
'view_log',
|
||||
'view_comment',
|
||||
'change_frontendsettings',
|
||||
'add_sharelink',
|
||||
'view_sharelink',
|
||||
'change_sharelink',
|
||||
'delete_sharelink',
|
||||
],
|
||||
{
|
||||
username: 'testuser',
|
||||
|
@ -24,6 +24,7 @@ export enum PermissionType {
|
||||
User = '%s_user',
|
||||
Group = '%s_group',
|
||||
Admin = '%s_logentry',
|
||||
ShareLink = '%s_sharelink',
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
|
@ -10,7 +10,7 @@ export abstract class AbstractPaperlessService<T extends ObjectWithId> {
|
||||
|
||||
constructor(
|
||||
protected http: HttpClient,
|
||||
private resourceName: string
|
||||
protected resourceName: string
|
||||
) {}
|
||||
|
||||
protected getResourceUrl(id: number = null, action: string = null): string {
|
||||
|
42
src-ui/src/app/services/rest/share-link.service.spec.ts
Normal file
42
src-ui/src/app/services/rest/share-link.service.spec.ts
Normal file
@ -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({})
|
||||
})
|
||||
})
|
36
src-ui/src/app/services/rest/share-link.service.ts
Normal file
36
src-ui/src/app/services/rest/share-link.service.ts
Normal file
@ -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<PaperlessShareLink> {
|
||||
constructor(http: HttpClient) {
|
||||
super(http, 'share_links')
|
||||
}
|
||||
|
||||
getLinksForDocument(documentId: number): Observable<PaperlessShareLink[]> {
|
||||
return this.http.get<PaperlessShareLink[]>(
|
||||
`${this.baseUrl}documents/${documentId}/${this.resourceName}/`
|
||||
)
|
||||
}
|
||||
|
||||
createLinkForDocument(
|
||||
documentId: number,
|
||||
file_version: PaperlessFileVersion = PaperlessFileVersion.Archive,
|
||||
expiration: Date = null
|
||||
) {
|
||||
this.clearCache()
|
||||
return this.http.post<PaperlessShareLink>(this.getResourceUrl(), {
|
||||
document: documentId,
|
||||
file_version,
|
||||
expiration: expiration?.toISOString(),
|
||||
})
|
||||
}
|
||||
}
|
@ -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)
|
||||
|
@ -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
|
||||
|
126
src/documents/migrations/1038_sharelink.py
Normal file
126
src/documents/migrations/1038_sharelink.py
Normal file
@ -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),
|
||||
]
|
@ -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}"
|
||||
|
@ -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)
|
||||
|
@ -8,7 +8,7 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<meta name="description" content="Paperless-ngx Signed Out">
|
||||
<meta name="author" content="The Paperless-ngx Team">
|
||||
<meta name="author" content="Paperless-ngx project and contributors">
|
||||
<meta name="robots" content="noindex,nofollow">
|
||||
|
||||
<title>{% translate "Paperless-ngx signed out" %}</title>
|
||||
|
@ -8,7 +8,7 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<meta name="description" content="Paperless-ngx Sign In">
|
||||
<meta name="author" content="The Paperless-ngx Team">
|
||||
<meta name="author" content="Paperless-ngx project and contributors">
|
||||
<meta name="robots" content="noindex,nofollow">
|
||||
|
||||
<title>{% translate "Paperless-ngx sign in" %}</title>
|
||||
@ -40,9 +40,17 @@
|
||||
</svg>
|
||||
<p>{% translate "Please sign in." %}</p>
|
||||
{% if form.errors %}
|
||||
<div class="alert alert-danger" role="alert">
|
||||
{% translate "Your username and password didn't match. Please try again." %}
|
||||
</div>
|
||||
<div class="alert alert-danger" role="alert">
|
||||
{% translate "Your username and password didn't match. Please try again." %}
|
||||
</div>
|
||||
{% elif request.GET.sharelink_notfound %}
|
||||
<div class="alert alert-danger" role="alert">
|
||||
{% translate "Share link was not found." %}
|
||||
</div>
|
||||
{% elif request.GET.sharelink_expired %}
|
||||
<div class="alert alert-danger" role="alert">
|
||||
{% translate "Share link has expired." %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% translate "Username" as i18n_username %}
|
||||
{% translate "Password" as i18n_password %}
|
||||
|
@ -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):
|
||||
|
@ -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)
|
||||
|
@ -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")
|
||||
|
@ -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
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -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<slug>\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"),
|
||||
]
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user