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>
159 lines
6.1 KiB
Bash
159 lines
6.1 KiB
Bash
#!/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."
|
|
}
|