Repository: sissbruecker/linkding (python)

Issue:
Add docker image to ghcr
Hi, any chance to push also the image to GitHub Container Registry (ghcr.io) to reduce the Hub Docker limitation of pull?

Pull request description:
Push Docker images to GHCR in addition to Docker Hub
### Description:
This PR updates the GitHub Actions workflow to push Docker images to **GitHub Container Registry (GHCR)** alongside **Docker Hub**.

### Changes Made:
- Added a Login to GHCR step using `${{ github.token }}`.
- Updated all Docker build steps to include GHCR tags (`ghcr.io/sissbruecker/linkding`).

### Why This Change?
- Provides an additional registry for hosting images.
- GHCR offers better integration with GitHub (e.g., private repos, organization access).
- Docker Hub usage limits:
  - Starting April 1, 2025, unauthenticated users will be limited to 10 pulls/hour, and free authenticated users to 100 pulls/hour.
  - Users with Pro, Team, or Business subscriptions will have unlimited pulls with fair use.
  - Providing an alternative registry ensures users can still access images without hitting rate limits.

### Testing & Verification:
- Workflow should successfully authenticate with both registries and push images.
- Verify that images appear in [GHCR](https://github.com/sissbruecker?tab=packages).

Fixes #939.

Patch:
diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml
index a8a1247..da1b3dc 100644
--- a/.github/workflows/build.yaml
+++ b/.github/workflows/build.yaml
@@ -19,6 +19,13 @@ jobs:
           username: ${{ vars.DOCKERHUB_USERNAME }}
           password: ${{ secrets.DOCKERHUB_TOKEN }}
 
+      - name: Login to GitHub Container Registry
+        uses: docker/login-action@v3
+        with:
+          registry: ghcr.io
+          username: ${{ github.actor }}
+          password: ${{ github.token }}
+
       - name: Set up QEMU
         uses: docker/setup-qemu-action@v3
 
@@ -34,6 +41,8 @@ jobs:
           tags: |
             sissbruecker/linkding:latest
             sissbruecker/linkding:${{ env.VERSION }}
+            ghcr.io/sissbruecker/linkding:latest
+            ghcr.io/sissbruecker/linkding:${{ env.VERSION }}
           target: linkding
           push: true
 
@@ -46,6 +55,8 @@ jobs:
           tags: |
             sissbruecker/linkding:latest-alpine
             sissbruecker/linkding:${{ env.VERSION }}-alpine
+            ghcr.io/sissbruecker/linkding:latest-alpine
+            ghcr.io/sissbruecker/linkding:${{ env.VERSION }}-alpine
           target: linkding
           push: true
 
@@ -58,6 +69,8 @@ jobs:
           tags: |
             sissbruecker/linkding:latest-plus
             sissbruecker/linkding:${{ env.VERSION }}-plus
+            ghcr.io/sissbruecker/linkding:latest-plus
+            ghcr.io/sissbruecker/linkding:${{ env.VERSION }}-plus
           target: linkding-plus
           push: true
 
@@ -70,5 +83,7 @@ jobs:
           tags: |
             sissbruecker/linkding:latest-plus-alpine
             sissbruecker/linkding:${{ env.VERSION }}-plus-alpine
+            ghcr.io/sissbruecker/linkding:latest-plus-alpine
+            ghcr.io/sissbruecker/linkding:${{ env.VERSION }}-plus-alpine
           target: linkding-plus
           push: true
\ No newline at end of file
diff --git a/bookmarks/api/routes.py b/bookmarks/api/routes.py
index 4fd6a30..eac5b5d 100644
--- a/bookmarks/api/routes.py
+++ b/bookmarks/api/routes.py
@@ -3,7 +3,7 @@ import logging
 import os
 
 from django.conf import settings
-from django.http import FileResponse, Http404
+from django.http import Http404, StreamingHttpResponse
 from rest_framework import viewsets, mixins, status
 from rest_framework.decorators import action
 from rest_framework.permissions import AllowAny
@@ -196,7 +196,7 @@ class BookmarkAssetViewSet(
                 if asset.asset_type == BookmarkAsset.TYPE_SNAPSHOT
                 else asset.display_name
             )
-            response = FileResponse(file_stream, content_type=content_type)
+            response = StreamingHttpResponse(file_stream, content_type=content_type)
             response["Content-Disposition"] = f'attachment; filename="{file_name}"'
             return response
         except FileNotFoundError:
diff --git a/bookmarks/services/auto_tagging.py b/bookmarks/services/auto_tagging.py
index 840745a..cdabab6 100644
--- a/bookmarks/services/auto_tagging.py
+++ b/bookmarks/services/auto_tagging.py
@@ -11,10 +11,18 @@ def get_tags(script: str, url: str):
         return result
 
     for line in script.lower().split("\n"):
-        if "#" in line:
-            i = line.index("#")
-            line = line[:i]
+        line = line.strip()
 
+        # Skip empty lines or lines that start with a comment
+        if not line or line.startswith("#"):
+            continue
+
+        # Remove trailing comment - only if # is preceded by whitespace
+        comment_match = re.search(r"\s+#", line)
+        if comment_match:
+            line = line[: comment_match.start()]
+
+        # Ignore lines that don't contain a URL and a tag
         parts = line.split()
         if len(parts) < 2:
             continue
@@ -36,6 +44,11 @@ def get_tags(script: str, url: str):
         ):
             continue
 
