forked from tangled.org/core
this repo has no description

Compare changes

Choose any two refs to compare.

+31470 -12731
+1 -1
.air/appview.toml
··· 5 5 6 6 exclude_regex = [".*_templ.go"] 7 7 include_ext = ["go", "templ", "html", "css"] 8 - exclude_dir = ["target", "atrium"] 8 + exclude_dir = ["target", "atrium", "nix"]
+4
.gitignore
··· 14 14 .DS_Store 15 15 .env 16 16 *.rdb 17 + .envrc 18 + # Created if following hacking.md 19 + genjwks.out 20 + /nix/vm-data
+12
.prettierrc.json
··· 1 + { 2 + "overrides": [ 3 + { 4 + "files": ["*.html"], 5 + "options": { 6 + "parser": "go-template" 7 + } 8 + } 9 + ], 10 + "bracketSameLine": true, 11 + "htmlWhitespaceSensitivity": "ignore" 12 + }
+12 -3
.tangled/workflows/build.yml
··· 2 2 - event: ["push", "pull_request"] 3 3 branch: ["master"] 4 4 5 + engine: nixery 6 + 5 7 dependencies: 6 8 nixpkgs: 7 9 - go 8 10 - gcc 9 11 10 - env: 12 + environment: 11 13 CGO_ENABLED: 1 12 14 13 15 steps: ··· 15 17 command: | 16 18 mkdir -p appview/pages/static; touch appview/pages/static/x 17 19 18 - - name: test all 20 + - name: build appview 21 + command: | 22 + go build -o appview.out ./cmd/appview 23 + 24 + - name: build knot 19 25 command: | 20 - go test -v ./... 26 + go build -o knot.out ./cmd/knot 21 27 28 + - name: build spindle 29 + command: | 30 + go build -o spindle.out ./cmd/spindle
+4 -12
.tangled/workflows/fmt.yml
··· 1 1 when: 2 2 - event: ["push", "pull_request"] 3 - branch: ["main", "master", "develop"] 3 + branch: ["master"] 4 4 5 - dependencies: 6 - nixpkgs: 7 - - go 8 - - alejandra 5 + engine: nixery 9 6 10 7 steps: 11 - - name: "nix fmt" 12 - command: | 13 - alejandra -c nix/**/*.nix flake.nix 14 - 15 - - name: "go fmt" 8 + - name: "Check formatting" 16 9 command: | 17 - gofmt -l . 18 - 10 + nix run .#fmt -- --ci
+21
.tangled/workflows/test.yml
··· 1 + when: 2 + - event: ["push", "pull_request"] 3 + branch: ["master"] 4 + 5 + engine: nixery 6 + 7 + dependencies: 8 + nixpkgs: 9 + - go 10 + - gcc 11 + 12 + steps: 13 + - name: patch static dir 14 + command: | 15 + mkdir -p appview/pages/static; touch appview/pages/static/x 16 + 17 + - name: run all tests 18 + environment: 19 + CGO_ENABLED: 1 20 + command: | 21 + go test -v ./...
-16
.zed/settings.json
··· 1 - // Folder-specific settings 2 - // 3 - // For a full list of overridable settings, and general information on folder-specific settings, 4 - // see the documentation: https://zed.dev/docs/configuring-zed#settings-files 5 - { 6 - "languages": { 7 - "HTML": { 8 - "prettier": { 9 - "format_on_save": false, 10 - "allowed": true, 11 - "parser": "go-template", 12 - "plugins": ["prettier-plugin-go-template"] 13 - } 14 - } 15 - } 16 - }
+1481 -1498
api/tangled/cbor_gen.go
··· 504 504 505 505 return nil 506 506 } 507 + func (t *FeedReaction) MarshalCBOR(w io.Writer) error { 508 + if t == nil { 509 + _, err := w.Write(cbg.CborNull) 510 + return err 511 + } 512 + 513 + cw := cbg.NewCborWriter(w) 514 + 515 + if _, err := cw.Write([]byte{164}); err != nil { 516 + return err 517 + } 518 + 519 + // t.LexiconTypeID (string) (string) 520 + if len("$type") > 1000000 { 521 + return xerrors.Errorf("Value in field \"$type\" was too long") 522 + } 523 + 524 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil { 525 + return err 526 + } 527 + if _, err := cw.WriteString(string("$type")); err != nil { 528 + return err 529 + } 530 + 531 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sh.tangled.feed.reaction"))); err != nil { 532 + return err 533 + } 534 + if _, err := cw.WriteString(string("sh.tangled.feed.reaction")); err != nil { 535 + return err 536 + } 537 + 538 + // t.Subject (string) (string) 539 + if len("subject") > 1000000 { 540 + return xerrors.Errorf("Value in field \"subject\" was too long") 541 + } 542 + 543 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("subject"))); err != nil { 544 + return err 545 + } 546 + if _, err := cw.WriteString(string("subject")); err != nil { 547 + return err 548 + } 549 + 550 + if len(t.Subject) > 1000000 { 551 + return xerrors.Errorf("Value in field t.Subject was too long") 552 + } 553 + 554 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Subject))); err != nil { 555 + return err 556 + } 557 + if _, err := cw.WriteString(string(t.Subject)); err != nil { 558 + return err 559 + } 560 + 561 + // t.Reaction (string) (string) 562 + if len("reaction") > 1000000 { 563 + return xerrors.Errorf("Value in field \"reaction\" was too long") 564 + } 565 + 566 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("reaction"))); err != nil { 567 + return err 568 + } 569 + if _, err := cw.WriteString(string("reaction")); err != nil { 570 + return err 571 + } 572 + 573 + if len(t.Reaction) > 1000000 { 574 + return xerrors.Errorf("Value in field t.Reaction was too long") 575 + } 576 + 577 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Reaction))); err != nil { 578 + return err 579 + } 580 + if _, err := cw.WriteString(string(t.Reaction)); err != nil { 581 + return err 582 + } 583 + 584 + // t.CreatedAt (string) (string) 585 + if len("createdAt") > 1000000 { 586 + return xerrors.Errorf("Value in field \"createdAt\" was too long") 587 + } 588 + 589 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil { 590 + return err 591 + } 592 + if _, err := cw.WriteString(string("createdAt")); err != nil { 593 + return err 594 + } 595 + 596 + if len(t.CreatedAt) > 1000000 { 597 + return xerrors.Errorf("Value in field t.CreatedAt was too long") 598 + } 599 + 600 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil { 601 + return err 602 + } 603 + if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { 604 + return err 605 + } 606 + return nil 607 + } 608 + 609 + func (t *FeedReaction) UnmarshalCBOR(r io.Reader) (err error) { 610 + *t = FeedReaction{} 611 + 612 + cr := cbg.NewCborReader(r) 613 + 614 + maj, extra, err := cr.ReadHeader() 615 + if err != nil { 616 + return err 617 + } 618 + defer func() { 619 + if err == io.EOF { 620 + err = io.ErrUnexpectedEOF 621 + } 622 + }() 623 + 624 + if maj != cbg.MajMap { 625 + return fmt.Errorf("cbor input should be of type map") 626 + } 627 + 628 + if extra > cbg.MaxLength { 629 + return fmt.Errorf("FeedReaction: map struct too large (%d)", extra) 630 + } 631 + 632 + n := extra 633 + 634 + nameBuf := make([]byte, 9) 635 + for i := uint64(0); i < n; i++ { 636 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 637 + if err != nil { 638 + return err 639 + } 640 + 641 + if !ok { 642 + // Field doesn't exist on this type, so ignore it 643 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 644 + return err 645 + } 646 + continue 647 + } 648 + 649 + switch string(nameBuf[:nameLen]) { 650 + // t.LexiconTypeID (string) (string) 651 + case "$type": 652 + 653 + { 654 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 655 + if err != nil { 656 + return err 657 + } 658 + 659 + t.LexiconTypeID = string(sval) 660 + } 661 + // t.Subject (string) (string) 662 + case "subject": 663 + 664 + { 665 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 666 + if err != nil { 667 + return err 668 + } 669 + 670 + t.Subject = string(sval) 671 + } 672 + // t.Reaction (string) (string) 673 + case "reaction": 674 + 675 + { 676 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 677 + if err != nil { 678 + return err 679 + } 680 + 681 + t.Reaction = string(sval) 682 + } 683 + // t.CreatedAt (string) (string) 684 + case "createdAt": 685 + 686 + { 687 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 688 + if err != nil { 689 + return err 690 + } 691 + 692 + t.CreatedAt = string(sval) 693 + } 694 + 695 + default: 696 + // Field doesn't exist on this type, so ignore it 697 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 698 + return err 699 + } 700 + } 701 + } 702 + 703 + return nil 704 + } 507 705 func (t *FeedStar) MarshalCBOR(w io.Writer) error { 508 706 if t == nil { 509 707 _, err := w.Write(cbg.CborNull) ··· 1004 1202 1005 1203 return nil 1006 1204 } 1007 - func (t *GitRefUpdate_Meta) MarshalCBOR(w io.Writer) error { 1205 + func (t *GitRefUpdate_CommitCountBreakdown) MarshalCBOR(w io.Writer) error { 1206 + if t == nil { 1207 + _, err := w.Write(cbg.CborNull) 1208 + return err 1209 + } 1210 + 1211 + cw := cbg.NewCborWriter(w) 1212 + fieldCount := 1 1213 + 1214 + if t.ByEmail == nil { 1215 + fieldCount-- 1216 + } 1217 + 1218 + if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { 1219 + return err 1220 + } 1221 + 1222 + // t.ByEmail ([]*tangled.GitRefUpdate_IndividualEmailCommitCount) (slice) 1223 + if t.ByEmail != nil { 1224 + 1225 + if len("byEmail") > 1000000 { 1226 + return xerrors.Errorf("Value in field \"byEmail\" was too long") 1227 + } 1228 + 1229 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("byEmail"))); err != nil { 1230 + return err 1231 + } 1232 + if _, err := cw.WriteString(string("byEmail")); err != nil { 1233 + return err 1234 + } 1235 + 1236 + if len(t.ByEmail) > 8192 { 1237 + return xerrors.Errorf("Slice value in field t.ByEmail was too long") 1238 + } 1239 + 1240 + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.ByEmail))); err != nil { 1241 + return err 1242 + } 1243 + for _, v := range t.ByEmail { 1244 + if err := v.MarshalCBOR(cw); err != nil { 1245 + return err 1246 + } 1247 + 1248 + } 1249 + } 1250 + return nil 1251 + } 1252 + 1253 + func (t *GitRefUpdate_CommitCountBreakdown) UnmarshalCBOR(r io.Reader) (err error) { 1254 + *t = GitRefUpdate_CommitCountBreakdown{} 1255 + 1256 + cr := cbg.NewCborReader(r) 1257 + 1258 + maj, extra, err := cr.ReadHeader() 1259 + if err != nil { 1260 + return err 1261 + } 1262 + defer func() { 1263 + if err == io.EOF { 1264 + err = io.ErrUnexpectedEOF 1265 + } 1266 + }() 1267 + 1268 + if maj != cbg.MajMap { 1269 + return fmt.Errorf("cbor input should be of type map") 1270 + } 1271 + 1272 + if extra > cbg.MaxLength { 1273 + return fmt.Errorf("GitRefUpdate_CommitCountBreakdown: map struct too large (%d)", extra) 1274 + } 1275 + 1276 + n := extra 1277 + 1278 + nameBuf := make([]byte, 7) 1279 + for i := uint64(0); i < n; i++ { 1280 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 1281 + if err != nil { 1282 + return err 1283 + } 1284 + 1285 + if !ok { 1286 + // Field doesn't exist on this type, so ignore it 1287 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 1288 + return err 1289 + } 1290 + continue 1291 + } 1292 + 1293 + switch string(nameBuf[:nameLen]) { 1294 + // t.ByEmail ([]*tangled.GitRefUpdate_IndividualEmailCommitCount) (slice) 1295 + case "byEmail": 1296 + 1297 + maj, extra, err = cr.ReadHeader() 1298 + if err != nil { 1299 + return err 1300 + } 1301 + 1302 + if extra > 8192 { 1303 + return fmt.Errorf("t.ByEmail: array too large (%d)", extra) 1304 + } 1305 + 1306 + if maj != cbg.MajArray { 1307 + return fmt.Errorf("expected cbor array") 1308 + } 1309 + 1310 + if extra > 0 { 1311 + t.ByEmail = make([]*GitRefUpdate_IndividualEmailCommitCount, extra) 1312 + } 1313 + 1314 + for i := 0; i < int(extra); i++ { 1315 + { 1316 + var maj byte 1317 + var extra uint64 1318 + var err error 1319 + _ = maj 1320 + _ = extra 1321 + _ = err 1322 + 1323 + { 1324 + 1325 + b, err := cr.ReadByte() 1326 + if err != nil { 1327 + return err 1328 + } 1329 + if b != cbg.CborNull[0] { 1330 + if err := cr.UnreadByte(); err != nil { 1331 + return err 1332 + } 1333 + t.ByEmail[i] = new(GitRefUpdate_IndividualEmailCommitCount) 1334 + if err := t.ByEmail[i].UnmarshalCBOR(cr); err != nil { 1335 + return xerrors.Errorf("unmarshaling t.ByEmail[i] pointer: %w", err) 1336 + } 1337 + } 1338 + 1339 + } 1340 + 1341 + } 1342 + } 1343 + 1344 + default: 1345 + // Field doesn't exist on this type, so ignore it 1346 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 1347 + return err 1348 + } 1349 + } 1350 + } 1351 + 1352 + return nil 1353 + } 1354 + func (t *GitRefUpdate_IndividualEmailCommitCount) MarshalCBOR(w io.Writer) error { 1008 1355 if t == nil { 1009 1356 _, err := w.Write(cbg.CborNull) 1010 1357 return err ··· 1016 1363 return err 1017 1364 } 1018 1365 1019 - // t.CommitCount (tangled.GitRefUpdate_Meta_CommitCount) (struct) 1020 - if len("commitCount") > 1000000 { 1021 - return xerrors.Errorf("Value in field \"commitCount\" was too long") 1366 + // t.Count (int64) (int64) 1367 + if len("count") > 1000000 { 1368 + return xerrors.Errorf("Value in field \"count\" was too long") 1022 1369 } 1023 1370 1024 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("commitCount"))); err != nil { 1371 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("count"))); err != nil { 1025 1372 return err 1026 1373 } 1027 - if _, err := cw.WriteString(string("commitCount")); err != nil { 1374 + if _, err := cw.WriteString(string("count")); err != nil { 1028 1375 return err 1029 1376 } 1030 1377 1031 - if err := t.CommitCount.MarshalCBOR(cw); err != nil { 1032 - return err 1378 + if t.Count >= 0 { 1379 + if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(t.Count)); err != nil { 1380 + return err 1381 + } 1382 + } else { 1383 + if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-t.Count-1)); err != nil { 1384 + return err 1385 + } 1033 1386 } 1034 1387 1035 - // t.IsDefaultRef (bool) (bool) 1036 - if len("isDefaultRef") > 1000000 { 1037 - return xerrors.Errorf("Value in field \"isDefaultRef\" was too long") 1388 + // t.Email (string) (string) 1389 + if len("email") > 1000000 { 1390 + return xerrors.Errorf("Value in field \"email\" was too long") 1038 1391 } 1039 1392 1040 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("isDefaultRef"))); err != nil { 1393 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("email"))); err != nil { 1041 1394 return err 1042 1395 } 1043 - if _, err := cw.WriteString(string("isDefaultRef")); err != nil { 1396 + if _, err := cw.WriteString(string("email")); err != nil { 1044 1397 return err 1045 1398 } 1046 1399 1047 - if err := cbg.WriteBool(w, t.IsDefaultRef); err != nil { 1400 + if len(t.Email) > 1000000 { 1401 + return xerrors.Errorf("Value in field t.Email was too long") 1402 + } 1403 + 1404 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Email))); err != nil { 1405 + return err 1406 + } 1407 + if _, err := cw.WriteString(string(t.Email)); err != nil { 1048 1408 return err 1049 1409 } 1050 1410 return nil 1051 1411 } 1052 1412 1053 - func (t *GitRefUpdate_Meta) UnmarshalCBOR(r io.Reader) (err error) { 1054 - *t = GitRefUpdate_Meta{} 1413 + func (t *GitRefUpdate_IndividualEmailCommitCount) UnmarshalCBOR(r io.Reader) (err error) { 1414 + *t = GitRefUpdate_IndividualEmailCommitCount{} 1055 1415 1056 1416 cr := cbg.NewCborReader(r) 1057 1417 ··· 1070 1430 } 1071 1431 1072 1432 if extra > cbg.MaxLength { 1073 - return fmt.Errorf("GitRefUpdate_Meta: map struct too large (%d)", extra) 1433 + return fmt.Errorf("GitRefUpdate_IndividualEmailCommitCount: map struct too large (%d)", extra) 1074 1434 } 1075 1435 1076 1436 n := extra 1077 1437 1078 - nameBuf := make([]byte, 12) 1438 + nameBuf := make([]byte, 5) 1079 1439 for i := uint64(0); i < n; i++ { 1080 1440 nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 1081 1441 if err != nil { ··· 1091 1451 } 1092 1452 1093 1453 switch string(nameBuf[:nameLen]) { 1094 - // t.CommitCount (tangled.GitRefUpdate_Meta_CommitCount) (struct) 1095 - case "commitCount": 1096 - 1454 + // t.Count (int64) (int64) 1455 + case "count": 1097 1456 { 1098 - 1099 - b, err := cr.ReadByte() 1457 + maj, extra, err := cr.ReadHeader() 1100 1458 if err != nil { 1101 1459 return err 1102 1460 } 1103 - if b != cbg.CborNull[0] { 1104 - if err := cr.UnreadByte(); err != nil { 1105 - return err 1461 + var extraI int64 1462 + switch maj { 1463 + case cbg.MajUnsignedInt: 1464 + extraI = int64(extra) 1465 + if extraI < 0 { 1466 + return fmt.Errorf("int64 positive overflow") 1106 1467 } 1107 - t.CommitCount = new(GitRefUpdate_Meta_CommitCount) 1108 - if err := t.CommitCount.UnmarshalCBOR(cr); err != nil { 1109 - return xerrors.Errorf("unmarshaling t.CommitCount pointer: %w", err) 1468 + case cbg.MajNegativeInt: 1469 + extraI = int64(extra) 1470 + if extraI < 0 { 1471 + return fmt.Errorf("int64 negative overflow") 1110 1472 } 1473 + extraI = -1 - extraI 1474 + default: 1475 + return fmt.Errorf("wrong type for int64 field: %d", maj) 1111 1476 } 1112 1477 1478 + t.Count = int64(extraI) 1113 1479 } 1114 - // t.IsDefaultRef (bool) (bool) 1115 - case "isDefaultRef": 1480 + // t.Email (string) (string) 1481 + case "email": 1116 1482 1117 - maj, extra, err = cr.ReadHeader() 1118 - if err != nil { 1119 - return err 1120 - } 1121 - if maj != cbg.MajOther { 1122 - return fmt.Errorf("booleans must be major type 7") 1123 - } 1124 - switch extra { 1125 - case 20: 1126 - t.IsDefaultRef = false 1127 - case 21: 1128 - t.IsDefaultRef = true 1129 - default: 1130 - return fmt.Errorf("booleans are either major type 7, value 20 or 21 (got %d)", extra) 1483 + { 1484 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 1485 + if err != nil { 1486 + return err 1487 + } 1488 + 1489 + t.Email = string(sval) 1131 1490 } 1132 1491 1133 1492 default: ··· 1140 1499 1141 1500 return nil 1142 1501 } 1143 - func (t *GitRefUpdate_Meta_CommitCount) MarshalCBOR(w io.Writer) error { 1502 + func (t *GitRefUpdate_LangBreakdown) MarshalCBOR(w io.Writer) error { 1144 1503 if t == nil { 1145 1504 _, err := w.Write(cbg.CborNull) 1146 1505 return err ··· 1149 1508 cw := cbg.NewCborWriter(w) 1150 1509 fieldCount := 1 1151 1510 1152 - if t.ByEmail == nil { 1511 + if t.Inputs == nil { 1153 1512 fieldCount-- 1154 1513 } 1155 1514 ··· 1157 1516 return err 1158 1517 } 1159 1518 1160 - // t.ByEmail ([]*tangled.GitRefUpdate_Meta_CommitCount_ByEmail_Elem) (slice) 1161 - if t.ByEmail != nil { 1519 + // t.Inputs ([]*tangled.GitRefUpdate_IndividualLanguageSize) (slice) 1520 + if t.Inputs != nil { 1162 1521 1163 - if len("byEmail") > 1000000 { 1164 - return xerrors.Errorf("Value in field \"byEmail\" was too long") 1522 + if len("inputs") > 1000000 { 1523 + return xerrors.Errorf("Value in field \"inputs\" was too long") 1165 1524 } 1166 1525 1167 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("byEmail"))); err != nil { 1526 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("inputs"))); err != nil { 1168 1527 return err 1169 1528 } 1170 - if _, err := cw.WriteString(string("byEmail")); err != nil { 1529 + if _, err := cw.WriteString(string("inputs")); err != nil { 1171 1530 return err 1172 1531 } 1173 1532 1174 - if len(t.ByEmail) > 8192 { 1175 - return xerrors.Errorf("Slice value in field t.ByEmail was too long") 1533 + if len(t.Inputs) > 8192 { 1534 + return xerrors.Errorf("Slice value in field t.Inputs was too long") 1176 1535 } 1177 1536 1178 - if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.ByEmail))); err != nil { 1537 + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Inputs))); err != nil { 1179 1538 return err 1180 1539 } 1181 - for _, v := range t.ByEmail { 1540 + for _, v := range t.Inputs { 1182 1541 if err := v.MarshalCBOR(cw); err != nil { 1183 1542 return err 1184 1543 } ··· 1188 1547 return nil 1189 1548 } 1190 1549 1191 - func (t *GitRefUpdate_Meta_CommitCount) UnmarshalCBOR(r io.Reader) (err error) { 1192 - *t = GitRefUpdate_Meta_CommitCount{} 1550 + func (t *GitRefUpdate_LangBreakdown) UnmarshalCBOR(r io.Reader) (err error) { 1551 + *t = GitRefUpdate_LangBreakdown{} 1193 1552 1194 1553 cr := cbg.NewCborReader(r) 1195 1554 ··· 1208 1567 } 1209 1568 1210 1569 if extra > cbg.MaxLength { 1211 - return fmt.Errorf("GitRefUpdate_Meta_CommitCount: map struct too large (%d)", extra) 1570 + return fmt.Errorf("GitRefUpdate_LangBreakdown: map struct too large (%d)", extra) 1212 1571 } 1213 1572 1214 1573 n := extra 1215 1574 1216 - nameBuf := make([]byte, 7) 1575 + nameBuf := make([]byte, 6) 1217 1576 for i := uint64(0); i < n; i++ { 1218 1577 nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 1219 1578 if err != nil { ··· 1229 1588 } 1230 1589 1231 1590 switch string(nameBuf[:nameLen]) { 1232 - // t.ByEmail ([]*tangled.GitRefUpdate_Meta_CommitCount_ByEmail_Elem) (slice) 1233 - case "byEmail": 1591 + // t.Inputs ([]*tangled.GitRefUpdate_IndividualLanguageSize) (slice) 1592 + case "inputs": 1234 1593 1235 1594 maj, extra, err = cr.ReadHeader() 1236 1595 if err != nil { ··· 1238 1597 } 1239 1598 1240 1599 if extra > 8192 { 1241 - return fmt.Errorf("t.ByEmail: array too large (%d)", extra) 1600 + return fmt.Errorf("t.Inputs: array too large (%d)", extra) 1242 1601 } 1243 1602 1244 1603 if maj != cbg.MajArray { ··· 1246 1605 } 1247 1606 1248 1607 if extra > 0 { 1249 - t.ByEmail = make([]*GitRefUpdate_Meta_CommitCount_ByEmail_Elem, extra) 1608 + t.Inputs = make([]*GitRefUpdate_IndividualLanguageSize, extra) 1250 1609 } 1251 1610 1252 1611 for i := 0; i < int(extra); i++ { ··· 1268 1627 if err := cr.UnreadByte(); err != nil { 1269 1628 return err 1270 1629 } 1271 - t.ByEmail[i] = new(GitRefUpdate_Meta_CommitCount_ByEmail_Elem) 1272 - if err := t.ByEmail[i].UnmarshalCBOR(cr); err != nil { 1273 - return xerrors.Errorf("unmarshaling t.ByEmail[i] pointer: %w", err) 1630 + t.Inputs[i] = new(GitRefUpdate_IndividualLanguageSize) 1631 + if err := t.Inputs[i].UnmarshalCBOR(cr); err != nil { 1632 + return xerrors.Errorf("unmarshaling t.Inputs[i] pointer: %w", err) 1274 1633 } 1275 1634 } 1276 1635 ··· 1289 1648 1290 1649 return nil 1291 1650 } 1292 - func (t *GitRefUpdate_Meta_CommitCount_ByEmail_Elem) MarshalCBOR(w io.Writer) error { 1651 + func (t *GitRefUpdate_IndividualLanguageSize) MarshalCBOR(w io.Writer) error { 1293 1652 if t == nil { 1294 1653 _, err := w.Write(cbg.CborNull) 1295 1654 return err ··· 1301 1660 return err 1302 1661 } 1303 1662 1304 - // t.Count (int64) (int64) 1305 - if len("count") > 1000000 { 1306 - return xerrors.Errorf("Value in field \"count\" was too long") 1663 + // t.Lang (string) (string) 1664 + if len("lang") > 1000000 { 1665 + return xerrors.Errorf("Value in field \"lang\" was too long") 1307 1666 } 1308 1667 1309 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("count"))); err != nil { 1668 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("lang"))); err != nil { 1310 1669 return err 1311 1670 } 1312 - if _, err := cw.WriteString(string("count")); err != nil { 1671 + if _, err := cw.WriteString(string("lang")); err != nil { 1313 1672 return err 1314 1673 } 1315 1674 1316 - if t.Count >= 0 { 1317 - if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(t.Count)); err != nil { 1318 - return err 1319 - } 1320 - } else { 1321 - if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-t.Count-1)); err != nil { 1322 - return err 1323 - } 1675 + if len(t.Lang) > 1000000 { 1676 + return xerrors.Errorf("Value in field t.Lang was too long") 1324 1677 } 1325 1678 1326 - // t.Email (string) (string) 1327 - if len("email") > 1000000 { 1328 - return xerrors.Errorf("Value in field \"email\" was too long") 1329 - } 1330 - 1331 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("email"))); err != nil { 1679 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Lang))); err != nil { 1332 1680 return err 1333 1681 } 1334 - if _, err := cw.WriteString(string("email")); err != nil { 1682 + if _, err := cw.WriteString(string(t.Lang)); err != nil { 1335 1683 return err 1336 1684 } 1337 1685 1338 - if len(t.Email) > 1000000 { 1339 - return xerrors.Errorf("Value in field t.Email was too long") 1686 + // t.Size (int64) (int64) 1687 + if len("size") > 1000000 { 1688 + return xerrors.Errorf("Value in field \"size\" was too long") 1340 1689 } 1341 1690 1342 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Email))); err != nil { 1691 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("size"))); err != nil { 1343 1692 return err 1344 1693 } 1345 - if _, err := cw.WriteString(string(t.Email)); err != nil { 1694 + if _, err := cw.WriteString(string("size")); err != nil { 1346 1695 return err 1347 1696 } 1697 + 1698 + if t.Size >= 0 { 1699 + if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(t.Size)); err != nil { 1700 + return err 1701 + } 1702 + } else { 1703 + if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-t.Size-1)); err != nil { 1704 + return err 1705 + } 1706 + } 1707 + 1348 1708 return nil 1349 1709 } 1350 1710 1351 - func (t *GitRefUpdate_Meta_CommitCount_ByEmail_Elem) UnmarshalCBOR(r io.Reader) (err error) { 1352 - *t = GitRefUpdate_Meta_CommitCount_ByEmail_Elem{} 1711 + func (t *GitRefUpdate_IndividualLanguageSize) UnmarshalCBOR(r io.Reader) (err error) { 1712 + *t = GitRefUpdate_IndividualLanguageSize{} 1353 1713 1354 1714 cr := cbg.NewCborReader(r) 1355 1715 ··· 1368 1728 } 1369 1729 1370 1730 if extra > cbg.MaxLength { 1371 - return fmt.Errorf("GitRefUpdate_Meta_CommitCount_ByEmail_Elem: map struct too large (%d)", extra) 1731 + return fmt.Errorf("GitRefUpdate_IndividualLanguageSize: map struct too large (%d)", extra) 1372 1732 } 1373 1733 1374 1734 n := extra 1375 1735 1376 - nameBuf := make([]byte, 5) 1736 + nameBuf := make([]byte, 4) 1377 1737 for i := uint64(0); i < n; i++ { 1378 1738 nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 1379 1739 if err != nil { ··· 1389 1749 } 1390 1750 1391 1751 switch string(nameBuf[:nameLen]) { 1392 - // t.Count (int64) (int64) 1393 - case "count": 1752 + // t.Lang (string) (string) 1753 + case "lang": 1754 + 1755 + { 1756 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 1757 + if err != nil { 1758 + return err 1759 + } 1760 + 1761 + t.Lang = string(sval) 1762 + } 1763 + // t.Size (int64) (int64) 1764 + case "size": 1394 1765 { 1395 1766 maj, extra, err := cr.ReadHeader() 1396 1767 if err != nil { ··· 1413 1784 return fmt.Errorf("wrong type for int64 field: %d", maj) 1414 1785 } 1415 1786 1416 - t.Count = int64(extraI) 1787 + t.Size = int64(extraI) 1417 1788 } 1418 - // t.Email (string) (string) 1419 - case "email": 1789 + 1790 + default: 1791 + // Field doesn't exist on this type, so ignore it 1792 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 1793 + return err 1794 + } 1795 + } 1796 + } 1797 + 1798 + return nil 1799 + } 1800 + func (t *GitRefUpdate_Meta) MarshalCBOR(w io.Writer) error { 1801 + if t == nil { 1802 + _, err := w.Write(cbg.CborNull) 1803 + return err 1804 + } 1805 + 1806 + cw := cbg.NewCborWriter(w) 1807 + fieldCount := 3 1808 + 1809 + if t.LangBreakdown == nil { 1810 + fieldCount-- 1811 + } 1812 + 1813 + if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { 1814 + return err 1815 + } 1816 + 1817 + // t.CommitCount (tangled.GitRefUpdate_CommitCountBreakdown) (struct) 1818 + if len("commitCount") > 1000000 { 1819 + return xerrors.Errorf("Value in field \"commitCount\" was too long") 1820 + } 1821 + 1822 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("commitCount"))); err != nil { 1823 + return err 1824 + } 1825 + if _, err := cw.WriteString(string("commitCount")); err != nil { 1826 + return err 1827 + } 1828 + 1829 + if err := t.CommitCount.MarshalCBOR(cw); err != nil { 1830 + return err 1831 + } 1832 + 1833 + // t.IsDefaultRef (bool) (bool) 1834 + if len("isDefaultRef") > 1000000 { 1835 + return xerrors.Errorf("Value in field \"isDefaultRef\" was too long") 1836 + } 1837 + 1838 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("isDefaultRef"))); err != nil { 1839 + return err 1840 + } 1841 + if _, err := cw.WriteString(string("isDefaultRef")); err != nil { 1842 + return err 1843 + } 1844 + 1845 + if err := cbg.WriteBool(w, t.IsDefaultRef); err != nil { 1846 + return err 1847 + } 1848 + 1849 + // t.LangBreakdown (tangled.GitRefUpdate_LangBreakdown) (struct) 1850 + if t.LangBreakdown != nil { 1851 + 1852 + if len("langBreakdown") > 1000000 { 1853 + return xerrors.Errorf("Value in field \"langBreakdown\" was too long") 1854 + } 1855 + 1856 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("langBreakdown"))); err != nil { 1857 + return err 1858 + } 1859 + if _, err := cw.WriteString(string("langBreakdown")); err != nil { 1860 + return err 1861 + } 1862 + 1863 + if err := t.LangBreakdown.MarshalCBOR(cw); err != nil { 1864 + return err 1865 + } 1866 + } 1867 + return nil 1868 + } 1869 + 1870 + func (t *GitRefUpdate_Meta) UnmarshalCBOR(r io.Reader) (err error) { 1871 + *t = GitRefUpdate_Meta{} 1872 + 1873 + cr := cbg.NewCborReader(r) 1874 + 1875 + maj, extra, err := cr.ReadHeader() 1876 + if err != nil { 1877 + return err 1878 + } 1879 + defer func() { 1880 + if err == io.EOF { 1881 + err = io.ErrUnexpectedEOF 1882 + } 1883 + }() 1884 + 1885 + if maj != cbg.MajMap { 1886 + return fmt.Errorf("cbor input should be of type map") 1887 + } 1888 + 1889 + if extra > cbg.MaxLength { 1890 + return fmt.Errorf("GitRefUpdate_Meta: map struct too large (%d)", extra) 1891 + } 1892 + 1893 + n := extra 1894 + 1895 + nameBuf := make([]byte, 13) 1896 + for i := uint64(0); i < n; i++ { 1897 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 1898 + if err != nil { 1899 + return err 1900 + } 1901 + 1902 + if !ok { 1903 + // Field doesn't exist on this type, so ignore it 1904 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 1905 + return err 1906 + } 1907 + continue 1908 + } 1909 + 1910 + switch string(nameBuf[:nameLen]) { 1911 + // t.CommitCount (tangled.GitRefUpdate_CommitCountBreakdown) (struct) 1912 + case "commitCount": 1420 1913 1421 1914 { 1422 - sval, err := cbg.ReadStringWithMax(cr, 1000000) 1915 + 1916 + b, err := cr.ReadByte() 1423 1917 if err != nil { 1424 1918 return err 1425 1919 } 1920 + if b != cbg.CborNull[0] { 1921 + if err := cr.UnreadByte(); err != nil { 1922 + return err 1923 + } 1924 + t.CommitCount = new(GitRefUpdate_CommitCountBreakdown) 1925 + if err := t.CommitCount.UnmarshalCBOR(cr); err != nil { 1926 + return xerrors.Errorf("unmarshaling t.CommitCount pointer: %w", err) 1927 + } 1928 + } 1426 1929 1427 - t.Email = string(sval) 1930 + } 1931 + // t.IsDefaultRef (bool) (bool) 1932 + case "isDefaultRef": 1933 + 1934 + maj, extra, err = cr.ReadHeader() 1935 + if err != nil { 1936 + return err 1937 + } 1938 + if maj != cbg.MajOther { 1939 + return fmt.Errorf("booleans must be major type 7") 1940 + } 1941 + switch extra { 1942 + case 20: 1943 + t.IsDefaultRef = false 1944 + case 21: 1945 + t.IsDefaultRef = true 1946 + default: 1947 + return fmt.Errorf("booleans are either major type 7, value 20 or 21 (got %d)", extra) 1948 + } 1949 + // t.LangBreakdown (tangled.GitRefUpdate_LangBreakdown) (struct) 1950 + case "langBreakdown": 1951 + 1952 + { 1953 + 1954 + b, err := cr.ReadByte() 1955 + if err != nil { 1956 + return err 1957 + } 1958 + if b != cbg.CborNull[0] { 1959 + if err := cr.UnreadByte(); err != nil { 1960 + return err 1961 + } 1962 + t.LangBreakdown = new(GitRefUpdate_LangBreakdown) 1963 + if err := t.LangBreakdown.UnmarshalCBOR(cr); err != nil { 1964 + return xerrors.Errorf("unmarshaling t.LangBreakdown pointer: %w", err) 1965 + } 1966 + } 1967 + 1428 1968 } 1429 1969 1430 1970 default: ··· 1578 2118 } 1579 2119 1580 2120 t.Subject = string(sval) 2121 + } 2122 + // t.CreatedAt (string) (string) 2123 + case "createdAt": 2124 + 2125 + { 2126 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 2127 + if err != nil { 2128 + return err 2129 + } 2130 + 2131 + t.CreatedAt = string(sval) 2132 + } 2133 + 2134 + default: 2135 + // Field doesn't exist on this type, so ignore it 2136 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 2137 + return err 2138 + } 2139 + } 2140 + } 2141 + 2142 + return nil 2143 + } 2144 + func (t *Knot) MarshalCBOR(w io.Writer) error { 2145 + if t == nil { 2146 + _, err := w.Write(cbg.CborNull) 2147 + return err 2148 + } 2149 + 2150 + cw := cbg.NewCborWriter(w) 2151 + 2152 + if _, err := cw.Write([]byte{162}); err != nil { 2153 + return err 2154 + } 2155 + 2156 + // t.LexiconTypeID (string) (string) 2157 + if len("$type") > 1000000 { 2158 + return xerrors.Errorf("Value in field \"$type\" was too long") 2159 + } 2160 + 2161 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil { 2162 + return err 2163 + } 2164 + if _, err := cw.WriteString(string("$type")); err != nil { 2165 + return err 2166 + } 2167 + 2168 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sh.tangled.knot"))); err != nil { 2169 + return err 2170 + } 2171 + if _, err := cw.WriteString(string("sh.tangled.knot")); err != nil { 2172 + return err 2173 + } 2174 + 2175 + // t.CreatedAt (string) (string) 2176 + if len("createdAt") > 1000000 { 2177 + return xerrors.Errorf("Value in field \"createdAt\" was too long") 2178 + } 2179 + 2180 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil { 2181 + return err 2182 + } 2183 + if _, err := cw.WriteString(string("createdAt")); err != nil { 2184 + return err 2185 + } 2186 + 2187 + if len(t.CreatedAt) > 1000000 { 2188 + return xerrors.Errorf("Value in field t.CreatedAt was too long") 2189 + } 2190 + 2191 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil { 2192 + return err 2193 + } 2194 + if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { 2195 + return err 2196 + } 2197 + return nil 2198 + } 2199 + 2200 + func (t *Knot) UnmarshalCBOR(r io.Reader) (err error) { 2201 + *t = Knot{} 2202 + 2203 + cr := cbg.NewCborReader(r) 2204 + 2205 + maj, extra, err := cr.ReadHeader() 2206 + if err != nil { 2207 + return err 2208 + } 2209 + defer func() { 2210 + if err == io.EOF { 2211 + err = io.ErrUnexpectedEOF 2212 + } 2213 + }() 2214 + 2215 + if maj != cbg.MajMap { 2216 + return fmt.Errorf("cbor input should be of type map") 2217 + } 2218 + 2219 + if extra > cbg.MaxLength { 2220 + return fmt.Errorf("Knot: map struct too large (%d)", extra) 2221 + } 2222 + 2223 + n := extra 2224 + 2225 + nameBuf := make([]byte, 9) 2226 + for i := uint64(0); i < n; i++ { 2227 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 2228 + if err != nil { 2229 + return err 2230 + } 2231 + 2232 + if !ok { 2233 + // Field doesn't exist on this type, so ignore it 2234 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 2235 + return err 2236 + } 2237 + continue 2238 + } 2239 + 2240 + switch string(nameBuf[:nameLen]) { 2241 + // t.LexiconTypeID (string) (string) 2242 + case "$type": 2243 + 2244 + { 2245 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 2246 + if err != nil { 2247 + return err 2248 + } 2249 + 2250 + t.LexiconTypeID = string(sval) 1581 2251 } 1582 2252 // t.CreatedAt (string) (string) 1583 2253 case "createdAt": ··· 2188 2858 2189 2859 return nil 2190 2860 } 2191 - func (t *Pipeline_Dependencies_Elem) MarshalCBOR(w io.Writer) error { 2192 - if t == nil { 2193 - _, err := w.Write(cbg.CborNull) 2194 - return err 2195 - } 2196 - 2197 - cw := cbg.NewCborWriter(w) 2198 - 2199 - if _, err := cw.Write([]byte{162}); err != nil { 2200 - return err 2201 - } 2202 - 2203 - // t.Packages ([]string) (slice) 2204 - if len("packages") > 1000000 { 2205 - return xerrors.Errorf("Value in field \"packages\" was too long") 2206 - } 2207 - 2208 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("packages"))); err != nil { 2209 - return err 2210 - } 2211 - if _, err := cw.WriteString(string("packages")); err != nil { 2212 - return err 2213 - } 2214 - 2215 - if len(t.Packages) > 8192 { 2216 - return xerrors.Errorf("Slice value in field t.Packages was too long") 2217 - } 2218 - 2219 - if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Packages))); err != nil { 2220 - return err 2221 - } 2222 - for _, v := range t.Packages { 2223 - if len(v) > 1000000 { 2224 - return xerrors.Errorf("Value in field v was too long") 2225 - } 2226 - 2227 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil { 2228 - return err 2229 - } 2230 - if _, err := cw.WriteString(string(v)); err != nil { 2231 - return err 2232 - } 2233 - 2234 - } 2235 - 2236 - // t.Registry (string) (string) 2237 - if len("registry") > 1000000 { 2238 - return xerrors.Errorf("Value in field \"registry\" was too long") 2239 - } 2240 - 2241 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("registry"))); err != nil { 2242 - return err 2243 - } 2244 - if _, err := cw.WriteString(string("registry")); err != nil { 2245 - return err 2246 - } 2247 - 2248 - if len(t.Registry) > 1000000 { 2249 - return xerrors.Errorf("Value in field t.Registry was too long") 2250 - } 2251 - 2252 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Registry))); err != nil { 2253 - return err 2254 - } 2255 - if _, err := cw.WriteString(string(t.Registry)); err != nil { 2256 - return err 2257 - } 2258 - return nil 2259 - } 2260 - 2261 - func (t *Pipeline_Dependencies_Elem) UnmarshalCBOR(r io.Reader) (err error) { 2262 - *t = Pipeline_Dependencies_Elem{} 2263 - 2264 - cr := cbg.NewCborReader(r) 2265 - 2266 - maj, extra, err := cr.ReadHeader() 2267 - if err != nil { 2268 - return err 2269 - } 2270 - defer func() { 2271 - if err == io.EOF { 2272 - err = io.ErrUnexpectedEOF 2273 - } 2274 - }() 2275 - 2276 - if maj != cbg.MajMap { 2277 - return fmt.Errorf("cbor input should be of type map") 2278 - } 2279 - 2280 - if extra > cbg.MaxLength { 2281 - return fmt.Errorf("Pipeline_Dependencies_Elem: map struct too large (%d)", extra) 2282 - } 2283 - 2284 - n := extra 2285 - 2286 - nameBuf := make([]byte, 8) 2287 - for i := uint64(0); i < n; i++ { 2288 - nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 2289 - if err != nil { 2290 - return err 2291 - } 2292 - 2293 - if !ok { 2294 - // Field doesn't exist on this type, so ignore it 2295 - if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 2296 - return err 2297 - } 2298 - continue 2299 - } 2300 - 2301 - switch string(nameBuf[:nameLen]) { 2302 - // t.Packages ([]string) (slice) 2303 - case "packages": 2304 - 2305 - maj, extra, err = cr.ReadHeader() 2306 - if err != nil { 2307 - return err 2308 - } 2309 - 2310 - if extra > 8192 { 2311 - return fmt.Errorf("t.Packages: array too large (%d)", extra) 2312 - } 2313 - 2314 - if maj != cbg.MajArray { 2315 - return fmt.Errorf("expected cbor array") 2316 - } 2317 - 2318 - if extra > 0 { 2319 - t.Packages = make([]string, extra) 2320 - } 2321 - 2322 - for i := 0; i < int(extra); i++ { 2323 - { 2324 - var maj byte 2325 - var extra uint64 2326 - var err error 2327 - _ = maj 2328 - _ = extra 2329 - _ = err 2330 - 2331 - { 2332 - sval, err := cbg.ReadStringWithMax(cr, 1000000) 2333 - if err != nil { 2334 - return err 2335 - } 2336 - 2337 - t.Packages[i] = string(sval) 2338 - } 2339 - 2340 - } 2341 - } 2342 - // t.Registry (string) (string) 2343 - case "registry": 2344 - 2345 - { 2346 - sval, err := cbg.ReadStringWithMax(cr, 1000000) 2347 - if err != nil { 2348 - return err 2349 - } 2350 - 2351 - t.Registry = string(sval) 2352 - } 2353 - 2354 - default: 2355 - // Field doesn't exist on this type, so ignore it 2356 - if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 2357 - return err 2358 - } 2359 - } 2360 - } 2361 - 2362 - return nil 2363 - } 2364 2861 func (t *Pipeline_ManualTriggerData) MarshalCBOR(w io.Writer) error { 2365 2862 if t == nil { 2366 2863 _, err := w.Write(cbg.CborNull) ··· 2378 2875 return err 2379 2876 } 2380 2877 2381 - // t.Inputs ([]*tangled.Pipeline_ManualTriggerData_Inputs_Elem) (slice) 2878 + // t.Inputs ([]*tangled.Pipeline_Pair) (slice) 2382 2879 if t.Inputs != nil { 2383 2880 2384 2881 if len("inputs") > 1000000 { ··· 2450 2947 } 2451 2948 2452 2949 switch string(nameBuf[:nameLen]) { 2453 - // t.Inputs ([]*tangled.Pipeline_ManualTriggerData_Inputs_Elem) (slice) 2950 + // t.Inputs ([]*tangled.Pipeline_Pair) (slice) 2454 2951 case "inputs": 2455 2952 2456 2953 maj, extra, err = cr.ReadHeader() ··· 2467 2964 } 2468 2965 2469 2966 if extra > 0 { 2470 - t.Inputs = make([]*Pipeline_ManualTriggerData_Inputs_Elem, extra) 2967 + t.Inputs = make([]*Pipeline_Pair, extra) 2471 2968 } 2472 2969 2473 2970 for i := 0; i < int(extra); i++ { ··· 2489 2986 if err := cr.UnreadByte(); err != nil { 2490 2987 return err 2491 2988 } 2492 - t.Inputs[i] = new(Pipeline_ManualTriggerData_Inputs_Elem) 2989 + t.Inputs[i] = new(Pipeline_Pair) 2493 2990 if err := t.Inputs[i].UnmarshalCBOR(cr); err != nil { 2494 2991 return xerrors.Errorf("unmarshaling t.Inputs[i] pointer: %w", err) 2495 2992 } ··· 2510 3007 2511 3008 return nil 2512 3009 } 2513 - func (t *Pipeline_ManualTriggerData_Inputs_Elem) MarshalCBOR(w io.Writer) error { 3010 + func (t *Pipeline_Pair) MarshalCBOR(w io.Writer) error { 2514 3011 if t == nil { 2515 3012 _, err := w.Write(cbg.CborNull) 2516 3013 return err ··· 2570 3067 return nil 2571 3068 } 2572 3069 2573 - func (t *Pipeline_ManualTriggerData_Inputs_Elem) UnmarshalCBOR(r io.Reader) (err error) { 2574 - *t = Pipeline_ManualTriggerData_Inputs_Elem{} 3070 + func (t *Pipeline_Pair) UnmarshalCBOR(r io.Reader) (err error) { 3071 + *t = Pipeline_Pair{} 2575 3072 2576 3073 cr := cbg.NewCborReader(r) 2577 3074 ··· 2590 3087 } 2591 3088 2592 3089 if extra > cbg.MaxLength { 2593 - return fmt.Errorf("Pipeline_ManualTriggerData_Inputs_Elem: map struct too large (%d)", extra) 3090 + return fmt.Errorf("Pipeline_Pair: map struct too large (%d)", extra) 2594 3091 } 2595 3092 2596 3093 n := extra ··· 3014 3511 3015 3512 return nil 3016 3513 } 3017 - 3018 - func (t *Pipeline_Step_Environment_Elem) MarshalCBOR(w io.Writer) error { 3019 - if t == nil { 3020 - _, err := w.Write(cbg.CborNull) 3021 - return err 3022 - } 3023 - 3024 - cw := cbg.NewCborWriter(w) 3025 - 3026 - if _, err := cw.Write([]byte{162}); err != nil { 3027 - return err 3028 - } 3029 - 3030 - // t.Key (string) (string) 3031 - if len("key") > 1000000 { 3032 - return xerrors.Errorf("Value in field \"key\" was too long") 3033 - } 3034 - 3035 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("key"))); err != nil { 3036 - return err 3037 - } 3038 - if _, err := cw.WriteString(string("key")); err != nil { 3039 - return err 3040 - } 3041 - 3042 - if len(t.Key) > 1000000 { 3043 - return xerrors.Errorf("Value in field t.Key was too long") 3044 - } 3045 - 3046 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Key))); err != nil { 3047 - return err 3048 - } 3049 - if _, err := cw.WriteString(string(t.Key)); err != nil { 3050 - return err 3051 - } 3052 - 3053 - // t.Value (string) (string) 3054 - if len("value") > 1000000 { 3055 - return xerrors.Errorf("Value in field \"value\" was too long") 3056 - } 3057 - 3058 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("value"))); err != nil { 3059 - return err 3060 - } 3061 - if _, err := cw.WriteString(string("value")); err != nil { 3062 - return err 3063 - } 3064 - 3065 - if len(t.Value) > 1000000 { 3066 - return xerrors.Errorf("Value in field t.Value was too long") 3067 - } 3068 - 3069 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Value))); err != nil { 3070 - return err 3071 - } 3072 - if _, err := cw.WriteString(string(t.Value)); err != nil { 3073 - return err 3074 - } 3075 - return nil 3076 - } 3077 - 3078 - func (t *Pipeline_Step_Environment_Elem) UnmarshalCBOR(r io.Reader) (err error) { 3079 - *t = Pipeline_Step_Environment_Elem{} 3080 - 3081 - cr := cbg.NewCborReader(r) 3082 - 3083 - maj, extra, err := cr.ReadHeader() 3084 - if err != nil { 3085 - return err 3086 - } 3087 - defer func() { 3088 - if err == io.EOF { 3089 - err = io.ErrUnexpectedEOF 3090 - } 3091 - }() 3092 - 3093 - if maj != cbg.MajMap { 3094 - return fmt.Errorf("cbor input should be of type map") 3095 - } 3096 - 3097 - if extra > cbg.MaxLength { 3098 - return fmt.Errorf("Pipeline_Step_Environment_Elem: map struct too large (%d)", extra) 3099 - } 3100 - 3101 - n := extra 3102 - 3103 - nameBuf := make([]byte, 5) 3104 - for i := uint64(0); i < n; i++ { 3105 - nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 3106 - if err != nil { 3107 - return err 3108 - } 3109 - 3110 - if !ok { 3111 - // Field doesn't exist on this type, so ignore it 3112 - if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 3113 - return err 3114 - } 3115 - continue 3116 - } 3117 - 3118 - switch string(nameBuf[:nameLen]) { 3119 - // t.Key (string) (string) 3120 - case "key": 3121 - 3122 - { 3123 - sval, err := cbg.ReadStringWithMax(cr, 1000000) 3124 - if err != nil { 3125 - return err 3126 - } 3127 - 3128 - t.Key = string(sval) 3129 - } 3130 - // t.Value (string) (string) 3131 - case "value": 3132 - 3133 - { 3134 - sval, err := cbg.ReadStringWithMax(cr, 1000000) 3135 - if err != nil { 3136 - return err 3137 - } 3138 - 3139 - t.Value = string(sval) 3140 - } 3141 - 3142 - default: 3143 - // Field doesn't exist on this type, so ignore it 3144 - if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 3145 - return err 3146 - } 3147 - } 3148 - } 3149 - 3150 - return nil 3151 - } 3152 3514 func (t *PipelineStatus) MarshalCBOR(w io.Writer) error { 3153 3515 if t == nil { 3154 3516 _, err := w.Write(cbg.CborNull) ··· 3511 3873 3512 3874 return nil 3513 3875 } 3514 - 3515 - func (t *Pipeline_Step) MarshalCBOR(w io.Writer) error { 3516 - if t == nil { 3517 - _, err := w.Write(cbg.CborNull) 3518 - return err 3519 - } 3520 - 3521 - cw := cbg.NewCborWriter(w) 3522 - fieldCount := 3 3523 - 3524 - if t.Environment == nil { 3525 - fieldCount-- 3526 - } 3527 - 3528 - if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { 3529 - return err 3530 - } 3531 - 3532 - // t.Name (string) (string) 3533 - if len("name") > 1000000 { 3534 - return xerrors.Errorf("Value in field \"name\" was too long") 3535 - } 3536 - 3537 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("name"))); err != nil { 3538 - return err 3539 - } 3540 - if _, err := cw.WriteString(string("name")); err != nil { 3541 - return err 3542 - } 3543 - 3544 - if len(t.Name) > 1000000 { 3545 - return xerrors.Errorf("Value in field t.Name was too long") 3546 - } 3547 - 3548 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Name))); err != nil { 3549 - return err 3550 - } 3551 - if _, err := cw.WriteString(string(t.Name)); err != nil { 3552 - return err 3553 - } 3554 - 3555 - // t.Command (string) (string) 3556 - if len("command") > 1000000 { 3557 - return xerrors.Errorf("Value in field \"command\" was too long") 3558 - } 3559 - 3560 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("command"))); err != nil { 3561 - return err 3562 - } 3563 - if _, err := cw.WriteString(string("command")); err != nil { 3564 - return err 3565 - } 3566 - 3567 - if len(t.Command) > 1000000 { 3568 - return xerrors.Errorf("Value in field t.Command was too long") 3569 - } 3570 - 3571 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Command))); err != nil { 3572 - return err 3573 - } 3574 - if _, err := cw.WriteString(string(t.Command)); err != nil { 3575 - return err 3576 - } 3577 - 3578 - // t.Environment ([]*tangled.Pipeline_Step_Environment_Elem) (slice) 3579 - if t.Environment != nil { 3580 - 3581 - if len("environment") > 1000000 { 3582 - return xerrors.Errorf("Value in field \"environment\" was too long") 3583 - } 3584 - 3585 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("environment"))); err != nil { 3586 - return err 3587 - } 3588 - if _, err := cw.WriteString(string("environment")); err != nil { 3589 - return err 3590 - } 3591 - 3592 - if len(t.Environment) > 8192 { 3593 - return xerrors.Errorf("Slice value in field t.Environment was too long") 3594 - } 3595 - 3596 - if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Environment))); err != nil { 3597 - return err 3598 - } 3599 - for _, v := range t.Environment { 3600 - if err := v.MarshalCBOR(cw); err != nil { 3601 - return err 3602 - } 3603 - 3604 - } 3605 - } 3606 - return nil 3607 - } 3608 - 3609 - func (t *Pipeline_Step) UnmarshalCBOR(r io.Reader) (err error) { 3610 - *t = Pipeline_Step{} 3611 - 3612 - cr := cbg.NewCborReader(r) 3613 - 3614 - maj, extra, err := cr.ReadHeader() 3615 - if err != nil { 3616 - return err 3617 - } 3618 - defer func() { 3619 - if err == io.EOF { 3620 - err = io.ErrUnexpectedEOF 3621 - } 3622 - }() 3623 - 3624 - if maj != cbg.MajMap { 3625 - return fmt.Errorf("cbor input should be of type map") 3626 - } 3627 - 3628 - if extra > cbg.MaxLength { 3629 - return fmt.Errorf("Pipeline_Step: map struct too large (%d)", extra) 3630 - } 3631 - 3632 - n := extra 3633 - 3634 - nameBuf := make([]byte, 11) 3635 - for i := uint64(0); i < n; i++ { 3636 - nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 3637 - if err != nil { 3638 - return err 3639 - } 3640 - 3641 - if !ok { 3642 - // Field doesn't exist on this type, so ignore it 3643 - if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 3644 - return err 3645 - } 3646 - continue 3647 - } 3648 - 3649 - switch string(nameBuf[:nameLen]) { 3650 - // t.Name (string) (string) 3651 - case "name": 3652 - 3653 - { 3654 - sval, err := cbg.ReadStringWithMax(cr, 1000000) 3655 - if err != nil { 3656 - return err 3657 - } 3658 - 3659 - t.Name = string(sval) 3660 - } 3661 - // t.Command (string) (string) 3662 - case "command": 3663 - 3664 - { 3665 - sval, err := cbg.ReadStringWithMax(cr, 1000000) 3666 - if err != nil { 3667 - return err 3668 - } 3669 - 3670 - t.Command = string(sval) 3671 - } 3672 - // t.Environment ([]*tangled.Pipeline_Step_Environment_Elem) (slice) 3673 - case "environment": 3674 - 3675 - maj, extra, err = cr.ReadHeader() 3676 - if err != nil { 3677 - return err 3678 - } 3679 - 3680 - if extra > 8192 { 3681 - return fmt.Errorf("t.Environment: array too large (%d)", extra) 3682 - } 3683 - 3684 - if maj != cbg.MajArray { 3685 - return fmt.Errorf("expected cbor array") 3686 - } 3687 - 3688 - if extra > 0 { 3689 - t.Environment = make([]*Pipeline_Step_Environment_Elem, extra) 3690 - } 3691 - 3692 - for i := 0; i < int(extra); i++ { 3693 - { 3694 - var maj byte 3695 - var extra uint64 3696 - var err error 3697 - _ = maj 3698 - _ = extra 3699 - _ = err 3700 - 3701 - { 3702 - 3703 - b, err := cr.ReadByte() 3704 - if err != nil { 3705 - return err 3706 - } 3707 - if b != cbg.CborNull[0] { 3708 - if err := cr.UnreadByte(); err != nil { 3709 - return err 3710 - } 3711 - t.Environment[i] = new(Pipeline_Step_Environment_Elem) 3712 - if err := t.Environment[i].UnmarshalCBOR(cr); err != nil { 3713 - return xerrors.Errorf("unmarshaling t.Environment[i] pointer: %w", err) 3714 - } 3715 - } 3716 - 3717 - } 3718 - 3719 - } 3720 - } 3721 - 3722 - default: 3723 - // Field doesn't exist on this type, so ignore it 3724 - if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 3725 - return err 3726 - } 3727 - } 3728 - } 3729 - 3730 - return nil 3731 - } 3732 3876 func (t *Pipeline_TriggerMetadata) MarshalCBOR(w io.Writer) error { 3733 3877 if t == nil { 3734 3878 _, err := w.Write(cbg.CborNull) ··· 4205 4349 4206 4350 cw := cbg.NewCborWriter(w) 4207 4351 4208 - if _, err := cw.Write([]byte{165}); err != nil { 4352 + if _, err := cw.Write([]byte{164}); err != nil { 4353 + return err 4354 + } 4355 + 4356 + // t.Raw (string) (string) 4357 + if len("raw") > 1000000 { 4358 + return xerrors.Errorf("Value in field \"raw\" was too long") 4359 + } 4360 + 4361 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("raw"))); err != nil { 4362 + return err 4363 + } 4364 + if _, err := cw.WriteString(string("raw")); err != nil { 4365 + return err 4366 + } 4367 + 4368 + if len(t.Raw) > 1000000 { 4369 + return xerrors.Errorf("Value in field t.Raw was too long") 4370 + } 4371 + 4372 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Raw))); err != nil { 4373 + return err 4374 + } 4375 + if _, err := cw.WriteString(string(t.Raw)); err != nil { 4209 4376 return err 4210 4377 } 4211 4378 ··· 4248 4415 return err 4249 4416 } 4250 4417 4251 - // t.Steps ([]*tangled.Pipeline_Step) (slice) 4252 - if len("steps") > 1000000 { 4253 - return xerrors.Errorf("Value in field \"steps\" was too long") 4418 + // t.Engine (string) (string) 4419 + if len("engine") > 1000000 { 4420 + return xerrors.Errorf("Value in field \"engine\" was too long") 4254 4421 } 4255 4422 4256 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("steps"))); err != nil { 4257 - return err 4258 - } 4259 - if _, err := cw.WriteString(string("steps")); err != nil { 4423 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("engine"))); err != nil { 4260 4424 return err 4261 4425 } 4262 - 4263 - if len(t.Steps) > 8192 { 4264 - return xerrors.Errorf("Slice value in field t.Steps was too long") 4265 - } 4266 - 4267 - if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Steps))); err != nil { 4426 + if _, err := cw.WriteString(string("engine")); err != nil { 4268 4427 return err 4269 4428 } 4270 - for _, v := range t.Steps { 4271 - if err := v.MarshalCBOR(cw); err != nil { 4272 - return err 4273 - } 4274 4429 4430 + if len(t.Engine) > 1000000 { 4431 + return xerrors.Errorf("Value in field t.Engine was too long") 4275 4432 } 4276 4433 4277 - // t.Environment ([]*tangled.Pipeline_Workflow_Environment_Elem) (slice) 4278 - if len("environment") > 1000000 { 4279 - return xerrors.Errorf("Value in field \"environment\" was too long") 4280 - } 4281 - 4282 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("environment"))); err != nil { 4434 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Engine))); err != nil { 4283 4435 return err 4284 4436 } 4285 - if _, err := cw.WriteString(string("environment")); err != nil { 4437 + if _, err := cw.WriteString(string(t.Engine)); err != nil { 4286 4438 return err 4287 4439 } 4288 - 4289 - if len(t.Environment) > 8192 { 4290 - return xerrors.Errorf("Slice value in field t.Environment was too long") 4291 - } 4292 - 4293 - if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Environment))); err != nil { 4294 - return err 4295 - } 4296 - for _, v := range t.Environment { 4297 - if err := v.MarshalCBOR(cw); err != nil { 4298 - return err 4299 - } 4300 - 4301 - } 4302 - 4303 - // t.Dependencies ([]tangled.Pipeline_Dependencies_Elem) (slice) 4304 - if len("dependencies") > 1000000 { 4305 - return xerrors.Errorf("Value in field \"dependencies\" was too long") 4306 - } 4307 - 4308 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("dependencies"))); err != nil { 4309 - return err 4310 - } 4311 - if _, err := cw.WriteString(string("dependencies")); err != nil { 4312 - return err 4313 - } 4314 - 4315 - if len(t.Dependencies) > 8192 { 4316 - return xerrors.Errorf("Slice value in field t.Dependencies was too long") 4317 - } 4318 - 4319 - if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Dependencies))); err != nil { 4320 - return err 4321 - } 4322 - for _, v := range t.Dependencies { 4323 - if err := v.MarshalCBOR(cw); err != nil { 4324 - return err 4325 - } 4326 - 4327 - } 4328 4440 return nil 4329 4441 } 4330 4442 ··· 4353 4465 4354 4466 n := extra 4355 4467 4356 - nameBuf := make([]byte, 12) 4468 + nameBuf := make([]byte, 6) 4357 4469 for i := uint64(0); i < n; i++ { 4358 4470 nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 4359 4471 if err != nil { ··· 4369 4481 } 4370 4482 4371 4483 switch string(nameBuf[:nameLen]) { 4372 - // t.Name (string) (string) 4484 + // t.Raw (string) (string) 4485 + case "raw": 4486 + 4487 + { 4488 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 4489 + if err != nil { 4490 + return err 4491 + } 4492 + 4493 + t.Raw = string(sval) 4494 + } 4495 + // t.Name (string) (string) 4373 4496 case "name": 4374 4497 4375 4498 { ··· 4400 4523 } 4401 4524 4402 4525 } 4403 - // t.Steps ([]*tangled.Pipeline_Step) (slice) 4404 - case "steps": 4405 - 4406 - maj, extra, err = cr.ReadHeader() 4407 - if err != nil { 4408 - return err 4409 - } 4410 - 4411 - if extra > 8192 { 4412 - return fmt.Errorf("t.Steps: array too large (%d)", extra) 4413 - } 4414 - 4415 - if maj != cbg.MajArray { 4416 - return fmt.Errorf("expected cbor array") 4417 - } 4418 - 4419 - if extra > 0 { 4420 - t.Steps = make([]*Pipeline_Step, extra) 4421 - } 4422 - 4423 - for i := 0; i < int(extra); i++ { 4424 - { 4425 - var maj byte 4426 - var extra uint64 4427 - var err error 4428 - _ = maj 4429 - _ = extra 4430 - _ = err 4431 - 4432 - { 4433 - 4434 - b, err := cr.ReadByte() 4435 - if err != nil { 4436 - return err 4437 - } 4438 - if b != cbg.CborNull[0] { 4439 - if err := cr.UnreadByte(); err != nil { 4440 - return err 4441 - } 4442 - t.Steps[i] = new(Pipeline_Step) 4443 - if err := t.Steps[i].UnmarshalCBOR(cr); err != nil { 4444 - return xerrors.Errorf("unmarshaling t.Steps[i] pointer: %w", err) 4445 - } 4446 - } 4447 - 4448 - } 4449 - 4450 - } 4451 - } 4452 - // t.Environment ([]*tangled.Pipeline_Workflow_Environment_Elem) (slice) 4453 - case "environment": 4454 - 4455 - maj, extra, err = cr.ReadHeader() 4456 - if err != nil { 4457 - return err 4458 - } 4459 - 4460 - if extra > 8192 { 4461 - return fmt.Errorf("t.Environment: array too large (%d)", extra) 4462 - } 4463 - 4464 - if maj != cbg.MajArray { 4465 - return fmt.Errorf("expected cbor array") 4466 - } 4467 - 4468 - if extra > 0 { 4469 - t.Environment = make([]*Pipeline_Workflow_Environment_Elem, extra) 4470 - } 4471 - 4472 - for i := 0; i < int(extra); i++ { 4473 - { 4474 - var maj byte 4475 - var extra uint64 4476 - var err error 4477 - _ = maj 4478 - _ = extra 4479 - _ = err 4480 - 4481 - { 4482 - 4483 - b, err := cr.ReadByte() 4484 - if err != nil { 4485 - return err 4486 - } 4487 - if b != cbg.CborNull[0] { 4488 - if err := cr.UnreadByte(); err != nil { 4489 - return err 4490 - } 4491 - t.Environment[i] = new(Pipeline_Workflow_Environment_Elem) 4492 - if err := t.Environment[i].UnmarshalCBOR(cr); err != nil { 4493 - return xerrors.Errorf("unmarshaling t.Environment[i] pointer: %w", err) 4494 - } 4495 - } 4496 - 4497 - } 4498 - 4499 - } 4500 - } 4501 - // t.Dependencies ([]tangled.Pipeline_Dependencies_Elem) (slice) 4502 - case "dependencies": 4503 - 4504 - maj, extra, err = cr.ReadHeader() 4505 - if err != nil { 4506 - return err 4507 - } 4508 - 4509 - if extra > 8192 { 4510 - return fmt.Errorf("t.Dependencies: array too large (%d)", extra) 4511 - } 4512 - 4513 - if maj != cbg.MajArray { 4514 - return fmt.Errorf("expected cbor array") 4515 - } 4516 - 4517 - if extra > 0 { 4518 - t.Dependencies = make([]Pipeline_Dependencies_Elem, extra) 4519 - } 4520 - 4521 - for i := 0; i < int(extra); i++ { 4522 - { 4523 - var maj byte 4524 - var extra uint64 4525 - var err error 4526 - _ = maj 4527 - _ = extra 4528 - _ = err 4529 - 4530 - { 4531 - 4532 - if err := t.Dependencies[i].UnmarshalCBOR(cr); err != nil { 4533 - return xerrors.Errorf("unmarshaling t.Dependencies[i]: %w", err) 4534 - } 4535 - 4536 - } 4537 - 4538 - } 4539 - } 4540 - 4541 - default: 4542 - // Field doesn't exist on this type, so ignore it 4543 - if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 4544 - return err 4545 - } 4546 - } 4547 - } 4548 - 4549 - return nil 4550 - } 4551 - func (t *Pipeline_Workflow_Environment_Elem) MarshalCBOR(w io.Writer) error { 4552 - if t == nil { 4553 - _, err := w.Write(cbg.CborNull) 4554 - return err 4555 - } 4556 - 4557 - cw := cbg.NewCborWriter(w) 4558 - 4559 - if _, err := cw.Write([]byte{162}); err != nil { 4560 - return err 4561 - } 4562 - 4563 - // t.Key (string) (string) 4564 - if len("key") > 1000000 { 4565 - return xerrors.Errorf("Value in field \"key\" was too long") 4566 - } 4567 - 4568 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("key"))); err != nil { 4569 - return err 4570 - } 4571 - if _, err := cw.WriteString(string("key")); err != nil { 4572 - return err 4573 - } 4574 - 4575 - if len(t.Key) > 1000000 { 4576 - return xerrors.Errorf("Value in field t.Key was too long") 4577 - } 4578 - 4579 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Key))); err != nil { 4580 - return err 4581 - } 4582 - if _, err := cw.WriteString(string(t.Key)); err != nil { 4583 - return err 4584 - } 4585 - 4586 - // t.Value (string) (string) 4587 - if len("value") > 1000000 { 4588 - return xerrors.Errorf("Value in field \"value\" was too long") 4589 - } 4590 - 4591 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("value"))); err != nil { 4592 - return err 4593 - } 4594 - if _, err := cw.WriteString(string("value")); err != nil { 4595 - return err 4596 - } 4597 - 4598 - if len(t.Value) > 1000000 { 4599 - return xerrors.Errorf("Value in field t.Value was too long") 4600 - } 4601 - 4602 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Value))); err != nil { 4603 - return err 4604 - } 4605 - if _, err := cw.WriteString(string(t.Value)); err != nil { 4606 - return err 4607 - } 4608 - return nil 4609 - } 4610 - 4611 - func (t *Pipeline_Workflow_Environment_Elem) UnmarshalCBOR(r io.Reader) (err error) { 4612 - *t = Pipeline_Workflow_Environment_Elem{} 4613 - 4614 - cr := cbg.NewCborReader(r) 4615 - 4616 - maj, extra, err := cr.ReadHeader() 4617 - if err != nil { 4618 - return err 4619 - } 4620 - defer func() { 4621 - if err == io.EOF { 4622 - err = io.ErrUnexpectedEOF 4623 - } 4624 - }() 4625 - 4626 - if maj != cbg.MajMap { 4627 - return fmt.Errorf("cbor input should be of type map") 4628 - } 4629 - 4630 - if extra > cbg.MaxLength { 4631 - return fmt.Errorf("Pipeline_Workflow_Environment_Elem: map struct too large (%d)", extra) 4632 - } 4633 - 4634 - n := extra 4635 - 4636 - nameBuf := make([]byte, 5) 4637 - for i := uint64(0); i < n; i++ { 4638 - nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 4639 - if err != nil { 4640 - return err 4641 - } 4642 - 4643 - if !ok { 4644 - // Field doesn't exist on this type, so ignore it 4645 - if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 4646 - return err 4647 - } 4648 - continue 4649 - } 4650 - 4651 - switch string(nameBuf[:nameLen]) { 4652 - // t.Key (string) (string) 4653 - case "key": 4526 + // t.Engine (string) (string) 4527 + case "engine": 4654 4528 4655 4529 { 4656 4530 sval, err := cbg.ReadStringWithMax(cr, 1000000) ··· 4658 4532 return err 4659 4533 } 4660 4534 4661 - t.Key = string(sval) 4662 - } 4663 - // t.Value (string) (string) 4664 - case "value": 4665 - 4666 - { 4667 - sval, err := cbg.ReadStringWithMax(cr, 1000000) 4668 - if err != nil { 4669 - return err 4670 - } 4671 - 4672 - t.Value = string(sval) 4535 + t.Engine = string(sval) 4673 4536 } 4674 4537 4675 4538 default: ··· 5574 5437 5575 5438 return nil 5576 5439 } 5440 + func (t *RepoCollaborator) MarshalCBOR(w io.Writer) error { 5441 + if t == nil { 5442 + _, err := w.Write(cbg.CborNull) 5443 + return err 5444 + } 5445 + 5446 + cw := cbg.NewCborWriter(w) 5447 + 5448 + if _, err := cw.Write([]byte{164}); err != nil { 5449 + return err 5450 + } 5451 + 5452 + // t.Repo (string) (string) 5453 + if len("repo") > 1000000 { 5454 + return xerrors.Errorf("Value in field \"repo\" was too long") 5455 + } 5456 + 5457 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("repo"))); err != nil { 5458 + return err 5459 + } 5460 + if _, err := cw.WriteString(string("repo")); err != nil { 5461 + return err 5462 + } 5463 + 5464 + if len(t.Repo) > 1000000 { 5465 + return xerrors.Errorf("Value in field t.Repo was too long") 5466 + } 5467 + 5468 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Repo))); err != nil { 5469 + return err 5470 + } 5471 + if _, err := cw.WriteString(string(t.Repo)); err != nil { 5472 + return err 5473 + } 5474 + 5475 + // t.LexiconTypeID (string) (string) 5476 + if len("$type") > 1000000 { 5477 + return xerrors.Errorf("Value in field \"$type\" was too long") 5478 + } 5479 + 5480 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil { 5481 + return err 5482 + } 5483 + if _, err := cw.WriteString(string("$type")); err != nil { 5484 + return err 5485 + } 5486 + 5487 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sh.tangled.repo.collaborator"))); err != nil { 5488 + return err 5489 + } 5490 + if _, err := cw.WriteString(string("sh.tangled.repo.collaborator")); err != nil { 5491 + return err 5492 + } 5493 + 5494 + // t.Subject (string) (string) 5495 + if len("subject") > 1000000 { 5496 + return xerrors.Errorf("Value in field \"subject\" was too long") 5497 + } 5498 + 5499 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("subject"))); err != nil { 5500 + return err 5501 + } 5502 + if _, err := cw.WriteString(string("subject")); err != nil { 5503 + return err 5504 + } 5505 + 5506 + if len(t.Subject) > 1000000 { 5507 + return xerrors.Errorf("Value in field t.Subject was too long") 5508 + } 5509 + 5510 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Subject))); err != nil { 5511 + return err 5512 + } 5513 + if _, err := cw.WriteString(string(t.Subject)); err != nil { 5514 + return err 5515 + } 5516 + 5517 + // t.CreatedAt (string) (string) 5518 + if len("createdAt") > 1000000 { 5519 + return xerrors.Errorf("Value in field \"createdAt\" was too long") 5520 + } 5521 + 5522 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil { 5523 + return err 5524 + } 5525 + if _, err := cw.WriteString(string("createdAt")); err != nil { 5526 + return err 5527 + } 5528 + 5529 + if len(t.CreatedAt) > 1000000 { 5530 + return xerrors.Errorf("Value in field t.CreatedAt was too long") 5531 + } 5532 + 5533 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil { 5534 + return err 5535 + } 5536 + if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { 5537 + return err 5538 + } 5539 + return nil 5540 + } 5541 + 5542 + func (t *RepoCollaborator) UnmarshalCBOR(r io.Reader) (err error) { 5543 + *t = RepoCollaborator{} 5544 + 5545 + cr := cbg.NewCborReader(r) 5546 + 5547 + maj, extra, err := cr.ReadHeader() 5548 + if err != nil { 5549 + return err 5550 + } 5551 + defer func() { 5552 + if err == io.EOF { 5553 + err = io.ErrUnexpectedEOF 5554 + } 5555 + }() 5556 + 5557 + if maj != cbg.MajMap { 5558 + return fmt.Errorf("cbor input should be of type map") 5559 + } 5560 + 5561 + if extra > cbg.MaxLength { 5562 + return fmt.Errorf("RepoCollaborator: map struct too large (%d)", extra) 5563 + } 5564 + 5565 + n := extra 5566 + 5567 + nameBuf := make([]byte, 9) 5568 + for i := uint64(0); i < n; i++ { 5569 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 5570 + if err != nil { 5571 + return err 5572 + } 5573 + 5574 + if !ok { 5575 + // Field doesn't exist on this type, so ignore it 5576 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 5577 + return err 5578 + } 5579 + continue 5580 + } 5581 + 5582 + switch string(nameBuf[:nameLen]) { 5583 + // t.Repo (string) (string) 5584 + case "repo": 5585 + 5586 + { 5587 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 5588 + if err != nil { 5589 + return err 5590 + } 5591 + 5592 + t.Repo = string(sval) 5593 + } 5594 + // t.LexiconTypeID (string) (string) 5595 + case "$type": 5596 + 5597 + { 5598 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 5599 + if err != nil { 5600 + return err 5601 + } 5602 + 5603 + t.LexiconTypeID = string(sval) 5604 + } 5605 + // t.Subject (string) (string) 5606 + case "subject": 5607 + 5608 + { 5609 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 5610 + if err != nil { 5611 + return err 5612 + } 5613 + 5614 + t.Subject = string(sval) 5615 + } 5616 + // t.CreatedAt (string) (string) 5617 + case "createdAt": 5618 + 5619 + { 5620 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 5621 + if err != nil { 5622 + return err 5623 + } 5624 + 5625 + t.CreatedAt = string(sval) 5626 + } 5627 + 5628 + default: 5629 + // Field doesn't exist on this type, so ignore it 5630 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 5631 + return err 5632 + } 5633 + } 5634 + } 5635 + 5636 + return nil 5637 + } 5577 5638 func (t *RepoIssue) MarshalCBOR(w io.Writer) error { 5578 5639 if t == nil { 5579 5640 _, err := w.Write(cbg.CborNull) ··· 5581 5642 } 5582 5643 5583 5644 cw := cbg.NewCborWriter(w) 5584 - fieldCount := 7 5645 + fieldCount := 5 5585 5646 5586 5647 if t.Body == nil { 5587 5648 fieldCount-- ··· 5665 5726 return err 5666 5727 } 5667 5728 5668 - // t.Owner (string) (string) 5669 - if len("owner") > 1000000 { 5670 - return xerrors.Errorf("Value in field \"owner\" was too long") 5671 - } 5672 - 5673 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("owner"))); err != nil { 5674 - return err 5675 - } 5676 - if _, err := cw.WriteString(string("owner")); err != nil { 5677 - return err 5678 - } 5679 - 5680 - if len(t.Owner) > 1000000 { 5681 - return xerrors.Errorf("Value in field t.Owner was too long") 5682 - } 5683 - 5684 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Owner))); err != nil { 5685 - return err 5686 - } 5687 - if _, err := cw.WriteString(string(t.Owner)); err != nil { 5688 - return err 5689 - } 5690 - 5691 5729 // t.Title (string) (string) 5692 5730 if len("title") > 1000000 { 5693 5731 return xerrors.Errorf("Value in field \"title\" was too long") ··· 5709 5747 } 5710 5748 if _, err := cw.WriteString(string(t.Title)); err != nil { 5711 5749 return err 5712 - } 5713 - 5714 - // t.IssueId (int64) (int64) 5715 - if len("issueId") > 1000000 { 5716 - return xerrors.Errorf("Value in field \"issueId\" was too long") 5717 - } 5718 - 5719 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("issueId"))); err != nil { 5720 - return err 5721 - } 5722 - if _, err := cw.WriteString(string("issueId")); err != nil { 5723 - return err 5724 - } 5725 - 5726 - if t.IssueId >= 0 { 5727 - if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(t.IssueId)); err != nil { 5728 - return err 5729 - } 5730 - } else { 5731 - if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-t.IssueId-1)); err != nil { 5732 - return err 5733 - } 5734 5750 } 5735 5751 5736 5752 // t.CreatedAt (string) (string) ··· 5842 5858 5843 5859 t.LexiconTypeID = string(sval) 5844 5860 } 5845 - // t.Owner (string) (string) 5846 - case "owner": 5847 - 5848 - { 5849 - sval, err := cbg.ReadStringWithMax(cr, 1000000) 5850 - if err != nil { 5851 - return err 5852 - } 5853 - 5854 - t.Owner = string(sval) 5855 - } 5856 5861 // t.Title (string) (string) 5857 5862 case "title": 5858 5863 ··· 5864 5869 5865 5870 t.Title = string(sval) 5866 5871 } 5867 - // t.IssueId (int64) (int64) 5868 - case "issueId": 5869 - { 5870 - maj, extra, err := cr.ReadHeader() 5871 - if err != nil { 5872 - return err 5873 - } 5874 - var extraI int64 5875 - switch maj { 5876 - case cbg.MajUnsignedInt: 5877 - extraI = int64(extra) 5878 - if extraI < 0 { 5879 - return fmt.Errorf("int64 positive overflow") 5880 - } 5881 - case cbg.MajNegativeInt: 5882 - extraI = int64(extra) 5883 - if extraI < 0 { 5884 - return fmt.Errorf("int64 negative overflow") 5885 - } 5886 - extraI = -1 - extraI 5887 - default: 5888 - return fmt.Errorf("wrong type for int64 field: %d", maj) 5889 - } 5890 - 5891 - t.IssueId = int64(extraI) 5892 - } 5893 5872 // t.CreatedAt (string) (string) 5894 5873 case "createdAt": 5895 5874 ··· 5919 5898 } 5920 5899 5921 5900 cw := cbg.NewCborWriter(w) 5922 - fieldCount := 7 5901 + fieldCount := 5 5923 5902 5924 - if t.CommentId == nil { 5925 - fieldCount-- 5926 - } 5927 - 5928 - if t.Owner == nil { 5929 - fieldCount-- 5930 - } 5931 - 5932 - if t.Repo == nil { 5903 + if t.ReplyTo == nil { 5933 5904 fieldCount-- 5934 5905 } 5935 5906 ··· 5960 5931 return err 5961 5932 } 5962 5933 5963 - // t.Repo (string) (string) 5964 - if t.Repo != nil { 5965 - 5966 - if len("repo") > 1000000 { 5967 - return xerrors.Errorf("Value in field \"repo\" was too long") 5968 - } 5969 - 5970 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("repo"))); err != nil { 5971 - return err 5972 - } 5973 - if _, err := cw.WriteString(string("repo")); err != nil { 5974 - return err 5975 - } 5976 - 5977 - if t.Repo == nil { 5978 - if _, err := cw.Write(cbg.CborNull); err != nil { 5979 - return err 5980 - } 5981 - } else { 5982 - if len(*t.Repo) > 1000000 { 5983 - return xerrors.Errorf("Value in field t.Repo was too long") 5984 - } 5985 - 5986 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Repo))); err != nil { 5987 - return err 5988 - } 5989 - if _, err := cw.WriteString(string(*t.Repo)); err != nil { 5990 - return err 5991 - } 5992 - } 5993 - } 5994 - 5995 5934 // t.LexiconTypeID (string) (string) 5996 5935 if len("$type") > 1000000 { 5997 5936 return xerrors.Errorf("Value in field \"$type\" was too long") ··· 6034 5973 return err 6035 5974 } 6036 5975 6037 - // t.Owner (string) (string) 6038 - if t.Owner != nil { 5976 + // t.ReplyTo (string) (string) 5977 + if t.ReplyTo != nil { 6039 5978 6040 - if len("owner") > 1000000 { 6041 - return xerrors.Errorf("Value in field \"owner\" was too long") 5979 + if len("replyTo") > 1000000 { 5980 + return xerrors.Errorf("Value in field \"replyTo\" was too long") 6042 5981 } 6043 5982 6044 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("owner"))); err != nil { 5983 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("replyTo"))); err != nil { 6045 5984 return err 6046 5985 } 6047 - if _, err := cw.WriteString(string("owner")); err != nil { 5986 + if _, err := cw.WriteString(string("replyTo")); err != nil { 6048 5987 return err 6049 5988 } 6050 5989 6051 - if t.Owner == nil { 5990 + if t.ReplyTo == nil { 6052 5991 if _, err := cw.Write(cbg.CborNull); err != nil { 6053 5992 return err 6054 5993 } 6055 5994 } else { 6056 - if len(*t.Owner) > 1000000 { 6057 - return xerrors.Errorf("Value in field t.Owner was too long") 5995 + if len(*t.ReplyTo) > 1000000 { 5996 + return xerrors.Errorf("Value in field t.ReplyTo was too long") 6058 5997 } 6059 5998 6060 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Owner))); err != nil { 5999 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.ReplyTo))); err != nil { 6061 6000 return err 6062 6001 } 6063 - if _, err := cw.WriteString(string(*t.Owner)); err != nil { 6002 + if _, err := cw.WriteString(string(*t.ReplyTo)); err != nil { 6064 6003 return err 6065 6004 } 6066 6005 } 6067 - } 6068 - 6069 - // t.CommentId (int64) (int64) 6070 - if t.CommentId != nil { 6071 - 6072 - if len("commentId") > 1000000 { 6073 - return xerrors.Errorf("Value in field \"commentId\" was too long") 6074 - } 6075 - 6076 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("commentId"))); err != nil { 6077 - return err 6078 - } 6079 - if _, err := cw.WriteString(string("commentId")); err != nil { 6080 - return err 6081 - } 6082 - 6083 - if t.CommentId == nil { 6084 - if _, err := cw.Write(cbg.CborNull); err != nil { 6085 - return err 6086 - } 6087 - } else { 6088 - if *t.CommentId >= 0 { 6089 - if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(*t.CommentId)); err != nil { 6090 - return err 6091 - } 6092 - } else { 6093 - if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-*t.CommentId-1)); err != nil { 6094 - return err 6095 - } 6096 - } 6097 - } 6098 - 6099 6006 } 6100 6007 6101 6008 // t.CreatedAt (string) (string) ··· 6175 6082 6176 6083 t.Body = string(sval) 6177 6084 } 6178 - // t.Repo (string) (string) 6179 - case "repo": 6180 - 6181 - { 6182 - b, err := cr.ReadByte() 6183 - if err != nil { 6184 - return err 6185 - } 6186 - if b != cbg.CborNull[0] { 6187 - if err := cr.UnreadByte(); err != nil { 6188 - return err 6189 - } 6190 - 6191 - sval, err := cbg.ReadStringWithMax(cr, 1000000) 6192 - if err != nil { 6193 - return err 6194 - } 6195 - 6196 - t.Repo = (*string)(&sval) 6197 - } 6198 - } 6199 6085 // t.LexiconTypeID (string) (string) 6200 6086 case "$type": 6201 6087 ··· 6218 6104 6219 6105 t.Issue = string(sval) 6220 6106 } 6221 - // t.Owner (string) (string) 6222 - case "owner": 6107 + // t.ReplyTo (string) (string) 6108 + case "replyTo": 6223 6109 6224 6110 { 6225 6111 b, err := cr.ReadByte() ··· 6236 6122 return err 6237 6123 } 6238 6124 6239 - t.Owner = (*string)(&sval) 6240 - } 6241 - } 6242 - // t.CommentId (int64) (int64) 6243 - case "commentId": 6244 - { 6245 - 6246 - b, err := cr.ReadByte() 6247 - if err != nil { 6248 - return err 6249 - } 6250 - if b != cbg.CborNull[0] { 6251 - if err := cr.UnreadByte(); err != nil { 6252 - return err 6253 - } 6254 - maj, extra, err := cr.ReadHeader() 6255 - if err != nil { 6256 - return err 6257 - } 6258 - var extraI int64 6259 - switch maj { 6260 - case cbg.MajUnsignedInt: 6261 - extraI = int64(extra) 6262 - if extraI < 0 { 6263 - return fmt.Errorf("int64 positive overflow") 6264 - } 6265 - case cbg.MajNegativeInt: 6266 - extraI = int64(extra) 6267 - if extraI < 0 { 6268 - return fmt.Errorf("int64 negative overflow") 6269 - } 6270 - extraI = -1 - extraI 6271 - default: 6272 - return fmt.Errorf("wrong type for int64 field: %d", maj) 6273 - } 6274 - 6275 - t.CommentId = (*int64)(&extraI) 6125 + t.ReplyTo = (*string)(&sval) 6276 6126 } 6277 6127 } 6278 6128 // t.CreatedAt (string) (string) ··· 6468 6318 } 6469 6319 6470 6320 cw := cbg.NewCborWriter(w) 6471 - fieldCount := 9 6321 + fieldCount := 7 6472 6322 6473 6323 if t.Body == nil { 6474 6324 fieldCount-- ··· 6579 6429 return err 6580 6430 } 6581 6431 6582 - // t.PullId (int64) (int64) 6583 - if len("pullId") > 1000000 { 6584 - return xerrors.Errorf("Value in field \"pullId\" was too long") 6585 - } 6586 - 6587 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("pullId"))); err != nil { 6588 - return err 6589 - } 6590 - if _, err := cw.WriteString(string("pullId")); err != nil { 6591 - return err 6592 - } 6593 - 6594 - if t.PullId >= 0 { 6595 - if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(t.PullId)); err != nil { 6596 - return err 6597 - } 6598 - } else { 6599 - if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-t.PullId-1)); err != nil { 6600 - return err 6601 - } 6602 - } 6603 - 6604 6432 // t.Source (tangled.RepoPull_Source) (struct) 6605 6433 if t.Source != nil { 6606 6434 ··· 6620 6448 } 6621 6449 } 6622 6450 6623 - // t.CreatedAt (string) (string) 6624 - if len("createdAt") > 1000000 { 6625 - return xerrors.Errorf("Value in field \"createdAt\" was too long") 6451 + // t.Target (tangled.RepoPull_Target) (struct) 6452 + if len("target") > 1000000 { 6453 + return xerrors.Errorf("Value in field \"target\" was too long") 6626 6454 } 6627 6455 6628 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil { 6456 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("target"))); err != nil { 6629 6457 return err 6630 6458 } 6631 - if _, err := cw.WriteString(string("createdAt")); err != nil { 6459 + if _, err := cw.WriteString(string("target")); err != nil { 6632 6460 return err 6633 6461 } 6634 6462 6635 - if len(t.CreatedAt) > 1000000 { 6636 - return xerrors.Errorf("Value in field t.CreatedAt was too long") 6637 - } 6638 - 6639 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil { 6640 - return err 6641 - } 6642 - if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { 6463 + if err := t.Target.MarshalCBOR(cw); err != nil { 6643 6464 return err 6644 6465 } 6645 6466 6646 - // t.TargetRepo (string) (string) 6647 - if len("targetRepo") > 1000000 { 6648 - return xerrors.Errorf("Value in field \"targetRepo\" was too long") 6649 - } 6650 - 6651 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("targetRepo"))); err != nil { 6652 - return err 6653 - } 6654 - if _, err := cw.WriteString(string("targetRepo")); err != nil { 6655 - return err 6656 - } 6657 - 6658 - if len(t.TargetRepo) > 1000000 { 6659 - return xerrors.Errorf("Value in field t.TargetRepo was too long") 6660 - } 6661 - 6662 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.TargetRepo))); err != nil { 6663 - return err 6664 - } 6665 - if _, err := cw.WriteString(string(t.TargetRepo)); err != nil { 6666 - return err 6467 + // t.CreatedAt (string) (string) 6468 + if len("createdAt") > 1000000 { 6469 + return xerrors.Errorf("Value in field \"createdAt\" was too long") 6667 6470 } 6668 6471 6669 - // t.TargetBranch (string) (string) 6670 - if len("targetBranch") > 1000000 { 6671 - return xerrors.Errorf("Value in field \"targetBranch\" was too long") 6672 - } 6673 - 6674 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("targetBranch"))); err != nil { 6472 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil { 6675 6473 return err 6676 6474 } 6677 - if _, err := cw.WriteString(string("targetBranch")); err != nil { 6475 + if _, err := cw.WriteString(string("createdAt")); err != nil { 6678 6476 return err 6679 6477 } 6680 6478 6681 - if len(t.TargetBranch) > 1000000 { 6682 - return xerrors.Errorf("Value in field t.TargetBranch was too long") 6479 + if len(t.CreatedAt) > 1000000 { 6480 + return xerrors.Errorf("Value in field t.CreatedAt was too long") 6683 6481 } 6684 6482 6685 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.TargetBranch))); err != nil { 6483 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil { 6686 6484 return err 6687 6485 } 6688 - if _, err := cw.WriteString(string(t.TargetBranch)); err != nil { 6486 + if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { 6689 6487 return err 6690 6488 } 6691 6489 return nil ··· 6716 6514 6717 6515 n := extra 6718 6516 6719 - nameBuf := make([]byte, 12) 6517 + nameBuf := make([]byte, 9) 6720 6518 for i := uint64(0); i < n; i++ { 6721 6519 nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 6722 6520 if err != nil { ··· 6786 6584 6787 6585 t.Title = string(sval) 6788 6586 } 6789 - // t.PullId (int64) (int64) 6790 - case "pullId": 6791 - { 6792 - maj, extra, err := cr.ReadHeader() 6793 - if err != nil { 6794 - return err 6795 - } 6796 - var extraI int64 6797 - switch maj { 6798 - case cbg.MajUnsignedInt: 6799 - extraI = int64(extra) 6800 - if extraI < 0 { 6801 - return fmt.Errorf("int64 positive overflow") 6802 - } 6803 - case cbg.MajNegativeInt: 6804 - extraI = int64(extra) 6805 - if extraI < 0 { 6806 - return fmt.Errorf("int64 negative overflow") 6807 - } 6808 - extraI = -1 - extraI 6809 - default: 6810 - return fmt.Errorf("wrong type for int64 field: %d", maj) 6811 - } 6812 - 6813 - t.PullId = int64(extraI) 6814 - } 6815 6587 // t.Source (tangled.RepoPull_Source) (struct) 6816 6588 case "source": 6817 6589 ··· 6832 6604 } 6833 6605 6834 6606 } 6835 - // t.CreatedAt (string) (string) 6836 - case "createdAt": 6607 + // t.Target (tangled.RepoPull_Target) (struct) 6608 + case "target": 6837 6609 6838 6610 { 6839 - sval, err := cbg.ReadStringWithMax(cr, 1000000) 6611 + 6612 + b, err := cr.ReadByte() 6840 6613 if err != nil { 6841 6614 return err 6842 6615 } 6843 - 6844 - t.CreatedAt = string(sval) 6845 - } 6846 - // t.TargetRepo (string) (string) 6847 - case "targetRepo": 6848 - 6849 - { 6850 - sval, err := cbg.ReadStringWithMax(cr, 1000000) 6851 - if err != nil { 6852 - return err 6616 + if b != cbg.CborNull[0] { 6617 + if err := cr.UnreadByte(); err != nil { 6618 + return err 6619 + } 6620 + t.Target = new(RepoPull_Target) 6621 + if err := t.Target.UnmarshalCBOR(cr); err != nil { 6622 + return xerrors.Errorf("unmarshaling t.Target pointer: %w", err) 6623 + } 6853 6624 } 6854 6625 6855 - t.TargetRepo = string(sval) 6856 6626 } 6857 - // t.TargetBranch (string) (string) 6858 - case "targetBranch": 6627 + // t.CreatedAt (string) (string) 6628 + case "createdAt": 6859 6629 6860 6630 { 6861 6631 sval, err := cbg.ReadStringWithMax(cr, 1000000) ··· 6863 6633 return err 6864 6634 } 6865 6635 6866 - t.TargetBranch = string(sval) 6636 + t.CreatedAt = string(sval) 6867 6637 } 6868 6638 6869 6639 default: ··· 6883 6653 } 6884 6654 6885 6655 cw := cbg.NewCborWriter(w) 6886 - fieldCount := 7 6887 6656 6888 - if t.CommentId == nil { 6889 - fieldCount-- 6890 - } 6891 - 6892 - if t.Owner == nil { 6893 - fieldCount-- 6894 - } 6895 - 6896 - if t.Repo == nil { 6897 - fieldCount-- 6898 - } 6899 - 6900 - if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { 6657 + if _, err := cw.Write([]byte{164}); err != nil { 6901 6658 return err 6902 6659 } 6903 6660 ··· 6947 6704 return err 6948 6705 } 6949 6706 6950 - // t.Repo (string) (string) 6951 - if t.Repo != nil { 6952 - 6953 - if len("repo") > 1000000 { 6954 - return xerrors.Errorf("Value in field \"repo\" was too long") 6955 - } 6956 - 6957 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("repo"))); err != nil { 6958 - return err 6959 - } 6960 - if _, err := cw.WriteString(string("repo")); err != nil { 6961 - return err 6962 - } 6963 - 6964 - if t.Repo == nil { 6965 - if _, err := cw.Write(cbg.CborNull); err != nil { 6966 - return err 6967 - } 6968 - } else { 6969 - if len(*t.Repo) > 1000000 { 6970 - return xerrors.Errorf("Value in field t.Repo was too long") 6971 - } 6972 - 6973 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Repo))); err != nil { 6974 - return err 6975 - } 6976 - if _, err := cw.WriteString(string(*t.Repo)); err != nil { 6977 - return err 6978 - } 6979 - } 6980 - } 6981 - 6982 6707 // t.LexiconTypeID (string) (string) 6983 6708 if len("$type") > 1000000 { 6984 6709 return xerrors.Errorf("Value in field \"$type\" was too long") ··· 6998 6723 return err 6999 6724 } 7000 6725 7001 - // t.Owner (string) (string) 7002 - if t.Owner != nil { 7003 - 7004 - if len("owner") > 1000000 { 7005 - return xerrors.Errorf("Value in field \"owner\" was too long") 7006 - } 7007 - 7008 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("owner"))); err != nil { 7009 - return err 7010 - } 7011 - if _, err := cw.WriteString(string("owner")); err != nil { 7012 - return err 7013 - } 7014 - 7015 - if t.Owner == nil { 7016 - if _, err := cw.Write(cbg.CborNull); err != nil { 7017 - return err 7018 - } 7019 - } else { 7020 - if len(*t.Owner) > 1000000 { 7021 - return xerrors.Errorf("Value in field t.Owner was too long") 7022 - } 7023 - 7024 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Owner))); err != nil { 7025 - return err 7026 - } 7027 - if _, err := cw.WriteString(string(*t.Owner)); err != nil { 7028 - return err 7029 - } 7030 - } 7031 - } 7032 - 7033 - // t.CommentId (int64) (int64) 7034 - if t.CommentId != nil { 7035 - 7036 - if len("commentId") > 1000000 { 7037 - return xerrors.Errorf("Value in field \"commentId\" was too long") 7038 - } 7039 - 7040 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("commentId"))); err != nil { 7041 - return err 7042 - } 7043 - if _, err := cw.WriteString(string("commentId")); err != nil { 7044 - return err 7045 - } 7046 - 7047 - if t.CommentId == nil { 7048 - if _, err := cw.Write(cbg.CborNull); err != nil { 7049 - return err 7050 - } 7051 - } else { 7052 - if *t.CommentId >= 0 { 7053 - if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(*t.CommentId)); err != nil { 7054 - return err 7055 - } 7056 - } else { 7057 - if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-*t.CommentId-1)); err != nil { 7058 - return err 7059 - } 7060 - } 7061 - } 7062 - 7063 - } 7064 - 7065 6726 // t.CreatedAt (string) (string) 7066 6727 if len("createdAt") > 1000000 { 7067 6728 return xerrors.Errorf("Value in field \"createdAt\" was too long") ··· 7150 6811 7151 6812 t.Pull = string(sval) 7152 6813 } 7153 - // t.Repo (string) (string) 7154 - case "repo": 7155 - 7156 - { 7157 - b, err := cr.ReadByte() 7158 - if err != nil { 7159 - return err 7160 - } 7161 - if b != cbg.CborNull[0] { 7162 - if err := cr.UnreadByte(); err != nil { 7163 - return err 7164 - } 7165 - 7166 - sval, err := cbg.ReadStringWithMax(cr, 1000000) 7167 - if err != nil { 7168 - return err 7169 - } 7170 - 7171 - t.Repo = (*string)(&sval) 7172 - } 7173 - } 7174 6814 // t.LexiconTypeID (string) (string) 7175 6815 case "$type": 7176 6816 ··· 7182 6822 7183 6823 t.LexiconTypeID = string(sval) 7184 6824 } 7185 - // t.Owner (string) (string) 7186 - case "owner": 7187 - 7188 - { 7189 - b, err := cr.ReadByte() 7190 - if err != nil { 7191 - return err 7192 - } 7193 - if b != cbg.CborNull[0] { 7194 - if err := cr.UnreadByte(); err != nil { 7195 - return err 7196 - } 7197 - 7198 - sval, err := cbg.ReadStringWithMax(cr, 1000000) 7199 - if err != nil { 7200 - return err 7201 - } 7202 - 7203 - t.Owner = (*string)(&sval) 7204 - } 7205 - } 7206 - // t.CommentId (int64) (int64) 7207 - case "commentId": 7208 - { 7209 - 7210 - b, err := cr.ReadByte() 7211 - if err != nil { 7212 - return err 7213 - } 7214 - if b != cbg.CborNull[0] { 7215 - if err := cr.UnreadByte(); err != nil { 7216 - return err 7217 - } 7218 - maj, extra, err := cr.ReadHeader() 7219 - if err != nil { 7220 - return err 7221 - } 7222 - var extraI int64 7223 - switch maj { 7224 - case cbg.MajUnsignedInt: 7225 - extraI = int64(extra) 7226 - if extraI < 0 { 7227 - return fmt.Errorf("int64 positive overflow") 7228 - } 7229 - case cbg.MajNegativeInt: 7230 - extraI = int64(extra) 7231 - if extraI < 0 { 7232 - return fmt.Errorf("int64 negative overflow") 7233 - } 7234 - extraI = -1 - extraI 7235 - default: 7236 - return fmt.Errorf("wrong type for int64 field: %d", maj) 7237 - } 7238 - 7239 - t.CommentId = (*int64)(&extraI) 7240 - } 7241 - } 7242 6825 // t.CreatedAt (string) (string) 7243 6826 case "createdAt": 7244 6827 ··· 7268 6851 } 7269 6852 7270 6853 cw := cbg.NewCborWriter(w) 7271 - fieldCount := 2 6854 + fieldCount := 3 7272 6855 7273 6856 if t.Repo == nil { 7274 6857 fieldCount-- 7275 6858 } 7276 6859 7277 6860 if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { 6861 + return err 6862 + } 6863 + 6864 + // t.Sha (string) (string) 6865 + if len("sha") > 1000000 { 6866 + return xerrors.Errorf("Value in field \"sha\" was too long") 6867 + } 6868 + 6869 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sha"))); err != nil { 6870 + return err 6871 + } 6872 + if _, err := cw.WriteString(string("sha")); err != nil { 6873 + return err 6874 + } 6875 + 6876 + if len(t.Sha) > 1000000 { 6877 + return xerrors.Errorf("Value in field t.Sha was too long") 6878 + } 6879 + 6880 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Sha))); err != nil { 6881 + return err 6882 + } 6883 + if _, err := cw.WriteString(string(t.Sha)); err != nil { 7278 6884 return err 7279 6885 } 7280 6886 ··· 7376 6982 } 7377 6983 7378 6984 switch string(nameBuf[:nameLen]) { 7379 - // t.Repo (string) (string) 6985 + // t.Sha (string) (string) 6986 + case "sha": 6987 + 6988 + { 6989 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 6990 + if err != nil { 6991 + return err 6992 + } 6993 + 6994 + t.Sha = string(sval) 6995 + } 6996 + // t.Repo (string) (string) 7380 6997 case "repo": 7381 6998 7382 6999 { ··· 7583 7200 7584 7201 return nil 7585 7202 } 7203 + func (t *RepoPull_Target) MarshalCBOR(w io.Writer) error { 7204 + if t == nil { 7205 + _, err := w.Write(cbg.CborNull) 7206 + return err 7207 + } 7208 + 7209 + cw := cbg.NewCborWriter(w) 7210 + 7211 + if _, err := cw.Write([]byte{162}); err != nil { 7212 + return err 7213 + } 7214 + 7215 + // t.Repo (string) (string) 7216 + if len("repo") > 1000000 { 7217 + return xerrors.Errorf("Value in field \"repo\" was too long") 7218 + } 7219 + 7220 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("repo"))); err != nil { 7221 + return err 7222 + } 7223 + if _, err := cw.WriteString(string("repo")); err != nil { 7224 + return err 7225 + } 7226 + 7227 + if len(t.Repo) > 1000000 { 7228 + return xerrors.Errorf("Value in field t.Repo was too long") 7229 + } 7230 + 7231 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Repo))); err != nil { 7232 + return err 7233 + } 7234 + if _, err := cw.WriteString(string(t.Repo)); err != nil { 7235 + return err 7236 + } 7237 + 7238 + // t.Branch (string) (string) 7239 + if len("branch") > 1000000 { 7240 + return xerrors.Errorf("Value in field \"branch\" was too long") 7241 + } 7242 + 7243 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("branch"))); err != nil { 7244 + return err 7245 + } 7246 + if _, err := cw.WriteString(string("branch")); err != nil { 7247 + return err 7248 + } 7249 + 7250 + if len(t.Branch) > 1000000 { 7251 + return xerrors.Errorf("Value in field t.Branch was too long") 7252 + } 7253 + 7254 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Branch))); err != nil { 7255 + return err 7256 + } 7257 + if _, err := cw.WriteString(string(t.Branch)); err != nil { 7258 + return err 7259 + } 7260 + return nil 7261 + } 7262 + 7263 + func (t *RepoPull_Target) UnmarshalCBOR(r io.Reader) (err error) { 7264 + *t = RepoPull_Target{} 7265 + 7266 + cr := cbg.NewCborReader(r) 7267 + 7268 + maj, extra, err := cr.ReadHeader() 7269 + if err != nil { 7270 + return err 7271 + } 7272 + defer func() { 7273 + if err == io.EOF { 7274 + err = io.ErrUnexpectedEOF 7275 + } 7276 + }() 7277 + 7278 + if maj != cbg.MajMap { 7279 + return fmt.Errorf("cbor input should be of type map") 7280 + } 7281 + 7282 + if extra > cbg.MaxLength { 7283 + return fmt.Errorf("RepoPull_Target: map struct too large (%d)", extra) 7284 + } 7285 + 7286 + n := extra 7287 + 7288 + nameBuf := make([]byte, 6) 7289 + for i := uint64(0); i < n; i++ { 7290 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 7291 + if err != nil { 7292 + return err 7293 + } 7294 + 7295 + if !ok { 7296 + // Field doesn't exist on this type, so ignore it 7297 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 7298 + return err 7299 + } 7300 + continue 7301 + } 7302 + 7303 + switch string(nameBuf[:nameLen]) { 7304 + // t.Repo (string) (string) 7305 + case "repo": 7306 + 7307 + { 7308 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 7309 + if err != nil { 7310 + return err 7311 + } 7312 + 7313 + t.Repo = string(sval) 7314 + } 7315 + // t.Branch (string) (string) 7316 + case "branch": 7317 + 7318 + { 7319 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 7320 + if err != nil { 7321 + return err 7322 + } 7323 + 7324 + t.Branch = string(sval) 7325 + } 7326 + 7327 + default: 7328 + // Field doesn't exist on this type, so ignore it 7329 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 7330 + return err 7331 + } 7332 + } 7333 + } 7334 + 7335 + return nil 7336 + } 7586 7337 func (t *Spindle) MarshalCBOR(w io.Writer) error { 7587 7338 if t == nil { 7588 7339 _, err := w.Write(cbg.CborNull) ··· 7911 7662 7912 7663 return nil 7913 7664 } 7665 + func (t *String) MarshalCBOR(w io.Writer) error { 7666 + if t == nil { 7667 + _, err := w.Write(cbg.CborNull) 7668 + return err 7669 + } 7670 + 7671 + cw := cbg.NewCborWriter(w) 7672 + 7673 + if _, err := cw.Write([]byte{165}); err != nil { 7674 + return err 7675 + } 7676 + 7677 + // t.LexiconTypeID (string) (string) 7678 + if len("$type") > 1000000 { 7679 + return xerrors.Errorf("Value in field \"$type\" was too long") 7680 + } 7681 + 7682 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil { 7683 + return err 7684 + } 7685 + if _, err := cw.WriteString(string("$type")); err != nil { 7686 + return err 7687 + } 7688 + 7689 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sh.tangled.string"))); err != nil { 7690 + return err 7691 + } 7692 + if _, err := cw.WriteString(string("sh.tangled.string")); err != nil { 7693 + return err 7694 + } 7695 + 7696 + // t.Contents (string) (string) 7697 + if len("contents") > 1000000 { 7698 + return xerrors.Errorf("Value in field \"contents\" was too long") 7699 + } 7700 + 7701 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("contents"))); err != nil { 7702 + return err 7703 + } 7704 + if _, err := cw.WriteString(string("contents")); err != nil { 7705 + return err 7706 + } 7707 + 7708 + if len(t.Contents) > 1000000 { 7709 + return xerrors.Errorf("Value in field t.Contents was too long") 7710 + } 7711 + 7712 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Contents))); err != nil { 7713 + return err 7714 + } 7715 + if _, err := cw.WriteString(string(t.Contents)); err != nil { 7716 + return err 7717 + } 7718 + 7719 + // t.Filename (string) (string) 7720 + if len("filename") > 1000000 { 7721 + return xerrors.Errorf("Value in field \"filename\" was too long") 7722 + } 7723 + 7724 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("filename"))); err != nil { 7725 + return err 7726 + } 7727 + if _, err := cw.WriteString(string("filename")); err != nil { 7728 + return err 7729 + } 7730 + 7731 + if len(t.Filename) > 1000000 { 7732 + return xerrors.Errorf("Value in field t.Filename was too long") 7733 + } 7734 + 7735 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Filename))); err != nil { 7736 + return err 7737 + } 7738 + if _, err := cw.WriteString(string(t.Filename)); err != nil { 7739 + return err 7740 + } 7741 + 7742 + // t.CreatedAt (string) (string) 7743 + if len("createdAt") > 1000000 { 7744 + return xerrors.Errorf("Value in field \"createdAt\" was too long") 7745 + } 7746 + 7747 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil { 7748 + return err 7749 + } 7750 + if _, err := cw.WriteString(string("createdAt")); err != nil { 7751 + return err 7752 + } 7753 + 7754 + if len(t.CreatedAt) > 1000000 { 7755 + return xerrors.Errorf("Value in field t.CreatedAt was too long") 7756 + } 7757 + 7758 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil { 7759 + return err 7760 + } 7761 + if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { 7762 + return err 7763 + } 7764 + 7765 + // t.Description (string) (string) 7766 + if len("description") > 1000000 { 7767 + return xerrors.Errorf("Value in field \"description\" was too long") 7768 + } 7769 + 7770 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("description"))); err != nil { 7771 + return err 7772 + } 7773 + if _, err := cw.WriteString(string("description")); err != nil { 7774 + return err 7775 + } 7776 + 7777 + if len(t.Description) > 1000000 { 7778 + return xerrors.Errorf("Value in field t.Description was too long") 7779 + } 7780 + 7781 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Description))); err != nil { 7782 + return err 7783 + } 7784 + if _, err := cw.WriteString(string(t.Description)); err != nil { 7785 + return err 7786 + } 7787 + return nil 7788 + } 7789 + 7790 + func (t *String) UnmarshalCBOR(r io.Reader) (err error) { 7791 + *t = String{} 7792 + 7793 + cr := cbg.NewCborReader(r) 7794 + 7795 + maj, extra, err := cr.ReadHeader() 7796 + if err != nil { 7797 + return err 7798 + } 7799 + defer func() { 7800 + if err == io.EOF { 7801 + err = io.ErrUnexpectedEOF 7802 + } 7803 + }() 7804 + 7805 + if maj != cbg.MajMap { 7806 + return fmt.Errorf("cbor input should be of type map") 7807 + } 7808 + 7809 + if extra > cbg.MaxLength { 7810 + return fmt.Errorf("String: map struct too large (%d)", extra) 7811 + } 7812 + 7813 + n := extra 7814 + 7815 + nameBuf := make([]byte, 11) 7816 + for i := uint64(0); i < n; i++ { 7817 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 7818 + if err != nil { 7819 + return err 7820 + } 7821 + 7822 + if !ok { 7823 + // Field doesn't exist on this type, so ignore it 7824 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 7825 + return err 7826 + } 7827 + continue 7828 + } 7829 + 7830 + switch string(nameBuf[:nameLen]) { 7831 + // t.LexiconTypeID (string) (string) 7832 + case "$type": 7833 + 7834 + { 7835 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 7836 + if err != nil { 7837 + return err 7838 + } 7839 + 7840 + t.LexiconTypeID = string(sval) 7841 + } 7842 + // t.Contents (string) (string) 7843 + case "contents": 7844 + 7845 + { 7846 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 7847 + if err != nil { 7848 + return err 7849 + } 7850 + 7851 + t.Contents = string(sval) 7852 + } 7853 + // t.Filename (string) (string) 7854 + case "filename": 7855 + 7856 + { 7857 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 7858 + if err != nil { 7859 + return err 7860 + } 7861 + 7862 + t.Filename = string(sval) 7863 + } 7864 + // t.CreatedAt (string) (string) 7865 + case "createdAt": 7866 + 7867 + { 7868 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 7869 + if err != nil { 7870 + return err 7871 + } 7872 + 7873 + t.CreatedAt = string(sval) 7874 + } 7875 + // t.Description (string) (string) 7876 + case "description": 7877 + 7878 + { 7879 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 7880 + if err != nil { 7881 + return err 7882 + } 7883 + 7884 + t.Description = string(sval) 7885 + } 7886 + 7887 + default: 7888 + // Field doesn't exist on this type, so ignore it 7889 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 7890 + return err 7891 + } 7892 + } 7893 + } 7894 + 7895 + return nil 7896 + }
+24
api/tangled/feedreaction.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.feed.reaction 6 + 7 + import ( 8 + "github.com/bluesky-social/indigo/lex/util" 9 + ) 10 + 11 + const ( 12 + FeedReactionNSID = "sh.tangled.feed.reaction" 13 + ) 14 + 15 + func init() { 16 + util.RegisterType("sh.tangled.feed.reaction", &FeedReaction{}) 17 + } // 18 + // RECORDTYPE: FeedReaction 19 + type FeedReaction struct { 20 + LexiconTypeID string `json:"$type,const=sh.tangled.feed.reaction" cborgen:"$type,const=sh.tangled.feed.reaction"` 21 + CreatedAt string `json:"createdAt" cborgen:"createdAt"` 22 + Reaction string `json:"reaction" cborgen:"reaction"` 23 + Subject string `json:"subject" cborgen:"subject"` 24 + }
+23 -8
api/tangled/gitrefUpdate.go
··· 33 33 RepoName string `json:"repoName" cborgen:"repoName"` 34 34 } 35 35 36 - type GitRefUpdate_Meta struct { 37 - CommitCount *GitRefUpdate_Meta_CommitCount `json:"commitCount" cborgen:"commitCount"` 38 - IsDefaultRef bool `json:"isDefaultRef" cborgen:"isDefaultRef"` 36 + // GitRefUpdate_CommitCountBreakdown is a "commitCountBreakdown" in the sh.tangled.git.refUpdate schema. 37 + type GitRefUpdate_CommitCountBreakdown struct { 38 + ByEmail []*GitRefUpdate_IndividualEmailCommitCount `json:"byEmail,omitempty" cborgen:"byEmail,omitempty"` 39 + } 40 + 41 + // GitRefUpdate_IndividualEmailCommitCount is a "individualEmailCommitCount" in the sh.tangled.git.refUpdate schema. 42 + type GitRefUpdate_IndividualEmailCommitCount struct { 43 + Count int64 `json:"count" cborgen:"count"` 44 + Email string `json:"email" cborgen:"email"` 45 + } 46 + 47 + // GitRefUpdate_IndividualLanguageSize is a "individualLanguageSize" in the sh.tangled.git.refUpdate schema. 48 + type GitRefUpdate_IndividualLanguageSize struct { 49 + Lang string `json:"lang" cborgen:"lang"` 50 + Size int64 `json:"size" cborgen:"size"` 39 51 } 40 52 41 - type GitRefUpdate_Meta_CommitCount struct { 42 - ByEmail []*GitRefUpdate_Meta_CommitCount_ByEmail_Elem `json:"byEmail,omitempty" cborgen:"byEmail,omitempty"` 53 + // GitRefUpdate_LangBreakdown is a "langBreakdown" in the sh.tangled.git.refUpdate schema. 54 + type GitRefUpdate_LangBreakdown struct { 55 + Inputs []*GitRefUpdate_IndividualLanguageSize `json:"inputs,omitempty" cborgen:"inputs,omitempty"` 43 56 } 44 57 45 - type GitRefUpdate_Meta_CommitCount_ByEmail_Elem struct { 46 - Count int64 `json:"count" cborgen:"count"` 47 - Email string `json:"email" cborgen:"email"` 58 + // GitRefUpdate_Meta is a "meta" in the sh.tangled.git.refUpdate schema. 59 + type GitRefUpdate_Meta struct { 60 + CommitCount *GitRefUpdate_CommitCountBreakdown `json:"commitCount" cborgen:"commitCount"` 61 + IsDefaultRef bool `json:"isDefaultRef" cborgen:"isDefaultRef"` 62 + LangBreakdown *GitRefUpdate_LangBreakdown `json:"langBreakdown,omitempty" cborgen:"langBreakdown,omitempty"` 48 63 }
+1 -3
api/tangled/issuecomment.go
··· 19 19 type RepoIssueComment struct { 20 20 LexiconTypeID string `json:"$type,const=sh.tangled.repo.issue.comment" cborgen:"$type,const=sh.tangled.repo.issue.comment"` 21 21 Body string `json:"body" cborgen:"body"` 22 - CommentId *int64 `json:"commentId,omitempty" cborgen:"commentId,omitempty"` 23 22 CreatedAt string `json:"createdAt" cborgen:"createdAt"` 24 23 Issue string `json:"issue" cborgen:"issue"` 25 - Owner *string `json:"owner,omitempty" cborgen:"owner,omitempty"` 26 - Repo *string `json:"repo,omitempty" cborgen:"repo,omitempty"` 24 + ReplyTo *string `json:"replyTo,omitempty" cborgen:"replyTo,omitempty"` 27 25 }
+53
api/tangled/knotlistKeys.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.knot.listKeys 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + KnotListKeysNSID = "sh.tangled.knot.listKeys" 15 + ) 16 + 17 + // KnotListKeys_Output is the output of a sh.tangled.knot.listKeys call. 18 + type KnotListKeys_Output struct { 19 + // cursor: Pagination cursor for next page 20 + Cursor *string `json:"cursor,omitempty" cborgen:"cursor,omitempty"` 21 + Keys []*KnotListKeys_PublicKey `json:"keys" cborgen:"keys"` 22 + } 23 + 24 + // KnotListKeys_PublicKey is a "publicKey" in the sh.tangled.knot.listKeys schema. 25 + type KnotListKeys_PublicKey struct { 26 + // createdAt: Key upload timestamp 27 + CreatedAt string `json:"createdAt" cborgen:"createdAt"` 28 + // did: DID associated with the public key 29 + Did string `json:"did" cborgen:"did"` 30 + // key: Public key contents 31 + Key string `json:"key" cborgen:"key"` 32 + } 33 + 34 + // KnotListKeys calls the XRPC method "sh.tangled.knot.listKeys". 35 + // 36 + // cursor: Pagination cursor 37 + // limit: Maximum number of keys to return 38 + func KnotListKeys(ctx context.Context, c util.LexClient, cursor string, limit int64) (*KnotListKeys_Output, error) { 39 + var out KnotListKeys_Output 40 + 41 + params := map[string]interface{}{} 42 + if cursor != "" { 43 + params["cursor"] = cursor 44 + } 45 + if limit != 0 { 46 + params["limit"] = limit 47 + } 48 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.knot.listKeys", params, nil, &out); err != nil { 49 + return nil, err 50 + } 51 + 52 + return &out, nil 53 + }
+30
api/tangled/knotversion.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.knot.version 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + KnotVersionNSID = "sh.tangled.knot.version" 15 + ) 16 + 17 + // KnotVersion_Output is the output of a sh.tangled.knot.version call. 18 + type KnotVersion_Output struct { 19 + Version string `json:"version" cborgen:"version"` 20 + } 21 + 22 + // KnotVersion calls the XRPC method "sh.tangled.knot.version". 23 + func KnotVersion(ctx context.Context, c util.LexClient) (*KnotVersion_Output, error) { 24 + var out KnotVersion_Output 25 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.knot.version", nil, nil, &out); err != nil { 26 + return nil, err 27 + } 28 + 29 + return &out, nil 30 + }
+4 -7
api/tangled/pullcomment.go
··· 17 17 } // 18 18 // RECORDTYPE: RepoPullComment 19 19 type RepoPullComment struct { 20 - LexiconTypeID string `json:"$type,const=sh.tangled.repo.pull.comment" cborgen:"$type,const=sh.tangled.repo.pull.comment"` 21 - Body string `json:"body" cborgen:"body"` 22 - CommentId *int64 `json:"commentId,omitempty" cborgen:"commentId,omitempty"` 23 - CreatedAt string `json:"createdAt" cborgen:"createdAt"` 24 - Owner *string `json:"owner,omitempty" cborgen:"owner,omitempty"` 25 - Pull string `json:"pull" cborgen:"pull"` 26 - Repo *string `json:"repo,omitempty" cborgen:"repo,omitempty"` 20 + LexiconTypeID string `json:"$type,const=sh.tangled.repo.pull.comment" cborgen:"$type,const=sh.tangled.repo.pull.comment"` 21 + Body string `json:"body" cborgen:"body"` 22 + CreatedAt string `json:"createdAt" cborgen:"createdAt"` 23 + Pull string `json:"pull" cborgen:"pull"` 27 24 }
+31
api/tangled/repoaddSecret.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.addSecret 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoAddSecretNSID = "sh.tangled.repo.addSecret" 15 + ) 16 + 17 + // RepoAddSecret_Input is the input argument to a sh.tangled.repo.addSecret call. 18 + type RepoAddSecret_Input struct { 19 + Key string `json:"key" cborgen:"key"` 20 + Repo string `json:"repo" cborgen:"repo"` 21 + Value string `json:"value" cborgen:"value"` 22 + } 23 + 24 + // RepoAddSecret calls the XRPC method "sh.tangled.repo.addSecret". 25 + func RepoAddSecret(ctx context.Context, c util.LexClient, input *RepoAddSecret_Input) error { 26 + if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.addSecret", nil, input, nil); err != nil { 27 + return err 28 + } 29 + 30 + return nil 31 + }
+41
api/tangled/repoarchive.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.archive 6 + 7 + import ( 8 + "bytes" 9 + "context" 10 + 11 + "github.com/bluesky-social/indigo/lex/util" 12 + ) 13 + 14 + const ( 15 + RepoArchiveNSID = "sh.tangled.repo.archive" 16 + ) 17 + 18 + // RepoArchive calls the XRPC method "sh.tangled.repo.archive". 19 + // 20 + // format: Archive format 21 + // prefix: Prefix for files in the archive 22 + // ref: Git reference (branch, tag, or commit SHA) 23 + // repo: Repository identifier in format 'did:plc:.../repoName' 24 + func RepoArchive(ctx context.Context, c util.LexClient, format string, prefix string, ref string, repo string) ([]byte, error) { 25 + buf := new(bytes.Buffer) 26 + 27 + params := map[string]interface{}{} 28 + if format != "" { 29 + params["format"] = format 30 + } 31 + if prefix != "" { 32 + params["prefix"] = prefix 33 + } 34 + params["ref"] = ref 35 + params["repo"] = repo 36 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.archive", params, nil, buf); err != nil { 37 + return nil, err 38 + } 39 + 40 + return buf.Bytes(), nil 41 + }
+80
api/tangled/repoblob.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.blob 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoBlobNSID = "sh.tangled.repo.blob" 15 + ) 16 + 17 + // RepoBlob_LastCommit is a "lastCommit" in the sh.tangled.repo.blob schema. 18 + type RepoBlob_LastCommit struct { 19 + Author *RepoBlob_Signature `json:"author,omitempty" cborgen:"author,omitempty"` 20 + // hash: Commit hash 21 + Hash string `json:"hash" cborgen:"hash"` 22 + // message: Commit message 23 + Message string `json:"message" cborgen:"message"` 24 + // shortHash: Short commit hash 25 + ShortHash *string `json:"shortHash,omitempty" cborgen:"shortHash,omitempty"` 26 + // when: Commit timestamp 27 + When string `json:"when" cborgen:"when"` 28 + } 29 + 30 + // RepoBlob_Output is the output of a sh.tangled.repo.blob call. 31 + type RepoBlob_Output struct { 32 + // content: File content (base64 encoded for binary files) 33 + Content string `json:"content" cborgen:"content"` 34 + // encoding: Content encoding 35 + Encoding *string `json:"encoding,omitempty" cborgen:"encoding,omitempty"` 36 + // isBinary: Whether the file is binary 37 + IsBinary *bool `json:"isBinary,omitempty" cborgen:"isBinary,omitempty"` 38 + LastCommit *RepoBlob_LastCommit `json:"lastCommit,omitempty" cborgen:"lastCommit,omitempty"` 39 + // mimeType: MIME type of the file 40 + MimeType *string `json:"mimeType,omitempty" cborgen:"mimeType,omitempty"` 41 + // path: The file path 42 + Path string `json:"path" cborgen:"path"` 43 + // ref: The git reference used 44 + Ref string `json:"ref" cborgen:"ref"` 45 + // size: File size in bytes 46 + Size *int64 `json:"size,omitempty" cborgen:"size,omitempty"` 47 + } 48 + 49 + // RepoBlob_Signature is a "signature" in the sh.tangled.repo.blob schema. 50 + type RepoBlob_Signature struct { 51 + // email: Author email 52 + Email string `json:"email" cborgen:"email"` 53 + // name: Author name 54 + Name string `json:"name" cborgen:"name"` 55 + // when: Author timestamp 56 + When string `json:"when" cborgen:"when"` 57 + } 58 + 59 + // RepoBlob calls the XRPC method "sh.tangled.repo.blob". 60 + // 61 + // path: Path to the file within the repository 62 + // raw: Return raw file content instead of JSON response 63 + // ref: Git reference (branch, tag, or commit SHA) 64 + // repo: Repository identifier in format 'did:plc:.../repoName' 65 + func RepoBlob(ctx context.Context, c util.LexClient, path string, raw bool, ref string, repo string) (*RepoBlob_Output, error) { 66 + var out RepoBlob_Output 67 + 68 + params := map[string]interface{}{} 69 + params["path"] = path 70 + if raw { 71 + params["raw"] = raw 72 + } 73 + params["ref"] = ref 74 + params["repo"] = repo 75 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.blob", params, nil, &out); err != nil { 76 + return nil, err 77 + } 78 + 79 + return &out, nil 80 + }
+59
api/tangled/repobranch.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.branch 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoBranchNSID = "sh.tangled.repo.branch" 15 + ) 16 + 17 + // RepoBranch_Output is the output of a sh.tangled.repo.branch call. 18 + type RepoBranch_Output struct { 19 + Author *RepoBranch_Signature `json:"author,omitempty" cborgen:"author,omitempty"` 20 + // hash: Latest commit hash on this branch 21 + Hash string `json:"hash" cborgen:"hash"` 22 + // isDefault: Whether this is the default branch 23 + IsDefault *bool `json:"isDefault,omitempty" cborgen:"isDefault,omitempty"` 24 + // message: Latest commit message 25 + Message *string `json:"message,omitempty" cborgen:"message,omitempty"` 26 + // name: Branch name 27 + Name string `json:"name" cborgen:"name"` 28 + // shortHash: Short commit hash 29 + ShortHash *string `json:"shortHash,omitempty" cborgen:"shortHash,omitempty"` 30 + // when: Timestamp of latest commit 31 + When string `json:"when" cborgen:"when"` 32 + } 33 + 34 + // RepoBranch_Signature is a "signature" in the sh.tangled.repo.branch schema. 35 + type RepoBranch_Signature struct { 36 + // email: Author email 37 + Email string `json:"email" cborgen:"email"` 38 + // name: Author name 39 + Name string `json:"name" cborgen:"name"` 40 + // when: Author timestamp 41 + When string `json:"when" cborgen:"when"` 42 + } 43 + 44 + // RepoBranch calls the XRPC method "sh.tangled.repo.branch". 45 + // 46 + // name: Branch name to get information for 47 + // repo: Repository identifier in format 'did:plc:.../repoName' 48 + func RepoBranch(ctx context.Context, c util.LexClient, name string, repo string) (*RepoBranch_Output, error) { 49 + var out RepoBranch_Output 50 + 51 + params := map[string]interface{}{} 52 + params["name"] = name 53 + params["repo"] = repo 54 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.branch", params, nil, &out); err != nil { 55 + return nil, err 56 + } 57 + 58 + return &out, nil 59 + }
+39
api/tangled/repobranches.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.branches 6 + 7 + import ( 8 + "bytes" 9 + "context" 10 + 11 + "github.com/bluesky-social/indigo/lex/util" 12 + ) 13 + 14 + const ( 15 + RepoBranchesNSID = "sh.tangled.repo.branches" 16 + ) 17 + 18 + // RepoBranches calls the XRPC method "sh.tangled.repo.branches". 19 + // 20 + // cursor: Pagination cursor 21 + // limit: Maximum number of branches to return 22 + // repo: Repository identifier in format 'did:plc:.../repoName' 23 + func RepoBranches(ctx context.Context, c util.LexClient, cursor string, limit int64, repo string) ([]byte, error) { 24 + buf := new(bytes.Buffer) 25 + 26 + params := map[string]interface{}{} 27 + if cursor != "" { 28 + params["cursor"] = cursor 29 + } 30 + if limit != 0 { 31 + params["limit"] = limit 32 + } 33 + params["repo"] = repo 34 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.branches", params, nil, buf); err != nil { 35 + return nil, err 36 + } 37 + 38 + return buf.Bytes(), nil 39 + }
+25
api/tangled/repocollaborator.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.collaborator 6 + 7 + import ( 8 + "github.com/bluesky-social/indigo/lex/util" 9 + ) 10 + 11 + const ( 12 + RepoCollaboratorNSID = "sh.tangled.repo.collaborator" 13 + ) 14 + 15 + func init() { 16 + util.RegisterType("sh.tangled.repo.collaborator", &RepoCollaborator{}) 17 + } // 18 + // RECORDTYPE: RepoCollaborator 19 + type RepoCollaborator struct { 20 + LexiconTypeID string `json:"$type,const=sh.tangled.repo.collaborator" cborgen:"$type,const=sh.tangled.repo.collaborator"` 21 + CreatedAt string `json:"createdAt" cborgen:"createdAt"` 22 + // repo: repo to add this user to 23 + Repo string `json:"repo" cborgen:"repo"` 24 + Subject string `json:"subject" cborgen:"subject"` 25 + }
+35
api/tangled/repocompare.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.compare 6 + 7 + import ( 8 + "bytes" 9 + "context" 10 + 11 + "github.com/bluesky-social/indigo/lex/util" 12 + ) 13 + 14 + const ( 15 + RepoCompareNSID = "sh.tangled.repo.compare" 16 + ) 17 + 18 + // RepoCompare calls the XRPC method "sh.tangled.repo.compare". 19 + // 20 + // repo: Repository identifier in format 'did:plc:.../repoName' 21 + // rev1: First revision (commit, branch, or tag) 22 + // rev2: Second revision (commit, branch, or tag) 23 + func RepoCompare(ctx context.Context, c util.LexClient, repo string, rev1 string, rev2 string) ([]byte, error) { 24 + buf := new(bytes.Buffer) 25 + 26 + params := map[string]interface{}{} 27 + params["repo"] = repo 28 + params["rev1"] = rev1 29 + params["rev2"] = rev2 30 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.compare", params, nil, buf); err != nil { 31 + return nil, err 32 + } 33 + 34 + return buf.Bytes(), nil 35 + }
+34
api/tangled/repocreate.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.create 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoCreateNSID = "sh.tangled.repo.create" 15 + ) 16 + 17 + // RepoCreate_Input is the input argument to a sh.tangled.repo.create call. 18 + type RepoCreate_Input struct { 19 + // defaultBranch: Default branch to push to 20 + DefaultBranch *string `json:"defaultBranch,omitempty" cborgen:"defaultBranch,omitempty"` 21 + // rkey: Rkey of the repository record 22 + Rkey string `json:"rkey" cborgen:"rkey"` 23 + // source: A source URL to clone from, populate this when forking or importing a repository. 24 + Source *string `json:"source,omitempty" cborgen:"source,omitempty"` 25 + } 26 + 27 + // RepoCreate calls the XRPC method "sh.tangled.repo.create". 28 + func RepoCreate(ctx context.Context, c util.LexClient, input *RepoCreate_Input) error { 29 + if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.create", nil, input, nil); err != nil { 30 + return err 31 + } 32 + 33 + return nil 34 + }
+34
api/tangled/repodelete.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.delete 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoDeleteNSID = "sh.tangled.repo.delete" 15 + ) 16 + 17 + // RepoDelete_Input is the input argument to a sh.tangled.repo.delete call. 18 + type RepoDelete_Input struct { 19 + // did: DID of the repository owner 20 + Did string `json:"did" cborgen:"did"` 21 + // name: Name of the repository to delete 22 + Name string `json:"name" cborgen:"name"` 23 + // rkey: Rkey of the repository record 24 + Rkey string `json:"rkey" cborgen:"rkey"` 25 + } 26 + 27 + // RepoDelete calls the XRPC method "sh.tangled.repo.delete". 28 + func RepoDelete(ctx context.Context, c util.LexClient, input *RepoDelete_Input) error { 29 + if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.delete", nil, input, nil); err != nil { 30 + return err 31 + } 32 + 33 + return nil 34 + }
+33
api/tangled/repodiff.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.diff 6 + 7 + import ( 8 + "bytes" 9 + "context" 10 + 11 + "github.com/bluesky-social/indigo/lex/util" 12 + ) 13 + 14 + const ( 15 + RepoDiffNSID = "sh.tangled.repo.diff" 16 + ) 17 + 18 + // RepoDiff calls the XRPC method "sh.tangled.repo.diff". 19 + // 20 + // ref: Git reference (branch, tag, or commit SHA) 21 + // repo: Repository identifier in format 'did:plc:.../repoName' 22 + func RepoDiff(ctx context.Context, c util.LexClient, ref string, repo string) ([]byte, error) { 23 + buf := new(bytes.Buffer) 24 + 25 + params := map[string]interface{}{} 26 + params["ref"] = ref 27 + params["repo"] = repo 28 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.diff", params, nil, buf); err != nil { 29 + return nil, err 30 + } 31 + 32 + return buf.Bytes(), nil 33 + }
+45
api/tangled/repoforkStatus.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.forkStatus 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoForkStatusNSID = "sh.tangled.repo.forkStatus" 15 + ) 16 + 17 + // RepoForkStatus_Input is the input argument to a sh.tangled.repo.forkStatus call. 18 + type RepoForkStatus_Input struct { 19 + // branch: Branch to check status for 20 + Branch string `json:"branch" cborgen:"branch"` 21 + // did: DID of the fork owner 22 + Did string `json:"did" cborgen:"did"` 23 + // hiddenRef: Hidden ref to use for comparison 24 + HiddenRef string `json:"hiddenRef" cborgen:"hiddenRef"` 25 + // name: Name of the forked repository 26 + Name string `json:"name" cborgen:"name"` 27 + // source: Source repository URL 28 + Source string `json:"source" cborgen:"source"` 29 + } 30 + 31 + // RepoForkStatus_Output is the output of a sh.tangled.repo.forkStatus call. 32 + type RepoForkStatus_Output struct { 33 + // status: Fork status: 0=UpToDate, 1=FastForwardable, 2=Conflict, 3=MissingBranch 34 + Status int64 `json:"status" cborgen:"status"` 35 + } 36 + 37 + // RepoForkStatus calls the XRPC method "sh.tangled.repo.forkStatus". 38 + func RepoForkStatus(ctx context.Context, c util.LexClient, input *RepoForkStatus_Input) (*RepoForkStatus_Output, error) { 39 + var out RepoForkStatus_Output 40 + if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.forkStatus", nil, input, &out); err != nil { 41 + return nil, err 42 + } 43 + 44 + return &out, nil 45 + }
+36
api/tangled/repoforkSync.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.forkSync 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoForkSyncNSID = "sh.tangled.repo.forkSync" 15 + ) 16 + 17 + // RepoForkSync_Input is the input argument to a sh.tangled.repo.forkSync call. 18 + type RepoForkSync_Input struct { 19 + // branch: Branch to sync 20 + Branch string `json:"branch" cborgen:"branch"` 21 + // did: DID of the fork owner 22 + Did string `json:"did" cborgen:"did"` 23 + // name: Name of the forked repository 24 + Name string `json:"name" cborgen:"name"` 25 + // source: AT-URI of the source repository 26 + Source string `json:"source" cborgen:"source"` 27 + } 28 + 29 + // RepoForkSync calls the XRPC method "sh.tangled.repo.forkSync". 30 + func RepoForkSync(ctx context.Context, c util.LexClient, input *RepoForkSync_Input) error { 31 + if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.forkSync", nil, input, nil); err != nil { 32 + return err 33 + } 34 + 35 + return nil 36 + }
+55
api/tangled/repogetDefaultBranch.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.getDefaultBranch 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoGetDefaultBranchNSID = "sh.tangled.repo.getDefaultBranch" 15 + ) 16 + 17 + // RepoGetDefaultBranch_Output is the output of a sh.tangled.repo.getDefaultBranch call. 18 + type RepoGetDefaultBranch_Output struct { 19 + Author *RepoGetDefaultBranch_Signature `json:"author,omitempty" cborgen:"author,omitempty"` 20 + // hash: Latest commit hash on default branch 21 + Hash string `json:"hash" cborgen:"hash"` 22 + // message: Latest commit message 23 + Message *string `json:"message,omitempty" cborgen:"message,omitempty"` 24 + // name: Default branch name 25 + Name string `json:"name" cborgen:"name"` 26 + // shortHash: Short commit hash 27 + ShortHash *string `json:"shortHash,omitempty" cborgen:"shortHash,omitempty"` 28 + // when: Timestamp of latest commit 29 + When string `json:"when" cborgen:"when"` 30 + } 31 + 32 + // RepoGetDefaultBranch_Signature is a "signature" in the sh.tangled.repo.getDefaultBranch schema. 33 + type RepoGetDefaultBranch_Signature struct { 34 + // email: Author email 35 + Email string `json:"email" cborgen:"email"` 36 + // name: Author name 37 + Name string `json:"name" cborgen:"name"` 38 + // when: Author timestamp 39 + When string `json:"when" cborgen:"when"` 40 + } 41 + 42 + // RepoGetDefaultBranch calls the XRPC method "sh.tangled.repo.getDefaultBranch". 43 + // 44 + // repo: Repository identifier in format 'did:plc:.../repoName' 45 + func RepoGetDefaultBranch(ctx context.Context, c util.LexClient, repo string) (*RepoGetDefaultBranch_Output, error) { 46 + var out RepoGetDefaultBranch_Output 47 + 48 + params := map[string]interface{}{} 49 + params["repo"] = repo 50 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.getDefaultBranch", params, nil, &out); err != nil { 51 + return nil, err 52 + } 53 + 54 + return &out, nil 55 + }
+45
api/tangled/repohiddenRef.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.hiddenRef 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoHiddenRefNSID = "sh.tangled.repo.hiddenRef" 15 + ) 16 + 17 + // RepoHiddenRef_Input is the input argument to a sh.tangled.repo.hiddenRef call. 18 + type RepoHiddenRef_Input struct { 19 + // forkRef: Fork reference name 20 + ForkRef string `json:"forkRef" cborgen:"forkRef"` 21 + // remoteRef: Remote reference name 22 + RemoteRef string `json:"remoteRef" cborgen:"remoteRef"` 23 + // repo: AT-URI of the repository 24 + Repo string `json:"repo" cborgen:"repo"` 25 + } 26 + 27 + // RepoHiddenRef_Output is the output of a sh.tangled.repo.hiddenRef call. 28 + type RepoHiddenRef_Output struct { 29 + // error: Error message if creation failed 30 + Error *string `json:"error,omitempty" cborgen:"error,omitempty"` 31 + // ref: The created hidden ref name 32 + Ref *string `json:"ref,omitempty" cborgen:"ref,omitempty"` 33 + // success: Whether the hidden ref was created successfully 34 + Success bool `json:"success" cborgen:"success"` 35 + } 36 + 37 + // RepoHiddenRef calls the XRPC method "sh.tangled.repo.hiddenRef". 38 + func RepoHiddenRef(ctx context.Context, c util.LexClient, input *RepoHiddenRef_Input) (*RepoHiddenRef_Output, error) { 39 + var out RepoHiddenRef_Output 40 + if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.hiddenRef", nil, input, &out); err != nil { 41 + return nil, err 42 + } 43 + 44 + return &out, nil 45 + }
-2
api/tangled/repoissue.go
··· 20 20 LexiconTypeID string `json:"$type,const=sh.tangled.repo.issue" cborgen:"$type,const=sh.tangled.repo.issue"` 21 21 Body *string `json:"body,omitempty" cborgen:"body,omitempty"` 22 22 CreatedAt string `json:"createdAt" cborgen:"createdAt"` 23 - IssueId int64 `json:"issueId" cborgen:"issueId"` 24 - Owner string `json:"owner" cborgen:"owner"` 25 23 Repo string `json:"repo" cborgen:"repo"` 26 24 Title string `json:"title" cborgen:"title"` 27 25 }
+61
api/tangled/repolanguages.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.languages 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoLanguagesNSID = "sh.tangled.repo.languages" 15 + ) 16 + 17 + // RepoLanguages_Language is a "language" in the sh.tangled.repo.languages schema. 18 + type RepoLanguages_Language struct { 19 + // color: Hex color code for this language 20 + Color *string `json:"color,omitempty" cborgen:"color,omitempty"` 21 + // extensions: File extensions associated with this language 22 + Extensions []string `json:"extensions,omitempty" cborgen:"extensions,omitempty"` 23 + // fileCount: Number of files in this language 24 + FileCount *int64 `json:"fileCount,omitempty" cborgen:"fileCount,omitempty"` 25 + // name: Programming language name 26 + Name string `json:"name" cborgen:"name"` 27 + // percentage: Percentage of total codebase (0-100) 28 + Percentage int64 `json:"percentage" cborgen:"percentage"` 29 + // size: Total size of files in this language (bytes) 30 + Size int64 `json:"size" cborgen:"size"` 31 + } 32 + 33 + // RepoLanguages_Output is the output of a sh.tangled.repo.languages call. 34 + type RepoLanguages_Output struct { 35 + Languages []*RepoLanguages_Language `json:"languages" cborgen:"languages"` 36 + // ref: The git reference used 37 + Ref string `json:"ref" cborgen:"ref"` 38 + // totalFiles: Total number of files analyzed 39 + TotalFiles *int64 `json:"totalFiles,omitempty" cborgen:"totalFiles,omitempty"` 40 + // totalSize: Total size of all analyzed files in bytes 41 + TotalSize *int64 `json:"totalSize,omitempty" cborgen:"totalSize,omitempty"` 42 + } 43 + 44 + // RepoLanguages calls the XRPC method "sh.tangled.repo.languages". 45 + // 46 + // ref: Git reference (branch, tag, or commit SHA) 47 + // repo: Repository identifier in format 'did:plc:.../repoName' 48 + func RepoLanguages(ctx context.Context, c util.LexClient, ref string, repo string) (*RepoLanguages_Output, error) { 49 + var out RepoLanguages_Output 50 + 51 + params := map[string]interface{}{} 52 + if ref != "" { 53 + params["ref"] = ref 54 + } 55 + params["repo"] = repo 56 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.languages", params, nil, &out); err != nil { 57 + return nil, err 58 + } 59 + 60 + return &out, nil 61 + }
+41
api/tangled/repolistSecrets.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.listSecrets 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoListSecretsNSID = "sh.tangled.repo.listSecrets" 15 + ) 16 + 17 + // RepoListSecrets_Output is the output of a sh.tangled.repo.listSecrets call. 18 + type RepoListSecrets_Output struct { 19 + Secrets []*RepoListSecrets_Secret `json:"secrets" cborgen:"secrets"` 20 + } 21 + 22 + // RepoListSecrets_Secret is a "secret" in the sh.tangled.repo.listSecrets schema. 23 + type RepoListSecrets_Secret struct { 24 + CreatedAt string `json:"createdAt" cborgen:"createdAt"` 25 + CreatedBy string `json:"createdBy" cborgen:"createdBy"` 26 + Key string `json:"key" cborgen:"key"` 27 + Repo string `json:"repo" cborgen:"repo"` 28 + } 29 + 30 + // RepoListSecrets calls the XRPC method "sh.tangled.repo.listSecrets". 31 + func RepoListSecrets(ctx context.Context, c util.LexClient, repo string) (*RepoListSecrets_Output, error) { 32 + var out RepoListSecrets_Output 33 + 34 + params := map[string]interface{}{} 35 + params["repo"] = repo 36 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.listSecrets", params, nil, &out); err != nil { 37 + return nil, err 38 + } 39 + 40 + return &out, nil 41 + }
+45
api/tangled/repolog.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.log 6 + 7 + import ( 8 + "bytes" 9 + "context" 10 + 11 + "github.com/bluesky-social/indigo/lex/util" 12 + ) 13 + 14 + const ( 15 + RepoLogNSID = "sh.tangled.repo.log" 16 + ) 17 + 18 + // RepoLog calls the XRPC method "sh.tangled.repo.log". 19 + // 20 + // cursor: Pagination cursor (commit SHA) 21 + // limit: Maximum number of commits to return 22 + // path: Path to filter commits by 23 + // ref: Git reference (branch, tag, or commit SHA) 24 + // repo: Repository identifier in format 'did:plc:.../repoName' 25 + func RepoLog(ctx context.Context, c util.LexClient, cursor string, limit int64, path string, ref string, repo string) ([]byte, error) { 26 + buf := new(bytes.Buffer) 27 + 28 + params := map[string]interface{}{} 29 + if cursor != "" { 30 + params["cursor"] = cursor 31 + } 32 + if limit != 0 { 33 + params["limit"] = limit 34 + } 35 + if path != "" { 36 + params["path"] = path 37 + } 38 + params["ref"] = ref 39 + params["repo"] = repo 40 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.log", params, nil, buf); err != nil { 41 + return nil, err 42 + } 43 + 44 + return buf.Bytes(), nil 45 + }
+44
api/tangled/repomerge.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.merge 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoMergeNSID = "sh.tangled.repo.merge" 15 + ) 16 + 17 + // RepoMerge_Input is the input argument to a sh.tangled.repo.merge call. 18 + type RepoMerge_Input struct { 19 + // authorEmail: Author email for the merge commit 20 + AuthorEmail *string `json:"authorEmail,omitempty" cborgen:"authorEmail,omitempty"` 21 + // authorName: Author name for the merge commit 22 + AuthorName *string `json:"authorName,omitempty" cborgen:"authorName,omitempty"` 23 + // branch: Target branch to merge into 24 + Branch string `json:"branch" cborgen:"branch"` 25 + // commitBody: Additional commit message body 26 + CommitBody *string `json:"commitBody,omitempty" cborgen:"commitBody,omitempty"` 27 + // commitMessage: Merge commit message 28 + CommitMessage *string `json:"commitMessage,omitempty" cborgen:"commitMessage,omitempty"` 29 + // did: DID of the repository owner 30 + Did string `json:"did" cborgen:"did"` 31 + // name: Name of the repository 32 + Name string `json:"name" cborgen:"name"` 33 + // patch: Patch content to merge 34 + Patch string `json:"patch" cborgen:"patch"` 35 + } 36 + 37 + // RepoMerge calls the XRPC method "sh.tangled.repo.merge". 38 + func RepoMerge(ctx context.Context, c util.LexClient, input *RepoMerge_Input) error { 39 + if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.merge", nil, input, nil); err != nil { 40 + return err 41 + } 42 + 43 + return nil 44 + }
+57
api/tangled/repomergeCheck.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.mergeCheck 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoMergeCheckNSID = "sh.tangled.repo.mergeCheck" 15 + ) 16 + 17 + // RepoMergeCheck_ConflictInfo is a "conflictInfo" in the sh.tangled.repo.mergeCheck schema. 18 + type RepoMergeCheck_ConflictInfo struct { 19 + // filename: Name of the conflicted file 20 + Filename string `json:"filename" cborgen:"filename"` 21 + // reason: Reason for the conflict 22 + Reason string `json:"reason" cborgen:"reason"` 23 + } 24 + 25 + // RepoMergeCheck_Input is the input argument to a sh.tangled.repo.mergeCheck call. 26 + type RepoMergeCheck_Input struct { 27 + // branch: Target branch to merge into 28 + Branch string `json:"branch" cborgen:"branch"` 29 + // did: DID of the repository owner 30 + Did string `json:"did" cborgen:"did"` 31 + // name: Name of the repository 32 + Name string `json:"name" cborgen:"name"` 33 + // patch: Patch or pull request to check for merge conflicts 34 + Patch string `json:"patch" cborgen:"patch"` 35 + } 36 + 37 + // RepoMergeCheck_Output is the output of a sh.tangled.repo.mergeCheck call. 38 + type RepoMergeCheck_Output struct { 39 + // conflicts: List of files with merge conflicts 40 + Conflicts []*RepoMergeCheck_ConflictInfo `json:"conflicts,omitempty" cborgen:"conflicts,omitempty"` 41 + // error: Error message if check failed 42 + Error *string `json:"error,omitempty" cborgen:"error,omitempty"` 43 + // is_conflicted: Whether the merge has conflicts 44 + Is_conflicted bool `json:"is_conflicted" cborgen:"is_conflicted"` 45 + // message: Additional message about the merge check 46 + Message *string `json:"message,omitempty" cborgen:"message,omitempty"` 47 + } 48 + 49 + // RepoMergeCheck calls the XRPC method "sh.tangled.repo.mergeCheck". 50 + func RepoMergeCheck(ctx context.Context, c util.LexClient, input *RepoMergeCheck_Input) (*RepoMergeCheck_Output, error) { 51 + var out RepoMergeCheck_Output 52 + if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.mergeCheck", nil, input, &out); err != nil { 53 + return nil, err 54 + } 55 + 56 + return &out, nil 57 + }
+8 -3
api/tangled/repopull.go
··· 21 21 Body *string `json:"body,omitempty" cborgen:"body,omitempty"` 22 22 CreatedAt string `json:"createdAt" cborgen:"createdAt"` 23 23 Patch string `json:"patch" cborgen:"patch"` 24 - PullId int64 `json:"pullId" cborgen:"pullId"` 25 24 Source *RepoPull_Source `json:"source,omitempty" cborgen:"source,omitempty"` 26 - TargetBranch string `json:"targetBranch" cborgen:"targetBranch"` 27 - TargetRepo string `json:"targetRepo" cborgen:"targetRepo"` 25 + Target *RepoPull_Target `json:"target" cborgen:"target"` 28 26 Title string `json:"title" cborgen:"title"` 29 27 } 30 28 ··· 32 30 type RepoPull_Source struct { 33 31 Branch string `json:"branch" cborgen:"branch"` 34 32 Repo *string `json:"repo,omitempty" cborgen:"repo,omitempty"` 33 + Sha string `json:"sha" cborgen:"sha"` 34 + } 35 + 36 + // RepoPull_Target is a "target" in the sh.tangled.repo.pull schema. 37 + type RepoPull_Target struct { 38 + Branch string `json:"branch" cborgen:"branch"` 39 + Repo string `json:"repo" cborgen:"repo"` 35 40 }
+30
api/tangled/reporemoveSecret.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.removeSecret 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoRemoveSecretNSID = "sh.tangled.repo.removeSecret" 15 + ) 16 + 17 + // RepoRemoveSecret_Input is the input argument to a sh.tangled.repo.removeSecret call. 18 + type RepoRemoveSecret_Input struct { 19 + Key string `json:"key" cborgen:"key"` 20 + Repo string `json:"repo" cborgen:"repo"` 21 + } 22 + 23 + // RepoRemoveSecret calls the XRPC method "sh.tangled.repo.removeSecret". 24 + func RepoRemoveSecret(ctx context.Context, c util.LexClient, input *RepoRemoveSecret_Input) error { 25 + if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.removeSecret", nil, input, nil); err != nil { 26 + return err 27 + } 28 + 29 + return nil 30 + }
+30
api/tangled/reposetDefaultBranch.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.setDefaultBranch 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoSetDefaultBranchNSID = "sh.tangled.repo.setDefaultBranch" 15 + ) 16 + 17 + // RepoSetDefaultBranch_Input is the input argument to a sh.tangled.repo.setDefaultBranch call. 18 + type RepoSetDefaultBranch_Input struct { 19 + DefaultBranch string `json:"defaultBranch" cborgen:"defaultBranch"` 20 + Repo string `json:"repo" cborgen:"repo"` 21 + } 22 + 23 + // RepoSetDefaultBranch calls the XRPC method "sh.tangled.repo.setDefaultBranch". 24 + func RepoSetDefaultBranch(ctx context.Context, c util.LexClient, input *RepoSetDefaultBranch_Input) error { 25 + if err := c.LexDo(ctx, util.Procedure, "application/json", "sh.tangled.repo.setDefaultBranch", nil, input, nil); err != nil { 26 + return err 27 + } 28 + 29 + return nil 30 + }
+39
api/tangled/repotags.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.tags 6 + 7 + import ( 8 + "bytes" 9 + "context" 10 + 11 + "github.com/bluesky-social/indigo/lex/util" 12 + ) 13 + 14 + const ( 15 + RepoTagsNSID = "sh.tangled.repo.tags" 16 + ) 17 + 18 + // RepoTags calls the XRPC method "sh.tangled.repo.tags". 19 + // 20 + // cursor: Pagination cursor 21 + // limit: Maximum number of tags to return 22 + // repo: Repository identifier in format 'did:plc:.../repoName' 23 + func RepoTags(ctx context.Context, c util.LexClient, cursor string, limit int64, repo string) ([]byte, error) { 24 + buf := new(bytes.Buffer) 25 + 26 + params := map[string]interface{}{} 27 + if cursor != "" { 28 + params["cursor"] = cursor 29 + } 30 + if limit != 0 { 31 + params["limit"] = limit 32 + } 33 + params["repo"] = repo 34 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.tags", params, nil, buf); err != nil { 35 + return nil, err 36 + } 37 + 38 + return buf.Bytes(), nil 39 + }
+72
api/tangled/repotree.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.tree 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + RepoTreeNSID = "sh.tangled.repo.tree" 15 + ) 16 + 17 + // RepoTree_LastCommit is a "lastCommit" in the sh.tangled.repo.tree schema. 18 + type RepoTree_LastCommit struct { 19 + // hash: Commit hash 20 + Hash string `json:"hash" cborgen:"hash"` 21 + // message: Commit message 22 + Message string `json:"message" cborgen:"message"` 23 + // when: Commit timestamp 24 + When string `json:"when" cborgen:"when"` 25 + } 26 + 27 + // RepoTree_Output is the output of a sh.tangled.repo.tree call. 28 + type RepoTree_Output struct { 29 + // dotdot: Parent directory path 30 + Dotdot *string `json:"dotdot,omitempty" cborgen:"dotdot,omitempty"` 31 + Files []*RepoTree_TreeEntry `json:"files" cborgen:"files"` 32 + // parent: The parent path in the tree 33 + Parent *string `json:"parent,omitempty" cborgen:"parent,omitempty"` 34 + // ref: The git reference used 35 + Ref string `json:"ref" cborgen:"ref"` 36 + } 37 + 38 + // RepoTree_TreeEntry is a "treeEntry" in the sh.tangled.repo.tree schema. 39 + type RepoTree_TreeEntry struct { 40 + // is_file: Whether this entry is a file 41 + Is_file bool `json:"is_file" cborgen:"is_file"` 42 + // is_subtree: Whether this entry is a directory/subtree 43 + Is_subtree bool `json:"is_subtree" cborgen:"is_subtree"` 44 + Last_commit *RepoTree_LastCommit `json:"last_commit,omitempty" cborgen:"last_commit,omitempty"` 45 + // mode: File mode 46 + Mode string `json:"mode" cborgen:"mode"` 47 + // name: Relative file or directory name 48 + Name string `json:"name" cborgen:"name"` 49 + // size: File size in bytes 50 + Size int64 `json:"size" cborgen:"size"` 51 + } 52 + 53 + // RepoTree calls the XRPC method "sh.tangled.repo.tree". 54 + // 55 + // path: Path within the repository tree 56 + // ref: Git reference (branch, tag, or commit SHA) 57 + // repo: Repository identifier in format 'did:plc:.../repoName' 58 + func RepoTree(ctx context.Context, c util.LexClient, path string, ref string, repo string) (*RepoTree_Output, error) { 59 + var out RepoTree_Output 60 + 61 + params := map[string]interface{}{} 62 + if path != "" { 63 + params["path"] = path 64 + } 65 + params["ref"] = ref 66 + params["repo"] = repo 67 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.tree", params, nil, &out); err != nil { 68 + return nil, err 69 + } 70 + 71 + return &out, nil 72 + }
+3 -1
api/tangled/stateclosed.go
··· 4 4 5 5 // schema: sh.tangled.repo.issue.state.closed 6 6 7 - const () 7 + const ( 8 + RepoIssueStateClosedNSID = "sh.tangled.repo.issue.state.closed" 9 + ) 8 10 9 11 const RepoIssueStateClosed = "sh.tangled.repo.issue.state.closed"
+3 -1
api/tangled/stateopen.go
··· 4 4 5 5 // schema: sh.tangled.repo.issue.state.open 6 6 7 - const () 7 + const ( 8 + RepoIssueStateOpenNSID = "sh.tangled.repo.issue.state.open" 9 + ) 8 10 9 11 const RepoIssueStateOpen = "sh.tangled.repo.issue.state.open"
+3 -1
api/tangled/statusclosed.go
··· 4 4 5 5 // schema: sh.tangled.repo.pull.status.closed 6 6 7 - const () 7 + const ( 8 + RepoPullStatusClosedNSID = "sh.tangled.repo.pull.status.closed" 9 + ) 8 10 9 11 const RepoPullStatusClosed = "sh.tangled.repo.pull.status.closed"
+3 -1
api/tangled/statusmerged.go
··· 4 4 5 5 // schema: sh.tangled.repo.pull.status.merged 6 6 7 - const () 7 + const ( 8 + RepoPullStatusMergedNSID = "sh.tangled.repo.pull.status.merged" 9 + ) 8 10 9 11 const RepoPullStatusMerged = "sh.tangled.repo.pull.status.merged"
+3 -1
api/tangled/statusopen.go
··· 4 4 5 5 // schema: sh.tangled.repo.pull.status.open 6 6 7 - const () 7 + const ( 8 + RepoPullStatusOpenNSID = "sh.tangled.repo.pull.status.open" 9 + ) 8 10 9 11 const RepoPullStatusOpen = "sh.tangled.repo.pull.status.open"
+22
api/tangled/tangledknot.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.knot 6 + 7 + import ( 8 + "github.com/bluesky-social/indigo/lex/util" 9 + ) 10 + 11 + const ( 12 + KnotNSID = "sh.tangled.knot" 13 + ) 14 + 15 + func init() { 16 + util.RegisterType("sh.tangled.knot", &Knot{}) 17 + } // 18 + // RECORDTYPE: Knot 19 + type Knot struct { 20 + LexiconTypeID string `json:"$type,const=sh.tangled.knot" cborgen:"$type,const=sh.tangled.knot"` 21 + CreatedAt string `json:"createdAt" cborgen:"createdAt"` 22 + }
+30
api/tangled/tangledowner.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.owner 6 + 7 + import ( 8 + "context" 9 + 10 + "github.com/bluesky-social/indigo/lex/util" 11 + ) 12 + 13 + const ( 14 + OwnerNSID = "sh.tangled.owner" 15 + ) 16 + 17 + // Owner_Output is the output of a sh.tangled.owner call. 18 + type Owner_Output struct { 19 + Owner string `json:"owner" cborgen:"owner"` 20 + } 21 + 22 + // Owner calls the XRPC method "sh.tangled.owner". 23 + func Owner(ctx context.Context, c util.LexClient) (*Owner_Output, error) { 24 + var out Owner_Output 25 + if err := c.LexDo(ctx, util.Query, "", "sh.tangled.owner", nil, nil, &out); err != nil { 26 + return nil, err 27 + } 28 + 29 + return &out, nil 30 + }
+7 -29
api/tangled/tangledpipeline.go
··· 29 29 Submodules bool `json:"submodules" cborgen:"submodules"` 30 30 } 31 31 32 - type Pipeline_Dependencies_Elem struct { 33 - Packages []string `json:"packages" cborgen:"packages"` 34 - Registry string `json:"registry" cborgen:"registry"` 35 - } 36 - 37 32 // Pipeline_ManualTriggerData is a "manualTriggerData" in the sh.tangled.pipeline schema. 38 33 type Pipeline_ManualTriggerData struct { 39 - Inputs []*Pipeline_ManualTriggerData_Inputs_Elem `json:"inputs,omitempty" cborgen:"inputs,omitempty"` 34 + Inputs []*Pipeline_Pair `json:"inputs,omitempty" cborgen:"inputs,omitempty"` 40 35 } 41 36 42 - type Pipeline_ManualTriggerData_Inputs_Elem struct { 37 + // Pipeline_Pair is a "pair" in the sh.tangled.pipeline schema. 38 + type Pipeline_Pair struct { 43 39 Key string `json:"key" cborgen:"key"` 44 40 Value string `json:"value" cborgen:"value"` 45 41 } ··· 59 55 Ref string `json:"ref" cborgen:"ref"` 60 56 } 61 57 62 - // Pipeline_Step is a "step" in the sh.tangled.pipeline schema. 63 - type Pipeline_Step struct { 64 - Command string `json:"command" cborgen:"command"` 65 - Environment []*Pipeline_Step_Environment_Elem `json:"environment,omitempty" cborgen:"environment,omitempty"` 66 - Name string `json:"name" cborgen:"name"` 67 - } 68 - 69 - type Pipeline_Step_Environment_Elem struct { 70 - Key string `json:"key" cborgen:"key"` 71 - Value string `json:"value" cborgen:"value"` 72 - } 73 - 74 58 // Pipeline_TriggerMetadata is a "triggerMetadata" in the sh.tangled.pipeline schema. 75 59 type Pipeline_TriggerMetadata struct { 76 60 Kind string `json:"kind" cborgen:"kind"` ··· 90 74 91 75 // Pipeline_Workflow is a "workflow" in the sh.tangled.pipeline schema. 92 76 type Pipeline_Workflow struct { 93 - Clone *Pipeline_CloneOpts `json:"clone" cborgen:"clone"` 94 - Dependencies []Pipeline_Dependencies_Elem `json:"dependencies" cborgen:"dependencies"` 95 - Environment []*Pipeline_Workflow_Environment_Elem `json:"environment" cborgen:"environment"` 96 - Name string `json:"name" cborgen:"name"` 97 - Steps []*Pipeline_Step `json:"steps" cborgen:"steps"` 98 - } 99 - 100 - type Pipeline_Workflow_Environment_Elem struct { 101 - Key string `json:"key" cborgen:"key"` 102 - Value string `json:"value" cborgen:"value"` 77 + Clone *Pipeline_CloneOpts `json:"clone" cborgen:"clone"` 78 + Engine string `json:"engine" cborgen:"engine"` 79 + Name string `json:"name" cborgen:"name"` 80 + Raw string `json:"raw" cborgen:"raw"` 103 81 }
+25
api/tangled/tangledstring.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.string 6 + 7 + import ( 8 + "github.com/bluesky-social/indigo/lex/util" 9 + ) 10 + 11 + const ( 12 + StringNSID = "sh.tangled.string" 13 + ) 14 + 15 + func init() { 16 + util.RegisterType("sh.tangled.string", &String{}) 17 + } // 18 + // RECORDTYPE: String 19 + type String struct { 20 + LexiconTypeID string `json:"$type,const=sh.tangled.string" cborgen:"$type,const=sh.tangled.string"` 21 + Contents string `json:"contents" cborgen:"contents"` 22 + CreatedAt string `json:"createdAt" cborgen:"createdAt"` 23 + Description string `json:"description" cborgen:"description"` 24 + Filename string `json:"filename" cborgen:"filename"` 25 + }
+1
appview/cache/session/store.go
··· 31 31 PkceVerifier string 32 32 DpopAuthserverNonce string 33 33 DpopPrivateJwk string 34 + ReturnUrl string 34 35 } 35 36 36 37 type SessionStore struct {
+24 -5
appview/config/config.go
··· 10 10 ) 11 11 12 12 type CoreConfig struct { 13 - CookieSecret string `env:"COOKIE_SECRET, default=00000000000000000000000000000000"` 14 - DbPath string `env:"DB_PATH, default=appview.db"` 15 - ListenAddr string `env:"LISTEN_ADDR, default=0.0.0.0:3000"` 16 - AppviewHost string `env:"APPVIEW_HOST, default=https://tangled.sh"` 17 - Dev bool `env:"DEV, default=false"` 13 + CookieSecret string `env:"COOKIE_SECRET, default=00000000000000000000000000000000"` 14 + DbPath string `env:"DB_PATH, default=appview.db"` 15 + ListenAddr string `env:"LISTEN_ADDR, default=0.0.0.0:3000"` 16 + AppviewHost string `env:"APPVIEW_HOST, default=https://tangled.sh"` 17 + Dev bool `env:"DEV, default=false"` 18 + DisallowedNicknamesFile string `env:"DISALLOWED_NICKNAMES_FILE"` 19 + 20 + // temporarily, to add users to default knot and spindle 21 + AppPassword string `env:"APP_PASSWORD"` 22 + 23 + // uhhhh this is because knot1 is under icy's did 24 + TmpAltAppPassword string `env:"ALT_APP_PASSWORD"` 18 25 } 19 26 20 27 type OAuthConfig struct { ··· 59 66 DB int `env:"DB, default=0"` 60 67 } 61 68 69 + type PdsConfig struct { 70 + Host string `env:"HOST, default=https://tngl.sh"` 71 + AdminSecret string `env:"ADMIN_SECRET"` 72 + } 73 + 74 + type Cloudflare struct { 75 + ApiToken string `env:"API_TOKEN"` 76 + ZoneId string `env:"ZONE_ID"` 77 + } 78 + 62 79 func (cfg RedisConfig) ToURL() string { 63 80 u := &url.URL{ 64 81 Scheme: "redis", ··· 84 101 Avatar AvatarConfig `env:",prefix=TANGLED_AVATAR_"` 85 102 OAuth OAuthConfig `env:",prefix=TANGLED_OAUTH_"` 86 103 Redis RedisConfig `env:",prefix=TANGLED_REDIS_"` 104 + Pds PdsConfig `env:",prefix=TANGLED_PDS_"` 105 + Cloudflare Cloudflare `env:",prefix=TANGLED_CLOUDFLARE_"` 87 106 } 88 107 89 108 func LoadConfig(ctx context.Context) (*Config, error) {
+1 -1
appview/db/artifact.go
··· 27 27 } 28 28 29 29 func (a *Artifact) ArtifactAt() syntax.ATURI { 30 - return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", a.Did, tangled.RepoPullNSID, a.Rkey)) 30 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", a.Did, tangled.RepoArtifactNSID, a.Rkey)) 31 31 } 32 32 33 33 func AddArtifact(e Execer, artifact Artifact) error {
+76
appview/db/collaborators.go
··· 1 + package db 2 + 3 + import ( 4 + "fmt" 5 + "strings" 6 + "time" 7 + 8 + "github.com/bluesky-social/indigo/atproto/syntax" 9 + ) 10 + 11 + type Collaborator struct { 12 + // identifiers for the record 13 + Id int64 14 + Did syntax.DID 15 + Rkey string 16 + 17 + // content 18 + SubjectDid syntax.DID 19 + RepoAt syntax.ATURI 20 + 21 + // meta 22 + Created time.Time 23 + } 24 + 25 + func AddCollaborator(e Execer, c Collaborator) error { 26 + _, err := e.Exec( 27 + `insert into collaborators (did, rkey, subject_did, repo_at) values (?, ?, ?, ?);`, 28 + c.Did, c.Rkey, c.SubjectDid, c.RepoAt, 29 + ) 30 + return err 31 + } 32 + 33 + func DeleteCollaborator(e Execer, filters ...filter) error { 34 + var conditions []string 35 + var args []any 36 + for _, filter := range filters { 37 + conditions = append(conditions, filter.Condition()) 38 + args = append(args, filter.Arg()...) 39 + } 40 + 41 + whereClause := "" 42 + if conditions != nil { 43 + whereClause = " where " + strings.Join(conditions, " and ") 44 + } 45 + 46 + query := fmt.Sprintf(`delete from collaborators %s`, whereClause) 47 + 48 + _, err := e.Exec(query, args...) 49 + return err 50 + } 51 + 52 + func CollaboratingIn(e Execer, collaborator string) ([]Repo, error) { 53 + rows, err := e.Query(`select repo_at from collaborators where subject_did = ?`, collaborator) 54 + if err != nil { 55 + return nil, err 56 + } 57 + defer rows.Close() 58 + 59 + var repoAts []string 60 + for rows.Next() { 61 + var aturi string 62 + err := rows.Scan(&aturi) 63 + if err != nil { 64 + return nil, err 65 + } 66 + repoAts = append(repoAts, aturi) 67 + } 68 + if err := rows.Err(); err != nil { 69 + return nil, err 70 + } 71 + if repoAts == nil { 72 + return nil, nil 73 + } 74 + 75 + return GetRepos(e, 0, FilterIn("at_uri", repoAts)) 76 + }
+363 -27
appview/db/db.go
··· 27 27 } 28 28 29 29 func Make(dbPath string) (*DB, error) { 30 - db, err := sql.Open("sqlite3", dbPath) 30 + // https://github.com/mattn/go-sqlite3#connection-string 31 + opts := []string{ 32 + "_foreign_keys=1", 33 + "_journal_mode=WAL", 34 + "_synchronous=NORMAL", 35 + "_auto_vacuum=incremental", 36 + } 37 + 38 + db, err := sql.Open("sqlite3", dbPath+"?"+strings.Join(opts, "&")) 39 + if err != nil { 40 + return nil, err 41 + } 42 + 43 + ctx := context.Background() 44 + 45 + conn, err := db.Conn(ctx) 31 46 if err != nil { 32 47 return nil, err 33 48 } 34 - _, err = db.Exec(` 35 - pragma journal_mode = WAL; 36 - pragma synchronous = normal; 37 - pragma foreign_keys = on; 38 - pragma temp_store = memory; 39 - pragma mmap_size = 30000000000; 40 - pragma page_size = 32768; 41 - pragma auto_vacuum = incremental; 42 - pragma busy_timeout = 5000; 49 + defer conn.Close() 43 50 51 + _, err = conn.ExecContext(ctx, ` 44 52 create table if not exists registrations ( 45 53 id integer primary key autoincrement, 46 54 domain text not null unique, ··· 199 207 unique(starred_by_did, repo_at) 200 208 ); 201 209 210 + create table if not exists reactions ( 211 + id integer primary key autoincrement, 212 + reacted_by_did text not null, 213 + thread_at text not null, 214 + kind text not null, 215 + rkey text not null, 216 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 217 + unique(reacted_by_did, thread_at, kind) 218 + ); 219 + 202 220 create table if not exists emails ( 203 221 id integer primary key autoincrement, 204 222 did text not null, ··· 330 348 verified text, -- time of verification 331 349 created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 332 350 333 - unique(instance) 351 + unique(owner, instance) 352 + ); 353 + 354 + create table if not exists spindle_members ( 355 + -- identifiers for the record 356 + id integer primary key autoincrement, 357 + did text not null, 358 + rkey text not null, 359 + 360 + -- data 361 + instance text not null, 362 + subject text not null, 363 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 364 + 365 + -- constraints 366 + unique (did, instance, subject) 334 367 ); 335 368 336 369 create table if not exists pipelines ( ··· 395 428 on delete cascade 396 429 ); 397 430 431 + create table if not exists repo_languages ( 432 + -- identifiers 433 + id integer primary key autoincrement, 434 + 435 + -- repo identifiers 436 + repo_at text not null, 437 + ref text not null, 438 + is_default_ref integer not null default 0, 439 + 440 + -- language breakdown 441 + language text not null, 442 + bytes integer not null check (bytes >= 0), 443 + 444 + unique(repo_at, ref, language) 445 + ); 446 + 447 + create table if not exists signups_inflight ( 448 + id integer primary key autoincrement, 449 + email text not null unique, 450 + invite_code text not null, 451 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) 452 + ); 453 + 454 + create table if not exists strings ( 455 + -- identifiers 456 + did text not null, 457 + rkey text not null, 458 + 459 + -- content 460 + filename text not null, 461 + description text, 462 + content text not null, 463 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 464 + edited text, 465 + 466 + primary key (did, rkey) 467 + ); 468 + 398 469 create table if not exists migrations ( 399 470 id integer primary key autoincrement, 400 471 name text unique 401 472 ); 473 + 474 + -- indexes for better star query performance 475 + create index if not exists idx_stars_created on stars(created); 476 + create index if not exists idx_stars_repo_at_created on stars(repo_at, created); 402 477 `) 403 478 if err != nil { 404 479 return nil, err 405 480 } 406 481 407 482 // run migrations 408 - runMigration(db, "add-description-to-repos", func(tx *sql.Tx) error { 483 + runMigration(conn, "add-description-to-repos", func(tx *sql.Tx) error { 409 484 tx.Exec(` 410 485 alter table repos add column description text check (length(description) <= 200); 411 486 `) 412 487 return nil 413 488 }) 414 489 415 - runMigration(db, "add-rkey-to-pubkeys", func(tx *sql.Tx) error { 490 + runMigration(conn, "add-rkey-to-pubkeys", func(tx *sql.Tx) error { 416 491 // add unconstrained column 417 492 _, err := tx.Exec(` 418 493 alter table public_keys ··· 435 510 return nil 436 511 }) 437 512 438 - runMigration(db, "add-rkey-to-comments", func(tx *sql.Tx) error { 513 + runMigration(conn, "add-rkey-to-comments", func(tx *sql.Tx) error { 439 514 _, err := tx.Exec(` 440 515 alter table comments drop column comment_at; 441 516 alter table comments add column rkey text; ··· 443 518 return err 444 519 }) 445 520 446 - runMigration(db, "add-deleted-and-edited-to-issue-comments", func(tx *sql.Tx) error { 521 + runMigration(conn, "add-deleted-and-edited-to-issue-comments", func(tx *sql.Tx) error { 447 522 _, err := tx.Exec(` 448 523 alter table comments add column deleted text; -- timestamp 449 524 alter table comments add column edited text; -- timestamp ··· 451 526 return err 452 527 }) 453 528 454 - runMigration(db, "add-source-info-to-pulls-and-submissions", func(tx *sql.Tx) error { 529 + runMigration(conn, "add-source-info-to-pulls-and-submissions", func(tx *sql.Tx) error { 455 530 _, err := tx.Exec(` 456 531 alter table pulls add column source_branch text; 457 532 alter table pulls add column source_repo_at text; ··· 460 535 return err 461 536 }) 462 537 463 - runMigration(db, "add-source-to-repos", func(tx *sql.Tx) error { 538 + runMigration(conn, "add-source-to-repos", func(tx *sql.Tx) error { 464 539 _, err := tx.Exec(` 465 540 alter table repos add column source text; 466 541 `) ··· 471 546 // NOTE: this cannot be done in a transaction, so it is run outside [0] 472 547 // 473 548 // [0]: https://sqlite.org/pragma.html#pragma_foreign_keys 474 - db.Exec("pragma foreign_keys = off;") 475 - runMigration(db, "recreate-pulls-column-for-stacking-support", func(tx *sql.Tx) error { 549 + conn.ExecContext(ctx, "pragma foreign_keys = off;") 550 + runMigration(conn, "recreate-pulls-column-for-stacking-support", func(tx *sql.Tx) error { 476 551 _, err := tx.Exec(` 477 552 create table pulls_new ( 478 553 -- identifiers ··· 527 602 `) 528 603 return err 529 604 }) 530 - db.Exec("pragma foreign_keys = on;") 605 + conn.ExecContext(ctx, "pragma foreign_keys = on;") 531 606 532 607 // run migrations 533 - runMigration(db, "add-spindle-to-repos", func(tx *sql.Tx) error { 608 + runMigration(conn, "add-spindle-to-repos", func(tx *sql.Tx) error { 534 609 tx.Exec(` 535 610 alter table repos add column spindle text; 536 611 `) 537 612 return nil 538 613 }) 539 614 615 + // drop all knot secrets, add unique constraint to knots 616 + // 617 + // knots will henceforth use service auth for signed requests 618 + runMigration(conn, "no-more-secrets", func(tx *sql.Tx) error { 619 + _, err := tx.Exec(` 620 + create table registrations_new ( 621 + id integer primary key autoincrement, 622 + domain text not null, 623 + did text not null, 624 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 625 + registered text, 626 + read_only integer not null default 0, 627 + unique(domain, did) 628 + ); 629 + 630 + insert into registrations_new (id, domain, did, created, registered, read_only) 631 + select id, domain, did, created, registered, 1 from registrations 632 + where registered is not null; 633 + 634 + drop table registrations; 635 + alter table registrations_new rename to registrations; 636 + `) 637 + return err 638 + }) 639 + 640 + // recreate and add rkey + created columns with default constraint 641 + runMigration(conn, "rework-collaborators-table", func(tx *sql.Tx) error { 642 + // create new table 643 + // - repo_at instead of repo integer 644 + // - rkey field 645 + // - created field 646 + _, err := tx.Exec(` 647 + create table collaborators_new ( 648 + -- identifiers for the record 649 + id integer primary key autoincrement, 650 + did text not null, 651 + rkey text, 652 + 653 + -- content 654 + subject_did text not null, 655 + repo_at text not null, 656 + 657 + -- meta 658 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 659 + 660 + -- constraints 661 + foreign key (repo_at) references repos(at_uri) on delete cascade 662 + ) 663 + `) 664 + if err != nil { 665 + return err 666 + } 667 + 668 + // copy data 669 + _, err = tx.Exec(` 670 + insert into collaborators_new (id, did, rkey, subject_did, repo_at) 671 + select 672 + c.id, 673 + r.did, 674 + '', 675 + c.did, 676 + r.at_uri 677 + from collaborators c 678 + join repos r on c.repo = r.id 679 + `) 680 + if err != nil { 681 + return err 682 + } 683 + 684 + // drop old table 685 + _, err = tx.Exec(`drop table collaborators`) 686 + if err != nil { 687 + return err 688 + } 689 + 690 + // rename new table 691 + _, err = tx.Exec(`alter table collaborators_new rename to collaborators`) 692 + return err 693 + }) 694 + 695 + runMigration(conn, "add-rkey-to-issues", func(tx *sql.Tx) error { 696 + _, err := tx.Exec(` 697 + alter table issues add column rkey text not null default ''; 698 + 699 + -- get last url section from issue_at and save to rkey column 700 + update issues 701 + set rkey = replace(issue_at, rtrim(issue_at, replace(issue_at, '/', '')), ''); 702 + `) 703 + return err 704 + }) 705 + 706 + // repurpose the read-only column to "needs-upgrade" 707 + runMigration(conn, "rename-registrations-read-only-to-needs-upgrade", func(tx *sql.Tx) error { 708 + _, err := tx.Exec(` 709 + alter table registrations rename column read_only to needs_upgrade; 710 + `) 711 + return err 712 + }) 713 + 714 + // require all knots to upgrade after the release of total xrpc 715 + runMigration(conn, "migrate-knots-to-total-xrpc", func(tx *sql.Tx) error { 716 + _, err := tx.Exec(` 717 + update registrations set needs_upgrade = 1; 718 + `) 719 + return err 720 + }) 721 + 722 + // require all knots to upgrade after the release of total xrpc 723 + runMigration(conn, "migrate-spindles-to-xrpc-owner", func(tx *sql.Tx) error { 724 + _, err := tx.Exec(` 725 + alter table spindles add column needs_upgrade integer not null default 0; 726 + `) 727 + if err != nil { 728 + return err 729 + } 730 + 731 + _, err = tx.Exec(` 732 + update spindles set needs_upgrade = 1; 733 + `) 734 + return err 735 + }) 736 + 737 + // remove issue_at from issues and replace with generated column 738 + // 739 + // this requires a full table recreation because stored columns 740 + // cannot be added via alter 741 + // 742 + // couple other changes: 743 + // - columns renamed to be more consistent 744 + // - adds edited and deleted fields 745 + // 746 + // disable foreign-keys for the next migration 747 + conn.ExecContext(ctx, "pragma foreign_keys = off;") 748 + runMigration(conn, "remove-issue-at-from-issues", func(tx *sql.Tx) error { 749 + _, err := tx.Exec(` 750 + create table if not exists issues_new ( 751 + -- identifiers 752 + id integer primary key autoincrement, 753 + did text not null, 754 + rkey text not null, 755 + at_uri text generated always as ('at://' || did || '/' || 'sh.tangled.repo.issue' || '/' || rkey) stored, 756 + 757 + -- at identifiers 758 + repo_at text not null, 759 + 760 + -- content 761 + issue_id integer not null, 762 + title text not null, 763 + body text not null, 764 + open integer not null default 1, 765 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 766 + edited text, -- timestamp 767 + deleted text, -- timestamp 768 + 769 + unique(did, rkey), 770 + unique(repo_at, issue_id), 771 + unique(at_uri), 772 + foreign key (repo_at) references repos(at_uri) on delete cascade 773 + ); 774 + `) 775 + if err != nil { 776 + return err 777 + } 778 + 779 + // transfer data 780 + _, err = tx.Exec(` 781 + insert into issues_new (id, did, rkey, repo_at, issue_id, title, body, open, created) 782 + select 783 + i.id, 784 + i.owner_did, 785 + i.rkey, 786 + i.repo_at, 787 + i.issue_id, 788 + i.title, 789 + i.body, 790 + i.open, 791 + i.created 792 + from issues i; 793 + `) 794 + if err != nil { 795 + return err 796 + } 797 + 798 + // drop old table 799 + _, err = tx.Exec(`drop table issues`) 800 + if err != nil { 801 + return err 802 + } 803 + 804 + // rename new table 805 + _, err = tx.Exec(`alter table issues_new rename to issues`) 806 + return err 807 + }) 808 + conn.ExecContext(ctx, "pragma foreign_keys = on;") 809 + 810 + // - renames the comments table to 'issue_comments' 811 + // - rework issue comments to update constraints: 812 + // * unique(did, rkey) 813 + // * remove comment-id and just use the global ID 814 + // * foreign key (repo_at, issue_id) 815 + // - new columns 816 + // * column "reply_to" which can be any other comment 817 + // * column "at-uri" which is a generated column 818 + runMigration(conn, "rework-issue-comments", func(tx *sql.Tx) error { 819 + _, err := tx.Exec(` 820 + create table if not exists issue_comments ( 821 + -- identifiers 822 + id integer primary key autoincrement, 823 + did text not null, 824 + rkey text, 825 + at_uri text generated always as ('at://' || did || '/' || 'sh.tangled.repo.issue.comment' || '/' || rkey) stored, 826 + 827 + -- at identifiers 828 + issue_at text not null, 829 + reply_to text, -- at_uri of parent comment 830 + 831 + -- content 832 + body text not null, 833 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 834 + edited text, 835 + deleted text, 836 + 837 + -- constraints 838 + unique(did, rkey), 839 + unique(at_uri), 840 + foreign key (issue_at) references issues(at_uri) on delete cascade 841 + ); 842 + `) 843 + if err != nil { 844 + return err 845 + } 846 + 847 + // transfer data 848 + _, err = tx.Exec(` 849 + insert into issue_comments (id, did, rkey, issue_at, body, created, edited, deleted) 850 + select 851 + c.id, 852 + c.owner_did, 853 + c.rkey, 854 + i.at_uri, -- get at_uri from issues table 855 + c.body, 856 + c.created, 857 + c.edited, 858 + c.deleted 859 + from comments c 860 + join issues i on c.repo_at = i.repo_at and c.issue_id = i.issue_id; 861 + `) 862 + if err != nil { 863 + return err 864 + } 865 + 866 + // drop old table 867 + _, err = tx.Exec(`drop table comments`) 868 + return err 869 + }) 870 + 540 871 return &DB{db}, nil 541 872 } 542 873 543 874 type migrationFn = func(*sql.Tx) error 544 875 545 - func runMigration(d *sql.DB, name string, migrationFn migrationFn) error { 546 - tx, err := d.Begin() 876 + func runMigration(c *sql.Conn, name string, migrationFn migrationFn) error { 877 + tx, err := c.BeginTx(context.Background(), nil) 547 878 if err != nil { 548 879 return err 549 880 } ··· 583 914 return nil 584 915 } 585 916 917 + func (d *DB) Close() error { 918 + return d.DB.Close() 919 + } 920 + 586 921 type filter struct { 587 922 key string 588 923 arg any ··· 610 945 kind := rv.Kind() 611 946 612 947 // if we have `FilterIn(k, [1, 2, 3])`, compile it down to `k in (?, ?, ?)` 613 - if kind == reflect.Slice || kind == reflect.Array { 948 + if (kind == reflect.Slice && rv.Type().Elem().Kind() != reflect.Uint8) || kind == reflect.Array { 614 949 if rv.Len() == 0 { 615 - panic(fmt.Sprintf("empty slice passed to %q filter on %s", f.cmp, f.key)) 950 + // always false 951 + return "1 = 0" 616 952 } 617 953 618 954 placeholders := make([]string, rv.Len()) ··· 629 965 func (f filter) Arg() []any { 630 966 rv := reflect.ValueOf(f.arg) 631 967 kind := rv.Kind() 632 - if kind == reflect.Slice || kind == reflect.Array { 968 + if (kind == reflect.Slice && rv.Type().Elem().Kind() != reflect.Uint8) || kind == reflect.Array { 633 969 if rv.Len() == 0 { 634 - panic(fmt.Sprintf("empty slice passed to %q filter on %s", f.cmp, f.key)) 970 + return nil 635 971 } 636 972 637 973 out := make([]any, rv.Len())
+16 -2
appview/db/email.go
··· 103 103 query := ` 104 104 select email, did 105 105 from emails 106 - where 107 - verified = ? 106 + where 107 + verified = ? 108 108 and email in (` + strings.Join(placeholders, ",") + `) 109 109 ` 110 110 ··· 153 153 ` 154 154 var count int 155 155 err := e.QueryRow(query, did, email).Scan(&count) 156 + if err != nil { 157 + return false, err 158 + } 159 + return count > 0, nil 160 + } 161 + 162 + func CheckEmailExistsAtAll(e Execer, email string) (bool, error) { 163 + query := ` 164 + select count(*) 165 + from emails 166 + where email = ? 167 + ` 168 + var count int 169 + err := e.QueryRow(query, email).Scan(&count) 156 170 if err != nil { 157 171 return false, err 158 172 }
+147 -44
appview/db/follow.go
··· 1 1 package db 2 2 3 3 import ( 4 + "fmt" 4 5 "log" 6 + "strings" 5 7 "time" 6 8 ) 7 9 ··· 12 14 Rkey string 13 15 } 14 16 15 - func AddFollow(e Execer, userDid, subjectDid, rkey string) error { 17 + func AddFollow(e Execer, follow *Follow) error { 16 18 query := `insert or ignore into follows (user_did, subject_did, rkey) values (?, ?, ?)` 17 - _, err := e.Exec(query, userDid, subjectDid, rkey) 19 + _, err := e.Exec(query, follow.UserDid, follow.SubjectDid, follow.Rkey) 18 20 return err 19 21 } 20 22 ··· 53 55 return err 54 56 } 55 57 56 - func GetFollowerFollowing(e Execer, did string) (int, int, error) { 57 - followers, following := 0, 0 58 + type FollowStats struct { 59 + Followers int64 60 + Following int64 61 + } 62 + 63 + func GetFollowerFollowingCount(e Execer, did string) (FollowStats, error) { 64 + var followers, following int64 58 65 err := e.QueryRow( 59 - `SELECT 66 + `SELECT 60 67 COUNT(CASE WHEN subject_did = ? THEN 1 END) AS followers, 61 68 COUNT(CASE WHEN user_did = ? THEN 1 END) AS following 62 69 FROM follows;`, did, did).Scan(&followers, &following) 63 70 if err != nil { 64 - return 0, 0, err 71 + return FollowStats{}, err 65 72 } 66 - return followers, following, nil 73 + return FollowStats{ 74 + Followers: followers, 75 + Following: following, 76 + }, nil 67 77 } 68 78 69 - type FollowStatus int 79 + func GetFollowerFollowingCounts(e Execer, dids []string) (map[string]FollowStats, error) { 80 + if len(dids) == 0 { 81 + return nil, nil 82 + } 70 83 71 - const ( 72 - IsNotFollowing FollowStatus = iota 73 - IsFollowing 74 - IsSelf 75 - ) 84 + placeholders := make([]string, len(dids)) 85 + for i := range placeholders { 86 + placeholders[i] = "?" 87 + } 88 + placeholderStr := strings.Join(placeholders, ",") 76 89 77 - func (s FollowStatus) String() string { 78 - switch s { 79 - case IsNotFollowing: 80 - return "IsNotFollowing" 81 - case IsFollowing: 82 - return "IsFollowing" 83 - case IsSelf: 84 - return "IsSelf" 85 - default: 86 - return "IsNotFollowing" 90 + args := make([]any, len(dids)*2) 91 + for i, did := range dids { 92 + args[i] = did 93 + args[i+len(dids)] = did 87 94 } 88 - } 89 95 90 - func GetFollowStatus(e Execer, userDid, subjectDid string) FollowStatus { 91 - if userDid == subjectDid { 92 - return IsSelf 93 - } else if _, err := GetFollow(e, userDid, subjectDid); err != nil { 94 - return IsNotFollowing 95 - } else { 96 - return IsFollowing 96 + query := fmt.Sprintf(` 97 + select 98 + coalesce(f.did, g.did) as did, 99 + coalesce(f.followers, 0) as followers, 100 + coalesce(g.following, 0) as following 101 + from ( 102 + select subject_did as did, count(*) as followers 103 + from follows 104 + where subject_did in (%s) 105 + group by subject_did 106 + ) f 107 + full outer join ( 108 + select user_did as did, count(*) as following 109 + from follows 110 + where user_did in (%s) 111 + group by user_did 112 + ) g on f.did = g.did`, 113 + placeholderStr, placeholderStr) 114 + 115 + result := make(map[string]FollowStats) 116 + 117 + rows, err := e.Query(query, args...) 118 + if err != nil { 119 + return nil, err 120 + } 121 + defer rows.Close() 122 + 123 + for rows.Next() { 124 + var did string 125 + var followers, following int64 126 + if err := rows.Scan(&did, &followers, &following); err != nil { 127 + return nil, err 128 + } 129 + result[did] = FollowStats{ 130 + Followers: followers, 131 + Following: following, 132 + } 133 + } 134 + 135 + for _, did := range dids { 136 + if _, exists := result[did]; !exists { 137 + result[did] = FollowStats{ 138 + Followers: 0, 139 + Following: 0, 140 + } 141 + } 97 142 } 143 + 144 + return result, nil 98 145 } 99 146 100 - func GetAllFollows(e Execer, limit int) ([]Follow, error) { 147 + func GetFollows(e Execer, limit int, filters ...filter) ([]Follow, error) { 101 148 var follows []Follow 102 149 103 - rows, err := e.Query(` 104 - select user_did, subject_did, followed_at, rkey 150 + var conditions []string 151 + var args []any 152 + for _, filter := range filters { 153 + conditions = append(conditions, filter.Condition()) 154 + args = append(args, filter.Arg()...) 155 + } 156 + 157 + whereClause := "" 158 + if conditions != nil { 159 + whereClause = " where " + strings.Join(conditions, " and ") 160 + } 161 + limitClause := "" 162 + if limit > 0 { 163 + limitClause = " limit ?" 164 + args = append(args, limit) 165 + } 166 + 167 + query := fmt.Sprintf( 168 + `select user_did, subject_did, followed_at, rkey 105 169 from follows 170 + %s 106 171 order by followed_at desc 107 - limit ?`, limit, 108 - ) 172 + %s 173 + `, whereClause, limitClause) 174 + 175 + rows, err := e.Query(query, args...) 109 176 if err != nil { 110 177 return nil, err 111 178 } 112 - defer rows.Close() 113 - 114 179 for rows.Next() { 115 180 var follow Follow 116 181 var followedAt string 117 - if err := rows.Scan(&follow.UserDid, &follow.SubjectDid, &followedAt, &follow.Rkey); err != nil { 182 + err := rows.Scan( 183 + &follow.UserDid, 184 + &follow.SubjectDid, 185 + &followedAt, 186 + &follow.Rkey, 187 + ) 188 + if err != nil { 118 189 return nil, err 119 190 } 120 - 121 191 followedAtTime, err := time.Parse(time.RFC3339, followedAt) 122 192 if err != nil { 123 193 log.Println("unable to determine followed at time") ··· 125 195 } else { 126 196 follow.FollowedAt = followedAtTime 127 197 } 128 - 129 198 follows = append(follows, follow) 130 199 } 200 + return follows, nil 201 + } 131 202 132 - if err := rows.Err(); err != nil { 133 - return nil, err 203 + func GetFollowers(e Execer, did string) ([]Follow, error) { 204 + return GetFollows(e, 0, FilterEq("subject_did", did)) 205 + } 206 + 207 + func GetFollowing(e Execer, did string) ([]Follow, error) { 208 + return GetFollows(e, 0, FilterEq("user_did", did)) 209 + } 210 + 211 + type FollowStatus int 212 + 213 + const ( 214 + IsNotFollowing FollowStatus = iota 215 + IsFollowing 216 + IsSelf 217 + ) 218 + 219 + func (s FollowStatus) String() string { 220 + switch s { 221 + case IsNotFollowing: 222 + return "IsNotFollowing" 223 + case IsFollowing: 224 + return "IsFollowing" 225 + case IsSelf: 226 + return "IsSelf" 227 + default: 228 + return "IsNotFollowing" 134 229 } 230 + } 135 231 136 - return follows, nil 232 + func GetFollowStatus(e Execer, userDid, subjectDid string) FollowStatus { 233 + if userDid == subjectDid { 234 + return IsSelf 235 + } else if _, err := GetFollow(e, userDid, subjectDid); err != nil { 236 + return IsNotFollowing 237 + } else { 238 + return IsFollowing 239 + } 137 240 }
+459 -306
appview/db/issues.go
··· 2 2 3 3 import ( 4 4 "database/sql" 5 + "fmt" 6 + "maps" 7 + "slices" 8 + "sort" 9 + "strings" 5 10 "time" 6 11 7 12 "github.com/bluesky-social/indigo/atproto/syntax" 13 + "tangled.sh/tangled.sh/core/api/tangled" 8 14 "tangled.sh/tangled.sh/core/appview/pagination" 9 15 ) 10 16 11 17 type Issue struct { 12 - RepoAt syntax.ATURI 13 - OwnerDid string 14 - IssueId int 15 - IssueAt string 16 - Created time.Time 17 - Title string 18 - Body string 19 - Open bool 18 + Id int64 19 + Did string 20 + Rkey string 21 + RepoAt syntax.ATURI 22 + IssueId int 23 + Created time.Time 24 + Edited *time.Time 25 + Deleted *time.Time 26 + Title string 27 + Body string 28 + Open bool 20 29 21 30 // optionally, populate this when querying for reverse mappings 22 31 // like comment counts, parent repo etc. 23 - Metadata *IssueMetadata 32 + Comments []IssueComment 33 + Repo *Repo 24 34 } 25 35 26 - type IssueMetadata struct { 27 - CommentCount int 28 - Repo *Repo 29 - // labels, assignee etc. 36 + func (i *Issue) AtUri() syntax.ATURI { 37 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.Did, tangled.RepoIssueNSID, i.Rkey)) 30 38 } 31 39 32 - type Comment struct { 33 - OwnerDid string 34 - RepoAt syntax.ATURI 35 - Rkey string 36 - Issue int 37 - CommentId int 38 - Body string 39 - Created *time.Time 40 - Deleted *time.Time 41 - Edited *time.Time 40 + func (i *Issue) AsRecord() tangled.RepoIssue { 41 + return tangled.RepoIssue{ 42 + Repo: i.RepoAt.String(), 43 + Title: i.Title, 44 + Body: &i.Body, 45 + CreatedAt: i.Created.Format(time.RFC3339), 46 + } 42 47 } 43 48 44 - func NewIssue(tx *sql.Tx, issue *Issue) error { 45 - defer tx.Rollback() 49 + func (i *Issue) State() string { 50 + if i.Open { 51 + return "open" 52 + } 53 + return "closed" 54 + } 46 55 47 - _, err := tx.Exec(` 48 - insert or ignore into repo_issue_seqs (repo_at, next_issue_id) 49 - values (?, 1) 50 - `, issue.RepoAt) 51 - if err != nil { 52 - return err 56 + type CommentListItem struct { 57 + Self *IssueComment 58 + Replies []*IssueComment 59 + } 60 + 61 + func (i *Issue) CommentList() []CommentListItem { 62 + // Create a map to quickly find comments by their aturi 63 + toplevel := make(map[string]*CommentListItem) 64 + var replies []*IssueComment 65 + 66 + // collect top level comments into the map 67 + for _, comment := range i.Comments { 68 + if comment.IsTopLevel() { 69 + toplevel[comment.AtUri().String()] = &CommentListItem{ 70 + Self: &comment, 71 + } 72 + } else { 73 + replies = append(replies, &comment) 74 + } 53 75 } 54 76 55 - var nextId int 56 - err = tx.QueryRow(` 57 - update repo_issue_seqs 58 - set next_issue_id = next_issue_id + 1 59 - where repo_at = ? 60 - returning next_issue_id - 1 61 - `, issue.RepoAt).Scan(&nextId) 62 - if err != nil { 63 - return err 77 + for _, r := range replies { 78 + parentAt := *r.ReplyTo 79 + if parent, exists := toplevel[parentAt]; exists { 80 + parent.Replies = append(parent.Replies, r) 81 + } 64 82 } 65 83 66 - issue.IssueId = nextId 84 + var listing []CommentListItem 85 + for _, v := range toplevel { 86 + listing = append(listing, *v) 87 + } 67 88 68 - _, err = tx.Exec(` 69 - insert into issues (repo_at, owner_did, issue_id, title, body) 70 - values (?, ?, ?, ?, ?) 71 - `, issue.RepoAt, issue.OwnerDid, issue.IssueId, issue.Title, issue.Body) 89 + // sort everything 90 + sortFunc := func(a, b *IssueComment) bool { 91 + return a.Created.Before(b.Created) 92 + } 93 + sort.Slice(listing, func(i, j int) bool { 94 + return sortFunc(listing[i].Self, listing[j].Self) 95 + }) 96 + for _, r := range listing { 97 + sort.Slice(r.Replies, func(i, j int) bool { 98 + return sortFunc(r.Replies[i], r.Replies[j]) 99 + }) 100 + } 101 + 102 + return listing 103 + } 104 + 105 + func IssueFromRecord(did, rkey string, record tangled.RepoIssue) Issue { 106 + created, err := time.Parse(time.RFC3339, record.CreatedAt) 72 107 if err != nil { 73 - return err 108 + created = time.Now() 74 109 } 75 110 76 - if err := tx.Commit(); err != nil { 77 - return err 111 + body := "" 112 + if record.Body != nil { 113 + body = *record.Body 78 114 } 79 115 80 - return nil 116 + return Issue{ 117 + RepoAt: syntax.ATURI(record.Repo), 118 + Did: did, 119 + Rkey: rkey, 120 + Created: created, 121 + Title: record.Title, 122 + Body: body, 123 + Open: true, // new issues are open by default 124 + } 81 125 } 82 126 83 - func SetIssueAt(e Execer, repoAt syntax.ATURI, issueId int, issueAt string) error { 84 - _, err := e.Exec(`update issues set issue_at = ? where repo_at = ? and issue_id = ?`, issueAt, repoAt, issueId) 85 - return err 127 + type IssueComment struct { 128 + Id int64 129 + Did string 130 + Rkey string 131 + IssueAt string 132 + ReplyTo *string 133 + Body string 134 + Created time.Time 135 + Edited *time.Time 136 + Deleted *time.Time 86 137 } 87 138 88 - func GetIssueAt(e Execer, repoAt syntax.ATURI, issueId int) (string, error) { 89 - var issueAt string 90 - err := e.QueryRow(`select issue_at from issues where repo_at = ? and issue_id = ?`, repoAt, issueId).Scan(&issueAt) 91 - return issueAt, err 139 + func (i *IssueComment) AtUri() syntax.ATURI { 140 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.Did, tangled.RepoIssueCommentNSID, i.Rkey)) 92 141 } 93 142 94 - func GetIssueId(e Execer, repoAt syntax.ATURI) (int, error) { 95 - var issueId int 96 - err := e.QueryRow(`select next_issue_id from repo_issue_seqs where repo_at = ?`, repoAt).Scan(&issueId) 97 - return issueId - 1, err 143 + func (i *IssueComment) AsRecord() tangled.RepoIssueComment { 144 + return tangled.RepoIssueComment{ 145 + Body: i.Body, 146 + Issue: i.IssueAt, 147 + CreatedAt: i.Created.Format(time.RFC3339), 148 + ReplyTo: i.ReplyTo, 149 + } 98 150 } 99 151 100 - func GetIssueOwnerDid(e Execer, repoAt syntax.ATURI, issueId int) (string, error) { 101 - var ownerDid string 102 - err := e.QueryRow(`select owner_did from issues where repo_at = ? and issue_id = ?`, repoAt, issueId).Scan(&ownerDid) 103 - return ownerDid, err 152 + func (i *IssueComment) IsTopLevel() bool { 153 + return i.ReplyTo == nil 104 154 } 105 155 106 - func GetIssues(e Execer, repoAt syntax.ATURI, isOpen bool, page pagination.Page) ([]Issue, error) { 107 - var issues []Issue 108 - openValue := 0 109 - if isOpen { 110 - openValue = 1 156 + func IssueCommentFromRecord(e Execer, did, rkey string, record tangled.RepoIssueComment) (*IssueComment, error) { 157 + created, err := time.Parse(time.RFC3339, record.CreatedAt) 158 + if err != nil { 159 + created = time.Now() 111 160 } 112 161 113 - rows, err := e.Query( 114 - ` 115 - with numbered_issue as ( 116 - select 117 - i.owner_did, 118 - i.issue_id, 119 - i.created, 120 - i.title, 121 - i.body, 122 - i.open, 123 - count(c.id) as comment_count, 124 - row_number() over (order by i.created desc) as row_num 125 - from 126 - issues i 127 - left join 128 - comments c on i.repo_at = c.repo_at and i.issue_id = c.issue_id 129 - where 130 - i.repo_at = ? and i.open = ? 131 - group by 132 - i.id, i.owner_did, i.issue_id, i.created, i.title, i.body, i.open 133 - ) 134 - select 135 - owner_did, 136 - issue_id, 137 - created, 138 - title, 139 - body, 140 - open, 141 - comment_count 142 - from 143 - numbered_issue 144 - where 145 - row_num between ? and ?`, 146 - repoAt, openValue, page.Offset+1, page.Offset+page.Limit) 147 - if err != nil { 162 + ownerDid := did 163 + 164 + if _, err = syntax.ParseATURI(record.Issue); err != nil { 148 165 return nil, err 149 166 } 150 - defer rows.Close() 151 167 152 - for rows.Next() { 153 - var issue Issue 154 - var createdAt string 155 - var metadata IssueMetadata 156 - err := rows.Scan(&issue.OwnerDid, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open, &metadata.CommentCount) 157 - if err != nil { 158 - return nil, err 159 - } 168 + comment := IssueComment{ 169 + Did: ownerDid, 170 + Rkey: rkey, 171 + Body: record.Body, 172 + IssueAt: record.Issue, 173 + ReplyTo: record.ReplyTo, 174 + Created: created, 175 + } 160 176 161 - createdTime, err := time.Parse(time.RFC3339, createdAt) 162 - if err != nil { 163 - return nil, err 177 + return &comment, nil 178 + } 179 + 180 + func PutIssue(tx *sql.Tx, issue *Issue) error { 181 + // ensure sequence exists 182 + _, err := tx.Exec(` 183 + insert or ignore into repo_issue_seqs (repo_at, next_issue_id) 184 + values (?, 1) 185 + `, issue.RepoAt) 186 + if err != nil { 187 + return err 188 + } 189 + 190 + issues, err := GetIssues( 191 + tx, 192 + FilterEq("did", issue.Did), 193 + FilterEq("rkey", issue.Rkey), 194 + ) 195 + switch { 196 + case err != nil: 197 + return err 198 + case len(issues) == 0: 199 + return createNewIssue(tx, issue) 200 + case len(issues) != 1: // should be unreachable 201 + return fmt.Errorf("invalid number of issues returned: %d", len(issues)) 202 + default: 203 + // if content is identical, do not edit 204 + existingIssue := issues[0] 205 + if existingIssue.Title == issue.Title && existingIssue.Body == issue.Body { 206 + return nil 164 207 } 165 - issue.Created = createdTime 166 - issue.Metadata = &metadata 167 208 168 - issues = append(issues, issue) 209 + issue.Id = existingIssue.Id 210 + issue.IssueId = existingIssue.IssueId 211 + return updateIssue(tx, issue) 169 212 } 213 + } 170 214 171 - if err := rows.Err(); err != nil { 172 - return nil, err 215 + func createNewIssue(tx *sql.Tx, issue *Issue) error { 216 + // get next issue_id 217 + var newIssueId int 218 + err := tx.QueryRow(` 219 + update repo_issue_seqs 220 + set next_issue_id = next_issue_id + 1 221 + where repo_at = ? 222 + returning next_issue_id - 1 223 + `, issue.RepoAt).Scan(&newIssueId) 224 + if err != nil { 225 + return err 173 226 } 174 227 175 - return issues, nil 228 + // insert new issue 229 + row := tx.QueryRow(` 230 + insert into issues (repo_at, did, rkey, issue_id, title, body) 231 + values (?, ?, ?, ?, ?, ?) 232 + returning rowid, issue_id 233 + `, issue.RepoAt, issue.Did, issue.Rkey, newIssueId, issue.Title, issue.Body) 234 + 235 + return row.Scan(&issue.Id, &issue.IssueId) 176 236 } 177 237 178 - // timeframe here is directly passed into the sql query filter, and any 179 - // timeframe in the past should be negative; e.g.: "-3 months" 180 - func GetIssuesByOwnerDid(e Execer, ownerDid string, timeframe string) ([]Issue, error) { 181 - var issues []Issue 238 + func updateIssue(tx *sql.Tx, issue *Issue) error { 239 + // update existing issue 240 + _, err := tx.Exec(` 241 + update issues 242 + set title = ?, body = ?, edited = ? 243 + where did = ? and rkey = ? 244 + `, issue.Title, issue.Body, time.Now().Format(time.RFC3339), issue.Did, issue.Rkey) 245 + return err 246 + } 247 + 248 + func GetIssuesPaginated(e Execer, page pagination.Page, filters ...filter) ([]Issue, error) { 249 + issueMap := make(map[string]*Issue) // at-uri -> issue 250 + 251 + var conditions []string 252 + var args []any 253 + 254 + for _, filter := range filters { 255 + conditions = append(conditions, filter.Condition()) 256 + args = append(args, filter.Arg()...) 257 + } 258 + 259 + whereClause := "" 260 + if conditions != nil { 261 + whereClause = " where " + strings.Join(conditions, " and ") 262 + } 263 + 264 + pLower := FilterGte("row_num", page.Offset+1) 265 + pUpper := FilterLte("row_num", page.Offset+page.Limit) 266 + 267 + args = append(args, pLower.Arg()...) 268 + args = append(args, pUpper.Arg()...) 269 + pagination := " where " + pLower.Condition() + " and " + pUpper.Condition() 270 + 271 + query := fmt.Sprintf( 272 + ` 273 + select * from ( 274 + select 275 + id, 276 + did, 277 + rkey, 278 + repo_at, 279 + issue_id, 280 + title, 281 + body, 282 + open, 283 + created, 284 + edited, 285 + deleted, 286 + row_number() over (order by created desc) as row_num 287 + from 288 + issues 289 + %s 290 + ) ranked_issues 291 + %s 292 + `, 293 + whereClause, 294 + pagination, 295 + ) 182 296 183 - rows, err := e.Query( 184 - `select 185 - i.owner_did, 186 - i.repo_at, 187 - i.issue_id, 188 - i.created, 189 - i.title, 190 - i.body, 191 - i.open, 192 - r.did, 193 - r.name, 194 - r.knot, 195 - r.rkey, 196 - r.created 197 - from 198 - issues i 199 - join 200 - repos r on i.repo_at = r.at_uri 201 - where 202 - i.owner_did = ? and i.created >= date ('now', ?) 203 - order by 204 - i.created desc`, 205 - ownerDid, timeframe) 297 + rows, err := e.Query(query, args...) 206 298 if err != nil { 207 - return nil, err 299 + return nil, fmt.Errorf("failed to query issues table: %w", err) 208 300 } 209 301 defer rows.Close() 210 302 211 303 for rows.Next() { 212 304 var issue Issue 213 - var issueCreatedAt, repoCreatedAt string 214 - var repo Repo 305 + var createdAt string 306 + var editedAt, deletedAt sql.Null[string] 307 + var rowNum int64 215 308 err := rows.Scan( 216 - &issue.OwnerDid, 309 + &issue.Id, 310 + &issue.Did, 311 + &issue.Rkey, 217 312 &issue.RepoAt, 218 313 &issue.IssueId, 219 - &issueCreatedAt, 220 314 &issue.Title, 221 315 &issue.Body, 222 316 &issue.Open, 223 - &repo.Did, 224 - &repo.Name, 225 - &repo.Knot, 226 - &repo.Rkey, 227 - &repoCreatedAt, 317 + &createdAt, 318 + &editedAt, 319 + &deletedAt, 320 + &rowNum, 228 321 ) 229 322 if err != nil { 230 - return nil, err 323 + return nil, fmt.Errorf("failed to scan issue: %w", err) 231 324 } 232 325 233 - issueCreatedTime, err := time.Parse(time.RFC3339, issueCreatedAt) 234 - if err != nil { 235 - return nil, err 326 + if t, err := time.Parse(time.RFC3339, createdAt); err == nil { 327 + issue.Created = t 236 328 } 237 - issue.Created = issueCreatedTime 238 329 239 - repoCreatedTime, err := time.Parse(time.RFC3339, repoCreatedAt) 240 - if err != nil { 241 - return nil, err 330 + if editedAt.Valid { 331 + if t, err := time.Parse(time.RFC3339, editedAt.V); err == nil { 332 + issue.Edited = &t 333 + } 242 334 } 243 - repo.Created = repoCreatedTime 244 335 245 - issue.Metadata = &IssueMetadata{ 246 - Repo: &repo, 336 + if deletedAt.Valid { 337 + if t, err := time.Parse(time.RFC3339, deletedAt.V); err == nil { 338 + issue.Deleted = &t 339 + } 247 340 } 248 341 249 - issues = append(issues, issue) 342 + atUri := issue.AtUri().String() 343 + issueMap[atUri] = &issue 250 344 } 251 345 252 - if err := rows.Err(); err != nil { 253 - return nil, err 346 + // collect reverse repos 347 + repoAts := make([]string, 0, len(issueMap)) // or just []string{} 348 + for _, issue := range issueMap { 349 + repoAts = append(repoAts, string(issue.RepoAt)) 350 + } 351 + 352 + repos, err := GetRepos(e, 0, FilterIn("at_uri", repoAts)) 353 + if err != nil { 354 + return nil, fmt.Errorf("failed to build repo mappings: %w", err) 355 + } 356 + 357 + repoMap := make(map[string]*Repo) 358 + for i := range repos { 359 + repoMap[string(repos[i].RepoAt())] = &repos[i] 360 + } 361 + 362 + for issueAt, i := range issueMap { 363 + if r, ok := repoMap[string(i.RepoAt)]; ok { 364 + i.Repo = r 365 + } else { 366 + // do not show up the issue if the repo is deleted 367 + // TODO: foreign key where? 368 + delete(issueMap, issueAt) 369 + } 370 + } 371 + 372 + // collect comments 373 + issueAts := slices.Collect(maps.Keys(issueMap)) 374 + comments, err := GetIssueComments(e, FilterIn("issue_at", issueAts)) 375 + if err != nil { 376 + return nil, fmt.Errorf("failed to query comments: %w", err) 377 + } 378 + 379 + for i := range comments { 380 + issueAt := comments[i].IssueAt 381 + if issue, ok := issueMap[issueAt]; ok { 382 + issue.Comments = append(issue.Comments, comments[i]) 383 + } 384 + } 385 + 386 + var issues []Issue 387 + for _, i := range issueMap { 388 + issues = append(issues, *i) 254 389 } 390 + 391 + sort.Slice(issues, func(i, j int) bool { 392 + return issues[i].Created.After(issues[j].Created) 393 + }) 255 394 256 395 return issues, nil 257 396 } 258 397 398 + func GetIssues(e Execer, filters ...filter) ([]Issue, error) { 399 + return GetIssuesPaginated(e, pagination.FirstPage(), filters...) 400 + } 401 + 259 402 func GetIssue(e Execer, repoAt syntax.ATURI, issueId int) (*Issue, error) { 260 - query := `select owner_did, created, title, body, open from issues where repo_at = ? and issue_id = ?` 403 + query := `select id, owner_did, rkey, created, title, body, open from issues where repo_at = ? and issue_id = ?` 261 404 row := e.QueryRow(query, repoAt, issueId) 262 405 263 406 var issue Issue 264 407 var createdAt string 265 - err := row.Scan(&issue.OwnerDid, &createdAt, &issue.Title, &issue.Body, &issue.Open) 408 + err := row.Scan(&issue.Id, &issue.Did, &issue.Rkey, &createdAt, &issue.Title, &issue.Body, &issue.Open) 266 409 if err != nil { 267 410 return nil, err 268 411 } ··· 276 419 return &issue, nil 277 420 } 278 421 279 - func GetIssueWithComments(e Execer, repoAt syntax.ATURI, issueId int) (*Issue, []Comment, error) { 280 - query := `select owner_did, issue_id, created, title, body, open from issues where repo_at = ? and issue_id = ?` 281 - row := e.QueryRow(query, repoAt, issueId) 422 + func AddIssueComment(e Execer, c IssueComment) (int64, error) { 423 + result, err := e.Exec( 424 + `insert into issue_comments ( 425 + did, 426 + rkey, 427 + issue_at, 428 + body, 429 + reply_to, 430 + created, 431 + edited 432 + ) 433 + values (?, ?, ?, ?, ?, ?, null) 434 + on conflict(did, rkey) do update set 435 + issue_at = excluded.issue_at, 436 + body = excluded.body, 437 + edited = case 438 + when 439 + issue_comments.issue_at != excluded.issue_at 440 + or issue_comments.body != excluded.body 441 + or issue_comments.reply_to != excluded.reply_to 442 + then ? 443 + else issue_comments.edited 444 + end`, 445 + c.Did, 446 + c.Rkey, 447 + c.IssueAt, 448 + c.Body, 449 + c.ReplyTo, 450 + c.Created.Format(time.RFC3339), 451 + time.Now().Format(time.RFC3339), 452 + ) 453 + if err != nil { 454 + return 0, err 455 + } 282 456 283 - var issue Issue 284 - var createdAt string 285 - err := row.Scan(&issue.OwnerDid, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open) 457 + id, err := result.LastInsertId() 286 458 if err != nil { 287 - return nil, nil, err 459 + return 0, err 288 460 } 289 461 290 - createdTime, err := time.Parse(time.RFC3339, createdAt) 291 - if err != nil { 292 - return nil, nil, err 462 + return id, nil 463 + } 464 + 465 + func DeleteIssueComments(e Execer, filters ...filter) error { 466 + var conditions []string 467 + var args []any 468 + for _, filter := range filters { 469 + conditions = append(conditions, filter.Condition()) 470 + args = append(args, filter.Arg()...) 293 471 } 294 - issue.Created = createdTime 295 472 296 - comments, err := GetComments(e, repoAt, issueId) 297 - if err != nil { 298 - return nil, nil, err 473 + whereClause := "" 474 + if conditions != nil { 475 + whereClause = " where " + strings.Join(conditions, " and ") 299 476 } 300 477 301 - return &issue, comments, nil 302 - } 478 + query := fmt.Sprintf(`update issue_comments set body = "", deleted = strftime('%%Y-%%m-%%dT%%H:%%M:%%SZ', 'now') %s`, whereClause) 303 479 304 - func NewIssueComment(e Execer, comment *Comment) error { 305 - query := `insert into comments (owner_did, repo_at, rkey, issue_id, comment_id, body) values (?, ?, ?, ?, ?, ?)` 306 - _, err := e.Exec( 307 - query, 308 - comment.OwnerDid, 309 - comment.RepoAt, 310 - comment.Rkey, 311 - comment.Issue, 312 - comment.CommentId, 313 - comment.Body, 314 - ) 480 + _, err := e.Exec(query, args...) 315 481 return err 316 482 } 317 483 318 - func GetComments(e Execer, repoAt syntax.ATURI, issueId int) ([]Comment, error) { 319 - var comments []Comment 484 + func GetIssueComments(e Execer, filters ...filter) ([]IssueComment, error) { 485 + var comments []IssueComment 320 486 321 - rows, err := e.Query(` 487 + var conditions []string 488 + var args []any 489 + for _, filter := range filters { 490 + conditions = append(conditions, filter.Condition()) 491 + args = append(args, filter.Arg()...) 492 + } 493 + 494 + whereClause := "" 495 + if conditions != nil { 496 + whereClause = " where " + strings.Join(conditions, " and ") 497 + } 498 + 499 + query := fmt.Sprintf(` 322 500 select 323 - owner_did, 324 - issue_id, 325 - comment_id, 501 + id, 502 + did, 326 503 rkey, 504 + issue_at, 505 + reply_to, 327 506 body, 328 507 created, 329 508 edited, 330 509 deleted 331 510 from 332 - comments 333 - where 334 - repo_at = ? and issue_id = ? 335 - order by 336 - created asc`, 337 - repoAt, 338 - issueId, 339 - ) 340 - if err == sql.ErrNoRows { 341 - return []Comment{}, nil 342 - } 511 + issue_comments 512 + %s 513 + `, whereClause) 514 + 515 + rows, err := e.Query(query, args...) 343 516 if err != nil { 344 517 return nil, err 345 518 } 346 - defer rows.Close() 347 519 348 520 for rows.Next() { 349 - var comment Comment 350 - var createdAt string 351 - var deletedAt, editedAt, rkey sql.NullString 352 - err := rows.Scan(&comment.OwnerDid, &comment.Issue, &comment.CommentId, &rkey, &comment.Body, &createdAt, &editedAt, &deletedAt) 521 + var comment IssueComment 522 + var created string 523 + var rkey, edited, deleted, replyTo sql.Null[string] 524 + err := rows.Scan( 525 + &comment.Id, 526 + &comment.Did, 527 + &rkey, 528 + &comment.IssueAt, 529 + &replyTo, 530 + &comment.Body, 531 + &created, 532 + &edited, 533 + &deleted, 534 + ) 353 535 if err != nil { 354 536 return nil, err 355 537 } 356 538 357 - createdAtTime, err := time.Parse(time.RFC3339, createdAt) 358 - if err != nil { 359 - return nil, err 539 + // this is a remnant from old times, newer comments always have rkey 540 + if rkey.Valid { 541 + comment.Rkey = rkey.V 360 542 } 361 - comment.Created = &createdAtTime 362 543 363 - if deletedAt.Valid { 364 - deletedTime, err := time.Parse(time.RFC3339, deletedAt.String) 365 - if err != nil { 366 - return nil, err 544 + if t, err := time.Parse(time.RFC3339, created); err == nil { 545 + comment.Created = t 546 + } 547 + 548 + if edited.Valid { 549 + if t, err := time.Parse(time.RFC3339, edited.V); err == nil { 550 + comment.Edited = &t 367 551 } 368 - comment.Deleted = &deletedTime 369 552 } 370 553 371 - if editedAt.Valid { 372 - editedTime, err := time.Parse(time.RFC3339, editedAt.String) 373 - if err != nil { 374 - return nil, err 554 + if deleted.Valid { 555 + if t, err := time.Parse(time.RFC3339, deleted.V); err == nil { 556 + comment.Deleted = &t 375 557 } 376 - comment.Edited = &editedTime 377 558 } 378 559 379 - if rkey.Valid { 380 - comment.Rkey = rkey.String 560 + if replyTo.Valid { 561 + comment.ReplyTo = &replyTo.V 381 562 } 382 563 383 564 comments = append(comments, comment) 384 565 } 385 566 386 - if err := rows.Err(); err != nil { 567 + if err = rows.Err(); err != nil { 387 568 return nil, err 388 569 } 389 570 390 571 return comments, nil 391 572 } 392 573 393 - func GetComment(e Execer, repoAt syntax.ATURI, issueId, commentId int) (*Comment, error) { 394 - query := ` 395 - select 396 - owner_did, body, rkey, created, deleted, edited 397 - from 398 - comments where repo_at = ? and issue_id = ? and comment_id = ? 399 - ` 400 - row := e.QueryRow(query, repoAt, issueId, commentId) 401 - 402 - var comment Comment 403 - var createdAt string 404 - var deletedAt, editedAt, rkey sql.NullString 405 - err := row.Scan(&comment.OwnerDid, &comment.Body, &rkey, &createdAt, &deletedAt, &editedAt) 406 - if err != nil { 407 - return nil, err 574 + func DeleteIssues(e Execer, filters ...filter) error { 575 + var conditions []string 576 + var args []any 577 + for _, filter := range filters { 578 + conditions = append(conditions, filter.Condition()) 579 + args = append(args, filter.Arg()...) 408 580 } 409 581 410 - createdTime, err := time.Parse(time.RFC3339, createdAt) 411 - if err != nil { 412 - return nil, err 582 + whereClause := "" 583 + if conditions != nil { 584 + whereClause = " where " + strings.Join(conditions, " and ") 413 585 } 414 - comment.Created = &createdTime 415 586 416 - if deletedAt.Valid { 417 - deletedTime, err := time.Parse(time.RFC3339, deletedAt.String) 418 - if err != nil { 419 - return nil, err 420 - } 421 - comment.Deleted = &deletedTime 422 - } 587 + query := fmt.Sprintf(`delete from issues %s`, whereClause) 588 + _, err := e.Exec(query, args...) 589 + return err 590 + } 423 591 424 - if editedAt.Valid { 425 - editedTime, err := time.Parse(time.RFC3339, editedAt.String) 426 - if err != nil { 427 - return nil, err 428 - } 429 - comment.Edited = &editedTime 592 + func CloseIssues(e Execer, filters ...filter) error { 593 + var conditions []string 594 + var args []any 595 + for _, filter := range filters { 596 + conditions = append(conditions, filter.Condition()) 597 + args = append(args, filter.Arg()...) 430 598 } 431 599 432 - if rkey.Valid { 433 - comment.Rkey = rkey.String 600 + whereClause := "" 601 + if conditions != nil { 602 + whereClause = " where " + strings.Join(conditions, " and ") 434 603 } 435 604 436 - comment.RepoAt = repoAt 437 - comment.Issue = issueId 438 - comment.CommentId = commentId 439 - 440 - return &comment, nil 441 - } 442 - 443 - func EditComment(e Execer, repoAt syntax.ATURI, issueId, commentId int, newBody string) error { 444 - _, err := e.Exec( 445 - ` 446 - update comments 447 - set body = ?, 448 - edited = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') 449 - where repo_at = ? and issue_id = ? and comment_id = ? 450 - `, newBody, repoAt, issueId, commentId) 605 + query := fmt.Sprintf(`update issues set open = 0 %s`, whereClause) 606 + _, err := e.Exec(query, args...) 451 607 return err 452 608 } 453 609 454 - func DeleteComment(e Execer, repoAt syntax.ATURI, issueId, commentId int) error { 455 - _, err := e.Exec( 456 - ` 457 - update comments 458 - set body = "", 459 - deleted = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') 460 - where repo_at = ? and issue_id = ? and comment_id = ? 461 - `, repoAt, issueId, commentId) 462 - return err 463 - } 610 + func ReopenIssues(e Execer, filters ...filter) error { 611 + var conditions []string 612 + var args []any 613 + for _, filter := range filters { 614 + conditions = append(conditions, filter.Condition()) 615 + args = append(args, filter.Arg()...) 616 + } 464 617 465 - func CloseIssue(e Execer, repoAt syntax.ATURI, issueId int) error { 466 - _, err := e.Exec(`update issues set open = 0 where repo_at = ? and issue_id = ?`, repoAt, issueId) 467 - return err 468 - } 618 + whereClause := "" 619 + if conditions != nil { 620 + whereClause = " where " + strings.Join(conditions, " and ") 621 + } 469 622 470 - func ReopenIssue(e Execer, repoAt syntax.ATURI, issueId int) error { 471 - _, err := e.Exec(`update issues set open = 1 where repo_at = ? and issue_id = ?`, repoAt, issueId) 623 + query := fmt.Sprintf(`update issues set open = 1 %s`, whereClause) 624 + _, err := e.Exec(query, args...) 472 625 return err 473 626 } 474 627
+93
appview/db/language.go
··· 1 + package db 2 + 3 + import ( 4 + "fmt" 5 + "strings" 6 + 7 + "github.com/bluesky-social/indigo/atproto/syntax" 8 + ) 9 + 10 + type RepoLanguage struct { 11 + Id int64 12 + RepoAt syntax.ATURI 13 + Ref string 14 + IsDefaultRef bool 15 + Language string 16 + Bytes int64 17 + } 18 + 19 + func GetRepoLanguages(e Execer, filters ...filter) ([]RepoLanguage, error) { 20 + var conditions []string 21 + var args []any 22 + for _, filter := range filters { 23 + conditions = append(conditions, filter.Condition()) 24 + args = append(args, filter.Arg()...) 25 + } 26 + 27 + whereClause := "" 28 + if conditions != nil { 29 + whereClause = " where " + strings.Join(conditions, " and ") 30 + } 31 + 32 + query := fmt.Sprintf( 33 + `select id, repo_at, ref, is_default_ref, language, bytes from repo_languages %s`, 34 + whereClause, 35 + ) 36 + rows, err := e.Query(query, args...) 37 + 38 + if err != nil { 39 + return nil, fmt.Errorf("failed to execute query: %w ", err) 40 + } 41 + 42 + var langs []RepoLanguage 43 + for rows.Next() { 44 + var rl RepoLanguage 45 + var isDefaultRef int 46 + 47 + err := rows.Scan( 48 + &rl.Id, 49 + &rl.RepoAt, 50 + &rl.Ref, 51 + &isDefaultRef, 52 + &rl.Language, 53 + &rl.Bytes, 54 + ) 55 + if err != nil { 56 + return nil, fmt.Errorf("failed to scan: %w ", err) 57 + } 58 + 59 + if isDefaultRef != 0 { 60 + rl.IsDefaultRef = true 61 + } 62 + 63 + langs = append(langs, rl) 64 + } 65 + if err = rows.Err(); err != nil { 66 + return nil, fmt.Errorf("failed to scan rows: %w ", err) 67 + } 68 + 69 + return langs, nil 70 + } 71 + 72 + func InsertRepoLanguages(e Execer, langs []RepoLanguage) error { 73 + stmt, err := e.Prepare( 74 + "insert or replace into repo_languages (repo_at, ref, is_default_ref, language, bytes) values (?, ?, ?, ?, ?)", 75 + ) 76 + if err != nil { 77 + return err 78 + } 79 + 80 + for _, l := range langs { 81 + isDefaultRef := 0 82 + if l.IsDefaultRef { 83 + isDefaultRef = 1 84 + } 85 + 86 + _, err := stmt.Exec(l.RepoAt, l.Ref, isDefaultRef, l.Language, l.Bytes) 87 + if err != nil { 88 + return err 89 + } 90 + } 91 + 92 + return nil 93 + }
-62
appview/db/migrations/20250305_113405.sql
··· 1 - -- Simplified SQLite Database Migration Script for Issues and Comments 2 - 3 - -- Migration for issues table 4 - CREATE TABLE issues_new ( 5 - id integer primary key autoincrement, 6 - owner_did text not null, 7 - repo_at text not null, 8 - issue_id integer not null, 9 - title text not null, 10 - body text not null, 11 - open integer not null default 1, 12 - created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 13 - issue_at text, 14 - unique(repo_at, issue_id), 15 - foreign key (repo_at) references repos(at_uri) on delete cascade 16 - ); 17 - 18 - -- Migrate data to new issues table 19 - INSERT INTO issues_new ( 20 - id, owner_did, repo_at, issue_id, 21 - title, body, open, created, issue_at 22 - ) 23 - SELECT 24 - id, owner_did, repo_at, issue_id, 25 - title, body, open, created, issue_at 26 - FROM issues; 27 - 28 - -- Drop old issues table 29 - DROP TABLE issues; 30 - 31 - -- Rename new issues table 32 - ALTER TABLE issues_new RENAME TO issues; 33 - 34 - -- Migration for comments table 35 - CREATE TABLE comments_new ( 36 - id integer primary key autoincrement, 37 - owner_did text not null, 38 - issue_id integer not null, 39 - repo_at text not null, 40 - comment_id integer not null, 41 - comment_at text not null, 42 - body text not null, 43 - created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 44 - unique(issue_id, comment_id), 45 - foreign key (repo_at, issue_id) references issues(repo_at, issue_id) on delete cascade 46 - ); 47 - 48 - -- Migrate data to new comments table 49 - INSERT INTO comments_new ( 50 - id, owner_did, issue_id, repo_at, 51 - comment_id, comment_at, body, created 52 - ) 53 - SELECT 54 - id, owner_did, issue_id, repo_at, 55 - comment_id, comment_at, body, created 56 - FROM comments; 57 - 58 - -- Drop old comments table 59 - DROP TABLE comments; 60 - 61 - -- Rename new comments table 62 - ALTER TABLE comments_new RENAME TO comments;
-66
appview/db/migrations/validate.sql
··· 1 - -- Validation Queries for Database Migration 2 - 3 - -- 1. Verify Issues Table Structure 4 - PRAGMA table_info(issues); 5 - 6 - -- 2. Verify Comments Table Structure 7 - PRAGMA table_info(comments); 8 - 9 - -- 3. Check Total Row Count Consistency 10 - SELECT 11 - 'Issues Row Count' AS check_type, 12 - (SELECT COUNT(*) FROM issues) AS row_count 13 - UNION ALL 14 - SELECT 15 - 'Comments Row Count' AS check_type, 16 - (SELECT COUNT(*) FROM comments) AS row_count; 17 - 18 - -- 4. Verify Unique Constraint on Issues 19 - SELECT 20 - repo_at, 21 - issue_id, 22 - COUNT(*) as duplicate_count 23 - FROM issues 24 - GROUP BY repo_at, issue_id 25 - HAVING duplicate_count > 1; 26 - 27 - -- 5. Verify Foreign Key Integrity for Comments 28 - SELECT 29 - 'Orphaned Comments' AS check_type, 30 - COUNT(*) AS orphaned_count 31 - FROM comments c 32 - LEFT JOIN issues i ON c.repo_at = i.repo_at AND c.issue_id = i.issue_id 33 - WHERE i.id IS NULL; 34 - 35 - -- 6. Check Foreign Key Constraint 36 - PRAGMA foreign_key_list(comments); 37 - 38 - -- 7. Sample Data Integrity Check 39 - SELECT 40 - 'Sample Issues' AS check_type, 41 - repo_at, 42 - issue_id, 43 - title, 44 - created 45 - FROM issues 46 - LIMIT 5; 47 - 48 - -- 8. Sample Comments Data Integrity Check 49 - SELECT 50 - 'Sample Comments' AS check_type, 51 - repo_at, 52 - issue_id, 53 - comment_id, 54 - body, 55 - created 56 - FROM comments 57 - LIMIT 5; 58 - 59 - -- 9. Verify Constraint on Comments (Issue ID and Comment ID Uniqueness) 60 - SELECT 61 - issue_id, 62 - comment_id, 63 - COUNT(*) as duplicate_count 64 - FROM comments 65 - GROUP BY issue_id, comment_id 66 - HAVING duplicate_count > 1;
+27 -12
appview/db/pipeline.go
··· 9 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 10 "github.com/go-git/go-git/v5/plumbing" 11 11 spindle "tangled.sh/tangled.sh/core/spindle/models" 12 + "tangled.sh/tangled.sh/core/workflow" 12 13 ) 13 14 14 15 type Pipeline struct { ··· 27 28 } 28 29 29 30 type WorkflowStatus struct { 30 - data []PipelineStatus 31 + Data []PipelineStatus 31 32 } 32 33 33 34 func (w WorkflowStatus) Latest() PipelineStatus { 34 - return w.data[len(w.data)-1] 35 + return w.Data[len(w.Data)-1] 35 36 } 36 37 37 38 // time taken by this workflow to reach an "end state" 38 39 func (w WorkflowStatus) TimeTaken() time.Duration { 39 40 var start, end *time.Time 40 - for _, s := range w.data { 41 + for _, s := range w.Data { 41 42 if s.Status.IsStart() { 42 43 start = &s.Created 43 44 } ··· 78 79 return ws 79 80 } 80 81 82 + // if we know that a spindle has picked up this pipeline, then it is Responding 83 + func (p Pipeline) IsResponding() bool { 84 + return len(p.Statuses) != 0 85 + } 86 + 81 87 type Trigger struct { 82 88 Id int 83 - Kind string 89 + Kind workflow.TriggerKind 84 90 85 91 // push trigger fields 86 92 PushRef *string ··· 95 101 } 96 102 97 103 func (t *Trigger) IsPush() bool { 98 - return t != nil && t.Kind == "push" 104 + return t != nil && t.Kind == workflow.TriggerKindPush 99 105 } 100 106 101 107 func (t *Trigger) IsPullRequest() bool { 102 - return t != nil && t.Kind == "pull_request" 108 + return t != nil && t.Kind == workflow.TriggerKindPullRequest 103 109 } 104 110 105 111 func (t *Trigger) TargetRef() string { ··· 256 262 status.Status, 257 263 status.Error, 258 264 status.ExitCode, 265 + status.Created.Format(time.RFC3339), 259 266 } 260 267 261 268 placeholders := make([]string, len(args)) ··· 272 279 workflow, 273 280 status, 274 281 error, 275 - exit_code 282 + exit_code, 283 + created 276 284 ) values (%s) 277 285 `, strings.Join(placeholders, ",")) 278 286 ··· 355 363 return nil, err 356 364 } 357 365 358 - // Parse created time manually 359 366 p.Created, err = time.Parse(time.RFC3339, created) 360 367 if err != nil { 361 368 return nil, fmt.Errorf("invalid pipeline created timestamp %q: %w", created, err) 362 369 } 363 370 364 - // Link trigger to pipeline 365 371 t.Id = p.TriggerId 366 372 p.Trigger = &t 367 373 p.Statuses = make(map[string]WorkflowStatus) ··· 440 446 } 441 447 442 448 // append 443 - statuses.data = append(statuses.data, ps) 449 + statuses.Data = append(statuses.Data, ps) 444 450 445 451 // reassign 446 452 pipeline.Statuses[ps.Workflow] = statuses ··· 450 456 var all []Pipeline 451 457 for _, p := range pipelines { 452 458 for _, s := range p.Statuses { 453 - slices.SortFunc(s.data, func(a, b PipelineStatus) int { 459 + slices.SortFunc(s.Data, func(a, b PipelineStatus) int { 454 460 if a.Created.After(b.Created) { 455 461 return 1 456 462 } 457 - return -1 463 + if a.Created.Before(b.Created) { 464 + return -1 465 + } 466 + if a.ID > b.ID { 467 + return 1 468 + } 469 + if a.ID < b.ID { 470 + return -1 471 + } 472 + return 0 458 473 }) 459 474 } 460 475 all = append(all, p)
+126 -5
appview/db/profile.go
··· 22 22 ByMonth []ByMonth 23 23 } 24 24 25 + func (p *ProfileTimeline) IsEmpty() bool { 26 + if p == nil { 27 + return true 28 + } 29 + 30 + for _, m := range p.ByMonth { 31 + if !m.IsEmpty() { 32 + return false 33 + } 34 + } 35 + 36 + return true 37 + } 38 + 25 39 type ByMonth struct { 26 40 RepoEvents []RepoEvent 27 41 IssueEvents IssueEvents ··· 118 132 *items = append(*items, &pull) 119 133 } 120 134 121 - issues, err := GetIssuesByOwnerDid(e, forDid, timeframe) 135 + issues, err := GetIssues( 136 + e, 137 + FilterEq("did", forDid), 138 + FilterGte("created", time.Now().AddDate(0, -TimeframeMonths, 0)), 139 + ) 122 140 if err != nil { 123 141 return nil, fmt.Errorf("error getting issues by owner did: %w", err) 124 142 } ··· 137 155 *items = append(*items, &issue) 138 156 } 139 157 140 - repos, err := GetAllReposByDid(e, forDid) 158 + repos, err := GetRepos(e, 0, FilterEq("did", forDid)) 141 159 if err != nil { 142 160 return nil, fmt.Errorf("error getting all repos by did: %w", err) 143 161 } ··· 348 366 return tx.Commit() 349 367 } 350 368 369 + func GetProfiles(e Execer, filters ...filter) (map[string]*Profile, error) { 370 + var conditions []string 371 + var args []any 372 + for _, filter := range filters { 373 + conditions = append(conditions, filter.Condition()) 374 + args = append(args, filter.Arg()...) 375 + } 376 + 377 + whereClause := "" 378 + if conditions != nil { 379 + whereClause = " where " + strings.Join(conditions, " and ") 380 + } 381 + 382 + profilesQuery := fmt.Sprintf( 383 + `select 384 + id, 385 + did, 386 + description, 387 + include_bluesky, 388 + location 389 + from 390 + profile 391 + %s`, 392 + whereClause, 393 + ) 394 + rows, err := e.Query(profilesQuery, args...) 395 + if err != nil { 396 + return nil, err 397 + } 398 + 399 + profileMap := make(map[string]*Profile) 400 + for rows.Next() { 401 + var profile Profile 402 + var includeBluesky int 403 + 404 + err = rows.Scan(&profile.ID, &profile.Did, &profile.Description, &includeBluesky, &profile.Location) 405 + if err != nil { 406 + return nil, err 407 + } 408 + 409 + if includeBluesky != 0 { 410 + profile.IncludeBluesky = true 411 + } 412 + 413 + profileMap[profile.Did] = &profile 414 + } 415 + if err = rows.Err(); err != nil { 416 + return nil, err 417 + } 418 + 419 + // populate profile links 420 + inClause := strings.TrimSuffix(strings.Repeat("?, ", len(profileMap)), ", ") 421 + args = make([]any, len(profileMap)) 422 + i := 0 423 + for did := range profileMap { 424 + args[i] = did 425 + i++ 426 + } 427 + 428 + linksQuery := fmt.Sprintf("select link, did from profile_links where did in (%s)", inClause) 429 + rows, err = e.Query(linksQuery, args...) 430 + if err != nil { 431 + return nil, err 432 + } 433 + idxs := make(map[string]int) 434 + for did := range profileMap { 435 + idxs[did] = 0 436 + } 437 + for rows.Next() { 438 + var link, did string 439 + if err = rows.Scan(&link, &did); err != nil { 440 + return nil, err 441 + } 442 + 443 + idx := idxs[did] 444 + profileMap[did].Links[idx] = link 445 + idxs[did] = idx + 1 446 + } 447 + 448 + pinsQuery := fmt.Sprintf("select at_uri, did from profile_pinned_repositories where did in (%s)", inClause) 449 + rows, err = e.Query(pinsQuery, args...) 450 + if err != nil { 451 + return nil, err 452 + } 453 + idxs = make(map[string]int) 454 + for did := range profileMap { 455 + idxs[did] = 0 456 + } 457 + for rows.Next() { 458 + var link syntax.ATURI 459 + var did string 460 + if err = rows.Scan(&link, &did); err != nil { 461 + return nil, err 462 + } 463 + 464 + idx := idxs[did] 465 + profileMap[did].PinnedRepos[idx] = link 466 + idxs[did] = idx + 1 467 + } 468 + 469 + return profileMap, nil 470 + } 471 + 351 472 func GetProfile(e Execer, did string) (*Profile, error) { 352 473 var profile Profile 353 474 profile.Did = did ··· 432 553 query = `select count(id) from pulls where owner_did = ? and state = ?` 433 554 args = append(args, did, PullOpen) 434 555 case VanityStatOpenIssueCount: 435 - query = `select count(id) from issues where owner_did = ? and open = 1` 556 + query = `select count(id) from issues where did = ? and open = 1` 436 557 args = append(args, did) 437 558 case VanityStatClosedIssueCount: 438 - query = `select count(id) from issues where owner_did = ? and open = 0` 559 + query = `select count(id) from issues where did = ? and open = 0` 439 560 args = append(args, did) 440 561 case VanityStatRepositoryCount: 441 562 query = `select count(id) from repos where did = ?` ··· 469 590 } 470 591 471 592 // ensure all pinned repos are either own repos or collaborating repos 472 - repos, err := GetAllReposByDid(e, profile.Did) 593 + repos, err := GetRepos(e, 0, FilterEq("did", profile.Did)) 473 594 if err != nil { 474 595 log.Printf("getting repos for %s: %s", profile.Did, err) 475 596 }
+37 -11
appview/db/pulls.go
··· 87 87 if p.PullSource != nil { 88 88 s := p.PullSource.AsRecord() 89 89 source = &s 90 + source.Sha = p.LatestSha() 90 91 } 91 92 92 93 record := tangled.RepoPull{ 93 - Title: p.Title, 94 - Body: &p.Body, 95 - CreatedAt: p.Created.Format(time.RFC3339), 96 - PullId: int64(p.PullId), 97 - TargetRepo: p.RepoAt.String(), 98 - TargetBranch: p.TargetBranch, 99 - Patch: p.LatestPatch(), 100 - Source: source, 94 + Title: p.Title, 95 + Body: &p.Body, 96 + CreatedAt: p.Created.Format(time.RFC3339), 97 + Target: &tangled.RepoPull_Target{ 98 + Repo: p.RepoAt.String(), 99 + Branch: p.TargetBranch, 100 + }, 101 + Patch: p.LatestPatch(), 102 + Source: source, 101 103 } 102 104 return record 103 105 } ··· 162 164 func (p *Pull) LatestPatch() string { 163 165 latestSubmission := p.Submissions[p.LastRoundNumber()] 164 166 return latestSubmission.Patch 167 + } 168 + 169 + func (p *Pull) LatestSha() string { 170 + latestSubmission := p.Submissions[p.LastRoundNumber()] 171 + return latestSubmission.SourceRev 165 172 } 166 173 167 174 func (p *Pull) PullAt() syntax.ATURI { ··· 304 311 return pullId - 1, err 305 312 } 306 313 307 - func GetPulls(e Execer, filters ...filter) ([]*Pull, error) { 314 + func GetPullsWithLimit(e Execer, limit int, filters ...filter) ([]*Pull, error) { 308 315 pulls := make(map[int]*Pull) 309 316 310 317 var conditions []string ··· 317 324 whereClause := "" 318 325 if conditions != nil { 319 326 whereClause = " where " + strings.Join(conditions, " and ") 327 + } 328 + limitClause := "" 329 + if limit != 0 { 330 + limitClause = fmt.Sprintf(" limit %d ", limit) 320 331 } 321 332 322 333 query := fmt.Sprintf(` ··· 338 349 from 339 350 pulls 340 351 %s 341 - `, whereClause) 352 + order by 353 + created desc 354 + %s 355 + `, whereClause, limitClause) 342 356 343 357 rows, err := e.Query(query, args...) 344 358 if err != nil { ··· 406 420 inClause := strings.TrimSuffix(strings.Repeat("?, ", len(pulls)), ", ") 407 421 submissionsQuery := fmt.Sprintf(` 408 422 select 409 - id, pull_id, round_number, patch, source_rev 423 + id, pull_id, round_number, patch, created, source_rev 410 424 from 411 425 pull_submissions 412 426 where ··· 432 446 for submissionsRows.Next() { 433 447 var s PullSubmission 434 448 var sourceRev sql.NullString 449 + var createdAt string 435 450 err := submissionsRows.Scan( 436 451 &s.ID, 437 452 &s.PullId, 438 453 &s.RoundNumber, 439 454 &s.Patch, 455 + &createdAt, 440 456 &sourceRev, 441 457 ) 442 458 if err != nil { 443 459 return nil, err 444 460 } 461 + 462 + createdTime, err := time.Parse(time.RFC3339, createdAt) 463 + if err != nil { 464 + return nil, err 465 + } 466 + s.Created = createdTime 445 467 446 468 if sourceRev.Valid { 447 469 s.SourceRev = sourceRev.String ··· 505 527 }) 506 528 507 529 return orderedByPullId, nil 530 + } 531 + 532 + func GetPulls(e Execer, filters ...filter) ([]*Pull, error) { 533 + return GetPullsWithLimit(e, 0, filters...) 508 534 } 509 535 510 536 func GetPull(e Execer, repoAt syntax.ATURI, pullId int) (*Pull, error) {
+4 -4
appview/db/punchcard.go
··· 29 29 Punches []Punch 30 30 } 31 31 32 - func MakePunchcard(e Execer, filters ...filter) (Punchcard, error) { 33 - punchcard := Punchcard{} 32 + func MakePunchcard(e Execer, filters ...filter) (*Punchcard, error) { 33 + punchcard := &Punchcard{} 34 34 now := time.Now() 35 35 startOfYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC) 36 36 endOfYear := time.Date(now.Year(), 12, 31, 0, 0, 0, 0, time.UTC) ··· 63 63 64 64 rows, err := e.Query(query, args...) 65 65 if err != nil { 66 - return punchcard, err 66 + return nil, err 67 67 } 68 68 defer rows.Close() 69 69 ··· 72 72 var date string 73 73 var count sql.NullInt64 74 74 if err := rows.Scan(&date, &count); err != nil { 75 - return punchcard, err 75 + return nil, err 76 76 } 77 77 78 78 punch.Date, err = time.Parse(time.DateOnly, date)
+141
appview/db/reaction.go
··· 1 + package db 2 + 3 + import ( 4 + "log" 5 + "time" 6 + 7 + "github.com/bluesky-social/indigo/atproto/syntax" 8 + ) 9 + 10 + type ReactionKind string 11 + 12 + const ( 13 + Like ReactionKind = "๐Ÿ‘" 14 + Unlike ReactionKind = "๐Ÿ‘Ž" 15 + Laugh ReactionKind = "๐Ÿ˜†" 16 + Celebration ReactionKind = "๐ŸŽ‰" 17 + Confused ReactionKind = "๐Ÿซค" 18 + Heart ReactionKind = "โค๏ธ" 19 + Rocket ReactionKind = "๐Ÿš€" 20 + Eyes ReactionKind = "๐Ÿ‘€" 21 + ) 22 + 23 + func (rk ReactionKind) String() string { 24 + return string(rk) 25 + } 26 + 27 + var OrderedReactionKinds = []ReactionKind{ 28 + Like, 29 + Unlike, 30 + Laugh, 31 + Celebration, 32 + Confused, 33 + Heart, 34 + Rocket, 35 + Eyes, 36 + } 37 + 38 + func ParseReactionKind(raw string) (ReactionKind, bool) { 39 + k, ok := (map[string]ReactionKind{ 40 + "๐Ÿ‘": Like, 41 + "๐Ÿ‘Ž": Unlike, 42 + "๐Ÿ˜†": Laugh, 43 + "๐ŸŽ‰": Celebration, 44 + "๐Ÿซค": Confused, 45 + "โค๏ธ": Heart, 46 + "๐Ÿš€": Rocket, 47 + "๐Ÿ‘€": Eyes, 48 + })[raw] 49 + return k, ok 50 + } 51 + 52 + type Reaction struct { 53 + ReactedByDid string 54 + ThreadAt syntax.ATURI 55 + Created time.Time 56 + Rkey string 57 + Kind ReactionKind 58 + } 59 + 60 + func AddReaction(e Execer, reactedByDid string, threadAt syntax.ATURI, kind ReactionKind, rkey string) error { 61 + query := `insert or ignore into reactions (reacted_by_did, thread_at, kind, rkey) values (?, ?, ?, ?)` 62 + _, err := e.Exec(query, reactedByDid, threadAt, kind, rkey) 63 + return err 64 + } 65 + 66 + // Get a reaction record 67 + func GetReaction(e Execer, reactedByDid string, threadAt syntax.ATURI, kind ReactionKind) (*Reaction, error) { 68 + query := ` 69 + select reacted_by_did, thread_at, created, rkey 70 + from reactions 71 + where reacted_by_did = ? and thread_at = ? and kind = ?` 72 + row := e.QueryRow(query, reactedByDid, threadAt, kind) 73 + 74 + var reaction Reaction 75 + var created string 76 + err := row.Scan(&reaction.ReactedByDid, &reaction.ThreadAt, &created, &reaction.Rkey) 77 + if err != nil { 78 + return nil, err 79 + } 80 + 81 + createdAtTime, err := time.Parse(time.RFC3339, created) 82 + if err != nil { 83 + log.Println("unable to determine followed at time") 84 + reaction.Created = time.Now() 85 + } else { 86 + reaction.Created = createdAtTime 87 + } 88 + 89 + return &reaction, nil 90 + } 91 + 92 + // Remove a reaction 93 + func DeleteReaction(e Execer, reactedByDid string, threadAt syntax.ATURI, kind ReactionKind) error { 94 + _, err := e.Exec(`delete from reactions where reacted_by_did = ? and thread_at = ? and kind = ?`, reactedByDid, threadAt, kind) 95 + return err 96 + } 97 + 98 + // Remove a reaction 99 + func DeleteReactionByRkey(e Execer, reactedByDid string, rkey string) error { 100 + _, err := e.Exec(`delete from reactions where reacted_by_did = ? and rkey = ?`, reactedByDid, rkey) 101 + return err 102 + } 103 + 104 + func GetReactionCount(e Execer, threadAt syntax.ATURI, kind ReactionKind) (int, error) { 105 + count := 0 106 + err := e.QueryRow( 107 + `select count(reacted_by_did) from reactions where thread_at = ? and kind = ?`, threadAt, kind).Scan(&count) 108 + if err != nil { 109 + return 0, err 110 + } 111 + return count, nil 112 + } 113 + 114 + func GetReactionCountMap(e Execer, threadAt syntax.ATURI) (map[ReactionKind]int, error) { 115 + countMap := map[ReactionKind]int{} 116 + for _, kind := range OrderedReactionKinds { 117 + count, err := GetReactionCount(e, threadAt, kind) 118 + if err != nil { 119 + return map[ReactionKind]int{}, nil 120 + } 121 + countMap[kind] = count 122 + } 123 + return countMap, nil 124 + } 125 + 126 + func GetReactionStatus(e Execer, userDid string, threadAt syntax.ATURI, kind ReactionKind) bool { 127 + if _, err := GetReaction(e, userDid, threadAt, kind); err != nil { 128 + return false 129 + } else { 130 + return true 131 + } 132 + } 133 + 134 + func GetReactionStatusMap(e Execer, userDid string, threadAt syntax.ATURI) map[ReactionKind]bool { 135 + statusMap := map[ReactionKind]bool{} 136 + for _, kind := range OrderedReactionKinds { 137 + count := GetReactionStatus(e, userDid, threadAt, kind) 138 + statusMap[kind] = count 139 + } 140 + return statusMap 141 + }
+94 -129
appview/db/registration.go
··· 1 1 package db 2 2 3 3 import ( 4 - "crypto/rand" 5 4 "database/sql" 6 - "encoding/hex" 7 5 "fmt" 8 - "log" 6 + "strings" 9 7 "time" 10 8 ) 11 9 10 + // Registration represents a knot registration. Knot would've been a better 11 + // name but we're stuck with this for historical reasons. 12 12 type Registration struct { 13 - Domain string 14 - ByDid string 15 - Created *time.Time 16 - Registered *time.Time 13 + Id int64 14 + Domain string 15 + ByDid string 16 + Created *time.Time 17 + Registered *time.Time 18 + NeedsUpgrade bool 17 19 } 18 20 19 21 func (r *Registration) Status() Status { 20 - if r.Registered != nil { 22 + if r.NeedsUpgrade { 23 + return NeedsUpgrade 24 + } else if r.Registered != nil { 21 25 return Registered 22 26 } else { 23 27 return Pending 24 28 } 25 29 } 26 30 31 + func (r *Registration) IsRegistered() bool { 32 + return r.Status() == Registered 33 + } 34 + 35 + func (r *Registration) IsNeedsUpgrade() bool { 36 + return r.Status() == NeedsUpgrade 37 + } 38 + 39 + func (r *Registration) IsPending() bool { 40 + return r.Status() == Pending 41 + } 42 + 27 43 type Status uint32 28 44 29 45 const ( 30 46 Registered Status = iota 31 47 Pending 48 + NeedsUpgrade 32 49 ) 33 50 34 - // returns registered status, did of owner, error 35 - func RegistrationsByDid(e Execer, did string) ([]Registration, error) { 51 + func GetRegistrations(e Execer, filters ...filter) ([]Registration, error) { 36 52 var registrations []Registration 37 53 38 - rows, err := e.Query(` 39 - select domain, did, created, registered from registrations 40 - where did = ? 41 - `, did) 54 + var conditions []string 55 + var args []any 56 + for _, filter := range filters { 57 + conditions = append(conditions, filter.Condition()) 58 + args = append(args, filter.Arg()...) 59 + } 60 + 61 + whereClause := "" 62 + if conditions != nil { 63 + whereClause = " where " + strings.Join(conditions, " and ") 64 + } 65 + 66 + query := fmt.Sprintf(` 67 + select id, domain, did, created, registered, needs_upgrade 68 + from registrations 69 + %s 70 + order by created 71 + `, 72 + whereClause, 73 + ) 74 + 75 + rows, err := e.Query(query, args...) 42 76 if err != nil { 43 77 return nil, err 44 78 } 45 79 46 80 for rows.Next() { 47 - var createdAt *string 48 - var registeredAt *string 49 - var registration Registration 50 - err = rows.Scan(&registration.Domain, &registration.ByDid, &createdAt, &registeredAt) 81 + var createdAt string 82 + var registeredAt sql.Null[string] 83 + var needsUpgrade int 84 + var reg Registration 51 85 86 + err = rows.Scan(&reg.Id, &reg.Domain, &reg.ByDid, &createdAt, &registeredAt, &needsUpgrade) 52 87 if err != nil { 53 - log.Println(err) 54 - } else { 55 - createdAtTime, _ := time.Parse(time.RFC3339, *createdAt) 56 - var registeredAtTime *time.Time 57 - if registeredAt != nil { 58 - x, _ := time.Parse(time.RFC3339, *registeredAt) 59 - registeredAtTime = &x 60 - } 61 - 62 - registration.Created = &createdAtTime 63 - registration.Registered = registeredAtTime 64 - registrations = append(registrations, registration) 88 + return nil, err 65 89 } 66 - } 67 90 68 - return registrations, nil 69 - } 70 - 71 - // returns registered status, did of owner, error 72 - func RegistrationByDomain(e Execer, domain string) (*Registration, error) { 73 - var createdAt *string 74 - var registeredAt *string 75 - var registration Registration 91 + if t, err := time.Parse(time.RFC3339, createdAt); err == nil { 92 + reg.Created = &t 93 + } 76 94 77 - err := e.QueryRow(` 78 - select domain, did, created, registered from registrations 79 - where domain = ? 80 - `, domain).Scan(&registration.Domain, &registration.ByDid, &createdAt, &registeredAt) 95 + if registeredAt.Valid { 96 + if t, err := time.Parse(time.RFC3339, registeredAt.V); err == nil { 97 + reg.Registered = &t 98 + } 99 + } 81 100 82 - if err != nil { 83 - if err == sql.ErrNoRows { 84 - return nil, nil 85 - } else { 86 - return nil, err 101 + if needsUpgrade != 0 { 102 + reg.NeedsUpgrade = true 87 103 } 88 - } 89 104 90 - createdAtTime, _ := time.Parse(time.RFC3339, *createdAt) 91 - var registeredAtTime *time.Time 92 - if registeredAt != nil { 93 - x, _ := time.Parse(time.RFC3339, *registeredAt) 94 - registeredAtTime = &x 105 + registrations = append(registrations, reg) 95 106 } 96 107 97 - registration.Created = &createdAtTime 98 - registration.Registered = registeredAtTime 99 - 100 - return &registration, nil 101 - } 102 - 103 - func genSecret() string { 104 - key := make([]byte, 32) 105 - rand.Read(key) 106 - return hex.EncodeToString(key) 108 + return registrations, nil 107 109 } 108 110 109 - func GenerateRegistrationKey(e Execer, domain, did string) (string, error) { 110 - // sanity check: does this domain already have a registration? 111 - reg, err := RegistrationByDomain(e, domain) 112 - if err != nil { 113 - return "", err 114 - } 115 - 116 - // registration is open 117 - if reg != nil { 118 - switch reg.Status() { 119 - case Registered: 120 - // already registered by `owner` 121 - return "", fmt.Errorf("%s already registered by %s", domain, reg.ByDid) 122 - case Pending: 123 - // TODO: be loud about this 124 - log.Printf("%s registered by %s, status pending", domain, reg.ByDid) 125 - } 111 + func MarkRegistered(e Execer, filters ...filter) error { 112 + var conditions []string 113 + var args []any 114 + for _, filter := range filters { 115 + conditions = append(conditions, filter.Condition()) 116 + args = append(args, filter.Arg()...) 126 117 } 127 118 128 - secret := genSecret() 129 - 130 - _, err = e.Exec(` 131 - insert into registrations (domain, did, secret) 132 - values (?, ?, ?) 133 - on conflict(domain) do update set did = excluded.did, secret = excluded.secret, created = excluded.created 134 - `, domain, did, secret) 135 - 136 - if err != nil { 137 - return "", err 119 + query := "update registrations set registered = strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), needs_upgrade = 0" 120 + if len(conditions) > 0 { 121 + query += " where " + strings.Join(conditions, " and ") 138 122 } 139 123 140 - return secret, nil 124 + _, err := e.Exec(query, args...) 125 + return err 141 126 } 142 127 143 - func GetRegistrationKey(e Execer, domain string) (string, error) { 144 - res := e.QueryRow(`select secret from registrations where domain = ?`, domain) 145 - 146 - var secret string 147 - err := res.Scan(&secret) 148 - if err != nil || secret == "" { 149 - return "", err 150 - } 151 - 152 - return secret, nil 128 + func AddKnot(e Execer, domain, did string) error { 129 + _, err := e.Exec(` 130 + insert into registrations (domain, did) 131 + values (?, ?) 132 + `, domain, did) 133 + return err 153 134 } 154 135 155 - func GetCompletedRegistrations(e Execer) ([]string, error) { 156 - rows, err := e.Query(`select domain from registrations where registered not null`) 157 - if err != nil { 158 - return nil, err 136 + func DeleteKnot(e Execer, filters ...filter) error { 137 + var conditions []string 138 + var args []any 139 + for _, filter := range filters { 140 + conditions = append(conditions, filter.Condition()) 141 + args = append(args, filter.Arg()...) 159 142 } 160 143 161 - var domains []string 162 - for rows.Next() { 163 - var domain string 164 - err = rows.Scan(&domain) 165 - 166 - if err != nil { 167 - log.Println(err) 168 - } else { 169 - domains = append(domains, domain) 170 - } 144 + whereClause := "" 145 + if conditions != nil { 146 + whereClause = " where " + strings.Join(conditions, " and ") 171 147 } 172 148 173 - if err = rows.Err(); err != nil { 174 - return nil, err 175 - } 149 + query := fmt.Sprintf(`delete from registrations %s`, whereClause) 176 150 177 - return domains, nil 178 - } 179 - 180 - func Register(e Execer, domain string) error { 181 - _, err := e.Exec(` 182 - update registrations 183 - set registered = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') 184 - where domain = ?; 185 - `, domain) 186 - 151 + _, err := e.Exec(query, args...) 187 152 return err 188 153 }
+254 -173
appview/db/repos.go
··· 2 2 3 3 import ( 4 4 "database/sql" 5 + "errors" 5 6 "fmt" 7 + "log" 8 + "slices" 9 + "strings" 6 10 "time" 7 11 8 12 "github.com/bluesky-social/indigo/atproto/syntax" ··· 16 20 Knot string 17 21 Rkey string 18 22 Created time.Time 19 - AtUri string 20 23 Description string 21 24 Spindle string 22 25 ··· 36 39 return p 37 40 } 38 41 39 - func GetAllRepos(e Execer, limit int) ([]Repo, error) { 40 - var repos []Repo 42 + func GetRepos(e Execer, limit int, filters ...filter) ([]Repo, error) { 43 + repoMap := make(map[syntax.ATURI]*Repo) 44 + 45 + var conditions []string 46 + var args []any 47 + for _, filter := range filters { 48 + conditions = append(conditions, filter.Condition()) 49 + args = append(args, filter.Arg()...) 50 + } 51 + 52 + whereClause := "" 53 + if conditions != nil { 54 + whereClause = " where " + strings.Join(conditions, " and ") 55 + } 41 56 42 - rows, err := e.Query( 43 - `select did, name, knot, rkey, description, created, source 44 - from repos 57 + limitClause := "" 58 + if limit != 0 { 59 + limitClause = fmt.Sprintf(" limit %d", limit) 60 + } 61 + 62 + repoQuery := fmt.Sprintf( 63 + `select 64 + did, 65 + name, 66 + knot, 67 + rkey, 68 + created, 69 + description, 70 + source, 71 + spindle 72 + from 73 + repos r 74 + %s 45 75 order by created desc 46 - limit ? 47 - `, 48 - limit, 76 + %s`, 77 + whereClause, 78 + limitClause, 49 79 ) 80 + rows, err := e.Query(repoQuery, args...) 81 + 50 82 if err != nil { 51 - return nil, err 83 + return nil, fmt.Errorf("failed to execute repo query: %w ", err) 52 84 } 53 - defer rows.Close() 54 85 55 86 for rows.Next() { 56 87 var repo Repo 57 - err := scanRepo( 58 - rows, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &repo.Description, &repo.Created, &repo.Source, 88 + var createdAt string 89 + var description, source, spindle sql.NullString 90 + 91 + err := rows.Scan( 92 + &repo.Did, 93 + &repo.Name, 94 + &repo.Knot, 95 + &repo.Rkey, 96 + &createdAt, 97 + &description, 98 + &source, 99 + &spindle, 59 100 ) 60 101 if err != nil { 61 - return nil, err 102 + return nil, fmt.Errorf("failed to execute repo query: %w ", err) 103 + } 104 + 105 + if t, err := time.Parse(time.RFC3339, createdAt); err == nil { 106 + repo.Created = t 107 + } 108 + if description.Valid { 109 + repo.Description = description.String 110 + } 111 + if source.Valid { 112 + repo.Source = source.String 113 + } 114 + if spindle.Valid { 115 + repo.Spindle = spindle.String 62 116 } 63 - repos = append(repos, repo) 117 + 118 + repo.RepoStats = &RepoStats{} 119 + repoMap[repo.RepoAt()] = &repo 64 120 } 65 121 66 - if err := rows.Err(); err != nil { 67 - return nil, err 122 + if err = rows.Err(); err != nil { 123 + return nil, fmt.Errorf("failed to execute repo query: %w ", err) 68 124 } 69 125 70 - return repos, nil 71 - } 126 + inClause := strings.TrimSuffix(strings.Repeat("?, ", len(repoMap)), ", ") 127 + args = make([]any, len(repoMap)) 72 128 73 - func GetAllReposByDid(e Execer, did string) ([]Repo, error) { 74 - var repos []Repo 129 + i := 0 130 + for _, r := range repoMap { 131 + args[i] = r.RepoAt() 132 + i++ 133 + } 75 134 76 - rows, err := e.Query( 77 - `select 78 - r.did, 79 - r.name, 80 - r.knot, 81 - r.rkey, 82 - r.description, 83 - r.created, 84 - count(s.id) as star_count, 85 - r.source 135 + languageQuery := fmt.Sprintf( 136 + ` 137 + select 138 + repo_at, language 86 139 from 87 - repos r 88 - left join 89 - stars s on r.at_uri = s.repo_at 140 + repo_languages r1 90 141 where 91 - r.did = ? 92 - group by 93 - r.at_uri 94 - order by r.created desc`, 95 - did) 142 + repo_at IN (%s) 143 + and is_default_ref = 1 144 + and id = ( 145 + select id 146 + from repo_languages r2 147 + where r2.repo_at = r1.repo_at 148 + and r2.is_default_ref = 1 149 + order by bytes desc 150 + limit 1 151 + ); 152 + `, 153 + inClause, 154 + ) 155 + rows, err = e.Query(languageQuery, args...) 96 156 if err != nil { 97 - return nil, err 157 + return nil, fmt.Errorf("failed to execute lang query: %w ", err) 158 + } 159 + for rows.Next() { 160 + var repoat, lang string 161 + if err := rows.Scan(&repoat, &lang); err != nil { 162 + log.Println("err", "err", err) 163 + continue 164 + } 165 + if r, ok := repoMap[syntax.ATURI(repoat)]; ok { 166 + r.RepoStats.Language = lang 167 + } 168 + } 169 + if err = rows.Err(); err != nil { 170 + return nil, fmt.Errorf("failed to execute lang query: %w ", err) 98 171 } 99 - defer rows.Close() 100 172 173 + starCountQuery := fmt.Sprintf( 174 + `select 175 + repo_at, count(1) 176 + from stars 177 + where repo_at in (%s) 178 + group by repo_at`, 179 + inClause, 180 + ) 181 + rows, err = e.Query(starCountQuery, args...) 182 + if err != nil { 183 + return nil, fmt.Errorf("failed to execute star-count query: %w ", err) 184 + } 101 185 for rows.Next() { 102 - var repo Repo 103 - var repoStats RepoStats 104 - var createdAt string 105 - var nullableDescription sql.NullString 106 - var nullableSource sql.NullString 186 + var repoat string 187 + var count int 188 + if err := rows.Scan(&repoat, &count); err != nil { 189 + log.Println("err", "err", err) 190 + continue 191 + } 192 + if r, ok := repoMap[syntax.ATURI(repoat)]; ok { 193 + r.RepoStats.StarCount = count 194 + } 195 + } 196 + if err = rows.Err(); err != nil { 197 + return nil, fmt.Errorf("failed to execute star-count query: %w ", err) 198 + } 107 199 108 - err := rows.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &repoStats.StarCount, &nullableSource) 109 - if err != nil { 110 - return nil, err 200 + issueCountQuery := fmt.Sprintf( 201 + `select 202 + repo_at, 203 + count(case when open = 1 then 1 end) as open_count, 204 + count(case when open = 0 then 1 end) as closed_count 205 + from issues 206 + where repo_at in (%s) 207 + group by repo_at`, 208 + inClause, 209 + ) 210 + rows, err = e.Query(issueCountQuery, args...) 211 + if err != nil { 212 + return nil, fmt.Errorf("failed to execute issue-count query: %w ", err) 213 + } 214 + for rows.Next() { 215 + var repoat string 216 + var open, closed int 217 + if err := rows.Scan(&repoat, &open, &closed); err != nil { 218 + log.Println("err", "err", err) 219 + continue 111 220 } 221 + if r, ok := repoMap[syntax.ATURI(repoat)]; ok { 222 + r.RepoStats.IssueCount.Open = open 223 + r.RepoStats.IssueCount.Closed = closed 224 + } 225 + } 226 + if err = rows.Err(); err != nil { 227 + return nil, fmt.Errorf("failed to execute issue-count query: %w ", err) 228 + } 112 229 113 - if nullableDescription.Valid { 114 - repo.Description = nullableDescription.String 230 + pullCountQuery := fmt.Sprintf( 231 + `select 232 + repo_at, 233 + count(case when state = ? then 1 end) as open_count, 234 + count(case when state = ? then 1 end) as merged_count, 235 + count(case when state = ? then 1 end) as closed_count, 236 + count(case when state = ? then 1 end) as deleted_count 237 + from pulls 238 + where repo_at in (%s) 239 + group by repo_at`, 240 + inClause, 241 + ) 242 + args = append([]any{ 243 + PullOpen, 244 + PullMerged, 245 + PullClosed, 246 + PullDeleted, 247 + }, args...) 248 + rows, err = e.Query( 249 + pullCountQuery, 250 + args..., 251 + ) 252 + if err != nil { 253 + return nil, fmt.Errorf("failed to execute pulls-count query: %w ", err) 254 + } 255 + for rows.Next() { 256 + var repoat string 257 + var open, merged, closed, deleted int 258 + if err := rows.Scan(&repoat, &open, &merged, &closed, &deleted); err != nil { 259 + log.Println("err", "err", err) 260 + continue 115 261 } 262 + if r, ok := repoMap[syntax.ATURI(repoat)]; ok { 263 + r.RepoStats.PullCount.Open = open 264 + r.RepoStats.PullCount.Merged = merged 265 + r.RepoStats.PullCount.Closed = closed 266 + r.RepoStats.PullCount.Deleted = deleted 267 + } 268 + } 269 + if err = rows.Err(); err != nil { 270 + return nil, fmt.Errorf("failed to execute pulls-count query: %w ", err) 271 + } 116 272 117 - if nullableSource.Valid { 118 - repo.Source = nullableSource.String 273 + var repos []Repo 274 + for _, r := range repoMap { 275 + repos = append(repos, *r) 276 + } 277 + 278 + slices.SortFunc(repos, func(a, b Repo) int { 279 + if a.Created.After(b.Created) { 280 + return -1 119 281 } 282 + return 1 283 + }) 120 284 121 - createdAtTime, err := time.Parse(time.RFC3339, createdAt) 122 - if err != nil { 123 - repo.Created = time.Now() 124 - } else { 125 - repo.Created = createdAtTime 126 - } 285 + return repos, nil 286 + } 127 287 128 - repo.RepoStats = &repoStats 288 + func CountRepos(e Execer, filters ...filter) (int64, error) { 289 + var conditions []string 290 + var args []any 291 + for _, filter := range filters { 292 + conditions = append(conditions, filter.Condition()) 293 + args = append(args, filter.Arg()...) 294 + } 129 295 130 - repos = append(repos, repo) 296 + whereClause := "" 297 + if conditions != nil { 298 + whereClause = " where " + strings.Join(conditions, " and ") 131 299 } 132 300 133 - if err := rows.Err(); err != nil { 134 - return nil, err 301 + repoQuery := fmt.Sprintf(`select count(1) from repos %s`, whereClause) 302 + var count int64 303 + err := e.QueryRow(repoQuery, args...).Scan(&count) 304 + 305 + if !errors.Is(err, sql.ErrNoRows) && err != nil { 306 + return 0, err 135 307 } 136 308 137 - return repos, nil 309 + return count, nil 138 310 } 139 311 140 312 func GetRepo(e Execer, did, name string) (*Repo, error) { ··· 142 314 var description, spindle sql.NullString 143 315 144 316 row := e.QueryRow(` 145 - select did, name, knot, created, at_uri, description, spindle 317 + select did, name, knot, created, description, spindle, rkey 146 318 from repos 147 319 where did = ? and name = ? 148 320 `, ··· 151 323 ) 152 324 153 325 var createdAt string 154 - if err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.AtUri, &description, &spindle); err != nil { 326 + if err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &createdAt, &description, &spindle, &repo.Rkey); err != nil { 155 327 return nil, err 156 328 } 157 329 createdAtTime, _ := time.Parse(time.RFC3339, createdAt) ··· 172 344 var repo Repo 173 345 var nullableDescription sql.NullString 174 346 175 - row := e.QueryRow(`select did, name, knot, created, at_uri, description from repos where at_uri = ?`, atUri) 347 + row := e.QueryRow(`select did, name, knot, created, rkey, description from repos where at_uri = ?`, atUri) 176 348 177 349 var createdAt string 178 - if err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.AtUri, &nullableDescription); err != nil { 350 + if err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.Rkey, &nullableDescription); err != nil { 179 351 return nil, err 180 352 } 181 353 createdAtTime, _ := time.Parse(time.RFC3339, createdAt) ··· 195 367 `insert into repos 196 368 (did, name, knot, rkey, at_uri, description, source) 197 369 values (?, ?, ?, ?, ?, ?, ?)`, 198 - repo.Did, repo.Name, repo.Knot, repo.Rkey, repo.AtUri, repo.Description, repo.Source, 370 + repo.Did, repo.Name, repo.Knot, repo.Rkey, repo.RepoAt().String(), repo.Description, repo.Source, 199 371 ) 200 372 return err 201 373 } ··· 218 390 var repos []Repo 219 391 220 392 rows, err := e.Query( 221 - `select did, name, knot, rkey, description, created, at_uri, source 222 - from repos 223 - where did = ? and source is not null and source != '' 224 - order by created desc`, 225 - did, 393 + `select distinct r.did, r.name, r.knot, r.rkey, r.description, r.created, r.source 394 + from repos r 395 + left join collaborators c on r.at_uri = c.repo_at 396 + where (r.did = ? or c.subject_did = ?) 397 + and r.source is not null 398 + and r.source != '' 399 + order by r.created desc`, 400 + did, did, 226 401 ) 227 402 if err != nil { 228 403 return nil, err ··· 235 410 var nullableDescription sql.NullString 236 411 var nullableSource sql.NullString 237 412 238 - err := rows.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &repo.AtUri, &nullableSource) 413 + err := rows.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource) 239 414 if err != nil { 240 415 return nil, err 241 416 } ··· 272 447 var nullableSource sql.NullString 273 448 274 449 row := e.QueryRow( 275 - `select did, name, knot, rkey, description, created, at_uri, source 450 + `select did, name, knot, rkey, description, created, source 276 451 from repos 277 452 where did = ? and name = ? and source is not null and source != ''`, 278 453 did, name, 279 454 ) 280 455 281 - err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &repo.AtUri, &nullableSource) 456 + err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &nullableSource) 282 457 if err != nil { 283 458 return nil, err 284 459 } ··· 301 476 return &repo, nil 302 477 } 303 478 304 - func AddCollaborator(e Execer, collaborator, repoOwnerDid, repoName, repoKnot string) error { 305 - _, err := e.Exec( 306 - `insert into collaborators (did, repo) 307 - values (?, (select id from repos where did = ? and name = ? and knot = ?));`, 308 - collaborator, repoOwnerDid, repoName, repoKnot) 309 - return err 310 - } 311 - 312 479 func UpdateDescription(e Execer, repoAt, newDescription string) error { 313 480 _, err := e.Exec( 314 481 `update repos set description = ? where at_uri = ?`, newDescription, repoAt) 315 482 return err 316 483 } 317 484 318 - func UpdateSpindle(e Execer, repoAt, spindle string) error { 485 + func UpdateSpindle(e Execer, repoAt string, spindle *string) error { 319 486 _, err := e.Exec( 320 487 `update repos set spindle = ? where at_uri = ?`, spindle, repoAt) 321 488 return err 322 489 } 323 490 324 - func CollaboratingIn(e Execer, collaborator string) ([]Repo, error) { 325 - var repos []Repo 326 - 327 - rows, err := e.Query( 328 - `select 329 - r.did, r.name, r.knot, r.rkey, r.description, r.created, count(s.id) as star_count 330 - from 331 - repos r 332 - join 333 - collaborators c on r.id = c.repo 334 - left join 335 - stars s on r.at_uri = s.repo_at 336 - where 337 - c.did = ? 338 - group by 339 - r.id;`, collaborator) 340 - if err != nil { 341 - return nil, err 342 - } 343 - defer rows.Close() 344 - 345 - for rows.Next() { 346 - var repo Repo 347 - var repoStats RepoStats 348 - var createdAt string 349 - var nullableDescription sql.NullString 350 - 351 - err := rows.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &repoStats.StarCount) 352 - if err != nil { 353 - return nil, err 354 - } 355 - 356 - if nullableDescription.Valid { 357 - repo.Description = nullableDescription.String 358 - } else { 359 - repo.Description = "" 360 - } 361 - 362 - createdAtTime, err := time.Parse(time.RFC3339, createdAt) 363 - if err != nil { 364 - repo.Created = time.Now() 365 - } else { 366 - repo.Created = createdAtTime 367 - } 368 - 369 - repo.RepoStats = &repoStats 370 - 371 - repos = append(repos, repo) 372 - } 373 - 374 - if err := rows.Err(); err != nil { 375 - return nil, err 376 - } 377 - 378 - return repos, nil 379 - } 380 - 381 491 type RepoStats struct { 492 + Language string 382 493 StarCount int 383 494 IssueCount IssueCount 384 495 PullCount PullCount 385 496 } 386 - 387 - func scanRepo(rows *sql.Rows, did, name, knot, rkey, description *string, created *time.Time, source *string) error { 388 - var createdAt string 389 - var nullableDescription sql.NullString 390 - var nullableSource sql.NullString 391 - if err := rows.Scan(did, name, knot, rkey, &nullableDescription, &createdAt, &nullableSource); err != nil { 392 - return err 393 - } 394 - 395 - if nullableDescription.Valid { 396 - *description = nullableDescription.String 397 - } else { 398 - *description = "" 399 - } 400 - 401 - createdAtTime, err := time.Parse(time.RFC3339, createdAt) 402 - if err != nil { 403 - *created = time.Now() 404 - } else { 405 - *created = createdAtTime 406 - } 407 - 408 - if nullableSource.Valid { 409 - *source = nullableSource.String 410 - } else { 411 - *source = "" 412 - } 413 - 414 - return nil 415 - }
+29
appview/db/signup.go
··· 1 + package db 2 + 3 + import "time" 4 + 5 + type InflightSignup struct { 6 + Id int64 7 + Email string 8 + InviteCode string 9 + Created time.Time 10 + } 11 + 12 + func AddInflightSignup(e Execer, signup InflightSignup) error { 13 + query := `insert into signups_inflight (email, invite_code) values (?, ?)` 14 + _, err := e.Exec(query, signup.Email, signup.InviteCode) 15 + return err 16 + } 17 + 18 + func DeleteInflightSignup(e Execer, email string) error { 19 + query := `delete from signups_inflight where email = ?` 20 + _, err := e.Exec(query, email) 21 + return err 22 + } 23 + 24 + func GetEmailForCode(e Execer, inviteCode string) (string, error) { 25 + query := `select email from signups_inflight where invite_code = ?` 26 + var email string 27 + err := e.QueryRow(query, inviteCode).Scan(&email) 28 + return email, err 29 + }
+107 -4
appview/db/spindle.go
··· 10 10 ) 11 11 12 12 type Spindle struct { 13 + Id int 14 + Owner syntax.DID 15 + Instance string 16 + Verified *time.Time 17 + Created time.Time 18 + NeedsUpgrade bool 19 + } 20 + 21 + type SpindleMember struct { 13 22 Id int 14 - Owner syntax.DID 23 + Did syntax.DID // owner of the record 24 + Rkey string // rkey of the record 15 25 Instance string 16 - Verified *time.Time 26 + Subject syntax.DID // the member being added 17 27 Created time.Time 18 28 } 19 29 ··· 33 43 } 34 44 35 45 query := fmt.Sprintf( 36 - `select id, owner, instance, verified, created 46 + `select id, owner, instance, verified, created, needs_upgrade 37 47 from spindles 38 48 %s 39 49 order by created ··· 52 62 var spindle Spindle 53 63 var createdAt string 54 64 var verified sql.NullString 65 + var needsUpgrade int 55 66 56 67 if err := rows.Scan( 57 68 &spindle.Id, ··· 59 70 &spindle.Instance, 60 71 &verified, 61 72 &createdAt, 73 + &needsUpgrade, 62 74 ); err != nil { 63 75 return nil, err 64 76 } ··· 77 89 spindle.Verified = &t 78 90 } 79 91 92 + if needsUpgrade != 0 { 93 + spindle.NeedsUpgrade = true 94 + } 95 + 80 96 spindles = append(spindles, spindle) 81 97 } 82 98 ··· 106 122 whereClause = " where " + strings.Join(conditions, " and ") 107 123 } 108 124 109 - query := fmt.Sprintf(`update spindles set verified = strftime('%%Y-%%m-%%dT%%H:%%M:%%SZ', 'now') %s`, whereClause) 125 + query := fmt.Sprintf(`update spindles set verified = strftime('%%Y-%%m-%%dT%%H:%%M:%%SZ', 'now'), needs_upgrade = 0 %s`, whereClause) 110 126 111 127 res, err := e.Exec(query, args...) 112 128 if err != nil { ··· 134 150 _, err := e.Exec(query, args...) 135 151 return err 136 152 } 153 + 154 + func AddSpindleMember(e Execer, member SpindleMember) error { 155 + _, err := e.Exec( 156 + `insert or ignore into spindle_members (did, rkey, instance, subject) values (?, ?, ?, ?)`, 157 + member.Did, 158 + member.Rkey, 159 + member.Instance, 160 + member.Subject, 161 + ) 162 + return err 163 + } 164 + 165 + func RemoveSpindleMember(e Execer, filters ...filter) error { 166 + var conditions []string 167 + var args []any 168 + for _, filter := range filters { 169 + conditions = append(conditions, filter.Condition()) 170 + args = append(args, filter.Arg()...) 171 + } 172 + 173 + whereClause := "" 174 + if conditions != nil { 175 + whereClause = " where " + strings.Join(conditions, " and ") 176 + } 177 + 178 + query := fmt.Sprintf(`delete from spindle_members %s`, whereClause) 179 + 180 + _, err := e.Exec(query, args...) 181 + return err 182 + } 183 + 184 + func GetSpindleMembers(e Execer, filters ...filter) ([]SpindleMember, error) { 185 + var members []SpindleMember 186 + 187 + var conditions []string 188 + var args []any 189 + for _, filter := range filters { 190 + conditions = append(conditions, filter.Condition()) 191 + args = append(args, filter.Arg()...) 192 + } 193 + 194 + whereClause := "" 195 + if conditions != nil { 196 + whereClause = " where " + strings.Join(conditions, " and ") 197 + } 198 + 199 + query := fmt.Sprintf( 200 + `select id, did, rkey, instance, subject, created 201 + from spindle_members 202 + %s 203 + order by created 204 + `, 205 + whereClause, 206 + ) 207 + 208 + rows, err := e.Query(query, args...) 209 + 210 + if err != nil { 211 + return nil, err 212 + } 213 + defer rows.Close() 214 + 215 + for rows.Next() { 216 + var member SpindleMember 217 + var createdAt string 218 + 219 + if err := rows.Scan( 220 + &member.Id, 221 + &member.Did, 222 + &member.Rkey, 223 + &member.Instance, 224 + &member.Subject, 225 + &createdAt, 226 + ); err != nil { 227 + return nil, err 228 + } 229 + 230 + member.Created, err = time.Parse(time.RFC3339, createdAt) 231 + if err != nil { 232 + member.Created = time.Now() 233 + } 234 + 235 + members = append(members, member) 236 + } 237 + 238 + return members, nil 239 + }
+191 -7
appview/db/star.go
··· 1 1 package db 2 2 3 3 import ( 4 + "database/sql" 5 + "errors" 6 + "fmt" 4 7 "log" 8 + "strings" 5 9 "time" 6 10 7 11 "github.com/bluesky-social/indigo/atproto/syntax" ··· 31 35 return nil 32 36 } 33 37 34 - func AddStar(e Execer, starredByDid string, repoAt syntax.ATURI, rkey string) error { 38 + func AddStar(e Execer, star *Star) error { 35 39 query := `insert or ignore into stars (starred_by_did, repo_at, rkey) values (?, ?, ?)` 36 - _, err := e.Exec(query, starredByDid, repoAt, rkey) 40 + _, err := e.Exec( 41 + query, 42 + star.StarredByDid, 43 + star.RepoAt.String(), 44 + star.Rkey, 45 + ) 37 46 return err 38 47 } 39 48 40 49 // Get a star record 41 50 func GetStar(e Execer, starredByDid string, repoAt syntax.ATURI) (*Star, error) { 42 51 query := ` 43 - select starred_by_did, repo_at, created, rkey 52 + select starred_by_did, repo_at, created, rkey 44 53 from stars 45 54 where starred_by_did = ? and repo_at = ?` 46 55 row := e.QueryRow(query, starredByDid, repoAt) ··· 93 102 } 94 103 } 95 104 105 + func GetStars(e Execer, limit int, filters ...filter) ([]Star, error) { 106 + var conditions []string 107 + var args []any 108 + for _, filter := range filters { 109 + conditions = append(conditions, filter.Condition()) 110 + args = append(args, filter.Arg()...) 111 + } 112 + 113 + whereClause := "" 114 + if conditions != nil { 115 + whereClause = " where " + strings.Join(conditions, " and ") 116 + } 117 + 118 + limitClause := "" 119 + if limit != 0 { 120 + limitClause = fmt.Sprintf(" limit %d", limit) 121 + } 122 + 123 + repoQuery := fmt.Sprintf( 124 + `select starred_by_did, repo_at, created, rkey 125 + from stars 126 + %s 127 + order by created desc 128 + %s`, 129 + whereClause, 130 + limitClause, 131 + ) 132 + rows, err := e.Query(repoQuery, args...) 133 + if err != nil { 134 + return nil, err 135 + } 136 + 137 + starMap := make(map[string][]Star) 138 + for rows.Next() { 139 + var star Star 140 + var created string 141 + err := rows.Scan(&star.StarredByDid, &star.RepoAt, &created, &star.Rkey) 142 + if err != nil { 143 + return nil, err 144 + } 145 + 146 + star.Created = time.Now() 147 + if t, err := time.Parse(time.RFC3339, created); err == nil { 148 + star.Created = t 149 + } 150 + 151 + repoAt := string(star.RepoAt) 152 + starMap[repoAt] = append(starMap[repoAt], star) 153 + } 154 + 155 + // populate *Repo in each star 156 + args = make([]any, len(starMap)) 157 + i := 0 158 + for r := range starMap { 159 + args[i] = r 160 + i++ 161 + } 162 + 163 + if len(args) == 0 { 164 + return nil, nil 165 + } 166 + 167 + repos, err := GetRepos(e, 0, FilterIn("at_uri", args)) 168 + if err != nil { 169 + return nil, err 170 + } 171 + 172 + for _, r := range repos { 173 + if stars, ok := starMap[string(r.RepoAt())]; ok { 174 + for i := range stars { 175 + stars[i].Repo = &r 176 + } 177 + } 178 + } 179 + 180 + var stars []Star 181 + for _, s := range starMap { 182 + stars = append(stars, s...) 183 + } 184 + 185 + return stars, nil 186 + } 187 + 188 + func CountStars(e Execer, filters ...filter) (int64, error) { 189 + var conditions []string 190 + var args []any 191 + for _, filter := range filters { 192 + conditions = append(conditions, filter.Condition()) 193 + args = append(args, filter.Arg()...) 194 + } 195 + 196 + whereClause := "" 197 + if conditions != nil { 198 + whereClause = " where " + strings.Join(conditions, " and ") 199 + } 200 + 201 + repoQuery := fmt.Sprintf(`select count(1) from stars %s`, whereClause) 202 + var count int64 203 + err := e.QueryRow(repoQuery, args...).Scan(&count) 204 + 205 + if !errors.Is(err, sql.ErrNoRows) && err != nil { 206 + return 0, err 207 + } 208 + 209 + return count, nil 210 + } 211 + 96 212 func GetAllStars(e Execer, limit int) ([]Star, error) { 97 213 var stars []Star 98 214 99 215 rows, err := e.Query(` 100 - select 216 + select 101 217 s.starred_by_did, 102 218 s.repo_at, 103 219 s.rkey, ··· 106 222 r.name, 107 223 r.knot, 108 224 r.rkey, 109 - r.created, 110 - r.at_uri 225 + r.created 111 226 from stars s 112 227 join repos r on s.repo_at = r.at_uri 113 228 `) ··· 132 247 &repo.Knot, 133 248 &repo.Rkey, 134 249 &repoCreatedAt, 135 - &repo.AtUri, 136 250 ); err != nil { 137 251 return nil, err 138 252 } ··· 156 270 157 271 return stars, nil 158 272 } 273 + 274 + // GetTopStarredReposLastWeek returns the top 8 most starred repositories from the last week 275 + func GetTopStarredReposLastWeek(e Execer) ([]Repo, error) { 276 + // first, get the top repo URIs by star count from the last week 277 + query := ` 278 + with recent_starred_repos as ( 279 + select distinct repo_at 280 + from stars 281 + where created >= datetime('now', '-7 days') 282 + ), 283 + repo_star_counts as ( 284 + select 285 + s.repo_at, 286 + count(*) as stars_gained_last_week 287 + from stars s 288 + join recent_starred_repos rsr on s.repo_at = rsr.repo_at 289 + where s.created >= datetime('now', '-7 days') 290 + group by s.repo_at 291 + ) 292 + select rsc.repo_at 293 + from repo_star_counts rsc 294 + order by rsc.stars_gained_last_week desc 295 + limit 8 296 + ` 297 + 298 + rows, err := e.Query(query) 299 + if err != nil { 300 + return nil, err 301 + } 302 + defer rows.Close() 303 + 304 + var repoUris []string 305 + for rows.Next() { 306 + var repoUri string 307 + err := rows.Scan(&repoUri) 308 + if err != nil { 309 + return nil, err 310 + } 311 + repoUris = append(repoUris, repoUri) 312 + } 313 + 314 + if err := rows.Err(); err != nil { 315 + return nil, err 316 + } 317 + 318 + if len(repoUris) == 0 { 319 + return []Repo{}, nil 320 + } 321 + 322 + // get full repo data 323 + repos, err := GetRepos(e, 0, FilterIn("at_uri", repoUris)) 324 + if err != nil { 325 + return nil, err 326 + } 327 + 328 + // sort repos by the original trending order 329 + repoMap := make(map[string]Repo) 330 + for _, repo := range repos { 331 + repoMap[repo.RepoAt().String()] = repo 332 + } 333 + 334 + orderedRepos := make([]Repo, 0, len(repoUris)) 335 + for _, uri := range repoUris { 336 + if repo, exists := repoMap[uri]; exists { 337 + orderedRepos = append(orderedRepos, repo) 338 + } 339 + } 340 + 341 + return orderedRepos, nil 342 + }
+276
appview/db/strings.go
··· 1 + package db 2 + 3 + import ( 4 + "bytes" 5 + "database/sql" 6 + "errors" 7 + "fmt" 8 + "io" 9 + "strings" 10 + "time" 11 + "unicode/utf8" 12 + 13 + "github.com/bluesky-social/indigo/atproto/syntax" 14 + "tangled.sh/tangled.sh/core/api/tangled" 15 + ) 16 + 17 + type String struct { 18 + Did syntax.DID 19 + Rkey string 20 + 21 + Filename string 22 + Description string 23 + Contents string 24 + Created time.Time 25 + Edited *time.Time 26 + } 27 + 28 + func (s *String) StringAt() syntax.ATURI { 29 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", s.Did, tangled.StringNSID, s.Rkey)) 30 + } 31 + 32 + type StringStats struct { 33 + LineCount uint64 34 + ByteCount uint64 35 + } 36 + 37 + func (s String) Stats() StringStats { 38 + lineCount, err := countLines(strings.NewReader(s.Contents)) 39 + if err != nil { 40 + // non-fatal 41 + // TODO: log this? 42 + } 43 + 44 + return StringStats{ 45 + LineCount: uint64(lineCount), 46 + ByteCount: uint64(len(s.Contents)), 47 + } 48 + } 49 + 50 + func (s String) Validate() error { 51 + var err error 52 + 53 + if utf8.RuneCountInString(s.Filename) > 140 { 54 + err = errors.Join(err, fmt.Errorf("filename too long")) 55 + } 56 + 57 + if utf8.RuneCountInString(s.Description) > 280 { 58 + err = errors.Join(err, fmt.Errorf("description too long")) 59 + } 60 + 61 + if len(s.Contents) == 0 { 62 + err = errors.Join(err, fmt.Errorf("contents is empty")) 63 + } 64 + 65 + return err 66 + } 67 + 68 + func (s *String) AsRecord() tangled.String { 69 + return tangled.String{ 70 + Filename: s.Filename, 71 + Description: s.Description, 72 + Contents: s.Contents, 73 + CreatedAt: s.Created.Format(time.RFC3339), 74 + } 75 + } 76 + 77 + func StringFromRecord(did, rkey string, record tangled.String) String { 78 + created, err := time.Parse(record.CreatedAt, time.RFC3339) 79 + if err != nil { 80 + created = time.Now() 81 + } 82 + return String{ 83 + Did: syntax.DID(did), 84 + Rkey: rkey, 85 + Filename: record.Filename, 86 + Description: record.Description, 87 + Contents: record.Contents, 88 + Created: created, 89 + } 90 + } 91 + 92 + func AddString(e Execer, s String) error { 93 + _, err := e.Exec( 94 + `insert into strings ( 95 + did, 96 + rkey, 97 + filename, 98 + description, 99 + content, 100 + created, 101 + edited 102 + ) 103 + values (?, ?, ?, ?, ?, ?, null) 104 + on conflict(did, rkey) do update set 105 + filename = excluded.filename, 106 + description = excluded.description, 107 + content = excluded.content, 108 + edited = case 109 + when 110 + strings.content != excluded.content 111 + or strings.filename != excluded.filename 112 + or strings.description != excluded.description then ? 113 + else strings.edited 114 + end`, 115 + s.Did, 116 + s.Rkey, 117 + s.Filename, 118 + s.Description, 119 + s.Contents, 120 + s.Created.Format(time.RFC3339), 121 + time.Now().Format(time.RFC3339), 122 + ) 123 + return err 124 + } 125 + 126 + func GetStrings(e Execer, limit int, filters ...filter) ([]String, error) { 127 + var all []String 128 + 129 + var conditions []string 130 + var args []any 131 + for _, filter := range filters { 132 + conditions = append(conditions, filter.Condition()) 133 + args = append(args, filter.Arg()...) 134 + } 135 + 136 + whereClause := "" 137 + if conditions != nil { 138 + whereClause = " where " + strings.Join(conditions, " and ") 139 + } 140 + 141 + limitClause := "" 142 + if limit != 0 { 143 + limitClause = fmt.Sprintf(" limit %d ", limit) 144 + } 145 + 146 + query := fmt.Sprintf(`select 147 + did, 148 + rkey, 149 + filename, 150 + description, 151 + content, 152 + created, 153 + edited 154 + from strings 155 + %s 156 + order by created desc 157 + %s`, 158 + whereClause, 159 + limitClause, 160 + ) 161 + 162 + rows, err := e.Query(query, args...) 163 + 164 + if err != nil { 165 + return nil, err 166 + } 167 + defer rows.Close() 168 + 169 + for rows.Next() { 170 + var s String 171 + var createdAt string 172 + var editedAt sql.NullString 173 + 174 + if err := rows.Scan( 175 + &s.Did, 176 + &s.Rkey, 177 + &s.Filename, 178 + &s.Description, 179 + &s.Contents, 180 + &createdAt, 181 + &editedAt, 182 + ); err != nil { 183 + return nil, err 184 + } 185 + 186 + s.Created, err = time.Parse(time.RFC3339, createdAt) 187 + if err != nil { 188 + s.Created = time.Now() 189 + } 190 + 191 + if editedAt.Valid { 192 + e, err := time.Parse(time.RFC3339, editedAt.String) 193 + if err != nil { 194 + e = time.Now() 195 + } 196 + s.Edited = &e 197 + } 198 + 199 + all = append(all, s) 200 + } 201 + 202 + if err := rows.Err(); err != nil { 203 + return nil, err 204 + } 205 + 206 + return all, nil 207 + } 208 + 209 + func CountStrings(e Execer, filters ...filter) (int64, error) { 210 + var conditions []string 211 + var args []any 212 + for _, filter := range filters { 213 + conditions = append(conditions, filter.Condition()) 214 + args = append(args, filter.Arg()...) 215 + } 216 + 217 + whereClause := "" 218 + if conditions != nil { 219 + whereClause = " where " + strings.Join(conditions, " and ") 220 + } 221 + 222 + repoQuery := fmt.Sprintf(`select count(1) from strings %s`, whereClause) 223 + var count int64 224 + err := e.QueryRow(repoQuery, args...).Scan(&count) 225 + 226 + if !errors.Is(err, sql.ErrNoRows) && err != nil { 227 + return 0, err 228 + } 229 + 230 + return count, nil 231 + } 232 + 233 + func DeleteString(e Execer, filters ...filter) error { 234 + var conditions []string 235 + var args []any 236 + for _, filter := range filters { 237 + conditions = append(conditions, filter.Condition()) 238 + args = append(args, filter.Arg()...) 239 + } 240 + 241 + whereClause := "" 242 + if conditions != nil { 243 + whereClause = " where " + strings.Join(conditions, " and ") 244 + } 245 + 246 + query := fmt.Sprintf(`delete from strings %s`, whereClause) 247 + 248 + _, err := e.Exec(query, args...) 249 + return err 250 + } 251 + 252 + func countLines(r io.Reader) (int, error) { 253 + buf := make([]byte, 32*1024) 254 + bufLen := 0 255 + count := 0 256 + nl := []byte{'\n'} 257 + 258 + for { 259 + c, err := r.Read(buf) 260 + if c > 0 { 261 + bufLen += c 262 + } 263 + count += bytes.Count(buf[:c], nl) 264 + 265 + switch { 266 + case err == io.EOF: 267 + /* handle last line not having a newline at the end */ 268 + if bufLen >= 1 && buf[(bufLen-1)%(32*1024)] != '\n' { 269 + count++ 270 + } 271 + return count, nil 272 + case err != nil: 273 + return 0, err 274 + } 275 + } 276 + }
+119 -28
appview/db/timeline.go
··· 14 14 15 15 // optional: populate only if Repo is a fork 16 16 Source *Repo 17 + 18 + // optional: populate only if event is Follow 19 + *Profile 20 + *FollowStats 17 21 } 18 22 19 23 // TODO: this gathers heterogenous events from different sources and aggregates 20 24 // them in code; if we did this entirely in sql, we could order and limit and paginate easily 21 - func MakeTimeline(e Execer) ([]TimelineEvent, error) { 25 + func MakeTimeline(e Execer, limit int) ([]TimelineEvent, error) { 22 26 var events []TimelineEvent 23 - limit := 50 24 27 25 - repos, err := GetAllRepos(e, limit) 28 + repos, err := getTimelineRepos(e, limit) 26 29 if err != nil { 27 30 return nil, err 28 31 } 29 32 30 - follows, err := GetAllFollows(e, limit) 33 + stars, err := getTimelineStars(e, limit) 31 34 if err != nil { 32 35 return nil, err 33 36 } 34 37 35 - stars, err := GetAllStars(e, limit) 38 + follows, err := getTimelineFollows(e, limit) 36 39 if err != nil { 37 40 return nil, err 38 41 } 39 42 40 - for _, repo := range repos { 41 - var sourceRepo *Repo 42 - if repo.Source != "" { 43 - sourceRepo, err = GetRepoByAtUri(e, repo.Source) 44 - if err != nil { 45 - return nil, err 43 + events = append(events, repos...) 44 + events = append(events, stars...) 45 + events = append(events, follows...) 46 + 47 + sort.Slice(events, func(i, j int) bool { 48 + return events[i].EventAt.After(events[j].EventAt) 49 + }) 50 + 51 + // Limit the slice to 100 events 52 + if len(events) > limit { 53 + events = events[:limit] 54 + } 55 + 56 + return events, nil 57 + } 58 + 59 + func getTimelineRepos(e Execer, limit int) ([]TimelineEvent, error) { 60 + repos, err := GetRepos(e, limit) 61 + if err != nil { 62 + return nil, err 63 + } 64 + 65 + // fetch all source repos 66 + var args []string 67 + for _, r := range repos { 68 + if r.Source != "" { 69 + args = append(args, r.Source) 70 + } 71 + } 72 + 73 + var origRepos []Repo 74 + if args != nil { 75 + origRepos, err = GetRepos(e, 0, FilterIn("at_uri", args)) 76 + } 77 + if err != nil { 78 + return nil, err 79 + } 80 + 81 + uriToRepo := make(map[string]Repo) 82 + for _, r := range origRepos { 83 + uriToRepo[r.RepoAt().String()] = r 84 + } 85 + 86 + var events []TimelineEvent 87 + for _, r := range repos { 88 + var source *Repo 89 + if r.Source != "" { 90 + if origRepo, ok := uriToRepo[r.Source]; ok { 91 + source = &origRepo 46 92 } 47 93 } 48 94 49 95 events = append(events, TimelineEvent{ 50 - Repo: &repo, 51 - EventAt: repo.Created, 52 - Source: sourceRepo, 96 + Repo: &r, 97 + EventAt: r.Created, 98 + Source: source, 53 99 }) 54 100 } 55 101 56 - for _, follow := range follows { 102 + return events, nil 103 + } 104 + 105 + func getTimelineStars(e Execer, limit int) ([]TimelineEvent, error) { 106 + stars, err := GetStars(e, limit) 107 + if err != nil { 108 + return nil, err 109 + } 110 + 111 + // filter star records without a repo 112 + n := 0 113 + for _, s := range stars { 114 + if s.Repo != nil { 115 + stars[n] = s 116 + n++ 117 + } 118 + } 119 + stars = stars[:n] 120 + 121 + var events []TimelineEvent 122 + for _, s := range stars { 57 123 events = append(events, TimelineEvent{ 58 - Follow: &follow, 59 - EventAt: follow.FollowedAt, 124 + Star: &s, 125 + EventAt: s.Created, 60 126 }) 61 127 } 62 128 63 - for _, star := range stars { 64 - events = append(events, TimelineEvent{ 65 - Star: &star, 66 - EventAt: star.Created, 67 - }) 129 + return events, nil 130 + } 131 + 132 + func getTimelineFollows(e Execer, limit int) ([]TimelineEvent, error) { 133 + follows, err := GetFollows(e, limit) 134 + if err != nil { 135 + return nil, err 136 + } 137 + 138 + var subjects []string 139 + for _, f := range follows { 140 + subjects = append(subjects, f.SubjectDid) 68 141 } 69 142 70 - sort.Slice(events, func(i, j int) bool { 71 - return events[i].EventAt.After(events[j].EventAt) 72 - }) 143 + if subjects == nil { 144 + return nil, nil 145 + } 73 146 74 - // Limit the slice to 100 events 75 - if len(events) > limit { 76 - events = events[:limit] 147 + profiles, err := GetProfiles(e, FilterIn("did", subjects)) 148 + if err != nil { 149 + return nil, err 150 + } 151 + 152 + followStatMap, err := GetFollowerFollowingCounts(e, subjects) 153 + if err != nil { 154 + return nil, err 155 + } 156 + 157 + var events []TimelineEvent 158 + for _, f := range follows { 159 + profile, _ := profiles[f.SubjectDid] 160 + followStatMap, _ := followStatMap[f.SubjectDid] 161 + 162 + events = append(events, TimelineEvent{ 163 + Follow: &f, 164 + Profile: profile, 165 + FollowStats: &followStatMap, 166 + EventAt: f.FollowedAt, 167 + }) 77 168 } 78 169 79 170 return events, nil
+53
appview/dns/cloudflare.go
··· 1 + package dns 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + 7 + "github.com/cloudflare/cloudflare-go" 8 + "tangled.sh/tangled.sh/core/appview/config" 9 + ) 10 + 11 + type Record struct { 12 + Type string 13 + Name string 14 + Content string 15 + TTL int 16 + Proxied bool 17 + } 18 + 19 + type Cloudflare struct { 20 + api *cloudflare.API 21 + zone string 22 + } 23 + 24 + func NewCloudflare(c *config.Config) (*Cloudflare, error) { 25 + apiToken := c.Cloudflare.ApiToken 26 + api, err := cloudflare.NewWithAPIToken(apiToken) 27 + if err != nil { 28 + return nil, err 29 + } 30 + return &Cloudflare{api: api, zone: c.Cloudflare.ZoneId}, nil 31 + } 32 + 33 + func (cf *Cloudflare) CreateDNSRecord(ctx context.Context, record Record) error { 34 + _, err := cf.api.CreateDNSRecord(ctx, cloudflare.ZoneIdentifier(cf.zone), cloudflare.CreateDNSRecordParams{ 35 + Type: record.Type, 36 + Name: record.Name, 37 + Content: record.Content, 38 + TTL: record.TTL, 39 + Proxied: &record.Proxied, 40 + }) 41 + if err != nil { 42 + return fmt.Errorf("failed to create DNS record: %w", err) 43 + } 44 + return nil 45 + } 46 + 47 + func (cf *Cloudflare) DeleteDNSRecord(ctx context.Context, recordID string) error { 48 + err := cf.api.DeleteDNSRecord(ctx, cloudflare.ZoneIdentifier(cf.zone), recordID) 49 + if err != nil { 50 + return fmt.Errorf("failed to delete DNS record: %w", err) 51 + } 52 + return nil 53 + }
-104
appview/idresolver/resolver.go
··· 1 - package idresolver 2 - 3 - import ( 4 - "context" 5 - "net" 6 - "net/http" 7 - "sync" 8 - "time" 9 - 10 - "github.com/bluesky-social/indigo/atproto/identity" 11 - "github.com/bluesky-social/indigo/atproto/identity/redisdir" 12 - "github.com/bluesky-social/indigo/atproto/syntax" 13 - "github.com/carlmjohnson/versioninfo" 14 - "tangled.sh/tangled.sh/core/appview/config" 15 - ) 16 - 17 - type Resolver struct { 18 - directory identity.Directory 19 - } 20 - 21 - func BaseDirectory() identity.Directory { 22 - base := identity.BaseDirectory{ 23 - PLCURL: identity.DefaultPLCURL, 24 - HTTPClient: http.Client{ 25 - Timeout: time.Second * 10, 26 - Transport: &http.Transport{ 27 - // would want this around 100ms for services doing lots of handle resolution. Impacts PLC connections as well, but not too bad. 28 - IdleConnTimeout: time.Millisecond * 1000, 29 - MaxIdleConns: 100, 30 - }, 31 - }, 32 - Resolver: net.Resolver{ 33 - Dial: func(ctx context.Context, network, address string) (net.Conn, error) { 34 - d := net.Dialer{Timeout: time.Second * 3} 35 - return d.DialContext(ctx, network, address) 36 - }, 37 - }, 38 - TryAuthoritativeDNS: true, 39 - // primary Bluesky PDS instance only supports HTTP resolution method 40 - SkipDNSDomainSuffixes: []string{".bsky.social"}, 41 - UserAgent: "indigo-identity/" + versioninfo.Short(), 42 - } 43 - return &base 44 - } 45 - 46 - func RedisDirectory(url string) (identity.Directory, error) { 47 - hitTTL := time.Hour * 24 48 - errTTL := time.Second * 30 49 - invalidHandleTTL := time.Minute * 5 50 - return redisdir.NewRedisDirectory(BaseDirectory(), url, hitTTL, errTTL, invalidHandleTTL, 10000) 51 - } 52 - 53 - func DefaultResolver() *Resolver { 54 - return &Resolver{ 55 - directory: identity.DefaultDirectory(), 56 - } 57 - } 58 - 59 - func RedisResolver(config config.RedisConfig) (*Resolver, error) { 60 - directory, err := RedisDirectory(config.ToURL()) 61 - if err != nil { 62 - return nil, err 63 - } 64 - return &Resolver{ 65 - directory: directory, 66 - }, nil 67 - } 68 - 69 - func (r *Resolver) ResolveIdent(ctx context.Context, arg string) (*identity.Identity, error) { 70 - id, err := syntax.ParseAtIdentifier(arg) 71 - if err != nil { 72 - return nil, err 73 - } 74 - 75 - return r.directory.Lookup(ctx, *id) 76 - } 77 - 78 - func (r *Resolver) ResolveIdents(ctx context.Context, idents []string) []*identity.Identity { 79 - results := make([]*identity.Identity, len(idents)) 80 - var wg sync.WaitGroup 81 - 82 - done := make(chan struct{}) 83 - defer close(done) 84 - 85 - for idx, ident := range idents { 86 - wg.Add(1) 87 - go func(index int, id string) { 88 - defer wg.Done() 89 - 90 - select { 91 - case <-ctx.Done(): 92 - results[index] = nil 93 - case <-done: 94 - results[index] = nil 95 - default: 96 - identity, _ := r.ResolveIdent(ctx, id) 97 - results[index] = identity 98 - } 99 - }(idx, ident) 100 - } 101 - 102 - wg.Wait() 103 - return results 104 - }
+582 -89
appview/ingester.go
··· 3 3 import ( 4 4 "context" 5 5 "encoding/json" 6 - "errors" 7 6 "fmt" 8 - "io" 9 - "log" 10 - "net/http" 11 - "strings" 7 + "log/slog" 8 + 12 9 "time" 13 10 14 11 "github.com/bluesky-social/indigo/atproto/syntax" ··· 16 13 "github.com/go-git/go-git/v5/plumbing" 17 14 "github.com/ipfs/go-cid" 18 15 "tangled.sh/tangled.sh/core/api/tangled" 16 + "tangled.sh/tangled.sh/core/appview/config" 19 17 "tangled.sh/tangled.sh/core/appview/db" 18 + "tangled.sh/tangled.sh/core/appview/serververify" 19 + "tangled.sh/tangled.sh/core/appview/validator" 20 + "tangled.sh/tangled.sh/core/idresolver" 20 21 "tangled.sh/tangled.sh/core/rbac" 21 22 ) 22 23 23 - type Ingester func(ctx context.Context, e *models.Event) error 24 + type Ingester struct { 25 + Db db.DbWrapper 26 + Enforcer *rbac.Enforcer 27 + IdResolver *idresolver.Resolver 28 + Config *config.Config 29 + Logger *slog.Logger 30 + Validator *validator.Validator 31 + } 24 32 25 - func Ingest(d db.DbWrapper, enforcer *rbac.Enforcer) Ingester { 33 + type processFunc func(ctx context.Context, e *models.Event) error 34 + 35 + func (i *Ingester) Ingest() processFunc { 26 36 return func(ctx context.Context, e *models.Event) error { 27 37 var err error 28 38 defer func() { 29 39 eventTime := e.TimeUS 30 40 lastTimeUs := eventTime + 1 31 - if err := d.SaveLastTimeUs(lastTimeUs); err != nil { 41 + if err := i.Db.SaveLastTimeUs(lastTimeUs); err != nil { 32 42 err = fmt.Errorf("(deferred) failed to save last time us: %w", err) 33 43 } 34 44 }() 35 45 36 - if e.Kind != models.EventKindCommit { 37 - return nil 46 + l := i.Logger.With("kind", e.Kind) 47 + switch e.Kind { 48 + case models.EventKindAccount: 49 + if !e.Account.Active && *e.Account.Status == "deactivated" { 50 + err = i.IdResolver.InvalidateIdent(ctx, e.Account.Did) 51 + } 52 + case models.EventKindIdentity: 53 + err = i.IdResolver.InvalidateIdent(ctx, e.Identity.Did) 54 + case models.EventKindCommit: 55 + switch e.Commit.Collection { 56 + case tangled.GraphFollowNSID: 57 + err = i.ingestFollow(e) 58 + case tangled.FeedStarNSID: 59 + err = i.ingestStar(e) 60 + case tangled.PublicKeyNSID: 61 + err = i.ingestPublicKey(e) 62 + case tangled.RepoArtifactNSID: 63 + err = i.ingestArtifact(e) 64 + case tangled.ActorProfileNSID: 65 + err = i.ingestProfile(e) 66 + case tangled.SpindleMemberNSID: 67 + err = i.ingestSpindleMember(ctx, e) 68 + case tangled.SpindleNSID: 69 + err = i.ingestSpindle(ctx, e) 70 + case tangled.KnotMemberNSID: 71 + err = i.ingestKnotMember(e) 72 + case tangled.KnotNSID: 73 + err = i.ingestKnot(e) 74 + case tangled.StringNSID: 75 + err = i.ingestString(e) 76 + case tangled.RepoIssueNSID: 77 + err = i.ingestIssue(ctx, e) 78 + case tangled.RepoIssueCommentNSID: 79 + err = i.ingestIssueComment(e) 80 + } 81 + l = i.Logger.With("nsid", e.Commit.Collection) 38 82 } 39 83 40 - switch e.Commit.Collection { 41 - case tangled.GraphFollowNSID: 42 - ingestFollow(&d, e) 43 - case tangled.FeedStarNSID: 44 - ingestStar(&d, e) 45 - case tangled.PublicKeyNSID: 46 - ingestPublicKey(&d, e) 47 - case tangled.RepoArtifactNSID: 48 - ingestArtifact(&d, e, enforcer) 49 - case tangled.ActorProfileNSID: 50 - ingestProfile(&d, e) 51 - case tangled.SpindleMemberNSID: 52 - ingestSpindleMember(&d, e, enforcer) 53 - case tangled.SpindleNSID: 54 - ingestSpindle(&d, e, true) // TODO: change this to dynamic 84 + if err != nil { 85 + l.Debug("error ingesting record", "err", err) 55 86 } 56 87 57 - return err 88 + return nil 58 89 } 59 90 } 60 91 61 - func ingestStar(d *db.DbWrapper, e *models.Event) error { 92 + func (i *Ingester) ingestStar(e *models.Event) error { 62 93 var err error 63 94 did := e.Did 95 + 96 + l := i.Logger.With("handler", "ingestStar") 97 + l = l.With("nsid", e.Commit.Collection) 64 98 65 99 switch e.Commit.Operation { 66 100 case models.CommitOperationCreate, models.CommitOperationUpdate: ··· 70 104 record := tangled.FeedStar{} 71 105 err := json.Unmarshal(raw, &record) 72 106 if err != nil { 73 - log.Println("invalid record") 107 + l.Error("invalid record", "err", err) 74 108 return err 75 109 } 76 110 77 111 subjectUri, err = syntax.ParseATURI(record.Subject) 78 112 if err != nil { 79 - log.Println("invalid record") 113 + l.Error("invalid record", "err", err) 80 114 return err 81 115 } 82 - err = db.AddStar(d, did, subjectUri, e.Commit.RKey) 116 + err = db.AddStar(i.Db, &db.Star{ 117 + StarredByDid: did, 118 + RepoAt: subjectUri, 119 + Rkey: e.Commit.RKey, 120 + }) 83 121 case models.CommitOperationDelete: 84 - err = db.DeleteStarByRkey(d, did, e.Commit.RKey) 122 + err = db.DeleteStarByRkey(i.Db, did, e.Commit.RKey) 85 123 } 86 124 87 125 if err != nil { ··· 91 129 return nil 92 130 } 93 131 94 - func ingestFollow(d *db.DbWrapper, e *models.Event) error { 132 + func (i *Ingester) ingestFollow(e *models.Event) error { 95 133 var err error 96 134 did := e.Did 135 + 136 + l := i.Logger.With("handler", "ingestFollow") 137 + l = l.With("nsid", e.Commit.Collection) 97 138 98 139 switch e.Commit.Operation { 99 140 case models.CommitOperationCreate, models.CommitOperationUpdate: ··· 101 142 record := tangled.GraphFollow{} 102 143 err = json.Unmarshal(raw, &record) 103 144 if err != nil { 104 - log.Println("invalid record") 145 + l.Error("invalid record", "err", err) 105 146 return err 106 147 } 107 148 108 - subjectDid := record.Subject 109 - err = db.AddFollow(d, did, subjectDid, e.Commit.RKey) 149 + err = db.AddFollow(i.Db, &db.Follow{ 150 + UserDid: did, 151 + SubjectDid: record.Subject, 152 + Rkey: e.Commit.RKey, 153 + }) 110 154 case models.CommitOperationDelete: 111 - err = db.DeleteFollowByRkey(d, did, e.Commit.RKey) 155 + err = db.DeleteFollowByRkey(i.Db, did, e.Commit.RKey) 112 156 } 113 157 114 158 if err != nil { ··· 118 162 return nil 119 163 } 120 164 121 - func ingestPublicKey(d *db.DbWrapper, e *models.Event) error { 165 + func (i *Ingester) ingestPublicKey(e *models.Event) error { 122 166 did := e.Did 123 167 var err error 168 + 169 + l := i.Logger.With("handler", "ingestPublicKey") 170 + l = l.With("nsid", e.Commit.Collection) 124 171 125 172 switch e.Commit.Operation { 126 173 case models.CommitOperationCreate, models.CommitOperationUpdate: 127 - log.Println("processing add of pubkey") 174 + l.Debug("processing add of pubkey") 128 175 raw := json.RawMessage(e.Commit.Record) 129 176 record := tangled.PublicKey{} 130 177 err = json.Unmarshal(raw, &record) 131 178 if err != nil { 132 - log.Printf("invalid record: %s", err) 179 + l.Error("invalid record", "err", err) 133 180 return err 134 181 } 135 182 136 183 name := record.Name 137 184 key := record.Key 138 - err = db.AddPublicKey(d, did, name, key, e.Commit.RKey) 185 + err = db.AddPublicKey(i.Db, did, name, key, e.Commit.RKey) 139 186 case models.CommitOperationDelete: 140 - log.Println("processing delete of pubkey") 141 - err = db.DeletePublicKeyByRkey(d, did, e.Commit.RKey) 187 + l.Debug("processing delete of pubkey") 188 + err = db.DeletePublicKeyByRkey(i.Db, did, e.Commit.RKey) 142 189 } 143 190 144 191 if err != nil { ··· 148 195 return nil 149 196 } 150 197 151 - func ingestArtifact(d *db.DbWrapper, e *models.Event, enforcer *rbac.Enforcer) error { 198 + func (i *Ingester) ingestArtifact(e *models.Event) error { 152 199 did := e.Did 153 200 var err error 201 + 202 + l := i.Logger.With("handler", "ingestArtifact") 203 + l = l.With("nsid", e.Commit.Collection) 154 204 155 205 switch e.Commit.Operation { 156 206 case models.CommitOperationCreate, models.CommitOperationUpdate: ··· 158 208 record := tangled.RepoArtifact{} 159 209 err = json.Unmarshal(raw, &record) 160 210 if err != nil { 161 - log.Printf("invalid record: %s", err) 211 + l.Error("invalid record", "err", err) 162 212 return err 163 213 } 164 214 ··· 167 217 return err 168 218 } 169 219 170 - repo, err := db.GetRepoByAtUri(d, repoAt.String()) 220 + repo, err := db.GetRepoByAtUri(i.Db, repoAt.String()) 171 221 if err != nil { 172 222 return err 173 223 } 174 224 175 - ok, err := enforcer.E.Enforce(did, repo.Knot, repo.DidSlashRepo(), "repo:push") 225 + ok, err := i.Enforcer.E.Enforce(did, repo.Knot, repo.DidSlashRepo(), "repo:push") 176 226 if err != nil || !ok { 177 227 return err 178 228 } ··· 194 244 MimeType: record.Artifact.MimeType, 195 245 } 196 246 197 - err = db.AddArtifact(d, artifact) 247 + err = db.AddArtifact(i.Db, artifact) 198 248 case models.CommitOperationDelete: 199 - err = db.DeleteArtifact(d, db.FilterEq("did", did), db.FilterEq("rkey", e.Commit.RKey)) 249 + err = db.DeleteArtifact(i.Db, db.FilterEq("did", did), db.FilterEq("rkey", e.Commit.RKey)) 200 250 } 201 251 202 252 if err != nil { ··· 206 256 return nil 207 257 } 208 258 209 - func ingestProfile(d *db.DbWrapper, e *models.Event) error { 259 + func (i *Ingester) ingestProfile(e *models.Event) error { 210 260 did := e.Did 211 261 var err error 262 + 263 + l := i.Logger.With("handler", "ingestProfile") 264 + l = l.With("nsid", e.Commit.Collection) 212 265 213 266 if e.Commit.RKey != "self" { 214 267 return fmt.Errorf("ingestProfile only ingests `self` record") ··· 220 273 record := tangled.ActorProfile{} 221 274 err = json.Unmarshal(raw, &record) 222 275 if err != nil { 223 - log.Printf("invalid record: %s", err) 276 + l.Error("invalid record", "err", err) 224 277 return err 225 278 } 226 279 ··· 267 320 PinnedRepos: pinned, 268 321 } 269 322 270 - ddb, ok := d.Execer.(*db.DB) 323 + ddb, ok := i.Db.Execer.(*db.DB) 271 324 if !ok { 272 325 return fmt.Errorf("failed to index profile record, invalid db cast") 273 326 } ··· 284 337 285 338 err = db.UpsertProfile(tx, &profile) 286 339 case models.CommitOperationDelete: 287 - err = db.DeleteArtifact(d, db.FilterEq("did", did), db.FilterEq("rkey", e.Commit.RKey)) 340 + err = db.DeleteArtifact(i.Db, db.FilterEq("did", did), db.FilterEq("rkey", e.Commit.RKey)) 288 341 } 289 342 290 343 if err != nil { ··· 294 347 return nil 295 348 } 296 349 297 - func ingestSpindleMember(_ *db.DbWrapper, e *models.Event, enforcer *rbac.Enforcer) error { 350 + func (i *Ingester) ingestSpindleMember(ctx context.Context, e *models.Event) error { 298 351 did := e.Did 299 352 var err error 353 + 354 + l := i.Logger.With("handler", "ingestSpindleMember") 355 + l = l.With("nsid", e.Commit.Collection) 300 356 301 357 switch e.Commit.Operation { 302 358 case models.CommitOperationCreate: ··· 304 360 record := tangled.SpindleMember{} 305 361 err = json.Unmarshal(raw, &record) 306 362 if err != nil { 307 - log.Printf("invalid record: %s", err) 363 + l.Error("invalid record", "err", err) 308 364 return err 309 365 } 310 366 311 367 // only spindle owner can invite to spindles 312 - ok, err := enforcer.IsSpindleInviteAllowed(did, record.Instance) 368 + ok, err := i.Enforcer.IsSpindleInviteAllowed(did, record.Instance) 313 369 if err != nil || !ok { 314 370 return fmt.Errorf("failed to enforce permissions: %w", err) 315 371 } 316 372 317 - err = enforcer.AddSpindleMember(record.Instance, record.Subject) 373 + memberId, err := i.IdResolver.ResolveIdent(ctx, record.Subject) 318 374 if err != nil { 319 - return fmt.Errorf("failed to add member: %w", err) 375 + return err 376 + } 377 + 378 + if memberId.Handle.IsInvalidHandle() { 379 + return err 380 + } 381 + 382 + ddb, ok := i.Db.Execer.(*db.DB) 383 + if !ok { 384 + return fmt.Errorf("failed to index profile record, invalid db cast") 385 + } 386 + 387 + err = db.AddSpindleMember(ddb, db.SpindleMember{ 388 + Did: syntax.DID(did), 389 + Rkey: e.Commit.RKey, 390 + Instance: record.Instance, 391 + Subject: memberId.DID, 392 + }) 393 + if !ok { 394 + return fmt.Errorf("failed to add to db: %w", err) 320 395 } 396 + 397 + err = i.Enforcer.AddSpindleMember(record.Instance, memberId.DID.String()) 398 + if err != nil { 399 + return fmt.Errorf("failed to update ACLs: %w", err) 400 + } 401 + 402 + l.Info("added spindle member") 403 + case models.CommitOperationDelete: 404 + rkey := e.Commit.RKey 405 + 406 + ddb, ok := i.Db.Execer.(*db.DB) 407 + if !ok { 408 + return fmt.Errorf("failed to index profile record, invalid db cast") 409 + } 410 + 411 + // get record from db first 412 + members, err := db.GetSpindleMembers( 413 + ddb, 414 + db.FilterEq("did", did), 415 + db.FilterEq("rkey", rkey), 416 + ) 417 + if err != nil || len(members) != 1 { 418 + return fmt.Errorf("failed to get member: %w, len(members) = %d", err, len(members)) 419 + } 420 + member := members[0] 421 + 422 + tx, err := ddb.Begin() 423 + if err != nil { 424 + return fmt.Errorf("failed to start txn: %w", err) 425 + } 426 + 427 + // remove record by rkey && update enforcer 428 + if err = db.RemoveSpindleMember( 429 + tx, 430 + db.FilterEq("did", did), 431 + db.FilterEq("rkey", rkey), 432 + ); err != nil { 433 + return fmt.Errorf("failed to remove from db: %w", err) 434 + } 435 + 436 + // update enforcer 437 + err = i.Enforcer.RemoveSpindleMember(member.Instance, member.Subject.String()) 438 + if err != nil { 439 + return fmt.Errorf("failed to update ACLs: %w", err) 440 + } 441 + 442 + if err = tx.Commit(); err != nil { 443 + return fmt.Errorf("failed to commit txn: %w", err) 444 + } 445 + 446 + if err = i.Enforcer.E.SavePolicy(); err != nil { 447 + return fmt.Errorf("failed to save ACLs: %w", err) 448 + } 449 + 450 + l.Info("removed spindle member") 321 451 } 322 452 323 453 return nil 324 454 } 325 455 326 - func ingestSpindle(d *db.DbWrapper, e *models.Event, dev bool) error { 456 + func (i *Ingester) ingestSpindle(ctx context.Context, e *models.Event) error { 327 457 did := e.Did 328 458 var err error 329 459 460 + l := i.Logger.With("handler", "ingestSpindle") 461 + l = l.With("nsid", e.Commit.Collection) 462 + 330 463 switch e.Commit.Operation { 331 464 case models.CommitOperationCreate: 332 465 raw := json.RawMessage(e.Commit.Record) 333 466 record := tangled.Spindle{} 334 467 err = json.Unmarshal(raw, &record) 335 468 if err != nil { 336 - log.Printf("invalid record: %s", err) 469 + l.Error("invalid record", "err", err) 337 470 return err 338 471 } 339 472 340 - // this is a special record whose rkey is the instance of the spindle itself 341 473 instance := e.Commit.RKey 342 474 343 - owner, err := fetchOwner(context.TODO(), instance, dev) 475 + ddb, ok := i.Db.Execer.(*db.DB) 476 + if !ok { 477 + return fmt.Errorf("failed to index profile record, invalid db cast") 478 + } 479 + 480 + err := db.AddSpindle(ddb, db.Spindle{ 481 + Owner: syntax.DID(did), 482 + Instance: instance, 483 + }) 344 484 if err != nil { 345 - log.Printf("failed to verify owner of %s: %s", instance, err) 485 + l.Error("failed to add spindle to db", "err", err, "instance", instance) 346 486 return err 347 487 } 348 488 349 - // verify that the spindle owner points back to this did 350 - if owner != did { 351 - log.Printf("incorrect owner for domain: %s, %s != %s", instance, owner, did) 489 + err = serververify.RunVerification(ctx, instance, did, i.Config.Core.Dev) 490 + if err != nil { 491 + l.Error("failed to add spindle to db", "err", err, "instance", instance) 352 492 return err 353 493 } 354 494 355 - // mark this spindle as registered 356 - ddb, ok := d.Execer.(*db.DB) 495 + _, err = serververify.MarkSpindleVerified(ddb, i.Enforcer, instance, did) 496 + if err != nil { 497 + return fmt.Errorf("failed to mark verified: %w", err) 498 + } 499 + 500 + return nil 501 + 502 + case models.CommitOperationDelete: 503 + instance := e.Commit.RKey 504 + 505 + ddb, ok := i.Db.Execer.(*db.DB) 357 506 if !ok { 358 507 return fmt.Errorf("failed to index profile record, invalid db cast") 359 508 } 360 509 361 - _, err = db.VerifySpindle( 510 + // get record from db first 511 + spindles, err := db.GetSpindles( 362 512 ddb, 363 513 db.FilterEq("owner", did), 364 514 db.FilterEq("instance", instance), 365 515 ) 516 + if err != nil || len(spindles) != 1 { 517 + return fmt.Errorf("failed to get spindles: %w, len(spindles) = %d", err, len(spindles)) 518 + } 519 + spindle := spindles[0] 520 + 521 + tx, err := ddb.Begin() 522 + if err != nil { 523 + return err 524 + } 525 + defer func() { 526 + tx.Rollback() 527 + i.Enforcer.E.LoadPolicy() 528 + }() 529 + 530 + // remove spindle members first 531 + err = db.RemoveSpindleMember( 532 + tx, 533 + db.FilterEq("owner", did), 534 + db.FilterEq("instance", instance), 535 + ) 536 + if err != nil { 537 + return err 538 + } 539 + 540 + err = db.DeleteSpindle( 541 + tx, 542 + db.FilterEq("owner", did), 543 + db.FilterEq("instance", instance), 544 + ) 545 + if err != nil { 546 + return err 547 + } 548 + 549 + if spindle.Verified != nil { 550 + err = i.Enforcer.RemoveSpindle(instance) 551 + if err != nil { 552 + return err 553 + } 554 + } 555 + 556 + err = tx.Commit() 557 + if err != nil { 558 + return err 559 + } 560 + 561 + err = i.Enforcer.E.SavePolicy() 562 + if err != nil { 563 + return err 564 + } 565 + } 366 566 367 - return err 567 + return nil 568 + } 569 + 570 + func (i *Ingester) ingestString(e *models.Event) error { 571 + did := e.Did 572 + rkey := e.Commit.RKey 573 + 574 + var err error 575 + 576 + l := i.Logger.With("handler", "ingestString", "nsid", e.Commit.Collection, "did", did, "rkey", rkey) 577 + l.Info("ingesting record") 578 + 579 + ddb, ok := i.Db.Execer.(*db.DB) 580 + if !ok { 581 + return fmt.Errorf("failed to index string record, invalid db cast") 582 + } 583 + 584 + switch e.Commit.Operation { 585 + case models.CommitOperationCreate, models.CommitOperationUpdate: 586 + raw := json.RawMessage(e.Commit.Record) 587 + record := tangled.String{} 588 + err = json.Unmarshal(raw, &record) 589 + if err != nil { 590 + l.Error("invalid record", "err", err) 591 + return err 592 + } 593 + 594 + string := db.StringFromRecord(did, rkey, record) 595 + 596 + if err = string.Validate(); err != nil { 597 + l.Error("invalid record", "err", err) 598 + return err 599 + } 600 + 601 + if err = db.AddString(ddb, string); err != nil { 602 + l.Error("failed to add string", "err", err) 603 + return err 604 + } 605 + 606 + return nil 607 + 608 + case models.CommitOperationDelete: 609 + if err := db.DeleteString( 610 + ddb, 611 + db.FilterEq("did", did), 612 + db.FilterEq("rkey", rkey), 613 + ); err != nil { 614 + l.Error("failed to delete", "err", err) 615 + return fmt.Errorf("failed to delete string record: %w", err) 616 + } 617 + 618 + return nil 368 619 } 369 620 370 621 return nil 371 622 } 372 623 373 - func fetchOwner(ctx context.Context, domain string, dev bool) (string, error) { 374 - scheme := "https" 375 - if dev { 376 - scheme = "http" 624 + func (i *Ingester) ingestKnotMember(e *models.Event) error { 625 + did := e.Did 626 + var err error 627 + 628 + l := i.Logger.With("handler", "ingestKnotMember") 629 + l = l.With("nsid", e.Commit.Collection) 630 + 631 + switch e.Commit.Operation { 632 + case models.CommitOperationCreate: 633 + raw := json.RawMessage(e.Commit.Record) 634 + record := tangled.KnotMember{} 635 + err = json.Unmarshal(raw, &record) 636 + if err != nil { 637 + l.Error("invalid record", "err", err) 638 + return err 639 + } 640 + 641 + // only knot owner can invite to knots 642 + ok, err := i.Enforcer.IsKnotInviteAllowed(did, record.Domain) 643 + if err != nil || !ok { 644 + return fmt.Errorf("failed to enforce permissions: %w", err) 645 + } 646 + 647 + memberId, err := i.IdResolver.ResolveIdent(context.Background(), record.Subject) 648 + if err != nil { 649 + return err 650 + } 651 + 652 + if memberId.Handle.IsInvalidHandle() { 653 + return err 654 + } 655 + 656 + err = i.Enforcer.AddKnotMember(record.Domain, memberId.DID.String()) 657 + if err != nil { 658 + return fmt.Errorf("failed to update ACLs: %w", err) 659 + } 660 + 661 + l.Info("added knot member") 662 + case models.CommitOperationDelete: 663 + // we don't store knot members in a table (like we do for spindle) 664 + // and we can't remove this just yet. possibly fixed if we switch 665 + // to either: 666 + // 1. a knot_members table like with spindle and store the rkey 667 + // 2. use the knot host as the rkey 668 + // 669 + // TODO: implement member deletion 670 + l.Info("skipping knot member delete", "did", did, "rkey", e.Commit.RKey) 377 671 } 378 672 379 - url := fmt.Sprintf("%s://%s/owner", scheme, domain) 380 - req, err := http.NewRequest("GET", url, nil) 381 - if err != nil { 382 - return "", err 673 + return nil 674 + } 675 + 676 + func (i *Ingester) ingestKnot(e *models.Event) error { 677 + did := e.Did 678 + var err error 679 + 680 + l := i.Logger.With("handler", "ingestKnot") 681 + l = l.With("nsid", e.Commit.Collection) 682 + 683 + switch e.Commit.Operation { 684 + case models.CommitOperationCreate: 685 + raw := json.RawMessage(e.Commit.Record) 686 + record := tangled.Knot{} 687 + err = json.Unmarshal(raw, &record) 688 + if err != nil { 689 + l.Error("invalid record", "err", err) 690 + return err 691 + } 692 + 693 + domain := e.Commit.RKey 694 + 695 + ddb, ok := i.Db.Execer.(*db.DB) 696 + if !ok { 697 + return fmt.Errorf("failed to index profile record, invalid db cast") 698 + } 699 + 700 + err := db.AddKnot(ddb, domain, did) 701 + if err != nil { 702 + l.Error("failed to add knot to db", "err", err, "domain", domain) 703 + return err 704 + } 705 + 706 + err = serververify.RunVerification(context.Background(), domain, did, i.Config.Core.Dev) 707 + if err != nil { 708 + l.Error("failed to verify knot", "err", err, "domain", domain) 709 + return err 710 + } 711 + 712 + err = serververify.MarkKnotVerified(ddb, i.Enforcer, domain, did) 713 + if err != nil { 714 + return fmt.Errorf("failed to mark verified: %w", err) 715 + } 716 + 717 + return nil 718 + 719 + case models.CommitOperationDelete: 720 + domain := e.Commit.RKey 721 + 722 + ddb, ok := i.Db.Execer.(*db.DB) 723 + if !ok { 724 + return fmt.Errorf("failed to index knot record, invalid db cast") 725 + } 726 + 727 + // get record from db first 728 + registrations, err := db.GetRegistrations( 729 + ddb, 730 + db.FilterEq("domain", domain), 731 + db.FilterEq("did", did), 732 + ) 733 + if err != nil { 734 + return fmt.Errorf("failed to get registration: %w", err) 735 + } 736 + if len(registrations) != 1 { 737 + return fmt.Errorf("got incorret number of registrations: %d, expected 1", len(registrations)) 738 + } 739 + registration := registrations[0] 740 + 741 + tx, err := ddb.Begin() 742 + if err != nil { 743 + return err 744 + } 745 + defer func() { 746 + tx.Rollback() 747 + i.Enforcer.E.LoadPolicy() 748 + }() 749 + 750 + err = db.DeleteKnot( 751 + tx, 752 + db.FilterEq("did", did), 753 + db.FilterEq("domain", domain), 754 + ) 755 + if err != nil { 756 + return err 757 + } 758 + 759 + if registration.Registered != nil { 760 + err = i.Enforcer.RemoveKnot(domain) 761 + if err != nil { 762 + return err 763 + } 764 + } 765 + 766 + err = tx.Commit() 767 + if err != nil { 768 + return err 769 + } 770 + 771 + err = i.Enforcer.E.SavePolicy() 772 + if err != nil { 773 + return err 774 + } 383 775 } 384 776 385 - client := &http.Client{ 386 - Timeout: 1 * time.Second, 777 + return nil 778 + } 779 + func (i *Ingester) ingestIssue(ctx context.Context, e *models.Event) error { 780 + did := e.Did 781 + rkey := e.Commit.RKey 782 + 783 + var err error 784 + 785 + l := i.Logger.With("handler", "ingestIssue", "nsid", e.Commit.Collection, "did", did, "rkey", rkey) 786 + l.Info("ingesting record") 787 + 788 + ddb, ok := i.Db.Execer.(*db.DB) 789 + if !ok { 790 + return fmt.Errorf("failed to index issue record, invalid db cast") 387 791 } 388 792 389 - resp, err := client.Do(req.WithContext(ctx)) 390 - if err != nil || resp.StatusCode != 200 { 391 - return "", errors.New("failed to fetch /owner") 793 + switch e.Commit.Operation { 794 + case models.CommitOperationCreate, models.CommitOperationUpdate: 795 + raw := json.RawMessage(e.Commit.Record) 796 + record := tangled.RepoIssue{} 797 + err = json.Unmarshal(raw, &record) 798 + if err != nil { 799 + l.Error("invalid record", "err", err) 800 + return err 801 + } 802 + 803 + issue := db.IssueFromRecord(did, rkey, record) 804 + 805 + if err := i.Validator.ValidateIssue(&issue); err != nil { 806 + return fmt.Errorf("failed to validate issue: %w", err) 807 + } 808 + 809 + tx, err := ddb.BeginTx(ctx, nil) 810 + if err != nil { 811 + l.Error("failed to begin transaction", "err", err) 812 + return err 813 + } 814 + defer tx.Rollback() 815 + 816 + err = db.PutIssue(tx, &issue) 817 + if err != nil { 818 + l.Error("failed to create issue", "err", err) 819 + return err 820 + } 821 + 822 + err = tx.Commit() 823 + if err != nil { 824 + l.Error("failed to commit txn", "err", err) 825 + return err 826 + } 827 + 828 + return nil 829 + 830 + case models.CommitOperationDelete: 831 + if err := db.DeleteIssues( 832 + ddb, 833 + db.FilterEq("did", did), 834 + db.FilterEq("rkey", rkey), 835 + ); err != nil { 836 + l.Error("failed to delete", "err", err) 837 + return fmt.Errorf("failed to delete issue record: %w", err) 838 + } 839 + 840 + return nil 392 841 } 393 842 394 - body, err := io.ReadAll(io.LimitReader(resp.Body, 1024)) // read atmost 1kb of data 395 - if err != nil { 396 - return "", fmt.Errorf("failed to read /owner response: %w", err) 843 + return nil 844 + } 845 + 846 + func (i *Ingester) ingestIssueComment(e *models.Event) error { 847 + did := e.Did 848 + rkey := e.Commit.RKey 849 + 850 + var err error 851 + 852 + l := i.Logger.With("handler", "ingestIssueComment", "nsid", e.Commit.Collection, "did", did, "rkey", rkey) 853 + l.Info("ingesting record") 854 + 855 + ddb, ok := i.Db.Execer.(*db.DB) 856 + if !ok { 857 + return fmt.Errorf("failed to index issue comment record, invalid db cast") 397 858 } 398 859 399 - did := strings.TrimSpace(string(body)) 400 - if did == "" { 401 - return "", errors.New("empty DID in /owner response") 860 + switch e.Commit.Operation { 861 + case models.CommitOperationCreate, models.CommitOperationUpdate: 862 + raw := json.RawMessage(e.Commit.Record) 863 + record := tangled.RepoIssueComment{} 864 + err = json.Unmarshal(raw, &record) 865 + if err != nil { 866 + return fmt.Errorf("invalid record: %w", err) 867 + } 868 + 869 + comment, err := db.IssueCommentFromRecord(ddb, did, rkey, record) 870 + if err != nil { 871 + return fmt.Errorf("failed to parse comment from record: %w", err) 872 + } 873 + 874 + if err := i.Validator.ValidateIssueComment(comment); err != nil { 875 + return fmt.Errorf("failed to validate comment: %w", err) 876 + } 877 + 878 + _, err = db.AddIssueComment(ddb, *comment) 879 + if err != nil { 880 + return fmt.Errorf("failed to create issue comment: %w", err) 881 + } 882 + 883 + return nil 884 + 885 + case models.CommitOperationDelete: 886 + if err := db.DeleteIssueComments( 887 + ddb, 888 + db.FilterEq("did", did), 889 + db.FilterEq("rkey", rkey), 890 + ); err != nil { 891 + return fmt.Errorf("failed to delete issue comment record: %w", err) 892 + } 893 + 894 + return nil 402 895 } 403 896 404 - return did, nil 897 + return nil 405 898 }
+486 -343
appview/issues/issues.go
··· 1 1 package issues 2 2 3 3 import ( 4 + "context" 5 + "database/sql" 6 + "errors" 4 7 "fmt" 5 8 "log" 6 - mathrand "math/rand/v2" 9 + "log/slog" 7 10 "net/http" 8 11 "slices" 9 - "strconv" 10 12 "time" 11 13 12 14 comatproto "github.com/bluesky-social/indigo/api/atproto" 13 - "github.com/bluesky-social/indigo/atproto/data" 15 + "github.com/bluesky-social/indigo/atproto/syntax" 14 16 lexutil "github.com/bluesky-social/indigo/lex/util" 15 17 "github.com/go-chi/chi/v5" 16 - "github.com/posthog/posthog-go" 17 18 18 19 "tangled.sh/tangled.sh/core/api/tangled" 19 - "tangled.sh/tangled.sh/core/appview" 20 20 "tangled.sh/tangled.sh/core/appview/config" 21 21 "tangled.sh/tangled.sh/core/appview/db" 22 - "tangled.sh/tangled.sh/core/appview/idresolver" 22 + "tangled.sh/tangled.sh/core/appview/notify" 23 23 "tangled.sh/tangled.sh/core/appview/oauth" 24 24 "tangled.sh/tangled.sh/core/appview/pages" 25 25 "tangled.sh/tangled.sh/core/appview/pagination" 26 26 "tangled.sh/tangled.sh/core/appview/reporesolver" 27 + "tangled.sh/tangled.sh/core/appview/validator" 28 + "tangled.sh/tangled.sh/core/appview/xrpcclient" 29 + "tangled.sh/tangled.sh/core/idresolver" 30 + tlog "tangled.sh/tangled.sh/core/log" 31 + "tangled.sh/tangled.sh/core/tid" 27 32 ) 28 33 29 34 type Issues struct { ··· 33 38 idResolver *idresolver.Resolver 34 39 db *db.DB 35 40 config *config.Config 36 - posthog posthog.Client 41 + notifier notify.Notifier 42 + logger *slog.Logger 43 + validator *validator.Validator 37 44 } 38 45 39 46 func New( ··· 43 50 idResolver *idresolver.Resolver, 44 51 db *db.DB, 45 52 config *config.Config, 46 - posthog posthog.Client, 53 + notifier notify.Notifier, 54 + validator *validator.Validator, 47 55 ) *Issues { 48 56 return &Issues{ 49 57 oauth: oauth, ··· 52 60 idResolver: idResolver, 53 61 db: db, 54 62 config: config, 55 - posthog: posthog, 63 + notifier: notifier, 64 + logger: tlog.New("issues"), 65 + validator: validator, 56 66 } 57 67 } 58 68 59 69 func (rp *Issues) RepoSingleIssue(w http.ResponseWriter, r *http.Request) { 70 + l := rp.logger.With("handler", "RepoSingleIssue") 60 71 user := rp.oauth.GetUser(r) 61 72 f, err := rp.repoResolver.Resolve(r) 62 73 if err != nil { ··· 64 75 return 65 76 } 66 77 67 - issueId := chi.URLParam(r, "issue") 68 - issueIdInt, err := strconv.Atoi(issueId) 69 - if err != nil { 70 - http.Error(w, "bad issue id", http.StatusBadRequest) 71 - log.Println("failed to parse issue id", err) 78 + issue, ok := r.Context().Value("issue").(*db.Issue) 79 + if !ok { 80 + l.Error("failed to get issue") 81 + rp.pages.Error404(w) 72 82 return 73 83 } 74 84 75 - issue, comments, err := db.GetIssueWithComments(rp.db, f.RepoAt, issueIdInt) 85 + reactionCountMap, err := db.GetReactionCountMap(rp.db, issue.AtUri()) 76 86 if err != nil { 77 - log.Println("failed to get issue and comments", err) 78 - rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 79 - return 87 + l.Error("failed to get issue reactions", "err", err) 88 + } 89 + 90 + userReactions := map[db.ReactionKind]bool{} 91 + if user != nil { 92 + userReactions = db.GetReactionStatusMap(rp.db, user.Did, issue.AtUri()) 80 93 } 81 94 82 - issueOwnerIdent, err := rp.idResolver.ResolveIdent(r.Context(), issue.OwnerDid) 95 + rp.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{ 96 + LoggedInUser: user, 97 + RepoInfo: f.RepoInfo(user), 98 + Issue: issue, 99 + CommentList: issue.CommentList(), 100 + OrderedReactionKinds: db.OrderedReactionKinds, 101 + Reactions: reactionCountMap, 102 + UserReacted: userReactions, 103 + }) 104 + } 105 + 106 + func (rp *Issues) EditIssue(w http.ResponseWriter, r *http.Request) { 107 + l := rp.logger.With("handler", "EditIssue") 108 + user := rp.oauth.GetUser(r) 109 + f, err := rp.repoResolver.Resolve(r) 83 110 if err != nil { 84 - log.Println("failed to resolve issue owner", err) 111 + log.Println("failed to get repo and knot", err) 112 + return 85 113 } 86 114 87 - identsToResolve := make([]string, len(comments)) 88 - for i, comment := range comments { 89 - identsToResolve[i] = comment.OwnerDid 115 + issue, ok := r.Context().Value("issue").(*db.Issue) 116 + if !ok { 117 + l.Error("failed to get issue") 118 + rp.pages.Error404(w) 119 + return 90 120 } 91 - resolvedIds := rp.idResolver.ResolveIdents(r.Context(), identsToResolve) 92 - didHandleMap := make(map[string]string) 93 - for _, identity := range resolvedIds { 94 - if !identity.Handle.IsInvalidHandle() { 95 - didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 96 - } else { 97 - didHandleMap[identity.DID.String()] = identity.DID.String() 121 + 122 + switch r.Method { 123 + case http.MethodGet: 124 + rp.pages.EditIssueFragment(w, pages.EditIssueParams{ 125 + LoggedInUser: user, 126 + RepoInfo: f.RepoInfo(user), 127 + Issue: issue, 128 + }) 129 + case http.MethodPost: 130 + noticeId := "issues" 131 + newIssue := issue 132 + newIssue.Title = r.FormValue("title") 133 + newIssue.Body = r.FormValue("body") 134 + 135 + if err := rp.validator.ValidateIssue(newIssue); err != nil { 136 + l.Error("validation error", "err", err) 137 + rp.pages.Notice(w, noticeId, fmt.Sprintf("Failed to edit issue: %s", err)) 138 + return 98 139 } 99 - } 100 140 101 - rp.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{ 102 - LoggedInUser: user, 103 - RepoInfo: f.RepoInfo(user), 104 - Issue: *issue, 105 - Comments: comments, 141 + newRecord := newIssue.AsRecord() 106 142 107 - IssueOwnerHandle: issueOwnerIdent.Handle.String(), 108 - DidHandleMap: didHandleMap, 109 - }) 143 + // edit an atproto record 144 + client, err := rp.oauth.AuthorizedClient(r) 145 + if err != nil { 146 + l.Error("failed to get authorized client", "err", err) 147 + rp.pages.Notice(w, noticeId, "Failed to edit issue.") 148 + return 149 + } 150 + 151 + ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueNSID, user.Did, newIssue.Rkey) 152 + if err != nil { 153 + l.Error("failed to get record", "err", err) 154 + rp.pages.Notice(w, noticeId, "Failed to edit issue, no record found on PDS.") 155 + return 156 + } 157 + 158 + _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 159 + Collection: tangled.RepoIssueNSID, 160 + Repo: user.Did, 161 + Rkey: newIssue.Rkey, 162 + SwapRecord: ex.Cid, 163 + Record: &lexutil.LexiconTypeDecoder{ 164 + Val: &newRecord, 165 + }, 166 + }) 167 + if err != nil { 168 + l.Error("failed to edit record on PDS", "err", err) 169 + rp.pages.Notice(w, noticeId, "Failed to edit issue on PDS.") 170 + return 171 + } 172 + 173 + // modify on DB -- TODO: transact this cleverly 174 + tx, err := rp.db.Begin() 175 + if err != nil { 176 + l.Error("failed to edit issue on DB", "err", err) 177 + rp.pages.Notice(w, noticeId, "Failed to edit issue.") 178 + return 179 + } 180 + defer tx.Rollback() 181 + 182 + err = db.PutIssue(tx, newIssue) 183 + if err != nil { 184 + log.Println("failed to edit issue", err) 185 + rp.pages.Notice(w, "issues", "Failed to edit issue.") 186 + return 187 + } 110 188 189 + if err = tx.Commit(); err != nil { 190 + l.Error("failed to edit issue", "err", err) 191 + rp.pages.Notice(w, "issues", "Failed to cedit issue.") 192 + return 193 + } 194 + 195 + rp.pages.HxRefresh(w) 196 + } 111 197 } 112 198 113 - func (rp *Issues) CloseIssue(w http.ResponseWriter, r *http.Request) { 199 + func (rp *Issues) DeleteIssue(w http.ResponseWriter, r *http.Request) { 200 + l := rp.logger.With("handler", "DeleteIssue") 201 + noticeId := "issue-actions-error" 202 + 114 203 user := rp.oauth.GetUser(r) 204 + 115 205 f, err := rp.repoResolver.Resolve(r) 116 206 if err != nil { 117 - log.Println("failed to get repo and knot", err) 207 + l.Error("failed to get repo and knot", "err", err) 208 + return 209 + } 210 + 211 + issue, ok := r.Context().Value("issue").(*db.Issue) 212 + if !ok { 213 + l.Error("failed to get issue") 214 + rp.pages.Notice(w, noticeId, "Failed to delete issue.") 118 215 return 119 216 } 217 + l = l.With("did", issue.Did, "rkey", issue.Rkey) 120 218 121 - issueId := chi.URLParam(r, "issue") 122 - issueIdInt, err := strconv.Atoi(issueId) 219 + // delete from PDS 220 + client, err := rp.oauth.AuthorizedClient(r) 123 221 if err != nil { 124 - http.Error(w, "bad issue id", http.StatusBadRequest) 125 - log.Println("failed to parse issue id", err) 222 + log.Println("failed to get authorized client", err) 223 + rp.pages.Notice(w, "issue-comment", "Failed to delete comment.") 224 + return 225 + } 226 + _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 227 + Collection: tangled.RepoIssueNSID, 228 + Repo: issue.Did, 229 + Rkey: issue.Rkey, 230 + }) 231 + if err != nil { 232 + // TODO: transact this better 233 + l.Error("failed to delete issue from PDS", "err", err) 234 + rp.pages.Notice(w, noticeId, "Failed to delete issue.") 126 235 return 127 236 } 128 237 129 - issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt) 238 + // delete from db 239 + if err := db.DeleteIssues(rp.db, db.FilterEq("id", issue.Id)); err != nil { 240 + l.Error("failed to delete issue", "err", err) 241 + rp.pages.Notice(w, noticeId, "Failed to delete issue.") 242 + return 243 + } 244 + 245 + // return to all issues page 246 + rp.pages.HxRedirect(w, "/"+f.RepoInfo(user).FullName()+"/issues") 247 + } 248 + 249 + func (rp *Issues) CloseIssue(w http.ResponseWriter, r *http.Request) { 250 + l := rp.logger.With("handler", "CloseIssue") 251 + user := rp.oauth.GetUser(r) 252 + f, err := rp.repoResolver.Resolve(r) 130 253 if err != nil { 131 - log.Println("failed to get issue", err) 132 - rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 254 + l.Error("failed to get repo and knot", "err", err) 255 + return 256 + } 257 + 258 + issue, ok := r.Context().Value("issue").(*db.Issue) 259 + if !ok { 260 + l.Error("failed to get issue") 261 + rp.pages.Error404(w) 133 262 return 134 263 } 135 264 ··· 140 269 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 141 270 return user.Did == collab.Did 142 271 }) 143 - isIssueOwner := user.Did == issue.OwnerDid 272 + isIssueOwner := user.Did == issue.Did 144 273 145 274 // TODO: make this more granular 146 275 if isIssueOwner || isCollaborator { 147 - 148 - closed := tangled.RepoIssueStateClosed 149 - 150 - client, err := rp.oauth.AuthorizedClient(r) 151 - if err != nil { 152 - log.Println("failed to get authorized client", err) 153 - return 154 - } 155 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 156 - Collection: tangled.RepoIssueStateNSID, 157 - Repo: user.Did, 158 - Rkey: appview.TID(), 159 - Record: &lexutil.LexiconTypeDecoder{ 160 - Val: &tangled.RepoIssueState{ 161 - Issue: issue.IssueAt, 162 - State: closed, 163 - }, 164 - }, 165 - }) 166 - 167 - if err != nil { 168 - log.Println("failed to update issue state", err) 169 - rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 170 - return 171 - } 172 - 173 - err = db.CloseIssue(rp.db, f.RepoAt, issueIdInt) 276 + err = db.CloseIssues( 277 + rp.db, 278 + db.FilterEq("id", issue.Id), 279 + ) 174 280 if err != nil { 175 281 log.Println("failed to close issue", err) 176 282 rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 177 283 return 178 284 } 179 285 180 - rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt)) 286 + rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 181 287 return 182 288 } else { 183 289 log.Println("user is not permitted to close issue") ··· 187 293 } 188 294 189 295 func (rp *Issues) ReopenIssue(w http.ResponseWriter, r *http.Request) { 296 + l := rp.logger.With("handler", "ReopenIssue") 190 297 user := rp.oauth.GetUser(r) 191 298 f, err := rp.repoResolver.Resolve(r) 192 299 if err != nil { ··· 194 301 return 195 302 } 196 303 197 - issueId := chi.URLParam(r, "issue") 198 - issueIdInt, err := strconv.Atoi(issueId) 199 - if err != nil { 200 - http.Error(w, "bad issue id", http.StatusBadRequest) 201 - log.Println("failed to parse issue id", err) 202 - return 203 - } 204 - 205 - issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt) 206 - if err != nil { 207 - log.Println("failed to get issue", err) 208 - rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 304 + issue, ok := r.Context().Value("issue").(*db.Issue) 305 + if !ok { 306 + l.Error("failed to get issue") 307 + rp.pages.Error404(w) 209 308 return 210 309 } 211 310 ··· 216 315 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 217 316 return user.Did == collab.Did 218 317 }) 219 - isIssueOwner := user.Did == issue.OwnerDid 318 + isIssueOwner := user.Did == issue.Did 220 319 221 320 if isCollaborator || isIssueOwner { 222 - err := db.ReopenIssue(rp.db, f.RepoAt, issueIdInt) 321 + err := db.ReopenIssues( 322 + rp.db, 323 + db.FilterEq("id", issue.Id), 324 + ) 223 325 if err != nil { 224 326 log.Println("failed to reopen issue", err) 225 327 rp.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.") 226 328 return 227 329 } 228 - rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt)) 330 + rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 229 331 return 230 332 } else { 231 333 log.Println("user is not the owner of the repo") ··· 235 337 } 236 338 237 339 func (rp *Issues) NewIssueComment(w http.ResponseWriter, r *http.Request) { 340 + l := rp.logger.With("handler", "NewIssueComment") 238 341 user := rp.oauth.GetUser(r) 239 342 f, err := rp.repoResolver.Resolve(r) 240 343 if err != nil { 241 - log.Println("failed to get repo and knot", err) 344 + l.Error("failed to get repo and knot", "err", err) 242 345 return 243 346 } 244 347 245 - issueId := chi.URLParam(r, "issue") 246 - issueIdInt, err := strconv.Atoi(issueId) 247 - if err != nil { 248 - http.Error(w, "bad issue id", http.StatusBadRequest) 249 - log.Println("failed to parse issue id", err) 348 + issue, ok := r.Context().Value("issue").(*db.Issue) 349 + if !ok { 350 + l.Error("failed to get issue") 351 + rp.pages.Error404(w) 250 352 return 251 353 } 252 354 253 - switch r.Method { 254 - case http.MethodPost: 255 - body := r.FormValue("body") 256 - if body == "" { 257 - rp.pages.Notice(w, "issue", "Body is required") 258 - return 259 - } 355 + body := r.FormValue("body") 356 + if body == "" { 357 + rp.pages.Notice(w, "issue", "Body is required") 358 + return 359 + } 260 360 261 - commentId := mathrand.IntN(1000000) 262 - rkey := appview.TID() 263 - 264 - err := db.NewIssueComment(rp.db, &db.Comment{ 265 - OwnerDid: user.Did, 266 - RepoAt: f.RepoAt, 267 - Issue: issueIdInt, 268 - CommentId: commentId, 269 - Body: body, 270 - Rkey: rkey, 271 - }) 272 - if err != nil { 273 - log.Println("failed to create comment", err) 274 - rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 275 - return 276 - } 277 - 278 - createdAt := time.Now().Format(time.RFC3339) 279 - commentIdInt64 := int64(commentId) 280 - ownerDid := user.Did 281 - issueAt, err := db.GetIssueAt(rp.db, f.RepoAt, issueIdInt) 282 - if err != nil { 283 - log.Println("failed to get issue at", err) 284 - rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 285 - return 286 - } 361 + replyToUri := r.FormValue("reply-to") 362 + var replyTo *string 363 + if replyToUri != "" { 364 + replyTo = &replyToUri 365 + } 287 366 288 - atUri := f.RepoAt.String() 289 - client, err := rp.oauth.AuthorizedClient(r) 290 - if err != nil { 291 - log.Println("failed to get authorized client", err) 292 - rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 293 - return 294 - } 295 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 296 - Collection: tangled.RepoIssueCommentNSID, 297 - Repo: user.Did, 298 - Rkey: rkey, 299 - Record: &lexutil.LexiconTypeDecoder{ 300 - Val: &tangled.RepoIssueComment{ 301 - Repo: &atUri, 302 - Issue: issueAt, 303 - CommentId: &commentIdInt64, 304 - Owner: &ownerDid, 305 - Body: body, 306 - CreatedAt: createdAt, 307 - }, 308 - }, 309 - }) 310 - if err != nil { 311 - log.Println("failed to create comment", err) 312 - rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 313 - return 314 - } 315 - 316 - rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issueIdInt, commentId)) 367 + comment := db.IssueComment{ 368 + Did: user.Did, 369 + Rkey: tid.TID(), 370 + IssueAt: issue.AtUri().String(), 371 + ReplyTo: replyTo, 372 + Body: body, 373 + Created: time.Now(), 374 + } 375 + if err = rp.validator.ValidateIssueComment(&comment); err != nil { 376 + l.Error("failed to validate comment", "err", err) 377 + rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 317 378 return 318 379 } 319 - } 380 + record := comment.AsRecord() 320 381 321 - func (rp *Issues) IssueComment(w http.ResponseWriter, r *http.Request) { 322 - user := rp.oauth.GetUser(r) 323 - f, err := rp.repoResolver.Resolve(r) 382 + client, err := rp.oauth.AuthorizedClient(r) 324 383 if err != nil { 325 - log.Println("failed to get repo and knot", err) 384 + l.Error("failed to get authorized client", "err", err) 385 + rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 326 386 return 327 387 } 328 388 329 - issueId := chi.URLParam(r, "issue") 330 - issueIdInt, err := strconv.Atoi(issueId) 389 + // create a record first 390 + resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 391 + Collection: tangled.RepoIssueCommentNSID, 392 + Repo: comment.Did, 393 + Rkey: comment.Rkey, 394 + Record: &lexutil.LexiconTypeDecoder{ 395 + Val: &record, 396 + }, 397 + }) 331 398 if err != nil { 332 - http.Error(w, "bad issue id", http.StatusBadRequest) 333 - log.Println("failed to parse issue id", err) 399 + l.Error("failed to create comment", "err", err) 400 + rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 334 401 return 335 402 } 403 + atUri := resp.Uri 404 + defer func() { 405 + if err := rollbackRecord(context.Background(), atUri, client); err != nil { 406 + l.Error("rollback failed", "err", err) 407 + } 408 + }() 336 409 337 - commentId := chi.URLParam(r, "comment_id") 338 - commentIdInt, err := strconv.Atoi(commentId) 410 + commentId, err := db.AddIssueComment(rp.db, comment) 339 411 if err != nil { 340 - http.Error(w, "bad comment id", http.StatusBadRequest) 341 - log.Println("failed to parse issue id", err) 412 + l.Error("failed to create comment", "err", err) 413 + rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 342 414 return 343 415 } 344 416 345 - issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt) 417 + // reset atUri to make rollback a no-op 418 + atUri = "" 419 + rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issue.IssueId, commentId)) 420 + } 421 + 422 + func (rp *Issues) IssueComment(w http.ResponseWriter, r *http.Request) { 423 + l := rp.logger.With("handler", "IssueComment") 424 + user := rp.oauth.GetUser(r) 425 + f, err := rp.repoResolver.Resolve(r) 346 426 if err != nil { 347 - log.Println("failed to get issue", err) 348 - rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 427 + l.Error("failed to get repo and knot", "err", err) 349 428 return 350 429 } 351 430 352 - comment, err := db.GetComment(rp.db, f.RepoAt, issueIdInt, commentIdInt) 353 - if err != nil { 354 - http.Error(w, "bad comment id", http.StatusBadRequest) 431 + issue, ok := r.Context().Value("issue").(*db.Issue) 432 + if !ok { 433 + l.Error("failed to get issue") 434 + rp.pages.Error404(w) 355 435 return 356 436 } 357 437 358 - identity, err := rp.idResolver.ResolveIdent(r.Context(), comment.OwnerDid) 438 + commentId := chi.URLParam(r, "commentId") 439 + comments, err := db.GetIssueComments( 440 + rp.db, 441 + db.FilterEq("id", commentId), 442 + ) 359 443 if err != nil { 360 - log.Println("failed to resolve did") 444 + l.Error("failed to fetch comment", "id", commentId) 445 + http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 361 446 return 362 447 } 363 - 364 - didHandleMap := make(map[string]string) 365 - if !identity.Handle.IsInvalidHandle() { 366 - didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 367 - } else { 368 - didHandleMap[identity.DID.String()] = identity.DID.String() 448 + if len(comments) != 1 { 449 + l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) 450 + http.Error(w, "invalid comment id", http.StatusBadRequest) 451 + return 369 452 } 453 + comment := comments[0] 370 454 371 - rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ 455 + rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{ 372 456 LoggedInUser: user, 373 457 RepoInfo: f.RepoInfo(user), 374 - DidHandleMap: didHandleMap, 375 458 Issue: issue, 376 - Comment: comment, 459 + Comment: &comment, 377 460 }) 378 461 } 379 462 380 463 func (rp *Issues) EditIssueComment(w http.ResponseWriter, r *http.Request) { 464 + l := rp.logger.With("handler", "EditIssueComment") 381 465 user := rp.oauth.GetUser(r) 382 466 f, err := rp.repoResolver.Resolve(r) 383 467 if err != nil { 384 - log.Println("failed to get repo and knot", err) 385 - return 386 - } 387 - 388 - issueId := chi.URLParam(r, "issue") 389 - issueIdInt, err := strconv.Atoi(issueId) 390 - if err != nil { 391 - http.Error(w, "bad issue id", http.StatusBadRequest) 392 - log.Println("failed to parse issue id", err) 468 + l.Error("failed to get repo and knot", "err", err) 393 469 return 394 470 } 395 471 396 - commentId := chi.URLParam(r, "comment_id") 397 - commentIdInt, err := strconv.Atoi(commentId) 398 - if err != nil { 399 - http.Error(w, "bad comment id", http.StatusBadRequest) 400 - log.Println("failed to parse issue id", err) 472 + issue, ok := r.Context().Value("issue").(*db.Issue) 473 + if !ok { 474 + l.Error("failed to get issue") 475 + rp.pages.Error404(w) 401 476 return 402 477 } 403 478 404 - issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt) 479 + commentId := chi.URLParam(r, "commentId") 480 + comments, err := db.GetIssueComments( 481 + rp.db, 482 + db.FilterEq("id", commentId), 483 + ) 405 484 if err != nil { 406 - log.Println("failed to get issue", err) 407 - rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 485 + l.Error("failed to fetch comment", "id", commentId) 486 + http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 408 487 return 409 488 } 410 - 411 - comment, err := db.GetComment(rp.db, f.RepoAt, issueIdInt, commentIdInt) 412 - if err != nil { 413 - http.Error(w, "bad comment id", http.StatusBadRequest) 489 + if len(comments) != 1 { 490 + l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) 491 + http.Error(w, "invalid comment id", http.StatusBadRequest) 414 492 return 415 493 } 494 + comment := comments[0] 416 495 417 - if comment.OwnerDid != user.Did { 496 + if comment.Did != user.Did { 497 + l.Error("unauthorized comment edit", "expectedDid", comment.Did, "gotDid", user.Did) 418 498 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 419 499 return 420 500 } ··· 425 505 LoggedInUser: user, 426 506 RepoInfo: f.RepoInfo(user), 427 507 Issue: issue, 428 - Comment: comment, 508 + Comment: &comment, 429 509 }) 430 510 case http.MethodPost: 431 511 // extract form value ··· 436 516 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 437 517 return 438 518 } 439 - rkey := comment.Rkey 519 + 520 + now := time.Now() 521 + newComment := comment 522 + newComment.Body = newBody 523 + newComment.Edited = &now 524 + record := newComment.AsRecord() 440 525 441 - // optimistic update 442 - edited := time.Now() 443 - err = db.EditComment(rp.db, comment.RepoAt, comment.Issue, comment.CommentId, newBody) 526 + _, err = db.AddIssueComment(rp.db, newComment) 444 527 if err != nil { 445 528 log.Println("failed to perferom update-description query", err) 446 529 rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") ··· 448 531 } 449 532 450 533 // rkey is optional, it was introduced later 451 - if comment.Rkey != "" { 534 + if newComment.Rkey != "" { 452 535 // update the record on pds 453 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueCommentNSID, user.Did, rkey) 536 + ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueCommentNSID, user.Did, comment.Rkey) 454 537 if err != nil { 455 - // failed to get record 456 - log.Println(err, rkey) 538 + log.Println("failed to get record", "err", err, "did", newComment.Did, "rkey", newComment.Rkey) 457 539 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.") 458 540 return 459 541 } 460 - value, _ := ex.Value.MarshalJSON() // we just did get record; it is valid json 461 - record, _ := data.UnmarshalJSON(value) 462 - 463 - repoAt := record["repo"].(string) 464 - issueAt := record["issue"].(string) 465 - createdAt := record["createdAt"].(string) 466 - commentIdInt64 := int64(commentIdInt) 467 542 468 543 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 469 544 Collection: tangled.RepoIssueCommentNSID, 470 545 Repo: user.Did, 471 - Rkey: rkey, 546 + Rkey: newComment.Rkey, 472 547 SwapRecord: ex.Cid, 473 548 Record: &lexutil.LexiconTypeDecoder{ 474 - Val: &tangled.RepoIssueComment{ 475 - Repo: &repoAt, 476 - Issue: issueAt, 477 - CommentId: &commentIdInt64, 478 - Owner: &comment.OwnerDid, 479 - Body: newBody, 480 - CreatedAt: createdAt, 481 - }, 549 + Val: &record, 482 550 }, 483 551 }) 484 552 if err != nil { 485 - log.Println(err) 553 + l.Error("failed to update record on PDS", "err", err) 486 554 } 487 555 } 488 556 489 - // optimistic update for htmx 490 - didHandleMap := map[string]string{ 491 - user.Did: user.Handle, 492 - } 493 - comment.Body = newBody 494 - comment.Edited = &edited 495 - 496 557 // return new comment body with htmx 497 - rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ 558 + rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{ 498 559 LoggedInUser: user, 499 560 RepoInfo: f.RepoInfo(user), 500 - DidHandleMap: didHandleMap, 501 561 Issue: issue, 502 - Comment: comment, 562 + Comment: &newComment, 503 563 }) 564 + } 565 + } 566 + 567 + func (rp *Issues) ReplyIssueCommentPlaceholder(w http.ResponseWriter, r *http.Request) { 568 + l := rp.logger.With("handler", "ReplyIssueCommentPlaceholder") 569 + user := rp.oauth.GetUser(r) 570 + f, err := rp.repoResolver.Resolve(r) 571 + if err != nil { 572 + l.Error("failed to get repo and knot", "err", err) 504 573 return 574 + } 505 575 576 + issue, ok := r.Context().Value("issue").(*db.Issue) 577 + if !ok { 578 + l.Error("failed to get issue") 579 + rp.pages.Error404(w) 580 + return 506 581 } 507 582 583 + commentId := chi.URLParam(r, "commentId") 584 + comments, err := db.GetIssueComments( 585 + rp.db, 586 + db.FilterEq("id", commentId), 587 + ) 588 + if err != nil { 589 + l.Error("failed to fetch comment", "id", commentId) 590 + http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 591 + return 592 + } 593 + if len(comments) != 1 { 594 + l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) 595 + http.Error(w, "invalid comment id", http.StatusBadRequest) 596 + return 597 + } 598 + comment := comments[0] 599 + 600 + rp.pages.ReplyIssueCommentPlaceholderFragment(w, pages.ReplyIssueCommentPlaceholderParams{ 601 + LoggedInUser: user, 602 + RepoInfo: f.RepoInfo(user), 603 + Issue: issue, 604 + Comment: &comment, 605 + }) 508 606 } 509 607 510 - func (rp *Issues) DeleteIssueComment(w http.ResponseWriter, r *http.Request) { 608 + func (rp *Issues) ReplyIssueComment(w http.ResponseWriter, r *http.Request) { 609 + l := rp.logger.With("handler", "ReplyIssueComment") 511 610 user := rp.oauth.GetUser(r) 512 611 f, err := rp.repoResolver.Resolve(r) 513 612 if err != nil { 514 - log.Println("failed to get repo and knot", err) 613 + l.Error("failed to get repo and knot", "err", err) 515 614 return 516 615 } 517 616 518 - issueId := chi.URLParam(r, "issue") 519 - issueIdInt, err := strconv.Atoi(issueId) 520 - if err != nil { 521 - http.Error(w, "bad issue id", http.StatusBadRequest) 522 - log.Println("failed to parse issue id", err) 617 + issue, ok := r.Context().Value("issue").(*db.Issue) 618 + if !ok { 619 + l.Error("failed to get issue") 620 + rp.pages.Error404(w) 523 621 return 524 622 } 525 623 526 - issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt) 624 + commentId := chi.URLParam(r, "commentId") 625 + comments, err := db.GetIssueComments( 626 + rp.db, 627 + db.FilterEq("id", commentId), 628 + ) 527 629 if err != nil { 528 - log.Println("failed to get issue", err) 529 - rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 630 + l.Error("failed to fetch comment", "id", commentId) 631 + http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 530 632 return 531 633 } 634 + if len(comments) != 1 { 635 + l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) 636 + http.Error(w, "invalid comment id", http.StatusBadRequest) 637 + return 638 + } 639 + comment := comments[0] 532 640 533 - commentId := chi.URLParam(r, "comment_id") 534 - commentIdInt, err := strconv.Atoi(commentId) 641 + rp.pages.ReplyIssueCommentFragment(w, pages.ReplyIssueCommentParams{ 642 + LoggedInUser: user, 643 + RepoInfo: f.RepoInfo(user), 644 + Issue: issue, 645 + Comment: &comment, 646 + }) 647 + } 648 + 649 + func (rp *Issues) DeleteIssueComment(w http.ResponseWriter, r *http.Request) { 650 + l := rp.logger.With("handler", "DeleteIssueComment") 651 + user := rp.oauth.GetUser(r) 652 + f, err := rp.repoResolver.Resolve(r) 535 653 if err != nil { 536 - http.Error(w, "bad comment id", http.StatusBadRequest) 537 - log.Println("failed to parse issue id", err) 654 + l.Error("failed to get repo and knot", "err", err) 538 655 return 539 656 } 540 657 541 - comment, err := db.GetComment(rp.db, f.RepoAt, issueIdInt, commentIdInt) 658 + issue, ok := r.Context().Value("issue").(*db.Issue) 659 + if !ok { 660 + l.Error("failed to get issue") 661 + rp.pages.Error404(w) 662 + return 663 + } 664 + 665 + commentId := chi.URLParam(r, "commentId") 666 + comments, err := db.GetIssueComments( 667 + rp.db, 668 + db.FilterEq("id", commentId), 669 + ) 542 670 if err != nil { 543 - http.Error(w, "bad comment id", http.StatusBadRequest) 671 + l.Error("failed to fetch comment", "id", commentId) 672 + http.Error(w, "failed to fetch comment id", http.StatusBadRequest) 544 673 return 545 674 } 675 + if len(comments) != 1 { 676 + l.Error("incorrect number of comments returned", "id", commentId, "len(comments)", len(comments)) 677 + http.Error(w, "invalid comment id", http.StatusBadRequest) 678 + return 679 + } 680 + comment := comments[0] 546 681 547 - if comment.OwnerDid != user.Did { 682 + if comment.Did != user.Did { 683 + l.Error("unauthorized action", "expectedDid", comment.Did, "gotDid", user.Did) 548 684 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 549 685 return 550 686 } ··· 556 692 557 693 // optimistic deletion 558 694 deleted := time.Now() 559 - err = db.DeleteComment(rp.db, f.RepoAt, issueIdInt, commentIdInt) 695 + err = db.DeleteIssueComments(rp.db, db.FilterEq("id", comment.Id)) 560 696 if err != nil { 561 - log.Println("failed to delete comment") 697 + l.Error("failed to delete comment", "err", err) 562 698 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment") 563 699 return 564 700 } ··· 572 708 return 573 709 } 574 710 _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 575 - Collection: tangled.GraphFollowNSID, 711 + Collection: tangled.RepoIssueCommentNSID, 576 712 Repo: user.Did, 577 713 Rkey: comment.Rkey, 578 714 }) ··· 582 718 } 583 719 584 720 // optimistic update for htmx 585 - didHandleMap := map[string]string{ 586 - user.Did: user.Handle, 587 - } 588 721 comment.Body = "" 589 722 comment.Deleted = &deleted 590 723 591 724 // htmx fragment of comment after deletion 592 - rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ 725 + rp.pages.IssueCommentBodyFragment(w, pages.IssueCommentBodyParams{ 593 726 LoggedInUser: user, 594 727 RepoInfo: f.RepoInfo(user), 595 - DidHandleMap: didHandleMap, 596 728 Issue: issue, 597 - Comment: comment, 729 + Comment: &comment, 598 730 }) 599 - return 600 731 } 601 732 602 733 func (rp *Issues) RepoIssues(w http.ResponseWriter, r *http.Request) { ··· 625 756 return 626 757 } 627 758 628 - issues, err := db.GetIssues(rp.db, f.RepoAt, isOpen, page) 759 + openVal := 0 760 + if isOpen { 761 + openVal = 1 762 + } 763 + issues, err := db.GetIssuesPaginated( 764 + rp.db, 765 + page, 766 + db.FilterEq("repo_at", f.RepoAt()), 767 + db.FilterEq("open", openVal), 768 + ) 629 769 if err != nil { 630 770 log.Println("failed to get issues", err) 631 771 rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.") 632 772 return 633 773 } 634 774 635 - identsToResolve := make([]string, len(issues)) 636 - for i, issue := range issues { 637 - identsToResolve[i] = issue.OwnerDid 638 - } 639 - resolvedIds := rp.idResolver.ResolveIdents(r.Context(), identsToResolve) 640 - didHandleMap := make(map[string]string) 641 - for _, identity := range resolvedIds { 642 - if !identity.Handle.IsInvalidHandle() { 643 - didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 644 - } else { 645 - didHandleMap[identity.DID.String()] = identity.DID.String() 646 - } 647 - } 648 - 649 775 rp.pages.RepoIssues(w, pages.RepoIssuesParams{ 650 776 LoggedInUser: rp.oauth.GetUser(r), 651 777 RepoInfo: f.RepoInfo(user), 652 778 Issues: issues, 653 - DidHandleMap: didHandleMap, 654 779 FilteringByOpen: isOpen, 655 780 Page: page, 656 781 }) 657 - return 658 782 } 659 783 660 784 func (rp *Issues) NewIssue(w http.ResponseWriter, r *http.Request) { 785 + l := rp.logger.With("handler", "NewIssue") 661 786 user := rp.oauth.GetUser(r) 662 787 663 788 f, err := rp.repoResolver.Resolve(r) 664 789 if err != nil { 665 - log.Println("failed to get repo and knot", err) 790 + l.Error("failed to get repo and knot", "err", err) 666 791 return 667 792 } 668 793 ··· 673 798 RepoInfo: f.RepoInfo(user), 674 799 }) 675 800 case http.MethodPost: 676 - title := r.FormValue("title") 677 - body := r.FormValue("body") 678 - 679 - if title == "" || body == "" { 680 - rp.pages.Notice(w, "issues", "Title and body are required") 681 - return 801 + issue := &db.Issue{ 802 + RepoAt: f.RepoAt(), 803 + Rkey: tid.TID(), 804 + Title: r.FormValue("title"), 805 + Body: r.FormValue("body"), 806 + Did: user.Did, 807 + Created: time.Now(), 682 808 } 683 809 684 - tx, err := rp.db.BeginTx(r.Context(), nil) 685 - if err != nil { 686 - rp.pages.Notice(w, "issues", "Failed to create issue, try again later") 687 - return 688 - } 689 - 690 - err = db.NewIssue(tx, &db.Issue{ 691 - RepoAt: f.RepoAt, 692 - Title: title, 693 - Body: body, 694 - OwnerDid: user.Did, 695 - }) 696 - if err != nil { 697 - log.Println("failed to create issue", err) 698 - rp.pages.Notice(w, "issues", "Failed to create issue.") 810 + if err := rp.validator.ValidateIssue(issue); err != nil { 811 + l.Error("validation error", "err", err) 812 + rp.pages.Notice(w, "issues", fmt.Sprintf("Failed to create issue: %s", err)) 699 813 return 700 814 } 701 815 702 - issueId, err := db.GetIssueId(rp.db, f.RepoAt) 703 - if err != nil { 704 - log.Println("failed to get issue id", err) 705 - rp.pages.Notice(w, "issues", "Failed to create issue.") 706 - return 707 - } 816 + record := issue.AsRecord() 708 817 818 + // create an atproto record 709 819 client, err := rp.oauth.AuthorizedClient(r) 710 820 if err != nil { 711 - log.Println("failed to get authorized client", err) 821 + l.Error("failed to get authorized client", "err", err) 712 822 rp.pages.Notice(w, "issues", "Failed to create issue.") 713 823 return 714 824 } 715 - atUri := f.RepoAt.String() 716 825 resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 717 826 Collection: tangled.RepoIssueNSID, 718 827 Repo: user.Did, 719 - Rkey: appview.TID(), 828 + Rkey: issue.Rkey, 720 829 Record: &lexutil.LexiconTypeDecoder{ 721 - Val: &tangled.RepoIssue{ 722 - Repo: atUri, 723 - Title: title, 724 - Body: &body, 725 - Owner: user.Did, 726 - IssueId: int64(issueId), 727 - }, 830 + Val: &record, 728 831 }, 729 832 }) 730 833 if err != nil { 731 - log.Println("failed to create issue", err) 834 + l.Error("failed to create issue", "err", err) 732 835 rp.pages.Notice(w, "issues", "Failed to create issue.") 733 836 return 734 837 } 838 + atUri := resp.Uri 735 839 736 - err = db.SetIssueAt(rp.db, f.RepoAt, issueId, resp.Uri) 840 + tx, err := rp.db.BeginTx(r.Context(), nil) 841 + if err != nil { 842 + rp.pages.Notice(w, "issues", "Failed to create issue, try again later") 843 + return 844 + } 845 + rollback := func() { 846 + err1 := tx.Rollback() 847 + err2 := rollbackRecord(context.Background(), atUri, client) 848 + 849 + if errors.Is(err1, sql.ErrTxDone) { 850 + err1 = nil 851 + } 852 + 853 + if err := errors.Join(err1, err2); err != nil { 854 + l.Error("failed to rollback txn", "err", err) 855 + } 856 + } 857 + defer rollback() 858 + 859 + err = db.PutIssue(tx, issue) 737 860 if err != nil { 738 - log.Println("failed to set issue at", err) 861 + log.Println("failed to create issue", err) 739 862 rp.pages.Notice(w, "issues", "Failed to create issue.") 740 863 return 741 864 } 742 865 743 - if !rp.config.Core.Dev { 744 - err = rp.posthog.Enqueue(posthog.Capture{ 745 - DistinctId: user.Did, 746 - Event: "new_issue", 747 - Properties: posthog.Properties{"repo_at": f.RepoAt.String(), "issue_id": issueId}, 748 - }) 749 - if err != nil { 750 - log.Println("failed to enqueue posthog event:", err) 751 - } 866 + if err = tx.Commit(); err != nil { 867 + log.Println("failed to create issue", err) 868 + rp.pages.Notice(w, "issues", "Failed to create issue.") 869 + return 752 870 } 753 871 754 - rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueId)) 872 + // everything is successful, do not rollback the atproto record 873 + atUri = "" 874 + rp.notifier.NewIssue(r.Context(), issue) 875 + rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 755 876 return 756 877 } 757 878 } 879 + 880 + // this is used to rollback changes made to the PDS 881 + // 882 + // it is a no-op if the provided ATURI is empty 883 + func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error { 884 + if aturi == "" { 885 + return nil 886 + } 887 + 888 + parsed := syntax.ATURI(aturi) 889 + 890 + collection := parsed.Collection().String() 891 + repo := parsed.Authority().String() 892 + rkey := parsed.RecordKey().String() 893 + 894 + _, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{ 895 + Collection: collection, 896 + Repo: repo, 897 + Rkey: rkey, 898 + }) 899 + return err 900 + }
+24 -10
appview/issues/router.go
··· 12 12 13 13 r.Route("/", func(r chi.Router) { 14 14 r.With(middleware.Paginate).Get("/", i.RepoIssues) 15 - r.Get("/{issue}", i.RepoSingleIssue) 15 + 16 + r.Route("/{issue}", func(r chi.Router) { 17 + r.Use(mw.ResolveIssue()) 18 + r.Get("/", i.RepoSingleIssue) 19 + 20 + // authenticated routes 21 + r.Group(func(r chi.Router) { 22 + r.Use(middleware.AuthMiddleware(i.oauth)) 23 + r.Post("/comment", i.NewIssueComment) 24 + r.Route("/comment/{commentId}/", func(r chi.Router) { 25 + r.Get("/", i.IssueComment) 26 + r.Delete("/", i.DeleteIssueComment) 27 + r.Get("/edit", i.EditIssueComment) 28 + r.Post("/edit", i.EditIssueComment) 29 + r.Get("/reply", i.ReplyIssueComment) 30 + r.Get("/replyPlaceholder", i.ReplyIssueCommentPlaceholder) 31 + }) 32 + r.Get("/edit", i.EditIssue) 33 + r.Post("/edit", i.EditIssue) 34 + r.Delete("/", i.DeleteIssue) 35 + r.Post("/close", i.CloseIssue) 36 + r.Post("/reopen", i.ReopenIssue) 37 + }) 38 + }) 16 39 17 40 r.Group(func(r chi.Router) { 18 41 r.Use(middleware.AuthMiddleware(i.oauth)) 19 42 r.Get("/new", i.NewIssue) 20 43 r.Post("/new", i.NewIssue) 21 - r.Post("/{issue}/comment", i.NewIssueComment) 22 - r.Route("/{issue}/comment/{comment_id}/", func(r chi.Router) { 23 - r.Get("/", i.IssueComment) 24 - r.Delete("/", i.DeleteIssueComment) 25 - r.Get("/edit", i.EditIssueComment) 26 - r.Post("/edit", i.EditIssueComment) 27 - }) 28 - r.Post("/{issue}/close", i.CloseIssue) 29 - r.Post("/{issue}/reopen", i.ReopenIssue) 30 44 }) 31 45 }) 32 46
+676
appview/knots/knots.go
··· 1 + package knots 2 + 3 + import ( 4 + "errors" 5 + "fmt" 6 + "log/slog" 7 + "net/http" 8 + "slices" 9 + "time" 10 + 11 + "github.com/go-chi/chi/v5" 12 + "tangled.sh/tangled.sh/core/api/tangled" 13 + "tangled.sh/tangled.sh/core/appview/config" 14 + "tangled.sh/tangled.sh/core/appview/db" 15 + "tangled.sh/tangled.sh/core/appview/middleware" 16 + "tangled.sh/tangled.sh/core/appview/oauth" 17 + "tangled.sh/tangled.sh/core/appview/pages" 18 + "tangled.sh/tangled.sh/core/appview/serververify" 19 + "tangled.sh/tangled.sh/core/appview/xrpcclient" 20 + "tangled.sh/tangled.sh/core/eventconsumer" 21 + "tangled.sh/tangled.sh/core/idresolver" 22 + "tangled.sh/tangled.sh/core/rbac" 23 + "tangled.sh/tangled.sh/core/tid" 24 + 25 + comatproto "github.com/bluesky-social/indigo/api/atproto" 26 + lexutil "github.com/bluesky-social/indigo/lex/util" 27 + ) 28 + 29 + type Knots struct { 30 + Db *db.DB 31 + OAuth *oauth.OAuth 32 + Pages *pages.Pages 33 + Config *config.Config 34 + Enforcer *rbac.Enforcer 35 + IdResolver *idresolver.Resolver 36 + Logger *slog.Logger 37 + Knotstream *eventconsumer.Consumer 38 + } 39 + 40 + func (k *Knots) Router() http.Handler { 41 + r := chi.NewRouter() 42 + 43 + r.With(middleware.AuthMiddleware(k.OAuth)).Get("/", k.knots) 44 + r.With(middleware.AuthMiddleware(k.OAuth)).Post("/register", k.register) 45 + 46 + r.With(middleware.AuthMiddleware(k.OAuth)).Get("/{domain}", k.dashboard) 47 + r.With(middleware.AuthMiddleware(k.OAuth)).Delete("/{domain}", k.delete) 48 + 49 + r.With(middleware.AuthMiddleware(k.OAuth)).Post("/{domain}/retry", k.retry) 50 + r.With(middleware.AuthMiddleware(k.OAuth)).Post("/{domain}/add", k.addMember) 51 + r.With(middleware.AuthMiddleware(k.OAuth)).Post("/{domain}/remove", k.removeMember) 52 + 53 + return r 54 + } 55 + 56 + func (k *Knots) knots(w http.ResponseWriter, r *http.Request) { 57 + user := k.OAuth.GetUser(r) 58 + registrations, err := db.GetRegistrations( 59 + k.Db, 60 + db.FilterEq("did", user.Did), 61 + ) 62 + if err != nil { 63 + k.Logger.Error("failed to fetch knot registrations", "err", err) 64 + w.WriteHeader(http.StatusInternalServerError) 65 + return 66 + } 67 + 68 + k.Pages.Knots(w, pages.KnotsParams{ 69 + LoggedInUser: user, 70 + Registrations: registrations, 71 + }) 72 + } 73 + 74 + func (k *Knots) dashboard(w http.ResponseWriter, r *http.Request) { 75 + l := k.Logger.With("handler", "dashboard") 76 + 77 + user := k.OAuth.GetUser(r) 78 + l = l.With("user", user.Did) 79 + 80 + domain := chi.URLParam(r, "domain") 81 + if domain == "" { 82 + return 83 + } 84 + l = l.With("domain", domain) 85 + 86 + registrations, err := db.GetRegistrations( 87 + k.Db, 88 + db.FilterEq("did", user.Did), 89 + db.FilterEq("domain", domain), 90 + ) 91 + if err != nil { 92 + l.Error("failed to get registrations", "err", err) 93 + http.Error(w, "Not found", http.StatusNotFound) 94 + return 95 + } 96 + if len(registrations) != 1 { 97 + l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1) 98 + return 99 + } 100 + registration := registrations[0] 101 + 102 + members, err := k.Enforcer.GetUserByRole("server:member", domain) 103 + if err != nil { 104 + l.Error("failed to get knot members", "err", err) 105 + http.Error(w, "Not found", http.StatusInternalServerError) 106 + return 107 + } 108 + slices.Sort(members) 109 + 110 + repos, err := db.GetRepos( 111 + k.Db, 112 + 0, 113 + db.FilterEq("knot", domain), 114 + ) 115 + if err != nil { 116 + l.Error("failed to get knot repos", "err", err) 117 + http.Error(w, "Not found", http.StatusInternalServerError) 118 + return 119 + } 120 + 121 + // organize repos by did 122 + repoMap := make(map[string][]db.Repo) 123 + for _, r := range repos { 124 + repoMap[r.Did] = append(repoMap[r.Did], r) 125 + } 126 + 127 + k.Pages.Knot(w, pages.KnotParams{ 128 + LoggedInUser: user, 129 + Registration: &registration, 130 + Members: members, 131 + Repos: repoMap, 132 + IsOwner: true, 133 + }) 134 + } 135 + 136 + func (k *Knots) register(w http.ResponseWriter, r *http.Request) { 137 + user := k.OAuth.GetUser(r) 138 + l := k.Logger.With("handler", "register") 139 + 140 + noticeId := "register-error" 141 + defaultErr := "Failed to register knot. Try again later." 142 + fail := func() { 143 + k.Pages.Notice(w, noticeId, defaultErr) 144 + } 145 + 146 + domain := r.FormValue("domain") 147 + if domain == "" { 148 + k.Pages.Notice(w, noticeId, "Incomplete form.") 149 + return 150 + } 151 + l = l.With("domain", domain) 152 + l = l.With("user", user.Did) 153 + 154 + tx, err := k.Db.Begin() 155 + if err != nil { 156 + l.Error("failed to start transaction", "err", err) 157 + fail() 158 + return 159 + } 160 + defer func() { 161 + tx.Rollback() 162 + k.Enforcer.E.LoadPolicy() 163 + }() 164 + 165 + err = db.AddKnot(tx, domain, user.Did) 166 + if err != nil { 167 + l.Error("failed to insert", "err", err) 168 + fail() 169 + return 170 + } 171 + 172 + err = k.Enforcer.AddKnot(domain) 173 + if err != nil { 174 + l.Error("failed to create knot", "err", err) 175 + fail() 176 + return 177 + } 178 + 179 + // create record on pds 180 + client, err := k.OAuth.AuthorizedClient(r) 181 + if err != nil { 182 + l.Error("failed to authorize client", "err", err) 183 + fail() 184 + return 185 + } 186 + 187 + ex, _ := client.RepoGetRecord(r.Context(), "", tangled.KnotNSID, user.Did, domain) 188 + var exCid *string 189 + if ex != nil { 190 + exCid = ex.Cid 191 + } 192 + 193 + // re-announce by registering under same rkey 194 + _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 195 + Collection: tangled.KnotNSID, 196 + Repo: user.Did, 197 + Rkey: domain, 198 + Record: &lexutil.LexiconTypeDecoder{ 199 + Val: &tangled.Knot{ 200 + CreatedAt: time.Now().Format(time.RFC3339), 201 + }, 202 + }, 203 + SwapRecord: exCid, 204 + }) 205 + 206 + if err != nil { 207 + l.Error("failed to put record", "err", err) 208 + fail() 209 + return 210 + } 211 + 212 + err = tx.Commit() 213 + if err != nil { 214 + l.Error("failed to commit transaction", "err", err) 215 + fail() 216 + return 217 + } 218 + 219 + err = k.Enforcer.E.SavePolicy() 220 + if err != nil { 221 + l.Error("failed to update ACL", "err", err) 222 + k.Pages.HxRefresh(w) 223 + return 224 + } 225 + 226 + // begin verification 227 + err = serververify.RunVerification(r.Context(), domain, user.Did, k.Config.Core.Dev) 228 + if err != nil { 229 + l.Error("verification failed", "err", err) 230 + k.Pages.HxRefresh(w) 231 + return 232 + } 233 + 234 + err = serververify.MarkKnotVerified(k.Db, k.Enforcer, domain, user.Did) 235 + if err != nil { 236 + l.Error("failed to mark verified", "err", err) 237 + k.Pages.HxRefresh(w) 238 + return 239 + } 240 + 241 + // add this knot to knotstream 242 + go k.Knotstream.AddSource( 243 + r.Context(), 244 + eventconsumer.NewKnotSource(domain), 245 + ) 246 + 247 + // ok 248 + k.Pages.HxRefresh(w) 249 + } 250 + 251 + func (k *Knots) delete(w http.ResponseWriter, r *http.Request) { 252 + user := k.OAuth.GetUser(r) 253 + l := k.Logger.With("handler", "delete") 254 + 255 + noticeId := "operation-error" 256 + defaultErr := "Failed to delete knot. Try again later." 257 + fail := func() { 258 + k.Pages.Notice(w, noticeId, defaultErr) 259 + } 260 + 261 + domain := chi.URLParam(r, "domain") 262 + if domain == "" { 263 + l.Error("empty domain") 264 + fail() 265 + return 266 + } 267 + 268 + // get record from db first 269 + registrations, err := db.GetRegistrations( 270 + k.Db, 271 + db.FilterEq("did", user.Did), 272 + db.FilterEq("domain", domain), 273 + ) 274 + if err != nil { 275 + l.Error("failed to get registration", "err", err) 276 + fail() 277 + return 278 + } 279 + if len(registrations) != 1 { 280 + l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1) 281 + fail() 282 + return 283 + } 284 + registration := registrations[0] 285 + 286 + tx, err := k.Db.Begin() 287 + if err != nil { 288 + l.Error("failed to start txn", "err", err) 289 + fail() 290 + return 291 + } 292 + defer func() { 293 + tx.Rollback() 294 + k.Enforcer.E.LoadPolicy() 295 + }() 296 + 297 + err = db.DeleteKnot( 298 + tx, 299 + db.FilterEq("did", user.Did), 300 + db.FilterEq("domain", domain), 301 + ) 302 + if err != nil { 303 + l.Error("failed to delete registration", "err", err) 304 + fail() 305 + return 306 + } 307 + 308 + // delete from enforcer if it was registered 309 + if registration.Registered != nil { 310 + err = k.Enforcer.RemoveKnot(domain) 311 + if err != nil { 312 + l.Error("failed to update ACL", "err", err) 313 + fail() 314 + return 315 + } 316 + } 317 + 318 + client, err := k.OAuth.AuthorizedClient(r) 319 + if err != nil { 320 + l.Error("failed to authorize client", "err", err) 321 + fail() 322 + return 323 + } 324 + 325 + _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 326 + Collection: tangled.KnotNSID, 327 + Repo: user.Did, 328 + Rkey: domain, 329 + }) 330 + if err != nil { 331 + // non-fatal 332 + l.Error("failed to delete record", "err", err) 333 + } 334 + 335 + err = tx.Commit() 336 + if err != nil { 337 + l.Error("failed to delete knot", "err", err) 338 + fail() 339 + return 340 + } 341 + 342 + err = k.Enforcer.E.SavePolicy() 343 + if err != nil { 344 + l.Error("failed to update ACL", "err", err) 345 + k.Pages.HxRefresh(w) 346 + return 347 + } 348 + 349 + shouldRedirect := r.Header.Get("shouldRedirect") 350 + if shouldRedirect == "true" { 351 + k.Pages.HxRedirect(w, "/knots") 352 + return 353 + } 354 + 355 + w.Write([]byte{}) 356 + } 357 + 358 + func (k *Knots) retry(w http.ResponseWriter, r *http.Request) { 359 + user := k.OAuth.GetUser(r) 360 + l := k.Logger.With("handler", "retry") 361 + 362 + noticeId := "operation-error" 363 + defaultErr := "Failed to verify knot. Try again later." 364 + fail := func() { 365 + k.Pages.Notice(w, noticeId, defaultErr) 366 + } 367 + 368 + domain := chi.URLParam(r, "domain") 369 + if domain == "" { 370 + l.Error("empty domain") 371 + fail() 372 + return 373 + } 374 + l = l.With("domain", domain) 375 + l = l.With("user", user.Did) 376 + 377 + // get record from db first 378 + registrations, err := db.GetRegistrations( 379 + k.Db, 380 + db.FilterEq("did", user.Did), 381 + db.FilterEq("domain", domain), 382 + ) 383 + if err != nil { 384 + l.Error("failed to get registration", "err", err) 385 + fail() 386 + return 387 + } 388 + if len(registrations) != 1 { 389 + l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1) 390 + fail() 391 + return 392 + } 393 + registration := registrations[0] 394 + 395 + // begin verification 396 + err = serververify.RunVerification(r.Context(), domain, user.Did, k.Config.Core.Dev) 397 + if err != nil { 398 + l.Error("verification failed", "err", err) 399 + 400 + if errors.Is(err, xrpcclient.ErrXrpcUnsupported) { 401 + k.Pages.Notice(w, noticeId, "Failed to verify knot, XRPC queries are unsupported on this knot, consider upgrading!") 402 + return 403 + } 404 + 405 + if e, ok := err.(*serververify.OwnerMismatch); ok { 406 + k.Pages.Notice(w, noticeId, e.Error()) 407 + return 408 + } 409 + 410 + fail() 411 + return 412 + } 413 + 414 + err = serververify.MarkKnotVerified(k.Db, k.Enforcer, domain, user.Did) 415 + if err != nil { 416 + l.Error("failed to mark verified", "err", err) 417 + k.Pages.Notice(w, noticeId, err.Error()) 418 + return 419 + } 420 + 421 + // if this knot requires upgrade, then emit a record too 422 + // 423 + // this is part of migrating from the old knot system to the new one 424 + if registration.NeedsUpgrade { 425 + // re-announce by registering under same rkey 426 + client, err := k.OAuth.AuthorizedClient(r) 427 + if err != nil { 428 + l.Error("failed to authorize client", "err", err) 429 + fail() 430 + return 431 + } 432 + 433 + ex, _ := client.RepoGetRecord(r.Context(), "", tangled.KnotNSID, user.Did, domain) 434 + var exCid *string 435 + if ex != nil { 436 + exCid = ex.Cid 437 + } 438 + 439 + // ignore the error here 440 + _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 441 + Collection: tangled.KnotNSID, 442 + Repo: user.Did, 443 + Rkey: domain, 444 + Record: &lexutil.LexiconTypeDecoder{ 445 + Val: &tangled.Knot{ 446 + CreatedAt: time.Now().Format(time.RFC3339), 447 + }, 448 + }, 449 + SwapRecord: exCid, 450 + }) 451 + if err != nil { 452 + l.Error("non-fatal: failed to reannouce knot", "err", err) 453 + } 454 + } 455 + 456 + // add this knot to knotstream 457 + go k.Knotstream.AddSource( 458 + r.Context(), 459 + eventconsumer.NewKnotSource(domain), 460 + ) 461 + 462 + shouldRefresh := r.Header.Get("shouldRefresh") 463 + if shouldRefresh == "true" { 464 + k.Pages.HxRefresh(w) 465 + return 466 + } 467 + 468 + // Get updated registration to show 469 + registrations, err = db.GetRegistrations( 470 + k.Db, 471 + db.FilterEq("did", user.Did), 472 + db.FilterEq("domain", domain), 473 + ) 474 + if err != nil { 475 + l.Error("failed to get registration", "err", err) 476 + fail() 477 + return 478 + } 479 + if len(registrations) != 1 { 480 + l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1) 481 + fail() 482 + return 483 + } 484 + updatedRegistration := registrations[0] 485 + 486 + w.Header().Set("HX-Reswap", "outerHTML") 487 + k.Pages.KnotListing(w, pages.KnotListingParams{ 488 + Registration: &updatedRegistration, 489 + }) 490 + } 491 + 492 + func (k *Knots) addMember(w http.ResponseWriter, r *http.Request) { 493 + user := k.OAuth.GetUser(r) 494 + l := k.Logger.With("handler", "addMember") 495 + 496 + domain := chi.URLParam(r, "domain") 497 + if domain == "" { 498 + l.Error("empty domain") 499 + http.Error(w, "Not found", http.StatusNotFound) 500 + return 501 + } 502 + l = l.With("domain", domain) 503 + l = l.With("user", user.Did) 504 + 505 + registrations, err := db.GetRegistrations( 506 + k.Db, 507 + db.FilterEq("did", user.Did), 508 + db.FilterEq("domain", domain), 509 + db.FilterIsNot("registered", "null"), 510 + ) 511 + if err != nil { 512 + l.Error("failed to get registration", "err", err) 513 + return 514 + } 515 + if len(registrations) != 1 { 516 + l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1) 517 + return 518 + } 519 + registration := registrations[0] 520 + 521 + noticeId := fmt.Sprintf("add-member-error-%d", registration.Id) 522 + defaultErr := "Failed to add member. Try again later." 523 + fail := func() { 524 + k.Pages.Notice(w, noticeId, defaultErr) 525 + } 526 + 527 + member := r.FormValue("member") 528 + if member == "" { 529 + l.Error("empty member") 530 + k.Pages.Notice(w, noticeId, "Failed to add member, empty form.") 531 + return 532 + } 533 + l = l.With("member", member) 534 + 535 + memberId, err := k.IdResolver.ResolveIdent(r.Context(), member) 536 + if err != nil { 537 + l.Error("failed to resolve member identity to handle", "err", err) 538 + k.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.") 539 + return 540 + } 541 + if memberId.Handle.IsInvalidHandle() { 542 + l.Error("failed to resolve member identity to handle") 543 + k.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.") 544 + return 545 + } 546 + 547 + // write to pds 548 + client, err := k.OAuth.AuthorizedClient(r) 549 + if err != nil { 550 + l.Error("failed to authorize client", "err", err) 551 + fail() 552 + return 553 + } 554 + 555 + rkey := tid.TID() 556 + 557 + _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 558 + Collection: tangled.KnotMemberNSID, 559 + Repo: user.Did, 560 + Rkey: rkey, 561 + Record: &lexutil.LexiconTypeDecoder{ 562 + Val: &tangled.KnotMember{ 563 + CreatedAt: time.Now().Format(time.RFC3339), 564 + Domain: domain, 565 + Subject: memberId.DID.String(), 566 + }, 567 + }, 568 + }) 569 + if err != nil { 570 + l.Error("failed to add record to PDS", "err", err) 571 + k.Pages.Notice(w, noticeId, "Failed to add record to PDS, try again later.") 572 + return 573 + } 574 + 575 + err = k.Enforcer.AddKnotMember(domain, memberId.DID.String()) 576 + if err != nil { 577 + l.Error("failed to add member to ACLs", "err", err) 578 + fail() 579 + return 580 + } 581 + 582 + err = k.Enforcer.E.SavePolicy() 583 + if err != nil { 584 + l.Error("failed to save ACL policy", "err", err) 585 + fail() 586 + return 587 + } 588 + 589 + // success 590 + k.Pages.HxRedirect(w, fmt.Sprintf("/knots/%s", domain)) 591 + } 592 + 593 + func (k *Knots) removeMember(w http.ResponseWriter, r *http.Request) { 594 + user := k.OAuth.GetUser(r) 595 + l := k.Logger.With("handler", "removeMember") 596 + 597 + noticeId := "operation-error" 598 + defaultErr := "Failed to remove member. Try again later." 599 + fail := func() { 600 + k.Pages.Notice(w, noticeId, defaultErr) 601 + } 602 + 603 + domain := chi.URLParam(r, "domain") 604 + if domain == "" { 605 + l.Error("empty domain") 606 + fail() 607 + return 608 + } 609 + l = l.With("domain", domain) 610 + l = l.With("user", user.Did) 611 + 612 + registrations, err := db.GetRegistrations( 613 + k.Db, 614 + db.FilterEq("did", user.Did), 615 + db.FilterEq("domain", domain), 616 + db.FilterIsNot("registered", "null"), 617 + ) 618 + if err != nil { 619 + l.Error("failed to get registration", "err", err) 620 + return 621 + } 622 + if len(registrations) != 1 { 623 + l.Error("got incorret number of registrations", "got", len(registrations), "expected", 1) 624 + return 625 + } 626 + 627 + member := r.FormValue("member") 628 + if member == "" { 629 + l.Error("empty member") 630 + k.Pages.Notice(w, noticeId, "Failed to remove member, empty form.") 631 + return 632 + } 633 + l = l.With("member", member) 634 + 635 + memberId, err := k.IdResolver.ResolveIdent(r.Context(), member) 636 + if err != nil { 637 + l.Error("failed to resolve member identity to handle", "err", err) 638 + k.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.") 639 + return 640 + } 641 + if memberId.Handle.IsInvalidHandle() { 642 + l.Error("failed to resolve member identity to handle") 643 + k.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.") 644 + return 645 + } 646 + 647 + // remove from enforcer 648 + err = k.Enforcer.RemoveKnotMember(domain, memberId.DID.String()) 649 + if err != nil { 650 + l.Error("failed to update ACLs", "err", err) 651 + fail() 652 + return 653 + } 654 + 655 + client, err := k.OAuth.AuthorizedClient(r) 656 + if err != nil { 657 + l.Error("failed to authorize client", "err", err) 658 + fail() 659 + return 660 + } 661 + 662 + // TODO: We need to track the rkey for knot members to delete the record 663 + // For now, just remove from ACLs 664 + _ = client 665 + 666 + // commit everything 667 + err = k.Enforcer.E.SavePolicy() 668 + if err != nil { 669 + l.Error("failed to save ACLs", "err", err) 670 + fail() 671 + return 672 + } 673 + 674 + // ok 675 + k.Pages.HxRefresh(w) 676 + }
+61 -26
appview/middleware/middleware.go
··· 5 5 "fmt" 6 6 "log" 7 7 "net/http" 8 + "net/url" 8 9 "slices" 9 10 "strconv" 10 11 "strings" 11 - "time" 12 12 13 13 "github.com/bluesky-social/indigo/atproto/identity" 14 14 "github.com/go-chi/chi/v5" 15 15 "tangled.sh/tangled.sh/core/appview/db" 16 - "tangled.sh/tangled.sh/core/appview/idresolver" 17 16 "tangled.sh/tangled.sh/core/appview/oauth" 18 17 "tangled.sh/tangled.sh/core/appview/pages" 19 18 "tangled.sh/tangled.sh/core/appview/pagination" 20 19 "tangled.sh/tangled.sh/core/appview/reporesolver" 20 + "tangled.sh/tangled.sh/core/idresolver" 21 21 "tangled.sh/tangled.sh/core/rbac" 22 22 ) 23 23 ··· 46 46 func AuthMiddleware(a *oauth.OAuth) middlewareFunc { 47 47 return func(next http.Handler) http.Handler { 48 48 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 49 + returnURL := "/" 50 + if u, err := url.Parse(r.Header.Get("Referer")); err == nil { 51 + returnURL = u.RequestURI() 52 + } 53 + 54 + loginURL := fmt.Sprintf("/login?return_url=%s", url.QueryEscape(returnURL)) 55 + 49 56 redirectFunc := func(w http.ResponseWriter, r *http.Request) { 50 - http.Redirect(w, r, "/login", http.StatusTemporaryRedirect) 57 + http.Redirect(w, r, loginURL, http.StatusTemporaryRedirect) 51 58 } 52 59 if r.Header.Get("HX-Request") == "true" { 53 60 redirectFunc = func(w http.ResponseWriter, _ *http.Request) { 54 - w.Header().Set("HX-Redirect", "/login") 61 + w.Header().Set("HX-Redirect", loginURL) 55 62 w.WriteHeader(http.StatusOK) 56 63 } 57 64 } ··· 167 174 } 168 175 } 169 176 170 - func StripLeadingAt(next http.Handler) http.Handler { 171 - return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 172 - path := req.URL.EscapedPath() 173 - if strings.HasPrefix(path, "/@") { 174 - req.URL.RawPath = "/" + strings.TrimPrefix(path, "/@") 175 - } 176 - next.ServeHTTP(w, req) 177 - }) 178 - } 179 - 180 177 func (mw Middleware) ResolveIdent() middlewareFunc { 181 178 excluded := []string{"favicon.ico"} 182 179 ··· 188 185 return 189 186 } 190 187 188 + didOrHandle = strings.TrimPrefix(didOrHandle, "@") 189 + 191 190 id, err := mw.idResolver.ResolveIdent(req.Context(), didOrHandle) 192 191 if err != nil { 193 192 // invalid did or handle 194 - log.Println("failed to resolve did/handle:", err) 195 - w.WriteHeader(http.StatusNotFound) 193 + log.Printf("failed to resolve did/handle '%s': %s\n", didOrHandle, err) 194 + mw.pages.Error404(w) 196 195 return 197 196 } 198 197 ··· 218 217 if err != nil { 219 218 // invalid did or handle 220 219 log.Println("failed to resolve repo") 221 - mw.pages.Error404(w) 220 + mw.pages.ErrorKnot404(w) 222 221 return 223 222 } 224 223 225 - ctx := context.WithValue(req.Context(), "knot", repo.Knot) 226 - ctx = context.WithValue(ctx, "repoAt", repo.AtUri) 227 - ctx = context.WithValue(ctx, "repoDescription", repo.Description) 228 - ctx = context.WithValue(ctx, "repoSpindle", repo.Spindle) 229 - ctx = context.WithValue(ctx, "repoAddedAt", repo.Created.Format(time.RFC3339)) 224 + ctx := context.WithValue(req.Context(), "repo", repo) 230 225 next.ServeHTTP(w, req.WithContext(ctx)) 231 226 }) 232 227 } ··· 239 234 f, err := mw.repoResolver.Resolve(r) 240 235 if err != nil { 241 236 log.Println("failed to fully resolve repo", err) 242 - http.Error(w, "invalid repo url", http.StatusNotFound) 237 + mw.pages.ErrorKnot404(w) 243 238 return 244 239 } 245 240 ··· 251 246 return 252 247 } 253 248 254 - pr, err := db.GetPull(mw.db, f.RepoAt, prIdInt) 249 + pr, err := db.GetPull(mw.db, f.RepoAt(), prIdInt) 255 250 if err != nil { 256 251 log.Println("failed to get pull and comments", err) 257 252 return ··· 280 275 } 281 276 } 282 277 278 + // middleware that is tacked on top of /{user}/{repo}/issues/{issue} 279 + func (mw Middleware) ResolveIssue() middlewareFunc { 280 + return func(next http.Handler) http.Handler { 281 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 282 + f, err := mw.repoResolver.Resolve(r) 283 + if err != nil { 284 + log.Println("failed to fully resolve repo", err) 285 + mw.pages.ErrorKnot404(w) 286 + return 287 + } 288 + 289 + issueIdStr := chi.URLParam(r, "issue") 290 + issueId, err := strconv.Atoi(issueIdStr) 291 + if err != nil { 292 + log.Println("failed to fully resolve issue ID", err) 293 + mw.pages.ErrorKnot404(w) 294 + return 295 + } 296 + 297 + issues, err := db.GetIssues( 298 + mw.db, 299 + db.FilterEq("repo_at", f.RepoAt()), 300 + db.FilterEq("issue_id", issueId), 301 + ) 302 + if err != nil { 303 + log.Println("failed to get issues", "err", err) 304 + return 305 + } 306 + if len(issues) != 1 { 307 + log.Println("got incorrect number of issues", "len(issuse)", len(issues)) 308 + return 309 + } 310 + issue := issues[0] 311 + 312 + ctx := context.WithValue(r.Context(), "issue", &issue) 313 + next.ServeHTTP(w, r.WithContext(ctx)) 314 + }) 315 + } 316 + } 317 + 283 318 // this should serve the go-import meta tag even if the path is technically 284 319 // a 404 like tangled.sh/oppi.li/go-git/v5 285 320 func (mw Middleware) GoImport() middlewareFunc { ··· 288 323 f, err := mw.repoResolver.Resolve(r) 289 324 if err != nil { 290 325 log.Println("failed to fully resolve repo", err) 291 - http.Error(w, "invalid repo url", http.StatusNotFound) 326 + mw.pages.ErrorKnot404(w) 292 327 return 293 328 } 294 329 295 - fullName := f.OwnerHandle() + "/" + f.RepoName 330 + fullName := f.OwnerHandle() + "/" + f.Name 296 331 297 332 if r.Header.Get("User-Agent") == "Go-http-client/1.1" { 298 333 if r.URL.Query().Get("go-get") == "1" {
+86
appview/notify/merged_notifier.go
··· 1 + package notify 2 + 3 + import ( 4 + "context" 5 + 6 + "tangled.sh/tangled.sh/core/appview/db" 7 + ) 8 + 9 + type mergedNotifier struct { 10 + notifiers []Notifier 11 + } 12 + 13 + func NewMergedNotifier(notifiers ...Notifier) Notifier { 14 + return &mergedNotifier{notifiers} 15 + } 16 + 17 + var _ Notifier = &mergedNotifier{} 18 + 19 + func (m *mergedNotifier) NewRepo(ctx context.Context, repo *db.Repo) { 20 + for _, notifier := range m.notifiers { 21 + notifier.NewRepo(ctx, repo) 22 + } 23 + } 24 + 25 + func (m *mergedNotifier) NewStar(ctx context.Context, star *db.Star) { 26 + for _, notifier := range m.notifiers { 27 + notifier.NewStar(ctx, star) 28 + } 29 + } 30 + func (m *mergedNotifier) DeleteStar(ctx context.Context, star *db.Star) { 31 + for _, notifier := range m.notifiers { 32 + notifier.DeleteStar(ctx, star) 33 + } 34 + } 35 + 36 + func (m *mergedNotifier) NewIssue(ctx context.Context, issue *db.Issue) { 37 + for _, notifier := range m.notifiers { 38 + notifier.NewIssue(ctx, issue) 39 + } 40 + } 41 + 42 + func (m *mergedNotifier) NewFollow(ctx context.Context, follow *db.Follow) { 43 + for _, notifier := range m.notifiers { 44 + notifier.NewFollow(ctx, follow) 45 + } 46 + } 47 + func (m *mergedNotifier) DeleteFollow(ctx context.Context, follow *db.Follow) { 48 + for _, notifier := range m.notifiers { 49 + notifier.DeleteFollow(ctx, follow) 50 + } 51 + } 52 + 53 + func (m *mergedNotifier) NewPull(ctx context.Context, pull *db.Pull) { 54 + for _, notifier := range m.notifiers { 55 + notifier.NewPull(ctx, pull) 56 + } 57 + } 58 + func (m *mergedNotifier) NewPullComment(ctx context.Context, comment *db.PullComment) { 59 + for _, notifier := range m.notifiers { 60 + notifier.NewPullComment(ctx, comment) 61 + } 62 + } 63 + 64 + func (m *mergedNotifier) UpdateProfile(ctx context.Context, profile *db.Profile) { 65 + for _, notifier := range m.notifiers { 66 + notifier.UpdateProfile(ctx, profile) 67 + } 68 + } 69 + 70 + func (m *mergedNotifier) NewString(ctx context.Context, string *db.String) { 71 + for _, notifier := range m.notifiers { 72 + notifier.NewString(ctx, string) 73 + } 74 + } 75 + 76 + func (m *mergedNotifier) EditString(ctx context.Context, string *db.String) { 77 + for _, notifier := range m.notifiers { 78 + notifier.EditString(ctx, string) 79 + } 80 + } 81 + 82 + func (m *mergedNotifier) DeleteString(ctx context.Context, did, rkey string) { 83 + for _, notifier := range m.notifiers { 84 + notifier.DeleteString(ctx, did, rkey) 85 + } 86 + }
+52
appview/notify/notifier.go
··· 1 + package notify 2 + 3 + import ( 4 + "context" 5 + 6 + "tangled.sh/tangled.sh/core/appview/db" 7 + ) 8 + 9 + type Notifier interface { 10 + NewRepo(ctx context.Context, repo *db.Repo) 11 + 12 + NewStar(ctx context.Context, star *db.Star) 13 + DeleteStar(ctx context.Context, star *db.Star) 14 + 15 + NewIssue(ctx context.Context, issue *db.Issue) 16 + 17 + NewFollow(ctx context.Context, follow *db.Follow) 18 + DeleteFollow(ctx context.Context, follow *db.Follow) 19 + 20 + NewPull(ctx context.Context, pull *db.Pull) 21 + NewPullComment(ctx context.Context, comment *db.PullComment) 22 + 23 + UpdateProfile(ctx context.Context, profile *db.Profile) 24 + 25 + NewString(ctx context.Context, s *db.String) 26 + EditString(ctx context.Context, s *db.String) 27 + DeleteString(ctx context.Context, did, rkey string) 28 + } 29 + 30 + // BaseNotifier is a listener that does nothing 31 + type BaseNotifier struct{} 32 + 33 + var _ Notifier = &BaseNotifier{} 34 + 35 + func (m *BaseNotifier) NewRepo(ctx context.Context, repo *db.Repo) {} 36 + 37 + func (m *BaseNotifier) NewStar(ctx context.Context, star *db.Star) {} 38 + func (m *BaseNotifier) DeleteStar(ctx context.Context, star *db.Star) {} 39 + 40 + func (m *BaseNotifier) NewIssue(ctx context.Context, issue *db.Issue) {} 41 + 42 + func (m *BaseNotifier) NewFollow(ctx context.Context, follow *db.Follow) {} 43 + func (m *BaseNotifier) DeleteFollow(ctx context.Context, follow *db.Follow) {} 44 + 45 + func (m *BaseNotifier) NewPull(ctx context.Context, pull *db.Pull) {} 46 + func (m *BaseNotifier) NewPullComment(ctx context.Context, comment *db.PullComment) {} 47 + 48 + func (m *BaseNotifier) UpdateProfile(ctx context.Context, profile *db.Profile) {} 49 + 50 + func (m *BaseNotifier) NewString(ctx context.Context, s *db.String) {} 51 + func (m *BaseNotifier) EditString(ctx context.Context, s *db.String) {} 52 + func (m *BaseNotifier) DeleteString(ctx context.Context, did, rkey string) {}
+197 -18
appview/oauth/handler/handler.go
··· 1 1 package oauth 2 2 3 3 import ( 4 + "bytes" 5 + "context" 4 6 "encoding/json" 5 7 "fmt" 6 8 "log" 7 9 "net/http" 8 10 "net/url" 11 + "slices" 9 12 "strings" 13 + "time" 10 14 11 15 "github.com/go-chi/chi/v5" 12 16 "github.com/gorilla/sessions" 13 17 "github.com/lestrrat-go/jwx/v2/jwk" 14 18 "github.com/posthog/posthog-go" 15 19 "tangled.sh/icyphox.sh/atproto-oauth/helpers" 20 + tangled "tangled.sh/tangled.sh/core/api/tangled" 16 21 sessioncache "tangled.sh/tangled.sh/core/appview/cache/session" 17 22 "tangled.sh/tangled.sh/core/appview/config" 18 23 "tangled.sh/tangled.sh/core/appview/db" 19 - "tangled.sh/tangled.sh/core/appview/idresolver" 20 24 "tangled.sh/tangled.sh/core/appview/middleware" 21 25 "tangled.sh/tangled.sh/core/appview/oauth" 22 26 "tangled.sh/tangled.sh/core/appview/oauth/client" 23 27 "tangled.sh/tangled.sh/core/appview/pages" 24 - "tangled.sh/tangled.sh/core/knotclient" 28 + "tangled.sh/tangled.sh/core/idresolver" 25 29 "tangled.sh/tangled.sh/core/rbac" 30 + "tangled.sh/tangled.sh/core/tid" 26 31 ) 27 32 28 33 const ( ··· 104 109 func (o *OAuthHandler) login(w http.ResponseWriter, r *http.Request) { 105 110 switch r.Method { 106 111 case http.MethodGet: 107 - o.pages.Login(w, pages.LoginParams{}) 112 + returnURL := r.URL.Query().Get("return_url") 113 + o.pages.Login(w, pages.LoginParams{ 114 + ReturnUrl: returnURL, 115 + }) 108 116 case http.MethodPost: 109 117 handle := r.FormValue("handle") 110 118 ··· 189 197 DpopAuthserverNonce: parResp.DpopAuthserverNonce, 190 198 DpopPrivateJwk: string(dpopKeyJson), 191 199 State: parResp.State, 200 + ReturnUrl: r.FormValue("return_url"), 192 201 }) 193 202 if err != nil { 194 203 log.Println("failed to save oauth request:", err) ··· 240 249 iss := r.FormValue("iss") 241 250 if iss == "" { 242 251 log.Println("missing iss for state: ", state) 252 + o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 253 + return 254 + } 255 + 256 + if iss != oauthRequest.AuthserverIss { 257 + log.Println("mismatched iss:", iss, "!=", oauthRequest.AuthserverIss, "for state:", state) 243 258 o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 244 259 return 245 260 } ··· 294 309 295 310 log.Println("session saved successfully") 296 311 go o.addToDefaultKnot(oauthRequest.Did) 312 + go o.addToDefaultSpindle(oauthRequest.Did) 297 313 298 314 if !o.config.Core.Dev { 299 315 err = o.posthog.Enqueue(posthog.Capture{ ··· 305 321 } 306 322 } 307 323 308 - http.Redirect(w, r, "/", http.StatusFound) 324 + returnUrl := oauthRequest.ReturnUrl 325 + if returnUrl == "" { 326 + returnUrl = "/" 327 + } 328 + 329 + http.Redirect(w, r, returnUrl, http.StatusFound) 309 330 } 310 331 311 332 func (o *OAuthHandler) logout(w http.ResponseWriter, r *http.Request) { ··· 332 353 return pubKey, nil 333 354 } 334 355 335 - func (o *OAuthHandler) addToDefaultKnot(did string) { 336 - defaultKnot := "knot1.tangled.sh" 356 + var ( 357 + tangledDid = "did:plc:wshs7t2adsemcrrd4snkeqli" 358 + icyDid = "did:plc:hwevmowznbiukdf6uk5dwrrq" 359 + 360 + defaultSpindle = "spindle.tangled.sh" 361 + defaultKnot = "knot1.tangled.sh" 362 + ) 337 363 338 - log.Printf("adding %s to default knot", did) 339 - err := o.enforcer.AddKnotMember(defaultKnot, did) 364 + func (o *OAuthHandler) addToDefaultSpindle(did string) { 365 + // use the tangled.sh app password to get an accessJwt 366 + // and create an sh.tangled.spindle.member record with that 367 + spindleMembers, err := db.GetSpindleMembers( 368 + o.db, 369 + db.FilterEq("instance", "spindle.tangled.sh"), 370 + db.FilterEq("subject", did), 371 + ) 340 372 if err != nil { 341 - log.Println("failed to add user to knot1.tangled.sh: ", err) 373 + log.Printf("failed to get spindle members for did %s: %v", did, err) 342 374 return 343 375 } 344 - err = o.enforcer.E.SavePolicy() 376 + 377 + if len(spindleMembers) != 0 { 378 + log.Printf("did %s is already a member of the default spindle", did) 379 + return 380 + } 381 + 382 + log.Printf("adding %s to default spindle", did) 383 + session, err := o.createAppPasswordSession(o.config.Core.AppPassword, tangledDid) 345 384 if err != nil { 346 - log.Println("failed to add user to knot1.tangled.sh: ", err) 385 + log.Printf("failed to create session: %s", err) 386 + return 387 + } 388 + 389 + record := tangled.SpindleMember{ 390 + LexiconTypeID: "sh.tangled.spindle.member", 391 + Subject: did, 392 + Instance: defaultSpindle, 393 + CreatedAt: time.Now().Format(time.RFC3339), 394 + } 395 + 396 + if err := session.putRecord(record, tangled.SpindleMemberNSID); err != nil { 397 + log.Printf("failed to add member to default spindle: %s", err) 347 398 return 348 399 } 349 400 350 - secret, err := db.GetRegistrationKey(o.db, defaultKnot) 401 + log.Printf("successfully added %s to default spindle", did) 402 + } 403 + 404 + func (o *OAuthHandler) addToDefaultKnot(did string) { 405 + // use the tangled.sh app password to get an accessJwt 406 + // and create an sh.tangled.spindle.member record with that 407 + 408 + allKnots, err := o.enforcer.GetKnotsForUser(did) 351 409 if err != nil { 352 - log.Println("failed to get registration key for knot1.tangled.sh") 410 + log.Printf("failed to get knot members for did %s: %v", did, err) 353 411 return 354 412 } 355 - signedClient, err := knotclient.NewSignedClient(defaultKnot, secret, o.config.Core.Dev) 356 - resp, err := signedClient.AddMember(did) 413 + 414 + if slices.Contains(allKnots, defaultKnot) { 415 + log.Printf("did %s is already a member of the default knot", did) 416 + return 417 + } 418 + 419 + log.Printf("adding %s to default knot", did) 420 + session, err := o.createAppPasswordSession(o.config.Core.TmpAltAppPassword, icyDid) 357 421 if err != nil { 358 - log.Println("failed to add user to knot1.tangled.sh: ", err) 422 + log.Printf("failed to create session: %s", err) 423 + return 424 + } 425 + 426 + record := tangled.KnotMember{ 427 + LexiconTypeID: "sh.tangled.knot.member", 428 + Subject: did, 429 + Domain: defaultKnot, 430 + CreatedAt: time.Now().Format(time.RFC3339), 431 + } 432 + 433 + if err := session.putRecord(record, tangled.KnotMemberNSID); err != nil { 434 + log.Printf("failed to add member to default knot: %s", err) 359 435 return 360 436 } 361 437 362 - if resp.StatusCode != http.StatusNoContent { 363 - log.Println("failed to add user to knot1.tangled.sh: ", resp.StatusCode) 438 + if err := o.enforcer.AddKnotMember(defaultKnot, did); err != nil { 439 + log.Printf("failed to set up enforcer rules: %s", err) 364 440 return 365 441 } 442 + 443 + log.Printf("successfully added %s to default Knot", did) 444 + } 445 + 446 + // create a session using apppasswords 447 + type session struct { 448 + AccessJwt string `json:"accessJwt"` 449 + PdsEndpoint string 450 + Did string 451 + } 452 + 453 + func (o *OAuthHandler) createAppPasswordSession(appPassword, did string) (*session, error) { 454 + if appPassword == "" { 455 + return nil, fmt.Errorf("no app password configured, skipping member addition") 456 + } 457 + 458 + resolved, err := o.idResolver.ResolveIdent(context.Background(), did) 459 + if err != nil { 460 + return nil, fmt.Errorf("failed to resolve tangled.sh DID %s: %v", did, err) 461 + } 462 + 463 + pdsEndpoint := resolved.PDSEndpoint() 464 + if pdsEndpoint == "" { 465 + return nil, fmt.Errorf("no PDS endpoint found for tangled.sh DID %s", did) 466 + } 467 + 468 + sessionPayload := map[string]string{ 469 + "identifier": did, 470 + "password": appPassword, 471 + } 472 + sessionBytes, err := json.Marshal(sessionPayload) 473 + if err != nil { 474 + return nil, fmt.Errorf("failed to marshal session payload: %v", err) 475 + } 476 + 477 + sessionURL := pdsEndpoint + "/xrpc/com.atproto.server.createSession" 478 + sessionReq, err := http.NewRequestWithContext(context.Background(), "POST", sessionURL, bytes.NewBuffer(sessionBytes)) 479 + if err != nil { 480 + return nil, fmt.Errorf("failed to create session request: %v", err) 481 + } 482 + sessionReq.Header.Set("Content-Type", "application/json") 483 + 484 + client := &http.Client{Timeout: 30 * time.Second} 485 + sessionResp, err := client.Do(sessionReq) 486 + if err != nil { 487 + return nil, fmt.Errorf("failed to create session: %v", err) 488 + } 489 + defer sessionResp.Body.Close() 490 + 491 + if sessionResp.StatusCode != http.StatusOK { 492 + return nil, fmt.Errorf("failed to create session: HTTP %d", sessionResp.StatusCode) 493 + } 494 + 495 + var session session 496 + if err := json.NewDecoder(sessionResp.Body).Decode(&session); err != nil { 497 + return nil, fmt.Errorf("failed to decode session response: %v", err) 498 + } 499 + 500 + session.PdsEndpoint = pdsEndpoint 501 + session.Did = did 502 + 503 + return &session, nil 504 + } 505 + 506 + func (s *session) putRecord(record any, collection string) error { 507 + recordBytes, err := json.Marshal(record) 508 + if err != nil { 509 + return fmt.Errorf("failed to marshal knot member record: %w", err) 510 + } 511 + 512 + payload := map[string]any{ 513 + "repo": s.Did, 514 + "collection": collection, 515 + "rkey": tid.TID(), 516 + "record": json.RawMessage(recordBytes), 517 + } 518 + 519 + payloadBytes, err := json.Marshal(payload) 520 + if err != nil { 521 + return fmt.Errorf("failed to marshal request payload: %w", err) 522 + } 523 + 524 + url := s.PdsEndpoint + "/xrpc/com.atproto.repo.putRecord" 525 + req, err := http.NewRequestWithContext(context.Background(), "POST", url, bytes.NewBuffer(payloadBytes)) 526 + if err != nil { 527 + return fmt.Errorf("failed to create HTTP request: %w", err) 528 + } 529 + 530 + req.Header.Set("Content-Type", "application/json") 531 + req.Header.Set("Authorization", "Bearer "+s.AccessJwt) 532 + 533 + client := &http.Client{Timeout: 30 * time.Second} 534 + resp, err := client.Do(req) 535 + if err != nil { 536 + return fmt.Errorf("failed to add user to default service: %w", err) 537 + } 538 + defer resp.Body.Close() 539 + 540 + if resp.StatusCode != http.StatusOK { 541 + return fmt.Errorf("failed to add user to default service: HTTP %d", resp.StatusCode) 542 + } 543 + 544 + return nil 366 545 }
+88 -2
appview/oauth/oauth.go
··· 7 7 "net/url" 8 8 "time" 9 9 10 + indigo_xrpc "github.com/bluesky-social/indigo/xrpc" 10 11 "github.com/gorilla/sessions" 11 12 oauth "tangled.sh/icyphox.sh/atproto-oauth" 12 13 "tangled.sh/icyphox.sh/atproto-oauth/helpers" ··· 102 103 if err != nil { 103 104 return nil, false, fmt.Errorf("error parsing expiry time: %w", err) 104 105 } 105 - if expiry.Sub(time.Now()) <= 5*time.Minute { 106 + if time.Until(expiry) <= 5*time.Minute { 106 107 privateJwk, err := helpers.ParseJWKFromBytes([]byte(session.DpopPrivateJwk)) 107 108 if err != nil { 108 109 return nil, false, err ··· 206 207 return xrpcClient, nil 207 208 } 208 209 210 + // use this to create a client to communicate with knots or spindles 211 + // 212 + // this is a higher level abstraction on ServerGetServiceAuth 213 + type ServiceClientOpts struct { 214 + service string 215 + exp int64 216 + lxm string 217 + dev bool 218 + } 219 + 220 + type ServiceClientOpt func(*ServiceClientOpts) 221 + 222 + func WithService(service string) ServiceClientOpt { 223 + return func(s *ServiceClientOpts) { 224 + s.service = service 225 + } 226 + } 227 + 228 + // Specify the Duration in seconds for the expiry of this token 229 + // 230 + // The time of expiry is calculated as time.Now().Unix() + exp 231 + func WithExp(exp int64) ServiceClientOpt { 232 + return func(s *ServiceClientOpts) { 233 + s.exp = time.Now().Unix() + exp 234 + } 235 + } 236 + 237 + func WithLxm(lxm string) ServiceClientOpt { 238 + return func(s *ServiceClientOpts) { 239 + s.lxm = lxm 240 + } 241 + } 242 + 243 + func WithDev(dev bool) ServiceClientOpt { 244 + return func(s *ServiceClientOpts) { 245 + s.dev = dev 246 + } 247 + } 248 + 249 + func (s *ServiceClientOpts) Audience() string { 250 + return fmt.Sprintf("did:web:%s", s.service) 251 + } 252 + 253 + func (s *ServiceClientOpts) Host() string { 254 + scheme := "https://" 255 + if s.dev { 256 + scheme = "http://" 257 + } 258 + 259 + return scheme + s.service 260 + } 261 + 262 + func (o *OAuth) ServiceClient(r *http.Request, os ...ServiceClientOpt) (*indigo_xrpc.Client, error) { 263 + opts := ServiceClientOpts{} 264 + for _, o := range os { 265 + o(&opts) 266 + } 267 + 268 + authorizedClient, err := o.AuthorizedClient(r) 269 + if err != nil { 270 + return nil, err 271 + } 272 + 273 + // force expiry to atleast 60 seconds in the future 274 + sixty := time.Now().Unix() + 60 275 + if opts.exp < sixty { 276 + opts.exp = sixty 277 + } 278 + 279 + resp, err := authorizedClient.ServerGetServiceAuth(r.Context(), opts.Audience(), opts.exp, opts.lxm) 280 + if err != nil { 281 + return nil, err 282 + } 283 + 284 + return &indigo_xrpc.Client{ 285 + Auth: &indigo_xrpc.AuthInfo{ 286 + AccessJwt: resp.Token, 287 + }, 288 + Host: opts.Host(), 289 + Client: &http.Client{ 290 + Timeout: time.Second * 5, 291 + }, 292 + }, nil 293 + } 294 + 209 295 type ClientMetadata struct { 210 296 ClientID string `json:"client_id"` 211 297 ClientName string `json:"client_name"` ··· 232 318 redirectURIs := makeRedirectURIs(clientURI) 233 319 234 320 if o.config.Core.Dev { 235 - clientURI = fmt.Sprintf("http://127.0.0.1:3000") 321 + clientURI = "http://127.0.0.1:3000" 236 322 redirectURIs = makeRedirectURIs(clientURI) 237 323 238 324 query := url.Values{}
+35
appview/pages/cache.go
··· 1 + package pages 2 + 3 + import ( 4 + "sync" 5 + ) 6 + 7 + type TmplCache[K comparable, V any] struct { 8 + data map[K]V 9 + mutex sync.RWMutex 10 + } 11 + 12 + func NewTmplCache[K comparable, V any]() *TmplCache[K, V] { 13 + return &TmplCache[K, V]{ 14 + data: make(map[K]V), 15 + } 16 + } 17 + 18 + func (c *TmplCache[K, V]) Get(key K) (V, bool) { 19 + c.mutex.RLock() 20 + defer c.mutex.RUnlock() 21 + val, exists := c.data[key] 22 + return val, exists 23 + } 24 + 25 + func (c *TmplCache[K, V]) Set(key K, value V) { 26 + c.mutex.Lock() 27 + defer c.mutex.Unlock() 28 + c.data[key] = value 29 + } 30 + 31 + func (c *TmplCache[K, V]) Size() int { 32 + c.mutex.RLock() 33 + defer c.mutex.RUnlock() 34 + return len(c.data) 35 + }
+124 -35
appview/pages/funcmap.go
··· 1 1 package pages 2 2 3 3 import ( 4 + "context" 5 + "crypto/hmac" 6 + "crypto/sha256" 7 + "encoding/hex" 4 8 "errors" 5 9 "fmt" 6 10 "html" ··· 14 18 "time" 15 19 16 20 "github.com/dustin/go-humanize" 17 - "github.com/microcosm-cc/bluemonday" 21 + "github.com/go-enry/go-enry/v2" 18 22 "tangled.sh/tangled.sh/core/appview/filetree" 19 23 "tangled.sh/tangled.sh/core/appview/pages/markup" 24 + "tangled.sh/tangled.sh/core/crypto" 20 25 ) 21 26 22 - func funcMap() template.FuncMap { 27 + func (p *Pages) funcMap() template.FuncMap { 23 28 return template.FuncMap{ 24 29 "split": func(s string) []string { 25 30 return strings.Split(s, "\n") 26 31 }, 32 + "contains": func(s string, target string) bool { 33 + return strings.Contains(s, target) 34 + }, 35 + "resolve": func(s string) string { 36 + identity, err := p.resolver.ResolveIdent(context.Background(), s) 37 + 38 + if err != nil { 39 + return s 40 + } 41 + 42 + if identity.Handle.IsInvalidHandle() { 43 + return "handle.invalid" 44 + } 45 + 46 + return "@" + identity.Handle.String() 47 + }, 27 48 "truncateAt30": func(s string) string { 28 49 if len(s) <= 30 { 29 50 return s ··· 70 91 "negf64": func(a float64) float64 { 71 92 return -a 72 93 }, 73 - "cond": func(cond interface{}, a, b string) string { 94 + "cond": func(cond any, a, b string) string { 74 95 if cond == nil { 75 96 return b 76 97 } ··· 102 123 s = append(s, values...) 103 124 return s 104 125 }, 105 - "timeFmt": humanize.Time, 106 - "longTimeFmt": func(t time.Time) string { 107 - return t.Format("2006-01-02 * 3:04 PM") 108 - }, 109 - "commaFmt": humanize.Comma, 110 - "shortTimeFmt": func(t time.Time) string { 126 + "commaFmt": humanize.Comma, 127 + "relTimeFmt": humanize.Time, 128 + "shortRelTimeFmt": func(t time.Time) string { 111 129 return humanize.CustomRelTime(t, time.Now(), "", "", []humanize.RelTimeMagnitude{ 112 130 {time.Second, "now", time.Second}, 113 131 {2 * time.Second, "1s %s", 1}, ··· 126 144 {math.MaxInt64, "a long while %s", 1}, 127 145 }) 128 146 }, 129 - "durationFmt": func(duration time.Duration) string { 147 + "longTimeFmt": func(t time.Time) string { 148 + return t.Format("Jan 2, 2006, 3:04 PM MST") 149 + }, 150 + "iso8601DateTimeFmt": func(t time.Time) string { 151 + return t.Format("2006-01-02T15:04:05-07:00") 152 + }, 153 + "iso8601DurationFmt": func(duration time.Duration) string { 130 154 days := int64(duration.Hours() / 24) 131 155 hours := int64(math.Mod(duration.Hours(), 24)) 132 156 minutes := int64(math.Mod(duration.Minutes(), 60)) 133 157 seconds := int64(math.Mod(duration.Seconds(), 60)) 134 - 135 - chunks := []struct { 136 - name string 137 - amount int64 138 - }{ 139 - {"d", days}, 140 - {"hr", hours}, 141 - {"min", minutes}, 142 - {"s", seconds}, 143 - } 144 - 145 - parts := []string{} 146 - 147 - for _, chunk := range chunks { 148 - if chunk.amount != 0 { 149 - parts = append(parts, fmt.Sprintf("%d%s", chunk.amount, chunk.name)) 150 - } 151 - } 152 - 153 - return strings.Join(parts, " ") 158 + return fmt.Sprintf("P%dD%dH%dM%dS", days, hours, minutes, seconds) 159 + }, 160 + "durationFmt": func(duration time.Duration) string { 161 + return durationFmt(duration, [4]string{"d", "hr", "min", "s"}) 162 + }, 163 + "longDurationFmt": func(duration time.Duration) string { 164 + return durationFmt(duration, [4]string{"days", "hours", "minutes", "seconds"}) 154 165 }, 155 166 "byteFmt": humanize.Bytes, 156 167 "length": func(slice any) int { ··· 173 184 return html.UnescapeString(s) 174 185 }, 175 186 "nl2br": func(text string) template.HTML { 176 - return template.HTML(strings.Replace(template.HTMLEscapeString(text), "\n", "<br>", -1)) 187 + return template.HTML(strings.ReplaceAll(template.HTMLEscapeString(text), "\n", "<br>")) 177 188 }, 178 189 "unwrapText": func(text string) string { 179 190 paragraphs := strings.Split(text, "\n\n") ··· 197 208 if v.Len() == 0 { 198 209 return nil 199 210 } 200 - return v.Slice(0, min(n, v.Len()-1)).Interface() 211 + return v.Slice(0, min(n, v.Len())).Interface() 201 212 }, 202 - 203 213 "markdown": func(text string) template.HTML { 204 - rctx := &markup.RenderContext{RendererType: markup.RendererTypeDefault} 205 - return template.HTML(bluemonday.UGCPolicy().Sanitize(rctx.RenderMarkdown(text))) 214 + p.rctx.RendererType = markup.RendererTypeDefault 215 + htmlString := p.rctx.RenderMarkdown(text) 216 + sanitized := p.rctx.SanitizeDefault(htmlString) 217 + return template.HTML(sanitized) 218 + }, 219 + "description": func(text string) template.HTML { 220 + p.rctx.RendererType = markup.RendererTypeDefault 221 + htmlString := p.rctx.RenderMarkdown(text) 222 + sanitized := p.rctx.SanitizeDescription(htmlString) 223 + return template.HTML(sanitized) 206 224 }, 207 225 "isNil": func(t any) bool { 208 226 // returns false for other "zero" values ··· 242 260 }, 243 261 "cssContentHash": CssContentHash, 244 262 "fileTree": filetree.FileTree, 263 + "pathEscape": func(s string) string { 264 + return url.PathEscape(s) 265 + }, 245 266 "pathUnescape": func(s string) string { 246 267 u, _ := url.PathUnescape(s) 247 268 return u 248 269 }, 270 + 271 + "tinyAvatar": func(handle string) string { 272 + return p.avatarUri(handle, "tiny") 273 + }, 274 + "fullAvatar": func(handle string) string { 275 + return p.avatarUri(handle, "") 276 + }, 277 + "langColor": enry.GetColor, 278 + "layoutSide": func() string { 279 + return "col-span-1 md:col-span-2 lg:col-span-3" 280 + }, 281 + "layoutCenter": func() string { 282 + return "col-span-1 md:col-span-8 lg:col-span-6" 283 + }, 284 + 285 + "normalizeForHtmlId": func(s string) string { 286 + // TODO: extend this to handle other cases? 287 + return strings.ReplaceAll(s, ":", "_") 288 + }, 289 + "sshFingerprint": func(pubKey string) string { 290 + fp, err := crypto.SSHFingerprint(pubKey) 291 + if err != nil { 292 + return "error" 293 + } 294 + return fp 295 + }, 249 296 } 250 297 } 251 298 299 + func (p *Pages) avatarUri(handle, size string) string { 300 + handle = strings.TrimPrefix(handle, "@") 301 + 302 + secret := p.avatar.SharedSecret 303 + h := hmac.New(sha256.New, []byte(secret)) 304 + h.Write([]byte(handle)) 305 + signature := hex.EncodeToString(h.Sum(nil)) 306 + 307 + sizeArg := "" 308 + if size != "" { 309 + sizeArg = fmt.Sprintf("size=%s", size) 310 + } 311 + return fmt.Sprintf("%s/%s/%s?%s", p.avatar.Host, signature, handle, sizeArg) 312 + } 313 + 252 314 func icon(name string, classes []string) (template.HTML, error) { 253 315 iconPath := filepath.Join("static", "icons", name) 254 316 ··· 274 336 modifiedSVG := svgStr[:svgTagEnd] + classTag + svgStr[svgTagEnd:] 275 337 return template.HTML(modifiedSVG), nil 276 338 } 339 + 340 + func durationFmt(duration time.Duration, names [4]string) string { 341 + days := int64(duration.Hours() / 24) 342 + hours := int64(math.Mod(duration.Hours(), 24)) 343 + minutes := int64(math.Mod(duration.Minutes(), 60)) 344 + seconds := int64(math.Mod(duration.Seconds(), 60)) 345 + 346 + chunks := []struct { 347 + name string 348 + amount int64 349 + }{ 350 + {names[0], days}, 351 + {names[1], hours}, 352 + {names[2], minutes}, 353 + {names[3], seconds}, 354 + } 355 + 356 + parts := []string{} 357 + 358 + for _, chunk := range chunks { 359 + if chunk.amount != 0 { 360 + parts = append(parts, fmt.Sprintf("%d%s", chunk.amount, chunk.name)) 361 + } 362 + } 363 + 364 + return strings.Join(parts, " ") 365 + }
+2 -2
appview/pages/markup/camo.go
··· 9 9 "github.com/yuin/goldmark/ast" 10 10 ) 11 11 12 - func generateCamoURL(baseURL, secret, imageURL string) string { 12 + func GenerateCamoURL(baseURL, secret, imageURL string) string { 13 13 h := hmac.New(sha256.New, []byte(secret)) 14 14 h.Write([]byte(imageURL)) 15 15 signature := hex.EncodeToString(h.Sum(nil)) ··· 24 24 } 25 25 26 26 if rctx.CamoUrl != "" && rctx.CamoSecret != "" { 27 - return generateCamoURL(rctx.CamoUrl, rctx.CamoSecret, dst) 27 + return GenerateCamoURL(rctx.CamoUrl, rctx.CamoSecret, dst) 28 28 } 29 29 30 30 return dst
+12
appview/pages/markup/format.go
··· 13 13 FormatMarkdown: []string{".md", ".markdown", ".mdown", ".mkdn", ".mkd"}, 14 14 } 15 15 16 + // ReadmeFilenames contains the list of common README filenames to search for, 17 + // in order of preference. Only includes well-supported formats. 18 + var ReadmeFilenames = []string{ 19 + "README.md", "readme.md", 20 + "README", 21 + "readme", 22 + "README.markdown", 23 + "readme.markdown", 24 + "README.txt", 25 + "readme.txt", 26 + } 27 + 16 28 func GetFormat(filename string) Format { 17 29 for format, extensions := range FileTypes { 18 30 for _, extension := range extensions {
+73 -39
appview/pages/markup/markdown.go
··· 9 9 "path" 10 10 "strings" 11 11 12 - "github.com/microcosm-cc/bluemonday" 12 + chromahtml "github.com/alecthomas/chroma/v2/formatters/html" 13 + "github.com/alecthomas/chroma/v2/styles" 14 + treeblood "github.com/wyatt915/goldmark-treeblood" 13 15 "github.com/yuin/goldmark" 16 + highlighting "github.com/yuin/goldmark-highlighting/v2" 14 17 "github.com/yuin/goldmark/ast" 15 18 "github.com/yuin/goldmark/extension" 16 19 "github.com/yuin/goldmark/parser" ··· 19 22 "github.com/yuin/goldmark/util" 20 23 htmlparse "golang.org/x/net/html" 21 24 25 + "tangled.sh/tangled.sh/core/api/tangled" 22 26 "tangled.sh/tangled.sh/core/appview/pages/repoinfo" 23 27 ) 24 28 ··· 40 44 repoinfo.RepoInfo 41 45 IsDev bool 42 46 RendererType RendererType 47 + Sanitizer Sanitizer 43 48 } 44 49 45 50 func (rctx *RenderContext) RenderMarkdown(source string) string { 46 51 md := goldmark.New( 47 - goldmark.WithExtensions(extension.GFM), 52 + goldmark.WithExtensions( 53 + extension.GFM, 54 + highlighting.NewHighlighting( 55 + highlighting.WithFormatOptions( 56 + chromahtml.Standalone(false), 57 + chromahtml.WithClasses(true), 58 + ), 59 + highlighting.WithCustomStyle(styles.Get("catppuccin-latte")), 60 + ), 61 + extension.NewFootnote( 62 + extension.WithFootnoteIDPrefix([]byte("footnote")), 63 + ), 64 + treeblood.MathML(), 65 + ), 48 66 goldmark.WithParserOptions( 49 67 parser.WithAutoHeadingID(), 50 68 ), ··· 145 163 } 146 164 } 147 165 148 - func (rctx *RenderContext) Sanitize(html string) string { 149 - policy := bluemonday.UGCPolicy() 166 + func (rctx *RenderContext) SanitizeDefault(html string) string { 167 + return rctx.Sanitizer.SanitizeDefault(html) 168 + } 150 169 151 - // video 152 - policy.AllowElements("video") 153 - policy.AllowAttrs("controls").OnElements("video") 154 - policy.AllowElements("source") 155 - policy.AllowAttrs("src", "type").OnElements("source") 156 - 157 - // centering content 158 - policy.AllowElements("center") 159 - 160 - policy.AllowAttrs("align", "style", "width", "height").Globally() 161 - policy.AllowStyles( 162 - "margin", 163 - "padding", 164 - "text-align", 165 - "font-weight", 166 - "text-decoration", 167 - "padding-left", 168 - "padding-right", 169 - "padding-top", 170 - "padding-bottom", 171 - "margin-left", 172 - "margin-right", 173 - "margin-top", 174 - "margin-bottom", 175 - ) 176 - return policy.Sanitize(html) 170 + func (rctx *RenderContext) SanitizeDescription(html string) string { 171 + return rctx.Sanitizer.SanitizeDescription(html) 177 172 } 178 173 179 174 type MarkdownTransformer struct { ··· 189 184 switch a.rctx.RendererType { 190 185 case RendererTypeRepoMarkdown: 191 186 switch n := n.(type) { 187 + case *ast.Heading: 188 + a.rctx.anchorHeadingTransformer(n) 192 189 case *ast.Link: 193 190 a.rctx.relativeLinkTransformer(n) 194 191 case *ast.Image: ··· 197 194 } 198 195 case RendererTypeDefault: 199 196 switch n := n.(type) { 197 + case *ast.Heading: 198 + a.rctx.anchorHeadingTransformer(n) 200 199 case *ast.Image: 201 200 a.rctx.imageFromKnotAstTransformer(n) 202 201 a.rctx.camoImageLinkAstTransformer(n) ··· 211 210 212 211 dst := string(link.Destination) 213 212 214 - if isAbsoluteUrl(dst) { 213 + if isAbsoluteUrl(dst) || isFragment(dst) || isMail(dst) { 215 214 return 216 215 } 217 216 ··· 233 232 234 233 actualPath := rctx.actualPath(dst) 235 234 235 + repoName := fmt.Sprintf("%s/%s", rctx.RepoInfo.OwnerDid, rctx.RepoInfo.Name) 236 + 237 + query := fmt.Sprintf("repo=%s&ref=%s&path=%s&raw=true", 238 + url.PathEscape(repoName), url.PathEscape(rctx.RepoInfo.Ref), actualPath) 239 + 236 240 parsedURL := &url.URL{ 237 - Scheme: scheme, 238 - Host: rctx.Knot, 239 - Path: path.Join("/", 240 - rctx.RepoInfo.OwnerDid, 241 - rctx.RepoInfo.Name, 242 - "raw", 243 - url.PathEscape(rctx.RepoInfo.Ref), 244 - actualPath), 241 + Scheme: scheme, 242 + Host: rctx.Knot, 243 + Path: path.Join("/xrpc", tangled.RepoBlobNSID), 244 + RawQuery: query, 245 245 } 246 246 newPath := parsedURL.String() 247 247 return newPath ··· 252 252 img.Destination = []byte(rctx.imageFromKnotTransformer(dst)) 253 253 } 254 254 255 + func (rctx *RenderContext) anchorHeadingTransformer(h *ast.Heading) { 256 + idGeneric, exists := h.AttributeString("id") 257 + if !exists { 258 + return // no id, nothing to do 259 + } 260 + id, ok := idGeneric.([]byte) 261 + if !ok { 262 + return 263 + } 264 + 265 + // create anchor link 266 + anchor := ast.NewLink() 267 + anchor.Destination = fmt.Appendf(nil, "#%s", string(id)) 268 + anchor.SetAttribute([]byte("class"), []byte("anchor")) 269 + 270 + // create icon text 271 + iconText := ast.NewString([]byte("#")) 272 + anchor.AppendChild(anchor, iconText) 273 + 274 + // set class on heading 275 + h.SetAttribute([]byte("class"), []byte("heading")) 276 + 277 + // append anchor to heading 278 + h.AppendChild(h, anchor) 279 + } 280 + 255 281 // actualPath decides when to join the file path with the 256 282 // current repository directory (essentially only when the link 257 283 // destination is relative. if it's absolute then we assume the ··· 271 297 } 272 298 return parsed.IsAbs() 273 299 } 300 + 301 + func isFragment(link string) bool { 302 + return strings.HasPrefix(link, "#") 303 + } 304 + 305 + func isMail(link string) bool { 306 + return strings.HasPrefix(link, "mailto:") 307 + }
+134
appview/pages/markup/sanitizer.go
··· 1 + package markup 2 + 3 + import ( 4 + "maps" 5 + "regexp" 6 + "slices" 7 + "strings" 8 + 9 + "github.com/alecthomas/chroma/v2" 10 + "github.com/microcosm-cc/bluemonday" 11 + ) 12 + 13 + type Sanitizer struct { 14 + defaultPolicy *bluemonday.Policy 15 + descriptionPolicy *bluemonday.Policy 16 + } 17 + 18 + func NewSanitizer() Sanitizer { 19 + return Sanitizer{ 20 + defaultPolicy: defaultPolicy(), 21 + descriptionPolicy: descriptionPolicy(), 22 + } 23 + } 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) 30 + } 31 + 32 + func defaultPolicy() *bluemonday.Policy { 33 + policy := bluemonday.UGCPolicy() 34 + 35 + // Allow generally safe attributes 36 + generalSafeAttrs := []string{ 37 + "abbr", "accept", "accept-charset", 38 + "accesskey", "action", "align", "alt", 39 + "aria-describedby", "aria-hidden", "aria-label", "aria-labelledby", 40 + "axis", "border", "cellpadding", "cellspacing", "char", 41 + "charoff", "charset", "checked", 42 + "clear", "cols", "colspan", "color", 43 + "compact", "coords", "datetime", "dir", 44 + "disabled", "enctype", "for", "frame", 45 + "headers", "height", "hreflang", 46 + "hspace", "ismap", "label", "lang", 47 + "maxlength", "media", "method", 48 + "multiple", "name", "nohref", "noshade", 49 + "nowrap", "open", "prompt", "readonly", "rel", "rev", 50 + "rows", "rowspan", "rules", "scope", 51 + "selected", "shape", "size", "span", 52 + "start", "summary", "tabindex", "target", 53 + "title", "type", "usemap", "valign", "value", 54 + "vspace", "width", "itemprop", 55 + } 56 + 57 + generalSafeElements := []string{ 58 + "h1", "h2", "h3", "h4", "h5", "h6", "h7", "h8", "br", "b", "i", "strong", "em", "a", "pre", "code", "img", "tt", 59 + "div", "ins", "del", "sup", "sub", "p", "ol", "ul", "table", "thead", "tbody", "tfoot", "blockquote", "label", 60 + "dl", "dt", "dd", "kbd", "q", "samp", "var", "hr", "ruby", "rt", "rp", "li", "tr", "td", "th", "s", "strike", "summary", 61 + "details", "caption", "figure", "figcaption", 62 + "abbr", "bdo", "cite", "dfn", "mark", "small", "span", "time", "video", "wbr", 63 + } 64 + 65 + policy.AllowAttrs(generalSafeAttrs...).OnElements(generalSafeElements...) 66 + 67 + // video 68 + policy.AllowAttrs("src", "autoplay", "controls").OnElements("video") 69 + 70 + // checkboxes 71 + policy.AllowAttrs("type").Matching(regexp.MustCompile(`^checkbox$`)).OnElements("input") 72 + policy.AllowAttrs("checked", "disabled", "data-source-position").OnElements("input") 73 + 74 + // for code blocks 75 + policy.AllowAttrs("class").Matching(regexp.MustCompile(`chroma`)).OnElements("pre") 76 + policy.AllowAttrs("class").Matching(regexp.MustCompile(`anchor|footnote-ref|footnote-backref`)).OnElements("a") 77 + policy.AllowAttrs("class").Matching(regexp.MustCompile(`heading`)).OnElements("h1", "h2", "h3", "h4", "h5", "h6", "h7", "h8") 78 + policy.AllowAttrs("class").Matching(regexp.MustCompile(strings.Join(slices.Collect(maps.Values(chroma.StandardTypes)), "|"))).OnElements("span") 79 + 80 + // centering content 81 + policy.AllowElements("center") 82 + 83 + policy.AllowAttrs("align", "style", "width", "height").Globally() 84 + policy.AllowStyles( 85 + "margin", 86 + "padding", 87 + "text-align", 88 + "font-weight", 89 + "text-decoration", 90 + "padding-left", 91 + "padding-right", 92 + "padding-top", 93 + "padding-bottom", 94 + "margin-left", 95 + "margin-right", 96 + "margin-top", 97 + "margin-bottom", 98 + ) 99 + 100 + // math 101 + mathAttrs := []string{ 102 + "accent", "columnalign", "columnlines", "columnspan", "dir", "display", 103 + "displaystyle", "encoding", "fence", "form", "largeop", "linebreak", 104 + "linethickness", "lspace", "mathcolor", "mathsize", "mathvariant", "minsize", 105 + "movablelimits", "notation", "rowalign", "rspace", "rowspacing", "rowspan", 106 + "scriptlevel", "stretchy", "symmetric", "title", "voffset", "width", 107 + } 108 + mathElements := []string{ 109 + "annotation", "math", "menclose", "merror", "mfrac", "mi", "mmultiscripts", 110 + "mn", "mo", "mover", "mpadded", "mprescripts", "mroot", "mrow", "mspace", 111 + "msqrt", "mstyle", "msub", "msubsup", "msup", "mtable", "mtd", "mtext", 112 + "mtr", "munder", "munderover", "semantics", 113 + } 114 + policy.AllowNoAttrs().OnElements(mathElements...) 115 + policy.AllowAttrs(mathAttrs...).OnElements(mathElements...) 116 + 117 + return policy 118 + } 119 + 120 + func descriptionPolicy() *bluemonday.Policy { 121 + policy := bluemonday.NewPolicy() 122 + policy.AllowStandardURLs() 123 + 124 + // allow italics and bold. 125 + policy.AllowElements("i", "b", "em", "strong") 126 + 127 + // allow code. 128 + policy.AllowElements("code") 129 + 130 + // allow links 131 + policy.AllowAttrs("href", "target", "rel").OnElements("a") 132 + 133 + return policy 134 + }
+616 -246
appview/pages/pages.go
··· 9 9 "html/template" 10 10 "io" 11 11 "io/fs" 12 - "log" 12 + "log/slog" 13 13 "net/http" 14 14 "os" 15 15 "path/filepath" 16 16 "strings" 17 + "sync" 17 18 19 + "tangled.sh/tangled.sh/core/api/tangled" 18 20 "tangled.sh/tangled.sh/core/appview/commitverify" 19 21 "tangled.sh/tangled.sh/core/appview/config" 20 22 "tangled.sh/tangled.sh/core/appview/db" ··· 22 24 "tangled.sh/tangled.sh/core/appview/pages/markup" 23 25 "tangled.sh/tangled.sh/core/appview/pages/repoinfo" 24 26 "tangled.sh/tangled.sh/core/appview/pagination" 27 + "tangled.sh/tangled.sh/core/idresolver" 25 28 "tangled.sh/tangled.sh/core/patchutil" 26 29 "tangled.sh/tangled.sh/core/types" 27 30 ··· 29 32 chromahtml "github.com/alecthomas/chroma/v2/formatters/html" 30 33 "github.com/alecthomas/chroma/v2/lexers" 31 34 "github.com/alecthomas/chroma/v2/styles" 35 + "github.com/bluesky-social/indigo/atproto/identity" 32 36 "github.com/bluesky-social/indigo/atproto/syntax" 33 37 "github.com/go-git/go-git/v5/plumbing" 34 38 "github.com/go-git/go-git/v5/plumbing/object" 35 - "github.com/microcosm-cc/bluemonday" 36 39 ) 37 40 38 41 //go:embed templates/* static 39 42 var Files embed.FS 40 43 41 44 type Pages struct { 42 - t map[string]*template.Template 45 + mu sync.RWMutex 46 + cache *TmplCache[string, *template.Template] 47 + 48 + avatar config.AvatarConfig 49 + resolver *idresolver.Resolver 43 50 dev bool 44 - embedFS embed.FS 51 + embedFS fs.FS 45 52 templateDir string // Path to templates on disk for dev mode 46 53 rctx *markup.RenderContext 54 + logger *slog.Logger 47 55 } 48 56 49 - func NewPages(config *config.Config) *Pages { 57 + func NewPages(config *config.Config, res *idresolver.Resolver) *Pages { 50 58 // initialized with safe defaults, can be overriden per use 51 59 rctx := &markup.RenderContext{ 52 60 IsDev: config.Core.Dev, 53 61 CamoUrl: config.Camo.Host, 54 62 CamoSecret: config.Camo.SharedSecret, 63 + Sanitizer: markup.NewSanitizer(), 55 64 } 56 65 57 66 p := &Pages{ 58 - t: make(map[string]*template.Template), 67 + mu: sync.RWMutex{}, 68 + cache: NewTmplCache[string, *template.Template](), 59 69 dev: config.Core.Dev, 60 - embedFS: Files, 70 + avatar: config.Avatar, 61 71 rctx: rctx, 72 + resolver: res, 62 73 templateDir: "appview/pages", 74 + logger: slog.Default().With("component", "pages"), 63 75 } 64 76 65 - // Initial load of all templates 66 - p.loadAllTemplates() 77 + if p.dev { 78 + p.embedFS = os.DirFS(p.templateDir) 79 + } else { 80 + p.embedFS = Files 81 + } 67 82 68 83 return p 69 84 } 70 85 71 - func (p *Pages) loadAllTemplates() { 72 - templates := make(map[string]*template.Template) 86 + func (p *Pages) pathToName(s string) string { 87 + return strings.TrimSuffix(strings.TrimPrefix(s, "templates/"), ".html") 88 + } 89 + 90 + // reverse of pathToName 91 + func (p *Pages) nameToPath(s string) string { 92 + return "templates/" + s + ".html" 93 + } 94 + 95 + func (p *Pages) fragmentPaths() ([]string, error) { 73 96 var fragmentPaths []string 74 - 75 - // Use embedded FS for initial loading 76 - // First, collect all fragment paths 77 97 err := fs.WalkDir(p.embedFS, "templates", func(path string, d fs.DirEntry, err error) error { 78 98 if err != nil { 79 99 return err ··· 87 107 if !strings.Contains(path, "fragments/") { 88 108 return nil 89 109 } 90 - name := strings.TrimPrefix(path, "templates/") 91 - name = strings.TrimSuffix(name, ".html") 92 - tmpl, err := template.New(name). 93 - Funcs(funcMap()). 94 - ParseFS(p.embedFS, path) 95 - if err != nil { 96 - log.Fatalf("setting up fragment: %v", err) 97 - } 98 - templates[name] = tmpl 99 110 fragmentPaths = append(fragmentPaths, path) 100 - log.Printf("loaded fragment: %s", name) 101 111 return nil 102 112 }) 103 113 if err != nil { 104 - log.Fatalf("walking template dir for fragments: %v", err) 114 + return nil, err 105 115 } 106 116 107 - // Then walk through and setup the rest of the templates 108 - err = fs.WalkDir(p.embedFS, "templates", func(path string, d fs.DirEntry, err error) error { 109 - if err != nil { 110 - return err 111 - } 112 - if d.IsDir() { 113 - return nil 114 - } 115 - if !strings.HasSuffix(path, "html") { 116 - return nil 117 - } 118 - // Skip fragments as they've already been loaded 119 - if strings.Contains(path, "fragments/") { 120 - return nil 121 - } 122 - // Skip layouts 123 - if strings.Contains(path, "layouts/") { 124 - return nil 125 - } 126 - name := strings.TrimPrefix(path, "templates/") 127 - name = strings.TrimSuffix(name, ".html") 128 - // Add the page template on top of the base 129 - allPaths := []string{} 130 - allPaths = append(allPaths, "templates/layouts/*.html") 131 - allPaths = append(allPaths, fragmentPaths...) 132 - allPaths = append(allPaths, path) 133 - tmpl, err := template.New(name). 134 - Funcs(funcMap()). 135 - ParseFS(p.embedFS, allPaths...) 136 - if err != nil { 137 - return fmt.Errorf("setting up template: %w", err) 138 - } 139 - templates[name] = tmpl 140 - log.Printf("loaded template: %s", name) 141 - return nil 142 - }) 117 + return fragmentPaths, nil 118 + } 119 + 120 + // parse without memoization 121 + func (p *Pages) rawParse(stack ...string) (*template.Template, error) { 122 + paths, err := p.fragmentPaths() 143 123 if err != nil { 144 - log.Fatalf("walking template dir: %v", err) 124 + return nil, err 125 + } 126 + for _, s := range stack { 127 + paths = append(paths, p.nameToPath(s)) 145 128 } 146 129 147 - log.Printf("total templates loaded: %d", len(templates)) 148 - p.t = templates 130 + funcs := p.funcMap() 131 + top := stack[len(stack)-1] 132 + parsed, err := template.New(top). 133 + Funcs(funcs). 134 + ParseFS(p.embedFS, paths...) 135 + if err != nil { 136 + return nil, err 137 + } 138 + 139 + return parsed, nil 149 140 } 150 141 151 - // loadTemplateFromDisk loads a template from the filesystem in dev mode 152 - func (p *Pages) loadTemplateFromDisk(name string) error { 153 - if !p.dev { 154 - return nil 142 + func (p *Pages) parse(stack ...string) (*template.Template, error) { 143 + key := strings.Join(stack, "|") 144 + 145 + // never cache in dev mode 146 + if cached, exists := p.cache.Get(key); !p.dev && exists { 147 + return cached, nil 155 148 } 156 149 157 - log.Printf("reloading template from disk: %s", name) 158 - 159 - // Find all fragments first 160 - var fragmentPaths []string 161 - err := filepath.WalkDir(filepath.Join(p.templateDir, "templates"), func(path string, d fs.DirEntry, err error) error { 162 - if err != nil { 163 - return err 164 - } 165 - if d.IsDir() { 166 - return nil 167 - } 168 - if !strings.HasSuffix(path, ".html") { 169 - return nil 170 - } 171 - if !strings.Contains(path, "fragments/") { 172 - return nil 173 - } 174 - fragmentPaths = append(fragmentPaths, path) 175 - return nil 176 - }) 150 + result, err := p.rawParse(stack...) 177 151 if err != nil { 178 - return fmt.Errorf("walking disk template dir for fragments: %w", err) 152 + return nil, err 179 153 } 180 154 181 - // Find the template path on disk 182 - templatePath := filepath.Join(p.templateDir, "templates", name+".html") 183 - if _, err := os.Stat(templatePath); os.IsNotExist(err) { 184 - return fmt.Errorf("template not found on disk: %s", name) 155 + p.cache.Set(key, result) 156 + return result, nil 157 + } 158 + 159 + func (p *Pages) parseBase(top string) (*template.Template, error) { 160 + stack := []string{ 161 + "layouts/base", 162 + top, 185 163 } 164 + return p.parse(stack...) 165 + } 186 166 187 - // Create a new template 188 - tmpl := template.New(name).Funcs(funcMap()) 167 + func (p *Pages) parseRepoBase(top string) (*template.Template, error) { 168 + stack := []string{ 169 + "layouts/base", 170 + "layouts/repobase", 171 + top, 172 + } 173 + return p.parse(stack...) 174 + } 175 + 176 + func (p *Pages) parseProfileBase(top string) (*template.Template, error) { 177 + stack := []string{ 178 + "layouts/base", 179 + "layouts/profilebase", 180 + top, 181 + } 182 + return p.parse(stack...) 183 + } 189 184 190 - // Parse layouts 191 - layoutGlob := filepath.Join(p.templateDir, "templates", "layouts", "*.html") 192 - layouts, err := filepath.Glob(layoutGlob) 185 + func (p *Pages) executePlain(name string, w io.Writer, params any) error { 186 + tpl, err := p.parse(name) 193 187 if err != nil { 194 - return fmt.Errorf("finding layout templates: %w", err) 188 + return err 195 189 } 196 190 197 - // Create paths for parsing 198 - allFiles := append(layouts, fragmentPaths...) 199 - allFiles = append(allFiles, templatePath) 191 + return tpl.Execute(w, params) 192 + } 200 193 201 - // Parse all templates 202 - tmpl, err = tmpl.ParseFiles(allFiles...) 194 + func (p *Pages) execute(name string, w io.Writer, params any) error { 195 + tpl, err := p.parseBase(name) 203 196 if err != nil { 204 - return fmt.Errorf("parsing template files: %w", err) 197 + return err 205 198 } 206 199 207 - // Update the template in the map 208 - p.t[name] = tmpl 209 - log.Printf("template reloaded from disk: %s", name) 210 - return nil 200 + return tpl.ExecuteTemplate(w, "layouts/base", params) 211 201 } 212 202 213 - func (p *Pages) executeOrReload(templateName string, w io.Writer, base string, params any) error { 214 - // In dev mode, reload the template from disk before executing 215 - if p.dev { 216 - if err := p.loadTemplateFromDisk(templateName); err != nil { 217 - log.Printf("warning: failed to reload template %s from disk: %v", templateName, err) 218 - // Continue with the existing template 219 - } 203 + func (p *Pages) executeRepo(name string, w io.Writer, params any) error { 204 + tpl, err := p.parseRepoBase(name) 205 + if err != nil { 206 + return err 220 207 } 221 208 222 - tmpl, exists := p.t[templateName] 223 - if !exists { 224 - return fmt.Errorf("template not found: %s", templateName) 225 - } 209 + return tpl.ExecuteTemplate(w, "layouts/base", params) 210 + } 226 211 227 - if base == "" { 228 - return tmpl.Execute(w, params) 229 - } else { 230 - return tmpl.ExecuteTemplate(w, base, params) 212 + func (p *Pages) executeProfile(name string, w io.Writer, params any) error { 213 + tpl, err := p.parseProfileBase(name) 214 + if err != nil { 215 + return err 231 216 } 232 - } 233 217 234 - func (p *Pages) execute(name string, w io.Writer, params any) error { 235 - return p.executeOrReload(name, w, "layouts/base", params) 218 + return tpl.ExecuteTemplate(w, "layouts/base", params) 236 219 } 237 220 238 - func (p *Pages) executePlain(name string, w io.Writer, params any) error { 239 - return p.executeOrReload(name, w, "", params) 240 - } 241 - 242 - func (p *Pages) executeRepo(name string, w io.Writer, params any) error { 243 - return p.executeOrReload(name, w, "layouts/repobase", params) 221 + func (p *Pages) Favicon(w io.Writer) error { 222 + return p.executePlain("favicon", w, nil) 244 223 } 245 224 246 225 type LoginParams struct { 226 + ReturnUrl string 247 227 } 248 228 249 229 func (p *Pages) Login(w io.Writer, params LoginParams) error { 250 230 return p.executePlain("user/login", w, params) 251 231 } 252 232 233 + func (p *Pages) Signup(w io.Writer) error { 234 + return p.executePlain("user/signup", w, nil) 235 + } 236 + 237 + func (p *Pages) CompleteSignup(w io.Writer) error { 238 + return p.executePlain("user/completeSignup", w, nil) 239 + } 240 + 241 + type TermsOfServiceParams struct { 242 + LoggedInUser *oauth.User 243 + Content template.HTML 244 + } 245 + 246 + func (p *Pages) TermsOfService(w io.Writer, params TermsOfServiceParams) error { 247 + filename := "terms.md" 248 + filePath := filepath.Join("legal", filename) 249 + markdownBytes, err := os.ReadFile(filePath) 250 + if err != nil { 251 + return fmt.Errorf("failed to read %s: %w", filename, err) 252 + } 253 + 254 + p.rctx.RendererType = markup.RendererTypeDefault 255 + htmlString := p.rctx.RenderMarkdown(string(markdownBytes)) 256 + sanitized := p.rctx.SanitizeDefault(htmlString) 257 + params.Content = template.HTML(sanitized) 258 + 259 + return p.execute("legal/terms", w, params) 260 + } 261 + 262 + type PrivacyPolicyParams struct { 263 + LoggedInUser *oauth.User 264 + Content template.HTML 265 + } 266 + 267 + func (p *Pages) PrivacyPolicy(w io.Writer, params PrivacyPolicyParams) error { 268 + filename := "privacy.md" 269 + filePath := filepath.Join("legal", filename) 270 + markdownBytes, err := os.ReadFile(filePath) 271 + if err != nil { 272 + return fmt.Errorf("failed to read %s: %w", filename, err) 273 + } 274 + 275 + p.rctx.RendererType = markup.RendererTypeDefault 276 + htmlString := p.rctx.RenderMarkdown(string(markdownBytes)) 277 + sanitized := p.rctx.SanitizeDefault(htmlString) 278 + params.Content = template.HTML(sanitized) 279 + 280 + return p.execute("legal/privacy", w, params) 281 + } 282 + 253 283 type TimelineParams struct { 254 284 LoggedInUser *oauth.User 255 285 Timeline []db.TimelineEvent 256 - DidHandleMap map[string]string 286 + Repos []db.Repo 257 287 } 258 288 259 289 func (p *Pages) Timeline(w io.Writer, params TimelineParams) error { 260 - return p.execute("timeline", w, params) 290 + return p.execute("timeline/timeline", w, params) 261 291 } 262 292 263 - type SettingsParams struct { 293 + type UserProfileSettingsParams struct { 294 + LoggedInUser *oauth.User 295 + Tabs []map[string]any 296 + Tab string 297 + } 298 + 299 + func (p *Pages) UserProfileSettings(w io.Writer, params UserProfileSettingsParams) error { 300 + return p.execute("user/settings/profile", w, params) 301 + } 302 + 303 + type UserKeysSettingsParams struct { 264 304 LoggedInUser *oauth.User 265 305 PubKeys []db.PublicKey 306 + Tabs []map[string]any 307 + Tab string 308 + } 309 + 310 + func (p *Pages) UserKeysSettings(w io.Writer, params UserKeysSettingsParams) error { 311 + return p.execute("user/settings/keys", w, params) 312 + } 313 + 314 + type UserEmailsSettingsParams struct { 315 + LoggedInUser *oauth.User 266 316 Emails []db.Email 317 + Tabs []map[string]any 318 + Tab string 267 319 } 268 320 269 - func (p *Pages) Settings(w io.Writer, params SettingsParams) error { 270 - return p.execute("settings", w, params) 321 + func (p *Pages) UserEmailsSettings(w io.Writer, params UserEmailsSettingsParams) error { 322 + return p.execute("user/settings/emails", w, params) 323 + } 324 + 325 + type UpgradeBannerParams struct { 326 + Registrations []db.Registration 327 + Spindles []db.Spindle 328 + } 329 + 330 + func (p *Pages) UpgradeBanner(w io.Writer, params UpgradeBannerParams) error { 331 + return p.executePlain("banner", w, params) 271 332 } 272 333 273 334 type KnotsParams struct { ··· 276 337 } 277 338 278 339 func (p *Pages) Knots(w io.Writer, params KnotsParams) error { 279 - return p.execute("knots", w, params) 340 + return p.execute("knots/index", w, params) 280 341 } 281 342 282 343 type KnotParams struct { 283 344 LoggedInUser *oauth.User 284 - DidHandleMap map[string]string 285 345 Registration *db.Registration 286 346 Members []string 347 + Repos map[string][]db.Repo 287 348 IsOwner bool 288 349 } 289 350 290 351 func (p *Pages) Knot(w io.Writer, params KnotParams) error { 291 - return p.execute("knot", w, params) 352 + return p.execute("knots/dashboard", w, params) 353 + } 354 + 355 + type KnotListingParams struct { 356 + *db.Registration 357 + } 358 + 359 + func (p *Pages) KnotListing(w io.Writer, params KnotListingParams) error { 360 + return p.executePlain("knots/fragments/knotListing", w, params) 292 361 } 293 362 294 363 type SpindlesParams struct { ··· 301 370 } 302 371 303 372 type SpindleListingParams struct { 373 + db.Spindle 374 + } 375 + 376 + func (p *Pages) SpindleListing(w io.Writer, params SpindleListingParams) error { 377 + return p.executePlain("spindles/fragments/spindleListing", w, params) 378 + } 379 + 380 + type SpindleDashboardParams struct { 304 381 LoggedInUser *oauth.User 305 382 Spindle db.Spindle 383 + Members []string 384 + Repos map[string][]db.Repo 306 385 } 307 386 308 - func (p *Pages) SpindleListing(w io.Writer, params SpindleListingParams) error { 309 - return p.execute("spindles/fragments/spindleListing", w, params) 387 + func (p *Pages) SpindleDashboard(w io.Writer, params SpindleDashboardParams) error { 388 + return p.execute("spindles/dashboard", w, params) 310 389 } 311 390 312 391 type NewRepoParams struct { ··· 328 407 return p.execute("repo/fork", w, params) 329 408 } 330 409 331 - type ProfilePageParams struct { 410 + type ProfileCard struct { 411 + UserDid string 412 + UserHandle string 413 + FollowStatus db.FollowStatus 414 + Punchcard *db.Punchcard 415 + Profile *db.Profile 416 + Stats ProfileStats 417 + Active string 418 + } 419 + 420 + type ProfileStats struct { 421 + RepoCount int64 422 + StarredCount int64 423 + StringCount int64 424 + FollowersCount int64 425 + FollowingCount int64 426 + } 427 + 428 + func (p *ProfileCard) GetTabs() [][]any { 429 + tabs := [][]any{ 430 + {"overview", "overview", "square-chart-gantt", nil}, 431 + {"repos", "repos", "book-marked", p.Stats.RepoCount}, 432 + {"starred", "starred", "star", p.Stats.StarredCount}, 433 + {"strings", "strings", "line-squiggle", p.Stats.StringCount}, 434 + } 435 + 436 + return tabs 437 + } 438 + 439 + type ProfileOverviewParams struct { 332 440 LoggedInUser *oauth.User 333 441 Repos []db.Repo 334 442 CollaboratingRepos []db.Repo 335 443 ProfileTimeline *db.ProfileTimeline 336 - Card ProfileCard 337 - Punchcard db.Punchcard 444 + Card *ProfileCard 445 + Active string 446 + } 338 447 339 - DidHandleMap map[string]string 448 + func (p *Pages) ProfileOverview(w io.Writer, params ProfileOverviewParams) error { 449 + params.Active = "overview" 450 + return p.executeProfile("user/overview", w, params) 340 451 } 341 452 342 - type ProfileCard struct { 343 - UserDid string 344 - UserHandle string 345 - FollowStatus db.FollowStatus 346 - AvatarUri string 347 - Followers int 348 - Following int 349 - 350 - Profile *db.Profile 453 + type ProfileReposParams struct { 454 + LoggedInUser *oauth.User 455 + Repos []db.Repo 456 + Card *ProfileCard 457 + Active string 351 458 } 352 459 353 - func (p *Pages) ProfilePage(w io.Writer, params ProfilePageParams) error { 354 - return p.execute("user/profile", w, params) 460 + func (p *Pages) ProfileRepos(w io.Writer, params ProfileReposParams) error { 461 + params.Active = "repos" 462 + return p.executeProfile("user/repos", w, params) 355 463 } 356 464 357 - type ReposPageParams struct { 465 + type ProfileStarredParams struct { 358 466 LoggedInUser *oauth.User 359 467 Repos []db.Repo 360 - Card ProfileCard 468 + Card *ProfileCard 469 + Active string 470 + } 361 471 362 - DidHandleMap map[string]string 472 + func (p *Pages) ProfileStarred(w io.Writer, params ProfileStarredParams) error { 473 + params.Active = "starred" 474 + return p.executeProfile("user/starred", w, params) 363 475 } 364 476 365 - func (p *Pages) ReposPage(w io.Writer, params ReposPageParams) error { 366 - return p.execute("user/repos", w, params) 477 + type ProfileStringsParams struct { 478 + LoggedInUser *oauth.User 479 + Strings []db.String 480 + Card *ProfileCard 481 + Active string 482 + } 483 + 484 + func (p *Pages) ProfileStrings(w io.Writer, params ProfileStringsParams) error { 485 + params.Active = "strings" 486 + return p.executeProfile("user/strings", w, params) 487 + } 488 + 489 + type FollowCard struct { 490 + UserDid string 491 + FollowStatus db.FollowStatus 492 + FollowersCount int64 493 + FollowingCount int64 494 + Profile *db.Profile 495 + } 496 + 497 + type ProfileFollowersParams struct { 498 + LoggedInUser *oauth.User 499 + Followers []FollowCard 500 + Card *ProfileCard 501 + Active string 502 + } 503 + 504 + func (p *Pages) ProfileFollowers(w io.Writer, params ProfileFollowersParams) error { 505 + params.Active = "overview" 506 + return p.executeProfile("user/followers", w, params) 507 + } 508 + 509 + type ProfileFollowingParams struct { 510 + LoggedInUser *oauth.User 511 + Following []FollowCard 512 + Card *ProfileCard 513 + Active string 514 + } 515 + 516 + func (p *Pages) ProfileFollowing(w io.Writer, params ProfileFollowingParams) error { 517 + params.Active = "overview" 518 + return p.executeProfile("user/following", w, params) 367 519 } 368 520 369 521 type FollowFragmentParams struct { ··· 388 540 LoggedInUser *oauth.User 389 541 Profile *db.Profile 390 542 AllRepos []PinnedRepo 391 - DidHandleMap map[string]string 392 543 } 393 544 394 545 type PinnedRepo struct { ··· 400 551 return p.executePlain("user/fragments/editPins", w, params) 401 552 } 402 553 403 - type RepoActionsFragmentParams struct { 554 + type RepoStarFragmentParams struct { 404 555 IsStarred bool 405 556 RepoAt syntax.ATURI 406 557 Stats db.RepoStats 407 558 } 408 559 409 - func (p *Pages) RepoActionsFragment(w io.Writer, params RepoActionsFragmentParams) error { 410 - return p.executePlain("repo/fragments/repoActions", w, params) 560 + func (p *Pages) RepoStarFragment(w io.Writer, params RepoStarFragmentParams) error { 561 + return p.executePlain("repo/fragments/repoStar", w, params) 411 562 } 412 563 413 564 type RepoDescriptionParams struct { ··· 423 574 } 424 575 425 576 type RepoIndexParams struct { 426 - LoggedInUser *oauth.User 427 - RepoInfo repoinfo.RepoInfo 428 - Active string 429 - TagMap map[string][]string 430 - CommitsTrunc []*object.Commit 431 - TagsTrunc []*types.TagReference 432 - BranchesTrunc []types.Branch 433 - ForkInfo *types.ForkInfo 577 + LoggedInUser *oauth.User 578 + RepoInfo repoinfo.RepoInfo 579 + Active string 580 + TagMap map[string][]string 581 + CommitsTrunc []*object.Commit 582 + TagsTrunc []*types.TagReference 583 + BranchesTrunc []types.Branch 584 + // ForkInfo *types.ForkInfo 434 585 HTMLReadme template.HTML 435 586 Raw bool 436 587 EmailToDidOrHandle map[string]string 437 588 VerifiedCommits commitverify.VerifiedCommits 438 - Languages *types.RepoLanguageResponse 589 + Languages []types.RepoLanguageDetails 439 590 Pipelines map[string]db.Pipeline 591 + NeedsKnotUpgrade bool 440 592 types.RepoIndexResponse 441 593 } 442 594 ··· 446 598 return p.executeRepo("repo/empty", w, params) 447 599 } 448 600 601 + if params.NeedsKnotUpgrade { 602 + return p.executeRepo("repo/needsUpgrade", w, params) 603 + } 604 + 449 605 p.rctx.RepoInfo = params.RepoInfo 606 + p.rctx.RepoInfo.Ref = params.Ref 450 607 p.rctx.RendererType = markup.RendererTypeRepoMarkdown 451 608 452 609 if params.ReadmeFileName != "" { 453 - var htmlString string 454 610 ext := filepath.Ext(params.ReadmeFileName) 455 611 switch ext { 456 612 case ".md", ".markdown", ".mdown", ".mkdn", ".mkd": 457 - htmlString = p.rctx.RenderMarkdown(params.Readme) 458 613 params.Raw = false 459 - params.HTMLReadme = template.HTML(p.rctx.Sanitize(htmlString)) 614 + htmlString := p.rctx.RenderMarkdown(params.Readme) 615 + sanitized := p.rctx.SanitizeDefault(htmlString) 616 + params.HTMLReadme = template.HTML(sanitized) 460 617 default: 461 - htmlString = string(params.Readme) 462 618 params.Raw = true 463 - params.HTMLReadme = template.HTML(bluemonday.NewPolicy().Sanitize(htmlString)) 464 619 } 465 620 } 466 621 ··· 489 644 Active string 490 645 EmailToDidOrHandle map[string]string 491 646 Pipeline *db.Pipeline 647 + DiffOpts types.DiffOpts 492 648 493 649 // singular because it's always going to be just one 494 650 VerifiedCommit commitverify.VerifiedCommits ··· 506 662 RepoInfo repoinfo.RepoInfo 507 663 Active string 508 664 BreadCrumbs [][]string 509 - BaseTreeLink string 510 - BaseBlobLink string 665 + TreePath string 511 666 types.RepoTreeResponse 512 667 } 513 668 ··· 534 689 535 690 func (p *Pages) RepoTree(w io.Writer, params RepoTreeParams) error { 536 691 params.Active = "overview" 537 - return p.execute("repo/tree", w, params) 692 + return p.executeRepo("repo/tree", w, params) 538 693 } 539 694 540 695 type RepoBranchesParams struct { ··· 577 732 LoggedInUser *oauth.User 578 733 RepoInfo repoinfo.RepoInfo 579 734 Active string 735 + Unsupported bool 736 + IsImage bool 737 + IsVideo bool 738 + ContentSrc string 580 739 BreadCrumbs [][]string 581 740 ShowRendered bool 582 741 RenderToggle bool 583 742 RenderedContents template.HTML 584 - types.RepoBlobResponse 743 + *tangled.RepoBlob_Output 744 + // Computed fields for template compatibility 745 + Contents string 746 + Lines int 747 + SizeHint uint64 748 + IsBinary bool 585 749 } 586 750 587 751 func (p *Pages) RepoBlob(w io.Writer, params RepoBlobParams) error { ··· 593 757 p.rctx.RepoInfo = params.RepoInfo 594 758 p.rctx.RendererType = markup.RendererTypeRepoMarkdown 595 759 htmlString := p.rctx.RenderMarkdown(params.Contents) 596 - params.RenderedContents = template.HTML(p.rctx.Sanitize(htmlString)) 760 + sanitized := p.rctx.SanitizeDefault(htmlString) 761 + params.RenderedContents = template.HTML(sanitized) 597 762 } 598 763 } 599 764 600 - if params.Lines < 5000 { 601 - c := params.Contents 602 - formatter := chromahtml.New( 603 - chromahtml.InlineCode(false), 604 - chromahtml.WithLineNumbers(true), 605 - chromahtml.WithLinkableLineNumbers(true, "L"), 606 - chromahtml.Standalone(false), 607 - chromahtml.WithClasses(true), 608 - ) 765 + c := params.Contents 766 + formatter := chromahtml.New( 767 + chromahtml.InlineCode(false), 768 + chromahtml.WithLineNumbers(true), 769 + chromahtml.WithLinkableLineNumbers(true, "L"), 770 + chromahtml.Standalone(false), 771 + chromahtml.WithClasses(true), 772 + ) 609 773 610 - lexer := lexers.Get(filepath.Base(params.Path)) 611 - if lexer == nil { 612 - lexer = lexers.Fallback 613 - } 614 - 615 - iterator, err := lexer.Tokenise(nil, c) 616 - if err != nil { 617 - return fmt.Errorf("chroma tokenize: %w", err) 618 - } 774 + lexer := lexers.Get(filepath.Base(params.Path)) 775 + if lexer == nil { 776 + lexer = lexers.Fallback 777 + } 619 778 620 - var code bytes.Buffer 621 - err = formatter.Format(&code, style, iterator) 622 - if err != nil { 623 - return fmt.Errorf("chroma format: %w", err) 624 - } 779 + iterator, err := lexer.Tokenise(nil, c) 780 + if err != nil { 781 + return fmt.Errorf("chroma tokenize: %w", err) 782 + } 625 783 626 - params.Contents = code.String() 784 + var code bytes.Buffer 785 + err = formatter.Format(&code, style, iterator) 786 + if err != nil { 787 + return fmt.Errorf("chroma format: %w", err) 627 788 } 628 789 790 + params.Contents = code.String() 629 791 params.Active = "overview" 630 792 return p.executeRepo("repo/blob", w, params) 631 793 } ··· 644 806 Branches []types.Branch 645 807 Spindles []string 646 808 CurrentSpindle string 809 + Secrets []*tangled.RepoListSecrets_Secret 810 + 647 811 // TODO: use repoinfo.roles 648 812 IsCollaboratorInviteAllowed bool 649 813 } ··· 653 817 return p.executeRepo("repo/settings", w, params) 654 818 } 655 819 820 + type RepoGeneralSettingsParams struct { 821 + LoggedInUser *oauth.User 822 + RepoInfo repoinfo.RepoInfo 823 + Active string 824 + Tabs []map[string]any 825 + Tab string 826 + Branches []types.Branch 827 + } 828 + 829 + func (p *Pages) RepoGeneralSettings(w io.Writer, params RepoGeneralSettingsParams) error { 830 + params.Active = "settings" 831 + return p.executeRepo("repo/settings/general", w, params) 832 + } 833 + 834 + type RepoAccessSettingsParams struct { 835 + LoggedInUser *oauth.User 836 + RepoInfo repoinfo.RepoInfo 837 + Active string 838 + Tabs []map[string]any 839 + Tab string 840 + Collaborators []Collaborator 841 + } 842 + 843 + func (p *Pages) RepoAccessSettings(w io.Writer, params RepoAccessSettingsParams) error { 844 + params.Active = "settings" 845 + return p.executeRepo("repo/settings/access", w, params) 846 + } 847 + 848 + type RepoPipelineSettingsParams struct { 849 + LoggedInUser *oauth.User 850 + RepoInfo repoinfo.RepoInfo 851 + Active string 852 + Tabs []map[string]any 853 + Tab string 854 + Spindles []string 855 + CurrentSpindle string 856 + Secrets []map[string]any 857 + } 858 + 859 + func (p *Pages) RepoPipelineSettings(w io.Writer, params RepoPipelineSettingsParams) error { 860 + params.Active = "settings" 861 + return p.executeRepo("repo/settings/pipelines", w, params) 862 + } 863 + 656 864 type RepoIssuesParams struct { 657 865 LoggedInUser *oauth.User 658 866 RepoInfo repoinfo.RepoInfo 659 867 Active string 660 868 Issues []db.Issue 661 - DidHandleMap map[string]string 662 869 Page pagination.Page 663 870 FilteringByOpen bool 664 871 } ··· 672 879 LoggedInUser *oauth.User 673 880 RepoInfo repoinfo.RepoInfo 674 881 Active string 675 - Issue db.Issue 676 - Comments []db.Comment 882 + Issue *db.Issue 883 + CommentList []db.CommentListItem 677 884 IssueOwnerHandle string 678 - DidHandleMap map[string]string 679 885 680 - State string 886 + OrderedReactionKinds []db.ReactionKind 887 + Reactions map[db.ReactionKind]int 888 + UserReacted map[db.ReactionKind]bool 681 889 } 682 890 683 891 func (p *Pages) RepoSingleIssue(w io.Writer, params RepoSingleIssueParams) error { 684 892 params.Active = "issues" 685 - if params.Issue.Open { 686 - params.State = "open" 687 - } else { 688 - params.State = "closed" 689 - } 690 - return p.execute("repo/issues/issue", w, params) 893 + return p.executeRepo("repo/issues/issue", w, params) 894 + } 895 + 896 + type EditIssueParams struct { 897 + LoggedInUser *oauth.User 898 + RepoInfo repoinfo.RepoInfo 899 + Issue *db.Issue 900 + Action string 901 + } 902 + 903 + func (p *Pages) EditIssueFragment(w io.Writer, params EditIssueParams) error { 904 + params.Action = "edit" 905 + return p.executePlain("repo/issues/fragments/putIssue", w, params) 906 + } 907 + 908 + type ThreadReactionFragmentParams struct { 909 + ThreadAt syntax.ATURI 910 + Kind db.ReactionKind 911 + Count int 912 + IsReacted bool 913 + } 914 + 915 + func (p *Pages) ThreadReactionFragment(w io.Writer, params ThreadReactionFragmentParams) error { 916 + return p.executePlain("repo/fragments/reaction", w, params) 691 917 } 692 918 693 919 type RepoNewIssueParams struct { 694 920 LoggedInUser *oauth.User 695 921 RepoInfo repoinfo.RepoInfo 922 + Issue *db.Issue // existing issue if any -- passed when editing 696 923 Active string 924 + Action string 697 925 } 698 926 699 927 func (p *Pages) RepoNewIssue(w io.Writer, params RepoNewIssueParams) error { 700 928 params.Active = "issues" 929 + params.Action = "create" 701 930 return p.executeRepo("repo/issues/new", w, params) 702 931 } 703 932 ··· 705 934 LoggedInUser *oauth.User 706 935 RepoInfo repoinfo.RepoInfo 707 936 Issue *db.Issue 708 - Comment *db.Comment 937 + Comment *db.IssueComment 709 938 } 710 939 711 940 func (p *Pages) EditIssueCommentFragment(w io.Writer, params EditIssueCommentParams) error { 712 941 return p.executePlain("repo/issues/fragments/editIssueComment", w, params) 713 942 } 714 943 715 - type SingleIssueCommentParams struct { 944 + type ReplyIssueCommentPlaceholderParams struct { 716 945 LoggedInUser *oauth.User 717 - DidHandleMap map[string]string 718 946 RepoInfo repoinfo.RepoInfo 719 947 Issue *db.Issue 720 - Comment *db.Comment 948 + Comment *db.IssueComment 949 + } 950 + 951 + func (p *Pages) ReplyIssueCommentPlaceholderFragment(w io.Writer, params ReplyIssueCommentPlaceholderParams) error { 952 + return p.executePlain("repo/issues/fragments/replyIssueCommentPlaceholder", w, params) 953 + } 954 + 955 + type ReplyIssueCommentParams struct { 956 + LoggedInUser *oauth.User 957 + RepoInfo repoinfo.RepoInfo 958 + Issue *db.Issue 959 + Comment *db.IssueComment 960 + } 961 + 962 + func (p *Pages) ReplyIssueCommentFragment(w io.Writer, params ReplyIssueCommentParams) error { 963 + return p.executePlain("repo/issues/fragments/replyComment", w, params) 721 964 } 722 965 723 - func (p *Pages) SingleIssueCommentFragment(w io.Writer, params SingleIssueCommentParams) error { 724 - return p.executePlain("repo/issues/fragments/issueComment", w, params) 966 + type IssueCommentBodyParams struct { 967 + LoggedInUser *oauth.User 968 + RepoInfo repoinfo.RepoInfo 969 + Issue *db.Issue 970 + Comment *db.IssueComment 971 + } 972 + 973 + func (p *Pages) IssueCommentBodyFragment(w io.Writer, params IssueCommentBodyParams) error { 974 + return p.executePlain("repo/issues/fragments/issueCommentBody", w, params) 725 975 } 726 976 727 977 type RepoNewPullParams struct { ··· 746 996 RepoInfo repoinfo.RepoInfo 747 997 Pulls []*db.Pull 748 998 Active string 749 - DidHandleMap map[string]string 750 999 FilteringBy db.PullState 751 1000 Stacks map[string]db.Stack 1001 + Pipelines map[string]db.Pipeline 752 1002 } 753 1003 754 1004 func (p *Pages) RepoPulls(w io.Writer, params RepoPullsParams) error { ··· 778 1028 LoggedInUser *oauth.User 779 1029 RepoInfo repoinfo.RepoInfo 780 1030 Active string 781 - DidHandleMap map[string]string 782 1031 Pull *db.Pull 783 1032 Stack db.Stack 784 1033 AbandonedPulls []*db.Pull 785 1034 MergeCheck types.MergeCheckResponse 786 1035 ResubmitCheck ResubmitResult 1036 + Pipelines map[string]db.Pipeline 1037 + 1038 + OrderedReactionKinds []db.ReactionKind 1039 + Reactions map[db.ReactionKind]int 1040 + UserReacted map[db.ReactionKind]bool 787 1041 } 788 1042 789 1043 func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error { ··· 792 1046 } 793 1047 794 1048 type RepoPullPatchParams struct { 795 - LoggedInUser *oauth.User 796 - DidHandleMap map[string]string 797 - RepoInfo repoinfo.RepoInfo 798 - Pull *db.Pull 799 - Stack db.Stack 800 - Diff *types.NiceDiff 801 - Round int 802 - Submission *db.PullSubmission 1049 + LoggedInUser *oauth.User 1050 + RepoInfo repoinfo.RepoInfo 1051 + Pull *db.Pull 1052 + Stack db.Stack 1053 + Diff *types.NiceDiff 1054 + Round int 1055 + Submission *db.PullSubmission 1056 + OrderedReactionKinds []db.ReactionKind 1057 + DiffOpts types.DiffOpts 803 1058 } 804 1059 805 1060 // this name is a mouthful ··· 808 1063 } 809 1064 810 1065 type RepoPullInterdiffParams struct { 811 - LoggedInUser *oauth.User 812 - DidHandleMap map[string]string 813 - RepoInfo repoinfo.RepoInfo 814 - Pull *db.Pull 815 - Round int 816 - Interdiff *patchutil.InterdiffResult 1066 + LoggedInUser *oauth.User 1067 + RepoInfo repoinfo.RepoInfo 1068 + Pull *db.Pull 1069 + Round int 1070 + Interdiff *patchutil.InterdiffResult 1071 + OrderedReactionKinds []db.ReactionKind 1072 + DiffOpts types.DiffOpts 817 1073 } 818 1074 819 1075 // this name is a mouthful ··· 904 1160 Base string 905 1161 Head string 906 1162 Diff *types.NiceDiff 1163 + DiffOpts types.DiffOpts 907 1164 908 1165 Active string 909 1166 } ··· 963 1220 return p.executeRepo("repo/pipelines/pipelines", w, params) 964 1221 } 965 1222 1223 + type LogBlockParams struct { 1224 + Id int 1225 + Name string 1226 + Command string 1227 + Collapsed bool 1228 + } 1229 + 1230 + func (p *Pages) LogBlock(w io.Writer, params LogBlockParams) error { 1231 + return p.executePlain("repo/pipelines/fragments/logBlock", w, params) 1232 + } 1233 + 1234 + type LogLineParams struct { 1235 + Id int 1236 + Content string 1237 + } 1238 + 1239 + func (p *Pages) LogLine(w io.Writer, params LogLineParams) error { 1240 + return p.executePlain("repo/pipelines/fragments/logLine", w, params) 1241 + } 1242 + 966 1243 type WorkflowParams struct { 967 1244 LoggedInUser *oauth.User 968 1245 RepoInfo repoinfo.RepoInfo 969 1246 Pipeline db.Pipeline 970 1247 Workflow string 1248 + LogUrl string 971 1249 Active string 972 1250 } 973 1251 ··· 976 1254 return p.executeRepo("repo/pipelines/workflow", w, params) 977 1255 } 978 1256 1257 + type PutStringParams struct { 1258 + LoggedInUser *oauth.User 1259 + Action string 1260 + 1261 + // this is supplied in the case of editing an existing string 1262 + String db.String 1263 + } 1264 + 1265 + func (p *Pages) PutString(w io.Writer, params PutStringParams) error { 1266 + return p.execute("strings/put", w, params) 1267 + } 1268 + 1269 + type StringsDashboardParams struct { 1270 + LoggedInUser *oauth.User 1271 + Card ProfileCard 1272 + Strings []db.String 1273 + } 1274 + 1275 + func (p *Pages) StringsDashboard(w io.Writer, params StringsDashboardParams) error { 1276 + return p.execute("strings/dashboard", w, params) 1277 + } 1278 + 1279 + type StringTimelineParams struct { 1280 + LoggedInUser *oauth.User 1281 + Strings []db.String 1282 + } 1283 + 1284 + func (p *Pages) StringsTimeline(w io.Writer, params StringTimelineParams) error { 1285 + return p.execute("strings/timeline", w, params) 1286 + } 1287 + 1288 + type SingleStringParams struct { 1289 + LoggedInUser *oauth.User 1290 + ShowRendered bool 1291 + RenderToggle bool 1292 + RenderedContents template.HTML 1293 + String db.String 1294 + Stats db.StringStats 1295 + Owner identity.Identity 1296 + } 1297 + 1298 + func (p *Pages) SingleString(w io.Writer, params SingleStringParams) error { 1299 + var style *chroma.Style = styles.Get("catpuccin-latte") 1300 + 1301 + if params.ShowRendered { 1302 + switch markup.GetFormat(params.String.Filename) { 1303 + case markup.FormatMarkdown: 1304 + p.rctx.RendererType = markup.RendererTypeRepoMarkdown 1305 + htmlString := p.rctx.RenderMarkdown(params.String.Contents) 1306 + sanitized := p.rctx.SanitizeDefault(htmlString) 1307 + params.RenderedContents = template.HTML(sanitized) 1308 + } 1309 + } 1310 + 1311 + c := params.String.Contents 1312 + formatter := chromahtml.New( 1313 + chromahtml.InlineCode(false), 1314 + chromahtml.WithLineNumbers(true), 1315 + chromahtml.WithLinkableLineNumbers(true, "L"), 1316 + chromahtml.Standalone(false), 1317 + chromahtml.WithClasses(true), 1318 + ) 1319 + 1320 + lexer := lexers.Get(filepath.Base(params.String.Filename)) 1321 + if lexer == nil { 1322 + lexer = lexers.Fallback 1323 + } 1324 + 1325 + iterator, err := lexer.Tokenise(nil, c) 1326 + if err != nil { 1327 + return fmt.Errorf("chroma tokenize: %w", err) 1328 + } 1329 + 1330 + var code bytes.Buffer 1331 + err = formatter.Format(&code, style, iterator) 1332 + if err != nil { 1333 + return fmt.Errorf("chroma format: %w", err) 1334 + } 1335 + 1336 + params.String.Contents = code.String() 1337 + return p.execute("strings/string", w, params) 1338 + } 1339 + 1340 + func (p *Pages) Home(w io.Writer, params TimelineParams) error { 1341 + return p.execute("timeline/home", w, params) 1342 + } 1343 + 979 1344 func (p *Pages) Static() http.Handler { 980 1345 if p.dev { 981 1346 return http.StripPrefix("/static/", http.FileServer(http.Dir("appview/pages/static"))) ··· 983 1348 984 1349 sub, err := fs.Sub(Files, "static") 985 1350 if err != nil { 986 - log.Fatalf("no static dir found? that's crazy: %v", err) 1351 + p.logger.Error("no static dir found? that's crazy", "err", err) 1352 + panic(err) 987 1353 } 988 1354 // Custom handler to apply Cache-Control headers for font files 989 1355 return Cache(http.StripPrefix("/static/", http.FileServer(http.FS(sub)))) ··· 1006 1372 func CssContentHash() string { 1007 1373 cssFile, err := Files.Open("static/tw.css") 1008 1374 if err != nil { 1009 - log.Printf("Error opening CSS file: %v", err) 1375 + slog.Debug("Error opening CSS file", "err", err) 1010 1376 return "" 1011 1377 } 1012 1378 defer cssFile.Close() 1013 1379 1014 1380 hasher := sha256.New() 1015 1381 if _, err := io.Copy(hasher, cssFile); err != nil { 1016 - log.Printf("Error hashing CSS file: %v", err) 1382 + slog.Debug("Error hashing CSS file", "err", err) 1017 1383 return "" 1018 1384 } 1019 1385 ··· 1026 1392 1027 1393 func (p *Pages) Error404(w io.Writer) error { 1028 1394 return p.execute("errors/404", w, nil) 1395 + } 1396 + 1397 + func (p *Pages) ErrorKnot404(w io.Writer) error { 1398 + return p.execute("errors/knot404", w, nil) 1029 1399 } 1030 1400 1031 1401 func (p *Pages) Error503(w io.Writer) error {
+3 -7
appview/pages/repoinfo/repoinfo.go
··· 56 56 OwnerHandle string 57 57 Description string 58 58 Knot string 59 + Spindle string 59 60 RepoAt syntax.ATURI 60 61 IsStarred bool 61 62 Stats db.RepoStats ··· 77 78 func (r RepoInfo) TabMetadata() map[string]any { 78 79 meta := make(map[string]any) 79 80 80 - if r.Stats.PullCount.Open > 0 { 81 - meta["pulls"] = r.Stats.PullCount.Open 82 - } 83 - 84 - if r.Stats.IssueCount.Open > 0 { 85 - meta["issues"] = r.Stats.IssueCount.Open 86 - } 81 + meta["pulls"] = r.Stats.PullCount.Open 82 + meta["issues"] = r.Stats.IssueCount.Open 87 83 88 84 // more stuff? 89 85
+38
appview/pages/templates/banner.html
··· 1 + {{ define "banner" }} 2 + <div class="flex items-center justify-center mx-auto w-full bg-red-100 dark:bg-red-900 border border-red-200 dark:border-red-800 rounded-b drop-shadow-sm text-red-800 dark:text-red-200"> 3 + <details class="group p-2"> 4 + <summary class="list-none cursor-pointer"> 5 + <div class="flex gap-4 items-center"> 6 + <span class="group-open:hidden inline">{{ i "triangle-alert" "w-4 h-4" }}</span> 7 + <span class="hidden group-open:inline">{{ i "x" "w-4 h-4" }}</span> 8 + 9 + <span class="group-open:hidden inline">Some services that you administer require an update. Click to show more.</span> 10 + <span class="hidden group-open:inline">Some services that you administer will have to be updated to be compatible with Tangled.</span> 11 + </div> 12 + </summary> 13 + 14 + {{ if .Registrations }} 15 + <ul class="list-disc mx-12 my-2"> 16 + {{range .Registrations}} 17 + <li>Knot: {{ .Domain }}</li> 18 + {{ end }} 19 + </ul> 20 + {{ end }} 21 + 22 + {{ if .Spindles }} 23 + <ul class="list-disc mx-12 my-2"> 24 + {{range .Spindles}} 25 + <li>Spindle: {{ .Instance }}</li> 26 + {{ end }} 27 + </ul> 28 + {{ end }} 29 + 30 + <div class="mx-6"> 31 + These services may not be fully accessible until upgraded. 32 + <a class="underline text-red-800 dark:text-red-200" 33 + href="https://tangled.sh/@tangled.sh/core/tree/master/docs/migrations.md"> 34 + Click to read the upgrade guide</a>. 35 + </div> 36 + </details> 37 + </div> 38 + {{ end }}
+24 -4
appview/pages/templates/errors/404.html
··· 1 1 {{ define "title" }}404 &middot; tangled{{ end }} 2 2 3 3 {{ define "content" }} 4 - <h1>404 &mdash; nothing like that here!</h1> 5 - <p> 6 - It seems we couldn't find what you were looking for. Sorry about that! 7 - </p> 4 + <div class="flex flex-col items-center justify-center min-h-[60vh] text-center"> 5 + <div class="bg-white dark:bg-gray-800 rounded-lg drop-shadow-sm p-8 max-w-lg mx-auto"> 6 + <div class="mb-6"> 7 + <div class="w-16 h-16 mx-auto mb-4 rounded-full bg-gray-100 dark:bg-gray-700 flex items-center justify-center"> 8 + {{ i "search-x" "w-8 h-8 text-gray-400 dark:text-gray-500" }} 9 + </div> 10 + </div> 11 + 12 + <div class="space-y-4"> 13 + <h1 class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-white"> 14 + 404 &mdash; page not found 15 + </h1> 16 + <p class="text-gray-600 dark:text-gray-300"> 17 + The page you're looking for doesn't exist. It may have been moved, deleted, or you have the wrong URL. 18 + </p> 19 + <div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6"> 20 + <a href="javascript:history.back()" class="btn no-underline hover:no-underline gap-2"> 21 + {{ i "arrow-left" "w-4 h-4" }} 22 + go back 23 + </a> 24 + </div> 25 + </div> 26 + </div> 27 + </div> 8 28 {{ end }}
+35 -2
appview/pages/templates/errors/500.html
··· 1 1 {{ define "title" }}500 &middot; tangled{{ end }} 2 2 3 3 {{ define "content" }} 4 - <h1>500 &mdash; something broke!</h1> 5 - <p>We're working on getting service back up. Hang tight!</p> 4 + <div class="flex flex-col items-center justify-center min-h-[60vh] text-center"> 5 + <div class="bg-white dark:bg-gray-800 rounded-lg drop-shadow-sm p-8 max-w-lg mx-auto"> 6 + <div class="mb-6"> 7 + <div class="w-16 h-16 mx-auto mb-4 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center"> 8 + {{ i "alert-triangle" "w-8 h-8 text-red-500 dark:text-red-400" }} 9 + </div> 10 + </div> 11 + 12 + <div class="space-y-4"> 13 + <h1 class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-white"> 14 + 500 &mdash; internal server error 15 + </h1> 16 + <p class="text-gray-600 dark:text-gray-300"> 17 + Something went wrong on our end. We've been notified and are working to fix the issue. 18 + </p> 19 + <div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded p-3 text-sm text-yellow-800 dark:text-yellow-200"> 20 + <div class="flex items-center gap-2"> 21 + {{ i "info" "w-4 h-4" }} 22 + <span class="font-medium">we're on it!</span> 23 + </div> 24 + <p class="mt-1">Our team has been automatically notified about this error.</p> 25 + </div> 26 + <div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6"> 27 + <button onclick="location.reload()" class="btn-create gap-2"> 28 + {{ i "refresh-cw" "w-4 h-4" }} 29 + try again 30 + </button> 31 + <a href="/" class="btn no-underline hover:no-underline gap-2"> 32 + {{ i "home" "w-4 h-4" }} 33 + back to home 34 + </a> 35 + </div> 36 + </div> 37 + </div> 38 + </div> 6 39 {{ end }}
+28 -5
appview/pages/templates/errors/503.html
··· 1 1 {{ define "title" }}503 &middot; tangled{{ end }} 2 2 3 3 {{ define "content" }} 4 - <h1>503 &mdash; unable to reach knot</h1> 5 - <p> 6 - We were unable to reach the knot hosting this repository. Try again 7 - later. 8 - </p> 4 + <div class="flex flex-col items-center justify-center min-h-[60vh] text-center"> 5 + <div class="bg-white dark:bg-gray-800 rounded-lg drop-shadow-sm p-8 max-w-lg mx-auto"> 6 + <div class="mb-6"> 7 + <div class="w-16 h-16 mx-auto mb-4 rounded-full bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center"> 8 + {{ i "server-off" "w-8 h-8 text-blue-500 dark:text-blue-400" }} 9 + </div> 10 + </div> 11 + 12 + <div class="space-y-4"> 13 + <h1 class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-white"> 14 + 503 &mdash; service unavailable 15 + </h1> 16 + <p class="text-gray-600 dark:text-gray-300"> 17 + We were unable to reach the knot hosting this repository. The service may be temporarily unavailable. 18 + </p> 19 + <div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6"> 20 + <button onclick="location.reload()" class="btn-create gap-2"> 21 + {{ i "refresh-cw" "w-4 h-4" }} 22 + try again 23 + </button> 24 + <a href="/" class="btn gap-2 no-underline hover:no-underline"> 25 + {{ i "arrow-left" "w-4 h-4" }} 26 + back to timeline 27 + </a> 28 + </div> 29 + </div> 30 + </div> 31 + </div> 9 32 {{ end }}
+28
appview/pages/templates/errors/knot404.html
··· 1 + {{ define "title" }}404 &middot; tangled{{ end }} 2 + 3 + {{ define "content" }} 4 + <div class="flex flex-col items-center justify-center min-h-[60vh] text-center"> 5 + <div class="bg-white dark:bg-gray-800 rounded-lg drop-shadow-sm p-8 max-w-lg mx-auto"> 6 + <div class="mb-6"> 7 + <div class="w-16 h-16 mx-auto mb-4 rounded-full bg-orange-100 dark:bg-orange-900/30 flex items-center justify-center"> 8 + {{ i "book-x" "w-8 h-8 text-orange-500 dark:text-orange-400" }} 9 + </div> 10 + </div> 11 + 12 + <div class="space-y-4"> 13 + <h1 class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-white"> 14 + 404 &mdash; repository not found 15 + </h1> 16 + <p class="text-gray-600 dark:text-gray-300"> 17 + The repository you were looking for could not be found. The knot serving the repository may be unavailable. 18 + </p> 19 + <div class="flex flex-col sm:flex-row gap-3 justify-center items-center mt-6"> 20 + <a href="/" class="btn flex items-center gap-2 no-underline hover:no-underline"> 21 + {{ i "arrow-left" "w-4 h-4" }} 22 + back to timeline 23 + </a> 24 + </div> 25 + </div> 26 + </div> 27 + </div> 28 + {{ end }}
+26
appview/pages/templates/favicon.html
··· 1 + {{ define "favicon" }} 2 + <svg xmlns="http://www.w3.org/2000/svg" width="32" height="32"> 3 + <style> 4 + .favicon-text { 5 + fill: #000000; 6 + stroke: none; 7 + } 8 + 9 + @media (prefers-color-scheme: dark) { 10 + .favicon-text { 11 + fill: #ffffff; 12 + stroke: none; 13 + } 14 + } 15 + </style> 16 + 17 + <g style="display:inline"> 18 + <path d="M0-2.117h62.177v25.135H0z" style="display:inline;fill:none;fill-opacity:1;stroke-width:.396875" transform="translate(11.01 6.9)"/> 19 + <path d="M3.64 22.787c-1.697 0-2.943-.45-3.74-1.35-.77-.9-1.156-2.094-1.156-3.585 0-.36.013-.72.038-1.08.052-.385.129-.873.232-1.464L.44 6.826h-5.089l.733-4.394h3.2c.822 0 1.439-.168 1.85-.502.437-.334.72-.938.848-1.812l.771-4.703h5.243L6.84 2.432h7.787l-.733 4.394H6.107L4.257 17.93l.77.27 6.015-4.742 2.775 3.161-2.313 2.005c-.822.694-1.568 1.31-2.236 1.85-.668.515-1.31.952-1.927 1.311a7.406 7.406 0 0 1-1.774.733c-.59.18-1.233.27-1.927.27z" 20 + aria-label="tangled.sh" 21 + class="favicon-text" 22 + style="font-size:16.2278px;font-family:'IBM Plex Mono';-inkscape-font-specification:'IBM Plex Mono, Normal';display:inline;fill-opacity:1" 23 + transform="translate(11.01 6.9)"/> 24 + </g> 25 + </svg> 26 + {{ end }}
+8
appview/pages/templates/fragments/logotype.html
··· 1 + {{ define "fragments/logotype" }} 2 + <span class="flex items-center gap-2"> 3 + <span class="font-bold italic">tangled</span> 4 + <span class="font-normal not-italic text-xs rounded bg-gray-100 dark:bg-gray-700 px-1"> 5 + alpha 6 + </span> 7 + <span> 8 + {{ end }}
+90
appview/pages/templates/fragments/multiline-select.html
··· 1 + {{ define "fragments/multiline-select" }} 2 + <script> 3 + function highlight(scroll = false) { 4 + document.querySelectorAll(".hl").forEach(el => { 5 + el.classList.remove("hl"); 6 + }); 7 + 8 + const hash = window.location.hash; 9 + if (!hash || !hash.startsWith("#L")) { 10 + return; 11 + } 12 + 13 + const rangeStr = hash.substring(2); 14 + const parts = rangeStr.split("-"); 15 + let startLine, endLine; 16 + 17 + if (parts.length === 2) { 18 + startLine = parseInt(parts[0], 10); 19 + endLine = parseInt(parts[1], 10); 20 + } else { 21 + startLine = parseInt(parts[0], 10); 22 + endLine = startLine; 23 + } 24 + 25 + if (isNaN(startLine) || isNaN(endLine)) { 26 + console.log("nan"); 27 + console.log(startLine); 28 + console.log(endLine); 29 + return; 30 + } 31 + 32 + let target = null; 33 + 34 + for (let i = startLine; i<= endLine; i++) { 35 + const idEl = document.getElementById(`L${i}`); 36 + if (idEl) { 37 + const el = idEl.closest(".line"); 38 + if (el) { 39 + el.classList.add("hl"); 40 + target = el; 41 + } 42 + } 43 + } 44 + 45 + if (scroll && target) { 46 + target.scrollIntoView({ 47 + behavior: "smooth", 48 + block: "center", 49 + }); 50 + } 51 + } 52 + 53 + document.addEventListener("DOMContentLoaded", () => { 54 + console.log("DOMContentLoaded"); 55 + highlight(true); 56 + }); 57 + window.addEventListener("hashchange", () => { 58 + console.log("hashchange"); 59 + highlight(); 60 + }); 61 + window.addEventListener("popstate", () => { 62 + console.log("popstate"); 63 + highlight(); 64 + }); 65 + 66 + const lineNumbers = document.querySelectorAll('a[href^="#L"'); 67 + let startLine = null; 68 + 69 + lineNumbers.forEach(el => { 70 + el.addEventListener("click", (event) => { 71 + event.preventDefault(); 72 + const currentLine = parseInt(el.href.split("#L")[1]); 73 + 74 + if (event.shiftKey && startLine !== null) { 75 + const endLine = currentLine; 76 + const min = Math.min(startLine, endLine); 77 + const max = Math.max(startLine, endLine); 78 + const newHash = `#L${min}-${max}`; 79 + history.pushState(null, '', newHash); 80 + } else { 81 + const newHash = `#L${currentLine}`; 82 + history.pushState(null, '', newHash); 83 + startLine = currentLine; 84 + } 85 + 86 + highlight(); 87 + }); 88 + }); 89 + </script> 90 + {{ end }}
-98
appview/pages/templates/knot.html
··· 1 - {{ define "title" }}{{ .Registration.Domain }}{{ end }} 2 - 3 - {{ define "content" }} 4 - <div class="p-6"> 5 - <p class="text-xl font-bold dark:text-white">{{ .Registration.Domain }}</p> 6 - </div> 7 - 8 - <div class="flex flex-col"> 9 - {{ block "registration-info" . }} {{ end }} 10 - {{ block "members" . }} {{ end }} 11 - {{ block "add-member" . }} {{ end }} 12 - </div> 13 - {{ end }} 14 - 15 - {{ define "registration-info" }} 16 - <section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit"> 17 - <dl class="grid grid-cols-[auto_1fr] gap-x-4 dark:text-gray-200"> 18 - <dt class="font-bold">opened by</dt> 19 - <dd> 20 - <span> 21 - {{ index $.DidHandleMap .Registration.ByDid }} <span class="text-gray-500 dark:text-gray-400 font-mono">{{ .Registration.ByDid }}</span> 22 - </span> 23 - {{ if eq $.LoggedInUser.Did $.Registration.ByDid }} 24 - <span class="text-xs bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 px-2 py-1 rounded ml-2">you</span> 25 - {{ end }} 26 - </dd> 27 - 28 - <dt class="font-bold">opened</dt> 29 - <dd>{{ .Registration.Created | timeFmt }}</dd> 30 - 31 - {{ if .Registration.Registered }} 32 - <dt class="font-bold">registered</dt> 33 - <dd>{{ .Registration.Registered | timeFmt }}</dd> 34 - {{ else }} 35 - <dt class="font-bold">status</dt> 36 - <dd class="text-yellow-800 dark:text-yellow-200 bg-yellow-100 dark:bg-yellow-900 rounded px-2 py-1 inline-block"> 37 - Pending Registration 38 - </dd> 39 - {{ end }} 40 - </dl> 41 - 42 - {{ if not .Registration.Registered }} 43 - <div class="mt-4"> 44 - <button 45 - class="btn dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600" 46 - hx-post="/knots/{{.Domain}}/init" 47 - hx-swap="none"> 48 - Initialize Registration 49 - </button> 50 - </div> 51 - {{ end }} 52 - </section> 53 - {{ end }} 54 - 55 - {{ define "members" }} 56 - <h2 class="text-sm font-bold py-2 px-6 uppercase dark:text-gray-300">members</h2> 57 - <section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit"> 58 - {{ if .Registration.Registered }} 59 - <div id="member-list" class="flex flex-col gap-4"> 60 - {{ range $.Members }} 61 - <div class="inline-flex items-center gap-4"> 62 - {{ i "user" "w-4 h-4 dark:text-gray-300" }} 63 - <a href="/{{index $.DidHandleMap .}}" class="text-gray-900 dark:text-white">{{index $.DidHandleMap .}} 64 - <span class="text-gray-500 dark:text-gray-400 font-mono">{{.}}</span> 65 - </a> 66 - </div> 67 - {{ else }} 68 - <p class="text-gray-500 dark:text-gray-400">No members have been added yet.</p> 69 - {{ end }} 70 - </div> 71 - {{ else }} 72 - <p class="text-gray-500 dark:text-gray-400">Members can be added after registration is complete.</p> 73 - {{ end }} 74 - </section> 75 - {{ end }} 76 - 77 - {{ define "add-member" }} 78 - {{ if $.IsOwner }} 79 - <h2 class="text-sm font-bold py-2 px-6 uppercase dark:text-gray-300">add member</h2> 80 - <section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit"> 81 - <form 82 - hx-put="/knots/{{.Registration.Domain}}/member" 83 - class="max-w-2xl space-y-4"> 84 - <input 85 - type="text" 86 - id="subject" 87 - name="subject" 88 - placeholder="did or handle" 89 - required 90 - class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400"/> 91 - 92 - <button class="btn dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600" type="submit">add member</button> 93 - 94 - <div id="add-member-error" class="error dark:text-red-400"></div> 95 - </form> 96 - </section> 97 - {{ end }} 98 - {{ end }}
+127
appview/pages/templates/knots/dashboard.html
··· 1 + {{ define "title" }}{{ .Registration.Domain }} &middot; knots{{ end }} 2 + 3 + {{ define "content" }} 4 + <div class="px-6 py-4"> 5 + <div class="flex justify-between items-center"> 6 + <h1 class="text-xl font-bold dark:text-white">{{ .Registration.Domain }}</h1> 7 + <div id="right-side" class="flex gap-2"> 8 + {{ $style := "px-2 py-1 rounded flex items-center flex-shrink-0 gap-2" }} 9 + {{ $isOwner := and .LoggedInUser (eq .LoggedInUser.Did .Registration.ByDid) }} 10 + {{ if .Registration.IsRegistered }} 11 + <span class="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 {{$style}}">{{ i "shield-check" "w-4 h-4" }} verified</span> 12 + {{ if $isOwner }} 13 + {{ template "knots/fragments/addMemberModal" .Registration }} 14 + {{ end }} 15 + {{ else if .Registration.IsReadOnly }} 16 + <span class="bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 {{$style}}"> 17 + {{ i "shield-alert" "w-4 h-4" }} read-only 18 + </span> 19 + {{ if $isOwner }} 20 + {{ block "retryButton" .Registration }} {{ end }} 21 + {{ end }} 22 + {{ else }} 23 + <span class="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 {{$style}}">{{ i "shield-off" "w-4 h-4" }} unverified</span> 24 + {{ if $isOwner }} 25 + {{ block "retryButton" .Registration }} {{ end }} 26 + {{ end }} 27 + {{ end }} 28 + 29 + {{ if $isOwner }} 30 + {{ block "deleteButton" .Registration }} {{ end }} 31 + {{ end }} 32 + </div> 33 + </div> 34 + <div id="operation-error" class="dark:text-red-400"></div> 35 + </div> 36 + 37 + {{ if .Members }} 38 + <section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 39 + <div class="flex flex-col gap-2"> 40 + {{ block "member" . }} {{ end }} 41 + </div> 42 + </section> 43 + {{ end }} 44 + {{ end }} 45 + 46 + 47 + {{ define "member" }} 48 + {{ range .Members }} 49 + <div> 50 + <div class="flex justify-between items-center"> 51 + <div class="flex items-center gap-2"> 52 + {{ template "user/fragments/picHandleLink" . }} 53 + <span class="ml-2 font-mono text-gray-500">{{.}}</span> 54 + </div> 55 + {{ if ne $.LoggedInUser.Did . }} 56 + {{ block "removeMemberButton" (list $ . ) }} {{ end }} 57 + {{ end }} 58 + </div> 59 + <div class="ml-2 pl-2 pt-2 border-l border-gray-200 dark:border-gray-700"> 60 + {{ $repos := index $.Repos . }} 61 + {{ range $repos }} 62 + <div class="flex gap-2 items-center"> 63 + {{ i "book-marked" "size-4" }} 64 + <a href="/{{ resolve .Did }}/{{ .Name }}"> 65 + {{ .Name }} 66 + </a> 67 + </div> 68 + {{ else }} 69 + <div class="text-gray-500 dark:text-gray-400"> 70 + No repositories configured yet. 71 + </div> 72 + {{ end }} 73 + </div> 74 + </div> 75 + {{ end }} 76 + {{ end }} 77 + 78 + {{ define "deleteButton" }} 79 + <button 80 + class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 81 + title="Delete knot" 82 + hx-delete="/knots/{{ .Domain }}" 83 + hx-swap="outerHTML" 84 + hx-confirm="Are you sure you want to delete the knot '{{ .Domain }}'?" 85 + hx-headers='{"shouldRedirect": "true"}' 86 + > 87 + {{ i "trash-2" "w-5 h-5" }} 88 + <span class="hidden md:inline">delete</span> 89 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 90 + </button> 91 + {{ end }} 92 + 93 + 94 + {{ define "retryButton" }} 95 + <button 96 + class="btn gap-2 group" 97 + title="Retry knot verification" 98 + hx-post="/knots/{{ .Domain }}/retry" 99 + hx-swap="none" 100 + hx-headers='{"shouldRefresh": "true"}' 101 + > 102 + {{ i "rotate-ccw" "w-5 h-5" }} 103 + <span class="hidden md:inline">retry</span> 104 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 105 + </button> 106 + {{ end }} 107 + 108 + 109 + {{ define "removeMemberButton" }} 110 + {{ $root := index . 0 }} 111 + {{ $member := index . 1 }} 112 + {{ $memberHandle := resolve $member }} 113 + <button 114 + class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 115 + title="Remove member" 116 + hx-post="/knots/{{ $root.Registration.Domain }}/remove" 117 + hx-swap="none" 118 + hx-vals='{"member": "{{$member}}" }' 119 + hx-confirm="Are you sure you want to remove {{ $memberHandle }} from this knot?" 120 + > 121 + {{ i "user-minus" "w-4 h-4" }} 122 + remove 123 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 124 + </button> 125 + {{ end }} 126 + 127 +
+57
appview/pages/templates/knots/fragments/addMemberModal.html
··· 1 + {{ define "knots/fragments/addMemberModal" }} 2 + <button 3 + class="btn gap-2 group" 4 + title="Add member to this knot" 5 + popovertarget="add-member-{{ .Id }}" 6 + popovertargetaction="toggle" 7 + > 8 + {{ i "user-plus" "w-5 h-5" }} 9 + <span class="hidden md:inline">add member</span> 10 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 11 + </button> 12 + 13 + <div 14 + id="add-member-{{ .Id }}" 15 + popover 16 + class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50"> 17 + {{ block "addKnotMemberPopover" . }} {{ end }} 18 + </div> 19 + {{ end }} 20 + 21 + {{ define "addKnotMemberPopover" }} 22 + <form 23 + hx-post="/knots/{{ .Domain }}/add" 24 + hx-indicator="#spinner" 25 + hx-swap="none" 26 + class="flex flex-col gap-2" 27 + > 28 + <label for="member-did-{{ .Id }}" class="uppercase p-0"> 29 + ADD MEMBER 30 + </label> 31 + <p class="text-sm text-gray-500 dark:text-gray-400">Members can create repositories and run workflows on this knot.</p> 32 + <input 33 + type="text" 34 + id="member-did-{{ .Id }}" 35 + name="member" 36 + required 37 + placeholder="@foo.bsky.social" 38 + /> 39 + <div class="flex gap-2 pt-2"> 40 + <button 41 + type="button" 42 + popovertarget="add-member-{{ .Id }}" 43 + popovertargetaction="hide" 44 + class="btn w-1/2 flex items-center gap-2 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300" 45 + > 46 + {{ i "x" "size-4" }} cancel 47 + </button> 48 + <button type="submit" class="btn w-1/2 flex items-center"> 49 + <span class="inline-flex gap-2 items-center">{{ i "user-plus" "size-4" }} add</span> 50 + <span id="spinner" class="group"> 51 + {{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 52 + </span> 53 + </button> 54 + </div> 55 + <div id="add-member-error-{{ .Id }}" class="text-red-500 dark:text-red-400"></div> 56 + </form> 57 + {{ end }}
+83
appview/pages/templates/knots/fragments/knotListing.html
··· 1 + {{ define "knots/fragments/knotListing" }} 2 + <div id="knot-{{.Id}}" class="flex items-center justify-between p-2 border-b border-gray-200 dark:border-gray-700"> 3 + {{ block "knotLeftSide" . }} {{ end }} 4 + {{ block "knotRightSide" . }} {{ end }} 5 + </div> 6 + {{ end }} 7 + 8 + {{ define "knotLeftSide" }} 9 + {{ if .Registered }} 10 + <a href="/knots/{{ .Domain }}" class="hover:no-underline flex items-center gap-2 min-w-0 max-w-[60%]"> 11 + {{ i "hard-drive" "w-4 h-4" }} 12 + <span class="hover:underline"> 13 + {{ .Domain }} 14 + </span> 15 + <span class="text-gray-500"> 16 + {{ template "repo/fragments/shortTimeAgo" .Created }} 17 + </span> 18 + </a> 19 + {{ else }} 20 + <div class="hover:no-underline flex items-center gap-2 min-w-0 max-w-[60%]"> 21 + {{ i "hard-drive" "w-4 h-4" }} 22 + {{ .Domain }} 23 + <span class="text-gray-500"> 24 + {{ template "repo/fragments/shortTimeAgo" .Created }} 25 + </span> 26 + </div> 27 + {{ end }} 28 + {{ end }} 29 + 30 + {{ define "knotRightSide" }} 31 + <div id="right-side" class="flex gap-2"> 32 + {{ $style := "px-2 py-1 rounded flex items-center flex-shrink-0 gap-2 text-sm" }} 33 + {{ if .IsRegistered }} 34 + <span class="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 {{$style}}"> 35 + {{ i "shield-check" "w-4 h-4" }} verified 36 + </span> 37 + {{ template "knots/fragments/addMemberModal" . }} 38 + {{ block "knotDeleteButton" . }} {{ end }} 39 + {{ else if .IsNeedsUpgrade }} 40 + <span class="bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 {{$style}}"> 41 + {{ i "shield-alert" "w-4 h-4" }} needs upgrade 42 + </span> 43 + {{ block "knotRetryButton" . }} {{ end }} 44 + {{ block "knotDeleteButton" . }} {{ end }} 45 + {{ else }} 46 + <span class="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 {{$style}}"> 47 + {{ i "shield-off" "w-4 h-4" }} unverified 48 + </span> 49 + {{ block "knotRetryButton" . }} {{ end }} 50 + {{ block "knotDeleteButton" . }} {{ end }} 51 + {{ end }} 52 + </div> 53 + {{ end }} 54 + 55 + {{ define "knotDeleteButton" }} 56 + <button 57 + class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 58 + title="Delete knot" 59 + hx-delete="/knots/{{ .Domain }}" 60 + hx-swap="outerHTML" 61 + hx-target="#knot-{{.Id}}" 62 + hx-confirm="Are you sure you want to delete the knot '{{ .Domain }}'?" 63 + > 64 + {{ i "trash-2" "w-5 h-5" }} 65 + <span class="hidden md:inline">delete</span> 66 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 67 + </button> 68 + {{ end }} 69 + 70 + 71 + {{ define "knotRetryButton" }} 72 + <button 73 + class="btn gap-2 group" 74 + title="Retry knot verification" 75 + hx-post="/knots/{{ .Domain }}/retry" 76 + hx-swap="none" 77 + hx-target="#knot-{{.Id}}" 78 + > 79 + {{ i "rotate-ccw" "w-5 h-5" }} 80 + <span class="hidden md:inline">retry</span> 81 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 82 + </button> 83 + {{ end }}
+86
appview/pages/templates/knots/index.html
··· 1 + {{ define "title" }}knots{{ end }} 2 + 3 + {{ define "content" }} 4 + <div class="px-6 py-4 flex items-center justify-between gap-4 align-bottom"> 5 + <h1 class="text-xl font-bold dark:text-white">Knots</h1> 6 + <span class="flex items-center gap-1"> 7 + {{ i "book" "w-3 h-3" }} 8 + <a href="https://tangled.sh/@tangled.sh/core/blob/master/docs/knot-hosting.md">docs</a> 9 + </span> 10 + </div> 11 + 12 + <section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 13 + <div class="flex flex-col gap-6"> 14 + {{ block "about" . }} {{ end }} 15 + {{ block "list" . }} {{ end }} 16 + {{ block "register" . }} {{ end }} 17 + </div> 18 + </section> 19 + {{ end }} 20 + 21 + {{ define "about" }} 22 + <section class="rounded"> 23 + <p class="text-gray-500 dark:text-gray-400"> 24 + Knots are lightweight headless servers that enable users to host Git repositories with ease. 25 + When creating a repository, you can choose a knot to store it on. 26 + </p> 27 + 28 + 29 + </section> 30 + {{ end }} 31 + 32 + {{ define "list" }} 33 + <section class="rounded w-full flex flex-col gap-2"> 34 + <h2 class="text-sm font-bold py-2 uppercase dark:text-gray-300">your knots</h2> 35 + <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 w-full"> 36 + {{ range $registration := .Registrations }} 37 + {{ template "knots/fragments/knotListing" . }} 38 + {{ else }} 39 + <div class="flex items-center justify-center p-2 border-b border-gray-200 dark:border-gray-700 text-gray-500"> 40 + no knots registered yet 41 + </div> 42 + {{ end }} 43 + </div> 44 + <div id="operation-error" class="text-red-500 dark:text-red-400"></div> 45 + </section> 46 + {{ end }} 47 + 48 + {{ define "register" }} 49 + <section class="rounded w-full lg:w-fit flex flex-col gap-2"> 50 + <h2 class="text-sm font-bold py-2 uppercase dark:text-gray-300">register a knot</h2> 51 + <p class="mb-2 dark:text-gray-300">Enter the hostname of your knot to get started.</p> 52 + <form 53 + hx-post="/knots/register" 54 + class="max-w-2xl mb-2 space-y-4" 55 + hx-indicator="#register-button" 56 + hx-swap="none" 57 + > 58 + <div class="flex gap-2"> 59 + <input 60 + type="text" 61 + id="domain" 62 + name="domain" 63 + placeholder="knot.example.com" 64 + required 65 + class="flex-1 w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400 px-3 py-2 border rounded" 66 + > 67 + <button 68 + type="submit" 69 + id="register-button" 70 + class="btn rounded flex items-center py-2 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 group" 71 + > 72 + <span class="inline-flex items-center gap-2"> 73 + {{ i "plus" "w-4 h-4" }} 74 + register 75 + </span> 76 + <span class="pl-2 hidden group-[.htmx-request]:inline"> 77 + {{ i "loader-circle" "w-4 h-4 animate-spin" }} 78 + </span> 79 + </button> 80 + </div> 81 + 82 + <div id="register-error" class="error dark:text-red-400"></div> 83 + </form> 84 + 85 + </section> 86 + {{ end }}
-93
appview/pages/templates/knots.html
··· 1 - {{ define "title" }}knots{{ end }} 2 - {{ define "content" }} 3 - <div class="p-6"> 4 - <p class="text-xl font-bold dark:text-white">Knots</p> 5 - </div> 6 - <div class="flex flex-col"> 7 - <h2 class="text-sm font-bold py-2 px-6 uppercase dark:text-gray-300">register a knot</h2> 8 - <section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit"> 9 - <p class="mb-8 dark:text-gray-300">Generate a key to initialize your knot server.</p> 10 - <form 11 - hx-post="/knots/key" 12 - class="max-w-2xl mb-8 space-y-4" 13 - hx-indicator="#generate-knot-key-spinner" 14 - > 15 - <input 16 - type="text" 17 - id="domain" 18 - name="domain" 19 - placeholder="knot.example.com" 20 - required 21 - class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400" 22 - > 23 - <button class="btn dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 flex items-center" type="submit"> 24 - <span>generate key</span> 25 - <span id="generate-knot-key-spinner" class="group"> 26 - {{ i "loader-circle" "pl-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 27 - </span> 28 - </button> 29 - <div id="settings-knots-error" class="error dark:text-red-400"></div> 30 - </form> 31 - </section> 32 - 33 - <h2 class="text-sm font-bold py-2 px-6 uppercase dark:text-gray-300">my knots</h2> 34 - <section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit"> 35 - <div id="knots-list" class="flex flex-col gap-6 mb-8"> 36 - {{ range .Registrations }} 37 - {{ if .Registered }} 38 - <div class="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-4"> 39 - <div class="flex flex-col gap-1"> 40 - <div class="inline-flex items-center gap-4"> 41 - {{ i "git-branch" "w-3 h-3 dark:text-gray-300" }} 42 - <a href="/knots/{{ .Domain }}"> 43 - <p class="font-bold dark:text-white">{{ .Domain }}</p> 44 - </a> 45 - </div> 46 - <p class="text-sm text-gray-500 dark:text-gray-400">owned by {{ .ByDid }}</p> 47 - <p class="text-sm text-gray-500 dark:text-gray-400">registered {{ .Registered | timeFmt }}</p> 48 - </div> 49 - </div> 50 - {{ end }} 51 - {{ else }} 52 - <p class="text-sm text-gray-500 dark:text-gray-400">No knots registered</p> 53 - {{ end }} 54 - </div> 55 - </section> 56 - 57 - <h2 class="text-sm font-bold py-2 px-6 uppercase dark:text-gray-300">pending registrations</h2> 58 - <section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit"> 59 - <div id="pending-knots-list" class="flex flex-col gap-6 mb-8"> 60 - {{ range .Registrations }} 61 - {{ if not .Registered }} 62 - <div class="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-4"> 63 - <div class="flex flex-col gap-1"> 64 - <div class="inline-flex items-center gap-4"> 65 - <p class="font-bold dark:text-white">{{ .Domain }}</p> 66 - <div class="inline-flex items-center gap-1"> 67 - <span class="text-xs bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 px-2 py-1 rounded"> 68 - pending 69 - </span> 70 - </div> 71 - </div> 72 - <p class="text-sm text-gray-500 dark:text-gray-400">opened by {{ .ByDid }}</p> 73 - <p class="text-sm text-gray-500 dark:text-gray-400">created {{ .Created | timeFmt }}</p> 74 - </div> 75 - <div class="flex gap-2 items-center"> 76 - <button 77 - class="btn dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 flex gap-2 items-center group" 78 - hx-post="/knots/{{ .Domain }}/init" 79 - > 80 - {{ i "square-play" "w-5 h-5" }} 81 - <span class="hidden md:inline">initialize</span> 82 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 83 - </button> 84 - </div> 85 - </div> 86 - {{ end }} 87 - {{ else }} 88 - <p class="text-sm text-gray-500 dark:text-gray-400">No pending registrations</p> 89 - {{ end }} 90 - </div> 91 - </section> 92 - </div> 93 - {{ end }}
+46 -16
appview/pages/templates/layouts/base.html
··· 3 3 <html lang="en" class="dark:bg-gray-900"> 4 4 <head> 5 5 <meta charset="UTF-8" /> 6 - <meta 7 - name="viewport" 8 - content="width=device-width, initial-scale=1.0" 9 - /> 6 + <meta name="viewport" content="width=device-width, initial-scale=1.0"/> 7 + <meta name="description" content="Social coding, but for real this time!"/> 10 8 <meta name="htmx-config" content='{"includeIndicatorStyles": false}'> 11 - <script src="/static/htmx.min.js"></script> 9 + 10 + <script defer src="/static/htmx.min.js"></script> 11 + <script defer src="/static/htmx-ext-ws.min.js"></script> 12 + 13 + <!-- preconnect to image cdn --> 14 + <link rel="preconnect" href="https://avatar.tangled.sh" /> 15 + <link rel="preconnect" href="https://camo.tangled.sh" /> 16 + 17 + <!-- preload main font --> 18 + <link rel="preload" href="/static/fonts/InterVariable.woff2" as="font" type="font/woff2" crossorigin /> 19 + 12 20 <link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" /> 13 21 <title>{{ block "title" . }}{{ end }} ยท tangled</title> 14 22 {{ block "extrameta" . }}{{ end }} 15 23 </head> 16 - <body class="bg-slate-100 dark:bg-gray-900 dark:text-white transition-colors duration-200"> 17 - <div class="container mx-auto px-1 md:pt-4 min-h-screen flex flex-col"> 18 - <header style="z-index: 20"> 19 - {{ block "topbar" . }} 20 - {{ template "layouts/topbar" . }} 24 + <body class="min-h-screen grid grid-cols-1 grid-rows-[min-content_auto_min-content] md:grid-cols-10 lg:grid-cols-12 gap-4 bg-slate-100 dark:bg-gray-900 dark:text-white transition-colors duration-200"> 25 + {{ block "topbarLayout" . }} 26 + <header class="px-1 col-span-1 md:col-start-2 md:col-span-8 lg:col-start-3" style="z-index: 20;"> 27 + 28 + {{ if .LoggedInUser }} 29 + <div id="upgrade-banner" 30 + hx-get="/upgradeBanner" 31 + hx-trigger="load" 32 + hx-swap="innerHTML"> 33 + </div> 21 34 {{ end }} 35 + {{ template "layouts/fragments/topbar" . }} 22 36 </header> 23 - <main class="content grow">{{ block "content" . }}{{ end }}</main> 24 - <footer class="mt-16"> 25 - {{ block "footer" . }} 26 - {{ template "layouts/footer" . }} 27 - {{ end }} 37 + {{ end }} 38 + 39 + {{ block "mainLayout" . }} 40 + <div class="px-1 col-span-1 md:col-start-2 md:col-span-8 lg:col-start-3 flex flex-col gap-4"> 41 + {{ block "contentLayout" . }} 42 + <main class="col-span-1 md:col-span-8"> 43 + {{ block "content" . }}{{ end }} 44 + </main> 45 + {{ end }} 46 + 47 + {{ block "contentAfterLayout" . }} 48 + <main class="col-span-1 md:col-span-8"> 49 + {{ block "contentAfter" . }}{{ end }} 50 + </main> 51 + {{ end }} 52 + </div> 53 + {{ end }} 54 + 55 + {{ block "footerLayout" . }} 56 + <footer class="px-1 col-span-1 md:col-start-2 md:col-span-8 lg:col-start-3 mt-12"> 57 + {{ template "layouts/fragments/footer" . }} 28 58 </footer> 29 - </div> 59 + {{ end }} 30 60 </body> 31 61 </html> 32 62 {{ end }}
-7
appview/pages/templates/layouts/footer.html
··· 1 - {{ define "layouts/footer" }} 2 - <div class="w-full p-4 bg-white dark:bg-gray-800 rounded-t drop-shadow-sm"> 3 - <div class="container mx-auto text-center text-gray-600 dark:text-gray-400 text-sm"> 4 - <span class="font-semibold italic">tangled</span> &mdash; made by <a href="/@oppi.li">@oppi.li</a> and <a href="/@icyphox.sh">@icyphox.sh</a> 5 - </div> 6 - </div> 7 - {{ end }}
+48
appview/pages/templates/layouts/fragments/footer.html
··· 1 + {{ define "layouts/fragments/footer" }} 2 + <div class="w-full p-4 md:p-8 bg-white dark:bg-gray-800 rounded-t drop-shadow-sm"> 3 + <div class="container mx-auto max-w-7xl px-4"> 4 + <div class="flex flex-col lg:flex-row justify-between items-start text-gray-600 dark:text-gray-400 text-sm gap-8"> 5 + <div class="mb-4 md:mb-0"> 6 + <a href="/" hx-boost="true" class="flex gap-2 font-semibold italic"> 7 + tangled<sub>alpha</sub> 8 + </a> 9 + </div> 10 + 11 + {{ $headerStyle := "text-gray-900 dark:text-gray-200 font-bold text-xs uppercase tracking-wide mb-1" }} 12 + {{ $linkStyle := "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:underline inline-flex gap-1 items-center" }} 13 + {{ $iconStyle := "w-4 h-4 flex-shrink-0" }} 14 + <div class="grid grid-cols-1 sm:grid-cols-1 md:grid-cols-4 sm:gap-6 md:gap-2 gap-6 flex-1"> 15 + <div class="flex flex-col gap-1"> 16 + <div class="{{ $headerStyle }}">legal</div> 17 + <a href="/terms" class="{{ $linkStyle }}">{{ i "file-text" $iconStyle }} terms of service</a> 18 + <a href="/privacy" class="{{ $linkStyle }}">{{ i "shield" $iconStyle }} privacy policy</a> 19 + </div> 20 + 21 + <div class="flex flex-col gap-1"> 22 + <div class="{{ $headerStyle }}">resources</div> 23 + <a href="https://blog.tangled.sh" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "book-open" $iconStyle }} blog</a> 24 + <a href="https://tangled.sh/@tangled.sh/core/tree/master/docs" class="{{ $linkStyle }}">{{ i "book" $iconStyle }} docs</a> 25 + <a href="https://tangled.sh/@tangled.sh/core" class="{{ $linkStyle }}">{{ i "code" $iconStyle }} source</a> 26 + </div> 27 + 28 + <div class="flex flex-col gap-1"> 29 + <div class="{{ $headerStyle }}">social</div> 30 + <a href="https://chat.tangled.sh" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "message-circle" $iconStyle }} discord</a> 31 + <a href="https://web.libera.chat/#tangled" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ i "hash" $iconStyle }} irc</a> 32 + <a href="https://bsky.app/profile/tangled.sh" class="{{ $linkStyle }}" target="_blank" rel="noopener noreferrer">{{ template "user/fragments/bluesky" $iconStyle }} bluesky</a> 33 + </div> 34 + 35 + <div class="flex flex-col gap-1"> 36 + <div class="{{ $headerStyle }}">contact</div> 37 + <a href="mailto:team@tangled.sh" class="{{ $linkStyle }}">{{ i "mail" "w-4 h-4 flex-shrink-0" }} team@tangled.sh</a> 38 + <a href="mailto:security@tangled.sh" class="{{ $linkStyle }}">{{ i "shield-check" "w-4 h-4 flex-shrink-0" }} security@tangled.sh</a> 39 + </div> 40 + </div> 41 + 42 + <div class="text-center lg:text-right flex-shrink-0"> 43 + <div class="text-xs">&copy; 2025 Tangled Labs Oy. All rights reserved.</div> 44 + </div> 45 + </div> 46 + </div> 47 + </div> 48 + {{ end }}
+78
appview/pages/templates/layouts/fragments/topbar.html
··· 1 + {{ define "layouts/fragments/topbar" }} 2 + <nav class="space-x-4 px-6 py-2 rounded bg-white dark:bg-gray-800 dark:text-white drop-shadow-sm"> 3 + <div class="flex justify-between p-0 items-center"> 4 + <div id="left-items"> 5 + <a href="/" hx-boost="true" class="text-lg">{{ template "fragments/logotype" }}</a> 6 + </div> 7 + 8 + <div id="right-items" class="flex items-center gap-2"> 9 + {{ with .LoggedInUser }} 10 + {{ block "newButton" . }} {{ end }} 11 + {{ block "dropDown" . }} {{ end }} 12 + {{ else }} 13 + <a href="/login">login</a> 14 + <span class="text-gray-500 dark:text-gray-400">or</span> 15 + <a href="/signup" class="btn-create py-0 hover:no-underline hover:text-white flex items-center gap-2"> 16 + join now {{ i "arrow-right" "size-4" }} 17 + </a> 18 + {{ end }} 19 + </div> 20 + </div> 21 + </nav> 22 + {{ end }} 23 + 24 + {{ define "newButton" }} 25 + <details class="relative inline-block text-left nav-dropdown"> 26 + <summary class="btn-create py-0 cursor-pointer list-none flex items-center gap-2"> 27 + {{ i "plus" "w-4 h-4" }} new 28 + </summary> 29 + <div class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700"> 30 + <a href="/repo/new" class="flex items-center gap-2"> 31 + {{ i "book-plus" "w-4 h-4" }} 32 + new repository 33 + </a> 34 + <a href="/strings/new" class="flex items-center gap-2"> 35 + {{ i "line-squiggle" "w-4 h-4" }} 36 + new string 37 + </a> 38 + </div> 39 + </details> 40 + {{ end }} 41 + 42 + {{ define "dropDown" }} 43 + <details class="relative inline-block text-left nav-dropdown"> 44 + <summary 45 + class="cursor-pointer list-none flex items-center" 46 + > 47 + {{ $user := didOrHandle .Did .Handle }} 48 + {{ template "user/fragments/picHandle" $user }} 49 + </summary> 50 + <div 51 + class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700" 52 + > 53 + <a href="/{{ $user }}">profile</a> 54 + <a href="/{{ $user }}?tab=repos">repositories</a> 55 + <a href="/{{ $user }}?tab=strings">strings</a> 56 + <a href="/knots">knots</a> 57 + <a href="/spindles">spindles</a> 58 + <a href="/settings">settings</a> 59 + <a href="#" 60 + hx-post="/logout" 61 + hx-swap="none" 62 + class="text-red-400 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"> 63 + logout 64 + </a> 65 + </div> 66 + </details> 67 + 68 + <script> 69 + document.addEventListener('click', function(event) { 70 + const dropdowns = document.querySelectorAll('.nav-dropdown'); 71 + dropdowns.forEach(function(dropdown) { 72 + if (!dropdown.contains(event.target)) { 73 + dropdown.removeAttribute('open'); 74 + } 75 + }); 76 + }); 77 + </script> 78 + {{ end }}
+109
appview/pages/templates/layouts/profilebase.html
··· 1 + {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }}{{ end }} 2 + 3 + {{ define "extrameta" }} 4 + <meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}" /> 5 + <meta property="og:type" content="profile" /> 6 + <meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}?tab={{ .Active }}" /> 7 + <meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" /> 8 + {{ end }} 9 + 10 + {{ define "content" }} 11 + {{ template "profileTabs" . }} 12 + <section class="bg-white dark:bg-gray-800 px-2 py-6 md:p-6 rounded w-full dark:text-white drop-shadow-sm"> 13 + <div class="grid grid-cols-1 md:grid-cols-11 gap-4"> 14 + {{ $style := "hidden md:block md:col-span-3" }} 15 + {{ if eq $.Active "overview" }} 16 + {{ $style = "md:col-span-3" }} 17 + {{ end }} 18 + <div class="{{ $style }} order-1 order-1"> 19 + <div class="flex flex-col gap-4"> 20 + {{ template "user/fragments/profileCard" .Card }} 21 + {{ block "punchcard" .Card.Punchcard }} {{ end }} 22 + </div> 23 + </div> 24 + 25 + {{ block "profileContent" . }} {{ end }} 26 + </div> 27 + </section> 28 + {{ end }} 29 + 30 + {{ define "profileTabs" }} 31 + <nav class="w-full pl-4 overflow-x-auto overflow-y-hidden"> 32 + <div class="flex z-60"> 33 + {{ $activeTabStyles := "-mb-px bg-white dark:bg-gray-800" }} 34 + {{ $tabs := .Card.GetTabs }} 35 + {{ $tabmeta := dict "x" "y" }} 36 + {{ range $item := $tabs }} 37 + {{ $key := index $item 0 }} 38 + {{ $value := index $item 1 }} 39 + {{ $icon := index $item 2 }} 40 + {{ $meta := index $item 3 }} 41 + <a 42 + href="?tab={{ $value }}" 43 + class="relative -mr-px group no-underline hover:no-underline" 44 + hx-boost="true"> 45 + <div 46 + class="px-4 py-1 mr-1 text-black dark:text-white min-w-[80px] text-center relative rounded-t whitespace-nowrap 47 + {{ if eq $.Active $key }} 48 + {{ $activeTabStyles }} 49 + {{ else }} 50 + group-hover:bg-gray-100/25 group-hover:dark:bg-gray-700/25 51 + {{ end }} 52 + "> 53 + <span class="flex items-center justify-center"> 54 + {{ i $icon "w-4 h-4 mr-2" }} 55 + {{ $key }} 56 + {{ if $meta }} 57 + <span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 text-sm ml-1">{{ $meta }}</span> 58 + {{ end }} 59 + </span> 60 + </div> 61 + </a> 62 + {{ end }} 63 + </div> 64 + </nav> 65 + {{ end }} 66 + 67 + {{ define "punchcard" }} 68 + {{ $now := now }} 69 + <div> 70 + <p class="px-2 pb-4 flex gap-2 text-sm font-bold dark:text-white"> 71 + PUNCHCARD 72 + <span class="font-mono font-normal text-sm text-gray-500 dark:text-gray-400 "> 73 + {{ .Total | int64 | commaFmt }} commits 74 + </span> 75 + </p> 76 + <div class="grid grid-cols-28 md:grid-cols-14 gap-y-3 w-full h-full"> 77 + {{ range .Punches }} 78 + {{ $count := .Count }} 79 + {{ $theme := "bg-gray-200 dark:bg-gray-700 size-[4px]" }} 80 + {{ if lt $count 1 }} 81 + {{ $theme = "bg-gray-200 dark:bg-gray-700 size-[4px]" }} 82 + {{ else if lt $count 2 }} 83 + {{ $theme = "bg-green-200 dark:bg-green-900 size-[5px]" }} 84 + {{ else if lt $count 4 }} 85 + {{ $theme = "bg-green-300 dark:bg-green-800 size-[5px]" }} 86 + {{ else if lt $count 8 }} 87 + {{ $theme = "bg-green-400 dark:bg-green-700 size-[6px]" }} 88 + {{ else }} 89 + {{ $theme = "bg-green-500 dark:bg-green-600 size-[7px]" }} 90 + {{ end }} 91 + 92 + {{ if .Date.After $now }} 93 + {{ $theme = "border border-gray-200 dark:border-gray-700 size-[4px]" }} 94 + {{ end }} 95 + <div class="w-full h-full flex justify-center items-center"> 96 + <div 97 + class="aspect-square rounded-full transition-all duration-300 {{ $theme }} max-w-full max-h-full" 98 + title="{{ .Date.Format "2006-01-02" }}: {{ .Count }} commits"> 99 + </div> 100 + </div> 101 + {{ end }} 102 + </div> 103 + </div> 104 + {{ end }} 105 + 106 + {{ define "layouts/profilebase" }} 107 + {{ template "layouts/base" . }} 108 + {{ end }} 109 +
+46 -30
appview/pages/templates/layouts/repobase.html
··· 1 1 {{ define "title" }}{{ .RepoInfo.FullName }}{{ end }} 2 2 3 3 {{ define "content" }} 4 - <section id="repo-header" class="mb-4 py-2 px-6 dark:text-white"> 5 - {{ if .RepoInfo.Source }} 6 - <p class="text-sm"> 7 - <div class="flex items-center"> 8 - {{ i "git-fork" "w-3 h-3 mr-1"}} 9 - forked from 10 - {{ $sourceOwner := didOrHandle .RepoInfo.Source.Did .RepoInfo.SourceHandle }} 11 - <a class="ml-1 underline" href="/{{ $sourceOwner }}/{{ .RepoInfo.Source.Name }}">{{ $sourceOwner }}/{{ .RepoInfo.Source.Name }}</a> 12 - </div> 13 - </p> 14 - {{ end }} 15 - <div class="text-lg flex items-center justify-between"> 16 - <div> 17 - <a href="/{{ .RepoInfo.OwnerWithAt }}">{{ .RepoInfo.OwnerWithAt }}</a> 18 - <span class="select-none">/</span> 19 - <a href="/{{ .RepoInfo.FullName }}" class="font-bold">{{ .RepoInfo.Name }}</a> 20 - </div> 4 + <section id="repo-header" class="mb-4 py-2 px-6 dark:text-white"> 5 + {{ if .RepoInfo.Source }} 6 + <p class="text-sm"> 7 + <div class="flex items-center"> 8 + {{ i "git-fork" "w-3 h-3 mr-1 shrink-0" }} 9 + forked from 10 + {{ $sourceOwner := didOrHandle .RepoInfo.Source.Did .RepoInfo.SourceHandle }} 11 + <a class="ml-1 underline" href="/{{ $sourceOwner }}/{{ .RepoInfo.Source.Name }}">{{ $sourceOwner }}/{{ .RepoInfo.Source.Name }}</a> 12 + </div> 13 + </p> 14 + {{ end }} 15 + <div class="text-lg flex items-center justify-between"> 16 + <div> 17 + <a href="/{{ .RepoInfo.OwnerWithAt }}">{{ .RepoInfo.OwnerWithAt }}</a> 18 + <span class="select-none">/</span> 19 + <a href="/{{ .RepoInfo.FullName }}" class="font-bold">{{ .RepoInfo.Name }}</a> 20 + </div> 21 21 22 - {{ template "repo/fragments/repoActions" .RepoInfo }} 23 - </div> 24 - {{ template "repo/fragments/repoDescription" . }} 25 - </section> 26 - <section class="min-h-screen flex flex-col drop-shadow-sm"> 22 + <div class="flex items-center gap-2 z-auto"> 23 + <a 24 + class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group" 25 + href="/{{ .RepoInfo.FullName }}/feed.atom" 26 + > 27 + {{ i "rss" "size-4" }} 28 + </a> 29 + {{ template "repo/fragments/repoStar" .RepoInfo }} 30 + <a 31 + class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group" 32 + hx-boost="true" 33 + href="/{{ .RepoInfo.FullName }}/fork" 34 + > 35 + {{ i "git-fork" "w-4 h-4" }} 36 + fork 37 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 38 + </a> 39 + </div> 40 + </div> 41 + {{ template "repo/fragments/repoDescription" . }} 42 + </section> 43 + 44 + <section 45 + class="w-full flex flex-col" 46 + > 27 47 <nav class="w-full pl-4 overflow-auto"> 28 48 <div class="flex z-60"> 29 49 {{ $activeTabStyles := "-mb-px bg-white dark:bg-gray-800" }} ··· 44 64 {{ if eq $.Active $key }} 45 65 {{ $activeTabStyles }} 46 66 {{ else }} 47 - group-hover:bg-gray-200 dark:group-hover:bg-gray-700 67 + group-hover:bg-gray-100/25 group-hover:dark:bg-gray-700/25 48 68 {{ end }} 49 69 " 50 70 > 51 71 <span class="flex items-center justify-center"> 52 72 {{ i $icon "w-4 h-4 mr-2" }} 53 73 {{ $key }} 54 - {{ if not (isNil $meta) }} 55 - <span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 text-sm ml-1">{{ $meta }}</span> 74 + {{ if $meta }} 75 + <span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 text-sm ml-1">{{ $meta }}</span> 56 76 {{ end }} 57 77 </span> 58 78 </div> ··· 61 81 </div> 62 82 </nav> 63 83 <section 64 - class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white" 84 + class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto dark:text-white" 65 85 > 66 86 {{ block "repoContent" . }}{{ end }} 67 87 </section> 68 88 {{ block "repoAfter" . }}{{ end }} 69 89 </section> 70 90 {{ end }} 71 - 72 - {{ define "layouts/repobase" }} 73 - {{ template "layouts/base" . }} 74 - {{ end }}
-58
appview/pages/templates/layouts/topbar.html
··· 1 - {{ define "layouts/topbar" }} 2 - <nav class="space-x-4 mb-4 px-6 py-2 rounded bg-white dark:bg-gray-800 dark:text-white drop-shadow-sm"> 3 - <div class="container flex justify-between p-0"> 4 - <div id="left-items"> 5 - <a href="/" hx-boost="true" class="flex gap-2 font-semibold italic"> 6 - tangled<sub>alpha</sub> 7 - </a> 8 - </div> 9 - <div class="hidden md:flex gap-4 items-center"> 10 - <a href="https://chat.tangled.sh" class="inline-flex gap-1 items-center"> 11 - {{ i "message-circle" "size-4" }} discord 12 - </a> 13 - 14 - <a href="https://web.libera.chat/#tangled" class="inline-flex gap-1 items-center"> 15 - {{ i "hash" "size-4" }} irc 16 - </a> 17 - 18 - <a href="https://tangled.sh/@tangled.sh/core" class="inline-flex gap-1 items-center"> 19 - {{ i "code" "size-4" }} source 20 - </a> 21 - </div> 22 - <div id="right-items" class="flex gap-2"> 23 - {{ with .LoggedInUser }} 24 - <a href="/repo/new" hx-boost="true"> 25 - {{ i "plus" "w-6 h-6" }} 26 - </a> 27 - {{ block "dropDown" . }} {{ end }} 28 - {{ else }} 29 - <a href="/login">login</a> 30 - {{ end }} 31 - </div> 32 - </div> 33 - </nav> 34 - {{ end }} 35 - 36 - {{ define "dropDown" }} 37 - <details class="relative inline-block text-left"> 38 - <summary 39 - class="cursor-pointer list-none" 40 - > 41 - {{ didOrHandle .Did .Handle }} 42 - </summary> 43 - <div 44 - class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700" 45 - > 46 - <a href="/{{ didOrHandle .Did .Handle }}">profile</a> 47 - <a href="/knots">knots</a> 48 - <a href="/spindles">spindles</a> 49 - <a href="/settings">settings</a> 50 - <a href="#" 51 - hx-post="/logout" 52 - hx-swap="none" 53 - class="text-red-400 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"> 54 - logout 55 - </a> 56 - </div> 57 - </details> 58 - {{ end }}
+11
appview/pages/templates/legal/privacy.html
··· 1 + {{ define "title" }}privacy policy{{ end }} 2 + 3 + {{ define "content" }} 4 + <div class="max-w-4xl mx-auto px-4 py-8"> 5 + <div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-8"> 6 + <div class="prose prose-gray dark:prose-invert max-w-none"> 7 + {{ .Content }} 8 + </div> 9 + </div> 10 + </div> 11 + {{ end }}
+11
appview/pages/templates/legal/terms.html
··· 1 + {{ define "title" }}terms of service{{ end }} 2 + 3 + {{ define "content" }} 4 + <div class="max-w-4xl mx-auto px-4 py-8"> 5 + <div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-8"> 6 + <div class="prose prose-gray dark:prose-invert max-w-none"> 7 + {{ .Content }} 8 + </div> 9 + </div> 10 + </div> 11 + {{ end }}
+20 -6
appview/pages/templates/repo/blob.html
··· 5 5 6 6 {{ $title := printf "%s at %s &middot; %s" .Path .Ref .RepoInfo.FullName }} 7 7 {{ $url := printf "https://tangled.sh/%s/blob/%s/%s" .RepoInfo.FullName .Ref .Path }} 8 - 8 + 9 9 {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 10 - 10 + 11 11 {{ end }} 12 12 13 13 {{ define "repoContent" }} ··· 44 44 <a href="/{{ .RepoInfo.FullName }}/raw/{{ .Ref }}/{{ .Path }}">view raw</a> 45 45 {{ if .RenderToggle }} 46 46 <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span> 47 - <a 48 - href="/{{ .RepoInfo.FullName }}/blob/{{ .Ref }}/{{ .Path }}?code={{ .ShowRendered }}" 47 + <a 48 + href="/{{ .RepoInfo.FullName }}/blob/{{ .Ref }}/{{ .Path }}?code={{ .ShowRendered }}" 49 49 hx-boost="true" 50 50 >view {{ if .ShowRendered }}code{{ else }}rendered{{ end }}</a> 51 51 {{ end }} 52 52 </div> 53 53 </div> 54 54 </div> 55 - {{ if .IsBinary }} 55 + {{ if and .IsBinary .Unsupported }} 56 56 <p class="text-center text-gray-400 dark:text-gray-500"> 57 - This is a binary file and will not be displayed. 57 + Previews are not supported for this file type. 58 58 </p> 59 + {{ else if .IsBinary }} 60 + <div class="text-center"> 61 + {{ if .IsImage }} 62 + <img src="{{ .ContentSrc }}" 63 + alt="{{ .Path }}" 64 + class="max-w-full h-auto mx-auto border border-gray-200 dark:border-gray-700 rounded" /> 65 + {{ else if .IsVideo }} 66 + <video controls class="max-w-full h-auto mx-auto border border-gray-200 dark:border-gray-700 rounded"> 67 + <source src="{{ .ContentSrc }}"> 68 + Your browser does not support the video tag. 69 + </video> 70 + {{ end }} 71 + </div> 59 72 {{ else }} 60 73 <div class="overflow-auto relative"> 61 74 {{ if .ShowRendered }} ··· 65 78 {{ end }} 66 79 </div> 67 80 {{ end }} 81 + {{ template "fragments/multiline-select" }} 68 82 {{ end }}
+2 -2
appview/pages/templates/repo/branches.html
··· 59 59 </td> 60 60 <td class="py-3 whitespace-nowrap text-gray-500 dark:text-gray-400"> 61 61 {{ if .Commit }} 62 - {{ .Commit.Committer.When | timeFmt }} 62 + {{ template "repo/fragments/time" .Commit.Committer.When }} 63 63 {{ end }} 64 64 </td> 65 65 </tr> ··· 98 98 </a> 99 99 </span> 100 100 <div class="inline-block px-1 select-none after:content-['ยท']"></div> 101 - <span>{{ .Commit.Committer.When | timeFmt }}</span> 101 + {{ template "repo/fragments/time" .Commit.Committer.When }} 102 102 </div> 103 103 {{ end }} 104 104 </div>
+44 -7
appview/pages/templates/repo/commit.html
··· 34 34 <a href="mailto:{{ $commit.Author.Email }}" class="no-underline hover:underline text-gray-500 dark:text-gray-300">{{ $commit.Author.Name }}</a> 35 35 {{ end }} 36 36 <span class="px-1 select-none before:content-['\00B7']"></span> 37 - {{ timeFmt $commit.Author.When }} 37 + {{ template "repo/fragments/time" $commit.Author.When }} 38 38 <span class="px-1 select-none before:content-['\00B7']"></span> 39 39 </p> 40 40 ··· 59 59 <div class="flex items-center gap-2 my-2"> 60 60 {{ i "user" "w-4 h-4" }} 61 61 {{ $committerDidOrHandle := index $.EmailToDidOrHandle $commit.Committer.Email }} 62 - <a href="/{{ $committerDidOrHandle }}">{{ $committerDidOrHandle }}</a> 62 + <a href="/{{ $committerDidOrHandle }}">{{ template "user/fragments/picHandleLink" $committerDidOrHandle }}</a> 63 63 </div> 64 64 <div class="my-1 pt-2 text-xs border-t"> 65 65 <div class="text-gray-600 dark:text-gray-300">SSH Key Fingerprint:</div> ··· 71 71 72 72 <div class="text-sm"> 73 73 {{ if $.Pipeline }} 74 - {{ template "repo/pipelines/fragments/pipelineSymbolLong" $.Pipeline }} 74 + {{ template "repo/pipelines/fragments/pipelineSymbolLong" (dict "Pipeline" $.Pipeline "RepoInfo" $.RepoInfo) }} 75 75 {{ end }} 76 76 </div> 77 77 </div> 78 78 79 79 </section> 80 + {{end}} 81 + 82 + {{ define "topbarLayout" }} 83 + <header class="px-1 col-span-full" style="z-index: 20;"> 84 + {{ template "layouts/fragments/topbar" . }} 85 + </header> 86 + {{ end }} 80 87 88 + {{ define "mainLayout" }} 89 + <div class="px-1 col-span-full flex flex-col gap-4"> 90 + {{ block "contentLayout" . }} 91 + {{ block "content" . }}{{ end }} 92 + {{ end }} 93 + 94 + {{ block "contentAfterLayout" . }} 95 + <div class="flex-grow grid grid-cols-1 md:grid-cols-12 gap-4"> 96 + <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 97 + {{ block "contentAfterLeft" . }} {{ end }} 98 + </div> 99 + <main class="col-span-1 md:col-span-10"> 100 + {{ block "contentAfter" . }}{{ end }} 101 + </main> 102 + </div> 103 + {{ end }} 104 + </div> 105 + {{ end }} 106 + 107 + {{ define "footerLayout" }} 108 + <footer class="px-1 col-span-full mt-12"> 109 + {{ template "layouts/fragments/footer" . }} 110 + </footer> 111 + {{ end }} 112 + 113 + {{ define "contentAfter" }} 114 + {{ template "repo/fragments/diff" (list .RepoInfo.FullName .Diff .DiffOpts) }} 81 115 {{end}} 82 116 83 - {{ define "repoAfter" }} 84 - <div class="-z-[9999]"> 85 - {{ template "repo/fragments/diff" (list .RepoInfo.FullName .Diff) }} 86 - </div> 117 + {{ define "contentAfterLeft" }} 118 + <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 119 + {{ template "repo/fragments/diffOpts" .DiffOpts }} 120 + </div> 121 + <div class="sticky top-0 flex-grow max-h-screen overflow-y-auto"> 122 + {{ template "repo/fragments/diffChangedFiles" .Diff }} 123 + </div> 87 124 {{end}}
+42 -2
appview/pages/templates/repo/compare/compare.html
··· 10 10 {{ end }} 11 11 {{ end }} 12 12 13 - {{ define "repoAfter" }} 14 - {{ template "repo/fragments/diff" (list .RepoInfo.FullName .Diff) }} 13 + {{ define "topbarLayout" }} 14 + <header class="px-1 col-span-full" style="z-index: 20;"> 15 + {{ template "layouts/fragments/topbar" . }} 16 + </header> 15 17 {{ end }} 18 + 19 + {{ define "mainLayout" }} 20 + <div class="px-1 col-span-full flex flex-col gap-4"> 21 + {{ block "contentLayout" . }} 22 + {{ block "content" . }}{{ end }} 23 + {{ end }} 24 + 25 + {{ block "contentAfterLayout" . }} 26 + <div class="flex-grow grid grid-cols-1 md:grid-cols-12 gap-4"> 27 + <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 28 + {{ block "contentAfterLeft" . }} {{ end }} 29 + </div> 30 + <main class="col-span-1 md:col-span-10"> 31 + {{ block "contentAfter" . }}{{ end }} 32 + </main> 33 + </div> 34 + {{ end }} 35 + </div> 36 + {{ end }} 37 + 38 + {{ define "footerLayout" }} 39 + <footer class="px-1 col-span-full mt-12"> 40 + {{ template "layouts/fragments/footer" . }} 41 + </footer> 42 + {{ end }} 43 + 44 + {{ define "contentAfter" }} 45 + {{ template "repo/fragments/diff" (list .RepoInfo.FullName .Diff .DiffOpts) }} 46 + {{end}} 47 + 48 + {{ define "contentAfterLeft" }} 49 + <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 50 + {{ template "repo/fragments/diffOpts" .DiffOpts }} 51 + </div> 52 + <div class="sticky top-0 flex-grow max-h-screen overflow-y-auto"> 53 + {{ template "repo/fragments/diffChangedFiles" .Diff }} 54 + </div> 55 + {{end}}
+19 -20
appview/pages/templates/repo/compare/new.html
··· 7 7 {{ end }} 8 8 9 9 {{ define "repoAfter" }} 10 - <section class="p-6 mt-4 rounded-br rounded-bl bg-white dark:bg-gray-800 dark:text-white drop-shadow-sm w-full mx-auto"> 11 - <div class="flex flex-col items-center"> 12 - <p class="text-center text-black dark:text-white"> 13 - Recently updated branches in this repository: 14 - </p> 15 - {{ block "recentBranchList" $ }} {{ end }} 16 - </div> 17 - </section> 18 - {{ end }} 19 - 20 - {{ define "recentBranchList" }} 21 - <div class="mt-4 grid grid-cols-1 divide-y divide-gray-200 dark:divide-gray-700 rounded border border-gray-200 dark:border-gray-700 w-full md:w-1/2"> 22 - {{ range $br := take .Branches 5 }} 23 - <a href="/{{ $.RepoInfo.FullName }}/compare?head={{ $br.Name | urlquery }}" class="no-underline hover:no-underline"> 24 - <div class="flex items-center justify-between p-2"> 25 - {{ $br.Name }} 26 - <time class="text-gray-500 dark:text-gray-400">{{ timeFmt $br.Commit.Committer.When }}</time> 10 + {{ $brs := take .Branches 5 }} 11 + {{ if $brs }} 12 + <section class="p-6 mt-4 rounded-br rounded-bl bg-white dark:bg-gray-800 dark:text-white drop-shadow-sm w-full mx-auto"> 13 + <div class="flex flex-col items-center"> 14 + <p class="text-center text-black dark:text-white"> 15 + Recently updated branches in this repository: 16 + </p> 17 + <div class="mt-4 grid grid-cols-1 divide-y divide-gray-200 dark:divide-gray-700 rounded border border-gray-200 dark:border-gray-700 w-full md:w-1/2"> 18 + {{ range $br := $brs }} 19 + <a href="/{{ $.RepoInfo.FullName }}/compare?head={{ $br.Name | urlquery }}" class="no-underline hover:no-underline"> 20 + <div class="flex items-center justify-between p-2"> 21 + {{ $br.Name }} 22 + <span class="text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" $br.Commit.Committer.When }}</span> 23 + </div> 24 + </a> 25 + {{ end }} 26 + </div> 27 27 </div> 28 - </a> 29 - {{ end }} 30 - </div> 28 + </section> 29 + {{ end }} 31 30 {{ end }}
+19 -9
appview/pages/templates/repo/empty.html
··· 14 14 </p> 15 15 <div class="mt-4 grid grid-cols-1 divide-y divide-gray-200 dark:divide-gray-700 rounded border border-gray-200 dark:border-gray-700 w-full md:w-1/2"> 16 16 {{ range $br := .BranchesTrunc }} 17 - <a href="/{{ $.RepoInfo.FullName }}/tree/{{$br.Name}}" class="no-underline hover:no-underline"> 17 + <a href="/{{ $.RepoInfo.FullName }}/tree/{{$br.Name | urlquery }}" class="no-underline hover:no-underline"> 18 18 <div class="flex items-center justify-between p-2"> 19 19 {{ $br.Name }} 20 - <time class="text-gray-500 dark:text-gray-400">{{ timeFmt $br.Commit.Committer.When }}</time> 20 + <span class="text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" $br.Commit.Committer.When }}</span> 21 21 </div> 22 22 </a> 23 23 {{ end }} 24 24 </div> 25 25 </div> 26 + {{ else if (and .LoggedInUser (eq .LoggedInUser.Did .RepoInfo.OwnerDid)) }} 27 + {{ $knot := .RepoInfo.Knot }} 28 + {{ if eq $knot "knot1.tangled.sh" }} 29 + {{ $knot = "tangled.sh" }} 30 + {{ end }} 31 + <div class="w-full flex place-content-center"> 32 + <div class="py-6 w-fit flex flex-col gap-4"> 33 + <p>This is an empty repository. To get started:</p> 34 + {{ $bullet := "mx-2 text-xs bg-gray-200 dark:bg-gray-600 rounded-full size-5 flex items-center justify-center font-mono inline-flex align-middle" }} 35 + 36 + <p><span class="{{$bullet}}">1</span>First, generate a new <a href="https://git-scm.com/book/en/v2/Git-on-the-Server-Generating-Your-SSH-Public-Key" class="underline">SSH key pair</a>.</p> 37 + <p><span class="{{$bullet}}">2</span>Then add the public key to your account from the <a href="/settings" class="underline">settings</a> page.</p> 38 + <p><span class="{{$bullet}}">3</span>Configure your remote to <code>git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code></p> 39 + <p><span class="{{$bullet}}">4</span>Push!</p> 40 + </div> 41 + </div> 26 42 {{ else }} 27 - <p class="text-center pt-5 text-gray-400 dark:text-gray-500"> 28 - This is an empty repository. Push some commits here. 29 - </p> 43 + <p class="text-gray-400 dark:text-gray-500 py-6 text-center">This is an empty repository.</p> 30 44 {{ end }} 31 45 </main> 32 46 {{ end }} 33 - 34 - {{ define "repoAfter" }} 35 - {{ template "repo/fragments/cloneInstructions" . }} 36 - {{ end }}
+9 -3
appview/pages/templates/repo/fork.html
··· 5 5 <p class="text-xl font-bold dark:text-white">Fork {{ .RepoInfo.FullName }}</p> 6 6 </div> 7 7 <div class="p-6 bg-white dark:bg-gray-800 drop-shadow-sm rounded"> 8 - <form hx-post="/{{ .RepoInfo.FullName }}/fork" class="space-y-12" hx-swap="none"> 8 + <form hx-post="/{{ .RepoInfo.FullName }}/fork" class="space-y-12" hx-swap="none" hx-indicator="#spinner"> 9 9 <fieldset class="space-y-3"> 10 10 <legend class="dark:text-white">Select a knot to fork into</legend> 11 11 <div class="space-y-2"> ··· 19 19 class="mr-2" 20 20 id="domain-{{ . }}" 21 21 /> 22 - <span class="dark:text-white">{{ . }}</span> 22 + <label for="domain-{{ . }}" class="dark:text-white">{{ . }}</label> 23 23 </div> 24 24 {{ else }} 25 25 <p class="dark:text-white">No knots available.</p> ··· 30 30 </fieldset> 31 31 32 32 <div class="space-y-2"> 33 - <button type="submit" class="btn">fork repo</button> 33 + <button type="submit" class="btn-create flex items-center gap-2"> 34 + {{ i "git-fork" "w-4 h-4" }} 35 + fork repo 36 + <span id="spinner" class="group"> 37 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 38 + </span> 39 + </button> 34 40 <div id="repo" class="error"></div> 35 41 </div> 36 42 </form>
+2 -2
appview/pages/templates/repo/fragments/artifact.html
··· 10 10 </div> 11 11 12 12 <div id="right-side" class="text-gray-500 dark:text-gray-400 flex items-center flex-shrink-0 gap-2 text-sm"> 13 - <span title="{{ longTimeFmt .Artifact.CreatedAt }}" class="hidden md:inline">{{ timeFmt .Artifact.CreatedAt }}</span> 14 - <span title="{{ longTimeFmt .Artifact.CreatedAt }}" class=" md:hidden">{{ shortTimeFmt .Artifact.CreatedAt }}</span> 13 + <span class="hidden md:inline">{{ template "repo/fragments/time" .Artifact.CreatedAt }}</span> 14 + <span class=" md:hidden">{{ template "repo/fragments/shortTime" .Artifact.CreatedAt }}</span> 15 15 16 16 <span class="select-none after:content-['ยท'] hidden md:inline"></span> 17 17 <span class="truncate max-w-[100px] hidden md:inline">{{ .Artifact.MimeType }}</span>
+104
appview/pages/templates/repo/fragments/cloneDropdown.html
··· 1 + {{ define "repo/fragments/cloneDropdown" }} 2 + {{ $knot := .RepoInfo.Knot }} 3 + {{ if eq $knot "knot1.tangled.sh" }} 4 + {{ $knot = "tangled.sh" }} 5 + {{ end }} 6 + 7 + <details id="clone-dropdown" class="relative inline-block text-left group"> 8 + <summary class="btn-create cursor-pointer list-none flex items-center gap-2"> 9 + {{ i "download" "w-4 h-4" }} 10 + <span class="hidden md:inline">code</span> 11 + <span class="group-open:hidden"> 12 + {{ i "chevron-down" "w-4 h-4" }} 13 + </span> 14 + <span class="hidden group-open:flex"> 15 + {{ i "chevron-up" "w-4 h-4" }} 16 + </span> 17 + </summary> 18 + 19 + <div class="absolute right-0 mt-2 w-96 bg-white dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700 drop-shadow-sm dark:text-white z-[9999]"> 20 + <div class="p-4"> 21 + <div class="mb-3"> 22 + <h3 class="text-sm font-semibold text-gray-900 dark:text-white mb-2">Clone this repository</h3> 23 + </div> 24 + 25 + <!-- HTTPS Clone --> 26 + <div class="mb-3"> 27 + <label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">HTTPS</label> 28 + <div class="flex items-center border border-gray-300 dark:border-gray-600 rounded"> 29 + <code 30 + class="flex-1 px-3 py-2 text-sm bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-l select-all cursor-pointer whitespace-nowrap overflow-x-auto" 31 + onclick="window.getSelection().selectAllChildren(this)" 32 + data-url="https://tangled.sh/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}" 33 + >https://tangled.sh/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}</code> 34 + <button 35 + onclick="copyToClipboard(this, this.previousElementSibling.getAttribute('data-url'))" 36 + class="px-3 py-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 border-l border-gray-300 dark:border-gray-600" 37 + title="Copy to clipboard" 38 + > 39 + {{ i "copy" "w-4 h-4" }} 40 + </button> 41 + </div> 42 + </div> 43 + 44 + <!-- SSH Clone --> 45 + <div class="mb-3"> 46 + <label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">SSH</label> 47 + <div class="flex items-center border border-gray-300 dark:border-gray-600 rounded"> 48 + <code 49 + class="flex-1 px-3 py-2 text-sm bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-l select-all cursor-pointer whitespace-nowrap overflow-x-auto" 50 + onclick="window.getSelection().selectAllChildren(this)" 51 + data-url="git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}" 52 + >git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code> 53 + <button 54 + onclick="copyToClipboard(this, this.previousElementSibling.getAttribute('data-url'))" 55 + class="px-3 py-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 border-l border-gray-300 dark:border-gray-600" 56 + title="Copy to clipboard" 57 + > 58 + {{ i "copy" "w-4 h-4" }} 59 + </button> 60 + </div> 61 + </div> 62 + 63 + <!-- Note for self-hosted --> 64 + <p class="text-xs text-gray-500 dark:text-gray-400"> 65 + For self-hosted knots, clone URLs may differ based on your setup. 66 + </p> 67 + 68 + <!-- Download Archive --> 69 + <div class="pt-2 mt-2 border-t border-gray-200 dark:border-gray-700"> 70 + <a 71 + href="/{{ .RepoInfo.FullName }}/archive/{{ .Ref | urlquery }}" 72 + class="flex items-center gap-2 px-3 py-2 text-sm" 73 + > 74 + {{ i "download" "w-4 h-4" }} 75 + Download tar.gz 76 + </a> 77 + </div> 78 + 79 + </div> 80 + </div> 81 + </details> 82 + 83 + <script> 84 + function copyToClipboard(button, text) { 85 + navigator.clipboard.writeText(text).then(() => { 86 + const originalContent = button.innerHTML; 87 + button.innerHTML = `{{ i "check" "w-4 h-4" }}`; 88 + setTimeout(() => { 89 + button.innerHTML = originalContent; 90 + }, 2000); 91 + }); 92 + } 93 + 94 + // Close clone dropdown when clicking outside 95 + document.addEventListener('click', function(event) { 96 + const cloneDropdown = document.getElementById('clone-dropdown'); 97 + if (cloneDropdown && cloneDropdown.hasAttribute('open')) { 98 + if (!cloneDropdown.contains(event.target)) { 99 + cloneDropdown.removeAttribute('open'); 100 + } 101 + } 102 + }); 103 + </script> 104 + {{ end }}
-55
appview/pages/templates/repo/fragments/cloneInstructions.html
··· 1 - {{ define "repo/fragments/cloneInstructions" }} 2 - {{ $knot := .RepoInfo.Knot }} 3 - {{ if eq $knot "knot1.tangled.sh" }} 4 - {{ $knot = "tangled.sh" }} 5 - {{ end }} 6 - <section 7 - class="mt-4 p-6 rounded drop-shadow-sm bg-white dark:bg-gray-800 dark:text-white w-full mx-auto overflow-auto flex flex-col gap-4" 8 - > 9 - <div class="flex flex-col gap-2"> 10 - <strong>push</strong> 11 - <div class="md:pl-4 overflow-x-auto whitespace-nowrap"> 12 - <code class="dark:text-gray-100" 13 - >git remote add origin 14 - git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code 15 - > 16 - </div> 17 - </div> 18 - 19 - <div class="flex flex-col gap-2"> 20 - <strong>clone</strong> 21 - <div class="md:pl-4 flex flex-col gap-2"> 22 - <div class="flex items-center gap-3"> 23 - <span 24 - class="bg-gray-100 dark:bg-gray-700 p-1 mr-1 font-mono text-sm rounded select-none dark:text-white" 25 - >HTTP</span 26 - > 27 - <div class="overflow-x-auto whitespace-nowrap flex-1"> 28 - <code class="dark:text-gray-100" 29 - >git clone 30 - https://tangled.sh/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}</code 31 - > 32 - </div> 33 - </div> 34 - 35 - <div class="flex items-center gap-3"> 36 - <span 37 - class="bg-gray-100 dark:bg-gray-700 p-1 mr-1 font-mono text-sm rounded select-none dark:text-white" 38 - >SSH</span 39 - > 40 - <div class="overflow-x-auto whitespace-nowrap flex-1"> 41 - <code class="dark:text-gray-100" 42 - >git clone 43 - git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code 44 - > 45 - </div> 46 - </div> 47 - </div> 48 - </div> 49 - 50 - <p class="py-2 text-gray-500 dark:text-gray-400"> 51 - Note that for self-hosted knots, clone URLs may be different based 52 - on your setup. 53 - </p> 54 - </section> 55 - {{ end }}
+27 -130
appview/pages/templates/repo/fragments/diff.html
··· 1 1 {{ define "repo/fragments/diff" }} 2 - {{ $repo := index . 0 }} 3 - {{ $diff := index . 1 }} 4 - {{ $commit := $diff.Commit }} 5 - {{ $stat := $diff.Stat }} 6 - {{ $fileTree := fileTree $diff.ChangedFiles }} 7 - {{ $diff := $diff.Diff }} 2 + {{ $repo := index . 0 }} 3 + {{ $diff := index . 1 }} 4 + {{ $opts := index . 2 }} 8 5 6 + {{ $commit := $diff.Commit }} 7 + {{ $diff := $diff.Diff }} 8 + {{ $isSplit := $opts.Split }} 9 9 {{ $this := $commit.This }} 10 10 {{ $parent := $commit.Parent }} 11 + {{ $last := sub (len $diff) 1 }} 11 12 12 - <section class="mt-6 p-6 border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm"> 13 - <div class="diff-stat"> 14 - <div class="flex gap-2 items-center"> 15 - <strong class="text-sm uppercase dark:text-gray-200">Changed files</strong> 16 - {{ block "statPill" $stat }} {{ end }} 13 + <div class="flex flex-col gap-4"> 14 + {{ if eq (len $diff) 0 }} 15 + <div class="text-center text-gray-500 dark:text-gray-400 py-8"> 16 + <p>No differences found between the selected revisions.</p> 17 17 </div> 18 - {{ block "fileTree" $fileTree }} {{ end }} 19 - </div> 20 - </section> 21 - 22 - {{ $last := sub (len $diff) 1 }} 23 - {{ range $idx, $hunk := $diff }} 24 - {{ with $hunk }} 25 - <section class="mt-6 border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm"> 26 - <div id="file-{{ .Name.New }}"> 27 - <div id="diff-file"> 28 - <details open> 18 + {{ else }} 19 + {{ range $idx, $hunk := $diff }} 20 + {{ with $hunk }} 21 + <details open id="file-{{ .Name.New }}" class="group border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm" tabindex="{{ add $idx 1 }}"> 29 22 <summary class="list-none cursor-pointer sticky top-0"> 30 23 <div id="diff-file-header" class="rounded cursor-pointer bg-white dark:bg-gray-800 flex justify-between"> 31 24 <div id="left-side-items" class="p-2 flex gap-2 items-center overflow-x-auto"> 32 - <div class="flex gap-1 items-center"> 33 - {{ $markerstyle := "diff-type p-1 mr-1 font-mono text-sm rounded select-none" }} 34 - {{ if .IsNew }} 35 - <span class="bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400 {{ $markerstyle }}">ADDED</span> 36 - {{ else if .IsDelete }} 37 - <span class="bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400 {{ $markerstyle }}">DELETED</span> 38 - {{ else if .IsCopy }} 39 - <span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">COPIED</span> 40 - {{ else if .IsRename }} 41 - <span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">RENAMED</span> 42 - {{ else }} 43 - <span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">MODIFIED</span> 44 - {{ end }} 45 - 46 - {{ block "statPill" .Stats }} {{ end }} 47 - </div> 25 + <span class="group-open:hidden inline">{{ i "chevron-right" "w-4 h-4" }}</span> 26 + <span class="hidden group-open:inline">{{ i "chevron-down" "w-4 h-4" }}</span> 27 + {{ template "repo/fragments/diffStatPill" .Stats }} 48 28 49 29 <div class="flex gap-2 items-center overflow-x-auto"> 50 30 {{ if .IsDelete }} 51 - <a class="dark:text-white whitespace-nowrap overflow-x-auto" {{if $this }}href="/{{ $repo }}/blob/{{ $this }}/{{ .Name.Old }}"{{end}}> 52 31 {{ .Name.Old }} 53 - </a> 54 32 {{ else if (or .IsCopy .IsRename) }} 55 - <a class="dark:text-white whitespace-nowrap overflow-x-auto" {{if $parent}}href="/{{ $repo }}/blob/{{ $parent }}/{{ .Name.Old }}"{{end}}> 56 - {{ .Name.Old }} 57 - </a> 58 - {{ i "arrow-right" "w-4 h-4" }} 59 - <a class="dark:text-white whitespace-nowrap overflow-x-auto" {{if $this}}href="/{{ $repo }}/blob/{{ $this }}/{{ .Name.New }}"{{end}}> 60 - {{ .Name.New }} 61 - </a> 33 + {{ .Name.Old }} {{ i "arrow-right" "w-4 h-4" }} {{ .Name.New }} 62 34 {{ else }} 63 - <a class="dark:text-white whitespace-nowrap overflow-x-auto" {{if $this}}href="/{{ $repo }}/blob/{{ $this }}/{{ .Name.New }}"{{end}}> 64 35 {{ .Name.New }} 65 - </a> 66 36 {{ end }} 67 37 </div> 68 38 </div> 69 - 70 - {{ $iconstyle := "p-1 mx-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded" }} 71 - <div id="right-side-items" class="p-2 flex items-center"> 72 - <a title="top of file" href="#file-{{ .Name.New }}" class="{{ $iconstyle }}">{{ i "arrow-up-to-line" "w-4 h-4" }}</a> 73 - {{ if gt $idx 0 }} 74 - {{ $prev := index $diff (sub $idx 1) }} 75 - <a title="previous file" href="#file-{{ $prev.Name.New }}" class="{{ $iconstyle }}">{{ i "arrow-up" "w-4 h-4" }}</a> 76 - {{ end }} 77 - 78 - {{ if lt $idx $last }} 79 - {{ $next := index $diff (add $idx 1) }} 80 - <a title="next file" href="#file-{{ $next.Name.New }}" class="{{ $iconstyle }}">{{ i "arrow-down" "w-4 h-4" }}</a> 81 - {{ end }} 82 - </div> 83 - 84 39 </div> 85 40 </summary> 86 41 87 42 <div class="transition-all duration-700 ease-in-out"> 88 - {{ if .IsDelete }} 89 - <p class="text-center text-gray-400 dark:text-gray-500 p-4"> 90 - This file has been deleted. 91 - </p> 92 - {{ else if .IsCopy }} 93 - <p class="text-center text-gray-400 dark:text-gray-500 p-4"> 94 - This file has been copied. 95 - </p> 96 - {{ else if .IsBinary }} 43 + {{ if .IsBinary }} 97 44 <p class="text-center text-gray-400 dark:text-gray-500 p-4"> 98 45 This is a binary file and will not be displayed. 99 46 </p> 100 47 {{ else }} 101 - {{ $name := .Name.New }} 102 - <pre class="overflow-x-auto"><div class="overflow-x-auto"><div class="min-w-full inline-block">{{- range .TextFragments -}}<div class="bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">&middot;&middot;&middot;</div> 103 - {{- $oldStart := .OldPosition -}} 104 - {{- $newStart := .NewPosition -}} 105 - {{- $lineNrStyle := "min-w-[3.5rem] flex-shrink-0 select-none text-right bg-white dark:bg-gray-800 scroll-mt-10 target:border target:border-amber-500 target:rounded " -}} 106 - {{- $linkStyle := "text-gray-400 dark:text-gray-500 hover:underline" -}} 107 - {{- $lineNrSepStyle1 := "" -}} 108 - {{- $lineNrSepStyle2 := "pr-2" -}} 109 - {{- range .Lines -}} 110 - {{- if eq .Op.String "+" -}} 111 - <div class="bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400 flex min-w-full items-center"> 112 - <div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><span aria-hidden="true" class="invisible">{{$newStart}}</span></div> 113 - <div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}" id="{{$name}}-N{{$newStart}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{$newStart}}">{{ $newStart }}</a></div> 114 - <div class="w-5 flex-shrink-0 select-none text-center">{{ .Op.String }}</div> 115 - <div class="px-2">{{ .Line }}</div> 116 - </div> 117 - {{- $newStart = add64 $newStart 1 -}} 118 - {{- end -}} 119 - {{- if eq .Op.String "-" -}} 120 - <div class="bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400 flex min-w-full items-center"> 121 - <div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}" id="{{$name}}-O{{$oldStart}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}">{{ $oldStart }}</a></div> 122 - <div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><span aria-hidden="true" class="invisible">{{$oldStart}}</span></div> 123 - <div class="w-5 flex-shrink-0 select-none text-center">{{ .Op.String }}</div> 124 - <div class="px-2">{{ .Line }}</div> 125 - </div> 126 - {{- $oldStart = add64 $oldStart 1 -}} 127 - {{- end -}} 128 - {{- if eq .Op.String " " -}} 129 - <div class="bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400 flex min-w-full items-center"> 130 - <div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}" id="{{$name}}-O{{$oldStart}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}">{{ $oldStart }}</a></div> 131 - <div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}" id="{{$name}}-N{{$newStart}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{$newStart}}">{{ $newStart }}</a></div> 132 - <div class="w-5 flex-shrink-0 select-none text-center">{{ .Op.String }}</div> 133 - <div class="px-2">{{ .Line }}</div> 134 - </div> 135 - {{- $newStart = add64 $newStart 1 -}} 136 - {{- $oldStart = add64 $oldStart 1 -}} 137 - {{- end -}} 138 - {{- end -}} 139 - {{- end -}}</div></div></pre> 48 + {{ if $isSplit }} 49 + {{- template "repo/fragments/splitDiff" .Split -}} 50 + {{ else }} 51 + {{- template "repo/fragments/unifiedDiff" . -}} 52 + {{ end }} 140 53 {{- end -}} 141 54 </div> 142 - 143 55 </details> 144 - 145 - </div> 146 - </div> 147 - </section> 148 - {{ end }} 149 - {{ end }} 150 - {{ end }} 151 - 152 - {{ define "statPill" }} 153 - <div class="flex items-center font-mono text-sm"> 154 - {{ if and .Insertions .Deletions }} 155 - <span class="rounded-l p-1 select-none bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400">+{{ .Insertions }}</span> 156 - <span class="rounded-r p-1 select-none bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400">-{{ .Deletions }}</span> 157 - {{ else if .Insertions }} 158 - <span class="rounded p-1 select-none bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400">+{{ .Insertions }}</span> 159 - {{ else if .Deletions }} 160 - <span class="rounded p-1 select-none bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400">-{{ .Deletions }}</span> 56 + {{ end }} 57 + {{ end }} 161 58 {{ end }} 162 59 </div> 163 60 {{ end }}
+13
appview/pages/templates/repo/fragments/diffChangedFiles.html
··· 1 + {{ define "repo/fragments/diffChangedFiles" }} 2 + {{ $stat := .Stat }} 3 + {{ $fileTree := fileTree .ChangedFiles }} 4 + <section class="overflow-x-auto text-sm px-6 py-2 border border-gray-200 dark:border-gray-700 w-full mx-auto min-h-full rounded bg-white dark:bg-gray-800 drop-shadow-sm"> 5 + <div class="diff-stat"> 6 + <div class="flex gap-2 items-center"> 7 + <strong class="text-sm uppercase dark:text-gray-200">Changed files</strong> 8 + {{ template "repo/fragments/diffStatPill" $stat }} 9 + </div> 10 + {{ template "repo/fragments/fileTree" $fileTree }} 11 + </div> 12 + </section> 13 + {{ end }}
+28
appview/pages/templates/repo/fragments/diffOpts.html
··· 1 + {{ define "repo/fragments/diffOpts" }} 2 + <section class="flex flex-col gap-2 overflow-x-auto text-sm px-6 py-2 border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm"> 3 + <strong class="text-sm uppercase dark:text-gray-200">options</strong> 4 + {{ $active := "unified" }} 5 + {{ if .Split }} 6 + {{ $active = "split" }} 7 + {{ end }} 8 + {{ $values := list "unified" "split" }} 9 + {{ block "tabSelector" (dict "Name" "diff" "Values" $values "Active" $active) }} {{ end }} 10 + </section> 11 + {{ end }} 12 + 13 + {{ define "tabSelector" }} 14 + {{ $name := .Name }} 15 + {{ $all := .Values }} 16 + {{ $active := .Active }} 17 + <div class="flex justify-between divide-x divide-gray-200 dark:divide-gray-700 rounded border border-gray-200 dark:border-gray-700 overflow-hidden"> 18 + {{ $activeTab := "bg-white dark:bg-gray-700 shadow-sm" }} 19 + {{ $inactiveTab := "bg-gray-100 dark:bg-gray-800 shadow-inner" }} 20 + {{ range $index, $value := $all }} 21 + {{ $isActive := eq $value $active }} 22 + <a href="?{{ $name }}={{ $value }}" 23 + class="py-2 text-sm w-full block hover:no-underline text-center {{ if $isActive }} {{$activeTab }} {{ else }} {{ $inactiveTab }} {{ end }}"> 24 + {{ $value }} 25 + </a> 26 + {{ end }} 27 + </div> 28 + {{ end }}
+13
appview/pages/templates/repo/fragments/diffStatPill.html
··· 1 + {{ define "repo/fragments/diffStatPill" }} 2 + <div class="flex items-center font-mono text-sm"> 3 + {{ if and .Insertions .Deletions }} 4 + <span class="rounded-l p-1 select-none bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400">+{{ .Insertions }}</span> 5 + <span class="rounded-r p-1 select-none bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400">-{{ .Deletions }}</span> 6 + {{ else if .Insertions }} 7 + <span class="rounded p-1 select-none bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400">+{{ .Insertions }}</span> 8 + {{ else if .Deletions }} 9 + <span class="rounded p-1 select-none bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400">-{{ .Deletions }}</span> 10 + {{ end }} 11 + </div> 12 + {{ end }} 13 +
+4
appview/pages/templates/repo/fragments/duration.html
··· 1 + {{ define "repo/fragments/duration" }} 2 + <time datetime="{{ . | iso8601DurationFmt }}" title="{{ . | longDurationFmt }}">{{ . | durationFmt }}</time> 3 + {{ end }} 4 +
+27
appview/pages/templates/repo/fragments/fileTree.html
··· 1 + {{ define "repo/fragments/fileTree" }} 2 + {{ if and .Name .IsDirectory }} 3 + <details open> 4 + <summary class="cursor-pointer list-none pt-1"> 5 + <span class="tree-directory inline-flex items-center gap-2 "> 6 + {{ i "folder" "flex-shrink-0 size-4 fill-current" }} 7 + <span class="filename truncate text-black dark:text-white">{{ .Name }}</span> 8 + </span> 9 + </summary> 10 + <div class="ml-1 pl-2 border-l border-gray-200 dark:border-gray-700"> 11 + {{ range $child := .Children }} 12 + {{ template "repo/fragments/fileTree" $child }} 13 + {{ end }} 14 + </div> 15 + </details> 16 + {{ else if .Name }} 17 + <div class="tree-file flex items-center gap-2 pt-1"> 18 + {{ i "file" "flex-shrink-0 size-4" }} 19 + <a href="#file-{{ .Path }}" class="filename truncate text-black dark:text-white no-underline hover:underline">{{ .Name }}</a> 20 + </div> 21 + {{ else }} 22 + {{ range $child := .Children }} 23 + {{ template "repo/fragments/fileTree" $child }} 24 + {{ end }} 25 + {{ end }} 26 + {{ end }} 27 +
-27
appview/pages/templates/repo/fragments/filetree.html
··· 1 - {{ define "fileTree" }} 2 - {{ if and .Name .IsDirectory }} 3 - <details open> 4 - <summary class="cursor-pointer list-none pt-1"> 5 - <span class="tree-directory inline-flex items-center gap-2 "> 6 - {{ i "folder" "size-4 fill-current" }} 7 - <span class="filename text-black dark:text-white">{{ .Name }}</span> 8 - </span> 9 - </summary> 10 - <div class="ml-1 pl-4 border-l border-gray-200 dark:border-gray-700"> 11 - {{ range $child := .Children }} 12 - {{ block "fileTree" $child }} {{ end }} 13 - {{ end }} 14 - </div> 15 - </details> 16 - {{ else if .Name }} 17 - <div class="tree-file flex items-center gap-2 pt-1"> 18 - {{ i "file" "size-4" }} 19 - <a href="#file-{{ .Path }}" class="filename text-black dark:text-white no-underline hover:underline">{{ .Name }}</a> 20 - </div> 21 - {{ else }} 22 - {{ range $child := .Children }} 23 - {{ block "fileTree" $child }} {{ end }} 24 - {{ end }} 25 - {{ end }} 26 - {{ end }} 27 -
+49 -125
appview/pages/templates/repo/fragments/interdiff.html
··· 1 1 {{ define "repo/fragments/interdiff" }} 2 2 {{ $repo := index . 0 }} 3 3 {{ $x := index . 1 }} 4 + {{ $opts := index . 2 }} 4 5 {{ $fileTree := fileTree $x.AffectedFiles }} 5 6 {{ $diff := $x.Files }} 6 - 7 - <section class="mt-6 p-6 border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm"> 8 - <div class="diff-stat"> 9 - <div class="flex gap-2 items-center"> 10 - <strong class="text-sm uppercase dark:text-gray-200">files</strong> 11 - </div> 12 - {{ block "fileTree" $fileTree }} {{ end }} 13 - </div> 14 - </section> 7 + {{ $last := sub (len $diff) 1 }} 8 + {{ $isSplit := $opts.Split }} 15 9 16 - {{ $last := sub (len $diff) 1 }} 10 + <div class="flex flex-col gap-4"> 17 11 {{ range $idx, $hunk := $diff }} 18 - {{ with $hunk }} 19 - <section class="mt-6 border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm"> 20 - <div id="file-{{ .Name }}"> 21 - <div id="diff-file"> 22 - <details {{ if not (.Status.IsOnlyInOne) }}open{{end}}> 23 - <summary class="list-none cursor-pointer sticky top-0"> 24 - <div id="diff-file-header" class="rounded cursor-pointer bg-white dark:bg-gray-800 flex justify-between"> 25 - <div id="left-side-items" class="p-2 flex gap-2 items-center overflow-x-auto"> 26 - <div class="flex gap-1 items-center" style="direction: ltr;"> 27 - {{ $markerstyle := "diff-type p-1 mr-1 font-mono text-sm rounded select-none" }} 28 - {{ if .Status.IsOk }} 29 - <span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">CHANGED</span> 30 - {{ else if .Status.IsUnchanged }} 31 - <span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">UNCHANGED</span> 32 - {{ else if .Status.IsOnlyInOne }} 33 - <span class="bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400 {{ $markerstyle }}">REVERTED</span> 34 - {{ else if .Status.IsOnlyInTwo }} 35 - <span class="bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400 {{ $markerstyle }}">NEW</span> 36 - {{ else if .Status.IsRebased }} 37 - <span class="bg-amber-100 text-amber-700 dark:bg-amber-800/50 dark:text-amber-400 {{ $markerstyle }}">REBASED</span> 38 - {{ else }} 39 - <span class="bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400 {{ $markerstyle }}">ERROR</span> 40 - {{ end }} 41 - </div> 42 - 43 - <div class="flex gap-2 items-center overflow-x-auto" style="direction: rtl;"> 44 - <a class="dark:text-white whitespace-nowrap overflow-x-auto" href=""> 45 - {{ .Name }} 46 - </a> 47 - </div> 48 - </div> 49 - 50 - {{ $iconstyle := "p-1 mx-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded" }} 51 - <div id="right-side-items" class="p-2 flex items-center"> 52 - <a title="top of file" href="#file-{{ .Name }}" class="{{ $iconstyle }}">{{ i "arrow-up-to-line" "w-4 h-4" }}</a> 53 - {{ if gt $idx 0 }} 54 - {{ $prev := index $diff (sub $idx 1) }} 55 - <a title="previous file" href="#file-{{ $prev.Name }}" class="{{ $iconstyle }}">{{ i "arrow-up" "w-4 h-4" }}</a> 56 - {{ end }} 57 - 58 - {{ if lt $idx $last }} 59 - {{ $next := index $diff (add $idx 1) }} 60 - <a title="next file" href="#file-{{ $next.Name }}" class="{{ $iconstyle }}">{{ i "arrow-down" "w-4 h-4" }}</a> 12 + {{ with $hunk }} 13 + <details {{ if not (.Status.IsOnlyInOne) }}open{{end}} id="file-{{ .Name }}" class="border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm"> 14 + <summary class="list-none cursor-pointer sticky top-0"> 15 + <div id="diff-file-header" class="rounded cursor-pointer bg-white dark:bg-gray-800 flex justify-between"> 16 + <div id="left-side-items" class="p-2 flex gap-2 items-center overflow-x-auto"> 17 + <div class="flex gap-1 items-center" style="direction: ltr;"> 18 + {{ $markerstyle := "diff-type p-1 mr-1 font-mono text-sm rounded select-none" }} 19 + {{ if .Status.IsOk }} 20 + <span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">CHANGED</span> 21 + {{ else if .Status.IsUnchanged }} 22 + <span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">UNCHANGED</span> 23 + {{ else if .Status.IsOnlyInOne }} 24 + <span class="bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400 {{ $markerstyle }}">REVERTED</span> 25 + {{ else if .Status.IsOnlyInTwo }} 26 + <span class="bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400 {{ $markerstyle }}">NEW</span> 27 + {{ else if .Status.IsRebased }} 28 + <span class="bg-amber-100 text-amber-700 dark:bg-amber-800/50 dark:text-amber-400 {{ $markerstyle }}">REBASED</span> 29 + {{ else }} 30 + <span class="bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400 {{ $markerstyle }}">ERROR</span> 61 31 {{ end }} 62 32 </div> 63 33 34 + <div class="flex gap-2 items-center overflow-x-auto" style="direction: rtl;">{{ .Name }}</div> 64 35 </div> 65 - </summary> 66 36 67 - <div class="transition-all duration-700 ease-in-out"> 68 - {{ if .Status.IsUnchanged }} 69 - <p class="text-center text-gray-400 dark:text-gray-500 p-4"> 70 - This file has not been changed. 71 - </p> 72 - {{ else if .Status.IsRebased }} 73 - <p class="text-center text-gray-400 dark:text-gray-500 p-4"> 74 - This patch was likely rebased, as context lines do not match. 75 - </p> 76 - {{ else if .Status.IsError }} 77 - <p class="text-center text-gray-400 dark:text-gray-500 p-4"> 78 - Failed to calculate interdiff for this file. 79 - </p> 80 - {{ else }} 81 - {{ $name := .Name }} 82 - <pre class="overflow-x-auto"><div class="overflow-x-auto"><div class="min-w-full inline-block">{{- range .TextFragments -}}<div class="bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">&middot;&middot;&middot;</div> 83 - {{- $oldStart := .OldPosition -}} 84 - {{- $newStart := .NewPosition -}} 85 - {{- $lineNrStyle := "min-w-[3.5rem] flex-shrink-0 select-none text-right bg-white dark:bg-gray-800 scroll-mt-10 target:border target:border-amber-500 target:rounded " -}} 86 - {{- $linkStyle := "text-gray-400 dark:text-gray-500 hover:underline" -}} 87 - {{- $lineNrSepStyle1 := "" -}} 88 - {{- $lineNrSepStyle2 := "pr-2" -}} 89 - {{- range .Lines -}} 90 - {{- if eq .Op.String "+" -}} 91 - <div class="bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400 flex min-w-full items-center"> 92 - <div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><span aria-hidden="true" class="invisible">{{$newStart}}</span></div> 93 - <div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}" id="{{$name}}-N{{$newStart}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{$newStart}}">{{ $newStart }}</a></div> 94 - <div class="w-5 flex-shrink-0 select-none text-center">{{ .Op.String }}</div> 95 - <div class="px-2">{{ .Line }}</div> 96 - </div> 97 - {{- $newStart = add64 $newStart 1 -}} 98 - {{- end -}} 99 - {{- if eq .Op.String "-" -}} 100 - <div class="bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400 flex min-w-full items-center"> 101 - <div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}" id="{{$name}}-O{{$oldStart}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}">{{ $oldStart }}</a></div> 102 - <div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><span aria-hidden="true" class="invisible">{{$oldStart}}</span></div> 103 - <div class="w-5 flex-shrink-0 select-none text-center">{{ .Op.String }}</div> 104 - <div class="px-2">{{ .Line }}</div> 105 - </div> 106 - {{- $oldStart = add64 $oldStart 1 -}} 107 - {{- end -}} 108 - {{- if eq .Op.String " " -}} 109 - <div class="bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400 flex min-w-full items-center"> 110 - <div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}" id="{{$name}}-O{{$oldStart}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}">{{ $oldStart }}</a></div> 111 - <div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}" id="{{$name}}-N{{$newStart}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{$newStart}}">{{ $newStart }}</a></div> 112 - <div class="w-5 flex-shrink-0 select-none text-center">{{ .Op.String }}</div> 113 - <div class="px-2">{{ .Line }}</div> 114 - </div> 115 - {{- $newStart = add64 $newStart 1 -}} 116 - {{- $oldStart = add64 $oldStart 1 -}} 117 - {{- end -}} 118 - {{- end -}} 119 - {{- end -}}</div></div></pre> 120 - {{- end -}} 121 37 </div> 38 + </summary> 122 39 123 - </details> 40 + <div class="transition-all duration-700 ease-in-out"> 41 + {{ if .Status.IsUnchanged }} 42 + <p class="text-center text-gray-400 dark:text-gray-500 p-4"> 43 + This file has not been changed. 44 + </p> 45 + {{ else if .Status.IsRebased }} 46 + <p class="text-center text-gray-400 dark:text-gray-500 p-4"> 47 + This patch was likely rebased, as context lines do not match. 48 + </p> 49 + {{ else if .Status.IsError }} 50 + <p class="text-center text-gray-400 dark:text-gray-500 p-4"> 51 + Failed to calculate interdiff for this file. 52 + </p> 53 + {{ else }} 54 + {{ if $isSplit }} 55 + {{- template "repo/fragments/splitDiff" .Split -}} 56 + {{ else }} 57 + {{- template "repo/fragments/unifiedDiff" . -}} 58 + {{ end }} 59 + {{- end -}} 60 + </div> 124 61 125 - </div> 126 - </div> 127 - </section> 62 + </details> 63 + {{ end }} 128 64 {{ end }} 129 - {{ end }} 65 + </div> 130 66 {{ end }} 131 67 132 - {{ define "statPill" }} 133 - <div class="flex items-center font-mono text-sm"> 134 - {{ if and .Insertions .Deletions }} 135 - <span class="rounded-l p-1 select-none bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400">+{{ .Insertions }}</span> 136 - <span class="rounded-r p-1 select-none bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400">-{{ .Deletions }}</span> 137 - {{ else if .Insertions }} 138 - <span class="rounded p-1 select-none bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400">+{{ .Insertions }}</span> 139 - {{ else if .Deletions }} 140 - <span class="rounded p-1 select-none bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400">-{{ .Deletions }}</span> 141 - {{ end }} 142 - </div> 143 - {{ end }}
+11
appview/pages/templates/repo/fragments/interdiffFiles.html
··· 1 + {{ define "repo/fragments/interdiffFiles" }} 2 + {{ $fileTree := fileTree .AffectedFiles }} 3 + <section class="px-6 py-2 border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm min-h-full text-sm"> 4 + <div class="diff-stat"> 5 + <div class="flex gap-2 items-center"> 6 + <strong class="text-sm uppercase dark:text-gray-200">files</strong> 7 + </div> 8 + {{ template "repo/fragments/fileTree" $fileTree }} 9 + </div> 10 + </section> 11 + {{ end }}
+6
appview/pages/templates/repo/fragments/languageBall.html
··· 1 + {{ define "repo/fragments/languageBall" }} 2 + <div 3 + class="size-2 rounded-full" 4 + style="background: radial-gradient(circle at 35% 35%, color-mix(in srgb, {{ langColor . }} 70%, white), {{ langColor . }} 30%, color-mix(in srgb, {{ langColor . }} 85%, black));" 5 + ></div> 6 + {{ end }}
+34
appview/pages/templates/repo/fragments/reaction.html
··· 1 + {{ define "repo/fragments/reaction" }} 2 + <button 3 + id="reactIndi-{{ .Kind }}" 4 + class="flex justify-center items-center min-w-8 min-h-8 rounded border 5 + leading-4 px-3 gap-1 6 + {{ if eq .Count 0 }} 7 + hidden 8 + {{ end }} 9 + {{ if .IsReacted }} 10 + bg-sky-100 11 + border-sky-400 12 + dark:bg-sky-900 13 + dark:border-sky-500 14 + {{ else }} 15 + border-gray-200 16 + hover:bg-gray-50 17 + hover:border-gray-300 18 + dark:border-gray-700 19 + dark:hover:bg-gray-700 20 + dark:hover:border-gray-600 21 + {{ end }} 22 + " 23 + {{ if .IsReacted }} 24 + hx-delete="/react?subject={{ .ThreadAt }}&kind={{ .Kind }}" 25 + {{ else }} 26 + hx-post="/react?subject={{ .ThreadAt }}&kind={{ .Kind }}" 27 + {{ end }} 28 + hx-swap="outerHTML" 29 + hx-trigger="click from:(#reactBtn-{{ .Kind }}, #reactIndi-{{ .Kind }})" 30 + hx-disabled-elt="this" 31 + > 32 + <span>{{ .Kind }}</span> <span>{{ .Count }}</span> 33 + </button> 34 + {{ end }}
+30
appview/pages/templates/repo/fragments/reactionsPopUp.html
··· 1 + {{ define "repo/fragments/reactionsPopUp" }} 2 + <details 3 + id="reactionsPopUp" 4 + class="relative inline-block" 5 + > 6 + <summary 7 + class="flex justify-center items-center min-w-8 min-h-8 rounded border border-gray-200 dark:border-gray-700 8 + hover:bg-gray-50 9 + hover:border-gray-300 10 + dark:hover:bg-gray-700 11 + dark:hover:border-gray-600 12 + cursor-pointer list-none" 13 + > 14 + {{ i "smile" "size-4" }} 15 + </summary> 16 + <div 17 + class="absolute flex left-0 z-10 mt-4 rounded bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700 shadow-lg" 18 + > 19 + {{ range $kind := . }} 20 + <button 21 + id="reactBtn-{{ $kind }}" 22 + class="size-12 hover:bg-gray-100 dark:hover:bg-gray-700" 23 + hx-on:click="this.parentElement.parentElement.removeAttribute('open')" 24 + > 25 + {{ $kind }} 26 + </button> 27 + {{ end }} 28 + </div> 29 + </details> 30 + {{ end }}
-48
appview/pages/templates/repo/fragments/repoActions.html
··· 1 - {{ define "repo/fragments/repoActions" }} 2 - <div class="flex items-center gap-2 z-auto"> 3 - <button 4 - id="starBtn" 5 - class="btn disabled:opacity-50 disabled:cursor-not-allowed flex gap-2 items-center group" 6 - {{ if .IsStarred }} 7 - hx-delete="/star?subject={{ .RepoAt }}&countHint={{ .Stats.StarCount }}" 8 - {{ else }} 9 - hx-post="/star?subject={{ .RepoAt }}&countHint={{ .Stats.StarCount }}" 10 - {{ end }} 11 - 12 - hx-trigger="click" 13 - hx-target="#starBtn" 14 - hx-swap="outerHTML" 15 - hx-disabled-elt="#starBtn" 16 - > 17 - {{ if .IsStarred }} 18 - {{ i "star" "w-4 h-4 fill-current" }} 19 - {{ else }} 20 - {{ i "star" "w-4 h-4" }} 21 - {{ end }} 22 - <span class="text-sm"> 23 - {{ .Stats.StarCount }} 24 - </span> 25 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 26 - </button> 27 - {{ if .DisableFork }} 28 - <button 29 - class="btn text-sm no-underline hover:no-underline flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed" 30 - disabled 31 - title="Empty repositories cannot be forked" 32 - > 33 - {{ i "git-fork" "w-4 h-4" }} 34 - fork 35 - </button> 36 - {{ else }} 37 - <a 38 - class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group" 39 - hx-boost="true" 40 - href="/{{ .FullName }}/fork" 41 - > 42 - {{ i "git-fork" "w-4 h-4" }} 43 - fork 44 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 45 - </a> 46 - {{ end }} 47 - </div> 48 - {{ end }}
+1 -1
appview/pages/templates/repo/fragments/repoDescription.html
··· 1 1 {{ define "repo/fragments/repoDescription" }} 2 2 <span id="repo-description" class="flex flex-wrap items-center gap-2 text-sm" hx-target="this" hx-swap="outerHTML"> 3 3 {{ if .RepoInfo.Description }} 4 - {{ .RepoInfo.Description }} 4 + {{ .RepoInfo.Description | description }} 5 5 {{ else }} 6 6 <span class="italic">this repo has no description</span> 7 7 {{ end }}
+26
appview/pages/templates/repo/fragments/repoStar.html
··· 1 + {{ define "repo/fragments/repoStar" }} 2 + <button 3 + id="starBtn" 4 + class="btn disabled:opacity-50 disabled:cursor-not-allowed flex gap-2 items-center group" 5 + {{ if .IsStarred }} 6 + hx-delete="/star?subject={{ .RepoAt }}&countHint={{ .Stats.StarCount }}" 7 + {{ else }} 8 + hx-post="/star?subject={{ .RepoAt }}&countHint={{ .Stats.StarCount }}" 9 + {{ end }} 10 + 11 + hx-trigger="click" 12 + hx-target="this" 13 + hx-swap="outerHTML" 14 + hx-disabled-elt="#starBtn" 15 + > 16 + {{ if .IsStarred }} 17 + {{ i "star" "w-4 h-4 fill-current" }} 18 + {{ else }} 19 + {{ i "star" "w-4 h-4" }} 20 + {{ end }} 21 + <span class="text-sm"> 22 + {{ .Stats.StarCount }} 23 + </span> 24 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 25 + </button> 26 + {{ end }}
+4
appview/pages/templates/repo/fragments/shortTime.html
··· 1 + {{ define "repo/fragments/shortTime" }} 2 + {{ template "repo/fragments/timeWrapper" (dict "Time" . "Content" (. | shortRelTimeFmt)) }} 3 + {{ end }} 4 +
+9
appview/pages/templates/repo/fragments/shortTimeAgo.html
··· 1 + {{ define "repo/fragments/shortTimeAgo" }} 2 + {{ $formatted := shortRelTimeFmt . }} 3 + {{ $content := printf "%s ago" $formatted }} 4 + {{ if eq $formatted "now" }} 5 + {{ $content = "now" }} 6 + {{ end }} 7 + {{ template "repo/fragments/timeWrapper" (dict "Time" . "Content" $content) }} 8 + {{ end }} 9 +
+61
appview/pages/templates/repo/fragments/splitDiff.html
··· 1 + {{ define "repo/fragments/splitDiff" }} 2 + {{ $name := .Id }} 3 + {{- $lineNrStyle := "min-w-[3.5rem] flex-shrink-0 select-none text-right bg-white dark:bg-gray-800" -}} 4 + {{- $linkStyle := "text-gray-400 dark:text-gray-500 hover:underline" -}} 5 + {{- $lineNrSepStyle := "pr-2 border-r border-gray-200 dark:border-gray-700" -}} 6 + {{- $containerStyle := "flex min-w-full items-center target:border target:rounded-sm target:border-yellow-200 target:dark:border-yellow-700 scroll-mt-20" -}} 7 + {{- $emptyStyle := "bg-gray-200/30 dark:bg-gray-700/30" -}} 8 + {{- $addStyle := "bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400" -}} 9 + {{- $delStyle := "bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400 " -}} 10 + {{- $ctxStyle := "bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400" -}} 11 + {{- $opStyle := "w-5 flex-shrink-0 select-none text-center" -}} 12 + <div class="grid grid-cols-2 divide-x divide-gray-200 dark:divide-gray-700"> 13 + <pre class="overflow-x-auto col-span-1"><div class="overflow-x-auto"><div class="min-w-full inline-block">{{- range .TextFragments -}}<div class="bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">&middot;&middot;&middot;</div> 14 + {{- range .LeftLines -}} 15 + {{- if .IsEmpty -}} 16 + <div class="{{ $emptyStyle }} {{ $containerStyle }}"> 17 + <div class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><span aria-hidden="true" class="invisible">{{.LineNumber}}</span></div> 18 + <div class="{{ $opStyle }}"><span aria-hidden="true" class="invisible">{{ .Op.String }}</span></div> 19 + <div class="px-2 invisible" aria-hidden="true">{{ .Content }}</div> 20 + </div> 21 + {{- else if eq .Op.String "-" -}} 22 + <div class="{{ $delStyle }} {{ $containerStyle }}" id="{{$name}}-O{{.LineNumber}}"> 23 + <div class="{{ $lineNrStyle }} {{ $lineNrSepStyle }}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{.LineNumber}}">{{ .LineNumber }}</a></div> 24 + <div class="{{ $opStyle }}">{{ .Op.String }}</div> 25 + <div class="px-2">{{ .Content }}</div> 26 + </div> 27 + {{- else if eq .Op.String " " -}} 28 + <div class="{{ $ctxStyle }} {{ $containerStyle }}" id="{{$name}}-O{{.LineNumber}}"> 29 + <div class="{{ $lineNrStyle }} {{ $lineNrSepStyle }}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{.LineNumber}}">{{ .LineNumber }}</a></div> 30 + <div class="{{ $opStyle }}">{{ .Op.String }}</div> 31 + <div class="px-2">{{ .Content }}</div> 32 + </div> 33 + {{- end -}} 34 + {{- end -}} 35 + {{- end -}}</div></div></pre> 36 + 37 + <pre class="overflow-x-auto col-span-1"><div class="overflow-x-auto"><div class="min-w-full inline-block">{{- range .TextFragments -}}<div class="bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">&middot;&middot;&middot;</div> 38 + {{- range .RightLines -}} 39 + {{- if .IsEmpty -}} 40 + <div class="{{ $emptyStyle }} {{ $containerStyle }}"> 41 + <div class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><span aria-hidden="true" class="invisible">{{.LineNumber}}</span></div> 42 + <div class="{{ $opStyle }}"><span aria-hidden="true" class="invisible">{{ .Op.String }}</span></div> 43 + <div class="px-2 invisible" aria-hidden="true">{{ .Content }}</div> 44 + </div> 45 + {{- else if eq .Op.String "+" -}} 46 + <div class="{{ $addStyle }} {{ $containerStyle }}" id="{{$name}}-N{{.LineNumber}}"> 47 + <div class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{.LineNumber}}">{{ .LineNumber }}</a></div> 48 + <div class="{{ $opStyle }}">{{ .Op.String }}</div> 49 + <div class="px-2" >{{ .Content }}</div> 50 + </div> 51 + {{- else if eq .Op.String " " -}} 52 + <div class="{{ $ctxStyle }} {{ $containerStyle }}" id="{{$name}}-N{{.LineNumber}}"> 53 + <div class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{.LineNumber}}">{{ .LineNumber }}</a></div> 54 + <div class="{{ $opStyle }}">{{ .Op.String }}</div> 55 + <div class="px-2">{{ .Content }}</div> 56 + </div> 57 + {{- end -}} 58 + {{- end -}} 59 + {{- end -}}</div></div></pre> 60 + </div> 61 + {{ end }}
+3
appview/pages/templates/repo/fragments/time.html
··· 1 + {{ define "repo/fragments/time" }} 2 + {{ template "repo/fragments/timeWrapper" (dict "Time" . "Content" (. | relTimeFmt)) }} 3 + {{ end }}
+5
appview/pages/templates/repo/fragments/timeWrapper.html
··· 1 + {{ define "repo/fragments/timeWrapper" }} 2 + <time datetime="{{ .Time | iso8601DateTimeFmt }}" title="{{ .Time | longTimeFmt }}">{{ .Content }}</time> 3 + {{ end }} 4 + 5 +
+47
appview/pages/templates/repo/fragments/unifiedDiff.html
··· 1 + {{ define "repo/fragments/unifiedDiff" }} 2 + {{ $name := .Id }} 3 + <pre class="overflow-x-auto"><div class="overflow-x-auto"><div class="min-w-full inline-block">{{- range .TextFragments -}}<div class="bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">&middot;&middot;&middot;</div> 4 + {{- $oldStart := .OldPosition -}} 5 + {{- $newStart := .NewPosition -}} 6 + {{- $lineNrStyle := "min-w-[3.5rem] flex-shrink-0 select-none text-right bg-white dark:bg-gray-800 target:bg-yellow-200 target:dark:bg-yellow-600" -}} 7 + {{- $linkStyle := "text-gray-400 dark:text-gray-500 hover:underline" -}} 8 + {{- $lineNrSepStyle1 := "" -}} 9 + {{- $lineNrSepStyle2 := "pr-2 border-r border-gray-200 dark:border-gray-700" -}} 10 + {{- $containerStyle := "flex min-w-full items-center target:border target:rounded-sm target:border-yellow-200 target:dark:border-yellow-700 scroll-mt-20" -}} 11 + {{- $addStyle := "bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400 " -}} 12 + {{- $delStyle := "bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400 " -}} 13 + {{- $ctxStyle := "bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400" -}} 14 + {{- $opStyle := "w-5 flex-shrink-0 select-none text-center" -}} 15 + {{- range .Lines -}} 16 + {{- if eq .Op.String "+" -}} 17 + <div class="{{ $addStyle }} {{ $containerStyle }}" id="{{$name}}-N{{$newStart}}"> 18 + <div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><span aria-hidden="true" class="invisible">{{$newStart}}</span></div> 19 + <div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{$newStart}}">{{ $newStart }}</a></div> 20 + <div class="{{ $opStyle }}">{{ .Op.String }}</div> 21 + <div class="px-2">{{ .Line }}</div> 22 + </div> 23 + {{- $newStart = add64 $newStart 1 -}} 24 + {{- end -}} 25 + {{- if eq .Op.String "-" -}} 26 + <div class="{{ $delStyle }} {{ $containerStyle }}" id="{{$name}}-O{{$oldStart}}"> 27 + <div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}">{{ $oldStart }}</a></div> 28 + <div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><span aria-hidden="true" class="invisible">{{$oldStart}}</span></div> 29 + <div class="{{ $opStyle }}">{{ .Op.String }}</div> 30 + <div class="px-2">{{ .Line }}</div> 31 + </div> 32 + {{- $oldStart = add64 $oldStart 1 -}} 33 + {{- end -}} 34 + {{- if eq .Op.String " " -}} 35 + <div class="{{ $ctxStyle }} {{ $containerStyle }}" id="{{$name}}-O{{$oldStart}}-N{{$newStart}}"> 36 + <div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}-N{{$newStart}}">{{ $oldStart }}</a></div> 37 + <div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}-N{{$newStart}}">{{ $newStart }}</a></div> 38 + <div class="{{ $opStyle }}">{{ .Op.String }}</div> 39 + <div class="px-2">{{ .Line }}</div> 40 + </div> 41 + {{- $newStart = add64 $newStart 1 -}} 42 + {{- $oldStart = add64 $oldStart 1 -}} 43 + {{- end -}} 44 + {{- end -}} 45 + {{- end -}}</div></div></pre> 46 + {{ end }} 47 +
+161 -183
appview/pages/templates/repo/index.html
··· 7 7 {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo) }} 8 8 {{ end }} 9 9 10 - 11 10 {{ define "repoContent" }} 12 11 <main> 12 + {{ if .Languages }} 13 + {{ block "repoLanguages" . }}{{ end }} 14 + {{ end }} 13 15 <div class="flex items-center justify-between pb-5"> 14 16 {{ block "branchSelector" . }}{{ end }} 15 - <div class="flex md:hidden items-center gap-4"> 16 - <a href="/{{ .RepoInfo.FullName }}/commits/{{ .Ref | urlquery }}" class="inline-flex items-center text-sm gap-1"> 17 + <div class="flex md:hidden items-center gap-2"> 18 + <a href="/{{ .RepoInfo.FullName }}/commits/{{ .Ref | urlquery }}" class="inline-flex items-center text-sm gap-1 font-bold"> 17 19 {{ i "git-commit-horizontal" "w-4" "h-4" }} {{ .TotalCommits }} 18 20 </a> 19 - <a href="/{{ .RepoInfo.FullName }}/branches" class="inline-flex items-center text-sm gap-1"> 21 + <a href="/{{ .RepoInfo.FullName }}/branches" class="inline-flex items-center text-sm gap-1 font-bold"> 20 22 {{ i "git-branch" "w-4" "h-4" }} {{ len .Branches }} 21 23 </a> 22 - <a href="/{{ .RepoInfo.FullName }}/tags" class="inline-flex items-center text-sm gap-1"> 24 + <a href="/{{ .RepoInfo.FullName }}/tags" class="inline-flex items-center text-sm gap-1 font-bold"> 23 25 {{ i "tags" "w-4" "h-4" }} {{ len .Tags }} 24 26 </a> 27 + {{ template "repo/fragments/cloneDropdown" . }} 25 28 </div> 26 29 </div> 27 30 <div class="grid grid-cols-1 md:grid-cols-2 gap-2"> ··· 31 34 </main> 32 35 {{ end }} 33 36 34 - {{ define "branchSelector" }} 35 - <div class="flex gap-2 items-center items-stretch justify-center"> 36 - <select 37 - onchange="window.location.href = '/{{ .RepoInfo.FullName }}/tree/' + encodeURIComponent(this.value)" 38 - class="p-1 border max-w-32 border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700" 39 - > 40 - <optgroup label="branches ({{len .Branches}})" class="bold text-sm"> 41 - {{ range .Branches }} 42 - <option 43 - value="{{ .Reference.Name }}" 44 - class="py-1" 45 - {{ if eq .Reference.Name $.Ref }} 46 - selected 47 - {{ end }} 48 - > 49 - {{ .Reference.Name }} 50 - </option> 51 - {{ end }} 52 - </optgroup> 53 - <optgroup label="tags ({{len .Tags}})" class="bold text-sm"> 54 - {{ range .Tags }} 55 - <option 56 - value="{{ .Reference.Name }}" 57 - class="py-1" 58 - {{ if eq .Reference.Name $.Ref }} 59 - selected 60 - {{ end }} 61 - > 62 - {{ .Reference.Name }} 63 - </option> 64 - {{ else }} 65 - <option class="py-1" disabled>no tags found</option> 66 - {{ end }} 67 - </optgroup> 68 - </select> 69 - <div class="flex items-center gap-2"> 70 - {{ $isOwner := and .LoggedInUser .RepoInfo.Roles.IsOwner }} 71 - {{ $isCollaborator := and .LoggedInUser .RepoInfo.Roles.IsCollaborator }} 72 - {{ if and (or $isOwner $isCollaborator) .ForkInfo .ForkInfo.IsFork }} 73 - {{ $disabled := "" }} 74 - {{ $title := "" }} 75 - {{ if eq .ForkInfo.Status 0 }} 76 - {{ $disabled = "disabled" }} 77 - {{ $title = "This branch is not behind the upstream" }} 78 - {{ else if eq .ForkInfo.Status 2 }} 79 - {{ $disabled = "disabled" }} 80 - {{ $title = "This branch has conflicts that must be resolved" }} 81 - {{ else if eq .ForkInfo.Status 3 }} 82 - {{ $disabled = "disabled" }} 83 - {{ $title = "This branch does not exist on the upstream" }} 37 + {{ define "repoLanguages" }} 38 + <details class="group -m-6 mb-4"> 39 + <summary class="flex gap-[1px] h-4 scale-y-50 hover:scale-y-100 origin-top group-open:scale-y-100 transition-all hover:cursor-pointer overflow-hidden rounded-t"> 40 + {{ range $value := .Languages }} 41 + <div 42 + title='{{ or $value.Name "Other" }} {{ printf "%.1f" $value.Percentage }}%' 43 + style="background-color: {{ $value.Color }}; width: {{ $value.Percentage }}%" 44 + ></div> 84 45 {{ end }} 85 - 86 - <button 87 - id="syncBtn" 88 - {{ $disabled }} 89 - {{ if $title }}title="{{ $title }}"{{ end }} 90 - class="btn flex gap-2 items-center disabled:opacity-50 disabled:cursor-not-allowed" 91 - hx-post="/{{ .RepoInfo.FullName }}/fork/sync" 92 - hx-trigger="click" 93 - hx-swap="none" 94 - > 95 - {{ if $disabled }} 96 - {{ i "refresh-cw-off" "w-4 h-4" }} 97 - {{ else }} 98 - {{ i "refresh-cw" "w-4 h-4" }} 99 - {{ end }} 100 - <span>sync</span> 101 - </button> 102 - {{ end }} 103 - <a 104 - href="/{{ .RepoInfo.FullName }}/compare?base={{ $.Ref | urlquery }}" 105 - class="btn flex items-center gap-2 no-underline hover:no-underline" 106 - title="Compare branches or tags" 46 + </summary> 47 + <div class="px-4 py-2 border-b border-gray-200 dark:border-gray-600 flex items-center gap-4 flex-wrap"> 48 + {{ range $value := .Languages }} 49 + <div 50 + class="flex flex-grow items-center gap-2 text-xs align-items-center justify-center" 107 51 > 108 - {{ i "git-compare" "w-4 h-4" }} 109 - </a> 52 + {{ template "repo/fragments/languageBall" $value.Name }} 53 + <div>{{ or $value.Name "Other" }} 54 + <span class="text-gray-500 dark:text-gray-400"> 55 + {{ if lt $value.Percentage 0.05 }} 56 + 0.1% 57 + {{ else }} 58 + {{ printf "%.1f" $value.Percentage }}% 59 + {{ end }} 60 + </span></div> 61 + </div> 62 + {{ end }} 63 + </div> 64 + </details> 65 + {{ end }} 66 + 67 + {{ define "branchSelector" }} 68 + <div class="flex gap-2 items-center justify-between w-full"> 69 + <div class="flex gap-2 items-center"> 70 + <select 71 + onchange="window.location.href = '/{{ .RepoInfo.FullName }}/tree/' + encodeURIComponent(this.value)" 72 + class="p-1 border max-w-32 border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700" 73 + > 74 + <optgroup label="branches ({{len .Branches}})" class="bold text-sm"> 75 + {{ range .Branches }} 76 + <option 77 + value="{{ .Reference.Name }}" 78 + class="py-1" 79 + {{ if eq .Reference.Name $.Ref }} 80 + selected 81 + {{ end }} 82 + > 83 + {{ .Reference.Name }} 84 + </option> 85 + {{ end }} 86 + </optgroup> 87 + <optgroup label="tags ({{len .Tags}})" class="bold text-sm"> 88 + {{ range .Tags }} 89 + <option 90 + value="{{ .Reference.Name }}" 91 + class="py-1" 92 + {{ if eq .Reference.Name $.Ref }} 93 + selected 94 + {{ end }} 95 + > 96 + {{ .Reference.Name }} 97 + </option> 98 + {{ else }} 99 + <option class="py-1" disabled>no tags found</option> 100 + {{ end }} 101 + </optgroup> 102 + </select> 103 + <div class="flex items-center gap-2"> 104 + <a 105 + href="/{{ .RepoInfo.FullName }}/compare?base={{ $.Ref | urlquery }}" 106 + class="btn flex items-center gap-2 no-underline hover:no-underline" 107 + title="Compare branches or tags" 108 + > 109 + {{ i "git-compare" "w-4 h-4" }} 110 + </a> 111 + </div> 110 112 </div> 111 - </div> 113 + 114 + <!-- Clone dropdown in top right --> 115 + <div class="hidden md:flex items-center "> 116 + {{ template "repo/fragments/cloneDropdown" . }} 117 + </div> 118 + </div> 112 119 {{ end }} 113 120 114 121 {{ define "fileTree" }} 115 - <div 116 - id="file-tree" 117 - class="col-span-1 pr-2 md:border-r md:border-gray-200 dark:md:border-gray-700" 118 - > 119 - {{ $containerstyle := "py-1" }} 120 - {{ $linkstyle := "no-underline hover:underline dark:text-white" }} 122 + <div id="file-tree" class="col-span-1 pr-2 md:border-r md:border-gray-200 dark:md:border-gray-700" > 123 + {{ $linkstyle := "no-underline hover:underline dark:text-white" }} 121 124 122 - {{ range .Files }} 123 - {{ if not .IsFile }} 124 - <div class="{{ $containerstyle }}"> 125 - <div class="flex justify-between items-center"> 126 - <a 127 - href="/{{ $.RepoInfo.FullName }}/tree/{{ $.Ref | urlquery }}/{{ .Name }}" 128 - class="{{ $linkstyle }}" 129 - > 130 - <div class="flex items-center gap-2"> 131 - {{ i "folder" "size-4 fill-current" }} 132 - {{ .Name }} 133 - </div> 134 - </a> 125 + {{ range .Files }} 126 + <div class="grid grid-cols-3 gap-4 items-center py-1"> 127 + <div class="col-span-2"> 128 + {{ $link := printf "/%s/%s/%s/%s" $.RepoInfo.FullName "tree" (urlquery $.Ref) .Name }} 129 + {{ $icon := "folder" }} 130 + {{ $iconStyle := "size-4 fill-current" }} 135 131 136 - {{ if .LastCommit }} 137 - <time class="text-xs text-gray-500 dark:text-gray-400" 138 - >{{ timeFmt .LastCommit.When }}</time 139 - > 140 - {{ end }} 141 - </div> 142 - </div> 143 - {{ end }} 144 - {{ end }} 145 - 146 - {{ range .Files }} 147 - {{ if .IsFile }} 148 - <div class="{{ $containerstyle }}"> 149 - <div class="flex justify-between items-center"> 150 - <a 151 - href="/{{ $.RepoInfo.FullName }}/blob/{{ $.Ref | urlquery }}/{{ .Name }}" 152 - class="{{ $linkstyle }}" 153 - > 154 - <div class="flex items-center gap-2"> 155 - {{ i "file" "size-4" }}{{ .Name }} 156 - </div> 157 - </a> 132 + {{ if .IsFile }} 133 + {{ $link = printf "/%s/%s/%s/%s" $.RepoInfo.FullName "blob" (urlquery $.Ref) .Name }} 134 + {{ $icon = "file" }} 135 + {{ $iconStyle = "size-4" }} 136 + {{ end }} 137 + <a href="{{ $link }}" class="{{ $linkstyle }}"> 138 + <div class="flex items-center gap-2"> 139 + {{ i $icon $iconStyle "flex-shrink-0" }} 140 + <span class="truncate">{{ .Name }}</span> 141 + </div> 142 + </a> 143 + </div> 158 144 159 - {{ if .LastCommit }} 160 - <time class="text-xs text-gray-500 dark:text-gray-400" 161 - >{{ timeFmt .LastCommit.When }}</time 162 - > 163 - {{ end }} 164 - </div> 165 - </div> 166 - {{ end }} 167 - {{ end }} 168 - </div> 145 + <div class="text-sm col-span-1 text-right"> 146 + {{ with .LastCommit }} 147 + <a href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash }}" class="text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" .When }}</a> 148 + {{ end }} 149 + </div> 150 + </div> 151 + {{ end }} 152 + </div> 169 153 {{ end }} 170 154 171 155 {{ define "rightInfo" }} ··· 179 163 {{ define "commitLog" }} 180 164 <div id="commit-log" class="md:col-span-1 px-2 pb-4"> 181 165 <div class="flex justify-between items-center"> 182 - <a href="/{{ .RepoInfo.FullName }}/commits/{{ .Ref | urlquery }}" class="flex text-black dark:text-white items-center gap-4 pb-2 no-underline hover:no-underline group"> 183 - <div class="flex gap-2 items-center font-bold"> 184 - {{ i "logs" "w-4 h-4" }} commits 185 - </div> 186 - <span class="hidden group-hover:flex gap-2 items-center text-sm text-gray-500 dark:text-gray-400 "> 187 - view {{ .TotalCommits }} commits {{ i "chevron-right" "w-4 h-4" }} 188 - </span> 166 + <a href="/{{ .RepoInfo.FullName }}/commits/{{ .Ref | urlquery }}" class="flex items-center gap-2 pb-2 cursor-pointer font-bold hover:text-gray-600 dark:hover:text-gray-300 hover:no-underline"> 167 + {{ i "logs" "w-4 h-4" }} commits 168 + <span class="bg-gray-100 dark:bg-gray-700 font-normal rounded py-1/2 px-1 text-sm">{{ .TotalCommits }}</span> 189 169 </a> 190 170 </div> 191 171 <div class="flex flex-col gap-6"> ··· 223 203 </div> 224 204 225 205 <!-- commit info bar --> 226 - <div class="text-xs mt-2 text-gray-500 dark:text-gray-400 flex items-center"> 206 + <div class="text-xs mt-2 text-gray-500 dark:text-gray-400 flex items-center flex-wrap"> 227 207 {{ $verified := $.VerifiedCommits.IsVerified .Hash.String }} 228 208 {{ $hashStyle := "text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-900" }} 229 209 {{ if $verified }} ··· 251 231 {{ end }}" 252 232 class="text-gray-500 dark:text-gray-400 no-underline hover:underline" 253 233 >{{ if $didOrHandle }} 254 - {{ $didOrHandle }} 234 + {{ template "user/fragments/picHandleLink" $didOrHandle }} 255 235 {{ else }} 256 236 {{ .Author.Name }} 257 237 {{ end }}</a 258 238 > 259 239 </span> 260 240 <div class="inline-block px-1 select-none after:content-['ยท']"></div> 261 - <span>{{ timeFmt .Committer.When }}</span> 241 + {{ template "repo/fragments/time" .Committer.When }} 262 242 263 243 <!-- tags/branches --> 264 244 {{ $tagsForCommit := index $.TagMap .Hash.String }} ··· 275 255 {{ $pipeline := index $.Pipelines .Hash.String }} 276 256 {{ if and $pipeline (gt (len $pipeline.Statuses) 0) }} 277 257 <div class="inline-block px-1 select-none after:content-['ยท']"></div> 278 - {{ template "repo/pipelines/fragments/pipelineSymbolLong" $pipeline }} 258 + {{ template "repo/pipelines/fragments/pipelineSymbolLong" (dict "RepoInfo" $.RepoInfo "Pipeline" $pipeline) }} 279 259 {{ end }} 280 260 </div> 281 261 </div> ··· 287 267 {{ define "branchList" }} 288 268 {{ if gt (len .BranchesTrunc) 0 }} 289 269 <div id="branches" class="md:col-span-1 px-2 py-4 border-t border-gray-200 dark:border-gray-700"> 290 - <a href="/{{ .RepoInfo.FullName }}/branches" class="flex text-black dark:text-white items-center gap-4 pb-2 no-underline hover:no-underline group"> 291 - <div class="flex gap-2 items-center font-bold"> 292 - {{ i "git-branch" "w-4 h-4" }} branches 293 - </div> 294 - <span class="hidden group-hover:flex gap-2 items-center text-sm text-gray-500 dark:text-gray-400 "> 295 - view {{ len .Branches }} branches {{ i "chevron-right" "w-4 h-4" }} 296 - </span> 270 + <a href="/{{ .RepoInfo.FullName }}/branches" class="flex items-center gap-2 pb-2 cursor-pointer font-bold hover:text-gray-600 dark:hover:text-gray-300 hover:no-underline"> 271 + {{ i "git-branch" "w-4 h-4" }} branches 272 + <span class="bg-gray-100 dark:bg-gray-700 font-normal rounded py-1/2 px-1 text-sm">{{ len .Branches }}</span> 297 273 </a> 298 274 <div class="flex flex-col gap-1"> 299 275 {{ range .BranchesTrunc }} 300 - <div class="text-base flex items-center justify-between"> 301 - <div class="flex items-center gap-2"> 276 + <div class="text-base flex items-center justify-between overflow-hidden"> 277 + <div class="flex items-center gap-2 min-w-0 flex-1"> 302 278 <a href="/{{ $.RepoInfo.FullName }}/tree/{{ .Reference.Name | urlquery }}" 303 - class="inline no-underline hover:underline dark:text-white"> 279 + class="inline-block truncate no-underline hover:underline dark:text-white"> 304 280 {{ .Reference.Name }} 305 281 </a> 306 282 {{ if .Commit }} 307 - <span class="px-1 text-gray-500 dark:text-gray-400 select-none after:content-['ยท']"></span> 308 - <time class="text-xs text-gray-500 dark:text-gray-400">{{ timeFmt .Commit.Committer.When }}</time> 283 + <span class="px-1 text-gray-500 dark:text-gray-400 select-none after:content-['ยท'] shrink-0"></span> 284 + <span class="whitespace-nowrap text-xs text-gray-500 dark:text-gray-400 shrink-0">{{ template "repo/fragments/time" .Commit.Committer.When }}</span> 309 285 {{ end }} 310 286 {{ if .IsDefault }} 311 - <span class="px-1 text-gray-500 dark:text-gray-400 select-none after:content-['ยท']"></span> 312 - <span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 text-xs font-mono">default</span> 287 + <span class="px-1 text-gray-500 dark:text-gray-400 select-none after:content-['ยท'] shrink-0"></span> 288 + <span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 text-xs font-mono shrink-0">default</span> 313 289 {{ end }} 314 290 </div> 315 291 {{ if ne $.Ref .Reference.Name }} 316 292 <a href="/{{ $.RepoInfo.FullName }}/compare/{{ $.Ref | urlquery }}...{{ .Reference.Name | urlquery }}" 317 - class="text-xs flex gap-2 items-center" 293 + class="text-xs flex gap-2 items-center shrink-0 ml-2" 318 294 title="Compare branches or tags"> 319 295 {{ i "git-compare" "w-3 h-3" }} compare 320 296 </a> 321 - {{end}} 297 + {{ end }} 322 298 </div> 323 299 {{ end }} 324 300 </div> ··· 330 306 {{ if gt (len .TagsTrunc) 0 }} 331 307 <div id="tags" class="md:col-span-1 px-2 py-4 border-t border-gray-200 dark:border-gray-700"> 332 308 <div class="flex justify-between items-center"> 333 - <a href="/{{ .RepoInfo.FullName }}/tags" class="flex text-black dark:text-white items-center gap-4 pb-2 no-underline hover:no-underline group"> 334 - <div class="flex gap-2 items-center font-bold"> 335 - {{ i "tags" "w-4 h-4" }} tags 336 - </div> 337 - <span class="hidden group-hover:flex gap-2 items-center text-sm text-gray-500 dark:text-gray-400 "> 338 - view {{ len .Tags }} tags {{ i "chevron-right" "w-4 h-4" }} 339 - </span> 309 + <a href="/{{ .RepoInfo.FullName }}/tags" class="flex items-center gap-2 pb-2 cursor-pointer font-bold hover:text-gray-600 dark:hover:text-gray-300 hover:no-underline"> 310 + {{ i "tags" "w-4 h-4" }} tags 311 + <span class="bg-gray-100 dark:bg-gray-700 font-normal rounded py-1/2 px-1 text-sm">{{ len .Tags }}</span> 340 312 </a> 341 313 </div> 342 314 <div class="flex flex-col gap-1"> ··· 351 323 </div> 352 324 <div> 353 325 {{ with .Tag }} 354 - <time class="text-xs text-gray-500 dark:text-gray-400">{{ timeFmt .Tagger.When }}</time> 326 + <span class="text-xs text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" .Tagger.When }}</span> 355 327 {{ end }} 356 328 {{ if eq $idx 0 }} 357 329 {{ with .Tag }}<span class="px-1 text-gray-500 dark:text-gray-400 select-none after:content-['ยท']"></span>{{ end }} ··· 367 339 {{ end }} 368 340 369 341 {{ define "repoAfter" }} 370 - {{- if .HTMLReadme -}} 371 - <section 372 - class="p-6 mt-4 rounded-br rounded-bl bg-white dark:bg-gray-800 dark:text-white drop-shadow-sm w-full mx-auto overflow-auto {{ if not .Raw }} 373 - prose dark:prose-invert dark:[&_pre]:bg-gray-900 374 - dark:[&_code]:text-gray-300 dark:[&_pre_code]:bg-gray-900 375 - dark:[&_pre]:border dark:[&_pre]:border-gray-700 376 - {{ end }}" 377 - > 378 - <article class="{{ if .Raw }}whitespace-pre{{ end }}">{{- if .Raw -}}<pre class="dark:bg-gray-800 dark:text-white overflow-x-scroll"> 379 - {{- .HTMLReadme -}} 380 - </pre> 381 - {{- else -}} 382 - {{ .HTMLReadme }} 383 - {{- end -}}</article> 384 - </section> 342 + {{- if or .HTMLReadme .Readme -}} 343 + <div class="mt-4 rounded bg-white dark:bg-gray-800 drop-shadow-sm w-full mx-auto overflow-hidden"> 344 + {{- if .ReadmeFileName -}} 345 + <div class="px-4 py-2 bg-gray-50 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-600 flex items-center gap-2"> 346 + {{ i "file-text" "w-4 h-4" "text-gray-600 dark:text-gray-400" }} 347 + <span class="font-mono text-sm text-gray-800 dark:text-gray-200">{{ .ReadmeFileName }}</span> 348 + </div> 349 + {{- end -}} 350 + <section 351 + class="p-6 overflow-auto {{ if not .Raw }} 352 + prose dark:prose-invert dark:[&_pre]:bg-gray-900 353 + dark:[&_code]:text-gray-300 dark:[&_pre_code]:bg-gray-900 354 + dark:[&_pre]:border dark:[&_pre]:border-gray-700 355 + {{ end }}" 356 + > 357 + <article class="{{ if .Raw }}whitespace-pre{{ end }}">{{- if .Raw -}}<pre class="dark:bg-gray-800 dark:text-white overflow-x-auto"> 358 + {{- .Readme -}} 359 + </pre> 360 + {{- else -}} 361 + {{ .HTMLReadme }} 362 + {{- end -}}</article> 363 + </section> 364 + </div> 385 365 {{- end -}} 386 - 387 - {{ template "repo/fragments/cloneInstructions" . }} 388 366 {{ end }}
+58
appview/pages/templates/repo/issues/fragments/commentList.html
··· 1 + {{ define "repo/issues/fragments/commentList" }} 2 + <div class="flex flex-col gap-8"> 3 + {{ range $item := .CommentList }} 4 + {{ template "commentListing" (list $ .) }} 5 + {{ end }} 6 + <div> 7 + {{ end }} 8 + 9 + {{ define "commentListing" }} 10 + {{ $root := index . 0 }} 11 + {{ $comment := index . 1 }} 12 + {{ $params := 13 + (dict 14 + "RepoInfo" $root.RepoInfo 15 + "LoggedInUser" $root.LoggedInUser 16 + "Issue" $root.Issue 17 + "Comment" $comment.Self) }} 18 + 19 + <div class="rounded border border-gray-300 dark:border-gray-700 w-full overflow-hidden shadow-sm"> 20 + {{ template "topLevelComment" $params }} 21 + 22 + <div class="relative ml-4 border-l border-gray-300 dark:border-gray-700"> 23 + {{ range $index, $reply := $comment.Replies }} 24 + <div class="relative "> 25 + <!-- Horizontal connector --> 26 + <div class="absolute left-0 top-6 w-4 h-px bg-gray-300 dark:bg-gray-700"></div> 27 + 28 + <div class="pl-2"> 29 + {{ 30 + template "replyComment" 31 + (dict 32 + "RepoInfo" $root.RepoInfo 33 + "LoggedInUser" $root.LoggedInUser 34 + "Issue" $root.Issue 35 + "Comment" $reply) 36 + }} 37 + </div> 38 + </div> 39 + {{ end }} 40 + </div> 41 + 42 + {{ template "repo/issues/fragments/replyIssueCommentPlaceholder" $params }} 43 + </div> 44 + {{ end }} 45 + 46 + {{ define "topLevelComment" }} 47 + <div class="rounded px-6 py-4 bg-white dark:bg-gray-800"> 48 + {{ template "repo/issues/fragments/issueCommentHeader" . }} 49 + {{ template "repo/issues/fragments/issueCommentBody" . }} 50 + </div> 51 + {{ end }} 52 + 53 + {{ define "replyComment" }} 54 + <div class="p-4 w-full mx-auto overflow-hidden"> 55 + {{ template "repo/issues/fragments/issueCommentHeader" . }} 56 + {{ template "repo/issues/fragments/issueCommentBody" . }} 57 + </div> 58 + {{ end }}
+37 -47
appview/pages/templates/repo/issues/fragments/editIssueComment.html
··· 1 1 {{ define "repo/issues/fragments/editIssueComment" }} 2 - {{ with .Comment }} 3 - <div id="comment-container-{{.CommentId}}"> 4 - <div class="flex items-center gap-2 mb-2 text-gray-500 text-sm"> 5 - {{ $owner := didOrHandle $.LoggedInUser.Did $.LoggedInUser.Handle }} 6 - <a href="/{{ $owner }}" class="no-underline hover:underline">{{ $owner }}</a> 7 - 8 - <!-- show user "hats" --> 9 - {{ $isIssueAuthor := eq .OwnerDid $.Issue.OwnerDid }} 10 - {{ if $isIssueAuthor }} 11 - <span class="before:content-['ยท']"></span> 12 - <span class="rounded bg-gray-100 text-black font-mono px-2 mx-1/2 inline-flex items-center"> 13 - author 14 - </span> 15 - {{ end }} 16 - 17 - <span class="before:content-['ยท']"></span> 18 - <a 19 - href="#{{ .CommentId }}" 20 - class="text-gray-500 hover:text-gray-500 hover:underline no-underline" 21 - id="{{ .CommentId }}"> 22 - {{ .Created | timeFmt }} 23 - </a> 2 + <div id="comment-body-{{.Comment.Id}}" class="pt-2"> 3 + <textarea 4 + id="edit-textarea-{{ .Comment.Id }}" 5 + name="body" 6 + class="w-full p-2 rounded border border-gray-200 dark:border-gray-700" 7 + rows="5" 8 + autofocus>{{ .Comment.Body }}</textarea> 24 9 25 - <button 26 - class="btn px-2 py-1 flex items-center gap-2 text-sm group" 27 - hx-post="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/edit" 28 - hx-include="#edit-textarea-{{ .CommentId }}" 29 - hx-target="#comment-container-{{ .CommentId }}" 30 - hx-swap="outerHTML"> 31 - {{ i "check" "w-4 h-4" }} 32 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 33 - </button> 34 - <button 35 - class="btn px-2 py-1 flex items-center gap-2 text-sm" 36 - hx-get="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/" 37 - hx-target="#comment-container-{{ .CommentId }}" 38 - hx-swap="outerHTML"> 39 - {{ i "x" "w-4 h-4" }} 40 - </button> 41 - <span id="comment-{{.CommentId}}-status"></span> 42 - </div> 10 + {{ template "editActions" $ }} 11 + </div> 12 + {{ end }} 43 13 44 - <div> 45 - <textarea 46 - id="edit-textarea-{{ .CommentId }}" 47 - name="body" 48 - class="w-full p-2 border rounded min-h-[100px]">{{ .Body }}</textarea> 49 - </div> 14 + {{ define "editActions" }} 15 + <div class="flex flex-wrap items-center justify-end gap-2 text-gray-500 dark:text-gray-400 text-sm pt-2"> 16 + {{ template "cancel" . }} 17 + {{ template "save" . }} 50 18 </div> 51 - {{ end }} 52 19 {{ end }} 53 20 21 + {{ define "save" }} 22 + <button 23 + class="btn-create py-0 flex gap-1 items-center group text-sm" 24 + hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/edit" 25 + hx-include="#edit-textarea-{{ .Comment.Id }}" 26 + hx-target="#comment-body-{{ .Comment.Id }}" 27 + hx-swap="outerHTML"> 28 + {{ i "check" "size-4" }} 29 + save 30 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 31 + </button> 32 + {{ end }} 33 + 34 + {{ define "cancel" }} 35 + <button 36 + class="btn py-0 text-red-500 dark:text-red-400 flex gap-1 items-center group" 37 + hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/" 38 + hx-target="#comment-body-{{ .Comment.Id }}" 39 + hx-swap="outerHTML"> 40 + {{ i "x" "size-4" }} 41 + cancel 42 + </button> 43 + {{ end }}
-60
appview/pages/templates/repo/issues/fragments/issueComment.html
··· 1 - {{ define "repo/issues/fragments/issueComment" }} 2 - {{ with .Comment }} 3 - <div id="comment-container-{{.CommentId}}"> 4 - <div class="flex items-center gap-2 mb-2 text-gray-500 dark:text-gray-400 text-sm"> 5 - {{ $owner := index $.DidHandleMap .OwnerDid }} 6 - <a href="/{{ $owner }}" class="no-underline hover:underline">{{ $owner }}</a> 7 - 8 - <span class="before:content-['ยท']"></span> 9 - <a 10 - href="#{{ .CommentId }}" 11 - class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-400 hover:underline no-underline" 12 - id="{{ .CommentId }}"> 13 - {{ if .Deleted }} 14 - deleted {{ .Deleted | timeFmt }} 15 - {{ else if .Edited }} 16 - edited {{ .Edited | timeFmt }} 17 - {{ else }} 18 - {{ .Created | timeFmt }} 19 - {{ end }} 20 - </a> 21 - 22 - <!-- show user "hats" --> 23 - {{ $isIssueAuthor := eq .OwnerDid $.Issue.OwnerDid }} 24 - {{ if $isIssueAuthor }} 25 - <span class="text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 mx-1/2 inline-flex items-center"> 26 - author 27 - </span> 28 - {{ end }} 29 - 30 - {{ $isCommentOwner := and $.LoggedInUser (eq $.LoggedInUser.Did .OwnerDid) }} 31 - {{ if and $isCommentOwner (not .Deleted) }} 32 - <button 33 - class="btn px-2 py-1 text-sm" 34 - hx-get="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/edit" 35 - hx-swap="outerHTML" 36 - hx-target="#comment-container-{{.CommentId}}" 37 - > 38 - {{ i "pencil" "w-4 h-4" }} 39 - </button> 40 - <button 41 - class="btn px-2 py-1 text-sm text-red-500 flex gap-2 items-center group" 42 - hx-delete="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/" 43 - hx-confirm="Are you sure you want to delete your comment?" 44 - hx-swap="outerHTML" 45 - hx-target="#comment-container-{{.CommentId}}" 46 - > 47 - {{ i "trash-2" "w-4 h-4" }} 48 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 49 - </button> 50 - {{ end }} 51 - 52 - </div> 53 - {{ if not .Deleted }} 54 - <div class="prose dark:prose-invert"> 55 - {{ .Body | markdown }} 56 - </div> 57 - {{ end }} 58 - </div> 59 - {{ end }} 60 - {{ end }}
+34
appview/pages/templates/repo/issues/fragments/issueCommentActions.html
··· 1 + {{ define "repo/issues/fragments/issueCommentActions" }} 2 + {{ $isCommentOwner := and .LoggedInUser (eq .LoggedInUser.Did .Comment.Did) }} 3 + {{ if and $isCommentOwner (not .Comment.Deleted) }} 4 + <div class="flex flex-wrap items-center gap-4 text-gray-500 dark:text-gray-400 text-sm pt-2"> 5 + {{ template "edit" . }} 6 + {{ template "delete" . }} 7 + </div> 8 + {{ end }} 9 + {{ end }} 10 + 11 + {{ define "edit" }} 12 + <a 13 + class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group" 14 + hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/edit" 15 + hx-swap="outerHTML" 16 + hx-target="#comment-body-{{.Comment.Id}}"> 17 + {{ i "pencil" "size-3" }} 18 + edit 19 + </a> 20 + {{ end }} 21 + 22 + {{ define "delete" }} 23 + <a 24 + class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group" 25 + hx-delete="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/" 26 + hx-confirm="Are you sure you want to delete your comment?" 27 + hx-swap="outerHTML" 28 + hx-target="#comment-body-{{.Comment.Id}}" 29 + > 30 + {{ i "trash-2" "size-3" }} 31 + delete 32 + {{ i "loader-circle" "size-3 animate-spin hidden group-[.htmx-request]:inline" }} 33 + </a> 34 + {{ end }}
+9
appview/pages/templates/repo/issues/fragments/issueCommentBody.html
··· 1 + {{ define "repo/issues/fragments/issueCommentBody" }} 2 + <div id="comment-body-{{.Comment.Id}}"> 3 + {{ if not .Comment.Deleted }} 4 + <div class="prose dark:prose-invert">{{ .Comment.Body | markdown }}</div> 5 + {{ else }} 6 + <div class="prose dark:prose-invert italic text-gray-500 dark:text-gray-400">[deleted by author]</div> 7 + {{ end }} 8 + </div> 9 + {{ end }}
+56
appview/pages/templates/repo/issues/fragments/issueCommentHeader.html
··· 1 + {{ define "repo/issues/fragments/issueCommentHeader" }} 2 + <div class="flex flex-wrap items-center gap-2 text-sm text-gray-500 dark:text-gray-400 "> 3 + {{ template "user/fragments/picHandleLink" .Comment.Did }} 4 + {{ template "hats" $ }} 5 + {{ template "timestamp" . }} 6 + {{ $isCommentOwner := and .LoggedInUser (eq .LoggedInUser.Did .Comment.Did) }} 7 + {{ if and $isCommentOwner (not .Comment.Deleted) }} 8 + {{ template "editIssueComment" . }} 9 + {{ template "deleteIssueComment" . }} 10 + {{ end }} 11 + </div> 12 + {{ end }} 13 + 14 + {{ define "hats" }} 15 + {{ $isIssueAuthor := eq .Comment.Did .Issue.Did }} 16 + {{ if $isIssueAuthor }} 17 + (author) 18 + {{ end }} 19 + {{ end }} 20 + 21 + {{ define "timestamp" }} 22 + <a href="#{{ .Comment.Id }}" 23 + class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-400 hover:underline no-underline" 24 + id="{{ .Comment.Id }}"> 25 + {{ if .Comment.Deleted }} 26 + {{ template "repo/fragments/shortTimeAgo" .Comment.Deleted }} 27 + {{ else if .Comment.Edited }} 28 + edited {{ template "repo/fragments/shortTimeAgo" .Comment.Edited }} 29 + {{ else }} 30 + {{ template "repo/fragments/shortTimeAgo" .Comment.Created }} 31 + {{ end }} 32 + </a> 33 + {{ end }} 34 + 35 + {{ define "editIssueComment" }} 36 + <a 37 + class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group" 38 + hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/edit" 39 + hx-swap="outerHTML" 40 + hx-target="#comment-body-{{.Comment.Id}}"> 41 + {{ i "pencil" "size-3" }} 42 + </a> 43 + {{ end }} 44 + 45 + {{ define "deleteIssueComment" }} 46 + <a 47 + class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group" 48 + hx-delete="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/" 49 + hx-confirm="Are you sure you want to delete your comment?" 50 + hx-swap="outerHTML" 51 + hx-target="#comment-body-{{.Comment.Id}}" 52 + > 53 + {{ i "trash-2" "size-3" }} 54 + {{ i "loader-circle" "size-3 animate-spin hidden group-[.htmx-request]:inline" }} 55 + </a> 56 + {{ end }}
+145
appview/pages/templates/repo/issues/fragments/newComment.html
··· 1 + {{ define "repo/issues/fragments/newComment" }} 2 + {{ if .LoggedInUser }} 3 + <form 4 + id="comment-form" 5 + hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment" 6 + hx-on::after-request="if(event.detail.successful) this.reset()" 7 + > 8 + <div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-4 px-4 relative w-full"> 9 + <div class="text-sm pb-2 text-gray-500 dark:text-gray-400"> 10 + {{ template "user/fragments/picHandleLink" .LoggedInUser.Did }} 11 + </div> 12 + <textarea 13 + id="comment-textarea" 14 + name="body" 15 + class="w-full p-2 rounded border border-gray-200 dark:border-gray-700" 16 + placeholder="Add to the discussion. Markdown is supported." 17 + onkeyup="updateCommentForm()" 18 + rows="5" 19 + ></textarea> 20 + <div id="issue-comment"></div> 21 + <div id="issue-action" class="error"></div> 22 + </div> 23 + 24 + <div class="flex gap-2 mt-2"> 25 + <button 26 + id="comment-button" 27 + hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment" 28 + type="submit" 29 + hx-disabled-elt="#comment-button" 30 + class="btn-create p-2 flex items-center gap-2 no-underline hover:no-underline group" 31 + disabled 32 + > 33 + {{ i "message-square-plus" "w-4 h-4" }} 34 + comment 35 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 36 + </button> 37 + 38 + {{ $isIssueAuthor := and .LoggedInUser (eq .LoggedInUser.Did .Issue.Did) }} 39 + {{ $isRepoCollaborator := .RepoInfo.Roles.IsCollaborator }} 40 + {{ $isRepoOwner := .RepoInfo.Roles.IsOwner }} 41 + {{ if and (or $isIssueAuthor $isRepoCollaborator $isRepoOwner) .Issue.Open }} 42 + <button 43 + id="close-button" 44 + type="button" 45 + class="btn flex items-center gap-2" 46 + hx-indicator="#close-spinner" 47 + hx-trigger="click" 48 + > 49 + {{ i "ban" "w-4 h-4" }} 50 + close 51 + <span id="close-spinner" class="group"> 52 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 53 + </span> 54 + </button> 55 + <div 56 + id="close-with-comment" 57 + hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment" 58 + hx-trigger="click from:#close-button" 59 + hx-disabled-elt="#close-with-comment" 60 + hx-target="#issue-comment" 61 + hx-indicator="#close-spinner" 62 + hx-vals="js:{body: document.getElementById('comment-textarea').value.trim() !== '' ? document.getElementById('comment-textarea').value : ''}" 63 + hx-swap="none" 64 + > 65 + </div> 66 + <div 67 + id="close-issue" 68 + hx-disabled-elt="#close-issue" 69 + hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/close" 70 + hx-trigger="click from:#close-button" 71 + hx-target="#issue-action" 72 + hx-indicator="#close-spinner" 73 + hx-swap="none" 74 + > 75 + </div> 76 + <script> 77 + document.addEventListener('htmx:configRequest', function(evt) { 78 + if (evt.target.id === 'close-with-comment') { 79 + const commentText = document.getElementById('comment-textarea').value.trim(); 80 + if (commentText === '') { 81 + evt.detail.parameters = {}; 82 + evt.preventDefault(); 83 + } 84 + } 85 + }); 86 + </script> 87 + {{ else if and (or $isIssueAuthor $isRepoCollaborator $isRepoOwner) (not .Issue.Open) }} 88 + <button 89 + type="button" 90 + class="btn flex items-center gap-2" 91 + hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/reopen" 92 + hx-indicator="#reopen-spinner" 93 + hx-swap="none" 94 + > 95 + {{ i "refresh-ccw-dot" "w-4 h-4" }} 96 + reopen 97 + <span id="reopen-spinner" class="group"> 98 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 99 + </span> 100 + </button> 101 + {{ end }} 102 + 103 + <script> 104 + function updateCommentForm() { 105 + const textarea = document.getElementById('comment-textarea'); 106 + const commentButton = document.getElementById('comment-button'); 107 + const closeButton = document.getElementById('close-button'); 108 + 109 + if (textarea.value.trim() !== '') { 110 + commentButton.removeAttribute('disabled'); 111 + } else { 112 + commentButton.setAttribute('disabled', ''); 113 + } 114 + 115 + if (closeButton) { 116 + if (textarea.value.trim() !== '') { 117 + closeButton.innerHTML = ` 118 + {{ i "ban" "w-4 h-4" }} 119 + <span>close with comment</span> 120 + <span id="close-spinner" class="group"> 121 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 122 + </span>`; 123 + } else { 124 + closeButton.innerHTML = ` 125 + {{ i "ban" "w-4 h-4" }} 126 + <span>close</span> 127 + <span id="close-spinner" class="group"> 128 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 129 + </span>`; 130 + } 131 + } 132 + } 133 + 134 + document.addEventListener('DOMContentLoaded', function() { 135 + updateCommentForm(); 136 + }); 137 + </script> 138 + </div> 139 + </form> 140 + {{ else }} 141 + <div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-4 px-4 relative w-fit"> 142 + <a href="/login" class="underline">login</a> to join the discussion 143 + </div> 144 + {{ end }} 145 + {{ end }}
+57
appview/pages/templates/repo/issues/fragments/putIssue.html
··· 1 + {{ define "repo/issues/fragments/putIssue" }} 2 + <!-- this form is used for new and edit, .Issue is passed when editing --> 3 + <form 4 + {{ if eq .Action "edit" }} 5 + hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/edit" 6 + {{ else }} 7 + hx-post="/{{ .RepoInfo.FullName }}/issues/new" 8 + {{ end }} 9 + hx-swap="none" 10 + hx-indicator="#spinner"> 11 + <div class="flex flex-col gap-2"> 12 + <div> 13 + <label for="title">title</label> 14 + <input type="text" name="title" id="title" class="w-full" value="{{ if .Issue }}{{ .Issue.Title }}{{ end }}" /> 15 + </div> 16 + <div> 17 + <label for="body">body</label> 18 + <textarea 19 + name="body" 20 + id="body" 21 + rows="6" 22 + class="w-full resize-y" 23 + placeholder="Describe your issue. Markdown is supported." 24 + >{{ if .Issue }}{{ .Issue.Body }}{{ end }}</textarea> 25 + </div> 26 + <div class="flex justify-between"> 27 + <div id="issues" class="error"></div> 28 + <div class="flex gap-2 items-center"> 29 + <a 30 + class="btn flex items-center gap-2 no-underline hover:no-underline" 31 + type="button" 32 + {{ if .Issue }} 33 + href="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}" 34 + {{ else }} 35 + href="/{{ .RepoInfo.FullName }}/issues" 36 + {{ end }} 37 + > 38 + {{ i "x" "w-4 h-4" }} 39 + cancel 40 + </a> 41 + <button type="submit" class="btn-create flex items-center gap-2"> 42 + {{ if eq .Action "edit" }} 43 + {{ i "pencil" "w-4 h-4" }} 44 + {{ .Action }} issue 45 + {{ else }} 46 + {{ i "circle-plus" "w-4 h-4" }} 47 + {{ .Action }} issue 48 + {{ end }} 49 + <span id="spinner" class="group"> 50 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 51 + </span> 52 + </button> 53 + </div> 54 + </div> 55 + </div> 56 + </form> 57 + {{ end }}
+61
appview/pages/templates/repo/issues/fragments/replyComment.html
··· 1 + {{ define "repo/issues/fragments/replyComment" }} 2 + <form 3 + class="p-2 group w-full border-t border-gray-200 dark:border-gray-700 flex flex-col gap-2" 4 + id="reply-form-{{ .Comment.Id }}" 5 + hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment" 6 + hx-on::after-request="if(event.detail.successful) this.reset()" 7 + hx-disabled-elt="#reply-{{ .Comment.Id }}" 8 + > 9 + {{ template "user/fragments/picHandleLink" .LoggedInUser.Did }} 10 + <textarea 11 + id="reply-{{.Comment.Id}}-textarea" 12 + name="body" 13 + class="w-full p-2" 14 + placeholder="Leave a reply..." 15 + autofocus 16 + rows="3" 17 + hx-trigger="keydown[ctrlKey&&key=='Enter']" 18 + hx-target="#reply-form-{{ .Comment.Id }}" 19 + hx-get="#" 20 + hx-on:htmx:before-request="event.preventDefault(); document.getElementById('reply-form-{{ .Comment.Id }}').requestSubmit()"></textarea> 21 + 22 + <input 23 + type="text" 24 + id="reply-to" 25 + name="reply-to" 26 + required 27 + value="{{ .Comment.AtUri }}" 28 + class="hidden" 29 + /> 30 + {{ template "replyActions" . }} 31 + </form> 32 + {{ end }} 33 + 34 + {{ define "replyActions" }} 35 + <div class="flex flex-wrap items-stretch justify-end gap-2 text-gray-500 dark:text-gray-400 text-sm"> 36 + {{ template "cancel" . }} 37 + {{ template "reply" . }} 38 + </div> 39 + {{ end }} 40 + 41 + {{ define "cancel" }} 42 + <button 43 + class="btn text-red-500 dark:text-red-400 flex gap-2 items-center group" 44 + hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/replyPlaceholder" 45 + hx-target="#reply-form-{{ .Comment.Id }}" 46 + hx-swap="outerHTML"> 47 + {{ i "x" "size-4" }} 48 + cancel 49 + </button> 50 + {{ end }} 51 + 52 + {{ define "reply" }} 53 + <button 54 + id="reply-{{ .Comment.Id }}" 55 + type="submit" 56 + class="btn-create flex items-center gap-2 no-underline hover:no-underline"> 57 + {{ i "reply" "w-4 h-4 inline group-[.htmx-request]:hidden" }} 58 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 59 + reply 60 + </button> 61 + {{ end }}
+20
appview/pages/templates/repo/issues/fragments/replyIssueCommentPlaceholder.html
··· 1 + {{ define "repo/issues/fragments/replyIssueCommentPlaceholder" }} 2 + <div class="p-2 border-t flex gap-2 items-center border-gray-300 dark:border-gray-700"> 3 + {{ if .LoggedInUser }} 4 + <img 5 + src="{{ tinyAvatar .LoggedInUser.Did }}" 6 + alt="" 7 + class="rounded-full h-6 w-6 mr-1 border border-gray-300 dark:border-gray-700" 8 + /> 9 + {{ end }} 10 + <input 11 + class="w-full py-2 border-none focus:outline-none" 12 + placeholder="Leave a reply..." 13 + hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment/{{ .Comment.Id }}/reply" 14 + hx-trigger="focus" 15 + hx-target="closest div" 16 + hx-swap="outerHTML" 17 + > 18 + </input> 19 + </div> 20 + {{ end }}
+96 -193
appview/pages/templates/repo/issues/issue.html
··· 4 4 {{ define "extrameta" }} 5 5 {{ $title := printf "%s &middot; issue #%d &middot; %s" .Issue.Title .Issue.IssueId .RepoInfo.FullName }} 6 6 {{ $url := printf "https://tangled.sh/%s/issues/%d" .RepoInfo.FullName .Issue.IssueId }} 7 - 7 + 8 8 {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 9 9 {{ end }} 10 10 11 11 {{ define "repoContent" }} 12 - <header class="pb-4"> 13 - <h1 class="text-2xl"> 14 - {{ .Issue.Title }} 15 - <span class="text-gray-500 dark:text-gray-400">#{{ .Issue.IssueId }}</span> 16 - </h1> 17 - </header> 12 + <section id="issue-{{ .Issue.IssueId }}"> 13 + {{ template "issueHeader" .Issue }} 14 + {{ template "issueInfo" . }} 15 + {{ if .Issue.Body }} 16 + <article id="body" class="mt-4 prose dark:prose-invert">{{ .Issue.Body | markdown }}</article> 17 + {{ end }} 18 + {{ template "issueReactions" . }} 19 + </section> 20 + {{ end }} 18 21 19 - {{ $bgColor := "bg-gray-800 dark:bg-gray-700" }} 20 - {{ $icon := "ban" }} 21 - {{ if eq .State "open" }} 22 - {{ $bgColor = "bg-green-600 dark:bg-green-700" }} 23 - {{ $icon = "circle-dot" }} 24 - {{ end }} 22 + {{ define "issueHeader" }} 23 + <header class="pb-2"> 24 + <h1 class="text-2xl"> 25 + {{ .Title | description }} 26 + <span class="text-gray-500 dark:text-gray-400">#{{ .IssueId }}</span> 27 + </h1> 28 + </header> 29 + {{ end }} 25 30 26 - <section class="mt-2"> 27 - <div class="inline-flex items-center gap-2"> 28 - <div id="state" 29 - class="inline-flex items-center rounded px-3 py-1 {{ $bgColor }}"> 30 - {{ i $icon "w-4 h-4 mr-1.5 text-white" }} 31 - <span class="text-white">{{ .State }}</span> 32 - </div> 33 - <span class="text-gray-500 dark:text-gray-400 text-sm"> 34 - opened by 35 - {{ $owner := didOrHandle .Issue.OwnerDid .IssueOwnerHandle }} 36 - <a href="/{{ $owner }}" class="no-underline hover:underline" 37 - >{{ $owner }}</a 38 - > 39 - <span class="px-1 select-none before:content-['\00B7']"></span> 40 - <time title="{{ .Issue.Created | longTimeFmt }}"> 41 - {{ .Issue.Created | timeFmt }} 42 - </time> 43 - </span> 44 - </div> 31 + {{ define "issueInfo" }} 32 + {{ $bgColor := "bg-gray-800 dark:bg-gray-700" }} 33 + {{ $icon := "ban" }} 34 + {{ if eq .Issue.State "open" }} 35 + {{ $bgColor = "bg-green-600 dark:bg-green-700" }} 36 + {{ $icon = "circle-dot" }} 37 + {{ end }} 38 + <div class="inline-flex items-center gap-2"> 39 + <div id="state" 40 + class="inline-flex items-center rounded px-3 py-1 {{ $bgColor }}"> 41 + {{ i $icon "w-4 h-4 mr-1.5 text-white" }} 42 + <span class="text-white">{{ .Issue.State }}</span> 43 + </div> 44 + <span class="text-gray-500 dark:text-gray-400 text-sm flex flex-wrap items-center gap-1"> 45 + opened by 46 + {{ template "user/fragments/picHandleLink" .Issue.Did }} 47 + <span class="select-none before:content-['\00B7']"></span> 48 + {{ if .Issue.Edited }} 49 + edited {{ template "repo/fragments/time" .Issue.Edited }} 50 + {{ else }} 51 + {{ template "repo/fragments/time" .Issue.Created }} 52 + {{ end }} 53 + </span> 45 54 46 - {{ if .Issue.Body }} 47 - <article id="body" class="mt-8 prose dark:prose-invert"> 48 - {{ .Issue.Body | markdown }} 49 - </article> 50 - {{ end }} 51 - </section> 55 + {{ if and .LoggedInUser (eq .LoggedInUser.Did .Issue.Did) }} 56 + {{ template "issueActions" . }} 57 + {{ end }} 58 + </div> 59 + <div id="issue-actions-error" class="error"></div> 52 60 {{ end }} 53 61 54 - {{ define "repoAfter" }} 55 - <section id="comments" class="my-2 mt-2 space-y-2 relative"> 56 - {{ range $index, $comment := .Comments }} 57 - <div 58 - id="comment-{{ .CommentId }}" 59 - class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-2 px-4 relative w-full md:max-w-3/5 md:w-fit"> 60 - {{ if gt $index 0 }} 61 - <div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div> 62 - {{ end }} 63 - {{ template "repo/issues/fragments/issueComment" (dict "RepoInfo" $.RepoInfo "LoggedInUser" $.LoggedInUser "DidHandleMap" $.DidHandleMap "Issue" $.Issue "Comment" .)}} 64 - </div> 65 - {{ end }} 66 - </section> 62 + {{ define "issueActions" }} 63 + {{ template "editIssue" . }} 64 + {{ template "deleteIssue" . }} 65 + {{ end }} 67 66 68 - {{ block "newComment" . }} {{ end }} 67 + {{ define "editIssue" }} 68 + <a 69 + class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group" 70 + hx-get="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/edit" 71 + hx-swap="innerHTML" 72 + hx-target="#issue-{{.Issue.IssueId}}"> 73 + {{ i "pencil" "size-3" }} 74 + </a> 75 + {{ end }} 69 76 77 + {{ define "deleteIssue" }} 78 + <a 79 + class="text-gray-500 dark:text-gray-400 flex gap-1 items-center group" 80 + hx-delete="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/" 81 + hx-confirm="Are you sure you want to delete your issue?" 82 + hx-swap="none"> 83 + {{ i "trash-2" "size-3" }} 84 + {{ i "loader-circle" "size-3 animate-spin hidden group-[.htmx-request]:inline" }} 85 + </a> 70 86 {{ end }} 71 87 72 - {{ define "newComment" }} 73 - {{ if .LoggedInUser }} 74 - <form 75 - id="comment-form" 76 - hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment" 77 - hx-on::after-request="if(event.detail.successful) this.reset()" 78 - > 79 - <div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-4 px-4 relative w-full md:w-3/5"> 80 - <div class="text-sm pb-2 text-gray-500 dark:text-gray-400"> 81 - {{ didOrHandle .LoggedInUser.Did .LoggedInUser.Handle }} 82 - </div> 83 - <textarea 84 - id="comment-textarea" 85 - name="body" 86 - class="w-full p-2 rounded border border-gray-200 dark:border-gray-700" 87 - placeholder="Add to the discussion. Markdown is supported." 88 - onkeyup="updateCommentForm()" 89 - ></textarea> 90 - <div id="issue-comment"></div> 91 - <div id="issue-action" class="error"></div> 92 - </div> 93 - 94 - <div class="flex gap-2 mt-2"> 95 - <button 96 - id="comment-button" 97 - hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment" 98 - type="submit" 99 - hx-disabled-elt="#comment-button" 100 - class="btn p-2 flex items-center gap-2 no-underline hover:no-underline group" 101 - disabled 102 - > 103 - {{ i "message-square-plus" "w-4 h-4" }} 104 - comment 105 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 106 - </button> 88 + {{ define "issueReactions" }} 89 + <div class="flex items-center gap-2 mt-2"> 90 + {{ template "repo/fragments/reactionsPopUp" .OrderedReactionKinds }} 91 + {{ range $kind := .OrderedReactionKinds }} 92 + {{ 93 + template "repo/fragments/reaction" 94 + (dict 95 + "Kind" $kind 96 + "Count" (index $.Reactions $kind) 97 + "IsReacted" (index $.UserReacted $kind) 98 + "ThreadAt" $.Issue.AtUri) 99 + }} 100 + {{ end }} 101 + </div> 102 + {{ end }} 107 103 108 - {{ $isIssueAuthor := and .LoggedInUser (eq .LoggedInUser.Did .Issue.OwnerDid) }} 109 - {{ $isRepoCollaborator := .RepoInfo.Roles.IsCollaborator }} 110 - {{ $isRepoOwner := .RepoInfo.Roles.IsOwner }} 111 - {{ if and (or $isIssueAuthor $isRepoCollaborator $isRepoOwner) (eq .State "open") }} 112 - <button 113 - id="close-button" 114 - type="button" 115 - class="btn flex items-center gap-2" 116 - hx-indicator="#close-spinner" 117 - hx-trigger="click" 118 - > 119 - {{ i "ban" "w-4 h-4" }} 120 - close 121 - <span id="close-spinner" class="group"> 122 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 123 - </span> 124 - </button> 125 - <div 126 - id="close-with-comment" 127 - hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment" 128 - hx-trigger="click from:#close-button" 129 - hx-disabled-elt="#close-with-comment" 130 - hx-target="#issue-comment" 131 - hx-indicator="#close-spinner" 132 - hx-vals="js:{body: document.getElementById('comment-textarea').value.trim() !== '' ? document.getElementById('comment-textarea').value : ''}" 133 - hx-swap="none" 134 - > 135 - </div> 136 - <div 137 - id="close-issue" 138 - hx-disabled-elt="#close-issue" 139 - hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/close" 140 - hx-trigger="click from:#close-button" 141 - hx-target="#issue-action" 142 - hx-indicator="#close-spinner" 143 - hx-swap="none" 144 - > 145 - </div> 146 - <script> 147 - document.addEventListener('htmx:configRequest', function(evt) { 148 - if (evt.target.id === 'close-with-comment') { 149 - const commentText = document.getElementById('comment-textarea').value.trim(); 150 - if (commentText === '') { 151 - evt.detail.parameters = {}; 152 - evt.preventDefault(); 153 - } 154 - } 155 - }); 156 - </script> 157 - {{ else if and (or $isIssueAuthor $isRepoCollaborator $isRepoOwner) (eq .State "closed") }} 158 - <button 159 - type="button" 160 - class="btn flex items-center gap-2" 161 - hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/reopen" 162 - hx-indicator="#reopen-spinner" 163 - hx-swap="none" 164 - > 165 - {{ i "refresh-ccw-dot" "w-4 h-4" }} 166 - reopen 167 - <span id="reopen-spinner" class="group"> 168 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 169 - </span> 170 - </button> 171 - {{ end }} 172 - 173 - <script> 174 - function updateCommentForm() { 175 - const textarea = document.getElementById('comment-textarea'); 176 - const commentButton = document.getElementById('comment-button'); 177 - const closeButton = document.getElementById('close-button'); 178 - 179 - if (textarea.value.trim() !== '') { 180 - commentButton.removeAttribute('disabled'); 181 - } else { 182 - commentButton.setAttribute('disabled', ''); 183 - } 184 - 185 - if (closeButton) { 186 - if (textarea.value.trim() !== '') { 187 - closeButton.innerHTML = ` 188 - {{ i "ban" "w-4 h-4" }} 189 - <span>close with comment</span> 190 - <span id="close-spinner" class="group"> 191 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 192 - </span>`; 193 - } else { 194 - closeButton.innerHTML = ` 195 - {{ i "ban" "w-4 h-4" }} 196 - <span>close</span> 197 - <span id="close-spinner" class="group"> 198 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 199 - </span>`; 200 - } 201 - } 202 - } 104 + {{ define "repoAfter" }} 105 + <div class="flex flex-col gap-4 mt-4"> 106 + {{ 107 + template "repo/issues/fragments/commentList" 108 + (dict 109 + "RepoInfo" $.RepoInfo 110 + "LoggedInUser" $.LoggedInUser 111 + "Issue" $.Issue 112 + "CommentList" $.Issue.CommentList) 113 + }} 203 114 204 - document.addEventListener('DOMContentLoaded', function() { 205 - updateCommentForm(); 206 - }); 207 - </script> 208 - </div> 209 - </form> 210 - {{ else }} 211 - <div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-4 px-4 relative w-fit"> 212 - <a href="/login" class="underline">login</a> to join the discussion 213 - </div> 214 - {{ end }} 115 + {{ template "repo/issues/fragments/newComment" . }} 116 + <div> 215 117 {{ end }} 118 +
+44 -49
appview/pages/templates/repo/issues/issues.html
··· 3 3 {{ define "extrameta" }} 4 4 {{ $title := "issues"}} 5 5 {{ $url := printf "https://tangled.sh/%s/issues" .RepoInfo.FullName }} 6 - 6 + 7 7 {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 8 8 {{ end }} 9 9 ··· 27 27 </div> 28 28 <a 29 29 href="/{{ .RepoInfo.FullName }}/issues/new" 30 - class="btn text-sm flex items-center justify-center gap-2 no-underline hover:no-underline" 30 + class="btn-create text-sm flex items-center justify-center gap-2 no-underline hover:no-underline hover:text-white" 31 31 > 32 32 {{ i "circle-plus" "w-4 h-4" }} 33 33 <span>new</span> ··· 37 37 {{ end }} 38 38 39 39 {{ define "repoAfter" }} 40 - <div class="flex flex-col gap-2 mt-2"> 41 - {{ range .Issues }} 42 - <div class="rounded drop-shadow-sm bg-white px-6 py-4 dark:bg-gray-800 dark:border-gray-700"> 43 - <div class="pb-2"> 44 - <a 45 - href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}" 46 - class="no-underline hover:underline" 47 - > 48 - {{ .Title }} 49 - <span class="text-gray-500">#{{ .IssueId }}</span> 50 - </a> 51 - </div> 52 - <p class="text-sm text-gray-500 dark:text-gray-400"> 53 - {{ $bgColor := "bg-gray-800 dark:bg-gray-700" }} 54 - {{ $icon := "ban" }} 55 - {{ $state := "closed" }} 56 - {{ if .Open }} 57 - {{ $bgColor = "bg-green-600 dark:bg-green-700" }} 58 - {{ $icon = "circle-dot" }} 59 - {{ $state = "open" }} 60 - {{ end }} 40 + <div class="flex flex-col gap-2 mt-2"> 41 + {{ range .Issues }} 42 + <div class="rounded drop-shadow-sm bg-white px-6 py-4 dark:bg-gray-800 dark:border-gray-700"> 43 + <div class="pb-2"> 44 + <a 45 + href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}" 46 + class="no-underline hover:underline" 47 + > 48 + {{ .Title | description }} 49 + <span class="text-gray-500">#{{ .IssueId }}</span> 50 + </a> 51 + </div> 52 + <p class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1"> 53 + {{ $bgColor := "bg-gray-800 dark:bg-gray-700" }} 54 + {{ $icon := "ban" }} 55 + {{ $state := "closed" }} 56 + {{ if .Open }} 57 + {{ $bgColor = "bg-green-600 dark:bg-green-700" }} 58 + {{ $icon = "circle-dot" }} 59 + {{ $state = "open" }} 60 + {{ end }} 61 61 62 - <span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }} text-sm"> 63 - {{ i $icon "w-3 h-3 mr-1.5 text-white dark:text-white" }} 64 - <span class="text-white dark:text-white">{{ $state }}</span> 65 - </span> 62 + <span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }} text-sm"> 63 + {{ i $icon "w-3 h-3 mr-1.5 text-white dark:text-white" }} 64 + <span class="text-white dark:text-white">{{ $state }}</span> 65 + </span> 66 66 67 - <span> 68 - {{ $owner := index $.DidHandleMap .OwnerDid }} 69 - <a href="/{{ $owner }}">{{ $owner }}</a> 70 - </span> 67 + <span class="ml-1"> 68 + {{ template "user/fragments/picHandleLink" .Did }} 69 + </span> 71 70 72 - <span class="before:content-['ยท']"> 73 - <time> 74 - {{ .Created | timeFmt }} 75 - </time> 76 - </span> 71 + <span class="before:content-['ยท']"> 72 + {{ template "repo/fragments/time" .Created }} 73 + </span> 77 74 78 - <span class="before:content-['ยท']"> 79 - {{ $s := "s" }} 80 - {{ if eq .Metadata.CommentCount 1 }} 81 - {{ $s = "" }} 82 - {{ end }} 83 - <a href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}" class="text-gray-500 dark:text-gray-400">{{ .Metadata.CommentCount }} comment{{$s}}</a> 84 - </span> 85 - </p> 75 + <span class="before:content-['ยท']"> 76 + {{ $s := "s" }} 77 + {{ if eq (len .Comments) 1 }} 78 + {{ $s = "" }} 79 + {{ end }} 80 + <a href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}" class="text-gray-500 dark:text-gray-400">{{ len .Comments }} comment{{$s}}</a> 81 + </span> 82 + </p> 83 + </div> 84 + {{ end }} 86 85 </div> 87 - {{ end }} 88 - </div> 89 - 90 - {{ block "pagination" . }} {{ end }} 91 - 86 + {{ block "pagination" . }} {{ end }} 92 87 {{ end }} 93 88 94 89 {{ define "pagination" }}
+1 -32
appview/pages/templates/repo/issues/new.html
··· 1 1 {{ define "title" }}new issue &middot; {{ .RepoInfo.FullName }}{{ end }} 2 2 3 3 {{ define "repoContent" }} 4 - <form 5 - hx-post="/{{ .RepoInfo.FullName }}/issues/new" 6 - class="mt-6 space-y-6" 7 - hx-swap="none" 8 - hx-indicator="#spinner" 9 - > 10 - <div class="flex flex-col gap-4"> 11 - <div> 12 - <label for="title">title</label> 13 - <input type="text" name="title" id="title" class="w-full" /> 14 - </div> 15 - <div> 16 - <label for="body">body</label> 17 - <textarea 18 - name="body" 19 - id="body" 20 - rows="6" 21 - class="w-full resize-y" 22 - placeholder="Describe your issue. Markdown is supported." 23 - ></textarea> 24 - </div> 25 - <div> 26 - <button type="submit" class="btn flex items-center gap-2"> 27 - create 28 - <span id="spinner" class="group"> 29 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 30 - </span> 31 - </button> 32 - </div> 33 - </div> 34 - <div id="issues" class="error"></div> 35 - </form> 4 + {{ template "repo/issues/fragments/putIssue" . }} 36 5 {{ end }}
+77 -80
appview/pages/templates/repo/log.html
··· 14 14 </h2> 15 15 16 16 <!-- desktop view (hidden on small screens) --> 17 - <table class="w-full border-collapse hidden md:table"> 18 - <thead> 19 - <tr> 20 - <th class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold">Author</th> 21 - <th class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold">Commit</th> 22 - <th class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold">Message</th> 23 - <th class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold"></th> 24 - <th class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold">Date</th> 25 - </tr> 26 - </thead> 27 - <tbody> 28 - {{ range $index, $commit := .Commits }} 29 - {{ $messageParts := splitN $commit.Message "\n\n" 2 }} 30 - <tr class="{{ if ne $index (sub (len $.Commits) 1) }}border-b border-gray-200 dark:border-gray-700{{ end }}"> 31 - <td class=" py-3 align-top"> 32 - {{ $didOrHandle := index $.EmailToDidOrHandle $commit.Author.Email }} 33 - {{ if $didOrHandle }} 34 - <a href="/{{ $didOrHandle }}" class="text-gray-700 dark:text-gray-300 no-underline hover:underline">{{ $didOrHandle }}</a> 35 - {{ else }} 36 - <a href="mailto:{{ $commit.Author.Email }}" class="text-gray-700 dark:text-gray-300 no-underline hover:underline">{{ $commit.Author.Name }}</a> 37 - {{ end }} 38 - </td> 39 - <td class="py-3 align-top font-mono flex items-center"> 40 - {{ $verified := $.VerifiedCommits.IsVerified $commit.Hash.String }} 41 - {{ $hashStyle := "text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-900" }} 42 - {{ if $verified }} 43 - {{ $hashStyle = "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 px-2 rounded" }} 44 - {{ end }} 45 - <a href="/{{ $.RepoInfo.FullName }}/commit/{{ $commit.Hash.String }}" class="no-underline hover:underline {{ $hashStyle }} px-2 py-1/2 rounded flex items-center gap-2"> 46 - {{ slice $commit.Hash.String 0 8 }} 47 - {{ if $verified }} 48 - {{ i "shield-check" "w-4 h-4" }} 49 - {{ end }} 50 - </a> 51 - <div class="{{ if not $verified }} ml-6 {{ end }}inline-flex"> 52 - <button class="p-1 mx-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded" 53 - title="Copy SHA" 54 - onclick="navigator.clipboard.writeText('{{ $commit.Hash.String }}'); this.innerHTML=`{{ i "copy-check" "w-4 h-4" }}`; setTimeout(() => this.innerHTML=`{{ i "copy" "w-4 h-4" }}`, 1500)"> 55 - {{ i "copy" "w-4 h-4" }} 56 - </button> 57 - <a href="/{{ $.RepoInfo.FullName }}/tree/{{ $commit.Hash.String }}" class="p-1 mx-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded" title="Browse repository at this commit"> 58 - {{ i "folder-code" "w-4 h-4" }} 59 - </a> 60 - </div> 17 + <div class="hidden md:flex md:flex-col divide-y divide-gray-200 dark:divide-gray-700"> 18 + {{ $grid := "grid grid-cols-14 gap-4" }} 19 + <div class="{{ $grid }}"> 20 + <div class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold col-span-2">Author</div> 21 + <div class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold col-span-3">Commit</div> 22 + <div class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold col-span-6">Message</div> 23 + <div class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold col-span-1"></div> 24 + <div class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold col-span-2 justify-self-end">Date</div> 25 + </div> 26 + {{ range $index, $commit := .Commits }} 27 + {{ $messageParts := splitN $commit.Message "\n\n" 2 }} 28 + <div class="{{ $grid }} py-3"> 29 + <div class="align-top truncate col-span-2"> 30 + {{ $didOrHandle := index $.EmailToDidOrHandle $commit.Author.Email }} 31 + {{ if $didOrHandle }} 32 + {{ template "user/fragments/picHandleLink" $didOrHandle }} 33 + {{ else }} 34 + <a href="mailto:{{ $commit.Author.Email }}" class="text-gray-700 dark:text-gray-300 no-underline hover:underline">{{ $commit.Author.Name }}</a> 35 + {{ end }} 36 + </div> 37 + <div class="align-top font-mono flex items-start col-span-3"> 38 + {{ $verified := $.VerifiedCommits.IsVerified $commit.Hash.String }} 39 + {{ $hashStyle := "text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-900" }} 40 + {{ if $verified }} 41 + {{ $hashStyle = "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 px-2 rounded" }} 42 + {{ end }} 43 + <a href="/{{ $.RepoInfo.FullName }}/commit/{{ $commit.Hash.String }}" class="no-underline hover:underline {{ $hashStyle }} px-2 py-1/2 rounded flex items-center gap-2"> 44 + {{ slice $commit.Hash.String 0 8 }} 45 + {{ if $verified }} 46 + {{ i "shield-check" "w-4 h-4" }} 47 + {{ end }} 48 + </a> 49 + <div class="{{ if not $verified }} ml-6 {{ end }}inline-flex"> 50 + <button class="p-1 mx-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded" 51 + title="Copy SHA" 52 + onclick="navigator.clipboard.writeText('{{ $commit.Hash.String }}'); this.innerHTML=`{{ i "copy-check" "w-4 h-4" }}`; setTimeout(() => this.innerHTML=`{{ i "copy" "w-4 h-4" }}`, 1500)"> 53 + {{ i "copy" "w-4 h-4" }} 54 + </button> 55 + <a href="/{{ $.RepoInfo.FullName }}/tree/{{ $commit.Hash.String }}" class="p-1 mx-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded" title="Browse repository at this commit"> 56 + {{ i "folder-code" "w-4 h-4" }} 57 + </a> 58 + </div> 61 59 62 - </td> 63 - <td class=" py-3 align-top"> 64 - <div class="flex items-center justify-start gap-2"> 65 - <a href="/{{ $.RepoInfo.FullName }}/commit/{{ $commit.Hash.String }}" class="dark:text-white no-underline hover:underline">{{ index $messageParts 0 }}</a> 66 - {{ if gt (len $messageParts) 1 }} 67 - <button class="py-1/2 px-1 bg-gray-200 hover:bg-gray-400 dark:bg-gray-700 dark:hover:bg-gray-600 rounded" hx-on:click="this.parentElement.nextElementSibling.classList.toggle('hidden')">{{ i "ellipsis" "w-3 h-3" }}</button> 68 - {{ end }} 60 + </div> 61 + <div class="align-top col-span-6"> 62 + <div> 63 + <a href="/{{ $.RepoInfo.FullName }}/commit/{{ $commit.Hash.String }}" class="dark:text-white no-underline hover:underline">{{ index $messageParts 0 }}</a> 64 + {{ if gt (len $messageParts) 1 }} 65 + <button class="py-1/2 px-1 bg-gray-200 hover:bg-gray-400 dark:bg-gray-700 dark:hover:bg-gray-600 rounded" hx-on:click="this.parentElement.nextElementSibling.classList.toggle('hidden')">{{ i "ellipsis" "w-3 h-3" }}</button> 66 + {{ end }} 69 67 70 - {{ if index $.TagMap $commit.Hash.String }} 71 - {{ range $tag := index $.TagMap $commit.Hash.String }} 72 - <span class="ml-2 text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 inline-flex items-center"> 73 - {{ $tag }} 74 - </span> 75 - {{ end }} 76 - {{ end }} 77 - </div> 78 - 79 - {{ if gt (len $messageParts) 1 }} 80 - <p class="hidden mt-1 text-sm text-gray-600 dark:text-gray-400">{{ nl2br (index $messageParts 1) }}</p> 81 - {{ end }} 82 - </td> 83 - <td class="py-3 align-top"> 84 - <!-- ci status --> 85 - {{ $pipeline := index $.Pipelines .Hash.String }} 86 - {{ if and $pipeline (gt (len $pipeline.Statuses) 0) }} 87 - {{ template "repo/pipelines/fragments/pipelineSymbolLong" $pipeline }} 88 - {{ end }} 89 - </td> 90 - <td class=" py-3 align-top text-gray-500 dark:text-gray-400">{{ timeFmt $commit.Committer.When }}</td> 91 - </tr> 68 + {{ if index $.TagMap $commit.Hash.String }} 69 + {{ range $tag := index $.TagMap $commit.Hash.String }} 70 + <span class="ml-2 text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 inline-flex items-center"> 71 + {{ $tag }} 72 + </span> 73 + {{ end }} 92 74 {{ end }} 93 - </tbody> 94 - </table> 75 + </div> 76 + 77 + {{ if gt (len $messageParts) 1 }} 78 + <p class="hidden mt-1 text-sm text-gray-600 dark:text-gray-400">{{ nl2br (index $messageParts 1) }}</p> 79 + {{ end }} 80 + </div> 81 + <div class="align-top col-span-1"> 82 + <!-- ci status --> 83 + {{ $pipeline := index $.Pipelines .Hash.String }} 84 + {{ if and $pipeline (gt (len $pipeline.Statuses) 0) }} 85 + {{ template "repo/pipelines/fragments/pipelineSymbolLong" (dict "Pipeline" $pipeline "RepoInfo" $.RepoInfo) }} 86 + {{ end }} 87 + </div> 88 + <div class="align-top justify-self-end text-gray-500 dark:text-gray-400 col-span-2">{{ template "repo/fragments/shortTimeAgo" $commit.Committer.When }}</div> 89 + </div> 90 + {{ end }} 91 + </div> 95 92 96 93 <!-- mobile view (visible only on small screens) --> 97 94 <div class="md:hidden"> ··· 102 99 <div class="text-base cursor-pointer"> 103 100 <div class="flex items-center justify-between"> 104 101 <div class="flex-1"> 105 - <div class="inline-flex items-end"> 102 + <div> 106 103 <a href="/{{ $.RepoInfo.FullName }}/commit/{{ $commit.Hash.String }}" 107 104 class="inline no-underline hover:underline dark:text-white"> 108 105 {{ index $messageParts 0 }} 109 106 </a> 110 107 {{ if gt (len $messageParts) 1 }} 111 108 <button 112 - class="py-1/2 px-1 bg-gray-200 hover:bg-gray-400 rounded dark:bg-gray-700 dark:hover:bg-gray-600 ml-2" 109 + class="py-1/2 px-1 bg-gray-200 hover:bg-gray-400 rounded dark:bg-gray-700 dark:hover:bg-gray-600" 113 110 hx-on:click="this.parentElement.nextElementSibling.classList.toggle('hidden')"> 114 111 {{ i "ellipsis" "w-3 h-3" }} 115 112 </button> ··· 159 156 {{ $didOrHandle := index $.EmailToDidOrHandle $commit.Author.Email }} 160 157 <a href="{{ if $didOrHandle }}/{{ $didOrHandle }}{{ else }}mailto:{{ $commit.Author.Email }}{{ end }}" 161 158 class="text-gray-500 dark:text-gray-400 no-underline hover:underline"> 162 - {{ if $didOrHandle }}{{ $didOrHandle }}{{ else }}{{ $commit.Author.Name }}{{ end }} 159 + {{ if $didOrHandle }}{{ template "user/fragments/picHandleLink" $didOrHandle }}{{ else }}{{ $commit.Author.Name }}{{ end }} 163 160 </a> 164 161 </span> 165 162 <div class="inline-block px-1 select-none after:content-['ยท']"></div> 166 - <span>{{ shortTimeFmt $commit.Committer.When }}</span> 163 + <span>{{ template "repo/fragments/shortTime" $commit.Committer.When }}</span> 167 164 168 165 <!-- ci status --> 169 166 {{ $pipeline := index $.Pipelines .Hash.String }} 170 167 {{ if and $pipeline (gt (len $pipeline.Statuses) 0) }} 171 168 <div class="inline-block px-1 select-none after:content-['ยท']"></div> 172 169 <span class="text-sm"> 173 - {{ template "repo/pipelines/fragments/pipelineSymbolLong" $pipeline }} 170 + {{ template "repo/pipelines/fragments/pipelineSymbolLong" (dict "Pipeline" $pipeline "RepoInfo" $.RepoInfo) }} 174 171 </span> 175 172 {{ end }} 176 173 </div>
+60
appview/pages/templates/repo/needsUpgrade.html
··· 1 + {{ define "title" }}{{ .RepoInfo.FullName }}{{ end }} 2 + {{ define "extrameta" }} 3 + {{ template "repo/fragments/meta" . }} 4 + {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo) }} 5 + {{ end }} 6 + {{ define "repoContent" }} 7 + <main> 8 + <div class="relative w-full h-96 flex items-center justify-center"> 9 + <div class="w-full h-full grid grid-cols-1 md:grid-cols-2 gap-4 md:divide-x divide-gray-300 dark:divide-gray-600 text-gray-300 dark:text-gray-600"> 10 + <!-- mimic the repo view here, placeholders are LLM generated --> 11 + <div id="file-list" class="flex flex-col gap-2 col-span-1 w-full h-full p-4 items-start justify-start text-left"> 12 + {{ $files := 13 + (list 14 + "src" 15 + "docs" 16 + "config" 17 + "lib" 18 + "index.html" 19 + "log.html" 20 + "needsUpgrade.html" 21 + "new.html" 22 + "tags.html" 23 + "tree.html") 24 + }} 25 + {{ range $files }} 26 + <span> 27 + {{ if (contains . ".") }} 28 + {{ i "file" "size-4 inline-flex" }} 29 + {{ else }} 30 + {{ i "folder" "size-4 inline-flex fill-current" }} 31 + {{ end }} 32 + 33 + {{ . }} 34 + </span> 35 + {{ end }} 36 + </div> 37 + <div id="commit-list" class="hidden md:flex md:flex-col gap-4 col-span-1 w-full h-full p-4 items-start justify-start text-left"> 38 + {{ $commits := 39 + (list 40 + "Fix authentication bug in login flow" 41 + "Add new dashboard widgets for metrics" 42 + "Implement real-time notifications system") 43 + }} 44 + {{ range $commits }} 45 + <div class="flex flex-col"> 46 + <span>{{ . }}</span> 47 + <span class="text-xs">{{ . }}</span> 48 + </div> 49 + {{ end }} 50 + </div> 51 + </div> 52 + <div class="absolute inset-0 flex items-center justify-center py-12 text-red-500 dark:text-red-400 backdrop-blur"> 53 + <div class="text-center"> 54 + {{ i "triangle-alert" "size-5 inline-flex items-center align-middle" }} 55 + The knot hosting this repository needs an upgrade. This repository is currently unavailable. 56 + </div> 57 + </div> 58 + </div> 59 + </main> 60 + {{ end }}
+9 -8
appview/pages/templates/repo/new.html
··· 49 49 class="mr-2" 50 50 id="domain-{{ . }}" 51 51 /> 52 - <span class="dark:text-white">{{ . }}</span> 52 + <label for="domain-{{ . }}" class="dark:text-white">{{ . }}</label> 53 53 </div> 54 54 {{ else }} 55 55 <p class="dark:text-white">No knots available.</p> ··· 60 60 </fieldset> 61 61 62 62 <div class="space-y-2"> 63 - <button type="submit" class="btn flex items-center"> 64 - create repo 65 - <span id="spinner" class="group"> 66 - {{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 67 - </span> 68 - </button> 69 - <div id="repo" class="error"></div> 63 + <button type="submit" class="btn-create flex items-center gap-2"> 64 + {{ i "book-plus" "w-4 h-4" }} 65 + create repo 66 + <span id="spinner" class="group"> 67 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 68 + </span> 69 + </button> 70 + <div id="repo" class="error"></div> 70 71 </div> 71 72 </form> 72 73 </div>
+15
appview/pages/templates/repo/pipelines/fragments/logBlock.html
··· 1 + {{ define "repo/pipelines/fragments/logBlock" }} 2 + <div id="lines" hx-swap-oob="beforeend"> 3 + <details id="step-{{ .Id }}" {{if not .Collapsed}}open{{end}} class="group pb-2 rounded-sm border border-gray-200 dark:border-gray-700"> 4 + <summary class="sticky top-0 pt-2 px-2 group-open:pb-2 group-open:mb-2 list-none cursor-pointer group-open:border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:text-gray-500 hover:dark:text-gray-400"> 5 + <div class="group-open:hidden flex items-center gap-1"> 6 + {{ i "chevron-right" "w-4 h-4" }} {{ .Name }} 7 + </div> 8 + <div class="hidden group-open:flex items-center gap-1"> 9 + {{ i "chevron-down" "w-4 h-4" }} {{ .Name }} 10 + </div> 11 + </summary> 12 + <div class="font-mono whitespace-pre overflow-x-auto px-2"><div class="text-blue-600 dark:text-blue-300">{{ .Command }}</div><div id="step-body-{{ .Id }}"></div></div> 13 + </details> 14 + </div> 15 + {{ end }}
+4
appview/pages/templates/repo/pipelines/fragments/logLine.html
··· 1 + {{ define "repo/pipelines/fragments/logLine" }} 2 + <div id="step-body-{{ .Id }}" hx-swap-oob="beforeend" class="whitespace-pre"><p>{{ .Content }}</p></div> 3 + {{ end }} 4 +
+28 -7
appview/pages/templates/repo/pipelines/fragments/pipelineSymbol.html
··· 4 4 {{ $statuses := .Statuses }} 5 5 {{ $total := len $statuses }} 6 6 {{ $success := index $c "success" }} 7 + {{ $fail := index $c "failed" }} 8 + {{ $timeout := index $c "timeout" }} 9 + {{ $empty := eq $total 0 }} 7 10 {{ $allPass := eq $success $total }} 11 + {{ $allFail := eq $fail $total }} 12 + {{ $allTimeout := eq $timeout $total }} 8 13 9 - {{ if $allPass }} 14 + {{ if $empty }} 10 15 <div class="flex gap-1 items-center"> 11 - {{ i "check" "size-4 text-green-600 dark:text-green-400 " }} 16 + {{ i "hourglass" "size-4 text-gray-600 dark:text-gray-400 " }} 17 + <span>0/{{ $total }}</span> 18 + </div> 19 + {{ else if $allPass }} 20 + <div class="flex gap-1 items-center"> 21 + {{ i "check" "size-4 text-green-600" }} 12 22 <span>{{ $total }}/{{ $total }}</span> 13 23 </div> 24 + {{ else if $allFail }} 25 + <div class="flex gap-1 items-center"> 26 + {{ i "x" "size-4 text-red-500" }} 27 + <span>0/{{ $total }}</span> 28 + </div> 29 + {{ else if $allTimeout }} 30 + <div class="flex gap-1 items-center"> 31 + {{ i "clock-alert" "size-4 text-orange-500" }} 32 + <span>0/{{ $total }}</span> 33 + </div> 14 34 {{ else }} 15 35 {{ $radius := f64 8 }} 16 36 {{ $circumference := mulf64 2.0 (mulf64 3.1416 $radius) }} ··· 22 42 {{ range $kind, $count := $c }} 23 43 {{ $color := "" }} 24 44 {{ if or (eq $kind "pending") (eq $kind "running") }} 25 - {{ $color = "#eab308" }} 45 + {{ $color = "#eab308" }} {{/* amber-500 */}} 26 46 {{ else if eq $kind "success" }} 27 - {{ $color = "#10b981" }} 47 + {{ $color = "#10b981" }} {{/* green-500 */}} 28 48 {{ else if eq $kind "cancelled" }} 29 - {{ $color = "#6b7280" }} 49 + {{ $color = "#6b7280" }} {{/* gray-500 */}} 50 + {{ else if eq $kind "timeout" }} 51 + {{ $color = "#fb923c" }} {{/* orange-400 */}} 30 52 {{ else }} 31 - {{ $color = "#ef4444" }} 53 + {{ $color = "#ef4444" }} {{/* red-500 for failed or unknown */}} 32 54 {{ end }} 33 55 34 56 {{ $percent := divf64 (f64 $count) (f64 $total) }} ··· 50 72 {{ end }} 51 73 </div> 52 74 {{ end }} 53 -
+9 -5
appview/pages/templates/repo/pipelines/fragments/pipelineSymbolLong.html
··· 1 1 {{ define "repo/pipelines/fragments/pipelineSymbolLong" }} 2 - <div class="group relative inline-block"> 3 - {{ template "repo/pipelines/fragments/pipelineSymbol" $ }} 4 - {{ template "repo/pipelines/fragments/tooltip" $ }} 2 + {{ $pipeline := .Pipeline }} 3 + {{ $repoinfo := .RepoInfo }} 4 + <div class="relative inline-block"> 5 + <details class="relative"> 6 + <summary class="cursor-pointer list-none"> 7 + {{ template "repo/pipelines/fragments/pipelineSymbol" .Pipeline }} 8 + </summary> 9 + {{ template "repo/pipelines/fragments/tooltip" $ }} 10 + </details> 5 11 </div> 6 12 {{ end }} 7 - 8 -
+26 -21
appview/pages/templates/repo/pipelines/fragments/tooltip.html
··· 1 1 {{ define "repo/pipelines/fragments/tooltip" }} 2 - <div class="absolute z-[9999] hidden group-hover:block bg-white dark:bg-gray-900 text-black dark:text-white rounded-md shadow w-80 top-full mt-2"> 2 + {{ $repoinfo := .RepoInfo }} 3 + {{ $pipeline := .Pipeline }} 4 + {{ $id := $pipeline.Id }} 5 + <div class="absolute z-[9999] bg-white dark:bg-gray-900 text-black dark:text-white rounded shadow-sm w-80 top-full mt-2 p-2"> 3 6 <div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700"> 4 - {{ range $name, $all := .Statuses }} 5 - <div class="flex items-center justify-between p-2"> 6 - {{ $lastStatus := $all.Latest }} 7 - {{ $kind := $lastStatus.Status.String }} 7 + {{ range $name, $all := $pipeline.Statuses }} 8 + <a href="/{{ $repoinfo.FullName }}/pipelines/{{ $id }}/workflow/{{ $name }}" class="hover:no-underline"> 9 + <div class="flex items-center justify-between p-2"> 10 + {{ $lastStatus := $all.Latest }} 11 + {{ $kind := $lastStatus.Status.String }} 8 12 9 - {{ $t := .TimeTaken }} 10 - {{ $time := "" }} 11 - {{ if $t }} 12 - {{ $time = durationFmt $t }} 13 - {{ else }} 14 - {{ $time = printf "%s ago" (shortTimeFmt $.Created) }} 15 - {{ end }} 16 - 17 - <div id="left" class="flex items-center gap-2 flex-shrink-0"> 18 - {{ template "repo/pipelines/fragments/workflowSymbol" $all }} 19 - {{ $name }} 13 + <div id="left" class="flex items-center gap-2 flex-shrink-0"> 14 + {{ template "repo/pipelines/fragments/workflowSymbol" $all }} 15 + {{ $name }} 16 + </div> 17 + <div id="right" class="flex items-center gap-2 flex-shrink-0"> 18 + <span class="font-bold">{{ $kind }}</span> 19 + {{ if .TimeTaken }} 20 + {{ template "repo/fragments/duration" .TimeTaken }} 21 + {{ else }} 22 + {{ template "repo/fragments/shortTimeAgo" $pipeline.Created }} 23 + {{ end }} 24 + </div> 20 25 </div> 21 - <div id="right" class="flex items-center gap-2 flex-shrink-0"> 22 - <span class="font-bold">{{ $kind }}</span> 23 - <time>{{ $time }}</time> 24 - </div> 26 + </a> 27 + {{ else }} 28 + <div class="flex items-center gap-2 p-2 italic text-gray-600 dark:text-gray-400 "> 29 + {{ i "hourglass" "size-4" }} 30 + Waiting for spindle ... 25 31 </div> 26 32 {{ end }} 27 33 </div> 28 34 </div> 29 35 {{ end }} 30 -
+3
appview/pages/templates/repo/pipelines/fragments/workflowSymbol.html
··· 17 17 {{ else if eq $kind "cancelled" }} 18 18 {{ $icon = "circle-slash" }} 19 19 {{ $color = "text-gray-600 dark:text-gray-500" }} 20 + {{ else if eq $kind "timeout" }} 21 + {{ $icon = "clock-alert" }} 22 + {{ $color = "text-orange-400 dark:text-orange-500" }} 20 23 {{ else }} 21 24 {{ $icon = "x" }} 22 25 {{ $color = "text-red-600 dark:text-red-500" }}
+41 -32
appview/pages/templates/repo/pipelines/pipelines.html
··· 8 8 9 9 {{ define "repoContent" }} 10 10 <div class="flex justify-between items-center gap-4"> 11 - <div class="flex gap-4"> 12 - </div> 13 - 14 - </div> 15 - <div class="error" id="issues"></div> 16 - {{ end }} 17 - 18 - {{ define "repoAfter" }} 19 - <section 20 - class="w-full flex flex-col gap-2 mt-2" 21 - > 11 + <div class="w-full flex flex-col gap-2"> 22 12 {{ range .Pipelines }} 23 13 {{ block "pipeline" (list $ .) }} {{ end }} 24 14 {{ else }} ··· 26 16 No pipelines run for this repository. 27 17 </p> 28 18 {{ end }} 29 - </section> 19 + </div> 20 + </div> 30 21 {{ end }} 31 22 23 + 32 24 {{ define "pipeline" }} 33 25 {{ $root := index . 0 }} 34 26 {{ $p := index . 1 }} 35 - <div class="py-4 px-6 bg-white dark:bg-gray-800 dark:text-white"> 27 + <div class="py-2 bg-white dark:bg-gray-800 dark:text-white"> 36 28 {{ block "pipelineHeader" $ }} {{ end }} 37 29 </div> 38 30 {{ end }} ··· 41 33 {{ $root := index . 0 }} 42 34 {{ $p := index . 1 }} 43 35 {{ with $p }} 44 - <div class="grid grid-cols-4 md:grid-cols-8 gap-2 items-center w-full"> 45 - <div class="col-span-1 md:col-span-5 flex items-center gap-4"> 36 + <div class="grid grid-cols-6 md:grid-cols-12 gap-2 items-center w-full"> 37 + <div class="text-sm md:text-base col-span-1"> 38 + {{ .Trigger.Kind.String }} 39 + </div> 40 + 41 + <div class="col-span-2 md:col-span-7 flex items-center gap-4"> 46 42 {{ $target := .Trigger.TargetRef }} 47 43 {{ $workflows := .Workflows }} 44 + {{ $link := "" }} 45 + {{ if .IsResponding }} 46 + {{ $link = printf "/%s/pipelines/%s/workflow/%d" $root.RepoInfo.FullName .Id (index $workflows 0) }} 47 + {{ end }} 48 48 {{ if .Trigger.IsPush }} 49 - <a href="/{{ $root.RepoInfo.FullName }}/pipelines/{{ .Id }}/workflow/{{ index $workflows 0 }}" class="block"> 50 - <span class="font-bold">{{ $target }}</span> 51 - <span>push</span> 52 - </a> 49 + <span class="font-bold">{{ $target }}</span> 53 50 <span class="hidden md:inline-flex gap-2 items-center font-mono text-sm"> 54 51 {{ $old := deref .Trigger.PushOldSha }} 55 52 {{ $new := deref .Trigger.PushNewSha }} ··· 59 56 <a href="/{{ $root.RepoInfo.FullName }}/commit/{{ $old }}">{{ slice $old 0 8 }}</a> 60 57 </span> 61 58 {{ else if .Trigger.IsPullRequest }} 62 - <span> 63 - pull request 64 - <span class="inline-flex gap-2 items-center"> 65 - {{ $target }} 66 - {{ i "arrow-left" "size-4" }} 67 - {{ .Trigger.PRSourceBranch }} 59 + {{ $sha := deref .Trigger.PRSourceSha }} 60 + <span class="inline-flex gap-2 items-center"> 61 + <span class="font-bold">{{ $target }}</span> 62 + {{ i "arrow-left" "size-4" }} 63 + {{ .Trigger.PRSourceBranch }} 64 + <span class="text-sm font-mono"> 65 + @ 66 + <a href="/{{ $root.RepoInfo.FullName }}/commit/{{ $sha }}">{{ slice $sha 0 8 }}</a> 68 67 </span> 69 68 </span> 70 69 {{ end }} 71 70 </div> 72 71 73 - <div class="col-span-1 pl-4"> 74 - {{ template "repo/pipelines/fragments/pipelineSymbolLong" . }} 72 + <div class="text-sm md:text-base col-span-1"> 73 + {{ template "repo/pipelines/fragments/pipelineSymbolLong" (dict "Pipeline" . "RepoInfo" $root.RepoInfo) }} 75 74 </div> 76 75 77 - <div class="col-span-1 text-right"> 78 - <time title="{{ .Created | longTimeFmt }}"> 79 - {{ .Created | shortTimeFmt }} ago 80 - </time> 76 + <div class="text-sm md:text-base col-span-1 text-right"> 77 + {{ template "repo/fragments/shortTimeAgo" .Created }} 81 78 </div> 82 79 83 80 {{ $t := .TimeTaken }} 84 - <div class="col-span-1 text-right"> 81 + <div class="text-sm md:text-base col-span-1 text-right"> 85 82 {{ if $t }} 86 83 <time title="{{ $t }}">{{ $t | durationFmt }}</time> 87 84 {{ else }} 88 85 <time>--</time> 89 86 {{ end }} 90 87 </div> 88 + 89 + <div class="col-span-1 flex justify-end"> 90 + {{ if $link }} 91 + <a class="md:hidden" href="/{{ $root.RepoInfo.FullName }}/pipelines/{{ .Id }}/workflow/{{ index $workflows 0 }}"> 92 + {{ i "arrow-up-right" "size-4" }} 93 + </a> 94 + <a class="hidden md:inline underline" href="/{{ $root.RepoInfo.FullName }}/pipelines/{{ .Id }}/workflow/{{ index $workflows 0 }}"> 95 + view 96 + </a> 97 + {{ end }} 98 + </div> 99 + 91 100 </div> 92 101 {{ end }} 93 102 {{ end }}
+34 -23
appview/pages/templates/repo/pipelines/workflow.html
··· 17 17 </section> 18 18 {{ end }} 19 19 20 - {{ define "repoAfter" }} 21 - {{ end }} 22 - 23 20 {{ define "sidebar" }} 24 21 {{ $active := .Workflow }} 22 + 23 + {{ $activeTab := "bg-white dark:bg-gray-700 drop-shadow-sm" }} 24 + {{ $inactiveTab := "bg-gray-100 dark:bg-gray-800" }} 25 + 25 26 {{ with .Pipeline }} 26 - <div class="rounded border border-gray-200 dark:border-gray-700"> 27 + {{ $id := .Id }} 28 + <div class="sticky top-2 grid grid-cols-1 rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700"> 27 29 {{ range $name, $all := .Statuses }} 28 - <div class="flex items-center justify-between p-2 border-b border-gray-200 dark:border-gray-700 {{if eq $name $active}}bg-gray-100/50 dark:bg-gray-700/50{{end}}"> 29 - {{ $lastStatus := $all.Latest }} 30 - {{ $kind := $lastStatus.Status.String }} 30 + <a href="/{{ $.RepoInfo.FullName }}/pipelines/{{ $id }}/workflow/{{ $name }}" class="no-underline hover:no-underline hover:bg-gray-100/25 hover:dark:bg-gray-700/25"> 31 + <div 32 + class="flex gap-2 items-center justify-between p-2 {{ if eq $name $active }} {{ $activeTab }} {{ else }} {{ $inactiveTab }} {{ end }}"> 33 + {{ $lastStatus := $all.Latest }} 34 + {{ $kind := $lastStatus.Status.String }} 31 35 32 - {{ $t := .TimeTaken }} 33 - {{ $time := "" }} 34 - {{ if $t }} 35 - {{ $time = durationFmt $t }} 36 - {{ else }} 37 - {{ $time = printf "%s ago" (shortTimeFmt $.Created) }} 38 - {{ end }} 39 - 40 - <div id="left" class="flex items-center gap-2 flex-shrink-0"> 41 - {{ template "repo/pipelines/fragments/workflowSymbol" $all }} 42 - {{ $name }} 43 - </div> 44 - <div id="right" class="flex items-center gap-2 flex-shrink-0"> 45 - <span class="font-bold">{{ $kind }}</span> 46 - <time>{{ $time }}</time> 36 + <div id="left" class="flex items-center gap-2 flex-shrink-0"> 37 + {{ template "repo/pipelines/fragments/workflowSymbol" $all }} 38 + {{ $name }} 39 + </div> 40 + <div id="right" class="flex items-center gap-2 flex-shrink-0"> 41 + <span class="font-bold">{{ $kind }}</span> 42 + {{ if .TimeTaken }} 43 + {{ template "repo/fragments/duration" .TimeTaken }} 44 + {{ else }} 45 + {{ template "repo/fragments/shortTimeAgo" $lastStatus.Created }} 46 + {{ end }} 47 + </div> 47 48 </div> 48 - </div> 49 + </a> 49 50 {{ end }} 50 51 </div> 51 52 {{ end }} 52 53 {{ end }} 54 + 55 + {{ define "logs" }} 56 + <div id="log-stream" 57 + class="text-sm" 58 + hx-ext="ws" 59 + ws-connect="/{{ $.RepoInfo.FullName }}/pipelines/{{ .Pipeline.Id }}/workflow/{{ .Workflow }}/logs"> 60 + <div id="lines" class="flex flex-col gap-2"> 61 + </div> 62 + </div> 63 + {{ end }}
+2 -2
appview/pages/templates/repo/pulls/fragments/pullCompareForks.html
··· 19 19 > 20 20 <option disabled selected>select a fork</option> 21 21 {{ range .Forks }} 22 - <option value="{{ .Name }}" {{ if eq .Name $.Selected }}selected{{ end }} class="py-1"> 23 - {{ .Name }} 22 + <option value="{{ .Did }}/{{ .Name }}" {{ if eq .Name $.Selected }}selected{{ end }} class="py-1"> 23 + {{ .Did | resolve }}/{{ .Name }} 24 24 </option> 25 25 {{ end }} 26 26 </select>
+21 -7
appview/pages/templates/repo/pulls/fragments/pullHeader.html
··· 1 1 {{ define "repo/pulls/fragments/pullHeader" }} 2 2 <header class="pb-4"> 3 3 <h1 class="text-2xl dark:text-white"> 4 - {{ .Pull.Title }} 4 + {{ .Pull.Title | description }} 5 5 <span class="text-gray-500 dark:text-gray-400">#{{ .Pull.PullId }}</span> 6 6 </h1> 7 7 </header> ··· 26 26 {{ i $icon "w-4 h-4 mr-1.5 text-white" }} 27 27 <span class="text-white">{{ .Pull.State.String }}</span> 28 28 </div> 29 - <span class="text-gray-500 dark:text-gray-400 text-sm"> 29 + <span class="text-gray-500 dark:text-gray-400 text-sm flex flex-wrap items-center gap-1"> 30 30 opened by 31 - {{ $owner := index $.DidHandleMap .Pull.OwnerDid }} 32 - <a href="/{{ $owner }}" class="no-underline hover:underline" 33 - >{{ $owner }}</a 34 - > 31 + {{ template "user/fragments/picHandleLink" .Pull.OwnerDid }} 35 32 <span class="select-none before:content-['\00B7']"></span> 36 - <time>{{ .Pull.Created | timeFmt }}</time> 33 + {{ template "repo/fragments/time" .Pull.Created }} 37 34 38 35 <span class="select-none before:content-['\00B7']"></span> 39 36 <span> ··· 47 44 <span class="text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 mx-1/2 inline-flex items-center"> 48 45 {{ if .Pull.IsForkBased }} 49 46 {{ if .Pull.PullSource.Repo }} 47 + {{ $owner := resolve .Pull.PullSource.Repo.Did }} 50 48 <a href="/{{ $owner }}/{{ .Pull.PullSource.Repo.Name }}" class="no-underline hover:underline">{{ $owner }}/{{ .Pull.PullSource.Repo.Name }}</a>: 51 49 {{- else -}} 52 50 <span class="italic">[deleted fork]</span> ··· 62 60 <article id="body" class="mt-8 prose dark:prose-invert"> 63 61 {{ .Pull.Body | markdown }} 64 62 </article> 63 + {{ end }} 64 + 65 + {{ with .OrderedReactionKinds }} 66 + <div class="flex items-center gap-2 mt-2"> 67 + {{ template "repo/fragments/reactionsPopUp" . }} 68 + {{ range $kind := . }} 69 + {{ 70 + template "repo/fragments/reaction" 71 + (dict 72 + "Kind" $kind 73 + "Count" (index $.Reactions $kind) 74 + "IsReacted" (index $.UserReacted $kind) 75 + "ThreadAt" $.Pull.PullAt) 76 + }} 77 + {{ end }} 78 + </div> 65 79 {{ end }} 66 80 </section> 67 81
+2 -3
appview/pages/templates/repo/pulls/fragments/pullNewComment.html
··· 1 1 {{ define "repo/pulls/fragments/pullNewComment" }} 2 - <div 3 - id="pull-comment-card-{{ .RoundNumber }}" 2 + <div 3 + id="pull-comment-card-{{ .RoundNumber }}" 4 4 class="bg-white dark:bg-gray-800 rounded drop-shadow-sm p-4 relative w-full flex flex-col gap-2"> 5 5 <div class="text-sm text-gray-500 dark:text-gray-400"> 6 6 {{ didOrHandle .LoggedInUser.Did .LoggedInUser.Handle }} ··· 38 38 </form> 39 39 </div> 40 40 {{ end }} 41 -
+3 -3
appview/pages/templates/repo/pulls/fragments/pullStack.html
··· 1 1 {{ define "repo/pulls/fragments/pullStack" }} 2 - 3 2 <details class="bg-white dark:bg-gray-800 group" open> 4 3 <summary class="p-2 text-sm font-bold list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400"> 5 4 <span class="flex items-center gap-2"> ··· 10 9 {{ i "chevrons-down-up" "w-4 h-4" }} 11 10 </span> 12 11 STACK 13 - <span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 text-sm ml-1">{{ len .Stack }}</span> 12 + <span class="bg-gray-200 dark:bg-gray-700 font-normal rounded py-1/2 px-1 text-sm">{{ len .Stack }}</span> 14 13 </span> 15 14 </summary> 16 15 {{ block "pullList" (list .Stack $) }} {{ end }} ··· 41 40 <div class="grid grid-cols-1 rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700"> 42 41 {{ range $pull := $list }} 43 42 {{ $isCurrent := false }} 43 + {{ $pipeline := index $root.Pipelines $pull.LatestSha }} 44 44 {{ with $root.Pull }} 45 45 {{ $isCurrent = eq $pull.PullId $root.Pull.PullId }} 46 46 {{ end }} ··· 52 52 </div> 53 53 {{ end }} 54 54 <div class="{{ if not $isCurrent }} pl-6 {{ end }} flex-grow min-w-0 w-full py-2"> 55 - {{ template "repo/pulls/fragments/summarizedHeader" $pull }} 55 + {{ template "repo/pulls/fragments/summarizedPullHeader" (list $pull $pipeline) }} 56 56 </div> 57 57 </div> 58 58 </a>
+35 -27
appview/pages/templates/repo/pulls/fragments/summarizedPullHeader.html
··· 1 - {{ define "repo/pulls/fragments/summarizedHeader" }} 2 - <div class="flex text-sm items-center justify-between w-full"> 3 - <div class="flex items-center gap-2 min-w-0 flex-1 pr-2"> 4 - <div class="flex-shrink-0"> 5 - {{ template "repo/pulls/fragments/summarizedPullState" .State }} 1 + {{ define "repo/pulls/fragments/summarizedPullHeader" }} 2 + {{ $pull := index . 0 }} 3 + {{ $pipeline := index . 1 }} 4 + {{ with $pull }} 5 + <div class="flex text-sm items-center justify-between w-full"> 6 + <div class="flex items-center gap-2 min-w-0 flex-1 pr-2"> 7 + <div class="flex-shrink-0"> 8 + {{ template "repo/pulls/fragments/summarizedPullState" .State }} 9 + </div> 10 + <span class="truncate text-sm text-gray-800 dark:text-gray-200"> 11 + <span class="text-gray-500 dark:text-gray-400">#{{ .PullId }}</span> 12 + {{ .Title | description }} 13 + </span> 6 14 </div> 7 - <span class="truncate text-sm text-gray-800 dark:text-gray-200"> 8 - <span class="text-gray-500 dark:text-gray-400">#{{ .PullId }}</span> 9 - {{ .Title }} 10 - </span> 11 - </div> 12 15 13 - <div class="flex-shrink-0"> 14 - {{ $latestRound := .LastRoundNumber }} 15 - {{ $lastSubmission := index .Submissions $latestRound }} 16 - {{ $commentCount := len $lastSubmission.Comments }} 17 - <span> 18 - <div class="inline-flex items-center gap-2"> 19 - {{ i "message-square" "w-3 h-3 md:hidden" }} 20 - {{ $commentCount }} 21 - <span class="hidden md:inline">comment{{if ne $commentCount 1}}s{{end}}</span> 22 - </div> 23 - </span> 24 - <span class="mx-2 before:content-['ยท'] before:select-none"></span> 25 - <span> 26 - <span class="hidden md:inline">round</span> 27 - <span class="font-mono">#{{ $latestRound }}</span> 28 - </span> 16 + <div class="flex-shrink-0 flex items-center gap-2"> 17 + {{ $latestRound := .LastRoundNumber }} 18 + {{ $lastSubmission := index .Submissions $latestRound }} 19 + {{ $commentCount := len $lastSubmission.Comments }} 20 + {{ if and $pipeline $pipeline.Id }} 21 + {{ template "repo/pipelines/fragments/pipelineSymbol" $pipeline }} 22 + <span class="before:content-['ยท'] before:select-none text-gray-500 dark:text-gray-400"></span> 23 + {{ end }} 24 + <span> 25 + <div class="inline-flex items-center gap-1"> 26 + {{ i "message-square" "w-3 h-3 md:hidden" }} 27 + {{ $commentCount }} 28 + <span class="hidden md:inline">comment{{if ne $commentCount 1}}s{{end}}</span> 29 + </div> 30 + </span> 31 + <span class="before:content-['ยท'] before:select-none text-gray-500 dark:text-gray-400"></span> 32 + <span> 33 + <span class="hidden md:inline">round</span> 34 + <span class="font-mono">#{{ $latestRound }}</span> 35 + </span> 36 + </div> 29 37 </div> 30 - </div> 38 + {{ end }} 31 39 {{ end }} 32 40
+44 -3
appview/pages/templates/repo/pulls/interdiff.html
··· 26 26 </header> 27 27 </section> 28 28 29 - <section> 30 - {{ template "repo/fragments/interdiff" (list .RepoInfo.FullName .Interdiff) }} 31 - </section> 29 + {{ end }} 30 + 31 + {{ define "topbarLayout" }} 32 + <header class="px-1 col-span-full" style="z-index: 20;"> 33 + {{ template "layouts/fragments/topbar" . }} 34 + </header> 35 + {{ end }} 36 + 37 + {{ define "mainLayout" }} 38 + <div class="px-1 col-span-full flex flex-col gap-4"> 39 + {{ block "contentLayout" . }} 40 + {{ block "content" . }}{{ end }} 41 + {{ end }} 42 + 43 + {{ block "contentAfterLayout" . }} 44 + <div class="flex-grow grid grid-cols-1 md:grid-cols-12 gap-4"> 45 + <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 46 + {{ block "contentAfterLeft" . }} {{ end }} 47 + </div> 48 + <main class="col-span-1 md:col-span-10"> 49 + {{ block "contentAfter" . }}{{ end }} 50 + </main> 51 + </div> 52 + {{ end }} 53 + </div> 32 54 {{ end }} 33 55 56 + {{ define "footerLayout" }} 57 + <footer class="px-1 col-span-full mt-12"> 58 + {{ template "layouts/fragments/footer" . }} 59 + </footer> 60 + {{ end }} 61 + 62 + 63 + {{ define "contentAfter" }} 64 + {{ template "repo/fragments/interdiff" (list .RepoInfo.FullName .Interdiff .DiffOpts) }} 65 + {{end}} 66 + 67 + {{ define "contentAfterLeft" }} 68 + <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 69 + {{ template "repo/fragments/diffOpts" .DiffOpts }} 70 + </div> 71 + <div class="sticky top-0 flex-grow max-h-screen overflow-y-auto"> 72 + {{ template "repo/fragments/interdiffFiles" .Interdiff }} 73 + </div> 74 + {{end}}
+1 -1
appview/pages/templates/repo/pulls/new.html
··· 141 141 </div> 142 142 143 143 <div class="flex justify-start items-center gap-2 mt-4"> 144 - <button type="submit" class="btn flex items-center gap-2"> 144 + <button type="submit" class="btn-create flex items-center gap-2"> 145 145 {{ i "git-pull-request-create" "w-4 h-4" }} 146 146 create pull 147 147 <span id="create-pull-spinner" class="group">
+44 -1
appview/pages/templates/repo/pulls/patch.html
··· 31 31 <div class="border-t border-gray-200 dark:border-gray-700 my-2"></div> 32 32 {{ template "repo/pulls/fragments/pullHeader" . }} 33 33 </section> 34 - {{ template "repo/fragments/diff" (list .RepoInfo.FullName .Diff) }} 35 34 </section> 36 35 {{ end }} 36 + 37 + {{ define "topbarLayout" }} 38 + <header class="px-1 col-span-full" style="z-index: 20;"> 39 + {{ template "layouts/fragments/topbar" . }} 40 + </header> 41 + {{ end }} 42 + 43 + {{ define "mainLayout" }} 44 + <div class="px-1 col-span-full flex flex-col gap-4"> 45 + {{ block "contentLayout" . }} 46 + {{ block "content" . }}{{ end }} 47 + {{ end }} 48 + 49 + {{ block "contentAfterLayout" . }} 50 + <div class="flex-grow grid grid-cols-1 md:grid-cols-12 gap-4"> 51 + <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 52 + {{ block "contentAfterLeft" . }} {{ end }} 53 + </div> 54 + <main class="col-span-1 md:col-span-10"> 55 + {{ block "contentAfter" . }}{{ end }} 56 + </main> 57 + </div> 58 + {{ end }} 59 + </div> 60 + {{ end }} 61 + 62 + {{ define "footerLayout" }} 63 + <footer class="px-1 col-span-full mt-12"> 64 + {{ template "layouts/fragments/footer" . }} 65 + </footer> 66 + {{ end }} 67 + 68 + {{ define "contentAfter" }} 69 + {{ template "repo/fragments/diff" (list .RepoInfo.FullName .Diff .DiffOpts) }} 70 + {{end}} 71 + 72 + {{ define "contentAfterLeft" }} 73 + <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 74 + {{ template "repo/fragments/diffOpts" .DiffOpts }} 75 + </div> 76 + <div class="sticky top-0 flex-grow max-h-screen overflow-y-auto"> 77 + {{ template "repo/fragments/diffChangedFiles" .Diff }} 78 + </div> 79 + {{end}}
+45 -13
appview/pages/templates/repo/pulls/pull.html
··· 5 5 {{ define "extrameta" }} 6 6 {{ $title := printf "%s &middot; pull #%d &middot; %s" .Pull.Title .Pull.PullId .RepoInfo.FullName }} 7 7 {{ $url := printf "https://tangled.sh/%s/pulls/%d" .RepoInfo.FullName .Pull.PullId }} 8 - 8 + 9 9 {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 10 10 {{ end }} 11 11 ··· 46 46 </div> 47 47 <!-- round summary --> 48 48 <div class="rounded drop-shadow-sm bg-white dark:bg-gray-800 p-2 text-gray-500 dark:text-gray-400"> 49 - <span> 50 - {{ $owner := index $.DidHandleMap $.Pull.OwnerDid }} 49 + <span class="gap-1 flex items-center"> 50 + {{ $owner := resolve $.Pull.OwnerDid }} 51 51 {{ $re := "re" }} 52 52 {{ if eq .RoundNumber 0 }} 53 53 {{ $re = "" }} 54 54 {{ end }} 55 55 <span class="hidden md:inline">{{$re}}submitted</span> 56 - by <a href="/{{ $owner }}">{{ $owner }}</a> 56 + by {{ template "user/fragments/picHandleLink" $.Pull.OwnerDid }} 57 57 <span class="select-none before:content-['\00B7']"></span> 58 - <a class="text-gray-500 dark:text-gray-400 hover:text-gray-500" href="#round-#{{ .RoundNumber }}"><time>{{ .Created | shortTimeFmt }}</time></a> 58 + <a class="text-gray-500 dark:text-gray-400 hover:text-gray-500" href="#round-#{{ .RoundNumber }}">{{ template "repo/fragments/shortTime" .Created }}</a> 59 59 <span class="select-none before:content-['ยท']"></span> 60 60 {{ $s := "s" }} 61 61 {{ if eq (len .Comments) 1 }} ··· 68 68 <a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group" 69 69 hx-boost="true" 70 70 href="/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/round/{{.RoundNumber}}"> 71 - {{ i "file-diff" "w-4 h-4" }} 71 + {{ i "file-diff" "w-4 h-4" }} 72 72 <span class="hidden md:inline">diff</span> 73 73 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 74 74 </a> ··· 122 122 {{ end }} 123 123 </div> 124 124 <div class="flex items-center"> 125 - <span>{{ .Title }}</span> 125 + <span>{{ .Title | description }}</span> 126 126 {{ if gt (len .Body) 0 }} 127 127 <button 128 128 class="py-1/2 px-1 mx-2 bg-gray-200 hover:bg-gray-400 rounded dark:bg-gray-700 dark:hover:bg-gray-600" ··· 150 150 {{ if gt $cidx 0 }} 151 151 <div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div> 152 152 {{ end }} 153 - <div class="text-sm text-gray-500 dark:text-gray-400"> 154 - {{ $owner := index $.DidHandleMap $c.OwnerDid }} 155 - <a href="/{{$owner}}">{{$owner}}</a> 153 + <div class="text-sm text-gray-500 dark:text-gray-400 flex items-center gap-1"> 154 + {{ template "user/fragments/picHandleLink" $c.OwnerDid }} 156 155 <span class="before:content-['ยท']"></span> 157 - <a class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-300" href="#comment-{{.ID}}"><time>{{ $c.Created | shortTimeFmt }}</time></a> 156 + <a class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-300" href="#comment-{{.ID}}">{{ template "repo/fragments/time" $c.Created }}</a> 158 157 </div> 159 158 <div class="prose dark:prose-invert"> 160 159 {{ $c.Body | markdown }} 161 160 </div> 162 161 </div> 163 162 {{ end }} 163 + 164 + {{ block "pipelineStatus" (list $ .) }} {{ end }} 164 165 165 166 {{ if eq $lastIdx .RoundNumber }} 166 167 {{ block "mergeStatus" $ }} {{ end }} ··· 177 178 {{ end }} 178 179 </div> 179 180 </details> 180 - <hr class="md:hidden border-t border-gray-300 dark:border-gray-600"/> 181 181 {{ end }} 182 182 {{ end }} 183 183 {{ end }} ··· 260 260 {{ end }} 261 261 {{ end }} 262 262 263 - {{ define "commits" }} 263 + {{ define "pipelineStatus" }} 264 + {{ $root := index . 0 }} 265 + {{ $submission := index . 1 }} 266 + {{ $pipeline := index $root.Pipelines $submission.SourceRev }} 267 + {{ with $pipeline }} 268 + {{ $id := .Id }} 269 + {{ if .Statuses }} 270 + <div class="max-w-80 grid grid-cols-1 bg-white dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700"> 271 + {{ range $name, $all := .Statuses }} 272 + <a href="/{{ $root.RepoInfo.FullName }}/pipelines/{{ $id }}/workflow/{{ $name }}" class="no-underline hover:no-underline hover:bg-gray-100/25 hover:dark:bg-gray-700/25"> 273 + <div 274 + class="flex gap-2 items-center justify-between p-2"> 275 + {{ $lastStatus := $all.Latest }} 276 + {{ $kind := $lastStatus.Status.String }} 277 + 278 + <div id="left" class="flex items-center gap-2 flex-shrink-0"> 279 + {{ template "repo/pipelines/fragments/workflowSymbol" $all }} 280 + {{ $name }} 281 + </div> 282 + <div id="right" class="flex items-center gap-2 flex-shrink-0"> 283 + <span class="font-bold">{{ $kind }}</span> 284 + {{ if .TimeTaken }} 285 + {{ template "repo/fragments/duration" .TimeTaken }} 286 + {{ else }} 287 + {{ template "repo/fragments/shortTimeAgo" $lastStatus.Created }} 288 + {{ end }} 289 + </div> 290 + </div> 291 + </a> 292 + {{ end }} 293 + </div> 294 + {{ end }} 295 + {{ end }} 264 296 {{ end }}
+49 -61
appview/pages/templates/repo/pulls/pulls.html
··· 3 3 {{ define "extrameta" }} 4 4 {{ $title := "pulls"}} 5 5 {{ $url := printf "https://tangled.sh/%s/pulls" .RepoInfo.FullName }} 6 - 6 + 7 7 {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 8 8 {{ end }} 9 9 ··· 34 34 </div> 35 35 <a 36 36 href="/{{ .RepoInfo.FullName }}/pulls/new" 37 - class="btn text-sm flex items-center gap-2 no-underline hover:no-underline" 37 + class="btn-create text-sm flex items-center gap-2 no-underline hover:no-underline hover:text-white" 38 38 > 39 39 {{ i "git-pull-request-create" "w-4 h-4" }} 40 40 <span>new</span> ··· 50 50 <div class="px-6 py-4 z-5"> 51 51 <div class="pb-2"> 52 52 <a href="/{{ $.RepoInfo.FullName }}/pulls/{{ .PullId }}" class="dark:text-white"> 53 - {{ .Title }} 53 + {{ .Title | description }} 54 54 <span class="text-gray-500 dark:text-gray-400">#{{ .PullId }}</span> 55 55 </a> 56 56 </div> 57 - <p class="text-sm text-gray-500 dark:text-gray-400"> 58 - {{ $owner := index $.DidHandleMap .OwnerDid }} 57 + <div class="text-sm text-gray-500 dark:text-gray-400 flex flex-wrap items-center gap-1"> 59 58 {{ $bgColor := "bg-gray-800 dark:bg-gray-700" }} 60 59 {{ $icon := "ban" }} 61 60 ··· 75 74 <span class="text-white">{{ .State.String }}</span> 76 75 </span> 77 76 78 - <span> 79 - <a href="/{{ $owner }}" class="dark:text-gray-300">{{ $owner }}</a> 77 + <span class="ml-1"> 78 + {{ template "user/fragments/picHandleLink" .OwnerDid }} 80 79 </span> 81 80 82 81 <span class="before:content-['ยท']"> 83 - <time> 84 - {{ .Created | timeFmt }} 85 - </time> 82 + {{ template "repo/fragments/time" .Created }} 86 83 </span> 84 + 85 + 86 + {{ $latestRound := .LastRoundNumber }} 87 + {{ $lastSubmission := index .Submissions $latestRound }} 87 88 88 89 <span class="before:content-['ยท']"> 89 - targeting 90 - <span class="text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 mx-1/2 inline-flex items-center"> 91 - {{ .TargetBranch }} 92 - </span> 90 + {{ $commentCount := len $lastSubmission.Comments }} 91 + {{ $s := "s" }} 92 + {{ if eq $commentCount 1 }} 93 + {{ $s = "" }} 94 + {{ end }} 95 + 96 + {{ len $lastSubmission.Comments}} comment{{$s}} 93 97 </span> 94 - {{ if not .IsPatchBased }} 95 - from 96 - <span class="text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 mx-1/2 inline-flex items-center"> 97 - {{ if .IsForkBased }} 98 - {{ if .PullSource.Repo }} 99 - <a href="/{{ $owner }}/{{ .PullSource.Repo.Name }}" class="no-underline hover:underline">{{ $owner }}/{{ .PullSource.Repo.Name }}</a>: 100 - {{- else -}} 101 - <span class="italic">[deleted fork]</span> 102 - {{- end -}} 103 - {{- end -}} 104 - {{- .PullSource.Branch -}} 105 - </span> 106 - {{ end }} 107 - <span class="before:content-['ยท']"> 108 - {{ $latestRound := .LastRoundNumber }} 109 - {{ $lastSubmission := index .Submissions $latestRound }} 110 - round 111 - <span class="text-xs rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white font-mono px-2 mx-1/2 inline-flex items-center"> 112 - #{{ .LastRoundNumber }} 113 - </span> 114 - {{ $commentCount := len $lastSubmission.Comments }} 115 - {{ $s := "s" }} 116 - {{ if eq $commentCount 1 }} 117 - {{ $s = "" }} 118 - {{ end }} 119 98 120 - {{ if eq $commentCount 0 }} 121 - awaiting comments 122 - {{ else }} 123 - recieved {{ len $lastSubmission.Comments}} comment{{$s}} 124 - {{ end }} 99 + <span class="before:content-['ยท']"> 100 + round 101 + <span class="font-mono"> 102 + #{{ .LastRoundNumber }} 103 + </span> 125 104 </span> 126 - </p> 105 + 106 + {{ $pipeline := index $.Pipelines .LatestSha }} 107 + {{ if and $pipeline $pipeline.Id }} 108 + <span class="before:content-['ยท']"></span> 109 + {{ template "repo/pipelines/fragments/pipelineSymbol" $pipeline }} 110 + {{ end }} 111 + </div> 127 112 </div> 128 113 {{ if .StackId }} 129 114 {{ $otherPulls := index $.Stacks .StackId }} 130 - <details class="bg-white dark:bg-gray-800 group"> 131 - <summary class="pb-4 px-6 text-xs list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400"> 132 - {{ $s := "s" }} 133 - {{ if eq (len $otherPulls) 1 }} 134 - {{ $s = "" }} 135 - {{ end }} 136 - <div class="group-open:hidden flex items-center gap-2"> 137 - {{ i "chevrons-up-down" "w-4 h-4" }} expand {{ len $otherPulls }} pull{{$s}} in this stack 138 - </div> 139 - <div class="hidden group-open:flex items-center gap-2"> 140 - {{ i "chevrons-down-up" "w-4 h-4" }} hide {{ len $otherPulls }} pull{{$s}} in this stack 141 - </div> 142 - </summary> 143 - {{ block "pullList" (list $otherPulls $) }} {{ end }} 144 - </details> 115 + {{ if gt (len $otherPulls) 0 }} 116 + <details class="bg-white dark:bg-gray-800 group"> 117 + <summary class="pb-4 px-6 text-xs list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400"> 118 + {{ $s := "s" }} 119 + {{ if eq (len $otherPulls) 1 }} 120 + {{ $s = "" }} 121 + {{ end }} 122 + <div class="group-open:hidden flex items-center gap-2"> 123 + {{ i "chevrons-up-down" "w-4 h-4" }} expand {{ len $otherPulls }} pull{{$s}} in this stack 124 + </div> 125 + <div class="hidden group-open:flex items-center gap-2"> 126 + {{ i "chevrons-down-up" "w-4 h-4" }} hide {{ len $otherPulls }} pull{{$s}} in this stack 127 + </div> 128 + </summary> 129 + {{ block "pullList" (list $otherPulls $) }} {{ end }} 130 + </details> 131 + {{ end }} 145 132 {{ end }} 146 133 </div> 147 134 {{ end }} ··· 153 140 {{ $root := index . 1 }} 154 141 <div class="grid grid-cols-1 rounded-b border-b border-t border-gray-200 dark:border-gray-900 divide-y divide-gray-200 dark:divide-gray-900"> 155 142 {{ range $pull := $list }} 143 + {{ $pipeline := index $root.Pipelines $pull.LatestSha }} 156 144 <a href="/{{ $root.RepoInfo.FullName }}/pulls/{{ $pull.PullId }}" class="no-underline hover:no-underline hover:bg-gray-100/25 hover:dark:bg-gray-700/25"> 157 145 <div class="flex gap-2 items-center px-6"> 158 146 <div class="flex-grow min-w-0 w-full py-2"> 159 - {{ template "repo/pulls/fragments/summarizedHeader" $pull }} 147 + {{ template "repo/pulls/fragments/summarizedPullHeader" (list $pull $pipeline) }} 160 148 </div> 161 149 </div> 162 150 </a>
+110
appview/pages/templates/repo/settings/access.html
··· 1 + {{ define "title" }}{{ .Tab }} settings &middot; {{ .RepoInfo.FullName }}{{ end }} 2 + 3 + {{ define "repoContent" }} 4 + <section class="w-full grid grid-cols-1 md:grid-cols-4 gap-2"> 5 + <div class="col-span-1"> 6 + {{ template "repo/settings/fragments/sidebar" . }} 7 + </div> 8 + <div class="col-span-1 md:col-span-3 flex flex-col gap-6 p-2"> 9 + {{ template "collaboratorSettings" . }} 10 + </div> 11 + </section> 12 + {{ end }} 13 + 14 + {{ define "collaboratorSettings" }} 15 + <div class="grid grid-cols-1 gap-4 items-center"> 16 + <div class="col-span-1"> 17 + <h2 class="text-sm pb-2 uppercase font-bold">Collaborators</h2> 18 + <p class="text-gray-500 dark:text-gray-400"> 19 + Any user added as a collaborator will be able to push commits and tags to this repository, upload releases, and workflows. 20 + </p> 21 + </div> 22 + {{ template "collaboratorsGrid" . }} 23 + </div> 24 + {{ end }} 25 + 26 + {{ define "collaboratorsGrid" }} 27 + <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"> 28 + {{ if .RepoInfo.Roles.CollaboratorInviteAllowed }} 29 + {{ template "addCollaboratorButton" . }} 30 + {{ end }} 31 + {{ range .Collaborators }} 32 + <div class="border border-gray-200 dark:border-gray-700 rounded p-4"> 33 + <div class="flex items-center gap-3"> 34 + <img 35 + src="{{ fullAvatar .Handle }}" 36 + alt="{{ .Handle }}" 37 + class="rounded-full h-10 w-10 border border-gray-300 dark:border-gray-600 flex-shrink-0"/> 38 + 39 + <div class="flex-1 min-w-0"> 40 + <a href="/{{ .Handle }}" class="block truncate"> 41 + {{ didOrHandle .Did .Handle }} 42 + </a> 43 + <p class="text-sm text-gray-500 dark:text-gray-400">{{ .Role }}</p> 44 + </div> 45 + </div> 46 + </div> 47 + {{ end }} 48 + </div> 49 + {{ end }} 50 + 51 + {{ define "addCollaboratorButton" }} 52 + <button 53 + class="btn block rounded p-4" 54 + popovertarget="add-collaborator-modal" 55 + popovertargetaction="toggle"> 56 + <div class="flex items-center gap-3"> 57 + <div class="w-10 h-10 rounded-full bg-gray-100 dark:bg-gray-700 flex items-center justify-center"> 58 + {{ i "user-plus" "size-4" }} 59 + </div> 60 + 61 + <div class="text-left flex-1 min-w-0 block truncate"> 62 + Add collaborator 63 + </div> 64 + </div> 65 + </button> 66 + <div 67 + id="add-collaborator-modal" 68 + popover 69 + class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50"> 70 + {{ template "addCollaboratorModal" . }} 71 + </div> 72 + {{ end }} 73 + 74 + {{ define "addCollaboratorModal" }} 75 + <form 76 + hx-put="/{{ $.RepoInfo.FullName }}/settings/collaborator" 77 + hx-indicator="#spinner" 78 + hx-swap="none" 79 + class="flex flex-col gap-2" 80 + > 81 + <label for="add-collaborator" class="uppercase p-0"> 82 + ADD COLLABORATOR 83 + </label> 84 + <p class="text-sm text-gray-500 dark:text-gray-400">Collaborators can push to this repository.</p> 85 + <input 86 + type="text" 87 + id="add-collaborator" 88 + name="collaborator" 89 + required 90 + placeholder="@foo.bsky.social" 91 + /> 92 + <div class="flex gap-2 pt-2"> 93 + <button 94 + type="button" 95 + popovertarget="add-collaborator-modal" 96 + popovertargetaction="hide" 97 + class="btn w-1/2 flex items-center gap-2 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300" 98 + > 99 + {{ i "x" "size-4" }} cancel 100 + </button> 101 + <button type="submit" class="btn w-1/2 flex items-center"> 102 + <span class="inline-flex gap-2 items-center">{{ i "user-plus" "size-4" }} add</span> 103 + <span id="spinner" class="group"> 104 + {{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 105 + </span> 106 + </button> 107 + </div> 108 + <div id="add-collaborator-error" class="text-red-500 dark:text-red-400"></div> 109 + </form> 110 + {{ end }}
+29
appview/pages/templates/repo/settings/fragments/secretListing.html
··· 1 + {{ define "repo/settings/fragments/secretListing" }} 2 + {{ $root := index . 0 }} 3 + {{ $secret := index . 1 }} 4 + <div id="secret-{{$secret.Key}}" class="flex items-center justify-between p-2"> 5 + <div class="hover:no-underline flex flex-col gap-1 text-sm min-w-0 max-w-[80%]"> 6 + <span class="font-mono"> 7 + {{ $secret.Key }} 8 + </span> 9 + <div class="flex flex-wrap text items-center gap-1 text-gray-500 dark:text-gray-400"> 10 + <span>added by</span> 11 + <span>{{ template "user/fragments/picHandleLink" $secret.CreatedBy }}</span> 12 + <span class="before:content-['ยท'] before:select-none"></span> 13 + <span>{{ template "repo/fragments/shortTimeAgo" $secret.CreatedAt }}</span> 14 + </div> 15 + </div> 16 + <button 17 + class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 18 + title="Delete secret" 19 + hx-delete="/{{ $root.RepoInfo.FullName }}/settings/secrets" 20 + hx-swap="none" 21 + hx-vals='{"key": "{{ $secret.Key }}"}' 22 + hx-confirm="Are you sure you want to delete the secret {{ $secret.Key }}?" 23 + > 24 + {{ i "trash-2" "w-5 h-5" }} 25 + <span class="hidden md:inline">delete</span> 26 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 27 + </button> 28 + </div> 29 + {{ end }}
+16
appview/pages/templates/repo/settings/fragments/sidebar.html
··· 1 + {{ define "repo/settings/fragments/sidebar" }} 2 + {{ $active := .Tab }} 3 + {{ $tabs := .Tabs }} 4 + <div class="sticky top-2 grid grid-cols-1 rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 shadow-inner"> 5 + {{ $activeTab := "bg-white dark:bg-gray-700 drop-shadow-sm" }} 6 + {{ $inactiveTab := "bg-gray-100 dark:bg-gray-800" }} 7 + {{ range $tabs }} 8 + <a href="/{{ $.RepoInfo.FullName }}/settings?tab={{.Name}}" class="no-underline hover:no-underline hover:bg-gray-100/25 hover:dark:bg-gray-700/25"> 9 + <div class="flex gap-3 items-center p-2 {{ if eq .Name $active }} {{ $activeTab }} {{ else }} {{ $inactiveTab }} {{ end }}"> 10 + {{ i .Icon "size-4" }} 11 + {{ .Name }} 12 + </div> 13 + </a> 14 + {{ end }} 15 + </div> 16 + {{ end }}
+70
appview/pages/templates/repo/settings/general.html
··· 1 + {{ define "title" }}{{ .Tab }} settings &middot; {{ .RepoInfo.FullName }}{{ end }} 2 + 3 + {{ define "repoContent" }} 4 + <section class="w-full grid grid-cols-1 md:grid-cols-4 gap-2"> 5 + <div class="col-span-1"> 6 + {{ template "repo/settings/fragments/sidebar" . }} 7 + </div> 8 + <div class="col-span-1 md:col-span-3 flex flex-col gap-6 p-2"> 9 + {{ template "branchSettings" . }} 10 + {{ template "deleteRepo" . }} 11 + <div id="operation-error" class="text-red-500 dark:text-red-400"></div> 12 + </div> 13 + </section> 14 + {{ end }} 15 + 16 + {{ define "branchSettings" }} 17 + <div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center"> 18 + <div class="col-span-1 md:col-span-2"> 19 + <h2 class="text-sm pb-2 uppercase font-bold">Default Branch</h2> 20 + <p class="text-gray-500 dark:text-gray-400"> 21 + The default branch is considered the โ€œbaseโ€ branch in your repository, 22 + against which all pull requests and code commits are automatically made, 23 + unless you specify a different branch. 24 + </p> 25 + </div> 26 + <form hx-put="/{{ $.RepoInfo.FullName }}/settings/branches/default" hx-swap="none" class="col-span-1 md:col-span-1 md:justify-self-end group flex gap-2 items-stretch"> 27 + <select id="branch" name="branch" required class="p-1 max-w-64 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700"> 28 + <option value="" disabled selected > 29 + Choose a default branch 30 + </option> 31 + {{ range .Branches }} 32 + <option value="{{ .Name }}" class="py-1" {{ if .IsDefault }}selected{{ end }} > 33 + {{ .Name }} 34 + </option> 35 + {{ end }} 36 + </select> 37 + <button class="btn flex gap-2 items-center" type="submit"> 38 + {{ i "check" "size-4" }} 39 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 40 + </button> 41 + </form> 42 + </div> 43 + {{ end }} 44 + 45 + {{ define "deleteRepo" }} 46 + {{ if .RepoInfo.Roles.RepoDeleteAllowed }} 47 + <div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center"> 48 + <div class="col-span-1 md:col-span-2"> 49 + <h2 class="text-sm pb-2 uppercase text-red-500 dark:text-red-400 font-bold">Delete Repository</h2> 50 + <p class="text-red-500 dark:text-red-400 "> 51 + Deleting a repository is irreversible and permanent. Be certain before deleting a repository. 52 + </p> 53 + </div> 54 + <div class="col-span-1 md:col-span-1 md:justify-self-end"> 55 + <button 56 + class="btn group text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 flex gap-2 items-center" 57 + type="button" 58 + hx-swap="none" 59 + hx-delete="/{{ $.RepoInfo.FullName }}/settings/delete" 60 + hx-confirm="Are you sure you want to delete {{ $.RepoInfo.FullName }}?"> 61 + {{ i "trash-2" "size-4" }} 62 + delete 63 + <span class="ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline"> 64 + {{ i "loader-circle" "w-4 h-4" }} 65 + </span> 66 + </button> 67 + </div> 68 + </div> 69 + {{ end }} 70 + {{ end }}
+145
appview/pages/templates/repo/settings/pipelines.html
··· 1 + {{ define "title" }}{{ .Tab }} settings &middot; {{ .RepoInfo.FullName }}{{ end }} 2 + 3 + {{ define "repoContent" }} 4 + <section class="w-full grid grid-cols-1 md:grid-cols-4 gap-2"> 5 + <div class="col-span-1"> 6 + {{ template "repo/settings/fragments/sidebar" . }} 7 + </div> 8 + <div class="col-span-1 md:col-span-3 flex flex-col gap-6 p-2"> 9 + {{ template "spindleSettings" . }} 10 + {{ if $.CurrentSpindle }} 11 + {{ template "secretSettings" . }} 12 + {{ end }} 13 + <div id="operation-error" class="text-red-500 dark:text-red-400"></div> 14 + </div> 15 + </section> 16 + {{ end }} 17 + 18 + {{ define "spindleSettings" }} 19 + <div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center"> 20 + <div class="col-span-1 md:col-span-2"> 21 + <h2 class="text-sm pb-2 uppercase font-bold">Spindle</h2> 22 + <p class="text-gray-500 dark:text-gray-400"> 23 + Choose a spindle to execute your workflows on. Only repository owners 24 + can configure spindles. Spindles can be selfhosted, 25 + <a class="text-gray-500 dark:text-gray-400 underline" href="https://tangled.sh/@tangled.sh/core/blob/master/docs/spindle/hosting.md"> 26 + click to learn more. 27 + </a> 28 + </p> 29 + </div> 30 + {{ if not $.RepoInfo.Roles.IsOwner }} 31 + <div class="col-span-1 md:col-span-1 md:justify-self-end group flex gap-2 items-stretch"> 32 + {{ or $.CurrentSpindle "No spindle configured" }} 33 + </div> 34 + {{ else }} 35 + <form hx-post="/{{ $.RepoInfo.FullName }}/settings/spindle" class="col-span-1 md:col-span-1 md:justify-self-end group flex gap-2 items-stretch"> 36 + <select 37 + id="spindle" 38 + name="spindle" 39 + required 40 + class="p-1 max-w-64 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700"> 41 + {{/* For some reason, we can't use an empty string in a <select> in all scenarios unless it is preceded by a disabled select?? No idea, could just be a Firefox thing? */}} 42 + <option value="[[none]]" class="py-1" {{ if not $.CurrentSpindle }}selected{{ end }}> 43 + {{ if not $.CurrentSpindle }} 44 + Choose a spindle 45 + {{ else }} 46 + Disable pipelines 47 + {{ end }} 48 + </option> 49 + {{ range $.Spindles }} 50 + <option value="{{ . }}" class="py-1" {{ if eq . $.CurrentSpindle }}selected{{ end }}> 51 + {{ . }} 52 + </option> 53 + {{ end }} 54 + </select> 55 + <button class="btn flex gap-2 items-center" type="submit" {{ if not $.RepoInfo.Roles.IsOwner }}disabled{{ end }}> 56 + {{ i "check" "size-4" }} 57 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 58 + </button> 59 + </form> 60 + {{ end }} 61 + </div> 62 + {{ end }} 63 + 64 + {{ define "secretSettings" }} 65 + <div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center"> 66 + <div class="col-span-1 md:col-span-2"> 67 + <h2 class="text-sm pb-2 uppercase font-bold">SECRETS</h2> 68 + <p class="text-gray-500 dark:text-gray-400"> 69 + Secrets are accessible in workflow runs via environment variables. Anyone 70 + with collaborator access to this repository can add and use secrets in 71 + workflow runs. 72 + </p> 73 + </div> 74 + <div class="col-span-1 md:col-span-1 md:justify-self-end"> 75 + {{ template "addSecretButton" . }} 76 + </div> 77 + </div> 78 + <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 w-full"> 79 + {{ range .Secrets }} 80 + {{ template "repo/settings/fragments/secretListing" (list $ .) }} 81 + {{ else }} 82 + <div class="flex items-center justify-center p-2 text-gray-500"> 83 + no secrets added yet 84 + </div> 85 + {{ end }} 86 + </div> 87 + {{ end }} 88 + 89 + {{ define "addSecretButton" }} 90 + <button 91 + class="btn flex items-center gap-2" 92 + popovertarget="add-secret-modal" 93 + popovertargetaction="toggle"> 94 + {{ i "plus" "size-4" }} 95 + add secret 96 + </button> 97 + <div 98 + id="add-secret-modal" 99 + popover 100 + class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50"> 101 + {{ template "addSecretModal" . }} 102 + </div> 103 + {{ end}} 104 + 105 + {{ define "addSecretModal" }} 106 + <form 107 + hx-put="/{{ $.RepoInfo.FullName }}/settings/secrets" 108 + hx-indicator="#spinner" 109 + hx-swap="none" 110 + class="flex flex-col gap-2" 111 + > 112 + <p class="uppercase p-0">ADD SECRET</p> 113 + <p class="text-sm text-gray-500 dark:text-gray-400">Secrets are available as environment variables in the workflow.</p> 114 + <input 115 + type="text" 116 + id="secret-key" 117 + name="key" 118 + required 119 + placeholder="SECRET_NAME" 120 + /> 121 + <textarea 122 + type="text" 123 + id="secret-value" 124 + name="value" 125 + required 126 + placeholder="secret value"></textarea> 127 + <div class="flex gap-2 pt-2"> 128 + <button 129 + type="button" 130 + popovertarget="add-secret-modal" 131 + popovertargetaction="hide" 132 + class="btn w-1/2 flex items-center gap-2 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300" 133 + > 134 + {{ i "x" "size-4" }} cancel 135 + </button> 136 + <button type="submit" class="btn w-1/2 flex items-center"> 137 + <span class="inline-flex gap-2 items-center">{{ i "plus" "size-4" }} add</span> 138 + <span id="spinner" class="group"> 139 + {{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 140 + </span> 141 + </button> 142 + </div> 143 + <div id="add-secret-error" class="text-red-500 dark:text-red-400"></div> 144 + </form> 145 + {{ end }}
-139
appview/pages/templates/repo/settings.html
··· 1 - {{ define "title" }}settings &middot; {{ .RepoInfo.FullName }}{{ end }} 2 - {{ define "repoContent" }} 3 - <header class="font-bold text-sm mb-4 uppercase dark:text-white"> 4 - Collaborators 5 - </header> 6 - 7 - <div id="collaborator-list" class="flex flex-col gap-2 mb-2"> 8 - {{ range .Collaborators }} 9 - <div id="collaborator" class="mb-2"> 10 - <a 11 - href="/{{ didOrHandle .Did .Handle }}" 12 - class="no-underline hover:underline text-black dark:text-white" 13 - > 14 - {{ didOrHandle .Did .Handle }} 15 - </a> 16 - <div> 17 - <span class="text-sm text-gray-500 dark:text-gray-400"> 18 - {{ .Role }} 19 - </span> 20 - </div> 21 - </div> 22 - {{ end }} 23 - </div> 24 - 25 - {{ if .RepoInfo.Roles.CollaboratorInviteAllowed }} 26 - <form 27 - hx-put="/{{ $.RepoInfo.FullName }}/settings/collaborator" 28 - class="group" 29 - > 30 - <label for="collaborator" class="dark:text-white"> 31 - add collaborator 32 - </label> 33 - <input 34 - type="text" 35 - id="collaborator" 36 - name="collaborator" 37 - required 38 - class="dark:bg-gray-700 dark:text-white" 39 - placeholder="enter did or handle" 40 - > 41 - <button 42 - class="btn my-2 flex gap-2 items-center dark:text-white dark:hover:bg-gray-700" 43 - type="text" 44 - > 45 - <span>add</span> 46 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 47 - </button> 48 - </form> 49 - {{ end }} 50 - 51 - <form 52 - hx-put="/{{ $.RepoInfo.FullName }}/settings/branches/default" 53 - class="mt-6 group" 54 - > 55 - <label for="branch">default branch</label> 56 - <div class="flex gap-2 items-center"> 57 - <select id="branch" name="branch" required class="p-1 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700"> 58 - <option 59 - value="" 60 - disabled 61 - selected 62 - > 63 - Choose a default branch 64 - </option> 65 - {{ range .Branches }} 66 - <option 67 - value="{{ .Name }}" 68 - class="py-1" 69 - {{ if .IsDefault }} 70 - selected 71 - {{ end }} 72 - > 73 - {{ .Name }} 74 - </option> 75 - {{ end }} 76 - </select> 77 - <button class="btn my-2 flex gap-2 items-center" type="submit"> 78 - <span>save</span> 79 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 80 - </button> 81 - </div> 82 - </form> 83 - 84 - {{ if .RepoInfo.Roles.IsOwner }} 85 - <form 86 - hx-post="/{{ $.RepoInfo.FullName }}/settings/spindle" 87 - class="mt-6 group" 88 - > 89 - <label for="spindle">spindle</label> 90 - <div class="flex gap-2 items-center"> 91 - <select id="spindle" name="spindle" required class="p-1 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700"> 92 - <option 93 - value="" 94 - disabled 95 - selected 96 - > 97 - Choose a spindle 98 - </option> 99 - {{ range .Spindles }} 100 - <option 101 - value="{{ . }}" 102 - class="py-1" 103 - {{ if eq . $.CurrentSpindle }} 104 - selected 105 - {{ end }} 106 - > 107 - {{ . }} 108 - </option> 109 - {{ end }} 110 - </select> 111 - <button class="btn my-2 flex gap-2 items-center" type="submit"> 112 - <span>save</span> 113 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 114 - </button> 115 - </div> 116 - </form> 117 - {{ end }} 118 - 119 - {{ if .RepoInfo.Roles.RepoDeleteAllowed }} 120 - <form 121 - hx-confirm="Are you sure you want to delete this repository?" 122 - hx-delete="/{{ $.RepoInfo.FullName }}/settings/delete" 123 - class="mt-6" 124 - hx-indicator="#delete-repo-spinner" 125 - > 126 - <label for="branch">delete repository</label> 127 - <button class="btn my-2 flex items-center" type="text"> 128 - <span>delete</span> 129 - <span id="delete-repo-spinner" class="group"> 130 - {{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 131 - </span> 132 - </button> 133 - <span> 134 - Deleting a repository is irreversible and permanent. 135 - </span> 136 - </form> 137 - {{ end }} 138 - 139 - {{ end }}
+10 -4
appview/pages/templates/repo/tags.html
··· 35 35 <span>{{ .Tag.Tagger.Name }}</span> 36 36 37 37 <span class="px-1 text-gray-500 dark:text-gray-400 select-none after:content-['ยท']"></span> 38 - <time>{{ shortTimeFmt .Tag.Tagger.When }}</time> 38 + {{ template "repo/fragments/shortTime" .Tag.Tagger.When }} 39 39 {{ end }} 40 40 </div> 41 41 </div> ··· 54 54 {{ slice .Tag.Target.String 0 8 }} 55 55 </a> 56 56 <span>{{ .Tag.Tagger.Name }}</span> 57 - <time>{{ timeFmt .Tag.Tagger.When }}</time> 57 + {{ template "repo/fragments/time" .Tag.Tagger.When }} 58 58 {{ end }} 59 59 </div> 60 60 </div> ··· 97 97 {{ $isPushAllowed := $root.RepoInfo.Roles.IsPushAllowed }} 98 98 {{ $artifacts := index $root.ArtifactMap $tag.Tag.Hash }} 99 99 100 - {{ if or (gt (len $artifacts) 0) $isPushAllowed }} 101 100 <h2 class="my-4 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold">artifacts</h2> 102 101 <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700"> 103 102 {{ range $artifact := $artifacts }} 104 103 {{ $args := dict "LoggedInUser" $root.LoggedInUser "RepoInfo" $root.RepoInfo "Artifact" $artifact }} 105 104 {{ template "repo/fragments/artifact" $args }} 106 105 {{ end }} 106 + <div id="artifact-git-source" class="flex items-center justify-between p-2 border-b border-gray-200 dark:border-gray-700"> 107 + <div id="left-side" class="flex items-center gap-2 min-w-0 max-w-[60%]"> 108 + {{ i "archive" "w-4 h-4" }} 109 + <a href="/{{ $root.RepoInfo.FullName }}/archive/{{ pathEscape (print "refs/tags/" $tag.Name) }}" class="no-underline hover:no-underline"> 110 + Source code (.tar.gz) 111 + </a> 112 + </div> 113 + </div> 107 114 {{ if $isPushAllowed }} 108 115 {{ block "uploadArtifact" (list $root $tag) }} {{ end }} 109 116 {{ end }} 110 117 </div> 111 - {{ end }} 112 118 {{ end }} 113 119 114 120 {{ define "uploadArtifact" }}
+31 -32
appview/pages/templates/repo/tree.html
··· 11 11 {{ template "repo/fragments/meta" . }} 12 12 {{ $title := printf "%s at %s &middot; %s" $path .Ref .RepoInfo.FullName }} 13 13 {{ $url := printf "https://tangled.sh/%s/tree/%s%s" .RepoInfo.FullName .Ref $path }} 14 - 14 + 15 15 {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 16 16 {{ end }} 17 17 ··· 19 19 {{define "repoContent"}} 20 20 <main> 21 21 <div class="tree"> 22 - {{ $containerstyle := "py-1" }} 23 22 {{ $linkstyle := "no-underline hover:underline" }} 24 23 25 24 <div class="pb-2 mb-3 text-base border-b border-gray-200 dark:border-gray-700"> 26 25 <div class="flex flex-col md:flex-row md:justify-between gap-2"> 27 26 <div id="breadcrumbs" class="overflow-x-auto whitespace-nowrap text-gray-400 dark:text-gray-500"> 28 27 {{ range .BreadCrumbs }} 29 - <a href="{{ index . 1}}" class="text-bold text-gray-500 dark:text-gray-400 {{ $linkstyle }}">{{ pathUnescape (index . 0) }}</a> / 28 + <a href="{{ index . 1 }}" class="text-bold text-gray-500 dark:text-gray-400 {{ $linkstyle }}">{{ pathUnescape (index . 0) }}</a> / 30 29 {{ end }} 31 30 </div> 32 31 <div id="dir-info" class="text-gray-500 dark:text-gray-400 text-xs md:text-sm flex flex-wrap items-center gap-1 md:gap-0"> 33 32 {{ $stats := .TreeStats }} 34 33 35 - <span>at <a href="/{{ $.RepoInfo.FullName }}/tree/{{ $.Ref }}">{{ $.Ref }}</a></span> 34 + <span>at <a href="/{{ $.RepoInfo.FullName }}/tree/{{ pathEscape $.Ref }}">{{ $.Ref }}</a></span> 36 35 {{ if eq $stats.NumFolders 1 }} 37 36 <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span> 38 37 <span>{{ $stats.NumFolders }} folder</span> ··· 54 53 </div> 55 54 56 55 {{ range .Files }} 57 - {{ if not .IsFile }} 58 - <div class="{{ $containerstyle }}"> 59 - <div class="flex justify-between items-center"> 60 - <a href="/{{ $.BaseTreeLink }}/{{ .Name }}" class="{{ $linkstyle }}"> 61 - <div class="flex items-center gap-2"> 62 - {{ i "folder" "size-4 fill-current" }}{{ .Name }} 63 - </div> 64 - </a> 65 - {{ if .LastCommit}} 66 - <time class="text-xs text-gray-500 dark:text-gray-400">{{ timeFmt .LastCommit.When }}</time> 67 - {{ end }} 56 + <div class="grid grid-cols-12 gap-4 items-center py-1"> 57 + <div class="col-span-8 md:col-span-4"> 58 + {{ $link := printf "/%s/%s/%s/%s/%s" $.RepoInfo.FullName "tree" (pathEscape $.Ref) $.TreePath .Name }} 59 + {{ $icon := "folder" }} 60 + {{ $iconStyle := "size-4 fill-current" }} 61 + 62 + {{ if .IsFile }} 63 + {{ $icon = "file" }} 64 + {{ $iconStyle = "size-4" }} 65 + {{ end }} 66 + <a href="{{ $link }}" class="{{ $linkstyle }}"> 67 + <div class="flex items-center gap-2"> 68 + {{ i $icon $iconStyle "flex-shrink-0" }} 69 + <span class="truncate">{{ .Name }}</span> 70 + </div> 71 + </a> 72 + </div> 73 + 74 + <div class="col-span-0 md:col-span-6 hidden md:block overflow-hidden"> 75 + {{ with .LastCommit }} 76 + <a href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash }}" class="text-gray-500 dark:text-gray-400 block truncate">{{ .Message }}</a> 77 + {{ end }} 68 78 </div> 69 - </div> 70 - {{ end }} 71 - {{ end }} 72 79 73 - {{ range .Files }} 74 - {{ if .IsFile }} 75 - <div class="{{ $containerstyle }}"> 76 - <div class="flex justify-between items-center"> 77 - <a href="/{{ $.BaseBlobLink }}/{{ .Name }}" class="{{ $linkstyle }}"> 78 - <div class="flex items-center gap-2"> 79 - {{ i "file" "size-4" }}{{ .Name }} 80 - </div> 81 - </a> 82 - {{ if .LastCommit}} 83 - <time class="text-xs text-gray-500 dark:text-gray-400">{{ timeFmt .LastCommit.When }}</time> 84 - {{ end }} 80 + <div class="col-span-4 md:col-span-2 text-sm text-right"> 81 + {{ with .LastCommit }} 82 + <a href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash }}" class="text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" .When }}</a> 83 + {{ end }} 85 84 </div> 86 - </div> 85 + </div> 87 86 {{ end }} 88 - {{ end }} 87 + 89 88 </div> 90 89 </main> 91 90 {{end}}
-192
appview/pages/templates/settings.html
··· 1 - {{ define "title" }}settings{{ end }} 2 - 3 - {{ define "content" }} 4 - <div class="p-6"> 5 - <p class="text-xl font-bold dark:text-white">Settings</p> 6 - </div> 7 - <div class="flex flex-col"> 8 - {{ block "profile" . }} {{ end }} 9 - {{ block "keys" . }} {{ end }} 10 - {{ block "emails" . }} {{ end }} 11 - </div> 12 - {{ end }} 13 - 14 - {{ define "profile" }} 15 - <h2 class="text-sm font-bold py-2 px-6 uppercase dark:text-gray-300">profile</h2> 16 - <section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit"> 17 - <dl class="grid grid-cols-[auto_1fr] gap-x-4 dark:text-gray-200"> 18 - {{ if .LoggedInUser.Handle }} 19 - <dt class="font-bold">handle</dt> 20 - <dd>@{{ .LoggedInUser.Handle }}</dd> 21 - {{ end }} 22 - <dt class="font-bold">did</dt> 23 - <dd>{{ .LoggedInUser.Did }}</dd> 24 - <dt class="font-bold">pds</dt> 25 - <dd>{{ .LoggedInUser.Pds }}</dd> 26 - </dl> 27 - </section> 28 - {{ end }} 29 - 30 - {{ define "keys" }} 31 - <h2 class="text-sm font-bold py-2 px-6 uppercase dark:text-gray-300">ssh keys</h2> 32 - <section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit"> 33 - <p class="mb-8 dark:text-gray-300">SSH public keys added here will be broadcasted to knots that you are a member of, <br> allowing you to push to repositories there.</p> 34 - <div id="key-list" class="flex flex-col gap-6 mb-8"> 35 - {{ range $index, $key := .PubKeys }} 36 - <div class="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-4"> 37 - <div class="flex flex-col gap-1"> 38 - <div class="inline-flex items-center gap-4"> 39 - {{ i "key" "w-3 h-3 dark:text-gray-300" }} 40 - <p class="font-bold dark:text-white">{{ .Name }}</p> 41 - </div> 42 - <p class="text-sm text-gray-500 dark:text-gray-400">added {{ .Created | timeFmt }}</p> 43 - <div class="overflow-x-auto whitespace-nowrap flex-1 max-w-full"> 44 - <code class="text-sm text-gray-500 dark:text-gray-400">{{ .Key }}</code> 45 - </div> 46 - </div> 47 - <button 48 - class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 49 - title="Delete key" 50 - hx-delete="/settings/keys?name={{urlquery .Name}}&rkey={{urlquery .Rkey}}&key={{urlquery .Key}}" 51 - hx-confirm="Are you sure you want to delete the key '{{ .Name }}'?" 52 - > 53 - {{ i "trash-2" "w-5 h-5" }} 54 - <span class="hidden md:inline">delete</span> 55 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 56 - </button> 57 - </div> 58 - {{ end }} 59 - </div> 60 - <form 61 - hx-put="/settings/keys" 62 - hx-indicator="#add-sshkey-spinner" 63 - hx-swap="none" 64 - class="max-w-2xl mb-8 space-y-4" 65 - > 66 - <input 67 - type="text" 68 - id="name" 69 - name="name" 70 - placeholder="key name" 71 - required 72 - class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400"/> 73 - 74 - <input 75 - id="key" 76 - name="key" 77 - placeholder="ssh-rsa AAAAAA..." 78 - required 79 - class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400"/> 80 - 81 - <button class="btn dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 flex gap-2 items-center" type="submit"> 82 - <span>add key</span> 83 - <span id="add-sshkey-spinner" class="group"> 84 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 85 - </span> 86 - </button> 87 - 88 - <div id="settings-keys" class="error dark:text-red-400"></div> 89 - </form> 90 - </section> 91 - {{ end }} 92 - 93 - {{ define "emails" }} 94 - <h2 class="text-sm font-bold py-2 px-6 uppercase dark:text-gray-300">email addresses</h2> 95 - <section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit"> 96 - <p class="mb-8 dark:text-gray-300">Commits authored using emails listed here will be associated with your Tangled profile.</p> 97 - <div id="email-list" class="flex flex-col gap-6 mb-8"> 98 - {{ range $index, $email := .Emails }} 99 - <div class="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-4"> 100 - <div class="flex flex-col gap-2"> 101 - <div class="inline-flex items-center gap-4"> 102 - {{ i "mail" "w-3 h-3 dark:text-gray-300" }} 103 - <p class="font-bold dark:text-white">{{ .Address }}</p> 104 - <div class="inline-flex items-center gap-1"> 105 - {{ if .Verified }} 106 - <span class="text-xs bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 px-2 py-1 rounded">verified</span> 107 - {{ else }} 108 - <span class="text-xs bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 px-2 py-1 rounded">unverified</span> 109 - {{ end }} 110 - {{ if .Primary }} 111 - <span class="text-xs bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 px-2 py-1 rounded">primary</span> 112 - {{ end }} 113 - </div> 114 - </div> 115 - <p class="text-sm text-gray-500 dark:text-gray-400">added {{ .CreatedAt | timeFmt }}</p> 116 - </div> 117 - <div class="flex gap-2 items-center"> 118 - {{ if not .Verified }} 119 - <button 120 - class="btn flex gap-2 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600" 121 - hx-post="/settings/emails/verify/resend" 122 - hx-swap="none" 123 - href="#" 124 - hx-vals='{"email": "{{ .Address }}"}'> 125 - {{ i "rotate-cw" "w-5 h-5" }} 126 - <span class="hidden md:inline">resend</span> 127 - </button> 128 - {{ end }} 129 - {{ if and (not .Primary) .Verified }} 130 - <a 131 - class="text-sm dark:text-blue-400 dark:hover:text-blue-300" 132 - hx-post="/settings/emails/primary" 133 - hx-swap="none" 134 - href="#" 135 - hx-vals='{"email": "{{ .Address }}"}'> 136 - set as primary 137 - </a> 138 - {{ end }} 139 - {{ if not .Primary }} 140 - <form 141 - hx-delete="/settings/emails" 142 - hx-confirm="Are you sure you wish to delete the email '{{ .Address }}'?" 143 - hx-indicator="#delete-email-{{ $index }}-spinner" 144 - > 145 - <input type="hidden" name="email" value="{{ .Address }}"> 146 - <button 147 - class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 flex gap-2 items-center" 148 - title="Delete email" 149 - type="submit" 150 - > 151 - {{ i "trash-2" "w-5 h-5" }} 152 - <span class="hidden md:inline">delete</span> 153 - <span id="delete-email-{{ $index }}-spinner" class="group"> 154 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 155 - </span> 156 - </button> 157 - </form> 158 - {{ end }} 159 - </div> 160 - </div> 161 - {{ end }} 162 - </div> 163 - <form 164 - hx-put="/settings/emails" 165 - hx-swap="none" 166 - class="max-w-2xl mb-8 space-y-4" 167 - hx-indicator="#add-email-spinner" 168 - > 169 - <input 170 - type="email" 171 - id="email" 172 - name="email" 173 - placeholder="your@email.com" 174 - required 175 - class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400" 176 - > 177 - 178 - <button 179 - class="btn dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 flex gap-2 items-center" 180 - type="submit" 181 - > 182 - <span>add email</span> 183 - <span id="add-email-spinner" class="group"> 184 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 185 - </span> 186 - </button> 187 - 188 - <div id="settings-emails-error" class="error dark:text-red-400"></div> 189 - <div id="settings-emails-success" class="success dark:text-green-400"></div> 190 - </form> 191 - </section> 192 - {{ end }}
+117
appview/pages/templates/spindles/dashboard.html
··· 1 + {{ define "title" }}{{.Spindle.Instance}} &middot; spindles{{ end }} 2 + 3 + {{ define "content" }} 4 + <div class="px-6 py-4"> 5 + <div class="flex justify-between items-center"> 6 + <h1 class="text-xl font-bold dark:text-white">{{ .Spindle.Instance }}</h1> 7 + <div id="right-side" class="flex gap-2"> 8 + {{ $style := "px-2 py-1 rounded flex items-center flex-shrink-0 gap-2" }} 9 + {{ $isOwner := and .LoggedInUser (eq .LoggedInUser.Did .Spindle.Owner) }} 10 + {{ if .Spindle.Verified }} 11 + <span class="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 {{$style}}">{{ i "shield-check" "w-4 h-4" }} verified</span> 12 + {{ if $isOwner }} 13 + {{ template "spindles/fragments/addMemberModal" .Spindle }} 14 + {{ end }} 15 + {{ else }} 16 + <span class="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 {{$style}}">{{ i "shield-off" "w-4 h-4" }} unverified</span> 17 + {{ if $isOwner }} 18 + {{ block "retryButton" .Spindle }} {{ end }} 19 + {{ end }} 20 + {{ end }} 21 + 22 + {{ if $isOwner }} 23 + {{ block "deleteButton" .Spindle }} {{ end }} 24 + {{ end }} 25 + </div> 26 + </div> 27 + <div id="operation-error" class="dark:text-red-400"></div> 28 + </div> 29 + 30 + {{ if .Members }} 31 + <section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 32 + <div class="flex flex-col gap-2"> 33 + {{ block "member" . }} {{ end }} 34 + </div> 35 + </section> 36 + {{ end }} 37 + {{ end }} 38 + 39 + 40 + {{ define "member" }} 41 + {{ range .Members }} 42 + <div> 43 + <div class="flex justify-between items-center"> 44 + <div class="flex items-center gap-2"> 45 + {{ template "user/fragments/picHandleLink" . }} 46 + </div> 47 + {{ if ne $.LoggedInUser.Did . }} 48 + {{ block "removeMemberButton" (list $ . ) }} {{ end }} 49 + {{ end }} 50 + </div> 51 + <div class="ml-2 pl-2 pt-2 border-l border-gray-200 dark:border-gray-700"> 52 + {{ $repos := index $.Repos . }} 53 + {{ range $repos }} 54 + <div class="flex gap-2 items-center"> 55 + {{ i "book-marked" "size-4" }} 56 + <a href="/{{ .Did }}/{{ .Name }}"> 57 + {{ .Name }} 58 + </a> 59 + </div> 60 + {{ else }} 61 + <div class="text-gray-500 dark:text-gray-400"> 62 + No repositories configured yet. 63 + </div> 64 + {{ end }} 65 + </div> 66 + </div> 67 + {{ end }} 68 + {{ end }} 69 + 70 + {{ define "deleteButton" }} 71 + <button 72 + class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 73 + title="Delete spindle" 74 + hx-delete="/spindles/{{ .Instance }}" 75 + hx-swap="outerHTML" 76 + hx-confirm="Are you sure you want to delete the spindle '{{ .Instance }}'?" 77 + hx-headers='{"shouldRedirect": "true"}' 78 + > 79 + {{ i "trash-2" "w-5 h-5" }} 80 + <span class="hidden md:inline">delete</span> 81 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 82 + </button> 83 + {{ end }} 84 + 85 + 86 + {{ define "retryButton" }} 87 + <button 88 + class="btn gap-2 group" 89 + title="Retry spindle verification" 90 + hx-post="/spindles/{{ .Instance }}/retry" 91 + hx-swap="none" 92 + hx-headers='{"shouldRefresh": "true"}' 93 + > 94 + {{ i "rotate-ccw" "w-5 h-5" }} 95 + <span class="hidden md:inline">retry</span> 96 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 97 + </button> 98 + {{ end }} 99 + 100 + 101 + {{ define "removeMemberButton" }} 102 + {{ $root := index . 0 }} 103 + {{ $member := index . 1 }} 104 + <button 105 + class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 106 + title="Remove member" 107 + hx-post="/spindles/{{ $root.Spindle.Instance }}/remove" 108 + hx-swap="none" 109 + hx-vals='{"member": "{{$member}}" }' 110 + hx-confirm="Are you sure you want to remove {{ resolve $member }} from this instance?" 111 + > 112 + {{ i "user-minus" "w-4 h-4" }} 113 + remove 114 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 115 + </button> 116 + {{ end }} 117 +
+57
appview/pages/templates/spindles/fragments/addMemberModal.html
··· 1 + {{ define "spindles/fragments/addMemberModal" }} 2 + <button 3 + class="btn gap-2 group" 4 + title="Add member to this spindle" 5 + popovertarget="add-member-{{ .Instance }}" 6 + popovertargetaction="toggle" 7 + > 8 + {{ i "user-plus" "w-5 h-5" }} 9 + <span class="hidden md:inline">add member</span> 10 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 11 + </button> 12 + 13 + <div 14 + id="add-member-{{ .Instance }}" 15 + popover 16 + class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50"> 17 + {{ block "addSpindleMemberPopover" . }} {{ end }} 18 + </div> 19 + {{ end }} 20 + 21 + {{ define "addSpindleMemberPopover" }} 22 + <form 23 + hx-post="/spindles/{{ .Instance }}/add" 24 + hx-indicator="#spinner" 25 + hx-swap="none" 26 + class="flex flex-col gap-2" 27 + > 28 + <label for="member-did-{{ .Id }}" class="uppercase p-0"> 29 + ADD MEMBER 30 + </label> 31 + <p class="text-sm text-gray-500 dark:text-gray-400">Members can register repositories and run workflows on this spindle.</p> 32 + <input 33 + type="text" 34 + id="member-did-{{ .Id }}" 35 + name="member" 36 + required 37 + placeholder="@foo.bsky.social" 38 + /> 39 + <div class="flex gap-2 pt-2"> 40 + <button 41 + type="button" 42 + popovertarget="add-member-{{ .Instance }}" 43 + popovertargetaction="hide" 44 + class="btn w-1/2 flex items-center gap-2 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300" 45 + > 46 + {{ i "x" "size-4" }} cancel 47 + </button> 48 + <button type="submit" class="btn w-1/2 flex items-center"> 49 + <span class="inline-flex gap-2 items-center">{{ i "user-plus" "size-4" }} add</span> 50 + <span id="spinner" class="group"> 51 + {{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 52 + </span> 53 + </button> 54 + </div> 55 + <div id="add-member-error-{{ .Id }}" class="text-red-500 dark:text-red-400"></div> 56 + </form> 57 + {{ end }}
+35 -9
appview/pages/templates/spindles/fragments/spindleListing.html
··· 1 1 {{ define "spindles/fragments/spindleListing" }} 2 - <div id="spindle-{{.Id}}" class="flex items-center justify-between p-2 border-b border-gray-200 dark:border-gray-700"> 3 - <div id="left-side" class="flex items-center gap-2 min-w-0 max-w-[60%]"> 2 + <div id="spindle-{{.Id}}" class="flex items-center justify-between p-2 border-b border-gray-200 dark:border-gray-700"> 3 + {{ block "spindleLeftSide" . }} {{ end }} 4 + {{ block "spindleRightSide" . }} {{ end }} 5 + </div> 6 + {{ end }} 7 + 8 + {{ define "spindleLeftSide" }} 9 + {{ if .Verified }} 10 + <a href="/spindles/{{ .Instance }}" class="hover:no-underline flex items-center gap-2 min-w-0 max-w-[60%]"> 11 + {{ i "hard-drive" "w-4 h-4" }} 12 + <span class="hover:underline"> 13 + {{ .Instance }} 14 + </span> 15 + <span class="text-gray-500"> 16 + {{ template "repo/fragments/shortTimeAgo" .Created }} 17 + </span> 18 + </a> 19 + {{ else }} 20 + <div class="hover:no-underline flex items-center gap-2 min-w-0 max-w-[60%]"> 4 21 {{ i "hard-drive" "w-4 h-4" }} 5 22 {{ .Instance }} 6 23 <span class="text-gray-500"> 7 - {{ .Created | shortTimeFmt }} ago 24 + {{ template "repo/fragments/shortTimeAgo" .Created }} 8 25 </span> 9 26 </div> 27 + {{ end }} 28 + {{ end }} 29 + 30 + {{ define "spindleRightSide" }} 10 31 <div id="right-side" class="flex gap-2"> 11 32 {{ $style := "px-2 py-1 rounded flex items-center flex-shrink-0 gap-2 text-sm" }} 12 - {{ if .Verified }} 33 + 34 + {{ if .NeedsUpgrade }} 35 + <span class="bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 {{$style}}"> {{ i "shield-alert" "w-4 h-4" }} needs upgrade </span> 36 + {{ block "spindleRetryButton" . }} {{ end }} 37 + {{ else if .Verified }} 13 38 <span class="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 {{$style}}">{{ i "shield-check" "w-4 h-4" }} verified</span> 39 + {{ template "spindles/fragments/addMemberModal" . }} 14 40 {{ else }} 15 41 <span class="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 {{$style}}">{{ i "shield-off" "w-4 h-4" }} unverified</span> 16 - {{ block "retryButton" . }} {{ end }} 42 + {{ block "spindleRetryButton" . }} {{ end }} 17 43 {{ end }} 18 - {{ block "deleteButton" . }} {{ end }} 44 + 45 + {{ block "spindleDeleteButton" . }} {{ end }} 19 46 </div> 20 - </div> 21 47 {{ end }} 22 48 23 - {{ define "deleteButton" }} 49 + {{ define "spindleDeleteButton" }} 24 50 <button 25 51 class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 26 52 title="Delete spindle" ··· 36 62 {{ end }} 37 63 38 64 39 - {{ define "retryButton" }} 65 + {{ define "spindleRetryButton" }} 40 66 <button 41 67 class="btn gap-2 group" 42 68 title="Retry spindle verification"
+21 -7
appview/pages/templates/spindles/index.html
··· 1 1 {{ define "title" }}spindles{{ end }} 2 2 3 3 {{ define "content" }} 4 - <div class="px-6 py-4"> 5 - <h1 class="text-xl font-bold dark:text-white">Spindles</h1> 4 + <div class="px-6 py-4 flex items-center justify-between gap-4 align-bottom"> 5 + <h1 class="text-xl font-bold dark:text-white">Spindles</h1> 6 + <span class="flex items-center gap-1"> 7 + {{ i "book" "w-3 h-3" }} 8 + <a href="https://tangled.sh/@tangled.sh/core/blob/master/docs/spindle/hosting.md">docs</a> 9 + </span> 6 10 </div> 7 11 8 12 <section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 9 13 <div class="flex flex-col gap-6"> 10 - {{ block "all" . }} {{ end }} 14 + {{ block "about" . }} {{ end }} 15 + {{ block "list" . }} {{ end }} 11 16 {{ block "register" . }} {{ end }} 12 17 </div> 13 18 </section> 14 19 {{ end }} 15 20 16 - {{ define "all" }} 21 + {{ define "about" }} 22 + <section class="rounded flex items-center gap-2"> 23 + <p class="text-gray-500 dark:text-gray-400"> 24 + Spindles are small CI runners. 25 + </p> 26 + </section> 27 + {{ end }} 28 + 29 + {{ define "list" }} 17 30 <section class="rounded w-full flex flex-col gap-2"> 18 31 <h2 class="text-sm font-bold py-2 uppercase dark:text-gray-300">your spindles</h2> 19 32 <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 w-full"> ··· 25 38 </div> 26 39 {{ end }} 27 40 </div> 28 - <div id="operation-error" class="dark:text-red-400"></div> 41 + <div id="operation-error" class="text-red-500 dark:text-red-400"></div> 29 42 </section> 30 43 {{ end }} 31 44 ··· 36 49 <form 37 50 hx-post="/spindles/register" 38 51 class="max-w-2xl mb-2 space-y-4" 39 - hx-indicator="#register-spinner" 52 + hx-indicator="#register-button" 40 53 hx-swap="none" 41 54 > 42 55 <div class="flex gap-2"> ··· 50 63 > 51 64 <button 52 65 type="submit" 66 + id="register-button" 53 67 class="btn rounded flex items-center py-2 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 group" 54 68 > 55 69 <span class="inline-flex items-center gap-2"> 56 70 {{ i "plus" "w-4 h-4" }} 57 71 register 58 72 </span> 59 - <span id="register-spinner" class="pl-2 hidden group-[.htmx-request]:inline"> 73 + <span class="pl-2 hidden group-[.htmx-request]:inline"> 60 74 {{ i "loader-circle" "w-4 h-4 animate-spin" }} 61 75 </span> 62 76 </button>
+57
appview/pages/templates/strings/dashboard.html
··· 1 + {{ define "title" }}strings by {{ or .Card.UserHandle .Card.UserDid }}{{ end }} 2 + 3 + {{ define "extrameta" }} 4 + <meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}" /> 5 + <meta property="og:type" content="profile" /> 6 + <meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}" /> 7 + <meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" /> 8 + {{ end }} 9 + 10 + 11 + {{ define "content" }} 12 + <div class="grid grid-cols-1 md:grid-cols-11 gap-4"> 13 + <div class="md:col-span-3 order-1 md:order-1"> 14 + {{ template "user/fragments/profileCard" .Card }} 15 + </div> 16 + <div id="all-strings" class="md:col-span-8 order-2 md:order-2"> 17 + {{ block "allStrings" . }}{{ end }} 18 + </div> 19 + </div> 20 + {{ end }} 21 + 22 + {{ define "allStrings" }} 23 + <p class="text-sm font-bold p-2 dark:text-white">ALL STRINGS</p> 24 + <div id="strings" class="grid grid-cols-1 gap-4 mb-6"> 25 + {{ range .Strings }} 26 + {{ template "singleString" (list $ .) }} 27 + {{ else }} 28 + <p class="px-6 dark:text-white">This user does not have any strings yet.</p> 29 + {{ end }} 30 + </div> 31 + {{ end }} 32 + 33 + {{ define "singleString" }} 34 + {{ $root := index . 0 }} 35 + {{ $s := index . 1 }} 36 + <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800"> 37 + <div class="font-medium dark:text-white flex gap-2 items-center"> 38 + <a href="/strings/{{ or $root.Card.UserHandle $root.Card.UserDid }}/{{ $s.Rkey }}">{{ $s.Filename }}</a> 39 + </div> 40 + {{ with $s.Description }} 41 + <div class="text-gray-600 dark:text-gray-300 text-sm"> 42 + {{ . }} 43 + </div> 44 + {{ end }} 45 + 46 + {{ $stat := $s.Stats }} 47 + <div class="text-gray-400 pt-4 text-sm font-mono inline-flex gap-2 mt-auto"> 48 + <span>{{ $stat.LineCount }} line{{if ne $stat.LineCount 1}}s{{end}}</span> 49 + <span class="select-none [&:before]:content-['ยท']"></span> 50 + {{ with $s.Edited }} 51 + <span>edited {{ template "repo/fragments/shortTimeAgo" . }}</span> 52 + {{ else }} 53 + {{ template "repo/fragments/shortTimeAgo" $s.Created }} 54 + {{ end }} 55 + </div> 56 + </div> 57 + {{ end }}
+90
appview/pages/templates/strings/fragments/form.html
··· 1 + {{ define "strings/fragments/form" }} 2 + <form 3 + {{ if eq .Action "new" }} 4 + hx-post="/strings/new" 5 + {{ else }} 6 + hx-post="/strings/{{.String.Did}}/{{.String.Rkey}}/edit" 7 + {{ end }} 8 + hx-indicator="#new-button" 9 + class="p-6 pb-4 dark:text-white flex flex-col gap-2 bg-white dark:bg-gray-800 drop-shadow-sm rounded" 10 + hx-swap="none"> 11 + <div class="flex flex-col md:flex-row md:items-center gap-2"> 12 + <input 13 + type="text" 14 + id="filename" 15 + name="filename" 16 + placeholder="Filename" 17 + required 18 + value="{{ .String.Filename }}" 19 + class="md:max-w-64 dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400 px-3 py-2 border rounded" 20 + > 21 + <input 22 + type="text" 23 + id="description" 24 + name="description" 25 + value="{{ .String.Description }}" 26 + placeholder="Description ..." 27 + class="flex-1 dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400 px-3 py-2 border rounded" 28 + > 29 + </div> 30 + <textarea 31 + name="content" 32 + id="content-textarea" 33 + wrap="off" 34 + class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400 font-mono" 35 + rows="20" 36 + spellcheck="false" 37 + placeholder="Paste your string here!" 38 + required>{{ .String.Contents }}</textarea> 39 + <div class="flex justify-between items-center"> 40 + <div id="content-stats" class="text-sm text-gray-500 dark:text-gray-400"> 41 + <span id="line-count">0 lines</span> 42 + <span class="select-none px-1 [&:before]:content-['ยท']"></span> 43 + <span id="byte-count">0 bytes</span> 44 + </div> 45 + <div id="actions" class="flex gap-2 items-center"> 46 + {{ if eq .Action "edit" }} 47 + <a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 " 48 + href="/strings/{{ .String.Did }}/{{ .String.Rkey }}"> 49 + {{ i "x" "size-4" }} 50 + <span class="hidden md:inline">cancel</span> 51 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 52 + </a> 53 + {{ end }} 54 + <button 55 + type="submit" 56 + id="new-button" 57 + class="w-fit btn-create rounded flex items-center py-0 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 group" 58 + > 59 + <span class="inline-flex items-center gap-2"> 60 + {{ i "arrow-up" "w-4 h-4" }} 61 + publish 62 + </span> 63 + <span class="pl-2 hidden group-[.htmx-request]:inline"> 64 + {{ i "loader-circle" "w-4 h-4 animate-spin" }} 65 + </span> 66 + </button> 67 + </div> 68 + </div> 69 + <script> 70 + (function() { 71 + const textarea = document.getElementById('content-textarea'); 72 + const lineCount = document.getElementById('line-count'); 73 + const byteCount = document.getElementById('byte-count'); 74 + function updateStats() { 75 + const content = textarea.value; 76 + const lines = content === '' ? 0 : content.split('\n').length; 77 + const bytes = new TextEncoder().encode(content).length; 78 + lineCount.textContent = `${lines} line${lines !== 1 ? 's' : ''}`; 79 + byteCount.textContent = `${bytes} byte${bytes !== 1 ? 's' : ''}`; 80 + } 81 + textarea.addEventListener('input', updateStats); 82 + textarea.addEventListener('paste', () => { 83 + setTimeout(updateStats, 0); 84 + }); 85 + updateStats(); 86 + })(); 87 + </script> 88 + <div id="error" class="error dark:text-red-400"></div> 89 + </form> 90 + {{ end }}
+13
appview/pages/templates/strings/put.html
··· 1 + {{ define "title" }}publish a new string{{ end }} 2 + 3 + {{ define "content" }} 4 + <div class="px-6 py-2 mb-4"> 5 + {{ if eq .Action "new" }} 6 + <p class="text-xl font-bold dark:text-white">Create a new string</p> 7 + <p class="">Store and share code snippets with ease.</p> 8 + {{ else }} 9 + <p class="text-xl font-bold dark:text-white">Edit string</p> 10 + {{ end }} 11 + </div> 12 + {{ template "strings/fragments/form" . }} 13 + {{ end }}
+85
appview/pages/templates/strings/string.html
··· 1 + {{ define "title" }}{{ .String.Filename }} ยท by {{ didOrHandle .Owner.DID.String .Owner.Handle.String }}{{ end }} 2 + 3 + {{ define "extrameta" }} 4 + {{ $ownerId := didOrHandle .Owner.DID.String .Owner.Handle.String }} 5 + <meta property="og:title" content="{{ .String.Filename }} ยท by {{ $ownerId }}" /> 6 + <meta property="og:type" content="object" /> 7 + <meta property="og:url" content="https://tangled.sh/strings/{{ $ownerId }}/{{ .String.Rkey }}" /> 8 + <meta property="og:description" content="{{ .String.Description }}" /> 9 + {{ end }} 10 + 11 + {{ define "content" }} 12 + {{ $ownerId := didOrHandle .Owner.DID.String .Owner.Handle.String }} 13 + <section id="string-header" class="mb-4 py-2 px-6 dark:text-white"> 14 + <div class="text-lg flex items-center justify-between"> 15 + <div> 16 + <a href="/strings/{{ $ownerId }}">{{ $ownerId }}</a> 17 + <span class="select-none">/</span> 18 + <a href="/strings/{{ $ownerId }}/{{ .String.Rkey }}" class="font-bold">{{ .String.Filename }}</a> 19 + </div> 20 + {{ if and .LoggedInUser (eq .LoggedInUser.Did .String.Did) }} 21 + <div class="flex gap-2 text-base"> 22 + <a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group" 23 + hx-boost="true" 24 + href="/strings/{{ .String.Did }}/{{ .String.Rkey }}/edit"> 25 + {{ i "pencil" "size-4" }} 26 + <span class="hidden md:inline">edit</span> 27 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 28 + </a> 29 + <button 30 + class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group p-2" 31 + title="Delete string" 32 + hx-delete="/strings/{{ .String.Did }}/{{ .String.Rkey }}/" 33 + hx-swap="none" 34 + hx-confirm="Are you sure you want to delete the string `{{ .String.Filename }}`?" 35 + > 36 + {{ i "trash-2" "size-4" }} 37 + <span class="hidden md:inline">delete</span> 38 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 39 + </button> 40 + </div> 41 + {{ end }} 42 + </div> 43 + <span> 44 + {{ with .String.Description }} 45 + {{ . }} 46 + {{ end }} 47 + </span> 48 + </section> 49 + <section class="bg-white dark:bg-gray-800 px-6 py-4 rounded relative w-full dark:text-white"> 50 + <div class="flex justify-between items-center text-gray-500 dark:text-gray-400 text-sm md:text-base pb-2 mb-3 text-base border-b border-gray-200 dark:border-gray-700"> 51 + <span> 52 + {{ .String.Filename }} 53 + <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span> 54 + <span> 55 + {{ with .String.Edited }} 56 + edited {{ template "repo/fragments/shortTimeAgo" . }} 57 + {{ else }} 58 + {{ template "repo/fragments/shortTimeAgo" .String.Created }} 59 + {{ end }} 60 + </span> 61 + </span> 62 + <div> 63 + <span>{{ .Stats.LineCount }} lines</span> 64 + <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span> 65 + <span>{{ byteFmt .Stats.ByteCount }}</span> 66 + <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span> 67 + <a href="/strings/{{ $ownerId }}/{{ .String.Rkey }}/raw">view raw</a> 68 + {{ if .RenderToggle }} 69 + <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span> 70 + <a href="?code={{ .ShowRendered }}" hx-boost="true"> 71 + view {{ if .ShowRendered }}code{{ else }}rendered{{ end }} 72 + </a> 73 + {{ end }} 74 + </div> 75 + </div> 76 + <div class="overflow-x-auto overflow-y-hidden relative"> 77 + {{ if .ShowRendered }} 78 + <div id="blob-contents" class="prose dark:prose-invert">{{ .RenderedContents }}</div> 79 + {{ else }} 80 + <div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ .String.Contents | escapeHtml }}</div> 81 + {{ end }} 82 + </div> 83 + {{ template "fragments/multiline-select" }} 84 + </section> 85 + {{ end }}
+61
appview/pages/templates/strings/timeline.html
··· 1 + {{ define "title" }} all strings {{ end }} 2 + 3 + {{ define "content" }} 4 + {{ block "timeline" $ }}{{ end }} 5 + {{ end }} 6 + 7 + {{ define "timeline" }} 8 + <div> 9 + <div class="p-6"> 10 + <p class="text-xl font-bold dark:text-white">All strings</p> 11 + </div> 12 + 13 + <div class="flex flex-col gap-4"> 14 + {{ range $i, $s := .Strings }} 15 + <div class="relative"> 16 + {{ if ne $i 0 }} 17 + <div class="absolute left-8 -top-4 w-px h-4 bg-gray-300 dark:bg-gray-600"></div> 18 + {{ end }} 19 + <div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-sm"> 20 + {{ template "stringCard" $s }} 21 + </div> 22 + </div> 23 + {{ end }} 24 + </div> 25 + </div> 26 + {{ end }} 27 + 28 + {{ define "stringCard" }} 29 + <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800"> 30 + <div class="font-medium dark:text-white flex gap-2 items-center"> 31 + <a href="/strings/{{ resolve .Did.String }}/{{ .Rkey }}">{{ .Filename }}</a> 32 + </div> 33 + {{ with .Description }} 34 + <div class="text-gray-600 dark:text-gray-300 text-sm"> 35 + {{ . }} 36 + </div> 37 + {{ end }} 38 + 39 + {{ template "stringCardInfo" . }} 40 + </div> 41 + {{ end }} 42 + 43 + {{ define "stringCardInfo" }} 44 + {{ $stat := .Stats }} 45 + {{ $resolved := resolve .Did.String }} 46 + <div class="text-gray-400 pt-4 text-sm font-mono inline-flex items-center gap-2 mt-auto"> 47 + <a href="/strings/{{ $resolved }}" class="flex items-center"> 48 + {{ template "user/fragments/picHandle" $resolved }} 49 + </a> 50 + <span class="select-none [&:before]:content-['ยท']"></span> 51 + <span>{{ $stat.LineCount }} line{{if ne $stat.LineCount 1}}s{{end}}</span> 52 + <span class="select-none [&:before]:content-['ยท']"></span> 53 + {{ with .Edited }} 54 + <span>edited {{ template "repo/fragments/shortTimeAgo" . }}</span> 55 + {{ else }} 56 + {{ template "repo/fragments/shortTimeAgo" .Created }} 57 + {{ end }} 58 + </div> 59 + {{ end }} 60 + 61 +
+34
appview/pages/templates/timeline/fragments/hero.html
··· 1 + {{ define "timeline/fragments/hero" }} 2 + <div class="mx-auto max-w-[100rem] flex flex-col text-black dark:text-white px-6 py-4 gap-6 items-center md:flex-row"> 3 + <div class="flex flex-col gap-6"> 4 + <h1 class="font-bold text-4xl">tightly-knit<br>social coding.</h1> 5 + 6 + <p class="text-lg"> 7 + tangled is new social-enabled git collaboration platform built on <a class="underline" href="https://atproto.com/">atproto</a>. 8 + </p> 9 + <p class="text-lg"> 10 + we envision a place where developers have complete ownership of their 11 + code, open source communities can freely self-govern and most 12 + importantly, coding can be social and fun again. 13 + </p> 14 + 15 + <div class="flex gap-6 items-center"> 16 + <a href="/signup" class="no-underline hover:no-underline "> 17 + <button class="btn-create flex gap-2 px-4 items-center"> 18 + join now {{ i "arrow-right" "size-4" }} 19 + </button> 20 + </a> 21 + </div> 22 + </div> 23 + 24 + <figure class="w-full hidden md:block md:w-auto"> 25 + <a href="https://tangled.sh/@tangled.sh/core" class="block"> 26 + <img src="https://assets.tangled.network/hero-repo.png" alt="Screenshot of the Tangled monorepo." class="max-w-md mx-auto md:max-w-none w-full md:w-[30vw] h-auto shadow-sm rounded" /> 27 + </a> 28 + <figcaption class="text-sm text-gray-600 dark:text-gray-400 mt-2 text-center"> 29 + Monorepo for Tangled, built in the open with the community. 30 + </figcaption> 31 + </figure> 32 + </div> 33 + {{ end }} 34 +
+116
appview/pages/templates/timeline/fragments/timeline.html
··· 1 + {{ define "timeline/fragments/timeline" }} 2 + <div class="py-4"> 3 + <div class="px-6 pb-4"> 4 + <p class="text-xl font-bold dark:text-white">Timeline</p> 5 + </div> 6 + 7 + <div class="flex flex-col gap-4"> 8 + {{ range $i, $e := .Timeline }} 9 + <div class="relative"> 10 + {{ if ne $i 0 }} 11 + <div class="absolute left-8 -top-4 w-px h-4 bg-gray-300 dark:bg-gray-600"></div> 12 + {{ end }} 13 + {{ with $e }} 14 + <div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-sm"> 15 + {{ if .Repo }} 16 + {{ template "timeline/fragments/repoEvent" (list $ .Repo .Source) }} 17 + {{ else if .Star }} 18 + {{ template "timeline/fragments/starEvent" (list $ .Star) }} 19 + {{ else if .Follow }} 20 + {{ template "timeline/fragments/followEvent" (list $ .Follow .Profile .FollowStats) }} 21 + {{ end }} 22 + </div> 23 + {{ end }} 24 + </div> 25 + {{ end }} 26 + </div> 27 + </div> 28 + {{ end }} 29 + 30 + {{ define "timeline/fragments/repoEvent" }} 31 + {{ $root := index . 0 }} 32 + {{ $repo := index . 1 }} 33 + {{ $source := index . 2 }} 34 + {{ $userHandle := resolve $repo.Did }} 35 + <div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm"> 36 + {{ template "user/fragments/picHandleLink" $repo.Did }} 37 + {{ with $source }} 38 + {{ $sourceDid := resolve .Did }} 39 + forked 40 + <a href="/{{ $sourceDid }}/{{ .Name }}"class="no-underline hover:underline"> 41 + {{ $sourceDid }}/{{ .Name }} 42 + </a> 43 + to 44 + <a href="/{{ $userHandle }}/{{ $repo.Name }}" class="no-underline hover:underline">{{ $repo.Name }}</a> 45 + {{ else }} 46 + created 47 + <a href="/{{ $userHandle }}/{{ $repo.Name }}" class="no-underline hover:underline"> 48 + {{ $repo.Name }} 49 + </a> 50 + {{ end }} 51 + <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $repo.Created }}</span> 52 + </div> 53 + {{ with $repo }} 54 + {{ template "user/fragments/repoCard" (list $root . true) }} 55 + {{ end }} 56 + {{ end }} 57 + 58 + {{ define "timeline/fragments/starEvent" }} 59 + {{ $root := index . 0 }} 60 + {{ $star := index . 1 }} 61 + {{ with $star }} 62 + {{ $starrerHandle := resolve .StarredByDid }} 63 + {{ $repoOwnerHandle := resolve .Repo.Did }} 64 + <div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm"> 65 + {{ template "user/fragments/picHandleLink" $starrerHandle }} 66 + starred 67 + <a href="/{{ $repoOwnerHandle }}/{{ .Repo.Name }}" class="no-underline hover:underline"> 68 + {{ $repoOwnerHandle | truncateAt30 }}/{{ .Repo.Name }} 69 + </a> 70 + <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" .Created }}</span> 71 + </div> 72 + {{ with .Repo }} 73 + {{ template "user/fragments/repoCard" (list $root . true) }} 74 + {{ end }} 75 + {{ end }} 76 + {{ end }} 77 + 78 + {{ define "timeline/fragments/followEvent" }} 79 + {{ $root := index . 0 }} 80 + {{ $follow := index . 1 }} 81 + {{ $profile := index . 2 }} 82 + {{ $stat := index . 3 }} 83 + 84 + {{ $userHandle := resolve $follow.UserDid }} 85 + {{ $subjectHandle := resolve $follow.SubjectDid }} 86 + <div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm"> 87 + {{ template "user/fragments/picHandleLink" $userHandle }} 88 + followed 89 + {{ template "user/fragments/picHandleLink" $subjectHandle }} 90 + <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $follow.FollowedAt }}</span> 91 + </div> 92 + <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4"> 93 + <div class="flex-shrink-0 max-h-full w-24 h-24"> 94 + <img alt="" class="object-cover rounded-full p-2" src="{{ fullAvatar $subjectHandle }}" /> 95 + </div> 96 + 97 + <div class="flex-1 min-h-0 justify-around flex flex-col"> 98 + <a href="/{{ $subjectHandle }}"> 99 + <span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $subjectHandle | truncateAt30 }}</span> 100 + </a> 101 + {{ with $profile }} 102 + {{ with .Description }} 103 + <p class="text-sm pb-2 md:pb-2">{{.}}</p> 104 + {{ end }} 105 + {{ end }} 106 + {{ with $stat }} 107 + <div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> 108 + <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 109 + <span id="followers"><a href="/{{ $subjectHandle }}?tab=followers">{{ .Followers }} followers</a></span> 110 + <span class="select-none after:content-['ยท']"></span> 111 + <span id="following"><a href="/{{ $subjectHandle }}?tab=following">{{ .Following }} following</a></span> 112 + </div> 113 + {{ end }} 114 + </div> 115 + </div> 116 + {{ end }}
+25
appview/pages/templates/timeline/fragments/trending.html
··· 1 + {{ define "timeline/fragments/trending" }} 2 + <div class="w-full md:mx-0 py-4"> 3 + <div class="px-6 pb-4"> 4 + <h3 class="text-xl font-bold dark:text-white flex items-center gap-2"> 5 + Trending 6 + {{ i "trending-up" "size-4 flex-shrink-0" }} 7 + </h3> 8 + </div> 9 + <div class="flex gap-4 overflow-x-auto scrollbar-hide items-stretch"> 10 + {{ range $index, $repo := .Repos }} 11 + <div class="flex-none h-full border border-gray-200 dark:border-gray-700 rounded-sm w-96"> 12 + {{ template "user/fragments/repoCard" (list $ $repo true) }} 13 + </div> 14 + {{ else }} 15 + <div class="py-8 px-6 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-sm"> 16 + <div class="text-sm text-gray-500 dark:text-gray-400 text-center"> 17 + No trending repositories this week 18 + </div> 19 + </div> 20 + {{ end }} 21 + </div> 22 + </div> 23 + {{ end }} 24 + 25 +
+90
appview/pages/templates/timeline/home.html
··· 1 + {{ define "title" }}tangled &middot; tightly-knit social coding{{ end }} 2 + 3 + {{ define "extrameta" }} 4 + <meta property="og:title" content="timeline ยท tangled" /> 5 + <meta property="og:type" content="object" /> 6 + <meta property="og:url" content="https://tangled.sh" /> 7 + <meta property="og:description" content="tightly-knit social coding" /> 8 + {{ end }} 9 + 10 + 11 + {{ define "content" }} 12 + <div class="flex flex-col gap-4"> 13 + {{ template "timeline/fragments/hero" . }} 14 + {{ template "features" . }} 15 + {{ template "timeline/fragments/trending" . }} 16 + {{ template "timeline/fragments/timeline" . }} 17 + <div class="flex justify-end"> 18 + <a href="/timeline" class="inline-flex items-center gap-2 text-gray-500 dark:text-gray-400"> 19 + view more 20 + {{ i "arrow-right" "size-4" }} 21 + </a> 22 + </div> 23 + </div> 24 + {{ end }} 25 + 26 + 27 + {{ define "feature" }} 28 + {{ $info := index . 0 }} 29 + {{ $bullets := index . 1 }} 30 + <div class="flex flex-col items-center gap-6 md:flex-row md:items-top"> 31 + <div class="flex-1"> 32 + <h2 class="text-2xl font-bold text-black dark:text-white mb-6">{{ $info.title }}</h2> 33 + <ul class="leading-normal"> 34 + {{ range $bullets }} 35 + <li><p>{{ escapeHtml . }}</p></li> 36 + {{ end }} 37 + </ul> 38 + </div> 39 + <div class="flex-shrink-0 w-96 md:w-1/3"> 40 + <a href="{{ $info.image }}"> 41 + <img src="{{ $info.image }}" alt="{{ $info.alt }}" class="w-full h-auto rounded shadow-sm" /> 42 + </a> 43 + </div> 44 + </div> 45 + {{ end }} 46 + 47 + {{ define "features" }} 48 + <div class="prose dark:text-gray-200 space-y-12 px-6 py-4 bg-white dark:bg-gray-800 rounded drop-shadow-sm"> 49 + {{ template "feature" (list 50 + (dict 51 + "title" "lightweight git repo hosting" 52 + "image" "https://assets.tangled.network/what-is-tangled-repo.png" 53 + "alt" "A repository hosted on Tangled" 54 + ) 55 + (list 56 + "Host your repositories on your own infrastructure using <em>knots</em>&mdash;tiny, headless servers that facilitate git operations." 57 + "Add friends to your knot or invite collaborators to your repository." 58 + "Guarded by fine-grained role-based access control." 59 + "Use SSH to push and pull." 60 + ) 61 + ) }} 62 + 63 + {{ template "feature" (list 64 + (dict 65 + "title" "improved pull request model" 66 + "image" "https://assets.tangled.network/pulls.png" 67 + "alt" "Round-based pull requests." 68 + ) 69 + (list 70 + "An intuitive and effective round-based pull request flow, with inter-diffing between rounds." 71 + "Stacked pull requests using Jujutsu's change IDs." 72 + "Paste a <code>git diff</code> or <code>git format-patch</code> for quick drive-by changes." 73 + ) 74 + ) }} 75 + 76 + {{ template "feature" (list 77 + (dict 78 + "title" "run pipelines using spindles" 79 + "image" "https://assets.tangled.network/pipelines.png" 80 + "alt" "CI pipeline running on spindle" 81 + ) 82 + (list 83 + "Run pipelines on your own infrastructure using <em>spindles</em>&mdash;lightweight CI runners." 84 + "Natively supports Nix for package management." 85 + "Easily extended to support different execution backends." 86 + ) 87 + ) }} 88 + </div> 89 + {{ end }} 90 +
+18
appview/pages/templates/timeline/timeline.html
··· 1 + {{ define "title" }}timeline{{ end }} 2 + 3 + {{ define "extrameta" }} 4 + <meta property="og:title" content="timeline ยท tangled" /> 5 + <meta property="og:type" content="object" /> 6 + <meta property="og:url" content="https://tangled.sh" /> 7 + <meta property="og:description" content="tightly-knit social coding" /> 8 + {{ end }} 9 + 10 + {{ define "content" }} 11 + {{ if .LoggedInUser }} 12 + {{ else }} 13 + {{ template "timeline/fragments/hero" . }} 14 + {{ end }} 15 + 16 + {{ template "timeline/fragments/trending" . }} 17 + {{ template "timeline/fragments/timeline" . }} 18 + {{ end }}
-146
appview/pages/templates/timeline.html
··· 1 - {{ define "title" }}timeline{{ end }} 2 - 3 - {{ define "extrameta" }} 4 - <meta property="og:title" content="timeline ยท tangled" /> 5 - <meta property="og:type" content="object" /> 6 - <meta property="og:url" content="https://tangled.sh" /> 7 - <meta property="og:description" content="see what's tangling" /> 8 - {{ end }} 9 - 10 - {{ define "topbar" }} 11 - {{ template "layouts/topbar" $ }} 12 - {{ end }} 13 - 14 - {{ define "content" }} 15 - {{ with .LoggedInUser }} 16 - {{ block "timeline" $ }}{{ end }} 17 - {{ else }} 18 - {{ block "hero" $ }}{{ end }} 19 - {{ block "timeline" $ }}{{ end }} 20 - {{ end }} 21 - {{ end }} 22 - 23 - {{ define "hero" }} 24 - <div class="flex flex-col text-black dark:text-white p-6 gap-6 max-w-xl"> 25 - <div class="font-bold text-4xl">tightly-knit<br>social coding.</div> 26 - 27 - <p class="text-lg"> 28 - tangled is new social-enabled git collaboration platform built on <a class="underline" href="https://atproto.com/">atproto</a>. 29 - </p> 30 - <p class="text-lg"> 31 - we envision a place where developers have complete ownership of their 32 - code, open source communities can freely self-govern and most 33 - importantly, coding can be social and fun again. 34 - </p> 35 - 36 - <div class="flex gap-6 items-center"> 37 - <a href="/login" class="no-underline hover:no-underline "> 38 - <button class="btn flex gap-2 px-4 items-center"> 39 - join now {{ i "arrow-right" "size-4" }} 40 - </button> 41 - </a> 42 - </div> 43 - </div> 44 - {{ end }} 45 - 46 - {{ define "timeline" }} 47 - <div> 48 - <div class="p-6"> 49 - <p class="text-xl font-bold dark:text-white">Timeline</p> 50 - </div> 51 - 52 - <div class="flex flex-col gap-3 relative"> 53 - <div 54 - class="absolute left-8 top-0 bottom-0 w-px bg-gray-300 dark:bg-gray-600" 55 - ></div> 56 - {{ range .Timeline }} 57 - <div 58 - class="px-6 py-2 bg-white dark:bg-gray-800 rounded drop-shadow-sm w-fit" 59 - > 60 - {{ if .Repo }} 61 - {{ $userHandle := index $.DidHandleMap .Repo.Did }} 62 - <div class="flex items-center"> 63 - <p class="text-gray-600 dark:text-gray-300"> 64 - <a 65 - href="/{{ $userHandle }}" 66 - class="no-underline hover:underline" 67 - >{{ $userHandle | truncateAt30 }}</a 68 - > 69 - {{ if .Source }} 70 - forked 71 - <a 72 - href="/{{ index $.DidHandleMap .Source.Did }}/{{ .Source.Name }}" 73 - class="no-underline hover:underline" 74 - > 75 - {{ index $.DidHandleMap .Source.Did }}/{{ .Source.Name }} 76 - </a> 77 - to 78 - <a 79 - href="/{{ $userHandle }}/{{ .Repo.Name }}" 80 - class="no-underline hover:underline" 81 - >{{ .Repo.Name }}</a 82 - > 83 - {{ else }} 84 - created 85 - <a 86 - href="/{{ $userHandle }}/{{ .Repo.Name }}" 87 - class="no-underline hover:underline" 88 - >{{ .Repo.Name }}</a 89 - > 90 - {{ end }} 91 - <time 92 - class="text-gray-700 dark:text-gray-400 text-xs" 93 - >{{ .Repo.Created | timeFmt }}</time 94 - > 95 - </p> 96 - </div> 97 - {{ else if .Follow }} 98 - {{ $userHandle := index $.DidHandleMap .Follow.UserDid }} 99 - {{ $subjectHandle := index $.DidHandleMap .Follow.SubjectDid }} 100 - <div class="flex items-center"> 101 - <p class="text-gray-600 dark:text-gray-300"> 102 - <a 103 - href="/{{ $userHandle }}" 104 - class="no-underline hover:underline" 105 - >{{ $userHandle | truncateAt30 }}</a 106 - > 107 - followed 108 - <a 109 - href="/{{ $subjectHandle }}" 110 - class="no-underline hover:underline" 111 - >{{ $subjectHandle | truncateAt30 }}</a 112 - > 113 - <time 114 - class="text-gray-700 dark:text-gray-400 text-xs" 115 - >{{ .Follow.FollowedAt | timeFmt }}</time 116 - > 117 - </p> 118 - </div> 119 - {{ else if .Star }} 120 - {{ $starrerHandle := index $.DidHandleMap .Star.StarredByDid }} 121 - {{ $repoOwnerHandle := index $.DidHandleMap .Star.Repo.Did }} 122 - <div class="flex items-center"> 123 - <p class="text-gray-600 dark:text-gray-300"> 124 - <a 125 - href="/{{ $starrerHandle }}" 126 - class="no-underline hover:underline" 127 - >{{ $starrerHandle | truncateAt30 }}</a 128 - > 129 - starred 130 - <a 131 - href="/{{ $repoOwnerHandle }}/{{ .Star.Repo.Name }}" 132 - class="no-underline hover:underline" 133 - >{{ $repoOwnerHandle | truncateAt30 }}/{{ .Star.Repo.Name }}</a 134 - > 135 - <time 136 - class="text-gray-700 dark:text-gray-400 text-xs" 137 - >{{ .Star.Created | timeFmt }}</time 138 - > 139 - </p> 140 - </div> 141 - {{ end }} 142 - </div> 143 - {{ end }} 144 - </div> 145 - </div> 146 - {{ end }}
+102
appview/pages/templates/user/completeSignup.html
··· 1 + {{ define "user/completeSignup" }} 2 + <!doctype html> 3 + <html lang="en" class="dark:bg-gray-900"> 4 + <head> 5 + <meta charset="UTF-8" /> 6 + <meta 7 + name="viewport" 8 + content="width=device-width, initial-scale=1.0" 9 + /> 10 + <meta 11 + property="og:title" 12 + content="complete signup ยท tangled" 13 + /> 14 + <meta 15 + property="og:url" 16 + content="https://tangled.sh/complete-signup" 17 + /> 18 + <meta 19 + property="og:description" 20 + content="complete your signup for tangled" 21 + /> 22 + <script src="/static/htmx.min.js"></script> 23 + <link 24 + rel="stylesheet" 25 + href="/static/tw.css?{{ cssContentHash }}" 26 + type="text/css" 27 + /> 28 + <title>complete signup &middot; tangled</title> 29 + </head> 30 + <body class="flex items-center justify-center min-h-screen"> 31 + <main class="max-w-md px-6 -mt-4"> 32 + <h1 class="flex place-content-center text-2xl font-semibold italic dark:text-white" > 33 + {{ template "fragments/logotype" }} 34 + </h1> 35 + <h2 class="text-center text-xl italic dark:text-white"> 36 + tightly-knit social coding. 37 + </h2> 38 + <form 39 + class="mt-4 max-w-sm mx-auto flex flex-col gap-4" 40 + hx-post="/signup/complete" 41 + hx-swap="none" 42 + hx-disabled-elt="#complete-signup-button" 43 + > 44 + <div class="flex flex-col"> 45 + <label for="code">verification code</label> 46 + <input 47 + type="text" 48 + id="code" 49 + name="code" 50 + tabindex="1" 51 + required 52 + placeholder="tngl-sh-foo-bar" 53 + /> 54 + <span class="text-sm text-gray-500 mt-1"> 55 + Enter the code sent to your email. 56 + </span> 57 + </div> 58 + 59 + <div class="flex flex-col"> 60 + <label for="username">username</label> 61 + <input 62 + type="text" 63 + id="username" 64 + name="username" 65 + tabindex="2" 66 + required 67 + placeholder="jason" 68 + /> 69 + <span class="text-sm text-gray-500 mt-1"> 70 + Your complete handle will be of the form <code>user.tngl.sh</code>. 71 + </span> 72 + </div> 73 + 74 + <div class="flex flex-col"> 75 + <label for="password">password</label> 76 + <input 77 + type="password" 78 + id="password" 79 + name="password" 80 + tabindex="3" 81 + required 82 + /> 83 + <span class="text-sm text-gray-500 mt-1"> 84 + Choose a strong password for your account. 85 + </span> 86 + </div> 87 + 88 + <button 89 + class="btn-create w-full my-2 mt-6 text-base" 90 + type="submit" 91 + id="complete-signup-button" 92 + tabindex="4" 93 + > 94 + <span>complete signup</span> 95 + </button> 96 + </form> 97 + <p id="signup-error" class="error w-full"></p> 98 + <p id="signup-msg" class="dark:text-white w-full"></p> 99 + </main> 100 + </body> 101 + </html> 102 + {{ end }}
+18
appview/pages/templates/user/followers.html
··· 1 + {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท followers {{ end }} 2 + 3 + {{ define "profileContent" }} 4 + <div id="all-followers" class="md:col-span-8 order-2 md:order-2"> 5 + {{ block "followers" . }}{{ end }} 6 + </div> 7 + {{ end }} 8 + 9 + {{ define "followers" }} 10 + <p class="text-sm font-bold p-2 dark:text-white">ALL FOLLOWERS</p> 11 + <div id="followers" class="grid grid-cols-1 gap-4 mb-6"> 12 + {{ range .Followers }} 13 + {{ template "user/fragments/followCard" . }} 14 + {{ else }} 15 + <p class="px-6 dark:text-white">This user does not have any followers yet.</p> 16 + {{ end }} 17 + </div> 18 + {{ end }}
+18
appview/pages/templates/user/following.html
··· 1 + {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท following {{ end }} 2 + 3 + {{ define "profileContent" }} 4 + <div id="all-following" class="md:col-span-8 order-2 md:order-2"> 5 + {{ block "following" . }}{{ end }} 6 + </div> 7 + {{ end }} 8 + 9 + {{ define "following" }} 10 + <p class="text-sm font-bold p-2 dark:text-white">ALL FOLLOWING</p> 11 + <div id="following" class="grid grid-cols-1 gap-4 mb-6"> 12 + {{ range .Following }} 13 + {{ template "user/fragments/followCard" . }} 14 + {{ else }} 15 + <p class="px-6 dark:text-white">This user does not follow anyone yet.</p> 16 + {{ end }} 17 + </div> 18 + {{ end }}
+1 -1
appview/pages/templates/user/fragments/editBio.html
··· 13 13 <label class="m-0 p-0" for="description">bio</label> 14 14 <textarea 15 15 type="text" 16 - class="py-1 px-1 w-full" 16 + class="p-2 w-full" 17 17 name="description" 18 18 rows="3" 19 19 placeholder="write a bio">{{ $description }}</textarea>
+1 -1
appview/pages/templates/user/fragments/editPins.html
··· 27 27 <input type="checkbox" id="repo-{{$idx}}" name="pinnedRepo{{$idx}}" value="{{.RepoAt}}" {{if .IsPinned}}checked{{end}}> 28 28 <label for="repo-{{$idx}}" class="my-0 py-0 normal-case font-normal w-full"> 29 29 <div class="flex justify-between items-center w-full"> 30 - <span class="flex-shrink-0 overflow-hidden text-ellipsis ">{{ index $.DidHandleMap .Did }}/{{.Name}}</span> 30 + <span class="flex-shrink-0 overflow-hidden text-ellipsis ">{{ resolve .Did }}/{{.Name}}</span> 31 31 <div class="flex gap-1 items-center"> 32 32 {{ i "star" "size-4 fill-current" }} 33 33 <span>{{ .RepoStats.StarCount }}</span>
+2 -2
appview/pages/templates/user/fragments/follow.html
··· 1 1 {{ define "user/fragments/follow" }} 2 - <button id="followBtn" 2 + <button id="{{ normalizeForHtmlId .UserDid }}" 3 3 class="btn mt-2 w-full flex gap-2 items-center group" 4 4 5 5 {{ if eq .FollowStatus.String "IsNotFollowing" }} ··· 9 9 {{ end }} 10 10 11 11 hx-trigger="click" 12 - hx-target="#followBtn" 12 + hx-target="#{{ normalizeForHtmlId .UserDid }}" 13 13 hx-swap="outerHTML" 14 14 > 15 15 {{ if eq .FollowStatus.String "IsNotFollowing" }}Follow{{ else }}Unfollow{{ end }}
+29
appview/pages/templates/user/fragments/followCard.html
··· 1 + {{ define "user/fragments/followCard" }} 2 + {{ $userIdent := resolve .UserDid }} 3 + <div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-sm"> 4 + <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4"> 5 + <div class="flex-shrink-0 max-h-full w-24 h-24"> 6 + <img class="object-cover rounded-full p-2" src="{{ fullAvatar $userIdent }}" /> 7 + </div> 8 + 9 + <div class="flex-1 min-h-0 justify-around flex flex-col"> 10 + <a href="/{{ $userIdent }}"> 11 + <span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $userIdent | truncateAt30 }}</span> 12 + </a> 13 + <p class="text-sm pb-2 md:pb-2">{{.Profile.Description}}</p> 14 + <div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> 15 + <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 16 + <span id="followers"><a href="/{{ $userIdent }}?tab=followers">{{ .FollowersCount }} followers</a></span> 17 + <span class="select-none after:content-['ยท']"></span> 18 + <span id="following"><a href="/{{ $userIdent }}?tab=following">{{ .FollowingCount }} following</a></span> 19 + </div> 20 + </div> 21 + 22 + {{ if ne .FollowStatus.String "IsSelf" }} 23 + <div class="max-w-24"> 24 + {{ template "user/fragments/follow" . }} 25 + </div> 26 + {{ end }} 27 + </div> 28 + </div> 29 + {{ end }}
+8
appview/pages/templates/user/fragments/picHandle.html
··· 1 + {{ define "user/fragments/picHandle" }} 2 + <img 3 + src="{{ tinyAvatar . }}" 4 + alt="" 5 + class="rounded-full h-6 w-6 mr-1 border border-gray-300 dark:border-gray-700" 6 + /> 7 + {{ . | truncateAt30 }} 8 + {{ end }}
+6
appview/pages/templates/user/fragments/picHandleLink.html
··· 1 + {{ define "user/fragments/picHandleLink" }} 2 + {{ $resolved := resolve . }} 3 + <a href="/{{ $resolved }}" class="flex items-center"> 4 + {{ template "user/fragments/picHandle" $resolved }} 5 + </a> 6 + {{ end }}
+22 -20
appview/pages/templates/user/fragments/profileCard.html
··· 1 1 {{ define "user/fragments/profileCard" }} 2 - <div class="bg-white dark:bg-gray-800 px-6 py-4 rounded drop-shadow-sm max-h-fit"> 2 + {{ $userIdent := didOrHandle .UserDid .UserHandle }} 3 3 <div class="grid grid-cols-3 md:grid-cols-1 gap-1 items-center"> 4 4 <div id="avatar" class="col-span-1 flex justify-center items-center"> 5 - {{ if .AvatarUri }} 6 5 <div class="w-3/4 aspect-square relative"> 7 - <img class="absolute inset-0 w-full h-full object-cover rounded-full p-2" src="{{ .AvatarUri }}" /> 6 + <img class="absolute inset-0 w-full h-full object-cover rounded-full p-2" src="{{ fullAvatar .UserDid }}" /> 8 7 </div> 9 - {{ end }} 10 8 </div> 11 9 <div class="col-span-2"> 12 - <p title="{{ didOrHandle .UserDid .UserHandle }}" 13 - class="text-lg font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> 14 - {{ didOrHandle .UserDid .UserHandle }} 15 - </p> 10 + <div class="flex items-center flex-row flex-nowrap gap-2"> 11 + <p title="{{ $userIdent }}" 12 + class="text-lg font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap"> 13 + {{ $userIdent }} 14 + </p> 15 + <a href="/{{ $userIdent }}/feed.atom">{{ i "rss" "size-4" }}</a> 16 + </div> 16 17 17 18 <div class="md:hidden"> 18 - {{ block "followerFollowing" (list .Followers .Following) }} {{ end }} 19 + {{ block "followerFollowing" (list . $userIdent) }} {{ end }} 19 20 </div> 20 21 </div> 21 22 <div class="col-span-3 md:col-span-full"> ··· 28 29 {{ end }} 29 30 30 31 <div class="hidden md:block"> 31 - {{ block "followerFollowing" (list $.Followers $.Following) }} {{ end }} 32 + {{ block "followerFollowing" (list $ $userIdent) }} {{ end }} 32 33 </div> 33 34 34 35 <div class="flex flex-col gap-2 mb-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> ··· 41 42 {{ if .IncludeBluesky }} 42 43 <div class="flex items-center gap-2"> 43 44 <span class="flex-shrink-0">{{ template "user/fragments/bluesky" "w-4 h-4 text-black dark:text-white" }}</span> 44 - <a id="bluesky-link" href="https://bsky.app/profile/{{ $.UserDid }}">{{ didOrHandle $.UserDid $.UserHandle }}</a> 45 + <a id="bluesky-link" href="https://bsky.app/profile/{{ $.UserDid }}">{{ $userIdent }}</a> 45 46 </div> 46 47 {{ end }} 47 48 {{ range $link := .Links }} ··· 83 84 <div id="update-profile" class="text-red-400 dark:text-red-500"></div> 84 85 </div> 85 86 </div> 86 - </div> 87 87 {{ end }} 88 88 89 89 {{ define "followerFollowing" }} 90 - {{ $followers := index . 0 }} 91 - {{ $following := index . 1 }} 92 - <div class="flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full text-sm"> 93 - <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 94 - <span id="followers">{{ $followers }} followers</span> 95 - <span class="select-none after:content-['ยท']"></span> 96 - <span id="following">{{ $following }} following</span> 97 - </div> 90 + {{ $root := index . 0 }} 91 + {{ $userIdent := index . 1 }} 92 + {{ with $root }} 93 + <div class="flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full text-sm"> 94 + <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 95 + <span id="followers"><a href="/{{ $userIdent }}?tab=followers">{{ .Stats.FollowersCount }} followers</a></span> 96 + <span class="select-none after:content-['ยท']"></span> 97 + <span id="following"><a href="/{{ $userIdent }}?tab=following">{{ .Stats.FollowingCount }} following</a></span> 98 + </div> 99 + {{ end }} 98 100 {{ end }} 99 101
+62
appview/pages/templates/user/fragments/repoCard.html
··· 1 + {{ define "user/fragments/repoCard" }} 2 + {{ $root := index . 0 }} 3 + {{ $repo := index . 1 }} 4 + {{ $fullName := index . 2 }} 5 + 6 + {{ with $repo }} 7 + <div class="py-4 px-6 gap-1 flex flex-col drop-shadow-sm rounded bg-white dark:bg-gray-800 min-h-32"> 8 + <div class="font-medium dark:text-white flex items-center"> 9 + {{ if .Source }} 10 + {{ i "git-fork" "w-4 h-4 mr-1.5 shrink-0" }} 11 + {{ else }} 12 + {{ i "book-marked" "w-4 h-4 mr-1.5 shrink-0" }} 13 + {{ end }} 14 + 15 + {{ $repoOwner := resolve .Did }} 16 + {{- if $fullName -}} 17 + <a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate">{{ $repoOwner }}/{{ .Name }}</a> 18 + {{- else -}} 19 + <a href="/{{ $repoOwner }}/{{ .Name }}" class="truncate">{{ .Name }}</a> 20 + {{- end -}} 21 + </div> 22 + {{ with .Description }} 23 + <div class="text-gray-600 dark:text-gray-300 text-sm line-clamp-2"> 24 + {{ . | description }} 25 + </div> 26 + {{ end }} 27 + 28 + {{ if .RepoStats }} 29 + {{ block "repoStats" .RepoStats }}{{ end }} 30 + {{ end }} 31 + </div> 32 + {{ end }} 33 + {{ end }} 34 + 35 + {{ define "repoStats" }} 36 + <div class="text-gray-400 text-sm font-mono inline-flex gap-4 mt-auto"> 37 + {{ with .Language }} 38 + <div class="flex gap-2 items-center text-sm"> 39 + {{ template "repo/fragments/languageBall" . }} 40 + <span>{{ . }}</span> 41 + </div> 42 + {{ end }} 43 + {{ with .StarCount }} 44 + <div class="flex gap-1 items-center text-sm"> 45 + {{ i "star" "w-3 h-3 fill-current" }} 46 + <span>{{ . }}</span> 47 + </div> 48 + {{ end }} 49 + {{ with .IssueCount.Open }} 50 + <div class="flex gap-1 items-center text-sm"> 51 + {{ i "circle-dot" "w-3 h-3" }} 52 + <span>{{ . }}</span> 53 + </div> 54 + {{ end }} 55 + {{ with .PullCount.Open }} 56 + <div class="flex gap-1 items-center text-sm"> 57 + {{ i "git-pull-request" "w-3 h-3" }} 58 + <span>{{ . }}</span> 59 + </div> 60 + {{ end }} 61 + </div> 62 + {{ end }}
+15 -35
appview/pages/templates/user/login.html
··· 3 3 <html lang="en" class="dark:bg-gray-900"> 4 4 <head> 5 5 <meta charset="UTF-8" /> 6 - <meta 7 - name="viewport" 8 - content="width=device-width, initial-scale=1.0" 9 - /> 10 - <meta 11 - property="og:title" 12 - content="login ยท tangled" 13 - /> 14 - <meta 15 - property="og:url" 16 - content="https://tangled.sh/login" 17 - /> 18 - <meta 19 - property="og:description" 20 - content="login to tangled" 21 - /> 6 + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 7 + <meta property="og:title" content="login ยท tangled" /> 8 + <meta property="og:url" content="https://tangled.sh/login" /> 9 + <meta property="og:description" content="login to for tangled" /> 22 10 <script src="/static/htmx.min.js"></script> 23 - <link 24 - rel="stylesheet" 25 - href="/static/tw.css?{{ cssContentHash }}" 26 - type="text/css" 27 - /> 11 + <link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" /> 28 12 <title>login &middot; tangled</title> 29 13 </head> 30 14 <body class="flex items-center justify-center min-h-screen"> 31 15 <main class="max-w-md px-6 -mt-4"> 32 - <h1 33 - class="text-center text-2xl font-semibold italic dark:text-white" 34 - > 35 - tangled 16 + <h1 class="flex place-content-center text-2xl font-semibold italic dark:text-white" > 17 + {{ template "fragments/logotype" }} 36 18 </h1> 37 19 <h2 class="text-center text-xl italic dark:text-white"> 38 20 tightly-knit social coding. ··· 51 33 name="handle" 52 34 tabindex="1" 53 35 required 36 + placeholder="akshay.tngl.sh" 54 37 /> 55 38 <span class="text-sm text-gray-500 mt-1"> 56 - Use your 57 - <a href="https://bsky.app">Bluesky</a> handle to log 58 - in. You will then be redirected to your PDS to 59 - complete authentication. 39 + Use your <a href="https://atproto.com">ATProto</a> 40 + handle to log in. If you're unsure, this is likely 41 + your Tangled (<code>.tngl.sh</code>) or <a href="https://bsky.app">Bluesky</a> (<code>.bsky.social</code>) account. 60 42 </span> 61 43 </div> 44 + <input type="hidden" name="return_url" value="{{ .ReturnUrl }}"> 62 45 63 46 <button 64 - class="btn w-full my-2 mt-6" 47 + class="btn w-full my-2 mt-6 text-base " 65 48 type="submit" 66 49 id="login-button" 67 50 tabindex="3" ··· 70 53 </button> 71 54 </form> 72 55 <p class="text-sm text-gray-500"> 73 - Join our <a href="https://chat.tangled.sh">Discord</a> or 74 - IRC channel: 75 - <a href="https://web.libera.chat/#tangled" 76 - ><code>#tangled</code> on Libera Chat</a 77 - >. 56 + Don't have an account? <a href="/signup" class="underline">Create an account</a> on Tangled now! 78 57 </p> 58 + 79 59 <p id="login-msg" class="error w-full"></p> 80 60 </main> 81 61 </body>
+269
appview/pages/templates/user/overview.html
··· 1 + {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }}{{ end }} 2 + 3 + {{ define "profileContent" }} 4 + <div id="all-repos" class="md:col-span-4 order-2 md:order-2"> 5 + <div class="grid grid-cols-1 gap-4"> 6 + {{ block "ownRepos" . }}{{ end }} 7 + {{ block "collaboratingRepos" . }}{{ end }} 8 + </div> 9 + </div> 10 + <div class="md:col-span-4 order-3 md:order-3"> 11 + {{ block "profileTimeline" . }}{{ end }} 12 + </div> 13 + {{ end }} 14 + 15 + {{ define "profileTimeline" }} 16 + <p class="text-sm font-bold px-2 pb-4 dark:text-white">ACTIVITY</p> 17 + <div class="flex flex-col gap-4 relative"> 18 + {{ if .ProfileTimeline.IsEmpty }} 19 + <p class="dark:text-white">This user does not have any activity yet.</p> 20 + {{ end }} 21 + 22 + {{ with .ProfileTimeline }} 23 + {{ range $idx, $byMonth := .ByMonth }} 24 + {{ with $byMonth }} 25 + {{ if not .IsEmpty }} 26 + <div class="border border-gray-200 dark:border-gray-700 rounded-sm py-4 px-6"> 27 + <p class="text-sm font-mono mb-2 text-gray-500 dark:text-gray-400"> 28 + {{ if eq $idx 0 }} 29 + this month 30 + {{ else }} 31 + {{$idx}} month{{if ne $idx 1}}s{{end}} ago 32 + {{ end }} 33 + </p> 34 + 35 + <div class="flex flex-col gap-1"> 36 + {{ block "repoEvents" .RepoEvents }} {{ end }} 37 + {{ block "issueEvents" .IssueEvents }} {{ end }} 38 + {{ block "pullEvents" .PullEvents }} {{ end }} 39 + </div> 40 + </div> 41 + {{ end }} 42 + {{ end }} 43 + {{ end }} 44 + {{ end }} 45 + </div> 46 + {{ end }} 47 + 48 + {{ define "repoEvents" }} 49 + {{ if gt (len .) 0 }} 50 + <details> 51 + <summary class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400"> 52 + <div class="flex flex-wrap items-center gap-2"> 53 + {{ i "book-plus" "w-4 h-4" }} 54 + created {{ len . }} {{if eq (len .) 1 }}repository{{else}}repositories{{end}} 55 + </div> 56 + </summary> 57 + <div class="py-2 text-sm flex flex-col gap-3 mb-2"> 58 + {{ range . }} 59 + <div class="flex flex-wrap items-center justify-between gap-2"> 60 + <span class="flex items-center gap-2"> 61 + <span class="text-gray-500 dark:text-gray-400"> 62 + {{ if .Source }} 63 + {{ i "git-fork" "w-4 h-4" }} 64 + {{ else }} 65 + {{ i "book-plus" "w-4 h-4" }} 66 + {{ end }} 67 + </span> 68 + <a href="/{{ resolve .Repo.Did }}/{{ .Repo.Name }}" class="no-underline hover:underline"> 69 + {{- .Repo.Name -}} 70 + </a> 71 + </span> 72 + 73 + {{ with .Repo.RepoStats }} 74 + {{ with .Language }} 75 + <div class="flex gap-2 items-center text-xs font-mono text-gray-400 "> 76 + {{ template "repo/fragments/languageBall" . }} 77 + <span>{{ . }}</span> 78 + </div> 79 + {{end }} 80 + {{end }} 81 + </div> 82 + {{ end }} 83 + </div> 84 + </details> 85 + {{ end }} 86 + {{ end }} 87 + 88 + {{ define "issueEvents" }} 89 + {{ $items := .Items }} 90 + {{ $stats := .Stats }} 91 + 92 + {{ if gt (len $items) 0 }} 93 + <details> 94 + <summary class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400"> 95 + <div class="flex flex-wrap items-center gap-2"> 96 + {{ i "circle-dot" "w-4 h-4" }} 97 + 98 + <div> 99 + created {{ len $items }} {{if eq (len $items) 1 }}issue{{else}}issues{{end}} 100 + </div> 101 + 102 + {{ if gt $stats.Open 0 }} 103 + <span class="px-2 py-1/2 text-sm rounded text-white bg-green-600 dark:bg-green-700"> 104 + {{$stats.Open}} open 105 + </span> 106 + {{ end }} 107 + 108 + {{ if gt $stats.Closed 0 }} 109 + <span class="px-2 py-1/2 text-sm rounded text-white bg-gray-800 dark:bg-gray-700"> 110 + {{$stats.Closed}} closed 111 + </span> 112 + {{ end }} 113 + 114 + </div> 115 + </summary> 116 + <div class="py-2 text-sm flex flex-col gap-3 mb-2"> 117 + {{ range $items }} 118 + {{ $repoOwner := resolve .Repo.Did }} 119 + {{ $repoName := .Repo.Name }} 120 + {{ $repoUrl := printf "%s/%s" $repoOwner $repoName }} 121 + 122 + <div class="flex gap-2 text-gray-600 dark:text-gray-300"> 123 + {{ if .Open }} 124 + <span class="text-green-600 dark:text-green-500"> 125 + {{ i "circle-dot" "w-4 h-4" }} 126 + </span> 127 + {{ else }} 128 + <span class="text-gray-500 dark:text-gray-400"> 129 + {{ i "ban" "w-4 h-4" }} 130 + </span> 131 + {{ end }} 132 + <div class="flex-none min-w-8 text-right"> 133 + <span class="text-gray-500 dark:text-gray-400">#{{ .IssueId }}</span> 134 + </div> 135 + <div class="break-words max-w-full"> 136 + <a href="/{{$repoUrl}}/issues/{{ .IssueId }}" class="no-underline hover:underline"> 137 + {{ .Title -}} 138 + </a> 139 + on 140 + <a href="/{{$repoUrl}}" class="no-underline hover:underline whitespace-nowrap"> 141 + {{$repoUrl}} 142 + </a> 143 + </div> 144 + </div> 145 + {{ end }} 146 + </div> 147 + </details> 148 + {{ end }} 149 + {{ end }} 150 + 151 + {{ define "pullEvents" }} 152 + {{ $items := .Items }} 153 + {{ $stats := .Stats }} 154 + {{ if gt (len $items) 0 }} 155 + <details> 156 + <summary class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400"> 157 + <div class="flex flex-wrap items-center gap-2"> 158 + {{ i "git-pull-request" "w-4 h-4" }} 159 + 160 + <div> 161 + created {{ len $items }} {{if eq (len $items) 1 }}pull request{{else}}pull requests{{end}} 162 + </div> 163 + 164 + {{ if gt $stats.Open 0 }} 165 + <span class="px-2 py-1/2 text-sm rounded text-white bg-green-600 dark:bg-green-700"> 166 + {{$stats.Open}} open 167 + </span> 168 + {{ end }} 169 + 170 + {{ if gt $stats.Merged 0 }} 171 + <span class="px-2 py-1/2 text-sm rounded text-white bg-purple-600 dark:bg-purple-700"> 172 + {{$stats.Merged}} merged 173 + </span> 174 + {{ end }} 175 + 176 + 177 + {{ if gt $stats.Closed 0 }} 178 + <span class="px-2 py-1/2 text-sm rounded text-white bg-gray-800 dark:bg-gray-700"> 179 + {{$stats.Closed}} closed 180 + </span> 181 + {{ end }} 182 + 183 + </div> 184 + </summary> 185 + <div class="py-2 text-sm flex flex-col gap-3 mb-2"> 186 + {{ range $items }} 187 + {{ $repoOwner := resolve .Repo.Did }} 188 + {{ $repoName := .Repo.Name }} 189 + {{ $repoUrl := printf "%s/%s" $repoOwner $repoName }} 190 + 191 + <div class="flex gap-2 text-gray-600 dark:text-gray-300"> 192 + {{ if .State.IsOpen }} 193 + <span class="text-green-600 dark:text-green-500"> 194 + {{ i "git-pull-request" "w-4 h-4" }} 195 + </span> 196 + {{ else if .State.IsMerged }} 197 + <span class="text-purple-600 dark:text-purple-500"> 198 + {{ i "git-merge" "w-4 h-4" }} 199 + </span> 200 + {{ else }} 201 + <span class="text-gray-600 dark:text-gray-300"> 202 + {{ i "git-pull-request-closed" "w-4 h-4" }} 203 + </span> 204 + {{ end }} 205 + <div class="flex-none min-w-8 text-right"> 206 + <span class="text-gray-500 dark:text-gray-400">#{{ .PullId }}</span> 207 + </div> 208 + <div class="break-words max-w-full"> 209 + <a href="/{{$repoUrl}}/pulls/{{ .PullId }}" class="no-underline hover:underline"> 210 + {{ .Title -}} 211 + </a> 212 + on 213 + <a href="/{{$repoUrl}}" class="no-underline hover:underline whitespace-nowrap"> 214 + {{$repoUrl}} 215 + </a> 216 + </div> 217 + </div> 218 + {{ end }} 219 + </div> 220 + </details> 221 + {{ end }} 222 + {{ end }} 223 + 224 + {{ define "ownRepos" }} 225 + <div> 226 + <div class="text-sm font-bold px-2 pb-4 dark:text-white flex items-center gap-2"> 227 + <a href="/@{{ or $.Card.UserHandle $.Card.UserDid }}?tab=repos" 228 + class="flex text-black dark:text-white items-center gap-2 no-underline hover:no-underline group"> 229 + <span>PINNED REPOS</span> 230 + </a> 231 + {{ if and .LoggedInUser (eq .LoggedInUser.Did .Card.UserDid) }} 232 + <button 233 + hx-get="profile/edit-pins" 234 + hx-target="#all-repos" 235 + class="py-0 font-normal text-sm flex gap-2 items-center group"> 236 + {{ i "pencil" "w-3 h-3" }} 237 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 238 + </button> 239 + {{ end }} 240 + </div> 241 + <div id="repos" class="grid grid-cols-1 gap-4 items-stretch"> 242 + {{ range .Repos }} 243 + <div class="border border-gray-200 dark:border-gray-700 rounded-sm"> 244 + {{ template "user/fragments/repoCard" (list $ . false) }} 245 + </div> 246 + {{ else }} 247 + <p class="dark:text-white">This user does not have any pinned repos.</p> 248 + {{ end }} 249 + </div> 250 + </div> 251 + {{ end }} 252 + 253 + {{ define "collaboratingRepos" }} 254 + {{ if gt (len .CollaboratingRepos) 0 }} 255 + <div> 256 + <p class="text-sm font-bold px-2 pb-4 dark:text-white">COLLABORATING ON</p> 257 + <div id="collaborating" class="grid grid-cols-1 gap-4"> 258 + {{ range .CollaboratingRepos }} 259 + <div class="border border-gray-200 dark:border-gray-700 rounded-sm"> 260 + {{ template "user/fragments/repoCard" (list $ . true) }} 261 + </div> 262 + {{ else }} 263 + <p class="px-6 dark:text-white">This user is not collaborating.</p> 264 + {{ end }} 265 + </div> 266 + </div> 267 + {{ end }} 268 + {{ end }} 269 +
-367
appview/pages/templates/user/profile.html
··· 1 - {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }}{{ end }} 2 - 3 - {{ define "extrameta" }} 4 - <meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}" /> 5 - <meta property="og:type" content="profile" /> 6 - <meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}" /> 7 - <meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" /> 8 - {{ end }} 9 - 10 - {{ define "content" }} 11 - <div class="grid grid-cols-1 md:grid-cols-8 gap-4"> 12 - <div class="md:col-span-2 order-1 md:order-1"> 13 - <div class="grid grid-cols-1 gap-4"> 14 - {{ template "user/fragments/profileCard" .Card }} 15 - {{ block "punchcard" .Punchcard }} {{ end }} 16 - </div> 17 - </div> 18 - <div id="all-repos" class="md:col-span-3 order-2 md:order-2"> 19 - <div class="grid grid-cols-1 gap-4"> 20 - {{ block "ownRepos" . }}{{ end }} 21 - {{ block "collaboratingRepos" . }}{{ end }} 22 - </div> 23 - </div> 24 - <div class="md:col-span-3 order-3 md:order-3"> 25 - {{ block "profileTimeline" . }}{{ end }} 26 - </div> 27 - </div> 28 - {{ end }} 29 - 30 - {{ define "profileTimeline" }} 31 - <p class="text-sm font-bold p-2 dark:text-white">ACTIVITY</p> 32 - <div class="flex flex-col gap-4 relative"> 33 - {{ with .ProfileTimeline }} 34 - {{ range $idx, $byMonth := .ByMonth }} 35 - {{ with $byMonth }} 36 - <div class="bg-white dark:bg-gray-800 px-6 py-4 rounded drop-shadow-sm"> 37 - {{ if eq $idx 0 }} 38 - 39 - {{ else }} 40 - {{ $s := "s" }} 41 - {{ if eq $idx 1 }} 42 - {{ $s = "" }} 43 - {{ end }} 44 - <p class="text-sm font-bold dark:text-white mb-2">{{$idx}} month{{$s}} ago</p> 45 - {{ end }} 46 - 47 - {{ if .IsEmpty }} 48 - <div class="text-gray-500 dark:text-gray-400"> 49 - No activity for this month 50 - </div> 51 - {{ else }} 52 - <div class="flex flex-col gap-1"> 53 - {{ block "repoEvents" (list .RepoEvents $.DidHandleMap) }} {{ end }} 54 - {{ block "issueEvents" (list .IssueEvents $.DidHandleMap) }} {{ end }} 55 - {{ block "pullEvents" (list .PullEvents $.DidHandleMap) }} {{ end }} 56 - </div> 57 - {{ end }} 58 - </div> 59 - 60 - {{ end }} 61 - {{ else }} 62 - <p class="dark:text-white">This user does not have any activity yet.</p> 63 - {{ end }} 64 - {{ end }} 65 - </div> 66 - {{ end }} 67 - 68 - {{ define "repoEvents" }} 69 - {{ $items := index . 0 }} 70 - {{ $handleMap := index . 1 }} 71 - 72 - {{ if gt (len $items) 0 }} 73 - <details> 74 - <summary class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400"> 75 - <div class="flex flex-wrap items-center gap-2"> 76 - {{ i "book-plus" "w-4 h-4" }} 77 - created {{ len $items }} {{if eq (len $items) 1 }}repository{{else}}repositories{{end}} 78 - </div> 79 - </summary> 80 - <div class="py-2 text-sm flex flex-col gap-3 mb-2"> 81 - {{ range $items }} 82 - <div class="flex flex-wrap items-center gap-2"> 83 - <span class="text-gray-500 dark:text-gray-400"> 84 - {{ if .Source }} 85 - {{ i "git-fork" "w-4 h-4" }} 86 - {{ else }} 87 - {{ i "book-plus" "w-4 h-4" }} 88 - {{ end }} 89 - </span> 90 - <a href="/{{ index $handleMap .Repo.Did }}/{{ .Repo.Name }}" class="no-underline hover:underline"> 91 - {{- .Repo.Name -}} 92 - </a> 93 - </div> 94 - {{ end }} 95 - </div> 96 - </details> 97 - {{ end }} 98 - {{ end }} 99 - 100 - {{ define "issueEvents" }} 101 - {{ $i := index . 0 }} 102 - {{ $items := $i.Items }} 103 - {{ $stats := $i.Stats }} 104 - {{ $handleMap := index . 1 }} 105 - 106 - {{ if gt (len $items) 0 }} 107 - <details> 108 - <summary class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400"> 109 - <div class="flex flex-wrap items-center gap-2"> 110 - {{ i "circle-dot" "w-4 h-4" }} 111 - 112 - <div> 113 - created {{ len $items }} {{if eq (len $items) 1 }}issue{{else}}issues{{end}} 114 - </div> 115 - 116 - {{ if gt $stats.Open 0 }} 117 - <span class="px-2 py-1/2 text-sm rounded text-white bg-green-600 dark:bg-green-700"> 118 - {{$stats.Open}} open 119 - </span> 120 - {{ end }} 121 - 122 - {{ if gt $stats.Closed 0 }} 123 - <span class="px-2 py-1/2 text-sm rounded text-white bg-gray-800 dark:bg-gray-700"> 124 - {{$stats.Closed}} closed 125 - </span> 126 - {{ end }} 127 - 128 - </div> 129 - </summary> 130 - <div class="py-2 text-sm flex flex-col gap-3 mb-2"> 131 - {{ range $items }} 132 - {{ $repoOwner := index $handleMap .Metadata.Repo.Did }} 133 - {{ $repoName := .Metadata.Repo.Name }} 134 - {{ $repoUrl := printf "%s/%s" $repoOwner $repoName }} 135 - 136 - <div class="flex gap-2 text-gray-600 dark:text-gray-300"> 137 - {{ if .Open }} 138 - <span class="text-green-600 dark:text-green-500"> 139 - {{ i "circle-dot" "w-4 h-4" }} 140 - </span> 141 - {{ else }} 142 - <span class="text-gray-500 dark:text-gray-400"> 143 - {{ i "ban" "w-4 h-4" }} 144 - </span> 145 - {{ end }} 146 - <div class="flex-none min-w-8 text-right"> 147 - <span class="text-gray-500 dark:text-gray-400">#{{ .IssueId }}</span> 148 - </div> 149 - <div class="break-words max-w-full"> 150 - <a href="/{{$repoUrl}}/issues/{{ .IssueId }}" class="no-underline hover:underline"> 151 - {{ .Title -}} 152 - </a> 153 - on 154 - <a href="/{{$repoUrl}}" class="no-underline hover:underline whitespace-nowrap"> 155 - {{$repoUrl}} 156 - </a> 157 - </div> 158 - </div> 159 - {{ end }} 160 - </div> 161 - </details> 162 - {{ end }} 163 - {{ end }} 164 - 165 - {{ define "pullEvents" }} 166 - {{ $i := index . 0 }} 167 - {{ $items := $i.Items }} 168 - {{ $stats := $i.Stats }} 169 - {{ $handleMap := index . 1 }} 170 - {{ if gt (len $items) 0 }} 171 - <details> 172 - <summary class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400"> 173 - <div class="flex flex-wrap items-center gap-2"> 174 - {{ i "git-pull-request" "w-4 h-4" }} 175 - 176 - <div> 177 - created {{ len $items }} {{if eq (len $items) 1 }}pull request{{else}}pull requests{{end}} 178 - </div> 179 - 180 - {{ if gt $stats.Open 0 }} 181 - <span class="px-2 py-1/2 text-sm rounded text-white bg-green-600 dark:bg-green-700"> 182 - {{$stats.Open}} open 183 - </span> 184 - {{ end }} 185 - 186 - {{ if gt $stats.Merged 0 }} 187 - <span class="px-2 py-1/2 text-sm rounded text-white bg-purple-600 dark:bg-purple-700"> 188 - {{$stats.Merged}} merged 189 - </span> 190 - {{ end }} 191 - 192 - 193 - {{ if gt $stats.Closed 0 }} 194 - <span class="px-2 py-1/2 text-sm rounded text-white bg-gray-800 dark:bg-gray-700"> 195 - {{$stats.Closed}} closed 196 - </span> 197 - {{ end }} 198 - 199 - </div> 200 - </summary> 201 - <div class="py-2 text-sm flex flex-col gap-3 mb-2"> 202 - {{ range $items }} 203 - {{ $repoOwner := index $handleMap .Repo.Did }} 204 - {{ $repoName := .Repo.Name }} 205 - {{ $repoUrl := printf "%s/%s" $repoOwner $repoName }} 206 - 207 - <div class="flex gap-2 text-gray-600 dark:text-gray-300"> 208 - {{ if .State.IsOpen }} 209 - <span class="text-green-600 dark:text-green-500"> 210 - {{ i "git-pull-request" "w-4 h-4" }} 211 - </span> 212 - {{ else if .State.IsMerged }} 213 - <span class="text-purple-600 dark:text-purple-500"> 214 - {{ i "git-merge" "w-4 h-4" }} 215 - </span> 216 - {{ else }} 217 - <span class="text-gray-600 dark:text-gray-300"> 218 - {{ i "git-pull-request-closed" "w-4 h-4" }} 219 - </span> 220 - {{ end }} 221 - <div class="flex-none min-w-8 text-right"> 222 - <span class="text-gray-500 dark:text-gray-400">#{{ .PullId }}</span> 223 - </div> 224 - <div class="break-words max-w-full"> 225 - <a href="/{{$repoUrl}}/pulls/{{ .PullId }}" class="no-underline hover:underline"> 226 - {{ .Title -}} 227 - </a> 228 - on 229 - <a href="/{{$repoUrl}}" class="no-underline hover:underline whitespace-nowrap"> 230 - {{$repoUrl}} 231 - </a> 232 - </div> 233 - </div> 234 - {{ end }} 235 - </div> 236 - </details> 237 - {{ end }} 238 - {{ end }} 239 - 240 - {{ define "ownRepos" }} 241 - <div> 242 - <div class="text-sm font-bold p-2 pr-0 dark:text-white flex items-center justify-between gap-2"> 243 - <a href="/@{{ or $.Card.UserHandle $.Card.UserDid }}?tab=repos" 244 - class="flex text-black dark:text-white items-center gap-2 no-underline hover:no-underline group"> 245 - <span>PINNED REPOS</span> 246 - <span class="flex gap-1 items-center font-normal text-sm text-gray-500 dark:text-gray-400 "> 247 - view all {{ i "chevron-right" "w-4 h-4" }} 248 - </span> 249 - </a> 250 - {{ if and .LoggedInUser (eq .LoggedInUser.Did .Card.UserDid) }} 251 - <button 252 - hx-get="profile/edit-pins" 253 - hx-target="#all-repos" 254 - class="btn py-0 font-normal text-sm flex gap-2 items-center group"> 255 - {{ i "pencil" "w-3 h-3" }} 256 - edit 257 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 258 - </button> 259 - {{ end }} 260 - </div> 261 - <div id="repos" class="grid grid-cols-1 gap-4"> 262 - {{ range .Repos }} 263 - <div 264 - id="repo-card" 265 - class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800"> 266 - <div id="repo-card-name" class="font-medium"> 267 - <a href="/@{{ or $.Card.UserHandle $.Card.UserDid }}/{{ .Name }}" 268 - >{{ .Name }}</a 269 - > 270 - </div> 271 - {{ if .Description }} 272 - <div class="text-gray-600 dark:text-gray-300 text-sm"> 273 - {{ .Description }} 274 - </div> 275 - {{ end }} 276 - <div class="text-gray-400 pt-1 text-sm font-mono inline-flex gap-4 mt-auto"> 277 - {{ if .RepoStats.StarCount }} 278 - <div class="flex gap-1 items-center text-sm"> 279 - {{ i "star" "w-3 h-3 fill-current" }} 280 - <span>{{ .RepoStats.StarCount }}</span> 281 - </div> 282 - {{ end }} 283 - </div> 284 - </div> 285 - {{ else }} 286 - <p class="px-6 dark:text-white">This user does not have any repos yet.</p> 287 - {{ end }} 288 - </div> 289 - </div> 290 - {{ end }} 291 - 292 - {{ define "collaboratingRepos" }} 293 - {{ if gt (len .CollaboratingRepos) 0 }} 294 - <div> 295 - <p class="text-sm font-bold p-2 dark:text-white">COLLABORATING ON</p> 296 - <div id="collaborating" class="grid grid-cols-1 gap-4"> 297 - {{ range .CollaboratingRepos }} 298 - <div 299 - id="repo-card" 300 - class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800"> 301 - <div id="repo-card-name" class="font-medium dark:text-white"> 302 - <a href="/{{ index $.DidHandleMap .Did }}/{{ .Name }}"> 303 - {{ index $.DidHandleMap .Did }}/{{ .Name }} 304 - </a> 305 - </div> 306 - {{ if .Description }} 307 - <div class="text-gray-600 dark:text-gray-300 text-sm"> 308 - {{ .Description }} 309 - </div> 310 - {{ end }} 311 - <div class="text-gray-400 pt-1 text-sm font-mono inline-flex gap-4 mt-auto"> 312 - {{ if .RepoStats.StarCount }} 313 - <div class="flex gap-1 items-center text-sm"> 314 - {{ i "star" "w-3 h-3 fill-current" }} 315 - <span>{{ .RepoStats.StarCount }}</span> 316 - </div> 317 - {{ end }} 318 - </div> 319 - </div> 320 - {{ else }} 321 - <p class="px-6 dark:text-white">This user is not collaborating.</p> 322 - {{ end }} 323 - </div> 324 - </div> 325 - {{ end }} 326 - {{ end }} 327 - 328 - {{ define "punchcard" }} 329 - {{ $now := now }} 330 - <div> 331 - <p class="p-2 flex gap-2 text-sm font-bold dark:text-white"> 332 - PUNCHCARD 333 - <span class="font-normal text-sm text-gray-500 dark:text-gray-400 "> 334 - {{ .Total | int64 | commaFmt }} commits 335 - </span> 336 - </p> 337 - <div class="bg-white dark:bg-gray-800 px-6 py-4 rounded drop-shadow-sm"> 338 - <div class="grid grid-cols-28 md:grid-cols-14 gap-y-2 w-full h-full"> 339 - {{ range .Punches }} 340 - {{ $count := .Count }} 341 - {{ $theme := "bg-gray-200 dark:bg-gray-700 size-[4px]" }} 342 - {{ if lt $count 1 }} 343 - {{ $theme = "bg-gray-200 dark:bg-gray-700 size-[4px]" }} 344 - {{ else if lt $count 2 }} 345 - {{ $theme = "bg-green-200 dark:bg-green-900 size-[5px]" }} 346 - {{ else if lt $count 4 }} 347 - {{ $theme = "bg-green-300 dark:bg-green-800 size-[5px]" }} 348 - {{ else if lt $count 8 }} 349 - {{ $theme = "bg-green-400 dark:bg-green-700 size-[6px]" }} 350 - {{ else }} 351 - {{ $theme = "bg-green-500 dark:bg-green-600 size-[7px]" }} 352 - {{ end }} 353 - 354 - {{ if .Date.After $now }} 355 - {{ $theme = "border border-gray-200 dark:border-gray-700 size-[4px]" }} 356 - {{ end }} 357 - <div class="w-full h-full flex justify-center items-center"> 358 - <div 359 - class="aspect-square rounded-full transition-all duration-300 {{ $theme }} max-w-full max-h-full" 360 - title="{{ .Date.Format "2006-01-02" }}: {{ .Count }} commits"> 361 - </div> 362 - </div> 363 - {{ end }} 364 - </div> 365 - </div> 366 - </div> 367 - {{ end }}
+6 -38
appview/pages/templates/user/repos.html
··· 1 1 {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท repos {{ end }} 2 2 3 - {{ define "extrameta" }} 4 - <meta property="og:title" content="{{ or .Card.UserHandle .Card.UserDid }}'s repos" /> 5 - <meta property="og:type" content="object" /> 6 - <meta property="og:url" content="https://tangled.sh/{{ or .Card.UserHandle .Card.UserDid }}/repos" /> 7 - <meta property="og:description" content="{{ or .Card.Profile.Description .Card.UserHandle .Card.UserDid }}" /> 8 - {{ end }} 9 - 10 - {{ define "content" }} 11 - <div class="grid grid-cols-1 md:grid-cols-8 gap-4"> 12 - <div class="md:col-span-2 order-1 md:order-1"> 13 - {{ template "user/fragments/profileCard" .Card }} 14 - </div> 15 - <div id="all-repos" class="md:col-span-6 order-2 md:order-2"> 16 - {{ block "ownRepos" . }}{{ end }} 17 - </div> 18 - </div> 3 + {{ define "profileContent" }} 4 + <div id="all-repos" class="md:col-span-8 order-2 md:order-2"> 5 + {{ block "ownRepos" . }}{{ end }} 6 + </div> 19 7 {{ end }} 20 8 21 9 {{ define "ownRepos" }} 22 - <p class="text-sm font-bold p-2 dark:text-white">ALL REPOSITORIES</p> 23 10 <div id="repos" class="grid grid-cols-1 gap-4 mb-6"> 24 11 {{ range .Repos }} 25 - <div 26 - id="repo-card" 27 - class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800"> 28 - <div id="repo-card-name" class="font-medium"> 29 - <a href="/@{{ or $.Card.UserHandle $.Card.UserDid }}/{{ .Name }}" 30 - >{{ .Name }}</a 31 - > 32 - </div> 33 - {{ if .Description }} 34 - <div class="text-gray-600 dark:text-gray-300 text-sm"> 35 - {{ .Description }} 36 - </div> 37 - {{ end }} 38 - <div class="text-gray-400 pt-1 text-sm font-mono inline-flex gap-4 mt-auto"> 39 - {{ if .RepoStats.StarCount }} 40 - <div class="flex gap-1 items-center text-sm"> 41 - {{ i "star" "w-3 h-3 fill-current" }} 42 - <span>{{ .RepoStats.StarCount }}</span> 43 - </div> 44 - {{ end }} 45 - </div> 12 + <div class="border border-gray-200 dark:border-gray-700 rounded-sm"> 13 + {{ template "user/fragments/repoCard" (list $ . false) }} 46 14 </div> 47 15 {{ else }} 48 16 <p class="px-6 dark:text-white">This user does not have any repos yet.</p>
+94
appview/pages/templates/user/settings/emails.html
··· 1 + {{ define "title" }}{{ .Tab }} settings{{ end }} 2 + 3 + {{ define "content" }} 4 + <div class="p-6"> 5 + <p class="text-xl font-bold dark:text-white">Settings</p> 6 + </div> 7 + <div class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 8 + <section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6"> 9 + <div class="col-span-1"> 10 + {{ template "user/settings/fragments/sidebar" . }} 11 + </div> 12 + <div class="col-span-1 md:col-span-3 flex flex-col gap-6"> 13 + {{ template "emailSettings" . }} 14 + </div> 15 + </section> 16 + </div> 17 + {{ end }} 18 + 19 + {{ define "emailSettings" }} 20 + <div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center"> 21 + <div class="col-span-1 md:col-span-2"> 22 + <h2 class="text-sm pb-2 uppercase font-bold">Email Addresses</h2> 23 + <p class="text-gray-500 dark:text-gray-400"> 24 + Commits authored using emails listed here will be associated with your Tangled profile. 25 + </p> 26 + </div> 27 + <div class="col-span-1 md:col-span-1 md:justify-self-end"> 28 + {{ template "addEmailButton" . }} 29 + </div> 30 + </div> 31 + <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 w-full"> 32 + {{ range .Emails }} 33 + {{ template "user/settings/fragments/emailListing" (list $ .) }} 34 + {{ else }} 35 + <div class="flex items-center justify-center p-2 text-gray-500"> 36 + no emails added yet 37 + </div> 38 + {{ end }} 39 + </div> 40 + {{ end }} 41 + 42 + {{ define "addEmailButton" }} 43 + <button 44 + class="btn flex items-center gap-2" 45 + popovertarget="add-email-modal" 46 + popovertargetaction="toggle"> 47 + {{ i "plus" "size-4" }} 48 + add email 49 + </button> 50 + <div 51 + id="add-email-modal" 52 + popover 53 + class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50"> 54 + {{ template "addEmailModal" . }} 55 + </div> 56 + {{ end}} 57 + 58 + {{ define "addEmailModal" }} 59 + <form 60 + hx-put="/settings/emails" 61 + hx-indicator="#spinner" 62 + hx-swap="none" 63 + class="flex flex-col gap-2" 64 + > 65 + <p class="uppercase p-0">ADD EMAIL</p> 66 + <p class="text-sm text-gray-500 dark:text-gray-400">Commits using this email will be associated with your profile.</p> 67 + <input 68 + type="email" 69 + id="email-address" 70 + name="email" 71 + required 72 + placeholder="your@email.com" 73 + class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400" 74 + /> 75 + <div class="flex gap-2 pt-2"> 76 + <button 77 + type="button" 78 + popovertarget="add-email-modal" 79 + popovertargetaction="hide" 80 + class="btn w-1/2 flex items-center gap-2 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300" 81 + > 82 + {{ i "x" "size-4" }} cancel 83 + </button> 84 + <button type="submit" class="btn w-1/2 flex items-center"> 85 + <span class="inline-flex gap-2 items-center">{{ i "plus" "size-4" }} add</span> 86 + <span id="spinner" class="group"> 87 + {{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 88 + </span> 89 + </button> 90 + </div> 91 + <div id="settings-emails-error" class="text-red-500 dark:text-red-400"></div> 92 + <div id="settings-emails-success" class="text-green-500 dark:text-green-400"></div> 93 + </form> 94 + {{ end }}
+62
appview/pages/templates/user/settings/fragments/emailListing.html
··· 1 + {{ define "user/settings/fragments/emailListing" }} 2 + {{ $root := index . 0 }} 3 + {{ $email := index . 1 }} 4 + <div id="email-{{$email.Address}}" class="flex items-center justify-between p-2"> 5 + <div class="hover:no-underline flex flex-col gap-1 min-w-0 max-w-[80%]"> 6 + <div class="flex items-center gap-2"> 7 + {{ i "mail" "w-4 h-4 text-gray-500 dark:text-gray-400" }} 8 + <span class="font-bold"> 9 + {{ $email.Address }} 10 + </span> 11 + <div class="inline-flex items-center gap-1"> 12 + {{ if $email.Verified }} 13 + <span class="text-xs bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 px-2 py-1 rounded">verified</span> 14 + {{ else }} 15 + <span class="text-xs bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 px-2 py-1 rounded">unverified</span> 16 + {{ end }} 17 + {{ if $email.Primary }} 18 + <span class="text-xs bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 px-2 py-1 rounded">primary</span> 19 + {{ end }} 20 + </div> 21 + </div> 22 + <div class="flex text-sm flex-wrap text items-center gap-1 text-gray-500 dark:text-gray-400"> 23 + <span>added {{ template "repo/fragments/time" $email.CreatedAt }}</span> 24 + </div> 25 + </div> 26 + <div class="flex gap-2 items-center"> 27 + {{ if not $email.Verified }} 28 + <button 29 + class="btn flex gap-2 text-sm px-2 py-1" 30 + hx-post="/settings/emails/verify/resend" 31 + hx-swap="none" 32 + hx-vals='{"email": "{{ $email.Address }}"}'> 33 + {{ i "rotate-cw" "w-4 h-4" }} 34 + <span class="hidden md:inline">resend</span> 35 + </button> 36 + {{ end }} 37 + {{ if and (not $email.Primary) $email.Verified }} 38 + <button 39 + class="btn text-sm px-2 py-1 text-blue-500 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300" 40 + hx-post="/settings/emails/primary" 41 + hx-swap="none" 42 + hx-vals='{"email": "{{ $email.Address }}"}'> 43 + set as primary 44 + </button> 45 + {{ end }} 46 + {{ if not $email.Primary }} 47 + <button 48 + class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 49 + title="Delete email" 50 + hx-delete="/settings/emails" 51 + hx-swap="none" 52 + hx-vals='{"email": "{{ $email.Address }}"}' 53 + hx-confirm="Are you sure you want to delete the email {{ $email.Address }}?" 54 + > 55 + {{ i "trash-2" "w-5 h-5" }} 56 + <span class="hidden md:inline">delete</span> 57 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 58 + </button> 59 + {{ end }} 60 + </div> 61 + </div> 62 + {{ end }}
+31
appview/pages/templates/user/settings/fragments/keyListing.html
··· 1 + {{ define "user/settings/fragments/keyListing" }} 2 + {{ $root := index . 0 }} 3 + {{ $key := index . 1 }} 4 + <div id="key-{{$key.Name}}" class="flex items-center justify-between p-2"> 5 + <div class="hover:no-underline flex flex-col gap-1 text min-w-0 max-w-[80%]"> 6 + <div class="flex items-center gap-2"> 7 + <span>{{ i "key" "w-4" "h-4" }}</span> 8 + <span class="font-bold"> 9 + {{ $key.Name }} 10 + </span> 11 + </div> 12 + <span class="font-mono text-sm text-gray-500 dark:text-gray-400"> 13 + {{ sshFingerprint $key.Key }} 14 + </span> 15 + <div class="flex flex-wrap text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 16 + <span>added {{ template "repo/fragments/time" $key.Created }}</span> 17 + </div> 18 + </div> 19 + <button 20 + class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2 group" 21 + title="Delete key" 22 + hx-delete="/settings/keys?name={{urlquery $key.Name}}&rkey={{urlquery $key.Rkey}}&key={{urlquery $key.Key}}" 23 + hx-swap="none" 24 + hx-confirm="Are you sure you want to delete the key {{ $key.Name }}?" 25 + > 26 + {{ i "trash-2" "w-5 h-5" }} 27 + <span class="hidden md:inline">delete</span> 28 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 29 + </button> 30 + </div> 31 + {{ end }}
+16
appview/pages/templates/user/settings/fragments/sidebar.html
··· 1 + {{ define "user/settings/fragments/sidebar" }} 2 + {{ $active := .Tab }} 3 + {{ $tabs := .Tabs }} 4 + <div class="sticky top-2 grid grid-cols-1 rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 shadow-inner"> 5 + {{ $activeTab := "bg-white dark:bg-gray-700 drop-shadow-sm" }} 6 + {{ $inactiveTab := "bg-gray-100 dark:bg-gray-800" }} 7 + {{ range $tabs }} 8 + <a href="/settings/{{.Name}}" class="no-underline hover:no-underline hover:bg-gray-100/25 hover:dark:bg-gray-700/25"> 9 + <div class="flex gap-3 items-center p-2 {{ if eq .Name $active }} {{ $activeTab }} {{ else }} {{ $inactiveTab }} {{ end }}"> 10 + {{ i .Icon "size-4" }} 11 + {{ .Name }} 12 + </div> 13 + </a> 14 + {{ end }} 15 + </div> 16 + {{ end }}
+101
appview/pages/templates/user/settings/keys.html
··· 1 + {{ define "title" }}{{ .Tab }} settings{{ end }} 2 + 3 + {{ define "content" }} 4 + <div class="p-6"> 5 + <p class="text-xl font-bold dark:text-white">Settings</p> 6 + </div> 7 + <div class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 8 + <section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6"> 9 + <div class="col-span-1"> 10 + {{ template "user/settings/fragments/sidebar" . }} 11 + </div> 12 + <div class="col-span-1 md:col-span-3 flex flex-col gap-6"> 13 + {{ template "sshKeysSettings" . }} 14 + </div> 15 + </section> 16 + </div> 17 + {{ end }} 18 + 19 + {{ define "sshKeysSettings" }} 20 + <div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center"> 21 + <div class="col-span-1 md:col-span-2"> 22 + <h2 class="text-sm pb-2 uppercase font-bold">SSH Keys</h2> 23 + <p class="text-gray-500 dark:text-gray-400"> 24 + SSH public keys added here will be broadcasted to knots that you are a member of, 25 + allowing you to push to repositories there. 26 + </p> 27 + </div> 28 + <div class="col-span-1 md:col-span-1 md:justify-self-end"> 29 + {{ template "addKeyButton" . }} 30 + </div> 31 + </div> 32 + <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 w-full"> 33 + {{ range .PubKeys }} 34 + {{ template "user/settings/fragments/keyListing" (list $ .) }} 35 + {{ else }} 36 + <div class="flex items-center justify-center p-2 text-gray-500"> 37 + no keys added yet 38 + </div> 39 + {{ end }} 40 + </div> 41 + {{ end }} 42 + 43 + {{ define "addKeyButton" }} 44 + <button 45 + class="btn flex items-center gap-2" 46 + popovertarget="add-key-modal" 47 + popovertargetaction="toggle"> 48 + {{ i "plus" "size-4" }} 49 + add key 50 + </button> 51 + <div 52 + id="add-key-modal" 53 + popover 54 + class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50"> 55 + {{ template "addKeyModal" . }} 56 + </div> 57 + {{ end}} 58 + 59 + {{ define "addKeyModal" }} 60 + <form 61 + hx-put="/settings/keys" 62 + hx-indicator="#spinner" 63 + hx-swap="none" 64 + class="flex flex-col gap-2" 65 + > 66 + <p class="uppercase p-0">ADD SSH KEY</p> 67 + <p class="text-sm text-gray-500 dark:text-gray-400">SSH keys allow you to push to repositories in knots you're a member of.</p> 68 + <input 69 + type="text" 70 + id="key-name" 71 + name="name" 72 + required 73 + placeholder="key name" 74 + class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400" 75 + /> 76 + <textarea 77 + type="text" 78 + id="key-value" 79 + name="key" 80 + required 81 + placeholder="ssh-rsa AAAAB3NzaC1yc2E..." 82 + class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400"></textarea> 83 + <div class="flex gap-2 pt-2"> 84 + <button 85 + type="button" 86 + popovertarget="add-key-modal" 87 + popovertargetaction="hide" 88 + class="btn w-1/2 flex items-center gap-2 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300" 89 + > 90 + {{ i "x" "size-4" }} cancel 91 + </button> 92 + <button type="submit" class="btn w-1/2 flex items-center"> 93 + <span class="inline-flex gap-2 items-center">{{ i "plus" "size-4" }} add</span> 94 + <span id="spinner" class="group"> 95 + {{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 96 + </span> 97 + </button> 98 + </div> 99 + <div id="settings-keys" class="text-red-500 dark:text-red-400"></div> 100 + </form> 101 + {{ end }}
+64
appview/pages/templates/user/settings/profile.html
··· 1 + {{ define "title" }}{{ .Tab }} settings{{ end }} 2 + 3 + {{ define "content" }} 4 + <div class="p-6"> 5 + <p class="text-xl font-bold dark:text-white">Settings</p> 6 + </div> 7 + <div class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 8 + <section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6"> 9 + <div class="col-span-1"> 10 + {{ template "user/settings/fragments/sidebar" . }} 11 + </div> 12 + <div class="col-span-1 md:col-span-3 flex flex-col gap-6"> 13 + {{ template "profileInfo" . }} 14 + </div> 15 + </section> 16 + </div> 17 + {{ end }} 18 + 19 + {{ define "profileInfo" }} 20 + <div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center"> 21 + <div class="col-span-1 md:col-span-2"> 22 + <h2 class="text-sm pb-2 uppercase font-bold">Profile</h2> 23 + <p class="text-gray-500 dark:text-gray-400"> 24 + Your account information from your AT Protocol identity. 25 + </p> 26 + </div> 27 + <div class="col-span-1 md:col-span-1 md:justify-self-end"> 28 + </div> 29 + </div> 30 + <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 w-full"> 31 + <div class="flex items-center justify-between p-4"> 32 + <div class="hover:no-underline flex flex-col gap-1 min-w-0 max-w-[80%]"> 33 + <div class="flex flex-wrap text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 34 + <span>Handle</span> 35 + </div> 36 + {{ if .LoggedInUser.Handle }} 37 + <span class="font-bold"> 38 + @{{ .LoggedInUser.Handle }} 39 + </span> 40 + {{ end }} 41 + </div> 42 + </div> 43 + <div class="flex items-center justify-between p-4"> 44 + <div class="hover:no-underline flex flex-col gap-1 min-w-0 max-w-[80%]"> 45 + <div class="flex flex-wrap text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 46 + <span>Decentralized Identifier (DID)</span> 47 + </div> 48 + <span class="font-mono font-bold"> 49 + {{ .LoggedInUser.Did }} 50 + </span> 51 + </div> 52 + </div> 53 + <div class="flex items-center justify-between p-4"> 54 + <div class="hover:no-underline flex flex-col gap-1 min-w-0 max-w-[80%]"> 55 + <div class="flex flex-wrap text-sm items-center gap-1 text-gray-500 dark:text-gray-400"> 56 + <span>Personal Data Server (PDS)</span> 57 + </div> 58 + <span class="font-bold"> 59 + {{ .LoggedInUser.Pds }} 60 + </span> 61 + </div> 62 + </div> 63 + </div> 64 + {{ end }}
+55
appview/pages/templates/user/signup.html
··· 1 + {{ define "user/signup" }} 2 + <!doctype html> 3 + <html lang="en" class="dark:bg-gray-900"> 4 + <head> 5 + <meta charset="UTF-8" /> 6 + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 7 + <meta property="og:title" content="signup ยท tangled" /> 8 + <meta property="og:url" content="https://tangled.sh/signup" /> 9 + <meta property="og:description" content="sign up for tangled" /> 10 + <script src="/static/htmx.min.js"></script> 11 + <link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" /> 12 + <title>sign up &middot; tangled</title> 13 + </head> 14 + <body class="flex items-center justify-center min-h-screen"> 15 + <main class="max-w-md px-6 -mt-4"> 16 + <h1 class="flex place-content-center text-2xl font-semibold italic dark:text-white" > 17 + {{ template "fragments/logotype" }} 18 + </h1> 19 + <h2 class="text-center text-xl italic dark:text-white">tightly-knit social coding.</h2> 20 + <form 21 + class="mt-4 max-w-sm mx-auto" 22 + hx-post="/signup" 23 + hx-swap="none" 24 + hx-disabled-elt="#signup-button" 25 + > 26 + <div class="flex flex-col mt-2"> 27 + <label for="email">email</label> 28 + <input 29 + type="email" 30 + id="email" 31 + name="email" 32 + tabindex="4" 33 + required 34 + placeholder="jason@bourne.co" 35 + /> 36 + </div> 37 + <span class="text-sm text-gray-500 mt-1"> 38 + You will receive an email with an invite code. Enter your 39 + invite code, desired username, and password in the next 40 + page to complete your registration. 41 + </span> 42 + <button class="btn text-base w-full my-2 mt-6" type="submit" id="signup-button" tabindex="7" > 43 + <span>join now</span> 44 + </button> 45 + </form> 46 + <p class="text-sm text-gray-500"> 47 + Already have an ATProto account? <a href="/login" class="underline">Login to Tangled</a>. 48 + </p> 49 + 50 + <p id="signup-msg" class="error w-full"></p> 51 + </main> 52 + </body> 53 + </html> 54 + {{ end }} 55 +
+19
appview/pages/templates/user/starred.html
··· 1 + {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท repos {{ end }} 2 + 3 + {{ define "profileContent" }} 4 + <div id="all-repos" class="md:col-span-8 order-2 md:order-2"> 5 + {{ block "starredRepos" . }}{{ end }} 6 + </div> 7 + {{ end }} 8 + 9 + {{ define "starredRepos" }} 10 + <div id="repos" class="grid grid-cols-1 gap-4 mb-6"> 11 + {{ range .Repos }} 12 + <div class="border border-gray-200 dark:border-gray-700 rounded-sm"> 13 + {{ template "user/fragments/repoCard" (list $ . true) }} 14 + </div> 15 + {{ else }} 16 + <p class="px-6 dark:text-white">This user does not have any starred repos yet.</p> 17 + {{ end }} 18 + </div> 19 + {{ end }}
+45
appview/pages/templates/user/strings.html
··· 1 + {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท strings {{ end }} 2 + 3 + {{ define "profileContent" }} 4 + <div id="all-strings" class="md:col-span-8 order-2 md:order-2"> 5 + {{ block "allStrings" . }}{{ end }} 6 + </div> 7 + {{ end }} 8 + 9 + {{ define "allStrings" }} 10 + <div id="strings" class="grid grid-cols-1 gap-4 mb-6"> 11 + {{ range .Strings }} 12 + <div class="border border-gray-200 dark:border-gray-700 rounded-sm"> 13 + {{ template "singleString" (list $ .) }} 14 + </div> 15 + {{ else }} 16 + <p class="px-6 dark:text-white">This user does not have any strings yet.</p> 17 + {{ end }} 18 + </div> 19 + {{ end }} 20 + 21 + {{ define "singleString" }} 22 + {{ $root := index . 0 }} 23 + {{ $s := index . 1 }} 24 + <div class="py-4 px-6 rounded bg-white dark:bg-gray-800"> 25 + <div class="font-medium dark:text-white flex gap-2 items-center"> 26 + <a href="/strings/{{ or $root.Card.UserHandle $root.Card.UserDid }}/{{ $s.Rkey }}">{{ $s.Filename }}</a> 27 + </div> 28 + {{ with $s.Description }} 29 + <div class="text-gray-600 dark:text-gray-300 text-sm"> 30 + {{ . }} 31 + </div> 32 + {{ end }} 33 + 34 + {{ $stat := $s.Stats }} 35 + <div class="text-gray-400 pt-4 text-sm font-mono inline-flex gap-2 mt-auto"> 36 + <span>{{ $stat.LineCount }} line{{if ne $stat.LineCount 1}}s{{end}}</span> 37 + <span class="select-none [&:before]:content-['ยท']"></span> 38 + {{ with $s.Edited }} 39 + <span>edited {{ template "repo/fragments/shortTimeAgo" . }}</span> 40 + {{ else }} 41 + {{ template "repo/fragments/shortTimeAgo" $s.Created }} 42 + {{ end }} 43 + </div> 44 + </div> 45 + {{ end }}
+204 -10
appview/pipelines/pipelines.go
··· 1 1 package pipelines 2 2 3 3 import ( 4 + "bytes" 5 + "context" 6 + "encoding/json" 4 7 "log/slog" 5 8 "net/http" 9 + "strings" 10 + "time" 6 11 7 12 "tangled.sh/tangled.sh/core/appview/config" 8 13 "tangled.sh/tangled.sh/core/appview/db" 9 - "tangled.sh/tangled.sh/core/appview/idresolver" 10 14 "tangled.sh/tangled.sh/core/appview/oauth" 11 15 "tangled.sh/tangled.sh/core/appview/pages" 12 16 "tangled.sh/tangled.sh/core/appview/reporesolver" 13 17 "tangled.sh/tangled.sh/core/eventconsumer" 18 + "tangled.sh/tangled.sh/core/idresolver" 14 19 "tangled.sh/tangled.sh/core/log" 15 20 "tangled.sh/tangled.sh/core/rbac" 21 + spindlemodel "tangled.sh/tangled.sh/core/spindle/models" 16 22 17 23 "github.com/go-chi/chi/v5" 18 - "github.com/posthog/posthog-go" 24 + "github.com/gorilla/websocket" 19 25 ) 20 26 21 27 type Pipelines struct { ··· 27 33 spindlestream *eventconsumer.Consumer 28 34 db *db.DB 29 35 enforcer *rbac.Enforcer 30 - posthog posthog.Client 31 - Logger *slog.Logger 36 + logger *slog.Logger 32 37 } 33 38 34 39 func New( ··· 39 44 idResolver *idresolver.Resolver, 40 45 db *db.DB, 41 46 config *config.Config, 42 - posthog posthog.Client, 43 47 enforcer *rbac.Enforcer, 44 48 ) *Pipelines { 45 49 logger := log.New("pipelines") ··· 51 55 config: config, 52 56 spindlestream: spindlestream, 53 57 db: db, 54 - posthog: posthog, 55 58 enforcer: enforcer, 56 - Logger: logger, 59 + logger: logger, 57 60 } 58 61 } 59 62 60 63 func (p *Pipelines) Index(w http.ResponseWriter, r *http.Request) { 61 64 user := p.oauth.GetUser(r) 62 - l := p.Logger.With("handler", "Index") 65 + l := p.logger.With("handler", "Index") 63 66 64 67 f, err := p.repoResolver.Resolve(r) 65 68 if err != nil { ··· 89 92 90 93 func (p *Pipelines) Workflow(w http.ResponseWriter, r *http.Request) { 91 94 user := p.oauth.GetUser(r) 92 - l := p.Logger.With("handler", "Workflow") 95 + l := p.logger.With("handler", "Workflow") 93 96 94 97 f, err := p.repoResolver.Resolve(r) 95 98 if err != nil { ··· 106 109 } 107 110 108 111 workflow := chi.URLParam(r, "workflow") 109 - if pipelineId == "" { 112 + if workflow == "" { 110 113 l.Error("empty workflow name") 111 114 return 112 115 } ··· 137 140 Workflow: workflow, 138 141 }) 139 142 } 143 + 144 + var upgrader = websocket.Upgrader{ 145 + ReadBufferSize: 1024, 146 + WriteBufferSize: 1024, 147 + } 148 + 149 + func (p *Pipelines) Logs(w http.ResponseWriter, r *http.Request) { 150 + l := p.logger.With("handler", "logs") 151 + 152 + clientConn, err := upgrader.Upgrade(w, r, nil) 153 + if err != nil { 154 + l.Error("websocket upgrade failed", "err", err) 155 + return 156 + } 157 + defer func() { 158 + _ = clientConn.WriteControl( 159 + websocket.CloseMessage, 160 + websocket.FormatCloseMessage(websocket.CloseNormalClosure, "log stream complete"), 161 + time.Now().Add(time.Second), 162 + ) 163 + clientConn.Close() 164 + }() 165 + 166 + ctx, cancel := context.WithCancel(r.Context()) 167 + defer cancel() 168 + 169 + user := p.oauth.GetUser(r) 170 + f, err := p.repoResolver.Resolve(r) 171 + if err != nil { 172 + l.Error("failed to get repo and knot", "err", err) 173 + http.Error(w, "bad repo/knot", http.StatusBadRequest) 174 + return 175 + } 176 + 177 + repoInfo := f.RepoInfo(user) 178 + 179 + pipelineId := chi.URLParam(r, "pipeline") 180 + workflow := chi.URLParam(r, "workflow") 181 + if pipelineId == "" || workflow == "" { 182 + http.Error(w, "missing pipeline ID or workflow", http.StatusBadRequest) 183 + return 184 + } 185 + 186 + ps, err := db.GetPipelineStatuses( 187 + p.db, 188 + db.FilterEq("repo_owner", repoInfo.OwnerDid), 189 + db.FilterEq("repo_name", repoInfo.Name), 190 + db.FilterEq("knot", repoInfo.Knot), 191 + db.FilterEq("id", pipelineId), 192 + ) 193 + if err != nil || len(ps) != 1 { 194 + l.Error("pipeline query failed", "err", err, "count", len(ps)) 195 + http.Error(w, "pipeline not found", http.StatusNotFound) 196 + return 197 + } 198 + 199 + singlePipeline := ps[0] 200 + spindle := repoInfo.Spindle 201 + knot := repoInfo.Knot 202 + rkey := singlePipeline.Rkey 203 + 204 + if spindle == "" || knot == "" || rkey == "" { 205 + http.Error(w, "invalid repo info", http.StatusBadRequest) 206 + return 207 + } 208 + 209 + scheme := "wss" 210 + if p.config.Core.Dev { 211 + scheme = "ws" 212 + } 213 + 214 + url := scheme + "://" + strings.Join([]string{spindle, "logs", knot, rkey, workflow}, "/") 215 + l = l.With("url", url) 216 + l.Info("logs endpoint hit") 217 + 218 + spindleConn, _, err := websocket.DefaultDialer.Dial(url, nil) 219 + if err != nil { 220 + l.Error("websocket dial failed", "err", err) 221 + http.Error(w, "failed to connect to log stream", http.StatusBadGateway) 222 + return 223 + } 224 + defer spindleConn.Close() 225 + 226 + // create a channel for incoming messages 227 + evChan := make(chan logEvent, 100) 228 + // start a goroutine to read from spindle 229 + go readLogs(spindleConn, evChan) 230 + 231 + stepIdx := 0 232 + var fragment bytes.Buffer 233 + for { 234 + select { 235 + case <-ctx.Done(): 236 + l.Info("client disconnected") 237 + return 238 + 239 + case ev, ok := <-evChan: 240 + if !ok { 241 + continue 242 + } 243 + 244 + if ev.err != nil && ev.isCloseError() { 245 + l.Debug("graceful shutdown, tail complete", "err", err) 246 + return 247 + } 248 + if ev.err != nil { 249 + l.Error("error reading from spindle", "err", err) 250 + return 251 + } 252 + 253 + var logLine spindlemodel.LogLine 254 + if err = json.Unmarshal(ev.msg, &logLine); err != nil { 255 + l.Error("failed to parse logline", "err", err) 256 + continue 257 + } 258 + 259 + fragment.Reset() 260 + 261 + switch logLine.Kind { 262 + case spindlemodel.LogKindControl: 263 + // control messages create a new step block 264 + stepIdx++ 265 + collapsed := false 266 + if logLine.StepKind == spindlemodel.StepKindSystem { 267 + collapsed = true 268 + } 269 + err = p.pages.LogBlock(&fragment, pages.LogBlockParams{ 270 + Id: stepIdx, 271 + Name: logLine.Content, 272 + Command: logLine.StepCommand, 273 + Collapsed: collapsed, 274 + }) 275 + case spindlemodel.LogKindData: 276 + // data messages simply insert new log lines into current step 277 + err = p.pages.LogLine(&fragment, pages.LogLineParams{ 278 + Id: stepIdx, 279 + Content: logLine.Content, 280 + }) 281 + } 282 + if err != nil { 283 + l.Error("failed to render log line", "err", err) 284 + return 285 + } 286 + 287 + if err = clientConn.WriteMessage(websocket.TextMessage, fragment.Bytes()); err != nil { 288 + l.Error("error writing to client", "err", err) 289 + return 290 + } 291 + 292 + case <-time.After(30 * time.Second): 293 + l.Debug("sent keepalive") 294 + if err = clientConn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(time.Second)); err != nil { 295 + l.Error("failed to write control", "err", err) 296 + return 297 + } 298 + } 299 + } 300 + } 301 + 302 + // either a message or an error 303 + type logEvent struct { 304 + msg []byte 305 + err error 306 + } 307 + 308 + func (ev *logEvent) isCloseError() bool { 309 + return websocket.IsCloseError( 310 + ev.err, 311 + websocket.CloseNormalClosure, 312 + websocket.CloseGoingAway, 313 + websocket.CloseAbnormalClosure, 314 + ) 315 + } 316 + 317 + // read logs from spindle and pass through to chan 318 + func readLogs(conn *websocket.Conn, ch chan logEvent) { 319 + defer close(ch) 320 + 321 + for { 322 + if conn == nil { 323 + return 324 + } 325 + 326 + _, msg, err := conn.ReadMessage() 327 + if err != nil { 328 + ch <- logEvent{err: err} 329 + return 330 + } 331 + ch <- logEvent{msg: msg} 332 + } 333 + }
+1
appview/pipelines/router.go
··· 11 11 r := chi.NewRouter() 12 12 r.Get("/", p.Index) 13 13 r.Get("/{pipeline}/workflow/{workflow}", p.Workflow) 14 + r.Get("/{pipeline}/workflow/{workflow}/logs", p.Logs) 14 15 15 16 return r 16 17 }
+164
appview/posthog/notifier.go
··· 1 + package posthog_service 2 + 3 + import ( 4 + "context" 5 + "log" 6 + 7 + "github.com/posthog/posthog-go" 8 + "tangled.sh/tangled.sh/core/appview/db" 9 + "tangled.sh/tangled.sh/core/appview/notify" 10 + ) 11 + 12 + type posthogNotifier struct { 13 + client posthog.Client 14 + notify.BaseNotifier 15 + } 16 + 17 + func NewPosthogNotifier(client posthog.Client) notify.Notifier { 18 + return &posthogNotifier{ 19 + client, 20 + notify.BaseNotifier{}, 21 + } 22 + } 23 + 24 + var _ notify.Notifier = &posthogNotifier{} 25 + 26 + func (n *posthogNotifier) NewRepo(ctx context.Context, repo *db.Repo) { 27 + err := n.client.Enqueue(posthog.Capture{ 28 + DistinctId: repo.Did, 29 + Event: "new_repo", 30 + Properties: posthog.Properties{"repo": repo.Name, "repo_at": repo.RepoAt()}, 31 + }) 32 + if err != nil { 33 + log.Println("failed to enqueue posthog event:", err) 34 + } 35 + } 36 + 37 + func (n *posthogNotifier) NewStar(ctx context.Context, star *db.Star) { 38 + err := n.client.Enqueue(posthog.Capture{ 39 + DistinctId: star.StarredByDid, 40 + Event: "star", 41 + Properties: posthog.Properties{"repo_at": star.RepoAt.String()}, 42 + }) 43 + if err != nil { 44 + log.Println("failed to enqueue posthog event:", err) 45 + } 46 + } 47 + 48 + func (n *posthogNotifier) DeleteStar(ctx context.Context, star *db.Star) { 49 + err := n.client.Enqueue(posthog.Capture{ 50 + DistinctId: star.StarredByDid, 51 + Event: "unstar", 52 + Properties: posthog.Properties{"repo_at": star.RepoAt.String()}, 53 + }) 54 + if err != nil { 55 + log.Println("failed to enqueue posthog event:", err) 56 + } 57 + } 58 + 59 + func (n *posthogNotifier) NewIssue(ctx context.Context, issue *db.Issue) { 60 + err := n.client.Enqueue(posthog.Capture{ 61 + DistinctId: issue.Did, 62 + Event: "new_issue", 63 + Properties: posthog.Properties{ 64 + "repo_at": issue.RepoAt.String(), 65 + "issue_id": issue.IssueId, 66 + }, 67 + }) 68 + if err != nil { 69 + log.Println("failed to enqueue posthog event:", err) 70 + } 71 + } 72 + 73 + func (n *posthogNotifier) NewPull(ctx context.Context, pull *db.Pull) { 74 + err := n.client.Enqueue(posthog.Capture{ 75 + DistinctId: pull.OwnerDid, 76 + Event: "new_pull", 77 + Properties: posthog.Properties{ 78 + "repo_at": pull.RepoAt, 79 + "pull_id": pull.PullId, 80 + }, 81 + }) 82 + if err != nil { 83 + log.Println("failed to enqueue posthog event:", err) 84 + } 85 + } 86 + 87 + func (n *posthogNotifier) NewPullComment(ctx context.Context, comment *db.PullComment) { 88 + err := n.client.Enqueue(posthog.Capture{ 89 + DistinctId: comment.OwnerDid, 90 + Event: "new_pull_comment", 91 + Properties: posthog.Properties{ 92 + "repo_at": comment.RepoAt, 93 + "pull_id": comment.PullId, 94 + }, 95 + }) 96 + if err != nil { 97 + log.Println("failed to enqueue posthog event:", err) 98 + } 99 + } 100 + 101 + func (n *posthogNotifier) NewFollow(ctx context.Context, follow *db.Follow) { 102 + err := n.client.Enqueue(posthog.Capture{ 103 + DistinctId: follow.UserDid, 104 + Event: "follow", 105 + Properties: posthog.Properties{"subject": follow.SubjectDid}, 106 + }) 107 + if err != nil { 108 + log.Println("failed to enqueue posthog event:", err) 109 + } 110 + } 111 + 112 + func (n *posthogNotifier) DeleteFollow(ctx context.Context, follow *db.Follow) { 113 + err := n.client.Enqueue(posthog.Capture{ 114 + DistinctId: follow.UserDid, 115 + Event: "unfollow", 116 + Properties: posthog.Properties{"subject": follow.SubjectDid}, 117 + }) 118 + if err != nil { 119 + log.Println("failed to enqueue posthog event:", err) 120 + } 121 + } 122 + 123 + func (n *posthogNotifier) UpdateProfile(ctx context.Context, profile *db.Profile) { 124 + err := n.client.Enqueue(posthog.Capture{ 125 + DistinctId: profile.Did, 126 + Event: "edit_profile", 127 + }) 128 + if err != nil { 129 + log.Println("failed to enqueue posthog event:", err) 130 + } 131 + } 132 + 133 + func (n *posthogNotifier) DeleteString(ctx context.Context, did, rkey string) { 134 + err := n.client.Enqueue(posthog.Capture{ 135 + DistinctId: did, 136 + Event: "delete_string", 137 + Properties: posthog.Properties{"rkey": rkey}, 138 + }) 139 + if err != nil { 140 + log.Println("failed to enqueue posthog event:", err) 141 + } 142 + } 143 + 144 + func (n *posthogNotifier) EditString(ctx context.Context, string *db.String) { 145 + err := n.client.Enqueue(posthog.Capture{ 146 + DistinctId: string.Did.String(), 147 + Event: "edit_string", 148 + Properties: posthog.Properties{"rkey": string.Rkey}, 149 + }) 150 + if err != nil { 151 + log.Println("failed to enqueue posthog event:", err) 152 + } 153 + } 154 + 155 + func (n *posthogNotifier) CreateString(ctx context.Context, string *db.String) { 156 + err := n.client.Enqueue(posthog.Capture{ 157 + DistinctId: string.Did.String(), 158 + Event: "create_string", 159 + Properties: posthog.Properties{"rkey": string.Rkey}, 160 + }) 161 + if err != nil { 162 + log.Println("failed to enqueue posthog event:", err) 163 + } 164 + }
+509 -319
appview/pulls/pulls.go
··· 5 5 "encoding/json" 6 6 "errors" 7 7 "fmt" 8 - "io" 9 8 "log" 10 9 "net/http" 11 10 "sort" ··· 14 13 "time" 15 14 16 15 "tangled.sh/tangled.sh/core/api/tangled" 17 - "tangled.sh/tangled.sh/core/appview" 18 16 "tangled.sh/tangled.sh/core/appview/config" 19 17 "tangled.sh/tangled.sh/core/appview/db" 20 - "tangled.sh/tangled.sh/core/appview/idresolver" 18 + "tangled.sh/tangled.sh/core/appview/notify" 21 19 "tangled.sh/tangled.sh/core/appview/oauth" 22 20 "tangled.sh/tangled.sh/core/appview/pages" 21 + "tangled.sh/tangled.sh/core/appview/pages/markup" 23 22 "tangled.sh/tangled.sh/core/appview/reporesolver" 24 - "tangled.sh/tangled.sh/core/knotclient" 23 + "tangled.sh/tangled.sh/core/appview/xrpcclient" 24 + "tangled.sh/tangled.sh/core/idresolver" 25 25 "tangled.sh/tangled.sh/core/patchutil" 26 + "tangled.sh/tangled.sh/core/tid" 26 27 "tangled.sh/tangled.sh/core/types" 27 28 28 29 "github.com/bluekeyes/go-gitdiff/gitdiff" 29 30 comatproto "github.com/bluesky-social/indigo/api/atproto" 30 - "github.com/bluesky-social/indigo/atproto/syntax" 31 31 lexutil "github.com/bluesky-social/indigo/lex/util" 32 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 32 33 "github.com/go-chi/chi/v5" 33 34 "github.com/google/uuid" 34 - "github.com/posthog/posthog-go" 35 35 ) 36 36 37 37 type Pulls struct { ··· 41 41 idResolver *idresolver.Resolver 42 42 db *db.DB 43 43 config *config.Config 44 - posthog posthog.Client 44 + notifier notify.Notifier 45 45 } 46 46 47 47 func New( ··· 51 51 resolver *idresolver.Resolver, 52 52 db *db.DB, 53 53 config *config.Config, 54 - posthog posthog.Client, 54 + notifier notify.Notifier, 55 55 ) *Pulls { 56 56 return &Pulls{ 57 57 oauth: oauth, ··· 60 60 idResolver: resolver, 61 61 db: db, 62 62 config: config, 63 - posthog: posthog, 63 + notifier: notifier, 64 64 } 65 65 } 66 66 ··· 96 96 return 97 97 } 98 98 99 - mergeCheckResponse := s.mergeCheck(f, pull, stack) 99 + mergeCheckResponse := s.mergeCheck(r, f, pull, stack) 100 100 resubmitResult := pages.Unknown 101 101 if user.Did == pull.OwnerDid { 102 - resubmitResult = s.resubmitCheck(f, pull, stack) 102 + resubmitResult = s.resubmitCheck(r, f, pull, stack) 103 103 } 104 104 105 105 s.pages.PullActionsFragment(w, pages.PullActionsParams{ ··· 151 151 } 152 152 } 153 153 154 - resolvedIds := s.idResolver.ResolveIdents(r.Context(), identsToResolve) 155 - didHandleMap := make(map[string]string) 156 - for _, identity := range resolvedIds { 157 - if !identity.Handle.IsInvalidHandle() { 158 - didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 159 - } else { 160 - didHandleMap[identity.DID.String()] = identity.DID.String() 161 - } 162 - } 163 - 164 - mergeCheckResponse := s.mergeCheck(f, pull, stack) 154 + mergeCheckResponse := s.mergeCheck(r, f, pull, stack) 165 155 resubmitResult := pages.Unknown 166 156 if user != nil && user.Did == pull.OwnerDid { 167 - resubmitResult = s.resubmitCheck(f, pull, stack) 157 + resubmitResult = s.resubmitCheck(r, f, pull, stack) 158 + } 159 + 160 + repoInfo := f.RepoInfo(user) 161 + 162 + m := make(map[string]db.Pipeline) 163 + 164 + var shas []string 165 + for _, s := range pull.Submissions { 166 + shas = append(shas, s.SourceRev) 167 + } 168 + for _, p := range stack { 169 + shas = append(shas, p.LatestSha()) 170 + } 171 + for _, p := range abandonedPulls { 172 + shas = append(shas, p.LatestSha()) 173 + } 174 + 175 + ps, err := db.GetPipelineStatuses( 176 + s.db, 177 + db.FilterEq("repo_owner", repoInfo.OwnerDid), 178 + db.FilterEq("repo_name", repoInfo.Name), 179 + db.FilterEq("knot", repoInfo.Knot), 180 + db.FilterIn("sha", shas), 181 + ) 182 + if err != nil { 183 + log.Printf("failed to fetch pipeline statuses: %s", err) 184 + // non-fatal 185 + } 186 + 187 + for _, p := range ps { 188 + m[p.Sha] = p 189 + } 190 + 191 + reactionCountMap, err := db.GetReactionCountMap(s.db, pull.PullAt()) 192 + if err != nil { 193 + log.Println("failed to get pull reactions") 194 + s.pages.Notice(w, "pulls", "Failed to load pull. Try again later.") 195 + } 196 + 197 + userReactions := map[db.ReactionKind]bool{} 198 + if user != nil { 199 + userReactions = db.GetReactionStatusMap(s.db, user.Did, pull.PullAt()) 168 200 } 169 201 170 202 s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{ 171 203 LoggedInUser: user, 172 - RepoInfo: f.RepoInfo(user), 173 - DidHandleMap: didHandleMap, 204 + RepoInfo: repoInfo, 174 205 Pull: pull, 175 206 Stack: stack, 176 207 AbandonedPulls: abandonedPulls, 177 208 MergeCheck: mergeCheckResponse, 178 209 ResubmitCheck: resubmitResult, 210 + Pipelines: m, 211 + 212 + OrderedReactionKinds: db.OrderedReactionKinds, 213 + Reactions: reactionCountMap, 214 + UserReacted: userReactions, 179 215 }) 180 216 } 181 217 182 - func (s *Pulls) mergeCheck(f *reporesolver.ResolvedRepo, pull *db.Pull, stack db.Stack) types.MergeCheckResponse { 218 + func (s *Pulls) mergeCheck(r *http.Request, f *reporesolver.ResolvedRepo, pull *db.Pull, stack db.Stack) types.MergeCheckResponse { 183 219 if pull.State == db.PullMerged { 184 220 return types.MergeCheckResponse{} 185 221 } 186 222 187 - secret, err := db.GetRegistrationKey(s.db, f.Knot) 188 - if err != nil { 189 - log.Printf("failed to get registration key: %v", err) 190 - return types.MergeCheckResponse{ 191 - Error: "failed to check merge status: this knot is unregistered", 192 - } 223 + scheme := "https" 224 + if s.config.Core.Dev { 225 + scheme = "http" 193 226 } 227 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 194 228 195 - ksClient, err := knotclient.NewSignedClient(f.Knot, secret, s.config.Core.Dev) 196 - if err != nil { 197 - log.Printf("failed to setup signed client for %s; ignoring: %v", f.Knot, err) 198 - return types.MergeCheckResponse{ 199 - Error: "failed to check merge status", 200 - } 229 + xrpcc := indigoxrpc.Client{ 230 + Host: host, 201 231 } 202 232 203 233 patch := pull.LatestPatch() ··· 210 240 patch = mergeable.CombinedPatch() 211 241 } 212 242 213 - resp, err := ksClient.MergeCheck([]byte(patch), f.OwnerDid(), f.RepoName, pull.TargetBranch) 214 - if err != nil { 215 - log.Println("failed to check for mergeability:", err) 243 + resp, xe := tangled.RepoMergeCheck( 244 + r.Context(), 245 + &xrpcc, 246 + &tangled.RepoMergeCheck_Input{ 247 + Did: f.OwnerDid(), 248 + Name: f.Name, 249 + Branch: pull.TargetBranch, 250 + Patch: patch, 251 + }, 252 + ) 253 + if err := xrpcclient.HandleXrpcErr(xe); err != nil { 254 + log.Println("failed to check for mergeability", "err", err) 216 255 return types.MergeCheckResponse{ 217 - Error: "failed to check merge status", 256 + Error: fmt.Sprintf("failed to check merge status: %s", err.Error()), 218 257 } 219 258 } 220 - switch resp.StatusCode { 221 - case 404: 222 - return types.MergeCheckResponse{ 223 - Error: "failed to check merge status: this knot does not support PRs", 224 - } 225 - case 400: 226 - return types.MergeCheckResponse{ 227 - Error: "failed to check merge status: does this knot support PRs?", 259 + 260 + // convert xrpc response to internal types 261 + conflicts := make([]types.ConflictInfo, len(resp.Conflicts)) 262 + for i, conflict := range resp.Conflicts { 263 + conflicts[i] = types.ConflictInfo{ 264 + Filename: conflict.Filename, 265 + Reason: conflict.Reason, 228 266 } 229 267 } 230 268 231 - respBody, err := io.ReadAll(resp.Body) 232 - if err != nil { 233 - log.Println("failed to read merge check response body") 234 - return types.MergeCheckResponse{ 235 - Error: "failed to check merge status: knot is not speaking the right language", 236 - } 269 + result := types.MergeCheckResponse{ 270 + IsConflicted: resp.Is_conflicted, 271 + Conflicts: conflicts, 272 + } 273 + 274 + if resp.Message != nil { 275 + result.Message = *resp.Message 237 276 } 238 - defer resp.Body.Close() 239 277 240 - var mergeCheckResponse types.MergeCheckResponse 241 - err = json.Unmarshal(respBody, &mergeCheckResponse) 242 - if err != nil { 243 - log.Println("failed to unmarshal merge check response", err) 244 - return types.MergeCheckResponse{ 245 - Error: "failed to check merge status: knot is not speaking the right language", 246 - } 278 + if resp.Error != nil { 279 + result.Error = *resp.Error 247 280 } 248 281 249 - return mergeCheckResponse 282 + return result 250 283 } 251 284 252 - func (s *Pulls) resubmitCheck(f *reporesolver.ResolvedRepo, pull *db.Pull, stack db.Stack) pages.ResubmitResult { 285 + func (s *Pulls) resubmitCheck(r *http.Request, f *reporesolver.ResolvedRepo, pull *db.Pull, stack db.Stack) pages.ResubmitResult { 253 286 if pull.State == db.PullMerged || pull.State == db.PullDeleted || pull.PullSource == nil { 254 287 return pages.Unknown 255 288 } ··· 271 304 // pulls within the same repo 272 305 knot = f.Knot 273 306 ownerDid = f.OwnerDid() 274 - repoName = f.RepoName 307 + repoName = f.Name 275 308 } 276 309 277 - us, err := knotclient.NewUnsignedClient(knot, s.config.Core.Dev) 278 - if err != nil { 279 - log.Printf("failed to setup client for %s; ignoring: %v", knot, err) 280 - return pages.Unknown 310 + scheme := "http" 311 + if !s.config.Core.Dev { 312 + scheme = "https" 313 + } 314 + host := fmt.Sprintf("%s://%s", scheme, knot) 315 + xrpcc := &indigoxrpc.Client{ 316 + Host: host, 281 317 } 282 318 283 - result, err := us.Branch(ownerDid, repoName, pull.PullSource.Branch) 319 + repo := fmt.Sprintf("%s/%s", ownerDid, repoName) 320 + branchResp, err := tangled.RepoBranch(r.Context(), xrpcc, pull.PullSource.Branch, repo) 284 321 if err != nil { 322 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 323 + log.Println("failed to call XRPC repo.branches", xrpcerr) 324 + return pages.Unknown 325 + } 285 326 log.Println("failed to reach knotserver", err) 286 327 return pages.Unknown 287 328 } 288 329 330 + targetBranch := branchResp 331 + 289 332 latestSourceRev := pull.Submissions[pull.LastRoundNumber()].SourceRev 290 333 291 334 if pull.IsStacked() && stack != nil { ··· 293 336 latestSourceRev = top.Submissions[top.LastRoundNumber()].SourceRev 294 337 } 295 338 296 - if latestSourceRev != result.Branch.Hash { 339 + if latestSourceRev != targetBranch.Hash { 297 340 return pages.ShouldResubmit 298 341 } 299 342 ··· 308 351 return 309 352 } 310 353 354 + var diffOpts types.DiffOpts 355 + if d := r.URL.Query().Get("diff"); d == "split" { 356 + diffOpts.Split = true 357 + } 358 + 311 359 pull, ok := r.Context().Value("pull").(*db.Pull) 312 360 if !ok { 313 361 log.Println("failed to get pull") ··· 325 373 return 326 374 } 327 375 328 - identsToResolve := []string{pull.OwnerDid} 329 - resolvedIds := s.idResolver.ResolveIdents(r.Context(), identsToResolve) 330 - didHandleMap := make(map[string]string) 331 - for _, identity := range resolvedIds { 332 - if !identity.Handle.IsInvalidHandle() { 333 - didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 334 - } else { 335 - didHandleMap[identity.DID.String()] = identity.DID.String() 336 - } 337 - } 338 - 339 376 patch := pull.Submissions[roundIdInt].Patch 340 377 diff := patchutil.AsNiceDiff(patch, pull.TargetBranch) 341 378 342 379 s.pages.RepoPullPatchPage(w, pages.RepoPullPatchParams{ 343 380 LoggedInUser: user, 344 - DidHandleMap: didHandleMap, 345 381 RepoInfo: f.RepoInfo(user), 346 382 Pull: pull, 347 383 Stack: stack, 348 384 Round: roundIdInt, 349 385 Submission: pull.Submissions[roundIdInt], 350 386 Diff: &diff, 387 + DiffOpts: diffOpts, 351 388 }) 352 389 353 390 } ··· 361 398 return 362 399 } 363 400 401 + var diffOpts types.DiffOpts 402 + if d := r.URL.Query().Get("diff"); d == "split" { 403 + diffOpts.Split = true 404 + } 405 + 364 406 pull, ok := r.Context().Value("pull").(*db.Pull) 365 407 if !ok { 366 408 log.Println("failed to get pull") ··· 382 424 return 383 425 } 384 426 385 - identsToResolve := []string{pull.OwnerDid} 386 - resolvedIds := s.idResolver.ResolveIdents(r.Context(), identsToResolve) 387 - didHandleMap := make(map[string]string) 388 - for _, identity := range resolvedIds { 389 - if !identity.Handle.IsInvalidHandle() { 390 - didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 391 - } else { 392 - didHandleMap[identity.DID.String()] = identity.DID.String() 393 - } 394 - } 395 - 396 427 currentPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt].Patch) 397 428 if err != nil { 398 429 log.Println("failed to interdiff; current patch malformed") ··· 414 445 RepoInfo: f.RepoInfo(user), 415 446 Pull: pull, 416 447 Round: roundIdInt, 417 - DidHandleMap: didHandleMap, 418 448 Interdiff: interdiff, 449 + DiffOpts: diffOpts, 419 450 }) 420 - return 421 451 } 422 452 423 453 func (s *Pulls) RepoPullPatchRaw(w http.ResponseWriter, r *http.Request) { ··· 436 466 return 437 467 } 438 468 439 - identsToResolve := []string{pull.OwnerDid} 440 - resolvedIds := s.idResolver.ResolveIdents(r.Context(), identsToResolve) 441 - didHandleMap := make(map[string]string) 442 - for _, identity := range resolvedIds { 443 - if !identity.Handle.IsInvalidHandle() { 444 - didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 445 - } else { 446 - didHandleMap[identity.DID.String()] = identity.DID.String() 447 - } 448 - } 449 - 450 - w.Header().Set("Content-Type", "text/plain") 469 + w.Header().Set("Content-Type", "text/plain; charset=utf-8") 451 470 w.Write([]byte(pull.Submissions[roundIdInt].Patch)) 452 471 } 453 472 ··· 471 490 472 491 pulls, err := db.GetPulls( 473 492 s.db, 474 - db.FilterEq("repo_at", f.RepoAt), 493 + db.FilterEq("repo_at", f.RepoAt()), 475 494 db.FilterEq("state", state), 476 495 ) 477 496 if err != nil { ··· 497 516 498 517 // we want to group all stacked PRs into just one list 499 518 stacks := make(map[string]db.Stack) 519 + var shas []string 500 520 n := 0 501 521 for _, p := range pulls { 522 + // store the sha for later 523 + shas = append(shas, p.LatestSha()) 502 524 // this PR is stacked 503 525 if p.StackId != "" { 504 526 // we have already seen this PR stack ··· 517 539 } 518 540 pulls = pulls[:n] 519 541 520 - identsToResolve := make([]string, len(pulls)) 521 - for i, pull := range pulls { 522 - identsToResolve[i] = pull.OwnerDid 542 + repoInfo := f.RepoInfo(user) 543 + ps, err := db.GetPipelineStatuses( 544 + s.db, 545 + db.FilterEq("repo_owner", repoInfo.OwnerDid), 546 + db.FilterEq("repo_name", repoInfo.Name), 547 + db.FilterEq("knot", repoInfo.Knot), 548 + db.FilterIn("sha", shas), 549 + ) 550 + if err != nil { 551 + log.Printf("failed to fetch pipeline statuses: %s", err) 552 + // non-fatal 523 553 } 524 - resolvedIds := s.idResolver.ResolveIdents(r.Context(), identsToResolve) 525 - didHandleMap := make(map[string]string) 526 - for _, identity := range resolvedIds { 527 - if !identity.Handle.IsInvalidHandle() { 528 - didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 529 - } else { 530 - didHandleMap[identity.DID.String()] = identity.DID.String() 531 - } 554 + m := make(map[string]db.Pipeline) 555 + for _, p := range ps { 556 + m[p.Sha] = p 532 557 } 533 558 534 559 s.pages.RepoPulls(w, pages.RepoPullsParams{ 535 560 LoggedInUser: s.oauth.GetUser(r), 536 561 RepoInfo: f.RepoInfo(user), 537 562 Pulls: pulls, 538 - DidHandleMap: didHandleMap, 539 563 FilteringBy: state, 540 564 Stacks: stacks, 565 + Pipelines: m, 541 566 }) 542 - return 543 567 } 544 568 545 569 func (s *Pulls) PullComment(w http.ResponseWriter, r *http.Request) { ··· 591 615 defer tx.Rollback() 592 616 593 617 createdAt := time.Now().Format(time.RFC3339) 594 - ownerDid := user.Did 595 618 596 - pullAt, err := db.GetPullAt(s.db, f.RepoAt, pull.PullId) 619 + pullAt, err := db.GetPullAt(s.db, f.RepoAt(), pull.PullId) 597 620 if err != nil { 598 621 log.Println("failed to get pull at", err) 599 622 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 600 623 return 601 624 } 602 625 603 - atUri := f.RepoAt.String() 604 626 client, err := s.oauth.AuthorizedClient(r) 605 627 if err != nil { 606 628 log.Println("failed to get authorized client", err) ··· 610 632 atResp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 611 633 Collection: tangled.RepoPullCommentNSID, 612 634 Repo: user.Did, 613 - Rkey: appview.TID(), 635 + Rkey: tid.TID(), 614 636 Record: &lexutil.LexiconTypeDecoder{ 615 637 Val: &tangled.RepoPullComment{ 616 - Repo: &atUri, 617 638 Pull: string(pullAt), 618 - Owner: &ownerDid, 619 639 Body: body, 620 640 CreatedAt: createdAt, 621 641 }, ··· 627 647 return 628 648 } 629 649 630 - // Create the pull comment in the database with the commentAt field 631 - commentId, err := db.NewPullComment(tx, &db.PullComment{ 650 + comment := &db.PullComment{ 632 651 OwnerDid: user.Did, 633 - RepoAt: f.RepoAt.String(), 652 + RepoAt: f.RepoAt().String(), 634 653 PullId: pull.PullId, 635 654 Body: body, 636 655 CommentAt: atResp.Uri, 637 656 SubmissionId: pull.Submissions[roundNumber].ID, 638 - }) 657 + } 658 + 659 + // Create the pull comment in the database with the commentAt field 660 + commentId, err := db.NewPullComment(tx, comment) 639 661 if err != nil { 640 662 log.Println("failed to create pull comment", err) 641 663 s.pages.Notice(w, "pull-comment", "Failed to create comment.") ··· 649 671 return 650 672 } 651 673 652 - if !s.config.Core.Dev { 653 - err = s.posthog.Enqueue(posthog.Capture{ 654 - DistinctId: user.Did, 655 - Event: "new_pull_comment", 656 - Properties: posthog.Properties{"repo_at": f.RepoAt.String(), "pull_id": pull.PullId}, 657 - }) 658 - if err != nil { 659 - log.Println("failed to enqueue posthog event:", err) 660 - } 661 - } 674 + s.notifier.NewPullComment(r.Context(), comment) 662 675 663 676 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", f.OwnerSlashRepo(), pull.PullId, commentId)) 664 677 return ··· 675 688 676 689 switch r.Method { 677 690 case http.MethodGet: 678 - us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 691 + scheme := "http" 692 + if !s.config.Core.Dev { 693 + scheme = "https" 694 + } 695 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 696 + xrpcc := &indigoxrpc.Client{ 697 + Host: host, 698 + } 699 + 700 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 701 + xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 679 702 if err != nil { 680 - log.Printf("failed to create unsigned client for %s", f.Knot) 681 - s.pages.Error503(w) 703 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 704 + log.Println("failed to call XRPC repo.branches", xrpcerr) 705 + s.pages.Error503(w) 706 + return 707 + } 708 + log.Println("failed to fetch branches", err) 682 709 return 683 710 } 684 711 685 - result, err := us.Branches(f.OwnerDid(), f.RepoName) 686 - if err != nil { 687 - log.Println("failed to fetch branches", err) 712 + var result types.RepoBranchesResponse 713 + if err := json.Unmarshal(xrpcBytes, &result); err != nil { 714 + log.Println("failed to decode XRPC response", err) 715 + s.pages.Error503(w) 688 716 return 689 717 } 690 718 ··· 730 758 s.pages.Notice(w, "pull", "Title is required for git-diff patches.") 731 759 return 732 760 } 761 + sanitizer := markup.NewSanitizer() 762 + if st := strings.TrimSpace(sanitizer.SanitizeDescription(title)); (st) == "" { 763 + s.pages.Notice(w, "pull", "Title is empty after HTML sanitization") 764 + return 765 + } 733 766 } 734 767 735 768 // Validate we have at least one valid PR creation method ··· 744 777 return 745 778 } 746 779 747 - us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 748 - if err != nil { 749 - log.Printf("failed to create unsigned client to %s: %v", f.Knot, err) 750 - s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.") 751 - return 780 + // us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 781 + // if err != nil { 782 + // log.Printf("failed to create unsigned client to %s: %v", f.Knot, err) 783 + // s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.") 784 + // return 785 + // } 786 + 787 + // TODO: make capabilities an xrpc call 788 + caps := struct { 789 + PullRequests struct { 790 + FormatPatch bool 791 + BranchSubmissions bool 792 + ForkSubmissions bool 793 + PatchSubmissions bool 794 + } 795 + }{ 796 + PullRequests: struct { 797 + FormatPatch bool 798 + BranchSubmissions bool 799 + ForkSubmissions bool 800 + PatchSubmissions bool 801 + }{ 802 + FormatPatch: true, 803 + BranchSubmissions: true, 804 + ForkSubmissions: true, 805 + PatchSubmissions: true, 806 + }, 752 807 } 753 808 754 - caps, err := us.Capabilities() 755 - if err != nil { 756 - log.Println("error fetching knot caps", f.Knot, err) 757 - s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.") 758 - return 759 - } 809 + // caps, err := us.Capabilities() 810 + // if err != nil { 811 + // log.Println("error fetching knot caps", f.Knot, err) 812 + // s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.") 813 + // return 814 + // } 760 815 761 816 if !caps.PullRequests.FormatPatch { 762 817 s.pages.Notice(w, "pull", "This knot doesn't support format-patch. Unfortunately, there is no fallback for now.") ··· 798 853 sourceBranch string, 799 854 isStacked bool, 800 855 ) { 801 - pullSource := &db.PullSource{ 802 - Branch: sourceBranch, 856 + scheme := "http" 857 + if !s.config.Core.Dev { 858 + scheme = "https" 803 859 } 804 - recordPullSource := &tangled.RepoPull_Source{ 805 - Branch: sourceBranch, 860 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 861 + xrpcc := &indigoxrpc.Client{ 862 + Host: host, 806 863 } 807 864 808 - // Generate a patch using /compare 809 - ksClient, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 865 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 866 + xrpcBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, targetBranch, sourceBranch) 810 867 if err != nil { 811 - log.Printf("failed to create signed client for %s: %s", f.Knot, err) 812 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 868 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 869 + log.Println("failed to call XRPC repo.compare", xrpcerr) 870 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 871 + return 872 + } 873 + log.Println("failed to compare", err) 874 + s.pages.Notice(w, "pull", err.Error()) 813 875 return 814 876 } 815 877 816 - comparison, err := ksClient.Compare(f.OwnerDid(), f.RepoName, targetBranch, sourceBranch) 817 - if err != nil { 818 - log.Println("failed to compare", err) 819 - s.pages.Notice(w, "pull", err.Error()) 878 + var comparison types.RepoFormatPatchResponse 879 + if err := json.Unmarshal(xrpcBytes, &comparison); err != nil { 880 + log.Println("failed to decode XRPC compare response", err) 881 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 820 882 return 821 883 } 822 884 ··· 828 890 return 829 891 } 830 892 893 + pullSource := &db.PullSource{ 894 + Branch: sourceBranch, 895 + } 896 + recordPullSource := &tangled.RepoPull_Source{ 897 + Branch: sourceBranch, 898 + Sha: comparison.Rev2, 899 + } 900 + 831 901 s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, sourceRev, pullSource, recordPullSource, isStacked) 832 902 } 833 903 ··· 841 911 } 842 912 843 913 func (s *Pulls) handleForkBasedPull(w http.ResponseWriter, r *http.Request, f *reporesolver.ResolvedRepo, user *oauth.User, forkRepo string, title, body, targetBranch, sourceBranch string, isStacked bool) { 844 - fork, err := db.GetForkByDid(s.db, user.Did, forkRepo) 914 + repoString := strings.SplitN(forkRepo, "/", 2) 915 + forkOwnerDid := repoString[0] 916 + repoName := repoString[1] 917 + fork, err := db.GetForkByDid(s.db, forkOwnerDid, repoName) 845 918 if errors.Is(err, sql.ErrNoRows) { 846 919 s.pages.Notice(w, "pull", "No such fork.") 847 920 return ··· 851 924 return 852 925 } 853 926 854 - secret, err := db.GetRegistrationKey(s.db, fork.Knot) 855 - if err != nil { 856 - log.Println("failed to fetch registration key:", err) 857 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 858 - return 859 - } 860 - 861 - sc, err := knotclient.NewSignedClient(fork.Knot, secret, s.config.Core.Dev) 862 - if err != nil { 863 - log.Println("failed to create signed client:", err) 864 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 865 - return 866 - } 867 - 868 - us, err := knotclient.NewUnsignedClient(fork.Knot, s.config.Core.Dev) 869 - if err != nil { 870 - log.Println("failed to create unsigned client:", err) 871 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 872 - return 873 - } 927 + client, err := s.oauth.ServiceClient( 928 + r, 929 + oauth.WithService(fork.Knot), 930 + oauth.WithLxm(tangled.RepoHiddenRefNSID), 931 + oauth.WithDev(s.config.Core.Dev), 932 + ) 874 933 875 - resp, err := sc.NewHiddenRef(user.Did, fork.Name, sourceBranch, targetBranch) 876 - if err != nil { 877 - log.Println("failed to create hidden ref:", err, resp.StatusCode) 878 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 934 + resp, err := tangled.RepoHiddenRef( 935 + r.Context(), 936 + client, 937 + &tangled.RepoHiddenRef_Input{ 938 + ForkRef: sourceBranch, 939 + RemoteRef: targetBranch, 940 + Repo: fork.RepoAt().String(), 941 + }, 942 + ) 943 + if err := xrpcclient.HandleXrpcErr(err); err != nil { 944 + s.pages.Notice(w, "pull", err.Error()) 879 945 return 880 946 } 881 947 882 - switch resp.StatusCode { 883 - case 404: 884 - case 400: 885 - s.pages.Notice(w, "pull", "Branch based pull requests are not supported on this knot.") 948 + if !resp.Success { 949 + errorMsg := "Failed to create pull request" 950 + if resp.Error != nil { 951 + errorMsg = fmt.Sprintf("Failed to create pull request: %s", *resp.Error) 952 + } 953 + s.pages.Notice(w, "pull", errorMsg) 886 954 return 887 955 } 888 956 ··· 892 960 // hiddenRef: hidden/feature-1/main (on repo-fork) 893 961 // targetBranch: main (on repo-1) 894 962 // sourceBranch: feature-1 (on repo-fork) 895 - comparison, err := us.Compare(user.Did, fork.Name, hiddenRef, sourceBranch) 963 + forkScheme := "http" 964 + if !s.config.Core.Dev { 965 + forkScheme = "https" 966 + } 967 + forkHost := fmt.Sprintf("%s://%s", forkScheme, fork.Knot) 968 + forkXrpcc := &indigoxrpc.Client{ 969 + Host: forkHost, 970 + } 971 + 972 + forkRepoId := fmt.Sprintf("%s/%s", fork.Did, fork.Name) 973 + forkXrpcBytes, err := tangled.RepoCompare(r.Context(), forkXrpcc, forkRepoId, hiddenRef, sourceBranch) 896 974 if err != nil { 975 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 976 + log.Println("failed to call XRPC repo.compare for fork", xrpcerr) 977 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 978 + return 979 + } 897 980 log.Println("failed to compare across branches", err) 898 981 s.pages.Notice(w, "pull", err.Error()) 899 982 return 900 983 } 901 984 985 + var comparison types.RepoFormatPatchResponse 986 + if err := json.Unmarshal(forkXrpcBytes, &comparison); err != nil { 987 + log.Println("failed to decode XRPC compare response for fork", err) 988 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 989 + return 990 + } 991 + 902 992 sourceRev := comparison.Rev2 903 993 patch := comparison.Patch 904 994 ··· 907 997 return 908 998 } 909 999 910 - forkAtUri, err := syntax.ParseATURI(fork.AtUri) 911 - if err != nil { 912 - log.Println("failed to parse fork AT URI", err) 913 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 914 - return 915 - } 1000 + forkAtUri := fork.RepoAt() 1001 + forkAtUriStr := forkAtUri.String() 916 1002 917 - s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, sourceRev, &db.PullSource{ 1003 + pullSource := &db.PullSource{ 918 1004 Branch: sourceBranch, 919 1005 RepoAt: &forkAtUri, 920 - }, &tangled.RepoPull_Source{Branch: sourceBranch, Repo: &fork.AtUri}, isStacked) 1006 + } 1007 + recordPullSource := &tangled.RepoPull_Source{ 1008 + Branch: sourceBranch, 1009 + Repo: &forkAtUriStr, 1010 + Sha: sourceRev, 1011 + } 1012 + 1013 + s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, sourceRev, pullSource, recordPullSource, isStacked) 921 1014 } 922 1015 923 1016 func (s *Pulls) createPullRequest( ··· 934 1027 ) { 935 1028 if isStacked { 936 1029 // creates a series of PRs, each linking to the previous, identified by jj's change-id 937 - s.createStackedPulLRequest( 1030 + s.createStackedPullRequest( 938 1031 w, 939 1032 r, 940 1033 f, ··· 979 1072 body = formatPatches[0].Body 980 1073 } 981 1074 982 - rkey := appview.TID() 1075 + rkey := tid.TID() 983 1076 initialSubmission := db.PullSubmission{ 984 1077 Patch: patch, 985 1078 SourceRev: sourceRev, 986 1079 } 987 - err = db.NewPull(tx, &db.Pull{ 1080 + pull := &db.Pull{ 988 1081 Title: title, 989 1082 Body: body, 990 1083 TargetBranch: targetBranch, 991 1084 OwnerDid: user.Did, 992 - RepoAt: f.RepoAt, 1085 + RepoAt: f.RepoAt(), 993 1086 Rkey: rkey, 994 1087 Submissions: []*db.PullSubmission{ 995 1088 &initialSubmission, 996 1089 }, 997 1090 PullSource: pullSource, 998 - }) 1091 + } 1092 + err = db.NewPull(tx, pull) 999 1093 if err != nil { 1000 1094 log.Println("failed to create pull request", err) 1001 1095 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1002 1096 return 1003 1097 } 1004 - pullId, err := db.NextPullId(tx, f.RepoAt) 1098 + pullId, err := db.NextPullId(tx, f.RepoAt()) 1005 1099 if err != nil { 1006 1100 log.Println("failed to get pull id", err) 1007 1101 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") ··· 1014 1108 Rkey: rkey, 1015 1109 Record: &lexutil.LexiconTypeDecoder{ 1016 1110 Val: &tangled.RepoPull{ 1017 - Title: title, 1018 - PullId: int64(pullId), 1019 - TargetRepo: string(f.RepoAt), 1020 - TargetBranch: targetBranch, 1021 - Patch: patch, 1022 - Source: recordPullSource, 1111 + Title: title, 1112 + Target: &tangled.RepoPull_Target{ 1113 + Repo: string(f.RepoAt()), 1114 + Branch: targetBranch, 1115 + }, 1116 + Patch: patch, 1117 + Source: recordPullSource, 1023 1118 }, 1024 1119 }, 1025 1120 }) ··· 1035 1130 return 1036 1131 } 1037 1132 1038 - if !s.config.Core.Dev { 1039 - err = s.posthog.Enqueue(posthog.Capture{ 1040 - DistinctId: user.Did, 1041 - Event: "new_pull", 1042 - Properties: posthog.Properties{"repo_at": f.RepoAt.String(), "pull_id": pullId}, 1043 - }) 1044 - if err != nil { 1045 - log.Println("failed to enqueue posthog event:", err) 1046 - } 1047 - } 1133 + s.notifier.NewPull(r.Context(), pull) 1048 1134 1049 1135 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pullId)) 1050 1136 } 1051 1137 1052 - func (s *Pulls) createStackedPulLRequest( 1138 + func (s *Pulls) createStackedPullRequest( 1053 1139 w http.ResponseWriter, 1054 1140 r *http.Request, 1055 1141 f *reporesolver.ResolvedRepo, ··· 1196 1282 return 1197 1283 } 1198 1284 1199 - us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 1285 + scheme := "http" 1286 + if !s.config.Core.Dev { 1287 + scheme = "https" 1288 + } 1289 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 1290 + xrpcc := &indigoxrpc.Client{ 1291 + Host: host, 1292 + } 1293 + 1294 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 1295 + xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 1200 1296 if err != nil { 1201 - log.Printf("failed to create unsigned client for %s", f.Knot) 1202 - s.pages.Error503(w) 1297 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1298 + log.Println("failed to call XRPC repo.branches", xrpcerr) 1299 + s.pages.Error503(w) 1300 + return 1301 + } 1302 + log.Println("failed to fetch branches", err) 1203 1303 return 1204 1304 } 1205 1305 1206 - result, err := us.Branches(f.OwnerDid(), f.RepoName) 1207 - if err != nil { 1208 - log.Println("failed to reach knotserver", err) 1306 + var result types.RepoBranchesResponse 1307 + if err := json.Unmarshal(xrpcBytes, &result); err != nil { 1308 + log.Println("failed to decode XRPC response", err) 1309 + s.pages.Error503(w) 1209 1310 return 1210 1311 } 1211 1312 ··· 1259 1360 } 1260 1361 1261 1362 forkVal := r.URL.Query().Get("fork") 1262 - 1363 + repoString := strings.SplitN(forkVal, "/", 2) 1364 + forkOwnerDid := repoString[0] 1365 + forkName := repoString[1] 1263 1366 // fork repo 1264 - repo, err := db.GetRepo(s.db, user.Did, forkVal) 1367 + repo, err := db.GetRepo(s.db, forkOwnerDid, forkName) 1265 1368 if err != nil { 1266 1369 log.Println("failed to get repo", user.Did, forkVal) 1267 1370 return 1268 1371 } 1269 1372 1270 - sourceBranchesClient, err := knotclient.NewUnsignedClient(repo.Knot, s.config.Core.Dev) 1271 - if err != nil { 1272 - log.Printf("failed to create unsigned client for %s", repo.Knot) 1273 - s.pages.Error503(w) 1274 - return 1373 + sourceScheme := "http" 1374 + if !s.config.Core.Dev { 1375 + sourceScheme = "https" 1376 + } 1377 + sourceHost := fmt.Sprintf("%s://%s", sourceScheme, repo.Knot) 1378 + sourceXrpcc := &indigoxrpc.Client{ 1379 + Host: sourceHost, 1275 1380 } 1276 1381 1277 - sourceResult, err := sourceBranchesClient.Branches(user.Did, repo.Name) 1382 + sourceRepo := fmt.Sprintf("%s/%s", forkOwnerDid, repo.Name) 1383 + sourceXrpcBytes, err := tangled.RepoBranches(r.Context(), sourceXrpcc, "", 0, sourceRepo) 1278 1384 if err != nil { 1279 - log.Println("failed to reach knotserver for source branches", err) 1385 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1386 + log.Println("failed to call XRPC repo.branches for source", xrpcerr) 1387 + s.pages.Error503(w) 1388 + return 1389 + } 1390 + log.Println("failed to fetch source branches", err) 1280 1391 return 1281 1392 } 1282 1393 1283 - targetBranchesClient, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 1284 - if err != nil { 1285 - log.Printf("failed to create unsigned client for target knot %s", f.Knot) 1394 + // Decode source branches 1395 + var sourceBranches types.RepoBranchesResponse 1396 + if err := json.Unmarshal(sourceXrpcBytes, &sourceBranches); err != nil { 1397 + log.Println("failed to decode source branches XRPC response", err) 1286 1398 s.pages.Error503(w) 1287 1399 return 1288 1400 } 1289 1401 1290 - targetResult, err := targetBranchesClient.Branches(f.OwnerDid(), f.RepoName) 1402 + targetScheme := "http" 1403 + if !s.config.Core.Dev { 1404 + targetScheme = "https" 1405 + } 1406 + targetHost := fmt.Sprintf("%s://%s", targetScheme, f.Knot) 1407 + targetXrpcc := &indigoxrpc.Client{ 1408 + Host: targetHost, 1409 + } 1410 + 1411 + targetRepo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 1412 + targetXrpcBytes, err := tangled.RepoBranches(r.Context(), targetXrpcc, "", 0, targetRepo) 1291 1413 if err != nil { 1292 - log.Println("failed to reach knotserver for target branches", err) 1414 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1415 + log.Println("failed to call XRPC repo.branches for target", xrpcerr) 1416 + s.pages.Error503(w) 1417 + return 1418 + } 1419 + log.Println("failed to fetch target branches", err) 1420 + return 1421 + } 1422 + 1423 + // Decode target branches 1424 + var targetBranches types.RepoBranchesResponse 1425 + if err := json.Unmarshal(targetXrpcBytes, &targetBranches); err != nil { 1426 + log.Println("failed to decode target branches XRPC response", err) 1427 + s.pages.Error503(w) 1293 1428 return 1294 1429 } 1295 1430 1296 - sourceBranches := sourceResult.Branches 1297 - sort.Slice(sourceBranches, func(i int, j int) bool { 1298 - return sourceBranches[i].Commit.Committer.When.After(sourceBranches[j].Commit.Committer.When) 1431 + sort.Slice(sourceBranches.Branches, func(i int, j int) bool { 1432 + return sourceBranches.Branches[i].Commit.Committer.When.After(sourceBranches.Branches[j].Commit.Committer.When) 1299 1433 }) 1300 1434 1301 1435 s.pages.PullCompareForkBranchesFragment(w, pages.PullCompareForkBranchesParams{ 1302 1436 RepoInfo: f.RepoInfo(user), 1303 - SourceBranches: sourceBranches, 1304 - TargetBranches: targetResult.Branches, 1437 + SourceBranches: sourceBranches.Branches, 1438 + TargetBranches: targetBranches.Branches, 1305 1439 }) 1306 1440 } 1307 1441 ··· 1396 1530 return 1397 1531 } 1398 1532 1399 - ksClient, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 1400 - if err != nil { 1401 - log.Printf("failed to create client for %s: %s", f.Knot, err) 1402 - s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1403 - return 1533 + scheme := "http" 1534 + if !s.config.Core.Dev { 1535 + scheme = "https" 1536 + } 1537 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 1538 + xrpcc := &indigoxrpc.Client{ 1539 + Host: host, 1404 1540 } 1405 1541 1406 - comparison, err := ksClient.Compare(f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.PullSource.Branch) 1542 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 1543 + xrpcBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, pull.TargetBranch, pull.PullSource.Branch) 1407 1544 if err != nil { 1545 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1546 + log.Println("failed to call XRPC repo.compare", xrpcerr) 1547 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1548 + return 1549 + } 1408 1550 log.Printf("compare request failed: %s", err) 1409 1551 s.pages.Notice(w, "resubmit-error", err.Error()) 1410 1552 return 1411 1553 } 1412 1554 1555 + var comparison types.RepoFormatPatchResponse 1556 + if err := json.Unmarshal(xrpcBytes, &comparison); err != nil { 1557 + log.Println("failed to decode XRPC compare response", err) 1558 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1559 + return 1560 + } 1561 + 1413 1562 sourceRev := comparison.Rev2 1414 1563 patch := comparison.Patch 1415 1564 ··· 1446 1595 } 1447 1596 1448 1597 // extract patch by performing compare 1449 - ksClient, err := knotclient.NewUnsignedClient(forkRepo.Knot, s.config.Core.Dev) 1598 + forkScheme := "http" 1599 + if !s.config.Core.Dev { 1600 + forkScheme = "https" 1601 + } 1602 + forkHost := fmt.Sprintf("%s://%s", forkScheme, forkRepo.Knot) 1603 + forkRepoId := fmt.Sprintf("%s/%s", forkRepo.Did, forkRepo.Name) 1604 + forkXrpcBytes, err := tangled.RepoCompare(r.Context(), &indigoxrpc.Client{Host: forkHost}, forkRepoId, pull.TargetBranch, pull.PullSource.Branch) 1450 1605 if err != nil { 1451 - log.Printf("failed to create client for %s: %s", forkRepo.Knot, err) 1606 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1607 + log.Println("failed to call XRPC repo.compare for fork", xrpcerr) 1608 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1609 + return 1610 + } 1611 + log.Printf("failed to compare branches: %s", err) 1452 1612 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1453 1613 return 1454 1614 } 1455 1615 1456 - secret, err := db.GetRegistrationKey(s.db, forkRepo.Knot) 1457 - if err != nil { 1458 - log.Printf("failed to get registration key for %s: %s", forkRepo.Knot, err) 1616 + var forkComparison types.RepoFormatPatchResponse 1617 + if err := json.Unmarshal(forkXrpcBytes, &forkComparison); err != nil { 1618 + log.Println("failed to decode XRPC compare response for fork", err) 1459 1619 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1460 1620 return 1461 1621 } 1462 1622 1463 1623 // update the hidden tracking branch to latest 1464 - signedClient, err := knotclient.NewSignedClient(forkRepo.Knot, secret, s.config.Core.Dev) 1624 + client, err := s.oauth.ServiceClient( 1625 + r, 1626 + oauth.WithService(forkRepo.Knot), 1627 + oauth.WithLxm(tangled.RepoHiddenRefNSID), 1628 + oauth.WithDev(s.config.Core.Dev), 1629 + ) 1465 1630 if err != nil { 1466 - log.Printf("failed to create signed client for %s: %s", forkRepo.Knot, err) 1467 - s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1631 + log.Printf("failed to connect to knot server: %v", err) 1468 1632 return 1469 1633 } 1470 1634 1471 - resp, err := signedClient.NewHiddenRef(forkRepo.Did, forkRepo.Name, pull.PullSource.Branch, pull.TargetBranch) 1472 - if err != nil || resp.StatusCode != http.StatusNoContent { 1473 - log.Printf("failed to update tracking branch: %s", err) 1474 - s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1635 + resp, err := tangled.RepoHiddenRef( 1636 + r.Context(), 1637 + client, 1638 + &tangled.RepoHiddenRef_Input{ 1639 + ForkRef: pull.PullSource.Branch, 1640 + RemoteRef: pull.TargetBranch, 1641 + Repo: forkRepo.RepoAt().String(), 1642 + }, 1643 + ) 1644 + if err := xrpcclient.HandleXrpcErr(err); err != nil { 1645 + s.pages.Notice(w, "resubmit-error", err.Error()) 1475 1646 return 1476 1647 } 1477 - 1478 - hiddenRef := fmt.Sprintf("hidden/%s/%s", pull.PullSource.Branch, pull.TargetBranch) 1479 - comparison, err := ksClient.Compare(forkRepo.Did, forkRepo.Name, hiddenRef, pull.PullSource.Branch) 1480 - if err != nil { 1481 - log.Printf("failed to compare branches: %s", err) 1482 - s.pages.Notice(w, "resubmit-error", err.Error()) 1648 + if !resp.Success { 1649 + log.Println("Failed to update tracking ref.", "err", resp.Error) 1650 + s.pages.Notice(w, "resubmit-error", "Failed to update tracking ref.") 1483 1651 return 1484 1652 } 1653 + 1654 + // Use the fork comparison we already made 1655 + comparison := forkComparison 1485 1656 1486 1657 sourceRev := comparison.Rev2 1487 1658 patch := comparison.Patch ··· 1566 1737 if pull.IsBranchBased() { 1567 1738 recordPullSource = &tangled.RepoPull_Source{ 1568 1739 Branch: pull.PullSource.Branch, 1740 + Sha: sourceRev, 1569 1741 } 1570 1742 } 1571 1743 if pull.IsForkBased() { ··· 1573 1745 recordPullSource = &tangled.RepoPull_Source{ 1574 1746 Branch: pull.PullSource.Branch, 1575 1747 Repo: &repoAt, 1748 + Sha: sourceRev, 1576 1749 } 1577 1750 } 1578 1751 ··· 1583 1756 SwapRecord: ex.Cid, 1584 1757 Record: &lexutil.LexiconTypeDecoder{ 1585 1758 Val: &tangled.RepoPull{ 1586 - Title: pull.Title, 1587 - PullId: int64(pull.PullId), 1588 - TargetRepo: string(f.RepoAt), 1589 - TargetBranch: pull.TargetBranch, 1590 - Patch: patch, // new patch 1591 - Source: recordPullSource, 1759 + Title: pull.Title, 1760 + Target: &tangled.RepoPull_Target{ 1761 + Repo: string(f.RepoAt()), 1762 + Branch: pull.TargetBranch, 1763 + }, 1764 + Patch: patch, // new patch 1765 + Source: recordPullSource, 1592 1766 }, 1593 1767 }, 1594 1768 }) ··· 1605 1779 } 1606 1780 1607 1781 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 1608 - return 1609 1782 } 1610 1783 1611 1784 func (s *Pulls) resubmitStackedPullHelper( ··· 1702 1875 1703 1876 // deleted pulls are marked as deleted in the DB 1704 1877 for _, p := range deletions { 1878 + // do not do delete already merged PRs 1879 + if p.State == db.PullMerged { 1880 + continue 1881 + } 1882 + 1705 1883 err := db.DeletePull(tx, p.RepoAt, p.PullId) 1706 1884 if err != nil { 1707 1885 log.Println("failed to delete pull", err, p.PullId) ··· 1742 1920 op, _ := origById[id] 1743 1921 np, _ := newById[id] 1744 1922 1923 + // do not update already merged PRs 1924 + if op.State == db.PullMerged { 1925 + continue 1926 + } 1927 + 1745 1928 submission := np.Submissions[np.LastRoundNumber()] 1746 1929 1747 1930 // resubmit the old pull ··· 1849 2032 } 1850 2033 1851 2034 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 1852 - return 1853 2035 } 1854 2036 1855 2037 func (s *Pulls) MergePull(w http.ResponseWriter, r *http.Request) { ··· 1887 2069 1888 2070 patch := pullsToMerge.CombinedPatch() 1889 2071 1890 - secret, err := db.GetRegistrationKey(s.db, f.Knot) 1891 - if err != nil { 1892 - log.Printf("no registration key found for domain %s: %s\n", f.Knot, err) 1893 - s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1894 - return 1895 - } 1896 - 1897 2072 ident, err := s.idResolver.ResolveIdent(r.Context(), pull.OwnerDid) 1898 2073 if err != nil { 1899 2074 log.Printf("resolving identity: %s", err) ··· 1906 2081 log.Printf("failed to get primary email: %s", err) 1907 2082 } 1908 2083 1909 - ksClient, err := knotclient.NewSignedClient(f.Knot, secret, s.config.Core.Dev) 1910 - if err != nil { 1911 - log.Printf("failed to create signed client for %s: %s", f.Knot, err) 1912 - s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1913 - return 2084 + authorName := ident.Handle.String() 2085 + mergeInput := &tangled.RepoMerge_Input{ 2086 + Did: f.OwnerDid(), 2087 + Name: f.Name, 2088 + Branch: pull.TargetBranch, 2089 + Patch: patch, 2090 + CommitMessage: &pull.Title, 2091 + AuthorName: &authorName, 2092 + } 2093 + 2094 + if pull.Body != "" { 2095 + mergeInput.CommitBody = &pull.Body 1914 2096 } 1915 2097 1916 - // Merge the pull request 1917 - resp, err := ksClient.Merge([]byte(patch), f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.Title, pull.Body, ident.Handle.String(), email.Address) 2098 + if email.Address != "" { 2099 + mergeInput.AuthorEmail = &email.Address 2100 + } 2101 + 2102 + client, err := s.oauth.ServiceClient( 2103 + r, 2104 + oauth.WithService(f.Knot), 2105 + oauth.WithLxm(tangled.RepoMergeNSID), 2106 + oauth.WithDev(s.config.Core.Dev), 2107 + ) 1918 2108 if err != nil { 1919 - log.Printf("failed to merge pull request: %s", err) 2109 + log.Printf("failed to connect to knot server: %v", err) 1920 2110 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1921 2111 return 1922 2112 } 1923 2113 1924 - if resp.StatusCode != http.StatusOK { 1925 - log.Printf("knotserver returned non-OK status code for merge: %d", resp.StatusCode) 1926 - s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 2114 + err = tangled.RepoMerge(r.Context(), client, mergeInput) 2115 + if err := xrpcclient.HandleXrpcErr(err); err != nil { 2116 + s.pages.Notice(w, "pull-merge-error", err.Error()) 1927 2117 return 1928 2118 } 1929 2119 ··· 1936 2126 defer tx.Rollback() 1937 2127 1938 2128 for _, p := range pullsToMerge { 1939 - err := db.MergePull(tx, f.RepoAt, p.PullId) 2129 + err := db.MergePull(tx, f.RepoAt(), p.PullId) 1940 2130 if err != nil { 1941 2131 log.Printf("failed to update pull request status in database: %s", err) 1942 2132 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") ··· 1952 2142 return 1953 2143 } 1954 2144 1955 - s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.RepoName, pull.PullId)) 2145 + s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.Name, pull.PullId)) 1956 2146 } 1957 2147 1958 2148 func (s *Pulls) ClosePull(w http.ResponseWriter, r *http.Request) { ··· 1973 2163 1974 2164 // auth filter: only owner or collaborators can close 1975 2165 roles := f.RolesInRepo(user) 2166 + isOwner := roles.IsOwner() 1976 2167 isCollaborator := roles.IsCollaborator() 1977 2168 isPullAuthor := user.Did == pull.OwnerDid 1978 - isCloseAllowed := isCollaborator || isPullAuthor 2169 + isCloseAllowed := isOwner || isCollaborator || isPullAuthor 1979 2170 if !isCloseAllowed { 1980 2171 log.Println("failed to close pull") 1981 2172 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.") ··· 2003 2194 2004 2195 for _, p := range pullsToClose { 2005 2196 // Close the pull in the database 2006 - err = db.ClosePull(tx, f.RepoAt, p.PullId) 2197 + err = db.ClosePull(tx, f.RepoAt(), p.PullId) 2007 2198 if err != nil { 2008 2199 log.Println("failed to close pull", err) 2009 2200 s.pages.Notice(w, "pull-close", "Failed to close pull.") ··· 2019 2210 } 2020 2211 2021 2212 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 2022 - return 2023 2213 } 2024 2214 2025 2215 func (s *Pulls) ReopenPull(w http.ResponseWriter, r *http.Request) { ··· 2041 2231 2042 2232 // auth filter: only owner or collaborators can close 2043 2233 roles := f.RolesInRepo(user) 2234 + isOwner := roles.IsOwner() 2044 2235 isCollaborator := roles.IsCollaborator() 2045 2236 isPullAuthor := user.Did == pull.OwnerDid 2046 - isCloseAllowed := isCollaborator || isPullAuthor 2237 + isCloseAllowed := isOwner || isCollaborator || isPullAuthor 2047 2238 if !isCloseAllowed { 2048 2239 log.Println("failed to close pull") 2049 2240 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.") ··· 2071 2262 2072 2263 for _, p := range pullsToReopen { 2073 2264 // Close the pull in the database 2074 - err = db.ReopenPull(tx, f.RepoAt, p.PullId) 2265 + err = db.ReopenPull(tx, f.RepoAt(), p.PullId) 2075 2266 if err != nil { 2076 2267 log.Println("failed to close pull", err) 2077 2268 s.pages.Notice(w, "pull-close", "Failed to close pull.") ··· 2087 2278 } 2088 2279 2089 2280 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 2090 - return 2091 2281 } 2092 2282 2093 2283 func newStack(f *reporesolver.ResolvedRepo, user *oauth.User, targetBranch, patch string, pullSource *db.PullSource, stackId string) (db.Stack, error) { ··· 2113 2303 2114 2304 title := fp.Title 2115 2305 body := fp.Body 2116 - rkey := appview.TID() 2306 + rkey := tid.TID() 2117 2307 2118 2308 initialSubmission := db.PullSubmission{ 2119 2309 Patch: fp.Raw, ··· 2124 2314 Body: body, 2125 2315 TargetBranch: targetBranch, 2126 2316 OwnerDid: user.Did, 2127 - RepoAt: f.RepoAt, 2317 + RepoAt: f.RepoAt(), 2128 2318 Rkey: rkey, 2129 2319 Submissions: []*db.PullSubmission{ 2130 2320 &initialSubmission,
+2
appview/pulls/router.go
··· 44 44 r.Get("/", s.ResubmitPull) 45 45 r.Post("/", s.ResubmitPull) 46 46 }) 47 + // permissions here require us to know pull author 48 + // it is handled within the route 47 49 r.Post("/close", s.ClosePull) 48 50 r.Post("/reopen", s.ReopenPull) 49 51 // collaborators only
+33 -15
appview/repo/artifact.go
··· 1 1 package repo 2 2 3 3 import ( 4 + "context" 5 + "encoding/json" 4 6 "fmt" 5 7 "log" 6 8 "net/http" ··· 9 11 10 12 comatproto "github.com/bluesky-social/indigo/api/atproto" 11 13 lexutil "github.com/bluesky-social/indigo/lex/util" 14 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 12 15 "github.com/dustin/go-humanize" 13 16 "github.com/go-chi/chi/v5" 14 17 "github.com/go-git/go-git/v5/plumbing" 15 18 "github.com/ipfs/go-cid" 16 19 "tangled.sh/tangled.sh/core/api/tangled" 17 - "tangled.sh/tangled.sh/core/appview" 18 20 "tangled.sh/tangled.sh/core/appview/db" 19 21 "tangled.sh/tangled.sh/core/appview/pages" 20 22 "tangled.sh/tangled.sh/core/appview/reporesolver" 21 - "tangled.sh/tangled.sh/core/knotclient" 23 + "tangled.sh/tangled.sh/core/appview/xrpcclient" 24 + "tangled.sh/tangled.sh/core/tid" 22 25 "tangled.sh/tangled.sh/core/types" 23 26 ) 24 27 ··· 33 36 return 34 37 } 35 38 36 - tag, err := rp.resolveTag(f, tagParam) 39 + tag, err := rp.resolveTag(r.Context(), f, tagParam) 37 40 if err != nil { 38 41 log.Println("failed to resolve tag", err) 39 42 rp.pages.Notice(w, "upload", "failed to upload artifact, error in tag resolution") ··· 64 67 65 68 log.Println("uploaded blob", humanize.Bytes(uint64(uploadBlobResp.Blob.Size)), uploadBlobResp.Blob.Ref.String()) 66 69 67 - rkey := appview.TID() 70 + rkey := tid.TID() 68 71 createdAt := time.Now() 69 72 70 73 putRecordResp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ ··· 76 79 Artifact: uploadBlobResp.Blob, 77 80 CreatedAt: createdAt.Format(time.RFC3339), 78 81 Name: handler.Filename, 79 - Repo: f.RepoAt.String(), 82 + Repo: f.RepoAt().String(), 80 83 Tag: tag.Tag.Hash[:], 81 84 }, 82 85 }, ··· 100 103 artifact := db.Artifact{ 101 104 Did: user.Did, 102 105 Rkey: rkey, 103 - RepoAt: f.RepoAt, 106 + RepoAt: f.RepoAt(), 104 107 Tag: tag.Tag.Hash, 105 108 CreatedAt: createdAt, 106 109 BlobCid: cid.Cid(uploadBlobResp.Blob.Ref), ··· 140 143 return 141 144 } 142 145 143 - tag, err := rp.resolveTag(f, tagParam) 146 + tag, err := rp.resolveTag(r.Context(), f, tagParam) 144 147 if err != nil { 145 148 log.Println("failed to resolve tag", err) 146 149 rp.pages.Notice(w, "upload", "failed to upload artifact, error in tag resolution") ··· 155 158 156 159 artifacts, err := db.GetArtifact( 157 160 rp.db, 158 - db.FilterEq("repo_at", f.RepoAt), 161 + db.FilterEq("repo_at", f.RepoAt()), 159 162 db.FilterEq("tag", tag.Tag.Hash[:]), 160 163 db.FilterEq("name", filename), 161 164 ) ··· 197 200 198 201 artifacts, err := db.GetArtifact( 199 202 rp.db, 200 - db.FilterEq("repo_at", f.RepoAt), 203 + db.FilterEq("repo_at", f.RepoAt()), 201 204 db.FilterEq("tag", tag[:]), 202 205 db.FilterEq("name", filename), 203 206 ) ··· 239 242 defer tx.Rollback() 240 243 241 244 err = db.DeleteArtifact(tx, 242 - db.FilterEq("repo_at", f.RepoAt), 245 + db.FilterEq("repo_at", f.RepoAt()), 243 246 db.FilterEq("tag", artifact.Tag[:]), 244 247 db.FilterEq("name", filename), 245 248 ) ··· 259 262 w.Write([]byte{}) 260 263 } 261 264 262 - func (rp *Repo) resolveTag(f *reporesolver.ResolvedRepo, tagParam string) (*types.TagReference, error) { 265 + func (rp *Repo) resolveTag(ctx context.Context, f *reporesolver.ResolvedRepo, tagParam string) (*types.TagReference, error) { 263 266 tagParam, err := url.QueryUnescape(tagParam) 264 267 if err != nil { 265 268 return nil, err 266 269 } 267 270 268 - us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 269 - if err != nil { 270 - return nil, err 271 + scheme := "http" 272 + if !rp.config.Core.Dev { 273 + scheme = "https" 274 + } 275 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 276 + xrpcc := &indigoxrpc.Client{ 277 + Host: host, 271 278 } 272 279 273 - result, err := us.Tags(f.OwnerDid(), f.RepoName) 280 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 281 + xrpcBytes, err := tangled.RepoTags(ctx, xrpcc, "", 0, repo) 274 282 if err != nil { 283 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 284 + log.Println("failed to call XRPC repo.tags", xrpcerr) 285 + return nil, xrpcerr 286 + } 275 287 log.Println("failed to reach knotserver", err) 288 + return nil, err 289 + } 290 + 291 + var result types.RepoTagsResponse 292 + if err := json.Unmarshal(xrpcBytes, &result); err != nil { 293 + log.Println("failed to decode XRPC tags response", err) 276 294 return nil, err 277 295 } 278 296
+170
appview/repo/feed.go
··· 1 + package repo 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "log" 7 + "net/http" 8 + "slices" 9 + "time" 10 + 11 + "tangled.sh/tangled.sh/core/appview/db" 12 + "tangled.sh/tangled.sh/core/appview/pagination" 13 + "tangled.sh/tangled.sh/core/appview/reporesolver" 14 + 15 + "github.com/bluesky-social/indigo/atproto/syntax" 16 + "github.com/gorilla/feeds" 17 + ) 18 + 19 + func (rp *Repo) getRepoFeed(ctx context.Context, f *reporesolver.ResolvedRepo) (*feeds.Feed, error) { 20 + const feedLimitPerType = 100 21 + 22 + pulls, err := db.GetPullsWithLimit(rp.db, feedLimitPerType, db.FilterEq("repo_at", f.RepoAt())) 23 + if err != nil { 24 + return nil, err 25 + } 26 + 27 + issues, err := db.GetIssuesPaginated( 28 + rp.db, 29 + pagination.Page{Limit: feedLimitPerType}, 30 + db.FilterEq("repo_at", f.RepoAt()), 31 + ) 32 + if err != nil { 33 + return nil, err 34 + } 35 + 36 + feed := &feeds.Feed{ 37 + Title: fmt.Sprintf("activity feed for %s", f.OwnerSlashRepo()), 38 + Link: &feeds.Link{Href: fmt.Sprintf("%s/%s", rp.config.Core.AppviewHost, f.OwnerSlashRepo()), Type: "text/html", Rel: "alternate"}, 39 + Items: make([]*feeds.Item, 0), 40 + Updated: time.UnixMilli(0), 41 + } 42 + 43 + for _, pull := range pulls { 44 + items, err := rp.createPullItems(ctx, pull, f) 45 + if err != nil { 46 + return nil, err 47 + } 48 + feed.Items = append(feed.Items, items...) 49 + } 50 + 51 + for _, issue := range issues { 52 + item, err := rp.createIssueItem(ctx, issue, f) 53 + if err != nil { 54 + return nil, err 55 + } 56 + feed.Items = append(feed.Items, item) 57 + } 58 + 59 + slices.SortFunc(feed.Items, func(a, b *feeds.Item) int { 60 + if a.Created.After(b.Created) { 61 + return -1 62 + } 63 + return 1 64 + }) 65 + 66 + if len(feed.Items) > 0 { 67 + feed.Updated = feed.Items[0].Created 68 + } 69 + 70 + return feed, nil 71 + } 72 + 73 + func (rp *Repo) createPullItems(ctx context.Context, pull *db.Pull, f *reporesolver.ResolvedRepo) ([]*feeds.Item, error) { 74 + owner, err := rp.idResolver.ResolveIdent(ctx, pull.OwnerDid) 75 + if err != nil { 76 + return nil, err 77 + } 78 + 79 + var items []*feeds.Item 80 + 81 + state := rp.getPullState(pull) 82 + description := rp.buildPullDescription(owner.Handle, state, pull, f.OwnerSlashRepo()) 83 + 84 + mainItem := &feeds.Item{ 85 + Title: fmt.Sprintf("[PR #%d] %s", pull.PullId, pull.Title), 86 + Description: description, 87 + Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/pulls/%d", rp.config.Core.AppviewHost, f.OwnerSlashRepo(), pull.PullId)}, 88 + Created: pull.Created, 89 + Author: &feeds.Author{Name: fmt.Sprintf("@%s", owner.Handle)}, 90 + } 91 + items = append(items, mainItem) 92 + 93 + for _, round := range pull.Submissions { 94 + if round == nil || round.RoundNumber == 0 { 95 + continue 96 + } 97 + 98 + roundItem := &feeds.Item{ 99 + Title: fmt.Sprintf("[PR #%d] %s (round #%d)", pull.PullId, pull.Title, round.RoundNumber), 100 + Description: fmt.Sprintf("@%s submitted changes (at round #%d) on PR #%d in %s", owner.Handle, round.RoundNumber, pull.PullId, f.OwnerSlashRepo()), 101 + Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/pulls/%d/round/%d/", rp.config.Core.AppviewHost, f.OwnerSlashRepo(), pull.PullId, round.RoundNumber)}, 102 + Created: round.Created, 103 + Author: &feeds.Author{Name: fmt.Sprintf("@%s", owner.Handle)}, 104 + } 105 + items = append(items, roundItem) 106 + } 107 + 108 + return items, nil 109 + } 110 + 111 + func (rp *Repo) createIssueItem(ctx context.Context, issue db.Issue, f *reporesolver.ResolvedRepo) (*feeds.Item, error) { 112 + owner, err := rp.idResolver.ResolveIdent(ctx, issue.Did) 113 + if err != nil { 114 + return nil, err 115 + } 116 + 117 + state := "closed" 118 + if issue.Open { 119 + state = "opened" 120 + } 121 + 122 + return &feeds.Item{ 123 + Title: fmt.Sprintf("[Issue #%d] %s", issue.IssueId, issue.Title), 124 + Description: fmt.Sprintf("@%s %s issue #%d in %s", owner.Handle, state, issue.IssueId, f.OwnerSlashRepo()), 125 + Link: &feeds.Link{Href: fmt.Sprintf("%s/%s/issues/%d", rp.config.Core.AppviewHost, f.OwnerSlashRepo(), issue.IssueId)}, 126 + Created: issue.Created, 127 + Author: &feeds.Author{Name: fmt.Sprintf("@%s", owner.Handle)}, 128 + }, nil 129 + } 130 + 131 + func (rp *Repo) getPullState(pull *db.Pull) string { 132 + if pull.State == db.PullOpen { 133 + return "opened" 134 + } 135 + return pull.State.String() 136 + } 137 + 138 + func (rp *Repo) buildPullDescription(handle syntax.Handle, state string, pull *db.Pull, repoName string) string { 139 + base := fmt.Sprintf("@%s %s pull request #%d", handle, state, pull.PullId) 140 + 141 + if pull.State == db.PullMerged { 142 + return fmt.Sprintf("%s (on round #%d) in %s", base, pull.LastRoundNumber(), repoName) 143 + } 144 + 145 + return fmt.Sprintf("%s in %s", base, repoName) 146 + } 147 + 148 + func (rp *Repo) RepoAtomFeed(w http.ResponseWriter, r *http.Request) { 149 + f, err := rp.repoResolver.Resolve(r) 150 + if err != nil { 151 + log.Println("failed to fully resolve repo:", err) 152 + return 153 + } 154 + 155 + feed, err := rp.getRepoFeed(r.Context(), f) 156 + if err != nil { 157 + log.Println("failed to get repo feed:", err) 158 + rp.pages.Error500(w) 159 + return 160 + } 161 + 162 + atom, err := feed.ToAtom() 163 + if err != nil { 164 + rp.pages.Error500(w) 165 + return 166 + } 167 + 168 + w.Header().Set("content-type", "application/atom+xml") 169 + w.Write([]byte(atom)) 170 + }
+261 -88
appview/repo/index.go
··· 1 1 package repo 2 2 3 3 import ( 4 - "encoding/json" 4 + "errors" 5 5 "fmt" 6 6 "log" 7 7 "net/http" 8 + "net/url" 8 9 "slices" 10 + "sort" 9 11 "strings" 12 + "sync" 13 + "time" 10 14 15 + "context" 16 + "encoding/json" 17 + 18 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 19 + "github.com/go-git/go-git/v5/plumbing" 20 + "tangled.sh/tangled.sh/core/api/tangled" 11 21 "tangled.sh/tangled.sh/core/appview/commitverify" 12 22 "tangled.sh/tangled.sh/core/appview/db" 13 - "tangled.sh/tangled.sh/core/appview/oauth" 14 23 "tangled.sh/tangled.sh/core/appview/pages" 15 - "tangled.sh/tangled.sh/core/appview/pages/repoinfo" 24 + "tangled.sh/tangled.sh/core/appview/pages/markup" 16 25 "tangled.sh/tangled.sh/core/appview/reporesolver" 17 - "tangled.sh/tangled.sh/core/knotclient" 26 + "tangled.sh/tangled.sh/core/appview/xrpcclient" 18 27 "tangled.sh/tangled.sh/core/types" 19 28 20 29 "github.com/go-chi/chi/v5" 30 + "github.com/go-enry/go-enry/v2" 21 31 ) 22 32 23 33 func (rp *Repo) RepoIndex(w http.ResponseWriter, r *http.Request) { 24 34 ref := chi.URLParam(r, "ref") 35 + ref, _ = url.PathUnescape(ref) 36 + 25 37 f, err := rp.repoResolver.Resolve(r) 26 38 if err != nil { 27 39 log.Println("failed to fully resolve repo", err) 28 40 return 29 41 } 30 42 31 - us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 32 - if err != nil { 33 - log.Printf("failed to create unsigned client for %s", f.Knot) 34 - rp.pages.Error503(w) 35 - return 43 + scheme := "http" 44 + if !rp.config.Core.Dev { 45 + scheme = "https" 46 + } 47 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 48 + xrpcc := &indigoxrpc.Client{ 49 + Host: host, 36 50 } 37 51 38 - result, err := us.Index(f.OwnerDid(), f.RepoName, ref) 39 - if err != nil { 52 + user := rp.oauth.GetUser(r) 53 + repoInfo := f.RepoInfo(user) 54 + 55 + // Build index response from multiple XRPC calls 56 + result, err := rp.buildIndexResponse(r.Context(), xrpcc, f, ref) 57 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 58 + if errors.Is(xrpcerr, xrpcclient.ErrXrpcUnsupported) { 59 + log.Println("failed to call XRPC repo.index", err) 60 + rp.pages.RepoIndexPage(w, pages.RepoIndexParams{ 61 + LoggedInUser: user, 62 + NeedsKnotUpgrade: true, 63 + RepoInfo: repoInfo, 64 + }) 65 + return 66 + } 67 + 40 68 rp.pages.Error503(w) 41 - log.Println("failed to reach knotserver", err) 69 + log.Println("failed to build index response", err) 42 70 return 43 71 } 44 72 ··· 55 83 hash := branch.Hash 56 84 tagMap[hash] = append(tagMap[hash], branch.Name) 57 85 } 86 + 87 + sortFiles(result.Files) 58 88 59 89 slices.SortFunc(result.Branches, func(a, b types.Branch) int { 60 90 if a.Name == result.Ref { ··· 97 127 log.Println(err) 98 128 } 99 129 100 - user := rp.oauth.GetUser(r) 101 - repoInfo := f.RepoInfo(user) 102 - 103 - secret, err := db.GetRegistrationKey(rp.db, f.Knot) 104 - if err != nil { 105 - log.Printf("failed to get registration key for %s: %s", f.Knot, err) 106 - rp.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 107 - } 108 - 109 - signedClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev) 110 - if err != nil { 111 - log.Printf("failed to create signed client for %s: %s", f.Knot, err) 112 - return 113 - } 114 - 115 - var forkInfo *types.ForkInfo 116 - if user != nil && (repoInfo.Roles.IsOwner() || repoInfo.Roles.IsCollaborator()) { 117 - forkInfo, err = getForkInfo(repoInfo, rp, f, user, signedClient) 118 - if err != nil { 119 - log.Printf("Failed to fetch fork information: %v", err) 120 - return 121 - } 122 - } 123 - 124 - repoLanguages, err := signedClient.RepoLanguages(f.OwnerDid(), f.RepoName, ref) 130 + // TODO: a bit dirty 131 + languageInfo, err := rp.getLanguageInfo(r.Context(), f, xrpcc, result.Ref, ref == "") 125 132 if err != nil { 126 133 log.Printf("failed to compute language percentages: %s", err) 127 134 // non-fatal ··· 131 138 for _, c := range commitsTrunc { 132 139 shas = append(shas, c.Hash.String()) 133 140 } 134 - pipelines, err := rp.getPipelineStatuses(repoInfo, shas) 141 + pipelines, err := getPipelineStatuses(rp.db, repoInfo, shas) 135 142 if err != nil { 136 143 log.Printf("failed to fetch pipeline statuses: %s", err) 137 144 // non-fatal 138 145 } 139 146 140 147 rp.pages.RepoIndexPage(w, pages.RepoIndexParams{ 141 - LoggedInUser: user, 142 - RepoInfo: repoInfo, 143 - TagMap: tagMap, 144 - RepoIndexResponse: *result, 145 - CommitsTrunc: commitsTrunc, 146 - TagsTrunc: tagsTrunc, 147 - ForkInfo: forkInfo, 148 + LoggedInUser: user, 149 + RepoInfo: repoInfo, 150 + TagMap: tagMap, 151 + RepoIndexResponse: *result, 152 + CommitsTrunc: commitsTrunc, 153 + TagsTrunc: tagsTrunc, 154 + // ForkInfo: forkInfo, // TODO: reinstate this after xrpc properly lands 148 155 BranchesTrunc: branchesTrunc, 149 156 EmailToDidOrHandle: emailToDidOrHandle(rp, emailToDidMap), 150 157 VerifiedCommits: vc, 151 - Languages: repoLanguages, 158 + Languages: languageInfo, 152 159 Pipelines: pipelines, 153 160 }) 154 - return 155 161 } 156 162 157 - func getForkInfo( 158 - repoInfo repoinfo.RepoInfo, 159 - rp *Repo, 163 + func (rp *Repo) getLanguageInfo( 164 + ctx context.Context, 160 165 f *reporesolver.ResolvedRepo, 161 - user *oauth.User, 162 - signedClient *knotclient.SignedClient, 163 - ) (*types.ForkInfo, error) { 164 - if user == nil { 165 - return nil, nil 166 + xrpcc *indigoxrpc.Client, 167 + currentRef string, 168 + isDefaultRef bool, 169 + ) ([]types.RepoLanguageDetails, error) { 170 + // first attempt to fetch from db 171 + langs, err := db.GetRepoLanguages( 172 + rp.db, 173 + db.FilterEq("repo_at", f.RepoAt()), 174 + db.FilterEq("ref", currentRef), 175 + ) 176 + 177 + if err != nil || langs == nil { 178 + // non-fatal, fetch langs from ks via XRPC 179 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 180 + ls, err := tangled.RepoLanguages(ctx, xrpcc, currentRef, repo) 181 + if err != nil { 182 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 183 + log.Println("failed to call XRPC repo.languages", xrpcerr) 184 + return nil, xrpcerr 185 + } 186 + return nil, err 187 + } 188 + 189 + if ls == nil || ls.Languages == nil { 190 + return nil, nil 191 + } 192 + 193 + for _, lang := range ls.Languages { 194 + langs = append(langs, db.RepoLanguage{ 195 + RepoAt: f.RepoAt(), 196 + Ref: currentRef, 197 + IsDefaultRef: isDefaultRef, 198 + Language: lang.Name, 199 + Bytes: lang.Size, 200 + }) 201 + } 202 + 203 + // update appview's cache 204 + err = db.InsertRepoLanguages(rp.db, langs) 205 + if err != nil { 206 + // non-fatal 207 + log.Println("failed to cache lang results", err) 208 + } 166 209 } 167 210 168 - forkInfo := types.ForkInfo{ 169 - IsFork: repoInfo.Source != nil, 170 - Status: types.UpToDate, 211 + var total int64 212 + for _, l := range langs { 213 + total += l.Bytes 171 214 } 172 215 173 - if !forkInfo.IsFork { 174 - forkInfo.IsFork = false 175 - return &forkInfo, nil 216 + var languageStats []types.RepoLanguageDetails 217 + for _, l := range langs { 218 + percentage := float32(l.Bytes) / float32(total) * 100 219 + color := enry.GetColor(l.Language) 220 + languageStats = append(languageStats, types.RepoLanguageDetails{ 221 + Name: l.Language, 222 + Percentage: percentage, 223 + Color: color, 224 + }) 176 225 } 177 226 178 - us, err := knotclient.NewUnsignedClient(repoInfo.Source.Knot, rp.config.Core.Dev) 227 + sort.Slice(languageStats, func(i, j int) bool { 228 + if languageStats[i].Name == enry.OtherLanguage { 229 + return false 230 + } 231 + if languageStats[j].Name == enry.OtherLanguage { 232 + return true 233 + } 234 + if languageStats[i].Percentage != languageStats[j].Percentage { 235 + return languageStats[i].Percentage > languageStats[j].Percentage 236 + } 237 + return languageStats[i].Name < languageStats[j].Name 238 + }) 239 + 240 + return languageStats, nil 241 + } 242 + 243 + // buildIndexResponse creates a RepoIndexResponse by combining multiple xrpc calls in parallel 244 + func (rp *Repo) buildIndexResponse(ctx context.Context, xrpcc *indigoxrpc.Client, f *reporesolver.ResolvedRepo, ref string) (*types.RepoIndexResponse, error) { 245 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 246 + 247 + // first get branches to determine the ref if not specified 248 + branchesBytes, err := tangled.RepoBranches(ctx, xrpcc, "", 0, repo) 179 249 if err != nil { 180 - log.Printf("failed to create unsigned client for %s", repoInfo.Source.Knot) 181 - return nil, err 250 + return nil, fmt.Errorf("failed to call repoBranches: %w", err) 182 251 } 183 252 184 - result, err := us.Branches(repoInfo.Source.Did, repoInfo.Source.Name) 185 - if err != nil { 186 - log.Println("failed to reach knotserver", err) 187 - return nil, err 253 + var branchesResp types.RepoBranchesResponse 254 + if err := json.Unmarshal(branchesBytes, &branchesResp); err != nil { 255 + return nil, fmt.Errorf("failed to unmarshal branches response: %w", err) 188 256 } 189 257 190 - if !slices.ContainsFunc(result.Branches, func(branch types.Branch) bool { 191 - return branch.Name == f.Ref 192 - }) { 193 - forkInfo.Status = types.MissingBranch 194 - return &forkInfo, nil 258 + // if no ref specified, use default branch or first available 259 + if ref == "" { 260 + for _, branch := range branchesResp.Branches { 261 + if branch.IsDefault { 262 + ref = branch.Name 263 + break 264 + } 265 + } 195 266 } 196 267 197 - newHiddenRefResp, err := signedClient.NewHiddenRef(user.Did, repoInfo.Name, f.Ref, f.Ref) 198 - if err != nil || newHiddenRefResp.StatusCode != http.StatusNoContent { 199 - log.Printf("failed to update tracking branch: %s", err) 200 - return nil, err 268 + // if ref is still empty, this means the default branch is not set 269 + if ref == "" { 270 + return &types.RepoIndexResponse{ 271 + IsEmpty: true, 272 + Branches: branchesResp.Branches, 273 + }, nil 201 274 } 202 275 203 - hiddenRef := fmt.Sprintf("hidden/%s/%s", f.Ref, f.Ref) 276 + // now run the remaining queries in parallel 277 + var wg sync.WaitGroup 278 + var errs error 279 + 280 + var ( 281 + tagsResp types.RepoTagsResponse 282 + treeResp *tangled.RepoTree_Output 283 + logResp types.RepoLogResponse 284 + readmeContent string 285 + readmeFileName string 286 + ) 287 + 288 + // tags 289 + wg.Add(1) 290 + go func() { 291 + defer wg.Done() 292 + tagsBytes, err := tangled.RepoTags(ctx, xrpcc, "", 0, repo) 293 + if err != nil { 294 + errs = errors.Join(errs, fmt.Errorf("failed to call repoTags: %w", err)) 295 + return 296 + } 297 + 298 + if err := json.Unmarshal(tagsBytes, &tagsResp); err != nil { 299 + errs = errors.Join(errs, fmt.Errorf("failed to unmarshal repoTags: %w", err)) 300 + } 301 + }() 204 302 205 - var status types.AncestorCheckResponse 206 - forkSyncableResp, err := signedClient.RepoForkAheadBehind(user.Did, string(f.RepoAt), repoInfo.Name, f.Ref, hiddenRef) 207 - if err != nil { 208 - log.Printf("failed to check if fork is ahead/behind: %s", err) 209 - return nil, err 303 + // tree/files 304 + wg.Add(1) 305 + go func() { 306 + defer wg.Done() 307 + resp, err := tangled.RepoTree(ctx, xrpcc, "", ref, repo) 308 + if err != nil { 309 + errs = errors.Join(errs, fmt.Errorf("failed to call repoTree: %w", err)) 310 + return 311 + } 312 + treeResp = resp 313 + }() 314 + 315 + // commits 316 + wg.Add(1) 317 + go func() { 318 + defer wg.Done() 319 + logBytes, err := tangled.RepoLog(ctx, xrpcc, "", 50, "", ref, repo) 320 + if err != nil { 321 + errs = errors.Join(errs, fmt.Errorf("failed to call repoLog: %w", err)) 322 + return 323 + } 324 + 325 + if err := json.Unmarshal(logBytes, &logResp); err != nil { 326 + errs = errors.Join(errs, fmt.Errorf("failed to unmarshal repoLog: %w", err)) 327 + } 328 + }() 329 + 330 + // readme content 331 + wg.Add(1) 332 + go func() { 333 + defer wg.Done() 334 + for _, filename := range markup.ReadmeFilenames { 335 + blobResp, err := tangled.RepoBlob(ctx, xrpcc, filename, false, ref, repo) 336 + if err != nil { 337 + continue 338 + } 339 + 340 + if blobResp == nil { 341 + continue 342 + } 343 + 344 + readmeContent = blobResp.Content 345 + readmeFileName = filename 346 + break 347 + } 348 + }() 349 + 350 + wg.Wait() 351 + 352 + if errs != nil { 353 + return nil, errs 210 354 } 211 355 212 - if err := json.NewDecoder(forkSyncableResp.Body).Decode(&status); err != nil { 213 - log.Printf("failed to decode fork status: %s", err) 214 - return nil, err 356 + var files []types.NiceTree 357 + if treeResp != nil && treeResp.Files != nil { 358 + for _, file := range treeResp.Files { 359 + niceFile := types.NiceTree{ 360 + IsFile: file.Is_file, 361 + IsSubtree: file.Is_subtree, 362 + Name: file.Name, 363 + Mode: file.Mode, 364 + Size: file.Size, 365 + } 366 + if file.Last_commit != nil { 367 + when, _ := time.Parse(time.RFC3339, file.Last_commit.When) 368 + niceFile.LastCommit = &types.LastCommitInfo{ 369 + Hash: plumbing.NewHash(file.Last_commit.Hash), 370 + Message: file.Last_commit.Message, 371 + When: when, 372 + } 373 + } 374 + files = append(files, niceFile) 375 + } 376 + } 377 + 378 + result := &types.RepoIndexResponse{ 379 + IsEmpty: false, 380 + Ref: ref, 381 + Readme: readmeContent, 382 + ReadmeFileName: readmeFileName, 383 + Commits: logResp.Commits, 384 + Description: logResp.Description, 385 + Files: files, 386 + Branches: branchesResp.Branches, 387 + Tags: tagsResp.Tags, 388 + TotalCommits: logResp.Total, 215 389 } 216 390 217 - forkInfo.Status = status.Status 218 - return &forkInfo, nil 391 + return result, nil 219 392 }
+977 -385
appview/repo/repo.go
··· 8 8 "fmt" 9 9 "io" 10 10 "log" 11 + "log/slog" 11 12 "net/http" 12 13 "net/url" 13 - "path" 14 + "path/filepath" 14 15 "slices" 15 - "sort" 16 16 "strconv" 17 17 "strings" 18 18 "time" 19 19 20 + comatproto "github.com/bluesky-social/indigo/api/atproto" 21 + lexutil "github.com/bluesky-social/indigo/lex/util" 22 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 20 23 "tangled.sh/tangled.sh/core/api/tangled" 21 - "tangled.sh/tangled.sh/core/appview" 22 24 "tangled.sh/tangled.sh/core/appview/commitverify" 23 25 "tangled.sh/tangled.sh/core/appview/config" 24 26 "tangled.sh/tangled.sh/core/appview/db" 25 - "tangled.sh/tangled.sh/core/appview/idresolver" 27 + "tangled.sh/tangled.sh/core/appview/notify" 26 28 "tangled.sh/tangled.sh/core/appview/oauth" 27 29 "tangled.sh/tangled.sh/core/appview/pages" 28 30 "tangled.sh/tangled.sh/core/appview/pages/markup" 29 31 "tangled.sh/tangled.sh/core/appview/reporesolver" 32 + xrpcclient "tangled.sh/tangled.sh/core/appview/xrpcclient" 30 33 "tangled.sh/tangled.sh/core/eventconsumer" 31 - "tangled.sh/tangled.sh/core/knotclient" 34 + "tangled.sh/tangled.sh/core/idresolver" 32 35 "tangled.sh/tangled.sh/core/patchutil" 33 36 "tangled.sh/tangled.sh/core/rbac" 37 + "tangled.sh/tangled.sh/core/tid" 34 38 "tangled.sh/tangled.sh/core/types" 39 + "tangled.sh/tangled.sh/core/xrpc/serviceauth" 35 40 36 41 securejoin "github.com/cyphar/filepath-securejoin" 37 42 "github.com/go-chi/chi/v5" 38 43 "github.com/go-git/go-git/v5/plumbing" 39 - "github.com/posthog/posthog-go" 40 44 41 - comatproto "github.com/bluesky-social/indigo/api/atproto" 42 - lexutil "github.com/bluesky-social/indigo/lex/util" 45 + "github.com/bluesky-social/indigo/atproto/syntax" 43 46 ) 44 47 45 48 type Repo struct { ··· 51 54 spindlestream *eventconsumer.Consumer 52 55 db *db.DB 53 56 enforcer *rbac.Enforcer 54 - posthog posthog.Client 57 + notifier notify.Notifier 58 + logger *slog.Logger 59 + serviceAuth *serviceauth.ServiceAuth 55 60 } 56 61 57 62 func New( ··· 62 67 idResolver *idresolver.Resolver, 63 68 db *db.DB, 64 69 config *config.Config, 65 - posthog posthog.Client, 70 + notifier notify.Notifier, 66 71 enforcer *rbac.Enforcer, 72 + logger *slog.Logger, 67 73 ) *Repo { 68 74 return &Repo{oauth: oauth, 69 75 repoResolver: repoResolver, ··· 72 78 config: config, 73 79 spindlestream: spindlestream, 74 80 db: db, 75 - posthog: posthog, 81 + notifier: notifier, 76 82 enforcer: enforcer, 83 + logger: logger, 77 84 } 78 85 } 79 86 87 + func (rp *Repo) DownloadArchive(w http.ResponseWriter, r *http.Request) { 88 + ref := chi.URLParam(r, "ref") 89 + ref, _ = url.PathUnescape(ref) 90 + 91 + f, err := rp.repoResolver.Resolve(r) 92 + if err != nil { 93 + log.Println("failed to get repo and knot", err) 94 + return 95 + } 96 + 97 + scheme := "http" 98 + if !rp.config.Core.Dev { 99 + scheme = "https" 100 + } 101 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 102 + xrpcc := &indigoxrpc.Client{ 103 + Host: host, 104 + } 105 + 106 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 107 + archiveBytes, err := tangled.RepoArchive(r.Context(), xrpcc, "tar.gz", "", ref, repo) 108 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 109 + log.Println("failed to call XRPC repo.archive", xrpcerr) 110 + rp.pages.Error503(w) 111 + return 112 + } 113 + 114 + // Set headers for file download, just pass along whatever the knot specifies 115 + safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(ref).Short(), "/", "-") 116 + filename := fmt.Sprintf("%s-%s.tar.gz", f.Name, safeRefFilename) 117 + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) 118 + w.Header().Set("Content-Type", "application/gzip") 119 + w.Header().Set("Content-Length", fmt.Sprintf("%d", len(archiveBytes))) 120 + 121 + // Write the archive data directly 122 + w.Write(archiveBytes) 123 + } 124 + 80 125 func (rp *Repo) RepoLog(w http.ResponseWriter, r *http.Request) { 81 126 f, err := rp.repoResolver.Resolve(r) 82 127 if err != nil { ··· 93 138 } 94 139 95 140 ref := chi.URLParam(r, "ref") 141 + ref, _ = url.PathUnescape(ref) 96 142 97 - us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 98 - if err != nil { 99 - log.Println("failed to create unsigned client", err) 143 + scheme := "http" 144 + if !rp.config.Core.Dev { 145 + scheme = "https" 146 + } 147 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 148 + xrpcc := &indigoxrpc.Client{ 149 + Host: host, 150 + } 151 + 152 + limit := int64(60) 153 + cursor := "" 154 + if page > 1 { 155 + // Convert page number to cursor (offset) 156 + offset := (page - 1) * int(limit) 157 + cursor = strconv.Itoa(offset) 158 + } 159 + 160 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 161 + xrpcBytes, err := tangled.RepoLog(r.Context(), xrpcc, cursor, limit, "", ref, repo) 162 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 163 + log.Println("failed to call XRPC repo.log", xrpcerr) 164 + rp.pages.Error503(w) 100 165 return 101 166 } 102 167 103 - repolog, err := us.Log(f.OwnerDid(), f.RepoName, ref, page) 104 - if err != nil { 105 - log.Println("failed to reach knotserver", err) 168 + var xrpcResp types.RepoLogResponse 169 + if err := json.Unmarshal(xrpcBytes, &xrpcResp); err != nil { 170 + log.Println("failed to decode XRPC response", err) 171 + rp.pages.Error503(w) 106 172 return 107 173 } 108 174 109 - result, err := us.Tags(f.OwnerDid(), f.RepoName) 110 - if err != nil { 111 - log.Println("failed to reach knotserver", err) 175 + tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 176 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 177 + log.Println("failed to call XRPC repo.tags", xrpcerr) 178 + rp.pages.Error503(w) 112 179 return 113 180 } 114 181 115 182 tagMap := make(map[string][]string) 116 - for _, tag := range result.Tags { 117 - hash := tag.Hash 118 - if tag.Tag != nil { 119 - hash = tag.Tag.Target.String() 183 + if tagBytes != nil { 184 + var tagResp types.RepoTagsResponse 185 + if err := json.Unmarshal(tagBytes, &tagResp); err == nil { 186 + for _, tag := range tagResp.Tags { 187 + tagMap[tag.Hash] = append(tagMap[tag.Hash], tag.Name) 188 + } 189 + } 190 + } 191 + 192 + branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 193 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 194 + log.Println("failed to call XRPC repo.branches", xrpcerr) 195 + rp.pages.Error503(w) 196 + return 197 + } 198 + 199 + if branchBytes != nil { 200 + var branchResp types.RepoBranchesResponse 201 + if err := json.Unmarshal(branchBytes, &branchResp); err == nil { 202 + for _, branch := range branchResp.Branches { 203 + tagMap[branch.Hash] = append(tagMap[branch.Hash], branch.Name) 204 + } 120 205 } 121 - tagMap[hash] = append(tagMap[hash], tag.Name) 122 206 } 123 207 124 208 user := rp.oauth.GetUser(r) 125 209 126 - emailToDidMap, err := db.GetEmailToDid(rp.db, uniqueEmails(repolog.Commits), true) 210 + emailToDidMap, err := db.GetEmailToDid(rp.db, uniqueEmails(xrpcResp.Commits), true) 127 211 if err != nil { 128 212 log.Println("failed to fetch email to did mapping", err) 129 213 } 130 214 131 - vc, err := commitverify.GetVerifiedObjectCommits(rp.db, emailToDidMap, repolog.Commits) 215 + vc, err := commitverify.GetVerifiedObjectCommits(rp.db, emailToDidMap, xrpcResp.Commits) 132 216 if err != nil { 133 217 log.Println(err) 134 218 } ··· 136 220 repoInfo := f.RepoInfo(user) 137 221 138 222 var shas []string 139 - for _, c := range repolog.Commits { 223 + for _, c := range xrpcResp.Commits { 140 224 shas = append(shas, c.Hash.String()) 141 225 } 142 - pipelines, err := rp.getPipelineStatuses(repoInfo, shas) 226 + pipelines, err := getPipelineStatuses(rp.db, repoInfo, shas) 143 227 if err != nil { 144 228 log.Println(err) 145 229 // non-fatal ··· 149 233 LoggedInUser: user, 150 234 TagMap: tagMap, 151 235 RepoInfo: repoInfo, 152 - RepoLogResponse: *repolog, 236 + RepoLogResponse: xrpcResp, 153 237 EmailToDidOrHandle: emailToDidOrHandle(rp, emailToDidMap), 154 238 VerifiedCommits: vc, 155 239 Pipelines: pipelines, 156 240 }) 157 - return 158 241 } 159 242 160 243 func (rp *Repo) RepoDescriptionEdit(w http.ResponseWriter, r *http.Request) { ··· 169 252 rp.pages.EditRepoDescriptionFragment(w, pages.RepoDescriptionParams{ 170 253 RepoInfo: f.RepoInfo(user), 171 254 }) 172 - return 173 255 } 174 256 175 257 func (rp *Repo) RepoDescription(w http.ResponseWriter, r *http.Request) { ··· 180 262 return 181 263 } 182 264 183 - repoAt := f.RepoAt 265 + repoAt := f.RepoAt() 184 266 rkey := repoAt.RecordKey().String() 185 267 if rkey == "" { 186 268 log.Println("invalid aturi for repo", err) ··· 230 312 Record: &lexutil.LexiconTypeDecoder{ 231 313 Val: &tangled.Repo{ 232 314 Knot: f.Knot, 233 - Name: f.RepoName, 315 + Name: f.Name, 234 316 Owner: user.Did, 235 - CreatedAt: f.CreatedAt, 317 + CreatedAt: f.Created.Format(time.RFC3339), 236 318 Description: &newDescription, 319 + Spindle: &f.Spindle, 237 320 }, 238 321 }, 239 322 }) ··· 262 345 return 263 346 } 264 347 ref := chi.URLParam(r, "ref") 265 - protocol := "http" 266 - if !rp.config.Core.Dev { 267 - protocol = "https" 348 + ref, _ = url.PathUnescape(ref) 349 + 350 + var diffOpts types.DiffOpts 351 + if d := r.URL.Query().Get("diff"); d == "split" { 352 + diffOpts.Split = true 268 353 } 269 354 270 355 if !plumbing.IsHash(ref) { ··· 272 357 return 273 358 } 274 359 275 - resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/commit/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref)) 276 - if err != nil { 277 - log.Println("failed to reach knotserver", err) 278 - return 360 + scheme := "http" 361 + if !rp.config.Core.Dev { 362 + scheme = "https" 363 + } 364 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 365 + xrpcc := &indigoxrpc.Client{ 366 + Host: host, 279 367 } 280 368 281 - body, err := io.ReadAll(resp.Body) 282 - if err != nil { 283 - log.Printf("Error reading response body: %v", err) 369 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 370 + xrpcBytes, err := tangled.RepoDiff(r.Context(), xrpcc, ref, repo) 371 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 372 + log.Println("failed to call XRPC repo.diff", xrpcerr) 373 + rp.pages.Error503(w) 284 374 return 285 375 } 286 376 287 377 var result types.RepoCommitResponse 288 - err = json.Unmarshal(body, &result) 289 - if err != nil { 290 - log.Println("failed to parse response:", err) 378 + if err := json.Unmarshal(xrpcBytes, &result); err != nil { 379 + log.Println("failed to decode XRPC response", err) 380 + rp.pages.Error503(w) 291 381 return 292 382 } 293 383 ··· 303 393 304 394 user := rp.oauth.GetUser(r) 305 395 repoInfo := f.RepoInfo(user) 306 - pipelines, err := rp.getPipelineStatuses(repoInfo, []string{result.Diff.Commit.This}) 396 + pipelines, err := getPipelineStatuses(rp.db, repoInfo, []string{result.Diff.Commit.This}) 307 397 if err != nil { 308 398 log.Println(err) 309 399 // non-fatal ··· 320 410 EmailToDidOrHandle: emailToDidOrHandle(rp, emailToDidMap), 321 411 VerifiedCommit: vc, 322 412 Pipeline: pipeline, 413 + DiffOpts: diffOpts, 323 414 }) 324 - return 325 415 } 326 416 327 417 func (rp *Repo) RepoTree(w http.ResponseWriter, r *http.Request) { ··· 332 422 } 333 423 334 424 ref := chi.URLParam(r, "ref") 425 + ref, _ = url.PathUnescape(ref) 426 + 427 + // if the tree path has a trailing slash, let's strip it 428 + // so we don't 404 335 429 treePath := chi.URLParam(r, "*") 336 - protocol := "http" 430 + treePath, _ = url.PathUnescape(treePath) 431 + treePath = strings.TrimSuffix(treePath, "/") 432 + 433 + scheme := "http" 337 434 if !rp.config.Core.Dev { 338 - protocol = "https" 435 + scheme = "https" 436 + } 437 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 438 + xrpcc := &indigoxrpc.Client{ 439 + Host: host, 339 440 } 340 - resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/tree/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, treePath)) 341 - if err != nil { 342 - log.Println("failed to reach knotserver", err) 441 + 442 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 443 + xrpcResp, err := tangled.RepoTree(r.Context(), xrpcc, treePath, ref, repo) 444 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 445 + log.Println("failed to call XRPC repo.tree", xrpcerr) 446 + rp.pages.Error503(w) 343 447 return 344 448 } 345 449 346 - body, err := io.ReadAll(resp.Body) 347 - if err != nil { 348 - log.Printf("Error reading response body: %v", err) 349 - return 450 + // Convert XRPC response to internal types.RepoTreeResponse 451 + files := make([]types.NiceTree, len(xrpcResp.Files)) 452 + for i, xrpcFile := range xrpcResp.Files { 453 + file := types.NiceTree{ 454 + Name: xrpcFile.Name, 455 + Mode: xrpcFile.Mode, 456 + Size: int64(xrpcFile.Size), 457 + IsFile: xrpcFile.Is_file, 458 + IsSubtree: xrpcFile.Is_subtree, 459 + } 460 + 461 + // Convert last commit info if present 462 + if xrpcFile.Last_commit != nil { 463 + commitWhen, _ := time.Parse(time.RFC3339, xrpcFile.Last_commit.When) 464 + file.LastCommit = &types.LastCommitInfo{ 465 + Hash: plumbing.NewHash(xrpcFile.Last_commit.Hash), 466 + Message: xrpcFile.Last_commit.Message, 467 + When: commitWhen, 468 + } 469 + } 470 + 471 + files[i] = file 350 472 } 351 473 352 - var result types.RepoTreeResponse 353 - err = json.Unmarshal(body, &result) 354 - if err != nil { 355 - log.Println("failed to parse response:", err) 356 - return 474 + result := types.RepoTreeResponse{ 475 + Ref: xrpcResp.Ref, 476 + Files: files, 477 + } 478 + 479 + if xrpcResp.Parent != nil { 480 + result.Parent = *xrpcResp.Parent 481 + } 482 + if xrpcResp.Dotdot != nil { 483 + result.DotDot = *xrpcResp.Dotdot 357 484 } 358 485 359 486 // redirects tree paths trying to access a blob; in this case the result.Files is unpopulated, 360 487 // so we can safely redirect to the "parent" (which is the same file). 361 488 if len(result.Files) == 0 && result.Parent == treePath { 362 - http.Redirect(w, r, fmt.Sprintf("/%s/blob/%s/%s", f.OwnerSlashRepo(), ref, result.Parent), http.StatusFound) 489 + redirectTo := fmt.Sprintf("/%s/blob/%s/%s", f.OwnerSlashRepo(), url.PathEscape(ref), result.Parent) 490 + http.Redirect(w, r, redirectTo, http.StatusFound) 363 491 return 364 492 } 365 493 366 494 user := rp.oauth.GetUser(r) 367 495 368 496 var breadcrumbs [][]string 369 - breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)}) 497 + breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), url.PathEscape(ref))}) 370 498 if treePath != "" { 371 499 for idx, elem := range strings.Split(treePath, "/") { 372 - breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)}) 500 + breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))}) 373 501 } 374 502 } 375 503 376 - baseTreeLink := path.Join(f.OwnerSlashRepo(), "tree", ref, treePath) 377 - baseBlobLink := path.Join(f.OwnerSlashRepo(), "blob", ref, treePath) 504 + sortFiles(result.Files) 378 505 379 506 rp.pages.RepoTree(w, pages.RepoTreeParams{ 380 507 LoggedInUser: user, 381 508 BreadCrumbs: breadcrumbs, 382 - BaseTreeLink: baseTreeLink, 383 - BaseBlobLink: baseBlobLink, 509 + TreePath: treePath, 384 510 RepoInfo: f.RepoInfo(user), 385 511 RepoTreeResponse: result, 386 512 }) 387 - return 388 513 } 389 514 390 515 func (rp *Repo) RepoTags(w http.ResponseWriter, r *http.Request) { ··· 394 519 return 395 520 } 396 521 397 - us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 398 - if err != nil { 399 - log.Println("failed to create unsigned client", err) 522 + scheme := "http" 523 + if !rp.config.Core.Dev { 524 + scheme = "https" 525 + } 526 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 527 + xrpcc := &indigoxrpc.Client{ 528 + Host: host, 529 + } 530 + 531 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 532 + xrpcBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 533 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 534 + log.Println("failed to call XRPC repo.tags", xrpcerr) 535 + rp.pages.Error503(w) 400 536 return 401 537 } 402 538 403 - result, err := us.Tags(f.OwnerDid(), f.RepoName) 404 - if err != nil { 405 - log.Println("failed to reach knotserver", err) 539 + var result types.RepoTagsResponse 540 + if err := json.Unmarshal(xrpcBytes, &result); err != nil { 541 + log.Println("failed to decode XRPC response", err) 542 + rp.pages.Error503(w) 406 543 return 407 544 } 408 545 409 - artifacts, err := db.GetArtifact(rp.db, db.FilterEq("repo_at", f.RepoAt)) 546 + artifacts, err := db.GetArtifact(rp.db, db.FilterEq("repo_at", f.RepoAt())) 410 547 if err != nil { 411 548 log.Println("failed grab artifacts", err) 412 549 return ··· 438 575 rp.pages.RepoTags(w, pages.RepoTagsParams{ 439 576 LoggedInUser: user, 440 577 RepoInfo: f.RepoInfo(user), 441 - RepoTagsResponse: *result, 578 + RepoTagsResponse: result, 442 579 ArtifactMap: artifactMap, 443 580 DanglingArtifacts: danglingArtifacts, 444 581 }) 445 - return 446 582 } 447 583 448 584 func (rp *Repo) RepoBranches(w http.ResponseWriter, r *http.Request) { ··· 452 588 return 453 589 } 454 590 455 - us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 456 - if err != nil { 457 - log.Println("failed to create unsigned client", err) 591 + scheme := "http" 592 + if !rp.config.Core.Dev { 593 + scheme = "https" 594 + } 595 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 596 + xrpcc := &indigoxrpc.Client{ 597 + Host: host, 598 + } 599 + 600 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 601 + xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 602 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 603 + log.Println("failed to call XRPC repo.branches", xrpcerr) 604 + rp.pages.Error503(w) 458 605 return 459 606 } 460 607 461 - result, err := us.Branches(f.OwnerDid(), f.RepoName) 462 - if err != nil { 463 - log.Println("failed to reach knotserver", err) 608 + var result types.RepoBranchesResponse 609 + if err := json.Unmarshal(xrpcBytes, &result); err != nil { 610 + log.Println("failed to decode XRPC response", err) 611 + rp.pages.Error503(w) 464 612 return 465 613 } 466 614 467 - slices.SortFunc(result.Branches, func(a, b types.Branch) int { 468 - if a.IsDefault { 469 - return -1 470 - } 471 - if b.IsDefault { 472 - return 1 473 - } 474 - if a.Commit != nil && b.Commit != nil { 475 - if a.Commit.Committer.When.Before(b.Commit.Committer.When) { 476 - return 1 477 - } else { 478 - return -1 479 - } 480 - } 481 - return strings.Compare(a.Name, b.Name) * -1 482 - }) 615 + sortBranches(result.Branches) 483 616 484 617 user := rp.oauth.GetUser(r) 485 618 rp.pages.RepoBranches(w, pages.RepoBranchesParams{ 486 619 LoggedInUser: user, 487 620 RepoInfo: f.RepoInfo(user), 488 - RepoBranchesResponse: *result, 621 + RepoBranchesResponse: result, 489 622 }) 490 - return 491 623 } 492 624 493 625 func (rp *Repo) RepoBlob(w http.ResponseWriter, r *http.Request) { ··· 498 630 } 499 631 500 632 ref := chi.URLParam(r, "ref") 633 + ref, _ = url.PathUnescape(ref) 634 + 501 635 filePath := chi.URLParam(r, "*") 502 - protocol := "http" 636 + filePath, _ = url.PathUnescape(filePath) 637 + 638 + scheme := "http" 503 639 if !rp.config.Core.Dev { 504 - protocol = "https" 640 + scheme = "https" 505 641 } 506 - resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath)) 507 - if err != nil { 508 - log.Println("failed to reach knotserver", err) 509 - return 642 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 643 + xrpcc := &indigoxrpc.Client{ 644 + Host: host, 510 645 } 511 646 512 - body, err := io.ReadAll(resp.Body) 513 - if err != nil { 514 - log.Printf("Error reading response body: %v", err) 647 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name) 648 + resp, err := tangled.RepoBlob(r.Context(), xrpcc, filePath, false, ref, repo) 649 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 650 + log.Println("failed to call XRPC repo.blob", xrpcerr) 651 + rp.pages.Error503(w) 515 652 return 516 653 } 517 654 518 - var result types.RepoBlobResponse 519 - err = json.Unmarshal(body, &result) 520 - if err != nil { 521 - log.Println("failed to parse response:", err) 522 - return 523 - } 655 + // Use XRPC response directly instead of converting to internal types 524 656 525 657 var breadcrumbs [][]string 526 - breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)}) 658 + breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), url.PathEscape(ref))}) 527 659 if filePath != "" { 528 660 for idx, elem := range strings.Split(filePath, "/") { 529 - breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)}) 661 + breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))}) 530 662 } 531 663 } 532 664 533 665 showRendered := false 534 666 renderToggle := false 535 667 536 - if markup.GetFormat(result.Path) == markup.FormatMarkdown { 668 + if markup.GetFormat(resp.Path) == markup.FormatMarkdown { 537 669 renderToggle = true 538 670 showRendered = r.URL.Query().Get("code") != "true" 539 671 } 540 672 673 + var unsupported bool 674 + var isImage bool 675 + var isVideo bool 676 + var contentSrc string 677 + 678 + if resp.IsBinary != nil && *resp.IsBinary { 679 + ext := strings.ToLower(filepath.Ext(resp.Path)) 680 + switch ext { 681 + case ".jpg", ".jpeg", ".png", ".gif", ".svg", ".webp": 682 + isImage = true 683 + case ".mp4", ".webm", ".ogg", ".mov", ".avi": 684 + isVideo = true 685 + default: 686 + unsupported = true 687 + } 688 + 689 + // fetch the raw binary content using sh.tangled.repo.blob xrpc 690 + repoName := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 691 + 692 + baseURL := &url.URL{ 693 + Scheme: scheme, 694 + Host: f.Knot, 695 + Path: "/xrpc/sh.tangled.repo.blob", 696 + } 697 + query := baseURL.Query() 698 + query.Set("repo", repoName) 699 + query.Set("ref", ref) 700 + query.Set("path", filePath) 701 + query.Set("raw", "true") 702 + baseURL.RawQuery = query.Encode() 703 + blobURL := baseURL.String() 704 + 705 + contentSrc = blobURL 706 + if !rp.config.Core.Dev { 707 + contentSrc = markup.GenerateCamoURL(rp.config.Camo.Host, rp.config.Camo.SharedSecret, blobURL) 708 + } 709 + } 710 + 711 + lines := 0 712 + if resp.IsBinary == nil || !*resp.IsBinary { 713 + lines = strings.Count(resp.Content, "\n") + 1 714 + } 715 + 716 + var sizeHint uint64 717 + if resp.Size != nil { 718 + sizeHint = uint64(*resp.Size) 719 + } else { 720 + sizeHint = uint64(len(resp.Content)) 721 + } 722 + 541 723 user := rp.oauth.GetUser(r) 724 + 725 + // Determine if content is binary (dereference pointer) 726 + isBinary := false 727 + if resp.IsBinary != nil { 728 + isBinary = *resp.IsBinary 729 + } 730 + 542 731 rp.pages.RepoBlob(w, pages.RepoBlobParams{ 543 - LoggedInUser: user, 544 - RepoInfo: f.RepoInfo(user), 545 - RepoBlobResponse: result, 546 - BreadCrumbs: breadcrumbs, 547 - ShowRendered: showRendered, 548 - RenderToggle: renderToggle, 732 + LoggedInUser: user, 733 + RepoInfo: f.RepoInfo(user), 734 + BreadCrumbs: breadcrumbs, 735 + ShowRendered: showRendered, 736 + RenderToggle: renderToggle, 737 + Unsupported: unsupported, 738 + IsImage: isImage, 739 + IsVideo: isVideo, 740 + ContentSrc: contentSrc, 741 + RepoBlob_Output: resp, 742 + Contents: resp.Content, 743 + Lines: lines, 744 + SizeHint: sizeHint, 745 + IsBinary: isBinary, 549 746 }) 550 - return 551 747 } 552 748 553 749 func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) { 554 750 f, err := rp.repoResolver.Resolve(r) 555 751 if err != nil { 556 752 log.Println("failed to get repo and knot", err) 753 + w.WriteHeader(http.StatusBadRequest) 557 754 return 558 755 } 559 756 560 757 ref := chi.URLParam(r, "ref") 758 + ref, _ = url.PathUnescape(ref) 759 + 561 760 filePath := chi.URLParam(r, "*") 761 + filePath, _ = url.PathUnescape(filePath) 562 762 563 - protocol := "http" 763 + scheme := "http" 564 764 if !rp.config.Core.Dev { 565 - protocol = "https" 765 + scheme = "https" 766 + } 767 + 768 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name) 769 + baseURL := &url.URL{ 770 + Scheme: scheme, 771 + Host: f.Knot, 772 + Path: "/xrpc/sh.tangled.repo.blob", 566 773 } 567 - resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath)) 774 + query := baseURL.Query() 775 + query.Set("repo", repo) 776 + query.Set("ref", ref) 777 + query.Set("path", filePath) 778 + query.Set("raw", "true") 779 + baseURL.RawQuery = query.Encode() 780 + blobURL := baseURL.String() 781 + 782 + req, err := http.NewRequest("GET", blobURL, nil) 568 783 if err != nil { 569 - log.Println("failed to reach knotserver", err) 784 + log.Println("failed to create request", err) 570 785 return 571 786 } 572 787 573 - body, err := io.ReadAll(resp.Body) 788 + // forward the If-None-Match header 789 + if clientETag := r.Header.Get("If-None-Match"); clientETag != "" { 790 + req.Header.Set("If-None-Match", clientETag) 791 + } 792 + 793 + client := &http.Client{} 794 + resp, err := client.Do(req) 574 795 if err != nil { 575 - log.Printf("Error reading response body: %v", err) 796 + log.Println("failed to reach knotserver", err) 797 + rp.pages.Error503(w) 798 + return 799 + } 800 + defer resp.Body.Close() 801 + 802 + // forward 304 not modified 803 + if resp.StatusCode == http.StatusNotModified { 804 + w.WriteHeader(http.StatusNotModified) 805 + return 806 + } 807 + 808 + if resp.StatusCode != http.StatusOK { 809 + log.Printf("knotserver returned non-OK status for raw blob %s: %d", blobURL, resp.StatusCode) 810 + w.WriteHeader(resp.StatusCode) 811 + _, _ = io.Copy(w, resp.Body) 576 812 return 577 813 } 578 814 579 - var result types.RepoBlobResponse 580 - err = json.Unmarshal(body, &result) 815 + contentType := resp.Header.Get("Content-Type") 816 + body, err := io.ReadAll(resp.Body) 581 817 if err != nil { 582 - log.Println("failed to parse response:", err) 818 + log.Printf("error reading response body from knotserver: %v", err) 819 + w.WriteHeader(http.StatusInternalServerError) 583 820 return 584 821 } 585 822 586 - if result.IsBinary { 587 - w.Header().Set("Content-Type", "application/octet-stream") 823 + if strings.HasPrefix(contentType, "text/") || isTextualMimeType(contentType) { 824 + // serve all textual content as text/plain 825 + w.Header().Set("Content-Type", "text/plain; charset=utf-8") 588 826 w.Write(body) 827 + } else if strings.HasPrefix(contentType, "image/") || strings.HasPrefix(contentType, "video/") { 828 + // serve images and videos with their original content type 829 + w.Header().Set("Content-Type", contentType) 830 + w.Write(body) 831 + } else { 832 + w.WriteHeader(http.StatusUnsupportedMediaType) 833 + w.Write([]byte("unsupported content type")) 589 834 return 590 835 } 836 + } 591 837 592 - w.Header().Set("Content-Type", "text/plain") 593 - w.Write([]byte(result.Contents)) 594 - return 838 + // isTextualMimeType returns true if the MIME type represents textual content 839 + // that should be served as text/plain 840 + func isTextualMimeType(mimeType string) bool { 841 + textualTypes := []string{ 842 + "application/json", 843 + "application/xml", 844 + "application/yaml", 845 + "application/x-yaml", 846 + "application/toml", 847 + "application/javascript", 848 + "application/ecmascript", 849 + "message/", 850 + } 851 + 852 + return slices.Contains(textualTypes, mimeType) 595 853 } 596 854 597 855 // modify the spindle configured for this repo 598 856 func (rp *Repo) EditSpindle(w http.ResponseWriter, r *http.Request) { 857 + user := rp.oauth.GetUser(r) 858 + l := rp.logger.With("handler", "EditSpindle") 859 + l = l.With("did", user.Did) 860 + l = l.With("handle", user.Handle) 861 + 862 + errorId := "operation-error" 863 + fail := func(msg string, err error) { 864 + l.Error(msg, "err", err) 865 + rp.pages.Notice(w, errorId, msg) 866 + } 867 + 599 868 f, err := rp.repoResolver.Resolve(r) 600 869 if err != nil { 601 - log.Println("failed to get repo and knot", err) 602 - w.WriteHeader(http.StatusBadRequest) 870 + fail("Failed to resolve repo. Try again later", err) 603 871 return 604 872 } 605 873 606 - repoAt := f.RepoAt 874 + repoAt := f.RepoAt() 607 875 rkey := repoAt.RecordKey().String() 608 876 if rkey == "" { 609 - log.Println("invalid aturi for repo", err) 610 - w.WriteHeader(http.StatusInternalServerError) 877 + fail("Failed to resolve repo. Try again later", err) 611 878 return 612 879 } 613 880 614 - user := rp.oauth.GetUser(r) 615 - 616 881 newSpindle := r.FormValue("spindle") 882 + removingSpindle := newSpindle == "[[none]]" // see pages/templates/repo/settings/pipelines.html for more info on why we use this value 617 883 client, err := rp.oauth.AuthorizedClient(r) 618 884 if err != nil { 619 - log.Println("failed to get client") 620 - rp.pages.Notice(w, "repo-notice", "Failed to configure spindle, try again later.") 885 + fail("Failed to authorize. Try again later.", err) 621 886 return 622 887 } 623 888 624 - // ensure that this is a valid spindle for this user 625 - validSpindles, err := rp.enforcer.GetSpindlesForUser(user.Did) 626 - if err != nil { 627 - log.Println("failed to get valid spindles") 628 - rp.pages.Notice(w, "repo-notice", "Failed to configure spindle, try again later.") 629 - return 889 + if !removingSpindle { 890 + // ensure that this is a valid spindle for this user 891 + validSpindles, err := rp.enforcer.GetSpindlesForUser(user.Did) 892 + if err != nil { 893 + fail("Failed to find spindles. Try again later.", err) 894 + return 895 + } 896 + 897 + if !slices.Contains(validSpindles, newSpindle) { 898 + fail("Failed to configure spindle.", fmt.Errorf("%s is not a valid spindle: %q", newSpindle, validSpindles)) 899 + return 900 + } 630 901 } 631 902 632 - if !slices.Contains(validSpindles, newSpindle) { 633 - log.Println("newSpindle not present in validSpindles", "newSpindle", newSpindle, "validSpindles", validSpindles) 634 - rp.pages.Notice(w, "repo-notice", "Failed to configure spindle, try again later.") 635 - return 903 + spindlePtr := &newSpindle 904 + if removingSpindle { 905 + spindlePtr = nil 636 906 } 637 907 638 908 // optimistic update 639 - err = db.UpdateSpindle(rp.db, string(repoAt), newSpindle) 909 + err = db.UpdateSpindle(rp.db, string(repoAt), spindlePtr) 640 910 if err != nil { 641 - log.Println("failed to perform update-spindle query", err) 642 - rp.pages.Notice(w, "repo-notice", "Failed to configure spindle, try again later.") 911 + fail("Failed to update spindle. Try again later.", err) 643 912 return 644 913 } 645 914 646 915 ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, user.Did, rkey) 647 916 if err != nil { 648 - // failed to get record 649 - rp.pages.Notice(w, "repo-notice", "Failed to configure spindle, no record found on PDS.") 917 + fail("Failed to update spindle, no record found on PDS.", err) 650 918 return 651 919 } 652 920 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ ··· 657 925 Record: &lexutil.LexiconTypeDecoder{ 658 926 Val: &tangled.Repo{ 659 927 Knot: f.Knot, 660 - Name: f.RepoName, 928 + Name: f.Name, 661 929 Owner: user.Did, 662 - CreatedAt: f.CreatedAt, 930 + CreatedAt: f.Created.Format(time.RFC3339), 663 931 Description: &f.Description, 664 - Spindle: &newSpindle, 932 + Spindle: spindlePtr, 665 933 }, 666 934 }, 667 935 }) 668 936 669 937 if err != nil { 670 - log.Println("failed to perform update-spindle query", err) 671 - // failed to get record 672 - rp.pages.Notice(w, "repo-notice", "Failed to configure spindle, unable to save to PDS.") 938 + fail("Failed to update spindle, unable to save to PDS.", err) 673 939 return 674 940 } 675 941 676 - // add this spindle to spindle stream 677 - rp.spindlestream.AddSource( 678 - context.Background(), 679 - eventconsumer.NewSpindleSource(newSpindle), 680 - ) 942 + if !removingSpindle { 943 + // add this spindle to spindle stream 944 + rp.spindlestream.AddSource( 945 + context.Background(), 946 + eventconsumer.NewSpindleSource(newSpindle), 947 + ) 948 + } 681 949 682 - w.Write(fmt.Append(nil, "spindle set to: ", newSpindle)) 950 + rp.pages.HxRefresh(w) 683 951 } 684 952 685 953 func (rp *Repo) AddCollaborator(w http.ResponseWriter, r *http.Request) { 954 + user := rp.oauth.GetUser(r) 955 + l := rp.logger.With("handler", "AddCollaborator") 956 + l = l.With("did", user.Did) 957 + l = l.With("handle", user.Handle) 958 + 686 959 f, err := rp.repoResolver.Resolve(r) 687 960 if err != nil { 688 - log.Println("failed to get repo and knot", err) 961 + l.Error("failed to get repo and knot", "err", err) 689 962 return 690 963 } 691 964 965 + errorId := "add-collaborator-error" 966 + fail := func(msg string, err error) { 967 + l.Error(msg, "err", err) 968 + rp.pages.Notice(w, errorId, msg) 969 + } 970 + 692 971 collaborator := r.FormValue("collaborator") 693 972 if collaborator == "" { 694 - http.Error(w, "malformed form", http.StatusBadRequest) 973 + fail("Invalid form.", nil) 695 974 return 696 975 } 697 976 977 + // remove a single leading `@`, to make @handle work with ResolveIdent 978 + collaborator = strings.TrimPrefix(collaborator, "@") 979 + 698 980 collaboratorIdent, err := rp.idResolver.ResolveIdent(r.Context(), collaborator) 699 981 if err != nil { 700 - w.Write([]byte("failed to resolve collaborator did to a handle")) 982 + fail(fmt.Sprintf("'%s' is not a valid DID/handle.", collaborator), err) 701 983 return 702 984 } 703 - log.Printf("adding %s to %s\n", collaboratorIdent.Handle.String(), f.Knot) 704 985 705 - // TODO: create an atproto record for this 706 - 707 - secret, err := db.GetRegistrationKey(rp.db, f.Knot) 708 - if err != nil { 709 - log.Printf("no key found for domain %s: %s\n", f.Knot, err) 986 + if collaboratorIdent.DID.String() == user.Did { 987 + fail("You seem to be adding yourself as a collaborator.", nil) 710 988 return 711 989 } 990 + l = l.With("collaborator", collaboratorIdent.Handle) 991 + l = l.With("knot", f.Knot) 712 992 713 - ksClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev) 993 + // announce this relation into the firehose, store into owners' pds 994 + client, err := rp.oauth.AuthorizedClient(r) 714 995 if err != nil { 715 - log.Println("failed to create client to ", f.Knot) 996 + fail("Failed to write to PDS.", err) 716 997 return 717 998 } 718 999 719 - ksResp, err := ksClient.AddCollaborator(f.OwnerDid(), f.RepoName, collaboratorIdent.DID.String()) 1000 + // emit a record 1001 + currentUser := rp.oauth.GetUser(r) 1002 + rkey := tid.TID() 1003 + createdAt := time.Now() 1004 + resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1005 + Collection: tangled.RepoCollaboratorNSID, 1006 + Repo: currentUser.Did, 1007 + Rkey: rkey, 1008 + Record: &lexutil.LexiconTypeDecoder{ 1009 + Val: &tangled.RepoCollaborator{ 1010 + Subject: collaboratorIdent.DID.String(), 1011 + Repo: string(f.RepoAt()), 1012 + CreatedAt: createdAt.Format(time.RFC3339), 1013 + }}, 1014 + }) 1015 + // invalid record 720 1016 if err != nil { 721 - log.Printf("failed to make request to %s: %s", f.Knot, err) 1017 + fail("Failed to write record to PDS.", err) 722 1018 return 723 1019 } 724 1020 725 - if ksResp.StatusCode != http.StatusNoContent { 726 - w.Write(fmt.Append(nil, "knotserver failed to add collaborator: ", err)) 727 - return 728 - } 1021 + aturi := resp.Uri 1022 + l = l.With("at-uri", aturi) 1023 + l.Info("wrote record to PDS") 729 1024 730 1025 tx, err := rp.db.BeginTx(r.Context(), nil) 731 1026 if err != nil { 732 - log.Println("failed to start tx") 733 - w.Write(fmt.Append(nil, "failed to add collaborator: ", err)) 1027 + fail("Failed to add collaborator.", err) 734 1028 return 735 1029 } 736 - defer func() { 737 - tx.Rollback() 738 - err = rp.enforcer.E.LoadPolicy() 739 - if err != nil { 740 - log.Println("failed to rollback policies") 1030 + 1031 + rollback := func() { 1032 + err1 := tx.Rollback() 1033 + err2 := rp.enforcer.E.LoadPolicy() 1034 + err3 := rollbackRecord(context.Background(), aturi, client) 1035 + 1036 + // ignore txn complete errors, this is okay 1037 + if errors.Is(err1, sql.ErrTxDone) { 1038 + err1 = nil 1039 + } 1040 + 1041 + if errs := errors.Join(err1, err2, err3); errs != nil { 1042 + l.Error("failed to rollback changes", "errs", errs) 1043 + return 741 1044 } 742 - }() 1045 + } 1046 + defer rollback() 743 1047 744 1048 err = rp.enforcer.AddCollaborator(collaboratorIdent.DID.String(), f.Knot, f.DidSlashRepo()) 745 1049 if err != nil { 746 - w.Write(fmt.Append(nil, "failed to add collaborator: ", err)) 1050 + fail("Failed to add collaborator permissions.", err) 747 1051 return 748 1052 } 749 1053 750 - err = db.AddCollaborator(rp.db, collaboratorIdent.DID.String(), f.OwnerDid(), f.RepoName, f.Knot) 1054 + err = db.AddCollaborator(rp.db, db.Collaborator{ 1055 + Did: syntax.DID(currentUser.Did), 1056 + Rkey: rkey, 1057 + SubjectDid: collaboratorIdent.DID, 1058 + RepoAt: f.RepoAt(), 1059 + Created: createdAt, 1060 + }) 751 1061 if err != nil { 752 - w.Write(fmt.Append(nil, "failed to add collaborator: ", err)) 1062 + fail("Failed to add collaborator.", err) 753 1063 return 754 1064 } 755 1065 756 1066 err = tx.Commit() 757 1067 if err != nil { 758 - log.Println("failed to commit changes", err) 759 - http.Error(w, err.Error(), http.StatusInternalServerError) 1068 + fail("Failed to add collaborator.", err) 760 1069 return 761 1070 } 762 1071 763 1072 err = rp.enforcer.E.SavePolicy() 764 1073 if err != nil { 765 - log.Println("failed to update ACLs", err) 766 - http.Error(w, err.Error(), http.StatusInternalServerError) 1074 + fail("Failed to update collaborator permissions.", err) 767 1075 return 768 1076 } 769 1077 770 - w.Write(fmt.Append(nil, "added collaborator: ", collaboratorIdent.Handle.String())) 1078 + // clear aturi to when everything is successful 1079 + aturi = "" 771 1080 1081 + rp.pages.HxRefresh(w) 772 1082 } 773 1083 774 1084 func (rp *Repo) DeleteRepo(w http.ResponseWriter, r *http.Request) { 775 1085 user := rp.oauth.GetUser(r) 776 1086 1087 + noticeId := "operation-error" 777 1088 f, err := rp.repoResolver.Resolve(r) 778 1089 if err != nil { 779 1090 log.Println("failed to get repo and knot", err) ··· 786 1097 log.Println("failed to get authorized client", err) 787 1098 return 788 1099 } 789 - repoRkey := f.RepoAt.RecordKey().String() 790 1100 _, err = xrpcClient.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 791 1101 Collection: tangled.RepoNSID, 792 1102 Repo: user.Did, 793 - Rkey: repoRkey, 1103 + Rkey: f.Rkey, 794 1104 }) 795 1105 if err != nil { 796 1106 log.Printf("failed to delete record: %s", err) 797 - rp.pages.Notice(w, "settings-delete", "Failed to delete repository from PDS.") 1107 + rp.pages.Notice(w, noticeId, "Failed to delete repository from PDS.") 798 1108 return 799 1109 } 800 - log.Println("removed repo record ", f.RepoAt.String()) 1110 + log.Println("removed repo record ", f.RepoAt().String()) 801 1111 802 - secret, err := db.GetRegistrationKey(rp.db, f.Knot) 1112 + client, err := rp.oauth.ServiceClient( 1113 + r, 1114 + oauth.WithService(f.Knot), 1115 + oauth.WithLxm(tangled.RepoDeleteNSID), 1116 + oauth.WithDev(rp.config.Core.Dev), 1117 + ) 803 1118 if err != nil { 804 - log.Printf("no key found for domain %s: %s\n", f.Knot, err) 1119 + log.Println("failed to connect to knot server:", err) 805 1120 return 806 1121 } 807 1122 808 - ksClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev) 809 - if err != nil { 810 - log.Println("failed to create client to ", f.Knot) 1123 + err = tangled.RepoDelete( 1124 + r.Context(), 1125 + client, 1126 + &tangled.RepoDelete_Input{ 1127 + Did: f.OwnerDid(), 1128 + Name: f.Name, 1129 + Rkey: f.Rkey, 1130 + }, 1131 + ) 1132 + if err := xrpcclient.HandleXrpcErr(err); err != nil { 1133 + rp.pages.Notice(w, noticeId, err.Error()) 811 1134 return 812 1135 } 813 - 814 - ksResp, err := ksClient.RemoveRepo(f.OwnerDid(), f.RepoName) 815 - if err != nil { 816 - log.Printf("failed to make request to %s: %s", f.Knot, err) 817 - return 818 - } 819 - 820 - if ksResp.StatusCode != http.StatusNoContent { 821 - log.Println("failed to remove repo from knot, continuing anyway ", f.Knot) 822 - } else { 823 - log.Println("removed repo from knot ", f.Knot) 824 - } 1136 + log.Println("deleted repo from knot") 825 1137 826 1138 tx, err := rp.db.BeginTx(r.Context(), nil) 827 1139 if err != nil { ··· 840 1152 // remove collaborator RBAC 841 1153 repoCollaborators, err := rp.enforcer.E.GetImplicitUsersForResourceByDomain(f.DidSlashRepo(), f.Knot) 842 1154 if err != nil { 843 - rp.pages.Notice(w, "settings-delete", "Failed to remove collaborators") 1155 + rp.pages.Notice(w, noticeId, "Failed to remove collaborators") 844 1156 return 845 1157 } 846 1158 for _, c := range repoCollaborators { ··· 852 1164 // remove repo RBAC 853 1165 err = rp.enforcer.RemoveRepo(f.OwnerDid(), f.Knot, f.DidSlashRepo()) 854 1166 if err != nil { 855 - rp.pages.Notice(w, "settings-delete", "Failed to update RBAC rules") 1167 + rp.pages.Notice(w, noticeId, "Failed to update RBAC rules") 856 1168 return 857 1169 } 858 1170 859 1171 // remove repo from db 860 - err = db.RemoveRepo(tx, f.OwnerDid(), f.RepoName) 1172 + err = db.RemoveRepo(tx, f.OwnerDid(), f.Name) 861 1173 if err != nil { 862 - rp.pages.Notice(w, "settings-delete", "Failed to update appview") 1174 + rp.pages.Notice(w, noticeId, "Failed to update appview") 863 1175 return 864 1176 } 865 1177 log.Println("removed repo from db") ··· 888 1200 return 889 1201 } 890 1202 1203 + noticeId := "operation-error" 891 1204 branch := r.FormValue("branch") 892 1205 if branch == "" { 893 1206 http.Error(w, "malformed form", http.StatusBadRequest) 894 1207 return 895 1208 } 896 1209 897 - secret, err := db.GetRegistrationKey(rp.db, f.Knot) 1210 + client, err := rp.oauth.ServiceClient( 1211 + r, 1212 + oauth.WithService(f.Knot), 1213 + oauth.WithLxm(tangled.RepoSetDefaultBranchNSID), 1214 + oauth.WithDev(rp.config.Core.Dev), 1215 + ) 898 1216 if err != nil { 899 - log.Printf("no key found for domain %s: %s\n", f.Knot, err) 1217 + log.Println("failed to connect to knot server:", err) 1218 + rp.pages.Notice(w, noticeId, "Failed to connect to knot server.") 900 1219 return 901 1220 } 902 1221 903 - ksClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev) 904 - if err != nil { 905 - log.Println("failed to create client to ", f.Knot) 1222 + xe := tangled.RepoSetDefaultBranch( 1223 + r.Context(), 1224 + client, 1225 + &tangled.RepoSetDefaultBranch_Input{ 1226 + Repo: f.RepoAt().String(), 1227 + DefaultBranch: branch, 1228 + }, 1229 + ) 1230 + if err := xrpcclient.HandleXrpcErr(xe); err != nil { 1231 + log.Println("xrpc failed", "err", xe) 1232 + rp.pages.Notice(w, noticeId, err.Error()) 906 1233 return 907 1234 } 908 1235 909 - ksResp, err := ksClient.SetDefaultBranch(f.OwnerDid(), f.RepoName, branch) 1236 + rp.pages.HxRefresh(w) 1237 + } 1238 + 1239 + func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) { 1240 + user := rp.oauth.GetUser(r) 1241 + l := rp.logger.With("handler", "Secrets") 1242 + l = l.With("handle", user.Handle) 1243 + l = l.With("did", user.Did) 1244 + 1245 + f, err := rp.repoResolver.Resolve(r) 910 1246 if err != nil { 911 - log.Printf("failed to make request to %s: %s", f.Knot, err) 1247 + log.Println("failed to get repo and knot", err) 912 1248 return 913 1249 } 914 1250 915 - if ksResp.StatusCode != http.StatusNoContent { 916 - rp.pages.Notice(w, "repo-settings", "Failed to set default branch. Try again later.") 1251 + if f.Spindle == "" { 1252 + log.Println("empty spindle cannot add/rm secret", err) 917 1253 return 918 1254 } 919 1255 920 - w.Write(fmt.Append(nil, "default branch set to: ", branch)) 921 - } 1256 + lxm := tangled.RepoAddSecretNSID 1257 + if r.Method == http.MethodDelete { 1258 + lxm = tangled.RepoRemoveSecretNSID 1259 + } 922 1260 923 - func (rp *Repo) RepoSettings(w http.ResponseWriter, r *http.Request) { 924 - f, err := rp.repoResolver.Resolve(r) 1261 + spindleClient, err := rp.oauth.ServiceClient( 1262 + r, 1263 + oauth.WithService(f.Spindle), 1264 + oauth.WithLxm(lxm), 1265 + oauth.WithExp(60), 1266 + oauth.WithDev(rp.config.Core.Dev), 1267 + ) 925 1268 if err != nil { 926 - log.Println("failed to get repo and knot", err) 1269 + log.Println("failed to create spindle client", err) 1270 + return 1271 + } 1272 + 1273 + key := r.FormValue("key") 1274 + if key == "" { 1275 + w.WriteHeader(http.StatusBadRequest) 927 1276 return 928 1277 } 929 1278 930 1279 switch r.Method { 931 - case http.MethodGet: 932 - // for now, this is just pubkeys 933 - user := rp.oauth.GetUser(r) 934 - repoCollaborators, err := f.Collaborators(r.Context()) 935 - if err != nil { 936 - log.Println("failed to get collaborators", err) 937 - } 1280 + case http.MethodPut: 1281 + errorId := "add-secret-error" 938 1282 939 - isCollaboratorInviteAllowed := false 940 - if user != nil { 941 - ok, err := rp.enforcer.IsCollaboratorInviteAllowed(user.Did, f.Knot, f.DidSlashRepo()) 942 - if err == nil && ok { 943 - isCollaboratorInviteAllowed = true 944 - } 1283 + value := r.FormValue("value") 1284 + if value == "" { 1285 + w.WriteHeader(http.StatusBadRequest) 1286 + return 945 1287 } 946 1288 947 - us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 1289 + err = tangled.RepoAddSecret( 1290 + r.Context(), 1291 + spindleClient, 1292 + &tangled.RepoAddSecret_Input{ 1293 + Repo: f.RepoAt().String(), 1294 + Key: key, 1295 + Value: value, 1296 + }, 1297 + ) 948 1298 if err != nil { 949 - log.Println("failed to create unsigned client", err) 1299 + l.Error("Failed to add secret.", "err", err) 1300 + rp.pages.Notice(w, errorId, "Failed to add secret.") 950 1301 return 951 1302 } 952 1303 953 - result, err := us.Branches(f.OwnerDid(), f.RepoName) 1304 + case http.MethodDelete: 1305 + errorId := "operation-error" 1306 + 1307 + err = tangled.RepoRemoveSecret( 1308 + r.Context(), 1309 + spindleClient, 1310 + &tangled.RepoRemoveSecret_Input{ 1311 + Repo: f.RepoAt().String(), 1312 + Key: key, 1313 + }, 1314 + ) 954 1315 if err != nil { 955 - log.Println("failed to reach knotserver", err) 1316 + l.Error("Failed to delete secret.", "err", err) 1317 + rp.pages.Notice(w, errorId, "Failed to delete secret.") 956 1318 return 957 1319 } 1320 + } 1321 + 1322 + rp.pages.HxRefresh(w) 1323 + } 1324 + 1325 + type tab = map[string]any 958 1326 959 - // all spindles that this user is a member of 960 - spindles, err := rp.enforcer.GetSpindlesForUser(user.Did) 961 - if err != nil { 962 - log.Println("failed to fetch spindles", err) 963 - return 1327 + var ( 1328 + // would be great to have ordered maps right about now 1329 + settingsTabs []tab = []tab{ 1330 + {"Name": "general", "Icon": "sliders-horizontal"}, 1331 + {"Name": "access", "Icon": "users"}, 1332 + {"Name": "pipelines", "Icon": "layers-2"}, 1333 + } 1334 + ) 1335 + 1336 + func (rp *Repo) RepoSettings(w http.ResponseWriter, r *http.Request) { 1337 + tabVal := r.URL.Query().Get("tab") 1338 + if tabVal == "" { 1339 + tabVal = "general" 1340 + } 1341 + 1342 + switch tabVal { 1343 + case "general": 1344 + rp.generalSettings(w, r) 1345 + 1346 + case "access": 1347 + rp.accessSettings(w, r) 1348 + 1349 + case "pipelines": 1350 + rp.pipelineSettings(w, r) 1351 + } 1352 + } 1353 + 1354 + func (rp *Repo) generalSettings(w http.ResponseWriter, r *http.Request) { 1355 + f, err := rp.repoResolver.Resolve(r) 1356 + user := rp.oauth.GetUser(r) 1357 + 1358 + scheme := "http" 1359 + if !rp.config.Core.Dev { 1360 + scheme = "https" 1361 + } 1362 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 1363 + xrpcc := &indigoxrpc.Client{ 1364 + Host: host, 1365 + } 1366 + 1367 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 1368 + xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 1369 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1370 + log.Println("failed to call XRPC repo.branches", xrpcerr) 1371 + rp.pages.Error503(w) 1372 + return 1373 + } 1374 + 1375 + var result types.RepoBranchesResponse 1376 + if err := json.Unmarshal(xrpcBytes, &result); err != nil { 1377 + log.Println("failed to decode XRPC response", err) 1378 + rp.pages.Error503(w) 1379 + return 1380 + } 1381 + 1382 + rp.pages.RepoGeneralSettings(w, pages.RepoGeneralSettingsParams{ 1383 + LoggedInUser: user, 1384 + RepoInfo: f.RepoInfo(user), 1385 + Branches: result.Branches, 1386 + Tabs: settingsTabs, 1387 + Tab: "general", 1388 + }) 1389 + } 1390 + 1391 + func (rp *Repo) accessSettings(w http.ResponseWriter, r *http.Request) { 1392 + f, err := rp.repoResolver.Resolve(r) 1393 + user := rp.oauth.GetUser(r) 1394 + 1395 + repoCollaborators, err := f.Collaborators(r.Context()) 1396 + if err != nil { 1397 + log.Println("failed to get collaborators", err) 1398 + } 1399 + 1400 + rp.pages.RepoAccessSettings(w, pages.RepoAccessSettingsParams{ 1401 + LoggedInUser: user, 1402 + RepoInfo: f.RepoInfo(user), 1403 + Tabs: settingsTabs, 1404 + Tab: "access", 1405 + Collaborators: repoCollaborators, 1406 + }) 1407 + } 1408 + 1409 + func (rp *Repo) pipelineSettings(w http.ResponseWriter, r *http.Request) { 1410 + f, err := rp.repoResolver.Resolve(r) 1411 + user := rp.oauth.GetUser(r) 1412 + 1413 + // all spindles that the repo owner is a member of 1414 + spindles, err := rp.enforcer.GetSpindlesForUser(f.OwnerDid()) 1415 + if err != nil { 1416 + log.Println("failed to fetch spindles", err) 1417 + return 1418 + } 1419 + 1420 + var secrets []*tangled.RepoListSecrets_Secret 1421 + if f.Spindle != "" { 1422 + if spindleClient, err := rp.oauth.ServiceClient( 1423 + r, 1424 + oauth.WithService(f.Spindle), 1425 + oauth.WithLxm(tangled.RepoListSecretsNSID), 1426 + oauth.WithExp(60), 1427 + oauth.WithDev(rp.config.Core.Dev), 1428 + ); err != nil { 1429 + log.Println("failed to create spindle client", err) 1430 + } else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt().String()); err != nil { 1431 + log.Println("failed to fetch secrets", err) 1432 + } else { 1433 + secrets = resp.Secrets 964 1434 } 1435 + } 965 1436 966 - rp.pages.RepoSettings(w, pages.RepoSettingsParams{ 967 - LoggedInUser: user, 968 - RepoInfo: f.RepoInfo(user), 969 - Collaborators: repoCollaborators, 970 - IsCollaboratorInviteAllowed: isCollaboratorInviteAllowed, 971 - Branches: result.Branches, 972 - Spindles: spindles, 973 - CurrentSpindle: f.Spindle, 1437 + slices.SortFunc(secrets, func(a, b *tangled.RepoListSecrets_Secret) int { 1438 + return strings.Compare(a.Key, b.Key) 1439 + }) 1440 + 1441 + var dids []string 1442 + for _, s := range secrets { 1443 + dids = append(dids, s.CreatedBy) 1444 + } 1445 + resolvedIdents := rp.idResolver.ResolveIdents(r.Context(), dids) 1446 + 1447 + // convert to a more manageable form 1448 + var niceSecret []map[string]any 1449 + for id, s := range secrets { 1450 + when, _ := time.Parse(time.RFC3339, s.CreatedAt) 1451 + niceSecret = append(niceSecret, map[string]any{ 1452 + "Id": id, 1453 + "Key": s.Key, 1454 + "CreatedAt": when, 1455 + "CreatedBy": resolvedIdents[id].Handle.String(), 974 1456 }) 975 1457 } 1458 + 1459 + rp.pages.RepoPipelineSettings(w, pages.RepoPipelineSettingsParams{ 1460 + LoggedInUser: user, 1461 + RepoInfo: f.RepoInfo(user), 1462 + Tabs: settingsTabs, 1463 + Tab: "pipelines", 1464 + Spindles: spindles, 1465 + CurrentSpindle: f.Spindle, 1466 + Secrets: niceSecret, 1467 + }) 976 1468 } 977 1469 978 1470 func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) { 1471 + ref := chi.URLParam(r, "ref") 1472 + ref, _ = url.PathUnescape(ref) 1473 + 979 1474 user := rp.oauth.GetUser(r) 980 1475 f, err := rp.repoResolver.Resolve(r) 981 1476 if err != nil { ··· 985 1480 986 1481 switch r.Method { 987 1482 case http.MethodPost: 988 - secret, err := db.GetRegistrationKey(rp.db, f.Knot) 1483 + client, err := rp.oauth.ServiceClient( 1484 + r, 1485 + oauth.WithService(f.Knot), 1486 + oauth.WithLxm(tangled.RepoForkSyncNSID), 1487 + oauth.WithDev(rp.config.Core.Dev), 1488 + ) 989 1489 if err != nil { 990 - rp.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", f.Knot)) 1490 + rp.pages.Notice(w, "repo", "Failed to connect to knot server.") 991 1491 return 992 1492 } 993 1493 994 - client, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev) 995 - if err != nil { 996 - rp.pages.Notice(w, "repo", "Failed to reach knot server.") 1494 + repoInfo := f.RepoInfo(user) 1495 + if repoInfo.Source == nil { 1496 + rp.pages.Notice(w, "repo", "This repository is not a fork.") 997 1497 return 998 1498 } 999 1499 1000 - var uri string 1001 - if rp.config.Core.Dev { 1002 - uri = "http" 1003 - } else { 1004 - uri = "https" 1005 - } 1006 - forkName := fmt.Sprintf("%s", f.RepoName) 1007 - forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.RepoName) 1008 - 1009 - _, err = client.SyncRepoFork(user.Did, forkSourceUrl, forkName, f.Ref) 1010 - if err != nil { 1011 - rp.pages.Notice(w, "repo", "Failed to sync repository fork.") 1500 + err = tangled.RepoForkSync( 1501 + r.Context(), 1502 + client, 1503 + &tangled.RepoForkSync_Input{ 1504 + Did: user.Did, 1505 + Name: f.Name, 1506 + Source: repoInfo.Source.RepoAt().String(), 1507 + Branch: ref, 1508 + }, 1509 + ) 1510 + if err := xrpcclient.HandleXrpcErr(err); err != nil { 1511 + rp.pages.Notice(w, "repo", err.Error()) 1012 1512 return 1013 1513 } 1014 1514 ··· 1041 1541 }) 1042 1542 1043 1543 case http.MethodPost: 1544 + l := rp.logger.With("handler", "ForkRepo") 1044 1545 1045 - knot := r.FormValue("knot") 1046 - if knot == "" { 1546 + targetKnot := r.FormValue("knot") 1547 + if targetKnot == "" { 1047 1548 rp.pages.Notice(w, "repo", "Invalid form submission&mdash;missing knot domain.") 1048 1549 return 1049 1550 } 1551 + l = l.With("targetKnot", targetKnot) 1050 1552 1051 - ok, err := rp.enforcer.E.Enforce(user.Did, knot, knot, "repo:create") 1553 + ok, err := rp.enforcer.E.Enforce(user.Did, targetKnot, targetKnot, "repo:create") 1052 1554 if err != nil || !ok { 1053 1555 rp.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.") 1054 1556 return 1055 1557 } 1056 1558 1057 - forkName := fmt.Sprintf("%s", f.RepoName) 1058 - 1559 + // choose a name for a fork 1560 + forkName := f.Name 1059 1561 // this check is *only* to see if the forked repo name already exists 1060 1562 // in the user's account. 1061 - existingRepo, err := db.GetRepo(rp.db, user.Did, f.RepoName) 1563 + existingRepo, err := db.GetRepo(rp.db, user.Did, f.Name) 1062 1564 if err != nil { 1063 1565 if errors.Is(err, sql.ErrNoRows) { 1064 1566 // no existing repo with this name found, we can use the name as is ··· 1071 1573 // repo with this name already exists, append random string 1072 1574 forkName = fmt.Sprintf("%s-%s", forkName, randomString(3)) 1073 1575 } 1074 - secret, err := db.GetRegistrationKey(rp.db, knot) 1075 - if err != nil { 1076 - rp.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", knot)) 1077 - return 1078 - } 1079 - 1080 - client, err := knotclient.NewSignedClient(knot, secret, rp.config.Core.Dev) 1081 - if err != nil { 1082 - rp.pages.Notice(w, "repo", "Failed to reach knot server.") 1083 - return 1084 - } 1576 + l = l.With("forkName", forkName) 1085 1577 1086 - var uri string 1578 + uri := "https" 1087 1579 if rp.config.Core.Dev { 1088 1580 uri = "http" 1089 - } else { 1090 - uri = "https" 1091 1581 } 1092 - forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.RepoName) 1093 - sourceAt := f.RepoAt.String() 1582 + 1583 + forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.Repo.Name) 1584 + l = l.With("cloneUrl", forkSourceUrl) 1585 + 1586 + sourceAt := f.RepoAt().String() 1094 1587 1095 - rkey := appview.TID() 1588 + // create an atproto record for this fork 1589 + rkey := tid.TID() 1096 1590 repo := &db.Repo{ 1097 1591 Did: user.Did, 1098 1592 Name: forkName, 1099 - Knot: knot, 1593 + Knot: targetKnot, 1100 1594 Rkey: rkey, 1101 1595 Source: sourceAt, 1102 1596 } 1103 1597 1104 - tx, err := rp.db.BeginTx(r.Context(), nil) 1105 - if err != nil { 1106 - log.Println(err) 1107 - rp.pages.Notice(w, "repo", "Failed to save repository information.") 1108 - return 1109 - } 1110 - defer func() { 1111 - tx.Rollback() 1112 - err = rp.enforcer.E.LoadPolicy() 1113 - if err != nil { 1114 - log.Println("failed to rollback policies") 1115 - } 1116 - }() 1117 - 1118 - resp, err := client.ForkRepo(user.Did, forkSourceUrl, forkName) 1119 - if err != nil { 1120 - rp.pages.Notice(w, "repo", "Failed to create repository on knot server.") 1121 - return 1122 - } 1123 - 1124 - switch resp.StatusCode { 1125 - case http.StatusConflict: 1126 - rp.pages.Notice(w, "repo", "A repository with that name already exists.") 1127 - return 1128 - case http.StatusInternalServerError: 1129 - rp.pages.Notice(w, "repo", "Failed to create repository on knot. Try again later.") 1130 - case http.StatusNoContent: 1131 - // continue 1132 - } 1133 - 1134 1598 xrpcClient, err := rp.oauth.AuthorizedClient(r) 1135 1599 if err != nil { 1136 - log.Println("failed to get authorized client", err) 1137 - rp.pages.Notice(w, "repo", "Failed to create repository.") 1600 + l.Error("failed to create xrpcclient", "err", err) 1601 + rp.pages.Notice(w, "repo", "Failed to fork repository.") 1138 1602 return 1139 1603 } 1140 1604 ··· 1153 1617 }}, 1154 1618 }) 1155 1619 if err != nil { 1156 - log.Printf("failed to create record: %s", err) 1620 + l.Error("failed to write to PDS", "err", err) 1157 1621 rp.pages.Notice(w, "repo", "Failed to announce repository creation.") 1158 1622 return 1159 1623 } 1160 - log.Println("created repo record: ", atresp.Uri) 1624 + 1625 + aturi := atresp.Uri 1626 + l = l.With("aturi", aturi) 1627 + l.Info("wrote to PDS") 1628 + 1629 + tx, err := rp.db.BeginTx(r.Context(), nil) 1630 + if err != nil { 1631 + l.Info("txn failed", "err", err) 1632 + rp.pages.Notice(w, "repo", "Failed to save repository information.") 1633 + return 1634 + } 1161 1635 1162 - repo.AtUri = atresp.Uri 1636 + // The rollback function reverts a few things on failure: 1637 + // - the pending txn 1638 + // - the ACLs 1639 + // - the atproto record created 1640 + rollback := func() { 1641 + err1 := tx.Rollback() 1642 + err2 := rp.enforcer.E.LoadPolicy() 1643 + err3 := rollbackRecord(context.Background(), aturi, xrpcClient) 1644 + 1645 + // ignore txn complete errors, this is okay 1646 + if errors.Is(err1, sql.ErrTxDone) { 1647 + err1 = nil 1648 + } 1649 + 1650 + if errs := errors.Join(err1, err2, err3); errs != nil { 1651 + l.Error("failed to rollback changes", "errs", errs) 1652 + return 1653 + } 1654 + } 1655 + defer rollback() 1656 + 1657 + client, err := rp.oauth.ServiceClient( 1658 + r, 1659 + oauth.WithService(targetKnot), 1660 + oauth.WithLxm(tangled.RepoCreateNSID), 1661 + oauth.WithDev(rp.config.Core.Dev), 1662 + ) 1663 + if err != nil { 1664 + l.Error("could not create service client", "err", err) 1665 + rp.pages.Notice(w, "repo", "Failed to connect to knot server.") 1666 + return 1667 + } 1668 + 1669 + err = tangled.RepoCreate( 1670 + r.Context(), 1671 + client, 1672 + &tangled.RepoCreate_Input{ 1673 + Rkey: rkey, 1674 + Source: &forkSourceUrl, 1675 + }, 1676 + ) 1677 + if err := xrpcclient.HandleXrpcErr(err); err != nil { 1678 + rp.pages.Notice(w, "repo", err.Error()) 1679 + return 1680 + } 1681 + 1163 1682 err = db.AddRepo(tx, repo) 1164 1683 if err != nil { 1165 1684 log.Println(err) ··· 1169 1688 1170 1689 // acls 1171 1690 p, _ := securejoin.SecureJoin(user.Did, forkName) 1172 - err = rp.enforcer.AddRepo(user.Did, knot, p) 1691 + err = rp.enforcer.AddRepo(user.Did, targetKnot, p) 1173 1692 if err != nil { 1174 1693 log.Println(err) 1175 1694 rp.pages.Notice(w, "repo", "Failed to set up repository permissions.") ··· 1190 1709 return 1191 1710 } 1192 1711 1712 + // reset the ATURI because the transaction completed successfully 1713 + aturi = "" 1714 + 1715 + rp.notifier.NewRepo(r.Context(), repo) 1193 1716 rp.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, forkName)) 1194 - return 1195 1717 } 1196 1718 } 1197 1719 1720 + // this is used to rollback changes made to the PDS 1721 + // 1722 + // it is a no-op if the provided ATURI is empty 1723 + func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error { 1724 + if aturi == "" { 1725 + return nil 1726 + } 1727 + 1728 + parsed := syntax.ATURI(aturi) 1729 + 1730 + collection := parsed.Collection().String() 1731 + repo := parsed.Authority().String() 1732 + rkey := parsed.RecordKey().String() 1733 + 1734 + _, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{ 1735 + Collection: collection, 1736 + Repo: repo, 1737 + Rkey: rkey, 1738 + }) 1739 + return err 1740 + } 1741 + 1198 1742 func (rp *Repo) RepoCompareNew(w http.ResponseWriter, r *http.Request) { 1199 1743 user := rp.oauth.GetUser(r) 1200 1744 f, err := rp.repoResolver.Resolve(r) ··· 1203 1747 return 1204 1748 } 1205 1749 1206 - us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 1207 - if err != nil { 1208 - log.Printf("failed to create unsigned client for %s", f.Knot) 1750 + scheme := "http" 1751 + if !rp.config.Core.Dev { 1752 + scheme = "https" 1753 + } 1754 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 1755 + xrpcc := &indigoxrpc.Client{ 1756 + Host: host, 1757 + } 1758 + 1759 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 1760 + branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 1761 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1762 + log.Println("failed to call XRPC repo.branches", xrpcerr) 1209 1763 rp.pages.Error503(w) 1210 1764 return 1211 1765 } 1212 1766 1213 - result, err := us.Branches(f.OwnerDid(), f.RepoName) 1214 - if err != nil { 1767 + var branchResult types.RepoBranchesResponse 1768 + if err := json.Unmarshal(branchBytes, &branchResult); err != nil { 1769 + log.Println("failed to decode XRPC branches response", err) 1215 1770 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1216 - log.Println("failed to reach knotserver", err) 1217 1771 return 1218 1772 } 1219 - branches := result.Branches 1220 - sort.Slice(branches, func(i int, j int) bool { 1221 - return branches[i].Commit.Committer.When.After(branches[j].Commit.Committer.When) 1222 - }) 1773 + branches := branchResult.Branches 1774 + 1775 + sortBranches(branches) 1223 1776 1224 1777 var defaultBranch string 1225 1778 for _, b := range branches { ··· 1241 1794 head = queryHead 1242 1795 } 1243 1796 1244 - tags, err := us.Tags(f.OwnerDid(), f.RepoName) 1245 - if err != nil { 1797 + tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 1798 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1799 + log.Println("failed to call XRPC repo.tags", xrpcerr) 1800 + rp.pages.Error503(w) 1801 + return 1802 + } 1803 + 1804 + var tags types.RepoTagsResponse 1805 + if err := json.Unmarshal(tagBytes, &tags); err != nil { 1806 + log.Println("failed to decode XRPC tags response", err) 1246 1807 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1247 - log.Println("failed to reach knotserver", err) 1248 1808 return 1249 1809 } 1250 1810 ··· 1268 1828 return 1269 1829 } 1270 1830 1831 + var diffOpts types.DiffOpts 1832 + if d := r.URL.Query().Get("diff"); d == "split" { 1833 + diffOpts.Split = true 1834 + } 1835 + 1271 1836 // if user is navigating to one of 1272 1837 // /compare/{base}/{head} 1273 1838 // /compare/{base}...{head} ··· 1291 1856 return 1292 1857 } 1293 1858 1294 - us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 1295 - if err != nil { 1296 - log.Printf("failed to create unsigned client for %s", f.Knot) 1859 + scheme := "http" 1860 + if !rp.config.Core.Dev { 1861 + scheme = "https" 1862 + } 1863 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 1864 + xrpcc := &indigoxrpc.Client{ 1865 + Host: host, 1866 + } 1867 + 1868 + repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 1869 + 1870 + branchBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 1871 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1872 + log.Println("failed to call XRPC repo.branches", xrpcerr) 1297 1873 rp.pages.Error503(w) 1298 1874 return 1299 1875 } 1300 1876 1301 - branches, err := us.Branches(f.OwnerDid(), f.RepoName) 1302 - if err != nil { 1877 + var branches types.RepoBranchesResponse 1878 + if err := json.Unmarshal(branchBytes, &branches); err != nil { 1879 + log.Println("failed to decode XRPC branches response", err) 1303 1880 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1304 - log.Println("failed to reach knotserver", err) 1305 1881 return 1306 1882 } 1307 1883 1308 - tags, err := us.Tags(f.OwnerDid(), f.RepoName) 1309 - if err != nil { 1884 + tagBytes, err := tangled.RepoTags(r.Context(), xrpcc, "", 0, repo) 1885 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1886 + log.Println("failed to call XRPC repo.tags", xrpcerr) 1887 + rp.pages.Error503(w) 1888 + return 1889 + } 1890 + 1891 + var tags types.RepoTagsResponse 1892 + if err := json.Unmarshal(tagBytes, &tags); err != nil { 1893 + log.Println("failed to decode XRPC tags response", err) 1310 1894 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1311 - log.Println("failed to reach knotserver", err) 1895 + return 1896 + } 1897 + 1898 + compareBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, base, head) 1899 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1900 + log.Println("failed to call XRPC repo.compare", xrpcerr) 1901 + rp.pages.Error503(w) 1312 1902 return 1313 1903 } 1314 1904 1315 - formatPatch, err := us.Compare(f.OwnerDid(), f.RepoName, base, head) 1316 - if err != nil { 1905 + var formatPatch types.RepoFormatPatchResponse 1906 + if err := json.Unmarshal(compareBytes, &formatPatch); err != nil { 1907 + log.Println("failed to decode XRPC compare response", err) 1317 1908 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1318 - log.Println("failed to compare", err) 1319 1909 return 1320 1910 } 1911 + 1321 1912 diff := patchutil.AsNiceDiff(formatPatch.Patch, base) 1322 1913 1323 1914 repoinfo := f.RepoInfo(user) ··· 1330 1921 Base: base, 1331 1922 Head: head, 1332 1923 Diff: &diff, 1924 + DiffOpts: diffOpts, 1333 1925 }) 1334 1926 1335 1927 }
+37 -2
appview/repo/repo_util.go
··· 5 5 "crypto/rand" 6 6 "fmt" 7 7 "math/big" 8 + "slices" 9 + "sort" 10 + "strings" 8 11 9 12 "tangled.sh/tangled.sh/core/appview/db" 10 13 "tangled.sh/tangled.sh/core/appview/pages/repoinfo" 14 + "tangled.sh/tangled.sh/core/types" 11 15 12 16 "github.com/go-git/go-git/v5/plumbing/object" 13 17 ) 18 + 19 + func sortFiles(files []types.NiceTree) { 20 + sort.Slice(files, func(i, j int) bool { 21 + iIsFile := files[i].IsFile 22 + jIsFile := files[j].IsFile 23 + if iIsFile != jIsFile { 24 + return !iIsFile 25 + } 26 + return files[i].Name < files[j].Name 27 + }) 28 + } 29 + 30 + func sortBranches(branches []types.Branch) { 31 + slices.SortFunc(branches, func(a, b types.Branch) int { 32 + if a.IsDefault { 33 + return -1 34 + } 35 + if b.IsDefault { 36 + return 1 37 + } 38 + if a.Commit != nil && b.Commit != nil { 39 + if a.Commit.Committer.When.Before(b.Commit.Committer.When) { 40 + return 1 41 + } else { 42 + return -1 43 + } 44 + } 45 + return strings.Compare(a.Name, b.Name) 46 + }) 47 + } 14 48 15 49 func uniqueEmails(commits []*object.Commit) []string { 16 50 emails := make(map[string]struct{}) ··· 105 139 // grab pipelines from DB and munge that into a hashmap with commit sha as key 106 140 // 107 141 // golang is so blessed that it requires 35 lines of imperative code for this 108 - func (rp *Repo) getPipelineStatuses( 142 + func getPipelineStatuses( 143 + d *db.DB, 109 144 repoInfo repoinfo.RepoInfo, 110 145 shas []string, 111 146 ) (map[string]db.Pipeline, error) { ··· 116 151 } 117 152 118 153 ps, err := db.GetPipelineStatuses( 119 - rp.db, 154 + d, 120 155 db.FilterEq("repo_owner", repoInfo.OwnerDid), 121 156 db.FilterEq("repo_name", repoInfo.Name), 122 157 db.FilterEq("knot", repoInfo.Knot),
+7
appview/repo/router.go
··· 10 10 func (rp *Repo) Router(mw *middleware.Middleware) http.Handler { 11 11 r := chi.NewRouter() 12 12 r.Get("/", rp.RepoIndex) 13 + r.Get("/feed.atom", rp.RepoAtomFeed) 13 14 r.Get("/commits/{ref}", rp.RepoLog) 14 15 r.Route("/tree/{ref}", func(r chi.Router) { 15 16 r.Get("/", rp.RepoIndex) ··· 37 38 }) 38 39 r.Get("/blob/{ref}/*", rp.RepoBlob) 39 40 r.Get("/raw/{ref}/*", rp.RepoBlobRaw) 41 + 42 + // intentionally doesn't use /* as this isn't 43 + // a file path 44 + r.Get("/archive/{ref}", rp.DownloadArchive) 40 45 41 46 r.Route("/fork", func(r chi.Router) { 42 47 r.Use(middleware.AuthMiddleware(rp.oauth)) ··· 74 79 r.With(mw.RepoPermissionMiddleware("repo:invite")).Put("/collaborator", rp.AddCollaborator) 75 80 r.With(mw.RepoPermissionMiddleware("repo:delete")).Delete("/delete", rp.DeleteRepo) 76 81 r.Put("/branches/default", rp.SetDefaultBranch) 82 + r.Put("/secrets", rp.Secrets) 83 + r.Delete("/secrets", rp.Secrets) 77 84 }) 78 85 }) 79 86
+43 -108
appview/reporesolver/resolver.go
··· 7 7 "fmt" 8 8 "log" 9 9 "net/http" 10 - "net/url" 11 10 "path" 11 + "regexp" 12 12 "strings" 13 13 14 14 "github.com/bluesky-social/indigo/atproto/identity" 15 - "github.com/bluesky-social/indigo/atproto/syntax" 16 15 securejoin "github.com/cyphar/filepath-securejoin" 17 16 "github.com/go-chi/chi/v5" 18 17 "tangled.sh/tangled.sh/core/appview/config" 19 18 "tangled.sh/tangled.sh/core/appview/db" 20 - "tangled.sh/tangled.sh/core/appview/idresolver" 21 19 "tangled.sh/tangled.sh/core/appview/oauth" 22 20 "tangled.sh/tangled.sh/core/appview/pages" 23 21 "tangled.sh/tangled.sh/core/appview/pages/repoinfo" 24 - "tangled.sh/tangled.sh/core/knotclient" 22 + "tangled.sh/tangled.sh/core/idresolver" 25 23 "tangled.sh/tangled.sh/core/rbac" 26 24 ) 27 25 28 26 type ResolvedRepo struct { 29 - Knot string 30 - OwnerId identity.Identity 31 - RepoName string 32 - RepoAt syntax.ATURI 33 - Description string 34 - Spindle string 35 - CreatedAt string 36 - Ref string 37 - CurrentDir string 27 + db.Repo 28 + OwnerId identity.Identity 29 + CurrentDir string 30 + Ref string 38 31 39 32 rr *RepoResolver 40 33 } ··· 51 44 } 52 45 53 46 func (rr *RepoResolver) Resolve(r *http.Request) (*ResolvedRepo, error) { 54 - repoName := chi.URLParam(r, "repo") 55 - knot, ok := r.Context().Value("knot").(string) 47 + repo, ok := r.Context().Value("repo").(*db.Repo) 56 48 if !ok { 57 - log.Println("malformed middleware") 49 + log.Println("malformed middleware: `repo` not exist in context") 58 50 return nil, fmt.Errorf("malformed middleware") 59 51 } 60 52 id, ok := r.Context().Value("resolvedId").(identity.Identity) ··· 63 55 return nil, fmt.Errorf("malformed middleware") 64 56 } 65 57 66 - repoAt, ok := r.Context().Value("repoAt").(string) 67 - if !ok { 68 - log.Println("malformed middleware") 69 - return nil, fmt.Errorf("malformed middleware") 70 - } 71 - 72 - parsedRepoAt, err := syntax.ParseATURI(repoAt) 73 - if err != nil { 74 - log.Println("malformed repo at-uri") 75 - return nil, fmt.Errorf("malformed middleware") 76 - } 77 - 58 + currentDir := path.Dir(extractPathAfterRef(r.URL.EscapedPath())) 78 59 ref := chi.URLParam(r, "ref") 79 60 80 - if ref == "" { 81 - us, err := knotclient.NewUnsignedClient(knot, rr.config.Core.Dev) 82 - if err != nil { 83 - return nil, err 84 - } 85 - 86 - defaultBranch, err := us.DefaultBranch(id.DID.String(), repoName) 87 - if err != nil { 88 - return nil, err 89 - } 90 - 91 - ref = defaultBranch.Branch 92 - } 93 - 94 - currentDir := path.Dir(extractPathAfterRef(r.URL.EscapedPath(), ref)) 95 - 96 - // pass through values from the middleware 97 - description, ok := r.Context().Value("repoDescription").(string) 98 - addedAt, ok := r.Context().Value("repoAddedAt").(string) 99 - spindle, ok := r.Context().Value("repoSpindle").(string) 100 - 101 61 return &ResolvedRepo{ 102 - Knot: knot, 103 - OwnerId: id, 104 - RepoName: repoName, 105 - RepoAt: parsedRepoAt, 106 - Description: description, 107 - CreatedAt: addedAt, 108 - Ref: ref, 109 - CurrentDir: currentDir, 110 - Spindle: spindle, 62 + Repo: *repo, 63 + OwnerId: id, 64 + CurrentDir: currentDir, 65 + Ref: ref, 111 66 112 67 rr: rr, 113 68 }, nil ··· 126 81 127 82 var p string 128 83 if handle != "" && !handle.IsInvalidHandle() { 129 - p, _ = securejoin.SecureJoin(fmt.Sprintf("@%s", handle), f.RepoName) 84 + p, _ = securejoin.SecureJoin(fmt.Sprintf("@%s", handle), f.Name) 130 85 } else { 131 - p, _ = securejoin.SecureJoin(f.OwnerDid(), f.RepoName) 86 + p, _ = securejoin.SecureJoin(f.OwnerDid(), f.Name) 132 87 } 133 88 134 89 return p 135 90 } 136 91 137 - func (f *ResolvedRepo) DidSlashRepo() string { 138 - p, _ := securejoin.SecureJoin(f.OwnerDid(), f.RepoName) 139 - return p 140 - } 141 - 142 92 func (f *ResolvedRepo) Collaborators(ctx context.Context) ([]pages.Collaborator, error) { 143 93 repoCollaborators, err := f.rr.enforcer.E.GetImplicitUsersForResourceByDomain(f.DidSlashRepo(), f.Knot) 144 94 if err != nil { ··· 149 99 for _, item := range repoCollaborators { 150 100 // currently only two roles: owner and member 151 101 var role string 152 - if item[3] == "repo:owner" { 102 + switch item[3] { 103 + case "repo:owner": 153 104 role = "owner" 154 - } else if item[3] == "repo:collaborator" { 105 + case "repo:collaborator": 155 106 role = "collaborator" 156 - } else { 107 + default: 157 108 continue 158 109 } 159 110 ··· 186 137 // this function is a bit weird since it now returns RepoInfo from an entirely different 187 138 // package. we should refactor this or get rid of RepoInfo entirely. 188 139 func (f *ResolvedRepo) RepoInfo(user *oauth.User) repoinfo.RepoInfo { 140 + repoAt := f.RepoAt() 189 141 isStarred := false 190 142 if user != nil { 191 - isStarred = db.GetStarStatus(f.rr.execer, user.Did, syntax.ATURI(f.RepoAt)) 143 + isStarred = db.GetStarStatus(f.rr.execer, user.Did, repoAt) 192 144 } 193 145 194 - starCount, err := db.GetStarCount(f.rr.execer, f.RepoAt) 146 + starCount, err := db.GetStarCount(f.rr.execer, repoAt) 195 147 if err != nil { 196 - log.Println("failed to get star count for ", f.RepoAt) 148 + log.Println("failed to get star count for ", repoAt) 197 149 } 198 - issueCount, err := db.GetIssueCount(f.rr.execer, f.RepoAt) 150 + issueCount, err := db.GetIssueCount(f.rr.execer, repoAt) 199 151 if err != nil { 200 - log.Println("failed to get issue count for ", f.RepoAt) 152 + log.Println("failed to get issue count for ", repoAt) 201 153 } 202 - pullCount, err := db.GetPullCount(f.rr.execer, f.RepoAt) 154 + pullCount, err := db.GetPullCount(f.rr.execer, repoAt) 203 155 if err != nil { 204 - log.Println("failed to get issue count for ", f.RepoAt) 156 + log.Println("failed to get issue count for ", repoAt) 205 157 } 206 - source, err := db.GetRepoSource(f.rr.execer, f.RepoAt) 158 + source, err := db.GetRepoSource(f.rr.execer, repoAt) 207 159 if errors.Is(err, sql.ErrNoRows) { 208 160 source = "" 209 161 } else if err != nil { 210 - log.Println("failed to get repo source for ", f.RepoAt, err) 162 + log.Println("failed to get repo source for ", repoAt, err) 211 163 } 212 164 213 165 var sourceRepo *db.Repo ··· 227 179 } 228 180 229 181 knot := f.Knot 230 - var disableFork bool 231 - us, err := knotclient.NewUnsignedClient(knot, f.rr.config.Core.Dev) 232 - if err != nil { 233 - log.Printf("failed to create unsigned client for %s: %v", knot, err) 234 - } else { 235 - result, err := us.Branches(f.OwnerDid(), f.RepoName) 236 - if err != nil { 237 - log.Printf("failed to get branches for %s/%s: %v", f.OwnerDid(), f.RepoName, err) 238 - } 239 - 240 - if len(result.Branches) == 0 { 241 - disableFork = true 242 - } 243 - } 244 182 245 183 repoInfo := repoinfo.RepoInfo{ 246 184 OwnerDid: f.OwnerDid(), 247 185 OwnerHandle: f.OwnerHandle(), 248 - Name: f.RepoName, 249 - RepoAt: f.RepoAt, 186 + Name: f.Name, 187 + RepoAt: repoAt, 250 188 Description: f.Description, 251 - Ref: f.Ref, 252 189 IsStarred: isStarred, 253 190 Knot: knot, 191 + Spindle: f.Spindle, 254 192 Roles: f.RolesInRepo(user), 255 193 Stats: db.RepoStats{ 256 194 StarCount: starCount, 257 195 IssueCount: issueCount, 258 196 PullCount: pullCount, 259 197 }, 260 - DisableFork: disableFork, 261 - CurrentDir: f.CurrentDir, 198 + CurrentDir: f.CurrentDir, 199 + Ref: f.Ref, 262 200 } 263 201 264 202 if sourceRepo != nil { ··· 282 220 // after the ref. for example: 283 221 // 284 222 // /@icyphox.sh/foorepo/blob/main/abc/xyz/ => abc/xyz/ 285 - func extractPathAfterRef(fullPath, ref string) string { 223 + func extractPathAfterRef(fullPath string) string { 286 224 fullPath = strings.TrimPrefix(fullPath, "/") 287 225 288 - ref = url.PathEscape(ref) 226 + // match blob/, tree/, or raw/ followed by any ref and then a slash 227 + // 228 + // captures everything after the final slash 229 + pattern := `(?:blob|tree|raw)/[^/]+/(.*)$` 289 230 290 - prefixes := []string{ 291 - fmt.Sprintf("blob/%s/", ref), 292 - fmt.Sprintf("tree/%s/", ref), 293 - fmt.Sprintf("raw/%s/", ref), 294 - } 231 + re := regexp.MustCompile(pattern) 232 + matches := re.FindStringSubmatch(fullPath) 295 233 296 - for _, prefix := range prefixes { 297 - idx := strings.Index(fullPath, prefix) 298 - if idx != -1 { 299 - return fullPath[idx+len(prefix):] 300 - } 234 + if len(matches) > 1 { 235 + return matches[1] 301 236 } 302 237 303 238 return ""
+148
appview/serververify/verify.go
··· 1 + package serververify 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "fmt" 7 + 8 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 9 + "tangled.sh/tangled.sh/core/api/tangled" 10 + "tangled.sh/tangled.sh/core/appview/db" 11 + "tangled.sh/tangled.sh/core/appview/xrpcclient" 12 + "tangled.sh/tangled.sh/core/rbac" 13 + ) 14 + 15 + var ( 16 + FetchError = errors.New("failed to fetch owner") 17 + ) 18 + 19 + // fetchOwner fetches the owner DID from a server's /owner endpoint 20 + func fetchOwner(ctx context.Context, domain string, dev bool) (string, error) { 21 + scheme := "https" 22 + if dev { 23 + scheme = "http" 24 + } 25 + 26 + host := fmt.Sprintf("%s://%s", scheme, domain) 27 + xrpcc := &indigoxrpc.Client{ 28 + Host: host, 29 + } 30 + 31 + res, err := tangled.Owner(ctx, xrpcc) 32 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 33 + return "", xrpcerr 34 + } 35 + 36 + return res.Owner, nil 37 + } 38 + 39 + type OwnerMismatch struct { 40 + expected string 41 + observed string 42 + } 43 + 44 + func (e *OwnerMismatch) Error() string { 45 + return fmt.Sprintf("owner mismatch: %q != %q", e.expected, e.observed) 46 + } 47 + 48 + // RunVerification verifies that the server at the given domain has the expected owner 49 + func RunVerification(ctx context.Context, domain, expectedOwner string, dev bool) error { 50 + observedOwner, err := fetchOwner(ctx, domain, dev) 51 + if err != nil { 52 + return err 53 + } 54 + 55 + if observedOwner != expectedOwner { 56 + return &OwnerMismatch{ 57 + expected: expectedOwner, 58 + observed: observedOwner, 59 + } 60 + } 61 + 62 + return nil 63 + } 64 + 65 + // MarkSpindleVerified marks a spindle as verified in the DB and adds the user as its owner 66 + func MarkSpindleVerified(d *db.DB, e *rbac.Enforcer, instance, owner string) (int64, error) { 67 + tx, err := d.Begin() 68 + if err != nil { 69 + return 0, fmt.Errorf("failed to create txn: %w", err) 70 + } 71 + defer func() { 72 + tx.Rollback() 73 + e.E.LoadPolicy() 74 + }() 75 + 76 + // mark this spindle as verified in the db 77 + rowId, err := db.VerifySpindle( 78 + tx, 79 + db.FilterEq("owner", owner), 80 + db.FilterEq("instance", instance), 81 + ) 82 + if err != nil { 83 + return 0, fmt.Errorf("failed to write to DB: %w", err) 84 + } 85 + 86 + err = e.AddSpindleOwner(instance, owner) 87 + if err != nil { 88 + return 0, fmt.Errorf("failed to update ACL: %w", err) 89 + } 90 + 91 + err = tx.Commit() 92 + if err != nil { 93 + return 0, fmt.Errorf("failed to commit txn: %w", err) 94 + } 95 + 96 + err = e.E.SavePolicy() 97 + if err != nil { 98 + return 0, fmt.Errorf("failed to update ACL: %w", err) 99 + } 100 + 101 + return rowId, nil 102 + } 103 + 104 + // MarkKnotVerified marks a knot as verified and sets up ownership/permissions 105 + func MarkKnotVerified(d *db.DB, e *rbac.Enforcer, domain, owner string) error { 106 + tx, err := d.BeginTx(context.Background(), nil) 107 + if err != nil { 108 + return fmt.Errorf("failed to start tx: %w", err) 109 + } 110 + defer func() { 111 + tx.Rollback() 112 + e.E.LoadPolicy() 113 + }() 114 + 115 + // mark as registered 116 + err = db.MarkRegistered( 117 + tx, 118 + db.FilterEq("did", owner), 119 + db.FilterEq("domain", domain), 120 + ) 121 + if err != nil { 122 + return fmt.Errorf("failed to register domain: %w", err) 123 + } 124 + 125 + // add basic acls for this domain 126 + err = e.AddKnot(domain) 127 + if err != nil { 128 + return fmt.Errorf("failed to add knot to enforcer: %w", err) 129 + } 130 + 131 + // add this did as owner of this domain 132 + err = e.AddKnotOwner(domain, owner) 133 + if err != nil { 134 + return fmt.Errorf("failed to add knot owner to enforcer: %w", err) 135 + } 136 + 137 + err = tx.Commit() 138 + if err != nil { 139 + return fmt.Errorf("failed to commit changes: %w", err) 140 + } 141 + 142 + err = e.E.SavePolicy() 143 + if err != nil { 144 + return fmt.Errorf("failed to update ACLs: %w", err) 145 + } 146 + 147 + return nil 148 + }
+46 -11
appview/settings/settings.go
··· 12 12 13 13 "github.com/go-chi/chi/v5" 14 14 "tangled.sh/tangled.sh/core/api/tangled" 15 - "tangled.sh/tangled.sh/core/appview" 16 15 "tangled.sh/tangled.sh/core/appview/config" 17 16 "tangled.sh/tangled.sh/core/appview/db" 18 17 "tangled.sh/tangled.sh/core/appview/email" 19 18 "tangled.sh/tangled.sh/core/appview/middleware" 20 19 "tangled.sh/tangled.sh/core/appview/oauth" 21 20 "tangled.sh/tangled.sh/core/appview/pages" 21 + "tangled.sh/tangled.sh/core/tid" 22 22 23 23 comatproto "github.com/bluesky-social/indigo/api/atproto" 24 24 lexutil "github.com/bluesky-social/indigo/lex/util" ··· 33 33 Config *config.Config 34 34 } 35 35 36 + type tab = map[string]any 37 + 38 + var ( 39 + settingsTabs []tab = []tab{ 40 + {"Name": "profile", "Icon": "user"}, 41 + {"Name": "keys", "Icon": "key"}, 42 + {"Name": "emails", "Icon": "mail"}, 43 + } 44 + ) 45 + 36 46 func (s *Settings) Router() http.Handler { 37 47 r := chi.NewRouter() 38 48 39 49 r.Use(middleware.AuthMiddleware(s.OAuth)) 40 50 41 - r.Get("/", s.settings) 51 + // settings pages 52 + r.Get("/", s.profileSettings) 53 + r.Get("/profile", s.profileSettings) 42 54 43 55 r.Route("/keys", func(r chi.Router) { 56 + r.Get("/", s.keysSettings) 44 57 r.Put("/", s.keys) 45 58 r.Delete("/", s.keys) 46 59 }) 47 60 48 61 r.Route("/emails", func(r chi.Router) { 62 + r.Get("/", s.emailsSettings) 49 63 r.Put("/", s.emails) 50 64 r.Delete("/", s.emails) 51 65 r.Get("/verify", s.emailsVerify) ··· 56 70 return r 57 71 } 58 72 59 - func (s *Settings) settings(w http.ResponseWriter, r *http.Request) { 73 + func (s *Settings) profileSettings(w http.ResponseWriter, r *http.Request) { 74 + user := s.OAuth.GetUser(r) 75 + 76 + s.Pages.UserProfileSettings(w, pages.UserProfileSettingsParams{ 77 + LoggedInUser: user, 78 + Tabs: settingsTabs, 79 + Tab: "profile", 80 + }) 81 + } 82 + 83 + func (s *Settings) keysSettings(w http.ResponseWriter, r *http.Request) { 60 84 user := s.OAuth.GetUser(r) 61 85 pubKeys, err := db.GetPublicKeysForDid(s.Db, user.Did) 62 86 if err != nil { 63 87 log.Println(err) 64 88 } 65 89 90 + s.Pages.UserKeysSettings(w, pages.UserKeysSettingsParams{ 91 + LoggedInUser: user, 92 + PubKeys: pubKeys, 93 + Tabs: settingsTabs, 94 + Tab: "keys", 95 + }) 96 + } 97 + 98 + func (s *Settings) emailsSettings(w http.ResponseWriter, r *http.Request) { 99 + user := s.OAuth.GetUser(r) 66 100 emails, err := db.GetAllEmails(s.Db, user.Did) 67 101 if err != nil { 68 102 log.Println(err) 69 103 } 70 104 71 - s.Pages.Settings(w, pages.SettingsParams{ 105 + s.Pages.UserEmailsSettings(w, pages.UserEmailsSettingsParams{ 72 106 LoggedInUser: user, 73 - PubKeys: pubKeys, 74 107 Emails: emails, 108 + Tabs: settingsTabs, 109 + Tab: "emails", 75 110 }) 76 111 } 77 112 ··· 201 236 return 202 237 } 203 238 204 - s.Pages.HxLocation(w, "/settings") 239 + s.Pages.HxLocation(w, "/settings/emails") 205 240 return 206 241 } 207 242 } ··· 244 279 return 245 280 } 246 281 247 - http.Redirect(w, r, "/settings", http.StatusSeeOther) 282 + http.Redirect(w, r, "/settings/emails", http.StatusSeeOther) 248 283 } 249 284 250 285 func (s *Settings) emailsVerifyResend(w http.ResponseWriter, r *http.Request) { ··· 339 374 return 340 375 } 341 376 342 - s.Pages.HxLocation(w, "/settings") 377 + s.Pages.HxLocation(w, "/settings/emails") 343 378 } 344 379 345 380 func (s *Settings) keys(w http.ResponseWriter, r *http.Request) { ··· 366 401 return 367 402 } 368 403 369 - rkey := appview.TID() 404 + rkey := tid.TID() 370 405 371 406 tx, err := s.Db.Begin() 372 407 if err != nil { ··· 410 445 return 411 446 } 412 447 413 - s.Pages.HxLocation(w, "/settings") 448 + s.Pages.HxLocation(w, "/settings/keys") 414 449 return 415 450 416 451 case http.MethodDelete: ··· 455 490 } 456 491 log.Println("deleted successfully") 457 492 458 - s.Pages.HxLocation(w, "/settings") 493 + s.Pages.HxLocation(w, "/settings/keys") 459 494 return 460 495 } 461 496 }
+104
appview/signup/requests.go
··· 1 + package signup 2 + 3 + // We have this extra code here for now since the xrpcclient package 4 + // only supports OAuth'd requests; these are unauthenticated or use PDS admin auth. 5 + 6 + import ( 7 + "bytes" 8 + "encoding/json" 9 + "fmt" 10 + "io" 11 + "net/http" 12 + "net/url" 13 + ) 14 + 15 + // makePdsRequest is a helper method to make requests to the PDS service 16 + func (s *Signup) makePdsRequest(method, endpoint string, body interface{}, useAuth bool) (*http.Response, error) { 17 + jsonData, err := json.Marshal(body) 18 + if err != nil { 19 + return nil, err 20 + } 21 + 22 + url := fmt.Sprintf("%s/xrpc/%s", s.config.Pds.Host, endpoint) 23 + req, err := http.NewRequest(method, url, bytes.NewBuffer(jsonData)) 24 + if err != nil { 25 + return nil, err 26 + } 27 + 28 + req.Header.Set("Content-Type", "application/json") 29 + 30 + if useAuth { 31 + req.SetBasicAuth("admin", s.config.Pds.AdminSecret) 32 + } 33 + 34 + return http.DefaultClient.Do(req) 35 + } 36 + 37 + // handlePdsError processes error responses from the PDS service 38 + func (s *Signup) handlePdsError(resp *http.Response, action string) error { 39 + var errorResp struct { 40 + Error string `json:"error"` 41 + Message string `json:"message"` 42 + } 43 + 44 + respBody, _ := io.ReadAll(resp.Body) 45 + if err := json.Unmarshal(respBody, &errorResp); err == nil && errorResp.Message != "" { 46 + return fmt.Errorf("Failed to %s: %s - %s.", action, errorResp.Error, errorResp.Message) 47 + } 48 + 49 + // Fallback if we couldn't parse the error 50 + return fmt.Errorf("failed to %s, status code: %d", action, resp.StatusCode) 51 + } 52 + 53 + func (s *Signup) inviteCodeRequest() (string, error) { 54 + body := map[string]any{"useCount": 1} 55 + 56 + resp, err := s.makePdsRequest("POST", "com.atproto.server.createInviteCode", body, true) 57 + if err != nil { 58 + return "", err 59 + } 60 + defer resp.Body.Close() 61 + 62 + if resp.StatusCode != http.StatusOK { 63 + return "", s.handlePdsError(resp, "create invite code") 64 + } 65 + 66 + var result map[string]string 67 + json.NewDecoder(resp.Body).Decode(&result) 68 + return result["code"], nil 69 + } 70 + 71 + func (s *Signup) createAccountRequest(username, password, email, code string) (string, error) { 72 + parsedURL, err := url.Parse(s.config.Pds.Host) 73 + if err != nil { 74 + return "", fmt.Errorf("invalid PDS host URL: %w", err) 75 + } 76 + 77 + pdsDomain := parsedURL.Hostname() 78 + 79 + body := map[string]string{ 80 + "email": email, 81 + "handle": fmt.Sprintf("%s.%s", username, pdsDomain), 82 + "password": password, 83 + "inviteCode": code, 84 + } 85 + 86 + resp, err := s.makePdsRequest("POST", "com.atproto.server.createAccount", body, false) 87 + if err != nil { 88 + return "", err 89 + } 90 + defer resp.Body.Close() 91 + 92 + if resp.StatusCode != http.StatusOK { 93 + return "", s.handlePdsError(resp, "create account") 94 + } 95 + 96 + var result struct { 97 + DID string `json:"did"` 98 + } 99 + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 100 + return "", fmt.Errorf("failed to decode create account response: %w", err) 101 + } 102 + 103 + return result.DID, nil 104 + }
+256
appview/signup/signup.go
··· 1 + package signup 2 + 3 + import ( 4 + "bufio" 5 + "fmt" 6 + "log/slog" 7 + "net/http" 8 + "os" 9 + "strings" 10 + 11 + "github.com/go-chi/chi/v5" 12 + "github.com/posthog/posthog-go" 13 + "tangled.sh/tangled.sh/core/appview/config" 14 + "tangled.sh/tangled.sh/core/appview/db" 15 + "tangled.sh/tangled.sh/core/appview/dns" 16 + "tangled.sh/tangled.sh/core/appview/email" 17 + "tangled.sh/tangled.sh/core/appview/pages" 18 + "tangled.sh/tangled.sh/core/appview/state/userutil" 19 + "tangled.sh/tangled.sh/core/appview/xrpcclient" 20 + "tangled.sh/tangled.sh/core/idresolver" 21 + ) 22 + 23 + type Signup struct { 24 + config *config.Config 25 + db *db.DB 26 + cf *dns.Cloudflare 27 + posthog posthog.Client 28 + xrpc *xrpcclient.Client 29 + idResolver *idresolver.Resolver 30 + pages *pages.Pages 31 + l *slog.Logger 32 + disallowedNicknames map[string]bool 33 + } 34 + 35 + func New(cfg *config.Config, database *db.DB, pc posthog.Client, idResolver *idresolver.Resolver, pages *pages.Pages, l *slog.Logger) *Signup { 36 + var cf *dns.Cloudflare 37 + if cfg.Cloudflare.ApiToken != "" && cfg.Cloudflare.ZoneId != "" { 38 + var err error 39 + cf, err = dns.NewCloudflare(cfg) 40 + if err != nil { 41 + l.Warn("failed to create cloudflare client, signup will be disabled", "error", err) 42 + } 43 + } 44 + 45 + disallowedNicknames := loadDisallowedNicknames(cfg.Core.DisallowedNicknamesFile, l) 46 + 47 + return &Signup{ 48 + config: cfg, 49 + db: database, 50 + posthog: pc, 51 + idResolver: idResolver, 52 + cf: cf, 53 + pages: pages, 54 + l: l, 55 + disallowedNicknames: disallowedNicknames, 56 + } 57 + } 58 + 59 + func loadDisallowedNicknames(filepath string, logger *slog.Logger) map[string]bool { 60 + disallowed := make(map[string]bool) 61 + 62 + if filepath == "" { 63 + logger.Debug("no disallowed nicknames file configured") 64 + return disallowed 65 + } 66 + 67 + file, err := os.Open(filepath) 68 + if err != nil { 69 + logger.Warn("failed to open disallowed nicknames file", "file", filepath, "error", err) 70 + return disallowed 71 + } 72 + defer file.Close() 73 + 74 + scanner := bufio.NewScanner(file) 75 + lineNum := 0 76 + for scanner.Scan() { 77 + lineNum++ 78 + line := strings.TrimSpace(scanner.Text()) 79 + if line == "" || strings.HasPrefix(line, "#") { 80 + continue // skip empty lines and comments 81 + } 82 + 83 + nickname := strings.ToLower(line) 84 + if userutil.IsValidSubdomain(nickname) { 85 + disallowed[nickname] = true 86 + } else { 87 + logger.Warn("invalid nickname format in disallowed nicknames file", 88 + "file", filepath, "line", lineNum, "nickname", nickname) 89 + } 90 + } 91 + 92 + if err := scanner.Err(); err != nil { 93 + logger.Error("error reading disallowed nicknames file", "file", filepath, "error", err) 94 + } 95 + 96 + logger.Info("loaded disallowed nicknames", "count", len(disallowed), "file", filepath) 97 + return disallowed 98 + } 99 + 100 + // isNicknameAllowed checks if a nickname is allowed (not in the disallowed list) 101 + func (s *Signup) isNicknameAllowed(nickname string) bool { 102 + return !s.disallowedNicknames[strings.ToLower(nickname)] 103 + } 104 + 105 + func (s *Signup) Router() http.Handler { 106 + r := chi.NewRouter() 107 + r.Get("/", s.signup) 108 + r.Post("/", s.signup) 109 + r.Get("/complete", s.complete) 110 + r.Post("/complete", s.complete) 111 + 112 + return r 113 + } 114 + 115 + func (s *Signup) signup(w http.ResponseWriter, r *http.Request) { 116 + switch r.Method { 117 + case http.MethodGet: 118 + s.pages.Signup(w) 119 + case http.MethodPost: 120 + if s.cf == nil { 121 + http.Error(w, "signup is disabled", http.StatusFailedDependency) 122 + } 123 + emailId := r.FormValue("email") 124 + 125 + noticeId := "signup-msg" 126 + if !email.IsValidEmail(emailId) { 127 + s.pages.Notice(w, noticeId, "Invalid email address.") 128 + return 129 + } 130 + 131 + exists, err := db.CheckEmailExistsAtAll(s.db, emailId) 132 + if err != nil { 133 + s.l.Error("failed to check email existence", "error", err) 134 + s.pages.Notice(w, noticeId, "Failed to complete signup. Try again later.") 135 + return 136 + } 137 + if exists { 138 + s.pages.Notice(w, noticeId, "Email already exists.") 139 + return 140 + } 141 + 142 + code, err := s.inviteCodeRequest() 143 + if err != nil { 144 + s.l.Error("failed to create invite code", "error", err) 145 + s.pages.Notice(w, noticeId, "Failed to create invite code.") 146 + return 147 + } 148 + 149 + em := email.Email{ 150 + APIKey: s.config.Resend.ApiKey, 151 + From: s.config.Resend.SentFrom, 152 + To: emailId, 153 + Subject: "Verify your Tangled account", 154 + Text: `Copy and paste this code below to verify your account on Tangled. 155 + ` + code, 156 + Html: `<p>Copy and paste this code below to verify your account on Tangled.</p> 157 + <p><code>` + code + `</code></p>`, 158 + } 159 + 160 + err = email.SendEmail(em) 161 + if err != nil { 162 + s.l.Error("failed to send email", "error", err) 163 + s.pages.Notice(w, noticeId, "Failed to send email.") 164 + return 165 + } 166 + err = db.AddInflightSignup(s.db, db.InflightSignup{ 167 + Email: emailId, 168 + InviteCode: code, 169 + }) 170 + if err != nil { 171 + s.l.Error("failed to add inflight signup", "error", err) 172 + s.pages.Notice(w, noticeId, "Failed to complete sign up. Try again later.") 173 + return 174 + } 175 + 176 + s.pages.HxRedirect(w, "/signup/complete") 177 + } 178 + } 179 + 180 + func (s *Signup) complete(w http.ResponseWriter, r *http.Request) { 181 + switch r.Method { 182 + case http.MethodGet: 183 + s.pages.CompleteSignup(w) 184 + case http.MethodPost: 185 + username := r.FormValue("username") 186 + password := r.FormValue("password") 187 + code := r.FormValue("code") 188 + 189 + if !userutil.IsValidSubdomain(username) { 190 + s.pages.Notice(w, "signup-error", "Invalid username. Username must be 4โ€“63 characters, lowercase letters, digits, or hyphens, and can't start or end with a hyphen.") 191 + return 192 + } 193 + 194 + if !s.isNicknameAllowed(username) { 195 + s.pages.Notice(w, "signup-error", "This username is not available. Please choose a different one.") 196 + return 197 + } 198 + 199 + email, err := db.GetEmailForCode(s.db, code) 200 + if err != nil { 201 + s.l.Error("failed to get email for code", "error", err) 202 + s.pages.Notice(w, "signup-error", "Failed to complete sign up. Try again later.") 203 + return 204 + } 205 + 206 + did, err := s.createAccountRequest(username, password, email, code) 207 + if err != nil { 208 + s.l.Error("failed to create account", "error", err) 209 + s.pages.Notice(w, "signup-error", err.Error()) 210 + return 211 + } 212 + 213 + if s.cf == nil { 214 + s.l.Error("cloudflare client is nil", "error", "Cloudflare integration is not enabled in configuration") 215 + s.pages.Notice(w, "signup-error", "Account signup is currently disabled. DNS record creation is not available. Please contact support.") 216 + return 217 + } 218 + 219 + err = s.cf.CreateDNSRecord(r.Context(), dns.Record{ 220 + Type: "TXT", 221 + Name: "_atproto." + username, 222 + Content: fmt.Sprintf(`"did=%s"`, did), 223 + TTL: 6400, 224 + Proxied: false, 225 + }) 226 + if err != nil { 227 + s.l.Error("failed to create DNS record", "error", err) 228 + s.pages.Notice(w, "signup-error", "Failed to create DNS record for your handle. Please contact support.") 229 + return 230 + } 231 + 232 + err = db.AddEmail(s.db, db.Email{ 233 + Did: did, 234 + Address: email, 235 + Verified: true, 236 + Primary: true, 237 + }) 238 + if err != nil { 239 + s.l.Error("failed to add email", "error", err) 240 + s.pages.Notice(w, "signup-error", "Failed to complete sign up. Try again later.") 241 + return 242 + } 243 + 244 + s.pages.Notice(w, "signup-msg", fmt.Sprintf(`Account created successfully. You can now 245 + <a class="underline text-black dark:text-white" href="/login">login</a> 246 + with <code>%s.tngl.sh</code>.`, username)) 247 + 248 + go func() { 249 + err := db.DeleteInflightSignup(s.db, email) 250 + if err != nil { 251 + s.l.Error("failed to delete inflight signup", "error", err) 252 + } 253 + }() 254 + return 255 + } 256 + }
-176
appview/spindleresolver/resolver.go
··· 1 - package spindleresolver 2 - 3 - import ( 4 - "context" 5 - "encoding/json" 6 - "errors" 7 - "fmt" 8 - "io" 9 - "net/http" 10 - "strings" 11 - "time" 12 - 13 - "tangled.sh/tangled.sh/core/api/tangled" 14 - "tangled.sh/tangled.sh/core/appview/cache" 15 - "tangled.sh/tangled.sh/core/appview/idresolver" 16 - 17 - "github.com/bluesky-social/indigo/api/atproto" 18 - "github.com/bluesky-social/indigo/xrpc" 19 - ) 20 - 21 - type ResolutionStatus string 22 - 23 - const ( 24 - StatusOK ResolutionStatus = "ok" 25 - StatusError ResolutionStatus = "error" 26 - StatusInvalid ResolutionStatus = "invalid" 27 - ) 28 - 29 - type Resolution struct { 30 - Status ResolutionStatus `json:"status"` 31 - OwnerDID string `json:"ownerDid,omitempty"` 32 - VerifiedAt time.Time `json:"verifiedAt"` 33 - } 34 - 35 - type Resolver struct { 36 - cache *cache.Cache 37 - http *http.Client 38 - config Config 39 - idResolver *idresolver.Resolver 40 - } 41 - 42 - type Config struct { 43 - HitTTL time.Duration 44 - ErrTTL time.Duration 45 - InvalidTTL time.Duration 46 - Dev bool 47 - } 48 - 49 - func NewResolver(cache *cache.Cache, client *http.Client, config Config) *Resolver { 50 - if client == nil { 51 - client = &http.Client{ 52 - Timeout: 2 * time.Second, 53 - } 54 - } 55 - return &Resolver{ 56 - cache: cache, 57 - http: client, 58 - config: config, 59 - } 60 - } 61 - 62 - func DefaultResolver(cache *cache.Cache) *Resolver { 63 - return NewResolver( 64 - cache, 65 - &http.Client{ 66 - Timeout: 2 * time.Second, 67 - }, 68 - Config{ 69 - HitTTL: 24 * time.Hour, 70 - ErrTTL: 30 * time.Second, 71 - InvalidTTL: 1 * time.Minute, 72 - }, 73 - ) 74 - } 75 - 76 - func (r *Resolver) ResolveInstance(ctx context.Context, domain string) (*Resolution, error) { 77 - key := "spindle:" + domain 78 - 79 - val, err := r.cache.Get(ctx, key).Result() 80 - if err == nil { 81 - var cached Resolution 82 - if err := json.Unmarshal([]byte(val), &cached); err == nil { 83 - return &cached, nil 84 - } 85 - } 86 - 87 - resolution, ttl := r.verify(ctx, domain) 88 - 89 - data, _ := json.Marshal(resolution) 90 - r.cache.Set(ctx, key, data, ttl) 91 - 92 - if resolution.Status == StatusOK { 93 - return resolution, nil 94 - } 95 - 96 - return resolution, fmt.Errorf("verification failed: %s", resolution.Status) 97 - } 98 - 99 - func (r *Resolver) verify(ctx context.Context, domain string) (*Resolution, time.Duration) { 100 - owner, err := r.fetchOwner(ctx, domain) 101 - if err != nil { 102 - return &Resolution{Status: StatusError, VerifiedAt: time.Now()}, r.config.ErrTTL 103 - } 104 - 105 - record, err := r.fetchRecord(ctx, owner, domain) 106 - if err != nil { 107 - return &Resolution{Status: StatusError, VerifiedAt: time.Now()}, r.config.ErrTTL 108 - } 109 - 110 - if record.Instance == domain { 111 - return &Resolution{ 112 - Status: StatusOK, 113 - OwnerDID: owner, 114 - VerifiedAt: time.Now(), 115 - }, r.config.HitTTL 116 - } 117 - 118 - return &Resolution{ 119 - Status: StatusInvalid, 120 - OwnerDID: owner, 121 - VerifiedAt: time.Now(), 122 - }, r.config.InvalidTTL 123 - } 124 - 125 - func (r *Resolver) fetchOwner(ctx context.Context, domain string) (string, error) { 126 - scheme := "https" 127 - if r.config.Dev { 128 - scheme = "http" 129 - } 130 - 131 - url := fmt.Sprintf("%s://%s/owner", scheme, domain) 132 - req, err := http.NewRequest("GET", url, nil) 133 - if err != nil { 134 - return "", err 135 - } 136 - 137 - resp, err := r.http.Do(req.WithContext(ctx)) 138 - if err != nil || resp.StatusCode != 200 { 139 - return "", errors.New("failed to fetch /owner") 140 - } 141 - 142 - body, err := io.ReadAll(io.LimitReader(resp.Body, 1024)) // read atmost 1kb of data 143 - if err != nil { 144 - return "", fmt.Errorf("failed to read /owner response: %w", err) 145 - } 146 - 147 - did := strings.TrimSpace(string(body)) 148 - if did == "" { 149 - return "", errors.New("empty DID in /owner response") 150 - } 151 - 152 - return did, nil 153 - } 154 - 155 - func (r *Resolver) fetchRecord(ctx context.Context, did, rkey string) (*tangled.Spindle, error) { 156 - ident, err := r.idResolver.ResolveIdent(ctx, did) 157 - if err != nil { 158 - return nil, err 159 - } 160 - 161 - xrpcc := xrpc.Client{ 162 - Host: ident.PDSEndpoint(), 163 - } 164 - 165 - rec, err := atproto.RepoGetRecord(ctx, &xrpcc, "", tangled.SpindleNSID, did, rkey) 166 - if err != nil { 167 - return nil, err 168 - } 169 - 170 - out, ok := rec.Value.Val.(*tangled.Spindle) 171 - if !ok { 172 - return nil, fmt.Errorf("invalid record returned") 173 - } 174 - 175 - return out, nil 176 - }
+443 -91
appview/spindles/spindles.go
··· 1 1 package spindles 2 2 3 3 import ( 4 - "context" 5 4 "errors" 6 5 "fmt" 7 - "io" 8 6 "log/slog" 9 7 "net/http" 10 - "strings" 8 + "slices" 11 9 "time" 12 10 13 11 "github.com/go-chi/chi/v5" ··· 17 15 "tangled.sh/tangled.sh/core/appview/middleware" 18 16 "tangled.sh/tangled.sh/core/appview/oauth" 19 17 "tangled.sh/tangled.sh/core/appview/pages" 18 + "tangled.sh/tangled.sh/core/appview/serververify" 19 + "tangled.sh/tangled.sh/core/appview/xrpcclient" 20 + "tangled.sh/tangled.sh/core/idresolver" 20 21 "tangled.sh/tangled.sh/core/rbac" 22 + "tangled.sh/tangled.sh/core/tid" 21 23 22 24 comatproto "github.com/bluesky-social/indigo/api/atproto" 23 25 "github.com/bluesky-social/indigo/atproto/syntax" ··· 25 27 ) 26 28 27 29 type Spindles struct { 28 - Db *db.DB 29 - OAuth *oauth.OAuth 30 - Pages *pages.Pages 31 - Config *config.Config 32 - Enforcer *rbac.Enforcer 33 - Logger *slog.Logger 30 + Db *db.DB 31 + OAuth *oauth.OAuth 32 + Pages *pages.Pages 33 + Config *config.Config 34 + Enforcer *rbac.Enforcer 35 + IdResolver *idresolver.Resolver 36 + Logger *slog.Logger 34 37 } 35 38 36 39 func (s *Spindles) Router() http.Handler { 37 40 r := chi.NewRouter() 38 41 39 - r.Use(middleware.AuthMiddleware(s.OAuth)) 42 + r.With(middleware.AuthMiddleware(s.OAuth)).Get("/", s.spindles) 43 + r.With(middleware.AuthMiddleware(s.OAuth)).Post("/register", s.register) 44 + 45 + r.With(middleware.AuthMiddleware(s.OAuth)).Get("/{instance}", s.dashboard) 46 + r.With(middleware.AuthMiddleware(s.OAuth)).Delete("/{instance}", s.delete) 40 47 41 - r.Get("/", s.spindles) 42 - r.Post("/register", s.register) 43 - r.Delete("/{instance}", s.delete) 44 - r.Post("/{instance}/retry", s.retry) 48 + r.With(middleware.AuthMiddleware(s.OAuth)).Post("/{instance}/retry", s.retry) 49 + r.With(middleware.AuthMiddleware(s.OAuth)).Post("/{instance}/add", s.addMember) 50 + r.With(middleware.AuthMiddleware(s.OAuth)).Post("/{instance}/remove", s.removeMember) 45 51 46 52 return r 47 53 } ··· 64 70 }) 65 71 } 66 72 73 + func (s *Spindles) dashboard(w http.ResponseWriter, r *http.Request) { 74 + l := s.Logger.With("handler", "dashboard") 75 + 76 + user := s.OAuth.GetUser(r) 77 + l = l.With("user", user.Did) 78 + 79 + instance := chi.URLParam(r, "instance") 80 + if instance == "" { 81 + return 82 + } 83 + l = l.With("instance", instance) 84 + 85 + spindles, err := db.GetSpindles( 86 + s.Db, 87 + db.FilterEq("instance", instance), 88 + db.FilterEq("owner", user.Did), 89 + db.FilterIsNot("verified", "null"), 90 + ) 91 + if err != nil || len(spindles) != 1 { 92 + l.Error("failed to get spindle", "err", err, "len(spindles)", len(spindles)) 93 + http.Error(w, "Not found", http.StatusNotFound) 94 + return 95 + } 96 + 97 + spindle := spindles[0] 98 + members, err := s.Enforcer.GetSpindleUsersByRole("server:member", spindle.Instance) 99 + if err != nil { 100 + l.Error("failed to get spindle members", "err", err) 101 + http.Error(w, "Not found", http.StatusInternalServerError) 102 + return 103 + } 104 + slices.Sort(members) 105 + 106 + repos, err := db.GetRepos( 107 + s.Db, 108 + 0, 109 + db.FilterEq("spindle", instance), 110 + ) 111 + if err != nil { 112 + l.Error("failed to get spindle repos", "err", err) 113 + http.Error(w, "Not found", http.StatusInternalServerError) 114 + return 115 + } 116 + 117 + // organize repos by did 118 + repoMap := make(map[string][]db.Repo) 119 + for _, r := range repos { 120 + repoMap[r.Did] = append(repoMap[r.Did], r) 121 + } 122 + 123 + s.Pages.SpindleDashboard(w, pages.SpindleDashboardParams{ 124 + LoggedInUser: user, 125 + Spindle: spindle, 126 + Members: members, 127 + Repos: repoMap, 128 + }) 129 + } 130 + 67 131 // this endpoint inserts a record on behalf of the user to register that domain 68 132 // 69 133 // when registered, it also makes a request to see if the spindle declares this users as its owner, ··· 85 149 s.Pages.Notice(w, noticeId, "Incomplete form.") 86 150 return 87 151 } 152 + l = l.With("instance", instance) 153 + l = l.With("user", user.Did) 88 154 89 155 tx, err := s.Db.Begin() 90 156 if err != nil { ··· 92 158 fail() 93 159 return 94 160 } 95 - defer tx.Rollback() 161 + defer func() { 162 + tx.Rollback() 163 + s.Enforcer.E.LoadPolicy() 164 + }() 96 165 97 166 err = db.AddSpindle(tx, db.Spindle{ 98 167 Owner: syntax.DID(user.Did), ··· 104 173 return 105 174 } 106 175 176 + err = s.Enforcer.AddSpindle(instance) 177 + if err != nil { 178 + l.Error("failed to create spindle", "err", err) 179 + fail() 180 + return 181 + } 182 + 107 183 // create record on pds 108 184 client, err := s.OAuth.AuthorizedClient(r) 109 185 if err != nil { ··· 144 220 return 145 221 } 146 222 147 - // begin verification 148 - expectedOwner, err := fetchOwner(r.Context(), instance, s.Config.Core.Dev) 149 - if err != nil { 150 - l.Error("verification failed", "err", err) 151 - 152 - // just refresh the page 153 - s.Pages.HxRefresh(w) 154 - return 155 - } 156 - 157 - if expectedOwner != user.Did { 158 - // verification failed 159 - l.Error("verification failed", "expectedOwner", expectedOwner, "observedOwner", user.Did) 160 - s.Pages.HxRefresh(w) 161 - return 162 - } 163 - 164 - tx, err = s.Db.Begin() 165 - if err != nil { 166 - l.Error("failed to commit verification info", "err", err) 167 - s.Pages.HxRefresh(w) 168 - return 169 - } 170 - defer func() { 171 - tx.Rollback() 172 - s.Enforcer.E.LoadPolicy() 173 - }() 174 - 175 - // mark this spindle as verified in the db 176 - _, err = db.VerifySpindle( 177 - tx, 178 - db.FilterEq("owner", user.Did), 179 - db.FilterEq("instance", instance), 180 - ) 181 - 182 - err = s.Enforcer.AddSpindleOwner(instance, user.Did) 223 + err = s.Enforcer.E.SavePolicy() 183 224 if err != nil { 184 225 l.Error("failed to update ACL", "err", err) 185 226 s.Pages.HxRefresh(w) 186 227 return 187 228 } 188 229 189 - err = tx.Commit() 230 + // begin verification 231 + err = serververify.RunVerification(r.Context(), instance, user.Did, s.Config.Core.Dev) 190 232 if err != nil { 191 - l.Error("failed to commit verification info", "err", err) 233 + l.Error("verification failed", "err", err) 192 234 s.Pages.HxRefresh(w) 193 235 return 194 236 } 195 237 196 - err = s.Enforcer.E.SavePolicy() 238 + _, err = serververify.MarkSpindleVerified(s.Db, s.Enforcer, instance, user.Did) 197 239 if err != nil { 198 - l.Error("failed to update ACL", "err", err) 240 + l.Error("failed to mark verified", "err", err) 199 241 s.Pages.HxRefresh(w) 200 242 return 201 243 } 202 244 203 245 // ok 204 246 s.Pages.HxRefresh(w) 205 - return 206 247 } 207 248 208 249 func (s *Spindles) delete(w http.ResponseWriter, r *http.Request) { 209 250 user := s.OAuth.GetUser(r) 210 - l := s.Logger.With("handler", "register") 251 + l := s.Logger.With("handler", "delete") 211 252 212 253 noticeId := "operation-error" 213 254 defaultErr := "Failed to delete spindle. Try again later." ··· 222 263 return 223 264 } 224 265 266 + spindles, err := db.GetSpindles( 267 + s.Db, 268 + db.FilterEq("owner", user.Did), 269 + db.FilterEq("instance", instance), 270 + ) 271 + if err != nil || len(spindles) != 1 { 272 + l.Error("failed to retrieve instance", "err", err, "len(spindles)", len(spindles)) 273 + fail() 274 + return 275 + } 276 + 277 + if string(spindles[0].Owner) != user.Did { 278 + l.Error("unauthorized", "user", user.Did, "owner", spindles[0].Owner) 279 + s.Pages.Notice(w, noticeId, "Failed to delete spindle, unauthorized deletion attempt.") 280 + return 281 + } 282 + 225 283 tx, err := s.Db.Begin() 226 284 if err != nil { 227 285 l.Error("failed to start txn", "err", err) 228 286 fail() 229 287 return 230 288 } 231 - defer tx.Rollback() 289 + defer func() { 290 + tx.Rollback() 291 + s.Enforcer.E.LoadPolicy() 292 + }() 293 + 294 + // remove spindle members first 295 + err = db.RemoveSpindleMember( 296 + tx, 297 + db.FilterEq("did", user.Did), 298 + db.FilterEq("instance", instance), 299 + ) 300 + if err != nil { 301 + l.Error("failed to remove spindle members", "err", err) 302 + fail() 303 + return 304 + } 232 305 233 306 err = db.DeleteSpindle( 234 307 tx, ··· 239 312 l.Error("failed to delete spindle", "err", err) 240 313 fail() 241 314 return 315 + } 316 + 317 + // delete from enforcer 318 + if spindles[0].Verified != nil { 319 + err = s.Enforcer.RemoveSpindle(instance) 320 + if err != nil { 321 + l.Error("failed to update ACL", "err", err) 322 + fail() 323 + return 324 + } 242 325 } 243 326 244 327 client, err := s.OAuth.AuthorizedClient(r) ··· 265 348 return 266 349 } 267 350 351 + err = s.Enforcer.E.SavePolicy() 352 + if err != nil { 353 + l.Error("failed to update ACL", "err", err) 354 + s.Pages.HxRefresh(w) 355 + return 356 + } 357 + 358 + shouldRedirect := r.Header.Get("shouldRedirect") 359 + if shouldRedirect == "true" { 360 + s.Pages.HxRedirect(w, "/spindles") 361 + return 362 + } 363 + 268 364 w.Write([]byte{}) 269 365 } 270 366 271 367 func (s *Spindles) retry(w http.ResponseWriter, r *http.Request) { 272 368 user := s.OAuth.GetUser(r) 273 - l := s.Logger.With("handler", "register") 369 + l := s.Logger.With("handler", "retry") 274 370 275 371 noticeId := "operation-error" 276 372 defaultErr := "Failed to verify spindle. Try again later." ··· 284 380 fail() 285 381 return 286 382 } 383 + l = l.With("instance", instance) 384 + l = l.With("user", user.Did) 385 + 386 + spindles, err := db.GetSpindles( 387 + s.Db, 388 + db.FilterEq("owner", user.Did), 389 + db.FilterEq("instance", instance), 390 + ) 391 + if err != nil || len(spindles) != 1 { 392 + l.Error("failed to retrieve instance", "err", err, "len(spindles)", len(spindles)) 393 + fail() 394 + return 395 + } 396 + 397 + if string(spindles[0].Owner) != user.Did { 398 + l.Error("unauthorized", "user", user.Did, "owner", spindles[0].Owner) 399 + s.Pages.Notice(w, noticeId, "Failed to verify spindle, unauthorized verification attempt.") 400 + return 401 + } 287 402 288 403 // begin verification 289 - expectedOwner, err := fetchOwner(r.Context(), instance, s.Config.Core.Dev) 404 + err = serververify.RunVerification(r.Context(), instance, user.Did, s.Config.Core.Dev) 290 405 if err != nil { 291 406 l.Error("verification failed", "err", err) 407 + 408 + if errors.Is(err, xrpcclient.ErrXrpcUnsupported) { 409 + s.Pages.Notice(w, noticeId, "Failed to verify spindle, XRPC queries are unsupported on this spindle, consider upgrading!") 410 + return 411 + } 412 + 413 + if e, ok := err.(*serververify.OwnerMismatch); ok { 414 + s.Pages.Notice(w, noticeId, e.Error()) 415 + return 416 + } 417 + 292 418 fail() 293 419 return 294 420 } 295 421 296 - if expectedOwner != user.Did { 297 - l.Error("verification failed", "expectedOwner", expectedOwner, "observedOwner", user.Did) 298 - s.Pages.Notice(w, noticeId, fmt.Sprintf("Owner did not match, expected %s, got %s", expectedOwner, user.Did)) 422 + rowId, err := serververify.MarkSpindleVerified(s.Db, s.Enforcer, instance, user.Did) 423 + if err != nil { 424 + l.Error("failed to mark verified", "err", err) 425 + s.Pages.Notice(w, noticeId, err.Error()) 299 426 return 300 427 } 301 428 302 - // mark this spindle as verified in the db 303 - rowId, err := db.VerifySpindle( 429 + verifiedSpindle, err := db.GetSpindles( 430 + s.Db, 431 + db.FilterEq("id", rowId), 432 + ) 433 + if err != nil || len(verifiedSpindle) != 1 { 434 + l.Error("failed get new spindle", "err", err) 435 + s.Pages.HxRefresh(w) 436 + return 437 + } 438 + 439 + shouldRefresh := r.Header.Get("shouldRefresh") 440 + if shouldRefresh == "true" { 441 + s.Pages.HxRefresh(w) 442 + return 443 + } 444 + 445 + w.Header().Set("HX-Reswap", "outerHTML") 446 + s.Pages.SpindleListing(w, pages.SpindleListingParams{Spindle: verifiedSpindle[0]}) 447 + } 448 + 449 + func (s *Spindles) addMember(w http.ResponseWriter, r *http.Request) { 450 + user := s.OAuth.GetUser(r) 451 + l := s.Logger.With("handler", "addMember") 452 + 453 + instance := chi.URLParam(r, "instance") 454 + if instance == "" { 455 + l.Error("empty instance") 456 + http.Error(w, "Not found", http.StatusNotFound) 457 + return 458 + } 459 + l = l.With("instance", instance) 460 + l = l.With("user", user.Did) 461 + 462 + spindles, err := db.GetSpindles( 304 463 s.Db, 305 464 db.FilterEq("owner", user.Did), 306 465 db.FilterEq("instance", instance), 307 466 ) 467 + if err != nil || len(spindles) != 1 { 468 + l.Error("failed to retrieve instance", "err", err, "len(spindles)", len(spindles)) 469 + http.Error(w, "Not found", http.StatusNotFound) 470 + return 471 + } 472 + 473 + noticeId := fmt.Sprintf("add-member-error-%d", spindles[0].Id) 474 + defaultErr := "Failed to add member. Try again later." 475 + fail := func() { 476 + s.Pages.Notice(w, noticeId, defaultErr) 477 + } 478 + 479 + if string(spindles[0].Owner) != user.Did { 480 + l.Error("unauthorized", "user", user.Did, "owner", spindles[0].Owner) 481 + s.Pages.Notice(w, noticeId, "Failed to add member, unauthorized attempt.") 482 + return 483 + } 484 + 485 + member := r.FormValue("member") 486 + if member == "" { 487 + l.Error("empty member") 488 + s.Pages.Notice(w, noticeId, "Failed to add member, empty form.") 489 + return 490 + } 491 + l = l.With("member", member) 492 + 493 + memberId, err := s.IdResolver.ResolveIdent(r.Context(), member) 308 494 if err != nil { 309 - l.Error("verification failed", "err", err) 495 + l.Error("failed to resolve member identity to handle", "err", err) 496 + s.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.") 497 + return 498 + } 499 + if memberId.Handle.IsInvalidHandle() { 500 + l.Error("failed to resolve member identity to handle") 501 + s.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.") 502 + return 503 + } 504 + 505 + // write to pds 506 + client, err := s.OAuth.AuthorizedClient(r) 507 + if err != nil { 508 + l.Error("failed to authorize client", "err", err) 310 509 fail() 311 510 return 312 511 } 313 512 314 - verifiedSpindle := db.Spindle{ 315 - Id: int(rowId), 316 - Owner: syntax.DID(user.Did), 513 + tx, err := s.Db.Begin() 514 + if err != nil { 515 + l.Error("failed to start txn", "err", err) 516 + fail() 517 + return 518 + } 519 + defer func() { 520 + tx.Rollback() 521 + s.Enforcer.E.LoadPolicy() 522 + }() 523 + 524 + rkey := tid.TID() 525 + 526 + // add member to db 527 + if err = db.AddSpindleMember(tx, db.SpindleMember{ 528 + Did: syntax.DID(user.Did), 529 + Rkey: rkey, 317 530 Instance: instance, 531 + Subject: memberId.DID, 532 + }); err != nil { 533 + l.Error("failed to add spindle member", "err", err) 534 + fail() 535 + return 318 536 } 319 537 320 - w.Header().Set("HX-Reswap", "outerHTML") 321 - s.Pages.SpindleListing(w, pages.SpindleListingParams{ 322 - LoggedInUser: user, 323 - Spindle: verifiedSpindle, 538 + if err = s.Enforcer.AddSpindleMember(instance, memberId.DID.String()); err != nil { 539 + l.Error("failed to add member to ACLs") 540 + fail() 541 + return 542 + } 543 + 544 + _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 545 + Collection: tangled.SpindleMemberNSID, 546 + Repo: user.Did, 547 + Rkey: rkey, 548 + Record: &lexutil.LexiconTypeDecoder{ 549 + Val: &tangled.SpindleMember{ 550 + CreatedAt: time.Now().Format(time.RFC3339), 551 + Instance: instance, 552 + Subject: memberId.DID.String(), 553 + }, 554 + }, 324 555 }) 556 + if err != nil { 557 + l.Error("failed to add record to PDS", "err", err) 558 + s.Pages.Notice(w, noticeId, "Failed to add record to PDS, try again later.") 559 + return 560 + } 561 + 562 + if err = tx.Commit(); err != nil { 563 + l.Error("failed to commit txn", "err", err) 564 + fail() 565 + return 566 + } 567 + 568 + if err = s.Enforcer.E.SavePolicy(); err != nil { 569 + l.Error("failed to add member to ACLs", "err", err) 570 + fail() 571 + return 572 + } 573 + 574 + // success 575 + s.Pages.HxRedirect(w, fmt.Sprintf("/spindles/%s", instance)) 325 576 } 326 577 327 - func fetchOwner(ctx context.Context, domain string, dev bool) (string, error) { 328 - scheme := "https" 329 - if dev { 330 - scheme = "http" 578 + func (s *Spindles) removeMember(w http.ResponseWriter, r *http.Request) { 579 + user := s.OAuth.GetUser(r) 580 + l := s.Logger.With("handler", "removeMember") 581 + 582 + noticeId := "operation-error" 583 + defaultErr := "Failed to remove member. Try again later." 584 + fail := func() { 585 + s.Pages.Notice(w, noticeId, defaultErr) 586 + } 587 + 588 + instance := chi.URLParam(r, "instance") 589 + if instance == "" { 590 + l.Error("empty instance") 591 + fail() 592 + return 593 + } 594 + l = l.With("instance", instance) 595 + l = l.With("user", user.Did) 596 + 597 + spindles, err := db.GetSpindles( 598 + s.Db, 599 + db.FilterEq("owner", user.Did), 600 + db.FilterEq("instance", instance), 601 + ) 602 + if err != nil || len(spindles) != 1 { 603 + l.Error("failed to retrieve instance", "err", err, "len(spindles)", len(spindles)) 604 + fail() 605 + return 606 + } 607 + 608 + if string(spindles[0].Owner) != user.Did { 609 + l.Error("unauthorized", "user", user.Did, "owner", spindles[0].Owner) 610 + s.Pages.Notice(w, noticeId, "Failed to remove member, unauthorized attempt.") 611 + return 612 + } 613 + 614 + member := r.FormValue("member") 615 + if member == "" { 616 + l.Error("empty member") 617 + s.Pages.Notice(w, noticeId, "Failed to remove member, empty form.") 618 + return 619 + } 620 + l = l.With("member", member) 621 + 622 + memberId, err := s.IdResolver.ResolveIdent(r.Context(), member) 623 + if err != nil { 624 + l.Error("failed to resolve member identity to handle", "err", err) 625 + s.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.") 626 + return 627 + } 628 + if memberId.Handle.IsInvalidHandle() { 629 + l.Error("failed to resolve member identity to handle") 630 + s.Pages.Notice(w, noticeId, "Failed to remove member, identity resolution failed.") 631 + return 331 632 } 332 633 333 - url := fmt.Sprintf("%s://%s/owner", scheme, domain) 334 - req, err := http.NewRequest("GET", url, nil) 634 + tx, err := s.Db.Begin() 335 635 if err != nil { 336 - return "", err 636 + l.Error("failed to start txn", "err", err) 637 + fail() 638 + return 639 + } 640 + defer func() { 641 + tx.Rollback() 642 + s.Enforcer.E.LoadPolicy() 643 + }() 644 + 645 + // get the record from the DB first: 646 + members, err := db.GetSpindleMembers( 647 + s.Db, 648 + db.FilterEq("did", user.Did), 649 + db.FilterEq("instance", instance), 650 + db.FilterEq("subject", memberId.DID), 651 + ) 652 + if err != nil || len(members) != 1 { 653 + l.Error("failed to get member", "err", err) 654 + fail() 655 + return 337 656 } 338 657 339 - client := &http.Client{ 340 - Timeout: 1 * time.Second, 658 + // remove from db 659 + if err = db.RemoveSpindleMember( 660 + tx, 661 + db.FilterEq("did", user.Did), 662 + db.FilterEq("instance", instance), 663 + db.FilterEq("subject", memberId.DID), 664 + ); err != nil { 665 + l.Error("failed to remove spindle member", "err", err) 666 + fail() 667 + return 341 668 } 342 669 343 - resp, err := client.Do(req.WithContext(ctx)) 344 - if err != nil || resp.StatusCode != 200 { 345 - return "", errors.New("failed to fetch /owner") 670 + // remove from enforcer 671 + if err = s.Enforcer.RemoveSpindleMember(instance, memberId.DID.String()); err != nil { 672 + l.Error("failed to update ACLs", "err", err) 673 + fail() 674 + return 346 675 } 347 676 348 - body, err := io.ReadAll(io.LimitReader(resp.Body, 1024)) // read atmost 1kb of data 677 + client, err := s.OAuth.AuthorizedClient(r) 349 678 if err != nil { 350 - return "", fmt.Errorf("failed to read /owner response: %w", err) 679 + l.Error("failed to authorize client", "err", err) 680 + fail() 681 + return 682 + } 683 + 684 + // remove from pds 685 + _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 686 + Collection: tangled.SpindleMemberNSID, 687 + Repo: user.Did, 688 + Rkey: members[0].Rkey, 689 + }) 690 + if err != nil { 691 + // non-fatal 692 + l.Error("failed to delete record", "err", err) 693 + } 694 + 695 + // commit everything 696 + if err = tx.Commit(); err != nil { 697 + l.Error("failed to commit txn", "err", err) 698 + fail() 699 + return 351 700 } 352 701 353 - did := strings.TrimSpace(string(body)) 354 - if did == "" { 355 - return "", errors.New("empty DID in /owner response") 702 + // commit everything 703 + if err = s.Enforcer.E.SavePolicy(); err != nil { 704 + l.Error("failed to save ACLs", "err", err) 705 + fail() 706 + return 356 707 } 357 708 358 - return did, nil 709 + // ok 710 + s.Pages.HxRefresh(w) 359 711 }
+13 -26
appview/state/follow.go
··· 7 7 8 8 comatproto "github.com/bluesky-social/indigo/api/atproto" 9 9 lexutil "github.com/bluesky-social/indigo/lex/util" 10 - "github.com/posthog/posthog-go" 11 10 "tangled.sh/tangled.sh/core/api/tangled" 12 - "tangled.sh/tangled.sh/core/appview" 13 11 "tangled.sh/tangled.sh/core/appview/db" 14 12 "tangled.sh/tangled.sh/core/appview/pages" 13 + "tangled.sh/tangled.sh/core/tid" 15 14 ) 16 15 17 16 func (s *State) Follow(w http.ResponseWriter, r *http.Request) { ··· 42 41 switch r.Method { 43 42 case http.MethodPost: 44 43 createdAt := time.Now().Format(time.RFC3339) 45 - rkey := appview.TID() 44 + rkey := tid.TID() 46 45 resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 47 46 Collection: tangled.GraphFollowNSID, 48 47 Repo: currentUser.Did, ··· 58 57 return 59 58 } 60 59 61 - err = db.AddFollow(s.db, currentUser.Did, subjectIdent.DID.String(), rkey) 60 + log.Println("created atproto record: ", resp.Uri) 61 + 62 + follow := &db.Follow{ 63 + UserDid: currentUser.Did, 64 + SubjectDid: subjectIdent.DID.String(), 65 + Rkey: rkey, 66 + } 67 + 68 + err = db.AddFollow(s.db, follow) 62 69 if err != nil { 63 70 log.Println("failed to follow", err) 64 71 return 65 72 } 66 73 67 - log.Println("created atproto record: ", resp.Uri) 74 + s.notifier.NewFollow(r.Context(), follow) 68 75 69 76 s.pages.FollowFragment(w, pages.FollowFragmentParams{ 70 77 UserDid: subjectIdent.DID.String(), 71 78 FollowStatus: db.IsFollowing, 72 79 }) 73 80 74 - if !s.config.Core.Dev { 75 - err = s.posthog.Enqueue(posthog.Capture{ 76 - DistinctId: currentUser.Did, 77 - Event: "follow", 78 - Properties: posthog.Properties{"subject": subjectIdent.DID.String()}, 79 - }) 80 - if err != nil { 81 - log.Println("failed to enqueue posthog event:", err) 82 - } 83 - } 84 - 85 81 return 86 82 case http.MethodDelete: 87 83 // find the record in the db ··· 113 109 FollowStatus: db.IsNotFollowing, 114 110 }) 115 111 116 - if !s.config.Core.Dev { 117 - err = s.posthog.Enqueue(posthog.Capture{ 118 - DistinctId: currentUser.Did, 119 - Event: "unfollow", 120 - Properties: posthog.Properties{"subject": subjectIdent.DID.String()}, 121 - }) 122 - if err != nil { 123 - log.Println("failed to enqueue posthog event:", err) 124 - } 125 - } 112 + s.notifier.DeleteFollow(r.Context(), follow) 126 113 127 114 return 128 115 }
+9 -12
appview/state/git_http.go
··· 3 3 import ( 4 4 "fmt" 5 5 "io" 6 + "maps" 6 7 "net/http" 7 8 8 9 "github.com/bluesky-social/indigo/atproto/identity" 9 10 "github.com/go-chi/chi/v5" 11 + "tangled.sh/tangled.sh/core/appview/db" 10 12 ) 11 13 12 14 func (s *State) InfoRefs(w http.ResponseWriter, r *http.Request) { 13 15 user := r.Context().Value("resolvedId").(identity.Identity) 14 - knot := r.Context().Value("knot").(string) 15 - repo := chi.URLParam(r, "repo") 16 + repo := r.Context().Value("repo").(*db.Repo) 16 17 17 18 scheme := "https" 18 19 if s.config.Core.Dev { 19 20 scheme = "http" 20 21 } 21 22 22 - targetURL := fmt.Sprintf("%s://%s/%s/%s/info/refs?%s", scheme, knot, user.DID, repo, r.URL.RawQuery) 23 + targetURL := fmt.Sprintf("%s://%s/%s/%s/info/refs?%s", scheme, repo.Knot, user.DID, repo.Name, r.URL.RawQuery) 23 24 s.proxyRequest(w, r, targetURL) 24 25 25 26 } ··· 30 31 http.Error(w, "failed to resolve user", http.StatusInternalServerError) 31 32 return 32 33 } 33 - knot := r.Context().Value("knot").(string) 34 - repo := chi.URLParam(r, "repo") 34 + repo := r.Context().Value("repo").(*db.Repo) 35 35 36 36 scheme := "https" 37 37 if s.config.Core.Dev { 38 38 scheme = "http" 39 39 } 40 40 41 - targetURL := fmt.Sprintf("%s://%s/%s/%s/git-upload-pack?%s", scheme, knot, user.DID, repo, r.URL.RawQuery) 41 + targetURL := fmt.Sprintf("%s://%s/%s/%s/git-upload-pack?%s", scheme, repo.Knot, user.DID, repo.Name, r.URL.RawQuery) 42 42 s.proxyRequest(w, r, targetURL) 43 43 } 44 44 ··· 48 48 http.Error(w, "failed to resolve user", http.StatusInternalServerError) 49 49 return 50 50 } 51 - knot := r.Context().Value("knot").(string) 52 - repo := chi.URLParam(r, "repo") 51 + repo := r.Context().Value("repo").(*db.Repo) 53 52 54 53 scheme := "https" 55 54 if s.config.Core.Dev { 56 55 scheme = "http" 57 56 } 58 57 59 - targetURL := fmt.Sprintf("%s://%s/%s/%s/git-receive-pack?%s", scheme, knot, user.DID, repo, r.URL.RawQuery) 58 + targetURL := fmt.Sprintf("%s://%s/%s/%s/git-receive-pack?%s", scheme, repo.Knot, user.DID, repo.Name, r.URL.RawQuery) 60 59 s.proxyRequest(w, r, targetURL) 61 60 } 62 61 ··· 85 84 defer resp.Body.Close() 86 85 87 86 // Copy response headers 88 - for k, v := range resp.Header { 89 - w.Header()[k] = v 90 - } 87 + maps.Copy(w.Header(), resp.Header) 91 88 92 89 // Set response status code 93 90 w.WriteHeader(resp.StatusCode)
+87 -22
appview/state/knotstream.go
··· 3 3 import ( 4 4 "context" 5 5 "encoding/json" 6 + "errors" 6 7 "fmt" 7 8 "slices" 8 9 "time" ··· 18 19 "tangled.sh/tangled.sh/core/workflow" 19 20 20 21 "github.com/bluesky-social/indigo/atproto/syntax" 22 + "github.com/go-git/go-git/v5/plumbing" 21 23 "github.com/posthog/posthog-go" 22 24 ) 23 25 24 26 func Knotstream(ctx context.Context, c *config.Config, d *db.DB, enforcer *rbac.Enforcer, posthog posthog.Client) (*ec.Consumer, error) { 25 - knots, err := db.GetCompletedRegistrations(d) 27 + knots, err := db.GetRegistrations( 28 + d, 29 + db.FilterIsNot("registered", "null"), 30 + ) 26 31 if err != nil { 27 32 return nil, err 28 33 } 29 34 30 35 srcs := make(map[ec.Source]struct{}) 31 36 for _, k := range knots { 32 - s := ec.NewKnotSource(k) 37 + s := ec.NewKnotSource(k.Domain) 33 38 srcs[s] = struct{}{} 34 39 } 35 40 ··· 39 44 40 45 cfg := ec.ConsumerConfig{ 41 46 Sources: srcs, 42 - ProcessFunc: knotIngester(ctx, d, enforcer, posthog, c.Core.Dev), 47 + ProcessFunc: knotIngester(d, enforcer, posthog, c.Core.Dev), 43 48 RetryInterval: c.Knotstream.RetryInterval, 44 49 MaxRetryInterval: c.Knotstream.MaxRetryInterval, 45 50 ConnectionTimeout: c.Knotstream.ConnectionTimeout, ··· 53 58 return ec.NewConsumer(cfg), nil 54 59 } 55 60 56 - func knotIngester(ctx context.Context, d *db.DB, enforcer *rbac.Enforcer, posthog posthog.Client, dev bool) ec.ProcessFunc { 61 + func knotIngester(d *db.DB, enforcer *rbac.Enforcer, posthog posthog.Client, dev bool) ec.ProcessFunc { 57 62 return func(ctx context.Context, source ec.Source, msg ec.Message) error { 58 63 switch msg.Nsid { 59 64 case tangled.GitRefUpdateNSID: ··· 81 86 return fmt.Errorf("%s does not belong to %s, something is fishy", record.CommitterDid, source.Key()) 82 87 } 83 88 89 + err1 := populatePunchcard(d, record) 90 + err2 := updateRepoLanguages(d, record) 91 + 92 + var err3 error 93 + if !dev { 94 + err3 = pc.Enqueue(posthog.Capture{ 95 + DistinctId: record.CommitterDid, 96 + Event: "git_ref_update", 97 + }) 98 + } 99 + 100 + return errors.Join(err1, err2, err3) 101 + } 102 + 103 + func populatePunchcard(d *db.DB, record tangled.GitRefUpdate) error { 84 104 knownEmails, err := db.GetAllEmails(d, record.CommitterDid) 85 105 if err != nil { 86 106 return err 87 107 } 108 + 88 109 count := 0 89 110 for _, ke := range knownEmails { 90 111 if record.Meta == nil { ··· 108 129 Date: time.Now(), 109 130 Count: count, 110 131 } 111 - if err := db.AddPunch(d, punch); err != nil { 112 - return err 132 + return db.AddPunch(d, punch) 133 + } 134 + 135 + func updateRepoLanguages(d *db.DB, record tangled.GitRefUpdate) error { 136 + if record.Meta == nil || record.Meta.LangBreakdown == nil || record.Meta.LangBreakdown.Inputs == nil { 137 + return fmt.Errorf("empty language data for repo: %s/%s", record.RepoDid, record.RepoName) 138 + } 139 + 140 + repos, err := db.GetRepos( 141 + d, 142 + 0, 143 + db.FilterEq("did", record.RepoDid), 144 + db.FilterEq("name", record.RepoName), 145 + ) 146 + if err != nil { 147 + return fmt.Errorf("failed to look for repo in DB (%s/%s): %w", record.RepoDid, record.RepoName, err) 148 + } 149 + if len(repos) != 1 { 150 + return fmt.Errorf("incorrect number of repos returned: %d (expected 1)", len(repos)) 151 + } 152 + repo := repos[0] 153 + 154 + ref := plumbing.ReferenceName(record.Ref) 155 + if !ref.IsBranch() { 156 + return fmt.Errorf("%s is not a valid reference name", ref) 113 157 } 114 158 115 - if !dev { 116 - err = pc.Enqueue(posthog.Capture{ 117 - DistinctId: record.CommitterDid, 118 - Event: "git_ref_update", 159 + var langs []db.RepoLanguage 160 + for _, l := range record.Meta.LangBreakdown.Inputs { 161 + if l == nil { 162 + continue 163 + } 164 + 165 + langs = append(langs, db.RepoLanguage{ 166 + RepoAt: repo.RepoAt(), 167 + Ref: ref.Short(), 168 + IsDefaultRef: record.Meta.IsDefaultRef, 169 + Language: l.Lang, 170 + Bytes: l.Size, 119 171 }) 120 - if err != nil { 121 - // non-fatal, TODO: log this 122 - } 123 172 } 124 173 125 - return nil 174 + return db.InsertRepoLanguages(d, langs) 126 175 } 127 176 128 177 func ingestPipeline(d *db.DB, source ec.Source, msg ec.Message) error { ··· 140 189 return fmt.Errorf("empty repo: nsid %s, rkey %s", msg.Nsid, msg.Rkey) 141 190 } 142 191 192 + // does this repo have a spindle configured? 193 + repos, err := db.GetRepos( 194 + d, 195 + 0, 196 + db.FilterEq("did", record.TriggerMetadata.Repo.Did), 197 + db.FilterEq("name", record.TriggerMetadata.Repo.Repo), 198 + ) 199 + if err != nil { 200 + return fmt.Errorf("failed to look for repo in DB: nsid %s, rkey %s, %w", msg.Nsid, msg.Rkey, err) 201 + } 202 + if len(repos) != 1 { 203 + return fmt.Errorf("incorrect number of repos returned: %d (expected 1)", len(repos)) 204 + } 205 + if repos[0].Spindle == "" { 206 + return fmt.Errorf("repo does not have a spindle configured yet: nsid %s, rkey %s", msg.Nsid, msg.Rkey) 207 + } 208 + 143 209 // trigger info 144 210 var trigger db.Trigger 145 211 var sha string 146 - switch record.TriggerMetadata.Kind { 212 + trigger.Kind = workflow.TriggerKind(record.TriggerMetadata.Kind) 213 + switch trigger.Kind { 147 214 case workflow.TriggerKindPush: 148 - trigger.Kind = workflow.TriggerKindPush 149 215 trigger.PushRef = &record.TriggerMetadata.Push.Ref 150 216 trigger.PushNewSha = &record.TriggerMetadata.Push.NewSha 151 217 trigger.PushOldSha = &record.TriggerMetadata.Push.OldSha 152 218 sha = *trigger.PushNewSha 153 219 case workflow.TriggerKindPullRequest: 154 - trigger.Kind = workflow.TriggerKindPush 155 220 trigger.PRSourceBranch = &record.TriggerMetadata.PullRequest.SourceBranch 156 221 trigger.PRTargetBranch = &record.TriggerMetadata.PullRequest.TargetBranch 157 222 trigger.PRSourceSha = &record.TriggerMetadata.PullRequest.SourceSha ··· 161 226 162 227 tx, err := d.Begin() 163 228 if err != nil { 164 - return err 229 + return fmt.Errorf("failed to start txn: %w", err) 165 230 } 166 231 167 232 triggerId, err := db.AddTrigger(tx, trigger) 168 233 if err != nil { 169 - return err 234 + return fmt.Errorf("failed to add trigger entry: %w", err) 170 235 } 171 236 172 237 pipeline := db.Pipeline{ ··· 180 245 181 246 err = db.AddPipeline(tx, pipeline) 182 247 if err != nil { 183 - return err 248 + return fmt.Errorf("failed to add pipeline: %w", err) 184 249 } 185 250 186 251 err = tx.Commit() 187 252 if err != nil { 188 - return err 253 + return fmt.Errorf("failed to commit txn: %w", err) 189 254 } 190 255 191 - return err 256 + return nil 192 257 }
+423 -134
appview/state/profile.go
··· 1 1 package state 2 2 3 3 import ( 4 - "crypto/hmac" 5 - "crypto/sha256" 6 - "encoding/hex" 4 + "context" 7 5 "fmt" 8 6 "log" 9 7 "net/http" ··· 16 14 "github.com/bluesky-social/indigo/atproto/syntax" 17 15 lexutil "github.com/bluesky-social/indigo/lex/util" 18 16 "github.com/go-chi/chi/v5" 19 - "github.com/posthog/posthog-go" 17 + "github.com/gorilla/feeds" 20 18 "tangled.sh/tangled.sh/core/api/tangled" 21 19 "tangled.sh/tangled.sh/core/appview/db" 22 20 "tangled.sh/tangled.sh/core/appview/pages" ··· 25 23 func (s *State) Profile(w http.ResponseWriter, r *http.Request) { 26 24 tabVal := r.URL.Query().Get("tab") 27 25 switch tabVal { 28 - case "": 29 - s.profilePage(w, r) 30 26 case "repos": 31 27 s.reposPage(w, r) 28 + case "followers": 29 + s.followersPage(w, r) 30 + case "following": 31 + s.followingPage(w, r) 32 + case "starred": 33 + s.starredPage(w, r) 34 + case "strings": 35 + s.stringsPage(w, r) 36 + default: 37 + s.profileOverview(w, r) 32 38 } 33 39 } 34 40 35 - func (s *State) profilePage(w http.ResponseWriter, r *http.Request) { 41 + func (s *State) profile(r *http.Request) (*pages.ProfileCard, error) { 36 42 didOrHandle := chi.URLParam(r, "user") 37 43 if didOrHandle == "" { 38 - http.Error(w, "Bad request", http.StatusBadRequest) 39 - return 44 + return nil, fmt.Errorf("empty DID or handle") 40 45 } 41 46 42 47 ident, ok := r.Context().Value("resolvedId").(identity.Identity) 43 48 if !ok { 44 - s.pages.Error404(w) 45 - return 49 + return nil, fmt.Errorf("failed to resolve ID") 46 50 } 51 + did := ident.DID.String() 47 52 48 - profile, err := db.GetProfile(s.db, ident.DID.String()) 53 + profile, err := db.GetProfile(s.db, did) 49 54 if err != nil { 50 - log.Printf("getting profile data for %s: %s", ident.DID.String(), err) 55 + return nil, fmt.Errorf("failed to get profile: %w", err) 51 56 } 52 57 53 - repos, err := db.GetAllReposByDid(s.db, ident.DID.String()) 58 + repoCount, err := db.CountRepos(s.db, db.FilterEq("did", did)) 54 59 if err != nil { 55 - log.Printf("getting repos for %s: %s", ident.DID.String(), err) 60 + return nil, fmt.Errorf("failed to get repo count: %w", err) 61 + } 62 + 63 + stringCount, err := db.CountStrings(s.db, db.FilterEq("did", did)) 64 + if err != nil { 65 + return nil, fmt.Errorf("failed to get string count: %w", err) 66 + } 67 + 68 + starredCount, err := db.CountStars(s.db, db.FilterEq("starred_by_did", did)) 69 + if err != nil { 70 + return nil, fmt.Errorf("failed to get starred repo count: %w", err) 71 + } 72 + 73 + followStats, err := db.GetFollowerFollowingCount(s.db, did) 74 + if err != nil { 75 + return nil, fmt.Errorf("failed to get follower stats: %w", err) 76 + } 77 + 78 + loggedInUser := s.oauth.GetUser(r) 79 + followStatus := db.IsNotFollowing 80 + if loggedInUser != nil { 81 + followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, did) 82 + } 83 + 84 + now := time.Now() 85 + startOfYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC) 86 + punchcard, err := db.MakePunchcard( 87 + s.db, 88 + db.FilterEq("did", did), 89 + db.FilterGte("date", startOfYear.Format(time.DateOnly)), 90 + db.FilterLte("date", now.Format(time.DateOnly)), 91 + ) 92 + if err != nil { 93 + return nil, fmt.Errorf("failed to get punchcard for %s: %w", did, err) 94 + } 95 + 96 + return &pages.ProfileCard{ 97 + UserDid: did, 98 + UserHandle: ident.Handle.String(), 99 + Profile: profile, 100 + FollowStatus: followStatus, 101 + Stats: pages.ProfileStats{ 102 + RepoCount: repoCount, 103 + StringCount: stringCount, 104 + StarredCount: starredCount, 105 + FollowersCount: followStats.Followers, 106 + FollowingCount: followStats.Following, 107 + }, 108 + Punchcard: punchcard, 109 + }, nil 110 + } 111 + 112 + func (s *State) profileOverview(w http.ResponseWriter, r *http.Request) { 113 + l := s.logger.With("handler", "profileHomePage") 114 + 115 + profile, err := s.profile(r) 116 + if err != nil { 117 + l.Error("failed to build profile card", "err", err) 118 + s.pages.Error500(w) 119 + return 120 + } 121 + l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle) 122 + 123 + repos, err := db.GetRepos( 124 + s.db, 125 + 0, 126 + db.FilterEq("did", profile.UserDid), 127 + ) 128 + if err != nil { 129 + l.Error("failed to fetch repos", "err", err) 56 130 } 57 131 58 132 // filter out ones that are pinned 59 133 pinnedRepos := []db.Repo{} 60 134 for i, r := range repos { 61 135 // if this is a pinned repo, add it 62 - if slices.Contains(profile.PinnedRepos[:], r.RepoAt()) { 136 + if slices.Contains(profile.Profile.PinnedRepos[:], r.RepoAt()) { 63 137 pinnedRepos = append(pinnedRepos, r) 64 138 } 65 139 66 140 // if there are no saved pins, add the first 4 repos 67 - if profile.IsPinnedReposEmpty() && i < 4 { 141 + if profile.Profile.IsPinnedReposEmpty() && i < 4 { 68 142 pinnedRepos = append(pinnedRepos, r) 69 143 } 70 144 } 71 145 72 - collaboratingRepos, err := db.CollaboratingIn(s.db, ident.DID.String()) 146 + collaboratingRepos, err := db.CollaboratingIn(s.db, profile.UserDid) 73 147 if err != nil { 74 - log.Printf("getting collaborating repos for %s: %s", ident.DID.String(), err) 148 + l.Error("failed to fetch collaborating repos", "err", err) 75 149 } 76 150 77 151 pinnedCollaboratingRepos := []db.Repo{} 78 152 for _, r := range collaboratingRepos { 79 153 // if this is a pinned repo, add it 80 - if slices.Contains(profile.PinnedRepos[:], r.RepoAt()) { 154 + if slices.Contains(profile.Profile.PinnedRepos[:], r.RepoAt()) { 81 155 pinnedCollaboratingRepos = append(pinnedCollaboratingRepos, r) 82 156 } 83 157 } 84 158 85 - timeline, err := db.MakeProfileTimeline(s.db, ident.DID.String()) 159 + timeline, err := db.MakeProfileTimeline(s.db, profile.UserDid) 160 + if err != nil { 161 + l.Error("failed to create timeline", "err", err) 162 + } 163 + 164 + s.pages.ProfileOverview(w, pages.ProfileOverviewParams{ 165 + LoggedInUser: s.oauth.GetUser(r), 166 + Card: profile, 167 + Repos: pinnedRepos, 168 + CollaboratingRepos: pinnedCollaboratingRepos, 169 + ProfileTimeline: timeline, 170 + }) 171 + } 172 + 173 + func (s *State) reposPage(w http.ResponseWriter, r *http.Request) { 174 + l := s.logger.With("handler", "reposPage") 175 + 176 + profile, err := s.profile(r) 177 + if err != nil { 178 + l.Error("failed to build profile card", "err", err) 179 + s.pages.Error500(w) 180 + return 181 + } 182 + l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle) 183 + 184 + repos, err := db.GetRepos( 185 + s.db, 186 + 0, 187 + db.FilterEq("did", profile.UserDid), 188 + ) 189 + if err != nil { 190 + l.Error("failed to get repos", "err", err) 191 + s.pages.Error500(w) 192 + return 193 + } 194 + 195 + err = s.pages.ProfileRepos(w, pages.ProfileReposParams{ 196 + LoggedInUser: s.oauth.GetUser(r), 197 + Repos: repos, 198 + Card: profile, 199 + }) 200 + } 201 + 202 + func (s *State) starredPage(w http.ResponseWriter, r *http.Request) { 203 + l := s.logger.With("handler", "starredPage") 204 + 205 + profile, err := s.profile(r) 206 + if err != nil { 207 + l.Error("failed to build profile card", "err", err) 208 + s.pages.Error500(w) 209 + return 210 + } 211 + l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle) 212 + 213 + stars, err := db.GetStars(s.db, 0, db.FilterEq("starred_by_did", profile.UserDid)) 214 + if err != nil { 215 + l.Error("failed to get stars", "err", err) 216 + s.pages.Error500(w) 217 + return 218 + } 219 + var repoAts []string 220 + for _, s := range stars { 221 + repoAts = append(repoAts, string(s.RepoAt)) 222 + } 223 + 224 + repos, err := db.GetRepos( 225 + s.db, 226 + 0, 227 + db.FilterIn("at_uri", repoAts), 228 + ) 229 + if err != nil { 230 + l.Error("failed to get repos", "err", err) 231 + s.pages.Error500(w) 232 + return 233 + } 234 + 235 + err = s.pages.ProfileStarred(w, pages.ProfileStarredParams{ 236 + LoggedInUser: s.oauth.GetUser(r), 237 + Repos: repos, 238 + Card: profile, 239 + }) 240 + } 241 + 242 + func (s *State) stringsPage(w http.ResponseWriter, r *http.Request) { 243 + l := s.logger.With("handler", "stringsPage") 244 + 245 + profile, err := s.profile(r) 246 + if err != nil { 247 + l.Error("failed to build profile card", "err", err) 248 + s.pages.Error500(w) 249 + return 250 + } 251 + l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle) 252 + 253 + strings, err := db.GetStrings(s.db, 0, db.FilterEq("did", profile.UserDid)) 254 + if err != nil { 255 + l.Error("failed to get strings", "err", err) 256 + s.pages.Error500(w) 257 + return 258 + } 259 + 260 + err = s.pages.ProfileStrings(w, pages.ProfileStringsParams{ 261 + LoggedInUser: s.oauth.GetUser(r), 262 + Strings: strings, 263 + Card: profile, 264 + }) 265 + } 266 + 267 + type FollowsPageParams struct { 268 + Follows []pages.FollowCard 269 + Card *pages.ProfileCard 270 + } 271 + 272 + func (s *State) followPage( 273 + r *http.Request, 274 + fetchFollows func(db.Execer, string) ([]db.Follow, error), 275 + extractDid func(db.Follow) string, 276 + ) (*FollowsPageParams, error) { 277 + l := s.logger.With("handler", "reposPage") 278 + 279 + profile, err := s.profile(r) 280 + if err != nil { 281 + return nil, err 282 + } 283 + l = l.With("profileDid", profile.UserDid, "profileHandle", profile.UserHandle) 284 + 285 + loggedInUser := s.oauth.GetUser(r) 286 + params := FollowsPageParams{ 287 + Card: profile, 288 + } 289 + 290 + follows, err := fetchFollows(s.db, profile.UserDid) 86 291 if err != nil { 87 - log.Printf("failed to create profile timeline for %s: %s", ident.DID.String(), err) 292 + l.Error("failed to fetch follows", "err", err) 293 + return &params, err 294 + } 295 + 296 + if len(follows) == 0 { 297 + return &params, nil 88 298 } 89 299 90 - var didsToResolve []string 91 - for _, r := range collaboratingRepos { 92 - didsToResolve = append(didsToResolve, r.Did) 300 + followDids := make([]string, 0, len(follows)) 301 + for _, follow := range follows { 302 + followDids = append(followDids, extractDid(follow)) 93 303 } 94 - for _, byMonth := range timeline.ByMonth { 95 - for _, pe := range byMonth.PullEvents.Items { 96 - didsToResolve = append(didsToResolve, pe.Repo.Did) 304 + 305 + profiles, err := db.GetProfiles(s.db, db.FilterIn("did", followDids)) 306 + if err != nil { 307 + l.Error("failed to get profiles", "followDids", followDids, "err", err) 308 + return &params, err 309 + } 310 + 311 + followStatsMap, err := db.GetFollowerFollowingCounts(s.db, followDids) 312 + if err != nil { 313 + log.Printf("getting follow counts for %s: %s", followDids, err) 314 + } 315 + 316 + loggedInUserFollowing := make(map[string]struct{}) 317 + if loggedInUser != nil { 318 + following, err := db.GetFollowing(s.db, loggedInUser.Did) 319 + if err != nil { 320 + l.Error("failed to get follow list", "err", err, "loggedInUser", loggedInUser.Did) 321 + return &params, err 97 322 } 98 - for _, ie := range byMonth.IssueEvents.Items { 99 - didsToResolve = append(didsToResolve, ie.Metadata.Repo.Did) 323 + loggedInUserFollowing = make(map[string]struct{}, len(following)) 324 + for _, follow := range following { 325 + loggedInUserFollowing[follow.SubjectDid] = struct{}{} 100 326 } 101 - for _, re := range byMonth.RepoEvents { 102 - didsToResolve = append(didsToResolve, re.Repo.Did) 103 - if re.Source != nil { 104 - didsToResolve = append(didsToResolve, re.Source.Did) 105 - } 327 + } 328 + 329 + followCards := make([]pages.FollowCard, len(follows)) 330 + for i, did := range followDids { 331 + followStats := followStatsMap[did] 332 + followStatus := db.IsNotFollowing 333 + if _, exists := loggedInUserFollowing[did]; exists { 334 + followStatus = db.IsFollowing 335 + } else if loggedInUser != nil && loggedInUser.Did == did { 336 + followStatus = db.IsSelf 106 337 } 107 - } 108 338 109 - resolvedIds := s.idResolver.ResolveIdents(r.Context(), didsToResolve) 110 - didHandleMap := make(map[string]string) 111 - for _, identity := range resolvedIds { 112 - if !identity.Handle.IsInvalidHandle() { 113 - didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 339 + var profile *db.Profile 340 + if p, exists := profiles[did]; exists { 341 + profile = p 114 342 } else { 115 - didHandleMap[identity.DID.String()] = identity.DID.String() 343 + profile = &db.Profile{} 344 + profile.Did = did 345 + } 346 + followCards[i] = pages.FollowCard{ 347 + UserDid: did, 348 + FollowStatus: followStatus, 349 + FollowersCount: followStats.Followers, 350 + FollowingCount: followStats.Following, 351 + Profile: profile, 116 352 } 117 353 } 118 354 119 - followers, following, err := db.GetFollowerFollowing(s.db, ident.DID.String()) 355 + params.Follows = followCards 356 + 357 + return &params, nil 358 + } 359 + 360 + func (s *State) followersPage(w http.ResponseWriter, r *http.Request) { 361 + followPage, err := s.followPage(r, db.GetFollowers, func(f db.Follow) string { return f.UserDid }) 120 362 if err != nil { 121 - log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err) 363 + s.pages.Notice(w, "all-followers", "Failed to load followers") 364 + return 122 365 } 123 366 124 - loggedInUser := s.oauth.GetUser(r) 125 - followStatus := db.IsNotFollowing 126 - if loggedInUser != nil { 127 - followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, ident.DID.String()) 128 - } 367 + s.pages.ProfileFollowers(w, pages.ProfileFollowersParams{ 368 + LoggedInUser: s.oauth.GetUser(r), 369 + Followers: followPage.Follows, 370 + Card: followPage.Card, 371 + }) 372 + } 129 373 130 - now := time.Now() 131 - startOfYear := time.Date(now.Year(), 1, 1, 0, 0, 0, 0, time.UTC) 132 - punchcard, err := db.MakePunchcard( 133 - s.db, 134 - db.FilterEq("did", ident.DID.String()), 135 - db.FilterGte("date", startOfYear.Format(time.DateOnly)), 136 - db.FilterLte("date", now.Format(time.DateOnly)), 137 - ) 374 + func (s *State) followingPage(w http.ResponseWriter, r *http.Request) { 375 + followPage, err := s.followPage(r, db.GetFollowing, func(f db.Follow) string { return f.SubjectDid }) 138 376 if err != nil { 139 - log.Println("failed to get punchcard for did", "did", ident.DID.String(), "err", err) 377 + s.pages.Notice(w, "all-following", "Failed to load following") 378 + return 140 379 } 141 380 142 - profileAvatarUri := s.GetAvatarUri(ident.Handle.String()) 143 - s.pages.ProfilePage(w, pages.ProfilePageParams{ 144 - LoggedInUser: loggedInUser, 145 - Repos: pinnedRepos, 146 - CollaboratingRepos: pinnedCollaboratingRepos, 147 - DidHandleMap: didHandleMap, 148 - Card: pages.ProfileCard{ 149 - UserDid: ident.DID.String(), 150 - UserHandle: ident.Handle.String(), 151 - AvatarUri: profileAvatarUri, 152 - Profile: profile, 153 - FollowStatus: followStatus, 154 - Followers: followers, 155 - Following: following, 156 - }, 157 - Punchcard: punchcard, 158 - ProfileTimeline: timeline, 381 + s.pages.ProfileFollowing(w, pages.ProfileFollowingParams{ 382 + LoggedInUser: s.oauth.GetUser(r), 383 + Following: followPage.Follows, 384 + Card: followPage.Card, 159 385 }) 160 386 } 161 387 162 - func (s *State) reposPage(w http.ResponseWriter, r *http.Request) { 388 + func (s *State) AtomFeedPage(w http.ResponseWriter, r *http.Request) { 163 389 ident, ok := r.Context().Value("resolvedId").(identity.Identity) 164 390 if !ok { 165 391 s.pages.Error404(w) 166 392 return 167 393 } 168 394 169 - profile, err := db.GetProfile(s.db, ident.DID.String()) 395 + feed, err := s.getProfileFeed(r.Context(), &ident) 170 396 if err != nil { 171 - log.Printf("getting profile data for %s: %s", ident.DID.String(), err) 397 + s.pages.Error500(w) 398 + return 399 + } 400 + 401 + if feed == nil { 402 + return 172 403 } 173 404 174 - repos, err := db.GetAllReposByDid(s.db, ident.DID.String()) 405 + atom, err := feed.ToAtom() 175 406 if err != nil { 176 - log.Printf("getting repos for %s: %s", ident.DID.String(), err) 407 + s.pages.Error500(w) 408 + return 177 409 } 178 410 179 - loggedInUser := s.oauth.GetUser(r) 180 - followStatus := db.IsNotFollowing 181 - if loggedInUser != nil { 182 - followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, ident.DID.String()) 183 - } 411 + w.Header().Set("content-type", "application/atom+xml") 412 + w.Write([]byte(atom)) 413 + } 184 414 185 - followers, following, err := db.GetFollowerFollowing(s.db, ident.DID.String()) 415 + func (s *State) getProfileFeed(ctx context.Context, id *identity.Identity) (*feeds.Feed, error) { 416 + timeline, err := db.MakeProfileTimeline(s.db, id.DID.String()) 186 417 if err != nil { 187 - log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err) 418 + return nil, err 188 419 } 189 420 190 - profileAvatarUri := s.GetAvatarUri(ident.Handle.String()) 421 + author := &feeds.Author{ 422 + Name: fmt.Sprintf("@%s", id.Handle), 423 + } 191 424 192 - s.pages.ReposPage(w, pages.ReposPageParams{ 193 - LoggedInUser: loggedInUser, 194 - Repos: repos, 195 - Card: pages.ProfileCard{ 196 - UserDid: ident.DID.String(), 197 - UserHandle: ident.Handle.String(), 198 - AvatarUri: profileAvatarUri, 199 - Profile: profile, 200 - FollowStatus: followStatus, 201 - Followers: followers, 202 - Following: following, 203 - }, 425 + feed := feeds.Feed{ 426 + Title: fmt.Sprintf("%s's timeline", author.Name), 427 + Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s", s.config.Core.AppviewHost, id.Handle), Type: "text/html", Rel: "alternate"}, 428 + Items: make([]*feeds.Item, 0), 429 + Updated: time.UnixMilli(0), 430 + Author: author, 431 + } 432 + 433 + for _, byMonth := range timeline.ByMonth { 434 + if err := s.addPullRequestItems(ctx, &feed, byMonth.PullEvents.Items, author); err != nil { 435 + return nil, err 436 + } 437 + if err := s.addIssueItems(ctx, &feed, byMonth.IssueEvents.Items, author); err != nil { 438 + return nil, err 439 + } 440 + if err := s.addRepoItems(ctx, &feed, byMonth.RepoEvents, author); err != nil { 441 + return nil, err 442 + } 443 + } 444 + 445 + slices.SortFunc(feed.Items, func(a *feeds.Item, b *feeds.Item) int { 446 + return int(b.Created.UnixMilli()) - int(a.Created.UnixMilli()) 204 447 }) 448 + 449 + if len(feed.Items) > 0 { 450 + feed.Updated = feed.Items[0].Created 451 + } 452 + 453 + return &feed, nil 205 454 } 206 455 207 - func (s *State) GetAvatarUri(handle string) string { 208 - secret := s.config.Avatar.SharedSecret 209 - h := hmac.New(sha256.New, []byte(secret)) 210 - h.Write([]byte(handle)) 211 - signature := hex.EncodeToString(h.Sum(nil)) 212 - return fmt.Sprintf("%s/%s/%s", s.config.Avatar.Host, signature, handle) 456 + func (s *State) addPullRequestItems(ctx context.Context, feed *feeds.Feed, pulls []*db.Pull, author *feeds.Author) error { 457 + for _, pull := range pulls { 458 + owner, err := s.idResolver.ResolveIdent(ctx, pull.Repo.Did) 459 + if err != nil { 460 + return err 461 + } 462 + 463 + // Add pull request creation item 464 + feed.Items = append(feed.Items, s.createPullRequestItem(pull, owner, author)) 465 + } 466 + return nil 467 + } 468 + 469 + func (s *State) addIssueItems(ctx context.Context, feed *feeds.Feed, issues []*db.Issue, author *feeds.Author) error { 470 + for _, issue := range issues { 471 + owner, err := s.idResolver.ResolveIdent(ctx, issue.Repo.Did) 472 + if err != nil { 473 + return err 474 + } 475 + 476 + feed.Items = append(feed.Items, s.createIssueItem(issue, owner, author)) 477 + } 478 + return nil 479 + } 480 + 481 + func (s *State) addRepoItems(ctx context.Context, feed *feeds.Feed, repos []db.RepoEvent, author *feeds.Author) error { 482 + for _, repo := range repos { 483 + item, err := s.createRepoItem(ctx, repo, author) 484 + if err != nil { 485 + return err 486 + } 487 + feed.Items = append(feed.Items, item) 488 + } 489 + return nil 490 + } 491 + 492 + func (s *State) createPullRequestItem(pull *db.Pull, owner *identity.Identity, author *feeds.Author) *feeds.Item { 493 + return &feeds.Item{ 494 + Title: fmt.Sprintf("%s created pull request '%s' in @%s/%s", author.Name, pull.Title, owner.Handle, pull.Repo.Name), 495 + Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s/pulls/%d", s.config.Core.AppviewHost, owner.Handle, pull.Repo.Name, pull.PullId), Type: "text/html", Rel: "alternate"}, 496 + Created: pull.Created, 497 + Author: author, 498 + } 499 + } 500 + 501 + func (s *State) createIssueItem(issue *db.Issue, owner *identity.Identity, author *feeds.Author) *feeds.Item { 502 + return &feeds.Item{ 503 + Title: fmt.Sprintf("%s created issue '%s' in @%s/%s", author.Name, issue.Title, owner.Handle, issue.Repo.Name), 504 + Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s/issues/%d", s.config.Core.AppviewHost, owner.Handle, issue.Repo.Name, issue.IssueId), Type: "text/html", Rel: "alternate"}, 505 + Created: issue.Created, 506 + Author: author, 507 + } 508 + } 509 + 510 + func (s *State) createRepoItem(ctx context.Context, repo db.RepoEvent, author *feeds.Author) (*feeds.Item, error) { 511 + var title string 512 + if repo.Source != nil { 513 + sourceOwner, err := s.idResolver.ResolveIdent(ctx, repo.Source.Did) 514 + if err != nil { 515 + return nil, err 516 + } 517 + title = fmt.Sprintf("%s forked repository @%s/%s to '%s'", author.Name, sourceOwner.Handle, repo.Source.Name, repo.Repo.Name) 518 + } else { 519 + title = fmt.Sprintf("%s created repository '%s'", author.Name, repo.Repo.Name) 520 + } 521 + 522 + return &feeds.Item{ 523 + Title: title, 524 + Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s", s.config.Core.AppviewHost, author.Name[1:], repo.Repo.Name), Type: "text/html", Rel: "alternate"}, // Remove @ prefix 525 + Created: repo.Repo.Created, 526 + Author: author, 527 + }, nil 213 528 } 214 529 215 530 func (s *State) UpdateProfileBio(w http.ResponseWriter, r *http.Request) { ··· 257 572 } 258 573 259 574 s.updateProfile(profile, w, r) 260 - return 261 575 } 262 576 263 577 func (s *State) UpdateProfilePins(w http.ResponseWriter, r *http.Request) { ··· 297 611 profile.PinnedRepos = pinnedRepos 298 612 299 613 s.updateProfile(profile, w, r) 300 - return 301 614 } 302 615 303 616 func (s *State) updateProfile(profile *db.Profile, w http.ResponseWriter, r *http.Request) { ··· 362 675 return 363 676 } 364 677 365 - if !s.config.Core.Dev { 366 - err = s.posthog.Enqueue(posthog.Capture{ 367 - DistinctId: user.Did, 368 - Event: "edit_profile", 369 - }) 370 - if err != nil { 371 - log.Println("failed to enqueue posthog event:", err) 372 - } 373 - } 678 + s.notifier.UpdateProfile(r.Context(), profile) 374 679 375 680 s.pages.HxRedirect(w, "/"+user.Did) 376 - return 377 681 } 378 682 379 683 func (s *State) EditBioFragment(w http.ResponseWriter, r *http.Request) { ··· 398 702 log.Printf("getting profile data for %s: %s", user.Did, err) 399 703 } 400 704 401 - repos, err := db.GetAllReposByDid(s.db, user.Did) 705 + repos, err := db.GetRepos(s.db, 0, db.FilterEq("did", user.Did)) 402 706 if err != nil { 403 707 log.Printf("getting repos for %s: %s", user.Did, err) 404 708 } ··· 425 729 }) 426 730 } 427 731 428 - var didsToResolve []string 429 - for _, r := range allRepos { 430 - didsToResolve = append(didsToResolve, r.Did) 431 - } 432 - resolvedIds := s.idResolver.ResolveIdents(r.Context(), didsToResolve) 433 - didHandleMap := make(map[string]string) 434 - for _, identity := range resolvedIds { 435 - if !identity.Handle.IsInvalidHandle() { 436 - didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 437 - } else { 438 - didHandleMap[identity.DID.String()] = identity.DID.String() 439 - } 440 - } 441 - 442 732 s.pages.EditPinsFragment(w, pages.EditPinsParams{ 443 733 LoggedInUser: user, 444 734 Profile: profile, 445 735 AllRepos: allRepos, 446 - DidHandleMap: didHandleMap, 447 736 }) 448 737 }
+126
appview/state/reaction.go
··· 1 + package state 2 + 3 + import ( 4 + "log" 5 + "net/http" 6 + "time" 7 + 8 + comatproto "github.com/bluesky-social/indigo/api/atproto" 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + 11 + lexutil "github.com/bluesky-social/indigo/lex/util" 12 + "tangled.sh/tangled.sh/core/api/tangled" 13 + "tangled.sh/tangled.sh/core/appview/db" 14 + "tangled.sh/tangled.sh/core/appview/pages" 15 + "tangled.sh/tangled.sh/core/tid" 16 + ) 17 + 18 + func (s *State) React(w http.ResponseWriter, r *http.Request) { 19 + currentUser := s.oauth.GetUser(r) 20 + 21 + subject := r.URL.Query().Get("subject") 22 + if subject == "" { 23 + log.Println("invalid form") 24 + return 25 + } 26 + 27 + subjectUri, err := syntax.ParseATURI(subject) 28 + if err != nil { 29 + log.Println("invalid form") 30 + return 31 + } 32 + 33 + reactionKind, ok := db.ParseReactionKind(r.URL.Query().Get("kind")) 34 + if !ok { 35 + log.Println("invalid reaction kind") 36 + return 37 + } 38 + 39 + client, err := s.oauth.AuthorizedClient(r) 40 + if err != nil { 41 + log.Println("failed to authorize client", err) 42 + return 43 + } 44 + 45 + switch r.Method { 46 + case http.MethodPost: 47 + createdAt := time.Now().Format(time.RFC3339) 48 + rkey := tid.TID() 49 + resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 50 + Collection: tangled.FeedReactionNSID, 51 + Repo: currentUser.Did, 52 + Rkey: rkey, 53 + Record: &lexutil.LexiconTypeDecoder{ 54 + Val: &tangled.FeedReaction{ 55 + Subject: subjectUri.String(), 56 + Reaction: reactionKind.String(), 57 + CreatedAt: createdAt, 58 + }, 59 + }, 60 + }) 61 + if err != nil { 62 + log.Println("failed to create atproto record", err) 63 + return 64 + } 65 + 66 + err = db.AddReaction(s.db, currentUser.Did, subjectUri, reactionKind, rkey) 67 + if err != nil { 68 + log.Println("failed to react", err) 69 + return 70 + } 71 + 72 + count, err := db.GetReactionCount(s.db, subjectUri, reactionKind) 73 + if err != nil { 74 + log.Println("failed to get reaction count for ", subjectUri) 75 + } 76 + 77 + log.Println("created atproto record: ", resp.Uri) 78 + 79 + s.pages.ThreadReactionFragment(w, pages.ThreadReactionFragmentParams{ 80 + ThreadAt: subjectUri, 81 + Kind: reactionKind, 82 + Count: count, 83 + IsReacted: true, 84 + }) 85 + 86 + return 87 + case http.MethodDelete: 88 + reaction, err := db.GetReaction(s.db, currentUser.Did, subjectUri, reactionKind) 89 + if err != nil { 90 + log.Println("failed to get reaction relationship for", currentUser.Did, subjectUri) 91 + return 92 + } 93 + 94 + _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 95 + Collection: tangled.FeedReactionNSID, 96 + Repo: currentUser.Did, 97 + Rkey: reaction.Rkey, 98 + }) 99 + 100 + if err != nil { 101 + log.Println("failed to remove reaction") 102 + return 103 + } 104 + 105 + err = db.DeleteReactionByRkey(s.db, currentUser.Did, reaction.Rkey) 106 + if err != nil { 107 + log.Println("failed to delete reaction from DB") 108 + // this is not an issue, the firehose event might have already done this 109 + } 110 + 111 + count, err := db.GetReactionCount(s.db, subjectUri, reactionKind) 112 + if err != nil { 113 + log.Println("failed to get reaction count for ", subjectUri) 114 + return 115 + } 116 + 117 + s.pages.ThreadReactionFragment(w, pages.ThreadReactionFragmentParams{ 118 + ThreadAt: subjectUri, 119 + Kind: reactionKind, 120 + Count: count, 121 + IsReacted: false, 122 + }) 123 + 124 + return 125 + } 126 + }
+84 -35
appview/state/router.go
··· 7 7 "github.com/go-chi/chi/v5" 8 8 "github.com/gorilla/sessions" 9 9 "tangled.sh/tangled.sh/core/appview/issues" 10 + "tangled.sh/tangled.sh/core/appview/knots" 10 11 "tangled.sh/tangled.sh/core/appview/middleware" 11 12 oauthhandler "tangled.sh/tangled.sh/core/appview/oauth/handler" 12 13 "tangled.sh/tangled.sh/core/appview/pipelines" 13 14 "tangled.sh/tangled.sh/core/appview/pulls" 14 15 "tangled.sh/tangled.sh/core/appview/repo" 15 16 "tangled.sh/tangled.sh/core/appview/settings" 17 + "tangled.sh/tangled.sh/core/appview/signup" 16 18 "tangled.sh/tangled.sh/core/appview/spindles" 17 19 "tangled.sh/tangled.sh/core/appview/state/userutil" 20 + avstrings "tangled.sh/tangled.sh/core/appview/strings" 18 21 "tangled.sh/tangled.sh/core/log" 19 22 ) 20 23 ··· 29 32 s.pages, 30 33 ) 31 34 35 + router.Get("/favicon.svg", s.Favicon) 36 + router.Get("/favicon.ico", s.Favicon) 37 + 38 + userRouter := s.UserRouter(&middleware) 39 + standardRouter := s.StandardRouter(&middleware) 40 + 32 41 router.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) { 33 42 pat := chi.URLParam(r, "*") 34 43 if strings.HasPrefix(pat, "did:") || strings.HasPrefix(pat, "@") { 35 - s.UserRouter(&middleware).ServeHTTP(w, r) 44 + userRouter.ServeHTTP(w, r) 36 45 } else { 37 46 // Check if the first path element is a valid handle without '@' or a flattened DID 38 47 pathParts := strings.SplitN(pat, "/", 2) ··· 55 64 return 56 65 } 57 66 } 58 - s.StandardRouter(&middleware).ServeHTTP(w, r) 67 + standardRouter.ServeHTTP(w, r) 59 68 } 60 69 }) 61 70 ··· 65 74 func (s *State) UserRouter(mw *middleware.Middleware) http.Handler { 66 75 r := chi.NewRouter() 67 76 68 - // strip @ from user 69 - r.Use(middleware.StripLeadingAt) 70 - 71 77 r.With(mw.ResolveIdent()).Route("/{user}", func(r chi.Router) { 72 78 r.Get("/", s.Profile) 79 + r.Get("/feed.atom", s.AtomFeedPage) 80 + 81 + // redirect /@handle/repo.git -> /@handle/repo 82 + r.Get("/{repo}.git", func(w http.ResponseWriter, r *http.Request) { 83 + nonDotGitPath := strings.TrimSuffix(r.URL.Path, ".git") 84 + http.Redirect(w, r, nonDotGitPath, http.StatusMovedPermanently) 85 + }) 73 86 74 87 r.With(mw.ResolveRepo()).Route("/{repo}", func(r chi.Router) { 75 88 r.Use(mw.GoImport()) 76 - 77 89 r.Mount("/", s.RepoRouter(mw)) 78 90 r.Mount("/issues", s.IssuesRouter(mw)) 79 91 r.Mount("/pulls", s.PullsRouter(mw)) ··· 99 111 100 112 r.Handle("/static/*", s.pages.Static()) 101 113 102 - r.Get("/", s.Timeline) 103 - 104 - r.Route("/knots", func(r chi.Router) { 105 - r.Use(middleware.AuthMiddleware(s.oauth)) 106 - r.Get("/", s.Knots) 107 - r.Post("/key", s.RegistrationKey) 108 - 109 - r.Route("/{domain}", func(r chi.Router) { 110 - r.Post("/init", s.InitKnotServer) 111 - r.Get("/", s.KnotServerInfo) 112 - r.Route("/member", func(r chi.Router) { 113 - r.Use(mw.KnotOwner()) 114 - r.Get("/", s.ListMembers) 115 - r.Put("/", s.AddMember) 116 - r.Delete("/", s.RemoveMember) 117 - }) 118 - }) 119 - }) 114 + r.Get("/", s.HomeOrTimeline) 115 + r.Get("/timeline", s.Timeline) 116 + r.With(middleware.AuthMiddleware(s.oauth)).Get("/upgradeBanner", s.UpgradeBanner) 120 117 121 118 r.Route("/repo", func(r chi.Router) { 122 119 r.Route("/new", func(r chi.Router) { ··· 137 134 r.Delete("/", s.Star) 138 135 }) 139 136 137 + r.With(middleware.AuthMiddleware(s.oauth)).Route("/react", func(r chi.Router) { 138 + r.Post("/", s.React) 139 + r.Delete("/", s.React) 140 + }) 141 + 140 142 r.Route("/profile", func(r chi.Router) { 141 143 r.Use(middleware.AuthMiddleware(s.oauth)) 142 144 r.Get("/edit-bio", s.EditBioFragment) ··· 146 148 }) 147 149 148 150 r.Mount("/settings", s.SettingsRouter()) 151 + r.Mount("/strings", s.StringsRouter(mw)) 152 + r.Mount("/knots", s.KnotsRouter()) 149 153 r.Mount("/spindles", s.SpindlesRouter()) 154 + r.Mount("/signup", s.SignupRouter()) 150 155 r.Mount("/", s.OAuthRouter()) 151 156 152 157 r.Get("/keys/{user}", s.Keys) 158 + r.Get("/terms", s.TermsOfService) 159 + r.Get("/privacy", s.PrivacyPolicy) 153 160 154 161 r.NotFound(func(w http.ResponseWriter, r *http.Request) { 155 162 s.pages.Error404(w) ··· 178 185 logger := log.New("spindles") 179 186 180 187 spindles := &spindles.Spindles{ 181 - Db: s.db, 182 - OAuth: s.oauth, 183 - Pages: s.pages, 184 - Config: s.config, 185 - Enforcer: s.enforcer, 186 - Logger: logger, 188 + Db: s.db, 189 + OAuth: s.oauth, 190 + Pages: s.pages, 191 + Config: s.config, 192 + Enforcer: s.enforcer, 193 + IdResolver: s.idResolver, 194 + Logger: logger, 187 195 } 188 196 189 197 return spindles.Router() 190 198 } 191 199 200 + func (s *State) KnotsRouter() http.Handler { 201 + logger := log.New("knots") 202 + 203 + knots := &knots.Knots{ 204 + Db: s.db, 205 + OAuth: s.oauth, 206 + Pages: s.pages, 207 + Config: s.config, 208 + Enforcer: s.enforcer, 209 + IdResolver: s.idResolver, 210 + Knotstream: s.knotstream, 211 + Logger: logger, 212 + } 213 + 214 + return knots.Router() 215 + } 216 + 217 + func (s *State) StringsRouter(mw *middleware.Middleware) http.Handler { 218 + logger := log.New("strings") 219 + 220 + strs := &avstrings.Strings{ 221 + Db: s.db, 222 + OAuth: s.oauth, 223 + Pages: s.pages, 224 + Config: s.config, 225 + Enforcer: s.enforcer, 226 + IdResolver: s.idResolver, 227 + Knotstream: s.knotstream, 228 + Logger: logger, 229 + } 230 + 231 + return strs.Router(mw) 232 + } 233 + 192 234 func (s *State) IssuesRouter(mw *middleware.Middleware) http.Handler { 193 - issues := issues.New(s.oauth, s.repoResolver, s.pages, s.idResolver, s.db, s.config, s.posthog) 235 + issues := issues.New(s.oauth, s.repoResolver, s.pages, s.idResolver, s.db, s.config, s.notifier, s.validator) 194 236 return issues.Router(mw) 195 - 196 237 } 197 238 198 239 func (s *State) PullsRouter(mw *middleware.Middleware) http.Handler { 199 - pulls := pulls.New(s.oauth, s.repoResolver, s.pages, s.idResolver, s.db, s.config, s.posthog) 240 + pulls := pulls.New(s.oauth, s.repoResolver, s.pages, s.idResolver, s.db, s.config, s.notifier) 200 241 return pulls.Router(mw) 201 242 } 202 243 203 244 func (s *State) RepoRouter(mw *middleware.Middleware) http.Handler { 204 - repo := repo.New(s.oauth, s.repoResolver, s.pages, s.spindlestream, s.idResolver, s.db, s.config, s.posthog, s.enforcer) 245 + logger := log.New("repo") 246 + repo := repo.New(s.oauth, s.repoResolver, s.pages, s.spindlestream, s.idResolver, s.db, s.config, s.notifier, s.enforcer, logger) 205 247 return repo.Router(mw) 206 248 } 207 249 208 250 func (s *State) PipelinesRouter(mw *middleware.Middleware) http.Handler { 209 - pipes := pipelines.New(s.oauth, s.repoResolver, s.pages, s.spindlestream, s.idResolver, s.db, s.config, s.posthog, s.enforcer) 251 + pipes := pipelines.New(s.oauth, s.repoResolver, s.pages, s.spindlestream, s.idResolver, s.db, s.config, s.enforcer) 210 252 return pipes.Router(mw) 211 253 } 254 + 255 + func (s *State) SignupRouter() http.Handler { 256 + logger := log.New("signup") 257 + 258 + sig := signup.New(s.config, s.db, s.posthog, s.idResolver, s.pages, logger) 259 + return sig.Router() 260 + }
+11 -2
appview/state/spindlestream.go
··· 3 3 import ( 4 4 "context" 5 5 "encoding/json" 6 + "fmt" 6 7 "log/slog" 7 8 "strings" 8 9 "time" ··· 20 21 ) 21 22 22 23 func Spindlestream(ctx context.Context, c *config.Config, d *db.DB, enforcer *rbac.Enforcer) (*ec.Consumer, error) { 23 - spindles, err := db.GetSpindles(d) 24 + spindles, err := db.GetSpindles( 25 + d, 26 + db.FilterIsNot("verified", "null"), 27 + ) 24 28 if err != nil { 25 29 return nil, err 26 30 } ··· 97 101 ExitCode: exitCode, 98 102 } 99 103 100 - return db.AddPipelineStatus(d, status) 104 + err = db.AddPipelineStatus(d, status) 105 + if err != nil { 106 + return fmt.Errorf("failed to add pipeline status: %w", err) 107 + } 108 + 109 + return nil 101 110 }
+15 -29
appview/state/star.go
··· 8 8 comatproto "github.com/bluesky-social/indigo/api/atproto" 9 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 10 lexutil "github.com/bluesky-social/indigo/lex/util" 11 - "github.com/posthog/posthog-go" 12 11 "tangled.sh/tangled.sh/core/api/tangled" 13 - "tangled.sh/tangled.sh/core/appview" 14 12 "tangled.sh/tangled.sh/core/appview/db" 15 13 "tangled.sh/tangled.sh/core/appview/pages" 14 + "tangled.sh/tangled.sh/core/tid" 16 15 ) 17 16 18 17 func (s *State) Star(w http.ResponseWriter, r *http.Request) { ··· 39 38 switch r.Method { 40 39 case http.MethodPost: 41 40 createdAt := time.Now().Format(time.RFC3339) 42 - rkey := appview.TID() 41 + rkey := tid.TID() 43 42 resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 44 43 Collection: tangled.FeedStarNSID, 45 44 Repo: currentUser.Did, ··· 54 53 log.Println("failed to create atproto record", err) 55 54 return 56 55 } 56 + log.Println("created atproto record: ", resp.Uri) 57 57 58 - err = db.AddStar(s.db, currentUser.Did, subjectUri, rkey) 58 + star := &db.Star{ 59 + StarredByDid: currentUser.Did, 60 + RepoAt: subjectUri, 61 + Rkey: rkey, 62 + } 63 + 64 + err = db.AddStar(s.db, star) 59 65 if err != nil { 60 66 log.Println("failed to star", err) 61 67 return ··· 66 72 log.Println("failed to get star count for ", subjectUri) 67 73 } 68 74 69 - log.Println("created atproto record: ", resp.Uri) 75 + s.notifier.NewStar(r.Context(), star) 70 76 71 - s.pages.RepoActionsFragment(w, pages.RepoActionsFragmentParams{ 77 + s.pages.RepoStarFragment(w, pages.RepoStarFragmentParams{ 72 78 IsStarred: true, 73 79 RepoAt: subjectUri, 74 80 Stats: db.RepoStats{ ··· 76 82 }, 77 83 }) 78 84 79 - if !s.config.Core.Dev { 80 - err = s.posthog.Enqueue(posthog.Capture{ 81 - DistinctId: currentUser.Did, 82 - Event: "star", 83 - Properties: posthog.Properties{"repo_at": subjectUri.String()}, 84 - }) 85 - if err != nil { 86 - log.Println("failed to enqueue posthog event:", err) 87 - } 88 - } 89 - 90 85 return 91 86 case http.MethodDelete: 92 87 // find the record in the db ··· 119 114 return 120 115 } 121 116 122 - s.pages.RepoActionsFragment(w, pages.RepoActionsFragmentParams{ 117 + s.notifier.DeleteStar(r.Context(), star) 118 + 119 + s.pages.RepoStarFragment(w, pages.RepoStarFragmentParams{ 123 120 IsStarred: false, 124 121 RepoAt: subjectUri, 125 122 Stats: db.RepoStats{ 126 123 StarCount: starCount, 127 124 }, 128 125 }) 129 - 130 - if !s.config.Core.Dev { 131 - err = s.posthog.Enqueue(posthog.Capture{ 132 - DistinctId: currentUser.Did, 133 - Event: "unstar", 134 - Properties: posthog.Properties{"repo_at": subjectUri.String()}, 135 - }) 136 - if err != nil { 137 - log.Println("failed to enqueue posthog event:", err) 138 - } 139 - } 140 126 141 127 return 142 128 }
+220 -407
appview/state/state.go
··· 2 2 3 3 import ( 4 4 "context" 5 - "crypto/hmac" 6 - "crypto/sha256" 7 - "encoding/hex" 5 + "database/sql" 6 + "errors" 8 7 "fmt" 9 8 "log" 10 9 "log/slog" ··· 24 23 "tangled.sh/tangled.sh/core/appview/cache/session" 25 24 "tangled.sh/tangled.sh/core/appview/config" 26 25 "tangled.sh/tangled.sh/core/appview/db" 27 - "tangled.sh/tangled.sh/core/appview/idresolver" 26 + "tangled.sh/tangled.sh/core/appview/notify" 28 27 "tangled.sh/tangled.sh/core/appview/oauth" 29 28 "tangled.sh/tangled.sh/core/appview/pages" 29 + posthogService "tangled.sh/tangled.sh/core/appview/posthog" 30 30 "tangled.sh/tangled.sh/core/appview/reporesolver" 31 + "tangled.sh/tangled.sh/core/appview/validator" 32 + xrpcclient "tangled.sh/tangled.sh/core/appview/xrpcclient" 31 33 "tangled.sh/tangled.sh/core/eventconsumer" 34 + "tangled.sh/tangled.sh/core/idresolver" 32 35 "tangled.sh/tangled.sh/core/jetstream" 33 - "tangled.sh/tangled.sh/core/knotclient" 36 + tlog "tangled.sh/tangled.sh/core/log" 34 37 "tangled.sh/tangled.sh/core/rbac" 38 + "tangled.sh/tangled.sh/core/tid" 39 + // xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 35 40 ) 36 41 37 42 type State struct { 38 43 db *db.DB 44 + notifier notify.Notifier 39 45 oauth *oauth.OAuth 40 46 enforcer *rbac.Enforcer 41 - tidClock syntax.TIDClock 42 47 pages *pages.Pages 43 48 sess *session.SessionStore 44 49 idResolver *idresolver.Resolver ··· 48 53 repoResolver *reporesolver.RepoResolver 49 54 knotstream *eventconsumer.Consumer 50 55 spindlestream *eventconsumer.Consumer 56 + logger *slog.Logger 57 + validator *validator.Validator 51 58 } 52 59 53 60 func Make(ctx context.Context, config *config.Config) (*State, error) { 54 61 d, err := db.Make(config.Core.DbPath) 55 62 if err != nil { 56 - return nil, err 63 + return nil, fmt.Errorf("failed to create db: %w", err) 57 64 } 58 65 59 66 enforcer, err := rbac.NewEnforcer(config.Core.DbPath) 60 67 if err != nil { 61 - return nil, err 68 + return nil, fmt.Errorf("failed to create enforcer: %w", err) 62 69 } 63 70 64 - clock := syntax.NewTIDClock(0) 65 - 66 - pgs := pages.NewPages(config) 67 - 68 - res, err := idresolver.RedisResolver(config.Redis) 71 + res, err := idresolver.RedisResolver(config.Redis.ToURL()) 69 72 if err != nil { 70 73 log.Printf("failed to create redis resolver: %v", err) 71 74 res = idresolver.DefaultResolver() 72 75 } 73 76 77 + pgs := pages.NewPages(config, res) 74 78 cache := cache.New(config.Redis.Addr) 75 79 sess := session.New(cache) 76 - 77 80 oauth := oauth.NewOAuth(config, sess) 81 + validator := validator.New(d) 78 82 79 83 posthog, err := posthog.NewWithConfig(config.Posthog.ApiKey, posthog.Config{Endpoint: config.Posthog.Endpoint}) 80 84 if err != nil { ··· 93 97 tangled.PublicKeyNSID, 94 98 tangled.RepoArtifactNSID, 95 99 tangled.ActorProfileNSID, 100 + tangled.SpindleMemberNSID, 101 + tangled.SpindleNSID, 102 + tangled.StringNSID, 103 + tangled.RepoIssueNSID, 104 + tangled.RepoIssueCommentNSID, 96 105 }, 97 106 nil, 98 107 slog.Default(), ··· 106 115 if err != nil { 107 116 return nil, fmt.Errorf("failed to create jetstream client: %w", err) 108 117 } 109 - err = jc.StartJetstream(ctx, appview.Ingest(wrapper, enforcer)) 118 + 119 + ingester := appview.Ingester{ 120 + Db: wrapper, 121 + Enforcer: enforcer, 122 + IdResolver: res, 123 + Config: config, 124 + Logger: tlog.New("ingester"), 125 + Validator: validator, 126 + } 127 + err = jc.StartJetstream(ctx, ingester.Ingest()) 110 128 if err != nil { 111 129 return nil, fmt.Errorf("failed to start jetstream watcher: %w", err) 112 130 } ··· 123 141 } 124 142 spindlestream.Start(ctx) 125 143 144 + var notifiers []notify.Notifier 145 + if !config.Core.Dev { 146 + notifiers = append(notifiers, posthogService.NewPosthogNotifier(posthog)) 147 + } 148 + notifier := notify.NewMergedNotifier(notifiers...) 149 + 126 150 state := &State{ 127 151 d, 152 + notifier, 128 153 oauth, 129 154 enforcer, 130 - clock, 131 155 pgs, 132 156 sess, 133 157 res, ··· 137 161 repoResolver, 138 162 knotstream, 139 163 spindlestream, 164 + slog.Default(), 165 + validator, 140 166 } 141 167 142 168 return state, nil 143 169 } 144 170 145 - func TID(c *syntax.TIDClock) string { 146 - return c.Next().String() 171 + func (s *State) Close() error { 172 + // other close up logic goes here 173 + return s.db.Close() 147 174 } 148 175 149 - func (s *State) Timeline(w http.ResponseWriter, r *http.Request) { 150 - user := s.oauth.GetUser(r) 176 + func (s *State) Favicon(w http.ResponseWriter, r *http.Request) { 177 + w.Header().Set("Content-Type", "image/svg+xml") 178 + w.Header().Set("Cache-Control", "public, max-age=31536000") // one year 179 + w.Header().Set("ETag", `"favicon-svg-v1"`) 151 180 152 - timeline, err := db.MakeTimeline(s.db) 153 - if err != nil { 154 - log.Println(err) 155 - s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.") 181 + if match := r.Header.Get("If-None-Match"); match == `"favicon-svg-v1"` { 182 + w.WriteHeader(http.StatusNotModified) 183 + return 156 184 } 157 185 158 - var didsToResolve []string 159 - for _, ev := range timeline { 160 - if ev.Repo != nil { 161 - didsToResolve = append(didsToResolve, ev.Repo.Did) 162 - if ev.Source != nil { 163 - didsToResolve = append(didsToResolve, ev.Source.Did) 164 - } 165 - } 166 - if ev.Follow != nil { 167 - didsToResolve = append(didsToResolve, ev.Follow.UserDid, ev.Follow.SubjectDid) 168 - } 169 - if ev.Star != nil { 170 - didsToResolve = append(didsToResolve, ev.Star.StarredByDid, ev.Star.Repo.Did) 171 - } 172 - } 186 + s.pages.Favicon(w) 187 + } 173 188 174 - resolvedIds := s.idResolver.ResolveIdents(r.Context(), didsToResolve) 175 - didHandleMap := make(map[string]string) 176 - for _, identity := range resolvedIds { 177 - if !identity.Handle.IsInvalidHandle() { 178 - didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 179 - } else { 180 - didHandleMap[identity.DID.String()] = identity.DID.String() 181 - } 182 - } 183 - 184 - s.pages.Timeline(w, pages.TimelineParams{ 189 + func (s *State) TermsOfService(w http.ResponseWriter, r *http.Request) { 190 + user := s.oauth.GetUser(r) 191 + s.pages.TermsOfService(w, pages.TermsOfServiceParams{ 185 192 LoggedInUser: user, 186 - Timeline: timeline, 187 - DidHandleMap: didHandleMap, 188 193 }) 189 - 190 - return 191 194 } 192 195 193 - // requires auth 194 - func (s *State) RegistrationKey(w http.ResponseWriter, r *http.Request) { 195 - switch r.Method { 196 - case http.MethodGet: 197 - // list open registrations under this did 198 - 199 - return 200 - case http.MethodPost: 201 - session, err := s.oauth.Stores().Get(r, oauth.SessionName) 202 - if err != nil || session.IsNew { 203 - log.Println("unauthorized attempt to generate registration key") 204 - http.Error(w, "Forbidden", http.StatusUnauthorized) 205 - return 206 - } 207 - 208 - did := session.Values[oauth.SessionDid].(string) 209 - 210 - // check if domain is valid url, and strip extra bits down to just host 211 - domain := r.FormValue("domain") 212 - if domain == "" { 213 - http.Error(w, "Invalid form", http.StatusBadRequest) 214 - return 215 - } 216 - 217 - key, err := db.GenerateRegistrationKey(s.db, domain, did) 218 - 219 - if err != nil { 220 - log.Println(err) 221 - http.Error(w, "unable to register this domain", http.StatusNotAcceptable) 222 - return 223 - } 224 - 225 - w.Write([]byte(key)) 226 - } 196 + func (s *State) PrivacyPolicy(w http.ResponseWriter, r *http.Request) { 197 + user := s.oauth.GetUser(r) 198 + s.pages.PrivacyPolicy(w, pages.PrivacyPolicyParams{ 199 + LoggedInUser: user, 200 + }) 227 201 } 228 202 229 - func (s *State) Keys(w http.ResponseWriter, r *http.Request) { 230 - user := chi.URLParam(r, "user") 231 - user = strings.TrimPrefix(user, "@") 232 - 233 - if user == "" { 234 - w.WriteHeader(http.StatusBadRequest) 235 - return 236 - } 237 - 238 - id, err := s.idResolver.ResolveIdent(r.Context(), user) 239 - if err != nil { 240 - w.WriteHeader(http.StatusInternalServerError) 241 - return 242 - } 243 - 244 - pubKeys, err := db.GetPublicKeysForDid(s.db, id.DID.String()) 245 - if err != nil { 246 - w.WriteHeader(http.StatusNotFound) 247 - return 248 - } 249 - 250 - if len(pubKeys) == 0 { 251 - w.WriteHeader(http.StatusNotFound) 203 + func (s *State) HomeOrTimeline(w http.ResponseWriter, r *http.Request) { 204 + if s.oauth.GetUser(r) != nil { 205 + s.Timeline(w, r) 252 206 return 253 207 } 254 - 255 - for _, k := range pubKeys { 256 - key := strings.TrimRight(k.Key, "\n") 257 - w.Write([]byte(fmt.Sprintln(key))) 258 - } 208 + s.Home(w, r) 259 209 } 260 210 261 - // create a signed request and check if a node responds to that 262 - func (s *State) InitKnotServer(w http.ResponseWriter, r *http.Request) { 211 + func (s *State) Timeline(w http.ResponseWriter, r *http.Request) { 263 212 user := s.oauth.GetUser(r) 264 213 265 - domain := chi.URLParam(r, "domain") 266 - if domain == "" { 267 - http.Error(w, "malformed url", http.StatusBadRequest) 268 - return 269 - } 270 - log.Println("checking ", domain) 271 - 272 - secret, err := db.GetRegistrationKey(s.db, domain) 214 + timeline, err := db.MakeTimeline(s.db, 50) 273 215 if err != nil { 274 - log.Printf("no key found for domain %s: %s\n", domain, err) 275 - return 216 + log.Println(err) 217 + s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.") 276 218 } 277 219 278 - client, err := knotclient.NewSignedClient(domain, secret, s.config.Core.Dev) 279 - if err != nil { 280 - log.Println("failed to create client to ", domain) 281 - } 282 - 283 - resp, err := client.Init(user.Did) 220 + repos, err := db.GetTopStarredReposLastWeek(s.db) 284 221 if err != nil { 285 - w.Write([]byte("no dice")) 286 - log.Println("domain was unreachable after 5 seconds") 222 + log.Println(err) 223 + s.pages.Notice(w, "topstarredrepos", "Unable to load.") 287 224 return 288 225 } 289 226 290 - if resp.StatusCode == http.StatusConflict { 291 - log.Println("status conflict", resp.StatusCode) 292 - w.Write([]byte("already registered, sorry!")) 293 - return 294 - } 227 + s.pages.Timeline(w, pages.TimelineParams{ 228 + LoggedInUser: user, 229 + Timeline: timeline, 230 + Repos: repos, 231 + }) 232 + } 295 233 296 - if resp.StatusCode != http.StatusNoContent { 297 - log.Println("status nok", resp.StatusCode) 298 - w.Write([]byte("no dice")) 299 - return 300 - } 234 + func (s *State) UpgradeBanner(w http.ResponseWriter, r *http.Request) { 235 + user := s.oauth.GetUser(r) 236 + l := s.logger.With("handler", "UpgradeBanner") 237 + l = l.With("did", user.Did) 238 + l = l.With("handle", user.Handle) 301 239 302 - // verify response mac 303 - signature := resp.Header.Get("X-Signature") 304 - signatureBytes, err := hex.DecodeString(signature) 240 + regs, err := db.GetRegistrations( 241 + s.db, 242 + db.FilterEq("did", user.Did), 243 + db.FilterEq("needs_upgrade", 1), 244 + ) 305 245 if err != nil { 306 - return 307 - } 308 - 309 - expectedMac := hmac.New(sha256.New, []byte(secret)) 310 - expectedMac.Write([]byte("ok")) 311 - 312 - if !hmac.Equal(expectedMac.Sum(nil), signatureBytes) { 313 - log.Printf("response body signature mismatch: %x\n", signatureBytes) 314 - return 315 - } 316 - 317 - tx, err := s.db.BeginTx(r.Context(), nil) 318 - if err != nil { 319 - log.Println("failed to start tx", err) 320 - http.Error(w, err.Error(), http.StatusInternalServerError) 321 - return 322 - } 323 - defer func() { 324 - tx.Rollback() 325 - err = s.enforcer.E.LoadPolicy() 326 - if err != nil { 327 - log.Println("failed to rollback policies") 328 - } 329 - }() 330 - 331 - // mark as registered 332 - err = db.Register(tx, domain) 333 - if err != nil { 334 - log.Println("failed to register domain", err) 335 - http.Error(w, err.Error(), http.StatusInternalServerError) 336 - return 337 - } 338 - 339 - // set permissions for this did as owner 340 - reg, err := db.RegistrationByDomain(tx, domain) 341 - if err != nil { 342 - log.Println("failed to register domain", err) 343 - http.Error(w, err.Error(), http.StatusInternalServerError) 344 - return 246 + l.Error("non-fatal: failed to get registrations", "err", err) 345 247 } 346 248 347 - // add basic acls for this domain 348 - err = s.enforcer.AddKnot(domain) 249 + spindles, err := db.GetSpindles( 250 + s.db, 251 + db.FilterEq("owner", user.Did), 252 + db.FilterEq("needs_upgrade", 1), 253 + ) 349 254 if err != nil { 350 - log.Println("failed to setup owner of domain", err) 351 - http.Error(w, err.Error(), http.StatusInternalServerError) 352 - return 255 + l.Error("non-fatal: failed to get spindles", "err", err) 353 256 } 354 257 355 - // add this did as owner of this domain 356 - err = s.enforcer.AddKnotOwner(domain, reg.ByDid) 357 - if err != nil { 358 - log.Println("failed to setup owner of domain", err) 359 - http.Error(w, err.Error(), http.StatusInternalServerError) 258 + if regs == nil && spindles == nil { 360 259 return 361 260 } 362 261 363 - err = tx.Commit() 364 - if err != nil { 365 - log.Println("failed to commit changes", err) 366 - http.Error(w, err.Error(), http.StatusInternalServerError) 367 - return 368 - } 369 - 370 - err = s.enforcer.E.SavePolicy() 371 - if err != nil { 372 - log.Println("failed to update ACLs", err) 373 - http.Error(w, err.Error(), http.StatusInternalServerError) 374 - return 375 - } 376 - 377 - // add this knot to knotstream 378 - go s.knotstream.AddSource( 379 - context.Background(), 380 - eventconsumer.NewKnotSource(domain), 381 - ) 382 - 383 - w.Write([]byte("check success")) 262 + s.pages.UpgradeBanner(w, pages.UpgradeBannerParams{ 263 + Registrations: regs, 264 + Spindles: spindles, 265 + }) 384 266 } 385 267 386 - func (s *State) KnotServerInfo(w http.ResponseWriter, r *http.Request) { 387 - domain := chi.URLParam(r, "domain") 388 - if domain == "" { 389 - http.Error(w, "malformed url", http.StatusBadRequest) 390 - return 391 - } 392 - 393 - user := s.oauth.GetUser(r) 394 - reg, err := db.RegistrationByDomain(s.db, domain) 268 + func (s *State) Home(w http.ResponseWriter, r *http.Request) { 269 + timeline, err := db.MakeTimeline(s.db, 5) 395 270 if err != nil { 396 - w.Write([]byte("failed to pull up registration info")) 271 + log.Println(err) 272 + s.pages.Notice(w, "timeline", "Uh oh! Failed to load timeline.") 397 273 return 398 274 } 399 275 400 - var members []string 401 - if reg.Registered != nil { 402 - members, err = s.enforcer.GetUserByRole("server:member", domain) 403 - if err != nil { 404 - w.Write([]byte("failed to fetch member list")) 405 - return 406 - } 407 - } 408 - 409 - var didsToResolve []string 410 - for _, m := range members { 411 - didsToResolve = append(didsToResolve, m) 412 - } 413 - didsToResolve = append(didsToResolve, reg.ByDid) 414 - resolvedIds := s.idResolver.ResolveIdents(r.Context(), didsToResolve) 415 - didHandleMap := make(map[string]string) 416 - for _, identity := range resolvedIds { 417 - if !identity.Handle.IsInvalidHandle() { 418 - didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 419 - } else { 420 - didHandleMap[identity.DID.String()] = identity.DID.String() 421 - } 422 - } 423 - 424 - ok, err := s.enforcer.IsKnotOwner(user.Did, domain) 425 - isOwner := err == nil && ok 426 - 427 - p := pages.KnotParams{ 428 - LoggedInUser: user, 429 - DidHandleMap: didHandleMap, 430 - Registration: reg, 431 - Members: members, 432 - IsOwner: isOwner, 433 - } 434 - 435 - s.pages.Knot(w, p) 436 - } 437 - 438 - // get knots registered by this user 439 - func (s *State) Knots(w http.ResponseWriter, r *http.Request) { 440 - // for now, this is just pubkeys 441 - user := s.oauth.GetUser(r) 442 - registrations, err := db.RegistrationsByDid(s.db, user.Did) 276 + repos, err := db.GetTopStarredReposLastWeek(s.db) 443 277 if err != nil { 444 278 log.Println(err) 279 + s.pages.Notice(w, "topstarredrepos", "Unable to load.") 280 + return 445 281 } 446 282 447 - s.pages.Knots(w, pages.KnotsParams{ 448 - LoggedInUser: user, 449 - Registrations: registrations, 283 + s.pages.Home(w, pages.TimelineParams{ 284 + LoggedInUser: nil, 285 + Timeline: timeline, 286 + Repos: repos, 450 287 }) 451 288 } 452 289 453 - // list members of domain, requires auth and requires owner status 454 - func (s *State) ListMembers(w http.ResponseWriter, r *http.Request) { 455 - domain := chi.URLParam(r, "domain") 456 - if domain == "" { 457 - http.Error(w, "malformed url", http.StatusBadRequest) 458 - return 459 - } 290 + func (s *State) Keys(w http.ResponseWriter, r *http.Request) { 291 + user := chi.URLParam(r, "user") 292 + user = strings.TrimPrefix(user, "@") 460 293 461 - // list all members for this domain 462 - memberDids, err := s.enforcer.GetUserByRole("server:member", domain) 463 - if err != nil { 464 - w.Write([]byte("failed to fetch member list")) 465 - return 466 - } 467 - 468 - w.Write([]byte(strings.Join(memberDids, "\n"))) 469 - return 470 - } 471 - 472 - // add member to domain, requires auth and requires invite access 473 - func (s *State) AddMember(w http.ResponseWriter, r *http.Request) { 474 - domain := chi.URLParam(r, "domain") 475 - if domain == "" { 476 - http.Error(w, "malformed url", http.StatusBadRequest) 477 - return 478 - } 479 - 480 - subjectIdentifier := r.FormValue("subject") 481 - if subjectIdentifier == "" { 482 - http.Error(w, "malformed form", http.StatusBadRequest) 483 - return 484 - } 485 - 486 - subjectIdentity, err := s.idResolver.ResolveIdent(r.Context(), subjectIdentifier) 487 - if err != nil { 488 - w.Write([]byte("failed to resolve member did to a handle")) 489 - return 490 - } 491 - log.Printf("adding %s to %s\n", subjectIdentity.Handle.String(), domain) 492 - 493 - // announce this relation into the firehose, store into owners' pds 494 - client, err := s.oauth.AuthorizedClient(r) 495 - if err != nil { 496 - http.Error(w, "failed to authorize client", http.StatusInternalServerError) 294 + if user == "" { 295 + w.WriteHeader(http.StatusBadRequest) 497 296 return 498 297 } 499 - currentUser := s.oauth.GetUser(r) 500 - createdAt := time.Now().Format(time.RFC3339) 501 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 502 - Collection: tangled.KnotMemberNSID, 503 - Repo: currentUser.Did, 504 - Rkey: appview.TID(), 505 - Record: &lexutil.LexiconTypeDecoder{ 506 - Val: &tangled.KnotMember{ 507 - Subject: subjectIdentity.DID.String(), 508 - Domain: domain, 509 - CreatedAt: createdAt, 510 - }}, 511 - }) 512 298 513 - // invalid record 299 + id, err := s.idResolver.ResolveIdent(r.Context(), user) 514 300 if err != nil { 515 - log.Printf("failed to create record: %s", err) 301 + w.WriteHeader(http.StatusInternalServerError) 516 302 return 517 303 } 518 - log.Println("created atproto record: ", resp.Uri) 519 304 520 - secret, err := db.GetRegistrationKey(s.db, domain) 305 + pubKeys, err := db.GetPublicKeysForDid(s.db, id.DID.String()) 521 306 if err != nil { 522 - log.Printf("no key found for domain %s: %s\n", domain, err) 307 + w.WriteHeader(http.StatusNotFound) 523 308 return 524 309 } 525 310 526 - ksClient, err := knotclient.NewSignedClient(domain, secret, s.config.Core.Dev) 527 - if err != nil { 528 - log.Println("failed to create client to ", domain) 311 + if len(pubKeys) == 0 { 312 + w.WriteHeader(http.StatusNotFound) 529 313 return 530 314 } 531 315 532 - ksResp, err := ksClient.AddMember(subjectIdentity.DID.String()) 533 - if err != nil { 534 - log.Printf("failed to make request to %s: %s", domain, err) 535 - return 316 + for _, k := range pubKeys { 317 + key := strings.TrimRight(k.Key, "\n") 318 + fmt.Fprintln(w, key) 536 319 } 537 - 538 - if ksResp.StatusCode != http.StatusNoContent { 539 - w.Write([]byte(fmt.Sprint("knotserver failed to add member: ", err))) 540 - return 541 - } 542 - 543 - err = s.enforcer.AddKnotMember(domain, subjectIdentity.DID.String()) 544 - if err != nil { 545 - w.Write([]byte(fmt.Sprint("failed to add member: ", err))) 546 - return 547 - } 548 - 549 - w.Write([]byte(fmt.Sprint("added member: ", subjectIdentity.Handle.String()))) 550 - } 551 - 552 - func (s *State) RemoveMember(w http.ResponseWriter, r *http.Request) { 553 320 } 554 321 555 322 func validateRepoName(name string) error { ··· 584 351 return nil 585 352 } 586 353 354 + func stripGitExt(name string) string { 355 + return strings.TrimSuffix(name, ".git") 356 + } 357 + 587 358 func (s *State) NewRepo(w http.ResponseWriter, r *http.Request) { 588 359 switch r.Method { 589 360 case http.MethodGet: ··· 600 371 }) 601 372 602 373 case http.MethodPost: 374 + l := s.logger.With("handler", "NewRepo") 375 + 603 376 user := s.oauth.GetUser(r) 377 + l = l.With("did", user.Did) 378 + l = l.With("handle", user.Handle) 604 379 380 + // form validation 605 381 domain := r.FormValue("domain") 606 382 if domain == "" { 607 383 s.pages.Notice(w, "repo", "Invalid form submission&mdash;missing knot domain.") 608 384 return 609 385 } 386 + l = l.With("knot", domain) 610 387 611 388 repoName := r.FormValue("name") 612 389 if repoName == "" { ··· 618 395 s.pages.Notice(w, "repo", err.Error()) 619 396 return 620 397 } 398 + repoName = stripGitExt(repoName) 399 + l = l.With("repoName", repoName) 621 400 622 401 defaultBranch := r.FormValue("branch") 623 402 if defaultBranch == "" { 624 403 defaultBranch = "main" 625 404 } 405 + l = l.With("defaultBranch", defaultBranch) 626 406 627 407 description := r.FormValue("description") 628 408 409 + // ACL validation 629 410 ok, err := s.enforcer.E.Enforce(user.Did, domain, domain, "repo:create") 630 411 if err != nil || !ok { 412 + l.Info("unauthorized") 631 413 s.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.") 632 414 return 633 415 } 634 416 417 + // Check for existing repos 635 418 existingRepo, err := db.GetRepo(s.db, user.Did, repoName) 636 419 if err == nil && existingRepo != nil { 637 - s.pages.Notice(w, "repo", fmt.Sprintf("A repo by this name already exists on %s", existingRepo.Knot)) 638 - return 639 - } 640 - 641 - secret, err := db.GetRegistrationKey(s.db, domain) 642 - if err != nil { 643 - s.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", domain)) 644 - return 645 - } 646 - 647 - client, err := knotclient.NewSignedClient(domain, secret, s.config.Core.Dev) 648 - if err != nil { 649 - s.pages.Notice(w, "repo", "Failed to connect to knot server.") 420 + l.Info("repo exists") 421 + s.pages.Notice(w, "repo", fmt.Sprintf("You already have a repository by this name on %s", existingRepo.Knot)) 650 422 return 651 423 } 652 424 653 - rkey := appview.TID() 425 + // create atproto record for this repo 426 + rkey := tid.TID() 654 427 repo := &db.Repo{ 655 428 Did: user.Did, 656 429 Name: repoName, ··· 661 434 662 435 xrpcClient, err := s.oauth.AuthorizedClient(r) 663 436 if err != nil { 437 + l.Info("PDS write failed", "err", err) 664 438 s.pages.Notice(w, "repo", "Failed to write record to PDS.") 665 439 return 666 440 } ··· 679 453 }}, 680 454 }) 681 455 if err != nil { 682 - log.Printf("failed to create record: %s", err) 456 + l.Info("PDS write failed", "err", err) 683 457 s.pages.Notice(w, "repo", "Failed to announce repository creation.") 684 458 return 685 459 } 686 - log.Println("created repo record: ", atresp.Uri) 460 + 461 + aturi := atresp.Uri 462 + l = l.With("aturi", aturi) 463 + l.Info("wrote to PDS") 687 464 688 465 tx, err := s.db.BeginTx(r.Context(), nil) 689 466 if err != nil { 690 - log.Println(err) 467 + l.Info("txn failed", "err", err) 691 468 s.pages.Notice(w, "repo", "Failed to save repository information.") 692 469 return 693 470 } 694 - defer func() { 695 - tx.Rollback() 696 - err = s.enforcer.E.LoadPolicy() 697 - if err != nil { 698 - log.Println("failed to rollback policies") 471 + 472 + // The rollback function reverts a few things on failure: 473 + // - the pending txn 474 + // - the ACLs 475 + // - the atproto record created 476 + rollback := func() { 477 + err1 := tx.Rollback() 478 + err2 := s.enforcer.E.LoadPolicy() 479 + err3 := rollbackRecord(context.Background(), aturi, xrpcClient) 480 + 481 + // ignore txn complete errors, this is okay 482 + if errors.Is(err1, sql.ErrTxDone) { 483 + err1 = nil 699 484 } 700 - }() 701 485 702 - resp, err := client.NewRepo(user.Did, repoName, defaultBranch) 486 + if errs := errors.Join(err1, err2, err3); errs != nil { 487 + l.Error("failed to rollback changes", "errs", errs) 488 + return 489 + } 490 + } 491 + defer rollback() 492 + 493 + client, err := s.oauth.ServiceClient( 494 + r, 495 + oauth.WithService(domain), 496 + oauth.WithLxm(tangled.RepoCreateNSID), 497 + oauth.WithDev(s.config.Core.Dev), 498 + ) 703 499 if err != nil { 704 - s.pages.Notice(w, "repo", "Failed to create repository on knot server.") 500 + l.Error("service auth failed", "err", err) 501 + s.pages.Notice(w, "repo", "Failed to reach PDS.") 705 502 return 706 503 } 707 504 708 - switch resp.StatusCode { 709 - case http.StatusConflict: 710 - s.pages.Notice(w, "repo", "A repository with that name already exists.") 505 + xe := tangled.RepoCreate( 506 + r.Context(), 507 + client, 508 + &tangled.RepoCreate_Input{ 509 + Rkey: rkey, 510 + }, 511 + ) 512 + if err := xrpcclient.HandleXrpcErr(xe); err != nil { 513 + l.Error("xrpc error", "xe", xe) 514 + s.pages.Notice(w, "repo", err.Error()) 711 515 return 712 - case http.StatusInternalServerError: 713 - s.pages.Notice(w, "repo", "Failed to create repository on knot. Try again later.") 714 - case http.StatusNoContent: 715 - // continue 716 516 } 717 517 718 - repo.AtUri = atresp.Uri 719 518 err = db.AddRepo(tx, repo) 720 519 if err != nil { 721 - log.Println(err) 520 + l.Error("db write failed", "err", err) 722 521 s.pages.Notice(w, "repo", "Failed to save repository information.") 723 522 return 724 523 } ··· 727 526 p, _ := securejoin.SecureJoin(user.Did, repoName) 728 527 err = s.enforcer.AddRepo(user.Did, domain, p) 729 528 if err != nil { 730 - log.Println(err) 529 + l.Error("acl setup failed", "err", err) 731 530 s.pages.Notice(w, "repo", "Failed to set up repository permissions.") 732 531 return 733 532 } 734 533 735 534 err = tx.Commit() 736 535 if err != nil { 737 - log.Println("failed to commit changes", err) 536 + l.Error("txn commit failed", "err", err) 738 537 http.Error(w, err.Error(), http.StatusInternalServerError) 739 538 return 740 539 } 741 540 742 541 err = s.enforcer.E.SavePolicy() 743 542 if err != nil { 744 - log.Println("failed to update ACLs", err) 543 + l.Error("acl save failed", "err", err) 745 544 http.Error(w, err.Error(), http.StatusInternalServerError) 746 545 return 747 546 } 748 547 749 - if !s.config.Core.Dev { 750 - err = s.posthog.Enqueue(posthog.Capture{ 751 - DistinctId: user.Did, 752 - Event: "new_repo", 753 - Properties: posthog.Properties{"repo": repoName, "repo_at": repo.AtUri}, 754 - }) 755 - if err != nil { 756 - log.Println("failed to enqueue posthog event:", err) 757 - } 758 - } 548 + // reset the ATURI because the transaction completed successfully 549 + aturi = "" 759 550 551 + s.notifier.NewRepo(r.Context(), repo) 760 552 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, repoName)) 761 - return 553 + } 554 + } 555 + 556 + // this is used to rollback changes made to the PDS 557 + // 558 + // it is a no-op if the provided ATURI is empty 559 + func rollbackRecord(ctx context.Context, aturi string, xrpcc *xrpcclient.Client) error { 560 + if aturi == "" { 561 + return nil 762 562 } 563 + 564 + parsed := syntax.ATURI(aturi) 565 + 566 + collection := parsed.Collection().String() 567 + repo := parsed.Authority().String() 568 + rkey := parsed.RecordKey().String() 569 + 570 + _, err := xrpcc.RepoDeleteRecord(ctx, &comatproto.RepoDeleteRecord_Input{ 571 + Collection: collection, 572 + Repo: repo, 573 + Rkey: rkey, 574 + }) 575 + return err 763 576 }
+14 -6
appview/state/userutil/userutil.go
··· 5 5 "strings" 6 6 ) 7 7 8 + var ( 9 + handleRegex = regexp.MustCompile(`^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$`) 10 + didRegex = regexp.MustCompile(`^did:[a-z]+:[a-zA-Z0-9._:%-]*[a-zA-Z0-9._-]$`) 11 + ) 12 + 8 13 func IsHandleNoAt(s string) bool { 9 14 // ref: https://atproto.com/specs/handle 10 - re := regexp.MustCompile(`^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$`) 11 - return re.MatchString(s) 15 + return handleRegex.MatchString(s) 12 16 } 13 17 14 18 func UnflattenDid(s string) string { ··· 29 33 // Reconstruct as a standard DID format using Replace 30 34 // Example: "did-plc-xyz-abc" becomes "did:plc:xyz-abc" 31 35 reconstructed := strings.Replace(s, "-", ":", 2) 32 - re := regexp.MustCompile(`^did:[a-z]+:[a-zA-Z0-9._:%-]*[a-zA-Z0-9._-]$`) 33 36 34 - return re.MatchString(reconstructed) 37 + return didRegex.MatchString(reconstructed) 35 38 } 36 39 37 40 // FlattenDid converts a DID to a flattened format. ··· 46 49 47 50 // IsDid checks if the given string is a standard DID. 48 51 func IsDid(s string) bool { 49 - re := regexp.MustCompile(`^did:[a-z]+:[a-zA-Z0-9._:%-]*[a-zA-Z0-9._-]$`) 50 - return re.MatchString(s) 52 + return didRegex.MatchString(s) 53 + } 54 + 55 + var subdomainRegex = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{2,61}[a-z0-9])?$`) 56 + 57 + func IsValidSubdomain(name string) bool { 58 + return len(name) >= 4 && len(name) <= 63 && subdomainRegex.MatchString(name) 51 59 }
+415
appview/strings/strings.go
··· 1 + package strings 2 + 3 + import ( 4 + "fmt" 5 + "log/slog" 6 + "net/http" 7 + "path" 8 + "strconv" 9 + "time" 10 + 11 + "tangled.sh/tangled.sh/core/api/tangled" 12 + "tangled.sh/tangled.sh/core/appview/config" 13 + "tangled.sh/tangled.sh/core/appview/db" 14 + "tangled.sh/tangled.sh/core/appview/middleware" 15 + "tangled.sh/tangled.sh/core/appview/notify" 16 + "tangled.sh/tangled.sh/core/appview/oauth" 17 + "tangled.sh/tangled.sh/core/appview/pages" 18 + "tangled.sh/tangled.sh/core/appview/pages/markup" 19 + "tangled.sh/tangled.sh/core/eventconsumer" 20 + "tangled.sh/tangled.sh/core/idresolver" 21 + "tangled.sh/tangled.sh/core/rbac" 22 + "tangled.sh/tangled.sh/core/tid" 23 + 24 + "github.com/bluesky-social/indigo/api/atproto" 25 + "github.com/bluesky-social/indigo/atproto/identity" 26 + "github.com/bluesky-social/indigo/atproto/syntax" 27 + lexutil "github.com/bluesky-social/indigo/lex/util" 28 + "github.com/go-chi/chi/v5" 29 + ) 30 + 31 + type Strings struct { 32 + Db *db.DB 33 + OAuth *oauth.OAuth 34 + Pages *pages.Pages 35 + Config *config.Config 36 + Enforcer *rbac.Enforcer 37 + IdResolver *idresolver.Resolver 38 + Logger *slog.Logger 39 + Knotstream *eventconsumer.Consumer 40 + Notifier notify.Notifier 41 + } 42 + 43 + func (s *Strings) Router(mw *middleware.Middleware) http.Handler { 44 + r := chi.NewRouter() 45 + 46 + r. 47 + Get("/", s.timeline) 48 + 49 + r. 50 + With(mw.ResolveIdent()). 51 + Route("/{user}", func(r chi.Router) { 52 + r.Get("/", s.dashboard) 53 + 54 + r.Route("/{rkey}", func(r chi.Router) { 55 + r.Get("/", s.contents) 56 + r.Delete("/", s.delete) 57 + r.Get("/raw", s.contents) 58 + r.Get("/edit", s.edit) 59 + r.Post("/edit", s.edit) 60 + r. 61 + With(middleware.AuthMiddleware(s.OAuth)). 62 + Post("/comment", s.comment) 63 + }) 64 + }) 65 + 66 + r. 67 + With(middleware.AuthMiddleware(s.OAuth)). 68 + Route("/new", func(r chi.Router) { 69 + r.Get("/", s.create) 70 + r.Post("/", s.create) 71 + }) 72 + 73 + return r 74 + } 75 + 76 + func (s *Strings) timeline(w http.ResponseWriter, r *http.Request) { 77 + l := s.Logger.With("handler", "timeline") 78 + 79 + strings, err := db.GetStrings(s.Db, 50) 80 + if err != nil { 81 + l.Error("failed to fetch string", "err", err) 82 + w.WriteHeader(http.StatusInternalServerError) 83 + return 84 + } 85 + 86 + s.Pages.StringsTimeline(w, pages.StringTimelineParams{ 87 + LoggedInUser: s.OAuth.GetUser(r), 88 + Strings: strings, 89 + }) 90 + } 91 + 92 + func (s *Strings) contents(w http.ResponseWriter, r *http.Request) { 93 + l := s.Logger.With("handler", "contents") 94 + 95 + id, ok := r.Context().Value("resolvedId").(identity.Identity) 96 + if !ok { 97 + l.Error("malformed middleware") 98 + w.WriteHeader(http.StatusInternalServerError) 99 + return 100 + } 101 + l = l.With("did", id.DID, "handle", id.Handle) 102 + 103 + rkey := chi.URLParam(r, "rkey") 104 + if rkey == "" { 105 + l.Error("malformed url, empty rkey") 106 + w.WriteHeader(http.StatusBadRequest) 107 + return 108 + } 109 + l = l.With("rkey", rkey) 110 + 111 + strings, err := db.GetStrings( 112 + s.Db, 113 + 0, 114 + db.FilterEq("did", id.DID), 115 + db.FilterEq("rkey", rkey), 116 + ) 117 + if err != nil { 118 + l.Error("failed to fetch string", "err", err) 119 + w.WriteHeader(http.StatusInternalServerError) 120 + return 121 + } 122 + if len(strings) < 1 { 123 + l.Error("string not found") 124 + s.Pages.Error404(w) 125 + return 126 + } 127 + if len(strings) != 1 { 128 + l.Error("incorrect number of records returned", "len(strings)", len(strings)) 129 + w.WriteHeader(http.StatusInternalServerError) 130 + return 131 + } 132 + string := strings[0] 133 + 134 + if path.Base(r.URL.Path) == "raw" { 135 + w.Header().Set("Content-Type", "text/plain; charset=utf-8") 136 + if string.Filename != "" { 137 + w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", string.Filename)) 138 + } 139 + w.Header().Set("Content-Length", strconv.Itoa(len(string.Contents))) 140 + 141 + _, err = w.Write([]byte(string.Contents)) 142 + if err != nil { 143 + l.Error("failed to write raw response", "err", err) 144 + } 145 + return 146 + } 147 + 148 + var showRendered, renderToggle bool 149 + if markup.GetFormat(string.Filename) == markup.FormatMarkdown { 150 + renderToggle = true 151 + showRendered = r.URL.Query().Get("code") != "true" 152 + } 153 + 154 + s.Pages.SingleString(w, pages.SingleStringParams{ 155 + LoggedInUser: s.OAuth.GetUser(r), 156 + RenderToggle: renderToggle, 157 + ShowRendered: showRendered, 158 + String: string, 159 + Stats: string.Stats(), 160 + Owner: id, 161 + }) 162 + } 163 + 164 + func (s *Strings) dashboard(w http.ResponseWriter, r *http.Request) { 165 + http.Redirect(w, r, fmt.Sprintf("/%s?tab=strings", chi.URLParam(r, "user")), http.StatusFound) 166 + } 167 + 168 + func (s *Strings) edit(w http.ResponseWriter, r *http.Request) { 169 + l := s.Logger.With("handler", "edit") 170 + 171 + user := s.OAuth.GetUser(r) 172 + 173 + id, ok := r.Context().Value("resolvedId").(identity.Identity) 174 + if !ok { 175 + l.Error("malformed middleware") 176 + w.WriteHeader(http.StatusInternalServerError) 177 + return 178 + } 179 + l = l.With("did", id.DID, "handle", id.Handle) 180 + 181 + rkey := chi.URLParam(r, "rkey") 182 + if rkey == "" { 183 + l.Error("malformed url, empty rkey") 184 + w.WriteHeader(http.StatusBadRequest) 185 + return 186 + } 187 + l = l.With("rkey", rkey) 188 + 189 + // get the string currently being edited 190 + all, err := db.GetStrings( 191 + s.Db, 192 + 0, 193 + db.FilterEq("did", id.DID), 194 + db.FilterEq("rkey", rkey), 195 + ) 196 + if err != nil { 197 + l.Error("failed to fetch string", "err", err) 198 + w.WriteHeader(http.StatusInternalServerError) 199 + return 200 + } 201 + if len(all) != 1 { 202 + l.Error("incorrect number of records returned", "len(strings)", len(all)) 203 + w.WriteHeader(http.StatusInternalServerError) 204 + return 205 + } 206 + first := all[0] 207 + 208 + // verify that the logged in user owns this string 209 + if user.Did != id.DID.String() { 210 + l.Error("unauthorized request", "expected", id.DID, "got", user.Did) 211 + w.WriteHeader(http.StatusUnauthorized) 212 + return 213 + } 214 + 215 + switch r.Method { 216 + case http.MethodGet: 217 + // return the form with prefilled fields 218 + s.Pages.PutString(w, pages.PutStringParams{ 219 + LoggedInUser: s.OAuth.GetUser(r), 220 + Action: "edit", 221 + String: first, 222 + }) 223 + case http.MethodPost: 224 + fail := func(msg string, err error) { 225 + l.Error(msg, "err", err) 226 + s.Pages.Notice(w, "error", msg) 227 + } 228 + 229 + filename := r.FormValue("filename") 230 + if filename == "" { 231 + fail("Empty filename.", nil) 232 + return 233 + } 234 + 235 + content := r.FormValue("content") 236 + if content == "" { 237 + fail("Empty contents.", nil) 238 + return 239 + } 240 + 241 + description := r.FormValue("description") 242 + 243 + // construct new string from form values 244 + entry := db.String{ 245 + Did: first.Did, 246 + Rkey: first.Rkey, 247 + Filename: filename, 248 + Description: description, 249 + Contents: content, 250 + Created: first.Created, 251 + } 252 + 253 + record := entry.AsRecord() 254 + 255 + client, err := s.OAuth.AuthorizedClient(r) 256 + if err != nil { 257 + fail("Failed to create record.", err) 258 + return 259 + } 260 + 261 + // first replace the existing record in the PDS 262 + ex, err := client.RepoGetRecord(r.Context(), "", tangled.StringNSID, entry.Did.String(), entry.Rkey) 263 + if err != nil { 264 + fail("Failed to updated existing record.", err) 265 + return 266 + } 267 + resp, err := client.RepoPutRecord(r.Context(), &atproto.RepoPutRecord_Input{ 268 + Collection: tangled.StringNSID, 269 + Repo: entry.Did.String(), 270 + Rkey: entry.Rkey, 271 + SwapRecord: ex.Cid, 272 + Record: &lexutil.LexiconTypeDecoder{ 273 + Val: &record, 274 + }, 275 + }) 276 + if err != nil { 277 + fail("Failed to updated existing record.", err) 278 + return 279 + } 280 + l := l.With("aturi", resp.Uri) 281 + l.Info("edited string") 282 + 283 + // if that went okay, updated the db 284 + if err = db.AddString(s.Db, entry); err != nil { 285 + fail("Failed to update string.", err) 286 + return 287 + } 288 + 289 + s.Notifier.EditString(r.Context(), &entry) 290 + 291 + // if that went okay, redir to the string 292 + s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+entry.Rkey) 293 + } 294 + 295 + } 296 + 297 + func (s *Strings) create(w http.ResponseWriter, r *http.Request) { 298 + l := s.Logger.With("handler", "create") 299 + user := s.OAuth.GetUser(r) 300 + 301 + switch r.Method { 302 + case http.MethodGet: 303 + s.Pages.PutString(w, pages.PutStringParams{ 304 + LoggedInUser: s.OAuth.GetUser(r), 305 + Action: "new", 306 + }) 307 + case http.MethodPost: 308 + fail := func(msg string, err error) { 309 + l.Error(msg, "err", err) 310 + s.Pages.Notice(w, "error", msg) 311 + } 312 + 313 + filename := r.FormValue("filename") 314 + if filename == "" { 315 + fail("Empty filename.", nil) 316 + return 317 + } 318 + 319 + content := r.FormValue("content") 320 + if content == "" { 321 + fail("Empty contents.", nil) 322 + return 323 + } 324 + 325 + description := r.FormValue("description") 326 + 327 + string := db.String{ 328 + Did: syntax.DID(user.Did), 329 + Rkey: tid.TID(), 330 + Filename: filename, 331 + Description: description, 332 + Contents: content, 333 + Created: time.Now(), 334 + } 335 + 336 + record := string.AsRecord() 337 + 338 + client, err := s.OAuth.AuthorizedClient(r) 339 + if err != nil { 340 + fail("Failed to create record.", err) 341 + return 342 + } 343 + 344 + resp, err := client.RepoPutRecord(r.Context(), &atproto.RepoPutRecord_Input{ 345 + Collection: tangled.StringNSID, 346 + Repo: user.Did, 347 + Rkey: string.Rkey, 348 + Record: &lexutil.LexiconTypeDecoder{ 349 + Val: &record, 350 + }, 351 + }) 352 + if err != nil { 353 + fail("Failed to create record.", err) 354 + return 355 + } 356 + l := l.With("aturi", resp.Uri) 357 + l.Info("created record") 358 + 359 + // insert into DB 360 + if err = db.AddString(s.Db, string); err != nil { 361 + fail("Failed to create string.", err) 362 + return 363 + } 364 + 365 + s.Notifier.NewString(r.Context(), &string) 366 + 367 + // successful 368 + s.Pages.HxRedirect(w, "/strings/"+user.Handle+"/"+string.Rkey) 369 + } 370 + } 371 + 372 + func (s *Strings) delete(w http.ResponseWriter, r *http.Request) { 373 + l := s.Logger.With("handler", "create") 374 + user := s.OAuth.GetUser(r) 375 + fail := func(msg string, err error) { 376 + l.Error(msg, "err", err) 377 + s.Pages.Notice(w, "error", msg) 378 + } 379 + 380 + id, ok := r.Context().Value("resolvedId").(identity.Identity) 381 + if !ok { 382 + l.Error("malformed middleware") 383 + w.WriteHeader(http.StatusInternalServerError) 384 + return 385 + } 386 + l = l.With("did", id.DID, "handle", id.Handle) 387 + 388 + rkey := chi.URLParam(r, "rkey") 389 + if rkey == "" { 390 + l.Error("malformed url, empty rkey") 391 + w.WriteHeader(http.StatusBadRequest) 392 + return 393 + } 394 + 395 + if user.Did != id.DID.String() { 396 + fail("You cannot delete this string", fmt.Errorf("unauthorized deletion, %s != %s", user.Did, id.DID.String())) 397 + return 398 + } 399 + 400 + if err := db.DeleteString( 401 + s.Db, 402 + db.FilterEq("did", user.Did), 403 + db.FilterEq("rkey", rkey), 404 + ); err != nil { 405 + fail("Failed to delete string.", err) 406 + return 407 + } 408 + 409 + s.Notifier.DeleteString(r.Context(), user.Did, rkey) 410 + 411 + s.Pages.HxRedirect(w, "/strings/"+user.Handle) 412 + } 413 + 414 + func (s *Strings) comment(w http.ResponseWriter, r *http.Request) { 415 + }
-11
appview/tid.go
··· 1 - package appview 2 - 3 - import ( 4 - "github.com/bluesky-social/indigo/atproto/syntax" 5 - ) 6 - 7 - var c syntax.TIDClock = syntax.NewTIDClock(0) 8 - 9 - func TID() string { 10 - return c.Next().String() 11 - }
+53
appview/validator/issue.go
··· 1 + package validator 2 + 3 + import ( 4 + "fmt" 5 + "strings" 6 + 7 + "tangled.sh/tangled.sh/core/appview/db" 8 + ) 9 + 10 + func (v *Validator) ValidateIssueComment(comment *db.IssueComment) error { 11 + // if comments have parents, only ingest ones that are 1 level deep 12 + if comment.ReplyTo != nil { 13 + parents, err := db.GetIssueComments(v.db, db.FilterEq("at_uri", *comment.ReplyTo)) 14 + if err != nil { 15 + return fmt.Errorf("failed to fetch parent comment: %w", err) 16 + } 17 + if len(parents) != 1 { 18 + return fmt.Errorf("incorrect number of parent comments returned: %d", len(parents)) 19 + } 20 + 21 + // depth check 22 + parent := parents[0] 23 + if parent.ReplyTo != nil { 24 + return fmt.Errorf("incorrect depth, this comment is replying at depth >1") 25 + } 26 + } 27 + 28 + if sb := strings.TrimSpace(v.sanitizer.SanitizeDefault(comment.Body)); sb == "" { 29 + return fmt.Errorf("body is empty after HTML sanitization") 30 + } 31 + 32 + return nil 33 + } 34 + 35 + func (v *Validator) ValidateIssue(issue *db.Issue) error { 36 + if issue.Title == "" { 37 + return fmt.Errorf("issue title is empty") 38 + } 39 + 40 + if issue.Body == "" { 41 + return fmt.Errorf("issue body is empty") 42 + } 43 + 44 + if st := strings.TrimSpace(v.sanitizer.SanitizeDescription(issue.Title)); st == "" { 45 + return fmt.Errorf("title is empty after HTML sanitization") 46 + } 47 + 48 + if sb := strings.TrimSpace(v.sanitizer.SanitizeDefault(issue.Body)); sb == "" { 49 + return fmt.Errorf("body is empty after HTML sanitization") 50 + } 51 + 52 + return nil 53 + }
+18
appview/validator/validator.go
··· 1 + package validator 2 + 3 + import ( 4 + "tangled.sh/tangled.sh/core/appview/db" 5 + "tangled.sh/tangled.sh/core/appview/pages/markup" 6 + ) 7 + 8 + type Validator struct { 9 + db *db.DB 10 + sanitizer markup.Sanitizer 11 + } 12 + 13 + func New(db *db.DB) *Validator { 14 + return &Validator{ 15 + db: db, 16 + sanitizer: markup.NewSanitizer(), 17 + } 18 + }
+46
appview/xrpcclient/xrpc.go
··· 3 3 import ( 4 4 "bytes" 5 5 "context" 6 + "errors" 6 7 "io" 8 + "net/http" 7 9 8 10 "github.com/bluesky-social/indigo/api/atproto" 9 11 "github.com/bluesky-social/indigo/xrpc" 12 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 10 13 oauth "tangled.sh/icyphox.sh/atproto-oauth" 14 + ) 15 + 16 + var ( 17 + ErrXrpcUnsupported = errors.New("xrpc not supported on this knot") 18 + ErrXrpcUnauthorized = errors.New("unauthorized xrpc request") 19 + ErrXrpcFailed = errors.New("xrpc request failed") 20 + ErrXrpcInvalid = errors.New("invalid xrpc request") 11 21 ) 12 22 13 23 type Client struct { ··· 87 97 88 98 return &out, nil 89 99 } 100 + 101 + func (c *Client) ServerGetServiceAuth(ctx context.Context, aud string, exp int64, lxm string) (*atproto.ServerGetServiceAuth_Output, error) { 102 + var out atproto.ServerGetServiceAuth_Output 103 + 104 + params := map[string]interface{}{ 105 + "aud": aud, 106 + "exp": exp, 107 + "lxm": lxm, 108 + } 109 + if err := c.Do(ctx, c.authArgs, xrpc.Query, "", "com.atproto.server.getServiceAuth", params, nil, &out); err != nil { 110 + return nil, err 111 + } 112 + 113 + return &out, nil 114 + } 115 + 116 + // produces a more manageable error 117 + func HandleXrpcErr(err error) error { 118 + if err == nil { 119 + return nil 120 + } 121 + 122 + var xrpcerr *indigoxrpc.Error 123 + if ok := errors.As(err, &xrpcerr); !ok { 124 + return ErrXrpcInvalid 125 + } 126 + 127 + switch xrpcerr.StatusCode { 128 + case http.StatusNotFound: 129 + return ErrXrpcUnsupported 130 + case http.StatusUnauthorized: 131 + return ErrXrpcUnauthorized 132 + default: 133 + return ErrXrpcFailed 134 + } 135 + }
+118 -68
avatar/src/index.js
··· 1 1 export default { 2 - async fetch(request, env) { 3 - const url = new URL(request.url); 4 - const { pathname } = url; 2 + async fetch(request, env) { 3 + // Helper function to generate a color from a string 4 + const stringToColor = (str) => { 5 + let hash = 0; 6 + for (let i = 0; i < str.length; i++) { 7 + hash = str.charCodeAt(i) + ((hash << 5) - hash); 8 + } 9 + let color = "#"; 10 + for (let i = 0; i < 3; i++) { 11 + const value = (hash >> (i * 8)) & 0xff; 12 + color += ("00" + value.toString(16)).substr(-2); 13 + } 14 + return color; 15 + }; 5 16 6 - if (!pathname || pathname === '/') { 7 - return new Response(`This is Tangled's avatar service. It fetches your pretty avatar from Bluesky and caches it on Cloudflare. 8 - You can't use this directly unforunately since all requests are signed and may only originate from the appview.`); 9 - } 17 + const url = new URL(request.url); 18 + const { pathname, searchParams } = url; 10 19 11 - const cache = caches.default; 20 + if (!pathname || pathname === "/") { 21 + return new Response(`This is Tangled's avatar service. It fetches your pretty avatar from Bluesky and caches it on Cloudflare. 22 + You can't use this directly unfortunately since all requests are signed and may only originate from the appview.`); 23 + } 12 24 13 - let cacheKey = request.url; 14 - let response = await cache.match(cacheKey); 15 - if (response) { 16 - return response; 17 - } 25 + const size = searchParams.get("size"); 26 + const resizeToTiny = size === "tiny"; 18 27 19 - const pathParts = pathname.slice(1).split('/'); 20 - if (pathParts.length < 2) { 21 - return new Response('Bad URL', { status: 400 }); 22 - } 28 + const cache = caches.default; 29 + let cacheKey = request.url; 30 + let response = await cache.match(cacheKey); 31 + if (response) return response; 23 32 24 - const [signatureHex, actor] = pathParts; 33 + const pathParts = pathname.slice(1).split("/"); 34 + if (pathParts.length < 2) { 35 + return new Response("Bad URL", { status: 400 }); 36 + } 25 37 26 - const actorBytes = new TextEncoder().encode(actor); 38 + const [signatureHex, actor] = pathParts; 39 + const actorBytes = new TextEncoder().encode(actor); 27 40 28 - const key = await crypto.subtle.importKey( 29 - 'raw', 30 - new TextEncoder().encode(env.AVATAR_SHARED_SECRET), 31 - { name: 'HMAC', hash: 'SHA-256' }, 32 - false, 33 - ['sign', 'verify'], 34 - ); 41 + const key = await crypto.subtle.importKey( 42 + "raw", 43 + new TextEncoder().encode(env.AVATAR_SHARED_SECRET), 44 + { name: "HMAC", hash: "SHA-256" }, 45 + false, 46 + ["sign", "verify"], 47 + ); 35 48 36 - const computedSigBuffer = await crypto.subtle.sign('HMAC', key, actorBytes); 37 - const computedSig = Array.from(new Uint8Array(computedSigBuffer)) 38 - .map((b) => b.toString(16).padStart(2, '0')) 39 - .join(''); 49 + const computedSigBuffer = await crypto.subtle.sign("HMAC", key, actorBytes); 50 + const computedSig = Array.from(new Uint8Array(computedSigBuffer)) 51 + .map((b) => b.toString(16).padStart(2, "0")) 52 + .join(""); 40 53 41 - console.log({ 42 - level: 'debug', 43 - message: 'avatar request for: ' + actor, 44 - computedSignature: computedSig, 45 - providedSignature: signatureHex, 46 - }); 54 + console.log({ 55 + level: "debug", 56 + message: "avatar request for: " + actor, 57 + computedSignature: computedSig, 58 + providedSignature: signatureHex, 59 + }); 47 60 48 - const sigBytes = Uint8Array.from(signatureHex.match(/.{2}/g).map((b) => parseInt(b, 16))); 49 - const valid = await crypto.subtle.verify('HMAC', key, sigBytes, actorBytes); 61 + const sigBytes = Uint8Array.from( 62 + signatureHex.match(/.{2}/g).map((b) => parseInt(b, 16)), 63 + ); 64 + const valid = await crypto.subtle.verify("HMAC", key, sigBytes, actorBytes); 50 65 51 - if (!valid) { 52 - return new Response('Invalid signature', { status: 403 }); 53 - } 66 + if (!valid) { 67 + return new Response("Invalid signature", { status: 403 }); 68 + } 54 69 55 - try { 56 - const profileResponse = await fetch(`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${actor}`, { method: 'GET' }); 57 - const profile = await profileResponse.json(); 58 - const avatar = profile.avatar; 70 + try { 71 + const profileResponse = await fetch( 72 + `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${actor}`, 73 + ); 74 + const profile = await profileResponse.json(); 75 + const avatar = profile.avatar; 59 76 60 - if (!avatar) { 61 - return new Response(`avatar not found for ${actor}.`, { status: 404 }); 62 - } 77 + let avatarUrl = profile.avatar; 78 + 79 + if (!avatarUrl) { 80 + // Generate a random color based on the actor string 81 + const bgColor = stringToColor(actor); 82 + const size = resizeToTiny ? 32 : 128; 83 + const svg = `<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" xmlns="http://www.w3.org/2000/svg"><rect width="${size}" height="${size}" fill="${bgColor}"/></svg>`; 84 + const svgData = new TextEncoder().encode(svg); 85 + 86 + response = new Response(svgData, { 87 + headers: { 88 + "Content-Type": "image/svg+xml", 89 + "Cache-Control": "public, max-age=43200", 90 + }, 91 + }); 92 + await cache.put(cacheKey, response.clone()); 93 + return response; 94 + } 63 95 64 - // fetch the actual avatar image 65 - const avatarResponse = await fetch(avatar); 66 - if (!avatarResponse.ok) { 67 - return new Response(`failed to fetch avatar for ${actor}.`, { status: avatarResponse.status }); 68 - } 96 + // Resize if requested 97 + let avatarResponse; 98 + if (resizeToTiny) { 99 + avatarResponse = await fetch(avatarUrl, { 100 + cf: { 101 + image: { 102 + width: 32, 103 + height: 32, 104 + fit: "cover", 105 + format: "webp", 106 + }, 107 + }, 108 + }); 109 + } else { 110 + avatarResponse = await fetch(avatarUrl); 111 + } 69 112 70 - const avatarData = await avatarResponse.arrayBuffer(); 71 - const contentType = avatarResponse.headers.get('content-type') || 'image/jpeg'; 113 + if (!avatarResponse.ok) { 114 + return new Response(`failed to fetch avatar for ${actor}.`, { 115 + status: avatarResponse.status, 116 + }); 117 + } 72 118 73 - response = new Response(avatarData, { 74 - headers: { 75 - 'Content-Type': contentType, 76 - 'Cache-Control': 'public, max-age=43200', // 12 h 77 - }, 78 - }); 119 + const avatarData = await avatarResponse.arrayBuffer(); 120 + const contentType = 121 + avatarResponse.headers.get("content-type") || "image/jpeg"; 79 122 80 - // cache it in cf using request.url as the key 81 - await cache.put(cacheKey, response.clone()); 123 + response = new Response(avatarData, { 124 + headers: { 125 + "Content-Type": contentType, 126 + "Cache-Control": "public, max-age=43200", 127 + }, 128 + }); 82 129 83 - return response; 84 - } catch (error) { 85 - return new Response(`error fetching avatar: ${error.message}`, { status: 500 }); 86 - } 87 - }, 130 + await cache.put(cacheKey, response.clone()); 131 + return response; 132 + } catch (error) { 133 + return new Response(`error fetching avatar: ${error.message}`, { 134 + status: 500, 135 + }); 136 + } 137 + }, 88 138 };
+3
cmd/appview/main.go
··· 23 23 } 24 24 25 25 state, err := state.Make(ctx, c) 26 + defer func() { 27 + log.Println(state.Close()) 28 + }() 26 29 27 30 if err != nil { 28 31 log.Fatal(err)
-50
cmd/eventconsumer/main.go
··· 1 - package main 2 - 3 - import ( 4 - "context" 5 - "flag" 6 - "fmt" 7 - "strings" 8 - "time" 9 - 10 - "tangled.sh/tangled.sh/core/knotclient" 11 - ) 12 - 13 - func main() { 14 - knots := flag.String("knots", "", "list of knots to connect to") 15 - retryFlag := flag.Duration("retry", 1*time.Minute, "retry interval") 16 - maxRetryFlag := flag.Duration("max-retry", 30*time.Minute, "max retry interval") 17 - workerCount := flag.Int("workers", 10, "goroutine pool size") 18 - 19 - flag.Parse() 20 - 21 - if *knots == "" { 22 - fmt.Println("error: -knots is required") 23 - flag.Usage() 24 - return 25 - } 26 - 27 - ccfg := knotclient.ConsumerConfig{ 28 - ProcessFunc: processEvent, 29 - RetryInterval: *retryFlag, 30 - MaxRetryInterval: *maxRetryFlag, 31 - WorkerCount: *workerCount, 32 - Dev: true, 33 - } 34 - for k := range strings.SplitSeq(*knots, ",") { 35 - ccfg.AddEventSource(knotclient.NewEventSource(k)) 36 - } 37 - 38 - consumer := knotclient.NewEventConsumer(ccfg) 39 - 40 - ctx, cancel := context.WithCancel(context.Background()) 41 - consumer.Start(ctx) 42 - time.Sleep(1 * time.Hour) 43 - cancel() 44 - consumer.Stop() 45 - } 46 - 47 - func processEvent(_ context.Context, source knotclient.EventSource, msg knotclient.Message) error { 48 - fmt.Printf("From %s (%s, %s): %s\n", source.Knot, msg.Rkey, msg.Nsid, string(msg.EventJson)) 49 - return nil 50 - }
+10 -7
cmd/gen.go
··· 15 15 "api/tangled/cbor_gen.go", 16 16 "tangled", 17 17 tangled.ActorProfile{}, 18 + tangled.FeedReaction{}, 18 19 tangled.FeedStar{}, 19 20 tangled.GitRefUpdate{}, 21 + tangled.GitRefUpdate_CommitCountBreakdown{}, 22 + tangled.GitRefUpdate_IndividualEmailCommitCount{}, 23 + tangled.GitRefUpdate_LangBreakdown{}, 24 + tangled.GitRefUpdate_IndividualLanguageSize{}, 20 25 tangled.GitRefUpdate_Meta{}, 21 - tangled.GitRefUpdate_Meta_CommitCount{}, 22 - tangled.GitRefUpdate_Meta_CommitCount_ByEmail_Elem{}, 23 26 tangled.GraphFollow{}, 27 + tangled.Knot{}, 24 28 tangled.KnotMember{}, 25 29 tangled.Pipeline{}, 26 30 tangled.Pipeline_CloneOpts{}, 27 - tangled.Pipeline_Dependencies_Elem{}, 28 31 tangled.Pipeline_ManualTriggerData{}, 29 - tangled.Pipeline_ManualTriggerData_Inputs_Elem{}, 32 + tangled.Pipeline_Pair{}, 30 33 tangled.Pipeline_PullRequestTriggerData{}, 31 34 tangled.Pipeline_PushTriggerData{}, 32 - tangled.Pipeline_Step_Environment_Elem{}, 33 35 tangled.PipelineStatus{}, 34 - tangled.Pipeline_Step{}, 35 36 tangled.Pipeline_TriggerMetadata{}, 36 37 tangled.Pipeline_TriggerRepo{}, 37 38 tangled.Pipeline_Workflow{}, 38 - tangled.Pipeline_Workflow_Environment_Elem{}, 39 39 tangled.PublicKey{}, 40 40 tangled.Repo{}, 41 41 tangled.RepoArtifact{}, 42 + tangled.RepoCollaborator{}, 42 43 tangled.RepoIssue{}, 43 44 tangled.RepoIssueComment{}, 44 45 tangled.RepoIssueState{}, ··· 46 47 tangled.RepoPullComment{}, 47 48 tangled.RepoPull_Source{}, 48 49 tangled.RepoPullStatus{}, 50 + tangled.RepoPull_Target{}, 49 51 tangled.Spindle{}, 50 52 tangled.SpindleMember{}, 53 + tangled.String{}, 51 54 ); err != nil { 52 55 panic(err) 53 56 }
+4
cmd/genjwks/main.go
··· 30 30 panic(err) 31 31 } 32 32 33 + if err := key.Set("use", "sig"); err != nil { 34 + panic(err) 35 + } 36 + 33 37 b, err := json.Marshal(key) 34 38 if err != nil { 35 39 panic(err)
+2 -2
cmd/punchcardPopulate/main.go
··· 11 11 ) 12 12 13 13 func main() { 14 - db, err := sql.Open("sqlite3", "./appview.db") 14 + db, err := sql.Open("sqlite3", "./appview.db?_foreign_keys=1") 15 15 if err != nil { 16 16 log.Fatal("Failed to open database:", err) 17 17 } ··· 37 37 dateStr := day.Format("2006-01-02") 38 38 _, err := stmt.Exec(did, dateStr, count) 39 39 if err != nil { 40 - log.Println("Failed to insert for date %s: %v", dateStr, err) 40 + log.Printf("Failed to insert for date %s: %v", dateStr, err) 41 41 } 42 42 } 43 43
+44
contrib/Tiltfile
··· 1 + common_env = { 2 + "TANGLED_VM_SPINDLE_OWNER": os.getenv("TANGLED_VM_SPINDLE_OWNER", default=""), 3 + "TANGLED_VM_KNOT_OWNER": os.getenv("TANGLED_VM_KNOT_OWNER", default=""), 4 + "TANGLED_DB_PATH": os.getenv("TANGLED_DB_PATH", default="dev.db"), 5 + "TANGLED_DEV": os.getenv("TANGLED_DEV", default="true"), 6 + } 7 + 8 + nix_globs = ["nix/**", "flake.nix", "flake.lock"] 9 + 10 + local_resource( 11 + name="appview", 12 + serve_cmd="nix run .#watch-appview", 13 + serve_dir="..", 14 + deps=nix_globs, 15 + env=common_env, 16 + allow_parallel=True, 17 + ) 18 + 19 + local_resource( 20 + name="tailwind", 21 + serve_cmd="nix run .#watch-tailwind", 22 + serve_dir="..", 23 + deps=nix_globs, 24 + env=common_env, 25 + allow_parallel=True, 26 + ) 27 + 28 + local_resource( 29 + name="redis", 30 + serve_cmd="redis-server", 31 + serve_dir="..", 32 + deps=nix_globs, 33 + env=common_env, 34 + allow_parallel=True, 35 + ) 36 + 37 + local_resource( 38 + name="vm", 39 + serve_cmd="nix run --impure .#vm", 40 + serve_dir="..", 41 + deps=nix_globs, 42 + env=common_env, 43 + allow_parallel=True, 44 + )
+16
default.nix
··· 1 + # Default setup from https://git.lix.systems/lix-project/flake-compat 2 + let 3 + lockFile = builtins.fromJSON (builtins.readFile ./flake.lock); 4 + flake-compat-node = lockFile.nodes.${lockFile.nodes.root.inputs.flake-compat}; 5 + flake-compat = builtins.fetchTarball { 6 + inherit (flake-compat-node.locked) url; 7 + sha256 = flake-compat-node.locked.narHash; 8 + }; 9 + 10 + flake = ( 11 + import flake-compat { 12 + src = ./.; 13 + } 14 + ); 15 + in 16 + flake.defaultNix
+17 -18
docs/contributing.md
··· 11 11 ### message format 12 12 13 13 ``` 14 - <service/top-level directory>: <affected package/directory>: <short summary of change> 14 + <service/top-level directory>/<affected package/directory>: <short summary of change> 15 15 16 16 17 17 Optional longer description can go here, if necessary. Explain what the ··· 23 23 Here are some examples: 24 24 25 25 ``` 26 - appview: state: fix token expiry check in middleware 26 + appview/state: fix token expiry check in middleware 27 27 28 28 The previous check did not account for clock drift, leading to premature 29 29 token invalidation. 30 30 ``` 31 31 32 32 ``` 33 - knotserver: git/service: improve error checking in upload-pack 33 + knotserver/git/service: improve error checking in upload-pack 34 34 ``` 35 35 36 36 ··· 54 54 - Don't include unrelated changes in the same commit. 55 55 - Avoid noisy commit messages like "wip" or "final fix"โ€”rewrite history 56 56 before submitting if necessary. 57 + 58 + ## code formatting 59 + 60 + We use a variety of tools to format our code, and multiplex them with 61 + [`treefmt`](https://treefmt.com): all you need to do to format your changes 62 + is run `nix run .#fmt` (or just `treefmt` if you're in the devshell). 57 63 58 64 ## proposals for bigger changes 59 65 ··· 115 121 If you're submitting a PR with multiple commits, make sure each one is 116 122 signed. 117 123 118 - For [jj](https://jj-vcs.github.io/jj/latest/) users, you can add this to 119 - your jj config: 124 + For [jj](https://jj-vcs.github.io/jj/latest/) users, you can run the following command 125 + to make it sign off commits in the tangled repo: 120 126 121 - ``` 122 - ui.should-sign-off = true 123 - ``` 124 - 125 - and to your `templates.draft_commit_description`, add the following `if` 126 - block: 127 - 128 - ``` 129 - if( 130 - config("ui.should-sign-off").as_boolean() && !description.contains("Signed-off-by: " ++ author.name()), 131 - "\nSigned-off-by: " ++ author.name() ++ " <" ++ author.email() ++ ">", 132 - ), 127 + ```shell 128 + # Safety check, should say "No matching config key..." 129 + jj config list templates.commit_trailers 130 + # The command below may need to be adjusted if the command above returned something. 131 + jj config set --repo templates.commit_trailers "format_signed_off_by_trailer(self)" 133 132 ``` 134 133 135 134 Refer to the [jj 136 - documentation](https://jj-vcs.github.io/jj/latest/config/#default-description) 135 + documentation](https://jj-vcs.github.io/jj/latest/config/#commit-trailers) 137 136 for more information.
+101 -11
docs/hacking.md
··· 32 32 nix run .#watch-tailwind 33 33 ``` 34 34 35 - ## running a knot 35 + To authenticate with the appview, you will need redis and 36 + OAUTH JWKs to be setup: 37 + 38 + ``` 39 + # oauth jwks should already be setup by the nix devshell: 40 + echo $TANGLED_OAUTH_JWKS 41 + {"crv":"P-256","d":"tELKHYH-Dko6qo4ozYcVPE1ah6LvXHFV2wpcWpi8ab4","kid":"1753352226","kty":"EC","x":"mRzYpLzAGq74kJez9UbgGfV040DxgsXpMbaVsdy8RZs","y":"azqqXzUYywMlLb2Uc5AVG18nuLXyPnXr4kI4T39eeIc"} 42 + 43 + # if not, you can set it up yourself: 44 + go build -o genjwks.out ./cmd/genjwks 45 + export TANGLED_OAUTH_JWKS="$(./genjwks.out)" 46 + 47 + # run redis in at a new shell to store oauth sessions 48 + redis-server 49 + ``` 50 + 51 + ## running knots and spindles 36 52 37 53 An end-to-end knot setup requires setting up a machine with 38 54 `sshd`, `AuthorizedKeysCommand`, and git user, which is 39 55 quite cumbersome. So the nix flake provides a 40 56 `nixosConfiguration` to do so. 41 57 42 - To begin, head to `http://localhost:3000` in the browser and 43 - generate a knot secret. Replace the existing secret in 44 - `flake.nix` with the newly generated secret. 58 + <details> 59 + <summary><strong>MacOS users will have to setup a Nix Builder first</strong></summary> 45 60 46 - You can now start a lightweight NixOS VM using 47 - `nixos-shell` like so: 61 + In order to build Tangled's dev VM on macOS, you will 62 + first need to set up a Linux Nix builder. The recommended 63 + way to do so is to run a [`darwin.linux-builder` 64 + VM](https://nixos.org/manual/nixpkgs/unstable/#sec-darwin-builder) 65 + and to register it in `nix.conf` as a builder for Linux 66 + with the same architecture as your Mac (`linux-aarch64` if 67 + you are using Apple Silicon). 68 + 69 + > IMPORTANT: You must build `darwin.linux-builder` somewhere other than inside 70 + > the tangled repo so that it doesn't conflict with the other VM. For example, 71 + > you can do 72 + > 73 + > ```shell 74 + > cd $(mktemp -d buildervm.XXXXX) && nix run nixpkgs#darwin.linux-builder 75 + > ``` 76 + > 77 + > to store the builder VM in a temporary dir. 78 + > 79 + > You should read and follow [all the other intructions][darwin builder vm] to 80 + > avoid subtle problems. 81 + 82 + Alternatively, you can use any other method to set up a 83 + Linux machine with `nix` installed that you can `sudo ssh` 84 + into (in other words, root user on your Mac has to be able 85 + to ssh into the Linux machine without entering a password) 86 + and that has the same architecture as your Mac. See 87 + [remote builder 88 + instructions](https://nix.dev/manual/nix/2.28/advanced-topics/distributed-builds.html#requirements) 89 + for how to register such a builder in `nix.conf`. 90 + 91 + > WARNING: If you'd like to use 92 + > [`nixos-lima`](https://github.com/nixos-lima/nixos-lima) or 93 + > [Orbstack](https://orbstack.dev/), note that setting them up so that `sudo 94 + > ssh` works can be tricky. It seems to be [possible with 95 + > Orbstack](https://github.com/orgs/orbstack/discussions/1669). 96 + 97 + </details> 98 + 99 + To begin, grab your DID from http://localhost:3000/settings. 100 + Then, set `TANGLED_VM_KNOT_OWNER` and 101 + `TANGLED_VM_SPINDLE_OWNER` to your DID. You can now start a 102 + lightweight NixOS VM like so: 48 103 49 104 ```bash 50 - QEMU_NET_OPTS="hostfwd=tcp::6000-:6000,hostfwd=tcp::2222-:22" nixos-shell --flake .#knotVM 105 + nix run --impure .#vm 51 106 52 - # hit Ctrl-a + c + q to exit the VM 107 + # type `poweroff` at the shell to exit the VM 53 108 ``` 54 109 55 - This starts a knot on port 6000 with `ssh` exposed on port 56 - 2222. You can push repositories to this VM with this ssh 57 - config block on your main machine: 110 + This starts a knot on port 6000, a spindle on port 6555 111 + with `ssh` exposed on port 2222. 112 + 113 + Once the services are running, head to 114 + http://localhost:3000/knots and hit verify. It should 115 + verify the ownership of the services instantly if everything 116 + went smoothly. 117 + 118 + You can push repositories to this VM with this ssh config 119 + block on your main machine: 58 120 59 121 ```bash 60 122 Host nixos-shell ··· 70 132 git remote add local-dev git@nixos-shell:user/repo 71 133 git push local-dev main 72 134 ``` 135 + 136 + ### running a spindle 137 + 138 + The above VM should already be running a spindle on 139 + `localhost:6555`. Head to http://localhost:3000/spindles and 140 + hit verify. You can then configure each repository to use 141 + this spindle and run CI jobs. 142 + 143 + Of interest when debugging spindles: 144 + 145 + ``` 146 + # service logs from journald: 147 + journalctl -xeu spindle 148 + 149 + # CI job logs from disk: 150 + ls /var/log/spindle 151 + 152 + # debugging spindle db: 153 + sqlite3 /var/lib/spindle/spindle.db 154 + 155 + # litecli has a nicer REPL interface: 156 + litecli /var/lib/spindle/spindle.db 157 + ``` 158 + 159 + If for any reason you wish to disable either one of the 160 + services in the VM, modify [nix/vm.nix](/nix/vm.nix) and set 161 + `services.tangled-spindle.enable` (or 162 + `services.tangled-knot.enable`) to `false`.
+59 -6
docs/knot-hosting.md
··· 2 2 3 3 So you want to run your own knot server? Great! Here are a few prerequisites: 4 4 5 - 1. A server of some kind (a VPS, a Raspberry Pi, etc.). Preferably running a Linux of some kind. 5 + 1. A server of some kind (a VPS, a Raspberry Pi, etc.). Preferably running a Linux distribution of some kind. 6 6 2. A (sub)domain name. People generally use `knot.example.com`. 7 7 3. A valid SSL certificate for your domain. 8 8 ··· 59 59 EOF 60 60 ``` 61 61 62 + Then, reload `sshd`: 63 + 64 + ``` 65 + sudo systemctl reload ssh 66 + ``` 67 + 62 68 Next, create the `git` user. We'll use the `git` user's home directory 63 69 to store repositories: 64 70 ··· 67 73 ``` 68 74 69 75 Create `/home/git/.knot.env` with the following, updating the values as 70 - necessary. The `KNOT_SERVER_SECRET` can be obtaind from the 71 - [/knots](/knots) page on Tangled. 76 + necessary. The `KNOT_SERVER_OWNER` should be set to your 77 + DID, you can find your DID in the [Settings](https://tangled.sh/settings) page. 72 78 73 79 ``` 74 80 KNOT_REPO_SCAN_PATH=/home/git 75 81 KNOT_SERVER_HOSTNAME=knot.example.com 76 82 APPVIEW_ENDPOINT=https://tangled.sh 77 - KNOT_SERVER_SECRET=secret 83 + KNOT_SERVER_OWNER=did:plc:foobar 78 84 KNOT_SERVER_INTERNAL_LISTEN_ADDR=127.0.0.1:5444 79 85 KNOT_SERVER_LISTEN_ADDR=127.0.0.1:5555 80 86 ``` ··· 89 95 systemctl start knotserver 90 96 ``` 91 97 92 - You should now have a running knot server! You can finalize your registration by hitting the 93 - `initialize` button on the [/knots](/knots) page. 98 + The last step is to configure a reverse proxy like Nginx or Caddy to front your 99 + knot. Here's an example configuration for Nginx: 100 + 101 + ``` 102 + server { 103 + listen 80; 104 + listen [::]:80; 105 + server_name knot.example.com; 106 + 107 + location / { 108 + proxy_pass http://localhost:5555; 109 + proxy_set_header Host $host; 110 + proxy_set_header X-Real-IP $remote_addr; 111 + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 112 + proxy_set_header X-Forwarded-Proto $scheme; 113 + } 114 + 115 + # wss endpoint for git events 116 + location /events { 117 + proxy_set_header X-Forwarded-For $remote_addr; 118 + proxy_set_header Host $http_host; 119 + proxy_set_header Upgrade websocket; 120 + proxy_set_header Connection Upgrade; 121 + proxy_pass http://localhost:5555; 122 + } 123 + # additional config for SSL/TLS go here. 124 + } 125 + 126 + ``` 127 + 128 + Remember to use Let's Encrypt or similar to procure a certificate for your 129 + knot domain. 130 + 131 + You should now have a running knot server! You can finalize 132 + your registration by hitting the `verify` button on the 133 + [/knots](https://tangled.sh/knots) page. This simply creates 134 + a record on your PDS to announce the existence of the knot. 94 135 95 136 ### custom paths 96 137 ··· 158 199 ``` 159 200 160 201 Make sure to restart your SSH server! 202 + 203 + #### MOTD (message of the day) 204 + 205 + To configure the MOTD used ("Welcome to this knot!" by default), edit the 206 + `/home/git/motd` file: 207 + 208 + ``` 209 + printf "Hi from this knot!\n" > /home/git/motd 210 + ``` 211 + 212 + Note that you should add a newline at the end if setting a non-empty message 213 + since the knot won't do this for you.
+60
docs/migrations.md
··· 1 + # Migrations 2 + 3 + This document is laid out in reverse-chronological order. 4 + Newer migration guides are listed first, and older guides 5 + are further down the page. 6 + 7 + ## Upgrading from v1.8.x 8 + 9 + After v1.8.2, the HTTP API for knot and spindles have been 10 + deprecated and replaced with XRPC. Repositories on outdated 11 + knots will not be viewable from the appview. Upgrading is 12 + straightforward however. 13 + 14 + For knots: 15 + 16 + - Upgrade to latest tag (v1.9.0 or above) 17 + - Head to the [knot dashboard](https://tangled.sh/knots) and 18 + hit the "retry" button to verify your knot 19 + 20 + For spindles: 21 + 22 + - Upgrade to latest tag (v1.9.0 or above) 23 + - Head to the [spindle 24 + dashboard](https://tangled.sh/spindles) and hit the 25 + "retry" button to verify your spindle 26 + 27 + ## Upgrading from v1.7.x 28 + 29 + After v1.7.0, knot secrets have been deprecated. You no 30 + longer need a secret from the appview to run a knot. All 31 + authorized commands to knots are managed via [Inter-Service 32 + Authentication](https://atproto.com/specs/xrpc#inter-service-authentication-jwt). 33 + Knots will be read-only until upgraded. 34 + 35 + Upgrading is quite easy, in essence: 36 + 37 + - `KNOT_SERVER_SECRET` is no more, you can remove this 38 + environment variable entirely 39 + - `KNOT_SERVER_OWNER` is now required on boot, set this to 40 + your DID. You can find your DID in the 41 + [settings](https://tangled.sh/settings) page. 42 + - Restart your knot once you have replaced the environment 43 + variable 44 + - Head to the [knot dashboard](https://tangled.sh/knots) and 45 + hit the "retry" button to verify your knot. This simply 46 + writes a `sh.tangled.knot` record to your PDS. 47 + 48 + If you use the nix module, simply bump the flake to the 49 + latest revision, and change your config block like so: 50 + 51 + ```diff 52 + services.tangled-knot = { 53 + enable = true; 54 + server = { 55 + - secretFile = /path/to/secret; 56 + + owner = "did:plc:foo"; 57 + }; 58 + }; 59 + ``` 60 +
+25
docs/spindle/architecture.md
··· 1 + # spindle architecture 2 + 3 + Spindle is a small CI runner service. Here's a high level overview of how it operates: 4 + 5 + * listens for [`sh.tangled.spindle.member`](/lexicons/spindle/member.json) and 6 + [`sh.tangled.repo`](/lexicons/repo.json) records on the Jetstream. 7 + * when a new repo record comes through (typically when you add a spindle to a 8 + repo from the settings), spindle then resolves the underlying knot and 9 + subscribes to repo events (see: 10 + [`sh.tangled.pipeline`](/lexicons/pipeline.json)). 11 + * the spindle engine then handles execution of the pipeline, with results and 12 + logs beamed on the spindle event stream over wss 13 + 14 + ### the engine 15 + 16 + At present, the only supported backend is Docker (and Podman, if Docker 17 + compatibility is enabled, so that `/run/docker.sock` is created). Spindle 18 + executes each step in the pipeline in a fresh container, with state persisted 19 + across steps within the `/tangled/workspace` directory. 20 + 21 + The base image for the container is constructed on the fly using 22 + [Nixery](https://nixery.dev), which is handy for caching layers for frequently 23 + used packages. 24 + 25 + The pipeline manifest is [specified here](/docs/spindle/pipeline.md).
+52
docs/spindle/hosting.md
··· 1 + # spindle self-hosting guide 2 + 3 + ## prerequisites 4 + 5 + * Go 6 + * Docker (the only supported backend currently) 7 + 8 + ## configuration 9 + 10 + Spindle is configured using environment variables. The following environment variables are available: 11 + 12 + * `SPINDLE_SERVER_LISTEN_ADDR`: The address the server listens on (default: `"0.0.0.0:6555"`). 13 + * `SPINDLE_SERVER_DB_PATH`: The path to the SQLite database file (default: `"spindle.db"`). 14 + * `SPINDLE_SERVER_HOSTNAME`: The hostname of the server (required). 15 + * `SPINDLE_SERVER_JETSTREAM_ENDPOINT`: The endpoint of the Jetstream server (default: `"wss://jetstream1.us-west.bsky.network/subscribe"`). 16 + * `SPINDLE_SERVER_DEV`: A boolean indicating whether the server is running in development mode (default: `false`). 17 + * `SPINDLE_SERVER_OWNER`: The DID of the owner (required). 18 + * `SPINDLE_PIPELINES_NIXERY`: The Nixery URL (default: `"nixery.tangled.sh"`). 19 + * `SPINDLE_PIPELINES_WORKFLOW_TIMEOUT`: The default workflow timeout (default: `"5m"`). 20 + * `SPINDLE_PIPELINES_LOG_DIR`: The directory to store workflow logs (default: `"/var/log/spindle"`). 21 + 22 + ## running spindle 23 + 24 + 1. **Set the environment variables.** For example: 25 + 26 + ```shell 27 + export SPINDLE_SERVER_HOSTNAME="your-hostname" 28 + export SPINDLE_SERVER_OWNER="your-did" 29 + ``` 30 + 31 + 2. **Build the Spindle binary.** 32 + 33 + ```shell 34 + cd core 35 + go mod download 36 + go build -o cmd/spindle/spindle cmd/spindle/main.go 37 + ``` 38 + 39 + 3. **Create the log directory.** 40 + 41 + ```shell 42 + sudo mkdir -p /var/log/spindle 43 + sudo chown $USER:$USER -R /var/log/spindle 44 + ``` 45 + 46 + 4. **Run the Spindle binary.** 47 + 48 + ```shell 49 + ./cmd/spindle/spindle 50 + ``` 51 + 52 + Spindle will now start, connect to the Jetstream server, and begin processing pipelines.
+285
docs/spindle/openbao.md
··· 1 + # spindle secrets with openbao 2 + 3 + This document covers setting up Spindle to use OpenBao for secrets 4 + management via OpenBao Proxy instead of the default SQLite backend. 5 + 6 + ## overview 7 + 8 + Spindle now uses OpenBao Proxy for secrets management. The proxy handles 9 + authentication automatically using AppRole credentials, while Spindle 10 + connects to the local proxy instead of directly to the OpenBao server. 11 + 12 + This approach provides better security, automatic token renewal, and 13 + simplified application code. 14 + 15 + ## installation 16 + 17 + Install OpenBao from nixpkgs: 18 + 19 + ```bash 20 + nix shell nixpkgs#openbao # for a local server 21 + ``` 22 + 23 + ## setup 24 + 25 + The setup process can is documented for both local development and production. 26 + 27 + ### local development 28 + 29 + Start OpenBao in dev mode: 30 + 31 + ```bash 32 + bao server -dev -dev-root-token-id="root" -dev-listen-address=127.0.0.1:8201 33 + ``` 34 + 35 + This starts OpenBao on `http://localhost:8201` with a root token. 36 + 37 + Set up environment for bao CLI: 38 + 39 + ```bash 40 + export BAO_ADDR=http://localhost:8200 41 + export BAO_TOKEN=root 42 + ``` 43 + 44 + ### production 45 + 46 + You would typically use a systemd service with a configuration file. Refer to 47 + [@tangled.sh/infra](https://tangled.sh/@tangled.sh/infra) for how this can be 48 + achieved using Nix. 49 + 50 + Then, initialize the bao server: 51 + ```bash 52 + bao operator init -key-shares=1 -key-threshold=1 53 + ``` 54 + 55 + This will print out an unseal key and a root key. Save them somewhere (like a password manager). Then unseal the vault to begin setting it up: 56 + ```bash 57 + bao operator unseal <unseal_key> 58 + ``` 59 + 60 + All steps below remain the same across both dev and production setups. 61 + 62 + ### configure openbao server 63 + 64 + Create the spindle KV mount: 65 + 66 + ```bash 67 + bao secrets enable -path=spindle -version=2 kv 68 + ``` 69 + 70 + Set up AppRole authentication and policy: 71 + 72 + Create a policy file `spindle-policy.hcl`: 73 + 74 + ```hcl 75 + # Full access to spindle KV v2 data 76 + path "spindle/data/*" { 77 + capabilities = ["create", "read", "update", "delete"] 78 + } 79 + 80 + # Access to metadata for listing and management 81 + path "spindle/metadata/*" { 82 + capabilities = ["list", "read", "delete", "update"] 83 + } 84 + 85 + # Allow listing at root level 86 + path "spindle/" { 87 + capabilities = ["list"] 88 + } 89 + 90 + # Required for connection testing and health checks 91 + path "auth/token/lookup-self" { 92 + capabilities = ["read"] 93 + } 94 + ``` 95 + 96 + Apply the policy and create an AppRole: 97 + 98 + ```bash 99 + bao policy write spindle-policy spindle-policy.hcl 100 + bao auth enable approle 101 + bao write auth/approle/role/spindle \ 102 + token_policies="spindle-policy" \ 103 + token_ttl=1h \ 104 + token_max_ttl=4h \ 105 + bind_secret_id=true \ 106 + secret_id_ttl=0 \ 107 + secret_id_num_uses=0 108 + ``` 109 + 110 + Get the credentials: 111 + 112 + ```bash 113 + # Get role ID (static) 114 + ROLE_ID=$(bao read -field=role_id auth/approle/role/spindle/role-id) 115 + 116 + # Generate secret ID 117 + SECRET_ID=$(bao write -f -field=secret_id auth/approle/role/spindle/secret-id) 118 + 119 + echo "Role ID: $ROLE_ID" 120 + echo "Secret ID: $SECRET_ID" 121 + ``` 122 + 123 + ### create proxy configuration 124 + 125 + Create the credential files: 126 + 127 + ```bash 128 + # Create directory for OpenBao files 129 + mkdir -p /tmp/openbao 130 + 131 + # Save credentials 132 + echo "$ROLE_ID" > /tmp/openbao/role-id 133 + echo "$SECRET_ID" > /tmp/openbao/secret-id 134 + chmod 600 /tmp/openbao/role-id /tmp/openbao/secret-id 135 + ``` 136 + 137 + Create a proxy configuration file `/tmp/openbao/proxy.hcl`: 138 + 139 + ```hcl 140 + # OpenBao server connection 141 + vault { 142 + address = "http://localhost:8200" 143 + } 144 + 145 + # Auto-Auth using AppRole 146 + auto_auth { 147 + method "approle" { 148 + mount_path = "auth/approle" 149 + config = { 150 + role_id_file_path = "/tmp/openbao/role-id" 151 + secret_id_file_path = "/tmp/openbao/secret-id" 152 + } 153 + } 154 + 155 + # Optional: write token to file for debugging 156 + sink "file" { 157 + config = { 158 + path = "/tmp/openbao/token" 159 + mode = 0640 160 + } 161 + } 162 + } 163 + 164 + # Proxy listener for Spindle 165 + listener "tcp" { 166 + address = "127.0.0.1:8201" 167 + tls_disable = true 168 + } 169 + 170 + # Enable API proxy with auto-auth token 171 + api_proxy { 172 + use_auto_auth_token = true 173 + } 174 + 175 + # Enable response caching 176 + cache { 177 + use_auto_auth_token = true 178 + } 179 + 180 + # Logging 181 + log_level = "info" 182 + ``` 183 + 184 + ### start the proxy 185 + 186 + Start OpenBao Proxy: 187 + 188 + ```bash 189 + bao proxy -config=/tmp/openbao/proxy.hcl 190 + ``` 191 + 192 + The proxy will authenticate with OpenBao and start listening on 193 + `127.0.0.1:8201`. 194 + 195 + ### configure spindle 196 + 197 + Set these environment variables for Spindle: 198 + 199 + ```bash 200 + export SPINDLE_SERVER_SECRETS_PROVIDER=openbao 201 + export SPINDLE_SERVER_SECRETS_OPENBAO_PROXY_ADDR=http://127.0.0.1:8201 202 + export SPINDLE_SERVER_SECRETS_OPENBAO_MOUNT=spindle 203 + ``` 204 + 205 + Start Spindle: 206 + 207 + Spindle will now connect to the local proxy, which handles all 208 + authentication automatically. 209 + 210 + ## production setup for proxy 211 + 212 + For production, you'll want to run the proxy as a service: 213 + 214 + Place your production configuration in `/etc/openbao/proxy.hcl` with 215 + proper TLS settings for the vault connection. 216 + 217 + ## verifying setup 218 + 219 + Test the proxy directly: 220 + 221 + ```bash 222 + # Check proxy health 223 + curl -H "X-Vault-Request: true" http://127.0.0.1:8201/v1/sys/health 224 + 225 + # Test token lookup through proxy 226 + curl -H "X-Vault-Request: true" http://127.0.0.1:8201/v1/auth/token/lookup-self 227 + ``` 228 + 229 + Test OpenBao operations through the server: 230 + 231 + ```bash 232 + # List all secrets 233 + bao kv list spindle/ 234 + 235 + # Add a test secret via Spindle API, then check it exists 236 + bao kv list spindle/repos/ 237 + 238 + # Get a specific secret 239 + bao kv get spindle/repos/your_repo_path/SECRET_NAME 240 + ``` 241 + 242 + ## how it works 243 + 244 + - Spindle connects to OpenBao Proxy on localhost (typically port 8200 or 8201) 245 + - The proxy authenticates with OpenBao using AppRole credentials 246 + - All Spindle requests go through the proxy, which injects authentication tokens 247 + - Secrets are stored at `spindle/repos/{sanitized_repo_path}/{secret_key}` 248 + - Repository paths like `did:plc:alice/myrepo` become `did_plc_alice_myrepo` 249 + - The proxy handles all token renewal automatically 250 + - Spindle no longer manages tokens or authentication directly 251 + 252 + ## troubleshooting 253 + 254 + **Connection refused**: Check that the OpenBao Proxy is running and 255 + listening on the configured address. 256 + 257 + **403 errors**: Verify the AppRole credentials are correct and the policy 258 + has the necessary permissions. 259 + 260 + **404 route errors**: The spindle KV mount probably doesn't exist - run 261 + the mount creation step again. 262 + 263 + **Proxy authentication failures**: Check the proxy logs and verify the 264 + role-id and secret-id files are readable and contain valid credentials. 265 + 266 + **Secret not found after writing**: This can indicate policy permission 267 + issues. Verify the policy includes both `spindle/data/*` and 268 + `spindle/metadata/*` paths with appropriate capabilities. 269 + 270 + Check proxy logs: 271 + 272 + ```bash 273 + # If running as systemd service 274 + journalctl -u openbao-proxy -f 275 + 276 + # If running directly, check the console output 277 + ``` 278 + 279 + Test AppRole authentication manually: 280 + 281 + ```bash 282 + bao write auth/approle/login \ 283 + role_id="$(cat /tmp/openbao/role-id)" \ 284 + secret_id="$(cat /tmp/openbao/secret-id)" 285 + ```
+165
docs/spindle/pipeline.md
··· 1 + # spindle pipelines 2 + 3 + Spindle workflows allow you to write CI/CD pipelines in a simple format. They're located in the `.tangled/workflows` directory at the root of your repository, and are defined using YAML. 4 + 5 + The fields are: 6 + 7 + - [Trigger](#trigger): A **required** field that defines when a workflow should be triggered. 8 + - [Engine](#engine): A **required** field that defines which engine a workflow should run on. 9 + - [Clone options](#clone-options): An **optional** field that defines how the repository should be cloned. 10 + - [Dependencies](#dependencies): An **optional** field that allows you to list dependencies you may need. 11 + - [Environment](#environment): An **optional** field that allows you to define environment variables. 12 + - [Steps](#steps): An **optional** field that allows you to define what steps should run in the workflow. 13 + 14 + ## Trigger 15 + 16 + The first thing to add to a workflow is the trigger, which defines when a workflow runs. This is defined using a `when` field, which takes in a list of conditions. Each condition has the following fields: 17 + 18 + - `event`: This is a **required** field that defines when your workflow should run. It's a list that can take one or more of the following values: 19 + - `push`: The workflow should run every time a commit is pushed to the repository. 20 + - `pull_request`: The workflow should run every time a pull request is made or updated. 21 + - `manual`: The workflow can be triggered manually. 22 + - `branch`: This is a **required** field that defines which branches the workflow should run for. If used with the `push` event, commits to the branch(es) listed here will trigger the workflow. If used with the `pull_request` event, updates to pull requests targeting the branch(es) listed here will trigger the workflow. This field has no effect with the `manual` event. 23 + 24 + For example, if you'd like define a workflow that runs when commits are pushed to the `main` and `develop` branches, or when pull requests that target the `main` branch are updated, or manually, you can do so with: 25 + 26 + ```yaml 27 + when: 28 + - event: ["push", "manual"] 29 + branch: ["main", "develop"] 30 + - event: ["pull_request"] 31 + branch: ["main"] 32 + ``` 33 + 34 + ## Engine 35 + 36 + Next is the engine on which the workflow should run, defined using the **required** `engine` field. The currently supported engines are: 37 + 38 + - `nixery`: This uses an instance of [Nixery](https://nixery.dev) to run steps, which allows you to add [dependencies](#dependencies) from [Nixpkgs](https://github.com/NixOS/nixpkgs). You can search for packages on https://search.nixos.org, and there's a pretty good chance the package(s) you're looking for will be there. 39 + 40 + Example: 41 + 42 + ```yaml 43 + engine: "nixery" 44 + ``` 45 + 46 + ## Clone options 47 + 48 + When a workflow starts, the first step is to clone the repository. You can customize this behavior using the **optional** `clone` field. It has the following fields: 49 + 50 + - `skip`: Setting this to `true` will skip cloning the repository. This can be useful if your workflow is doing something that doesn't require anything from the repository itself. This is `false` by default. 51 + - `depth`: This sets the number of commits, or the "clone depth", to fetch from the repository. For example, if you set this to 2, the last 2 commits will be fetched. By default, the depth is set to 1, meaning only the most recent commit will be fetched, which is the commit that triggered the workflow. 52 + - `submodules`: If you use [git submodules](https://git-scm.com/book/en/v2/Git-Tools-Submodules) in your repository, setting this field to `true` will recursively fetch all submodules. This is `false` by default. 53 + 54 + The default settings are: 55 + 56 + ```yaml 57 + clone: 58 + skip: false 59 + depth: 1 60 + submodules: false 61 + ``` 62 + 63 + ## Dependencies 64 + 65 + Usually when you're running a workflow, you'll need additional dependencies. The `dependencies` field lets you define which dependencies to get, and from where. It's a key-value map, with the key being the registry to fetch dependencies from, and the value being the list of dependencies to fetch. 66 + 67 + Say you want to fetch Node.js and Go from `nixpkgs`, and a package called `my_pkg` you've made from your own registry at your repository at `https://tangled.sh/@example.com/my_pkg`. You can define those dependencies like so: 68 + 69 + ```yaml 70 + dependencies: 71 + # nixpkgs 72 + nixpkgs: 73 + - nodejs 74 + - go 75 + # custom registry 76 + git+https://tangled.sh/@example.com/my_pkg: 77 + - my_pkg 78 + ``` 79 + 80 + Now these dependencies are available to use in your workflow! 81 + 82 + ## Environment 83 + 84 + The `environment` field allows you define environment variables that will be available throughout the entire workflow. **Do not put secrets here, these environment variables are visible to anyone viewing the repository. You can add secrets for pipelines in your repository's settings.** 85 + 86 + Example: 87 + 88 + ```yaml 89 + environment: 90 + GOOS: "linux" 91 + GOARCH: "arm64" 92 + NODE_ENV: "production" 93 + MY_ENV_VAR: "MY_ENV_VALUE" 94 + ``` 95 + 96 + ## Steps 97 + 98 + The `steps` field allows you to define what steps should run in the workflow. It's a list of step objects, each with the following fields: 99 + 100 + - `name`: This field allows you to give your step a name. This name is visible in your workflow runs, and is used to describe what the step is doing. 101 + - `command`: This field allows you to define a command to run in that step. The step is run in a Bash shell, and the logs from the command will be visible in the pipelines page on the Tangled website. The [dependencies](#dependencies) you added will be available to use here. 102 + - `environment`: Similar to the global [environment](#environment) config, this **optional** field is a key-value map that allows you to set environment variables for the step. **Do not put secrets here, these environment variables are visible to anyone viewing the repository. You can add secrets for pipelines in your repository's settings.** 103 + 104 + Example: 105 + 106 + ```yaml 107 + steps: 108 + - name: "Build backend" 109 + command: "go build" 110 + environment: 111 + GOOS: "darwin" 112 + GOARCH: "arm64" 113 + - name: "Build frontend" 114 + command: "npm run build" 115 + environment: 116 + NODE_ENV: "production" 117 + ``` 118 + 119 + ## Complete workflow 120 + 121 + ```yaml 122 + # .tangled/workflows/build.yml 123 + 124 + when: 125 + - event: ["push", "manual"] 126 + branch: ["main", "develop"] 127 + - event: ["pull_request"] 128 + branch: ["main"] 129 + 130 + engine: "nixery" 131 + 132 + # using the default values 133 + clone: 134 + skip: false 135 + depth: 1 136 + submodules: false 137 + 138 + dependencies: 139 + # nixpkgs 140 + nixpkgs: 141 + - nodejs 142 + - go 143 + # custom registry 144 + git+https://tangled.sh/@example.com/my_pkg: 145 + - my_pkg 146 + 147 + environment: 148 + GOOS: "linux" 149 + GOARCH: "arm64" 150 + NODE_ENV: "production" 151 + MY_ENV_VAR: "MY_ENV_VALUE" 152 + 153 + steps: 154 + - name: "Build backend" 155 + command: "go build" 156 + environment: 157 + GOOS: "darwin" 158 + GOARCH: "arm64" 159 + - name: "Build frontend" 160 + command: "npm run build" 161 + environment: 162 + NODE_ENV: "production" 163 + ``` 164 + 165 + If you want another example of a workflow, you can look at the one [Tangled uses to build the project](https://tangled.sh/@tangled.sh/core/blob/master/.tangled/workflows/build.yml).
+43 -25
eventconsumer/consumer.go
··· 12 12 "tangled.sh/tangled.sh/core/eventconsumer/cursor" 13 13 "tangled.sh/tangled.sh/core/log" 14 14 15 + "github.com/avast/retry-go/v4" 15 16 "github.com/gorilla/websocket" 16 17 ) 17 18 ··· 170 171 171 172 func (c *Consumer) startConnectionLoop(ctx context.Context, source Source) { 172 173 defer c.wg.Done() 173 - retryInterval := c.cfg.RetryInterval 174 + 175 + // attempt connection initially 176 + err := c.runConnection(ctx, source) 177 + if err != nil { 178 + c.logger.Error("failed to run connection", "err", err) 179 + } 180 + 181 + timer := time.NewTimer(1 * time.Minute) 182 + defer timer.Stop() 183 + 184 + // every subsequent attempt is delayed by 1 minute 174 185 for { 175 186 select { 176 187 case <-ctx.Done(): 177 188 return 178 - default: 189 + case <-timer.C: 179 190 err := c.runConnection(ctx, source) 180 191 if err != nil { 181 - c.logger.Error("connection failed", "source", source, "err", err) 182 - } 183 - 184 - // apply jitter 185 - jitter := time.Duration(c.randSource.Int63n(int64(retryInterval) / 5)) 186 - delay := retryInterval + jitter 187 - 188 - if retryInterval < c.cfg.MaxRetryInterval { 189 - retryInterval *= 2 190 - if retryInterval > c.cfg.MaxRetryInterval { 191 - retryInterval = c.cfg.MaxRetryInterval 192 - } 192 + c.logger.Error("failed to run connection", "err", err) 193 193 } 194 - c.logger.Info("retrying connection", "source", source, "delay", delay) 195 - select { 196 - case <-time.After(delay): 197 - case <-ctx.Done(): 198 - return 199 - } 194 + timer.Reset(1 * time.Minute) 200 195 } 201 196 } 202 197 } 203 198 204 199 func (c *Consumer) runConnection(ctx context.Context, source Source) error { 205 - connCtx, cancel := context.WithTimeout(ctx, c.cfg.ConnectionTimeout) 206 - defer cancel() 207 - 208 200 cursor := c.cfg.CursorStore.Get(source.Key()) 209 201 210 202 u, err := source.Url(cursor, c.cfg.Dev) ··· 213 205 } 214 206 215 207 c.logger.Info("connecting", "url", u.String()) 216 - conn, _, err := c.dialer.DialContext(connCtx, u.String(), nil) 208 + 209 + retryOpts := []retry.Option{ 210 + retry.Attempts(0), // infinite attempts 211 + retry.DelayType(retry.BackOffDelay), 212 + retry.Delay(c.cfg.RetryInterval), 213 + retry.MaxDelay(c.cfg.MaxRetryInterval), 214 + retry.MaxJitter(c.cfg.RetryInterval / 5), 215 + retry.OnRetry(func(n uint, err error) { 216 + c.logger.Info("retrying connection", 217 + "source", source, 218 + "url", u.String(), 219 + "attempt", n+1, 220 + "err", err, 221 + ) 222 + }), 223 + retry.Context(ctx), 224 + } 225 + 226 + var conn *websocket.Conn 227 + 228 + err = retry.Do(func() error { 229 + connCtx, cancel := context.WithTimeout(ctx, c.cfg.ConnectionTimeout) 230 + defer cancel() 231 + conn, _, err = c.dialer.DialContext(connCtx, u.String(), nil) 232 + return err 233 + }, retryOpts...) 217 234 if err != nil { 218 235 return err 219 236 } 220 - defer conn.Close() 237 + 221 238 c.connMap.Store(source, conn) 239 + defer conn.Close() 222 240 defer c.connMap.Delete(source) 223 241 224 242 c.logger.Info("connected", "source", source)
+1 -1
eventconsumer/cursor/sqlite.go
··· 21 21 } 22 22 23 23 func NewSQLiteStore(dbPath string, opts ...SqliteStoreOpt) (*SqliteStore, error) { 24 - db, err := sql.Open("sqlite3", dbPath) 24 + db, err := sql.Open("sqlite3", dbPath+"?_foreign_keys=1") 25 25 if err != nil { 26 26 return nil, fmt.Errorf("failed to open sqlite database: %w", err) 27 27 }
+81 -19
flake.lock
··· 1 1 { 2 2 "nodes": { 3 - "gitignore": { 3 + "flake-compat": { 4 + "flake": false, 5 + "locked": { 6 + "lastModified": 1751685974, 7 + "narHash": "sha256-NKw96t+BgHIYzHUjkTK95FqYRVKB8DHpVhefWSz/kTw=", 8 + "rev": "549f2762aebeff29a2e5ece7a7dc0f955281a1d1", 9 + "type": "tarball", 10 + "url": "https://git.lix.systems/api/v1/repos/lix-project/flake-compat/archive/549f2762aebeff29a2e5ece7a7dc0f955281a1d1.tar.gz?rev=549f2762aebeff29a2e5ece7a7dc0f955281a1d1" 11 + }, 12 + "original": { 13 + "type": "tarball", 14 + "url": "https://git.lix.systems/lix-project/flake-compat/archive/main.tar.gz" 15 + } 16 + }, 17 + "flake-utils": { 4 18 "inputs": { 19 + "systems": "systems" 20 + }, 21 + "locked": { 22 + "lastModified": 1694529238, 23 + "narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=", 24 + "owner": "numtide", 25 + "repo": "flake-utils", 26 + "rev": "ff7b65b44d01cf9ba6a71320833626af21126384", 27 + "type": "github" 28 + }, 29 + "original": { 30 + "owner": "numtide", 31 + "repo": "flake-utils", 32 + "type": "github" 33 + } 34 + }, 35 + "gomod2nix": { 36 + "inputs": { 37 + "flake-utils": "flake-utils", 5 38 "nixpkgs": [ 6 39 "nixpkgs" 7 40 ] 8 41 }, 9 42 "locked": { 10 - "lastModified": 1709087332, 11 - "narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=", 12 - "owner": "hercules-ci", 13 - "repo": "gitignore.nix", 14 - "rev": "637db329424fd7e46cf4185293b9cc8c88c95394", 43 + "lastModified": 1754078208, 44 + "narHash": "sha256-YVoIFDCDpYuU3riaDEJ3xiGdPOtsx4sR5eTzHTytPV8=", 45 + "owner": "nix-community", 46 + "repo": "gomod2nix", 47 + "rev": "7f963246a71626c7fc70b431a315c4388a0c95cf", 15 48 "type": "github" 16 49 }, 17 50 "original": { 18 - "owner": "hercules-ci", 19 - "repo": "gitignore.nix", 51 + "owner": "nix-community", 52 + "repo": "gomod2nix", 20 53 "type": "github" 21 54 } 22 55 }, ··· 32 65 "url": "https://unpkg.com/htmx.org@2.0.4/dist/htmx.min.js" 33 66 } 34 67 }, 68 + "htmx-ws-src": { 69 + "flake": false, 70 + "locked": { 71 + "narHash": "sha256-2fg6KyEJoO24q0fQqbz9RMaYNPQrMwpZh29tkSqdqGY=", 72 + "type": "file", 73 + "url": "https://cdn.jsdelivr.net/npm/htmx-ext-ws@2.0.2" 74 + }, 75 + "original": { 76 + "type": "file", 77 + "url": "https://cdn.jsdelivr.net/npm/htmx-ext-ws@2.0.2" 78 + } 79 + }, 35 80 "ibm-plex-mono-src": { 36 81 "flake": false, 37 82 "locked": { ··· 48 93 "indigo": { 49 94 "flake": false, 50 95 "locked": { 51 - "lastModified": 1745333930, 52 - "narHash": "sha256-83fIHqDE+dfnZ88HaNuwfKFO+R0RKAM1WxMfNh/Matk=", 96 + "lastModified": 1753693716, 97 + "narHash": "sha256-DMIKnCJRODQXEHUxA+7mLzRALmnZhkkbHlFT2rCQYrE=", 53 98 "owner": "oppiliappan", 54 99 "repo": "indigo", 55 - "rev": "e4e59280737b8676611fc077a228d47b3e8e9491", 100 + "rev": "5f170569da9360f57add450a278d73538092d8ca", 56 101 "type": "github" 57 102 }, 58 103 "original": { ··· 77 122 "lucide-src": { 78 123 "flake": false, 79 124 "locked": { 80 - "lastModified": 1742302029, 81 - "narHash": "sha256-OyPVtpnC4/AAmPq84Wt1r1Gcs48d9KG+UBCtZK87e9k=", 125 + "lastModified": 1754044466, 126 + "narHash": "sha256-+exBR2OToB1iv7ZQI2S4B0lXA/QRvC9n6U99UxGpJGs=", 82 127 "type": "tarball", 83 - "url": "https://github.com/lucide-icons/lucide/releases/download/0.483.0/lucide-icons-0.483.0.zip" 128 + "url": "https://github.com/lucide-icons/lucide/releases/download/0.536.0/lucide-icons-0.536.0.zip" 84 129 }, 85 130 "original": { 86 131 "type": "tarball", 87 - "url": "https://github.com/lucide-icons/lucide/releases/download/0.483.0/lucide-icons-0.483.0.zip" 132 + "url": "https://github.com/lucide-icons/lucide/releases/download/0.536.0/lucide-icons-0.536.0.zip" 88 133 } 89 134 }, 90 135 "nixpkgs": { 91 136 "locked": { 92 - "lastModified": 1746904237, 93 - "narHash": "sha256-3e+AVBczosP5dCLQmMoMEogM57gmZ2qrVSrmq9aResQ=", 137 + "lastModified": 1751984180, 138 + "narHash": "sha256-LwWRsENAZJKUdD3SpLluwDmdXY9F45ZEgCb0X+xgOL0=", 94 139 "owner": "nixos", 95 140 "repo": "nixpkgs", 96 - "rev": "d89fc19e405cb2d55ce7cc114356846a0ee5e956", 141 + "rev": "9807714d6944a957c2e036f84b0ff8caf9930bc0", 97 142 "type": "github" 98 143 }, 99 144 "original": { ··· 105 150 }, 106 151 "root": { 107 152 "inputs": { 108 - "gitignore": "gitignore", 153 + "flake-compat": "flake-compat", 154 + "gomod2nix": "gomod2nix", 109 155 "htmx-src": "htmx-src", 156 + "htmx-ws-src": "htmx-ws-src", 110 157 "ibm-plex-mono-src": "ibm-plex-mono-src", 111 158 "indigo": "indigo", 112 159 "inter-fonts-src": "inter-fonts-src", ··· 126 173 "original": { 127 174 "type": "tarball", 128 175 "url": "https://sqlite.org/2024/sqlite-amalgamation-3450100.zip" 176 + } 177 + }, 178 + "systems": { 179 + "locked": { 180 + "lastModified": 1681028828, 181 + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 182 + "owner": "nix-systems", 183 + "repo": "default", 184 + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 185 + "type": "github" 186 + }, 187 + "original": { 188 + "owner": "nix-systems", 189 + "repo": "default", 190 + "type": "github" 129 191 } 130 192 } 131 193 },
+198 -62
flake.nix
··· 3 3 4 4 inputs = { 5 5 nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; 6 + gomod2nix = { 7 + url = "github:nix-community/gomod2nix"; 8 + inputs.nixpkgs.follows = "nixpkgs"; 9 + }; 10 + flake-compat = { 11 + url = "https://git.lix.systems/lix-project/flake-compat/archive/main.tar.gz"; 12 + flake = false; 13 + }; 6 14 indigo = { 7 15 url = "github:oppiliappan/indigo"; 8 16 flake = false; ··· 11 19 url = "https://unpkg.com/htmx.org@2.0.4/dist/htmx.min.js"; 12 20 flake = false; 13 21 }; 22 + htmx-ws-src = { 23 + # strange errors in consle that i can't really make out 24 + # url = "https://unpkg.com/htmx.org@2.0.4/dist/ext/ws.js"; 25 + url = "https://cdn.jsdelivr.net/npm/htmx-ext-ws@2.0.2"; 26 + flake = false; 27 + }; 14 28 lucide-src = { 15 - url = "https://github.com/lucide-icons/lucide/releases/download/0.483.0/lucide-icons-0.483.0.zip"; 29 + url = "https://github.com/lucide-icons/lucide/releases/download/0.536.0/lucide-icons-0.536.0.zip"; 16 30 flake = false; 17 31 }; 18 32 inter-fonts-src = { ··· 27 41 url = "https://sqlite.org/2024/sqlite-amalgamation-3450100.zip"; 28 42 flake = false; 29 43 }; 30 - gitignore = { 31 - url = "github:hercules-ci/gitignore.nix"; 32 - inputs.nixpkgs.follows = "nixpkgs"; 33 - }; 34 44 }; 35 45 36 46 outputs = { 37 47 self, 38 48 nixpkgs, 49 + gomod2nix, 39 50 indigo, 40 51 htmx-src, 52 + htmx-ws-src, 41 53 lucide-src, 42 - gitignore, 43 54 inter-fonts-src, 44 55 sqlite-lib-src, 45 56 ibm-plex-mono-src, 57 + ... 46 58 }: let 47 59 supportedSystems = ["x86_64-linux" "x86_64-darwin" "aarch64-linux" "aarch64-darwin"]; 48 60 forAllSystems = nixpkgs.lib.genAttrs supportedSystems; 49 - nixpkgsFor = forAllSystems (system: 50 - import nixpkgs { 51 - inherit system; 52 - overlays = [self.overlays.default]; 53 - }); 54 - inherit (gitignore.lib) gitignoreSource; 55 - in { 56 - overlays.default = final: prev: let 57 - goModHash = "sha256-G+59ZwQwBbnO9ZjAB5zMEmWZbeG4k7ko/lPz+ceqYKs="; 58 - appviewDeps = { 59 - inherit htmx-src lucide-src inter-fonts-src ibm-plex-mono-src goModHash gitignoreSource; 60 - }; 61 - knotDeps = { 62 - inherit goModHash gitignoreSource; 63 - }; 64 - mkPackageSet = pkgs: { 65 - lexgen = pkgs.callPackage ./nix/pkgs/lexgen.nix {inherit indigo;}; 66 - appview = pkgs.callPackage ./nix/pkgs/appview.nix appviewDeps; 67 - knot = pkgs.callPackage ./nix/pkgs/knot.nix {}; 68 - knot-unwrapped = pkgs.callPackage ./nix/pkgs/knot-unwrapped.nix knotDeps; 69 - sqlite-lib = pkgs.callPackage ./nix/pkgs/sqlite-lib.nix { 61 + nixpkgsFor = forAllSystems (system: nixpkgs.legacyPackages.${system}); 62 + 63 + mkPackageSet = pkgs: 64 + pkgs.lib.makeScope pkgs.newScope (self: { 65 + src = let 66 + fs = pkgs.lib.fileset; 67 + in 68 + fs.toSource { 69 + root = ./.; 70 + fileset = fs.difference (fs.intersection (fs.gitTracked ./.) (fs.fileFilter (file: !(file.hasExt "nix")) ./.)) (fs.maybeMissing ./.jj); 71 + }; 72 + buildGoApplication = 73 + (self.callPackage "${gomod2nix}/builder" { 74 + gomod2nix = gomod2nix.legacyPackages.${pkgs.system}.gomod2nix; 75 + }).buildGoApplication; 76 + modules = ./nix/gomod2nix.toml; 77 + sqlite-lib = self.callPackage ./nix/pkgs/sqlite-lib.nix { 70 78 inherit (pkgs) gcc; 71 79 inherit sqlite-lib-src; 72 80 }; 73 - genjwks = pkgs.callPackage ./nix/pkgs/genjwks.nix {inherit goModHash gitignoreSource;}; 74 - }; 75 - in 76 - mkPackageSet final; 81 + genjwks = self.callPackage ./nix/pkgs/genjwks.nix {}; 82 + lexgen = self.callPackage ./nix/pkgs/lexgen.nix {inherit indigo;}; 83 + appview-static-files = self.callPackage ./nix/pkgs/appview-static-files.nix { 84 + inherit htmx-src htmx-ws-src lucide-src inter-fonts-src ibm-plex-mono-src; 85 + }; 86 + appview = self.callPackage ./nix/pkgs/appview.nix {}; 87 + spindle = self.callPackage ./nix/pkgs/spindle.nix {}; 88 + knot-unwrapped = self.callPackage ./nix/pkgs/knot-unwrapped.nix {}; 89 + knot = self.callPackage ./nix/pkgs/knot.nix {}; 90 + }); 91 + in { 92 + overlays.default = final: prev: { 93 + inherit (mkPackageSet final) lexgen sqlite-lib genjwks spindle knot-unwrapped knot appview; 94 + }; 77 95 78 96 packages = forAllSystems (system: let 79 97 pkgs = nixpkgsFor.${system}; 80 - staticPkgs = pkgs.pkgsStatic; 81 - crossPkgs = pkgs.pkgsCross.gnu64.pkgsStatic; 98 + packages = mkPackageSet pkgs; 99 + staticPackages = mkPackageSet pkgs.pkgsStatic; 100 + crossPackages = mkPackageSet pkgs.pkgsCross.gnu64.pkgsStatic; 82 101 in { 83 - appview = pkgs.appview; 84 - lexgen = pkgs.lexgen; 85 - knot = pkgs.knot; 86 - knot-unwrapped = pkgs.knot-unwrapped; 87 - genjwks = pkgs.genjwks; 88 - sqlite-lib = pkgs.sqlite-lib; 102 + inherit (packages) appview appview-static-files lexgen genjwks spindle knot knot-unwrapped sqlite-lib; 103 + 104 + pkgsStatic-appview = staticPackages.appview; 105 + pkgsStatic-knot = staticPackages.knot; 106 + pkgsStatic-knot-unwrapped = staticPackages.knot-unwrapped; 107 + pkgsStatic-spindle = staticPackages.spindle; 108 + pkgsStatic-sqlite-lib = staticPackages.sqlite-lib; 109 + 110 + pkgsCross-gnu64-pkgsStatic-appview = crossPackages.appview; 111 + pkgsCross-gnu64-pkgsStatic-knot = crossPackages.knot; 112 + pkgsCross-gnu64-pkgsStatic-knot-unwrapped = crossPackages.knot-unwrapped; 113 + pkgsCross-gnu64-pkgsStatic-spindle = crossPackages.spindle; 114 + 115 + treefmt-wrapper = pkgs.treefmt.withConfig { 116 + settings.formatter = { 117 + alejandra = { 118 + command = pkgs.lib.getExe pkgs.alejandra; 119 + includes = ["*.nix"]; 120 + }; 89 121 90 - pkgsStatic-appview = staticPkgs.appview; 91 - pkgsStatic-knot = staticPkgs.knot; 92 - pkgsStatic-knot-unwrapped = staticPkgs.knot-unwrapped; 93 - pkgsStatic-sqlite-lib = staticPkgs.sqlite-lib; 94 - pkgsCross-gnu64-pkgsStatic-appview = crossPkgs.appview; 95 - pkgsCross-gnu64-pkgsStatic-knot = crossPkgs.knot; 96 - pkgsCross-gnu64-pkgsStatic-knot-unwrapped = crossPkgs.knot-unwrapped; 122 + gofmt = { 123 + command = pkgs.lib.getExe' pkgs.go "gofmt"; 124 + options = ["-w"]; 125 + includes = ["*.go"]; 126 + }; 127 + 128 + # prettier = let 129 + # wrapper = pkgs.runCommandLocal "prettier-wrapper" {nativeBuildInputs = [pkgs.makeWrapper];} '' 130 + # makeWrapper ${pkgs.prettier}/bin/prettier "$out" --add-flags "--plugin=${pkgs.prettier-plugin-go-template}/lib/node_modules/prettier-plugin-go-template/lib/index.js" 131 + # ''; 132 + # in { 133 + # command = wrapper; 134 + # options = ["-w"]; 135 + # includes = ["*.html"]; 136 + # # causes Go template plugin errors: https://github.com/NiklasPor/prettier-plugin-go-template/issues/120 137 + # excludes = ["appview/pages/templates/layouts/repobase.html" "appview/pages/templates/repo/tags.html"]; 138 + # }; 139 + }; 140 + }; 97 141 }); 98 - defaultPackage = forAllSystems (system: nixpkgsFor.${system}.appview); 99 - formatter = forAllSystems (system: nixpkgsFor."${system}".alejandra); 142 + defaultPackage = forAllSystems (system: self.packages.${system}.appview); 100 143 devShells = forAllSystems (system: let 101 144 pkgs = nixpkgsFor.${system}; 145 + packages' = self.packages.${system}; 102 146 staticShell = pkgs.mkShell.override { 103 147 stdenv = pkgs.pkgsStatic.stdenv; 104 148 }; ··· 107 151 nativeBuildInputs = [ 108 152 pkgs.go 109 153 pkgs.air 154 + pkgs.tilt 110 155 pkgs.gopls 111 156 pkgs.httpie 112 - pkgs.lexgen 113 157 pkgs.litecli 114 158 pkgs.websocat 115 159 pkgs.tailwindcss 116 160 pkgs.nixos-shell 117 161 pkgs.redis 162 + pkgs.coreutils # for those of us who are on systems that use busybox (alpine) 163 + packages'.lexgen 164 + packages'.treefmt-wrapper 118 165 ]; 119 166 shellHook = '' 120 - mkdir -p appview/pages/static/{fonts,icons} 121 - cp -f ${htmx-src} appview/pages/static/htmx.min.js 122 - cp -rf ${lucide-src}/*.svg appview/pages/static/icons/ 123 - cp -f ${inter-fonts-src}/web/InterVariable*.woff2 appview/pages/static/fonts/ 124 - cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 appview/pages/static/fonts/ 125 - cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono-Regular.woff2 appview/pages/static/fonts/ 126 - export TANGLED_OAUTH_JWKS="$(${pkgs.genjwks}/bin/genjwks)" 167 + mkdir -p appview/pages/static 168 + # no preserve is needed because watch-tailwind will want to be able to overwrite 169 + cp -fr --no-preserve=ownership ${packages'.appview-static-files}/* appview/pages/static 170 + export TANGLED_OAUTH_JWKS="$(${packages'.genjwks}/bin/genjwks)" 127 171 ''; 128 172 env.CGO_ENABLED = 1; 129 173 }; 130 174 }); 131 175 apps = forAllSystems (system: let 132 176 pkgs = nixpkgsFor."${system}"; 177 + packages' = self.packages.${system}; 133 178 air-watcher = name: arg: 134 179 pkgs.writeShellScriptBin "run" 135 180 '' 136 181 ${pkgs.air}/bin/air -c /dev/null \ 137 182 -build.cmd "${pkgs.go}/bin/go build -o ./out/${name}.out ./cmd/${name}/main.go" \ 138 - -build.bin "./out/${name}.out ${arg}" \ 183 + -build.bin "./out/${name}.out" \ 184 + -build.args_bin "${arg}" \ 139 185 -build.stop_on_error "true" \ 140 186 -build.include_ext "go" 141 187 ''; 142 188 tailwind-watcher = 143 189 pkgs.writeShellScriptBin "run" 144 190 '' 145 - ${pkgs.tailwindcss}/bin/tailwindcss -w -i input.css -o ./appview/pages/static/tw.css 191 + ${pkgs.tailwindcss}/bin/tailwindcss --watch=always -i input.css -o ./appview/pages/static/tw.css 146 192 ''; 147 193 in { 194 + fmt = { 195 + type = "app"; 196 + program = pkgs.lib.getExe packages'.treefmt-wrapper; 197 + }; 148 198 watch-appview = { 149 199 type = "app"; 150 - program = ''${air-watcher "appview" ""}/bin/run''; 200 + program = toString (pkgs.writeShellScript "watch-appview" '' 201 + echo "copying static files to appview/pages/static..." 202 + ${pkgs.coreutils}/bin/cp -fr --no-preserve=ownership ${packages'.appview-static-files}/* appview/pages/static 203 + ${air-watcher "appview" ""}/bin/run 204 + ''); 151 205 }; 152 206 watch-knot = { 153 207 type = "app"; ··· 157 211 type = "app"; 158 212 program = ''${tailwind-watcher}/bin/run''; 159 213 }; 214 + vm = let 215 + guestSystem = 216 + if pkgs.stdenv.hostPlatform.isAarch64 217 + then "aarch64-linux" 218 + else "x86_64-linux"; 219 + in { 220 + type = "app"; 221 + program = 222 + (pkgs.writeShellApplication { 223 + name = "launch-vm"; 224 + text = '' 225 + rootDir=$(jj --ignore-working-copy root || git rev-parse --show-toplevel) || (echo "error: can't find repo root?"; exit 1) 226 + cd "$rootDir" 227 + 228 + mkdir -p nix/vm-data/{knot,repos,spindle,spindle-logs} 229 + 230 + export TANGLED_VM_DATA_DIR="$rootDir/nix/vm-data" 231 + exec ${pkgs.lib.getExe 232 + (import ./nix/vm.nix { 233 + inherit nixpkgs self; 234 + system = guestSystem; 235 + hostSystem = system; 236 + }).config.system.build.vm} 237 + ''; 238 + }) 239 + + /bin/launch-vm; 240 + }; 241 + gomod2nix = { 242 + type = "app"; 243 + program = toString (pkgs.writeShellScript "gomod2nix" '' 244 + ${gomod2nix.legacyPackages.${system}.gomod2nix}/bin/gomod2nix generate --outdir ./nix 245 + ''); 246 + }; 247 + lexgen = { 248 + type = "app"; 249 + program = 250 + (pkgs.writeShellApplication { 251 + name = "lexgen"; 252 + text = '' 253 + if ! command -v lexgen > /dev/null; then 254 + echo "error: must be executed from devshell" 255 + exit 1 256 + fi 257 + 258 + rootDir=$(jj --ignore-working-copy root || git rev-parse --show-toplevel) || (echo "error: can't find repo root?"; exit 1) 259 + cd "$rootDir" 260 + 261 + rm -f api/tangled/* 262 + lexgen --build-file lexicon-build-config.json lexicons 263 + sed -i.bak 's/\tutil/\/\/\tutil/' api/tangled/* 264 + ${pkgs.gotools}/bin/goimports -w api/tangled/* 265 + go run cmd/gen.go 266 + lexgen --build-file lexicon-build-config.json lexicons 267 + rm api/tangled/*.bak 268 + ''; 269 + }) 270 + + /bin/lexgen; 271 + }; 160 272 }); 161 273 162 - nixosModules.appview = import ./nix/modules/appview.nix {inherit self;}; 163 - nixosModules.knot = import ./nix/modules/knot.nix {inherit self;}; 164 - nixosConfigurations.knotVM = import ./nix/vm.nix {inherit self nixpkgs;}; 274 + nixosModules.appview = { 275 + lib, 276 + pkgs, 277 + ... 278 + }: { 279 + imports = [./nix/modules/appview.nix]; 280 + 281 + services.tangled-appview.package = lib.mkDefault self.packages.${pkgs.system}.appview; 282 + }; 283 + nixosModules.knot = { 284 + lib, 285 + pkgs, 286 + ... 287 + }: { 288 + imports = [./nix/modules/knot.nix]; 289 + 290 + services.tangled-knot.package = lib.mkDefault self.packages.${pkgs.system}.knot; 291 + }; 292 + nixosModules.spindle = { 293 + lib, 294 + pkgs, 295 + ... 296 + }: { 297 + imports = [./nix/modules/spindle.nix]; 298 + 299 + services.tangled-spindle.package = lib.mkDefault self.packages.${pkgs.system}.spindle; 300 + }; 165 301 }; 166 302 }
+62 -34
go.mod
··· 1 1 module tangled.sh/tangled.sh/core 2 2 3 - go 1.24.0 4 - 5 - toolchain go1.24.3 3 + go 1.24.4 6 4 7 5 require ( 8 6 github.com/Blank-Xu/sql-adapter v1.1.1 7 + github.com/alecthomas/assert/v2 v2.11.0 9 8 github.com/alecthomas/chroma/v2 v2.15.0 9 + github.com/avast/retry-go/v4 v4.6.1 10 10 github.com/bluekeyes/go-gitdiff v0.8.1 11 - github.com/bluesky-social/indigo v0.0.0-20250520232546-236dd575c91e 11 + github.com/bluesky-social/indigo v0.0.0-20250724221105-5827c8fb61bb 12 12 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 13 13 github.com/carlmjohnson/versioninfo v0.22.5 14 14 github.com/casbin/casbin/v2 v2.103.0 15 + github.com/cloudflare/cloudflare-go v0.115.0 15 16 github.com/cyphar/filepath-securejoin v0.4.1 16 17 github.com/dgraph-io/ristretto v0.2.0 17 18 github.com/docker/docker v28.2.2+incompatible ··· 21 22 github.com/go-enry/go-enry/v2 v2.9.2 22 23 github.com/go-git/go-git/v5 v5.14.0 23 24 github.com/google/uuid v1.6.0 25 + github.com/gorilla/feeds v1.2.0 24 26 github.com/gorilla/sessions v1.4.0 25 - github.com/gorilla/websocket v1.5.3 27 + github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 26 28 github.com/hiddeco/sshsig v0.2.0 29 + github.com/hpcloud/tail v1.0.0 27 30 github.com/ipfs/go-cid v0.5.0 28 31 github.com/lestrrat-go/jwx/v2 v2.1.6 29 32 github.com/mattn/go-sqlite3 v1.14.24 30 33 github.com/microcosm-cc/bluemonday v1.0.27 34 + github.com/openbao/openbao/api/v2 v2.3.0 31 35 github.com/posthog/posthog-go v1.5.5 32 - github.com/redis/go-redis/v9 v9.3.0 36 + github.com/redis/go-redis/v9 v9.7.3 33 37 github.com/resend/resend-go/v2 v2.15.0 34 38 github.com/sethvargo/go-envconfig v1.1.0 35 39 github.com/stretchr/testify v1.10.0 36 40 github.com/urfave/cli/v3 v3.3.3 37 41 github.com/whyrusleeping/cbor-gen v0.3.1 38 - github.com/yuin/goldmark v1.4.13 39 - golang.org/x/crypto v0.38.0 40 - golang.org/x/net v0.40.0 42 + github.com/wyatt915/goldmark-treeblood v0.0.0-20250825231212-5dcbdb2f4b57 43 + github.com/yuin/goldmark v1.7.12 44 + github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc 45 + golang.org/x/crypto v0.40.0 46 + golang.org/x/net v0.42.0 47 + golang.org/x/sync v0.16.0 41 48 golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da 42 49 gopkg.in/yaml.v3 v3.0.1 43 - tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250526154904-3906c5336421 50 + tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250724194903-28e660378cb1 44 51 ) 45 52 46 53 require ( 47 54 dario.cat/mergo v1.0.1 // indirect 48 55 github.com/Microsoft/go-winio v0.6.2 // indirect 49 - github.com/ProtonMail/go-crypto v1.2.0 // indirect 56 + github.com/ProtonMail/go-crypto v1.3.0 // indirect 57 + github.com/alecthomas/repr v0.4.0 // indirect 50 58 github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect 51 59 github.com/aymerick/douceur v0.2.0 // indirect 52 60 github.com/beorn7/perks v1.0.1 // indirect 53 61 github.com/bmatcuk/doublestar/v4 v4.7.1 // indirect 54 62 github.com/casbin/govaluate v1.3.0 // indirect 63 + github.com/cenkalti/backoff/v4 v4.3.0 // indirect 55 64 github.com/cespare/xxhash/v2 v2.3.0 // indirect 56 - github.com/cloudflare/circl v1.6.0 // indirect 65 + github.com/cloudflare/circl v1.6.2-0.20250618153321-aa837fd1539d // indirect 57 66 github.com/containerd/errdefs v1.0.0 // indirect 58 67 github.com/containerd/errdefs/pkg v0.3.0 // indirect 59 68 github.com/containerd/log v0.1.0 // indirect ··· 66 75 github.com/docker/go-units v0.5.0 // indirect 67 76 github.com/emirpasic/gods v1.18.1 // indirect 68 77 github.com/felixge/httpsnoop v1.0.4 // indirect 78 + github.com/fsnotify/fsnotify v1.6.0 // indirect 69 79 github.com/go-enry/go-oniguruma v1.2.1 // indirect 70 80 github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect 71 81 github.com/go-git/go-billy/v5 v5.6.2 // indirect 72 - github.com/go-logr/logr v1.4.2 // indirect 82 + github.com/go-jose/go-jose/v3 v3.0.4 // indirect 83 + github.com/go-logr/logr v1.4.3 // indirect 73 84 github.com/go-logr/stdr v1.2.2 // indirect 74 85 github.com/go-redis/cache/v9 v9.0.0 // indirect 86 + github.com/go-test/deep v1.1.1 // indirect 75 87 github.com/goccy/go-json v0.10.5 // indirect 76 88 github.com/gogo/protobuf v1.3.2 // indirect 77 - github.com/golang-jwt/jwt/v5 v5.2.2 // indirect 89 + github.com/golang-jwt/jwt/v5 v5.2.3 // indirect 78 90 github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect 91 + github.com/golang/mock v1.6.0 // indirect 92 + github.com/google/go-querystring v1.1.0 // indirect 79 93 github.com/gorilla/css v1.0.1 // indirect 80 94 github.com/gorilla/securecookie v1.1.2 // indirect 95 + github.com/hashicorp/errwrap v1.1.0 // indirect 81 96 github.com/hashicorp/go-cleanhttp v0.5.2 // indirect 82 - github.com/hashicorp/go-retryablehttp v0.7.7 // indirect 97 + github.com/hashicorp/go-multierror v1.1.1 // indirect 98 + github.com/hashicorp/go-retryablehttp v0.7.8 // indirect 99 + github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 // indirect 100 + github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect 101 + github.com/hashicorp/go-sockaddr v1.0.7 // indirect 83 102 github.com/hashicorp/golang-lru v1.0.2 // indirect 84 103 github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect 104 + github.com/hashicorp/hcl v1.0.1-vault-7 // indirect 105 + github.com/hexops/gotextdiff v1.0.3 // indirect 85 106 github.com/ipfs/bbloom v0.0.4 // indirect 86 - github.com/ipfs/boxo v0.30.0 // indirect 87 - github.com/ipfs/go-block-format v0.2.1 // indirect 107 + github.com/ipfs/boxo v0.33.0 // indirect 108 + github.com/ipfs/go-block-format v0.2.2 // indirect 88 109 github.com/ipfs/go-datastore v0.8.2 // indirect 89 110 github.com/ipfs/go-ipfs-blockstore v1.3.1 // indirect 90 111 github.com/ipfs/go-ipfs-ds-help v1.1.1 // indirect 91 - github.com/ipfs/go-ipld-cbor v0.2.0 // indirect 92 - github.com/ipfs/go-ipld-format v0.6.1 // indirect 112 + github.com/ipfs/go-ipld-cbor v0.2.1 // indirect 113 + github.com/ipfs/go-ipld-format v0.6.2 // indirect 93 114 github.com/ipfs/go-log v1.0.5 // indirect 94 115 github.com/ipfs/go-log/v2 v2.6.0 // indirect 95 116 github.com/ipfs/go-metrics-interface v0.3.0 // indirect 96 117 github.com/kevinburke/ssh_config v1.2.0 // indirect 97 118 github.com/klauspost/compress v1.18.0 // indirect 98 - github.com/klauspost/cpuid/v2 v2.2.10 // indirect 99 - github.com/lestrrat-go/blackmagic v1.0.3 // indirect 119 + github.com/klauspost/cpuid/v2 v2.3.0 // indirect 120 + github.com/lestrrat-go/blackmagic v1.0.4 // indirect 100 121 github.com/lestrrat-go/httpcc v1.0.1 // indirect 101 122 github.com/lestrrat-go/httprc v1.0.6 // indirect 102 123 github.com/lestrrat-go/iter v1.0.2 // indirect 103 124 github.com/lestrrat-go/option v1.0.1 // indirect 104 125 github.com/mattn/go-isatty v0.0.20 // indirect 105 126 github.com/minio/sha256-simd v1.0.1 // indirect 127 + github.com/mitchellh/mapstructure v1.5.0 // indirect 106 128 github.com/moby/docker-image-spec v1.3.1 // indirect 107 129 github.com/moby/sys/atomicwriter v0.1.0 // indirect 108 130 github.com/moby/term v0.5.2 // indirect ··· 114 136 github.com/multiformats/go-multihash v0.2.3 // indirect 115 137 github.com/multiformats/go-varint v0.0.7 // indirect 116 138 github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 139 + github.com/onsi/gomega v1.37.0 // indirect 117 140 github.com/opencontainers/go-digest v1.0.0 // indirect 118 141 github.com/opencontainers/image-spec v1.1.1 // indirect 119 - github.com/opentracing/opentracing-go v1.2.0 // indirect 142 + github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b // indirect 120 143 github.com/pjbgf/sha1cd v0.3.2 // indirect 121 144 github.com/pkg/errors v0.9.1 // indirect 122 145 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 123 146 github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f // indirect 124 147 github.com/prometheus/client_golang v1.22.0 // indirect 125 148 github.com/prometheus/client_model v0.6.2 // indirect 126 - github.com/prometheus/common v0.63.0 // indirect 149 + github.com/prometheus/common v0.64.0 // indirect 127 150 github.com/prometheus/procfs v0.16.1 // indirect 151 + github.com/ryanuber/go-glob v1.0.0 // indirect 128 152 github.com/segmentio/asm v1.2.0 // indirect 129 153 github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect 130 154 github.com/spaolacci/murmur3 v1.1.0 // indirect 131 155 github.com/vmihailenco/go-tinylfu v0.2.2 // indirect 132 156 github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect 133 157 github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect 158 + github.com/wyatt915/treeblood v0.1.15 // indirect 134 159 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect 135 160 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect 136 161 go.opentelemetry.io/auto/sdk v1.1.0 // indirect 137 - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect 138 - go.opentelemetry.io/otel v1.36.0 // indirect 139 - go.opentelemetry.io/otel/metric v1.36.0 // indirect 140 - go.opentelemetry.io/otel/trace v1.36.0 // indirect 162 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 // indirect 163 + go.opentelemetry.io/otel v1.37.0 // indirect 164 + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 // indirect 165 + go.opentelemetry.io/otel/metric v1.37.0 // indirect 166 + go.opentelemetry.io/otel/trace v1.37.0 // indirect 141 167 go.opentelemetry.io/proto/otlp v1.6.0 // indirect 142 168 go.uber.org/atomic v1.11.0 // indirect 143 169 go.uber.org/multierr v1.11.0 // indirect 144 170 go.uber.org/zap v1.27.0 // indirect 145 - golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect 146 - golang.org/x/sync v0.14.0 // indirect 147 - golang.org/x/sys v0.33.0 // indirect 148 - golang.org/x/time v0.8.0 // indirect 149 - google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237 // indirect 150 - google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237 // indirect 151 - google.golang.org/grpc v1.72.1 // indirect 171 + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect 172 + golang.org/x/sys v0.34.0 // indirect 173 + golang.org/x/text v0.27.0 // indirect 174 + golang.org/x/time v0.12.0 // indirect 175 + google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 // indirect 176 + google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect 177 + google.golang.org/grpc v1.73.0 // indirect 152 178 google.golang.org/protobuf v1.36.6 // indirect 179 + gopkg.in/fsnotify.v1 v1.4.7 // indirect 180 + gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect 153 181 gopkg.in/warnings.v0 v0.1.2 // indirect 154 182 gotest.tools/v3 v3.5.2 // indirect 155 183 lukechampine.com/blake3 v1.4.1 // indirect
+145 -88
go.sum
··· 7 7 github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 8 8 github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= 9 9 github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= 10 - github.com/ProtonMail/go-crypto v1.2.0 h1:+PhXXn4SPGd+qk76TlEePBfOfivE0zkWFenhGhFLzWs= 11 - github.com/ProtonMail/go-crypto v1.2.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= 10 + github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw= 11 + github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= 12 12 github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= 13 13 github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= 14 14 github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= ··· 17 17 github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= 18 18 github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= 19 19 github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= 20 + github.com/avast/retry-go/v4 v4.6.1 h1:VkOLRubHdisGrHnTu89g08aQEWEgRU7LVEop3GbIcMk= 21 + github.com/avast/retry-go/v4 v4.6.1/go.mod h1:V6oF8njAwxJ5gRo1Q7Cxab24xs5NCWZBeaHHBklR8mA= 20 22 github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= 21 23 github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= 22 24 github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 23 25 github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 24 - github.com/bluesky-social/indigo v0.0.0-20250520232546-236dd575c91e h1:DVD+HxQsDCVJtAkjfIKZVaBNc3kayHaU+A2TJZkFdp4= 25 - github.com/bluesky-social/indigo v0.0.0-20250520232546-236dd575c91e/go.mod h1:ovyxp8AMO1Hoe838vMJUbqHTZaAR8ABM3g3TXu+A5Ng= 26 + github.com/bluesky-social/indigo v0.0.0-20250724221105-5827c8fb61bb h1:BqMNDZMfXwiRTJ6NvQotJ0qInn37JH5U8E+TF01CFHQ= 27 + github.com/bluesky-social/indigo v0.0.0-20250724221105-5827c8fb61bb/go.mod h1:0XUyOCRtL4/OiyeqMTmr6RlVHQMDgw3LS7CfibuZR5Q= 26 28 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 h1:CFvRtYNSnWRAi/98M3O466t9dYuwtesNbu6FVPymRrA= 27 29 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1/go.mod h1:WiYEeyJSdUwqoaZ71KJSpTblemUCpwJfh5oVXplK6T4= 28 30 github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= ··· 49 51 github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= 50 52 github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= 51 53 github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= 52 - github.com/cloudflare/circl v1.6.0 h1:cr5JKic4HI+LkINy2lg3W2jF8sHCVTBncJr5gIIq7qk= 53 - github.com/cloudflare/circl v1.6.0/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= 54 + github.com/cloudflare/circl v1.6.2-0.20250618153321-aa837fd1539d h1:IiIprFGH6SqstblP0Y9NIo3eaUJGkI/YDOFVSL64Uq4= 55 + github.com/cloudflare/circl v1.6.2-0.20250618153321-aa837fd1539d/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= 56 + github.com/cloudflare/cloudflare-go v0.115.0 h1:84/dxeeXweCc0PN5Cto44iTA8AkG1fyT11yPO5ZB7sM= 57 + github.com/cloudflare/cloudflare-go v0.115.0/go.mod h1:Ds6urDwn/TF2uIU24mu7H91xkKP8gSAHxQ44DSZgVmU= 54 58 github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= 55 59 github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= 56 60 github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= ··· 75 79 github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= 76 80 github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= 77 81 github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= 82 + github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= 78 83 github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= 79 84 github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= 80 85 github.com/docker/docker v28.2.2+incompatible h1:CjwRSksz8Yo4+RmQ339Dp/D2tGO5JxwYeqtMOEe0LDw= ··· 89 94 github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= 90 95 github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= 91 96 github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= 92 - github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= 93 - github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= 97 + github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= 98 + github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= 94 99 github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= 95 100 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= 96 101 github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= 97 - github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= 98 102 github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= 103 + github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= 104 + github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= 99 105 github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= 100 106 github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= 101 107 github.com/go-chi/chi/v5 v5.2.0 h1:Aj1EtB0qR2Rdo2dG4O94RIU35w2lvQSj6BRA4+qwFL0= ··· 112 118 github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= 113 119 github.com/go-git/go-git-fixtures/v5 v5.0.0-20241203230421-0753e18f8f03 h1:LumE+tQdnYW24a9RoO08w64LHTzkNkdUqBD/0QPtlEY= 114 120 github.com/go-git/go-git-fixtures/v5 v5.0.0-20241203230421-0753e18f8f03/go.mod h1:hMKrMnUE4W0SJ7bFyM00dyz/HoknZoptGWzrj6M+dEM= 121 + github.com/go-jose/go-jose/v3 v3.0.4 h1:Wp5HA7bLQcKnf6YYao/4kpRpVMp/yf6+pJKV8WFSaNY= 122 + github.com/go-jose/go-jose/v3 v3.0.4/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ= 115 123 github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 116 124 github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 117 - github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= 118 - github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 125 + github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= 126 + github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 119 127 github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 120 128 github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 121 129 github.com/go-redis/cache/v9 v9.0.0 h1:0thdtFo0xJi0/WXbRVu8B066z8OvVymXTJGaXrVWnN0= 122 130 github.com/go-redis/cache/v9 v9.0.0/go.mod h1:cMwi1N8ASBOufbIvk7cdXe2PbPjK/WMRL95FFHWsSgI= 123 131 github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= 132 + github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= 133 + github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= 124 134 github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0= 125 135 github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= 126 136 github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= 127 137 github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 128 138 github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 129 - github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= 130 - github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= 139 + github.com/golang-jwt/jwt/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0= 140 + github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= 131 141 github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= 132 142 github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= 133 - github.com/golang/mock v1.4.4 h1:l75CXGRSwbaYNpl/Z2X1XIIAMSCquvXgpVZDhwEIJsc= 134 143 github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= 144 + github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= 145 + github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= 135 146 github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 136 147 github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= 137 148 github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= ··· 144 155 github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 145 156 github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 146 157 github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 158 + github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 147 159 github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 148 160 github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 149 161 github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 150 162 github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 151 163 github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 164 + github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= 165 + github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 152 166 github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= 153 167 github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 154 168 github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= ··· 160 174 github.com/gopherjs/gopherjs v0.0.0-20190430165422-3e4dfb77656c/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= 161 175 github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= 162 176 github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= 177 + github.com/gorilla/feeds v1.2.0 h1:O6pBiXJ5JHhPvqy53NsjKOThq+dNFm8+DFrxBEdzSCc= 178 + github.com/gorilla/feeds v1.2.0/go.mod h1:WMib8uJP3BbY+X8Szd1rA5Pzhdfh+HCCAYT2z7Fza6Y= 163 179 github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= 164 180 github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= 165 181 github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= 166 182 github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= 167 - github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= 168 - github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 183 + github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= 184 + github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= 169 185 github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo= 170 186 github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI= 187 + github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= 188 + github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= 189 + github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= 171 190 github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= 172 191 github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= 173 192 github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= 174 193 github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= 175 - github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU= 176 - github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= 194 + github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= 195 + github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= 196 + github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= 197 + github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= 198 + github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 h1:U+kC2dOhMFQctRfhK0gRctKAPTloZdMU5ZJxaesJ/VM= 199 + github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0/go.mod h1:Ll013mhdmsVDuoIXVfBtvgGJsXDYkTw1kooNcoCXuE0= 200 + github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts= 201 + github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= 202 + github.com/hashicorp/go-sockaddr v1.0.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9dbT+Fw= 203 + github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw= 177 204 github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c= 178 205 github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= 179 206 github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= 180 207 github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= 208 + github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I= 209 + github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM= 181 210 github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= 182 211 github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= 183 212 github.com/hiddeco/sshsig v0.2.0 h1:gMWllgKCITXdydVkDL+Zro0PU96QI55LwUwebSwNTSw= 184 213 github.com/hiddeco/sshsig v0.2.0/go.mod h1:nJc98aGgiH6Yql2doqH4CTBVHexQA40Q+hMMLHP4EqE= 214 + github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI= 185 215 github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= 186 216 github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= 187 217 github.com/ipfs/bbloom v0.0.4 h1:Gi+8EGJ2y5qiD5FbsbpX/TMNcJw8gSqr7eyjHa4Fhvs= 188 218 github.com/ipfs/bbloom v0.0.4/go.mod h1:cS9YprKXpoZ9lT0n/Mw/a6/aFV6DTjTLYHeA+gyqMG0= 189 - github.com/ipfs/boxo v0.30.0 h1:7afsoxPGGqfoH7Dum/wOTGUB9M5fb8HyKPMlLfBvIEQ= 190 - github.com/ipfs/boxo v0.30.0/go.mod h1:BPqgGGyHB9rZZcPSzah2Dc9C+5Or3U1aQe7EH1H7370= 191 - github.com/ipfs/go-block-format v0.2.1 h1:96kW71XGNNa+mZw/MTzJrCpMhBWCrd9kBLoKm9Iip/Q= 192 - github.com/ipfs/go-block-format v0.2.1/go.mod h1:frtvXHMQhM6zn7HvEQu+Qz5wSTj+04oEH/I+NjDgEjk= 219 + github.com/ipfs/boxo v0.33.0 h1:9ow3chwkDzMj0Deq4AWRUEI7WnIIV7SZhPTzzG2mmfw= 220 + github.com/ipfs/boxo v0.33.0/go.mod h1:3IPh7YFcCIcKp6o02mCHovrPntoT5Pctj/7j4syh/RM= 221 + github.com/ipfs/go-block-format v0.2.2 h1:uecCTgRwDIXyZPgYspaLXoMiMmxQpSx2aq34eNc4YvQ= 222 + github.com/ipfs/go-block-format v0.2.2/go.mod h1:vmuefuWU6b+9kIU0vZJgpiJt1yicQz9baHXE8qR+KB8= 193 223 github.com/ipfs/go-cid v0.5.0 h1:goEKKhaGm0ul11IHA7I6p1GmKz8kEYniqFopaB5Otwg= 194 224 github.com/ipfs/go-cid v0.5.0/go.mod h1:0L7vmeNXpQpUS9vt+yEARkJ8rOg43DF3iPgn4GIN0mk= 195 225 github.com/ipfs/go-datastore v0.8.2 h1:Jy3wjqQR6sg/LhyY0NIePZC3Vux19nLtg7dx0TVqr6U= ··· 202 232 github.com/ipfs/go-ipfs-ds-help v1.1.1/go.mod h1:75vrVCkSdSFidJscs8n4W+77AtTpCIAdDGAwjitJMIo= 203 233 github.com/ipfs/go-ipfs-util v0.0.3 h1:2RFdGez6bu2ZlZdI+rWfIdbQb1KudQp3VGwPtdNCmE0= 204 234 github.com/ipfs/go-ipfs-util v0.0.3/go.mod h1:LHzG1a0Ig4G+iZ26UUOMjHd+lfM84LZCrn17xAKWBvs= 205 - github.com/ipfs/go-ipld-cbor v0.2.0 h1:VHIW3HVIjcMd8m4ZLZbrYpwjzqlVUfjLM7oK4T5/YF0= 206 - github.com/ipfs/go-ipld-cbor v0.2.0/go.mod h1:Cp8T7w1NKcu4AQJLqK0tWpd1nkgTxEVB5C6kVpLW6/0= 207 - github.com/ipfs/go-ipld-format v0.6.1 h1:lQLmBM/HHbrXvjIkrydRXkn+gc0DE5xO5fqelsCKYOQ= 208 - github.com/ipfs/go-ipld-format v0.6.1/go.mod h1:8TOH1Hj+LFyqM2PjSqI2/ZnyO0KlfhHbJLkbxFa61hs= 235 + github.com/ipfs/go-ipld-cbor v0.2.1 h1:H05yEJbK/hxg0uf2AJhyerBDbjOuHX4yi+1U/ogRa7E= 236 + github.com/ipfs/go-ipld-cbor v0.2.1/go.mod h1:x9Zbeq8CoE5R2WicYgBMcr/9mnkQ0lHddYWJP2sMV3A= 237 + github.com/ipfs/go-ipld-format v0.6.2 h1:bPZQ+A05ol0b3lsJSl0bLvwbuQ+HQbSsdGTy4xtYUkU= 238 + github.com/ipfs/go-ipld-format v0.6.2/go.mod h1:nni2xFdHKx5lxvXJ6brt/pndtGxKAE+FPR1rg4jTkyk= 209 239 github.com/ipfs/go-log v1.0.5 h1:2dOuUCB1Z7uoczMWgAyDck5JLb72zHzrMnGnCNNbvY8= 210 240 github.com/ipfs/go-log v1.0.5/go.mod h1:j0b8ZoR+7+R99LD9jZ6+AJsrzkPbSXbZfGakb5JPtIo= 211 241 github.com/ipfs/go-log/v2 v2.1.3/go.mod h1:/8d0SH3Su5Ooc31QlL1WysJhvyOTDCjcCZ9Axpmri6g= ··· 213 243 github.com/ipfs/go-log/v2 v2.6.0/go.mod h1:p+Efr3qaY5YXpx9TX7MoLCSEZX5boSWj9wh86P5HJa8= 214 244 github.com/ipfs/go-metrics-interface v0.3.0 h1:YwG7/Cy4R94mYDUuwsBfeziJCVm9pBMJ6q/JR9V40TU= 215 245 github.com/ipfs/go-metrics-interface v0.3.0/go.mod h1:OxxQjZDGocXVdyTPocns6cOLwHieqej/jos7H4POwoY= 216 - github.com/ipfs/go-test v0.2.1 h1:/D/a8xZ2JzkYqcVcV/7HYlCnc7bv/pKHQiX5TdClkPE= 217 - github.com/ipfs/go-test v0.2.1/go.mod h1:dzu+KB9cmWjuJnXFDYJwC25T3j1GcN57byN+ixmK39M= 218 246 github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 219 247 github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 220 248 github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= ··· 226 254 github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= 227 255 github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= 228 256 github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= 229 - github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= 230 - github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= 257 + github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= 258 + github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= 231 259 github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 232 260 github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= 233 261 github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= ··· 236 264 github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 237 265 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 238 266 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 239 - github.com/lestrrat-go/blackmagic v1.0.3 h1:94HXkVLxkZO9vJI/w2u1T0DAoprShFd13xtnSINtDWs= 240 - github.com/lestrrat-go/blackmagic v1.0.3/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw= 267 + github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA= 268 + github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw= 241 269 github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= 242 270 github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= 243 271 github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k= ··· 248 276 github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU= 249 277 github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= 250 278 github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= 251 - github.com/libp2p/go-buffer-pool v0.1.0 h1:oK4mSFcQz7cTQIfqbe4MIj9gLW+mnanjyFtc6cdF0Y8= 252 - github.com/libp2p/go-buffer-pool v0.1.0/go.mod h1:N+vh8gMqimBzdKkSMVuydVDq+UV5QTWy5HSiZacSbPg= 253 - github.com/libp2p/go-libp2p v0.41.1 h1:8ecNQVT5ev/jqALTvisSJeVNvXYJyK4NhQx1nNRXQZE= 254 - github.com/libp2p/go-libp2p v0.41.1/go.mod h1:DcGTovJzQl/I7HMrby5ZRjeD0kQkGiy+9w6aEkSZpRI= 255 - github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 256 - github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 279 + github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= 280 + github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= 257 281 github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 258 282 github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 259 283 github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= ··· 262 286 github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= 263 287 github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM= 264 288 github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8= 289 + github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= 290 + github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= 265 291 github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= 266 292 github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= 267 293 github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= ··· 278 304 github.com/multiformats/go-base32 v0.1.0/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI= 279 305 github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9rQyccr0= 280 306 github.com/multiformats/go-base36 v0.2.0/go.mod h1:qvnKE++v+2MWCfePClUEjE78Z7P2a1UV0xHgWc0hkp4= 281 - github.com/multiformats/go-multiaddr v0.15.0 h1:zB/HeaI/apcZiTDwhY5YqMvNVl/oQYvs3XySU+qeAVo= 282 - github.com/multiformats/go-multiaddr v0.15.0/go.mod h1:JSVUmXDjsVFiW7RjIFMP7+Ev+h1DTbiJgVeTV/tcmP0= 283 307 github.com/multiformats/go-multibase v0.2.0 h1:isdYCVLvksgWlMW9OZRYJEa9pZETFivncJHmHnnd87g= 284 308 github.com/multiformats/go-multibase v0.2.0/go.mod h1:bFBZX4lKCA/2lyOFSAoKH5SS6oPyjtnzK/XTFDPkNuk= 285 - github.com/multiformats/go-multicodec v0.9.0 h1:pb/dlPnzee/Sxv/j4PmkDRxCOi3hXTz3IbPKOXWJkmg= 286 - github.com/multiformats/go-multicodec v0.9.0/go.mod h1:L3QTQvMIaVBkXOXXtVmYE+LI16i14xuaojr/H7Ai54k= 287 309 github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7BFvVU9RSh+U= 288 310 github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM= 289 311 github.com/multiformats/go-varint v0.0.7 h1:sWSGR+f/eu5ABZA2ZpYKBILXTTs9JWpdEM/nEGOHFS8= ··· 315 337 github.com/onsi/gomega v1.24.0/go.mod h1:Z/NWtiqwBrwUt4/2loMmHL63EDLnYHmVbuBpDr2vQAg= 316 338 github.com/onsi/gomega v1.24.1/go.mod h1:3AOiACssS3/MajrniINInwbfOOtfZvplPzuRSmvt1jM= 317 339 github.com/onsi/gomega v1.25.0/go.mod h1:r+zV744Re+DiYCIPRlYOTxn0YkOLcAnW8k1xXdMPGhM= 318 - github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= 319 - github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= 340 + github.com/onsi/gomega v1.37.0 h1:CdEG8g0S133B4OswTDC/5XPSzE1OeP29QOioj2PID2Y= 341 + github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0= 342 + github.com/openbao/openbao/api/v2 v2.3.0 h1:61FO3ILtpKoxbD9kTWeGaCq8pz1sdt4dv2cmTXsiaAc= 343 + github.com/openbao/openbao/api/v2 v2.3.0/go.mod h1:T47WKHb7DqHa3Ms3xicQtl5EiPE+U8diKjb9888okWs= 320 344 github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= 321 345 github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= 322 346 github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= 323 347 github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= 324 - github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs= 325 348 github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= 349 + github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b h1:FfH+VrHHk6Lxt9HdVS0PXzSXFyS2NbZKXv33FYPol0A= 350 + github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b/go.mod h1:AC62GU6hc0BrNm+9RK9VSiwa/EUe1bkIeFORAMcHvJU= 326 351 github.com/oppiliappan/chroma/v2 v2.19.0 h1:PN7/pb+6JRKCva30NPTtRJMlrOyzgpPpIroNzy4ekHU= 327 352 github.com/oppiliappan/chroma/v2 v2.19.0/go.mod h1:RVX6AvYm4VfYe/zsk7mjHueLDZor3aWCNE14TFlepBk= 328 353 github.com/oppiliappan/go-git/v5 v5.17.0 h1:CuJnpcIDxr0oiNaSHMconovSWnowHznVDG+AhjGuSEo= ··· 343 368 github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= 344 369 github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= 345 370 github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= 346 - github.com/prometheus/common v0.63.0 h1:YR/EIY1o3mEFP/kZCD7iDMnLPlGyuU2Gb3HIcXnA98k= 347 - github.com/prometheus/common v0.63.0/go.mod h1:VVFF/fBIoToEnWRVkYoXEkq3R3paCoxG9PXP74SnV18= 371 + github.com/prometheus/common v0.64.0 h1:pdZeA+g617P7oGv1CzdTzyeShxAGrTBsolKNOLQPGO4= 372 + github.com/prometheus/common v0.64.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= 348 373 github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= 349 374 github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= 350 375 github.com/redis/go-redis/v9 v9.0.0-rc.4/go.mod h1:Vo3EsyWnicKnSKCA7HhgnvnyA74wOA69Cd2Meli5mmA= 351 - github.com/redis/go-redis/v9 v9.3.0 h1:RiVDjmig62jIWp7Kk4XVLs0hzV6pI3PyTnnL0cnn0u0= 352 - github.com/redis/go-redis/v9 v9.3.0/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M= 376 + github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM= 377 + github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA= 353 378 github.com/resend/resend-go/v2 v2.15.0 h1:B6oMEPf8IEQwn2Ovx/9yymkESLDSeNfLFaNMw+mzHhE= 354 379 github.com/resend/resend-go/v2 v2.15.0/go.mod h1:3YCb8c8+pLiqhtRFXTyFwlLvfjQtluxOr9HEh2BwCkQ= 355 380 github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= ··· 357 382 github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= 358 383 github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= 359 384 github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 385 + github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= 386 + github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= 360 387 github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= 361 388 github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= 362 389 github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= ··· 399 426 github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw= 400 427 github.com/whyrusleeping/cbor-gen v0.3.1 h1:82ioxmhEYut7LBVGhGq8xoRkXPLElVuh5mV67AFfdv0= 401 428 github.com/whyrusleeping/cbor-gen v0.3.1/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so= 429 + github.com/wyatt915/goldmark-treeblood v0.0.0-20250825231212-5dcbdb2f4b57 h1:UqtQdzLXnvdBdqn/go53qGyncw1wJ7Mq5SQdieM1/Ew= 430 + github.com/wyatt915/goldmark-treeblood v0.0.0-20250825231212-5dcbdb2f4b57/go.mod h1:BxSCWByWSRSuembL3cDG1IBUbkBoO/oW/6tF19aA4hs= 431 + github.com/wyatt915/treeblood v0.1.15 h1:3KZ3o2LpcKZAzOLqMoW9qeUzKEaKArKpbcPpTkNfQC8= 432 + github.com/wyatt915/treeblood v0.1.15/go.mod h1:i7+yhhmzdDP17/97pIsOSffw74EK/xk+qJ0029cSXUY= 402 433 github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 403 434 github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 435 + github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 404 436 github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 405 - github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE= 406 437 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 438 + github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 439 + github.com/yuin/goldmark v1.7.12 h1:YwGP/rrea2/CnCtUHgjuolG/PnMxdQtPMO5PvaE2/nY= 440 + github.com/yuin/goldmark v1.7.12/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= 441 + github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ= 442 + github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I= 407 443 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRySiX3drau9C6Q5CAbNIApmLdat5jPMqChvDA= 408 444 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8= 409 445 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAFgcfgTnfJrmYKWhHnci3GjDqcZp1M3Q= 410 446 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02/go.mod h1:JTnUj0mpYiAsuZLmKjTx/ex3AtMowcCgnE7YNyCEP0I= 411 447 go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= 412 448 go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= 413 - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= 414 - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= 415 - go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg= 416 - go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E= 417 - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.31.0 h1:K0XaT3DwHAcV4nKLzcQvwAgSyisUghWoY20I7huthMk= 418 - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.31.0/go.mod h1:B5Ki776z/MBnVha1Nzwp5arlzBbE3+1jk+pGmaP5HME= 449 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 h1:Hf9xI/XLML9ElpiHVDNwvqI0hIFlzV8dgIr35kV1kRU= 450 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0/go.mod h1:NfchwuyNoMcZ5MLHwPrODwUF1HWCXWrL31s8gSAdIKY= 451 + go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= 452 + go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= 453 + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 h1:Vh5HayB/0HHfOQA7Ctx69E/Y/DcQSMPpKANYVMQ7fBA= 454 + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0/go.mod h1:cpgtDBaqD/6ok/UG0jT15/uKjAY8mRA53diogHBg3UI= 419 455 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.31.0 h1:lUsI2TYsQw2r1IASwoROaCnjdj2cvC2+Jbxvk6nHnWU= 420 456 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.31.0/go.mod h1:2HpZxxQurfGxJlJDblybejHB6RX6pmExPNe517hREw4= 421 - go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE= 422 - go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs= 423 - go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs= 424 - go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY= 425 - go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis= 426 - go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4= 427 - go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w= 428 - go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA= 457 + go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= 458 + go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= 459 + go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI= 460 + go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg= 461 + go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc= 462 + go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps= 463 + go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= 464 + go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= 429 465 go.opentelemetry.io/proto/otlp v1.6.0 h1:jQjP+AQyTf+Fe7OKj/MfkDrmK4MNVtw2NpXsf9fefDI= 430 466 go.opentelemetry.io/proto/otlp v1.6.0/go.mod h1:cicgGehlFuNdgZkcALOCh3VE6K/u2tAjzlRhDwmVpZc= 431 467 go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= ··· 448 484 golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 449 485 golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 450 486 golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw= 451 - golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= 452 - golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= 453 - golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= 454 - golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= 487 + golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= 488 + golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= 489 + golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= 490 + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= 491 + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= 455 492 golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 456 493 golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= 457 494 golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 458 495 golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 496 + golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 459 497 golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY= 460 498 golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 461 499 golang.org/x/mod v0.6.0/go.mod h1:4mET923SAdbXp2ki8ey+zGs1SLqsuM2Y0uvdZR/fUNI= 462 500 golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 501 + golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 463 502 golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 464 503 golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 465 504 golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= ··· 468 507 golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= 469 508 golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 470 509 golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 510 + golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= 471 511 golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= 472 512 golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 473 513 golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= ··· 477 517 golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= 478 518 golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE= 479 519 golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws= 480 - golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= 481 - golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= 520 + golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 521 + golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= 522 + golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= 523 + golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= 482 524 golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 483 525 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 484 526 golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= ··· 486 528 golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 487 529 golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 488 530 golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 489 - golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= 490 - golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 531 + golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= 532 + golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 491 533 golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 492 534 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 493 535 golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= ··· 499 541 golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 500 542 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 501 543 golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 544 + golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 502 545 golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 546 + golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 503 547 golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 504 548 golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 505 549 golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= ··· 507 551 golang.org/x/sys v0.0.0-20220422013727-9388b58f7150/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 508 552 golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 509 553 golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 554 + golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 510 555 golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 511 556 golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 512 557 golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 513 558 golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 559 + golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 514 560 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 515 - golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= 516 - golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 561 + golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 562 + golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 563 + golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= 564 + golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 517 565 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 518 566 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 519 567 golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 520 568 golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= 521 569 golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA= 522 570 golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ= 523 - golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= 524 - golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= 571 + golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 572 + golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= 573 + golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= 574 + golang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg= 575 + golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0= 525 576 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 526 577 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 527 578 golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= ··· 529 580 golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 530 581 golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 531 582 golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 532 - golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= 533 - golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= 534 - golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg= 535 - golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 583 + golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 584 + golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 585 + golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 586 + golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= 587 + golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= 588 + golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= 589 + golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= 536 590 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 537 591 golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 538 592 golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= ··· 544 598 golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 545 599 golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 546 600 golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 601 + golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= 547 602 golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E= 548 603 golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 549 604 golang.org/x/tools v0.2.0/go.mod h1:y4OqIKeOV/fWJetJ8bXPU1sEVniLMIyDAZWeHdV+NTA= 550 605 golang.org/x/tools v0.4.0/go.mod h1:UE5sM2OK9E/d67R0ANs2xJizIymRP5gJU295PvKXxjQ= 606 + golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 551 607 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 552 608 golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 553 609 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 554 610 golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 555 611 golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY= 556 612 golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= 557 - google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237 h1:Kog3KlB4xevJlAcbbbzPfRG0+X9fdoGM+UBRKVz6Wr0= 558 - google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237/go.mod h1:ezi0AVyMKDWy5xAncvjLWH7UcLBB5n7y2fQ8MzjJcto= 559 - google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237 h1:cJfm9zPbe1e873mHJzmQ1nwVEeRDU/T1wXDK2kUSU34= 560 - google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= 561 - google.golang.org/grpc v1.72.1 h1:HR03wO6eyZ7lknl75XlxABNVLLFc2PAb6mHlYh756mA= 562 - google.golang.org/grpc v1.72.1/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM= 613 + google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 h1:oWVWY3NzT7KJppx2UKhKmzPq4SRe0LdCijVRwvGeikY= 614 + google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822/go.mod h1:h3c4v36UTKzUiuaOKQ6gr3S+0hovBtUrXzTG/i3+XEc= 615 + google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 h1:fc6jSaCT0vBduLYZHYrBBNY4dsWuvgyff9noRNDdBeE= 616 + google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= 617 + google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok= 618 + google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc= 563 619 google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= 564 620 google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= 565 621 google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= ··· 577 633 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 578 634 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 579 635 gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= 636 + gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4= 580 637 gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= 581 638 gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= 582 639 gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= ··· 595 652 honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= 596 653 lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg= 597 654 lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo= 598 - tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250526154904-3906c5336421 h1:ZQNKE1HKWjfQBizxDb2XLhrJcgZqE0MLFIU1iggaF90= 599 - tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250526154904-3906c5336421/go.mod h1:Wad0H70uyyY4qZryU/1Ic+QZyw41YxN5QfzNAEBaXkQ= 655 + tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250724194903-28e660378cb1 h1:z1os1aRIqeo5e8d0Tx7hk+LH8OdZZeIOY0zw9VB/ZoU= 656 + tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250724194903-28e660378cb1/go.mod h1:+oQi9S6IIDll0nxLZVhuzOPX8WKLCYEnE6M5kUKupDg= 600 657 tangled.sh/oppi.li/go-gitdiff v0.8.2 h1:pASJJNWaFn6EmEIUNNjHZQ3stRu6BqTO2YyjKvTcxIc= 601 658 tangled.sh/oppi.li/go-gitdiff v0.8.2/go.mod h1:WWAk1Mc6EgWarCrPFO+xeYlujPu98VuLW3Tu+B/85AE=
+23 -8
guard/guard.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "errors" 5 6 "fmt" 7 + "io" 6 8 "log/slog" 7 9 "net/http" 8 10 "net/url" ··· 13 15 "github.com/bluesky-social/indigo/atproto/identity" 14 16 securejoin "github.com/cyphar/filepath-securejoin" 15 17 "github.com/urfave/cli/v3" 16 - "tangled.sh/tangled.sh/core/appview/idresolver" 18 + "tangled.sh/tangled.sh/core/idresolver" 17 19 "tangled.sh/tangled.sh/core/log" 18 20 ) 19 21 ··· 43 45 Usage: "internal API endpoint", 44 46 Value: "http://localhost:5444", 45 47 }, 48 + &cli.StringFlag{ 49 + Name: "motd-file", 50 + Usage: "path to message of the day file", 51 + Value: "/home/git/motd", 52 + }, 46 53 }, 47 54 } 48 55 } ··· 54 61 gitDir := cmd.String("git-dir") 55 62 logPath := cmd.String("log-path") 56 63 endpoint := cmd.String("internal-api") 64 + motdFile := cmd.String("motd-file") 57 65 58 66 logFile, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) 59 67 if err != nil { ··· 86 94 "client", clientIP) 87 95 88 96 if sshCommand == "" { 89 - l.Error("access denied: no interactive shells", "user", incomingUser) 90 - fmt.Fprintln(os.Stderr, "access denied: we don't serve interactive shells :)") 97 + l.Info("access denied: no interactive shells", "user", incomingUser) 98 + fmt.Fprintf(os.Stderr, "Hi @%s! You've successfully authenticated.\n", incomingUser) 91 99 os.Exit(-1) 92 100 } 93 101 ··· 149 157 "fullPath", fullPath, 150 158 "client", clientIP) 151 159 160 + var motdReader io.Reader 161 + if reader, err := os.Open(motdFile); err != nil { 162 + if !errors.Is(err, os.ErrNotExist) { 163 + l.Error("failed to read motd file", "error", err) 164 + } 165 + motdReader = strings.NewReader("Welcome to this knot!\n") 166 + } else { 167 + motdReader = reader 168 + } 152 169 if gitCommand == "git-upload-pack" { 153 - fmt.Fprintf(os.Stderr, "\x02%s\n", "Welcome to this knot!") 154 - } else { 155 - fmt.Fprintf(os.Stderr, "%s\n", "Welcome to this knot!") 170 + io.WriteString(os.Stderr, "\x02") 156 171 } 172 + io.Copy(os.Stderr, motdReader) 157 173 158 174 gitCmd := exec.Command(gitCommand, fullPath) 159 175 gitCmd.Stdout = os.Stdout 160 176 gitCmd.Stderr = os.Stderr 161 177 gitCmd.Stdin = os.Stdin 162 178 gitCmd.Env = append(os.Environ(), 163 - fmt.Sprintf("GIT_USER_DID=%s", identity.DID.String()), 164 - fmt.Sprintf("GIT_USER_HANDLE=%s", identity.Handle.String()), 179 + fmt.Sprintf("GIT_USER_DID=%s", incomingUser), 165 180 fmt.Sprintf("GIT_USER_PDS_ENDPOINT=%s", identity.PDSEndpoint()), 166 181 ) 167 182
+25 -1
hook/hook.go
··· 3 3 import ( 4 4 "bufio" 5 5 "context" 6 + "encoding/json" 6 7 "fmt" 7 8 "net/http" 8 9 "os" ··· 10 11 11 12 "github.com/urfave/cli/v3" 12 13 ) 14 + 15 + type HookResponse struct { 16 + Messages []string `json:"messages"` 17 + } 13 18 14 19 // The hook command is nested like so: 15 20 // ··· 36 41 Usage: "endpoint for the internal API", 37 42 Value: "http://localhost:5444", 38 43 }, 44 + &cli.StringSliceFlag{ 45 + Name: "push-option", 46 + Usage: "any push option from git", 47 + }, 39 48 }, 40 49 Commands: []*cli.Command{ 41 50 { ··· 52 61 userDid := cmd.String("user-did") 53 62 userHandle := cmd.String("user-handle") 54 63 endpoint := cmd.String("internal-api") 64 + pushOptions := cmd.StringSlice("push-option") 55 65 56 66 payloadReader := bufio.NewReader(os.Stdin) 57 67 payload, _ := payloadReader.ReadString('\n') ··· 63 73 return fmt.Errorf("failed to create request: %w", err) 64 74 } 65 75 66 - req.Header.Set("Content-Type", "text/plain") 76 + req.Header.Set("Content-Type", "text/plain; charset=utf-8") 67 77 req.Header.Set("X-Git-Dir", gitDir) 68 78 req.Header.Set("X-Git-User-Did", userDid) 69 79 req.Header.Set("X-Git-User-Handle", userHandle) 80 + if pushOptions != nil { 81 + for _, option := range pushOptions { 82 + req.Header.Add("X-Git-Push-Option", option) 83 + } 84 + } 70 85 71 86 resp, err := client.Do(req) 72 87 if err != nil { ··· 76 91 77 92 if resp.StatusCode != http.StatusOK { 78 93 return fmt.Errorf("unexpected status code: %d", resp.StatusCode) 94 + } 95 + 96 + var data HookResponse 97 + if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { 98 + return fmt.Errorf("failed to decode response: %w", err) 99 + } 100 + 101 + for _, message := range data.Messages { 102 + fmt.Println(message) 79 103 } 80 104 81 105 return nil
+6 -1
hook/setup.go
··· 133 133 134 134 hookContent := fmt.Sprintf(`#!/usr/bin/env bash 135 135 # AUTO GENERATED BY KNOT, DO NOT MODIFY 136 - %s hook -git-dir "$GIT_DIR" -user-did "$GIT_USER_DID" -user-handle "$GIT_USER_HANDLE" -internal-api "%s" post-recieve 136 + push_options=() 137 + for ((i=0; i<GIT_PUSH_OPTION_COUNT; i++)); do 138 + option_var="GIT_PUSH_OPTION_$i" 139 + push_options+=(-push-option "${!option_var}") 140 + done 141 + %s hook -git-dir "$GIT_DIR" -user-did "$GIT_USER_DID" -user-handle "$GIT_USER_HANDLE" -internal-api "%s" "${push_options[@]}" post-recieve 137 142 `, executablePath, config.internalApi) 138 143 139 144 return os.WriteFile(hookPath, []byte(hookContent), 0755)
+116
idresolver/resolver.go
··· 1 + package idresolver 2 + 3 + import ( 4 + "context" 5 + "net" 6 + "net/http" 7 + "sync" 8 + "time" 9 + 10 + "github.com/bluesky-social/indigo/atproto/identity" 11 + "github.com/bluesky-social/indigo/atproto/identity/redisdir" 12 + "github.com/bluesky-social/indigo/atproto/syntax" 13 + "github.com/carlmjohnson/versioninfo" 14 + ) 15 + 16 + type Resolver struct { 17 + directory identity.Directory 18 + } 19 + 20 + func BaseDirectory() identity.Directory { 21 + base := identity.BaseDirectory{ 22 + PLCURL: identity.DefaultPLCURL, 23 + HTTPClient: http.Client{ 24 + Timeout: time.Second * 10, 25 + Transport: &http.Transport{ 26 + // would want this around 100ms for services doing lots of handle resolution. Impacts PLC connections as well, but not too bad. 27 + IdleConnTimeout: time.Millisecond * 1000, 28 + MaxIdleConns: 100, 29 + }, 30 + }, 31 + Resolver: net.Resolver{ 32 + Dial: func(ctx context.Context, network, address string) (net.Conn, error) { 33 + d := net.Dialer{Timeout: time.Second * 3} 34 + return d.DialContext(ctx, network, address) 35 + }, 36 + }, 37 + TryAuthoritativeDNS: true, 38 + // primary Bluesky PDS instance only supports HTTP resolution method 39 + SkipDNSDomainSuffixes: []string{".bsky.social"}, 40 + UserAgent: "indigo-identity/" + versioninfo.Short(), 41 + } 42 + return &base 43 + } 44 + 45 + func RedisDirectory(url string) (identity.Directory, error) { 46 + hitTTL := time.Hour * 24 47 + errTTL := time.Second * 30 48 + invalidHandleTTL := time.Minute * 5 49 + return redisdir.NewRedisDirectory(BaseDirectory(), url, hitTTL, errTTL, invalidHandleTTL, 10000) 50 + } 51 + 52 + func DefaultResolver() *Resolver { 53 + return &Resolver{ 54 + directory: identity.DefaultDirectory(), 55 + } 56 + } 57 + 58 + func RedisResolver(redisUrl string) (*Resolver, error) { 59 + directory, err := RedisDirectory(redisUrl) 60 + if err != nil { 61 + return nil, err 62 + } 63 + return &Resolver{ 64 + directory: directory, 65 + }, nil 66 + } 67 + 68 + func (r *Resolver) ResolveIdent(ctx context.Context, arg string) (*identity.Identity, error) { 69 + id, err := syntax.ParseAtIdentifier(arg) 70 + if err != nil { 71 + return nil, err 72 + } 73 + 74 + return r.directory.Lookup(ctx, *id) 75 + } 76 + 77 + func (r *Resolver) ResolveIdents(ctx context.Context, idents []string) []*identity.Identity { 78 + results := make([]*identity.Identity, len(idents)) 79 + var wg sync.WaitGroup 80 + 81 + done := make(chan struct{}) 82 + defer close(done) 83 + 84 + for idx, ident := range idents { 85 + wg.Add(1) 86 + go func(index int, id string) { 87 + defer wg.Done() 88 + 89 + select { 90 + case <-ctx.Done(): 91 + results[index] = nil 92 + case <-done: 93 + results[index] = nil 94 + default: 95 + identity, _ := r.ResolveIdent(ctx, id) 96 + results[index] = identity 97 + } 98 + }(idx, ident) 99 + } 100 + 101 + wg.Wait() 102 + return results 103 + } 104 + 105 + func (r *Resolver) InvalidateIdent(ctx context.Context, arg string) error { 106 + id, err := syntax.ParseAtIdentifier(arg) 107 + if err != nil { 108 + return err 109 + } 110 + 111 + return r.directory.Purge(ctx, *id) 112 + } 113 + 114 + func (r *Resolver) Directory() identity.Directory { 115 + return r.directory 116 + }
+109 -33
input.css
··· 13 13 @font-face { 14 14 font-family: "InterVariable"; 15 15 src: url("/static/fonts/InterVariable-Italic.woff2") format("woff2"); 16 - font-weight: 400; 16 + font-weight: normal; 17 17 font-style: italic; 18 18 font-display: swap; 19 19 } 20 20 21 21 @font-face { 22 22 font-family: "InterVariable"; 23 - src: url("/static/fonts/InterVariable.woff2") format("woff2"); 24 - font-weight: 600; 23 + src: url("/static/fonts/InterDisplay-Bold.woff2") format("woff2"); 24 + font-weight: bold; 25 25 font-style: normal; 26 26 font-display: swap; 27 27 } 28 28 29 29 @font-face { 30 + font-family: "InterVariable"; 31 + src: url("/static/fonts/InterDisplay-BoldItalic.woff2") format("woff2"); 32 + font-weight: bold; 33 + font-style: italic; 34 + font-display: swap; 35 + } 36 + 37 + @font-face { 30 38 font-family: "IBMPlexMono"; 31 39 src: url("/static/fonts/IBMPlexMono-Regular.woff2") format("woff2"); 32 40 font-weight: normal; 41 + font-style: normal; 42 + font-display: swap; 43 + } 44 + 45 + @font-face { 46 + font-family: "IBMPlexMono"; 47 + src: url("/static/fonts/IBMPlexMono-Italic.woff2") format("woff2"); 48 + font-weight: normal; 49 + font-style: italic; 50 + font-display: swap; 51 + } 52 + 53 + @font-face { 54 + font-family: "IBMPlexMono"; 55 + src: url("/static/fonts/IBMPlexMono-Bold.woff2") format("woff2"); 56 + font-weight: bold; 57 + font-style: normal; 58 + font-display: swap; 59 + } 60 + 61 + @font-face { 62 + font-family: "IBMPlexMono"; 63 + src: url("/static/fonts/IBMPlexMono-BoldItalic.woff2") format("woff2"); 64 + font-weight: bold; 33 65 font-style: italic; 34 66 font-display: swap; 35 67 } ··· 46 78 @supports (font-variation-settings: normal) { 47 79 html { 48 80 font-feature-settings: 49 - "ss01" 1, 50 81 "kern" 1, 51 82 "liga" 1, 52 83 "cv05" 1, ··· 59 90 } 60 91 61 92 label { 62 - @apply block mb-2 text-gray-900 text-sm font-bold py-2 uppercase dark:text-gray-100; 93 + @apply block text-gray-900 text-sm font-bold py-2 uppercase dark:text-gray-100; 63 94 } 64 95 input { 65 96 @apply border border-gray-400 block rounded bg-gray-50 focus:ring-black p-3 dark:bg-gray-800 dark:border-gray-600 dark:text-white dark:focus:ring-gray-400; ··· 69 100 } 70 101 details summary::-webkit-details-marker { 71 102 display: none; 103 + } 104 + 105 + code { 106 + @apply font-mono rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white; 72 107 } 73 108 } 74 109 75 110 @layer components { 76 111 .btn { 77 - @apply relative z-10 inline-flex min-h-[30px] cursor-pointer items-center 78 - justify-center bg-transparent px-2 pb-[0.2rem] text-base 79 - text-gray-900 before:absolute before:inset-0 before:-z-10 80 - before:block before:rounded before:border before:border-gray-200 81 - before:bg-white before:drop-shadow-sm 82 - before:content-[''] hover:before:border-gray-300 83 - hover:before:bg-gray-50 84 - hover:before:shadow-[0_2px_2px_0_rgba(20,20,96,0.1),inset_0_-2px_0_0_#f5f5f5] 85 - focus:outline-none focus-visible:before:outline 86 - focus-visible:before:outline-4 focus-visible:before:outline-gray-500 87 - active:before:shadow-[inset_0_2px_2px_0_rgba(20,20,96,0.1)] 88 - disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:before:border-gray-200 89 - disabled:hover:before:bg-white disabled:hover:before:shadow-none 90 - dark:text-gray-100 dark:before:bg-gray-800 dark:before:border-gray-700 91 - dark:hover:before:border-gray-600 dark:hover:before:bg-gray-700 92 - dark:hover:before:shadow-[0_2px_2px_0_rgba(0,0,0,0.2),inset_0_-2px_0_0_#2d3748] 93 - dark:focus-visible:before:outline-gray-400 94 - dark:active:before:shadow-[inset_0_2px_2px_0_rgba(0,0,0,0.3)] 95 - dark:disabled:hover:before:bg-gray-800 dark:disabled:hover:before:border-gray-700; 112 + @apply relative z-10 inline-flex min-h-[30px] cursor-pointer items-center justify-center 113 + bg-transparent px-2 pb-[0.2rem] text-sm text-gray-900 114 + before:absolute before:inset-0 before:-z-10 before:block before:rounded 115 + before:border before:border-gray-200 before:bg-white 116 + before:shadow-[inset_0_-2px_0_0_rgba(0,0,0,0.1),0_1px_0_0_rgba(0,0,0,0.04)] 117 + before:content-[''] before:transition-all before:duration-150 before:ease-in-out 118 + hover:before:shadow-[inset_0_-2px_0_0_rgba(0,0,0,0.15),0_2px_1px_0_rgba(0,0,0,0.06)] 119 + hover:before:bg-gray-50 120 + dark:hover:before:bg-gray-700 121 + active:before:shadow-[inset_0_2px_2px_0_rgba(0,0,0,0.1)] 122 + focus:outline-none focus-visible:before:outline focus-visible:before:outline-2 focus-visible:before:outline-gray-400 123 + disabled:cursor-not-allowed disabled:opacity-50 124 + dark:text-gray-100 dark:before:bg-gray-800 dark:before:border-gray-700; 125 + } 126 + 127 + .btn-create { 128 + @apply btn text-white 129 + before:bg-green-600 hover:before:bg-green-700 130 + dark:before:bg-green-700 dark:hover:before:bg-green-800 131 + before:border before:border-green-700 hover:before:border-green-800 132 + focus-visible:before:outline-green-500 133 + disabled:before:bg-green-400 dark:disabled:before:bg-green-600; 134 + } 135 + 136 + .prose hr { 137 + @apply my-2; 138 + } 139 + 140 + .prose li:has(input) { 141 + @apply list-none; 142 + } 143 + 144 + .prose ul:has(input) { 145 + @apply pl-2; 146 + } 147 + 148 + .prose .heading .anchor { 149 + @apply no-underline mx-2 opacity-0; 150 + } 151 + 152 + .prose .heading:hover .anchor { 153 + @apply opacity-70; 154 + } 155 + 156 + .prose .heading .anchor:hover { 157 + @apply opacity-70; 158 + } 159 + 160 + .prose a.footnote-backref { 161 + @apply no-underline; 162 + } 163 + 164 + .prose li { 165 + @apply my-0 py-0; 166 + } 167 + 168 + .prose ul, .prose ol { 169 + @apply my-1 py-0; 96 170 } 97 171 98 172 .prose img { 99 173 display: inline; 100 - margin-left: 0; 101 - margin-right: 0; 174 + margin: 0; 102 175 vertical-align: middle; 103 176 } 177 + 178 + .prose input { 179 + @apply inline-block my-0 mb-1 mx-1; 180 + } 181 + 182 + .prose input[type="checkbox"] { 183 + @apply disabled:accent-blue-500 checked:accent-blue-500 disabled:checked:accent-blue-500; 184 + } 104 185 } 105 186 @layer utilities { 106 187 .error { ··· 120 201 /* PreWrapper */ 121 202 .chroma { 122 203 color: #4c4f69; 123 - background-color: #eff1f5; 124 204 } 125 205 /* Error */ 126 206 .chroma .err { ··· 148 228 } 149 229 /* LineHighlight */ 150 230 .chroma .hl { 151 - background-color: #bcc0cc; 231 + @apply bg-amber-400/30 dark:bg-amber-500/20; 152 232 } 233 + 153 234 /* LineNumbersTable */ 154 235 .chroma .lnt { 155 236 white-space: pre; ··· 457 538 /* PreWrapper */ 458 539 .chroma { 459 540 color: #cad3f5; 460 - background-color: #24273a; 461 541 } 462 542 /* Error */ 463 543 .chroma .err { ··· 785 865 text-decoration: underline; 786 866 } 787 867 } 788 - 789 - .chroma .line:has(.ln:target) { 790 - @apply bg-amber-400/30 dark:bg-amber-500/20; 791 - }
+19 -4
jetstream/jetstream.go
··· 52 52 j.mu.Unlock() 53 53 } 54 54 55 + func (j *JetstreamClient) RemoveDid(did string) { 56 + if did == "" { 57 + return 58 + } 59 + 60 + if j.logDids { 61 + j.l.Info("removing did from in-memory filter", "did", did) 62 + } 63 + j.mu.Lock() 64 + delete(j.wantedDids, did) 65 + j.mu.Unlock() 66 + } 67 + 55 68 type processor func(context.Context, *models.Event) error 56 69 57 70 func (j *JetstreamClient) withDidFilter(processFunc processor) processor { 58 - // empty filter => all dids allowed 59 - if len(j.wantedDids) == 0 { 60 - return processFunc 61 - } 62 71 // since this closure references j.WantedDids; it should auto-update 63 72 // existing instances of the closure when j.WantedDids is mutated 64 73 return func(ctx context.Context, evt *models.Event) error { 74 + 75 + // empty filter => all dids allowed 76 + if len(j.wantedDids) == 0 { 77 + return processFunc(ctx, evt) 78 + } 79 + 65 80 if _, ok := j.wantedDids[evt.Did]; ok { 66 81 return processFunc(ctx, evt) 67 82 } else {
-336
knotclient/signer.go
··· 1 - package knotclient 2 - 3 - import ( 4 - "bytes" 5 - "crypto/hmac" 6 - "crypto/sha256" 7 - "encoding/hex" 8 - "encoding/json" 9 - "fmt" 10 - "io" 11 - "log" 12 - "net/http" 13 - "net/url" 14 - "time" 15 - 16 - "tangled.sh/tangled.sh/core/types" 17 - ) 18 - 19 - type SignerTransport struct { 20 - Secret string 21 - } 22 - 23 - func (s SignerTransport) RoundTrip(req *http.Request) (*http.Response, error) { 24 - timestamp := time.Now().Format(time.RFC3339) 25 - mac := hmac.New(sha256.New, []byte(s.Secret)) 26 - message := req.Method + req.URL.Path + timestamp 27 - mac.Write([]byte(message)) 28 - signature := hex.EncodeToString(mac.Sum(nil)) 29 - req.Header.Set("X-Signature", signature) 30 - req.Header.Set("X-Timestamp", timestamp) 31 - return http.DefaultTransport.RoundTrip(req) 32 - } 33 - 34 - type SignedClient struct { 35 - Secret string 36 - Url *url.URL 37 - client *http.Client 38 - } 39 - 40 - func NewSignedClient(domain, secret string, dev bool) (*SignedClient, error) { 41 - client := &http.Client{ 42 - Timeout: 5 * time.Second, 43 - Transport: SignerTransport{ 44 - Secret: secret, 45 - }, 46 - } 47 - 48 - scheme := "https" 49 - if dev { 50 - scheme = "http" 51 - } 52 - url, err := url.Parse(fmt.Sprintf("%s://%s", scheme, domain)) 53 - if err != nil { 54 - return nil, err 55 - } 56 - 57 - signedClient := &SignedClient{ 58 - Secret: secret, 59 - client: client, 60 - Url: url, 61 - } 62 - 63 - return signedClient, nil 64 - } 65 - 66 - func (s *SignedClient) newRequest(method, endpoint string, body []byte) (*http.Request, error) { 67 - return http.NewRequest(method, s.Url.JoinPath(endpoint).String(), bytes.NewReader(body)) 68 - } 69 - 70 - func (s *SignedClient) Init(did string) (*http.Response, error) { 71 - const ( 72 - Method = "POST" 73 - Endpoint = "/init" 74 - ) 75 - 76 - body, _ := json.Marshal(map[string]any{ 77 - "did": did, 78 - }) 79 - 80 - req, err := s.newRequest(Method, Endpoint, body) 81 - if err != nil { 82 - return nil, err 83 - } 84 - 85 - return s.client.Do(req) 86 - } 87 - 88 - func (s *SignedClient) NewRepo(did, repoName, defaultBranch string) (*http.Response, error) { 89 - const ( 90 - Method = "PUT" 91 - Endpoint = "/repo/new" 92 - ) 93 - 94 - body, _ := json.Marshal(map[string]any{ 95 - "did": did, 96 - "name": repoName, 97 - "default_branch": defaultBranch, 98 - }) 99 - 100 - req, err := s.newRequest(Method, Endpoint, body) 101 - if err != nil { 102 - return nil, err 103 - } 104 - 105 - return s.client.Do(req) 106 - } 107 - 108 - func (s *SignedClient) RepoLanguages(ownerDid, repoName, ref string) (*types.RepoLanguageResponse, error) { 109 - const ( 110 - Method = "GET" 111 - ) 112 - endpoint := fmt.Sprintf("/%s/%s/languages/%s", ownerDid, repoName, url.PathEscape(ref)) 113 - 114 - req, err := s.newRequest(Method, endpoint, nil) 115 - if err != nil { 116 - return nil, err 117 - } 118 - 119 - resp, err := s.client.Do(req) 120 - if err != nil { 121 - return nil, err 122 - } 123 - 124 - var result types.RepoLanguageResponse 125 - if resp.StatusCode != http.StatusOK { 126 - log.Println("failed to calculate languages", resp.Status) 127 - return &types.RepoLanguageResponse{}, nil 128 - } 129 - 130 - body, err := io.ReadAll(resp.Body) 131 - if err != nil { 132 - return nil, err 133 - } 134 - 135 - err = json.Unmarshal(body, &result) 136 - if err != nil { 137 - return nil, err 138 - } 139 - 140 - return &result, nil 141 - } 142 - 143 - func (s *SignedClient) RepoForkAheadBehind(ownerDid, source, name, branch, hiddenRef string) (*http.Response, error) { 144 - const ( 145 - Method = "GET" 146 - ) 147 - endpoint := fmt.Sprintf("/repo/fork/sync/%s", url.PathEscape(branch)) 148 - 149 - body, _ := json.Marshal(map[string]any{ 150 - "did": ownerDid, 151 - "source": source, 152 - "name": name, 153 - "hiddenref": hiddenRef, 154 - }) 155 - 156 - req, err := s.newRequest(Method, endpoint, body) 157 - if err != nil { 158 - return nil, err 159 - } 160 - 161 - return s.client.Do(req) 162 - } 163 - 164 - func (s *SignedClient) SyncRepoFork(ownerDid, source, name, branch string) (*http.Response, error) { 165 - const ( 166 - Method = "POST" 167 - ) 168 - endpoint := fmt.Sprintf("/repo/fork/sync/%s", url.PathEscape(branch)) 169 - 170 - body, _ := json.Marshal(map[string]any{ 171 - "did": ownerDid, 172 - "source": source, 173 - "name": name, 174 - }) 175 - 176 - req, err := s.newRequest(Method, endpoint, body) 177 - if err != nil { 178 - return nil, err 179 - } 180 - 181 - return s.client.Do(req) 182 - } 183 - 184 - func (s *SignedClient) ForkRepo(ownerDid, source, name string) (*http.Response, error) { 185 - const ( 186 - Method = "POST" 187 - Endpoint = "/repo/fork" 188 - ) 189 - 190 - body, _ := json.Marshal(map[string]any{ 191 - "did": ownerDid, 192 - "source": source, 193 - "name": name, 194 - }) 195 - 196 - req, err := s.newRequest(Method, Endpoint, body) 197 - if err != nil { 198 - return nil, err 199 - } 200 - 201 - return s.client.Do(req) 202 - } 203 - 204 - func (s *SignedClient) RemoveRepo(did, repoName string) (*http.Response, error) { 205 - const ( 206 - Method = "DELETE" 207 - Endpoint = "/repo" 208 - ) 209 - 210 - body, _ := json.Marshal(map[string]any{ 211 - "did": did, 212 - "name": repoName, 213 - }) 214 - 215 - req, err := s.newRequest(Method, Endpoint, body) 216 - if err != nil { 217 - return nil, err 218 - } 219 - 220 - return s.client.Do(req) 221 - } 222 - 223 - func (s *SignedClient) AddMember(did string) (*http.Response, error) { 224 - const ( 225 - Method = "PUT" 226 - Endpoint = "/member/add" 227 - ) 228 - 229 - body, _ := json.Marshal(map[string]any{ 230 - "did": did, 231 - }) 232 - 233 - req, err := s.newRequest(Method, Endpoint, body) 234 - if err != nil { 235 - return nil, err 236 - } 237 - 238 - return s.client.Do(req) 239 - } 240 - 241 - func (s *SignedClient) SetDefaultBranch(ownerDid, repoName, branch string) (*http.Response, error) { 242 - const ( 243 - Method = "PUT" 244 - ) 245 - endpoint := fmt.Sprintf("/%s/%s/branches/default", ownerDid, repoName) 246 - 247 - body, _ := json.Marshal(map[string]any{ 248 - "branch": branch, 249 - }) 250 - 251 - req, err := s.newRequest(Method, endpoint, body) 252 - if err != nil { 253 - return nil, err 254 - } 255 - 256 - return s.client.Do(req) 257 - } 258 - 259 - func (s *SignedClient) AddCollaborator(ownerDid, repoName, memberDid string) (*http.Response, error) { 260 - const ( 261 - Method = "POST" 262 - ) 263 - endpoint := fmt.Sprintf("/%s/%s/collaborator/add", ownerDid, repoName) 264 - 265 - body, _ := json.Marshal(map[string]any{ 266 - "did": memberDid, 267 - }) 268 - 269 - req, err := s.newRequest(Method, endpoint, body) 270 - if err != nil { 271 - return nil, err 272 - } 273 - 274 - return s.client.Do(req) 275 - } 276 - 277 - func (s *SignedClient) Merge( 278 - patch []byte, 279 - ownerDid, targetRepo, branch, commitMessage, commitBody, authorName, authorEmail string, 280 - ) (*http.Response, error) { 281 - const ( 282 - Method = "POST" 283 - ) 284 - endpoint := fmt.Sprintf("/%s/%s/merge", ownerDid, targetRepo) 285 - 286 - mr := types.MergeRequest{ 287 - Branch: branch, 288 - CommitMessage: commitMessage, 289 - CommitBody: commitBody, 290 - AuthorName: authorName, 291 - AuthorEmail: authorEmail, 292 - Patch: string(patch), 293 - } 294 - 295 - body, _ := json.Marshal(mr) 296 - 297 - req, err := s.newRequest(Method, endpoint, body) 298 - if err != nil { 299 - return nil, err 300 - } 301 - 302 - return s.client.Do(req) 303 - } 304 - 305 - func (s *SignedClient) MergeCheck(patch []byte, ownerDid, targetRepo, branch string) (*http.Response, error) { 306 - const ( 307 - Method = "POST" 308 - ) 309 - endpoint := fmt.Sprintf("/%s/%s/merge/check", ownerDid, targetRepo) 310 - 311 - body, _ := json.Marshal(map[string]any{ 312 - "patch": string(patch), 313 - "branch": branch, 314 - }) 315 - 316 - req, err := s.newRequest(Method, endpoint, body) 317 - if err != nil { 318 - return nil, err 319 - } 320 - 321 - return s.client.Do(req) 322 - } 323 - 324 - func (s *SignedClient) NewHiddenRef(ownerDid, targetRepo, forkBranch, remoteBranch string) (*http.Response, error) { 325 - const ( 326 - Method = "POST" 327 - ) 328 - endpoint := fmt.Sprintf("/%s/%s/hidden-ref/%s/%s", ownerDid, targetRepo, url.PathEscape(forkBranch), url.PathEscape(remoteBranch)) 329 - 330 - req, err := s.newRequest(Method, endpoint, nil) 331 - if err != nil { 332 - return nil, err 333 - } 334 - 335 - return s.client.Do(req) 336 - }
-250
knotclient/unsigned.go
··· 1 - package knotclient 2 - 3 - import ( 4 - "bytes" 5 - "encoding/json" 6 - "fmt" 7 - "io" 8 - "log" 9 - "net/http" 10 - "net/url" 11 - "strconv" 12 - "time" 13 - 14 - "tangled.sh/tangled.sh/core/types" 15 - ) 16 - 17 - type UnsignedClient struct { 18 - Url *url.URL 19 - client *http.Client 20 - } 21 - 22 - func NewUnsignedClient(domain string, dev bool) (*UnsignedClient, error) { 23 - client := &http.Client{ 24 - Timeout: 5 * time.Second, 25 - } 26 - 27 - scheme := "https" 28 - if dev { 29 - scheme = "http" 30 - } 31 - url, err := url.Parse(fmt.Sprintf("%s://%s", scheme, domain)) 32 - if err != nil { 33 - return nil, err 34 - } 35 - 36 - unsignedClient := &UnsignedClient{ 37 - client: client, 38 - Url: url, 39 - } 40 - 41 - return unsignedClient, nil 42 - } 43 - 44 - func (us *UnsignedClient) newRequest(method, endpoint string, query url.Values, body []byte) (*http.Request, error) { 45 - reqUrl := us.Url.JoinPath(endpoint) 46 - 47 - // add query parameters 48 - if query != nil { 49 - reqUrl.RawQuery = query.Encode() 50 - } 51 - 52 - return http.NewRequest(method, reqUrl.String(), bytes.NewReader(body)) 53 - } 54 - 55 - func do[T any](us *UnsignedClient, req *http.Request) (*T, error) { 56 - resp, err := us.client.Do(req) 57 - if err != nil { 58 - return nil, err 59 - } 60 - defer resp.Body.Close() 61 - 62 - body, err := io.ReadAll(resp.Body) 63 - if err != nil { 64 - log.Printf("Error reading response body: %v", err) 65 - return nil, err 66 - } 67 - 68 - var result T 69 - err = json.Unmarshal(body, &result) 70 - if err != nil { 71 - log.Printf("Error unmarshalling response body: %v", err) 72 - return nil, err 73 - } 74 - 75 - return &result, nil 76 - } 77 - 78 - func (us *UnsignedClient) Index(ownerDid, repoName, ref string) (*types.RepoIndexResponse, error) { 79 - const ( 80 - Method = "GET" 81 - ) 82 - 83 - endpoint := fmt.Sprintf("/%s/%s/tree/%s", ownerDid, repoName, ref) 84 - if ref == "" { 85 - endpoint = fmt.Sprintf("/%s/%s", ownerDid, repoName) 86 - } 87 - 88 - req, err := us.newRequest(Method, endpoint, nil, nil) 89 - if err != nil { 90 - return nil, err 91 - } 92 - 93 - return do[types.RepoIndexResponse](us, req) 94 - } 95 - 96 - func (us *UnsignedClient) Log(ownerDid, repoName, ref string, page int) (*types.RepoLogResponse, error) { 97 - const ( 98 - Method = "GET" 99 - ) 100 - 101 - endpoint := fmt.Sprintf("/%s/%s/log/%s", ownerDid, repoName, url.PathEscape(ref)) 102 - 103 - query := url.Values{} 104 - query.Add("page", strconv.Itoa(page)) 105 - query.Add("per_page", strconv.Itoa(60)) 106 - 107 - req, err := us.newRequest(Method, endpoint, query, nil) 108 - if err != nil { 109 - return nil, err 110 - } 111 - 112 - return do[types.RepoLogResponse](us, req) 113 - } 114 - 115 - func (us *UnsignedClient) Branches(ownerDid, repoName string) (*types.RepoBranchesResponse, error) { 116 - const ( 117 - Method = "GET" 118 - ) 119 - 120 - endpoint := fmt.Sprintf("/%s/%s/branches", ownerDid, repoName) 121 - 122 - req, err := us.newRequest(Method, endpoint, nil, nil) 123 - if err != nil { 124 - return nil, err 125 - } 126 - 127 - return do[types.RepoBranchesResponse](us, req) 128 - } 129 - 130 - func (us *UnsignedClient) Tags(ownerDid, repoName string) (*types.RepoTagsResponse, error) { 131 - const ( 132 - Method = "GET" 133 - ) 134 - 135 - endpoint := fmt.Sprintf("/%s/%s/tags", ownerDid, repoName) 136 - 137 - req, err := us.newRequest(Method, endpoint, nil, nil) 138 - if err != nil { 139 - return nil, err 140 - } 141 - 142 - return do[types.RepoTagsResponse](us, req) 143 - } 144 - 145 - func (us *UnsignedClient) Branch(ownerDid, repoName, branch string) (*types.RepoBranchResponse, error) { 146 - const ( 147 - Method = "GET" 148 - ) 149 - 150 - endpoint := fmt.Sprintf("/%s/%s/branches/%s", ownerDid, repoName, url.PathEscape(branch)) 151 - 152 - req, err := us.newRequest(Method, endpoint, nil, nil) 153 - if err != nil { 154 - return nil, err 155 - } 156 - 157 - return do[types.RepoBranchResponse](us, req) 158 - } 159 - 160 - func (us *UnsignedClient) DefaultBranch(ownerDid, repoName string) (*types.RepoDefaultBranchResponse, error) { 161 - const ( 162 - Method = "GET" 163 - ) 164 - 165 - endpoint := fmt.Sprintf("/%s/%s/branches/default", ownerDid, repoName) 166 - 167 - req, err := us.newRequest(Method, endpoint, nil, nil) 168 - if err != nil { 169 - return nil, err 170 - } 171 - 172 - resp, err := us.client.Do(req) 173 - if err != nil { 174 - return nil, err 175 - } 176 - defer resp.Body.Close() 177 - 178 - var defaultBranch types.RepoDefaultBranchResponse 179 - if err := json.NewDecoder(resp.Body).Decode(&defaultBranch); err != nil { 180 - return nil, err 181 - } 182 - 183 - return &defaultBranch, nil 184 - } 185 - 186 - func (us *UnsignedClient) Capabilities() (*types.Capabilities, error) { 187 - const ( 188 - Method = "GET" 189 - Endpoint = "/capabilities" 190 - ) 191 - 192 - req, err := us.newRequest(Method, Endpoint, nil, nil) 193 - if err != nil { 194 - return nil, err 195 - } 196 - 197 - resp, err := us.client.Do(req) 198 - if err != nil { 199 - return nil, err 200 - } 201 - defer resp.Body.Close() 202 - 203 - var capabilities types.Capabilities 204 - if err := json.NewDecoder(resp.Body).Decode(&capabilities); err != nil { 205 - return nil, err 206 - } 207 - 208 - return &capabilities, nil 209 - } 210 - 211 - func (us *UnsignedClient) Compare(ownerDid, repoName, rev1, rev2 string) (*types.RepoFormatPatchResponse, error) { 212 - const ( 213 - Method = "GET" 214 - ) 215 - 216 - endpoint := fmt.Sprintf("/%s/%s/compare/%s/%s", ownerDid, repoName, url.PathEscape(rev1), url.PathEscape(rev2)) 217 - 218 - req, err := us.newRequest(Method, endpoint, nil, nil) 219 - if err != nil { 220 - return nil, fmt.Errorf("Failed to create request.") 221 - } 222 - 223 - compareResp, err := us.client.Do(req) 224 - if err != nil { 225 - return nil, fmt.Errorf("Failed to create request.") 226 - } 227 - defer compareResp.Body.Close() 228 - 229 - switch compareResp.StatusCode { 230 - case 404: 231 - case 400: 232 - return nil, fmt.Errorf("Branch comparisons not supported on this knot.") 233 - } 234 - 235 - respBody, err := io.ReadAll(compareResp.Body) 236 - if err != nil { 237 - log.Println("failed to compare across branches") 238 - return nil, fmt.Errorf("Failed to compare branches.") 239 - } 240 - defer compareResp.Body.Close() 241 - 242 - var formatPatchResponse types.RepoFormatPatchResponse 243 - err = json.Unmarshal(respBody, &formatPatchResponse) 244 - if err != nil { 245 - log.Println("failed to unmarshal format-patch response", err) 246 - return nil, fmt.Errorf("failed to compare branches.") 247 - } 248 - 249 - return &formatPatchResponse, nil 250 - }
+14 -1
knotserver/config/config.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "fmt" 5 6 7 + "github.com/bluesky-social/indigo/atproto/syntax" 6 8 "github.com/sethvargo/go-envconfig" 7 9 ) 8 10 ··· 15 17 type Server struct { 16 18 ListenAddr string `env:"LISTEN_ADDR, default=0.0.0.0:5555"` 17 19 InternalListenAddr string `env:"INTERNAL_LISTEN_ADDR, default=127.0.0.1:5444"` 18 - Secret string `env:"SECRET, required"` 19 20 DBPath string `env:"DB_PATH, default=knotserver.db"` 20 21 Hostname string `env:"HOSTNAME, required"` 21 22 JetstreamEndpoint string `env:"JETSTREAM_ENDPOINT, default=wss://jetstream1.us-west.bsky.network/subscribe"` 23 + Owner string `env:"OWNER, required"` 22 24 LogDids bool `env:"LOG_DIDS, default=true"` 23 25 24 26 // This disables signature verification so use with caution. 25 27 Dev bool `env:"DEV, default=false"` 26 28 } 27 29 30 + type Git struct { 31 + // user name & email used as committer 32 + UserName string `env:"USER_NAME, default=Tangled"` 33 + UserEmail string `env:"USER_EMAIL, default=noreply@tangled.sh"` 34 + } 35 + 36 + func (s Server) Did() syntax.DID { 37 + return syntax.DID(fmt.Sprintf("did:web:%s", s.Hostname)) 38 + } 39 + 28 40 type Config struct { 29 41 Repo Repo `env:",prefix=KNOT_REPO_"` 30 42 Server Server `env:",prefix=KNOT_SERVER_"` 43 + Git Git `env:",prefix=KNOT_GIT_"` 31 44 AppViewEndpoint string `env:"APPVIEW_ENDPOINT, default=https://tangled.sh"` 32 45 } 33 46
+14 -10
knotserver/db/init.go
··· 2 2 3 3 import ( 4 4 "database/sql" 5 + "strings" 5 6 6 7 _ "github.com/mattn/go-sqlite3" 7 8 ) ··· 11 12 } 12 13 13 14 func Setup(dbPath string) (*DB, error) { 14 - db, err := sql.Open("sqlite3", dbPath) 15 + // https://github.com/mattn/go-sqlite3#connection-string 16 + opts := []string{ 17 + "_foreign_keys=1", 18 + "_journal_mode=WAL", 19 + "_synchronous=NORMAL", 20 + "_auto_vacuum=incremental", 21 + } 22 + 23 + db, err := sql.Open("sqlite3", dbPath+"?"+strings.Join(opts, "&")) 15 24 if err != nil { 16 25 return nil, err 17 26 } 18 27 19 - _, err = db.Exec(` 20 - pragma journal_mode = WAL; 21 - pragma synchronous = normal; 22 - pragma foreign_keys = on; 23 - pragma temp_store = memory; 24 - pragma mmap_size = 30000000000; 25 - pragma page_size = 32768; 26 - pragma auto_vacuum = incremental; 27 - pragma busy_timeout = 5000; 28 + // NOTE: If any other migration is added here, you MUST 29 + // copy the pattern in appview: use a single sql.Conn 30 + // for every migration. 28 31 32 + _, err = db.Exec(` 29 33 create table if not exists known_dids ( 30 34 did text primary key 31 35 );
+40
knotserver/db/pubkeys.go
··· 1 1 package db 2 2 3 3 import ( 4 + "strconv" 4 5 "time" 5 6 6 7 "tangled.sh/tangled.sh/core/api/tangled" ··· 99 100 100 101 return keys, nil 101 102 } 103 + 104 + func (d *DB) GetPublicKeysPaginated(limit int, cursor string) ([]PublicKey, string, error) { 105 + var keys []PublicKey 106 + 107 + offset := 0 108 + if cursor != "" { 109 + if o, err := strconv.Atoi(cursor); err == nil && o >= 0 { 110 + offset = o 111 + } 112 + } 113 + 114 + query := `select key, did, created from public_keys order by created desc limit ? offset ?` 115 + rows, err := d.db.Query(query, limit+1, offset) // +1 to check if there are more results 116 + if err != nil { 117 + return nil, "", err 118 + } 119 + defer rows.Close() 120 + 121 + for rows.Next() { 122 + var publicKey PublicKey 123 + if err := rows.Scan(&publicKey.Key, &publicKey.Did, &publicKey.CreatedAt); err != nil { 124 + return nil, "", err 125 + } 126 + keys = append(keys, publicKey) 127 + } 128 + 129 + if err := rows.Err(); err != nil { 130 + return nil, "", err 131 + } 132 + 133 + // check if there are more results for pagination 134 + var nextCursor string 135 + if len(keys) > limit { 136 + keys = keys[:limit] // remove the extra item 137 + nextCursor = strconv.Itoa(offset + limit) 138 + } 139 + 140 + return keys, nextCursor, nil 141 + }
+2 -2
knotserver/events.go
··· 15 15 WriteBufferSize: 1024, 16 16 } 17 17 18 - func (h *Handle) Events(w http.ResponseWriter, r *http.Request) { 18 + func (h *Knot) Events(w http.ResponseWriter, r *http.Request) { 19 19 l := h.l.With("handler", "OpLog") 20 20 l.Debug("received new connection") 21 21 ··· 83 83 } 84 84 } 85 85 86 - func (h *Handle) streamOps(conn *websocket.Conn, cursor *int64) error { 86 + func (h *Knot) streamOps(conn *websocket.Conn, cursor *int64) error { 87 87 events, err := h.db.GetEvents(*cursor) 88 88 if err != nil { 89 89 h.l.Error("failed to fetch events from db", "err", err, "cursor", cursor)
-56
knotserver/file.go
··· 1 - package knotserver 2 - 3 - import ( 4 - "bytes" 5 - "io" 6 - "log/slog" 7 - "net/http" 8 - "strings" 9 - 10 - "tangled.sh/tangled.sh/core/types" 11 - ) 12 - 13 - func (h *Handle) listFiles(files []types.NiceTree, data map[string]any, w http.ResponseWriter) { 14 - data["files"] = files 15 - 16 - writeJSON(w, data) 17 - return 18 - } 19 - 20 - func countLines(r io.Reader) (int, error) { 21 - buf := make([]byte, 32*1024) 22 - bufLen := 0 23 - count := 0 24 - nl := []byte{'\n'} 25 - 26 - for { 27 - c, err := r.Read(buf) 28 - if c > 0 { 29 - bufLen += c 30 - } 31 - count += bytes.Count(buf[:c], nl) 32 - 33 - switch { 34 - case err == io.EOF: 35 - /* handle last line not having a newline at the end */ 36 - if bufLen >= 1 && buf[(bufLen-1)%(32*1024)] != '\n' { 37 - count++ 38 - } 39 - return count, nil 40 - case err != nil: 41 - return 0, err 42 - } 43 - } 44 - } 45 - 46 - func (h *Handle) showFile(resp types.RepoBlobResponse, w http.ResponseWriter, l *slog.Logger) { 47 - lc, err := countLines(strings.NewReader(resp.Contents)) 48 - if err != nil { 49 - // Non-fatal, we'll just skip showing line numbers in the template. 50 - l.Warn("counting lines", "error", err) 51 - } 52 - 53 - resp.Lines = lc 54 - writeJSON(w, resp) 55 - return 56 - }
+112
knotserver/git/branch.go
··· 1 + package git 2 + 3 + import ( 4 + "fmt" 5 + "slices" 6 + "strconv" 7 + "strings" 8 + "time" 9 + 10 + "github.com/go-git/go-git/v5/plumbing" 11 + "github.com/go-git/go-git/v5/plumbing/object" 12 + "tangled.sh/tangled.sh/core/types" 13 + ) 14 + 15 + func (g *GitRepo) Branches() ([]types.Branch, error) { 16 + fields := []string{ 17 + "refname:short", 18 + "objectname", 19 + "authorname", 20 + "authoremail", 21 + "authordate:unix", 22 + "committername", 23 + "committeremail", 24 + "committerdate:unix", 25 + "tree", 26 + "parent", 27 + "contents", 28 + } 29 + 30 + var outFormat strings.Builder 31 + outFormat.WriteString("--format=") 32 + for i, f := range fields { 33 + if i != 0 { 34 + outFormat.WriteString(fieldSeparator) 35 + } 36 + outFormat.WriteString(fmt.Sprintf("%%(%s)", f)) 37 + } 38 + outFormat.WriteString("") 39 + outFormat.WriteString(recordSeparator) 40 + 41 + output, err := g.forEachRef(outFormat.String(), "refs/heads") 42 + if err != nil { 43 + return nil, fmt.Errorf("failed to get branches: %w", err) 44 + } 45 + 46 + records := strings.Split(strings.TrimSpace(string(output)), recordSeparator) 47 + if len(records) == 1 && records[0] == "" { 48 + return nil, nil 49 + } 50 + 51 + branches := make([]types.Branch, 0, len(records)) 52 + 53 + // ignore errors here 54 + defaultBranch, _ := g.FindMainBranch() 55 + 56 + for _, line := range records { 57 + parts := strings.SplitN(strings.TrimSpace(line), fieldSeparator, len(fields)) 58 + if len(parts) < 6 { 59 + continue 60 + } 61 + 62 + branchName := parts[0] 63 + commitHash := plumbing.NewHash(parts[1]) 64 + authorName := parts[2] 65 + authorEmail := strings.TrimSuffix(strings.TrimPrefix(parts[3], "<"), ">") 66 + authorDate := parts[4] 67 + committerName := parts[5] 68 + committerEmail := strings.TrimSuffix(strings.TrimPrefix(parts[6], "<"), ">") 69 + committerDate := parts[7] 70 + treeHash := plumbing.NewHash(parts[8]) 71 + parentHash := plumbing.NewHash(parts[9]) 72 + message := parts[10] 73 + 74 + // parse creation time 75 + var authoredAt, committedAt time.Time 76 + if unix, err := strconv.ParseInt(authorDate, 10, 64); err == nil { 77 + authoredAt = time.Unix(unix, 0) 78 + } 79 + if unix, err := strconv.ParseInt(committerDate, 10, 64); err == nil { 80 + committedAt = time.Unix(unix, 0) 81 + } 82 + 83 + branch := types.Branch{ 84 + IsDefault: branchName == defaultBranch, 85 + Reference: types.Reference{ 86 + Name: branchName, 87 + Hash: commitHash.String(), 88 + }, 89 + Commit: &object.Commit{ 90 + Hash: commitHash, 91 + Author: object.Signature{ 92 + Name: authorName, 93 + Email: authorEmail, 94 + When: authoredAt, 95 + }, 96 + Committer: object.Signature{ 97 + Name: committerName, 98 + Email: committerEmail, 99 + When: committedAt, 100 + }, 101 + TreeHash: treeHash, 102 + ParentHashes: []plumbing.Hash{parentHash}, 103 + Message: message, 104 + }, 105 + } 106 + 107 + branches = append(branches, branch) 108 + } 109 + 110 + slices.Reverse(branches) 111 + return branches, nil 112 + }
+42
knotserver/git/cmd.go
··· 1 + package git 2 + 3 + import ( 4 + "fmt" 5 + "os/exec" 6 + ) 7 + 8 + const ( 9 + fieldSeparator = "\x1f" // ASCII Unit Separator 10 + recordSeparator = "\x1e" // ASCII Record Separator 11 + ) 12 + 13 + func (g *GitRepo) runGitCmd(command string, extraArgs ...string) ([]byte, error) { 14 + var args []string 15 + args = append(args, command) 16 + args = append(args, extraArgs...) 17 + 18 + cmd := exec.Command("git", args...) 19 + cmd.Dir = g.path 20 + 21 + out, err := cmd.Output() 22 + if err != nil { 23 + if exitErr, ok := err.(*exec.ExitError); ok { 24 + return nil, fmt.Errorf("%w, stderr: %s", err, string(exitErr.Stderr)) 25 + } 26 + return nil, err 27 + } 28 + 29 + return out, nil 30 + } 31 + 32 + func (g *GitRepo) revList(extraArgs ...string) ([]byte, error) { 33 + return g.runGitCmd("rev-list", extraArgs...) 34 + } 35 + 36 + func (g *GitRepo) forEachRef(extraArgs ...string) ([]byte, error) { 37 + return g.runGitCmd("for-each-ref", extraArgs...) 38 + } 39 + 40 + func (g *GitRepo) revParse(extraArgs ...string) ([]byte, error) { 41 + return g.runGitCmd("rev-parse", extraArgs...) 42 + }
+8 -10
knotserver/git/fork.go
··· 10 10 ) 11 11 12 12 func Fork(repoPath, source string) error { 13 - _, err := git.PlainClone(repoPath, true, &git.CloneOptions{ 14 - URL: source, 15 - SingleBranch: false, 16 - }) 17 - 18 - if err != nil { 13 + cloneCmd := exec.Command("git", "clone", "--bare", source, repoPath) 14 + if err := cloneCmd.Run(); err != nil { 19 15 return fmt.Errorf("failed to bare clone repository: %w", err) 20 16 } 21 17 22 - err = exec.Command("git", "-C", repoPath, "config", "receive.hideRefs", "refs/hidden").Run() 23 - if err != nil { 18 + configureCmd := exec.Command("git", "-C", repoPath, "config", "receive.hideRefs", "refs/hidden") 19 + if err := configureCmd.Run(); err != nil { 24 20 return fmt.Errorf("failed to configure hidden refs: %w", err) 25 21 } 26 22 27 23 return nil 28 24 } 29 25 30 - func (g *GitRepo) Sync(branch string) error { 26 + func (g *GitRepo) Sync() error { 27 + branch := g.h.String() 28 + 31 29 fetchOpts := &git.FetchOptions{ 32 30 RefSpecs: []config.RefSpec{ 33 - config.RefSpec(fmt.Sprintf("+refs/heads/%s:refs/heads/%s", branch, branch)), 31 + config.RefSpec("+" + branch + ":" + branch), // +refs/heads/master:refs/heads/master 34 32 }, 35 33 } 36 34
+18 -110
knotserver/git/git.go
··· 2 2 3 3 import ( 4 4 "archive/tar" 5 + "bytes" 5 6 "fmt" 6 7 "io" 7 8 "io/fs" 8 - "os/exec" 9 9 "path" 10 - "sort" 11 10 "strconv" 12 11 "strings" 13 12 "time" ··· 15 14 "github.com/go-git/go-git/v5" 16 15 "github.com/go-git/go-git/v5/plumbing" 17 16 "github.com/go-git/go-git/v5/plumbing/object" 18 - "tangled.sh/tangled.sh/core/types" 19 17 ) 20 18 21 19 var ( ··· 158 156 fmt.Sprintf("--count"), 159 157 ) 160 158 if err != nil { 161 - return 0, fmt.Errorf("failed to run rev-list", err) 159 + return 0, fmt.Errorf("failed to run rev-list: %w", err) 162 160 } 163 161 164 162 count, err := strconv.Atoi(strings.TrimSpace(string(output))) ··· 169 167 return count, nil 170 168 } 171 169 172 - func (g *GitRepo) revList(extraArgs ...string) ([]byte, error) { 173 - var args []string 174 - args = append(args, "rev-list") 175 - args = append(args, extraArgs...) 176 - 177 - cmd := exec.Command("git", args...) 178 - cmd.Dir = g.path 179 - 180 - out, err := cmd.Output() 181 - if err != nil { 182 - if exitErr, ok := err.(*exec.ExitError); ok { 183 - return nil, fmt.Errorf("%w, stderr: %s", err, string(exitErr.Stderr)) 184 - } 185 - return nil, err 186 - } 187 - 188 - return out, nil 189 - } 190 - 191 170 func (g *GitRepo) Commit(h plumbing.Hash) (*object.Commit, error) { 192 171 return g.r.CommitObject(h) 193 172 } ··· 201 180 } 202 181 203 182 func (g *GitRepo) FileContentN(path string, cap int64) ([]byte, error) { 204 - buf := []byte{} 205 - 206 183 c, err := g.r.CommitObject(g.h) 207 184 if err != nil { 208 185 return nil, fmt.Errorf("commit object: %w", err) ··· 219 196 } 220 197 221 198 isbin, _ := file.IsBinary() 222 - 223 - if !isbin { 224 - reader, err := file.Reader() 225 - if err != nil { 226 - return nil, err 227 - } 228 - bufReader := io.LimitReader(reader, cap) 229 - _, err = bufReader.Read(buf) 230 - if err != nil { 231 - return nil, err 232 - } 233 - return buf, nil 234 - } else { 199 + if isbin { 235 200 return nil, ErrBinaryFile 236 201 } 202 + 203 + reader, err := file.Reader() 204 + if err != nil { 205 + return nil, err 206 + } 207 + 208 + buf := new(bytes.Buffer) 209 + if _, err = buf.ReadFrom(io.LimitReader(reader, cap)); err != nil { 210 + return nil, err 211 + } 212 + 213 + return buf.Bytes(), nil 237 214 } 238 215 239 216 func (g *GitRepo) FileContent(path string) (string, error) { ··· 286 263 return io.ReadAll(reader) 287 264 } 288 265 289 - func (g *GitRepo) Tags() ([]*TagReference, error) { 290 - iter, err := g.r.Tags() 291 - if err != nil { 292 - return nil, fmt.Errorf("tag objects: %w", err) 293 - } 294 - 295 - tags := make([]*TagReference, 0) 296 - 297 - if err := iter.ForEach(func(ref *plumbing.Reference) error { 298 - obj, err := g.r.TagObject(ref.Hash()) 299 - switch err { 300 - case nil: 301 - tags = append(tags, &TagReference{ 302 - ref: ref, 303 - tag: obj, 304 - }) 305 - case plumbing.ErrObjectNotFound: 306 - tags = append(tags, &TagReference{ 307 - ref: ref, 308 - }) 309 - default: 310 - return err 311 - } 312 - return nil 313 - }); err != nil { 314 - return nil, err 315 - } 316 - 317 - tagList := &TagList{r: g.r, refs: tags} 318 - sort.Sort(tagList) 319 - return tags, nil 320 - } 321 - 322 - func (g *GitRepo) Branches() ([]types.Branch, error) { 323 - bi, err := g.r.Branches() 324 - if err != nil { 325 - return nil, fmt.Errorf("branchs: %w", err) 326 - } 327 - 328 - branches := []types.Branch{} 329 - 330 - defaultBranch, err := g.FindMainBranch() 331 - 332 - _ = bi.ForEach(func(ref *plumbing.Reference) error { 333 - b := types.Branch{} 334 - b.Hash = ref.Hash().String() 335 - b.Name = ref.Name().Short() 336 - 337 - // resolve commit that this branch points to 338 - commit, _ := g.Commit(ref.Hash()) 339 - if commit != nil { 340 - b.Commit = commit 341 - } 342 - 343 - if defaultBranch != "" && defaultBranch == b.Name { 344 - b.IsDefault = true 345 - } 346 - 347 - branches = append(branches, b) 348 - 349 - return nil 350 - }) 351 - 352 - return branches, nil 353 - } 354 - 355 266 func (g *GitRepo) Branch(name string) (*plumbing.Reference, error) { 356 267 ref, err := g.r.Reference(plumbing.NewBranchReferenceName(name), false) 357 268 if err != nil { ··· 371 282 } 372 283 373 284 func (g *GitRepo) FindMainBranch() (string, error) { 374 - ref, err := g.r.Head() 285 + output, err := g.revParse("--abbrev-ref", "HEAD") 375 286 if err != nil { 376 - return "", fmt.Errorf("unable to find main branch: %w", err) 377 - } 378 - if ref.Name().IsBranch() { 379 - return strings.TrimPrefix(string(ref.Name()), "refs/heads/"), nil 287 + return "", fmt.Errorf("failed to find main branch: %w", err) 380 288 } 381 289 382 - return "", fmt.Errorf("unable to find main branch: %w", err) 290 + return strings.TrimSpace(string(output)), nil 383 291 } 384 292 385 293 // WriteTar writes itself from a tree into a binary tar file format.
+69
knotserver/git/language.go
··· 1 + package git 2 + 3 + import ( 4 + "context" 5 + "path" 6 + "strings" 7 + 8 + "github.com/go-enry/go-enry/v2" 9 + "github.com/go-git/go-git/v5/plumbing/object" 10 + ) 11 + 12 + type LangBreakdown map[string]int64 13 + 14 + func (g *GitRepo) AnalyzeLanguages(ctx context.Context) (LangBreakdown, error) { 15 + sizes := make(map[string]int64) 16 + err := g.Walk(ctx, "", func(node object.TreeEntry, parent *object.Tree, root string) error { 17 + filepath := path.Join(root, node.Name) 18 + 19 + content, err := g.FileContentN(filepath, 16*1024) // 16KB 20 + if err != nil { 21 + return nil 22 + } 23 + 24 + if enry.IsGenerated(filepath, content) || 25 + enry.IsBinary(content) || 26 + strings.HasSuffix(filepath, "bun.lock") { 27 + return nil 28 + } 29 + 30 + language := analyzeLanguage(node, content) 31 + if group := enry.GetLanguageGroup(language); group != "" { 32 + language = group 33 + } 34 + 35 + langType := enry.GetLanguageType(language) 36 + if langType != enry.Programming && langType != enry.Markup && langType != enry.Unknown { 37 + return nil 38 + } 39 + 40 + sz, _ := parent.Size(node.Name) 41 + sizes[language] += sz 42 + 43 + return nil 44 + }) 45 + 46 + if err != nil { 47 + return nil, err 48 + } 49 + 50 + return sizes, nil 51 + } 52 + 53 + func analyzeLanguage(node object.TreeEntry, content []byte) string { 54 + language, ok := enry.GetLanguageByExtension(node.Name) 55 + if ok { 56 + return language 57 + } 58 + 59 + language, ok = enry.GetLanguageByFilename(node.Name) 60 + if ok { 61 + return language 62 + } 63 + 64 + if len(content) == 0 { 65 + return enry.OtherLanguage 66 + } 67 + 68 + return enry.GetLanguage(node.Name, content) 69 + }
+58 -72
knotserver/git/merge.go
··· 12 12 "github.com/dgraph-io/ristretto" 13 13 "github.com/go-git/go-git/v5" 14 14 "github.com/go-git/go-git/v5/plumbing" 15 - "tangled.sh/tangled.sh/core/patchutil" 16 15 ) 17 16 18 17 type MergeCheckCache struct { ··· 86 85 87 86 // MergeOptions specifies the configuration for a merge operation 88 87 type MergeOptions struct { 89 - CommitMessage string 90 - CommitBody string 91 - AuthorName string 92 - AuthorEmail string 93 - FormatPatch bool 88 + CommitMessage string 89 + CommitBody string 90 + AuthorName string 91 + AuthorEmail string 92 + CommitterName string 93 + CommitterEmail string 94 + FormatPatch bool 94 95 } 95 96 96 97 func (e ErrMerge) Error() string { ··· 143 144 return tmpDir, nil 144 145 } 145 146 146 - func (g *GitRepo) applyPatch(tmpDir, patchFile string, checkOnly bool, opts *MergeOptions) error { 147 + func (g *GitRepo) checkPatch(tmpDir, patchFile string) error { 147 148 var stderr bytes.Buffer 148 - var cmd *exec.Cmd 149 149 150 - if checkOnly { 151 - cmd = exec.Command("git", "-C", tmpDir, "apply", "--check", "-v", patchFile) 152 - } else { 153 - // if patch is a format-patch, apply using 'git am' 154 - if opts.FormatPatch { 155 - amCmd := exec.Command("git", "-C", tmpDir, "am", patchFile) 156 - amCmd.Stderr = &stderr 157 - if err := amCmd.Run(); err != nil { 158 - return fmt.Errorf("patch application failed: %s", stderr.String()) 159 - } 160 - return nil 150 + cmd := exec.Command("git", "-C", tmpDir, "apply", "--check", "-v", patchFile) 151 + cmd.Stderr = &stderr 152 + 153 + if err := cmd.Run(); err != nil { 154 + conflicts := parseGitApplyErrors(stderr.String()) 155 + return &ErrMerge{ 156 + Message: "patch cannot be applied cleanly", 157 + Conflicts: conflicts, 158 + HasConflict: len(conflicts) > 0, 159 + OtherError: err, 161 160 } 162 - 163 - // else, apply using 'git apply' and commit it manually 164 - exec.Command("git", "-C", tmpDir, "config", "advice.mergeConflict", "false").Run() 165 - if opts != nil { 166 - applyCmd := exec.Command("git", "-C", tmpDir, "apply", patchFile) 167 - applyCmd.Stderr = &stderr 168 - if err := applyCmd.Run(); err != nil { 169 - return fmt.Errorf("patch application failed: %s", stderr.String()) 170 - } 161 + } 162 + return nil 163 + } 171 164 172 - stageCmd := exec.Command("git", "-C", tmpDir, "add", ".") 173 - if err := stageCmd.Run(); err != nil { 174 - return fmt.Errorf("failed to stage changes: %w", err) 175 - } 165 + func (g *GitRepo) applyPatch(tmpDir, patchFile string, opts MergeOptions) error { 166 + var stderr bytes.Buffer 167 + var cmd *exec.Cmd 176 168 177 - commitArgs := []string{"-C", tmpDir, "commit"} 169 + // configure default git user before merge 170 + exec.Command("git", "-C", tmpDir, "config", "user.name", opts.CommitterName).Run() 171 + exec.Command("git", "-C", tmpDir, "config", "user.email", opts.CommitterEmail).Run() 172 + exec.Command("git", "-C", tmpDir, "config", "advice.mergeConflict", "false").Run() 178 173 179 - // Set author if provided 180 - authorName := opts.AuthorName 181 - authorEmail := opts.AuthorEmail 174 + // if patch is a format-patch, apply using 'git am' 175 + if opts.FormatPatch { 176 + cmd = exec.Command("git", "-C", tmpDir, "am", patchFile) 177 + } else { 178 + // else, apply using 'git apply' and commit it manually 179 + applyCmd := exec.Command("git", "-C", tmpDir, "apply", patchFile) 180 + applyCmd.Stderr = &stderr 181 + if err := applyCmd.Run(); err != nil { 182 + return fmt.Errorf("patch application failed: %s", stderr.String()) 183 + } 182 184 183 - if authorEmail == "" { 184 - authorEmail = "noreply@tangled.sh" 185 - } 185 + stageCmd := exec.Command("git", "-C", tmpDir, "add", ".") 186 + if err := stageCmd.Run(); err != nil { 187 + return fmt.Errorf("failed to stage changes: %w", err) 188 + } 186 189 187 - if authorName == "" { 188 - authorName = "Tangled" 189 - } 190 + commitArgs := []string{"-C", tmpDir, "commit"} 190 191 191 - if authorName != "" { 192 - commitArgs = append(commitArgs, "--author", fmt.Sprintf("%s <%s>", authorName, authorEmail)) 193 - } 192 + // Set author if provided 193 + authorName := opts.AuthorName 194 + authorEmail := opts.AuthorEmail 194 195 195 - commitArgs = append(commitArgs, "-m", opts.CommitMessage) 196 + if authorName != "" && authorEmail != "" { 197 + commitArgs = append(commitArgs, "--author", fmt.Sprintf("%s <%s>", authorName, authorEmail)) 198 + } 199 + // else, will default to knot's global user.name & user.email configured via `KNOT_GIT_USER_*` env variables 196 200 197 - if opts.CommitBody != "" { 198 - commitArgs = append(commitArgs, "-m", opts.CommitBody) 199 - } 201 + commitArgs = append(commitArgs, "-m", opts.CommitMessage) 200 202 201 - cmd = exec.Command("git", commitArgs...) 202 - } else { 203 - // If no commit message specified, use git-am which automatically creates a commit 204 - cmd = exec.Command("git", "-C", tmpDir, "am", patchFile) 203 + if opts.CommitBody != "" { 204 + commitArgs = append(commitArgs, "-m", opts.CommitBody) 205 205 } 206 + 207 + cmd = exec.Command("git", commitArgs...) 206 208 } 207 209 208 210 cmd.Stderr = &stderr 209 211 210 212 if err := cmd.Run(); err != nil { 211 - if checkOnly { 212 - conflicts := parseGitApplyErrors(stderr.String()) 213 - return &ErrMerge{ 214 - Message: "patch cannot be applied cleanly", 215 - Conflicts: conflicts, 216 - HasConflict: len(conflicts) > 0, 217 - OtherError: err, 218 - } 219 - } 220 213 return fmt.Errorf("patch application failed: %s", stderr.String()) 221 214 } 222 215 ··· 227 220 if val, ok := mergeCheckCache.Get(g, patchData, targetBranch); ok { 228 221 return val 229 222 } 230 - 231 - var opts MergeOptions 232 - opts.FormatPatch = patchutil.IsFormatPatch(string(patchData)) 233 223 234 224 patchFile, err := g.createTempFileWithPatch(patchData) 235 225 if err != nil { ··· 249 239 } 250 240 defer os.RemoveAll(tmpDir) 251 241 252 - result := g.applyPatch(tmpDir, patchFile, true, &opts) 242 + result := g.checkPatch(tmpDir, patchFile) 253 243 mergeCheckCache.Set(g, patchData, targetBranch, result) 254 244 return result 255 245 } 256 246 257 - func (g *GitRepo) Merge(patchData []byte, targetBranch string) error { 258 - return g.MergeWithOptions(patchData, targetBranch, nil) 259 - } 260 - 261 - func (g *GitRepo) MergeWithOptions(patchData []byte, targetBranch string, opts *MergeOptions) error { 247 + func (g *GitRepo) MergeWithOptions(patchData []byte, targetBranch string, opts MergeOptions) error { 262 248 patchFile, err := g.createTempFileWithPatch(patchData) 263 249 if err != nil { 264 250 return &ErrMerge{ ··· 277 263 } 278 264 defer os.RemoveAll(tmpDir) 279 265 280 - if err := g.applyPatch(tmpDir, patchFile, false, opts); err != nil { 266 + if err := g.applyPatch(tmpDir, patchFile, opts); err != nil { 281 267 return err 282 268 } 283 269
+70 -32
knotserver/git/post_receive.go
··· 2 2 3 3 import ( 4 4 "bufio" 5 + "context" 6 + "errors" 5 7 "fmt" 6 8 "io" 7 9 "strings" 10 + "time" 8 11 9 12 "tangled.sh/tangled.sh/core/api/tangled" 10 13 ··· 46 49 } 47 50 48 51 type RefUpdateMeta struct { 49 - CommitCount CommitCount 50 - IsDefaultRef bool 52 + CommitCount CommitCount 53 + IsDefaultRef bool 54 + LangBreakdown LangBreakdown 51 55 } 52 56 53 57 type CommitCount struct { 54 58 ByEmail map[string]int 55 59 } 56 60 57 - func (g *GitRepo) RefUpdateMeta(line PostReceiveLine) RefUpdateMeta { 61 + func (g *GitRepo) RefUpdateMeta(line PostReceiveLine) (RefUpdateMeta, error) { 62 + var errs error 63 + 58 64 commitCount, err := g.newCommitCount(line) 59 - if err != nil { 60 - // TODO: non-fatal, log this 61 - } 65 + errors.Join(errs, err) 62 66 63 67 isDefaultRef, err := g.isDefaultBranch(line) 64 - if err != nil { 65 - // TODO: non-fatal, log this 66 - } 68 + errors.Join(errs, err) 69 + 70 + ctx, cancel := context.WithTimeout(context.Background(), time.Second*2) 71 + defer cancel() 72 + breakdown, err := g.AnalyzeLanguages(ctx) 73 + errors.Join(errs, err) 67 74 68 75 return RefUpdateMeta{ 69 - CommitCount: commitCount, 70 - IsDefaultRef: isDefaultRef, 71 - } 76 + CommitCount: commitCount, 77 + IsDefaultRef: isDefaultRef, 78 + LangBreakdown: breakdown, 79 + }, errs 72 80 } 73 81 74 82 func (g *GitRepo) newCommitCount(line PostReceiveLine) (CommitCount, error) { ··· 77 85 ByEmail: byEmail, 78 86 } 79 87 80 - if !line.NewSha.IsZero() { 81 - output, err := g.revList( 82 - fmt.Sprintf("--max-count=%d", 100), 83 - fmt.Sprintf("%s..%s", line.OldSha.String(), line.NewSha.String()), 84 - ) 85 - if err != nil { 86 - return commitCount, fmt.Errorf("failed to run rev-list: %w", err) 87 - } 88 + if line.NewSha.IsZero() { 89 + return commitCount, nil 90 + } 88 91 89 - lines := strings.Split(strings.TrimSpace(string(output)), "\n") 90 - if len(lines) == 1 && lines[0] == "" { 91 - return commitCount, nil 92 - } 92 + args := []string{fmt.Sprintf("--max-count=%d", 100)} 93 93 94 - for _, item := range lines { 95 - obj, err := g.r.CommitObject(plumbing.NewHash(item)) 96 - if err != nil { 97 - continue 94 + if line.OldSha.IsZero() { 95 + // git rev-list <newsha> ^other-branches --not ^this-branch 96 + args = append(args, line.NewSha.String()) 97 + 98 + branches, _ := g.Branches() 99 + for _, b := range branches { 100 + if !strings.Contains(line.Ref, b.Name) { 101 + args = append(args, fmt.Sprintf("^%s", b.Name)) 98 102 } 99 - commitCount.ByEmail[obj.Author.Email] += 1 103 + } 104 + 105 + args = append(args, "--not") 106 + args = append(args, fmt.Sprintf("^%s", line.Ref)) 107 + } else { 108 + // git rev-list <oldsha>..<newsha> 109 + args = append(args, fmt.Sprintf("%s..%s", line.OldSha.String(), line.NewSha.String())) 110 + } 111 + 112 + output, err := g.revList(args...) 113 + if err != nil { 114 + return commitCount, fmt.Errorf("failed to run rev-list: %w", err) 115 + } 116 + 117 + lines := strings.Split(strings.TrimSpace(string(output)), "\n") 118 + if len(lines) == 1 && lines[0] == "" { 119 + return commitCount, nil 120 + } 121 + 122 + for _, item := range lines { 123 + obj, err := g.r.CommitObject(plumbing.NewHash(item)) 124 + if err != nil { 125 + continue 100 126 } 127 + commitCount.ByEmail[obj.Author.Email] += 1 101 128 } 102 129 103 130 return commitCount, nil ··· 118 145 } 119 146 120 147 func (m RefUpdateMeta) AsRecord() tangled.GitRefUpdate_Meta { 121 - var byEmail []*tangled.GitRefUpdate_Meta_CommitCount_ByEmail_Elem 148 + var byEmail []*tangled.GitRefUpdate_IndividualEmailCommitCount 122 149 for e, v := range m.CommitCount.ByEmail { 123 - byEmail = append(byEmail, &tangled.GitRefUpdate_Meta_CommitCount_ByEmail_Elem{ 150 + byEmail = append(byEmail, &tangled.GitRefUpdate_IndividualEmailCommitCount{ 124 151 Email: e, 125 152 Count: int64(v), 126 153 }) 127 154 } 128 155 156 + var langs []*tangled.GitRefUpdate_IndividualLanguageSize 157 + for lang, size := range m.LangBreakdown { 158 + langs = append(langs, &tangled.GitRefUpdate_IndividualLanguageSize{ 159 + Lang: lang, 160 + Size: size, 161 + }) 162 + } 163 + 129 164 return tangled.GitRefUpdate_Meta{ 130 - CommitCount: &tangled.GitRefUpdate_Meta_CommitCount{ 165 + CommitCount: &tangled.GitRefUpdate_CommitCountBreakdown{ 131 166 ByEmail: byEmail, 132 167 }, 133 168 IsDefaultRef: m.IsDefaultRef, 169 + LangBreakdown: &tangled.GitRefUpdate_LangBreakdown{ 170 + Inputs: langs, 171 + }, 134 172 } 135 173 }
+99
knotserver/git/tag.go
··· 1 + package git 2 + 3 + import ( 4 + "fmt" 5 + "slices" 6 + "strconv" 7 + "strings" 8 + "time" 9 + 10 + "github.com/go-git/go-git/v5/plumbing" 11 + "github.com/go-git/go-git/v5/plumbing/object" 12 + ) 13 + 14 + func (g *GitRepo) Tags() ([]object.Tag, error) { 15 + fields := []string{ 16 + "refname:short", 17 + "objectname", 18 + "objecttype", 19 + "*objectname", 20 + "*objecttype", 21 + "taggername", 22 + "taggeremail", 23 + "taggerdate:unix", 24 + "contents", 25 + } 26 + 27 + var outFormat strings.Builder 28 + outFormat.WriteString("--format=") 29 + for i, f := range fields { 30 + if i != 0 { 31 + outFormat.WriteString(fieldSeparator) 32 + } 33 + outFormat.WriteString(fmt.Sprintf("%%(%s)", f)) 34 + } 35 + outFormat.WriteString("") 36 + outFormat.WriteString(recordSeparator) 37 + 38 + output, err := g.forEachRef(outFormat.String(), "refs/tags") 39 + if err != nil { 40 + return nil, fmt.Errorf("failed to get tags: %w", err) 41 + } 42 + 43 + records := strings.Split(strings.TrimSpace(string(output)), recordSeparator) 44 + if len(records) == 1 && records[0] == "" { 45 + return nil, nil 46 + } 47 + 48 + tags := make([]object.Tag, 0, len(records)) 49 + 50 + for _, line := range records { 51 + parts := strings.SplitN(strings.TrimSpace(line), fieldSeparator, len(fields)) 52 + if len(parts) < 6 { 53 + continue 54 + } 55 + 56 + tagName := parts[0] 57 + objectHash := parts[1] 58 + objectType := parts[2] 59 + targetHash := parts[3] // dereferenced object hash (empty for lightweight tags) 60 + // targetType := parts[4] // dereferenced object type (empty for lightweight tags) 61 + taggerName := parts[5] 62 + taggerEmail := parts[6] 63 + taggerDate := parts[7] 64 + message := parts[8] 65 + 66 + // parse creation time 67 + var createdAt time.Time 68 + if unix, err := strconv.ParseInt(taggerDate, 10, 64); err == nil { 69 + createdAt = time.Unix(unix, 0) 70 + } 71 + 72 + // parse object type 73 + typ, err := plumbing.ParseObjectType(objectType) 74 + if err != nil { 75 + return nil, err 76 + } 77 + 78 + // strip email separators 79 + taggerEmail = strings.TrimSuffix(strings.TrimPrefix(taggerEmail, "<"), ">") 80 + 81 + tag := object.Tag{ 82 + Hash: plumbing.NewHash(objectHash), 83 + Name: tagName, 84 + Tagger: object.Signature{ 85 + Name: taggerName, 86 + Email: taggerEmail, 87 + When: createdAt, 88 + }, 89 + Message: message, 90 + TargetType: typ, 91 + Target: plumbing.NewHash(targetHash), 92 + } 93 + 94 + tags = append(tags, tag) 95 + } 96 + 97 + slices.Reverse(tags) 98 + return tags, nil 99 + }
+79
knotserver/git/tree.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "errors" 5 6 "fmt" 6 7 "path" 7 8 "time" ··· 78 79 79 80 return nts 80 81 } 82 + 83 + var ( 84 + TerminateWalk error = errors.New("terminate walk") 85 + ) 86 + 87 + type callback = func(node object.TreeEntry, parent *object.Tree, fullPath string) error 88 + 89 + func (g *GitRepo) Walk( 90 + ctx context.Context, 91 + root string, 92 + cb callback, 93 + ) error { 94 + c, err := g.r.CommitObject(g.h) 95 + if err != nil { 96 + return fmt.Errorf("commit object: %w", err) 97 + } 98 + 99 + tree, err := c.Tree() 100 + if err != nil { 101 + return fmt.Errorf("file tree: %w", err) 102 + } 103 + 104 + subtree := tree 105 + if root != "" { 106 + subtree, err = tree.Tree(root) 107 + if err != nil { 108 + return fmt.Errorf("sub tree: %w", err) 109 + } 110 + } 111 + 112 + return g.walkHelper(ctx, root, subtree, cb) 113 + } 114 + 115 + func (g *GitRepo) walkHelper( 116 + ctx context.Context, 117 + root string, 118 + currentTree *object.Tree, 119 + cb callback, 120 + ) error { 121 + for _, e := range currentTree.Entries { 122 + // check if context hits deadline before processing 123 + select { 124 + case <-ctx.Done(): 125 + return ctx.Err() 126 + default: 127 + } 128 + 129 + mode, err := e.Mode.ToOSFileMode() 130 + if err != nil { 131 + // TODO: log this 132 + continue 133 + } 134 + 135 + if e.Mode.IsFile() { 136 + err = cb(e, currentTree, root) 137 + if errors.Is(err, TerminateWalk) { 138 + return err 139 + } 140 + } 141 + 142 + // e is a directory 143 + if mode.IsDir() { 144 + subtree, err := currentTree.Tree(e.Name) 145 + if err != nil { 146 + return fmt.Errorf("sub tree %s: %w", e.Name, err) 147 + } 148 + 149 + fullPath := path.Join(root, e.Name) 150 + 151 + err = g.walkHelper(ctx, fullPath, subtree, cb) 152 + if err != nil { 153 + return err 154 + } 155 + } 156 + } 157 + 158 + return nil 159 + }
+9 -4
knotserver/git.go
··· 13 13 "tangled.sh/tangled.sh/core/knotserver/git/service" 14 14 ) 15 15 16 - func (d *Handle) InfoRefs(w http.ResponseWriter, r *http.Request) { 16 + func (d *Knot) InfoRefs(w http.ResponseWriter, r *http.Request) { 17 17 did := chi.URLParam(r, "did") 18 18 name := chi.URLParam(r, "name") 19 19 repoName, err := securejoin.SecureJoin(did, name) ··· 56 56 } 57 57 } 58 58 59 - func (d *Handle) UploadPack(w http.ResponseWriter, r *http.Request) { 59 + func (d *Knot) UploadPack(w http.ResponseWriter, r *http.Request) { 60 60 did := chi.URLParam(r, "did") 61 61 name := chi.URLParam(r, "name") 62 62 repo, err := securejoin.SecureJoin(d.c.Repo.ScanPath, filepath.Join(did, name)) ··· 105 105 } 106 106 } 107 107 108 - func (d *Handle) ReceivePack(w http.ResponseWriter, r *http.Request) { 108 + func (d *Knot) ReceivePack(w http.ResponseWriter, r *http.Request) { 109 109 did := chi.URLParam(r, "did") 110 110 name := chi.URLParam(r, "name") 111 111 _, err := securejoin.SecureJoin(d.c.Repo.ScanPath, filepath.Join(did, name)) ··· 118 118 d.RejectPush(w, r, name) 119 119 } 120 120 121 - func (d *Handle) RejectPush(w http.ResponseWriter, r *http.Request, unqualifiedRepoName string) { 121 + func (d *Knot) RejectPush(w http.ResponseWriter, r *http.Request, unqualifiedRepoName string) { 122 122 // A text/plain response will cause git to print each line of the body 123 123 // prefixed with "remote: ". 124 124 w.Header().Set("content-type", "text/plain; charset=UTF-8") ··· 129 129 // If the appview gave us the repository owner's handle we can attempt to 130 130 // construct the correct ssh url. 131 131 ownerHandle := r.Header.Get("x-tangled-repo-owner-handle") 132 + ownerHandle = strings.TrimPrefix(ownerHandle, "@") 132 133 if ownerHandle != "" && !strings.ContainsAny(ownerHandle, ":") { 133 134 hostname := d.c.Server.Hostname 134 135 if strings.Contains(hostname, ":") { 135 136 hostname = strings.Split(hostname, ":")[0] 137 + } 138 + 139 + if hostname == "knot1.tangled.sh" { 140 + hostname = "tangled.sh" 136 141 } 137 142 138 143 fmt.Fprintf(w, " Try:\ngit remote set-url --push origin git@%s:%s/%s\n\n... and push again.", hostname, ownerHandle, unqualifiedRepoName)
-192
knotserver/handler.go
··· 1 - package knotserver 2 - 3 - import ( 4 - "context" 5 - "fmt" 6 - "log/slog" 7 - "net/http" 8 - "runtime/debug" 9 - 10 - "github.com/go-chi/chi/v5" 11 - "tangled.sh/tangled.sh/core/jetstream" 12 - "tangled.sh/tangled.sh/core/knotserver/config" 13 - "tangled.sh/tangled.sh/core/knotserver/db" 14 - "tangled.sh/tangled.sh/core/notifier" 15 - "tangled.sh/tangled.sh/core/rbac" 16 - ) 17 - 18 - const ( 19 - ThisServer = "thisserver" // resource identifier for rbac enforcement 20 - ) 21 - 22 - type Handle struct { 23 - c *config.Config 24 - db *db.DB 25 - jc *jetstream.JetstreamClient 26 - e *rbac.Enforcer 27 - l *slog.Logger 28 - n *notifier.Notifier 29 - 30 - // init is a channel that is closed when the knot has been initailized 31 - // i.e. when the first user (knot owner) has been added. 32 - init chan struct{} 33 - knotInitialized bool 34 - } 35 - 36 - func Setup(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, jc *jetstream.JetstreamClient, l *slog.Logger, n *notifier.Notifier) (http.Handler, error) { 37 - r := chi.NewRouter() 38 - 39 - h := Handle{ 40 - c: c, 41 - db: db, 42 - e: e, 43 - l: l, 44 - jc: jc, 45 - n: n, 46 - init: make(chan struct{}), 47 - } 48 - 49 - err := e.AddKnot(ThisServer) 50 - if err != nil { 51 - return nil, fmt.Errorf("failed to setup enforcer: %w", err) 52 - } 53 - 54 - err = h.jc.StartJetstream(ctx, h.processMessages) 55 - if err != nil { 56 - return nil, fmt.Errorf("failed to start jetstream: %w", err) 57 - } 58 - 59 - // Check if the knot knows about any Dids; 60 - // if it does, it is already initialized and we can repopulate the 61 - // Jetstream subscriptions. 62 - dids, err := db.GetAllDids() 63 - if err != nil { 64 - return nil, fmt.Errorf("failed to get all Dids: %w", err) 65 - } 66 - 67 - if len(dids) > 0 { 68 - h.knotInitialized = true 69 - close(h.init) 70 - for _, d := range dids { 71 - h.jc.AddDid(d) 72 - } 73 - } 74 - 75 - r.Get("/", h.Index) 76 - r.Get("/capabilities", h.Capabilities) 77 - r.Get("/version", h.Version) 78 - r.Route("/{did}", func(r chi.Router) { 79 - // Repo routes 80 - r.Route("/{name}", func(r chi.Router) { 81 - r.Route("/collaborator", func(r chi.Router) { 82 - r.Use(h.VerifySignature) 83 - r.Post("/add", h.AddRepoCollaborator) 84 - }) 85 - 86 - r.Route("/languages", func(r chi.Router) { 87 - r.With(h.VerifySignature) 88 - r.Get("/", h.RepoLanguages) 89 - r.Get("/{ref}", h.RepoLanguages) 90 - }) 91 - 92 - r.Get("/", h.RepoIndex) 93 - r.Get("/info/refs", h.InfoRefs) 94 - r.Post("/git-upload-pack", h.UploadPack) 95 - r.Post("/git-receive-pack", h.ReceivePack) 96 - r.Get("/compare/{rev1}/{rev2}", h.Compare) // git diff-tree compare of two objects 97 - 98 - r.With(h.VerifySignature).Post("/hidden-ref/{forkRef}/{remoteRef}", h.NewHiddenRef) 99 - 100 - r.Route("/merge", func(r chi.Router) { 101 - r.With(h.VerifySignature) 102 - r.Post("/", h.Merge) 103 - r.Post("/check", h.MergeCheck) 104 - }) 105 - 106 - r.Route("/tree/{ref}", func(r chi.Router) { 107 - r.Get("/", h.RepoIndex) 108 - r.Get("/*", h.RepoTree) 109 - }) 110 - 111 - r.Route("/blob/{ref}", func(r chi.Router) { 112 - r.Get("/*", h.Blob) 113 - }) 114 - 115 - r.Route("/raw/{ref}", func(r chi.Router) { 116 - r.Get("/*", h.BlobRaw) 117 - }) 118 - 119 - r.Get("/log/{ref}", h.Log) 120 - r.Get("/archive/{file}", h.Archive) 121 - r.Get("/commit/{ref}", h.Diff) 122 - r.Get("/tags", h.Tags) 123 - r.Route("/branches", func(r chi.Router) { 124 - r.Get("/", h.Branches) 125 - r.Get("/{branch}", h.Branch) 126 - r.Route("/default", func(r chi.Router) { 127 - r.Get("/", h.DefaultBranch) 128 - r.With(h.VerifySignature).Put("/", h.SetDefaultBranch) 129 - }) 130 - }) 131 - }) 132 - }) 133 - 134 - // Create a new repository. 135 - r.Route("/repo", func(r chi.Router) { 136 - r.Use(h.VerifySignature) 137 - r.Put("/new", h.NewRepo) 138 - r.Delete("/", h.RemoveRepo) 139 - r.Route("/fork", func(r chi.Router) { 140 - r.Post("/", h.RepoFork) 141 - r.Post("/sync/{branch}", h.RepoForkSync) 142 - r.Get("/sync/{branch}", h.RepoForkAheadBehind) 143 - }) 144 - }) 145 - 146 - r.Route("/member", func(r chi.Router) { 147 - r.Use(h.VerifySignature) 148 - r.Put("/add", h.AddMember) 149 - }) 150 - 151 - // Socket that streams git oplogs 152 - r.Get("/events", h.Events) 153 - 154 - // Initialize the knot with an owner and public key. 155 - r.With(h.VerifySignature).Post("/init", h.Init) 156 - 157 - // Health check. Used for two-way verification with appview. 158 - r.With(h.VerifySignature).Get("/health", h.Health) 159 - 160 - // All public keys on the knot. 161 - r.Get("/keys", h.Keys) 162 - 163 - return r, nil 164 - } 165 - 166 - // version is set during build time. 167 - var version string 168 - 169 - func (h *Handle) Version(w http.ResponseWriter, r *http.Request) { 170 - if version == "" { 171 - info, ok := debug.ReadBuildInfo() 172 - if !ok { 173 - http.Error(w, "failed to read build info", http.StatusInternalServerError) 174 - return 175 - } 176 - 177 - var modVer string 178 - for _, mod := range info.Deps { 179 - if mod.Path == "tangled.sh/tangled.sh/knotserver" { 180 - version = mod.Version 181 - break 182 - } 183 - } 184 - 185 - if modVer == "" { 186 - version = "unknown" 187 - } 188 - } 189 - 190 - w.Header().Set("Content-Type", "text/plain") 191 - fmt.Fprintf(w, "knotserver/%s", version) 192 - }
-10
knotserver/http_util.go
··· 20 20 func notFound(w http.ResponseWriter) { 21 21 writeError(w, "not found", http.StatusNotFound) 22 22 } 23 - 24 - func writeMsg(w http.ResponseWriter, msg string) { 25 - writeJSON(w, map[string]string{"msg": msg}) 26 - } 27 - 28 - func writeConflict(w http.ResponseWriter, data interface{}) { 29 - w.Header().Set("Content-Type", "application/json") 30 - w.WriteHeader(http.StatusConflict) 31 - json.NewEncoder(w).Encode(data) 32 - }
+356
knotserver/ingester.go
··· 1 + package knotserver 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "io" 8 + "net/http" 9 + "net/url" 10 + "path/filepath" 11 + "strings" 12 + 13 + comatproto "github.com/bluesky-social/indigo/api/atproto" 14 + "github.com/bluesky-social/indigo/atproto/syntax" 15 + "github.com/bluesky-social/indigo/xrpc" 16 + "github.com/bluesky-social/jetstream/pkg/models" 17 + securejoin "github.com/cyphar/filepath-securejoin" 18 + "tangled.sh/tangled.sh/core/api/tangled" 19 + "tangled.sh/tangled.sh/core/idresolver" 20 + "tangled.sh/tangled.sh/core/knotserver/db" 21 + "tangled.sh/tangled.sh/core/knotserver/git" 22 + "tangled.sh/tangled.sh/core/log" 23 + "tangled.sh/tangled.sh/core/rbac" 24 + "tangled.sh/tangled.sh/core/workflow" 25 + ) 26 + 27 + func (h *Knot) processPublicKey(ctx context.Context, event *models.Event) error { 28 + l := log.FromContext(ctx) 29 + raw := json.RawMessage(event.Commit.Record) 30 + did := event.Did 31 + 32 + var record tangled.PublicKey 33 + if err := json.Unmarshal(raw, &record); err != nil { 34 + return fmt.Errorf("failed to unmarshal record: %w", err) 35 + } 36 + 37 + pk := db.PublicKey{ 38 + Did: did, 39 + PublicKey: record, 40 + } 41 + if err := h.db.AddPublicKey(pk); err != nil { 42 + l.Error("failed to add public key", "error", err) 43 + return fmt.Errorf("failed to add public key: %w", err) 44 + } 45 + l.Info("added public key from firehose", "did", did) 46 + return nil 47 + } 48 + 49 + func (h *Knot) processKnotMember(ctx context.Context, event *models.Event) error { 50 + l := log.FromContext(ctx) 51 + raw := json.RawMessage(event.Commit.Record) 52 + did := event.Did 53 + 54 + var record tangled.KnotMember 55 + if err := json.Unmarshal(raw, &record); err != nil { 56 + return fmt.Errorf("failed to unmarshal record: %w", err) 57 + } 58 + 59 + if record.Domain != h.c.Server.Hostname { 60 + l.Error("domain mismatch", "domain", record.Domain, "expected", h.c.Server.Hostname) 61 + return fmt.Errorf("domain mismatch: %s != %s", record.Domain, h.c.Server.Hostname) 62 + } 63 + 64 + ok, err := h.e.E.Enforce(did, rbac.ThisServer, rbac.ThisServer, "server:invite") 65 + if err != nil || !ok { 66 + l.Error("failed to add member", "did", did) 67 + return fmt.Errorf("failed to enforce permissions: %w", err) 68 + } 69 + 70 + if err := h.e.AddKnotMember(rbac.ThisServer, record.Subject); err != nil { 71 + l.Error("failed to add member", "error", err) 72 + return fmt.Errorf("failed to add member: %w", err) 73 + } 74 + l.Info("added member from firehose", "member", record.Subject) 75 + 76 + if err := h.db.AddDid(record.Subject); err != nil { 77 + l.Error("failed to add did", "error", err) 78 + return fmt.Errorf("failed to add did: %w", err) 79 + } 80 + h.jc.AddDid(record.Subject) 81 + 82 + if err := h.fetchAndAddKeys(ctx, record.Subject); err != nil { 83 + return fmt.Errorf("failed to fetch and add keys: %w", err) 84 + } 85 + 86 + return nil 87 + } 88 + 89 + func (h *Knot) processPull(ctx context.Context, event *models.Event) error { 90 + raw := json.RawMessage(event.Commit.Record) 91 + did := event.Did 92 + 93 + var record tangled.RepoPull 94 + if err := json.Unmarshal(raw, &record); err != nil { 95 + return fmt.Errorf("failed to unmarshal record: %w", err) 96 + } 97 + 98 + l := log.FromContext(ctx) 99 + l = l.With("handler", "processPull") 100 + l = l.With("did", did) 101 + 102 + if record.Target == nil { 103 + return fmt.Errorf("ignoring pull record: target repo is nil") 104 + } 105 + 106 + l = l.With("target_repo", record.Target.Repo) 107 + l = l.With("target_branch", record.Target.Branch) 108 + 109 + if record.Source == nil { 110 + return fmt.Errorf("ignoring pull record: not a branch-based pull request") 111 + } 112 + 113 + if record.Source.Repo != nil { 114 + return fmt.Errorf("ignoring pull record: fork based pull") 115 + } 116 + 117 + repoAt, err := syntax.ParseATURI(record.Target.Repo) 118 + if err != nil { 119 + return fmt.Errorf("failed to parse ATURI: %w", err) 120 + } 121 + 122 + // resolve this aturi to extract the repo record 123 + resolver := idresolver.DefaultResolver() 124 + ident, err := resolver.ResolveIdent(ctx, repoAt.Authority().String()) 125 + if err != nil || ident.Handle.IsInvalidHandle() { 126 + return fmt.Errorf("failed to resolve handle: %w", err) 127 + } 128 + 129 + xrpcc := xrpc.Client{ 130 + Host: ident.PDSEndpoint(), 131 + } 132 + 133 + resp, err := comatproto.RepoGetRecord(ctx, &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 134 + if err != nil { 135 + return fmt.Errorf("failed to resolver repo: %w", err) 136 + } 137 + 138 + repo := resp.Value.Val.(*tangled.Repo) 139 + 140 + if repo.Knot != h.c.Server.Hostname { 141 + return fmt.Errorf("rejected pull record: not this knot, %s != %s", repo.Knot, h.c.Server.Hostname) 142 + } 143 + 144 + didSlashRepo, err := securejoin.SecureJoin(repo.Owner, repo.Name) 145 + if err != nil { 146 + return fmt.Errorf("failed to construct relative repo path: %w", err) 147 + } 148 + 149 + repoPath, err := securejoin.SecureJoin(h.c.Repo.ScanPath, didSlashRepo) 150 + if err != nil { 151 + return fmt.Errorf("failed to construct absolute repo path: %w", err) 152 + } 153 + 154 + gr, err := git.Open(repoPath, record.Source.Branch) 155 + if err != nil { 156 + return fmt.Errorf("failed to open git repository: %w", err) 157 + } 158 + 159 + workflowDir, err := gr.FileTree(ctx, workflow.WorkflowDir) 160 + if err != nil { 161 + return fmt.Errorf("failed to open workflow directory: %w", err) 162 + } 163 + 164 + var pipeline workflow.RawPipeline 165 + for _, e := range workflowDir { 166 + if !e.IsFile { 167 + continue 168 + } 169 + 170 + fpath := filepath.Join(workflow.WorkflowDir, e.Name) 171 + contents, err := gr.RawContent(fpath) 172 + if err != nil { 173 + continue 174 + } 175 + 176 + pipeline = append(pipeline, workflow.RawWorkflow{ 177 + Name: e.Name, 178 + Contents: contents, 179 + }) 180 + } 181 + 182 + trigger := tangled.Pipeline_PullRequestTriggerData{ 183 + Action: "create", 184 + SourceBranch: record.Source.Branch, 185 + SourceSha: record.Source.Sha, 186 + TargetBranch: record.Target.Branch, 187 + } 188 + 189 + compiler := workflow.Compiler{ 190 + Trigger: tangled.Pipeline_TriggerMetadata{ 191 + Kind: string(workflow.TriggerKindPullRequest), 192 + PullRequest: &trigger, 193 + Repo: &tangled.Pipeline_TriggerRepo{ 194 + Did: repo.Owner, 195 + Knot: repo.Knot, 196 + Repo: repo.Name, 197 + }, 198 + }, 199 + } 200 + 201 + cp := compiler.Compile(compiler.Parse(pipeline)) 202 + eventJson, err := json.Marshal(cp) 203 + if err != nil { 204 + return fmt.Errorf("failed to marshal pipeline event: %w", err) 205 + } 206 + 207 + // do not run empty pipelines 208 + if cp.Workflows == nil { 209 + return nil 210 + } 211 + 212 + ev := db.Event{ 213 + Rkey: TID(), 214 + Nsid: tangled.PipelineNSID, 215 + EventJson: string(eventJson), 216 + } 217 + 218 + return h.db.InsertEvent(ev, h.n) 219 + } 220 + 221 + // duplicated from add collaborator 222 + func (h *Knot) processCollaborator(ctx context.Context, event *models.Event) error { 223 + raw := json.RawMessage(event.Commit.Record) 224 + did := event.Did 225 + 226 + var record tangled.RepoCollaborator 227 + if err := json.Unmarshal(raw, &record); err != nil { 228 + return fmt.Errorf("failed to unmarshal record: %w", err) 229 + } 230 + 231 + repoAt, err := syntax.ParseATURI(record.Repo) 232 + if err != nil { 233 + return err 234 + } 235 + 236 + resolver := idresolver.DefaultResolver() 237 + 238 + subjectId, err := resolver.ResolveIdent(ctx, record.Subject) 239 + if err != nil || subjectId.Handle.IsInvalidHandle() { 240 + return err 241 + } 242 + 243 + // TODO: fix this for good, we need to fetch the record here unfortunately 244 + // resolve this aturi to extract the repo record 245 + owner, err := resolver.ResolveIdent(ctx, repoAt.Authority().String()) 246 + if err != nil || owner.Handle.IsInvalidHandle() { 247 + return fmt.Errorf("failed to resolve handle: %w", err) 248 + } 249 + 250 + xrpcc := xrpc.Client{ 251 + Host: owner.PDSEndpoint(), 252 + } 253 + 254 + resp, err := comatproto.RepoGetRecord(ctx, &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 255 + if err != nil { 256 + return err 257 + } 258 + 259 + repo := resp.Value.Val.(*tangled.Repo) 260 + didSlashRepo, _ := securejoin.SecureJoin(owner.DID.String(), repo.Name) 261 + 262 + // check perms for this user 263 + ok, err := h.e.IsCollaboratorInviteAllowed(did, rbac.ThisServer, didSlashRepo) 264 + if err != nil { 265 + return fmt.Errorf("failed to check permissions: %w", err) 266 + } 267 + if !ok { 268 + return fmt.Errorf("insufficient permissions: %s, %s, %s", did, "IsCollaboratorInviteAllowed", didSlashRepo) 269 + } 270 + 271 + if err := h.db.AddDid(subjectId.DID.String()); err != nil { 272 + return err 273 + } 274 + h.jc.AddDid(subjectId.DID.String()) 275 + 276 + if err := h.e.AddCollaborator(subjectId.DID.String(), rbac.ThisServer, didSlashRepo); err != nil { 277 + return err 278 + } 279 + 280 + return h.fetchAndAddKeys(ctx, subjectId.DID.String()) 281 + } 282 + 283 + func (h *Knot) fetchAndAddKeys(ctx context.Context, did string) error { 284 + l := log.FromContext(ctx) 285 + 286 + keysEndpoint, err := url.JoinPath(h.c.AppViewEndpoint, "keys", did) 287 + if err != nil { 288 + l.Error("error building endpoint url", "did", did, "error", err.Error()) 289 + return fmt.Errorf("error building endpoint url: %w", err) 290 + } 291 + 292 + resp, err := http.Get(keysEndpoint) 293 + if err != nil { 294 + l.Error("error getting keys", "did", did, "error", err) 295 + return fmt.Errorf("error getting keys: %w", err) 296 + } 297 + defer resp.Body.Close() 298 + 299 + if resp.StatusCode == http.StatusNotFound { 300 + l.Info("no keys found for did", "did", did) 301 + return nil 302 + } 303 + 304 + plaintext, err := io.ReadAll(resp.Body) 305 + if err != nil { 306 + l.Error("error reading response body", "error", err) 307 + return fmt.Errorf("error reading response body: %w", err) 308 + } 309 + 310 + for key := range strings.SplitSeq(string(plaintext), "\n") { 311 + if key == "" { 312 + continue 313 + } 314 + pk := db.PublicKey{ 315 + Did: did, 316 + } 317 + pk.Key = key 318 + if err := h.db.AddPublicKey(pk); err != nil { 319 + l.Error("failed to add public key", "error", err) 320 + return fmt.Errorf("failed to add public key: %w", err) 321 + } 322 + } 323 + return nil 324 + } 325 + 326 + func (h *Knot) processMessages(ctx context.Context, event *models.Event) error { 327 + if event.Kind != models.EventKindCommit { 328 + return nil 329 + } 330 + 331 + var err error 332 + defer func() { 333 + eventTime := event.TimeUS 334 + lastTimeUs := eventTime + 1 335 + if err := h.db.SaveLastTimeUs(lastTimeUs); err != nil { 336 + err = fmt.Errorf("(deferred) failed to save last time us: %w", err) 337 + } 338 + }() 339 + 340 + switch event.Commit.Collection { 341 + case tangled.PublicKeyNSID: 342 + err = h.processPublicKey(ctx, event) 343 + case tangled.KnotMemberNSID: 344 + err = h.processKnotMember(ctx, event) 345 + case tangled.RepoPullNSID: 346 + err = h.processPull(ctx, event) 347 + case tangled.RepoCollaboratorNSID: 348 + err = h.processCollaborator(ctx, event) 349 + } 350 + 351 + if err != nil { 352 + h.l.Debug("failed to process event", "nsid", event.Commit.Collection, "err", err) 353 + } 354 + 355 + return nil 356 + }
+61 -24
knotserver/internal.go
··· 3 3 import ( 4 4 "context" 5 5 "encoding/json" 6 + "errors" 7 + "fmt" 6 8 "log/slog" 7 9 "net/http" 8 10 "path/filepath" ··· 12 14 "github.com/go-chi/chi/v5" 13 15 "github.com/go-chi/chi/v5/middleware" 14 16 "tangled.sh/tangled.sh/core/api/tangled" 17 + "tangled.sh/tangled.sh/core/hook" 15 18 "tangled.sh/tangled.sh/core/knotserver/config" 16 19 "tangled.sh/tangled.sh/core/knotserver/db" 17 20 "tangled.sh/tangled.sh/core/knotserver/git" ··· 37 40 return 38 41 } 39 42 40 - ok, err := h.e.IsPushAllowed(user, ThisServer, repo) 43 + ok, err := h.e.IsPushAllowed(user, rbac.ThisServer, repo) 41 44 if err != nil || !ok { 42 45 w.WriteHeader(http.StatusForbidden) 43 46 return 44 47 } 45 48 46 49 w.WriteHeader(http.StatusNoContent) 47 - return 48 50 } 49 51 50 52 func (h *InternalHandle) InternalKeys(w http.ResponseWriter, r *http.Request) { ··· 60 62 data = append(data, j) 61 63 } 62 64 writeJSON(w, data) 63 - return 65 + } 66 + 67 + type PushOptions struct { 68 + skipCi bool 69 + verboseCi bool 64 70 } 65 71 66 72 func (h *InternalHandle) PostReceiveHook(w http.ResponseWriter, r *http.Request) { ··· 89 95 // non-fatal 90 96 } 91 97 98 + // extract any push options 99 + pushOptionsRaw := r.Header.Values("X-Git-Push-Option") 100 + pushOptions := PushOptions{} 101 + for _, option := range pushOptionsRaw { 102 + if option == "skip-ci" || option == "ci-skip" { 103 + pushOptions.skipCi = true 104 + } 105 + if option == "verbose-ci" || option == "ci-verbose" { 106 + pushOptions.verboseCi = true 107 + } 108 + } 109 + 110 + resp := hook.HookResponse{ 111 + Messages: make([]string, 0), 112 + } 113 + 92 114 for _, line := range lines { 93 115 err := h.insertRefUpdate(line, gitUserDid, repoDid, repoName) 94 116 if err != nil { ··· 96 118 // non-fatal 97 119 } 98 120 99 - err = h.triggerPipeline(line, gitUserDid, repoDid, repoName) 121 + err = h.triggerPipeline(&resp.Messages, line, gitUserDid, repoDid, repoName, pushOptions) 100 122 if err != nil { 101 123 l.Error("failed to trigger pipeline", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir) 102 124 // non-fatal 103 125 } 104 126 } 127 + 128 + writeJSON(w, resp) 105 129 } 106 130 107 131 func (h *InternalHandle) insertRefUpdate(line git.PostReceiveLine, gitUserDid, repoDid, repoName string) error { ··· 115 139 return err 116 140 } 117 141 118 - gr, err := git.PlainOpen(repoPath) 142 + gr, err := git.Open(repoPath, line.Ref) 119 143 if err != nil { 120 - return err 144 + return fmt.Errorf("failed to open git repo at ref %s: %w", line.Ref, err) 121 145 } 122 146 123 - meta := gr.RefUpdateMeta(line) 147 + var errs error 148 + meta, err := gr.RefUpdateMeta(line) 149 + errors.Join(errs, err) 150 + 124 151 metaRecord := meta.AsRecord() 125 152 126 153 refUpdate := tangled.GitRefUpdate{ ··· 143 170 EventJson: string(eventJson), 144 171 } 145 172 146 - return h.db.InsertEvent(event, h.n) 173 + return errors.Join(errs, h.db.InsertEvent(event, h.n)) 147 174 } 148 175 149 - func (h *InternalHandle) triggerPipeline(line git.PostReceiveLine, gitUserDid, repoDid, repoName string) error { 150 - const ( 151 - WorkflowDir = ".tangled/workflows" 152 - ) 176 + func (h *InternalHandle) triggerPipeline(clientMsgs *[]string, line git.PostReceiveLine, gitUserDid, repoDid, repoName string, pushOptions PushOptions) error { 177 + if pushOptions.skipCi { 178 + return nil 179 + } 153 180 154 181 didSlashRepo, err := securejoin.SecureJoin(repoDid, repoName) 155 182 if err != nil { ··· 166 193 return err 167 194 } 168 195 169 - workflowDir, err := gr.FileTree(context.Background(), WorkflowDir) 196 + workflowDir, err := gr.FileTree(context.Background(), workflow.WorkflowDir) 170 197 if err != nil { 171 198 return err 172 199 } 173 200 174 - var pipeline workflow.Pipeline 201 + var pipeline workflow.RawPipeline 175 202 for _, e := range workflowDir { 176 203 if !e.IsFile { 177 204 continue 178 205 } 179 206 180 - fpath := filepath.Join(WorkflowDir, e.Name) 207 + fpath := filepath.Join(workflow.WorkflowDir, e.Name) 181 208 contents, err := gr.RawContent(fpath) 182 209 if err != nil { 183 210 continue 184 211 } 185 212 186 - wf, err := workflow.FromFile(e.Name, contents) 187 - if err != nil { 188 - // TODO: log here, respond to client that is pushing 189 - continue 190 - } 191 - 192 - pipeline = append(pipeline, wf) 213 + pipeline = append(pipeline, workflow.RawWorkflow{ 214 + Name: e.Name, 215 + Contents: contents, 216 + }) 193 217 } 194 218 195 219 trigger := tangled.Pipeline_PushTriggerData{ ··· 210 234 }, 211 235 } 212 236 213 - // TODO: send the diagnostics back to the user here via stderr 214 - cp := compiler.Compile(pipeline) 237 + cp := compiler.Compile(compiler.Parse(pipeline)) 215 238 eventJson, err := json.Marshal(cp) 216 239 if err != nil { 217 240 return err 241 + } 242 + 243 + for _, e := range compiler.Diagnostics.Errors { 244 + *clientMsgs = append(*clientMsgs, e.String()) 245 + } 246 + 247 + if pushOptions.verboseCi { 248 + if compiler.Diagnostics.IsEmpty() { 249 + *clientMsgs = append(*clientMsgs, "success: pipeline compiled with no diagnostics") 250 + } 251 + 252 + for _, w := range compiler.Diagnostics.Warnings { 253 + *clientMsgs = append(*clientMsgs, w.String()) 254 + } 218 255 } 219 256 220 257 // do not run empty pipelines
-147
knotserver/jetstream.go
··· 1 - package knotserver 2 - 3 - import ( 4 - "context" 5 - "encoding/json" 6 - "fmt" 7 - "io" 8 - "net/http" 9 - "net/url" 10 - "strings" 11 - 12 - "github.com/bluesky-social/jetstream/pkg/models" 13 - "tangled.sh/tangled.sh/core/api/tangled" 14 - "tangled.sh/tangled.sh/core/knotserver/db" 15 - "tangled.sh/tangled.sh/core/log" 16 - ) 17 - 18 - func (h *Handle) processPublicKey(ctx context.Context, did string, record tangled.PublicKey) error { 19 - l := log.FromContext(ctx) 20 - pk := db.PublicKey{ 21 - Did: did, 22 - PublicKey: record, 23 - } 24 - if err := h.db.AddPublicKey(pk); err != nil { 25 - l.Error("failed to add public key", "error", err) 26 - return fmt.Errorf("failed to add public key: %w", err) 27 - } 28 - l.Info("added public key from firehose", "did", did) 29 - return nil 30 - } 31 - 32 - func (h *Handle) processKnotMember(ctx context.Context, did string, record tangled.KnotMember) error { 33 - l := log.FromContext(ctx) 34 - 35 - if record.Domain != h.c.Server.Hostname { 36 - l.Error("domain mismatch", "domain", record.Domain, "expected", h.c.Server.Hostname) 37 - return fmt.Errorf("domain mismatch: %s != %s", record.Domain, h.c.Server.Hostname) 38 - } 39 - 40 - ok, err := h.e.E.Enforce(did, ThisServer, ThisServer, "server:invite") 41 - if err != nil || !ok { 42 - l.Error("failed to add member", "did", did) 43 - return fmt.Errorf("failed to enforce permissions: %w", err) 44 - } 45 - 46 - if err := h.e.AddKnotMember(ThisServer, record.Subject); err != nil { 47 - l.Error("failed to add member", "error", err) 48 - return fmt.Errorf("failed to add member: %w", err) 49 - } 50 - l.Info("added member from firehose", "member", record.Subject) 51 - 52 - if err := h.db.AddDid(did); err != nil { 53 - l.Error("failed to add did", "error", err) 54 - return fmt.Errorf("failed to add did: %w", err) 55 - } 56 - h.jc.AddDid(did) 57 - 58 - if err := h.fetchAndAddKeys(ctx, did); err != nil { 59 - return fmt.Errorf("failed to fetch and add keys: %w", err) 60 - } 61 - 62 - return nil 63 - } 64 - 65 - func (h *Handle) fetchAndAddKeys(ctx context.Context, did string) error { 66 - l := log.FromContext(ctx) 67 - 68 - keysEndpoint, err := url.JoinPath(h.c.AppViewEndpoint, "keys", did) 69 - if err != nil { 70 - l.Error("error building endpoint url", "did", did, "error", err.Error()) 71 - return fmt.Errorf("error building endpoint url: %w", err) 72 - } 73 - 74 - resp, err := http.Get(keysEndpoint) 75 - if err != nil { 76 - l.Error("error getting keys", "did", did, "error", err) 77 - return fmt.Errorf("error getting keys: %w", err) 78 - } 79 - defer resp.Body.Close() 80 - 81 - if resp.StatusCode == http.StatusNotFound { 82 - l.Info("no keys found for did", "did", did) 83 - return nil 84 - } 85 - 86 - plaintext, err := io.ReadAll(resp.Body) 87 - if err != nil { 88 - l.Error("error reading response body", "error", err) 89 - return fmt.Errorf("error reading response body: %w", err) 90 - } 91 - 92 - for _, key := range strings.Split(string(plaintext), "\n") { 93 - if key == "" { 94 - continue 95 - } 96 - pk := db.PublicKey{ 97 - Did: did, 98 - } 99 - pk.Key = key 100 - if err := h.db.AddPublicKey(pk); err != nil { 101 - l.Error("failed to add public key", "error", err) 102 - return fmt.Errorf("failed to add public key: %w", err) 103 - } 104 - } 105 - return nil 106 - } 107 - 108 - func (h *Handle) processMessages(ctx context.Context, event *models.Event) error { 109 - did := event.Did 110 - if event.Kind != models.EventKindCommit { 111 - return nil 112 - } 113 - 114 - var err error 115 - defer func() { 116 - eventTime := event.TimeUS 117 - lastTimeUs := eventTime + 1 118 - fmt.Println("lastTimeUs", lastTimeUs) 119 - if err := h.db.SaveLastTimeUs(lastTimeUs); err != nil { 120 - err = fmt.Errorf("(deferred) failed to save last time us: %w", err) 121 - } 122 - }() 123 - 124 - raw := json.RawMessage(event.Commit.Record) 125 - 126 - switch event.Commit.Collection { 127 - case tangled.PublicKeyNSID: 128 - var record tangled.PublicKey 129 - if err := json.Unmarshal(raw, &record); err != nil { 130 - return fmt.Errorf("failed to unmarshal record: %w", err) 131 - } 132 - if err := h.processPublicKey(ctx, did, record); err != nil { 133 - return fmt.Errorf("failed to process public key: %w", err) 134 - } 135 - 136 - case tangled.KnotMemberNSID: 137 - var record tangled.KnotMember 138 - if err := json.Unmarshal(raw, &record); err != nil { 139 - return fmt.Errorf("failed to unmarshal record: %w", err) 140 - } 141 - if err := h.processKnotMember(ctx, did, record); err != nil { 142 - return fmt.Errorf("failed to process knot member: %w", err) 143 - } 144 - } 145 - 146 - return err 147 - }
-53
knotserver/middleware.go
··· 1 - package knotserver 2 - 3 - import ( 4 - "crypto/hmac" 5 - "crypto/sha256" 6 - "encoding/hex" 7 - "net/http" 8 - "time" 9 - ) 10 - 11 - func (h *Handle) VerifySignature(next http.Handler) http.Handler { 12 - if h.c.Server.Dev { 13 - return next 14 - } 15 - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 16 - signature := r.Header.Get("X-Signature") 17 - if signature == "" || !h.verifyHMAC(signature, r) { 18 - writeError(w, "signature verification failed", http.StatusForbidden) 19 - return 20 - } 21 - next.ServeHTTP(w, r) 22 - }) 23 - } 24 - 25 - func (h *Handle) verifyHMAC(signature string, r *http.Request) bool { 26 - secret := h.c.Server.Secret 27 - timestamp := r.Header.Get("X-Timestamp") 28 - if timestamp == "" { 29 - return false 30 - } 31 - 32 - // Verify that the timestamp is not older than a minute 33 - reqTime, err := time.Parse(time.RFC3339, timestamp) 34 - if err != nil { 35 - return false 36 - } 37 - if time.Since(reqTime) > time.Minute { 38 - return false 39 - } 40 - 41 - message := r.Method + r.URL.Path + timestamp 42 - 43 - mac := hmac.New(sha256.New, []byte(secret)) 44 - mac.Write([]byte(message)) 45 - expectedMAC := mac.Sum(nil) 46 - 47 - signatureBytes, err := hex.DecodeString(signature) 48 - if err != nil { 49 - return false 50 - } 51 - 52 - return hmac.Equal(signatureBytes, expectedMAC) 53 - }
+152
knotserver/router.go
··· 1 + package knotserver 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "log/slog" 7 + "net/http" 8 + 9 + "github.com/go-chi/chi/v5" 10 + "tangled.sh/tangled.sh/core/idresolver" 11 + "tangled.sh/tangled.sh/core/jetstream" 12 + "tangled.sh/tangled.sh/core/knotserver/config" 13 + "tangled.sh/tangled.sh/core/knotserver/db" 14 + "tangled.sh/tangled.sh/core/knotserver/xrpc" 15 + tlog "tangled.sh/tangled.sh/core/log" 16 + "tangled.sh/tangled.sh/core/notifier" 17 + "tangled.sh/tangled.sh/core/rbac" 18 + "tangled.sh/tangled.sh/core/xrpc/serviceauth" 19 + ) 20 + 21 + type Knot struct { 22 + c *config.Config 23 + db *db.DB 24 + jc *jetstream.JetstreamClient 25 + e *rbac.Enforcer 26 + l *slog.Logger 27 + n *notifier.Notifier 28 + resolver *idresolver.Resolver 29 + } 30 + 31 + func Setup(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, jc *jetstream.JetstreamClient, l *slog.Logger, n *notifier.Notifier) (http.Handler, error) { 32 + r := chi.NewRouter() 33 + 34 + h := Knot{ 35 + c: c, 36 + db: db, 37 + e: e, 38 + l: l, 39 + jc: jc, 40 + n: n, 41 + resolver: idresolver.DefaultResolver(), 42 + } 43 + 44 + err := e.AddKnot(rbac.ThisServer) 45 + if err != nil { 46 + return nil, fmt.Errorf("failed to setup enforcer: %w", err) 47 + } 48 + 49 + // configure owner 50 + if err = h.configureOwner(); err != nil { 51 + return nil, err 52 + } 53 + h.l.Info("owner set", "did", h.c.Server.Owner) 54 + h.jc.AddDid(h.c.Server.Owner) 55 + 56 + // configure known-dids in jetstream consumer 57 + dids, err := h.db.GetAllDids() 58 + if err != nil { 59 + return nil, fmt.Errorf("failed to get all dids: %w", err) 60 + } 61 + for _, d := range dids { 62 + jc.AddDid(d) 63 + } 64 + 65 + err = h.jc.StartJetstream(ctx, h.processMessages) 66 + if err != nil { 67 + return nil, fmt.Errorf("failed to start jetstream: %w", err) 68 + } 69 + 70 + r.Get("/", func(w http.ResponseWriter, r *http.Request) { 71 + w.Write([]byte("This is a knot server. More info at https://tangled.sh")) 72 + }) 73 + 74 + r.Route("/{did}", func(r chi.Router) { 75 + r.Route("/{name}", func(r chi.Router) { 76 + // routes for git operations 77 + r.Get("/info/refs", h.InfoRefs) 78 + r.Post("/git-upload-pack", h.UploadPack) 79 + r.Post("/git-receive-pack", h.ReceivePack) 80 + }) 81 + }) 82 + 83 + // xrpc apis 84 + r.Mount("/xrpc", h.XrpcRouter()) 85 + 86 + // Socket that streams git oplogs 87 + r.Get("/events", h.Events) 88 + 89 + return r, nil 90 + } 91 + 92 + func (h *Knot) XrpcRouter() http.Handler { 93 + logger := tlog.New("knots") 94 + 95 + serviceAuth := serviceauth.NewServiceAuth(h.l, h.resolver, h.c.Server.Did().String()) 96 + 97 + xrpc := &xrpc.Xrpc{ 98 + Config: h.c, 99 + Db: h.db, 100 + Ingester: h.jc, 101 + Enforcer: h.e, 102 + Logger: logger, 103 + Notifier: h.n, 104 + Resolver: h.resolver, 105 + ServiceAuth: serviceAuth, 106 + } 107 + return xrpc.Router() 108 + } 109 + 110 + func (h *Knot) configureOwner() error { 111 + cfgOwner := h.c.Server.Owner 112 + 113 + rbacDomain := "thisserver" 114 + 115 + existing, err := h.e.GetKnotUsersByRole("server:owner", rbacDomain) 116 + if err != nil { 117 + return err 118 + } 119 + 120 + switch len(existing) { 121 + case 0: 122 + // no owner configured, continue 123 + case 1: 124 + // find existing owner 125 + existingOwner := existing[0] 126 + 127 + // no ownership change, this is okay 128 + if existingOwner == h.c.Server.Owner { 129 + break 130 + } 131 + 132 + // remove existing owner 133 + if err = h.db.RemoveDid(existingOwner); err != nil { 134 + return err 135 + } 136 + if err = h.e.RemoveKnotOwner(rbacDomain, existingOwner); err != nil { 137 + return err 138 + } 139 + 140 + default: 141 + return fmt.Errorf("more than one owner in DB, try deleting %q and starting over", h.c.Server.DBPath) 142 + } 143 + 144 + if err = h.db.AddDid(cfgOwner); err != nil { 145 + return fmt.Errorf("failed to add owner to DB: %w", err) 146 + } 147 + if err := h.e.AddKnotOwner(rbacDomain, cfgOwner); err != nil { 148 + return fmt.Errorf("failed to add owner to RBAC: %w", err) 149 + } 150 + 151 + return nil 152 + }
-1370
knotserver/routes.go
··· 1 - package knotserver 2 - 3 - import ( 4 - "compress/gzip" 5 - "context" 6 - "crypto/hmac" 7 - "crypto/sha256" 8 - "encoding/hex" 9 - "encoding/json" 10 - "errors" 11 - "fmt" 12 - "log" 13 - "net/http" 14 - "net/url" 15 - "os" 16 - "path" 17 - "path/filepath" 18 - "strconv" 19 - "strings" 20 - "sync" 21 - 22 - securejoin "github.com/cyphar/filepath-securejoin" 23 - "github.com/gliderlabs/ssh" 24 - "github.com/go-chi/chi/v5" 25 - "github.com/go-enry/go-enry/v2" 26 - gogit "github.com/go-git/go-git/v5" 27 - "github.com/go-git/go-git/v5/plumbing" 28 - "github.com/go-git/go-git/v5/plumbing/object" 29 - "tangled.sh/tangled.sh/core/hook" 30 - "tangled.sh/tangled.sh/core/knotserver/db" 31 - "tangled.sh/tangled.sh/core/knotserver/git" 32 - "tangled.sh/tangled.sh/core/patchutil" 33 - "tangled.sh/tangled.sh/core/types" 34 - ) 35 - 36 - func (h *Handle) Index(w http.ResponseWriter, r *http.Request) { 37 - w.Write([]byte("This is a knot server. More info at https://tangled.sh")) 38 - } 39 - 40 - func (h *Handle) Capabilities(w http.ResponseWriter, r *http.Request) { 41 - w.Header().Set("Content-Type", "application/json") 42 - 43 - capabilities := map[string]any{ 44 - "pull_requests": map[string]any{ 45 - "format_patch": true, 46 - "patch_submissions": true, 47 - "branch_submissions": true, 48 - "fork_submissions": true, 49 - }, 50 - } 51 - 52 - jsonData, err := json.Marshal(capabilities) 53 - if err != nil { 54 - http.Error(w, "Failed to serialize JSON", http.StatusInternalServerError) 55 - return 56 - } 57 - 58 - w.Write(jsonData) 59 - } 60 - 61 - func (h *Handle) RepoIndex(w http.ResponseWriter, r *http.Request) { 62 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 63 - l := h.l.With("path", path, "handler", "RepoIndex") 64 - ref := chi.URLParam(r, "ref") 65 - ref, _ = url.PathUnescape(ref) 66 - 67 - gr, err := git.Open(path, ref) 68 - if err != nil { 69 - plain, err2 := git.PlainOpen(path) 70 - if err2 != nil { 71 - l.Error("opening repo", "error", err2.Error()) 72 - notFound(w) 73 - return 74 - } 75 - branches, _ := plain.Branches() 76 - 77 - log.Println(err) 78 - 79 - if errors.Is(err, plumbing.ErrReferenceNotFound) { 80 - resp := types.RepoIndexResponse{ 81 - IsEmpty: true, 82 - Branches: branches, 83 - } 84 - writeJSON(w, resp) 85 - return 86 - } else { 87 - l.Error("opening repo", "error", err.Error()) 88 - notFound(w) 89 - return 90 - } 91 - } 92 - 93 - var ( 94 - commits []*object.Commit 95 - total int 96 - branches []types.Branch 97 - files []types.NiceTree 98 - tags []*git.TagReference 99 - ) 100 - 101 - var wg sync.WaitGroup 102 - errorsCh := make(chan error, 5) 103 - 104 - wg.Add(1) 105 - go func() { 106 - defer wg.Done() 107 - cs, err := gr.Commits(0, 60) 108 - if err != nil { 109 - errorsCh <- fmt.Errorf("commits: %w", err) 110 - return 111 - } 112 - commits = cs 113 - }() 114 - 115 - wg.Add(1) 116 - go func() { 117 - defer wg.Done() 118 - t, err := gr.TotalCommits() 119 - if err != nil { 120 - errorsCh <- fmt.Errorf("calculating total: %w", err) 121 - return 122 - } 123 - total = t 124 - }() 125 - 126 - wg.Add(1) 127 - go func() { 128 - defer wg.Done() 129 - bs, err := gr.Branches() 130 - if err != nil { 131 - errorsCh <- fmt.Errorf("fetching branches: %w", err) 132 - return 133 - } 134 - branches = bs 135 - }() 136 - 137 - wg.Add(1) 138 - go func() { 139 - defer wg.Done() 140 - ts, err := gr.Tags() 141 - if err != nil { 142 - errorsCh <- fmt.Errorf("fetching tags: %w", err) 143 - return 144 - } 145 - tags = ts 146 - }() 147 - 148 - wg.Add(1) 149 - go func() { 150 - defer wg.Done() 151 - fs, err := gr.FileTree(r.Context(), "") 152 - if err != nil { 153 - errorsCh <- fmt.Errorf("fetching filetree: %w", err) 154 - return 155 - } 156 - files = fs 157 - }() 158 - 159 - wg.Wait() 160 - close(errorsCh) 161 - 162 - // show any errors 163 - for err := range errorsCh { 164 - l.Error("loading repo", "error", err.Error()) 165 - writeError(w, err.Error(), http.StatusInternalServerError) 166 - return 167 - } 168 - 169 - rtags := []*types.TagReference{} 170 - for _, tag := range tags { 171 - tr := types.TagReference{ 172 - Tag: tag.TagObject(), 173 - } 174 - 175 - tr.Reference = types.Reference{ 176 - Name: tag.Name(), 177 - Hash: tag.Hash().String(), 178 - } 179 - 180 - if tag.Message() != "" { 181 - tr.Message = tag.Message() 182 - } 183 - 184 - rtags = append(rtags, &tr) 185 - } 186 - 187 - var readmeContent string 188 - var readmeFile string 189 - for _, readme := range h.c.Repo.Readme { 190 - content, _ := gr.FileContent(readme) 191 - if len(content) > 0 { 192 - readmeContent = string(content) 193 - readmeFile = readme 194 - } 195 - } 196 - 197 - if ref == "" { 198 - mainBranch, err := gr.FindMainBranch() 199 - if err != nil { 200 - writeError(w, err.Error(), http.StatusInternalServerError) 201 - l.Error("finding main branch", "error", err.Error()) 202 - return 203 - } 204 - ref = mainBranch 205 - } 206 - 207 - resp := types.RepoIndexResponse{ 208 - IsEmpty: false, 209 - Ref: ref, 210 - Commits: commits, 211 - Description: getDescription(path), 212 - Readme: readmeContent, 213 - ReadmeFileName: readmeFile, 214 - Files: files, 215 - Branches: branches, 216 - Tags: rtags, 217 - TotalCommits: total, 218 - } 219 - 220 - writeJSON(w, resp) 221 - return 222 - } 223 - 224 - func (h *Handle) RepoTree(w http.ResponseWriter, r *http.Request) { 225 - treePath := chi.URLParam(r, "*") 226 - ref := chi.URLParam(r, "ref") 227 - ref, _ = url.PathUnescape(ref) 228 - 229 - l := h.l.With("handler", "RepoTree", "ref", ref, "treePath", treePath) 230 - 231 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 232 - gr, err := git.Open(path, ref) 233 - if err != nil { 234 - notFound(w) 235 - return 236 - } 237 - 238 - files, err := gr.FileTree(r.Context(), treePath) 239 - if err != nil { 240 - writeError(w, err.Error(), http.StatusInternalServerError) 241 - l.Error("file tree", "error", err.Error()) 242 - return 243 - } 244 - 245 - resp := types.RepoTreeResponse{ 246 - Ref: ref, 247 - Parent: treePath, 248 - Description: getDescription(path), 249 - DotDot: filepath.Dir(treePath), 250 - Files: files, 251 - } 252 - 253 - writeJSON(w, resp) 254 - return 255 - } 256 - 257 - func (h *Handle) BlobRaw(w http.ResponseWriter, r *http.Request) { 258 - treePath := chi.URLParam(r, "*") 259 - ref := chi.URLParam(r, "ref") 260 - ref, _ = url.PathUnescape(ref) 261 - 262 - l := h.l.With("handler", "BlobRaw", "ref", ref, "treePath", treePath) 263 - 264 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 265 - gr, err := git.Open(path, ref) 266 - if err != nil { 267 - notFound(w) 268 - return 269 - } 270 - 271 - contents, err := gr.RawContent(treePath) 272 - if err != nil { 273 - writeError(w, err.Error(), http.StatusBadRequest) 274 - l.Error("file content", "error", err.Error()) 275 - return 276 - } 277 - 278 - mimeType := http.DetectContentType(contents) 279 - 280 - // exception for svg 281 - if filepath.Ext(treePath) == ".svg" { 282 - mimeType = "image/svg+xml" 283 - } 284 - 285 - if !strings.HasPrefix(mimeType, "image/") && !strings.HasPrefix(mimeType, "video/") { 286 - l.Error("attempted to serve non-image/video file", "mimetype", mimeType) 287 - writeError(w, "only image and video files can be accessed directly", http.StatusForbidden) 288 - return 289 - } 290 - 291 - w.Header().Set("Cache-Control", "public, max-age=86400") // cache for 24 hours 292 - w.Header().Set("ETag", fmt.Sprintf("%x", sha256.Sum256(contents))) 293 - w.Header().Set("Content-Type", mimeType) 294 - w.Write(contents) 295 - } 296 - 297 - func (h *Handle) Blob(w http.ResponseWriter, r *http.Request) { 298 - treePath := chi.URLParam(r, "*") 299 - ref := chi.URLParam(r, "ref") 300 - ref, _ = url.PathUnescape(ref) 301 - 302 - l := h.l.With("handler", "Blob", "ref", ref, "treePath", treePath) 303 - 304 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 305 - gr, err := git.Open(path, ref) 306 - if err != nil { 307 - notFound(w) 308 - return 309 - } 310 - 311 - var isBinaryFile bool = false 312 - contents, err := gr.FileContent(treePath) 313 - if errors.Is(err, git.ErrBinaryFile) { 314 - isBinaryFile = true 315 - } else if errors.Is(err, object.ErrFileNotFound) { 316 - notFound(w) 317 - return 318 - } else if err != nil { 319 - writeError(w, err.Error(), http.StatusInternalServerError) 320 - return 321 - } 322 - 323 - bytes := []byte(contents) 324 - // safe := string(sanitize(bytes)) 325 - sizeHint := len(bytes) 326 - 327 - resp := types.RepoBlobResponse{ 328 - Ref: ref, 329 - Contents: string(bytes), 330 - Path: treePath, 331 - IsBinary: isBinaryFile, 332 - SizeHint: uint64(sizeHint), 333 - } 334 - 335 - h.showFile(resp, w, l) 336 - } 337 - 338 - func (h *Handle) Archive(w http.ResponseWriter, r *http.Request) { 339 - name := chi.URLParam(r, "name") 340 - file := chi.URLParam(r, "file") 341 - 342 - l := h.l.With("handler", "Archive", "name", name, "file", file) 343 - 344 - // TODO: extend this to add more files compression (e.g.: xz) 345 - if !strings.HasSuffix(file, ".tar.gz") { 346 - notFound(w) 347 - return 348 - } 349 - 350 - ref := strings.TrimSuffix(file, ".tar.gz") 351 - 352 - // This allows the browser to use a proper name for the file when 353 - // downloading 354 - filename := fmt.Sprintf("%s-%s.tar.gz", name, ref) 355 - setContentDisposition(w, filename) 356 - setGZipMIME(w) 357 - 358 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 359 - gr, err := git.Open(path, ref) 360 - if err != nil { 361 - notFound(w) 362 - return 363 - } 364 - 365 - gw := gzip.NewWriter(w) 366 - defer gw.Close() 367 - 368 - prefix := fmt.Sprintf("%s-%s", name, ref) 369 - err = gr.WriteTar(gw, prefix) 370 - if err != nil { 371 - // once we start writing to the body we can't report error anymore 372 - // so we are only left with printing the error. 373 - l.Error("writing tar file", "error", err.Error()) 374 - return 375 - } 376 - 377 - err = gw.Flush() 378 - if err != nil { 379 - // once we start writing to the body we can't report error anymore 380 - // so we are only left with printing the error. 381 - l.Error("flushing?", "error", err.Error()) 382 - return 383 - } 384 - } 385 - 386 - func (h *Handle) Log(w http.ResponseWriter, r *http.Request) { 387 - ref := chi.URLParam(r, "ref") 388 - ref, _ = url.PathUnescape(ref) 389 - 390 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 391 - 392 - l := h.l.With("handler", "Log", "ref", ref, "path", path) 393 - 394 - gr, err := git.Open(path, ref) 395 - if err != nil { 396 - notFound(w) 397 - return 398 - } 399 - 400 - // Get page parameters 401 - page := 1 402 - pageSize := 30 403 - 404 - if pageParam := r.URL.Query().Get("page"); pageParam != "" { 405 - if p, err := strconv.Atoi(pageParam); err == nil && p > 0 { 406 - page = p 407 - } 408 - } 409 - 410 - if pageSizeParam := r.URL.Query().Get("per_page"); pageSizeParam != "" { 411 - if ps, err := strconv.Atoi(pageSizeParam); err == nil && ps > 0 { 412 - pageSize = ps 413 - } 414 - } 415 - 416 - // convert to offset/limit 417 - offset := (page - 1) * pageSize 418 - limit := pageSize 419 - 420 - commits, err := gr.Commits(offset, limit) 421 - if err != nil { 422 - writeError(w, err.Error(), http.StatusInternalServerError) 423 - l.Error("fetching commits", "error", err.Error()) 424 - return 425 - } 426 - 427 - total := len(commits) 428 - 429 - resp := types.RepoLogResponse{ 430 - Commits: commits, 431 - Ref: ref, 432 - Description: getDescription(path), 433 - Log: true, 434 - Total: total, 435 - Page: page, 436 - PerPage: pageSize, 437 - } 438 - 439 - writeJSON(w, resp) 440 - return 441 - } 442 - 443 - func (h *Handle) Diff(w http.ResponseWriter, r *http.Request) { 444 - ref := chi.URLParam(r, "ref") 445 - ref, _ = url.PathUnescape(ref) 446 - 447 - l := h.l.With("handler", "Diff", "ref", ref) 448 - 449 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 450 - gr, err := git.Open(path, ref) 451 - if err != nil { 452 - notFound(w) 453 - return 454 - } 455 - 456 - diff, err := gr.Diff() 457 - if err != nil { 458 - writeError(w, err.Error(), http.StatusInternalServerError) 459 - l.Error("getting diff", "error", err.Error()) 460 - return 461 - } 462 - 463 - resp := types.RepoCommitResponse{ 464 - Ref: ref, 465 - Diff: diff, 466 - } 467 - 468 - writeJSON(w, resp) 469 - return 470 - } 471 - 472 - func (h *Handle) Tags(w http.ResponseWriter, r *http.Request) { 473 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 474 - l := h.l.With("handler", "Refs") 475 - 476 - gr, err := git.Open(path, "") 477 - if err != nil { 478 - notFound(w) 479 - return 480 - } 481 - 482 - tags, err := gr.Tags() 483 - if err != nil { 484 - // Non-fatal, we *should* have at least one branch to show. 485 - l.Warn("getting tags", "error", err.Error()) 486 - } 487 - 488 - rtags := []*types.TagReference{} 489 - for _, tag := range tags { 490 - tr := types.TagReference{ 491 - Tag: tag.TagObject(), 492 - } 493 - 494 - tr.Reference = types.Reference{ 495 - Name: tag.Name(), 496 - Hash: tag.Hash().String(), 497 - } 498 - 499 - if tag.Message() != "" { 500 - tr.Message = tag.Message() 501 - } 502 - 503 - rtags = append(rtags, &tr) 504 - } 505 - 506 - resp := types.RepoTagsResponse{ 507 - Tags: rtags, 508 - } 509 - 510 - writeJSON(w, resp) 511 - return 512 - } 513 - 514 - func (h *Handle) Branches(w http.ResponseWriter, r *http.Request) { 515 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 516 - 517 - gr, err := git.PlainOpen(path) 518 - if err != nil { 519 - notFound(w) 520 - return 521 - } 522 - 523 - branches, _ := gr.Branches() 524 - 525 - resp := types.RepoBranchesResponse{ 526 - Branches: branches, 527 - } 528 - 529 - writeJSON(w, resp) 530 - return 531 - } 532 - 533 - func (h *Handle) Branch(w http.ResponseWriter, r *http.Request) { 534 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 535 - branchName := chi.URLParam(r, "branch") 536 - branchName, _ = url.PathUnescape(branchName) 537 - 538 - l := h.l.With("handler", "Branch") 539 - 540 - gr, err := git.PlainOpen(path) 541 - if err != nil { 542 - notFound(w) 543 - return 544 - } 545 - 546 - ref, err := gr.Branch(branchName) 547 - if err != nil { 548 - l.Error("getting branch", "error", err.Error()) 549 - writeError(w, err.Error(), http.StatusInternalServerError) 550 - return 551 - } 552 - 553 - commit, err := gr.Commit(ref.Hash()) 554 - if err != nil { 555 - l.Error("getting commit object", "error", err.Error()) 556 - writeError(w, err.Error(), http.StatusInternalServerError) 557 - return 558 - } 559 - 560 - defaultBranch, err := gr.FindMainBranch() 561 - isDefault := false 562 - if err != nil { 563 - l.Error("getting default branch", "error", err.Error()) 564 - // do not quit though 565 - } else if defaultBranch == branchName { 566 - isDefault = true 567 - } 568 - 569 - resp := types.RepoBranchResponse{ 570 - Branch: types.Branch{ 571 - Reference: types.Reference{ 572 - Name: ref.Name().Short(), 573 - Hash: ref.Hash().String(), 574 - }, 575 - Commit: commit, 576 - IsDefault: isDefault, 577 - }, 578 - } 579 - 580 - writeJSON(w, resp) 581 - return 582 - } 583 - 584 - func (h *Handle) Keys(w http.ResponseWriter, r *http.Request) { 585 - l := h.l.With("handler", "Keys") 586 - 587 - switch r.Method { 588 - case http.MethodGet: 589 - keys, err := h.db.GetAllPublicKeys() 590 - if err != nil { 591 - writeError(w, err.Error(), http.StatusInternalServerError) 592 - l.Error("getting public keys", "error", err.Error()) 593 - return 594 - } 595 - 596 - data := make([]map[string]any, 0) 597 - for _, key := range keys { 598 - j := key.JSON() 599 - data = append(data, j) 600 - } 601 - writeJSON(w, data) 602 - return 603 - 604 - case http.MethodPut: 605 - pk := db.PublicKey{} 606 - if err := json.NewDecoder(r.Body).Decode(&pk); err != nil { 607 - writeError(w, "invalid request body", http.StatusBadRequest) 608 - return 609 - } 610 - 611 - _, _, _, _, err := ssh.ParseAuthorizedKey([]byte(pk.Key)) 612 - if err != nil { 613 - writeError(w, "invalid pubkey", http.StatusBadRequest) 614 - } 615 - 616 - if err := h.db.AddPublicKey(pk); err != nil { 617 - writeError(w, err.Error(), http.StatusInternalServerError) 618 - l.Error("adding public key", "error", err.Error()) 619 - return 620 - } 621 - 622 - w.WriteHeader(http.StatusNoContent) 623 - return 624 - } 625 - } 626 - 627 - func (h *Handle) NewRepo(w http.ResponseWriter, r *http.Request) { 628 - l := h.l.With("handler", "NewRepo") 629 - 630 - data := struct { 631 - Did string `json:"did"` 632 - Name string `json:"name"` 633 - DefaultBranch string `json:"default_branch,omitempty"` 634 - }{} 635 - 636 - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 637 - writeError(w, "invalid request body", http.StatusBadRequest) 638 - return 639 - } 640 - 641 - if data.DefaultBranch == "" { 642 - data.DefaultBranch = h.c.Repo.MainBranch 643 - } 644 - 645 - did := data.Did 646 - name := data.Name 647 - defaultBranch := data.DefaultBranch 648 - 649 - if err := validateRepoName(name); err != nil { 650 - l.Error("creating repo", "error", err.Error()) 651 - writeError(w, err.Error(), http.StatusBadRequest) 652 - return 653 - } 654 - 655 - relativeRepoPath := filepath.Join(did, name) 656 - repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) 657 - err := git.InitBare(repoPath, defaultBranch) 658 - if err != nil { 659 - l.Error("initializing bare repo", "error", err.Error()) 660 - if errors.Is(err, gogit.ErrRepositoryAlreadyExists) { 661 - writeError(w, "That repo already exists!", http.StatusConflict) 662 - return 663 - } else { 664 - writeError(w, err.Error(), http.StatusInternalServerError) 665 - return 666 - } 667 - } 668 - 669 - // add perms for this user to access the repo 670 - err = h.e.AddRepo(did, ThisServer, relativeRepoPath) 671 - if err != nil { 672 - l.Error("adding repo permissions", "error", err.Error()) 673 - writeError(w, err.Error(), http.StatusInternalServerError) 674 - return 675 - } 676 - 677 - hook.SetupRepo( 678 - hook.Config( 679 - hook.WithScanPath(h.c.Repo.ScanPath), 680 - hook.WithInternalApi(h.c.Server.InternalListenAddr), 681 - ), 682 - repoPath, 683 - ) 684 - 685 - w.WriteHeader(http.StatusNoContent) 686 - } 687 - 688 - func (h *Handle) RepoForkAheadBehind(w http.ResponseWriter, r *http.Request) { 689 - l := h.l.With("handler", "RepoForkSync") 690 - 691 - data := struct { 692 - Did string `json:"did"` 693 - Source string `json:"source"` 694 - Name string `json:"name,omitempty"` 695 - HiddenRef string `json:"hiddenref"` 696 - }{} 697 - 698 - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 699 - writeError(w, "invalid request body", http.StatusBadRequest) 700 - return 701 - } 702 - 703 - did := data.Did 704 - source := data.Source 705 - 706 - if did == "" || source == "" { 707 - l.Error("invalid request body, empty did or name") 708 - w.WriteHeader(http.StatusBadRequest) 709 - return 710 - } 711 - 712 - var name string 713 - if data.Name != "" { 714 - name = data.Name 715 - } else { 716 - name = filepath.Base(source) 717 - } 718 - 719 - branch := chi.URLParam(r, "branch") 720 - branch, _ = url.PathUnescape(branch) 721 - 722 - relativeRepoPath := filepath.Join(did, name) 723 - repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) 724 - 725 - gr, err := git.PlainOpen(repoPath) 726 - if err != nil { 727 - log.Println(err) 728 - notFound(w) 729 - return 730 - } 731 - 732 - forkCommit, err := gr.ResolveRevision(branch) 733 - if err != nil { 734 - l.Error("error resolving ref revision", "msg", err.Error()) 735 - writeError(w, fmt.Sprintf("error resolving revision %s", branch), http.StatusBadRequest) 736 - return 737 - } 738 - 739 - sourceCommit, err := gr.ResolveRevision(data.HiddenRef) 740 - if err != nil { 741 - l.Error("error resolving hidden ref revision", "msg", err.Error()) 742 - writeError(w, fmt.Sprintf("error resolving revision %s", data.HiddenRef), http.StatusBadRequest) 743 - return 744 - } 745 - 746 - status := types.UpToDate 747 - if forkCommit.Hash.String() != sourceCommit.Hash.String() { 748 - isAncestor, err := forkCommit.IsAncestor(sourceCommit) 749 - if err != nil { 750 - log.Printf("error resolving whether %s is ancestor of %s: %s", branch, data.HiddenRef, err) 751 - return 752 - } 753 - 754 - if isAncestor { 755 - status = types.FastForwardable 756 - } else { 757 - status = types.Conflict 758 - } 759 - } 760 - 761 - w.Header().Set("Content-Type", "application/json") 762 - json.NewEncoder(w).Encode(types.AncestorCheckResponse{Status: status}) 763 - } 764 - 765 - func (h *Handle) RepoLanguages(w http.ResponseWriter, r *http.Request) { 766 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 767 - ref := chi.URLParam(r, "ref") 768 - ref, _ = url.PathUnescape(ref) 769 - 770 - l := h.l.With("handler", "RepoLanguages") 771 - 772 - gr, err := git.Open(path, ref) 773 - if err != nil { 774 - l.Error("opening repo", "error", err.Error()) 775 - notFound(w) 776 - return 777 - } 778 - 779 - languageFileCount := make(map[string]int) 780 - 781 - err = recurseEntireTree(r.Context(), gr, func(absPath string) { 782 - lang, safe := enry.GetLanguageByExtension(absPath) 783 - if len(lang) == 0 || !safe { 784 - content, _ := gr.FileContentN(absPath, 1024) 785 - if !safe { 786 - lang = enry.GetLanguage(absPath, content) 787 - } else { 788 - lang, _ = enry.GetLanguageByContent(absPath, content) 789 - if len(lang) == 0 { 790 - return 791 - } 792 - } 793 - } 794 - 795 - v, ok := languageFileCount[lang] 796 - if ok { 797 - languageFileCount[lang] = v + 1 798 - } else { 799 - languageFileCount[lang] = 1 800 - } 801 - }, "") 802 - if err != nil { 803 - l.Error("failed to recurse file tree", "error", err.Error()) 804 - writeError(w, err.Error(), http.StatusNoContent) 805 - return 806 - } 807 - 808 - resp := types.RepoLanguageResponse{Languages: languageFileCount} 809 - 810 - writeJSON(w, resp) 811 - return 812 - } 813 - 814 - func recurseEntireTree(ctx context.Context, git *git.GitRepo, callback func(absPath string), filePath string) error { 815 - files, err := git.FileTree(ctx, filePath) 816 - if err != nil { 817 - log.Println(err) 818 - return err 819 - } 820 - 821 - for _, file := range files { 822 - absPath := path.Join(filePath, file.Name) 823 - if !file.IsFile { 824 - return recurseEntireTree(ctx, git, callback, absPath) 825 - } 826 - callback(absPath) 827 - } 828 - 829 - return nil 830 - } 831 - 832 - func (h *Handle) RepoForkSync(w http.ResponseWriter, r *http.Request) { 833 - l := h.l.With("handler", "RepoForkSync") 834 - 835 - data := struct { 836 - Did string `json:"did"` 837 - Source string `json:"source"` 838 - Name string `json:"name,omitempty"` 839 - }{} 840 - 841 - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 842 - writeError(w, "invalid request body", http.StatusBadRequest) 843 - return 844 - } 845 - 846 - did := data.Did 847 - source := data.Source 848 - 849 - if did == "" || source == "" { 850 - l.Error("invalid request body, empty did or name") 851 - w.WriteHeader(http.StatusBadRequest) 852 - return 853 - } 854 - 855 - var name string 856 - if data.Name != "" { 857 - name = data.Name 858 - } else { 859 - name = filepath.Base(source) 860 - } 861 - 862 - branch := chi.URLParam(r, "branch") 863 - branch, _ = url.PathUnescape(branch) 864 - 865 - relativeRepoPath := filepath.Join(did, name) 866 - repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) 867 - 868 - gr, err := git.PlainOpen(repoPath) 869 - if err != nil { 870 - log.Println(err) 871 - notFound(w) 872 - return 873 - } 874 - 875 - err = gr.Sync(branch) 876 - if err != nil { 877 - l.Error("error syncing repo fork", "error", err.Error()) 878 - writeError(w, err.Error(), http.StatusInternalServerError) 879 - return 880 - } 881 - 882 - w.WriteHeader(http.StatusNoContent) 883 - } 884 - 885 - func (h *Handle) RepoFork(w http.ResponseWriter, r *http.Request) { 886 - l := h.l.With("handler", "RepoFork") 887 - 888 - data := struct { 889 - Did string `json:"did"` 890 - Source string `json:"source"` 891 - Name string `json:"name,omitempty"` 892 - }{} 893 - 894 - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 895 - writeError(w, "invalid request body", http.StatusBadRequest) 896 - return 897 - } 898 - 899 - did := data.Did 900 - source := data.Source 901 - 902 - if did == "" || source == "" { 903 - l.Error("invalid request body, empty did or name") 904 - w.WriteHeader(http.StatusBadRequest) 905 - return 906 - } 907 - 908 - var name string 909 - if data.Name != "" { 910 - name = data.Name 911 - } else { 912 - name = filepath.Base(source) 913 - } 914 - 915 - relativeRepoPath := filepath.Join(did, name) 916 - repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) 917 - 918 - err := git.Fork(repoPath, source) 919 - if err != nil { 920 - l.Error("forking repo", "error", err.Error()) 921 - writeError(w, err.Error(), http.StatusInternalServerError) 922 - return 923 - } 924 - 925 - // add perms for this user to access the repo 926 - err = h.e.AddRepo(did, ThisServer, relativeRepoPath) 927 - if err != nil { 928 - l.Error("adding repo permissions", "error", err.Error()) 929 - writeError(w, err.Error(), http.StatusInternalServerError) 930 - return 931 - } 932 - 933 - hook.SetupRepo( 934 - hook.Config( 935 - hook.WithScanPath(h.c.Repo.ScanPath), 936 - hook.WithInternalApi(h.c.Server.InternalListenAddr), 937 - ), 938 - repoPath, 939 - ) 940 - 941 - w.WriteHeader(http.StatusNoContent) 942 - } 943 - 944 - func (h *Handle) RemoveRepo(w http.ResponseWriter, r *http.Request) { 945 - l := h.l.With("handler", "RemoveRepo") 946 - 947 - data := struct { 948 - Did string `json:"did"` 949 - Name string `json:"name"` 950 - }{} 951 - 952 - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 953 - writeError(w, "invalid request body", http.StatusBadRequest) 954 - return 955 - } 956 - 957 - did := data.Did 958 - name := data.Name 959 - 960 - if did == "" || name == "" { 961 - l.Error("invalid request body, empty did or name") 962 - w.WriteHeader(http.StatusBadRequest) 963 - return 964 - } 965 - 966 - relativeRepoPath := filepath.Join(did, name) 967 - repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) 968 - err := os.RemoveAll(repoPath) 969 - if err != nil { 970 - l.Error("removing repo", "error", err.Error()) 971 - writeError(w, err.Error(), http.StatusInternalServerError) 972 - return 973 - } 974 - 975 - w.WriteHeader(http.StatusNoContent) 976 - 977 - } 978 - func (h *Handle) Merge(w http.ResponseWriter, r *http.Request) { 979 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 980 - 981 - data := types.MergeRequest{} 982 - 983 - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 984 - writeError(w, err.Error(), http.StatusBadRequest) 985 - h.l.Error("git: failed to unmarshal json patch", "handler", "Merge", "error", err) 986 - return 987 - } 988 - 989 - mo := &git.MergeOptions{ 990 - AuthorName: data.AuthorName, 991 - AuthorEmail: data.AuthorEmail, 992 - CommitBody: data.CommitBody, 993 - CommitMessage: data.CommitMessage, 994 - } 995 - 996 - patch := data.Patch 997 - branch := data.Branch 998 - gr, err := git.Open(path, branch) 999 - if err != nil { 1000 - notFound(w) 1001 - return 1002 - } 1003 - 1004 - mo.FormatPatch = patchutil.IsFormatPatch(patch) 1005 - 1006 - if err := gr.MergeWithOptions([]byte(patch), branch, mo); err != nil { 1007 - var mergeErr *git.ErrMerge 1008 - if errors.As(err, &mergeErr) { 1009 - conflicts := make([]types.ConflictInfo, len(mergeErr.Conflicts)) 1010 - for i, conflict := range mergeErr.Conflicts { 1011 - conflicts[i] = types.ConflictInfo{ 1012 - Filename: conflict.Filename, 1013 - Reason: conflict.Reason, 1014 - } 1015 - } 1016 - response := types.MergeCheckResponse{ 1017 - IsConflicted: true, 1018 - Conflicts: conflicts, 1019 - Message: mergeErr.Message, 1020 - } 1021 - writeConflict(w, response) 1022 - h.l.Error("git: merge conflict", "handler", "Merge", "error", mergeErr) 1023 - } else { 1024 - writeError(w, err.Error(), http.StatusBadRequest) 1025 - h.l.Error("git: failed to merge", "handler", "Merge", "error", err.Error()) 1026 - } 1027 - return 1028 - } 1029 - 1030 - w.WriteHeader(http.StatusOK) 1031 - } 1032 - 1033 - func (h *Handle) MergeCheck(w http.ResponseWriter, r *http.Request) { 1034 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 1035 - 1036 - var data struct { 1037 - Patch string `json:"patch"` 1038 - Branch string `json:"branch"` 1039 - } 1040 - 1041 - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 1042 - writeError(w, err.Error(), http.StatusBadRequest) 1043 - h.l.Error("git: failed to unmarshal json patch", "handler", "MergeCheck", "error", err) 1044 - return 1045 - } 1046 - 1047 - patch := data.Patch 1048 - branch := data.Branch 1049 - gr, err := git.Open(path, branch) 1050 - if err != nil { 1051 - notFound(w) 1052 - return 1053 - } 1054 - 1055 - err = gr.MergeCheck([]byte(patch), branch) 1056 - if err == nil { 1057 - response := types.MergeCheckResponse{ 1058 - IsConflicted: false, 1059 - } 1060 - writeJSON(w, response) 1061 - return 1062 - } 1063 - 1064 - var mergeErr *git.ErrMerge 1065 - if errors.As(err, &mergeErr) { 1066 - conflicts := make([]types.ConflictInfo, len(mergeErr.Conflicts)) 1067 - for i, conflict := range mergeErr.Conflicts { 1068 - conflicts[i] = types.ConflictInfo{ 1069 - Filename: conflict.Filename, 1070 - Reason: conflict.Reason, 1071 - } 1072 - } 1073 - response := types.MergeCheckResponse{ 1074 - IsConflicted: true, 1075 - Conflicts: conflicts, 1076 - Message: mergeErr.Message, 1077 - } 1078 - writeConflict(w, response) 1079 - h.l.Error("git: merge conflict", "handler", "MergeCheck", "error", mergeErr.Error()) 1080 - return 1081 - } 1082 - writeError(w, err.Error(), http.StatusInternalServerError) 1083 - h.l.Error("git: failed to check merge", "handler", "MergeCheck", "error", err.Error()) 1084 - } 1085 - 1086 - func (h *Handle) Compare(w http.ResponseWriter, r *http.Request) { 1087 - rev1 := chi.URLParam(r, "rev1") 1088 - rev1, _ = url.PathUnescape(rev1) 1089 - 1090 - rev2 := chi.URLParam(r, "rev2") 1091 - rev2, _ = url.PathUnescape(rev2) 1092 - 1093 - l := h.l.With("handler", "Compare", "r1", rev1, "r2", rev2) 1094 - 1095 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 1096 - gr, err := git.PlainOpen(path) 1097 - if err != nil { 1098 - notFound(w) 1099 - return 1100 - } 1101 - 1102 - commit1, err := gr.ResolveRevision(rev1) 1103 - if err != nil { 1104 - l.Error("error resolving revision 1", "msg", err.Error()) 1105 - writeError(w, fmt.Sprintf("error resolving revision %s", rev1), http.StatusBadRequest) 1106 - return 1107 - } 1108 - 1109 - commit2, err := gr.ResolveRevision(rev2) 1110 - if err != nil { 1111 - l.Error("error resolving revision 2", "msg", err.Error()) 1112 - writeError(w, fmt.Sprintf("error resolving revision %s", rev2), http.StatusBadRequest) 1113 - return 1114 - } 1115 - 1116 - rawPatch, formatPatch, err := gr.FormatPatch(commit1, commit2) 1117 - if err != nil { 1118 - l.Error("error comparing revisions", "msg", err.Error()) 1119 - writeError(w, "error comparing revisions", http.StatusBadRequest) 1120 - return 1121 - } 1122 - 1123 - writeJSON(w, types.RepoFormatPatchResponse{ 1124 - Rev1: commit1.Hash.String(), 1125 - Rev2: commit2.Hash.String(), 1126 - FormatPatch: formatPatch, 1127 - Patch: rawPatch, 1128 - }) 1129 - return 1130 - } 1131 - 1132 - func (h *Handle) NewHiddenRef(w http.ResponseWriter, r *http.Request) { 1133 - l := h.l.With("handler", "NewHiddenRef") 1134 - 1135 - forkRef := chi.URLParam(r, "forkRef") 1136 - forkRef, _ = url.PathUnescape(forkRef) 1137 - 1138 - remoteRef := chi.URLParam(r, "remoteRef") 1139 - remoteRef, _ = url.PathUnescape(remoteRef) 1140 - 1141 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 1142 - gr, err := git.PlainOpen(path) 1143 - if err != nil { 1144 - notFound(w) 1145 - return 1146 - } 1147 - 1148 - err = gr.TrackHiddenRemoteRef(forkRef, remoteRef) 1149 - if err != nil { 1150 - l.Error("error tracking hidden remote ref", "msg", err.Error()) 1151 - writeError(w, "error tracking hidden remote ref", http.StatusBadRequest) 1152 - return 1153 - } 1154 - 1155 - w.WriteHeader(http.StatusNoContent) 1156 - return 1157 - } 1158 - 1159 - func (h *Handle) AddMember(w http.ResponseWriter, r *http.Request) { 1160 - l := h.l.With("handler", "AddMember") 1161 - 1162 - data := struct { 1163 - Did string `json:"did"` 1164 - }{} 1165 - 1166 - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 1167 - writeError(w, "invalid request body", http.StatusBadRequest) 1168 - return 1169 - } 1170 - 1171 - did := data.Did 1172 - 1173 - if err := h.db.AddDid(did); err != nil { 1174 - l.Error("adding did", "error", err.Error()) 1175 - writeError(w, err.Error(), http.StatusInternalServerError) 1176 - return 1177 - } 1178 - h.jc.AddDid(did) 1179 - 1180 - if err := h.e.AddKnotMember(ThisServer, did); err != nil { 1181 - l.Error("adding member", "error", err.Error()) 1182 - writeError(w, err.Error(), http.StatusInternalServerError) 1183 - return 1184 - } 1185 - 1186 - if err := h.fetchAndAddKeys(r.Context(), did); err != nil { 1187 - l.Error("fetching and adding keys", "error", err.Error()) 1188 - writeError(w, err.Error(), http.StatusInternalServerError) 1189 - return 1190 - } 1191 - 1192 - w.WriteHeader(http.StatusNoContent) 1193 - } 1194 - 1195 - func (h *Handle) AddRepoCollaborator(w http.ResponseWriter, r *http.Request) { 1196 - l := h.l.With("handler", "AddRepoCollaborator") 1197 - 1198 - data := struct { 1199 - Did string `json:"did"` 1200 - }{} 1201 - 1202 - ownerDid := chi.URLParam(r, "did") 1203 - repo := chi.URLParam(r, "name") 1204 - 1205 - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 1206 - writeError(w, "invalid request body", http.StatusBadRequest) 1207 - return 1208 - } 1209 - 1210 - if err := h.db.AddDid(data.Did); err != nil { 1211 - l.Error("adding did", "error", err.Error()) 1212 - writeError(w, err.Error(), http.StatusInternalServerError) 1213 - return 1214 - } 1215 - h.jc.AddDid(data.Did) 1216 - 1217 - repoName, _ := securejoin.SecureJoin(ownerDid, repo) 1218 - if err := h.e.AddCollaborator(data.Did, ThisServer, repoName); err != nil { 1219 - l.Error("adding repo collaborator", "error", err.Error()) 1220 - writeError(w, err.Error(), http.StatusInternalServerError) 1221 - return 1222 - } 1223 - 1224 - if err := h.fetchAndAddKeys(r.Context(), data.Did); err != nil { 1225 - l.Error("fetching and adding keys", "error", err.Error()) 1226 - writeError(w, err.Error(), http.StatusInternalServerError) 1227 - return 1228 - } 1229 - 1230 - w.WriteHeader(http.StatusNoContent) 1231 - } 1232 - 1233 - func (h *Handle) DefaultBranch(w http.ResponseWriter, r *http.Request) { 1234 - l := h.l.With("handler", "DefaultBranch") 1235 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 1236 - 1237 - gr, err := git.Open(path, "") 1238 - if err != nil { 1239 - notFound(w) 1240 - return 1241 - } 1242 - 1243 - branch, err := gr.FindMainBranch() 1244 - if err != nil { 1245 - writeError(w, err.Error(), http.StatusInternalServerError) 1246 - l.Error("getting default branch", "error", err.Error()) 1247 - return 1248 - } 1249 - 1250 - writeJSON(w, types.RepoDefaultBranchResponse{ 1251 - Branch: branch, 1252 - }) 1253 - } 1254 - 1255 - func (h *Handle) SetDefaultBranch(w http.ResponseWriter, r *http.Request) { 1256 - l := h.l.With("handler", "SetDefaultBranch") 1257 - path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 1258 - 1259 - data := struct { 1260 - Branch string `json:"branch"` 1261 - }{} 1262 - 1263 - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 1264 - writeError(w, err.Error(), http.StatusBadRequest) 1265 - return 1266 - } 1267 - 1268 - gr, err := git.PlainOpen(path) 1269 - if err != nil { 1270 - notFound(w) 1271 - return 1272 - } 1273 - 1274 - err = gr.SetDefaultBranch(data.Branch) 1275 - if err != nil { 1276 - writeError(w, err.Error(), http.StatusInternalServerError) 1277 - l.Error("setting default branch", "error", err.Error()) 1278 - return 1279 - } 1280 - 1281 - w.WriteHeader(http.StatusNoContent) 1282 - } 1283 - 1284 - func (h *Handle) Init(w http.ResponseWriter, r *http.Request) { 1285 - l := h.l.With("handler", "Init") 1286 - 1287 - if h.knotInitialized { 1288 - writeError(w, "knot already initialized", http.StatusConflict) 1289 - return 1290 - } 1291 - 1292 - data := struct { 1293 - Did string `json:"did"` 1294 - }{} 1295 - 1296 - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 1297 - l.Error("failed to decode request body", "error", err.Error()) 1298 - writeError(w, "invalid request body", http.StatusBadRequest) 1299 - return 1300 - } 1301 - 1302 - if data.Did == "" { 1303 - l.Error("empty DID in request", "did", data.Did) 1304 - writeError(w, "did is empty", http.StatusBadRequest) 1305 - return 1306 - } 1307 - 1308 - if err := h.db.AddDid(data.Did); err != nil { 1309 - l.Error("failed to add DID", "error", err.Error()) 1310 - writeError(w, err.Error(), http.StatusInternalServerError) 1311 - return 1312 - } 1313 - h.jc.AddDid(data.Did) 1314 - 1315 - if err := h.e.AddKnotOwner(ThisServer, data.Did); err != nil { 1316 - l.Error("adding owner", "error", err.Error()) 1317 - writeError(w, err.Error(), http.StatusInternalServerError) 1318 - return 1319 - } 1320 - 1321 - if err := h.fetchAndAddKeys(r.Context(), data.Did); err != nil { 1322 - l.Error("fetching and adding keys", "error", err.Error()) 1323 - writeError(w, err.Error(), http.StatusInternalServerError) 1324 - return 1325 - } 1326 - 1327 - close(h.init) 1328 - 1329 - mac := hmac.New(sha256.New, []byte(h.c.Server.Secret)) 1330 - mac.Write([]byte("ok")) 1331 - w.Header().Add("X-Signature", hex.EncodeToString(mac.Sum(nil))) 1332 - 1333 - w.WriteHeader(http.StatusNoContent) 1334 - } 1335 - 1336 - func (h *Handle) Health(w http.ResponseWriter, r *http.Request) { 1337 - w.Write([]byte("ok")) 1338 - } 1339 - 1340 - func validateRepoName(name string) error { 1341 - // check for path traversal attempts 1342 - if name == "." || name == ".." || 1343 - strings.Contains(name, "/") || strings.Contains(name, "\\") { 1344 - return fmt.Errorf("Repository name contains invalid path characters") 1345 - } 1346 - 1347 - // check for sequences that could be used for traversal when normalized 1348 - if strings.Contains(name, "./") || strings.Contains(name, "../") || 1349 - strings.HasPrefix(name, ".") || strings.HasSuffix(name, ".") { 1350 - return fmt.Errorf("Repository name contains invalid path sequence") 1351 - } 1352 - 1353 - // then continue with character validation 1354 - for _, char := range name { 1355 - if !((char >= 'a' && char <= 'z') || 1356 - (char >= 'A' && char <= 'Z') || 1357 - (char >= '0' && char <= '9') || 1358 - char == '-' || char == '_' || char == '.') { 1359 - return fmt.Errorf("Repository name can only contain alphanumeric characters, periods, hyphens, and underscores") 1360 - } 1361 - } 1362 - 1363 - // additional check to prevent multiple sequential dots 1364 - if strings.Contains(name, "..") { 1365 - return fmt.Errorf("Repository name cannot contain sequential dots") 1366 - } 1367 - 1368 - // if all checks pass 1369 - return nil 1370 - }
+18 -13
knotserver/server.go
··· 22 22 Usage: "run a knot server", 23 23 Action: Run, 24 24 Description: ` 25 - Environment variables: 26 - KNOT_SERVER_SECRET (required) 27 - KNOT_SERVER_HOSTNAME (required) 28 - KNOT_SERVER_LISTEN_ADDR (default: 0.0.0.0:5555) 29 - KNOT_SERVER_INTERNAL_LISTEN_ADDR (default: 127.0.0.1:5444) 30 - KNOT_SERVER_DB_PATH (default: knotserver.db) 31 - KNOT_SERVER_JETSTREAM_ENDPOINT (default: wss://jetstream1.us-west.bsky.network/subscribe) 32 - KNOT_SERVER_DEV (default: false) 33 - KNOT_REPO_SCAN_PATH (default: /home/git) 34 - KNOT_REPO_README (comma-separated list) 35 - KNOT_REPO_MAIN_BRANCH (default: main) 36 - APPVIEW_ENDPOINT (default: https://tangled.sh) 37 - `, 25 + Environment variables: 26 + KNOT_SERVER_LISTEN_ADDR (default: 0.0.0.0:5555) 27 + KNOT_SERVER_INTERNAL_LISTEN_ADDR (default: 127.0.0.1:5444) 28 + KNOT_SERVER_DB_PATH (default: knotserver.db) 29 + KNOT_SERVER_HOSTNAME (required) 30 + KNOT_SERVER_JETSTREAM_ENDPOINT (default: wss://jetstream1.us-west.bsky.network/subscribe) 31 + KNOT_SERVER_OWNER (required) 32 + KNOT_SERVER_LOG_DIDS (default: true) 33 + KNOT_SERVER_DEV (default: false) 34 + KNOT_REPO_SCAN_PATH (default: /home/git) 35 + KNOT_REPO_README (comma-separated list) 36 + KNOT_REPO_MAIN_BRANCH (default: main) 37 + KNOT_GIT_USER_NAME (default: Tangled) 38 + KNOT_GIT_USER_EMAIL (default: noreply@tangled.sh) 39 + APPVIEW_ENDPOINT (default: https://tangled.sh) 40 + `, 38 41 } 39 42 } 40 43 ··· 75 78 jc, err := jetstream.NewJetstreamClient(c.Server.JetstreamEndpoint, "knotserver", []string{ 76 79 tangled.PublicKeyNSID, 77 80 tangled.KnotMemberNSID, 81 + tangled.RepoPullNSID, 82 + tangled.RepoCollaboratorNSID, 78 83 }, nil, logger, db, true, c.Server.LogDids) 79 84 if err != nil { 80 85 logger.Error("failed to setup jetstream", "error", err)
-5
knotserver/util.go
··· 8 8 "github.com/bluesky-social/indigo/atproto/syntax" 9 9 securejoin "github.com/cyphar/filepath-securejoin" 10 10 "github.com/go-chi/chi/v5" 11 - "github.com/microcosm-cc/bluemonday" 12 11 ) 13 - 14 - func sanitize(content []byte) []byte { 15 - return bluemonday.UGCPolicy().SanitizeBytes([]byte(content)) 16 - } 17 12 18 13 func didPath(r *http.Request) string { 19 14 did := chi.URLParam(r, "did")
+156
knotserver/xrpc/create_repo.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "errors" 6 + "fmt" 7 + "net/http" 8 + "path/filepath" 9 + "strings" 10 + 11 + comatproto "github.com/bluesky-social/indigo/api/atproto" 12 + "github.com/bluesky-social/indigo/atproto/syntax" 13 + "github.com/bluesky-social/indigo/xrpc" 14 + securejoin "github.com/cyphar/filepath-securejoin" 15 + gogit "github.com/go-git/go-git/v5" 16 + "tangled.sh/tangled.sh/core/api/tangled" 17 + "tangled.sh/tangled.sh/core/hook" 18 + "tangled.sh/tangled.sh/core/knotserver/git" 19 + "tangled.sh/tangled.sh/core/rbac" 20 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 21 + ) 22 + 23 + func (h *Xrpc) CreateRepo(w http.ResponseWriter, r *http.Request) { 24 + l := h.Logger.With("handler", "NewRepo") 25 + fail := func(e xrpcerr.XrpcError) { 26 + l.Error("failed", "kind", e.Tag, "error", e.Message) 27 + writeError(w, e, http.StatusBadRequest) 28 + } 29 + 30 + actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 31 + if !ok { 32 + fail(xrpcerr.MissingActorDidError) 33 + return 34 + } 35 + 36 + isMember, err := h.Enforcer.IsRepoCreateAllowed(actorDid.String(), rbac.ThisServer) 37 + if err != nil { 38 + fail(xrpcerr.GenericError(err)) 39 + return 40 + } 41 + if !isMember { 42 + fail(xrpcerr.AccessControlError(actorDid.String())) 43 + return 44 + } 45 + 46 + var data tangled.RepoCreate_Input 47 + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 48 + fail(xrpcerr.GenericError(err)) 49 + return 50 + } 51 + 52 + rkey := data.Rkey 53 + 54 + ident, err := h.Resolver.ResolveIdent(r.Context(), actorDid.String()) 55 + if err != nil || ident.Handle.IsInvalidHandle() { 56 + fail(xrpcerr.GenericError(err)) 57 + return 58 + } 59 + 60 + xrpcc := xrpc.Client{ 61 + Host: ident.PDSEndpoint(), 62 + } 63 + 64 + resp, err := comatproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, actorDid.String(), rkey) 65 + if err != nil { 66 + fail(xrpcerr.GenericError(err)) 67 + return 68 + } 69 + 70 + repo := resp.Value.Val.(*tangled.Repo) 71 + 72 + defaultBranch := h.Config.Repo.MainBranch 73 + if data.DefaultBranch != nil && *data.DefaultBranch != "" { 74 + defaultBranch = *data.DefaultBranch 75 + } 76 + 77 + if err := validateRepoName(repo.Name); err != nil { 78 + l.Error("creating repo", "error", err.Error()) 79 + fail(xrpcerr.GenericError(err)) 80 + return 81 + } 82 + 83 + relativeRepoPath := filepath.Join(actorDid.String(), repo.Name) 84 + repoPath, _ := securejoin.SecureJoin(h.Config.Repo.ScanPath, relativeRepoPath) 85 + 86 + if data.Source != nil && *data.Source != "" { 87 + err = git.Fork(repoPath, *data.Source) 88 + if err != nil { 89 + l.Error("forking repo", "error", err.Error()) 90 + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 91 + return 92 + } 93 + } else { 94 + err = git.InitBare(repoPath, defaultBranch) 95 + if err != nil { 96 + l.Error("initializing bare repo", "error", err.Error()) 97 + if errors.Is(err, gogit.ErrRepositoryAlreadyExists) { 98 + fail(xrpcerr.RepoExistsError("repository already exists")) 99 + return 100 + } else { 101 + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 102 + return 103 + } 104 + } 105 + } 106 + 107 + // add perms for this user to access the repo 108 + err = h.Enforcer.AddRepo(actorDid.String(), rbac.ThisServer, relativeRepoPath) 109 + if err != nil { 110 + l.Error("adding repo permissions", "error", err.Error()) 111 + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 112 + return 113 + } 114 + 115 + hook.SetupRepo( 116 + hook.Config( 117 + hook.WithScanPath(h.Config.Repo.ScanPath), 118 + hook.WithInternalApi(h.Config.Server.InternalListenAddr), 119 + ), 120 + repoPath, 121 + ) 122 + 123 + w.WriteHeader(http.StatusOK) 124 + } 125 + 126 + func validateRepoName(name string) error { 127 + // check for path traversal attempts 128 + if name == "." || name == ".." || 129 + strings.Contains(name, "/") || strings.Contains(name, "\\") { 130 + return fmt.Errorf("Repository name contains invalid path characters") 131 + } 132 + 133 + // check for sequences that could be used for traversal when normalized 134 + if strings.Contains(name, "./") || strings.Contains(name, "../") || 135 + strings.HasPrefix(name, ".") || strings.HasSuffix(name, ".") { 136 + return fmt.Errorf("Repository name contains invalid path sequence") 137 + } 138 + 139 + // then continue with character validation 140 + for _, char := range name { 141 + if !((char >= 'a' && char <= 'z') || 142 + (char >= 'A' && char <= 'Z') || 143 + (char >= '0' && char <= '9') || 144 + char == '-' || char == '_' || char == '.') { 145 + return fmt.Errorf("Repository name can only contain alphanumeric characters, periods, hyphens, and underscores") 146 + } 147 + } 148 + 149 + // additional check to prevent multiple sequential dots 150 + if strings.Contains(name, "..") { 151 + return fmt.Errorf("Repository name cannot contain sequential dots") 152 + } 153 + 154 + // if all checks pass 155 + return nil 156 + }
+96
knotserver/xrpc/delete_repo.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net/http" 7 + "os" 8 + "path/filepath" 9 + 10 + comatproto "github.com/bluesky-social/indigo/api/atproto" 11 + "github.com/bluesky-social/indigo/atproto/syntax" 12 + "github.com/bluesky-social/indigo/xrpc" 13 + securejoin "github.com/cyphar/filepath-securejoin" 14 + "tangled.sh/tangled.sh/core/api/tangled" 15 + "tangled.sh/tangled.sh/core/rbac" 16 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 17 + ) 18 + 19 + func (x *Xrpc) DeleteRepo(w http.ResponseWriter, r *http.Request) { 20 + l := x.Logger.With("handler", "DeleteRepo") 21 + fail := func(e xrpcerr.XrpcError) { 22 + l.Error("failed", "kind", e.Tag, "error", e.Message) 23 + writeError(w, e, http.StatusBadRequest) 24 + } 25 + 26 + actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 27 + if !ok { 28 + fail(xrpcerr.MissingActorDidError) 29 + return 30 + } 31 + 32 + var data tangled.RepoDelete_Input 33 + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 34 + fail(xrpcerr.GenericError(err)) 35 + return 36 + } 37 + 38 + did := data.Did 39 + name := data.Name 40 + rkey := data.Rkey 41 + 42 + if did == "" || name == "" { 43 + fail(xrpcerr.GenericError(fmt.Errorf("did and name are required"))) 44 + return 45 + } 46 + 47 + ident, err := x.Resolver.ResolveIdent(r.Context(), actorDid.String()) 48 + if err != nil || ident.Handle.IsInvalidHandle() { 49 + fail(xrpcerr.GenericError(err)) 50 + return 51 + } 52 + 53 + xrpcc := xrpc.Client{ 54 + Host: ident.PDSEndpoint(), 55 + } 56 + 57 + // ensure that the record does not exists 58 + _, err = comatproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, actorDid.String(), rkey) 59 + if err == nil { 60 + fail(xrpcerr.RecordExistsError(rkey)) 61 + return 62 + } 63 + 64 + relativeRepoPath := filepath.Join(did, name) 65 + isDeleteAllowed, err := x.Enforcer.IsRepoDeleteAllowed(actorDid.String(), rbac.ThisServer, relativeRepoPath) 66 + if err != nil { 67 + fail(xrpcerr.GenericError(err)) 68 + return 69 + } 70 + if !isDeleteAllowed { 71 + fail(xrpcerr.AccessControlError(actorDid.String())) 72 + return 73 + } 74 + 75 + repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, relativeRepoPath) 76 + if err != nil { 77 + fail(xrpcerr.GenericError(err)) 78 + return 79 + } 80 + 81 + err = os.RemoveAll(repoPath) 82 + if err != nil { 83 + l.Error("deleting repo", "error", err.Error()) 84 + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 85 + return 86 + } 87 + 88 + err = x.Enforcer.RemoveRepo(did, rbac.ThisServer, relativeRepoPath) 89 + if err != nil { 90 + l.Error("failed to delete repo from enforcer", "error", err.Error()) 91 + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 92 + return 93 + } 94 + 95 + w.WriteHeader(http.StatusOK) 96 + }
+111
knotserver/xrpc/fork_status.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net/http" 7 + "path/filepath" 8 + 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + securejoin "github.com/cyphar/filepath-securejoin" 11 + "tangled.sh/tangled.sh/core/api/tangled" 12 + "tangled.sh/tangled.sh/core/knotserver/git" 13 + "tangled.sh/tangled.sh/core/rbac" 14 + "tangled.sh/tangled.sh/core/types" 15 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 16 + ) 17 + 18 + func (x *Xrpc) ForkStatus(w http.ResponseWriter, r *http.Request) { 19 + l := x.Logger.With("handler", "ForkStatus") 20 + fail := func(e xrpcerr.XrpcError) { 21 + l.Error("failed", "kind", e.Tag, "error", e.Message) 22 + writeError(w, e, http.StatusBadRequest) 23 + } 24 + 25 + actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 26 + if !ok { 27 + fail(xrpcerr.MissingActorDidError) 28 + return 29 + } 30 + 31 + var data tangled.RepoForkStatus_Input 32 + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 33 + fail(xrpcerr.GenericError(err)) 34 + return 35 + } 36 + 37 + did := data.Did 38 + source := data.Source 39 + branch := data.Branch 40 + hiddenRef := data.HiddenRef 41 + 42 + if did == "" || source == "" || branch == "" || hiddenRef == "" { 43 + fail(xrpcerr.GenericError(fmt.Errorf("did, source, branch, and hiddenRef are required"))) 44 + return 45 + } 46 + 47 + var name string 48 + if data.Name != "" { 49 + name = data.Name 50 + } else { 51 + name = filepath.Base(source) 52 + } 53 + 54 + relativeRepoPath := filepath.Join(did, name) 55 + 56 + if ok, err := x.Enforcer.IsPushAllowed(actorDid.String(), rbac.ThisServer, relativeRepoPath); !ok || err != nil { 57 + l.Error("insufficient permissions", "did", actorDid.String(), "repo", relativeRepoPath) 58 + writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 59 + return 60 + } 61 + 62 + repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, relativeRepoPath) 63 + if err != nil { 64 + fail(xrpcerr.GenericError(err)) 65 + return 66 + } 67 + 68 + gr, err := git.PlainOpen(repoPath) 69 + if err != nil { 70 + fail(xrpcerr.GenericError(fmt.Errorf("failed to open repository: %w", err))) 71 + return 72 + } 73 + 74 + forkCommit, err := gr.ResolveRevision(branch) 75 + if err != nil { 76 + l.Error("error resolving ref revision", "msg", err.Error()) 77 + fail(xrpcerr.GenericError(fmt.Errorf("error resolving revision %s: %w", branch, err))) 78 + return 79 + } 80 + 81 + sourceCommit, err := gr.ResolveRevision(hiddenRef) 82 + if err != nil { 83 + l.Error("error resolving hidden ref revision", "msg", err.Error()) 84 + fail(xrpcerr.GenericError(fmt.Errorf("error resolving revision %s: %w", hiddenRef, err))) 85 + return 86 + } 87 + 88 + status := types.UpToDate 89 + if forkCommit.Hash.String() != sourceCommit.Hash.String() { 90 + isAncestor, err := forkCommit.IsAncestor(sourceCommit) 91 + if err != nil { 92 + l.Error("error checking ancestor relationship", "error", err.Error()) 93 + fail(xrpcerr.GenericError(fmt.Errorf("error resolving whether %s is ancestor of %s: %w", branch, hiddenRef, err))) 94 + return 95 + } 96 + 97 + if isAncestor { 98 + status = types.FastForwardable 99 + } else { 100 + status = types.Conflict 101 + } 102 + } 103 + 104 + response := tangled.RepoForkStatus_Output{ 105 + Status: int64(status), 106 + } 107 + 108 + w.Header().Set("Content-Type", "application/json") 109 + w.WriteHeader(http.StatusOK) 110 + json.NewEncoder(w).Encode(response) 111 + }
+73
knotserver/xrpc/fork_sync.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net/http" 7 + "path/filepath" 8 + 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + securejoin "github.com/cyphar/filepath-securejoin" 11 + "tangled.sh/tangled.sh/core/api/tangled" 12 + "tangled.sh/tangled.sh/core/knotserver/git" 13 + "tangled.sh/tangled.sh/core/rbac" 14 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 15 + ) 16 + 17 + func (x *Xrpc) ForkSync(w http.ResponseWriter, r *http.Request) { 18 + l := x.Logger.With("handler", "ForkSync") 19 + fail := func(e xrpcerr.XrpcError) { 20 + l.Error("failed", "kind", e.Tag, "error", e.Message) 21 + writeError(w, e, http.StatusBadRequest) 22 + } 23 + 24 + actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 25 + if !ok { 26 + fail(xrpcerr.MissingActorDidError) 27 + return 28 + } 29 + 30 + var data tangled.RepoForkSync_Input 31 + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 32 + fail(xrpcerr.GenericError(err)) 33 + return 34 + } 35 + 36 + did := data.Did 37 + name := data.Name 38 + branch := data.Branch 39 + 40 + if did == "" || name == "" { 41 + fail(xrpcerr.GenericError(fmt.Errorf("did, name are required"))) 42 + return 43 + } 44 + 45 + relativeRepoPath := filepath.Join(did, name) 46 + 47 + if ok, err := x.Enforcer.IsPushAllowed(actorDid.String(), rbac.ThisServer, relativeRepoPath); !ok || err != nil { 48 + l.Error("insufficient permissions", "did", actorDid.String(), "repo", relativeRepoPath) 49 + writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 50 + return 51 + } 52 + 53 + repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, relativeRepoPath) 54 + if err != nil { 55 + fail(xrpcerr.GenericError(err)) 56 + return 57 + } 58 + 59 + gr, err := git.Open(repoPath, branch) 60 + if err != nil { 61 + fail(xrpcerr.GenericError(fmt.Errorf("failed to open repository: %w", err))) 62 + return 63 + } 64 + 65 + err = gr.Sync() 66 + if err != nil { 67 + l.Error("error syncing repo fork", "error", err.Error()) 68 + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 69 + return 70 + } 71 + 72 + w.WriteHeader(http.StatusOK) 73 + }
+104
knotserver/xrpc/hidden_ref.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net/http" 7 + 8 + comatproto "github.com/bluesky-social/indigo/api/atproto" 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + "github.com/bluesky-social/indigo/xrpc" 11 + securejoin "github.com/cyphar/filepath-securejoin" 12 + "tangled.sh/tangled.sh/core/api/tangled" 13 + "tangled.sh/tangled.sh/core/knotserver/git" 14 + "tangled.sh/tangled.sh/core/rbac" 15 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 16 + ) 17 + 18 + func (x *Xrpc) HiddenRef(w http.ResponseWriter, r *http.Request) { 19 + l := x.Logger.With("handler", "HiddenRef") 20 + fail := func(e xrpcerr.XrpcError) { 21 + l.Error("failed", "kind", e.Tag, "error", e.Message) 22 + writeError(w, e, http.StatusBadRequest) 23 + } 24 + 25 + actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 26 + if !ok { 27 + fail(xrpcerr.MissingActorDidError) 28 + return 29 + } 30 + 31 + var data tangled.RepoHiddenRef_Input 32 + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 33 + fail(xrpcerr.GenericError(err)) 34 + return 35 + } 36 + 37 + forkRef := data.ForkRef 38 + remoteRef := data.RemoteRef 39 + repoAtUri := data.Repo 40 + 41 + if forkRef == "" || remoteRef == "" || repoAtUri == "" { 42 + fail(xrpcerr.GenericError(fmt.Errorf("forkRef, remoteRef, and repo are required"))) 43 + return 44 + } 45 + 46 + repoAt, err := syntax.ParseATURI(repoAtUri) 47 + if err != nil { 48 + fail(xrpcerr.InvalidRepoError(repoAtUri)) 49 + return 50 + } 51 + 52 + ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String()) 53 + if err != nil || ident.Handle.IsInvalidHandle() { 54 + fail(xrpcerr.GenericError(fmt.Errorf("failed to resolve handle: %w", err))) 55 + return 56 + } 57 + 58 + xrpcc := xrpc.Client{Host: ident.PDSEndpoint()} 59 + resp, err := comatproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 60 + if err != nil { 61 + fail(xrpcerr.GenericError(err)) 62 + return 63 + } 64 + 65 + repo := resp.Value.Val.(*tangled.Repo) 66 + didPath, err := securejoin.SecureJoin(actorDid.String(), repo.Name) 67 + if err != nil { 68 + fail(xrpcerr.GenericError(err)) 69 + return 70 + } 71 + 72 + if ok, err := x.Enforcer.IsPushAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil { 73 + l.Error("insufficient permissions", "did", actorDid.String(), "repo", didPath) 74 + writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 75 + return 76 + } 77 + 78 + repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, didPath) 79 + if err != nil { 80 + fail(xrpcerr.GenericError(err)) 81 + return 82 + } 83 + 84 + gr, err := git.PlainOpen(repoPath) 85 + if err != nil { 86 + fail(xrpcerr.GenericError(fmt.Errorf("failed to open repository: %w", err))) 87 + return 88 + } 89 + 90 + err = gr.TrackHiddenRemoteRef(forkRef, remoteRef) 91 + if err != nil { 92 + l.Error("error tracking hidden remote ref", "error", err.Error()) 93 + writeError(w, xrpcerr.GitError(err), http.StatusInternalServerError) 94 + return 95 + } 96 + 97 + response := tangled.RepoHiddenRef_Output{ 98 + Success: true, 99 + } 100 + 101 + w.Header().Set("Content-Type", "application/json") 102 + w.WriteHeader(http.StatusOK) 103 + json.NewEncoder(w).Encode(response) 104 + }
+49
knotserver/xrpc/list_keys.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "net/http" 5 + "strconv" 6 + 7 + "tangled.sh/tangled.sh/core/api/tangled" 8 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 9 + ) 10 + 11 + func (x *Xrpc) ListKeys(w http.ResponseWriter, r *http.Request) { 12 + cursor := r.URL.Query().Get("cursor") 13 + 14 + limit := 100 // default 15 + if limitStr := r.URL.Query().Get("limit"); limitStr != "" { 16 + if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 1000 { 17 + limit = l 18 + } 19 + } 20 + 21 + keys, nextCursor, err := x.Db.GetPublicKeysPaginated(limit, cursor) 22 + if err != nil { 23 + x.Logger.Error("failed to get public keys", "error", err) 24 + writeError(w, xrpcerr.NewXrpcError( 25 + xrpcerr.WithTag("InternalServerError"), 26 + xrpcerr.WithMessage("failed to retrieve public keys"), 27 + ), http.StatusInternalServerError) 28 + return 29 + } 30 + 31 + publicKeys := make([]*tangled.KnotListKeys_PublicKey, 0, len(keys)) 32 + for _, key := range keys { 33 + publicKeys = append(publicKeys, &tangled.KnotListKeys_PublicKey{ 34 + Did: key.Did, 35 + Key: key.Key, 36 + CreatedAt: key.CreatedAt, 37 + }) 38 + } 39 + 40 + response := tangled.KnotListKeys_Output{ 41 + Keys: publicKeys, 42 + } 43 + 44 + if nextCursor != "" { 45 + response.Cursor = &nextCursor 46 + } 47 + 48 + writeJson(w, response) 49 + }
+114
knotserver/xrpc/merge.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "errors" 6 + "fmt" 7 + "net/http" 8 + 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + securejoin "github.com/cyphar/filepath-securejoin" 11 + "tangled.sh/tangled.sh/core/api/tangled" 12 + "tangled.sh/tangled.sh/core/knotserver/git" 13 + "tangled.sh/tangled.sh/core/patchutil" 14 + "tangled.sh/tangled.sh/core/rbac" 15 + "tangled.sh/tangled.sh/core/types" 16 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 17 + ) 18 + 19 + func (x *Xrpc) Merge(w http.ResponseWriter, r *http.Request) { 20 + l := x.Logger.With("handler", "Merge") 21 + fail := func(e xrpcerr.XrpcError) { 22 + l.Error("failed", "kind", e.Tag, "error", e.Message) 23 + writeError(w, e, http.StatusBadRequest) 24 + } 25 + 26 + actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 27 + if !ok { 28 + fail(xrpcerr.MissingActorDidError) 29 + return 30 + } 31 + 32 + var data tangled.RepoMerge_Input 33 + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 34 + fail(xrpcerr.GenericError(err)) 35 + return 36 + } 37 + 38 + did := data.Did 39 + name := data.Name 40 + 41 + if did == "" || name == "" { 42 + fail(xrpcerr.GenericError(fmt.Errorf("did and name are required"))) 43 + return 44 + } 45 + 46 + relativeRepoPath, err := securejoin.SecureJoin(did, name) 47 + if err != nil { 48 + fail(xrpcerr.GenericError(err)) 49 + return 50 + } 51 + 52 + if ok, err := x.Enforcer.IsPushAllowed(actorDid.String(), rbac.ThisServer, relativeRepoPath); !ok || err != nil { 53 + l.Error("insufficient permissions", "did", actorDid.String(), "repo", relativeRepoPath) 54 + writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 55 + return 56 + } 57 + 58 + repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, relativeRepoPath) 59 + if err != nil { 60 + fail(xrpcerr.GenericError(err)) 61 + return 62 + } 63 + 64 + gr, err := git.Open(repoPath, data.Branch) 65 + if err != nil { 66 + fail(xrpcerr.GenericError(fmt.Errorf("failed to open repository: %w", err))) 67 + return 68 + } 69 + 70 + mo := git.MergeOptions{} 71 + if data.AuthorName != nil { 72 + mo.AuthorName = *data.AuthorName 73 + } 74 + if data.AuthorEmail != nil { 75 + mo.AuthorEmail = *data.AuthorEmail 76 + } 77 + if data.CommitBody != nil { 78 + mo.CommitBody = *data.CommitBody 79 + } 80 + if data.CommitMessage != nil { 81 + mo.CommitMessage = *data.CommitMessage 82 + } 83 + 84 + mo.CommitterName = x.Config.Git.UserName 85 + mo.CommitterEmail = x.Config.Git.UserEmail 86 + mo.FormatPatch = patchutil.IsFormatPatch(data.Patch) 87 + 88 + err = gr.MergeWithOptions([]byte(data.Patch), data.Branch, mo) 89 + if err != nil { 90 + var mergeErr *git.ErrMerge 91 + if errors.As(err, &mergeErr) { 92 + conflicts := make([]types.ConflictInfo, len(mergeErr.Conflicts)) 93 + for i, conflict := range mergeErr.Conflicts { 94 + conflicts[i] = types.ConflictInfo{ 95 + Filename: conflict.Filename, 96 + Reason: conflict.Reason, 97 + } 98 + } 99 + 100 + conflictErr := xrpcerr.NewXrpcError( 101 + xrpcerr.WithTag("MergeConflict"), 102 + xrpcerr.WithMessage(fmt.Sprintf("Merge failed due to conflicts: %s", mergeErr.Message)), 103 + ) 104 + writeError(w, conflictErr, http.StatusConflict) 105 + return 106 + } else { 107 + l.Error("failed to merge", "error", err.Error()) 108 + writeError(w, xrpcerr.GitError(err), http.StatusInternalServerError) 109 + return 110 + } 111 + } 112 + 113 + w.WriteHeader(http.StatusOK) 114 + }
+87
knotserver/xrpc/merge_check.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "errors" 6 + "fmt" 7 + "net/http" 8 + 9 + securejoin "github.com/cyphar/filepath-securejoin" 10 + "tangled.sh/tangled.sh/core/api/tangled" 11 + "tangled.sh/tangled.sh/core/knotserver/git" 12 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 13 + ) 14 + 15 + func (x *Xrpc) MergeCheck(w http.ResponseWriter, r *http.Request) { 16 + l := x.Logger.With("handler", "MergeCheck") 17 + fail := func(e xrpcerr.XrpcError) { 18 + l.Error("failed", "kind", e.Tag, "error", e.Message) 19 + writeError(w, e, http.StatusBadRequest) 20 + } 21 + 22 + var data tangled.RepoMergeCheck_Input 23 + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 24 + fail(xrpcerr.GenericError(err)) 25 + return 26 + } 27 + 28 + did := data.Did 29 + name := data.Name 30 + 31 + if did == "" || name == "" { 32 + fail(xrpcerr.GenericError(fmt.Errorf("did and name are required"))) 33 + return 34 + } 35 + 36 + relativeRepoPath, err := securejoin.SecureJoin(did, name) 37 + if err != nil { 38 + fail(xrpcerr.GenericError(err)) 39 + return 40 + } 41 + 42 + repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, relativeRepoPath) 43 + if err != nil { 44 + fail(xrpcerr.GenericError(err)) 45 + return 46 + } 47 + 48 + gr, err := git.Open(repoPath, data.Branch) 49 + if err != nil { 50 + fail(xrpcerr.GenericError(fmt.Errorf("failed to open repository: %w", err))) 51 + return 52 + } 53 + 54 + err = gr.MergeCheck([]byte(data.Patch), data.Branch) 55 + 56 + response := tangled.RepoMergeCheck_Output{ 57 + Is_conflicted: false, 58 + } 59 + 60 + if err != nil { 61 + var mergeErr *git.ErrMerge 62 + if errors.As(err, &mergeErr) { 63 + response.Is_conflicted = true 64 + 65 + conflicts := make([]*tangled.RepoMergeCheck_ConflictInfo, len(mergeErr.Conflicts)) 66 + for i, conflict := range mergeErr.Conflicts { 67 + conflicts[i] = &tangled.RepoMergeCheck_ConflictInfo{ 68 + Filename: conflict.Filename, 69 + Reason: conflict.Reason, 70 + } 71 + } 72 + response.Conflicts = conflicts 73 + 74 + if mergeErr.Message != "" { 75 + response.Message = &mergeErr.Message 76 + } 77 + } else { 78 + response.Is_conflicted = true 79 + errMsg := err.Error() 80 + response.Error = &errMsg 81 + } 82 + } 83 + 84 + w.Header().Set("Content-Type", "application/json") 85 + w.WriteHeader(http.StatusOK) 86 + json.NewEncoder(w).Encode(response) 87 + }
+22
knotserver/xrpc/owner.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "net/http" 5 + 6 + "tangled.sh/tangled.sh/core/api/tangled" 7 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 8 + ) 9 + 10 + func (x *Xrpc) Owner(w http.ResponseWriter, r *http.Request) { 11 + owner := x.Config.Server.Owner 12 + if owner == "" { 13 + writeError(w, xrpcerr.OwnerNotFoundError, http.StatusInternalServerError) 14 + return 15 + } 16 + 17 + response := tangled.Owner_Output{ 18 + Owner: owner, 19 + } 20 + 21 + writeJson(w, response) 22 + }
+81
knotserver/xrpc/repo_archive.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "compress/gzip" 5 + "fmt" 6 + "net/http" 7 + "strings" 8 + 9 + "github.com/go-git/go-git/v5/plumbing" 10 + 11 + "tangled.sh/tangled.sh/core/knotserver/git" 12 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 13 + ) 14 + 15 + func (x *Xrpc) RepoArchive(w http.ResponseWriter, r *http.Request) { 16 + repo := r.URL.Query().Get("repo") 17 + repoPath, err := x.parseRepoParam(repo) 18 + if err != nil { 19 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 20 + return 21 + } 22 + 23 + ref := r.URL.Query().Get("ref") 24 + // ref can be empty (git.Open handles this) 25 + 26 + format := r.URL.Query().Get("format") 27 + if format == "" { 28 + format = "tar.gz" // default 29 + } 30 + 31 + prefix := r.URL.Query().Get("prefix") 32 + 33 + if format != "tar.gz" { 34 + writeError(w, xrpcerr.NewXrpcError( 35 + xrpcerr.WithTag("InvalidRequest"), 36 + xrpcerr.WithMessage("only tar.gz format is supported"), 37 + ), http.StatusBadRequest) 38 + return 39 + } 40 + 41 + gr, err := git.Open(repoPath, ref) 42 + if err != nil { 43 + writeError(w, xrpcerr.RefNotFoundError, http.StatusNotFound) 44 + return 45 + } 46 + 47 + repoParts := strings.Split(repo, "/") 48 + repoName := repoParts[len(repoParts)-1] 49 + 50 + safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(ref).Short(), "/", "-") 51 + 52 + var archivePrefix string 53 + if prefix != "" { 54 + archivePrefix = prefix 55 + } else { 56 + archivePrefix = fmt.Sprintf("%s-%s", repoName, safeRefFilename) 57 + } 58 + 59 + filename := fmt.Sprintf("%s-%s.tar.gz", repoName, safeRefFilename) 60 + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) 61 + w.Header().Set("Content-Type", "application/gzip") 62 + 63 + gw := gzip.NewWriter(w) 64 + defer gw.Close() 65 + 66 + err = gr.WriteTar(gw, archivePrefix) 67 + if err != nil { 68 + // once we start writing to the body we can't report error anymore 69 + // so we are only left with logging the error 70 + x.Logger.Error("writing tar file", "error", err.Error()) 71 + return 72 + } 73 + 74 + err = gw.Flush() 75 + if err != nil { 76 + // once we start writing to the body we can't report error anymore 77 + // so we are only left with logging the error 78 + x.Logger.Error("flushing", "error", err.Error()) 79 + return 80 + } 81 + }
+143
knotserver/xrpc/repo_blob.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "crypto/sha256" 5 + "encoding/base64" 6 + "fmt" 7 + "net/http" 8 + "path/filepath" 9 + "slices" 10 + "strings" 11 + 12 + "tangled.sh/tangled.sh/core/api/tangled" 13 + "tangled.sh/tangled.sh/core/knotserver/git" 14 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 15 + ) 16 + 17 + func (x *Xrpc) RepoBlob(w http.ResponseWriter, r *http.Request) { 18 + repo := r.URL.Query().Get("repo") 19 + repoPath, err := x.parseRepoParam(repo) 20 + if err != nil { 21 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 22 + return 23 + } 24 + 25 + ref := r.URL.Query().Get("ref") 26 + // ref can be empty (git.Open handles this) 27 + 28 + treePath := r.URL.Query().Get("path") 29 + if treePath == "" { 30 + writeError(w, xrpcerr.NewXrpcError( 31 + xrpcerr.WithTag("InvalidRequest"), 32 + xrpcerr.WithMessage("missing path parameter"), 33 + ), http.StatusBadRequest) 34 + return 35 + } 36 + 37 + raw := r.URL.Query().Get("raw") == "true" 38 + 39 + gr, err := git.Open(repoPath, ref) 40 + if err != nil { 41 + writeError(w, xrpcerr.RefNotFoundError, http.StatusNotFound) 42 + return 43 + } 44 + 45 + contents, err := gr.RawContent(treePath) 46 + if err != nil { 47 + x.Logger.Error("file content", "error", err.Error()) 48 + writeError(w, xrpcerr.NewXrpcError( 49 + xrpcerr.WithTag("FileNotFound"), 50 + xrpcerr.WithMessage("file not found at the specified path"), 51 + ), http.StatusNotFound) 52 + return 53 + } 54 + 55 + mimeType := http.DetectContentType(contents) 56 + 57 + if filepath.Ext(treePath) == ".svg" { 58 + mimeType = "image/svg+xml" 59 + } 60 + 61 + if raw { 62 + contentHash := sha256.Sum256(contents) 63 + eTag := fmt.Sprintf("\"%x\"", contentHash) 64 + 65 + switch { 66 + case strings.HasPrefix(mimeType, "image/"), strings.HasPrefix(mimeType, "video/"): 67 + if clientETag := r.Header.Get("If-None-Match"); clientETag == eTag { 68 + w.WriteHeader(http.StatusNotModified) 69 + return 70 + } 71 + w.Header().Set("ETag", eTag) 72 + w.Header().Set("Content-Type", mimeType) 73 + 74 + case strings.HasPrefix(mimeType, "text/"): 75 + w.Header().Set("Cache-Control", "public, no-cache") 76 + // serve all text content as text/plain 77 + w.Header().Set("Content-Type", "text/plain; charset=utf-8") 78 + 79 + case isTextualMimeType(mimeType): 80 + // handle textual application types (json, xml, etc.) as text/plain 81 + w.Header().Set("Cache-Control", "public, no-cache") 82 + w.Header().Set("Content-Type", "text/plain; charset=utf-8") 83 + 84 + default: 85 + x.Logger.Error("attempted to serve disallowed file type", "mimetype", mimeType) 86 + writeError(w, xrpcerr.NewXrpcError( 87 + xrpcerr.WithTag("InvalidRequest"), 88 + xrpcerr.WithMessage("only image, video, and text files can be accessed directly"), 89 + ), http.StatusForbidden) 90 + return 91 + } 92 + w.Write(contents) 93 + return 94 + } 95 + 96 + isTextual := func(mt string) bool { 97 + return strings.HasPrefix(mt, "text/") || isTextualMimeType(mt) 98 + } 99 + 100 + var content string 101 + var encoding string 102 + 103 + isBinary := !isTextual(mimeType) 104 + 105 + if isBinary { 106 + content = base64.StdEncoding.EncodeToString(contents) 107 + encoding = "base64" 108 + } else { 109 + content = string(contents) 110 + encoding = "utf-8" 111 + } 112 + 113 + response := tangled.RepoBlob_Output{ 114 + Ref: ref, 115 + Path: treePath, 116 + Content: content, 117 + Encoding: &encoding, 118 + Size: &[]int64{int64(len(contents))}[0], 119 + IsBinary: &isBinary, 120 + } 121 + 122 + if mimeType != "" { 123 + response.MimeType = &mimeType 124 + } 125 + 126 + writeJson(w, response) 127 + } 128 + 129 + // isTextualMimeType returns true if the MIME type represents textual content 130 + // that should be served as text/plain for security reasons 131 + func isTextualMimeType(mimeType string) bool { 132 + textualTypes := []string{ 133 + "application/json", 134 + "application/xml", 135 + "application/yaml", 136 + "application/x-yaml", 137 + "application/toml", 138 + "application/javascript", 139 + "application/ecmascript", 140 + } 141 + 142 + return slices.Contains(textualTypes, mimeType) 143 + }
+85
knotserver/xrpc/repo_branch.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "net/http" 5 + "net/url" 6 + "time" 7 + 8 + "tangled.sh/tangled.sh/core/api/tangled" 9 + "tangled.sh/tangled.sh/core/knotserver/git" 10 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 11 + ) 12 + 13 + func (x *Xrpc) RepoBranch(w http.ResponseWriter, r *http.Request) { 14 + repo := r.URL.Query().Get("repo") 15 + repoPath, err := x.parseRepoParam(repo) 16 + if err != nil { 17 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 18 + return 19 + } 20 + 21 + name := r.URL.Query().Get("name") 22 + if name == "" { 23 + writeError(w, xrpcerr.NewXrpcError( 24 + xrpcerr.WithTag("InvalidRequest"), 25 + xrpcerr.WithMessage("missing name parameter"), 26 + ), http.StatusBadRequest) 27 + return 28 + } 29 + 30 + branchName, _ := url.PathUnescape(name) 31 + 32 + gr, err := git.PlainOpen(repoPath) 33 + if err != nil { 34 + writeError(w, xrpcerr.RepoNotFoundError, http.StatusNoContent) 35 + return 36 + } 37 + 38 + ref, err := gr.Branch(branchName) 39 + if err != nil { 40 + x.Logger.Error("getting branch", "error", err.Error()) 41 + writeError(w, xrpcerr.NewXrpcError( 42 + xrpcerr.WithTag("BranchNotFound"), 43 + xrpcerr.WithMessage("branch not found"), 44 + ), http.StatusNotFound) 45 + return 46 + } 47 + 48 + commit, err := gr.Commit(ref.Hash()) 49 + if err != nil { 50 + x.Logger.Error("getting commit object", "error", err.Error()) 51 + writeError(w, xrpcerr.NewXrpcError( 52 + xrpcerr.WithTag("BranchNotFound"), 53 + xrpcerr.WithMessage("failed to get commit object"), 54 + ), http.StatusInternalServerError) 55 + return 56 + } 57 + 58 + defaultBranch, err := gr.FindMainBranch() 59 + isDefault := false 60 + if err != nil { 61 + x.Logger.Error("getting default branch", "error", err.Error()) 62 + } else if defaultBranch == branchName { 63 + isDefault = true 64 + } 65 + 66 + response := tangled.RepoBranch_Output{ 67 + Name: ref.Name().Short(), 68 + Hash: ref.Hash().String(), 69 + ShortHash: &[]string{ref.Hash().String()[:7]}[0], 70 + When: commit.Author.When.Format(time.RFC3339), 71 + IsDefault: &isDefault, 72 + } 73 + 74 + if commit.Message != "" { 75 + response.Message = &commit.Message 76 + } 77 + 78 + response.Author = &tangled.RepoBranch_Signature{ 79 + Name: commit.Author.Name, 80 + Email: commit.Author.Email, 81 + When: commit.Author.When.Format(time.RFC3339), 82 + } 83 + 84 + writeJson(w, response) 85 + }
+56
knotserver/xrpc/repo_branches.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "net/http" 5 + "strconv" 6 + 7 + "tangled.sh/tangled.sh/core/knotserver/git" 8 + "tangled.sh/tangled.sh/core/types" 9 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 10 + ) 11 + 12 + func (x *Xrpc) RepoBranches(w http.ResponseWriter, r *http.Request) { 13 + repo := r.URL.Query().Get("repo") 14 + repoPath, err := x.parseRepoParam(repo) 15 + if err != nil { 16 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 17 + return 18 + } 19 + 20 + cursor := r.URL.Query().Get("cursor") 21 + 22 + // limit := 50 // default 23 + // if limitStr := r.URL.Query().Get("limit"); limitStr != "" { 24 + // if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 { 25 + // limit = l 26 + // } 27 + // } 28 + 29 + limit := 500 30 + 31 + gr, err := git.PlainOpen(repoPath) 32 + if err != nil { 33 + writeError(w, xrpcerr.RepoNotFoundError, http.StatusNoContent) 34 + return 35 + } 36 + 37 + branches, _ := gr.Branches() 38 + 39 + offset := 0 40 + if cursor != "" { 41 + if o, err := strconv.Atoi(cursor); err == nil && o >= 0 && o < len(branches) { 42 + offset = o 43 + } 44 + } 45 + 46 + end := min(offset+limit, len(branches)) 47 + 48 + paginatedBranches := branches[offset:end] 49 + 50 + // Create response using existing types.RepoBranchesResponse 51 + response := types.RepoBranchesResponse{ 52 + Branches: paginatedBranches, 53 + } 54 + 55 + writeJson(w, response) 56 + }
+82
knotserver/xrpc/repo_compare.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "fmt" 5 + "net/http" 6 + 7 + "tangled.sh/tangled.sh/core/knotserver/git" 8 + "tangled.sh/tangled.sh/core/types" 9 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 10 + ) 11 + 12 + func (x *Xrpc) RepoCompare(w http.ResponseWriter, r *http.Request) { 13 + repo := r.URL.Query().Get("repo") 14 + repoPath, err := x.parseRepoParam(repo) 15 + if err != nil { 16 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 17 + return 18 + } 19 + 20 + rev1 := r.URL.Query().Get("rev1") 21 + if rev1 == "" { 22 + writeError(w, xrpcerr.NewXrpcError( 23 + xrpcerr.WithTag("InvalidRequest"), 24 + xrpcerr.WithMessage("missing rev1 parameter"), 25 + ), http.StatusBadRequest) 26 + return 27 + } 28 + 29 + rev2 := r.URL.Query().Get("rev2") 30 + if rev2 == "" { 31 + writeError(w, xrpcerr.NewXrpcError( 32 + xrpcerr.WithTag("InvalidRequest"), 33 + xrpcerr.WithMessage("missing rev2 parameter"), 34 + ), http.StatusBadRequest) 35 + return 36 + } 37 + 38 + gr, err := git.PlainOpen(repoPath) 39 + if err != nil { 40 + writeError(w, xrpcerr.RepoNotFoundError, http.StatusNoContent) 41 + return 42 + } 43 + 44 + commit1, err := gr.ResolveRevision(rev1) 45 + if err != nil { 46 + x.Logger.Error("error resolving revision 1", "msg", err.Error()) 47 + writeError(w, xrpcerr.NewXrpcError( 48 + xrpcerr.WithTag("RevisionNotFound"), 49 + xrpcerr.WithMessage(fmt.Sprintf("error resolving revision %s", rev1)), 50 + ), http.StatusBadRequest) 51 + return 52 + } 53 + 54 + commit2, err := gr.ResolveRevision(rev2) 55 + if err != nil { 56 + x.Logger.Error("error resolving revision 2", "msg", err.Error()) 57 + writeError(w, xrpcerr.NewXrpcError( 58 + xrpcerr.WithTag("RevisionNotFound"), 59 + xrpcerr.WithMessage(fmt.Sprintf("error resolving revision %s", rev2)), 60 + ), http.StatusBadRequest) 61 + return 62 + } 63 + 64 + rawPatch, formatPatch, err := gr.FormatPatch(commit1, commit2) 65 + if err != nil { 66 + x.Logger.Error("error comparing revisions", "msg", err.Error()) 67 + writeError(w, xrpcerr.NewXrpcError( 68 + xrpcerr.WithTag("CompareError"), 69 + xrpcerr.WithMessage("error comparing revisions"), 70 + ), http.StatusBadRequest) 71 + return 72 + } 73 + 74 + response := types.RepoFormatPatchResponse{ 75 + Rev1: commit1.Hash.String(), 76 + Rev2: commit2.Hash.String(), 77 + FormatPatch: formatPatch, 78 + Patch: rawPatch, 79 + } 80 + 81 + writeJson(w, response) 82 + }
+41
knotserver/xrpc/repo_diff.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "net/http" 5 + 6 + "tangled.sh/tangled.sh/core/knotserver/git" 7 + "tangled.sh/tangled.sh/core/types" 8 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 9 + ) 10 + 11 + func (x *Xrpc) RepoDiff(w http.ResponseWriter, r *http.Request) { 12 + repo := r.URL.Query().Get("repo") 13 + repoPath, err := x.parseRepoParam(repo) 14 + if err != nil { 15 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 16 + return 17 + } 18 + 19 + ref := r.URL.Query().Get("ref") 20 + // ref can be empty (git.Open handles this) 21 + 22 + gr, err := git.Open(repoPath, ref) 23 + if err != nil { 24 + writeError(w, xrpcerr.RefNotFoundError, http.StatusNotFound) 25 + return 26 + } 27 + 28 + diff, err := gr.Diff() 29 + if err != nil { 30 + x.Logger.Error("getting diff", "error", err.Error()) 31 + writeError(w, xrpcerr.RefNotFoundError, http.StatusInternalServerError) 32 + return 33 + } 34 + 35 + response := types.RepoCommitResponse{ 36 + Ref: ref, 37 + Diff: diff, 38 + } 39 + 40 + writeJson(w, response) 41 + }
+39
knotserver/xrpc/repo_get_default_branch.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "net/http" 5 + "time" 6 + 7 + "tangled.sh/tangled.sh/core/api/tangled" 8 + "tangled.sh/tangled.sh/core/knotserver/git" 9 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 10 + ) 11 + 12 + func (x *Xrpc) RepoGetDefaultBranch(w http.ResponseWriter, r *http.Request) { 13 + repo := r.URL.Query().Get("repo") 14 + repoPath, err := x.parseRepoParam(repo) 15 + if err != nil { 16 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 17 + return 18 + } 19 + 20 + gr, err := git.PlainOpen(repoPath) 21 + 22 + branch, err := gr.FindMainBranch() 23 + if err != nil { 24 + x.Logger.Error("getting default branch", "error", err.Error()) 25 + writeError(w, xrpcerr.NewXrpcError( 26 + xrpcerr.WithTag("InvalidRequest"), 27 + xrpcerr.WithMessage("failed to get default branch"), 28 + ), http.StatusInternalServerError) 29 + return 30 + } 31 + 32 + response := tangled.RepoGetDefaultBranch_Output{ 33 + Name: branch, 34 + Hash: "", 35 + When: time.UnixMicro(0).Format(time.RFC3339), 36 + } 37 + 38 + writeJson(w, response) 39 + }
+76
knotserver/xrpc/repo_languages.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "context" 5 + "math" 6 + "net/http" 7 + "time" 8 + 9 + "tangled.sh/tangled.sh/core/api/tangled" 10 + "tangled.sh/tangled.sh/core/knotserver/git" 11 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 12 + ) 13 + 14 + func (x *Xrpc) RepoLanguages(w http.ResponseWriter, r *http.Request) { 15 + repo := r.URL.Query().Get("repo") 16 + repoPath, err := x.parseRepoParam(repo) 17 + if err != nil { 18 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 19 + return 20 + } 21 + 22 + ref := r.URL.Query().Get("ref") 23 + 24 + gr, err := git.Open(repoPath, ref) 25 + if err != nil { 26 + x.Logger.Error("opening repo", "error", err.Error()) 27 + writeError(w, xrpcerr.RefNotFoundError, http.StatusNotFound) 28 + return 29 + } 30 + 31 + ctx, cancel := context.WithTimeout(r.Context(), 1*time.Second) 32 + defer cancel() 33 + 34 + sizes, err := gr.AnalyzeLanguages(ctx) 35 + if err != nil { 36 + x.Logger.Error("failed to analyze languages", "error", err.Error()) 37 + writeError(w, xrpcerr.NewXrpcError( 38 + xrpcerr.WithTag("InvalidRequest"), 39 + xrpcerr.WithMessage("failed to analyze repository languages"), 40 + ), http.StatusNoContent) 41 + return 42 + } 43 + 44 + var apiLanguages []*tangled.RepoLanguages_Language 45 + var totalSize int64 46 + 47 + for _, size := range sizes { 48 + totalSize += size 49 + } 50 + 51 + for name, size := range sizes { 52 + percentagef64 := float64(size) / float64(totalSize) * 100 53 + percentage := math.Round(percentagef64) 54 + 55 + lang := &tangled.RepoLanguages_Language{ 56 + Name: name, 57 + Size: size, 58 + Percentage: int64(percentage), 59 + } 60 + 61 + apiLanguages = append(apiLanguages, lang) 62 + } 63 + 64 + response := tangled.RepoLanguages_Output{ 65 + Ref: ref, 66 + Languages: apiLanguages, 67 + } 68 + 69 + if totalSize > 0 { 70 + response.TotalSize = &totalSize 71 + totalFiles := int64(len(sizes)) 72 + response.TotalFiles = &totalFiles 73 + } 74 + 75 + writeJson(w, response) 76 + }
+81
knotserver/xrpc/repo_log.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "net/http" 5 + "strconv" 6 + 7 + "tangled.sh/tangled.sh/core/knotserver/git" 8 + "tangled.sh/tangled.sh/core/types" 9 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 10 + ) 11 + 12 + func (x *Xrpc) RepoLog(w http.ResponseWriter, r *http.Request) { 13 + repo := r.URL.Query().Get("repo") 14 + repoPath, err := x.parseRepoParam(repo) 15 + if err != nil { 16 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 17 + return 18 + } 19 + 20 + ref := r.URL.Query().Get("ref") 21 + 22 + path := r.URL.Query().Get("path") 23 + cursor := r.URL.Query().Get("cursor") 24 + 25 + limit := 50 // default 26 + if limitStr := r.URL.Query().Get("limit"); limitStr != "" { 27 + if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 { 28 + limit = l 29 + } 30 + } 31 + 32 + gr, err := git.Open(repoPath, ref) 33 + if err != nil { 34 + writeError(w, xrpcerr.RefNotFoundError, http.StatusNotFound) 35 + return 36 + } 37 + 38 + offset := 0 39 + if cursor != "" { 40 + if o, err := strconv.Atoi(cursor); err == nil && o >= 0 { 41 + offset = o 42 + } 43 + } 44 + 45 + commits, err := gr.Commits(offset, limit) 46 + if err != nil { 47 + x.Logger.Error("fetching commits", "error", err.Error()) 48 + writeError(w, xrpcerr.NewXrpcError( 49 + xrpcerr.WithTag("PathNotFound"), 50 + xrpcerr.WithMessage("failed to read commit log"), 51 + ), http.StatusNotFound) 52 + return 53 + } 54 + 55 + total, err := gr.TotalCommits() 56 + if err != nil { 57 + x.Logger.Error("fetching total commits", "error", err.Error()) 58 + writeError(w, xrpcerr.NewXrpcError( 59 + xrpcerr.WithTag("InternalServerError"), 60 + xrpcerr.WithMessage("failed to fetch total commits"), 61 + ), http.StatusNotFound) 62 + return 63 + } 64 + 65 + // Create response using existing types.RepoLogResponse 66 + response := types.RepoLogResponse{ 67 + Commits: commits, 68 + Ref: ref, 69 + Page: (offset / limit) + 1, 70 + PerPage: limit, 71 + Total: total, 72 + } 73 + 74 + if path != "" { 75 + response.Description = path 76 + } 77 + 78 + response.Log = true 79 + 80 + writeJson(w, response) 81 + }
+86
knotserver/xrpc/repo_tags.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "net/http" 5 + "strconv" 6 + 7 + "github.com/go-git/go-git/v5/plumbing" 8 + "github.com/go-git/go-git/v5/plumbing/object" 9 + 10 + "tangled.sh/tangled.sh/core/knotserver/git" 11 + "tangled.sh/tangled.sh/core/types" 12 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 13 + ) 14 + 15 + func (x *Xrpc) RepoTags(w http.ResponseWriter, r *http.Request) { 16 + repo := r.URL.Query().Get("repo") 17 + repoPath, err := x.parseRepoParam(repo) 18 + if err != nil { 19 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 20 + return 21 + } 22 + 23 + cursor := r.URL.Query().Get("cursor") 24 + 25 + limit := 50 // default 26 + if limitStr := r.URL.Query().Get("limit"); limitStr != "" { 27 + if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 { 28 + limit = l 29 + } 30 + } 31 + 32 + gr, err := git.PlainOpen(repoPath) 33 + if err != nil { 34 + x.Logger.Error("failed to open", "error", err) 35 + writeError(w, xrpcerr.RepoNotFoundError, http.StatusNoContent) 36 + return 37 + } 38 + 39 + tags, err := gr.Tags() 40 + if err != nil { 41 + x.Logger.Warn("getting tags", "error", err.Error()) 42 + tags = []object.Tag{} 43 + } 44 + 45 + rtags := []*types.TagReference{} 46 + for _, tag := range tags { 47 + var target *object.Tag 48 + if tag.Target != plumbing.ZeroHash { 49 + target = &tag 50 + } 51 + tr := types.TagReference{ 52 + Tag: target, 53 + } 54 + 55 + tr.Reference = types.Reference{ 56 + Name: tag.Name, 57 + Hash: tag.Hash.String(), 58 + } 59 + 60 + if tag.Message != "" { 61 + tr.Message = tag.Message 62 + } 63 + 64 + rtags = append(rtags, &tr) 65 + } 66 + 67 + // apply pagination manually 68 + offset := 0 69 + if cursor != "" { 70 + if o, err := strconv.Atoi(cursor); err == nil && o >= 0 && o < len(rtags) { 71 + offset = o 72 + } 73 + } 74 + 75 + // calculate end index 76 + end := min(offset+limit, len(rtags)) 77 + 78 + paginatedTags := rtags[offset:end] 79 + 80 + // Create response using existing types.RepoTagsResponse 81 + response := types.RepoTagsResponse{ 82 + Tags: paginatedTags, 83 + } 84 + 85 + writeJson(w, response) 86 + }
+89
knotserver/xrpc/repo_tree.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "net/http" 5 + "path/filepath" 6 + "time" 7 + 8 + "tangled.sh/tangled.sh/core/api/tangled" 9 + "tangled.sh/tangled.sh/core/knotserver/git" 10 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 11 + ) 12 + 13 + func (x *Xrpc) RepoTree(w http.ResponseWriter, r *http.Request) { 14 + ctx := r.Context() 15 + 16 + repo := r.URL.Query().Get("repo") 17 + repoPath, err := x.parseRepoParam(repo) 18 + if err != nil { 19 + writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest) 20 + return 21 + } 22 + 23 + ref := r.URL.Query().Get("ref") 24 + // ref can be empty (git.Open handles this) 25 + 26 + path := r.URL.Query().Get("path") 27 + // path can be empty (defaults to root) 28 + 29 + gr, err := git.Open(repoPath, ref) 30 + if err != nil { 31 + x.Logger.Error("failed to open git repository", "error", err, "path", repoPath, "ref", ref) 32 + writeError(w, xrpcerr.RefNotFoundError, http.StatusNotFound) 33 + return 34 + } 35 + 36 + files, err := gr.FileTree(ctx, path) 37 + if err != nil { 38 + x.Logger.Error("failed to get file tree", "error", err, "path", path) 39 + writeError(w, xrpcerr.NewXrpcError( 40 + xrpcerr.WithTag("PathNotFound"), 41 + xrpcerr.WithMessage("failed to read repository tree"), 42 + ), http.StatusNotFound) 43 + return 44 + } 45 + 46 + // convert NiceTree -> tangled.RepoTree_TreeEntry 47 + treeEntries := make([]*tangled.RepoTree_TreeEntry, len(files)) 48 + for i, file := range files { 49 + entry := &tangled.RepoTree_TreeEntry{ 50 + Name: file.Name, 51 + Mode: file.Mode, 52 + Size: file.Size, 53 + Is_file: file.IsFile, 54 + Is_subtree: file.IsSubtree, 55 + } 56 + 57 + if file.LastCommit != nil { 58 + entry.Last_commit = &tangled.RepoTree_LastCommit{ 59 + Hash: file.LastCommit.Hash.String(), 60 + Message: file.LastCommit.Message, 61 + When: file.LastCommit.When.Format(time.RFC3339), 62 + } 63 + } 64 + 65 + treeEntries[i] = entry 66 + } 67 + 68 + var parentPtr *string 69 + if path != "" { 70 + parentPtr = &path 71 + } 72 + 73 + var dotdotPtr *string 74 + if path != "" { 75 + dotdot := filepath.Dir(path) 76 + if dotdot != "." { 77 + dotdotPtr = &dotdot 78 + } 79 + } 80 + 81 + response := tangled.RepoTree_Output{ 82 + Ref: ref, 83 + Parent: parentPtr, 84 + Dotdot: dotdotPtr, 85 + Files: treeEntries, 86 + } 87 + 88 + writeJson(w, response) 89 + }
+89
knotserver/xrpc/set_default_branch.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net/http" 7 + 8 + comatproto "github.com/bluesky-social/indigo/api/atproto" 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + "github.com/bluesky-social/indigo/xrpc" 11 + securejoin "github.com/cyphar/filepath-securejoin" 12 + "tangled.sh/tangled.sh/core/api/tangled" 13 + "tangled.sh/tangled.sh/core/knotserver/git" 14 + "tangled.sh/tangled.sh/core/rbac" 15 + 16 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 17 + ) 18 + 19 + const ActorDid string = "ActorDid" 20 + 21 + func (x *Xrpc) SetDefaultBranch(w http.ResponseWriter, r *http.Request) { 22 + l := x.Logger 23 + fail := func(e xrpcerr.XrpcError) { 24 + l.Error("failed", "kind", e.Tag, "error", e.Message) 25 + writeError(w, e, http.StatusBadRequest) 26 + } 27 + 28 + actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 29 + if !ok { 30 + fail(xrpcerr.MissingActorDidError) 31 + return 32 + } 33 + 34 + var data tangled.RepoSetDefaultBranch_Input 35 + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 36 + fail(xrpcerr.GenericError(err)) 37 + return 38 + } 39 + 40 + // unfortunately we have to resolve repo-at here 41 + repoAt, err := syntax.ParseATURI(data.Repo) 42 + if err != nil { 43 + fail(xrpcerr.InvalidRepoError(data.Repo)) 44 + return 45 + } 46 + 47 + // resolve this aturi to extract the repo record 48 + ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String()) 49 + if err != nil || ident.Handle.IsInvalidHandle() { 50 + fail(xrpcerr.GenericError(fmt.Errorf("failed to resolve handle: %w", err))) 51 + return 52 + } 53 + 54 + xrpcc := xrpc.Client{Host: ident.PDSEndpoint()} 55 + resp, err := comatproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 56 + if err != nil { 57 + fail(xrpcerr.GenericError(err)) 58 + return 59 + } 60 + 61 + repo := resp.Value.Val.(*tangled.Repo) 62 + didPath, err := securejoin.SecureJoin(actorDid.String(), repo.Name) 63 + if err != nil { 64 + fail(xrpcerr.GenericError(err)) 65 + return 66 + } 67 + 68 + if ok, err := x.Enforcer.IsPushAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil { 69 + l.Error("insufficent permissions", "did", actorDid.String()) 70 + writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 71 + return 72 + } 73 + 74 + path, _ := securejoin.SecureJoin(x.Config.Repo.ScanPath, didPath) 75 + gr, err := git.PlainOpen(path) 76 + if err != nil { 77 + fail(xrpcerr.GenericError(err)) 78 + return 79 + } 80 + 81 + err = gr.SetDefaultBranch(data.DefaultBranch) 82 + if err != nil { 83 + l.Error("setting default branch", "error", err.Error()) 84 + writeError(w, xrpcerr.GitError(err), http.StatusInternalServerError) 85 + return 86 + } 87 + 88 + w.WriteHeader(http.StatusOK) 89 + }
+60
knotserver/xrpc/version.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "fmt" 5 + "net/http" 6 + "runtime/debug" 7 + 8 + "tangled.sh/tangled.sh/core/api/tangled" 9 + ) 10 + 11 + // version is set during build time. 12 + var version string 13 + 14 + func (x *Xrpc) Version(w http.ResponseWriter, r *http.Request) { 15 + if version == "" { 16 + info, ok := debug.ReadBuildInfo() 17 + if !ok { 18 + http.Error(w, "failed to read build info", http.StatusInternalServerError) 19 + return 20 + } 21 + 22 + var modVer string 23 + var sha string 24 + var modified bool 25 + 26 + for _, mod := range info.Deps { 27 + if mod.Path == "tangled.sh/tangled.sh/knotserver/xrpc" { 28 + modVer = mod.Version 29 + break 30 + } 31 + } 32 + 33 + for _, setting := range info.Settings { 34 + switch setting.Key { 35 + case "vcs.revision": 36 + sha = setting.Value 37 + case "vcs.modified": 38 + modified = setting.Value == "true" 39 + } 40 + } 41 + 42 + if modVer == "" { 43 + modVer = "unknown" 44 + } 45 + 46 + if sha == "" { 47 + version = modVer 48 + } else if modified { 49 + version = fmt.Sprintf("%s (%s with modifications)", modVer, sha) 50 + } else { 51 + version = fmt.Sprintf("%s (%s)", modVer, sha) 52 + } 53 + } 54 + 55 + response := tangled.KnotVersion_Output{ 56 + Version: version, 57 + } 58 + 59 + writeJson(w, response) 60 + }
+127
knotserver/xrpc/xrpc.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "log/slog" 6 + "net/http" 7 + "strings" 8 + 9 + securejoin "github.com/cyphar/filepath-securejoin" 10 + "tangled.sh/tangled.sh/core/api/tangled" 11 + "tangled.sh/tangled.sh/core/idresolver" 12 + "tangled.sh/tangled.sh/core/jetstream" 13 + "tangled.sh/tangled.sh/core/knotserver/config" 14 + "tangled.sh/tangled.sh/core/knotserver/db" 15 + "tangled.sh/tangled.sh/core/notifier" 16 + "tangled.sh/tangled.sh/core/rbac" 17 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 18 + "tangled.sh/tangled.sh/core/xrpc/serviceauth" 19 + 20 + "github.com/go-chi/chi/v5" 21 + ) 22 + 23 + type Xrpc struct { 24 + Config *config.Config 25 + Db *db.DB 26 + Ingester *jetstream.JetstreamClient 27 + Enforcer *rbac.Enforcer 28 + Logger *slog.Logger 29 + Notifier *notifier.Notifier 30 + Resolver *idresolver.Resolver 31 + ServiceAuth *serviceauth.ServiceAuth 32 + } 33 + 34 + func (x *Xrpc) Router() http.Handler { 35 + r := chi.NewRouter() 36 + 37 + r.Group(func(r chi.Router) { 38 + r.Use(x.ServiceAuth.VerifyServiceAuth) 39 + 40 + r.Post("/"+tangled.RepoSetDefaultBranchNSID, x.SetDefaultBranch) 41 + r.Post("/"+tangled.RepoCreateNSID, x.CreateRepo) 42 + r.Post("/"+tangled.RepoDeleteNSID, x.DeleteRepo) 43 + r.Post("/"+tangled.RepoForkStatusNSID, x.ForkStatus) 44 + r.Post("/"+tangled.RepoForkSyncNSID, x.ForkSync) 45 + r.Post("/"+tangled.RepoHiddenRefNSID, x.HiddenRef) 46 + r.Post("/"+tangled.RepoMergeNSID, x.Merge) 47 + }) 48 + 49 + // merge check is an open endpoint 50 + // 51 + // TODO: should we constrain this more? 52 + // - we can calculate on PR submit/resubmit/gitRefUpdate etc. 53 + // - use ETags on clients to keep requests to a minimum 54 + r.Post("/"+tangled.RepoMergeCheckNSID, x.MergeCheck) 55 + 56 + // repo query endpoints (no auth required) 57 + r.Get("/"+tangled.RepoTreeNSID, x.RepoTree) 58 + r.Get("/"+tangled.RepoLogNSID, x.RepoLog) 59 + r.Get("/"+tangled.RepoBranchesNSID, x.RepoBranches) 60 + r.Get("/"+tangled.RepoTagsNSID, x.RepoTags) 61 + r.Get("/"+tangled.RepoBlobNSID, x.RepoBlob) 62 + r.Get("/"+tangled.RepoDiffNSID, x.RepoDiff) 63 + r.Get("/"+tangled.RepoCompareNSID, x.RepoCompare) 64 + r.Get("/"+tangled.RepoGetDefaultBranchNSID, x.RepoGetDefaultBranch) 65 + r.Get("/"+tangled.RepoBranchNSID, x.RepoBranch) 66 + r.Get("/"+tangled.RepoArchiveNSID, x.RepoArchive) 67 + r.Get("/"+tangled.RepoLanguagesNSID, x.RepoLanguages) 68 + 69 + // knot query endpoints (no auth required) 70 + r.Get("/"+tangled.KnotListKeysNSID, x.ListKeys) 71 + r.Get("/"+tangled.KnotVersionNSID, x.Version) 72 + 73 + // service query endpoints (no auth required) 74 + r.Get("/"+tangled.OwnerNSID, x.Owner) 75 + 76 + return r 77 + } 78 + 79 + // parseRepoParam parses a repo parameter in 'did/repoName' format and returns 80 + // the full repository path on disk 81 + func (x *Xrpc) parseRepoParam(repo string) (string, error) { 82 + if repo == "" { 83 + return "", xrpcerr.NewXrpcError( 84 + xrpcerr.WithTag("InvalidRequest"), 85 + xrpcerr.WithMessage("missing repo parameter"), 86 + ) 87 + } 88 + 89 + // Parse repo string (did/repoName format) 90 + parts := strings.SplitN(repo, "/", 2) 91 + if len(parts) != 2 { 92 + return "", xrpcerr.NewXrpcError( 93 + xrpcerr.WithTag("InvalidRequest"), 94 + xrpcerr.WithMessage("invalid repo format, expected 'did/repoName'"), 95 + ) 96 + } 97 + 98 + did := parts[0] 99 + repoName := parts[1] 100 + 101 + // Construct repository path using the same logic as didPath 102 + didRepoPath, err := securejoin.SecureJoin(did, repoName) 103 + if err != nil { 104 + return "", xrpcerr.RepoNotFoundError 105 + } 106 + 107 + repoPath, err := securejoin.SecureJoin(x.Config.Repo.ScanPath, didRepoPath) 108 + if err != nil { 109 + return "", xrpcerr.RepoNotFoundError 110 + } 111 + 112 + return repoPath, nil 113 + } 114 + 115 + func writeError(w http.ResponseWriter, e xrpcerr.XrpcError, status int) { 116 + w.Header().Set("Content-Type", "application/json") 117 + w.WriteHeader(status) 118 + json.NewEncoder(w).Encode(e) 119 + } 120 + 121 + func writeJson(w http.ResponseWriter, response any) { 122 + w.Header().Set("Content-Type", "application/json") 123 + if err := json.NewEncoder(w).Encode(response); err != nil { 124 + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 125 + return 126 + } 127 + }
+158
legal/privacy.md
··· 1 + # Privacy Policy 2 + 3 + **Last updated:** January 15, 2025 4 + 5 + This Privacy Policy describes how Tangled ("we," "us," or "our") 6 + collects, uses, and shares your personal information when you use our 7 + platform and services (the "Service"). 8 + 9 + ## 1. Information We Collect 10 + 11 + ### Account Information 12 + 13 + When you create an account, we collect: 14 + 15 + - Your chosen username 16 + - Email address 17 + - Profile information you choose to provide 18 + - Authentication data 19 + 20 + ### Content and Activity 21 + 22 + We store: 23 + 24 + - Code repositories and associated metadata 25 + - Issues, pull requests, and comments 26 + - Activity logs and usage patterns 27 + - Public keys for authentication 28 + 29 + ## 2. Data Location and Hosting 30 + 31 + ### EU Data Hosting 32 + 33 + **All Tangled service data is hosted within the European Union.** 34 + Specifically: 35 + 36 + - **Personal Data Servers (PDS):** Accounts hosted on Tangled PDS 37 + (*.tngl.sh) are located in Finland 38 + - **Application Data:** All other service data is stored on EU-based 39 + servers 40 + - **Data Processing:** All data processing occurs within EU 41 + jurisdiction 42 + 43 + ### External PDS Notice 44 + 45 + **Important:** If your account is hosted on Bluesky's PDS or other 46 + self-hosted Personal Data Servers (not *.tngl.sh), we do not control 47 + that data. The data protection, storage location, and privacy 48 + practices for such accounts are governed by the respective PDS 49 + provider's policies, not this Privacy Policy. We only control data 50 + processing within our own services and infrastructure. 51 + 52 + ## 3. Third-Party Data Processors 53 + 54 + We only share your data with the following third-party processors: 55 + 56 + ### Resend (Email Services) 57 + 58 + - **Purpose:** Sending transactional emails (account verification, 59 + notifications) 60 + - **Data Shared:** Email address and necessary message content 61 + 62 + ### Cloudflare (Image Caching) 63 + 64 + - **Purpose:** Caching and optimizing image delivery 65 + - **Data Shared:** Public images and associated metadata for caching 66 + purposes 67 + 68 + ### Posthog (Usage Metrics Tracking) 69 + 70 + - **Purpose:** Tracking usage and platform metrics 71 + - **Data Shared:** Anonymous usage data, IP addresses, DIDs, and browser 72 + information 73 + 74 + ## 4. How We Use Your Information 75 + 76 + We use your information to: 77 + 78 + - Provide and maintain the Service 79 + - Process your transactions and requests 80 + - Send you technical notices and support messages 81 + - Improve and develop new features 82 + - Ensure security and prevent fraud 83 + - Comply with legal obligations 84 + 85 + ## 5. Data Sharing and Disclosure 86 + 87 + We do not sell, trade, or rent your personal information. We may share 88 + your information only in the following circumstances: 89 + 90 + - With the third-party processors listed above 91 + - When required by law or legal process 92 + - To protect our rights, property, or safety, or that of our users 93 + - In connection with a merger, acquisition, or sale of assets (with 94 + appropriate protections) 95 + 96 + ## 6. Data Security 97 + 98 + We implement appropriate technical and organizational measures to 99 + protect your personal information against unauthorized access, 100 + alteration, disclosure, or destruction. However, no method of 101 + transmission over the Internet is 100% secure. 102 + 103 + ## 7. Data Retention 104 + 105 + We retain your personal information for as long as necessary to provide 106 + the Service and fulfill the purposes outlined in this Privacy Policy, 107 + unless a longer retention period is required by law. 108 + 109 + ## 8. Your Rights 110 + 111 + Under applicable data protection laws, you have the right to: 112 + 113 + - Access your personal information 114 + - Correct inaccurate information 115 + - Request deletion of your information 116 + - Object to processing of your information 117 + - Data portability 118 + - Withdraw consent (where applicable) 119 + 120 + ## 9. Cookies and Tracking 121 + 122 + We use cookies and similar technologies to: 123 + 124 + - Maintain your login session 125 + - Remember your preferences 126 + - Analyze usage patterns to improve the Service 127 + 128 + You can control cookie settings through your browser preferences. 129 + 130 + ## 10. Children's Privacy 131 + 132 + The Service is not intended for children under 16 years of age. We do 133 + not knowingly collect personal information from children under 16. If 134 + we become aware that we have collected such information, we will take 135 + steps to delete it. 136 + 137 + ## 11. International Data Transfers 138 + 139 + While all our primary data processing occurs within the EU, some of our 140 + third-party processors may process data outside the EU. When this 141 + occurs, we ensure appropriate safeguards are in place, such as Standard 142 + Contractual Clauses or adequacy decisions. 143 + 144 + ## 12. Changes to This Privacy Policy 145 + 146 + We may update this Privacy Policy from time to time. We will notify you 147 + of any changes by posting the new Privacy Policy on this page and 148 + updating the "Last updated" date. 149 + 150 + ## 13. Contact Information 151 + 152 + If you have any questions about this Privacy Policy or wish to exercise 153 + your rights, please contact us through our platform or via email. 154 + 155 + --- 156 + 157 + This Privacy Policy complies with the EU General Data Protection 158 + Regulation (GDPR) and other applicable data protection laws.
+109
legal/terms.md
··· 1 + # Terms of Service 2 + 3 + **Last updated:** January 15, 2025 4 + 5 + Welcome to Tangled. These Terms of Service ("Terms") govern your access 6 + to and use of the Tangled platform and services (the "Service") 7 + operated by us ("Tangled," "we," "us," or "our"). 8 + 9 + ## 1. Acceptance of Terms 10 + 11 + By accessing or using our Service, you agree to be bound by these Terms. 12 + If you disagree with any part of these terms, then you may not access 13 + the Service. 14 + 15 + ## 2. Account Registration 16 + 17 + To use certain features of the Service, you must register for an 18 + account. You agree to provide accurate, current, and complete 19 + information during the registration process and to update such 20 + information to keep it accurate, current, and complete. 21 + 22 + ## 3. Account Termination 23 + 24 + > **Important Notice** 25 + > 26 + > **We reserve the right to terminate, suspend, or restrict access to 27 + > your account at any time, for any reason, or for no reason at all, at 28 + > our sole discretion.** This includes, but is not limited to, 29 + > termination for violation of these Terms, inappropriate conduct, spam, 30 + > abuse, or any other behavior we deem harmful to the Service or other 31 + > users. 32 + > 33 + > Account termination may result in the loss of access to your 34 + > repositories, data, and other content associated with your account. We 35 + > are not obligated to provide advance notice of termination, though we 36 + > may do so in our discretion. 37 + 38 + ## 4. Acceptable Use 39 + 40 + You agree not to use the Service to: 41 + 42 + - Violate any applicable laws or regulations 43 + - Infringe upon the rights of others 44 + - Upload, store, or share content that is illegal, harmful, threatening, 45 + abusive, harassing, defamatory, vulgar, obscene, or otherwise 46 + objectionable 47 + - Engage in spam, phishing, or other deceptive practices 48 + - Attempt to gain unauthorized access to the Service or other users' 49 + accounts 50 + - Interfere with or disrupt the Service or servers connected to the 51 + Service 52 + 53 + ## 5. Content and Intellectual Property 54 + 55 + You retain ownership of the content you upload to the Service. By 56 + uploading content, you grant us a non-exclusive, worldwide, royalty-free 57 + license to use, reproduce, modify, and distribute your content as 58 + necessary to provide the Service. 59 + 60 + ## 6. Privacy 61 + 62 + Your privacy is important to us. Please review our [Privacy 63 + Policy](/privacy), which also governs your use of the Service. 64 + 65 + ## 7. Disclaimers 66 + 67 + The Service is provided on an "AS IS" and "AS AVAILABLE" basis. We make 68 + no warranties, expressed or implied, and hereby disclaim and negate all 69 + other warranties including without limitation, implied warranties or 70 + conditions of merchantability, fitness for a particular purpose, or 71 + non-infringement of intellectual property or other violation of rights. 72 + 73 + ## 8. Limitation of Liability 74 + 75 + In no event shall Tangled, nor its directors, employees, partners, 76 + agents, suppliers, or affiliates, be liable for any indirect, 77 + incidental, special, consequential, or punitive damages, including 78 + without limitation, loss of profits, data, use, goodwill, or other 79 + intangible losses, resulting from your use of the Service. 80 + 81 + ## 9. Indemnification 82 + 83 + You agree to defend, indemnify, and hold harmless Tangled and its 84 + affiliates, officers, directors, employees, and agents from and against 85 + any and all claims, damages, obligations, losses, liabilities, costs, 86 + or debt, and expenses (including attorney's fees). 87 + 88 + ## 10. Governing Law 89 + 90 + These Terms shall be interpreted and governed by the laws of Finland, 91 + without regard to its conflict of law provisions. 92 + 93 + ## 11. Changes to Terms 94 + 95 + We reserve the right to modify or replace these Terms at any time. If a 96 + revision is material, we will try to provide at least 30 days notice 97 + prior to any new terms taking effect. 98 + 99 + ## 12. Contact Information 100 + 101 + If you have any questions about these Terms of Service, please contact 102 + us through our platform or via email. 103 + 104 + --- 105 + 106 + These terms are effective as of the last updated date shown above and 107 + will remain in effect except with respect to any changes in their 108 + provisions in the future, which will be in effect immediately after 109 + being posted on this page.
-52
lexicons/artifact.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "sh.tangled.repo.artifact", 4 - "needsCbor": true, 5 - "needsType": true, 6 - "defs": { 7 - "main": { 8 - "type": "record", 9 - "key": "tid", 10 - "record": { 11 - "type": "object", 12 - "required": [ 13 - "name", 14 - "repo", 15 - "tag", 16 - "createdAt", 17 - "artifact" 18 - ], 19 - "properties": { 20 - "name": { 21 - "type": "string", 22 - "description": "name of the artifact" 23 - }, 24 - "repo": { 25 - "type": "string", 26 - "format": "at-uri", 27 - "description": "repo that this artifact is being uploaded to" 28 - }, 29 - "tag": { 30 - "type": "bytes", 31 - "description": "hash of the tag object that this artifact is attached to (only annotated tags are supported)", 32 - "minLength": 20, 33 - "maxLength": 20 34 - }, 35 - "createdAt": { 36 - "type": "string", 37 - "format": "datetime", 38 - "description": "time of creation of this artifact" 39 - }, 40 - "artifact": { 41 - "type": "blob", 42 - "description": "the artifact", 43 - "accept": [ 44 - "*/*" 45 - ], 46 - "maxSize": 52428800 47 - } 48 - } 49 - } 50 - } 51 - } 52 - }
+34
lexicons/feed/reaction.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.feed.reaction", 4 + "needsCbor": true, 5 + "needsType": true, 6 + "defs": { 7 + "main": { 8 + "type": "record", 9 + "key": "tid", 10 + "record": { 11 + "type": "object", 12 + "required": [ 13 + "subject", 14 + "reaction", 15 + "createdAt" 16 + ], 17 + "properties": { 18 + "subject": { 19 + "type": "string", 20 + "format": "at-uri" 21 + }, 22 + "reaction": { 23 + "type": "string", 24 + "enum": [ "๐Ÿ‘", "๐Ÿ‘Ž", "๐Ÿ˜†", "๐ŸŽ‰", "๐Ÿซค", "โค๏ธ", "๐Ÿš€", "๐Ÿ‘€" ] 25 + }, 26 + "createdAt": { 27 + "type": "string", 28 + "format": "datetime" 29 + } 30 + } 31 + } 32 + } 33 + } 34 + }
+69 -35
lexicons/git/refUpdate.json
··· 51 51 "maxLength": 40 52 52 }, 53 53 "meta": { 54 - "type": "object", 55 - "required": [ 56 - "isDefaultRef", 57 - "commitCount" 58 - ], 59 - "properties": { 60 - "isDefaultRef": { 61 - "type": "boolean", 62 - "default": "false" 63 - }, 64 - "commitCount": { 65 - "type": "object", 66 - "required": [], 67 - "properties": { 68 - "byEmail": { 69 - "type": "array", 70 - "items": { 71 - "type": "object", 72 - "required": [ 73 - "email", 74 - "count" 75 - ], 76 - "properties": { 77 - "email": { 78 - "type": "string" 79 - }, 80 - "count": { 81 - "type": "integer" 82 - } 83 - } 84 - } 85 - } 86 - } 87 - } 88 - } 54 + "type": "ref", 55 + "ref": "#meta" 89 56 } 57 + } 58 + } 59 + }, 60 + "meta": { 61 + "type": "object", 62 + "required": ["isDefaultRef", "commitCount"], 63 + "properties": { 64 + "isDefaultRef": { 65 + "type": "boolean", 66 + "default": false 67 + }, 68 + "langBreakdown": { 69 + "type": "ref", 70 + "ref": "#langBreakdown" 71 + }, 72 + "commitCount": { 73 + "type": "ref", 74 + "ref": "#commitCountBreakdown" 75 + } 76 + } 77 + }, 78 + "langBreakdown": { 79 + "type": "object", 80 + "properties": { 81 + "inputs": { 82 + "type": "array", 83 + "items": { 84 + "type": "ref", 85 + "ref": "#individualLanguageSize" 86 + } 87 + } 88 + } 89 + }, 90 + "individualLanguageSize": { 91 + "type": "object", 92 + "required": ["lang", "size"], 93 + "properties": { 94 + "lang": { 95 + "type": "string" 96 + }, 97 + "size": { 98 + "type": "integer" 99 + } 100 + } 101 + }, 102 + "commitCountBreakdown": { 103 + "type": "object", 104 + "required": [], 105 + "properties": { 106 + "byEmail": { 107 + "type": "array", 108 + "items": { 109 + "type": "ref", 110 + "ref": "#individualEmailCommitCount" 111 + } 112 + } 113 + } 114 + }, 115 + "individualEmailCommitCount": { 116 + "type": "object", 117 + "required": ["email", "count"], 118 + "properties": { 119 + "email": { 120 + "type": "string" 121 + }, 122 + "count": { 123 + "type": "integer" 90 124 } 91 125 } 92 126 }
+4 -11
lexicons/issue/comment.json
··· 19 19 "type": "string", 20 20 "format": "at-uri" 21 21 }, 22 - "repo": { 23 - "type": "string", 24 - "format": "at-uri" 25 - }, 26 - "commentId": { 27 - "type": "integer" 28 - }, 29 - "owner": { 30 - "type": "string", 31 - "format": "did" 32 - }, 33 22 "body": { 34 23 "type": "string" 35 24 }, 36 25 "createdAt": { 37 26 "type": "string", 38 27 "format": "datetime" 28 + }, 29 + "replyTo": { 30 + "type": "string", 31 + "format": "at-uri" 39 32 } 40 33 } 41 34 }
+1 -14
lexicons/issue/issue.json
··· 9 9 "key": "tid", 10 10 "record": { 11 11 "type": "object", 12 - "required": [ 13 - "repo", 14 - "issueId", 15 - "owner", 16 - "title", 17 - "createdAt" 18 - ], 12 + "required": ["repo", "title", "createdAt"], 19 13 "properties": { 20 14 "repo": { 21 15 "type": "string", 22 16 "format": "at-uri" 23 - }, 24 - "issueId": { 25 - "type": "integer" 26 - }, 27 - "owner": { 28 - "type": "string", 29 - "format": "did" 30 17 }, 31 18 "title": { 32 19 "type": "string"
+24
lexicons/knot/knot.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.knot", 4 + "needsCbor": true, 5 + "needsType": true, 6 + "defs": { 7 + "main": { 8 + "type": "record", 9 + "key": "any", 10 + "record": { 11 + "type": "object", 12 + "required": [ 13 + "createdAt" 14 + ], 15 + "properties": { 16 + "createdAt": { 17 + "type": "string", 18 + "format": "datetime" 19 + } 20 + } 21 + } 22 + } 23 + } 24 + }
+73
lexicons/knot/listKeys.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.knot.listKeys", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "List all public keys stored in the knot server", 8 + "parameters": { 9 + "type": "params", 10 + "properties": { 11 + "limit": { 12 + "type": "integer", 13 + "description": "Maximum number of keys to return", 14 + "minimum": 1, 15 + "maximum": 1000, 16 + "default": 100 17 + }, 18 + "cursor": { 19 + "type": "string", 20 + "description": "Pagination cursor" 21 + } 22 + } 23 + }, 24 + "output": { 25 + "encoding": "application/json", 26 + "schema": { 27 + "type": "object", 28 + "required": ["keys"], 29 + "properties": { 30 + "keys": { 31 + "type": "array", 32 + "items": { 33 + "type": "ref", 34 + "ref": "#publicKey" 35 + } 36 + }, 37 + "cursor": { 38 + "type": "string", 39 + "description": "Pagination cursor for next page" 40 + } 41 + } 42 + } 43 + }, 44 + "errors": [ 45 + { 46 + "name": "InternalServerError", 47 + "description": "Failed to retrieve public keys" 48 + } 49 + ] 50 + }, 51 + "publicKey": { 52 + "type": "object", 53 + "required": ["did", "key", "createdAt"], 54 + "properties": { 55 + "did": { 56 + "type": "string", 57 + "format": "did", 58 + "description": "DID associated with the public key" 59 + }, 60 + "key": { 61 + "type": "string", 62 + "maxLength": 4096, 63 + "description": "Public key contents" 64 + }, 65 + "createdAt": { 66 + "type": "string", 67 + "format": "datetime", 68 + "description": "Key upload timestamp" 69 + } 70 + } 71 + } 72 + } 73 + }
+25
lexicons/knot/version.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.knot.version", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "Get the version of a knot", 8 + "output": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": [ 13 + "version" 14 + ], 15 + "properties": { 16 + "version": { 17 + "type": "string" 18 + } 19 + } 20 + } 21 + }, 22 + "errors": [] 23 + } 24 + } 25 + }
+31
lexicons/owner.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.owner", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "Get the owner of a service", 8 + "output": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": [ 13 + "owner" 14 + ], 15 + "properties": { 16 + "owner": { 17 + "type": "string", 18 + "format": "did" 19 + } 20 + } 21 + } 22 + }, 23 + "errors": [ 24 + { 25 + "name": "OwnerNotFound", 26 + "description": "Owner is not set for this service" 27 + } 28 + ] 29 + } 30 + } 31 + }
+207
lexicons/pipeline/pipeline.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.pipeline", 4 + "needsCbor": true, 5 + "needsType": true, 6 + "defs": { 7 + "main": { 8 + "type": "record", 9 + "key": "tid", 10 + "record": { 11 + "type": "object", 12 + "required": [ 13 + "triggerMetadata", 14 + "workflows" 15 + ], 16 + "properties": { 17 + "triggerMetadata": { 18 + "type": "ref", 19 + "ref": "#triggerMetadata" 20 + }, 21 + "workflows": { 22 + "type": "array", 23 + "items": { 24 + "type": "ref", 25 + "ref": "#workflow" 26 + } 27 + } 28 + } 29 + } 30 + }, 31 + "triggerMetadata": { 32 + "type": "object", 33 + "required": [ 34 + "kind", 35 + "repo" 36 + ], 37 + "properties": { 38 + "kind": { 39 + "type": "string", 40 + "enum": [ 41 + "push", 42 + "pull_request", 43 + "manual" 44 + ] 45 + }, 46 + "repo": { 47 + "type": "ref", 48 + "ref": "#triggerRepo" 49 + }, 50 + "push": { 51 + "type": "ref", 52 + "ref": "#pushTriggerData" 53 + }, 54 + "pullRequest": { 55 + "type": "ref", 56 + "ref": "#pullRequestTriggerData" 57 + }, 58 + "manual": { 59 + "type": "ref", 60 + "ref": "#manualTriggerData" 61 + } 62 + } 63 + }, 64 + "triggerRepo": { 65 + "type": "object", 66 + "required": [ 67 + "knot", 68 + "did", 69 + "repo", 70 + "defaultBranch" 71 + ], 72 + "properties": { 73 + "knot": { 74 + "type": "string" 75 + }, 76 + "did": { 77 + "type": "string", 78 + "format": "did" 79 + }, 80 + "repo": { 81 + "type": "string" 82 + }, 83 + "defaultBranch": { 84 + "type": "string" 85 + } 86 + } 87 + }, 88 + "pushTriggerData": { 89 + "type": "object", 90 + "required": [ 91 + "ref", 92 + "newSha", 93 + "oldSha" 94 + ], 95 + "properties": { 96 + "ref": { 97 + "type": "string" 98 + }, 99 + "newSha": { 100 + "type": "string", 101 + "minLength": 40, 102 + "maxLength": 40 103 + }, 104 + "oldSha": { 105 + "type": "string", 106 + "minLength": 40, 107 + "maxLength": 40 108 + } 109 + } 110 + }, 111 + "pullRequestTriggerData": { 112 + "type": "object", 113 + "required": [ 114 + "sourceBranch", 115 + "targetBranch", 116 + "sourceSha", 117 + "action" 118 + ], 119 + "properties": { 120 + "sourceBranch": { 121 + "type": "string" 122 + }, 123 + "targetBranch": { 124 + "type": "string" 125 + }, 126 + "sourceSha": { 127 + "type": "string", 128 + "minLength": 40, 129 + "maxLength": 40 130 + }, 131 + "action": { 132 + "type": "string" 133 + } 134 + } 135 + }, 136 + "manualTriggerData": { 137 + "type": "object", 138 + "properties": { 139 + "inputs": { 140 + "type": "array", 141 + "items": { 142 + "type": "ref", 143 + "ref": "#pair" 144 + } 145 + } 146 + } 147 + }, 148 + "workflow": { 149 + "type": "object", 150 + "required": [ 151 + "name", 152 + "engine", 153 + "clone", 154 + "raw" 155 + ], 156 + "properties": { 157 + "name": { 158 + "type": "string" 159 + }, 160 + "engine": { 161 + "type": "string" 162 + }, 163 + "clone": { 164 + "type": "ref", 165 + "ref": "#cloneOpts" 166 + }, 167 + "raw": { 168 + "type": "string" 169 + } 170 + } 171 + }, 172 + "cloneOpts": { 173 + "type": "object", 174 + "required": [ 175 + "skip", 176 + "depth", 177 + "submodules" 178 + ], 179 + "properties": { 180 + "skip": { 181 + "type": "boolean" 182 + }, 183 + "depth": { 184 + "type": "integer" 185 + }, 186 + "submodules": { 187 + "type": "boolean" 188 + } 189 + } 190 + }, 191 + "pair": { 192 + "type": "object", 193 + "required": [ 194 + "key", 195 + "value" 196 + ], 197 + "properties": { 198 + "key": { 199 + "type": "string" 200 + }, 201 + "value": { 202 + "type": "string" 203 + } 204 + } 205 + } 206 + } 207 + }
-232
lexicons/pipeline.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "sh.tangled.pipeline", 4 - "needsCbor": true, 5 - "needsType": true, 6 - "defs": { 7 - "main": { 8 - "type": "record", 9 - "key": "tid", 10 - "record": { 11 - "type": "object", 12 - "required": ["triggerMetadata", "workflows"], 13 - "properties": { 14 - "triggerMetadata": { 15 - "type": "ref", 16 - "ref": "#triggerMetadata" 17 - }, 18 - "workflows": { 19 - "type": "array", 20 - "items": { 21 - "type": "ref", 22 - "ref": "#workflow" 23 - } 24 - } 25 - } 26 - } 27 - }, 28 - "triggerMetadata": { 29 - "type": "object", 30 - "required": ["kind", "repo"], 31 - "properties": { 32 - "kind": { 33 - "type": "string", 34 - "enum": ["push", "pull_request", "manual"] 35 - }, 36 - "repo": { 37 - "type": "ref", 38 - "ref": "#triggerRepo" 39 - }, 40 - "push": { 41 - "type": "ref", 42 - "ref": "#pushTriggerData" 43 - }, 44 - "pullRequest": { 45 - "type": "ref", 46 - "ref": "#pullRequestTriggerData" 47 - }, 48 - "manual": { 49 - "type": "ref", 50 - "ref": "#manualTriggerData" 51 - } 52 - } 53 - }, 54 - "triggerRepo": { 55 - "type": "object", 56 - "required": ["knot", "did", "repo", "defaultBranch"], 57 - "properties": { 58 - "knot": { 59 - "type": "string" 60 - }, 61 - "did": { 62 - "type": "string", 63 - "format": "did" 64 - }, 65 - "repo": { 66 - "type": "string" 67 - }, 68 - "defaultBranch": { 69 - "type": "string" 70 - } 71 - } 72 - }, 73 - "pushTriggerData": { 74 - "type": "object", 75 - "required": ["ref", "newSha", "oldSha"], 76 - "properties": { 77 - "ref": { 78 - "type": "string" 79 - }, 80 - "newSha": { 81 - "type": "string", 82 - "minLength": 40, 83 - "maxLength": 40 84 - }, 85 - "oldSha": { 86 - "type": "string", 87 - "minLength": 40, 88 - "maxLength": 40 89 - } 90 - } 91 - }, 92 - "pullRequestTriggerData": { 93 - "type": "object", 94 - "required": ["sourceBranch", "targetBranch", "sourceSha", "action"], 95 - "properties": { 96 - "sourceBranch": { 97 - "type": "string" 98 - }, 99 - "targetBranch": { 100 - "type": "string" 101 - }, 102 - "sourceSha": { 103 - "type": "string", 104 - "minLength": 40, 105 - "maxLength": 40 106 - }, 107 - "action": { 108 - "type": "string" 109 - } 110 - } 111 - }, 112 - "manualTriggerData": { 113 - "type": "object", 114 - "properties": { 115 - "inputs": { 116 - "type": "array", 117 - "items": { 118 - "type": "object", 119 - "required": ["key", "value"], 120 - "properties": { 121 - "key": { 122 - "type": "string" 123 - }, 124 - "value": { 125 - "type": "string" 126 - } 127 - } 128 - } 129 - } 130 - } 131 - }, 132 - "workflow": { 133 - "type": "object", 134 - "required": ["name", "dependencies", "steps", "environment", "clone"], 135 - "properties": { 136 - "name": { 137 - "type": "string" 138 - }, 139 - "dependencies": { 140 - "type": "ref", 141 - "ref": "#dependencies" 142 - }, 143 - "steps": { 144 - "type": "array", 145 - "items": { 146 - "type": "ref", 147 - "ref": "#step" 148 - } 149 - }, 150 - "environment": { 151 - "type": "array", 152 - "items": { 153 - "type": "object", 154 - "required": ["key", "value"], 155 - "properties": { 156 - "key": { 157 - "type": "string" 158 - }, 159 - "value": { 160 - "type": "string" 161 - } 162 - } 163 - } 164 - }, 165 - "clone": { 166 - "type": "ref", 167 - "ref": "#cloneOpts" 168 - } 169 - } 170 - }, 171 - "dependencies": { 172 - "type": "array", 173 - "items": { 174 - "type": "object", 175 - "required": ["registry", "packages"], 176 - "properties": { 177 - "registry": { 178 - "type": "string" 179 - }, 180 - "packages": { 181 - "type": "array", 182 - "items": { 183 - "type": "string" 184 - } 185 - } 186 - } 187 - } 188 - }, 189 - "cloneOpts": { 190 - "type": "object", 191 - "required": ["skip", "depth", "submodules"], 192 - "properties": { 193 - "skip": { 194 - "type": "boolean" 195 - }, 196 - "depth": { 197 - "type": "integer" 198 - }, 199 - "submodules": { 200 - "type": "boolean" 201 - } 202 - } 203 - }, 204 - "step": { 205 - "type": "object", 206 - "required": ["name", "command"], 207 - "properties": { 208 - "name": { 209 - "type": "string" 210 - }, 211 - "command": { 212 - "type": "string" 213 - }, 214 - "environment": { 215 - "type": "array", 216 - "items": { 217 - "type": "object", 218 - "required": ["key", "value"], 219 - "properties": { 220 - "key": { 221 - "type": "string" 222 - }, 223 - "value": { 224 - "type": "string" 225 - } 226 - } 227 - } 228 - } 229 - } 230 - } 231 - } 232 - }
-11
lexicons/pulls/comment.json
··· 19 19 "type": "string", 20 20 "format": "at-uri" 21 21 }, 22 - "repo": { 23 - "type": "string", 24 - "format": "at-uri" 25 - }, 26 - "commentId": { 27 - "type": "integer" 28 - }, 29 - "owner": { 30 - "type": "string", 31 - "format": "did" 32 - }, 33 22 "body": { 34 23 "type": "string" 35 24 },
+27 -13
lexicons/pulls/pull.json
··· 10 10 "record": { 11 11 "type": "object", 12 12 "required": [ 13 - "targetRepo", 14 - "targetBranch", 15 - "pullId", 13 + "target", 16 14 "title", 17 15 "patch", 18 16 "createdAt" 19 17 ], 20 18 "properties": { 21 - "targetRepo": { 22 - "type": "string", 23 - "format": "at-uri" 24 - }, 25 - "targetBranch": { 26 - "type": "string" 27 - }, 28 - "pullId": { 29 - "type": "integer" 19 + "target": { 20 + "type": "ref", 21 + "ref": "#target" 30 22 }, 31 23 "title": { 32 24 "type": "string" ··· 48 40 } 49 41 } 50 42 }, 51 - "source": { 43 + "target": { 52 44 "type": "object", 53 45 "required": [ 46 + "repo", 54 47 "branch" 55 48 ], 56 49 "properties": { 50 + "repo": { 51 + "type": "string", 52 + "format": "at-uri" 53 + }, 57 54 "branch": { 58 55 "type": "string" 56 + } 57 + } 58 + }, 59 + "source": { 60 + "type": "object", 61 + "required": [ 62 + "branch", 63 + "sha" 64 + ], 65 + "properties": { 66 + "branch": { 67 + "type": "string" 68 + }, 69 + "sha": { 70 + "type": "string", 71 + "minLength": 40, 72 + "maxLength": 40 59 73 }, 60 74 "repo": { 61 75 "type": "string",
+37
lexicons/repo/addSecret.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.addSecret", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Add a CI secret", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": [ 13 + "repo", 14 + "key", 15 + "value" 16 + ], 17 + "properties": { 18 + "repo": { 19 + "type": "string", 20 + "format": "at-uri" 21 + }, 22 + "key": { 23 + "type": "string", 24 + "maxLength": 50, 25 + "minLength": 1 26 + }, 27 + "value": { 28 + "type": "string", 29 + "maxLength": 200, 30 + "minLength": 1 31 + } 32 + } 33 + } 34 + } 35 + } 36 + } 37 + }
+55
lexicons/repo/archive.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.archive", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "parameters": { 8 + "type": "params", 9 + "required": ["repo", "ref"], 10 + "properties": { 11 + "repo": { 12 + "type": "string", 13 + "description": "Repository identifier in format 'did:plc:.../repoName'" 14 + }, 15 + "ref": { 16 + "type": "string", 17 + "description": "Git reference (branch, tag, or commit SHA)" 18 + }, 19 + "format": { 20 + "type": "string", 21 + "description": "Archive format", 22 + "enum": ["tar", "zip", "tar.gz", "tar.bz2", "tar.xz"], 23 + "default": "tar.gz" 24 + }, 25 + "prefix": { 26 + "type": "string", 27 + "description": "Prefix for files in the archive" 28 + } 29 + } 30 + }, 31 + "output": { 32 + "encoding": "*/*", 33 + "description": "Binary archive data" 34 + }, 35 + "errors": [ 36 + { 37 + "name": "RepoNotFound", 38 + "description": "Repository not found or access denied" 39 + }, 40 + { 41 + "name": "RefNotFound", 42 + "description": "Git reference not found" 43 + }, 44 + { 45 + "name": "InvalidRequest", 46 + "description": "Invalid request parameters" 47 + }, 48 + { 49 + "name": "ArchiveError", 50 + "description": "Failed to create archive" 51 + } 52 + ] 53 + } 54 + } 55 + }
+52
lexicons/repo/artifact.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.artifact", 4 + "needsCbor": true, 5 + "needsType": true, 6 + "defs": { 7 + "main": { 8 + "type": "record", 9 + "key": "tid", 10 + "record": { 11 + "type": "object", 12 + "required": [ 13 + "name", 14 + "repo", 15 + "tag", 16 + "createdAt", 17 + "artifact" 18 + ], 19 + "properties": { 20 + "name": { 21 + "type": "string", 22 + "description": "name of the artifact" 23 + }, 24 + "repo": { 25 + "type": "string", 26 + "format": "at-uri", 27 + "description": "repo that this artifact is being uploaded to" 28 + }, 29 + "tag": { 30 + "type": "bytes", 31 + "description": "hash of the tag object that this artifact is attached to (only annotated tags are supported)", 32 + "minLength": 20, 33 + "maxLength": 20 34 + }, 35 + "createdAt": { 36 + "type": "string", 37 + "format": "datetime", 38 + "description": "time of creation of this artifact" 39 + }, 40 + "artifact": { 41 + "type": "blob", 42 + "description": "the artifact", 43 + "accept": [ 44 + "*/*" 45 + ], 46 + "maxSize": 52428800 47 + } 48 + } 49 + } 50 + } 51 + } 52 + }
+138
lexicons/repo/blob.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.blob", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "parameters": { 8 + "type": "params", 9 + "required": ["repo", "ref", "path"], 10 + "properties": { 11 + "repo": { 12 + "type": "string", 13 + "description": "Repository identifier in format 'did:plc:.../repoName'" 14 + }, 15 + "ref": { 16 + "type": "string", 17 + "description": "Git reference (branch, tag, or commit SHA)" 18 + }, 19 + "path": { 20 + "type": "string", 21 + "description": "Path to the file within the repository" 22 + }, 23 + "raw": { 24 + "type": "boolean", 25 + "description": "Return raw file content instead of JSON response", 26 + "default": false 27 + } 28 + } 29 + }, 30 + "output": { 31 + "encoding": "application/json", 32 + "schema": { 33 + "type": "object", 34 + "required": ["ref", "path", "content"], 35 + "properties": { 36 + "ref": { 37 + "type": "string", 38 + "description": "The git reference used" 39 + }, 40 + "path": { 41 + "type": "string", 42 + "description": "The file path" 43 + }, 44 + "content": { 45 + "type": "string", 46 + "description": "File content (base64 encoded for binary files)" 47 + }, 48 + "encoding": { 49 + "type": "string", 50 + "description": "Content encoding", 51 + "enum": ["utf-8", "base64"] 52 + }, 53 + "size": { 54 + "type": "integer", 55 + "description": "File size in bytes" 56 + }, 57 + "isBinary": { 58 + "type": "boolean", 59 + "description": "Whether the file is binary" 60 + }, 61 + "mimeType": { 62 + "type": "string", 63 + "description": "MIME type of the file" 64 + }, 65 + "lastCommit": { 66 + "type": "ref", 67 + "ref": "#lastCommit" 68 + } 69 + } 70 + } 71 + }, 72 + "errors": [ 73 + { 74 + "name": "RepoNotFound", 75 + "description": "Repository not found or access denied" 76 + }, 77 + { 78 + "name": "RefNotFound", 79 + "description": "Git reference not found" 80 + }, 81 + { 82 + "name": "FileNotFound", 83 + "description": "File not found at the specified path" 84 + }, 85 + { 86 + "name": "InvalidRequest", 87 + "description": "Invalid request parameters" 88 + } 89 + ] 90 + }, 91 + "lastCommit": { 92 + "type": "object", 93 + "required": ["hash", "message", "when"], 94 + "properties": { 95 + "hash": { 96 + "type": "string", 97 + "description": "Commit hash" 98 + }, 99 + "shortHash": { 100 + "type": "string", 101 + "description": "Short commit hash" 102 + }, 103 + "message": { 104 + "type": "string", 105 + "description": "Commit message" 106 + }, 107 + "author": { 108 + "type": "ref", 109 + "ref": "#signature" 110 + }, 111 + "when": { 112 + "type": "string", 113 + "format": "datetime", 114 + "description": "Commit timestamp" 115 + } 116 + } 117 + }, 118 + "signature": { 119 + "type": "object", 120 + "required": ["name", "email", "when"], 121 + "properties": { 122 + "name": { 123 + "type": "string", 124 + "description": "Author name" 125 + }, 126 + "email": { 127 + "type": "string", 128 + "description": "Author email" 129 + }, 130 + "when": { 131 + "type": "string", 132 + "format": "datetime", 133 + "description": "Author timestamp" 134 + } 135 + } 136 + } 137 + } 138 + }
+94
lexicons/repo/branch.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.branch", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "parameters": { 8 + "type": "params", 9 + "required": ["repo", "name"], 10 + "properties": { 11 + "repo": { 12 + "type": "string", 13 + "description": "Repository identifier in format 'did:plc:.../repoName'" 14 + }, 15 + "name": { 16 + "type": "string", 17 + "description": "Branch name to get information for" 18 + } 19 + } 20 + }, 21 + "output": { 22 + "encoding": "application/json", 23 + "schema": { 24 + "type": "object", 25 + "required": ["name", "hash", "when"], 26 + "properties": { 27 + "name": { 28 + "type": "string", 29 + "description": "Branch name" 30 + }, 31 + "hash": { 32 + "type": "string", 33 + "description": "Latest commit hash on this branch" 34 + }, 35 + "shortHash": { 36 + "type": "string", 37 + "description": "Short commit hash" 38 + }, 39 + "when": { 40 + "type": "string", 41 + "format": "datetime", 42 + "description": "Timestamp of latest commit" 43 + }, 44 + "message": { 45 + "type": "string", 46 + "description": "Latest commit message" 47 + }, 48 + "author": { 49 + "type": "ref", 50 + "ref": "#signature" 51 + }, 52 + "isDefault": { 53 + "type": "boolean", 54 + "description": "Whether this is the default branch" 55 + } 56 + } 57 + } 58 + }, 59 + "errors": [ 60 + { 61 + "name": "RepoNotFound", 62 + "description": "Repository not found or access denied" 63 + }, 64 + { 65 + "name": "BranchNotFound", 66 + "description": "Branch not found" 67 + }, 68 + { 69 + "name": "InvalidRequest", 70 + "description": "Invalid request parameters" 71 + } 72 + ] 73 + }, 74 + "signature": { 75 + "type": "object", 76 + "required": ["name", "email", "when"], 77 + "properties": { 78 + "name": { 79 + "type": "string", 80 + "description": "Author name" 81 + }, 82 + "email": { 83 + "type": "string", 84 + "description": "Author email" 85 + }, 86 + "when": { 87 + "type": "string", 88 + "format": "datetime", 89 + "description": "Author timestamp" 90 + } 91 + } 92 + } 93 + } 94 + }
+43
lexicons/repo/branches.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.branches", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "parameters": { 8 + "type": "params", 9 + "required": ["repo"], 10 + "properties": { 11 + "repo": { 12 + "type": "string", 13 + "description": "Repository identifier in format 'did:plc:.../repoName'" 14 + }, 15 + "limit": { 16 + "type": "integer", 17 + "description": "Maximum number of branches to return", 18 + "minimum": 1, 19 + "maximum": 100, 20 + "default": 50 21 + }, 22 + "cursor": { 23 + "type": "string", 24 + "description": "Pagination cursor" 25 + } 26 + } 27 + }, 28 + "output": { 29 + "encoding": "*/*" 30 + }, 31 + "errors": [ 32 + { 33 + "name": "RepoNotFound", 34 + "description": "Repository not found or access denied" 35 + }, 36 + { 37 + "name": "InvalidRequest", 38 + "description": "Invalid request parameters" 39 + } 40 + ] 41 + } 42 + } 43 + }
+36
lexicons/repo/collaborator.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.collaborator", 4 + "needsCbor": true, 5 + "needsType": true, 6 + "defs": { 7 + "main": { 8 + "type": "record", 9 + "key": "tid", 10 + "record": { 11 + "type": "object", 12 + "required": [ 13 + "subject", 14 + "repo", 15 + "createdAt" 16 + ], 17 + "properties": { 18 + "subject": { 19 + "type": "string", 20 + "format": "did" 21 + }, 22 + "repo": { 23 + "type": "string", 24 + "description": "repo to add this user to", 25 + "format": "at-uri" 26 + }, 27 + "createdAt": { 28 + "type": "string", 29 + "format": "datetime" 30 + } 31 + } 32 + } 33 + } 34 + } 35 + } 36 +
+49
lexicons/repo/compare.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.compare", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "parameters": { 8 + "type": "params", 9 + "required": ["repo", "rev1", "rev2"], 10 + "properties": { 11 + "repo": { 12 + "type": "string", 13 + "description": "Repository identifier in format 'did:plc:.../repoName'" 14 + }, 15 + "rev1": { 16 + "type": "string", 17 + "description": "First revision (commit, branch, or tag)" 18 + }, 19 + "rev2": { 20 + "type": "string", 21 + "description": "Second revision (commit, branch, or tag)" 22 + } 23 + } 24 + }, 25 + "output": { 26 + "encoding": "*/*", 27 + "description": "Compare output in application/json" 28 + }, 29 + "errors": [ 30 + { 31 + "name": "RepoNotFound", 32 + "description": "Repository not found or access denied" 33 + }, 34 + { 35 + "name": "RevisionNotFound", 36 + "description": "One or both revisions not found" 37 + }, 38 + { 39 + "name": "InvalidRequest", 40 + "description": "Invalid request parameters" 41 + }, 42 + { 43 + "name": "CompareError", 44 + "description": "Failed to compare revisions" 45 + } 46 + ] 47 + } 48 + } 49 + }
+33
lexicons/repo/create.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.create", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Create a new repository", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": [ 13 + "rkey" 14 + ], 15 + "properties": { 16 + "rkey": { 17 + "type": "string", 18 + "description": "Rkey of the repository record" 19 + }, 20 + "defaultBranch": { 21 + "type": "string", 22 + "description": "Default branch to push to" 23 + }, 24 + "source": { 25 + "type": "string", 26 + "description": "A source URL to clone from, populate this when forking or importing a repository." 27 + } 28 + } 29 + } 30 + } 31 + } 32 + } 33 + }
+29
lexicons/repo/defaultBranch.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.setDefaultBranch", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Set the default branch for a repository", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": [ 13 + "repo", 14 + "defaultBranch" 15 + ], 16 + "properties": { 17 + "repo": { 18 + "type": "string", 19 + "format": "at-uri" 20 + }, 21 + "defaultBranch": { 22 + "type": "string" 23 + } 24 + } 25 + } 26 + } 27 + } 28 + } 29 + }
+32
lexicons/repo/delete.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.delete", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Delete a repository", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["did", "name", "rkey"], 13 + "properties": { 14 + "did": { 15 + "type": "string", 16 + "format": "did", 17 + "description": "DID of the repository owner" 18 + }, 19 + "name": { 20 + "type": "string", 21 + "description": "Name of the repository to delete" 22 + }, 23 + "rkey": { 24 + "type": "string", 25 + "description": "Rkey of the repository record" 26 + } 27 + } 28 + } 29 + } 30 + } 31 + } 32 + }
+40
lexicons/repo/diff.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.diff", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "parameters": { 8 + "type": "params", 9 + "required": ["repo", "ref"], 10 + "properties": { 11 + "repo": { 12 + "type": "string", 13 + "description": "Repository identifier in format 'did:plc:.../repoName'" 14 + }, 15 + "ref": { 16 + "type": "string", 17 + "description": "Git reference (branch, tag, or commit SHA)" 18 + } 19 + } 20 + }, 21 + "output": { 22 + "encoding": "*/*" 23 + }, 24 + "errors": [ 25 + { 26 + "name": "RepoNotFound", 27 + "description": "Repository not found or access denied" 28 + }, 29 + { 30 + "name": "RefNotFound", 31 + "description": "Git reference not found" 32 + }, 33 + { 34 + "name": "InvalidRequest", 35 + "description": "Invalid request parameters" 36 + } 37 + ] 38 + } 39 + } 40 + }
+53
lexicons/repo/forkStatus.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.forkStatus", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Check fork status relative to upstream source", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["did", "name", "source", "branch", "hiddenRef"], 13 + "properties": { 14 + "did": { 15 + "type": "string", 16 + "format": "did", 17 + "description": "DID of the fork owner" 18 + }, 19 + "name": { 20 + "type": "string", 21 + "description": "Name of the forked repository" 22 + }, 23 + "source": { 24 + "type": "string", 25 + "description": "Source repository URL" 26 + }, 27 + "branch": { 28 + "type": "string", 29 + "description": "Branch to check status for" 30 + }, 31 + "hiddenRef": { 32 + "type": "string", 33 + "description": "Hidden ref to use for comparison" 34 + } 35 + } 36 + } 37 + }, 38 + "output": { 39 + "encoding": "application/json", 40 + "schema": { 41 + "type": "object", 42 + "required": ["status"], 43 + "properties": { 44 + "status": { 45 + "type": "integer", 46 + "description": "Fork status: 0=UpToDate, 1=FastForwardable, 2=Conflict, 3=MissingBranch" 47 + } 48 + } 49 + } 50 + } 51 + } 52 + } 53 + }
+42
lexicons/repo/forkSync.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.forkSync", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Sync a forked repository with its upstream source", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": [ 13 + "did", 14 + "source", 15 + "name", 16 + "branch" 17 + ], 18 + "properties": { 19 + "did": { 20 + "type": "string", 21 + "format": "did", 22 + "description": "DID of the fork owner" 23 + }, 24 + "source": { 25 + "type": "string", 26 + "format": "at-uri", 27 + "description": "AT-URI of the source repository" 28 + }, 29 + "name": { 30 + "type": "string", 31 + "description": "Name of the forked repository" 32 + }, 33 + "branch": { 34 + "type": "string", 35 + "description": "Branch to sync" 36 + } 37 + } 38 + } 39 + } 40 + } 41 + } 42 + }
+82
lexicons/repo/getDefaultBranch.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.getDefaultBranch", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "parameters": { 8 + "type": "params", 9 + "required": ["repo"], 10 + "properties": { 11 + "repo": { 12 + "type": "string", 13 + "description": "Repository identifier in format 'did:plc:.../repoName'" 14 + } 15 + } 16 + }, 17 + "output": { 18 + "encoding": "application/json", 19 + "schema": { 20 + "type": "object", 21 + "required": ["name", "hash", "when"], 22 + "properties": { 23 + "name": { 24 + "type": "string", 25 + "description": "Default branch name" 26 + }, 27 + "hash": { 28 + "type": "string", 29 + "description": "Latest commit hash on default branch" 30 + }, 31 + "shortHash": { 32 + "type": "string", 33 + "description": "Short commit hash" 34 + }, 35 + "when": { 36 + "type": "string", 37 + "format": "datetime", 38 + "description": "Timestamp of latest commit" 39 + }, 40 + "message": { 41 + "type": "string", 42 + "description": "Latest commit message" 43 + }, 44 + "author": { 45 + "type": "ref", 46 + "ref": "#signature" 47 + } 48 + } 49 + } 50 + }, 51 + "errors": [ 52 + { 53 + "name": "RepoNotFound", 54 + "description": "Repository not found or access denied" 55 + }, 56 + { 57 + "name": "InvalidRequest", 58 + "description": "Invalid request parameters" 59 + } 60 + ] 61 + }, 62 + "signature": { 63 + "type": "object", 64 + "required": ["name", "email", "when"], 65 + "properties": { 66 + "name": { 67 + "type": "string", 68 + "description": "Author name" 69 + }, 70 + "email": { 71 + "type": "string", 72 + "description": "Author email" 73 + }, 74 + "when": { 75 + "type": "string", 76 + "format": "datetime", 77 + "description": "Author timestamp" 78 + } 79 + } 80 + } 81 + } 82 + }
+59
lexicons/repo/hiddenRef.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.hiddenRef", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Create a hidden ref in a repository", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": [ 13 + "repo", 14 + "forkRef", 15 + "remoteRef" 16 + ], 17 + "properties": { 18 + "repo": { 19 + "type": "string", 20 + "format": "at-uri", 21 + "description": "AT-URI of the repository" 22 + }, 23 + "forkRef": { 24 + "type": "string", 25 + "description": "Fork reference name" 26 + }, 27 + "remoteRef": { 28 + "type": "string", 29 + "description": "Remote reference name" 30 + } 31 + } 32 + } 33 + }, 34 + "output": { 35 + "encoding": "application/json", 36 + "schema": { 37 + "type": "object", 38 + "required": [ 39 + "success" 40 + ], 41 + "properties": { 42 + "success": { 43 + "type": "boolean", 44 + "description": "Whether the hidden ref was created successfully" 45 + }, 46 + "ref": { 47 + "type": "string", 48 + "description": "The created hidden ref name" 49 + }, 50 + "error": { 51 + "type": "string", 52 + "description": "Error message if creation failed" 53 + } 54 + } 55 + } 56 + } 57 + } 58 + } 59 + }
+99
lexicons/repo/languages.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.languages", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "parameters": { 8 + "type": "params", 9 + "required": ["repo"], 10 + "properties": { 11 + "repo": { 12 + "type": "string", 13 + "description": "Repository identifier in format 'did:plc:.../repoName'" 14 + }, 15 + "ref": { 16 + "type": "string", 17 + "description": "Git reference (branch, tag, or commit SHA)", 18 + "default": "HEAD" 19 + } 20 + } 21 + }, 22 + "output": { 23 + "encoding": "application/json", 24 + "schema": { 25 + "type": "object", 26 + "required": ["ref", "languages"], 27 + "properties": { 28 + "ref": { 29 + "type": "string", 30 + "description": "The git reference used" 31 + }, 32 + "languages": { 33 + "type": "array", 34 + "items": { 35 + "type": "ref", 36 + "ref": "#language" 37 + } 38 + }, 39 + "totalSize": { 40 + "type": "integer", 41 + "description": "Total size of all analyzed files in bytes" 42 + }, 43 + "totalFiles": { 44 + "type": "integer", 45 + "description": "Total number of files analyzed" 46 + } 47 + } 48 + } 49 + }, 50 + "errors": [ 51 + { 52 + "name": "RepoNotFound", 53 + "description": "Repository not found or access denied" 54 + }, 55 + { 56 + "name": "RefNotFound", 57 + "description": "Git reference not found" 58 + }, 59 + { 60 + "name": "InvalidRequest", 61 + "description": "Invalid request parameters" 62 + } 63 + ] 64 + }, 65 + "language": { 66 + "type": "object", 67 + "required": ["name", "size", "percentage"], 68 + "properties": { 69 + "name": { 70 + "type": "string", 71 + "description": "Programming language name" 72 + }, 73 + "size": { 74 + "type": "integer", 75 + "description": "Total size of files in this language (bytes)" 76 + }, 77 + "percentage": { 78 + "type": "integer", 79 + "description": "Percentage of total codebase (0-100)" 80 + }, 81 + "fileCount": { 82 + "type": "integer", 83 + "description": "Number of files in this language" 84 + }, 85 + "color": { 86 + "type": "string", 87 + "description": "Hex color code for this language" 88 + }, 89 + "extensions": { 90 + "type": "array", 91 + "items": { 92 + "type": "string" 93 + }, 94 + "description": "File extensions associated with this language" 95 + } 96 + } 97 + } 98 + } 99 + }
+67
lexicons/repo/listSecrets.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.listSecrets", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "parameters": { 8 + "type": "params", 9 + "required": [ 10 + "repo" 11 + ], 12 + "properties": { 13 + "repo": { 14 + "type": "string", 15 + "format": "at-uri" 16 + } 17 + } 18 + }, 19 + "output": { 20 + "encoding": "application/json", 21 + "schema": { 22 + "type": "object", 23 + "required": [ 24 + "secrets" 25 + ], 26 + "properties": { 27 + "secrets": { 28 + "type": "array", 29 + "items": { 30 + "type": "ref", 31 + "ref": "#secret" 32 + } 33 + } 34 + } 35 + } 36 + } 37 + }, 38 + "secret": { 39 + "type": "object", 40 + "required": [ 41 + "repo", 42 + "key", 43 + "createdAt", 44 + "createdBy" 45 + ], 46 + "properties": { 47 + "repo": { 48 + "type": "string", 49 + "format": "at-uri" 50 + }, 51 + "key": { 52 + "type": "string", 53 + "maxLength": 50, 54 + "minLength": 1 55 + }, 56 + "createdAt": { 57 + "type": "string", 58 + "format": "datetime" 59 + }, 60 + "createdBy": { 61 + "type": "string", 62 + "format": "did" 63 + } 64 + } 65 + } 66 + } 67 + }
+60
lexicons/repo/log.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.log", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "parameters": { 8 + "type": "params", 9 + "required": ["repo", "ref"], 10 + "properties": { 11 + "repo": { 12 + "type": "string", 13 + "description": "Repository identifier in format 'did:plc:.../repoName'" 14 + }, 15 + "ref": { 16 + "type": "string", 17 + "description": "Git reference (branch, tag, or commit SHA)" 18 + }, 19 + "path": { 20 + "type": "string", 21 + "description": "Path to filter commits by", 22 + "default": "" 23 + }, 24 + "limit": { 25 + "type": "integer", 26 + "description": "Maximum number of commits to return", 27 + "minimum": 1, 28 + "maximum": 100, 29 + "default": 50 30 + }, 31 + "cursor": { 32 + "type": "string", 33 + "description": "Pagination cursor (commit SHA)" 34 + } 35 + } 36 + }, 37 + "output": { 38 + "encoding": "*/*" 39 + }, 40 + "errors": [ 41 + { 42 + "name": "RepoNotFound", 43 + "description": "Repository not found or access denied" 44 + }, 45 + { 46 + "name": "RefNotFound", 47 + "description": "Git reference not found" 48 + }, 49 + { 50 + "name": "PathNotFound", 51 + "description": "Path not found in repository" 52 + }, 53 + { 54 + "name": "InvalidRequest", 55 + "description": "Invalid request parameters" 56 + } 57 + ] 58 + } 59 + } 60 + }
+52
lexicons/repo/merge.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.merge", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Merge a patch into a repository branch", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["did", "name", "patch", "branch"], 13 + "properties": { 14 + "did": { 15 + "type": "string", 16 + "format": "did", 17 + "description": "DID of the repository owner" 18 + }, 19 + "name": { 20 + "type": "string", 21 + "description": "Name of the repository" 22 + }, 23 + "patch": { 24 + "type": "string", 25 + "description": "Patch content to merge" 26 + }, 27 + "branch": { 28 + "type": "string", 29 + "description": "Target branch to merge into" 30 + }, 31 + "authorName": { 32 + "type": "string", 33 + "description": "Author name for the merge commit" 34 + }, 35 + "authorEmail": { 36 + "type": "string", 37 + "description": "Author email for the merge commit" 38 + }, 39 + "commitBody": { 40 + "type": "string", 41 + "description": "Additional commit message body" 42 + }, 43 + "commitMessage": { 44 + "type": "string", 45 + "description": "Merge commit message" 46 + } 47 + } 48 + } 49 + } 50 + } 51 + } 52 + }
+79
lexicons/repo/mergeCheck.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.mergeCheck", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Check if a merge is possible between two branches", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["did", "name", "patch", "branch"], 13 + "properties": { 14 + "did": { 15 + "type": "string", 16 + "format": "did", 17 + "description": "DID of the repository owner" 18 + }, 19 + "name": { 20 + "type": "string", 21 + "description": "Name of the repository" 22 + }, 23 + "patch": { 24 + "type": "string", 25 + "description": "Patch or pull request to check for merge conflicts" 26 + }, 27 + "branch": { 28 + "type": "string", 29 + "description": "Target branch to merge into" 30 + } 31 + } 32 + } 33 + }, 34 + "output": { 35 + "encoding": "application/json", 36 + "schema": { 37 + "type": "object", 38 + "required": ["is_conflicted"], 39 + "properties": { 40 + "is_conflicted": { 41 + "type": "boolean", 42 + "description": "Whether the merge has conflicts" 43 + }, 44 + "conflicts": { 45 + "type": "array", 46 + "description": "List of files with merge conflicts", 47 + "items": { 48 + "type": "ref", 49 + "ref": "#conflictInfo" 50 + } 51 + }, 52 + "message": { 53 + "type": "string", 54 + "description": "Additional message about the merge check" 55 + }, 56 + "error": { 57 + "type": "string", 58 + "description": "Error message if check failed" 59 + } 60 + } 61 + } 62 + } 63 + }, 64 + "conflictInfo": { 65 + "type": "object", 66 + "required": ["filename", "reason"], 67 + "properties": { 68 + "filename": { 69 + "type": "string", 70 + "description": "Name of the conflicted file" 71 + }, 72 + "reason": { 73 + "type": "string", 74 + "description": "Reason for the conflict" 75 + } 76 + } 77 + } 78 + } 79 + }
+31
lexicons/repo/removeSecret.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.removeSecret", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "Remove a CI secret", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": [ 13 + "repo", 14 + "key" 15 + ], 16 + "properties": { 17 + "repo": { 18 + "type": "string", 19 + "format": "at-uri" 20 + }, 21 + "key": { 22 + "type": "string", 23 + "maxLength": 50, 24 + "minLength": 1 25 + } 26 + } 27 + } 28 + } 29 + } 30 + } 31 + }
+53
lexicons/repo/repo.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo", 4 + "needsCbor": true, 5 + "needsType": true, 6 + "defs": { 7 + "main": { 8 + "type": "record", 9 + "key": "tid", 10 + "record": { 11 + "type": "object", 12 + "required": [ 13 + "name", 14 + "knot", 15 + "owner", 16 + "createdAt" 17 + ], 18 + "properties": { 19 + "name": { 20 + "type": "string", 21 + "description": "name of the repo" 22 + }, 23 + "owner": { 24 + "type": "string", 25 + "format": "did" 26 + }, 27 + "knot": { 28 + "type": "string", 29 + "description": "knot where the repo was created" 30 + }, 31 + "spindle": { 32 + "type": "string", 33 + "description": "CI runner to send jobs to and receive results from" 34 + }, 35 + "description": { 36 + "type": "string", 37 + "minGraphemes": 1, 38 + "maxGraphemes": 140 39 + }, 40 + "source": { 41 + "type": "string", 42 + "format": "uri", 43 + "description": "source of the repo" 44 + }, 45 + "createdAt": { 46 + "type": "string", 47 + "format": "datetime" 48 + } 49 + } 50 + } 51 + } 52 + } 53 + }
+43
lexicons/repo/tags.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.tags", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "parameters": { 8 + "type": "params", 9 + "required": ["repo"], 10 + "properties": { 11 + "repo": { 12 + "type": "string", 13 + "description": "Repository identifier in format 'did:plc:.../repoName'" 14 + }, 15 + "limit": { 16 + "type": "integer", 17 + "description": "Maximum number of tags to return", 18 + "minimum": 1, 19 + "maximum": 100, 20 + "default": 50 21 + }, 22 + "cursor": { 23 + "type": "string", 24 + "description": "Pagination cursor" 25 + } 26 + } 27 + }, 28 + "output": { 29 + "encoding": "*/*" 30 + }, 31 + "errors": [ 32 + { 33 + "name": "RepoNotFound", 34 + "description": "Repository not found or access denied" 35 + }, 36 + { 37 + "name": "InvalidRequest", 38 + "description": "Invalid request parameters" 39 + } 40 + ] 41 + } 42 + } 43 + }
+123
lexicons/repo/tree.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.repo.tree", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "parameters": { 8 + "type": "params", 9 + "required": ["repo", "ref"], 10 + "properties": { 11 + "repo": { 12 + "type": "string", 13 + "description": "Repository identifier in format 'did:plc:.../repoName'" 14 + }, 15 + "ref": { 16 + "type": "string", 17 + "description": "Git reference (branch, tag, or commit SHA)" 18 + }, 19 + "path": { 20 + "type": "string", 21 + "description": "Path within the repository tree", 22 + "default": "" 23 + } 24 + } 25 + }, 26 + "output": { 27 + "encoding": "application/json", 28 + "schema": { 29 + "type": "object", 30 + "required": ["ref", "files"], 31 + "properties": { 32 + "ref": { 33 + "type": "string", 34 + "description": "The git reference used" 35 + }, 36 + "parent": { 37 + "type": "string", 38 + "description": "The parent path in the tree" 39 + }, 40 + "dotdot": { 41 + "type": "string", 42 + "description": "Parent directory path" 43 + }, 44 + "files": { 45 + "type": "array", 46 + "items": { 47 + "type": "ref", 48 + "ref": "#treeEntry" 49 + } 50 + } 51 + } 52 + } 53 + }, 54 + "errors": [ 55 + { 56 + "name": "RepoNotFound", 57 + "description": "Repository not found or access denied" 58 + }, 59 + { 60 + "name": "RefNotFound", 61 + "description": "Git reference not found" 62 + }, 63 + { 64 + "name": "PathNotFound", 65 + "description": "Path not found in repository tree" 66 + }, 67 + { 68 + "name": "InvalidRequest", 69 + "description": "Invalid request parameters" 70 + } 71 + ] 72 + }, 73 + "treeEntry": { 74 + "type": "object", 75 + "required": ["name", "mode", "size", "is_file", "is_subtree"], 76 + "properties": { 77 + "name": { 78 + "type": "string", 79 + "description": "Relative file or directory name" 80 + }, 81 + "mode": { 82 + "type": "string", 83 + "description": "File mode" 84 + }, 85 + "size": { 86 + "type": "integer", 87 + "description": "File size in bytes" 88 + }, 89 + "is_file": { 90 + "type": "boolean", 91 + "description": "Whether this entry is a file" 92 + }, 93 + "is_subtree": { 94 + "type": "boolean", 95 + "description": "Whether this entry is a directory/subtree" 96 + }, 97 + "last_commit": { 98 + "type": "ref", 99 + "ref": "#lastCommit" 100 + } 101 + } 102 + }, 103 + "lastCommit": { 104 + "type": "object", 105 + "required": ["hash", "message", "when"], 106 + "properties": { 107 + "hash": { 108 + "type": "string", 109 + "description": "Commit hash" 110 + }, 111 + "message": { 112 + "type": "string", 113 + "description": "Commit message" 114 + }, 115 + "when": { 116 + "type": "string", 117 + "format": "datetime", 118 + "description": "Commit timestamp" 119 + } 120 + } 121 + } 122 + } 123 + }
-54
lexicons/repo.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "sh.tangled.repo", 4 - "needsCbor": true, 5 - "needsType": true, 6 - "defs": { 7 - "main": { 8 - "type": "record", 9 - "key": "tid", 10 - "record": { 11 - "type": "object", 12 - "required": [ 13 - "name", 14 - "knot", 15 - "owner", 16 - "createdAt" 17 - ], 18 - "properties": { 19 - "name": { 20 - "type": "string", 21 - "description": "name of the repo" 22 - }, 23 - "owner": { 24 - "type": "string", 25 - "format": "did" 26 - }, 27 - "knot": { 28 - "type": "string", 29 - "description": "knot where the repo was created" 30 - }, 31 - "spindle": { 32 - "type": "string", 33 - "description": "CI runner to send jobs to and receive results from" 34 - }, 35 - "description": { 36 - "type": "string", 37 - "format": "datetime", 38 - "minGraphemes": 1, 39 - "maxGraphemes": 140 40 - }, 41 - "source": { 42 - "type": "string", 43 - "format": "uri", 44 - "description": "source of the repo" 45 - }, 46 - "createdAt": { 47 - "type": "string", 48 - "format": "datetime" 49 - } 50 - } 51 - } 52 - } 53 - } 54 - }
+25
lexicons/spindle/spindle.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.spindle", 4 + "needsCbor": true, 5 + "needsType": true, 6 + "defs": { 7 + "main": { 8 + "type": "record", 9 + "key": "any", 10 + "record": { 11 + "type": "object", 12 + "required": [ 13 + "createdAt" 14 + ], 15 + "properties": { 16 + "createdAt": { 17 + "type": "string", 18 + "format": "datetime" 19 + } 20 + } 21 + } 22 + } 23 + } 24 + } 25 +
-25
lexicons/spindle.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "sh.tangled.spindle", 4 - "needsCbor": true, 5 - "needsType": true, 6 - "defs": { 7 - "main": { 8 - "type": "record", 9 - "key": "any", 10 - "record": { 11 - "type": "object", 12 - "required": [ 13 - "createdAt" 14 - ], 15 - "properties": { 16 - "createdAt": { 17 - "type": "string", 18 - "format": "datetime" 19 - } 20 - } 21 - } 22 - } 23 - } 24 - } 25 -
+40
lexicons/string/string.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.string", 4 + "needsCbor": true, 5 + "needsType": true, 6 + "defs": { 7 + "main": { 8 + "type": "record", 9 + "key": "tid", 10 + "record": { 11 + "type": "object", 12 + "required": [ 13 + "filename", 14 + "description", 15 + "createdAt", 16 + "contents" 17 + ], 18 + "properties": { 19 + "filename": { 20 + "type": "string", 21 + "maxGraphemes": 140, 22 + "minGraphemes": 1 23 + }, 24 + "description": { 25 + "type": "string", 26 + "maxGraphemes": 280 27 + }, 28 + "createdAt": { 29 + "type": "string", 30 + "format": "datetime" 31 + }, 32 + "contents": { 33 + "type": "string", 34 + "minGraphemes": 1 35 + } 36 + } 37 + } 38 + } 39 + } 40 + }
+3 -1
log/log.go
··· 9 9 // NewHandler sets up a new slog.Handler with the service name 10 10 // as an attribute 11 11 func NewHandler(name string) slog.Handler { 12 - handler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{}) 12 + handler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ 13 + Level: slog.LevelDebug, 14 + }) 13 15 14 16 var attrs []slog.Attr 15 17 attrs = append(attrs, slog.Attr{Key: "service", Value: slog.StringValue(name)})
+532
nix/gomod2nix.toml
··· 1 + schema = 3 2 + 3 + [mod] 4 + [mod."dario.cat/mergo"] 5 + version = "v1.0.1" 6 + hash = "sha256-wcG6+x0k6KzOSlaPA+1RFxa06/RIAePJTAjjuhLbImw=" 7 + [mod."github.com/Blank-Xu/sql-adapter"] 8 + version = "v1.1.1" 9 + hash = "sha256-9AiQhXoNPCiViV+p5aa3qGFkYU4rJNbADvNdYGq4GA4=" 10 + [mod."github.com/Microsoft/go-winio"] 11 + version = "v0.6.2" 12 + hash = "sha256-tVNWDUMILZbJvarcl/E7tpSnkn7urqgSHa2Eaka5vSU=" 13 + [mod."github.com/ProtonMail/go-crypto"] 14 + version = "v1.3.0" 15 + hash = "sha256-TUG+C4MyeWglOmiwiW2/NUVurFHXLgEPRd3X9uQ1NGI=" 16 + [mod."github.com/alecthomas/assert/v2"] 17 + version = "v2.11.0" 18 + hash = "sha256-tDJCDKZ0R4qNA7hgMKWrpDyogt1802LCJDBCExxdqaU=" 19 + [mod."github.com/alecthomas/chroma/v2"] 20 + version = "v2.19.0" 21 + hash = "sha256-dxsu43a+PvHg2jYR0Tfys6a8x6IVR+9oCGAh+fvL3SM=" 22 + replaced = "github.com/oppiliappan/chroma/v2" 23 + [mod."github.com/alecthomas/repr"] 24 + version = "v0.4.0" 25 + hash = "sha256-CyAzMSTfLGHDtfGXi91y7XMVpPUDNOKjsznb+osl9dU=" 26 + [mod."github.com/anmitsu/go-shlex"] 27 + version = "v0.0.0-20200514113438-38f4b401e2be" 28 + hash = "sha256-L3Ak4X2z7WXq7vMKuiHCOJ29nlpajUQ08Sfb9T0yP54=" 29 + [mod."github.com/avast/retry-go/v4"] 30 + version = "v4.6.1" 31 + hash = "sha256-PeZc8k4rDV64+k8nZt/oy1YNVbLevltXP3ZD1jf6Z6k=" 32 + [mod."github.com/aymerick/douceur"] 33 + version = "v0.2.0" 34 + hash = "sha256-NiBX8EfOvLXNiK3pJaZX4N73YgfzdrzRXdiBFe3X3sE=" 35 + [mod."github.com/beorn7/perks"] 36 + version = "v1.0.1" 37 + hash = "sha256-h75GUqfwJKngCJQVE5Ao5wnO3cfKD9lSIteoLp/3xJ4=" 38 + [mod."github.com/bluekeyes/go-gitdiff"] 39 + version = "v0.8.2" 40 + hash = "sha256-GWm5i1ukuBukV0GMF1rffpbOSSXZdfg6/0pABMiGzLQ=" 41 + replaced = "tangled.sh/oppi.li/go-gitdiff" 42 + [mod."github.com/bluesky-social/indigo"] 43 + version = "v0.0.0-20250724221105-5827c8fb61bb" 44 + hash = "sha256-uDYmzP4/mT7xP62LIL4QIOlkaKWS/IT1uho+udyVOAI=" 45 + [mod."github.com/bluesky-social/jetstream"] 46 + version = "v0.0.0-20241210005130-ea96859b93d1" 47 + hash = "sha256-AiapbrkjXboIKc5QNiWH0KyNs0zKnn6UlGwWFlkUfm0=" 48 + [mod."github.com/bmatcuk/doublestar/v4"] 49 + version = "v4.7.1" 50 + hash = "sha256-idO38nWZtmxvE0CgdziepwFM+Xc70Isr6NiKEZjHOjA=" 51 + [mod."github.com/carlmjohnson/versioninfo"] 52 + version = "v0.22.5" 53 + hash = "sha256-tf7yKVFTUPmGKBLK43bjyIRQUboCYduh3I5HXE5+LPw=" 54 + [mod."github.com/casbin/casbin/v2"] 55 + version = "v2.103.0" 56 + hash = "sha256-adYds8Arni/ioPM9J0F+wAlJqhLLtCV9epv7d7tDvAQ=" 57 + [mod."github.com/casbin/govaluate"] 58 + version = "v1.3.0" 59 + hash = "sha256-vDUFEGt8oL4n/PHwlMZPjmaLvcpGTN4HEIRGl2FPxUA=" 60 + [mod."github.com/cenkalti/backoff/v4"] 61 + version = "v4.3.0" 62 + hash = "sha256-wfVjNZsGG1WoNC5aL+kdcy6QXPgZo4THAevZ1787md8=" 63 + [mod."github.com/cespare/xxhash/v2"] 64 + version = "v2.3.0" 65 + hash = "sha256-7hRlwSR+fos1kx4VZmJ/7snR7zHh8ZFKX+qqqqGcQpY=" 66 + [mod."github.com/cloudflare/circl"] 67 + version = "v1.6.2-0.20250618153321-aa837fd1539d" 68 + hash = "sha256-0s/i/XmMcuvPQ+qK9OIU5KxwYZyLVXRtdlYvIXRJT3Y=" 69 + [mod."github.com/cloudflare/cloudflare-go"] 70 + version = "v0.115.0" 71 + hash = "sha256-jezmDs6IsHA4rag7DzcHDfDgde0vU4iKgCN9+0XDViw=" 72 + [mod."github.com/containerd/errdefs"] 73 + version = "v1.0.0" 74 + hash = "sha256-wMZGoeqvRhuovYCJx0Js4P3qFCNTZ/6Atea/kNYoPMI=" 75 + [mod."github.com/containerd/errdefs/pkg"] 76 + version = "v0.3.0" 77 + hash = "sha256-BILJ0Be4cc8xfvLPylc/Pvwwa+w88+Hd0njzetUCeTg=" 78 + [mod."github.com/containerd/log"] 79 + version = "v0.1.0" 80 + hash = "sha256-vuE6Mie2gSxiN3jTKTZovjcbdBd1YEExb7IBe3GM+9s=" 81 + [mod."github.com/cyphar/filepath-securejoin"] 82 + version = "v0.4.1" 83 + hash = "sha256-NOV6MfbkcQbfhNmfADQw2SJmZ6q1nw0wwg8Pm2tf2DM=" 84 + [mod."github.com/davecgh/go-spew"] 85 + version = "v1.1.2-0.20180830191138-d8f796af33cc" 86 + hash = "sha256-fV9oI51xjHdOmEx6+dlq7Ku2Ag+m/bmbzPo6A4Y74qc=" 87 + [mod."github.com/decred/dcrd/dcrec/secp256k1/v4"] 88 + version = "v4.4.0" 89 + hash = "sha256-qrhEIwhDll3cxoVpMbm1NQ9/HTI42S7ms8Buzlo5HCg=" 90 + [mod."github.com/dgraph-io/ristretto"] 91 + version = "v0.2.0" 92 + hash = "sha256-bnpxX+oO/Qf7IJevA0gsbloVoqRx+5bh7RQ9d9eLNYw=" 93 + [mod."github.com/dgryski/go-rendezvous"] 94 + version = "v0.0.0-20200823014737-9f7001d12a5f" 95 + hash = "sha256-n/7xo5CQqo4yLaWMSzSN1Muk/oqK6O5dgDOFWapeDUI=" 96 + [mod."github.com/distribution/reference"] 97 + version = "v0.6.0" 98 + hash = "sha256-gr4tL+qz4jKyAtl8LINcxMSanztdt+pybj1T+2ulQv4=" 99 + [mod."github.com/dlclark/regexp2"] 100 + version = "v1.11.5" 101 + hash = "sha256-jN5+2ED+YbIoPIuyJ4Ou5pqJb2w1uNKzp5yTjKY6rEQ=" 102 + [mod."github.com/docker/docker"] 103 + version = "v28.2.2+incompatible" 104 + hash = "sha256-5FnlTcygdxpHyFB0/7EsYocFhADUAjC/Dku0Xn4W8so=" 105 + [mod."github.com/docker/go-connections"] 106 + version = "v0.5.0" 107 + hash = "sha256-aGbMRrguh98DupIHgcpLkVUZpwycx1noQXbtTl5Sbms=" 108 + [mod."github.com/docker/go-units"] 109 + version = "v0.5.0" 110 + hash = "sha256-iK/V/jJc+borzqMeqLY+38Qcts2KhywpsTk95++hImE=" 111 + [mod."github.com/dustin/go-humanize"] 112 + version = "v1.0.1" 113 + hash = "sha256-yuvxYYngpfVkUg9yAmG99IUVmADTQA0tMbBXe0Fq0Mc=" 114 + [mod."github.com/emirpasic/gods"] 115 + version = "v1.18.1" 116 + hash = "sha256-hGDKddjLj+5dn2woHtXKUdd49/3xdsqnhx7VEdCu1m4=" 117 + [mod."github.com/felixge/httpsnoop"] 118 + version = "v1.0.4" 119 + hash = "sha256-c1JKoRSndwwOyOxq9ddCe+8qn7mG9uRq2o/822x5O/c=" 120 + [mod."github.com/fsnotify/fsnotify"] 121 + version = "v1.6.0" 122 + hash = "sha256-DQesOCweQPEwmAn6s7DCP/Dwy8IypC+osbpfsvpkdP0=" 123 + [mod."github.com/gliderlabs/ssh"] 124 + version = "v0.3.8" 125 + hash = "sha256-FW+91qCB3rfTm0I1VmqfwA7o+2kDys2JHOudKKyxWwc=" 126 + [mod."github.com/go-chi/chi/v5"] 127 + version = "v5.2.0" 128 + hash = "sha256-rCZ2W5BdWwjtv7SSpHOgpYEHf9ketzdPX+r2500JL8A=" 129 + [mod."github.com/go-enry/go-enry/v2"] 130 + version = "v2.9.2" 131 + hash = "sha256-LkCSW+4+DkTok1JcOQR0rt3UKNKVn4KPaiDeatdQhCU=" 132 + [mod."github.com/go-enry/go-oniguruma"] 133 + version = "v1.2.1" 134 + hash = "sha256-DoCNyX75CuCgFnfSZs63VB4+HAIMDBgwcQglXXHRj/I=" 135 + [mod."github.com/go-git/gcfg"] 136 + version = "v1.5.1-0.20230307220236-3a3c6141e376" 137 + hash = "sha256-f4k0gSYuo0/q3WOoTxl2eFaj7WZpdz29ih6CKc8Ude8=" 138 + [mod."github.com/go-git/go-billy/v5"] 139 + version = "v5.6.2" 140 + hash = "sha256-VgbxcLkHjiSyRIfKS7E9Sn8OynCrMGUDkwFz6K2TVL4=" 141 + [mod."github.com/go-git/go-git/v5"] 142 + version = "v5.17.0" 143 + hash = "sha256-gya68abB6GtejUqr60DyU7NIGtNzHQVCAeDTYKk1evQ=" 144 + replaced = "github.com/oppiliappan/go-git/v5" 145 + [mod."github.com/go-jose/go-jose/v3"] 146 + version = "v3.0.4" 147 + hash = "sha256-RrLHCu9l6k0XVobdZQJ9Sx/VTQcWjrdLR5BEG7yXTEQ=" 148 + [mod."github.com/go-logr/logr"] 149 + version = "v1.4.3" 150 + hash = "sha256-Nnp/dEVNMxLp3RSPDHZzGbI8BkSNuZMX0I0cjWKXXLA=" 151 + [mod."github.com/go-logr/stdr"] 152 + version = "v1.2.2" 153 + hash = "sha256-rRweAP7XIb4egtT1f2gkz4sYOu7LDHmcJ5iNsJUd0sE=" 154 + [mod."github.com/go-redis/cache/v9"] 155 + version = "v9.0.0" 156 + hash = "sha256-b4S3K4KoZhF0otw6FRIOq/PTdHGrb/LumB4GKo4khsY=" 157 + [mod."github.com/go-test/deep"] 158 + version = "v1.1.1" 159 + hash = "sha256-WvPrTvUPmbQb4R6DrvSB9O3zm0IOk+n14YpnSl2deR8=" 160 + [mod."github.com/goccy/go-json"] 161 + version = "v0.10.5" 162 + hash = "sha256-/EtlGihP0/7oInzMC5E0InZ4b5Ad3s4xOpqotloi3xw=" 163 + [mod."github.com/gogo/protobuf"] 164 + version = "v1.3.2" 165 + hash = "sha256-pogILFrrk+cAtb0ulqn9+gRZJ7sGnnLLdtqITvxvG6c=" 166 + [mod."github.com/golang-jwt/jwt/v5"] 167 + version = "v5.2.3" 168 + hash = "sha256-dY2avNPPS3xokn5E+VCLxXcQk7DsM7err2QGrG0nXKo=" 169 + [mod."github.com/golang/groupcache"] 170 + version = "v0.0.0-20241129210726-2c02b8208cf8" 171 + hash = "sha256-AdLZ3dJLe/yduoNvZiXugZxNfmwJjNQyQGsIdzYzH74=" 172 + [mod."github.com/golang/mock"] 173 + version = "v1.6.0" 174 + hash = "sha256-fWdnMQisRbiRzGT3ISrUHovquzLRHWvcv1JEsJFZRno=" 175 + [mod."github.com/google/go-querystring"] 176 + version = "v1.1.0" 177 + hash = "sha256-itsKgKghuX26czU79cK6C2n+lc27jm5Dw1XbIRgwZJY=" 178 + [mod."github.com/google/uuid"] 179 + version = "v1.6.0" 180 + hash = "sha256-VWl9sqUzdOuhW0KzQlv0gwwUQClYkmZwSydHG2sALYw=" 181 + [mod."github.com/gorilla/css"] 182 + version = "v1.0.1" 183 + hash = "sha256-6JwNHqlY2NpZ0pSQTyYPSpiNqjXOdFHqrUT10sv3y8A=" 184 + [mod."github.com/gorilla/feeds"] 185 + version = "v1.2.0" 186 + hash = "sha256-ptczizo27t6Bsq6rHJ4WiHmBRP54UC5yNfHghAqOBQk=" 187 + [mod."github.com/gorilla/securecookie"] 188 + version = "v1.1.2" 189 + hash = "sha256-KeMHNM9emxX+N0WYiZsTii7n8sNsmjWwbnQ9SaJfTKE=" 190 + [mod."github.com/gorilla/sessions"] 191 + version = "v1.4.0" 192 + hash = "sha256-cLK2z1uOEz7Wah/LclF65ptYMqzuvaRnfIGYqtn3b7g=" 193 + [mod."github.com/gorilla/websocket"] 194 + version = "v1.5.4-0.20250319132907-e064f32e3674" 195 + hash = "sha256-a8n6oe20JDpwThClgAyVhJDi6QVaS0qzT4PvRxlQ9to=" 196 + [mod."github.com/hashicorp/errwrap"] 197 + version = "v1.1.0" 198 + hash = "sha256-6lwuMQOfBq+McrViN3maJTIeh4f8jbEqvLy2c9FvvFw=" 199 + [mod."github.com/hashicorp/go-cleanhttp"] 200 + version = "v0.5.2" 201 + hash = "sha256-N9GOKYo7tK6XQUFhvhImtL7PZW/mr4C4Manx/yPVvcQ=" 202 + [mod."github.com/hashicorp/go-multierror"] 203 + version = "v1.1.1" 204 + hash = "sha256-ANzPEUJIZIlToxR89Mn7Db73d9LGI51ssy7eNnUgmlA=" 205 + [mod."github.com/hashicorp/go-retryablehttp"] 206 + version = "v0.7.8" 207 + hash = "sha256-4LZwKaFBbpKi9lSq5y6lOlYHU6WMnQdGNMxTd33rN80=" 208 + [mod."github.com/hashicorp/go-secure-stdlib/parseutil"] 209 + version = "v0.2.0" 210 + hash = "sha256-mb27ZKw5VDTmNj1QJvxHVR0GyY7UdacLJ0jWDV3nQd8=" 211 + [mod."github.com/hashicorp/go-secure-stdlib/strutil"] 212 + version = "v0.1.2" 213 + hash = "sha256-UmCMzjamCW1d9KNvNzELqKf1ElHOXPz+ZtdJkI+DV0A=" 214 + [mod."github.com/hashicorp/go-sockaddr"] 215 + version = "v1.0.7" 216 + hash = "sha256-p6eDOrGzN1jMmT/F/f/VJMq0cKNFhUcEuVVwTE6vSrs=" 217 + [mod."github.com/hashicorp/golang-lru"] 218 + version = "v1.0.2" 219 + hash = "sha256-yy+5botc6T5wXgOe2mfNXJP3wr+MkVlUZ2JBkmmrA48=" 220 + [mod."github.com/hashicorp/golang-lru/v2"] 221 + version = "v2.0.7" 222 + hash = "sha256-t1bcXLgrQNOYUVyYEZ0knxcXpsTk4IuJZDjKvyJX75g=" 223 + [mod."github.com/hashicorp/hcl"] 224 + version = "v1.0.1-vault-7" 225 + hash = "sha256-xqYtjCJQVsg04Yj2Uy2Q5bi6X6cDRYhJD/SUEWaHMDM=" 226 + [mod."github.com/hexops/gotextdiff"] 227 + version = "v1.0.3" 228 + hash = "sha256-wVs5uJs2KHU1HnDCDdSe0vIgNZylvs8oNidDxwA3+O0=" 229 + [mod."github.com/hiddeco/sshsig"] 230 + version = "v0.2.0" 231 + hash = "sha256-Yc8Ip4XxrL5plb7Lq0ziYFznteVDZnskoyOZDIMsWOU=" 232 + [mod."github.com/hpcloud/tail"] 233 + version = "v1.0.0" 234 + hash = "sha256-7ByBr/RcOwIsGPCiCUpfNwUSvU18QAY+HMnCJr8uU1w=" 235 + [mod."github.com/ipfs/bbloom"] 236 + version = "v0.0.4" 237 + hash = "sha256-4k778kBlNul2Rc4xuNQ9WA4kT0V7x5X9odZrT+2xjTU=" 238 + [mod."github.com/ipfs/boxo"] 239 + version = "v0.33.0" 240 + hash = "sha256-C85D/TjyoWNKvRFvl2A9hBjpDPhC//hGB2jRoDmXM38=" 241 + [mod."github.com/ipfs/go-block-format"] 242 + version = "v0.2.2" 243 + hash = "sha256-kz87tlGneqEARGnjA1Lb0K5CqD1lgxhBD68rfmzZZGU=" 244 + [mod."github.com/ipfs/go-cid"] 245 + version = "v0.5.0" 246 + hash = "sha256-BuZKkcBXrnx7mM1c9SP4LdzZoaAoai9U49vITGrYJQk=" 247 + [mod."github.com/ipfs/go-datastore"] 248 + version = "v0.8.2" 249 + hash = "sha256-9Q7+bi04srAE3AcXzWSGs/HP6DWnE1Edtx3NnjMQi8U=" 250 + [mod."github.com/ipfs/go-ipfs-blockstore"] 251 + version = "v1.3.1" 252 + hash = "sha256-NFlKr8bdJcM5FLlkc51sKt4AnMMlHS4wbdKiiaoDaqg=" 253 + [mod."github.com/ipfs/go-ipfs-ds-help"] 254 + version = "v1.1.1" 255 + hash = "sha256-cpEohOsf4afYRGTdsWh84TCVGIDzJo2hSjWy7NtNtvY=" 256 + [mod."github.com/ipfs/go-ipld-cbor"] 257 + version = "v0.2.1" 258 + hash = "sha256-ONBX/YO/knnmp+12fC13KsKVeo/vdWOI3SDyqCBxRE4=" 259 + [mod."github.com/ipfs/go-ipld-format"] 260 + version = "v0.6.2" 261 + hash = "sha256-nGLM/n/hy+0q1VIzQUvF4D3aKvL238ALIEziQQhVMkU=" 262 + [mod."github.com/ipfs/go-log"] 263 + version = "v1.0.5" 264 + hash = "sha256-WQarHZo2y/rH6ixLsOlN5fFZeLUqsOTMnvdxszP2Qj4=" 265 + [mod."github.com/ipfs/go-log/v2"] 266 + version = "v2.6.0" 267 + hash = "sha256-cZ+rsx7LIROoNITyu/s0B6hq8lNQsUC1ynvx2f2o4Gk=" 268 + [mod."github.com/ipfs/go-metrics-interface"] 269 + version = "v0.3.0" 270 + hash = "sha256-b3tp3jxecLmJEGx2kW7MiKGlAKPEWg/LJ7hXylSC8jQ=" 271 + [mod."github.com/kevinburke/ssh_config"] 272 + version = "v1.2.0" 273 + hash = "sha256-Ta7ZOmyX8gG5tzWbY2oES70EJPfI90U7CIJS9EAce0s=" 274 + [mod."github.com/klauspost/compress"] 275 + version = "v1.18.0" 276 + hash = "sha256-jc5pMU/HCBFOShMcngVwNMhz9wolxjOb579868LtOuk=" 277 + [mod."github.com/klauspost/cpuid/v2"] 278 + version = "v2.3.0" 279 + hash = "sha256-50JhbQyT67BK38HIdJihPtjV7orYp96HknI2VP7A9Yc=" 280 + [mod."github.com/lestrrat-go/blackmagic"] 281 + version = "v1.0.4" 282 + hash = "sha256-HmWOpwoPDNMwLdOi7onNn3Sb+ZsAa3Ai3gVBbXmQ0e8=" 283 + [mod."github.com/lestrrat-go/httpcc"] 284 + version = "v1.0.1" 285 + hash = "sha256-SMRSwJpqDIs/xL0l2e8vP0W65qtCHX2wigcOeqPJmos=" 286 + [mod."github.com/lestrrat-go/httprc"] 287 + version = "v1.0.6" 288 + hash = "sha256-mfZzePEhrmyyu/avEBd2MsDXyto8dq5+fyu5lA8GUWM=" 289 + [mod."github.com/lestrrat-go/iter"] 290 + version = "v1.0.2" 291 + hash = "sha256-30tErRf7Qu/NOAt1YURXY/XJSA6sCr6hYQfO8QqHrtw=" 292 + [mod."github.com/lestrrat-go/jwx/v2"] 293 + version = "v2.1.6" 294 + hash = "sha256-0LszXRZIba+X8AOrs3T4uanAUafBdlVB8/MpUNEFpbc=" 295 + [mod."github.com/lestrrat-go/option"] 296 + version = "v1.0.1" 297 + hash = "sha256-jVcIYYVsxElIS/l2akEw32vdEPR8+anR6oeT1FoYULI=" 298 + [mod."github.com/mattn/go-isatty"] 299 + version = "v0.0.20" 300 + hash = "sha256-qhw9hWtU5wnyFyuMbKx+7RB8ckQaFQ8D+8GKPkN3HHQ=" 301 + [mod."github.com/mattn/go-sqlite3"] 302 + version = "v1.14.24" 303 + hash = "sha256-taGKFZFQlR5++5b2oZ1dYS3RERKv6yh1gniNWhb4egg=" 304 + [mod."github.com/microcosm-cc/bluemonday"] 305 + version = "v1.0.27" 306 + hash = "sha256-EZSya9FLPQ83CL7N2cZy21fdS35hViTkiMK5f3op8Es=" 307 + [mod."github.com/minio/sha256-simd"] 308 + version = "v1.0.1" 309 + hash = "sha256-4hfGDIQaWq8fvtGzHDhoK9v2IocXnJY7OAL6saMJbmA=" 310 + [mod."github.com/mitchellh/mapstructure"] 311 + version = "v1.5.0" 312 + hash = "sha256-ztVhGQXs67MF8UadVvG72G3ly0ypQW0IRDdOOkjYwoE=" 313 + [mod."github.com/moby/docker-image-spec"] 314 + version = "v1.3.1" 315 + hash = "sha256-xwSNLmMagzywdGJIuhrWl1r7cIWBYCOMNYbuDDT6Jhs=" 316 + [mod."github.com/moby/sys/atomicwriter"] 317 + version = "v0.1.0" 318 + hash = "sha256-i46GNrsICnJ0AYkN+ocbVZ2GNTQVEsrVX5WcjKzjtBM=" 319 + [mod."github.com/moby/term"] 320 + version = "v0.5.2" 321 + hash = "sha256-/G20jUZKx36ktmPU/nEw/gX7kRTl1Dbu7zvNBYNt4xU=" 322 + [mod."github.com/morikuni/aec"] 323 + version = "v1.0.0" 324 + hash = "sha256-5zYgLeGr3K+uhGKlN3xv0PO67V+2Zw+cezjzNCmAWOE=" 325 + [mod."github.com/mr-tron/base58"] 326 + version = "v1.2.0" 327 + hash = "sha256-8FzMu3kHUbBX10pUdtGf59Ag7BNupx8ZHeUaodR1/Vk=" 328 + [mod."github.com/multiformats/go-base32"] 329 + version = "v0.1.0" 330 + hash = "sha256-O2IM7FB+Y9MkDdZztyQL5F8oEnmON2Yew7XkotQziio=" 331 + [mod."github.com/multiformats/go-base36"] 332 + version = "v0.2.0" 333 + hash = "sha256-GKNnAGA0Lb39BDGYBm1ieKdXmho8Pu7ouyfVPXvV0PE=" 334 + [mod."github.com/multiformats/go-multibase"] 335 + version = "v0.2.0" 336 + hash = "sha256-w+hp6u5bWyd34qe0CX+bq487ADqq6SgRR/JuqRB578s=" 337 + [mod."github.com/multiformats/go-multihash"] 338 + version = "v0.2.3" 339 + hash = "sha256-zqIIE5jMFzm+qhUrouSF+WdXGeHUEYIQvVnKWWU6mRs=" 340 + [mod."github.com/multiformats/go-varint"] 341 + version = "v0.0.7" 342 + hash = "sha256-To3Uuv7uSUJEr5OTwxE1LEIpA62xY3M/KKMNlscHmlA=" 343 + [mod."github.com/munnerz/goautoneg"] 344 + version = "v0.0.0-20191010083416-a7dc8b61c822" 345 + hash = "sha256-79URDDFenmGc9JZu+5AXHToMrtTREHb3BC84b/gym9Q=" 346 + [mod."github.com/onsi/gomega"] 347 + version = "v1.37.0" 348 + hash = "sha256-PfHFYp365MwBo+CUZs+mN5QEk3Kqe9xrBX+twWfIc9o=" 349 + [mod."github.com/openbao/openbao/api/v2"] 350 + version = "v2.3.0" 351 + hash = "sha256-1bIyvL3GdzPUfsM+gxuKMaH5jKxMaucZQgL6/DfbmDM=" 352 + [mod."github.com/opencontainers/go-digest"] 353 + version = "v1.0.0" 354 + hash = "sha256-cfVDjHyWItmUGZ2dzQhCHgmOmou8v7N+itDkLZVkqkQ=" 355 + [mod."github.com/opencontainers/image-spec"] 356 + version = "v1.1.1" 357 + hash = "sha256-bxBjtl+6846Ed3QHwdssOrNvlHV6b+Dn17zPISSQGP8=" 358 + [mod."github.com/opentracing/opentracing-go"] 359 + version = "v1.2.1-0.20220228012449-10b1cf09e00b" 360 + hash = "sha256-77oWcDviIoGWHVAotbgmGRpLGpH5AUy+pM15pl3vRrw=" 361 + [mod."github.com/pjbgf/sha1cd"] 362 + version = "v0.3.2" 363 + hash = "sha256-jdbiRhU8xc1C5c8m7BSCj71PUXHY3f7TWFfxDKKpUMk=" 364 + [mod."github.com/pkg/errors"] 365 + version = "v0.9.1" 366 + hash = "sha256-mNfQtcrQmu3sNg/7IwiieKWOgFQOVVe2yXgKBpe/wZw=" 367 + [mod."github.com/pmezard/go-difflib"] 368 + version = "v1.0.1-0.20181226105442-5d4384ee4fb2" 369 + hash = "sha256-XA4Oj1gdmdV/F/+8kMI+DBxKPthZ768hbKsO3d9Gx90=" 370 + [mod."github.com/polydawn/refmt"] 371 + version = "v0.89.1-0.20221221234430-40501e09de1f" 372 + hash = "sha256-wBdFROClTHNPYU4IjeKbBXaG7F6j5hZe15gMxiqKvi4=" 373 + [mod."github.com/posthog/posthog-go"] 374 + version = "v1.5.5" 375 + hash = "sha256-ouhfDUCXsfpcgaCLfJE9oYprAQHuV61OJzb/aEhT0j8=" 376 + [mod."github.com/prometheus/client_golang"] 377 + version = "v1.22.0" 378 + hash = "sha256-OJ/9rlWG1DIPQJAZUTzjykkX0o+f+4IKLvW8YityaMQ=" 379 + [mod."github.com/prometheus/client_model"] 380 + version = "v0.6.2" 381 + hash = "sha256-q6Fh6v8iNJN9ypD47LjWmx66YITa3FyRjZMRsuRTFeQ=" 382 + [mod."github.com/prometheus/common"] 383 + version = "v0.64.0" 384 + hash = "sha256-uy3KO60F2Cvhamz3fWQALGSsy13JiTk3NfpXgRuwqtI=" 385 + [mod."github.com/prometheus/procfs"] 386 + version = "v0.16.1" 387 + hash = "sha256-OBCvKlLW2obct35p0L9Q+1ZrxZjpTmbgHMP2rng9hpo=" 388 + [mod."github.com/redis/go-redis/v9"] 389 + version = "v9.7.3" 390 + hash = "sha256-7ip5Ns/NEnFmVLr5iN8m3gS4RrzVAYJ7pmJeeaTmjjo=" 391 + [mod."github.com/resend/resend-go/v2"] 392 + version = "v2.15.0" 393 + hash = "sha256-1lMoxuMLQXaNWFKadS6rpztAKwvIl3/LWMXqw7f5WYg=" 394 + [mod."github.com/ryanuber/go-glob"] 395 + version = "v1.0.0" 396 + hash = "sha256-YkMl1utwUhi3E0sHK23ISpAsPyj4+KeXyXKoFYGXGVY=" 397 + [mod."github.com/segmentio/asm"] 398 + version = "v1.2.0" 399 + hash = "sha256-zbNuKxNrUDUc6IlmRQNuJQzVe5Ol/mqp7srDg9IMMqs=" 400 + [mod."github.com/sergi/go-diff"] 401 + version = "v1.1.0" 402 + hash = "sha256-8NJMabldpf40uwQN20T6QXx5KORDibCBJL02KD661xY=" 403 + replaced = "github.com/sergi/go-diff" 404 + [mod."github.com/sethvargo/go-envconfig"] 405 + version = "v1.1.0" 406 + hash = "sha256-WelRHfyZG9hrA4fbQcfBawb2ZXBQNT1ourEYHzQdZ4w=" 407 + [mod."github.com/spaolacci/murmur3"] 408 + version = "v1.1.0" 409 + hash = "sha256-RWD4PPrlAsZZ8Xy356MBxpj+/NZI7w2XOU14Ob7/Y9M=" 410 + [mod."github.com/stretchr/testify"] 411 + version = "v1.10.0" 412 + hash = "sha256-fJ4gnPr0vnrOhjQYQwJ3ARDKPsOtA7d4olQmQWR+wpI=" 413 + [mod."github.com/urfave/cli/v3"] 414 + version = "v3.3.3" 415 + hash = "sha256-FdPiu7koY1qBinkfca4A05zCrX+Vu4eRz8wlRDZJyGg=" 416 + [mod."github.com/vmihailenco/go-tinylfu"] 417 + version = "v0.2.2" 418 + hash = "sha256-ZHr4g7DJAV6rLcfrEWZwo9wJSeZcXB9KSP38UIOFfaM=" 419 + [mod."github.com/vmihailenco/msgpack/v5"] 420 + version = "v5.4.1" 421 + hash = "sha256-pDplX6xU6UpNLcFbO1pRREW5vCnSPvSU+ojAwFDv3Hk=" 422 + [mod."github.com/vmihailenco/tagparser/v2"] 423 + version = "v2.0.0" 424 + hash = "sha256-M9QyaKhSmmYwsJk7gkjtqu9PuiqZHSmTkous8VWkWY0=" 425 + [mod."github.com/whyrusleeping/cbor-gen"] 426 + version = "v0.3.1" 427 + hash = "sha256-PAd8M2Z8t6rVRBII+Rg8Bz+QaJIwbW64bfyqsv31kgc=" 428 + [mod."github.com/wyatt915/goldmark-treeblood"] 429 + version = "v0.0.0-20250825231212-5dcbdb2f4b57" 430 + hash = "sha256-IZEsUXTBTsNgWoD7vqRUc9aFCCHNjzk1IUmI9O+NCnM=" 431 + [mod."github.com/wyatt915/treeblood"] 432 + version = "v0.1.15" 433 + hash = "sha256-hb99exdkoY2Qv8WdDxhwgPXGbEYimUr6wFtPXEvcO9g=" 434 + [mod."github.com/yuin/goldmark"] 435 + version = "v1.7.12" 436 + hash = "sha256-thLYBS4woL2X5qRdo7vP+xCvjlGRDU0jXtDCUt6vvWM=" 437 + [mod."github.com/yuin/goldmark-highlighting/v2"] 438 + version = "v2.0.0-20230729083705-37449abec8cc" 439 + hash = "sha256-HpiwU7jIeDUAg2zOpTIiviQir8dpRPuXYh2nqFFccpg=" 440 + [mod."gitlab.com/yawning/secp256k1-voi"] 441 + version = "v0.0.0-20230925100816-f2616030848b" 442 + hash = "sha256-X8INg01LTg13iOuwPI3uOhPN7r01sPZtmtwJ2sudjCA=" 443 + [mod."gitlab.com/yawning/tuplehash"] 444 + version = "v0.0.0-20230713102510-df83abbf9a02" 445 + hash = "sha256-pehQduoaJRLchebhgvMYacVvbuNIBA++XkiqCuqdato=" 446 + [mod."go.opentelemetry.io/auto/sdk"] 447 + version = "v1.1.0" 448 + hash = "sha256-cA9qCCu8P1NSJRxgmpfkfa5rKyn9X+Y/9FSmSd5xjyo=" 449 + [mod."go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"] 450 + version = "v0.62.0" 451 + hash = "sha256-WcDogpsvFxGOVqc+/ljlZ10nrOU2q0rPteukGyWWmfc=" 452 + [mod."go.opentelemetry.io/otel"] 453 + version = "v1.37.0" 454 + hash = "sha256-zWpyp9K8/Te86uhNjamchZctTdAnmHhoVw9m4ACfSoo=" 455 + [mod."go.opentelemetry.io/otel/exporters/otlp/otlptrace"] 456 + version = "v1.33.0" 457 + hash = "sha256-D5BMzmtN1d3pRnxIcvDOyQrjerK1JoavtYjJLhPKv/I=" 458 + [mod."go.opentelemetry.io/otel/metric"] 459 + version = "v1.37.0" 460 + hash = "sha256-BWnkdldA3xzGhnaConzMAuQzOnugytIvrP6GjkZVAYg=" 461 + [mod."go.opentelemetry.io/otel/trace"] 462 + version = "v1.37.0" 463 + hash = "sha256-FBeLOb5qmIiE9VmbgCf1l/xpndBqHkRiaPt1PvoKrVY=" 464 + [mod."go.opentelemetry.io/proto/otlp"] 465 + version = "v1.6.0" 466 + hash = "sha256-1kjkJ9cqkvx3ib6ytLcw+Adp4xqD3ShF97lpzt/BeCg=" 467 + [mod."go.uber.org/atomic"] 468 + version = "v1.11.0" 469 + hash = "sha256-TyYws/cSPVqYNffFX0gbDml1bD4bBGcysrUWU7mHPIY=" 470 + [mod."go.uber.org/multierr"] 471 + version = "v1.11.0" 472 + hash = "sha256-Lb6rHHfR62Ozg2j2JZy3MKOMKdsfzd1IYTR57r3Mhp0=" 473 + [mod."go.uber.org/zap"] 474 + version = "v1.27.0" 475 + hash = "sha256-8655KDrulc4Das3VRduO9MjCn8ZYD5WkULjCvruaYsU=" 476 + [mod."golang.org/x/crypto"] 477 + version = "v0.40.0" 478 + hash = "sha256-I6p2fqvz63P9MwAuoQrljI7IUbfZQvCem0ii4Q2zZng=" 479 + [mod."golang.org/x/exp"] 480 + version = "v0.0.0-20250620022241-b7579e27df2b" 481 + hash = "sha256-IsDTeuWLj4UkPO4NhWTvFeZ22WNtlxjoWiyAJh6zdig=" 482 + [mod."golang.org/x/net"] 483 + version = "v0.42.0" 484 + hash = "sha256-YxileisIIez+kcAI+21kY5yk0iRuEqti2YdmS8jvP2s=" 485 + [mod."golang.org/x/sync"] 486 + version = "v0.16.0" 487 + hash = "sha256-sqKDRESeMzLe0jWGWltLZL/JIgrn0XaIeBWCzVN3Bks=" 488 + [mod."golang.org/x/sys"] 489 + version = "v0.34.0" 490 + hash = "sha256-5rZ7p8IaGli5X1sJbfIKOcOEwY4c0yQhinJPh2EtK50=" 491 + [mod."golang.org/x/text"] 492 + version = "v0.27.0" 493 + hash = "sha256-VX0rOh6L3qIvquKSGjfZQFU8URNtGvkNvxE7OZtboW8=" 494 + [mod."golang.org/x/time"] 495 + version = "v0.12.0" 496 + hash = "sha256-Cp3oxrCMH2wyxjzr5SHVmyhgaoUuSl56Uy00Q7DYEpw=" 497 + [mod."golang.org/x/xerrors"] 498 + version = "v0.0.0-20240903120638-7835f813f4da" 499 + hash = "sha256-bE7CcrnAvryNvM26ieJGXqbAtuLwHaGcmtVMsVnksqo=" 500 + [mod."google.golang.org/genproto/googleapis/api"] 501 + version = "v0.0.0-20250603155806-513f23925822" 502 + hash = "sha256-0CS432v9zVhkVLqFpZtxBX8rvVqP67lb7qQ3es7RqIU=" 503 + [mod."google.golang.org/genproto/googleapis/rpc"] 504 + version = "v0.0.0-20250603155806-513f23925822" 505 + hash = "sha256-WK7iDtAhH19NPe3TywTQlGjDawNaDKWnxhFL9PgVUwM=" 506 + [mod."google.golang.org/grpc"] 507 + version = "v1.73.0" 508 + hash = "sha256-LfVlwip++q2DX70RU6CxoXglx1+r5l48DwlFD05G11c=" 509 + [mod."google.golang.org/protobuf"] 510 + version = "v1.36.6" 511 + hash = "sha256-lT5qnefI5FDJnowz9PEkAGylH3+fE+A3DJDkAyy9RMc=" 512 + [mod."gopkg.in/fsnotify.v1"] 513 + version = "v1.4.7" 514 + hash = "sha256-j/Ts92oXa3k1MFU7Yd8/AqafRTsFn7V2pDKCyDJLah8=" 515 + [mod."gopkg.in/tomb.v1"] 516 + version = "v1.0.0-20141024135613-dd632973f1e7" 517 + hash = "sha256-W/4wBAvuaBFHhowB67SZZfXCRDp5tzbYG4vo81TAFdM=" 518 + [mod."gopkg.in/warnings.v0"] 519 + version = "v0.1.2" 520 + hash = "sha256-ATVL9yEmgYbkJ1DkltDGRn/auGAjqGOfjQyBYyUo8s8=" 521 + [mod."gopkg.in/yaml.v3"] 522 + version = "v3.0.1" 523 + hash = "sha256-FqL9TKYJ0XkNwJFnq9j0VvJ5ZUU1RvH/52h/f5bkYAU=" 524 + [mod."gotest.tools/v3"] 525 + version = "v3.5.2" 526 + hash = "sha256-eAxnRrF2bQugeFYzGLOr+4sLyCPOpaTWpoZsIKNP1WE=" 527 + [mod."lukechampine.com/blake3"] 528 + version = "v1.4.1" 529 + hash = "sha256-HaZGo9L44ptPsgxIhvKy3+0KZZm1+xt+cZC1rDQA9Yc=" 530 + [mod."tangled.sh/icyphox.sh/atproto-oauth"] 531 + version = "v0.0.0-20250724194903-28e660378cb1" 532 + hash = "sha256-z7huwCTTHqLb1hxQW62lz9GQ3Orqt4URfeOVhQVd1f8="
+54 -35
nix/modules/appview.nix
··· 1 - {self}: { 1 + { 2 2 config, 3 - pkgs, 4 3 lib, 5 4 ... 6 - }: 7 - with lib; { 8 - options = { 9 - services.tangled-appview = { 10 - enable = mkOption { 11 - type = types.bool; 12 - default = false; 13 - description = "Enable tangled appview"; 14 - }; 15 - port = mkOption { 16 - type = types.int; 17 - default = 3000; 18 - description = "Port to run the appview on"; 19 - }; 20 - cookie_secret = mkOption { 21 - type = types.str; 22 - default = "00000000000000000000000000000000"; 23 - description = "Cookie secret"; 5 + }: let 6 + cfg = config.services.tangled-appview; 7 + in 8 + with lib; { 9 + options = { 10 + services.tangled-appview = { 11 + enable = mkOption { 12 + type = types.bool; 13 + default = false; 14 + description = "Enable tangled appview"; 15 + }; 16 + package = mkOption { 17 + type = types.package; 18 + description = "Package to use for the appview"; 19 + }; 20 + port = mkOption { 21 + type = types.int; 22 + default = 3000; 23 + description = "Port to run the appview on"; 24 + }; 25 + cookie_secret = mkOption { 26 + type = types.str; 27 + default = "00000000000000000000000000000000"; 28 + description = "Cookie secret"; 29 + }; 30 + environmentFile = mkOption { 31 + type = with types; nullOr path; 32 + default = null; 33 + example = "/etc/tangled-appview.env"; 34 + description = '' 35 + Additional environment file as defined in {manpage}`systemd.exec(5)`. 36 + 37 + Sensitive secrets such as {env}`TANGLED_COOKIE_SECRET` may be 38 + passed to the service without makeing them world readable in the 39 + nix store. 40 + 41 + ''; 42 + }; 24 43 }; 25 44 }; 26 - }; 27 45 28 - config = mkIf config.services.tangled-appview.enable { 29 - systemd.services.tangled-appview = { 30 - description = "tangled appview service"; 31 - wantedBy = ["multi-user.target"]; 46 + config = mkIf cfg.enable { 47 + systemd.services.tangled-appview = { 48 + description = "tangled appview service"; 49 + wantedBy = ["multi-user.target"]; 32 50 33 - serviceConfig = { 34 - ListenStream = "0.0.0.0:${toString config.services.tangled-appview.port}"; 35 - ExecStart = "${self.packages.${pkgs.system}.appview}/bin/appview"; 36 - Restart = "always"; 37 - }; 51 + serviceConfig = { 52 + ListenStream = "0.0.0.0:${toString cfg.port}"; 53 + ExecStart = "${cfg.package}/bin/appview"; 54 + Restart = "always"; 55 + EnvironmentFile = optional (cfg.environmentFile != null) cfg.environmentFile; 56 + }; 38 57 39 - environment = { 40 - TANGLED_DB_PATH = "appview.db"; 41 - TANGLED_COOKIE_SECRET = config.services.tangled-appview.cookie_secret; 58 + environment = { 59 + TANGLED_DB_PATH = "appview.db"; 60 + TANGLED_COOKIE_SECRET = cfg.cookie_secret; 61 + }; 42 62 }; 43 63 }; 44 - }; 45 - } 64 + }
+65 -26
nix/modules/knot.nix
··· 1 - {self}: { 1 + { 2 2 config, 3 3 pkgs, 4 4 lib, ··· 13 13 type = types.bool; 14 14 default = false; 15 15 description = "Enable a tangled knot"; 16 + }; 17 + 18 + package = mkOption { 19 + type = types.package; 20 + description = "Package to use for the knot"; 16 21 }; 17 22 18 23 appviewEndpoint = mkOption { ··· 53 58 }; 54 59 }; 55 60 61 + motd = mkOption { 62 + type = types.nullOr types.str; 63 + default = null; 64 + description = '' 65 + Message of the day 66 + 67 + The contents are shown as-is; eg. you will want to add a newline if 68 + setting a non-empty message since the knot won't do this for you. 69 + ''; 70 + }; 71 + 72 + motdFile = mkOption { 73 + type = types.nullOr types.path; 74 + default = null; 75 + description = '' 76 + File containing message of the day 77 + 78 + The contents are shown as-is; eg. you will want to add a newline if 79 + setting a non-empty message since the knot won't do this for you. 80 + ''; 81 + }; 82 + 56 83 server = { 57 84 listenAddr = mkOption { 58 85 type = types.str; ··· 66 93 description = "Internal address for inter-service communication"; 67 94 }; 68 95 69 - secretFile = mkOption { 70 - type = lib.types.path; 71 - example = "KNOT_SERVER_SECRET=<hash>"; 72 - description = "File containing secret key provided by appview (required)"; 96 + owner = mkOption { 97 + type = types.str; 98 + example = "did:plc:qfpnj4og54vl56wngdriaxug"; 99 + description = "DID of owner (required)"; 73 100 }; 74 101 75 102 dbPath = mkOption { ··· 94 121 }; 95 122 96 123 config = mkIf cfg.enable { 97 - environment.systemPackages = with pkgs; [ 98 - git 99 - self.packages."${pkgs.system}".knot 124 + environment.systemPackages = [ 125 + pkgs.git 126 + cfg.package 100 127 ]; 101 128 102 - system.activationScripts.gitConfig = '' 103 - mkdir -p "${cfg.repo.scanPath}" 104 - chown -R ${cfg.gitUser}:${cfg.gitUser} \ 105 - "${cfg.repo.scanPath}" 106 - 107 - mkdir -p "${cfg.stateDir}/.config/git" 108 - cat > "${cfg.stateDir}/.config/git/config" << EOF 109 - [user] 110 - name = Git User 111 - email = git@example.com 112 - EOF 113 - chown -R ${cfg.gitUser}:${cfg.gitUser} \ 114 - "${cfg.stateDir}" 115 - ''; 116 - 117 129 users.users.${cfg.gitUser} = { 118 130 isSystemUser = true; 119 131 useDefaultShell = true; ··· 137 149 mode = "0555"; 138 150 text = '' 139 151 #!${pkgs.stdenv.shell} 140 - ${self.packages.${pkgs.system}.knot}/bin/knot keys \ 152 + ${cfg.package}/bin/knot keys \ 141 153 -output authorized-keys \ 142 154 -internal-api "http://${cfg.server.internalListenAddr}" \ 143 155 -git-dir "${cfg.repo.scanPath}" \ ··· 149 161 description = "knot service"; 150 162 after = ["network.target" "sshd.service"]; 151 163 wantedBy = ["multi-user.target"]; 164 + enableStrictShellChecks = true; 165 + 166 + preStart = let 167 + setMotd = 168 + if cfg.motdFile != null && cfg.motd != null 169 + then throw "motdFile and motd cannot be both set" 170 + else '' 171 + ${optionalString (cfg.motdFile != null) "cat ${cfg.motdFile} > ${cfg.stateDir}/motd"} 172 + ${optionalString (cfg.motd != null) ''printf "${cfg.motd}" > ${cfg.stateDir}/motd''} 173 + ''; 174 + in '' 175 + mkdir -p "${cfg.repo.scanPath}" 176 + chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.repo.scanPath}" 177 + 178 + mkdir -p "${cfg.stateDir}/.config/git" 179 + cat > "${cfg.stateDir}/.config/git/config" << EOF 180 + [user] 181 + name = Git User 182 + email = git@example.com 183 + [receive] 184 + advertisePushOptions = true 185 + EOF 186 + ${setMotd} 187 + chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.stateDir}" 188 + ''; 189 + 152 190 serviceConfig = { 153 191 User = cfg.gitUser; 192 + PermissionsStartOnly = true; 154 193 WorkingDirectory = cfg.stateDir; 155 194 Environment = [ 156 195 "KNOT_REPO_SCAN_PATH=${cfg.repo.scanPath}" ··· 160 199 "KNOT_SERVER_LISTEN_ADDR=${cfg.server.listenAddr}" 161 200 "KNOT_SERVER_DB_PATH=${cfg.server.dbPath}" 162 201 "KNOT_SERVER_HOSTNAME=${cfg.server.hostname}" 202 + "KNOT_SERVER_OWNER=${cfg.server.owner}" 163 203 ]; 164 - EnvironmentFile = cfg.server.secretFile; 165 - ExecStart = "${self.packages.${pkgs.system}.knot}/bin/knot server"; 204 + ExecStart = "${cfg.package}/bin/knot server"; 166 205 Restart = "always"; 167 206 }; 168 207 };
+138
nix/modules/spindle.nix
··· 1 + { 2 + config, 3 + lib, 4 + ... 5 + }: let 6 + cfg = config.services.tangled-spindle; 7 + in 8 + with lib; { 9 + options = { 10 + services.tangled-spindle = { 11 + enable = mkOption { 12 + type = types.bool; 13 + default = false; 14 + description = "Enable a tangled spindle"; 15 + }; 16 + package = mkOption { 17 + type = types.package; 18 + description = "Package to use for the spindle"; 19 + }; 20 + 21 + server = { 22 + listenAddr = mkOption { 23 + type = types.str; 24 + default = "0.0.0.0:6555"; 25 + description = "Address to listen on"; 26 + }; 27 + 28 + dbPath = mkOption { 29 + type = types.path; 30 + default = "/var/lib/spindle/spindle.db"; 31 + description = "Path to the database file"; 32 + }; 33 + 34 + hostname = mkOption { 35 + type = types.str; 36 + example = "spindle.tangled.sh"; 37 + description = "Hostname for the server (required)"; 38 + }; 39 + 40 + jetstreamEndpoint = mkOption { 41 + type = types.str; 42 + default = "wss://jetstream1.us-west.bsky.network/subscribe"; 43 + description = "Jetstream endpoint to subscribe to"; 44 + }; 45 + 46 + dev = mkOption { 47 + type = types.bool; 48 + default = false; 49 + description = "Enable development mode (disables signature verification)"; 50 + }; 51 + 52 + owner = mkOption { 53 + type = types.str; 54 + example = "did:plc:qfpnj4og54vl56wngdriaxug"; 55 + description = "DID of owner (required)"; 56 + }; 57 + 58 + maxJobCount = mkOption { 59 + type = types.int; 60 + default = 2; 61 + example = 5; 62 + description = "Maximum number of concurrent jobs to run"; 63 + }; 64 + 65 + queueSize = mkOption { 66 + type = types.int; 67 + default = 100; 68 + example = 100; 69 + description = "Maximum number of jobs queue up"; 70 + }; 71 + 72 + secrets = { 73 + provider = mkOption { 74 + type = types.str; 75 + default = "sqlite"; 76 + description = "Backend to use for secret management, valid options are 'sqlite', and 'openbao'."; 77 + }; 78 + 79 + openbao = { 80 + proxyAddr = mkOption { 81 + type = types.str; 82 + default = "http://127.0.0.1:8200"; 83 + }; 84 + mount = mkOption { 85 + type = types.str; 86 + default = "spindle"; 87 + }; 88 + }; 89 + }; 90 + }; 91 + 92 + pipelines = { 93 + nixery = mkOption { 94 + type = types.str; 95 + default = "nixery.tangled.sh"; 96 + description = "Nixery instance to use"; 97 + }; 98 + 99 + workflowTimeout = mkOption { 100 + type = types.str; 101 + default = "5m"; 102 + description = "Timeout for each step of a pipeline"; 103 + }; 104 + }; 105 + }; 106 + }; 107 + 108 + config = mkIf cfg.enable { 109 + virtualisation.docker.enable = true; 110 + 111 + systemd.services.spindle = { 112 + description = "spindle service"; 113 + after = ["network.target" "docker.service"]; 114 + wantedBy = ["multi-user.target"]; 115 + serviceConfig = { 116 + LogsDirectory = "spindle"; 117 + StateDirectory = "spindle"; 118 + Environment = [ 119 + "SPINDLE_SERVER_LISTEN_ADDR=${cfg.server.listenAddr}" 120 + "SPINDLE_SERVER_DB_PATH=${cfg.server.dbPath}" 121 + "SPINDLE_SERVER_HOSTNAME=${cfg.server.hostname}" 122 + "SPINDLE_SERVER_JETSTREAM=${cfg.server.jetstreamEndpoint}" 123 + "SPINDLE_SERVER_DEV=${lib.boolToString cfg.server.dev}" 124 + "SPINDLE_SERVER_OWNER=${cfg.server.owner}" 125 + "SPINDLE_SERVER_MAX_JOB_COUNT=${toString cfg.server.maxJobCount}" 126 + "SPINDLE_SERVER_QUEUE_SIZE=${toString cfg.server.queueSize}" 127 + "SPINDLE_SERVER_SECRETS_PROVIDER=${cfg.server.secrets.provider}" 128 + "SPINDLE_SERVER_SECRETS_OPENBAO_PROXY_ADDR=${cfg.server.secrets.openbao.proxyAddr}" 129 + "SPINDLE_SERVER_SECRETS_OPENBAO_MOUNT=${cfg.server.secrets.openbao.mount}" 130 + "SPINDLE_NIXERY_PIPELINES_NIXERY=${cfg.pipelines.nixery}" 131 + "SPINDLE_NIXERY_PIPELINES_WORKFLOW_TIMEOUT=${cfg.pipelines.workflowTimeout}" 132 + ]; 133 + ExecStart = "${cfg.package}/bin/spindle"; 134 + Restart = "always"; 135 + }; 136 + }; 137 + }; 138 + }
+29
nix/pkgs/appview-static-files.nix
··· 1 + { 2 + runCommandLocal, 3 + htmx-src, 4 + htmx-ws-src, 5 + lucide-src, 6 + inter-fonts-src, 7 + ibm-plex-mono-src, 8 + sqlite-lib, 9 + tailwindcss, 10 + src, 11 + }: 12 + runCommandLocal "appview-static-files" { 13 + # TOOD(winter): figure out why this is even required after 14 + # changing the libraries that the tailwindcss binary loads 15 + sandboxProfile = '' 16 + (allow file-read* (subpath "/System/Library/OpenSSL")) 17 + ''; 18 + } '' 19 + mkdir -p $out/{fonts,icons} && cd $out 20 + cp -f ${htmx-src} htmx.min.js 21 + cp -f ${htmx-ws-src} htmx-ext-ws.min.js 22 + cp -rf ${lucide-src}/*.svg icons/ 23 + cp -f ${inter-fonts-src}/web/InterVariable*.woff2 fonts/ 24 + cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 fonts/ 25 + cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono*.woff2 fonts/ 26 + # tailwindcss -c $src/tailwind.config.js -i $src/input.css -o tw.css won't work 27 + # for whatever reason (produces broken css), so we are doing this instead 28 + cd ${src} && ${tailwindcss}/bin/tailwindcss -i input.css -o $out/tw.css 29 + ''
+10 -23
nix/pkgs/appview.nix
··· 1 1 { 2 - buildGoModule, 3 - stdenv, 4 - htmx-src, 5 - lucide-src, 6 - inter-fonts-src, 7 - ibm-plex-mono-src, 8 - tailwindcss, 2 + buildGoApplication, 3 + modules, 4 + appview-static-files, 9 5 sqlite-lib, 10 - goModHash, 11 - gitignoreSource, 6 + src, 12 7 }: 13 - buildGoModule { 14 - inherit stdenv; 15 - 8 + buildGoApplication { 16 9 pname = "appview"; 17 10 version = "0.1.0"; 18 - src = gitignoreSource ../..; 11 + inherit src modules; 19 12 20 13 postUnpack = '' 21 14 pushd source 22 - mkdir -p appview/pages/static/{fonts,icons} 23 - cp -f ${htmx-src} appview/pages/static/htmx.min.js 24 - cp -rf ${lucide-src}/*.svg appview/pages/static/icons/ 25 - cp -f ${inter-fonts-src}/web/InterVariable*.woff2 appview/pages/static/fonts/ 26 - cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 appview/pages/static/fonts/ 27 - cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono-Regular.woff2 appview/pages/static/fonts/ 28 - ${tailwindcss}/bin/tailwindcss -i input.css -o appview/pages/static/tw.css 15 + mkdir -p appview/pages/static 16 + cp -frv ${appview-static-files}/* appview/pages/static 29 17 popd 30 18 ''; 31 19 32 20 doCheck = false; 33 21 subPackages = ["cmd/appview"]; 34 - vendorHash = goModHash; 35 22 36 - tags = "libsqlite3"; 23 + tags = ["libsqlite3"]; 37 24 env.CGO_CFLAGS = "-I ${sqlite-lib}/include "; 38 25 env.CGO_LDFLAGS = "-L ${sqlite-lib}/lib"; 39 - env.CGO_ENABLED = 1; 26 + CGO_ENABLED = 1; 40 27 }
+12 -8
nix/pkgs/genjwks.nix
··· 1 1 { 2 - buildGoModule, 3 - goModHash, 4 - gitignoreSource, 2 + buildGoApplication, 3 + modules, 5 4 }: 6 - buildGoModule { 5 + buildGoApplication { 7 6 pname = "genjwks"; 8 7 version = "0.1.0"; 9 - src = gitignoreSource ../..; 10 - subPackages = ["cmd/genjwks"]; 11 - vendorHash = goModHash; 8 + src = ../../cmd/genjwks; 9 + postPatch = '' 10 + ln -s ${../../go.mod} ./go.mod 11 + ''; 12 + postInstall = '' 13 + mv $out/bin/core $out/bin/genjwks 14 + ''; 15 + inherit modules; 12 16 doCheck = false; 13 - env.CGO_ENABLED = 0; 17 + CGO_ENABLED = 0; 14 18 }
+20 -17
nix/pkgs/knot-unwrapped.nix
··· 1 1 { 2 - buildGoModule, 3 - stdenv, 2 + buildGoApplication, 3 + modules, 4 4 sqlite-lib, 5 - goModHash, 6 - gitignoreSource, 7 - }: 8 - buildGoModule { 9 - pname = "knot"; 10 - version = "0.1.0"; 11 - src = gitignoreSource ../..; 5 + src, 6 + }: let 7 + version = "1.9.0-alpha"; 8 + in 9 + buildGoApplication { 10 + pname = "knot"; 11 + inherit src version modules; 12 + 13 + doCheck = false; 12 14 13 - doCheck = false; 15 + subPackages = ["cmd/knot"]; 16 + tags = ["libsqlite3"]; 14 17 15 - subPackages = ["cmd/knot"]; 16 - vendorHash = goModHash; 17 - tags = "libsqlite3"; 18 + ldflags = [ 19 + "-X tangled.sh/tangled.sh/core/knotserver/xrpc.version=${version}" 20 + ]; 18 21 19 - env.CGO_CFLAGS = "-I ${sqlite-lib}/include "; 20 - env.CGO_LDFLAGS = "-L ${sqlite-lib}/lib"; 21 - env.CGO_ENABLED = 1; 22 - } 22 + env.CGO_CFLAGS = "-I ${sqlite-lib}/include "; 23 + env.CGO_LDFLAGS = "-L ${sqlite-lib}/lib"; 24 + CGO_ENABLED = 1; 25 + }
+1 -1
nix/pkgs/lexgen.nix
··· 7 7 version = "0.1.0"; 8 8 src = indigo; 9 9 subPackages = ["cmd/lexgen"]; 10 - vendorHash = "sha256-pGc29fgJFq8LP7n/pY1cv6ExZl88PAeFqIbFEhB3xXs="; 10 + vendorHash = "sha256-VbDrcN4r5b7utRFQzVsKgDsVgdQLSXl7oZ5kdPA/huw="; 11 11 doCheck = false; 12 12 }
+20
nix/pkgs/spindle.nix
··· 1 + { 2 + buildGoApplication, 3 + modules, 4 + sqlite-lib, 5 + src, 6 + }: 7 + buildGoApplication { 8 + pname = "spindle"; 9 + version = "0.1.0"; 10 + inherit src modules; 11 + 12 + doCheck = false; 13 + 14 + subPackages = ["cmd/spindle"]; 15 + tags = ["libsqlite3"]; 16 + 17 + env.CGO_CFLAGS = "-I ${sqlite-lib}/include "; 18 + env.CGO_LDFLAGS = "-L ${sqlite-lib}/lib"; 19 + CGO_ENABLED = 1; 20 + }
+124 -32
nix/vm.nix
··· 1 1 { 2 2 nixpkgs, 3 + system, 4 + hostSystem, 3 5 self, 4 - }: 5 - nixpkgs.lib.nixosSystem { 6 - system = "x86_64-linux"; 7 - modules = [ 8 - self.nixosModules.knot 9 - ({ 10 - config, 11 - pkgs, 12 - ... 13 - }: { 14 - virtualisation.memorySize = 2048; 15 - virtualisation.diskSize = 10 * 1024; 16 - virtualisation.cores = 2; 17 - services.getty.autologinUser = "root"; 18 - environment.systemPackages = with pkgs; [curl vim git]; 19 - systemd.tmpfiles.rules = let 20 - u = config.services.tangled-knot.gitUser; 21 - g = config.services.tangled-knot.gitUser; 22 - in [ 23 - "d /var/lib/knot 0770 ${u} ${g} - -" # Create the directory first 24 - "f+ /var/lib/knot/secret 0660 ${u} ${g} - KNOT_SERVER_SECRET=7387221d57e64499b179a9dff19c5f1abf436470e2976d3585badddad5282970" 25 - ]; 26 - services.tangled-knot = { 27 - enable = true; 28 - server = { 29 - secretFile = "/var/lib/knot/secret"; 30 - hostname = "localhost:6000"; 31 - listenAddr = "0.0.0.0:6000"; 6 + }: let 7 + envVar = name: let 8 + var = builtins.getEnv name; 9 + in 10 + if var == "" 11 + then throw "\$${name} must be defined, see docs/hacking.md for more details" 12 + else var; 13 + in 14 + nixpkgs.lib.nixosSystem { 15 + inherit system; 16 + modules = [ 17 + self.nixosModules.knot 18 + self.nixosModules.spindle 19 + ({ 20 + lib, 21 + config, 22 + pkgs, 23 + ... 24 + }: { 25 + virtualisation.vmVariant.virtualisation = { 26 + host.pkgs = import nixpkgs {system = hostSystem;}; 27 + 28 + graphics = false; 29 + memorySize = 2048; 30 + diskSize = 10 * 1024; 31 + cores = 2; 32 + forwardPorts = [ 33 + # ssh 34 + { 35 + from = "host"; 36 + host.port = 2222; 37 + guest.port = 22; 38 + } 39 + # knot 40 + { 41 + from = "host"; 42 + host.port = 6000; 43 + guest.port = 6000; 44 + } 45 + # spindle 46 + { 47 + from = "host"; 48 + host.port = 6555; 49 + guest.port = 6555; 50 + } 51 + ]; 52 + sharedDirectories = { 53 + # We can't use the 9p mounts directly for most of these 54 + # as SQLite is incompatible with them. So instead we 55 + # mount the shared directories to a different location 56 + # and copy the contents around on service start/stop. 57 + knotData = { 58 + source = "$TANGLED_VM_DATA_DIR/knot"; 59 + target = "/mnt/knot-data"; 60 + }; 61 + spindleData = { 62 + source = "$TANGLED_VM_DATA_DIR/spindle"; 63 + target = "/mnt/spindle-data"; 64 + }; 65 + spindleLogs = { 66 + source = "$TANGLED_VM_DATA_DIR/spindle-logs"; 67 + target = "/var/log/spindle"; 68 + }; 69 + }; 32 70 }; 33 - }; 34 - }) 35 - ]; 36 - } 71 + # 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 72 + networking.firewall.enable = false; 73 + time.timeZone = "Europe/London"; 74 + services.getty.autologinUser = "root"; 75 + environment.systemPackages = with pkgs; [curl vim git sqlite litecli]; 76 + services.tangled-knot = { 77 + enable = true; 78 + motd = "Welcome to the development knot!\n"; 79 + server = { 80 + owner = envVar "TANGLED_VM_KNOT_OWNER"; 81 + hostname = "localhost:6000"; 82 + listenAddr = "0.0.0.0:6000"; 83 + }; 84 + }; 85 + services.tangled-spindle = { 86 + enable = true; 87 + server = { 88 + owner = envVar "TANGLED_VM_SPINDLE_OWNER"; 89 + hostname = "localhost:6555"; 90 + listenAddr = "0.0.0.0:6555"; 91 + dev = true; 92 + queueSize = 100; 93 + maxJobCount = 2; 94 + secrets = { 95 + provider = "sqlite"; 96 + }; 97 + }; 98 + }; 99 + users = { 100 + # So we don't have to deal with permission clashing between 101 + # blank disk VMs and existing state 102 + users.${config.services.tangled-knot.gitUser}.uid = 666; 103 + groups.${config.services.tangled-knot.gitUser}.gid = 666; 104 + 105 + # TODO: separate spindle user 106 + }; 107 + systemd.services = let 108 + mkDataSyncScripts = source: target: { 109 + enableStrictShellChecks = true; 110 + 111 + preStart = lib.mkBefore '' 112 + mkdir -p ${target} 113 + ${lib.getExe pkgs.rsync} -a ${source}/ ${target} 114 + ''; 115 + 116 + postStop = lib.mkAfter '' 117 + ${lib.getExe pkgs.rsync} -a ${target}/ ${source} 118 + ''; 119 + 120 + serviceConfig.PermissionsStartOnly = true; 121 + }; 122 + in { 123 + knot = mkDataSyncScripts "/mnt/knot-data" config.services.tangled-knot.stateDir; 124 + spindle = mkDataSyncScripts "/mnt/spindle-data" (builtins.dirOf config.services.tangled-spindle.server.dbPath); 125 + }; 126 + }) 127 + ]; 128 + }
+1 -1
patchutil/combinediff.go
··· 119 119 // we have f1 and f2, combine them 120 120 combined, err := combineFiles(f1, f2) 121 121 if err != nil { 122 - fmt.Println(err) 122 + // fmt.Println(err) 123 123 } 124 124 125 125 // combined can be nil commit 2 reverted all changes from commit 1
+25
patchutil/interdiff.go
··· 5 5 "strings" 6 6 7 7 "github.com/bluekeyes/go-gitdiff/gitdiff" 8 + "tangled.sh/tangled.sh/core/types" 8 9 ) 9 10 10 11 type InterdiffResult struct { ··· 33 34 *gitdiff.File 34 35 Name string 35 36 Status InterdiffFileStatus 37 + } 38 + 39 + func (s *InterdiffFile) Split() *types.SplitDiff { 40 + fragments := make([]types.SplitFragment, len(s.TextFragments)) 41 + 42 + for i, fragment := range s.TextFragments { 43 + leftLines, rightLines := types.SeparateLines(fragment) 44 + 45 + fragments[i] = types.SplitFragment{ 46 + Header: fragment.Header(), 47 + LeftLines: leftLines, 48 + RightLines: rightLines, 49 + } 50 + } 51 + 52 + return &types.SplitDiff{ 53 + Name: s.Id(), 54 + TextFragments: fragments, 55 + } 56 + } 57 + 58 + // used by html elements as a unique ID for hrefs 59 + func (s *InterdiffFile) Id() string { 60 + return s.Name 36 61 } 37 62 38 63 func (s *InterdiffFile) String() string {
+48 -1
rbac/rbac.go
··· 11 11 ) 12 12 13 13 const ( 14 + ThisServer = "thisserver" // resource identifier for local rbac enforcement 15 + ) 16 + 17 + const ( 14 18 Model = ` 15 19 [request_definition] 16 20 r = sub, dom, obj, act ··· 39 43 return nil, err 40 44 } 41 45 42 - db, err := sql.Open("sqlite3", path) 46 + db, err := sql.Open("sqlite3", path+"?_foreign_keys=1") 43 47 if err != nil { 44 48 return nil, err 45 49 } ··· 90 94 return err 91 95 } 92 96 97 + func (e *Enforcer) RemoveSpindle(spindle string) error { 98 + spindle = intoSpindle(spindle) 99 + _, err := e.E.DeleteDomains(spindle) 100 + return err 101 + } 102 + 103 + func (e *Enforcer) RemoveKnot(knot string) error { 104 + _, err := e.E.DeleteDomains(knot) 105 + return err 106 + } 107 + 93 108 func (e *Enforcer) GetKnotsForUser(did string) ([]string, error) { 94 109 keepFunc := isNotSpindle 95 110 stripFunc := unSpindle ··· 106 121 return e.addOwner(domain, owner) 107 122 } 108 123 124 + func (e *Enforcer) RemoveKnotOwner(domain, owner string) error { 125 + return e.removeOwner(domain, owner) 126 + } 127 + 109 128 func (e *Enforcer) AddKnotMember(domain, member string) error { 110 129 return e.addMember(domain, member) 130 + } 131 + 132 + func (e *Enforcer) RemoveKnotMember(domain, member string) error { 133 + return e.removeMember(domain, member) 111 134 } 112 135 113 136 func (e *Enforcer) AddSpindleOwner(domain, owner string) error { 114 137 return e.addOwner(intoSpindle(domain), owner) 115 138 } 116 139 140 + func (e *Enforcer) RemoveSpindleOwner(domain, owner string) error { 141 + return e.removeOwner(intoSpindle(domain), owner) 142 + } 143 + 117 144 func (e *Enforcer) AddSpindleMember(domain, member string) error { 118 145 return e.addMember(intoSpindle(domain), member) 146 + } 147 + 148 + func (e *Enforcer) RemoveSpindleMember(domain, member string) error { 149 + return e.removeMember(intoSpindle(domain), member) 119 150 } 120 151 121 152 func repoPolicies(member, domain, repo string) [][]string { ··· 196 227 return slices.Compact(membersWithoutRoles), nil 197 228 } 198 229 230 + func (e *Enforcer) GetKnotUsersByRole(role, domain string) ([]string, error) { 231 + return e.GetUserByRole(role, domain) 232 + } 233 + 234 + func (e *Enforcer) GetSpindleUsersByRole(role, domain string) ([]string, error) { 235 + return e.GetUserByRole(role, intoSpindle(domain)) 236 + } 237 + 199 238 func (e *Enforcer) GetUserByRoleInRepo(role, domain, repo string) ([]string, error) { 200 239 var users []string 201 240 ··· 236 275 237 276 func (e *Enforcer) IsSpindleInviteAllowed(user, domain string) (bool, error) { 238 277 return e.isInviteAllowed(user, intoSpindle(domain)) 278 + } 279 + 280 + func (e *Enforcer) IsRepoCreateAllowed(user, domain string) (bool, error) { 281 + return e.E.Enforce(user, domain, domain, "repo:create") 282 + } 283 + 284 + func (e *Enforcer) IsRepoDeleteAllowed(user, domain, repo string) (bool, error) { 285 + return e.E.Enforce(user, domain, repo, "repo:delete") 239 286 } 240 287 241 288 func (e *Enforcer) IsPushAllowed(user, domain, repo string) (bool, error) {
+149 -3
rbac/rbac_test.go
··· 14 14 ) 15 15 16 16 func setup(t *testing.T) *rbac.Enforcer { 17 - db, err := sql.Open("sqlite3", ":memory:") 17 + db, err := sql.Open("sqlite3", ":memory:?_foreign_keys=1") 18 18 assert.NoError(t, err) 19 19 20 20 a, err := adapter.NewAdapter(db, "sqlite3", "acl") ··· 214 214 assert.Contains(t, knots2, "example.com") 215 215 } 216 216 217 - func TestGetUserByRole(t *testing.T) { 217 + func TestGetKnotUsersByRole(t *testing.T) { 218 218 e := setup(t) 219 219 _ = e.AddKnot("example.com") 220 220 _ = e.AddKnotMember("example.com", "did:plc:foo") 221 221 _ = e.AddKnotOwner("example.com", "did:plc:bar") 222 222 223 - members, _ := e.GetUserByRole("server:member", "example.com") 223 + members, _ := e.GetKnotUsersByRole("server:member", "example.com") 224 + assert.Contains(t, members, "did:plc:foo") 225 + assert.Contains(t, members, "did:plc:bar") // due to inheritance 226 + } 227 + 228 + func TestGetSpindleUsersByRole(t *testing.T) { 229 + e := setup(t) 230 + _ = e.AddSpindle("example.com") 231 + _ = e.AddSpindleMember("example.com", "did:plc:foo") 232 + _ = e.AddSpindleOwner("example.com", "did:plc:bar") 233 + 234 + members, _ := e.GetSpindleUsersByRole("server:member", "example.com") 224 235 assert.Contains(t, members, "did:plc:foo") 225 236 assert.Contains(t, members, "did:plc:bar") // due to inheritance 226 237 } ··· 301 312 assert.NoError(t, err) 302 313 assert.True(t, ok) 303 314 } 315 + 316 + func TestRemoveKnotOwner(t *testing.T) { 317 + e := setup(t) 318 + 319 + err := e.AddKnot("k.com") 320 + assert.NoError(t, err) 321 + 322 + err = e.AddKnotOwner("k.com", "did:plc:foo") 323 + assert.NoError(t, err) 324 + 325 + knots, err := e.GetKnotsForUser("did:plc:foo") 326 + assert.NoError(t, err) 327 + assert.ElementsMatch(t, []string{ 328 + "k.com", 329 + }, knots) 330 + 331 + err = e.RemoveKnotOwner("k.com", "did:plc:foo") 332 + assert.NoError(t, err) 333 + 334 + knots, err = e.GetKnotsForUser("did:plc:foo") 335 + assert.NoError(t, err) 336 + assert.Empty(t, knots) 337 + } 338 + 339 + func TestRemoveKnotMember(t *testing.T) { 340 + e := setup(t) 341 + 342 + err := e.AddKnot("k.com") 343 + assert.NoError(t, err) 344 + 345 + err = e.AddKnotOwner("k.com", "did:plc:foo") 346 + assert.NoError(t, err) 347 + 348 + err = e.AddKnotMember("k.com", "did:plc:bar") 349 + assert.NoError(t, err) 350 + 351 + knots, err := e.GetKnotsForUser("did:plc:bar") 352 + assert.NoError(t, err) 353 + assert.ElementsMatch(t, []string{ 354 + "k.com", 355 + }, knots) 356 + 357 + err = e.RemoveKnotMember("k.com", "did:plc:bar") 358 + assert.NoError(t, err) 359 + 360 + knots, err = e.GetKnotsForUser("did:plc:bar") 361 + assert.NoError(t, err) 362 + assert.Empty(t, knots) 363 + } 364 + 365 + func TestRemoveSpindleOwner(t *testing.T) { 366 + e := setup(t) 367 + 368 + err := e.AddSpindle("s.com") 369 + assert.NoError(t, err) 370 + 371 + err = e.AddSpindleOwner("s.com", "did:plc:foo") 372 + assert.NoError(t, err) 373 + 374 + spindles, err := e.GetSpindlesForUser("did:plc:foo") 375 + assert.NoError(t, err) 376 + assert.ElementsMatch(t, []string{ 377 + "s.com", 378 + }, spindles) 379 + 380 + err = e.RemoveSpindleOwner("s.com", "did:plc:foo") 381 + assert.NoError(t, err) 382 + 383 + spindles, err = e.GetSpindlesForUser("did:plc:foo") 384 + assert.NoError(t, err) 385 + assert.Empty(t, spindles) 386 + } 387 + 388 + func TestRemoveSpindleMember(t *testing.T) { 389 + e := setup(t) 390 + 391 + err := e.AddSpindle("s.com") 392 + assert.NoError(t, err) 393 + 394 + err = e.AddSpindleOwner("s.com", "did:plc:foo") 395 + assert.NoError(t, err) 396 + 397 + err = e.AddSpindleMember("s.com", "did:plc:bar") 398 + assert.NoError(t, err) 399 + 400 + spindles, err := e.GetSpindlesForUser("did:plc:foo") 401 + assert.NoError(t, err) 402 + assert.ElementsMatch(t, []string{ 403 + "s.com", 404 + }, spindles) 405 + 406 + spindles, err = e.GetSpindlesForUser("did:plc:bar") 407 + assert.NoError(t, err) 408 + assert.ElementsMatch(t, []string{ 409 + "s.com", 410 + }, spindles) 411 + 412 + err = e.RemoveSpindleMember("s.com", "did:plc:bar") 413 + assert.NoError(t, err) 414 + 415 + spindles, err = e.GetSpindlesForUser("did:plc:bar") 416 + assert.NoError(t, err) 417 + assert.Empty(t, spindles) 418 + } 419 + 420 + func TestRemoveSpindle(t *testing.T) { 421 + e := setup(t) 422 + 423 + err := e.AddSpindle("s.com") 424 + assert.NoError(t, err) 425 + 426 + err = e.AddSpindleOwner("s.com", "did:plc:foo") 427 + assert.NoError(t, err) 428 + 429 + err = e.AddSpindleMember("s.com", "did:plc:bar") 430 + assert.NoError(t, err) 431 + 432 + users, err := e.GetSpindleUsersByRole("server:member", "s.com") 433 + assert.NoError(t, err) 434 + assert.ElementsMatch(t, []string{ 435 + "did:plc:foo", 436 + "did:plc:bar", 437 + }, users) 438 + 439 + err = e.RemoveSpindle("s.com") 440 + assert.NoError(t, err) 441 + 442 + // TODO: see this issue https://github.com/casbin/casbin/issues/1492 443 + // s, err := e.E.GetAllDomains() 444 + // assert.Empty(t, s) 445 + 446 + spindles, err := e.GetSpindleUsersByRole("server:member", "s.com") 447 + assert.NoError(t, err) 448 + assert.Empty(t, spindles) 449 + }
+10
rbac/util.go
··· 29 29 return err 30 30 } 31 31 32 + func (e *Enforcer) removeOwner(domain, owner string) error { 33 + _, err := e.E.RemoveGroupingPolicy(owner, "server:owner", domain) 34 + return err 35 + } 36 + 32 37 func (e *Enforcer) addMember(domain, member string) error { 33 38 _, err := e.E.AddGroupingPolicy(member, "server:member", domain) 39 + return err 40 + } 41 + 42 + func (e *Enforcer) removeMember(domain, member string) error { 43 + _, err := e.E.RemoveGroupingPolicy(member, "server:member", domain) 34 44 return err 35 45 } 36 46
+31 -12
spindle/config/config.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "fmt" 5 6 7 + "github.com/bluesky-social/indigo/atproto/syntax" 6 8 "github.com/sethvargo/go-envconfig" 7 9 ) 8 10 9 11 type Server struct { 10 - ListenAddr string `env:"LISTEN_ADDR, default=0.0.0.0:6555"` 11 - DBPath string `env:"DB_PATH, default=spindle.db"` 12 - Hostname string `env:"HOSTNAME, required"` 13 - JetstreamEndpoint string `env:"JETSTREAM_ENDPOINT, default=wss://jetstream1.us-west.bsky.network/subscribe"` 14 - Dev bool `env:"DEV, default=false"` 15 - Owner string `env:"OWNER, required"` 12 + ListenAddr string `env:"LISTEN_ADDR, default=0.0.0.0:6555"` 13 + DBPath string `env:"DB_PATH, default=spindle.db"` 14 + Hostname string `env:"HOSTNAME, required"` 15 + JetstreamEndpoint string `env:"JETSTREAM_ENDPOINT, default=wss://jetstream1.us-west.bsky.network/subscribe"` 16 + Dev bool `env:"DEV, default=false"` 17 + Owner string `env:"OWNER, required"` 18 + Secrets Secrets `env:",prefix=SECRETS_"` 19 + LogDir string `env:"LOG_DIR, default=/var/log/spindle"` 20 + QueueSize int `env:"QUEUE_SIZE, default=100"` 21 + MaxJobCount int `env:"MAX_JOB_COUNT, default=2"` // max number of jobs that run at a time 22 + } 23 + 24 + func (s Server) Did() syntax.DID { 25 + return syntax.DID(fmt.Sprintf("did:web:%s", s.Hostname)) 26 + } 27 + 28 + type Secrets struct { 29 + Provider string `env:"PROVIDER, default=sqlite"` 30 + OpenBao OpenBaoConfig `env:",prefix=OPENBAO_"` 31 + } 32 + 33 + type OpenBaoConfig struct { 34 + ProxyAddr string `env:"PROXY_ADDR, default=http://127.0.0.1:8200"` 35 + Mount string `env:"MOUNT, default=spindle"` 16 36 } 17 37 18 - type Pipelines struct { 19 - // TODO: change default to nixery.tangled.sh 20 - Nixery string `env:"NIXERY, default=nixery.dev"` 21 - StepTimeout string `env:"STEP_TIMEOUT, default=5m"` 38 + type NixeryPipelines struct { 39 + Nixery string `env:"NIXERY, default=nixery.tangled.sh"` 40 + WorkflowTimeout string `env:"WORKFLOW_TIMEOUT, default=5m"` 22 41 } 23 42 24 43 type Config struct { 25 - Server Server `env:",prefix=SPINDLE_SERVER_"` 26 - Pipelines Pipelines `env:",prefix=SPINDLE_PIPELINES_"` 44 + Server Server `env:",prefix=SPINDLE_SERVER_"` 45 + NixeryPipelines NixeryPipelines `env:",prefix=SPINDLE_NIXERY_PIPELINES_"` 27 46 } 28 47 29 48 func Load(ctx context.Context) (*Config, error) {
+29 -10
spindle/db/db.go
··· 2 2 3 3 import ( 4 4 "database/sql" 5 + "strings" 5 6 6 7 _ "github.com/mattn/go-sqlite3" 7 8 ) ··· 11 12 } 12 13 13 14 func Make(dbPath string) (*DB, error) { 14 - db, err := sql.Open("sqlite3", dbPath) 15 + // https://github.com/mattn/go-sqlite3#connection-string 16 + opts := []string{ 17 + "_foreign_keys=1", 18 + "_journal_mode=WAL", 19 + "_synchronous=NORMAL", 20 + "_auto_vacuum=incremental", 21 + } 22 + 23 + db, err := sql.Open("sqlite3", dbPath+"?"+strings.Join(opts, "&")) 15 24 if err != nil { 16 25 return nil, err 17 26 } 27 + 28 + // NOTE: If any other migration is added here, you MUST 29 + // copy the pattern in appview: use a single sql.Conn 30 + // for every migration. 18 31 19 32 _, err = db.Exec(` 20 - pragma journal_mode = WAL; 21 - pragma synchronous = normal; 22 - pragma foreign_keys = on; 23 - pragma temp_store = memory; 24 - pragma mmap_size = 30000000000; 25 - pragma page_size = 32768; 26 - pragma auto_vacuum = incremental; 27 - pragma busy_timeout = 5000; 28 - 29 33 create table if not exists _jetstream ( 30 34 id integer primary key autoincrement, 31 35 last_time_us integer not null ··· 43 47 addedAt text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 44 48 45 49 unique(owner, name) 50 + ); 51 + 52 + create table if not exists spindle_members ( 53 + -- identifiers for the record 54 + id integer primary key autoincrement, 55 + did text not null, 56 + rkey text not null, 57 + 58 + -- data 59 + instance text not null, 60 + subject text not null, 61 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 62 + 63 + -- constraints 64 + unique (did, instance, subject) 46 65 ); 47 66 48 67 -- status event for a single workflow
+38
spindle/db/events.go
··· 120 120 121 121 } 122 122 123 + func (d *DB) GetStatus(workflowId models.WorkflowId) (*tangled.PipelineStatus, error) { 124 + pipelineAtUri := workflowId.PipelineId.AtUri() 125 + 126 + var eventJson string 127 + err := d.QueryRow( 128 + ` 129 + select 130 + event from events 131 + where 132 + nsid = ? 133 + and json_extract(event, '$.pipeline') = ? 134 + and json_extract(event, '$.workflow') = ? 135 + order by 136 + created desc 137 + limit 138 + 1 139 + `, 140 + tangled.PipelineStatusNSID, 141 + string(pipelineAtUri), 142 + workflowId.Name, 143 + ).Scan(&eventJson) 144 + 145 + if err != nil { 146 + return nil, err 147 + } 148 + 149 + var status tangled.PipelineStatus 150 + if err := json.Unmarshal([]byte(eventJson), &status); err != nil { 151 + return nil, err 152 + } 153 + 154 + return &status, nil 155 + } 156 + 123 157 func (d *DB) StatusPending(workflowId models.WorkflowId, n *notifier.Notifier) error { 124 158 return d.createStatusEvent(workflowId, models.StatusKindPending, nil, nil, n) 125 159 } ··· 135 169 func (d *DB) StatusSuccess(workflowId models.WorkflowId, n *notifier.Notifier) error { 136 170 return d.createStatusEvent(workflowId, models.StatusKindSuccess, nil, nil, n) 137 171 } 172 + 173 + func (d *DB) StatusTimeout(workflowId models.WorkflowId, n *notifier.Notifier) error { 174 + return d.createStatusEvent(workflowId, models.StatusKindTimeout, nil, nil, n) 175 + }
+59
spindle/db/member.go
··· 1 + package db 2 + 3 + import ( 4 + "time" 5 + 6 + "github.com/bluesky-social/indigo/atproto/syntax" 7 + ) 8 + 9 + type SpindleMember struct { 10 + Id int 11 + Did syntax.DID // owner of the record 12 + Rkey string // rkey of the record 13 + Instance string 14 + Subject syntax.DID // the member being added 15 + Created time.Time 16 + } 17 + 18 + func AddSpindleMember(db *DB, member SpindleMember) error { 19 + _, err := db.Exec( 20 + `insert or ignore into spindle_members (did, rkey, instance, subject) values (?, ?, ?, ?)`, 21 + member.Did, 22 + member.Rkey, 23 + member.Instance, 24 + member.Subject, 25 + ) 26 + return err 27 + } 28 + 29 + func RemoveSpindleMember(db *DB, owner_did, rkey string) error { 30 + _, err := db.Exec( 31 + "delete from spindle_members where did = ? and rkey = ?", 32 + owner_did, 33 + rkey, 34 + ) 35 + return err 36 + } 37 + 38 + func GetSpindleMember(db *DB, did, rkey string) (*SpindleMember, error) { 39 + query := 40 + `select id, did, rkey, instance, subject, created 41 + from spindle_members 42 + where did = ? and rkey = ?` 43 + 44 + var member SpindleMember 45 + var createdAt string 46 + err := db.QueryRow(query, did, rkey).Scan( 47 + &member.Id, 48 + &member.Did, 49 + &member.Rkey, 50 + &member.Instance, 51 + &member.Subject, 52 + &createdAt, 53 + ) 54 + if err != nil { 55 + return nil, err 56 + } 57 + 58 + return &member, nil 59 + }
-21
spindle/engine/ansi_stripper.go
··· 1 - package engine 2 - 3 - import ( 4 - "io" 5 - 6 - "regexp" 7 - ) 8 - 9 - // regex to match ANSI escape codes (e.g., color codes, cursor moves) 10 - const ansi = "[\u001B\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PRZcf-ntqry=><~]))" 11 - 12 - var re = regexp.MustCompile(ansi) 13 - 14 - type ansiStrippingWriter struct { 15 - underlying io.Writer 16 - } 17 - 18 - func (w *ansiStrippingWriter) Write(p []byte) (int, error) { 19 - clean := re.ReplaceAll(p, []byte{}) 20 - return w.underlying.Write(clean) 21 - }
+80 -461
spindle/engine/engine.go
··· 1 1 package engine 2 2 3 3 import ( 4 - "bufio" 5 4 "context" 6 5 "errors" 7 6 "fmt" 8 - "io" 9 7 "log/slog" 10 - "os" 11 - "strings" 12 - "sync" 13 - "time" 14 8 15 - "github.com/docker/docker/api/types/container" 16 - "github.com/docker/docker/api/types/image" 17 - "github.com/docker/docker/api/types/mount" 18 - "github.com/docker/docker/api/types/network" 19 - "github.com/docker/docker/api/types/volume" 20 - "github.com/docker/docker/client" 21 - "github.com/docker/docker/pkg/stdcopy" 22 - "tangled.sh/tangled.sh/core/log" 9 + securejoin "github.com/cyphar/filepath-securejoin" 10 + "golang.org/x/sync/errgroup" 23 11 "tangled.sh/tangled.sh/core/notifier" 24 12 "tangled.sh/tangled.sh/core/spindle/config" 25 13 "tangled.sh/tangled.sh/core/spindle/db" 26 14 "tangled.sh/tangled.sh/core/spindle/models" 15 + "tangled.sh/tangled.sh/core/spindle/secrets" 27 16 ) 28 17 29 - const ( 30 - workspaceDir = "/tangled/workspace" 18 + var ( 19 + ErrTimedOut = errors.New("timed out") 20 + ErrWorkflowFailed = errors.New("workflow failed") 31 21 ) 32 22 33 - type cleanupFunc func(context.Context) error 34 - 35 - type Engine struct { 36 - docker client.APIClient 37 - l *slog.Logger 38 - db *db.DB 39 - n *notifier.Notifier 40 - cfg *config.Config 41 - 42 - chanMu sync.RWMutex 43 - stdoutChans map[string]chan string 44 - stderrChans map[string]chan string 23 + func StartWorkflows(l *slog.Logger, vault secrets.Manager, cfg *config.Config, db *db.DB, n *notifier.Notifier, ctx context.Context, pipeline *models.Pipeline, pipelineId models.PipelineId) { 24 + l.Info("starting all workflows in parallel", "pipeline", pipelineId) 45 25 46 - cleanupMu sync.Mutex 47 - cleanup map[string][]cleanupFunc 48 - } 49 - 50 - func New(ctx context.Context, cfg *config.Config, db *db.DB, n *notifier.Notifier) (*Engine, error) { 51 - dcli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) 52 - if err != nil { 53 - return nil, err 54 - } 55 - 56 - l := log.FromContext(ctx).With("component", "spindle") 57 - 58 - e := &Engine{ 59 - docker: dcli, 60 - l: l, 61 - db: db, 62 - n: n, 63 - cfg: cfg, 26 + // extract secrets 27 + var allSecrets []secrets.UnlockedSecret 28 + if didSlashRepo, err := securejoin.SecureJoin(pipeline.RepoOwner, pipeline.RepoName); err == nil { 29 + if res, err := vault.GetSecretsUnlocked(ctx, secrets.DidSlashRepo(didSlashRepo)); err == nil { 30 + allSecrets = res 31 + } 64 32 } 65 33 66 - e.stdoutChans = make(map[string]chan string, 100) 67 - e.stderrChans = make(map[string]chan string, 100) 68 - 69 - e.cleanup = make(map[string][]cleanupFunc) 70 - 71 - return e, nil 72 - } 73 - 74 - func (e *Engine) StartWorkflows(ctx context.Context, pipeline *models.Pipeline, pipelineId models.PipelineId) { 75 - e.l.Info("starting all workflows in parallel", "pipeline", pipelineId) 76 - 77 - wg := sync.WaitGroup{} 78 - for _, w := range pipeline.Workflows { 79 - wg.Add(1) 80 - go func() error { 81 - defer wg.Done() 82 - wid := models.WorkflowId{ 83 - PipelineId: pipelineId, 84 - Name: w.Name, 85 - } 86 - 87 - err := e.db.StatusRunning(wid, e.n) 88 - if err != nil { 89 - return err 90 - } 34 + eg, ctx := errgroup.WithContext(ctx) 35 + for eng, wfs := range pipeline.Workflows { 36 + workflowTimeout := eng.WorkflowTimeout() 37 + l.Info("using workflow timeout", "timeout", workflowTimeout) 91 38 92 - err = e.SetupWorkflow(ctx, wid) 93 - if err != nil { 94 - e.l.Error("setting up worklow", "wid", wid, "err", err) 95 - return err 96 - } 97 - defer e.DestroyWorkflow(ctx, wid) 98 - 99 - reader, err := e.docker.ImagePull(ctx, w.Image, image.PullOptions{}) 100 - if err != nil { 101 - e.l.Error("pipeline failed!", "workflowId", wid, "error", err.Error()) 39 + for _, w := range wfs { 40 + eg.Go(func() error { 41 + wid := models.WorkflowId{ 42 + PipelineId: pipelineId, 43 + Name: w.Name, 44 + } 102 45 103 - err := e.db.StatusFailed(wid, err.Error(), -1, e.n) 46 + err := db.StatusRunning(wid, n) 104 47 if err != nil { 105 48 return err 106 49 } 107 50 108 - return fmt.Errorf("pulling image: %w", err) 109 - } 110 - defer reader.Close() 111 - io.Copy(os.Stdout, reader) 51 + err = eng.SetupWorkflow(ctx, wid, &w) 52 + if err != nil { 53 + // TODO(winter): Should this always set StatusFailed? 54 + // In the original, we only do in a subset of cases. 55 + l.Error("setting up worklow", "wid", wid, "err", err) 112 56 113 - err = e.StartSteps(ctx, w.Steps, wid, w.Image) 114 - if err != nil { 115 - e.l.Error("workflow failed!", "wid", wid.String(), "error", err.Error()) 57 + destroyErr := eng.DestroyWorkflow(ctx, wid) 58 + if destroyErr != nil { 59 + l.Error("failed to destroy workflow after setup failure", "error", destroyErr) 60 + } 116 61 117 - dbErr := e.db.StatusFailed(wid, err.Error(), -1, e.n) 118 - if dbErr != nil { 119 - return dbErr 62 + dbErr := db.StatusFailed(wid, err.Error(), -1, n) 63 + if dbErr != nil { 64 + return dbErr 65 + } 66 + return err 120 67 } 68 + defer eng.DestroyWorkflow(ctx, wid) 121 69 122 - return fmt.Errorf("starting steps image: %w", err) 123 - } 70 + wfLogger, err := models.NewWorkflowLogger(cfg.Server.LogDir, wid) 71 + if err != nil { 72 + l.Warn("failed to setup step logger; logs will not be persisted", "error", err) 73 + wfLogger = nil 74 + } else { 75 + defer wfLogger.Close() 76 + } 124 77 125 - err = e.db.StatusSuccess(wid, e.n) 126 - if err != nil { 127 - return err 128 - } 78 + ctx, cancel := context.WithTimeout(ctx, workflowTimeout) 79 + defer cancel() 129 80 130 - return nil 131 - }() 132 - } 81 + for stepIdx, step := range w.Steps { 82 + if wfLogger != nil { 83 + ctl := wfLogger.ControlWriter(stepIdx, step) 84 + ctl.Write([]byte(step.Name())) 85 + } 133 86 134 - wg.Wait() 135 - } 87 + err = eng.RunStep(ctx, wid, &w, stepIdx, allSecrets, wfLogger) 88 + if err != nil { 89 + if errors.Is(err, ErrTimedOut) { 90 + dbErr := db.StatusTimeout(wid, n) 91 + if dbErr != nil { 92 + return dbErr 93 + } 94 + } else { 95 + dbErr := db.StatusFailed(wid, err.Error(), -1, n) 96 + if dbErr != nil { 97 + return dbErr 98 + } 99 + } 136 100 137 - // SetupWorkflow sets up a new network for the workflow and volumes for 138 - // the workspace and Nix store. These are persisted across steps and are 139 - // destroyed at the end of the workflow. 140 - func (e *Engine) SetupWorkflow(ctx context.Context, wid models.WorkflowId) error { 141 - e.l.Info("setting up workflow", "workflow", wid) 101 + return fmt.Errorf("starting steps image: %w", err) 102 + } 103 + } 142 104 143 - _, err := e.docker.VolumeCreate(ctx, volume.CreateOptions{ 144 - Name: workspaceVolume(wid), 145 - Driver: "local", 146 - }) 147 - if err != nil { 148 - return err 149 - } 150 - e.registerCleanup(wid, func(ctx context.Context) error { 151 - return e.docker.VolumeRemove(ctx, workspaceVolume(wid), true) 152 - }) 105 + err = db.StatusSuccess(wid, n) 106 + if err != nil { 107 + return err 108 + } 153 109 154 - _, err = e.docker.VolumeCreate(ctx, volume.CreateOptions{ 155 - Name: nixVolume(wid), 156 - Driver: "local", 157 - }) 158 - if err != nil { 159 - return err 160 - } 161 - e.registerCleanup(wid, func(ctx context.Context) error { 162 - return e.docker.VolumeRemove(ctx, nixVolume(wid), true) 163 - }) 164 - 165 - _, err = e.docker.NetworkCreate(ctx, networkName(wid), network.CreateOptions{ 166 - Driver: "bridge", 167 - }) 168 - if err != nil { 169 - return err 170 - } 171 - e.registerCleanup(wid, func(ctx context.Context) error { 172 - return e.docker.NetworkRemove(ctx, networkName(wid)) 173 - }) 174 - 175 - return nil 176 - } 177 - 178 - // StartSteps starts all steps sequentially with the same base image. 179 - // ONLY marks pipeline as failed if container's exit code is non-zero. 180 - // All other errors are bubbled up. 181 - // Fixed version of the step execution logic 182 - func (e *Engine) StartSteps(ctx context.Context, steps []models.Step, wid models.WorkflowId, image string) error { 183 - stepTimeoutStr := e.cfg.Pipelines.StepTimeout 184 - stepTimeout, err := time.ParseDuration(stepTimeoutStr) 185 - if err != nil { 186 - e.l.Error("failed to parse step timeout", "error", err, "timeout", stepTimeoutStr) 187 - stepTimeout = 5 * time.Minute 188 - } 189 - e.l.Info("using step timeout", "timeout", stepTimeout) 190 - 191 - e.chanMu.Lock() 192 - if _, exists := e.stdoutChans[wid.String()]; !exists { 193 - e.stdoutChans[wid.String()] = make(chan string, 100) 194 - } 195 - if _, exists := e.stderrChans[wid.String()]; !exists { 196 - e.stderrChans[wid.String()] = make(chan string, 100) 197 - } 198 - e.chanMu.Unlock() 199 - 200 - // close channels after all steps are complete 201 - defer func() { 202 - close(e.stdoutChans[wid.String()]) 203 - close(e.stderrChans[wid.String()]) 204 - }() 205 - 206 - for _, step := range steps { 207 - envs := ConstructEnvs(step.Environment) 208 - envs.AddEnv("HOME", workspaceDir) 209 - e.l.Debug("envs for step", "step", step.Name, "envs", envs.Slice()) 210 - 211 - hostConfig := hostConfig(wid) 212 - resp, err := e.docker.ContainerCreate(ctx, &container.Config{ 213 - Image: image, 214 - Cmd: []string{"bash", "-c", step.Command}, 215 - WorkingDir: workspaceDir, 216 - Tty: false, 217 - Hostname: "spindle", 218 - Env: envs.Slice(), 219 - }, hostConfig, nil, nil, "") 220 - if err != nil { 221 - return fmt.Errorf("creating container: %w", err) 222 - } 223 - 224 - err = e.docker.NetworkConnect(ctx, networkName(wid), resp.ID, nil) 225 - if err != nil { 226 - return fmt.Errorf("connecting network: %w", err) 227 - } 228 - 229 - stepCtx, stepCancel := context.WithTimeout(ctx, stepTimeout) 230 - 231 - err = e.docker.ContainerStart(stepCtx, resp.ID, container.StartOptions{}) 232 - if err != nil { 233 - stepCancel() 234 - return err 235 - } 236 - e.l.Info("started container", "name", resp.ID, "step", step.Name) 237 - 238 - // start tailing logs in background 239 - tailDone := make(chan error, 1) 240 - go func() { 241 - tailDone <- e.TailStep(stepCtx, resp.ID, wid) 242 - }() 243 - 244 - // wait for container completion or timeout 245 - waitDone := make(chan struct{}) 246 - var state *container.State 247 - var waitErr error 248 - 249 - go func() { 250 - defer close(waitDone) 251 - state, waitErr = e.WaitStep(stepCtx, resp.ID) 252 - }() 253 - 254 - select { 255 - case <-waitDone: 256 - // container finished normally 257 - stepCancel() 258 - 259 - // wait for tailing to complete 260 - <-tailDone 261 - 262 - case <-stepCtx.Done(): 263 - e.l.Warn("step timed out; killing container", "container", resp.ID, "timeout", stepTimeout) 264 - 265 - _ = e.DestroyStep(ctx, resp.ID) 266 - 267 - // wait for both goroutines to finish 268 - <-waitDone 269 - <-tailDone 270 - 271 - stepCancel() 272 - return fmt.Errorf("step timed out after %v", stepTimeout) 273 - } 274 - 275 - if waitErr != nil { 276 - return waitErr 277 - } 278 - 279 - err = e.DestroyStep(ctx, resp.ID) 280 - if err != nil { 281 - return err 282 - } 283 - 284 - if state.ExitCode != 0 { 285 - e.l.Error("workflow failed!", "workflow_id", wid.String(), "error", state.Error, "exit_code", state.ExitCode, "oom_killed", state.OOMKilled) 286 - err := e.db.StatusFailed(wid, state.Error, int64(state.ExitCode), e.n) 287 - if err != nil { 288 - return err 289 - } 290 - return fmt.Errorf("error: %s, exit code: %d, oom: %t", state.Error, state.ExitCode, state.OOMKilled) 110 + return nil 111 + }) 291 112 } 292 113 } 293 114 294 - return nil 295 - } 296 - 297 - func (e *Engine) WaitStep(ctx context.Context, containerID string) (*container.State, error) { 298 - wait, errCh := e.docker.ContainerWait(ctx, containerID, container.WaitConditionNotRunning) 299 - select { 300 - case err := <-errCh: 301 - if err != nil { 302 - return nil, err 303 - } 304 - case <-wait: 305 - } 306 - 307 - e.l.Info("waited for container", "name", containerID) 308 - 309 - info, err := e.docker.ContainerInspect(ctx, containerID) 310 - if err != nil { 311 - return nil, err 312 - } 313 - 314 - return info.State, nil 315 - } 316 - 317 - func (e *Engine) TailStep(ctx context.Context, containerID string, wid models.WorkflowId) error { 318 - logs, err := e.docker.ContainerLogs(ctx, containerID, container.LogsOptions{ 319 - Follow: true, 320 - ShowStdout: true, 321 - ShowStderr: true, 322 - Details: true, 323 - Timestamps: false, 324 - }) 325 - if err != nil { 326 - return err 327 - } 328 - 329 - var devOutput io.Writer = io.Discard 330 - if e.cfg.Server.Dev { 331 - devOutput = &ansiStrippingWriter{underlying: os.Stdout} 332 - } 333 - 334 - tee := io.TeeReader(logs, devOutput) 335 - 336 - // using StdCopy we demux logs and stream stdout and stderr to different 337 - // channels. 338 - // 339 - // stdout w||r stdoutCh 340 - // stderr w||r stderrCh 341 - // 342 - 343 - rpipeOut, wpipeOut := io.Pipe() 344 - rpipeErr, wpipeErr := io.Pipe() 345 - 346 - wg := sync.WaitGroup{} 347 - 348 - wg.Add(1) 349 - go func() { 350 - defer wg.Done() 351 - defer wpipeOut.Close() 352 - defer wpipeErr.Close() 353 - _, err := stdcopy.StdCopy(wpipeOut, wpipeErr, tee) 354 - if err != nil && err != io.EOF && !errors.Is(context.DeadlineExceeded, err) { 355 - e.l.Error("failed to copy logs", "error", err) 356 - } 357 - }() 358 - 359 - // read from stdout and send to stdout pipe 360 - // NOTE: the stdoutCh channnel is closed further up in StartSteps 361 - // once all steps are done. 362 - wg.Add(1) 363 - go func() { 364 - defer wg.Done() 365 - e.chanMu.RLock() 366 - stdoutCh := e.stdoutChans[wid.String()] 367 - e.chanMu.RUnlock() 368 - 369 - scanner := bufio.NewScanner(rpipeOut) 370 - for scanner.Scan() { 371 - stdoutCh <- scanner.Text() 372 - } 373 - if err := scanner.Err(); err != nil { 374 - e.l.Error("failed to scan stdout", "error", err) 375 - } 376 - }() 377 - 378 - // read from stderr and send to stderr pipe 379 - // NOTE: the stderrCh channnel is closed further up in StartSteps 380 - // once all steps are done. 381 - wg.Add(1) 382 - go func() { 383 - defer wg.Done() 384 - e.chanMu.RLock() 385 - stderrCh := e.stderrChans[wid.String()] 386 - e.chanMu.RUnlock() 387 - 388 - scanner := bufio.NewScanner(rpipeErr) 389 - for scanner.Scan() { 390 - stderrCh <- scanner.Text() 391 - } 392 - if err := scanner.Err(); err != nil { 393 - e.l.Error("failed to scan stderr", "error", err) 394 - } 395 - }() 396 - 397 - wg.Wait() 398 - 399 - return nil 400 - } 401 - 402 - func (e *Engine) DestroyStep(ctx context.Context, containerID string) error { 403 - err := e.docker.ContainerKill(ctx, containerID, "9") // SIGKILL 404 - if err != nil && !isErrContainerNotFoundOrNotRunning(err) { 405 - return err 406 - } 407 - 408 - if err := e.docker.ContainerRemove(ctx, containerID, container.RemoveOptions{ 409 - RemoveVolumes: true, 410 - RemoveLinks: false, 411 - Force: false, 412 - }); err != nil && !isErrContainerNotFoundOrNotRunning(err) { 413 - return err 414 - } 415 - 416 - return nil 417 - } 418 - 419 - func (e *Engine) DestroyWorkflow(ctx context.Context, wid models.WorkflowId) error { 420 - e.cleanupMu.Lock() 421 - key := wid.String() 422 - 423 - fns := e.cleanup[key] 424 - delete(e.cleanup, key) 425 - e.cleanupMu.Unlock() 426 - 427 - for _, fn := range fns { 428 - if err := fn(ctx); err != nil { 429 - e.l.Error("failed to cleanup workflow resource", "workflowId", wid, "error", err) 430 - } 115 + if err := eg.Wait(); err != nil { 116 + l.Error("failed to run one or more workflows", "err", err) 117 + } else { 118 + l.Error("successfully ran full pipeline") 431 119 } 432 - return nil 433 - } 434 - 435 - func (e *Engine) LogChannels(wid models.WorkflowId) (stdout <-chan string, stderr <-chan string, ok bool) { 436 - e.chanMu.RLock() 437 - defer e.chanMu.RUnlock() 438 - 439 - stdoutCh, ok1 := e.stdoutChans[wid.String()] 440 - stderrCh, ok2 := e.stderrChans[wid.String()] 441 - 442 - if !ok1 || !ok2 { 443 - return nil, nil, false 444 - } 445 - return stdoutCh, stderrCh, true 446 - } 447 - 448 - func (e *Engine) registerCleanup(wid models.WorkflowId, fn cleanupFunc) { 449 - e.cleanupMu.Lock() 450 - defer e.cleanupMu.Unlock() 451 - 452 - key := wid.String() 453 - e.cleanup[key] = append(e.cleanup[key], fn) 454 - } 455 - 456 - func workspaceVolume(wid models.WorkflowId) string { 457 - return fmt.Sprintf("workspace-%s", wid) 458 - } 459 - 460 - func nixVolume(wid models.WorkflowId) string { 461 - return fmt.Sprintf("nix-%s", wid) 462 - } 463 - 464 - func networkName(wid models.WorkflowId) string { 465 - return fmt.Sprintf("workflow-network-%s", wid) 466 - } 467 - 468 - func hostConfig(wid models.WorkflowId) *container.HostConfig { 469 - hostConfig := &container.HostConfig{ 470 - Mounts: []mount.Mount{ 471 - { 472 - Type: mount.TypeVolume, 473 - Source: workspaceVolume(wid), 474 - Target: workspaceDir, 475 - }, 476 - { 477 - Type: mount.TypeVolume, 478 - Source: nixVolume(wid), 479 - Target: "/nix", 480 - }, 481 - { 482 - Type: mount.TypeTmpfs, 483 - Target: "/tmp", 484 - }, 485 - }, 486 - ReadonlyRootfs: false, 487 - CapDrop: []string{"ALL"}, 488 - SecurityOpt: []string{"seccomp=unconfined"}, 489 - } 490 - 491 - return hostConfig 492 - } 493 - 494 - // thanks woodpecker 495 - func isErrContainerNotFoundOrNotRunning(err error) bool { 496 - // Error response from daemon: Cannot kill container: ...: No such container: ... 497 - // Error response from daemon: Cannot kill container: ...: Container ... is not running" 498 - // Error response from podman daemon: can only kill running containers. ... is in state exited 499 - // Error: No such container: ... 500 - return err != nil && (strings.Contains(err.Error(), "No such container") || strings.Contains(err.Error(), "is not running") || strings.Contains(err.Error(), "can only kill running containers")) 501 120 }
-28
spindle/engine/envs.go
··· 1 - package engine 2 - 3 - import ( 4 - "fmt" 5 - ) 6 - 7 - type EnvVars []string 8 - 9 - // ConstructEnvs converts a tangled.Pipeline_Step_Environment_Elem.{Key,Value} 10 - // representation into a docker-friendly []string{"KEY=value", ...} slice. 11 - func ConstructEnvs(envs map[string]string) EnvVars { 12 - var dockerEnvs EnvVars 13 - for k, v := range envs { 14 - ev := fmt.Sprintf("%s=%s", k, v) 15 - dockerEnvs = append(dockerEnvs, ev) 16 - } 17 - return dockerEnvs 18 - } 19 - 20 - // Slice returns the EnvVar as a []string slice. 21 - func (ev EnvVars) Slice() []string { 22 - return ev 23 - } 24 - 25 - // AddEnv adds a key=value string to the EnvVar. 26 - func (ev *EnvVars) AddEnv(key, value string) { 27 - *ev = append(*ev, fmt.Sprintf("%s=%s", key, value)) 28 - }
-55
spindle/engine/envs_test.go
··· 1 - package engine 2 - 3 - import ( 4 - "reflect" 5 - "testing" 6 - ) 7 - 8 - func TestConstructEnvs(t *testing.T) { 9 - tests := []struct { 10 - name string 11 - in map[string]string 12 - want EnvVars 13 - }{ 14 - { 15 - name: "empty input", 16 - in: make(map[string]string), 17 - want: EnvVars{}, 18 - }, 19 - { 20 - name: "single env var", 21 - in: map[string]string{"FOO": "bar"}, 22 - want: EnvVars{"FOO=bar"}, 23 - }, 24 - { 25 - name: "multiple env vars", 26 - in: map[string]string{"FOO": "bar", "BAZ": "qux"}, 27 - want: EnvVars{"FOO=bar", "BAZ=qux"}, 28 - }, 29 - } 30 - 31 - for _, tt := range tests { 32 - t.Run(tt.name, func(t *testing.T) { 33 - got := ConstructEnvs(tt.in) 34 - 35 - if got == nil { 36 - got = EnvVars{} 37 - } 38 - 39 - if !reflect.DeepEqual(got, tt.want) { 40 - t.Errorf("ConstructEnvs() = %v, want %v", got, tt.want) 41 - } 42 - }) 43 - } 44 - } 45 - 46 - func TestAddEnv(t *testing.T) { 47 - ev := EnvVars{} 48 - ev.AddEnv("FOO", "bar") 49 - ev.AddEnv("BAZ", "qux") 50 - 51 - want := EnvVars{"FOO=bar", "BAZ=qux"} 52 - if !reflect.DeepEqual(ev, want) { 53 - t.Errorf("AddEnv result = %v, want %v", ev, want) 54 - } 55 - }
+21
spindle/engines/nixery/ansi_stripper.go
··· 1 + package nixery 2 + 3 + import ( 4 + "io" 5 + 6 + "regexp" 7 + ) 8 + 9 + // regex to match ANSI escape codes (e.g., color codes, cursor moves) 10 + const ansi = "[\u001B\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PRZcf-ntqry=><~]))" 11 + 12 + var re = regexp.MustCompile(ansi) 13 + 14 + type ansiStrippingWriter struct { 15 + underlying io.Writer 16 + } 17 + 18 + func (w *ansiStrippingWriter) Write(p []byte) (int, error) { 19 + clean := re.ReplaceAll(p, []byte{}) 20 + return w.underlying.Write(clean) 21 + }
+421
spindle/engines/nixery/engine.go
··· 1 + package nixery 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "fmt" 7 + "io" 8 + "log/slog" 9 + "os" 10 + "path" 11 + "runtime" 12 + "sync" 13 + "time" 14 + 15 + "github.com/docker/docker/api/types/container" 16 + "github.com/docker/docker/api/types/image" 17 + "github.com/docker/docker/api/types/mount" 18 + "github.com/docker/docker/api/types/network" 19 + "github.com/docker/docker/client" 20 + "github.com/docker/docker/pkg/stdcopy" 21 + "gopkg.in/yaml.v3" 22 + "tangled.sh/tangled.sh/core/api/tangled" 23 + "tangled.sh/tangled.sh/core/log" 24 + "tangled.sh/tangled.sh/core/spindle/config" 25 + "tangled.sh/tangled.sh/core/spindle/engine" 26 + "tangled.sh/tangled.sh/core/spindle/models" 27 + "tangled.sh/tangled.sh/core/spindle/secrets" 28 + ) 29 + 30 + const ( 31 + workspaceDir = "/tangled/workspace" 32 + homeDir = "/tangled/home" 33 + ) 34 + 35 + type cleanupFunc func(context.Context) error 36 + 37 + type Engine struct { 38 + docker client.APIClient 39 + l *slog.Logger 40 + cfg *config.Config 41 + 42 + cleanupMu sync.Mutex 43 + cleanup map[string][]cleanupFunc 44 + } 45 + 46 + type Step struct { 47 + name string 48 + kind models.StepKind 49 + command string 50 + environment map[string]string 51 + } 52 + 53 + func (s Step) Name() string { 54 + return s.name 55 + } 56 + 57 + func (s Step) Command() string { 58 + return s.command 59 + } 60 + 61 + func (s Step) Kind() models.StepKind { 62 + return s.kind 63 + } 64 + 65 + // setupSteps get added to start of Steps 66 + type setupSteps []models.Step 67 + 68 + // addStep adds a step to the beginning of the workflow's steps. 69 + func (ss *setupSteps) addStep(step models.Step) { 70 + *ss = append(*ss, step) 71 + } 72 + 73 + type addlFields struct { 74 + image string 75 + container string 76 + env map[string]string 77 + } 78 + 79 + func (e *Engine) InitWorkflow(twf tangled.Pipeline_Workflow, tpl tangled.Pipeline) (*models.Workflow, error) { 80 + swf := &models.Workflow{} 81 + addl := addlFields{} 82 + 83 + dwf := &struct { 84 + Steps []struct { 85 + Command string `yaml:"command"` 86 + Name string `yaml:"name"` 87 + Environment map[string]string `yaml:"environment"` 88 + } `yaml:"steps"` 89 + Dependencies map[string][]string `yaml:"dependencies"` 90 + Environment map[string]string `yaml:"environment"` 91 + }{} 92 + err := yaml.Unmarshal([]byte(twf.Raw), &dwf) 93 + if err != nil { 94 + return nil, err 95 + } 96 + 97 + for _, dstep := range dwf.Steps { 98 + sstep := Step{} 99 + sstep.environment = dstep.Environment 100 + sstep.command = dstep.Command 101 + sstep.name = dstep.Name 102 + sstep.kind = models.StepKindUser 103 + swf.Steps = append(swf.Steps, sstep) 104 + } 105 + swf.Name = twf.Name 106 + addl.env = dwf.Environment 107 + addl.image = workflowImage(dwf.Dependencies, e.cfg.NixeryPipelines.Nixery) 108 + 109 + setup := &setupSteps{} 110 + 111 + setup.addStep(nixConfStep()) 112 + setup.addStep(cloneStep(twf, *tpl.TriggerMetadata, e.cfg.Server.Dev)) 113 + // this step could be empty 114 + if s := dependencyStep(dwf.Dependencies); s != nil { 115 + setup.addStep(*s) 116 + } 117 + 118 + // append setup steps in order to the start of workflow steps 119 + swf.Steps = append(*setup, swf.Steps...) 120 + swf.Data = addl 121 + 122 + return swf, nil 123 + } 124 + 125 + func (e *Engine) WorkflowTimeout() time.Duration { 126 + workflowTimeoutStr := e.cfg.NixeryPipelines.WorkflowTimeout 127 + workflowTimeout, err := time.ParseDuration(workflowTimeoutStr) 128 + if err != nil { 129 + e.l.Error("failed to parse workflow timeout", "error", err, "timeout", workflowTimeoutStr) 130 + workflowTimeout = 5 * time.Minute 131 + } 132 + 133 + return workflowTimeout 134 + } 135 + 136 + func workflowImage(deps map[string][]string, nixery string) string { 137 + var dependencies string 138 + for reg, ds := range deps { 139 + if reg == "nixpkgs" { 140 + dependencies = path.Join(ds...) 141 + } 142 + } 143 + 144 + // load defaults from somewhere else 145 + dependencies = path.Join(dependencies, "bash", "git", "coreutils", "nix") 146 + 147 + if runtime.GOARCH == "arm64" { 148 + dependencies = path.Join("arm64", dependencies) 149 + } 150 + 151 + return path.Join(nixery, dependencies) 152 + } 153 + 154 + func New(ctx context.Context, cfg *config.Config) (*Engine, error) { 155 + dcli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) 156 + if err != nil { 157 + return nil, err 158 + } 159 + 160 + l := log.FromContext(ctx).With("component", "spindle") 161 + 162 + e := &Engine{ 163 + docker: dcli, 164 + l: l, 165 + cfg: cfg, 166 + } 167 + 168 + e.cleanup = make(map[string][]cleanupFunc) 169 + 170 + return e, nil 171 + } 172 + 173 + func (e *Engine) SetupWorkflow(ctx context.Context, wid models.WorkflowId, wf *models.Workflow) error { 174 + e.l.Info("setting up workflow", "workflow", wid) 175 + 176 + _, err := e.docker.NetworkCreate(ctx, networkName(wid), network.CreateOptions{ 177 + Driver: "bridge", 178 + }) 179 + if err != nil { 180 + return err 181 + } 182 + e.registerCleanup(wid, func(ctx context.Context) error { 183 + return e.docker.NetworkRemove(ctx, networkName(wid)) 184 + }) 185 + 186 + addl := wf.Data.(addlFields) 187 + 188 + reader, err := e.docker.ImagePull(ctx, addl.image, image.PullOptions{}) 189 + if err != nil { 190 + e.l.Error("pipeline image pull failed!", "image", addl.image, "workflowId", wid, "error", err.Error()) 191 + 192 + return fmt.Errorf("pulling image: %w", err) 193 + } 194 + defer reader.Close() 195 + io.Copy(os.Stdout, reader) 196 + 197 + resp, err := e.docker.ContainerCreate(ctx, &container.Config{ 198 + Image: addl.image, 199 + Cmd: []string{"cat"}, 200 + OpenStdin: true, // so cat stays alive :3 201 + Tty: false, 202 + Hostname: "spindle", 203 + WorkingDir: workspaceDir, 204 + Labels: map[string]string{ 205 + "sh.tangled.pipeline/workflow_id": wid.String(), 206 + }, 207 + // TODO(winter): investigate whether environment variables passed here 208 + // get propagated to ContainerExec processes 209 + }, &container.HostConfig{ 210 + Mounts: []mount.Mount{ 211 + { 212 + Type: mount.TypeTmpfs, 213 + Target: "/tmp", 214 + ReadOnly: false, 215 + TmpfsOptions: &mount.TmpfsOptions{ 216 + Mode: 0o1777, // world-writeable sticky bit 217 + Options: [][]string{ 218 + {"exec"}, 219 + }, 220 + }, 221 + }, 222 + }, 223 + ReadonlyRootfs: false, 224 + CapDrop: []string{"ALL"}, 225 + CapAdd: []string{"CAP_DAC_OVERRIDE"}, 226 + SecurityOpt: []string{"no-new-privileges"}, 227 + ExtraHosts: []string{"host.docker.internal:host-gateway"}, 228 + }, nil, nil, "") 229 + if err != nil { 230 + return fmt.Errorf("creating container: %w", err) 231 + } 232 + e.registerCleanup(wid, func(ctx context.Context) error { 233 + err = e.docker.ContainerStop(ctx, resp.ID, container.StopOptions{}) 234 + if err != nil { 235 + return err 236 + } 237 + 238 + return e.docker.ContainerRemove(ctx, resp.ID, container.RemoveOptions{ 239 + RemoveVolumes: true, 240 + RemoveLinks: false, 241 + Force: false, 242 + }) 243 + }) 244 + 245 + err = e.docker.ContainerStart(ctx, resp.ID, container.StartOptions{}) 246 + if err != nil { 247 + return fmt.Errorf("starting container: %w", err) 248 + } 249 + 250 + mkExecResp, err := e.docker.ContainerExecCreate(ctx, resp.ID, container.ExecOptions{ 251 + Cmd: []string{"mkdir", "-p", workspaceDir, homeDir}, 252 + AttachStdout: true, // NOTE(winter): pretty sure this will make it so that when stdout read is done below, mkdir is done. maybe?? 253 + AttachStderr: true, // for good measure, backed up by docker/cli ("If -d is not set, attach to everything by default") 254 + }) 255 + if err != nil { 256 + return err 257 + } 258 + 259 + // This actually *starts* the command. Thanks, Docker! 260 + execResp, err := e.docker.ContainerExecAttach(ctx, mkExecResp.ID, container.ExecAttachOptions{}) 261 + if err != nil { 262 + return err 263 + } 264 + defer execResp.Close() 265 + 266 + // This is apparently best way to wait for the command to complete. 267 + _, err = io.ReadAll(execResp.Reader) 268 + if err != nil { 269 + return err 270 + } 271 + 272 + execInspectResp, err := e.docker.ContainerExecInspect(ctx, mkExecResp.ID) 273 + if err != nil { 274 + return err 275 + } 276 + 277 + if execInspectResp.ExitCode != 0 { 278 + return fmt.Errorf("mkdir exited with exit code %d", execInspectResp.ExitCode) 279 + } else if execInspectResp.Running { 280 + return errors.New("mkdir is somehow still running??") 281 + } 282 + 283 + addl.container = resp.ID 284 + wf.Data = addl 285 + 286 + return nil 287 + } 288 + 289 + func (e *Engine) RunStep(ctx context.Context, wid models.WorkflowId, w *models.Workflow, idx int, secrets []secrets.UnlockedSecret, wfLogger *models.WorkflowLogger) error { 290 + addl := w.Data.(addlFields) 291 + workflowEnvs := ConstructEnvs(addl.env) 292 + // TODO(winter): should SetupWorkflow also have secret access? 293 + // IMO yes, but probably worth thinking on. 294 + for _, s := range secrets { 295 + workflowEnvs.AddEnv(s.Key, s.Value) 296 + } 297 + 298 + step := w.Steps[idx].(Step) 299 + 300 + select { 301 + case <-ctx.Done(): 302 + return ctx.Err() 303 + default: 304 + } 305 + 306 + envs := append(EnvVars(nil), workflowEnvs...) 307 + for k, v := range step.environment { 308 + envs.AddEnv(k, v) 309 + } 310 + envs.AddEnv("HOME", homeDir) 311 + 312 + mkExecResp, err := e.docker.ContainerExecCreate(ctx, addl.container, container.ExecOptions{ 313 + Cmd: []string{"bash", "-c", step.command}, 314 + AttachStdout: true, 315 + AttachStderr: true, 316 + Env: envs, 317 + }) 318 + if err != nil { 319 + return fmt.Errorf("creating exec: %w", err) 320 + } 321 + 322 + // start tailing logs in background 323 + tailDone := make(chan error, 1) 324 + go func() { 325 + tailDone <- e.tailStep(ctx, wfLogger, mkExecResp.ID, wid, idx, step) 326 + }() 327 + 328 + select { 329 + case <-tailDone: 330 + 331 + case <-ctx.Done(): 332 + // cleanup will be handled by DestroyWorkflow, since 333 + // Docker doesn't provide an API to kill an exec run 334 + // (sure, we could grab the PID and kill it ourselves, 335 + // but that's wasted effort) 336 + e.l.Warn("step timed out", "step", step.Name) 337 + 338 + <-tailDone 339 + 340 + return engine.ErrTimedOut 341 + } 342 + 343 + select { 344 + case <-ctx.Done(): 345 + return ctx.Err() 346 + default: 347 + } 348 + 349 + execInspectResp, err := e.docker.ContainerExecInspect(ctx, mkExecResp.ID) 350 + if err != nil { 351 + return err 352 + } 353 + 354 + if execInspectResp.ExitCode != 0 { 355 + inspectResp, err := e.docker.ContainerInspect(ctx, addl.container) 356 + if err != nil { 357 + return err 358 + } 359 + 360 + e.l.Error("workflow failed!", "workflow_id", wid.String(), "exit_code", execInspectResp.ExitCode, "oom_killed", inspectResp.State.OOMKilled) 361 + 362 + if inspectResp.State.OOMKilled { 363 + return ErrOOMKilled 364 + } 365 + return engine.ErrWorkflowFailed 366 + } 367 + 368 + return nil 369 + } 370 + 371 + func (e *Engine) tailStep(ctx context.Context, wfLogger *models.WorkflowLogger, execID string, wid models.WorkflowId, stepIdx int, step models.Step) error { 372 + if wfLogger == nil { 373 + return nil 374 + } 375 + 376 + // This actually *starts* the command. Thanks, Docker! 377 + logs, err := e.docker.ContainerExecAttach(ctx, execID, container.ExecAttachOptions{}) 378 + if err != nil { 379 + return err 380 + } 381 + defer logs.Close() 382 + 383 + _, err = stdcopy.StdCopy( 384 + wfLogger.DataWriter("stdout"), 385 + wfLogger.DataWriter("stderr"), 386 + logs.Reader, 387 + ) 388 + if err != nil && err != io.EOF && !errors.Is(err, context.DeadlineExceeded) { 389 + return fmt.Errorf("failed to copy logs: %w", err) 390 + } 391 + 392 + return nil 393 + } 394 + 395 + func (e *Engine) DestroyWorkflow(ctx context.Context, wid models.WorkflowId) error { 396 + e.cleanupMu.Lock() 397 + key := wid.String() 398 + 399 + fns := e.cleanup[key] 400 + delete(e.cleanup, key) 401 + e.cleanupMu.Unlock() 402 + 403 + for _, fn := range fns { 404 + if err := fn(ctx); err != nil { 405 + e.l.Error("failed to cleanup workflow resource", "workflowId", wid, "error", err) 406 + } 407 + } 408 + return nil 409 + } 410 + 411 + func (e *Engine) registerCleanup(wid models.WorkflowId, fn cleanupFunc) { 412 + e.cleanupMu.Lock() 413 + defer e.cleanupMu.Unlock() 414 + 415 + key := wid.String() 416 + e.cleanup[key] = append(e.cleanup[key], fn) 417 + } 418 + 419 + func networkName(wid models.WorkflowId) string { 420 + return fmt.Sprintf("workflow-network-%s", wid) 421 + }
+28
spindle/engines/nixery/envs.go
··· 1 + package nixery 2 + 3 + import ( 4 + "fmt" 5 + ) 6 + 7 + type EnvVars []string 8 + 9 + // ConstructEnvs converts a tangled.Pipeline_Step_Environment_Elem.{Key,Value} 10 + // representation into a docker-friendly []string{"KEY=value", ...} slice. 11 + func ConstructEnvs(envs map[string]string) EnvVars { 12 + var dockerEnvs EnvVars 13 + for k, v := range envs { 14 + ev := fmt.Sprintf("%s=%s", k, v) 15 + dockerEnvs = append(dockerEnvs, ev) 16 + } 17 + return dockerEnvs 18 + } 19 + 20 + // Slice returns the EnvVar as a []string slice. 21 + func (ev EnvVars) Slice() []string { 22 + return ev 23 + } 24 + 25 + // AddEnv adds a key=value string to the EnvVar. 26 + func (ev *EnvVars) AddEnv(key, value string) { 27 + *ev = append(*ev, fmt.Sprintf("%s=%s", key, value)) 28 + }
+48
spindle/engines/nixery/envs_test.go
··· 1 + package nixery 2 + 3 + import ( 4 + "testing" 5 + 6 + "github.com/stretchr/testify/assert" 7 + ) 8 + 9 + func TestConstructEnvs(t *testing.T) { 10 + tests := []struct { 11 + name string 12 + in map[string]string 13 + want EnvVars 14 + }{ 15 + { 16 + name: "empty input", 17 + in: make(map[string]string), 18 + want: EnvVars{}, 19 + }, 20 + { 21 + name: "single env var", 22 + in: map[string]string{"FOO": "bar"}, 23 + want: EnvVars{"FOO=bar"}, 24 + }, 25 + { 26 + name: "multiple env vars", 27 + in: map[string]string{"FOO": "bar", "BAZ": "qux"}, 28 + want: EnvVars{"FOO=bar", "BAZ=qux"}, 29 + }, 30 + } 31 + for _, tt := range tests { 32 + t.Run(tt.name, func(t *testing.T) { 33 + got := ConstructEnvs(tt.in) 34 + if got == nil { 35 + got = EnvVars{} 36 + } 37 + assert.ElementsMatch(t, tt.want, got) 38 + }) 39 + } 40 + } 41 + 42 + func TestAddEnv(t *testing.T) { 43 + ev := EnvVars{} 44 + ev.AddEnv("FOO", "bar") 45 + ev.AddEnv("BAZ", "qux") 46 + want := EnvVars{"FOO=bar", "BAZ=qux"} 47 + assert.ElementsMatch(t, want, ev) 48 + }
+7
spindle/engines/nixery/errors.go
··· 1 + package nixery 2 + 3 + import "errors" 4 + 5 + var ( 6 + ErrOOMKilled = errors.New("oom killed") 7 + )
+126
spindle/engines/nixery/setup_steps.go
··· 1 + package nixery 2 + 3 + import ( 4 + "fmt" 5 + "path" 6 + "strings" 7 + 8 + "tangled.sh/tangled.sh/core/api/tangled" 9 + "tangled.sh/tangled.sh/core/workflow" 10 + ) 11 + 12 + func nixConfStep() Step { 13 + setupCmd := `mkdir -p /etc/nix 14 + echo 'extra-experimental-features = nix-command flakes' >> /etc/nix/nix.conf 15 + echo 'build-users-group = ' >> /etc/nix/nix.conf` 16 + return Step{ 17 + command: setupCmd, 18 + name: "Configure Nix", 19 + } 20 + } 21 + 22 + // cloneOptsAsSteps processes clone options and adds corresponding steps 23 + // to the beginning of the workflow's step list if cloning is not skipped. 24 + // 25 + // the steps to do here are: 26 + // - git init 27 + // - git remote add origin <url> 28 + // - git fetch --depth=<d> --recurse-submodules=<yes|no> <sha> 29 + // - git checkout FETCH_HEAD 30 + func cloneStep(twf tangled.Pipeline_Workflow, tr tangled.Pipeline_TriggerMetadata, dev bool) Step { 31 + if twf.Clone.Skip { 32 + return Step{} 33 + } 34 + 35 + var commands []string 36 + 37 + // initialize git repo in workspace 38 + commands = append(commands, "git init") 39 + 40 + // add repo as git remote 41 + scheme := "https://" 42 + if dev { 43 + scheme = "http://" 44 + tr.Repo.Knot = strings.ReplaceAll(tr.Repo.Knot, "localhost", "host.docker.internal") 45 + } 46 + url := scheme + path.Join(tr.Repo.Knot, tr.Repo.Did, tr.Repo.Repo) 47 + commands = append(commands, fmt.Sprintf("git remote add origin %s", url)) 48 + 49 + // run git fetch 50 + { 51 + var fetchArgs []string 52 + 53 + // default clone depth is 1 54 + depth := 1 55 + if twf.Clone.Depth > 1 { 56 + depth = int(twf.Clone.Depth) 57 + } 58 + fetchArgs = append(fetchArgs, fmt.Sprintf("--depth=%d", depth)) 59 + 60 + // optionally recurse submodules 61 + if twf.Clone.Submodules { 62 + fetchArgs = append(fetchArgs, "--recurse-submodules=yes") 63 + } 64 + 65 + // set remote to fetch from 66 + fetchArgs = append(fetchArgs, "origin") 67 + 68 + // set revision to checkout 69 + switch workflow.TriggerKind(tr.Kind) { 70 + case workflow.TriggerKindManual: 71 + // TODO: unimplemented 72 + case workflow.TriggerKindPush: 73 + fetchArgs = append(fetchArgs, tr.Push.NewSha) 74 + case workflow.TriggerKindPullRequest: 75 + fetchArgs = append(fetchArgs, tr.PullRequest.SourceSha) 76 + } 77 + 78 + commands = append(commands, fmt.Sprintf("git fetch %s", strings.Join(fetchArgs, " "))) 79 + } 80 + 81 + // run git checkout 82 + commands = append(commands, "git checkout FETCH_HEAD") 83 + 84 + cloneStep := Step{ 85 + command: strings.Join(commands, "\n"), 86 + name: "Clone repository into workspace", 87 + } 88 + return cloneStep 89 + } 90 + 91 + // dependencyStep processes dependencies defined in the workflow. 92 + // For dependencies using a custom registry (i.e. not nixpkgs), it collects 93 + // all packages and adds a single 'nix profile install' step to the 94 + // beginning of the workflow's step list. 95 + func dependencyStep(deps map[string][]string) *Step { 96 + var customPackages []string 97 + 98 + for registry, packages := range deps { 99 + if registry == "nixpkgs" { 100 + continue 101 + } 102 + 103 + if len(packages) == 0 { 104 + customPackages = append(customPackages, registry) 105 + } 106 + // collect packages from custom registries 107 + for _, pkg := range packages { 108 + customPackages = append(customPackages, fmt.Sprintf("'%s#%s'", registry, pkg)) 109 + } 110 + } 111 + 112 + if len(customPackages) > 0 { 113 + installCmd := "nix --extra-experimental-features nix-command --extra-experimental-features flakes profile install" 114 + cmd := fmt.Sprintf("%s %s", installCmd, strings.Join(customPackages, " ")) 115 + installStep := Step{ 116 + command: cmd, 117 + name: "Install custom dependencies", 118 + environment: map[string]string{ 119 + "NIX_NO_COLOR": "1", 120 + "NIX_SHOW_DOWNLOAD_PROGRESS": "0", 121 + }, 122 + } 123 + return &installStep 124 + } 125 + return nil 126 + }
+179 -13
spindle/ingester.go
··· 3 3 import ( 4 4 "context" 5 5 "encoding/json" 6 + "errors" 6 7 "fmt" 8 + "time" 7 9 8 10 "tangled.sh/tangled.sh/core/api/tangled" 9 11 "tangled.sh/tangled.sh/core/eventconsumer" 12 + "tangled.sh/tangled.sh/core/idresolver" 13 + "tangled.sh/tangled.sh/core/rbac" 14 + "tangled.sh/tangled.sh/core/spindle/db" 10 15 16 + comatproto "github.com/bluesky-social/indigo/api/atproto" 17 + "github.com/bluesky-social/indigo/atproto/identity" 18 + "github.com/bluesky-social/indigo/atproto/syntax" 19 + "github.com/bluesky-social/indigo/xrpc" 11 20 "github.com/bluesky-social/jetstream/pkg/models" 21 + securejoin "github.com/cyphar/filepath-securejoin" 12 22 ) 13 23 14 24 type Ingester func(ctx context.Context, e *models.Event) error ··· 30 40 31 41 switch e.Commit.Collection { 32 42 case tangled.SpindleMemberNSID: 33 - s.ingestMember(ctx, e) 43 + err = s.ingestMember(ctx, e) 34 44 case tangled.RepoNSID: 35 - s.ingestRepo(ctx, e) 45 + err = s.ingestRepo(ctx, e) 46 + case tangled.RepoCollaboratorNSID: 47 + err = s.ingestCollaborator(ctx, e) 36 48 } 37 49 38 - return err 50 + if err != nil { 51 + s.l.Debug("failed to process message", "nsid", e.Commit.Collection, "err", err) 52 + } 53 + 54 + return nil 39 55 } 40 56 } 41 57 42 58 func (s *Spindle) ingestMember(_ context.Context, e *models.Event) error { 43 - did := e.Did 44 59 var err error 60 + did := e.Did 61 + rkey := e.Commit.RKey 45 62 46 63 l := s.l.With("component", "ingester", "record", tangled.SpindleMemberNSID) 47 64 ··· 56 73 } 57 74 58 75 domain := s.cfg.Server.Hostname 59 - if s.cfg.Server.Dev { 60 - domain = s.cfg.Server.ListenAddr 61 - } 62 76 recordInstance := record.Instance 63 77 64 78 if recordInstance != domain { ··· 66 80 return fmt.Errorf("domain mismatch: %s != %s", record.Instance, domain) 67 81 } 68 82 69 - ok, err := s.e.E.Enforce(did, rbacDomain, rbacDomain, "server:invite") 83 + ok, err := s.e.IsSpindleInviteAllowed(did, rbacDomain) 70 84 if err != nil || !ok { 71 - l.Error("failed to add member", "did", did) 85 + l.Error("failed to add member", "did", did, "error", err) 72 86 return fmt.Errorf("failed to enforce permissions: %w", err) 73 87 } 74 88 75 - if err := s.e.AddKnotMember(rbacDomain, record.Subject); err != nil { 89 + if err := db.AddSpindleMember(s.db, db.SpindleMember{ 90 + Did: syntax.DID(did), 91 + Rkey: rkey, 92 + Instance: recordInstance, 93 + Subject: syntax.DID(record.Subject), 94 + Created: time.Now(), 95 + }); err != nil { 96 + l.Error("failed to add member", "error", err) 97 + return fmt.Errorf("failed to add member: %w", err) 98 + } 99 + 100 + if err := s.e.AddSpindleMember(rbacDomain, record.Subject); err != nil { 76 101 l.Error("failed to add member", "error", err) 77 102 return fmt.Errorf("failed to add member: %w", err) 78 103 } 79 104 l.Info("added member from firehose", "member", record.Subject) 80 105 81 - if err := s.db.AddDid(did); err != nil { 106 + if err := s.db.AddDid(record.Subject); err != nil { 82 107 l.Error("failed to add did", "error", err) 83 108 return fmt.Errorf("failed to add did: %w", err) 84 109 } 85 - s.jc.AddDid(did) 110 + s.jc.AddDid(record.Subject) 86 111 87 112 return nil 88 113 114 + case models.CommitOperationDelete: 115 + record, err := db.GetSpindleMember(s.db, did, rkey) 116 + if err != nil { 117 + l.Error("failed to find member", "error", err) 118 + return fmt.Errorf("failed to find member: %w", err) 119 + } 120 + 121 + if err := db.RemoveSpindleMember(s.db, did, rkey); err != nil { 122 + l.Error("failed to remove member", "error", err) 123 + return fmt.Errorf("failed to remove member: %w", err) 124 + } 125 + 126 + if err := s.e.RemoveSpindleMember(rbacDomain, record.Subject.String()); err != nil { 127 + l.Error("failed to add member", "error", err) 128 + return fmt.Errorf("failed to add member: %w", err) 129 + } 130 + l.Info("added member from firehose", "member", record.Subject) 131 + 132 + if err := s.db.RemoveDid(record.Subject.String()); err != nil { 133 + l.Error("failed to add did", "error", err) 134 + return fmt.Errorf("failed to add did: %w", err) 135 + } 136 + s.jc.RemoveDid(record.Subject.String()) 137 + 89 138 } 90 139 return nil 91 140 } 92 141 93 - func (s *Spindle) ingestRepo(_ context.Context, e *models.Event) error { 142 + func (s *Spindle) ingestRepo(ctx context.Context, e *models.Event) error { 94 143 var err error 144 + did := e.Did 145 + resolver := idresolver.DefaultResolver() 95 146 96 147 l := s.l.With("component", "ingester", "record", tangled.RepoNSID) 97 148 ··· 127 178 return fmt.Errorf("failed to add repo: %w", err) 128 179 } 129 180 181 + didSlashRepo, err := securejoin.SecureJoin(record.Owner, record.Name) 182 + if err != nil { 183 + return err 184 + } 185 + 186 + // add repo to rbac 187 + if err := s.e.AddRepo(record.Owner, rbac.ThisServer, didSlashRepo); err != nil { 188 + l.Error("failed to add repo to enforcer", "error", err) 189 + return fmt.Errorf("failed to add repo: %w", err) 190 + } 191 + 192 + // add collaborators to rbac 193 + owner, err := resolver.ResolveIdent(ctx, did) 194 + if err != nil || owner.Handle.IsInvalidHandle() { 195 + return err 196 + } 197 + if err := s.fetchAndAddCollaborators(ctx, owner, didSlashRepo); err != nil { 198 + return err 199 + } 200 + 130 201 // add this knot to the event consumer 131 202 src := eventconsumer.NewKnotSource(record.Knot) 132 203 s.ks.AddSource(context.Background(), src) ··· 136 207 } 137 208 return nil 138 209 } 210 + 211 + func (s *Spindle) ingestCollaborator(ctx context.Context, e *models.Event) error { 212 + var err error 213 + 214 + l := s.l.With("component", "ingester", "record", tangled.RepoCollaboratorNSID, "did", e.Did) 215 + 216 + l.Info("ingesting collaborator record") 217 + 218 + switch e.Commit.Operation { 219 + case models.CommitOperationCreate, models.CommitOperationUpdate: 220 + raw := e.Commit.Record 221 + record := tangled.RepoCollaborator{} 222 + err = json.Unmarshal(raw, &record) 223 + if err != nil { 224 + l.Error("invalid record", "error", err) 225 + return err 226 + } 227 + 228 + resolver := idresolver.DefaultResolver() 229 + 230 + subjectId, err := resolver.ResolveIdent(ctx, record.Subject) 231 + if err != nil || subjectId.Handle.IsInvalidHandle() { 232 + return err 233 + } 234 + 235 + repoAt, err := syntax.ParseATURI(record.Repo) 236 + if err != nil { 237 + l.Info("rejecting record, invalid repoAt", "repoAt", record.Repo) 238 + return nil 239 + } 240 + 241 + // TODO: get rid of this entirely 242 + // resolve this aturi to extract the repo record 243 + owner, err := resolver.ResolveIdent(ctx, repoAt.Authority().String()) 244 + if err != nil || owner.Handle.IsInvalidHandle() { 245 + return fmt.Errorf("failed to resolve handle: %w", err) 246 + } 247 + 248 + xrpcc := xrpc.Client{ 249 + Host: owner.PDSEndpoint(), 250 + } 251 + 252 + resp, err := comatproto.RepoGetRecord(ctx, &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 253 + if err != nil { 254 + return err 255 + } 256 + 257 + repo := resp.Value.Val.(*tangled.Repo) 258 + didSlashRepo, _ := securejoin.SecureJoin(owner.DID.String(), repo.Name) 259 + 260 + // check perms for this user 261 + if ok, err := s.e.IsCollaboratorInviteAllowed(owner.DID.String(), rbac.ThisServer, didSlashRepo); !ok || err != nil { 262 + return fmt.Errorf("insufficient permissions: %w", err) 263 + } 264 + 265 + // add collaborator to rbac 266 + if err := s.e.AddCollaborator(record.Subject, rbac.ThisServer, didSlashRepo); err != nil { 267 + l.Error("failed to add repo to enforcer", "error", err) 268 + return fmt.Errorf("failed to add repo: %w", err) 269 + } 270 + 271 + return nil 272 + } 273 + return nil 274 + } 275 + 276 + func (s *Spindle) fetchAndAddCollaborators(ctx context.Context, owner *identity.Identity, didSlashRepo string) error { 277 + l := s.l.With("component", "ingester", "handler", "fetchAndAddCollaborators") 278 + 279 + l.Info("fetching and adding existing collaborators") 280 + 281 + xrpcc := xrpc.Client{ 282 + Host: owner.PDSEndpoint(), 283 + } 284 + 285 + resp, err := comatproto.RepoListRecords(ctx, &xrpcc, tangled.RepoCollaboratorNSID, "", 50, owner.DID.String(), false) 286 + if err != nil { 287 + return err 288 + } 289 + 290 + var errs error 291 + for _, r := range resp.Records { 292 + if r == nil { 293 + continue 294 + } 295 + record := r.Value.Val.(*tangled.RepoCollaborator) 296 + 297 + if err := s.e.AddCollaborator(record.Subject, rbac.ThisServer, didSlashRepo); err != nil { 298 + l.Error("failed to add repo to enforcer", "error", err) 299 + errors.Join(errs, fmt.Errorf("failed to add repo: %w", err)) 300 + } 301 + } 302 + 303 + return errs 304 + }
+17
spindle/models/engine.go
··· 1 + package models 2 + 3 + import ( 4 + "context" 5 + "time" 6 + 7 + "tangled.sh/tangled.sh/core/api/tangled" 8 + "tangled.sh/tangled.sh/core/spindle/secrets" 9 + ) 10 + 11 + type Engine interface { 12 + InitWorkflow(twf tangled.Pipeline_Workflow, tpl tangled.Pipeline) (*Workflow, error) 13 + SetupWorkflow(ctx context.Context, wid WorkflowId, wf *Workflow) error 14 + WorkflowTimeout() time.Duration 15 + DestroyWorkflow(ctx context.Context, wid WorkflowId) error 16 + RunStep(ctx context.Context, wid WorkflowId, w *Workflow, idx int, secrets []secrets.UnlockedSecret, wfLogger *WorkflowLogger) error 17 + }
+82
spindle/models/logger.go
··· 1 + package models 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "io" 7 + "os" 8 + "path/filepath" 9 + "strings" 10 + ) 11 + 12 + type WorkflowLogger struct { 13 + file *os.File 14 + encoder *json.Encoder 15 + } 16 + 17 + func NewWorkflowLogger(baseDir string, wid WorkflowId) (*WorkflowLogger, error) { 18 + path := LogFilePath(baseDir, wid) 19 + 20 + file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) 21 + if err != nil { 22 + return nil, fmt.Errorf("creating log file: %w", err) 23 + } 24 + 25 + return &WorkflowLogger{ 26 + file: file, 27 + encoder: json.NewEncoder(file), 28 + }, nil 29 + } 30 + 31 + func LogFilePath(baseDir string, workflowID WorkflowId) string { 32 + logFilePath := filepath.Join(baseDir, fmt.Sprintf("%s.log", workflowID.String())) 33 + return logFilePath 34 + } 35 + 36 + func (l *WorkflowLogger) Close() error { 37 + return l.file.Close() 38 + } 39 + 40 + func (l *WorkflowLogger) DataWriter(stream string) io.Writer { 41 + // TODO: emit stream 42 + return &dataWriter{ 43 + logger: l, 44 + stream: stream, 45 + } 46 + } 47 + 48 + func (l *WorkflowLogger) ControlWriter(idx int, step Step) io.Writer { 49 + return &controlWriter{ 50 + logger: l, 51 + idx: idx, 52 + step: step, 53 + } 54 + } 55 + 56 + type dataWriter struct { 57 + logger *WorkflowLogger 58 + stream string 59 + } 60 + 61 + func (w *dataWriter) Write(p []byte) (int, error) { 62 + line := strings.TrimRight(string(p), "\r\n") 63 + entry := NewDataLogLine(line, w.stream) 64 + if err := w.logger.encoder.Encode(entry); err != nil { 65 + return 0, err 66 + } 67 + return len(p), nil 68 + } 69 + 70 + type controlWriter struct { 71 + logger *WorkflowLogger 72 + idx int 73 + step Step 74 + } 75 + 76 + func (w *controlWriter) Write(_ []byte) (int, error) { 77 + entry := NewControlLogLine(w.idx, w.step) 78 + if err := w.logger.encoder.Encode(entry); err != nil { 79 + return 0, err 80 + } 81 + return len(w.step.Name()), nil 82 + }
+40
spindle/models/models.go
··· 70 70 func (s StatusKind) IsFinish() bool { 71 71 return slices.Contains(FinishStates[:], s) 72 72 } 73 + 74 + type LogKind string 75 + 76 + var ( 77 + // step log data 78 + LogKindData LogKind = "data" 79 + // indicates start/end of a step 80 + LogKindControl LogKind = "control" 81 + ) 82 + 83 + type LogLine struct { 84 + Kind LogKind `json:"kind"` 85 + Content string `json:"content"` 86 + 87 + // fields if kind is "data" 88 + Stream string `json:"stream,omitempty"` 89 + 90 + // fields if kind is "control" 91 + StepId int `json:"step_id,omitempty"` 92 + StepKind StepKind `json:"step_kind,omitempty"` 93 + StepCommand string `json:"step_command,omitempty"` 94 + } 95 + 96 + func NewDataLogLine(content, stream string) LogLine { 97 + return LogLine{ 98 + Kind: LogKindData, 99 + Content: content, 100 + Stream: stream, 101 + } 102 + } 103 + 104 + func NewControlLogLine(idx int, step Step) LogLine { 105 + return LogLine{ 106 + Kind: LogKindControl, 107 + Content: step.Name(), 108 + StepId: idx, 109 + StepKind: step.Kind(), 110 + StepCommand: step.Command(), 111 + } 112 + }
+18 -92
spindle/models/pipeline.go
··· 1 1 package models 2 2 3 - import ( 4 - "path" 5 - 6 - "tangled.sh/tangled.sh/core/api/tangled" 7 - "tangled.sh/tangled.sh/core/spindle/config" 8 - ) 9 - 10 3 type Pipeline struct { 11 - Workflows []Workflow 12 - } 13 - 14 - type Step struct { 15 - Command string 16 - Name string 17 - Environment map[string]string 18 - } 19 - 20 - type Workflow struct { 21 - Steps []Step 22 - Environment map[string]string 23 - Name string 24 - Image string 25 - } 26 - 27 - // setupSteps get added to start of Steps 28 - type setupSteps []Step 29 - 30 - // addStep adds a step to the beginning of the workflow's steps. 31 - func (ss *setupSteps) addStep(step Step) { 32 - *ss = append(*ss, step) 33 - } 34 - 35 - // ToPipeline converts a tangled.Pipeline into a model.Pipeline. 36 - // In the process, dependencies are resolved: nixpkgs deps 37 - // are constructed atop nixery and set as the Workflow.Image, 38 - // and ones from custom registries 39 - func ToPipeline(pl tangled.Pipeline, cfg config.Config) *Pipeline { 40 - workflows := []Workflow{} 41 - 42 - for _, twf := range pl.Workflows { 43 - swf := &Workflow{} 44 - for _, tstep := range twf.Steps { 45 - sstep := Step{} 46 - sstep.Environment = stepEnvToMap(tstep.Environment) 47 - sstep.Command = tstep.Command 48 - sstep.Name = tstep.Name 49 - swf.Steps = append(swf.Steps, sstep) 50 - } 51 - swf.Name = twf.Name 52 - swf.Environment = workflowEnvToMap(twf.Environment) 53 - swf.Image = workflowImage(twf.Dependencies, cfg.Pipelines.Nixery) 54 - 55 - swf.addNixProfileToPath() 56 - setup := &setupSteps{} 57 - 58 - setup.addStep(cloneStep(*twf, *pl.TriggerMetadata.Repo, cfg.Server.Dev)) 59 - setup.addStep(checkoutStep(*twf, *pl.TriggerMetadata)) 60 - setup.addStep(dependencyStep(*twf)) 61 - 62 - // append setup steps in order to the start of workflow steps 63 - swf.Steps = append(*setup, swf.Steps...) 64 - 65 - workflows = append(workflows, *swf) 66 - } 67 - return &Pipeline{Workflows: workflows} 4 + RepoOwner string 5 + RepoName string 6 + Workflows map[Engine][]Workflow 68 7 } 69 8 70 - func workflowEnvToMap(envs []*tangled.Pipeline_Workflow_Environment_Elem) map[string]string { 71 - envMap := map[string]string{} 72 - for _, env := range envs { 73 - envMap[env.Key] = env.Value 74 - } 75 - return envMap 9 + type Step interface { 10 + Name() string 11 + Command() string 12 + Kind() StepKind 76 13 } 77 14 78 - func stepEnvToMap(envs []*tangled.Pipeline_Step_Environment_Elem) map[string]string { 79 - envMap := map[string]string{} 80 - for _, env := range envs { 81 - envMap[env.Key] = env.Value 82 - } 83 - return envMap 84 - } 15 + type StepKind int 85 16 86 - func workflowImage(deps []tangled.Pipeline_Dependencies_Elem, nixery string) string { 87 - var dependencies string 88 - for _, d := range deps { 89 - if d.Registry == "nixpkgs" { 90 - dependencies = path.Join(d.Packages...) 91 - } 92 - } 17 + const ( 18 + // steps injected by the CI runner 19 + StepKindSystem StepKind = iota 20 + // steps defined by the user in the original pipeline 21 + StepKindUser 22 + ) 93 23 94 - // load defaults from somewhere else 95 - dependencies = path.Join(dependencies, "bash", "git", "coreutils", "nix") 96 - 97 - return path.Join(nixery, dependencies) 98 - } 99 - 100 - func (wf *Workflow) addNixProfileToPath() { 101 - wf.Environment["PATH"] = "$PATH:/.nix-profile/bin" 24 + type Workflow struct { 25 + Steps []Step 26 + Name string 27 + Data any 102 28 }
-108
spindle/models/setup_steps.go
··· 1 - package models 2 - 3 - import ( 4 - "fmt" 5 - "path" 6 - "strings" 7 - 8 - "tangled.sh/tangled.sh/core/api/tangled" 9 - ) 10 - 11 - // checkoutStep checks out the specified ref in the cloned repository. 12 - func checkoutStep(twf tangled.Pipeline_Workflow, tr tangled.Pipeline_TriggerMetadata) Step { 13 - if twf.Clone.Skip { 14 - return Step{} 15 - } 16 - 17 - var ref string 18 - switch tr.Kind { 19 - case "push": 20 - ref = tr.Push.Ref 21 - case "pull_request": 22 - ref = tr.PullRequest.TargetBranch 23 - 24 - // TODO: this needs to be specified in lexicon 25 - case "manual": 26 - ref = tr.Repo.DefaultBranch 27 - } 28 - 29 - checkoutCmd := fmt.Sprintf("git config advice.detachedHead false; git checkout --progress --force %s", ref) 30 - 31 - return Step{ 32 - Command: checkoutCmd, 33 - Name: "Checkout ref " + ref, 34 - } 35 - } 36 - 37 - // cloneOptsAsSteps processes clone options and adds corresponding steps 38 - // to the beginning of the workflow's step list if cloning is not skipped. 39 - func cloneStep(twf tangled.Pipeline_Workflow, tr tangled.Pipeline_TriggerRepo, dev bool) Step { 40 - if twf.Clone.Skip { 41 - return Step{} 42 - } 43 - 44 - uri := "https://" 45 - if dev { 46 - uri = "http://" 47 - tr.Knot = strings.ReplaceAll(tr.Knot, "localhost", "host.docker.internal") 48 - } 49 - 50 - cloneUrl := uri + path.Join(tr.Knot, tr.Did, tr.Repo) 51 - cloneCmd := []string{"git", "clone", cloneUrl, "."} 52 - 53 - // default clone depth is 1 54 - cloneDepth := 1 55 - if twf.Clone.Depth > 1 { 56 - cloneDepth = int(twf.Clone.Depth) 57 - cloneCmd = append(cloneCmd, []string{"--depth", fmt.Sprintf("%d", cloneDepth)}...) 58 - } 59 - 60 - if twf.Clone.Submodules { 61 - cloneCmd = append(cloneCmd, "--recursive") 62 - } 63 - 64 - fmt.Println(strings.Join(cloneCmd, " ")) 65 - 66 - cloneStep := Step{ 67 - Command: strings.Join(cloneCmd, " "), 68 - Name: "Clone repository into workspace", 69 - } 70 - return cloneStep 71 - } 72 - 73 - // dependencyStep processes dependencies defined in the workflow. 74 - // For dependencies using a custom registry (i.e. not nixpkgs), it collects 75 - // all packages and adds a single 'nix profile install' step to the 76 - // beginning of the workflow's step list. 77 - func dependencyStep(twf tangled.Pipeline_Workflow) Step { 78 - var customPackages []string 79 - 80 - for _, d := range twf.Dependencies { 81 - registry := d.Registry 82 - packages := d.Packages 83 - 84 - if registry == "nixpkgs" { 85 - continue 86 - } 87 - 88 - // collect packages from custom registries 89 - for _, pkg := range packages { 90 - customPackages = append(customPackages, fmt.Sprintf("'%s#%s'", registry, pkg)) 91 - } 92 - } 93 - 94 - if len(customPackages) > 0 { 95 - installCmd := "nix --extra-experimental-features nix-command --extra-experimental-features flakes profile install" 96 - cmd := fmt.Sprintf("%s %s", installCmd, strings.Join(customPackages, " ")) 97 - installStep := Step{ 98 - Command: cmd, 99 - Name: "Install custom dependencies", 100 - Environment: map[string]string{ 101 - "NIX_NO_COLOR": "1", 102 - "NIX_SHOW_DOWNLOAD_PROGRESS": "0", 103 - }, 104 - } 105 - return installStep 106 - } 107 - return Step{} 108 - }
+25
spindle/motd
··· 1 + **** 2 + *** *** 3 + *** ** ****** ** 4 + ** * ***** 5 + * ** ** 6 + * * * *************** 7 + ** ** *# ** 8 + * ** ** *** ** 9 + * * ** ** * ****** 10 + * ** ** * ** * * 11 + ** ** *** ** ** * 12 + ** ** * ** * * 13 + ** **** ** * * 14 + ** *** ** ** ** 15 + *** ** ***** 16 + ******************** 17 + ** 18 + * 19 + #************** 20 + ** 21 + ******** 22 + 23 + This is a spindle server. More info at https://tangled.sh/@tangled.sh/core/tree/master/docs/spindle 24 + 25 + Most API routes are under /xrpc/
+70
spindle/secrets/manager.go
··· 1 + package secrets 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "regexp" 7 + "time" 8 + 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + ) 11 + 12 + type DidSlashRepo string 13 + 14 + type Secret[T any] struct { 15 + Key string 16 + Value T 17 + Repo DidSlashRepo 18 + CreatedAt time.Time 19 + CreatedBy syntax.DID 20 + } 21 + 22 + // the secret is not present 23 + type LockedSecret = Secret[struct{}] 24 + 25 + // the secret is present in plaintext, never expose this publicly, 26 + // only use in the workflow engine 27 + type UnlockedSecret = Secret[string] 28 + 29 + type Manager interface { 30 + AddSecret(ctx context.Context, secret UnlockedSecret) error 31 + RemoveSecret(ctx context.Context, secret Secret[any]) error 32 + GetSecretsLocked(ctx context.Context, repo DidSlashRepo) ([]LockedSecret, error) 33 + GetSecretsUnlocked(ctx context.Context, repo DidSlashRepo) ([]UnlockedSecret, error) 34 + } 35 + 36 + // stopper interface for managers that need cleanup 37 + type Stopper interface { 38 + Stop() 39 + } 40 + 41 + var ErrKeyAlreadyPresent = errors.New("key already present") 42 + var ErrInvalidKeyIdent = errors.New("key is not a valid identifier") 43 + var ErrKeyNotFound = errors.New("key not found") 44 + 45 + // ensure that we are satisfying the interface 46 + var ( 47 + _ = []Manager{ 48 + &SqliteManager{}, 49 + &OpenBaoManager{}, 50 + } 51 + ) 52 + 53 + var ( 54 + // bash identifier syntax 55 + keyIdent = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`) 56 + ) 57 + 58 + func isValidKey(key string) bool { 59 + if key == "" { 60 + return false 61 + } 62 + return keyIdent.MatchString(key) 63 + } 64 + 65 + func ValidateKey(key string) error { 66 + if !isValidKey(key) { 67 + return ErrInvalidKeyIdent 68 + } 69 + return nil 70 + }
+313
spindle/secrets/openbao.go
··· 1 + package secrets 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "log/slog" 7 + "path" 8 + "strings" 9 + "time" 10 + 11 + "github.com/bluesky-social/indigo/atproto/syntax" 12 + vault "github.com/openbao/openbao/api/v2" 13 + ) 14 + 15 + type OpenBaoManager struct { 16 + client *vault.Client 17 + mountPath string 18 + logger *slog.Logger 19 + } 20 + 21 + type OpenBaoManagerOpt func(*OpenBaoManager) 22 + 23 + func WithMountPath(mountPath string) OpenBaoManagerOpt { 24 + return func(v *OpenBaoManager) { 25 + v.mountPath = mountPath 26 + } 27 + } 28 + 29 + // NewOpenBaoManager creates a new OpenBao manager that connects to a Bao Proxy 30 + // The proxyAddress should point to the local Bao Proxy (e.g., "http://127.0.0.1:8200") 31 + // The proxy handles all authentication automatically via Auto-Auth 32 + func NewOpenBaoManager(proxyAddress string, logger *slog.Logger, opts ...OpenBaoManagerOpt) (*OpenBaoManager, error) { 33 + if proxyAddress == "" { 34 + return nil, fmt.Errorf("proxy address cannot be empty") 35 + } 36 + 37 + config := vault.DefaultConfig() 38 + config.Address = proxyAddress 39 + 40 + client, err := vault.NewClient(config) 41 + if err != nil { 42 + return nil, fmt.Errorf("failed to create openbao client: %w", err) 43 + } 44 + 45 + manager := &OpenBaoManager{ 46 + client: client, 47 + mountPath: "spindle", // default KV v2 mount path 48 + logger: logger, 49 + } 50 + 51 + for _, opt := range opts { 52 + opt(manager) 53 + } 54 + 55 + if err := manager.testConnection(); err != nil { 56 + return nil, fmt.Errorf("failed to connect to bao proxy: %w", err) 57 + } 58 + 59 + logger.Info("successfully connected to bao proxy", "address", proxyAddress) 60 + return manager, nil 61 + } 62 + 63 + // testConnection verifies that we can connect to the proxy 64 + func (v *OpenBaoManager) testConnection() error { 65 + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 66 + defer cancel() 67 + 68 + // try token self-lookup as a quick way to verify proxy works 69 + // and is authenticated 70 + _, err := v.client.Auth().Token().LookupSelfWithContext(ctx) 71 + if err != nil { 72 + return fmt.Errorf("proxy connection test failed: %w", err) 73 + } 74 + 75 + return nil 76 + } 77 + 78 + func (v *OpenBaoManager) AddSecret(ctx context.Context, secret UnlockedSecret) error { 79 + if err := ValidateKey(secret.Key); err != nil { 80 + return err 81 + } 82 + 83 + secretPath := v.buildSecretPath(secret.Repo, secret.Key) 84 + v.logger.Debug("adding secret", "repo", secret.Repo, "key", secret.Key, "path", secretPath) 85 + 86 + // Check if secret already exists 87 + existing, err := v.client.KVv2(v.mountPath).Get(ctx, secretPath) 88 + if err == nil && existing != nil { 89 + v.logger.Debug("secret already exists", "path", secretPath) 90 + return ErrKeyAlreadyPresent 91 + } 92 + 93 + secretData := map[string]interface{}{ 94 + "value": secret.Value, 95 + "repo": string(secret.Repo), 96 + "key": secret.Key, 97 + "created_at": secret.CreatedAt.Format(time.RFC3339), 98 + "created_by": secret.CreatedBy.String(), 99 + } 100 + 101 + v.logger.Debug("writing secret to openbao", "path", secretPath, "mount", v.mountPath) 102 + resp, err := v.client.KVv2(v.mountPath).Put(ctx, secretPath, secretData) 103 + if err != nil { 104 + v.logger.Error("failed to write secret", "path", secretPath, "error", err) 105 + return fmt.Errorf("failed to store secret in openbao: %w", err) 106 + } 107 + 108 + v.logger.Debug("secret write response", "version", resp.VersionMetadata.Version, "created_time", resp.VersionMetadata.CreatedTime) 109 + 110 + v.logger.Debug("verifying secret was written", "path", secretPath) 111 + readBack, err := v.client.KVv2(v.mountPath).Get(ctx, secretPath) 112 + if err != nil { 113 + v.logger.Error("failed to verify secret after write", "path", secretPath, "error", err) 114 + return fmt.Errorf("secret not found after writing to %s/%s: %w", v.mountPath, secretPath, err) 115 + } 116 + 117 + if readBack == nil || readBack.Data == nil { 118 + v.logger.Error("secret verification returned empty data", "path", secretPath) 119 + return fmt.Errorf("secret verification failed: empty data returned for %s/%s", v.mountPath, secretPath) 120 + } 121 + 122 + v.logger.Info("secret added and verified successfully", "repo", secret.Repo, "key", secret.Key, "version", readBack.VersionMetadata.Version) 123 + return nil 124 + } 125 + 126 + func (v *OpenBaoManager) RemoveSecret(ctx context.Context, secret Secret[any]) error { 127 + secretPath := v.buildSecretPath(secret.Repo, secret.Key) 128 + 129 + // check if secret exists 130 + existing, err := v.client.KVv2(v.mountPath).Get(ctx, secretPath) 131 + if err != nil || existing == nil { 132 + return ErrKeyNotFound 133 + } 134 + 135 + err = v.client.KVv2(v.mountPath).DeleteMetadata(ctx, secretPath) 136 + if err != nil { 137 + return fmt.Errorf("failed to delete secret from openbao: %w", err) 138 + } 139 + 140 + v.logger.Debug("secret removed successfully", "repo", secret.Repo, "key", secret.Key) 141 + return nil 142 + } 143 + 144 + func (v *OpenBaoManager) GetSecretsLocked(ctx context.Context, repo DidSlashRepo) ([]LockedSecret, error) { 145 + repoPath := v.buildRepoPath(repo) 146 + 147 + secretsList, err := v.client.Logical().ListWithContext(ctx, fmt.Sprintf("%s/metadata/%s", v.mountPath, repoPath)) 148 + if err != nil { 149 + if strings.Contains(err.Error(), "no secret found") || strings.Contains(err.Error(), "no handler for route") { 150 + return []LockedSecret{}, nil 151 + } 152 + return nil, fmt.Errorf("failed to list secrets: %w", err) 153 + } 154 + 155 + if secretsList == nil || secretsList.Data == nil { 156 + return []LockedSecret{}, nil 157 + } 158 + 159 + keys, ok := secretsList.Data["keys"].([]interface{}) 160 + if !ok { 161 + return []LockedSecret{}, nil 162 + } 163 + 164 + var secrets []LockedSecret 165 + 166 + for _, keyInterface := range keys { 167 + key, ok := keyInterface.(string) 168 + if !ok { 169 + continue 170 + } 171 + 172 + secretPath := fmt.Sprintf("%s/%s", repoPath, key) 173 + secretData, err := v.client.KVv2(v.mountPath).Get(ctx, secretPath) 174 + if err != nil { 175 + v.logger.Warn("failed to read secret metadata", "path", secretPath, "error", err) 176 + continue 177 + } 178 + 179 + if secretData == nil || secretData.Data == nil { 180 + continue 181 + } 182 + 183 + data := secretData.Data 184 + 185 + createdAtStr, ok := data["created_at"].(string) 186 + if !ok { 187 + createdAtStr = time.Now().Format(time.RFC3339) 188 + } 189 + 190 + createdAt, err := time.Parse(time.RFC3339, createdAtStr) 191 + if err != nil { 192 + createdAt = time.Now() 193 + } 194 + 195 + createdByStr, ok := data["created_by"].(string) 196 + if !ok { 197 + createdByStr = "" 198 + } 199 + 200 + keyStr, ok := data["key"].(string) 201 + if !ok { 202 + keyStr = key 203 + } 204 + 205 + secret := LockedSecret{ 206 + Key: keyStr, 207 + Repo: repo, 208 + CreatedAt: createdAt, 209 + CreatedBy: syntax.DID(createdByStr), 210 + } 211 + 212 + secrets = append(secrets, secret) 213 + } 214 + 215 + v.logger.Debug("retrieved locked secrets", "repo", repo, "count", len(secrets)) 216 + return secrets, nil 217 + } 218 + 219 + func (v *OpenBaoManager) GetSecretsUnlocked(ctx context.Context, repo DidSlashRepo) ([]UnlockedSecret, error) { 220 + repoPath := v.buildRepoPath(repo) 221 + 222 + secretsList, err := v.client.Logical().ListWithContext(ctx, fmt.Sprintf("%s/metadata/%s", v.mountPath, repoPath)) 223 + if err != nil { 224 + if strings.Contains(err.Error(), "no secret found") || strings.Contains(err.Error(), "no handler for route") { 225 + return []UnlockedSecret{}, nil 226 + } 227 + return nil, fmt.Errorf("failed to list secrets: %w", err) 228 + } 229 + 230 + if secretsList == nil || secretsList.Data == nil { 231 + return []UnlockedSecret{}, nil 232 + } 233 + 234 + keys, ok := secretsList.Data["keys"].([]interface{}) 235 + if !ok { 236 + return []UnlockedSecret{}, nil 237 + } 238 + 239 + var secrets []UnlockedSecret 240 + 241 + for _, keyInterface := range keys { 242 + key, ok := keyInterface.(string) 243 + if !ok { 244 + continue 245 + } 246 + 247 + secretPath := fmt.Sprintf("%s/%s", repoPath, key) 248 + secretData, err := v.client.KVv2(v.mountPath).Get(ctx, secretPath) 249 + if err != nil { 250 + v.logger.Warn("failed to read secret", "path", secretPath, "error", err) 251 + continue 252 + } 253 + 254 + if secretData == nil || secretData.Data == nil { 255 + continue 256 + } 257 + 258 + data := secretData.Data 259 + 260 + valueStr, ok := data["value"].(string) 261 + if !ok { 262 + v.logger.Warn("secret missing value", "path", secretPath) 263 + continue 264 + } 265 + 266 + createdAtStr, ok := data["created_at"].(string) 267 + if !ok { 268 + createdAtStr = time.Now().Format(time.RFC3339) 269 + } 270 + 271 + createdAt, err := time.Parse(time.RFC3339, createdAtStr) 272 + if err != nil { 273 + createdAt = time.Now() 274 + } 275 + 276 + createdByStr, ok := data["created_by"].(string) 277 + if !ok { 278 + createdByStr = "" 279 + } 280 + 281 + keyStr, ok := data["key"].(string) 282 + if !ok { 283 + keyStr = key 284 + } 285 + 286 + secret := UnlockedSecret{ 287 + Key: keyStr, 288 + Value: valueStr, 289 + Repo: repo, 290 + CreatedAt: createdAt, 291 + CreatedBy: syntax.DID(createdByStr), 292 + } 293 + 294 + secrets = append(secrets, secret) 295 + } 296 + 297 + v.logger.Debug("retrieved unlocked secrets", "repo", repo, "count", len(secrets)) 298 + return secrets, nil 299 + } 300 + 301 + // buildRepoPath creates a safe path for a repository 302 + func (v *OpenBaoManager) buildRepoPath(repo DidSlashRepo) string { 303 + // convert DidSlashRepo to a safe path by replacing special characters 304 + repoPath := strings.ReplaceAll(string(repo), "/", "_") 305 + repoPath = strings.ReplaceAll(repoPath, ":", "_") 306 + repoPath = strings.ReplaceAll(repoPath, ".", "_") 307 + return fmt.Sprintf("repos/%s", repoPath) 308 + } 309 + 310 + // buildSecretPath creates a path for a specific secret 311 + func (v *OpenBaoManager) buildSecretPath(repo DidSlashRepo, key string) string { 312 + return path.Join(v.buildRepoPath(repo), key) 313 + }
+605
spindle/secrets/openbao_test.go
··· 1 + package secrets 2 + 3 + import ( 4 + "context" 5 + "log/slog" 6 + "os" 7 + "testing" 8 + "time" 9 + 10 + "github.com/bluesky-social/indigo/atproto/syntax" 11 + "github.com/stretchr/testify/assert" 12 + ) 13 + 14 + // MockOpenBaoManager is a mock implementation of Manager interface for testing 15 + type MockOpenBaoManager struct { 16 + secrets map[string]UnlockedSecret // key: repo_key format 17 + shouldError bool 18 + errorToReturn error 19 + } 20 + 21 + func NewMockOpenBaoManager() *MockOpenBaoManager { 22 + return &MockOpenBaoManager{secrets: make(map[string]UnlockedSecret)} 23 + } 24 + 25 + func (m *MockOpenBaoManager) SetError(err error) { 26 + m.shouldError = true 27 + m.errorToReturn = err 28 + } 29 + 30 + func (m *MockOpenBaoManager) ClearError() { 31 + m.shouldError = false 32 + m.errorToReturn = nil 33 + } 34 + 35 + func (m *MockOpenBaoManager) buildKey(repo DidSlashRepo, key string) string { 36 + return string(repo) + "_" + key 37 + } 38 + 39 + func (m *MockOpenBaoManager) AddSecret(ctx context.Context, secret UnlockedSecret) error { 40 + if m.shouldError { 41 + return m.errorToReturn 42 + } 43 + 44 + key := m.buildKey(secret.Repo, secret.Key) 45 + if _, exists := m.secrets[key]; exists { 46 + return ErrKeyAlreadyPresent 47 + } 48 + 49 + m.secrets[key] = secret 50 + return nil 51 + } 52 + 53 + func (m *MockOpenBaoManager) RemoveSecret(ctx context.Context, secret Secret[any]) error { 54 + if m.shouldError { 55 + return m.errorToReturn 56 + } 57 + 58 + key := m.buildKey(secret.Repo, secret.Key) 59 + if _, exists := m.secrets[key]; !exists { 60 + return ErrKeyNotFound 61 + } 62 + 63 + delete(m.secrets, key) 64 + return nil 65 + } 66 + 67 + func (m *MockOpenBaoManager) GetSecretsLocked(ctx context.Context, repo DidSlashRepo) ([]LockedSecret, error) { 68 + if m.shouldError { 69 + return nil, m.errorToReturn 70 + } 71 + 72 + var result []LockedSecret 73 + for _, secret := range m.secrets { 74 + if secret.Repo == repo { 75 + result = append(result, LockedSecret{ 76 + Key: secret.Key, 77 + Repo: secret.Repo, 78 + CreatedAt: secret.CreatedAt, 79 + CreatedBy: secret.CreatedBy, 80 + }) 81 + } 82 + } 83 + 84 + return result, nil 85 + } 86 + 87 + func (m *MockOpenBaoManager) GetSecretsUnlocked(ctx context.Context, repo DidSlashRepo) ([]UnlockedSecret, error) { 88 + if m.shouldError { 89 + return nil, m.errorToReturn 90 + } 91 + 92 + var result []UnlockedSecret 93 + for _, secret := range m.secrets { 94 + if secret.Repo == repo { 95 + result = append(result, secret) 96 + } 97 + } 98 + 99 + return result, nil 100 + } 101 + 102 + func createTestSecretForOpenBao(repo, key, value, createdBy string) UnlockedSecret { 103 + return UnlockedSecret{ 104 + Key: key, 105 + Value: value, 106 + Repo: DidSlashRepo(repo), 107 + CreatedAt: time.Now(), 108 + CreatedBy: syntax.DID(createdBy), 109 + } 110 + } 111 + 112 + // Test MockOpenBaoManager interface compliance 113 + func TestMockOpenBaoManagerInterface(t *testing.T) { 114 + var _ Manager = (*MockOpenBaoManager)(nil) 115 + } 116 + 117 + func TestOpenBaoManagerInterface(t *testing.T) { 118 + var _ Manager = (*OpenBaoManager)(nil) 119 + } 120 + 121 + func TestNewOpenBaoManager(t *testing.T) { 122 + tests := []struct { 123 + name string 124 + proxyAddr string 125 + opts []OpenBaoManagerOpt 126 + expectError bool 127 + errorContains string 128 + }{ 129 + { 130 + name: "empty proxy address", 131 + proxyAddr: "", 132 + opts: nil, 133 + expectError: true, 134 + errorContains: "proxy address cannot be empty", 135 + }, 136 + { 137 + name: "valid proxy address", 138 + proxyAddr: "http://localhost:8200", 139 + opts: nil, 140 + expectError: true, // Will fail because no real proxy is running 141 + errorContains: "failed to connect to bao proxy", 142 + }, 143 + { 144 + name: "with mount path option", 145 + proxyAddr: "http://localhost:8200", 146 + opts: []OpenBaoManagerOpt{WithMountPath("custom-mount")}, 147 + expectError: true, // Will fail because no real proxy is running 148 + errorContains: "failed to connect to bao proxy", 149 + }, 150 + } 151 + 152 + for _, tt := range tests { 153 + t.Run(tt.name, func(t *testing.T) { 154 + logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) 155 + manager, err := NewOpenBaoManager(tt.proxyAddr, logger, tt.opts...) 156 + 157 + if tt.expectError { 158 + assert.Error(t, err) 159 + assert.Nil(t, manager) 160 + assert.Contains(t, err.Error(), tt.errorContains) 161 + } else { 162 + assert.NoError(t, err) 163 + assert.NotNil(t, manager) 164 + } 165 + }) 166 + } 167 + } 168 + 169 + func TestOpenBaoManager_PathBuilding(t *testing.T) { 170 + manager := &OpenBaoManager{mountPath: "secret"} 171 + 172 + tests := []struct { 173 + name string 174 + repo DidSlashRepo 175 + key string 176 + expected string 177 + }{ 178 + { 179 + name: "simple repo path", 180 + repo: DidSlashRepo("did:plc:foo/repo"), 181 + key: "api_key", 182 + expected: "repos/did_plc_foo_repo/api_key", 183 + }, 184 + { 185 + name: "complex repo path with dots", 186 + repo: DidSlashRepo("did:web:example.com/my-repo"), 187 + key: "secret_key", 188 + expected: "repos/did_web_example_com_my-repo/secret_key", 189 + }, 190 + } 191 + 192 + for _, tt := range tests { 193 + t.Run(tt.name, func(t *testing.T) { 194 + result := manager.buildSecretPath(tt.repo, tt.key) 195 + assert.Equal(t, tt.expected, result) 196 + }) 197 + } 198 + } 199 + 200 + func TestOpenBaoManager_buildRepoPath(t *testing.T) { 201 + manager := &OpenBaoManager{mountPath: "test"} 202 + 203 + tests := []struct { 204 + name string 205 + repo DidSlashRepo 206 + expected string 207 + }{ 208 + { 209 + name: "simple repo", 210 + repo: "did:plc:test/myrepo", 211 + expected: "repos/did_plc_test_myrepo", 212 + }, 213 + { 214 + name: "repo with dots", 215 + repo: "did:plc:example.com/my.repo", 216 + expected: "repos/did_plc_example_com_my_repo", 217 + }, 218 + { 219 + name: "complex repo", 220 + repo: "did:web:example.com:8080/path/to/repo", 221 + expected: "repos/did_web_example_com_8080_path_to_repo", 222 + }, 223 + } 224 + 225 + for _, tt := range tests { 226 + t.Run(tt.name, func(t *testing.T) { 227 + result := manager.buildRepoPath(tt.repo) 228 + assert.Equal(t, tt.expected, result) 229 + }) 230 + } 231 + } 232 + 233 + func TestWithMountPath(t *testing.T) { 234 + manager := &OpenBaoManager{mountPath: "default"} 235 + 236 + opt := WithMountPath("custom-mount") 237 + opt(manager) 238 + 239 + assert.Equal(t, "custom-mount", manager.mountPath) 240 + } 241 + 242 + func TestMockOpenBaoManager_AddSecret(t *testing.T) { 243 + tests := []struct { 244 + name string 245 + secrets []UnlockedSecret 246 + expectError bool 247 + }{ 248 + { 249 + name: "add single secret", 250 + secrets: []UnlockedSecret{ 251 + createTestSecretForOpenBao("did:plc:test/repo1", "API_KEY", "secret123", "did:plc:creator"), 252 + }, 253 + expectError: false, 254 + }, 255 + { 256 + name: "add multiple secrets", 257 + secrets: []UnlockedSecret{ 258 + createTestSecretForOpenBao("did:plc:test/repo1", "API_KEY", "secret123", "did:plc:creator"), 259 + createTestSecretForOpenBao("did:plc:test/repo1", "DB_PASSWORD", "dbpass456", "did:plc:creator"), 260 + }, 261 + expectError: false, 262 + }, 263 + { 264 + name: "add duplicate secret", 265 + secrets: []UnlockedSecret{ 266 + createTestSecretForOpenBao("did:plc:test/repo1", "API_KEY", "secret123", "did:plc:creator"), 267 + createTestSecretForOpenBao("did:plc:test/repo1", "API_KEY", "newsecret", "did:plc:creator"), 268 + }, 269 + expectError: true, 270 + }, 271 + } 272 + 273 + for _, tt := range tests { 274 + t.Run(tt.name, func(t *testing.T) { 275 + mock := NewMockOpenBaoManager() 276 + ctx := context.Background() 277 + var err error 278 + 279 + for i, secret := range tt.secrets { 280 + err = mock.AddSecret(ctx, secret) 281 + if tt.expectError && i == 1 { // Second secret should fail for duplicate test 282 + assert.Equal(t, ErrKeyAlreadyPresent, err) 283 + return 284 + } 285 + if !tt.expectError { 286 + assert.NoError(t, err) 287 + } 288 + } 289 + 290 + if !tt.expectError { 291 + assert.NoError(t, err) 292 + } 293 + }) 294 + } 295 + } 296 + 297 + func TestMockOpenBaoManager_RemoveSecret(t *testing.T) { 298 + tests := []struct { 299 + name string 300 + setupSecrets []UnlockedSecret 301 + removeSecret Secret[any] 302 + expectError bool 303 + }{ 304 + { 305 + name: "remove existing secret", 306 + setupSecrets: []UnlockedSecret{ 307 + createTestSecretForOpenBao("did:plc:test/repo1", "API_KEY", "secret123", "did:plc:creator"), 308 + }, 309 + removeSecret: Secret[any]{ 310 + Key: "API_KEY", 311 + Repo: DidSlashRepo("did:plc:test/repo1"), 312 + }, 313 + expectError: false, 314 + }, 315 + { 316 + name: "remove non-existent secret", 317 + setupSecrets: []UnlockedSecret{}, 318 + removeSecret: Secret[any]{ 319 + Key: "API_KEY", 320 + Repo: DidSlashRepo("did:plc:test/repo1"), 321 + }, 322 + expectError: true, 323 + }, 324 + } 325 + 326 + for _, tt := range tests { 327 + t.Run(tt.name, func(t *testing.T) { 328 + mock := NewMockOpenBaoManager() 329 + ctx := context.Background() 330 + 331 + // Setup secrets 332 + for _, secret := range tt.setupSecrets { 333 + err := mock.AddSecret(ctx, secret) 334 + assert.NoError(t, err) 335 + } 336 + 337 + // Remove secret 338 + err := mock.RemoveSecret(ctx, tt.removeSecret) 339 + 340 + if tt.expectError { 341 + assert.Equal(t, ErrKeyNotFound, err) 342 + } else { 343 + assert.NoError(t, err) 344 + } 345 + }) 346 + } 347 + } 348 + 349 + func TestMockOpenBaoManager_GetSecretsLocked(t *testing.T) { 350 + tests := []struct { 351 + name string 352 + setupSecrets []UnlockedSecret 353 + queryRepo DidSlashRepo 354 + expectedCount int 355 + expectedKeys []string 356 + expectError bool 357 + }{ 358 + { 359 + name: "get secrets from repo with secrets", 360 + setupSecrets: []UnlockedSecret{ 361 + createTestSecretForOpenBao("did:plc:test/repo1", "API_KEY", "secret123", "did:plc:creator"), 362 + createTestSecretForOpenBao("did:plc:test/repo1", "DB_PASSWORD", "dbpass456", "did:plc:creator"), 363 + createTestSecretForOpenBao("did:plc:test/repo2", "OTHER_KEY", "other789", "did:plc:creator"), 364 + }, 365 + queryRepo: DidSlashRepo("did:plc:test/repo1"), 366 + expectedCount: 2, 367 + expectedKeys: []string{"API_KEY", "DB_PASSWORD"}, 368 + expectError: false, 369 + }, 370 + { 371 + name: "get secrets from empty repo", 372 + setupSecrets: []UnlockedSecret{}, 373 + queryRepo: DidSlashRepo("did:plc:test/empty"), 374 + expectedCount: 0, 375 + expectedKeys: []string{}, 376 + expectError: false, 377 + }, 378 + } 379 + 380 + for _, tt := range tests { 381 + t.Run(tt.name, func(t *testing.T) { 382 + mock := NewMockOpenBaoManager() 383 + ctx := context.Background() 384 + 385 + // Setup 386 + for _, secret := range tt.setupSecrets { 387 + err := mock.AddSecret(ctx, secret) 388 + assert.NoError(t, err) 389 + } 390 + 391 + // Test 392 + secrets, err := mock.GetSecretsLocked(ctx, tt.queryRepo) 393 + 394 + if tt.expectError { 395 + assert.Error(t, err) 396 + } else { 397 + assert.NoError(t, err) 398 + assert.Len(t, secrets, tt.expectedCount) 399 + 400 + // Check keys 401 + actualKeys := make([]string, len(secrets)) 402 + for i, secret := range secrets { 403 + actualKeys[i] = secret.Key 404 + } 405 + 406 + for _, expectedKey := range tt.expectedKeys { 407 + assert.Contains(t, actualKeys, expectedKey) 408 + } 409 + } 410 + }) 411 + } 412 + } 413 + 414 + func TestMockOpenBaoManager_GetSecretsUnlocked(t *testing.T) { 415 + tests := []struct { 416 + name string 417 + setupSecrets []UnlockedSecret 418 + queryRepo DidSlashRepo 419 + expectedCount int 420 + expectedSecrets map[string]string // key -> value 421 + expectError bool 422 + }{ 423 + { 424 + name: "get unlocked secrets from repo", 425 + setupSecrets: []UnlockedSecret{ 426 + createTestSecretForOpenBao("did:plc:test/repo1", "API_KEY", "secret123", "did:plc:creator"), 427 + createTestSecretForOpenBao("did:plc:test/repo1", "DB_PASSWORD", "dbpass456", "did:plc:creator"), 428 + createTestSecretForOpenBao("did:plc:test/repo2", "OTHER_KEY", "other789", "did:plc:creator"), 429 + }, 430 + queryRepo: DidSlashRepo("did:plc:test/repo1"), 431 + expectedCount: 2, 432 + expectedSecrets: map[string]string{ 433 + "API_KEY": "secret123", 434 + "DB_PASSWORD": "dbpass456", 435 + }, 436 + expectError: false, 437 + }, 438 + { 439 + name: "get secrets from empty repo", 440 + setupSecrets: []UnlockedSecret{}, 441 + queryRepo: DidSlashRepo("did:plc:test/empty"), 442 + expectedCount: 0, 443 + expectedSecrets: map[string]string{}, 444 + expectError: false, 445 + }, 446 + } 447 + 448 + for _, tt := range tests { 449 + t.Run(tt.name, func(t *testing.T) { 450 + mock := NewMockOpenBaoManager() 451 + ctx := context.Background() 452 + 453 + // Setup 454 + for _, secret := range tt.setupSecrets { 455 + err := mock.AddSecret(ctx, secret) 456 + assert.NoError(t, err) 457 + } 458 + 459 + // Test 460 + secrets, err := mock.GetSecretsUnlocked(ctx, tt.queryRepo) 461 + 462 + if tt.expectError { 463 + assert.Error(t, err) 464 + } else { 465 + assert.NoError(t, err) 466 + assert.Len(t, secrets, tt.expectedCount) 467 + 468 + // Check key-value pairs 469 + actualSecrets := make(map[string]string) 470 + for _, secret := range secrets { 471 + actualSecrets[secret.Key] = secret.Value 472 + } 473 + 474 + for expectedKey, expectedValue := range tt.expectedSecrets { 475 + actualValue, exists := actualSecrets[expectedKey] 476 + assert.True(t, exists, "Expected key %s not found", expectedKey) 477 + assert.Equal(t, expectedValue, actualValue) 478 + } 479 + } 480 + }) 481 + } 482 + } 483 + 484 + func TestMockOpenBaoManager_ErrorHandling(t *testing.T) { 485 + mock := NewMockOpenBaoManager() 486 + ctx := context.Background() 487 + testError := assert.AnError 488 + 489 + // Test error injection 490 + mock.SetError(testError) 491 + 492 + secret := createTestSecretForOpenBao("did:plc:test/repo1", "API_KEY", "secret123", "did:plc:creator") 493 + 494 + // All operations should return the injected error 495 + err := mock.AddSecret(ctx, secret) 496 + assert.Equal(t, testError, err) 497 + 498 + _, err = mock.GetSecretsLocked(ctx, "did:plc:test/repo1") 499 + assert.Equal(t, testError, err) 500 + 501 + _, err = mock.GetSecretsUnlocked(ctx, "did:plc:test/repo1") 502 + assert.Equal(t, testError, err) 503 + 504 + err = mock.RemoveSecret(ctx, Secret[any]{Key: "API_KEY", Repo: "did:plc:test/repo1"}) 505 + assert.Equal(t, testError, err) 506 + 507 + // Clear error and test normal operation 508 + mock.ClearError() 509 + err = mock.AddSecret(ctx, secret) 510 + assert.NoError(t, err) 511 + } 512 + 513 + func TestMockOpenBaoManager_Integration(t *testing.T) { 514 + tests := []struct { 515 + name string 516 + scenario func(t *testing.T, mock *MockOpenBaoManager) 517 + }{ 518 + { 519 + name: "complete workflow", 520 + scenario: func(t *testing.T, mock *MockOpenBaoManager) { 521 + ctx := context.Background() 522 + repo := DidSlashRepo("did:plc:test/integration") 523 + 524 + // Start with empty repo 525 + secrets, err := mock.GetSecretsLocked(ctx, repo) 526 + assert.NoError(t, err) 527 + assert.Empty(t, secrets) 528 + 529 + // Add some secrets 530 + secret1 := createTestSecretForOpenBao(string(repo), "API_KEY", "secret123", "did:plc:creator") 531 + secret2 := createTestSecretForOpenBao(string(repo), "DB_PASSWORD", "dbpass456", "did:plc:creator") 532 + 533 + err = mock.AddSecret(ctx, secret1) 534 + assert.NoError(t, err) 535 + 536 + err = mock.AddSecret(ctx, secret2) 537 + assert.NoError(t, err) 538 + 539 + // Verify secrets exist 540 + secrets, err = mock.GetSecretsLocked(ctx, repo) 541 + assert.NoError(t, err) 542 + assert.Len(t, secrets, 2) 543 + 544 + unlockedSecrets, err := mock.GetSecretsUnlocked(ctx, repo) 545 + assert.NoError(t, err) 546 + assert.Len(t, unlockedSecrets, 2) 547 + 548 + // Remove one secret 549 + err = mock.RemoveSecret(ctx, Secret[any]{Key: "API_KEY", Repo: repo}) 550 + assert.NoError(t, err) 551 + 552 + // Verify only one secret remains 553 + secrets, err = mock.GetSecretsLocked(ctx, repo) 554 + assert.NoError(t, err) 555 + assert.Len(t, secrets, 1) 556 + assert.Equal(t, "DB_PASSWORD", secrets[0].Key) 557 + }, 558 + }, 559 + } 560 + 561 + for _, tt := range tests { 562 + t.Run(tt.name, func(t *testing.T) { 563 + mock := NewMockOpenBaoManager() 564 + tt.scenario(t, mock) 565 + }) 566 + } 567 + } 568 + 569 + func TestOpenBaoManager_ProxyConfiguration(t *testing.T) { 570 + tests := []struct { 571 + name string 572 + proxyAddr string 573 + description string 574 + }{ 575 + { 576 + name: "default_localhost", 577 + proxyAddr: "http://127.0.0.1:8200", 578 + description: "Should connect to default localhost proxy", 579 + }, 580 + { 581 + name: "custom_host", 582 + proxyAddr: "http://bao-proxy:8200", 583 + description: "Should connect to custom proxy host", 584 + }, 585 + { 586 + name: "https_proxy", 587 + proxyAddr: "https://127.0.0.1:8200", 588 + description: "Should connect to HTTPS proxy", 589 + }, 590 + } 591 + 592 + for _, tt := range tests { 593 + t.Run(tt.name, func(t *testing.T) { 594 + t.Log("Testing scenario:", tt.description) 595 + logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) 596 + 597 + // All these will fail because no real proxy is running 598 + // but we can test that the configuration is properly accepted 599 + manager, err := NewOpenBaoManager(tt.proxyAddr, logger) 600 + assert.Error(t, err) // Expected because no real proxy 601 + assert.Nil(t, manager) 602 + assert.Contains(t, err.Error(), "failed to connect to bao proxy") 603 + }) 604 + } 605 + }
+22
spindle/secrets/policy.hcl
··· 1 + # Allow full access to the spindle KV mount 2 + path "spindle/*" { 3 + capabilities = ["create", "read", "update", "delete", "list"] 4 + } 5 + 6 + path "spindle/data/*" { 7 + capabilities = ["create", "read", "update", "delete"] 8 + } 9 + 10 + path "spindle/metadata/*" { 11 + capabilities = ["list", "read", "delete"] 12 + } 13 + 14 + # Allow listing mounts (for connection testing) 15 + path "sys/mounts" { 16 + capabilities = ["read"] 17 + } 18 + 19 + # Allow token self-lookup (for health checks) 20 + path "auth/token/lookup-self" { 21 + capabilities = ["read"] 22 + }
+172
spindle/secrets/sqlite.go
··· 1 + // an sqlite3 backed secret manager 2 + package secrets 3 + 4 + import ( 5 + "context" 6 + "database/sql" 7 + "fmt" 8 + "time" 9 + 10 + _ "github.com/mattn/go-sqlite3" 11 + ) 12 + 13 + type SqliteManager struct { 14 + db *sql.DB 15 + tableName string 16 + } 17 + 18 + type SqliteManagerOpt func(*SqliteManager) 19 + 20 + func WithTableName(name string) SqliteManagerOpt { 21 + return func(s *SqliteManager) { 22 + s.tableName = name 23 + } 24 + } 25 + 26 + func NewSQLiteManager(dbPath string, opts ...SqliteManagerOpt) (*SqliteManager, error) { 27 + db, err := sql.Open("sqlite3", dbPath+"?_foreign_keys=1") 28 + if err != nil { 29 + return nil, fmt.Errorf("failed to open sqlite database: %w", err) 30 + } 31 + 32 + manager := &SqliteManager{ 33 + db: db, 34 + tableName: "secrets", 35 + } 36 + 37 + for _, o := range opts { 38 + o(manager) 39 + } 40 + 41 + if err := manager.init(); err != nil { 42 + return nil, err 43 + } 44 + 45 + return manager, nil 46 + } 47 + 48 + // creates a table and sets up the schema, migrations if any can go here 49 + func (s *SqliteManager) init() error { 50 + createTable := 51 + `create table if not exists ` + s.tableName + `( 52 + id integer primary key autoincrement, 53 + repo text not null, 54 + key text not null, 55 + value text not null, 56 + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 57 + created_by text not null, 58 + 59 + unique(repo, key) 60 + );` 61 + _, err := s.db.Exec(createTable) 62 + return err 63 + } 64 + 65 + func (s *SqliteManager) AddSecret(ctx context.Context, secret UnlockedSecret) error { 66 + query := fmt.Sprintf(` 67 + insert or ignore into %s (repo, key, value, created_by) 68 + values (?, ?, ?, ?); 69 + `, s.tableName) 70 + 71 + res, err := s.db.ExecContext(ctx, query, secret.Repo, secret.Key, secret.Value, secret.CreatedBy) 72 + if err != nil { 73 + return err 74 + } 75 + 76 + num, err := res.RowsAffected() 77 + if err != nil { 78 + return err 79 + } 80 + 81 + if num == 0 { 82 + return ErrKeyAlreadyPresent 83 + } 84 + 85 + return nil 86 + } 87 + 88 + func (s *SqliteManager) RemoveSecret(ctx context.Context, secret Secret[any]) error { 89 + query := fmt.Sprintf(` 90 + delete from %s where repo = ? and key = ?; 91 + `, s.tableName) 92 + 93 + res, err := s.db.ExecContext(ctx, query, secret.Repo, secret.Key) 94 + if err != nil { 95 + return err 96 + } 97 + 98 + num, err := res.RowsAffected() 99 + if err != nil { 100 + return err 101 + } 102 + 103 + if num == 0 { 104 + return ErrKeyNotFound 105 + } 106 + 107 + return nil 108 + } 109 + 110 + func (s *SqliteManager) GetSecretsLocked(ctx context.Context, didSlashRepo DidSlashRepo) ([]LockedSecret, error) { 111 + query := fmt.Sprintf(` 112 + select repo, key, created_at, created_by from %s where repo = ?; 113 + `, s.tableName) 114 + 115 + rows, err := s.db.QueryContext(ctx, query, didSlashRepo) 116 + if err != nil { 117 + return nil, err 118 + } 119 + 120 + var ls []LockedSecret 121 + for rows.Next() { 122 + var l LockedSecret 123 + var createdAt string 124 + if err = rows.Scan(&l.Repo, &l.Key, &createdAt, &l.CreatedBy); err != nil { 125 + return nil, err 126 + } 127 + 128 + if t, err := time.Parse(time.RFC3339, createdAt); err == nil { 129 + l.CreatedAt = t 130 + } 131 + 132 + ls = append(ls, l) 133 + } 134 + 135 + if err = rows.Err(); err != nil { 136 + return nil, err 137 + } 138 + 139 + return ls, nil 140 + } 141 + 142 + func (s *SqliteManager) GetSecretsUnlocked(ctx context.Context, didSlashRepo DidSlashRepo) ([]UnlockedSecret, error) { 143 + query := fmt.Sprintf(` 144 + select repo, key, value, created_at, created_by from %s where repo = ?; 145 + `, s.tableName) 146 + 147 + rows, err := s.db.QueryContext(ctx, query, didSlashRepo) 148 + if err != nil { 149 + return nil, err 150 + } 151 + 152 + var ls []UnlockedSecret 153 + for rows.Next() { 154 + var l UnlockedSecret 155 + var createdAt string 156 + if err = rows.Scan(&l.Repo, &l.Key, &l.Value, &createdAt, &l.CreatedBy); err != nil { 157 + return nil, err 158 + } 159 + 160 + if t, err := time.Parse(time.RFC3339, createdAt); err == nil { 161 + l.CreatedAt = t 162 + } 163 + 164 + ls = append(ls, l) 165 + } 166 + 167 + if err = rows.Err(); err != nil { 168 + return nil, err 169 + } 170 + 171 + return ls, nil 172 + }
+590
spindle/secrets/sqlite_test.go
··· 1 + package secrets 2 + 3 + import ( 4 + "context" 5 + "testing" 6 + "time" 7 + 8 + "github.com/alecthomas/assert/v2" 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + ) 11 + 12 + func createInMemoryDB(t *testing.T) *SqliteManager { 13 + t.Helper() 14 + manager, err := NewSQLiteManager(":memory:") 15 + if err != nil { 16 + t.Fatalf("Failed to create in-memory manager: %v", err) 17 + } 18 + return manager 19 + } 20 + 21 + func createTestSecret(repo, key, value, createdBy string) UnlockedSecret { 22 + return UnlockedSecret{ 23 + Key: key, 24 + Value: value, 25 + Repo: DidSlashRepo(repo), 26 + CreatedAt: time.Now(), 27 + CreatedBy: syntax.DID(createdBy), 28 + } 29 + } 30 + 31 + // ensure that interface is satisfied 32 + func TestManagerInterface(t *testing.T) { 33 + var _ Manager = (*SqliteManager)(nil) 34 + } 35 + 36 + func TestNewSQLiteManager(t *testing.T) { 37 + tests := []struct { 38 + name string 39 + dbPath string 40 + opts []SqliteManagerOpt 41 + expectError bool 42 + expectTable string 43 + }{ 44 + { 45 + name: "default table name", 46 + dbPath: ":memory:", 47 + opts: nil, 48 + expectError: false, 49 + expectTable: "secrets", 50 + }, 51 + { 52 + name: "custom table name", 53 + dbPath: ":memory:", 54 + opts: []SqliteManagerOpt{WithTableName("custom_secrets")}, 55 + expectError: false, 56 + expectTable: "custom_secrets", 57 + }, 58 + { 59 + name: "invalid database path", 60 + dbPath: "/invalid/path/to/database.db", 61 + opts: nil, 62 + expectError: true, 63 + expectTable: "", 64 + }, 65 + } 66 + 67 + for _, tt := range tests { 68 + t.Run(tt.name, func(t *testing.T) { 69 + manager, err := NewSQLiteManager(tt.dbPath, tt.opts...) 70 + if tt.expectError { 71 + if err == nil { 72 + t.Error("Expected error but got none") 73 + } 74 + return 75 + } 76 + 77 + if err != nil { 78 + t.Fatalf("Unexpected error: %v", err) 79 + } 80 + defer manager.db.Close() 81 + 82 + if manager.tableName != tt.expectTable { 83 + t.Errorf("Expected table name %s, got %s", tt.expectTable, manager.tableName) 84 + } 85 + }) 86 + } 87 + } 88 + 89 + func TestSqliteManager_AddSecret(t *testing.T) { 90 + tests := []struct { 91 + name string 92 + secrets []UnlockedSecret 93 + expectError []error 94 + }{ 95 + { 96 + name: "add single secret", 97 + secrets: []UnlockedSecret{ 98 + createTestSecret("did:plc:foo/repo", "api_key", "secret_value_123", "did:plc:example123"), 99 + }, 100 + expectError: []error{nil}, 101 + }, 102 + { 103 + name: "add multiple unique secrets", 104 + secrets: []UnlockedSecret{ 105 + createTestSecret("did:plc:foo/repo", "api_key", "secret_value_123", "did:plc:example123"), 106 + createTestSecret("did:plc:foo/repo", "db_password", "password_456", "did:plc:example123"), 107 + createTestSecret("other.com/repo", "api_key", "other_secret", "did:plc:other"), 108 + }, 109 + expectError: []error{nil, nil, nil}, 110 + }, 111 + { 112 + name: "add duplicate secret", 113 + secrets: []UnlockedSecret{ 114 + createTestSecret("did:plc:foo/repo", "api_key", "secret_value_123", "did:plc:example123"), 115 + createTestSecret("did:plc:foo/repo", "api_key", "different_value", "did:plc:example123"), 116 + }, 117 + expectError: []error{nil, ErrKeyAlreadyPresent}, 118 + }, 119 + } 120 + 121 + for _, tt := range tests { 122 + t.Run(tt.name, func(t *testing.T) { 123 + manager := createInMemoryDB(t) 124 + defer manager.db.Close() 125 + 126 + for i, secret := range tt.secrets { 127 + err := manager.AddSecret(context.Background(), secret) 128 + if err != tt.expectError[i] { 129 + t.Errorf("Secret %d: expected error %v, got %v", i, tt.expectError[i], err) 130 + } 131 + } 132 + }) 133 + } 134 + } 135 + 136 + func TestSqliteManager_RemoveSecret(t *testing.T) { 137 + tests := []struct { 138 + name string 139 + setupSecrets []UnlockedSecret 140 + removeSecret Secret[any] 141 + expectError error 142 + }{ 143 + { 144 + name: "remove existing secret", 145 + setupSecrets: []UnlockedSecret{ 146 + createTestSecret("did:plc:foo/repo", "api_key", "secret_value_123", "did:plc:example123"), 147 + }, 148 + removeSecret: Secret[any]{ 149 + Key: "api_key", 150 + Repo: DidSlashRepo("did:plc:foo/repo"), 151 + }, 152 + expectError: nil, 153 + }, 154 + { 155 + name: "remove non-existent secret", 156 + setupSecrets: []UnlockedSecret{ 157 + createTestSecret("did:plc:foo/repo", "api_key", "secret_value_123", "did:plc:example123"), 158 + }, 159 + removeSecret: Secret[any]{ 160 + Key: "non_existent_key", 161 + Repo: DidSlashRepo("did:plc:foo/repo"), 162 + }, 163 + expectError: ErrKeyNotFound, 164 + }, 165 + { 166 + name: "remove from empty database", 167 + setupSecrets: []UnlockedSecret{}, 168 + removeSecret: Secret[any]{ 169 + Key: "any_key", 170 + Repo: DidSlashRepo("did:plc:foo/repo"), 171 + }, 172 + expectError: ErrKeyNotFound, 173 + }, 174 + { 175 + name: "remove secret from wrong repo", 176 + setupSecrets: []UnlockedSecret{ 177 + createTestSecret("did:plc:foo/repo", "api_key", "secret_value_123", "did:plc:example123"), 178 + }, 179 + removeSecret: Secret[any]{ 180 + Key: "api_key", 181 + Repo: DidSlashRepo("other.com/repo"), 182 + }, 183 + expectError: ErrKeyNotFound, 184 + }, 185 + } 186 + 187 + for _, tt := range tests { 188 + t.Run(tt.name, func(t *testing.T) { 189 + manager := createInMemoryDB(t) 190 + defer manager.db.Close() 191 + 192 + // Setup secrets 193 + for _, secret := range tt.setupSecrets { 194 + if err := manager.AddSecret(context.Background(), secret); err != nil { 195 + t.Fatalf("Failed to setup secret: %v", err) 196 + } 197 + } 198 + 199 + // Test removal 200 + err := manager.RemoveSecret(context.Background(), tt.removeSecret) 201 + if err != tt.expectError { 202 + t.Errorf("Expected error %v, got %v", tt.expectError, err) 203 + } 204 + }) 205 + } 206 + } 207 + 208 + func TestSqliteManager_GetSecretsLocked(t *testing.T) { 209 + tests := []struct { 210 + name string 211 + setupSecrets []UnlockedSecret 212 + queryRepo DidSlashRepo 213 + expectedCount int 214 + expectedKeys []string 215 + expectError bool 216 + }{ 217 + { 218 + name: "get secrets for repo with multiple secrets", 219 + setupSecrets: []UnlockedSecret{ 220 + createTestSecret("did:plc:foo/repo", "key1", "value1", "did:plc:user1"), 221 + createTestSecret("did:plc:foo/repo", "key2", "value2", "did:plc:user2"), 222 + createTestSecret("other.com/repo", "key3", "value3", "did:plc:user3"), 223 + }, 224 + queryRepo: DidSlashRepo("did:plc:foo/repo"), 225 + expectedCount: 2, 226 + expectedKeys: []string{"key1", "key2"}, 227 + expectError: false, 228 + }, 229 + { 230 + name: "get secrets for repo with single secret", 231 + setupSecrets: []UnlockedSecret{ 232 + createTestSecret("did:plc:foo/repo", "single_key", "single_value", "did:plc:user1"), 233 + createTestSecret("other.com/repo", "other_key", "other_value", "did:plc:user2"), 234 + }, 235 + queryRepo: DidSlashRepo("did:plc:foo/repo"), 236 + expectedCount: 1, 237 + expectedKeys: []string{"single_key"}, 238 + expectError: false, 239 + }, 240 + { 241 + name: "get secrets for non-existent repo", 242 + setupSecrets: []UnlockedSecret{ 243 + createTestSecret("did:plc:foo/repo", "key1", "value1", "did:plc:user1"), 244 + }, 245 + queryRepo: DidSlashRepo("nonexistent.com/repo"), 246 + expectedCount: 0, 247 + expectedKeys: []string{}, 248 + expectError: false, 249 + }, 250 + { 251 + name: "get secrets from empty database", 252 + setupSecrets: []UnlockedSecret{}, 253 + queryRepo: DidSlashRepo("did:plc:foo/repo"), 254 + expectedCount: 0, 255 + expectedKeys: []string{}, 256 + expectError: false, 257 + }, 258 + } 259 + 260 + for _, tt := range tests { 261 + t.Run(tt.name, func(t *testing.T) { 262 + manager := createInMemoryDB(t) 263 + defer manager.db.Close() 264 + 265 + // Setup secrets 266 + for _, secret := range tt.setupSecrets { 267 + if err := manager.AddSecret(context.Background(), secret); err != nil { 268 + t.Fatalf("Failed to setup secret: %v", err) 269 + } 270 + } 271 + 272 + // Test getting locked secrets 273 + lockedSecrets, err := manager.GetSecretsLocked(context.Background(), tt.queryRepo) 274 + if tt.expectError && err == nil { 275 + t.Error("Expected error but got none") 276 + return 277 + } 278 + if !tt.expectError && err != nil { 279 + t.Fatalf("Unexpected error: %v", err) 280 + } 281 + 282 + if len(lockedSecrets) != tt.expectedCount { 283 + t.Errorf("Expected %d secrets, got %d", tt.expectedCount, len(lockedSecrets)) 284 + } 285 + 286 + // Verify keys and that values are not present (locked) 287 + foundKeys := make(map[string]bool) 288 + for _, ls := range lockedSecrets { 289 + foundKeys[ls.Key] = true 290 + if ls.Repo != tt.queryRepo { 291 + t.Errorf("Expected repo %s, got %s", tt.queryRepo, ls.Repo) 292 + } 293 + if ls.CreatedBy == "" { 294 + t.Error("Expected CreatedBy to be present") 295 + } 296 + if ls.CreatedAt.IsZero() { 297 + t.Error("Expected CreatedAt to be set") 298 + } 299 + } 300 + 301 + for _, expectedKey := range tt.expectedKeys { 302 + if !foundKeys[expectedKey] { 303 + t.Errorf("Expected key %s not found", expectedKey) 304 + } 305 + } 306 + }) 307 + } 308 + } 309 + 310 + func TestSqliteManager_GetSecretsUnlocked(t *testing.T) { 311 + tests := []struct { 312 + name string 313 + setupSecrets []UnlockedSecret 314 + queryRepo DidSlashRepo 315 + expectedCount int 316 + expectedSecrets map[string]string // key -> value 317 + expectError bool 318 + }{ 319 + { 320 + name: "get unlocked secrets for repo with multiple secrets", 321 + setupSecrets: []UnlockedSecret{ 322 + createTestSecret("did:plc:foo/repo", "key1", "value1", "did:plc:user1"), 323 + createTestSecret("did:plc:foo/repo", "key2", "value2", "did:plc:user2"), 324 + createTestSecret("other.com/repo", "key3", "value3", "did:plc:user3"), 325 + }, 326 + queryRepo: DidSlashRepo("did:plc:foo/repo"), 327 + expectedCount: 2, 328 + expectedSecrets: map[string]string{ 329 + "key1": "value1", 330 + "key2": "value2", 331 + }, 332 + expectError: false, 333 + }, 334 + { 335 + name: "get unlocked secrets for repo with single secret", 336 + setupSecrets: []UnlockedSecret{ 337 + createTestSecret("did:plc:foo/repo", "single_key", "single_value", "did:plc:user1"), 338 + createTestSecret("other.com/repo", "other_key", "other_value", "did:plc:user2"), 339 + }, 340 + queryRepo: DidSlashRepo("did:plc:foo/repo"), 341 + expectedCount: 1, 342 + expectedSecrets: map[string]string{ 343 + "single_key": "single_value", 344 + }, 345 + expectError: false, 346 + }, 347 + { 348 + name: "get unlocked secrets for non-existent repo", 349 + setupSecrets: []UnlockedSecret{ 350 + createTestSecret("did:plc:foo/repo", "key1", "value1", "did:plc:user1"), 351 + }, 352 + queryRepo: DidSlashRepo("nonexistent.com/repo"), 353 + expectedCount: 0, 354 + expectedSecrets: map[string]string{}, 355 + expectError: false, 356 + }, 357 + { 358 + name: "get unlocked secrets from empty database", 359 + setupSecrets: []UnlockedSecret{}, 360 + queryRepo: DidSlashRepo("did:plc:foo/repo"), 361 + expectedCount: 0, 362 + expectedSecrets: map[string]string{}, 363 + expectError: false, 364 + }, 365 + } 366 + 367 + for _, tt := range tests { 368 + t.Run(tt.name, func(t *testing.T) { 369 + manager := createInMemoryDB(t) 370 + defer manager.db.Close() 371 + 372 + // Setup secrets 373 + for _, secret := range tt.setupSecrets { 374 + if err := manager.AddSecret(context.Background(), secret); err != nil { 375 + t.Fatalf("Failed to setup secret: %v", err) 376 + } 377 + } 378 + 379 + // Test getting unlocked secrets 380 + unlockedSecrets, err := manager.GetSecretsUnlocked(context.Background(), tt.queryRepo) 381 + if tt.expectError && err == nil { 382 + t.Error("Expected error but got none") 383 + return 384 + } 385 + if !tt.expectError && err != nil { 386 + t.Fatalf("Unexpected error: %v", err) 387 + } 388 + 389 + if len(unlockedSecrets) != tt.expectedCount { 390 + t.Errorf("Expected %d secrets, got %d", tt.expectedCount, len(unlockedSecrets)) 391 + } 392 + 393 + // Verify keys, values, and metadata 394 + for _, us := range unlockedSecrets { 395 + expectedValue, exists := tt.expectedSecrets[us.Key] 396 + if !exists { 397 + t.Errorf("Unexpected key: %s", us.Key) 398 + continue 399 + } 400 + if us.Value != expectedValue { 401 + t.Errorf("Expected value %s for key %s, got %s", expectedValue, us.Key, us.Value) 402 + } 403 + if us.Repo != tt.queryRepo { 404 + t.Errorf("Expected repo %s, got %s", tt.queryRepo, us.Repo) 405 + } 406 + if us.CreatedBy == "" { 407 + t.Error("Expected CreatedBy to be present") 408 + } 409 + if us.CreatedAt.IsZero() { 410 + t.Error("Expected CreatedAt to be set") 411 + } 412 + } 413 + }) 414 + } 415 + } 416 + 417 + // Test that demonstrates interface usage with table-driven tests 418 + func TestManagerInterface_Usage(t *testing.T) { 419 + tests := []struct { 420 + name string 421 + operations []func(Manager) error 422 + expectError bool 423 + }{ 424 + { 425 + name: "successful workflow", 426 + operations: []func(Manager) error{ 427 + func(m Manager) error { 428 + secret := createTestSecret("interface.test/repo", "test_key", "test_value", "did:plc:user") 429 + return m.AddSecret(context.Background(), secret) 430 + }, 431 + func(m Manager) error { 432 + _, err := m.GetSecretsLocked(context.Background(), DidSlashRepo("interface.test/repo")) 433 + return err 434 + }, 435 + func(m Manager) error { 436 + _, err := m.GetSecretsUnlocked(context.Background(), DidSlashRepo("interface.test/repo")) 437 + return err 438 + }, 439 + func(m Manager) error { 440 + secret := Secret[any]{ 441 + Key: "test_key", 442 + Repo: DidSlashRepo("interface.test/repo"), 443 + } 444 + return m.RemoveSecret(context.Background(), secret) 445 + }, 446 + }, 447 + expectError: false, 448 + }, 449 + { 450 + name: "error on duplicate key", 451 + operations: []func(Manager) error{ 452 + func(m Manager) error { 453 + secret := createTestSecret("interface.test/repo", "dup_key", "value1", "did:plc:user") 454 + return m.AddSecret(context.Background(), secret) 455 + }, 456 + func(m Manager) error { 457 + secret := createTestSecret("interface.test/repo", "dup_key", "value2", "did:plc:user") 458 + return m.AddSecret(context.Background(), secret) // Should return ErrKeyAlreadyPresent 459 + }, 460 + }, 461 + expectError: true, 462 + }, 463 + } 464 + 465 + for _, tt := range tests { 466 + t.Run(tt.name, func(t *testing.T) { 467 + var manager Manager = createInMemoryDB(t) 468 + defer func() { 469 + if sqliteManager, ok := manager.(*SqliteManager); ok { 470 + sqliteManager.db.Close() 471 + } 472 + }() 473 + 474 + var finalErr error 475 + for i, operation := range tt.operations { 476 + if err := operation(manager); err != nil { 477 + finalErr = err 478 + t.Logf("Operation %d returned error: %v", i, err) 479 + } 480 + } 481 + 482 + if tt.expectError && finalErr == nil { 483 + t.Error("Expected error but got none") 484 + } 485 + if !tt.expectError && finalErr != nil { 486 + t.Errorf("Unexpected error: %v", finalErr) 487 + } 488 + }) 489 + } 490 + } 491 + 492 + // Integration test with table-driven scenarios 493 + func TestSqliteManager_Integration(t *testing.T) { 494 + tests := []struct { 495 + name string 496 + scenario func(*testing.T, *SqliteManager) 497 + }{ 498 + { 499 + name: "multi-repo secret management", 500 + scenario: func(t *testing.T, manager *SqliteManager) { 501 + repo1 := DidSlashRepo("example1.com/repo") 502 + repo2 := DidSlashRepo("example2.com/repo") 503 + 504 + secrets := []UnlockedSecret{ 505 + createTestSecret(string(repo1), "db_password", "super_secret_123", "did:plc:admin"), 506 + createTestSecret(string(repo1), "api_key", "api_key_456", "did:plc:user1"), 507 + createTestSecret(string(repo2), "token", "bearer_token_789", "did:plc:user2"), 508 + } 509 + 510 + // Add all secrets 511 + for _, secret := range secrets { 512 + if err := manager.AddSecret(context.Background(), secret); err != nil { 513 + t.Fatalf("Failed to add secret %s: %v", secret.Key, err) 514 + } 515 + } 516 + 517 + // Verify counts 518 + locked1, _ := manager.GetSecretsLocked(context.Background(), repo1) 519 + locked2, _ := manager.GetSecretsLocked(context.Background(), repo2) 520 + 521 + if len(locked1) != 2 { 522 + t.Errorf("Expected 2 secrets for repo1, got %d", len(locked1)) 523 + } 524 + if len(locked2) != 1 { 525 + t.Errorf("Expected 1 secret for repo2, got %d", len(locked2)) 526 + } 527 + 528 + // Remove and verify 529 + secretToRemove := Secret[any]{Key: "db_password", Repo: repo1} 530 + if err := manager.RemoveSecret(context.Background(), secretToRemove); err != nil { 531 + t.Fatalf("Failed to remove secret: %v", err) 532 + } 533 + 534 + locked1After, _ := manager.GetSecretsLocked(context.Background(), repo1) 535 + if len(locked1After) != 1 { 536 + t.Errorf("Expected 1 secret for repo1 after removal, got %d", len(locked1After)) 537 + } 538 + if locked1After[0].Key != "api_key" { 539 + t.Errorf("Expected remaining secret to be 'api_key', got %s", locked1After[0].Key) 540 + } 541 + }, 542 + }, 543 + { 544 + name: "empty database operations", 545 + scenario: func(t *testing.T, manager *SqliteManager) { 546 + repo := DidSlashRepo("empty.test/repo") 547 + 548 + // Operations on empty database should not error 549 + locked, err := manager.GetSecretsLocked(context.Background(), repo) 550 + if err != nil { 551 + t.Errorf("GetSecretsLocked on empty DB failed: %v", err) 552 + } 553 + if len(locked) != 0 { 554 + t.Errorf("Expected 0 secrets, got %d", len(locked)) 555 + } 556 + 557 + unlocked, err := manager.GetSecretsUnlocked(context.Background(), repo) 558 + if err != nil { 559 + t.Errorf("GetSecretsUnlocked on empty DB failed: %v", err) 560 + } 561 + if len(unlocked) != 0 { 562 + t.Errorf("Expected 0 secrets, got %d", len(unlocked)) 563 + } 564 + 565 + // Remove from empty should return ErrKeyNotFound 566 + nonExistent := Secret[any]{Key: "none", Repo: repo} 567 + err = manager.RemoveSecret(context.Background(), nonExistent) 568 + if err != ErrKeyNotFound { 569 + t.Errorf("Expected ErrKeyNotFound, got %v", err) 570 + } 571 + }, 572 + }, 573 + } 574 + 575 + for _, tt := range tests { 576 + t.Run(tt.name, func(t *testing.T) { 577 + manager := createInMemoryDB(t) 578 + defer manager.db.Close() 579 + tt.scenario(t, manager) 580 + }) 581 + } 582 + } 583 + 584 + func TestSqliteManager_StopperInterface(t *testing.T) { 585 + manager := &SqliteManager{} 586 + 587 + // Verify that SqliteManager does NOT implement the Stopper interface 588 + _, ok := interface{}(manager).(Stopper) 589 + assert.False(t, ok, "SqliteManager should NOT implement Stopper interface") 590 + }
+160 -35
spindle/server.go
··· 2 2 3 3 import ( 4 4 "context" 5 + _ "embed" 5 6 "encoding/json" 6 7 "fmt" 7 8 "log/slog" ··· 11 12 "tangled.sh/tangled.sh/core/api/tangled" 12 13 "tangled.sh/tangled.sh/core/eventconsumer" 13 14 "tangled.sh/tangled.sh/core/eventconsumer/cursor" 15 + "tangled.sh/tangled.sh/core/idresolver" 14 16 "tangled.sh/tangled.sh/core/jetstream" 15 17 "tangled.sh/tangled.sh/core/log" 16 18 "tangled.sh/tangled.sh/core/notifier" ··· 18 20 "tangled.sh/tangled.sh/core/spindle/config" 19 21 "tangled.sh/tangled.sh/core/spindle/db" 20 22 "tangled.sh/tangled.sh/core/spindle/engine" 23 + "tangled.sh/tangled.sh/core/spindle/engines/nixery" 21 24 "tangled.sh/tangled.sh/core/spindle/models" 22 25 "tangled.sh/tangled.sh/core/spindle/queue" 26 + "tangled.sh/tangled.sh/core/spindle/secrets" 27 + "tangled.sh/tangled.sh/core/spindle/xrpc" 28 + "tangled.sh/tangled.sh/core/xrpc/serviceauth" 23 29 ) 24 30 31 + //go:embed motd 32 + var motd []byte 33 + 25 34 const ( 26 35 rbacDomain = "thisserver" 27 36 ) 28 37 29 38 type Spindle struct { 30 - jc *jetstream.JetstreamClient 31 - db *db.DB 32 - e *rbac.Enforcer 33 - l *slog.Logger 34 - n *notifier.Notifier 35 - eng *engine.Engine 36 - jq *queue.Queue 37 - cfg *config.Config 38 - ks *eventconsumer.Consumer 39 + jc *jetstream.JetstreamClient 40 + db *db.DB 41 + e *rbac.Enforcer 42 + l *slog.Logger 43 + n *notifier.Notifier 44 + engs map[string]models.Engine 45 + jq *queue.Queue 46 + cfg *config.Config 47 + ks *eventconsumer.Consumer 48 + res *idresolver.Resolver 49 + vault secrets.Manager 39 50 } 40 51 41 52 func Run(ctx context.Context) error { ··· 59 70 60 71 n := notifier.New() 61 72 62 - eng, err := engine.New(ctx, cfg, d, &n) 73 + var vault secrets.Manager 74 + switch cfg.Server.Secrets.Provider { 75 + case "openbao": 76 + if cfg.Server.Secrets.OpenBao.ProxyAddr == "" { 77 + return fmt.Errorf("openbao proxy address is required when using openbao secrets provider") 78 + } 79 + vault, err = secrets.NewOpenBaoManager( 80 + cfg.Server.Secrets.OpenBao.ProxyAddr, 81 + logger, 82 + secrets.WithMountPath(cfg.Server.Secrets.OpenBao.Mount), 83 + ) 84 + if err != nil { 85 + return fmt.Errorf("failed to setup openbao secrets provider: %w", err) 86 + } 87 + logger.Info("using openbao secrets provider", "proxy_address", cfg.Server.Secrets.OpenBao.ProxyAddr, "mount", cfg.Server.Secrets.OpenBao.Mount) 88 + case "sqlite", "": 89 + vault, err = secrets.NewSQLiteManager(cfg.Server.DBPath, secrets.WithTableName("secrets")) 90 + if err != nil { 91 + return fmt.Errorf("failed to setup sqlite secrets provider: %w", err) 92 + } 93 + logger.Info("using sqlite secrets provider", "path", cfg.Server.DBPath) 94 + default: 95 + return fmt.Errorf("unknown secrets provider: %s", cfg.Server.Secrets.Provider) 96 + } 97 + 98 + nixeryEng, err := nixery.New(ctx, cfg) 63 99 if err != nil { 64 100 return err 65 101 } 66 102 67 - jq := queue.NewQueue(100, 2) 103 + jq := queue.NewQueue(cfg.Server.QueueSize, cfg.Server.MaxJobCount) 104 + logger.Info("initialized queue", "queueSize", cfg.Server.QueueSize, "numWorkers", cfg.Server.MaxJobCount) 68 105 69 106 collections := []string{ 70 107 tangled.SpindleMemberNSID, 71 108 tangled.RepoNSID, 109 + tangled.RepoCollaboratorNSID, 72 110 } 73 111 jc, err := jetstream.NewJetstreamClient(cfg.Server.JetstreamEndpoint, "spindle", collections, nil, logger, d, true, true) 74 112 if err != nil { ··· 76 114 } 77 115 jc.AddDid(cfg.Server.Owner) 78 116 117 + // Check if the spindle knows about any Dids; 118 + dids, err := d.GetAllDids() 119 + if err != nil { 120 + return fmt.Errorf("failed to get all dids: %w", err) 121 + } 122 + for _, d := range dids { 123 + jc.AddDid(d) 124 + } 125 + 126 + resolver := idresolver.DefaultResolver() 127 + 79 128 spindle := Spindle{ 80 - jc: jc, 81 - e: e, 82 - db: d, 83 - l: logger, 84 - n: &n, 85 - eng: eng, 86 - jq: jq, 87 - cfg: cfg, 129 + jc: jc, 130 + e: e, 131 + db: d, 132 + l: logger, 133 + n: &n, 134 + engs: map[string]models.Engine{"nixery": nixeryEng}, 135 + jq: jq, 136 + cfg: cfg, 137 + res: resolver, 138 + vault: vault, 88 139 } 89 140 90 - err = e.AddKnot(rbacDomain) 141 + err = e.AddSpindle(rbacDomain) 91 142 if err != nil { 92 143 return fmt.Errorf("failed to set rbac domain: %w", err) 93 144 } ··· 100 151 // starts a job queue runner in the background 101 152 jq.Start() 102 153 defer jq.Stop() 154 + 155 + // Stop vault token renewal if it implements Stopper 156 + if stopper, ok := vault.(secrets.Stopper); ok { 157 + defer stopper.Stop() 158 + } 103 159 104 160 cursorStore, err := cursor.NewSQLiteStore(cfg.Server.DBPath) 105 161 if err != nil { ··· 143 199 func (s *Spindle) Router() http.Handler { 144 200 mux := chi.NewRouter() 145 201 146 - mux.HandleFunc("/events", s.Events) 147 - mux.HandleFunc("/owner", func(w http.ResponseWriter, r *http.Request) { 148 - w.Write([]byte(s.cfg.Server.Owner)) 202 + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 203 + w.Write(motd) 149 204 }) 205 + mux.HandleFunc("/events", s.Events) 150 206 mux.HandleFunc("/logs/{knot}/{rkey}/{name}", s.Logs) 207 + 208 + mux.Mount("/xrpc", s.XrpcRouter()) 151 209 return mux 210 + } 211 + 212 + func (s *Spindle) XrpcRouter() http.Handler { 213 + logger := s.l.With("route", "xrpc") 214 + 215 + serviceAuth := serviceauth.NewServiceAuth(s.l, s.res, s.cfg.Server.Did().String()) 216 + 217 + x := xrpc.Xrpc{ 218 + Logger: logger, 219 + Db: s.db, 220 + Enforcer: s.e, 221 + Engines: s.engs, 222 + Config: s.cfg, 223 + Resolver: s.res, 224 + Vault: s.vault, 225 + ServiceAuth: serviceAuth, 226 + } 227 + 228 + return x.Router() 152 229 } 153 230 154 231 func (s *Spindle) processPipeline(ctx context.Context, src eventconsumer.Source, msg eventconsumer.Message) error { ··· 168 245 return fmt.Errorf("no repo data found") 169 246 } 170 247 248 + if src.Key() != tpl.TriggerMetadata.Repo.Knot { 249 + return fmt.Errorf("repo knot does not match event source: %s != %s", src.Key(), tpl.TriggerMetadata.Repo.Knot) 250 + } 251 + 171 252 // filter by repos 172 253 _, err = s.db.GetRepo( 173 254 tpl.TriggerMetadata.Repo.Knot, ··· 183 264 Rkey: msg.Rkey, 184 265 } 185 266 267 + workflows := make(map[models.Engine][]models.Workflow) 268 + 186 269 for _, w := range tpl.Workflows { 187 270 if w != nil { 188 - err := s.db.StatusPending(models.WorkflowId{ 271 + if _, ok := s.engs[w.Engine]; !ok { 272 + err = s.db.StatusFailed(models.WorkflowId{ 273 + PipelineId: pipelineId, 274 + Name: w.Name, 275 + }, fmt.Sprintf("unknown engine %#v", w.Engine), -1, s.n) 276 + if err != nil { 277 + return err 278 + } 279 + 280 + continue 281 + } 282 + 283 + eng := s.engs[w.Engine] 284 + 285 + if _, ok := workflows[eng]; !ok { 286 + workflows[eng] = []models.Workflow{} 287 + } 288 + 289 + ewf, err := s.engs[w.Engine].InitWorkflow(*w, tpl) 290 + if err != nil { 291 + return err 292 + } 293 + 294 + workflows[eng] = append(workflows[eng], *ewf) 295 + 296 + err = s.db.StatusPending(models.WorkflowId{ 189 297 PipelineId: pipelineId, 190 298 Name: w.Name, 191 299 }, s.n) ··· 195 303 } 196 304 } 197 305 198 - spl := models.ToPipeline(tpl, *s.cfg) 199 - 200 306 ok := s.jq.Enqueue(queue.Job{ 201 307 Run: func() error { 202 - s.eng.StartWorkflows(ctx, spl, pipelineId) 308 + engine.StartWorkflows(s.l, s.vault, s.cfg, s.db, s.n, ctx, &models.Pipeline{ 309 + RepoOwner: tpl.TriggerMetadata.Repo.Did, 310 + RepoName: tpl.TriggerMetadata.Repo.Repo, 311 + Workflows: workflows, 312 + }, pipelineId) 203 313 return nil 204 314 }, 205 315 OnFail: func(jobError error) { ··· 218 328 219 329 func (s *Spindle) configureOwner() error { 220 330 cfgOwner := s.cfg.Server.Owner 221 - serverOwner, err := s.e.GetUserByRole("server:owner", rbacDomain) 331 + 332 + existing, err := s.e.GetSpindleUsersByRole("server:owner", rbacDomain) 222 333 if err != nil { 223 - return fmt.Errorf("failed to fetch server:owner: %w", err) 334 + return err 224 335 } 225 336 226 - if len(serverOwner) == 0 { 227 - s.e.AddKnotOwner(rbacDomain, cfgOwner) 228 - } else { 229 - if serverOwner[0] != cfgOwner { 230 - return fmt.Errorf("server owner mismatch: %s != %s", cfgOwner, serverOwner[0]) 337 + switch len(existing) { 338 + case 0: 339 + // no owner configured, continue 340 + case 1: 341 + // find existing owner 342 + existingOwner := existing[0] 343 + 344 + // no ownership change, this is okay 345 + if existingOwner == s.cfg.Server.Owner { 346 + break 231 347 } 348 + 349 + // remove existing owner 350 + err = s.e.RemoveSpindleOwner(rbacDomain, existingOwner) 351 + if err != nil { 352 + return nil 353 + } 354 + default: 355 + return fmt.Errorf("more than one owner in DB, try deleting %q and starting over", s.cfg.Server.DBPath) 232 356 } 233 - return nil 357 + 358 + return s.e.AddSpindleOwner(rbacDomain, cfgOwner) 234 359 }
+104 -76
spindle/stream.go
··· 4 4 "context" 5 5 "encoding/json" 6 6 "fmt" 7 + "io" 7 8 "net/http" 9 + "os" 8 10 "strconv" 9 11 "time" 10 12 ··· 12 14 13 15 "github.com/go-chi/chi/v5" 14 16 "github.com/gorilla/websocket" 17 + "github.com/hpcloud/tail" 15 18 ) 16 19 17 20 var upgrader = websocket.Upgrader{ ··· 88 91 } 89 92 90 93 func (s *Spindle) Logs(w http.ResponseWriter, r *http.Request) { 91 - l := s.l.With("handler", "Logs") 92 - 93 - knot := chi.URLParam(r, "knot") 94 - if knot == "" { 95 - http.Error(w, "knot required", http.StatusBadRequest) 94 + wid, err := getWorkflowID(r) 95 + if err != nil { 96 + http.Error(w, err.Error(), http.StatusBadRequest) 96 97 return 97 98 } 98 99 99 - rkey := chi.URLParam(r, "rkey") 100 - if rkey == "" { 101 - http.Error(w, "rkey required", http.StatusBadRequest) 102 - return 103 - } 104 - 105 - name := chi.URLParam(r, "name") 106 - if name == "" { 107 - http.Error(w, "name required", http.StatusBadRequest) 108 - return 109 - } 110 - 111 - wid := models.WorkflowId{ 112 - PipelineId: models.PipelineId{ 113 - Knot: knot, 114 - Rkey: rkey, 115 - }, 116 - Name: name, 117 - } 118 - 119 - l = l.With("knot", knot, "rkey", rkey, "name", name) 100 + l := s.l.With("handler", "Logs") 101 + l = s.l.With("wid", wid) 120 102 121 103 conn, err := upgrader.Upgrade(w, r, nil) 122 104 if err != nil { ··· 124 106 http.Error(w, "failed to upgrade", http.StatusInternalServerError) 125 107 return 126 108 } 127 - defer conn.Close() 109 + defer func() { 110 + _ = conn.WriteControl( 111 + websocket.CloseMessage, 112 + websocket.FormatCloseMessage(websocket.CloseNormalClosure, "log stream complete"), 113 + time.Now().Add(time.Second), 114 + ) 115 + conn.Close() 116 + }() 128 117 l.Debug("upgraded http to wss") 129 118 130 119 ctx, cancel := context.WithCancel(r.Context()) ··· 140 129 } 141 130 }() 142 131 143 - if err := s.streamLogs(ctx, conn, wid); err != nil { 144 - l.Error("streamLogs failed", "err", err) 132 + if err := s.streamLogsFromDisk(ctx, conn, wid); err != nil { 133 + l.Info("log stream ended", "err", err) 145 134 } 146 - l.Debug("logs connection closed") 147 - } 148 135 149 - func (s *Spindle) streamLogs(ctx context.Context, conn *websocket.Conn, wid models.WorkflowId) error { 150 - l := s.l.With("workflow_id", wid.String()) 136 + l.Info("logs connection closed") 137 + } 151 138 152 - stdoutCh, stderrCh, ok := s.eng.LogChannels(wid) 153 - if !ok { 154 - return fmt.Errorf("workflow_id %q not found", wid.String()) 139 + func (s *Spindle) streamLogsFromDisk(ctx context.Context, conn *websocket.Conn, wid models.WorkflowId) error { 140 + status, err := s.db.GetStatus(wid) 141 + if err != nil { 142 + return err 155 143 } 144 + isFinished := models.StatusKind(status.Status).IsFinish() 156 145 157 - done := make(chan struct{}) 146 + filePath := models.LogFilePath(s.cfg.Server.LogDir, wid) 158 147 159 - go func() { 160 - for { 161 - select { 162 - case line, ok := <-stdoutCh: 163 - if !ok { 164 - done <- struct{}{} 165 - return 166 - } 167 - msg := map[string]string{"type": "stdout", "data": line} 168 - if err := conn.WriteJSON(msg); err != nil { 169 - l.Error("write stdout failed", "err", err) 170 - done <- struct{}{} 171 - return 172 - } 173 - case <-ctx.Done(): 174 - done <- struct{}{} 175 - return 148 + if status.Status == models.StatusKindFailed.String() && status.Error != nil { 149 + if _, err := os.Stat(filePath); os.IsNotExist(err) { 150 + msgs := []models.LogLine{ 151 + { 152 + Kind: models.LogKindControl, 153 + Content: "", 154 + StepId: 0, 155 + StepKind: models.StepKindUser, 156 + }, 157 + { 158 + Kind: models.LogKindData, 159 + Content: *status.Error, 160 + }, 176 161 } 177 - } 178 - }() 179 162 180 - go func() { 181 - for { 182 - select { 183 - case line, ok := <-stderrCh: 184 - if !ok { 185 - done <- struct{}{} 186 - return 163 + for _, msg := range msgs { 164 + b, err := json.Marshal(msg) 165 + if err != nil { 166 + return err 187 167 } 188 - msg := map[string]string{"type": "stderr", "data": line} 189 - if err := conn.WriteJSON(msg); err != nil { 190 - l.Error("write stderr failed", "err", err) 191 - done <- struct{}{} 192 - return 168 + 169 + if err := conn.WriteMessage(websocket.TextMessage, b); err != nil { 170 + return fmt.Errorf("failed to write to websocket: %w", err) 193 171 } 194 - case <-ctx.Done(): 195 - done <- struct{}{} 196 - return 197 172 } 173 + 174 + return nil 198 175 } 199 - }() 176 + } 200 177 201 - select { 202 - case <-done: 203 - case <-ctx.Done(): 178 + config := tail.Config{ 179 + Follow: !isFinished, 180 + ReOpen: !isFinished, 181 + MustExist: false, 182 + Location: &tail.SeekInfo{ 183 + Offset: 0, 184 + Whence: io.SeekStart, 185 + }, 186 + // Logger: tail.DiscardingLogger, 204 187 } 205 188 206 - return nil 189 + t, err := tail.TailFile(filePath, config) 190 + if err != nil { 191 + return fmt.Errorf("failed to tail log file: %w", err) 192 + } 193 + defer t.Stop() 194 + 195 + for { 196 + select { 197 + case <-ctx.Done(): 198 + return ctx.Err() 199 + case line := <-t.Lines: 200 + if line == nil && isFinished { 201 + return fmt.Errorf("tail completed") 202 + } 203 + 204 + if line == nil { 205 + return fmt.Errorf("tail channel closed unexpectedly") 206 + } 207 + 208 + if line.Err != nil { 209 + return fmt.Errorf("error tailing log file: %w", line.Err) 210 + } 211 + 212 + if err := conn.WriteMessage(websocket.TextMessage, []byte(line.Text)); err != nil { 213 + return fmt.Errorf("failed to write to websocket: %w", err) 214 + } 215 + } 216 + } 207 217 } 208 218 209 219 func (s *Spindle) streamPipelines(conn *websocket.Conn, cursor *int64) error { ··· 242 252 243 253 return nil 244 254 } 255 + 256 + func getWorkflowID(r *http.Request) (models.WorkflowId, error) { 257 + knot := chi.URLParam(r, "knot") 258 + rkey := chi.URLParam(r, "rkey") 259 + name := chi.URLParam(r, "name") 260 + 261 + if knot == "" || rkey == "" || name == "" { 262 + return models.WorkflowId{}, fmt.Errorf("missing required parameters") 263 + } 264 + 265 + return models.WorkflowId{ 266 + PipelineId: models.PipelineId{ 267 + Knot: knot, 268 + Rkey: rkey, 269 + }, 270 + Name: name, 271 + }, nil 272 + }
+92
spindle/xrpc/add_secret.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net/http" 7 + "time" 8 + 9 + "github.com/bluesky-social/indigo/api/atproto" 10 + "github.com/bluesky-social/indigo/atproto/syntax" 11 + "github.com/bluesky-social/indigo/xrpc" 12 + securejoin "github.com/cyphar/filepath-securejoin" 13 + "tangled.sh/tangled.sh/core/api/tangled" 14 + "tangled.sh/tangled.sh/core/rbac" 15 + "tangled.sh/tangled.sh/core/spindle/secrets" 16 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 17 + ) 18 + 19 + func (x *Xrpc) AddSecret(w http.ResponseWriter, r *http.Request) { 20 + l := x.Logger 21 + fail := func(e xrpcerr.XrpcError) { 22 + l.Error("failed", "kind", e.Tag, "error", e.Message) 23 + writeError(w, e, http.StatusBadRequest) 24 + } 25 + 26 + actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 27 + if !ok { 28 + fail(xrpcerr.MissingActorDidError) 29 + return 30 + } 31 + 32 + var data tangled.RepoAddSecret_Input 33 + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 34 + fail(xrpcerr.GenericError(err)) 35 + return 36 + } 37 + 38 + if err := secrets.ValidateKey(data.Key); err != nil { 39 + fail(xrpcerr.GenericError(err)) 40 + return 41 + } 42 + 43 + // unfortunately we have to resolve repo-at here 44 + repoAt, err := syntax.ParseATURI(data.Repo) 45 + if err != nil { 46 + fail(xrpcerr.InvalidRepoError(data.Repo)) 47 + return 48 + } 49 + 50 + // resolve this aturi to extract the repo record 51 + ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String()) 52 + if err != nil || ident.Handle.IsInvalidHandle() { 53 + fail(xrpcerr.GenericError(fmt.Errorf("failed to resolve handle: %w", err))) 54 + return 55 + } 56 + 57 + xrpcc := xrpc.Client{Host: ident.PDSEndpoint()} 58 + resp, err := atproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 59 + if err != nil { 60 + fail(xrpcerr.GenericError(err)) 61 + return 62 + } 63 + 64 + repo := resp.Value.Val.(*tangled.Repo) 65 + didPath, err := securejoin.SecureJoin(repo.Owner, repo.Name) 66 + if err != nil { 67 + fail(xrpcerr.GenericError(err)) 68 + return 69 + } 70 + 71 + if ok, err := x.Enforcer.IsSettingsAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil { 72 + l.Error("insufficent permissions", "did", actorDid.String()) 73 + writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 74 + return 75 + } 76 + 77 + secret := secrets.UnlockedSecret{ 78 + Repo: secrets.DidSlashRepo(didPath), 79 + Key: data.Key, 80 + Value: data.Value, 81 + CreatedAt: time.Now(), 82 + CreatedBy: actorDid, 83 + } 84 + err = x.Vault.AddSecret(r.Context(), secret) 85 + if err != nil { 86 + l.Error("failed to add secret to vault", "did", actorDid.String(), "err", err) 87 + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 88 + return 89 + } 90 + 91 + w.WriteHeader(http.StatusOK) 92 + }
+92
spindle/xrpc/list_secrets.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net/http" 7 + "time" 8 + 9 + "github.com/bluesky-social/indigo/api/atproto" 10 + "github.com/bluesky-social/indigo/atproto/syntax" 11 + "github.com/bluesky-social/indigo/xrpc" 12 + securejoin "github.com/cyphar/filepath-securejoin" 13 + "tangled.sh/tangled.sh/core/api/tangled" 14 + "tangled.sh/tangled.sh/core/rbac" 15 + "tangled.sh/tangled.sh/core/spindle/secrets" 16 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 17 + ) 18 + 19 + func (x *Xrpc) ListSecrets(w http.ResponseWriter, r *http.Request) { 20 + l := x.Logger 21 + fail := func(e xrpcerr.XrpcError) { 22 + l.Error("failed", "kind", e.Tag, "error", e.Message) 23 + writeError(w, e, http.StatusBadRequest) 24 + } 25 + 26 + actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 27 + if !ok { 28 + fail(xrpcerr.MissingActorDidError) 29 + return 30 + } 31 + 32 + repoParam := r.URL.Query().Get("repo") 33 + if repoParam == "" { 34 + fail(xrpcerr.GenericError(fmt.Errorf("empty params"))) 35 + return 36 + } 37 + 38 + // unfortunately we have to resolve repo-at here 39 + repoAt, err := syntax.ParseATURI(repoParam) 40 + if err != nil { 41 + fail(xrpcerr.InvalidRepoError(repoParam)) 42 + return 43 + } 44 + 45 + // resolve this aturi to extract the repo record 46 + ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String()) 47 + if err != nil || ident.Handle.IsInvalidHandle() { 48 + fail(xrpcerr.GenericError(fmt.Errorf("failed to resolve handle: %w", err))) 49 + return 50 + } 51 + 52 + xrpcc := xrpc.Client{Host: ident.PDSEndpoint()} 53 + resp, err := atproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 54 + if err != nil { 55 + fail(xrpcerr.GenericError(err)) 56 + return 57 + } 58 + 59 + repo := resp.Value.Val.(*tangled.Repo) 60 + didPath, err := securejoin.SecureJoin(repo.Owner, repo.Name) 61 + if err != nil { 62 + fail(xrpcerr.GenericError(err)) 63 + return 64 + } 65 + 66 + if ok, err := x.Enforcer.IsSettingsAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil { 67 + l.Error("insufficent permissions", "did", actorDid.String()) 68 + writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 69 + return 70 + } 71 + 72 + ls, err := x.Vault.GetSecretsLocked(r.Context(), secrets.DidSlashRepo(didPath)) 73 + if err != nil { 74 + l.Error("failed to get secret from vault", "did", actorDid.String(), "err", err) 75 + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 76 + return 77 + } 78 + 79 + var out tangled.RepoListSecrets_Output 80 + for _, l := range ls { 81 + out.Secrets = append(out.Secrets, &tangled.RepoListSecrets_Secret{ 82 + Repo: repoAt.String(), 83 + Key: l.Key, 84 + CreatedAt: l.CreatedAt.Format(time.RFC3339), 85 + CreatedBy: l.CreatedBy.String(), 86 + }) 87 + } 88 + 89 + w.Header().Set("Content-Type", "application/json") 90 + w.WriteHeader(http.StatusOK) 91 + json.NewEncoder(w).Encode(out) 92 + }
+31
spindle/xrpc/owner.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "net/http" 6 + 7 + "tangled.sh/tangled.sh/core/api/tangled" 8 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 9 + ) 10 + 11 + func (x *Xrpc) Owner(w http.ResponseWriter, r *http.Request) { 12 + owner := x.Config.Server.Owner 13 + if owner == "" { 14 + writeError(w, xrpcerr.OwnerNotFoundError, http.StatusInternalServerError) 15 + return 16 + } 17 + 18 + response := tangled.Owner_Output{ 19 + Owner: owner, 20 + } 21 + 22 + w.Header().Set("Content-Type", "application/json") 23 + if err := json.NewEncoder(w).Encode(response); err != nil { 24 + x.Logger.Error("failed to encode response", "error", err) 25 + writeError(w, xrpcerr.NewXrpcError( 26 + xrpcerr.WithTag("InternalServerError"), 27 + xrpcerr.WithMessage("failed to encode response"), 28 + ), http.StatusInternalServerError) 29 + return 30 + } 31 + }
+83
spindle/xrpc/remove_secret.go
··· 1 + package xrpc 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net/http" 7 + 8 + "github.com/bluesky-social/indigo/api/atproto" 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + "github.com/bluesky-social/indigo/xrpc" 11 + securejoin "github.com/cyphar/filepath-securejoin" 12 + "tangled.sh/tangled.sh/core/api/tangled" 13 + "tangled.sh/tangled.sh/core/rbac" 14 + "tangled.sh/tangled.sh/core/spindle/secrets" 15 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 16 + ) 17 + 18 + func (x *Xrpc) RemoveSecret(w http.ResponseWriter, r *http.Request) { 19 + l := x.Logger 20 + fail := func(e xrpcerr.XrpcError) { 21 + l.Error("failed", "kind", e.Tag, "error", e.Message) 22 + writeError(w, e, http.StatusBadRequest) 23 + } 24 + 25 + actorDid, ok := r.Context().Value(ActorDid).(syntax.DID) 26 + if !ok { 27 + fail(xrpcerr.MissingActorDidError) 28 + return 29 + } 30 + 31 + var data tangled.RepoRemoveSecret_Input 32 + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 33 + fail(xrpcerr.GenericError(err)) 34 + return 35 + } 36 + 37 + // unfortunately we have to resolve repo-at here 38 + repoAt, err := syntax.ParseATURI(data.Repo) 39 + if err != nil { 40 + fail(xrpcerr.InvalidRepoError(data.Repo)) 41 + return 42 + } 43 + 44 + // resolve this aturi to extract the repo record 45 + ident, err := x.Resolver.ResolveIdent(r.Context(), repoAt.Authority().String()) 46 + if err != nil || ident.Handle.IsInvalidHandle() { 47 + fail(xrpcerr.GenericError(fmt.Errorf("failed to resolve handle: %w", err))) 48 + return 49 + } 50 + 51 + xrpcc := xrpc.Client{Host: ident.PDSEndpoint()} 52 + resp, err := atproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoNSID, repoAt.Authority().String(), repoAt.RecordKey().String()) 53 + if err != nil { 54 + fail(xrpcerr.GenericError(err)) 55 + return 56 + } 57 + 58 + repo := resp.Value.Val.(*tangled.Repo) 59 + didPath, err := securejoin.SecureJoin(repo.Owner, repo.Name) 60 + if err != nil { 61 + fail(xrpcerr.GenericError(err)) 62 + return 63 + } 64 + 65 + if ok, err := x.Enforcer.IsSettingsAllowed(actorDid.String(), rbac.ThisServer, didPath); !ok || err != nil { 66 + l.Error("insufficent permissions", "did", actorDid.String()) 67 + writeError(w, xrpcerr.AccessControlError(actorDid.String()), http.StatusUnauthorized) 68 + return 69 + } 70 + 71 + secret := secrets.Secret[any]{ 72 + Repo: secrets.DidSlashRepo(didPath), 73 + Key: data.Key, 74 + } 75 + err = x.Vault.RemoveSecret(r.Context(), secret) 76 + if err != nil { 77 + l.Error("failed to remove secret from vault", "did", actorDid.String(), "err", err) 78 + writeError(w, xrpcerr.GenericError(err), http.StatusInternalServerError) 79 + return 80 + } 81 + 82 + w.WriteHeader(http.StatusOK) 83 + }
+59
spindle/xrpc/xrpc.go
··· 1 + package xrpc 2 + 3 + import ( 4 + _ "embed" 5 + "encoding/json" 6 + "log/slog" 7 + "net/http" 8 + 9 + "github.com/go-chi/chi/v5" 10 + 11 + "tangled.sh/tangled.sh/core/api/tangled" 12 + "tangled.sh/tangled.sh/core/idresolver" 13 + "tangled.sh/tangled.sh/core/rbac" 14 + "tangled.sh/tangled.sh/core/spindle/config" 15 + "tangled.sh/tangled.sh/core/spindle/db" 16 + "tangled.sh/tangled.sh/core/spindle/models" 17 + "tangled.sh/tangled.sh/core/spindle/secrets" 18 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 19 + "tangled.sh/tangled.sh/core/xrpc/serviceauth" 20 + ) 21 + 22 + const ActorDid string = "ActorDid" 23 + 24 + type Xrpc struct { 25 + Logger *slog.Logger 26 + Db *db.DB 27 + Enforcer *rbac.Enforcer 28 + Engines map[string]models.Engine 29 + Config *config.Config 30 + Resolver *idresolver.Resolver 31 + Vault secrets.Manager 32 + ServiceAuth *serviceauth.ServiceAuth 33 + } 34 + 35 + func (x *Xrpc) Router() http.Handler { 36 + r := chi.NewRouter() 37 + 38 + r.Group(func(r chi.Router) { 39 + r.Use(x.ServiceAuth.VerifyServiceAuth) 40 + 41 + r.Post("/"+tangled.RepoAddSecretNSID, x.AddSecret) 42 + r.Post("/"+tangled.RepoRemoveSecretNSID, x.RemoveSecret) 43 + r.Get("/"+tangled.RepoListSecretsNSID, x.ListSecrets) 44 + }) 45 + 46 + // service query endpoints (no auth required) 47 + r.Get("/"+tangled.OwnerNSID, x.Owner) 48 + 49 + return r 50 + } 51 + 52 + // this is slightly different from http_util::write_error to follow the spec: 53 + // 54 + // the json object returned must include an "error" and a "message" 55 + func writeError(w http.ResponseWriter, e xrpcerr.XrpcError, status int) { 56 + w.Header().Set("Content-Type", "application/json") 57 + w.WriteHeader(status) 58 + json.NewEncoder(w).Encode(e) 59 + }
+1 -3
tailwind.config.js
··· 36 36 css: { 37 37 maxWidth: "none", 38 38 pre: { 39 - backgroundColor: colors.gray[100], 40 - color: colors.black, 41 - "@apply font-normal text-black bg-gray-100 dark:bg-gray-900 dark:text-gray-300 dark:border-gray-700 dark:border": {}, 39 + "@apply font-normal text-black bg-gray-50 dark:bg-gray-900 dark:text-gray-300 dark:border-gray-700 border": {}, 42 40 }, 43 41 code: { 44 42 "@apply font-normal font-mono p-1 rounded text-black bg-gray-100 dark:bg-gray-900 dark:text-gray-300 dark:border-gray-700": {},
+26
types/diff.go
··· 5 5 "github.com/go-git/go-git/v5/plumbing/object" 6 6 ) 7 7 8 + type DiffOpts struct { 9 + Split bool `json:"split"` 10 + } 11 + 8 12 type TextFragment struct { 9 13 Header string `json:"comment"` 10 14 Lines []gitdiff.Line `json:"lines"` ··· 77 81 78 82 return files 79 83 } 84 + 85 + // used by html elements as a unique ID for hrefs 86 + func (d *Diff) Id() string { 87 + return d.Name.New 88 + } 89 + 90 + func (d *Diff) Split() *SplitDiff { 91 + fragments := make([]SplitFragment, len(d.TextFragments)) 92 + for i, fragment := range d.TextFragments { 93 + leftLines, rightLines := SeparateLines(&fragment) 94 + fragments[i] = SplitFragment{ 95 + Header: fragment.Header(), 96 + LeftLines: leftLines, 97 + RightLines: rightLines, 98 + } 99 + } 100 + 101 + return &SplitDiff{ 102 + Name: d.Id(), 103 + TextFragments: fragments, 104 + } 105 + }
+8 -2
types/repo.go
··· 109 109 Status ForkStatus `json:"status"` 110 110 } 111 111 112 + type RepoLanguageDetails struct { 113 + Name string 114 + Percentage float32 115 + Color string 116 + } 117 + 112 118 type RepoLanguageResponse struct { 113 - // Language: Percentage 114 - Languages map[string]int `json:"languages"` 119 + // Language: File count 120 + Languages map[string]int64 `json:"languages"` 115 121 }
+131
types/split.go
··· 1 + package types 2 + 3 + import ( 4 + "github.com/bluekeyes/go-gitdiff/gitdiff" 5 + ) 6 + 7 + type SplitLine struct { 8 + LineNumber int `json:"line_number,omitempty"` 9 + Content string `json:"content"` 10 + Op gitdiff.LineOp `json:"op"` 11 + IsEmpty bool `json:"is_empty"` 12 + } 13 + 14 + type SplitFragment struct { 15 + Header string `json:"header"` 16 + LeftLines []SplitLine `json:"left_lines"` 17 + RightLines []SplitLine `json:"right_lines"` 18 + } 19 + 20 + type SplitDiff struct { 21 + Name string `json:"name"` 22 + TextFragments []SplitFragment `json:"fragments"` 23 + } 24 + 25 + // used by html elements as a unique ID for hrefs 26 + func (d *SplitDiff) Id() string { 27 + return d.Name 28 + } 29 + 30 + // separate lines into left and right, this includes additional logic to 31 + // group consecutive runs of additions and deletions in order to align them 32 + // properly in the final output 33 + // 34 + // TODO: move all diff stuff to a single package, we are spread across patchutil and types right now 35 + func SeparateLines(fragment *gitdiff.TextFragment) ([]SplitLine, []SplitLine) { 36 + lines := fragment.Lines 37 + var leftLines, rightLines []SplitLine 38 + oldLineNum := fragment.OldPosition 39 + newLineNum := fragment.NewPosition 40 + 41 + // process deletions and additions in groups for better alignment 42 + i := 0 43 + for i < len(lines) { 44 + line := lines[i] 45 + 46 + switch line.Op { 47 + case gitdiff.OpContext: 48 + leftLines = append(leftLines, SplitLine{ 49 + LineNumber: int(oldLineNum), 50 + Content: line.Line, 51 + Op: gitdiff.OpContext, 52 + IsEmpty: false, 53 + }) 54 + rightLines = append(rightLines, SplitLine{ 55 + LineNumber: int(newLineNum), 56 + Content: line.Line, 57 + Op: gitdiff.OpContext, 58 + IsEmpty: false, 59 + }) 60 + oldLineNum++ 61 + newLineNum++ 62 + i++ 63 + 64 + case gitdiff.OpDelete: 65 + deletionCount := 0 66 + for j := i; j < len(lines) && lines[j].Op == gitdiff.OpDelete; j++ { 67 + leftLines = append(leftLines, SplitLine{ 68 + LineNumber: int(oldLineNum), 69 + Content: lines[j].Line, 70 + Op: gitdiff.OpDelete, 71 + IsEmpty: false, 72 + }) 73 + oldLineNum++ 74 + deletionCount++ 75 + } 76 + i += deletionCount 77 + 78 + additionCount := 0 79 + for j := i; j < len(lines) && lines[j].Op == gitdiff.OpAdd; j++ { 80 + rightLines = append(rightLines, SplitLine{ 81 + LineNumber: int(newLineNum), 82 + Content: lines[j].Line, 83 + Op: gitdiff.OpAdd, 84 + IsEmpty: false, 85 + }) 86 + newLineNum++ 87 + additionCount++ 88 + } 89 + i += additionCount 90 + 91 + // add empty lines to balance the sides 92 + if deletionCount > additionCount { 93 + // more deletions than additions - pad right side 94 + for k := 0; k < deletionCount-additionCount; k++ { 95 + rightLines = append(rightLines, SplitLine{ 96 + Content: "", 97 + Op: gitdiff.OpContext, 98 + IsEmpty: true, 99 + }) 100 + } 101 + } else if additionCount > deletionCount { 102 + // more additions than deletions - pad left side 103 + for k := 0; k < additionCount-deletionCount; k++ { 104 + leftLines = append(leftLines, SplitLine{ 105 + Content: "", 106 + Op: gitdiff.OpContext, 107 + IsEmpty: true, 108 + }) 109 + } 110 + } 111 + 112 + case gitdiff.OpAdd: 113 + // standalone addition (not preceded by deletion) 114 + leftLines = append(leftLines, SplitLine{ 115 + Content: "", 116 + Op: gitdiff.OpContext, 117 + IsEmpty: true, 118 + }) 119 + rightLines = append(rightLines, SplitLine{ 120 + LineNumber: int(newLineNum), 121 + Content: line.Line, 122 + Op: gitdiff.OpAdd, 123 + IsEmpty: false, 124 + }) 125 + newLineNum++ 126 + i++ 127 + } 128 + } 129 + 130 + return leftLines, rightLines 131 + }
-1
w
··· 1 - www
+62 -41
workflow/compile.go
··· 1 1 package workflow 2 2 3 3 import ( 4 + "errors" 4 5 "fmt" 5 6 6 7 "tangled.sh/tangled.sh/core/api/tangled" 7 8 ) 8 9 10 + type RawWorkflow struct { 11 + Name string 12 + Contents []byte 13 + } 14 + 15 + type RawPipeline = []RawWorkflow 16 + 9 17 type Compiler struct { 10 18 Trigger tangled.Pipeline_TriggerMetadata 11 19 Diagnostics Diagnostics 12 20 } 13 21 14 22 type Diagnostics struct { 15 - Errors []error 23 + Errors []Error 16 24 Warnings []Warning 17 25 } 18 26 27 + func (d *Diagnostics) IsEmpty() bool { 28 + return len(d.Errors) == 0 && len(d.Warnings) == 0 29 + } 30 + 19 31 func (d *Diagnostics) Combine(o Diagnostics) { 20 32 d.Errors = append(d.Errors, o.Errors...) 21 33 d.Warnings = append(d.Warnings, o.Warnings...) ··· 25 37 d.Warnings = append(d.Warnings, Warning{path, kind, reason}) 26 38 } 27 39 28 - func (d *Diagnostics) AddError(err error) { 29 - d.Errors = append(d.Errors, err) 40 + func (d *Diagnostics) AddError(path string, err error) { 41 + d.Errors = append(d.Errors, Error{path, err}) 30 42 } 31 43 32 44 func (d Diagnostics) IsErr() bool { 33 45 return len(d.Errors) != 0 34 46 } 35 47 48 + type Error struct { 49 + Path string 50 + Error error 51 + } 52 + 53 + func (e Error) String() string { 54 + return fmt.Sprintf("error: %s: %s", e.Path, e.Error.Error()) 55 + } 56 + 36 57 type Warning struct { 37 58 Path string 38 59 Type WarningKind 39 60 Reason string 40 61 } 41 62 63 + func (w Warning) String() string { 64 + return fmt.Sprintf("warning: %s: %s: %s", w.Path, w.Type, w.Reason) 65 + } 66 + 67 + var ( 68 + MissingEngine error = errors.New("missing engine") 69 + ) 70 + 42 71 type WarningKind string 43 72 44 73 var ( ··· 46 75 InvalidConfiguration WarningKind = "invalid configuration" 47 76 ) 48 77 78 + func (compiler *Compiler) Parse(p RawPipeline) Pipeline { 79 + var pp Pipeline 80 + 81 + for _, w := range p { 82 + wf, err := FromFile(w.Name, w.Contents) 83 + if err != nil { 84 + compiler.Diagnostics.AddError(w.Name, err) 85 + continue 86 + } 87 + 88 + pp = append(pp, wf) 89 + } 90 + 91 + return pp 92 + } 93 + 49 94 // convert a repositories' workflow files into a fully compiled pipeline that runners accept 50 95 func (compiler *Compiler) Compile(p Pipeline) tangled.Pipeline { 51 96 cp := tangled.Pipeline{ 52 97 TriggerMetadata: &compiler.Trigger, 53 98 } 54 99 55 - for _, w := range p { 56 - cw := compiler.compileWorkflow(w) 100 + for _, wf := range p { 101 + cw := compiler.compileWorkflow(wf) 57 102 58 - // empty workflows are not added to the pipeline 59 - if len(cw.Steps) == 0 { 103 + if cw == nil { 60 104 continue 61 105 } 62 106 63 - cp.Workflows = append(cp.Workflows, &cw) 107 + cp.Workflows = append(cp.Workflows, cw) 64 108 } 65 109 66 110 return cp 67 111 } 68 112 69 - func (compiler *Compiler) compileWorkflow(w Workflow) tangled.Pipeline_Workflow { 70 - cw := tangled.Pipeline_Workflow{} 113 + func (compiler *Compiler) compileWorkflow(w Workflow) *tangled.Pipeline_Workflow { 114 + cw := &tangled.Pipeline_Workflow{} 71 115 72 116 if !w.Match(compiler.Trigger) { 73 117 compiler.Diagnostics.AddWarning( ··· 75 119 WorkflowSkipped, 76 120 fmt.Sprintf("did not match trigger %s", compiler.Trigger.Kind), 77 121 ) 78 - return cw 79 - } 80 - 81 - if len(w.Steps) == 0 { 82 - compiler.Diagnostics.AddWarning( 83 - w.Name, 84 - WorkflowSkipped, 85 - "empty workflow", 86 - ) 87 - return cw 122 + return nil 88 123 } 89 124 90 125 // validate clone options 91 126 compiler.analyzeCloneOptions(w) 92 127 93 128 cw.Name = w.Name 94 - cw.Dependencies = w.Dependencies.AsRecord() 95 - for _, s := range w.Steps { 96 - step := tangled.Pipeline_Step{ 97 - Command: s.Command, 98 - Name: s.Name, 99 - } 100 - for k, v := range s.Environment { 101 - e := &tangled.Pipeline_Step_Environment_Elem{ 102 - Key: k, 103 - Value: v, 104 - } 105 - step.Environment = append(step.Environment, e) 106 - } 107 - cw.Steps = append(cw.Steps, &step) 129 + 130 + if w.Engine == "" { 131 + compiler.Diagnostics.AddError(w.Name, MissingEngine) 132 + return nil 108 133 } 109 - for k, v := range w.Environment { 110 - e := &tangled.Pipeline_Workflow_Environment_Elem{ 111 - Key: k, 112 - Value: v, 113 - } 114 - cw.Environment = append(cw.Environment, e) 115 - } 134 + 135 + cw.Engine = w.Engine 136 + cw.Raw = w.Raw 116 137 117 138 o := w.CloneOpts.AsRecord() 118 139 cw.Clone = &o
+24 -30
workflow/compile_test.go
··· 9 9 ) 10 10 11 11 var trigger = tangled.Pipeline_TriggerMetadata{ 12 - Kind: TriggerKindPush, 12 + Kind: string(TriggerKindPush), 13 13 Push: &tangled.Pipeline_PushTriggerData{ 14 14 Ref: "refs/heads/main", 15 15 OldSha: strings.Repeat("0", 40), ··· 26 26 27 27 func TestCompileWorkflow_MatchingWorkflowWithSteps(t *testing.T) { 28 28 wf := Workflow{ 29 - Name: ".tangled/workflows/test.yml", 30 - When: when, 31 - Steps: []Step{ 32 - {Name: "Test", Command: "go test ./..."}, 33 - }, 29 + Name: ".tangled/workflows/test.yml", 30 + Engine: "nixery", 31 + When: when, 34 32 CloneOpts: CloneOpts{}, // default true 35 33 } 36 34 ··· 43 41 assert.False(t, c.Diagnostics.IsErr()) 44 42 } 45 43 46 - func TestCompileWorkflow_EmptySteps(t *testing.T) { 47 - wf := Workflow{ 48 - Name: ".tangled/workflows/empty.yml", 49 - When: when, 50 - Steps: []Step{}, // no steps 51 - } 52 - 53 - c := Compiler{Trigger: trigger} 54 - cp := c.Compile([]Workflow{wf}) 55 - 56 - assert.Len(t, cp.Workflows, 0) 57 - assert.Len(t, c.Diagnostics.Warnings, 1) 58 - assert.Equal(t, WorkflowSkipped, c.Diagnostics.Warnings[0].Type) 59 - } 60 - 61 44 func TestCompileWorkflow_TriggerMismatch(t *testing.T) { 62 45 wf := Workflow{ 63 - Name: ".tangled/workflows/mismatch.yml", 46 + Name: ".tangled/workflows/mismatch.yml", 47 + Engine: "nixery", 64 48 When: []Constraint{ 65 49 { 66 50 Event: []string{"push"}, 67 51 Branch: []string{"master"}, // different branch 68 52 }, 69 - }, 70 - Steps: []Step{ 71 - {Name: "Lint", Command: "golint ./..."}, 72 53 }, 73 54 } 74 55 ··· 82 63 83 64 func TestCompileWorkflow_CloneFalseWithShallowTrue(t *testing.T) { 84 65 wf := Workflow{ 85 - Name: ".tangled/workflows/clone_skip.yml", 86 - When: when, 87 - Steps: []Step{ 88 - {Name: "Skip", Command: "echo skip"}, 89 - }, 66 + Name: ".tangled/workflows/clone_skip.yml", 67 + Engine: "nixery", 68 + When: when, 90 69 CloneOpts: CloneOpts{ 91 70 Skip: true, 92 71 Depth: 1, ··· 101 80 assert.Len(t, c.Diagnostics.Warnings, 1) 102 81 assert.Equal(t, InvalidConfiguration, c.Diagnostics.Warnings[0].Type) 103 82 } 83 + 84 + func TestCompileWorkflow_MissingEngine(t *testing.T) { 85 + wf := Workflow{ 86 + Name: ".tangled/workflows/missing_engine.yml", 87 + When: when, 88 + Engine: "", 89 + } 90 + 91 + c := Compiler{Trigger: trigger} 92 + cp := c.Compile([]Workflow{wf}) 93 + 94 + assert.Len(t, cp.Workflows, 0) 95 + assert.Len(t, c.Diagnostics.Errors, 1) 96 + assert.Equal(t, MissingEngine, c.Diagnostics.Errors[0].Error) 97 + }
+18 -38
workflow/def.go
··· 4 4 "errors" 5 5 "fmt" 6 6 "slices" 7 + "strings" 7 8 8 9 "tangled.sh/tangled.sh/core/api/tangled" 9 10 ··· 23 24 24 25 // this is simply a structural representation of the workflow file 25 26 Workflow struct { 26 - Name string `yaml:"-"` // name of the workflow file 27 - When []Constraint `yaml:"when"` 28 - Dependencies Dependencies `yaml:"dependencies"` 29 - Steps []Step `yaml:"steps"` 30 - Environment map[string]string `yaml:"environment"` 31 - CloneOpts CloneOpts `yaml:"clone"` 27 + Name string `yaml:"-"` // name of the workflow file 28 + Engine string `yaml:"engine"` 29 + When []Constraint `yaml:"when"` 30 + CloneOpts CloneOpts `yaml:"clone"` 31 + Raw string `yaml:"-"` 32 32 } 33 33 34 34 Constraint struct { 35 35 Event StringList `yaml:"event"` 36 36 Branch StringList `yaml:"branch"` // this is optional, and only applied on "push" events 37 37 } 38 - 39 - Dependencies map[string][]string 40 38 41 39 CloneOpts struct { 42 40 Skip bool `yaml:"skip"` ··· 44 42 IncludeSubmodules bool `yaml:"submodules"` 45 43 } 46 44 47 - Step struct { 48 - Name string `yaml:"name"` 49 - Command string `yaml:"command"` 50 - Environment map[string]string `yaml:"environment"` 51 - } 45 + StringList []string 52 46 53 - StringList []string 47 + TriggerKind string 54 48 ) 55 49 56 50 const ( 57 - TriggerKindPush string = "push" 58 - TriggerKindPullRequest string = "pull_request" 59 - TriggerKindManual string = "manual" 51 + WorkflowDir = ".tangled/workflows" 52 + 53 + TriggerKindPush TriggerKind = "push" 54 + TriggerKindPullRequest TriggerKind = "pull_request" 55 + TriggerKindManual TriggerKind = "manual" 60 56 ) 57 + 58 + func (t TriggerKind) String() string { 59 + return strings.ReplaceAll(string(t), "_", " ") 60 + } 61 61 62 62 func FromFile(name string, contents []byte) (Workflow, error) { 63 63 var wf Workflow ··· 68 68 } 69 69 70 70 wf.Name = name 71 + wf.Raw = string(contents) 71 72 72 73 return wf, nil 73 74 } ··· 127 128 if refName.IsBranch() { 128 129 return slices.Contains(c.Branch, refName.Short()) 129 130 } 130 - fmt.Println("no", c.Branch, refName.Short()) 131 - 132 131 return false 133 132 } 134 133 ··· 166 165 } 167 166 168 167 return errors.New("failed to unmarshal StringOrSlice") 169 - } 170 - 171 - // conversion utilities to atproto records 172 - func (d Dependencies) AsRecord() []tangled.Pipeline_Dependencies_Elem { 173 - var deps []tangled.Pipeline_Dependencies_Elem 174 - for registry, packages := range d { 175 - deps = append(deps, tangled.Pipeline_Dependencies_Elem{ 176 - Registry: registry, 177 - Packages: packages, 178 - }) 179 - } 180 - return deps 181 - } 182 - 183 - func (s Step) AsRecord() tangled.Pipeline_Step { 184 - return tangled.Pipeline_Step{ 185 - Command: s.Command, 186 - Name: s.Name, 187 - } 188 168 } 189 169 190 170 func (c CloneOpts) AsRecord() tangled.Pipeline_CloneOpts {
+1 -86
workflow/def_test.go
··· 10 10 yamlData := ` 11 11 when: 12 12 - event: ["push", "pull_request"] 13 - branch: ["main", "develop"] 14 - 15 - dependencies: 16 - nixpkgs: 17 - - go 18 - - git 19 - - curl 20 - 21 - steps: 22 - - name: "Test" 23 - command: | 24 - go test ./...` 13 + branch: ["main", "develop"]` 25 14 26 15 wf, err := FromFile("test.yml", []byte(yamlData)) 27 16 assert.NoError(t, err, "YAML should unmarshal without error") ··· 30 19 assert.ElementsMatch(t, []string{"main", "develop"}, wf.When[0].Branch) 31 20 assert.ElementsMatch(t, []string{"push", "pull_request"}, wf.When[0].Event) 32 21 33 - assert.Len(t, wf.Steps, 1) 34 - assert.Equal(t, "Test", wf.Steps[0].Name) 35 - assert.Equal(t, "go test ./...", wf.Steps[0].Command) 36 - 37 - pkgs, ok := wf.Dependencies["nixpkgs"] 38 - assert.True(t, ok, "`nixpkgs` should be present in dependencies") 39 - assert.ElementsMatch(t, []string{"go", "git", "curl"}, pkgs) 40 - 41 22 assert.False(t, wf.CloneOpts.Skip, "Skip should default to false") 42 23 } 43 24 44 - func TestUnmarshalCustomRegistry(t *testing.T) { 45 - yamlData := ` 46 - when: 47 - - event: push 48 - branch: main 49 - 50 - dependencies: 51 - git+https://tangled.sh/@oppi.li/tbsp: 52 - - tbsp 53 - git+https://git.peppe.rs/languages/statix: 54 - - statix 55 - 56 - steps: 57 - - name: "Check" 58 - command: | 59 - statix check` 60 - 61 - wf, err := FromFile("test.yml", []byte(yamlData)) 62 - assert.NoError(t, err, "YAML should unmarshal without error") 63 - 64 - assert.ElementsMatch(t, []string{"push"}, wf.When[0].Event) 65 - assert.ElementsMatch(t, []string{"main"}, wf.When[0].Branch) 66 - 67 - assert.ElementsMatch(t, []string{"tbsp"}, wf.Dependencies["git+https://tangled.sh/@oppi.li/tbsp"]) 68 - assert.ElementsMatch(t, []string{"statix"}, wf.Dependencies["git+https://git.peppe.rs/languages/statix"]) 69 - } 70 - 71 25 func TestUnmarshalCloneFalse(t *testing.T) { 72 26 yamlData := ` 73 27 when: ··· 75 29 76 30 clone: 77 31 skip: true 78 - 79 - dependencies: 80 - nixpkgs: 81 - - python3 82 - 83 - steps: 84 - - name: Notify 85 - command: | 86 - python3 ./notify.py 87 32 ` 88 33 89 34 wf, err := FromFile("test.yml", []byte(yamlData)) ··· 93 38 94 39 assert.True(t, wf.CloneOpts.Skip, "Skip should be false") 95 40 } 96 - 97 - func TestUnmarshalEnv(t *testing.T) { 98 - yamlData := ` 99 - when: 100 - - event: ["pull_request_close"] 101 - 102 - clone: 103 - skip: false 104 - 105 - environment: 106 - HOME: /home/foo bar/baz 107 - CGO_ENABLED: 1 108 - 109 - steps: 110 - - name: Something 111 - command: echo "hello" 112 - environment: 113 - FOO: bar 114 - BAZ: qux 115 - ` 116 - 117 - wf, err := FromFile("test.yml", []byte(yamlData)) 118 - assert.NoError(t, err) 119 - 120 - assert.Len(t, wf.Environment, 2) 121 - assert.Equal(t, "/home/foo bar/baz", wf.Environment["HOME"]) 122 - assert.Equal(t, "1", wf.Environment["CGO_ENABLED"]) 123 - assert.Equal(t, "bar", wf.Steps[0].Environment["FOO"]) 124 - assert.Equal(t, "qux", wf.Steps[0].Environment["BAZ"]) 125 - }
-1
x
··· 1 - xxx
+125
xrpc/errors/errors.go
··· 1 + package errors 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + ) 7 + 8 + type XrpcError struct { 9 + Tag string `json:"error"` 10 + Message string `json:"message"` 11 + } 12 + 13 + func (x XrpcError) Error() string { 14 + if x.Message != "" { 15 + return fmt.Sprintf("%s: %s", x.Tag, x.Message) 16 + } 17 + return x.Tag 18 + } 19 + 20 + func NewXrpcError(opts ...ErrOpt) XrpcError { 21 + x := XrpcError{} 22 + for _, o := range opts { 23 + o(&x) 24 + } 25 + 26 + return x 27 + } 28 + 29 + type ErrOpt = func(xerr *XrpcError) 30 + 31 + func WithTag(tag string) ErrOpt { 32 + return func(xerr *XrpcError) { 33 + xerr.Tag = tag 34 + } 35 + } 36 + 37 + func WithMessage[S ~string](s S) ErrOpt { 38 + return func(xerr *XrpcError) { 39 + xerr.Message = string(s) 40 + } 41 + } 42 + 43 + func WithError(e error) ErrOpt { 44 + return func(xerr *XrpcError) { 45 + xerr.Message = e.Error() 46 + } 47 + } 48 + 49 + var MissingActorDidError = NewXrpcError( 50 + WithTag("MissingActorDid"), 51 + WithMessage("actor DID not supplied"), 52 + ) 53 + 54 + var OwnerNotFoundError = NewXrpcError( 55 + WithTag("OwnerNotFound"), 56 + WithMessage("owner not set for this service"), 57 + ) 58 + 59 + var RepoNotFoundError = NewXrpcError( 60 + WithTag("RepoNotFound"), 61 + WithMessage("failed to access repository"), 62 + ) 63 + 64 + var RefNotFoundError = NewXrpcError( 65 + WithTag("RefNotFound"), 66 + WithMessage("failed to access ref"), 67 + ) 68 + 69 + var AuthError = func(err error) XrpcError { 70 + return NewXrpcError( 71 + WithTag("Auth"), 72 + WithError(fmt.Errorf("signature verification failed: %w", err)), 73 + ) 74 + } 75 + 76 + var InvalidRepoError = func(r string) XrpcError { 77 + return NewXrpcError( 78 + WithTag("InvalidRepo"), 79 + WithError(fmt.Errorf("supplied at-uri is not a repo: %s", r)), 80 + ) 81 + } 82 + 83 + var GitError = func(e error) XrpcError { 84 + return NewXrpcError( 85 + WithTag("Git"), 86 + WithError(fmt.Errorf("git error: %w", e)), 87 + ) 88 + } 89 + 90 + var AccessControlError = func(d string) XrpcError { 91 + return NewXrpcError( 92 + WithTag("AccessControl"), 93 + WithError(fmt.Errorf("DID does not have sufficent access permissions for this operation: %s", d)), 94 + ) 95 + } 96 + 97 + var RepoExistsError = func(r string) XrpcError { 98 + return NewXrpcError( 99 + WithTag("RepoExists"), 100 + WithError(fmt.Errorf("repo already exists: %s", r)), 101 + ) 102 + } 103 + 104 + var RecordExistsError = func(r string) XrpcError { 105 + return NewXrpcError( 106 + WithTag("RecordExists"), 107 + WithError(fmt.Errorf("repo already exists: %s", r)), 108 + ) 109 + } 110 + 111 + func GenericError(err error) XrpcError { 112 + return NewXrpcError( 113 + WithTag("Generic"), 114 + WithError(err), 115 + ) 116 + } 117 + 118 + func Unmarshal(errStr string) (XrpcError, error) { 119 + var xerr XrpcError 120 + err := json.Unmarshal([]byte(errStr), &xerr) 121 + if err != nil { 122 + return XrpcError{}, fmt.Errorf("failed to unmarshal XrpcError: %w", err) 123 + } 124 + return xerr, nil 125 + }
+65
xrpc/serviceauth/service_auth.go
··· 1 + package serviceauth 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "log/slog" 7 + "net/http" 8 + "strings" 9 + 10 + "github.com/bluesky-social/indigo/atproto/auth" 11 + "tangled.sh/tangled.sh/core/idresolver" 12 + xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors" 13 + ) 14 + 15 + const ActorDid string = "ActorDid" 16 + 17 + type ServiceAuth struct { 18 + logger *slog.Logger 19 + resolver *idresolver.Resolver 20 + audienceDid string 21 + } 22 + 23 + func NewServiceAuth(logger *slog.Logger, resolver *idresolver.Resolver, audienceDid string) *ServiceAuth { 24 + return &ServiceAuth{ 25 + logger: logger, 26 + resolver: resolver, 27 + audienceDid: audienceDid, 28 + } 29 + } 30 + 31 + func (sa *ServiceAuth) VerifyServiceAuth(next http.Handler) http.Handler { 32 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 33 + l := sa.logger.With("url", r.URL) 34 + 35 + token := r.Header.Get("Authorization") 36 + token = strings.TrimPrefix(token, "Bearer ") 37 + 38 + s := auth.ServiceAuthValidator{ 39 + Audience: sa.audienceDid, 40 + Dir: sa.resolver.Directory(), 41 + } 42 + 43 + did, err := s.Validate(r.Context(), token, nil) 44 + if err != nil { 45 + l.Error("signature verification failed", "err", err) 46 + writeError(w, xrpcerr.AuthError(err), http.StatusForbidden) 47 + return 48 + } 49 + 50 + r = r.WithContext( 51 + context.WithValue(r.Context(), ActorDid, did), 52 + ) 53 + 54 + next.ServeHTTP(w, r) 55 + }) 56 + } 57 + 58 + // this is slightly different from http_util::write_error to follow the spec: 59 + // 60 + // the json object returned must include an "error" and a "message" 61 + func writeError(w http.ResponseWriter, e xrpcerr.XrpcError, status int) { 62 + w.Header().Set("Content-Type", "application/json") 63 + w.WriteHeader(status) 64 + json.NewEncoder(w).Encode(e) 65 + }
-1
y
··· 1 - yyy