Skip to content

Add an admin area: login with emailed code, picture review, settings, account disabling - #501

Merged
laffer1 merged 9 commits into
masterfrom
admin-area
Nov 3, 2026
Merged

laffer1 merged 9 commits into
masterfrom
admin-area

Conversation

@laffer1

@laffer1 laffer1 commented Nov 3, 2026

Copy link
Copy Markdown
Collaborator

Adds an admin area at /admin so held pictures, site settings and problem accounts can be handled without editing the database by hand.

Login

  • One administrator, set by admin.username and admin.password-hash, read from ${ADMIN_USERNAME:} and ${ADMIN_PASSWORD_HASH:}. The hash is an argon2id string of the password; the comment in application.yml shows how to make one with the argon2 CLI. With either value blank, every /admin address answers 404.
  • After the password, a 6-digit code is mailed to the siteAdminEmail setting through the existing mail queue and sent straight away.
    • The code is stored hashed in Redis, works for 10 minutes and only once.
    • 5 wrong codes lock the login for the rest of a 10-minute window.
    • A new code can be sent at most once a minute.
  • The session ID changes once the code is accepted.
  • AdminInterceptor guards every /admin route:
    • It sends sessions that haven't passed both steps to the login page.
    • It refuses any POST without the session's form token (_csrf).
    • It marks admin pages no-store and not frameable.
  • A bucket4j filter limits POSTs to /admin/login and /admin/code to 5 a minute and 20 an hour per IP.

Pictures (/admin/images)

  • Lists REVIEW album pictures, avatars, post pictures and link previews, with the image, owner and moderation_category, and buttons to approve or reject each.
  • A decision re-reads the row and only changes it if it is still REVIEW, the way ModerationRetry does.
  • An approved post picture calls entryAttachmentsChanged, and an approved avatar calls profileChanged. Approving a post picture or link preview also clears the recent-posts RSS cache.
  • Held images are served to the admin with no-store, nosniff and a sandbox CSP.

Settings (/admin/settings)

  • Edits the values of existing settings rows; rows can't be added or removed.
  • Values are checked: at most 100 characters, whole numbers for mailPort and tzOffset, true/false for the boolean settings.
  • Rows whose name looks secret (mailPass) are never shown; leaving the field blank keeps the current value.
  • core.Settings gained reload(), which runs after each save commits, so changes apply without a restart. The one exception is WebFinger, which copies baseuri at startup.

Disabling accounts (/admin/users)

  • Flyway V1_32__user_disabled.sql adds user.disabled.
  • A disabled account:
    • can't log in on the web or through XML-RPC/desktop clients, can't finish a pending 2FA login, and can't reset its password. It sees the same error as a wrong password.
    • has its open sessions logged out on their next request.
    • gets a 404 for its journal, feeds, pictures, avatar, public APIs and ActivityPub actor. It is also left out of recent posts, members, the sitemap, search, friends pages and favorites.
    • has federation stopped and its pending deliveries dropped. No Delete activity is sent, so enabling the account restores everything.
  • Disabled accounts are kept in an in-memory list that reloads every 30 s, and immediately after an admin change.

Testing

  • ./mvnw -o package: 1994 unit tests, 0 failures.
  • Not run here (no local MySQL): the IT* tests, the migration, the new JPQL queries, and the pages in a running app. Jenkins will run the ITs.

Known limits, left for later

  • Remote servers keep their cached copy of a disabled actor.
  • Site-wide statistics and NodeInfo counts still include disabled accounts.
  • Pictures already served with public, max-age=3600 can stay in browser and proxy caches for up to an hour.
  • A rejected post picture still tells its owner it is "awaiting review".

🤖 Generated with Claude Code

laffer1 and others added 9 commits November 3, 2026 15:58
Every /admin address answers 404 until admin.username and
admin.password-hash are set, sends sessions that haven't passed both
login factors to /admin/login, and refuses form posts without the
session's token. The login, picture review, settings and account pages
build on this.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The admin can now change the value of any existing row in the settings
table at /admin/settings. Rows are never created, renamed or removed.
Values are trimmed and checked against the 100 character column and the
types the loader parses (mailPort and tzOffset as integers, the flags as
true/false), so a save can no longer leave the site unable to read its
settings. Settings whose names look like credentials (pass, secret, key,
token) are never sent to the browser or logged; posting them blank keeps
the current value.