+        if parsed_pattern.fragment and not _fragment_matches(
+            parsed_pattern.fragment, parsed_url.fragment
+        ):
+            continue
+
         for tag in parts[1:]:
             result.add(tag)
 
@@ -65,3 +78,7 @@ def _qs_matches(expected_qs: str, actual_qs: str) -> bool:
                 return False
 
     return True
+
+
+def _fragment_matches(expected_fragment: str, actual_fragment: str) -> bool:
+    return actual_fragment.startswith(expected_fragment)
diff --git a/docs/package-lock.json b/docs/package-lock.json
index 60d0091..7554093 100644
--- a/docs/package-lock.json
+++ b/docs/package-lock.json
@@ -2405,45 +2405,75 @@
       }
     },
     "node_modules/bare-events": {
-      "version": "2.4.2",
-      "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.4.2.tgz",
-      "integrity": "sha512-qMKFd2qG/36aA4GwvKq8MxnPgCQAmBWmSyLWsJcbn8v03wvIPQ/hG1Ms8bPzndZxMDoHpxez5VOS+gC9Yi24/Q==",
+      "version": "2.5.4",
+      "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.5.4.tgz",
+      "integrity": "sha512-+gFfDkR8pj4/TrWCGUGWmJIkBwuxPS5F+a5yWjOHQt2hHvNZd5YLzadjmDUtFmMM4y429bnKLa8bYBMHcYdnQA==",
+      "license": "Apache-2.0",
       "optional": true
     },
     "node_modules/bare-fs": {
-      "version": "2.3.5",
-      "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-2.3.5.tgz",
-      "integrity": "sha512-SlE9eTxifPDJrT6YgemQ1WGFleevzwY+XAP1Xqgl56HtcrisC2CHCZ2tq6dBpcH2TnNxwUEUGhweo+lrQtYuiw==",
+      "version": "4.0.2",
+      "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.0.2.tgz",
+      "integrity": "sha512-S5mmkMesiduMqnz51Bfh0Et9EX0aTCJxhsI4bvzFFLs8Z1AV8RDHadfY5CyLwdoLHgXbNBEN1gQcbEtGwuvixw==",
+      "license": "Apache-2.0",
       "optional": true,
       "dependencies": {
-        "bare-events": "^2.0.0",
-        "bare-path": "^2.0.0",
-        "bare-stream": "^2.0.0"
+        "bare-events": "^2.5.4",
+        "bare-path": "^3.0.0",
+        "bare-stream": "^2.6.4"
+      },
+      "engines": {
+        "bare": ">=1.16.0"
+      },
+      "peerDependencies": {
+        "bare-buffer": "*"
+      },
+      "peerDependenciesMeta": {
+        "bare-buffer": {
+          "optional": true
+        }
       }
     },
     "node_modules/bare-os": {
-      "version": "2.4.4",
-      "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-2.4.4.tgz",
-      "integrity": "sha512-z3UiI2yi1mK0sXeRdc4O1Kk8aOa/e+FNWZcTiPB/dfTWyLypuE99LibgRaQki914Jq//yAWylcAt+mknKdixRQ==",
-      "optional": true
+      "version": "3.6.1",
+      "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.6.1.tgz",
+      "integrity": "sha512-uaIjxokhFidJP+bmmvKSgiMzj2sV5GPHaZVAIktcxcpCyBFFWO+YlikVAdhmUo2vYFvFhOXIAlldqV29L8126g==",
+      "license": "Apache-2.0",
+      "optional": true,
+      "engines": {
+        "bare": ">=1.14.0"
+      }
     },
     "node_modules/bare-path": {
-      "version": "2.1.3",
-      "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-2.1.3.tgz",
-      "integrity": "sha512-lh/eITfU8hrj9Ru5quUp0Io1kJWIk1bTjzo7JH1P5dWmQ2EL4hFUlfI8FonAhSlgIfhn63p84CDY/x+PisgcXA==",
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz",
+      "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==",
+      "license": "Apache-2.0",
       "optional": true,
       "dependencies": {
-        "bare-os": "^2.1.0"
+        "bare-os": "^3.0.1"
       }
     },
     "node_modules/bare-stream": {
-      "version": "2.3.0",
-      "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.3.0.tgz",
-      "integrity": "sha512-pVRWciewGUeCyKEuRxwv06M079r+fRjAQjBEK2P6OYGrO43O+Z0LrPZZEjlc4mB6C2RpZ9AxJ1s7NLEtOHO6eA==",
+      "version": "2.6.5",
+      "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.6.5.tgz",
+      "integrity": "sha512-jSmxKJNJmHySi6hC42zlZnq00rga4jjxcgNZjY9N5WlOe/iOoGRtdwGsHzQv2RlH2KOYMwGUXhf2zXd32BA9RA==",
+      "license": "Apache-2.0",
       "optional": true,
       "dependencies": {
-        "b4a": "^1.6.6",
-        "streamx": "^2.20.0"
+        "streamx": "^2.21.0"
+      },
+      "peerDependencies": {
+        "bare-buffer": "*",
+        "bare-events": "*"
+      },
+      "peerDependenciesMeta": {
+        "bare-buffer": {
+          "optional": true
+        },
+        "bare-events": {
+          "optional": true
+        }
       }
     },
     "node_modules/base-64": {
@@ -5860,9 +5890,10 @@
       }
     },
     "node_modules/prebuild-install/node_modules/tar-fs": {
-      "version": "2.1.1",
-      "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.1.tgz",
-      "integrity": "sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng==",
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.2.tgz",
+      "integrity": "sha512-EsaAXwxmx8UB7FRKqeozqEPop69DXcmYwTQwXvyAPF352HJsPdkVhvTaDPYqfNgruveJIJy3TA2l+2zj8LJIJA==",
+      "license": "MIT",
       "dependencies": {
         "chownr": "^1.1.1",
         "mkdirp-classic": "^0.5.2",
@@ -5979,11 +6010,6 @@
         }
       ]
     },
