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:
parent
153c90bf68
commit
dc77ddaa4c
158
containers/linkding/scripts/linkding_auth.sh
Normal file
158
containers/linkding/scripts/linkding_auth.sh
Normal 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."
|
||||||
|
}
|
||||||
106
containers/linkding/tools/linkding.tools.json
Normal file
106
containers/linkding/tools/linkding.tools.json
Normal 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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
10
containers/linkding/tools/linkding_create_account.sh
Normal file
10
containers/linkding/tools/linkding_create_account.sh
Normal 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)"
|
||||||
|
}
|
||||||
6
containers/linkding/tools/linkding_delete_user.sh
Normal file
6
containers/linkding/tools/linkding_delete_user.sh
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
appLinkdingDeleteUser() {
|
||||||
|
local args="$1"
|
||||||
|
authAdapterCall linkding deleteUser "$(authToolArg "$args" username)"
|
||||||
|
}
|
||||||
5
containers/linkding/tools/linkding_list_users.sh
Normal file
5
containers/linkding/tools/linkding_list_users.sh
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
appLinkdingListUsers() {
|
||||||
|
authAdapterCall linkding listUsers
|
||||||
|
}
|
||||||
8
containers/linkding/tools/linkding_reset_password.sh
Normal file
8
containers/linkding/tools/linkding_reset_password.sh
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
appLinkdingResetPassword() {
|
||||||
|
local args="$1"
|
||||||
|
authAdapterCall linkding setPassword \
|
||||||
|
"$(authToolArg "$args" username)" \
|
||||||
|
"$(authToolArg "$args" password)"
|
||||||
|
}
|
||||||
8
containers/linkding/tools/linkding_set_admin.sh
Normal file
8
containers/linkding/tools/linkding_set_admin.sh
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
appLinkdingSetAdmin() {
|
||||||
|
local args="$1"
|
||||||
|
authAdapterCall linkding setAdmin \
|
||||||
|
"$(authToolArg "$args" username)" \
|
||||||
|
"$(authToolArg "$args" admin)"
|
||||||
|
}
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
@ -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"
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user