Commit Graph

78 Commits

Author SHA1 Message Date
wmantly a52f5ce53d Merge pull request #45 from theta42/nav-unification
Unify nav: merge Admin+Notifications into Dashboard, fold API Tokens into Profile
2026-07-14 23:48:55 -04:00
wmantly 2788dcd796 Unify nav: merge Admin+Notifications into Dashboard, fold API Tokens into Profile
- Replace the separate Profile/API Tokens nav items with a single link
  showing the logged-in user's name, pointing at their own profile.
- Merge admin.ejs + notifications.ejs into a new dashboard.ejs page.
  /admin and /notifications now 301-redirect to /dashboard.
- Fold the API Tokens page into profile.ejs as a self-service-only
  section, gated on isOwnProfile so it never appears when an admin
  views another user's profile via /users/:uid. /api-tokens 301s to /.
- Fix: the section must not carry class="row" — app-base.js runs a
  page-wide $('div.row').fadeIn() on every page load that would reveal
  it regardless of the isOwnProfile check, since it fires before this
  page's own gating logic.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-14 23:42:28 -04:00
wmantly f45349e0cd Fix uid/gidNumber allocation crash, add a configurable id floor (#44)
Reported: creating any user via the API failed with

  {"name":"InvalidSyntaxError","message":"gidNumber: value #0 invalid per syntax Code: 0x15"}

Root cause: addPosixGroup() computes the next gidNumber as
`Math.max(...groups.map(i => i.gidNumber)) + 1`. theta-env's
bootstrap.js creates the first admin via raw ldapadd with a hardcoded
uidNumber/gidNumber (10000) directly on the user entry, but never
creates a matching posixGroup entry -- so on a theta-env-bootstrapped
directory there are zero posixGroup entries, `Math.max()` on an empty
array is `-Infinity` in JS (not 0), and `-Infinity + 1` stringifies to
"-Infinity" -- an invalid LDAP integer, rejected by the directory. This
broke every single user creation, not just this one.

Separately: the reporter's intended scheme is for organically-created
users to start at uidNumber/gidNumber 1500, distinct from the
bootstrap admin's reserved 10000. Fixing the crash with a bare "floor
of 1500" alone wouldn't achieve that, since addPosixAccount's own
Math.max() would still find the admin's posixAccount entry (uidNumber
10000, found via a different, correctly-indexed search) and allocate
10001 for the next user.

Added a shared nextPosixId(entries, key) helper: takes the highest
existing value strictly below conf.ldap.uidGidReservedFloor (default
9000) plus one, or conf.ldap.uidGidMin (default 1500) if there are no
such entries. Ids at/above the reserved floor -- like the bootstrap
admin's 10000 -- are ignored entirely when computing the next
available number, so real users always start at 1500 and grow upward
regardless of the admin's reserved id.

Verified against a real theta-env deployment end to end:
- Reproduced the exact reported crash on a fresh bootstrap
- After the fix: first real user gets uidNumber/gidNumber "1500",
  second gets "1501" -- admin's 10000 never enters the calculation
- New unit tests (nodejs/tests/posix_id.test.js, no LDAP required):
  6/6 pass, covering the empty-array case, the reserved-floor
  exclusion, and the NaN-from-missing-value case
- npm test: 18/18 passing tests still pass (unchanged); the other 155
  failures are pre-existing/environmental (no LDAP server in this
  sandbox) -- confirmed via git stash before starting this fix
