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"