From b96741971794c85ff6d109024db7675cd832dede Mon Sep 17 00:00:00 2001 From: Carlos Arias Date: Thu, 23 Jul 2026 22:15:50 +0000 Subject: [PATCH] Phase 1: admin login page + secret-link token auth - /admin login page (public, noindex): password form + signed-in panel that activates the in-page console. Verified in-browser: login -> panel -> the astroagent handle appears on other pages. - Secret-link login: visit /admin/ to sign in without a password. adminauth token() validates a bcrypt-hashed token, sets the session, 302s to /admin. nginx routes the token path to PHP. set-admin-token.php generates it. - Password login kept as a fallback. Verified: token link -> session -> console access; bad token -> /admin?e=1. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DoFYZY9gkGPNDqZ7NuEa9a --- api/cli/set-admin-token.php | 33 ++++ api/db/migrations/009_admin_token.sql | 8 + api/public/controllers/adminauth.php | 32 ++++ api/system/errors.json | 216 ++++++++++++++++++++++++++ app/src/pages/admin/index.astro | 178 +++++++++++++++++++++ 5 files changed, 467 insertions(+) create mode 100644 api/cli/set-admin-token.php create mode 100644 api/db/migrations/009_admin_token.sql create mode 100644 app/src/pages/admin/index.astro diff --git a/api/cli/set-admin-token.php b/api/cli/set-admin-token.php new file mode 100644 index 0000000..c40076e --- /dev/null +++ b/api/cli/set-admin-token.php @@ -0,0 +1,33 @@ + + * + * Prints the token and the full login URL ONCE — it is not recoverable + * afterwards (only its hash is stored). Re-run to rotate. + */ + +require __DIR__ . '/../vendor/autoload.php'; +require __DIR__ . '/../config.php'; + +$username = $argv[1] ?? ''; +if ($username === '') { + fwrite(STDERR, "usage: php api/cli/set-admin-token.php \n"); + exit(1); +} + +$id = Db::getValue('SELECT admin_id FROM cja_admin WHERE username = ?', [$username]); +if (!$id) { + fwrite(STDERR, "No admin '{$username}'. Create one first with set-admin-password.php\n"); + exit(1); +} + +// URL-safe, 43 chars of entropy. +$token = rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '='); +Db::update('cja_admin', ['token_hash' => password_hash($token, PASSWORD_DEFAULT)], 'admin_id = ?', [$id]); + +$base = defined('SITE_URL') ? SITE_URL : 'https://carlosarias.co'; +echo "\n Admin login link (save it — shown only once):\n\n"; +echo " {$base}/admin/{$token}\n\n"; +echo " Rotate any time by re-running this. The old link stops working.\n"; diff --git a/api/db/migrations/009_admin_token.sql b/api/db/migrations/009_admin_token.sql new file mode 100644 index 0000000..e229b19 --- /dev/null +++ b/api/db/migrations/009_admin_token.sql @@ -0,0 +1,8 @@ +-- Secret-link login for the admin console. +-- +-- A hashed token so you can log in by visiting /admin/ instead of typing +-- a password. Stored the same way as the password (bcrypt) — the plaintext +-- lives only in your bookmark. Password login stays as a fallback. + +ALTER TABLE `cja_admin` + ADD COLUMN `token_hash` varchar(255) DEFAULT NULL AFTER `password_hash`; diff --git a/api/public/controllers/adminauth.php b/api/public/controllers/adminauth.php index 278b845..97d5518 100644 --- a/api/public/controllers/adminauth.php +++ b/api/public/controllers/adminauth.php @@ -74,6 +74,38 @@ class AdminAuth extends PublicController $this->json(['admin' => true, 'username' => $row['username']]); } + /** + * Secret-link login: GET /admin/ (nginx routes it here as + * ?t=). Validates against the stored token hash, sets the admin + * session, and redirects to /admin. On failure, redirects with ?e=1. + * + * Note: the token appears in this request's path, so it can land in access + * logs — rotate it if that matters. A password login remains available. + */ + public function token(): void + { + $this->guardPublic('admin_token', 10, 300); + $t = (string) ($_GET['t'] ?? ''); + + // Single admin: fetch the row that has a token set and verify. + $row = \Db::getRow('SELECT admin_id, username, token_hash FROM cja_admin WHERE token_hash IS NOT NULL LIMIT 1'); + $ok = $row && $t !== '' && password_verify($t, $row['token_hash']); + + if (!$ok) { + $this->logAttempt('token_fail', ''); + header('Location: /admin?e=1', true, 302); + exit; + } + + session_regenerate_id(true); + $_SESSION['admin'] = ['ok' => true, 'username' => $row['username'], 'at' => time()]; + \Db::update('cja_admin', ['last_login_at' => date('Y-m-d H:i:s')], 'admin_id = ?', [$row['admin_id']]); + $this->logAttempt('login_token', "username={$row['username']}"); + + header('Location: /admin', true, 302); + exit; + } + public function logout(): void { $this->logAttempt('logout', ''); diff --git a/api/system/errors.json b/api/system/errors.json index 57e78f2..ee82609 100644 --- a/api/system/errors.json +++ b/api/system/errors.json @@ -1,4 +1,220 @@ [ + { + "id": "err_6a627fa4393a50.82526004", + "userid": null, + "org_id": null, + "errortype": "Exception", + "errorcode": "1", + "url": "https:\/\/app.medellin.co\/api\/openapi.json", + "file": "\/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/controllers\/error.php", + "line": 19, + "fullerror": "Undefined constant \"COMPANY\"", + "trace": "#0 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(244): _Error->index()\n#1 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(151): Bootstrap->_error()\n#2 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(63): Bootstrap->_loadExistingController()\n#3 \/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/index.php(22): Bootstrap->init()\n#4 \/www\/wwwroot\/CarlosAriasPersonal\/api\/index.php(2): require_once('...')\n#5 {main}", + "method": "GET", + "ip_address": "34.154.209.186", + "user_agent": "Mozilla\/5.0 AppleWebKit\/537.36 (KHTML, like Gecko); compatible; OAI-SearchBot\/1.0; +https:\/\/openai.com\/searchbot", + "referer": null, + "status": "unresolved", + "createdate": "2026-07-23 16:55:00" + }, + { + "id": "err_6a627fa420dfe0.76567025", + "userid": null, + "org_id": null, + "errortype": "Exception", + "errorcode": "1", + "url": "https:\/\/app.medellin.co\/api\/graphql", + "file": "\/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/controllers\/error.php", + "line": 19, + "fullerror": "Undefined constant \"COMPANY\"", + "trace": "#0 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(244): _Error->index()\n#1 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(151): Bootstrap->_error()\n#2 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(63): Bootstrap->_loadExistingController()\n#3 \/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/index.php(22): Bootstrap->init()\n#4 \/www\/wwwroot\/CarlosAriasPersonal\/api\/index.php(2): require_once('...')\n#5 {main}", + "method": "GET", + "ip_address": "34.154.209.186", + "user_agent": "Mozilla\/5.0 (compatible; Diffbot\/1.0; +https:\/\/diffbot.com)", + "referer": null, + "status": "unresolved", + "createdate": "2026-07-23 16:55:00" + }, + { + "id": "err_6a627fa40e8d84.79284344", + "userid": null, + "org_id": null, + "errortype": "Exception", + "errorcode": "1", + "url": "https:\/\/app.medellin.co\/api\/v2\/settings", + "file": "\/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/controllers\/error.php", + "line": 19, + "fullerror": "Undefined constant \"COMPANY\"", + "trace": "#0 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(244): _Error->index()\n#1 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(151): Bootstrap->_error()\n#2 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(63): Bootstrap->_loadExistingController()\n#3 \/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/index.php(22): Bootstrap->init()\n#4 \/www\/wwwroot\/CarlosAriasPersonal\/api\/index.php(2): require_once('...')\n#5 {main}", + "method": "GET", + "ip_address": "34.154.209.186", + "user_agent": "Mozilla\/5.0 AppleWebKit\/537.36 (KHTML, like Gecko); compatible; ClaudeBot\/1.0; +mailto:support@anthropic.com", + "referer": null, + "status": "unresolved", + "createdate": "2026-07-23 16:55:00" + }, + { + "id": "err_6a627fa3f1ad05.50593044", + "userid": null, + "org_id": null, + "errortype": "Exception", + "errorcode": "1", + "url": "https:\/\/app.medellin.co\/api\/v1\/env", + "file": "\/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/controllers\/error.php", + "line": 19, + "fullerror": "Undefined constant \"COMPANY\"", + "trace": "#0 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(244): _Error->index()\n#1 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(151): Bootstrap->_error()\n#2 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(63): Bootstrap->_loadExistingController()\n#3 \/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/index.php(22): Bootstrap->init()\n#4 \/www\/wwwroot\/CarlosAriasPersonal\/api\/index.php(2): require_once('...')\n#5 {main}", + "method": "GET", + "ip_address": "34.154.209.186", + "user_agent": "CCBot\/2.0 (https:\/\/commoncrawl.org\/faq\/)", + "referer": null, + "status": "unresolved", + "createdate": "2026-07-23 16:54:59" + }, + { + "id": "err_6a627fa3ef7212.28308658", + "userid": null, + "org_id": null, + "errortype": "Exception", + "errorcode": "1", + "url": "https:\/\/app.medellin.co\/api\/v1\/config", + "file": "\/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/controllers\/error.php", + "line": 19, + "fullerror": "Undefined constant \"COMPANY\"", + "trace": "#0 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(244): _Error->index()\n#1 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(151): Bootstrap->_error()\n#2 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(63): Bootstrap->_loadExistingController()\n#3 \/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/index.php(22): Bootstrap->init()\n#4 \/www\/wwwroot\/CarlosAriasPersonal\/api\/index.php(2): require_once('...')\n#5 {main}", + "method": "GET", + "ip_address": "34.154.209.186", + "user_agent": "Mozilla\/5.0 (compatible; Amazonbot\/0.1; +https:\/\/developer.amazon.com\/support\/amazonbot)", + "referer": null, + "status": "unresolved", + "createdate": "2026-07-23 16:54:59" + }, + { + "id": "err_6a627fa3edf767.51816380", + "userid": null, + "org_id": null, + "errortype": "Exception", + "errorcode": "1", + "url": "https:\/\/app.medellin.co\/api\/env", + "file": "\/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/controllers\/error.php", + "line": 19, + "fullerror": "Undefined constant \"COMPANY\"", + "trace": "#0 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(244): _Error->index()\n#1 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(151): Bootstrap->_error()\n#2 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(63): Bootstrap->_loadExistingController()\n#3 \/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/index.php(22): Bootstrap->init()\n#4 \/www\/wwwroot\/CarlosAriasPersonal\/api\/index.php(2): require_once('...')\n#5 {main}", + "method": "GET", + "ip_address": "34.154.209.186", + "user_agent": "Mozilla\/5.0 AppleWebKit\/537.36 (KHTML, like Gecko); compatible; ChatGPT-User\/1.0; +https:\/\/openai.com\/bot", + "referer": null, + "status": "unresolved", + "createdate": "2026-07-23 16:54:59" + }, + { + "id": "err_6a627fa3d27488.12114345", + "userid": null, + "org_id": null, + "errortype": "Exception", + "errorcode": "1", + "url": "https:\/\/app.medellin.co\/api\/v2\/config", + "file": "\/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/controllers\/error.php", + "line": 19, + "fullerror": "Undefined constant \"COMPANY\"", + "trace": "#0 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(244): _Error->index()\n#1 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(151): Bootstrap->_error()\n#2 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(63): Bootstrap->_loadExistingController()\n#3 \/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/index.php(22): Bootstrap->init()\n#4 \/www\/wwwroot\/CarlosAriasPersonal\/api\/index.php(2): require_once('...')\n#5 {main}", + "method": "GET", + "ip_address": "34.154.209.186", + "user_agent": "Mozilla\/5.0 (compatible; Bytespider; spider-feedback@bytedance.com)", + "referer": null, + "status": "unresolved", + "createdate": "2026-07-23 16:54:59" + }, + { + "id": "err_6a627fa33d9058.20852796", + "userid": null, + "org_id": null, + "errortype": "Exception", + "errorcode": "1", + "url": "https:\/\/app.medellin.co\/api\/.env", + "file": "\/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/controllers\/error.php", + "line": 19, + "fullerror": "Undefined constant \"COMPANY\"", + "trace": "#0 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(244): _Error->index()\n#1 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(151): Bootstrap->_error()\n#2 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(63): Bootstrap->_loadExistingController()\n#3 \/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/index.php(22): Bootstrap->init()\n#4 \/www\/wwwroot\/CarlosAriasPersonal\/api\/index.php(2): require_once('...')\n#5 {main}", + "method": "GET", + "ip_address": "34.154.209.186", + "user_agent": "Mozilla\/5.0 AppleWebKit\/537.36 (KHTML, like Gecko); compatible; ChatGPT-User\/1.0; +https:\/\/openai.com\/bot", + "referer": null, + "status": "unresolved", + "createdate": "2026-07-23 16:54:59" + }, + { + "id": "err_6a627fa3038b94.97882332", + "userid": null, + "org_id": null, + "errortype": "Exception", + "errorcode": "1", + "url": "https:\/\/app.medellin.co\/api\/v1\/settings", + "file": "\/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/controllers\/error.php", + "line": 19, + "fullerror": "Undefined constant \"COMPANY\"", + "trace": "#0 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(244): _Error->index()\n#1 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(151): Bootstrap->_error()\n#2 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(63): Bootstrap->_loadExistingController()\n#3 \/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/index.php(22): Bootstrap->init()\n#4 \/www\/wwwroot\/CarlosAriasPersonal\/api\/index.php(2): require_once('...')\n#5 {main}", + "method": "GET", + "ip_address": "34.154.209.186", + "user_agent": "anthropic-ai", + "referer": null, + "status": "unresolved", + "createdate": "2026-07-23 16:54:59" + }, + { + "id": "err_6a627fa2ed6324.91148593", + "userid": null, + "org_id": null, + "errortype": "Exception", + "errorcode": "1", + "url": "https:\/\/app.medellin.co\/api\/graphql", + "file": "\/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/controllers\/error.php", + "line": 19, + "fullerror": "Undefined constant \"COMPANY\"", + "trace": "#0 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(244): _Error->index()\n#1 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(151): Bootstrap->_error()\n#2 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(63): Bootstrap->_loadExistingController()\n#3 \/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/index.php(22): Bootstrap->init()\n#4 \/www\/wwwroot\/CarlosAriasPersonal\/api\/index.php(2): require_once('...')\n#5 {main}", + "method": "POST", + "ip_address": "34.154.209.186", + "user_agent": "Go-http-client\/2.0", + "referer": null, + "status": "unresolved", + "createdate": "2026-07-23 16:54:58" + }, + { + "id": "err_6a627fa2c9d346.16028485", + "userid": null, + "org_id": null, + "errortype": "Exception", + "errorcode": "1", + "url": "https:\/\/app.medellin.co\/api\/settings", + "file": "\/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/controllers\/error.php", + "line": 19, + "fullerror": "Undefined constant \"COMPANY\"", + "trace": "#0 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(244): _Error->index()\n#1 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(151): Bootstrap->_error()\n#2 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(63): Bootstrap->_loadExistingController()\n#3 \/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/index.php(22): Bootstrap->init()\n#4 \/www\/wwwroot\/CarlosAriasPersonal\/api\/index.php(2): require_once('...')\n#5 {main}", + "method": "GET", + "ip_address": "34.154.209.186", + "user_agent": "Mozilla\/5.0 (compatible; Amazonbot\/0.1; +https:\/\/developer.amazon.com\/support\/amazonbot)", + "referer": null, + "status": "unresolved", + "createdate": "2026-07-23 16:54:58" + }, + { + "id": "err_6a627fa2b972f9.65333495", + "userid": null, + "org_id": null, + "errortype": "Exception", + "errorcode": "1", + "url": "https:\/\/app.medellin.co\/api\/config", + "file": "\/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/controllers\/error.php", + "line": 19, + "fullerror": "Undefined constant \"COMPANY\"", + "trace": "#0 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(244): _Error->index()\n#1 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(151): Bootstrap->_error()\n#2 \/www\/wwwroot\/CarlosAriasPersonal\/api\/core\/Bootstrap.php(63): Bootstrap->_loadExistingController()\n#3 \/www\/wwwroot\/CarlosAriasPersonal\/api\/public\/index.php(22): Bootstrap->init()\n#4 \/www\/wwwroot\/CarlosAriasPersonal\/api\/index.php(2): require_once('...')\n#5 {main}", + "method": "GET", + "ip_address": "34.154.209.186", + "user_agent": "Mozilla\/5.0 AppleWebKit\/537.36 (KHTML, like Gecko); compatible; ClaudeBot\/1.0; +mailto:support@anthropic.com", + "referer": null, + "status": "unresolved", + "createdate": "2026-07-23 16:54:58" + }, { "id": "err_6a6204ee4c9044.13450162", "userid": null, diff --git a/app/src/pages/admin/index.astro b/app/src/pages/admin/index.astro new file mode 100644 index 0000000..11d6aa5 --- /dev/null +++ b/app/src/pages/admin/index.astro @@ -0,0 +1,178 @@ +--- +import { SITE } from "../../lib/blog-data.js"; +// Public page (NOT gated — you must be able to reach it to log in). It reveals +// the console only after a successful admin login sets the session. The gate +// lives on /devconsole + /_preview, not here. +--- + + + + + + + Admin — {SITE.name} + + + + + + +
+
+ + Carlos Arias · Admin +
+ + + + + + + +

Checking session…

+
+ + + +