Repository: sissbruecker/linkding
Pull request #851 (2024-09-23 23:12:47)

Bump rollup from 4.13.0 to 4.22.4
Bumps [rollup](https://github.com/rollup/rollup) from 4.13.0 to 4.22.4.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a href="https://github.com/rollup/rollup/releases">rollup's releases</a>.</em></p>
<blockquote>
<h2>v4.22.4</h2>
<h2>4.22.4</h2>
<p><em>2024-09-21</em></p>
<h3>Bug Fixes</h3>
<ul>
<li>Fix a vulnerability in generated code that affects IIFE, UMD and CJS bundles when run in a browser context (<a href="https://redirect.github.com/rollup/rollup/issues/5671">#5671</a>)</li>
</ul>
<h3>Pull Requests</h3>
<ul>
<li><a href="https://redirect.github.com/rollup/rollup/pull/5670">#5670</a>: refactor: Use object.prototype to check for reserved properties (<a href="https://github.com/YuHyeonWook"><code>@​YuHyeonWook</code></a>)</li>
<li><a href="https://redirect.github.com/rollup/rollup/pull/5671">#5671</a>: Fix DOM Clobbering CVE (<a href="https://github.com/lukastaegert"><code>@​lukastaegert</code></a>)</li>
</ul>
<h2>v4.22.3</h2>
<h2>4.22.3</h2>
<p><em>2024-09-21</em></p>
<h3>Bug Fixes</h3>
<ul>
<li>Ensure that mutations in modules without side effects are observed while properly handling transitive dependencies (<a href="https://redirect.github.com/rollup/rollup/issues/5669">#5669</a>)</li>
</ul>
<h3>Pull Requests</h3>
<ul>
<li><a href="https://redirect.github.com/rollup/rollup/pull/5669">#5669</a>: Ensure impure dependencies of pure modules are added (<a href="https://github.com/lukastaegert"><code>@​lukastaegert</code></a>)</li>
</ul>
<h2>v4.22.2</h2>
<h2>4.22.2</h2>
<p><em>2024-09-20</em></p>
<h3>Bug Fixes</h3>
<ul>
<li>Revert fix for side effect free modules until other issues are investigated (<a href="https://redirect.github.com/rollup/rollup/issues/5667">#5667</a>)</li>
</ul>
<h3>Pull Requests</h3>
<ul>
<li><a href="https://redirect.github.com/rollup/rollup/pull/5667">#5667</a>: Partially revert <a href="https://redirect.github.com/rollup/rollup/issues/5658">#5658</a> and re-apply <a href="https://redirect.github.com/rollup/rollup/issues/5644">#5644</a> (<a href="https://github.com/lukastaegert"><code>@​lukastaegert</code></a>)</li>
</ul>
<h2>v4.22.1</h2>
<h2>4.22.1</h2>
<p><em>2024-09-20</em></p>
<h3>Bug Fixes</h3>
<ul>
<li>Revert <a href="https://redirect.github.com/rollup/rollup/issues/5644">#5644</a> &quot;stable chunk hashes&quot; while issues are being investigated</li>
</ul>
<h3>Pull Requests</h3>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a href="https://github.com/rollup/rollup/blob/master/CHANGELOG.md">rollup's changelog</a>.</em></p>
<blockquote>
<h2>4.22.4</h2>
<p><em>2024-09-21</em></p>
<h3>Bug Fixes</h3>
<ul>
<li>Fix a vulnerability in generated code that affects IIFE, UMD and CJS bundles when run in a browser context (<a href="https://redirect.github.com/rollup/rollup/issues/5671">#5671</a>)</li>
</ul>
<h3>Pull Requests</h3>
<ul>
<li><a href="https://redirect.github.com/rollup/rollup/pull/5670">#5670</a>: refactor: Use object.prototype to check for reserved properties (<a href="https://github.com/YuHyeonWook"><code>@​YuHyeonWook</code></a>)</li>
<li><a href="https://redirect.github.com/rollup/rollup/pull/5671">#5671</a>: Fix DOM Clobbering CVE (<a href="https://github.com/lukastaegert"><code>@​lukastaegert</code></a>)</li>
</ul>
<h2>4.22.3</h2>
<p><em>2024-09-21</em></p>
<h3>Bug Fixes</h3>
<ul>
<li>Ensure that mutations in modules without side effects are observed while properly handling transitive dependencies (<a href="https://redirect.github.com/rollup/rollup/issues/5669">#5669</a>)</li>
</ul>
<h3>Pull Requests</h3>
<ul>
<li><a href="https://redirect.github.com/rollup/rollup/pull/5669">#5669</a>: Ensure impure dependencies of pure modules are added (<a href="https://github.com/lukastaegert"><code>@​lukastaegert</code></a>)</li>
</ul>
<h2>4.22.2</h2>
<p><em>2024-09-20</em></p>
<h3>Bug Fixes</h3>
<ul>
<li>Revert fix for side effect free modules until other issues are investigated (<a href="https://redirect.github.com/rollup/rollup/issues/5667">#5667</a>)</li>
</ul>
<h3>Pull Requests</h3>
<ul>
<li><a href="https://redirect.github.com/rollup/rollup/pull/5667">#5667</a>: Partially revert <a href="https://redirect.github.com/rollup/rollup/issues/5658">#5658</a> and re-apply <a href="https://redirect.github.com/rollup/rollup/issues/5644">#5644</a> (<a href="https://github.com/lukastaegert"><code>@​lukastaegert</code></a>)</li>
</ul>
<h2>4.22.1</h2>
<p><em>2024-09-20</em></p>
<h3>Bug Fixes</h3>
<ul>
<li>Revert <a href="https://redirect.github.com/rollup/rollup/issues/5644">#5644</a> &quot;stable chunk hashes&quot; while issues are being investigated</li>
</ul>
<h3>Pull Requests</h3>
<ul>
<li><a href="https://redirect.github.com/rollup/rollup/pull/5663">#5663</a>: chore(deps): update dependency inquirer to v11 (<a href="https://github.com/renovate"><code>@​renovate</code></a>[bot], <a href="https://github.com/lukastaegert"><code>@​lukastaegert</code></a>)</li>
<li><a href="https://redirect.github.com/rollup/rollup/pull/5664">#5664</a>: chore(deps): lock file maintenance minor/patch updates (<a href="https://github.com/renovate"><code>@​renovate</code></a>[bot])</li>
<li><a href="https://redirect.github.com/rollup/rollup/pull/5665">#5665</a>: fix: type in CI file (<a href="https://github.com/YuHyeonWook"><code>@​YuHyeonWook</code></a>)</li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a href="https://github.com/rollup/rollup/commit/79c0aba353ca84c0e22c3cfe9eee433ba83f3670"><code>79c0aba</code></a> 4.22.4</li>
<li><a href="https://github.com/rollup/rollup/commit/e2552c9e955e0a61f70f508200ee9f752f85a541"><code>e2552c9</code></a> Fix DOM Clobbering CVE (<a href="https://redirect.github.com/rollup/rollup/issues/5671">#5671</a>)</li>
<li><a href="https://github.com/rollup/rollup/commit/10ab90ea612f80de21c6c433c2d792eaf7b45f1c"><code>10ab90e</code></a> refactor: Use object.prototype to check for reserved properties (<a href="https://redirect.github.com/rollup/rollup/issues/5670">#5670</a>)</li>
<li><a href="https://github.com/rollup/rollup/commit/e1cba8e84a0c01dd16580ba7a2536a988dfb4e18"><code>e1cba8e</code></a> 4.22.3</li>
<li><a href="https://github.com/rollup/rollup/commit/59cec3e86748369ce887f8fdb4ef7351335ab281"><code>59cec3e</code></a> Ensure impure dependencies of pure modules are added (<a href="https://redirect.github.com/rollup/rollup/issues/5669">#5669</a>)</li>
<li><a href="https://github.com/rollup/rollup/commit/b86ffd776cfa906573d36c3f019316d02445d9ef"><code>b86ffd7</code></a> 4.22.2</li>
<li><a href="https://github.com/rollup/rollup/commit/d5ff63de9e317283f059bde06320bca11cf90488"><code>d5ff63d</code></a> Partially revert <a href="https://redirect.github.com/rollup/rollup/issues/5658">#5658</a> and re-apply <a href="https://redirect.github.com/rollup/rollup/issues/5644">#5644</a> (<a href="https://redirect.github.com/rollup/rollup/issues/5667">#5667</a>)</li>
<li><a href="https://github.com/rollup/rollup/commit/0a821d931894f7f6f4ee33285b6f0925e10c8348"><code>0a821d9</code></a> Create SECURITY.md</li>
<li><a href="https://github.com/rollup/rollup/commit/76e962daca5b7352bf199c28fa0a10ad4745c5e7"><code>76e962d</code></a> 4.22.1</li>
<li><a href="https://github.com/rollup/rollup/commit/68c23da8824e05e84460a9a5bf18c4e91912a52a"><code>68c23da</code></a> Partially revert <a href="https://redirect.github.com/rollup/rollup/issues/5644">#5644</a></li>
<li>Additional commits viewable in <a href="https://github.com/rollup/rollup/compare/v4.13.0...v4.22.4">compare view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=rollup&package-manager=npm_and_yarn&previous-version=4.13.0&new-version=4.22.4)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/sissbruecker/linkding/network/alerts).

</details>


Patch:
diff --git a/bookmarks/api/serializers.py b/bookmarks/api/serializers.py
index 88efa26..153341e 100644
--- a/bookmarks/api/serializers.py
+++ b/bookmarks/api/serializers.py
@@ -49,6 +49,7 @@ class BookmarkSerializer(serializers.ModelSerializer):
             "web_archive_snapshot_url",
             "favicon_url",
             "preview_image_url",
+            "tag_names",
             "date_added",
             "date_modified",
             "website_title",
@@ -56,15 +57,9 @@ 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 return URLs for favicon and preview image
     favicon_url = serializers.SerializerMethodField()
     preview_image_url = serializers.SerializerMethodField()
     # Add dummy website title and description fields for backwards compatibility but keep them empty
@@ -94,15 +89,9 @@ 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"])
         # Unless scraping is explicitly disabled, enhance bookmark with website
@@ -113,18 +102,33 @@ class BookmarkSerializer(serializers.ModelSerializer):
         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"])
 
+    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 TagSerializer(serializers.ModelSerializer):
     class Meta:
diff --git a/bookmarks/models.py b/bookmarks/models.py
index e65c225..2b94cfa 100644
--- a/bookmarks/models.py
+++ b/bookmarks/models.py
@@ -168,6 +168,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"
diff --git a/bookmarks/services/auto_tagging.py b/bookmarks/services/auto_tagging.py
index 2608761..840745a 100644
--- a/bookmarks/services/auto_tagging.py
+++ b/bookmarks/services/auto_tagging.py
@@ -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("#")
diff --git a/bookmarks/services/bookmarks.py b/bookmarks/services/bookmarks.py
index 034404f..9bc9ea4 100644
--- a/bookmarks/services/bookmarks.py
+++ b/bookmarks/services/bookmarks.py
@@ -65,7 +65,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
 
diff --git a/bookmarks/styles/theme/modals.css b/bookmarks/styles/theme/modals.css
index 0077fd3..aa0d73a 100644
--- a/bookmarks/styles/theme/modals.css
+++ b/bookmarks/styles/theme/modals.css
@@ -33,7 +33,7 @@
       cursor: default;
       display: block;
       left: 0;
-      position: absolute;
+      position: fixed;
       right: 0;
       top: 0;
     }
diff --git a/docs/src/content/docs/api.md b/docs/src/content/docs/api.md
index 7a12bad..06f640a 100644
--- a/docs/src/content/docs/api.md
+++ b/docs/src/content/docs/api.md
@@ -127,11 +127,9 @@ 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,35 +153,16 @@ Example payload:
 
 ```
 PUT /api/bookmarks/<id>/
+PATCH /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.
+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.
 
-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.
-Tags are simply assigned using their names.
+If the provided URL is already bookmarked this returns an error.
 
 Example payload:
 
diff --git a/docs/src/styles/custom.css b/docs/src/styles/custom.css
index 8ee7be6..21988f3 100644
--- a/docs/src/styles/custom.css
+++ b/docs/src/styles/custom.css
@@ -46,6 +46,7 @@
 [data-has-hero] header {
     background: transparent;
     border-bottom: solid 1px transparent;
+    -webkit-backdrop-filter: blur(10px);
     backdrop-filter: blur(10px);
 }
 
diff --git a/package-lock.json b/package-lock.json
index bdd1dfb..72a0524 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
 {
   "name": "linkding",
-  "version": "1.34.0",
+  "version": "1.35.0",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "linkding",
-      "version": "1.34.0",
+      "version": "1.35.0",
       "license": "MIT",
       "dependencies": {
         "@hotwired/turbo": "^8.0.6",
@@ -238,9 +238,9 @@
       }
     },
     "node_modules/@rollup/rollup-android-arm-eabi": {
-      "version": "4.13.0",
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.13.0.tgz",
-      "integrity": "sha512-5ZYPOuaAqEH/W3gYsRkxQATBW3Ii1MfaT4EQstTnLKViLi2gLSQmlmtTpGucNP3sXEpOiI5tdGhjdE111ekyEg==",
+      "version": "4.22.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.22.4.tgz",
+      "integrity": "sha512-Fxamp4aEZnfPOcGA8KSNEohV8hX7zVHOemC8jVBoBUHu5zpJK/Eu3uJwt6BMgy9fkvzxDaurgj96F/NiLukF2w==",
       "cpu": [
         "arm"
       ],
@@ -251,9 +251,9 @@
       "peer": true
     },
     "node_modules/@rollup/rollup-android-arm64": {
-      "version": "4.13.0",
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.13.0.tgz",
-      "integrity": "sha512-BSbaCmn8ZadK3UAQdlauSvtaJjhlDEjS5hEVVIN3A4bbl3X+otyf/kOJV08bYiRxfejP3DXFzO2jz3G20107+Q==",
+      "version": "4.22.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.22.4.tgz",
+      "integrity": "sha512-VXoK5UMrgECLYaMuGuVTOx5kcuap1Jm8g/M83RnCHBKOqvPPmROFJGQaZhGccnsFtfXQ3XYa4/jMCJvZnbJBdA==",
       "cpu": [
         "arm64"
       ],
@@ -264,9 +264,9 @@
       "peer": true
     },
     "node_modules/@rollup/rollup-darwin-arm64": {
-      "version": "4.13.0",
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.13.0.tgz",
-      "integrity": "sha512-Ovf2evVaP6sW5Ut0GHyUSOqA6tVKfrTHddtmxGQc1CTQa1Cw3/KMCDEEICZBbyppcwnhMwcDce9ZRxdWRpVd6g==",
+      "version": "4.22.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.22.4.tgz",
+      "integrity": "sha512-xMM9ORBqu81jyMKCDP+SZDhnX2QEVQzTcC6G18KlTQEzWK8r/oNZtKuZaCcHhnsa6fEeOBionoyl5JsAbE/36Q==",
       "cpu": [
         "arm64"
       ],
@@ -277,9 +277,9 @@
       "peer": true
     },
     "node_modules/@rollup/rollup-darwin-x64": {
-      "version": "4.13.0",
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.13.0.tgz",
-      "integrity": "sha512-U+Jcxm89UTK592vZ2J9st9ajRv/hrwHdnvyuJpa5A2ngGSVHypigidkQJP+YiGL6JODiUeMzkqQzbCG3At81Gg==",
+      "version": "4.22.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.22.4.tgz",
+      "integrity": "sha512-aJJyYKQwbHuhTUrjWjxEvGnNNBCnmpHDvrb8JFDbeSH3m2XdHcxDd3jthAzvmoI8w/kSjd2y0udT+4okADsZIw==",
       "cpu": [
         "x64"
       ],
@@ -290,9 +290,22 @@
       "peer": true
     },
     "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
-      "version": "4.13.0",
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.13.0.tgz",
-      "integrity": "sha512-8wZidaUJUTIR5T4vRS22VkSMOVooG0F4N+JSwQXWSRiC6yfEsFMLTYRFHvby5mFFuExHa/yAp9juSphQQJAijQ==",
+      "version": "4.22.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.22.4.tgz",
+      "integrity": "sha512-j63YtCIRAzbO+gC2L9dWXRh5BFetsv0j0va0Wi9epXDgU/XUi5dJKo4USTttVyK7fGw2nPWK0PbAvyliz50SCQ==",
+      "cpu": [
+        "arm"
+      ],
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "peer": true
+    },
+    "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+      "version": "4.22.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.22.4.tgz",
+      "integrity": "sha512-dJnWUgwWBX1YBRsuKKMOlXCzh2Wu1mlHzv20TpqEsfdZLb3WoJW2kIEsGwLkroYf24IrPAvOT/ZQ2OYMV6vlrg==",
       "cpu": [
         "arm"
       ],
@@ -303,9 +316,9 @@
       "peer": true
     },
     "node_modules/@rollup/rollup-linux-arm64-gnu": {
-      "version": "4.13.0",
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.13.0.tgz",
-      "integrity": "sha512-Iu0Kno1vrD7zHQDxOmvweqLkAzjxEVqNhUIXBsZ8hu8Oak7/5VTPrxOEZXYC1nmrBVJp0ZcL2E7lSuuOVaE3+w==",
+      "version": "4.22.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.22.4.tgz",
+      "integrity": "sha512-AdPRoNi3NKVLolCN/Sp4F4N1d98c4SBnHMKoLuiG6RXgoZ4sllseuGioszumnPGmPM2O7qaAX/IJdeDU8f26Aw==",
       "cpu": [
         "arm64"
       ],
@@ -316,9 +329,9 @@
       "peer": true
     },
     "node_modules/@rollup/rollup-linux-arm64-musl": {
-      "version": "4.13.0",
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.13.0.tgz",
-      "integrity": "sha512-C31QrW47llgVyrRjIwiOwsHFcaIwmkKi3PCroQY5aVq4H0A5v/vVVAtFsI1nfBngtoRpeREvZOkIhmRwUKkAdw==",
+      "version": "4.22.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.22.4.tgz",
+      "integrity": "sha512-Gl0AxBtDg8uoAn5CCqQDMqAx22Wx22pjDOjBdmG0VIWX3qUBHzYmOKh8KXHL4UpogfJ14G4wk16EQogF+v8hmA==",
       "cpu": [
         "arm64"
       ],
@@ -328,10 +341,23 @@
       ],
       "peer": true
     },
+    "node_modules/@rollup/rollup-linux-powerpc64le-gnu": {
+      "version": "4.22.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.22.4.tgz",
+      "integrity": "sha512-3aVCK9xfWW1oGQpTsYJJPF6bfpWfhbRnhdlyhak2ZiyFLDaayz0EP5j9V1RVLAAxlmWKTDfS9wyRyY3hvhPoOg==",
+      "cpu": [
+        "ppc64"
+      ],
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "peer": true
+    },
     "node_modules/@rollup/rollup-linux-riscv64-gnu": {
-      "version": "4.13.0",
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.13.0.tgz",
-      "integrity": "sha512-Oq90dtMHvthFOPMl7pt7KmxzX7E71AfyIhh+cPhLY9oko97Zf2C9tt/XJD4RgxhaGeAraAXDtqxvKE1y/j35lA==",
+      "version": "4.22.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.22.4.tgz",
+      "integrity": "sha512-ePYIir6VYnhgv2C5Xe9u+ico4t8sZWXschR6fMgoPUK31yQu7hTEJb7bCqivHECwIClJfKgE7zYsh1qTP3WHUA==",
       "cpu": [
         "riscv64"
       ],
@@ -341,10 +367,23 @@
       ],
       "peer": true
     },
+    "node_modules/@rollup/rollup-linux-s390x-gnu": {
+      "version": "4.22.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.22.4.tgz",
+      "integrity": "sha512-GqFJ9wLlbB9daxhVlrTe61vJtEY99/xB3C8e4ULVsVfflcpmR6c8UZXjtkMA6FhNONhj2eA5Tk9uAVw5orEs4Q==",
+      "cpu": [
+        "s390x"
+      ],
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "peer": true
+    },
     "node_modules/@rollup/rollup-linux-x64-gnu": {
-      "version": "4.13.0",
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.13.0.tgz",
-      "integrity": "sha512-yUD/8wMffnTKuiIsl6xU+4IA8UNhQ/f1sAnQebmE/lyQ8abjsVyDkyRkWop0kdMhKMprpNIhPmYlCxgHrPoXoA==",
+      "version": "4.22.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.22.4.tgz",
+      "integrity": "sha512-87v0ol2sH9GE3cLQLNEy0K/R0pz1nvg76o8M5nhMR0+Q+BBGLnb35P0fVz4CQxHYXaAOhE8HhlkaZfsdUOlHwg==",
       "cpu": [
         "x64"
       ],
@@ -355,9 +394,9 @@
       "peer": true
     },
     "node_modules/@rollup/rollup-linux-x64-musl": {
-      "version": "4.13.0",
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.13.0.tgz",
-      "integrity": "sha512-9RyNqoFNdF0vu/qqX63fKotBh43fJQeYC98hCaf89DYQpv+xu0D8QFSOS0biA7cGuqJFOc1bJ+m2rhhsKcw1hw==",
+      "version": "4.22.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.22.4.tgz",
+      "integrity": "sha512-UV6FZMUgePDZrFjrNGIWzDo/vABebuXBhJEqrHxrGiU6HikPy0Z3LfdtciIttEUQfuDdCn8fqh7wiFJjCNwO+g==",
       "cpu": [
         "x64"
       ],
@@ -368,9 +407,9 @@
       "peer": true
     },
     "node_modules/@rollup/rollup-win32-arm64-msvc": {
-      "version": "4.13.0",
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.13.0.tgz",
-      "integrity": "sha512-46ue8ymtm/5PUU6pCvjlic0z82qWkxv54GTJZgHrQUuZnVH+tvvSP0LsozIDsCBFO4VjJ13N68wqrKSeScUKdA==",
+      "version": "4.22.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.22.4.tgz",
+      "integrity": "sha512-BjI+NVVEGAXjGWYHz/vv0pBqfGoUH0IGZ0cICTn7kB9PyjrATSkX+8WkguNjWoj2qSr1im/+tTGRaY+4/PdcQw==",
       "cpu": [
         "arm64"
       ],
@@ -381,9 +420,9 @@
       "peer": true
     },
     "node_modules/@rollup/rollup-win32-ia32-msvc": {
-      "version": "4.13.0",
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.13.0.tgz",
-      "integrity": "sha512-P5/MqLdLSlqxbeuJ3YDeX37srC8mCflSyTrUsgbU1c/U9j6l2g2GiIdYaGD9QjdMQPMSgYm7hgg0551wHyIluw==",
+      "version": "4.22.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.22.4.tgz",
+      "integrity": "sha512-SiWG/1TuUdPvYmzmYnmd3IEifzR61Tragkbx9D3+R8mzQqDBz8v+BvZNDlkiTtI9T15KYZhP0ehn3Dld4n9J5g==",
       "cpu": [
         "ia32"
       ],
@@ -394,9 +433,9 @@
       "peer": true
     },
     "node_modules/@rollup/rollup-win32-x64-msvc": {
-      "version": "4.13.0",
-      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.13.0.tgz",
-      "integrity": "sha512-UKXUQNbO3DOhzLRwHSpa0HnhhCgNODvfoPWv2FCXme8N/ANFfhIPMGuOT+QuKd16+B5yxZ0HdpNlqPvTMS1qfw==",
+      "version": "4.22.4",
+      "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.22.4.tgz",
+      "integrity": "sha512-j8pPKp53/lq9lMXN57S8cFz0MynJk8OWNuUnXct/9KCpKU7DgU3bYMJhwWmcqC0UU29p8Lr0/7KEVcaM6bf47Q==",
       "cpu": [
         "x64"
       ],
@@ -2041,9 +2080,9 @@
       }
     },
     "node_modules/rollup": {
-      "version": "4.13.0",
-      "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.13.0.tgz",
-      "integrity": "sha512-3YegKemjoQnYKmsBlOHfMLVPPA5xLkQ8MHLLSw/fBrFaVkEayL51DilPpNNLq1exr98F2B1TzrV0FUlN3gWRPg==",
+      "version": "4.22.4",
+      "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.22.4.tgz",
+      "integrity": "sha512-vD8HJ5raRcWOyymsR6Z3o6+RzfEPCnVLMFJ6vRslO1jt4LO6dUo5Qnpg7y4RkZFM2DMe3WUirkI5c16onjrc6A==",
       "peer": true,
       "dependencies": {
         "@types/estree": "1.0.5"
@@ -2056,19 +2095,22 @@
         "npm": ">=8.0.0"
       },
       "optionalDependencies": {
-        "@rollup/rollup-android-arm-eabi": "4.13.0",
-        "@rollup/rollup-android-arm64": "4.13.0",
-        "@rollup/rollup-darwin-arm64": "4.13.0",
-        "@rollup/rollup-darwin-x64": "4.13.0",
-        "@rollup/rollup-linux-arm-gnueabihf": "4.13.0",
-        "@rollup/rollup-linux-arm64-gnu": "4.13.0",
-        "@rollup/rollup-linux-arm64-musl": "4.13.0",
-        "@rollup/rollup-linux-riscv64-gnu": "4.13.0",
-        "@rollup/rollup-linux-x64-gnu": "4.13.0",
-        "@rollup/rollup-linux-x64-musl": "4.13.0",
-        "@rollup/rollup-win32-arm64-msvc": "4.13.0",
-        "@rollup/rollup-win32-ia32-msvc": "4.13.0",
-        "@rollup/rollup-win32-x64-msvc": "4.13.0",
+        "@rollup/rollup-android-arm-eabi": "4.22.4",
+        "@rollup/rollup-android-arm64": "4.22.4",
+        "@rollup/rollup-darwin-arm64": "4.22.4",
+        "@rollup/rollup-darwin-x64": "4.22.4",
+        "@rollup/rollup-linux-arm-gnueabihf": "4.22.4",
+        "@rollup/rollup-linux-arm-musleabihf": "4.22.4",
+        "@rollup/rollup-linux-arm64-gnu": "4.22.4",
+        "@rollup/rollup-linux-arm64-musl": "4.22.4",
+        "@rollup/rollup-linux-powerpc64le-gnu": "4.22.4",
+        "@rollup/rollup-linux-riscv64-gnu": "4.22.4",
+        "@rollup/rollup-linux-s390x-gnu": "4.22.4",
+        "@rollup/rollup-linux-x64-gnu": "4.22.4",
+        "@rollup/rollup-linux-x64-musl": "4.22.4",
+        "@rollup/rollup-win32-arm64-msvc": "4.22.4",
+        "@rollup/rollup-win32-ia32-msvc": "4.22.4",
+        "@rollup/rollup-win32-x64-msvc": "4.22.4",
         "fsevents": "~2.3.2"
       }
     },


Test patch:
diff --git a/bookmarks/tests/test_auto_tagging.py b/bookmarks/tests/test_auto_tagging.py
index 320003f..b4213a1 100644
--- a/bookmarks/tests/test_auto_tagging.py
+++ b/bookmarks/tests/test_auto_tagging.py
@@ -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
diff --git a/bookmarks/tests/test_bookmark_edit_view.py b/bookmarks/tests/test_bookmark_edit_view.py
index 18b04ec..46e305d 100644
--- a/bookmarks/tests/test_bookmark_edit_view.py
+++ b/bookmarks/tests/test_bookmark_edit_view.py
@@ -141,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()
diff --git a/bookmarks/tests/test_bookmarks_api.py b/bookmarks/tests/test_bookmarks_api.py
index 8794b8d..2166773 100644
--- a/bookmarks/tests/test_bookmarks_api.py
+++ b/bookmarks/tests/test_bookmarks_api.py
@@ -480,7 +480,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()
@@ -586,6 +600,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 +630,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 +685,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 +767,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 +1009,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 +1019,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)
diff --git a/bookmarks/tests/test_bookmarks_api_permissions.py b/bookmarks/tests/test_bookmarks_api_permissions.py
index 23830a1..2602c87 100644
--- a/bookmarks/tests/test_bookmarks_api_permissions.py
+++ b/bookmarks/tests/test_bookmarks_api_permissions.py
@@ -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])