Lexicon-driven ATProto AppView in Gleam — port of HappyView
0

Configure Feed

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

gleamview / src / gleamview / xrpc / procedure.gleam
8.8 kB 317 lines
1//// Default XRPC procedure handling (local index or OAuth PDS proxy). 2 3import gleam/dynamic.{type Dynamic} 4import gleam/json 5import gleam/option.{None, Some} 6import gleam/result 7import gleam/string 8import gleamview/db.{type Db} 9import gleamview/error.{type AppError} 10import gleamview/json_util 11import gleamview/lexicon.{type ParsedLexicon, Create, Delete, Update, Upsert} 12import gleamview/pds_write 13import gleamview/records 14import gleamview/util 15 16/// Claims for a write. `identity` is DID or handle used for OAuth session lookup. 17pub type WriteClaims { 18 WriteClaims(did: String) 19} 20 21pub fn handle( 22 db: Db, 23 method: String, 24 claims: WriteClaims, 25 input: Dynamic, 26 input_json: String, 27 lexicon: ParsedLexicon, 28 allow_local: Bool, 29) -> Result(String, AppError) { 30 use collection <- result.try(case lexicon.target_collection { 31 Some(c) -> Ok(c) 32 None -> 33 Error(error.BadRequest( 34 method <> " has no target_collection configured", 35 )) 36 }) 37 let action = resolve_action(lexicon.action, input) 38 case allow_local { 39 True -> 40 case action { 41 Create -> create_record_local(db, claims.did, collection, input_json) 42 Update -> put_record_local(db, claims.did, collection, input, input_json) 43 Delete -> delete_record_local(db, claims.did, collection, input) 44 Upsert -> create_record_local(db, claims.did, collection, input_json) 45 } 46 False -> 47 case action { 48 Create -> create_record_pds(db, claims.did, collection, input_json) 49 Update -> put_record_pds(db, claims.did, collection, input, input_json) 50 Delete -> delete_record_pds(db, claims.did, collection, input) 51 Upsert -> create_record_pds(db, claims.did, collection, input_json) 52 } 53 } 54} 55 56fn resolve_action( 57 action: lexicon.ProcedureAction, 58 input: Dynamic, 59) -> lexicon.ProcedureAction { 60 case action { 61 Upsert -> 62 case json_util.optional_string(input, "uri") { 63 Some(_) -> Update 64 None -> Create 65 } 66 other -> other 67 } 68} 69 70// --------------------------------------------------------------------------- 71// Local index mode 72// --------------------------------------------------------------------------- 73 74fn create_record_local( 75 db: Db, 76 did: String, 77 collection: String, 78 input_json: String, 79) -> Result(String, AppError) { 80 let rkey = util.new_token("", 13) |> tidish 81 let body = ensure_type(input_json, collection) 82 use uri <- result.try(records.upsert(db, did, collection, rkey, body, "")) 83 Ok( 84 json.object([ 85 #("uri", json.string(uri)), 86 #("cid", json.string("")), 87 #("commit", json.object([ 88 #("cid", json.string("")), 89 #("rev", json.string("")), 90 ])), 91 ]) 92 |> json.to_string, 93 ) 94} 95 96fn put_record_local( 97 db: Db, 98 did: String, 99 collection: String, 100 input: Dynamic, 101 input_json: String, 102) -> Result(String, AppError) { 103 use client_uri <- result.try( 104 json_util.field_string(input, "uri") 105 |> result.map_error(error.BadRequest), 106 ) 107 use rkey <- result.try( 108 util.rkey_from_uri(client_uri) 109 |> result.map_error(fn(_) { error.BadRequest("invalid AT URI") }), 110 ) 111 let body = case extract_record_json(input_json) { 112 Ok(rec) -> ensure_type(rec, collection) 113 Error(_) -> ensure_type("{}", collection) 114 } 115 use uri <- result.try(records.upsert(db, did, collection, rkey, body, "")) 116 Ok( 117 json.object([ 118 #("uri", json.string(uri)), 119 #("cid", json.string("")), 120 ]) 121 |> json.to_string, 122 ) 123} 124 125fn delete_record_local( 126 db: Db, 127 did: String, 128 collection: String, 129 input: Dynamic, 130) -> Result(String, AppError) { 131 use client_uri <- result.try( 132 json_util.field_string(input, "uri") 133 |> result.map_error(error.BadRequest), 134 ) 135 use rkey <- result.try( 136 util.rkey_from_uri(client_uri) 137 |> result.map_error(fn(_) { error.BadRequest("invalid AT URI") }), 138 ) 139 let uri = util.at_uri(did, collection, rkey) 140 use _ <- result.try(records.delete(db, uri)) 141 Ok("{}") 142} 143 144// --------------------------------------------------------------------------- 145// OAuth PDS proxy mode (also indexes locally after successful write) 146// --------------------------------------------------------------------------- 147 148fn create_record_pds( 149 db: Db, 150 identity: String, 151 collection: String, 152 input_json: String, 153) -> Result(String, AppError) { 154 let rkey = util.new_token("", 13) |> tidish 155 let body = ensure_type(input_json, collection) 156 use wr <- result.try(pds_write.create_record(identity, collection, body, rkey)) 157 // Index for AppView queries 158 let _ = records.upsert(db, identity_did(identity, wr.uri), collection, rkey, body, wr.cid) 159 Ok( 160 json.object([ 161 #("uri", json.string(wr.uri)), 162 #("cid", json.string(wr.cid)), 163 #("commit", json.object([ 164 #("cid", json.string(wr.cid)), 165 #("rev", json.string("")), 166 ])), 167 ]) 168 |> json.to_string, 169 ) 170} 171 172fn put_record_pds( 173 db: Db, 174 identity: String, 175 collection: String, 176 input: Dynamic, 177 input_json: String, 178) -> Result(String, AppError) { 179 use client_uri <- result.try( 180 json_util.field_string(input, "uri") 181 |> result.map_error(error.BadRequest), 182 ) 183 use rkey <- result.try( 184 util.rkey_from_uri(client_uri) 185 |> result.map_error(fn(_) { error.BadRequest("invalid AT URI") }), 186 ) 187 let body = case extract_record_json(input_json) { 188 Ok(rec) -> ensure_type(rec, collection) 189 Error(_) -> ensure_type(input_json, collection) 190 } 191 use wr <- result.try(pds_write.put_record(identity, collection, rkey, body)) 192 let _ = records.upsert(db, identity_did(identity, wr.uri), collection, rkey, body, wr.cid) 193 Ok( 194 json.object([ 195 #("uri", json.string(wr.uri)), 196 #("cid", json.string(wr.cid)), 197 ]) 198 |> json.to_string, 199 ) 200} 201 202fn delete_record_pds( 203 db: Db, 204 identity: String, 205 collection: String, 206 input: Dynamic, 207) -> Result(String, AppError) { 208 use client_uri <- result.try( 209 json_util.field_string(input, "uri") 210 |> result.map_error(error.BadRequest), 211 ) 212 use rkey <- result.try( 213 util.rkey_from_uri(client_uri) 214 |> result.map_error(fn(_) { error.BadRequest("invalid AT URI") }), 215 ) 216 use _ <- result.try(pds_write.delete_record(identity, collection, rkey)) 217 let uri = util.at_uri(identity, collection, rkey) 218 let _ = records.delete(db, uri) 219 // also try uri from client 220 let _ = records.delete(db, client_uri) 221 Ok("{}") 222} 223 224/// Prefer DID from AT URI when identity was a handle. 225fn identity_did(identity: String, uri: String) -> String { 226 case string.starts_with(identity, "did:") { 227 True -> identity 228 False -> 229 case string.split(uri, "/") { 230 ["at:", "", did, ..] -> did 231 _ -> identity 232 } 233 } 234} 235 236fn ensure_type(input_json: String, collection: String) -> String { 237 case string.contains(input_json, "\"$type\"") { 238 True -> input_json 239 False -> 240 case input_json { 241 "{" <> rest -> 242 "{\"$type\":" 243 <> json.to_string(json.string(collection)) 244 <> case rest { 245 "}" -> "}" 246 _ -> "," <> rest 247 } 248 _ -> input_json 249 } 250 } 251} 252 253/// Pull nested `"record":{...}` JSON object from a procedure body when present. 254fn extract_record_json(input_json: String) -> Result(String, Nil) { 255 let needle = "\"record\":" 256 case string.split_once(input_json, needle) { 257 Error(_) -> Error(Nil) 258 Ok(#(_, rest)) -> { 259 let rest = string.trim_start(rest) 260 case rest { 261 "{" <> _ -> extract_balanced_object(rest) 262 _ -> Error(Nil) 263 } 264 } 265 } 266} 267 268fn extract_balanced_object(s: String) -> Result(String, Nil) { 269 do_bal(s, 0, False, False, "") 270} 271 272fn do_bal( 273 rest: String, 274 depth: Int, 275 in_str: Bool, 276 escape: Bool, 277 acc: String, 278) -> Result(String, Nil) { 279 case string.pop_grapheme(rest) { 280 Error(_) -> Error(Nil) 281 Ok(#(ch, rest2)) -> { 282 let acc2 = acc <> ch 283 case in_str { 284 True -> 285 case escape { 286 True -> do_bal(rest2, depth, True, False, acc2) 287 False -> 288 case ch { 289 "\\" -> do_bal(rest2, depth, True, True, acc2) 290 "\"" -> do_bal(rest2, depth, False, False, acc2) 291 _ -> do_bal(rest2, depth, True, False, acc2) 292 } 293 } 294 False -> 295 case ch { 296 "\"" -> do_bal(rest2, depth, True, False, acc2) 297 "{" -> do_bal(rest2, depth + 1, False, False, acc2) 298 "}" -> { 299 let d2 = depth - 1 300 case d2 == 0 { 301 True -> Ok(acc2) 302 False -> do_bal(rest2, d2, False, False, acc2) 303 } 304 } 305 _ -> do_bal(rest2, depth, False, False, acc2) 306 } 307 } 308 } 309 } 310} 311 312fn tidish(s: String) -> String { 313 case string.starts_with(s, "_") { 314 True -> string.drop_start(s, 1) 315 False -> s 316 } 317}