Compare commits

..

14 Commits

Author SHA1 Message Date
Sascha Ißbrücker
d6484ba8e9 Add release script 2024-03-18 22:54:22 +01:00
dependabot[bot]
4c26d66177 Bump django from 5.0.2 to 5.0.3 (#658)
Bumps [django](https://github.com/django/django) from 5.0.2 to 5.0.3.
- [Commits](https://github.com/django/django/compare/5.0.2...5.0.3)

---
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>
2024-03-18 22:51:29 +01:00
Sascha Ißbrücker
c51dcafa40 Fix docker build 2024-03-18 22:50:07 +01:00
Sascha Ißbrücker
262dd2b28f Update OIDC configuration defaults 2024-03-18 22:41:25 +01:00
Sascha Ißbrücker
01ad7f4d9e Bump version 2024-03-17 12:00:30 +01:00
Sascha Ißbrücker
d0d5c15345 Add RSS feeds for shared bookmarks (#656)
* Add shared bookmarks feed

* Add public shared bookmarks feed
2024-03-17 11:55:34 +01:00
Sascha Ißbrücker
afb752765d Include web archive link in /api/bookmarks/ (#655) 2024-03-17 10:04:05 +01:00
Sascha Ißbrücker
ce213775b6 Try fix workflow config 2024-03-17 09:59:04 +01:00
Bruno Henriques
fd1bbadcf3 Update backup location to safe directory (#653)
The previous directory may not share the same directory as the user that runs the container. `/etc/linkding/data` is a safe directory.

Fixes #626
2024-03-17 09:06:07 +01:00
Sascha Ißbrücker
83c2530df4 Add option for custom CSS (#652)
* Add option for adding custom CSS

* add missing migration
2024-03-17 01:11:59 +01:00
ηg
39782e75e7 Add support for OIDC (#389)
* added support for oidc auth

* fixed oidc usernames

* hiding password for users that aren't logged in via local auth

* add dependency, update settings

* keep change password link

* add tests

* add docs

---------

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@gmail.com>
2024-03-16 23:42:46 +01:00
Hugo van Rijswijk
4bee104b62 Build improvements (#649)
* Improve PWA capabilities

* Invert background_color theme logic

* Revert build changes

* Revert "Revert build changes"

This reverts commit 1ab640fda1.

* update

* revert svelte component changes
2024-03-16 15:57:23 +01:00
Sascha Ißbrücker
f4ecffbb7f Fix flaky bulk edit E2E test (#650) 2024-03-16 15:35:22 +01:00
Hugo van Rijswijk
6f52bafda8 Improve PWA capabilities (#630)
* Improve PWA capabilities

* Invert background_color theme logic

* Revert build changes
2024-03-16 15:20:22 +01:00
58 changed files with 1416 additions and 1021 deletions

View File

@@ -13,7 +13,7 @@
!/package-lock.json
!/requirements.dev.txt
!/requirements.txt
!/rollup.config.js
!/rollup.config.mjs
!/supervisord.conf
!/uwsgi.ini
!/version.txt

View File

@@ -1,23 +1,28 @@
name: linkding CI
on: [push, pull_request]
on:
pull_request:
push:
branches:
- master
jobs:
unit_tests:
name: Unit Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v4
uses: actions/setup-python@v5
with:
python-version: "3.10"
- name: Set up Node
uses: actions/setup-node@v3
uses: actions/setup-node@v4
with:
node-version: 18
node-version: 20
cache: 'npm'
- name: Install Node dependencies
run: npm install
run: npm ci
- name: Setup Python environment
run: pip install -r requirements.txt -r requirements.dev.txt
- name: Run tests
@@ -26,17 +31,18 @@ jobs:
name: E2E Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v4
uses: actions/setup-python@v5
with:
python-version: "3.10"
- name: Set up Node
uses: actions/setup-node@v3
uses: actions/setup-node@v4
with:
node-version: 18
node-version: 20
cache: 'npm'
- name: Install Node dependencies
run: npm install
run: npm ci
- name: Setup Python environment
run: |
pip install -r requirements.txt -r requirements.dev.txt

View File

@@ -40,11 +40,12 @@ The name comes from:
- Automatically provides titles, descriptions and icons of bookmarked websites
- Automatically creates snapshots of bookmarked websites on [the Internet Archive Wayback Machine](https://archive.org/web/)
- Import and export bookmarks in Netscape HTML format
- Installable as a Progressive Web App (PWA)
- Extensions for [Firefox](https://addons.mozilla.org/firefox/addon/linkding-extension/) and [Chrome](https://chrome.google.com/webstore/detail/linkding-extension/beakmhbijpdhipnjhnclmhgjlddhidpe), as well as a bookmarklet
- Light and dark themes
- SSO support via OIDC or authentication proxies
- REST API for developing 3rd party apps
- Admin panel for user self-service and raw data access
- Easy setup using Docker and a SQLite database, with PostgreSQL as an option
**Demo:** https://demo.linkding.link/ ([see here](https://github.com/sissbruecker/linkding/issues/408) if you have trouble accessing it)

View File

@@ -30,6 +30,7 @@ class BookmarkSerializer(serializers.ModelSerializer):
"notes",
"website_title",
"website_description",
"web_archive_snapshot_url",
"is_archived",
"unread",
"shared",
@@ -40,6 +41,7 @@ class BookmarkSerializer(serializers.ModelSerializer):
read_only_fields = [
"website_title",
"website_description",
"web_archive_snapshot_url",
"date_added",
"date_modified",
]

View File

@@ -313,6 +313,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
self.locate_bulk_edit_bar().get_by_text("Execute").click()
self.locate_bulk_edit_bar().get_by_text("Confirm").click()
expect(self.locate_bulk_edit_select_all()).not_to_be_checked()
self.locate_bulk_edit_select_all().click()
expect(

View File

@@ -6,12 +6,12 @@ from django.db.models import QuerySet
from django.urls import reverse
from bookmarks import queries
from bookmarks.models import Bookmark, BookmarkSearch, FeedToken
from bookmarks.models import Bookmark, BookmarkSearch, FeedToken, UserProfile
@dataclass
class FeedContext:
feed_token: FeedToken
feed_token: FeedToken | None
query_set: QuerySet[Bookmark]
@@ -67,3 +67,39 @@ class UnreadBookmarksFeed(BaseBookmarksFeed):
def items(self, context: FeedContext):
return context.query_set.filter(unread=True)
class SharedBookmarksFeed(BaseBookmarksFeed):
title = "Shared bookmarks"
description = "All shared bookmarks"
def get_object(self, request, feed_key: str):
feed_token = FeedToken.objects.get(key__exact=feed_key)
search = BookmarkSearch(q=request.GET.get("q", ""))
query_set = queries.query_shared_bookmarks(
None, feed_token.user.profile, search, False
)
return FeedContext(feed_token, query_set)
def link(self, context: FeedContext):
return reverse("bookmarks:feeds.shared", args=[context.feed_token.key])
def items(self, context: FeedContext):
return context.query_set
class PublicSharedBookmarksFeed(BaseBookmarksFeed):
title = "Public shared bookmarks"
description = "All public shared bookmarks"
def get_object(self, request):
search = BookmarkSearch(q=request.GET.get("q", ""))
default_profile = UserProfile()
query_set = queries.query_shared_bookmarks(None, default_profile, search, True)
return FeedContext(None, query_set)
def link(self, context: FeedContext):
return reverse("bookmarks:feeds.public_shared")
def items(self, context: FeedContext):
return context.query_set

View File

@@ -258,4 +258,4 @@
z-index: 2;
}
</style>
</style>

View File

@@ -1,16 +1,10 @@
import TagAutoComplete from "./components/TagAutocomplete.svelte";
import SearchAutoComplete from "./components/SearchAutoComplete.svelte";
import { ApiClient } from "./api";
import "./behaviors/bookmark-page";
import "./behaviors/bulk-edit";
import "./behaviors/confirm-button";
import "./behaviors/dropdown";
import "./behaviors/modal";
import "./behaviors/global-shortcuts";
import "./behaviors/tag-autocomplete";
export default {
ApiClient,
TagAutoComplete,
SearchAutoComplete,
};
import './behaviors/bookmark-page';
import './behaviors/bulk-edit';
import './behaviors/confirm-button';
import './behaviors/dropdown';
import './behaviors/modal';
import './behaviors/global-shortcuts';
import './behaviors/tag-autocomplete';
export { default as TagAutoComplete } from './components/TagAutocomplete.svelte';
export { default as SearchAutoComplete } from './components/SearchAutoComplete.svelte';
export { ApiClient } from './api';

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.0.2 on 2024-03-16 23:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookmarks", "0025_userprofile_search_preferences"),
]
operations = [
migrations.AddField(
model_name="userprofile",
name="custom_css",
field=models.TextField(blank=True),
),
]

View File

@@ -331,6 +331,7 @@ class UserProfile(models.Model):
enable_favicons = models.BooleanField(default=False, null=False)
display_url = models.BooleanField(default=False, null=False)
permanent_notes = models.BooleanField(default=False, null=False)
custom_css = models.TextField(blank=True, null=False)
search_preferences = models.JSONField(default=dict, null=False)
@@ -348,6 +349,7 @@ class UserProfileForm(forms.ModelForm):
"enable_favicons",
"display_url",
"permanent_notes",
"custom_css",
]

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -0,0 +1 @@
<svg clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.5" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg"><circle cx="255.0164" cy="254.9236" fill="#5856e0" r="224.78528" stroke-width="1.18"/><g fill="none" stroke="#fff" stroke-width="31.25"><path d="m1244.39 1293.95v199.64s-.81 67.89 74.9 68.88c75.98.99 74.88-68.88 74.88-68.88v-199.64" transform="matrix(.70710678 .70710678 -.70710678 .70710678 284.139117 -1684.198509)"/><path d="m1244.39 1293.95v199.64s-.81 67.89 74.9 68.88c75.98.99 74.88-68.88 74.88-68.88v-199.64" transform="matrix(-.70957074 -.70463421 .70463421 -.70957074 235.113139 2195.434643)"/></g></svg>

After

Width:  |  Height:  |  Size: 663 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -0,0 +1 @@
<svg clip-rule="evenodd" fill-rule="evenodd" height="512" stroke-linejoin="round" stroke-miterlimit="1.5" viewBox="0 0 512 512" width="512" xmlns="http://www.w3.org/2000/svg"><circle cx="255.0164" cy="254.9236" fill="#5856e0" r="224.78528" stroke-width="1.18"/><g fill="none" stroke="#fff" stroke-width="31.25"><path d="m1244.39 1293.95v199.64s-.81 67.89 74.9 68.88c75.98.99 74.88-68.88 74.88-68.88v-199.64" transform="matrix(.70710678 .70710678 -.70710678 .70710678 284.139117 -1684.198509)"/><path d="m1244.39 1293.95v199.64s-.81 67.89 74.9 68.88c75.98.99 74.88-68.88 74.88-68.88v-199.64" transform="matrix(-.70957074 -.70463421 .70463421 -.70957074 235.113139 2195.434643)"/></g></svg>

After

Width:  |  Height:  |  Size: 688 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

View File

@@ -0,0 +1 @@
<svg clip-rule="evenodd" fill-rule="evenodd" height="512" stroke-linejoin="round" stroke-miterlimit="1.5" width="512" xmlns="http://www.w3.org/2000/svg"><path d="m512 512h-512v-512h512" fill="#5856e0" fill-rule="nonzero" stroke-width=".293"/><g fill="none" stroke="#fff" stroke-width="31.25"><path d="m249.095 110.679-141.167 141.167s-48.578 47.432 4.257 101.668c53.026 54.426 101.654 4.242 101.654 4.242l141.166-141.166"/><path d="m263.892 400.446 140.673-141.659s48.412-47.602-4.612-101.652c-53.215-54.24-101.667-3.888-101.667-3.888l-140.674 141.659"/></g></svg>

After

Width:  |  Height:  |  Size: 564 B

View File

@@ -0,0 +1 @@
<svg height="700pt" preserveAspectRatio="xMidYMid meet" viewBox="0 0 700 700" width="700pt" xmlns="http://www.w3.org/2000/svg"><path d="m3210 6573c-780-79-1463-417-1985-983-444-481-716-1082-791-1750-18-160-18-490 0-650 80-713 380-1341 880-1842 492-494 1125-801 1822-883 150-18 512-21 654-5 407 44 737 142 1094 323 775 394 1350 1108 1571 1952 71 271 98 487 98 785-1 311-35 562-117 847-54 188-99 302-201 508-216 439-510 795-900 1090-441 335-992 550-1544 605-95 9-499 12-581 3zm-639-2228c-543-544-1003-1011-1020-1038-91-134-135-274-134-422 1-167 61-314 200-485 135-165 308-291 467-338 110-32 264-27 376 12 185 65 130 15 1233 1115l1007 1006 150-150c83-82 150-154 150-160s-442-452-982-992c-658-656-1010-1000-1064-1040-304-223-643-298-965-214-271 72-548 272-751 543-115 153-179 283-226 457-22 85-26 115-25 256 0 140 3 172 26 257 34 130 95 266 169 380 54 83 172 205 1067 1101l1006 1007 152-152 153-153zm2359 1051c242-44 461-167 675-381 282-281 415-567 415-890 0-119-17-230-51-336-32-101-123-277-188-363-54-71-2003-2046-2020-2046-11 0-301 281-301 292 0 6 435 449 967 985 533 536 986 998 1007 1027 53 70 121 216 140 303 30 136 14 277-48 416-65 147-245 351-396 449-211 137-403 161-617 79-162-63-173-73-1009-913-428-431-871-876-984-991-112-114-207-207-211-207s-75 67-160 148l-153 149 785 789c431 434 865 872 964 973 266 271 397 369 612 455 176 70 396 94 573 62z" transform="matrix(.1 0 0 -.1 0 700)"/></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -7,6 +7,10 @@
}
}
textarea.custom-css {
font-family: monospace;
}
.input-group > input[type=submit] {
height: auto;
}

View File

@@ -6,8 +6,10 @@
<html lang="en" data-api-base-url="{% url 'bookmarks:api-root' %}">
<head>
<meta charset="UTF-8">
<link rel="icon" href="{% static 'favicon.png' %}"/>
<link rel="apple-touch-icon" href="{% static 'apple-touch-icon.png' %}">
<link rel="icon" href="{% static 'favicon.ico' %}" sizes="48x48">
<link rel="icon" href="{% static 'favicon.svg' %}" sizes="any" type="image/svg+xml">
<link rel="apple-touch-icon" sizes="180x180" href="{% static 'apple-touch-icon.png' %}">
<link rel="mask-icon" href="{% static 'safari-pinned-tab.svg' %}" color="#5856e0">
<link rel="manifest" href="{% url 'bookmarks:manifest' %}">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimal-ui">
@@ -19,14 +21,21 @@
{# Include specific theme variant based on user profile setting #}
{% if request.user_profile.theme == 'light' %}
<link href="{% sass_src 'theme-light.scss' %}?v={{ app_version }}" rel="stylesheet" type="text/css"/>
<meta name="theme-color" content="#5856e0">
{% elif request.user_profile.theme == 'dark' %}
<link href="{% sass_src 'theme-dark.scss' %}?v={{ app_version }}" rel="stylesheet" type="text/css"/>
<meta name="theme-color" content="#161822">
{% else %}
{# Use auto theme as fallback #}
<link href="{% sass_src 'theme-dark.scss' %}?v={{ app_version }}" rel="stylesheet" type="text/css"
media="(prefers-color-scheme: dark)"/>
<link href="{% sass_src 'theme-light.scss' %}?v={{ app_version }}" rel="stylesheet" type="text/css"
media="(prefers-color-scheme: light)"/>
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#161822">
<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>
{% endif %}
</head>
<body ld-global-shortcuts>

View File

@@ -17,22 +17,24 @@
{% endif %}
<div class="form-group">
<label class="form-label" for="{{ form.username.id_for_label }}">Username</label>
{{ form.username|add_class:'form-input'|attr:"placeholder: " }}
{{ form.username|add_class:'form-input'|attr:'placeholder: ' }}
</div>
<div class="form-group">
<label class="form-label" for="{{ form.password.id_for_label }}">Password</label>
{{ form.password|add_class:'form-input'|attr:"placeholder: " }}
{{ form.password|add_class:'form-input'|attr:'placeholder: ' }}
</div>
<br/>
<div class="d-flex justify-between">
<input type="submit" value="Login" class="btn btn-primary btn-wide">
<input type="hidden" name="next" value="{{ next }}">
<input type="submit" value="Login" class="btn btn-primary btn-wide"/>
<input type="hidden" name="next" value="{{ next }}"/>
{% if enable_oidc %}
<a class="btn btn-link" href="{% url 'oidc_authentication_init' %}">Login with OIDC</a>
{% endif %}
{% if allow_registration %}
<a href="{% url 'django_registration_register' %}" class="btn btn-link">Register</a>
{% endif %}
</div>
</form>
</section>
{% endblock %}

View File

@@ -124,6 +124,18 @@
href="{% url 'bookmarks:shared' %}">shared bookmarks page</a>.
</div>
</div>
<div class="form-group">
<details {% if form.custom_css.value %}open{% endif %}>
<summary>Custom CSS</summary>
<label for="{{ form.custom_css.id_for_label }}" class="text-assistive">Custom CSS</label>
<div class="mt-2">
{{ form.custom_css|add_class:"form-input custom-css"|attr:"rows:6" }}
</div>
</details>
<div class="form-input-hint">
Allows to add custom CSS to the page.
</div>
</div>
<div class="form-group">
<input type="submit" name="update_profile" value="Save" class="btn btn-primary mt-2">
{% if update_profile_success_message %}
@@ -150,7 +162,9 @@
<i class="form-icon"></i> Import public bookmarks as shared
</label>
<div class="form-input-hint">
When importing bookmarks from a service that supports marking bookmarks as public or private (using the <code>PRIVATE</code> attribute), enabling this option will import all bookmarks that are marked as not private as shared bookmarks.
When importing bookmarks from a service that supports marking bookmarks as public or private (using the
<code>PRIVATE</code> attribute), enabling this option will import all bookmarks that are marked as not
private as shared bookmarks.
Otherwise, all bookmarks will be imported as private bookmarks.
</div>
</div>

View File

@@ -49,9 +49,11 @@
<section class="content-area">
<h2>RSS Feeds</h2>
<p>The following URLs provide RSS feeds for your bookmarks:</p>
<ul>
<ul style="list-style-position: outside;">
<li><a href="{{ all_feed_url }}">All bookmarks</a></li>
<li><a href="{{ unread_feed_url }}">Unread bookmarks</a></li>
<li><a href="{{ shared_feed_url }}">Shared bookmarks</a></li>
<li><a href="{{ public_shared_feed_url }}">Public shared bookmarks</a><br><span class="text-small text-gray">The public shared feed does not contain an authentication token and can be shared with other people. Only shows shared bookmarks from users who have explicitly enabled public sharing.</span></li>
</ul>
<p>
All URLs support appending a <code>q</code> URL parameter for specifying a search query.

View File

@@ -84,6 +84,7 @@ class BookmarkFactoryMixin:
unread: bool = False,
shared: bool = False,
with_tags: bool = False,
with_web_archive_snapshot_url: bool = False,
user: User = None,
):
user = user or self.get_or_create_test_user()
@@ -112,6 +113,9 @@ class BookmarkFactoryMixin:
if with_tags:
tag_name = f"{tag_prefix} {i}{suffix}"
tags = [self.setup_tag(name=tag_name, user=user)]
web_archive_snapshot_url = ""
if with_web_archive_snapshot_url:
web_archive_snapshot_url = f"https://web.archive.org/web/{i}"
bookmark = self.setup_bookmark(
url=url,
title=title,
@@ -119,6 +123,7 @@ class BookmarkFactoryMixin:
unread=unread,
shared=shared,
tags=tags,
web_archive_snapshot_url=web_archive_snapshot_url,
user=user,
)
bookmarks.append(bookmark)

View File

@@ -35,6 +35,7 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
expectation["notes"] = bookmark.notes
expectation["website_title"] = bookmark.website_title
expectation["website_description"] = bookmark.website_description
expectation["web_archive_snapshot_url"] = bookmark.web_archive_snapshot_url
expectation["is_archived"] = bookmark.is_archived
expectation["unread"] = bookmark.unread
expectation["shared"] = bookmark.shared
@@ -61,6 +62,17 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
)
self.assertBookmarkListEqual(response.data["results"], bookmarks)
def test_list_bookmarks_with_more_details(self):
self.authenticate()
bookmarks = self.setup_numbered_bookmarks(
5, with_tags=True, with_web_archive_snapshot_url=True
)
response = self.get(
reverse("bookmarks:bookmark-list"), expected_status_code=status.HTTP_200_OK
)
self.assertBookmarkListEqual(response.data["results"], bookmarks)
def test_list_bookmarks_does_not_return_archived_bookmarks(self):
self.authenticate()
bookmarks = self.setup_numbered_bookmarks(5)
@@ -436,6 +448,18 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
response = self.get(url, expected_status_code=status.HTTP_200_OK)
self.assertBookmarkListEqual([response.data], [bookmark])
def test_get_bookmark_with_more_details(self):
self.authenticate()
tag1 = self.setup_tag()
bookmark = self.setup_bookmark(
web_archive_snapshot_url="https://web.archive.org/web/1/",
tags=[tag1],
)
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id])
response = self.get(url, expected_status_code=status.HTTP_200_OK)
self.assertBookmarkListEqual([response.data], [bookmark])
def test_update_bookmark(self):
self.authenticate()
bookmark = self.setup_bookmark()

View File

@@ -0,0 +1,21 @@
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

@@ -194,7 +194,7 @@ class FeedsTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark(unread=True)
self.setup_bookmark(unread=True)
feed_url = reverse("bookmarks:feeds.all", args=[self.token.key])
feed_url = reverse("bookmarks:feeds.unread", args=[self.token.key])
url = feed_url + f"?q={bookmark1.title}"
response = self.client.get(url)
@@ -229,3 +229,172 @@ class FeedsTestCase(TestCase, BookmarkFactoryMixin):
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<item>", count=0)
def test_shared_returns_404_for_unknown_feed_token(self):
response = self.client.get(reverse("bookmarks:feeds.shared", args=["foo"]))
self.assertEqual(response.status_code, 404)
def test_shared_metadata(self):
feed_url = reverse("bookmarks:feeds.shared", args=[self.token.key])
response = self.client.get(feed_url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<title>Shared bookmarks</title>")
self.assertContains(response, "<description>All shared bookmarks</description>")
self.assertContains(response, f"<link>http://testserver{feed_url}</link>")
self.assertContains(
response, f'<atom:link href="http://testserver{feed_url}" rel="self"/>'
)
def test_shared_returns_shared_bookmarks_only(self):
user1 = self.setup_user(enable_sharing=True)
user2 = self.setup_user(enable_sharing=False)
self.setup_bookmark()
self.setup_bookmark(shared=False, user=user1)
self.setup_bookmark(shared=True, user=user2)
shared_bookmarks = [
self.setup_bookmark(shared=True, user=user1, description="test"),
self.setup_bookmark(shared=True, user=user1, description="test"),
self.setup_bookmark(shared=True, user=user1, description="test"),
]
response = self.client.get(
reverse("bookmarks:feeds.shared", args=[self.token.key])
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<item>", count=len(shared_bookmarks))
for bookmark in shared_bookmarks:
expected_item = (
"<item>"
f"<title>{bookmark.resolved_title}</title>"
f"<link>{bookmark.url}</link>"
f"<description>{bookmark.resolved_description}</description>"
f"<pubDate>{rfc2822_date(bookmark.date_added)}</pubDate>"
f"<guid>{bookmark.url}</guid>"
"</item>"
)
self.assertContains(response, expected_item, count=1)
def test_shared_with_query(self):
user = self.setup_user(enable_sharing=True)
tag1 = self.setup_tag(user=user)
bookmark1 = self.setup_bookmark(shared=True, user=user)
bookmark2 = self.setup_bookmark(shared=True, tags=[tag1], user=user)
bookmark3 = self.setup_bookmark(shared=True, tags=[tag1], user=user)
self.setup_bookmark(shared=True, user=user)
self.setup_bookmark(shared=True, user=user)
self.setup_bookmark(shared=True, user=user)
feed_url = reverse("bookmarks:feeds.shared", args=[self.token.key])
url = feed_url + f"?q={bookmark1.title}"
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<item>", count=1)
self.assertContains(response, f"<guid>{bookmark1.url}</guid>", count=1)
url = feed_url + "?q=" + urllib.parse.quote("#" + tag1.name)
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<item>", count=2)
self.assertContains(response, f"<guid>{bookmark2.url}</guid>", count=1)
self.assertContains(response, f"<guid>{bookmark3.url}</guid>", count=1)
url = feed_url + "?q=" + urllib.parse.quote(f"#{tag1.name} {bookmark2.title}")
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<item>", count=1)
self.assertContains(response, f"<guid>{bookmark2.url}</guid>", count=1)
def test_public_shared_does_not_require_auth(self):
response = self.client.get(reverse("bookmarks:feeds.public_shared"))
self.assertEqual(response.status_code, 200)
def test_public_shared_metadata(self):
feed_url = reverse("bookmarks:feeds.public_shared")
response = self.client.get(feed_url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<title>Public shared bookmarks</title>")
self.assertContains(
response, "<description>All public shared bookmarks</description>"
)
self.assertContains(response, f"<link>http://testserver{feed_url}</link>")
self.assertContains(
response, f'<atom:link href="http://testserver{feed_url}" rel="self"/>'
)
def test_public_shared_returns_publicly_shared_bookmarks_only(self):
user1 = self.setup_user(enable_sharing=True, enable_public_sharing=True)
user2 = self.setup_user(enable_sharing=True)
user3 = self.setup_user(enable_sharing=False)
self.setup_bookmark()
self.setup_bookmark(shared=False, user=user1)
self.setup_bookmark(shared=False, user=user2)
self.setup_bookmark(shared=True, user=user2)
self.setup_bookmark(shared=True, user=user3)
public_shared_bookmarks = [
self.setup_bookmark(shared=True, user=user1, description="test"),
self.setup_bookmark(shared=True, user=user1, description="test"),
self.setup_bookmark(shared=True, user=user1, description="test"),
]
response = self.client.get(reverse("bookmarks:feeds.public_shared"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<item>", count=len(public_shared_bookmarks))
for bookmark in public_shared_bookmarks:
expected_item = (
"<item>"
f"<title>{bookmark.resolved_title}</title>"
f"<link>{bookmark.url}</link>"
f"<description>{bookmark.resolved_description}</description>"
f"<pubDate>{rfc2822_date(bookmark.date_added)}</pubDate>"
f"<guid>{bookmark.url}</guid>"
"</item>"
)
self.assertContains(response, expected_item, count=1)
def test_public_shared_with_query(self):
user = self.setup_user(enable_sharing=True, enable_public_sharing=True)
tag1 = self.setup_tag(user=user)
bookmark1 = self.setup_bookmark(shared=True, user=user)
bookmark2 = self.setup_bookmark(shared=True, tags=[tag1], user=user)
bookmark3 = self.setup_bookmark(shared=True, tags=[tag1], user=user)
self.setup_bookmark(shared=True, user=user)
self.setup_bookmark(shared=True, user=user)
self.setup_bookmark(shared=True, user=user)
feed_url = reverse("bookmarks:feeds.shared", args=[self.token.key])
url = feed_url + f"?q={bookmark1.title}"
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<item>", count=1)
self.assertContains(response, f"<guid>{bookmark1.url}</guid>", count=1)
url = feed_url + "?q=" + urllib.parse.quote("#" + tag1.name)
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<item>", count=2)
self.assertContains(response, f"<guid>{bookmark2.url}</guid>", count=1)
self.assertContains(response, f"<guid>{bookmark3.url}</guid>", count=1)
url = feed_url + "?q=" + urllib.parse.quote(f"#{tag1.name} {bookmark2.title}")
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "<item>", count=1)
self.assertContains(response, f"<guid>{bookmark2.url}</guid>", count=1)

View File

@@ -0,0 +1,29 @@
from django.test import TestCase, override_settings
from django.urls import path, include
from bookmarks.tests.helpers import HtmlTestMixin
from siteroot.urls import urlpatterns as base_patterns
# Register OIDC urls for this test, otherwise login template can not render when OIDC is enabled
urlpatterns = base_patterns + [path("oidc/", include("mozilla_django_oidc.urls"))]
@override_settings(ROOT_URLCONF=__name__)
class LoginViewTestCase(TestCase, HtmlTestMixin):
def test_should_not_show_oidc_login_by_default(self):
response = self.client.get("/login/")
soup = self.make_soup(response.content.decode())
oidc_login_link = soup.find("a", text="Login with OIDC")
self.assertIsNone(oidc_login_link)
@override_settings(LD_ENABLE_OIDC=True)
def test_should_show_oidc_login_when_enabled(self):
response = self.client.get("/login/")
soup = self.make_soup(response.content.decode())
oidc_login_link = soup.find("a", text="Login with OIDC")
self.assertIsNotNone(oidc_login_link)

View File

@@ -11,9 +11,91 @@ class MetadataViewTestCase(TestCase):
response_body = response.json()
expected_body = {
"short_name": "linkding",
"name": "linkding",
"description": "Self-hosted bookmark service",
"start_url": "bookmarks",
"display": "standalone",
"scope": "/",
"theme_color": "#5856e0",
"background_color": "#ffffff",
"icons": [
{
"src": "/static/logo.svg",
"type": "image/svg+xml",
"sizes": "512x512",
"purpose": "any"
},
{
"src": "/static/logo-512.png",
"type": "image/png",
"sizes": "512x512",
"purpose": "any"
},
{
"src": "/static/logo-192.png",
"type": "image/png",
"sizes": "192x192",
"purpose": "any"
},
{
"src": "/static/maskable-logo.svg",
"type": "image/svg+xml",
"sizes": "512x512",
"purpose": "maskable"
},
{
"src": "/static/maskable-logo-512.png",
"type": "image/png",
"sizes": "512x512",
"purpose": "maskable"
},
{
"src": "/static/maskable-logo-192.png",
"type": "image/png",
"sizes": "192x192",
"purpose": "maskable"
},
],
"shortcuts": [
{
"name": "Add bookmark",
"url": "/bookmarks/new",
},
{
"name": "Archived",
"url": "/bookmarks/archived",
},
{
"name": "Unread",
"url": "/bookmarks?unread=yes",
},
{
"name": "Untagged",
"url": "/bookmarks?q=!untagged",
},
{
"name": "Shared",
"url": "/bookmarks/shared",
}
],
"screenshots": [
{
"src": "/static/linkding-screenshot.png",
"type": "image/png",
"sizes": "2158x1160",
"form_factor": "wide"
}
],
"share_target": {
"action": "/bookmarks/new",
"method": "GET",
"enctype": "application/x-www-form-urlencoded",
"params": {
"url": "url",
"text": "url",
"title": "title",
}
}
}
self.assertDictEqual(response_body, expected_body)
@@ -26,8 +108,90 @@ class MetadataViewTestCase(TestCase):
response_body = response.json()
expected_body = {
"short_name": "linkding",
"name": "linkding",
"description": "Self-hosted bookmark service",
"start_url": "bookmarks",
"display": "standalone",
"scope": "/linkding/",
"theme_color": "#5856e0",
"background_color": "#ffffff",
"icons": [
{
"src": "/linkding/static/logo.svg",
"type": "image/svg+xml",
"sizes": "512x512",
"purpose": "any"
},
{
"src": "/linkding/static/logo-512.png",
"type": "image/png",
"sizes": "512x512",
"purpose": "any"
},
{
"src": "/linkding/static/logo-192.png",
"type": "image/png",
"sizes": "192x192",
"purpose": "any"
},
{
"src": "/linkding/static/maskable-logo.svg",
"type": "image/svg+xml",
"sizes": "512x512",
"purpose": "maskable"
},
{
"src": "/linkding/static/maskable-logo-512.png",
"type": "image/png",
"sizes": "512x512",
"purpose": "maskable"
},
{
"src": "/linkding/static/maskable-logo-192.png",
"type": "image/png",
"sizes": "192x192",
"purpose": "maskable"
},
],
"shortcuts": [
{
"name": "Add bookmark",
"url": "/linkding/bookmarks/new",
},
{
"name": "Archived",
"url": "/linkding/bookmarks/archived",
},
{
"name": "Unread",
"url": "/linkding/bookmarks?unread=yes",
},
{
"name": "Untagged",
"url": "/linkding/bookmarks?q=!untagged",
},
{
"name": "Shared",
"url": "/linkding/bookmarks/shared",
}
],
"screenshots": [
{
"src": "/linkding/static/linkding-screenshot.png",
"type": "image/png",
"sizes": "2158x1160",
"form_factor": "wide"
}
],
"share_target": {
"action": "/linkding/bookmarks/new",
"method": "GET",
"enctype": "application/x-www-form-urlencoded",
"params": {
"url": "url",
"text": "url",
"title": "title",
}
}
}
self.assertDictEqual(response_body, expected_body)

View File

@@ -0,0 +1,51 @@
import importlib
import os
from django.test import TestCase, override_settings
from django.urls import URLResolver
class OidcSupportTest(TestCase):
def test_should_not_add_oidc_urls_by_default(self):
siteroot_urls = importlib.import_module("siteroot.urls")
importlib.reload(siteroot_urls)
oidc_url_found = any(
isinstance(urlpattern, URLResolver) and urlpattern.pattern._route == "oidc/"
for urlpattern in siteroot_urls.urlpatterns
)
self.assertFalse(oidc_url_found)
@override_settings(LD_ENABLE_OIDC=True)
def test_should_add_oidc_urls_when_enabled(self):
siteroot_urls = importlib.import_module("siteroot.urls")
importlib.reload(siteroot_urls)
oidc_url_found = any(
isinstance(urlpattern, URLResolver) and urlpattern.pattern._route == "oidc/"
for urlpattern in siteroot_urls.urlpatterns
)
self.assertTrue(oidc_url_found)
def test_should_not_add_oidc_authentication_backend_by_default(self):
base_settings = importlib.import_module("siteroot.settings.base")
importlib.reload(base_settings)
self.assertListEqual(
["django.contrib.auth.backends.ModelBackend"],
base_settings.AUTHENTICATION_BACKENDS,
)
def test_should_add_oidc_authentication_backend_when_enabled(self):
os.environ["LD_ENABLE_OIDC"] = "True"
base_settings = importlib.import_module("siteroot.settings.base")
importlib.reload(base_settings)
self.assertListEqual(
[
"django.contrib.auth.backends.ModelBackend",
"mozilla_django_oidc.auth.OIDCAuthenticationBackend",
],
base_settings.AUTHENTICATION_BACKENDS,
)
del os.environ["LD_ENABLE_OIDC"] # Remove the temporary environment variable

View File

@@ -32,6 +32,7 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
"tag_search": UserProfile.TAG_SEARCH_STRICT,
"display_url": False,
"permanent_notes": False,
"custom_css": "",
}
return {**form_data, **overrides}
@@ -63,6 +64,7 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
"tag_search": UserProfile.TAG_SEARCH_LAX,
"display_url": True,
"permanent_notes": True,
"custom_css": "body { background-color: #000; }",
}
response = self.client.post(reverse("bookmarks:settings.general"), form_data)
html = response.content.decode()
@@ -93,6 +95,7 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertEqual(
self.user.profile.permanent_notes, form_data["permanent_notes"]
)
self.assertEqual(self.user.profile.custom_css, form_data["custom_css"])
self.assertInHTML(
"""
<p class="form-input-hint">Profile updated</p>

View File

@@ -74,3 +74,11 @@ class SettingsIntegrationsViewTestCase(TestCase, BookmarkFactoryMixin):
f'<a href="http://testserver/feeds/{token.key}/unread">Unread bookmarks</a>',
html,
)
self.assertInHTML(
f'<a href="http://testserver/feeds/{token.key}/shared">Shared bookmarks</a>',
html,
)
self.assertInHTML(
f'<a href="http://testserver/feeds/shared">Public shared bookmarks</a>',
html,
)

View File

@@ -4,7 +4,12 @@ from django.views.generic import RedirectView
from bookmarks import views
from bookmarks.api.routes import router
from bookmarks.feeds import AllBookmarksFeed, UnreadBookmarksFeed
from bookmarks.feeds import (
AllBookmarksFeed,
UnreadBookmarksFeed,
SharedBookmarksFeed,
PublicSharedBookmarksFeed,
)
from bookmarks.views import partials
app_name = "bookmarks"
@@ -77,6 +82,8 @@ urlpatterns = [
# Feeds
path("feeds/<str:feed_key>/all", AllBookmarksFeed(), name="feeds.all"),
path("feeds/<str:feed_key>/unread", UnreadBookmarksFeed(), name="feeds.unread"),
path("feeds/<str:feed_key>/shared", SharedBookmarksFeed(), name="feeds.shared"),
path("feeds/shared", PublicSharedBookmarksFeed(), name="feeds.public_shared"),
# Health check
path("health", views.health, name="health"),
# Manifest

View File

@@ -1,5 +1,6 @@
import logging
import re
import unicodedata
from datetime import datetime
from typing import Optional
@@ -111,3 +112,12 @@ def get_safe_return_url(return_url: str, fallback_url: str):
if not return_url or not re.match(r"^/[a-z]+", return_url):
return fallback_url
return return_url
def generate_username(email):
# 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]

View File

@@ -5,9 +5,91 @@ from django.conf import settings
def manifest(request):
response = {
"short_name": "linkding",
"name": "linkding",
"description": "Self-hosted bookmark service",
"start_url": "bookmarks",
"display": "standalone",
"scope": "/" + settings.LD_CONTEXT_PATH,
"theme_color": "#5856e0",
"background_color": "#161822" if request.user_profile.theme == "dark" else "#ffffff",
"icons": [
{
"src": "/" + settings.LD_CONTEXT_PATH + "static/logo.svg",
"type": "image/svg+xml",
"sizes": "512x512",
"purpose": "any"
},
{
"src": "/" + settings.LD_CONTEXT_PATH + "static/logo-512.png",
"type": "image/png",
"sizes": "512x512",
"purpose": "any"
},
{
"src": "/" + settings.LD_CONTEXT_PATH + "static/logo-192.png",
"type": "image/png",
"sizes": "192x192",
"purpose": "any"
},
{
"src": "/" + settings.LD_CONTEXT_PATH + "static/maskable-logo.svg",
"type": "image/svg+xml",
"sizes": "512x512",
"purpose": "maskable"
},
{
"src": "/" + settings.LD_CONTEXT_PATH + "static/maskable-logo-512.png",
"type": "image/png",
"sizes": "512x512",
"purpose": "maskable"
},
{
"src": "/" + settings.LD_CONTEXT_PATH + "static/maskable-logo-192.png",
"type": "image/png",
"sizes": "192x192",
"purpose": "maskable"
},
],
"shortcuts": [
{
"name": "Add bookmark",
"url": "/" + settings.LD_CONTEXT_PATH + "bookmarks/new",
},
{
"name": "Archived",
"url": "/" + settings.LD_CONTEXT_PATH + "bookmarks/archived",
},
{
"name": "Unread",
"url": "/" + settings.LD_CONTEXT_PATH + "bookmarks?unread=yes",
},
{
"name": "Untagged",
"url": "/" + settings.LD_CONTEXT_PATH + "bookmarks?q=!untagged",
},
{
"name": "Shared",
"url": "/" + settings.LD_CONTEXT_PATH + "bookmarks/shared",
}
],
"screenshots": [
{
"src": "/" + settings.LD_CONTEXT_PATH + "static/linkding-screenshot.png",
"type": "image/png",
"sizes": "2158x1160",
"form_factor": "wide"
}
],
"share_target": {
"action": "/" + settings.LD_CONTEXT_PATH + "bookmarks/new",
"method": "GET",
"enctype": "application/x-www-form-urlencoded",
"params": {
"url": "url",
"text": "url",
"title": "title",
}
}
}
return JsonResponse(response, status=200)

View File

@@ -114,6 +114,12 @@ def integrations(request):
unread_feed_url = request.build_absolute_uri(
reverse("bookmarks:feeds.unread", args=[feed_token.key])
)
shared_feed_url = request.build_absolute_uri(
reverse("bookmarks:feeds.shared", args=[feed_token.key])
)
public_shared_feed_url = request.build_absolute_uri(
reverse("bookmarks:feeds.public_shared")
)
return render(
request,
"settings/integrations.html",
@@ -122,6 +128,8 @@ def integrations(request):
"api_token": api_token.key,
"all_feed_url": all_feed_url,
"unread_feed_url": unread_feed_url,
"shared_feed_url": shared_feed_url,
"public_shared_feed_url": public_shared_feed_url,
},
)

View File

@@ -1,16 +1,22 @@
FROM node:18.18.0-alpine AS node-build
FROM node:18-alpine AS node-build
WORKDIR /etc/linkding
# install build dependencies
COPY rollup.config.js package.json package-lock.json ./
RUN npm install
COPY rollup.config.mjs package.json package-lock.json ./
RUN npm ci
# copy files needed for JS build
COPY bookmarks/frontend ./bookmarks/frontend
# run build
RUN npm run build
FROM python:3.10.13-alpine3.18 AS python-base
RUN apk update && apk add alpine-sdk linux-headers libpq-dev pkgconfig icu-dev sqlite-dev
# Use 3.11 for now, as django4-background-tasks doesn't work with 3.12 yet
FROM python:3.11.8-alpine3.19 AS python-base
# Add required packages
# alpine-sdk linux-headers pkgconfig: build Python packages from source
# libpq-dev: build Postgres client from source
# icu-dev sqlite-dev: build Sqlite ICU extension
# 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
@@ -32,7 +38,7 @@ RUN python manage.py compilescss && \
FROM python-base AS prod-deps
COPY requirements.txt ./requirements.txt
# replace psycopg2-binary with psycopg2
# Need to build psycopg2 from source for ARM platforms
RUN sed -i 's/psycopg2-binary/psycopg2/g' requirements.txt
RUN mkdir /opt/venv && \
python -m venv --upgrade-deps --copies /opt/venv && \
@@ -61,9 +67,9 @@ 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.10.13-alpine3.18 AS final
FROM python:3.11.8-alpine3.19 AS final
# install runtime dependencies
RUN apk update && apk add bash curl icu libpq mailcap
RUN apk update && apk add bash curl icu libpq mailcap libssl3
# create www-data user and group
RUN set -x ; \
addgroup -g 82 -S www-data ; \

View File

@@ -1,16 +1,24 @@
FROM node:18.18.0-alpine AS node-build
FROM node:18-alpine AS node-build
WORKDIR /etc/linkding
# install build dependencies
COPY rollup.config.js package.json package-lock.json ./
RUN npm install
COPY rollup.config.mjs package.json package-lock.json ./
RUN npm ci
# copy files needed for JS build
COPY bookmarks/frontend ./bookmarks/frontend
# run build
RUN npm run build
FROM python:3.10.6-slim-buster AS python-base
RUN apt-get update && apt-get -y install build-essential libpq-dev
# Use 3.11 for now, as django4-background-tasks doesn't work with 3.12 yet
FROM python:3.11.8-slim-bookworm AS python-base
# Add required packages
# build-essential pkg-config: build Python packages from source
# libpq-dev: build Postgres client from source
# libicu-dev libsqlite3-dev: build Sqlite ICU extension
# llibffi-dev libssl-dev curl rustup: build Python cryptography from source
RUN apt-get update && apt-get -y install build-essential pkg-config libpq-dev libicu-dev libsqlite3-dev wget unzip libffi-dev libssl-dev curl
RUN curl https://sh.rustup.rs -sSf | sh -s -- -y
ENV PATH="/root/.cargo/bin:${PATH}"
WORKDIR /etc/linkding
@@ -32,7 +40,7 @@ RUN python manage.py compilescss && \
FROM python-base AS prod-deps
COPY requirements.txt ./requirements.txt
# replace psycopg2-binary with psycopg2
# Need to build psycopg2 from source for ARM platforms
RUN sed -i 's/psycopg2-binary/psycopg2/g' requirements.txt
RUN mkdir /opt/venv && \
python -m venv --upgrade-deps --copies /opt/venv && \
@@ -41,9 +49,6 @@ RUN mkdir /opt/venv && \
FROM python-base AS compile-icu
RUN apt-get update && apt-get -y install libicu-dev libsqlite3-dev wget unzip
WORKDIR /etc/linkding
# 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
@@ -64,8 +69,8 @@ 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.10.6-slim-buster as final
RUN apt-get update && apt-get -y install mime-support libpq-dev libicu-dev curl
FROM python:3.11.8-slim-bookworm as final
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

View File

@@ -48,6 +48,7 @@ Example response:
"notes": "Example notes",
"website_title": "Website title",
"website_description": "Website description",
"web_archive_snapshot_url": "https://web.archive.org/web/20200926094623/https://example.com",
"is_archived": false,
"unread": false,
"shared": false,

View File

@@ -94,6 +94,31 @@ For example, for Authelia, which passes the `Remote-User` HTTP header, the `LD_A
By default, the logout redirects to the login URL, which means the user will be automatically authenticated again.
Instead, you might want to configure the logout URL of the auth proxy here.
### `LD_ENABLE_OIDC`
Values: `True`, `False` | Default = `False`
Enables support for OpenID Connect (OIDC) authentication, allowing to use single sign-on (SSO) with OIDC providers.
When enabled, this shows a button on the login page that allows users to authenticate using an OIDC provider.
Users are associated by the email address provided from the OIDC provider, which is used as the username in linkding.
If there is no user with that email address as username, a new user is created automatically.
This requires configuring a number of options, which of those you need depends on which OIDC provider you use and how it is configured.
In general, you should find the required information in the UI of your OIDC provider, or its documentation.
The options are adopted from the [mozilla-django-oidc](https://mozilla-django-oidc.readthedocs.io/en/stable/) library, which is used by linkding for OIDC support.
Please check their documentation for more information on the options.
The following options can be configured:
- `OIDC_OP_AUTHORIZATION_ENDPOINT` - The authorization endpoint of the OIDC provider.
- `OIDC_OP_TOKEN_ENDPOINT` - The token endpoint of the OIDC provider.
- `OIDC_OP_USER_ENDPOINT` - The user info endpoint of the OIDC provider.
- `OIDC_OP_JWKS_ENDPOINT` - The JWKS endpoint of the OIDC provider.
- `OIDC_RP_CLIENT_ID` - The client ID of the application.
- `OIDC_RP_CLIENT_SECRET` - The client secret of the application.
- `OIDC_RP_SIGN_ALGO` - The algorithm the OIDC provider uses to sign ID tokens. Default is `RS256`.
- `OIDC_USE_PKCE` - Whether to use PKCE for the OIDC flow. Default is `True`.
### `LD_CSRF_TRUSTED_ORIGINS`
Values: `String` | Default = None

View File

@@ -25,13 +25,13 @@ linkding includes a CLI command for creating a backup copy of the database.
To create a backup, execute the following command:
```shell
docker exec -it linkding python manage.py backup backup.sqlite3
docker exec -it linkding python manage.py backup /etc/linkding/data/backup.sqlite3
```
This creates a `backup.sqlite3` file in the Docker container.
This creates a `backup.sqlite3` file in the Docker container under `/etc/linkding/data`.
To copy the backup file to your host system, execute the following command:
```shell
docker cp linkding:/etc/linkding/backup.sqlite3 backup.sqlite3
docker cp linkding:/etc/linkding/data/backup.sqlite3 backup.sqlite3
```
This copies the backup file from the Docker container to the current folder on your host system.
Now you can move that file to your backup location.

1408
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "linkding",
"version": "1.24.2",
"version": "1.25.0",
"description": "",
"main": "index.js",
"scripts": {
@@ -13,19 +13,18 @@
},
"keywords": [],
"author": "",
"license": "ISC",
"license": "MIT",
"bugs": {
"url": "https://github.com/sissbruecker/linkding/issues"
},
"homepage": "https://github.com/sissbruecker/linkding#readme",
"dependencies": {
"@rollup/plugin-commonjs": "^21.0.2",
"@rollup/plugin-node-resolve": "^13.1.3",
"rollup": "^2.70.1",
"rollup-plugin-svelte": "^7.1.0",
"rollup-plugin-terser": "^7.0.2",
"@rollup/plugin-node-resolve": "^15.2.3",
"@rollup/plugin-terser": "^0.4.4",
"@rollup/wasm-node": "^4.13.0",
"rollup-plugin-svelte": "^7.2.0",
"spectre.css": "^0.5.8",
"svelte": "^3.49.0"
"svelte": "^4.0.0"
},
"devDependencies": {
"prettier": "^3.0.2"

View File

@@ -12,7 +12,7 @@ click==8.1.7
# via black
coverage==7.4.1
# via -r requirements.dev.in
django==5.0.2
django==5.0.3
# via
# django-appconf
# django-debug-toolbar

View File

@@ -8,6 +8,7 @@ django-widget-tweaks
django4-background-tasks
djangorestframework
Markdown
mozilla-django-oidc
psycopg2-binary
python-dateutil
requests

View File

@@ -14,17 +14,25 @@ bleach-allowlist==1.0.3
# via -r requirements.in
certifi==2023.11.17
# via requests
cffi==1.16.0
# via cryptography
charset-normalizer==3.3.2
# via requests
click==8.1.7
# via waybackpy
confusable-homoglyphs==3.2.0
# via django-registration
django==5.0.2
cryptography==42.0.5
# via
# josepy
# mozilla-django-oidc
# pyopenssl
django==5.0.3
# via
# -r requirements.in
# django-registration
# djangorestframework
# mozilla-django-oidc
django-registration==3.4
# via -r requirements.in
django-sass-processor==1.4
@@ -37,10 +45,18 @@ djangorestframework==3.14.0
# via -r requirements.in
idna==3.6
# via requests
josepy==1.14.0
# via mozilla-django-oidc
markdown==3.5.2
# via -r requirements.in
mozilla-django-oidc==4.0.1
# via -r requirements.in
psycopg2-binary==2.9.9
# via -r requirements.in
pycparser==2.21
# via cffi
pyopenssl==24.1.0
# via josepy
python-dateutil==2.8.2
# via -r requirements.in
pytz==2023.3.post1
@@ -48,6 +64,7 @@ pytz==2023.3.post1
requests==2.31.0
# via
# -r requirements.in
# mozilla-django-oidc
# waybackpy
six==1.16.0
# via

View File

@@ -1,40 +0,0 @@
import svelte from 'rollup-plugin-svelte';
import resolve from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
import { terser } from 'rollup-plugin-terser';
const production = !process.env.ROLLUP_WATCH;
export default {
input: 'bookmarks/frontend/index.js',
output: {
sourcemap: true,
format: 'iife',
name: 'linkding',
// Generate bundle in static folder to that it is picked up by Django static files finder
file: 'bookmarks/static/bundle.js'
},
plugins: [
svelte({
emitCss: false
}),
// If you have external dependencies installed from
// npm, you'll most likely need these plugins. In
// some cases you'll need additional configuration —
// consult the documentation for details:
// https://github.com/rollup/rollup-plugin-commonjs
resolve({
browser: true,
dedupe: importee => importee === 'svelte' || importee.startsWith('svelte/')
}),
commonjs(),
// If we're building for production (npm run build
// instead of npm run dev), minify
production && terser()
],
watch: {
clearScreen: false
}
};

37
rollup.config.mjs Normal file
View File

@@ -0,0 +1,37 @@
import svelte from 'rollup-plugin-svelte';
import resolve from '@rollup/plugin-node-resolve';
import terser from '@rollup/plugin-terser';
const production = !process.env.ROLLUP_WATCH;
export default {
input: 'bookmarks/frontend/index.js',
output: {
sourcemap: true,
format: 'iife',
name: 'linkding',
// Generate bundle in static folder to that it is picked up by Django static files finder
file: 'bookmarks/static/bundle.js',
},
plugins: [
svelte({
emitCss: false,
}),
// If you have external dependencies installed from
// npm, you'll most likely need these plugins. In
// some cases you'll need additional configuration —
// consult the documentation for details:
// https://github.com/rollup/rollup-plugin-commonjs
resolve({
browser: true,
}),
// If we're building for production (npm run build
// instead of npm run dev), minify
production && terser(),
],
watch: {
clearScreen: false,
},
};

10
scripts/release.sh Executable file
View File

@@ -0,0 +1,10 @@
#!/usr/bin/env bash
version=$(<version.txt)
git push origin master
git tag v${version}
git push origin v${version}
./scripts/build-docker.sh
echo "Done ✅"

7
scripts/setup-oicd.sh Normal file
View File

@@ -0,0 +1,7 @@
# Example setup for OIDC with Zitadel
export LD_ENABLE_OIDC=True
export OIDC_USE_PKCE=True
export OIDC_OP_AUTHORIZATION_ENDPOINT=http://localhost:8080/oauth/v2/authorize
export OIDC_OP_TOKEN_ENDPOINT=http://localhost:8080/oauth/v2/token
export OIDC_OP_USER_ENDPOINT=http://localhost:8080/oidc/v1/userinfo
export OIDC_RP_CLIENT_ID=258574559115018243@linkding

View File

@@ -43,6 +43,7 @@ INSTALLED_APPS = [
"rest_framework",
"rest_framework.authtoken",
"background_task",
"mozilla_django_oidc",
]
MIDDLEWARE = [
@@ -130,8 +131,6 @@ STATIC_ROOT = os.path.join(BASE_DIR, "static")
# Turn off SASS compilation by default
SASS_PROCESSOR_ENABLED = False
# Location where generated CSS files are saved
SASS_PROCESSOR_ROOT = os.path.join(BASE_DIR, "bookmarks", "static")
# Add SASS preprocessor finder to resolve generated CSS
STATICFILES_FINDERS = [
@@ -182,6 +181,24 @@ MAX_ATTEMPTS = 5
BACKGROUND_TASK_RUN_ASYNC = True
BACKGROUND_TASK_ASYNC_THREADS = 2
# Enable OICD support if configured
LD_ENABLE_OIDC = os.getenv("LD_ENABLE_OIDC", False) in (True, "True", "1")
AUTHENTICATION_BACKENDS = ["django.contrib.auth.backends.ModelBackend"]
if LD_ENABLE_OIDC:
AUTHENTICATION_BACKENDS.append("mozilla_django_oidc.auth.OIDCAuthenticationBackend")
OIDC_USERNAME_ALGO = "bookmarks.utils.generate_username"
OIDC_OP_AUTHORIZATION_ENDPOINT = os.getenv("OIDC_OP_AUTHORIZATION_ENDPOINT")
OIDC_OP_TOKEN_ENDPOINT = os.getenv("OIDC_OP_TOKEN_ENDPOINT")
OIDC_OP_USER_ENDPOINT = os.getenv("OIDC_OP_USER_ENDPOINT")
OIDC_OP_JWKS_ENDPOINT = os.getenv("OIDC_OP_JWKS_ENDPOINT")
OIDC_RP_CLIENT_ID = os.getenv("OIDC_RP_CLIENT_ID")
OIDC_RP_CLIENT_SECRET = os.getenv("OIDC_RP_CLIENT_SECRET")
OIDC_RP_SIGN_ALGO = os.getenv("OIDC_RP_SIGN_ALGO", "RS256")
OIDC_USE_PKCE = os.getenv("OIDC_USE_PKCE", True) in (True, "True", "1")
# Enable authentication proxy support if configured
LD_ENABLE_AUTH_PROXY = os.getenv("LD_ENABLE_AUTH_PROXY", False) in (True, "True", "1")
LD_AUTH_PROXY_USERNAME_HEADER = os.getenv(
@@ -194,9 +211,7 @@ if LD_ENABLE_AUTH_PROXY:
# in the LD_AUTH_PROXY_USERNAME_HEADER request header
MIDDLEWARE.append("bookmarks.middlewares.CustomRemoteUserMiddleware")
# Configure auth backend that does not require a password credential
AUTHENTICATION_BACKENDS = [
"django.contrib.auth.backends.RemoteUserBackend",
]
AUTHENTICATION_BACKENDS = ["django.contrib.auth.backends.RemoteUserBackend"]
# Configure logout URL
if LD_AUTH_PROXY_LOGOUT_URL:
LOGOUT_REDIRECT_URL = LD_AUTH_PROXY_LOGOUT_URL

View File

@@ -19,16 +19,27 @@ from django.contrib.auth import views as auth_views
from django.urls import path, include
from bookmarks.admin import linkding_admin_site
from .settings import ALLOW_REGISTRATION, DEBUG
class LinkdingLoginView(auth_views.LoginView):
"""
Custom login view to lazily add additional context data
Allows to override settings in tests
"""
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["allow_registration"] = settings.ALLOW_REGISTRATION
context["enable_oidc"] = settings.LD_ENABLE_OIDC
return context
urlpatterns = [
path("admin/", linkding_admin_site.urls),
path(
"login/",
auth_views.LoginView.as_view(
redirect_authenticated_user=True,
extra_context=dict(allow_registration=ALLOW_REGISTRATION),
),
LinkdingLoginView.as_view(redirect_authenticated_user=True),
name="login",
),
path("logout/", auth_views.LogoutView.as_view(), name="logout"),
@@ -45,13 +56,16 @@ urlpatterns = [
path("", include("bookmarks.urls")),
]
if settings.LD_ENABLE_OIDC:
urlpatterns.append(path("oidc/", include("mozilla_django_oidc.urls")))
if settings.LD_CONTEXT_PATH:
urlpatterns = [path(settings.LD_CONTEXT_PATH, include(urlpatterns))]
if DEBUG:
if settings.DEBUG:
import debug_toolbar
urlpatterns.append(path("__debug__/", include(debug_toolbar.urls)))
if ALLOW_REGISTRATION:
if settings.ALLOW_REGISTRATION:
urlpatterns.append(path("", include("django_registration.backends.one_step.urls")))

View File

@@ -1 +1 @@
1.24.2
1.25.0