feat(linkding): add full per-app tools (5 user-management actions)

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_<id>.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<username>\t<username>\t<role> 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 "<code>"`, 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 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
This commit is contained in:
librelad 2026-05-26 20:23:56 +01:00
parent 153c90bf68
commit dc77ddaa4c
9 changed files with 301 additions and 30 deletions

View File

@ -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."
}

View File

@ -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
}
]
}
]
}

View File

@ -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)"
}

View File

@ -0,0 +1,6 @@
#!/bin/bash
appLinkdingDeleteUser() {
local args="$1"
authAdapterCall linkding deleteUser "$(authToolArg "$args" username)"
}

View File

@ -0,0 +1,5 @@
#!/bin/bash
appLinkdingListUsers() {
authAdapterCall linkding listUsers
}

View File

@ -0,0 +1,8 @@
#!/bin/bash
appLinkdingResetPassword() {
local args="$1"
authAdapterCall linkding setPassword \
"$(authToolArg "$args" username)" \
"$(authToolArg "$args" password)"
}

View File

@ -0,0 +1,8 @@
#!/bin/bash
appLinkdingSetAdmin() {
local args="$1"
authAdapterCall linkding setAdmin \
"$(authToolArg "$args" username)" \
"$(authToolArg "$args" admin)"
}

View File

@ -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
}

View File

@ -18,7 +18,6 @@ menu_scripts=(
"menu/tools/manage_dashy.sh" "menu/tools/manage_dashy.sh"
"menu/tools/manage_docker.sh" "menu/tools/manage_docker.sh"
"menu/tools/manage_invidious.sh" "menu/tools/manage_invidious.sh"
"menu/tools/manage_linkding.sh"
"menu/tools/manage_main.sh" "menu/tools/manage_main.sh"
"menu/tools/manage_mattermost.sh" "menu/tools/manage_mattermost.sh"