#!/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." }