-    "node_modules/queue-tick": {
-      "version": "1.0.1",
-      "resolved": "https://registry.npmjs.org/queue-tick/-/queue-tick-1.0.1.tgz",
-      "integrity": "sha512-kJt5qhMxoszgU/62PLP1CJytzd2NKetjSRnyuj31fDd3Rlcz3fzlFdFLD1SItunPwyqEOkca6GbV612BWfaBag=="
-    },
     "node_modules/rc": {
       "version": "1.2.8",
       "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
@@ -6638,12 +6664,12 @@
       "integrity": "sha512-TlnjJ1C0QrmxRNrON00JvaFFlNh5TTG00APw23j74ET7gkQpTASi6/L2fuiav8pzK715HXtUeClpBTw2NPSn6w=="
     },
     "node_modules/streamx": {
-      "version": "2.20.1",
-      "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.20.1.tgz",
-      "integrity": "sha512-uTa0mU6WUC65iUvzKH4X9hEdvSW7rbPxPtwfWiLMSj3qTdQbAiUboZTxauKfpFuGIGa1C2BYijZ7wgdUXICJhA==",
+      "version": "2.22.0",
+      "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.22.0.tgz",
+      "integrity": "sha512-sLh1evHOzBy/iWRiR6d1zRcLao4gGZr3C1kzNz4fopCOKJb6xD9ub8Mpi9Mr1R6id5o43S+d93fI48UC5uM9aw==",
+      "license": "MIT",
       "dependencies": {
         "fast-fifo": "^1.3.2",
-        "queue-tick": "^1.0.1",
         "text-decoder": "^1.1.0"
       },
       "optionalDependencies": {
@@ -6734,16 +6760,17 @@
       }
     },
     "node_modules/tar-fs": {
-      "version": "3.0.6",
-      "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.6.tgz",
-      "integrity": "sha512-iokBDQQkUyeXhgPYaZxmczGPhnhXZ0CmrqI+MOb/WFGS9DW5wnfrLgtjUJBvz50vQ3qfRwJ62QVoCFu8mPVu5w==",
+      "version": "3.0.8",
+      "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.8.tgz",
+      "integrity": "sha512-ZoROL70jptorGAlgAYiLoBLItEKw/fUxg9BSYK/dF/GAGYFJOJJJMvjPAKDJraCXFwadD456FCuvLWgfhMsPwg==",
+      "license": "MIT",
       "dependencies": {
         "pump": "^3.0.0",
         "tar-stream": "^3.1.5"
       },
       "optionalDependencies": {
-        "bare-fs": "^2.1.1",
-        "bare-path": "^2.1.0"
+        "bare-fs": "^4.0.1",
+        "bare-path": "^3.0.0"
       }
     },
     "node_modules/tar-stream": {
diff --git a/docs/src/content/docs/auto-tagging.md b/docs/src/content/docs/auto-tagging.md
index ae8edc1..cec9cdc 100644
--- a/docs/src/content/docs/auto-tagging.md
+++ b/docs/src/content/docs/auto-tagging.md
@@ -14,8 +14,8 @@ youtube.com video
 reddit.com/r/Music music reddit
 ```
 
-When a bookmark is created or updated, the URL of the bookmark is parsed to extract the hostname, path, and query
-string. These components are then compared against the patterns defined in the auto tagging rules. If all components
+When a bookmark is created or updated, the URL of the bookmark is parsed to extract the hostname, path, query
+string and fragment. These components are then compared against the patterns defined in the auto tagging rules. If all components
 match, the tags associated with the rule are added to the bookmark. Both the bookmark form in the web interface and the
 browser extension will show a preview of the tags that will be added based on the auto tagging rules.
 
@@ -33,6 +33,9 @@ The URL matching works like this:
   rule does not specify any query string parameters, it matches all query strings. If the rule specifies a query string
   it will only match if the bookmark URL contains all the specified query string parameters with their respective
   values.
+- **Fragment Matching**: The URL fragment (part after the # symbol) is also compared when present in a rule. If the rule
+  specifies a fragment, it will match any URL whose fragment starts with the specified fragment. For example, a rule with
+  `example.com/#/projects` will match URLs like `example.com/#/projects/123` or `example.com/#/projects/456`.
 
 Note that URL matching currently does not support any kind of wildcards. Rule matching only works based on the URL, not
 on the content of the website or any other aspect of the bookmark.
diff --git a/docs/src/content/docs/community.md b/docs/src/content/docs/community.md
index 8d3a86d..7e68aa6 100644
--- a/docs/src/content/docs/community.md
+++ b/docs/src/content/docs/community.md
@@ -25,6 +25,7 @@ This section lists community projects around using linkding, in alphabetical ord
 - [Linkdy](https://github.com/JGeek00/linkdy): An open-source Android and iOS client created with Flutter. Available on the [Google Play Store](https://play.google.com/store/apps/details?id=com.jgeek00.linkdy) and [App Store](https://apps.apple.com/us/app/linkdy/id6479930976). By [JGeek00](https://github.com/JGeek00).
 - [Linklater](https://github.com/danielyrovas/linklater) An open-source Android client written in Kotlin.
 - [LinkThing](https://apps.apple.com/us/app/linkthing/id1666031776) An iOS client for linkding. By [amoscardino](https://github.com/amoscardino)
+- [linktiles](https://github.com/haondt/linktiles) A web app that displays your links as tiles in a configurable mosaic. By [haondt](https://github.com/haondt)
 - [Open all links bookmarklet](https://gist.github.com/ukcuddlyguy/336dd7339e6d35fc64a75ccfc9323c66) A browser bookmarklet to open all links on the current Linkding page in new tabs. By [ukcuddlyguy](https://github.com/ukcuddlyguy)
 - [Pinkt](https://github.com/fibelatti/pinboard-kotlin) An Android client for linkding. By [fibelatti](https://github.com/fibelatti)
 - [Postman collection](https://gist.github.com/gingerbeardman/f0b42502f3bc9344e92ce63afd4360d3) a group of saved request templates for API testing. By [gingerbeardman](https://github.com/gingerbeardman)
diff --git a/requirements.dev.txt b/requirements.dev.txt
index db60cd6..1097e94 100644
--- a/requirements.dev.txt
+++ b/requirements.dev.txt
@@ -12,7 +12,7 @@ click==8.1.7
     # via black
 coverage==7.6.1
     # via -r requirements.dev.in
-django==5.1.7
+django==5.1.8
     # via django-debug-toolbar
 django-debug-toolbar==4.4.6
     # via -r requirements.dev.in
diff --git a/requirements.txt b/requirements.txt
index c0b339e..1b40cc6 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -27,7 +27,7 @@ cryptography==43.0.1
     #   josepy
     #   mozilla-django-oidc
     #   pyopenssl
-django==5.1.7
+django==5.1.8
     # via
     #   -r requirements.in
     #   django-registration


Test patch:
diff --git a/bookmarks/tests/test_auto_tagging.py b/bookmarks/tests/test_auto_tagging.py
index b4213a1..1273264 100644
--- a/bookmarks/tests/test_auto_tagging.py
+++ b/bookmarks/tests/test_auto_tagging.py
@@ -202,3 +202,44 @@ class AutoTaggingTestCase(TestCase):
         tags = auto_tagging.get_tags(script, url)
 
         self.assertEqual(tags, {"tag1", "tag2"})
+
+    def test_auto_tag_with_url_fragment(self):
+        script = """
+            example.com/#/section/1 section1
+            example.com/#/section/2 section2
+        """
+        url = "https://example.com/#/section/1"
+
+        tags = auto_tagging.get_tags(script, url)
+
+        self.assertEqual(tags, {"section1"})
+
+    def test_auto_tag_with_url_fragment_partial_match(self):
+        script = """
+            example.com/#/section section
+        """
+        url = "https://example.com/#/section/1"
+
+        tags = auto_tagging.get_tags(script, url)
+
+        self.assertEqual(tags, {"section"})
+
+    def test_auto_tag_with_url_fragment_ignores_case(self):
+        script = """
+            example.com/#SECTION section
+        """
+        url = "https://example.com/#section"
+
+        tags = auto_tagging.get_tags(script, url)
+
+        self.assertEqual(tags, {"section"})
+
+    def test_auto_tag_with_url_fragment_and_comment(self):
+        script = """
+            example.com/#section1 section1 #This is a comment
+        """
+        url = "https://example.com/#section1"
+
+        tags = auto_tagging.get_tags(script, url)
+
+        self.assertEqual(tags, {"section1"})