Compare commits

...

71 Commits

Author SHA1 Message Date
Sascha Ißbrücker
5b2a2c2b0d Bump version 2025-03-06 19:39:51 +01:00
Sascha Ißbrücker
988468f3e5 fix path context 2025-03-06 19:35:35 +01:00
Sascha Ißbrücker
3ac0503843 Add documentation for archiving web pages (#1004) 2025-03-06 19:13:35 +01:00
Sascha Ißbrücker
6d3755f46a Update version 2025-03-06 17:36:10 +01:00
Sascha Ißbrücker
25342e5fb6 Update version 2025-03-06 17:32:02 +01:00
Sascha Ißbrücker
be548a95a0 Update build job 2025-03-06 17:31:14 +01:00
Sascha Ißbrücker
978fba4cf5 Update build job 2025-03-06 09:48:04 +01:00
Sascha Ißbrücker
8a3572ba4b Add bookmark assets API (#1003)
* Add list, details and download endpoints

* Avoid using multiple DefaultRoute instances

* Add upload endpoint

* Add docs

* Allow configuring max request content length

* Add option for disabling uploads

* Remove gzip field

* Add delete endpoint
2025-03-06 09:09:53 +01:00
Sascha Ißbrücker
b21812c30a Update build job 2025-03-06 09:09:16 +01:00
Nick Sartor
72fbf6a590 Add linklater to community section (#1002)
Adding android client linklater to community projects md. Fixes #741
2025-03-04 11:33:56 +01:00
jvt
31ac796d6d Add Telegram bot to community section (#1001)
add my telegram bot project to send bookmarks directly to Linkding.
2025-03-04 11:31:45 +01:00
Sascha Ißbrücker
2d81ea6f6e Add REST endpoint for uploading snapshots from the Singlefile extension (#996)
* Extract asset logic

* Allow disabling HTML snapshot when creating bookmark

* Add endpoint for uploading singlefile snapshots

* Add URL parameter to disable HTML snapshots

* Allow using asset list in base Docker image

* Expose app version through profile
2025-02-23 22:58:14 +01:00
Sascha Ißbrücker
2e97b13bad Allow providing REST API authentication token with Bearer keyword (#995) 2025-02-22 19:59:53 +01:00
Sascha Ißbrücker
30f85103cd Update CHANGELOG.md 2025-02-22 19:51:00 +01:00
Sascha Ißbrücker
cfe4ff113d Bump version 2025-02-22 19:28:47 +01:00
Sascha Ißbrücker
757dc56277 Bump base images 2025-02-19 16:14:34 +01:00
Sascha Ißbrücker
dfbb367857 Fix auth proxy logout (#994) 2025-02-19 07:27:04 +01:00
Sascha Ißbrücker
2276832465 Return web archive fallback URL from REST API (#993) 2025-02-19 06:44:21 +01:00
Chris M
9d61bdce52 Add note about OIDC and LD_SUPERUSER_NAME combination (#992)
* docs: add note about OIDC and LD_SUPERUSER_NAME combination

Resolves #988

* tweak text

---------

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@gmail.com>
2025-02-18 22:45:26 +01:00
Sascha Ißbrücker
1274a9ae0a Try limit uwsgi memory usage by configuring file descriptor limit (#990) 2025-02-15 08:49:58 +01:00
Sascha Ißbrücker
5e7172d17e Remove preview image when bookmark is deleted (#989) 2025-02-15 08:26:58 +01:00
Sascha Ißbrücker
78608135d9 Update CHANGELOG.md 2025-02-09 10:47:02 +01:00
Sascha Ißbrücker
51acd1da3f add build script 2025-02-08 18:20:15 +01:00
Sascha Ißbrücker
016ff2da66 Bump version 2025-02-08 10:54:46 +01:00
Sascha Ißbrücker
77d7e6e66a Add RSS link to shared bookmarks page (#984) 2025-02-08 10:51:17 +01:00
Sascha Ißbrücker
c5a300a435 Convert tag modal into drawer (#977)
* change tag modal into drawer

* improve scroll handling

* teleport all side bar content

* improve naming

* fix focus trap in filter drawer
2025-02-02 20:42:28 +01:00
Sascha Ißbrücker
0d4c47eb81 Add option to collapse side panel (#975) 2025-02-02 11:28:35 +01:00
Sascha Ißbrücker
17442eeb9a Improve accessibility of modal dialogs (#974)
* improve details modal accessibility

* improve tag modal accessibility

* fix overlays in archive and shared pages

* update tests

* use buttons for closing dialogs

* replace description list

* hide preview image from screen readers

* update tests
2025-02-02 00:28:17 +01:00
Kyuuk
2973812626 Allow customizing username when creating user through OIDC (#971)
* add ability to cutomize claim user for username generation on oidc login

* update documentation with new OIDC options

* oidc: also normalize custom claim as username

* improve tests

* improve docs

* some more cleanup

---------

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@gmail.com>
2025-01-30 03:40:52 +01:00
Josh Dick
fc48b266a8 Add Additional iOS Shortcut to community section (#968) 2025-01-28 13:29:44 +01:00
Sascha Ißbrücker
7b42241026 Fix nav menu closing on mousedown in Safari (#965) 2025-01-27 09:05:19 +01:00
Sascha Ißbrücker
9c648dc67f Update CHANGELOG.md 2025-01-27 00:02:14 +01:00
Sascha Ißbrücker
1624128132 Bump versions to make Docker build 2025-01-26 23:52:36 +01:00
Sascha Ißbrücker
d1dd85538b Bump version 2025-01-26 19:19:48 +01:00
dependabot[bot]
c5aab3886e Bump nanoid from 3.3.7 to 3.3.8 in /docs (#962)
Bumps [nanoid](https://github.com/ai/nanoid) from 3.3.7 to 3.3.8.
- [Release notes](https://github.com/ai/nanoid/releases)
- [Changelog](https://github.com/ai/nanoid/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ai/nanoid/compare/3.3.7...3.3.8)

---
updated-dependencies:
- dependency-name: nanoid
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-26 19:09:02 +01:00
dependabot[bot]
3f2739e5a6 Bump astro from 4.16.3 to 4.16.18 in /docs (#929)
Bumps [astro](https://github.com/withastro/astro/tree/HEAD/packages/astro) from 4.16.3 to 4.16.18.
- [Release notes](https://github.com/withastro/astro/releases)
- [Changelog](https://github.com/withastro/astro/blob/astro@4.16.18/packages/astro/CHANGELOG.md)
- [Commits](https://github.com/withastro/astro/commits/astro@4.16.18/packages/astro)

---
updated-dependencies:
- dependency-name: astro
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-26 19:08:50 +01:00
dependabot[bot]
f1ed89a0ba Bump nanoid from 3.3.7 to 3.3.8 (#928)
Bumps [nanoid](https://github.com/ai/nanoid) from 3.3.7 to 3.3.8.
- [Release notes](https://github.com/ai/nanoid/releases)
- [Changelog](https://github.com/ai/nanoid/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ai/nanoid/compare/3.3.7...3.3.8)

---
updated-dependencies:
- dependency-name: nanoid
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-26 18:51:40 +01:00
dependabot[bot]
a59a7a777c Bump django from 5.1.1 to 5.1.5 (#947)
Bumps [django](https://github.com/django/django) from 5.1.1 to 5.1.5.
- [Commits](https://github.com/django/django/compare/5.1.1...5.1.5)

---
updated-dependencies:
- dependency-name: django
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-26 18:50:51 +01:00
dependabot[bot]
9a5c535872 Bump vite from 5.4.9 to 5.4.14 in /docs (#953)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.4.9 to 5.4.14.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v5.4.14/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.4.14/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-26 18:50:34 +01:00
Sascha Ißbrücker
e6ebca1436 Add default robots.txt to block crawlers (#959) 2025-01-26 09:58:58 +01:00
Justus Grunow
085d67e9f4 Update community.md (#897)
Added link to iOS App Store for Linkdy.

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@googlemail.com>
2025-01-15 22:10:16 +01:00
Rostislav
68825444fb Add a rust client library to community.md (#914)
Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@googlemail.com>
2025-01-15 22:04:28 +01:00
Sebastien Wains
b2ca16ec9c Update community.md (#949) 2025-01-15 22:02:23 +01:00
Sascha Ißbrücker
649f4154e5 Provide accessible name to radio groups (#945) 2025-01-12 22:10:14 +01:00
Sascha Ißbrücker
d2e8a95e3c Fix menu dropdown focus traps (#944) 2025-01-11 12:44:20 +01:00
Sascha Ißbrücker
c3149409b0 Fix how to for changing font size 2024-10-24 22:24:58 +02:00
Vadim Khitrin
4626fa1c67 docs: Add cosmicding To Community Resources (#892)
* docs: Add cosmicding To Community Resources

* sort alphabetically

---------

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@googlemail.com>
2024-10-24 22:16:22 +02:00
Dany Marcoux
6548e16baa Add option to disable request logs (#887)
Closes #785
2024-10-17 21:47:56 +02:00
dependabot[bot]
c177de164a Bump astro from 4.15.8 to 4.16.3 in /docs (#884)
Bumps [astro](https://github.com/withastro/astro/tree/HEAD/packages/astro) from 4.15.8 to 4.16.3.
- [Release notes](https://github.com/withastro/astro/releases)
- [Changelog](https://github.com/withastro/astro/blob/main/packages/astro/CHANGELOG.md)
- [Commits](https://github.com/withastro/astro/commits/astro@4.16.3/packages/astro)

---
updated-dependencies:
- dependency-name: astro
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-16 21:08:46 +02:00
Xuanli
e9ecad38ac Add serchding to community projects (#880) 2024-10-12 18:13:50 +02:00
Sascha Ißbrücker
621aedd8eb Update donations 2024-10-04 18:43:02 +02:00
Sascha Ißbrücker
4187141ac8 Update CHANGELOG.md 2024-10-03 00:09:51 +02:00
Sascha Ißbrücker
cf0cc32090 Bump version 2024-10-02 22:39:57 +02:00
ixzhao
1f2cf21585 Add LAST_MODIFIED attribute when exporting (#860)
* add LAST_MODIFIED attribute when exporting

* complement test_exporter for LAST_MODIFIED attribute

* parse LAST_MODIFIED attribute when importing

* use bookmark date_added when no modified date is parsed, otherwise use parsed datetime.

* complement test_parser and test_importer for LAST_MODIFIED attribute

* cleanup tests a bit

---------

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@gmail.com>
2024-10-02 22:21:08 +02:00
Sascha Ißbrücker
0dd05b9269 Fix permission issue with ublock extension 2024-10-02 21:47:32 +02:00
Sascha Ißbrücker
5cd6d773db Replace uBlock Origin with uBlock Origin Lite (#866) 2024-09-29 14:42:50 +02:00
Sascha Ißbrücker
d4c348cc5a Simplify Docker build (#865) 2024-09-28 16:13:07 +02:00
Sascha Ißbrücker
791a5c73ca Do not escape valid characters in custom CSS (#863) 2024-09-28 11:17:48 +02:00
Sascha Ißbrücker
ebed0c050d Add edit link to docs pages 2024-09-25 17:34:19 +02:00
Sascha Ißbrücker
f4dd2b53b5 Fix select dropdown menu background in dark theme (#858) 2024-09-24 21:42:52 +02:00
dependabot[bot]
b53fe09c39 Bump rollup from 4.21.3 to 4.22.4 in /docs (#856)
Bumps [rollup](https://github.com/rollup/rollup) from 4.21.3 to 4.22.4.
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.21.3...v4.22.4)

---
updated-dependencies:
- dependency-name: rollup
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-24 19:11:08 +02:00
dependabot[bot]
ff88e726cc Bump rollup from 4.13.0 to 4.22.4 (#851)
Bumps [rollup](https://github.com/rollup/rollup) from 4.13.0 to 4.22.4.
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.13.0...v4.22.4)

---
updated-dependencies:
- dependency-name: rollup
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-24 17:28:21 +02:00
Sascha Ißbrücker
52400feacf Improve error handling for auto tagging (#855) 2024-09-24 17:26:01 +02:00
Sascha Ißbrücker
c93709b549 Fix jumping details modal on back navigation (#854) 2024-09-24 17:09:17 +02:00
Sascha Ißbrücker
ba904ed191 Fix header backdrop on iOS 2024-09-24 16:29:05 +02:00
Sascha Ißbrücker
d1f81fee0e Prevent duplicates when editing (#853)
* prevent creating duplicate URLs on edit

* Prevent duplicates when editing
2024-09-24 13:13:32 +02:00
Sascha Ißbrücker
7b405c054d Do not clear fields in POST requests (API behavior change) (#852) 2024-09-24 11:37:50 +02:00
Vlad-Stefan Harbuz
23ad52f75d Fix header.svg text (#850) 2024-09-23 23:28:37 +02:00
Sascha Ißbrücker
c3a2305a5f Return client error status code for invalid form submissions (#849)
* Returns client error status code for invalid form submissions

* fix flaky test
2024-09-23 20:30:49 +02:00
Sascha Ißbrücker
d4006026db Fix preview image spacing 2024-09-23 19:30:42 +02:00
Sascha Ißbrücker
70bdf88791 Update CHANGELOG.md 2024-09-23 17:44:06 +02:00
116 changed files with 4151 additions and 1603 deletions

74
.github/workflows/build.yaml vendored Normal file
View File

@@ -0,0 +1,74 @@
name: build
on: workflow_dispatch
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Read version from file
id: get_version
run: echo "VERSION=$(cat version.txt)" >> $GITHUB_ENV
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build latest
uses: docker/build-push-action@v6
with:
context: .
file: ./docker/default.Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7
tags: |
sissbruecker/linkding:latest
sissbruecker/linkding:${{ env.VERSION }}
target: linkding
push: true
- name: Build latest-alpine
uses: docker/build-push-action@v6
with:
context: .
file: ./docker/alpine.Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7
tags: |
sissbruecker/linkding:latest-alpine
sissbruecker/linkding:${{ env.VERSION }}-alpine
target: linkding
push: true
- name: Build latest-plus
uses: docker/build-push-action@v6
with:
context: .
file: ./docker/default.Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7
tags: |
sissbruecker/linkding:latest-plus
sissbruecker/linkding:${{ env.VERSION }}-plus
target: linkding-plus
push: true
- name: Build latest-plus-alpine
uses: docker/build-push-action@v6
with:
context: .
file: ./docker/alpine.Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7
tags: |
sissbruecker/linkding:latest-plus-alpine
sissbruecker/linkding:${{ env.VERSION }}-plus-alpine
target: linkding-plus
push: true

2
.gitignore vendored
View File

@@ -192,7 +192,7 @@ typings/
# Database file
/data
# ublock + chromium
/uBlock0.chromium
/uBOLite.chromium.mv3
/chromium-profile
# direnv
/.direnv

View File

@@ -1,5 +1,119 @@
# Changelog
## v1.38.1 (22/02/2025)
### What's Changed
* Remove preview image when bookmark is deleted by @sissbruecker in https://github.com/sissbruecker/linkding/pull/989
* Try limit uwsgi memory usage by configuring file descriptor limit by @sissbruecker in https://github.com/sissbruecker/linkding/pull/990
* Add note about OIDC and LD_SUPERUSER_NAME combination by @tebriel in https://github.com/sissbruecker/linkding/pull/992
* Return web archive fallback URL from REST API by @sissbruecker in https://github.com/sissbruecker/linkding/pull/993
* Fix auth proxy logout by @sissbruecker in https://github.com/sissbruecker/linkding/pull/994
### New Contributors
* @tebriel made their first contribution in https://github.com/sissbruecker/linkding/pull/992
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.38.0...v1.38.1
---
## v1.38.0 (09/02/2025)
### What's Changed
* Fix nav menu closing on mousedown in Safari by @sissbruecker in https://github.com/sissbruecker/linkding/pull/965
* Allow customizing username when creating user through OIDC by @kyuuk in https://github.com/sissbruecker/linkding/pull/971
* Improve accessibility of modal dialogs by @sissbruecker in https://github.com/sissbruecker/linkding/pull/974
* Add option to collapse side panel by @sissbruecker in https://github.com/sissbruecker/linkding/pull/975
* Convert tag modal into drawer by @sissbruecker in https://github.com/sissbruecker/linkding/pull/977
* Add RSS link to shared bookmarks page by @sissbruecker in https://github.com/sissbruecker/linkding/pull/984
* Add Additional iOS Shortcut to community section by @joshdick in https://github.com/sissbruecker/linkding/pull/968
### New Contributors
* @kyuuk made their first contribution in https://github.com/sissbruecker/linkding/pull/971
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.37.0...v1.38.0
---
## v1.37.0 (26/01/2025)
### What's Changed
* Add option to disable request logs by @dmarcoux in https://github.com/sissbruecker/linkding/pull/887
* Add default robots.txt to block crawlers by @sissbruecker in https://github.com/sissbruecker/linkding/pull/959
* Fix menu dropdown focus traps by @sissbruecker in https://github.com/sissbruecker/linkding/pull/944
* Provide accessible name to radio groups by @sissbruecker in https://github.com/sissbruecker/linkding/pull/945
* Add serchding to community projects, sort the list by alphabetical order by @ldwgchen in https://github.com/sissbruecker/linkding/pull/880
* Add cosmicding To Community Resources by @vkhitrin in https://github.com/sissbruecker/linkding/pull/892
* Add 3 new community projects by @sebw in https://github.com/sissbruecker/linkding/pull/949
* Add a rust client library to community.md by @zbrox in https://github.com/sissbruecker/linkding/pull/914
* Update community.md by @justusthane in https://github.com/sissbruecker/linkding/pull/897
* Bump astro from 4.15.8 to 4.16.3 in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/884
* Bump vite from 5.4.9 to 5.4.14 in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/953
* Bump django from 5.1.1 to 5.1.5 by @dependabot in https://github.com/sissbruecker/linkding/pull/947
* Bump nanoid from 3.3.7 to 3.3.8 by @dependabot in https://github.com/sissbruecker/linkding/pull/928
* Bump astro from 4.16.3 to 4.16.18 in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/929
* Bump nanoid from 3.3.7 to 3.3.8 in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/962
### New Contributors
* @ldwgchen made their first contribution in https://github.com/sissbruecker/linkding/pull/880
* @dmarcoux made their first contribution in https://github.com/sissbruecker/linkding/pull/887
* @vkhitrin made their first contribution in https://github.com/sissbruecker/linkding/pull/892
* @sebw made their first contribution in https://github.com/sissbruecker/linkding/pull/949
* @justusthane made their first contribution in https://github.com/sissbruecker/linkding/pull/897
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.36.0...v1.37.0
---
## v1.36.0 (02/10/2024)
### What's Changed
* Replace uBlock Origin with uBlock Origin Lite by @sissbruecker in https://github.com/sissbruecker/linkding/pull/866
* Add LAST_MODIFIED attribute when exporting by @ixzhao in https://github.com/sissbruecker/linkding/pull/860
* Return client error status code for invalid form submissions by @sissbruecker in https://github.com/sissbruecker/linkding/pull/849
* Fix header.svg text by @vladh in https://github.com/sissbruecker/linkding/pull/850
* Do not clear fields in POST requests (API behavior change) by @sissbruecker in https://github.com/sissbruecker/linkding/pull/852
* Prevent duplicates when editing by @sissbruecker in https://github.com/sissbruecker/linkding/pull/853
* Fix jumping details modal on back navigation by @sissbruecker in https://github.com/sissbruecker/linkding/pull/854
* Fix select dropdown menu background in dark theme by @sissbruecker in https://github.com/sissbruecker/linkding/pull/858
* Do not escape valid characters in custom CSS by @sissbruecker in https://github.com/sissbruecker/linkding/pull/863
* Simplify Docker build by @sissbruecker in https://github.com/sissbruecker/linkding/pull/865
* Improve error handling for auto tagging by @sissbruecker in https://github.com/sissbruecker/linkding/pull/855
* Bump rollup from 4.13.0 to 4.22.4 by @dependabot in https://github.com/sissbruecker/linkding/pull/851
* Bump rollup from 4.21.3 to 4.22.4 in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/856
### New Contributors
* @vladh made their first contribution in https://github.com/sissbruecker/linkding/pull/850
* @ixzhao made their first contribution in https://github.com/sissbruecker/linkding/pull/860
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.35.0...v1.36.0
---
## v1.35.0 (23/09/2024)
### What's Changed
* Add configuration options for pagination by @sissbruecker in https://github.com/sissbruecker/linkding/pull/835
* Show placeholder if there is no preview image by @sissbruecker in https://github.com/sissbruecker/linkding/pull/842
* Allow bookmarks to have empty title and description by @sissbruecker in https://github.com/sissbruecker/linkding/pull/843
* Add clear buttons in bookmark form by @sissbruecker in https://github.com/sissbruecker/linkding/pull/846
* Add basic fail2ban support by @sissbruecker in https://github.com/sissbruecker/linkding/pull/847
* Add documentation website by @sissbruecker in https://github.com/sissbruecker/linkding/pull/833
* Add go-linkding to community projects by @piero-vic in https://github.com/sissbruecker/linkding/pull/836
* Fix a broken link to options documentation by @zbrox in https://github.com/sissbruecker/linkding/pull/844
* Use HTTPS repository link for devcontainer by @voltagex in https://github.com/sissbruecker/linkding/pull/837
* Bump requests version to 3.23.3 by @voltagex in https://github.com/sissbruecker/linkding/pull/839
* Bump path-to-regexp and astro in /docs by @dependabot in https://github.com/sissbruecker/linkding/pull/840
* Bump dependencies by @sissbruecker in https://github.com/sissbruecker/linkding/pull/841
### New Contributors
* @piero-vic made their first contribution in https://github.com/sissbruecker/linkding/pull/836
* @voltagex made their first contribution in https://github.com/sissbruecker/linkding/pull/839
* @zbrox made their first contribution in https://github.com/sissbruecker/linkding/pull/844
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.34.0...v1.35.0
---
## v1.34.0 (16/09/2024)
### What's Changed

View File

@@ -13,6 +13,29 @@
</g>
</g>
<g transform="matrix(8.26174,0,0,8.26174,-5762.21,-2037.46)">
<text x="770.835px" y="299.13px" style="font-family:'HelveticaNeue', 'Helvetica Neue';font-size:50px;fill:rgb(94,94,219);">l<tspan x="782.685px 794.535px 823.085px 849.785px 880.185px 892.035px 920.585px " y="299.13px 299.13px 299.13px 299.13px 299.13px 299.13px 299.13px ">inkding</tspan></text>
<g transform="matrix(50,0,0,50,770.835,299.13)">
<rect x="0.064" y="-0.716" width="0.088" height="0.716" style="fill:rgb(94,94,219);fill-rule:nonzero;"/>
</g>
<g transform="matrix(50,0,0,50,782.693,299.13)">
<path d="M0.066,-0.615L0.066,-0.716L0.154,-0.716L0.154,-0.615L0.066,-0.615ZM0.066,-0L0.066,-0.519L0.154,-0.519L0.154,-0L0.066,-0Z" style="fill:rgb(94,94,219);fill-rule:nonzero;"/>
</g>
<g transform="matrix(50,0,0,50,794.552,299.13)">
<path d="M0.066,-0L0.066,-0.519L0.145,-0.519L0.145,-0.445C0.183,-0.502 0.238,-0.53 0.31,-0.53C0.341,-0.53 0.37,-0.525 0.396,-0.513C0.422,-0.502 0.442,-0.487 0.455,-0.469C0.468,-0.451 0.477,-0.429 0.482,-0.404C0.486,-0.388 0.487,-0.36 0.487,-0.319L0.487,-0L0.399,-0L0.399,-0.315C0.399,-0.351 0.396,-0.378 0.389,-0.396C0.382,-0.413 0.37,-0.428 0.353,-0.438C0.335,-0.449 0.315,-0.454 0.292,-0.454C0.254,-0.454 0.222,-0.442 0.194,-0.418C0.167,-0.395 0.154,-0.35 0.154,-0.283L0.154,-0L0.066,-0Z" style="fill:rgb(94,94,219);fill-rule:nonzero;"/>
</g>
<g transform="matrix(50,0,0,50,823.109,299.13)">
<path d="M0.066,-0L0.066,-0.716L0.154,-0.716L0.154,-0.308L0.362,-0.519L0.476,-0.519L0.278,-0.326L0.496,-0L0.388,-0L0.216,-0.265L0.154,-0.206L0.154,-0L0.066,-0Z" style="fill:rgb(94,94,219);fill-rule:nonzero;"/>
</g>
<g transform="matrix(50,0,0,50,848.859,299.13)">
<path d="M0.402,-0L0.402,-0.065C0.369,-0.014 0.321,0.012 0.257,0.012C0.216,0.012 0.178,0 0.143,-0.022C0.109,-0.045 0.082,-0.077 0.063,-0.118C0.044,-0.159 0.034,-0.206 0.034,-0.259C0.034,-0.311 0.043,-0.357 0.06,-0.399C0.077,-0.442 0.103,-0.474 0.138,-0.497C0.172,-0.519 0.211,-0.53 0.253,-0.53C0.285,-0.53 0.313,-0.524 0.337,-0.51C0.361,-0.497 0.381,-0.48 0.396,-0.459L0.396,-0.716L0.484,-0.716L0.484,-0L0.402,-0ZM0.125,-0.259C0.125,-0.192 0.139,-0.143 0.167,-0.11C0.194,-0.077 0.228,-0.061 0.266,-0.061C0.304,-0.061 0.337,-0.076 0.363,-0.107C0.39,-0.139 0.404,-0.187 0.404,-0.251C0.404,-0.322 0.39,-0.375 0.363,-0.408C0.335,-0.441 0.302,-0.458 0.262,-0.458C0.223,-0.458 0.19,-0.442 0.164,-0.41C0.138,-0.378 0.125,-0.327 0.125,-0.259Z" style="fill:rgb(94,94,219);fill-rule:nonzero;"/>
</g>
<g transform="matrix(50,0,0,50,877.417,299.13)">
<path d="M0.066,-0.615L0.066,-0.716L0.154,-0.716L0.154,-0.615L0.066,-0.615ZM0.066,-0L0.066,-0.519L0.154,-0.519L0.154,-0L0.066,-0Z" style="fill:rgb(94,94,219);fill-rule:nonzero;"/>
</g>
<g transform="matrix(50,0,0,50,889.275,299.13)">
<path d="M0.066,-0L0.066,-0.519L0.145,-0.519L0.145,-0.445C0.183,-0.502 0.238,-0.53 0.31,-0.53C0.341,-0.53 0.37,-0.525 0.396,-0.513C0.422,-0.502 0.442,-0.487 0.455,-0.469C0.468,-0.451 0.477,-0.429 0.482,-0.404C0.486,-0.388 0.487,-0.36 0.487,-0.319L0.487,-0L0.399,-0L0.399,-0.315C0.399,-0.351 0.396,-0.378 0.389,-0.396C0.382,-0.413 0.37,-0.428 0.353,-0.438C0.335,-0.449 0.315,-0.454 0.292,-0.454C0.254,-0.454 0.222,-0.442 0.194,-0.418C0.167,-0.395 0.154,-0.35 0.154,-0.283L0.154,-0L0.066,-0Z" style="fill:rgb(94,94,219);fill-rule:nonzero;"/>
</g>
<g transform="matrix(50,0,0,50,917.833,299.13)">
<path d="M0.05,0.043L0.135,0.056C0.139,0.082 0.149,0.101 0.165,0.113C0.187,0.13 0.217,0.138 0.254,0.138C0.295,0.138 0.326,0.13 0.349,0.113C0.371,0.097 0.386,0.074 0.394,0.045C0.398,0.027 0.4,-0.011 0.4,-0.068C0.361,-0.023 0.314,-0 0.256,-0C0.185,-0 0.13,-0.026 0.091,-0.077C0.052,-0.129 0.032,-0.19 0.032,-0.262C0.032,-0.312 0.041,-0.357 0.059,-0.399C0.077,-0.441 0.103,-0.473 0.137,-0.496C0.171,-0.519 0.211,-0.53 0.257,-0.53C0.318,-0.53 0.368,-0.506 0.408,-0.456L0.408,-0.519L0.489,-0.519L0.489,-0.07C0.489,0.01 0.481,0.068 0.464,0.101C0.448,0.135 0.422,0.162 0.386,0.181C0.351,0.201 0.307,0.21 0.255,0.21C0.193,0.21 0.143,0.196 0.105,0.168C0.067,0.141 0.049,0.099 0.05,0.043ZM0.123,-0.269C0.123,-0.201 0.136,-0.151 0.163,-0.12C0.19,-0.088 0.224,-0.073 0.265,-0.073C0.305,-0.073 0.339,-0.088 0.366,-0.119C0.394,-0.15 0.407,-0.199 0.407,-0.266C0.407,-0.329 0.393,-0.377 0.365,-0.409C0.337,-0.441 0.303,-0.458 0.263,-0.458C0.224,-0.458 0.191,-0.442 0.164,-0.41C0.136,-0.378 0.123,-0.331 0.123,-0.269Z" style="fill:rgb(94,94,219);fill-rule:nonzero;"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

34
bookmarks/api/auth.py Normal file
View File

@@ -0,0 +1,34 @@
from django.utils.translation import gettext_lazy as _
from rest_framework import exceptions
from rest_framework.authentication import TokenAuthentication, get_authorization_header
class LinkdingTokenAuthentication(TokenAuthentication):
"""
Extends DRF TokenAuthentication to add support for multiple keywords
"""
keywords = [keyword.lower().encode() for keyword in ["Token", "Bearer"]]
def authenticate(self, request):
auth = get_authorization_header(request).split()
if not auth or auth[0].lower() not in self.keywords:
return None
if len(auth) == 1:
msg = _("Invalid token header. No credentials provided.")
raise exceptions.AuthenticationFailed(msg)
elif len(auth) > 2:
msg = _("Invalid token header. Token string should not contain spaces.")
raise exceptions.AuthenticationFailed(msg)
try:
token = auth[1].decode()
except UnicodeError:
msg = _(
"Invalid token header. Token string should not contain invalid characters."
)
raise exceptions.AuthenticationFailed(msg)
return self.authenticate_credentials(token)

View File

@@ -1,25 +1,24 @@
import gzip
import logging
import os
from django.conf import settings
from django.http import FileResponse, Http404
from rest_framework import viewsets, mixins, status
from rest_framework.decorators import action
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from rest_framework.routers import DefaultRouter
from rest_framework.routers import SimpleRouter, DefaultRouter
from bookmarks import queries
from bookmarks.api.serializers import (
BookmarkSerializer,
BookmarkAssetSerializer,
TagSerializer,
UserProfileSerializer,
)
from bookmarks.models import Bookmark, BookmarkSearch, Tag, User
from bookmarks.services import auto_tagging
from bookmarks.services.bookmarks import (
archive_bookmark,
unarchive_bookmark,
website_loader,
)
from bookmarks.services.website_loader import WebsiteMetadata
from bookmarks.models import Bookmark, BookmarkAsset, BookmarkSearch, Tag, User
from bookmarks.services import assets, bookmarks, auto_tagging, website_loader
logger = logging.getLogger(__name__)
@@ -57,10 +56,12 @@ class BookmarkViewSet(
def get_serializer_context(self):
disable_scraping = "disable_scraping" in self.request.GET
disable_html_snapshot = "disable_html_snapshot" in self.request.GET
return {
"request": self.request,
"user": self.request.user,
"disable_scraping": disable_scraping,
"disable_html_snapshot": disable_html_snapshot,
}
@action(methods=["get"], detail=False)
@@ -89,13 +90,13 @@ class BookmarkViewSet(
@action(methods=["post"], detail=True)
def archive(self, request, pk):
bookmark = self.get_object()
archive_bookmark(bookmark)
bookmarks.archive_bookmark(bookmark)
return Response(status=status.HTTP_204_NO_CONTENT)
@action(methods=["post"], detail=True)
def unarchive(self, request, pk):
bookmark = self.get_object()
unarchive_bookmark(bookmark)
bookmarks.unarchive_bookmark(bookmark)
return Response(status=status.HTTP_204_NO_CONTENT)
@action(methods=["get"], detail=False)
@@ -129,6 +130,118 @@ class BookmarkViewSet(
status=status.HTTP_200_OK,
)
@action(methods=["post"], detail=False)
def singlefile(self, request):
if settings.LD_DISABLE_ASSET_UPLOAD:
return Response(
{"error": "Asset upload is disabled."},
status=status.HTTP_403_FORBIDDEN,
)
url = request.data.get("url")
file = request.FILES.get("file")
if not url or not file:
return Response(
{"error": "Both 'url' and 'file' parameters are required."},
status=status.HTTP_400_BAD_REQUEST,
)
bookmark = Bookmark.objects.filter(owner=request.user, url=url).first()
if not bookmark:
bookmark = Bookmark(url=url)
bookmark = bookmarks.create_bookmark(
bookmark, "", request.user, disable_html_snapshot=True
)
bookmarks.enhance_with_website_metadata(bookmark)
assets.upload_snapshot(bookmark, file.read())
return Response(
{"message": "Snapshot uploaded successfully."},
status=status.HTTP_201_CREATED,
)
class BookmarkAssetViewSet(
viewsets.GenericViewSet,
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.DestroyModelMixin,
):
serializer_class = BookmarkAssetSerializer
def get_queryset(self):
user = self.request.user
bookmark_id = self.kwargs["bookmark_id"]
if not Bookmark.objects.filter(id=bookmark_id, owner=user).exists():
raise Http404("Bookmark does not exist")
return BookmarkAsset.objects.filter(
bookmark_id=bookmark_id, bookmark__owner=user
)
def get_serializer_context(self):
return {"user": self.request.user}
@action(detail=True, methods=["get"], url_path="download")
def download(self, request, bookmark_id, pk):
asset = self.get_object()
try:
file_path = os.path.join(settings.LD_ASSET_FOLDER, asset.file)
content_type = asset.content_type
file_stream = (
gzip.GzipFile(file_path, mode="rb")
if asset.gzip
else open(file_path, "rb")
)
file_name = (
f"{asset.display_name}.html"
if asset.asset_type == BookmarkAsset.TYPE_SNAPSHOT
else asset.display_name
)
response = FileResponse(file_stream, content_type=content_type)
response["Content-Disposition"] = f'attachment; filename="{file_name}"'
return response
except FileNotFoundError:
raise Http404("Asset file does not exist")
except Exception as e:
logger.error(
f"Failed to download asset. bookmark_id={bookmark_id}, asset_id={pk}",
exc_info=e,
)
return Response(status=status.HTTP_500_INTERNAL_SERVER_ERROR)
@action(methods=["post"], detail=False)
def upload(self, request, bookmark_id):
if settings.LD_DISABLE_ASSET_UPLOAD:
return Response(
{"error": "Asset upload is disabled."},
status=status.HTTP_403_FORBIDDEN,
)
bookmark = Bookmark.objects.filter(id=bookmark_id, owner=request.user).first()
if not bookmark:
raise Http404("Bookmark does not exist")
upload_file = request.FILES.get("file")
if not upload_file:
return Response(
{"error": "No file provided."}, status=status.HTTP_400_BAD_REQUEST
)
try:
asset = assets.upload_asset(bookmark, upload_file)
serializer = self.get_serializer(asset)
return Response(serializer.data, status=status.HTTP_201_CREATED)
except Exception as e:
logger.error(
f"Failed to upload asset file. bookmark_id={bookmark_id}, file={upload_file.name}",
exc_info=e,
)
return Response(
{"error": "Failed to upload asset."},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
class TagViewSet(
viewsets.GenericViewSet,
@@ -152,7 +265,19 @@ class UserViewSet(viewsets.GenericViewSet):
return Response(UserProfileSerializer(request.user.profile).data)
router = DefaultRouter()
router.register(r"bookmarks", BookmarkViewSet, basename="bookmark")
router.register(r"tags", TagViewSet, basename="tag")
router.register(r"user", UserViewSet, basename="user")
# DRF routers do not support nested view sets such as /bookmarks/<id>/assets/<id>/
# Instead create separate routers for each view set and manually register them in urls.py
# The default router is only used to allow reversing a URL for the API root
default_router = DefaultRouter()
bookmark_router = SimpleRouter()
bookmark_router.register("", BookmarkViewSet, basename="bookmark")
tag_router = SimpleRouter()
tag_router.register("", TagViewSet, basename="tag")
user_router = SimpleRouter()
user_router.register("", UserViewSet, basename="user")
bookmark_asset_router = SimpleRouter()
bookmark_asset_router.register("", BookmarkAssetViewSet, basename="bookmark_asset")

View File

@@ -3,13 +3,11 @@ from django.templatetags.static import static
from rest_framework import serializers
from rest_framework.serializers import ListSerializer
from bookmarks.models import Bookmark, Tag, build_tag_string, UserProfile
from bookmarks.services.bookmarks import (
create_bookmark,
update_bookmark,
enhance_with_website_metadata,
)
from bookmarks.models import Bookmark, BookmarkAsset, Tag, build_tag_string, UserProfile
from bookmarks.services import bookmarks
from bookmarks.services.tags import get_or_create_tag
from bookmarks.services.wayback import generate_fallback_webarchive_url
from bookmarks.utils import app_version
class TagListField(serializers.ListField):
@@ -49,6 +47,7 @@ class BookmarkSerializer(serializers.ModelSerializer):
"web_archive_snapshot_url",
"favicon_url",
"preview_image_url",
"tag_names",
"date_added",
"date_modified",
"website_title",
@@ -56,17 +55,12 @@ class BookmarkSerializer(serializers.ModelSerializer):
]
list_serializer_class = BookmarkListSerializer
# Override optional char fields to provide default value
title = serializers.CharField(required=False, allow_blank=True, default="")
description = serializers.CharField(required=False, allow_blank=True, default="")
notes = serializers.CharField(required=False, allow_blank=True, default="")
is_archived = serializers.BooleanField(required=False, default=False)
unread = serializers.BooleanField(required=False, default=False)
shared = serializers.BooleanField(required=False, default=False)
# Override readonly tag_names property to allow passing a list of tag names to create/update
tag_names = TagListField(required=False, default=[])
# Custom tag_names field to allow passing a list of tag names to create/update
tag_names = TagListField(required=False)
# Custom fields to generate URLs for favicon, preview image, and web archive snapshot
favicon_url = serializers.SerializerMethodField()
preview_image_url = serializers.SerializerMethodField()
web_archive_snapshot_url = serializers.SerializerMethodField()
# Add dummy website title and description fields for backwards compatibility but keep them empty
website_title = serializers.SerializerMethodField()
website_description = serializers.SerializerMethodField()
@@ -87,6 +81,12 @@ class BookmarkSerializer(serializers.ModelSerializer):
preview_image_url = request.build_absolute_uri(preview_image_file_path)
return preview_image_url
def get_web_archive_snapshot_url(self, obj: Bookmark):
if obj.web_archive_snapshot_url:
return obj.web_archive_snapshot_url
return generate_fallback_webarchive_url(obj.url, obj.date_added)
def get_website_title(self, obj: Bookmark):
return None
@@ -94,36 +94,68 @@ class BookmarkSerializer(serializers.ModelSerializer):
return None
def create(self, validated_data):
bookmark = Bookmark()
bookmark.url = validated_data["url"]
bookmark.title = validated_data["title"]
bookmark.description = validated_data["description"]
bookmark.notes = validated_data["notes"]
bookmark.is_archived = validated_data["is_archived"]
bookmark.unread = validated_data["unread"]
bookmark.shared = validated_data["shared"]
tag_string = build_tag_string(validated_data["tag_names"])
tag_names = validated_data.pop("tag_names", [])
tag_string = build_tag_string(tag_names)
bookmark = Bookmark(**validated_data)
saved_bookmark = create_bookmark(bookmark, tag_string, self.context["user"])
disable_scraping = self.context.get("disable_scraping", False)
disable_html_snapshot = self.context.get("disable_html_snapshot", False)
saved_bookmark = bookmarks.create_bookmark(
bookmark,
tag_string,
self.context["user"],
disable_html_snapshot=disable_html_snapshot,
)
# Unless scraping is explicitly disabled, enhance bookmark with website
# metadata to preserve backwards compatibility with clients that expect
# title and description to be populated automatically when left empty
if not self.context.get("disable_scraping", False):
enhance_with_website_metadata(saved_bookmark)
if not disable_scraping:
bookmarks.enhance_with_website_metadata(saved_bookmark)
return saved_bookmark
def update(self, instance: Bookmark, validated_data):
# Update fields if they were provided in the payload
for key in ["url", "title", "description", "notes", "unread", "shared"]:
if key in validated_data:
setattr(instance, key, validated_data[key])
tag_names = validated_data.pop("tag_names", instance.tag_names)
tag_string = build_tag_string(tag_names)
# Use tag string from payload, or use bookmark's current tags as fallback
tag_string = build_tag_string(instance.tag_names)
if "tag_names" in validated_data:
tag_string = build_tag_string(validated_data["tag_names"])
for field_name, field in self.fields.items():
if not field.read_only and field_name in validated_data:
setattr(instance, field_name, validated_data[field_name])
return update_bookmark(instance, tag_string, self.context["user"])
return bookmarks.update_bookmark(instance, tag_string, self.context["user"])
def validate(self, attrs):
# When creating a bookmark, the service logic prevents duplicate URLs by
# updating the existing bookmark instead. When editing a bookmark,
# there is no assumption that it would update a different bookmark if
# the URL is a duplicate, so raise a validation error in that case.
if self.instance and "url" in attrs:
is_duplicate = (
Bookmark.objects.filter(owner=self.instance.owner, url=attrs["url"])
.exclude(pk=self.instance.pk)
.exists()
)
if is_duplicate:
raise serializers.ValidationError(
{"url": "A bookmark with this URL already exists."}
)
return attrs
class BookmarkAssetSerializer(serializers.ModelSerializer):
class Meta:
model = BookmarkAsset
fields = [
"id",
"bookmark",
"date_created",
"file_size",
"asset_type",
"content_type",
"display_name",
"status",
]
class TagSerializer(serializers.ModelSerializer):
@@ -151,4 +183,11 @@ class UserProfileSerializer(serializers.ModelSerializer):
"display_url",
"permanent_notes",
"search_preferences",
"version",
]
read_only_fields = ["version"]
version = serializers.SerializerMethodField()
def get_version(self, obj: UserProfile):
return app_version

View File

@@ -0,0 +1,48 @@
from django.urls import reverse
from playwright.sync_api import sync_playwright, expect
from bookmarks.e2e.helpers import LinkdingE2ETestCase
class CollapseSidePanelE2ETestCase(LinkdingE2ETestCase):
def setUp(self) -> None:
super().setUp()
def assertSidePanelIsVisible(self):
expect(self.page.locator(".bookmarks-page .side-panel")).to_be_visible()
expect(
self.page.locator(".bookmarks-page [ld-filter-drawer-trigger]")
).not_to_be_visible()
def assertSidePanelIsHidden(self):
expect(self.page.locator(".bookmarks-page .side-panel")).not_to_be_visible()
expect(
self.page.locator(".bookmarks-page [ld-filter-drawer-trigger]")
).to_be_visible()
def test_side_panel_should_be_visible_by_default(self):
with sync_playwright() as p:
self.open(reverse("bookmarks:index"), p)
self.assertSidePanelIsVisible()
self.page.goto(self.live_server_url + reverse("bookmarks:archived"))
self.assertSidePanelIsVisible()
self.page.goto(self.live_server_url + reverse("bookmarks:shared"))
self.assertSidePanelIsVisible()
def test_side_panel_should_be_hidden_when_collapsed(self):
user = self.get_or_create_test_user()
user.profile.collapse_side_panel = True
user.profile.save()
with sync_playwright() as p:
self.open(reverse("bookmarks:index"), p)
self.assertSidePanelIsHidden()
self.page.goto(self.live_server_url + reverse("bookmarks:archived"))
self.assertSidePanelIsHidden()
self.page.goto(self.live_server_url + reverse("bookmarks:shared"))
self.assertSidePanelIsHidden()

View File

@@ -4,7 +4,7 @@ from playwright.sync_api import sync_playwright, expect
from bookmarks.e2e.helpers import LinkdingE2ETestCase
class TagCloudModalE2ETestCase(LinkdingE2ETestCase):
class FilterDrawerE2ETestCase(LinkdingE2ETestCase):
def test_show_modal_close_modal(self):
self.setup_bookmark(tags=[self.setup_tag(name="cooking")])
self.setup_bookmark(tags=[self.setup_tag(name="hiking")])
@@ -12,31 +12,31 @@ class TagCloudModalE2ETestCase(LinkdingE2ETestCase):
with sync_playwright() as p:
page = self.open(reverse("bookmarks:index"), p)
# use smaller viewport to make tags button visible
# use smaller viewport to make filter button visible
page.set_viewport_size({"width": 375, "height": 812})
# open tag cloud modal
modal_trigger = page.locator(".content-area-header").get_by_role(
"button", name="Tags"
# open drawer
drawer_trigger = page.locator(".content-area-header").get_by_role(
"button", name="Filters"
)
modal_trigger.click()
drawer_trigger.click()
# verify modal is visible
modal = page.locator(".modal")
expect(modal).to_be_visible()
expect(modal.locator("h2")).to_have_text("Tags")
# verify drawer is visible
drawer = page.locator(".modal.drawer.filter-drawer")
expect(drawer).to_be_visible()
expect(drawer.locator("h2")).to_have_text("Filters")
# close with close button
modal.locator("button.close").click()
expect(modal).to_be_hidden()
drawer.locator("button.close").click()
expect(drawer).to_be_hidden()
# open modal again
modal_trigger.click()
# open drawer again
drawer_trigger.click()
# close with backdrop
backdrop = modal.locator(".modal-overlay")
backdrop = drawer.locator(".modal-overlay")
backdrop.click(position={"x": 0, "y": 0})
expect(modal).to_be_hidden()
expect(drawer).to_be_hidden()
def test_select_tag(self):
self.setup_bookmark(tags=[self.setup_tag(name="cooking")])
@@ -45,29 +45,29 @@ class TagCloudModalE2ETestCase(LinkdingE2ETestCase):
with sync_playwright() as p:
page = self.open(reverse("bookmarks:index"), p)
# use smaller viewport to make tags button visible
# use smaller viewport to make filter button visible
page.set_viewport_size({"width": 375, "height": 812})
# open tag cloud modal
modal_trigger = page.locator(".content-area-header").get_by_role(
"button", name="Tags"
drawer_trigger = page.locator(".content-area-header").get_by_role(
"button", name="Filters"
)
modal_trigger.click()
drawer_trigger.click()
# verify tags are displayed
modal = page.locator(".modal")
unselected_tags = modal.locator(".unselected-tags")
drawer = page.locator(".modal.drawer.filter-drawer")
unselected_tags = drawer.locator(".unselected-tags")
expect(unselected_tags.get_by_text("cooking")).to_be_visible()
expect(unselected_tags.get_by_text("hiking")).to_be_visible()
# select tag
unselected_tags.get_by_text("cooking").click()
# open modal again
modal_trigger.click()
# open drawer again
drawer_trigger.click()
# verify tag is selected, other tag is not visible anymore
selected_tags = modal.locator(".selected-tags")
selected_tags = drawer.locator(".selected-tags")
expect(selected_tags.get_by_text("cooking")).to_be_visible()
expect(unselected_tags.get_by_text("cooking")).not_to_be_visible()

View File

@@ -1,61 +1,28 @@
import { Behavior, registerBehavior } from "./index";
import { registerBehavior } from "./index";
import { isKeyboardActive } from "./focus-utils";
import { ModalBehavior } from "./modal";
class DetailsModalBehavior extends Behavior {
constructor(element) {
super(element);
class DetailsModalBehavior extends ModalBehavior {
doClose() {
super.doClose();
this.onClose = this.onClose.bind(this);
this.onKeyDown = this.onKeyDown.bind(this);
// Navigate to close URL
const closeUrl = this.element.dataset.closeUrl;
Turbo.visit(closeUrl, {
action: "replace",
frame: "details-modal",
});
this.overlayLink = element.querySelector("a:has(.modal-overlay)");
this.buttonLink = element.querySelector("a:has(button.close)");
// Try restore focus to view details to view details link of respective bookmark
const bookmarkId = this.element.dataset.bookmarkId;
const restoreFocusElement =
document.querySelector(
`ul.bookmark-list li[data-bookmark-id='${bookmarkId}'] a.view-action`,
) ||
document.querySelector("ul.bookmark-list") ||
document.body;
this.overlayLink.addEventListener("click", this.onClose);
this.buttonLink.addEventListener("click", this.onClose);
document.addEventListener("keydown", this.onKeyDown);
}
destroy() {
this.overlayLink.removeEventListener("click", this.onClose);
this.buttonLink.removeEventListener("click", this.onClose);
document.removeEventListener("keydown", this.onKeyDown);
}
onKeyDown(event) {
// Skip if event occurred within an input element
const targetNodeName = event.target.nodeName;
const isInputTarget =
targetNodeName === "INPUT" ||
targetNodeName === "SELECT" ||
targetNodeName === "TEXTAREA";
if (isInputTarget) {
return;
}
if (event.key === "Escape") {
this.onClose(event);
}
}
onClose(event) {
event.preventDefault();
this.element.classList.add("closing");
this.element.addEventListener(
"animationend",
(event) => {
if (event.animationName === "fade-out") {
this.element.remove();
const closeUrl = this.overlayLink.href;
Turbo.visit(closeUrl, {
action: "replace",
frame: "details-modal",
});
}
},
{ once: true },
);
restoreFocusElement.focus({ focusVisible: isKeyboardActive() });
}
}

View File

@@ -6,23 +6,38 @@ class DropdownBehavior extends Behavior {
this.opened = false;
this.onClick = this.onClick.bind(this);
this.onOutsideClick = this.onOutsideClick.bind(this);
this.onEscape = this.onEscape.bind(this);
this.onFocusOut = this.onFocusOut.bind(this);
// Prevent opening the dropdown automatically on focus, so that it only
// opens on click then JS is enabled
this.element.style.setProperty("--dropdown-focus-display", "none");
this.element.addEventListener("keydown", this.onEscape);
this.element.addEventListener("focusout", this.onFocusOut);
this.toggle = element.querySelector(".dropdown-toggle");
this.toggle.setAttribute("aria-expanded", "false");
this.toggle.addEventListener("click", this.onClick);
}
destroy() {
this.close();
this.toggle.removeEventListener("click", this.onClick);
this.element.removeEventListener("keydown", this.onEscape);
this.element.removeEventListener("focusout", this.onFocusOut);
}
open() {
this.opened = true;
this.element.classList.add("active");
this.toggle.setAttribute("aria-expanded", "true");
document.addEventListener("click", this.onOutsideClick);
}
close() {
this.opened = false;
this.element.classList.remove("active");
this.toggle.setAttribute("aria-expanded", "false");
document.removeEventListener("click", this.onOutsideClick);
}
@@ -39,6 +54,20 @@ class DropdownBehavior extends Behavior {
this.close();
}
}
onEscape(event) {
if (event.key === "Escape" && this.opened) {
event.preventDefault();
this.close();
this.toggle.focus();
}
}
onFocusOut(event) {
if (!this.element.contains(event.relatedTarget)) {
this.close();
}
}
}
registerBehavior("ld-dropdown", DropdownBehavior);

View File

@@ -0,0 +1,97 @@
import { Behavior, registerBehavior } from "./index";
import { ModalBehavior } from "./modal";
import { isKeyboardActive } from "./focus-utils";
class FilterDrawerTriggerBehavior extends Behavior {
constructor(element) {
super(element);
this.onClick = this.onClick.bind(this);
element.addEventListener("click", this.onClick);
}
destroy() {
this.element.removeEventListener("click", this.onClick);
}
onClick() {
const modal = document.createElement("div");
modal.classList.add("modal", "drawer", "filter-drawer");
modal.setAttribute("ld-filter-drawer", "");
modal.innerHTML = `
<div class="modal-overlay"></div>
<div class="modal-container" role="dialog" aria-modal="true">
<div class="modal-header">
<h2>Filters</h2>
<button class="close" aria-label="Close dialog">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke-width="2"
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M18 6l-12 12"></path>
<path d="M6 6l12 12"></path>
</svg>
</button>
</div>
<div class="modal-body">
<section class="content content-area"></div>
</div>
</div>
`;
document.body.querySelector(".modals").appendChild(modal);
}
}
class FilterDrawerBehavior extends ModalBehavior {
init() {
// Teleport content before creating focus trap, otherwise it will not detect
// focusable content elements
this.teleport();
super.init();
// Add active class to start slide-in animation
this.element.classList.add("active");
}
destroy() {
super.destroy();
// Always close on destroy to restore drawer content to original location
// before turbo caches DOM
this.doClose();
}
mapHeading(container, from, to) {
const headings = container.querySelectorAll(from);
headings.forEach((heading) => {
const newHeading = document.createElement(to);
newHeading.textContent = heading.textContent;
heading.replaceWith(newHeading);
});
}
teleport() {
const content = this.element.querySelector(".content");
const sidePanel = document.querySelector("section.side-panel");
content.append(...sidePanel.children);
this.mapHeading(content, "h2", "h3");
}
teleportBack() {
const sidePanel = document.querySelector("section.side-panel");
const content = this.element.querySelector(".content");
sidePanel.append(...content.children);
this.mapHeading(sidePanel, "h3", "h2");
}
doClose() {
super.doClose();
this.teleportBack();
// Try restore focus to drawer trigger
const restoreFocusElement =
document.querySelector("[ld-filter-drawer-trigger]") || document.body;
restoreFocusElement.focus({ focusVisible: isKeyboardActive() });
}
}
registerBehavior("ld-filter-drawer-trigger", FilterDrawerTriggerBehavior);
registerBehavior("ld-filter-drawer", FilterDrawerBehavior);

View File

@@ -0,0 +1,59 @@
let keyboardActive = false;
window.addEventListener(
"keydown",
() => {
keyboardActive = true;
},
{ capture: true },
);
window.addEventListener(
"mousedown",
() => {
keyboardActive = false;
},
{ capture: true },
);
export function isKeyboardActive() {
return keyboardActive;
}
export class FocusTrapController {
constructor(element) {
this.element = element;
this.focusableElements = this.element.querySelectorAll(
'a[href]:not([disabled]), button:not([disabled]), textarea:not([disabled]), input[type="text"]:not([disabled]), input[type="radio"]:not([disabled]), input[type="checkbox"]:not([disabled]), select:not([disabled])',
);
this.firstFocusableElement = this.focusableElements[0];
this.lastFocusableElement =
this.focusableElements[this.focusableElements.length - 1];
this.onKeyDown = this.onKeyDown.bind(this);
this.firstFocusableElement.focus({ focusVisible: keyboardActive });
this.element.addEventListener("keydown", this.onKeyDown);
}
destroy() {
this.element.removeEventListener("keydown", this.onKeyDown);
}
onKeyDown(event) {
if (event.key !== "Tab") {
return;
}
if (event.shiftKey) {
if (document.activeElement === this.firstFocusableElement) {
event.preventDefault();
this.lastFocusableElement.focus();
}
} else {
if (document.activeElement === this.lastFocusableElement) {
event.preventDefault();
this.firstFocusableElement.focus();
}
}
}
}

View File

@@ -0,0 +1,91 @@
import { Behavior } from "./index";
import { FocusTrapController } from "./focus-utils";
export class ModalBehavior extends Behavior {
constructor(element) {
super(element);
this.onClose = this.onClose.bind(this);
this.onKeyDown = this.onKeyDown.bind(this);
this.overlay = element.querySelector(".modal-overlay");
this.closeButton = element.querySelector(".modal-header .close");
this.overlay.addEventListener("click", this.onClose);
this.closeButton.addEventListener("click", this.onClose);
document.addEventListener("keydown", this.onKeyDown);
this.init();
}
destroy() {
this.overlay.removeEventListener("click", this.onClose);
this.closeButton.removeEventListener("click", this.onClose);
document.removeEventListener("keydown", this.onKeyDown);
this.clearInert();
this.focusTrap.destroy();
}
init() {
this.setupInert();
this.focusTrap = new FocusTrapController(
this.element.querySelector(".modal-container"),
);
}
setupInert() {
// Inert all other elements on the page
document
.querySelectorAll("body > *:not(.modals)")
.forEach((el) => el.setAttribute("inert", ""));
// Lock scroll on the body
document.body.classList.add("scroll-lock");
}
clearInert() {
// Clear inert attribute from all elements to allow focus outside the modal again
document
.querySelectorAll("body > *")
.forEach((el) => el.removeAttribute("inert"));
// Remove scroll lock from the body
document.body.classList.remove("scroll-lock");
}
onKeyDown(event) {
// Skip if event occurred within an input element
const targetNodeName = event.target.nodeName;
const isInputTarget =
targetNodeName === "INPUT" ||
targetNodeName === "SELECT" ||
targetNodeName === "TEXTAREA";
if (isInputTarget) {
return;
}
if (event.key === "Escape") {
this.onClose(event);
}
}
onClose(event) {
event.preventDefault();
this.element.classList.add("closing");
this.element.addEventListener(
"animationend",
(event) => {
if (event.animationName === "fade-out") {
this.doClose();
}
},
{ once: true },
);
}
doClose() {
this.element.remove();
this.clearInert();
this.element.dispatchEvent(new CustomEvent("modal:close"));
}
}

View File

@@ -1,68 +0,0 @@
import { Behavior, registerBehavior } from "./index";
class TagModalBehavior extends Behavior {
constructor(element) {
super(element);
this.onClick = this.onClick.bind(this);
this.onClose = this.onClose.bind(this);
element.addEventListener("click", this.onClick);
}
destroy() {
this.onClose();
this.element.removeEventListener("click", this.onClick);
}
onClick() {
const modal = document.createElement("div");
modal.classList.add("modal", "active");
modal.innerHTML = `
<div class="modal-overlay" aria-label="Close"></div>
<div class="modal-container">
<div class="modal-header">
<h2>Tags</h2>
<button class="close" aria-label="Close">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke-width="2"
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M18 6l-12 12"></path>
<path d="M6 6l12 12"></path>
</svg>
</button>
</div>
<div class="modal-body">
<div class="content"></div>
</div>
</div>
`;
const tagCloud = document.querySelector(".tag-cloud");
const tagCloudContainer = tagCloud.parentElement;
const content = modal.querySelector(".content");
content.appendChild(tagCloud);
const overlay = modal.querySelector(".modal-overlay");
const closeButton = modal.querySelector(".close");
overlay.addEventListener("click", this.onClose);
closeButton.addEventListener("click", this.onClose);
this.modal = modal;
this.tagCloud = tagCloud;
this.tagCloudContainer = tagCloudContainer;
document.body.appendChild(modal);
}
onClose() {
if (!this.modal) {
return;
}
this.modal.remove();
this.tagCloudContainer.appendChild(this.tagCloud);
}
}
registerBehavior("ld-tag-modal", TagModalBehavior);

View File

@@ -3,13 +3,13 @@ import "./behaviors/bookmark-page";
import "./behaviors/bulk-edit";
import "./behaviors/clear-button";
import "./behaviors/confirm-button";
import "./behaviors/dropdown";
import "./behaviors/form";
import "./behaviors/details-modal";
import "./behaviors/dropdown";
import "./behaviors/filter-drawer";
import "./behaviors/form";
import "./behaviors/global-shortcuts";
import "./behaviors/search-autocomplete";
import "./behaviors/tag-autocomplete";
import "./behaviors/tag-modal";
export { default as TagAutoComplete } from "./components/TagAutocomplete.svelte";
export { default as SearchAutoComplete } from "./components/SearchAutoComplete.svelte";

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.1.1 on 2024-09-28 08:03
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0041_merge_metadata"),
]
operations = [
migrations.AddField(
model_name="userprofile",
name="custom_css_hash",
field=models.CharField(blank=True, max_length=32),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.1.5 on 2025-02-02 09:35
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0042_userprofile_custom_css_hash"),
]
operations = [
migrations.AddField(
model_name="userprofile",
name="collapse_side_panel",
field=models.BooleanField(default=False),
),
]

View File

@@ -1,4 +1,5 @@
import binascii
import hashlib
import logging
import os
from typing import List
@@ -92,6 +93,19 @@ class Bookmark(models.Model):
return self.resolved_title + " (" + self.url[:30] + "...)"
@receiver(post_delete, sender=Bookmark)
def bookmark_deleted(sender, instance, **kwargs):
if instance.preview_image_file:
filepath = os.path.join(settings.LD_PREVIEW_FOLDER, instance.preview_image_file)
if os.path.isfile(filepath):
try:
os.remove(filepath)
except Exception as error:
logger.error(
f"Failed to delete preview image: {filepath}", exc_info=error
)
class BookmarkAsset(models.Model):
TYPE_SNAPSHOT = "snapshot"
TYPE_UPLOAD = "upload"
@@ -168,6 +182,24 @@ class BookmarkForm(forms.ModelForm):
self.instance and self.instance.notes
)
def clean_url(self):
# When creating a bookmark, the service logic prevents duplicate URLs by
# updating the existing bookmark instead, which is also communicated in
# the form's UI. When editing a bookmark, there is no assumption that
# it would update a different bookmark if the URL is a duplicate, so
# raise a validation error in that case.
url = self.cleaned_data["url"]
if self.instance.pk:
is_duplicate = (
Bookmark.objects.filter(owner=self.instance.owner, url=url)
.exclude(pk=self.instance.pk)
.exists()
)
if is_duplicate:
raise forms.ValidationError("A bookmark with this URL already exists.")
return url
class BookmarkSearch:
SORT_ADDED_ASC = "added_asc"
@@ -412,6 +444,7 @@ class UserProfile(models.Model):
display_remove_bookmark_action = models.BooleanField(default=True, null=False)
permanent_notes = models.BooleanField(default=False, null=False)
custom_css = models.TextField(blank=True, null=False)
custom_css_hash = models.CharField(blank=True, null=False, max_length=32)
auto_tagging_rules = models.TextField(blank=True, null=False)
search_preferences = models.JSONField(default=dict, null=False)
enable_automatic_html_snapshots = models.BooleanField(default=True, null=False)
@@ -420,6 +453,16 @@ class UserProfile(models.Model):
null=False, default=30, validators=[MinValueValidator(10)]
)
sticky_pagination = models.BooleanField(default=False, null=False)
collapse_side_panel = models.BooleanField(default=False, null=False)
def save(self, *args, **kwargs):
if self.custom_css:
self.custom_css_hash = hashlib.md5(
self.custom_css.encode("utf-8")
).hexdigest()
else:
self.custom_css_hash = ""
super().save(*args, **kwargs)
class UserProfileForm(forms.ModelForm):
@@ -450,6 +493,7 @@ class UserProfileForm(forms.ModelForm):
"auto_tagging_rules",
"items_per_page",
"sticky_pagination",
"collapse_side_panel",
]

View File

@@ -0,0 +1,128 @@
import gzip
import logging
import os
import shutil
from django.conf import settings
from django.core.files.uploadedfile import UploadedFile
from django.utils import timezone, formats
from bookmarks.models import Bookmark, BookmarkAsset
from bookmarks.services import singlefile
MAX_ASSET_FILENAME_LENGTH = 192
logger = logging.getLogger(__name__)
def create_snapshot_asset(bookmark: Bookmark) -> BookmarkAsset:
date_created = timezone.now()
timestamp = formats.date_format(date_created, "SHORT_DATE_FORMAT")
asset = BookmarkAsset(
bookmark=bookmark,
asset_type=BookmarkAsset.TYPE_SNAPSHOT,
date_created=date_created,
content_type=BookmarkAsset.CONTENT_TYPE_HTML,
display_name=f"HTML snapshot from {timestamp}",
status=BookmarkAsset.STATUS_PENDING,
)
return asset
def create_snapshot(asset: BookmarkAsset):
try:
# Create snapshot into temporary file
temp_filename = _generate_asset_filename(asset, asset.bookmark.url, "tmp")
temp_filepath = os.path.join(settings.LD_ASSET_FOLDER, temp_filename)
singlefile.create_snapshot(asset.bookmark.url, temp_filepath)
# Store as gzip in asset folder
filename = _generate_asset_filename(asset, asset.bookmark.url, "html.gz")
filepath = os.path.join(settings.LD_ASSET_FOLDER, filename)
with open(temp_filepath, "rb") as temp_file, gzip.open(
filepath, "wb"
) as gz_file:
shutil.copyfileobj(temp_file, gz_file)
# Remove temporary file
os.remove(temp_filepath)
asset.status = BookmarkAsset.STATUS_COMPLETE
asset.file = filename
asset.gzip = True
asset.save()
except Exception as error:
asset.status = BookmarkAsset.STATUS_FAILURE
asset.save()
raise error
def upload_snapshot(bookmark: Bookmark, html: bytes):
asset = create_snapshot_asset(bookmark)
filename = _generate_asset_filename(asset, asset.bookmark.url, "html.gz")
filepath = os.path.join(settings.LD_ASSET_FOLDER, filename)
with gzip.open(filepath, "wb") as gz_file:
gz_file.write(html)
# Only save the asset if the file was written successfully
asset.status = BookmarkAsset.STATUS_COMPLETE
asset.file = filename
asset.gzip = True
asset.save()
return asset
def upload_asset(bookmark: Bookmark, upload_file: UploadedFile):
try:
asset = BookmarkAsset(
bookmark=bookmark,
asset_type=BookmarkAsset.TYPE_UPLOAD,
date_created=timezone.now(),
content_type=upload_file.content_type,
display_name=upload_file.name,
status=BookmarkAsset.STATUS_COMPLETE,
gzip=False,
)
name, extension = os.path.splitext(upload_file.name)
filename = _generate_asset_filename(asset, name, extension.lstrip("."))
filepath = os.path.join(settings.LD_ASSET_FOLDER, filename)
with open(filepath, "wb") as f:
for chunk in upload_file.chunks():
f.write(chunk)
asset.file = filename
asset.file_size = upload_file.size
asset.save()
logger.info(
f"Successfully uploaded asset file. bookmark={bookmark} file={upload_file.name}"
)
return asset
except Exception as e:
logger.error(
f"Failed to upload asset file. bookmark={bookmark} file={upload_file.name}",
exc_info=e,
)
raise e
def _generate_asset_filename(
asset: BookmarkAsset, filename: str, extension: str
) -> str:
def sanitize_char(char):
if char.isalnum() or char in ("-", "_", "."):
return char
else:
return "_"
formatted_datetime = asset.date_created.strftime("%Y-%m-%d_%H%M%S")
sanitized_filename = "".join(sanitize_char(char) for char in filename)
# Calculate the length of fixed parts of the final filename
non_filename_length = len(f"{asset.asset_type}_{formatted_datetime}_.{extension}")
# Calculate the maximum length for the dynamic part of the filename
max_filename_length = MAX_ASSET_FILENAME_LENGTH - non_filename_length
# Truncate the filename if necessary
sanitized_filename = sanitized_filename[:max_filename_length]
return f"{asset.asset_type}_{formatted_datetime}_{sanitized_filename}.{extension}"

View File

@@ -7,6 +7,9 @@ def get_tags(script: str, url: str):
parsed_url = urlparse(url.lower())
result = set()
if not parsed_url.hostname:
return result
for line in script.lower().split("\n"):
if "#" in line:
i = line.index("#")

View File

@@ -1,22 +1,24 @@
import logging
import os
from typing import Union
from django.conf import settings
from django.contrib.auth.models import User
from django.core.files.uploadedfile import UploadedFile
from django.utils import timezone
from bookmarks.models import Bookmark, BookmarkAsset, parse_tag_string
from bookmarks.models import Bookmark, parse_tag_string
from bookmarks.services import auto_tagging
from bookmarks.services import tasks
from bookmarks.services import website_loader
from bookmarks.services import auto_tagging
from bookmarks.services.tags import get_or_create_tags
logger = logging.getLogger(__name__)
def create_bookmark(bookmark: Bookmark, tag_string: str, current_user: User):
def create_bookmark(
bookmark: Bookmark,
tag_string: str,
current_user: User,
disable_html_snapshot: bool = False,
):
# If URL is already bookmarked, then update it
existing_bookmark: Bookmark = Bookmark.objects.filter(
owner=current_user, url=bookmark.url
@@ -42,7 +44,10 @@ def create_bookmark(bookmark: Bookmark, tag_string: str, current_user: User):
# Load preview image
tasks.load_preview_image(current_user, bookmark)
# Create HTML snapshot
if current_user.profile.enable_automatic_html_snapshots:
if (
current_user.profile.enable_automatic_html_snapshots
and not disable_html_snapshot
):
tasks.create_html_snapshot(bookmark)
return bookmark
@@ -65,7 +70,6 @@ def update_bookmark(bookmark: Bookmark, tag_string, current_user: User):
if has_url_changed:
# Update web archive snapshot, if URL changed
tasks.create_web_archive_snapshot(current_user, bookmark, True)
bookmark.save()
return bookmark
@@ -194,46 +198,6 @@ def unshare_bookmarks(bookmark_ids: [Union[int, str]], current_user: User):
)
def _generate_upload_asset_filename(asset: BookmarkAsset, filename: str):
formatted_datetime = asset.date_created.strftime("%Y-%m-%d_%H%M%S")
return f"{asset.asset_type}_{formatted_datetime}_{filename}"
def upload_asset(bookmark: Bookmark, upload_file: UploadedFile) -> BookmarkAsset:
asset = BookmarkAsset(
bookmark=bookmark,
asset_type=BookmarkAsset.TYPE_UPLOAD,
content_type=upload_file.content_type,
display_name=upload_file.name,
status=BookmarkAsset.STATUS_PENDING,
gzip=False,
)
asset.save()
try:
filename = _generate_upload_asset_filename(asset, upload_file.name)
filepath = os.path.join(settings.LD_ASSET_FOLDER, filename)
with open(filepath, "wb") as f:
for chunk in upload_file.chunks():
f.write(chunk)
asset.status = BookmarkAsset.STATUS_COMPLETE
asset.file = filename
asset.file_size = upload_file.size
logger.info(
f"Successfully uploaded asset file. bookmark={bookmark} file={upload_file.name}"
)
except Exception as e:
logger.error(
f"Failed to upload asset file. bookmark={bookmark} file={upload_file.name}",
exc_info=e,
)
asset.status = BookmarkAsset.STATUS_FAILURE
asset.save()
return asset
def _merge_bookmark_data(from_bookmark: Bookmark, to_bookmark: Bookmark):
to_bookmark.title = from_bookmark.title
to_bookmark.description = from_bookmark.description

View File

@@ -40,9 +40,10 @@ def append_bookmark(doc: BookmarkDocument, bookmark: Bookmark):
toread = "1" if bookmark.unread else "0"
private = "0" if bookmark.shared else "1"
added = int(bookmark.date_added.timestamp())
modified = int(bookmark.date_modified.timestamp())
doc.append(
f'<DT><A HREF="{url}" ADD_DATE="{added}" PRIVATE="{private}" TOREAD="{toread}" TAGS="{tags}">{title}</A>'
f'<DT><A HREF="{url}" ADD_DATE="{added}" LAST_MODIFIED="{modified}" PRIVATE="{private}" TOREAD="{toread}" TAGS="{tags}">{title}</A>'
)
if desc:

View File

@@ -231,7 +231,10 @@ def _copy_bookmark_data(
bookmark.date_added = parse_timestamp(netscape_bookmark.date_added)
else:
bookmark.date_added = timezone.now()
bookmark.date_modified = bookmark.date_added
if netscape_bookmark.date_modified:
bookmark.date_modified = parse_timestamp(netscape_bookmark.date_modified)
else:
bookmark.date_modified = bookmark.date_added
bookmark.unread = netscape_bookmark.to_read
if netscape_bookmark.title:
bookmark.title = netscape_bookmark.title

View File

@@ -12,6 +12,7 @@ class NetscapeBookmark:
description: str
notes: str
date_added: str
date_modified: str
tag_names: List[str]
to_read: bool
private: bool
@@ -27,6 +28,7 @@ class BookmarkParser(HTMLParser):
self.bookmark = None
self.href = ""
self.add_date = ""
self.last_modified = ""
self.tags = ""
self.title = ""
self.description = ""
@@ -72,6 +74,7 @@ class BookmarkParser(HTMLParser):
description="",
notes="",
date_added=self.add_date,
date_modified=self.last_modified,
tag_names=tag_names,
to_read=self.toread == "1",
# Mark as private by default, also when attribute is not specified
@@ -97,6 +100,7 @@ class BookmarkParser(HTMLParser):
self.bookmark = None
self.href = ""
self.add_date = ""
self.last_modified = ""
self.tags = ""
self.title = ""
self.description = ""

View File

@@ -1,8 +1,6 @@
import gzip
import logging
import os
import shlex
import shutil
import signal
import subprocess
@@ -18,27 +16,20 @@ logger = logging.getLogger(__name__)
def create_snapshot(url: str, filepath: str):
singlefile_path = settings.LD_SINGLEFILE_PATH
# parse options to list of arguments
ublock_options = shlex.split(settings.LD_SINGLEFILE_UBLOCK_OPTIONS)
custom_options = shlex.split(settings.LD_SINGLEFILE_OPTIONS)
temp_filepath = filepath + ".tmp"
# concat lists
args = [singlefile_path] + ublock_options + custom_options + [url, temp_filepath]
args = [singlefile_path] + ublock_options + custom_options + [url, filepath]
try:
# Use start_new_session=True to create a new process group
process = subprocess.Popen(args, start_new_session=True)
process.wait(timeout=settings.LD_SINGLEFILE_TIMEOUT_SEC)
# check if the file was created
if not os.path.exists(temp_filepath):
if not os.path.exists(filepath):
raise SingleFileError("Failed to create snapshot")
with open(temp_filepath, "rb") as raw_file, gzip.open(
filepath, "wb"
) as gz_file:
shutil.copyfileobj(raw_file, gz_file)
os.remove(temp_filepath)
except subprocess.TimeoutExpired:
# First try to terminate properly
try:

View File

@@ -1,6 +1,5 @@
import functools
import logging
import os
from typing import List
import waybackpy
@@ -8,14 +7,13 @@ from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.auth.models import User
from django.db.models import Q
from django.utils import timezone, formats
from huey import crontab
from huey.contrib.djhuey import HUEY as huey
from huey.exceptions import TaskLockedException
from waybackpy.exceptions import WaybackError, TooManyRequestsError
from bookmarks.models import Bookmark, BookmarkAsset, UserProfile
from bookmarks.services import favicon_loader, singlefile, preview_image_loader
from bookmarks.services import assets, favicon_loader, preview_image_loader
from bookmarks.services.website_loader import DEFAULT_USER_AGENT
logger = logging.getLogger(__name__)
@@ -236,7 +234,7 @@ def create_html_snapshot(bookmark: Bookmark):
if not is_html_snapshot_feature_active():
return
asset = _create_snapshot_asset(bookmark)
asset = assets.create_snapshot_asset(bookmark)
asset.save()
@@ -246,47 +244,12 @@ def create_html_snapshots(bookmark_list: List[Bookmark]):
assets_to_create = []
for bookmark in bookmark_list:
asset = _create_snapshot_asset(bookmark)
asset = assets.create_snapshot_asset(bookmark)
assets_to_create.append(asset)
BookmarkAsset.objects.bulk_create(assets_to_create)
MAX_SNAPSHOT_FILENAME_LENGTH = 192
def _create_snapshot_asset(bookmark: Bookmark) -> BookmarkAsset:
timestamp = formats.date_format(timezone.now(), "SHORT_DATE_FORMAT")
asset = BookmarkAsset(
bookmark=bookmark,
asset_type=BookmarkAsset.TYPE_SNAPSHOT,
content_type="text/html",
display_name=f"HTML snapshot from {timestamp}",
status=BookmarkAsset.STATUS_PENDING,
)
return asset
def _generate_snapshot_filename(asset: BookmarkAsset) -> str:
def sanitize_char(char):
if char.isalnum() or char in ("-", "_", "."):
return char
else:
return "_"
formatted_datetime = asset.date_created.strftime("%Y-%m-%d_%H%M%S")
sanitized_url = "".join(sanitize_char(char) for char in asset.bookmark.url)
# Calculate the length of the non-URL parts of the filename
non_url_length = len(f"{asset.asset_type}{formatted_datetime}__.html.gz")
# Calculate the maximum length for the URL part
max_url_length = MAX_SNAPSHOT_FILENAME_LENGTH - non_url_length
# Truncate the URL if necessary
sanitized_url = sanitized_url[:max_url_length]
return f"{asset.asset_type}_{formatted_datetime}_{sanitized_url}.html.gz"
# singe-file does not support running multiple instances in parallel, so we can
# not queue up multiple snapshot tasks at once. Instead, schedule a periodic
# task that grabs a number of pending assets and creates snapshots for them in
@@ -313,13 +276,8 @@ def _create_html_snapshot_task(asset_id: int):
logger.info(f"Create HTML snapshot for bookmark. url={asset.bookmark.url}")
try:
filename = _generate_snapshot_filename(asset)
filepath = os.path.join(settings.LD_ASSET_FOLDER, filename)
singlefile.create_snapshot(asset.bookmark.url, filepath)
asset.status = BookmarkAsset.STATUS_COMPLETE
asset.file = filename
asset.gzip = True
asset.save()
assets.create_snapshot(asset)
logger.info(
f"Successfully created HTML snapshot for bookmark. url={asset.bookmark.url}"
)
@@ -328,8 +286,6 @@ def _create_html_snapshot_task(asset_id: int):
f"Failed to HTML snapshot for bookmark. url={asset.bookmark.url}",
exc_info=error,
)
asset.status = BookmarkAsset.STATUS_FAILURE
asset.save()
def create_missing_html_snapshots(user: User) -> int:

View File

@@ -0,0 +1,2 @@
User-agent: *
Disallow: /

View File

@@ -28,7 +28,7 @@
}
& .preview-image {
margin: var(--unit-4 0);
margin: var(--unit-4) 0;
img {
max-width: 100%;
@@ -36,8 +36,14 @@
}
}
& dl {
margin-bottom: 0;
& .sections section {
margin-top: var(--unit-4);
}
& .sections h3 {
margin-bottom: var(--unit-2);
font-size: var(--font-size);
font-weight: bold;
}
& .assets {

View File

@@ -10,8 +10,38 @@
}
/* Bookmark page grid */
.bookmarks-page.grid {
grid-gap: var(--unit-9);
.bookmarks-page {
&.grid {
grid-gap: var(--unit-9);
}
[ld-filter-drawer-trigger] {
display: none;
}
@media (max-width: 840px) {
section.side-panel {
display: none;
}
[ld-filter-drawer-trigger] {
display: inline-block;
}
}
&.collapse-side-panel {
section.main {
grid-column: span var(--grid-columns);
}
section.side-panel {
display: none;
}
[ld-filter-drawer-trigger] {
display: inline-block;
}
}
}
/* Bookmark area header controls */

View File

@@ -2,7 +2,8 @@
/* Content area component */
section.content-area {
h2 {
h2,
h3 {
font-size: var(--font-size-lg);
}
@@ -14,7 +15,8 @@ section.content-area {
padding-bottom: var(--unit-2);
margin-bottom: var(--unit-4);
h2 {
h2,
h3 {
flex: 0 0 auto;
line-height: var(--unit-9);
margin: 0;

View File

@@ -141,3 +141,10 @@
--bookmark-actions-weight: 400;
--bulk-actions-bg-color: var(--contrast-5);
}
/* Try to force dark color scheme for all native elements (e.g. upload button
in file inputs, native select dropdown). For the select dropdown some browsers
ignore this and use whatever users have configured in their system settings. */
:root {
color-scheme: dark;
}

View File

@@ -10,7 +10,6 @@ html {
font-size: var(--html-font-size);
line-height: var(--html-line-height);
-webkit-tap-highlight-color: transparent;
scrollbar-gutter: stable;
}
/* Reserve space for vert. scrollbar to avoid layout shifting when scrollbars are added */

View File

@@ -1,5 +1,7 @@
/* Dropdown */
.dropdown {
--dropdown-focus-display: block;
display: inline-block;
position: relative;
@@ -20,9 +22,13 @@
}
}
&.active .menu,
.dropdown-toggle:focus + .menu,
.menu:hover {
&:focus-within .menu {
/* Use custom CSS property to allow disabling opening on focus when using JS */
display: var(--dropdown-focus-display);
}
&.active .menu {
/* Always show menu when class is added through JS */
display: block;
}

View File

@@ -197,6 +197,16 @@ textarea.form-input {
no-repeat right 0.35rem center / 0.4rem 0.5rem;
padding-right: calc(var(--control-icon-size) + var(--control-padding-x));
}
/* Options */
& option {
/* On Windows with Chrome / Edge, options seems to use the same
background color as the select. However for the dark theme the
background is a semi-transparent white, resulting in an opaque white
background for the dropdown. Use the modal background color to force
a dark background instead. */
background: var(--modal-container-bg-color);
}
}
/* Form element: Checkbox and Radio */

View File

@@ -33,7 +33,7 @@
cursor: default;
display: block;
left: 0;
position: absolute;
position: fixed;
right: 0;
top: 0;
}
@@ -62,13 +62,14 @@
gap: var(--unit-4);
max-height: 75vh;
max-width: var(--control-width-md);
padding: var(--unit-6);
width: 100%;
& .modal-header {
display: flex;
align-items: flex-start;
gap: var(--unit-2);
padding: var(--unit-6);
padding-bottom: 0;
color: var(--text-color);
& h2 {
@@ -78,7 +79,7 @@
margin: 0;
}
& button.close {
& .close {
background: none;
border: none;
padding: 0;
@@ -95,10 +96,53 @@
& .modal-body {
overflow-y: auto;
position: relative;
padding: 0 var(--unit-6);
}
& .modal-body:not(:has(+ .modal-footer)) {
margin-bottom: var(--unit-6);
}
& .modal-footer {
padding: var(--unit-6);
padding-top: 0;
text-align: right;
}
}
.modal.drawer {
display: block;
& .modal-container {
position: fixed;
top: 0;
right: 0;
width: 400px;
max-width: 100%;
height: 100%;
max-height: 100%;
border: none;
border-left: solid 1px var(--modal-container-border-color);
border-radius: 0;
transform: translateX(100%);
animation: fade-in 0.25s ease 1;
transition: transform 0.25s ease;
}
&.active {
& .modal-container {
transform: translateX(0);
}
}
&.active.closing {
& .modal-container {
animation: fade-out 0.25s ease 1;
transform: translateX(100%);
}
}
}
.scroll-lock {
overflow: hidden !important;
}

View File

@@ -4,17 +4,17 @@
{% load bookmarks %}
{% block content %}
<div ld-bulk-edit class="bookmarks-page grid columns-md-1">
<div ld-bulk-edit
class="bookmarks-page grid columns-md-1 {% if bookmark_list.collapse_side_panel %}collapse-side-panel{% endif %}">
{# Bookmark list #}
<section class="content-area col-2">
<section class="main content-area col-2">
<div class="content-area-header mb-0">
<h2>Archived bookmarks</h2>
<div class="header-controls">
{% bookmark_search bookmark_list.search mode='archived' %}
{% include 'bookmarks/bulk_edit/toggle.html' %}
<button ld-tag-modal class="btn ml-2 show-md">Tags
</button>
<button ld-filter-drawer-trigger class="btn ml-2">Filters</button>
</div>
</div>
@@ -31,7 +31,7 @@
</section>
{# Tag cloud #}
<section class="content-area col-1 hide-md">
<section class="side-panel content-area col-1">
<div class="content-area-header">
<h2>Tags</h2>
</div>
@@ -39,12 +39,14 @@
{% include 'bookmarks/tag_cloud.html' %}
</div>
</section>
{# Bookmark details #}
<turbo-frame id="details-modal" target="_top">
{% if details %}
{% include 'bookmarks/details/modal.html' %}
{% endif %}
</turbo-frame>
</div>
{% endblock %}
{% block overlays %}
{# Bookmark details #}
<turbo-frame id="details-modal" target="_top">
{% if details %}
{% include 'bookmarks/details/modal.html' %}
{% endif %}
</turbo-frame>
{% endblock %}

View File

@@ -6,10 +6,12 @@
{% include 'bookmarks/empty_bookmarks.html' %}
{% else %}
<ul class="bookmark-list{% if bookmark_list.show_notes %} show-notes{% endif %}"
role="list" tabindex="-1"
style="--ld-bookmark-description-max-lines:{{ bookmark_list.description_max_lines }};"
data-bookmarks-total="{{ bookmark_list.bookmarks_total }}">
{% for bookmark_item in bookmark_list.items %}
<li ld-bookmark-item{% if bookmark_item.css_classes %} class="{{ bookmark_item.css_classes }}"{% endif %}>
<li ld-bookmark-item data-bookmark-id="{{ bookmark_item.id }}" role="listitem"
{% if bookmark_item.css_classes %}class="{{ bookmark_item.css_classes }}"{% endif %}>
<div class="content">
<div class="title">
<label class="form-checkbox bulk-edit-checkbox">
@@ -78,7 +80,8 @@
{% endif %}
{# View link is visible for both owned and shared bookmarks #}
{% if bookmark_list.show_view_action %}
<a href="{{ bookmark_item.details_url }}" data-turbo-action="replace" data-turbo-frame="details-modal">View</a>
<a href="{{ bookmark_item.details_url }}" class="view-action"
data-turbo-action="replace" data-turbo-frame="details-modal">View</a>
{% endif %}
{% if bookmark_item.is_editable %}
{# Bookmark owner actions #}

View File

@@ -33,12 +33,16 @@
{% if details.is_editable %}
<div class="assets-actions">
<button type="submit" name="create_html_snapshot" value="{{ details.bookmark.id }}" class="btn btn-sm"
{% if details.has_pending_assets %}disabled{% endif %}>Create HTML snapshot
</button>
<button ld-upload-button id="upload-asset" name="upload_asset" value="{{ details.bookmark.id }}" type="submit"
class="btn btn-sm">Upload file
</button>
{% if details.snapshots_enabled %}
<button type="submit" name="create_html_snapshot" value="{{ details.bookmark.id }}" class="btn btn-sm"
{% if details.has_pending_assets %}disabled{% endif %}>Create HTML snapshot
</button>
{% endif %}
{% if details.uploads_enabled %}
<button ld-upload-button id="upload-asset" name="upload_asset" value="{{ details.bookmark.id }}" type="submit"
class="btn btn-sm">Upload file
</button>
{% endif %}
<input id="upload-asset-file" name="upload_asset_file" type="file" class="d-hide">
</div>
{% endif %}

View File

@@ -40,14 +40,14 @@
</div>
{% if details.preview_image_enabled and details.bookmark.preview_image_file %}
<div class="preview-image">
<img src="{% static details.bookmark.preview_image_file %}"/>
<img src="{% static details.bookmark.preview_image_file %}" alt=""/>
</div>
{% endif %}
<dl class="grid columns-2 columns-sm-1 gap-0">
<div class="sections grid columns-2 columns-sm-1 gap-0">
{% if details.is_editable %}
<div class="status col-2">
<dt>Status</dt>
<dd class="d-flex" style="gap: .8rem">
<section class="status col-2">
<h3>Status</h3>
<div class="d-flex" style="gap: .8rem">
<div class="form-group">
<label class="form-switch">
<input ld-auto-submit type="checkbox" name="is_archived"
@@ -71,44 +71,42 @@
</label>
</div>
{% endif %}
</dd>
</div>
</div>
</section>
{% endif %}
{% if details.show_files %}
<div class="files col-2">
<dt>Files</dt>
<dd>
{% include 'bookmarks/details/assets.html' %}
</dd>
<section class="files col-2">
<h3>Files</h3>
<div>
{% include 'bookmarks/details/assets.html' %}
</div>
{% endif %}
</section>
{% if details.bookmark.tag_names %}
<div class="tags col-1">
<dt>Tags</dt>
<dd>
<section class="tags col-1">
<h3 id="details-modal-tags-title">Tags</h3>
<div>
{% for tag_name in details.bookmark.tag_names %}
<a href="{% url 'bookmarks:index' %}?{% add_tag_to_query tag_name %}">{{ tag_name|hash_tag }}</a>
{% endfor %}
</dd>
</div>
</div>
</section>
{% endif %}
<div class="date-added col-1">
<dt>Date added</dt>
<dd>
<section class="date-added col-1">
<h3>Date added</h3>
<div>
<span>{{ details.bookmark.date_added }}</span>
</dd>
</div>
{% if details.bookmark.resolved_description %}
<div class="description col-2">
<dt>Description</dt>
<dd>{{ details.bookmark.resolved_description }}</dd>
</div>
</section>
{% if details.bookmark.resolved_description %}
<section class="description col-2">
<h3>Description</h3>
<div>{{ details.bookmark.resolved_description }}</div>
</section>
{% endif %}
{% if details.bookmark.notes %}
<div class="notes col-2">
<dt>Notes</dt>
<dd class="markdown">{% markdown details.bookmark.notes %}</dd>
</div>
<section class="notes col-2">
<h3>Notes</h3>
<div class="markdown">{% markdown details.bookmark.notes %}</div>
</section>
{% endif %}
</dl>
</div>
</form>

View File

@@ -1,21 +1,17 @@
<div class="modal active bookmark-details"
ld-details-modal>
<a href="{{ details.close_url }}" data-turbo-frame="details-modal">
<div class="modal-overlay" aria-label="Close"></div>
</a>
<div class="modal-container">
<div class="modal active bookmark-details" ld-details-modal
data-bookmark-id="{{ details.bookmark.id }}" data-close-url="{{ details.close_url }}">
<div class="modal-overlay"></div>
<div class="modal-container" role="dialog" aria-modal="true">
<div class="modal-header">
<h2>{{ details.bookmark.resolved_title }}</h2>
<a href="{{ details.close_url }}" data-turbo-frame="details-modal">
<button class="close">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke-width="2"
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M18 6l-12 12"></path>
<path d="M6 6l12 12"></path>
</svg>
</button>
</a>
<button class="close" aria-label="Close dialog">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke-width="2"
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M18 6l-12 12"></path>
<path d="M6 6l12 12"></path>
</svg>
</button>
</div>
<div class="modal-body">
<div class="content">

View File

@@ -30,11 +30,14 @@
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#5856e0">
{% endif %}
{% if request.user_profile.custom_css %}
<style>{{ request.user_profile.custom_css }}</style>
<link href="{% url 'bookmarks:custom_css' %}?hash={{ request.user_profile.custom_css_hash }}" rel="stylesheet" type="text/css"/>
{% endif %}
<meta name="turbo-cache-control" content="no-preview">
{% if not request.global_settings.enable_link_prefetch %}
<meta name="turbo-prefetch" content="false">
{% endif %}
{% if rss_feed_url %}
<link rel="alternate" type="application/rss+xml" href="{{ rss_feed_url }}" />
{% endif %}
<script src="{% static "bundle.js" %}?v={{ app_version }}"></script>
</head>

View File

@@ -4,16 +4,17 @@
{% load bookmarks %}
{% block content %}
<div ld-bulk-edit class="bookmarks-page grid columns-md-1">
<div ld-bulk-edit
class="bookmarks-page grid columns-md-1 {% if bookmark_list.collapse_side_panel %}collapse-side-panel{% endif %}">
{# Bookmark list #}
<section class="content-area col-2">
<section class="main content-area col-2">
<div class="content-area-header mb-0">
<h2>Bookmarks</h2>
<div class="header-controls">
{% bookmark_search bookmark_list.search %}
{% include 'bookmarks/bulk_edit/toggle.html' %}
<button ld-tag-modal class="btn ml-2 show-md">Tags</button>
<button ld-filter-drawer-trigger class="btn ml-2">Filters</button>
</div>
</div>
@@ -30,7 +31,7 @@
</section>
{# Tag cloud #}
<section class="content-area col-1 hide-md">
<section class="side-panel content-area col-1">
<div class="content-area-header">
<h2>Tags</h2>
</div>
@@ -38,12 +39,14 @@
{% include 'bookmarks/tag_cloud.html' %}
</div>
</section>
{# Bookmark details #}
<turbo-frame id="details-modal" target="_top">
{% if details %}
{% include 'bookmarks/details/modal.html' %}
{% endif %}
</turbo-frame>
</div>
{% endblock %}
{% block overlays %}
{# Bookmark details #}
<turbo-frame id="details-modal" target="_top">
{% if details %}
{% include 'bookmarks/details/modal.html' %}
{% endif %}
</turbo-frame>
{% endblock %}

View File

@@ -97,5 +97,9 @@
{% block content %}
{% endblock %}
</div>
<div class="modals">
{% block overlays %}
{% endblock %}
</div>
</body>
</html>

View File

@@ -3,11 +3,11 @@
{# Basic menu list #}
<div class="hide-md">
<a href="{% url 'bookmarks:new' %}" class="btn btn-primary mr-2">Add bookmark</a>
<div class="dropdown">
<div ld-dropdown class="dropdown">
<button class="btn btn-link dropdown-toggle" tabindex="0">
Bookmarks
</button>
<ul class="menu">
<ul class="menu" role="list" tabindex="-1">
<li class="menu-item">
<a href="{% url 'bookmarks:index' %}" class="menu-link">Active</a>
</li>
@@ -28,28 +28,28 @@
</ul>
</div>
<a href="{% url 'bookmarks:settings.index' %}" class="btn btn-link">Settings</a>
<form class="d-inline" action="{% url 'logout' %}" method="post">
<form class="d-inline" action="{% url 'logout' %}" method="post" data-turbo="false">
{% csrf_token %}
<button type="submit" class="btn btn-link">Logout</button>
</form>
</div>
{# Menu drop-down for smaller devices #}
<div class="show-md">
<a href="{% url 'bookmarks:new' %}" class="btn btn-primary">
<a href="{% url 'bookmarks:new' %}" aria-label="Add bookmark" class="btn btn-primary">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"
style="width: 24px; height: 24px">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
</svg>
</a>
<div ld-dropdown class="dropdown dropdown-right">
<button class="btn btn-link dropdown-toggle" tabindex="0">
<button class="btn btn-link dropdown-toggle" aria-label="Navigation menu" tabindex="0">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"
style="width: 24px; height: 24px">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
</svg>
</button>
<!-- menu component -->
<ul class="menu">
<ul class="menu" role="list" tabindex="-1">
<li class="menu-item">
<a href="{% url 'bookmarks:index' %}" class="menu-link">Bookmarks</a>
</li>
@@ -72,7 +72,7 @@
<a href="{% url 'bookmarks:settings.index' %}" class="menu-link">Settings</a>
</li>
<li class="menu-item">
<form class="d-inline" action="{% url 'logout' %}" method="post">
<form class="d-inline" action="{% url 'logout' %}" method="post" data-turbo="false">
{% csrf_token %}
<button type="submit" class="btn btn-link menu-link">Logout</button>
</form>

View File

@@ -15,7 +15,8 @@
{% endfor %}
</form>
<div ld-dropdown class="search-options dropdown dropdown-right">
<button type="button" class="btn dropdown-toggle{% if search.has_modified_preferences %} badge{% endif %}">
<button type="button" aria-label="Search preferences"
class="btn dropdown-toggle{% if search.has_modified_preferences %} badge{% endif %}">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" stroke-width="2"
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
@@ -41,8 +42,11 @@
</div>
{% endif %}
{% if 'shared' in preferences_form.editable_fields %}
<div class="form-group radio-group">
<div class="form-label{% if 'shared' in search.modified_params %} text-bold{% endif %}">Shared filter</div>
<div class="form-group radio-group" role="radiogroup" aria-labelledby="search-shared-label">
<label id="search-shared-label"
class="form-label{% if 'shared' in search.modified_params %} text-bold{% endif %}">
Shared filter
</label>
{% for radio in preferences_form.shared %}
<label for="{{ radio.id_for_label }}" class="form-radio form-inline">
{{ radio.tag }}
@@ -53,8 +57,11 @@
</div>
{% endif %}
{% if 'unread' in preferences_form.editable_fields %}
<div class="form-group radio-group">
<div class="form-label{% if 'unread' in search.modified_params %} text-bold{% endif %}">Unread filter</div>
<div class="form-group radio-group" role="radiogroup" aria-labelledby="search-unread-label">
<label id="search-unread-label"
class="form-label{% if 'unread' in search.modified_params %} text-bold{% endif %}">
Unread filter
</label>
{% for radio in preferences_form.unread %}
<label for="{{ radio.id_for_label }}" class="form-radio form-inline">
{{ radio.tag }}

View File

@@ -4,16 +4,16 @@
{% load bookmarks %}
{% block content %}
<div class="bookmarks-page grid columns-md-1">
<div
class="bookmarks-page grid columns-md-1 {% if bookmark_list.collapse_side_panel %}collapse-side-panel{% endif %}">
{# Bookmark list #}
<section class="content-area col-2">
<section class="main content-area col-2">
<div class="content-area-header">
<h2>Shared bookmarks</h2>
<div class="header-controls">
{% bookmark_search bookmark_list.search mode='shared' %}
<button ld-tag-modal class="btn ml-2 show-md">Tags
</button>
<button ld-filter-drawer-trigger class="btn ml-2">Filters</button>
</div>
</div>
@@ -28,7 +28,7 @@
</section>
{# Filters #}
<section class="content-area col-1 hide-md">
<section class="side-panel content-area col-1">
<div class="content-area-header">
<h2>User</h2>
</div>
@@ -43,12 +43,14 @@
{% include 'bookmarks/tag_cloud.html' %}
</div>
</section>
{# Bookmark details #}
<turbo-frame id="details-modal" target="_top">
{% if details %}
{% include 'bookmarks/details/modal.html' %}
{% endif %}
</turbo-frame>
</div>
{% endblock %}
{% block overlays %}
{# Bookmark details #}
<turbo-frame id="details-modal" target="_top">
{% if details %}
{% include 'bookmarks/details/modal.html' %}
{% endif %}
</turbo-frame>
{% endblock %}

View File

@@ -124,6 +124,16 @@
visible without having to scroll to the end of the page first.
</div>
</div>
<div class="form-group">
<label for="{{ form.collapse_side_panel.id_for_label }}" class="form-checkbox">
{{ form.collapse_side_panel }}
<i class="form-icon"></i> Collapse side panel
</label>
<div class="form-input-hint">
When enabled, the tags side panel will be collapsed by default to give more space to the bookmark list.
Instead, the tags are shown in an expandable drawer.
</div>
</div>
<div class="form-group">
<label for="{{ form.tag_search.id_for_label }}" class="form-label">Tag search</label>
{{ form.tag_search|add_class:"form-select width-25 width-sm-100" }}

View File

@@ -1,14 +1,21 @@
import random
import gzip
import logging
import os
import random
import shutil
import tempfile
from datetime import datetime
from typing import List
from unittest import TestCase
from bs4 import BeautifulSoup
from django.conf import settings
from django.contrib.auth.models import User
from django.test import override_settings
from django.utils import timezone
from django.utils.crypto import get_random_string
from rest_framework import status
from rest_framework.authtoken.models import Token
from rest_framework.test import APITestCase
from bookmarks.models import Bookmark, BookmarkAsset, Tag
@@ -17,6 +24,16 @@ from bookmarks.models import Bookmark, BookmarkAsset, Tag
class BookmarkFactoryMixin:
user = None
def setup_temp_assets_dir(self):
self.assets_dir = tempfile.mkdtemp()
self.settings_override = override_settings(LD_ASSET_FOLDER=self.assets_dir)
self.settings_override.enable()
self.addCleanup(self.cleanup_temp_assets_dir)
def cleanup_temp_assets_dir(self):
shutil.rmtree(self.assets_dir)
self.settings_override.disable()
def get_or_create_test_user(self):
if self.user is None:
self.user = User.objects.create_user(
@@ -45,6 +62,7 @@ class BookmarkFactoryMixin:
favicon_file: str = "",
preview_image_file: str = "",
added: datetime = None,
modified: datetime = None,
):
if title is None:
title = get_random_string(length=32)
@@ -57,13 +75,15 @@ class BookmarkFactoryMixin:
url = "https://example.com/" + unique_id
if added is None:
added = timezone.now()
if modified is None:
modified = timezone.now()
bookmark = Bookmark(
url=url,
title=title,
description=description,
notes=notes,
date_added=added,
date_modified=timezone.now(),
date_modified=modified,
owner=user,
is_archived=is_archived,
unread=unread,
@@ -179,6 +199,24 @@ class BookmarkFactoryMixin:
asset.save()
return asset
def setup_asset_file(self, asset: BookmarkAsset, file_content: str = "test"):
filepath = os.path.join(settings.LD_ASSET_FOLDER, asset.file)
if asset.gzip:
with gzip.open(filepath, "wb") as f:
f.write(file_content.encode())
else:
with open(filepath, "w") as f:
f.write(file_content)
def read_asset_file(self, asset: BookmarkAsset):
filepath = os.path.join(settings.LD_ASSET_FOLDER, asset.file)
with open(filepath, "rb") as f:
return f.read()
def has_asset_file(self, asset: BookmarkAsset):
filepath = os.path.join(settings.LD_ASSET_FOLDER, asset.file)
return os.path.exists(filepath)
def setup_tag(self, user: User = None, name: str = ""):
if user is None:
user = self.get_or_create_test_user()
@@ -287,6 +325,12 @@ class TagCloudTestMixin(TestCase, HtmlTestMixin):
class LinkdingApiTestCase(APITestCase):
def authenticate(self):
self.api_token = Token.objects.get_or_create(
user=self.get_or_create_test_user()
)[0]
self.client.credentials(HTTP_AUTHORIZATION="Token " + self.api_token.key)
def get(self, url, expected_status_code=status.HTTP_200_OK):
response = self.client.get(url)
self.assertEqual(response.status_code, expected_status_code)
@@ -320,6 +364,7 @@ class BookmarkHtmlTag:
title: str = "",
description: str = "",
add_date: str = "",
last_modified: str = "",
tags: str = "",
to_read: bool = False,
private: bool = True,
@@ -328,6 +373,7 @@ class BookmarkHtmlTag:
self.title = title
self.description = description
self.add_date = add_date
self.last_modified = last_modified
self.tags = tags
self.to_read = to_read
self.private = private
@@ -339,6 +385,7 @@ class ImportTestMixin:
<DT>
<A {f'HREF="{tag.href}"' if tag.href else ''}
{f'ADD_DATE="{tag.add_date}"' if tag.add_date else ''}
{f'LAST_MODIFIED="{tag.last_modified}"' if tag.last_modified else ''}
{f'TAGS="{tag.tags}"' if tag.tags else ''}
TOREAD="{1 if tag.to_read else 0}"
PRIVATE="{1 if tag.private else 0}">

View File

@@ -0,0 +1,238 @@
import datetime
import gzip
import os
from unittest import mock
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase
from django.utils import timezone
from bookmarks.models import BookmarkAsset
from bookmarks.services import assets
from bookmarks.tests.helpers import BookmarkFactoryMixin, disable_logging
class AssetServiceTestCase(TestCase, BookmarkFactoryMixin):
def setUp(self) -> None:
self.setup_temp_assets_dir()
self.get_or_create_test_user()
self.html_content = "<html><body><h1>Hello, World!</h1></body></html>"
self.mock_singlefile_create_snapshot_patcher = mock.patch(
"bookmarks.services.singlefile.create_snapshot",
)
self.mock_singlefile_create_snapshot = (
self.mock_singlefile_create_snapshot_patcher.start()
)
self.mock_singlefile_create_snapshot.side_effect = lambda url, filepath: (
open(filepath, "w").write(self.html_content)
)
def tearDown(self) -> None:
self.mock_singlefile_create_snapshot_patcher.stop()
def get_saved_snapshot_file(self):
# look up first file in the asset folder
files = os.listdir(self.assets_dir)
if files:
return files[0]
def test_create_snapshot_asset(self):
bookmark = self.setup_bookmark()
asset = assets.create_snapshot_asset(bookmark)
self.assertIsNotNone(asset)
self.assertEqual(asset.bookmark, bookmark)
self.assertEqual(asset.asset_type, BookmarkAsset.TYPE_SNAPSHOT)
self.assertEqual(asset.content_type, BookmarkAsset.CONTENT_TYPE_HTML)
self.assertIn("HTML snapshot from", asset.display_name)
self.assertEqual(asset.status, BookmarkAsset.STATUS_PENDING)
# asset is not saved to the database
self.assertIsNone(asset.id)
def test_create_snapshot(self):
bookmark = self.setup_bookmark(url="https://example.com")
asset = assets.create_snapshot_asset(bookmark)
asset.save()
asset.date_created = timezone.datetime(
2023, 8, 11, 21, 45, 11, tzinfo=datetime.timezone.utc
)
assets.create_snapshot(asset)
expected_temp_filename = "snapshot_2023-08-11_214511_https___example.com.tmp"
expected_temp_filepath = os.path.join(self.assets_dir, expected_temp_filename)
expected_filename = "snapshot_2023-08-11_214511_https___example.com.html.gz"
expected_filepath = os.path.join(self.assets_dir, expected_filename)
# should call singlefile.create_snapshot with the correct arguments
self.mock_singlefile_create_snapshot.assert_called_once_with(
"https://example.com",
expected_temp_filepath,
)
# should create gzip file in asset folder
self.assertTrue(os.path.exists(expected_filepath))
# gzip file should contain the correct content
with gzip.open(expected_filepath, "rb") as gz_file:
self.assertEqual(gz_file.read().decode(), self.html_content)
# should remove temporary file
self.assertFalse(os.path.exists(expected_temp_filepath))
# should update asset status and file
asset.refresh_from_db()
self.assertEqual(asset.status, BookmarkAsset.STATUS_COMPLETE)
self.assertEqual(asset.file, expected_filename)
self.assertTrue(asset.gzip)
def test_create_snapshot_failure(self):
bookmark = self.setup_bookmark(url="https://example.com")
asset = assets.create_snapshot_asset(bookmark)
asset.save()
self.mock_singlefile_create_snapshot.side_effect = Exception
with self.assertRaises(Exception):
assets.create_snapshot(asset)
asset.refresh_from_db()
self.assertEqual(asset.status, BookmarkAsset.STATUS_FAILURE)
def test_create_snapshot_truncates_asset_file_name(self):
# Create a bookmark with a very long URL
long_url = "http://" + "a" * 300 + ".com"
bookmark = self.setup_bookmark(url=long_url)
asset = assets.create_snapshot_asset(bookmark)
asset.save()
assets.create_snapshot(asset)
saved_file = self.get_saved_snapshot_file()
self.assertEqual(192, len(saved_file))
self.assertTrue(saved_file.startswith("snapshot_"))
self.assertTrue(saved_file.endswith("aaaa.html.gz"))
def test_upload_snapshot(self):
bookmark = self.setup_bookmark(url="https://example.com")
asset = assets.upload_snapshot(bookmark, self.html_content.encode())
# should create gzip file in asset folder
saved_file_name = self.get_saved_snapshot_file()
self.assertIsNotNone(saved_file_name)
# verify file name
self.assertTrue(saved_file_name.startswith("snapshot_"))
self.assertTrue(saved_file_name.endswith("_https___example.com.html.gz"))
# gzip file should contain the correct content
with gzip.open(os.path.join(self.assets_dir, saved_file_name), "rb") as gz_file:
self.assertEqual(gz_file.read().decode(), self.html_content)
# should create asset
self.assertIsNotNone(asset.id)
self.assertEqual(asset.bookmark, bookmark)
self.assertEqual(asset.asset_type, BookmarkAsset.TYPE_SNAPSHOT)
self.assertEqual(asset.content_type, BookmarkAsset.CONTENT_TYPE_HTML)
self.assertIn("HTML snapshot from", asset.display_name)
self.assertEqual(asset.status, BookmarkAsset.STATUS_COMPLETE)
self.assertEqual(asset.file, saved_file_name)
self.assertTrue(asset.gzip)
def test_upload_snapshot_failure(self):
bookmark = self.setup_bookmark(url="https://example.com")
# make gzip.open raise an exception
with mock.patch("gzip.open") as mock_gzip_open:
mock_gzip_open.side_effect = Exception
with self.assertRaises(Exception):
assets.upload_snapshot(bookmark, b"invalid content")
# asset is not saved to the database
self.assertIsNone(BookmarkAsset.objects.first())
def test_upload_snapshot_truncates_asset_file_name(self):
# Create a bookmark with a very long URL
long_url = "http://" + "a" * 300 + ".com"
bookmark = self.setup_bookmark(url=long_url)
assets.upload_snapshot(bookmark, self.html_content.encode())
saved_file = self.get_saved_snapshot_file()
self.assertEqual(192, len(saved_file))
self.assertTrue(saved_file.startswith("snapshot_"))
self.assertTrue(saved_file.endswith("aaaa.html.gz"))
@disable_logging
def test_upload_asset(self):
bookmark = self.setup_bookmark()
file_content = b"test content"
upload_file = SimpleUploadedFile(
"test_file.txt", file_content, content_type="text/plain"
)
asset = assets.upload_asset(bookmark, upload_file)
# should create file in asset folder
saved_file_name = self.get_saved_snapshot_file()
self.assertIsNotNone(upload_file)
# verify file name
self.assertTrue(saved_file_name.startswith("upload_"))
self.assertTrue(saved_file_name.endswith("_test_file.txt"))
# file should contain the correct content
with open(os.path.join(self.assets_dir, saved_file_name), "rb") as file:
self.assertEqual(file.read(), file_content)
# should create asset
self.assertIsNotNone(asset.id)
self.assertEqual(asset.bookmark, bookmark)
self.assertEqual(asset.asset_type, BookmarkAsset.TYPE_UPLOAD)
self.assertEqual(asset.content_type, upload_file.content_type)
self.assertEqual(asset.display_name, upload_file.name)
self.assertEqual(asset.status, BookmarkAsset.STATUS_COMPLETE)
self.assertEqual(asset.file, saved_file_name)
self.assertEqual(asset.file_size, len(file_content))
self.assertFalse(asset.gzip)
@disable_logging
def test_upload_asset_truncates_asset_file_name(self):
# Create a bookmark with a very long URL
long_file_name = "a" * 300 + ".txt"
bookmark = self.setup_bookmark()
file_content = b"test content"
upload_file = SimpleUploadedFile(
long_file_name, file_content, content_type="text/plain"
)
assets.upload_asset(bookmark, upload_file)
saved_file = self.get_saved_snapshot_file()
self.assertEqual(192, len(saved_file))
self.assertTrue(saved_file.startswith("upload_"))
self.assertTrue(saved_file.endswith("aaaa.txt"))
@disable_logging
def test_upload_asset_failure(self):
bookmark = self.setup_bookmark()
upload_file = SimpleUploadedFile("test_file.txt", b"test content")
# make open raise an exception
with mock.patch("builtins.open") as mock_open:
mock_open.side_effect = Exception
with self.assertRaises(Exception):
assets.upload_asset(bookmark, upload_file)
# asset is not saved to the database
self.assertIsNone(BookmarkAsset.objects.first())

View File

@@ -0,0 +1,32 @@
from django.urls import reverse
from rest_framework import status
from rest_framework.authtoken.models import Token
from bookmarks.tests.helpers import LinkdingApiTestCase, BookmarkFactoryMixin
class AuthApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
def authenticate(self, keyword):
self.api_token = Token.objects.get_or_create(
user=self.get_or_create_test_user()
)[0]
self.client.credentials(HTTP_AUTHORIZATION=f"{keyword} {self.api_token.key}")
def test_auth_with_token_keyword(self):
self.authenticate("Token")
url = reverse("bookmarks:user-profile")
self.get(url, expected_status_code=status.HTTP_200_OK)
def test_auth_with_bearer_keyword(self):
self.authenticate("Bearer")
url = reverse("bookmarks:user-profile")
self.get(url, expected_status_code=status.HTTP_200_OK)
def test_auth_with_unknown_keyword(self):
self.authenticate("Key")
url = reverse("bookmarks:user-profile")
self.get(url, expected_status_code=status.HTTP_401_UNAUTHORIZED)

View File

@@ -14,6 +14,20 @@ class AutoTaggingTestCase(TestCase):
self.assertEqual(tags, {"example"})
def test_auto_tag_by_domain_handles_invalid_urls(self):
script = """
example.com example
test.com test
"""
url = "https://"
tags = auto_tagging.get_tags(script, url)
self.assertEqual(tags, set([]))
url = "example.com"
tags = auto_tagging.get_tags(script, url)
self.assertEqual(tags, set([]))
def test_auto_tag_by_domain_works_with_port(self):
script = """
example.com example

View File

@@ -8,7 +8,7 @@ from django.test import TestCase, override_settings
from django.urls import reverse
from bookmarks.models import Bookmark, BookmarkAsset
from bookmarks.services import tasks, bookmarks
from bookmarks.services import assets, tasks
from bookmarks.tests.helpers import (
BookmarkFactoryMixin,
BookmarkListTestMixin,
@@ -200,7 +200,7 @@ class BookmarkActionViewTestCase(
file_content = b"file content"
upload_file = SimpleUploadedFile("test.txt", file_content)
with patch.object(bookmarks, "upload_asset") as mock_upload_asset:
with patch.object(assets, "upload_asset") as mock_upload_asset:
response = self.client.post(
reverse("bookmarks:index.action"),
{"upload_asset": bookmark.id, "upload_asset_file": upload_file},
@@ -221,7 +221,7 @@ class BookmarkActionViewTestCase(
file_content = b"file content"
upload_file = SimpleUploadedFile("test.txt", file_content)
with patch.object(bookmarks, "upload_asset") as mock_upload_asset:
with patch.object(assets, "upload_asset") as mock_upload_asset:
response = self.client.post(
reverse("bookmarks:index.action"),
{"upload_asset": bookmark.id, "upload_asset_file": upload_file},
@@ -230,6 +230,27 @@ class BookmarkActionViewTestCase(
mock_upload_asset.assert_not_called()
@override_settings(LD_DISABLE_ASSET_UPLOAD=True)
def test_upload_asset_disabled(self):
bookmark = self.setup_bookmark()
file_content = b"file content"
upload_file = SimpleUploadedFile("test.txt", file_content)
response = self.client.post(
reverse("bookmarks:index.action"),
{"upload_asset": bookmark.id, "upload_asset_file": upload_file},
)
self.assertEqual(response.status_code, 403)
def test_upload_asset_without_file(self):
bookmark = self.setup_bookmark()
response = self.client.post(
reverse("bookmarks:index.action"),
{"upload_asset": bookmark.id},
)
self.assertEqual(response.status_code, 400)
def test_remove_asset(self):
bookmark = self.setup_bookmark()
asset = self.setup_asset(bookmark)

View File

@@ -503,3 +503,10 @@ class BookmarkArchivedViewTestCase(
self.assertIsNotNone(soup.select_one("turbo-frame#details-modal"))
self.assertIsNone(soup.select_one("#bookmark-list-container"))
self.assertIsNone(soup.select_one("#tag-cloud-container"))
def test_does_not_include_rss_feed(self):
response = self.client.get(reverse("bookmarks:archived"))
soup = self.make_soup(response.content.decode())
feed = soup.select_one('head link[type="application/rss+xml"]')
self.assertIsNone(feed)

View File

@@ -5,11 +5,11 @@ from django.test.utils import CaptureQueriesContext
from django.urls import reverse
from bookmarks.models import GlobalSettings
from bookmarks.tests.helpers import BookmarkFactoryMixin
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
class BookmarkArchivedViewPerformanceTestCase(
TransactionTestCase, BookmarkFactoryMixin
TransactionTestCase, BookmarkFactoryMixin, HtmlTestMixin
):
def setUp(self) -> None:
@@ -32,9 +32,10 @@ class BookmarkArchivedViewPerformanceTestCase(
context = CaptureQueriesContext(self.get_connection())
with context:
response = self.client.get(reverse("bookmarks:archived"))
self.assertContains(
response, "<li ld-bookmark-item>", num_initial_bookmarks
)
html = response.content.decode("utf-8")
soup = self.make_soup(html)
list_items = soup.select("li[ld-bookmark-item]")
self.assertEqual(len(list_items), num_initial_bookmarks)
number_of_queries = context.final_queries
@@ -46,8 +47,9 @@ class BookmarkArchivedViewPerformanceTestCase(
# assert num queries doesn't increase
with self.assertNumQueries(number_of_queries):
response = self.client.get(reverse("bookmarks:archived"))
self.assertContains(
response,
"<li ld-bookmark-item>",
num_initial_bookmarks + num_additional_bookmarks,
html = response.content.decode("utf-8")
soup = self.make_soup(html)
list_items = soup.select("li[ld-bookmark-item]")
self.assertEqual(
len(list_items), num_initial_bookmarks + num_additional_bookmarks
)

View File

@@ -11,19 +11,11 @@ from bookmarks.tests.helpers import (
class BookmarkAssetViewTestCase(TestCase, BookmarkFactoryMixin):
def setUp(self) -> None:
self.setup_temp_assets_dir()
user = self.get_or_create_test_user()
self.client.force_login(user)
def tearDown(self):
temp_files = [
f for f in os.listdir(settings.LD_ASSET_FOLDER) if f.startswith("temp")
]
for temp_file in temp_files:
os.remove(os.path.join(settings.LD_ASSET_FOLDER, temp_file))
def setup_asset_file(self, filename):
if not os.path.exists(settings.LD_ASSET_FOLDER):
os.makedirs(settings.LD_ASSET_FOLDER)
filepath = os.path.join(settings.LD_ASSET_FOLDER, filename)
with open(filepath, "w") as f:
f.write("test")

View File

@@ -3,23 +3,15 @@ import os
from django.conf import settings
from django.test import TestCase
from bookmarks.tests.helpers import (
BookmarkFactoryMixin,
)
from bookmarks.services import bookmarks
from bookmarks.tests.helpers import BookmarkFactoryMixin
class BookmarkAssetsTestCase(TestCase, BookmarkFactoryMixin):
def tearDown(self):
temp_files = [
f for f in os.listdir(settings.LD_ASSET_FOLDER) if f.startswith("temp")
]
for temp_file in temp_files:
os.remove(os.path.join(settings.LD_ASSET_FOLDER, temp_file))
def setUp(self):
self.setup_temp_assets_dir()
def setup_asset_file(self, filename):
if not os.path.exists(settings.LD_ASSET_FOLDER):
os.makedirs(settings.LD_ASSET_FOLDER)
filepath = os.path.join(settings.LD_ASSET_FOLDER, filename)
with open(filepath, "w") as f:
f.write("test")

View File

@@ -0,0 +1,340 @@
import io
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import override_settings
from django.urls import reverse
from rest_framework import status
from bookmarks.models import BookmarkAsset
from bookmarks.tests.helpers import LinkdingApiTestCase, BookmarkFactoryMixin
class BookmarkAssetsApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
def setUp(self):
self.setup_temp_assets_dir()
def assertAsset(self, asset: BookmarkAsset, data: dict):
self.assertEqual(asset.id, data["id"])
self.assertEqual(asset.bookmark.id, data["bookmark"])
self.assertEqual(
asset.date_created.isoformat().replace("+00:00", "Z"), data["date_created"]
)
self.assertEqual(asset.file_size, data["file_size"])
self.assertEqual(asset.asset_type, data["asset_type"])
self.assertEqual(asset.content_type, data["content_type"])
self.assertEqual(asset.display_name, data["display_name"])
self.assertEqual(asset.status, data["status"])
def test_asset_list(self):
self.authenticate()
bookmark1 = self.setup_bookmark(url="https://example1.com")
bookmark1_assets = [
self.setup_asset(bookmark=bookmark1),
self.setup_asset(bookmark=bookmark1),
self.setup_asset(bookmark=bookmark1),
]
bookmark2 = self.setup_bookmark(url="https://example2.com")
bookmark2_assets = [
self.setup_asset(bookmark=bookmark2),
self.setup_asset(bookmark=bookmark2),
self.setup_asset(bookmark=bookmark2),
]
url = reverse(
"bookmarks:bookmark_asset-list", kwargs={"bookmark_id": bookmark1.id}
)
response = self.get(url, expected_status_code=status.HTTP_200_OK)
self.assertEqual(len(response.data["results"]), 3)
self.assertAsset(bookmark1_assets[0], response.data["results"][0])
self.assertAsset(bookmark1_assets[1], response.data["results"][1])
self.assertAsset(bookmark1_assets[2], response.data["results"][2])
url = reverse(
"bookmarks:bookmark_asset-list", kwargs={"bookmark_id": bookmark2.id}
)
response = self.get(url, expected_status_code=status.HTTP_200_OK)
self.assertEqual(len(response.data["results"]), 3)
self.assertAsset(bookmark2_assets[0], response.data["results"][0])
self.assertAsset(bookmark2_assets[1], response.data["results"][1])
self.assertAsset(bookmark2_assets[2], response.data["results"][2])
def test_asset_list_only_returns_assets_for_own_bookmarks(self):
self.authenticate()
other_user = self.setup_user()
bookmark = self.setup_bookmark(user=other_user)
self.setup_asset(bookmark=bookmark)
url = reverse(
"bookmarks:bookmark_asset-list", kwargs={"bookmark_id": bookmark.id}
)
self.get(url, expected_status_code=status.HTTP_404_NOT_FOUND)
def test_asset_list_requires_authentication(self):
bookmark = self.setup_bookmark()
url = reverse(
"bookmarks:bookmark_asset-list", kwargs={"bookmark_id": bookmark.id}
)
self.get(url, expected_status_code=status.HTTP_401_UNAUTHORIZED)
def test_asset_detail(self):
self.authenticate()
bookmark = self.setup_bookmark()
asset = self.setup_asset(
bookmark=bookmark,
asset_type=BookmarkAsset.TYPE_UPLOAD,
file="cats.png",
file_size=1234,
content_type="image/png",
display_name="cats.png",
status=BookmarkAsset.STATUS_PENDING,
gzip=False,
)
url = reverse(
"bookmarks:bookmark_asset-detail",
kwargs={"bookmark_id": asset.bookmark.id, "pk": asset.id},
)
response = self.get(url, expected_status_code=status.HTTP_200_OK)
self.assertAsset(asset, response.data)
def test_asset_detail_only_returns_asset_for_own_bookmarks(self):
self.authenticate()
other_user = self.setup_user()
bookmark = self.setup_bookmark(user=other_user)
asset = self.setup_asset(bookmark=bookmark)
url = reverse(
"bookmarks:bookmark_asset-detail",
kwargs={"bookmark_id": asset.bookmark.id, "pk": asset.id},
)
self.get(url, expected_status_code=status.HTTP_404_NOT_FOUND)
def test_asset_detail_requires_authentication(self):
bookmark = self.setup_bookmark()
asset = self.setup_asset(bookmark=bookmark)
url = reverse(
"bookmarks:bookmark_asset-detail",
kwargs={"bookmark_id": asset.bookmark.id, "pk": asset.id},
)
self.get(url, expected_status_code=status.HTTP_401_UNAUTHORIZED)
def test_asset_download_with_snapshot_asset(self):
self.authenticate()
file_content = """
<html>
<head>
<title>Test</title>
</head>
<body>
<h1>Test</h1>
</body>
"""
bookmark = self.setup_bookmark()
asset = self.setup_asset(
bookmark=bookmark,
asset_type=BookmarkAsset.TYPE_SNAPSHOT,
display_name="Snapshot from today",
content_type="text/html",
gzip=True,
)
self.setup_asset_file(asset=asset, file_content=file_content)
url = reverse(
"bookmarks:bookmark_asset-download",
kwargs={"bookmark_id": asset.bookmark.id, "pk": asset.id},
)
response = self.get(url, expected_status_code=status.HTTP_200_OK)
self.assertEqual(response["Content-Type"], "text/html")
self.assertEqual(
response["Content-Disposition"],
'attachment; filename="Snapshot from today.html"',
)
content = b"".join(response.streaming_content).decode("utf-8")
self.assertEqual(content, file_content)
def test_asset_download_with_uploaded_asset(self):
self.authenticate()
file_content = "some file content"
bookmark = self.setup_bookmark()
asset = self.setup_asset(
bookmark=bookmark,
asset_type=BookmarkAsset.TYPE_UPLOAD,
display_name="cats.png",
content_type="image/png",
gzip=False,
)
self.setup_asset_file(asset=asset, file_content=file_content)
url = reverse(
"bookmarks:bookmark_asset-download",
kwargs={"bookmark_id": asset.bookmark.id, "pk": asset.id},
)
response = self.get(url, expected_status_code=status.HTTP_200_OK)
self.assertEqual(response["Content-Type"], "image/png")
self.assertEqual(
response["Content-Disposition"],
'attachment; filename="cats.png"',
)
content = b"".join(response.streaming_content).decode("utf-8")
self.assertEqual(content, file_content)
def test_asset_download_with_missing_file(self):
self.authenticate()
bookmark = self.setup_bookmark()
asset = self.setup_asset(
bookmark=bookmark,
asset_type=BookmarkAsset.TYPE_UPLOAD,
display_name="cats.png",
content_type="image/png",
gzip=False,
)
url = reverse(
"bookmarks:bookmark_asset-download",
kwargs={"bookmark_id": asset.bookmark.id, "pk": asset.id},
)
self.get(url, expected_status_code=status.HTTP_404_NOT_FOUND)
def test_asset_download_only_returns_asset_for_own_bookmarks(self):
self.authenticate()
other_user = self.setup_user()
bookmark = self.setup_bookmark(user=other_user)
asset = self.setup_asset(bookmark=bookmark)
url = reverse(
"bookmarks:bookmark_asset-download",
kwargs={"bookmark_id": asset.bookmark.id, "pk": asset.id},
)
self.get(url, expected_status_code=status.HTTP_404_NOT_FOUND)
def test_asset_download_requires_authentication(self):
bookmark = self.setup_bookmark()
asset = self.setup_asset(bookmark=bookmark)
url = reverse(
"bookmarks:bookmark_asset-download",
kwargs={"bookmark_id": asset.bookmark.id, "pk": asset.id},
)
self.get(url, expected_status_code=status.HTTP_401_UNAUTHORIZED)
def create_upload_body(self):
url = "https://example.com"
file_content = b"dummy content"
file = io.BytesIO(file_content)
file.name = "snapshot.html"
return {"url": url, "file": file}
def test_upload_asset(self):
self.authenticate()
bookmark = self.setup_bookmark()
url = reverse(
"bookmarks:bookmark_asset-upload", kwargs={"bookmark_id": bookmark.id}
)
file_content = b"test file content"
file_name = "test.txt"
file = SimpleUploadedFile(file_name, file_content, content_type="text/plain")
response = self.client.post(url, {"file": file}, format="multipart")
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
asset = BookmarkAsset.objects.get(id=response.data["id"])
self.assertEqual(asset.bookmark, bookmark)
self.assertEqual(asset.display_name, file_name)
self.assertEqual(asset.asset_type, BookmarkAsset.TYPE_UPLOAD)
self.assertEqual(asset.content_type, "text/plain")
self.assertEqual(asset.file_size, len(file_content))
self.assertFalse(asset.gzip)
content = self.read_asset_file(asset)
self.assertEqual(content, file_content)
def test_upload_asset_with_missing_file(self):
self.authenticate()
bookmark = self.setup_bookmark()
url = reverse(
"bookmarks:bookmark_asset-upload", kwargs={"bookmark_id": bookmark.id}
)
response = self.client.post(url, {}, format="multipart")
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
def test_upload_asset_only_works_for_own_bookmarks(self):
self.authenticate()
other_user = self.setup_user()
bookmark = self.setup_bookmark(user=other_user)
url = reverse(
"bookmarks:bookmark_asset-upload", kwargs={"bookmark_id": bookmark.id}
)
response = self.client.post(url, {}, format="multipart")
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
def test_upload_asset_requires_authentication(self):
bookmark = self.setup_bookmark()
url = reverse(
"bookmarks:bookmark_asset-upload", kwargs={"bookmark_id": bookmark.id}
)
response = self.client.post(url, {}, format="multipart")
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
@override_settings(LD_DISABLE_ASSET_UPLOAD=True)
def test_upload_asset_disabled(self):
self.authenticate()
bookmark = self.setup_bookmark()
url = reverse(
"bookmarks:bookmark_asset-upload", kwargs={"bookmark_id": bookmark.id}
)
response = self.client.post(url, {}, format="multipart")
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
def test_delete_asset(self):
self.authenticate()
bookmark = self.setup_bookmark()
asset = self.setup_asset(bookmark=bookmark)
self.setup_asset_file(asset=asset)
url = reverse(
"bookmarks:bookmark_asset-detail",
kwargs={"bookmark_id": asset.bookmark.id, "pk": asset.id},
)
self.delete(url, expected_status_code=status.HTTP_204_NO_CONTENT)
self.assertFalse(BookmarkAsset.objects.filter(id=asset.id).exists())
self.assertFalse(self.has_asset_file(asset))
def test_delete_asset_only_works_for_own_bookmarks(self):
self.authenticate()
other_user = self.setup_user()
bookmark = self.setup_bookmark(user=other_user)
asset = self.setup_asset(bookmark=bookmark)
url = reverse(
"bookmarks:bookmark_asset-detail",
kwargs={"bookmark_id": asset.bookmark.id, "pk": asset.id},
)
self.delete(url, expected_status_code=status.HTTP_404_NOT_FOUND)
def test_delete_asset_requires_authentication(self):
bookmark = self.setup_bookmark()
asset = self.setup_asset(bookmark=bookmark)
url = reverse(
"bookmarks:bookmark_asset-detail",
kwargs={"bookmark_id": asset.bookmark.id, "pk": asset.id},
)
self.delete(url, expected_status_code=status.HTTP_401_UNAUTHORIZED)

View File

@@ -32,15 +32,15 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
modal = soup.find("turbo-frame", {"id": "details-modal"})
return modal
def find_section(self, soup, section_name):
dt = soup.find("dt", string=section_name)
dd = dt.find_next_sibling("dd") if dt else None
return dd
def find_section_content(self, soup, section_name):
h3 = soup.find("h3", string=section_name)
content = h3.find_next_sibling("div") if h3 else None
return content
def get_section(self, soup, section_name):
dd = self.find_section(soup, section_name)
self.assertIsNotNone(dd)
return dd
def get_section_content(self, soup, section_name):
content = self.find_section_content(soup, section_name)
self.assertIsNotNone(content)
return content
def find_weblink(self, soup, url):
return soup.find("a", {"class": "weblink", "href": url})
@@ -367,7 +367,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
# sharing disabled
bookmark = self.setup_bookmark()
soup = self.get_index_details_modal(bookmark)
section = self.get_section(soup, "Status")
section = self.get_section_content(soup, "Status")
archived = section.find("input", {"type": "checkbox", "name": "is_archived"})
self.assertIsNotNone(archived)
@@ -383,7 +383,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
bookmark = self.setup_bookmark()
soup = self.get_index_details_modal(bookmark)
section = self.get_section(soup, "Status")
section = self.get_section_content(soup, "Status")
archived = section.find("input", {"type": "checkbox", "name": "is_archived"})
self.assertIsNotNone(archived)
@@ -395,7 +395,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
# unchecked
bookmark = self.setup_bookmark()
soup = self.get_index_details_modal(bookmark)
section = self.get_section(soup, "Status")
section = self.get_section_content(soup, "Status")
archived = section.find("input", {"type": "checkbox", "name": "is_archived"})
self.assertFalse(archived.has_attr("checked"))
@@ -407,7 +407,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
# checked
bookmark = self.setup_bookmark(is_archived=True, unread=True, shared=True)
soup = self.get_index_details_modal(bookmark)
section = self.get_section(soup, "Status")
section = self.get_section_content(soup, "Status")
archived = section.find("input", {"type": "checkbox", "name": "is_archived"})
self.assertTrue(archived.has_attr("checked"))
@@ -420,14 +420,14 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
# own bookmark
bookmark = self.setup_bookmark()
soup = self.get_index_details_modal(bookmark)
section = self.find_section(soup, "Status")
section = self.find_section_content(soup, "Status")
self.assertIsNotNone(section)
# other user's bookmark
other_user = self.setup_user(enable_sharing=True)
bookmark = self.setup_bookmark(user=other_user, shared=True)
soup = self.get_shared_details_modal(bookmark)
section = self.find_section(soup, "Status")
section = self.find_section_content(soup, "Status")
self.assertIsNone(section)
# guest user
@@ -436,13 +436,13 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
other_user.profile.save()
bookmark = self.setup_bookmark(user=other_user, shared=True)
soup = self.get_shared_details_modal(bookmark)
section = self.find_section(soup, "Status")
section = self.find_section_content(soup, "Status")
self.assertIsNone(section)
def test_date_added(self):
bookmark = self.setup_bookmark()
soup = self.get_index_details_modal(bookmark)
section = self.get_section(soup, "Date added")
section = self.get_section_content(soup, "Date added")
expected_date = formats.date_format(bookmark.date_added, "DATETIME_FORMAT")
date = section.find("span", string=expected_date)
@@ -453,14 +453,14 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
bookmark = self.setup_bookmark()
soup = self.get_index_details_modal(bookmark)
section = self.find_section(soup, "Tags")
section = self.find_section_content(soup, "Tags")
self.assertIsNone(section)
# with tags
bookmark = self.setup_bookmark(tags=[self.setup_tag(), self.setup_tag()])
soup = self.get_index_details_modal(bookmark)
section = self.get_section(soup, "Tags")
section = self.get_section_content(soup, "Tags")
for tag in bookmark.tags.all():
tag_link = section.find("a", string=f"#{tag.name}")
@@ -473,14 +473,14 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
bookmark = self.setup_bookmark(description="")
soup = self.get_index_details_modal(bookmark)
section = self.find_section(soup, "Description")
section = self.find_section_content(soup, "Description")
self.assertIsNone(section)
# with description
bookmark = self.setup_bookmark(description="Test description")
soup = self.get_index_details_modal(bookmark)
section = self.get_section(soup, "Description")
section = self.get_section_content(soup, "Description")
self.assertEqual(section.text.strip(), bookmark.description)
def test_notes(self):
@@ -488,14 +488,14 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
bookmark = self.setup_bookmark()
soup = self.get_index_details_modal(bookmark)
section = self.find_section(soup, "Notes")
section = self.find_section_content(soup, "Notes")
self.assertIsNone(section)
# with notes
bookmark = self.setup_bookmark(notes="Test notes")
soup = self.get_index_details_modal(bookmark)
section = self.get_section(soup, "Notes")
section = self.get_section_content(soup, "Notes")
self.assertEqual(section.decode_contents(), "<p>Test notes</p>")
def test_edit_link(self):
@@ -564,28 +564,12 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
self.assertIsNone(edit_link)
self.assertIsNone(delete_button)
def test_assets_visibility_no_snapshot_support(self):
bookmark = self.setup_bookmark()
soup = self.get_index_details_modal(bookmark)
section = self.find_section(soup, "Files")
self.assertIsNone(section)
@override_settings(LD_ENABLE_SNAPSHOTS=True)
def test_assets_visibility_with_snapshot_support(self):
bookmark = self.setup_bookmark()
soup = self.get_index_details_modal(bookmark)
section = self.find_section(soup, "Files")
self.assertIsNotNone(section)
@override_settings(LD_ENABLE_SNAPSHOTS=True)
def test_asset_list_visibility(self):
# no assets
bookmark = self.setup_bookmark()
soup = self.get_index_details_modal(bookmark)
section = self.get_section(soup, "Files")
section = self.get_section_content(soup, "Files")
asset_list = section.find("div", {"class": "assets"})
self.assertIsNone(asset_list)
@@ -594,11 +578,10 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
self.setup_asset(bookmark)
soup = self.get_index_details_modal(bookmark)
section = self.get_section(soup, "Files")
section = self.get_section_content(soup, "Files")
asset_list = section.find("div", {"class": "assets"})
self.assertIsNotNone(asset_list)
@override_settings(LD_ENABLE_SNAPSHOTS=True)
def test_asset_list(self):
bookmark = self.setup_bookmark()
assets = [
@@ -608,7 +591,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
]
soup = self.get_index_details_modal(bookmark)
section = self.get_section(soup, "Files")
section = self.get_section_content(soup, "Files")
asset_list = section.find("div", {"class": "assets"})
for asset in assets:
@@ -627,6 +610,76 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
self.assertIsNotNone(view_link)
@override_settings(LD_ENABLE_SNAPSHOTS=True)
def test_asset_list_actions_visibility(self):
# own bookmark
bookmark = self.setup_bookmark()
soup = self.get_index_details_modal(bookmark)
create_snapshot = soup.find(
"button", {"type": "submit", "name": "create_html_snapshot"}
)
upload_asset = soup.find("button", {"type": "submit", "name": "upload_asset"})
self.assertIsNotNone(create_snapshot)
self.assertIsNotNone(upload_asset)
# with sharing
other_user = self.setup_user(enable_sharing=True)
bookmark = self.setup_bookmark(user=other_user, shared=True)
soup = self.get_shared_details_modal(bookmark)
create_snapshot = soup.find(
"button", {"type": "submit", "name": "create_html_snapshot"}
)
upload_asset = soup.find("button", {"type": "submit", "name": "upload_asset"})
self.assertIsNone(create_snapshot)
self.assertIsNone(upload_asset)
# with public sharing
profile = other_user.profile
profile.enable_public_sharing = True
profile.save()
soup = self.get_shared_details_modal(bookmark)
create_snapshot = soup.find(
"button", {"type": "submit", "name": "create_html_snapshot"}
)
upload_asset = soup.find("button", {"type": "submit", "name": "upload_asset"})
self.assertIsNone(create_snapshot)
self.assertIsNone(upload_asset)
# guest user
self.client.logout()
bookmark = self.setup_bookmark(user=other_user, shared=True)
soup = self.get_shared_details_modal(bookmark)
edit_link = soup.find("a", string="Edit")
delete_button = soup.find("button", {"type": "submit", "name": "remove"})
self.assertIsNone(edit_link)
self.assertIsNone(delete_button)
def test_asset_list_actions_visibility_without_snapshots_enabled(self):
bookmark = self.setup_bookmark()
soup = self.get_index_details_modal(bookmark)
create_snapshot = soup.find(
"button", {"type": "submit", "name": "create_html_snapshot"}
)
upload_asset = soup.find("button", {"type": "submit", "name": "upload_asset"})
self.assertIsNone(create_snapshot)
self.assertIsNotNone(upload_asset)
@override_settings(LD_DISABLE_ASSET_UPLOAD=True)
def test_asset_list_actions_visibility_with_uploads_disabled(self):
bookmark = self.setup_bookmark()
soup = self.get_index_details_modal(bookmark)
create_snapshot = soup.find(
"button", {"type": "submit", "name": "create_html_snapshot"}
)
upload_asset = soup.find("button", {"type": "submit", "name": "upload_asset"})
self.assertIsNone(create_snapshot)
self.assertIsNone(upload_asset)
def test_asset_without_file(self):
bookmark = self.setup_bookmark()
asset = self.setup_asset(bookmark)
@@ -639,7 +692,6 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
view_link = asset_item.find("a", {"href": view_url})
self.assertIsNone(view_link)
@override_settings(LD_ENABLE_SNAPSHOTS=True)
def test_asset_status(self):
bookmark = self.setup_bookmark()
pending_asset = self.setup_asset(bookmark, status=BookmarkAsset.STATUS_PENDING)
@@ -655,7 +707,6 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
asset_text = asset_item.select_one(".asset-text span")
self.assertIn("(failed)", asset_text.text)
@override_settings(LD_ENABLE_SNAPSHOTS=True)
def test_asset_file_size(self):
bookmark = self.setup_bookmark()
asset1 = self.setup_asset(bookmark, file_size=None)
@@ -676,7 +727,6 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
asset_text = asset_item.select_one(".asset-text")
self.assertIn("11.0\xa0MB", asset_text.text)
@override_settings(LD_ENABLE_SNAPSHOTS=True)
def test_asset_actions_visibility(self):
bookmark = self.setup_bookmark()
@@ -738,7 +788,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
# no pending asset
soup = self.get_index_details_modal(bookmark)
files_section = self.find_section(soup, "Files")
files_section = self.find_section_content(soup, "Files")
create_button = files_section.find(
"button", string=re.compile("Create HTML snapshot")
)
@@ -749,7 +799,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
asset.save()
soup = self.get_index_details_modal(bookmark)
files_section = self.find_section(soup, "Files")
files_section = self.find_section_content(soup, "Files")
create_button = files_section.find(
"button", string=re.compile("Create HTML snapshot")
)

View File

@@ -26,6 +26,11 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
}
return {**form_data, **overrides}
def test_should_render_successfully(self):
bookmark = self.setup_bookmark()
response = self.client.get(reverse("bookmarks:edit", args=[bookmark.id]))
self.assertEqual(response.status_code, 200)
def test_should_edit_bookmark(self):
bookmark = self.setup_bookmark()
form_data = self.create_form_data({"id": bookmark.id})
@@ -46,6 +51,14 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertEqual(tags[0].name, "editedtag1")
self.assertEqual(tags[1].name, "editedtag2")
def test_should_return_422_with_invalid_form(self):
bookmark = self.setup_bookmark()
form_data = self.create_form_data({"id": bookmark.id, "url": ""})
response = self.client.post(
reverse("bookmarks:edit", args=[bookmark.id]), form_data
)
self.assertEqual(response.status_code, 422)
def test_should_edit_unread_state(self):
bookmark = self.setup_bookmark()
@@ -128,6 +141,40 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin):
html,
)
def test_should_prevent_duplicate_urls(self):
edited_bookmark = self.setup_bookmark(url="http://example.com/edited")
existing_bookmark = self.setup_bookmark(url="http://example.com/existing")
other_user_bookmark = self.setup_bookmark(
url="http://example.com/other-user", user=User.objects.create_user("other")
)
# if the URL isn't modified it's not a duplicate
form_data = self.create_form_data({"url": edited_bookmark.url})
response = self.client.post(
reverse("bookmarks:edit", args=[edited_bookmark.id]), form_data
)
self.assertEqual(response.status_code, 302)
# if the URL is already bookmarked by another user, it's not a duplicate
form_data = self.create_form_data({"url": other_user_bookmark.url})
response = self.client.post(
reverse("bookmarks:edit", args=[edited_bookmark.id]), form_data
)
self.assertEqual(response.status_code, 302)
# if the URL is already bookmarked by the same user, it's a duplicate
form_data = self.create_form_data({"url": existing_bookmark.url})
response = self.client.post(
reverse("bookmarks:edit", args=[edited_bookmark.id]), form_data
)
self.assertEqual(response.status_code, 422)
self.assertInHTML(
"<li>A bookmark with this URL already exists.</li>",
response.content.decode(),
)
edited_bookmark.refresh_from_db()
self.assertNotEqual(edited_bookmark.url, existing_bookmark.url)
def test_should_redirect_to_return_url(self):
bookmark = self.setup_bookmark()
form_data = self.create_form_data()

View File

@@ -481,3 +481,10 @@ class BookmarkIndexViewTestCase(
self.assertIsNotNone(soup.select_one("turbo-frame#details-modal"))
self.assertIsNone(soup.select_one("#bookmark-list-container"))
self.assertIsNone(soup.select_one("#tag-cloud-container"))
def test_does_not_include_rss_feed(self):
response = self.client.get(reverse("bookmarks:index"))
soup = self.make_soup(response.content.decode())
feed = soup.select_one('head link[type="application/rss+xml"]')
self.assertIsNone(feed)

View File

@@ -5,10 +5,12 @@ from django.test.utils import CaptureQueriesContext
from django.urls import reverse
from bookmarks.models import GlobalSettings
from bookmarks.tests.helpers import BookmarkFactoryMixin
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
class BookmarkIndexViewPerformanceTestCase(TransactionTestCase, BookmarkFactoryMixin):
class BookmarkIndexViewPerformanceTestCase(
TransactionTestCase, BookmarkFactoryMixin, HtmlTestMixin
):
def setUp(self) -> None:
user = self.get_or_create_test_user()
@@ -30,9 +32,10 @@ class BookmarkIndexViewPerformanceTestCase(TransactionTestCase, BookmarkFactoryM
context = CaptureQueriesContext(self.get_connection())
with context:
response = self.client.get(reverse("bookmarks:index"))
self.assertContains(
response, "<li ld-bookmark-item>", num_initial_bookmarks
)
html = response.content.decode("utf-8")
soup = self.make_soup(html)
list_items = soup.select("li[ld-bookmark-item]")
self.assertEqual(len(list_items), num_initial_bookmarks)
number_of_queries = context.final_queries
@@ -44,8 +47,9 @@ class BookmarkIndexViewPerformanceTestCase(TransactionTestCase, BookmarkFactoryM
# assert num queries doesn't increase
with self.assertNumQueries(number_of_queries):
response = self.client.get(reverse("bookmarks:index"))
self.assertContains(
response,
"<li ld-bookmark-item>",
num_initial_bookmarks + num_additional_bookmarks,
html = response.content.decode("utf-8")
soup = self.make_soup(html)
list_items = soup.select("li[ld-bookmark-item]")
self.assertEqual(
len(list_items), num_initial_bookmarks + num_additional_bookmarks
)

View File

@@ -46,6 +46,11 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertEqual(tags[0].name, "tag1")
self.assertEqual(tags[1].name, "tag2")
def test_should_return_422_with_invalid_form(self):
form_data = self.create_form_data({"url": ""})
response = self.client.post(reverse("bookmarks:new"), form_data)
self.assertEqual(response.status_code, 422)
def test_should_create_new_unread_bookmark(self):
form_data = self.create_form_data({"unread": True})

View File

@@ -0,0 +1,70 @@
import os
import shutil
import tempfile
from django.conf import settings
from django.test import TestCase, override_settings
from bookmarks.services import bookmarks
from bookmarks.tests.helpers import BookmarkFactoryMixin
class BookmarkPreviewsTestCase(TestCase, BookmarkFactoryMixin):
def setUp(self):
self.temp_dir = tempfile.mkdtemp()
self.override = override_settings(LD_PREVIEW_FOLDER=self.temp_dir)
self.override.enable()
def tearDown(self):
self.override.disable()
shutil.rmtree(self.temp_dir)
def setup_preview_file(self, filename):
filepath = os.path.join(settings.LD_PREVIEW_FOLDER, filename)
with open(filepath, "w") as f:
f.write("test")
def setup_bookmark_with_preview(self):
bookmark = self.setup_bookmark()
bookmark.preview_image_file = f"preview_{bookmark.id}.jpg"
bookmark.save()
self.setup_preview_file(bookmark.preview_image_file)
return bookmark
def assertPreviewImageExists(self, bookmark):
self.assertTrue(
os.path.exists(
os.path.join(settings.LD_PREVIEW_FOLDER, bookmark.preview_image_file)
)
)
def assertPreviewImageDoesNotExist(self, bookmark):
self.assertFalse(
os.path.exists(
os.path.join(settings.LD_PREVIEW_FOLDER, bookmark.preview_image_file)
)
)
def test_delete_bookmark_deletes_preview_image(self):
bookmark = self.setup_bookmark_with_preview()
self.assertPreviewImageExists(bookmark)
bookmark.delete()
self.assertPreviewImageDoesNotExist(bookmark)
def test_bulk_delete_bookmarks_deletes_preview_images(self):
bookmark1 = self.setup_bookmark_with_preview()
bookmark2 = self.setup_bookmark_with_preview()
bookmark3 = self.setup_bookmark_with_preview()
self.assertPreviewImageExists(bookmark1)
self.assertPreviewImageExists(bookmark2)
self.assertPreviewImageExists(bookmark3)
bookmarks.delete_bookmarks(
[bookmark1.id, bookmark2.id, bookmark3.id], self.get_or_create_test_user()
)
self.assertPreviewImageDoesNotExist(bookmark1)
self.assertPreviewImageDoesNotExist(bookmark2)
self.assertPreviewImageDoesNotExist(bookmark3)

View File

@@ -71,19 +71,15 @@ class BookmarkSearchTagTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
radios = form.select(f'input[name="{name}"][type="radio"]')
self.assertTrue(len(radios) == 0)
def assertUnmodifiedLabel(self, html: str, text: str, id: str = ""):
id_attr = f'for="{id}"' if id else ""
tag = "label" if id else "div"
needle = f'<{tag} class="form-label" {id_attr}>{text}</{tag}>'
def assertUnmodifiedLabel(self, html: str, text: str):
soup = self.make_soup(html)
label = soup.find("label", string=lambda s: s and s.strip() == text)
self.assertEqual(label["class"], ["form-label"])
self.assertInHTML(needle, html)
def assertModifiedLabel(self, html: str, text: str, id: str = ""):
id_attr = f'for="{id}"' if id else ""
tag = "label" if id else "div"
needle = f'<{tag} class="form-label text-bold" {id_attr}>{text}</{tag}>'
self.assertInHTML(needle, html)
def assertModifiedLabel(self, html: str, text: str):
soup = self.make_soup(html)
label = soup.find("label", string=lambda s: s and s.strip() == text)
self.assertEqual(label["class"], ["form-label", "text-bold"])
def test_search_form_inputs(self):
# Without params
@@ -190,54 +186,53 @@ class BookmarkSearchTagTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
# Without modifications
url = "/test"
rendered_template = self.render_template(url)
soup = self.make_soup(rendered_template)
button = soup.select_one("button[aria-label='Search preferences']")
self.assertIn(
'<button type="button" class="btn dropdown-toggle">', rendered_template
)
self.assertNotIn("badge", button["class"])
# With modifications
url = "/test?sort=title_asc"
rendered_template = self.render_template(url)
soup = self.make_soup(rendered_template)
button = soup.select_one("button[aria-label='Search preferences']")
self.assertIn(
'<button type="button" class="btn dropdown-toggle badge">',
rendered_template,
)
self.assertIn("badge", button["class"])
# Ignores non-preferences modifications
url = "/test?q=foo&user=john"
rendered_template = self.render_template(url)
soup = self.make_soup(rendered_template)
button = soup.select_one("button[aria-label='Search preferences']")
self.assertIn(
'<button type="button" class="btn dropdown-toggle">', rendered_template
)
self.assertNotIn("badge", button["class"])
def test_modified_labels(self):
# Without modifications
url = "/test"
rendered_template = self.render_template(url)
self.assertUnmodifiedLabel(rendered_template, "Sort by", "id_sort")
self.assertUnmodifiedLabel(rendered_template, "Sort by")
self.assertUnmodifiedLabel(rendered_template, "Shared filter")
self.assertUnmodifiedLabel(rendered_template, "Unread filter")
# Modified sort
url = "/test?sort=title_asc"
rendered_template = self.render_template(url)
self.assertModifiedLabel(rendered_template, "Sort by", "id_sort")
self.assertModifiedLabel(rendered_template, "Sort by")
self.assertUnmodifiedLabel(rendered_template, "Shared filter")
self.assertUnmodifiedLabel(rendered_template, "Unread filter")
# Modified shared
url = "/test?shared=yes"
rendered_template = self.render_template(url)
self.assertUnmodifiedLabel(rendered_template, "Sort by", "id_sort")
self.assertUnmodifiedLabel(rendered_template, "Sort by")
self.assertModifiedLabel(rendered_template, "Shared filter")
self.assertUnmodifiedLabel(rendered_template, "Unread filter")
# Modified unread
url = "/test?unread=yes"
rendered_template = self.render_template(url)
self.assertUnmodifiedLabel(rendered_template, "Sort by", "id_sort")
self.assertUnmodifiedLabel(rendered_template, "Sort by")
self.assertUnmodifiedLabel(rendered_template, "Shared filter")
self.assertModifiedLabel(rendered_template, "Unread filter")

View File

@@ -593,3 +593,11 @@ class BookmarkSharedViewTestCase(
self.assertIsNotNone(soup.select_one("turbo-frame#details-modal"))
self.assertIsNone(soup.select_one("#bookmark-list-container"))
self.assertIsNone(soup.select_one("#tag-cloud-container"))
def test_includes_public_shared_rss_feed(self):
response = self.client.get(reverse("bookmarks:shared"))
soup = self.make_soup(response.content.decode())
feed = soup.select_one('head link[type="application/rss+xml"]')
self.assertIsNotNone(feed)
self.assertEqual(feed.attrs["href"], reverse("bookmarks:feeds.public_shared"))

View File

@@ -5,10 +5,12 @@ from django.test.utils import CaptureQueriesContext
from django.urls import reverse
from bookmarks.models import GlobalSettings
from bookmarks.tests.helpers import BookmarkFactoryMixin
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
class BookmarkSharedViewPerformanceTestCase(TransactionTestCase, BookmarkFactoryMixin):
class BookmarkSharedViewPerformanceTestCase(
TransactionTestCase, BookmarkFactoryMixin, HtmlTestMixin
):
def setUp(self) -> None:
user = self.get_or_create_test_user()
@@ -31,9 +33,10 @@ class BookmarkSharedViewPerformanceTestCase(TransactionTestCase, BookmarkFactory
context = CaptureQueriesContext(self.get_connection())
with context:
response = self.client.get(reverse("bookmarks:shared"))
self.assertContains(
response, '<li ld-bookmark-item class="shared">', num_initial_bookmarks
)
html = response.content.decode("utf-8")
soup = self.make_soup(html)
list_items = soup.select("li[ld-bookmark-item]")
self.assertEqual(len(list_items), num_initial_bookmarks)
number_of_queries = context.final_queries
@@ -46,8 +49,9 @@ class BookmarkSharedViewPerformanceTestCase(TransactionTestCase, BookmarkFactory
# assert num queries doesn't increase
with self.assertNumQueries(number_of_queries):
response = self.client.get(reverse("bookmarks:shared"))
self.assertContains(
response,
'<li ld-bookmark-item class="shared">',
num_initial_bookmarks + num_additional_bookmarks,
html = response.content.decode("utf-8")
soup = self.make_soup(html)
list_items = soup.select("li[ld-bookmark-item]")
self.assertEqual(
len(list_items), num_initial_bookmarks + num_additional_bookmarks
)

View File

@@ -1,21 +1,39 @@
import datetime
import io
import urllib.parse
from collections import OrderedDict
from unittest.mock import patch
from unittest.mock import patch, ANY
from django.contrib.auth.models import User
from django.test import override_settings
from django.urls import reverse
from django.utils import timezone
from rest_framework import status
from rest_framework.authtoken.models import Token
from rest_framework.response import Response
import bookmarks.services.bookmarks
from bookmarks.models import Bookmark, BookmarkSearch, UserProfile
from bookmarks.services import website_loader
from bookmarks.services.wayback import generate_fallback_webarchive_url
from bookmarks.services.website_loader import WebsiteMetadata
from bookmarks.tests.helpers import LinkdingApiTestCase, BookmarkFactoryMixin
from bookmarks.utils import app_version
class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
def setUp(self):
self.mock_assets_upload_snapshot_patcher = patch(
"bookmarks.services.assets.upload_snapshot",
)
self.mock_assets_upload_snapshot = (
self.mock_assets_upload_snapshot_patcher.start()
)
def tearDown(self):
self.mock_assets_upload_snapshot_patcher.stop()
def authenticate(self):
self.api_token = Token.objects.get_or_create(
user=self.get_or_create_test_user()
@@ -33,7 +51,10 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
expectation["title"] = bookmark.title
expectation["description"] = bookmark.description
expectation["notes"] = bookmark.notes
expectation["web_archive_snapshot_url"] = bookmark.web_archive_snapshot_url
expectation["web_archive_snapshot_url"] = (
bookmark.web_archive_snapshot_url
or generate_fallback_webarchive_url(bookmark.url, bookmark.date_added)
)
expectation["favicon_url"] = (
f"http://testserver/static/{bookmark.favicon_file}"
if bookmark.favicon_file
@@ -433,6 +454,40 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
self.assertEqual(bookmark.title, "")
self.assertEqual(bookmark.description, "")
def test_create_bookmark_creates_html_snapshot_by_default(self):
self.authenticate()
with patch.object(
bookmarks.services.bookmarks,
"create_bookmark",
wraps=bookmarks.services.bookmarks.create_bookmark,
) as mock_create_bookmark:
data = {"url": "https://example.com/"}
self.post(reverse("bookmarks:bookmark-list"), data, status.HTTP_201_CREATED)
mock_create_bookmark.assert_called_with(
ANY, "", self.get_or_create_test_user(), disable_html_snapshot=False
)
def test_create_bookmark_does_not_create_html_snapshot_if_disabled(self):
self.authenticate()
with patch.object(
bookmarks.services.bookmarks,
"create_bookmark",
wraps=bookmarks.services.bookmarks.create_bookmark,
) as mock_create_bookmark:
data = {"url": "https://example.com/"}
self.post(
reverse("bookmarks:bookmark-list") + "?disable_html_snapshot",
data,
status.HTTP_201_CREATED,
)
mock_create_bookmark.assert_called_with(
ANY, "", self.get_or_create_test_user(), disable_html_snapshot=True
)
def test_create_bookmark_with_same_url_updates_existing_bookmark(self):
self.authenticate()
@@ -480,7 +535,21 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
self.authenticate()
data = {"url": "https://example.com/"}
self.post(reverse("bookmarks:bookmark-list"), data, status.HTTP_201_CREATED)
self.post(
reverse("bookmarks:bookmark-list") + "?disable_scraping",
data,
status.HTTP_201_CREATED,
)
bookmark = Bookmark.objects.get(url=data["url"])
self.assertEqual(data["url"], bookmark.url)
self.assertEqual("", bookmark.title)
self.assertEqual("", bookmark.description)
self.assertEqual("", bookmark.notes)
self.assertFalse(bookmark.is_archived)
self.assertFalse(bookmark.unread)
self.assertFalse(bookmark.shared)
self.assertBookmarkListEqual([], bookmark.tag_names)
def test_create_archived_bookmark(self):
self.authenticate()
@@ -576,6 +645,23 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
response = self.get(url, expected_status_code=status.HTTP_200_OK)
self.assertBookmarkListEqual([response.data], [bookmark])
def test_get_bookmark_returns_fallback_webarchive_url(self):
self.authenticate()
bookmark = self.setup_bookmark(
web_archive_snapshot_url="",
url="https://example.com/",
added=timezone.datetime(
2023, 8, 11, 21, 45, 11, tzinfo=datetime.timezone.utc
),
)
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id])
response = self.get(url, expected_status_code=status.HTTP_200_OK)
self.assertEqual(
response.data["web_archive_snapshot_url"],
"https://web.archive.org/web/20230811214511/https://example.com/",
)
def test_update_bookmark(self):
self.authenticate()
bookmark = self.setup_bookmark()
@@ -586,6 +672,28 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
updated_bookmark = Bookmark.objects.get(id=bookmark.id)
self.assertEqual(updated_bookmark.url, data["url"])
def test_update_bookmark_ignores_readonly_fields(self):
self.authenticate()
bookmark = self.setup_bookmark()
data = {
"url": "https://example.com/updated",
"web_archive_snapshot_url": "test",
"website_title": "test",
"website_description": "test",
}
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id])
self.put(url, data, expected_status_code=status.HTTP_200_OK)
updated_bookmark = Bookmark.objects.get(id=bookmark.id)
self.assertEqual(data["url"], updated_bookmark.url)
self.assertNotEqual(
data["web_archive_snapshot_url"], updated_bookmark.web_archive_snapshot_url
)
self.assertNotEqual(data["website_title"], updated_bookmark.website_title)
self.assertNotEqual(
data["website_description"], updated_bookmark.website_description
)
def test_update_bookmark_fails_without_required_fields(self):
self.authenticate()
bookmark = self.setup_bookmark()
@@ -594,19 +702,24 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id])
self.put(url, data, expected_status_code=status.HTTP_400_BAD_REQUEST)
def test_update_bookmark_with_minimal_payload_clears_all_fields(self):
def test_update_bookmark_with_minimal_payload_does_not_modify_bookmark(self):
self.authenticate()
bookmark = self.setup_bookmark()
bookmark = self.setup_bookmark(
is_archived=True, unread=True, shared=True, tags=[self.setup_tag()]
)
data = {"url": "https://example.com/"}
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id])
self.put(url, data, expected_status_code=status.HTTP_200_OK)
updated_bookmark = Bookmark.objects.get(id=bookmark.id)
self.assertEqual(updated_bookmark.url, data["url"])
self.assertEqual(updated_bookmark.title, "")
self.assertEqual(updated_bookmark.description, "")
self.assertEqual(updated_bookmark.notes, "")
self.assertEqual(updated_bookmark.tag_names, [])
self.assertEqual(updated_bookmark.title, bookmark.title)
self.assertEqual(updated_bookmark.description, bookmark.description)
self.assertEqual(updated_bookmark.notes, bookmark.notes)
self.assertEqual(updated_bookmark.is_archived, bookmark.is_archived)
self.assertEqual(updated_bookmark.unread, bookmark.unread)
self.assertEqual(updated_bookmark.shared, bookmark.shared)
self.assertListEqual(updated_bookmark.tag_names, bookmark.tag_names)
def test_update_bookmark_unread_flag(self):
self.authenticate()
@@ -644,6 +757,29 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
updated_bookmark = Bookmark.objects.get(id=bookmark.id)
self.assertCountEqual(updated_bookmark.tags.all(), [tag1, tag2])
def test_update_bookmark_should_prevent_duplicate_urls(self):
self.authenticate()
edited_bookmark = self.setup_bookmark(url="https://example.com/edited")
existing_bookmark = self.setup_bookmark(url="https://example.com/existing")
other_user_bookmark = self.setup_bookmark(
url="https://example.com/other", user=self.setup_user()
)
# if the URL isn't modified it's not a duplicate
data = {"url": edited_bookmark.url}
url = reverse("bookmarks:bookmark-detail", args=[edited_bookmark.id])
self.put(url, data, expected_status_code=status.HTTP_200_OK)
# if the URL is already bookmarked by another user, it's not a duplicate
data = {"url": other_user_bookmark.url}
url = reverse("bookmarks:bookmark-detail", args=[edited_bookmark.id])
self.put(url, data, expected_status_code=status.HTTP_200_OK)
# if the URL is already bookmarked by the same user, it's a duplicate
data = {"url": existing_bookmark.url}
url = reverse("bookmarks:bookmark-detail", args=[edited_bookmark.id])
self.put(url, data, expected_status_code=status.HTTP_400_BAD_REQUEST)
def test_patch_bookmark(self):
self.authenticate()
bookmark = self.setup_bookmark()
@@ -703,16 +839,42 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
tag_names = [tag.name for tag in bookmark.tags.all()]
self.assertListEqual(tag_names, ["updated-tag-1", "updated-tag-2"])
def test_patch_with_empty_payload_does_not_modify_bookmark(self):
def test_patch_ignores_readonly_fields(self):
self.authenticate()
bookmark = self.setup_bookmark()
data = {
"web_archive_snapshot_url": "test",
"website_title": "test",
"website_description": "test",
}
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id])
self.patch(url, data, expected_status_code=status.HTTP_200_OK)
updated_bookmark = Bookmark.objects.get(id=bookmark.id)
self.assertNotEqual(
data["web_archive_snapshot_url"], updated_bookmark.web_archive_snapshot_url
)
self.assertNotEqual(data["website_title"], updated_bookmark.website_title)
self.assertNotEqual(
data["website_description"], updated_bookmark.website_description
)
def test_patch_with_empty_payload_does_not_modify_bookmark(self):
self.authenticate()
bookmark = self.setup_bookmark(
is_archived=True, unread=True, shared=True, tags=[self.setup_tag()]
)
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id])
self.patch(url, {}, expected_status_code=status.HTTP_200_OK)
updated_bookmark = Bookmark.objects.get(id=bookmark.id)
self.assertEqual(updated_bookmark.url, bookmark.url)
self.assertEqual(updated_bookmark.title, bookmark.title)
self.assertEqual(updated_bookmark.description, bookmark.description)
self.assertEqual(updated_bookmark.notes, bookmark.notes)
self.assertEqual(updated_bookmark.is_archived, bookmark.is_archived)
self.assertEqual(updated_bookmark.unread, bookmark.unread)
self.assertEqual(updated_bookmark.shared, bookmark.shared)
self.assertListEqual(updated_bookmark.tag_names, bookmark.tag_names)
def test_patch_bookmark_adds_tags_from_auto_tagging(self):
@@ -919,6 +1081,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
{url: "https://example.com/"},
expected_status_code=status.HTTP_404_NOT_FOUND,
)
self.patch(url, expected_status_code=status.HTTP_404_NOT_FOUND)
url = reverse(
"bookmarks:bookmark-detail", args=[inaccessible_shared_bookmark.id]
@@ -928,6 +1091,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
{url: "https://example.com/"},
expected_status_code=status.HTTP_404_NOT_FOUND,
)
self.patch(url, expected_status_code=status.HTTP_404_NOT_FOUND)
url = reverse("bookmarks:bookmark-detail", args=[inaccessible_bookmark.id])
self.delete(url, expected_status_code=status.HTTP_404_NOT_FOUND)
@@ -982,6 +1146,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
self.assertEqual(
response.data["search_preferences"], profile.search_preferences
)
self.assertEqual(response.data["version"], app_version)
def test_user_profile(self):
self.authenticate()
@@ -1015,3 +1180,119 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
response = self.get(url, expected_status_code=status.HTTP_200_OK)
self.assertUserProfile(response, profile)
def create_singlefile_upload_body(self):
url = "https://example.com"
file_content = b"dummy content"
file = io.BytesIO(file_content)
file.name = "snapshot.html"
return {"url": url, "file": file}
def test_singlefile_upload(self):
bookmark = self.setup_bookmark(url="https://example.com")
self.authenticate()
response = self.client.post(
reverse("bookmarks:bookmark-singlefile"),
self.create_singlefile_upload_body(),
format="multipart",
expected_status_code=status.HTTP_201_CREATED,
)
self.assertEqual(response.data["message"], "Snapshot uploaded successfully.")
self.mock_assets_upload_snapshot.assert_called_once()
self.mock_assets_upload_snapshot.assert_called_with(bookmark, b"dummy content")
def test_singlefile_creates_bookmark_if_not_exists(self):
other_user = self.setup_user()
self.setup_bookmark(url="https://example.com", user=other_user)
self.authenticate()
self.client.post(
reverse("bookmarks:bookmark-singlefile"),
self.create_singlefile_upload_body(),
format="multipart",
expected_status_code=status.HTTP_201_CREATED,
)
self.assertEqual(Bookmark.objects.count(), 2)
bookmark = Bookmark.objects.get(
url="https://example.com", owner=self.get_or_create_test_user()
)
self.mock_assets_upload_snapshot.assert_called_once()
self.mock_assets_upload_snapshot.assert_called_with(bookmark, b"dummy content")
def test_singlefile_updates_own_bookmark_if_exists(self):
bookmark = self.setup_bookmark(url="https://example.com")
other_user = self.setup_user()
self.setup_bookmark(url="https://example.com", user=other_user)
self.authenticate()
self.client.post(
reverse("bookmarks:bookmark-singlefile"),
self.create_singlefile_upload_body(),
format="multipart",
expected_status_code=status.HTTP_201_CREATED,
)
self.assertEqual(Bookmark.objects.count(), 2)
self.mock_assets_upload_snapshot.assert_called_once()
self.mock_assets_upload_snapshot.assert_called_with(bookmark, b"dummy content")
def test_singlefile_creates_bookmark_without_creating_snapshot(self):
with patch(
"bookmarks.services.bookmarks.create_bookmark"
) as mock_create_bookmark:
self.authenticate()
self.client.post(
reverse("bookmarks:bookmark-singlefile"),
self.create_singlefile_upload_body(),
format="multipart",
expected_status_code=status.HTTP_201_CREATED,
)
mock_create_bookmark.assert_called_once()
mock_create_bookmark.assert_called_with(
ANY, "", self.get_or_create_test_user(), disable_html_snapshot=True
)
def test_singlefile_upload_missing_parameters(self):
self.authenticate()
# Missing 'url'
file_content = b"dummy content"
file = io.BytesIO(file_content)
file.name = "snapshot.html"
response = self.client.post(
reverse("bookmarks:bookmark-singlefile"),
{"file": file},
format="multipart",
expected_status_code=status.HTTP_400_BAD_REQUEST,
)
self.assertEqual(
response.data["error"], "Both 'url' and 'file' parameters are required."
)
# Missing 'file'
response = self.client.post(
reverse("bookmarks:bookmark-singlefile"),
{"url": "https://example.com"},
format="multipart",
expected_status_code=status.HTTP_400_BAD_REQUEST,
)
self.assertEqual(
response.data["error"], "Both 'url' and 'file' parameters are required."
)
@override_settings(LD_DISABLE_ASSET_UPLOAD=True)
def test_singlefile_upload_disabled(self):
self.authenticate()
self.client.post(
reverse("bookmarks:bookmark-singlefile"),
self.create_singlefile_upload_body(),
format="multipart",
expected_status_code=status.HTTP_403_FORBIDDEN,
)

View File

@@ -87,6 +87,16 @@ class BookmarksApiPermissionsTestCase(LinkdingApiTestCase, BookmarkFactoryMixin)
self.authenticate()
self.put(url, data, expected_status_code=status.HTTP_200_OK)
def test_update_bookmark_only_updates_own_bookmarks(self):
self.authenticate()
other_user = self.setup_user()
bookmark = self.setup_bookmark(user=other_user)
data = {"url": "https://example.com/"}
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id])
self.put(url, data, expected_status_code=status.HTTP_404_NOT_FOUND)
def test_patch_bookmark_requires_authentication(self):
bookmark = self.setup_bookmark()
data = {"url": "https://example.com"}
@@ -97,6 +107,16 @@ class BookmarksApiPermissionsTestCase(LinkdingApiTestCase, BookmarkFactoryMixin)
self.authenticate()
self.patch(url, data, expected_status_code=status.HTTP_200_OK)
def test_patch_bookmark_only_updates_own_bookmarks(self):
self.authenticate()
other_user = self.setup_user()
bookmark = self.setup_bookmark(user=other_user)
data = {"url": "https://example.com"}
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id])
self.patch(url, data, expected_status_code=status.HTTP_404_NOT_FOUND)
def test_delete_bookmark_requires_authentication(self):
bookmark = self.setup_bookmark()
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id])
@@ -142,3 +162,8 @@ class BookmarksApiPermissionsTestCase(LinkdingApiTestCase, BookmarkFactoryMixin)
self.authenticate()
self.get(url, expected_status_code=status.HTTP_200_OK)
def test_singlefile_upload_requires_authentication(self):
url = reverse("bookmarks:bookmark-singlefile")
self.post(url, expected_status_code=status.HTTP_401_UNAUTHORIZED)

View File

@@ -69,7 +69,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
details_url = base_url + f"?details={bookmark.id}"
self.assertInHTML(
f"""
<a href="{details_url}" data-turbo-action="replace" data-turbo-frame="details-modal">View</a>
<a href="{details_url}" class="view-action" data-turbo-action="replace" data-turbo-frame="details-modal">View</a>
""",
html,
count=count,
@@ -562,8 +562,11 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
def test_should_reflect_unread_state_as_css_class(self):
self.setup_bookmark(unread=True)
html = self.render_template()
soup = self.make_soup(html)
self.assertIn('<li ld-bookmark-item class="unread">', html)
list_item = soup.select_one("li[ld-bookmark-item]")
self.assertIsNotNone(list_item)
self.assertListEqual(["unread"], list_item["class"])
def test_should_reflect_shared_state_as_css_class(self):
profile = self.get_or_create_test_user().profile
@@ -572,8 +575,11 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
self.setup_bookmark(shared=True)
html = self.render_template()
soup = self.make_soup(html)
self.assertIn('<li ld-bookmark-item class="shared">', html)
list_item = soup.select_one("li[ld-bookmark-item]")
self.assertIsNotNone(list_item)
self.assertListEqual(["shared"], list_item["class"])
def test_should_reflect_both_unread_and_shared_state_as_css_class(self):
profile = self.get_or_create_test_user().profile
@@ -582,8 +588,11 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
self.setup_bookmark(unread=True, shared=True)
html = self.render_template()
soup = self.make_soup(html)
self.assertIn('<li ld-bookmark-item class="unread shared">', html)
list_item = soup.select_one("li[ld-bookmark-item]")
self.assertIsNotNone(list_item)
self.assertListEqual(["unread", "shared"], list_item["class"])
def test_show_bookmark_actions_for_owned_bookmarks(self):
bookmark = self.setup_bookmark()

View File

@@ -1,13 +1,10 @@
import os
import tempfile
from unittest.mock import patch
from django.contrib.auth import get_user_model
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase, override_settings
from django.test import TestCase
from django.utils import timezone
from bookmarks.models import Bookmark, BookmarkAsset, Tag
from bookmarks.models import Bookmark, Tag
from bookmarks.services import tasks
from bookmarks.services import website_loader
from bookmarks.services.bookmarks import (
@@ -24,7 +21,6 @@ from bookmarks.services.bookmarks import (
mark_bookmarks_as_unread,
share_bookmarks,
unshare_bookmarks,
upload_asset,
enhance_with_website_metadata,
)
from bookmarks.tests.helpers import BookmarkFactoryMixin
@@ -110,6 +106,15 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
mock_create_html_snapshot.assert_called_once_with(bookmark)
def test_create_should_not_load_html_snapshot_when_disabled(self):
with patch.object(tasks, "create_html_snapshot") as mock_create_html_snapshot:
bookmark_data = Bookmark(url="https://example.com")
create_bookmark(
bookmark_data, "tag1,tag2", self.user, disable_html_snapshot=True
)
mock_create_html_snapshot.assert_not_called()
def test_create_should_not_load_html_snapshot_when_setting_is_disabled(self):
profile = self.get_or_create_test_user().profile
profile.enable_automatic_html_snapshots = False
@@ -850,53 +855,6 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
self.assertFalse(Bookmark.objects.get(id=bookmark2.id).shared)
self.assertFalse(Bookmark.objects.get(id=bookmark3.id).shared)
def test_upload_asset_should_save_file(self):
bookmark = self.setup_bookmark()
with tempfile.TemporaryDirectory() as temp_assets:
with override_settings(LD_ASSET_FOLDER=temp_assets):
file_content = b"file content"
upload_file = SimpleUploadedFile(
"test_file.txt", file_content, content_type="text/plain"
)
upload_asset(bookmark, upload_file)
assets = bookmark.bookmarkasset_set.all()
self.assertEqual(1, len(assets))
asset = assets[0]
self.assertEqual("test_file.txt", asset.display_name)
self.assertEqual("text/plain", asset.content_type)
self.assertEqual(upload_file.size, asset.file_size)
self.assertEqual(BookmarkAsset.STATUS_COMPLETE, asset.status)
self.assertTrue(asset.file.startswith("upload_"))
self.assertTrue(asset.file.endswith(upload_file.name))
# check file exists
filepath = os.path.join(temp_assets, asset.file)
self.assertTrue(os.path.exists(filepath))
with open(filepath, "rb") as f:
self.assertEqual(file_content, f.read())
def test_upload_asset_should_be_failed_if_saving_file_fails(self):
bookmark = self.setup_bookmark()
# Use an invalid path to force an error
with override_settings(LD_ASSET_FOLDER="/non/existing/folder"):
file_content = b"file content"
upload_file = SimpleUploadedFile(
"test_file.txt", file_content, content_type="text/plain"
)
upload_asset(bookmark, upload_file)
assets = bookmark.bookmarkasset_set.all()
self.assertEqual(1, len(assets))
asset = assets[0]
self.assertEqual("test_file.txt", asset.display_name)
self.assertEqual("text/plain", asset.content_type)
self.assertIsNone(asset.file_size)
self.assertEqual(BookmarkAsset.STATUS_FAILURE, asset.status)
self.assertEqual("", asset.file)
def test_enhance_with_website_metadata(self):
bookmark = self.setup_bookmark(url="https://example.com")
with patch.object(

View File

@@ -1,15 +1,13 @@
import os.path
from unittest import mock
import waybackpy
from django.conf import settings
from django.contrib.auth.models import User
from django.test import TestCase, override_settings
from huey.contrib.djhuey import HUEY as huey
from waybackpy.exceptions import WaybackError
from bookmarks.models import BookmarkAsset, UserProfile
from bookmarks.services import tasks, singlefile
from bookmarks.services import tasks
from bookmarks.tests.helpers import BookmarkFactoryMixin
@@ -46,11 +44,11 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
self.mock_load_favicon = self.mock_load_favicon_patcher.start()
self.mock_load_favicon.return_value = "https_example_com.png"
self.mock_singlefile_create_snapshot_patcher = mock.patch(
"bookmarks.services.singlefile.create_snapshot",
self.mock_assets_create_snapshot_patcher = mock.patch(
"bookmarks.services.assets.create_snapshot",
)
self.mock_singlefile_create_snapshot = (
self.mock_singlefile_create_snapshot_patcher.start()
self.mock_assets_create_snapshot = (
self.mock_assets_create_snapshot_patcher.start()
)
self.mock_load_preview_image_patcher = mock.patch(
@@ -70,7 +68,7 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
def tearDown(self):
self.mock_save_api_patcher.stop()
self.mock_load_favicon_patcher.stop()
self.mock_singlefile_create_snapshot_patcher.stop()
self.mock_assets_create_snapshot_patcher.stop()
self.mock_load_preview_image_patcher.stop()
huey.storage.flush_results()
huey.immediate = False
@@ -488,72 +486,31 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
self.assertIn("HTML snapshot", asset.display_name)
self.assertEqual(asset.status, BookmarkAsset.STATUS_PENDING)
self.mock_assets_create_snapshot.assert_not_called()
@override_settings(LD_ENABLE_SNAPSHOTS=True)
def test_create_html_snapshot_should_update_file_info(self):
def test_schedule_html_snapshots_should_create_snapshots(self):
bookmark = self.setup_bookmark(url="https://example.com")
with mock.patch(
"bookmarks.services.tasks._generate_snapshot_filename"
) as mock_generate:
expected_filename = "snapshot_2021-01-02_034455_https___example.com.html.gz"
mock_generate.return_value = expected_filename
tasks.create_html_snapshot(bookmark)
BookmarkAsset.objects.get(bookmark=bookmark)
# Run periodic task to process the snapshot
tasks._schedule_html_snapshots_task()
self.mock_singlefile_create_snapshot.assert_called_once_with(
"https://example.com",
os.path.join(
settings.LD_ASSET_FOLDER,
expected_filename,
),
)
asset = BookmarkAsset.objects.get(bookmark=bookmark)
self.assertEqual(asset.status, BookmarkAsset.STATUS_COMPLETE)
self.assertEqual(asset.file, expected_filename)
self.assertTrue(asset.gzip)
@override_settings(LD_ENABLE_SNAPSHOTS=True)
def test_create_html_snapshot_truncate_filename(self):
# Create a bookmark with a very long URL
long_url = "http://" + "a" * 300 + ".com"
bookmark = self.setup_bookmark(url=long_url)
tasks.create_html_snapshot(bookmark)
BookmarkAsset.objects.get(bookmark=bookmark)
# Run periodic task to process the snapshot
tasks._schedule_html_snapshots_task()
asset = BookmarkAsset.objects.get(bookmark=bookmark)
self.assertEqual(len(asset.file), 192)
@override_settings(LD_ENABLE_SNAPSHOTS=True)
def test_create_html_snapshot_should_handle_error(self):
bookmark = self.setup_bookmark(url="https://example.com")
self.mock_singlefile_create_snapshot.side_effect = singlefile.SingleFileError(
"Error"
)
tasks.create_html_snapshot(bookmark)
tasks.create_html_snapshot(bookmark)
# Run periodic task to process the snapshot
assets = BookmarkAsset.objects.filter(bookmark=bookmark)
tasks._schedule_html_snapshots_task()
asset = BookmarkAsset.objects.get(bookmark=bookmark)
self.assertEqual(asset.status, BookmarkAsset.STATUS_FAILURE)
self.assertEqual(asset.file, "")
self.assertFalse(asset.gzip)
# should call create_snapshot for each pending asset
self.assertEqual(self.mock_assets_create_snapshot.call_count, 3)
for asset in assets:
self.mock_assets_create_snapshot.assert_any_call(asset)
@override_settings(LD_ENABLE_SNAPSHOTS=True)
def test_create_html_snapshot_should_handle_missing_bookmark(self):
def test_create_html_snapshot_should_handle_missing_asset(self):
tasks._create_html_snapshot_task(123)
self.mock_singlefile_create_snapshot.assert_not_called()
self.mock_assets_create_snapshot.assert_not_called()
@override_settings(LD_ENABLE_SNAPSHOTS=False)
def test_create_html_snapshot_should_not_create_asset_when_single_file_is_disabled(

View File

@@ -1,21 +0,0 @@
from django.test import TestCase
from django.urls import reverse
from bookmarks.tests.helpers import BookmarkFactoryMixin
class CustomCssTestCase(TestCase, BookmarkFactoryMixin):
def setUp(self):
self.client.force_login(self.get_or_create_test_user())
def test_does_not_render_custom_style_tag_by_default(self):
response = self.client.get(reverse("bookmarks:index"))
self.assertNotContains(response, "<style>")
def test_renders_custom_style_tag_if_user_has_custom_css(self):
profile = self.get_or_create_test_user().profile
profile.custom_css = "body { background-color: red; }"
profile.save()
response = self.client.get(reverse("bookmarks:index"))
self.assertContains(response, "<style>body { background-color: red; }</style>")

View File

@@ -0,0 +1,28 @@
from django.test import TestCase
from django.urls import reverse
from bookmarks.tests.helpers import BookmarkFactoryMixin
class CustomCssViewTestCase(TestCase, BookmarkFactoryMixin):
def setUp(self) -> None:
user = self.get_or_create_test_user()
self.client.force_login(user)
def test_with_empty_css(self):
response = self.client.get(reverse("bookmarks:custom_css"))
self.assertEqual(response.status_code, 200)
self.assertEqual(response["Content-Type"], "text/css")
self.assertEqual(response.headers["Cache-Control"], "public, max-age=2592000")
self.assertEqual(response.content.decode(), "")
def test_with_custom_css(self):
css = "body { background-color: red; }"
self.user.profile.custom_css = css
self.user.profile.save()
response = self.client.get(reverse("bookmarks:custom_css"))
self.assertEqual(response.status_code, 200)
self.assertEqual(response["Content-Type"], "text/css")
self.assertEqual(response.headers["Cache-Control"], "public, max-age=2592000")
self.assertEqual(response.content.decode(), css)

View File

@@ -1,5 +1,6 @@
from datetime import datetime, timezone
from django.test import TestCase
from django.utils import timezone
from bookmarks.services import exporter
from bookmarks.tests.helpers import BookmarkFactoryMixin
@@ -7,20 +8,19 @@ from bookmarks.tests.helpers import BookmarkFactoryMixin
class ExporterTestCase(TestCase, BookmarkFactoryMixin):
def test_export_bookmarks(self):
added = timezone.now()
timestamp = int(added.timestamp())
bookmarks = [
self.setup_bookmark(
url="https://example.com/1",
title="Title 1",
added=added,
added=datetime.fromtimestamp(1, timezone.utc),
modified=datetime.fromtimestamp(11, timezone.utc),
description="Example description",
),
self.setup_bookmark(
url="https://example.com/2",
title="Title 2",
added=added,
added=datetime.fromtimestamp(2, timezone.utc),
modified=datetime.fromtimestamp(22, timezone.utc),
tags=[
self.setup_tag(name="tag1"),
self.setup_tag(name="tag2"),
@@ -28,15 +28,24 @@ class ExporterTestCase(TestCase, BookmarkFactoryMixin):
],
),
self.setup_bookmark(
url="https://example.com/3", title="Title 3", added=added, unread=True
url="https://example.com/3",
title="Title 3",
added=datetime.fromtimestamp(3, timezone.utc),
modified=datetime.fromtimestamp(33, timezone.utc),
unread=True,
),
self.setup_bookmark(
url="https://example.com/4", title="Title 4", added=added, shared=True
url="https://example.com/4",
title="Title 4",
added=datetime.fromtimestamp(4, timezone.utc),
modified=datetime.fromtimestamp(44, timezone.utc),
shared=True,
),
self.setup_bookmark(
url="https://example.com/5",
title="Title 5",
added=added,
added=datetime.fromtimestamp(5, timezone.utc),
modified=datetime.fromtimestamp(55, timezone.utc),
shared=True,
description="Example description",
notes="Example notes",
@@ -44,20 +53,23 @@ class ExporterTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark(
url="https://example.com/6",
title="Title 6",
added=added,
added=datetime.fromtimestamp(6, timezone.utc),
modified=datetime.fromtimestamp(66, timezone.utc),
shared=True,
notes="Example notes",
),
self.setup_bookmark(
url="https://example.com/7",
title="Title 7",
added=added,
added=datetime.fromtimestamp(7, timezone.utc),
modified=datetime.fromtimestamp(77, timezone.utc),
is_archived=True,
),
self.setup_bookmark(
url="https://example.com/8",
title="Title 8",
added=added,
added=datetime.fromtimestamp(8, timezone.utc),
modified=datetime.fromtimestamp(88, timezone.utc),
tags=[self.setup_tag(name="tag4"), self.setup_tag(name="tag5")],
is_archived=True,
),
@@ -65,17 +77,17 @@ class ExporterTestCase(TestCase, BookmarkFactoryMixin):
html = exporter.export_netscape_html(bookmarks)
lines = [
f'<DT><A HREF="https://example.com/1" ADD_DATE="{timestamp}" PRIVATE="1" TOREAD="0" TAGS="">Title 1</A>',
'<DT><A HREF="https://example.com/1" ADD_DATE="1" LAST_MODIFIED="11" PRIVATE="1" TOREAD="0" TAGS="">Title 1</A>',
"<DD>Example description",
f'<DT><A HREF="https://example.com/2" ADD_DATE="{timestamp}" PRIVATE="1" TOREAD="0" TAGS="tag1,tag2,tag3">Title 2</A>',
f'<DT><A HREF="https://example.com/3" ADD_DATE="{timestamp}" PRIVATE="1" TOREAD="1" TAGS="">Title 3</A>',
f'<DT><A HREF="https://example.com/4" ADD_DATE="{timestamp}" PRIVATE="0" TOREAD="0" TAGS="">Title 4</A>',
f'<DT><A HREF="https://example.com/5" ADD_DATE="{timestamp}" PRIVATE="0" TOREAD="0" TAGS="">Title 5</A>',
'<DT><A HREF="https://example.com/2" ADD_DATE="2" LAST_MODIFIED="22" PRIVATE="1" TOREAD="0" TAGS="tag1,tag2,tag3">Title 2</A>',
'<DT><A HREF="https://example.com/3" ADD_DATE="3" LAST_MODIFIED="33" PRIVATE="1" TOREAD="1" TAGS="">Title 3</A>',
'<DT><A HREF="https://example.com/4" ADD_DATE="4" LAST_MODIFIED="44" PRIVATE="0" TOREAD="0" TAGS="">Title 4</A>',
'<DT><A HREF="https://example.com/5" ADD_DATE="5" LAST_MODIFIED="55" PRIVATE="0" TOREAD="0" TAGS="">Title 5</A>',
"<DD>Example description[linkding-notes]Example notes[/linkding-notes]",
f'<DT><A HREF="https://example.com/6" ADD_DATE="{timestamp}" PRIVATE="0" TOREAD="0" TAGS="">Title 6</A>',
'<DT><A HREF="https://example.com/6" ADD_DATE="6" LAST_MODIFIED="66" PRIVATE="0" TOREAD="0" TAGS="">Title 6</A>',
"<DD>[linkding-notes]Example notes[/linkding-notes]",
f'<DT><A HREF="https://example.com/7" ADD_DATE="{timestamp}" PRIVATE="1" TOREAD="0" TAGS="linkding:archived">Title 7</A>',
f'<DT><A HREF="https://example.com/8" ADD_DATE="{timestamp}" PRIVATE="1" TOREAD="0" TAGS="tag4,tag5,linkding:archived">Title 8</A>',
'<DT><A HREF="https://example.com/7" ADD_DATE="7" LAST_MODIFIED="77" PRIVATE="1" TOREAD="0" TAGS="linkding:archived">Title 7</A>',
'<DT><A HREF="https://example.com/8" ADD_DATE="8" LAST_MODIFIED="88" PRIVATE="1" TOREAD="0" TAGS="tag4,tag5,linkding:archived">Title 8</A>',
]
self.assertIn("\n\r".join(lines), html)

View File

@@ -26,6 +26,9 @@ class ImporterTestCase(TestCase, BookmarkFactoryMixin, ImportTestMixin):
self.assertEqual(bookmark.title, html_tag.title)
self.assertEqual(bookmark.description, html_tag.description)
self.assertEqual(bookmark.date_added, parse_timestamp(html_tag.add_date))
self.assertEqual(
bookmark.date_modified, parse_timestamp(html_tag.last_modified)
)
self.assertEqual(bookmark.unread, html_tag.to_read)
self.assertEqual(bookmark.shared, not html_tag.private)
@@ -45,6 +48,7 @@ class ImporterTestCase(TestCase, BookmarkFactoryMixin, ImportTestMixin):
title="Example title",
description="Example description",
add_date="1",
last_modified="11",
tags="example-tag",
),
BookmarkHtmlTag(
@@ -52,6 +56,7 @@ class ImporterTestCase(TestCase, BookmarkFactoryMixin, ImportTestMixin):
title="Foo title",
description="",
add_date="2",
last_modified="22",
tags="",
),
BookmarkHtmlTag(
@@ -59,6 +64,7 @@ class ImporterTestCase(TestCase, BookmarkFactoryMixin, ImportTestMixin):
title="Bar title",
description="Bar description",
add_date="3",
last_modified="33",
tags="bar-tag, other-tag",
),
BookmarkHtmlTag(
@@ -66,6 +72,7 @@ class ImporterTestCase(TestCase, BookmarkFactoryMixin, ImportTestMixin):
title="Baz title",
description="Baz description",
add_date="4",
last_modified="44",
to_read=True,
),
]
@@ -90,6 +97,7 @@ class ImporterTestCase(TestCase, BookmarkFactoryMixin, ImportTestMixin):
title="Example title",
description="Example description",
add_date="1",
last_modified="11",
tags="example-tag",
),
BookmarkHtmlTag(
@@ -97,6 +105,7 @@ class ImporterTestCase(TestCase, BookmarkFactoryMixin, ImportTestMixin):
title="Foo title",
description="",
add_date="2",
last_modified="22",
tags="",
),
BookmarkHtmlTag(
@@ -104,20 +113,23 @@ class ImporterTestCase(TestCase, BookmarkFactoryMixin, ImportTestMixin):
title="Bar title",
description="Bar description",
add_date="3",
last_modified="33",
tags="bar-tag, other-tag",
),
BookmarkHtmlTag(
href="https://example.com/unread",
title="Unread title",
description="Unread description",
add_date="3",
add_date="4",
last_modified="44",
to_read=True,
),
BookmarkHtmlTag(
href="https://example.com/private",
title="Private title",
description="Private description",
add_date="4",
add_date="5",
last_modified="55",
private=True,
),
]
@@ -136,6 +148,7 @@ class ImporterTestCase(TestCase, BookmarkFactoryMixin, ImportTestMixin):
title="Updated Example title",
description="Updated Example description",
add_date="111",
last_modified="1111",
tags="updated-example-tag",
),
BookmarkHtmlTag(
@@ -143,6 +156,7 @@ class ImporterTestCase(TestCase, BookmarkFactoryMixin, ImportTestMixin):
title="Updated Foo title",
description="Updated Foo description",
add_date="222",
last_modified="2222",
tags="new-tag",
),
BookmarkHtmlTag(
@@ -150,6 +164,7 @@ class ImporterTestCase(TestCase, BookmarkFactoryMixin, ImportTestMixin):
title="Updated Bar title",
description="Updated Bar description",
add_date="333",
last_modified="3333",
tags="updated-bar-tag, updated-other-tag",
),
BookmarkHtmlTag(
@@ -157,6 +172,7 @@ class ImporterTestCase(TestCase, BookmarkFactoryMixin, ImportTestMixin):
title="Unread title",
description="Unread description",
add_date="3",
last_modified="3",
to_read=False,
),
BookmarkHtmlTag(
@@ -164,9 +180,15 @@ class ImporterTestCase(TestCase, BookmarkFactoryMixin, ImportTestMixin):
title="Private title",
description="Private description",
add_date="4",
last_modified="4",
private=False,
),
BookmarkHtmlTag(href="https://baz.com", add_date="444", tags="baz-tag"),
BookmarkHtmlTag(
href="https://baz.com",
add_date="444",
last_modified="4444",
tags="baz-tag",
),
]
# Import updated data
@@ -291,6 +313,19 @@ class ImporterTestCase(TestCase, BookmarkFactoryMixin, ImportTestMixin):
Bookmark.objects.all()[0].date_added, timezone.datetime(2021, 1, 1)
)
def test_use_add_date_when_no_last_modified(self):
test_html = self.render_html(
tags_html=f"""
<DT><A HREF="https://example.com" ADD_DATE="1">Example.com</A>
<DD>Example.com
"""
)
import_netscape_html(test_html, self.get_or_create_test_user())
self.assertEqual(Bookmark.objects.count(), 1)
self.assertEqual(Bookmark.objects.all()[0].date_modified, parse_timestamp("1"))
def test_keep_title_if_imported_bookmark_has_empty_title(self):
test_html = self.render_html(
tags=[BookmarkHtmlTag(href="https://example.com", title="Example.com")]

View File

@@ -2,10 +2,10 @@ from django.test import TestCase
from django.urls import reverse
from bookmarks.models import GlobalSettings
from bookmarks.tests.helpers import BookmarkFactoryMixin
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
class LayoutTestCase(TestCase, BookmarkFactoryMixin):
class LayoutTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
def setUp(self) -> None:
user = self.get_or_create_test_user()
@@ -63,3 +63,38 @@ class LayoutTestCase(TestCase, BookmarkFactoryMixin):
html,
count=0,
)
def test_does_not_link_custom_css_when_empty(self):
response = self.client.get(reverse("bookmarks:index"))
html = response.content.decode()
soup = self.make_soup(html)
link = soup.select_one("link[rel='stylesheet'][href*='custom_css']")
self.assertIsNone(link)
def test_does_link_custom_css_when_not_empty(self):
profile = self.get_or_create_test_user().profile
profile.custom_css = "body { background-color: red; }"
profile.save()
response = self.client.get(reverse("bookmarks:index"))
html = response.content.decode()
soup = self.make_soup(html)
link = soup.select_one("link[rel='stylesheet'][href*='custom_css']")
self.assertIsNotNone(link)
def test_custom_css_link_href(self):
profile = self.get_or_create_test_user().profile
profile.custom_css = "body { background-color: red; }"
profile.save()
response = self.client.get(reverse("bookmarks:index"))
html = response.content.decode()
soup = self.make_soup(html)
link = soup.select_one("link[rel='stylesheet'][href*='custom_css']")
expected_url = (
reverse("bookmarks:custom_css") + f"?hash={profile.custom_css_hash}"
)
self.assertEqual(link["href"], expected_url)

View File

@@ -4,6 +4,8 @@ import os
from django.test import TestCase, override_settings
from django.urls import URLResolver
from bookmarks import utils
class OidcSupportTest(TestCase):
def test_should_not_add_oidc_urls_by_default(self):
@@ -55,9 +57,83 @@ class OidcSupportTest(TestCase):
base_settings = importlib.import_module("siteroot.settings.base")
importlib.reload(base_settings)
self.assertEqual(
True,
base_settings.OIDC_VERIFY_SSL,
)
self.assertEqual(True, base_settings.OIDC_VERIFY_SSL)
self.assertEqual("openid email profile", base_settings.OIDC_RP_SCOPES)
self.assertEqual("email", base_settings.OIDC_USERNAME_CLAIM)
del os.environ["LD_ENABLE_OIDC"]
del os.environ["LD_ENABLE_OIDC"] # Remove the temporary environment variable
@override_settings(LD_ENABLE_OIDC=True, OIDC_USERNAME_CLAIM="email")
def test_username_should_use_email_by_default(self):
claims = {
"email": "test@example.com",
"name": "test name",
"given_name": "test given name",
"preferred_username": "test preferred username",
"nickname": "test nickname",
"groups": [],
}
username = utils.generate_username(claims["email"], claims)
self.assertEqual(claims["email"], username)
@override_settings(LD_ENABLE_OIDC=True, OIDC_USERNAME_CLAIM="preferred_username")
def test_username_should_use_custom_claim(self):
claims = {
"email": "test@example.com",
"name": "test name",
"given_name": "test given name",
"preferred_username": "test preferred username",
"nickname": "test nickname",
"groups": [],
}
username = utils.generate_username(claims["email"], claims)
self.assertEqual(claims["preferred_username"], username)
@override_settings(LD_ENABLE_OIDC=True, OIDC_USERNAME_CLAIM="nonexistant_claim")
def test_username_should_fallback_to_email_for_non_existing_claim(self):
claims = {
"email": "test@example.com",
"name": "test name",
"given_name": "test given name",
"preferred_username": "test preferred username",
"nickname": "test nickname",
"groups": [],
}
username = utils.generate_username(claims["email"], claims)
self.assertEqual(claims["email"], username)
@override_settings(LD_ENABLE_OIDC=True, OIDC_USERNAME_CLAIM="preferred_username")
def test_username_should_fallback_to_email_for_empty_claim(self):
claims = {
"email": "test@example.com",
"name": "test name",
"given_name": "test given name",
"preferred_username": "",
"nickname": "test nickname",
"groups": [],
}
username = utils.generate_username(claims["email"], claims)
self.assertEqual(claims["email"], username)
@override_settings(LD_ENABLE_OIDC=True, OIDC_USERNAME_CLAIM="preferred_username")
def test_username_should_be_normalized(self):
claims = {
"email": "test@example.com",
"name": "test name",
"given_name": "test given name",
"preferred_username": "",
"nickname": "test nickname",
"groups": [],
}
username = utils.generate_username(claims["email"], claims)
self.assertEqual("NormalizedUser", username)

View File

@@ -18,6 +18,7 @@ class ParserTestCase(TestCase, ImportTestMixin):
self.assertEqual(bookmark.href, html_tag.href)
self.assertEqual(bookmark.title, html_tag.title)
self.assertEqual(bookmark.date_added, html_tag.add_date)
self.assertEqual(bookmark.date_modified, html_tag.last_modified)
self.assertEqual(bookmark.description, html_tag.description)
self.assertEqual(bookmark.tag_names, parse_tag_string(html_tag.tags))
self.assertEqual(bookmark.to_read, html_tag.to_read)
@@ -30,6 +31,7 @@ class ParserTestCase(TestCase, ImportTestMixin):
title="Example title",
description="Example description",
add_date="1",
last_modified="11",
tags="example-tag",
),
BookmarkHtmlTag(
@@ -37,6 +39,7 @@ class ParserTestCase(TestCase, ImportTestMixin):
title="Foo title",
description="",
add_date="2",
last_modified="22",
tags="",
),
BookmarkHtmlTag(
@@ -44,13 +47,14 @@ class ParserTestCase(TestCase, ImportTestMixin):
title="Bar title",
description="Bar description",
add_date="3",
last_modified="33",
tags="bar-tag, other-tag",
),
BookmarkHtmlTag(
href="https://example.com/baz",
title="Baz title",
description="Baz description",
add_date="3",
add_date="4",
to_read=True,
),
]
@@ -72,9 +76,17 @@ class ParserTestCase(TestCase, ImportTestMixin):
title="Example title",
description="Example description",
add_date="1",
last_modified="1",
tags="example-tag",
),
BookmarkHtmlTag(href="", title="", description="", add_date="", tags=""),
BookmarkHtmlTag(
href="",
title="",
description="",
add_date="",
last_modified="",
tags="",
),
]
html = self.render_html(html_tags)
bookmarks = parse(html)

View File

@@ -43,6 +43,7 @@ class PasswordChangeViewTestCase(TestCase, BookmarkFactoryMixin):
response = self.client.post(reverse("change_password"), form_data)
self.assertEqual(response.status_code, 422)
self.assertIn("old_password", response.context_data["form"].errors)
def test_should_return_error_for_mismatching_new_password(self):
@@ -54,4 +55,5 @@ class PasswordChangeViewTestCase(TestCase, BookmarkFactoryMixin):
response = self.client.post(reverse("change_password"), form_data)
self.assertEqual(response.status_code, 422)
self.assertIn("new_password2", response.context_data["form"].errors)

View File

@@ -1,3 +1,4 @@
import hashlib
import random
from unittest.mock import patch, Mock
@@ -22,6 +23,7 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
if not overrides:
overrides = {}
form_data = {
"update_profile": "",
"theme": UserProfile.THEME_AUTO,
"bookmark_date_display": UserProfile.BOOKMARK_DATE_DISPLAY_RELATIVE,
"bookmark_description_display": UserProfile.BOOKMARK_DESCRIPTION_DISPLAY_INLINE,
@@ -45,6 +47,7 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
"auto_tagging_rules": "",
"items_per_page": "30",
"sticky_pagination": False,
"collapse_side_panel": False,
}
return {**form_data, **overrides}
@@ -115,6 +118,7 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
"auto_tagging_rules": "example.com tag",
"items_per_page": "10",
"sticky_pagination": True,
"collapse_side_panel": True,
}
response = self.client.post(
reverse("bookmarks:settings.update"), form_data, follow=True
@@ -192,9 +196,18 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertEqual(
self.user.profile.sticky_pagination, form_data["sticky_pagination"]
)
self.assertEqual(
self.user.profile.collapse_side_panel, form_data["collapse_side_panel"]
)
self.assertSuccessMessage(html, "Profile updated")
def test_update_profile_with_invalid_form_returns_422(self):
form_data = self.create_profile_form_data({"items_per_page": "-1"})
response = self.client.post(reverse("bookmarks:settings.update"), form_data)
self.assertEqual(response.status_code, 422)
def test_update_profile_should_not_be_called_without_respective_form_action(self):
form_data = {
"theme": UserProfile.THEME_DARK,
@@ -210,6 +223,31 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertEqual(self.user.profile.theme, UserProfile.THEME_AUTO)
self.assertSuccessMessage(html, "Profile updated", count=0)
def test_update_profile_updates_custom_css_hash(self):
form_data = self.create_profile_form_data(
{
"custom_css": "body { background-color: #000; }",
}
)
self.client.post(reverse("bookmarks:settings.update"), form_data, follow=True)
self.user.profile.refresh_from_db()
expected_hash = hashlib.md5(form_data["custom_css"].encode("utf-8")).hexdigest()
self.assertEqual(expected_hash, self.user.profile.custom_css_hash)
form_data["custom_css"] = "body { background-color: #fff; }"
self.client.post(reverse("bookmarks:settings.update"), form_data, follow=True)
self.user.profile.refresh_from_db()
expected_hash = hashlib.md5(form_data["custom_css"].encode("utf-8")).hexdigest()
self.assertEqual(expected_hash, self.user.profile.custom_css_hash)
form_data["custom_css"] = ""
self.client.post(reverse("bookmarks:settings.update"), form_data, follow=True)
self.user.profile.refresh_from_db()
self.assertEqual("", self.user.profile.custom_css_hash)
def test_enable_favicons_should_schedule_icon_update(self):
with patch.object(
tasks, "schedule_bookmarks_without_favicons"
@@ -217,7 +255,6 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
# Enabling favicons schedules update
form_data = self.create_profile_form_data(
{
"update_profile": "",
"enable_favicons": True,
}
)
@@ -331,7 +368,6 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
# Enabling favicons schedules update
form_data = self.create_profile_form_data(
{
"update_profile": "",
"enable_preview_images": True,
}
)

View File

@@ -1,6 +1,6 @@
import gzip
import os
import subprocess
import tempfile
from unittest import mock
from django.test import TestCase, override_settings
@@ -9,34 +9,15 @@ from bookmarks.services import singlefile
class SingleFileServiceTestCase(TestCase):
html_content = "<html><body><h1>Hello, World!</h1></body></html>"
html_filepath = "temp.html.gz"
temp_html_filepath = "temp.html.gz.tmp"
def setUp(self):
self.temp_html_filepath = None
def tearDown(self):
if os.path.exists(self.html_filepath):
os.remove(self.html_filepath)
if os.path.exists(self.temp_html_filepath):
if self.temp_html_filepath and os.path.exists(self.temp_html_filepath):
os.remove(self.temp_html_filepath)
def create_test_file(self, *args, **kwargs):
with open(self.temp_html_filepath, "w") as file:
file.write(self.html_content)
def test_create_snapshot(self):
mock_process = mock.Mock()
mock_process.wait.return_value = 0
self.create_test_file()
with mock.patch("subprocess.Popen", return_value=mock_process):
singlefile.create_snapshot("http://example.com", self.html_filepath)
self.assertTrue(os.path.exists(self.html_filepath))
self.assertFalse(os.path.exists(self.temp_html_filepath))
with gzip.open(self.html_filepath, "rt") as file:
content = file.read()
self.assertEqual(content, self.html_content)
self.temp_html_filepath = tempfile.mkstemp(suffix=".tmp")[1]
def test_create_snapshot_failure(self):
# subprocess fails - which it probably doesn't as single-file doesn't return exit codes
@@ -44,12 +25,12 @@ class SingleFileServiceTestCase(TestCase):
mock_popen.side_effect = subprocess.CalledProcessError(1, "command")
with self.assertRaises(singlefile.SingleFileError):
singlefile.create_snapshot("http://example.com", self.html_filepath)
singlefile.create_snapshot("http://example.com", "nonexistentfile.tmp")
# so also check that it raises error if output file isn't created
with mock.patch("subprocess.Popen"):
with self.assertRaises(singlefile.SingleFileError):
singlefile.create_snapshot("http://example.com", self.html_filepath)
singlefile.create_snapshot("http://example.com", "nonexistentfile.tmp")
def test_create_snapshot_empty_options(self):
mock_process = mock.Mock()
@@ -57,16 +38,16 @@ class SingleFileServiceTestCase(TestCase):
self.create_test_file()
with mock.patch("subprocess.Popen") as mock_popen:
singlefile.create_snapshot("http://example.com", self.html_filepath)
singlefile.create_snapshot("http://example.com", self.temp_html_filepath)
expected_args = [
"single-file",
'--browser-arg="--headless=new"',
'--browser-arg="--user-data-dir=./chromium-profile"',
'--browser-arg="--no-sandbox"',
'--browser-arg="--load-extension=uBlock0.chromium"',
'--browser-arg="--load-extension=uBOLite.chromium.mv3"',
"http://example.com",
self.html_filepath + ".tmp",
self.temp_html_filepath,
]
mock_popen.assert_called_with(expected_args, start_new_session=True)
@@ -79,21 +60,21 @@ class SingleFileServiceTestCase(TestCase):
self.create_test_file()
with mock.patch("subprocess.Popen") as mock_popen:
singlefile.create_snapshot("http://example.com", self.html_filepath)
singlefile.create_snapshot("http://example.com", self.temp_html_filepath)
expected_args = [
"single-file",
'--browser-arg="--headless=new"',
'--browser-arg="--user-data-dir=./chromium-profile"',
'--browser-arg="--no-sandbox"',
'--browser-arg="--load-extension=uBlock0.chromium"',
'--browser-arg="--load-extension=uBOLite.chromium.mv3"',
"--some-option",
"some value",
"--another-option",
"another value",
"--third-option=third value",
"http://example.com",
self.html_filepath + ".tmp",
self.temp_html_filepath,
]
mock_popen.assert_called_with(expected_args, start_new_session=True)
@@ -103,7 +84,7 @@ class SingleFileServiceTestCase(TestCase):
self.create_test_file()
with mock.patch("subprocess.Popen", return_value=mock_process):
singlefile.create_snapshot("http://example.com", self.html_filepath)
singlefile.create_snapshot("http://example.com", self.temp_html_filepath)
mock_process.wait.assert_called_with(timeout=120)
@@ -114,6 +95,6 @@ class SingleFileServiceTestCase(TestCase):
self.create_test_file()
with mock.patch("subprocess.Popen", return_value=mock_process):
singlefile.create_snapshot("http://example.com", self.html_filepath)
singlefile.create_snapshot("http://example.com", self.temp_html_filepath)
mock_process.wait.assert_called_with(timeout=180)

View File

@@ -2,7 +2,7 @@ from django.urls import path, include
from django.urls import re_path
from bookmarks import views
from bookmarks.api.routes import router
from bookmarks.api import routes as api_routes
from bookmarks.feeds import (
AllBookmarksFeed,
UnreadBookmarksFeed,
@@ -55,7 +55,14 @@ urlpatterns = [
# Toasts
path("toasts/acknowledge", views.toasts.acknowledge, name="toasts.acknowledge"),
# API
path("api/", include(router.urls), name="api"),
path("api/", include(api_routes.default_router.urls)),
path("api/bookmarks/", include(api_routes.bookmark_router.urls)),
path(
"api/bookmarks/<int:bookmark_id>/assets/",
include(api_routes.bookmark_asset_router.urls),
),
path("api/tags/", include(api_routes.tag_router.urls)),
path("api/user/", include(api_routes.user_router.urls)),
# Feeds
path("feeds/<str:feed_key>/all", AllBookmarksFeed(), name="feeds.all"),
path("feeds/<str:feed_key>/unread", UnreadBookmarksFeed(), name="feeds.unread"),
@@ -65,4 +72,6 @@ urlpatterns = [
path("health", views.health, name="health"),
# Manifest
path("manifest.json", views.manifest, name="manifest"),
# Custom CSS
path("custom_css", views.custom_css, name="custom_css"),
]

View File

@@ -9,6 +9,7 @@ from dateutil.relativedelta import relativedelta
from django.http import HttpResponseRedirect
from django.template.defaultfilters import pluralize
from django.utils import timezone, formats
from django.conf import settings
try:
with open("version.txt", "r") as f:
@@ -128,10 +129,13 @@ def redirect_with_query(request, redirect_url):
return HttpResponseRedirect(redirect_url)
def generate_username(email):
def generate_username(email, claims):
# taken from mozilla-django-oidc docs :)
# Using Python 3 and Django 1.11+, usernames can contain alphanumeric
# (ascii and unicode), _, @, +, . and - characters. So we normalize
# it and slice at 150 characters.
return unicodedata.normalize("NFKC", email)[:150]
if settings.OIDC_USERNAME_CLAIM in claims and claims[settings.OIDC_USERNAME_CLAIM]:
username = claims[settings.OIDC_USERNAME_CLAIM]
else:
username = email
return unicodedata.normalize("NFKC", username)[:150]

View File

@@ -4,4 +4,5 @@ from .settings import *
from .toasts import *
from .health import health
from .manifest import manifest
from .custom_css import custom_css
from .root import root

View File

@@ -1,5 +1,6 @@
import urllib.parse
from django.conf import settings
from django.contrib.auth.decorators import login_required
from django.db.models import QuerySet
from django.http import (
@@ -19,7 +20,7 @@ from bookmarks.models import (
BookmarkSearch,
build_tag_string,
)
from bookmarks.services import bookmarks as bookmark_actions, tasks
from bookmarks.services import assets as asset_actions, tasks
from bookmarks.services.bookmarks import (
create_bookmark,
update_bookmark,
@@ -104,6 +105,7 @@ def shared(request):
"tag_cloud": tag_cloud,
"details": bookmark_details,
"users": users,
"rss_feed_url": reverse("bookmarks:feeds.public_shared"),
},
)
@@ -150,7 +152,6 @@ def convert_tag_string(tag_string: str):
@login_required
def new(request):
status = 200
initial_url = request.GET.get("url")
initial_title = request.GET.get("title")
initial_description = request.GET.get("description")
@@ -169,8 +170,6 @@ def new(request):
return HttpResponseRedirect(reverse("bookmarks:close"))
else:
return HttpResponseRedirect(reverse("bookmarks:index"))
else:
status = 422
else:
form = BookmarkForm()
if initial_url:
@@ -186,6 +185,7 @@ def new(request):
if initial_mark_unread:
form.initial["unread"] = "true"
status = 422 if request.method == "POST" and not form.is_valid() else 200
context = {
"form": form,
"auto_close": initial_auto_close,
@@ -216,9 +216,10 @@ def edit(request, bookmark_id: int):
form.initial["tag_string"] = build_tag_string(bookmark.tag_names, " ")
status = 422 if request.method == "POST" and not form.is_valid() else 200
context = {"form": form, "bookmark_id": bookmark_id, "return_url": return_url}
return render(request, "bookmarks/edit.html", context)
return render(request, "bookmarks/edit.html", context, status=status)
def remove(request, bookmark_id: int):
@@ -278,6 +279,9 @@ def create_html_snapshot(request, bookmark_id: int):
def upload_asset(request, bookmark_id: int):
if settings.LD_DISABLE_ASSET_UPLOAD:
return HttpResponseForbidden("Asset upload is disabled")
try:
bookmark = Bookmark.objects.get(pk=bookmark_id, owner=request.user)
except Bookmark.DoesNotExist:
@@ -285,9 +289,9 @@ def upload_asset(request, bookmark_id: int):
file = request.FILES.get("upload_asset_file")
if not file:
raise ValueError("No file uploaded")
return HttpResponseBadRequest("No file provided")
bookmark_actions.upload_asset(bookmark, file)
asset_actions.upload_asset(bookmark, file)
def remove_asset(request, asset_id: int):
@@ -315,7 +319,10 @@ def update_state(request, bookmark_id: int):
def index_action(request):
search = BookmarkSearch.from_request(request.GET)
query = queries.query_bookmarks(request.user, request.user_profile, search)
handle_action(request, query)
response = handle_action(request, query)
if response:
return response
if turbo.accept(request):
return partials.active_bookmark_update(request)
@@ -327,7 +334,10 @@ def index_action(request):
def archived_action(request):
search = BookmarkSearch.from_request(request.GET)
query = queries.query_archived_bookmarks(request.user, request.user_profile, search)
handle_action(request, query)
response = handle_action(request, query)
if response:
return response
if turbo.accept(request):
return partials.archived_bookmark_update(request)
@@ -340,7 +350,9 @@ def shared_action(request):
if "bulk_execute" in request.POST:
return HttpResponseBadRequest("View does not support bulk actions")
handle_action(request)
response = handle_action(request)
if response:
return response
if turbo.accept(request):
return partials.shared_bookmark_update(request)
@@ -351,25 +363,25 @@ def shared_action(request):
def handle_action(request, query: QuerySet[Bookmark] = None):
# Single bookmark actions
if "archive" in request.POST:
archive(request, request.POST["archive"])
return archive(request, request.POST["archive"])
if "unarchive" in request.POST:
unarchive(request, request.POST["unarchive"])
return unarchive(request, request.POST["unarchive"])
if "remove" in request.POST:
remove(request, request.POST["remove"])
return remove(request, request.POST["remove"])
if "mark_as_read" in request.POST:
mark_as_read(request, request.POST["mark_as_read"])
return mark_as_read(request, request.POST["mark_as_read"])
if "unshare" in request.POST:
unshare(request, request.POST["unshare"])
return unshare(request, request.POST["unshare"])
if "create_html_snapshot" in request.POST:
create_html_snapshot(request, request.POST["create_html_snapshot"])
return create_html_snapshot(request, request.POST["create_html_snapshot"])
if "upload_asset" in request.POST:
upload_asset(request, request.POST["upload_asset"])
return upload_asset(request, request.POST["upload_asset"])
if "remove_asset" in request.POST:
remove_asset(request, request.POST["remove_asset"])
return remove_asset(request, request.POST["remove_asset"])
# State updates
if "update_state" in request.POST:
update_state(request, request.POST["update_state"])
return update_state(request, request.POST["update_state"])
# Bulk actions
if "bulk_execute" in request.POST:
@@ -387,25 +399,25 @@ def handle_action(request, query: QuerySet[Bookmark] = None):
bookmark_ids = request.POST.getlist("bookmark_id")
if "bulk_archive" == bulk_action:
archive_bookmarks(bookmark_ids, request.user)
return archive_bookmarks(bookmark_ids, request.user)
if "bulk_unarchive" == bulk_action:
unarchive_bookmarks(bookmark_ids, request.user)
return unarchive_bookmarks(bookmark_ids, request.user)
if "bulk_delete" == bulk_action:
delete_bookmarks(bookmark_ids, request.user)
return delete_bookmarks(bookmark_ids, request.user)
if "bulk_tag" == bulk_action:
tag_string = convert_tag_string(request.POST["bulk_tag_string"])
tag_bookmarks(bookmark_ids, tag_string, request.user)
return tag_bookmarks(bookmark_ids, tag_string, request.user)
if "bulk_untag" == bulk_action:
tag_string = convert_tag_string(request.POST["bulk_tag_string"])
untag_bookmarks(bookmark_ids, tag_string, request.user)
return untag_bookmarks(bookmark_ids, tag_string, request.user)
if "bulk_read" == bulk_action:
mark_bookmarks_as_read(bookmark_ids, request.user)
return mark_bookmarks_as_read(bookmark_ids, request.user)
if "bulk_unread" == bulk_action:
mark_bookmarks_as_unread(bookmark_ids, request.user)
return mark_bookmarks_as_unread(bookmark_ids, request.user)
if "bulk_share" == bulk_action:
share_bookmarks(bookmark_ids, request.user)
return share_bookmarks(bookmark_ids, request.user)
if "bulk_unshare" == bulk_action:
unshare_bookmarks(bookmark_ids, request.user)
return unshare_bookmarks(bookmark_ids, request.user)
@login_required

View File

@@ -208,6 +208,7 @@ class BookmarkListContext:
self.show_favicons = user_profile.enable_favicons
self.show_preview_images = user_profile.enable_preview_images
self.show_notes = user_profile.permanent_notes
self.collapse_side_panel = user_profile.collapse_side_panel
@staticmethod
def generate_return_url(search: BookmarkSearch, base_url: str, page: int = None):
@@ -358,7 +359,6 @@ class BookmarkAssetItem:
self.id = asset.id
self.display_name = asset.display_name
self.asset_type = asset.asset_type
self.content_type = asset.content_type
self.file = asset.file
self.file_size = asset.file_size
self.status = asset.status
@@ -398,8 +398,8 @@ class BookmarkDetailsContext:
self.sharing_enabled = user_profile.enable_sharing
self.preview_image_enabled = user_profile.enable_preview_images
self.show_link_icons = user_profile.enable_favicons and bookmark.favicon_file
# For now hide files section if snapshots are not supported
self.show_files = settings.LD_ENABLE_SNAPSHOTS
self.snapshots_enabled = settings.LD_ENABLE_SNAPSHOTS
self.uploads_enabled = not settings.LD_DISABLE_ASSET_UPLOAD
self.web_archive_snapshot_url = bookmark.web_archive_snapshot_url
if not self.web_archive_snapshot_url:

View File

@@ -0,0 +1,10 @@
from django.http import HttpResponse
custom_css_cache_max_age = 2592000 # 30 days
def custom_css(request):
css = request.user_profile.custom_css
response = HttpResponse(css, content_type="text/css")
response["Cache-Control"] = f"public, max-age={custom_css_cache_max_age}"
return response

View File

@@ -10,7 +10,7 @@ COPY bookmarks/styles ./bookmarks/styles
RUN npm run build
FROM python:3.12.6-alpine3.20 AS python-base
FROM python:3.12.9-alpine3.21 AS build-deps
# Add required packages
# alpine-sdk linux-headers pkgconfig: build Python packages from source
# libpq-dev: build Postgres client from source
@@ -18,24 +18,8 @@ FROM python:3.12.6-alpine3.20 AS python-base
# libffi-dev openssl-dev rust cargo: build Python cryptography from source
RUN apk update && apk add alpine-sdk linux-headers libpq-dev pkgconfig icu-dev sqlite-dev libffi-dev openssl-dev rust cargo
WORKDIR /etc/linkding
FROM python-base AS python-build
# install build dependencies
# install python dependencies
COPY requirements.txt requirements.txt
RUN pip install -U pip && pip install -r requirements.txt
# copy files needed for Django build
COPY . .
COPY --from=node-build /etc/linkding .
# remove style sources
RUN rm -rf bookmarks/styles
# run Django part of the build
RUN mkdir data && \
python manage.py collectstatic
FROM python-base AS prod-deps
COPY requirements.txt ./requirements.txt
# Need to build psycopg2 from source for ARM platforms
RUN sed -i 's/psycopg2-binary/psycopg2/g' requirements.txt
RUN mkdir /opt/venv && \
@@ -44,7 +28,7 @@ RUN mkdir /opt/venv && \
/opt/venv/bin/pip install -r requirements.txt
FROM python-base AS compile-icu
FROM build-deps AS compile-icu
# Defines SQLite version
# Since this is only needed for downloading the header files this probably
# doesn't need to be up-to-date, assuming the SQLite APIs used by the ICU
@@ -65,7 +49,7 @@ RUN wget https://www.sqlite.org/${SQLITE_RELEASE_YEAR}/sqlite-amalgamation-${SQL
gcc -fPIC -shared icu.c `pkg-config --libs --cflags icu-uc icu-io` -o libicu.so
FROM python:3.12.6-alpine3.20 AS linkding
FROM python:3.12.9-alpine3.21 AS linkding
LABEL org.opencontainers.image.source="https://github.com/sissbruecker/linkding"
# install runtime dependencies
RUN apk update && apk add bash curl icu libpq mailcap libssl3
@@ -74,19 +58,25 @@ RUN set -x ; \
addgroup -g 82 -S www-data ; \
adduser -u 82 -D -S -G www-data www-data && exit 0 ; exit 1
WORKDIR /etc/linkding
# copy prod dependencies
COPY --from=prod-deps /opt/venv /opt/venv
# copy output from build stage
COPY --from=python-build /etc/linkding/static static/
# copy python dependencies
COPY --from=build-deps /opt/venv /opt/venv
# copy output from node build
COPY --from=node-build /etc/linkding/bookmarks/static bookmarks/static/
# copy compiled icu extension
COPY --from=compile-icu /etc/linkding/libicu.so libicu.so
# copy application code
COPY . .
# Activate virtual env
ENV VIRTUAL_ENV=/opt/venv
ENV PATH=/opt/venv/bin:$PATH
# Generate static files, remove source styles that are not needed
RUN mkdir data && \
python manage.py collectstatic
# Limit file descriptors used by uwsgi, see https://github.com/sissbruecker/linkding/issues/453
ENV UWSGI_MAX_FD=4096
# Expose uwsgi server at port 9090
EXPOSE 9090
# Activate virtual env
ENV VIRTUAL_ENV /opt/venv
ENV PATH /opt/venv/bin:$PATH
# Allow running containers as an an arbitrary user in the root group, to support deployment scenarios like OpenShift, Podman
RUN chmod g+w . && \
chmod +x ./bootstrap.sh
@@ -100,18 +90,20 @@ CMD ["./bootstrap.sh"]
FROM node:18-alpine AS ublock-build
WORKDIR /etc/linkding
# Install necessary tools
RUN apk add --no-cache curl jq unzip
# Fetch the latest release tag
# Download the library
# Unzip the library
RUN TAG=$(curl -sL https://api.github.com/repos/gorhill/uBlock/releases/latest | jq -r '.tag_name') && \
DOWNLOAD_URL=https://github.com/gorhill/uBlock/releases/download/$TAG/uBlock0_$TAG.chromium.zip && \
curl -L -o uBlock0.zip $DOWNLOAD_URL && \
unzip uBlock0.zip
# Patch assets.json to enable easylist-cookies by default
RUN curl -L -o ./uBlock0.chromium/assets/thirdparties/easylist/easylist-cookies.txt https://ublockorigin.github.io/uAssets/thirdparties/easylist-cookies.txt
RUN jq '."assets.json" |= del(.cdnURLs) | ."assets.json".contentURL = ["assets/assets.json"] | ."fanboy-cookiemonster" |= del(.off) | ."fanboy-cookiemonster".contentURL += ["assets/thirdparties/easylist/easylist-cookies.txt"]' ./uBlock0.chromium/assets/assets.json > temp.json && \
mv temp.json ./uBlock0.chromium/assets/assets.json
# Download and unzip the latest uBlock Origin Lite release
# Patch manifest to enable annoyances by default
# Patch ruleset-manager.js to use rulesets enabled in manifest by default
RUN apk add --no-cache curl jq unzip && \
TAG=$(curl -sL https://api.github.com/repos/uBlockOrigin/uBOL-home/releases/latest | jq -r '.tag_name') && \
DOWNLOAD_URL=https://github.com/uBlockOrigin/uBOL-home/releases/download/$TAG/$TAG.chromium.mv3.zip && \
echo "Downloading $DOWNLOAD_URL" && \
curl -L -o uBOLite.zip $DOWNLOAD_URL && \
unzip uBOLite.zip -d uBOLite.chromium.mv3 && \
rm uBOLite.zip && \
jq '.declarative_net_request.rule_resources |= map(if .id == "annoyances-overlays" or .id == "annoyances-cookies" or .id == "annoyances-social" or .id == "annoyances-widgets" or .id == "annoyances-others" then .enabled = true else . end)' \
uBOLite.chromium.mv3/manifest.json > temp.json && \
mv temp.json uBOLite.chromium.mv3/manifest.json && \
sed -i 's/const out = \[ '\''default'\'' \];/const out = await dnr.getEnabledRulesets();/' uBOLite.chromium.mv3/js/ruleset-manager.js
FROM linkding AS linkding-plus
@@ -119,9 +111,11 @@ FROM linkding AS linkding-plus
RUN apk update && apk add nodejs npm chromium
# install single-file from fork for now, which contains several hotfixes
RUN npm install -g https://github.com/sissbruecker/single-file-cli/tarball/4c54b3bc704cfb3e96cec2d24854caca3df0b3b6
# copy uBlock0
COPY --from=ublock-build /etc/linkding/uBlock0.chromium uBlock0.chromium/
# create chromium profile folder for user running background tasks
RUN mkdir -p chromium-profile && chown -R www-data:www-data chromium-profile
# copy uBlock
COPY --from=ublock-build /etc/linkding/uBOLite.chromium.mv3 uBOLite.chromium.mv3/
# create chromium profile folder for user running background tasks and set permissions
RUN mkdir -p chromium-profile && \
chown -R www-data:www-data chromium-profile && \
chown -R www-data:www-data uBOLite.chromium.mv3
# enable snapshot support
ENV LD_ENABLE_SNAPSHOTS=True

View File

@@ -10,7 +10,7 @@ COPY bookmarks/styles ./bookmarks/styles
RUN npm run build
FROM python:3.12.6-slim-bookworm AS python-base
FROM python:3.12.9-slim-bookworm AS build-deps
# Add required packages
# build-essential pkg-config: build Python packages from source
# libpq-dev: build Postgres client from source
@@ -20,24 +20,8 @@ RUN apt-get update && apt-get -y install build-essential pkg-config libpq-dev li
RUN curl https://sh.rustup.rs -sSf | sh -s -- -y
ENV PATH="/root/.cargo/bin:${PATH}"
WORKDIR /etc/linkding
FROM python-base AS python-build
# install build dependencies
# install python dependencies
COPY requirements.txt requirements.txt
RUN pip install -U pip && pip install -r requirements.txt
# copy files needed for Django build
COPY . .
COPY --from=node-build /etc/linkding .
# remove style sources
RUN rm -rf bookmarks/styles
# run Django part of the build
RUN mkdir data && \
python manage.py collectstatic
FROM python-base AS prod-deps
COPY requirements.txt ./requirements.txt
# Need to build psycopg2 from source for ARM platforms
RUN sed -i 's/psycopg2-binary/psycopg2/g' requirements.txt
RUN mkdir /opt/venv && \
@@ -46,7 +30,7 @@ RUN mkdir /opt/venv && \
/opt/venv/bin/pip install -r requirements.txt
FROM python-base AS compile-icu
FROM build-deps AS compile-icu
# Defines SQLite version
# Since this is only needed for downloading the header files this probably
# doesn't need to be up-to-date, assuming the SQLite APIs used by the ICU
@@ -67,27 +51,33 @@ RUN wget https://www.sqlite.org/${SQLITE_RELEASE_YEAR}/sqlite-amalgamation-${SQL
gcc -fPIC -shared icu.c `pkg-config --libs --cflags icu-uc icu-io` -o libicu.so
FROM python:3.12.6-slim-bookworm as linkding
FROM python:3.12.9-slim-bookworm AS linkding
LABEL org.opencontainers.image.source="https://github.com/sissbruecker/linkding"
# install runtime dependencies
RUN apt-get update && apt-get -y install mime-support libpq-dev libicu-dev libssl3 curl
WORKDIR /etc/linkding
# copy prod dependencies
COPY --from=prod-deps /opt/venv /opt/venv
# copy output from build stage
COPY --from=python-build /etc/linkding/static static/
# copy python dependencies
COPY --from=build-deps /opt/venv /opt/venv
# copy output from node build
COPY --from=node-build /etc/linkding/bookmarks/static bookmarks/static/
# copy compiled icu extension
COPY --from=compile-icu /etc/linkding/libicu.so libicu.so
# copy application code
COPY . .
# Activate virtual env
ENV VIRTUAL_ENV=/opt/venv
ENV PATH=/opt/venv/bin:$PATH
# Generate static files
RUN mkdir data && \
python manage.py collectstatic
# Limit file descriptors used by uwsgi, see https://github.com/sissbruecker/linkding/issues/453
ENV UWSGI_MAX_FD=4096
# Expose uwsgi server at port 9090
EXPOSE 9090
# Activate virtual env
ENV VIRTUAL_ENV /opt/venv
ENV PATH /opt/venv/bin:$PATH
# Allow running containers as an an arbitrary user in the root group, to support deployment scenarios like OpenShift, Podman
RUN ["chmod", "g+w", "."]
# Run bootstrap logic
RUN ["chmod", "+x", "./bootstrap.sh"]
RUN chmod g+w . && \
chmod +x ./bootstrap.sh
HEALTHCHECK --interval=30s --retries=3 --timeout=1s \
CMD curl -f http://localhost:${LD_SERVER_PORT:-9090}/${LD_CONTEXT_PATH}health || exit 1
@@ -98,18 +88,20 @@ CMD ["./bootstrap.sh"]
FROM node:18-alpine AS ublock-build
WORKDIR /etc/linkding
# Install necessary tools
RUN apk add --no-cache curl jq unzip
# Fetch the latest release tag
# Download the library
# Unzip the library
RUN TAG=$(curl -sL https://api.github.com/repos/gorhill/uBlock/releases/latest | jq -r '.tag_name') && \
DOWNLOAD_URL=https://github.com/gorhill/uBlock/releases/download/$TAG/uBlock0_$TAG.chromium.zip && \
curl -L -o uBlock0.zip $DOWNLOAD_URL && \
unzip uBlock0.zip
# Patch assets.json to enable easylist-cookies by default
RUN curl -L -o ./uBlock0.chromium/assets/thirdparties/easylist/easylist-cookies.txt https://ublockorigin.github.io/uAssets/thirdparties/easylist-cookies.txt
RUN jq '."assets.json" |= del(.cdnURLs) | ."assets.json".contentURL = ["assets/assets.json"] | ."fanboy-cookiemonster" |= del(.off) | ."fanboy-cookiemonster".contentURL += ["assets/thirdparties/easylist/easylist-cookies.txt"]' ./uBlock0.chromium/assets/assets.json > temp.json && \
mv temp.json ./uBlock0.chromium/assets/assets.json
# Download and unzip the latest uBlock Origin Lite release
# Patch manifest to enable annoyances by default
# Patch ruleset-manager.js to use rulesets enabled in manifest by default
RUN apk add --no-cache curl jq unzip && \
TAG=$(curl -sL https://api.github.com/repos/uBlockOrigin/uBOL-home/releases/latest | jq -r '.tag_name') && \
DOWNLOAD_URL=https://github.com/uBlockOrigin/uBOL-home/releases/download/$TAG/$TAG.chromium.mv3.zip && \
echo "Downloading $DOWNLOAD_URL" && \
curl -L -o uBOLite.zip $DOWNLOAD_URL && \
unzip uBOLite.zip -d uBOLite.chromium.mv3 && \
rm uBOLite.zip && \
jq '.declarative_net_request.rule_resources |= map(if .id == "annoyances-overlays" or .id == "annoyances-cookies" or .id == "annoyances-social" or .id == "annoyances-widgets" or .id == "annoyances-others" then .enabled = true else . end)' \
uBOLite.chromium.mv3/manifest.json > temp.json && \
mv temp.json uBOLite.chromium.mv3/manifest.json && \
sed -i 's/const out = \[ '\''default'\'' \];/const out = await dnr.getEnabledRulesets();/' uBOLite.chromium.mv3/js/ruleset-manager.js
FROM linkding AS linkding-plus
@@ -123,9 +115,11 @@ RUN apt-get install -y gnupg2 apt-transport-https ca-certificates && \
apt-get update && apt-get install -y nodejs
# install single-file from fork for now, which contains several hotfixes
RUN npm install -g https://github.com/sissbruecker/single-file-cli/tarball/4c54b3bc704cfb3e96cec2d24854caca3df0b3b6
# create chromium profile folder for user running background tasks
RUN mkdir -p chromium-profile && chown -R www-data:www-data chromium-profile
# copy uBlock0
COPY --from=ublock-build /etc/linkding/uBlock0.chromium uBlock0.chromium/
# copy uBlock
COPY --from=ublock-build /etc/linkding/uBOLite.chromium.mv3 uBOLite.chromium.mv3/
# create chromium profile folder for user running background tasks and set permissions
RUN mkdir -p chromium-profile && \
chown -R www-data:www-data chromium-profile && \
chown -R www-data:www-data uBOLite.chromium.mv3
# enable snapshot support
ENV LD_ENABLE_SNAPSHOTS=True

View File

@@ -27,10 +27,11 @@ export default defineConfig({
label: 'Guides',
items: [
{ label: 'Backups', slug: 'backups' },
{ label: 'Admin', slug: 'admin' },
{ label: 'Archiving', slug: 'archiving' },
{ label: 'Keyboard Shortcuts', slug: 'shortcuts' },
{ label: 'How To', slug: 'how-to' },
{ label: 'Troubleshooting', slug: 'troubleshooting' },
{ label: 'Admin', slug: 'admin' },
{ label: 'REST API', slug: 'api' },
],
},
@@ -45,6 +46,9 @@ export default defineConfig({
customCss: [
'./src/styles/custom.css',
],
editLink: {
baseUrl: 'https://github.com/sissbruecker/linkding/edit/master/docs/',
},
}),
],
});

1199
docs/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,7 +12,7 @@
"dependencies": {
"@astrojs/check": "^0.9.3",
"@astrojs/starlight": "^0.27.1",
"astro": "^4.15.8",
"astro": "^4.16.18",
"sharp": "^0.32.5",
"typescript": "^5.6.2"
}

View File

Before

Width:  |  Height:  |  Size: 189 KiB

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

View File

@@ -9,9 +9,35 @@ description: "Acknowledgements and thanks to contributors and sponsors"
See the table below for a list of donations.
| Source | Description | Amount | Donated to |
|---------------------------------------|---------------------------------------------|---------|------------------------------------------------------------------|
| [PikaPods](https://www.pikapods.com/) | Linkding hosting June 2022 - September 2023 | $163.50 | [Internet Archive](/2023-10-11-internet-archive.png) |
<table>
<thead>
<tr>
<th>Source</th>
<th>Description</th>
<th>Amount</th>
<th>Donated to</th>
</tr>
</thead>
<tbody>
<tr>
<td><a href="https://www.pikapods.com/">PikaPods</a></td>
<td>Linkding hosting June 2022 - September 2023</td>
<td>$163.50</td>
<td><a href="/donations/2023-10-11-internet-archive.png">Internet Archive</a></td>
</tr>
<tr>
<td><a href="https://www.pikapods.com/">PikaPods</a></td>
<td>Linkding hosting October 2023 - September 2024</td>
<td>$287.04</td>
<td>
<a href="/donations/2024-10-04-django.png">Django</a><br>
<a href="/donations/2024-10-04-singlefile.png">SingleFile</a><br>
<a href="/donations/2024-10-04-internet-archive.png">Internet Archive</a><br>
<a href="/donations/2024-10-04-noyb.png">NOYB</a>
</td>
</tr>
</tbody>
</table>
## JetBrains

View File

@@ -7,7 +7,8 @@ The application provides a REST API that can be used by 3rd party applications t
## Authentication
All requests against the API must be authorized using an authorization token. The application automatically generates an API token for each user, which can be accessed through the *Settings* page.
All requests against the API must be authorized using an authorization token. The application automatically generates an
API token for each user, which can be accessed through the *Settings* page.
The token needs to be passed as `Authorization` header in the HTTP request:
@@ -91,9 +92,11 @@ Retrieves a single bookmark by ID.
GET /api/bookmarks/check/?url=https%3A%2F%2Fexample.com
```
Allows to check if a URL is already bookmarked. If the URL is already bookmarked, the `bookmark` property in the response holds the bookmark data, otherwise it is `null`.
Allows to check if a URL is already bookmarked. If the URL is already bookmarked, the `bookmark` property in the
response holds the bookmark data, otherwise it is `null`.
Also returns a `metadata` property that contains metadata scraped from the website. Finally, the `auto_tags` property contains the tag names that would be automatically added when creating a bookmark for that URL.
Also returns a `metadata` property that contains metadata scraped from the website. Finally, the `auto_tags` property
contains the tag names that would be automatically added when creating a bookmark for that URL.
Example response:
@@ -127,11 +130,13 @@ POST /api/bookmarks/
Creates a new bookmark. Tags are simply assigned using their names. Including
`is_archived: true` saves a bookmark directly to the archive.
If the title and description are not provided or empty, the application automatically tries to scrape them from the bookmarked website. This behavior can be disabled by adding the `disable_scraping` query parameter to the API request. If you have an application where you want to keep using scraped metadata, but also allow users to leave the title or description empty, you should:
If the provided URL is already bookmarked, this silently updates the existing bookmark instead of creating a new one. If
you are implementing a user interface, consider notifying users about this behavior. You can use the `/check` endpoint
to check if a URL is already bookmarked and at the same time get the existing bookmark data. This behavior may change in
the future to return an error instead.
- Fetch the scraped title and description using the `/check` endpoint.
- Prefill the title and description fields in your app with the fetched values and allow users to clear those values.
- Add the `disable_scraping` query parameter to prevent the API from adding them back again.
If the title and description are not provided or empty, the application automatically tries to scrape them from the
bookmarked website. This behavior can be disabled by adding the `disable_scraping` query parameter to the API request.
Example payload:
@@ -155,36 +160,17 @@ Example payload:
```
PUT /api/bookmarks/<id>/
```
Updates a bookmark.
This is a full update, which requires at least a URL, and fields that are not specified are cleared or reset to their defaults.
Tags are simply assigned using their names.
Example payload:
```json
{
"url": "https://example.com",
"title": "Example title",
"description": "Example description",
"tag_names": [
"tag1",
"tag2"
]
}
```
**Patch**
```
PATCH /api/bookmarks/<id>/
```
Updates a bookmark partially.
Allows to modify individual fields of a bookmark.
Updates a bookmark.
When using `POST`, at least all required fields must be provided (currently only `url`).
When using `PATCH`, only the fields that should be updated need to be provided.
Regardless which method is used, any field that is not provided is not modified.
Tags are simply assigned using their names.
If the provided URL is already bookmarked this returns an error.
Example payload:
```json
@@ -223,6 +209,96 @@ DELETE /api/bookmarks/<id>/
Deletes a bookmark by ID.
### Bookmark Assets
**List**
```
GET /api/bookmarks/<bookmark_id>/assets/
```
List assets for a specific bookmark.
Example response:
```json
{
"count": 2,
"next": null,
"previous": null,
"results": [
{
"id": 1,
"bookmark": 1,
"asset_type": "snapshot",
"date_created": "2023-10-01T12:00:00Z",
"content_type": "text/html",
"display_name": "HTML snapshot from 10/01/2023",
"status": "complete",
"gzip": true
},
{
"id": 2,
"bookmark": 1,
"asset_type": "upload",
"date_created": "2023-10-01T12:05:00Z",
"content_type": "image/png",
"display_name": "example.png",
"status": "complete",
"gzip": false
}
]
}
```
**Retrieve**
```
GET /api/bookmarks/<bookmark_id>/assets/<id>/
```
Retrieves a single asset by ID for a specific bookmark.
**Download**
```
GET /api/bookmarks/<bookmark_id>/assets/<id>/download/
```
Downloads the asset file.
**Upload**
```
POST /api/bookmarks/<bookmark_id>/assets/upload/
```
Uploads a new asset for a specific bookmark. The request must be a `multipart/form-data` request with a single part
named `file` containing the file to upload.
Example response:
```json
{
"id": 3,
"bookmark": 1,
"asset_type": "upload",
"date_created": "2023-10-01T12:10:00Z",
"content_type": "application/pdf",
"display_name": "example.pdf",
"status": "complete",
"gzip": false
}
```
**Delete**
```
DELETE /api/bookmarks/<bookmark_id>/assets/<id>/
```
Deletes an asset by ID for a specific bookmark.
### Tags
**List**

Some files were not shown because too many files have changed in this diff Show More