simple list of pds servers with open registration
1
fork

Configure Feed

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

Replace trust flags with trust score percentage

Five signals at 20% each: contact email, ToS, privacy policy,
>5 users, latest PDS version. Sorted by score descending.
Removed sorting controls and outdated version toggle.
Added trust score explanation footnote.

+89 -175
+14 -48
backend/database/queries.ts
··· 1 1 import { sqlite } from "https://esm.town/v/stevekrouse/sqlite?v=13"; 2 2 import { METADATA_TABLE, SERVERS_TABLE } from "../../shared/constants.ts"; 3 - import type { 4 - PdsServer, 5 - SortDirection, 6 - SortField, 7 - } from "../../shared/types.ts"; 3 + import type { PdsServer } from "../../shared/types.ts"; 8 4 9 5 // --- Server operations --- 10 6 ··· 118 114 } 119 115 120 116 export async function getOpenServers( 121 - sort: SortField = "users", 122 - dir: SortDirection = "desc", 123 - showOutdated: boolean = false, 124 117 latestVersion: string | null = null, 125 118 ): Promise<PdsServer[]> { 126 - let orderClause: string; 127 - switch (sort) { 128 - case "users": 129 - orderClause = `user_count ${dir} NULLS LAST`; 130 - break; 131 - case "country": 132 - orderClause = `country_name ${dir} NULLS LAST`; 133 - break; 134 - case "first_seen": 135 - orderClause = `first_seen ${dir}`; 136 - break; 137 - case "version": 138 - orderClause = `version ${dir} NULLS LAST`; 139 - break; 140 - default: 141 - orderClause = "user_count DESC NULLS LAST"; 142 - } 143 - 144 - // Only show servers that actually responded (have version from health endpoint) 145 - let whereClause = 146 - "is_open = 1 AND last_enriched IS NOT NULL AND version IS NOT NULL"; 147 - const args: (string | number)[] = []; 148 - 149 - if (!showOutdated && latestVersion) { 150 - // Servers with trust signals always show regardless of version 151 - whereClause += 152 - " AND (version = ? OR contact_email IS NOT NULL OR terms_of_service IS NOT NULL OR privacy_policy IS NOT NULL)"; 153 - args.push(latestVersion); 154 - } 155 - 156 - // Tier 1: featured (contact email), Tier 2: has ToS or PP, Tier 3: rest 157 - const tierExpr = ` 158 - CASE 159 - WHEN contact_email IS NOT NULL THEN 0 160 - WHEN terms_of_service IS NOT NULL OR privacy_policy IS NOT NULL THEN 1 161 - ELSE 2 162 - END`; 119 + const trustScoreExpr = `( 120 + (CASE WHEN contact_email IS NOT NULL THEN 20 ELSE 0 END) + 121 + (CASE WHEN terms_of_service IS NOT NULL THEN 20 ELSE 0 END) + 122 + (CASE WHEN privacy_policy IS NOT NULL THEN 20 ELSE 0 END) + 123 + (CASE WHEN user_count > 5 THEN 20 ELSE 0 END) + 124 + (CASE WHEN version = ? THEN 20 ELSE 0 END) 125 + )`; 163 126 164 127 const result = await sqlite.execute({ 165 - sql: 166 - `SELECT * FROM ${SERVERS_TABLE} WHERE ${whereClause} ORDER BY ${tierExpr}, ${orderClause}`, 167 - args, 128 + sql: ` 129 + SELECT * FROM ${SERVERS_TABLE} 130 + WHERE is_open = 1 AND last_enriched IS NOT NULL AND version IS NOT NULL 131 + ORDER BY ${trustScoreExpr} DESC, user_count DESC NULLS LAST 132 + `, 133 + args: [latestVersion ?? ""], 168 134 }); 169 135 170 136 return result.rows.map(rowToServer);
+73 -125
backend/routes/pages.ts
··· 5 5 getServerCount, 6 6 } from "../database/queries.ts"; 7 7 import { countryFlag } from "../../shared/constants.ts"; 8 - import type { 9 - PdsServer, 10 - SortDirection, 11 - SortField, 12 - } from "../../shared/types.ts"; 8 + import type { PdsServer } from "../../shared/types.ts"; 13 9 14 10 const pages = new Hono(); 15 11 16 12 pages.get("/", async (c) => { 17 - const sort = (c.req.query("sort") || "users") as SortField; 18 - const dir = (c.req.query("dir") || "desc") as SortDirection; 19 - const showOutdated = c.req.query("show_outdated") === "true"; 20 - 21 13 const [latestVersion, counts] = await Promise.all([ 22 14 getMetadata("latest_pds_version"), 23 15 getServerCount(), 24 16 ]); 25 17 26 - const servers = await getOpenServers(sort, dir, showOutdated, latestVersion); 18 + const servers = await getOpenServers(latestVersion); 27 19 28 20 return c.html(renderPage(servers, { 29 - sort, 30 - dir, 31 - showOutdated, 32 21 latestVersion, 33 22 openCount: counts.open, 34 23 totalCount: counts.total, ··· 36 25 }); 37 26 38 27 type PageOpts = { 39 - sort: SortField; 40 - dir: SortDirection; 41 - showOutdated: boolean; 42 28 latestVersion: string | null; 43 29 openCount: number; 44 30 totalCount: number; 45 31 }; 46 32 47 - function renderPage(servers: PdsServer[], opts: PageOpts): string { 48 - const { sort, dir, showOutdated, latestVersion, openCount, totalCount } = 49 - opts; 33 + function trustScore(s: PdsServer, latestVersion: string | null): number { 34 + let score = 0; 35 + if (s.contact_email) score += 20; 36 + if (s.terms_of_service) score += 20; 37 + if (s.privacy_policy) score += 20; 38 + if (s.user_count != null && s.user_count > 5) score += 20; 39 + if (s.version && s.version === latestVersion) score += 20; 40 + return score; 41 + } 50 42 51 - function sortUrl(field: SortField): string { 52 - const newDir = sort === field && dir === "desc" ? "asc" : "desc"; 53 - const params = new URLSearchParams(); 54 - params.set("sort", field); 55 - params.set("dir", newDir); 56 - if (showOutdated) params.set("show_outdated", "true"); 57 - return `/?${params}`; 58 - } 43 + function renderPage(servers: PdsServer[], opts: PageOpts): string { 44 + const { latestVersion, openCount, totalCount } = opts; 59 45 60 - function sortIndicator(field: SortField): string { 61 - if (sort !== field) return ""; 62 - return dir === "asc" ? " &#9650;" : " &#9660;"; 63 - } 64 - 65 - const toggleUrl = (() => { 66 - const params = new URLSearchParams(); 67 - params.set("sort", sort); 68 - params.set("dir", dir); 69 - if (!showOutdated) params.set("show_outdated", "true"); 70 - return `/?${params}`; 71 - })(); 72 - 73 - const rows = servers.map((s) => renderRow(s, latestVersion)).join("\n"); 46 + const rows = servers 47 + .map((s) => renderRow(s, latestVersion, trustScore(s, latestVersion))) 48 + .join("\n"); 74 49 75 50 return `<!DOCTYPE html> 76 51 <html lang="en"> ··· 118 93 font-size: 0.85rem; 119 94 color: var(--muted); 120 95 } 121 - .controls { 122 - display: flex; 123 - justify-content: flex-end; 124 - margin-bottom: 0.5rem; 125 - } 126 - .controls a { 127 - font-size: 0.8rem; 128 - color: var(--blue); 129 - text-decoration: none; 130 - } 131 - .controls a:hover { text-decoration: underline; } 132 96 table { 133 97 width: 100%; 134 98 border-collapse: collapse; ··· 141 105 font-weight: 600; 142 106 white-space: nowrap; 143 107 } 144 - th a { 145 - color: var(--fg); 146 - text-decoration: none; 147 - } 148 - th a:hover { color: var(--blue); } 149 108 td { 150 109 padding: 0.5rem 0.75rem; 151 110 border-bottom: 1px solid var(--border); ··· 163 122 } 164 123 .version-current { background: #dcfce7; color: var(--green); } 165 124 .version-outdated { background: #fef3c7; color: var(--amber); } 166 - .version-unknown { background: #f3f4f6; color: var(--muted); } 167 125 @media (prefers-color-scheme: dark) { 168 126 .version-current { background: #052e16; } 169 127 .version-outdated { background: #451a03; } 170 - .version-unknown { background: #1f2937; } 171 128 } 172 - .trust-icons { display: flex; gap: 0.25rem; } 173 - .trust-icon { 174 - display: inline-flex; 175 - align-items: center; 176 - justify-content: center; 177 - padding: 0.1rem 0.3rem; 178 - font-size: 0.7rem; 179 - border-radius: 2px; 180 - background: #dcfce7; 181 - color: var(--green); 182 - text-decoration: none; 129 + .trust-score { 130 + display: inline-block; 131 + min-width: 2.5rem; 132 + text-align: center; 133 + padding: 0.1rem 0.4rem; 134 + border-radius: 3px; 135 + font-size: 0.75rem; 136 + font-weight: 600; 183 137 } 138 + .trust-high { background: #dcfce7; color: var(--green); } 139 + .trust-mid { background: #fef3c7; color: var(--amber); } 140 + .trust-low { background: #f3f4f6; color: var(--muted); } 184 141 @media (prefers-color-scheme: dark) { 185 - .trust-icon { background: #052e16; } 142 + .trust-high { background: #052e16; } 143 + .trust-mid { background: #451a03; } 144 + .trust-low { background: #1f2937; } 186 145 } 187 146 .move-btn { 188 147 display: inline-block; ··· 196 155 } 197 156 .move-btn:hover { background: var(--blue); color: #fff; } 198 157 .empty { text-align: center; padding: 3rem 1rem; color: var(--muted); } 158 + .trust-note { 159 + margin-top: 1.5rem; 160 + padding: 1rem; 161 + border: 1px solid var(--border); 162 + border-radius: 4px; 163 + font-size: 0.8rem; 164 + color: var(--muted); 165 + } 166 + .trust-note h3 { font-size: 0.85rem; color: var(--fg); margin-bottom: 0.5rem; } 167 + .trust-note ul { margin: 0.5rem 0 0 1.2rem; } 168 + .trust-note li { margin-bottom: 0.25rem; } 199 169 footer { 200 170 margin-top: 2rem; 201 171 padding-top: 1rem; ··· 223 193 <span>${openCount} open of ${totalCount} known</span> 224 194 ${latestVersion ? `<span>Latest PDS: ${esc(latestVersion)}</span>` : ""} 225 195 </div> 226 - <div class="controls"> 227 - <a href="${esc(toggleUrl)}">${ 228 - showOutdated ? "Hide outdated versions" : "Show outdated versions" 229 - }</a> 230 - </div> 231 196 ${ 232 197 servers.length > 0 233 198 ? ` 234 199 <table> 235 200 <thead> 236 201 <tr> 237 - <th><a href="${esc(sortUrl("users"))}">Hostname</a></th> 238 - <th class="hide-mobile"><a href="${esc(sortUrl("country"))}">Country${ 239 - sortIndicator("country") 240 - }</a></th> 241 - <th><a href="${esc(sortUrl("version"))}">Version${ 242 - sortIndicator("version") 243 - }</a></th> 244 - <th><a href="${esc(sortUrl("users"))}">Users${ 245 - sortIndicator("users") 246 - }</a></th> 247 - <th class="hide-mobile"><a href="${ 248 - esc(sortUrl("first_seen")) 249 - }">First seen${sortIndicator("first_seen")}</a></th> 250 - <th class="hide-mobile">Trust</th> 202 + <th>Trust<sup>*</sup></th> 203 + <th>Hostname</th> 204 + <th class="hide-mobile">Country</th> 205 + <th>Version</th> 206 + <th>Users</th> 207 + <th class="hide-mobile">First seen</th> 251 208 <th></th> 252 209 </tr> 253 210 </thead> ··· 257 214 </table>` 258 215 : `<div class="empty">No servers found. Data may still be loading — check back after the first cron run.</div>` 259 216 } 217 + <div class="trust-note"> 218 + <h3><sup>*</sup> Trust score</h3> 219 + <p>A simple transparency score based on five signals, each worth 20%:</p> 220 + <ul> 221 + <li><strong>Contact email</strong> &mdash; operator provides a way to reach them</li> 222 + <li><strong>Terms of Service</strong> &mdash; published terms for using the server</li> 223 + <li><strong>Privacy Policy</strong> &mdash; published data handling policy</li> 224 + <li><strong>Active users</strong> &mdash; more than 5 accounts on the server</li> 225 + <li><strong>Latest version</strong> &mdash; running the most recent PDS software</li> 226 + </ul> 227 + <p>This is not an endorsement. Always review a server's policies before creating an account.</p> 228 + </div> 260 229 <footer> 261 230 Built by <a href="https://bsky.app/profile/tijs.org">@tijs.org</a> 262 231 &middot; <a href="/api/servers">JSON API</a> ··· 266 235 </html>`; 267 236 } 268 237 269 - function renderRow(s: PdsServer, latestVersion: string | null): string { 238 + function renderRow( 239 + s: PdsServer, 240 + latestVersion: string | null, 241 + score: number, 242 + ): string { 270 243 const hostname = new URL(s.url).hostname; 271 244 const flag = s.country_code ? countryFlag(s.country_code) + " " : ""; 272 245 const country = s.country_name || ""; 273 246 274 - let versionClass = "version-unknown"; 275 - let versionText = "unknown"; 276 - if (s.version) { 277 - versionText = s.version; 278 - versionClass = s.version === latestVersion 279 - ? "version-current" 280 - : "version-outdated"; 281 - } 247 + let versionClass = "version-outdated"; 248 + const versionText = s.version || "unknown"; 249 + if (s.version === latestVersion) versionClass = "version-current"; 282 250 283 251 const users = s.user_count != null 284 252 ? (s.user_count >= 1000 ? "1000+" : String(s.user_count)) ··· 292 260 }) 293 261 : "-"; 294 262 295 - const hasEmail = s.contact_email; 296 - const hasTos = s.terms_of_service; 297 - const hasPrivacy = s.privacy_policy; 263 + const trustClass = score >= 60 264 + ? "trust-high" 265 + : score >= 40 266 + ? "trust-mid" 267 + : "trust-low"; 298 268 299 269 return `<tr> 270 + <td><span class="trust-score ${trustClass}">${score}%</span></td> 300 271 <td class="hostname"><a href="${ 301 272 esc(s.url) 302 273 }" target="_blank" rel="noopener">${esc(hostname)}</a></td> ··· 306 277 }</span></td> 307 278 <td>${esc(users)}</td> 308 279 <td class="hide-mobile">${esc(firstSeen)}</td> 309 - <td class="hide-mobile"> 310 - <span class="trust-icons"> 311 - ${ 312 - hasEmail 313 - ? `<span class="trust-icon" title="Contact: ${esc(hasEmail)}">@</span>` 314 - : "" 315 - } 316 - ${ 317 - hasTos 318 - ? `<a class="trust-icon" href="${ 319 - esc(hasTos) 320 - }" title="Terms of Service" target="_blank" rel="noopener">ToS</a>` 321 - : "" 322 - } 323 - ${ 324 - hasPrivacy 325 - ? `<a class="trust-icon" href="${ 326 - esc(hasPrivacy) 327 - }" title="Privacy Policy" target="_blank" rel="noopener">PP</a>` 328 - : "" 329 - } 330 - </span> 331 - </td> 332 280 <td><a class="move-btn" href="https://pdsmoover.com/moover/${ 333 281 esc(hostname) 334 282 }" target="_blank" rel="noopener">Moove here</a></td>
+2 -2
shared/types.ts
··· 64 64 }; 65 65 66 66 /** Sort options for the directory listing */ 67 - export type SortField = "users" | "country" | "first_seen" | "version"; 68 - export type SortDirection = "asc" | "desc"; 67 + export type SortField = "trust"; 68 + export type SortDirection = "desc";