2026-07-14 23:03:59 -04:00
wmantly 38cc6696a4 Fix commit hash not showing in Docker builds (#43)
* Fix commit hash not showing in Docker builds

build_info.js computed buildHash via `git rev-parse --short HEAD` at
runtime, but the final image intentionally has no git binary and no
.git directory (kept lean, per .dockerignore) — so this always failed
silently and the footer's version line showed "unknown" for every
Docker deployment. Working correctly only for bare-metal/dev, where
git + .git are actually present.

Added a throwaway gitinfo build stage that reuses the main base image
(no extra pull) with git installed just for this stage, reads .git
from the build context (now no longer excluded — see .dockerignore),
and bakes the resolved short hash into a small file that IS copied
into the final image. build_info.js reads that file first, falling
back to the old git-rev-parse behavior (still needed for bare-metal).

Verified against a real build: `docker exec sso-manager cat
/app/.build_commit` matches `git rev-parse --short HEAD` on the host,
and the footer now shows the real hash instead of "unknown". Same fix
already applied to proxy (theta42/proxy#133).

* Support GIT_COMMIT build-arg override for submodule builds

The gitinfo stage from the previous commit works for a standalone
clone (.git is a real directory) but not when this repo is built as a
git submodule (e.g. from theta-env): a submodule's .git is a pointer
FILE, not a directory — the real object database lives in the
superproject's .git/modules/, outside this repo's own directory and
therefore outside Docker's build context entirely. `git rev-parse`
can never resolve it from in here no matter what, so builds via
theta-env still baked in "unknown" despite the earlier fix.

Add an optional GIT_COMMIT build-arg that, when set, wins over the
in-context git resolution. theta-env's setup.sh now computes it on the
host (where the submodule DOES resolve correctly) and passes it via
docker-compose.yml's build.args. Same fix in proxy: theta42/proxy#133.

Verified via theta-env's actual setup.sh end to end: rebuilding with
this change, `docker exec sso-manager cat /app/.build_commit` now
matches `git -C sso-manager-node rev-parse --short HEAD` on the host
(previously: "unknown").
2026-07-14 22:32:35 -04:00
wmantly f72e8881cf Fix mobile layout: wrap unresponsive tables, let the group filter bar wrap (#42)
Auditing mobile/responsive views turned up two classes of bug:

1. Missing .table-responsive wrapper (token.ejs and profile.ejs's other
   table already had it): admin.ejs (2 tables), invites.ejs (6 columns),
   notifications.ejs (5 columns), users.ejs (7 columns — the worst case).
   Without it these overflow the page horizontally on narrow viewports
   instead of scrolling within the table.

2. groups.ejs's search/sort/count toolbar was a plain `d-flex` (no
   flex-wrap) containing a search input, a <select> with a hard
   min-width:175px, and a nowrap count span — on a narrow viewport
   there's nowhere for that content to go but off-screen. Added
   flex-wrap and gave the search input a flex-basis so it wraps onto
   its own line first when space is tight.

Verified: EJS compiles for all five templates, npm test 192/192 pass,
and fetched each route from a running instance to confirm the fixes
are present in the served HTML.
2026-07-14 21:18:01 -04:00
wmantly 3ceeeeeca1 Clean up footer: fix copyright, move GitHub link out of the nav (#41)
- Copyright was "© <year> <name>" where <name> is conf.name — an
  operator-configurable display name (e.g. whatever CFG_ORG is set
  to), not a real copyright holder. Changed to "© <year> theta42",
  matching the LICENSE file. Also dropped "All rights reserved",
  which contradicts the MIT license this project ships under; added
  an explicit MIT License link instead.
- Moved the GitHub icon link out of the top nav (where it competed
  with actual navigation items) and into the footer, alongside the
  license link and version/build info.
- Deduplicated the identical buildVersion/buildHash/buildYear
  computation that was copy-pasted in both routes/index.js and
  routes/oauth.js into a shared nodejs/utils/build_info.js.

Verified by rendering top+bottom with the real ejs package: no
template errors, GitHub link present exactly once (in the footer,
not the nav), "All rights reserved" gone, MIT License link present.
npm test failures (155) are pre-existing/environmental (no LDAP
server here) — identical failure count with these changes stashed
out.
2026-07-14 20:54:36 -04:00
wmantly 3d3b15b283 README: rewrite with feature overview, comparison, and tiered quick start (#40)
* README: rewrite with feature overview, comparison, and tiered quick start

Expands the README with a fuller feature description, a "why this over
the alternatives" comparison against Keycloak/Authentik/Authelia/Zitadel,
an architecture diagram, and a three-tier quick start (unified theta-env
stack, standalone Docker, bare metal) instead of the old OpenLDAP-setup-
first structure.

* docs/index.md: fix stale setup.env quickstart snippet (.env -> setup.env, CFG_DOMAIN)
2026-07-14 00:24:40 -04:00
wmantly 158109de59 Documentation cleanup for public release (#38)
* docs: cleanup for public release (fix stale/wrong API docs, LICENSE, versions)

Documentation cleanup ahead of the public release announcement. Fixes a set
of confirmed issues from a prior audit:

- LICENSE: fill in MIT template placeholders (theta42, 2026).
- README.md: fix broken API docs link (api.md -> API.md), correct required
  Node.js version (13.x -> 20.x), add the missing app_sso_invite group to
  the LDAP groups table, scrub hardcoded dc=theta42,dc=com to the generic
  dc=example,dc=com used elsewhere, add a "Recommended: Docker or
  install.sh" section pointing to DEPLOYMENT.md/docs before the manual
  OpenLDAP walkthrough, and drop an emoji from a warning callout.
- nodejs/api.md: deleted — it was a stale/legacy doc with wrong routes,
  wrong request bodies, and endpoints that are dead/commented-out code.
  The root API.md is the accurate, current reference; README now links
  there directly.
- API.md: add the missing app_sso_invite permission group, fix the
  documented invite response to match the real {token, link, mail_sent}
  payload, document the previously-undocumented GET/PUT/DELETE
  /api/user/invite endpoints, add the real allowed_groups field to the
  OAuth client management examples, and document POST /api/oauth/authorize
  (the endpoint that actually issues the code after consent).
- nodejs/routes/auth.js + API.md: fix "emaill address" typo in the
  password-reset response message (source and docs kept in sync).
- DEPLOYMENT.md: fix the top-level summary to mention Redis, matching
  docs/deployment.md and the entrypoint behavior it already documents.

Flagged, not changed: tos.md reads like a personal home-lab acceptable-use
policy (Emby/Gitea/Proxmox/Discord/Signal, first-person "the admin") rather
than generic OSS docs. Left in place pending a manual decision to
genericize, relocate, or remove it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* docs: genericize tos.md template, track runtime-editable terms in #39

Removes operator-specific references (Emby, Gitea, Proxmox, Discord,
Signal, first-person "the admin") so the shipped tos.md reads as a
neutral starting template rather than one operator's internal policy.
Actual runtime editability (admin/legal editing terms without a code
change) is tracked in issue #39, not implemented here.

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 23:21:27 -04:00
wmantly 11fb2c0a54 docs: document that the domain/base DN is entered once (#37)
The base DN (stack.ldapBaseDn) is the single place the domain is set; the
LDAP DNs (bindDN/userBase/groupBase) and oauth.issuer all derive from it and
must stay consistent. Drifting them apart (leaving bindDN at dc=example,dc=com
while ldapBaseDn is the real domain) makes the SSO bind against a non-existent
root DN and every login fails with Invalid Credentials.

- secrets.js.example: clarifying comment at ldapBaseDn
- DEPLOYMENT.md: "domain entered once as the base DN" note + theta-env
  setup.env cross-link (merged the two duplicate theta-env blockquotes)
- README.md: cross-link to DEPLOYMENT.md from "Server set up"

Docs only; no app/secrets-structure change.

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-13 21:56:48 -04:00
wmantly 796e013234 Fix api-tokens date display + quiet authIO no-token log (#36)
- api_tokens.ejs: created_on/last_used_on come back from Redis as strings
  (model-redis only coerces fields with an explicit `type`), so `new Date(ms)`
  yielded "Invalid date". Use `moment(ms, "x")` (the hosts.ejs/dns.ejs
  precedent) which parses a numeric string-or-number as a Unix-ms timestamp.
- api_tokens.ejs: `isExpired` is a class getter not serialized to the client
  JSON, so the "expired" badge never showed — compute expiry in the view via
  `Date.now() > Number(expires_at)`. Also guard the `last_used_on: 0` / falsy
  case (string "0" is truthy) so unset timestamps render "—" not "1970".
- middleware/auth.js: authIO did `checkToken(socket.handshake.auth.token || 0)`,
  so any socket connect without a token (login page, pre-login) did an
  `AuthToken.get(0)` lookup and logged a noisy `EntryNotFound` trace. Guard:
  reject the socket with a generic 401 when there's no token (behavior-
  preserving — unauth sockets were already rejected; just no Redis lookup / 404).

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-12 17:42:00 -04:00
wmantly b91ef2792d Add self-service API tokens (PATs) with UI + Bearer auth (#35)
Personal access tokens so scripts/CI can call the management API without a
browser session. Each logged-in user mints their own token; it authenticates as
the creator (carries their LDAP group permissions, re-resolved live), so the
existing permission.byGroup checks apply unchanged.

- models/api_token.js: new ApiToken model (sso_<id>_<secret> format; id is the
  lookup key, secret bcrypt-hashed + isPrivate, shown once). add()/rotate()/
  authenticate(); optional expires_at; best-effort last_used_on. No _ttl
  (persists; lifetime via expires_at).
- routes/api_token.js: self-service CRUD (list/get/update/delete/rotate),
  owner-scoped (created_by === req.user.uid, 403 otherwise).
- middleware/auth.js + models/auth.js: accept `Authorization: Bearer sso_...`
  (precedence over the auth-token session header); checkApiToken collapses
  every failure to one generic 401 (no existence/secret/expiry leak).
- views/api_tokens.ejs + routes/index.js (GET /api-tokens): self-service page
  (forceLogin, no group gate) — create (token shown once), edit, rotate, revoke.
- views/top.ejs: "API Tokens" nav entry visible to all logged-in users.
- public/js/app.js: app.apiToken client module.
- DEPLOYMENT.md + docs/deployment.md: API tokens section.

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-12 17:12:35 -04:00
wmantly 6920a9f9f0 Persist Redis (AOF+vol), read config from mounted secrets.js, add backup/restore docs (#34)
Lossless upgrades + config story for the all-in-one image.

Redis persistence (Part A):
- Replace in-memory `--save "" --appendonly no` with AOF + RDB persisted to /data
  (--appendonly yes, periodic saves, --dbfilename dump.rdb). OAuth clients,
  tokens, and other model-redis state now survive container recreation.
- Add the `sso-data` named volume -> /data in docker-compose.yml.

Config from ./config/sso-secrets.js (Part B):
- docker-entrypoint.sh: when /config/sso-secrets.js is mounted, symlink it to
  /app/conf/secrets.js and read the server-side LDAP vars (base DN, admin pass,
  org, domain, cert CN, JWT) from the file via one `node` call (base64-decoded,
  no eval/quoting hazards). No app_* env is exported in this mode, so the file
  is authoritative (@simpleworkjs/conf precedence: base < env < secrets.js <
  app_* env). Falls back to the existing LDAP_* env-var mode when the file is
  absent (standalone/bare-metal still works).
- docker-compose.yml: trim `environment:` to NODE_ENV/NODE_PORT only and add
  `./config:/config:ro`. Removing the app_* env is required — any leftover
  app_* would silently override secrets.js.
- secrets.js.example: add orchestrator-only `stack`, `bootstrap`, and
  `serviceAccountPass` keys (ignored by the app; read by the entrypoint, the
  theta-env bootstrap, and setup.sh).

Backup/restore docs:
- Full "Backups and restore" runbook in DEPLOYMENT.md (what lives where,
  manual backup, full / Redis-only / LDAP-only restore, AOF-vs-RDB note,
  upgrades). Restore uses slapadd -f (static slapd.conf), and RDB restore
  requires deleting the AOF first (AOF wins on startup).
- Pointers in docs/deployment.md and docs/ldap.md; update the Docker Setup
  section for the new ./config/ approach (env vars now advanced/optional).

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-12 12:55:22 -04:00
wmantly d5e951fa9a Merge pull request #33 from theta42/fix/load-sudo-lpk-schema-in-image
Load sudo + openssh-lpk schemas in the all-in-one image (fix schema COPY too)
2026-07-11 22:24:35 -04:00
wmantly 665a41cfde Load sudo + openssh-lpk schemas in the all-in-one image; fix schema COPY
The SSO app (nodejs/models/user_ldap.js addPosixAccount) tags every new user
with objectClasses [inetOrgPerson, sudoRole, ldapPublicKey, posixAccount, top,
theta42Person] and writes sudoHost/sudoCommand/sudoUser + sshPublicKey. The
all-in-one image's slapd.conf only included core/cosine/inetorgperson/nis +
theta42, so creating a user failed: sudoRole and ldapPublicKey were unknown
objectClasses (LDAP objectClassViolation 65) and sudoHost/sudoCommand/sudoUser
and sshPublicKey were unknown attributes.

Ship the two missing schemas and include them in slapd.conf:
  - ops/schema/sudo.schema       (sudoRole + sudo* attributes)
  - ops/schema/openssh-lpk.schema (sshPublicKey + ldapPublicKey)

sudoRole is AUXILIARY here, not STRUCTURAL as in upstream sudo. The app
attaches sudoRole directly onto the user entry, which is already inetOrgPerson
(STRUCTURAL); two unrelated structural classes violate RFC 4512 and OpenLDAP
rejects with 65. AUXILIARY lets it coexist with inetOrgPerson — the app's
per-user-sudoers model. sudo's LDAP backend still finds entries via
(objectClass=sudoRole) regardless. ldapPublicKey is AUXILIARY as in upstream
openssh-lpk.

Also fix the build error from the previous theta42 schema PR: .dockerignore
excluded all of ops/, so 'COPY ops/schema/theta42.schema' failed at build
time ('not found' — file is git-tracked but stripped from the context). Re-
include ops/schema/*.schema with !exceptions, matching the existing
README.md/tos.md pattern.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-11 22:24:12 -04:00
wmantly 84e5358e08 Merge pull request #32 from theta42/fix/load-theta42-schema-in-image
Load the theta42 (dateOfBirth) schema in the all-in-one image
2026-07-11 21:41:40 -04:00
wmantly ef7b6215bd Load the theta42 (dateOfBirth) schema in the all-in-one image
The app (nodejs/models/user_ldap.js) adds objectClass theta42Person whenever a
user has a dateOfBirth, and lists it among every new user's objectClasses. But
the all-in-one image's static slapd.conf only included core/cosine/inetorgperson/
nis — never the project's own theta42 schema — so slapd rejects
objectClass: theta42Person with LDAP 0x15 (objectClass: value #0 invalid per
syntax). Symptom: PUT /api/user/<uid> (and user creation) failing.

ops/ldap-setup.sh loads this schema for bare-metal (as a cn=config LDIF); the
Docker image uses slapd.conf, so ship it as a .schema file and include it.

- ops/schema/theta42.schema: dateOfBirth attribute + theta42Person auxiliary
  objectClass (same OIDs/definition as ldap-setup.sh section 5).
- Dockerfile.openldap: COPY it to /etc/openldap/schema/theta42.schema.
- docker-entrypoint.sh: include it in the generated slapd.conf (after nis).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-11 21:41:27 -04:00
wmantly d1620dd002 Merge pull request #31 from theta42/docs-logs
docs: add a Logs (Docker) section
2026-07-11 21:06:38 -04:00
wmantly faa694aef3 docs: add a Logs (Docker) section
Document how to get logs when running the all-in-one image: docker compose
logs for the app + slapd (both stdout/stderr, slapd runs -d 0), plus a direct
ldapsearch health check. Added to README.md, DEPLOYMENT.md (Method 1), and the
GitHub Pages docs/deployment.md.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-11 21:05:42 -04:00
wmantly fe9b7c168b Dockerize SSO Manager (all-in-one image) + GitHub Pages docs
All-in-one Dockerfile.openldap bundling the app + OpenLDAP + Redis in one
container, plus an idempotent bare-metal install.sh, and a Jekyll docs site
for GitHub Pages:
- Dockerfile.openldap (node:20-alpine; openldap + pw-sha2/ppolicy/memberof/
  refint; dumb-init PID 1; npm ci --omit=dev; tos.md copied to /).
- docker-entrypoint.sh: generate slapd.conf (mdb + overlays + TLS + indexes +
  access), self-signed LDAPS cert, seed directory tree + required groups,
  bundled redis, export app_* config, exec node.
- docker-compose.yml, .dockerignore, DEPLOYMENT.md, secrets.js.example.
- install.sh: idempotent Debian/Ubuntu bare-metal installer (Node 20.x,
  OpenLDAP, Redis, systemd unit) with flags + --dry-run/--skip-ldap/--skip-app.
- ops/ldif/: memberof/refint/tls/index/nodes/logging LDIFs.
- nodejs/conf/base.js: generic defaults (dc=example,dc=com / localhost /
  SSO Manager) so per-deployment values move to secrets.js or app_* env.
- nodejs/package.json: bump @simpleworkjs/conf to ^1.1.0 (app_* env overrides).
- nodejs/routes/index.js: /health endpoint for healthchecks.
- docs/: _config.yml + index/deployment/configuration/oauth/ldap pages
  (jekyll-theme-cayman) for GitHub Pages from /docs.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-11 17:03:16 -04:00
wmantly e28fb63784 Merge pull request #30 from theta42/Permissions
oauth edit
2026-07-11 00:55:53 -04:00
wmantly 4f432ec448 oauth edit 2026-07-11 00:54:49 -04:00
wmantly a48e8c71d7 Merge pull request #29 from theta42/Permissions
oath grpup fixes
2026-07-02 16:52:16 -04:00
wmantly bb79247054 oath grpup fixes 2026-07-02 16:49:22 -04:00
wmantly 54250a6f98 Merge pull request #28 from theta42/Permissions
oath fixes
2026-07-02 16:23:42 -04:00
wmantly 93df047a21 oath fixes 2026-07-02 16:22:16 -04:00
wmantly 7a1f81fc5d Merge pull request #27 from theta42/Permissions
Setup
2026-07-01 23:30:23 -04:00
wmantly 46e0be2271 Setup 2026-07-01 23:29:48 -04:00
wmantly 5476ea9347 Merge pull request #26 from theta42/Permissions
Script fix
2026-07-01 23:19:54 -04:00
wmantly 12262ec85b Script fix 2026-07-01 23:18:25 -04:00
wmantly 94bdbe3093 Merge pull request #25 from theta42/Permissions
Script fix
2026-07-01 23:10:12 -04:00
wmantly 196bbadc20 Script fix 2026-07-01 23:09:44 -04:00
wmantly 7f8ce9afe0 Merge pull request #24 from theta42/Permissions
Login page
2026-07-01 12:08:34 -04:00
wmantly cf2418a9eb Login page 2026-07-01 12:08:11 -04:00
wmantly 7d2d6f5b8c Merge pull request #23 from theta42/Permissions
Testing fix
2026-07-01 11:47:44 -04:00
wmantly c92dbe4aa6 Testing fix 2026-07-01 11:40:38 -04:00
wmantly dc398cf04a Merge pull request #22 from theta42/Permissions
Updated frontend
2026-07-01 11:16:55 -04:00
wmantly 5644bfa5ec Updated frontend 2026-07-01 11:10:09 -04:00
wmantly 5b643dbaec Merge pull request #21 from theta42/Permissions
Corrected email address
2024-02-19 10:52:44 -05:00
wmantly ee21bdbf22 Added app name to conf file 2024-02-19 10:52:06 -05:00
wmantly 24d5364da0 Corrected email address 2024-02-19 10:49:37 -05:00
wmantly 693bdecfc8 Merge pull request #20 from theta42/Permissions
Permissions
2024-02-19 10:44:35 -05:00
wmantly e132015871 Added error message when non-admin cant see groups 2024-02-19 10:42:40 -05:00
wmantly 2a6aaafa3d Added permission to block non admins from seeing all users 2024-02-19 10:42:02 -05:00
wmantly 763e8af7a0 Merge pull request #11 from theta42/updates
name error fixed
2021-04-28 12:56:13 -04:00
wmantly 2654c31f68 name error fixed 2021-04-28 12:55:36 -04:00
wmantly 1022d9da86 name error fixed 2021-04-28 12:52:59 -04:00
wmantly b8ed0f2171 Merge pull request #9 from theta42/updates
Updates
2021-03-23 00:49:39 -04:00
wmantly 623e52e135 started token API 2021-03-23 00:41:12 -04:00
wmantly b97970c8de Updated packages 2021-03-23 00:40:22 -04:00
wmantly 0e212c48cc added white lable name 2021-03-23 00:36:53 -04:00