this repo has no description
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(admin): admin dashboard + moderation (icons, reports, takedowns, featured)

Adds an in-app admin dashboard at /admin gated by ADMIN_DIDS allowlist
(non-admins get 404 to avoid leaking the section's existence).

* DB: new icon_status / icon_reviewed_* / icon_rejected_reason columns
on profile, new takedown_status / takedown_reason / takedown_by /
takedown_at columns + profile_takedown index, new report table.
upsertProfile preserves icon + takedown state across firehose updates.
* Public reads (search, featured, single-profile API, icon API,
/explore/[handle], picker) now filter taken-down profiles by default.
PUT /api/registry/profile refuses updates on taken-down accounts (403).
* Admin pages: /admin overview with counts, /admin/icons, /admin/reports,
/admin/featured, /admin/takedowns; matching islands for each row/editor.
* Admin APIs: icon approve/reject/preview, report resolve, featured
publish (replaces com.atmosphereaccount.registry.featured/self),
profile takedown/restore (takedown auto-resolves open reports).
* Public: report-profile button + modal on /explore/[handle], soft
per-IP rate limit + IP hashing via REPORT_IP_SECRET. Owner sees a
takedown banner on /explore/manage with the admin-supplied reason.
* lib/admin.ts (isAdmin / requireAdmin / requireAdminApi),
lib/reports.ts, env additions (ADMIN_DIDS, REPORT_IP_SECRET) +
.env.example documentation. i18n strings + CSS for all UI surfaces.

Made-with: Cursor

+3680 -22
+9
.env.example
··· 10 10 # Curator DID for the featured directory record (indexer + publish:featured) 11 11 # ATMOSPHERE_DID= 12 12 13 + # Comma-separated DIDs allowed to access /admin and /api/admin/* routes. 14 + # Leave unset on forks — /admin will then be effectively unreachable. 15 + # ADMIN_DIDS= 16 + 17 + # Secret used to hash reporter IPs (24h dedup + soft rate-limit only). 18 + # Falls back to SESSION_SECRET if unset; set a dedicated value in production 19 + # so rotating one secret doesn't invalidate the other. 20 + # REPORT_IP_SECRET= 21 + 13 22 # OAuth confidential client — run: deno task gen:oauth-key 14 23 # 15 24 # IMPORTANT: paste the values EXACTLY as printed by the script. The JWK
+579
assets/styles.css
··· 3991 3991 .dark-phase .api-endpoint-param-name { 3992 3992 color: #cfdfff; 3993 3993 } 3994 + 3995 + /* ================================================================== * 3996 + * Admin dashboard + moderation UI 3997 + * ================================================================== */ 3998 + 3999 + .admin-section { 4000 + padding: 7rem 0 4rem; 4001 + } 4002 + .admin-header { 4003 + margin-bottom: 2rem; 4004 + } 4005 + .admin-header h1 { 4006 + margin: 0; 4007 + } 4008 + .admin-grid { 4009 + display: grid; 4010 + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); 4011 + gap: 1.25rem; 4012 + margin-top: 1rem; 4013 + } 4014 + .admin-card { 4015 + display: block; 4016 + padding: 1.25rem 1.4rem; 4017 + border-radius: 1rem; 4018 + background: rgba(255, 255, 255, 0.55); 4019 + border: 1px solid rgba(18, 26, 47, 0.08); 4020 + text-decoration: none; 4021 + color: inherit; 4022 + transition: transform 0.15s ease, box-shadow 0.15s ease; 4023 + } 4024 + .admin-card:hover { 4025 + transform: translateY(-2px); 4026 + box-shadow: 0 8px 24px rgba(8, 14, 31, 0.08); 4027 + } 4028 + .admin-card-count { 4029 + font-size: 2rem; 4030 + font-weight: 700; 4031 + margin: 0; 4032 + line-height: 1; 4033 + } 4034 + .admin-card-title { 4035 + margin: 0.5rem 0 0.25rem; 4036 + font-size: 1rem; 4037 + font-weight: 600; 4038 + } 4039 + .admin-card-body { 4040 + margin: 0; 4041 + font-size: 0.85rem; 4042 + color: rgba(18, 26, 47, 0.65); 4043 + } 4044 + .dark-phase .admin-card { 4045 + background: rgba(255, 255, 255, 0.05); 4046 + border-color: rgba(255, 255, 255, 0.1); 4047 + } 4048 + .dark-phase .admin-card-body { 4049 + color: rgba(255, 255, 255, 0.7); 4050 + } 4051 + .admin-empty { 4052 + margin-top: 2rem; 4053 + font-style: italic; 4054 + color: rgba(18, 26, 47, 0.55); 4055 + } 4056 + .dark-phase .admin-empty { 4057 + color: rgba(255, 255, 255, 0.55); 4058 + } 4059 + 4060 + /* --- Pending icon review rows ------------------------------------- */ 4061 + .admin-icon-list { 4062 + display: flex; 4063 + flex-direction: column; 4064 + gap: 1rem; 4065 + margin-top: 1.5rem; 4066 + } 4067 + .admin-icon-row { 4068 + display: grid; 4069 + grid-template-columns: 96px minmax(0, 1fr) auto; 4070 + gap: 1.2rem; 4071 + align-items: center; 4072 + padding: 1rem 1.2rem; 4073 + border-radius: 1rem; 4074 + background: rgba(255, 255, 255, 0.55); 4075 + border: 1px solid rgba(18, 26, 47, 0.08); 4076 + } 4077 + .admin-icon-row--done { 4078 + display: flex; 4079 + align-items: center; 4080 + gap: 0.75rem; 4081 + opacity: 0.65; 4082 + } 4083 + .admin-icon-row-preview { 4084 + width: 96px; 4085 + height: 96px; 4086 + border-radius: 12px; 4087 + background: rgba(255, 255, 255, 0.6); 4088 + border: 1px solid rgba(18, 26, 47, 0.08); 4089 + display: flex; 4090 + align-items: center; 4091 + justify-content: center; 4092 + } 4093 + .admin-icon-row-img { 4094 + width: 100%; 4095 + height: 100%; 4096 + object-fit: contain; 4097 + padding: 8px; 4098 + } 4099 + .admin-icon-row-meta { 4100 + min-width: 0; 4101 + } 4102 + .admin-icon-row-name { 4103 + margin: 0; 4104 + font-size: 1rem; 4105 + display: flex; 4106 + gap: 0.6rem; 4107 + align-items: baseline; 4108 + flex-wrap: wrap; 4109 + } 4110 + .admin-icon-row-handle { 4111 + color: rgba(18, 26, 47, 0.55); 4112 + font-size: 0.85rem; 4113 + } 4114 + .admin-icon-row-did { 4115 + margin: 0.25rem 0; 4116 + font-size: 0.75rem; 4117 + color: rgba(18, 26, 47, 0.55); 4118 + word-break: break-all; 4119 + } 4120 + .admin-icon-row-uploaded { 4121 + margin: 0; 4122 + font-size: 0.75rem; 4123 + color: rgba(18, 26, 47, 0.55); 4124 + } 4125 + .admin-icon-row-actions { 4126 + display: flex; 4127 + flex-direction: column; 4128 + gap: 0.5rem; 4129 + align-items: flex-end; 4130 + min-width: 200px; 4131 + } 4132 + .admin-icon-reject { 4133 + display: flex; 4134 + flex-direction: column; 4135 + gap: 0.5rem; 4136 + width: 280px; 4137 + max-width: 100%; 4138 + } 4139 + .admin-icon-reject-label { 4140 + font-size: 0.8rem; 4141 + color: rgba(18, 26, 47, 0.7); 4142 + display: flex; 4143 + flex-direction: column; 4144 + gap: 0.4rem; 4145 + } 4146 + .admin-icon-reject-input { 4147 + width: 100%; 4148 + border: 1px solid rgba(18, 26, 47, 0.15); 4149 + border-radius: 0.5rem; 4150 + padding: 0.5rem 0.6rem; 4151 + font: inherit; 4152 + background: rgba(255, 255, 255, 0.95); 4153 + resize: vertical; 4154 + } 4155 + .admin-icon-reject-actions { 4156 + display: flex; 4157 + gap: 0.5rem; 4158 + align-items: center; 4159 + } 4160 + .admin-icon-row-error { 4161 + margin: 0; 4162 + font-size: 0.75rem; 4163 + color: #c25048; 4164 + } 4165 + .dark-phase .admin-icon-row { 4166 + background: rgba(255, 255, 255, 0.05); 4167 + border-color: rgba(255, 255, 255, 0.1); 4168 + } 4169 + .dark-phase .admin-icon-row-handle, 4170 + .dark-phase .admin-icon-row-did, 4171 + .dark-phase .admin-icon-row-uploaded { 4172 + color: rgba(255, 255, 255, 0.6); 4173 + } 4174 + .dark-phase .admin-icon-row-preview { 4175 + background: rgba(255, 255, 255, 0.06); 4176 + border-color: rgba(255, 255, 255, 0.1); 4177 + } 4178 + .dark-phase .admin-icon-reject-input { 4179 + background: rgba(255, 255, 255, 0.06); 4180 + color: rgba(255, 255, 255, 0.95); 4181 + border-color: rgba(255, 255, 255, 0.18); 4182 + } 4183 + 4184 + /* --- Status pills ------------------------------------------------- */ 4185 + .admin-status-badge { 4186 + display: inline-block; 4187 + padding: 0.15rem 0.55rem; 4188 + border-radius: 999px; 4189 + font-size: 0.7rem; 4190 + font-weight: 600; 4191 + letter-spacing: 0.02em; 4192 + } 4193 + .admin-status-badge--pending { 4194 + background: rgba(255, 197, 99, 0.2); 4195 + color: #8a5a00; 4196 + } 4197 + .admin-status-badge--approved { 4198 + background: rgba(70, 196, 142, 0.2); 4199 + color: #1f7a4e; 4200 + } 4201 + .admin-status-badge--rejected { 4202 + background: rgba(217, 104, 96, 0.2); 4203 + color: #b1453d; 4204 + } 4205 + .dark-phase .admin-status-badge--pending { 4206 + color: #ffd791; 4207 + } 4208 + .dark-phase .admin-status-badge--approved { 4209 + color: #8be0b3; 4210 + } 4211 + .dark-phase .admin-status-badge--rejected { 4212 + color: #ffaca6; 4213 + } 4214 + 4215 + /* --- Inline icon-status banner on the manage form ---------------- */ 4216 + .icon-status-banner { 4217 + display: flex; 4218 + flex-direction: column; 4219 + gap: 0.2rem; 4220 + padding: 0.65rem 0.85rem; 4221 + border-radius: 0.75rem; 4222 + font-size: 0.8rem; 4223 + margin: 0 0 0.75rem; 4224 + } 4225 + .icon-status-banner strong { 4226 + font-size: 0.85rem; 4227 + } 4228 + .icon-status-banner--pending { 4229 + background: rgba(255, 197, 99, 0.15); 4230 + border: 1px solid rgba(255, 197, 99, 0.4); 4231 + color: #6c4500; 4232 + } 4233 + .icon-status-banner--rejected { 4234 + background: rgba(217, 104, 96, 0.12); 4235 + border: 1px solid rgba(217, 104, 96, 0.4); 4236 + color: #8a3a34; 4237 + } 4238 + .dark-phase .icon-status-banner--pending { 4239 + color: #ffd791; 4240 + background: rgba(255, 197, 99, 0.1); 4241 + } 4242 + .dark-phase .icon-status-banner--rejected { 4243 + color: #ffb1ab; 4244 + background: rgba(217, 104, 96, 0.12); 4245 + } 4246 + 4247 + /* ================================================================== * 4248 + * Reports — admin queue + user-facing button/modal 4249 + * ================================================================== */ 4250 + 4251 + .admin-report-list { 4252 + display: flex; 4253 + flex-direction: column; 4254 + gap: 1rem; 4255 + margin-top: 1.5rem; 4256 + } 4257 + .admin-report-row { 4258 + padding: 1rem 1.2rem; 4259 + border-radius: 1rem; 4260 + background: rgba(255, 255, 255, 0.55); 4261 + border: 1px solid rgba(18, 26, 47, 0.08); 4262 + display: flex; 4263 + flex-direction: column; 4264 + gap: 0.6rem; 4265 + } 4266 + .admin-report-row--done { 4267 + opacity: 0.55; 4268 + } 4269 + .admin-report-meta { 4270 + display: flex; 4271 + flex-wrap: wrap; 4272 + gap: 0.6rem 1.2rem; 4273 + font-size: 0.8rem; 4274 + color: rgba(18, 26, 47, 0.7); 4275 + } 4276 + .admin-report-meta strong { 4277 + color: rgba(18, 26, 47, 0.95); 4278 + } 4279 + .admin-report-details { 4280 + margin: 0; 4281 + font-size: 0.85rem; 4282 + white-space: pre-wrap; 4283 + } 4284 + .admin-report-actions { 4285 + display: flex; 4286 + flex-wrap: wrap; 4287 + gap: 0.5rem; 4288 + align-items: center; 4289 + } 4290 + .admin-report-notes-input { 4291 + flex: 1 1 200px; 4292 + border: 1px solid rgba(18, 26, 47, 0.15); 4293 + border-radius: 0.5rem; 4294 + padding: 0.5rem 0.6rem; 4295 + font: inherit; 4296 + background: rgba(255, 255, 255, 0.95); 4297 + } 4298 + .dark-phase .admin-report-row { 4299 + background: rgba(255, 255, 255, 0.05); 4300 + border-color: rgba(255, 255, 255, 0.1); 4301 + } 4302 + .dark-phase .admin-report-meta { 4303 + color: rgba(255, 255, 255, 0.65); 4304 + } 4305 + .dark-phase .admin-report-meta strong { 4306 + color: rgba(255, 255, 255, 0.95); 4307 + } 4308 + .dark-phase .admin-report-notes-input { 4309 + background: rgba(255, 255, 255, 0.06); 4310 + color: rgba(255, 255, 255, 0.95); 4311 + border-color: rgba(255, 255, 255, 0.18); 4312 + } 4313 + 4314 + /* User-facing report button (under the profile hero) */ 4315 + .profile-report-row { 4316 + margin-top: 1.5rem; 4317 + display: flex; 4318 + justify-content: flex-end; 4319 + } 4320 + .profile-report-button { 4321 + background: transparent; 4322 + border: 1px solid rgba(18, 26, 47, 0.18); 4323 + color: rgba(18, 26, 47, 0.65); 4324 + border-radius: 999px; 4325 + padding: 0.45rem 0.95rem; 4326 + font: inherit; 4327 + font-size: 0.8rem; 4328 + cursor: pointer; 4329 + } 4330 + .profile-report-button:hover { 4331 + color: #c25048; 4332 + border-color: rgba(217, 104, 96, 0.5); 4333 + } 4334 + .dark-phase .profile-report-button { 4335 + border-color: rgba(255, 255, 255, 0.18); 4336 + color: rgba(255, 255, 255, 0.65); 4337 + } 4338 + 4339 + /* Modal-internal styles for the report dialog */ 4340 + .report-modal-fieldset { 4341 + display: flex; 4342 + flex-direction: column; 4343 + gap: 0.55rem; 4344 + border: 0; 4345 + margin: 0 0 1rem; 4346 + padding: 0; 4347 + } 4348 + .report-modal-fieldset legend { 4349 + font-weight: 600; 4350 + font-size: 0.85rem; 4351 + margin-bottom: 0.4rem; 4352 + } 4353 + .report-modal-radio { 4354 + display: flex; 4355 + align-items: center; 4356 + gap: 0.5rem; 4357 + font-size: 0.85rem; 4358 + cursor: pointer; 4359 + } 4360 + .report-modal-textarea { 4361 + width: 100%; 4362 + border: 1px solid rgba(18, 26, 47, 0.15); 4363 + border-radius: 0.5rem; 4364 + padding: 0.55rem 0.7rem; 4365 + font: inherit; 4366 + background: rgba(255, 255, 255, 0.95); 4367 + resize: vertical; 4368 + min-height: 4.5rem; 4369 + } 4370 + .dark-phase .report-modal-textarea { 4371 + background: rgba(255, 255, 255, 0.06); 4372 + color: rgba(255, 255, 255, 0.95); 4373 + border-color: rgba(255, 255, 255, 0.18); 4374 + } 4375 + .report-modal-actions { 4376 + display: flex; 4377 + gap: 0.5rem; 4378 + justify-content: flex-end; 4379 + } 4380 + .report-modal-status { 4381 + font-size: 0.8rem; 4382 + margin: 0.5rem 0; 4383 + } 4384 + .report-modal-status--ok { 4385 + color: #1f7a4e; 4386 + } 4387 + .report-modal-status--error { 4388 + color: #c25048; 4389 + } 4390 + 4391 + /* ================================================================== * 4392 + * Featured curation 4393 + * ================================================================== */ 4394 + 4395 + .admin-featured-layout { 4396 + display: grid; 4397 + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); 4398 + gap: 1.5rem; 4399 + margin-top: 1.5rem; 4400 + } 4401 + @media (max-width: 760px) { 4402 + .admin-featured-layout { 4403 + grid-template-columns: 1fr; 4404 + } 4405 + } 4406 + .admin-featured-column h2 { 4407 + font-size: 1rem; 4408 + margin: 0 0 0.75rem; 4409 + } 4410 + .admin-featured-list { 4411 + list-style: none; 4412 + margin: 0; 4413 + padding: 0; 4414 + display: flex; 4415 + flex-direction: column; 4416 + gap: 0.5rem; 4417 + } 4418 + .admin-featured-row { 4419 + display: grid; 4420 + grid-template-columns: minmax(0, 1fr) auto; 4421 + gap: 0.75rem; 4422 + align-items: center; 4423 + padding: 0.6rem 0.8rem; 4424 + border-radius: 0.75rem; 4425 + background: rgba(255, 255, 255, 0.55); 4426 + border: 1px solid rgba(18, 26, 47, 0.08); 4427 + } 4428 + .admin-featured-row strong { 4429 + font-size: 0.9rem; 4430 + } 4431 + .admin-featured-handle { 4432 + color: rgba(18, 26, 47, 0.55); 4433 + font-size: 0.78rem; 4434 + margin-left: 0.4rem; 4435 + } 4436 + .admin-featured-actions { 4437 + display: flex; 4438 + gap: 0.35rem; 4439 + align-items: center; 4440 + } 4441 + .admin-featured-icon-button { 4442 + background: transparent; 4443 + border: 1px solid rgba(18, 26, 47, 0.15); 4444 + border-radius: 6px; 4445 + width: 26px; 4446 + height: 26px; 4447 + font-size: 0.85rem; 4448 + cursor: pointer; 4449 + color: rgba(18, 26, 47, 0.7); 4450 + display: inline-flex; 4451 + align-items: center; 4452 + justify-content: center; 4453 + } 4454 + .admin-featured-icon-button:disabled { 4455 + opacity: 0.3; 4456 + cursor: not-allowed; 4457 + } 4458 + .admin-featured-badges { 4459 + display: flex; 4460 + gap: 0.25rem; 4461 + } 4462 + .admin-featured-badge-toggle { 4463 + background: transparent; 4464 + border: 1px solid rgba(18, 26, 47, 0.15); 4465 + border-radius: 999px; 4466 + padding: 0.1rem 0.55rem; 4467 + font: inherit; 4468 + font-size: 0.7rem; 4469 + cursor: pointer; 4470 + color: rgba(18, 26, 47, 0.65); 4471 + } 4472 + .admin-featured-badge-toggle--on { 4473 + background: rgba(70, 196, 142, 0.18); 4474 + border-color: rgba(70, 196, 142, 0.5); 4475 + color: #1f7a4e; 4476 + } 4477 + .admin-featured-filter { 4478 + width: 100%; 4479 + border: 1px solid rgba(18, 26, 47, 0.15); 4480 + border-radius: 0.5rem; 4481 + padding: 0.5rem 0.7rem; 4482 + font: inherit; 4483 + background: rgba(255, 255, 255, 0.95); 4484 + margin-bottom: 0.75rem; 4485 + } 4486 + .admin-featured-toolbar { 4487 + display: flex; 4488 + gap: 0.75rem; 4489 + align-items: center; 4490 + margin-top: 1.5rem; 4491 + flex-wrap: wrap; 4492 + } 4493 + .admin-featured-status { 4494 + font-size: 0.85rem; 4495 + } 4496 + .admin-featured-status--ok { color: #1f7a4e; } 4497 + .admin-featured-status--error { color: #c25048; } 4498 + .dark-phase .admin-featured-row { 4499 + background: rgba(255, 255, 255, 0.05); 4500 + border-color: rgba(255, 255, 255, 0.1); 4501 + } 4502 + .dark-phase .admin-featured-handle { 4503 + color: rgba(255, 255, 255, 0.55); 4504 + } 4505 + .dark-phase .admin-featured-icon-button { 4506 + border-color: rgba(255, 255, 255, 0.18); 4507 + color: rgba(255, 255, 255, 0.7); 4508 + } 4509 + .dark-phase .admin-featured-badge-toggle { 4510 + border-color: rgba(255, 255, 255, 0.18); 4511 + color: rgba(255, 255, 255, 0.65); 4512 + } 4513 + .dark-phase .admin-featured-filter { 4514 + background: rgba(255, 255, 255, 0.06); 4515 + color: rgba(255, 255, 255, 0.95); 4516 + border-color: rgba(255, 255, 255, 0.18); 4517 + } 4518 + 4519 + /* ================================================================== * 4520 + * Takedowns 4521 + * ================================================================== */ 4522 + 4523 + .admin-report-takedown-button { 4524 + background: rgba(194, 80, 72, 0.12); 4525 + border: 1px solid rgba(194, 80, 72, 0.4); 4526 + color: #c25048; 4527 + border-radius: 6px; 4528 + padding: 0.4rem 0.85rem; 4529 + font: inherit; 4530 + font-size: 0.85rem; 4531 + cursor: pointer; 4532 + } 4533 + .admin-report-takedown-button:hover:not(:disabled) { 4534 + background: rgba(194, 80, 72, 0.2); 4535 + } 4536 + .admin-report-takedown-button:disabled { 4537 + opacity: 0.5; 4538 + cursor: not-allowed; 4539 + } 4540 + .dark-phase .admin-report-takedown-button { 4541 + background: rgba(255, 110, 100, 0.15); 4542 + border-color: rgba(255, 110, 100, 0.45); 4543 + color: #ff9b94; 4544 + } 4545 + 4546 + .manage-takedown-banner { 4547 + margin-top: 1.5rem; 4548 + padding: 1rem 1.1rem; 4549 + border-radius: 0.75rem; 4550 + background: rgba(194, 80, 72, 0.08); 4551 + border: 1px solid rgba(194, 80, 72, 0.35); 4552 + color: #7a2a24; 4553 + } 4554 + .manage-takedown-banner-title { 4555 + display: block; 4556 + font-size: 1rem; 4557 + margin-bottom: 0.4rem; 4558 + } 4559 + .manage-takedown-banner-body { 4560 + margin: 0 0 0.5rem; 4561 + font-size: 0.9rem; 4562 + line-height: 1.45; 4563 + } 4564 + .manage-takedown-banner-reason { 4565 + margin: 0; 4566 + font-size: 0.85rem; 4567 + } 4568 + .dark-phase .manage-takedown-banner { 4569 + background: rgba(255, 110, 100, 0.1); 4570 + border-color: rgba(255, 110, 100, 0.4); 4571 + color: #ffb6b0; 4572 + }
+154
i18n/messages/en.tsx
··· 599 599 remove: "Remove SVG", 600 600 invalidType: "Icon must be an SVG (image/svg+xml).", 601 601 tooLarge: "Icon must be 200KB or smaller.", 602 + statusPendingTitle: "Pending review", 603 + statusPendingBody: 604 + "Your icon is on your PDS but won't appear in the developer API until an admin approves it.", 605 + statusRejectedTitle: "Rejected", 606 + statusRejectedBody: (reason: string): string => 607 + `An admin rejected this icon. Reason: ${reason}. Replace it below to resubmit.`, 602 608 }, 609 + }, 610 + }, 611 + 612 + /** 613 + * Lightweight in-app moderation/curation dashboard. Routes are 614 + * gated by `ADMIN_DIDS` so non-admin users never see this copy. 615 + */ 616 + admin: { 617 + backToOverview: "Back to admin overview", 618 + errorPrefix: "Error", 619 + overview: { 620 + headline: "Admin", 621 + subhead: 622 + "Approve developer icons, triage reports, and curate the featured rail.", 623 + iconsTitle: "Pending icons", 624 + iconsBody: 625 + "Developer-facing SVGs awaiting approval before they're served via the public API.", 626 + reportsTitle: "Open reports", 627 + reportsBody: "User-submitted reports against profiles in Explore.", 628 + featuredTitle: "Featured", 629 + featuredBody: 630 + "Curate the projects that appear in the featured rail at the top of Explore.", 631 + takedownsTitle: "Taken down", 632 + takedownsBody: 633 + "Profiles removed from Explore. Restorable at any time — the user's PDS record is untouched.", 634 + }, 635 + statusBadge: { 636 + pending: "Pending review", 637 + approved: "Approved", 638 + rejected: "Rejected", 639 + }, 640 + icons: { 641 + headline: "Pending developer icons", 642 + subhead: 643 + "Each icon was uploaded to the project's PDS and sanitised, but won't be served via /api/registry/icon/:did until you approve it.", 644 + empty: "Nothing in the queue. Check back later.", 645 + approve: "Approve", 646 + reject: "Reject", 647 + confirmReject: "Reject this icon. Tell the project owner why:", 648 + rejectReasonPlaceholder: 649 + "e.g. unrelated to the project, low quality, contains text", 650 + submitReject: "Submit rejection", 651 + cancel: "Cancel", 652 + markedApproved: "Approved", 653 + markedRejected: "Rejected", 654 + }, 655 + reports: { 656 + headline: "Open reports", 657 + subhead: 658 + "Reports submitted against Explore profiles. Mark actioned or dismiss to close the report; take down to remove the profile from Explore (auto-resolves all other reports against it).", 659 + empty: "No open reports.", 660 + action: "Mark actioned", 661 + dismiss: "Dismiss", 662 + takedown: "Take down profile", 663 + takedownPrompt: 664 + "Take this profile down. Why? (saved with the takedown record)", 665 + takedownDoneLabel: "Taken down", 666 + actionedLabel: "Actioned", 667 + dismissedLabel: "Dismissed", 668 + noteLabel: "Notes (optional)", 669 + notePlaceholder: "What did you do?", 670 + reasonLabel: "Reason", 671 + reporterLabel: "Reporter", 672 + anonymousReporter: "Anonymous", 673 + detailsLabel: "Details", 674 + submittedAt: "Submitted", 675 + reasons: { 676 + not_a_project: "Not a project", 677 + harmful: "Harmful or hateful", 678 + impersonation: "Impersonation", 679 + spam: "Spam", 680 + other: "Other", 681 + }, 682 + }, 683 + takedowns: { 684 + headline: "Taken-down profiles", 685 + subhead: 686 + "Profiles currently hidden from Explore and the public registry API. Restore returns them to /explore immediately. The user's PDS record is never touched.", 687 + empty: "No profiles are currently taken down.", 688 + reasonLabel: "Reason", 689 + byLabel: "Taken down by", 690 + atLabel: "Taken down on", 691 + restore: "Restore", 692 + confirmRestore: 693 + "Restore this profile? It will reappear in Explore immediately.", 694 + restored: "Restored", 695 + }, 696 + featured: { 697 + headline: "Curate featured", 698 + subhead: 699 + "Pick the projects that appear in the featured rail. Drag to reorder. Save & publish writes the canonical record on the Atmosphere account's PDS.", 700 + saveAndPublish: "Save & publish", 701 + saving: "Publishing…", 702 + saved: "Published.", 703 + filterPlaceholder: "Filter by name, handle, or DID…", 704 + featuredHeading: "Featured (in order)", 705 + candidatesHeading: "All projects", 706 + empty: "No projects in the registry yet.", 707 + moveUp: "Move up", 708 + moveDown: "Move down", 709 + remove: "Remove", 710 + add: "Feature", 711 + badgesLabel: "Badges", 712 + badgeVerified: "Verified", 713 + badgeOfficial: "Official", 714 + }, 715 + }, 716 + 717 + /** 718 + * Banner shown on /explore/manage when the owner's profile has been 719 + * taken down by an admin. Explains the state and surfaces the 720 + * recorded reason; the Publish button below also returns 403 from 721 + * the API so the user gets a consistent message either way. 722 + */ 723 + manageTakedown: { 724 + title: "Your profile has been removed from Explore", 725 + body: 726 + "An Atmosphere admin took your profile down. Updates won't be published until it's restored. The record on your PDS is untouched — you can delete it from your PDS at any time.", 727 + reasonLabel: "Reason given", 728 + }, 729 + 730 + /** 731 + * User-facing report flow on /explore/<handle>. The button mounts 732 + * the modal; modal handles submission to /api/registry/profile/:id/report. 733 + */ 734 + report: { 735 + button: "Report profile", 736 + buttonShort: "Report", 737 + modalTitle: "Report this profile", 738 + modalBody: 739 + "Send a report to the Atmosphere admins. Reports are anonymous unless you're signed in.", 740 + reasonLabel: "What's wrong?", 741 + detailsLabel: "Add details (optional)", 742 + detailsPlaceholder: "Anything we should know?", 743 + submit: "Send report", 744 + submitting: "Sending…", 745 + cancel: "Cancel", 746 + sentTitle: "Report sent", 747 + sentBody: "Thanks. An admin will review it shortly.", 748 + duplicate: 749 + "You've already submitted this report recently. We'll review the existing one.", 750 + error: "Couldn't send the report. Please try again.", 751 + reasons: { 752 + not_a_project: "Not a real project", 753 + harmful: "Harmful or hateful content", 754 + impersonation: "Impersonating someone", 755 + spam: "Spam", 756 + other: "Other", 603 757 }, 604 758 }, 605 759
+273
islands/AdminFeaturedEditor.tsx
··· 1 + import { useComputed, useSignal } from "@preact/signals"; 2 + 3 + export interface FeaturedCandidate { 4 + did: string; 5 + handle: string; 6 + name: string; 7 + } 8 + 9 + export interface FeaturedEntryDraft { 10 + did: string; 11 + badges: string[]; 12 + } 13 + 14 + interface Props { 15 + /** All registry profiles, used as the candidate pool. */ 16 + candidates: FeaturedCandidate[]; 17 + /** Currently-featured entries, ordered by position. */ 18 + initial: FeaturedEntryDraft[]; 19 + copy: { 20 + saveAndPublish: string; 21 + saving: string; 22 + saved: string; 23 + filterPlaceholder: string; 24 + featuredHeading: string; 25 + candidatesHeading: string; 26 + empty: string; 27 + moveUp: string; 28 + moveDown: string; 29 + remove: string; 30 + add: string; 31 + badgesLabel: string; 32 + badgeVerified: string; 33 + badgeOfficial: string; 34 + error: string; 35 + }; 36 + } 37 + 38 + const BADGE_KEYS = ["verified", "official"] as const; 39 + type Badge = typeof BADGE_KEYS[number]; 40 + 41 + /** 42 + * Two-pane editor: featured entries on the left (ordered, with badge 43 + * toggles + reorder buttons), full project pool on the right with a 44 + * filter. "Save & publish" POSTs the resulting list to /api/admin/ 45 + * featured which writes the canonical record on the curator's PDS. 46 + */ 47 + export default function AdminFeaturedEditor( 48 + { candidates, initial, copy }: Props, 49 + ) { 50 + const entries = useSignal<FeaturedEntryDraft[]>(initial); 51 + const filter = useSignal(""); 52 + const status = useSignal< 53 + | { kind: "idle" } 54 + | { kind: "saving" } 55 + | { kind: "saved" } 56 + | { kind: "error"; text: string } 57 + >({ kind: "idle" }); 58 + 59 + const candidateMap = useComputed(() => { 60 + const m = new Map<string, FeaturedCandidate>(); 61 + for (const c of candidates) m.set(c.did, c); 62 + return m; 63 + }); 64 + 65 + const featuredDids = useComputed(() => new Set(entries.value.map((e) => e.did))); 66 + 67 + const filteredCandidates = useComputed(() => { 68 + const q = filter.value.trim().toLowerCase(); 69 + return candidates 70 + .filter((c) => !featuredDids.value.has(c.did)) 71 + .filter((c) => { 72 + if (!q) return true; 73 + return ( 74 + c.handle.toLowerCase().includes(q) || 75 + c.did.toLowerCase().includes(q) || 76 + c.name.toLowerCase().includes(q) 77 + ); 78 + }) 79 + .slice(0, 100); 80 + }); 81 + 82 + const add = (did: string) => { 83 + if (entries.value.find((e) => e.did === did)) return; 84 + entries.value = [...entries.value, { did, badges: [] }]; 85 + }; 86 + const remove = (did: string) => { 87 + entries.value = entries.value.filter((e) => e.did !== did); 88 + }; 89 + const move = (i: number, delta: -1 | 1) => { 90 + const j = i + delta; 91 + if (j < 0 || j >= entries.value.length) return; 92 + const next = [...entries.value]; 93 + const [item] = next.splice(i, 1); 94 + next.splice(j, 0, item); 95 + entries.value = next; 96 + }; 97 + const toggleBadge = (i: number, badge: Badge) => { 98 + const next = [...entries.value]; 99 + const cur = new Set(next[i].badges); 100 + if (cur.has(badge)) cur.delete(badge); 101 + else cur.add(badge); 102 + next[i] = { ...next[i], badges: Array.from(cur) }; 103 + entries.value = next; 104 + }; 105 + 106 + const save = async () => { 107 + status.value = { kind: "saving" }; 108 + try { 109 + const r = await fetch("/api/admin/featured", { 110 + method: "POST", 111 + headers: { "content-type": "application/json" }, 112 + body: JSON.stringify({ 113 + entries: entries.value.map((e, i) => ({ 114 + did: e.did, 115 + badges: e.badges, 116 + position: i, 117 + })), 118 + }), 119 + }); 120 + if (!r.ok) throw new Error(await r.text()); 121 + status.value = { kind: "saved" }; 122 + } catch (err) { 123 + status.value = { 124 + kind: "error", 125 + text: err instanceof Error ? err.message : String(err), 126 + }; 127 + } 128 + }; 129 + 130 + const badgeLabel = (b: Badge) => 131 + b === "verified" ? copy.badgeVerified : copy.badgeOfficial; 132 + 133 + return ( 134 + <div> 135 + <div class="admin-featured-layout"> 136 + <div class="admin-featured-column"> 137 + <h2>{copy.featuredHeading}</h2> 138 + {entries.value.length === 0 139 + ? <p class="admin-empty">{copy.empty}</p> 140 + : ( 141 + <ul class="admin-featured-list"> 142 + {entries.value.map((e, i) => { 143 + const c = candidateMap.value.get(e.did); 144 + return ( 145 + <li class="admin-featured-row" key={e.did}> 146 + <div> 147 + <strong>{c?.name ?? e.did}</strong> 148 + {c && ( 149 + <span class="admin-featured-handle">@{c.handle}</span> 150 + )} 151 + <div 152 + class="admin-featured-badges" 153 + style={{ marginTop: "0.4rem" }} 154 + > 155 + <span 156 + style={{ 157 + fontSize: "0.7rem", 158 + color: "rgba(18,26,47,0.55)", 159 + alignSelf: "center", 160 + marginRight: "0.25rem", 161 + }} 162 + > 163 + {copy.badgesLabel}: 164 + </span> 165 + {BADGE_KEYS.map((b) => ( 166 + <button 167 + type="button" 168 + key={b} 169 + class={`admin-featured-badge-toggle${ 170 + e.badges.includes(b) 171 + ? " admin-featured-badge-toggle--on" 172 + : "" 173 + }`} 174 + onClick={() => toggleBadge(i, b)} 175 + > 176 + {badgeLabel(b)} 177 + </button> 178 + ))} 179 + </div> 180 + </div> 181 + <div class="admin-featured-actions"> 182 + <button 183 + type="button" 184 + class="admin-featured-icon-button" 185 + aria-label={copy.moveUp} 186 + onClick={() => move(i, -1)} 187 + disabled={i === 0} 188 + > 189 + 190 + </button> 191 + <button 192 + type="button" 193 + class="admin-featured-icon-button" 194 + aria-label={copy.moveDown} 195 + onClick={() => move(i, 1)} 196 + disabled={i === entries.value.length - 1} 197 + > 198 + 199 + </button> 200 + <button 201 + type="button" 202 + class="profile-form-button-link" 203 + onClick={() => remove(e.did)} 204 + > 205 + {copy.remove} 206 + </button> 207 + </div> 208 + </li> 209 + ); 210 + })} 211 + </ul> 212 + )} 213 + </div> 214 + 215 + <div class="admin-featured-column"> 216 + <h2>{copy.candidatesHeading}</h2> 217 + <input 218 + type="text" 219 + class="admin-featured-filter" 220 + placeholder={copy.filterPlaceholder} 221 + value={filter.value} 222 + onInput={(e) => 223 + filter.value = (e.currentTarget as HTMLInputElement).value} 224 + /> 225 + {filteredCandidates.value.length === 0 226 + ? <p class="admin-empty">{copy.empty}</p> 227 + : ( 228 + <ul class="admin-featured-list"> 229 + {filteredCandidates.value.map((c) => ( 230 + <li class="admin-featured-row" key={c.did}> 231 + <div> 232 + <strong>{c.name}</strong> 233 + <span class="admin-featured-handle">@{c.handle}</span> 234 + </div> 235 + <div class="admin-featured-actions"> 236 + <button 237 + type="button" 238 + class="profile-form-button-secondary" 239 + onClick={() => add(c.did)} 240 + > 241 + {copy.add} 242 + </button> 243 + </div> 244 + </li> 245 + ))} 246 + </ul> 247 + )} 248 + </div> 249 + </div> 250 + 251 + <div class="admin-featured-toolbar"> 252 + <button 253 + type="button" 254 + class="profile-form-button-primary" 255 + onClick={save} 256 + disabled={status.value.kind === "saving"} 257 + > 258 + {status.value.kind === "saving" ? copy.saving : copy.saveAndPublish} 259 + </button> 260 + {status.value.kind === "saved" && ( 261 + <span class="admin-featured-status admin-featured-status--ok"> 262 + {copy.saved} 263 + </span> 264 + )} 265 + {status.value.kind === "error" && ( 266 + <span class="admin-featured-status admin-featured-status--error"> 267 + {copy.error}: {status.value.text} 268 + </span> 269 + )} 270 + </div> 271 + </div> 272 + ); 273 + }
+186
islands/AdminIconReview.tsx
··· 1 + import { useSignal } from "@preact/signals"; 2 + 3 + interface Props { 4 + did: string; 5 + handle: string; 6 + name: string; 7 + /** Admin-only preview URL — bypasses the public approval gate. */ 8 + previewUrl: string; 9 + /** When the icon was uploaded / last reindexed. */ 10 + uploadedAt: number; 11 + copy: { 12 + approve: string; 13 + reject: string; 14 + rejectReasonPlaceholder: string; 15 + confirmReject: string; 16 + submit: string; 17 + cancel: string; 18 + pending: string; 19 + approved: string; 20 + rejected: string; 21 + error: string; 22 + }; 23 + } 24 + 25 + type Status = "pending" | "approving" | "approved" | "rejecting" | "rejected"; 26 + 27 + /** 28 + * Per-row review widget on /admin/icons. Server renders the static 29 + * project info; this island owns the buttons + reject-reason flow and 30 + * removes the row from the DOM optimistically once the API returns. 31 + */ 32 + export default function AdminIconReview( 33 + { did, handle, name, previewUrl, uploadedAt, copy }: Props, 34 + ) { 35 + const status = useSignal<Status>("pending"); 36 + const error = useSignal<string | null>(null); 37 + const showReject = useSignal(false); 38 + const reason = useSignal(""); 39 + 40 + const onApprove = async () => { 41 + status.value = "approving"; 42 + error.value = null; 43 + try { 44 + const r = await fetch( 45 + `/api/admin/icons/${encodeURIComponent(did)}/approve`, 46 + { method: "POST" }, 47 + ); 48 + if (!r.ok) throw new Error(await r.text()); 49 + status.value = "approved"; 50 + } catch (e) { 51 + error.value = e instanceof Error ? e.message : String(e); 52 + status.value = "pending"; 53 + } 54 + }; 55 + 56 + const onReject = async () => { 57 + const text = reason.value.trim(); 58 + if (!text) return; 59 + status.value = "rejecting"; 60 + error.value = null; 61 + try { 62 + const r = await fetch( 63 + `/api/admin/icons/${encodeURIComponent(did)}/reject`, 64 + { 65 + method: "POST", 66 + headers: { "content-type": "application/json" }, 67 + body: JSON.stringify({ reason: text }), 68 + }, 69 + ); 70 + if (!r.ok) throw new Error(await r.text()); 71 + status.value = "rejected"; 72 + showReject.value = false; 73 + } catch (e) { 74 + error.value = e instanceof Error ? e.message : String(e); 75 + status.value = "pending"; 76 + } 77 + }; 78 + 79 + if (status.value === "approved") { 80 + return ( 81 + <div class="admin-icon-row admin-icon-row--done"> 82 + <strong>{name}</strong>{" "} 83 + <span class="admin-status-badge admin-status-badge--approved"> 84 + {copy.approved} 85 + </span> 86 + </div> 87 + ); 88 + } 89 + if (status.value === "rejected") { 90 + return ( 91 + <div class="admin-icon-row admin-icon-row--done"> 92 + <strong>{name}</strong>{" "} 93 + <span class="admin-status-badge admin-status-badge--rejected"> 94 + {copy.rejected} 95 + </span> 96 + </div> 97 + ); 98 + } 99 + 100 + const uploaded = new Date(uploadedAt).toISOString().slice(0, 10); 101 + 102 + return ( 103 + <div class="admin-icon-row"> 104 + <div class="admin-icon-row-preview"> 105 + <img src={previewUrl} alt="" class="admin-icon-row-img" /> 106 + </div> 107 + <div class="admin-icon-row-meta"> 108 + <p class="admin-icon-row-name"> 109 + <strong>{name}</strong> 110 + <span class="admin-icon-row-handle">@{handle}</span> 111 + </p> 112 + <p class="admin-icon-row-did"> 113 + <code>{did}</code> 114 + </p> 115 + <p class="admin-icon-row-uploaded">Uploaded {uploaded}</p> 116 + </div> 117 + <div class="admin-icon-row-actions"> 118 + {!showReject.value 119 + ? ( 120 + <> 121 + <button 122 + type="button" 123 + class="profile-form-button-primary" 124 + onClick={onApprove} 125 + disabled={status.value === "approving"} 126 + > 127 + {status.value === "approving" ? "…" : copy.approve} 128 + </button> 129 + <button 130 + type="button" 131 + class="profile-form-button-secondary" 132 + onClick={() => { 133 + showReject.value = true; 134 + }} 135 + > 136 + {copy.reject} 137 + </button> 138 + </> 139 + ) 140 + : ( 141 + <div class="admin-icon-reject"> 142 + <label class="admin-icon-reject-label"> 143 + {copy.confirmReject} 144 + <textarea 145 + class="admin-icon-reject-input" 146 + rows={3} 147 + maxLength={500} 148 + placeholder={copy.rejectReasonPlaceholder} 149 + value={reason.value} 150 + onInput={(e) => 151 + reason.value = (e.currentTarget as HTMLTextAreaElement) 152 + .value} 153 + /> 154 + </label> 155 + <div class="admin-icon-reject-actions"> 156 + <button 157 + type="button" 158 + class="profile-form-button-primary" 159 + onClick={onReject} 160 + disabled={status.value === "rejecting" || 161 + !reason.value.trim()} 162 + > 163 + {status.value === "rejecting" ? "…" : copy.submit} 164 + </button> 165 + <button 166 + type="button" 167 + class="profile-form-button-link" 168 + onClick={() => { 169 + showReject.value = false; 170 + reason.value = ""; 171 + }} 172 + > 173 + {copy.cancel} 174 + </button> 175 + </div> 176 + </div> 177 + )} 178 + {error.value && ( 179 + <p class="admin-icon-row-error"> 180 + {copy.error}: {error.value} 181 + </p> 182 + )} 183 + </div> 184 + </div> 185 + ); 186 + }
+189
islands/AdminReportRow.tsx
··· 1 + import { useSignal } from "@preact/signals"; 2 + 3 + interface Props { 4 + id: number; 5 + targetDid: string; 6 + targetHandle: string; 7 + reporterDid: string | null; 8 + reason: string; 9 + reasonLabel: string; 10 + details: string | null; 11 + createdAt: number; 12 + copy: { 13 + action: string; 14 + dismiss: string; 15 + takedown: string; 16 + takedownPrompt: string; 17 + takedownDoneLabel: string; 18 + actionedLabel: string; 19 + dismissedLabel: string; 20 + noteLabel: string; 21 + notePlaceholder: string; 22 + reasonLabel: string; 23 + reporterLabel: string; 24 + anonymousReporter: string; 25 + detailsLabel: string; 26 + submittedAt: string; 27 + error: string; 28 + }; 29 + } 30 + 31 + type ResolutionKind = "actioned" | "dismissed" | "taken_down"; 32 + 33 + export default function AdminReportRow(p: Props) { 34 + const notes = useSignal(""); 35 + const status = useSignal< 36 + | { kind: "open" } 37 + | { kind: "submitting" } 38 + | { kind: "done"; action: ResolutionKind } 39 + | { kind: "error"; text: string } 40 + >({ kind: "open" }); 41 + 42 + const resolve = async (action: "actioned" | "dismissed") => { 43 + status.value = { kind: "submitting" }; 44 + try { 45 + const r = await fetch( 46 + `/api/admin/reports/${p.id}/resolve`, 47 + { 48 + method: "POST", 49 + headers: { "content-type": "application/json" }, 50 + body: JSON.stringify({ 51 + action, 52 + notes: notes.value.trim() || undefined, 53 + }), 54 + }, 55 + ); 56 + if (!r.ok) throw new Error(await r.text()); 57 + status.value = { kind: "done", action }; 58 + } catch (err) { 59 + status.value = { 60 + kind: "error", 61 + text: err instanceof Error ? err.message : String(err), 62 + }; 63 + } 64 + }; 65 + 66 + /** 67 + * Take down the *target profile* (not just the report). The reason 68 + * field is required by the API; we collect it via a prompt() so the 69 + * row stays compact. Side effects on success: the profile becomes 70 + * invisible to /explore + public APIs, and *all* open reports 71 + * against this DID are auto-resolved as actioned (so this row's 72 + * sibling reports also disappear on the next page load). 73 + */ 74 + const takedown = async () => { 75 + const reason = window.prompt(p.copy.takedownPrompt, ""); 76 + if (!reason || !reason.trim()) return; 77 + status.value = { kind: "submitting" }; 78 + try { 79 + const r = await fetch( 80 + `/api/admin/profiles/${encodeURIComponent(p.targetDid)}/takedown`, 81 + { 82 + method: "POST", 83 + headers: { "content-type": "application/json" }, 84 + body: JSON.stringify({ 85 + reason: reason.trim(), 86 + notes: notes.value.trim() || undefined, 87 + }), 88 + }, 89 + ); 90 + if (!r.ok) throw new Error(await r.text()); 91 + status.value = { kind: "done", action: "taken_down" }; 92 + } catch (err) { 93 + status.value = { 94 + kind: "error", 95 + text: err instanceof Error ? err.message : String(err), 96 + }; 97 + } 98 + }; 99 + 100 + if (status.value.kind === "done") { 101 + const label = status.value.action === "actioned" 102 + ? p.copy.actionedLabel 103 + : status.value.action === "dismissed" 104 + ? p.copy.dismissedLabel 105 + : p.copy.takedownDoneLabel; 106 + return ( 107 + <div class="admin-report-row admin-report-row--done"> 108 + <div class="admin-report-meta"> 109 + <span> 110 + <strong>@{p.targetHandle}</strong> 111 + </span> 112 + <span>{p.reasonLabel}</span> 113 + <span> 114 + <span class="admin-status-badge admin-status-badge--approved"> 115 + {label} 116 + </span> 117 + </span> 118 + </div> 119 + </div> 120 + ); 121 + } 122 + 123 + const submitted = new Date(p.createdAt).toISOString().slice(0, 10); 124 + return ( 125 + <div class="admin-report-row"> 126 + <div class="admin-report-meta"> 127 + <span> 128 + <strong> 129 + <a href={`/explore/${p.targetHandle}`}>@{p.targetHandle}</a> 130 + </strong> 131 + </span> 132 + <span> 133 + {p.copy.reasonLabel}: <strong>{p.reasonLabel}</strong> 134 + </span> 135 + <span> 136 + {p.copy.reporterLabel}:{" "} 137 + <strong>{p.reporterDid ?? p.copy.anonymousReporter}</strong> 138 + </span> 139 + <span> 140 + {p.copy.submittedAt}: <strong>{submitted}</strong> 141 + </span> 142 + </div> 143 + {p.details && ( 144 + <p class="admin-report-details"> 145 + <strong>{p.copy.detailsLabel}:</strong> {p.details} 146 + </p> 147 + )} 148 + <div class="admin-report-actions"> 149 + <input 150 + type="text" 151 + class="admin-report-notes-input" 152 + placeholder={p.copy.notePlaceholder} 153 + value={notes.value} 154 + onInput={(e) => 155 + notes.value = (e.currentTarget as HTMLInputElement).value} 156 + /> 157 + <button 158 + type="button" 159 + class="profile-form-button-primary" 160 + onClick={() => resolve("actioned")} 161 + disabled={status.value.kind === "submitting"} 162 + > 163 + {p.copy.action} 164 + </button> 165 + <button 166 + type="button" 167 + class="profile-form-button-secondary" 168 + onClick={() => resolve("dismissed")} 169 + disabled={status.value.kind === "submitting"} 170 + > 171 + {p.copy.dismiss} 172 + </button> 173 + <button 174 + type="button" 175 + class="admin-report-takedown-button" 176 + onClick={takedown} 177 + disabled={status.value.kind === "submitting"} 178 + > 179 + {p.copy.takedown} 180 + </button> 181 + </div> 182 + {status.value.kind === "error" && ( 183 + <p class="admin-icon-row-error"> 184 + {p.copy.error}: {status.value.text} 185 + </p> 186 + )} 187 + </div> 188 + ); 189 + }
+106
islands/AdminTakedownRow.tsx
··· 1 + import { useSignal } from "@preact/signals"; 2 + 3 + interface Props { 4 + did: string; 5 + handle: string; 6 + name: string; 7 + reason: string; 8 + by: string; 9 + at: number; 10 + copy: { 11 + reasonLabel: string; 12 + byLabel: string; 13 + atLabel: string; 14 + restore: string; 15 + confirmRestore: string; 16 + restored: string; 17 + error: string; 18 + }; 19 + } 20 + 21 + /** 22 + * Single row in /admin/takedowns. Shows the taken-down profile's 23 + * metadata + a Restore button that POSTs to 24 + * /api/admin/profiles/:did/restore. On success the row collapses to a 25 + * lightweight "restored" confirmation so the page doesn't need a full 26 + * reload to feel responsive. 27 + */ 28 + export default function AdminTakedownRow(p: Props) { 29 + const status = useSignal< 30 + | { kind: "idle" } 31 + | { kind: "submitting" } 32 + | { kind: "done" } 33 + | { kind: "error"; text: string } 34 + >({ kind: "idle" }); 35 + 36 + const restore = async () => { 37 + if (!confirm(p.copy.confirmRestore)) return; 38 + status.value = { kind: "submitting" }; 39 + try { 40 + const r = await fetch( 41 + `/api/admin/profiles/${encodeURIComponent(p.did)}/restore`, 42 + { method: "POST" }, 43 + ); 44 + if (!r.ok) throw new Error(await r.text()); 45 + status.value = { kind: "done" }; 46 + } catch (err) { 47 + status.value = { 48 + kind: "error", 49 + text: err instanceof Error ? err.message : String(err), 50 + }; 51 + } 52 + }; 53 + 54 + if (status.value.kind === "done") { 55 + return ( 56 + <div class="admin-report-row admin-report-row--done"> 57 + <div class="admin-report-meta"> 58 + <span> 59 + <strong>@{p.handle}</strong> 60 + </span> 61 + <span> 62 + <span class="admin-status-badge admin-status-badge--approved"> 63 + {p.copy.restored} 64 + </span> 65 + </span> 66 + </div> 67 + </div> 68 + ); 69 + } 70 + 71 + const at = new Date(p.at).toISOString().slice(0, 10); 72 + return ( 73 + <div class="admin-report-row"> 74 + <div class="admin-report-meta"> 75 + <span> 76 + <strong>{p.name}</strong> 77 + <span class="admin-featured-handle">@{p.handle}</span> 78 + </span> 79 + <span> 80 + {p.copy.atLabel}: <strong>{at}</strong> 81 + </span> 82 + <span> 83 + {p.copy.byLabel}: <strong>{p.by}</strong> 84 + </span> 85 + </div> 86 + <p class="admin-report-details"> 87 + <strong>{p.copy.reasonLabel}:</strong> {p.reason} 88 + </p> 89 + <div class="admin-report-actions"> 90 + <button 91 + type="button" 92 + class="profile-form-button-secondary" 93 + onClick={restore} 94 + disabled={status.value.kind === "submitting"} 95 + > 96 + {p.copy.restore} 97 + </button> 98 + </div> 99 + {status.value.kind === "error" && ( 100 + <p class="admin-icon-row-error"> 101 + {p.copy.error}: {status.value.text} 102 + </p> 103 + )} 104 + </div> 105 + ); 106 + }
+36 -2
islands/CreateProfileForm.tsx
··· 25 25 subcategories: string[]; 26 26 links: LinkEntry[]; 27 27 avatar: { ref: string; mime: string } | null; 28 - /** Optional developer-facing SVG icon. */ 29 - icon: { ref: string; mime: string } | null; 28 + /** Optional developer-facing SVG icon. `status` is the moderation 29 + * state from the registry index — drives the "Pending review" / 30 + * "Rejected" badge in the icon section. */ 31 + icon: 32 + | { 33 + ref: string; 34 + mime: string; 35 + status?: "pending" | "approved" | "rejected" | null; 36 + rejectedReason?: string | null; 37 + } 38 + | null; 30 39 } 31 40 32 41 interface Props { ··· 688 697 } 689 698 <fieldset class="profile-form-field"> 690 699 <legend class="profile-form-label">{tIcon.sectionLabel}</legend> 700 + {/** 701 + * Surface the moderation state for the *currently saved* 702 + * icon so the user knows whether the developer API is 703 + * actually serving it. We only show a badge when the user 704 + * hasn't queued a replacement (otherwise the badge would 705 + * lie until the next save). 706 + */} 707 + {!iconFile.value && !iconRemoved.value && initial?.icon && 708 + initial.icon.status === "pending" && ( 709 + <div class="icon-status-banner icon-status-banner--pending"> 710 + <strong>{tIcon.statusPendingTitle}</strong> 711 + <span>{tIcon.statusPendingBody}</span> 712 + </div> 713 + )} 714 + {!iconFile.value && !iconRemoved.value && initial?.icon && 715 + initial.icon.status === "rejected" && ( 716 + <div class="icon-status-banner icon-status-banner--rejected"> 717 + <strong>{tIcon.statusRejectedTitle}</strong> 718 + <span> 719 + {tIcon.statusRejectedBody( 720 + initial.icon.rejectedReason ?? "(no reason given)", 721 + )} 722 + </span> 723 + </div> 724 + )} 691 725 <div class="profile-form-icon-row"> 692 726 <div class="profile-form-icon-preview" aria-hidden="true"> 693 727 {iconPreviewUrl.value
+204
islands/ReportProfileButton.tsx
··· 1 + import { useSignal } from "@preact/signals"; 2 + 3 + interface Props { 4 + /** Handle or DID of the profile being reported. The API accepts both. */ 5 + targetId: string; 6 + /** Whether the viewer is signed in (controls the modal copy). */ 7 + signedIn: boolean; 8 + copy: { 9 + button: string; 10 + modalTitle: string; 11 + modalBody: string; 12 + reasonLabel: string; 13 + detailsLabel: string; 14 + detailsPlaceholder: string; 15 + submit: string; 16 + submitting: string; 17 + cancel: string; 18 + sentTitle: string; 19 + sentBody: string; 20 + duplicate: string; 21 + error: string; 22 + reasons: Record< 23 + "not_a_project" | "harmful" | "impersonation" | "spam" | "other", 24 + string 25 + >; 26 + }; 27 + } 28 + 29 + const REASONS: Array<keyof Props["copy"]["reasons"]> = [ 30 + "not_a_project", 31 + "harmful", 32 + "impersonation", 33 + "spam", 34 + "other", 35 + ]; 36 + 37 + /** 38 + * Mounted on /explore/<handle>. Opens a modal where any visitor can 39 + * submit a moderation report against the profile. Stays in island form 40 + * because the modal + submission state needs interactivity, but the 41 + * trigger is a single small button so the JS payload is minimal. 42 + */ 43 + export default function ReportProfileButton({ targetId, copy }: Props) { 44 + const open = useSignal(false); 45 + const reason = useSignal<keyof Props["copy"]["reasons"]>("not_a_project"); 46 + const details = useSignal(""); 47 + const submitting = useSignal(false); 48 + const status = useSignal< 49 + | { kind: "idle" } 50 + | { kind: "ok" } 51 + | { kind: "error"; text: string } 52 + >({ kind: "idle" }); 53 + 54 + const reset = () => { 55 + reason.value = "not_a_project"; 56 + details.value = ""; 57 + status.value = { kind: "idle" }; 58 + }; 59 + 60 + const close = () => { 61 + open.value = false; 62 + reset(); 63 + }; 64 + 65 + const submit = async () => { 66 + submitting.value = true; 67 + try { 68 + const r = await fetch( 69 + `/api/registry/profile/${encodeURIComponent(targetId)}/report`, 70 + { 71 + method: "POST", 72 + headers: { "content-type": "application/json" }, 73 + body: JSON.stringify({ 74 + reason: reason.value, 75 + details: details.value.trim() || undefined, 76 + }), 77 + }, 78 + ); 79 + if (!r.ok) { 80 + const text = await r.text(); 81 + status.value = { kind: "error", text: text || copy.error }; 82 + return; 83 + } 84 + status.value = { kind: "ok" }; 85 + } catch (err) { 86 + status.value = { 87 + kind: "error", 88 + text: err instanceof Error ? err.message : copy.error, 89 + }; 90 + } finally { 91 + submitting.value = false; 92 + } 93 + }; 94 + 95 + return ( 96 + <> 97 + <div class="profile-report-row"> 98 + <button 99 + type="button" 100 + class="profile-report-button" 101 + onClick={() => { 102 + open.value = true; 103 + }} 104 + > 105 + {copy.button} 106 + </button> 107 + </div> 108 + 109 + {open.value && ( 110 + <div 111 + class="modal-backdrop" 112 + onClick={(e) => { 113 + if (e.target === e.currentTarget) close(); 114 + }} 115 + > 116 + <div class="modal-card"> 117 + <div class="modal-header"> 118 + <p class="modal-title">{copy.modalTitle}</p> 119 + <p class="modal-body-text">{copy.modalBody}</p> 120 + </div> 121 + 122 + {status.value.kind === "ok" 123 + ? ( 124 + <> 125 + <p class="report-modal-status report-modal-status--ok"> 126 + <strong>{copy.sentTitle}</strong> 127 + </p> 128 + <p class="modal-body-text">{copy.sentBody}</p> 129 + <div class="report-modal-actions" style={{ marginTop: "1rem" }}> 130 + <button 131 + type="button" 132 + class="profile-form-button-primary" 133 + onClick={close} 134 + > 135 + {copy.cancel} 136 + </button> 137 + </div> 138 + </> 139 + ) 140 + : ( 141 + <> 142 + <fieldset class="report-modal-fieldset"> 143 + <legend>{copy.reasonLabel}</legend> 144 + {REASONS.map((r) => ( 145 + <label key={r} class="report-modal-radio"> 146 + <input 147 + type="radio" 148 + name="report-reason" 149 + value={r} 150 + checked={reason.value === r} 151 + onChange={() => reason.value = r} 152 + /> 153 + {copy.reasons[r]} 154 + </label> 155 + ))} 156 + </fieldset> 157 + 158 + <label class="report-modal-radio" style={{ display: "block" }}> 159 + <span style={{ display: "block", marginBottom: "0.4rem" }}> 160 + {copy.detailsLabel} 161 + </span> 162 + <textarea 163 + class="report-modal-textarea" 164 + maxLength={500} 165 + placeholder={copy.detailsPlaceholder} 166 + value={details.value} 167 + onInput={(e) => 168 + details.value = 169 + (e.currentTarget as HTMLTextAreaElement).value} 170 + /> 171 + </label> 172 + 173 + {status.value.kind === "error" && ( 174 + <p class="report-modal-status report-modal-status--error"> 175 + {copy.error}: {status.value.text} 176 + </p> 177 + )} 178 + 179 + <div class="report-modal-actions" style={{ marginTop: "1rem" }}> 180 + <button 181 + type="button" 182 + class="profile-form-button-link" 183 + onClick={close} 184 + disabled={submitting.value} 185 + > 186 + {copy.cancel} 187 + </button> 188 + <button 189 + type="button" 190 + class="profile-form-button-primary" 191 + onClick={submit} 192 + disabled={submitting.value} 193 + > 194 + {submitting.value ? copy.submitting : copy.submit} 195 + </button> 196 + </div> 197 + </> 198 + )} 199 + </div> 200 + </div> 201 + )} 202 + </> 203 + ); 204 + }
+95
lib/admin.ts
··· 1 + /** 2 + * Admin allowlist + helpers. 3 + * 4 + * The list of admin DIDs is supplied via the `ADMIN_DIDS` env var 5 + * (comma-separated). All `/admin/*` pages are gated by 6 + * `routes/admin/_middleware.ts` which calls `requireAdmin`; admin API 7 + * routes under `/api/admin/*` should call `requireAdminApi` directly 8 + * because they need to return JSON 401/403 instead of an HTML redirect. 9 + * 10 + * Admins authenticate exactly like normal users (via the existing 11 + * atproto OAuth flow); admin status is purely a server-side allowlist 12 + * check on the resulting session DID. 13 + */ 14 + import { ADMIN_DIDS } from "./env.ts"; 15 + 16 + export function isAdmin(did: string | null | undefined): boolean { 17 + if (!did) return false; 18 + return ADMIN_DIDS.includes(did); 19 + } 20 + 21 + /** Are any admins configured at all? Useful for hiding admin links from 22 + * navigation in deployments that haven't opted in. */ 23 + export function adminConfigured(): boolean { 24 + return ADMIN_DIDS.length > 0; 25 + } 26 + 27 + interface AdminCtxLike { 28 + state: { user: { did: string } | null }; 29 + req: Request; 30 + } 31 + 32 + /** 33 + * Throws via redirect (302 → /oauth/login) when the request isn't from 34 + * an admin. Page routes should `await requireAdmin(ctx)` at the top of 35 + * their handler. Returns the verified admin DID on success. 36 + */ 37 + export function requireAdmin(ctx: AdminCtxLike): string { 38 + const user = ctx.state.user; 39 + if (!user) { 40 + throw redirectResponse(loginUrl(ctx.req.url)); 41 + } 42 + if (!isAdmin(user.did)) { 43 + throw notFoundResponse(); 44 + } 45 + return user.did; 46 + } 47 + 48 + /** 49 + * API-shaped admin gate. Returns `{ ok: false, response }` so handlers 50 + * can early-return; or `{ ok: true, did }` to proceed. 51 + */ 52 + export function requireAdminApi( 53 + ctx: AdminCtxLike, 54 + ): 55 + | { ok: true; did: string } 56 + | { ok: false; response: Response } { 57 + const user = ctx.state.user; 58 + if (!user) { 59 + return { 60 + ok: false, 61 + response: jsonResponse(401, { error: "not_authenticated" }), 62 + }; 63 + } 64 + if (!isAdmin(user.did)) { 65 + return { ok: false, response: jsonResponse(403, { error: "forbidden" }) }; 66 + } 67 + return { ok: true, did: user.did }; 68 + } 69 + 70 + function loginUrl(currentUrl: string): string { 71 + try { 72 + const url = new URL(currentUrl); 73 + const next = url.pathname + url.search; 74 + return `/oauth/login?next=${encodeURIComponent(next)}`; 75 + } catch { 76 + return "/oauth/login"; 77 + } 78 + } 79 + 80 + function redirectResponse(location: string): Response { 81 + return new Response(null, { status: 303, headers: { location } }); 82 + } 83 + 84 + /** Surface admin routes as 404 to anonymous/non-admin users so we don't 85 + * leak that the URL exists. (Logged-in non-admins also get 404.) */ 86 + function notFoundResponse(): Response { 87 + return new Response("not found", { status: 404 }); 88 + } 89 + 90 + function jsonResponse(status: number, body: unknown): Response { 91 + return new Response(JSON.stringify(body), { 92 + status, 93 + headers: { "content-type": "application/json; charset=utf-8" }, 94 + }); 95 + }
+77
lib/db.ts
··· 77 77 avatar_mime TEXT, 78 78 icon_cid TEXT, 79 79 icon_mime TEXT, 80 + icon_status TEXT, 81 + icon_reviewed_by TEXT, 82 + icon_reviewed_at INTEGER, 83 + icon_rejected_reason TEXT, 84 + takedown_status TEXT, 85 + takedown_reason TEXT, 86 + takedown_by TEXT, 87 + takedown_at INTEGER, 80 88 pds_url TEXT NOT NULL, 81 89 record_cid TEXT NOT NULL, 82 90 record_rev TEXT NOT NULL, ··· 134 142 created_at INTEGER NOT NULL, 135 143 expires_at INTEGER NOT NULL 136 144 )`, 145 + /** 146 + * User reports against profiles. Anonymous reports carry a hashed IP 147 + * for dedup + rate-limit; signed-in reports also record the 148 + * reporter's DID. Admin actions write `status`, `admin_notes`, 149 + * `resolved_at`, `resolved_by`. 150 + */ 151 + `CREATE TABLE IF NOT EXISTS report ( 152 + id INTEGER PRIMARY KEY AUTOINCREMENT, 153 + target_did TEXT NOT NULL, 154 + reporter_did TEXT, 155 + reporter_ip_hash TEXT, 156 + reason TEXT NOT NULL, 157 + details TEXT, 158 + status TEXT NOT NULL DEFAULT 'open', 159 + admin_notes TEXT, 160 + created_at INTEGER NOT NULL, 161 + resolved_at INTEGER, 162 + resolved_by TEXT 163 + )`, 164 + `CREATE INDEX IF NOT EXISTS report_status_target ON report(status, target_did)`, 165 + `CREATE INDEX IF NOT EXISTS report_dedup ON report(target_did, reporter_ip_hash, reason, created_at)`, 166 + /** 167 + * Hot-path index for excluding taken-down profiles from public reads. 168 + * The vast majority of rows have NULL `takedown_status`, so a partial 169 + * index would be ideal; SQLite supports `WHERE` on indexes only via 170 + * CREATE INDEX … WHERE, but the planner won't always pick it for 171 + * `IS NULL` predicates. Plain index covers both directions. 172 + */ 173 + `CREATE INDEX IF NOT EXISTS profile_takedown ON profile(takedown_status)`, 137 174 ]; 138 175 139 176 /** ··· 170 207 table: "profile", 171 208 column: "icon_mime", 172 209 ddl: "ALTER TABLE profile ADD COLUMN icon_mime TEXT", 210 + }, 211 + { 212 + table: "profile", 213 + column: "icon_status", 214 + ddl: "ALTER TABLE profile ADD COLUMN icon_status TEXT", 215 + }, 216 + { 217 + table: "profile", 218 + column: "icon_reviewed_by", 219 + ddl: "ALTER TABLE profile ADD COLUMN icon_reviewed_by TEXT", 220 + }, 221 + { 222 + table: "profile", 223 + column: "icon_reviewed_at", 224 + ddl: "ALTER TABLE profile ADD COLUMN icon_reviewed_at INTEGER", 225 + }, 226 + { 227 + table: "profile", 228 + column: "icon_rejected_reason", 229 + ddl: "ALTER TABLE profile ADD COLUMN icon_rejected_reason TEXT", 230 + }, 231 + { 232 + table: "profile", 233 + column: "takedown_status", 234 + ddl: "ALTER TABLE profile ADD COLUMN takedown_status TEXT", 235 + }, 236 + { 237 + table: "profile", 238 + column: "takedown_reason", 239 + ddl: "ALTER TABLE profile ADD COLUMN takedown_reason TEXT", 240 + }, 241 + { 242 + table: "profile", 243 + column: "takedown_by", 244 + ddl: "ALTER TABLE profile ADD COLUMN takedown_by TEXT", 245 + }, 246 + { 247 + table: "profile", 248 + column: "takedown_at", 249 + ddl: "ALTER TABLE profile ADD COLUMN takedown_at INTEGER", 173 250 }, 174 251 ]; 175 252 for (const m of additiveColumns) {
+21
lib/env.ts
··· 64 64 65 65 export const ATMOSPHERE_DID = safeGet("ATMOSPHERE_DID"); 66 66 67 + /** 68 + * Comma-separated list of DIDs allowed to access /admin and the 69 + * /api/admin/* endpoints. The signed-in OAuth session DID must match 70 + * one of these for admin middleware to let the request through. If 71 + * unset, /admin is effectively unreachable (good default for forks). 72 + */ 73 + export const ADMIN_DIDS: string[] = (safeGet("ADMIN_DIDS") ?? "") 74 + .split(",") 75 + .map((s) => s.trim()) 76 + .filter((s) => s.length > 0); 77 + 78 + /** 79 + * Server-side secret used to hash reporter IPs before storing them. 80 + * The hashes are used for 24h dedup + soft rate-limit only — they 81 + * never leave the server. Falls back to `SESSION_SECRET` so deployments 82 + * still get *some* salt instead of an empty key, but operators should 83 + * set a dedicated value so rotating one secret doesn't break the 84 + * other. 85 + */ 86 + export const REPORT_IP_SECRET = safeGet("REPORT_IP_SECRET") ?? SESSION_SECRET; 87 + 67 88 export const TURSO_DATABASE_URL = safeGet("TURSO_DATABASE_URL"); 68 89 export const TURSO_AUTH_TOKEN = safeGet("TURSO_AUTH_TOKEN"); 69 90
+346 -10
lib/registry.ts
··· 6 6 import { withDb } from "./db.ts"; 7 7 import type { FeaturedBadge, LinkEntry } from "./lexicons.ts"; 8 8 9 + /** 10 + * Approval state of the developer-facing SVG icon. 11 + * 12 + * - `pending` — uploaded but not yet reviewed; not served publicly. 13 + * - `approved` — visible via /api/registry/icon/:did + iconUrl. 14 + * - `rejected` — admin rejected; reason kept in `iconRejectedReason`. 15 + * 16 + * `null` means the profile has no icon at all. 17 + */ 18 + export type IconStatus = "pending" | "approved" | "rejected"; 19 + 20 + /** 21 + * Moderation state for the *whole profile*. Distinct from icon status — 22 + * a takedown removes the profile from public reads (search, /explore, 23 + * /api/registry/*) regardless of icon state. The user's PDS record is 24 + * untouched; only this AppView refuses to serve it. 25 + * 26 + * - `null` — live and visible. 27 + * - `taken_down` — admin removed it; reason in `takedownReason`. 28 + */ 29 + export type TakedownStatus = "taken_down"; 30 + 9 31 export interface ProfileRow { 10 32 did: string; 11 33 handle: string; ··· 19 41 links: LinkEntry[]; 20 42 avatarCid: string | null; 21 43 avatarMime: string | null; 22 - /** Optional developer-facing SVG icon. Not rendered on public profile. */ 44 + /** Optional developer-facing SVG icon. Not rendered on public profile. 45 + * Approval state lives in `iconStatus`. */ 23 46 iconCid: string | null; 24 47 iconMime: string | null; 48 + iconStatus: IconStatus | null; 49 + iconReviewedBy: string | null; 50 + iconReviewedAt: number | null; 51 + iconRejectedReason: string | null; 52 + /** Profile-level takedown state. `null` means live. */ 53 + takedownStatus: TakedownStatus | null; 54 + takedownReason: string | null; 55 + takedownBy: string | null; 56 + takedownAt: number | null; 25 57 pdsUrl: string; 26 58 recordCid: string; 27 59 recordRev: string; ··· 46 78 avatar_mime: string | null; 47 79 icon_cid: string | null; 48 80 icon_mime: string | null; 81 + icon_status: string | null; 82 + icon_reviewed_by: string | null; 83 + icon_reviewed_at: number | null; 84 + icon_rejected_reason: string | null; 85 + takedown_status: string | null; 86 + takedown_reason: string | null; 87 + takedown_by: string | null; 88 + takedown_at: number | null; 49 89 pds_url: string; 50 90 record_cid: string; 51 91 record_rev: string; ··· 87 127 } 88 128 } 89 129 130 + function normalizeIconStatus(v: string | null): IconStatus | null { 131 + if (v === "pending" || v === "approved" || v === "rejected") return v; 132 + return null; 133 + } 134 + 135 + function normalizeTakedownStatus(v: string | null): TakedownStatus | null { 136 + return v === "taken_down" ? "taken_down" : null; 137 + } 138 + 90 139 function rowToProfile(r: RawProfileRow): ProfileRow { 91 140 const out: ProfileRow = { 92 141 did: r.did, ··· 100 149 avatarMime: r.avatar_mime, 101 150 iconCid: r.icon_cid, 102 151 iconMime: r.icon_mime, 152 + iconStatus: normalizeIconStatus(r.icon_status), 153 + iconReviewedBy: r.icon_reviewed_by, 154 + iconReviewedAt: r.icon_reviewed_at != null 155 + ? Number(r.icon_reviewed_at) 156 + : null, 157 + iconRejectedReason: r.icon_rejected_reason, 158 + takedownStatus: normalizeTakedownStatus(r.takedown_status), 159 + takedownReason: r.takedown_reason, 160 + takedownBy: r.takedown_by, 161 + takedownAt: r.takedown_at != null ? Number(r.takedown_at) : null, 103 162 pdsUrl: r.pds_url, 104 163 recordCid: r.record_cid, 105 164 recordRev: r.record_rev, ··· 154 213 if (cats.length === 0) { 155 214 throw new Error("upsertProfile: categories[] is required and non-empty"); 156 215 } 216 + /** 217 + * Initial icon_status for the INSERT branch (and for new icons on 218 + * existing rows): `pending` if there's an icon, NULL otherwise. 219 + * The ON CONFLICT branch below uses CASE statements to preserve any 220 + * existing approval state when the CID hasn't changed. 221 + */ 222 + const initialIconStatus = input.iconCid ? "pending" : null; 157 223 await withDb(async (c) => { 158 224 await c.execute({ 159 225 sql: ` 160 226 INSERT INTO profile ( 161 227 did, handle, name, description, categories, subcategories, links, 162 - avatar_cid, avatar_mime, icon_cid, icon_mime, pds_url, record_cid, 163 - record_rev, created_at, indexed_at 164 - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 228 + avatar_cid, avatar_mime, icon_cid, icon_mime, icon_status, 229 + icon_reviewed_by, icon_reviewed_at, icon_rejected_reason, 230 + takedown_status, takedown_reason, takedown_by, takedown_at, 231 + pds_url, record_cid, record_rev, created_at, indexed_at 232 + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, NULL, NULL, NULL, NULL, NULL, ?, ?, ?, ?, ?) 165 233 ON CONFLICT(did) DO UPDATE SET 166 234 handle=excluded.handle, 167 235 name=excluded.name, ··· 173 241 avatar_mime=excluded.avatar_mime, 174 242 icon_cid=excluded.icon_cid, 175 243 icon_mime=excluded.icon_mime, 244 + icon_status = CASE 245 + WHEN excluded.icon_cid IS NULL THEN NULL 246 + WHEN profile.icon_cid IS NOT NULL AND profile.icon_cid = excluded.icon_cid THEN profile.icon_status 247 + ELSE 'pending' 248 + END, 249 + icon_reviewed_by = CASE 250 + WHEN excluded.icon_cid IS NULL THEN NULL 251 + WHEN profile.icon_cid IS NOT NULL AND profile.icon_cid = excluded.icon_cid THEN profile.icon_reviewed_by 252 + ELSE NULL 253 + END, 254 + icon_reviewed_at = CASE 255 + WHEN excluded.icon_cid IS NULL THEN NULL 256 + WHEN profile.icon_cid IS NOT NULL AND profile.icon_cid = excluded.icon_cid THEN profile.icon_reviewed_at 257 + ELSE NULL 258 + END, 259 + icon_rejected_reason = CASE 260 + WHEN excluded.icon_cid IS NULL THEN NULL 261 + WHEN profile.icon_cid IS NOT NULL AND profile.icon_cid = excluded.icon_cid THEN profile.icon_rejected_reason 262 + ELSE NULL 263 + END, 264 + /** 265 + * Takedown columns are admin-only state and must survive any 266 + * firehose-driven re-upsert. We never overwrite them from the 267 + * INSERT branch's NULLs — they're only ever cleared explicitly 268 + * by restoreProfile() (or by the user deleting their record, 269 + * which DELETEs the row entirely). 270 + */ 271 + takedown_status = profile.takedown_status, 272 + takedown_reason = profile.takedown_reason, 273 + takedown_by = profile.takedown_by, 274 + takedown_at = profile.takedown_at, 176 275 pds_url=excluded.pds_url, 177 276 record_cid=excluded.record_cid, 178 277 record_rev=excluded.record_rev, ··· 191 290 input.avatarMime ?? null, 192 291 input.iconCid ?? null, 193 292 input.iconMime ?? null, 293 + initialIconStatus, 194 294 input.pdsUrl, 195 295 input.recordCid, 196 296 input.recordRev, ··· 201 301 }); 202 302 } 203 303 304 + /* -------------------------------------------------------------------------- * 305 + * Icon moderation * 306 + * -------------------------------------------------------------------------- */ 307 + 308 + export interface PendingIconRow { 309 + did: string; 310 + handle: string; 311 + name: string; 312 + iconCid: string; 313 + iconMime: string; 314 + indexedAt: number; 315 + } 316 + 317 + /** Profiles awaiting SVG icon approval, oldest first (FIFO review queue). */ 318 + export async function listPendingIcons(): Promise<PendingIconRow[]> { 319 + return await withDb(async (c) => { 320 + const r = await c.execute(` 321 + SELECT did, handle, name, icon_cid, icon_mime, indexed_at 322 + FROM profile 323 + WHERE icon_status = 'pending' AND icon_cid IS NOT NULL 324 + ORDER BY indexed_at ASC 325 + `); 326 + return r.rows.map((row) => { 327 + const x = row as unknown as { 328 + did: string; 329 + handle: string; 330 + name: string; 331 + icon_cid: string; 332 + icon_mime: string; 333 + indexed_at: number; 334 + }; 335 + return { 336 + did: x.did, 337 + handle: x.handle, 338 + name: x.name, 339 + iconCid: x.icon_cid, 340 + iconMime: x.icon_mime, 341 + indexedAt: Number(x.indexed_at), 342 + }; 343 + }); 344 + }); 345 + } 346 + 347 + export async function countPendingIcons(): Promise<number> { 348 + return await withDb(async (c) => { 349 + const r = await c.execute( 350 + `SELECT COUNT(*) AS n FROM profile WHERE icon_status = 'pending' AND icon_cid IS NOT NULL`, 351 + ); 352 + return Number((r.rows[0] as Record<string, unknown>).n ?? 0); 353 + }); 354 + } 355 + 356 + export async function approveIcon( 357 + did: string, 358 + reviewer: string, 359 + ): Promise<void> { 360 + await withDb(async (c) => { 361 + await c.execute({ 362 + sql: ` 363 + UPDATE profile SET 364 + icon_status = 'approved', 365 + icon_reviewed_by = ?, 366 + icon_reviewed_at = ?, 367 + icon_rejected_reason = NULL 368 + WHERE did = ? AND icon_cid IS NOT NULL 369 + `, 370 + args: [reviewer, Date.now(), did], 371 + }); 372 + }); 373 + } 374 + 375 + export async function rejectIcon( 376 + did: string, 377 + reviewer: string, 378 + reason: string, 379 + ): Promise<void> { 380 + await withDb(async (c) => { 381 + await c.execute({ 382 + sql: ` 383 + UPDATE profile SET 384 + icon_status = 'rejected', 385 + icon_reviewed_by = ?, 386 + icon_reviewed_at = ?, 387 + icon_rejected_reason = ? 388 + WHERE did = ? AND icon_cid IS NOT NULL 389 + `, 390 + args: [reviewer, Date.now(), reason.slice(0, 500), did], 391 + }); 392 + }); 393 + } 394 + 204 395 export async function deleteProfile(did: string): Promise<void> { 205 396 await withDb(async (c) => { 206 397 await c.execute({ sql: `DELETE FROM profile WHERE did = ?`, args: [did] }); 207 398 }); 208 399 } 209 400 401 + /* -------------------------------------------------------------------------- * 402 + * Profile-level moderation (takedowns) * 403 + * -------------------------------------------------------------------------- */ 404 + 405 + export interface TakenDownProfileRow { 406 + did: string; 407 + handle: string; 408 + name: string; 409 + takedownReason: string; 410 + takedownBy: string; 411 + takedownAt: number; 412 + } 413 + 414 + /** 415 + * Mark a profile as taken down. The row stays in the DB (so the 416 + * indexer can preserve the takedown across firehose updates), but 417 + * default-filtered read paths exclude it. Idempotent — re-applying 418 + * just refreshes the reason/by/at fields. 419 + */ 420 + export async function takedownProfile( 421 + did: string, 422 + reason: string, 423 + by: string, 424 + ): Promise<void> { 425 + await withDb(async (c) => { 426 + await c.execute({ 427 + sql: ` 428 + UPDATE profile SET 429 + takedown_status = 'taken_down', 430 + takedown_reason = ?, 431 + takedown_by = ?, 432 + takedown_at = ? 433 + WHERE did = ? 434 + `, 435 + args: [reason.slice(0, 500), by, Date.now(), did], 436 + }); 437 + }); 438 + } 439 + 440 + /** Reverse a takedown — clears all four columns. */ 441 + export async function restoreProfile(did: string): Promise<void> { 442 + await withDb(async (c) => { 443 + await c.execute({ 444 + sql: ` 445 + UPDATE profile SET 446 + takedown_status = NULL, 447 + takedown_reason = NULL, 448 + takedown_by = NULL, 449 + takedown_at = NULL 450 + WHERE did = ? 451 + `, 452 + args: [did], 453 + }); 454 + }); 455 + } 456 + 457 + export async function listTakenDownProfiles(): Promise<TakenDownProfileRow[]> { 458 + return await withDb(async (c) => { 459 + const r = await c.execute(` 460 + SELECT did, handle, name, takedown_reason, takedown_by, takedown_at 461 + FROM profile 462 + WHERE takedown_status = 'taken_down' 463 + ORDER BY takedown_at DESC 464 + `); 465 + return r.rows.map((row) => { 466 + const x = row as unknown as { 467 + did: string; 468 + handle: string; 469 + name: string; 470 + takedown_reason: string | null; 471 + takedown_by: string | null; 472 + takedown_at: number | null; 473 + }; 474 + return { 475 + did: x.did, 476 + handle: x.handle, 477 + name: x.name, 478 + takedownReason: x.takedown_reason ?? "", 479 + takedownBy: x.takedown_by ?? "", 480 + takedownAt: x.takedown_at != null ? Number(x.takedown_at) : 0, 481 + }; 482 + }); 483 + }); 484 + } 485 + 486 + export async function countTakenDownProfiles(): Promise<number> { 487 + return await withDb(async (c) => { 488 + const r = await c.execute( 489 + `SELECT COUNT(*) AS n FROM profile WHERE takedown_status = 'taken_down'`, 490 + ); 491 + return Number((r.rows[0] as Record<string, unknown>).n ?? 0); 492 + }); 493 + } 494 + 210 495 const SELECT_PROFILE = ` 211 496 SELECT p.*, 212 497 f.badges AS featured_badges, ··· 215 500 LEFT JOIN featured f ON f.did = p.did 216 501 `; 217 502 218 - export async function getProfileByDid(did: string): Promise<ProfileRow | null> { 503 + /** 504 + * Public read paths default to hiding taken-down profiles. Pass 505 + * `includeTakenDown: true` from owner-aware UI (e.g. /explore/manage) 506 + * and admin tooling that needs to inspect or restore the row. 507 + */ 508 + export interface ProfileLookupOptions { 509 + includeTakenDown?: boolean; 510 + } 511 + 512 + export async function getProfileByDid( 513 + did: string, 514 + opts: ProfileLookupOptions = {}, 515 + ): Promise<ProfileRow | null> { 516 + const where = opts.includeTakenDown 517 + ? `WHERE p.did = ?` 518 + : `WHERE p.did = ? AND p.takedown_status IS NULL`; 219 519 return await withDb(async (c) => { 220 520 const r = await c.execute({ 221 - sql: `${SELECT_PROFILE} WHERE p.did = ? LIMIT 1`, 521 + sql: `${SELECT_PROFILE} ${where} LIMIT 1`, 222 522 args: [did], 223 523 }); 224 524 if (r.rows.length === 0) return null; ··· 228 528 229 529 export async function getProfileByHandle( 230 530 handle: string, 531 + opts: ProfileLookupOptions = {}, 231 532 ): Promise<ProfileRow | null> { 533 + const where = opts.includeTakenDown 534 + ? `WHERE p.handle = ?` 535 + : `WHERE p.handle = ? AND p.takedown_status IS NULL`; 232 536 return await withDb(async (c) => { 233 537 const r = await c.execute({ 234 - sql: `${SELECT_PROFILE} WHERE p.handle = ? LIMIT 1`, 538 + sql: `${SELECT_PROFILE} ${where} LIMIT 1`, 235 539 args: [handle], 236 540 }); 237 541 if (r.rows.length === 0) return null; ··· 261 565 const pageSize = Math.min(48, Math.max(1, opts.pageSize ?? 24)); 262 566 const offset = (page - 1) * pageSize; 263 567 264 - const where: string[] = []; 568 + /** 569 + * Default-exclude taken-down profiles. There is no opt-in path for 570 + * search because surfacing taken-down rows in /explore would defeat 571 + * the purpose of the takedown; admin tooling reads via 572 + * `listTakenDownProfiles` instead. 573 + */ 574 + const where: string[] = ["p.takedown_status IS NULL"]; 265 575 const args: InValue[] = []; 266 576 267 577 if (opts.query && opts.query.trim()) { ··· 284 594 args.push(opts.subcategory); 285 595 } 286 596 287 - const whereClause = where.length ? `WHERE ${where.join(" AND ")}` : ""; 597 + const whereClause = `WHERE ${where.join(" AND ")}`; 288 598 289 599 return await withDb(async (c) => { 290 600 const countRes = await c.execute({ ··· 317 627 }); 318 628 } 319 629 630 + /** 631 + * Lightweight projection of every profile in the registry, used by 632 + * admin curation UIs (featured picker, etc.). Skips JSON fields that 633 + * the picker doesn't need so the payload stays small even with 634 + * hundreds of entries. 635 + */ 636 + export interface ProfilePickerRow { 637 + did: string; 638 + handle: string; 639 + name: string; 640 + } 641 + 642 + export async function listAllProfilesForPicker(): Promise<ProfilePickerRow[]> { 643 + return await withDb(async (c) => { 644 + const r = await c.execute( 645 + `SELECT did, handle, name FROM profile 646 + WHERE takedown_status IS NULL 647 + ORDER BY handle ASC`, 648 + ); 649 + return r.rows.map((row) => { 650 + const x = row as unknown as { did: string; handle: string; name: string }; 651 + return { did: x.did, handle: x.handle, name: x.name }; 652 + }); 653 + }); 654 + } 655 + 320 656 export async function listFeaturedProfiles(limit = 12): Promise<ProfileRow[]> { 321 657 return await withDb(async (c) => { 322 658 const r = await c.execute({ 323 659 sql: ` 324 660 ${SELECT_PROFILE} 325 - WHERE f.did IS NOT NULL 661 + WHERE f.did IS NOT NULL AND p.takedown_status IS NULL 326 662 ORDER BY COALESCE(f.position, 999999) ASC, p.indexed_at DESC 327 663 LIMIT ? 328 664 `,
+216
lib/reports.ts
··· 1 + /** 2 + * User-submitted reports against registry profiles. Backed by the 3 + * `report` table; admin UI lives at /admin/reports, public submission 4 + * at POST /api/registry/profile/:id/report. 5 + * 6 + * IPs are hashed with `REPORT_IP_SECRET` so we can dedup repeated 7 + * submissions from the same source within 24h without ever storing the 8 + * raw address. Authenticated reports additionally record the 9 + * reporter's DID. 10 + */ 11 + import { withDb } from "./db.ts"; 12 + import { REPORT_IP_SECRET } from "./env.ts"; 13 + 14 + export const REPORT_REASONS = [ 15 + "not_a_project", 16 + "harmful", 17 + "impersonation", 18 + "spam", 19 + "other", 20 + ] as const; 21 + export type ReportReason = typeof REPORT_REASONS[number]; 22 + 23 + export type ReportStatus = "open" | "actioned" | "dismissed"; 24 + 25 + export interface ReportRow { 26 + id: number; 27 + targetDid: string; 28 + reporterDid: string | null; 29 + reason: ReportReason; 30 + details: string | null; 31 + status: ReportStatus; 32 + adminNotes: string | null; 33 + createdAt: number; 34 + resolvedAt: number | null; 35 + resolvedBy: string | null; 36 + } 37 + 38 + interface RawReportRow { 39 + id: number; 40 + target_did: string; 41 + reporter_did: string | null; 42 + reason: string; 43 + details: string | null; 44 + status: string; 45 + admin_notes: string | null; 46 + created_at: number; 47 + resolved_at: number | null; 48 + resolved_by: string | null; 49 + } 50 + 51 + function rowToReport(r: RawReportRow): ReportRow { 52 + const reason = (REPORT_REASONS as readonly string[]).includes(r.reason) 53 + ? r.reason as ReportReason 54 + : "other"; 55 + const status = r.status === "actioned" || r.status === "dismissed" 56 + ? r.status 57 + : "open"; 58 + return { 59 + id: Number(r.id), 60 + targetDid: r.target_did, 61 + reporterDid: r.reporter_did, 62 + reason, 63 + details: r.details, 64 + status, 65 + adminNotes: r.admin_notes, 66 + createdAt: Number(r.created_at), 67 + resolvedAt: r.resolved_at != null ? Number(r.resolved_at) : null, 68 + resolvedBy: r.resolved_by, 69 + }; 70 + } 71 + 72 + const DEDUP_WINDOW_MS = 24 * 60 * 60 * 1000; 73 + 74 + /** Best-effort caller IP — same approach as `lib/rate-limit.ts`. */ 75 + export function callerIp(req: Request): string { 76 + const xff = req.headers.get("x-forwarded-for"); 77 + if (xff) { 78 + const first = xff.split(",")[0]?.trim(); 79 + if (first) return first; 80 + } 81 + const real = req.headers.get("x-real-ip"); 82 + if (real) return real.trim(); 83 + return "anonymous"; 84 + } 85 + 86 + export async function hashIp(ip: string): Promise<string> { 87 + const data = new TextEncoder().encode(`${ip}|${REPORT_IP_SECRET}`); 88 + const buf = await crypto.subtle.digest("SHA-256", data); 89 + const bytes = new Uint8Array(buf); 90 + let hex = ""; 91 + for (let i = 0; i < bytes.length; i++) { 92 + hex += bytes[i].toString(16).padStart(2, "0"); 93 + } 94 + return hex; 95 + } 96 + 97 + export interface CreateReportInput { 98 + targetDid: string; 99 + reporterDid: string | null; 100 + ipHash: string | null; 101 + reason: ReportReason; 102 + details?: string | null; 103 + } 104 + 105 + export type CreateReportResult = 106 + | { ok: true; id: number } 107 + | { ok: false; reason: "duplicate" }; 108 + 109 + export async function createReport( 110 + input: CreateReportInput, 111 + ): Promise<CreateReportResult> { 112 + return await withDb(async (c) => { 113 + if (input.ipHash) { 114 + const since = Date.now() - DEDUP_WINDOW_MS; 115 + const dup = await c.execute({ 116 + sql: ` 117 + SELECT id FROM report 118 + WHERE target_did = ? AND reporter_ip_hash = ? AND reason = ? AND created_at >= ? 119 + LIMIT 1 120 + `, 121 + args: [input.targetDid, input.ipHash, input.reason, since], 122 + }); 123 + if (dup.rows.length > 0) { 124 + return { ok: false, reason: "duplicate" }; 125 + } 126 + } 127 + const r = await c.execute({ 128 + sql: ` 129 + INSERT INTO report ( 130 + target_did, reporter_did, reporter_ip_hash, reason, details, 131 + status, created_at 132 + ) VALUES (?, ?, ?, ?, ?, 'open', ?) 133 + `, 134 + args: [ 135 + input.targetDid, 136 + input.reporterDid, 137 + input.ipHash, 138 + input.reason, 139 + input.details ?? null, 140 + Date.now(), 141 + ], 142 + }); 143 + const id = Number(r.lastInsertRowid ?? 0); 144 + return { ok: true, id }; 145 + }); 146 + } 147 + 148 + export async function listOpenReports(): Promise<ReportRow[]> { 149 + return await withDb(async (c) => { 150 + const r = await c.execute(` 151 + SELECT id, target_did, reporter_did, reason, details, status, 152 + admin_notes, created_at, resolved_at, resolved_by 153 + FROM report 154 + WHERE status = 'open' 155 + ORDER BY created_at ASC 156 + `); 157 + return r.rows.map((row) => rowToReport(row as unknown as RawReportRow)); 158 + }); 159 + } 160 + 161 + export async function countOpenReports(): Promise<number> { 162 + return await withDb(async (c) => { 163 + const r = await c.execute( 164 + `SELECT COUNT(*) AS n FROM report WHERE status = 'open'`, 165 + ); 166 + return Number((r.rows[0] as Record<string, unknown>).n ?? 0); 167 + }); 168 + } 169 + 170 + export async function resolveReport( 171 + id: number, 172 + resolver: string, 173 + status: "actioned" | "dismissed", 174 + notes?: string | null, 175 + ): Promise<void> { 176 + await withDb(async (c) => { 177 + await c.execute({ 178 + sql: ` 179 + UPDATE report SET 180 + status = ?, 181 + admin_notes = ?, 182 + resolved_at = ?, 183 + resolved_by = ? 184 + WHERE id = ? 185 + `, 186 + args: [status, notes ?? null, Date.now(), resolver, id], 187 + }); 188 + }); 189 + } 190 + 191 + /** 192 + * Bulk-resolve every open report against a single target. Used when an 193 + * admin takes a profile down — reports are then "actioned" implicitly 194 + * by the takedown itself, so leaving them in the inbox would be noise. 195 + * Returns the number of rows updated for logging / UI feedback. 196 + */ 197 + export async function resolveOpenReportsForTarget( 198 + targetDid: string, 199 + resolver: string, 200 + notes?: string | null, 201 + ): Promise<number> { 202 + return await withDb(async (c) => { 203 + const r = await c.execute({ 204 + sql: ` 205 + UPDATE report SET 206 + status = 'actioned', 207 + admin_notes = ?, 208 + resolved_at = ?, 209 + resolved_by = ? 210 + WHERE target_did = ? AND status = 'open' 211 + `, 212 + args: [notes ?? null, Date.now(), resolver, targetDid], 213 + }); 214 + return Number(r.rowsAffected ?? 0); 215 + }); 216 + }
+27
routes/admin/_middleware.ts
··· 1 + /** 2 + * Gate every /admin/* route on the ADMIN_DIDS allowlist. 3 + * 4 + * Non-admin and signed-out callers see the same 404 we'd render for a 5 + * truly missing path, so we don't leak that the section exists. 6 + * 7 + * `/api/admin/*` lives under `/api`, not `/admin`, so it goes through 8 + * its own JSON-shaped gate (`requireAdminApi`). 9 + */ 10 + import { define } from "../../utils.ts"; 11 + import { isAdmin } from "../../lib/admin.ts"; 12 + 13 + export const handler = define.middleware((ctx) => { 14 + const user = ctx.state.user; 15 + if (!user) { 16 + const url = new URL(ctx.req.url); 17 + const next = url.pathname + url.search; 18 + return new Response(null, { 19 + status: 303, 20 + headers: { location: `/oauth/login?next=${encodeURIComponent(next)}` }, 21 + }); 22 + } 23 + if (!isAdmin(user.did)) { 24 + return new Response("not found", { status: 404 }); 25 + } 26 + return ctx.next(); 27 + });
+103
routes/admin/featured.tsx
··· 1 + /** 2 + * Admin: curate the featured rail. Mounts an island that owns the 3 + * checkbox/reorder/badge UI and calls POST /api/admin/featured to 4 + * publish the result via the curator's OAuth session. 5 + */ 6 + import { define } from "../../utils.ts"; 7 + import Nav from "../../components/Nav.tsx"; 8 + import GlassClouds from "../../components/GlassClouds.tsx"; 9 + import Footer from "../../components/Footer.tsx"; 10 + import AdminFeaturedEditor, { 11 + type FeaturedCandidate, 12 + type FeaturedEntryDraft, 13 + } from "../../islands/AdminFeaturedEditor.tsx"; 14 + import { getMessages } from "../../i18n/mod.ts"; 15 + import type { Locale } from "../../i18n/mod.ts"; 16 + import { 17 + listAllProfilesForPicker, 18 + listFeaturedProfiles, 19 + type ProfilePickerRow, 20 + type ProfileRow, 21 + } from "../../lib/registry.ts"; 22 + 23 + export const handler = define.handlers({ 24 + async GET(ctx) { 25 + const [candidates, featured] = await Promise.all([ 26 + listAllProfilesForPicker().catch(() => [] as ProfilePickerRow[]), 27 + listFeaturedProfiles(48).catch(() => [] as ProfileRow[]), 28 + ]); 29 + const initial: FeaturedEntryDraft[] = featured.map((p) => ({ 30 + did: p.did, 31 + badges: (p.featured?.badges ?? []) as string[], 32 + })); 33 + return ctx.render( 34 + <AdminFeaturedPage 35 + user={ctx.state.user!} 36 + candidates={candidates} 37 + initial={initial} 38 + locale={ctx.state.locale} 39 + />, 40 + ); 41 + }, 42 + }); 43 + 44 + interface PageProps { 45 + user: { did: string; handle: string }; 46 + candidates: FeaturedCandidate[]; 47 + initial: FeaturedEntryDraft[]; 48 + locale: Locale; 49 + } 50 + 51 + function AdminFeaturedPage({ user, candidates, initial, locale }: PageProps) { 52 + const t = getMessages(locale).admin; 53 + return ( 54 + <div id="page-top"> 55 + <GlassClouds /> 56 + <div class="content-layer"> 57 + <Nav 58 + account={{ 59 + user: { did: user.did, handle: user.handle }, 60 + avatarUrl: "/api/me/avatar", 61 + publicProfileHandle: null, 62 + }} 63 + /> 64 + <section class="admin-section"> 65 + <div class="container" style={{ maxWidth: "1080px" }}> 66 + <p> 67 + <a href="/admin" class="text-link-button"> 68 + ← {t.backToOverview} 69 + </a> 70 + </p> 71 + <header class="admin-header" style={{ marginTop: "0.75rem" }}> 72 + <h1 class="text-section">{t.featured.headline}</h1> 73 + <p class="text-body mt-2">{t.featured.subhead}</p> 74 + </header> 75 + 76 + <AdminFeaturedEditor 77 + candidates={candidates} 78 + initial={initial} 79 + copy={{ 80 + saveAndPublish: t.featured.saveAndPublish, 81 + saving: t.featured.saving, 82 + saved: t.featured.saved, 83 + filterPlaceholder: t.featured.filterPlaceholder, 84 + featuredHeading: t.featured.featuredHeading, 85 + candidatesHeading: t.featured.candidatesHeading, 86 + empty: t.featured.empty, 87 + moveUp: t.featured.moveUp, 88 + moveDown: t.featured.moveDown, 89 + remove: t.featured.remove, 90 + add: t.featured.add, 91 + badgesLabel: t.featured.badgesLabel, 92 + badgeVerified: t.featured.badgeVerified, 93 + badgeOfficial: t.featured.badgeOfficial, 94 + error: t.errorPrefix, 95 + }} 96 + /> 97 + </div> 98 + </section> 99 + <Footer variant="compact" /> 100 + </div> 101 + </div> 102 + ); 103 + }
+100
routes/admin/icons.tsx
··· 1 + /** 2 + * Admin: pending SVG icon review queue. Server-renders one row per 3 + * pending project + an `AdminIconReview` island that owns the 4 + * approve / reject buttons. 5 + */ 6 + import { define } from "../../utils.ts"; 7 + import Nav from "../../components/Nav.tsx"; 8 + import GlassClouds from "../../components/GlassClouds.tsx"; 9 + import Footer from "../../components/Footer.tsx"; 10 + import AdminIconReview from "../../islands/AdminIconReview.tsx"; 11 + import { getMessages } from "../../i18n/mod.ts"; 12 + import type { Locale } from "../../i18n/mod.ts"; 13 + import { listPendingIcons, type PendingIconRow } from "../../lib/registry.ts"; 14 + 15 + export const handler = define.handlers({ 16 + async GET(ctx) { 17 + const queue = await listPendingIcons().catch(() => [] as PendingIconRow[]); 18 + return ctx.render( 19 + <AdminIconsPage 20 + user={ctx.state.user!} 21 + queue={queue} 22 + locale={ctx.state.locale} 23 + />, 24 + ); 25 + }, 26 + }); 27 + 28 + interface PageProps { 29 + user: { did: string; handle: string }; 30 + queue: PendingIconRow[]; 31 + locale: Locale; 32 + } 33 + 34 + function AdminIconsPage({ user, queue, locale }: PageProps) { 35 + const t = getMessages(locale).admin; 36 + return ( 37 + <div id="page-top"> 38 + <GlassClouds /> 39 + <div class="content-layer"> 40 + <Nav 41 + account={{ 42 + user: { did: user.did, handle: user.handle }, 43 + avatarUrl: "/api/me/avatar", 44 + publicProfileHandle: null, 45 + }} 46 + /> 47 + <section class="admin-section"> 48 + <div class="container" style={{ maxWidth: "920px" }}> 49 + <p> 50 + <a href="/admin" class="text-link-button"> 51 + ← {t.backToOverview} 52 + </a> 53 + </p> 54 + <header class="admin-header" style={{ marginTop: "0.75rem" }}> 55 + <h1 class="text-section">{t.icons.headline}</h1> 56 + <p class="text-body mt-2">{t.icons.subhead}</p> 57 + </header> 58 + 59 + {queue.length === 0 60 + ? ( 61 + <p class="text-body admin-empty"> 62 + {t.icons.empty} 63 + </p> 64 + ) 65 + : ( 66 + <div class="admin-icon-list"> 67 + {queue.map((row) => ( 68 + <AdminIconReview 69 + key={row.did} 70 + did={row.did} 71 + handle={row.handle} 72 + name={row.name} 73 + previewUrl={`/api/admin/icons/${ 74 + encodeURIComponent(row.did) 75 + }/preview`} 76 + uploadedAt={row.indexedAt} 77 + copy={{ 78 + approve: t.icons.approve, 79 + reject: t.icons.reject, 80 + rejectReasonPlaceholder: 81 + t.icons.rejectReasonPlaceholder, 82 + confirmReject: t.icons.confirmReject, 83 + submit: t.icons.submitReject, 84 + cancel: t.icons.cancel, 85 + pending: t.statusBadge.pending, 86 + approved: t.icons.markedApproved, 87 + rejected: t.icons.markedRejected, 88 + error: t.errorPrefix, 89 + }} 90 + /> 91 + ))} 92 + </div> 93 + )} 94 + </div> 95 + </section> 96 + <Footer variant="compact" /> 97 + </div> 98 + </div> 99 + ); 100 + }
+94
routes/admin/index.tsx
··· 1 + /** 2 + * Admin overview page. Aggregates moderation/curation queues so the 3 + * homepage shows what needs attention without per-section visits. 4 + */ 5 + import { define } from "../../utils.ts"; 6 + import Nav from "../../components/Nav.tsx"; 7 + import GlassClouds from "../../components/GlassClouds.tsx"; 8 + import Footer from "../../components/Footer.tsx"; 9 + import { getMessages } from "../../i18n/mod.ts"; 10 + import type { Locale } from "../../i18n/mod.ts"; 11 + import { 12 + countPendingIcons, 13 + countTakenDownProfiles, 14 + } from "../../lib/registry.ts"; 15 + import { countOpenReports } from "../../lib/reports.ts"; 16 + 17 + export const handler = define.handlers({ 18 + async GET(ctx) { 19 + const [pendingIcons, openReports, takenDown] = await Promise.all([ 20 + countPendingIcons().catch(() => 0), 21 + countOpenReports().catch(() => 0), 22 + countTakenDownProfiles().catch(() => 0), 23 + ]); 24 + return ctx.render( 25 + <AdminHome 26 + user={ctx.state.user!} 27 + pendingIcons={pendingIcons} 28 + openReports={openReports} 29 + takenDown={takenDown} 30 + locale={ctx.state.locale} 31 + />, 32 + ); 33 + }, 34 + }); 35 + 36 + interface AdminHomeProps { 37 + user: { did: string; handle: string }; 38 + pendingIcons: number; 39 + openReports: number; 40 + takenDown: number; 41 + locale: Locale; 42 + } 43 + 44 + function AdminHome( 45 + { user, pendingIcons, openReports, takenDown, locale }: AdminHomeProps, 46 + ) { 47 + const t = getMessages(locale).admin; 48 + return ( 49 + <div id="page-top"> 50 + <GlassClouds /> 51 + <div class="content-layer"> 52 + <Nav 53 + account={{ 54 + user: { did: user.did, handle: user.handle }, 55 + avatarUrl: "/api/me/avatar", 56 + publicProfileHandle: null, 57 + }} 58 + /> 59 + <section class="admin-section"> 60 + <div class="container" style={{ maxWidth: "920px" }}> 61 + <header class="admin-header"> 62 + <h1 class="text-section">{t.overview.headline}</h1> 63 + <p class="text-body mt-2">{t.overview.subhead}</p> 64 + </header> 65 + 66 + <div class="admin-grid"> 67 + <a href="/admin/icons" class="admin-card"> 68 + <p class="admin-card-count">{pendingIcons}</p> 69 + <h2 class="admin-card-title">{t.overview.iconsTitle}</h2> 70 + <p class="admin-card-body">{t.overview.iconsBody}</p> 71 + </a> 72 + <a href="/admin/reports" class="admin-card"> 73 + <p class="admin-card-count">{openReports}</p> 74 + <h2 class="admin-card-title">{t.overview.reportsTitle}</h2> 75 + <p class="admin-card-body">{t.overview.reportsBody}</p> 76 + </a> 77 + <a href="/admin/featured" class="admin-card"> 78 + <p class="admin-card-count">★</p> 79 + <h2 class="admin-card-title">{t.overview.featuredTitle}</h2> 80 + <p class="admin-card-body">{t.overview.featuredBody}</p> 81 + </a> 82 + <a href="/admin/takedowns" class="admin-card"> 83 + <p class="admin-card-count">{takenDown}</p> 84 + <h2 class="admin-card-title">{t.overview.takedownsTitle}</h2> 85 + <p class="admin-card-body">{t.overview.takedownsBody}</p> 86 + </a> 87 + </div> 88 + </div> 89 + </section> 90 + <Footer variant="compact" /> 91 + </div> 92 + </div> 93 + ); 94 + }
+116
routes/admin/reports.tsx
··· 1 + /** 2 + * Admin: open report inbox. Server-renders one row per open report and 3 + * mounts an island so each can be actioned/dismissed inline. 4 + */ 5 + import { define } from "../../utils.ts"; 6 + import Nav from "../../components/Nav.tsx"; 7 + import GlassClouds from "../../components/GlassClouds.tsx"; 8 + import Footer from "../../components/Footer.tsx"; 9 + import AdminReportRow from "../../islands/AdminReportRow.tsx"; 10 + import { getMessages } from "../../i18n/mod.ts"; 11 + import type { Locale } from "../../i18n/mod.ts"; 12 + import { getProfileByDid } from "../../lib/registry.ts"; 13 + import { listOpenReports, type ReportRow } from "../../lib/reports.ts"; 14 + 15 + interface ReportWithHandle extends ReportRow { 16 + targetHandle: string; 17 + } 18 + 19 + export const handler = define.handlers({ 20 + async GET(ctx) { 21 + const reports = await listOpenReports().catch(() => [] as ReportRow[]); 22 + /** Resolve target handles in parallel — without this the table just 23 + * shows DIDs which makes it harder to scan. */ 24 + const enriched: ReportWithHandle[] = await Promise.all( 25 + reports.map(async (r) => { 26 + // Admin tooling — show handles even for already-taken-down 27 + // targets so the queue stays scannable after a moderation pass. 28 + const p = await getProfileByDid(r.targetDid, { includeTakenDown: true }) 29 + .catch(() => null); 30 + return { ...r, targetHandle: p?.handle ?? r.targetDid }; 31 + }), 32 + ); 33 + return ctx.render( 34 + <AdminReportsPage 35 + user={ctx.state.user!} 36 + reports={enriched} 37 + locale={ctx.state.locale} 38 + />, 39 + ); 40 + }, 41 + }); 42 + 43 + interface PageProps { 44 + user: { did: string; handle: string }; 45 + reports: ReportWithHandle[]; 46 + locale: Locale; 47 + } 48 + 49 + function AdminReportsPage({ user, reports, locale }: PageProps) { 50 + const t = getMessages(locale).admin; 51 + return ( 52 + <div id="page-top"> 53 + <GlassClouds /> 54 + <div class="content-layer"> 55 + <Nav 56 + account={{ 57 + user: { did: user.did, handle: user.handle }, 58 + avatarUrl: "/api/me/avatar", 59 + publicProfileHandle: null, 60 + }} 61 + /> 62 + <section class="admin-section"> 63 + <div class="container" style={{ maxWidth: "920px" }}> 64 + <p> 65 + <a href="/admin" class="text-link-button"> 66 + ← {t.backToOverview} 67 + </a> 68 + </p> 69 + <header class="admin-header" style={{ marginTop: "0.75rem" }}> 70 + <h1 class="text-section">{t.reports.headline}</h1> 71 + <p class="text-body mt-2">{t.reports.subhead}</p> 72 + </header> 73 + 74 + {reports.length === 0 75 + ? <p class="text-body admin-empty">{t.reports.empty}</p> 76 + : ( 77 + <div class="admin-report-list"> 78 + {reports.map((r) => ( 79 + <AdminReportRow 80 + key={r.id} 81 + id={r.id} 82 + targetDid={r.targetDid} 83 + targetHandle={r.targetHandle} 84 + reporterDid={r.reporterDid} 85 + reason={r.reason} 86 + reasonLabel={t.reports.reasons[r.reason]} 87 + details={r.details} 88 + createdAt={r.createdAt} 89 + copy={{ 90 + action: t.reports.action, 91 + dismiss: t.reports.dismiss, 92 + takedown: t.reports.takedown, 93 + takedownPrompt: t.reports.takedownPrompt, 94 + takedownDoneLabel: t.reports.takedownDoneLabel, 95 + actionedLabel: t.reports.actionedLabel, 96 + dismissedLabel: t.reports.dismissedLabel, 97 + noteLabel: t.reports.noteLabel, 98 + notePlaceholder: t.reports.notePlaceholder, 99 + reasonLabel: t.reports.reasonLabel, 100 + reporterLabel: t.reports.reporterLabel, 101 + anonymousReporter: t.reports.anonymousReporter, 102 + detailsLabel: t.reports.detailsLabel, 103 + submittedAt: t.reports.submittedAt, 104 + error: t.errorPrefix, 105 + }} 106 + /> 107 + ))} 108 + </div> 109 + )} 110 + </div> 111 + </section> 112 + <Footer variant="compact" /> 113 + </div> 114 + </div> 115 + ); 116 + }
+96
routes/admin/takedowns.tsx
··· 1 + /** 2 + * Admin: list of currently taken-down profiles, with per-row Restore. 3 + * Server-renders one row per taken-down profile and mounts an island 4 + * so each can be restored inline without a full reload. 5 + */ 6 + import { define } from "../../utils.ts"; 7 + import Nav from "../../components/Nav.tsx"; 8 + import GlassClouds from "../../components/GlassClouds.tsx"; 9 + import Footer from "../../components/Footer.tsx"; 10 + import AdminTakedownRow from "../../islands/AdminTakedownRow.tsx"; 11 + import { getMessages } from "../../i18n/mod.ts"; 12 + import type { Locale } from "../../i18n/mod.ts"; 13 + import { 14 + listTakenDownProfiles, 15 + type TakenDownProfileRow, 16 + } from "../../lib/registry.ts"; 17 + 18 + export const handler = define.handlers({ 19 + async GET(ctx) { 20 + const rows = await listTakenDownProfiles().catch( 21 + () => [] as TakenDownProfileRow[], 22 + ); 23 + return ctx.render( 24 + <AdminTakedownsPage 25 + user={ctx.state.user!} 26 + rows={rows} 27 + locale={ctx.state.locale} 28 + />, 29 + ); 30 + }, 31 + }); 32 + 33 + interface PageProps { 34 + user: { did: string; handle: string }; 35 + rows: TakenDownProfileRow[]; 36 + locale: Locale; 37 + } 38 + 39 + function AdminTakedownsPage({ user, rows, locale }: PageProps) { 40 + const t = getMessages(locale).admin; 41 + return ( 42 + <div id="page-top"> 43 + <GlassClouds /> 44 + <div class="content-layer"> 45 + <Nav 46 + account={{ 47 + user: { did: user.did, handle: user.handle }, 48 + avatarUrl: "/api/me/avatar", 49 + publicProfileHandle: null, 50 + }} 51 + /> 52 + <section class="admin-section"> 53 + <div class="container" style={{ maxWidth: "920px" }}> 54 + <p> 55 + <a href="/admin" class="text-link-button"> 56 + ← {t.backToOverview} 57 + </a> 58 + </p> 59 + <header class="admin-header" style={{ marginTop: "0.75rem" }}> 60 + <h1 class="text-section">{t.takedowns.headline}</h1> 61 + <p class="text-body mt-2">{t.takedowns.subhead}</p> 62 + </header> 63 + 64 + {rows.length === 0 65 + ? <p class="text-body admin-empty">{t.takedowns.empty}</p> 66 + : ( 67 + <div class="admin-report-list"> 68 + {rows.map((r) => ( 69 + <AdminTakedownRow 70 + key={r.did} 71 + did={r.did} 72 + handle={r.handle} 73 + name={r.name} 74 + reason={r.takedownReason} 75 + by={r.takedownBy} 76 + at={r.takedownAt} 77 + copy={{ 78 + reasonLabel: t.takedowns.reasonLabel, 79 + byLabel: t.takedowns.byLabel, 80 + atLabel: t.takedowns.atLabel, 81 + restore: t.takedowns.restore, 82 + confirmRestore: t.takedowns.confirmRestore, 83 + restored: t.takedowns.restored, 84 + error: t.errorPrefix, 85 + }} 86 + /> 87 + ))} 88 + </div> 89 + )} 90 + </div> 91 + </section> 92 + <Footer variant="compact" /> 93 + </div> 94 + </div> 95 + ); 96 + }
+111
routes/api/admin/featured.ts
··· 1 + /** 2 + * Admin: replace the curated featured directory. 3 + * 4 + * POST /api/admin/featured 5 + * { entries: [{ did, badges?, position? }, ...] } 6 + * 7 + * Mirrors `scripts/publish-featured.ts` — validates the payload against 8 + * the featured lexicon, then writes `com.atmosphereaccount.registry. 9 + * featured/self` to the Atmosphere account's PDS using its existing 10 + * OAuth session. The Jetstream indexer picks up the new record within 11 + * seconds and replaces the local `featured` table. 12 + */ 13 + import { define } from "../../../utils.ts"; 14 + import { requireAdminApi } from "../../../lib/admin.ts"; 15 + import { 16 + FEATURED_BADGES, 17 + FEATURED_NSID, 18 + validateFeatured, 19 + } from "../../../lib/lexicons.ts"; 20 + import { putRecord } from "../../../lib/pds.ts"; 21 + import { getValidSession } from "../../../lib/oauth.ts"; 22 + import { ATMOSPHERE_DID } from "../../../lib/env.ts"; 23 + 24 + interface PayloadEntry { 25 + did?: unknown; 26 + badges?: unknown; 27 + position?: unknown; 28 + } 29 + 30 + export const handler = define.handlers({ 31 + async POST(ctx) { 32 + const gate = requireAdminApi(ctx); 33 + if (!gate.ok) return gate.response; 34 + 35 + if (!ATMOSPHERE_DID) { 36 + return jsonError(500, "atmosphere_did_unset", "Set ATMOSPHERE_DID"); 37 + } 38 + 39 + const body = await ctx.req.json().catch(() => null) as 40 + | { entries?: PayloadEntry[] } 41 + | null; 42 + if (!body || !Array.isArray(body.entries)) { 43 + return jsonError(400, "invalid_body"); 44 + } 45 + 46 + const entries: { did: string; badges?: string[]; position?: number }[] = []; 47 + for (const [i, raw] of body.entries.entries()) { 48 + const did = typeof raw.did === "string" ? raw.did.trim() : ""; 49 + if (!did.startsWith("did:")) { 50 + return jsonError(400, `invalid_did_at_${i}`); 51 + } 52 + const badges = Array.isArray(raw.badges) 53 + ? raw.badges 54 + .filter((b): b is string => typeof b === "string") 55 + .filter((b) => (FEATURED_BADGES as readonly string[]).includes(b)) 56 + : []; 57 + const positionRaw = typeof raw.position === "number" 58 + ? raw.position 59 + : Number(raw.position); 60 + const position = Number.isFinite(positionRaw) ? positionRaw : i; 61 + entries.push({ did, badges, position }); 62 + } 63 + 64 + const record = { entries }; 65 + const validation = validateFeatured(record); 66 + if (!validation.ok || !validation.value) { 67 + return jsonError(400, "invalid_record", validation.error); 68 + } 69 + 70 + const session = await getValidSession(ATMOSPHERE_DID); 71 + if (!session) { 72 + return jsonError( 73 + 401, 74 + "atmosphere_session_missing", 75 + "Sign in once with the curator account at /oauth/login first.", 76 + ); 77 + } 78 + 79 + let result: Awaited<ReturnType<typeof putRecord>>; 80 + try { 81 + result = await putRecord( 82 + ATMOSPHERE_DID, 83 + session.pdsUrl, 84 + FEATURED_NSID, 85 + "self", 86 + record as unknown as Record<string, unknown>, 87 + ); 88 + } catch (err) { 89 + const m = err instanceof Error ? err.message : String(err); 90 + return jsonError(502, "put_record_failed", m); 91 + } 92 + 93 + return new Response( 94 + JSON.stringify({ ok: true, uri: result.uri, cid: result.cid }), 95 + { 96 + status: 200, 97 + headers: { "content-type": "application/json; charset=utf-8" }, 98 + }, 99 + ); 100 + }, 101 + }); 102 + 103 + function jsonError(status: number, code: string, detail?: string): Response { 104 + return new Response( 105 + JSON.stringify(detail ? { error: code, detail } : { error: code }), 106 + { 107 + status, 108 + headers: { "content-type": "application/json; charset=utf-8" }, 109 + }, 110 + ); 111 + }
+43
routes/api/admin/icons/[did]/approve.ts
··· 1 + /** 2 + * Admin: approve a pending SVG icon for a project. 3 + * 4 + * POST /api/admin/icons/:did/approve 5 + * 6 + * On success, /api/registry/icon/:did starts serving the bytes and 7 + * /api/registry/profile/:id begins emitting `iconUrl` for the project. 8 + */ 9 + import { define } from "../../../../../utils.ts"; 10 + import { requireAdminApi } from "../../../../../lib/admin.ts"; 11 + import { approveIcon } from "../../../../../lib/registry.ts"; 12 + 13 + export const handler = define.handlers({ 14 + async POST(ctx) { 15 + const gate = requireAdminApi(ctx); 16 + if (!gate.ok) return gate.response; 17 + 18 + const did = decodeURIComponent(ctx.params.did); 19 + if (!did.startsWith("did:")) { 20 + return jsonError(400, "invalid_did"); 21 + } 22 + try { 23 + await approveIcon(did, gate.did); 24 + } catch (err) { 25 + const m = err instanceof Error ? err.message : String(err); 26 + return jsonError(500, "approve_failed", m); 27 + } 28 + return new Response(JSON.stringify({ ok: true }), { 29 + status: 200, 30 + headers: { "content-type": "application/json; charset=utf-8" }, 31 + }); 32 + }, 33 + }); 34 + 35 + function jsonError(status: number, code: string, detail?: string): Response { 36 + return new Response( 37 + JSON.stringify(detail ? { error: code, detail } : { error: code }), 38 + { 39 + status, 40 + headers: { "content-type": "application/json; charset=utf-8" }, 41 + }, 42 + ); 43 + }
+60
routes/api/admin/icons/[did]/preview.ts
··· 1 + /** 2 + * Admin-only proxy that serves a project's SVG icon regardless of 3 + * approval state — used by the review screen so admins can see what 4 + * they're approving / rejecting. 5 + * 6 + * GET /api/admin/icons/:did/preview 7 + * 8 + * Same hardening headers as the public icon route (`Content-Security- 9 + * Policy: default-src 'none'; …`, `nosniff`, inline disposition) so 10 + * even an as-yet-unsanitised SVG can't run scripts in the admin's 11 + * browser. 12 + */ 13 + import { define } from "../../../../../utils.ts"; 14 + import { requireAdminApi } from "../../../../../lib/admin.ts"; 15 + import { getProfileByDid } from "../../../../../lib/registry.ts"; 16 + import { fetchBlobPublic } from "../../../../../lib/pds.ts"; 17 + 18 + export const handler = define.handlers({ 19 + async GET(ctx) { 20 + const gate = requireAdminApi(ctx); 21 + if (!gate.ok) return gate.response; 22 + 23 + const did = decodeURIComponent(ctx.params.did); 24 + // Include taken-down rows so admins can still inspect an icon that 25 + // was attached to a profile they later took down (useful when 26 + // restoring or re-evaluating). 27 + const profile = await getProfileByDid(did, { includeTakenDown: true }) 28 + .catch(() => null); 29 + if (!profile || !profile.iconCid) { 30 + return new Response("not found", { status: 404 }); 31 + } 32 + try { 33 + const upstream = await fetchBlobPublic( 34 + profile.pdsUrl, 35 + did, 36 + profile.iconCid, 37 + ); 38 + if (!upstream.ok) { 39 + return new Response("not found", { status: 404 }); 40 + } 41 + const headers = new Headers(); 42 + headers.set("content-type", "image/svg+xml; charset=utf-8"); 43 + headers.set("x-content-type-options", "nosniff"); 44 + headers.set( 45 + "content-security-policy", 46 + "default-src 'none'; style-src 'unsafe-inline'; img-src data:", 47 + ); 48 + headers.set( 49 + "content-disposition", 50 + 'inline; filename="atmosphere-icon-preview.svg"', 51 + ); 52 + // Admin previews should never be cached by browsers / CDNs. 53 + headers.set("cache-control", "private, no-store"); 54 + return new Response(upstream.body, { status: 200, headers }); 55 + } catch (err) { 56 + console.warn("admin icon preview error:", err); 57 + return new Response("upstream error", { status: 502 }); 58 + } 59 + }, 60 + });
+49
routes/api/admin/icons/[did]/reject.ts
··· 1 + /** 2 + * Admin: reject a pending SVG icon for a project. 3 + * 4 + * POST /api/admin/icons/:did/reject { reason: string } 5 + * 6 + * The reason is shown to the project owner on /explore/manage so they 7 + * know why their icon isn't appearing in the developer API. 8 + */ 9 + import { define } from "../../../../../utils.ts"; 10 + import { requireAdminApi } from "../../../../../lib/admin.ts"; 11 + import { rejectIcon } from "../../../../../lib/registry.ts"; 12 + 13 + export const handler = define.handlers({ 14 + async POST(ctx) { 15 + const gate = requireAdminApi(ctx); 16 + if (!gate.ok) return gate.response; 17 + 18 + const did = decodeURIComponent(ctx.params.did); 19 + if (!did.startsWith("did:")) { 20 + return jsonError(400, "invalid_did"); 21 + } 22 + const body = await ctx.req.json().catch(() => null) as 23 + | { reason?: unknown } 24 + | null; 25 + const reason = typeof body?.reason === "string" ? body.reason.trim() : ""; 26 + if (!reason) return jsonError(400, "missing_reason"); 27 + 28 + try { 29 + await rejectIcon(did, gate.did, reason); 30 + } catch (err) { 31 + const m = err instanceof Error ? err.message : String(err); 32 + return jsonError(500, "reject_failed", m); 33 + } 34 + return new Response(JSON.stringify({ ok: true }), { 35 + status: 200, 36 + headers: { "content-type": "application/json; charset=utf-8" }, 37 + }); 38 + }, 39 + }); 40 + 41 + function jsonError(status: number, code: string, detail?: string): Response { 42 + return new Response( 43 + JSON.stringify(detail ? { error: code, detail } : { error: code }), 44 + { 45 + status, 46 + headers: { "content-type": "application/json; charset=utf-8" }, 47 + }, 48 + ); 49 + }
+42
routes/api/admin/profiles/[did]/restore.ts
··· 1 + /** 2 + * Admin: restore a previously taken-down profile. Clears the four 3 + * takedown_* columns; the row immediately becomes visible again to 4 + * /explore + the public APIs. 5 + * 6 + * POST /api/admin/profiles/:did/restore 7 + */ 8 + import { define } from "../../../../../utils.ts"; 9 + import { requireAdminApi } from "../../../../../lib/admin.ts"; 10 + import { restoreProfile } from "../../../../../lib/registry.ts"; 11 + 12 + export const handler = define.handlers({ 13 + async POST(ctx) { 14 + const gate = requireAdminApi(ctx); 15 + if (!gate.ok) return gate.response; 16 + 17 + const did = decodeURIComponent(ctx.params.did); 18 + if (!did.startsWith("did:")) return jsonError(400, "invalid_did"); 19 + 20 + try { 21 + await restoreProfile(did); 22 + } catch (err) { 23 + const m = err instanceof Error ? err.message : String(err); 24 + return jsonError(500, "restore_failed", m); 25 + } 26 + 27 + return new Response(JSON.stringify({ ok: true }), { 28 + status: 200, 29 + headers: { "content-type": "application/json; charset=utf-8" }, 30 + }); 31 + }, 32 + }); 33 + 34 + function jsonError(status: number, code: string, detail?: string): Response { 35 + return new Response( 36 + JSON.stringify(detail ? { error: code, detail } : { error: code }), 37 + { 38 + status, 39 + headers: { "content-type": "application/json; charset=utf-8" }, 40 + }, 41 + ); 42 + }
+90
routes/api/admin/profiles/[did]/takedown.ts
··· 1 + /** 2 + * Admin: take a profile down. Removes it from /explore + /api/registry/* 3 + * reads (search, featured, profile detail, icon, avatar). The user's 4 + * PDS record is untouched — only this AppView refuses to serve it. 5 + * 6 + * POST /api/admin/profiles/:did/takedown 7 + * { reason: string, notes?: string } 8 + * 9 + * As a side effect, all open reports against this DID are resolved as 10 + * `actioned` with the takedown reason in admin_notes — leaving them 11 + * "open" would be noise once the underlying issue has been removed. 12 + */ 13 + import { define } from "../../../../../utils.ts"; 14 + import { requireAdminApi } from "../../../../../lib/admin.ts"; 15 + import { 16 + getProfileByDid, 17 + takedownProfile, 18 + } from "../../../../../lib/registry.ts"; 19 + import { resolveOpenReportsForTarget } from "../../../../../lib/reports.ts"; 20 + 21 + const MAX_REASON_LEN = 500; 22 + 23 + export const handler = define.handlers({ 24 + async POST(ctx) { 25 + const gate = requireAdminApi(ctx); 26 + if (!gate.ok) return gate.response; 27 + 28 + const did = decodeURIComponent(ctx.params.did); 29 + if (!did.startsWith("did:")) return jsonError(400, "invalid_did"); 30 + 31 + const body = await ctx.req.json().catch(() => null) as 32 + | { reason?: unknown; notes?: unknown } 33 + | null; 34 + const rawReason = typeof body?.reason === "string" ? body.reason.trim() : ""; 35 + if (!rawReason) return jsonError(400, "missing_reason"); 36 + const reason = rawReason.slice(0, MAX_REASON_LEN); 37 + const notes = typeof body?.notes === "string" 38 + ? body.notes.trim().slice(0, 1000) || null 39 + : null; 40 + 41 + /** Confirm the profile actually exists (in any state) before 42 + * flipping its takedown flag — avoids leaving a stub row that 43 + * nothing else has indexed. */ 44 + const profile = await getProfileByDid(did, { includeTakenDown: true }) 45 + .catch(() => null); 46 + if (!profile) return jsonError(404, "not_found"); 47 + 48 + try { 49 + await takedownProfile(did, reason, gate.did); 50 + } catch (err) { 51 + const m = err instanceof Error ? err.message : String(err); 52 + return jsonError(500, "takedown_failed", m); 53 + } 54 + 55 + /** Best-effort auto-resolve. We don't fail the whole request if 56 + * this errors — the takedown itself succeeded, which is the 57 + * hard guarantee we care about. */ 58 + let resolvedReports = 0; 59 + try { 60 + resolvedReports = await resolveOpenReportsForTarget( 61 + did, 62 + gate.did, 63 + `Auto-resolved by takedown: ${reason}${notes ? ` — ${notes}` : ""}`, 64 + ); 65 + } catch (err) { 66 + console.warn( 67 + "[admin] takedown succeeded but report auto-resolve failed:", 68 + err, 69 + ); 70 + } 71 + 72 + return new Response( 73 + JSON.stringify({ ok: true, resolvedReports }), 74 + { 75 + status: 200, 76 + headers: { "content-type": "application/json; charset=utf-8" }, 77 + }, 78 + ); 79 + }, 80 + }); 81 + 82 + function jsonError(status: number, code: string, detail?: string): Response { 83 + return new Response( 84 + JSON.stringify(detail ? { error: code, detail } : { error: code }), 85 + { 86 + status, 87 + headers: { "content-type": "application/json; charset=utf-8" }, 88 + }, 89 + ); 90 + }
+52
routes/api/admin/reports/[id]/resolve.ts
··· 1 + /** 2 + * Admin: resolve an open report. 3 + * 4 + * POST /api/admin/reports/:id/resolve 5 + * { action: 'actioned' | 'dismissed', notes?: string } 6 + */ 7 + import { define } from "../../../../../utils.ts"; 8 + import { requireAdminApi } from "../../../../../lib/admin.ts"; 9 + import { resolveReport } from "../../../../../lib/reports.ts"; 10 + 11 + export const handler = define.handlers({ 12 + async POST(ctx) { 13 + const gate = requireAdminApi(ctx); 14 + if (!gate.ok) return gate.response; 15 + 16 + const id = Number(ctx.params.id); 17 + if (!Number.isFinite(id) || id <= 0) { 18 + return jsonError(400, "invalid_id"); 19 + } 20 + const body = await ctx.req.json().catch(() => null) as 21 + | { action?: unknown; notes?: unknown } 22 + | null; 23 + const action = body?.action; 24 + if (action !== "actioned" && action !== "dismissed") { 25 + return jsonError(400, "invalid_action"); 26 + } 27 + const notes = typeof body?.notes === "string" 28 + ? body.notes.trim().slice(0, 1000) || null 29 + : null; 30 + 31 + try { 32 + await resolveReport(id, gate.did, action, notes); 33 + } catch (err) { 34 + const m = err instanceof Error ? err.message : String(err); 35 + return jsonError(500, "resolve_failed", m); 36 + } 37 + return new Response(JSON.stringify({ ok: true }), { 38 + status: 200, 39 + headers: { "content-type": "application/json; charset=utf-8" }, 40 + }); 41 + }, 42 + }); 43 + 44 + function jsonError(status: number, code: string, detail?: string): Response { 45 + return new Response( 46 + JSON.stringify(detail ? { error: code, detail } : { error: code }), 47 + { 48 + status, 49 + headers: { "content-type": "application/json; charset=utf-8" }, 50 + }, 51 + ); 52 + }
+14 -1
routes/api/registry/icon/[did].ts
··· 32 32 if (!profile || !profile.iconCid) { 33 33 return new Response("not found", { status: 404 }); 34 34 } 35 + /** Refuse to serve until an admin has approved this icon. The blob 36 + * itself is on the user's PDS regardless — we just gate our proxy + 37 + * iconUrl emission, which is what developer-facing API consumers 38 + * rely on. The owner is allowed to see their own pending/rejected 39 + * icon so the manage-page preview keeps working. */ 40 + if (profile.iconStatus !== "approved") { 41 + const owner = ctx.state.user?.did === did; 42 + if (!owner) return new Response("not found", { status: 404 }); 43 + } 35 44 try { 36 45 const upstream = await fetchBlobPublic( 37 46 profile.pdsUrl, ··· 52 61 "content-disposition", 53 62 'inline; filename="atmosphere-icon.svg"', 54 63 ); 64 + // Owner-only previews of unapproved icons must not enter shared 65 + // caches; only the public approved path gets the long s-maxage. 55 66 headers.set( 56 67 "cache-control", 57 - "public, max-age=3600, s-maxage=86400, stale-while-revalidate=86400", 68 + profile.iconStatus === "approved" 69 + ? "public, max-age=3600, s-maxage=86400, stale-while-revalidate=86400" 70 + : "private, max-age=60", 58 71 ); 59 72 headers.set("etag", profile.iconCid); 60 73 return new Response(upstream.body, { status: 200, headers });
+25 -1
routes/api/registry/profile.ts
··· 21 21 type ProfileRecord, 22 22 validateProfile, 23 23 } from "../../../lib/lexicons.ts"; 24 - import { deleteProfile, upsertProfile } from "../../../lib/registry.ts"; 24 + import { 25 + deleteProfile, 26 + getProfileByDid, 27 + upsertProfile, 28 + } from "../../../lib/registry.ts"; 25 29 import { sanitizeSvgBytes } from "../../../lib/svg-sanitize.ts"; 26 30 27 31 const ICON_MAX_BYTES = 200_000; ··· 131 135 return new Response("OAuth session expired, please sign in again", { 132 136 status: 401, 133 137 }); 138 + } 139 + 140 + /** 141 + * Refuse to publish updates while the profile is taken down. The 142 + * upsert below would also preserve the takedown via the SQL CASE 143 + * branch, but that means the user would see a confusing "Saved!" 144 + * for a record we still won't serve. Surface a clear 403 instead. 145 + * The user can still DELETE the record from their PDS — that path 146 + * removes the row and clears the takedown along with it (so a 147 + * later republish would face the report queue afresh, which is 148 + * fine). 149 + */ 150 + const existing = await getProfileByDid(user.did, { includeTakenDown: true }) 151 + .catch(() => null); 152 + if (existing?.takedownStatus === "taken_down") { 153 + return new Response( 154 + "This profile has been taken down by an Atmosphere admin. " + 155 + "You can delete it from your PDS, but you can't update it.", 156 + { status: 403 }, 157 + ); 134 158 } 135 159 136 160 const body = await ctx.req.json().catch(() => null) as
+7 -3
routes/api/registry/profile/[id].ts
··· 26 26 avatarUrl: string | null; 27 27 /** 28 28 * Fully-qualified URL for the profile's developer-facing SVG icon, 29 - * or null if unset. Served as `image/svg+xml` with strict CSP + 30 - * `nosniff`; safe for `<img src>` embedding. 29 + * or null if unset / pending review / rejected. Served as 30 + * `image/svg+xml` with strict CSP + `nosniff`; safe for `<img src>` 31 + * embedding. 32 + * 33 + * SDK consumers that want to hint at pending/rejected state should 34 + * read `iconStatus` directly. 31 35 */ 32 36 iconUrl: string | null; 33 37 } ··· 58 62 avatarUrl: profile.avatarCid 59 63 ? `${origin}/api/registry/avatar/${encodeURIComponent(profile.did)}` 60 64 : null, 61 - iconUrl: profile.iconCid 65 + iconUrl: profile.iconCid && profile.iconStatus === "approved" 62 66 ? `${origin}/api/registry/icon/${encodeURIComponent(profile.did)}` 63 67 : null, 64 68 };
+92
routes/api/registry/profile/[id]/report.ts
··· 1 + /** 2 + * Public report submission for a registry profile. 3 + * 4 + * POST /api/registry/profile/:id/report { reason, details? } 5 + * 6 + * `:id` accepts a handle or DID, mirroring the public read endpoint 7 + * one directory up. 8 + * 9 + * Reports are anonymous unless the caller has an OAuth session (in 10 + * which case the reporter's DID is recorded). Same-IP submissions for 11 + * the same target/reason within 24h are silently deduped — we still 12 + * return 200 so spammers don't learn what's already on file. 13 + * 14 + * Rate-limited via the shared per-IP soft limit (`withRateLimit`). 15 + */ 16 + import { define } from "../../../../../utils.ts"; 17 + import { withRateLimit } from "../../../../../lib/rate-limit.ts"; 18 + import { 19 + getProfileByDid, 20 + getProfileByHandle, 21 + } from "../../../../../lib/registry.ts"; 22 + import { 23 + callerIp, 24 + createReport, 25 + hashIp, 26 + REPORT_REASONS, 27 + type ReportReason, 28 + } from "../../../../../lib/reports.ts"; 29 + 30 + interface ReportPayload { 31 + reason?: unknown; 32 + details?: unknown; 33 + } 34 + 35 + const MAX_DETAILS_LEN = 500; 36 + 37 + export const handler = define.handlers({ 38 + POST: withRateLimit(async (ctx) => { 39 + const raw = decodeURIComponent(ctx.params.id ?? "").trim(); 40 + if (!raw) return jsonError(400, "missing_id"); 41 + 42 + const target = raw.startsWith("did:") 43 + ? await getProfileByDid(raw).catch(() => null) 44 + : await getProfileByHandle(raw.toLowerCase()).catch(() => null); 45 + if (!target) return jsonError(404, "not_found"); 46 + 47 + const body = await ctx.req.json().catch(() => null) as 48 + | ReportPayload 49 + | null; 50 + if (!body) return jsonError(400, "invalid_body"); 51 + 52 + const reason = typeof body.reason === "string" ? body.reason : ""; 53 + if (!(REPORT_REASONS as readonly string[]).includes(reason)) { 54 + return jsonError(400, "invalid_reason"); 55 + } 56 + const details = typeof body.details === "string" 57 + ? body.details.trim().slice(0, MAX_DETAILS_LEN) || null 58 + : null; 59 + 60 + const ip = callerIp(ctx.req); 61 + const ipHash = ip === "anonymous" ? null : await hashIp(ip); 62 + const reporterDid = ctx.state.user?.did ?? null; 63 + 64 + const result = await createReport({ 65 + targetDid: target.did, 66 + reporterDid, 67 + ipHash, 68 + reason: reason as ReportReason, 69 + details, 70 + }); 71 + 72 + /** We always return 200 — even when deduped — so the caller can't 73 + * use the API to probe for prior reports against a target. */ 74 + return new Response( 75 + JSON.stringify({ 76 + ok: true, 77 + deduped: result.ok === false, 78 + }), 79 + { 80 + status: 200, 81 + headers: { "content-type": "application/json; charset=utf-8" }, 82 + }, 83 + ); 84 + }), 85 + }); 86 + 87 + function jsonError(status: number, code: string): Response { 88 + return new Response(JSON.stringify({ error: code }), { 89 + status, 90 + headers: { "content-type": "application/json; charset=utf-8" }, 91 + }); 92 + }
+26 -1
routes/explore/[handle].tsx
··· 4 4 import Footer from "../../components/Footer.tsx"; 5 5 import ProfileHero from "../../components/explore/ProfileHero.tsx"; 6 6 import ProfileLinks from "../../components/explore/ProfileLinks.tsx"; 7 + import ReportProfileButton from "../../islands/ReportProfileButton.tsx"; 7 8 import { getMessages } from "../../i18n/mod.ts"; 8 9 import type { Locale } from "../../i18n/mod.ts"; 9 10 import { ··· 51 52 function ProfileDetailPage( 52 53 { profile, signedInUser, ownerHandle, locale }: DetailProps, 53 54 ) { 54 - const t = getMessages(locale).explore; 55 + const messages = getMessages(locale); 56 + const t = messages.explore; 55 57 if (!profile) { 56 58 return ( 57 59 <NotFound ··· 95 97 {t.detail.editProfile} 96 98 </a> 97 99 </p> 100 + )} 101 + 102 + {!isOwner && ( 103 + <ReportProfileButton 104 + targetId={profile.handle} 105 + signedIn={!!signedInUser} 106 + copy={{ 107 + button: messages.report.button, 108 + modalTitle: messages.report.modalTitle, 109 + modalBody: messages.report.modalBody, 110 + reasonLabel: messages.report.reasonLabel, 111 + detailsLabel: messages.report.detailsLabel, 112 + detailsPlaceholder: messages.report.detailsPlaceholder, 113 + submit: messages.report.submit, 114 + submitting: messages.report.submitting, 115 + cancel: messages.report.cancel, 116 + sentTitle: messages.report.sentTitle, 117 + sentBody: messages.report.sentBody, 118 + duplicate: messages.report.duplicate, 119 + error: messages.report.error, 120 + reasons: messages.report.reasons, 121 + }} 122 + /> 98 123 )} 99 124 100 125 <div class="profile-footer">
+42 -4
routes/explore/manage.tsx
··· 43 43 * registry record exists, the form switches to the cached 44 44 * /api/registry/avatar/:did proxy. */ 45 45 let initialAvatarUrl: string | null = null; 46 - const existing = await getProfileByDid(user.did).catch(() => null); 46 + /** Owner-aware lookup: include taken-down rows so the form can 47 + * surface a "Your profile has been taken down" banner with the 48 + * admin reason instead of pretending no profile exists. */ 49 + const existing = await getProfileByDid(user.did, { includeTakenDown: true }) 50 + .catch(() => null); 47 51 if (existing) { 48 52 initial = { 49 53 name: existing.name, ··· 55 59 ? { ref: existing.avatarCid, mime: existing.avatarMime } 56 60 : null, 57 61 icon: existing.iconCid && existing.iconMime 58 - ? { ref: existing.iconCid, mime: existing.iconMime } 62 + ? { 63 + ref: existing.iconCid, 64 + mime: existing.iconMime, 65 + status: existing.iconStatus, 66 + rejectedReason: existing.iconRejectedReason, 67 + } 59 68 : null, 60 69 }; 61 70 } else { ··· 90 99 } 91 100 } 92 101 102 + /** Surface profile-level takedowns to the owner so they understand 103 + * why edits won't publish. The PUT endpoint also returns 403 in 104 + * this state, but a banner is much friendlier than a thrown 105 + * error after Publish. */ 106 + const takedown = existing?.takedownStatus === "taken_down" 107 + ? { 108 + reason: existing.takedownReason ?? "", 109 + at: existing.takedownAt, 110 + } 111 + : null; 112 + 93 113 return ctx.render( 94 114 <ManagePage 95 115 user={user} 96 116 initial={initial} 97 117 initialAvatarUrl={initialAvatarUrl} 98 - initialPublished={!!existing} 99 - publicProfileHandle={existing?.handle ?? null} 118 + initialPublished={!!existing && !takedown} 119 + publicProfileHandle={takedown ? null : existing?.handle ?? null} 120 + takedown={takedown} 100 121 t={t} 101 122 />, 102 123 ); ··· 109 130 initialAvatarUrl: string | null; 110 131 initialPublished: boolean; 111 132 publicProfileHandle: string | null; 133 + takedown: { reason: string; at: number | null } | null; 112 134 // deno-lint-ignore no-explicit-any 113 135 t: any; 114 136 } ··· 120 142 initialAvatarUrl, 121 143 initialPublished, 122 144 publicProfileHandle, 145 + takedown, 123 146 t, 124 147 }: ManagePageProps, 125 148 ) { 126 149 const explore = t.explore; 150 + const takedownCopy = t.manageTakedown; 127 151 return ( 128 152 <div id="page-top"> 129 153 <GlassClouds /> ··· 153 177 </form> 154 178 </div> 155 179 </div> 180 + 181 + {takedown && ( 182 + <div class="manage-takedown-banner" role="alert"> 183 + <strong class="manage-takedown-banner-title"> 184 + {takedownCopy.title} 185 + </strong> 186 + <p class="manage-takedown-banner-body"> 187 + {takedownCopy.body} 188 + </p> 189 + <p class="manage-takedown-banner-reason"> 190 + <strong>{takedownCopy.reasonLabel}:</strong> {takedown.reason} 191 + </p> 192 + </div> 193 + )} 156 194 157 195 <div style={{ marginTop: "2.5rem" }}> 158 196 <CreateProfileForm