tangled
alpha
login
or
join now
seth.computer
/
sitebase
0
fork
atom
this repo has no description
0
fork
atom
overview
issues
pulls
pipelines
Formatting and linting
seth.computer
2 months ago
c53aa39d
aa8cc394
verified
This commit was signed with the committer's
known signature
.
seth.computer
SSH Key Fingerprint:
SHA256:utUtG8j2hgvZ0Rnm/rPJiqFu4NT5bjOnC26AUIBh500=
+1268
-1154
20 changed files
expand all
collapse all
unified
split
.gitignore
biome.json
bun.lock
package.json
packages
web
.dockerignore
package.json
public
styles.css
scripts
cleanup.ts
src
lib
content-types.ts
csrf.ts
logger.ts
oauth.ts
session.ts
validation.ts
routes
auth.ts
documents.ts
publication.ts
server.ts
views
home.ts
layouts
main.ts
+2
.gitignore
···
32
32
33
33
# Finder (MacOS) folder config
34
34
.DS_Store
35
35
+
36
36
+
packages/web/data
+34
biome.json
···
1
1
+
{
2
2
+
"$schema": "https://biomejs.dev/schemas/2.3.11/schema.json",
3
3
+
"vcs": {
4
4
+
"enabled": true,
5
5
+
"clientKind": "git",
6
6
+
"useIgnoreFile": true
7
7
+
},
8
8
+
"files": {
9
9
+
"ignoreUnknown": false
10
10
+
},
11
11
+
"formatter": {
12
12
+
"enabled": true,
13
13
+
"indentStyle": "tab"
14
14
+
},
15
15
+
"linter": {
16
16
+
"enabled": true,
17
17
+
"rules": {
18
18
+
"recommended": true
19
19
+
}
20
20
+
},
21
21
+
"javascript": {
22
22
+
"formatter": {
23
23
+
"quoteStyle": "double"
24
24
+
}
25
25
+
},
26
26
+
"assist": {
27
27
+
"enabled": true,
28
28
+
"actions": {
29
29
+
"source": {
30
30
+
"organizeImports": "on"
31
31
+
}
32
32
+
}
33
33
+
}
34
34
+
}
+21
bun.lock
···
4
4
"workspaces": {
5
5
"": {
6
6
"name": "stdpub",
7
7
+
"devDependencies": {
8
8
+
"@biomejs/biome": "^2.3.11",
9
9
+
},
7
10
},
8
11
"packages/lib": {
9
12
"name": "@stdpub/lib",
···
71
74
"@atproto/syntax": ["@atproto/syntax@0.4.2", "", {}, "sha512-X9XSRPinBy/0VQ677j8VXlBsYSsUXaiqxWVpGGxJYsAhugdQRb0jqaVKJFtm6RskeNkV6y9xclSUi9UYG/COrA=="],
72
75
73
76
"@atproto/xrpc": ["@atproto/xrpc@0.7.7", "", { "dependencies": { "@atproto/lexicon": "^0.6.0", "zod": "^3.23.8" } }, "sha512-K1ZyO/BU8JNtXX5dmPp7b5UrkLMMqpsIa/Lrj5D3Su+j1Xwq1m6QJ2XJ1AgjEjkI1v4Muzm7klianLE6XGxtmA=="],
77
77
+
78
78
+
"@biomejs/biome": ["@biomejs/biome@2.3.11", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.3.11", "@biomejs/cli-darwin-x64": "2.3.11", "@biomejs/cli-linux-arm64": "2.3.11", "@biomejs/cli-linux-arm64-musl": "2.3.11", "@biomejs/cli-linux-x64": "2.3.11", "@biomejs/cli-linux-x64-musl": "2.3.11", "@biomejs/cli-win32-arm64": "2.3.11", "@biomejs/cli-win32-x64": "2.3.11" }, "bin": { "biome": "bin/biome" } }, "sha512-/zt+6qazBWguPG6+eWmiELqO+9jRsMZ/DBU3lfuU2ngtIQYzymocHhKiZRyrbra4aCOoyTg/BmY+6WH5mv9xmQ=="],
79
79
+
80
80
+
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.3.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-/uXXkBcPKVQY7rc9Ys2CrlirBJYbpESEDme7RKiBD6MmqR2w3j0+ZZXRIL2xiaNPsIMMNhP1YnA+jRRxoOAFrA=="],
81
81
+
82
82
+
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.3.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-fh7nnvbweDPm2xEmFjfmq7zSUiox88plgdHF9OIW4i99WnXrAC3o2P3ag9judoUMv8FCSUnlwJCM1B64nO5Fbg=="],
83
83
+
84
84
+
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.3.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-l4xkGa9E7Uc0/05qU2lMYfN1H+fzzkHgaJoy98wO+b/7Gl78srbCRRgwYSW+BTLixTBrM6Ede5NSBwt7rd/i6g=="],
85
85
+
86
86
+
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.3.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-XPSQ+XIPZMLaZ6zveQdwNjbX+QdROEd1zPgMwD47zvHV+tCGB88VH+aynyGxAHdzL+Tm/+DtKST5SECs4iwCLg=="],
87
87
+
88
88
+
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.3.11", "", { "os": "linux", "cpu": "x64" }, "sha512-/1s9V/H3cSe0r0Mv/Z8JryF5x9ywRxywomqZVLHAoa/uN0eY7F8gEngWKNS5vbbN/BsfpCG5yeBT5ENh50Frxg=="],
89
89
+
90
90
+
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.3.11", "", { "os": "linux", "cpu": "x64" }, "sha512-vU7a8wLs5C9yJ4CB8a44r12aXYb8yYgBn+WeyzbMjaCMklzCv1oXr8x+VEyWodgJt9bDmhiaW/I0RHbn7rsNmw=="],
91
91
+
92
92
+
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.3.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-PZQ6ElCOnkYapSsysiTy0+fYX+agXPlWugh6+eQ6uPKI3vKAqNp6TnMhoM3oY2NltSB89hz59o8xIfOdyhi9Iw=="],
93
93
+
94
94
+
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.3.11", "", { "os": "win32", "cpu": "x64" }, "sha512-43VrG813EW+b5+YbDbz31uUsheX+qFKCpXeY9kfdAx+ww3naKxeVkTD9zLIWxUPfJquANMHrmW3wbe/037G0Qg=="],
74
95
75
96
"@stdpub/lib": ["@stdpub/lib@workspace:packages/lib"],
76
97
+10
-1
package.json
···
3
3
"private": true,
4
4
"workspaces": [
5
5
"packages/*"
6
6
-
]
6
6
+
],
7
7
+
"devDependencies": {
8
8
+
"@biomejs/biome": "^2.3.11"
9
9
+
},
10
10
+
"scripts": {
11
11
+
"format": "biome format packages/ --write",
12
12
+
"format:check": "biome format packages/",
13
13
+
"lint": "biome lint packages/ --write",
14
14
+
"lint:check": "biome lint packages/"
15
15
+
}
7
16
}
+9
packages/web/.dockerignore
···
1
1
+
node_modules
2
2
+
.git
3
3
+
.gitignore
4
4
+
*.log
5
5
+
.DS_Store
6
6
+
.env
7
7
+
.env.*
8
8
+
data/private-key.json
9
9
+
data/oauth.db
+21
-21
packages/web/package.json
···
1
1
{
2
2
-
"name": "@stdpub/web",
3
3
-
"module": "src/server.ts",
4
4
-
"type": "module",
5
5
-
"private": true,
6
6
-
"scripts": {
7
7
-
"dev": "bun --hot run src/server.ts",
8
8
-
"start": "bun run src/server.ts"
9
9
-
},
10
10
-
"devDependencies": {
11
11
-
"@types/bun": "latest"
12
12
-
},
13
13
-
"peerDependencies": {
14
14
-
"typescript": "^5"
15
15
-
},
16
16
-
"dependencies": {
17
17
-
"@atproto/api": "^0.18.13",
18
18
-
"@atproto/jwk-jose": "^0.1.11",
19
19
-
"@atproto/oauth-client-node": "^0.3.15",
20
20
-
"hono": "^4.11.3",
21
21
-
"marked": "^15.0.0"
22
22
-
}
2
2
+
"name": "@stdpub/web",
3
3
+
"module": "src/server.ts",
4
4
+
"type": "module",
5
5
+
"private": true,
6
6
+
"scripts": {
7
7
+
"dev": "bun --hot run src/server.ts",
8
8
+
"start": "bun run src/server.ts"
9
9
+
},
10
10
+
"devDependencies": {
11
11
+
"@types/bun": "latest"
12
12
+
},
13
13
+
"peerDependencies": {
14
14
+
"typescript": "^5"
15
15
+
},
16
16
+
"dependencies": {
17
17
+
"@atproto/api": "^0.18.13",
18
18
+
"@atproto/jwk-jose": "^0.1.11",
19
19
+
"@atproto/oauth-client-node": "^0.3.15",
20
20
+
"hono": "^4.11.3",
21
21
+
"marked": "^15.0.0"
22
22
+
}
23
23
}
+228
-225
packages/web/public/styles.css
···
1
1
:root {
2
2
-
--bg: #fafafa;
3
3
-
--bg-secondary: #f0f0f0;
4
4
-
--text: #1a1a1a;
5
5
-
--text-muted: #666;
6
6
-
--border: #ddd;
7
7
-
--primary: #0066cc;
8
8
-
--primary-hover: #0052a3;
9
9
-
--success: #22c55e;
10
10
-
--danger: #ef4444;
11
11
-
--draft: #f59e0b;
2
2
+
--bg: #fafafa;
3
3
+
--bg-secondary: #f0f0f0;
4
4
+
--text: #1a1a1a;
5
5
+
--text-muted: #666;
6
6
+
--border: #ddd;
7
7
+
--primary: #0066cc;
8
8
+
--primary-hover: #0052a3;
9
9
+
--success: #22c55e;
10
10
+
--danger: #ef4444;
11
11
+
--draft: #f59e0b;
12
12
}
13
13
14
14
@media (prefers-color-scheme: dark) {
15
15
-
:root {
16
16
-
--bg: #1a1a1a;
17
17
-
--bg-secondary: #2a2a2a;
18
18
-
--text: #f0f0f0;
19
19
-
--text-muted: #999;
20
20
-
--border: #333;
21
21
-
--primary: #3b82f6;
22
22
-
--primary-hover: #2563eb;
23
23
-
}
15
15
+
:root {
16
16
+
--bg: #1a1a1a;
17
17
+
--bg-secondary: #2a2a2a;
18
18
+
--text: #f0f0f0;
19
19
+
--text-muted: #999;
20
20
+
--border: #333;
21
21
+
--primary: #3b82f6;
22
22
+
--primary-hover: #2563eb;
23
23
+
}
24
24
}
25
25
26
26
* {
27
27
-
box-sizing: border-box;
28
28
-
margin: 0;
29
29
-
padding: 0;
27
27
+
box-sizing: border-box;
28
28
+
margin: 0;
29
29
+
padding: 0;
30
30
}
31
31
32
32
body {
33
33
-
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
34
34
-
background: var(--bg);
35
35
-
color: var(--text);
36
36
-
line-height: 1.6;
37
37
-
min-height: 100vh;
38
38
-
display: flex;
39
39
-
flex-direction: column;
33
33
+
font-family:
34
34
+
system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
35
35
+
background: var(--bg);
36
36
+
color: var(--text);
37
37
+
line-height: 1.6;
38
38
+
min-height: 100vh;
39
39
+
display: flex;
40
40
+
flex-direction: column;
40
41
}
41
42
42
43
a {
43
43
-
color: var(--primary);
44
44
-
text-decoration: none;
44
44
+
color: var(--primary);
45
45
+
text-decoration: none;
45
46
}
46
47
47
48
a:hover {
48
48
-
text-decoration: underline;
49
49
+
text-decoration: underline;
49
50
}
50
51
51
52
/* Header */
52
53
.header {
53
53
-
background: var(--bg-secondary);
54
54
-
border-bottom: 1px solid var(--border);
55
55
-
padding: 1rem;
54
54
+
background: var(--bg-secondary);
55
55
+
border-bottom: 1px solid var(--border);
56
56
+
padding: 1rem;
56
57
}
57
58
58
59
.nav {
59
59
-
max-width: 1200px;
60
60
-
margin: 0 auto;
61
61
-
display: flex;
62
62
-
justify-content: space-between;
63
63
-
align-items: center;
60
60
+
max-width: 1200px;
61
61
+
margin: 0 auto;
62
62
+
display: flex;
63
63
+
justify-content: space-between;
64
64
+
align-items: center;
64
65
}
65
66
66
67
.logo {
67
67
-
font-size: 1.25rem;
68
68
-
font-weight: 600;
69
69
-
color: var(--text);
68
68
+
font-size: 1.25rem;
69
69
+
font-weight: 600;
70
70
+
color: var(--text);
70
71
}
71
72
72
73
.logo:hover {
73
73
-
text-decoration: none;
74
74
+
text-decoration: none;
74
75
}
75
76
76
77
.nav-links {
77
77
-
display: flex;
78
78
-
gap: 1.5rem;
79
79
-
align-items: center;
78
78
+
display: flex;
79
79
+
gap: 1.5rem;
80
80
+
align-items: center;
80
81
}
81
82
82
83
.nav-links a {
83
83
-
color: var(--text-muted);
84
84
+
color: var(--text-muted);
84
85
}
85
86
86
87
.nav-links a:hover {
87
87
-
color: var(--text);
88
88
+
color: var(--text);
88
89
}
89
90
90
91
.handle {
91
91
-
color: var(--text-muted);
92
92
-
font-size: 0.9rem;
92
92
+
color: var(--text-muted);
93
93
+
font-size: 0.9rem;
93
94
}
94
95
95
96
/* Main content */
96
97
.main {
97
97
-
flex: 1;
98
98
-
max-width: 1200px;
99
99
-
margin: 0 auto;
100
100
-
padding: 2rem 1rem;
101
101
-
width: 100%;
98
98
+
flex: 1;
99
99
+
max-width: 1200px;
100
100
+
margin: 0 auto;
101
101
+
padding: 2rem 1rem;
102
102
+
width: 100%;
102
103
}
103
104
104
105
/* Footer */
105
106
.footer {
106
106
-
background: var(--bg-secondary);
107
107
-
border-top: 1px solid var(--border);
108
108
-
padding: 1rem;
109
109
-
text-align: center;
110
110
-
color: var(--text-muted);
111
111
-
font-size: 0.9rem;
107
107
+
background: var(--bg-secondary);
108
108
+
border-top: 1px solid var(--border);
109
109
+
padding: 1rem;
110
110
+
text-align: center;
111
111
+
color: var(--text-muted);
112
112
+
font-size: 0.9rem;
112
113
}
113
114
114
115
/* Buttons */
115
116
.btn {
116
116
-
display: inline-block;
117
117
-
padding: 0.5rem 1rem;
118
118
-
border: none;
119
119
-
border-radius: 4px;
120
120
-
font-size: 1rem;
121
121
-
cursor: pointer;
122
122
-
text-decoration: none;
117
117
+
display: inline-block;
118
118
+
padding: 0.5rem 1rem;
119
119
+
border: none;
120
120
+
border-radius: 4px;
121
121
+
font-size: 1rem;
122
122
+
cursor: pointer;
123
123
+
text-decoration: none;
123
124
}
124
125
125
126
.btn:hover {
126
126
-
text-decoration: none;
127
127
+
text-decoration: none;
127
128
}
128
129
129
130
.btn-primary {
130
130
-
background: var(--primary);
131
131
-
color: white;
131
131
+
background: var(--primary);
132
132
+
color: white;
132
133
}
133
134
134
135
.btn-primary:hover {
135
135
-
background: var(--primary-hover);
136
136
+
background: var(--primary-hover);
136
137
}
137
138
138
139
.btn-secondary {
139
139
-
background: var(--bg-secondary);
140
140
-
color: var(--text);
141
141
-
border: 1px solid var(--border);
140
140
+
background: var(--bg-secondary);
141
141
+
color: var(--text);
142
142
+
border: 1px solid var(--border);
142
143
}
143
144
144
145
.btn-secondary:hover {
145
145
-
background: var(--border);
146
146
+
background: var(--border);
146
147
}
147
148
148
149
.btn-success {
149
149
-
background: var(--success);
150
150
-
color: white;
150
150
+
background: var(--success);
151
151
+
color: white;
151
152
}
152
153
153
154
.btn-danger {
154
154
-
background: var(--danger);
155
155
-
color: white;
155
155
+
background: var(--danger);
156
156
+
color: white;
156
157
}
157
158
158
159
.btn-large {
159
159
-
padding: 0.75rem 2rem;
160
160
-
font-size: 1.125rem;
160
160
+
padding: 0.75rem 2rem;
161
161
+
font-size: 1.125rem;
161
162
}
162
163
163
164
/* Forms */
164
165
.form-group {
165
165
-
margin-bottom: 1.5rem;
166
166
+
margin-bottom: 1.5rem;
166
167
}
167
168
168
169
.form-group label {
169
169
-
display: block;
170
170
-
margin-bottom: 0.5rem;
171
171
-
font-weight: 500;
170
170
+
display: block;
171
171
+
margin-bottom: 0.5rem;
172
172
+
font-weight: 500;
172
173
}
173
174
174
175
.form-group input,
175
176
.form-group textarea {
176
176
-
width: 100%;
177
177
-
padding: 0.75rem;
178
178
-
border: 1px solid var(--border);
179
179
-
border-radius: 4px;
180
180
-
font-size: 1rem;
181
181
-
background: var(--bg);
182
182
-
color: var(--text);
177
177
+
width: 100%;
178
178
+
padding: 0.75rem;
179
179
+
border: 1px solid var(--border);
180
180
+
border-radius: 4px;
181
181
+
font-size: 1rem;
182
182
+
background: var(--bg);
183
183
+
color: var(--text);
183
184
}
184
185
185
186
.form-group input:focus,
186
187
.form-group textarea:focus {
187
187
-
outline: none;
188
188
-
border-color: var(--primary);
188
188
+
outline: none;
189
189
+
border-color: var(--primary);
189
190
}
190
191
191
192
.form-group small {
192
192
-
display: block;
193
193
-
margin-top: 0.25rem;
194
194
-
color: var(--text-muted);
195
195
-
font-size: 0.875rem;
193
193
+
display: block;
194
194
+
margin-top: 0.25rem;
195
195
+
color: var(--text-muted);
196
196
+
font-size: 0.875rem;
196
197
}
197
198
198
199
.form-actions {
199
199
-
display: flex;
200
200
-
gap: 1rem;
201
201
-
margin-top: 2rem;
200
200
+
display: flex;
201
201
+
gap: 1rem;
202
202
+
margin-top: 2rem;
202
203
}
203
204
204
205
.form-page {
205
205
-
max-width: 800px;
206
206
+
max-width: 800px;
206
207
}
207
208
208
209
.content-editor {
209
209
-
font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Monaco, monospace;
210
210
-
min-height: 400px;
211
211
-
resize: vertical;
210
210
+
font-family:
211
211
+
ui-monospace, "Cascadia Code", "Source Code Pro", Menlo, Monaco, monospace;
212
212
+
min-height: 400px;
213
213
+
resize: vertical;
212
214
}
213
215
214
216
/* Auth form */
215
217
.auth-form {
216
216
-
max-width: 400px;
217
217
-
margin: 2rem auto;
218
218
+
max-width: 400px;
219
219
+
margin: 2rem auto;
218
220
}
219
221
220
222
.auth-form h1 {
221
221
-
margin-bottom: 1.5rem;
223
223
+
margin-bottom: 1.5rem;
222
224
}
223
225
224
226
/* Home page */
225
227
.hero {
226
226
-
text-align: center;
227
227
-
padding: 4rem 1rem;
228
228
+
text-align: center;
229
229
+
padding: 4rem 1rem;
228
230
}
229
231
230
232
.hero h1 {
231
231
-
font-size: 3rem;
232
232
-
margin-bottom: 1rem;
233
233
+
font-size: 3rem;
234
234
+
margin-bottom: 1rem;
233
235
}
234
236
235
237
.hero p {
236
236
-
font-size: 1.25rem;
237
237
-
color: var(--text-muted);
238
238
-
max-width: 600px;
239
239
-
margin: 0 auto 3rem;
238
238
+
font-size: 1.25rem;
239
239
+
color: var(--text-muted);
240
240
+
max-width: 600px;
241
241
+
margin: 0 auto 3rem;
240
242
}
241
243
242
244
.features {
243
243
-
display: grid;
244
244
-
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
245
245
-
gap: 2rem;
246
246
-
margin-bottom: 3rem;
247
247
-
text-align: left;
245
245
+
display: grid;
246
246
+
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
247
247
+
gap: 2rem;
248
248
+
margin-bottom: 3rem;
249
249
+
text-align: left;
248
250
}
249
251
250
252
.feature {
251
251
-
background: var(--bg-secondary);
252
252
-
padding: 1.5rem;
253
253
-
border-radius: 8px;
254
254
-
border: 1px solid var(--border);
253
253
+
background: var(--bg-secondary);
254
254
+
padding: 1.5rem;
255
255
+
border-radius: 8px;
256
256
+
border: 1px solid var(--border);
255
257
}
256
258
257
259
.feature h3 {
258
258
-
margin-bottom: 0.5rem;
260
260
+
margin-bottom: 0.5rem;
259
261
}
260
262
261
263
.feature p {
262
262
-
font-size: 1rem;
263
263
-
margin: 0;
264
264
+
font-size: 1rem;
265
265
+
margin: 0;
264
266
}
265
267
266
268
/* Dashboard */
267
269
.dashboard {
268
268
-
max-width: 800px;
270
270
+
max-width: 800px;
269
271
}
270
272
271
273
.dashboard h1 {
272
272
-
margin-bottom: 0.5rem;
274
274
+
margin-bottom: 0.5rem;
273
275
}
274
276
275
277
.dashboard p {
276
276
-
color: var(--text-muted);
277
277
-
margin-bottom: 2rem;
278
278
+
color: var(--text-muted);
279
279
+
margin-bottom: 2rem;
278
280
}
279
281
280
282
.quick-actions {
281
281
-
display: flex;
282
282
-
gap: 1rem;
283
283
-
flex-wrap: wrap;
283
283
+
display: flex;
284
284
+
gap: 1rem;
285
285
+
flex-wrap: wrap;
284
286
}
285
287
286
288
/* Publication */
287
289
.publication {
288
288
-
max-width: 800px;
290
290
+
max-width: 800px;
289
291
}
290
292
291
293
.pub-details {
292
292
-
background: var(--bg-secondary);
293
293
-
padding: 1.5rem;
294
294
-
border-radius: 8px;
295
295
-
margin-bottom: 1.5rem;
294
294
+
background: var(--bg-secondary);
295
295
+
padding: 1.5rem;
296
296
+
border-radius: 8px;
297
297
+
margin-bottom: 1.5rem;
296
298
}
297
299
298
300
.pub-details h2 {
299
299
-
margin-bottom: 0.5rem;
301
301
+
margin-bottom: 0.5rem;
300
302
}
301
303
302
304
.pub-details .url {
303
303
-
color: var(--text-muted);
304
304
-
margin-bottom: 0.5rem;
305
305
+
color: var(--text-muted);
306
306
+
margin-bottom: 0.5rem;
305
307
}
306
308
307
309
.pub-details .description {
308
308
-
margin: 0;
310
310
+
margin: 0;
309
311
}
310
312
311
313
/* Documents */
312
314
.documents {
313
313
-
max-width: 800px;
315
315
+
max-width: 800px;
314
316
}
315
317
316
318
.documents-header {
317
317
-
display: flex;
318
318
-
justify-content: space-between;
319
319
-
align-items: center;
320
320
-
margin-bottom: 1.5rem;
319
319
+
display: flex;
320
320
+
justify-content: space-between;
321
321
+
align-items: center;
322
322
+
margin-bottom: 1.5rem;
321
323
}
322
324
323
325
.filters {
324
324
-
display: flex;
325
325
-
gap: 1rem;
326
326
-
margin-bottom: 1.5rem;
327
327
-
border-bottom: 1px solid var(--border);
328
328
-
padding-bottom: 1rem;
326
326
+
display: flex;
327
327
+
gap: 1rem;
328
328
+
margin-bottom: 1.5rem;
329
329
+
border-bottom: 1px solid var(--border);
330
330
+
padding-bottom: 1rem;
329
331
}
330
332
331
333
.filter {
332
332
-
color: var(--text-muted);
333
333
-
padding: 0.25rem 0;
334
334
+
color: var(--text-muted);
335
335
+
padding: 0.25rem 0;
334
336
}
335
337
336
338
.filter.active {
337
337
-
color: var(--primary);
338
338
-
border-bottom: 2px solid var(--primary);
339
339
+
color: var(--primary);
340
340
+
border-bottom: 2px solid var(--primary);
339
341
}
340
342
341
343
.document-list {
342
342
-
list-style: none;
344
344
+
list-style: none;
343
345
}
344
346
345
347
.document-item {
346
346
-
border: 1px solid var(--border);
347
347
-
border-radius: 4px;
348
348
-
margin-bottom: 0.5rem;
348
348
+
border: 1px solid var(--border);
349
349
+
border-radius: 4px;
350
350
+
margin-bottom: 0.5rem;
349
351
}
350
352
351
353
.document-item a {
352
352
-
display: flex;
353
353
-
justify-content: space-between;
354
354
-
align-items: center;
355
355
-
padding: 1rem;
356
356
-
color: var(--text);
354
354
+
display: flex;
355
355
+
justify-content: space-between;
356
356
+
align-items: center;
357
357
+
padding: 1rem;
358
358
+
color: var(--text);
357
359
}
358
360
359
361
.document-item a:hover {
360
360
-
background: var(--bg-secondary);
361
361
-
text-decoration: none;
362
362
+
background: var(--bg-secondary);
363
363
+
text-decoration: none;
362
364
}
363
365
364
366
.document-item .title {
365
365
-
font-weight: 500;
367
367
+
font-weight: 500;
366
368
}
367
369
368
370
.document-item .meta {
369
369
-
display: flex;
370
370
-
gap: 1rem;
371
371
-
align-items: center;
371
371
+
display: flex;
372
372
+
gap: 1rem;
373
373
+
align-items: center;
372
374
}
373
375
374
376
.document-item .date {
375
375
-
color: var(--text-muted);
376
376
-
font-size: 0.9rem;
377
377
+
color: var(--text-muted);
378
378
+
font-size: 0.9rem;
377
379
}
378
380
379
381
/* Badges */
380
382
.badge {
381
381
-
display: inline-block;
382
382
-
padding: 0.25rem 0.5rem;
383
383
-
border-radius: 4px;
384
384
-
font-size: 0.75rem;
385
385
-
font-weight: 600;
386
386
-
text-transform: uppercase;
383
383
+
display: inline-block;
384
384
+
padding: 0.25rem 0.5rem;
385
385
+
border-radius: 4px;
386
386
+
font-size: 0.75rem;
387
387
+
font-weight: 600;
388
388
+
text-transform: uppercase;
387
389
}
388
390
389
391
.badge-draft {
390
390
-
background: var(--draft);
391
391
-
color: white;
392
392
+
background: var(--draft);
393
393
+
color: white;
392
394
}
393
395
394
396
.badge-published {
395
395
-
background: var(--success);
396
396
-
color: white;
397
397
+
background: var(--success);
398
398
+
color: white;
397
399
}
398
400
399
401
/* Document view */
400
402
.document-view {
401
401
-
max-width: 800px;
403
403
+
max-width: 800px;
402
404
}
403
405
404
406
.document-header {
405
405
-
margin-bottom: 1.5rem;
407
407
+
margin-bottom: 1.5rem;
406
408
}
407
409
408
410
.document-header h1 {
409
409
-
margin-bottom: 0.5rem;
411
411
+
margin-bottom: 0.5rem;
410
412
}
411
413
412
414
.document-meta {
413
413
-
display: flex;
414
414
-
gap: 1rem;
415
415
-
align-items: center;
416
416
-
flex-wrap: wrap;
417
417
-
color: var(--text-muted);
418
418
-
font-size: 0.9rem;
415
415
+
display: flex;
416
416
+
gap: 1rem;
417
417
+
align-items: center;
418
418
+
flex-wrap: wrap;
419
419
+
color: var(--text-muted);
420
420
+
font-size: 0.9rem;
419
421
}
420
422
421
423
.document-content {
422
422
-
background: var(--bg-secondary);
423
423
-
padding: 1.5rem;
424
424
-
border-radius: 8px;
425
425
-
margin-bottom: 1.5rem;
424
424
+
background: var(--bg-secondary);
425
425
+
padding: 1.5rem;
426
426
+
border-radius: 8px;
427
427
+
margin-bottom: 1.5rem;
426
428
}
427
429
428
430
.document-content pre {
429
429
-
white-space: pre-wrap;
430
430
-
word-break: break-word;
431
431
-
font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Monaco, monospace;
432
432
-
font-size: 0.9rem;
433
433
-
line-height: 1.7;
431
431
+
white-space: pre-wrap;
432
432
+
word-break: break-word;
433
433
+
font-family:
434
434
+
ui-monospace, "Cascadia Code", "Source Code Pro", Menlo, Monaco, monospace;
435
435
+
font-size: 0.9rem;
436
436
+
line-height: 1.7;
434
437
}
435
438
436
439
.actions {
437
437
-
display: flex;
438
438
-
gap: 1rem;
439
439
-
flex-wrap: wrap;
440
440
+
display: flex;
441
441
+
gap: 1rem;
442
442
+
flex-wrap: wrap;
440
443
}
441
444
442
445
.empty {
443
443
-
color: var(--text-muted);
444
444
-
text-align: center;
445
445
-
padding: 3rem;
446
446
+
color: var(--text-muted);
447
447
+
text-align: center;
448
448
+
padding: 3rem;
446
449
}
447
450
448
451
.error {
449
449
-
color: var(--danger);
450
450
-
text-align: center;
451
451
-
padding: 2rem;
452
452
+
color: var(--danger);
453
453
+
text-align: center;
454
454
+
padding: 2rem;
452
455
}
453
456
454
457
.error-message {
455
455
-
background: #fef2f2;
456
456
-
border: 1px solid #fecaca;
457
457
-
color: #dc2626;
458
458
-
padding: 1rem;
459
459
-
border-radius: 4px;
460
460
-
margin-bottom: 1.5rem;
458
458
+
background: #fef2f2;
459
459
+
border: 1px solid #fecaca;
460
460
+
color: #dc2626;
461
461
+
padding: 1rem;
462
462
+
border-radius: 4px;
463
463
+
margin-bottom: 1.5rem;
461
464
}
462
465
463
466
@media (prefers-color-scheme: dark) {
464
464
-
.error-message {
465
465
-
background: #450a0a;
466
466
-
border-color: #7f1d1d;
467
467
-
color: #fca5a5;
468
468
-
}
467
467
+
.error-message {
468
468
+
background: #450a0a;
469
469
+
border-color: #7f1d1d;
470
470
+
color: #fca5a5;
471
471
+
}
469
472
}
+18
-18
packages/web/scripts/cleanup.ts
···
12
12
const DB_PATH = path.join(DATA_DIR, "oauth.db");
13
13
14
14
try {
15
15
-
const db = new Database(DB_PATH);
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
19
-
`DELETE FROM oauth_states WHERE created_at < strftime('%s', 'now') - 3600`,
20
20
-
);
17
17
+
// Clean up OAuth states older than 1 hour
18
18
+
const statesResult = db.run(
19
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
24
-
`DELETE FROM oauth_sessions WHERE updated_at < strftime('%s', 'now') - 2592000`,
25
25
-
);
22
22
+
// Clean up sessions older than 30 days (optional - sessions may still be valid)
23
23
+
const sessionsResult = db.run(
24
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
28
-
db.run("VACUUM");
27
27
+
// Vacuum the database to reclaim space
28
28
+
db.run("VACUUM");
29
29
30
30
-
const timestamp = new Date().toISOString();
31
31
-
console.log(
32
32
-
`[${timestamp}] Cleanup complete: removed old states and sessions, vacuumed database`,
33
33
-
);
30
30
+
const timestamp = new Date().toISOString();
31
31
+
console.log(
32
32
+
`[${timestamp}] Cleanup complete: removed old states and sessions, vacuumed database`,
33
33
+
);
34
34
35
35
-
db.close();
35
35
+
db.close();
36
36
} catch (error) {
37
37
-
console.error("Cleanup failed:", error);
38
38
-
process.exit(1);
37
37
+
console.error("Cleanup failed:", error);
38
38
+
process.exit(1);
39
39
}
+22
-20
packages/web/src/lib/content-types.ts
···
1
1
export interface ContentMarkdown {
2
2
-
$type: "site.standard.content.markdown";
3
3
-
text: string;
2
2
+
$type: "site.standard.content.markdown";
3
3
+
text: string;
4
4
}
5
5
6
6
export type DocumentContent = ContentMarkdown;
7
7
8
8
export function createMarkdownContent(text: string): ContentMarkdown {
9
9
-
return {
10
10
-
$type: "site.standard.content.markdown",
11
11
-
text,
12
12
-
};
9
9
+
return {
10
10
+
$type: "site.standard.content.markdown",
11
11
+
text,
12
12
+
};
13
13
}
14
14
15
15
export function isMarkdownContent(value: unknown): value is ContentMarkdown {
16
16
-
return (
17
17
-
typeof value === "object" &&
18
18
-
value !== null &&
19
19
-
"$type" in value &&
20
20
-
(value as ContentMarkdown).$type === "site.standard.content.markdown"
21
21
-
);
16
16
+
return (
17
17
+
typeof value === "object" &&
18
18
+
value !== null &&
19
19
+
"$type" in value &&
20
20
+
(value as ContentMarkdown).$type === "site.standard.content.markdown"
21
21
+
);
22
22
}
23
23
24
24
-
export function getDocumentContentText(doc: Record<string, unknown>): string | null {
25
25
-
if (isMarkdownContent(doc.content)) {
26
26
-
return doc.content.text;
27
27
-
}
28
28
-
if (typeof doc.textContent === "string") {
29
29
-
return doc.textContent;
30
30
-
}
31
31
-
return null;
24
24
+
export function getDocumentContentText(
25
25
+
doc: Record<string, unknown>,
26
26
+
): string | null {
27
27
+
if (isMarkdownContent(doc.content)) {
28
28
+
return doc.content.text;
29
29
+
}
30
30
+
if (typeof doc.textContent === "string") {
31
31
+
return doc.textContent;
32
32
+
}
33
33
+
return null;
32
34
}
+61
-57
packages/web/src/lib/csrf.ts
···
1
1
-
import type { Context, Next } from 'hono';
2
2
-
import { getCookie, setCookie } from 'hono/cookie';
1
1
+
import type { Context, Next } from "hono";
2
2
+
import { getCookie, setCookie } from "hono/cookie";
3
3
4
4
-
const CSRF_COOKIE_NAME = 'csrf_token';
5
5
-
const CSRF_HEADER_NAME = 'x-csrf-token';
6
6
-
const CSRF_FORM_FIELD = '_csrf';
4
4
+
const CSRF_COOKIE_NAME = "csrf_token";
5
5
+
const CSRF_HEADER_NAME = "x-csrf-token";
6
6
+
const CSRF_FORM_FIELD = "_csrf";
7
7
8
8
/**
9
9
* Generate a cryptographically secure random token
10
10
*/
11
11
function generateToken(): string {
12
12
-
const buffer = new Uint8Array(32);
13
13
-
crypto.getRandomValues(buffer);
14
14
-
return Array.from(buffer, b => b.toString(16).padStart(2, '0')).join('');
12
12
+
const buffer = new Uint8Array(32);
13
13
+
crypto.getRandomValues(buffer);
14
14
+
return Array.from(buffer, (b) => b.toString(16).padStart(2, "0")).join("");
15
15
}
16
16
17
17
/**
18
18
* Get or create a CSRF token for the current session
19
19
*/
20
20
export function getCSRFToken(c: Context): string {
21
21
-
let token = getCookie(c, CSRF_COOKIE_NAME);
22
22
-
23
23
-
if (!token) {
24
24
-
token = generateToken();
25
25
-
setCookie(c, CSRF_COOKIE_NAME, token, {
26
26
-
httpOnly: true,
27
27
-
secure: process.env.PUBLIC_URL?.startsWith('https') || false,
28
28
-
sameSite: 'Strict',
29
29
-
path: '/',
30
30
-
maxAge: 60 * 60 * 24, // 24 hours
31
31
-
});
32
32
-
}
33
33
-
34
34
-
return token;
21
21
+
let token = getCookie(c, CSRF_COOKIE_NAME);
22
22
+
23
23
+
if (!token) {
24
24
+
token = generateToken();
25
25
+
setCookie(c, CSRF_COOKIE_NAME, token, {
26
26
+
httpOnly: true,
27
27
+
secure: process.env.PUBLIC_URL?.startsWith("https") || false,
28
28
+
sameSite: "Strict",
29
29
+
path: "/",
30
30
+
maxAge: 60 * 60 * 24, // 24 hours
31
31
+
});
32
32
+
}
33
33
+
34
34
+
return token;
35
35
}
36
36
37
37
/**
38
38
* Middleware to validate CSRF token on POST/PUT/DELETE requests
39
39
*/
40
40
export async function csrfProtection(c: Context, next: Next) {
41
41
-
const method = c.req.method.toUpperCase();
42
42
-
43
43
-
// Only check CSRF for state-changing methods
44
44
-
if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(method)) {
45
45
-
const cookieToken = getCookie(c, CSRF_COOKIE_NAME);
46
46
-
47
47
-
if (!cookieToken) {
48
48
-
return c.text('CSRF token missing', 403);
49
49
-
}
50
50
-
51
51
-
// Check header first (for AJAX requests)
52
52
-
let requestToken = c.req.header(CSRF_HEADER_NAME);
53
53
-
54
54
-
// Fall back to form field
55
55
-
if (!requestToken) {
56
56
-
const contentType = c.req.header('content-type') || '';
57
57
-
if (contentType.includes('application/x-www-form-urlencoded') ||
58
58
-
contentType.includes('multipart/form-data')) {
59
59
-
try {
60
60
-
const body = await c.req.parseBody();
61
61
-
requestToken = body[CSRF_FORM_FIELD] as string;
62
62
-
} catch {
63
63
-
// Body might have already been parsed
64
64
-
}
65
65
-
}
66
66
-
}
67
67
-
68
68
-
if (!requestToken || requestToken !== cookieToken) {
69
69
-
return c.text('CSRF token invalid', 403);
70
70
-
}
71
71
-
}
72
72
-
73
73
-
await next();
41
41
+
const method = c.req.method.toUpperCase();
42
42
+
43
43
+
// Only check CSRF for state-changing methods
44
44
+
if (["POST", "PUT", "DELETE", "PATCH"].includes(method)) {
45
45
+
const cookieToken = getCookie(c, CSRF_COOKIE_NAME);
46
46
+
47
47
+
if (!cookieToken) {
48
48
+
return c.text("CSRF token missing", 403);
49
49
+
}
50
50
+
51
51
+
// Check header first (for AJAX requests)
52
52
+
let requestToken = c.req.header(CSRF_HEADER_NAME);
53
53
+
54
54
+
// Fall back to form field
55
55
+
if (!requestToken) {
56
56
+
const contentType = c.req.header("content-type") || "";
57
57
+
if (
58
58
+
contentType.includes("application/x-www-form-urlencoded") ||
59
59
+
contentType.includes("multipart/form-data")
60
60
+
) {
61
61
+
try {
62
62
+
const body = await c.req.parseBody();
63
63
+
requestToken = body[CSRF_FORM_FIELD] as string;
64
64
+
} catch {
65
65
+
// Body might have already been parsed
66
66
+
}
67
67
+
}
68
68
+
}
69
69
+
70
70
+
if (!requestToken || requestToken !== cookieToken) {
71
71
+
return c.text("CSRF token invalid", 403);
72
72
+
}
73
73
+
}
74
74
+
75
75
+
await next();
74
76
}
75
77
76
76
-
import { raw } from 'hono/html';
78
78
+
import { raw } from "hono/html";
77
79
78
80
/**
79
81
* HTML helper to generate a hidden CSRF input field
80
82
* Returns a raw HTML string that won't be escaped by Hono's html template
81
83
*/
82
84
export function csrfField(token: string) {
83
83
-
return raw(`<input type="hidden" name="${CSRF_FORM_FIELD}" value="${token}" />`);
85
85
+
return raw(
86
86
+
`<input type="hidden" name="${CSRF_FORM_FIELD}" value="${token}" />`,
87
87
+
);
84
88
}
+40
-33
packages/web/src/lib/logger.ts
···
1
1
-
import * as fs from 'fs';
2
2
-
import * as path from 'path';
1
1
+
import * as fs from "fs";
2
2
+
import * as path from "path";
3
3
4
4
-
const DATA_DIR = process.env.DATA_DIR || './data';
5
5
-
const LOG_PATH = path.join(DATA_DIR, 'app.log');
4
4
+
const DATA_DIR = process.env.DATA_DIR || "./data";
5
5
+
const LOG_PATH = path.join(DATA_DIR, "app.log");
6
6
7
7
-
type LogLevel = 'INFO' | 'WARN' | 'ERROR' | 'DEBUG';
7
7
+
type LogLevel = "INFO" | "WARN" | "ERROR" | "DEBUG";
8
8
9
9
function formatDate(date: Date): string {
10
10
-
return date.toISOString();
10
10
+
return date.toISOString();
11
11
}
12
12
13
13
-
function writeLog(level: LogLevel, message: string, meta?: Record<string, any>) {
14
14
-
const timestamp = formatDate(new Date());
15
15
-
const metaStr = meta ? ` ${JSON.stringify(meta)}` : '';
16
16
-
const logLine = `[${timestamp}] ${level}: ${message}${metaStr}\n`;
17
17
-
18
18
-
// Write to file
19
19
-
try {
20
20
-
fs.appendFileSync(LOG_PATH, logLine);
21
21
-
} catch (err) {
22
22
-
// Fall back to console if file write fails
23
23
-
console.error('Failed to write to log file:', err);
24
24
-
}
25
25
-
26
26
-
// Also write to stdout/stderr for systemd journal
27
27
-
if (level === 'ERROR') {
28
28
-
process.stderr.write(logLine);
29
29
-
} else {
30
30
-
process.stdout.write(logLine);
31
31
-
}
13
13
+
function writeLog(
14
14
+
level: LogLevel,
15
15
+
message: string,
16
16
+
meta?: Record<string, any>,
17
17
+
) {
18
18
+
const timestamp = formatDate(new Date());
19
19
+
const metaStr = meta ? ` ${JSON.stringify(meta)}` : "";
20
20
+
const logLine = `[${timestamp}] ${level}: ${message}${metaStr}\n`;
21
21
+
22
22
+
// Write to file
23
23
+
try {
24
24
+
fs.appendFileSync(LOG_PATH, logLine);
25
25
+
} catch (err) {
26
26
+
// Fall back to console if file write fails
27
27
+
console.error("Failed to write to log file:", err);
28
28
+
}
29
29
+
30
30
+
// Also write to stdout/stderr for systemd journal
31
31
+
if (level === "ERROR") {
32
32
+
process.stderr.write(logLine);
33
33
+
} else {
34
34
+
process.stdout.write(logLine);
35
35
+
}
32
36
}
33
37
34
38
export const logger = {
35
35
-
info: (message: string, meta?: Record<string, any>) => writeLog('INFO', message, meta),
36
36
-
warn: (message: string, meta?: Record<string, any>) => writeLog('WARN', message, meta),
37
37
-
error: (message: string, meta?: Record<string, any>) => writeLog('ERROR', message, meta),
38
38
-
debug: (message: string, meta?: Record<string, any>) => {
39
39
-
if (process.env.DEBUG) {
40
40
-
writeLog('DEBUG', message, meta);
41
41
-
}
42
42
-
},
39
39
+
info: (message: string, meta?: Record<string, any>) =>
40
40
+
writeLog("INFO", message, meta),
41
41
+
warn: (message: string, meta?: Record<string, any>) =>
42
42
+
writeLog("WARN", message, meta),
43
43
+
error: (message: string, meta?: Record<string, any>) =>
44
44
+
writeLog("ERROR", message, meta),
45
45
+
debug: (message: string, meta?: Record<string, any>) => {
46
46
+
if (process.env.DEBUG) {
47
47
+
writeLog("DEBUG", message, meta);
48
48
+
}
49
49
+
},
43
50
};
+94
-94
packages/web/src/lib/oauth.ts
···
1
1
import { NodeOAuthClient } from "@atproto/oauth-client-node";
2
2
import type {
3
3
-
NodeSavedSession,
4
4
-
NodeSavedState,
3
3
+
NodeSavedSession,
4
4
+
NodeSavedState,
5
5
} from "@atproto/oauth-client-node";
6
6
import { JoseKey } from "@atproto/jwk-jose";
7
7
import { Agent } from "@atproto/api";
···
17
17
18
18
// Ensure data directory exists
19
19
if (!fs.existsSync(DATA_DIR)) {
20
20
-
fs.mkdirSync(DATA_DIR, { recursive: true });
20
20
+
fs.mkdirSync(DATA_DIR, { recursive: true });
21
21
}
22
22
23
23
// Initialize SQLite database
···
42
42
43
43
// Clean up old states (older than 1 hour)
44
44
db.run(
45
45
-
`DELETE FROM oauth_states WHERE created_at < strftime('%s', 'now') - 3600`,
45
45
+
`DELETE FROM oauth_states WHERE created_at < strftime('%s', 'now') - 3600`,
46
46
);
47
47
48
48
// State store implementation
49
49
const stateStore = {
50
50
-
async set(key: string, state: NodeSavedState): Promise<void> {
51
51
-
const stateJson = JSON.stringify(state);
52
52
-
db.run(
53
53
-
`INSERT OR REPLACE INTO oauth_states (key, state, created_at) VALUES (?, ?, strftime('%s', 'now'))`,
54
54
-
[key, stateJson],
55
55
-
);
56
56
-
},
57
57
-
async get(key: string): Promise<NodeSavedState | undefined> {
58
58
-
const row = db
59
59
-
.query(`SELECT state FROM oauth_states WHERE key = ?`)
60
60
-
.get(key) as { state: string } | null;
61
61
-
if (!row) return undefined;
62
62
-
return JSON.parse(row.state);
63
63
-
},
64
64
-
async del(key: string): Promise<void> {
65
65
-
db.run(`DELETE FROM oauth_states WHERE key = ?`, [key]);
66
66
-
},
50
50
+
async set(key: string, state: NodeSavedState): Promise<void> {
51
51
+
const stateJson = JSON.stringify(state);
52
52
+
db.run(
53
53
+
`INSERT OR REPLACE INTO oauth_states (key, state, created_at) VALUES (?, ?, strftime('%s', 'now'))`,
54
54
+
[key, stateJson],
55
55
+
);
56
56
+
},
57
57
+
async get(key: string): Promise<NodeSavedState | undefined> {
58
58
+
const row = db
59
59
+
.query(`SELECT state FROM oauth_states WHERE key = ?`)
60
60
+
.get(key) as { state: string } | null;
61
61
+
if (!row) return undefined;
62
62
+
return JSON.parse(row.state);
63
63
+
},
64
64
+
async del(key: string): Promise<void> {
65
65
+
db.run(`DELETE FROM oauth_states WHERE key = ?`, [key]);
66
66
+
},
67
67
};
68
68
69
69
// Session store implementation
70
70
const sessionStore = {
71
71
-
async set(did: string, session: NodeSavedSession): Promise<void> {
72
72
-
const sessionJson = JSON.stringify(session);
73
73
-
db.run(
74
74
-
`INSERT OR REPLACE INTO oauth_sessions (did, session, updated_at) VALUES (?, ?, strftime('%s', 'now'))`,
75
75
-
[did, sessionJson],
76
76
-
);
77
77
-
},
78
78
-
async get(did: string): Promise<NodeSavedSession | undefined> {
79
79
-
const row = db
80
80
-
.query(`SELECT session FROM oauth_sessions WHERE did = ?`)
81
81
-
.get(did) as { session: string } | null;
82
82
-
if (!row) return undefined;
83
83
-
return JSON.parse(row.session);
84
84
-
},
85
85
-
async del(did: string): Promise<void> {
86
86
-
db.run(`DELETE FROM oauth_sessions WHERE did = ?`, [did]);
87
87
-
},
71
71
+
async set(did: string, session: NodeSavedSession): Promise<void> {
72
72
+
const sessionJson = JSON.stringify(session);
73
73
+
db.run(
74
74
+
`INSERT OR REPLACE INTO oauth_sessions (did, session, updated_at) VALUES (?, ?, strftime('%s', 'now'))`,
75
75
+
[did, sessionJson],
76
76
+
);
77
77
+
},
78
78
+
async get(did: string): Promise<NodeSavedSession | undefined> {
79
79
+
const row = db
80
80
+
.query(`SELECT session FROM oauth_sessions WHERE did = ?`)
81
81
+
.get(did) as { session: string } | null;
82
82
+
if (!row) return undefined;
83
83
+
return JSON.parse(row.session);
84
84
+
},
85
85
+
async del(did: string): Promise<void> {
86
86
+
db.run(`DELETE FROM oauth_sessions WHERE did = ?`, [did]);
87
87
+
},
88
88
};
89
89
90
90
// Generate or load private key for confidential client
91
91
async function getOrCreatePrivateKey(): Promise<JoseKey> {
92
92
-
if (fs.existsSync(KEYS_PATH)) {
93
93
-
const keyData = JSON.parse(fs.readFileSync(KEYS_PATH, "utf-8"));
94
94
-
return JoseKey.fromJWK(keyData, keyData.kid);
95
95
-
}
92
92
+
if (fs.existsSync(KEYS_PATH)) {
93
93
+
const keyData = JSON.parse(fs.readFileSync(KEYS_PATH, "utf-8"));
94
94
+
return JoseKey.fromJWK(keyData, keyData.kid);
95
95
+
}
96
96
97
97
-
// Generate a new ES256 key
98
98
-
const key = await JoseKey.generate(["ES256"], crypto.randomUUID());
99
99
-
const jwk = key.privateJwk;
97
97
+
// Generate a new ES256 key
98
98
+
const key = await JoseKey.generate(["ES256"], crypto.randomUUID());
99
99
+
const jwk = key.privateJwk;
100
100
101
101
-
// Save to disk with restrictive permissions (owner read/write only)
102
102
-
fs.writeFileSync(KEYS_PATH, JSON.stringify(jwk, null, 2), { mode: 0o600 });
101
101
+
// Save to disk with restrictive permissions (owner read/write only)
102
102
+
fs.writeFileSync(KEYS_PATH, JSON.stringify(jwk, null, 2), { mode: 0o600 });
103
103
104
104
-
return key;
104
104
+
return key;
105
105
}
106
106
107
107
let oauthClientInstance: NodeOAuthClient | null = null;
108
108
let initPromise: Promise<NodeOAuthClient> | null = null;
109
109
110
110
async function initOAuthClient(): Promise<NodeOAuthClient> {
111
111
-
if (oauthClientInstance) return oauthClientInstance;
112
112
-
if (initPromise) return initPromise;
111
111
+
if (oauthClientInstance) return oauthClientInstance;
112
112
+
if (initPromise) return initPromise;
113
113
114
114
-
initPromise = (async () => {
115
115
-
const privateKey = await getOrCreatePrivateKey();
114
114
+
initPromise = (async () => {
115
115
+
const privateKey = await getOrCreatePrivateKey();
116
116
117
117
-
oauthClientInstance = new NodeOAuthClient({
118
118
-
clientMetadata: {
119
119
-
client_id: `${PUBLIC_URL}/client-metadata.json`,
120
120
-
client_name: "std.pub",
121
121
-
client_uri: PUBLIC_URL,
122
122
-
redirect_uris: [`${PUBLIC_URL}/auth/callback`],
123
123
-
scope: "atproto transition:generic",
124
124
-
grant_types: ["authorization_code", "refresh_token"],
125
125
-
response_types: ["code"],
126
126
-
application_type: "web",
127
127
-
token_endpoint_auth_method: "private_key_jwt",
128
128
-
token_endpoint_auth_signing_alg: "ES256",
129
129
-
dpop_bound_access_tokens: true,
130
130
-
jwks_uri: `${PUBLIC_URL}/jwks.json`,
131
131
-
},
132
132
-
keyset: [privateKey],
133
133
-
stateStore,
134
134
-
sessionStore,
135
135
-
});
117
117
+
oauthClientInstance = new NodeOAuthClient({
118
118
+
clientMetadata: {
119
119
+
client_id: `${PUBLIC_URL}/client-metadata.json`,
120
120
+
client_name: "std.pub",
121
121
+
client_uri: PUBLIC_URL,
122
122
+
redirect_uris: [`${PUBLIC_URL}/auth/callback`],
123
123
+
scope: "atproto transition:generic",
124
124
+
grant_types: ["authorization_code", "refresh_token"],
125
125
+
response_types: ["code"],
126
126
+
application_type: "web",
127
127
+
token_endpoint_auth_method: "private_key_jwt",
128
128
+
token_endpoint_auth_signing_alg: "ES256",
129
129
+
dpop_bound_access_tokens: true,
130
130
+
jwks_uri: `${PUBLIC_URL}/jwks.json`,
131
131
+
},
132
132
+
keyset: [privateKey],
133
133
+
stateStore,
134
134
+
sessionStore,
135
135
+
});
136
136
137
137
-
return oauthClientInstance;
138
138
-
})();
137
137
+
return oauthClientInstance;
138
138
+
})();
139
139
140
140
-
return initPromise;
140
140
+
return initPromise;
141
141
}
142
142
143
143
export async function getOAuthClient(): Promise<NodeOAuthClient> {
144
144
-
return initOAuthClient();
144
144
+
return initOAuthClient();
145
145
}
146
146
147
147
export async function getClientMetadata() {
148
148
-
const client = await getOAuthClient();
149
149
-
return client.clientMetadata;
148
148
+
const client = await getOAuthClient();
149
149
+
return client.clientMetadata;
150
150
}
151
151
152
152
export async function getJwks() {
153
153
-
const client = await getOAuthClient();
154
154
-
return client.jwks;
153
153
+
const client = await getOAuthClient();
154
154
+
return client.jwks;
155
155
}
156
156
157
157
export async function getAgentForSession(
158
158
-
did: string,
158
158
+
did: string,
159
159
): Promise<{ agent: Agent; did: string; handle: string }> {
160
160
-
const client = await getOAuthClient();
161
161
-
const oauthSession = await client.restore(did);
160
160
+
const client = await getOAuthClient();
161
161
+
const oauthSession = await client.restore(did);
162
162
163
163
-
if (!oauthSession) {
164
164
-
throw new Error("Session not found");
165
165
-
}
163
163
+
if (!oauthSession) {
164
164
+
throw new Error("Session not found");
165
165
+
}
166
166
167
167
-
const agent = new Agent(oauthSession);
167
167
+
const agent = new Agent(oauthSession);
168
168
169
169
-
// Fetch profile to get handle
170
170
-
const profile = await agent.getProfile({ actor: did });
169
169
+
// Fetch profile to get handle
170
170
+
const profile = await agent.getProfile({ actor: did });
171
171
172
172
-
return {
173
173
-
agent,
174
174
-
did,
175
175
-
handle: profile.data.handle,
176
176
-
};
172
172
+
return {
173
173
+
agent,
174
174
+
did,
175
175
+
handle: profile.data.handle,
176
176
+
};
177
177
}
178
178
179
179
export async function deleteSession(did: string): Promise<void> {
180
180
-
await sessionStore.del(did);
180
180
+
await sessionStore.del(did);
181
181
}
+25
-25
packages/web/src/lib/session.ts
···
1
1
-
import type { Context } from 'hono';
2
2
-
import { getCookie } from 'hono/cookie';
3
3
-
import { getAgentForSession } from './oauth';
4
4
-
import type { Agent } from '@atproto/api';
1
1
+
import type { Context } from "hono";
2
2
+
import { getCookie } from "hono/cookie";
3
3
+
import { getAgentForSession } from "./oauth";
4
4
+
import type { Agent } from "@atproto/api";
5
5
6
6
export interface Session {
7
7
-
did: string | null;
8
8
-
handle: string | null;
9
9
-
agent: Agent | null;
7
7
+
did: string | null;
8
8
+
handle: string | null;
9
9
+
agent: Agent | null;
10
10
}
11
11
12
12
export async function getSession(c: Context): Promise<Session> {
13
13
-
const did = getCookie(c, 'session');
14
14
-
15
15
-
if (!did) {
16
16
-
return { did: null, handle: null, agent: null };
17
17
-
}
13
13
+
const did = getCookie(c, "session");
18
14
19
19
-
try {
20
20
-
const { agent, handle } = await getAgentForSession(did);
21
21
-
return { did, handle, agent };
22
22
-
} catch (error) {
23
23
-
// Session might be invalid or expired
24
24
-
console.error('Session error:', error);
25
25
-
return { did: null, handle: null, agent: null };
26
26
-
}
15
15
+
if (!did) {
16
16
+
return { did: null, handle: null, agent: null };
17
17
+
}
18
18
+
19
19
+
try {
20
20
+
const { agent, handle } = await getAgentForSession(did);
21
21
+
return { did, handle, agent };
22
22
+
} catch (error) {
23
23
+
// Session might be invalid or expired
24
24
+
console.error("Session error:", error);
25
25
+
return { did: null, handle: null, agent: null };
26
26
+
}
27
27
}
28
28
29
29
export function requireAuth(c: Context): Session {
30
30
-
const session = c.get('session') as Session;
31
31
-
if (!session.did || !session.agent) {
32
32
-
throw new Error('Not authenticated');
33
33
-
}
34
34
-
return session;
30
30
+
const session = c.get("session") as Session;
31
31
+
if (!session.did || !session.agent) {
32
32
+
throw new Error("Not authenticated");
33
33
+
}
34
34
+
return session;
35
35
}
+11
-11
packages/web/src/lib/validation.ts
···
3
3
* TIDs are base36 encoded and should be 13 characters
4
4
*/
5
5
export function isValidTID(tid: string): boolean {
6
6
-
if (!tid || typeof tid !== 'string') return false;
7
7
-
// TID should be 13 characters of base36 (0-9, a-z)
8
8
-
return /^[0-9a-z]{13}$/.test(tid);
6
6
+
if (!tid || typeof tid !== "string") return false;
7
7
+
// TID should be 13 characters of base36 (0-9, a-z)
8
8
+
return /^[0-9a-z]{13}$/.test(tid);
9
9
}
10
10
11
11
/**
12
12
* Validate that a URL is a valid HTTPS URL
13
13
*/
14
14
export function isValidHttpsUrl(url: string): boolean {
15
15
-
try {
16
16
-
const parsed = new URL(url);
17
17
-
return parsed.protocol === 'https:';
18
18
-
} catch {
19
19
-
return false;
20
20
-
}
15
15
+
try {
16
16
+
const parsed = new URL(url);
17
17
+
return parsed.protocol === "https:";
18
18
+
} catch {
19
19
+
return false;
20
20
+
}
21
21
}
22
22
23
23
/**
···
25
25
* Note: Hono's html template already escapes, but this is defense in depth
26
26
*/
27
27
export function sanitizeString(str: string, maxLength: number = 1000): string {
28
28
-
if (!str || typeof str !== 'string') return '';
29
29
-
return str.slice(0, maxLength);
28
28
+
if (!str || typeof str !== "string") return "";
29
29
+
return str.slice(0, maxLength);
30
30
}
+95
-91
packages/web/src/routes/auth.ts
···
2
2
import { getCookie, setCookie, deleteCookie } from "hono/cookie";
3
3
import { html } from "hono/html";
4
4
import {
5
5
-
getOAuthClient,
6
6
-
getClientMetadata,
7
7
-
getJwks,
8
8
-
deleteSession,
5
5
+
getOAuthClient,
6
6
+
getClientMetadata,
7
7
+
getJwks,
8
8
+
deleteSession,
9
9
} from "../lib/oauth";
10
10
import { layout } from "../views/layouts/main";
11
11
import { csrfField } from "../lib/csrf";
···
14
14
15
15
// Client metadata endpoint (required for OAuth)
16
16
authRoutes.get("/client-metadata.json", async (c) => {
17
17
-
try {
18
18
-
const metadata = await getClientMetadata();
19
19
-
return c.json(metadata);
20
20
-
} catch (error) {
21
21
-
console.error("Error getting client metadata:", error);
22
22
-
return c.json({ error: "Failed to get client metadata" }, 500);
23
23
-
}
17
17
+
try {
18
18
+
const metadata = await getClientMetadata();
19
19
+
return c.json(metadata);
20
20
+
} catch (error) {
21
21
+
console.error("Error getting client metadata:", error);
22
22
+
return c.json({ error: "Failed to get client metadata" }, 500);
23
23
+
}
24
24
});
25
25
26
26
// JWKS endpoint (required for confidential clients)
27
27
authRoutes.get("/jwks.json", async (c) => {
28
28
-
try {
29
29
-
const jwks = await getJwks();
30
30
-
return c.json(jwks);
31
31
-
} catch (error) {
32
32
-
console.error("Error getting JWKS:", error);
33
33
-
return c.json({ error: "Failed to get JWKS" }, 500);
34
34
-
}
28
28
+
try {
29
29
+
const jwks = await getJwks();
30
30
+
return c.json(jwks);
31
31
+
} catch (error) {
32
32
+
console.error("Error getting JWKS:", error);
33
33
+
return c.json({ error: "Failed to get JWKS" }, 500);
34
34
+
}
35
35
});
36
36
37
37
// Login page
38
38
authRoutes.get("/login", async (c) => {
39
39
-
const error = c.req.query("error");
40
40
-
const csrfToken = c.get("csrfToken") as string;
39
39
+
const error = c.req.query("error");
40
40
+
const csrfToken = c.get("csrfToken") as string;
41
41
42
42
-
const content = html`
42
42
+
const content = html`
43
43
<div class="auth-form">
44
44
<h1>Login with Bluesky</h1>
45
45
46
46
-
${error
47
47
-
? html`
46
46
+
${
47
47
+
error
48
48
+
? html`
48
49
<div class="error-message">
49
49
-
${error === "handle_required"
50
50
-
? "Please enter your handle or DID."
51
51
-
: error === "authorization_failed"
52
52
-
? "Authorization failed. Please try again."
53
53
-
: error === "callback_failed"
54
54
-
? "Login failed. Please try again."
55
55
-
: "An error occurred. Please try again."}
50
50
+
${
51
51
+
error === "handle_required"
52
52
+
? "Please enter your handle or DID."
53
53
+
: error === "authorization_failed"
54
54
+
? "Authorization failed. Please try again."
55
55
+
: error === "callback_failed"
56
56
+
? "Login failed. Please try again."
57
57
+
: "An error occurred. Please try again."
58
58
+
}
56
59
</div>
57
60
`
58
58
-
: ""}
61
61
+
: ""
62
62
+
}
59
63
60
64
<form action="/auth/login" method="POST">
61
65
${csrfField(csrfToken)}
···
80
84
</div>
81
85
`;
82
86
83
83
-
return c.html(layout(content, { title: "Login - std.pub" }));
87
87
+
return c.html(layout(content, { title: "Login - std.pub" }));
84
88
});
85
89
86
90
// Handle login form submission
87
91
authRoutes.post("/login", async (c) => {
88
88
-
const body = await c.req.parseBody();
89
89
-
let handle = body.handle as string;
92
92
+
const body = await c.req.parseBody();
93
93
+
let handle = body.handle as string;
90
94
91
91
-
if (!handle) {
92
92
-
return c.redirect("/auth/login?error=handle_required");
93
93
-
}
95
95
+
if (!handle) {
96
96
+
return c.redirect("/auth/login?error=handle_required");
97
97
+
}
94
98
95
95
-
// Trim and normalize handle
96
96
-
handle = handle.trim().toLowerCase();
99
99
+
// Trim and normalize handle
100
100
+
handle = handle.trim().toLowerCase();
97
101
98
98
-
// Remove @ prefix if present
99
99
-
if (handle.startsWith("@")) {
100
100
-
handle = handle.slice(1);
101
101
-
}
102
102
+
// Remove @ prefix if present
103
103
+
if (handle.startsWith("@")) {
104
104
+
handle = handle.slice(1);
105
105
+
}
102
106
103
103
-
try {
104
104
-
const client = await getOAuthClient();
105
105
-
const url = await client.authorize(handle, {
106
106
-
scope: "atproto transition:generic",
107
107
-
});
107
107
+
try {
108
108
+
const client = await getOAuthClient();
109
109
+
const url = await client.authorize(handle, {
110
110
+
scope: "atproto transition:generic",
111
111
+
});
108
112
109
109
-
return c.redirect(url.toString());
110
110
-
} catch (error) {
111
111
-
console.error("Login error:", error);
112
112
-
return c.redirect("/auth/login?error=authorization_failed");
113
113
-
}
113
113
+
return c.redirect(url.toString());
114
114
+
} catch (error) {
115
115
+
console.error("Login error:", error);
116
116
+
return c.redirect("/auth/login?error=authorization_failed");
117
117
+
}
114
118
});
115
119
116
120
// OAuth callback
117
121
authRoutes.get("/callback", async (c) => {
118
118
-
const url = new URL(c.req.url);
119
119
-
const params = url.searchParams;
122
122
+
const url = new URL(c.req.url);
123
123
+
const params = url.searchParams;
120
124
121
121
-
// Check for error from authorization server
122
122
-
const error = params.get("error");
123
123
-
if (error) {
124
124
-
console.error("OAuth error:", error, params.get("error_description"));
125
125
-
return c.redirect("/auth/login?error=callback_failed");
126
126
-
}
125
125
+
// Check for error from authorization server
126
126
+
const error = params.get("error");
127
127
+
if (error) {
128
128
+
console.error("OAuth error:", error, params.get("error_description"));
129
129
+
return c.redirect("/auth/login?error=callback_failed");
130
130
+
}
127
131
128
128
-
try {
129
129
-
const client = await getOAuthClient();
130
130
-
const { session } = await client.callback(params);
132
132
+
try {
133
133
+
const client = await getOAuthClient();
134
134
+
const { session } = await client.callback(params);
131
135
132
132
-
// Store the DID in a cookie for session management
133
133
-
// The actual OAuth session is stored in the database by the OAuth client
134
134
-
setCookie(c, "session", session.did, {
135
135
-
httpOnly: true,
136
136
-
secure:
137
137
-
process.env.NODE_ENV === "production" ||
138
138
-
process.env.PUBLIC_URL?.startsWith("https"),
139
139
-
sameSite: "Lax",
140
140
-
maxAge: 60 * 60 * 24 * 7, // 7 days
141
141
-
path: "/",
142
142
-
});
136
136
+
// Store the DID in a cookie for session management
137
137
+
// The actual OAuth session is stored in the database by the OAuth client
138
138
+
setCookie(c, "session", session.did, {
139
139
+
httpOnly: true,
140
140
+
secure:
141
141
+
process.env.NODE_ENV === "production" ||
142
142
+
process.env.PUBLIC_URL?.startsWith("https"),
143
143
+
sameSite: "Lax",
144
144
+
maxAge: 60 * 60 * 24 * 7, // 7 days
145
145
+
path: "/",
146
146
+
});
143
147
144
144
-
return c.redirect("/");
145
145
-
} catch (error) {
146
146
-
console.error("Callback error:", error);
147
147
-
return c.redirect("/auth/login?error=callback_failed");
148
148
-
}
148
148
+
return c.redirect("/");
149
149
+
} catch (error) {
150
150
+
console.error("Callback error:", error);
151
151
+
return c.redirect("/auth/login?error=callback_failed");
152
152
+
}
149
153
});
150
154
151
155
// Logout
152
156
authRoutes.get("/logout", async (c) => {
153
153
-
const did = getCookie(c, "session");
157
157
+
const did = getCookie(c, "session");
154
158
155
155
-
if (did) {
156
156
-
try {
157
157
-
// Delete the OAuth session from the database
158
158
-
await deleteSession(did);
159
159
-
} catch (error) {
160
160
-
console.error("Error deleting session:", error);
161
161
-
}
162
162
-
}
159
159
+
if (did) {
160
160
+
try {
161
161
+
// Delete the OAuth session from the database
162
162
+
await deleteSession(did);
163
163
+
} catch (error) {
164
164
+
console.error("Error deleting session:", error);
165
165
+
}
166
166
+
}
163
167
164
164
-
deleteCookie(c, "session", { path: "/" });
165
165
-
return c.redirect("/");
168
168
+
deleteCookie(c, "session", { path: "/" });
169
169
+
return c.redirect("/");
166
170
});
+376
-363
packages/web/src/routes/documents.ts
···
4
4
import { requireAuth, type Session } from "../lib/session";
5
5
import { csrfField } from "../lib/csrf";
6
6
import { isValidTID } from "../lib/validation";
7
7
-
import { createMarkdownContent, getDocumentContentText } from "../lib/content-types";
7
7
+
import {
8
8
+
createMarkdownContent,
9
9
+
getDocumentContentText,
10
10
+
} from "../lib/content-types";
8
11
import { marked } from "marked";
9
12
10
13
export const documentRoutes = new Hono();
···
14
17
15
18
// List all documents
16
19
documentRoutes.get("/", async (c) => {
17
17
-
let session: Session;
18
18
-
try {
19
19
-
session = requireAuth(c);
20
20
-
} catch {
21
21
-
return c.redirect("/auth/login");
22
22
-
}
20
20
+
let session: Session;
21
21
+
try {
22
22
+
session = requireAuth(c);
23
23
+
} catch {
24
24
+
return c.redirect("/auth/login");
25
25
+
}
23
26
24
24
-
const filter = c.req.query("filter") || "all";
27
27
+
const filter = c.req.query("filter") || "all";
25
28
26
26
-
try {
27
27
-
const response = await session.agent!.com.atproto.repo.listRecords({
28
28
-
repo: session.did!,
29
29
-
collection: DOCUMENT_COLLECTION,
30
30
-
limit: 100,
31
31
-
});
29
29
+
try {
30
30
+
const response = await session.agent!.com.atproto.repo.listRecords({
31
31
+
repo: session.did!,
32
32
+
collection: DOCUMENT_COLLECTION,
33
33
+
limit: 100,
34
34
+
});
32
35
33
33
-
let documents = response.data.records;
36
36
+
let documents = response.data.records;
34
37
35
35
-
// Filter by draft/published status
36
36
-
if (filter === "drafts") {
37
37
-
documents = documents.filter((doc: any) => {
38
38
-
const tags = doc.value.tags || [];
39
39
-
return tags.includes("draft");
40
40
-
});
41
41
-
} else if (filter === "published") {
42
42
-
documents = documents.filter((doc: any) => {
43
43
-
const tags = doc.value.tags || [];
44
44
-
return !tags.includes("draft");
45
45
-
});
46
46
-
}
38
38
+
// Filter by draft/published status
39
39
+
if (filter === "drafts") {
40
40
+
documents = documents.filter((doc: any) => {
41
41
+
const tags = doc.value.tags || [];
42
42
+
return tags.includes("draft");
43
43
+
});
44
44
+
} else if (filter === "published") {
45
45
+
documents = documents.filter((doc: any) => {
46
46
+
const tags = doc.value.tags || [];
47
47
+
return !tags.includes("draft");
48
48
+
});
49
49
+
}
47
50
48
48
-
// Sort by publishedAt or updatedAt
49
49
-
documents.sort((a: any, b: any) => {
50
50
-
const dateA = new Date(
51
51
-
a.value.updatedAt || a.value.publishedAt,
52
52
-
).getTime();
53
53
-
const dateB = new Date(
54
54
-
b.value.updatedAt || b.value.publishedAt,
55
55
-
).getTime();
56
56
-
return dateB - dateA;
57
57
-
});
51
51
+
// Sort by publishedAt or updatedAt
52
52
+
documents.sort((a: any, b: any) => {
53
53
+
const dateA = new Date(
54
54
+
a.value.updatedAt || a.value.publishedAt,
55
55
+
).getTime();
56
56
+
const dateB = new Date(
57
57
+
b.value.updatedAt || b.value.publishedAt,
58
58
+
).getTime();
59
59
+
return dateB - dateA;
60
60
+
});
58
61
59
59
-
const content = html`
62
62
+
const content = html`
60
63
<div class="documents">
61
64
<div class="documents-header">
62
65
<h1>Documents</h1>
···
81
84
>
82
85
</div>
83
86
84
84
-
${documents.length === 0
85
85
-
? html`
87
87
+
${
88
88
+
documents.length === 0
89
89
+
? html`
86
90
<p class="empty">
87
91
No documents yet.
88
92
<a href="/documents/new">Create your first document</a>.
89
93
</p>
90
94
`
91
91
-
: html`
95
95
+
: html`
92
96
<ul class="document-list">
93
97
${documents.map((doc: any) => {
94
94
-
const rkey = doc.uri.split("/").pop();
95
95
-
const value = doc.value;
96
96
-
const isDraft = (value.tags || []).includes("draft");
97
97
-
const date = value.publishedAt
98
98
-
? new Date(value.publishedAt).toLocaleDateString()
99
99
-
: "";
98
98
+
const rkey = doc.uri.split("/").pop();
99
99
+
const value = doc.value;
100
100
+
const isDraft = (value.tags || []).includes("draft");
101
101
+
const date = value.publishedAt
102
102
+
? new Date(value.publishedAt).toLocaleDateString()
103
103
+
: "";
100
104
101
101
-
return html`
105
105
+
return html`
102
106
<li
103
107
class="document-item ${isDraft ? "draft" : "published"}"
104
108
>
105
109
<a href="/documents/${rkey}">
106
110
<span class="title">${value.title}</span>
107
111
<span class="meta">
108
108
-
${isDraft
109
109
-
? html`<span class="badge badge-draft">Draft</span>`
110
110
-
: ""}
112
112
+
${
113
113
+
isDraft
114
114
+
? html`<span class="badge badge-draft">Draft</span>`
115
115
+
: ""
116
116
+
}
111
117
<span class="date">${date}</span>
112
118
</span>
113
119
</a>
114
120
</li>
115
121
`;
116
116
-
})}
122
122
+
})}
117
123
</ul>
118
118
-
`}
124
124
+
`
125
125
+
}
119
126
</div>
120
127
`;
121
128
122
122
-
return c.html(layout(content, { title: "Documents - std.pub", session }));
123
123
-
} catch (error) {
124
124
-
console.error("Error fetching documents:", error);
125
125
-
const content = html`<p class="error">
129
129
+
return c.html(layout(content, { title: "Documents - std.pub", session }));
130
130
+
} catch (error) {
131
131
+
console.error("Error fetching documents:", error);
132
132
+
const content = html`<p class="error">
126
133
Error loading documents. Please try again.
127
134
</p>`;
128
128
-
return c.html(layout(content, { title: "Documents - std.pub", session }));
129
129
-
}
135
135
+
return c.html(layout(content, { title: "Documents - std.pub", session }));
136
136
+
}
130
137
});
131
138
132
139
// New document form
133
140
documentRoutes.get("/new", async (c) => {
134
134
-
let session: Session;
135
135
-
try {
136
136
-
session = requireAuth(c);
137
137
-
} catch {
138
138
-
return c.redirect("/auth/login");
139
139
-
}
141
141
+
let session: Session;
142
142
+
try {
143
143
+
session = requireAuth(c);
144
144
+
} catch {
145
145
+
return c.redirect("/auth/login");
146
146
+
}
140
147
141
141
-
// Get publication to use as site reference
142
142
-
let publicationUri = "";
143
143
-
try {
144
144
-
const response = await session.agent!.com.atproto.repo.listRecords({
145
145
-
repo: session.did!,
146
146
-
collection: PUBLICATION_COLLECTION,
147
147
-
limit: 1,
148
148
-
});
149
149
-
if (response.data.records[0]) {
150
150
-
publicationUri = response.data.records[0].uri;
151
151
-
}
152
152
-
} catch (e) {
153
153
-
// No publication yet, will need URL
154
154
-
}
148
148
+
// Get publication to use as site reference
149
149
+
let publicationUri = "";
150
150
+
try {
151
151
+
const response = await session.agent!.com.atproto.repo.listRecords({
152
152
+
repo: session.did!,
153
153
+
collection: PUBLICATION_COLLECTION,
154
154
+
limit: 1,
155
155
+
});
156
156
+
if (response.data.records[0]) {
157
157
+
publicationUri = response.data.records[0].uri;
158
158
+
}
159
159
+
} catch (e) {
160
160
+
// No publication yet, will need URL
161
161
+
}
155
162
156
156
-
const csrfToken = c.get("csrfToken") as string;
163
163
+
const csrfToken = c.get("csrfToken") as string;
157
164
158
158
-
const content = html`
165
165
+
const content = html`
159
166
<div class="form-page">
160
167
<h1>New Document</h1>
161
168
···
242
249
</script>
243
250
`;
244
251
245
245
-
return c.html(layout(content, { title: "New Document - std.pub", session }));
252
252
+
return c.html(layout(content, { title: "New Document - std.pub", session }));
246
253
});
247
254
248
255
// Handle document creation
249
256
documentRoutes.post("/new", async (c) => {
250
250
-
let session: Session;
251
251
-
try {
252
252
-
session = requireAuth(c);
253
253
-
} catch {
254
254
-
return c.redirect("/auth/login");
255
255
-
}
257
257
+
let session: Session;
258
258
+
try {
259
259
+
session = requireAuth(c);
260
260
+
} catch {
261
261
+
return c.redirect("/auth/login");
262
262
+
}
256
263
257
257
-
const body = await c.req.parseBody();
258
258
-
const title = body.title as string;
259
259
-
const path = (body.path as string) || undefined;
260
260
-
const description = (body.description as string) || undefined;
261
261
-
const content = (body.content as string) || undefined;
262
262
-
const tagsStr = (body.tags as string) || "";
263
263
-
const action = body.action as string;
264
264
-
const publicationUri = body.publicationUri as string;
264
264
+
const body = await c.req.parseBody();
265
265
+
const title = body.title as string;
266
266
+
const path = (body.path as string) || undefined;
267
267
+
const description = (body.description as string) || undefined;
268
268
+
const content = (body.content as string) || undefined;
269
269
+
const tagsStr = (body.tags as string) || "";
270
270
+
const action = body.action as string;
271
271
+
const publicationUri = body.publicationUri as string;
265
272
266
266
-
// Parse tags
267
267
-
let tags = tagsStr
268
268
-
.split(",")
269
269
-
.map((t) => t.trim())
270
270
-
.filter((t) => t);
273
273
+
// Parse tags
274
274
+
let tags = tagsStr
275
275
+
.split(",")
276
276
+
.map((t) => t.trim())
277
277
+
.filter((t) => t);
271
278
272
272
-
// If publishing, remove draft tag
273
273
-
if (action === "publish") {
274
274
-
tags = tags.filter((t) => t !== "draft");
275
275
-
} else if (!tags.includes("draft")) {
276
276
-
tags.push("draft");
277
277
-
}
279
279
+
// If publishing, remove draft tag
280
280
+
if (action === "publish") {
281
281
+
tags = tags.filter((t) => t !== "draft");
282
282
+
} else if (!tags.includes("draft")) {
283
283
+
tags.push("draft");
284
284
+
}
278
285
279
279
-
const now = new Date().toISOString();
286
286
+
const now = new Date().toISOString();
280
287
281
281
-
try {
282
282
-
const rkey = generateTID();
288
288
+
try {
289
289
+
const rkey = generateTID();
283
290
284
284
-
// Determine site reference
285
285
-
let site = publicationUri;
286
286
-
if (!site) {
287
287
-
// Fall back to a URL if no publication
288
288
-
site = `https://${session.handle}.bsky.social`;
289
289
-
}
291
291
+
// Determine site reference
292
292
+
let site = publicationUri;
293
293
+
if (!site) {
294
294
+
// Fall back to a URL if no publication
295
295
+
site = `https://${session.handle}.bsky.social`;
296
296
+
}
290
297
291
291
-
const record: Record<string, any> = {
292
292
-
$type: DOCUMENT_COLLECTION,
293
293
-
title,
294
294
-
site,
295
295
-
publishedAt: action === "publish" ? now : now,
296
296
-
updatedAt: now,
297
297
-
};
298
298
+
const record: Record<string, any> = {
299
299
+
$type: DOCUMENT_COLLECTION,
300
300
+
title,
301
301
+
site,
302
302
+
publishedAt: action === "publish" ? now : now,
303
303
+
updatedAt: now,
304
304
+
};
298
305
299
299
-
if (path) record.path = path.startsWith("/") ? path : `/${path}`;
300
300
-
if (description) record.description = description;
301
301
-
if (content) {
302
302
-
record.content = createMarkdownContent(content);
303
303
-
record.textContent = content;
304
304
-
}
305
305
-
if (tags.length > 0) record.tags = tags;
306
306
+
if (path) record.path = path.startsWith("/") ? path : `/${path}`;
307
307
+
if (description) record.description = description;
308
308
+
if (content) {
309
309
+
record.content = createMarkdownContent(content);
310
310
+
record.textContent = content;
311
311
+
}
312
312
+
if (tags.length > 0) record.tags = tags;
306
313
307
307
-
await session.agent!.com.atproto.repo.createRecord({
308
308
-
repo: session.did!,
309
309
-
collection: DOCUMENT_COLLECTION,
310
310
-
rkey,
311
311
-
record,
312
312
-
});
314
314
+
await session.agent!.com.atproto.repo.createRecord({
315
315
+
repo: session.did!,
316
316
+
collection: DOCUMENT_COLLECTION,
317
317
+
rkey,
318
318
+
record,
319
319
+
});
313
320
314
314
-
return c.redirect(`/documents/${rkey}`);
315
315
-
} catch (error) {
316
316
-
console.error("Error creating document:", error);
317
317
-
return c.redirect("/documents/new?error=create_failed");
318
318
-
}
321
321
+
return c.redirect(`/documents/${rkey}`);
322
322
+
} catch (error) {
323
323
+
console.error("Error creating document:", error);
324
324
+
return c.redirect("/documents/new?error=create_failed");
325
325
+
}
319
326
});
320
327
321
328
// View single document
322
329
documentRoutes.get("/:rkey", async (c) => {
323
323
-
let session: Session;
324
324
-
try {
325
325
-
session = requireAuth(c);
326
326
-
} catch {
327
327
-
return c.redirect("/auth/login");
328
328
-
}
330
330
+
let session: Session;
331
331
+
try {
332
332
+
session = requireAuth(c);
333
333
+
} catch {
334
334
+
return c.redirect("/auth/login");
335
335
+
}
329
336
330
330
-
const rkey = c.req.param("rkey");
337
337
+
const rkey = c.req.param("rkey");
331
338
332
332
-
// Validate rkey format
333
333
-
if (!isValidTID(rkey)) {
334
334
-
return c.redirect("/documents");
335
335
-
}
339
339
+
// Validate rkey format
340
340
+
if (!isValidTID(rkey)) {
341
341
+
return c.redirect("/documents");
342
342
+
}
336
343
337
337
-
try {
338
338
-
const response = await session.agent!.com.atproto.repo.getRecord({
339
339
-
repo: session.did!,
340
340
-
collection: DOCUMENT_COLLECTION,
341
341
-
rkey,
342
342
-
});
344
344
+
try {
345
345
+
const response = await session.agent!.com.atproto.repo.getRecord({
346
346
+
repo: session.did!,
347
347
+
collection: DOCUMENT_COLLECTION,
348
348
+
rkey,
349
349
+
});
343
350
344
344
-
const doc = response.data.value as any;
345
345
-
const isDraft = (doc.tags || []).includes("draft");
346
346
-
const csrfToken = c.get("csrfToken") as string;
351
351
+
const doc = response.data.value as any;
352
352
+
const isDraft = (doc.tags || []).includes("draft");
353
353
+
const csrfToken = c.get("csrfToken") as string;
347
354
348
348
-
const content = html`
355
355
+
const content = html`
349
356
<div class="document-view">
350
357
<div class="document-header">
351
358
<h1>${doc.title}</h1>
352
359
<div class="document-meta">
353
353
-
${isDraft
354
354
-
? html`<span class="badge badge-draft">Draft</span>`
355
355
-
: html`<span class="badge badge-published">Published</span>`}
356
356
-
${doc.publishedAt
357
357
-
? html`<span class="date"
360
360
+
${
361
361
+
isDraft
362
362
+
? html`<span class="badge badge-draft">Draft</span>`
363
363
+
: html`<span class="badge badge-published">Published</span>`
364
364
+
}
365
365
+
${
366
366
+
doc.publishedAt
367
367
+
? html`<span class="date"
358
368
>Published:
359
369
${new Date(doc.publishedAt).toLocaleDateString()}</span
360
370
>`
361
361
-
: ""}
371
371
+
: ""
372
372
+
}
362
373
${doc.path ? html`<span class="path">Path: ${doc.path}</span>` : ""}
363
374
</div>
364
375
</div>
365
376
366
366
-
${doc.description
367
367
-
? html`<p class="description">${doc.description}</p>`
368
368
-
: ""}
377
377
+
${
378
378
+
doc.description
379
379
+
? html`<p class="description">${doc.description}</p>`
380
380
+
: ""
381
381
+
}
369
382
370
383
<div class="document-content">
371
371
-
${
372
372
-
(() => {
373
373
-
const text = getDocumentContentText(doc);
374
374
-
if (!text) return html`<p class="empty">(No content)</p>`;
375
375
-
const htmlContent = marked.parse(text) as string;
376
376
-
return html`<div class="markdown-body">${raw(htmlContent)}</div>`;
377
377
-
})()
378
378
-
}
384
384
+
${(() => {
385
385
+
const text = getDocumentContentText(doc);
386
386
+
if (!text) return html`<p class="empty">(No content)</p>`;
387
387
+
const htmlContent = marked.parse(text) as string;
388
388
+
return html`<div class="markdown-body">${raw(htmlContent)}</div>`;
389
389
+
})()}
379
390
</div>
380
391
381
392
<div class="actions">
382
393
<a href="/documents/${rkey}/edit" class="btn btn-primary">Edit</a>
383
383
-
${isDraft
384
384
-
? html`
394
394
+
${
395
395
+
isDraft
396
396
+
? html`
385
397
<form
386
398
action="/documents/${rkey}/publish"
387
399
method="POST"
···
391
403
<button type="submit" class="btn btn-success">Publish</button>
392
404
</form>
393
405
`
394
394
-
: html`
406
406
+
: html`
395
407
<form
396
408
action="/documents/${rkey}/unpublish"
397
409
method="POST"
···
402
414
Unpublish
403
415
</button>
404
416
</form>
405
405
-
`}
417
417
+
`
418
418
+
}
406
419
<form
407
420
action="/documents/${rkey}/delete"
408
421
method="POST"
···
417
430
</div>
418
431
`;
419
432
420
420
-
return c.html(
421
421
-
layout(content, { title: `${doc.title} - std.pub`, session }),
422
422
-
);
423
423
-
} catch (error) {
424
424
-
console.error("Error fetching document:", error);
425
425
-
return c.redirect("/documents");
426
426
-
}
433
433
+
return c.html(
434
434
+
layout(content, { title: `${doc.title} - std.pub`, session }),
435
435
+
);
436
436
+
} catch (error) {
437
437
+
console.error("Error fetching document:", error);
438
438
+
return c.redirect("/documents");
439
439
+
}
427
440
});
428
441
429
442
// Edit document form
430
443
documentRoutes.get("/:rkey/edit", async (c) => {
431
431
-
let session: Session;
432
432
-
try {
433
433
-
session = requireAuth(c);
434
434
-
} catch {
435
435
-
return c.redirect("/auth/login");
436
436
-
}
444
444
+
let session: Session;
445
445
+
try {
446
446
+
session = requireAuth(c);
447
447
+
} catch {
448
448
+
return c.redirect("/auth/login");
449
449
+
}
437
450
438
438
-
const rkey = c.req.param("rkey");
451
451
+
const rkey = c.req.param("rkey");
439
452
440
440
-
if (!isValidTID(rkey)) {
441
441
-
return c.redirect("/documents");
442
442
-
}
453
453
+
if (!isValidTID(rkey)) {
454
454
+
return c.redirect("/documents");
455
455
+
}
443
456
444
444
-
try {
445
445
-
const response = await session.agent!.com.atproto.repo.getRecord({
446
446
-
repo: session.did!,
447
447
-
collection: DOCUMENT_COLLECTION,
448
448
-
rkey,
449
449
-
});
457
457
+
try {
458
458
+
const response = await session.agent!.com.atproto.repo.getRecord({
459
459
+
repo: session.did!,
460
460
+
collection: DOCUMENT_COLLECTION,
461
461
+
rkey,
462
462
+
});
450
463
451
451
-
const doc = response.data.value as any;
452
452
-
const csrfToken = c.get("csrfToken") as string;
464
464
+
const doc = response.data.value as any;
465
465
+
const csrfToken = c.get("csrfToken") as string;
453
466
454
454
-
const content = html`
467
467
+
const content = html`
455
468
<div class="form-page">
456
469
<h1>Edit Document</h1>
457
470
···
525
538
</div>
526
539
`;
527
540
528
528
-
return c.html(
529
529
-
layout(content, { title: `Edit: ${doc.title} - std.pub`, session }),
530
530
-
);
531
531
-
} catch (error) {
532
532
-
console.error("Error fetching document:", error);
533
533
-
return c.redirect("/documents");
534
534
-
}
541
541
+
return c.html(
542
542
+
layout(content, { title: `Edit: ${doc.title} - std.pub`, session }),
543
543
+
);
544
544
+
} catch (error) {
545
545
+
console.error("Error fetching document:", error);
546
546
+
return c.redirect("/documents");
547
547
+
}
535
548
});
536
549
537
550
// Handle document update
538
551
documentRoutes.post("/:rkey/edit", async (c) => {
539
539
-
let session: Session;
540
540
-
try {
541
541
-
session = requireAuth(c);
542
542
-
} catch {
543
543
-
return c.redirect("/auth/login");
544
544
-
}
552
552
+
let session: Session;
553
553
+
try {
554
554
+
session = requireAuth(c);
555
555
+
} catch {
556
556
+
return c.redirect("/auth/login");
557
557
+
}
545
558
546
546
-
const rkey = c.req.param("rkey");
559
559
+
const rkey = c.req.param("rkey");
547
560
548
548
-
if (!isValidTID(rkey)) {
549
549
-
return c.redirect("/documents");
550
550
-
}
561
561
+
if (!isValidTID(rkey)) {
562
562
+
return c.redirect("/documents");
563
563
+
}
551
564
552
552
-
const body = await c.req.parseBody();
565
565
+
const body = await c.req.parseBody();
553
566
554
554
-
try {
555
555
-
// Get existing record
556
556
-
const existing = await session.agent!.com.atproto.repo.getRecord({
557
557
-
repo: session.did!,
558
558
-
collection: DOCUMENT_COLLECTION,
559
559
-
rkey,
560
560
-
});
567
567
+
try {
568
568
+
// Get existing record
569
569
+
const existing = await session.agent!.com.atproto.repo.getRecord({
570
570
+
repo: session.did!,
571
571
+
collection: DOCUMENT_COLLECTION,
572
572
+
rkey,
573
573
+
});
561
574
562
562
-
const oldDoc = existing.data.value as any;
575
575
+
const oldDoc = existing.data.value as any;
563
576
564
564
-
const title = body.title as string;
565
565
-
const path = (body.path as string) || undefined;
566
566
-
const description = (body.description as string) || undefined;
567
567
-
const content = (body.content as string) || undefined;
568
568
-
const tagsStr = (body.tags as string) || "";
569
569
-
const tags = tagsStr
570
570
-
.split(",")
571
571
-
.map((t) => t.trim())
572
572
-
.filter((t) => t);
577
577
+
const title = body.title as string;
578
578
+
const path = (body.path as string) || undefined;
579
579
+
const description = (body.description as string) || undefined;
580
580
+
const content = (body.content as string) || undefined;
581
581
+
const tagsStr = (body.tags as string) || "";
582
582
+
const tags = tagsStr
583
583
+
.split(",")
584
584
+
.map((t) => t.trim())
585
585
+
.filter((t) => t);
573
586
574
574
-
const record: Record<string, any> = {
575
575
-
$type: DOCUMENT_COLLECTION,
576
576
-
title,
577
577
-
site: oldDoc.site,
578
578
-
publishedAt: oldDoc.publishedAt,
579
579
-
updatedAt: new Date().toISOString(),
580
580
-
};
587
587
+
const record: Record<string, any> = {
588
588
+
$type: DOCUMENT_COLLECTION,
589
589
+
title,
590
590
+
site: oldDoc.site,
591
591
+
publishedAt: oldDoc.publishedAt,
592
592
+
updatedAt: new Date().toISOString(),
593
593
+
};
581
594
582
582
-
if (path) record.path = path.startsWith("/") ? path : `/${path}`;
583
583
-
if (description) record.description = description;
584
584
-
if (content) {
585
585
-
record.content = createMarkdownContent(content);
586
586
-
record.textContent = content;
587
587
-
}
588
588
-
if (tags.length > 0) record.tags = tags;
595
595
+
if (path) record.path = path.startsWith("/") ? path : `/${path}`;
596
596
+
if (description) record.description = description;
597
597
+
if (content) {
598
598
+
record.content = createMarkdownContent(content);
599
599
+
record.textContent = content;
600
600
+
}
601
601
+
if (tags.length > 0) record.tags = tags;
589
602
590
590
-
await session.agent!.com.atproto.repo.putRecord({
591
591
-
repo: session.did!,
592
592
-
collection: DOCUMENT_COLLECTION,
593
593
-
rkey,
594
594
-
record,
595
595
-
});
603
603
+
await session.agent!.com.atproto.repo.putRecord({
604
604
+
repo: session.did!,
605
605
+
collection: DOCUMENT_COLLECTION,
606
606
+
rkey,
607
607
+
record,
608
608
+
});
596
609
597
597
-
return c.redirect(`/documents/${rkey}`);
598
598
-
} catch (error) {
599
599
-
console.error("Error updating document:", error);
600
600
-
return c.redirect(`/documents/${rkey}/edit?error=update_failed`);
601
601
-
}
610
610
+
return c.redirect(`/documents/${rkey}`);
611
611
+
} catch (error) {
612
612
+
console.error("Error updating document:", error);
613
613
+
return c.redirect(`/documents/${rkey}/edit?error=update_failed`);
614
614
+
}
602
615
});
603
616
604
617
// Publish document
605
618
documentRoutes.post("/:rkey/publish", async (c) => {
606
606
-
let session: Session;
607
607
-
try {
608
608
-
session = requireAuth(c);
609
609
-
} catch {
610
610
-
return c.redirect("/auth/login");
611
611
-
}
619
619
+
let session: Session;
620
620
+
try {
621
621
+
session = requireAuth(c);
622
622
+
} catch {
623
623
+
return c.redirect("/auth/login");
624
624
+
}
612
625
613
613
-
const rkey = c.req.param("rkey");
626
626
+
const rkey = c.req.param("rkey");
614
627
615
615
-
if (!isValidTID(rkey)) {
616
616
-
return c.redirect("/documents");
617
617
-
}
628
628
+
if (!isValidTID(rkey)) {
629
629
+
return c.redirect("/documents");
630
630
+
}
618
631
619
619
-
try {
620
620
-
const existing = await session.agent!.com.atproto.repo.getRecord({
621
621
-
repo: session.did!,
622
622
-
collection: DOCUMENT_COLLECTION,
623
623
-
rkey,
624
624
-
});
632
632
+
try {
633
633
+
const existing = await session.agent!.com.atproto.repo.getRecord({
634
634
+
repo: session.did!,
635
635
+
collection: DOCUMENT_COLLECTION,
636
636
+
rkey,
637
637
+
});
625
638
626
626
-
const doc = existing.data.value as any;
627
627
-
const tags = (doc.tags || []).filter((t: string) => t !== "draft");
639
639
+
const doc = existing.data.value as any;
640
640
+
const tags = (doc.tags || []).filter((t: string) => t !== "draft");
628
641
629
629
-
const record = {
630
630
-
...doc,
631
631
-
tags: tags.length > 0 ? tags : undefined,
632
632
-
publishedAt: doc.publishedAt || new Date().toISOString(),
633
633
-
updatedAt: new Date().toISOString(),
634
634
-
};
642
642
+
const record = {
643
643
+
...doc,
644
644
+
tags: tags.length > 0 ? tags : undefined,
645
645
+
publishedAt: doc.publishedAt || new Date().toISOString(),
646
646
+
updatedAt: new Date().toISOString(),
647
647
+
};
635
648
636
636
-
await session.agent!.com.atproto.repo.putRecord({
637
637
-
repo: session.did!,
638
638
-
collection: DOCUMENT_COLLECTION,
639
639
-
rkey,
640
640
-
record,
641
641
-
});
649
649
+
await session.agent!.com.atproto.repo.putRecord({
650
650
+
repo: session.did!,
651
651
+
collection: DOCUMENT_COLLECTION,
652
652
+
rkey,
653
653
+
record,
654
654
+
});
642
655
643
643
-
return c.redirect(`/documents/${rkey}`);
644
644
-
} catch (error) {
645
645
-
console.error("Error publishing document:", error);
646
646
-
return c.redirect(`/documents/${rkey}?error=publish_failed`);
647
647
-
}
656
656
+
return c.redirect(`/documents/${rkey}`);
657
657
+
} catch (error) {
658
658
+
console.error("Error publishing document:", error);
659
659
+
return c.redirect(`/documents/${rkey}?error=publish_failed`);
660
660
+
}
648
661
});
649
662
650
663
// Unpublish document (add draft tag)
651
664
documentRoutes.post("/:rkey/unpublish", async (c) => {
652
652
-
let session: Session;
653
653
-
try {
654
654
-
session = requireAuth(c);
655
655
-
} catch {
656
656
-
return c.redirect("/auth/login");
657
657
-
}
665
665
+
let session: Session;
666
666
+
try {
667
667
+
session = requireAuth(c);
668
668
+
} catch {
669
669
+
return c.redirect("/auth/login");
670
670
+
}
658
671
659
659
-
const rkey = c.req.param("rkey");
672
672
+
const rkey = c.req.param("rkey");
660
673
661
661
-
if (!isValidTID(rkey)) {
662
662
-
return c.redirect("/documents");
663
663
-
}
674
674
+
if (!isValidTID(rkey)) {
675
675
+
return c.redirect("/documents");
676
676
+
}
664
677
665
665
-
try {
666
666
-
const existing = await session.agent!.com.atproto.repo.getRecord({
667
667
-
repo: session.did!,
668
668
-
collection: DOCUMENT_COLLECTION,
669
669
-
rkey,
670
670
-
});
678
678
+
try {
679
679
+
const existing = await session.agent!.com.atproto.repo.getRecord({
680
680
+
repo: session.did!,
681
681
+
collection: DOCUMENT_COLLECTION,
682
682
+
rkey,
683
683
+
});
671
684
672
672
-
const doc = existing.data.value as any;
673
673
-
const tags = [...(doc.tags || []), "draft"];
685
685
+
const doc = existing.data.value as any;
686
686
+
const tags = [...(doc.tags || []), "draft"];
674
687
675
675
-
const record = {
676
676
-
...doc,
677
677
-
tags,
678
678
-
updatedAt: new Date().toISOString(),
679
679
-
};
688
688
+
const record = {
689
689
+
...doc,
690
690
+
tags,
691
691
+
updatedAt: new Date().toISOString(),
692
692
+
};
680
693
681
681
-
await session.agent!.com.atproto.repo.putRecord({
682
682
-
repo: session.did!,
683
683
-
collection: DOCUMENT_COLLECTION,
684
684
-
rkey,
685
685
-
record,
686
686
-
});
694
694
+
await session.agent!.com.atproto.repo.putRecord({
695
695
+
repo: session.did!,
696
696
+
collection: DOCUMENT_COLLECTION,
697
697
+
rkey,
698
698
+
record,
699
699
+
});
687
700
688
688
-
return c.redirect(`/documents/${rkey}`);
689
689
-
} catch (error) {
690
690
-
console.error("Error unpublishing document:", error);
691
691
-
return c.redirect(`/documents/${rkey}?error=unpublish_failed`);
692
692
-
}
701
701
+
return c.redirect(`/documents/${rkey}`);
702
702
+
} catch (error) {
703
703
+
console.error("Error unpublishing document:", error);
704
704
+
return c.redirect(`/documents/${rkey}?error=unpublish_failed`);
705
705
+
}
693
706
});
694
707
695
708
// Delete document
696
709
documentRoutes.post("/:rkey/delete", async (c) => {
697
697
-
let session: Session;
698
698
-
try {
699
699
-
session = requireAuth(c);
700
700
-
} catch {
701
701
-
return c.redirect("/auth/login");
702
702
-
}
710
710
+
let session: Session;
711
711
+
try {
712
712
+
session = requireAuth(c);
713
713
+
} catch {
714
714
+
return c.redirect("/auth/login");
715
715
+
}
703
716
704
704
-
const rkey = c.req.param("rkey");
717
717
+
const rkey = c.req.param("rkey");
705
718
706
706
-
if (!isValidTID(rkey)) {
707
707
-
return c.redirect("/documents");
708
708
-
}
719
719
+
if (!isValidTID(rkey)) {
720
720
+
return c.redirect("/documents");
721
721
+
}
709
722
710
710
-
try {
711
711
-
await session.agent!.com.atproto.repo.deleteRecord({
712
712
-
repo: session.did!,
713
713
-
collection: DOCUMENT_COLLECTION,
714
714
-
rkey,
715
715
-
});
723
723
+
try {
724
724
+
await session.agent!.com.atproto.repo.deleteRecord({
725
725
+
repo: session.did!,
726
726
+
collection: DOCUMENT_COLLECTION,
727
727
+
rkey,
728
728
+
});
716
729
717
717
-
return c.redirect("/documents");
718
718
-
} catch (error) {
719
719
-
console.error("Error deleting document:", error);
720
720
-
return c.redirect(`/documents/${rkey}?error=delete_failed`);
721
721
-
}
730
730
+
return c.redirect("/documents");
731
731
+
} catch (error) {
732
732
+
console.error("Error deleting document:", error);
733
733
+
return c.redirect(`/documents/${rkey}?error=delete_failed`);
734
734
+
}
722
735
});
723
736
724
737
// Generate a TID (timestamp-based ID)
725
738
function generateTID(): string {
726
726
-
const now = Date.now() * 1000;
727
727
-
const clockId = Math.floor(Math.random() * 1024);
728
728
-
const tid = (BigInt(now) << 10n) | BigInt(clockId);
729
729
-
return tid.toString(36).padStart(13, "0");
739
739
+
const now = Date.now() * 1000;
740
740
+
const clockId = Math.floor(Math.random() * 1024);
741
741
+
const tid = (BigInt(now) << 10n) | BigInt(clockId);
742
742
+
return tid.toString(36).padStart(13, "0");
730
743
}
+131
-129
packages/web/src/routes/publication.ts
···
10
10
11
11
// View/manage publication
12
12
publicationRoutes.get("/", async (c) => {
13
13
-
let session: Session;
14
14
-
try {
15
15
-
session = requireAuth(c);
16
16
-
} catch {
17
17
-
return c.redirect("/auth/login");
18
18
-
}
13
13
+
let session: Session;
14
14
+
try {
15
15
+
session = requireAuth(c);
16
16
+
} catch {
17
17
+
return c.redirect("/auth/login");
18
18
+
}
19
19
20
20
-
try {
21
21
-
// Fetch existing publication
22
22
-
const response = await session.agent!.com.atproto.repo.listRecords({
23
23
-
repo: session.did!,
24
24
-
collection: PUBLICATION_COLLECTION,
25
25
-
limit: 1,
26
26
-
});
20
20
+
try {
21
21
+
// Fetch existing publication
22
22
+
const response = await session.agent!.com.atproto.repo.listRecords({
23
23
+
repo: session.did!,
24
24
+
collection: PUBLICATION_COLLECTION,
25
25
+
limit: 1,
26
26
+
});
27
27
28
28
-
const publication = response.data.records[0];
28
28
+
const publication = response.data.records[0];
29
29
30
30
-
if (publication) {
31
31
-
const pub = publication.value as any;
32
32
-
const content = html`
30
30
+
if (publication) {
31
31
+
const pub = publication.value as any;
32
32
+
const content = html`
33
33
<div class="publication">
34
34
<h1>Your Publication</h1>
35
35
···
38
38
<p class="url">
39
39
<a href="${pub.url}" target="_blank">${pub.url}</a>
40
40
</p>
41
41
-
${pub.description
42
42
-
? html`<p class="description">${pub.description}</p>`
43
43
-
: ""}
41
41
+
${
42
42
+
pub.description
43
43
+
? html`<p class="description">${pub.description}</p>`
44
44
+
: ""
45
45
+
}
44
46
</div>
45
47
46
48
<div class="actions">
···
50
52
</div>
51
53
</div>
52
54
`;
53
53
-
return c.html(
54
54
-
layout(content, { title: "Publication - std.pub", session }),
55
55
-
);
56
56
-
}
55
55
+
return c.html(
56
56
+
layout(content, { title: "Publication - std.pub", session }),
57
57
+
);
58
58
+
}
57
59
58
58
-
// No publication exists, show create form
59
59
-
return c.redirect("/publication/new");
60
60
-
} catch (error) {
61
61
-
console.error("Error fetching publication:", error);
62
62
-
return c.redirect("/publication/new");
63
63
-
}
60
60
+
// No publication exists, show create form
61
61
+
return c.redirect("/publication/new");
62
62
+
} catch (error) {
63
63
+
console.error("Error fetching publication:", error);
64
64
+
return c.redirect("/publication/new");
65
65
+
}
64
66
});
65
67
66
68
// New publication form
67
69
publicationRoutes.get("/new", async (c) => {
68
68
-
let session: Session;
69
69
-
try {
70
70
-
session = requireAuth(c);
71
71
-
} catch {
72
72
-
return c.redirect("/auth/login");
73
73
-
}
70
70
+
let session: Session;
71
71
+
try {
72
72
+
session = requireAuth(c);
73
73
+
} catch {
74
74
+
return c.redirect("/auth/login");
75
75
+
}
74
76
75
75
-
const csrfToken = c.get("csrfToken") as string;
77
77
+
const csrfToken = c.get("csrfToken") as string;
76
78
77
77
-
const content = html`
79
79
+
const content = html`
78
80
<div class="form-page">
79
81
<h1>Create Publication</h1>
80
82
···
116
118
</div>
117
119
`;
118
120
119
119
-
return c.html(
120
120
-
layout(content, { title: "New Publication - std.pub", session }),
121
121
-
);
121
121
+
return c.html(
122
122
+
layout(content, { title: "New Publication - std.pub", session }),
123
123
+
);
122
124
});
123
125
124
126
// Handle publication creation
125
127
publicationRoutes.post("/new", async (c) => {
126
126
-
let session: Session;
127
127
-
try {
128
128
-
session = requireAuth(c);
129
129
-
} catch {
130
130
-
return c.redirect("/auth/login");
131
131
-
}
128
128
+
let session: Session;
129
129
+
try {
130
130
+
session = requireAuth(c);
131
131
+
} catch {
132
132
+
return c.redirect("/auth/login");
133
133
+
}
132
134
133
133
-
const body = await c.req.parseBody();
134
134
-
const name = body.name as string;
135
135
-
const url = (body.url as string).replace(/\/$/, ""); // Remove trailing slash
136
136
-
const description = (body.description as string) || undefined;
135
135
+
const body = await c.req.parseBody();
136
136
+
const name = body.name as string;
137
137
+
const url = (body.url as string).replace(/\/$/, ""); // Remove trailing slash
138
138
+
const description = (body.description as string) || undefined;
137
139
138
138
-
try {
139
139
-
// Generate a TID for the record key
140
140
-
const rkey = generateTID();
140
140
+
try {
141
141
+
// Generate a TID for the record key
142
142
+
const rkey = generateTID();
141
143
142
142
-
await session.agent!.com.atproto.repo.createRecord({
143
143
-
repo: session.did!,
144
144
-
collection: PUBLICATION_COLLECTION,
145
145
-
rkey,
146
146
-
record: {
147
147
-
$type: PUBLICATION_COLLECTION,
148
148
-
name,
149
149
-
url,
150
150
-
...(description && { description }),
151
151
-
},
152
152
-
});
144
144
+
await session.agent!.com.atproto.repo.createRecord({
145
145
+
repo: session.did!,
146
146
+
collection: PUBLICATION_COLLECTION,
147
147
+
rkey,
148
148
+
record: {
149
149
+
$type: PUBLICATION_COLLECTION,
150
150
+
name,
151
151
+
url,
152
152
+
...(description && { description }),
153
153
+
},
154
154
+
});
153
155
154
154
-
return c.redirect("/publication");
155
155
-
} catch (error) {
156
156
-
console.error("Error creating publication:", error);
157
157
-
return c.redirect("/publication/new?error=create_failed");
158
158
-
}
156
156
+
return c.redirect("/publication");
157
157
+
} catch (error) {
158
158
+
console.error("Error creating publication:", error);
159
159
+
return c.redirect("/publication/new?error=create_failed");
160
160
+
}
159
161
});
160
162
161
163
// Edit publication form
162
164
publicationRoutes.get("/edit", async (c) => {
163
163
-
let session: Session;
164
164
-
try {
165
165
-
session = requireAuth(c);
166
166
-
} catch {
167
167
-
return c.redirect("/auth/login");
168
168
-
}
165
165
+
let session: Session;
166
166
+
try {
167
167
+
session = requireAuth(c);
168
168
+
} catch {
169
169
+
return c.redirect("/auth/login");
170
170
+
}
169
171
170
170
-
try {
171
171
-
const response = await session.agent!.com.atproto.repo.listRecords({
172
172
-
repo: session.did!,
173
173
-
collection: PUBLICATION_COLLECTION,
174
174
-
limit: 1,
175
175
-
});
172
172
+
try {
173
173
+
const response = await session.agent!.com.atproto.repo.listRecords({
174
174
+
repo: session.did!,
175
175
+
collection: PUBLICATION_COLLECTION,
176
176
+
limit: 1,
177
177
+
});
176
178
177
177
-
const publication = response.data.records[0];
178
178
-
if (!publication) {
179
179
-
return c.redirect("/publication/new");
180
180
-
}
179
179
+
const publication = response.data.records[0];
180
180
+
if (!publication) {
181
181
+
return c.redirect("/publication/new");
182
182
+
}
181
183
182
182
-
const pub = publication.value as any;
183
183
-
const rkey = publication.uri.split("/").pop();
184
184
+
const pub = publication.value as any;
185
185
+
const rkey = publication.uri.split("/").pop();
184
186
185
185
-
const csrfToken = c.get("csrfToken") as string;
187
187
+
const csrfToken = c.get("csrfToken") as string;
186
188
187
187
-
const content = html`
189
189
+
const content = html`
188
190
<div class="form-page">
189
191
<h1>Edit Publication</h1>
190
192
···
227
229
</div>
228
230
`;
229
231
230
230
-
return c.html(
231
231
-
layout(content, { title: "Edit Publication - std.pub", session }),
232
232
-
);
233
233
-
} catch (error) {
234
234
-
console.error("Error fetching publication:", error);
235
235
-
return c.redirect("/publication");
236
236
-
}
232
232
+
return c.html(
233
233
+
layout(content, { title: "Edit Publication - std.pub", session }),
234
234
+
);
235
235
+
} catch (error) {
236
236
+
console.error("Error fetching publication:", error);
237
237
+
return c.redirect("/publication");
238
238
+
}
237
239
});
238
240
239
241
// Handle publication update
240
242
publicationRoutes.post("/edit", async (c) => {
241
241
-
let session: Session;
242
242
-
try {
243
243
-
session = requireAuth(c);
244
244
-
} catch {
245
245
-
return c.redirect("/auth/login");
246
246
-
}
243
243
+
let session: Session;
244
244
+
try {
245
245
+
session = requireAuth(c);
246
246
+
} catch {
247
247
+
return c.redirect("/auth/login");
248
248
+
}
247
249
248
248
-
const body = await c.req.parseBody();
249
249
-
const rkey = body.rkey as string;
250
250
-
const name = body.name as string;
251
251
-
const url = (body.url as string).replace(/\/$/, "");
252
252
-
const description = (body.description as string) || undefined;
250
250
+
const body = await c.req.parseBody();
251
251
+
const rkey = body.rkey as string;
252
252
+
const name = body.name as string;
253
253
+
const url = (body.url as string).replace(/\/$/, "");
254
254
+
const description = (body.description as string) || undefined;
253
255
254
254
-
try {
255
255
-
await session.agent!.com.atproto.repo.putRecord({
256
256
-
repo: session.did!,
257
257
-
collection: PUBLICATION_COLLECTION,
258
258
-
rkey,
259
259
-
record: {
260
260
-
$type: PUBLICATION_COLLECTION,
261
261
-
name,
262
262
-
url,
263
263
-
...(description && { description }),
264
264
-
},
265
265
-
});
256
256
+
try {
257
257
+
await session.agent!.com.atproto.repo.putRecord({
258
258
+
repo: session.did!,
259
259
+
collection: PUBLICATION_COLLECTION,
260
260
+
rkey,
261
261
+
record: {
262
262
+
$type: PUBLICATION_COLLECTION,
263
263
+
name,
264
264
+
url,
265
265
+
...(description && { description }),
266
266
+
},
267
267
+
});
266
268
267
267
-
return c.redirect("/publication");
268
268
-
} catch (error) {
269
269
-
console.error("Error updating publication:", error);
270
270
-
return c.redirect("/publication/edit?error=update_failed");
271
271
-
}
269
269
+
return c.redirect("/publication");
270
270
+
} catch (error) {
271
271
+
console.error("Error updating publication:", error);
272
272
+
return c.redirect("/publication/edit?error=update_failed");
273
273
+
}
272
274
});
273
275
274
276
// Generate a TID (timestamp-based ID)
275
277
function generateTID(): string {
276
276
-
const now = Date.now() * 1000; // microseconds
277
277
-
const clockId = Math.floor(Math.random() * 1024);
278
278
-
const tid = (BigInt(now) << 10n) | BigInt(clockId);
279
279
-
return tid.toString(36).padStart(13, "0");
278
278
+
const now = Date.now() * 1000; // microseconds
279
279
+
const clockId = Math.floor(Math.random() * 1024);
280
280
+
const tid = (BigInt(now) << 10n) | BigInt(clockId);
281
281
+
return tid.toString(36).padStart(13, "0");
280
282
}
+56
-54
packages/web/src/server.ts
···
1
1
-
import { Hono } from 'hono';
2
2
-
import { serveStatic } from 'hono/bun';
3
3
-
import { getCookie, setCookie, deleteCookie } from 'hono/cookie';
4
4
-
import { authRoutes } from './routes/auth';
5
5
-
import { publicationRoutes } from './routes/publication';
6
6
-
import { documentRoutes } from './routes/documents';
7
7
-
import { layout } from './views/layouts/main';
8
8
-
import { homePage } from './views/home';
9
9
-
import { getSession } from './lib/session';
10
10
-
import { getClientMetadata, getJwks } from './lib/oauth';
11
11
-
import { csrfProtection, getCSRFToken } from './lib/csrf';
1
1
+
import { Hono } from "hono";
2
2
+
import { serveStatic } from "hono/bun";
3
3
+
import { getCookie, setCookie, deleteCookie } from "hono/cookie";
4
4
+
import { authRoutes } from "./routes/auth";
5
5
+
import { publicationRoutes } from "./routes/publication";
6
6
+
import { documentRoutes } from "./routes/documents";
7
7
+
import { layout } from "./views/layouts/main";
8
8
+
import { homePage } from "./views/home";
9
9
+
import { getSession } from "./lib/session";
10
10
+
import { getClientMetadata, getJwks } from "./lib/oauth";
11
11
+
import { csrfProtection, getCSRFToken } from "./lib/csrf";
12
12
13
13
export const app = new Hono();
14
14
15
15
// Static files
16
16
-
app.use('/public/*', serveStatic({ root: './' }));
16
16
+
app.use("/public/*", serveStatic({ root: "./" }));
17
17
18
18
// OAuth metadata endpoints at root level
19
19
// These MUST be publicly accessible (no authentication)
20
20
-
app.get('/client-metadata.json', async (c) => {
21
21
-
try {
22
22
-
const metadata = await getClientMetadata();
23
23
-
// Set appropriate cache headers
24
24
-
c.header('Cache-Control', 'public, max-age=600'); // Cache for 10 minutes
25
25
-
c.header('Access-Control-Allow-Origin', '*');
26
26
-
return c.json(metadata);
27
27
-
} catch (error) {
28
28
-
console.error('Error getting client metadata:', error);
29
29
-
return c.json({ error: 'Failed to get client metadata' }, 500);
30
30
-
}
20
20
+
app.get("/client-metadata.json", async (c) => {
21
21
+
try {
22
22
+
const metadata = await getClientMetadata();
23
23
+
// Set appropriate cache headers
24
24
+
c.header("Cache-Control", "public, max-age=600"); // Cache for 10 minutes
25
25
+
c.header("Access-Control-Allow-Origin", "*");
26
26
+
return c.json(metadata);
27
27
+
} catch (error) {
28
28
+
console.error("Error getting client metadata:", error);
29
29
+
return c.json({ error: "Failed to get client metadata" }, 500);
30
30
+
}
31
31
});
32
32
33
33
-
app.get('/jwks.json', async (c) => {
34
34
-
try {
35
35
-
const jwks = await getJwks();
36
36
-
// Set appropriate cache headers
37
37
-
c.header('Cache-Control', 'public, max-age=600'); // Cache for 10 minutes
38
38
-
c.header('Access-Control-Allow-Origin', '*');
39
39
-
return c.json(jwks);
40
40
-
} catch (error) {
41
41
-
console.error('Error getting JWKS:', error);
42
42
-
return c.json({ error: 'Failed to get JWKS' }, 500);
43
43
-
}
33
33
+
app.get("/jwks.json", async (c) => {
34
34
+
try {
35
35
+
const jwks = await getJwks();
36
36
+
// Set appropriate cache headers
37
37
+
c.header("Cache-Control", "public, max-age=600"); // Cache for 10 minutes
38
38
+
c.header("Access-Control-Allow-Origin", "*");
39
39
+
return c.json(jwks);
40
40
+
} catch (error) {
41
41
+
console.error("Error getting JWKS:", error);
42
42
+
return c.json({ error: "Failed to get JWKS" }, 500);
43
43
+
}
44
44
});
45
45
46
46
// Session middleware - adds session and CSRF token to context
47
47
-
app.use('*', async (c, next) => {
48
48
-
const session = await getSession(c);
49
49
-
c.set('session', session);
50
50
-
// Generate CSRF token for all requests (sets cookie if not present)
51
51
-
const csrfToken = getCSRFToken(c);
52
52
-
c.set('csrfToken', csrfToken);
53
53
-
await next();
47
47
+
app.use("*", async (c, next) => {
48
48
+
const session = await getSession(c);
49
49
+
c.set("session", session);
50
50
+
// Generate CSRF token for all requests (sets cookie if not present)
51
51
+
const csrfToken = getCSRFToken(c);
52
52
+
c.set("csrfToken", csrfToken);
53
53
+
await next();
54
54
});
55
55
56
56
// CSRF protection for state-changing requests
57
57
// Applied after session middleware but before routes
58
58
-
app.use('/auth/*', csrfProtection);
59
59
-
app.use('/publication/*', csrfProtection);
60
60
-
app.use('/documents/*', csrfProtection);
58
58
+
app.use("/auth/*", csrfProtection);
59
59
+
app.use("/publication/*", csrfProtection);
60
60
+
app.use("/documents/*", csrfProtection);
61
61
62
62
// Home page
63
63
-
app.get('/', async (c) => {
64
64
-
const session = c.get('session');
65
65
-
return c.html(layout(homePage(session), { session }));
63
63
+
app.get("/", async (c) => {
64
64
+
const session = c.get("session");
65
65
+
return c.html(layout(homePage(session), { session }));
66
66
});
67
67
68
68
// Mount routes
69
69
-
app.route('/auth', authRoutes);
70
70
-
app.route('/publication', publicationRoutes);
71
71
-
app.route('/documents', documentRoutes);
69
69
+
app.route("/auth", authRoutes);
70
70
+
app.route("/publication", publicationRoutes);
71
71
+
app.route("/documents", documentRoutes);
72
72
73
73
-
const port = parseInt(process.env.PORT || '8000');
73
73
+
const port = parseInt(process.env.PORT || "8000");
74
74
console.log(`Starting server on http://localhost:${port}`);
75
75
-
console.log(`Public URL: ${process.env.PUBLIC_URL || 'http://localhost:' + port}`);
75
75
+
console.log(
76
76
+
`Public URL: ${process.env.PUBLIC_URL || "http://localhost:" + port}`,
77
77
+
);
76
78
77
79
export default {
78
78
-
port,
79
79
-
fetch: app.fetch,
80
80
+
port,
81
81
+
fetch: app.fetch,
80
82
};
+4
-4
packages/web/src/views/home.ts
···
2
2
import type { Session } from "../lib/session";
3
3
4
4
export function homePage(session: Session) {
5
5
-
if (session.did) {
6
6
-
return html`
5
5
+
if (session.did) {
6
6
+
return html`
7
7
<div class="dashboard">
8
8
<h1>Welcome, @${session.handle}</h1>
9
9
<p>Manage your standard.site publication and documents.</p>
···
15
15
</div>
16
16
</div>
17
17
`;
18
18
-
}
18
18
+
}
19
19
20
20
-
return html`
20
20
+
return html`
21
21
<div class="hero">
22
22
<h1>std.pub</h1>
23
23
<p>
+10
-8
packages/web/src/views/layouts/main.ts
···
2
2
import type { Session } from "../../lib/session";
3
3
4
4
interface LayoutOptions {
5
5
-
title?: string;
6
6
-
session?: Session;
7
7
-
csrfToken?: string;
5
5
+
title?: string;
6
6
+
session?: Session;
7
7
+
csrfToken?: string;
8
8
}
9
9
10
10
export function layout(content: string, options: LayoutOptions = {}) {
11
11
-
const { title = "std.pub", session } = options;
11
11
+
const { title = "std.pub", session } = options;
12
12
13
13
-
return html`
13
13
+
return html`
14
14
<!DOCTYPE html>
15
15
<html lang="en">
16
16
<head>
···
24
24
<nav class="nav">
25
25
<a href="/" class="logo">std.pub</a>
26
26
<div class="nav-links">
27
27
-
${session?.did
28
28
-
? html`
27
27
+
${
28
28
+
session?.did
29
29
+
? html`
29
30
<a href="/publication">Publication</a>
30
31
<a href="/documents">Documents</a>
31
32
<span class="handle">@${session.handle}</span>
32
33
<a href="/auth/logout">Logout</a>
33
34
`
34
34
-
: html` <a href="/auth/login">Login with Bluesky</a> `}
35
35
+
: html` <a href="/auth/login">Login with Bluesky</a> `
36
36
+
}
35
37
</div>
36
38
</nav>
37
39
</header>