this repo has no description

Rename to std.pub, eject from exe.dev

seth.computer 98399e99 ca0f154b

verified
+1022 -526
+1 -16
CLAUDE.md
··· 1 - # stdeditor 1 + # std.pub 2 2 3 3 A minimal web UI for managing standard.site publications and documents in a Bluesky PDS. 4 4 ··· 40 40 - `PORT` - Server port (default: 8000) 41 41 - `PUBLIC_URL` - Public URL for OAuth callbacks (MUST be HTTPS without custom port for production) 42 42 - `DATA_DIR` - Directory for persistent data (default: ./data) 43 - 44 - ## Production Deployment 45 - 46 - **IMPORTANT**: Bluesky's OAuth server does not allow custom HTTPS ports. You must: 47 - 48 - 1. Configure exe.dev proxy to forward port 8000: 49 - ```bash 50 - ssh exe.dev share port stdeditor 8000 51 - ssh exe.dev share set-public stdeditor 52 - ``` 53 - 54 - 2. Set PUBLIC_URL to the standard HTTPS URL (no port): 55 - ``` 56 - PUBLIC_URL=https://stdeditor.exe.xyz 57 - ``` 58 43 59 44 ## ATProto OAuth Implementation 60 45
+1 -1
README.md
··· 1 - # stdeditor 1 + # std.pub 2 2 3 3 To install dependencies: 4 4
+418 -84
bun.lock
··· 3 3 "configVersion": 1, 4 4 "workspaces": { 5 5 "": { 6 - "name": "stdeditor", 6 + "name": "std.pub", 7 7 "dependencies": { 8 8 "@atproto/api": "^0.18.13", 9 9 "@atproto/jwk-jose": "^0.1.11", ··· 19 19 }, 20 20 }, 21 21 "packages": { 22 - "@atproto-labs/did-resolver": ["@atproto-labs/did-resolver@0.2.5", "", { "dependencies": { "@atproto-labs/fetch": "0.2.3", "@atproto-labs/pipe": "0.1.1", "@atproto-labs/simple-store": "0.3.0", "@atproto-labs/simple-store-memory": "0.1.4", "@atproto/did": "0.2.4", "zod": "^3.23.8" } }, "sha512-he7EC6OMSifNs01a4RT9mta/yYitoKDzlK9ty2TFV5Uj/+HpB4vYMRdIDFrRW0Hcsehy90E2t/dw0t7361MEKQ=="], 23 - 24 - "@atproto-labs/fetch": ["@atproto-labs/fetch@0.2.3", "", { "dependencies": { "@atproto-labs/pipe": "0.1.1" } }, "sha512-NZtbJOCbxKUFRFKMpamT38PUQMY0hX0p7TG5AEYOPhZKZEP7dHZ1K2s1aB8MdVH0qxmqX7nQleNrrvLf09Zfdw=="], 25 - 26 - "@atproto-labs/fetch-node": ["@atproto-labs/fetch-node@0.2.0", "", { "dependencies": { "@atproto-labs/fetch": "0.2.3", "@atproto-labs/pipe": "0.1.1", "ipaddr.js": "^2.1.0", "undici": "^6.14.1" } }, "sha512-Krq09nH/aeoiU2s9xdHA0FjTEFWG9B5FFenipv1iRixCcPc7V3DhTNDawxG9gI8Ny0k4dBVS9WTRN/IDzBx86Q=="], 27 - 28 - "@atproto-labs/handle-resolver": ["@atproto-labs/handle-resolver@0.3.5", "", { "dependencies": { "@atproto-labs/simple-store": "0.3.0", "@atproto-labs/simple-store-memory": "0.1.4", "@atproto/did": "0.2.4", "zod": "^3.23.8" } }, "sha512-r3b+plCh/0arN535Aool9gL6yTSbAPDOyReURbA2TWAaeW4vrSJPwR6yYUx0k0vmVPjkZPIdUVd63bG/+VG5MA=="], 29 - 30 - "@atproto-labs/handle-resolver-node": ["@atproto-labs/handle-resolver-node@0.1.24", "", { "dependencies": { "@atproto-labs/fetch-node": "0.2.0", "@atproto-labs/handle-resolver": "0.3.5", "@atproto/did": "0.2.4" } }, "sha512-w/zvktigmRQpOLQQclp48tbb2K/2XW8j1szoIpT8T8v6P5dZ8GGVDIEF142xQMX9vWToFqMTu1P2yOuz8e3Ilg=="], 31 - 32 - "@atproto-labs/identity-resolver": ["@atproto-labs/identity-resolver@0.3.5", "", { "dependencies": { "@atproto-labs/did-resolver": "0.2.5", "@atproto-labs/handle-resolver": "0.3.5" } }, "sha512-kSxnreUSPhKL77doUbSl/9I6Y9qpkpD7MMJoYFQVU/WG0PB90tzfIb6DNuWsjbU2I5Q91Nzc4Tm4VJMV+OPKGQ=="], 33 - 34 - "@atproto-labs/pipe": ["@atproto-labs/pipe@0.1.1", "", {}, "sha512-hdNw2oUs2B6BN1lp+32pF7cp8EMKuIN5Qok2Vvv/aOpG/3tNSJ9YkvfI0k6Zd188LeDDYRUpYpxcoFIcGH/FNg=="], 35 - 36 - "@atproto-labs/simple-store": ["@atproto-labs/simple-store@0.3.0", "", {}, "sha512-nOb6ONKBRJHRlukW1sVawUkBqReLlLx6hT35VS3imaNPwiXDxLnTK7lxw3Lrl9k5yugSBDQAkZAq3MPTEFSUBQ=="], 37 - 38 - "@atproto-labs/simple-store-memory": ["@atproto-labs/simple-store-memory@0.1.4", "", { "dependencies": { "@atproto-labs/simple-store": "0.3.0", "lru-cache": "^10.2.0" } }, "sha512-3mKY4dP8I7yKPFj9VKpYyCRzGJOi5CEpOLPlRhoJyLmgs3J4RzDrjn323Oakjz2Aj2JzRU/AIvWRAZVhpYNJHw=="], 39 - 40 - "@atproto/api": ["@atproto/api@0.18.13", "", { "dependencies": { "@atproto/common-web": "^0.4.11", "@atproto/lexicon": "^0.6.0", "@atproto/syntax": "^0.4.2", "@atproto/xrpc": "^0.7.7", "await-lock": "^2.2.2", "multiformats": "^9.9.0", "tlds": "^1.234.0", "zod": "^3.23.8" } }, "sha512-CULZ01pSJDltLS/Gc9MMrhFzB6OM3ezyZw7KoeLT/sBfwgA1ddA4mWdTh7DIRosPRigXtA05bnoiCutZbQDo+Q=="], 41 - 42 - "@atproto/common-web": ["@atproto/common-web@0.4.11", "", { "dependencies": { "@atproto/lex-data": "0.0.7", "@atproto/lex-json": "0.0.7", "zod": "^3.23.8" } }, "sha512-VHejNmSABU8/03VrQ3e36AmT5U3UIeio+qSUqCrO1oNgrJcWfGy1rpj0FVtUugWF8Un29+yzkukzWGZfXL70rQ=="], 43 - 44 - "@atproto/did": ["@atproto/did@0.2.4", "", { "dependencies": { "zod": "^3.23.8" } }, "sha512-nxNiCgXeo7pfjojq9fpfZxCO0X0xUipNVKW+AHNZwQKiUDt6zYL0VXEfm8HBUwQOCmKvj2pRRSM1Cur+tUWk3g=="], 45 - 46 - "@atproto/jwk": ["@atproto/jwk@0.6.0", "", { "dependencies": { "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-bDoJPvt7TrQVi/rBfBrSSpGykhtIriKxeYCYQTiPRKFfyRhbgpElF0wPXADjIswnbzZdOwbY63az4E/CFVT3Tw=="], 47 - 48 - "@atproto/jwk-jose": ["@atproto/jwk-jose@0.1.11", "", { "dependencies": { "@atproto/jwk": "0.6.0", "jose": "^5.2.0" } }, "sha512-i4Fnr2sTBYmMmHXl7NJh8GrCH+tDQEVWrcDMDnV5DjJfkgT17wIqvojIw9SNbSL4Uf0OtfEv6AgG0A+mgh8b5Q=="], 49 - 50 - "@atproto/jwk-webcrypto": ["@atproto/jwk-webcrypto@0.2.0", "", { "dependencies": { "@atproto/jwk": "0.6.0", "@atproto/jwk-jose": "0.1.11", "zod": "^3.23.8" } }, "sha512-UmgRrrEAkWvxwhlwe30UmDOdTEFidlIzBC7C3cCbeJMcBN1x8B3KH+crXrsTqfWQBG58mXgt8wgSK3Kxs2LhFg=="], 51 - 52 - "@atproto/lex-data": ["@atproto/lex-data@0.0.7", "", { "dependencies": { "@atproto/syntax": "0.4.2", "multiformats": "^9.9.0", "tslib": "^2.8.1", "uint8arrays": "3.0.0", "unicode-segmenter": "^0.14.0" } }, "sha512-W/Q5o9o7n2Sv3UywckChu01X5lwQUtaiiOkGJLnRsdkQTyC6813nPgY+p2sG7NwwM+82lu+FUV9fE/Ul3VqaJw=="], 53 - 54 - "@atproto/lex-json": ["@atproto/lex-json@0.0.7", "", { "dependencies": { "@atproto/lex-data": "0.0.7", "tslib": "^2.8.1" } }, "sha512-bjNPD5M/MhLfjNM7tcxuls80UgXpHqxdOxDXEUouAtZQV/nIDhGjmNUvKxOmOgnDsiZRnT2g5y3onrnjH3a44g=="], 55 - 56 - "@atproto/lexicon": ["@atproto/lexicon@0.6.0", "", { "dependencies": { "@atproto/common-web": "^0.4.7", "@atproto/syntax": "^0.4.2", "iso-datestring-validator": "^2.2.2", "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-5veb8aD+J5M0qszLJ+73KSFsFrJBgAY/nM1TSAJvGY7fNc9ZAT+PSUlmIyrdye9YznAZ07yktalls/TwNV7cHQ=="], 57 - 58 - "@atproto/oauth-client": ["@atproto/oauth-client@0.5.13", "", { "dependencies": { "@atproto-labs/did-resolver": "0.2.5", "@atproto-labs/fetch": "0.2.3", "@atproto-labs/handle-resolver": "0.3.5", "@atproto-labs/identity-resolver": "0.3.5", "@atproto-labs/simple-store": "0.3.0", "@atproto-labs/simple-store-memory": "0.1.4", "@atproto/did": "0.2.4", "@atproto/jwk": "0.6.0", "@atproto/oauth-types": "0.6.1", "@atproto/xrpc": "0.7.7", "core-js": "^3", "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-FLbqHkC7BAVZ90LHVzSxQf+s8ZNIQI4TsDuhYDyzi7lYtktFHDbgd88KuM2ClJFOtGCsSS17yR1Joy925tDSaA=="], 59 - 60 - "@atproto/oauth-client-node": ["@atproto/oauth-client-node@0.3.15", "", { "dependencies": { "@atproto-labs/did-resolver": "0.2.5", "@atproto-labs/handle-resolver-node": "0.1.24", "@atproto-labs/simple-store": "0.3.0", "@atproto/did": "0.2.4", "@atproto/jwk": "0.6.0", "@atproto/jwk-jose": "0.1.11", "@atproto/jwk-webcrypto": "0.2.0", "@atproto/oauth-client": "0.5.13", "@atproto/oauth-types": "0.6.1" } }, "sha512-iuT7QrLli7IyB4px1+lHvm/YoIRfNRpbNG9seJRtu5eX4N5aLsBP6vpXs9rCygd1+/15LcLRAAGKVEcrLT9tXA=="], 61 - 62 - "@atproto/oauth-types": ["@atproto/oauth-types@0.6.1", "", { "dependencies": { "@atproto/did": "0.2.4", "@atproto/jwk": "0.6.0", "zod": "^3.23.8" } }, "sha512-3z92GN/6zCq9E2GTTfZM27tWEbvi1qwFSA7KoS5+wqBC4kSsLvnLxmbKH402Z40DfWS4YWqw0DkHsgP0LNFDEA=="], 63 - 64 - "@atproto/syntax": ["@atproto/syntax@0.4.2", "", {}, "sha512-X9XSRPinBy/0VQ677j8VXlBsYSsUXaiqxWVpGGxJYsAhugdQRb0jqaVKJFtm6RskeNkV6y9xclSUi9UYG/COrA=="], 65 - 66 - "@atproto/xrpc": ["@atproto/xrpc@0.7.7", "", { "dependencies": { "@atproto/lexicon": "^0.6.0", "zod": "^3.23.8" } }, "sha512-K1ZyO/BU8JNtXX5dmPp7b5UrkLMMqpsIa/Lrj5D3Su+j1Xwq1m6QJ2XJ1AgjEjkI1v4Muzm7klianLE6XGxtmA=="], 67 - 68 - "@types/bun": ["@types/bun@1.3.5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="], 69 - 70 - "@types/node": ["@types/node@25.0.6", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-NNu0sjyNxpoiW3YuVFfNz7mxSQ+S4X2G28uqg2s+CzoqoQjLPsWSbsFFyztIAqt2vb8kfEAsJNepMGPTxFDx3Q=="], 71 - 72 - "await-lock": ["await-lock@2.2.2", "", {}, "sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw=="], 73 - 74 - "bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="], 75 - 76 - "core-js": ["core-js@3.47.0", "", {}, "sha512-c3Q2VVkGAUyupsjRnaNX6u8Dq2vAdzm9iuPj5FW0fRxzlxgq9Q39MDq10IvmQSpLgHQNyQzQmOo6bgGHmH3NNg=="], 77 - 78 - "hono": ["hono@4.11.3", "", {}, "sha512-PmQi306+M/ct/m5s66Hrg+adPnkD5jiO6IjA7WhWw0gSBSo1EcRegwuI1deZ+wd5pzCGynCcn2DprnE4/yEV4w=="], 79 - 80 - "ipaddr.js": ["ipaddr.js@2.3.0", "", {}, "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg=="], 81 - 82 - "iso-datestring-validator": ["iso-datestring-validator@2.2.2", "", {}, "sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA=="], 83 - 84 - "jose": ["jose@5.10.0", "", {}, "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg=="], 85 - 86 - "lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], 87 - 88 - "multiformats": ["multiformats@9.9.0", "", {}, "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg=="], 89 - 90 - "tlds": ["tlds@1.261.0", "", { "bin": { "tlds": "bin.js" } }, "sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA=="], 91 - 92 - "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], 93 - 94 - "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], 95 - 96 - "uint8arrays": ["uint8arrays@3.0.0", "", { "dependencies": { "multiformats": "^9.4.2" } }, "sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA=="], 97 - 98 - "undici": ["undici@6.23.0", "", {}, "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g=="], 99 - 100 - "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], 101 - 102 - "unicode-segmenter": ["unicode-segmenter@0.14.5", "", {}, "sha512-jHGmj2LUuqDcX3hqY12Ql+uhUTn8huuxNZGq7GvtF6bSybzH3aFgedYu/KTzQStEgt1Ra2F3HxadNXsNjb3m3g=="], 103 - 104 - "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], 22 + "@atproto-labs/did-resolver": [ 23 + "@atproto-labs/did-resolver@0.2.5", 24 + "", 25 + { 26 + "dependencies": { 27 + "@atproto-labs/fetch": "0.2.3", 28 + "@atproto-labs/pipe": "0.1.1", 29 + "@atproto-labs/simple-store": "0.3.0", 30 + "@atproto-labs/simple-store-memory": "0.1.4", 31 + "@atproto/did": "0.2.4", 32 + "zod": "^3.23.8" 33 + } 34 + }, 35 + "sha512-he7EC6OMSifNs01a4RT9mta/yYitoKDzlK9ty2TFV5Uj/+HpB4vYMRdIDFrRW0Hcsehy90E2t/dw0t7361MEKQ==" 36 + ], 37 + "@atproto-labs/fetch": [ 38 + "@atproto-labs/fetch@0.2.3", 39 + "", 40 + { 41 + "dependencies": { 42 + "@atproto-labs/pipe": "0.1.1" 43 + } 44 + }, 45 + "sha512-NZtbJOCbxKUFRFKMpamT38PUQMY0hX0p7TG5AEYOPhZKZEP7dHZ1K2s1aB8MdVH0qxmqX7nQleNrrvLf09Zfdw==" 46 + ], 47 + "@atproto-labs/fetch-node": [ 48 + "@atproto-labs/fetch-node@0.2.0", 49 + "", 50 + { 51 + "dependencies": { 52 + "@atproto-labs/fetch": "0.2.3", 53 + "@atproto-labs/pipe": "0.1.1", 54 + "ipaddr.js": "^2.1.0", 55 + "undici": "^6.14.1" 56 + } 57 + }, 58 + "sha512-Krq09nH/aeoiU2s9xdHA0FjTEFWG9B5FFenipv1iRixCcPc7V3DhTNDawxG9gI8Ny0k4dBVS9WTRN/IDzBx86Q==" 59 + ], 60 + "@atproto-labs/handle-resolver": [ 61 + "@atproto-labs/handle-resolver@0.3.5", 62 + "", 63 + { 64 + "dependencies": { 65 + "@atproto-labs/simple-store": "0.3.0", 66 + "@atproto-labs/simple-store-memory": "0.1.4", 67 + "@atproto/did": "0.2.4", 68 + "zod": "^3.23.8" 69 + } 70 + }, 71 + "sha512-r3b+plCh/0arN535Aool9gL6yTSbAPDOyReURbA2TWAaeW4vrSJPwR6yYUx0k0vmVPjkZPIdUVd63bG/+VG5MA==" 72 + ], 73 + "@atproto-labs/handle-resolver-node": [ 74 + "@atproto-labs/handle-resolver-node@0.1.24", 75 + "", 76 + { 77 + "dependencies": { 78 + "@atproto-labs/fetch-node": "0.2.0", 79 + "@atproto-labs/handle-resolver": "0.3.5", 80 + "@atproto/did": "0.2.4" 81 + } 82 + }, 83 + "sha512-w/zvktigmRQpOLQQclp48tbb2K/2XW8j1szoIpT8T8v6P5dZ8GGVDIEF142xQMX9vWToFqMTu1P2yOuz8e3Ilg==" 84 + ], 85 + "@atproto-labs/identity-resolver": [ 86 + "@atproto-labs/identity-resolver@0.3.5", 87 + "", 88 + { 89 + "dependencies": { 90 + "@atproto-labs/did-resolver": "0.2.5", 91 + "@atproto-labs/handle-resolver": "0.3.5" 92 + } 93 + }, 94 + "sha512-kSxnreUSPhKL77doUbSl/9I6Y9qpkpD7MMJoYFQVU/WG0PB90tzfIb6DNuWsjbU2I5Q91Nzc4Tm4VJMV+OPKGQ==" 95 + ], 96 + "@atproto-labs/pipe": [ 97 + "@atproto-labs/pipe@0.1.1", 98 + "", 99 + {}, 100 + "sha512-hdNw2oUs2B6BN1lp+32pF7cp8EMKuIN5Qok2Vvv/aOpG/3tNSJ9YkvfI0k6Zd188LeDDYRUpYpxcoFIcGH/FNg==" 101 + ], 102 + "@atproto-labs/simple-store": [ 103 + "@atproto-labs/simple-store@0.3.0", 104 + "", 105 + {}, 106 + "sha512-nOb6ONKBRJHRlukW1sVawUkBqReLlLx6hT35VS3imaNPwiXDxLnTK7lxw3Lrl9k5yugSBDQAkZAq3MPTEFSUBQ==" 107 + ], 108 + "@atproto-labs/simple-store-memory": [ 109 + "@atproto-labs/simple-store-memory@0.1.4", 110 + "", 111 + { 112 + "dependencies": { 113 + "@atproto-labs/simple-store": "0.3.0", 114 + "lru-cache": "^10.2.0" 115 + } 116 + }, 117 + "sha512-3mKY4dP8I7yKPFj9VKpYyCRzGJOi5CEpOLPlRhoJyLmgs3J4RzDrjn323Oakjz2Aj2JzRU/AIvWRAZVhpYNJHw==" 118 + ], 119 + "@atproto/api": [ 120 + "@atproto/api@0.18.13", 121 + "", 122 + { 123 + "dependencies": { 124 + "@atproto/common-web": "^0.4.11", 125 + "@atproto/lexicon": "^0.6.0", 126 + "@atproto/syntax": "^0.4.2", 127 + "@atproto/xrpc": "^0.7.7", 128 + "await-lock": "^2.2.2", 129 + "multiformats": "^9.9.0", 130 + "tlds": "^1.234.0", 131 + "zod": "^3.23.8" 132 + } 133 + }, 134 + "sha512-CULZ01pSJDltLS/Gc9MMrhFzB6OM3ezyZw7KoeLT/sBfwgA1ddA4mWdTh7DIRosPRigXtA05bnoiCutZbQDo+Q==" 135 + ], 136 + "@atproto/common-web": [ 137 + "@atproto/common-web@0.4.11", 138 + "", 139 + { 140 + "dependencies": { 141 + "@atproto/lex-data": "0.0.7", 142 + "@atproto/lex-json": "0.0.7", 143 + "zod": "^3.23.8" 144 + } 145 + }, 146 + "sha512-VHejNmSABU8/03VrQ3e36AmT5U3UIeio+qSUqCrO1oNgrJcWfGy1rpj0FVtUugWF8Un29+yzkukzWGZfXL70rQ==" 147 + ], 148 + "@atproto/did": [ 149 + "@atproto/did@0.2.4", 150 + "", 151 + { 152 + "dependencies": { 153 + "zod": "^3.23.8" 154 + } 155 + }, 156 + "sha512-nxNiCgXeo7pfjojq9fpfZxCO0X0xUipNVKW+AHNZwQKiUDt6zYL0VXEfm8HBUwQOCmKvj2pRRSM1Cur+tUWk3g==" 157 + ], 158 + "@atproto/jwk": [ 159 + "@atproto/jwk@0.6.0", 160 + "", 161 + { 162 + "dependencies": { 163 + "multiformats": "^9.9.0", 164 + "zod": "^3.23.8" 165 + } 166 + }, 167 + "sha512-bDoJPvt7TrQVi/rBfBrSSpGykhtIriKxeYCYQTiPRKFfyRhbgpElF0wPXADjIswnbzZdOwbY63az4E/CFVT3Tw==" 168 + ], 169 + "@atproto/jwk-jose": [ 170 + "@atproto/jwk-jose@0.1.11", 171 + "", 172 + { 173 + "dependencies": { 174 + "@atproto/jwk": "0.6.0", 175 + "jose": "^5.2.0" 176 + } 177 + }, 178 + "sha512-i4Fnr2sTBYmMmHXl7NJh8GrCH+tDQEVWrcDMDnV5DjJfkgT17wIqvojIw9SNbSL4Uf0OtfEv6AgG0A+mgh8b5Q==" 179 + ], 180 + "@atproto/jwk-webcrypto": [ 181 + "@atproto/jwk-webcrypto@0.2.0", 182 + "", 183 + { 184 + "dependencies": { 185 + "@atproto/jwk": "0.6.0", 186 + "@atproto/jwk-jose": "0.1.11", 187 + "zod": "^3.23.8" 188 + } 189 + }, 190 + "sha512-UmgRrrEAkWvxwhlwe30UmDOdTEFidlIzBC7C3cCbeJMcBN1x8B3KH+crXrsTqfWQBG58mXgt8wgSK3Kxs2LhFg==" 191 + ], 192 + "@atproto/lex-data": [ 193 + "@atproto/lex-data@0.0.7", 194 + "", 195 + { 196 + "dependencies": { 197 + "@atproto/syntax": "0.4.2", 198 + "multiformats": "^9.9.0", 199 + "tslib": "^2.8.1", 200 + "uint8arrays": "3.0.0", 201 + "unicode-segmenter": "^0.14.0" 202 + } 203 + }, 204 + "sha512-W/Q5o9o7n2Sv3UywckChu01X5lwQUtaiiOkGJLnRsdkQTyC6813nPgY+p2sG7NwwM+82lu+FUV9fE/Ul3VqaJw==" 205 + ], 206 + "@atproto/lex-json": [ 207 + "@atproto/lex-json@0.0.7", 208 + "", 209 + { 210 + "dependencies": { 211 + "@atproto/lex-data": "0.0.7", 212 + "tslib": "^2.8.1" 213 + } 214 + }, 215 + "sha512-bjNPD5M/MhLfjNM7tcxuls80UgXpHqxdOxDXEUouAtZQV/nIDhGjmNUvKxOmOgnDsiZRnT2g5y3onrnjH3a44g==" 216 + ], 217 + "@atproto/lexicon": [ 218 + "@atproto/lexicon@0.6.0", 219 + "", 220 + { 221 + "dependencies": { 222 + "@atproto/common-web": "^0.4.7", 223 + "@atproto/syntax": "^0.4.2", 224 + "iso-datestring-validator": "^2.2.2", 225 + "multiformats": "^9.9.0", 226 + "zod": "^3.23.8" 227 + } 228 + }, 229 + "sha512-5veb8aD+J5M0qszLJ+73KSFsFrJBgAY/nM1TSAJvGY7fNc9ZAT+PSUlmIyrdye9YznAZ07yktalls/TwNV7cHQ==" 230 + ], 231 + "@atproto/oauth-client": [ 232 + "@atproto/oauth-client@0.5.13", 233 + "", 234 + { 235 + "dependencies": { 236 + "@atproto-labs/did-resolver": "0.2.5", 237 + "@atproto-labs/fetch": "0.2.3", 238 + "@atproto-labs/handle-resolver": "0.3.5", 239 + "@atproto-labs/identity-resolver": "0.3.5", 240 + "@atproto-labs/simple-store": "0.3.0", 241 + "@atproto-labs/simple-store-memory": "0.1.4", 242 + "@atproto/did": "0.2.4", 243 + "@atproto/jwk": "0.6.0", 244 + "@atproto/oauth-types": "0.6.1", 245 + "@atproto/xrpc": "0.7.7", 246 + "core-js": "^3", 247 + "multiformats": "^9.9.0", 248 + "zod": "^3.23.8" 249 + } 250 + }, 251 + "sha512-FLbqHkC7BAVZ90LHVzSxQf+s8ZNIQI4TsDuhYDyzi7lYtktFHDbgd88KuM2ClJFOtGCsSS17yR1Joy925tDSaA==" 252 + ], 253 + "@atproto/oauth-client-node": [ 254 + "@atproto/oauth-client-node@0.3.15", 255 + "", 256 + { 257 + "dependencies": { 258 + "@atproto-labs/did-resolver": "0.2.5", 259 + "@atproto-labs/handle-resolver-node": "0.1.24", 260 + "@atproto-labs/simple-store": "0.3.0", 261 + "@atproto/did": "0.2.4", 262 + "@atproto/jwk": "0.6.0", 263 + "@atproto/jwk-jose": "0.1.11", 264 + "@atproto/jwk-webcrypto": "0.2.0", 265 + "@atproto/oauth-client": "0.5.13", 266 + "@atproto/oauth-types": "0.6.1" 267 + } 268 + }, 269 + "sha512-iuT7QrLli7IyB4px1+lHvm/YoIRfNRpbNG9seJRtu5eX4N5aLsBP6vpXs9rCygd1+/15LcLRAAGKVEcrLT9tXA==" 270 + ], 271 + "@atproto/oauth-types": [ 272 + "@atproto/oauth-types@0.6.1", 273 + "", 274 + { 275 + "dependencies": { 276 + "@atproto/did": "0.2.4", 277 + "@atproto/jwk": "0.6.0", 278 + "zod": "^3.23.8" 279 + } 280 + }, 281 + "sha512-3z92GN/6zCq9E2GTTfZM27tWEbvi1qwFSA7KoS5+wqBC4kSsLvnLxmbKH402Z40DfWS4YWqw0DkHsgP0LNFDEA==" 282 + ], 283 + "@atproto/syntax": [ 284 + "@atproto/syntax@0.4.2", 285 + "", 286 + {}, 287 + "sha512-X9XSRPinBy/0VQ677j8VXlBsYSsUXaiqxWVpGGxJYsAhugdQRb0jqaVKJFtm6RskeNkV6y9xclSUi9UYG/COrA==" 288 + ], 289 + "@atproto/xrpc": [ 290 + "@atproto/xrpc@0.7.7", 291 + "", 292 + { 293 + "dependencies": { 294 + "@atproto/lexicon": "^0.6.0", 295 + "zod": "^3.23.8" 296 + } 297 + }, 298 + "sha512-K1ZyO/BU8JNtXX5dmPp7b5UrkLMMqpsIa/Lrj5D3Su+j1Xwq1m6QJ2XJ1AgjEjkI1v4Muzm7klianLE6XGxtmA==" 299 + ], 300 + "@types/bun": [ 301 + "@types/bun@1.3.5", 302 + "", 303 + { 304 + "dependencies": { 305 + "bun-types": "1.3.5" 306 + } 307 + }, 308 + "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w==" 309 + ], 310 + "@types/node": [ 311 + "@types/node@25.0.6", 312 + "", 313 + { 314 + "dependencies": { 315 + "undici-types": "~7.16.0" 316 + } 317 + }, 318 + "sha512-NNu0sjyNxpoiW3YuVFfNz7mxSQ+S4X2G28uqg2s+CzoqoQjLPsWSbsFFyztIAqt2vb8kfEAsJNepMGPTxFDx3Q==" 319 + ], 320 + "await-lock": [ 321 + "await-lock@2.2.2", 322 + "", 323 + {}, 324 + "sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==" 325 + ], 326 + "bun-types": [ 327 + "bun-types@1.3.5", 328 + "", 329 + { 330 + "dependencies": { 331 + "@types/node": "*" 332 + } 333 + }, 334 + "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw==" 335 + ], 336 + "core-js": [ 337 + "core-js@3.47.0", 338 + "", 339 + {}, 340 + "sha512-c3Q2VVkGAUyupsjRnaNX6u8Dq2vAdzm9iuPj5FW0fRxzlxgq9Q39MDq10IvmQSpLgHQNyQzQmOo6bgGHmH3NNg==" 341 + ], 342 + "hono": [ 343 + "hono@4.11.3", 344 + "", 345 + {}, 346 + "sha512-PmQi306+M/ct/m5s66Hrg+adPnkD5jiO6IjA7WhWw0gSBSo1EcRegwuI1deZ+wd5pzCGynCcn2DprnE4/yEV4w==" 347 + ], 348 + "ipaddr.js": [ 349 + "ipaddr.js@2.3.0", 350 + "", 351 + {}, 352 + "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==" 353 + ], 354 + "iso-datestring-validator": [ 355 + "iso-datestring-validator@2.2.2", 356 + "", 357 + {}, 358 + "sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA==" 359 + ], 360 + "jose": [ 361 + "jose@5.10.0", 362 + "", 363 + {}, 364 + "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==" 365 + ], 366 + "lru-cache": [ 367 + "lru-cache@10.4.3", 368 + "", 369 + {}, 370 + "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==" 371 + ], 372 + "multiformats": [ 373 + "multiformats@9.9.0", 374 + "", 375 + {}, 376 + "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==" 377 + ], 378 + "tlds": [ 379 + "tlds@1.261.0", 380 + "", 381 + { 382 + "bin": { 383 + "tlds": "bin.js" 384 + } 385 + }, 386 + "sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==" 387 + ], 388 + "tslib": [ 389 + "tslib@2.8.1", 390 + "", 391 + {}, 392 + "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" 393 + ], 394 + "typescript": [ 395 + "typescript@5.9.3", 396 + "", 397 + { 398 + "bin": { 399 + "tsc": "bin/tsc", 400 + "tsserver": "bin/tsserver" 401 + } 402 + }, 403 + "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==" 404 + ], 405 + "uint8arrays": [ 406 + "uint8arrays@3.0.0", 407 + "", 408 + { 409 + "dependencies": { 410 + "multiformats": "^9.4.2" 411 + } 412 + }, 413 + "sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA==" 414 + ], 415 + "undici": [ 416 + "undici@6.23.0", 417 + "", 418 + {}, 419 + "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==" 420 + ], 421 + "undici-types": [ 422 + "undici-types@7.16.0", 423 + "", 424 + {}, 425 + "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==" 426 + ], 427 + "unicode-segmenter": [ 428 + "unicode-segmenter@0.14.5", 429 + "", 430 + {}, 431 + "sha512-jHGmj2LUuqDcX3hqY12Ql+uhUTn8huuxNZGq7GvtF6bSybzH3aFgedYu/KTzQStEgt1Ra2F3HxadNXsNjb3m3g==" 432 + ], 433 + "zod": [ 434 + "zod@3.25.76", 435 + "", 436 + {}, 437 + "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==" 438 + ], 105 439 } 106 440 }
-11
logrotate.conf
··· 1 - /home/exedev/stdeditor/data/app.log /home/exedev/stdeditor/data/cleanup.log { 2 - daily 3 - rotate 14 4 - compress 5 - delaycompress 6 - missingok 7 - notifempty 8 - create 640 exedev exedev 9 - dateext 10 - dateformat -%Y%m%d 11 - }
+1 -1
package.json
··· 1 1 { 2 - "name": "stdeditor", 2 + "name": "std.pub", 3 3 "module": "src/server.ts", 4 4 "type": "module", 5 5 "private": true,
+17 -15
scripts/cleanup.ts
··· 2 2 /** 3 3 * Database cleanup script 4 4 * Removes expired OAuth states and optionally old sessions 5 - * Run via cron: 0 * * * * /home/exedev/.bun/bin/bun /home/exedev/stdeditor/scripts/cleanup.ts 5 + * Run via cron: 0 * * * * /home/exedev/.bun/bin/bun /home/exedev/std.pub/scripts/cleanup.ts 6 6 */ 7 7 8 - import { Database } from 'bun:sqlite'; 9 - import * as path from 'path'; 8 + import { Database } from "bun:sqlite"; 9 + import * as path from "path"; 10 10 11 - const DATA_DIR = process.env.DATA_DIR || './data'; 12 - const DB_PATH = path.join(DATA_DIR, 'oauth.db'); 11 + const DATA_DIR = process.env.DATA_DIR || "./data"; 12 + const DB_PATH = path.join(DATA_DIR, "oauth.db"); 13 13 14 14 try { 15 15 const db = new Database(DB_PATH); 16 - 16 + 17 17 // Clean up OAuth states older than 1 hour 18 18 const statesResult = db.run( 19 - `DELETE FROM oauth_states WHERE created_at < strftime('%s', 'now') - 3600` 19 + `DELETE FROM oauth_states WHERE created_at < strftime('%s', 'now') - 3600`, 20 20 ); 21 - 21 + 22 22 // Clean up sessions older than 30 days (optional - sessions may still be valid) 23 23 const sessionsResult = db.run( 24 - `DELETE FROM oauth_sessions WHERE updated_at < strftime('%s', 'now') - 2592000` 24 + `DELETE FROM oauth_sessions WHERE updated_at < strftime('%s', 'now') - 2592000`, 25 25 ); 26 - 26 + 27 27 // Vacuum the database to reclaim space 28 - db.run('VACUUM'); 29 - 28 + db.run("VACUUM"); 29 + 30 30 const timestamp = new Date().toISOString(); 31 - console.log(`[${timestamp}] Cleanup complete: removed old states and sessions, vacuumed database`); 32 - 31 + console.log( 32 + `[${timestamp}] Cleanup complete: removed old states and sessions, vacuumed database`, 33 + ); 34 + 33 35 db.close(); 34 36 } catch (error) { 35 - console.error('Cleanup failed:', error); 37 + console.error("Cleanup failed:", error); 36 38 process.exit(1); 37 39 }
+46 -35
src/lib/oauth.ts
··· 1 - import { NodeOAuthClient } from '@atproto/oauth-client-node'; 2 - import type { NodeSavedSession, NodeSavedState } from '@atproto/oauth-client-node'; 3 - import { JoseKey } from '@atproto/jwk-jose'; 4 - import { Agent } from '@atproto/api'; 5 - import { Database } from 'bun:sqlite'; 6 - import * as fs from 'fs'; 7 - import * as path from 'path'; 1 + import { NodeOAuthClient } from "@atproto/oauth-client-node"; 2 + import type { 3 + NodeSavedSession, 4 + NodeSavedState, 5 + } from "@atproto/oauth-client-node"; 6 + import { JoseKey } from "@atproto/jwk-jose"; 7 + import { Agent } from "@atproto/api"; 8 + import { Database } from "bun:sqlite"; 9 + import * as fs from "fs"; 10 + import * as path from "path"; 8 11 9 12 // Constants 10 - const PUBLIC_URL = process.env.PUBLIC_URL || 'http://localhost:8000'; 11 - const DATA_DIR = process.env.DATA_DIR || './data'; 12 - const DB_PATH = path.join(DATA_DIR, 'oauth.db'); 13 - const KEYS_PATH = path.join(DATA_DIR, 'private-key.json'); 13 + const PUBLIC_URL = process.env.PUBLIC_URL || "http://localhost:8000"; 14 + const DATA_DIR = process.env.DATA_DIR || "./data"; 15 + const DB_PATH = path.join(DATA_DIR, "oauth.db"); 16 + const KEYS_PATH = path.join(DATA_DIR, "private-key.json"); 14 17 15 18 // Ensure data directory exists 16 19 if (!fs.existsSync(DATA_DIR)) { ··· 38 41 `); 39 42 40 43 // Clean up old states (older than 1 hour) 41 - db.run(`DELETE FROM oauth_states WHERE created_at < strftime('%s', 'now') - 3600`); 44 + db.run( 45 + `DELETE FROM oauth_states WHERE created_at < strftime('%s', 'now') - 3600`, 46 + ); 42 47 43 48 // State store implementation 44 49 const stateStore = { ··· 46 51 const stateJson = JSON.stringify(state); 47 52 db.run( 48 53 `INSERT OR REPLACE INTO oauth_states (key, state, created_at) VALUES (?, ?, strftime('%s', 'now'))`, 49 - [key, stateJson] 54 + [key, stateJson], 50 55 ); 51 56 }, 52 57 async get(key: string): Promise<NodeSavedState | undefined> { 53 - const row = db.query(`SELECT state FROM oauth_states WHERE key = ?`).get(key) as { state: string } | null; 58 + const row = db 59 + .query(`SELECT state FROM oauth_states WHERE key = ?`) 60 + .get(key) as { state: string } | null; 54 61 if (!row) return undefined; 55 62 return JSON.parse(row.state); 56 63 }, ··· 65 72 const sessionJson = JSON.stringify(session); 66 73 db.run( 67 74 `INSERT OR REPLACE INTO oauth_sessions (did, session, updated_at) VALUES (?, ?, strftime('%s', 'now'))`, 68 - [did, sessionJson] 75 + [did, sessionJson], 69 76 ); 70 77 }, 71 78 async get(did: string): Promise<NodeSavedSession | undefined> { 72 - const row = db.query(`SELECT session FROM oauth_sessions WHERE did = ?`).get(did) as { session: string } | null; 79 + const row = db 80 + .query(`SELECT session FROM oauth_sessions WHERE did = ?`) 81 + .get(did) as { session: string } | null; 73 82 if (!row) return undefined; 74 83 return JSON.parse(row.session); 75 84 }, ··· 81 90 // Generate or load private key for confidential client 82 91 async function getOrCreatePrivateKey(): Promise<JoseKey> { 83 92 if (fs.existsSync(KEYS_PATH)) { 84 - const keyData = JSON.parse(fs.readFileSync(KEYS_PATH, 'utf-8')); 93 + const keyData = JSON.parse(fs.readFileSync(KEYS_PATH, "utf-8")); 85 94 return JoseKey.fromJWK(keyData, keyData.kid); 86 95 } 87 - 96 + 88 97 // Generate a new ES256 key 89 - const key = await JoseKey.generate(['ES256'], crypto.randomUUID()); 98 + const key = await JoseKey.generate(["ES256"], crypto.randomUUID()); 90 99 const jwk = key.privateJwk; 91 - 100 + 92 101 // Save to disk with restrictive permissions (owner read/write only) 93 102 fs.writeFileSync(KEYS_PATH, JSON.stringify(jwk, null, 2), { mode: 0o600 }); 94 - 103 + 95 104 return key; 96 105 } 97 106 ··· 101 110 async function initOAuthClient(): Promise<NodeOAuthClient> { 102 111 if (oauthClientInstance) return oauthClientInstance; 103 112 if (initPromise) return initPromise; 104 - 113 + 105 114 initPromise = (async () => { 106 115 const privateKey = await getOrCreatePrivateKey(); 107 - 116 + 108 117 oauthClientInstance = new NodeOAuthClient({ 109 118 clientMetadata: { 110 119 client_id: `${PUBLIC_URL}/client-metadata.json`, 111 - client_name: 'stdeditor', 120 + client_name: "std.pub", 112 121 client_uri: PUBLIC_URL, 113 122 redirect_uris: [`${PUBLIC_URL}/auth/callback`], 114 - scope: 'atproto transition:generic', 115 - grant_types: ['authorization_code', 'refresh_token'], 116 - response_types: ['code'], 117 - application_type: 'web', 118 - token_endpoint_auth_method: 'private_key_jwt', 119 - token_endpoint_auth_signing_alg: 'ES256', 123 + scope: "atproto transition:generic", 124 + grant_types: ["authorization_code", "refresh_token"], 125 + response_types: ["code"], 126 + application_type: "web", 127 + token_endpoint_auth_method: "private_key_jwt", 128 + token_endpoint_auth_signing_alg: "ES256", 120 129 dpop_bound_access_tokens: true, 121 130 jwks_uri: `${PUBLIC_URL}/jwks.json`, 122 131 }, ··· 145 154 return client.jwks; 146 155 } 147 156 148 - export async function getAgentForSession(did: string): Promise<{ agent: Agent; did: string; handle: string }> { 157 + export async function getAgentForSession( 158 + did: string, 159 + ): Promise<{ agent: Agent; did: string; handle: string }> { 149 160 const client = await getOAuthClient(); 150 161 const oauthSession = await client.restore(did); 151 - 162 + 152 163 if (!oauthSession) { 153 - throw new Error('Session not found'); 164 + throw new Error("Session not found"); 154 165 } 155 166 156 167 const agent = new Agent(oauthSession); 157 - 168 + 158 169 // Fetch profile to get handle 159 170 const profile = await agent.getProfile({ actor: did }); 160 - 171 + 161 172 return { 162 173 agent, 163 174 did,
+81 -66
src/routes/auth.ts
··· 1 - import { Hono } from 'hono'; 2 - import { getCookie, setCookie, deleteCookie } from 'hono/cookie'; 3 - import { html } from 'hono/html'; 4 - import { getOAuthClient, getClientMetadata, getJwks, deleteSession } from '../lib/oauth'; 5 - import { layout } from '../views/layouts/main'; 6 - import { csrfField } from '../lib/csrf'; 1 + import { Hono } from "hono"; 2 + import { getCookie, setCookie, deleteCookie } from "hono/cookie"; 3 + import { html } from "hono/html"; 4 + import { 5 + getOAuthClient, 6 + getClientMetadata, 7 + getJwks, 8 + deleteSession, 9 + } from "../lib/oauth"; 10 + import { layout } from "../views/layouts/main"; 11 + import { csrfField } from "../lib/csrf"; 7 12 8 13 export const authRoutes = new Hono(); 9 14 10 15 // Client metadata endpoint (required for OAuth) 11 - authRoutes.get('/client-metadata.json', async (c) => { 16 + authRoutes.get("/client-metadata.json", async (c) => { 12 17 try { 13 18 const metadata = await getClientMetadata(); 14 19 return c.json(metadata); 15 20 } catch (error) { 16 - console.error('Error getting client metadata:', error); 17 - return c.json({ error: 'Failed to get client metadata' }, 500); 21 + console.error("Error getting client metadata:", error); 22 + return c.json({ error: "Failed to get client metadata" }, 500); 18 23 } 19 24 }); 20 25 21 26 // JWKS endpoint (required for confidential clients) 22 - authRoutes.get('/jwks.json', async (c) => { 27 + authRoutes.get("/jwks.json", async (c) => { 23 28 try { 24 29 const jwks = await getJwks(); 25 30 return c.json(jwks); 26 31 } catch (error) { 27 - console.error('Error getting JWKS:', error); 28 - return c.json({ error: 'Failed to get JWKS' }, 500); 32 + console.error("Error getting JWKS:", error); 33 + return c.json({ error: "Failed to get JWKS" }, 500); 29 34 } 30 35 }); 31 36 32 37 // Login page 33 - authRoutes.get('/login', async (c) => { 34 - const error = c.req.query('error'); 35 - const csrfToken = c.get('csrfToken') as string; 36 - 38 + authRoutes.get("/login", async (c) => { 39 + const error = c.req.query("error"); 40 + const csrfToken = c.get("csrfToken") as string; 41 + 37 42 const content = html` 38 43 <div class="auth-form"> 39 44 <h1>Login with Bluesky</h1> 40 - 41 - ${error ? html` 42 - <div class="error-message"> 43 - ${error === 'handle_required' ? 'Please enter your handle or DID.' : 44 - error === 'authorization_failed' ? 'Authorization failed. Please try again.' : 45 - error === 'callback_failed' ? 'Login failed. Please try again.' : 46 - 'An error occurred. Please try again.'} 47 - </div> 48 - ` : ''} 49 - 45 + 46 + ${error 47 + ? html` 48 + <div class="error-message"> 49 + ${error === "handle_required" 50 + ? "Please enter your handle or DID." 51 + : error === "authorization_failed" 52 + ? "Authorization failed. Please try again." 53 + : error === "callback_failed" 54 + ? "Login failed. Please try again." 55 + : "An error occurred. Please try again."} 56 + </div> 57 + ` 58 + : ""} 59 + 50 60 <form action="/auth/login" method="POST"> 51 61 ${csrfField(csrfToken)} 52 62 <div class="form-group"> 53 63 <label for="handle">Handle or DID</label> 54 - <input 55 - type="text" 56 - id="handle" 57 - name="handle" 64 + <input 65 + type="text" 66 + id="handle" 67 + name="handle" 58 68 placeholder="yourname.bsky.social" 59 69 required 60 70 autocomplete="username" 61 71 autocapitalize="none" 62 72 /> 63 - <small>Enter your Bluesky handle (e.g., yourname.bsky.social) or DID</small> 73 + <small 74 + >Enter your Bluesky handle (e.g., yourname.bsky.social) or 75 + DID</small 76 + > 64 77 </div> 65 78 <button type="submit" class="btn btn-primary">Login</button> 66 79 </form> 67 80 </div> 68 81 `; 69 - 70 - return c.html(layout(content, { title: 'Login - stdeditor' })); 82 + 83 + return c.html(layout(content, { title: "Login - std.pub" })); 71 84 }); 72 85 73 86 // Handle login form submission 74 - authRoutes.post('/login', async (c) => { 87 + authRoutes.post("/login", async (c) => { 75 88 const body = await c.req.parseBody(); 76 89 let handle = body.handle as string; 77 - 90 + 78 91 if (!handle) { 79 - return c.redirect('/auth/login?error=handle_required'); 92 + return c.redirect("/auth/login?error=handle_required"); 80 93 } 81 - 94 + 82 95 // Trim and normalize handle 83 96 handle = handle.trim().toLowerCase(); 84 - 97 + 85 98 // Remove @ prefix if present 86 - if (handle.startsWith('@')) { 99 + if (handle.startsWith("@")) { 87 100 handle = handle.slice(1); 88 101 } 89 - 102 + 90 103 try { 91 104 const client = await getOAuthClient(); 92 105 const url = await client.authorize(handle, { 93 - scope: 'atproto transition:generic', 106 + scope: "atproto transition:generic", 94 107 }); 95 - 108 + 96 109 return c.redirect(url.toString()); 97 110 } catch (error) { 98 - console.error('Login error:', error); 99 - return c.redirect('/auth/login?error=authorization_failed'); 111 + console.error("Login error:", error); 112 + return c.redirect("/auth/login?error=authorization_failed"); 100 113 } 101 114 }); 102 115 103 116 // OAuth callback 104 - authRoutes.get('/callback', async (c) => { 117 + authRoutes.get("/callback", async (c) => { 105 118 const url = new URL(c.req.url); 106 119 const params = url.searchParams; 107 - 120 + 108 121 // Check for error from authorization server 109 - const error = params.get('error'); 122 + const error = params.get("error"); 110 123 if (error) { 111 - console.error('OAuth error:', error, params.get('error_description')); 112 - return c.redirect('/auth/login?error=callback_failed'); 124 + console.error("OAuth error:", error, params.get("error_description")); 125 + return c.redirect("/auth/login?error=callback_failed"); 113 126 } 114 - 127 + 115 128 try { 116 129 const client = await getOAuthClient(); 117 130 const { session } = await client.callback(params); 118 - 131 + 119 132 // Store the DID in a cookie for session management 120 133 // The actual OAuth session is stored in the database by the OAuth client 121 - setCookie(c, 'session', session.did, { 134 + setCookie(c, "session", session.did, { 122 135 httpOnly: true, 123 - secure: process.env.NODE_ENV === 'production' || process.env.PUBLIC_URL?.startsWith('https'), 124 - sameSite: 'Lax', 136 + secure: 137 + process.env.NODE_ENV === "production" || 138 + process.env.PUBLIC_URL?.startsWith("https"), 139 + sameSite: "Lax", 125 140 maxAge: 60 * 60 * 24 * 7, // 7 days 126 - path: '/', 141 + path: "/", 127 142 }); 128 - 129 - return c.redirect('/'); 143 + 144 + return c.redirect("/"); 130 145 } catch (error) { 131 - console.error('Callback error:', error); 132 - return c.redirect('/auth/login?error=callback_failed'); 146 + console.error("Callback error:", error); 147 + return c.redirect("/auth/login?error=callback_failed"); 133 148 } 134 149 }); 135 150 136 151 // Logout 137 - authRoutes.get('/logout', async (c) => { 138 - const did = getCookie(c, 'session'); 139 - 152 + authRoutes.get("/logout", async (c) => { 153 + const did = getCookie(c, "session"); 154 + 140 155 if (did) { 141 156 try { 142 157 // Delete the OAuth session from the database 143 158 await deleteSession(did); 144 159 } catch (error) { 145 - console.error('Error deleting session:', error); 160 + console.error("Error deleting session:", error); 146 161 } 147 162 } 148 - 149 - deleteCookie(c, 'session', { path: '/' }); 150 - return c.redirect('/'); 163 + 164 + deleteCookie(c, "session", { path: "/" }); 165 + return c.redirect("/"); 151 166 });
+305 -175
src/routes/documents.ts
··· 1 - import { Hono } from 'hono'; 2 - import { html, raw } from 'hono/html'; 3 - import { layout } from '../views/layouts/main'; 4 - import { requireAuth, type Session } from '../lib/session'; 5 - import { csrfField } from '../lib/csrf'; 6 - import { isValidTID } from '../lib/validation'; 1 + import { Hono } from "hono"; 2 + import { html, raw } from "hono/html"; 3 + import { layout } from "../views/layouts/main"; 4 + import { requireAuth, type Session } from "../lib/session"; 5 + import { csrfField } from "../lib/csrf"; 6 + import { isValidTID } from "../lib/validation"; 7 7 8 8 export const documentRoutes = new Hono(); 9 9 10 - const DOCUMENT_COLLECTION = 'site.standard.document'; 11 - const PUBLICATION_COLLECTION = 'site.standard.publication'; 10 + const DOCUMENT_COLLECTION = "site.standard.document"; 11 + const PUBLICATION_COLLECTION = "site.standard.publication"; 12 12 13 13 // List all documents 14 - documentRoutes.get('/', async (c) => { 14 + documentRoutes.get("/", async (c) => { 15 15 let session: Session; 16 16 try { 17 17 session = requireAuth(c); 18 18 } catch { 19 - return c.redirect('/auth/login'); 19 + return c.redirect("/auth/login"); 20 20 } 21 21 22 - const filter = c.req.query('filter') || 'all'; 22 + const filter = c.req.query("filter") || "all"; 23 23 24 24 try { 25 25 const response = await session.agent!.com.atproto.repo.listRecords({ ··· 31 31 let documents = response.data.records; 32 32 33 33 // Filter by draft/published status 34 - if (filter === 'drafts') { 34 + if (filter === "drafts") { 35 35 documents = documents.filter((doc: any) => { 36 36 const tags = doc.value.tags || []; 37 - return tags.includes('draft'); 37 + return tags.includes("draft"); 38 38 }); 39 - } else if (filter === 'published') { 39 + } else if (filter === "published") { 40 40 documents = documents.filter((doc: any) => { 41 41 const tags = doc.value.tags || []; 42 - return !tags.includes('draft'); 42 + return !tags.includes("draft"); 43 43 }); 44 44 } 45 45 46 46 // Sort by publishedAt or updatedAt 47 47 documents.sort((a: any, b: any) => { 48 - const dateA = new Date(a.value.updatedAt || a.value.publishedAt).getTime(); 49 - const dateB = new Date(b.value.updatedAt || b.value.publishedAt).getTime(); 48 + const dateA = new Date( 49 + a.value.updatedAt || a.value.publishedAt, 50 + ).getTime(); 51 + const dateB = new Date( 52 + b.value.updatedAt || b.value.publishedAt, 53 + ).getTime(); 50 54 return dateB - dateA; 51 55 }); 52 56 ··· 56 60 <h1>Documents</h1> 57 61 <a href="/documents/new" class="btn btn-primary">New Document</a> 58 62 </div> 59 - 63 + 60 64 <div class="filters"> 61 - <a href="/documents" class="filter ${filter === 'all' ? 'active' : ''}">All</a> 62 - <a href="/documents?filter=drafts" class="filter ${filter === 'drafts' ? 'active' : ''}">Drafts</a> 63 - <a href="/documents?filter=published" class="filter ${filter === 'published' ? 'active' : ''}">Published</a> 65 + <a 66 + href="/documents" 67 + class="filter ${filter === "all" ? "active" : ""}" 68 + >All</a 69 + > 70 + <a 71 + href="/documents?filter=drafts" 72 + class="filter ${filter === "drafts" ? "active" : ""}" 73 + >Drafts</a 74 + > 75 + <a 76 + href="/documents?filter=published" 77 + class="filter ${filter === "published" ? "active" : ""}" 78 + >Published</a 79 + > 64 80 </div> 65 - 66 - ${documents.length === 0 ? html` 67 - <p class="empty">No documents yet. <a href="/documents/new">Create your first document</a>.</p> 68 - ` : html` 69 - <ul class="document-list"> 70 - ${documents.map((doc: any) => { 71 - const rkey = doc.uri.split('/').pop(); 72 - const value = doc.value; 73 - const isDraft = (value.tags || []).includes('draft'); 74 - const date = value.publishedAt ? new Date(value.publishedAt).toLocaleDateString() : ''; 75 - 76 - return html` 77 - <li class="document-item ${isDraft ? 'draft' : 'published'}"> 78 - <a href="/documents/${rkey}"> 79 - <span class="title">${value.title}</span> 80 - <span class="meta"> 81 - ${isDraft ? html`<span class="badge badge-draft">Draft</span>` : ''} 82 - <span class="date">${date}</span> 83 - </span> 84 - </a> 85 - </li> 86 - `; 87 - })} 88 - </ul> 89 - `} 81 + 82 + ${documents.length === 0 83 + ? html` 84 + <p class="empty"> 85 + No documents yet. 86 + <a href="/documents/new">Create your first document</a>. 87 + </p> 88 + ` 89 + : html` 90 + <ul class="document-list"> 91 + ${documents.map((doc: any) => { 92 + const rkey = doc.uri.split("/").pop(); 93 + const value = doc.value; 94 + const isDraft = (value.tags || []).includes("draft"); 95 + const date = value.publishedAt 96 + ? new Date(value.publishedAt).toLocaleDateString() 97 + : ""; 98 + 99 + return html` 100 + <li 101 + class="document-item ${isDraft ? "draft" : "published"}" 102 + > 103 + <a href="/documents/${rkey}"> 104 + <span class="title">${value.title}</span> 105 + <span class="meta"> 106 + ${isDraft 107 + ? html`<span class="badge badge-draft">Draft</span>` 108 + : ""} 109 + <span class="date">${date}</span> 110 + </span> 111 + </a> 112 + </li> 113 + `; 114 + })} 115 + </ul> 116 + `} 90 117 </div> 91 118 `; 92 119 93 - return c.html(layout(content, { title: 'Documents - stdeditor', session })); 120 + return c.html(layout(content, { title: "Documents - std.pub", session })); 94 121 } catch (error) { 95 - console.error('Error fetching documents:', error); 96 - const content = html`<p class="error">Error loading documents. Please try again.</p>`; 97 - return c.html(layout(content, { title: 'Documents - stdeditor', session })); 122 + console.error("Error fetching documents:", error); 123 + const content = html`<p class="error"> 124 + Error loading documents. Please try again. 125 + </p>`; 126 + return c.html(layout(content, { title: "Documents - std.pub", session })); 98 127 } 99 128 }); 100 129 101 130 // New document form 102 - documentRoutes.get('/new', async (c) => { 131 + documentRoutes.get("/new", async (c) => { 103 132 let session: Session; 104 133 try { 105 134 session = requireAuth(c); 106 135 } catch { 107 - return c.redirect('/auth/login'); 136 + return c.redirect("/auth/login"); 108 137 } 109 138 110 139 // Get publication to use as site reference 111 - let publicationUri = ''; 140 + let publicationUri = ""; 112 141 try { 113 142 const response = await session.agent!.com.atproto.repo.listRecords({ 114 143 repo: session.did!, ··· 122 151 // No publication yet, will need URL 123 152 } 124 153 125 - const csrfToken = c.get('csrfToken') as string; 154 + const csrfToken = c.get("csrfToken") as string; 126 155 127 156 const content = html` 128 157 <div class="form-page"> 129 158 <h1>New Document</h1> 130 - 159 + 131 160 <form action="/documents/new" method="POST" class="document-form"> 132 161 ${csrfField(csrfToken)} 133 162 <input type="hidden" name="publicationUri" value="${publicationUri}" /> 134 - 163 + 135 164 <div class="form-group"> 136 165 <label for="title">Title *</label> 137 166 <input type="text" id="title" name="title" required maxlength="128" /> 138 167 </div> 139 - 168 + 140 169 <div class="form-group"> 141 170 <label for="path">Path</label> 142 171 <input type="text" id="path" name="path" placeholder="/my-post" /> 143 172 <small>The URL path for this document (e.g., /my-post)</small> 144 173 </div> 145 - 174 + 146 175 <div class="form-group"> 147 176 <label for="description">Description</label> 148 - <textarea id="description" name="description" rows="2" maxlength="300"></textarea> 177 + <textarea 178 + id="description" 179 + name="description" 180 + rows="2" 181 + maxlength="300" 182 + ></textarea> 149 183 </div> 150 - 184 + 151 185 <div class="form-group"> 152 186 <label for="content">Content (Markdown)</label> 153 - <textarea id="content" name="content" rows="20" class="content-editor"></textarea> 187 + <textarea 188 + id="content" 189 + name="content" 190 + rows="20" 191 + class="content-editor" 192 + ></textarea> 154 193 </div> 155 - 194 + 156 195 <div class="form-group"> 157 196 <label for="tags">Tags (comma-separated)</label> 158 - <input type="text" id="tags" name="tags" placeholder="draft, tutorial" value="draft" /> 197 + <input 198 + type="text" 199 + id="tags" 200 + name="tags" 201 + placeholder="draft, tutorial" 202 + value="draft" 203 + /> 159 204 </div> 160 - 205 + 161 206 <div class="form-actions"> 162 - <button type="submit" name="action" value="save" class="btn btn-secondary">Save Draft</button> 163 - <button type="submit" name="action" value="publish" class="btn btn-primary">Publish</button> 207 + <button 208 + type="submit" 209 + name="action" 210 + value="save" 211 + class="btn btn-secondary" 212 + > 213 + Save Draft 214 + </button> 215 + <button 216 + type="submit" 217 + name="action" 218 + value="publish" 219 + class="btn btn-primary" 220 + > 221 + Publish 222 + </button> 164 223 </div> 165 224 </form> 166 225 </div> 167 - 226 + 168 227 <script> 169 228 // Auto-save functionality 170 - const form = document.querySelector('.document-form'); 171 - const contentField = document.getElementById('content'); 229 + const form = document.querySelector(".document-form"); 230 + const contentField = document.getElementById("content"); 172 231 let saveTimeout; 173 - 174 - contentField.addEventListener('input', () => { 232 + 233 + contentField.addEventListener("input", () => { 175 234 clearTimeout(saveTimeout); 176 235 saveTimeout = setTimeout(() => { 177 236 // Could implement auto-save here 178 - console.log('Would auto-save...'); 237 + console.log("Would auto-save..."); 179 238 }, 2000); 180 239 }); 181 240 </script> 182 241 `; 183 242 184 - return c.html(layout(content, { title: 'New Document - stdeditor', session })); 243 + return c.html(layout(content, { title: "New Document - std.pub", session })); 185 244 }); 186 245 187 246 // Handle document creation 188 - documentRoutes.post('/new', async (c) => { 247 + documentRoutes.post("/new", async (c) => { 189 248 let session: Session; 190 249 try { 191 250 session = requireAuth(c); 192 251 } catch { 193 - return c.redirect('/auth/login'); 252 + return c.redirect("/auth/login"); 194 253 } 195 254 196 255 const body = await c.req.parseBody(); 197 256 const title = body.title as string; 198 - const path = body.path as string || undefined; 199 - const description = body.description as string || undefined; 200 - const content = body.content as string || undefined; 201 - const tagsStr = body.tags as string || ''; 257 + const path = (body.path as string) || undefined; 258 + const description = (body.description as string) || undefined; 259 + const content = (body.content as string) || undefined; 260 + const tagsStr = (body.tags as string) || ""; 202 261 const action = body.action as string; 203 262 const publicationUri = body.publicationUri as string; 204 263 205 264 // Parse tags 206 - let tags = tagsStr.split(',').map(t => t.trim()).filter(t => t); 207 - 265 + let tags = tagsStr 266 + .split(",") 267 + .map((t) => t.trim()) 268 + .filter((t) => t); 269 + 208 270 // If publishing, remove draft tag 209 - if (action === 'publish') { 210 - tags = tags.filter(t => t !== 'draft'); 211 - } else if (!tags.includes('draft')) { 212 - tags.push('draft'); 271 + if (action === "publish") { 272 + tags = tags.filter((t) => t !== "draft"); 273 + } else if (!tags.includes("draft")) { 274 + tags.push("draft"); 213 275 } 214 276 215 277 const now = new Date().toISOString(); ··· 228 290 $type: DOCUMENT_COLLECTION, 229 291 title, 230 292 site, 231 - publishedAt: action === 'publish' ? now : now, 293 + publishedAt: action === "publish" ? now : now, 232 294 updatedAt: now, 233 295 }; 234 296 235 - if (path) record.path = path.startsWith('/') ? path : `/${path}`; 297 + if (path) record.path = path.startsWith("/") ? path : `/${path}`; 236 298 if (description) record.description = description; 237 299 if (content) record.textContent = content; 238 300 if (tags.length > 0) record.tags = tags; ··· 246 308 247 309 return c.redirect(`/documents/${rkey}`); 248 310 } catch (error) { 249 - console.error('Error creating document:', error); 250 - return c.redirect('/documents/new?error=create_failed'); 311 + console.error("Error creating document:", error); 312 + return c.redirect("/documents/new?error=create_failed"); 251 313 } 252 314 }); 253 315 254 316 // View single document 255 - documentRoutes.get('/:rkey', async (c) => { 317 + documentRoutes.get("/:rkey", async (c) => { 256 318 let session: Session; 257 319 try { 258 320 session = requireAuth(c); 259 321 } catch { 260 - return c.redirect('/auth/login'); 322 + return c.redirect("/auth/login"); 261 323 } 262 324 263 - const rkey = c.req.param('rkey'); 264 - 325 + const rkey = c.req.param("rkey"); 326 + 265 327 // Validate rkey format 266 328 if (!isValidTID(rkey)) { 267 - return c.redirect('/documents'); 329 + return c.redirect("/documents"); 268 330 } 269 331 270 332 try { ··· 275 337 }); 276 338 277 339 const doc = response.data.value as any; 278 - const isDraft = (doc.tags || []).includes('draft'); 279 - const csrfToken = c.get('csrfToken') as string; 340 + const isDraft = (doc.tags || []).includes("draft"); 341 + const csrfToken = c.get("csrfToken") as string; 280 342 281 343 const content = html` 282 344 <div class="document-view"> 283 345 <div class="document-header"> 284 346 <h1>${doc.title}</h1> 285 347 <div class="document-meta"> 286 - ${isDraft ? html`<span class="badge badge-draft">Draft</span>` : html`<span class="badge badge-published">Published</span>`} 287 - ${doc.publishedAt ? html`<span class="date">Published: ${new Date(doc.publishedAt).toLocaleDateString()}</span>` : ''} 288 - ${doc.path ? html`<span class="path">Path: ${doc.path}</span>` : ''} 348 + ${isDraft 349 + ? html`<span class="badge badge-draft">Draft</span>` 350 + : html`<span class="badge badge-published">Published</span>`} 351 + ${doc.publishedAt 352 + ? html`<span class="date" 353 + >Published: 354 + ${new Date(doc.publishedAt).toLocaleDateString()}</span 355 + >` 356 + : ""} 357 + ${doc.path ? html`<span class="path">Path: ${doc.path}</span>` : ""} 289 358 </div> 290 359 </div> 291 - 292 - ${doc.description ? html`<p class="description">${doc.description}</p>` : ''} 293 - 360 + 361 + ${doc.description 362 + ? html`<p class="description">${doc.description}</p>` 363 + : ""} 364 + 294 365 <div class="document-content"> 295 - <pre>${doc.textContent || '(No content)'}</pre> 366 + <pre>${doc.textContent || "(No content)"}</pre> 296 367 </div> 297 - 368 + 298 369 <div class="actions"> 299 370 <a href="/documents/${rkey}/edit" class="btn btn-primary">Edit</a> 300 - ${isDraft ? html` 301 - <form action="/documents/${rkey}/publish" method="POST" style="display:inline"> 302 - ${csrfField(csrfToken)} 303 - <button type="submit" class="btn btn-success">Publish</button> 304 - </form> 305 - ` : html` 306 - <form action="/documents/${rkey}/unpublish" method="POST" style="display:inline"> 307 - ${csrfField(csrfToken)} 308 - <button type="submit" class="btn btn-secondary">Unpublish</button> 309 - </form> 310 - `} 311 - <form action="/documents/${rkey}/delete" method="POST" style="display:inline" onsubmit="return confirm('Are you sure you want to delete this document?')"> 371 + ${isDraft 372 + ? html` 373 + <form 374 + action="/documents/${rkey}/publish" 375 + method="POST" 376 + style="display:inline" 377 + > 378 + ${csrfField(csrfToken)} 379 + <button type="submit" class="btn btn-success">Publish</button> 380 + </form> 381 + ` 382 + : html` 383 + <form 384 + action="/documents/${rkey}/unpublish" 385 + method="POST" 386 + style="display:inline" 387 + > 388 + ${csrfField(csrfToken)} 389 + <button type="submit" class="btn btn-secondary"> 390 + Unpublish 391 + </button> 392 + </form> 393 + `} 394 + <form 395 + action="/documents/${rkey}/delete" 396 + method="POST" 397 + style="display:inline" 398 + onsubmit="return confirm('Are you sure you want to delete this document?')" 399 + > 312 400 ${csrfField(csrfToken)} 313 401 <button type="submit" class="btn btn-danger">Delete</button> 314 402 </form> ··· 317 405 </div> 318 406 `; 319 407 320 - return c.html(layout(content, { title: `${doc.title} - stdeditor`, session })); 408 + return c.html( 409 + layout(content, { title: `${doc.title} - std.pub`, session }), 410 + ); 321 411 } catch (error) { 322 - console.error('Error fetching document:', error); 323 - return c.redirect('/documents'); 412 + console.error("Error fetching document:", error); 413 + return c.redirect("/documents"); 324 414 } 325 415 }); 326 416 327 417 // Edit document form 328 - documentRoutes.get('/:rkey/edit', async (c) => { 418 + documentRoutes.get("/:rkey/edit", async (c) => { 329 419 let session: Session; 330 420 try { 331 421 session = requireAuth(c); 332 422 } catch { 333 - return c.redirect('/auth/login'); 423 + return c.redirect("/auth/login"); 334 424 } 335 425 336 - const rkey = c.req.param('rkey'); 337 - 426 + const rkey = c.req.param("rkey"); 427 + 338 428 if (!isValidTID(rkey)) { 339 - return c.redirect('/documents'); 429 + return c.redirect("/documents"); 340 430 } 341 431 342 432 try { ··· 347 437 }); 348 438 349 439 const doc = response.data.value as any; 350 - const csrfToken = c.get('csrfToken') as string; 440 + const csrfToken = c.get("csrfToken") as string; 351 441 352 442 const content = html` 353 443 <div class="form-page"> 354 444 <h1>Edit Document</h1> 355 - 356 - <form action="/documents/${rkey}/edit" method="POST" class="document-form"> 445 + 446 + <form 447 + action="/documents/${rkey}/edit" 448 + method="POST" 449 + class="document-form" 450 + > 357 451 ${csrfField(csrfToken)} 358 452 <div class="form-group"> 359 453 <label for="title">Title *</label> 360 - <input type="text" id="title" name="title" value="${doc.title}" required maxlength="128" /> 454 + <input 455 + type="text" 456 + id="title" 457 + name="title" 458 + value="${doc.title}" 459 + required 460 + maxlength="128" 461 + /> 361 462 </div> 362 - 463 + 363 464 <div class="form-group"> 364 465 <label for="path">Path</label> 365 - <input type="text" id="path" name="path" value="${doc.path || ''}" /> 466 + <input 467 + type="text" 468 + id="path" 469 + name="path" 470 + value="${doc.path || ""}" 471 + /> 366 472 </div> 367 - 473 + 368 474 <div class="form-group"> 369 475 <label for="description">Description</label> 370 - <textarea id="description" name="description" rows="2" maxlength="300">${doc.description || ''}</textarea> 476 + <textarea 477 + id="description" 478 + name="description" 479 + rows="2" 480 + maxlength="300" 481 + > 482 + ${doc.description || ""}</textarea 483 + > 371 484 </div> 372 - 485 + 373 486 <div class="form-group"> 374 487 <label for="content">Content (Markdown)</label> 375 - <textarea id="content" name="content" rows="20" class="content-editor">${doc.textContent || ''}</textarea> 488 + <textarea 489 + id="content" 490 + name="content" 491 + rows="20" 492 + class="content-editor" 493 + > 494 + ${doc.textContent || ""}</textarea 495 + > 376 496 </div> 377 - 497 + 378 498 <div class="form-group"> 379 499 <label for="tags">Tags (comma-separated)</label> 380 - <input type="text" id="tags" name="tags" value="${(doc.tags || []).join(', ')}" /> 500 + <input 501 + type="text" 502 + id="tags" 503 + name="tags" 504 + value="${(doc.tags || []).join(", ")}" 505 + /> 381 506 </div> 382 - 507 + 383 508 <div class="form-actions"> 384 509 <button type="submit" class="btn btn-primary">Save Changes</button> 385 510 <a href="/documents/${rkey}" class="btn btn-secondary">Cancel</a> ··· 388 513 </div> 389 514 `; 390 515 391 - return c.html(layout(content, { title: `Edit: ${doc.title} - stdeditor`, session })); 516 + return c.html( 517 + layout(content, { title: `Edit: ${doc.title} - std.pub`, session }), 518 + ); 392 519 } catch (error) { 393 - console.error('Error fetching document:', error); 394 - return c.redirect('/documents'); 520 + console.error("Error fetching document:", error); 521 + return c.redirect("/documents"); 395 522 } 396 523 }); 397 524 398 525 // Handle document update 399 - documentRoutes.post('/:rkey/edit', async (c) => { 526 + documentRoutes.post("/:rkey/edit", async (c) => { 400 527 let session: Session; 401 528 try { 402 529 session = requireAuth(c); 403 530 } catch { 404 - return c.redirect('/auth/login'); 531 + return c.redirect("/auth/login"); 405 532 } 406 533 407 - const rkey = c.req.param('rkey'); 408 - 534 + const rkey = c.req.param("rkey"); 535 + 409 536 if (!isValidTID(rkey)) { 410 - return c.redirect('/documents'); 537 + return c.redirect("/documents"); 411 538 } 412 - 539 + 413 540 const body = await c.req.parseBody(); 414 541 415 542 try { ··· 423 550 const oldDoc = existing.data.value as any; 424 551 425 552 const title = body.title as string; 426 - const path = body.path as string || undefined; 427 - const description = body.description as string || undefined; 428 - const content = body.content as string || undefined; 429 - const tagsStr = body.tags as string || ''; 430 - const tags = tagsStr.split(',').map(t => t.trim()).filter(t => t); 553 + const path = (body.path as string) || undefined; 554 + const description = (body.description as string) || undefined; 555 + const content = (body.content as string) || undefined; 556 + const tagsStr = (body.tags as string) || ""; 557 + const tags = tagsStr 558 + .split(",") 559 + .map((t) => t.trim()) 560 + .filter((t) => t); 431 561 432 562 const record: Record<string, any> = { 433 563 $type: DOCUMENT_COLLECTION, ··· 437 567 updatedAt: new Date().toISOString(), 438 568 }; 439 569 440 - if (path) record.path = path.startsWith('/') ? path : `/${path}`; 570 + if (path) record.path = path.startsWith("/") ? path : `/${path}`; 441 571 if (description) record.description = description; 442 572 if (content) record.textContent = content; 443 573 if (tags.length > 0) record.tags = tags; ··· 451 581 452 582 return c.redirect(`/documents/${rkey}`); 453 583 } catch (error) { 454 - console.error('Error updating document:', error); 584 + console.error("Error updating document:", error); 455 585 return c.redirect(`/documents/${rkey}/edit?error=update_failed`); 456 586 } 457 587 }); 458 588 459 589 // Publish document 460 - documentRoutes.post('/:rkey/publish', async (c) => { 590 + documentRoutes.post("/:rkey/publish", async (c) => { 461 591 let session: Session; 462 592 try { 463 593 session = requireAuth(c); 464 594 } catch { 465 - return c.redirect('/auth/login'); 595 + return c.redirect("/auth/login"); 466 596 } 467 597 468 - const rkey = c.req.param('rkey'); 469 - 598 + const rkey = c.req.param("rkey"); 599 + 470 600 if (!isValidTID(rkey)) { 471 - return c.redirect('/documents'); 601 + return c.redirect("/documents"); 472 602 } 473 603 474 604 try { ··· 479 609 }); 480 610 481 611 const doc = existing.data.value as any; 482 - const tags = (doc.tags || []).filter((t: string) => t !== 'draft'); 612 + const tags = (doc.tags || []).filter((t: string) => t !== "draft"); 483 613 484 614 const record = { 485 615 ...doc, ··· 497 627 498 628 return c.redirect(`/documents/${rkey}`); 499 629 } catch (error) { 500 - console.error('Error publishing document:', error); 630 + console.error("Error publishing document:", error); 501 631 return c.redirect(`/documents/${rkey}?error=publish_failed`); 502 632 } 503 633 }); 504 634 505 635 // Unpublish document (add draft tag) 506 - documentRoutes.post('/:rkey/unpublish', async (c) => { 636 + documentRoutes.post("/:rkey/unpublish", async (c) => { 507 637 let session: Session; 508 638 try { 509 639 session = requireAuth(c); 510 640 } catch { 511 - return c.redirect('/auth/login'); 641 + return c.redirect("/auth/login"); 512 642 } 513 643 514 - const rkey = c.req.param('rkey'); 515 - 644 + const rkey = c.req.param("rkey"); 645 + 516 646 if (!isValidTID(rkey)) { 517 - return c.redirect('/documents'); 647 + return c.redirect("/documents"); 518 648 } 519 649 520 650 try { ··· 525 655 }); 526 656 527 657 const doc = existing.data.value as any; 528 - const tags = [...(doc.tags || []), 'draft']; 658 + const tags = [...(doc.tags || []), "draft"]; 529 659 530 660 const record = { 531 661 ...doc, ··· 542 672 543 673 return c.redirect(`/documents/${rkey}`); 544 674 } catch (error) { 545 - console.error('Error unpublishing document:', error); 675 + console.error("Error unpublishing document:", error); 546 676 return c.redirect(`/documents/${rkey}?error=unpublish_failed`); 547 677 } 548 678 }); 549 679 550 680 // Delete document 551 - documentRoutes.post('/:rkey/delete', async (c) => { 681 + documentRoutes.post("/:rkey/delete", async (c) => { 552 682 let session: Session; 553 683 try { 554 684 session = requireAuth(c); 555 685 } catch { 556 - return c.redirect('/auth/login'); 686 + return c.redirect("/auth/login"); 557 687 } 558 688 559 - const rkey = c.req.param('rkey'); 560 - 689 + const rkey = c.req.param("rkey"); 690 + 561 691 if (!isValidTID(rkey)) { 562 - return c.redirect('/documents'); 692 + return c.redirect("/documents"); 563 693 } 564 694 565 695 try { ··· 569 699 rkey, 570 700 }); 571 701 572 - return c.redirect('/documents'); 702 + return c.redirect("/documents"); 573 703 } catch (error) { 574 - console.error('Error deleting document:', error); 704 + console.error("Error deleting document:", error); 575 705 return c.redirect(`/documents/${rkey}?error=delete_failed`); 576 706 } 577 707 }); ··· 581 711 const now = Date.now() * 1000; 582 712 const clockId = Math.floor(Math.random() * 1024); 583 713 const tid = (BigInt(now) << 10n) | BigInt(clockId); 584 - return tid.toString(36).padStart(13, '0'); 714 + return tid.toString(36).padStart(13, "0"); 585 715 }
+100 -59
src/routes/publication.ts
··· 1 - import { Hono } from 'hono'; 2 - import { html } from 'hono/html'; 3 - import { layout } from '../views/layouts/main'; 4 - import { requireAuth, type Session } from '../lib/session'; 5 - import { csrfField } from '../lib/csrf'; 1 + import { Hono } from "hono"; 2 + import { html } from "hono/html"; 3 + import { layout } from "../views/layouts/main"; 4 + import { requireAuth, type Session } from "../lib/session"; 5 + import { csrfField } from "../lib/csrf"; 6 6 7 7 export const publicationRoutes = new Hono(); 8 8 9 - const PUBLICATION_COLLECTION = 'site.standard.publication'; 9 + const PUBLICATION_COLLECTION = "site.standard.publication"; 10 10 11 11 // View/manage publication 12 - publicationRoutes.get('/', async (c) => { 12 + publicationRoutes.get("/", async (c) => { 13 13 let session: Session; 14 14 try { 15 15 session = requireAuth(c); 16 16 } catch { 17 - return c.redirect('/auth/login'); 17 + return c.redirect("/auth/login"); 18 18 } 19 19 20 20 try { ··· 32 32 const content = html` 33 33 <div class="publication"> 34 34 <h1>Your Publication</h1> 35 - 35 + 36 36 <div class="pub-details"> 37 37 <h2>${pub.name}</h2> 38 - <p class="url"><a href="${pub.url}" target="_blank">${pub.url}</a></p> 39 - ${pub.description ? html`<p class="description">${pub.description}</p>` : ''} 38 + <p class="url"> 39 + <a href="${pub.url}" target="_blank">${pub.url}</a> 40 + </p> 41 + ${pub.description 42 + ? html`<p class="description">${pub.description}</p>` 43 + : ""} 40 44 </div> 41 - 45 + 42 46 <div class="actions"> 43 - <a href="/publication/edit" class="btn btn-primary">Edit Publication</a> 47 + <a href="/publication/edit" class="btn btn-primary" 48 + >Edit Publication</a 49 + > 44 50 </div> 45 51 </div> 46 52 `; 47 - return c.html(layout(content, { title: 'Publication - stdeditor', session })); 53 + return c.html( 54 + layout(content, { title: "Publication - std.pub", session }), 55 + ); 48 56 } 49 57 50 58 // No publication exists, show create form 51 - return c.redirect('/publication/new'); 59 + return c.redirect("/publication/new"); 52 60 } catch (error) { 53 - console.error('Error fetching publication:', error); 54 - return c.redirect('/publication/new'); 61 + console.error("Error fetching publication:", error); 62 + return c.redirect("/publication/new"); 55 63 } 56 64 }); 57 65 58 66 // New publication form 59 - publicationRoutes.get('/new', async (c) => { 67 + publicationRoutes.get("/new", async (c) => { 60 68 let session: Session; 61 69 try { 62 70 session = requireAuth(c); 63 71 } catch { 64 - return c.redirect('/auth/login'); 72 + return c.redirect("/auth/login"); 65 73 } 66 74 67 - const csrfToken = c.get('csrfToken') as string; 75 + const csrfToken = c.get("csrfToken") as string; 68 76 69 77 const content = html` 70 78 <div class="form-page"> 71 79 <h1>Create Publication</h1> 72 - 80 + 73 81 <form action="/publication/new" method="POST"> 74 82 ${csrfField(csrfToken)} 75 83 <div class="form-group"> 76 84 <label for="name">Name *</label> 77 85 <input type="text" id="name" name="name" required maxlength="128" /> 78 86 </div> 79 - 87 + 80 88 <div class="form-group"> 81 89 <label for="url">URL *</label> 82 - <input type="url" id="url" name="url" placeholder="https://yourblog.com" required /> 83 - <small>The base URL of your publication (without trailing slash)</small> 90 + <input 91 + type="url" 92 + id="url" 93 + name="url" 94 + placeholder="https://yourblog.com" 95 + required 96 + /> 97 + <small 98 + >The base URL of your publication (without trailing slash)</small 99 + > 84 100 </div> 85 - 101 + 86 102 <div class="form-group"> 87 103 <label for="description">Description</label> 88 - <textarea id="description" name="description" rows="3" maxlength="300"></textarea> 104 + <textarea 105 + id="description" 106 + name="description" 107 + rows="3" 108 + maxlength="300" 109 + ></textarea> 89 110 </div> 90 - 91 - <button type="submit" class="btn btn-primary">Create Publication</button> 111 + 112 + <button type="submit" class="btn btn-primary"> 113 + Create Publication 114 + </button> 92 115 </form> 93 116 </div> 94 117 `; 95 118 96 - return c.html(layout(content, { title: 'New Publication - stdeditor', session })); 119 + return c.html( 120 + layout(content, { title: "New Publication - std.pub", session }), 121 + ); 97 122 }); 98 123 99 124 // Handle publication creation 100 - publicationRoutes.post('/new', async (c) => { 125 + publicationRoutes.post("/new", async (c) => { 101 126 let session: Session; 102 127 try { 103 128 session = requireAuth(c); 104 129 } catch { 105 - return c.redirect('/auth/login'); 130 + return c.redirect("/auth/login"); 106 131 } 107 132 108 133 const body = await c.req.parseBody(); 109 134 const name = body.name as string; 110 - const url = (body.url as string).replace(/\/$/, ''); // Remove trailing slash 111 - const description = body.description as string || undefined; 135 + const url = (body.url as string).replace(/\/$/, ""); // Remove trailing slash 136 + const description = (body.description as string) || undefined; 112 137 113 138 try { 114 139 // Generate a TID for the record key ··· 126 151 }, 127 152 }); 128 153 129 - return c.redirect('/publication'); 154 + return c.redirect("/publication"); 130 155 } catch (error) { 131 - console.error('Error creating publication:', error); 132 - return c.redirect('/publication/new?error=create_failed'); 156 + console.error("Error creating publication:", error); 157 + return c.redirect("/publication/new?error=create_failed"); 133 158 } 134 159 }); 135 160 136 161 // Edit publication form 137 - publicationRoutes.get('/edit', async (c) => { 162 + publicationRoutes.get("/edit", async (c) => { 138 163 let session: Session; 139 164 try { 140 165 session = requireAuth(c); 141 166 } catch { 142 - return c.redirect('/auth/login'); 167 + return c.redirect("/auth/login"); 143 168 } 144 169 145 170 try { ··· 151 176 152 177 const publication = response.data.records[0]; 153 178 if (!publication) { 154 - return c.redirect('/publication/new'); 179 + return c.redirect("/publication/new"); 155 180 } 156 181 157 182 const pub = publication.value as any; 158 - const rkey = publication.uri.split('/').pop(); 183 + const rkey = publication.uri.split("/").pop(); 159 184 160 - const csrfToken = c.get('csrfToken') as string; 185 + const csrfToken = c.get("csrfToken") as string; 161 186 162 187 const content = html` 163 188 <div class="form-page"> 164 189 <h1>Edit Publication</h1> 165 - 190 + 166 191 <form action="/publication/edit" method="POST"> 167 192 ${csrfField(csrfToken)} 168 193 <input type="hidden" name="rkey" value="${rkey}" /> 169 - 194 + 170 195 <div class="form-group"> 171 196 <label for="name">Name *</label> 172 - <input type="text" id="name" name="name" value="${pub.name}" required maxlength="128" /> 197 + <input 198 + type="text" 199 + id="name" 200 + name="name" 201 + value="${pub.name}" 202 + required 203 + maxlength="128" 204 + /> 173 205 </div> 174 - 206 + 175 207 <div class="form-group"> 176 208 <label for="url">URL *</label> 177 209 <input type="url" id="url" name="url" value="${pub.url}" required /> 178 210 </div> 179 - 211 + 180 212 <div class="form-group"> 181 213 <label for="description">Description</label> 182 - <textarea id="description" name="description" rows="3" maxlength="300">${pub.description || ''}</textarea> 214 + <textarea 215 + id="description" 216 + name="description" 217 + rows="3" 218 + maxlength="300" 219 + > 220 + ${pub.description || ""}</textarea 221 + > 183 222 </div> 184 - 223 + 185 224 <button type="submit" class="btn btn-primary">Save Changes</button> 186 225 <a href="/publication" class="btn btn-secondary">Cancel</a> 187 226 </form> 188 227 </div> 189 228 `; 190 229 191 - return c.html(layout(content, { title: 'Edit Publication - stdeditor', session })); 230 + return c.html( 231 + layout(content, { title: "Edit Publication - std.pub", session }), 232 + ); 192 233 } catch (error) { 193 - console.error('Error fetching publication:', error); 194 - return c.redirect('/publication'); 234 + console.error("Error fetching publication:", error); 235 + return c.redirect("/publication"); 195 236 } 196 237 }); 197 238 198 239 // Handle publication update 199 - publicationRoutes.post('/edit', async (c) => { 240 + publicationRoutes.post("/edit", async (c) => { 200 241 let session: Session; 201 242 try { 202 243 session = requireAuth(c); 203 244 } catch { 204 - return c.redirect('/auth/login'); 245 + return c.redirect("/auth/login"); 205 246 } 206 247 207 248 const body = await c.req.parseBody(); 208 249 const rkey = body.rkey as string; 209 250 const name = body.name as string; 210 - const url = (body.url as string).replace(/\/$/, ''); 211 - const description = body.description as string || undefined; 251 + const url = (body.url as string).replace(/\/$/, ""); 252 + const description = (body.description as string) || undefined; 212 253 213 254 try { 214 255 await session.agent!.com.atproto.repo.putRecord({ ··· 223 264 }, 224 265 }); 225 266 226 - return c.redirect('/publication'); 267 + return c.redirect("/publication"); 227 268 } catch (error) { 228 - console.error('Error updating publication:', error); 229 - return c.redirect('/publication/edit?error=update_failed'); 269 + console.error("Error updating publication:", error); 270 + return c.redirect("/publication/edit?error=update_failed"); 230 271 } 231 272 }); 232 273 ··· 235 276 const now = Date.now() * 1000; // microseconds 236 277 const clockId = Math.floor(Math.random() * 1024); 237 278 const tid = (BigInt(now) << 10n) | BigInt(clockId); 238 - return tid.toString(36).padStart(13, '0'); 279 + return tid.toString(36).padStart(13, "0"); 239 280 }
+14 -9
src/views/home.ts
··· 1 - import { html } from 'hono/html'; 2 - import type { Session } from '../lib/session'; 1 + import { html } from "hono/html"; 2 + import type { Session } from "../lib/session"; 3 3 4 4 export function homePage(session: Session) { 5 5 if (session.did) { ··· 7 7 <div class="dashboard"> 8 8 <h1>Welcome, @${session.handle}</h1> 9 9 <p>Manage your standard.site publication and documents.</p> 10 - 10 + 11 11 <div class="quick-actions"> 12 12 <a href="/publication" class="btn btn-primary">Manage Publication</a> 13 13 <a href="/documents" class="btn btn-secondary">View Documents</a> ··· 16 16 </div> 17 17 `; 18 18 } 19 - 19 + 20 20 return html` 21 21 <div class="hero"> 22 - <h1>stdeditor</h1> 23 - <p>A minimal web UI for managing standard.site publications and documents in your Bluesky PDS.</p> 24 - 22 + <h1>std.pub</h1> 23 + <p> 24 + A minimal web UI for managing standard.site publications and documents 25 + in your Bluesky PDS. 26 + </p> 27 + 25 28 <div class="features"> 26 29 <div class="feature"> 27 30 <h3>📝 Publish</h3> ··· 36 39 <p>Uses the standard.site lexicon for interoperability</p> 37 40 </div> 38 41 </div> 39 - 40 - <a href="/auth/login" class="btn btn-primary btn-large">Login with Bluesky</a> 42 + 43 + <a href="/auth/login" class="btn btn-primary btn-large" 44 + >Login with Bluesky</a 45 + > 41 46 </div> 42 47 `; 43 48 }
+38 -37
src/views/layouts/main.ts
··· 1 - import { html } from 'hono/html'; 2 - import type { Session } from '../../lib/session'; 1 + import { html } from "hono/html"; 2 + import type { Session } from "../../lib/session"; 3 3 4 4 interface LayoutOptions { 5 5 title?: string; ··· 8 8 } 9 9 10 10 export function layout(content: string, options: LayoutOptions = {}) { 11 - const { title = 'stdeditor', session } = options; 12 - 11 + const { title = "std.pub", session } = options; 12 + 13 13 return html` 14 - <!DOCTYPE html> 15 - <html lang="en"> 16 - <head> 17 - <meta charset="UTF-8"> 18 - <meta name="viewport" content="width=device-width, initial-scale=1.0"> 19 - <title>${title}</title> 20 - <link rel="stylesheet" href="/public/styles.css"> 21 - </head> 22 - <body> 23 - <header class="header"> 24 - <nav class="nav"> 25 - <a href="/" class="logo">stdeditor</a> 26 - <div class="nav-links"> 27 - ${session?.did ? html` 28 - <a href="/publication">Publication</a> 29 - <a href="/documents">Documents</a> 30 - <span class="handle">@${session.handle}</span> 31 - <a href="/auth/logout">Logout</a> 32 - ` : html` 33 - <a href="/auth/login">Login with Bluesky</a> 34 - `} 35 - </div> 36 - </nav> 37 - </header> 38 - <main class="main"> 39 - ${content} 40 - </main> 41 - <footer class="footer"> 42 - <p>stdeditor - Manage your <a href="https://standard.site">standard.site</a> content</p> 43 - </footer> 44 - </body> 45 - </html> 46 - `; 14 + <!DOCTYPE html> 15 + <html lang="en"> 16 + <head> 17 + <meta charset="UTF-8" /> 18 + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 19 + <title>${title}</title> 20 + <link rel="stylesheet" href="/public/styles.css" /> 21 + </head> 22 + <body> 23 + <header class="header"> 24 + <nav class="nav"> 25 + <a href="/" class="logo">std.pub</a> 26 + <div class="nav-links"> 27 + ${session?.did 28 + ? html` 29 + <a href="/publication">Publication</a> 30 + <a href="/documents">Documents</a> 31 + <span class="handle">@${session.handle}</span> 32 + <a href="/auth/logout">Logout</a> 33 + ` 34 + : html` <a href="/auth/login">Login with Bluesky</a> `} 35 + </div> 36 + </nav> 37 + </header> 38 + <main class="main">${content}</main> 39 + <footer class="footer"> 40 + <p> 41 + std.pub - Manage your 42 + <a href="https://standard.site">standard.site</a> content 43 + </p> 44 + </footer> 45 + </body> 46 + </html> 47 + `; 47 48 }
-17
stdeditor.service
··· 1 - [Unit] 2 - Description=stdeditor - standard.site editor 3 - After=network.target 4 - 5 - [Service] 6 - Type=simple 7 - User=exedev 8 - WorkingDirectory=/home/exedev/stdeditor 9 - Environment=PATH=/home/exedev/.bun/bin:/usr/local/bin:/usr/bin:/bin 10 - Environment=PUBLIC_URL=https://stdeditor.exe.xyz 11 - Environment=PORT=8000 12 - ExecStart=/home/exedev/.bun/bin/bun run src/server.ts 13 - Restart=on-failure 14 - RestartSec=5 15 - 16 - [Install] 17 - WantedBy=multi-user.target