71 Commits
Author SHA1 Message Date
ancsemi 9cc230621f Show the whole startup error; no pg download just to refuse (#5827 follow-up) 2026-10-31 01:01:34 -04:00
Slyke 9d226de145 Add database backend adapters and a guarded PostgreSQL preview (#5827)
SQLite stays the default. PostgreSQL startup stays disabled. Thanks Slyke.
2026-10-31 00:32:25 -04:00
ancsemi 5f209316de Audit log: record every admin and moderator action
Adds entries for transfer admin, account deletion, role reset and channel access, message deletes, pins and protection by moderators, channel settings, voice and channel removals, webhooks, whitelist, codes and tokens, emoji, stickers, sounds, tags, backups and restore, imports, updates and admin recovery. Settings record their previous value.
2026-10-22 07:51:56 -04:00
Kamden Bolhuis 6872049e71 Feature: External S3 Media Storage (#5813)
* Stamp deleted attachments with their deletion time on every delete path

The socket handlers carried their own copies of moveUploadToDeleted that
skipped the utimes stamp, so an old attachment deleted from chat kept its
upload time and was purged at the next 15 minute sweep instead of after the
retention window. All callers now share the one implementation in
src/uploadFiles.js.

* Route every upload move and delete through a storage layer

src/storage holds the driver for uploads/ (local disk for now) and keeps a
new media_objects index in step with each write, so a bucket driver can sit
behind the same calls later. Soft deletes, permanent deletes, purges and
restores all go through it; src/uploadFiles.js is gone.

Storage calls are asynchronous, and every caller now awaits them before it
answers or emits: message, bulk, channel, sub-channel, DM and group DM
deletes, kicks from a group, bot deletes, auto-cleanup, the encrypted file
release, the uploads trim, self-destruct, and the sound, emoji, sticker and
badge icon deletes. Self-destruct sweeps no longer overlap when a timer fires
mid-sweep, and stop() keeps a running sweep from re-arming the timer.

The storage layer accepts any path that stays inside uploads/, so names that
keep an uploader's unusual extension still move and delete as before.

* Serve /uploads from the storage layer instead of express.static

src/storage/serve.js reads each file through the storage driver and streams
it out, so the same safety headers will hold for files kept in a bucket. It
answers as express.static did: the same weak ETag, Last-Modified, the 7 day
immutable cache, conditional GETs and 304s, a single byte range with 206,
HEAD, the dotfile refusal, and falling through to the app's 404, with a
failed precondition or an unsatisfiable range still handed to the error
handler. The guard that refuses deleted-attachments/ and bot-audio/ in every
encoding moved into the same module unchanged.

test/uploadsServe.test.js serves one folder through the old express.static
setup and the new handler side by side and compares status, headers and
body for each file type, conditional request, range and refused path.

One difference: a folder path without a trailing slash (/uploads/stickers)
now gets the 404 directly instead of a 301 to the same 404.

* Admit uploads to storage only after their route has checked them

A cleaned upload now waits in unprocessed/staging/ (never served) while its
route checks the magic bytes and fixes the extension, and is then admitted
to storage with storage.admitUpload before anything stores its URL. A
failed admit answers 500 and leaves nothing behind; a restart sweeps any
staged file a cut-short request left.

Covers attachments and files, avatars, borders, bot and persona avatars,
sounds, emoji and stickers (single and bulk), badge icons, the server icon
and banner, role icons, sign-in avatars and the starter sticker pack. Bulk
emoji and sticker uploads report a file that could not be stored as an error
and carry on with the rest. A persona avatar refused as someone else's
persona is now removed instead of left in uploads.

* Read upload sizes from the media index instead of walking the folder

The moderator usage report, the uploads trim, the Files & Media sizes, the
check for files a bot delete may move, and the deleted-attachments purge now
read media_objects rather than stat'ing or walking uploads/, so none of them
needs a listing of the bucket once storage can be one. The report's numbers
are unchanged; the trim still counts the temporary bot audio kept on disk,
as before. The purge counts the retention window from the deletion time the
index records, and the local driver still prunes the sub-folders it empties.

The server only starts listening once storage.prepare has checked storage
answers; a failure exits with a message rather than starting a server that
cannot serve media. On the first start after this change it builds the index
from what is already in uploads/ (src/storage/reconcile.js), once.
scripts/reconcile-media.js runs the same repair by hand. The privacy sweep
keeps the index in step when it rewrites, holds back or returns a file.

* Add an S3-compatible storage driver for uploads

HAVEN_EXTERNAL_STORAGE=true keeps media in a bucket (HAVEN_S3_ENDPOINT,
_BUCKET, _REGION, _ACCESS_KEY, _SECRET_KEY, _PREFIX, _FORCE_PATH_STYLE)
through src/storage/s3.js, behind the same calls as the local driver: put
(multipart over 5 MB), head, ranged reads, soft delete and restore by copy
and delete, and paged listing. Haven still streams every file itself, so the
safety headers are unchanged. Missing settings or a bucket that does not
answer stop the server at boot with the endpoint, bucket and reason.

HAVEN_S3_LOCAL_FALLBACK=true serves files not yet moved from uploads/ on
disk; deleting then removes the local copy too, so it cannot come back.
Without it, files left on disk are counted in a warning at boot.

Checksums are only sent and checked where the API needs them: the SDK's
default CRC32 is rejected by stores such as Garage for files sent in parts.

test/storageS3.integration.test.js runs against a real bucket when
HAVEN_TEST_S3_ENDPOINT is set (passes on Garage 1.1.0) and is skipped
otherwise. Not documented for operators yet: Ferry, badges, templates and
backups still read uploads/ directly.

* Clean photos in the bucket, release replaced profile pictures, reconcile on demand

The privacy sweep has a pass of its own for media kept in a bucket: it goes
by the media index, cleans each photo and video in unprocessed/ and writes
it back only when cleaning changed it, and holds back a file it cannot clean
the same way it does on disk. Its progress is kept per driver, so moving to
a bucket checks everything there once.

A replaced or removed avatar or border, and a persona's avatar when it is
replaced or the persona is deleted, now go to deleted-attachments instead of
staying in uploads forever, but only when the file is that person's own
upload and nothing else still shows it. A persona can point at anyone's
upload, so the ownership check is what keeps one person from taking another
person's file. Border uploads now record their owner as avatars do.

Admins can rebuild the media index from storage with
POST /api/admin/media/reconcile, the same repair as
scripts/reconcile-media.js.

* Read uploads through storage everywhere, and move servers to a bucket

The last readers of uploads/ on disk now go through src/storage:

- Badge icons are keyed from storage (imageIconKey); built-in and emoji keys
  stay synchronous.
- Ferry checks Discord's size limits against the media index, then reads each
  file's bytes once, so a retry no longer reads the files again.
- Server templates read and write images through storage. exportTemplate and
  applyTemplate are now async; images still go in before the database
  changes and are removed if those fail. tools/template.js uses the target
  server's own storage, bucket included.
- Backups with files stream them out of the bucket one at a time. A restore
  marks the restored database so the next start rebuilds the media index,
  and on a bucket first copies the restored files into it
  (media_restore_pending), so the restore no longer has to send gigabytes
  in the moment before it restarts.

scripts/migrate-uploads-to-s3.js moves an existing server's files into the
bucket while it stays up (with HAVEN_S3_LOCAL_FALLBACK=true), skips what is
already there, and with --delete-local frees the disk only once the bucket
holds everything. GUIDE.md, docker-compose.yml and .env.example now document
external storage.

Tested against Garage 1.1.0: a live move from disk to the bucket with
nothing going missing, and a backup taken on the bucket restored back into it.

* Let the media scripts read the server's .env

migrate-uploads-to-s3.js and reconcile-media.js only saw HAVEN_S3_* when
they were set in the shell, so on an install that keeps its settings in the
data folder's .env, as a non-Docker install does, the migration refused to
run. They now load that .env the way server.js does; anything set in the
shell still wins.

* More documentation in .env.example
2026-10-20 18:03:57 -04:00
5f8caf6bf8 [safety] Remove location data from every uploaded photo and video (#5805)
* Remove location data from every uploaded photo and video

Photos and videos can carry the GPS coordinates of where they were
taken, and Haven kept whatever arrived: anyone who could see such a
file could find where it was taken. Not every upload has one. Android
zeroes the location for apps without its media location permission,
and a photo taken before the phone knows where it is has none. But
files uploaded from a computer, or through an app that has that
permission, keep their coordinates, and so may photos from an iPhone
(not tested). Every photo and video now has its location removed
before anyone else can see it, and the ones already on the server are
cleaned once. There is no setting to turn this off.

New dependencies: exiftool and ffmpeg
  Both are now required. They are tried and true, kept up to date as
  phones and cameras change their formats, and thorough, which is the
  whole job here. exiftool knows every place a device hides a location
  (EXIF, XMP, IPTC, maker notes, Apple and Android video tags, the
  location tracks dashcams and action cameras record) and can read and
  write all of them. ffmpeg rewrites a video keeping only its picture
  and sound.

  Haven looks for each in HAVEN_EXIFTOOL_PATH / HAVEN_FFMPEG_PATH, then
  the data folder's tools/, then the PATH. When one is missing it
  downloads a pinned version from GitHub into tools/ and checks it
  against a fixed SHA-256 before using it: ExifTool 13.59 from its
  author's repository (byte for byte the exiftool.org release; it needs
  Perl, which Linux and macOS ship), ffmpeg from the ffmpeg-static
  b6.1.1 release. ExifTool's Windows build is not on GitHub, so Windows
  installs it by hand. HAVEN_NO_TOOL_DOWNLOAD=1 turns downloading off.
  The Docker image installs both from Debian.

What happens to a file
  1. exiftool reads it. The type comes from the contents, not the name.
     Anything that is not a photo or video is left alone (PDFs, audio,
     voice messages, encrypted DM files).
  2. Nothing to remove (no location, XMP or IPTC) means no rewrite.
  3. Photos: exiftool deletes the GPS tags, every location tag, all XMP
     and all IPTC, without re-encoding, so pixels and rotation are
     untouched. If anything survives, a second pass keeps only rotation
     and the colour profile.
     Videos: ffmpeg copies only the video and audio streams into a new
     file with no metadata, without re-encoding. Dropping every other
     stream is what removes the separate location tracks iPhones and
     action cameras add.
  4. exiftool reads the result again, embedded tracks included. Any
     location left means the file is refused, never let through.
  Files are rewritten under a temporary name and swapped in with a
  rename, so a crash leaves the old file or the clean one, never both.
  A rewrite keeps the file's original date, so retention and cleanup
  still count from when it was uploaded.

New uploads
  Uploads land in data/unprocessed/, which is not served, and only move
  into uploads/ once clean, so a dirty file is never reachable, not
  even for a moment. This covers every upload route: chat files and
  pictures, avatars and borders, server icons and banners, role and
  badge icons, emoji, stickers, and avatars fetched from a sign-in
  provider.
  - A photo or video whose extension lies (image.bin) gets its real
    one, on disk and in the name shown in chat, so it displays inline.
  - The progress bar fills once for the upload, then again as
    "Removing location data..." following ffmpeg's progress on videos.
    Photos take a moment, so they only change the label. Progress
    reaches the uploader over the socket, so the upload endpoint and
    the clients that use it are unchanged.
  - Cancelling during that step stops the tool and discards the file.
  - New uploads always go first: the sweep waits while one is cleaned.
    At most two videos are cleaned at once, and the tools run at the
    lowest CPU priority so chat and voice come first.
  - A file that cannot be cleaned is refused, deleted, logged to the
    console, and the uploader is told why:
      "Your file contains GPS data that Haven failed to remove. Please
      try again or remove the GPS data manually" (location still there)
      "Haven couldn't process this file. Please try again or upload a
      different file." (the tool itself failed)
  - Without the tools, photo and video uploads are refused with "Photo/
    Videos uploads are disabled due to a misconfiguration of the server";
    chat uploads check this before sending anything. Other files still
    upload. Startup logs the problem, and installing the tools reopens
    uploads within five minutes, without a restart.

Files already on the server
  A one-time sweep at startup cleans every photo and video in uploads/,
  deleted-attachments included (those can be restored), where it is.
  Old files keep being shown as normal while it runs, and it resumes
  where it left off after a restart. A file it cannot clean is moved to
  unprocessed/held/, out of reach, and retried after the sweep, up to
  three more times, one file at a time. After that it is listed in
  unprocessed/held/failed.json with the tool's error and the messages
  that use it. Held files are never deleted automatically: a retry that
  works puts the file back, otherwise an admin decides.
  In chat, a held file is taken out of its message and the message gets
  one note under whatever still shows, starting with a lock:
    "This file is still being processed." while retries remain,
    "This file couldn't be processed." after,
  and counts when more than one file is held ("1 file is still being
  processed. 2 files couldn't be processed."). The message text stays.
  A picture group re-lays itself out around the files that remain; a
  single picture left shows like a lone picture, a single video keeps
  its tile so it still plays.

What this cannot reach
  - Copies people already downloaded, copies Ferry already sent to
    Discord, and the server's backups.
  - Encrypted DMs: their files reach the server already encrypted. Each
    DM now shows a dismissable notice saying so and asking people to
    turn off location on their camera and check what they send.

Testing
  Check a photo really has a location before uploading it:
    exiftool -a -G1 -gps:all -c "%.6f" photo.jpg
  Real coordinates show as [GPS] GPS Latitude / GPS Longitude lines.
  Without -a, exiftool hides them: it keeps only its own calculated
  copy of each, which -gps:all then filters out, so a photo with a
  location looks like it has none.

  Where uploads come from matters on Android. Since Android 10, an app
  reads photos with the location zeroed out unless it declares
  ACCESS_MEDIA_LOCATION, shown in its permission list as "read
  locations from your media collection".
  - Haven Mobile asks for photo and video access but not for that
    permission, so on Android 10 and later every photo it sends
    arrives with its GPS already zeroed by Android. Videos were not
    tested but are assumed to be the same case.
  - Firefox for Android does ask for it, and a photo uploaded through
    Firefox was confirmed to reach Haven with its GPS intact. Test
    from a browser like that, or from a computer.
  - iPhone was not tested.

  Then upload it, open the stored file in data/uploads/ (or download
  it from chat), and run the same command: no location should come
  back.
  To see a held file, cut a phone photo that has GPS short, inside its
  metadata (head -c 2000 usually is), and drop it in uploads/ before a
  first start: exiftool can still read the location but cannot rewrite
  the broken file, so it is held, retried and listed in failed.json.
  test/privacyPass.test.js covers detection, renaming, and cleaning a
  photo and a video. The cleaning tests and the image upload tests need
  exiftool and ffmpeg installed; without them they skip or expect the
  refusal, and test servers never download the tools.

Translations added for German, Persian, Japanese, Korean, Portuguese
and Chinese; Spanish, French, Polish and Russian get the progress label
(their other strings in these sections are still English).

* Leave Persian out of the location data strings

The translations validator fails any change to fa.json, which has no
_meta.flag, and choosing a flag for Persian is not this change's call.
Persian shows the new privacy pass text in English until that is
settled.

* Bump cache tags above main

---------

Co-authored-by: bo0sted <mc526+github@proton.me>
Co-authored-by: ancsemi <ancsemi@gmail.com>
2026-10-17 18:12:03 -04:00
ancsemi d85c53bdfd Hand the SSO consent page its origin as a checked JSON string 2026-10-15 01:08:28 -04:00
ancsemi b6f6c12e97 Keep the owner able to sign in while strangers guess; limit every password check 2026-10-15 00:55:30 -04:00
ancsemi 4871edee0d Finish an SSO login only in the browser that started it 2026-10-15 00:39:57 -04:00
ancsemi 869b78688e Session tokens carry their version; sign-in checks fail closed; SSO and 2FA gaps 2026-10-15 00:25:29 -04:00
ancsemi 64145e67a0 Harden sign-in: 2FA setup asks for the password, codes work once, safer recovery 2026-10-14 23:55:42 -04:00
ancsemi 1ff2d0d986 Keep the admin username reserved while the server has an admin 2026-10-14 23:41:21 -04:00
ancsemi 3218932aea Recovery codes need a full session 2026-10-14 23:39:04 -04:00
ancsemi 92738c2b09 Replace em dashes in server code comments and scripts 2026-10-10 16:08:55 -04:00
ancsemi 0b8c04e708 Replace em dashes in the English UI text and messages 2026-10-10 16:08:54 -04:00
ancsemi aa56435ee7 Link Server sign-up lets you change the username when it is taken here (#5734) 2026-10-05 17:16:07 -04:00
Matt eb220e9189 Outgoing proxy support for servers behind a proxy (#5710)
For servers that can only reach the internet through a proxy: set
https_proxy, http_proxy and no_proxy in .env and Haven's outgoing requests
(link previews, the media proxy, bot callbacks, push notifications, update and
DDNS checks) go through it. Only http:// proxy addresses are supported, and
voice is unaffected.

With no proxy set, nothing changes. With one set, Haven warns at startup that
the proxy becomes part of its security: requests through it cannot be pinned
to the address Haven checked, so the proxy should be limited to the
destinations Haven needs. .env.example and GUIDE.md ("Outgoing Proxy") explain
it. Proxy passwords are hidden in the log, HTTPS certificates are checked
against the real target, and proxied requests time out and can be cancelled.
2026-10-02 12:25:26 -04:00
ancsemi 6d9a76eec5 Every silent catch in the server now either reports the error or says why ignoring it is safe
Went through all of them (about 220 across server.js and src/). The ones
that could leave data, settings or files wrong without anyone knowing now
log a short warning: failed file moves and deletes after a delete or
restore, backup entries that could not be added, startup steps like the
sticker folder or the Referrer-Policy, auto-role grants on join, audit log
writes, attachment tags dropped on send, webhook delivery status, the AFK,
temp-channel and disk-space timers, DM call and push lookups, Discord import
files that could not be parsed, Ferry's loop guard and automod checks, the
media cache folder and the one-time data folder migration. Ones that repeat
on busy paths log at most once every few minutes.

The rest are expected failures (a temp file already gone, a socket already
closed, untrusted input with a fallback) and each now carries a comment
saying so instead of a bare catch {}. Two guards that could never fire were
removed. No bare catch {} is left in the server code.
2026-10-02 07:36:58 -04:00
ancsemi 8c3ae6edea Checks that used to let things through when an error was swallowed now refuse instead
Found while going through every silent catch in the server:
- a personal "deny" on a permission was skipped if reading it failed, so a
  role could still grant it (server routes, socket permission checks, and the
  list that decides what a non-admin may hand out to others)
- deleting your own messages, singly or in bulk, went ahead when a deny on
  that could not be read
- a banned account was given voice relay (TURN) credentials if the ban
  check errored
- link previews went ahead for hosts the admin had not allowed if the
  automod gate errored, which is the IP leak that gate exists to stop
- the FCM privacy switch stayed on if it could not be read at startup
- a login could be issued with no expiry if the session length setting
  could not be read; it now falls back to 7 days
- someone who turned activity sharing off, or went invisible, could have
  their activity shown if their preference could not be read
- a failed read of the IP ban list cleared every IP ban for 30 seconds; the
  last known list is kept instead
- a backup restore overwrote the live database even when saving the safety
  copy failed, and that copy could miss writes still in the WAL; it now
  checkpoints first and stops if the copy cannot be made
- a corrupt android-beta-signups file was read as empty and the next signup
  wiped the rest
- failing to remove ADMIN_RESET_PASSWORD from .env (which resets the admin
  password on every start) is now reported with what to do
2026-10-02 07:35:20 -04:00
ancsemi 1f63295e01 The Admin role follows admin: a new server gets the role and its first admin wears it, and whoever becomes admin later (a transfer, admin recovery, the ADMIN_USERNAME bootstrap) is given it too. Servers converted before this keep the role they already have (#5707) 2026-09-30 21:36:25 -04:00
Amnibro b6a0367771 Single sign-on: the consent page runs again (#5696) 2026-09-25 21:32:48 -04:00
ancsemi 73285c0d9d Settings > Encryption: lock the encrypted-DM key backup with a passphrase of your own instead of your login password, which the server receives at every sign-in; sign-in then asks for the passphrase, and a password change leaves the backup alone 2026-09-24 00:25:52 -04:00
ancsemi 89152e2511 Admin recovery asks for the two-factor code when the admin account has it on; the password alone returned a full admin session 2026-09-23 21:54:13 -04:00
ancsemi 90d9468cc1 Login: only a real session token counts as a login. The two-factor challenge token, issued before the code is entered, could set a new password and come back with a full session; the forced password change now also needs a pending change on the account 2026-09-23 21:50:01 -04:00
ancsemi fbacc084ba Two-factor: wrong authenticator codes are limited per account, 10 in 15 minutes, as well as per address 2026-09-23 21:42:53 -04:00
ancsemi ad5efa7e98 Single sign-on: the handoff page escapes the session data for the script it sits in, and a provider's display name goes through the same character rules as one typed in Haven 2026-09-23 21:32:07 -04:00
ancsemi 41549d1487 Count invite redemptions on the link so a deleted account cannot hand one back (#5562) 2026-09-07 13:01:04 -04:00
ancsemi 39d56b4a8d Stop registration handing out admin when the server already has one
ADMIN_USERNAME is meant to bootstrap the first admin on a new server and
nothing more. Login and the socket handshake both treat it that way: they only
promote the account while the server has no admin at all. Registration did not
check, it promoted on the username alone.

That gap only opens once the name is free again. Rename the admin account, or
remove it while another admin holds the server, and the next person to register
the old name arrives as a second admin standing over the real one. On a server
with open sign-ups that is anyone who reads the compose file.

Registration now runs the same check as the other two. A brand new server has
no admin to find, so first-run setup is unchanged, and a server that has
genuinely lost every admin can still re-bootstrap the same way it always could.

Verified both directions against a running instance: with an admin present the
new account comes back is_admin=0, and with none present it still comes back
is_admin=1.
2026-08-29 16:14:22 -04:00
ancsemi 45ee86f7da Fix account linking for anyone who has changed their password
Steam and Spotify linking failed with "Link session expired" for any account
whose password_version had moved past 1: a password change, an admin reset, or
a recovery code. Fresh accounts were fine, which is why it looked intermittent.

The short-lived token these redirects carry was the only one Haven mints that
left out pwv. verifyToken checks that field on every token carrying a user id
and reads a missing one as 1, so the token looked like it predated the
account's first password change and got rejected on the way back.

It now carries the real value, which also keeps the property the check exists
for: changing the password mid-flow still invalidates the link in progress.

Closes #5527
2026-08-26 19:55:16 -04:00
ancsemi 1b54046820 Add an active sessions list and a sign-out-everywhere button 2026-08-23 23:39:58 -04:00
ancsemi 10cd8d4f5e Tidy up comments and whitespace after #5508 2026-08-21 20:16:35 -04:00
birdcrazy 6766a1f847 Invite Link Fixes (#5508)
* Land on the "Register" tab when an invite link is detected.

* Add clarifying comment for invite-link tab switch

* Hide reg-token-group when an invite link is used.

* Bypass registration-token requirement for validated invite links.

* restrict default invite link expiry and usage options
Change default invite link optionsTo something more restrictive than unlimited-uses, no-exiry
This prevents accidental creation of a unlimited invite.

* Clarify error message for registration attempts with a pre-exitings username.

* Only gate invite-link registration when server requires the registration token

* Make user-already-exists error to be slightly vague

When a user tries to register with a username that already exists, change the displayed error message to be specific enough that it prompts the user to change their username in the form, but vague enough that it doesn't explicitly reveal the usernames that already exist to an attacker probing.

However, since this is the only place in the code that this specific error message is shown, an attacker who has access to the code can determine that this error actually means that the username already exists. This was also possible with the previous error message(s)

Signed-off-by: birdcrazy <227081272+birdcrazy@users.noreply.github.com>

* rework invite-bypassing-regToken flow
- Added a setting to allow the admin to allow or disallow the invite links to bypass the registration token.
    - Backend enforces the setting when validating registrations.
    - Frontend uses the setting to determine registration token field visibility.
- Updated registration flow to handle invalid invite links without redirecting.
  - Clears the invalid pending invite from sessionStorage and URL.
  - Re-evaluates registration requirements and exposes the registration token field when required.
  - Preserves the user's entered registration form data.
- Updated app redirect URL handling to prevent stale invite codes from being reused after an invalid invite.

* Fix inverted logic on token field visibility

* Consume the invite if it was used for registration

Signed-off-by: birdcrazy <227081272+birdcrazy@users.noreply.github.com>

* inviteRow initialization correction

Signed-off-by: birdcrazy <227081272+birdcrazy@users.noreply.github.com>

* Enforce that passwords don't contain the username for security
Passwords that equal, or contain usernames are easier to guess.
Better to prevent this potential secuity risk before registering.
Performs the password check both on registering, and on password changed

(Also removes redundant check in register flow)

---------

Signed-off-by: birdcrazy <227081272+birdcrazy@users.noreply.github.com>
2026-08-21 20:16:04 -04:00
ancsemi d63f33e9b5 Keep OIDC account identity stable across issuer spellings (#5501)
Follow-up to #5503, which stopped Haven rewriting the configured issuer.
Two gaps were left behind:

- Discovery compared the document issuer against the configured string
  exactly, so an admin who omits a trailing slash their provider does
  publish still hit provider_unreachable. The comparison is now canonical
  (trailing slash only). Every other difference still fails.
- cfg.issuer is the identity key for federated accounts. Leaving it
  verbatim meant editing the trailing slash in settings stopped matching
  existing rows, silently creating a second account for a returning SSO
  user. Accounts now key on the canonical form via cfg.issuerKey.

ID tokens are verified against doc.issuer, the provider's own spelling,
which discovery has already proven equals the configured issuer.
2026-08-18 20:58:06 -04:00
ancsemi 4b33775c98 Add OIDC sign-in, with an encryption passphrase for SSO accounts (#12)
Members can sign in with an account from an external provider (Authentik,
Keycloak, Authelia, Auth0, anything that speaks OpenID Connect) instead of a
Haven password. Authorization Code flow with PKCE, state and nonce, and a
verified ID token signature.

The thing that has blocked this since February is that encrypted DMs derive
their key from what you type at login, and an SSO member never types a
password here. They now set a separate encryption passphrase on first sign-in
and enter it again on each new device. It is stretched with the same PBKDF2
parameters as a password and lands in the same place, so nothing downstream
of the login page changes. It never reaches the server in any form, which
means nobody can reset it, and the admin panel says so plainly.

Notes on a few decisions:

- No new dependency. Discovery and the token exchange are fetch calls, and a
  JWKS key imports straight into Node's crypto for jsonwebtoken to verify. A
  full OIDC library would add dozens of transitive packages to a project whose
  pitch is that you can read what you are hosting.
- The client secret is read from OIDC_CLIENT_SECRET and is deliberately not a
  database setting, so a backup never carries an identity-provider credential.
- A federated account never inherits admin from the directory, and never
  adopts an existing local account: a colliding username gets a suffix. Both
  are account-takeover paths otherwise.
- The session is handed to the login page through the document, not the URL,
  so it stays out of history, proxy logs and Referer headers.
- Account linking and RP-initiated logout are not here. Linking especially
  wants its own design; adopting an account on an email match is not safe.

Verified against a local provider: PKCE and nonce enforced end to end, replayed
callbacks refused, ID tokens signed by another key or with alg none/HS256,
an unknown kid, or a mismatched nonce all rejected, and a directory user
claiming the name "admin" lands as a plain account. Cross-device: first login
sets the passphrase and uploads the wrapped key, a second device unwraps the
same identity, and a wrong passphrase leaves the stored key untouched instead
of regenerating over it.
2026-08-10 00:47:22 -04:00
ancsemi b29cf7fecf Add ban appeals: show reason at login and let users appeal (#5457) 2026-07-28 22:18:42 -04:00
ancsemi e065e9e7e0 Bulk cleanup checkbox vetting + optional registration rate limit
Verified end-to-end against a throwaway HTTP instance.

Bulk cleanup: the preview now returns the full candidate list (up to 2000)
with per-account message counts, rendered as checkboxes (all checked, an
activity flag on accounts that have posted). The admin unchecks any real
users, and commit deletes exactly the selected IDs — the server re-applies
the hard exclusions on the committed list. Fixes the staff-exclusion guard,
which excluded EVERY account because it matched the default auto-assign
role given to all users on registration; it now only shields accounts with
a deliberately-granted (non-auto-assign) role.

Rate limit: opt-in global cap on new accounts per rolling hour (default 20)
to blunt a bot wave spread across many IPs, enforced in /register and
configurable in the admin settings. Off by default.
2026-07-27 12:46:54 -04:00
ancsemi c3c7ecd45b Add opt-in Cloudflare Turnstile CAPTCHA on registration
New admin setting (Settings > registration section) to require a Turnstile
challenge on sign-up, to blunt automated mass registration on public
servers. Off by default; admin pastes a site key and secret key.

- Server verifies the token against Cloudflare in /register (fail-closed:
  any verification error rejects). Applies to both standard and SSO
  registration so there's no bypass, since SSO signup isn't separately
  verified server-side.
- /registration-info exposes only the public site key; the secret never
  leaves the server and is admin-only in get-server-settings.
- Registration page loads the Turnstile widget and sends the token; the
  widget resets on a failed attempt (tokens are single-use).
- CSP: allow challenges.cloudflare.com in script-src and frame-src.
2026-07-27 10:49:58 -04:00
ancsemi 5aead216f1 Persistent welcome messages for new members
Welcome messages were live-only: drawn client-side on the user-joined
socket event, never saved. They only appeared for people actively viewing
the exact channel at that instant, vanished on reload, and re-fired every
time a member reconnected (so they looked random and out of order).

Now a welcome is posted once, when a member registers, as a saved message
(type='welcome') in every channel flagged show_welcome. It stays in history
for everyone, like Discord.

- messages.type + channels.show_welcome columns (safe ALTER migrations)
- registration posts the welcome to enabled channels + live broadcast
- get-messages serializes type; client renders type='welcome' as a
  non-interactive welcome line (history + live)
- per-channel "Welcome Messages" toggle in Channel Functions (admin only),
  default on for the server's first/default channel
- gated on the existing admin welcome_message template (empty = off)
- i18n for the new toggle in all 7 locales
2026-07-23 21:37:12 -04:00
ancsemi f540ce5e75 feat: rich presence — show what people are playing and listening to
Adds an activity system with four sources:

- Haven's own voice-channel music player (no external calls, no setup)
- Last.fm (username + one server API key, no OAuth) — the recommended music
  source, and the one that also covers Spotify, Apple Music, YouTube Music,
  Navidrome and Plex listeners, since those all scrobble
- Steam (OpenID link, one batched GetPlayerSummaries call for the whole server)
- Spotify (full OAuth) — kept for people who specifically want it, but its
  development-mode allowlist caps it at roughly 25 users, so it sits under
  "Other options" rather than being offered first

Display rules: the member list shows at most one activity per person with
games taking precedence, so the sidebar stays scannable. The profile card
shows a row per activity that is actually present, so someone doing both gets
both and someone doing neither gets no section at all.

Privacy:
- Activity is in memory only. It never touches the database, so no play
  history accumulates for anyone.
- Sharing is on by default but nothing appears until a user links an account,
  which is the real consent gate. Games and music can be muted separately, or
  sharing turned off entirely.
- Invisible status suppresses everything regardless of preferences.
- Polling only covers connected, opted-in users, so an idle server makes no
  external calls at all.

Credentials:
- OAuth tokens are stored AES-256-GCM encrypted, keyed off JWT_SECRET, and are
  removed with the account.
- Admins can paste API keys straight into Settings rather than editing .env by
  hand. envStore.js allow-lists exactly which keys are writable and rejects
  newlines, so that field cannot reach JWT_SECRET or smuggle a second variable
  into the file.
- Link flows run in a popup and end on a self-closing page, so the app window
  is never navigated away, and the server pushes the result to every socket the
  user has open — the callback often completes in a different browser entirely.
2026-07-18 17:24:10 -04:00
ancsemi fa9b42fc96 Guest mode: grant sub-channels and voice rooms individually (#5401)
The guest-channels picker only listed top-level channels, and the auto-join
cascaded to public sub-channels of a whitelisted parent. So private sub-channels
were never reachable, and voice rooms the guest wasn't a member of failed with
"Not a member of this channel" (the server already lets guests use voice once
they're a member).

Now the picker lists every channel individually — parents, sub-channels, and
voice rooms — each with private/voice tags, grouped under their parent. Ticking
a sub-channel auto-ticks its parent (a sub only renders nested under a visible
parent); unticking a parent unticks its subs. The server joins exactly the
selected ids and never cascades, so admins control precisely what guests see.
2026-06-26 22:46:46 -04:00
ancsemi 33b5d9f552 Fix guest subchannel access and voice join (#5401) 2026-06-03 10:57:18 -04:00
ancsemi 72bcd34088 feat(auth): add 'Never expire' option to session duration (#5391)
session_duration_days now accepts 0 to mean 'never expire' (JWT signed with no exp claim). The admin settings UI becomes a dropdown (Never / 1 / 7 / 30 / 90 / 365 days) instead of a number input, and new installs default to Never. Existing installs that were seeded with '7' on older versions keep that value until the admin picks something else. Was a common complaint after #5391 where users kept getting silently logged out after the default 7-day expiry. Self-hosters who actually want short-lived tokens can still pick a value; the rest of us don't have to worry about it.
2026-06-01 00:31:08 -04:00
ancsemi b6b95bdea8 feat(guests): add Join-as-Guest mode with per-channel whitelist (#5381)
- New admin settings (guests_enabled, guest_channels CSV) wired through
  admin.js allowedKeys + validation.
- Schema: users.is_guest migration.
- /api/auth/guest-info + /api/auth/guest-login REST endpoints; guests
  get a real users row (is_guest=1), unusable random password hash, 12h
  JWT, and are auto-joined to admin-whitelisted channels (DMs skipped).
- Socket middleware loads is_guest, disconnect handler deletes the row
  ~5s after the last socket drops (frees the username).
- start-dm and join-channel socket events reject guests.
- Login page exposes a Join-as-Guest button when guests-enabled flag
  is set; guests do not derive an E2E wrap key.
- DM pane + split handle hidden client-side for guests.
- Guest badge in member list and message header.
- Admin panel: toggle + channel whitelist UI in Settings -> Admin.
- Bump 3.18.3 -> 3.19.0; CHANGELOG covers #5381 feature and #5398 fix.
2026-05-31 15:29:23 -04:00
ancsemi 90c65dec68 feat(#5300): MFA gate + DM-preservation escape hatch + admin reset UI
Extends Amnibro's #5318 admin password reset:

- Require target 2FA before admin reset is allowed (mfa_required gate)

- New temp_password_hash column so original password stays valid (DM wrap key preserved); login accepts either hash, original silently cancels reset

- /api/auth/change-password-required now accepts optional oldPassword to cancel reset from the forced-change screen without rotating password_hash

- New admin gear-menu Reset Password entry with confirm + reveal modals (DM warning, MFA warning, copy-to-clipboard temp pw, single-use display)

- New forced change-password form on the login page with expandable I remember my original password section that funnels the user into the DM-preservation path
2026-05-24 20:24:11 -04:00
ancsemi 90512d3fda Merge PR #5318 (Amnibro): admin password reset to one-time placeholder
# Conflicts:
#	CHANGELOG.md
#	src/database.js
#	src/socketHandlers/admin.js
2026-05-24 20:15:55 -04:00
ancsemi 6df91c29f9 fix: raise body-parser limit on PUT /user-servers to 96 KB
Global limit is 16 KB (intentionally tight), but the server-list sync
blob is legitimately allowed up to 64 KB per the route's own validation.
Users with large server lists were getting a 500 PayloadTooLargeError
before the request even reached the route handler. Apply a per-route
express.json({ limit: '96kb' }) override to match the actual ceiling.
2026-05-07 13:37:30 -04:00
ancsemi 6c8de03498 feat(#5344, #5345): registration token + default channels for invite joiners
#5344 Registration Token: New admin-controlled token gate that sits alongside (or instead of) the username whitelist. When 'registration_token_enabled' is true, anyone signing up must enter the 16-char hex token the admin generated. New GET /api/auth/registration-info endpoint (returns just a boolean — never the token itself) so the registration page can reveal the field on demand. Token check runs before whitelist so both can apply. Generate / Reroll / Copy / Clear buttons live in the Whitelist admin section.

#5345 Default channels for invite joiners + private-channel safety: When someone joins via the server invite code or vanity link, admins can now curate exactly which public channels they auto-land in via a checkbox list. Empty selection = every public channel (default behavior). Just as importantly, the auto-join SQL now filters out is_private parents AND code_visibility='private' parents — previously a private top-level channel was unlocked alongside everything else, contradicting the original intent. Both vanity-code and server-code branches share the same _resolveAutoJoinChannels / _doAutoJoin helpers so the rules can never drift.
2026-05-06 19:40:54 -04:00
Amnibroandancsemi 75fb915589 feat(#5294): admin-configurable login session duration (1–365 days) (#5317)
Issue request: extend the hard-coded 7-day login token lifetime so users
don't have to sign back in as often.

Server:
- src/database.js: new default `session_duration_days = '7'` row.
- src/socketHandlers/admin.js: add `session_duration_days` to the
  allowedKeys list and validate `1 <= n <= 365`.
- src/auth.js: small `_sessionExpiresIn()` helper reads the setting at
  sign time and falls back to '7d' on any DB error so a missing row or
  bad value can never break login. All 7 `expiresIn: '7d'` JWT signing
  sites (login, refreshSession, TOTP confirm, password change, recovery,
  signup, signSession helper) now use the helper. Default preserves
  current behavior.

Client:
- public/app.html: new "Login session duration (days)" number input
  under Settings → Uploads & Limits, range 1–365, default 7, with a hint
  explaining that existing tokens keep their original expiry.
- public/js/modules/app-admin.js: load + save + snapshot + revert paths
  mirror the surrounding number-input fields (max_upload_mb,
  max_emoji_kb, max_poll_options).

Out of scope (intentional, leaves a clean follow-up):
- "Stay logged in" opt-in checkbox on the login form (so users can pick
  short or long sessions per device) — separate UX feature; needs a
  decision on whether to also rotate refresh tokens.
- Cookie-refresh-on-use to extend an active session without re-login —
  requires a refresh endpoint and a security review of token rotation
  rules.

Co-authored-by: ancsemi <copilot@anonymous>
2026-05-05 12:34:36 -04:00
ancsemi 73689b4cf7 3.10.12: auth rate limiter scoped to credential routes only, not token validation (#5323) 2026-04-30 00:32:26 -04:00
Amnibro a36388a009 feat(#5300): admin password reset to one-time placeholder (opt-in)
Server-side:
- New `admin_password_reset_enabled` setting (default 'false', allowed
  values 'true'/'false'). Default-off because the issue rightly raised
  trust concerns; admins must explicitly opt in.
- `users.must_change_password` migration (INTEGER DEFAULT 0). Set to 1
  by the reset flow, cleared when the user picks a new password.
- New socket event `admin-reset-user-password` in admin.js. Admin-only,
  gated on the setting. Generates a 16-hex temp password formatted as
  XXXX-XXXX-XXXX-XXXX, bcrypt-hashes it, bumps password_version (so
  existing JWTs for the target are invalidated by the existing pwv
  check), sets must_change_password=1, and returns the plaintext temp
  password to the calling admin once. Audit-logged as
  `admin_password_reset`.
- Login response now includes `mustChangePassword` so the client can
  route the user through a forced change-password screen before doing
  anything else.
- New `POST /api/auth/change-password-required` endpoint: takes a
  valid token + new password (8–128 chars), updates the row, clears
  the flag, and returns a fresh JWT so the client doesn't have to
  re-login.
- `/api/public-config` now exposes `admin_password_reset_enabled` —
  this is the user-visible disclosure half of the feature: anyone
  (including unauthenticated visitors on the login page) can see
  whether the admin on this server has this power, so users can
  decide whether to trust the server before signing up.

Client (admin toggle UI only):
- public/app.html: new checkbox under Admin Settings → Members &
  Visibility with a warning about the E2E impact (resetting the
  password derives a new wrap key, so encrypted DM history that
  depended on the old key becomes unrecoverable on the user's side
  — same behavior as the existing recovery-codes flow).
- public/js/modules/app-admin.js: load / save / snapshot of the new
  setting, mirroring the existing update_banner_admin_only checkbox.

Out of scope (intentional follow-up):
- Actual "Reset password to placeholder" button on the moderation
  modal — needs its own confirm modal showing the warning, plus a
  one-time-display modal for the temp password the admin then hands
  to the user out of band.
- Client-side mandatory change-password screen that fires when a
  login response carries mustChangePassword=true.

Both are wired against the same socket event / endpoint already
implemented here, so the follow-up is purely UI.
2026-04-29 17:39:01 -04:00
ancsemi 9f2f4dfd74 SSO consent: tighten validate timeout (4s) and watchdog (5s), fall back to cached profile if validate is slow 2026-04-21 09:29:04 -04:00