Compare commits
14 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
d6484ba8e9 | ||
![]() |
4c26d66177 | ||
![]() |
c51dcafa40 | ||
![]() |
262dd2b28f | ||
![]() |
01ad7f4d9e | ||
![]() |
d0d5c15345 | ||
![]() |
afb752765d | ||
![]() |
ce213775b6 | ||
![]() |
fd1bbadcf3 | ||
![]() |
83c2530df4 | ||
![]() |
39782e75e7 | ||
![]() |
4bee104b62 | ||
![]() |
f4ecffbb7f | ||
![]() |
6f52bafda8 |
@@ -13,7 +13,7 @@
|
||||
!/package-lock.json
|
||||
!/requirements.dev.txt
|
||||
!/requirements.txt
|
||||
!/rollup.config.js
|
||||
!/rollup.config.mjs
|
||||
!/supervisord.conf
|
||||
!/uwsgi.ini
|
||||
!/version.txt
|
||||
|
28
.github/workflows/main.yaml
vendored
@@ -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
|
||||
|
@@ -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)
|
||||
|
@@ -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",
|
||||
]
|
||||
|
@@ -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(
|
||||
|
@@ -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
|
||||
|
@@ -258,4 +258,4 @@
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
</style>
|
||||
</style>
|
||||
|
@@ -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';
|
||||
|
18
bookmarks/migrations/0026_userprofile_custom_css.py
Normal 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),
|
||||
),
|
||||
]
|
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 2.5 KiB |
BIN
bookmarks/static/favicon.ico
Normal file
After Width: | Height: | Size: 15 KiB |
Before Width: | Height: | Size: 12 KiB |
1
bookmarks/static/favicon.svg
Normal 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 |
BIN
bookmarks/static/linkding-screenshot.png
Normal file
After Width: | Height: | Size: 184 KiB |
BIN
bookmarks/static/logo-192.png
Normal file
After Width: | Height: | Size: 5.4 KiB |
BIN
bookmarks/static/logo-512.png
Normal file
After Width: | Height: | Size: 17 KiB |
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 1.6 KiB |
1
bookmarks/static/logo.svg
Normal 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 |
BIN
bookmarks/static/maskable-logo-192.png
Normal file
After Width: | Height: | Size: 2.4 KiB |
BIN
bookmarks/static/maskable-logo-512.png
Normal file
After Width: | Height: | Size: 5.3 KiB |
1
bookmarks/static/maskable-logo.svg
Normal 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 |
1
bookmarks/static/safari-pinned-tab.svg
Normal 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 |
@@ -7,6 +7,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
textarea.custom-css {
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.input-group > input[type=submit] {
|
||||
height: auto;
|
||||
}
|
||||
|
@@ -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>
|
||||
|
@@ -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 %}
|
||||
|
@@ -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>
|
||||
|
@@ -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.
|
||||
|
@@ -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)
|
||||
|
@@ -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()
|
||||
|
21
bookmarks/tests/test_custom_css.py
Normal 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>")
|
@@ -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)
|
||||
|
29
bookmarks/tests/test_login_view.py
Normal 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)
|
@@ -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)
|
||||
|
51
bookmarks/tests/test_oidc_support.py
Normal 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
|
@@ -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>
|
||||
|
@@ -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,
|
||||
)
|
||||
|
@@ -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
|
||||
|
@@ -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]
|
||||
|
@@ -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)
|
||||
|
@@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
|
@@ -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 ; \
|
||||
|
@@ -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
|
||||
|
@@ -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,
|
||||
|
@@ -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
|
||||
|
@@ -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
15
package.json
@@ -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"
|
||||
|
@@ -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
|
||||
|
@@ -8,6 +8,7 @@ django-widget-tweaks
|
||||
django4-background-tasks
|
||||
djangorestframework
|
||||
Markdown
|
||||
mozilla-django-oidc
|
||||
psycopg2-binary
|
||||
python-dateutil
|
||||
requests
|
||||
|
@@ -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
|
||||
|
@@ -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
@@ -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
@@ -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
@@ -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
|
@@ -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
|
||||
|
@@ -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")))
|
||||
|
@@ -1 +1 @@
|
||||
1.24.2
|
||||
1.25.0
|
||||
|