this repo has no description
0
fork

Configure Feed

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

[mega-merge]

Signed-off-by: Seongmin Lee <git@boltless.me>

+1379 -791
+133
appview/db/db.go
··· 3 3 import ( 4 4 "context" 5 5 "database/sql" 6 + "fmt" 6 7 "log/slog" 7 8 "strings" 8 9 ··· 1253 1254 alter table profile_stats_new rename to profile_stats; 1254 1255 `) 1255 1256 return err 1257 + }) 1258 + 1259 + // we cannot modify user-owned record on repository delete 1260 + orm.RunMigration(conn, logger, "remove-foreign-key-profile_pinned_repositories-and-repos", func(tx *sql.Tx) error { 1261 + _, err := tx.Exec(` 1262 + create table profile_pinned_repositories_new ( 1263 + did text not null, 1264 + 1265 + -- data 1266 + at_uri text not null, 1267 + 1268 + -- constraints 1269 + unique(did, at_uri), 1270 + foreign key (did) references profile(did) on delete cascade 1271 + ); 1272 + 1273 + insert into profile_pinned_repositories_new (did, at_uri) 1274 + select did, at_uri from profile_pinned_repositories; 1275 + 1276 + drop table profile_pinned_repositories; 1277 + alter table profile_pinned_repositories_new rename to profile_pinned_repositories; 1278 + `) 1279 + return err 1280 + }) 1281 + 1282 + // several changes here 1283 + // 1. remove autoincrement id for these tables 1284 + // 2. remove unique constraints other than (did, rkey) to handle non-unique atproto records 1285 + // 3. add generated at_uri field 1286 + // 1287 + // see comments below and commit message for details 1288 + orm.RunMigration(conn, logger, "flexible-stars-reactions-follows-public_keys", func(tx *sql.Tx) error { 1289 + // - add at_uri 1290 + // - remove unique constraint (did, subject_at) 1291 + if _, err := tx.Exec(` 1292 + create table stars_new ( 1293 + did text not null, 1294 + rkey text not null, 1295 + at_uri text generated always as ('at://' || did || '/' || 'sh.tangled.feed.star' || '/' || rkey) stored, 1296 + 1297 + subject_at text not null, 1298 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 1299 + 1300 + unique(did, rkey) 1301 + ); 1302 + 1303 + insert into stars_new (did, rkey, subject_at, created) 1304 + select did, rkey, subject_at, created from stars; 1305 + 1306 + drop table stars; 1307 + alter table stars_new rename to stars; 1308 + `); err != nil { 1309 + return fmt.Errorf("migrating stars: %w", err) 1310 + } 1311 + 1312 + // - add at_uri 1313 + // - reacted_by_did -> did 1314 + // - thread_at -> subject_at 1315 + // - remove unique constraint 1316 + if _, err := tx.Exec(` 1317 + create table reactions_new ( 1318 + did text not null, 1319 + rkey text not null, 1320 + at_uri text generated always as ('at://' || did || '/' || 'sh.tangled.feed.reaction' || '/' || rkey) stored, 1321 + 1322 + subject_at text not null, 1323 + kind text not null, 1324 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 1325 + 1326 + unique(did, rkey) 1327 + ); 1328 + 1329 + insert into reactions_new (did, rkey, subject_at, kind, created) 1330 + select reacted_by_did, rkey, thread_at, kind, created from reactions; 1331 + 1332 + drop table reactions; 1333 + alter table reactions_new rename to reactions; 1334 + `); err != nil { 1335 + return fmt.Errorf("migrating reactions: %w", err) 1336 + } 1337 + 1338 + // - add at_uri column 1339 + // - user_did -> did 1340 + // - followed_at -> created 1341 + // - remove unique constraint 1342 + // - remove check constraint 1343 + if _, err := tx.Exec(` 1344 + create table follows_new ( 1345 + did text not null, 1346 + rkey text not null, 1347 + at_uri text generated always as ('at://' || did || '/' || 'sh.tangled.graph.follow' || '/' || rkey) stored, 1348 + 1349 + subject_did text not null, 1350 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 1351 + 1352 + unique(did, rkey) 1353 + ); 1354 + 1355 + insert into follows_new (did, rkey, subject_did, created) 1356 + select user_did, rkey, subject_did, followed_at from follows; 1357 + 1358 + drop table follows; 1359 + alter table follows_new rename to follows; 1360 + `); err != nil { 1361 + return fmt.Errorf("migrating follows: %w", err) 1362 + } 1363 + 1364 + // - add at_uri column 1365 + // - remove foreign key relationship from repos 1366 + if _, err := tx.Exec(` 1367 + create table public_keys_new ( 1368 + did text not null, 1369 + rkey text not null, 1370 + at_uri text generated always as ('at://' || did || '/' || 'sh.tangled.publicKey' || '/' || rkey) stored, 1371 + 1372 + name text not null, 1373 + key text not null, 1374 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 1375 + 1376 + unique(did, rkey) 1377 + ); 1378 + 1379 + insert into public_keys_new (did, rkey, name, key, created) 1380 + select did, rkey, name, key, created from public_keys; 1381 + 1382 + drop table public_keys; 1383 + alter table public_keys_new rename to public_keys; 1384 + `); err != nil { 1385 + return fmt.Errorf("migrating public_keys: %w", err) 1386 + } 1387 + 1388 + return nil 1256 1389 }) 1257 1390 1258 1391 return &DB{
+41 -35
appview/db/follow.go
··· 6 6 "strings" 7 7 "time" 8 8 9 + "github.com/bluesky-social/indigo/atproto/syntax" 9 10 "tangled.org/core/appview/models" 10 11 "tangled.org/core/orm" 11 12 ) 12 13 13 - func AddFollow(e Execer, follow *models.Follow) error { 14 - query := `insert or ignore into follows (user_did, subject_did, rkey) values (?, ?, ?)` 15 - _, err := e.Exec(query, follow.UserDid, follow.SubjectDid, follow.Rkey) 14 + func UpsertFollow(e Execer, follow models.Follow) error { 15 + _, err := e.Exec( 16 + `insert into follows (did, rkey, subject_did, created) 17 + values (?, ?, ?, ?) 18 + on conflict(did, rkey) do update set 19 + subject_did = excluded.subject_did, 20 + created = excluded.created`, 21 + follow.UserDid, 22 + follow.Rkey, 23 + follow.SubjectDid, 24 + follow.FollowedAt.Format(time.RFC3339), 25 + ) 16 26 return err 17 27 } 18 28 19 - // Get a follow record 20 - func GetFollow(e Execer, userDid, subjectDid string) (*models.Follow, error) { 21 - query := `select user_did, subject_did, followed_at, rkey from follows where user_did = ? and subject_did = ?` 22 - row := e.QueryRow(query, userDid, subjectDid) 23 - 24 - var follow models.Follow 25 - var followedAt string 26 - err := row.Scan(&follow.UserDid, &follow.SubjectDid, &followedAt, &follow.Rkey) 29 + // Remove a follow 30 + func DeleteFollow(e Execer, did, subjectDid syntax.DID) ([]syntax.ATURI, error) { 31 + var deleted []syntax.ATURI 32 + rows, err := e.Query( 33 + `delete from follows 34 + where did = ? and subject_did = ? 35 + returning at_uri`, 36 + did, 37 + subjectDid, 38 + ) 27 39 if err != nil { 28 - return nil, err 40 + return nil, fmt.Errorf("deleting stars: %w", err) 29 41 } 42 + defer rows.Close() 30 43 31 - followedAtTime, err := time.Parse(time.RFC3339, followedAt) 32 - if err != nil { 33 - log.Println("unable to determine followed at time") 34 - follow.FollowedAt = time.Now() 35 - } else { 36 - follow.FollowedAt = followedAtTime 44 + for rows.Next() { 45 + var aturi syntax.ATURI 46 + if err := rows.Scan(&aturi); err != nil { 47 + return nil, fmt.Errorf("scanning at_uri: %w", err) 48 + } 49 + deleted = append(deleted, aturi) 37 50 } 38 - 39 - return &follow, nil 40 - } 41 - 42 - // Remove a follow 43 - func DeleteFollow(e Execer, userDid, subjectDid string) error { 44 - _, err := e.Exec(`delete from follows where user_did = ? and subject_did = ?`, userDid, subjectDid) 45 - return err 51 + return deleted, nil 46 52 } 47 53 48 54 // Remove a follow 49 55 func DeleteFollowByRkey(e Execer, userDid, rkey string) error { 50 - _, err := e.Exec(`delete from follows where user_did = ? and rkey = ?`, userDid, rkey) 56 + _, err := e.Exec(`delete from follows where did = ? and rkey = ?`, userDid, rkey) 51 57 return err 52 58 } 53 59 ··· 56 62 err := e.QueryRow( 57 63 `SELECT 58 64 COUNT(CASE WHEN subject_did = ? THEN 1 END) AS followers, 59 - COUNT(CASE WHEN user_did = ? THEN 1 END) AS following 65 + COUNT(CASE WHEN did = ? THEN 1 END) AS following 60 66 FROM follows;`, did, did).Scan(&followers, &following) 61 67 if err != nil { 62 68 return models.FollowStats{}, err ··· 96 102 group by subject_did 97 103 ) f 98 104 full outer join ( 99 - select user_did as did, count(*) as following 105 + select did as did, count(*) as following 100 106 from follows 101 - where user_did in (%s) 102 - group by user_did 107 + where did in (%s) 108 + group by did 103 109 ) g on f.did = g.did`, 104 110 placeholderStr, placeholderStr) 105 111 ··· 156 162 } 157 163 158 164 query := fmt.Sprintf( 159 - `select user_did, subject_did, followed_at, rkey 165 + `select did, subject_did, created, rkey 160 166 from follows 161 167 %s 162 - order by followed_at desc 168 + order by created desc 163 169 %s 164 170 `, whereClause, limitClause) 165 171 ··· 198 204 } 199 205 200 206 func GetFollowing(e Execer, did string) ([]models.Follow, error) { 201 - return GetFollows(e, 0, orm.FilterEq("user_did", did)) 207 + return GetFollows(e, 0, orm.FilterEq("did", did)) 202 208 } 203 209 204 210 func getFollowStatuses(e Execer, userDid string, subjectDids []string) (map[string]models.FollowStatus, error) { ··· 239 245 query := fmt.Sprintf(` 240 246 SELECT subject_did 241 247 FROM follows 242 - WHERE user_did = ? AND subject_did IN (%s) 248 + WHERE did = ? AND subject_did IN (%s) 243 249 `, strings.Join(placeholders, ",")) 244 250 245 251 rows, err := e.Query(query, args...)
+1 -4
appview/db/profile.go
··· 131 131 } 132 132 133 133 func UpsertProfile(tx *sql.Tx, profile *models.Profile) error { 134 - defer tx.Rollback() 135 - 136 134 // update links 137 135 _, err := tx.Exec(`delete from profile_links where did = ?`, profile.Did) 138 136 if err != nil { ··· 228 226 return err 229 227 } 230 228 } 231 - 232 - return tx.Commit() 229 + return nil 233 230 } 234 231 235 232 func GetProfiles(e Execer, filters ...orm.Filter) (map[string]*models.Profile, error) {
+17 -7
appview/db/pubkeys.go
··· 5 5 "time" 6 6 ) 7 7 8 - func AddPublicKey(e Execer, did, name, key, rkey string) error { 8 + func UpsertPublicKey(e Execer, pubKey models.PublicKey) error { 9 9 _, err := e.Exec( 10 - `insert or ignore into public_keys (did, name, key, rkey) 11 - values (?, ?, ?, ?)`, 12 - did, name, key, rkey) 10 + `insert into public_keys (did, rkey, name, key, created) 11 + values (?, ?, ?, ?, ?) 12 + on conflict(did, rkey) do update set 13 + name = excluded.name, 14 + key = excluded.key, 15 + created = excluded.created`, 16 + pubKey.Did, 17 + pubKey.Rkey, 18 + pubKey.Name, 19 + pubKey.Key, 20 + pubKey.Created.Format(time.RFC3339), 21 + ) 13 22 return err 14 23 } 15 24 16 - func DeletePublicKey(e Execer, did, name, key string) error { 25 + // for public_keys with empty rkey 26 + func DeletePublicKeyLegacy(e Execer, did, name string) error { 17 27 _, err := e.Exec(` 18 28 delete from public_keys 19 - where did = ? and name = ? and key = ?`, 20 - did, name, key) 29 + where did = ? and name = ? and rkey = ''`, 30 + did, name) 21 31 return err 22 32 } 23 33
+62 -48
appview/db/reaction.go
··· 1 1 package db 2 2 3 3 import ( 4 - "log" 4 + "fmt" 5 5 "time" 6 6 7 7 "github.com/bluesky-social/indigo/atproto/syntax" 8 8 "tangled.org/core/appview/models" 9 9 ) 10 10 11 - func AddReaction(e Execer, reactedByDid string, threadAt syntax.ATURI, kind models.ReactionKind, rkey string) error { 12 - query := `insert or ignore into reactions (reacted_by_did, thread_at, kind, rkey) values (?, ?, ?, ?)` 13 - _, err := e.Exec(query, reactedByDid, threadAt, kind, rkey) 11 + func UpsertReaction(e Execer, reaction models.Reaction) error { 12 + _, err := e.Exec( 13 + `insert into reactions (did, rkey, subject_at, kind, created) 14 + values (?, ?, ?, ?, ?) 15 + on conflict(did, rkey) do update set 16 + subject_at = excluded.subject_at, 17 + kind = excluded.kind, 18 + created = excluded.created`, 19 + reaction.ReactedByDid, 20 + reaction.Rkey, 21 + reaction.ThreadAt, 22 + reaction.Kind, 23 + reaction.Created.Format(time.RFC3339), 24 + ) 14 25 return err 15 26 } 16 27 17 - // Get a reaction record 18 - func GetReaction(e Execer, reactedByDid string, threadAt syntax.ATURI, kind models.ReactionKind) (*models.Reaction, error) { 19 - query := ` 20 - select reacted_by_did, thread_at, created, rkey 21 - from reactions 22 - where reacted_by_did = ? and thread_at = ? and kind = ?` 23 - row := e.QueryRow(query, reactedByDid, threadAt, kind) 24 - 25 - var reaction models.Reaction 26 - var created string 27 - err := row.Scan(&reaction.ReactedByDid, &reaction.ThreadAt, &created, &reaction.Rkey) 28 + // Remove a reaction 29 + func DeleteReaction(e Execer, did syntax.DID, subjectAt syntax.ATURI, kind models.ReactionKind) ([]syntax.ATURI, error) { 30 + var deleted []syntax.ATURI 31 + rows, err := e.Query( 32 + `delete from reactions 33 + where did = ? and subject_at = ? and kind = ? 34 + returning at_uri`, 35 + did, 36 + subjectAt, 37 + kind, 38 + ) 28 39 if err != nil { 29 - return nil, err 40 + return nil, fmt.Errorf("deleting stars: %w", err) 30 41 } 42 + defer rows.Close() 31 43 32 - createdAtTime, err := time.Parse(time.RFC3339, created) 33 - if err != nil { 34 - log.Println("unable to determine followed at time") 35 - reaction.Created = time.Now() 36 - } else { 37 - reaction.Created = createdAtTime 44 + for rows.Next() { 45 + var aturi syntax.ATURI 46 + if err := rows.Scan(&aturi); err != nil { 47 + return nil, fmt.Errorf("scanning at_uri: %w", err) 48 + } 49 + deleted = append(deleted, aturi) 38 50 } 39 - 40 - return &reaction, nil 41 - } 42 - 43 - // Remove a reaction 44 - func DeleteReaction(e Execer, reactedByDid string, threadAt syntax.ATURI, kind models.ReactionKind) error { 45 - _, err := e.Exec(`delete from reactions where reacted_by_did = ? and thread_at = ? and kind = ?`, reactedByDid, threadAt, kind) 46 - return err 51 + return deleted, nil 47 52 } 48 53 49 54 // Remove a reaction 50 - func DeleteReactionByRkey(e Execer, reactedByDid string, rkey string) error { 51 - _, err := e.Exec(`delete from reactions where reacted_by_did = ? and rkey = ?`, reactedByDid, rkey) 55 + func DeleteReactionByRkey(e Execer, did string, rkey string) error { 56 + _, err := e.Exec(`delete from reactions where did = ? and rkey = ?`, did, rkey) 52 57 return err 53 58 } 54 59 55 - func GetReactionCount(e Execer, threadAt syntax.ATURI, kind models.ReactionKind) (int, error) { 60 + func GetReactionCount(e Execer, subjectAt syntax.ATURI, kind models.ReactionKind) (int, error) { 56 61 count := 0 57 62 err := e.QueryRow( 58 - `select count(reacted_by_did) from reactions where thread_at = ? and kind = ?`, threadAt, kind).Scan(&count) 63 + `select count(did) from reactions where subject_at = ? and kind = ?`, subjectAt, kind).Scan(&count) 59 64 if err != nil { 60 65 return 0, err 61 66 } 62 67 return count, nil 63 68 } 64 69 65 - func GetReactionMap(e Execer, userLimit int, threadAt syntax.ATURI) (map[models.ReactionKind]models.ReactionDisplayData, error) { 70 + func GetReactionMap(e Execer, userLimit int, subjectAt syntax.ATURI) (map[models.ReactionKind]models.ReactionDisplayData, error) { 66 71 query := ` 67 - select kind, reacted_by_did, 72 + select kind, did, 68 73 row_number() over (partition by kind order by created asc) as rn, 69 74 count(*) over (partition by kind) as total 70 75 from reactions 71 - where thread_at = ? 76 + where subject_at = ? 72 77 order by kind, created asc` 73 78 74 - rows, err := e.Query(query, threadAt) 79 + rows, err := e.Query(query, subjectAt) 75 80 if err != nil { 76 81 return nil, err 77 82 } ··· 101 106 return reactionMap, rows.Err() 102 107 } 103 108 104 - func GetReactionStatus(e Execer, userDid string, threadAt syntax.ATURI, kind models.ReactionKind) bool { 105 - if _, err := GetReaction(e, userDid, threadAt, kind); err != nil { 106 - return false 107 - } else { 108 - return true 109 - } 109 + func GetReactionStatus(e Execer, userDid string, threadAt syntax.ATURI, kind models.ReactionKind) (bool, error) { 110 + var exists bool 111 + err := e.QueryRow( 112 + `select exists ( 113 + select 1 from reactions 114 + where did = ? and subject_at = ? and kind = ? 115 + )`, 116 + userDid, 117 + threadAt, 118 + kind, 119 + ).Scan(&exists) 120 + return exists, err 110 121 } 111 122 112 - func GetReactionStatusMap(e Execer, userDid string, threadAt syntax.ATURI) map[models.ReactionKind]bool { 123 + func GetReactionStatusMap(e Execer, userDid string, threadAt syntax.ATURI) (map[models.ReactionKind]bool, error) { 113 124 statusMap := map[models.ReactionKind]bool{} 114 125 for _, kind := range models.OrderedReactionKinds { 115 - count := GetReactionStatus(e, userDid, threadAt, kind) 116 - statusMap[kind] = count 126 + reacted, err := GetReactionStatus(e, userDid, threadAt, kind) 127 + if err != nil { 128 + return nil, err 129 + } 130 + statusMap[kind] = reacted 117 131 } 118 - return statusMap 132 + return statusMap, nil 119 133 }
+27 -31
appview/db/star.go
··· 4 4 "database/sql" 5 5 "errors" 6 6 "fmt" 7 - "log" 8 7 "slices" 9 8 "strings" 10 9 "time" ··· 14 13 "tangled.org/core/orm" 15 14 ) 16 15 17 - func AddStar(e Execer, star *models.Star) error { 18 - query := `insert or ignore into stars (did, subject_at, rkey) values (?, ?, ?)` 16 + func UpsertStar(e Execer, star models.Star) error { 19 17 _, err := e.Exec( 20 - query, 18 + `insert into stars (did, rkey, subject_at, created) 19 + values (?, ?, ?, ?) 20 + on conflict(did, rkey) do update set 21 + subject_at = excluded.subject_at, 22 + created = excluded.created`, 21 23 star.Did, 22 - star.RepoAt.String(), 23 24 star.Rkey, 25 + star.RepoAt, 26 + star.Created.Format(time.RFC3339), 24 27 ) 25 28 return err 26 29 } 27 30 28 - // Get a star record 29 - func GetStar(e Execer, did string, subjectAt syntax.ATURI) (*models.Star, error) { 30 - query := ` 31 - select did, subject_at, created, rkey 32 - from stars 33 - where did = ? and subject_at = ?` 34 - row := e.QueryRow(query, did, subjectAt) 35 - 36 - var star models.Star 37 - var created string 38 - err := row.Scan(&star.Did, &star.RepoAt, &created, &star.Rkey) 31 + // Remove a star 32 + func DeleteStar(tx *sql.Tx, did syntax.DID, subjectAt syntax.ATURI) ([]syntax.ATURI, error) { 33 + var deleted []syntax.ATURI 34 + rows, err := tx.Query( 35 + `delete from stars 36 + where did = ? and subject_at = ? 37 + returning at_uri`, 38 + did, 39 + subjectAt, 40 + ) 39 41 if err != nil { 40 - return nil, err 42 + return nil, fmt.Errorf("deleting stars: %w", err) 41 43 } 44 + defer rows.Close() 42 45 43 - createdAtTime, err := time.Parse(time.RFC3339, created) 44 - if err != nil { 45 - log.Println("unable to determine followed at time") 46 - star.Created = time.Now() 47 - } else { 48 - star.Created = createdAtTime 46 + for rows.Next() { 47 + var aturi syntax.ATURI 48 + if err := rows.Scan(&aturi); err != nil { 49 + return nil, fmt.Errorf("scanning at_uri: %w", err) 50 + } 51 + deleted = append(deleted, aturi) 49 52 } 50 - 51 - return &star, nil 52 - } 53 - 54 - // Remove a star 55 - func DeleteStar(e Execer, did string, subjectAt syntax.ATURI) error { 56 - _, err := e.Exec(`delete from stars where did = ? and subject_at = ?`, did, subjectAt) 57 - return err 53 + return deleted, nil 58 54 } 59 55 60 56 // Remove a star
+1 -1
appview/db/timeline.go
··· 183 183 func getTimelineFollows(e Execer, limit int, loggedInUserDid string, userIsFollowing []string) ([]models.TimelineEvent, error) { 184 184 filters := make([]orm.Filter, 0) 185 185 if userIsFollowing != nil { 186 - filters = append(filters, orm.FilterIn("user_did", userIsFollowing)) 186 + filters = append(filters, orm.FilterIn("did", userIsFollowing)) 187 187 } 188 188 189 189 follows, err := GetFollows(e, limit, filters...)
+38 -12
appview/ingester.go
··· 19 19 "tangled.org/core/appview/db" 20 20 "tangled.org/core/appview/models" 21 21 "tangled.org/core/appview/serververify" 22 - "tangled.org/core/appview/validator" 23 22 "tangled.org/core/idresolver" 24 23 "tangled.org/core/orm" 25 24 "tangled.org/core/rbac" ··· 31 30 IdResolver *idresolver.Resolver 32 31 Config *config.Config 33 32 Logger *slog.Logger 34 - Validator *validator.Validator 35 33 } 36 34 37 35 type processFunc func(ctx context.Context, e *jmodels.Event) error ··· 121 119 l.Error("invalid record", "err", err) 122 120 return err 123 121 } 124 - err = db.AddStar(i.Db, &models.Star{ 122 + err = db.UpsertStar(i.Db, models.Star{ 125 123 Did: did, 126 124 RepoAt: subjectUri, 127 125 Rkey: e.Commit.RKey, ··· 133 131 if err != nil { 134 132 return fmt.Errorf("failed to %s star record: %w", e.Commit.Operation, err) 135 133 } 134 + l.Info("processed star", "operation", e.Commit.Operation, "rkey", e.Commit.RKey) 136 135 137 136 return nil 138 137 } ··· 154 153 return err 155 154 } 156 155 157 - err = db.AddFollow(i.Db, &models.Follow{ 156 + err = db.UpsertFollow(i.Db, models.Follow{ 158 157 UserDid: did, 159 158 SubjectDid: record.Subject, 160 159 Rkey: e.Commit.RKey, ··· 166 165 if err != nil { 167 166 return fmt.Errorf("failed to %s follow record: %w", e.Commit.Operation, err) 168 167 } 168 + l.Info("processed follow", "operation", e.Commit.Operation, "rkey", e.Commit.RKey) 169 169 170 170 return nil 171 171 } ··· 187 187 l.Error("invalid record", "err", err) 188 188 return err 189 189 } 190 + pubKey, err := models.PublicKeyFromRecord(syntax.DID(did), syntax.RecordKey(e.Commit.RKey), record) 191 + if err != nil { 192 + l.Error("invalid record", "err", err) 193 + return err 194 + } 195 + if err := pubKey.Validate(); err != nil { 196 + l.Error("invalid record", "err", err) 197 + return err 198 + } 190 199 191 - name := record.Name 192 - key := record.Key 193 - err = db.AddPublicKey(i.Db, did, name, key, e.Commit.RKey) 200 + err = db.UpsertPublicKey(i.Db, pubKey) 194 201 case jmodels.CommitOperationDelete: 195 202 l.Debug("processing delete of pubkey") 196 203 err = db.DeletePublicKeyByRkey(i.Db, did, e.Commit.RKey) ··· 199 206 if err != nil { 200 207 return fmt.Errorf("failed to %s pubkey record: %w", e.Commit.Operation, err) 201 208 } 209 + l.Info("processed pubkey", "operation", e.Commit.Operation, "rkey", e.Commit.RKey) 202 210 203 211 return nil 204 212 } ··· 349 357 if err != nil { 350 358 return fmt.Errorf("failed to start transaction") 351 359 } 360 + defer tx.Rollback() 352 361 353 362 err = db.ValidateProfile(tx, &profile) 354 363 if err != nil { ··· 356 365 } 357 366 358 367 err = db.UpsertProfile(tx, &profile) 368 + if err != nil { 369 + return fmt.Errorf("upserting profile: %w", err) 370 + } 371 + 372 + err = tx.Commit() 359 373 case jmodels.CommitOperationDelete: 360 374 err = db.DeleteArtifact(i.Db, orm.FilterEq("did", did), orm.FilterEq("rkey", e.Commit.RKey)) 361 375 } ··· 613 627 614 628 string := models.StringFromRecord(did, rkey, record) 615 629 616 - if err = i.Validator.ValidateString(&string); err != nil { 630 + if err = string.Validate(); err != nil { 617 631 l.Error("invalid record", "err", err) 618 632 return err 619 633 } ··· 822 836 823 837 issue := models.IssueFromRecord(did, rkey, record) 824 838 825 - if err := i.Validator.ValidateIssue(&issue); err != nil { 839 + if err := issue.Validate(); err != nil { 826 840 return fmt.Errorf("failed to validate issue: %w", err) 827 841 } 828 842 ··· 902 916 return fmt.Errorf("failed to parse comment from record: %w", err) 903 917 } 904 918 905 - if err := i.Validator.ValidateIssueComment(comment); err != nil { 919 + if err := comment.Validate(); err != nil { 906 920 return fmt.Errorf("failed to validate comment: %w", err) 907 921 } 908 922 ··· 962 976 return fmt.Errorf("failed to parse labeldef from record: %w", err) 963 977 } 964 978 965 - if err := i.Validator.ValidateLabelDefinition(def); err != nil { 979 + if err := def.Validate(); err != nil { 966 980 return fmt.Errorf("failed to validate labeldef: %w", err) 967 981 } 968 982 ··· 1038 1052 if !ok { 1039 1053 return fmt.Errorf("failed to find label def for key: %s, expected: %q", o.OperandKey, slices.Collect(maps.Keys(actx.Defs))) 1040 1054 } 1041 - if err := i.Validator.ValidateLabelOp(def, repo, &o); err != nil { 1055 + 1056 + // validate permissions: only collaborators can apply labels currently 1057 + // 1058 + // TODO: introduce a repo:triage permission 1059 + ok, err := i.Enforcer.IsPushAllowed(o.Did, repo.Knot, repo.DidSlashRepo()) 1060 + if err != nil { 1061 + return fmt.Errorf("enforcing permission: %w", err) 1062 + } 1063 + if !ok { 1064 + return fmt.Errorf("unauthorized label operation") 1065 + } 1066 + 1067 + if err := def.ValidateOperandValue(&o); err != nil { 1042 1068 return fmt.Errorf("failed to validate labelop: %w", err) 1043 1069 } 1044 1070 }
+7 -8
appview/issues/issues.go
··· 28 28 "tangled.org/core/appview/pagination" 29 29 "tangled.org/core/appview/reporesolver" 30 30 "tangled.org/core/appview/searchquery" 31 - "tangled.org/core/appview/validator" 32 31 "tangled.org/core/idresolver" 33 32 "tangled.org/core/orm" 34 33 "tangled.org/core/rbac" ··· 46 45 config *config.Config 47 46 notifier notify.Notifier 48 47 logger *slog.Logger 49 - validator *validator.Validator 50 48 indexer *issues_indexer.Indexer 51 49 } 52 50 ··· 60 58 db *db.DB, 61 59 config *config.Config, 62 60 notifier notify.Notifier, 63 - validator *validator.Validator, 64 61 indexer *issues_indexer.Indexer, 65 62 logger *slog.Logger, 66 63 ) *Issues { ··· 75 72 config: config, 76 73 notifier: notifier, 77 74 logger: logger, 78 - validator: validator, 79 75 indexer: indexer, 80 76 } 81 77 } ··· 103 99 104 100 userReactions := map[models.ReactionKind]bool{} 105 101 if user != nil { 106 - userReactions = db.GetReactionStatusMap(rp.db, user.Active.Did, issue.AtUri()) 102 + userReactions, err = db.GetReactionStatusMap(rp.db, user.Active.Did, issue.AtUri()) 103 + if err != nil { 104 + l.Error("failed to get issue reaction status", "err", err) 105 + } 107 106 } 108 107 109 108 backlinks, err := db.GetBacklinks(rp.db, issue.AtUri()) ··· 166 165 newIssue.Body = r.FormValue("body") 167 166 newIssue.Mentions, newIssue.References = rp.mentionsResolver.Resolve(r.Context(), newIssue.Body) 168 167 169 - if err := rp.validator.ValidateIssue(newIssue); err != nil { 168 + if err := newIssue.Validate(); err != nil { 170 169 l.Error("validation error", "err", err) 171 170 rp.pages.Notice(w, noticeId, fmt.Sprintf("Failed to edit issue: %s", err)) 172 171 return ··· 425 424 Mentions: mentions, 426 425 References: references, 427 426 } 428 - if err = rp.validator.ValidateIssueComment(&comment); err != nil { 427 + if err = comment.Validate(); err != nil { 429 428 l.Error("failed to validate comment", "err", err) 430 429 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 431 430 return ··· 1022 1021 Repo: f, 1023 1022 } 1024 1023 1025 - if err := rp.validator.ValidateIssue(issue); err != nil { 1024 + if err := issue.Validate(); err != nil { 1026 1025 l.Error("validation error", "err", err) 1027 1026 rp.pages.Notice(w, "issues", fmt.Sprintf("Failed to create issue: %s", err)) 1028 1027 return
+29 -17
appview/labels/labels.go
··· 16 16 "tangled.org/core/appview/notify" 17 17 "tangled.org/core/appview/oauth" 18 18 "tangled.org/core/appview/pages" 19 - "tangled.org/core/appview/validator" 20 19 "tangled.org/core/orm" 21 20 "tangled.org/core/rbac" 22 21 "tangled.org/core/tid" ··· 29 28 ) 30 29 31 30 type Labels struct { 32 - oauth *oauth.OAuth 33 - pages *pages.Pages 34 - db *db.DB 35 - logger *slog.Logger 36 - validator *validator.Validator 37 - enforcer *rbac.Enforcer 38 - notifier notify.Notifier 31 + oauth *oauth.OAuth 32 + pages *pages.Pages 33 + db *db.DB 34 + logger *slog.Logger 35 + enforcer *rbac.Enforcer 36 + notifier notify.Notifier 39 37 } 40 38 41 39 func New( 42 40 oauth *oauth.OAuth, 43 41 pages *pages.Pages, 44 42 db *db.DB, 45 - validator *validator.Validator, 46 43 enforcer *rbac.Enforcer, 47 44 notifier notify.Notifier, 48 45 logger *slog.Logger, 49 46 ) *Labels { 50 47 return &Labels{ 51 - oauth: oauth, 52 - pages: pages, 53 - db: db, 54 - logger: logger, 55 - validator: validator, 56 - enforcer: enforcer, 57 - notifier: notifier, 48 + oauth: oauth, 49 + pages: pages, 50 + db: db, 51 + logger: logger, 52 + enforcer: enforcer, 53 + notifier: notifier, 58 54 } 59 55 } 60 56 ··· 167 163 168 164 for i := range labelOps { 169 165 def := actx.Defs[labelOps[i].OperandKey] 170 - if err := l.validator.ValidateLabelOp(def, repo, &labelOps[i]); err != nil { 166 + op := labelOps[i] 167 + 168 + // validate permissions: only collaborators can apply labels currently 169 + // 170 + // TODO: introduce a repo:triage permission 171 + ok, err := l.enforcer.IsPushAllowed(op.Did, repo.Knot, repo.DidSlashRepo()) 172 + if err != nil { 173 + fail("Failed to enforce permissions. Please try again later", fmt.Errorf("enforcing permission: %w", err)) 174 + return 175 + } 176 + if !ok { 177 + fail("Unauthorized label operation", fmt.Errorf("unauthorized label operation")) 178 + return 179 + } 180 + 181 + if err := def.ValidateOperandValue(&op); err != nil { 171 182 fail(fmt.Sprintf("Invalid form data: %s", err), err) 172 183 return 173 184 } 185 + labelOps[i] = op 174 186 } 175 187 176 188 // reduce the opset
+9
appview/models/follow.go
··· 2 2 3 3 import ( 4 4 "time" 5 + 6 + "tangled.org/core/api/tangled" 5 7 ) 6 8 7 9 type Follow struct { ··· 9 11 SubjectDid string 10 12 FollowedAt time.Time 11 13 Rkey string 14 + } 15 + 16 + func (f *Follow) AsRecord() tangled.GraphFollow { 17 + return tangled.GraphFollow{ 18 + Subject: f.SubjectDid, 19 + CreatedAt: f.FollowedAt.Format(time.RFC3339), 20 + } 12 21 } 13 22 14 23 type FollowStats struct {
+32
appview/models/issue.go
··· 3 3 import ( 4 4 "fmt" 5 5 "sort" 6 + "strings" 6 7 "time" 7 8 8 9 "github.com/bluesky-social/indigo/atproto/syntax" 9 10 "tangled.org/core/api/tangled" 11 + "tangled.org/core/appview/pages/markup/sanitizer" 10 12 ) 11 13 12 14 type Issue struct { ··· 59 61 return "open" 60 62 } 61 63 return "closed" 64 + } 65 + 66 + var _ Validator = new(Issue) 67 + 68 + func (i *Issue) Validate() error { 69 + if i.Title == "" { 70 + return fmt.Errorf("issue title is empty") 71 + } 72 + if i.Body == "" { 73 + return fmt.Errorf("issue body is empty") 74 + } 75 + 76 + if st := strings.TrimSpace(sanitizer.SanitizeDescription(i.Title)); st == "" { 77 + return fmt.Errorf("title is empty after HTML sanitization") 78 + } 79 + 80 + if st := strings.TrimSpace(sanitizer.SanitizeDefault(i.Body)); st == "" { 81 + return fmt.Errorf("body is empty after HTML sanitization") 82 + } 83 + return nil 62 84 } 63 85 64 86 type CommentListItem struct { ··· 215 237 216 238 func (i *IssueComment) IsReply() bool { 217 239 return i.ReplyTo != nil 240 + } 241 + 242 + var _ Validator = new(IssueComment) 243 + 244 + func (i *IssueComment) Validate() error { 245 + if sb := strings.TrimSpace(sanitizer.SanitizeDefault(i.Body)); sb == "" { 246 + return fmt.Errorf("body is empty after HTML sanitization") 247 + } 248 + 249 + return nil 218 250 } 219 251 220 252 func IssueCommentFromRecord(did, rkey string, record tangled.RepoIssueComment) (*IssueComment, error) {
+183 -4
appview/models/label.go
··· 7 7 "encoding/json" 8 8 "errors" 9 9 "fmt" 10 + "regexp" 10 11 "slices" 12 + "strings" 11 13 "time" 12 14 13 15 "github.com/bluesky-social/indigo/api/atproto" ··· 120 122 } 121 123 } 122 124 125 + var ( 126 + // Label name should be alphanumeric with hyphens/underscores, but not start/end with them 127 + labelNameRegex = regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9_-]*[a-zA-Z0-9])?$`) 128 + // Color should be a valid hex color 129 + colorRegex = regexp.MustCompile(`^#[a-fA-F0-9]{6}$`) 130 + // You can only label issues and pulls presently 131 + validScopes = []string{tangled.RepoIssueNSID, tangled.RepoPullNSID} 132 + ) 133 + 134 + var _ Validator = new(LabelDefinition) 135 + 136 + func (l *LabelDefinition) Validate() error { 137 + if l.Name == "" { 138 + return fmt.Errorf("label name is empty") 139 + } 140 + if len(l.Name) > 40 { 141 + return fmt.Errorf("label name too long (max 40 graphemes)") 142 + } 143 + if len(l.Name) < 1 { 144 + return fmt.Errorf("label name too short (min 1 grapheme)") 145 + } 146 + if !labelNameRegex.MatchString(l.Name) { 147 + return fmt.Errorf("label name contains invalid characters (use only letters, numbers, hyphens, and underscores)") 148 + } 149 + 150 + if !l.ValueType.IsConcreteType() { 151 + return fmt.Errorf("invalid value type: %q (must be one of: null, boolean, integer, string)", l.ValueType.Type) 152 + } 153 + 154 + // null type checks: cannot be enums, multiple or explicit format 155 + if l.ValueType.IsNull() && l.ValueType.IsEnum() { 156 + return fmt.Errorf("null type cannot be used in conjunction with enum type") 157 + } 158 + if l.ValueType.IsNull() && l.Multiple { 159 + return fmt.Errorf("null type labels cannot be multiple") 160 + } 161 + if l.ValueType.IsNull() && !l.ValueType.IsAnyFormat() { 162 + return fmt.Errorf("format cannot be used in conjunction with null type") 163 + } 164 + 165 + // format checks: cannot be used with enum, or integers 166 + if !l.ValueType.IsAnyFormat() && l.ValueType.IsEnum() { 167 + return fmt.Errorf("enum types cannot be used in conjunction with format specification") 168 + } 169 + 170 + if !l.ValueType.IsAnyFormat() && !l.ValueType.IsString() { 171 + return fmt.Errorf("format specifications are only permitted on string types") 172 + } 173 + 174 + // validate scope (nsid format) 175 + if l.Scope == nil { 176 + return fmt.Errorf("scope is required") 177 + } 178 + for _, s := range l.Scope { 179 + if _, err := syntax.ParseNSID(s); err != nil { 180 + return fmt.Errorf("failed to parse scope: %w", err) 181 + } 182 + if !slices.Contains(validScopes, s) { 183 + return fmt.Errorf("invalid scope: scope must be present in %q", validScopes) 184 + } 185 + } 186 + 187 + // validate color if provided 188 + if l.Color != nil { 189 + color := strings.TrimSpace(*l.Color) 190 + if color == "" { 191 + // empty color is fine, set to nil 192 + l.Color = nil 193 + } else { 194 + if !colorRegex.MatchString(color) { 195 + return fmt.Errorf("color must be a valid hex color (e.g. #79FFE1 or #000)") 196 + } 197 + // expand 3-digit hex to 6-digit hex 198 + if len(color) == 4 { // #ABC 199 + color = fmt.Sprintf("#%c%c%c%c%c%c", color[1], color[1], color[2], color[2], color[3], color[3]) 200 + } 201 + // convert to uppercase for consistency 202 + color = strings.ToUpper(color) 203 + l.Color = &color 204 + } 205 + } 206 + 207 + return nil 208 + } 209 + 210 + // ValidateOperandValue validates the label operation operand value based on 211 + // label definition. 212 + // 213 + // NOTE: This can modify the [LabelOp] 214 + func (def *LabelDefinition) ValidateOperandValue(op *LabelOp) error { 215 + expectedKey := def.AtUri().String() 216 + if op.OperandKey != def.AtUri().String() { 217 + return fmt.Errorf("operand key %q does not match label definition URI %q", op.OperandKey, expectedKey) 218 + } 219 + 220 + valueType := def.ValueType 221 + 222 + // this is permitted, it "unsets" a label 223 + if op.OperandValue == "" { 224 + op.Operation = LabelOperationDel 225 + return nil 226 + } 227 + 228 + switch valueType.Type { 229 + case ConcreteTypeNull: 230 + // For null type, value should be empty 231 + if op.OperandValue != "null" { 232 + return fmt.Errorf("null type requires empty value, got %q", op.OperandValue) 233 + } 234 + 235 + case ConcreteTypeString: 236 + // For string type, validate enum constraints if present 237 + if valueType.IsEnum() { 238 + if !slices.Contains(valueType.Enum, op.OperandValue) { 239 + return fmt.Errorf("value %q is not in allowed enum values %v", op.OperandValue, valueType.Enum) 240 + } 241 + } 242 + 243 + switch valueType.Format { 244 + case ValueTypeFormatDid: 245 + if _, err := syntax.ParseDID(op.OperandValue); err != nil { 246 + return fmt.Errorf("failed to resolve did/handle: %w", err) 247 + } 248 + case ValueTypeFormatAny, "": 249 + default: 250 + return fmt.Errorf("unsupported format constraint: %q", valueType.Format) 251 + } 252 + 253 + case ConcreteTypeInt: 254 + if op.OperandValue == "" { 255 + return fmt.Errorf("integer type requires non-empty value") 256 + } 257 + if _, err := fmt.Sscanf(op.OperandValue, "%d", new(int)); err != nil { 258 + return fmt.Errorf("value %q is not a valid integer", op.OperandValue) 259 + } 260 + 261 + if valueType.IsEnum() { 262 + if !slices.Contains(valueType.Enum, op.OperandValue) { 263 + return fmt.Errorf("value %q is not in allowed enum values %v", op.OperandValue, valueType.Enum) 264 + } 265 + } 266 + 267 + case ConcreteTypeBool: 268 + if op.OperandValue != "true" && op.OperandValue != "false" { 269 + return fmt.Errorf("boolean type requires value to be 'true' or 'false', got %q", op.OperandValue) 270 + } 271 + 272 + // validate enum constraints if present (though uncommon for booleans) 273 + if valueType.IsEnum() { 274 + if !slices.Contains(valueType.Enum, op.OperandValue) { 275 + return fmt.Errorf("value %q is not in allowed enum values %v", op.OperandValue, valueType.Enum) 276 + } 277 + } 278 + 279 + default: 280 + return fmt.Errorf("unsupported value type: %q", valueType.Type) 281 + } 282 + 283 + return nil 284 + } 285 + 123 286 // random color for a given seed 124 287 func randomColor(seed string) string { 125 288 hash := sha1.Sum([]byte(seed)) ··· 131 294 return fmt.Sprintf("#%s%s%s", r, g, b) 132 295 } 133 296 134 - func (ld LabelDefinition) GetColor() string { 135 - if ld.Color == nil { 136 - seed := fmt.Sprintf("%d:%s:%s", ld.Id, ld.Did, ld.Rkey) 297 + func (l LabelDefinition) GetColor() string { 298 + if l.Color == nil { 299 + seed := fmt.Sprintf("%d:%s:%s", l.Id, l.Did, l.Rkey) 137 300 color := randomColor(seed) 138 301 return color 139 302 } 140 303 141 - return *ld.Color 304 + return *l.Color 142 305 } 143 306 144 307 func LabelDefinitionFromRecord(did, rkey string, record tangled.LabelDefinition) (*LabelDefinition, error) { ··· 203 366 204 367 // otherwise, createdat is in the future relative to indexedat -> use indexedat 205 368 return indexedAt 369 + } 370 + 371 + var _ Validator = new(LabelOp) 372 + 373 + func (l *LabelOp) Validate() error { 374 + if _, err := syntax.ParseATURI(string(l.Subject)); err != nil { 375 + return fmt.Errorf("invalid subject URI: %w", err) 376 + } 377 + if l.Operation != LabelOperationAdd && l.Operation != LabelOperationDel { 378 + return fmt.Errorf("invalid operation: %q (must be 'add' or 'del')", l.Operation) 379 + } 380 + // Validate performed time is not zero/invalid 381 + if l.PerformedAt.IsZero() { 382 + return fmt.Errorf("performed_at timestamp is required") 383 + } 384 + return nil 206 385 } 207 386 208 387 type LabelOperation string
+38
appview/models/pubkey.go
··· 2 2 3 3 import ( 4 4 "encoding/json" 5 + "fmt" 5 6 "time" 7 + 8 + "github.com/bluesky-social/indigo/atproto/syntax" 9 + "github.com/gliderlabs/ssh" 10 + "tangled.org/core/api/tangled" 6 11 ) 7 12 8 13 type PublicKey struct { ··· 23 28 Alias: (*Alias)(&p), 24 29 }) 25 30 } 31 + 32 + func (p *PublicKey) AsRecord() tangled.PublicKey { 33 + return tangled.PublicKey{ 34 + Name: p.Name, 35 + Key: p.Key, 36 + CreatedAt: p.Created.Format(time.RFC3339), 37 + } 38 + } 39 + 40 + var _ Validator = new(PublicKey) 41 + 42 + func (p *PublicKey) Validate() error { 43 + if _, _, _, _, err := ssh.ParseAuthorizedKey([]byte(p.Key)); err != nil { 44 + return fmt.Errorf("invalid ssh key format: %w", err) 45 + } 46 + 47 + return nil 48 + } 49 + 50 + func PublicKeyFromRecord(did syntax.DID, rkey syntax.RecordKey, record tangled.PublicKey) (PublicKey, error) { 51 + created, err := time.Parse(time.RFC3339, record.CreatedAt) 52 + if err != nil { 53 + return PublicKey{}, fmt.Errorf("invalid time format '%s'", record.CreatedAt) 54 + } 55 + 56 + return PublicKey{ 57 + Did: did.String(), 58 + Rkey: rkey.String(), 59 + Name: record.Name, 60 + Key: record.Key, 61 + Created: &created, 62 + }, nil 63 + }
+9
appview/models/reaction.go
··· 4 4 "time" 5 5 6 6 "github.com/bluesky-social/indigo/atproto/syntax" 7 + "tangled.org/core/api/tangled" 7 8 ) 8 9 9 10 type ReactionKind string ··· 54 55 Created time.Time 55 56 Rkey string 56 57 Kind ReactionKind 58 + } 59 + 60 + func (r *Reaction) AsRecord() tangled.FeedReaction { 61 + return tangled.FeedReaction{ 62 + Subject: r.ThreadAt.String(), 63 + Reaction: r.Kind.String(), 64 + CreatedAt: r.Created.Format(time.RFC3339), 65 + } 57 66 } 58 67 59 68 type ReactionDisplayData struct {
+8
appview/models/star.go
··· 4 4 "time" 5 5 6 6 "github.com/bluesky-social/indigo/atproto/syntax" 7 + "tangled.org/core/api/tangled" 7 8 ) 8 9 9 10 type Star struct { ··· 11 12 RepoAt syntax.ATURI 12 13 Created time.Time 13 14 Rkey string 15 + } 16 + 17 + func (s *Star) AsRecord() tangled.FeedStar { 18 + return tangled.FeedStar{ 19 + Subject: s.RepoAt.String(), 20 + CreatedAt: s.Created.Format(time.RFC3339), 21 + } 14 22 } 15 23 16 24 // RepoStar is used for reverse mapping to repos
+21
appview/models/string.go
··· 2 2 3 3 import ( 4 4 "bytes" 5 + "errors" 5 6 "fmt" 6 7 "io" 7 8 "strings" 8 9 "time" 10 + "unicode/utf8" 9 11 10 12 "github.com/bluesky-social/indigo/atproto/syntax" 11 13 "tangled.org/core/api/tangled" ··· 33 35 Contents: s.Contents, 34 36 CreatedAt: s.Created.Format(time.RFC3339), 35 37 } 38 + } 39 + 40 + var _ Validator = new(String) 41 + 42 + func (s *String) Validate() error { 43 + var err error 44 + if utf8.RuneCountInString(s.Filename) > 140 { 45 + err = errors.Join(err, fmt.Errorf("filename too long")) 46 + } 47 + 48 + if utf8.RuneCountInString(s.Description) > 280 { 49 + err = errors.Join(err, fmt.Errorf("description too long")) 50 + } 51 + 52 + if len(s.Contents) == 0 { 53 + err = errors.Join(err, fmt.Errorf("contents is empty")) 54 + } 55 + 56 + return err 36 57 } 37 58 38 59 func StringFromRecord(did, rkey string, record tangled.String) String {
+6
appview/models/validator.go
··· 1 + package models 2 + 3 + type Validator interface { 4 + // Validate checks the object and returns any error. 5 + Validate() error 6 + }
+4 -3
appview/pages/funcmap.go
··· 31 31 "tangled.org/core/appview/models" 32 32 "tangled.org/core/appview/oauth" 33 33 "tangled.org/core/appview/pages/markup" 34 + "tangled.org/core/appview/pages/markup/sanitizer" 34 35 "tangled.org/core/crypto" 35 36 ) 36 37 ··· 264 265 "markdown": func(text string) template.HTML { 265 266 p.rctx.RendererType = markup.RendererTypeDefault 266 267 htmlString := p.rctx.RenderMarkdown(text) 267 - sanitized := p.rctx.SanitizeDefault(htmlString) 268 + sanitized := sanitizer.SanitizeDefault(htmlString) 268 269 return template.HTML(sanitized) 269 270 }, 270 271 "description": func(text string) template.HTML { ··· 274 275 emoji.Emoji, 275 276 ), 276 277 )) 277 - sanitized := p.rctx.SanitizeDescription(htmlString) 278 + sanitized := sanitizer.SanitizeDescription(htmlString) 278 279 return template.HTML(sanitized) 279 280 }, 280 281 "readme": func(text string) template.HTML { 281 282 p.rctx.RendererType = markup.RendererTypeRepoMarkdown 282 283 htmlString := p.rctx.RenderMarkdown(text) 283 - sanitized := p.rctx.SanitizeDefault(htmlString) 284 + sanitized := sanitizer.SanitizeDefault(htmlString) 284 285 return template.HTML(sanitized) 285 286 }, 286 287 "code": func(content, path string) string {
-9
appview/pages/markup/markdown.go
··· 49 49 IsDev bool 50 50 Hostname string 51 51 RendererType RendererType 52 - Sanitizer Sanitizer 53 52 Files fs.FS 54 53 } 55 54 ··· 182 181 } 183 182 default: 184 183 } 185 - } 186 - 187 - func (rctx *RenderContext) SanitizeDefault(html string) string { 188 - return rctx.Sanitizer.SanitizeDefault(html) 189 - } 190 - 191 - func (rctx *RenderContext) SanitizeDescription(html string) string { 192 - return rctx.Sanitizer.SanitizeDescription(html) 193 184 } 194 185 195 186 type MarkdownTransformer struct {
+11 -18
appview/pages/markup/sanitizer.go appview/pages/markup/sanitizer/sanitizer.go
··· 1 - package markup 1 + package sanitizer 2 2 3 3 import ( 4 4 "maps" ··· 10 10 "github.com/microcosm-cc/bluemonday" 11 11 ) 12 12 13 - type Sanitizer struct { 14 - defaultPolicy *bluemonday.Policy 15 - descriptionPolicy *bluemonday.Policy 16 - } 13 + var ( 14 + defaultPolicy = newDefaultPolicy() 15 + descriptionPolicy = newDescriptionPolicy() 16 + ) 17 17 18 - func NewSanitizer() Sanitizer { 19 - return Sanitizer{ 20 - defaultPolicy: defaultPolicy(), 21 - descriptionPolicy: descriptionPolicy(), 22 - } 18 + func SanitizeDefault(html string) string { 19 + return defaultPolicy.Sanitize(html) 23 20 } 24 - 25 - func (s *Sanitizer) SanitizeDefault(html string) string { 26 - return s.defaultPolicy.Sanitize(html) 27 - } 28 - func (s *Sanitizer) SanitizeDescription(html string) string { 29 - return s.descriptionPolicy.Sanitize(html) 21 + func SanitizeDescription(html string) string { 22 + return descriptionPolicy.Sanitize(html) 30 23 } 31 24 32 - func defaultPolicy() *bluemonday.Policy { 25 + func newDefaultPolicy() *bluemonday.Policy { 33 26 policy := bluemonday.UGCPolicy() 34 27 35 28 // Allow generally safe attributes ··· 123 116 return policy 124 117 } 125 118 126 - func descriptionPolicy() *bluemonday.Policy { 119 + func newDescriptionPolicy() *bluemonday.Policy { 127 120 policy := bluemonday.NewPolicy() 128 121 policy.AllowStandardURLs() 129 122
+5 -5
appview/pages/pages.go
··· 23 23 "tangled.org/core/appview/models" 24 24 "tangled.org/core/appview/oauth" 25 25 "tangled.org/core/appview/pages/markup" 26 + "tangled.org/core/appview/pages/markup/sanitizer" 26 27 "tangled.org/core/appview/pages/repoinfo" 27 28 "tangled.org/core/appview/pagination" 28 29 "tangled.org/core/idresolver" ··· 58 59 Hostname: config.Core.AppviewHost, 59 60 CamoUrl: config.Camo.Host, 60 61 CamoSecret: config.Camo.SharedSecret, 61 - Sanitizer: markup.NewSanitizer(), 62 62 Files: Files, 63 63 } 64 64 ··· 293 293 294 294 p.rctx.RendererType = markup.RendererTypeDefault 295 295 htmlString := p.rctx.RenderMarkdown(string(markdownBytes)) 296 - sanitized := p.rctx.SanitizeDefault(htmlString) 296 + sanitized := sanitizer.SanitizeDefault(htmlString) 297 297 params.Content = template.HTML(sanitized) 298 298 299 299 return p.execute("legal/terms", w, params) ··· 321 321 322 322 p.rctx.RendererType = markup.RendererTypeDefault 323 323 htmlString := p.rctx.RenderMarkdown(string(markdownBytes)) 324 - sanitized := p.rctx.SanitizeDefault(htmlString) 324 + sanitized := sanitizer.SanitizeDefault(htmlString) 325 325 params.Content = template.HTML(sanitized) 326 326 327 327 return p.execute("legal/privacy", w, params) ··· 727 727 case ".md", ".markdown", ".mdown", ".mkdn", ".mkd": 728 728 params.Raw = false 729 729 htmlString := p.rctx.RenderMarkdown(params.Readme) 730 - sanitized := p.rctx.SanitizeDefault(htmlString) 730 + sanitized := sanitizer.SanitizeDefault(htmlString) 731 731 params.HTMLReadme = template.HTML(sanitized) 732 732 default: 733 733 params.Raw = true ··· 820 820 case ".md", ".markdown", ".mdown", ".mkdn", ".mkd": 821 821 params.Raw = false 822 822 htmlString := p.rctx.RenderMarkdown(params.Readme) 823 - sanitized := p.rctx.SanitizeDefault(htmlString) 823 + sanitized := sanitizer.SanitizeDefault(htmlString) 824 824 params.HTMLReadme = template.HTML(sanitized) 825 825 default: 826 826 params.Raw = true
+1 -1
appview/pages/templates/strings/fragments/form.html
··· 31 31 name="content" 32 32 id="content-textarea" 33 33 wrap="off" 34 - class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400 font-mono" 34 + class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400" 35 35 rows="20" 36 36 spellcheck="false" 37 37 placeholder="Paste your string here!"
+1 -1
appview/pages/templates/user/settings/fragments/keyListing.html
··· 19 19 <button 20 20 class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 21 21 title="Delete key" 22 - hx-delete="/settings/keys?name={{urlquery $key.Name}}&rkey={{urlquery $key.Rkey}}&key={{urlquery $key.Key}}" 22 + hx-delete="/settings/keys?name={{urlquery $key.Name}}&rkey={{urlquery $key.Rkey}}" 23 23 hx-swap="none" 24 24 hx-confirm="Are you sure you want to delete the key {{ $key.Name }}?" 25 25 >
+28 -13
appview/pulls/pulls.go
··· 27 27 "tangled.org/core/appview/notify" 28 28 "tangled.org/core/appview/oauth" 29 29 "tangled.org/core/appview/pages" 30 - "tangled.org/core/appview/pages/markup" 30 + "tangled.org/core/appview/pages/markup/sanitizer" 31 31 "tangled.org/core/appview/pages/repoinfo" 32 32 "tangled.org/core/appview/pagination" 33 33 "tangled.org/core/appview/reporesolver" 34 34 "tangled.org/core/appview/searchquery" 35 - "tangled.org/core/appview/validator" 36 35 "tangled.org/core/appview/xrpcclient" 37 36 "tangled.org/core/idresolver" 38 37 "tangled.org/core/orm" ··· 63 62 notifier notify.Notifier 64 63 enforcer *rbac.Enforcer 65 64 logger *slog.Logger 66 - validator *validator.Validator 67 65 indexer *pulls_indexer.Indexer 68 66 } 69 67 ··· 77 75 config *config.Config, 78 76 notifier notify.Notifier, 79 77 enforcer *rbac.Enforcer, 80 - validator *validator.Validator, 81 78 indexer *pulls_indexer.Indexer, 82 79 logger *slog.Logger, 83 80 ) *Pulls { ··· 92 89 notifier: notifier, 93 90 enforcer: enforcer, 94 91 logger: logger, 95 - validator: validator, 96 92 indexer: indexer, 97 93 } 98 94 } ··· 231 227 232 228 reactionMap, err := db.GetReactionMap(s.db, 20, pull.AtUri()) 233 229 if err != nil { 234 - log.Println("failed to get pull reactions") 230 + s.logger.Error("failed to get pull reaction status", "err", err) 235 231 } 236 232 237 233 userReactions := map[models.ReactionKind]bool{} 238 234 if user != nil { 239 - userReactions = db.GetReactionStatusMap(s.db, user.Active.Did, pull.AtUri()) 235 + userReactions, err = db.GetReactionStatusMap(s.db, user.Active.Did, pull.AtUri()) 236 + if err != nil { 237 + s.logger.Error("failed to get pull reaction status", "err", err) 238 + } 240 239 } 241 240 242 241 labelDefs, err := db.GetLabelDefinitions( ··· 975 974 s.pages.Notice(w, "pull", "Title is required for git-diff patches.") 976 975 return 977 976 } 978 - sanitizer := markup.NewSanitizer() 979 977 if st := strings.TrimSpace(sanitizer.SanitizeDescription(title)); (st) == "" { 980 978 s.pages.Notice(w, "pull", "Title is empty after HTML sanitization") 981 979 return ··· 1103 1101 patch := comparison.FormatPatchRaw 1104 1102 combined := comparison.CombinedPatchRaw 1105 1103 1106 - if err := s.validator.ValidatePatch(&patch); err != nil { 1104 + if err := validatePatch(&patch); err != nil { 1107 1105 s.logger.Error("failed to validate patch", "err", err) 1108 1106 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 1109 1107 return ··· 1121 1119 } 1122 1120 1123 1121 func (s *Pulls) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, repo *models.Repo, user *oauth.MultiAccountUser, title, body, targetBranch, patch string, isStacked bool) { 1124 - if err := s.validator.ValidatePatch(&patch); err != nil { 1122 + if err := validatePatch(&patch); err != nil { 1125 1123 s.logger.Error("patch validation failed", "err", err) 1126 1124 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 1127 1125 return ··· 1213 1211 patch := comparison.FormatPatchRaw 1214 1212 combined := comparison.CombinedPatchRaw 1215 1213 1216 - if err := s.validator.ValidatePatch(&patch); err != nil { 1214 + if err := validatePatch(&patch); err != nil { 1217 1215 s.logger.Error("failed to validate patch", "err", err) 1218 1216 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 1219 1217 return ··· 1506 1504 return 1507 1505 } 1508 1506 1509 - if err := s.validator.ValidatePatch(&patch); err != nil { 1507 + if err := validatePatch(&patch); err != nil { 1510 1508 s.logger.Error("faield to validate patch", "err", err) 1511 1509 s.pages.Notice(w, "patch-error", "Invalid patch format. Please provide a valid git diff or format-patch.") 1512 1510 return ··· 1927 1925 return 1928 1926 } 1929 1927 1930 - if err := s.validator.ValidatePatch(&patch); err != nil { 1928 + if err := validatePatch(&patch); err != nil { 1931 1929 s.pages.Notice(w, "resubmit-error", err.Error()) 1932 1930 return 1933 1931 } ··· 2559 2557 } 2560 2558 2561 2559 func ptrPullState(s models.PullState) *models.PullState { return &s } 2560 + 2561 + func validatePatch(patch *string) error { 2562 + if patch == nil || *patch == "" { 2563 + return fmt.Errorf("patch is empty") 2564 + } 2565 + 2566 + // add newline if not present to diff style patches 2567 + if !patchutil.IsFormatPatch(*patch) && !strings.HasSuffix(*patch, "\n") { 2568 + *patch = *patch + "\n" 2569 + } 2570 + 2571 + if err := patchutil.IsPatchValid(*patch); err != nil { 2572 + return err 2573 + } 2574 + 2575 + return nil 2576 + }
+1 -5
appview/repo/repo.go
··· 20 20 "tangled.org/core/appview/oauth" 21 21 "tangled.org/core/appview/pages" 22 22 "tangled.org/core/appview/reporesolver" 23 - "tangled.org/core/appview/validator" 24 23 xrpcclient "tangled.org/core/appview/xrpcclient" 25 24 "tangled.org/core/eventconsumer" 26 25 "tangled.org/core/idresolver" ··· 49 48 notifier notify.Notifier 50 49 logger *slog.Logger 51 50 serviceAuth *serviceauth.ServiceAuth 52 - validator *validator.Validator 53 51 } 54 52 55 53 func New( ··· 63 61 notifier notify.Notifier, 64 62 enforcer *rbac.Enforcer, 65 63 logger *slog.Logger, 66 - validator *validator.Validator, 67 64 ) *Repo { 68 65 return &Repo{oauth: oauth, 69 66 repoResolver: repoResolver, ··· 75 72 notifier: notifier, 76 73 enforcer: enforcer, 77 74 logger: logger, 78 - validator: validator, 79 75 } 80 76 } 81 77 ··· 225 221 Multiple: multiple, 226 222 Created: time.Now(), 227 223 } 228 - if err := rp.validator.ValidateLabelDefinition(&label); err != nil { 224 + if err := label.Validate(); err != nil { 229 225 fail(err.Error(), err) 230 226 return 231 227 }
+66 -6
appview/repo/settings.go
··· 4 4 "encoding/json" 5 5 "fmt" 6 6 "net/http" 7 + "net/url" 8 + "regexp" 7 9 "slices" 8 10 "strings" 9 11 "time" ··· 15 17 "tangled.org/core/appview/pages" 16 18 xrpcclient "tangled.org/core/appview/xrpcclient" 17 19 "tangled.org/core/orm" 20 + "tangled.org/core/sets" 18 21 "tangled.org/core/types" 19 22 20 23 comatproto "github.com/bluesky-social/indigo/api/atproto" ··· 384 387 topicStr = r.FormValue("topics") 385 388 ) 386 389 387 - err = rp.validator.ValidateURI(website) 388 - if website != "" && err != nil { 389 - l.Error("invalid uri", "err", err) 390 - rp.pages.Notice(w, noticeId, err.Error()) 391 - return 390 + if website != "" { 391 + if err := validateURI(website); err != nil { 392 + l.Error("invalid uri", "err", err) 393 + rp.pages.Notice(w, noticeId, err.Error()) 394 + return 395 + } 392 396 } 393 397 394 - topics, err := rp.validator.ValidateRepoTopicStr(topicStr) 398 + topics, err := parseRepoTopicStr(topicStr) 395 399 if err != nil { 396 400 l.Error("invalid topics", "err", err) 397 401 rp.pages.Notice(w, noticeId, err.Error()) ··· 451 455 452 456 rp.pages.HxRefresh(w) 453 457 } 458 + 459 + const ( 460 + maxTopicLen = 50 461 + maxTopics = 20 462 + ) 463 + 464 + var ( 465 + topicRE = regexp.MustCompile(`\A[a-z0-9-]+\z`) 466 + ) 467 + 468 + // parseRepoTopicStr parses and validates whitespace-separated topic string. 469 + // 470 + // Rules: 471 + // - topics are separated by whitespace 472 + // - each topic may contain lowercase letters, digits, and hyphens only 473 + // - each topic must be <= 50 characters long 474 + // - no more than 20 topics allowed 475 + // - duplicates are removed 476 + func parseRepoTopicStr(topicStr string) ([]string, error) { 477 + topicStr = strings.TrimSpace(topicStr) 478 + if topicStr == "" { 479 + return nil, nil 480 + } 481 + parts := strings.Fields(topicStr) 482 + if len(parts) > maxTopics { 483 + return nil, fmt.Errorf("too many topics: %d (maximum %d)", len(parts), maxTopics) 484 + } 485 + 486 + topicSet := sets.New[string]() 487 + 488 + for _, t := range parts { 489 + if topicSet.Contains(t) { 490 + continue 491 + } 492 + if len(t) > maxTopicLen { 493 + return nil, fmt.Errorf("topic '%s' is too long (maximum %d characters)", t, maxTopics) 494 + } 495 + if !topicRE.MatchString(t) { 496 + return nil, fmt.Errorf("topic '%s' contains invalid characters (allowed: lowercase letters, digits, hyphens)", t) 497 + } 498 + topicSet.Insert(t) 499 + } 500 + return slices.Collect(topicSet.All()), nil 501 + } 502 + 503 + // TODO(boltless): move this to models.Repo instead 504 + func validateURI(uri string) error { 505 + parsed, err := url.Parse(uri) 506 + if err != nil { 507 + return fmt.Errorf("invalid uri format") 508 + } 509 + if parsed.Scheme == "" { 510 + return fmt.Errorf("uri scheme missing") 511 + } 512 + return nil 513 + }
+39 -36
appview/settings/settings.go
··· 24 24 comatproto "github.com/bluesky-social/indigo/api/atproto" 25 25 "github.com/bluesky-social/indigo/atproto/syntax" 26 26 lexutil "github.com/bluesky-social/indigo/lex/util" 27 - "github.com/gliderlabs/ssh" 28 27 "github.com/google/uuid" 29 28 ) 30 29 ··· 424 423 log.Println("unimplemented") 425 424 return 426 425 case http.MethodPut: 427 - did := s.OAuth.GetDid(r) 428 - key := r.FormValue("key") 429 - key = strings.TrimSpace(key) 430 - name := r.FormValue("name") 431 - client, err := s.OAuth.AuthorizedClient(r) 432 - if err != nil { 433 - s.Pages.Notice(w, "settings-keys", "Failed to authorize. Try again later.") 434 - return 426 + created := time.Now() 427 + pubKey := models.PublicKey{ 428 + Did: s.OAuth.GetDid(r), 429 + Rkey: tid.TID(), 430 + Name: r.FormValue("name"), 431 + Key: strings.TrimSpace(r.FormValue("key")), 432 + Created: &created, 435 433 } 436 434 437 - _, _, _, _, err = ssh.ParseAuthorizedKey([]byte(key)) 438 - if err != nil { 435 + if err := pubKey.Validate(); err != nil { 439 436 log.Printf("parsing public key: %s", err) 440 437 s.Pages.Notice(w, "settings-keys", "That doesn't look like a valid public key. Make sure it's a <strong>public</strong> key.") 441 438 return 442 439 } 443 440 444 - rkey := tid.TID() 445 - 446 441 tx, err := s.Db.Begin() 447 442 if err != nil { 448 443 log.Printf("failed to start tx; adding public key: %s", err) ··· 451 446 } 452 447 defer tx.Rollback() 453 448 454 - if err := db.AddPublicKey(tx, did, name, key, rkey); err != nil { 449 + if err = db.UpsertPublicKey(s.Db, pubKey); err != nil { 455 450 log.Printf("adding public key: %s", err) 456 451 s.Pages.Notice(w, "settings-keys", "Failed to add public key.") 452 + return 453 + } 454 + 455 + client, err := s.OAuth.AuthorizedClient(r) 456 + if err != nil { 457 + s.Pages.Notice(w, "settings-keys", "Failed to authorize. Try again later.") 457 458 return 458 459 } 459 460 460 461 // store in pds too 462 + record := pubKey.AsRecord() 461 463 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 462 464 Collection: tangled.PublicKeyNSID, 463 - Repo: did, 464 - Rkey: rkey, 465 + Repo: pubKey.Did, 466 + Rkey: pubKey.Rkey, 465 467 Record: &lexutil.LexiconTypeDecoder{ 466 - Val: &tangled.PublicKey{ 467 - CreatedAt: time.Now().Format(time.RFC3339), 468 - Key: key, 469 - Name: name, 470 - }}, 468 + Val: &record, 469 + }, 471 470 }) 472 471 // invalid record 473 472 if err != nil { ··· 494 493 495 494 name := q.Get("name") 496 495 rkey := q.Get("rkey") 497 - key := q.Get("key") 498 496 499 497 log.Println(name) 500 498 log.Println(rkey) 501 - log.Println(key) 502 499 503 - client, err := s.OAuth.AuthorizedClient(r) 504 - if err != nil { 505 - log.Printf("failed to authorize client: %s", err) 506 - s.Pages.Notice(w, "settings-keys", "Failed to authorize client.") 507 - return 508 - } 500 + if rkey == "" { 501 + if err := db.DeletePublicKeyLegacy(s.Db, did, name); err != nil { 502 + log.Printf("removing public key: %s", err) 503 + s.Pages.Notice(w, "settings-keys", "Failed to remove public key.") 504 + return 505 + } 506 + } else { 507 + if err := db.DeletePublicKeyByRkey(s.Db, did, rkey); err != nil { 508 + log.Printf("removing public key: %s", err) 509 + s.Pages.Notice(w, "settings-keys", "Failed to remove public key.") 510 + return 511 + } 509 512 510 - if err := db.DeletePublicKey(s.Db, did, name, key); err != nil { 511 - log.Printf("removing public key: %s", err) 512 - s.Pages.Notice(w, "settings-keys", "Failed to remove public key.") 513 - return 514 - } 513 + client, err := s.OAuth.AuthorizedClient(r) 514 + if err != nil { 515 + log.Printf("failed to authorize client: %s", err) 516 + s.Pages.Notice(w, "settings-keys", "Failed to authorize client.") 517 + return 518 + } 515 519 516 - if rkey != "" { 517 520 // remove from pds too 518 - _, err := comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 521 + _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 519 522 Collection: tangled.PublicKeyNSID, 520 523 Repo: did, 521 524 Rkey: rkey,
+59 -34
appview/state/follow.go
··· 6 6 "time" 7 7 8 8 comatproto "github.com/bluesky-social/indigo/api/atproto" 9 + "github.com/bluesky-social/indigo/atproto/syntax" 9 10 lexutil "github.com/bluesky-social/indigo/lex/util" 10 11 "tangled.org/core/api/tangled" 11 12 "tangled.org/core/appview/db" ··· 42 43 43 44 switch r.Method { 44 45 case http.MethodPost: 45 - createdAt := time.Now().Format(time.RFC3339) 46 - rkey := tid.TID() 46 + follow := models.Follow{ 47 + UserDid: currentUser.Active.Did, 48 + SubjectDid: subjectIdent.DID.String(), 49 + Rkey: tid.TID(), 50 + FollowedAt: time.Now(), 51 + } 52 + 53 + tx, err := s.db.BeginTx(r.Context(), nil) 54 + if err != nil { 55 + s.logger.Error("failed to start transaction", "err", err) 56 + return 57 + } 58 + defer tx.Rollback() 59 + 60 + if err := db.UpsertFollow(tx, follow); err != nil { 61 + s.logger.Error("failed to follow", "err", err) 62 + return 63 + } 64 + 65 + record := follow.AsRecord() 47 66 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 48 67 Collection: tangled.GraphFollowNSID, 49 68 Repo: currentUser.Active.Did, 50 - Rkey: rkey, 69 + Rkey: follow.Rkey, 51 70 Record: &lexutil.LexiconTypeDecoder{ 52 - Val: &tangled.GraphFollow{ 53 - Subject: subjectIdent.DID.String(), 54 - CreatedAt: createdAt, 55 - }}, 71 + Val: &record, 72 + }, 56 73 }) 57 74 if err != nil { 58 75 log.Println("failed to create atproto record", err) 59 76 return 60 77 } 61 - 62 78 log.Println("created atproto record: ", resp.Uri) 63 79 64 - follow := &models.Follow{ 65 - UserDid: currentUser.Active.Did, 66 - SubjectDid: subjectIdent.DID.String(), 67 - Rkey: rkey, 80 + if err := tx.Commit(); err != nil { 81 + s.logger.Error("failed to commit transaction", "err", err) 82 + // DB op failed but record is created in PDS. Ingester will backfill the missed operation 68 83 } 69 84 70 - err = db.AddFollow(s.db, follow) 71 - if err != nil { 72 - log.Println("failed to follow", err) 73 - return 74 - } 75 - 76 - s.notifier.NewFollow(r.Context(), follow) 85 + s.notifier.NewFollow(r.Context(), &follow) 77 86 78 87 followStats, err := db.GetFollowerFollowingCount(s.db, subjectIdent.DID.String()) 79 88 if err != nil { ··· 88 97 89 98 return 90 99 case http.MethodDelete: 91 - // find the record in the db 92 - follow, err := db.GetFollow(s.db, currentUser.Active.Did, subjectIdent.DID.String()) 100 + tx, err := s.db.BeginTx(r.Context(), nil) 93 101 if err != nil { 94 - log.Println("failed to get follow relationship") 102 + s.logger.Error("failed to start transaction", "err", err) 103 + } 104 + defer tx.Rollback() 105 + 106 + follows, err := db.DeleteFollow(tx, syntax.DID(currentUser.Active.Did), subjectIdent.DID) 107 + if err != nil { 108 + s.logger.Error("failed to delete follows from db", "err", err) 95 109 return 96 110 } 97 111 98 - _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 99 - Collection: tangled.GraphFollowNSID, 100 - Repo: currentUser.Active.Did, 101 - Rkey: follow.Rkey, 112 + var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem 113 + for _, followAt := range follows { 114 + writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 115 + RepoApplyWrites_Delete: &comatproto.RepoApplyWrites_Delete{ 116 + Collection: tangled.GraphFollowNSID, 117 + Rkey: followAt.RecordKey().String(), 118 + }, 119 + }) 120 + } 121 + _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{ 122 + Repo: currentUser.Active.Did, 123 + Writes: writes, 102 124 }) 103 - 104 125 if err != nil { 105 - log.Println("failed to unfollow") 126 + s.logger.Error("failed to delete follows from PDS", "err", err) 106 127 return 107 128 } 108 129 109 - err = db.DeleteFollowByRkey(s.db, currentUser.Active.Did, follow.Rkey) 110 - if err != nil { 111 - log.Println("failed to delete follow from DB") 112 - // this is not an issue, the firehose event might have already done this 130 + if err := tx.Commit(); err != nil { 131 + s.logger.Error("failed to commit transaction", "err", err) 132 + // DB op failed but record is created in PDS. Ingester will backfill the missed operation 113 133 } 134 + 135 + s.notifier.DeleteFollow(r.Context(), &models.Follow{ 136 + UserDid: currentUser.Active.Did, 137 + SubjectDid: subjectIdent.DID.String(), 138 + // Rkey 139 + // FollowedAt 140 + }) 114 141 115 142 followStats, err := db.GetFollowerFollowingCount(s.db, subjectIdent.DID.String()) 116 143 if err != nil { ··· 122 149 FollowStatus: models.IsNotFollowing, 123 150 FollowersCount: followStats.Followers, 124 151 }) 125 - 126 - s.notifier.DeleteFollow(r.Context(), follow) 127 152 128 153 return 129 154 }
+12 -5
appview/state/profile.go
··· 661 661 s.pages.Notice(w, "update-profile", "Failed to update profile, try again later.") 662 662 return 663 663 } 664 + defer tx.Rollback() 665 + 666 + err = db.UpsertProfile(tx, profile) 667 + if err != nil { 668 + log.Println("failed to update profile", err) 669 + s.pages.Notice(w, "update-profile", "Failed to update profile, try again later.") 670 + return 671 + } 664 672 665 673 client, err := s.oauth.AuthorizedClient(r) 666 674 if err != nil { ··· 709 717 return 710 718 } 711 719 712 - err = db.UpsertProfile(tx, profile) 713 - if err != nil { 714 - log.Println("failed to update profile", err) 715 - s.pages.Notice(w, "update-profile", "Failed to update profile, try again later.") 716 - return 720 + if err := tx.Commit(); err != nil { 721 + s.logger.Error("failed to commit transaction", "err", err) 722 + // db failed, but PDS operation succeed. 723 + // log error and continue 717 724 } 718 725 719 726 s.notifier.UpdateProfile(r.Context(), profile)
+51 -26
appview/state/reaction.go
··· 45 45 46 46 switch r.Method { 47 47 case http.MethodPost: 48 - createdAt := time.Now().Format(time.RFC3339) 49 - rkey := tid.TID() 48 + reaction := models.Reaction{ 49 + ReactedByDid: currentUser.Active.Did, 50 + Rkey: tid.TID(), 51 + Kind: reactionKind, 52 + ThreadAt: subjectUri, 53 + Created: time.Now(), 54 + } 55 + 56 + tx, err := s.db.BeginTx(r.Context(), nil) 57 + if err != nil { 58 + s.logger.Error("failed to start transaction", "err", err) 59 + return 60 + } 61 + defer tx.Rollback() 62 + 63 + if err := db.UpsertReaction(tx, reaction); err != nil { 64 + log.Println("failed to react", err) 65 + return 66 + } 67 + 68 + record := reaction.AsRecord() 50 69 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 51 70 Collection: tangled.FeedReactionNSID, 52 71 Repo: currentUser.Active.Did, 53 - Rkey: rkey, 72 + Rkey: reaction.Rkey, 54 73 Record: &lexutil.LexiconTypeDecoder{ 55 - Val: &tangled.FeedReaction{ 56 - Subject: subjectUri.String(), 57 - Reaction: reactionKind.String(), 58 - CreatedAt: createdAt, 59 - }, 74 + Val: &record, 60 75 }, 61 76 }) 62 77 if err != nil { 63 78 log.Println("failed to create atproto record", err) 64 79 return 65 80 } 81 + log.Println("created atproto record: ", resp.Uri) 66 82 67 - err = db.AddReaction(s.db, currentUser.Active.Did, subjectUri, reactionKind, rkey) 68 - if err != nil { 69 - log.Println("failed to react", err) 70 - return 83 + if err := tx.Commit(); err != nil { 84 + s.logger.Error("failed to commit transaction", "err", err) 85 + // DB op failed but record is created in PDS. Ingester will backfill the missed operation 71 86 } 72 87 73 88 reactionMap, err := db.GetReactionMap(s.db, 20, subjectUri) ··· 75 90 log.Println("failed to get reactions for ", subjectUri) 76 91 } 77 92 78 - log.Println("created atproto record: ", resp.Uri) 79 - 80 93 s.pages.ThreadReactionFragment(w, pages.ThreadReactionFragmentParams{ 81 94 ThreadAt: subjectUri, 82 95 Kind: reactionKind, ··· 87 100 88 101 return 89 102 case http.MethodDelete: 90 - reaction, err := db.GetReaction(s.db, currentUser.Active.Did, subjectUri, reactionKind) 103 + tx, err := s.db.BeginTx(r.Context(), nil) 104 + if err != nil { 105 + s.logger.Error("failed to start transaction", "err", err) 106 + } 107 + defer tx.Rollback() 108 + 109 + reactions, err := db.DeleteReaction(tx, syntax.DID(currentUser.Active.Did), subjectUri, reactionKind) 91 110 if err != nil { 92 - log.Println("failed to get reaction relationship for", currentUser.Active.Did, subjectUri) 111 + s.logger.Error("failed to delete reactions from db", "err", err) 93 112 return 94 113 } 95 114 96 - _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 97 - Collection: tangled.FeedReactionNSID, 98 - Repo: currentUser.Active.Did, 99 - Rkey: reaction.Rkey, 115 + var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem 116 + for _, reactionAt := range reactions { 117 + writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 118 + RepoApplyWrites_Delete: &comatproto.RepoApplyWrites_Delete{ 119 + Collection: tangled.FeedReactionNSID, 120 + Rkey: reactionAt.RecordKey().String(), 121 + }, 122 + }) 123 + } 124 + _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{ 125 + Repo: currentUser.Active.Did, 126 + Writes: writes, 100 127 }) 101 - 102 128 if err != nil { 103 - log.Println("failed to remove reaction") 129 + s.logger.Error("failed to delete reactions from PDS", "err", err) 104 130 return 105 131 } 106 132 107 - err = db.DeleteReactionByRkey(s.db, currentUser.Active.Did, reaction.Rkey) 108 - if err != nil { 109 - log.Println("failed to delete reaction from DB") 110 - // this is not an issue, the firehose event might have already done this 133 + if err := tx.Commit(); err != nil { 134 + s.logger.Error("failed to commit transaction", "err", err) 135 + // DB op failed but record is created in PDS. Ingester will backfill the missed operation 111 136 } 112 137 113 138 reactionMap, err := db.GetReactionMap(s.db, 20, subjectUri)
-4
appview/state/router.go
··· 278 278 s.db, 279 279 s.config, 280 280 s.notifier, 281 - s.validator, 282 281 s.indexer.Issues, 283 282 log.SubLogger(s.logger, "issues"), 284 283 ) ··· 296 295 s.config, 297 296 s.notifier, 298 297 s.enforcer, 299 - s.validator, 300 298 s.indexer.Pulls, 301 299 log.SubLogger(s.logger, "pulls"), 302 300 ) ··· 315 313 s.notifier, 316 314 s.enforcer, 317 315 log.SubLogger(s.logger, "repo"), 318 - s.validator, 319 316 ) 320 317 return repo.Router(mw) 321 318 } ··· 340 337 s.oauth, 341 338 s.pages, 342 339 s.db, 343 - s.validator, 344 340 s.enforcer, 345 341 s.notifier, 346 342 log.SubLogger(s.logger, "labels"),
+58 -33
appview/state/star.go
··· 38 38 39 39 switch r.Method { 40 40 case http.MethodPost: 41 - createdAt := time.Now().Format(time.RFC3339) 42 - rkey := tid.TID() 41 + star := models.Star{ 42 + Did: currentUser.Active.Did, 43 + Rkey: tid.TID(), 44 + RepoAt: subjectUri, 45 + Created: time.Now(), 46 + } 47 + 48 + tx, err := s.db.BeginTx(r.Context(), nil) 49 + if err != nil { 50 + s.logger.Error("failed to start transaction", "err", err) 51 + return 52 + } 53 + defer tx.Rollback() 54 + 55 + if err := db.UpsertStar(tx, star); err != nil { 56 + s.logger.Error("failed to star", "err", err) 57 + return 58 + } 59 + 60 + record := star.AsRecord() 43 61 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 44 62 Collection: tangled.FeedStarNSID, 45 63 Repo: currentUser.Active.Did, 46 - Rkey: rkey, 64 + Rkey: star.Rkey, 47 65 Record: &lexutil.LexiconTypeDecoder{ 48 - Val: &tangled.FeedStar{ 49 - Subject: subjectUri.String(), 50 - CreatedAt: createdAt, 51 - }}, 66 + Val: &record, 67 + }, 52 68 }) 53 69 if err != nil { 54 70 log.Println("failed to create atproto record", err) ··· 56 72 } 57 73 log.Println("created atproto record: ", resp.Uri) 58 74 59 - star := &models.Star{ 60 - Did: currentUser.Active.Did, 61 - RepoAt: subjectUri, 62 - Rkey: rkey, 75 + if err := tx.Commit(); err != nil { 76 + s.logger.Error("failed to commit transaction", "err", err) 77 + // DB op failed but record is created in PDS. Ingester will backfill the missed operation 63 78 } 64 79 65 - err = db.AddStar(s.db, star) 66 - if err != nil { 67 - log.Println("failed to star", err) 68 - return 69 - } 80 + s.notifier.NewStar(r.Context(), &star) 70 81 71 82 starCount, err := db.GetStarCount(s.db, subjectUri) 72 83 if err != nil { 73 84 log.Println("failed to get star count for ", subjectUri) 74 85 } 75 - 76 - s.notifier.NewStar(r.Context(), star) 77 86 78 87 s.pages.StarBtnFragment(w, pages.StarBtnFragmentParams{ 79 88 IsStarred: true, ··· 83 92 84 93 return 85 94 case http.MethodDelete: 86 - // find the record in the db 87 - star, err := db.GetStar(s.db, currentUser.Active.Did, subjectUri) 95 + tx, err := s.db.BeginTx(r.Context(), nil) 88 96 if err != nil { 89 - log.Println("failed to get star relationship") 97 + s.logger.Error("failed to start transaction", "err", err) 98 + } 99 + defer tx.Rollback() 100 + 101 + stars, err := db.DeleteStar(tx, syntax.DID(currentUser.Active.Did), subjectUri) 102 + if err != nil { 103 + s.logger.Error("failed to delete stars from db", "err", err) 90 104 return 91 105 } 92 106 93 - _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 94 - Collection: tangled.FeedStarNSID, 95 - Repo: currentUser.Active.Did, 96 - Rkey: star.Rkey, 107 + var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem 108 + for _, starAt := range stars { 109 + writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 110 + RepoApplyWrites_Delete: &comatproto.RepoApplyWrites_Delete{ 111 + Collection: tangled.FeedStarNSID, 112 + Rkey: starAt.RecordKey().String(), 113 + }, 114 + }) 115 + } 116 + _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{ 117 + Repo: currentUser.Active.Did, 118 + Writes: writes, 97 119 }) 98 - 99 120 if err != nil { 100 - log.Println("failed to unstar") 121 + s.logger.Error("failed to delete stars from PDS", "err", err) 101 122 return 102 123 } 103 124 104 - err = db.DeleteStarByRkey(s.db, currentUser.Active.Did, star.Rkey) 105 - if err != nil { 106 - log.Println("failed to delete star from DB") 107 - // this is not an issue, the firehose event might have already done this 125 + if err := tx.Commit(); err != nil { 126 + s.logger.Error("failed to commit transaction", "err", err) 127 + // DB op failed but record is created in PDS. Ingester will backfill the missed operation 108 128 } 109 129 130 + s.notifier.DeleteStar(r.Context(), &models.Star{ 131 + Did: currentUser.Active.Did, 132 + RepoAt: subjectUri, 133 + // Rkey 134 + // Created 135 + }) 136 + 110 137 starCount, err := db.GetStarCount(s.db, subjectUri) 111 138 if err != nil { 112 139 log.Println("failed to get star count for ", subjectUri) 113 140 return 114 141 } 115 - 116 - s.notifier.DeleteStar(r.Context(), star) 117 142 118 143 s.pages.StarBtnFragment(w, pages.StarBtnFragmentParams{ 119 144 IsStarred: false,
-5
appview/state/state.go
··· 24 24 "tangled.org/core/appview/oauth" 25 25 "tangled.org/core/appview/pages" 26 26 "tangled.org/core/appview/reporesolver" 27 - "tangled.org/core/appview/validator" 28 27 xrpcclient "tangled.org/core/appview/xrpcclient" 29 28 "tangled.org/core/consts" 30 29 "tangled.org/core/eventconsumer" ··· 62 61 knotstream *eventconsumer.Consumer 63 62 spindlestream *eventconsumer.Consumer 64 63 logger *slog.Logger 65 - validator *validator.Validator 66 64 } 67 65 68 66 func Make(ctx context.Context, config *config.Config) (*State, error) { ··· 100 98 if err != nil { 101 99 return nil, fmt.Errorf("failed to start oauth handler: %w", err) 102 100 } 103 - validator := validator.New(d, res, enforcer) 104 101 105 102 repoResolver := reporesolver.New(config, enforcer, d) 106 103 ··· 148 145 IdResolver: res, 149 146 Config: config, 150 147 Logger: log.SubLogger(logger, "ingester"), 151 - Validator: validator, 152 148 } 153 149 err = jc.StartJetstream(ctx, ingester.Ingest()) 154 150 if err != nil { ··· 200 196 knotstream, 201 197 spindlestream, 202 198 logger, 203 - validator, 204 199 } 205 200 206 201 // fetch initial bluesky posts if configured
-55
appview/validator/issue.go
··· 1 - package validator 2 - 3 - import ( 4 - "fmt" 5 - "strings" 6 - 7 - "tangled.org/core/appview/db" 8 - "tangled.org/core/appview/models" 9 - "tangled.org/core/orm" 10 - ) 11 - 12 - func (v *Validator) ValidateIssueComment(comment *models.IssueComment) error { 13 - // if comments have parents, only ingest ones that are 1 level deep 14 - if comment.ReplyTo != nil { 15 - parents, err := db.GetIssueComments(v.db, orm.FilterEq("at_uri", *comment.ReplyTo)) 16 - if err != nil { 17 - return fmt.Errorf("failed to fetch parent comment: %w", err) 18 - } 19 - if len(parents) != 1 { 20 - return fmt.Errorf("incorrect number of parent comments returned: %d", len(parents)) 21 - } 22 - 23 - // depth check 24 - parent := parents[0] 25 - if parent.ReplyTo != nil { 26 - return fmt.Errorf("incorrect depth, this comment is replying at depth >1") 27 - } 28 - } 29 - 30 - if sb := strings.TrimSpace(v.sanitizer.SanitizeDefault(comment.Body)); sb == "" { 31 - return fmt.Errorf("body is empty after HTML sanitization") 32 - } 33 - 34 - return nil 35 - } 36 - 37 - func (v *Validator) ValidateIssue(issue *models.Issue) error { 38 - if issue.Title == "" { 39 - return fmt.Errorf("issue title is empty") 40 - } 41 - 42 - if issue.Body == "" { 43 - return fmt.Errorf("issue body is empty") 44 - } 45 - 46 - if st := strings.TrimSpace(v.sanitizer.SanitizeDescription(issue.Title)); st == "" { 47 - return fmt.Errorf("title is empty after HTML sanitization") 48 - } 49 - 50 - if sb := strings.TrimSpace(v.sanitizer.SanitizeDefault(issue.Body)); sb == "" { 51 - return fmt.Errorf("body is empty after HTML sanitization") 52 - } 53 - 54 - return nil 55 - }
-217
appview/validator/label.go
··· 1 - package validator 2 - 3 - import ( 4 - "context" 5 - "fmt" 6 - "regexp" 7 - "slices" 8 - "strings" 9 - 10 - "github.com/bluesky-social/indigo/atproto/syntax" 11 - "tangled.org/core/api/tangled" 12 - "tangled.org/core/appview/models" 13 - ) 14 - 15 - var ( 16 - // Label name should be alphanumeric with hyphens/underscores, but not start/end with them 17 - labelNameRegex = regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9_-]*[a-zA-Z0-9])?$`) 18 - // Color should be a valid hex color 19 - colorRegex = regexp.MustCompile(`^#[a-fA-F0-9]{6}$`) 20 - // You can only label issues and pulls presently 21 - validScopes = []string{tangled.RepoIssueNSID, tangled.RepoPullNSID} 22 - ) 23 - 24 - func (v *Validator) ValidateLabelDefinition(label *models.LabelDefinition) error { 25 - if label.Name == "" { 26 - return fmt.Errorf("label name is empty") 27 - } 28 - if len(label.Name) > 40 { 29 - return fmt.Errorf("label name too long (max 40 graphemes)") 30 - } 31 - if len(label.Name) < 1 { 32 - return fmt.Errorf("label name too short (min 1 grapheme)") 33 - } 34 - if !labelNameRegex.MatchString(label.Name) { 35 - return fmt.Errorf("label name contains invalid characters (use only letters, numbers, hyphens, and underscores)") 36 - } 37 - 38 - if !label.ValueType.IsConcreteType() { 39 - return fmt.Errorf("invalid value type: %q (must be one of: null, boolean, integer, string)", label.ValueType.Type) 40 - } 41 - 42 - // null type checks: cannot be enums, multiple or explicit format 43 - if label.ValueType.IsNull() && label.ValueType.IsEnum() { 44 - return fmt.Errorf("null type cannot be used in conjunction with enum type") 45 - } 46 - if label.ValueType.IsNull() && label.Multiple { 47 - return fmt.Errorf("null type labels cannot be multiple") 48 - } 49 - if label.ValueType.IsNull() && !label.ValueType.IsAnyFormat() { 50 - return fmt.Errorf("format cannot be used in conjunction with null type") 51 - } 52 - 53 - // format checks: cannot be used with enum, or integers 54 - if !label.ValueType.IsAnyFormat() && label.ValueType.IsEnum() { 55 - return fmt.Errorf("enum types cannot be used in conjunction with format specification") 56 - } 57 - 58 - if !label.ValueType.IsAnyFormat() && !label.ValueType.IsString() { 59 - return fmt.Errorf("format specifications are only permitted on string types") 60 - } 61 - 62 - // validate scope (nsid format) 63 - if label.Scope == nil { 64 - return fmt.Errorf("scope is required") 65 - } 66 - for _, s := range label.Scope { 67 - if _, err := syntax.ParseNSID(s); err != nil { 68 - return fmt.Errorf("failed to parse scope: %w", err) 69 - } 70 - if !slices.Contains(validScopes, s) { 71 - return fmt.Errorf("invalid scope: scope must be present in %q", validScopes) 72 - } 73 - } 74 - 75 - // validate color if provided 76 - if label.Color != nil { 77 - color := strings.TrimSpace(*label.Color) 78 - if color == "" { 79 - // empty color is fine, set to nil 80 - label.Color = nil 81 - } else { 82 - if !colorRegex.MatchString(color) { 83 - return fmt.Errorf("color must be a valid hex color (e.g. #79FFE1 or #000)") 84 - } 85 - // expand 3-digit hex to 6-digit hex 86 - if len(color) == 4 { // #ABC 87 - color = fmt.Sprintf("#%c%c%c%c%c%c", color[1], color[1], color[2], color[2], color[3], color[3]) 88 - } 89 - // convert to uppercase for consistency 90 - color = strings.ToUpper(color) 91 - label.Color = &color 92 - } 93 - } 94 - 95 - return nil 96 - } 97 - 98 - func (v *Validator) ValidateLabelOp(labelDef *models.LabelDefinition, repo *models.Repo, labelOp *models.LabelOp) error { 99 - if labelDef == nil { 100 - return fmt.Errorf("label definition is required") 101 - } 102 - if repo == nil { 103 - return fmt.Errorf("repo is required") 104 - } 105 - if labelOp == nil { 106 - return fmt.Errorf("label operation is required") 107 - } 108 - 109 - // validate permissions: only collaborators can apply labels currently 110 - // 111 - // TODO: introduce a repo:triage permission 112 - ok, err := v.enforcer.IsPushAllowed(labelOp.Did, repo.Knot, repo.DidSlashRepo()) 113 - if err != nil { 114 - return fmt.Errorf("failed to enforce permissions: %w", err) 115 - } 116 - if !ok { 117 - return fmt.Errorf("unauhtorized label operation") 118 - } 119 - 120 - expectedKey := labelDef.AtUri().String() 121 - if labelOp.OperandKey != expectedKey { 122 - return fmt.Errorf("operand key %q does not match label definition URI %q", labelOp.OperandKey, expectedKey) 123 - } 124 - 125 - if labelOp.Operation != models.LabelOperationAdd && labelOp.Operation != models.LabelOperationDel { 126 - return fmt.Errorf("invalid operation: %q (must be 'add' or 'del')", labelOp.Operation) 127 - } 128 - 129 - if labelOp.Subject == "" { 130 - return fmt.Errorf("subject URI is required") 131 - } 132 - if _, err := syntax.ParseATURI(string(labelOp.Subject)); err != nil { 133 - return fmt.Errorf("invalid subject URI: %w", err) 134 - } 135 - 136 - if err := v.validateOperandValue(labelDef, labelOp); err != nil { 137 - return fmt.Errorf("invalid operand value: %w", err) 138 - } 139 - 140 - // Validate performed time is not zero/invalid 141 - if labelOp.PerformedAt.IsZero() { 142 - return fmt.Errorf("performed_at timestamp is required") 143 - } 144 - 145 - return nil 146 - } 147 - 148 - func (v *Validator) validateOperandValue(labelDef *models.LabelDefinition, labelOp *models.LabelOp) error { 149 - valueType := labelDef.ValueType 150 - 151 - // this is permitted, it "unsets" a label 152 - if labelOp.OperandValue == "" { 153 - labelOp.Operation = models.LabelOperationDel 154 - return nil 155 - } 156 - 157 - switch valueType.Type { 158 - case models.ConcreteTypeNull: 159 - // For null type, value should be empty 160 - if labelOp.OperandValue != "null" { 161 - return fmt.Errorf("null type requires empty value, got %q", labelOp.OperandValue) 162 - } 163 - 164 - case models.ConcreteTypeString: 165 - // For string type, validate enum constraints if present 166 - if valueType.IsEnum() { 167 - if !slices.Contains(valueType.Enum, labelOp.OperandValue) { 168 - return fmt.Errorf("value %q is not in allowed enum values %v", labelOp.OperandValue, valueType.Enum) 169 - } 170 - } 171 - 172 - switch valueType.Format { 173 - case models.ValueTypeFormatDid: 174 - id, err := v.resolver.ResolveIdent(context.Background(), labelOp.OperandValue) 175 - if err != nil { 176 - return fmt.Errorf("failed to resolve did/handle: %w", err) 177 - } 178 - 179 - labelOp.OperandValue = id.DID.String() 180 - 181 - case models.ValueTypeFormatAny, "": 182 - default: 183 - return fmt.Errorf("unsupported format constraint: %q", valueType.Format) 184 - } 185 - 186 - case models.ConcreteTypeInt: 187 - if labelOp.OperandValue == "" { 188 - return fmt.Errorf("integer type requires non-empty value") 189 - } 190 - if _, err := fmt.Sscanf(labelOp.OperandValue, "%d", new(int)); err != nil { 191 - return fmt.Errorf("value %q is not a valid integer", labelOp.OperandValue) 192 - } 193 - 194 - if valueType.IsEnum() { 195 - if !slices.Contains(valueType.Enum, labelOp.OperandValue) { 196 - return fmt.Errorf("value %q is not in allowed enum values %v", labelOp.OperandValue, valueType.Enum) 197 - } 198 - } 199 - 200 - case models.ConcreteTypeBool: 201 - if labelOp.OperandValue != "true" && labelOp.OperandValue != "false" { 202 - return fmt.Errorf("boolean type requires value to be 'true' or 'false', got %q", labelOp.OperandValue) 203 - } 204 - 205 - // validate enum constraints if present (though uncommon for booleans) 206 - if valueType.IsEnum() { 207 - if !slices.Contains(valueType.Enum, labelOp.OperandValue) { 208 - return fmt.Errorf("value %q is not in allowed enum values %v", labelOp.OperandValue, valueType.Enum) 209 - } 210 - } 211 - 212 - default: 213 - return fmt.Errorf("unsupported value type: %q", valueType.Type) 214 - } 215 - 216 - return nil 217 - }
-25
appview/validator/patch.go
··· 1 - package validator 2 - 3 - import ( 4 - "fmt" 5 - "strings" 6 - 7 - "tangled.org/core/patchutil" 8 - ) 9 - 10 - func (v *Validator) ValidatePatch(patch *string) error { 11 - if patch == nil || *patch == "" { 12 - return fmt.Errorf("patch is empty") 13 - } 14 - 15 - // add newline if not present to diff style patches 16 - if !patchutil.IsFormatPatch(*patch) && !strings.HasSuffix(*patch, "\n") { 17 - *patch = *patch + "\n" 18 - } 19 - 20 - if err := patchutil.IsPatchValid(*patch); err != nil { 21 - return err 22 - } 23 - 24 - return nil 25 - }
-53
appview/validator/repo_topics.go
··· 1 - package validator 2 - 3 - import ( 4 - "fmt" 5 - "maps" 6 - "regexp" 7 - "slices" 8 - "strings" 9 - ) 10 - 11 - const ( 12 - maxTopicLen = 50 13 - maxTopics = 20 14 - ) 15 - 16 - var ( 17 - topicRE = regexp.MustCompile(`\A[a-z0-9-]+\z`) 18 - ) 19 - 20 - // ValidateRepoTopicStr parses and validates whitespace-separated topic string. 21 - // 22 - // Rules: 23 - // - topics are separated by whitespace 24 - // - each topic may contain lowercase letters, digits, and hyphens only 25 - // - each topic must be <= 50 characters long 26 - // - no more than 20 topics allowed 27 - // - duplicates are removed 28 - func (v *Validator) ValidateRepoTopicStr(topicsStr string) ([]string, error) { 29 - topicsStr = strings.TrimSpace(topicsStr) 30 - if topicsStr == "" { 31 - return nil, nil 32 - } 33 - parts := strings.Fields(topicsStr) 34 - if len(parts) > maxTopics { 35 - return nil, fmt.Errorf("too many topics: %d (maximum %d)", len(parts), maxTopics) 36 - } 37 - 38 - topicSet := make(map[string]struct{}) 39 - 40 - for _, t := range parts { 41 - if _, exists := topicSet[t]; exists { 42 - continue 43 - } 44 - if len(t) > maxTopicLen { 45 - return nil, fmt.Errorf("topic '%s' is too long (maximum %d characters)", t, maxTopics) 46 - } 47 - if !topicRE.MatchString(t) { 48 - return nil, fmt.Errorf("topic '%s' contains invalid characters (allowed: lowercase letters, digits, hyphens)", t) 49 - } 50 - topicSet[t] = struct{}{} 51 - } 52 - return slices.Collect(maps.Keys(topicSet)), nil 53 - }
-27
appview/validator/string.go
··· 1 - package validator 2 - 3 - import ( 4 - "errors" 5 - "fmt" 6 - "unicode/utf8" 7 - 8 - "tangled.org/core/appview/models" 9 - ) 10 - 11 - func (v *Validator) ValidateString(s *models.String) error { 12 - var err error 13 - 14 - if utf8.RuneCountInString(s.Filename) > 140 { 15 - err = errors.Join(err, fmt.Errorf("filename too long")) 16 - } 17 - 18 - if utf8.RuneCountInString(s.Description) > 280 { 19 - err = errors.Join(err, fmt.Errorf("description too long")) 20 - } 21 - 22 - if len(s.Contents) == 0 { 23 - err = errors.Join(err, fmt.Errorf("contents is empty")) 24 - } 25 - 26 - return err 27 - }
-17
appview/validator/uri.go
··· 1 - package validator 2 - 3 - import ( 4 - "fmt" 5 - "net/url" 6 - ) 7 - 8 - func (v *Validator) ValidateURI(uri string) error { 9 - parsed, err := url.Parse(uri) 10 - if err != nil { 11 - return fmt.Errorf("invalid uri format") 12 - } 13 - if parsed.Scheme == "" { 14 - return fmt.Errorf("uri scheme missing") 15 - } 16 - return nil 17 - }
-24
appview/validator/validator.go
··· 1 - package validator 2 - 3 - import ( 4 - "tangled.org/core/appview/db" 5 - "tangled.org/core/appview/pages/markup" 6 - "tangled.org/core/idresolver" 7 - "tangled.org/core/rbac" 8 - ) 9 - 10 - type Validator struct { 11 - db *db.DB 12 - sanitizer markup.Sanitizer 13 - resolver *idresolver.Resolver 14 - enforcer *rbac.Enforcer 15 - } 16 - 17 - func New(db *db.DB, res *idresolver.Resolver, enforcer *rbac.Enforcer) *Validator { 18 - return &Validator{ 19 - db: db, 20 - sanitizer: markup.NewSanitizer(), 21 - resolver: res, 22 - enforcer: enforcer, 23 - } 24 - }
+11
contrib/certs/root.crt
··· 1 + -----BEGIN CERTIFICATE----- 2 + MIIBpDCCAUqgAwIBAgIRAIu1RX0P2Js35XIiiJJgAmgwCgYIKoZIzj0EAwIwMDEu 3 + MCwGA1UEAxMlQ2FkZHkgTG9jYWwgQXV0aG9yaXR5IC0gMjAyNiBFQ0MgUm9vdDAe 4 + Fw0yNjAzMDkxNTU3NTVaFw0zNjAxMTYxNTU3NTVaMDAxLjAsBgNVBAMTJUNhZGR5 5 + IExvY2FsIEF1dGhvcml0eSAtIDIwMjYgRUNDIFJvb3QwWTATBgcqhkjOPQIBBggq 6 + hkjOPQMBBwNCAASazquLyfq/CAPnJUlPhHUIgH4CMqXcKUZ/eLpkVg5HZrqmOhEo 7 + ma0p/EaNJJ1y390TxJ0Z401ZtwsKV3bBvny6o0UwQzAOBgNVHQ8BAf8EBAMCAQYw 8 + EgYDVR0TAQH/BAgwBgEB/wIBATAdBgNVHQ4EFgQUaQbcT77nBTGxgVWPHxoEKa1s 9 + U5MwCgYIKoZIzj0EAwIDSAAwRQIgZVJ5unzemUax0EVHs91KGBInjwrK1B1M46Ji 10 + wzG3Ws8CIQD6251zR7YO/omTeShaXRZ3ctCzsMbW2Ic/tz/aLy6h/A== 11 + -----END CERTIFICATE-----
+31
contrib/example.env
··· 1 + # NOTE: put actual DIDs here 2 + alice_did=did:plc:alice-did 3 + tangled_did=did:plc:tangled-did 4 + 5 + #core 6 + export TANGLED_DEV=true 7 + export TANGLED_APPVIEW_HOST=127.0.0.1:3000 8 + # plc 9 + export TANGLED_PLC_URL=https://plc.tngl.boltless.dev 10 + # jetstream 11 + export TANGLED_JETSTREAM_ENDPOINT=wss://jetstream.tngl.boltless.dev/subscribe 12 + # label 13 + export TANGLED_LABEL_GFI=at://${tangled_did}/sh.tangled.label.definition/good-first-issue 14 + export TANGLED_LABEL_DEFAULTS=$TANGLED_LABEL_GFI 15 + export TANGLED_LABEL_DEFAULTS=$TANGLED_LABEL_DEFAULTS,at://${tangled_did}/sh.tangled.label.definition/assignee 16 + export TANGLED_LABEL_DEFAULTS=$TANGLED_LABEL_DEFAULTS,at://${tangled_did}/sh.tangled.label.definition/documentation 17 + export TANGLED_LABEL_DEFAULTS=$TANGLED_LABEL_DEFAULTS,at://${tangled_did}/sh.tangled.label.definition/duplicate 18 + export TANGLED_LABEL_DEFAULTS=$TANGLED_LABEL_DEFAULTS,at://${tangled_did}/sh.tangled.label.definition/wontfix 19 + 20 + # vm settings 21 + export TANGLED_VM_PLC_URL=https://plc.tngl.boltless.dev 22 + export TANGLED_VM_JETSTREAM_ENDPOINT=wss://jetstream.tngl.boltless.dev/subscribe 23 + export TANGLED_VM_KNOT_HOST=knot.tngl.boltless.dev 24 + export TANGLED_VM_KNOT_OWNER=$alice_did 25 + export TANGLED_VM_SPINDLE_HOST=spindle.tngl.boltless.dev 26 + export TANGLED_VM_SPINDLE_OWNER=$alice_did 27 + 28 + if [ -n "${TANGLED_RESEND_API_KEY:-}" ] && [ -n "${TANGLED_RESEND_SENT_FROM:-}" ]; then 29 + export TANGLED_VM_PDS_EMAIL_SMTP_URL=smtps://resend:$TANGLED_RESEND_API_KEY@smtp.resend.com:465/ 30 + export TANGLED_VM_PDS_EMAIL_FROM_ADDRESS=$TANGLED_RESEND_SENT_FROM 31 + fi
+12
contrib/pds.env
··· 1 + LOG_ENABLED=true 2 + 3 + PDS_JWT_SECRET=8cae8bffcc73d9932819650791e4e89a 4 + PDS_ADMIN_PASSWORD=d6a902588cd93bee1af83f924f60cfd3 5 + PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX=2e92e336a50a618458e1097d94a1db86ec3fd8829d7735020cbae80625c761d7 6 + 7 + PDS_DATA_DIRECTORY=/pds 8 + PDS_BLOBSTORE_DISK_LOCATION=/pds/blocks 9 + 10 + PDS_DID_PLC_URL=http://localhost:8080 11 + PDS_HOSTNAME=pds.tngl.boltless.dev 12 + PDS_PORT=3000
+25
contrib/readme.md
··· 1 + # how to setup local appview dev environment 2 + 3 + Appview requires several microservices from knot and spindle to entire atproto infra. This test environment is implemented under nixos vm. 4 + 5 + 1. copy `contrib/example.env` to `.env`, fill it and source it 6 + 2. run vm 7 + ```bash 8 + nix run --impure .#vm 9 + ``` 10 + 3. trust the generated cert from host machine 11 + ```bash 12 + # for macos 13 + sudo security add-trusted-cert -d -r trustRoot \ 14 + -k /Library/Keychains/System.keychain \ 15 + ./nix/vm-data/caddy/.local/share/caddy/pki/authorities/local/root.crt 16 + ``` 17 + 4. create test accounts with valid emails (use [`create-test-account.sh`](./scripts/create-test-account.sh)) 18 + 5. create default labels (use [`setup-const-records`](./scripts/setup-const-records.sh)) 19 + 6. restart vm with correct owner-did 20 + 21 + for git-https, you should change your local git config: 22 + ``` 23 + [http "https://knot.tngl.boltless.dev"] 24 + sslCAPath = /Users/boltless/repo/tangled/nix/vm-data/caddy/.local/share/caddy/pki/authorities/local/ 25 + ```
+68
contrib/scripts/create-test-account.sh
··· 1 + #!/bin/bash 2 + set -o errexit 3 + set -o nounset 4 + set -o pipefail 5 + 6 + source "$(dirname "$0")/../pds.env" 7 + 8 + # PDS_HOSTNAME= 9 + # PDS_ADMIN_PASSWORD= 10 + 11 + # curl a URL and fail if the request fails. 12 + function curl_cmd_get { 13 + curl --fail --silent --show-error "$@" 14 + } 15 + 16 + # curl a URL and fail if the request fails. 17 + function curl_cmd_post { 18 + curl --fail --silent --show-error --request POST --header "Content-Type: application/json" "$@" 19 + } 20 + 21 + # curl a URL but do not fail if the request fails. 22 + function curl_cmd_post_nofail { 23 + curl --silent --show-error --request POST --header "Content-Type: application/json" "$@" 24 + } 25 + 26 + USERNAME="${1:-}" 27 + 28 + if [[ "${USERNAME}" == "" ]]; then 29 + read -p "Enter a username: " USERNAME 30 + fi 31 + 32 + if [[ "${USERNAME}" == "" ]]; then 33 + echo "ERROR: missing USERNAME parameter." >/dev/stderr 34 + echo "Usage: $0 ${SUBCOMMAND} <USERNAME>" >/dev/stderr 35 + exit 1 36 + fi 37 + 38 + EMAIL=${USERNAME}@${PDS_HOSTNAME} 39 + 40 + PASSWORD="password" 41 + INVITE_CODE="$(curl_cmd_post \ 42 + --user "admin:${PDS_ADMIN_PASSWORD}" \ 43 + --data '{"useCount": 1}' \ 44 + "https://${PDS_HOSTNAME}/xrpc/com.atproto.server.createInviteCode" | jq --raw-output '.code' 45 + )" 46 + RESULT="$(curl_cmd_post_nofail \ 47 + --data "{\"email\":\"${EMAIL}\", \"handle\":\"${USERNAME}.${PDS_HOSTNAME}\", \"password\":\"${PASSWORD}\", \"inviteCode\":\"${INVITE_CODE}\"}" \ 48 + "https://${PDS_HOSTNAME}/xrpc/com.atproto.server.createAccount" 49 + )" 50 + 51 + DID="$(echo $RESULT | jq --raw-output '.did')" 52 + if [[ "${DID}" != did:* ]]; then 53 + ERR="$(echo ${RESULT} | jq --raw-output '.message')" 54 + echo "ERROR: ${ERR}" >/dev/stderr 55 + echo "Usage: $0 <EMAIL> <HANDLE>" >/dev/stderr 56 + exit 1 57 + fi 58 + 59 + echo 60 + echo "Account created successfully!" 61 + echo "-----------------------------" 62 + echo "Handle : ${USERNAME}.${PDS_HOSTNAME}" 63 + echo "DID : ${DID}" 64 + echo "Password : ${PASSWORD}" 65 + echo "-----------------------------" 66 + echo "This is a test account with an insecure password." 67 + echo "Make sure it's only used for development." 68 + echo
+106
contrib/scripts/setup-const-records.sh
··· 1 + #!/bin/bash 2 + set -o errexit 3 + set -o nounset 4 + set -o pipefail 5 + 6 + source "$(dirname "$0")/../pds.env" 7 + 8 + # PDS_HOSTNAME= 9 + 10 + # curl a URL and fail if the request fails. 11 + function curl_cmd_get { 12 + curl --fail --silent --show-error "$@" 13 + } 14 + 15 + # curl a URL and fail if the request fails. 16 + function curl_cmd_post { 17 + curl --fail --silent --show-error --request POST --header "Content-Type: application/json" "$@" 18 + } 19 + 20 + # curl a URL but do not fail if the request fails. 21 + function curl_cmd_post_nofail { 22 + curl --silent --show-error --request POST --header "Content-Type: application/json" "$@" 23 + } 24 + 25 + USERNAME="${1:-}" 26 + 27 + if [[ "${USERNAME}" == "" ]]; then 28 + read -p "Enter a username: " USERNAME 29 + fi 30 + 31 + if [[ "${USERNAME}" == "" ]]; then 32 + echo "ERROR: missing USERNAME parameter." >/dev/stderr 33 + echo "Usage: $0 ${SUBCOMMAND} <USERNAME>" >/dev/stderr 34 + exit 1 35 + fi 36 + 37 + SESS_RESULT="$(curl_cmd_post \ 38 + --data "$(cat <<EOF 39 + { 40 + "identifier": "$USERNAME", 41 + "password": "password" 42 + } 43 + EOF 44 + )" \ 45 + https://pds.tngl.boltless.dev/xrpc/com.atproto.server.createSession 46 + )" 47 + 48 + echo $SESS_RESULT | jq 49 + 50 + DID="$(echo $SESS_RESULT | jq --raw-output '.did')" 51 + ACCESS_JWT="$(echo $SESS_RESULT | jq --raw-output '.accessJwt')" 52 + 53 + function add_label_def { 54 + local color=$1 55 + local name=$2 56 + echo $color 57 + echo $name 58 + local json_payload=$(cat <<EOF 59 + { 60 + "repo": "$DID", 61 + "collection": "sh.tangled.label.definition", 62 + "rkey": "$name", 63 + "record": { 64 + "name": "$name", 65 + "color": "$color", 66 + "scope": ["sh.tangled.repo.issue"], 67 + "multiple": false, 68 + "createdAt": "2025-09-22T11:14:35+01:00", 69 + "valueType": {"type": "null", "format": "any"} 70 + } 71 + } 72 + EOF 73 + ) 74 + echo $json_payload 75 + echo $json_payload | jq 76 + RESULT="$(curl_cmd_post \ 77 + --data "$json_payload" \ 78 + -H "Authorization: Bearer ${ACCESS_JWT}" \ 79 + "https://${PDS_HOSTNAME}/xrpc/com.atproto.repo.createRecord")" 80 + echo $RESULT | jq 81 + } 82 + 83 + add_label_def '#64748b' 'wontfix' 84 + add_label_def '#8B5CF6' 'good-first-issue' 85 + add_label_def '#ef4444' 'duplicate' 86 + add_label_def '#06b6d4' 'documentation' 87 + json_payload=$(cat <<EOF 88 + { 89 + "repo": "$DID", 90 + "collection": "sh.tangled.label.definition", 91 + "rkey": "assignee", 92 + "record": { 93 + "name": "assignee", 94 + "color": "#10B981", 95 + "scope": ["sh.tangled.repo.issue", "sh.tangled.repo.pull"], 96 + "multiple": false, 97 + "createdAt": "2025-09-22T11:14:35+01:00", 98 + "valueType": {"type": "string", "format": "did"} 99 + } 100 + } 101 + EOF 102 + ) 103 + curl_cmd_post \ 104 + --data "$json_payload" \ 105 + -H "Authorization: Bearer ${ACCESS_JWT}" \ 106 + "https://${PDS_HOSTNAME}/xrpc/com.atproto.repo.createRecord"
+1 -1
flake.nix
··· 264 264 rootDir=$(jj --ignore-working-copy root || git rev-parse --show-toplevel) || (echo "error: can't find repo root?"; exit 1) 265 265 cd "$rootDir" 266 266 267 - mkdir -p nix/vm-data/{knot,repos,spindle,spindle-logs} 267 + mkdir -p nix/vm-data/{caddy,knot,repos,spindle,spindle-logs} 268 268 269 269 export TANGLED_VM_DATA_DIR="$rootDir/nix/vm-data" 270 270 exec ${pkgs.lib.getExe
+3
input.css
··· 99 99 border border-gray-300 dark:border-gray-600 100 100 focus:outline-none focus:ring-1 focus:ring-gray-400 dark:focus:ring-gray-500; 101 101 } 102 + textarea { 103 + @apply font-mono; 104 + } 102 105 details summary::-webkit-details-marker { 103 106 display: none; 104 107 }
+2 -1
jetstream/jetstream.go
··· 159 159 j.cancelMu.Unlock() 160 160 161 161 if err := j.client.ConnectAndRead(connCtx, cursor); err != nil { 162 - l.Error("error reading jetstream", "error", err) 162 + l.Error("error reading jetstream, retry in 3s", "error", err) 163 163 cancel() 164 + time.Sleep(3 * time.Second) 164 165 continue 165 166 } 166 167
+122
nix/vm.nix
··· 24 24 nixpkgs.lib.nixosSystem { 25 25 inherit system; 26 26 modules = [ 27 + self.nixosModules.did-method-plc 28 + self.nixosModules.bluesky-jetstream 29 + self.nixosModules.bluesky-relay 27 30 self.nixosModules.knot 28 31 self.nixosModules.spindle 29 32 ({ ··· 40 43 diskSize = 10 * 1024; 41 44 cores = 2; 42 45 forwardPorts = [ 46 + # caddy 47 + { 48 + from = "host"; 49 + host.port = 80; 50 + guest.port = 80; 51 + } 52 + { 53 + from = "host"; 54 + host.port = 443; 55 + guest.port = 443; 56 + } 57 + { 58 + from = "host"; 59 + proto = "udp"; 60 + host.port = 443; 61 + guest.port = 443; 62 + } 43 63 # ssh 44 64 { 45 65 from = "host"; ··· 69 89 # as SQLite is incompatible with them. So instead we 70 90 # mount the shared directories to a different location 71 91 # and copy the contents around on service start/stop. 92 + caddyData = { 93 + source = "$TANGLED_VM_DATA_DIR/caddy"; 94 + target = config.services.caddy.dataDir; 95 + }; 72 96 knotData = { 73 97 source = "$TANGLED_VM_DATA_DIR/knot"; 74 98 target = "/mnt/knot-data"; ··· 85 109 }; 86 110 # This is fine because any and all ports that are forwarded to host are explicitly marked above, we don't need a separate guest firewall 87 111 networking.firewall.enable = false; 112 + # resolve `*.tngl.boltless.dev` to host 113 + services.dnsmasq.enable = true; 114 + services.dnsmasq.settings.address = "/tngl.boltless.dev/10.0.2.2"; 115 + security.pki.certificates = [ 116 + (builtins.readFile ../contrib/certs/root.crt) 117 + ]; 88 118 time.timeZone = "Europe/London"; 119 + services.timesyncd.enable = lib.mkVMOverride true; 89 120 services.getty.autologinUser = "root"; 90 121 environment.systemPackages = with pkgs; [curl vim git sqlite litecli]; 122 + virtualisation.docker.extraOptions = '' 123 + --dns 172.17.0.1 124 + ''; 91 125 services.tangled.knot = { 92 126 enable = true; 93 127 motd = "Welcome to the development knot!\n"; ··· 114 148 provider = "sqlite"; 115 149 }; 116 150 }; 151 + }; 152 + services.did-method-plc.enable = true; 153 + services.bluesky-pds = { 154 + enable = true; 155 + # overriding package version to support emails 156 + package = pkgs.bluesky-pds.overrideAttrs (old: rec { 157 + version = "0.4.188"; 158 + src = pkgs.fetchFromGitHub { 159 + owner = "bluesky-social"; 160 + repo = "pds"; 161 + tag = "v${version}"; 162 + hash = "sha256-t8KdyEygXdbj/5Rhj8W40e1o8mXprELpjsKddHExmo0="; 163 + }; 164 + pnpmDeps = pkgs.fetchPnpmDeps { 165 + inherit version src; 166 + pname = old.pname; 167 + sourceRoot = old.sourceRoot; 168 + fetcherVersion = 2; 169 + hash = "sha256-lQie7f8JbWKSpoavnMjHegBzH3GB9teXsn+S2SLJHHU="; 170 + }; 171 + }); 172 + settings = { 173 + LOG_ENABLED = "true"; 174 + 175 + PDS_JWT_SECRET = "8cae8bffcc73d9932819650791e4e89a"; 176 + PDS_ADMIN_PASSWORD = "d6a902588cd93bee1af83f924f60cfd3"; 177 + PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX = "2e92e336a50a618458e1097d94a1db86ec3fd8829d7735020cbae80625c761d7"; 178 + 179 + PDS_EMAIL_SMTP_URL = envVarOr "TANGLED_VM_PDS_EMAIL_SMTP_URL" null; 180 + PDS_EMAIL_FROM_ADDRESS = envVarOr "TANGLED_VM_PDS_EMAIL_FROM_ADDRESS" null; 181 + 182 + PDS_DID_PLC_URL = "http://localhost:8080"; 183 + PDS_CRAWLERS = "https://relay.tngl.boltless.dev"; 184 + PDS_HOSTNAME = "pds.tngl.boltless.dev"; 185 + PDS_PORT = 3000; 186 + }; 187 + }; 188 + services.bluesky-relay = { 189 + enable = true; 190 + }; 191 + services.bluesky-jetstream = { 192 + enable = true; 193 + livenessTtl = 300; 194 + websocketUrl = "ws://localhost:3000/xrpc/com.atproto.sync.subscribeRepos"; 195 + }; 196 + services.caddy = { 197 + enable = true; 198 + configFile = pkgs.writeText "Caddyfile" '' 199 + { 200 + debug 201 + cert_lifetime 3601d 202 + pki { 203 + ca local { 204 + intermediate_lifetime 3599d 205 + } 206 + } 207 + } 208 + 209 + plc.tngl.boltless.dev { 210 + tls internal 211 + reverse_proxy http://localhost:8080 212 + } 213 + 214 + *.pds.tngl.boltless.dev, pds.tngl.boltless.dev { 215 + tls internal 216 + reverse_proxy http://localhost:3000 217 + } 218 + 219 + jetstream.tngl.boltless.dev { 220 + tls internal 221 + reverse_proxy http://localhost:6008 222 + } 223 + 224 + relay.tngl.boltless.dev { 225 + tls internal 226 + reverse_proxy http://localhost:2470 227 + } 228 + 229 + knot.tngl.boltless.dev { 230 + tls internal 231 + reverse_proxy http://localhost:6444 232 + } 233 + 234 + spindle.tngl.boltless.dev { 235 + tls internal 236 + reverse_proxy http://localhost:6555 237 + } 238 + ''; 117 239 }; 118 240 users = { 119 241 # So we don't have to deal with permission clashing between