Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

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

at main 354 lines 10 kB view raw
1import { AtpAgent } from "@atproto/api"; 2import { shell } from "./shell.mjs"; 3import { userEmailFromID } from "./authorization.mjs"; 4import crypto from "crypto"; 5 6const DEFAULT_PDS_URL = "https://at.aesthetic.computer"; 7 8const FALLBACK_ERROR_MESSAGES = [ 9 "Reserved handle", 10 "Invalid handle", 11 "must be a valid handle", 12 "Handle too short", 13 "Handle already taken", 14]; 15 16function getPdsUrl() { 17 return process.env.PDS_URL || DEFAULT_PDS_URL; 18} 19 20function getAdminPassword() { 21 return process.env.PDS_ADMIN_PASSWORD; 22} 23 24export function sanitizeHandleForPds(handle) { 25 if (!handle) return handle; 26 return handle.replace(/[._]/g, "-"); 27} 28 29function isRetryableHandleError(error) { 30 if (!error?.message) return false; 31 return FALLBACK_ERROR_MESSAGES.some((msg) => error.message.includes(msg)); 32} 33 34export async function updateAtprotoHandle(database, sub, handle) { 35 const users = database.db.collection("users"); 36 const userRecord = await users.findOne({ _id: sub }); 37 38 if (!userRecord) { 39 shell.log("🪪 No user record for ATProto sync:", sub); 40 return { updated: false, reason: "user-missing" }; 41 } 42 43 const atproto = userRecord.atproto; 44 if (!atproto?.password) { 45 shell.log("🪪 No ATProto credentials stored for:", sub); 46 return { updated: false, reason: "missing-credentials" }; 47 } 48 49 const sanitizedHandle = sanitizeHandleForPds(handle); 50 const desiredHandle = `${sanitizedHandle}.at.aesthetic.computer`; 51 const currentHandle = atproto.handle; 52 const identifier = currentHandle || atproto.did; 53 54 if (!identifier) { 55 shell.log("🪪 No ATProto identifier available for:", sub); 56 return { updated: false, reason: "missing-identifier" }; 57 } 58 59 if (desiredHandle === currentHandle) { 60 return { 61 updated: false, 62 reason: "already-synced", 63 handle: desiredHandle, 64 sanitized: sanitizedHandle, 65 }; 66 } 67 68 const agent = new AtpAgent({ service: getPdsUrl() }); 69 70 try { 71 await agent.login({ identifier, password: atproto.password }); 72 } catch (error) { 73 shell.log("🪪 Failed to login to PDS for handle sync:", error); 74 return { updated: false, reason: "login-failed", error: error.message }; 75 } 76 77 const performUpdate = async (targetHandle) => { 78 await agent.com.atproto.identity.updateHandle({ handle: targetHandle }); 79 await users.updateOne( 80 { _id: sub }, 81 { 82 $set: { 83 "atproto.handle": targetHandle, 84 "atproto.syncedAt": new Date().toISOString(), 85 }, 86 }, 87 ); 88 shell.log("🪪 Updated PDS handle to:", targetHandle); 89 return targetHandle; 90 }; 91 92 try { 93 const finalHandle = await performUpdate(desiredHandle); 94 return { 95 updated: true, 96 handle: finalHandle, 97 sanitized: sanitizedHandle, 98 }; 99 } catch (error) { 100 const fallbackHandle = userRecord.code 101 ? `${userRecord.code}.at.aesthetic.computer` 102 : null; 103 104 if (fallbackHandle && isRetryableHandleError(error)) { 105 shell.log( 106 "🪪 Falling back to user code handle for PDS:", 107 fallbackHandle, 108 "Reason:", 109 error.message, 110 ); 111 112 try { 113 const finalHandle = await performUpdate(fallbackHandle); 114 return { 115 updated: true, 116 handle: finalHandle, 117 sanitized: fallbackHandle.replace(/\.at\.aesthetic\.computer$/, ""), 118 fallback: true, 119 error: error.message, 120 }; 121 } catch (fallbackError) { 122 shell.log("🪪 Fallback PDS handle update failed:", fallbackError); 123 return { 124 updated: false, 125 reason: "fallback-failed", 126 error: fallbackError.message, 127 }; 128 } 129 } 130 131 shell.log("🪪 PDS handle update failed:", error); 132 return { updated: false, reason: "update-failed", error: error.message }; 133 } 134} 135 136export async function deleteAtprotoAccount(database, sub) { 137 const adminPassword = getAdminPassword(); 138 if (!adminPassword) { 139 shell.log("🪦 Skipping PDS deletion, missing admin password env."); 140 return { deleted: false, reason: "missing-admin-password" }; 141 } 142 143 const users = database.db.collection("users"); 144 const userRecord = await users.findOne({ _id: sub }); 145 146 if (!userRecord?.atproto?.did) { 147 shell.log("🪦 No ATProto DID on record for:", sub); 148 return { deleted: false, reason: "missing-did" }; 149 } 150 151 const did = userRecord.atproto.did; 152 const auth = Buffer.from(`admin:${adminPassword}`).toString("base64"); 153 154 try { 155 const response = await fetch( 156 `${getPdsUrl()}/xrpc/com.atproto.admin.deleteAccount`, 157 { 158 method: "POST", 159 headers: { 160 "Content-Type": "application/json", 161 Authorization: `Basic ${auth}`, 162 }, 163 body: JSON.stringify({ did }), 164 }, 165 ); 166 167 if (!response.ok) { 168 const errorText = await response.text(); 169 throw new Error(`status ${response.status}: ${errorText}`); 170 } 171 172 await users.updateOne({ _id: sub }, { $unset: { atproto: "" } }); 173 shell.log("🪦 Deleted ATProto account for:", sub); 174 return { deleted: true }; 175 } catch (error) { 176 shell.log("🪦 Failed to delete ATProto account:", error.message); 177 return { deleted: false, reason: "request-failed", error: error.message }; 178 } 179} 180 181async function generateInviteCode() { 182 const adminPassword = getAdminPassword(); 183 if (!adminPassword) { 184 throw new Error("PDS_ADMIN_PASSWORD environment variable is required"); 185 } 186 187 const auth = Buffer.from(`admin:${adminPassword}`).toString("base64"); 188 189 const response = await fetch( 190 `${getPdsUrl()}/xrpc/com.atproto.server.createInviteCode`, 191 { 192 method: "POST", 193 headers: { 194 "Content-Type": "application/json", 195 Authorization: `Basic ${auth}`, 196 }, 197 body: JSON.stringify({ useCount: 1 }), 198 }, 199 ); 200 201 if (!response.ok) { 202 throw new Error(`Failed to create invite code: ${response.statusText}`); 203 } 204 205 const data = await response.json(); 206 return data.code; 207} 208 209function generatePassword() { 210 const chars = 211 "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789!@#$%&*"; 212 let password = ""; 213 for (let i = 0; i < 32; i++) { 214 const randomIndex = crypto.randomInt(0, chars.length); 215 password += chars[randomIndex]; 216 } 217 return password; 218} 219 220export async function createAtprotoAccount(database, sub, email) { 221 try { 222 shell.log(`🦋 Creating ATProto account for: ${sub}`); 223 224 const users = database.db.collection("users"); 225 const handles = database.db.collection("@handles"); 226 227 // Check if account already exists 228 const existingUser = await users.findOne({ _id: sub }); 229 if (existingUser?.atproto?.did) { 230 shell.log(`🦋 ATProto account already exists for: ${sub}`); 231 return { 232 created: false, 233 reason: "already-exists", 234 did: existingUser.atproto.did, 235 handle: existingUser.atproto.handle, 236 }; 237 } 238 239 // Get email if not provided 240 if (!email) { 241 const tenant = sub.startsWith("sotce-") ? "sotce" : "aesthetic"; 242 const result = await userEmailFromID(sub, tenant); 243 if (!result?.email) { 244 throw new Error(`No email found for user: ${sub}`); 245 } 246 email = result.email; 247 } 248 249 shell.log(`📧 Email: ${email}`); 250 251 // Generate password 252 const password = generatePassword(); 253 254 // Determine handle from AC handle or user code 255 const handleRecord = await handles.findOne({ _id: sub }); 256 let pdsHandle; 257 if (handleRecord?.handle) { 258 const sanitizedHandle = sanitizeHandleForPds(handleRecord.handle); 259 pdsHandle = `${sanitizedHandle}.at.aesthetic.computer`; 260 shell.log(`🏷️ Using AC handle: ${pdsHandle}`); 261 } else if (existingUser?.code) { 262 pdsHandle = `${existingUser.code}.at.aesthetic.computer`; 263 shell.log(`🏷️ Using user code: ${pdsHandle}`); 264 } else { 265 throw new Error(`No handle or user code found for: ${sub}`); 266 } 267 268 // Generate invite code 269 shell.log(`🎫 Generating invite code...`); 270 const inviteCode = await generateInviteCode(); 271 272 // Create account on PDS 273 const agent = new AtpAgent({ service: getPdsUrl() }); 274 const tenant = sub.startsWith("sotce-") ? "sotce" : "aesthetic"; 275 276 let accountCreated = false; 277 let finalDid, finalHandle; 278 let currentEmail = email; 279 let attempts = 0; 280 281 while (!accountCreated && attempts < 3) { 282 attempts++; 283 284 try { 285 const response = await agent.createAccount({ 286 email: currentEmail, 287 handle: pdsHandle, 288 password, 289 inviteCode, 290 }); 291 292 finalDid = response.data.did; 293 finalHandle = response.data.handle; 294 accountCreated = true; 295 } catch (error) { 296 // Handle duplicate email by appending tenant 297 if ( 298 error.message.includes("Email already taken") && 299 attempts === 1 && 300 tenant === "sotce" 301 ) { 302 shell.log(`⚠️ Email "${currentEmail}" already taken, adding +sotce`); 303 const [localPart, domain] = currentEmail.split("@"); 304 currentEmail = `${localPart}+sotce@${domain}`; 305 } 306 // Try fallback to user code if handle fails 307 else if ( 308 isRetryableHandleError(error) && 309 attempts <= 2 && 310 existingUser?.code 311 ) { 312 shell.log(`⚠️ Handle failed, falling back to user code`); 313 pdsHandle = `${existingUser.code}.at.aesthetic.computer`; 314 } else { 315 throw error; 316 } 317 } 318 } 319 320 if (!accountCreated) { 321 throw new Error("Failed to create account after all attempts"); 322 } 323 324 shell.log(`✅ Created ATProto account: ${finalDid}`); 325 326 // Store atproto data in MongoDB 327 const atprotoData = { 328 did: finalDid, 329 handle: finalHandle, 330 password: password, 331 created: new Date().toISOString(), 332 }; 333 334 await users.updateOne( 335 { _id: sub }, 336 { $set: { atproto: atprotoData } }, 337 { upsert: true }, 338 ); 339 340 return { 341 created: true, 342 did: finalDid, 343 handle: finalHandle, 344 email: currentEmail, 345 }; 346 } catch (error) { 347 shell.log(`❌ Failed to create ATProto account: ${error.message}`); 348 return { 349 created: false, 350 reason: "creation-failed", 351 error: error.message, 352 }; 353 } 354}