core.Settings now loads through a public reload(), called at startup and
after each committed change. A reload builds a complete new set of values
and swaps it in, so readers never see a half loaded mix, and a table that
can't be read keeps the current values. Getters and defaults are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
/admin/login checks the configured admin username and argon2id password
hash (cost settings read from the hash, base64 with or without padding,
as the argon2 CLI emits), then mails a six digit code to the site admin
address. /admin/code accepts that code once, within ten minutes and five
tries, and only then marks the session as admin under a new session id.
A missing admin address or a failed send refuses the login outright.
Login and code posts get their own bucket4j limit, and admin.username /
admin.password-hash are read from ADMIN_USERNAME / ADMIN_PASSWORD_HASH.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Pictures the content filter held stayed visible only to their owners with
no way to release them. /admin/images lists held album pictures, avatars,
post pictures and link preview pictures (up to 50 of each), serves their
files to the admin with no-store, and approves or rejects them.

A decision only applies while the row is still REVIEW and keeps the
model's category. Approving does what ModerationRetry does: a post
picture on a post publishes its attachments, an avatar publishes the
profile. Approving a post or link picture on a post also drops the
cached recent posts feed after commit, since that feed carries them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds a disabled flag to user (V1_32) and an Accounts page in the admin
area to search accounts and disable or enable them. A disabled account
can't log in by any route (web, second factor, XML-RPC/desktop clients,
password change or reset), its open sessions are logged out on their next
request, and its journal, feeds, pictures, avatar and fediverse actor
answer 404 until it is enabled again. Nothing is deleted, so enabling the
account restores everything.

A request interceptor checks an in-memory list of disabled accounts,
refreshed every 30 seconds and right after a change, so the per request
check costs no query. Cross-journal listings (recent posts, friends pages,
members, sitemap, site search, favorites) leave the account out, and its
queued fediverse deliveries are dropped when it is disabled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sourcery-ai

sourcery-ai Bot commented Nov 3, 2026

Copy link
Copy Markdown

Sorry @laffer1, your pull request is larger than the review limit of 150,000 diff characters

@laffer1

laffer1 commented Nov 3, 2026

Copy link
Copy Markdown
Collaborator Author

✅ Snyk checks have passed. No issues have been found so far.

Status Scan Engine Critical High Medium Low Total (0)
✅ Open Source Security 0 0 0 0 0 issues

💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.

@deepsource-io

deepsource-io Bot commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

DeepSource Code Review

We reviewed changes in 8e9ac23...4efca43 on this pull request. Below is the summary for the review, and you can see the individual issues we found as inline review comments.

See full review on DeepSource ↗

PR Report Card

Overall Grade   Security  

Reliability  

Complexity  

Hygiene  

Code Review Summary

Analyzer Status Updated (UTC) Details
PHP Nov 3, 2026 9:16p.m. Review ↗
Java Nov 3, 2026 9:16p.m. Review ↗
Shell Nov 3, 2026 9:16p.m. Review ↗
JavaScript Nov 3, 2026 9:16p.m. Review ↗

Important

AI Review is run only on demand for your team. We're only showing results of static analysis review right now. To trigger AI Review, comment @deepsourcebot review on this thread.

return;
}
if (user.isDisabled()) {
log.info("Password reset requested for disabled account {}", username);
private com.justjournal.model.User lookupUser(@NotNull final String userName, @NotNull final String password) {
final com.justjournal.model.User user = matchPassword(userName, password);
if (user != null && user.isDisabled()) {
log.warn("Login refused for {}: the account is disabled", userName);
final HttpSession session = request.getSession(false);
if ("POST".equalsIgnoreCase(request.getMethod())
&& !AdminSession.isValidCsrf(session, request.getParameter(AdminSession.CSRF_PARAM))) {
log.warn("Admin form post to {} refused: missing or wrong token", request.getRequestURI());
return;
}
if (user.isDisabled()) {
log.info("Password reset requested for disabled account {}", username);
} else if (name.equalsIgnoreCase("mailEnable")) {
mailEnable = value.equalsIgnoreCase("true");
} else if (name.equalsIgnoreCase("mailHost")) mailHost = value;
else if (name.equalsIgnoreCase("mailPort")) mailPort = Integer.parseInt(value);
commentMailEnable = value.equalsIgnoreCase("true");
} else if (name.equalsIgnoreCase("commentEnableAnonymous")) {
commentEnableAnonymous = value.equalsIgnoreCase("true");
} else if (name.equalsIgnoreCase("tzOffset")) tzOffset = Integer.parseInt(value);
@laffer1
laffer1 merged commit 296348a into master Nov 3, 2026
9 of 11 checks passed
@laffer1
laffer1 deleted the admin-area branch November 3, 2026 21:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants