From dc77ddaa4cfb0a60d5013fde95abe8b511494495 Mon Sep 17 00:00:00 2001 From: librelad Date: Tue, 26 May 2026 20:23:56 +0100 Subject: [PATCH] feat(linkding): add full per-app tools (5 user-management actions) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Linkding has shipped without any Tools-tab actions since v0.1.0 — the only artifact was scripts/menu/tools/manage_linkding.sh, a dead legacy CLI menu referencing an `appLinkdingSetupUser` function that was never defined. Build the real thing, mirroring bookstack's pattern (manifest + thin tool wrappers + auth_adapter that drives the app's native admin shell): containers/linkding/tools/linkding.tools.json — manifest, 5 tools containers/linkding/tools/linkding_.sh — one wrapper per tool containers/linkding/scripts/linkding_auth.sh — Django shell driver Tools (all category=users, so the WebUI's custom user-list panel and its row-level 🔑 / 👑 / 🗑 buttons light up): reset_password — set_password on an existing user (random if blank) create_account — create_user / create_superuser list_users — emits EZ_USER\t\t\t rows (linkding is username-primary, so username goes into both display slots — keeps the panel click-through identifier consistent with the other tools' fields) delete_user — delete by username (destructive, confirm gated) set_admin — toggle is_superuser + is_staff Implementation runs entirely inside the linkding-service container via `runFileOp docker exec ... python manage.py shell -c ""`, reading inputs through `-e` env vars so quoting stays safe. Django's default get_user_model() User is used directly — passwords hash exactly the way the web UI does, admin flags map to the same fields the UI reads. Also drop the dead legacy stub (scripts/menu/tools/manage_linkding.sh) and regenerate files_menu.sh so the source-scan no longer pulls it in. Nothing referenced linkdingToolsMenu — verified by tree-wide grep. Verified live on dev-ai (Debian 12, linkding installed, Django 5 + sqlite): $ libreportal app tool linkding create_account 'username=alice|password=…|admin=true' ✓ Linkding user created — Username: alice — Password: … $ libreportal app tool linkding list_users '' EZ_USER alice alice admin Co-Authored-By: Claude Opus 4.7 Signed-off-by: librelad --- containers/linkding/scripts/linkding_auth.sh | 158 ++++++++++++++++++ containers/linkding/tools/linkding.tools.json | 106 ++++++++++++ .../linkding/tools/linkding_create_account.sh | 10 ++ .../linkding/tools/linkding_delete_user.sh | 6 + .../linkding/tools/linkding_list_users.sh | 5 + .../linkding/tools/linkding_reset_password.sh | 8 + .../linkding/tools/linkding_set_admin.sh | 8 + scripts/menu/tools/manage_linkding.sh | 29 ---- scripts/source/files/arrays/files_menu.sh | 1 - 9 files changed, 301 insertions(+), 30 deletions(-) create mode 100644 containers/linkding/scripts/linkding_auth.sh create mode 100644 containers/linkding/tools/linkding.tools.json create mode 100644 containers/linkding/tools/linkding_create_account.sh create mode 100644 containers/linkding/tools/linkding_delete_user.sh create mode 100644 containers/linkding/tools/linkding_list_users.sh create mode 100644 containers/linkding/tools/linkding_reset_password.sh create mode 100644 containers/linkding/tools/linkding_set_admin.sh delete mode 100755 scripts/menu/tools/manage_linkding.sh diff --git a/containers/linkding/scripts/linkding_auth.sh b/containers/linkding/scripts/linkding_auth.sh new file mode 100644 index 0000000..ef3a3ec --- /dev/null +++ b/containers/linkding/scripts/linkding_auth.sh @@ -0,0 +1,158 @@ +#!/bin/bash + +# Linkding stores users in standard Django auth (django.contrib.auth.models.User). +# We drive the same User model the web UI uses via `python manage.py shell -c` +# from inside the linkding-service container — password hashing and admin flags +# come out identical to what Django itself would write. +# +# Username (not email) is the primary identifier in Linkding's UI, so the Tools +# manifest, the EZ_USER\t panel rows, and every lookup here all key on username. +# Email is stored when supplied but never required. + +_linkdingManage() { + local container="linkding-service" + local py_body="$1" + shift + runFileOp docker exec -i -w /etc/linkding "$@" "$container" \ + python manage.py shell -c "$py_body" 2>&1 +} + +authAdapter_linkding_setPassword() { + local username="$1" password="$2" + [[ -z "$username" ]] && { isError "Username is required."; return 1; } + [[ -z "$password" ]] && password=$(generateRandomPassword) + + local out + out=$(_linkdingManage ' +import os +from django.contrib.auth import get_user_model +U = get_user_model() +u = U.objects.filter(username=os.environ["EZ_USER"]).first() +if not u: + print("EZ_USER_NOT_FOUND"); raise SystemExit +u.set_password(os.environ["EZ_PASS"]); u.save() +print("EZ_RESET_OK:" + u.username) +' -e "EZ_USER=$username" -e "EZ_PASS=$password") + + if echo "$out" | grep -q EZ_USER_NOT_FOUND; then + isError "No Linkding user '$username'."; return 1 + fi + if ! echo "$out" | grep -q EZ_RESET_OK; then + isError "Linkding password reset failed: $out"; return 1 + fi + + # Mirror the admin password back into the .config if we just reset the + # account we know about — keeps the WebUI's "Admin credentials" card + # honest. Skipped when the user resets a non-admin account. + if [[ "$username" == "${CFG_LINKDING_ADMIN_USER:-}" ]]; then + authPersistCfg linkding ADMIN_PASSWORD "$password" + fi + isSuccessful "Linkding password set for $username — New password: $password" +} + +authAdapter_linkding_createUser() { + local username="$1" password="$2" email="$3" isAdmin="$4" + [[ -z "$username" ]] && { isError "Username is required."; return 1; } + [[ -z "$password" ]] && password=$(generateRandomPassword) + + local make_super="False" + [[ "$isAdmin" == "true" ]] && make_super="True" + + local out + out=$(_linkdingManage ' +import os +from django.contrib.auth import get_user_model +U = get_user_model() +if U.objects.filter(username=os.environ["EZ_USER"]).exists(): + print("EZ_USER_EXISTS"); raise SystemExit +email = os.environ.get("EZ_EMAIL", "") +if os.environ["EZ_ADMIN"] == "True": + u = U.objects.create_superuser( + username=os.environ["EZ_USER"], + email=email, + password=os.environ["EZ_PASS"], + ) +else: + u = U.objects.create_user( + username=os.environ["EZ_USER"], + email=email, + password=os.environ["EZ_PASS"], + ) +print("EZ_USER_CREATED:" + str(u.id)) +' -e "EZ_USER=$username" -e "EZ_PASS=$password" -e "EZ_EMAIL=$email" -e "EZ_ADMIN=$make_super") + + if echo "$out" | grep -q EZ_USER_EXISTS; then + isError "Linkding user '$username' already exists."; return 1 + fi + if ! echo "$out" | grep -q EZ_USER_CREATED; then + isError "Linkding user creation failed: $out"; return 1 + fi + + # If we just minted the first admin account, remember it so subsequent + # password resets keep the .config in sync (same pattern bookstack uses). + if [[ "$isAdmin" == "true" && -z "${CFG_LINKDING_ADMIN_USER:-}" ]]; then + authPersistCfg linkding ADMIN_USER "$username" + authPersistCfg linkding ADMIN_PASSWORD "$password" + fi + isSuccessful "Linkding user created — Username: $username — Password: $password" +} + +authAdapter_linkding_listUsers() { + # The user-list modal in the WebUI uses `email || username` as each row's + # display id and pre-fills the tool's `username` field with that value + # (via fillIdentifier in tools-manager.js). Linkding's primary id IS the + # username and email is optional, so emit username in BOTH slots — the + # row click then always sends back the username our other tools expect. + # The third column (`roles`) is matched against /admin/i in the JS to + # decide whether to show the 👑→👤 demote toggle vs 👤→👑 promote. + _linkdingManage ' +from django.contrib.auth import get_user_model +for u in get_user_model().objects.all().order_by("username"): + if u.is_superuser: role = "admin" + elif u.is_staff: role = "staff" + else: role = "user" + print("EZ_USER\t%s\t%s\t%s" % (u.username, u.username, role)) +' +} + +authAdapter_linkding_deleteUser() { + local username="$1" + [[ -z "$username" ]] && { isError "Username is required."; return 1; } + local out + out=$(_linkdingManage ' +import os +from django.contrib.auth import get_user_model +u = get_user_model().objects.filter(username=os.environ["EZ_USER"]).first() +if not u: + print("EZ_USER_NOT_FOUND"); raise SystemExit +u.delete() +print("EZ_USER_DELETED") +' -e "EZ_USER=$username") + echo "$out" | grep -q EZ_USER_NOT_FOUND && { isError "No Linkding user '$username'."; return 1; } + echo "$out" | grep -q EZ_USER_DELETED || { isError "Linkding delete failed: $out"; return 1; } + isSuccessful "Linkding user '$username' deleted." +} + +authAdapter_linkding_setAdmin() { + local username="$1" isAdmin="$2" + [[ -z "$username" ]] && { isError "Username is required."; return 1; } + local target="$isAdmin" + [[ "$target" != "true" ]] && target="false" + + local out + out=$(_linkdingManage ' +import os +from django.contrib.auth import get_user_model +u = get_user_model().objects.filter(username=os.environ["EZ_USER"]).first() +if not u: + print("EZ_USER_NOT_FOUND"); raise SystemExit +flag = os.environ["EZ_TARGET"] == "true" +u.is_superuser = flag +u.is_staff = flag +u.save() +print("EZ_OK") +' -e "EZ_USER=$username" -e "EZ_TARGET=$target") + echo "$out" | grep -q EZ_USER_NOT_FOUND && { isError "No Linkding user '$username'."; return 1; } + echo "$out" | grep -q EZ_OK || { isError "Linkding admin toggle failed: $out"; return 1; } + isSuccessful "Linkding user '$username' admin → $target." +} diff --git a/containers/linkding/tools/linkding.tools.json b/containers/linkding/tools/linkding.tools.json new file mode 100644 index 0000000..7ac9723 --- /dev/null +++ b/containers/linkding/tools/linkding.tools.json @@ -0,0 +1,106 @@ +{ + "tools": [ + { + "id": "reset_password", + "category": "users", + "label": "Reset User Password", + "description": "Reset an existing Linkding user's password. Leave the password field blank to generate a random one — it is shown in the task log.", + "icon": "🔑", + "fields": [ + { + "name": "username", + "label": "Username", + "type": "text", + "placeholder": "alice", + "required": true + }, + { + "name": "password", + "label": "New password (leave blank to generate)", + "type": "password", + "placeholder": "Leave blank for random" + } + ] + }, + { + "id": "create_account", + "category": "users", + "label": "Create User Account", + "description": "Create a new Linkding user. Tick \"Make admin\" to grant full admin (Django superuser) rights. Leave the password blank to generate a random one.", + "icon": "👤", + "fields": [ + { + "name": "username", + "label": "Username", + "type": "text", + "placeholder": "alice", + "required": true + }, + { + "name": "email", + "label": "Email (optional)", + "type": "text", + "placeholder": "alice@example.com" + }, + { + "name": "password", + "label": "Password (leave blank to generate)", + "type": "password", + "placeholder": "Leave blank for random" + }, + { + "name": "admin", + "label": "Make admin", + "type": "checkbox", + "default": false + } + ] + }, + { + "id": "list_users", + "category": "users", + "label": "List Users", + "description": "Show every Linkding user with their admin status.", + "icon": "📋", + "fields": [] + }, + { + "id": "delete_user", + "category": "users", + "label": "Delete User Account", + "description": "Permanently delete a Linkding user account and all of their bookmarks.", + "icon": "🗑", + "destructive": true, + "confirm": "This cannot be undone — the user and all of their bookmarks will be removed.", + "fields": [ + { + "name": "username", + "label": "Username", + "type": "text", + "required": true + } + ] + }, + { + "id": "set_admin", + "category": "users", + "label": "Set Admin Status", + "description": "Promote a user to admin (Django is_superuser + is_staff) or demote them to a normal user.", + "icon": "👑", + "fields": [ + { + "name": "username", + "label": "Username", + "type": "text", + "required": true + }, + { + "name": "admin", + "label": "Make admin", + "type": "checkbox", + "default": false + } + ] + } + ] +} diff --git a/containers/linkding/tools/linkding_create_account.sh b/containers/linkding/tools/linkding_create_account.sh new file mode 100644 index 0000000..505d7db --- /dev/null +++ b/containers/linkding/tools/linkding_create_account.sh @@ -0,0 +1,10 @@ +#!/bin/bash + +appLinkdingCreateAccount() { + local args="$1" + authAdapterCall linkding createUser \ + "$(authToolArg "$args" username)" \ + "$(authToolArg "$args" password)" \ + "$(authToolArg "$args" email)" \ + "$(authToolArg "$args" admin)" +} diff --git a/containers/linkding/tools/linkding_delete_user.sh b/containers/linkding/tools/linkding_delete_user.sh new file mode 100644 index 0000000..9d446da --- /dev/null +++ b/containers/linkding/tools/linkding_delete_user.sh @@ -0,0 +1,6 @@ +#!/bin/bash + +appLinkdingDeleteUser() { + local args="$1" + authAdapterCall linkding deleteUser "$(authToolArg "$args" username)" +} diff --git a/containers/linkding/tools/linkding_list_users.sh b/containers/linkding/tools/linkding_list_users.sh new file mode 100644 index 0000000..f2b3b7c --- /dev/null +++ b/containers/linkding/tools/linkding_list_users.sh @@ -0,0 +1,5 @@ +#!/bin/bash + +appLinkdingListUsers() { + authAdapterCall linkding listUsers +} diff --git a/containers/linkding/tools/linkding_reset_password.sh b/containers/linkding/tools/linkding_reset_password.sh new file mode 100644 index 0000000..63ea302 --- /dev/null +++ b/containers/linkding/tools/linkding_reset_password.sh @@ -0,0 +1,8 @@ +#!/bin/bash + +appLinkdingResetPassword() { + local args="$1" + authAdapterCall linkding setPassword \ + "$(authToolArg "$args" username)" \ + "$(authToolArg "$args" password)" +} diff --git a/containers/linkding/tools/linkding_set_admin.sh b/containers/linkding/tools/linkding_set_admin.sh new file mode 100644 index 0000000..a3fd15c --- /dev/null +++ b/containers/linkding/tools/linkding_set_admin.sh @@ -0,0 +1,8 @@ +#!/bin/bash + +appLinkdingSetAdmin() { + local args="$1" + authAdapterCall linkding setAdmin \ + "$(authToolArg "$args" username)" \ + "$(authToolArg "$args" admin)" +} diff --git a/scripts/menu/tools/manage_linkding.sh b/scripts/menu/tools/manage_linkding.sh deleted file mode 100755 index 46ff038..0000000 --- a/scripts/menu/tools/manage_linkding.sh +++ /dev/null @@ -1,29 +0,0 @@ -#!/bin/bash - -linkdingToolsMenu() -{ - # Enable input - stty echo - - while true; do - isHeader "Linkding Tools" - isOption "1. Run Config Updater" - isOption "x. Exit to Main Menu" - echo "" - isQuestion "What is your choice: " - read -rp "" linkding_menu_choice - - case $linkding_menu_choice in - 1) - appLinkdingSetupUser; - ;; - x) - endStart; - - ;; - *) - isNotice "Invalid choice. Please select a valid option." - ;; - esac - done -} diff --git a/scripts/source/files/arrays/files_menu.sh b/scripts/source/files/arrays/files_menu.sh index 851c8a2..f2d59d7 100755 --- a/scripts/source/files/arrays/files_menu.sh +++ b/scripts/source/files/arrays/files_menu.sh @@ -18,7 +18,6 @@ menu_scripts=( "menu/tools/manage_dashy.sh" "menu/tools/manage_docker.sh" "menu/tools/manage_invidious.sh" - "menu/tools/manage_linkding.sh" "menu/tools/manage_main.sh" "menu/tools/manage_mattermost.sh"