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

Compare changes

Choose any two refs to compare.

Changed files
+23135 -4610
.air
api
appview
auth
db
filetree
knotclient
middleware
oauth
pages
pagination
settings
state
xrpcclient
avatar
camo
cmd
appview
combinediff
genjwks
interdiff
jstest
knotserver
docker
rootfs
etc
s6-overlay
s6-rc.d
create-sshd-host-keys
knotserver
dependencies.d
run
sshd
user
contents.d
scripts
ssh
sshd_config.d
docs
jetstream
knotserver
lexicons
patchutil
rbac
scripts
types
+1 -1
.air/appview.toml
··· 1 [build] 2 cmd = "tailwindcss -i input.css -o ./appview/pages/static/tw.css && go build -o .bin/app ./cmd/appview/main.go" 3 - bin = ".bin/app" 4 root = "." 5 6 exclude_regex = [".*_templ.go"]
··· 1 [build] 2 cmd = "tailwindcss -i input.css -o ./appview/pages/static/tw.css && go build -o .bin/app ./cmd/appview/main.go" 3 + bin = ";set -o allexport && source .env && set +o allexport; .bin/app" 4 root = "." 5 6 exclude_regex = [".*_templ.go"]
+1 -1
.air/knotserver.toml
··· 1 [build] 2 - cmd = "go build -o .bin/knot ./cmd/knotserver/main.go" 3 bin = ".bin/knot" 4 root = "." 5
··· 1 [build] 2 + cmd = 'go build -ldflags "-X tangled.sh/tangled.sh/core/knotserver.version=$(git describe --tags --long)" -o .bin/knot ./cmd/knotserver/main.go' 3 bin = ".bin/knot" 4 root = "." 5
+7
.gitignore
··· 6 appview/pages/static/* 7 result 8 !.gitkeep
··· 6 appview/pages/static/* 7 result 8 !.gitkeep 9 + out/ 10 + ./camo/node_modules/* 11 + ./avatar/node_modules/* 12 + patches 13 + *.qcow2 14 + .DS_Store 15 + .env
+31
api/tangled/actorprofile.go
···
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.actor.profile 6 + 7 + import ( 8 + "github.com/bluesky-social/indigo/lex/util" 9 + ) 10 + 11 + const ( 12 + ActorProfileNSID = "sh.tangled.actor.profile" 13 + ) 14 + 15 + func init() { 16 + util.RegisterType("sh.tangled.actor.profile", &ActorProfile{}) 17 + } // 18 + // RECORDTYPE: ActorProfile 19 + type ActorProfile struct { 20 + LexiconTypeID string `json:"$type,const=sh.tangled.actor.profile" cborgen:"$type,const=sh.tangled.actor.profile"` 21 + // bluesky: Include link to this account on Bluesky. 22 + Bluesky bool `json:"bluesky" cborgen:"bluesky"` 23 + // description: Free-form profile description text. 24 + Description *string `json:"description,omitempty" cborgen:"description,omitempty"` 25 + Links []string `json:"links,omitempty" cborgen:"links,omitempty"` 26 + // location: Free-form location text. 27 + Location *string `json:"location,omitempty" cborgen:"location,omitempty"` 28 + // pinnedRepositories: Any ATURI, it is up to appviews to validate these fields. 29 + PinnedRepositories []string `json:"pinnedRepositories,omitempty" cborgen:"pinnedRepositories,omitempty"` 30 + Stats []string `json:"stats,omitempty" cborgen:"stats,omitempty"` 31 + }
+1193 -449
api/tangled/cbor_gen.go
··· 8 "math" 9 "sort" 10 11 cid "github.com/ipfs/go-cid" 12 cbg "github.com/whyrusleeping/cbor-gen" 13 xerrors "golang.org/x/xerrors" ··· 353 } 354 355 cw := cbg.NewCborWriter(w) 356 - fieldCount := 4 357 358 - if t.AddedAt == nil { 359 - fieldCount-- 360 - } 361 - 362 - if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { 363 return err 364 } 365 ··· 405 return err 406 } 407 408 - // t.Member (string) (string) 409 - if len("member") > 1000000 { 410 - return xerrors.Errorf("Value in field \"member\" was too long") 411 } 412 413 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("member"))); err != nil { 414 return err 415 } 416 - if _, err := cw.WriteString(string("member")); err != nil { 417 return err 418 } 419 420 - if len(t.Member) > 1000000 { 421 - return xerrors.Errorf("Value in field t.Member was too long") 422 } 423 424 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Member))); err != nil { 425 return err 426 } 427 - if _, err := cw.WriteString(string(t.Member)); err != nil { 428 return err 429 } 430 431 - // t.AddedAt (string) (string) 432 - if t.AddedAt != nil { 433 434 - if len("addedAt") > 1000000 { 435 - return xerrors.Errorf("Value in field \"addedAt\" was too long") 436 - } 437 438 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("addedAt"))); err != nil { 439 - return err 440 - } 441 - if _, err := cw.WriteString(string("addedAt")); err != nil { 442 - return err 443 - } 444 - 445 - if t.AddedAt == nil { 446 - if _, err := cw.Write(cbg.CborNull); err != nil { 447 - return err 448 - } 449 - } else { 450 - if len(*t.AddedAt) > 1000000 { 451 - return xerrors.Errorf("Value in field t.AddedAt was too long") 452 - } 453 454 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.AddedAt))); err != nil { 455 - return err 456 - } 457 - if _, err := cw.WriteString(string(*t.AddedAt)); err != nil { 458 - return err 459 - } 460 - } 461 } 462 return nil 463 } ··· 487 488 n := extra 489 490 - nameBuf := make([]byte, 7) 491 for i := uint64(0); i < n; i++ { 492 nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 493 if err != nil { ··· 525 526 t.Domain = string(sval) 527 } 528 - // t.Member (string) (string) 529 - case "member": 530 531 { 532 sval, err := cbg.ReadStringWithMax(cr, 1000000) ··· 534 return err 535 } 536 537 - t.Member = string(sval) 538 } 539 - // t.AddedAt (string) (string) 540 - case "addedAt": 541 542 { 543 - b, err := cr.ReadByte() 544 if err != nil { 545 return err 546 } 547 - if b != cbg.CborNull[0] { 548 - if err := cr.UnreadByte(); err != nil { 549 - return err 550 - } 551 552 - sval, err := cbg.ReadStringWithMax(cr, 1000000) 553 - if err != nil { 554 - return err 555 - } 556 - 557 - t.AddedAt = (*string)(&sval) 558 - } 559 } 560 561 default: ··· 645 return err 646 } 647 648 - // t.Created (string) (string) 649 - if len("created") > 1000000 { 650 - return xerrors.Errorf("Value in field \"created\" was too long") 651 } 652 653 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("created"))); err != nil { 654 return err 655 } 656 - if _, err := cw.WriteString(string("created")); err != nil { 657 return err 658 } 659 660 - if len(t.Created) > 1000000 { 661 - return xerrors.Errorf("Value in field t.Created was too long") 662 } 663 664 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Created))); err != nil { 665 return err 666 } 667 - if _, err := cw.WriteString(string(t.Created)); err != nil { 668 return err 669 } 670 return nil ··· 695 696 n := extra 697 698 - nameBuf := make([]byte, 7) 699 for i := uint64(0); i < n; i++ { 700 nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 701 if err != nil { ··· 744 745 t.LexiconTypeID = string(sval) 746 } 747 - // t.Created (string) (string) 748 - case "created": 749 750 { 751 sval, err := cbg.ReadStringWithMax(cr, 1000000) ··· 753 return err 754 } 755 756 - t.Created = string(sval) 757 } 758 759 default: ··· 775 cw := cbg.NewCborWriter(w) 776 fieldCount := 7 777 778 - if t.Body == nil { 779 - fieldCount-- 780 - } 781 - 782 if t.CommentId == nil { 783 fieldCount-- 784 } 785 786 - if t.CreatedAt == nil { 787 - fieldCount-- 788 - } 789 - 790 if t.Owner == nil { 791 fieldCount-- 792 } ··· 800 } 801 802 // t.Body (string) (string) 803 - if t.Body != nil { 804 805 - if len("body") > 1000000 { 806 - return xerrors.Errorf("Value in field \"body\" was too long") 807 - } 808 809 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("body"))); err != nil { 810 - return err 811 - } 812 - if _, err := cw.WriteString(string("body")); err != nil { 813 - return err 814 - } 815 816 - if t.Body == nil { 817 - if _, err := cw.Write(cbg.CborNull); err != nil { 818 - return err 819 - } 820 - } else { 821 - if len(*t.Body) > 1000000 { 822 - return xerrors.Errorf("Value in field t.Body was too long") 823 - } 824 - 825 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Body))); err != nil { 826 - return err 827 - } 828 - if _, err := cw.WriteString(string(*t.Body)); err != nil { 829 - return err 830 - } 831 - } 832 } 833 834 // t.Repo (string) (string) ··· 970 } 971 972 // t.CreatedAt (string) (string) 973 - if t.CreatedAt != nil { 974 975 - if len("createdAt") > 1000000 { 976 - return xerrors.Errorf("Value in field \"createdAt\" was too long") 977 - } 978 979 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil { 980 - return err 981 - } 982 - if _, err := cw.WriteString(string("createdAt")); err != nil { 983 - return err 984 - } 985 986 - if t.CreatedAt == nil { 987 - if _, err := cw.Write(cbg.CborNull); err != nil { 988 - return err 989 - } 990 - } else { 991 - if len(*t.CreatedAt) > 1000000 { 992 - return xerrors.Errorf("Value in field t.CreatedAt was too long") 993 - } 994 - 995 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.CreatedAt))); err != nil { 996 - return err 997 - } 998 - if _, err := cw.WriteString(string(*t.CreatedAt)); err != nil { 999 - return err 1000 - } 1001 - } 1002 } 1003 return nil 1004 } ··· 1048 case "body": 1049 1050 { 1051 - b, err := cr.ReadByte() 1052 if err != nil { 1053 return err 1054 } 1055 - if b != cbg.CborNull[0] { 1056 - if err := cr.UnreadByte(); err != nil { 1057 - return err 1058 - } 1059 1060 - sval, err := cbg.ReadStringWithMax(cr, 1000000) 1061 - if err != nil { 1062 - return err 1063 - } 1064 - 1065 - t.Body = (*string)(&sval) 1066 - } 1067 } 1068 // t.Repo (string) (string) 1069 case "repo": ··· 1169 case "createdAt": 1170 1171 { 1172 - b, err := cr.ReadByte() 1173 if err != nil { 1174 return err 1175 } 1176 - if b != cbg.CborNull[0] { 1177 - if err := cr.UnreadByte(); err != nil { 1178 - return err 1179 - } 1180 - 1181 - sval, err := cbg.ReadStringWithMax(cr, 1000000) 1182 - if err != nil { 1183 - return err 1184 - } 1185 1186 - t.CreatedAt = (*string)(&sval) 1187 - } 1188 } 1189 1190 default: ··· 1204 } 1205 1206 cw := cbg.NewCborWriter(w) 1207 - fieldCount := 3 1208 1209 - if t.State == nil { 1210 - fieldCount-- 1211 - } 1212 - 1213 - if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { 1214 return err 1215 } 1216 ··· 1257 } 1258 1259 // t.State (string) (string) 1260 - if t.State != nil { 1261 1262 - if len("state") > 1000000 { 1263 - return xerrors.Errorf("Value in field \"state\" was too long") 1264 - } 1265 1266 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("state"))); err != nil { 1267 - return err 1268 - } 1269 - if _, err := cw.WriteString(string("state")); err != nil { 1270 - return err 1271 - } 1272 1273 - if t.State == nil { 1274 - if _, err := cw.Write(cbg.CborNull); err != nil { 1275 - return err 1276 - } 1277 - } else { 1278 - if len(*t.State) > 1000000 { 1279 - return xerrors.Errorf("Value in field t.State was too long") 1280 - } 1281 - 1282 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.State))); err != nil { 1283 - return err 1284 - } 1285 - if _, err := cw.WriteString(string(*t.State)); err != nil { 1286 - return err 1287 - } 1288 - } 1289 } 1290 return nil 1291 } ··· 1357 case "state": 1358 1359 { 1360 - b, err := cr.ReadByte() 1361 if err != nil { 1362 return err 1363 } 1364 - if b != cbg.CborNull[0] { 1365 - if err := cr.UnreadByte(); err != nil { 1366 - return err 1367 - } 1368 1369 - sval, err := cbg.ReadStringWithMax(cr, 1000000) 1370 - if err != nil { 1371 - return err 1372 - } 1373 - 1374 - t.State = (*string)(&sval) 1375 - } 1376 } 1377 1378 default: ··· 1398 fieldCount-- 1399 } 1400 1401 - if t.CreatedAt == nil { 1402 - fieldCount-- 1403 - } 1404 - 1405 if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { 1406 return err 1407 } ··· 1549 } 1550 1551 // t.CreatedAt (string) (string) 1552 - if t.CreatedAt != nil { 1553 1554 - if len("createdAt") > 1000000 { 1555 - return xerrors.Errorf("Value in field \"createdAt\" was too long") 1556 - } 1557 1558 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil { 1559 - return err 1560 - } 1561 - if _, err := cw.WriteString(string("createdAt")); err != nil { 1562 - return err 1563 - } 1564 1565 - if t.CreatedAt == nil { 1566 - if _, err := cw.Write(cbg.CborNull); err != nil { 1567 - return err 1568 - } 1569 - } else { 1570 - if len(*t.CreatedAt) > 1000000 { 1571 - return xerrors.Errorf("Value in field t.CreatedAt was too long") 1572 - } 1573 - 1574 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.CreatedAt))); err != nil { 1575 - return err 1576 - } 1577 - if _, err := cw.WriteString(string(*t.CreatedAt)); err != nil { 1578 - return err 1579 - } 1580 - } 1581 } 1582 return nil 1583 } ··· 1718 case "createdAt": 1719 1720 { 1721 - b, err := cr.ReadByte() 1722 if err != nil { 1723 return err 1724 } 1725 - if b != cbg.CborNull[0] { 1726 - if err := cr.UnreadByte(); err != nil { 1727 - return err 1728 - } 1729 1730 - sval, err := cbg.ReadStringWithMax(cr, 1000000) 1731 - if err != nil { 1732 - return err 1733 - } 1734 - 1735 - t.CreatedAt = (*string)(&sval) 1736 - } 1737 } 1738 1739 default: ··· 1753 } 1754 1755 cw := cbg.NewCborWriter(w) 1756 - fieldCount := 6 1757 1758 - if t.AddedAt == nil { 1759 fieldCount-- 1760 } 1761 1762 - if t.Description == nil { 1763 fieldCount-- 1764 } 1765 ··· 1855 return err 1856 } 1857 1858 - // t.AddedAt (string) (string) 1859 - if t.AddedAt != nil { 1860 1861 - if len("addedAt") > 1000000 { 1862 - return xerrors.Errorf("Value in field \"addedAt\" was too long") 1863 } 1864 1865 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("addedAt"))); err != nil { 1866 return err 1867 } 1868 - if _, err := cw.WriteString(string("addedAt")); err != nil { 1869 return err 1870 } 1871 1872 - if t.AddedAt == nil { 1873 if _, err := cw.Write(cbg.CborNull); err != nil { 1874 return err 1875 } 1876 } else { 1877 - if len(*t.AddedAt) > 1000000 { 1878 - return xerrors.Errorf("Value in field t.AddedAt was too long") 1879 } 1880 1881 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.AddedAt))); err != nil { 1882 return err 1883 } 1884 - if _, err := cw.WriteString(string(*t.AddedAt)); err != nil { 1885 return err 1886 } 1887 } 1888 } 1889 1890 // t.Description (string) (string) 1891 if t.Description != nil { 1892 ··· 2006 2007 t.Owner = string(sval) 2008 } 2009 - // t.AddedAt (string) (string) 2010 - case "addedAt": 2011 2012 { 2013 b, err := cr.ReadByte() ··· 2024 return err 2025 } 2026 2027 - t.AddedAt = (*string)(&sval) 2028 } 2029 } 2030 // t.Description (string) (string) 2031 case "description": 2032 ··· 2072 fieldCount-- 2073 } 2074 2075 - if t.CreatedAt == nil { 2076 - fieldCount-- 2077 - } 2078 - 2079 - if t.SourceRepo == nil { 2080 fieldCount-- 2081 } 2082 ··· 2203 } 2204 } 2205 2206 - // t.CreatedAt (string) (string) 2207 - if t.CreatedAt != nil { 2208 2209 - if len("createdAt") > 1000000 { 2210 - return xerrors.Errorf("Value in field \"createdAt\" was too long") 2211 } 2212 2213 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil { 2214 return err 2215 } 2216 - if _, err := cw.WriteString(string("createdAt")); err != nil { 2217 return err 2218 } 2219 2220 - if t.CreatedAt == nil { 2221 - if _, err := cw.Write(cbg.CborNull); err != nil { 2222 - return err 2223 - } 2224 - } else { 2225 - if len(*t.CreatedAt) > 1000000 { 2226 - return xerrors.Errorf("Value in field t.CreatedAt was too long") 2227 - } 2228 - 2229 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.CreatedAt))); err != nil { 2230 - return err 2231 - } 2232 - if _, err := cw.WriteString(string(*t.CreatedAt)); err != nil { 2233 - return err 2234 - } 2235 } 2236 } 2237 2238 - // t.SourceRepo (string) (string) 2239 - if t.SourceRepo != nil { 2240 2241 - if len("sourceRepo") > 1000000 { 2242 - return xerrors.Errorf("Value in field \"sourceRepo\" was too long") 2243 - } 2244 2245 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sourceRepo"))); err != nil { 2246 - return err 2247 - } 2248 - if _, err := cw.WriteString(string("sourceRepo")); err != nil { 2249 - return err 2250 - } 2251 2252 - if t.SourceRepo == nil { 2253 - if _, err := cw.Write(cbg.CborNull); err != nil { 2254 - return err 2255 - } 2256 - } else { 2257 - if len(*t.SourceRepo) > 1000000 { 2258 - return xerrors.Errorf("Value in field t.SourceRepo was too long") 2259 - } 2260 - 2261 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.SourceRepo))); err != nil { 2262 - return err 2263 - } 2264 - if _, err := cw.WriteString(string(*t.SourceRepo)); err != nil { 2265 - return err 2266 - } 2267 - } 2268 } 2269 2270 // t.TargetRepo (string) (string) ··· 2436 2437 t.PullId = int64(extraI) 2438 } 2439 - // t.CreatedAt (string) (string) 2440 - case "createdAt": 2441 2442 { 2443 b, err := cr.ReadByte() 2444 if err != nil { 2445 return err ··· 2448 if err := cr.UnreadByte(); err != nil { 2449 return err 2450 } 2451 - 2452 - sval, err := cbg.ReadStringWithMax(cr, 1000000) 2453 - if err != nil { 2454 - return err 2455 } 2456 2457 - t.CreatedAt = (*string)(&sval) 2458 - } 2459 } 2460 - // t.SourceRepo (string) (string) 2461 - case "sourceRepo": 2462 2463 { 2464 - b, err := cr.ReadByte() 2465 if err != nil { 2466 return err 2467 } 2468 - if b != cbg.CborNull[0] { 2469 - if err := cr.UnreadByte(); err != nil { 2470 - return err 2471 - } 2472 2473 - sval, err := cbg.ReadStringWithMax(cr, 1000000) 2474 - if err != nil { 2475 - return err 2476 - } 2477 - 2478 - t.SourceRepo = (*string)(&sval) 2479 - } 2480 } 2481 // t.TargetRepo (string) (string) 2482 case "targetRepo": ··· 2511 2512 return nil 2513 } 2514 - func (t *RepoPullStatus) MarshalCBOR(w io.Writer) error { 2515 if t == nil { 2516 _, err := w.Write(cbg.CborNull) 2517 return err 2518 } 2519 2520 cw := cbg.NewCborWriter(w) 2521 - fieldCount := 3 2522 2523 - if t.Status == nil { 2524 fieldCount-- 2525 } 2526 ··· 2528 return err 2529 } 2530 2531 // t.Pull (string) (string) 2532 if len("pull") > 1000000 { 2533 return xerrors.Errorf("Value in field \"pull\" was too long") ··· 2571 } 2572 2573 // t.Status (string) (string) 2574 - if t.Status != nil { 2575 2576 - if len("status") > 1000000 { 2577 - return xerrors.Errorf("Value in field \"status\" was too long") 2578 - } 2579 2580 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("status"))); err != nil { 2581 - return err 2582 - } 2583 - if _, err := cw.WriteString(string("status")); err != nil { 2584 - return err 2585 - } 2586 - 2587 - if t.Status == nil { 2588 - if _, err := cw.Write(cbg.CborNull); err != nil { 2589 - return err 2590 - } 2591 - } else { 2592 - if len(*t.Status) > 1000000 { 2593 - return xerrors.Errorf("Value in field t.Status was too long") 2594 - } 2595 2596 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Status))); err != nil { 2597 - return err 2598 - } 2599 - if _, err := cw.WriteString(string(*t.Status)); err != nil { 2600 - return err 2601 - } 2602 - } 2603 } 2604 return nil 2605 } ··· 2671 case "status": 2672 2673 { 2674 - b, err := cr.ReadByte() 2675 if err != nil { 2676 return err 2677 } 2678 - if b != cbg.CborNull[0] { 2679 - if err := cr.UnreadByte(); err != nil { 2680 - return err 2681 - } 2682 2683 - sval, err := cbg.ReadStringWithMax(cr, 1000000) 2684 - if err != nil { 2685 - return err 2686 - } 2687 - 2688 - t.Status = (*string)(&sval) 2689 - } 2690 } 2691 2692 default: ··· 2708 cw := cbg.NewCborWriter(w) 2709 fieldCount := 7 2710 2711 - if t.Body == nil { 2712 - fieldCount-- 2713 - } 2714 - 2715 if t.CommentId == nil { 2716 - fieldCount-- 2717 - } 2718 - 2719 - if t.CreatedAt == nil { 2720 fieldCount-- 2721 } 2722 ··· 2733 } 2734 2735 // t.Body (string) (string) 2736 - if t.Body != nil { 2737 - 2738 - if len("body") > 1000000 { 2739 - return xerrors.Errorf("Value in field \"body\" was too long") 2740 - } 2741 2742 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("body"))); err != nil { 2743 - return err 2744 - } 2745 - if _, err := cw.WriteString(string("body")); err != nil { 2746 - return err 2747 - } 2748 2749 - if t.Body == nil { 2750 - if _, err := cw.Write(cbg.CborNull); err != nil { 2751 - return err 2752 - } 2753 - } else { 2754 - if len(*t.Body) > 1000000 { 2755 - return xerrors.Errorf("Value in field t.Body was too long") 2756 - } 2757 2758 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Body))); err != nil { 2759 - return err 2760 - } 2761 - if _, err := cw.WriteString(string(*t.Body)); err != nil { 2762 - return err 2763 - } 2764 - } 2765 } 2766 2767 // t.Pull (string) (string) ··· 2903 } 2904 2905 // t.CreatedAt (string) (string) 2906 - if t.CreatedAt != nil { 2907 - 2908 - if len("createdAt") > 1000000 { 2909 - return xerrors.Errorf("Value in field \"createdAt\" was too long") 2910 - } 2911 2912 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil { 2913 - return err 2914 - } 2915 - if _, err := cw.WriteString(string("createdAt")); err != nil { 2916 - return err 2917 - } 2918 2919 - if t.CreatedAt == nil { 2920 - if _, err := cw.Write(cbg.CborNull); err != nil { 2921 - return err 2922 - } 2923 - } else { 2924 - if len(*t.CreatedAt) > 1000000 { 2925 - return xerrors.Errorf("Value in field t.CreatedAt was too long") 2926 - } 2927 2928 - if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.CreatedAt))); err != nil { 2929 - return err 2930 - } 2931 - if _, err := cw.WriteString(string(*t.CreatedAt)); err != nil { 2932 - return err 2933 - } 2934 - } 2935 } 2936 return nil 2937 } ··· 2981 case "body": 2982 2983 { 2984 - b, err := cr.ReadByte() 2985 if err != nil { 2986 return err 2987 } 2988 - if b != cbg.CborNull[0] { 2989 - if err := cr.UnreadByte(); err != nil { 2990 - return err 2991 - } 2992 2993 - sval, err := cbg.ReadStringWithMax(cr, 1000000) 2994 - if err != nil { 2995 - return err 2996 - } 2997 - 2998 - t.Body = (*string)(&sval) 2999 - } 3000 } 3001 // t.Pull (string) (string) 3002 case "pull": ··· 3102 case "createdAt": 3103 3104 { 3105 b, err := cr.ReadByte() 3106 if err != nil { 3107 return err ··· 3116 return err 3117 } 3118 3119 - t.CreatedAt = (*string)(&sval) 3120 } 3121 } 3122
··· 8 "math" 9 "sort" 10 11 + util "github.com/bluesky-social/indigo/lex/util" 12 cid "github.com/ipfs/go-cid" 13 cbg "github.com/whyrusleeping/cbor-gen" 14 xerrors "golang.org/x/xerrors" ··· 354 } 355 356 cw := cbg.NewCborWriter(w) 357 358 + if _, err := cw.Write([]byte{164}); err != nil { 359 return err 360 } 361 ··· 401 return err 402 } 403 404 + // t.Subject (string) (string) 405 + if len("subject") > 1000000 { 406 + return xerrors.Errorf("Value in field \"subject\" was too long") 407 } 408 409 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("subject"))); err != nil { 410 return err 411 } 412 + if _, err := cw.WriteString(string("subject")); err != nil { 413 return err 414 } 415 416 + if len(t.Subject) > 1000000 { 417 + return xerrors.Errorf("Value in field t.Subject was too long") 418 } 419 420 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Subject))); err != nil { 421 return err 422 } 423 + if _, err := cw.WriteString(string(t.Subject)); err != nil { 424 return err 425 } 426 427 + // t.CreatedAt (string) (string) 428 + if len("createdAt") > 1000000 { 429 + return xerrors.Errorf("Value in field \"createdAt\" was too long") 430 + } 431 432 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil { 433 + return err 434 + } 435 + if _, err := cw.WriteString(string("createdAt")); err != nil { 436 + return err 437 + } 438 439 + if len(t.CreatedAt) > 1000000 { 440 + return xerrors.Errorf("Value in field t.CreatedAt was too long") 441 + } 442 443 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil { 444 + return err 445 + } 446 + if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { 447 + return err 448 } 449 return nil 450 } ··· 474 475 n := extra 476 477 + nameBuf := make([]byte, 9) 478 for i := uint64(0); i < n; i++ { 479 nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 480 if err != nil { ··· 512 513 t.Domain = string(sval) 514 } 515 + // t.Subject (string) (string) 516 + case "subject": 517 518 { 519 sval, err := cbg.ReadStringWithMax(cr, 1000000) ··· 521 return err 522 } 523 524 + t.Subject = string(sval) 525 } 526 + // t.CreatedAt (string) (string) 527 + case "createdAt": 528 529 { 530 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 531 if err != nil { 532 return err 533 } 534 535 + t.CreatedAt = string(sval) 536 } 537 538 default: ··· 622 return err 623 } 624 625 + // t.CreatedAt (string) (string) 626 + if len("createdAt") > 1000000 { 627 + return xerrors.Errorf("Value in field \"createdAt\" was too long") 628 } 629 630 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil { 631 return err 632 } 633 + if _, err := cw.WriteString(string("createdAt")); err != nil { 634 return err 635 } 636 637 + if len(t.CreatedAt) > 1000000 { 638 + return xerrors.Errorf("Value in field t.CreatedAt was too long") 639 } 640 641 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil { 642 return err 643 } 644 + if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { 645 return err 646 } 647 return nil ··· 672 673 n := extra 674 675 + nameBuf := make([]byte, 9) 676 for i := uint64(0); i < n; i++ { 677 nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 678 if err != nil { ··· 721 722 t.LexiconTypeID = string(sval) 723 } 724 + // t.CreatedAt (string) (string) 725 + case "createdAt": 726 727 { 728 sval, err := cbg.ReadStringWithMax(cr, 1000000) ··· 730 return err 731 } 732 733 + t.CreatedAt = string(sval) 734 } 735 736 default: ··· 752 cw := cbg.NewCborWriter(w) 753 fieldCount := 7 754 755 if t.CommentId == nil { 756 fieldCount-- 757 } 758 759 if t.Owner == nil { 760 fieldCount-- 761 } ··· 769 } 770 771 // t.Body (string) (string) 772 + if len("body") > 1000000 { 773 + return xerrors.Errorf("Value in field \"body\" was too long") 774 + } 775 776 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("body"))); err != nil { 777 + return err 778 + } 779 + if _, err := cw.WriteString(string("body")); err != nil { 780 + return err 781 + } 782 783 + if len(t.Body) > 1000000 { 784 + return xerrors.Errorf("Value in field t.Body was too long") 785 + } 786 787 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Body))); err != nil { 788 + return err 789 + } 790 + if _, err := cw.WriteString(string(t.Body)); err != nil { 791 + return err 792 } 793 794 // t.Repo (string) (string) ··· 930 } 931 932 // t.CreatedAt (string) (string) 933 + if len("createdAt") > 1000000 { 934 + return xerrors.Errorf("Value in field \"createdAt\" was too long") 935 + } 936 937 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil { 938 + return err 939 + } 940 + if _, err := cw.WriteString(string("createdAt")); err != nil { 941 + return err 942 + } 943 944 + if len(t.CreatedAt) > 1000000 { 945 + return xerrors.Errorf("Value in field t.CreatedAt was too long") 946 + } 947 948 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil { 949 + return err 950 + } 951 + if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { 952 + return err 953 } 954 return nil 955 } ··· 999 case "body": 1000 1001 { 1002 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 1003 if err != nil { 1004 return err 1005 } 1006 1007 + t.Body = string(sval) 1008 } 1009 // t.Repo (string) (string) 1010 case "repo": ··· 1110 case "createdAt": 1111 1112 { 1113 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 1114 if err != nil { 1115 return err 1116 } 1117 1118 + t.CreatedAt = string(sval) 1119 } 1120 1121 default: ··· 1135 } 1136 1137 cw := cbg.NewCborWriter(w) 1138 1139 + if _, err := cw.Write([]byte{163}); err != nil { 1140 return err 1141 } 1142 ··· 1183 } 1184 1185 // t.State (string) (string) 1186 + if len("state") > 1000000 { 1187 + return xerrors.Errorf("Value in field \"state\" was too long") 1188 + } 1189 1190 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("state"))); err != nil { 1191 + return err 1192 + } 1193 + if _, err := cw.WriteString(string("state")); err != nil { 1194 + return err 1195 + } 1196 1197 + if len(t.State) > 1000000 { 1198 + return xerrors.Errorf("Value in field t.State was too long") 1199 + } 1200 1201 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.State))); err != nil { 1202 + return err 1203 + } 1204 + if _, err := cw.WriteString(string(t.State)); err != nil { 1205 + return err 1206 } 1207 return nil 1208 } ··· 1274 case "state": 1275 1276 { 1277 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 1278 if err != nil { 1279 return err 1280 } 1281 1282 + t.State = string(sval) 1283 } 1284 1285 default: ··· 1305 fieldCount-- 1306 } 1307 1308 if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { 1309 return err 1310 } ··· 1452 } 1453 1454 // t.CreatedAt (string) (string) 1455 + if len("createdAt") > 1000000 { 1456 + return xerrors.Errorf("Value in field \"createdAt\" was too long") 1457 + } 1458 1459 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil { 1460 + return err 1461 + } 1462 + if _, err := cw.WriteString(string("createdAt")); err != nil { 1463 + return err 1464 + } 1465 1466 + if len(t.CreatedAt) > 1000000 { 1467 + return xerrors.Errorf("Value in field t.CreatedAt was too long") 1468 + } 1469 1470 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil { 1471 + return err 1472 + } 1473 + if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { 1474 + return err 1475 } 1476 return nil 1477 } ··· 1612 case "createdAt": 1613 1614 { 1615 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 1616 if err != nil { 1617 return err 1618 } 1619 1620 + t.CreatedAt = string(sval) 1621 } 1622 1623 default: ··· 1637 } 1638 1639 cw := cbg.NewCborWriter(w) 1640 + fieldCount := 7 1641 1642 + if t.Description == nil { 1643 fieldCount-- 1644 } 1645 1646 + if t.Source == nil { 1647 fieldCount-- 1648 } 1649 ··· 1739 return err 1740 } 1741 1742 + // t.Source (string) (string) 1743 + if t.Source != nil { 1744 1745 + if len("source") > 1000000 { 1746 + return xerrors.Errorf("Value in field \"source\" was too long") 1747 } 1748 1749 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("source"))); err != nil { 1750 return err 1751 } 1752 + if _, err := cw.WriteString(string("source")); err != nil { 1753 return err 1754 } 1755 1756 + if t.Source == nil { 1757 if _, err := cw.Write(cbg.CborNull); err != nil { 1758 return err 1759 } 1760 } else { 1761 + if len(*t.Source) > 1000000 { 1762 + return xerrors.Errorf("Value in field t.Source was too long") 1763 } 1764 1765 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Source))); err != nil { 1766 return err 1767 } 1768 + if _, err := cw.WriteString(string(*t.Source)); err != nil { 1769 return err 1770 } 1771 } 1772 } 1773 1774 + // t.CreatedAt (string) (string) 1775 + if len("createdAt") > 1000000 { 1776 + return xerrors.Errorf("Value in field \"createdAt\" was too long") 1777 + } 1778 + 1779 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil { 1780 + return err 1781 + } 1782 + if _, err := cw.WriteString(string("createdAt")); err != nil { 1783 + return err 1784 + } 1785 + 1786 + if len(t.CreatedAt) > 1000000 { 1787 + return xerrors.Errorf("Value in field t.CreatedAt was too long") 1788 + } 1789 + 1790 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil { 1791 + return err 1792 + } 1793 + if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { 1794 + return err 1795 + } 1796 + 1797 // t.Description (string) (string) 1798 if t.Description != nil { 1799 ··· 1913 1914 t.Owner = string(sval) 1915 } 1916 + // t.Source (string) (string) 1917 + case "source": 1918 1919 { 1920 b, err := cr.ReadByte() ··· 1931 return err 1932 } 1933 1934 + t.Source = (*string)(&sval) 1935 } 1936 } 1937 + // t.CreatedAt (string) (string) 1938 + case "createdAt": 1939 + 1940 + { 1941 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 1942 + if err != nil { 1943 + return err 1944 + } 1945 + 1946 + t.CreatedAt = string(sval) 1947 + } 1948 // t.Description (string) (string) 1949 case "description": 1950 ··· 1990 fieldCount-- 1991 } 1992 1993 + if t.Source == nil { 1994 fieldCount-- 1995 } 1996 ··· 2117 } 2118 } 2119 2120 + // t.Source (tangled.RepoPull_Source) (struct) 2121 + if t.Source != nil { 2122 2123 + if len("source") > 1000000 { 2124 + return xerrors.Errorf("Value in field \"source\" was too long") 2125 } 2126 2127 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("source"))); err != nil { 2128 return err 2129 } 2130 + if _, err := cw.WriteString(string("source")); err != nil { 2131 return err 2132 } 2133 2134 + if err := t.Source.MarshalCBOR(cw); err != nil { 2135 + return err 2136 } 2137 } 2138 2139 + // t.CreatedAt (string) (string) 2140 + if len("createdAt") > 1000000 { 2141 + return xerrors.Errorf("Value in field \"createdAt\" was too long") 2142 + } 2143 2144 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil { 2145 + return err 2146 + } 2147 + if _, err := cw.WriteString(string("createdAt")); err != nil { 2148 + return err 2149 + } 2150 2151 + if len(t.CreatedAt) > 1000000 { 2152 + return xerrors.Errorf("Value in field t.CreatedAt was too long") 2153 + } 2154 2155 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil { 2156 + return err 2157 + } 2158 + if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { 2159 + return err 2160 } 2161 2162 // t.TargetRepo (string) (string) ··· 2328 2329 t.PullId = int64(extraI) 2330 } 2331 + // t.Source (tangled.RepoPull_Source) (struct) 2332 + case "source": 2333 2334 { 2335 + 2336 b, err := cr.ReadByte() 2337 if err != nil { 2338 return err ··· 2341 if err := cr.UnreadByte(); err != nil { 2342 return err 2343 } 2344 + t.Source = new(RepoPull_Source) 2345 + if err := t.Source.UnmarshalCBOR(cr); err != nil { 2346 + return xerrors.Errorf("unmarshaling t.Source pointer: %w", err) 2347 } 2348 + } 2349 2350 } 2351 + // t.CreatedAt (string) (string) 2352 + case "createdAt": 2353 2354 { 2355 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 2356 if err != nil { 2357 return err 2358 } 2359 2360 + t.CreatedAt = string(sval) 2361 } 2362 // t.TargetRepo (string) (string) 2363 case "targetRepo": ··· 2392 2393 return nil 2394 } 2395 + func (t *RepoPull_Source) MarshalCBOR(w io.Writer) error { 2396 if t == nil { 2397 _, err := w.Write(cbg.CborNull) 2398 return err 2399 } 2400 2401 cw := cbg.NewCborWriter(w) 2402 + fieldCount := 2 2403 2404 + if t.Repo == nil { 2405 fieldCount-- 2406 } 2407 ··· 2409 return err 2410 } 2411 2412 + // t.Repo (string) (string) 2413 + if t.Repo != nil { 2414 + 2415 + if len("repo") > 1000000 { 2416 + return xerrors.Errorf("Value in field \"repo\" was too long") 2417 + } 2418 + 2419 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("repo"))); err != nil { 2420 + return err 2421 + } 2422 + if _, err := cw.WriteString(string("repo")); err != nil { 2423 + return err 2424 + } 2425 + 2426 + if t.Repo == nil { 2427 + if _, err := cw.Write(cbg.CborNull); err != nil { 2428 + return err 2429 + } 2430 + } else { 2431 + if len(*t.Repo) > 1000000 { 2432 + return xerrors.Errorf("Value in field t.Repo was too long") 2433 + } 2434 + 2435 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Repo))); err != nil { 2436 + return err 2437 + } 2438 + if _, err := cw.WriteString(string(*t.Repo)); err != nil { 2439 + return err 2440 + } 2441 + } 2442 + } 2443 + 2444 + // t.Branch (string) (string) 2445 + if len("branch") > 1000000 { 2446 + return xerrors.Errorf("Value in field \"branch\" was too long") 2447 + } 2448 + 2449 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("branch"))); err != nil { 2450 + return err 2451 + } 2452 + if _, err := cw.WriteString(string("branch")); err != nil { 2453 + return err 2454 + } 2455 + 2456 + if len(t.Branch) > 1000000 { 2457 + return xerrors.Errorf("Value in field t.Branch was too long") 2458 + } 2459 + 2460 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Branch))); err != nil { 2461 + return err 2462 + } 2463 + if _, err := cw.WriteString(string(t.Branch)); err != nil { 2464 + return err 2465 + } 2466 + return nil 2467 + } 2468 + 2469 + func (t *RepoPull_Source) UnmarshalCBOR(r io.Reader) (err error) { 2470 + *t = RepoPull_Source{} 2471 + 2472 + cr := cbg.NewCborReader(r) 2473 + 2474 + maj, extra, err := cr.ReadHeader() 2475 + if err != nil { 2476 + return err 2477 + } 2478 + defer func() { 2479 + if err == io.EOF { 2480 + err = io.ErrUnexpectedEOF 2481 + } 2482 + }() 2483 + 2484 + if maj != cbg.MajMap { 2485 + return fmt.Errorf("cbor input should be of type map") 2486 + } 2487 + 2488 + if extra > cbg.MaxLength { 2489 + return fmt.Errorf("RepoPull_Source: map struct too large (%d)", extra) 2490 + } 2491 + 2492 + n := extra 2493 + 2494 + nameBuf := make([]byte, 6) 2495 + for i := uint64(0); i < n; i++ { 2496 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 2497 + if err != nil { 2498 + return err 2499 + } 2500 + 2501 + if !ok { 2502 + // Field doesn't exist on this type, so ignore it 2503 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 2504 + return err 2505 + } 2506 + continue 2507 + } 2508 + 2509 + switch string(nameBuf[:nameLen]) { 2510 + // t.Repo (string) (string) 2511 + case "repo": 2512 + 2513 + { 2514 + b, err := cr.ReadByte() 2515 + if err != nil { 2516 + return err 2517 + } 2518 + if b != cbg.CborNull[0] { 2519 + if err := cr.UnreadByte(); err != nil { 2520 + return err 2521 + } 2522 + 2523 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 2524 + if err != nil { 2525 + return err 2526 + } 2527 + 2528 + t.Repo = (*string)(&sval) 2529 + } 2530 + } 2531 + // t.Branch (string) (string) 2532 + case "branch": 2533 + 2534 + { 2535 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 2536 + if err != nil { 2537 + return err 2538 + } 2539 + 2540 + t.Branch = string(sval) 2541 + } 2542 + 2543 + default: 2544 + // Field doesn't exist on this type, so ignore it 2545 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 2546 + return err 2547 + } 2548 + } 2549 + } 2550 + 2551 + return nil 2552 + } 2553 + func (t *RepoPullStatus) MarshalCBOR(w io.Writer) error { 2554 + if t == nil { 2555 + _, err := w.Write(cbg.CborNull) 2556 + return err 2557 + } 2558 + 2559 + cw := cbg.NewCborWriter(w) 2560 + 2561 + if _, err := cw.Write([]byte{163}); err != nil { 2562 + return err 2563 + } 2564 + 2565 // t.Pull (string) (string) 2566 if len("pull") > 1000000 { 2567 return xerrors.Errorf("Value in field \"pull\" was too long") ··· 2605 } 2606 2607 // t.Status (string) (string) 2608 + if len("status") > 1000000 { 2609 + return xerrors.Errorf("Value in field \"status\" was too long") 2610 + } 2611 2612 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("status"))); err != nil { 2613 + return err 2614 + } 2615 + if _, err := cw.WriteString(string("status")); err != nil { 2616 + return err 2617 + } 2618 2619 + if len(t.Status) > 1000000 { 2620 + return xerrors.Errorf("Value in field t.Status was too long") 2621 + } 2622 2623 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Status))); err != nil { 2624 + return err 2625 + } 2626 + if _, err := cw.WriteString(string(t.Status)); err != nil { 2627 + return err 2628 } 2629 return nil 2630 } ··· 2696 case "status": 2697 2698 { 2699 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 2700 if err != nil { 2701 return err 2702 } 2703 2704 + t.Status = string(sval) 2705 } 2706 2707 default: ··· 2723 cw := cbg.NewCborWriter(w) 2724 fieldCount := 7 2725 2726 if t.CommentId == nil { 2727 fieldCount-- 2728 } 2729 ··· 2740 } 2741 2742 // t.Body (string) (string) 2743 + if len("body") > 1000000 { 2744 + return xerrors.Errorf("Value in field \"body\" was too long") 2745 + } 2746 2747 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("body"))); err != nil { 2748 + return err 2749 + } 2750 + if _, err := cw.WriteString(string("body")); err != nil { 2751 + return err 2752 + } 2753 2754 + if len(t.Body) > 1000000 { 2755 + return xerrors.Errorf("Value in field t.Body was too long") 2756 + } 2757 2758 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Body))); err != nil { 2759 + return err 2760 + } 2761 + if _, err := cw.WriteString(string(t.Body)); err != nil { 2762 + return err 2763 } 2764 2765 // t.Pull (string) (string) ··· 2901 } 2902 2903 // t.CreatedAt (string) (string) 2904 + if len("createdAt") > 1000000 { 2905 + return xerrors.Errorf("Value in field \"createdAt\" was too long") 2906 + } 2907 2908 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil { 2909 + return err 2910 + } 2911 + if _, err := cw.WriteString(string("createdAt")); err != nil { 2912 + return err 2913 + } 2914 2915 + if len(t.CreatedAt) > 1000000 { 2916 + return xerrors.Errorf("Value in field t.CreatedAt was too long") 2917 + } 2918 2919 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil { 2920 + return err 2921 + } 2922 + if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { 2923 + return err 2924 } 2925 return nil 2926 } ··· 2970 case "body": 2971 2972 { 2973 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 2974 if err != nil { 2975 return err 2976 } 2977 2978 + t.Body = string(sval) 2979 } 2980 // t.Pull (string) (string) 2981 case "pull": ··· 3081 case "createdAt": 3082 3083 { 3084 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 3085 + if err != nil { 3086 + return err 3087 + } 3088 + 3089 + t.CreatedAt = string(sval) 3090 + } 3091 + 3092 + default: 3093 + // Field doesn't exist on this type, so ignore it 3094 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 3095 + return err 3096 + } 3097 + } 3098 + } 3099 + 3100 + return nil 3101 + } 3102 + func (t *RepoArtifact) MarshalCBOR(w io.Writer) error { 3103 + if t == nil { 3104 + _, err := w.Write(cbg.CborNull) 3105 + return err 3106 + } 3107 + 3108 + cw := cbg.NewCborWriter(w) 3109 + fieldCount := 6 3110 + 3111 + if t.Tag == nil { 3112 + fieldCount-- 3113 + } 3114 + 3115 + if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { 3116 + return err 3117 + } 3118 + 3119 + // t.Tag (util.LexBytes) (slice) 3120 + if t.Tag != nil { 3121 + 3122 + if len("tag") > 1000000 { 3123 + return xerrors.Errorf("Value in field \"tag\" was too long") 3124 + } 3125 + 3126 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("tag"))); err != nil { 3127 + return err 3128 + } 3129 + if _, err := cw.WriteString(string("tag")); err != nil { 3130 + return err 3131 + } 3132 + 3133 + if len(t.Tag) > 2097152 { 3134 + return xerrors.Errorf("Byte array in field t.Tag was too long") 3135 + } 3136 + 3137 + if err := cw.WriteMajorTypeHeader(cbg.MajByteString, uint64(len(t.Tag))); err != nil { 3138 + return err 3139 + } 3140 + 3141 + if _, err := cw.Write(t.Tag); err != nil { 3142 + return err 3143 + } 3144 + 3145 + } 3146 + 3147 + // t.Name (string) (string) 3148 + if len("name") > 1000000 { 3149 + return xerrors.Errorf("Value in field \"name\" was too long") 3150 + } 3151 + 3152 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("name"))); err != nil { 3153 + return err 3154 + } 3155 + if _, err := cw.WriteString(string("name")); err != nil { 3156 + return err 3157 + } 3158 + 3159 + if len(t.Name) > 1000000 { 3160 + return xerrors.Errorf("Value in field t.Name was too long") 3161 + } 3162 + 3163 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Name))); err != nil { 3164 + return err 3165 + } 3166 + if _, err := cw.WriteString(string(t.Name)); err != nil { 3167 + return err 3168 + } 3169 + 3170 + // t.Repo (string) (string) 3171 + if len("repo") > 1000000 { 3172 + return xerrors.Errorf("Value in field \"repo\" was too long") 3173 + } 3174 + 3175 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("repo"))); err != nil { 3176 + return err 3177 + } 3178 + if _, err := cw.WriteString(string("repo")); err != nil { 3179 + return err 3180 + } 3181 + 3182 + if len(t.Repo) > 1000000 { 3183 + return xerrors.Errorf("Value in field t.Repo was too long") 3184 + } 3185 + 3186 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Repo))); err != nil { 3187 + return err 3188 + } 3189 + if _, err := cw.WriteString(string(t.Repo)); err != nil { 3190 + return err 3191 + } 3192 + 3193 + // t.LexiconTypeID (string) (string) 3194 + if len("$type") > 1000000 { 3195 + return xerrors.Errorf("Value in field \"$type\" was too long") 3196 + } 3197 + 3198 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil { 3199 + return err 3200 + } 3201 + if _, err := cw.WriteString(string("$type")); err != nil { 3202 + return err 3203 + } 3204 + 3205 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sh.tangled.repo.artifact"))); err != nil { 3206 + return err 3207 + } 3208 + if _, err := cw.WriteString(string("sh.tangled.repo.artifact")); err != nil { 3209 + return err 3210 + } 3211 + 3212 + // t.Artifact (util.LexBlob) (struct) 3213 + if len("artifact") > 1000000 { 3214 + return xerrors.Errorf("Value in field \"artifact\" was too long") 3215 + } 3216 + 3217 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("artifact"))); err != nil { 3218 + return err 3219 + } 3220 + if _, err := cw.WriteString(string("artifact")); err != nil { 3221 + return err 3222 + } 3223 + 3224 + if err := t.Artifact.MarshalCBOR(cw); err != nil { 3225 + return err 3226 + } 3227 + 3228 + // t.CreatedAt (string) (string) 3229 + if len("createdAt") > 1000000 { 3230 + return xerrors.Errorf("Value in field \"createdAt\" was too long") 3231 + } 3232 + 3233 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil { 3234 + return err 3235 + } 3236 + if _, err := cw.WriteString(string("createdAt")); err != nil { 3237 + return err 3238 + } 3239 + 3240 + if len(t.CreatedAt) > 1000000 { 3241 + return xerrors.Errorf("Value in field t.CreatedAt was too long") 3242 + } 3243 + 3244 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil { 3245 + return err 3246 + } 3247 + if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { 3248 + return err 3249 + } 3250 + return nil 3251 + } 3252 + 3253 + func (t *RepoArtifact) UnmarshalCBOR(r io.Reader) (err error) { 3254 + *t = RepoArtifact{} 3255 + 3256 + cr := cbg.NewCborReader(r) 3257 + 3258 + maj, extra, err := cr.ReadHeader() 3259 + if err != nil { 3260 + return err 3261 + } 3262 + defer func() { 3263 + if err == io.EOF { 3264 + err = io.ErrUnexpectedEOF 3265 + } 3266 + }() 3267 + 3268 + if maj != cbg.MajMap { 3269 + return fmt.Errorf("cbor input should be of type map") 3270 + } 3271 + 3272 + if extra > cbg.MaxLength { 3273 + return fmt.Errorf("RepoArtifact: map struct too large (%d)", extra) 3274 + } 3275 + 3276 + n := extra 3277 + 3278 + nameBuf := make([]byte, 9) 3279 + for i := uint64(0); i < n; i++ { 3280 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 3281 + if err != nil { 3282 + return err 3283 + } 3284 + 3285 + if !ok { 3286 + // Field doesn't exist on this type, so ignore it 3287 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 3288 + return err 3289 + } 3290 + continue 3291 + } 3292 + 3293 + switch string(nameBuf[:nameLen]) { 3294 + // t.Tag (util.LexBytes) (slice) 3295 + case "tag": 3296 + 3297 + maj, extra, err = cr.ReadHeader() 3298 + if err != nil { 3299 + return err 3300 + } 3301 + 3302 + if extra > 2097152 { 3303 + return fmt.Errorf("t.Tag: byte array too large (%d)", extra) 3304 + } 3305 + if maj != cbg.MajByteString { 3306 + return fmt.Errorf("expected byte array") 3307 + } 3308 + 3309 + if extra > 0 { 3310 + t.Tag = make([]uint8, extra) 3311 + } 3312 + 3313 + if _, err := io.ReadFull(cr, t.Tag); err != nil { 3314 + return err 3315 + } 3316 + 3317 + // t.Name (string) (string) 3318 + case "name": 3319 + 3320 + { 3321 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 3322 + if err != nil { 3323 + return err 3324 + } 3325 + 3326 + t.Name = string(sval) 3327 + } 3328 + // t.Repo (string) (string) 3329 + case "repo": 3330 + 3331 + { 3332 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 3333 + if err != nil { 3334 + return err 3335 + } 3336 + 3337 + t.Repo = string(sval) 3338 + } 3339 + // t.LexiconTypeID (string) (string) 3340 + case "$type": 3341 + 3342 + { 3343 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 3344 + if err != nil { 3345 + return err 3346 + } 3347 + 3348 + t.LexiconTypeID = string(sval) 3349 + } 3350 + // t.Artifact (util.LexBlob) (struct) 3351 + case "artifact": 3352 + 3353 + { 3354 + 3355 + b, err := cr.ReadByte() 3356 + if err != nil { 3357 + return err 3358 + } 3359 + if b != cbg.CborNull[0] { 3360 + if err := cr.UnreadByte(); err != nil { 3361 + return err 3362 + } 3363 + t.Artifact = new(util.LexBlob) 3364 + if err := t.Artifact.UnmarshalCBOR(cr); err != nil { 3365 + return xerrors.Errorf("unmarshaling t.Artifact pointer: %w", err) 3366 + } 3367 + } 3368 + 3369 + } 3370 + // t.CreatedAt (string) (string) 3371 + case "createdAt": 3372 + 3373 + { 3374 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 3375 + if err != nil { 3376 + return err 3377 + } 3378 + 3379 + t.CreatedAt = string(sval) 3380 + } 3381 + 3382 + default: 3383 + // Field doesn't exist on this type, so ignore it 3384 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 3385 + return err 3386 + } 3387 + } 3388 + } 3389 + 3390 + return nil 3391 + } 3392 + func (t *ActorProfile) MarshalCBOR(w io.Writer) error { 3393 + if t == nil { 3394 + _, err := w.Write(cbg.CborNull) 3395 + return err 3396 + } 3397 + 3398 + cw := cbg.NewCborWriter(w) 3399 + fieldCount := 7 3400 + 3401 + if t.Description == nil { 3402 + fieldCount-- 3403 + } 3404 + 3405 + if t.Links == nil { 3406 + fieldCount-- 3407 + } 3408 + 3409 + if t.Location == nil { 3410 + fieldCount-- 3411 + } 3412 + 3413 + if t.PinnedRepositories == nil { 3414 + fieldCount-- 3415 + } 3416 + 3417 + if t.Stats == nil { 3418 + fieldCount-- 3419 + } 3420 + 3421 + if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { 3422 + return err 3423 + } 3424 + 3425 + // t.LexiconTypeID (string) (string) 3426 + if len("$type") > 1000000 { 3427 + return xerrors.Errorf("Value in field \"$type\" was too long") 3428 + } 3429 + 3430 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil { 3431 + return err 3432 + } 3433 + if _, err := cw.WriteString(string("$type")); err != nil { 3434 + return err 3435 + } 3436 + 3437 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sh.tangled.actor.profile"))); err != nil { 3438 + return err 3439 + } 3440 + if _, err := cw.WriteString(string("sh.tangled.actor.profile")); err != nil { 3441 + return err 3442 + } 3443 + 3444 + // t.Links ([]string) (slice) 3445 + if t.Links != nil { 3446 + 3447 + if len("links") > 1000000 { 3448 + return xerrors.Errorf("Value in field \"links\" was too long") 3449 + } 3450 + 3451 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("links"))); err != nil { 3452 + return err 3453 + } 3454 + if _, err := cw.WriteString(string("links")); err != nil { 3455 + return err 3456 + } 3457 + 3458 + if len(t.Links) > 8192 { 3459 + return xerrors.Errorf("Slice value in field t.Links was too long") 3460 + } 3461 + 3462 + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Links))); err != nil { 3463 + return err 3464 + } 3465 + for _, v := range t.Links { 3466 + if len(v) > 1000000 { 3467 + return xerrors.Errorf("Value in field v was too long") 3468 + } 3469 + 3470 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil { 3471 + return err 3472 + } 3473 + if _, err := cw.WriteString(string(v)); err != nil { 3474 + return err 3475 + } 3476 + 3477 + } 3478 + } 3479 + 3480 + // t.Stats ([]string) (slice) 3481 + if t.Stats != nil { 3482 + 3483 + if len("stats") > 1000000 { 3484 + return xerrors.Errorf("Value in field \"stats\" was too long") 3485 + } 3486 + 3487 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("stats"))); err != nil { 3488 + return err 3489 + } 3490 + if _, err := cw.WriteString(string("stats")); err != nil { 3491 + return err 3492 + } 3493 + 3494 + if len(t.Stats) > 8192 { 3495 + return xerrors.Errorf("Slice value in field t.Stats was too long") 3496 + } 3497 + 3498 + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Stats))); err != nil { 3499 + return err 3500 + } 3501 + for _, v := range t.Stats { 3502 + if len(v) > 1000000 { 3503 + return xerrors.Errorf("Value in field v was too long") 3504 + } 3505 + 3506 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil { 3507 + return err 3508 + } 3509 + if _, err := cw.WriteString(string(v)); err != nil { 3510 + return err 3511 + } 3512 + 3513 + } 3514 + } 3515 + 3516 + // t.Bluesky (bool) (bool) 3517 + if len("bluesky") > 1000000 { 3518 + return xerrors.Errorf("Value in field \"bluesky\" was too long") 3519 + } 3520 + 3521 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("bluesky"))); err != nil { 3522 + return err 3523 + } 3524 + if _, err := cw.WriteString(string("bluesky")); err != nil { 3525 + return err 3526 + } 3527 + 3528 + if err := cbg.WriteBool(w, t.Bluesky); err != nil { 3529 + return err 3530 + } 3531 + 3532 + // t.Location (string) (string) 3533 + if t.Location != nil { 3534 + 3535 + if len("location") > 1000000 { 3536 + return xerrors.Errorf("Value in field \"location\" was too long") 3537 + } 3538 + 3539 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("location"))); err != nil { 3540 + return err 3541 + } 3542 + if _, err := cw.WriteString(string("location")); err != nil { 3543 + return err 3544 + } 3545 + 3546 + if t.Location == nil { 3547 + if _, err := cw.Write(cbg.CborNull); err != nil { 3548 + return err 3549 + } 3550 + } else { 3551 + if len(*t.Location) > 1000000 { 3552 + return xerrors.Errorf("Value in field t.Location was too long") 3553 + } 3554 + 3555 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Location))); err != nil { 3556 + return err 3557 + } 3558 + if _, err := cw.WriteString(string(*t.Location)); err != nil { 3559 + return err 3560 + } 3561 + } 3562 + } 3563 + 3564 + // t.Description (string) (string) 3565 + if t.Description != nil { 3566 + 3567 + if len("description") > 1000000 { 3568 + return xerrors.Errorf("Value in field \"description\" was too long") 3569 + } 3570 + 3571 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("description"))); err != nil { 3572 + return err 3573 + } 3574 + if _, err := cw.WriteString(string("description")); err != nil { 3575 + return err 3576 + } 3577 + 3578 + if t.Description == nil { 3579 + if _, err := cw.Write(cbg.CborNull); err != nil { 3580 + return err 3581 + } 3582 + } else { 3583 + if len(*t.Description) > 1000000 { 3584 + return xerrors.Errorf("Value in field t.Description was too long") 3585 + } 3586 + 3587 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Description))); err != nil { 3588 + return err 3589 + } 3590 + if _, err := cw.WriteString(string(*t.Description)); err != nil { 3591 + return err 3592 + } 3593 + } 3594 + } 3595 + 3596 + // t.PinnedRepositories ([]string) (slice) 3597 + if t.PinnedRepositories != nil { 3598 + 3599 + if len("pinnedRepositories") > 1000000 { 3600 + return xerrors.Errorf("Value in field \"pinnedRepositories\" was too long") 3601 + } 3602 + 3603 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("pinnedRepositories"))); err != nil { 3604 + return err 3605 + } 3606 + if _, err := cw.WriteString(string("pinnedRepositories")); err != nil { 3607 + return err 3608 + } 3609 + 3610 + if len(t.PinnedRepositories) > 8192 { 3611 + return xerrors.Errorf("Slice value in field t.PinnedRepositories was too long") 3612 + } 3613 + 3614 + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.PinnedRepositories))); err != nil { 3615 + return err 3616 + } 3617 + for _, v := range t.PinnedRepositories { 3618 + if len(v) > 1000000 { 3619 + return xerrors.Errorf("Value in field v was too long") 3620 + } 3621 + 3622 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(v))); err != nil { 3623 + return err 3624 + } 3625 + if _, err := cw.WriteString(string(v)); err != nil { 3626 + return err 3627 + } 3628 + 3629 + } 3630 + } 3631 + return nil 3632 + } 3633 + 3634 + func (t *ActorProfile) UnmarshalCBOR(r io.Reader) (err error) { 3635 + *t = ActorProfile{} 3636 + 3637 + cr := cbg.NewCborReader(r) 3638 + 3639 + maj, extra, err := cr.ReadHeader() 3640 + if err != nil { 3641 + return err 3642 + } 3643 + defer func() { 3644 + if err == io.EOF { 3645 + err = io.ErrUnexpectedEOF 3646 + } 3647 + }() 3648 + 3649 + if maj != cbg.MajMap { 3650 + return fmt.Errorf("cbor input should be of type map") 3651 + } 3652 + 3653 + if extra > cbg.MaxLength { 3654 + return fmt.Errorf("ActorProfile: map struct too large (%d)", extra) 3655 + } 3656 + 3657 + n := extra 3658 + 3659 + nameBuf := make([]byte, 18) 3660 + for i := uint64(0); i < n; i++ { 3661 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 3662 + if err != nil { 3663 + return err 3664 + } 3665 + 3666 + if !ok { 3667 + // Field doesn't exist on this type, so ignore it 3668 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 3669 + return err 3670 + } 3671 + continue 3672 + } 3673 + 3674 + switch string(nameBuf[:nameLen]) { 3675 + // t.LexiconTypeID (string) (string) 3676 + case "$type": 3677 + 3678 + { 3679 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 3680 + if err != nil { 3681 + return err 3682 + } 3683 + 3684 + t.LexiconTypeID = string(sval) 3685 + } 3686 + // t.Links ([]string) (slice) 3687 + case "links": 3688 + 3689 + maj, extra, err = cr.ReadHeader() 3690 + if err != nil { 3691 + return err 3692 + } 3693 + 3694 + if extra > 8192 { 3695 + return fmt.Errorf("t.Links: array too large (%d)", extra) 3696 + } 3697 + 3698 + if maj != cbg.MajArray { 3699 + return fmt.Errorf("expected cbor array") 3700 + } 3701 + 3702 + if extra > 0 { 3703 + t.Links = make([]string, extra) 3704 + } 3705 + 3706 + for i := 0; i < int(extra); i++ { 3707 + { 3708 + var maj byte 3709 + var extra uint64 3710 + var err error 3711 + _ = maj 3712 + _ = extra 3713 + _ = err 3714 + 3715 + { 3716 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 3717 + if err != nil { 3718 + return err 3719 + } 3720 + 3721 + t.Links[i] = string(sval) 3722 + } 3723 + 3724 + } 3725 + } 3726 + // t.Stats ([]string) (slice) 3727 + case "stats": 3728 + 3729 + maj, extra, err = cr.ReadHeader() 3730 + if err != nil { 3731 + return err 3732 + } 3733 + 3734 + if extra > 8192 { 3735 + return fmt.Errorf("t.Stats: array too large (%d)", extra) 3736 + } 3737 + 3738 + if maj != cbg.MajArray { 3739 + return fmt.Errorf("expected cbor array") 3740 + } 3741 + 3742 + if extra > 0 { 3743 + t.Stats = make([]string, extra) 3744 + } 3745 + 3746 + for i := 0; i < int(extra); i++ { 3747 + { 3748 + var maj byte 3749 + var extra uint64 3750 + var err error 3751 + _ = maj 3752 + _ = extra 3753 + _ = err 3754 + 3755 + { 3756 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 3757 + if err != nil { 3758 + return err 3759 + } 3760 + 3761 + t.Stats[i] = string(sval) 3762 + } 3763 + 3764 + } 3765 + } 3766 + // t.Bluesky (bool) (bool) 3767 + case "bluesky": 3768 + 3769 + maj, extra, err = cr.ReadHeader() 3770 + if err != nil { 3771 + return err 3772 + } 3773 + if maj != cbg.MajOther { 3774 + return fmt.Errorf("booleans must be major type 7") 3775 + } 3776 + switch extra { 3777 + case 20: 3778 + t.Bluesky = false 3779 + case 21: 3780 + t.Bluesky = true 3781 + default: 3782 + return fmt.Errorf("booleans are either major type 7, value 20 or 21 (got %d)", extra) 3783 + } 3784 + // t.Location (string) (string) 3785 + case "location": 3786 + 3787 + { 3788 b, err := cr.ReadByte() 3789 if err != nil { 3790 return err ··· 3799 return err 3800 } 3801 3802 + t.Location = (*string)(&sval) 3803 + } 3804 + } 3805 + // t.Description (string) (string) 3806 + case "description": 3807 + 3808 + { 3809 + b, err := cr.ReadByte() 3810 + if err != nil { 3811 + return err 3812 + } 3813 + if b != cbg.CborNull[0] { 3814 + if err := cr.UnreadByte(); err != nil { 3815 + return err 3816 + } 3817 + 3818 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 3819 + if err != nil { 3820 + return err 3821 + } 3822 + 3823 + t.Description = (*string)(&sval) 3824 + } 3825 + } 3826 + // t.PinnedRepositories ([]string) (slice) 3827 + case "pinnedRepositories": 3828 + 3829 + maj, extra, err = cr.ReadHeader() 3830 + if err != nil { 3831 + return err 3832 + } 3833 + 3834 + if extra > 8192 { 3835 + return fmt.Errorf("t.PinnedRepositories: array too large (%d)", extra) 3836 + } 3837 + 3838 + if maj != cbg.MajArray { 3839 + return fmt.Errorf("expected cbor array") 3840 + } 3841 + 3842 + if extra > 0 { 3843 + t.PinnedRepositories = make([]string, extra) 3844 + } 3845 + 3846 + for i := 0; i < int(extra); i++ { 3847 + { 3848 + var maj byte 3849 + var extra uint64 3850 + var err error 3851 + _ = maj 3852 + _ = extra 3853 + _ = err 3854 + 3855 + { 3856 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 3857 + if err != nil { 3858 + return err 3859 + } 3860 + 3861 + t.PinnedRepositories[i] = string(sval) 3862 + } 3863 + 3864 } 3865 } 3866
+2 -2
api/tangled/issuecomment.go
··· 18 // RECORDTYPE: RepoIssueComment 19 type RepoIssueComment struct { 20 LexiconTypeID string `json:"$type,const=sh.tangled.repo.issue.comment" cborgen:"$type,const=sh.tangled.repo.issue.comment"` 21 - Body *string `json:"body,omitempty" cborgen:"body,omitempty"` 22 CommentId *int64 `json:"commentId,omitempty" cborgen:"commentId,omitempty"` 23 - CreatedAt *string `json:"createdAt,omitempty" cborgen:"createdAt,omitempty"` 24 Issue string `json:"issue" cborgen:"issue"` 25 Owner *string `json:"owner,omitempty" cborgen:"owner,omitempty"` 26 Repo *string `json:"repo,omitempty" cborgen:"repo,omitempty"`
··· 18 // RECORDTYPE: RepoIssueComment 19 type RepoIssueComment struct { 20 LexiconTypeID string `json:"$type,const=sh.tangled.repo.issue.comment" cborgen:"$type,const=sh.tangled.repo.issue.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 Issue string `json:"issue" cborgen:"issue"` 25 Owner *string `json:"owner,omitempty" cborgen:"owner,omitempty"` 26 Repo *string `json:"repo,omitempty" cborgen:"repo,omitempty"`
+1 -1
api/tangled/issuestate.go
··· 20 LexiconTypeID string `json:"$type,const=sh.tangled.repo.issue.state" cborgen:"$type,const=sh.tangled.repo.issue.state"` 21 Issue string `json:"issue" cborgen:"issue"` 22 // state: state of the issue 23 - State *string `json:"state,omitempty" cborgen:"state,omitempty"` 24 }
··· 20 LexiconTypeID string `json:"$type,const=sh.tangled.repo.issue.state" cborgen:"$type,const=sh.tangled.repo.issue.state"` 21 Issue string `json:"issue" cborgen:"issue"` 22 // state: state of the issue 23 + State string `json:"state" cborgen:"state"` 24 }
+4 -4
api/tangled/knotmember.go
··· 17 } // 18 // RECORDTYPE: KnotMember 19 type KnotMember struct { 20 - LexiconTypeID string `json:"$type,const=sh.tangled.knot.member" cborgen:"$type,const=sh.tangled.knot.member"` 21 - AddedAt *string `json:"addedAt,omitempty" cborgen:"addedAt,omitempty"` 22 // domain: domain that this member now belongs to 23 - Domain string `json:"domain" cborgen:"domain"` 24 - Member string `json:"member" cborgen:"member"` 25 }
··· 17 } // 18 // RECORDTYPE: KnotMember 19 type KnotMember struct { 20 + LexiconTypeID string `json:"$type,const=sh.tangled.knot.member" cborgen:"$type,const=sh.tangled.knot.member"` 21 + CreatedAt string `json:"createdAt" cborgen:"createdAt"` 22 // domain: domain that this member now belongs to 23 + Domain string `json:"domain" cborgen:"domain"` 24 + Subject string `json:"subject" cborgen:"subject"` 25 }
+2 -2
api/tangled/pullcomment.go
··· 18 // RECORDTYPE: RepoPullComment 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,omitempty" cborgen:"body,omitempty"` 22 CommentId *int64 `json:"commentId,omitempty" cborgen:"commentId,omitempty"` 23 - CreatedAt *string `json:"createdAt,omitempty" cborgen:"createdAt,omitempty"` 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"`
··· 18 // RECORDTYPE: RepoPullComment 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"`
+1 -1
api/tangled/pullstatus.go
··· 20 LexiconTypeID string `json:"$type,const=sh.tangled.repo.pull.status" cborgen:"$type,const=sh.tangled.repo.pull.status"` 21 Pull string `json:"pull" cborgen:"pull"` 22 // status: status of the pull request 23 - Status *string `json:"status,omitempty" cborgen:"status,omitempty"` 24 }
··· 20 LexiconTypeID string `json:"$type,const=sh.tangled.repo.pull.status" cborgen:"$type,const=sh.tangled.repo.pull.status"` 21 Pull string `json:"pull" cborgen:"pull"` 22 // status: status of the pull request 23 + Status string `json:"status" cborgen:"status"` 24 }
+31
api/tangled/repoartifact.go
···
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.repo.artifact 6 + 7 + import ( 8 + "github.com/bluesky-social/indigo/lex/util" 9 + ) 10 + 11 + const ( 12 + RepoArtifactNSID = "sh.tangled.repo.artifact" 13 + ) 14 + 15 + func init() { 16 + util.RegisterType("sh.tangled.repo.artifact", &RepoArtifact{}) 17 + } // 18 + // RECORDTYPE: RepoArtifact 19 + type RepoArtifact struct { 20 + LexiconTypeID string `json:"$type,const=sh.tangled.repo.artifact" cborgen:"$type,const=sh.tangled.repo.artifact"` 21 + // artifact: the artifact 22 + Artifact *util.LexBlob `json:"artifact" cborgen:"artifact"` 23 + // createdAt: time of creation of this artifact 24 + CreatedAt string `json:"createdAt" cborgen:"createdAt"` 25 + // name: name of the artifact 26 + Name string `json:"name" cborgen:"name"` 27 + // repo: repo that this artifact is being uploaded to 28 + Repo string `json:"repo" cborgen:"repo"` 29 + // tag: hash of the tag object that this artifact is attached to (only annotated tags are supported) 30 + Tag util.LexBytes `json:"tag,omitempty" cborgen:"tag,omitempty"` 31 + }
+1 -1
api/tangled/repoissue.go
··· 19 type RepoIssue struct { 20 LexiconTypeID string `json:"$type,const=sh.tangled.repo.issue" cborgen:"$type,const=sh.tangled.repo.issue"` 21 Body *string `json:"body,omitempty" cborgen:"body,omitempty"` 22 - CreatedAt *string `json:"createdAt,omitempty" cborgen:"createdAt,omitempty"` 23 IssueId int64 `json:"issueId" cborgen:"issueId"` 24 Owner string `json:"owner" cborgen:"owner"` 25 Repo string `json:"repo" cborgen:"repo"`
··· 19 type RepoIssue struct { 20 LexiconTypeID string `json:"$type,const=sh.tangled.repo.issue" cborgen:"$type,const=sh.tangled.repo.issue"` 21 Body *string `json:"body,omitempty" cborgen:"body,omitempty"` 22 + CreatedAt string `json:"createdAt" cborgen:"createdAt"` 23 IssueId int64 `json:"issueId" cborgen:"issueId"` 24 Owner string `json:"owner" cborgen:"owner"` 25 Repo string `json:"repo" cborgen:"repo"`
+15 -9
api/tangled/repopull.go
··· 17 } // 18 // RECORDTYPE: RepoPull 19 type RepoPull struct { 20 - LexiconTypeID string `json:"$type,const=sh.tangled.repo.pull" cborgen:"$type,const=sh.tangled.repo.pull"` 21 - Body *string `json:"body,omitempty" cborgen:"body,omitempty"` 22 - CreatedAt *string `json:"createdAt,omitempty" cborgen:"createdAt,omitempty"` 23 - Patch string `json:"patch" cborgen:"patch"` 24 - PullId int64 `json:"pullId" cborgen:"pullId"` 25 - SourceRepo *string `json:"sourceRepo,omitempty" cborgen:"sourceRepo,omitempty"` 26 - TargetBranch string `json:"targetBranch" cborgen:"targetBranch"` 27 - TargetRepo string `json:"targetRepo" cborgen:"targetRepo"` 28 - Title string `json:"title" cborgen:"title"` 29 }
··· 17 } // 18 // RECORDTYPE: RepoPull 19 type RepoPull struct { 20 + LexiconTypeID string `json:"$type,const=sh.tangled.repo.pull" cborgen:"$type,const=sh.tangled.repo.pull"` 21 + Body *string `json:"body,omitempty" cborgen:"body,omitempty"` 22 + CreatedAt string `json:"createdAt" cborgen:"createdAt"` 23 + Patch string `json:"patch" cborgen:"patch"` 24 + PullId int64 `json:"pullId" cborgen:"pullId"` 25 + Source *RepoPull_Source `json:"source,omitempty" cborgen:"source,omitempty"` 26 + TargetBranch string `json:"targetBranch" cborgen:"targetBranch"` 27 + TargetRepo string `json:"targetRepo" cborgen:"targetRepo"` 28 + Title string `json:"title" cborgen:"title"` 29 + } 30 + 31 + // RepoPull_Source is a "source" in the sh.tangled.repo.pull schema. 32 + type RepoPull_Source struct { 33 + Branch string `json:"branch" cborgen:"branch"` 34 + Repo *string `json:"repo,omitempty" cborgen:"repo,omitempty"` 35 }
+2 -2
api/tangled/tangledpublicKey.go
··· 18 // RECORDTYPE: PublicKey 19 type PublicKey struct { 20 LexiconTypeID string `json:"$type,const=sh.tangled.publicKey" cborgen:"$type,const=sh.tangled.publicKey"` 21 - // created: key upload timestamp 22 - Created string `json:"created" cborgen:"created"` 23 // key: public key contents 24 Key string `json:"key" cborgen:"key"` 25 // name: human-readable name for this key
··· 18 // RECORDTYPE: PublicKey 19 type PublicKey struct { 20 LexiconTypeID string `json:"$type,const=sh.tangled.publicKey" cborgen:"$type,const=sh.tangled.publicKey"` 21 + // createdAt: key upload timestamp 22 + CreatedAt string `json:"createdAt" cborgen:"createdAt"` 23 // key: public key contents 24 Key string `json:"key" cborgen:"key"` 25 // name: human-readable name for this key
+3 -1
api/tangled/tangledrepo.go
··· 18 // RECORDTYPE: Repo 19 type Repo struct { 20 LexiconTypeID string `json:"$type,const=sh.tangled.repo" cborgen:"$type,const=sh.tangled.repo"` 21 - AddedAt *string `json:"addedAt,omitempty" cborgen:"addedAt,omitempty"` 22 Description *string `json:"description,omitempty" cborgen:"description,omitempty"` 23 // knot: knot where the repo was created 24 Knot string `json:"knot" cborgen:"knot"` 25 // name: name of the repo 26 Name string `json:"name" cborgen:"name"` 27 Owner string `json:"owner" cborgen:"owner"` 28 }
··· 18 // RECORDTYPE: Repo 19 type Repo struct { 20 LexiconTypeID string `json:"$type,const=sh.tangled.repo" cborgen:"$type,const=sh.tangled.repo"` 21 + CreatedAt string `json:"createdAt" cborgen:"createdAt"` 22 Description *string `json:"description,omitempty" cborgen:"description,omitempty"` 23 // knot: knot where the repo was created 24 Knot string `json:"knot" cborgen:"knot"` 25 // name: name of the repo 26 Name string `json:"name" cborgen:"name"` 27 Owner string `json:"owner" cborgen:"owner"` 28 + // source: source of the repo 29 + Source *string `json:"source,omitempty" cborgen:"source,omitempty"` 30 }
-211
appview/auth/auth.go
··· 1 - package auth 2 - 3 - import ( 4 - "context" 5 - "fmt" 6 - "net/http" 7 - "time" 8 - 9 - comatproto "github.com/bluesky-social/indigo/api/atproto" 10 - "github.com/bluesky-social/indigo/atproto/identity" 11 - "github.com/bluesky-social/indigo/xrpc" 12 - "github.com/gorilla/sessions" 13 - "tangled.sh/tangled.sh/core/appview" 14 - ) 15 - 16 - type Auth struct { 17 - Store *sessions.CookieStore 18 - } 19 - 20 - type AtSessionCreate struct { 21 - comatproto.ServerCreateSession_Output 22 - PDSEndpoint string 23 - } 24 - 25 - type AtSessionRefresh struct { 26 - comatproto.ServerRefreshSession_Output 27 - PDSEndpoint string 28 - } 29 - 30 - func Make(secret string) (*Auth, error) { 31 - store := sessions.NewCookieStore([]byte(secret)) 32 - return &Auth{store}, nil 33 - } 34 - 35 - func (a *Auth) CreateInitialSession(ctx context.Context, resolved *identity.Identity, appPassword string) (*comatproto.ServerCreateSession_Output, error) { 36 - 37 - pdsUrl := resolved.PDSEndpoint() 38 - client := xrpc.Client{ 39 - Host: pdsUrl, 40 - } 41 - 42 - atSession, err := comatproto.ServerCreateSession(ctx, &client, &comatproto.ServerCreateSession_Input{ 43 - Identifier: resolved.DID.String(), 44 - Password: appPassword, 45 - }) 46 - if err != nil { 47 - return nil, fmt.Errorf("invalid app password") 48 - } 49 - 50 - return atSession, nil 51 - } 52 - 53 - // Sessionish is an interface that provides access to the common fields of both types. 54 - type Sessionish interface { 55 - GetAccessJwt() string 56 - GetActive() *bool 57 - GetDid() string 58 - GetDidDoc() *interface{} 59 - GetHandle() string 60 - GetRefreshJwt() string 61 - GetStatus() *string 62 - } 63 - 64 - // Create a wrapper type for ServerRefreshSession_Output 65 - type RefreshSessionWrapper struct { 66 - *comatproto.ServerRefreshSession_Output 67 - } 68 - 69 - func (s *RefreshSessionWrapper) GetAccessJwt() string { 70 - return s.AccessJwt 71 - } 72 - 73 - func (s *RefreshSessionWrapper) GetActive() *bool { 74 - return s.Active 75 - } 76 - 77 - func (s *RefreshSessionWrapper) GetDid() string { 78 - return s.Did 79 - } 80 - 81 - func (s *RefreshSessionWrapper) GetDidDoc() *interface{} { 82 - return s.DidDoc 83 - } 84 - 85 - func (s *RefreshSessionWrapper) GetHandle() string { 86 - return s.Handle 87 - } 88 - 89 - func (s *RefreshSessionWrapper) GetRefreshJwt() string { 90 - return s.RefreshJwt 91 - } 92 - 93 - func (s *RefreshSessionWrapper) GetStatus() *string { 94 - return s.Status 95 - } 96 - 97 - // Create a wrapper type for ServerRefreshSession_Output 98 - type CreateSessionWrapper struct { 99 - *comatproto.ServerCreateSession_Output 100 - } 101 - 102 - func (s *CreateSessionWrapper) GetAccessJwt() string { 103 - return s.AccessJwt 104 - } 105 - 106 - func (s *CreateSessionWrapper) GetActive() *bool { 107 - return s.Active 108 - } 109 - 110 - func (s *CreateSessionWrapper) GetDid() string { 111 - return s.Did 112 - } 113 - 114 - func (s *CreateSessionWrapper) GetDidDoc() *interface{} { 115 - return s.DidDoc 116 - } 117 - 118 - func (s *CreateSessionWrapper) GetHandle() string { 119 - return s.Handle 120 - } 121 - 122 - func (s *CreateSessionWrapper) GetRefreshJwt() string { 123 - return s.RefreshJwt 124 - } 125 - 126 - func (s *CreateSessionWrapper) GetStatus() *string { 127 - return s.Status 128 - } 129 - 130 - func (a *Auth) ClearSession(r *http.Request, w http.ResponseWriter) error { 131 - clientSession, _ := a.Store.Get(r, appview.SessionName) 132 - clientSession.Options.MaxAge = -1 133 - return clientSession.Save(r, w) 134 - } 135 - 136 - func (a *Auth) StoreSession(r *http.Request, w http.ResponseWriter, atSessionish Sessionish, pdsEndpoint string) error { 137 - clientSession, _ := a.Store.Get(r, appview.SessionName) 138 - clientSession.Values[appview.SessionHandle] = atSessionish.GetHandle() 139 - clientSession.Values[appview.SessionDid] = atSessionish.GetDid() 140 - clientSession.Values[appview.SessionPds] = pdsEndpoint 141 - clientSession.Values[appview.SessionAccessJwt] = atSessionish.GetAccessJwt() 142 - clientSession.Values[appview.SessionRefreshJwt] = atSessionish.GetRefreshJwt() 143 - clientSession.Values[appview.SessionExpiry] = time.Now().Add(time.Minute * 15).Format(time.RFC3339) 144 - clientSession.Values[appview.SessionAuthenticated] = true 145 - return clientSession.Save(r, w) 146 - } 147 - 148 - func (a *Auth) AuthorizedClient(r *http.Request) (*xrpc.Client, error) { 149 - clientSession, err := a.Store.Get(r, "appview-session") 150 - if err != nil || clientSession.IsNew { 151 - return nil, err 152 - } 153 - 154 - did := clientSession.Values["did"].(string) 155 - pdsUrl := clientSession.Values["pds"].(string) 156 - accessJwt := clientSession.Values["accessJwt"].(string) 157 - refreshJwt := clientSession.Values["refreshJwt"].(string) 158 - 159 - client := &xrpc.Client{ 160 - Host: pdsUrl, 161 - Auth: &xrpc.AuthInfo{ 162 - AccessJwt: accessJwt, 163 - RefreshJwt: refreshJwt, 164 - Did: did, 165 - }, 166 - } 167 - 168 - return client, nil 169 - } 170 - 171 - func (a *Auth) GetSession(r *http.Request) (*sessions.Session, error) { 172 - return a.Store.Get(r, appview.SessionName) 173 - } 174 - 175 - func (a *Auth) GetDid(r *http.Request) string { 176 - clientSession, err := a.Store.Get(r, appview.SessionName) 177 - if err != nil || clientSession.IsNew { 178 - return "" 179 - } 180 - 181 - return clientSession.Values[appview.SessionDid].(string) 182 - } 183 - 184 - func (a *Auth) GetHandle(r *http.Request) string { 185 - clientSession, err := a.Store.Get(r, appview.SessionName) 186 - if err != nil || clientSession.IsNew { 187 - return "" 188 - } 189 - 190 - return clientSession.Values[appview.SessionHandle].(string) 191 - } 192 - 193 - type User struct { 194 - Handle string 195 - Did string 196 - Pds string 197 - } 198 - 199 - func (a *Auth) GetUser(r *http.Request) *User { 200 - clientSession, err := a.Store.Get(r, appview.SessionName) 201 - 202 - if err != nil || clientSession.IsNew { 203 - return nil 204 - } 205 - 206 - return &User{ 207 - Handle: clientSession.Values[appview.SessionHandle].(string), 208 - Did: clientSession.Values[appview.SessionDid].(string), 209 - Pds: clientSession.Values[appview.SessionPds].(string), 210 - } 211 - }
···
+36 -6
appview/config.go
··· 6 "github.com/sethvargo/go-envconfig" 7 ) 8 9 type Config struct { 10 - CookieSecret string `env:"TANGLED_COOKIE_SECRET, default=00000000000000000000000000000000"` 11 - DbPath string `env:"TANGLED_DB_PATH, default=appview.db"` 12 - ListenAddr string `env:"TANGLED_LISTEN_ADDR, default=0.0.0.0:3000"` 13 - Dev bool `env:"TANGLED_DEV, default=false"` 14 - JetstreamEndpoint string `env:"TANGLED_JETSTREAM_ENDPOINT, default=wss://jetstream1.us-east.bsky.network/subscribe"` 15 - ResendApiKey string `env:"TANGLED_RESEND_API_KEY"` 16 } 17 18 func LoadConfig(ctx context.Context) (*Config, error) {
··· 6 "github.com/sethvargo/go-envconfig" 7 ) 8 9 + type CoreConfig struct { 10 + CookieSecret string `env:"COOKIE_SECRET, default=00000000000000000000000000000000"` 11 + DbPath string `env:"DB_PATH, default=appview.db"` 12 + ListenAddr string `env:"LISTEN_ADDR, default=0.0.0.0:3000"` 13 + AppviewHost string `env:"APPVIEW_HOST, default=https://tangled.sh"` 14 + Dev bool `env:"DEV, default=false"` 15 + } 16 + 17 + type OAuthConfig struct { 18 + Jwks string `env:"JWKS"` 19 + } 20 + 21 + type JetstreamConfig struct { 22 + Endpoint string `env:"ENDPOINT, default=wss://jetstream1.us-east.bsky.network/subscribe"` 23 + } 24 + 25 + type ResendConfig struct { 26 + ApiKey string `env:"API_KEY"` 27 + } 28 + 29 + type CamoConfig struct { 30 + Host string `env:"HOST, default=https://camo.tangled.sh"` 31 + SharedSecret string `env:"SHARED_SECRET"` 32 + } 33 + 34 + type AvatarConfig struct { 35 + Host string `env:"HOST, default=https://avatar.tangled.sh"` 36 + SharedSecret string `env:"SHARED_SECRET"` 37 + } 38 + 39 type Config struct { 40 + Core CoreConfig `env:",prefix=TANGLED_"` 41 + Jetstream JetstreamConfig `env:",prefix=TANGLED_JETSTREAM_"` 42 + Resend ResendConfig `env:",prefix=TANGLED_RESEND_"` 43 + Camo CamoConfig `env:",prefix=TANGLED_CAMO_"` 44 + Avatar AvatarConfig `env:",prefix=TANGLED_AVATAR_"` 45 + OAuth OAuthConfig `env:",prefix=TANGLED_OAUTH_"` 46 } 47 48 func LoadConfig(ctx context.Context) (*Config, error) {
+3
appview/consts.go
··· 9 SessionRefreshJwt = "refreshJwt" 10 SessionExpiry = "expiry" 11 SessionAuthenticated = "authenticated" 12 )
··· 9 SessionRefreshJwt = "refreshJwt" 10 SessionExpiry = "expiry" 11 SessionAuthenticated = "authenticated" 12 + 13 + SessionDpopPrivateJwk = "dpopPrivateJwk" 14 + SessionDpopAuthServerNonce = "dpopAuthServerNonce" 15 )
+150
appview/db/artifact.go
···
··· 1 + package db 2 + 3 + import ( 4 + "fmt" 5 + "strings" 6 + "time" 7 + 8 + "github.com/bluesky-social/indigo/atproto/syntax" 9 + "github.com/go-git/go-git/v5/plumbing" 10 + "github.com/ipfs/go-cid" 11 + "tangled.sh/tangled.sh/core/api/tangled" 12 + ) 13 + 14 + type Artifact struct { 15 + Id uint64 16 + Did string 17 + Rkey string 18 + 19 + RepoAt syntax.ATURI 20 + Tag plumbing.Hash 21 + CreatedAt time.Time 22 + 23 + BlobCid cid.Cid 24 + Name string 25 + Size uint64 26 + MimeType string 27 + } 28 + 29 + func (a *Artifact) ArtifactAt() syntax.ATURI { 30 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", a.Did, tangled.RepoPullNSID, a.Rkey)) 31 + } 32 + 33 + func AddArtifact(e Execer, artifact Artifact) error { 34 + _, err := e.Exec( 35 + `insert or ignore into artifacts ( 36 + did, 37 + rkey, 38 + repo_at, 39 + tag, 40 + created, 41 + blob_cid, 42 + name, 43 + size, 44 + mimetype 45 + ) 46 + values (?, ?, ?, ?, ?, ?, ?, ?, ?)`, 47 + artifact.Did, 48 + artifact.Rkey, 49 + artifact.RepoAt, 50 + artifact.Tag[:], 51 + artifact.CreatedAt.Format(time.RFC3339), 52 + artifact.BlobCid.String(), 53 + artifact.Name, 54 + artifact.Size, 55 + artifact.MimeType, 56 + ) 57 + return err 58 + } 59 + 60 + func GetArtifact(e Execer, filters ...filter) ([]Artifact, error) { 61 + var artifacts []Artifact 62 + 63 + var conditions []string 64 + var args []any 65 + for _, filter := range filters { 66 + conditions = append(conditions, filter.Condition()) 67 + args = append(args, filter.arg) 68 + } 69 + 70 + whereClause := "" 71 + if conditions != nil { 72 + whereClause = " where " + strings.Join(conditions, " and ") 73 + } 74 + 75 + query := fmt.Sprintf(`select 76 + did, 77 + rkey, 78 + repo_at, 79 + tag, 80 + created, 81 + blob_cid, 82 + name, 83 + size, 84 + mimetype 85 + from artifacts %s`, 86 + whereClause, 87 + ) 88 + 89 + rows, err := e.Query(query, args...) 90 + 91 + if err != nil { 92 + return nil, err 93 + } 94 + defer rows.Close() 95 + 96 + for rows.Next() { 97 + var artifact Artifact 98 + var createdAt string 99 + var tag []byte 100 + var blobCid string 101 + 102 + if err := rows.Scan( 103 + &artifact.Did, 104 + &artifact.Rkey, 105 + &artifact.RepoAt, 106 + &tag, 107 + &createdAt, 108 + &blobCid, 109 + &artifact.Name, 110 + &artifact.Size, 111 + &artifact.MimeType, 112 + ); err != nil { 113 + return nil, err 114 + } 115 + 116 + artifact.CreatedAt, err = time.Parse(time.RFC3339, createdAt) 117 + if err != nil { 118 + artifact.CreatedAt = time.Now() 119 + } 120 + artifact.Tag = plumbing.Hash(tag) 121 + artifact.BlobCid = cid.MustParse(blobCid) 122 + 123 + artifacts = append(artifacts, artifact) 124 + } 125 + 126 + if err := rows.Err(); err != nil { 127 + return nil, err 128 + } 129 + 130 + return artifacts, nil 131 + } 132 + 133 + func DeleteArtifact(e Execer, filters ...filter) error { 134 + var conditions []string 135 + var args []any 136 + for _, filter := range filters { 137 + conditions = append(conditions, filter.Condition()) 138 + args = append(args, filter.arg) 139 + } 140 + 141 + whereClause := "" 142 + if conditions != nil { 143 + whereClause = " where " + strings.Join(conditions, " and ") 144 + } 145 + 146 + query := fmt.Sprintf(`delete from artifacts %s`, whereClause) 147 + 148 + _, err := e.Exec(query, args...) 149 + return err 150 + }
+154
appview/db/db.go
··· 3 import ( 4 "context" 5 "database/sql" 6 "log" 7 8 _ "github.com/mattn/go-sqlite3" ··· 208 unique(did, email) 209 ); 210 211 create table if not exists migrations ( 212 id integer primary key autoincrement, 213 name text unique ··· 248 return nil 249 }) 250 251 return &DB{db}, nil 252 } 253 ··· 293 294 return nil 295 }
··· 3 import ( 4 "context" 5 "database/sql" 6 + "fmt" 7 "log" 8 9 _ "github.com/mattn/go-sqlite3" ··· 209 unique(did, email) 210 ); 211 212 + create table if not exists artifacts ( 213 + -- id 214 + id integer primary key autoincrement, 215 + did text not null, 216 + rkey text not null, 217 + 218 + -- meta 219 + repo_at text not null, 220 + tag binary(20) not null, 221 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 222 + 223 + -- data 224 + blob_cid text not null, 225 + name text not null, 226 + size integer not null default 0, 227 + mimetype string not null default "*/*", 228 + 229 + -- constraints 230 + unique(did, rkey), -- record must be unique 231 + unique(repo_at, tag, name), -- for a given tag object, each file must be unique 232 + foreign key (repo_at) references repos(at_uri) on delete cascade 233 + ); 234 + 235 + create table if not exists profile ( 236 + -- id 237 + id integer primary key autoincrement, 238 + did text not null, 239 + 240 + -- data 241 + description text not null, 242 + include_bluesky integer not null default 0, 243 + location text, 244 + 245 + -- constraints 246 + unique(did) 247 + ); 248 + create table if not exists profile_links ( 249 + -- id 250 + id integer primary key autoincrement, 251 + did text not null, 252 + 253 + -- data 254 + link text not null, 255 + 256 + -- constraints 257 + foreign key (did) references profile(did) on delete cascade 258 + ); 259 + create table if not exists profile_stats ( 260 + -- id 261 + id integer primary key autoincrement, 262 + did text not null, 263 + 264 + -- data 265 + kind text not null check (kind in ( 266 + "merged-pull-request-count", 267 + "closed-pull-request-count", 268 + "open-pull-request-count", 269 + "open-issue-count", 270 + "closed-issue-count", 271 + "repository-count" 272 + )), 273 + 274 + -- constraints 275 + foreign key (did) references profile(did) on delete cascade 276 + ); 277 + create table if not exists profile_pinned_repositories ( 278 + -- id 279 + id integer primary key autoincrement, 280 + did text not null, 281 + 282 + -- data 283 + at_uri text not null, 284 + 285 + -- constraints 286 + unique(did, at_uri), 287 + foreign key (did) references profile(did) on delete cascade, 288 + foreign key (at_uri) references repos(at_uri) on delete cascade 289 + ); 290 + 291 + create table if not exists oauth_requests ( 292 + id integer primary key autoincrement, 293 + auth_server_iss text not null, 294 + state text not null, 295 + did text not null, 296 + handle text not null, 297 + pds_url text not null, 298 + pkce_verifier text not null, 299 + dpop_auth_server_nonce text not null, 300 + dpop_private_jwk text not null 301 + ); 302 + 303 + create table if not exists oauth_sessions ( 304 + id integer primary key autoincrement, 305 + did text not null, 306 + handle text not null, 307 + pds_url text not null, 308 + auth_server_iss text not null, 309 + access_jwt text not null, 310 + refresh_jwt text not null, 311 + dpop_pds_nonce text, 312 + dpop_auth_server_nonce text not null, 313 + dpop_private_jwk text not null, 314 + expiry text not null 315 + ); 316 + 317 create table if not exists migrations ( 318 id integer primary key autoincrement, 319 name text unique ··· 354 return nil 355 }) 356 357 + runMigration(db, "add-rkey-to-comments", func(tx *sql.Tx) error { 358 + _, err := tx.Exec(` 359 + alter table comments drop column comment_at; 360 + alter table comments add column rkey text; 361 + `) 362 + return err 363 + }) 364 + 365 + runMigration(db, "add-deleted-and-edited-to-issue-comments", func(tx *sql.Tx) error { 366 + _, err := tx.Exec(` 367 + alter table comments add column deleted text; -- timestamp 368 + alter table comments add column edited text; -- timestamp 369 + `) 370 + return err 371 + }) 372 + 373 + runMigration(db, "add-source-info-to-pulls-and-submissions", func(tx *sql.Tx) error { 374 + _, err := tx.Exec(` 375 + alter table pulls add column source_branch text; 376 + alter table pulls add column source_repo_at text; 377 + alter table pull_submissions add column source_rev text; 378 + `) 379 + return err 380 + }) 381 + 382 + runMigration(db, "add-source-to-repos", func(tx *sql.Tx) error { 383 + _, err := tx.Exec(` 384 + alter table repos add column source text; 385 + `) 386 + return err 387 + }) 388 + 389 return &DB{db}, nil 390 } 391 ··· 431 432 return nil 433 } 434 + 435 + type filter struct { 436 + key string 437 + arg any 438 + } 439 + 440 + func Filter(key string, arg any) filter { 441 + return filter{ 442 + key: key, 443 + arg: arg, 444 + } 445 + } 446 + 447 + func (f filter) Condition() string { 448 + return fmt.Sprintf("%s = ?", f.key) 449 + }
+6
appview/db/follow.go
··· 47 return err 48 } 49 50 func GetFollowerFollowing(e Execer, did string) (int, int, error) { 51 followers, following := 0, 0 52 err := e.QueryRow(
··· 47 return err 48 } 49 50 + // Remove a follow 51 + func DeleteFollowByRkey(e Execer, userDid, rkey string) error { 52 + _, err := e.Exec(`delete from follows where user_did = ? and rkey = ?`, userDid, rkey) 53 + return err 54 + } 55 + 56 func GetFollowerFollowing(e Execer, did string) (int, int, error) { 57 followers, following := 0, 0 58 err := e.QueryRow(
+237 -24
appview/db/issues.go
··· 5 "time" 6 7 "github.com/bluesky-social/indigo/atproto/syntax" 8 ) 9 10 type Issue struct { ··· 12 OwnerDid string 13 IssueId int 14 IssueAt string 15 - Created *time.Time 16 Title string 17 Body string 18 Open bool 19 Metadata *IssueMetadata 20 } 21 22 type IssueMetadata struct { 23 CommentCount int 24 // labels, assignee etc. 25 } 26 27 type Comment struct { 28 OwnerDid string 29 RepoAt syntax.ATURI 30 - CommentAt string 31 Issue int 32 CommentId int 33 Body string 34 Created *time.Time 35 } 36 37 func NewIssue(tx *sql.Tx, issue *Issue) error { ··· 96 return ownerDid, err 97 } 98 99 - func GetIssues(e Execer, repoAt syntax.ATURI, isOpen bool) ([]Issue, error) { 100 var issues []Issue 101 openValue := 0 102 if isOpen { ··· 104 } 105 106 rows, err := e.Query( 107 `select 108 i.owner_did, 109 i.issue_id, 110 i.created, 111 i.title, 112 i.body, 113 i.open, 114 - count(c.id) 115 from 116 issues i 117 - left join 118 - comments c on i.repo_at = c.repo_at and i.issue_id = c.issue_id 119 - where 120 - i.repo_at = ? and i.open = ? 121 - group by 122 - i.id, i.owner_did, i.issue_id, i.created, i.title, i.body, i.open 123 order by 124 i.created desc`, 125 - repoAt, openValue) 126 if err != nil { 127 return nil, err 128 } ··· 130 131 for rows.Next() { 132 var issue Issue 133 - var createdAt string 134 - var metadata IssueMetadata 135 - err := rows.Scan(&issue.OwnerDid, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open, &metadata.CommentCount) 136 if err != nil { 137 return nil, err 138 } 139 140 - createdTime, err := time.Parse(time.RFC3339, createdAt) 141 if err != nil { 142 return nil, err 143 } 144 - issue.Created = &createdTime 145 - issue.Metadata = &metadata 146 147 issues = append(issues, issue) 148 } ··· 169 if err != nil { 170 return nil, err 171 } 172 - issue.Created = &createdTime 173 174 return &issue, nil 175 } ··· 189 if err != nil { 190 return nil, nil, err 191 } 192 - issue.Created = &createdTime 193 194 comments, err := GetComments(e, repoAt, issueId) 195 if err != nil { ··· 199 return &issue, comments, nil 200 } 201 202 - func NewComment(e Execer, comment *Comment) error { 203 - query := `insert into comments (owner_did, repo_at, comment_at, issue_id, comment_id, body) values (?, ?, ?, ?, ?, ?)` 204 _, err := e.Exec( 205 query, 206 comment.OwnerDid, 207 comment.RepoAt, 208 - comment.CommentAt, 209 comment.Issue, 210 comment.CommentId, 211 comment.Body, ··· 216 func GetComments(e Execer, repoAt syntax.ATURI, issueId int) ([]Comment, error) { 217 var comments []Comment 218 219 - rows, err := e.Query(`select owner_did, issue_id, comment_id, comment_at, body, created from comments where repo_at = ? and issue_id = ? order by created asc`, repoAt, issueId) 220 if err == sql.ErrNoRows { 221 return []Comment{}, nil 222 } ··· 228 for rows.Next() { 229 var comment Comment 230 var createdAt string 231 - err := rows.Scan(&comment.OwnerDid, &comment.Issue, &comment.CommentId, &comment.CommentAt, &comment.Body, &createdAt) 232 if err != nil { 233 return nil, err 234 } ··· 239 } 240 comment.Created = &createdAtTime 241 242 comments = append(comments, comment) 243 } 244 ··· 247 } 248 249 return comments, nil 250 } 251 252 func CloseIssue(e Execer, repoAt syntax.ATURI, issueId int) error {
··· 5 "time" 6 7 "github.com/bluesky-social/indigo/atproto/syntax" 8 + "tangled.sh/tangled.sh/core/appview/pagination" 9 ) 10 11 type Issue struct { ··· 13 OwnerDid string 14 IssueId int 15 IssueAt string 16 + Created time.Time 17 Title string 18 Body string 19 Open bool 20 + 21 + // optionally, populate this when querying for reverse mappings 22 + // like comment counts, parent repo etc. 23 Metadata *IssueMetadata 24 } 25 26 type IssueMetadata struct { 27 CommentCount int 28 + Repo *Repo 29 // labels, assignee etc. 30 } 31 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 42 } 43 44 func NewIssue(tx *sql.Tx, issue *Issue) error { ··· 103 return ownerDid, err 104 } 105 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 { ··· 111 } 112 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 { 148 + return nil, err 149 + } 150 + defer rows.Close() 151 + 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 + } 160 + 161 + createdTime, err := time.Parse(time.RFC3339, createdAt) 162 + if err != nil { 163 + return nil, err 164 + } 165 + issue.Created = createdTime 166 + issue.Metadata = &metadata 167 + 168 + issues = append(issues, issue) 169 + } 170 + 171 + if err := rows.Err(); err != nil { 172 + return nil, err 173 + } 174 + 175 + return issues, nil 176 + } 177 + 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 182 + 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) 206 if err != nil { 207 return nil, err 208 } ··· 210 211 for rows.Next() { 212 var issue Issue 213 + var issueCreatedAt, repoCreatedAt string 214 + var repo Repo 215 + err := rows.Scan( 216 + &issue.OwnerDid, 217 + &issue.RepoAt, 218 + &issue.IssueId, 219 + &issueCreatedAt, 220 + &issue.Title, 221 + &issue.Body, 222 + &issue.Open, 223 + &repo.Did, 224 + &repo.Name, 225 + &repo.Knot, 226 + &repo.Rkey, 227 + &repoCreatedAt, 228 + ) 229 if err != nil { 230 return nil, err 231 } 232 233 + issueCreatedTime, err := time.Parse(time.RFC3339, issueCreatedAt) 234 if err != nil { 235 return nil, err 236 } 237 + issue.Created = issueCreatedTime 238 + 239 + repoCreatedTime, err := time.Parse(time.RFC3339, repoCreatedAt) 240 + if err != nil { 241 + return nil, err 242 + } 243 + repo.Created = repoCreatedTime 244 + 245 + issue.Metadata = &IssueMetadata{ 246 + Repo: &repo, 247 + } 248 249 issues = append(issues, issue) 250 } ··· 271 if err != nil { 272 return nil, err 273 } 274 + issue.Created = createdTime 275 276 return &issue, nil 277 } ··· 291 if err != nil { 292 return nil, nil, err 293 } 294 + issue.Created = createdTime 295 296 comments, err := GetComments(e, repoAt, issueId) 297 if err != nil { ··· 301 return &issue, comments, nil 302 } 303 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, ··· 318 func GetComments(e Execer, repoAt syntax.ATURI, issueId int) ([]Comment, error) { 319 var comments []Comment 320 321 + rows, err := e.Query(` 322 + select 323 + owner_did, 324 + issue_id, 325 + comment_id, 326 + rkey, 327 + body, 328 + created, 329 + edited, 330 + deleted 331 + 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 } ··· 348 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) 353 if err != nil { 354 return nil, err 355 } ··· 360 } 361 comment.Created = &createdAtTime 362 363 + if deletedAt.Valid { 364 + deletedTime, err := time.Parse(time.RFC3339, deletedAt.String) 365 + if err != nil { 366 + return nil, err 367 + } 368 + comment.Deleted = &deletedTime 369 + } 370 + 371 + if editedAt.Valid { 372 + editedTime, err := time.Parse(time.RFC3339, editedAt.String) 373 + if err != nil { 374 + return nil, err 375 + } 376 + comment.Edited = &editedTime 377 + } 378 + 379 + if rkey.Valid { 380 + comment.Rkey = rkey.String 381 + } 382 + 383 comments = append(comments, comment) 384 } 385 ··· 388 } 389 390 return comments, nil 391 + } 392 + 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 408 + } 409 + 410 + createdTime, err := time.Parse(time.RFC3339, createdAt) 411 + if err != nil { 412 + return nil, err 413 + } 414 + comment.Created = &createdTime 415 + 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 + } 423 + 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 430 + } 431 + 432 + if rkey.Valid { 433 + comment.Rkey = rkey.String 434 + } 435 + 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) 451 + return err 452 + } 453 + 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 } 464 465 func CloseIssue(e Execer, repoAt syntax.ATURI, issueId int) error {
+6 -10
appview/db/jetstream.go
··· 5 } 6 7 func (db DbWrapper) SaveLastTimeUs(lastTimeUs int64) error { 8 - _, err := db.Exec(`insert into _jetstream (last_time_us) values (?)`, lastTimeUs) 9 return err 10 } 11 12 - func (db DbWrapper) UpdateLastTimeUs(lastTimeUs int64) error { 13 - _, err := db.Exec(`update _jetstream set last_time_us = ? where rowid = 1`, lastTimeUs) 14 - if err != nil { 15 - return err 16 - } 17 - return nil 18 - } 19 - 20 func (db DbWrapper) GetLastTimeUs() (int64, error) { 21 var lastTimeUs int64 22 - row := db.QueryRow(`select last_time_us from _jetstream`) 23 err := row.Scan(&lastTimeUs) 24 return lastTimeUs, err 25 }
··· 5 } 6 7 func (db DbWrapper) SaveLastTimeUs(lastTimeUs int64) error { 8 + _, err := db.Exec(` 9 + insert into _jetstream (id, last_time_us) 10 + values (1, ?) 11 + on conflict(id) do update set last_time_us = excluded.last_time_us 12 + `, lastTimeUs) 13 return err 14 } 15 16 func (db DbWrapper) GetLastTimeUs() (int64, error) { 17 var lastTimeUs int64 18 + row := db.QueryRow(`select last_time_us from _jetstream where id = 1;`) 19 err := row.Scan(&lastTimeUs) 20 return lastTimeUs, err 21 }
+173
appview/db/oauth.go
···
··· 1 + package db 2 + 3 + type OAuthRequest struct { 4 + ID uint 5 + AuthserverIss string 6 + Handle string 7 + State string 8 + Did string 9 + PdsUrl string 10 + PkceVerifier string 11 + DpopAuthserverNonce string 12 + DpopPrivateJwk string 13 + } 14 + 15 + func SaveOAuthRequest(e Execer, oauthRequest OAuthRequest) error { 16 + _, err := e.Exec(` 17 + insert into oauth_requests ( 18 + auth_server_iss, 19 + state, 20 + handle, 21 + did, 22 + pds_url, 23 + pkce_verifier, 24 + dpop_auth_server_nonce, 25 + dpop_private_jwk 26 + ) values (?, ?, ?, ?, ?, ?, ?, ?)`, 27 + oauthRequest.AuthserverIss, 28 + oauthRequest.State, 29 + oauthRequest.Handle, 30 + oauthRequest.Did, 31 + oauthRequest.PdsUrl, 32 + oauthRequest.PkceVerifier, 33 + oauthRequest.DpopAuthserverNonce, 34 + oauthRequest.DpopPrivateJwk, 35 + ) 36 + return err 37 + } 38 + 39 + func GetOAuthRequestByState(e Execer, state string) (OAuthRequest, error) { 40 + var req OAuthRequest 41 + err := e.QueryRow(` 42 + select 43 + id, 44 + auth_server_iss, 45 + handle, 46 + state, 47 + did, 48 + pds_url, 49 + pkce_verifier, 50 + dpop_auth_server_nonce, 51 + dpop_private_jwk 52 + from oauth_requests 53 + where state = ?`, state).Scan( 54 + &req.ID, 55 + &req.AuthserverIss, 56 + &req.Handle, 57 + &req.State, 58 + &req.Did, 59 + &req.PdsUrl, 60 + &req.PkceVerifier, 61 + &req.DpopAuthserverNonce, 62 + &req.DpopPrivateJwk, 63 + ) 64 + return req, err 65 + } 66 + 67 + func DeleteOAuthRequestByState(e Execer, state string) error { 68 + _, err := e.Exec(` 69 + delete from oauth_requests 70 + where state = ?`, state) 71 + return err 72 + } 73 + 74 + type OAuthSession struct { 75 + ID uint 76 + Handle string 77 + Did string 78 + PdsUrl string 79 + AccessJwt string 80 + RefreshJwt string 81 + AuthServerIss string 82 + DpopPdsNonce string 83 + DpopAuthserverNonce string 84 + DpopPrivateJwk string 85 + Expiry string 86 + } 87 + 88 + func SaveOAuthSession(e Execer, session OAuthSession) error { 89 + _, err := e.Exec(` 90 + insert into oauth_sessions ( 91 + did, 92 + handle, 93 + pds_url, 94 + access_jwt, 95 + refresh_jwt, 96 + auth_server_iss, 97 + dpop_auth_server_nonce, 98 + dpop_private_jwk, 99 + expiry 100 + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?)`, 101 + session.Did, 102 + session.Handle, 103 + session.PdsUrl, 104 + session.AccessJwt, 105 + session.RefreshJwt, 106 + session.AuthServerIss, 107 + session.DpopAuthserverNonce, 108 + session.DpopPrivateJwk, 109 + session.Expiry, 110 + ) 111 + return err 112 + } 113 + 114 + func RefreshOAuthSession(e Execer, did string, accessJwt, refreshJwt, expiry string) error { 115 + _, err := e.Exec(` 116 + update oauth_sessions 117 + set access_jwt = ?, refresh_jwt = ?, expiry = ? 118 + where did = ?`, 119 + accessJwt, 120 + refreshJwt, 121 + expiry, 122 + did, 123 + ) 124 + return err 125 + } 126 + 127 + func GetOAuthSessionByDid(e Execer, did string) (*OAuthSession, error) { 128 + var session OAuthSession 129 + err := e.QueryRow(` 130 + select 131 + id, 132 + did, 133 + handle, 134 + pds_url, 135 + access_jwt, 136 + refresh_jwt, 137 + auth_server_iss, 138 + dpop_auth_server_nonce, 139 + dpop_private_jwk, 140 + expiry 141 + from oauth_sessions 142 + where did = ?`, did).Scan( 143 + &session.ID, 144 + &session.Did, 145 + &session.Handle, 146 + &session.PdsUrl, 147 + &session.AccessJwt, 148 + &session.RefreshJwt, 149 + &session.AuthServerIss, 150 + &session.DpopAuthserverNonce, 151 + &session.DpopPrivateJwk, 152 + &session.Expiry, 153 + ) 154 + return &session, err 155 + } 156 + 157 + func DeleteOAuthSessionByDid(e Execer, did string) error { 158 + _, err := e.Exec(` 159 + delete from oauth_sessions 160 + where did = ?`, did) 161 + return err 162 + } 163 + 164 + func UpdateDpopPdsNonce(e Execer, did string, dpopPdsNonce string) error { 165 + _, err := e.Exec(` 166 + update oauth_sessions 167 + set dpop_pds_nonce = ? 168 + where did = ?`, 169 + dpopPdsNonce, 170 + did, 171 + ) 172 + return err 173 + }
+530
appview/db/profile.go
···
··· 1 + package db 2 + 3 + import ( 4 + "database/sql" 5 + "fmt" 6 + "log" 7 + "net/url" 8 + "slices" 9 + "strings" 10 + "time" 11 + 12 + "github.com/bluesky-social/indigo/atproto/syntax" 13 + "tangled.sh/tangled.sh/core/api/tangled" 14 + ) 15 + 16 + type RepoEvent struct { 17 + Repo *Repo 18 + Source *Repo 19 + } 20 + 21 + type ProfileTimeline struct { 22 + ByMonth []ByMonth 23 + } 24 + 25 + type ByMonth struct { 26 + RepoEvents []RepoEvent 27 + IssueEvents IssueEvents 28 + PullEvents PullEvents 29 + } 30 + 31 + func (b ByMonth) IsEmpty() bool { 32 + return len(b.RepoEvents) == 0 && 33 + len(b.IssueEvents.Items) == 0 && 34 + len(b.PullEvents.Items) == 0 35 + } 36 + 37 + type IssueEvents struct { 38 + Items []*Issue 39 + } 40 + 41 + type IssueEventStats struct { 42 + Open int 43 + Closed int 44 + } 45 + 46 + func (i IssueEvents) Stats() IssueEventStats { 47 + var open, closed int 48 + for _, issue := range i.Items { 49 + if issue.Open { 50 + open += 1 51 + } else { 52 + closed += 1 53 + } 54 + } 55 + 56 + return IssueEventStats{ 57 + Open: open, 58 + Closed: closed, 59 + } 60 + } 61 + 62 + type PullEvents struct { 63 + Items []*Pull 64 + } 65 + 66 + func (p PullEvents) Stats() PullEventStats { 67 + var open, merged, closed int 68 + for _, pull := range p.Items { 69 + switch pull.State { 70 + case PullOpen: 71 + open += 1 72 + case PullMerged: 73 + merged += 1 74 + case PullClosed: 75 + closed += 1 76 + } 77 + } 78 + 79 + return PullEventStats{ 80 + Open: open, 81 + Merged: merged, 82 + Closed: closed, 83 + } 84 + } 85 + 86 + type PullEventStats struct { 87 + Closed int 88 + Open int 89 + Merged int 90 + } 91 + 92 + const TimeframeMonths = 7 93 + 94 + func MakeProfileTimeline(e Execer, forDid string) (*ProfileTimeline, error) { 95 + timeline := ProfileTimeline{ 96 + ByMonth: make([]ByMonth, TimeframeMonths), 97 + } 98 + currentMonth := time.Now().Month() 99 + timeframe := fmt.Sprintf("-%d months", TimeframeMonths) 100 + 101 + pulls, err := GetPullsByOwnerDid(e, forDid, timeframe) 102 + if err != nil { 103 + return nil, fmt.Errorf("error getting pulls by owner did: %w", err) 104 + } 105 + 106 + // group pulls by month 107 + for _, pull := range pulls { 108 + pullMonth := pull.Created.Month() 109 + 110 + if currentMonth-pullMonth >= TimeframeMonths { 111 + // shouldn't happen; but times are weird 112 + continue 113 + } 114 + 115 + idx := currentMonth - pullMonth 116 + items := &timeline.ByMonth[idx].PullEvents.Items 117 + 118 + *items = append(*items, &pull) 119 + } 120 + 121 + issues, err := GetIssuesByOwnerDid(e, forDid, timeframe) 122 + if err != nil { 123 + return nil, fmt.Errorf("error getting issues by owner did: %w", err) 124 + } 125 + 126 + for _, issue := range issues { 127 + issueMonth := issue.Created.Month() 128 + 129 + if currentMonth-issueMonth >= TimeframeMonths { 130 + // shouldn't happen; but times are weird 131 + continue 132 + } 133 + 134 + idx := currentMonth - issueMonth 135 + items := &timeline.ByMonth[idx].IssueEvents.Items 136 + 137 + *items = append(*items, &issue) 138 + } 139 + 140 + repos, err := GetAllReposByDid(e, forDid) 141 + if err != nil { 142 + return nil, fmt.Errorf("error getting all repos by did: %w", err) 143 + } 144 + 145 + for _, repo := range repos { 146 + // TODO: get this in the original query; requires COALESCE because nullable 147 + var sourceRepo *Repo 148 + if repo.Source != "" { 149 + sourceRepo, err = GetRepoByAtUri(e, repo.Source) 150 + if err != nil { 151 + return nil, err 152 + } 153 + } 154 + 155 + repoMonth := repo.Created.Month() 156 + 157 + if currentMonth-repoMonth >= TimeframeMonths { 158 + // shouldn't happen; but times are weird 159 + continue 160 + } 161 + 162 + idx := currentMonth - repoMonth 163 + 164 + items := &timeline.ByMonth[idx].RepoEvents 165 + *items = append(*items, RepoEvent{ 166 + Repo: &repo, 167 + Source: sourceRepo, 168 + }) 169 + } 170 + 171 + return &timeline, nil 172 + } 173 + 174 + type Profile struct { 175 + // ids 176 + ID int 177 + Did string 178 + 179 + // data 180 + Description string 181 + IncludeBluesky bool 182 + Location string 183 + Links [5]string 184 + Stats [2]VanityStat 185 + PinnedRepos [6]syntax.ATURI 186 + } 187 + 188 + func (p Profile) IsLinksEmpty() bool { 189 + for _, l := range p.Links { 190 + if l != "" { 191 + return false 192 + } 193 + } 194 + return true 195 + } 196 + 197 + func (p Profile) IsStatsEmpty() bool { 198 + for _, s := range p.Stats { 199 + if s.Kind != "" { 200 + return false 201 + } 202 + } 203 + return true 204 + } 205 + 206 + func (p Profile) IsPinnedReposEmpty() bool { 207 + for _, r := range p.PinnedRepos { 208 + if r != "" { 209 + return false 210 + } 211 + } 212 + return true 213 + } 214 + 215 + type VanityStatKind string 216 + 217 + const ( 218 + VanityStatMergedPRCount VanityStatKind = "merged-pull-request-count" 219 + VanityStatClosedPRCount VanityStatKind = "closed-pull-request-count" 220 + VanityStatOpenPRCount VanityStatKind = "open-pull-request-count" 221 + VanityStatOpenIssueCount VanityStatKind = "open-issue-count" 222 + VanityStatClosedIssueCount VanityStatKind = "closed-issue-count" 223 + VanityStatRepositoryCount VanityStatKind = "repository-count" 224 + ) 225 + 226 + func (v VanityStatKind) String() string { 227 + switch v { 228 + case VanityStatMergedPRCount: 229 + return "Merged PRs" 230 + case VanityStatClosedPRCount: 231 + return "Closed PRs" 232 + case VanityStatOpenPRCount: 233 + return "Open PRs" 234 + case VanityStatOpenIssueCount: 235 + return "Open Issues" 236 + case VanityStatClosedIssueCount: 237 + return "Closed Issues" 238 + case VanityStatRepositoryCount: 239 + return "Repositories" 240 + } 241 + return "" 242 + } 243 + 244 + type VanityStat struct { 245 + Kind VanityStatKind 246 + Value uint64 247 + } 248 + 249 + func (p *Profile) ProfileAt() syntax.ATURI { 250 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", p.Did, tangled.ActorProfileNSID, "self")) 251 + } 252 + 253 + func UpsertProfile(tx *sql.Tx, profile *Profile) error { 254 + defer tx.Rollback() 255 + 256 + // update links 257 + _, err := tx.Exec(`delete from profile_links where did = ?`, profile.Did) 258 + if err != nil { 259 + return err 260 + } 261 + // update vanity stats 262 + _, err = tx.Exec(`delete from profile_stats where did = ?`, profile.Did) 263 + if err != nil { 264 + return err 265 + } 266 + 267 + // update pinned repos 268 + _, err = tx.Exec(`delete from profile_pinned_repositories where did = ?`, profile.Did) 269 + if err != nil { 270 + return err 271 + } 272 + 273 + includeBskyValue := 0 274 + if profile.IncludeBluesky { 275 + includeBskyValue = 1 276 + } 277 + 278 + _, err = tx.Exec( 279 + `insert or replace into profile ( 280 + did, 281 + description, 282 + include_bluesky, 283 + location 284 + ) 285 + values (?, ?, ?, ?)`, 286 + profile.Did, 287 + profile.Description, 288 + includeBskyValue, 289 + profile.Location, 290 + ) 291 + 292 + if err != nil { 293 + log.Println("profile", "err", err) 294 + return err 295 + } 296 + 297 + for _, link := range profile.Links { 298 + if link == "" { 299 + continue 300 + } 301 + 302 + _, err := tx.Exec( 303 + `insert into profile_links (did, link) values (?, ?)`, 304 + profile.Did, 305 + link, 306 + ) 307 + 308 + if err != nil { 309 + log.Println("profile_links", "err", err) 310 + return err 311 + } 312 + } 313 + 314 + for _, v := range profile.Stats { 315 + if v.Kind == "" { 316 + continue 317 + } 318 + 319 + _, err := tx.Exec( 320 + `insert into profile_stats (did, kind) values (?, ?)`, 321 + profile.Did, 322 + v.Kind, 323 + ) 324 + 325 + if err != nil { 326 + log.Println("profile_stats", "err", err) 327 + return err 328 + } 329 + } 330 + 331 + for _, pin := range profile.PinnedRepos { 332 + if pin == "" { 333 + continue 334 + } 335 + 336 + _, err := tx.Exec( 337 + `insert into profile_pinned_repositories (did, at_uri) values (?, ?)`, 338 + profile.Did, 339 + pin, 340 + ) 341 + 342 + if err != nil { 343 + log.Println("profile_pinned_repositories", "err", err) 344 + return err 345 + } 346 + } 347 + 348 + return tx.Commit() 349 + } 350 + 351 + func GetProfile(e Execer, did string) (*Profile, error) { 352 + var profile Profile 353 + profile.Did = did 354 + 355 + includeBluesky := 0 356 + err := e.QueryRow( 357 + `select description, include_bluesky, location from profile where did = ?`, 358 + did, 359 + ).Scan(&profile.Description, &includeBluesky, &profile.Location) 360 + if err == sql.ErrNoRows { 361 + profile := Profile{} 362 + profile.Did = did 363 + return &profile, nil 364 + } 365 + 366 + if err != nil { 367 + return nil, err 368 + } 369 + 370 + if includeBluesky != 0 { 371 + profile.IncludeBluesky = true 372 + } 373 + 374 + rows, err := e.Query(`select link from profile_links where did = ?`, did) 375 + if err != nil { 376 + return nil, err 377 + } 378 + defer rows.Close() 379 + i := 0 380 + for rows.Next() { 381 + if err := rows.Scan(&profile.Links[i]); err != nil { 382 + return nil, err 383 + } 384 + i++ 385 + } 386 + 387 + rows, err = e.Query(`select kind from profile_stats where did = ?`, did) 388 + if err != nil { 389 + return nil, err 390 + } 391 + defer rows.Close() 392 + i = 0 393 + for rows.Next() { 394 + if err := rows.Scan(&profile.Stats[i].Kind); err != nil { 395 + return nil, err 396 + } 397 + value, err := GetVanityStat(e, profile.Did, profile.Stats[i].Kind) 398 + if err != nil { 399 + return nil, err 400 + } 401 + profile.Stats[i].Value = value 402 + i++ 403 + } 404 + 405 + rows, err = e.Query(`select at_uri from profile_pinned_repositories where did = ?`, did) 406 + if err != nil { 407 + return nil, err 408 + } 409 + defer rows.Close() 410 + i = 0 411 + for rows.Next() { 412 + if err := rows.Scan(&profile.PinnedRepos[i]); err != nil { 413 + return nil, err 414 + } 415 + i++ 416 + } 417 + 418 + return &profile, nil 419 + } 420 + 421 + func GetVanityStat(e Execer, did string, stat VanityStatKind) (uint64, error) { 422 + query := "" 423 + var args []any 424 + switch stat { 425 + case VanityStatMergedPRCount: 426 + query = `select count(id) from pulls where owner_did = ? and state = ?` 427 + args = append(args, did, PullMerged) 428 + case VanityStatClosedPRCount: 429 + query = `select count(id) from pulls where owner_did = ? and state = ?` 430 + args = append(args, did, PullClosed) 431 + case VanityStatOpenPRCount: 432 + query = `select count(id) from pulls where owner_did = ? and state = ?` 433 + args = append(args, did, PullOpen) 434 + case VanityStatOpenIssueCount: 435 + query = `select count(id) from issues where owner_did = ? and open = 1` 436 + args = append(args, did) 437 + case VanityStatClosedIssueCount: 438 + query = `select count(id) from issues where owner_did = ? and open = 0` 439 + args = append(args, did) 440 + case VanityStatRepositoryCount: 441 + query = `select count(id) from repos where did = ?` 442 + args = append(args, did) 443 + } 444 + 445 + var result uint64 446 + err := e.QueryRow(query, args...).Scan(&result) 447 + if err != nil { 448 + return 0, err 449 + } 450 + 451 + return result, nil 452 + } 453 + 454 + func ValidateProfile(e Execer, profile *Profile) error { 455 + // ensure description is not too long 456 + if len(profile.Description) > 256 { 457 + return fmt.Errorf("Entered bio is too long.") 458 + } 459 + 460 + // ensure description is not too long 461 + if len(profile.Location) > 40 { 462 + return fmt.Errorf("Entered location is too long.") 463 + } 464 + 465 + // ensure links are in order 466 + err := validateLinks(profile) 467 + if err != nil { 468 + return err 469 + } 470 + 471 + // ensure all pinned repos are either own repos or collaborating repos 472 + repos, err := GetAllReposByDid(e, profile.Did) 473 + if err != nil { 474 + log.Printf("getting repos for %s: %s", profile.Did, err) 475 + } 476 + 477 + collaboratingRepos, err := CollaboratingIn(e, profile.Did) 478 + if err != nil { 479 + log.Printf("getting collaborating repos for %s: %s", profile.Did, err) 480 + } 481 + 482 + var validRepos []syntax.ATURI 483 + for _, r := range repos { 484 + validRepos = append(validRepos, r.RepoAt()) 485 + } 486 + for _, r := range collaboratingRepos { 487 + validRepos = append(validRepos, r.RepoAt()) 488 + } 489 + 490 + for _, pinned := range profile.PinnedRepos { 491 + if pinned == "" { 492 + continue 493 + } 494 + if !slices.Contains(validRepos, pinned) { 495 + return fmt.Errorf("Invalid pinned repo: `%s, does not belong to own or collaborating repos", pinned) 496 + } 497 + } 498 + 499 + return nil 500 + } 501 + 502 + func validateLinks(profile *Profile) error { 503 + for i, link := range profile.Links { 504 + if link == "" { 505 + continue 506 + } 507 + 508 + parsedURL, err := url.Parse(link) 509 + if err != nil { 510 + return fmt.Errorf("Invalid URL '%s': %v\n", link, err) 511 + } 512 + 513 + if parsedURL.Scheme == "" { 514 + if strings.HasPrefix(link, "//") { 515 + profile.Links[i] = "https:" + link 516 + } else { 517 + profile.Links[i] = "https://" + link 518 + } 519 + continue 520 + } else if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { 521 + return fmt.Errorf("Warning: URL '%s' has unusual scheme: %s\n", link, parsedURL.Scheme) 522 + } 523 + 524 + // catch relative paths 525 + if parsedURL.Host == "" { 526 + return fmt.Errorf("Warning: URL '%s' appears to be a relative path\n", link) 527 + } 528 + } 529 + return nil 530 + }
+9 -1
appview/db/pubkeys.go
··· 13 return err 14 } 15 16 - func RemovePublicKey(e Execer, did, name, key string) error { 17 _, err := e.Exec(` 18 delete from public_keys 19 where did = ? and name = ? and key = ?`, 20 did, name, key) 21 return err 22 } 23
··· 13 return err 14 } 15 16 + func DeletePublicKey(e Execer, did, name, key string) error { 17 _, err := e.Exec(` 18 delete from public_keys 19 where did = ? and name = ? and key = ?`, 20 did, name, key) 21 + return err 22 + } 23 + 24 + func DeletePublicKeyByRkey(e Execer, did, rkey string) error { 25 + _, err := e.Exec(` 26 + delete from public_keys 27 + where did = ? and rkey = ?`, 28 + did, rkey) 29 return err 30 } 31
+351 -47
appview/db/pulls.go
··· 4 "database/sql" 5 "fmt" 6 "log" 7 "strings" 8 "time" 9 10 "github.com/bluekeyes/go-gitdiff/gitdiff" 11 "github.com/bluesky-social/indigo/atproto/syntax" 12 "tangled.sh/tangled.sh/core/types" 13 ) 14 ··· 52 RepoAt syntax.ATURI 53 OwnerDid string 54 Rkey string 55 - PullAt syntax.ATURI 56 57 // content 58 Title string ··· 62 Submissions []*PullSubmission 63 64 // meta 65 - Created time.Time 66 } 67 68 type PullSubmission struct { ··· 77 RoundNumber int 78 Patch string 79 Comments []PullComment 80 81 // meta 82 Created time.Time ··· 105 return latestSubmission.Patch 106 } 107 108 func (p *Pull) LastRoundNumber() int { 109 return len(p.Submissions) - 1 110 } 111 112 - func (s PullSubmission) AsNiceDiff(targetBranch string) types.NiceDiff { 113 patch := s.Patch 114 115 - diffs, _, err := gitdiff.Parse(strings.NewReader(patch)) 116 if err != nil { 117 log.Println(err) 118 } ··· 150 return nd 151 } 152 153 - func NewPull(tx *sql.Tx, pull *Pull) error { 154 - defer tx.Rollback() 155 156 _, err := tx.Exec(` 157 insert or ignore into repo_pull_seqs (repo_at, next_pull_id) 158 values (?, 1) ··· 175 pull.PullId = nextId 176 pull.State = PullOpen 177 178 - _, err = tx.Exec(` 179 - insert into pulls (repo_at, owner_did, pull_id, title, target_branch, body, rkey, state) 180 - values (?, ?, ?, ?, ?, ?, ?, ?) 181 - `, pull.RepoAt, pull.OwnerDid, pull.PullId, pull.Title, pull.TargetBranch, pull.Body, pull.Rkey, pull.State) 182 - if err != nil { 183 - return err 184 } 185 186 - _, err = tx.Exec(` 187 - insert into pull_submissions (pull_id, repo_at, round_number, patch) 188 - values (?, ?, ?, ?) 189 - `, pull.PullId, pull.RepoAt, 0, pull.Submissions[0].Patch) 190 if err != nil { 191 return err 192 } 193 194 - if err := tx.Commit(); err != nil { 195 - return err 196 - } 197 - 198 - return nil 199 - } 200 - 201 - func SetPullAt(e Execer, repoAt syntax.ATURI, pullId int, pullAt string) error { 202 - _, err := e.Exec(`update pulls set pull_at = ? where repo_at = ? and pull_id = ?`, pullAt, repoAt, pullId) 203 return err 204 } 205 206 - func GetPullAt(e Execer, repoAt syntax.ATURI, pullId int) (string, error) { 207 - var pullAt string 208 - err := e.QueryRow(`select pull_at from pulls where repo_at = ? and pull_id = ?`, repoAt, pullId).Scan(&pullAt) 209 - return pullAt, err 210 } 211 212 func NextPullId(e Execer, repoAt syntax.ATURI) (int, error) { ··· 215 return pullId - 1, err 216 } 217 218 - func GetPulls(e Execer, repoAt syntax.ATURI, state PullState) ([]Pull, error) { 219 - var pulls []Pull 220 221 rows, err := e.Query(` 222 select ··· 226 title, 227 state, 228 target_branch, 229 - pull_at, 230 body, 231 - rkey 232 from 233 pulls 234 where 235 - repo_at = ? and state = ? 236 - order by 237 - created desc`, repoAt, state) 238 if err != nil { 239 return nil, err 240 } ··· 243 for rows.Next() { 244 var pull Pull 245 var createdAt string 246 err := rows.Scan( 247 &pull.OwnerDid, 248 &pull.PullId, ··· 250 &pull.Title, 251 &pull.State, 252 &pull.TargetBranch, 253 - &pull.PullAt, 254 &pull.Body, 255 &pull.Rkey, 256 ) 257 if err != nil { 258 return nil, err ··· 264 } 265 pull.Created = createdTime 266 267 - pulls = append(pulls, pull) 268 } 269 270 if err := rows.Err(); err != nil { 271 return nil, err 272 } 273 274 - return pulls, nil 275 } 276 277 func GetPull(e Execer, repoAt syntax.ATURI, pullId int) (*Pull, error) { ··· 283 title, 284 state, 285 target_branch, 286 - pull_at, 287 repo_at, 288 body, 289 - rkey 290 from 291 pulls 292 where ··· 296 297 var pull Pull 298 var createdAt string 299 err := row.Scan( 300 &pull.OwnerDid, 301 &pull.PullId, ··· 303 &pull.Title, 304 &pull.State, 305 &pull.TargetBranch, 306 - &pull.PullAt, 307 &pull.RepoAt, 308 &pull.Body, 309 &pull.Rkey, 310 ) 311 if err != nil { 312 return nil, err ··· 318 } 319 pull.Created = createdTime 320 321 submissionsQuery := ` 322 select 323 - id, pull_id, repo_at, round_number, patch, created 324 from 325 pull_submissions 326 where ··· 337 for submissionsRows.Next() { 338 var submission PullSubmission 339 var submissionCreatedStr string 340 err := submissionsRows.Scan( 341 &submission.ID, 342 &submission.PullId, ··· 344 &submission.RoundNumber, 345 &submission.Patch, 346 &submissionCreatedStr, 347 ) 348 if err != nil { 349 return nil, err ··· 355 } 356 submission.Created = submissionCreatedTime 357 358 submissionsMap[submission.ID] = &submission 359 } 360 if err = submissionsRows.Close(); err != nil { ··· 425 return nil, err 426 } 427 428 pull.Submissions = make([]*PullSubmission, len(submissionsMap)) 429 for _, submission := range submissionsMap { 430 pull.Submissions[submission.RoundNumber] = submission ··· 433 return &pull, nil 434 } 435 436 func NewPullComment(e Execer, comment *PullComment) (int64, error) { 437 query := `insert into pull_comments (owner_did, repo_at, submission_id, comment_at, pull_id, body) values (?, ?, ?, ?, ?, ?)` 438 res, err := e.Exec( ··· 476 return err 477 } 478 479 - func ResubmitPull(e Execer, pull *Pull, newPatch string) error { 480 newRoundNumber := len(pull.Submissions) 481 _, err := e.Exec(` 482 - insert into pull_submissions (pull_id, repo_at, round_number, patch) 483 - values (?, ?, ?, ?) 484 - `, pull.PullId, pull.RepoAt, newRoundNumber, newPatch) 485 486 return err 487 }
··· 4 "database/sql" 5 "fmt" 6 "log" 7 + "sort" 8 "strings" 9 "time" 10 11 "github.com/bluekeyes/go-gitdiff/gitdiff" 12 "github.com/bluesky-social/indigo/atproto/syntax" 13 + "tangled.sh/tangled.sh/core/api/tangled" 14 + "tangled.sh/tangled.sh/core/patchutil" 15 "tangled.sh/tangled.sh/core/types" 16 ) 17 ··· 55 RepoAt syntax.ATURI 56 OwnerDid string 57 Rkey string 58 59 // content 60 Title string ··· 64 Submissions []*PullSubmission 65 66 // meta 67 + Created time.Time 68 + PullSource *PullSource 69 + 70 + // optionally, populate this when querying for reverse mappings 71 + Repo *Repo 72 + } 73 + 74 + type PullSource struct { 75 + Branch string 76 + RepoAt *syntax.ATURI 77 + 78 + // optionally populate this for reverse mappings 79 + Repo *Repo 80 } 81 82 type PullSubmission struct { ··· 91 RoundNumber int 92 Patch string 93 Comments []PullComment 94 + SourceRev string // include the rev that was used to create this submission: only for branch PRs 95 96 // meta 97 Created time.Time ··· 120 return latestSubmission.Patch 121 } 122 123 + func (p *Pull) PullAt() syntax.ATURI { 124 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", p.OwnerDid, tangled.RepoPullNSID, p.Rkey)) 125 + } 126 + 127 func (p *Pull) LastRoundNumber() int { 128 return len(p.Submissions) - 1 129 } 130 131 + func (p *Pull) IsPatchBased() bool { 132 + return p.PullSource == nil 133 + } 134 + 135 + func (p *Pull) IsBranchBased() bool { 136 + if p.PullSource != nil { 137 + if p.PullSource.RepoAt != nil { 138 + return p.PullSource.RepoAt == &p.RepoAt 139 + } else { 140 + // no repo specified 141 + return true 142 + } 143 + } 144 + return false 145 + } 146 + 147 + func (p *Pull) IsForkBased() bool { 148 + if p.PullSource != nil { 149 + if p.PullSource.RepoAt != nil { 150 + // make sure repos are different 151 + return p.PullSource.RepoAt != &p.RepoAt 152 + } 153 + } 154 + return false 155 + } 156 + 157 + func (s PullSubmission) AsDiff(targetBranch string) ([]*gitdiff.File, error) { 158 patch := s.Patch 159 160 + // if format-patch; then extract each patch 161 + var diffs []*gitdiff.File 162 + if patchutil.IsFormatPatch(patch) { 163 + patches, err := patchutil.ExtractPatches(patch) 164 + if err != nil { 165 + return nil, err 166 + } 167 + var ps [][]*gitdiff.File 168 + for _, p := range patches { 169 + ps = append(ps, p.Files) 170 + } 171 + 172 + diffs = patchutil.CombineDiff(ps...) 173 + } else { 174 + d, _, err := gitdiff.Parse(strings.NewReader(patch)) 175 + if err != nil { 176 + return nil, err 177 + } 178 + diffs = d 179 + } 180 + 181 + return diffs, nil 182 + } 183 + 184 + func (s PullSubmission) AsNiceDiff(targetBranch string) types.NiceDiff { 185 + diffs, err := s.AsDiff(targetBranch) 186 if err != nil { 187 log.Println(err) 188 } ··· 220 return nd 221 } 222 223 + func (s PullSubmission) IsFormatPatch() bool { 224 + return patchutil.IsFormatPatch(s.Patch) 225 + } 226 + 227 + func (s PullSubmission) AsFormatPatch() []patchutil.FormatPatch { 228 + patches, err := patchutil.ExtractPatches(s.Patch) 229 + if err != nil { 230 + log.Println("error extracting patches from submission:", err) 231 + return []patchutil.FormatPatch{} 232 + } 233 234 + return patches 235 + } 236 + 237 + func NewPull(tx *sql.Tx, pull *Pull) error { 238 _, err := tx.Exec(` 239 insert or ignore into repo_pull_seqs (repo_at, next_pull_id) 240 values (?, 1) ··· 257 pull.PullId = nextId 258 pull.State = PullOpen 259 260 + var sourceBranch, sourceRepoAt *string 261 + if pull.PullSource != nil { 262 + sourceBranch = &pull.PullSource.Branch 263 + if pull.PullSource.RepoAt != nil { 264 + x := pull.PullSource.RepoAt.String() 265 + sourceRepoAt = &x 266 + } 267 } 268 269 + _, err = tx.Exec( 270 + ` 271 + insert into pulls (repo_at, owner_did, pull_id, title, target_branch, body, rkey, state, source_branch, source_repo_at) 272 + values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, 273 + pull.RepoAt, 274 + pull.OwnerDid, 275 + pull.PullId, 276 + pull.Title, 277 + pull.TargetBranch, 278 + pull.Body, 279 + pull.Rkey, 280 + pull.State, 281 + sourceBranch, 282 + sourceRepoAt, 283 + ) 284 if err != nil { 285 return err 286 } 287 288 + _, err = tx.Exec(` 289 + insert into pull_submissions (pull_id, repo_at, round_number, patch, source_rev) 290 + values (?, ?, ?, ?, ?) 291 + `, pull.PullId, pull.RepoAt, 0, pull.Submissions[0].Patch, pull.Submissions[0].SourceRev) 292 return err 293 } 294 295 + func GetPullAt(e Execer, repoAt syntax.ATURI, pullId int) (syntax.ATURI, error) { 296 + pull, err := GetPull(e, repoAt, pullId) 297 + if err != nil { 298 + return "", err 299 + } 300 + return pull.PullAt(), err 301 } 302 303 func NextPullId(e Execer, repoAt syntax.ATURI) (int, error) { ··· 306 return pullId - 1, err 307 } 308 309 + func GetPulls(e Execer, repoAt syntax.ATURI, state PullState) ([]*Pull, error) { 310 + pulls := make(map[int]*Pull) 311 312 rows, err := e.Query(` 313 select ··· 317 title, 318 state, 319 target_branch, 320 body, 321 + rkey, 322 + source_branch, 323 + source_repo_at 324 from 325 pulls 326 where 327 + repo_at = ? and state = ?`, repoAt, state) 328 if err != nil { 329 return nil, err 330 } ··· 333 for rows.Next() { 334 var pull Pull 335 var createdAt string 336 + var sourceBranch, sourceRepoAt sql.NullString 337 err := rows.Scan( 338 &pull.OwnerDid, 339 &pull.PullId, ··· 341 &pull.Title, 342 &pull.State, 343 &pull.TargetBranch, 344 &pull.Body, 345 &pull.Rkey, 346 + &sourceBranch, 347 + &sourceRepoAt, 348 ) 349 if err != nil { 350 return nil, err ··· 356 } 357 pull.Created = createdTime 358 359 + if sourceBranch.Valid { 360 + pull.PullSource = &PullSource{ 361 + Branch: sourceBranch.String, 362 + } 363 + if sourceRepoAt.Valid { 364 + sourceRepoAtParsed, err := syntax.ParseATURI(sourceRepoAt.String) 365 + if err != nil { 366 + return nil, err 367 + } 368 + pull.PullSource.RepoAt = &sourceRepoAtParsed 369 + } 370 + } 371 + 372 + pulls[pull.PullId] = &pull 373 } 374 375 + // get latest round no. for each pull 376 + inClause := strings.TrimSuffix(strings.Repeat("?, ", len(pulls)), ", ") 377 + submissionsQuery := fmt.Sprintf(` 378 + select 379 + id, pull_id, round_number 380 + from 381 + pull_submissions 382 + where 383 + repo_at = ? and pull_id in (%s) 384 + `, inClause) 385 + 386 + args := make([]any, len(pulls)+1) 387 + args[0] = repoAt.String() 388 + idx := 1 389 + for _, p := range pulls { 390 + args[idx] = p.PullId 391 + idx += 1 392 + } 393 + submissionsRows, err := e.Query(submissionsQuery, args...) 394 + if err != nil { 395 + return nil, err 396 + } 397 + defer submissionsRows.Close() 398 + 399 + for submissionsRows.Next() { 400 + var s PullSubmission 401 + err := submissionsRows.Scan( 402 + &s.ID, 403 + &s.PullId, 404 + &s.RoundNumber, 405 + ) 406 + if err != nil { 407 + return nil, err 408 + } 409 + 410 + if p, ok := pulls[s.PullId]; ok { 411 + p.Submissions = make([]*PullSubmission, s.RoundNumber+1) 412 + p.Submissions[s.RoundNumber] = &s 413 + } 414 + } 415 if err := rows.Err(); err != nil { 416 return nil, err 417 } 418 419 + // get comment count on latest submission on each pull 420 + inClause = strings.TrimSuffix(strings.Repeat("?, ", len(pulls)), ", ") 421 + commentsQuery := fmt.Sprintf(` 422 + select 423 + count(id), pull_id 424 + from 425 + pull_comments 426 + where 427 + submission_id in (%s) 428 + group by 429 + submission_id 430 + `, inClause) 431 + 432 + args = []any{} 433 + for _, p := range pulls { 434 + args = append(args, p.Submissions[p.LastRoundNumber()].ID) 435 + } 436 + commentsRows, err := e.Query(commentsQuery, args...) 437 + if err != nil { 438 + return nil, err 439 + } 440 + defer commentsRows.Close() 441 + 442 + for commentsRows.Next() { 443 + var commentCount, pullId int 444 + err := commentsRows.Scan( 445 + &commentCount, 446 + &pullId, 447 + ) 448 + if err != nil { 449 + return nil, err 450 + } 451 + if p, ok := pulls[pullId]; ok { 452 + p.Submissions[p.LastRoundNumber()].Comments = make([]PullComment, commentCount) 453 + } 454 + } 455 + if err := rows.Err(); err != nil { 456 + return nil, err 457 + } 458 + 459 + orderedByDate := []*Pull{} 460 + for _, p := range pulls { 461 + orderedByDate = append(orderedByDate, p) 462 + } 463 + sort.Slice(orderedByDate, func(i, j int) bool { 464 + return orderedByDate[i].Created.After(orderedByDate[j].Created) 465 + }) 466 + 467 + return orderedByDate, nil 468 } 469 470 func GetPull(e Execer, repoAt syntax.ATURI, pullId int) (*Pull, error) { ··· 476 title, 477 state, 478 target_branch, 479 repo_at, 480 body, 481 + rkey, 482 + source_branch, 483 + source_repo_at 484 from 485 pulls 486 where ··· 490 491 var pull Pull 492 var createdAt string 493 + var sourceBranch, sourceRepoAt sql.NullString 494 err := row.Scan( 495 &pull.OwnerDid, 496 &pull.PullId, ··· 498 &pull.Title, 499 &pull.State, 500 &pull.TargetBranch, 501 &pull.RepoAt, 502 &pull.Body, 503 &pull.Rkey, 504 + &sourceBranch, 505 + &sourceRepoAt, 506 ) 507 if err != nil { 508 return nil, err ··· 514 } 515 pull.Created = createdTime 516 517 + // populate source 518 + if sourceBranch.Valid { 519 + pull.PullSource = &PullSource{ 520 + Branch: sourceBranch.String, 521 + } 522 + if sourceRepoAt.Valid { 523 + sourceRepoAtParsed, err := syntax.ParseATURI(sourceRepoAt.String) 524 + if err != nil { 525 + return nil, err 526 + } 527 + pull.PullSource.RepoAt = &sourceRepoAtParsed 528 + } 529 + } 530 + 531 submissionsQuery := ` 532 select 533 + id, pull_id, repo_at, round_number, patch, created, source_rev 534 from 535 pull_submissions 536 where ··· 547 for submissionsRows.Next() { 548 var submission PullSubmission 549 var submissionCreatedStr string 550 + var submissionSourceRev sql.NullString 551 err := submissionsRows.Scan( 552 &submission.ID, 553 &submission.PullId, ··· 555 &submission.RoundNumber, 556 &submission.Patch, 557 &submissionCreatedStr, 558 + &submissionSourceRev, 559 ) 560 if err != nil { 561 return nil, err ··· 567 } 568 submission.Created = submissionCreatedTime 569 570 + if submissionSourceRev.Valid { 571 + submission.SourceRev = submissionSourceRev.String 572 + } 573 + 574 submissionsMap[submission.ID] = &submission 575 } 576 if err = submissionsRows.Close(); err != nil { ··· 641 return nil, err 642 } 643 644 + var pullSourceRepo *Repo 645 + if pull.PullSource != nil { 646 + if pull.PullSource.RepoAt != nil { 647 + pullSourceRepo, err = GetRepoByAtUri(e, pull.PullSource.RepoAt.String()) 648 + if err != nil { 649 + log.Printf("failed to get repo by at uri: %v", err) 650 + } else { 651 + pull.PullSource.Repo = pullSourceRepo 652 + } 653 + } 654 + } 655 + 656 pull.Submissions = make([]*PullSubmission, len(submissionsMap)) 657 for _, submission := range submissionsMap { 658 pull.Submissions[submission.RoundNumber] = submission ··· 661 return &pull, nil 662 } 663 664 + // timeframe here is directly passed into the sql query filter, and any 665 + // timeframe in the past should be negative; e.g.: "-3 months" 666 + func GetPullsByOwnerDid(e Execer, did, timeframe string) ([]Pull, error) { 667 + var pulls []Pull 668 + 669 + rows, err := e.Query(` 670 + select 671 + p.owner_did, 672 + p.repo_at, 673 + p.pull_id, 674 + p.created, 675 + p.title, 676 + p.state, 677 + r.did, 678 + r.name, 679 + r.knot, 680 + r.rkey, 681 + r.created 682 + from 683 + pulls p 684 + join 685 + repos r on p.repo_at = r.at_uri 686 + where 687 + p.owner_did = ? and p.created >= date ('now', ?) 688 + order by 689 + p.created desc`, did, timeframe) 690 + if err != nil { 691 + return nil, err 692 + } 693 + defer rows.Close() 694 + 695 + for rows.Next() { 696 + var pull Pull 697 + var repo Repo 698 + var pullCreatedAt, repoCreatedAt string 699 + err := rows.Scan( 700 + &pull.OwnerDid, 701 + &pull.RepoAt, 702 + &pull.PullId, 703 + &pullCreatedAt, 704 + &pull.Title, 705 + &pull.State, 706 + &repo.Did, 707 + &repo.Name, 708 + &repo.Knot, 709 + &repo.Rkey, 710 + &repoCreatedAt, 711 + ) 712 + if err != nil { 713 + return nil, err 714 + } 715 + 716 + pullCreatedTime, err := time.Parse(time.RFC3339, pullCreatedAt) 717 + if err != nil { 718 + return nil, err 719 + } 720 + pull.Created = pullCreatedTime 721 + 722 + repoCreatedTime, err := time.Parse(time.RFC3339, repoCreatedAt) 723 + if err != nil { 724 + return nil, err 725 + } 726 + repo.Created = repoCreatedTime 727 + 728 + pull.Repo = &repo 729 + 730 + pulls = append(pulls, pull) 731 + } 732 + 733 + if err := rows.Err(); err != nil { 734 + return nil, err 735 + } 736 + 737 + return pulls, nil 738 + } 739 + 740 func NewPullComment(e Execer, comment *PullComment) (int64, error) { 741 query := `insert into pull_comments (owner_did, repo_at, submission_id, comment_at, pull_id, body) values (?, ?, ?, ?, ?, ?)` 742 res, err := e.Exec( ··· 780 return err 781 } 782 783 + func ResubmitPull(e Execer, pull *Pull, newPatch, sourceRev string) error { 784 newRoundNumber := len(pull.Submissions) 785 _, err := e.Exec(` 786 + insert into pull_submissions (pull_id, repo_at, round_number, patch, source_rev) 787 + values (?, ?, ?, ?, ?) 788 + `, pull.PullId, pull.RepoAt, newRoundNumber, newPatch, sourceRev) 789 790 return err 791 }
+141 -15
appview/db/repos.go
··· 2 3 import ( 4 "database/sql" 5 "time" 6 ) 7 8 type Repo struct { ··· 16 17 // optionally, populate this when querying for reverse mappings 18 RepoStats *RepoStats 19 } 20 21 func GetAllRepos(e Execer, limit int) ([]Repo, error) { 22 var repos []Repo 23 24 rows, err := e.Query( 25 - `select did, name, knot, rkey, description, created 26 from repos 27 order by created desc 28 limit ? ··· 37 for rows.Next() { 38 var repo Repo 39 err := scanRepo( 40 - rows, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &repo.Description, &repo.Created, 41 ) 42 if err != nil { 43 return nil, err ··· 63 r.rkey, 64 r.description, 65 r.created, 66 - count(s.id) as star_count 67 from 68 repos r 69 left join ··· 71 where 72 r.did = ? 73 group by 74 - r.at_uri`, did) 75 if err != nil { 76 return nil, err 77 } ··· 82 var repoStats RepoStats 83 var createdAt string 84 var nullableDescription sql.NullString 85 86 - err := rows.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &repoStats.StarCount) 87 if err != nil { 88 return nil, err 89 } 90 91 if nullableDescription.Valid { 92 repo.Description = nullableDescription.String 93 - } else { 94 - repo.Description = "" 95 } 96 97 createdAtTime, err := time.Parse(time.RFC3339, createdAt) ··· 159 160 func AddRepo(e Execer, repo *Repo) error { 161 _, err := e.Exec( 162 - `insert into repos 163 - (did, name, knot, rkey, at_uri, description) 164 - values (?, ?, ?, ?, ?, ?)`, 165 - repo.Did, repo.Name, repo.Knot, repo.Rkey, repo.AtUri, repo.Description, 166 ) 167 return err 168 } 169 170 - func RemoveRepo(e Execer, did, name, rkey string) error { 171 - _, err := e.Exec(`delete from repos where did = ? and name = ? and rkey = ?`, did, name, rkey) 172 return err 173 } 174 175 func AddCollaborator(e Execer, collaborator, repoOwnerDid, repoName, repoKnot string) error { 176 _, err := e.Exec( 177 `insert into collaborators (did, repo) ··· 249 PullCount PullCount 250 } 251 252 - func scanRepo(rows *sql.Rows, did, name, knot, rkey, description *string, created *time.Time) error { 253 var createdAt string 254 var nullableDescription sql.NullString 255 - if err := rows.Scan(did, name, knot, rkey, &nullableDescription, &createdAt); err != nil { 256 return err 257 } 258 ··· 267 *created = time.Now() 268 } else { 269 *created = createdAtTime 270 } 271 272 return nil
··· 2 3 import ( 4 "database/sql" 5 + "fmt" 6 "time" 7 + 8 + "github.com/bluesky-social/indigo/atproto/syntax" 9 + securejoin "github.com/cyphar/filepath-securejoin" 10 + "tangled.sh/tangled.sh/core/api/tangled" 11 ) 12 13 type Repo struct { ··· 21 22 // optionally, populate this when querying for reverse mappings 23 RepoStats *RepoStats 24 + 25 + // optional 26 + Source string 27 + } 28 + 29 + func (r Repo) RepoAt() syntax.ATURI { 30 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", r.Did, tangled.RepoNSID, r.Rkey)) 31 + } 32 + 33 + func (r Repo) DidSlashRepo() string { 34 + p, _ := securejoin.SecureJoin(r.Did, r.Name) 35 + return p 36 } 37 38 func GetAllRepos(e Execer, limit int) ([]Repo, error) { 39 var repos []Repo 40 41 rows, err := e.Query( 42 + `select did, name, knot, rkey, description, created, source 43 from repos 44 order by created desc 45 limit ? ··· 54 for rows.Next() { 55 var repo Repo 56 err := scanRepo( 57 + rows, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &repo.Description, &repo.Created, &repo.Source, 58 ) 59 if err != nil { 60 return nil, err ··· 80 r.rkey, 81 r.description, 82 r.created, 83 + count(s.id) as star_count, 84 + r.source 85 from 86 repos r 87 left join ··· 89 where 90 r.did = ? 91 group by 92 + r.at_uri 93 + order by r.created desc`, 94 + did) 95 if err != nil { 96 return nil, err 97 } ··· 102 var repoStats RepoStats 103 var createdAt string 104 var nullableDescription sql.NullString 105 + var nullableSource sql.NullString 106 107 + err := rows.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &repoStats.StarCount, &nullableSource) 108 if err != nil { 109 return nil, err 110 } 111 112 if nullableDescription.Valid { 113 repo.Description = nullableDescription.String 114 + } 115 + 116 + if nullableSource.Valid { 117 + repo.Source = nullableSource.String 118 } 119 120 createdAtTime, err := time.Parse(time.RFC3339, createdAt) ··· 182 183 func AddRepo(e Execer, repo *Repo) error { 184 _, err := e.Exec( 185 + `insert into repos 186 + (did, name, knot, rkey, at_uri, description, source) 187 + values (?, ?, ?, ?, ?, ?, ?)`, 188 + repo.Did, repo.Name, repo.Knot, repo.Rkey, repo.AtUri, repo.Description, repo.Source, 189 ) 190 return err 191 } 192 193 + func RemoveRepo(e Execer, did, name string) error { 194 + _, err := e.Exec(`delete from repos where did = ? and name = ?`, did, name) 195 return err 196 } 197 198 + func GetRepoSource(e Execer, repoAt syntax.ATURI) (string, error) { 199 + var nullableSource sql.NullString 200 + err := e.QueryRow(`select source from repos where at_uri = ?`, repoAt).Scan(&nullableSource) 201 + if err != nil { 202 + return "", err 203 + } 204 + return nullableSource.String, nil 205 + } 206 + 207 + func GetForksByDid(e Execer, did string) ([]Repo, error) { 208 + var repos []Repo 209 + 210 + rows, err := e.Query( 211 + `select did, name, knot, rkey, description, created, at_uri, source 212 + from repos 213 + where did = ? and source is not null and source != '' 214 + order by created desc`, 215 + did, 216 + ) 217 + if err != nil { 218 + return nil, err 219 + } 220 + defer rows.Close() 221 + 222 + for rows.Next() { 223 + var repo Repo 224 + var createdAt string 225 + var nullableDescription sql.NullString 226 + var nullableSource sql.NullString 227 + 228 + err := rows.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &repo.AtUri, &nullableSource) 229 + if err != nil { 230 + return nil, err 231 + } 232 + 233 + if nullableDescription.Valid { 234 + repo.Description = nullableDescription.String 235 + } 236 + 237 + if nullableSource.Valid { 238 + repo.Source = nullableSource.String 239 + } 240 + 241 + createdAtTime, err := time.Parse(time.RFC3339, createdAt) 242 + if err != nil { 243 + repo.Created = time.Now() 244 + } else { 245 + repo.Created = createdAtTime 246 + } 247 + 248 + repos = append(repos, repo) 249 + } 250 + 251 + if err := rows.Err(); err != nil { 252 + return nil, err 253 + } 254 + 255 + return repos, nil 256 + } 257 + 258 + func GetForkByDid(e Execer, did string, name string) (*Repo, error) { 259 + var repo Repo 260 + var createdAt string 261 + var nullableDescription sql.NullString 262 + var nullableSource sql.NullString 263 + 264 + row := e.QueryRow( 265 + `select did, name, knot, rkey, description, created, at_uri, source 266 + from repos 267 + where did = ? and name = ? and source is not null and source != ''`, 268 + did, name, 269 + ) 270 + 271 + err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &repo.AtUri, &nullableSource) 272 + if err != nil { 273 + return nil, err 274 + } 275 + 276 + if nullableDescription.Valid { 277 + repo.Description = nullableDescription.String 278 + } 279 + 280 + if nullableSource.Valid { 281 + repo.Source = nullableSource.String 282 + } 283 + 284 + createdAtTime, err := time.Parse(time.RFC3339, createdAt) 285 + if err != nil { 286 + repo.Created = time.Now() 287 + } else { 288 + repo.Created = createdAtTime 289 + } 290 + 291 + return &repo, nil 292 + } 293 + 294 func AddCollaborator(e Execer, collaborator, repoOwnerDid, repoName, repoKnot string) error { 295 _, err := e.Exec( 296 `insert into collaborators (did, repo) ··· 368 PullCount PullCount 369 } 370 371 + func scanRepo(rows *sql.Rows, did, name, knot, rkey, description *string, created *time.Time, source *string) error { 372 var createdAt string 373 var nullableDescription sql.NullString 374 + var nullableSource sql.NullString 375 + if err := rows.Scan(did, name, knot, rkey, &nullableDescription, &createdAt, &nullableSource); err != nil { 376 return err 377 } 378 ··· 387 *created = time.Now() 388 } else { 389 *created = createdAtTime 390 + } 391 + 392 + if nullableSource.Valid { 393 + *source = nullableSource.String 394 + } else { 395 + *source = "" 396 } 397 398 return nil
+6
appview/db/star.go
··· 69 return err 70 } 71 72 func GetStarCount(e Execer, repoAt syntax.ATURI) (int, error) { 73 stars := 0 74 err := e.QueryRow(
··· 69 return err 70 } 71 72 + // Remove a star 73 + func DeleteStarByRkey(e Execer, starredByDid string, rkey string) error { 74 + _, err := e.Exec(`delete from stars where starred_by_did = ? and rkey = ?`, starredByDid, rkey) 75 + return err 76 + } 77 + 78 func GetStarCount(e Execer, repoAt syntax.ATURI) (int, error) { 79 stars := 0 80 err := e.QueryRow(
+13
appview/db/timeline.go
··· 9 *Repo 10 *Follow 11 *Star 12 EventAt time.Time 13 } 14 15 // TODO: this gathers heterogenous events from different sources and aggregates ··· 34 } 35 36 for _, repo := range repos { 37 events = append(events, TimelineEvent{ 38 Repo: &repo, 39 EventAt: repo.Created, 40 }) 41 } 42
··· 9 *Repo 10 *Follow 11 *Star 12 + 13 EventAt time.Time 14 + 15 + // optional: populate only if Repo is a fork 16 + Source *Repo 17 } 18 19 // TODO: this gathers heterogenous events from different sources and aggregates ··· 38 } 39 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 46 + } 47 + } 48 + 49 events = append(events, TimelineEvent{ 50 Repo: &repo, 51 EventAt: repo.Created, 52 + Source: sourceRepo, 53 }) 54 } 55
+62
appview/filetree/filetree.go
···
··· 1 + package filetree 2 + 3 + import ( 4 + "path/filepath" 5 + "sort" 6 + "strings" 7 + ) 8 + 9 + type FileTreeNode struct { 10 + Name string 11 + Path string 12 + IsDirectory bool 13 + Children map[string]*FileTreeNode 14 + } 15 + 16 + // NewNode creates a new node 17 + func newNode(name, path string, isDir bool) *FileTreeNode { 18 + return &FileTreeNode{ 19 + Name: name, 20 + Path: path, 21 + IsDirectory: isDir, 22 + Children: make(map[string]*FileTreeNode), 23 + } 24 + } 25 + 26 + func FileTree(files []string) *FileTreeNode { 27 + rootNode := newNode("", "", true) 28 + 29 + sort.Strings(files) 30 + 31 + for _, file := range files { 32 + if file == "" { 33 + continue 34 + } 35 + 36 + parts := strings.Split(filepath.Clean(file), "/") 37 + if len(parts) == 0 { 38 + continue 39 + } 40 + 41 + currentNode := rootNode 42 + currentPath := "" 43 + 44 + for i, part := range parts { 45 + if currentPath == "" { 46 + currentPath = part 47 + } else { 48 + currentPath = filepath.Join(currentPath, part) 49 + } 50 + 51 + isDir := i < len(parts)-1 52 + 53 + if _, exists := currentNode.Children[part]; !exists { 54 + currentNode.Children[part] = newNode(part, currentPath, isDir) 55 + } 56 + 57 + currentNode = currentNode.Children[part] 58 + } 59 + } 60 + 61 + return rootNode 62 + }
+287
appview/ingester.go
···
··· 1 + package appview 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "log" 8 + "time" 9 + 10 + "github.com/bluesky-social/indigo/atproto/syntax" 11 + "github.com/bluesky-social/jetstream/pkg/models" 12 + "github.com/go-git/go-git/v5/plumbing" 13 + "github.com/ipfs/go-cid" 14 + "tangled.sh/tangled.sh/core/api/tangled" 15 + "tangled.sh/tangled.sh/core/appview/db" 16 + "tangled.sh/tangled.sh/core/rbac" 17 + ) 18 + 19 + type Ingester func(ctx context.Context, e *models.Event) error 20 + 21 + func Ingest(d db.DbWrapper, enforcer *rbac.Enforcer) Ingester { 22 + return func(ctx context.Context, e *models.Event) error { 23 + var err error 24 + defer func() { 25 + eventTime := e.TimeUS 26 + lastTimeUs := eventTime + 1 27 + if err := d.SaveLastTimeUs(lastTimeUs); err != nil { 28 + err = fmt.Errorf("(deferred) failed to save last time us: %w", err) 29 + } 30 + }() 31 + 32 + if e.Kind != models.EventKindCommit { 33 + return nil 34 + } 35 + 36 + switch e.Commit.Collection { 37 + case tangled.GraphFollowNSID: 38 + ingestFollow(&d, e) 39 + case tangled.FeedStarNSID: 40 + ingestStar(&d, e) 41 + case tangled.PublicKeyNSID: 42 + ingestPublicKey(&d, e) 43 + case tangled.RepoArtifactNSID: 44 + ingestArtifact(&d, e, enforcer) 45 + case tangled.ActorProfileNSID: 46 + ingestProfile(&d, e) 47 + } 48 + 49 + return err 50 + } 51 + } 52 + 53 + func ingestStar(d *db.DbWrapper, e *models.Event) error { 54 + var err error 55 + did := e.Did 56 + 57 + switch e.Commit.Operation { 58 + case models.CommitOperationCreate, models.CommitOperationUpdate: 59 + var subjectUri syntax.ATURI 60 + 61 + raw := json.RawMessage(e.Commit.Record) 62 + record := tangled.FeedStar{} 63 + err := json.Unmarshal(raw, &record) 64 + if err != nil { 65 + log.Println("invalid record") 66 + return err 67 + } 68 + 69 + subjectUri, err = syntax.ParseATURI(record.Subject) 70 + if err != nil { 71 + log.Println("invalid record") 72 + return err 73 + } 74 + err = db.AddStar(d, did, subjectUri, e.Commit.RKey) 75 + case models.CommitOperationDelete: 76 + err = db.DeleteStarByRkey(d, did, e.Commit.RKey) 77 + } 78 + 79 + if err != nil { 80 + return fmt.Errorf("failed to %s star record: %w", e.Commit.Operation, err) 81 + } 82 + 83 + return nil 84 + } 85 + 86 + func ingestFollow(d *db.DbWrapper, e *models.Event) error { 87 + var err error 88 + did := e.Did 89 + 90 + switch e.Commit.Operation { 91 + case models.CommitOperationCreate, models.CommitOperationUpdate: 92 + raw := json.RawMessage(e.Commit.Record) 93 + record := tangled.GraphFollow{} 94 + err = json.Unmarshal(raw, &record) 95 + if err != nil { 96 + log.Println("invalid record") 97 + return err 98 + } 99 + 100 + subjectDid := record.Subject 101 + err = db.AddFollow(d, did, subjectDid, e.Commit.RKey) 102 + case models.CommitOperationDelete: 103 + err = db.DeleteFollowByRkey(d, did, e.Commit.RKey) 104 + } 105 + 106 + if err != nil { 107 + return fmt.Errorf("failed to %s follow record: %w", e.Commit.Operation, err) 108 + } 109 + 110 + return nil 111 + } 112 + 113 + func ingestPublicKey(d *db.DbWrapper, e *models.Event) error { 114 + did := e.Did 115 + var err error 116 + 117 + switch e.Commit.Operation { 118 + case models.CommitOperationCreate, models.CommitOperationUpdate: 119 + log.Println("processing add of pubkey") 120 + raw := json.RawMessage(e.Commit.Record) 121 + record := tangled.PublicKey{} 122 + err = json.Unmarshal(raw, &record) 123 + if err != nil { 124 + log.Printf("invalid record: %s", err) 125 + return err 126 + } 127 + 128 + name := record.Name 129 + key := record.Key 130 + err = db.AddPublicKey(d, did, name, key, e.Commit.RKey) 131 + case models.CommitOperationDelete: 132 + log.Println("processing delete of pubkey") 133 + err = db.DeletePublicKeyByRkey(d, did, e.Commit.RKey) 134 + } 135 + 136 + if err != nil { 137 + return fmt.Errorf("failed to %s pubkey record: %w", e.Commit.Operation, err) 138 + } 139 + 140 + return nil 141 + } 142 + 143 + func ingestArtifact(d *db.DbWrapper, e *models.Event, enforcer *rbac.Enforcer) error { 144 + did := e.Did 145 + var err error 146 + 147 + switch e.Commit.Operation { 148 + case models.CommitOperationCreate, models.CommitOperationUpdate: 149 + raw := json.RawMessage(e.Commit.Record) 150 + record := tangled.RepoArtifact{} 151 + err = json.Unmarshal(raw, &record) 152 + if err != nil { 153 + log.Printf("invalid record: %s", err) 154 + return err 155 + } 156 + 157 + repoAt, err := syntax.ParseATURI(record.Repo) 158 + if err != nil { 159 + return err 160 + } 161 + 162 + repo, err := db.GetRepoByAtUri(d, repoAt.String()) 163 + if err != nil { 164 + return err 165 + } 166 + 167 + ok, err := enforcer.E.Enforce(did, repo.Knot, repo.DidSlashRepo(), "repo:push") 168 + if err != nil || !ok { 169 + return err 170 + } 171 + 172 + createdAt, err := time.Parse(time.RFC3339, record.CreatedAt) 173 + if err != nil { 174 + createdAt = time.Now() 175 + } 176 + 177 + artifact := db.Artifact{ 178 + Did: did, 179 + Rkey: e.Commit.RKey, 180 + RepoAt: repoAt, 181 + Tag: plumbing.Hash(record.Tag), 182 + CreatedAt: createdAt, 183 + BlobCid: cid.Cid(record.Artifact.Ref), 184 + Name: record.Name, 185 + Size: uint64(record.Artifact.Size), 186 + MimeType: record.Artifact.MimeType, 187 + } 188 + 189 + err = db.AddArtifact(d, artifact) 190 + case models.CommitOperationDelete: 191 + err = db.DeleteArtifact(d, db.Filter("did", did), db.Filter("rkey", e.Commit.RKey)) 192 + } 193 + 194 + if err != nil { 195 + return fmt.Errorf("failed to %s artifact record: %w", e.Commit.Operation, err) 196 + } 197 + 198 + return nil 199 + } 200 + 201 + func ingestProfile(d *db.DbWrapper, e *models.Event) error { 202 + did := e.Did 203 + var err error 204 + 205 + if e.Commit.RKey != "self" { 206 + return fmt.Errorf("ingestProfile only ingests `self` record") 207 + } 208 + 209 + switch e.Commit.Operation { 210 + case models.CommitOperationCreate, models.CommitOperationUpdate: 211 + raw := json.RawMessage(e.Commit.Record) 212 + record := tangled.ActorProfile{} 213 + err = json.Unmarshal(raw, &record) 214 + if err != nil { 215 + log.Printf("invalid record: %s", err) 216 + return err 217 + } 218 + 219 + description := "" 220 + if record.Description != nil { 221 + description = *record.Description 222 + } 223 + 224 + includeBluesky := record.Bluesky 225 + 226 + location := "" 227 + if record.Location != nil { 228 + location = *record.Location 229 + } 230 + 231 + var links [5]string 232 + for i, l := range record.Links { 233 + if i < 5 { 234 + links[i] = l 235 + } 236 + } 237 + 238 + var stats [2]db.VanityStat 239 + for i, s := range record.Stats { 240 + if i < 2 { 241 + stats[i].Kind = db.VanityStatKind(s) 242 + } 243 + } 244 + 245 + var pinned [6]syntax.ATURI 246 + for i, r := range record.PinnedRepositories { 247 + if i < 6 { 248 + pinned[i] = syntax.ATURI(r) 249 + } 250 + } 251 + 252 + profile := db.Profile{ 253 + Did: did, 254 + Description: description, 255 + IncludeBluesky: includeBluesky, 256 + Location: location, 257 + Links: links, 258 + Stats: stats, 259 + PinnedRepos: pinned, 260 + } 261 + 262 + ddb, ok := d.Execer.(*db.DB) 263 + if !ok { 264 + return fmt.Errorf("failed to index profile record, invalid db cast") 265 + } 266 + 267 + tx, err := ddb.Begin() 268 + if err != nil { 269 + return fmt.Errorf("failed to start transaction") 270 + } 271 + 272 + err = db.ValidateProfile(tx, &profile) 273 + if err != nil { 274 + return fmt.Errorf("invalid profile record") 275 + } 276 + 277 + err = db.UpsertProfile(tx, &profile) 278 + case models.CommitOperationDelete: 279 + err = db.DeleteArtifact(d, db.Filter("did", did), db.Filter("rkey", e.Commit.RKey)) 280 + } 281 + 282 + if err != nil { 283 + return fmt.Errorf("failed to %s profile record: %w", e.Commit.Operation, err) 284 + } 285 + 286 + return nil 287 + }
+489
appview/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 + "strconv" 15 + "time" 16 + 17 + "tangled.sh/tangled.sh/core/types" 18 + ) 19 + 20 + type SignerTransport struct { 21 + Secret string 22 + } 23 + 24 + func (s SignerTransport) RoundTrip(req *http.Request) (*http.Response, error) { 25 + timestamp := time.Now().Format(time.RFC3339) 26 + mac := hmac.New(sha256.New, []byte(s.Secret)) 27 + message := req.Method + req.URL.Path + timestamp 28 + mac.Write([]byte(message)) 29 + signature := hex.EncodeToString(mac.Sum(nil)) 30 + req.Header.Set("X-Signature", signature) 31 + req.Header.Set("X-Timestamp", timestamp) 32 + return http.DefaultTransport.RoundTrip(req) 33 + } 34 + 35 + type SignedClient struct { 36 + Secret string 37 + Url *url.URL 38 + client *http.Client 39 + } 40 + 41 + func NewSignedClient(domain, secret string, dev bool) (*SignedClient, error) { 42 + client := &http.Client{ 43 + Timeout: 5 * time.Second, 44 + Transport: SignerTransport{ 45 + Secret: secret, 46 + }, 47 + } 48 + 49 + scheme := "https" 50 + if dev { 51 + scheme = "http" 52 + } 53 + url, err := url.Parse(fmt.Sprintf("%s://%s", scheme, domain)) 54 + if err != nil { 55 + return nil, err 56 + } 57 + 58 + signedClient := &SignedClient{ 59 + Secret: secret, 60 + client: client, 61 + Url: url, 62 + } 63 + 64 + return signedClient, nil 65 + } 66 + 67 + func (s *SignedClient) newRequest(method, endpoint string, body []byte) (*http.Request, error) { 68 + return http.NewRequest(method, s.Url.JoinPath(endpoint).String(), bytes.NewReader(body)) 69 + } 70 + 71 + func (s *SignedClient) Init(did string) (*http.Response, error) { 72 + const ( 73 + Method = "POST" 74 + Endpoint = "/init" 75 + ) 76 + 77 + body, _ := json.Marshal(map[string]any{ 78 + "did": did, 79 + }) 80 + 81 + req, err := s.newRequest(Method, Endpoint, body) 82 + if err != nil { 83 + return nil, err 84 + } 85 + 86 + return s.client.Do(req) 87 + } 88 + 89 + func (s *SignedClient) NewRepo(did, repoName, defaultBranch string) (*http.Response, error) { 90 + const ( 91 + Method = "PUT" 92 + Endpoint = "/repo/new" 93 + ) 94 + 95 + body, _ := json.Marshal(map[string]any{ 96 + "did": did, 97 + "name": repoName, 98 + "default_branch": defaultBranch, 99 + }) 100 + 101 + req, err := s.newRequest(Method, Endpoint, body) 102 + if err != nil { 103 + return nil, err 104 + } 105 + 106 + return s.client.Do(req) 107 + } 108 + 109 + func (s *SignedClient) ForkRepo(ownerDid, source, name string) (*http.Response, error) { 110 + const ( 111 + Method = "POST" 112 + Endpoint = "/repo/fork" 113 + ) 114 + 115 + body, _ := json.Marshal(map[string]any{ 116 + "did": ownerDid, 117 + "source": source, 118 + "name": name, 119 + }) 120 + 121 + req, err := s.newRequest(Method, Endpoint, body) 122 + if err != nil { 123 + return nil, err 124 + } 125 + 126 + return s.client.Do(req) 127 + } 128 + 129 + func (s *SignedClient) RemoveRepo(did, repoName string) (*http.Response, error) { 130 + const ( 131 + Method = "DELETE" 132 + Endpoint = "/repo" 133 + ) 134 + 135 + body, _ := json.Marshal(map[string]any{ 136 + "did": did, 137 + "name": repoName, 138 + }) 139 + 140 + req, err := s.newRequest(Method, Endpoint, body) 141 + if err != nil { 142 + return nil, err 143 + } 144 + 145 + return s.client.Do(req) 146 + } 147 + 148 + func (s *SignedClient) AddMember(did string) (*http.Response, error) { 149 + const ( 150 + Method = "PUT" 151 + Endpoint = "/member/add" 152 + ) 153 + 154 + body, _ := json.Marshal(map[string]any{ 155 + "did": did, 156 + }) 157 + 158 + req, err := s.newRequest(Method, Endpoint, body) 159 + if err != nil { 160 + return nil, err 161 + } 162 + 163 + return s.client.Do(req) 164 + } 165 + 166 + func (s *SignedClient) SetDefaultBranch(ownerDid, repoName, branch string) (*http.Response, error) { 167 + const ( 168 + Method = "PUT" 169 + ) 170 + endpoint := fmt.Sprintf("/%s/%s/branches/default", ownerDid, repoName) 171 + 172 + body, _ := json.Marshal(map[string]any{ 173 + "branch": branch, 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) AddCollaborator(ownerDid, repoName, memberDid string) (*http.Response, error) { 185 + const ( 186 + Method = "POST" 187 + ) 188 + endpoint := fmt.Sprintf("/%s/%s/collaborator/add", ownerDid, repoName) 189 + 190 + body, _ := json.Marshal(map[string]any{ 191 + "did": memberDid, 192 + }) 193 + 194 + req, err := s.newRequest(Method, endpoint, body) 195 + if err != nil { 196 + return nil, err 197 + } 198 + 199 + return s.client.Do(req) 200 + } 201 + 202 + func (s *SignedClient) Merge( 203 + patch []byte, 204 + ownerDid, targetRepo, branch, commitMessage, commitBody, authorName, authorEmail string, 205 + ) (*http.Response, error) { 206 + const ( 207 + Method = "POST" 208 + ) 209 + endpoint := fmt.Sprintf("/%s/%s/merge", ownerDid, targetRepo) 210 + 211 + mr := types.MergeRequest{ 212 + Branch: branch, 213 + CommitMessage: commitMessage, 214 + CommitBody: commitBody, 215 + AuthorName: authorName, 216 + AuthorEmail: authorEmail, 217 + Patch: string(patch), 218 + } 219 + 220 + body, _ := json.Marshal(mr) 221 + 222 + req, err := s.newRequest(Method, endpoint, body) 223 + if err != nil { 224 + return nil, err 225 + } 226 + 227 + return s.client.Do(req) 228 + } 229 + 230 + func (s *SignedClient) MergeCheck(patch []byte, ownerDid, targetRepo, branch string) (*http.Response, error) { 231 + const ( 232 + Method = "POST" 233 + ) 234 + endpoint := fmt.Sprintf("/%s/%s/merge/check", ownerDid, targetRepo) 235 + 236 + body, _ := json.Marshal(map[string]any{ 237 + "patch": string(patch), 238 + "branch": branch, 239 + }) 240 + 241 + req, err := s.newRequest(Method, endpoint, body) 242 + if err != nil { 243 + return nil, err 244 + } 245 + 246 + return s.client.Do(req) 247 + } 248 + 249 + func (s *SignedClient) NewHiddenRef(ownerDid, targetRepo, forkBranch, remoteBranch string) (*http.Response, error) { 250 + const ( 251 + Method = "POST" 252 + ) 253 + endpoint := fmt.Sprintf("/%s/%s/hidden-ref/%s/%s", ownerDid, targetRepo, url.PathEscape(forkBranch), url.PathEscape(remoteBranch)) 254 + 255 + req, err := s.newRequest(Method, endpoint, nil) 256 + if err != nil { 257 + return nil, err 258 + } 259 + 260 + return s.client.Do(req) 261 + } 262 + 263 + type UnsignedClient struct { 264 + Url *url.URL 265 + client *http.Client 266 + } 267 + 268 + func NewUnsignedClient(domain string, dev bool) (*UnsignedClient, error) { 269 + client := &http.Client{ 270 + Timeout: 5 * time.Second, 271 + } 272 + 273 + scheme := "https" 274 + if dev { 275 + scheme = "http" 276 + } 277 + url, err := url.Parse(fmt.Sprintf("%s://%s", scheme, domain)) 278 + if err != nil { 279 + return nil, err 280 + } 281 + 282 + unsignedClient := &UnsignedClient{ 283 + client: client, 284 + Url: url, 285 + } 286 + 287 + return unsignedClient, nil 288 + } 289 + 290 + func (us *UnsignedClient) newRequest(method, endpoint string, query url.Values, body []byte) (*http.Request, error) { 291 + reqUrl := us.Url.JoinPath(endpoint) 292 + 293 + // add query parameters 294 + if query != nil { 295 + reqUrl.RawQuery = query.Encode() 296 + } 297 + 298 + return http.NewRequest(method, reqUrl.String(), bytes.NewReader(body)) 299 + } 300 + 301 + func (us *UnsignedClient) Index(ownerDid, repoName, ref string) (*http.Response, error) { 302 + const ( 303 + Method = "GET" 304 + ) 305 + 306 + endpoint := fmt.Sprintf("/%s/%s/tree/%s", ownerDid, repoName, ref) 307 + if ref == "" { 308 + endpoint = fmt.Sprintf("/%s/%s", ownerDid, repoName) 309 + } 310 + 311 + req, err := us.newRequest(Method, endpoint, nil, nil) 312 + if err != nil { 313 + return nil, err 314 + } 315 + 316 + return us.client.Do(req) 317 + } 318 + 319 + func (us *UnsignedClient) Log(ownerDid, repoName, ref string, page int) (*http.Response, error) { 320 + const ( 321 + Method = "GET" 322 + ) 323 + 324 + endpoint := fmt.Sprintf("/%s/%s/log/%s", ownerDid, repoName, url.PathEscape(ref)) 325 + 326 + query := url.Values{} 327 + query.Add("page", strconv.Itoa(page)) 328 + query.Add("per_page", strconv.Itoa(60)) 329 + 330 + req, err := us.newRequest(Method, endpoint, query, nil) 331 + if err != nil { 332 + return nil, err 333 + } 334 + 335 + return us.client.Do(req) 336 + } 337 + 338 + func (us *UnsignedClient) Branches(ownerDid, repoName string) (*http.Response, error) { 339 + const ( 340 + Method = "GET" 341 + ) 342 + 343 + endpoint := fmt.Sprintf("/%s/%s/branches", ownerDid, repoName) 344 + 345 + req, err := us.newRequest(Method, endpoint, nil, nil) 346 + if err != nil { 347 + return nil, err 348 + } 349 + 350 + return us.client.Do(req) 351 + } 352 + 353 + func (us *UnsignedClient) Tags(ownerDid, repoName string) (*types.RepoTagsResponse, error) { 354 + const ( 355 + Method = "GET" 356 + ) 357 + 358 + endpoint := fmt.Sprintf("/%s/%s/tags", ownerDid, repoName) 359 + 360 + req, err := us.newRequest(Method, endpoint, nil, nil) 361 + if err != nil { 362 + return nil, err 363 + } 364 + 365 + resp, err := us.client.Do(req) 366 + if err != nil { 367 + return nil, err 368 + } 369 + 370 + body, err := io.ReadAll(resp.Body) 371 + if err != nil { 372 + return nil, err 373 + } 374 + 375 + var result types.RepoTagsResponse 376 + err = json.Unmarshal(body, &result) 377 + if err != nil { 378 + return nil, err 379 + } 380 + 381 + return &result, nil 382 + } 383 + 384 + func (us *UnsignedClient) Branch(ownerDid, repoName, branch string) (*http.Response, error) { 385 + const ( 386 + Method = "GET" 387 + ) 388 + 389 + endpoint := fmt.Sprintf("/%s/%s/branches/%s", ownerDid, repoName, url.PathEscape(branch)) 390 + 391 + req, err := us.newRequest(Method, endpoint, nil, nil) 392 + if err != nil { 393 + return nil, err 394 + } 395 + 396 + return us.client.Do(req) 397 + } 398 + 399 + func (us *UnsignedClient) DefaultBranch(ownerDid, repoName string) (*types.RepoDefaultBranchResponse, error) { 400 + const ( 401 + Method = "GET" 402 + ) 403 + 404 + endpoint := fmt.Sprintf("/%s/%s/branches/default", ownerDid, repoName) 405 + 406 + req, err := us.newRequest(Method, endpoint, nil, nil) 407 + if err != nil { 408 + return nil, err 409 + } 410 + 411 + resp, err := us.client.Do(req) 412 + if err != nil { 413 + return nil, err 414 + } 415 + defer resp.Body.Close() 416 + 417 + var defaultBranch types.RepoDefaultBranchResponse 418 + if err := json.NewDecoder(resp.Body).Decode(&defaultBranch); err != nil { 419 + return nil, err 420 + } 421 + 422 + return &defaultBranch, nil 423 + } 424 + 425 + func (us *UnsignedClient) Capabilities() (*types.Capabilities, error) { 426 + const ( 427 + Method = "GET" 428 + Endpoint = "/capabilities" 429 + ) 430 + 431 + req, err := us.newRequest(Method, Endpoint, nil, nil) 432 + if err != nil { 433 + return nil, err 434 + } 435 + 436 + resp, err := us.client.Do(req) 437 + if err != nil { 438 + return nil, err 439 + } 440 + defer resp.Body.Close() 441 + 442 + var capabilities types.Capabilities 443 + if err := json.NewDecoder(resp.Body).Decode(&capabilities); err != nil { 444 + return nil, err 445 + } 446 + 447 + return &capabilities, nil 448 + } 449 + 450 + func (us *UnsignedClient) Compare(ownerDid, repoName, rev1, rev2 string) (*types.RepoFormatPatchResponse, error) { 451 + const ( 452 + Method = "GET" 453 + ) 454 + 455 + endpoint := fmt.Sprintf("/%s/%s/compare/%s/%s", ownerDid, repoName, url.PathEscape(rev1), url.PathEscape(rev2)) 456 + 457 + req, err := us.newRequest(Method, endpoint, nil, nil) 458 + if err != nil { 459 + return nil, fmt.Errorf("Failed to create request.") 460 + } 461 + 462 + compareResp, err := us.client.Do(req) 463 + if err != nil { 464 + return nil, fmt.Errorf("Failed to create request.") 465 + } 466 + defer compareResp.Body.Close() 467 + 468 + switch compareResp.StatusCode { 469 + case 404: 470 + case 400: 471 + return nil, fmt.Errorf("Branch comparisons not supported on this knot.") 472 + } 473 + 474 + respBody, err := io.ReadAll(compareResp.Body) 475 + if err != nil { 476 + log.Println("failed to compare across branches") 477 + return nil, fmt.Errorf("Failed to compare branches.") 478 + } 479 + defer compareResp.Body.Close() 480 + 481 + var formatPatchResponse types.RepoFormatPatchResponse 482 + err = json.Unmarshal(respBody, &formatPatchResponse) 483 + if err != nil { 484 + log.Println("failed to unmarshal format-patch response", err) 485 + return nil, fmt.Errorf("failed to compare branches.") 486 + } 487 + 488 + return &formatPatchResponse, nil 489 + }
+73
appview/middleware/middleware.go
···
··· 1 + package middleware 2 + 3 + import ( 4 + "context" 5 + "log" 6 + "net/http" 7 + "strconv" 8 + 9 + "tangled.sh/tangled.sh/core/appview/oauth" 10 + "tangled.sh/tangled.sh/core/appview/pagination" 11 + ) 12 + 13 + type Middleware func(http.Handler) http.Handler 14 + 15 + func AuthMiddleware(a *oauth.OAuth) Middleware { 16 + return func(next http.Handler) http.Handler { 17 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 18 + redirectFunc := func(w http.ResponseWriter, r *http.Request) { 19 + http.Redirect(w, r, "/login", http.StatusTemporaryRedirect) 20 + } 21 + if r.Header.Get("HX-Request") == "true" { 22 + redirectFunc = func(w http.ResponseWriter, _ *http.Request) { 23 + w.Header().Set("HX-Redirect", "/login") 24 + w.WriteHeader(http.StatusOK) 25 + } 26 + } 27 + 28 + _, auth, err := a.GetSession(r) 29 + if err != nil { 30 + log.Printf("not logged in, redirecting") 31 + redirectFunc(w, r) 32 + return 33 + } 34 + 35 + if !auth { 36 + log.Printf("not logged in, redirecting") 37 + redirectFunc(w, r) 38 + return 39 + } 40 + 41 + next.ServeHTTP(w, r) 42 + }) 43 + } 44 + } 45 + 46 + func Paginate(next http.Handler) http.Handler { 47 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 48 + page := pagination.FirstPage() 49 + 50 + offsetVal := r.URL.Query().Get("offset") 51 + if offsetVal != "" { 52 + offset, err := strconv.Atoi(offsetVal) 53 + if err != nil { 54 + log.Println("invalid offset") 55 + } else { 56 + page.Offset = offset 57 + } 58 + } 59 + 60 + limitVal := r.URL.Query().Get("limit") 61 + if limitVal != "" { 62 + limit, err := strconv.Atoi(limitVal) 63 + if err != nil { 64 + log.Println("invalid limit") 65 + } else { 66 + page.Limit = limit 67 + } 68 + } 69 + 70 + ctx := context.WithValue(r.Context(), "page", page) 71 + next.ServeHTTP(w, r.WithContext(ctx)) 72 + }) 73 + }
+24
appview/oauth/client/oauth_client.go
···
··· 1 + package client 2 + 3 + import ( 4 + oauth "github.com/haileyok/atproto-oauth-golang" 5 + "github.com/haileyok/atproto-oauth-golang/helpers" 6 + ) 7 + 8 + type OAuthClient struct { 9 + *oauth.Client 10 + } 11 + 12 + func NewClient(clientId, clientJwk, redirectUri string) (*OAuthClient, error) { 13 + k, err := helpers.ParseJWKFromBytes([]byte(clientJwk)) 14 + if err != nil { 15 + return nil, err 16 + } 17 + 18 + cli, err := oauth.NewClient(oauth.ClientArgs{ 19 + ClientId: clientId, 20 + ClientJwk: k, 21 + RedirectUri: redirectUri, 22 + }) 23 + return &OAuthClient{cli}, err 24 + }
+309
appview/oauth/handler/handler.go
···
··· 1 + package oauth 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "log" 7 + "net/http" 8 + "net/url" 9 + "strings" 10 + 11 + "github.com/go-chi/chi/v5" 12 + "github.com/gorilla/sessions" 13 + "github.com/haileyok/atproto-oauth-golang/helpers" 14 + "github.com/lestrrat-go/jwx/v2/jwk" 15 + "tangled.sh/tangled.sh/core/appview" 16 + "tangled.sh/tangled.sh/core/appview/db" 17 + "tangled.sh/tangled.sh/core/appview/knotclient" 18 + "tangled.sh/tangled.sh/core/appview/middleware" 19 + "tangled.sh/tangled.sh/core/appview/oauth" 20 + "tangled.sh/tangled.sh/core/appview/oauth/client" 21 + "tangled.sh/tangled.sh/core/appview/pages" 22 + "tangled.sh/tangled.sh/core/rbac" 23 + ) 24 + 25 + const ( 26 + oauthScope = "atproto transition:generic" 27 + ) 28 + 29 + type OAuthHandler struct { 30 + Config *appview.Config 31 + Pages *pages.Pages 32 + Resolver *appview.Resolver 33 + Db *db.DB 34 + Store *sessions.CookieStore 35 + OAuth *oauth.OAuth 36 + Enforcer *rbac.Enforcer 37 + } 38 + 39 + func (o *OAuthHandler) Router() http.Handler { 40 + r := chi.NewRouter() 41 + 42 + r.Get("/login", o.login) 43 + r.Post("/login", o.login) 44 + 45 + r.With(middleware.AuthMiddleware(o.OAuth)).Post("/logout", o.logout) 46 + 47 + r.Get("/oauth/client-metadata.json", o.clientMetadata) 48 + r.Get("/oauth/jwks.json", o.jwks) 49 + r.Get("/oauth/callback", o.callback) 50 + return r 51 + } 52 + 53 + func (o *OAuthHandler) clientMetadata(w http.ResponseWriter, r *http.Request) { 54 + w.Header().Set("Content-Type", "application/json") 55 + w.WriteHeader(http.StatusOK) 56 + json.NewEncoder(w).Encode(o.OAuth.ClientMetadata()) 57 + } 58 + 59 + func (o *OAuthHandler) jwks(w http.ResponseWriter, r *http.Request) { 60 + jwks := o.Config.OAuth.Jwks 61 + pubKey, err := pubKeyFromJwk(jwks) 62 + if err != nil { 63 + log.Printf("error parsing public key: %v", err) 64 + http.Error(w, err.Error(), http.StatusInternalServerError) 65 + return 66 + } 67 + 68 + response := helpers.CreateJwksResponseObject(pubKey) 69 + 70 + w.Header().Set("Content-Type", "application/json") 71 + w.WriteHeader(http.StatusOK) 72 + json.NewEncoder(w).Encode(response) 73 + } 74 + 75 + func (o *OAuthHandler) login(w http.ResponseWriter, r *http.Request) { 76 + switch r.Method { 77 + case http.MethodGet: 78 + o.Pages.Login(w, pages.LoginParams{}) 79 + case http.MethodPost: 80 + handle := strings.TrimPrefix(r.FormValue("handle"), "@") 81 + 82 + resolved, err := o.Resolver.ResolveIdent(r.Context(), handle) 83 + if err != nil { 84 + log.Println("failed to resolve handle:", err) 85 + o.Pages.Notice(w, "login-msg", fmt.Sprintf("\"%s\" is an invalid handle.", handle)) 86 + return 87 + } 88 + self := o.OAuth.ClientMetadata() 89 + oauthClient, err := client.NewClient( 90 + self.ClientID, 91 + o.Config.OAuth.Jwks, 92 + self.RedirectURIs[0], 93 + ) 94 + 95 + if err != nil { 96 + log.Println("failed to create oauth client:", err) 97 + o.Pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 98 + return 99 + } 100 + 101 + authServer, err := oauthClient.ResolvePdsAuthServer(r.Context(), resolved.PDSEndpoint()) 102 + if err != nil { 103 + log.Println("failed to resolve auth server:", err) 104 + o.Pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 105 + return 106 + } 107 + 108 + authMeta, err := oauthClient.FetchAuthServerMetadata(r.Context(), authServer) 109 + if err != nil { 110 + log.Println("failed to fetch auth server metadata:", err) 111 + o.Pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 112 + return 113 + } 114 + 115 + dpopKey, err := helpers.GenerateKey(nil) 116 + if err != nil { 117 + log.Println("failed to generate dpop key:", err) 118 + o.Pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 119 + return 120 + } 121 + 122 + dpopKeyJson, err := json.Marshal(dpopKey) 123 + if err != nil { 124 + log.Println("failed to marshal dpop key:", err) 125 + o.Pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 126 + return 127 + } 128 + 129 + parResp, err := oauthClient.SendParAuthRequest(r.Context(), authServer, authMeta, handle, oauthScope, dpopKey) 130 + if err != nil { 131 + log.Println("failed to send par auth request:", err) 132 + o.Pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 133 + return 134 + } 135 + 136 + err = db.SaveOAuthRequest(o.Db, db.OAuthRequest{ 137 + Did: resolved.DID.String(), 138 + PdsUrl: resolved.PDSEndpoint(), 139 + Handle: handle, 140 + AuthserverIss: authMeta.Issuer, 141 + PkceVerifier: parResp.PkceVerifier, 142 + DpopAuthserverNonce: parResp.DpopAuthserverNonce, 143 + DpopPrivateJwk: string(dpopKeyJson), 144 + State: parResp.State, 145 + }) 146 + if err != nil { 147 + log.Println("failed to save oauth request:", err) 148 + o.Pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 149 + return 150 + } 151 + 152 + u, _ := url.Parse(authMeta.AuthorizationEndpoint) 153 + query := url.Values{} 154 + query.Add("client_id", self.ClientID) 155 + query.Add("request_uri", parResp.RequestUri) 156 + u.RawQuery = query.Encode() 157 + o.Pages.HxRedirect(w, u.String()) 158 + } 159 + } 160 + 161 + func (o *OAuthHandler) callback(w http.ResponseWriter, r *http.Request) { 162 + state := r.FormValue("state") 163 + 164 + oauthRequest, err := db.GetOAuthRequestByState(o.Db, state) 165 + if err != nil { 166 + log.Println("failed to get oauth request:", err) 167 + o.Pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 168 + return 169 + } 170 + 171 + defer func() { 172 + err := db.DeleteOAuthRequestByState(o.Db, state) 173 + if err != nil { 174 + log.Println("failed to delete oauth request for state:", state, err) 175 + } 176 + }() 177 + 178 + error := r.FormValue("error") 179 + errorDescription := r.FormValue("error_description") 180 + if error != "" || errorDescription != "" { 181 + log.Printf("error: %s, %s", error, errorDescription) 182 + o.Pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 183 + return 184 + } 185 + 186 + code := r.FormValue("code") 187 + if code == "" { 188 + log.Println("missing code for state: ", state) 189 + o.Pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 190 + return 191 + } 192 + 193 + iss := r.FormValue("iss") 194 + if iss == "" { 195 + log.Println("missing iss for state: ", state) 196 + o.Pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 197 + return 198 + } 199 + 200 + self := o.OAuth.ClientMetadata() 201 + 202 + oauthClient, err := client.NewClient( 203 + self.ClientID, 204 + o.Config.OAuth.Jwks, 205 + self.RedirectURIs[0], 206 + ) 207 + 208 + if err != nil { 209 + log.Println("failed to create oauth client:", err) 210 + o.Pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 211 + return 212 + } 213 + 214 + jwk, err := helpers.ParseJWKFromBytes([]byte(oauthRequest.DpopPrivateJwk)) 215 + if err != nil { 216 + log.Println("failed to parse jwk:", err) 217 + o.Pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 218 + return 219 + } 220 + 221 + tokenResp, err := oauthClient.InitialTokenRequest( 222 + r.Context(), 223 + code, 224 + oauthRequest.AuthserverIss, 225 + oauthRequest.PkceVerifier, 226 + oauthRequest.DpopAuthserverNonce, 227 + jwk, 228 + ) 229 + if err != nil { 230 + log.Println("failed to get token:", err) 231 + o.Pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 232 + return 233 + } 234 + 235 + if tokenResp.Scope != oauthScope { 236 + log.Println("scope doesn't match:", tokenResp.Scope) 237 + o.Pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 238 + return 239 + } 240 + 241 + err = o.OAuth.SaveSession(w, r, oauthRequest, tokenResp) 242 + if err != nil { 243 + log.Println("failed to save session:", err) 244 + o.Pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 245 + return 246 + } 247 + 248 + log.Println("session saved successfully") 249 + go o.addToDefaultKnot(oauthRequest.Did) 250 + 251 + http.Redirect(w, r, "/", http.StatusFound) 252 + } 253 + 254 + func (o *OAuthHandler) logout(w http.ResponseWriter, r *http.Request) { 255 + err := o.OAuth.ClearSession(r, w) 256 + if err != nil { 257 + log.Println("failed to clear session:", err) 258 + http.Redirect(w, r, "/", http.StatusFound) 259 + return 260 + } 261 + 262 + log.Println("session cleared successfully") 263 + http.Redirect(w, r, "/", http.StatusFound) 264 + } 265 + 266 + func pubKeyFromJwk(jwks string) (jwk.Key, error) { 267 + k, err := helpers.ParseJWKFromBytes([]byte(jwks)) 268 + if err != nil { 269 + return nil, err 270 + } 271 + pubKey, err := k.PublicKey() 272 + if err != nil { 273 + return nil, err 274 + } 275 + return pubKey, nil 276 + } 277 + 278 + func (o *OAuthHandler) addToDefaultKnot(did string) { 279 + defaultKnot := "knot1.tangled.sh" 280 + 281 + log.Printf("adding %s to default knot", did) 282 + err := o.Enforcer.AddMember(defaultKnot, did) 283 + if err != nil { 284 + log.Println("failed to add user to knot1.tangled.sh: ", err) 285 + return 286 + } 287 + err = o.Enforcer.E.SavePolicy() 288 + if err != nil { 289 + log.Println("failed to add user to knot1.tangled.sh: ", err) 290 + return 291 + } 292 + 293 + secret, err := db.GetRegistrationKey(o.Db, defaultKnot) 294 + if err != nil { 295 + log.Println("failed to get registration key for knot1.tangled.sh") 296 + return 297 + } 298 + signedClient, err := knotclient.NewSignedClient(defaultKnot, secret, o.Config.Core.Dev) 299 + resp, err := signedClient.AddMember(did) 300 + if err != nil { 301 + log.Println("failed to add user to knot1.tangled.sh: ", err) 302 + return 303 + } 304 + 305 + if resp.StatusCode != http.StatusNoContent { 306 + log.Println("failed to add user to knot1.tangled.sh: ", resp.StatusCode) 307 + return 308 + } 309 + }
+268
appview/oauth/oauth.go
···
··· 1 + package oauth 2 + 3 + import ( 4 + "fmt" 5 + "log" 6 + "net/http" 7 + "net/url" 8 + "time" 9 + 10 + "github.com/gorilla/sessions" 11 + oauth "github.com/haileyok/atproto-oauth-golang" 12 + "github.com/haileyok/atproto-oauth-golang/helpers" 13 + "tangled.sh/tangled.sh/core/appview" 14 + "tangled.sh/tangled.sh/core/appview/db" 15 + "tangled.sh/tangled.sh/core/appview/oauth/client" 16 + xrpc "tangled.sh/tangled.sh/core/appview/xrpcclient" 17 + ) 18 + 19 + type OAuthRequest struct { 20 + ID uint 21 + AuthserverIss string 22 + State string 23 + Did string 24 + PdsUrl string 25 + PkceVerifier string 26 + DpopAuthserverNonce string 27 + DpopPrivateJwk string 28 + } 29 + 30 + type OAuth struct { 31 + Store *sessions.CookieStore 32 + Db *db.DB 33 + Config *appview.Config 34 + } 35 + 36 + func NewOAuth(db *db.DB, config *appview.Config) *OAuth { 37 + return &OAuth{ 38 + Store: sessions.NewCookieStore([]byte(config.Core.CookieSecret)), 39 + Db: db, 40 + Config: config, 41 + } 42 + } 43 + 44 + func (o *OAuth) SaveSession(w http.ResponseWriter, r *http.Request, oreq db.OAuthRequest, oresp *oauth.TokenResponse) error { 45 + // first we save the did in the user session 46 + userSession, err := o.Store.Get(r, appview.SessionName) 47 + if err != nil { 48 + return err 49 + } 50 + 51 + userSession.Values[appview.SessionDid] = oreq.Did 52 + userSession.Values[appview.SessionHandle] = oreq.Handle 53 + userSession.Values[appview.SessionPds] = oreq.PdsUrl 54 + userSession.Values[appview.SessionAuthenticated] = true 55 + err = userSession.Save(r, w) 56 + if err != nil { 57 + return fmt.Errorf("error saving user session: %w", err) 58 + } 59 + 60 + // then save the whole thing in the db 61 + session := db.OAuthSession{ 62 + Did: oreq.Did, 63 + Handle: oreq.Handle, 64 + PdsUrl: oreq.PdsUrl, 65 + DpopAuthserverNonce: oreq.DpopAuthserverNonce, 66 + AuthServerIss: oreq.AuthserverIss, 67 + DpopPrivateJwk: oreq.DpopPrivateJwk, 68 + AccessJwt: oresp.AccessToken, 69 + RefreshJwt: oresp.RefreshToken, 70 + Expiry: time.Now().Add(time.Duration(oresp.ExpiresIn) * time.Second).Format(time.RFC3339), 71 + } 72 + 73 + return db.SaveOAuthSession(o.Db, session) 74 + } 75 + 76 + func (o *OAuth) ClearSession(r *http.Request, w http.ResponseWriter) error { 77 + userSession, err := o.Store.Get(r, appview.SessionName) 78 + if err != nil || userSession.IsNew { 79 + return fmt.Errorf("error getting user session (or new session?): %w", err) 80 + } 81 + 82 + did := userSession.Values[appview.SessionDid].(string) 83 + 84 + err = db.DeleteOAuthSessionByDid(o.Db, did) 85 + if err != nil { 86 + return fmt.Errorf("error deleting oauth session: %w", err) 87 + } 88 + 89 + userSession.Options.MaxAge = -1 90 + 91 + return userSession.Save(r, w) 92 + } 93 + 94 + func (o *OAuth) GetSession(r *http.Request) (*db.OAuthSession, bool, error) { 95 + userSession, err := o.Store.Get(r, appview.SessionName) 96 + if err != nil || userSession.IsNew { 97 + return nil, false, fmt.Errorf("error getting user session (or new session?): %w", err) 98 + } 99 + 100 + did := userSession.Values[appview.SessionDid].(string) 101 + auth := userSession.Values[appview.SessionAuthenticated].(bool) 102 + 103 + session, err := db.GetOAuthSessionByDid(o.Db, did) 104 + if err != nil { 105 + return nil, false, fmt.Errorf("error getting oauth session: %w", err) 106 + } 107 + 108 + expiry, err := time.Parse(time.RFC3339, session.Expiry) 109 + if err != nil { 110 + return nil, false, fmt.Errorf("error parsing expiry time: %w", err) 111 + } 112 + if expiry.Sub(time.Now()) <= 5*time.Minute { 113 + privateJwk, err := helpers.ParseJWKFromBytes([]byte(session.DpopPrivateJwk)) 114 + if err != nil { 115 + return nil, false, err 116 + } 117 + 118 + self := o.ClientMetadata() 119 + 120 + oauthClient, err := client.NewClient( 121 + self.ClientID, 122 + o.Config.OAuth.Jwks, 123 + self.RedirectURIs[0], 124 + ) 125 + 126 + if err != nil { 127 + return nil, false, err 128 + } 129 + 130 + resp, err := oauthClient.RefreshTokenRequest(r.Context(), session.RefreshJwt, session.AuthServerIss, session.DpopAuthserverNonce, privateJwk) 131 + if err != nil { 132 + return nil, false, err 133 + } 134 + 135 + newExpiry := time.Now().Add(time.Duration(resp.ExpiresIn) * time.Second).Format(time.RFC3339) 136 + err = db.RefreshOAuthSession(o.Db, did, resp.AccessToken, resp.RefreshToken, newExpiry) 137 + if err != nil { 138 + return nil, false, fmt.Errorf("error refreshing oauth session: %w", err) 139 + } 140 + 141 + // update the current session 142 + session.AccessJwt = resp.AccessToken 143 + session.RefreshJwt = resp.RefreshToken 144 + session.DpopAuthserverNonce = resp.DpopAuthserverNonce 145 + session.Expiry = newExpiry 146 + } 147 + 148 + return session, auth, nil 149 + } 150 + 151 + type User struct { 152 + Handle string 153 + Did string 154 + Pds string 155 + } 156 + 157 + func (a *OAuth) GetUser(r *http.Request) *User { 158 + clientSession, err := a.Store.Get(r, appview.SessionName) 159 + 160 + if err != nil || clientSession.IsNew { 161 + return nil 162 + } 163 + 164 + return &User{ 165 + Handle: clientSession.Values[appview.SessionHandle].(string), 166 + Did: clientSession.Values[appview.SessionDid].(string), 167 + Pds: clientSession.Values[appview.SessionPds].(string), 168 + } 169 + } 170 + 171 + func (a *OAuth) GetDid(r *http.Request) string { 172 + clientSession, err := a.Store.Get(r, appview.SessionName) 173 + 174 + if err != nil || clientSession.IsNew { 175 + return "" 176 + } 177 + 178 + return clientSession.Values[appview.SessionDid].(string) 179 + } 180 + 181 + func (o *OAuth) AuthorizedClient(r *http.Request) (*xrpc.Client, error) { 182 + session, auth, err := o.GetSession(r) 183 + if err != nil { 184 + return nil, fmt.Errorf("error getting session: %w", err) 185 + } 186 + if !auth { 187 + return nil, fmt.Errorf("not authorized") 188 + } 189 + 190 + client := &oauth.XrpcClient{ 191 + OnDpopPdsNonceChanged: func(did, newNonce string) { 192 + err := db.UpdateDpopPdsNonce(o.Db, did, newNonce) 193 + if err != nil { 194 + log.Printf("error updating dpop pds nonce: %v", err) 195 + } 196 + }, 197 + } 198 + 199 + privateJwk, err := helpers.ParseJWKFromBytes([]byte(session.DpopPrivateJwk)) 200 + if err != nil { 201 + return nil, fmt.Errorf("error parsing private jwk: %w", err) 202 + } 203 + 204 + xrpcClient := xrpc.NewClient(client, &oauth.XrpcAuthedRequestArgs{ 205 + Did: session.Did, 206 + PdsUrl: session.PdsUrl, 207 + DpopPdsNonce: session.PdsUrl, 208 + AccessToken: session.AccessJwt, 209 + Issuer: session.AuthServerIss, 210 + DpopPrivateJwk: privateJwk, 211 + }) 212 + 213 + return xrpcClient, nil 214 + } 215 + 216 + type ClientMetadata struct { 217 + ClientID string `json:"client_id"` 218 + ClientName string `json:"client_name"` 219 + SubjectType string `json:"subject_type"` 220 + ClientURI string `json:"client_uri"` 221 + RedirectURIs []string `json:"redirect_uris"` 222 + GrantTypes []string `json:"grant_types"` 223 + ResponseTypes []string `json:"response_types"` 224 + ApplicationType string `json:"application_type"` 225 + DpopBoundAccessTokens bool `json:"dpop_bound_access_tokens"` 226 + JwksURI string `json:"jwks_uri"` 227 + Scope string `json:"scope"` 228 + TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"` 229 + TokenEndpointAuthSigningAlg string `json:"token_endpoint_auth_signing_alg"` 230 + } 231 + 232 + func (o *OAuth) ClientMetadata() ClientMetadata { 233 + makeRedirectURIs := func(c string) []string { 234 + return []string{fmt.Sprintf("%s/oauth/callback", c)} 235 + } 236 + 237 + clientURI := o.Config.Core.AppviewHost 238 + clientID := fmt.Sprintf("%s/oauth/client-metadata.json", clientURI) 239 + redirectURIs := makeRedirectURIs(clientURI) 240 + 241 + if o.Config.Core.Dev { 242 + clientURI = fmt.Sprintf("http://127.0.0.1:3000") 243 + redirectURIs = makeRedirectURIs(clientURI) 244 + 245 + query := url.Values{} 246 + query.Add("redirect_uri", redirectURIs[0]) 247 + query.Add("scope", "atproto transition:generic") 248 + clientID = fmt.Sprintf("http://localhost?%s", query.Encode()) 249 + } 250 + 251 + jwksURI := fmt.Sprintf("%s/oauth/jwks.json", clientURI) 252 + 253 + return ClientMetadata{ 254 + ClientID: clientID, 255 + ClientName: "Tangled", 256 + SubjectType: "public", 257 + ClientURI: clientURI, 258 + RedirectURIs: redirectURIs, 259 + GrantTypes: []string{"authorization_code", "refresh_token"}, 260 + ResponseTypes: []string{"code"}, 261 + ApplicationType: "web", 262 + DpopBoundAccessTokens: true, 263 + JwksURI: jwksURI, 264 + Scope: "atproto transition:generic", 265 + TokenEndpointAuthMethod: "private_key_jwt", 266 + TokenEndpointAuthSigningAlg: "ES256", 267 + } 268 + }
+14 -1
appview/pages/funcmap.go
··· 13 "time" 14 15 "github.com/dustin/go-humanize" 16 ) 17 18 func funcMap() template.FuncMap { ··· 30 return strings.Split(s, sep) 31 }, 32 "add": func(a, b int) int { 33 return a + b 34 }, 35 "sub": func(a, b int) int { ··· 68 return s 69 }, 70 "timeFmt": humanize.Time, 71 "shortTimeFmt": func(t time.Time) string { 72 return humanize.CustomRelTime(t, time.Now(), "", "", []humanize.RelTimeMagnitude{ 73 {time.Second, "now", time.Second}, ··· 134 return v.Slice(start, end).Interface() 135 }, 136 "markdown": func(text string) template.HTML { 137 - return template.HTML(renderMarkdown(text)) 138 }, 139 "isNil": func(t any) bool { 140 // returns false for other "zero" values ··· 165 } 166 return template.HTML(data) 167 }, 168 } 169 } 170
··· 13 "time" 14 15 "github.com/dustin/go-humanize" 16 + "github.com/microcosm-cc/bluemonday" 17 + "tangled.sh/tangled.sh/core/appview/filetree" 18 + "tangled.sh/tangled.sh/core/appview/pages/markup" 19 ) 20 21 func funcMap() template.FuncMap { ··· 33 return strings.Split(s, sep) 34 }, 35 "add": func(a, b int) int { 36 + return a + b 37 + }, 38 + // the absolute state of go templates 39 + "add64": func(a, b int64) int64 { 40 return a + b 41 }, 42 "sub": func(a, b int) int { ··· 75 return s 76 }, 77 "timeFmt": humanize.Time, 78 + "longTimeFmt": func(t time.Time) string { 79 + return t.Format("2006-01-02 * 3:04 PM") 80 + }, 81 "shortTimeFmt": func(t time.Time) string { 82 return humanize.CustomRelTime(t, time.Now(), "", "", []humanize.RelTimeMagnitude{ 83 {time.Second, "now", time.Second}, ··· 144 return v.Slice(start, end).Interface() 145 }, 146 "markdown": func(text string) template.HTML { 147 + rctx := &markup.RenderContext{RendererType: markup.RendererTypeDefault} 148 + return template.HTML(bluemonday.UGCPolicy().Sanitize(rctx.RenderMarkdown(text))) 149 }, 150 "isNil": func(t any) bool { 151 // returns false for other "zero" values ··· 176 } 177 return template.HTML(data) 178 }, 179 + "cssContentHash": CssContentHash, 180 + "fileTree": filetree.FileTree, 181 } 182 } 183
-23
appview/pages/markdown.go
··· 1 - package pages 2 - 3 - import ( 4 - "bytes" 5 - 6 - "github.com/yuin/goldmark" 7 - "github.com/yuin/goldmark/extension" 8 - "github.com/yuin/goldmark/parser" 9 - ) 10 - 11 - func renderMarkdown(source string) string { 12 - md := goldmark.New( 13 - goldmark.WithExtensions(extension.GFM), 14 - goldmark.WithParserOptions( 15 - parser.WithAutoHeadingID(), 16 - ), 17 - ) 18 - var buf bytes.Buffer 19 - if err := md.Convert([]byte(source), &buf); err != nil { 20 - return source 21 - } 22 - return buf.String() 23 - }
···
+31
appview/pages/markup/camo.go
···
··· 1 + package markup 2 + 3 + import ( 4 + "crypto/hmac" 5 + "crypto/sha256" 6 + "encoding/hex" 7 + "fmt" 8 + 9 + "github.com/yuin/goldmark/ast" 10 + ) 11 + 12 + func generateCamoURL(baseURL, secret, imageURL string) string { 13 + h := hmac.New(sha256.New, []byte(secret)) 14 + h.Write([]byte(imageURL)) 15 + signature := hex.EncodeToString(h.Sum(nil)) 16 + hexURL := hex.EncodeToString([]byte(imageURL)) 17 + return fmt.Sprintf("%s/%s/%s", baseURL, signature, hexURL) 18 + } 19 + 20 + func (rctx *RenderContext) camoImageLinkTransformer(img *ast.Image) { 21 + // don't camo on dev 22 + if rctx.IsDev { 23 + return 24 + } 25 + 26 + dst := string(img.Destination) 27 + 28 + if rctx.CamoUrl != "" && rctx.CamoSecret != "" { 29 + img.Destination = []byte(generateCamoURL(rctx.CamoUrl, rctx.CamoSecret, dst)) 30 + } 31 + }
+26
appview/pages/markup/format.go
···
··· 1 + package markup 2 + 3 + import "strings" 4 + 5 + type Format string 6 + 7 + const ( 8 + FormatMarkdown Format = "markdown" 9 + FormatText Format = "text" 10 + ) 11 + 12 + var FileTypes map[Format][]string = map[Format][]string{ 13 + FormatMarkdown: []string{".md", ".markdown", ".mdown", ".mkdn", ".mkd"}, 14 + } 15 + 16 + func GetFormat(filename string) Format { 17 + for format, extensions := range FileTypes { 18 + for _, extension := range extensions { 19 + if strings.HasSuffix(filename, extension) { 20 + return format 21 + } 22 + } 23 + } 24 + // default format 25 + return FormatText 26 + }
+144
appview/pages/markup/markdown.go
···
··· 1 + // Package markup is an umbrella package for all markups and their renderers. 2 + package markup 3 + 4 + import ( 5 + "bytes" 6 + "net/url" 7 + "path" 8 + 9 + "github.com/yuin/goldmark" 10 + "github.com/yuin/goldmark/ast" 11 + "github.com/yuin/goldmark/extension" 12 + "github.com/yuin/goldmark/parser" 13 + "github.com/yuin/goldmark/renderer/html" 14 + "github.com/yuin/goldmark/text" 15 + "github.com/yuin/goldmark/util" 16 + "tangled.sh/tangled.sh/core/appview/pages/repoinfo" 17 + ) 18 + 19 + // RendererType defines the type of renderer to use based on context 20 + type RendererType int 21 + 22 + const ( 23 + // RendererTypeRepoMarkdown is for repository documentation markdown files 24 + RendererTypeRepoMarkdown RendererType = iota 25 + // RendererTypeDefault is non-repo markdown, like issues/pulls/comments. 26 + RendererTypeDefault 27 + ) 28 + 29 + // RenderContext holds the contextual data for rendering markdown. 30 + // It can be initialized empty, and that'll skip any transformations. 31 + type RenderContext struct { 32 + CamoUrl string 33 + CamoSecret string 34 + repoinfo.RepoInfo 35 + IsDev bool 36 + RendererType RendererType 37 + } 38 + 39 + func (rctx *RenderContext) RenderMarkdown(source string) string { 40 + md := goldmark.New( 41 + goldmark.WithExtensions(extension.GFM), 42 + goldmark.WithParserOptions( 43 + parser.WithAutoHeadingID(), 44 + ), 45 + goldmark.WithRendererOptions(html.WithUnsafe()), 46 + ) 47 + 48 + if rctx != nil { 49 + var transformers []util.PrioritizedValue 50 + 51 + transformers = append(transformers, util.Prioritized(&MarkdownTransformer{rctx: rctx}, 10000)) 52 + 53 + md.Parser().AddOptions( 54 + parser.WithASTTransformers(transformers...), 55 + ) 56 + } 57 + 58 + var buf bytes.Buffer 59 + if err := md.Convert([]byte(source), &buf); err != nil { 60 + return source 61 + } 62 + return buf.String() 63 + } 64 + 65 + type MarkdownTransformer struct { 66 + rctx *RenderContext 67 + } 68 + 69 + func (a *MarkdownTransformer) Transform(node *ast.Document, reader text.Reader, pc parser.Context) { 70 + _ = ast.Walk(node, func(n ast.Node, entering bool) (ast.WalkStatus, error) { 71 + if !entering { 72 + return ast.WalkContinue, nil 73 + } 74 + 75 + switch a.rctx.RendererType { 76 + case RendererTypeRepoMarkdown: 77 + switch n.(type) { 78 + case *ast.Link: 79 + a.rctx.relativeLinkTransformer(n.(*ast.Link)) 80 + case *ast.Image: 81 + a.rctx.imageFromKnotTransformer(n.(*ast.Image)) 82 + a.rctx.camoImageLinkTransformer(n.(*ast.Image)) 83 + } 84 + 85 + case RendererTypeDefault: 86 + switch n.(type) { 87 + case *ast.Image: 88 + a.rctx.imageFromKnotTransformer(n.(*ast.Image)) 89 + a.rctx.camoImageLinkTransformer(n.(*ast.Image)) 90 + } 91 + } 92 + 93 + return ast.WalkContinue, nil 94 + }) 95 + } 96 + 97 + func (rctx *RenderContext) relativeLinkTransformer(link *ast.Link) { 98 + dst := string(link.Destination) 99 + 100 + if isAbsoluteUrl(dst) { 101 + return 102 + } 103 + 104 + newPath := path.Join("/", rctx.RepoInfo.FullName(), "tree", rctx.RepoInfo.Ref, dst) 105 + link.Destination = []byte(newPath) 106 + } 107 + 108 + func (rctx *RenderContext) imageFromKnotTransformer(img *ast.Image) { 109 + dst := string(img.Destination) 110 + 111 + if isAbsoluteUrl(dst) { 112 + return 113 + } 114 + 115 + // strip leading './' 116 + if len(dst) >= 2 && dst[0:2] == "./" { 117 + dst = dst[2:] 118 + } 119 + 120 + scheme := "https" 121 + if rctx.IsDev { 122 + scheme = "http" 123 + } 124 + parsedURL := &url.URL{ 125 + Scheme: scheme, 126 + Host: rctx.Knot, 127 + Path: path.Join("/", 128 + rctx.RepoInfo.OwnerDid, 129 + rctx.RepoInfo.Name, 130 + "raw", 131 + url.PathEscape(rctx.RepoInfo.Ref), 132 + dst), 133 + } 134 + newPath := parsedURL.String() 135 + img.Destination = []byte(newPath) 136 + } 137 + 138 + func isAbsoluteUrl(link string) bool { 139 + parsed, err := url.Parse(link) 140 + if err != nil { 141 + return false 142 + } 143 + return parsed.IsAbs() 144 + }
+472 -221
appview/pages/pages.go
··· 2 3 import ( 4 "bytes" 5 "embed" 6 "fmt" 7 "html/template" 8 "io" 9 "io/fs" 10 "log" 11 "net/http" 12 - "path" 13 "path/filepath" 14 - "slices" 15 "strings" 16 17 "github.com/alecthomas/chroma/v2" 18 chromahtml "github.com/alecthomas/chroma/v2/formatters/html" 19 "github.com/alecthomas/chroma/v2/lexers" 20 "github.com/alecthomas/chroma/v2/styles" 21 "github.com/bluesky-social/indigo/atproto/syntax" 22 "github.com/microcosm-cc/bluemonday" 23 - "tangled.sh/tangled.sh/core/appview/auth" 24 - "tangled.sh/tangled.sh/core/appview/db" 25 - "tangled.sh/tangled.sh/core/appview/state/userutil" 26 - "tangled.sh/tangled.sh/core/types" 27 ) 28 29 //go:embed templates/* static 30 var Files embed.FS 31 32 type Pages struct { 33 - t map[string]*template.Template 34 } 35 36 - func NewPages() *Pages { 37 templates := make(map[string]*template.Template) 38 39 - // Walk through embedded templates directory and parse all .html files 40 - err := fs.WalkDir(Files, "templates", func(path string, d fs.DirEntry, err error) error { 41 if err != nil { 42 return err 43 } 44 - 45 - if !d.IsDir() && strings.HasSuffix(path, ".html") { 46 - name := strings.TrimPrefix(path, "templates/") 47 - name = strings.TrimSuffix(name, ".html") 48 49 - // add fragments as templates 50 - if strings.HasPrefix(path, "templates/fragments/") { 51 - tmpl, err := template.New(name). 52 - Funcs(funcMap()). 53 - ParseFS(Files, path) 54 - if err != nil { 55 - return fmt.Errorf("setting up fragment: %w", err) 56 - } 57 58 - templates[name] = tmpl 59 - log.Printf("loaded fragment: %s", name) 60 - } 61 62 - // layouts and fragments are applied first 63 - if !strings.HasPrefix(path, "templates/layouts/") && 64 - !strings.HasPrefix(path, "templates/fragments/") { 65 - // Add the page template on top of the base 66 - tmpl, err := template.New(name). 67 - Funcs(funcMap()). 68 - ParseFS(Files, "templates/layouts/*.html", "templates/fragments/*.html", path) 69 - if err != nil { 70 - return fmt.Errorf("setting up template: %w", err) 71 - } 72 73 - templates[name] = tmpl 74 - log.Printf("loaded template: %s", name) 75 - } 76 77 return nil 78 } 79 return nil 80 }) 81 if err != nil { 82 - log.Fatalf("walking template dir: %v", err) 83 } 84 85 - log.Printf("total templates loaded: %d", len(templates)) 86 87 - return &Pages{ 88 - t: templates, 89 } 90 } 91 92 - type LoginParams struct { 93 } 94 95 func (p *Pages) execute(name string, w io.Writer, params any) error { 96 - return p.t[name].ExecuteTemplate(w, "layouts/base", params) 97 } 98 99 func (p *Pages) executePlain(name string, w io.Writer, params any) error { 100 - return p.t[name].Execute(w, params) 101 } 102 103 func (p *Pages) executeRepo(name string, w io.Writer, params any) error { 104 - return p.t[name].ExecuteTemplate(w, "layouts/repobase", params) 105 } 106 107 func (p *Pages) Login(w io.Writer, params LoginParams) error { ··· 109 } 110 111 type TimelineParams struct { 112 - LoggedInUser *auth.User 113 Timeline []db.TimelineEvent 114 DidHandleMap map[string]string 115 } ··· 119 } 120 121 type SettingsParams struct { 122 - LoggedInUser *auth.User 123 PubKeys []db.PublicKey 124 Emails []db.Email 125 } ··· 129 } 130 131 type KnotsParams struct { 132 - LoggedInUser *auth.User 133 Registrations []db.Registration 134 } 135 ··· 138 } 139 140 type KnotParams struct { 141 - LoggedInUser *auth.User 142 Registration *db.Registration 143 Members []string 144 IsOwner bool ··· 149 } 150 151 type NewRepoParams struct { 152 - LoggedInUser *auth.User 153 Knots []string 154 } 155 ··· 157 return p.execute("repo/new", w, params) 158 } 159 160 type ProfilePageParams struct { 161 - LoggedInUser *auth.User 162 - UserDid string 163 - UserHandle string 164 Repos []db.Repo 165 CollaboratingRepos []db.Repo 166 - ProfileStats ProfileStats 167 - FollowStatus db.FollowStatus 168 - DidHandleMap map[string]string 169 - AvatarUri string 170 - } 171 172 - type ProfileStats struct { 173 - Followers int 174 - Following int 175 - } 176 - 177 - func (p *Pages) ProfilePage(w io.Writer, params ProfilePageParams) error { 178 - return p.execute("user/profile", w, params) 179 } 180 181 - type FollowFragmentParams struct { 182 UserDid string 183 FollowStatus db.FollowStatus 184 - } 185 186 - func (p *Pages) FollowFragment(w io.Writer, params FollowFragmentParams) error { 187 - return p.executePlain("fragments/follow", w, params) 188 } 189 190 - type StarFragmentParams struct { 191 - IsStarred bool 192 - RepoAt syntax.ATURI 193 - Stats db.RepoStats 194 } 195 196 - func (p *Pages) StarFragment(w io.Writer, params StarFragmentParams) error { 197 - return p.executePlain("fragments/star", w, params) 198 - } 199 200 - type RepoDescriptionParams struct { 201 - RepoInfo RepoInfo 202 } 203 204 - func (p *Pages) EditRepoDescriptionFragment(w io.Writer, params RepoDescriptionParams) error { 205 - return p.executePlain("fragments/editRepoDescription", w, params) 206 } 207 208 - func (p *Pages) RepoDescriptionFragment(w io.Writer, params RepoDescriptionParams) error { 209 - return p.executePlain("fragments/repoDescription", w, params) 210 } 211 212 - type RepoInfo struct { 213 - Name string 214 - OwnerDid string 215 - OwnerHandle string 216 - Description string 217 - Knot string 218 - RepoAt syntax.ATURI 219 - IsStarred bool 220 - Stats db.RepoStats 221 - Roles RolesInRepo 222 } 223 224 - type RolesInRepo struct { 225 - Roles []string 226 } 227 228 - func (r RolesInRepo) SettingsAllowed() bool { 229 - return slices.Contains(r.Roles, "repo:settings") 230 } 231 232 - func (r RolesInRepo) IsOwner() bool { 233 - return slices.Contains(r.Roles, "repo:owner") 234 } 235 236 - func (r RolesInRepo) IsCollaborator() bool { 237 - return slices.Contains(r.Roles, "repo:collaborator") 238 } 239 240 - func (r RolesInRepo) IsPushAllowed() bool { 241 - return slices.Contains(r.Roles, "repo:push") 242 } 243 244 - func (r RepoInfo) OwnerWithAt() string { 245 - if r.OwnerHandle != "" { 246 - return fmt.Sprintf("@%s", r.OwnerHandle) 247 - } else { 248 - return r.OwnerDid 249 - } 250 } 251 252 - func (r RepoInfo) FullName() string { 253 - return path.Join(r.OwnerWithAt(), r.Name) 254 } 255 256 - func (r RepoInfo) OwnerWithoutAt() string { 257 - if strings.HasPrefix(r.OwnerWithAt(), "@") { 258 - return strings.TrimPrefix(r.OwnerWithAt(), "@") 259 - } else { 260 - return userutil.FlattenDid(r.OwnerDid) 261 - } 262 } 263 264 - func (r RepoInfo) FullNameWithoutAt() string { 265 - return path.Join(r.OwnerWithoutAt(), r.Name) 266 } 267 268 - func (r RepoInfo) GetTabs() [][]string { 269 - tabs := [][]string{ 270 - {"overview", "/"}, 271 - {"issues", "/issues"}, 272 - {"pulls", "/pulls"}, 273 - } 274 - 275 - if r.Roles.SettingsAllowed() { 276 - tabs = append(tabs, []string{"settings", "/settings"}) 277 - } 278 - 279 - return tabs 280 - } 281 - 282 - // each tab on a repo could have some metadata: 283 - // 284 - // issues -> number of open issues etc. 285 - // settings -> a warning icon to setup branch protection? idk 286 - // 287 - // we gather these bits of info here, because go templates 288 - // are difficult to program in 289 - func (r RepoInfo) TabMetadata() map[string]any { 290 - meta := make(map[string]any) 291 - 292 - if r.Stats.PullCount.Open > 0 { 293 - meta["pulls"] = r.Stats.PullCount.Open 294 - } 295 - 296 - if r.Stats.IssueCount.Open > 0 { 297 - meta["issues"] = r.Stats.IssueCount.Open 298 - } 299 - 300 - // more stuff? 301 - 302 - return meta 303 } 304 305 type RepoIndexParams struct { 306 - LoggedInUser *auth.User 307 - RepoInfo RepoInfo 308 - Active string 309 - TagMap map[string][]string 310 types.RepoIndexResponse 311 HTMLReadme template.HTML 312 Raw bool ··· 319 return p.executeRepo("repo/empty", w, params) 320 } 321 322 if params.ReadmeFileName != "" { 323 var htmlString string 324 ext := filepath.Ext(params.ReadmeFileName) 325 switch ext { 326 case ".md", ".markdown", ".mdown", ".mkdn", ".mkd": 327 - htmlString = renderMarkdown(params.Readme) 328 params.Raw = false 329 params.HTMLReadme = template.HTML(bluemonday.UGCPolicy().Sanitize(htmlString)) 330 default: ··· 338 } 339 340 type RepoLogParams struct { 341 - LoggedInUser *auth.User 342 - RepoInfo RepoInfo 343 types.RepoLogResponse 344 Active string 345 EmailToDidOrHandle map[string]string ··· 347 348 func (p *Pages) RepoLog(w io.Writer, params RepoLogParams) error { 349 params.Active = "overview" 350 - return p.execute("repo/log", w, params) 351 } 352 353 type RepoCommitParams struct { 354 - LoggedInUser *auth.User 355 - RepoInfo RepoInfo 356 - Active string 357 types.RepoCommitResponse 358 - EmailToDidOrHandle map[string]string 359 } 360 361 func (p *Pages) RepoCommit(w io.Writer, params RepoCommitParams) error { ··· 364 } 365 366 type RepoTreeParams struct { 367 - LoggedInUser *auth.User 368 - RepoInfo RepoInfo 369 Active string 370 BreadCrumbs [][]string 371 BaseTreeLink string ··· 400 } 401 402 type RepoBranchesParams struct { 403 - LoggedInUser *auth.User 404 - RepoInfo RepoInfo 405 types.RepoBranchesResponse 406 } 407 408 func (p *Pages) RepoBranches(w io.Writer, params RepoBranchesParams) error { 409 return p.executeRepo("repo/branches", w, params) 410 } 411 412 type RepoTagsParams struct { 413 - LoggedInUser *auth.User 414 - RepoInfo RepoInfo 415 types.RepoTagsResponse 416 } 417 418 func (p *Pages) RepoTags(w io.Writer, params RepoTagsParams) error { 419 return p.executeRepo("repo/tags", w, params) 420 } 421 422 type RepoBlobParams struct { 423 - LoggedInUser *auth.User 424 - RepoInfo RepoInfo 425 - Active string 426 - BreadCrumbs [][]string 427 types.RepoBlobResponse 428 } 429 430 func (p *Pages) RepoBlob(w io.Writer, params RepoBlobParams) error { 431 - style := styles.Get("bw") 432 - b := style.Builder() 433 - b.Add(chroma.LiteralString, "noitalic") 434 - style, _ = b.Build() 435 436 if params.Lines < 5000 { 437 c := params.Contents 438 formatter := chromahtml.New( 439 - chromahtml.InlineCode(true), 440 chromahtml.WithLineNumbers(true), 441 chromahtml.WithLinkableLineNumbers(true, "L"), 442 chromahtml.Standalone(false), 443 ) 444 445 lexer := lexers.Get(filepath.Base(params.Path)) ··· 472 } 473 474 type RepoSettingsParams struct { 475 - LoggedInUser *auth.User 476 - RepoInfo RepoInfo 477 Collaborators []Collaborator 478 Active string 479 // TODO: use repoinfo.roles 480 IsCollaboratorInviteAllowed bool 481 } ··· 486 } 487 488 type RepoIssuesParams struct { 489 - LoggedInUser *auth.User 490 - RepoInfo RepoInfo 491 - Active string 492 - Issues []db.Issue 493 - DidHandleMap map[string]string 494 - 495 FilteringByOpen bool 496 } 497 ··· 501 } 502 503 type RepoSingleIssueParams struct { 504 - LoggedInUser *auth.User 505 - RepoInfo RepoInfo 506 Active string 507 Issue db.Issue 508 Comments []db.Comment ··· 523 } 524 525 type RepoNewIssueParams struct { 526 - LoggedInUser *auth.User 527 - RepoInfo RepoInfo 528 Active string 529 } 530 ··· 533 return p.executeRepo("repo/issues/new", w, params) 534 } 535 536 type RepoNewPullParams struct { 537 - LoggedInUser *auth.User 538 - RepoInfo RepoInfo 539 Branches []types.Branch 540 Active string 541 } ··· 546 } 547 548 type RepoPullsParams struct { 549 - LoggedInUser *auth.User 550 - RepoInfo RepoInfo 551 - Pulls []db.Pull 552 Active string 553 DidHandleMap map[string]string 554 FilteringBy db.PullState ··· 559 return p.executeRepo("repo/pulls/pulls", w, params) 560 } 561 562 - type RepoSinglePullParams struct { 563 - LoggedInUser *auth.User 564 - RepoInfo RepoInfo 565 - Active string 566 - DidHandleMap map[string]string 567 568 - Pull db.Pull 569 - MergeCheck types.MergeCheckResponse 570 } 571 572 func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error { ··· 575 } 576 577 type RepoPullPatchParams struct { 578 - LoggedInUser *auth.User 579 DidHandleMap map[string]string 580 - RepoInfo RepoInfo 581 Pull *db.Pull 582 - Diff types.NiceDiff 583 Round int 584 Submission *db.PullSubmission 585 } ··· 589 return p.execute("repo/pulls/patch", w, params) 590 } 591 592 type PullResubmitParams struct { 593 - LoggedInUser *auth.User 594 - RepoInfo RepoInfo 595 Pull *db.Pull 596 SubmissionId int 597 } 598 599 func (p *Pages) PullResubmitFragment(w io.Writer, params PullResubmitParams) error { 600 - return p.executePlain("fragments/pullResubmit", w, params) 601 } 602 603 type PullActionsParams struct { 604 - LoggedInUser *auth.User 605 - RepoInfo RepoInfo 606 - Pull *db.Pull 607 - RoundNumber int 608 - MergeCheck types.MergeCheckResponse 609 } 610 611 func (p *Pages) PullActionsFragment(w io.Writer, params PullActionsParams) error { 612 - return p.executePlain("fragments/pullActions", w, params) 613 } 614 615 type PullNewCommentParams struct { 616 - LoggedInUser *auth.User 617 - RepoInfo RepoInfo 618 Pull *db.Pull 619 RoundNumber int 620 } 621 622 func (p *Pages) PullNewCommentFragment(w io.Writer, params PullNewCommentParams) error { 623 - return p.executePlain("fragments/pullNewComment", w, params) 624 } 625 626 func (p *Pages) Static() http.Handler { 627 sub, err := fs.Sub(Files, "static") 628 if err != nil { 629 log.Fatalf("no static dir found? that's crazy: %v", err) ··· 634 635 func Cache(h http.Handler) http.Handler { 636 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 637 - if strings.HasSuffix(r.URL.Path, ".css") { 638 // on day for css files 639 w.Header().Set("Cache-Control", "public, max-age=86400") 640 } else { ··· 642 } 643 h.ServeHTTP(w, r) 644 }) 645 } 646 647 func (p *Pages) Error500(w io.Writer) error {
··· 2 3 import ( 4 "bytes" 5 + "crypto/sha256" 6 "embed" 7 + "encoding/hex" 8 "fmt" 9 "html/template" 10 "io" 11 "io/fs" 12 "log" 13 "net/http" 14 + "os" 15 "path/filepath" 16 "strings" 17 18 + "tangled.sh/tangled.sh/core/appview" 19 + "tangled.sh/tangled.sh/core/appview/db" 20 + "tangled.sh/tangled.sh/core/appview/oauth" 21 + "tangled.sh/tangled.sh/core/appview/pages/markup" 22 + "tangled.sh/tangled.sh/core/appview/pages/repoinfo" 23 + "tangled.sh/tangled.sh/core/appview/pagination" 24 + "tangled.sh/tangled.sh/core/patchutil" 25 + "tangled.sh/tangled.sh/core/types" 26 + 27 "github.com/alecthomas/chroma/v2" 28 chromahtml "github.com/alecthomas/chroma/v2/formatters/html" 29 "github.com/alecthomas/chroma/v2/lexers" 30 "github.com/alecthomas/chroma/v2/styles" 31 "github.com/bluesky-social/indigo/atproto/syntax" 32 + "github.com/go-git/go-git/v5/plumbing" 33 + "github.com/go-git/go-git/v5/plumbing/object" 34 "github.com/microcosm-cc/bluemonday" 35 ) 36 37 //go:embed templates/* static 38 var Files embed.FS 39 40 type Pages struct { 41 + t map[string]*template.Template 42 + dev bool 43 + embedFS embed.FS 44 + templateDir string // Path to templates on disk for dev mode 45 + rctx *markup.RenderContext 46 + } 47 + 48 + func NewPages(config *appview.Config) *Pages { 49 + // initialized with safe defaults, can be overriden per use 50 + rctx := &markup.RenderContext{ 51 + IsDev: config.Core.Dev, 52 + CamoUrl: config.Camo.Host, 53 + CamoSecret: config.Camo.SharedSecret, 54 + } 55 + 56 + p := &Pages{ 57 + t: make(map[string]*template.Template), 58 + dev: config.Core.Dev, 59 + embedFS: Files, 60 + rctx: rctx, 61 + templateDir: "appview/pages", 62 + } 63 + 64 + // Initial load of all templates 65 + p.loadAllTemplates() 66 + 67 + return p 68 } 69 70 + func (p *Pages) loadAllTemplates() { 71 templates := make(map[string]*template.Template) 72 + var fragmentPaths []string 73 74 + // Use embedded FS for initial loading 75 + // First, collect all fragment paths 76 + err := fs.WalkDir(p.embedFS, "templates", func(path string, d fs.DirEntry, err error) error { 77 if err != nil { 78 return err 79 } 80 + if d.IsDir() { 81 + return nil 82 + } 83 + if !strings.HasSuffix(path, ".html") { 84 + return nil 85 + } 86 + if !strings.Contains(path, "fragments/") { 87 + return nil 88 + } 89 + name := strings.TrimPrefix(path, "templates/") 90 + name = strings.TrimSuffix(name, ".html") 91 + tmpl, err := template.New(name). 92 + Funcs(funcMap()). 93 + ParseFS(p.embedFS, path) 94 + if err != nil { 95 + log.Fatalf("setting up fragment: %v", err) 96 + } 97 + templates[name] = tmpl 98 + fragmentPaths = append(fragmentPaths, path) 99 + log.Printf("loaded fragment: %s", name) 100 + return nil 101 + }) 102 + if err != nil { 103 + log.Fatalf("walking template dir for fragments: %v", err) 104 + } 105 106 + // Then walk through and setup the rest of the templates 107 + err = fs.WalkDir(p.embedFS, "templates", func(path string, d fs.DirEntry, err error) error { 108 + if err != nil { 109 + return err 110 + } 111 + if d.IsDir() { 112 + return nil 113 + } 114 + if !strings.HasSuffix(path, "html") { 115 + return nil 116 + } 117 + // Skip fragments as they've already been loaded 118 + if strings.Contains(path, "fragments/") { 119 + return nil 120 + } 121 + // Skip layouts 122 + if strings.Contains(path, "layouts/") { 123 + return nil 124 + } 125 + name := strings.TrimPrefix(path, "templates/") 126 + name = strings.TrimSuffix(name, ".html") 127 + // Add the page template on top of the base 128 + allPaths := []string{} 129 + allPaths = append(allPaths, "templates/layouts/*.html") 130 + allPaths = append(allPaths, fragmentPaths...) 131 + allPaths = append(allPaths, path) 132 + tmpl, err := template.New(name). 133 + Funcs(funcMap()). 134 + ParseFS(p.embedFS, allPaths...) 135 + if err != nil { 136 + return fmt.Errorf("setting up template: %w", err) 137 + } 138 + templates[name] = tmpl 139 + log.Printf("loaded template: %s", name) 140 + return nil 141 + }) 142 + if err != nil { 143 + log.Fatalf("walking template dir: %v", err) 144 + } 145 146 + log.Printf("total templates loaded: %d", len(templates)) 147 + p.t = templates 148 + } 149 150 + // loadTemplateFromDisk loads a template from the filesystem in dev mode 151 + func (p *Pages) loadTemplateFromDisk(name string) error { 152 + if !p.dev { 153 + return nil 154 + } 155 156 + log.Printf("reloading template from disk: %s", name) 157 158 + // Find all fragments first 159 + var fragmentPaths []string 160 + err := filepath.WalkDir(filepath.Join(p.templateDir, "templates"), func(path string, d fs.DirEntry, err error) error { 161 + if err != nil { 162 + return err 163 + } 164 + if d.IsDir() { 165 return nil 166 } 167 + if !strings.HasSuffix(path, ".html") { 168 + return nil 169 + } 170 + if !strings.Contains(path, "fragments/") { 171 + return nil 172 + } 173 + fragmentPaths = append(fragmentPaths, path) 174 return nil 175 }) 176 if err != nil { 177 + return fmt.Errorf("walking disk template dir for fragments: %w", err) 178 + } 179 + 180 + // Find the template path on disk 181 + templatePath := filepath.Join(p.templateDir, "templates", name+".html") 182 + if _, err := os.Stat(templatePath); os.IsNotExist(err) { 183 + return fmt.Errorf("template not found on disk: %s", name) 184 } 185 186 + // Create a new template 187 + tmpl := template.New(name).Funcs(funcMap()) 188 189 + // Parse layouts 190 + layoutGlob := filepath.Join(p.templateDir, "templates", "layouts", "*.html") 191 + layouts, err := filepath.Glob(layoutGlob) 192 + if err != nil { 193 + return fmt.Errorf("finding layout templates: %w", err) 194 } 195 + 196 + // Create paths for parsing 197 + allFiles := append(layouts, fragmentPaths...) 198 + allFiles = append(allFiles, templatePath) 199 + 200 + // Parse all templates 201 + tmpl, err = tmpl.ParseFiles(allFiles...) 202 + if err != nil { 203 + return fmt.Errorf("parsing template files: %w", err) 204 + } 205 + 206 + // Update the template in the map 207 + p.t[name] = tmpl 208 + log.Printf("template reloaded from disk: %s", name) 209 + return nil 210 } 211 212 + func (p *Pages) executeOrReload(templateName string, w io.Writer, base string, params any) error { 213 + // In dev mode, reload the template from disk before executing 214 + if p.dev { 215 + if err := p.loadTemplateFromDisk(templateName); err != nil { 216 + log.Printf("warning: failed to reload template %s from disk: %v", templateName, err) 217 + // Continue with the existing template 218 + } 219 + } 220 + 221 + tmpl, exists := p.t[templateName] 222 + if !exists { 223 + return fmt.Errorf("template not found: %s", templateName) 224 + } 225 + 226 + if base == "" { 227 + return tmpl.Execute(w, params) 228 + } else { 229 + return tmpl.ExecuteTemplate(w, base, params) 230 + } 231 } 232 233 func (p *Pages) execute(name string, w io.Writer, params any) error { 234 + return p.executeOrReload(name, w, "layouts/base", params) 235 } 236 237 func (p *Pages) executePlain(name string, w io.Writer, params any) error { 238 + return p.executeOrReload(name, w, "", params) 239 } 240 241 func (p *Pages) executeRepo(name string, w io.Writer, params any) error { 242 + return p.executeOrReload(name, w, "layouts/repobase", params) 243 + } 244 + 245 + type LoginParams struct { 246 } 247 248 func (p *Pages) Login(w io.Writer, params LoginParams) error { ··· 250 } 251 252 type TimelineParams struct { 253 + LoggedInUser *oauth.User 254 Timeline []db.TimelineEvent 255 DidHandleMap map[string]string 256 } ··· 260 } 261 262 type SettingsParams struct { 263 + LoggedInUser *oauth.User 264 PubKeys []db.PublicKey 265 Emails []db.Email 266 } ··· 270 } 271 272 type KnotsParams struct { 273 + LoggedInUser *oauth.User 274 Registrations []db.Registration 275 } 276 ··· 279 } 280 281 type KnotParams struct { 282 + LoggedInUser *oauth.User 283 + DidHandleMap map[string]string 284 Registration *db.Registration 285 Members []string 286 IsOwner bool ··· 291 } 292 293 type NewRepoParams struct { 294 + LoggedInUser *oauth.User 295 Knots []string 296 } 297 ··· 299 return p.execute("repo/new", w, params) 300 } 301 302 + type ForkRepoParams struct { 303 + LoggedInUser *oauth.User 304 + Knots []string 305 + RepoInfo repoinfo.RepoInfo 306 + } 307 + 308 + func (p *Pages) ForkRepo(w io.Writer, params ForkRepoParams) error { 309 + return p.execute("repo/fork", w, params) 310 + } 311 + 312 type ProfilePageParams struct { 313 + LoggedInUser *oauth.User 314 Repos []db.Repo 315 CollaboratingRepos []db.Repo 316 + ProfileTimeline *db.ProfileTimeline 317 + Card ProfileCard 318 319 + DidHandleMap map[string]string 320 } 321 322 + type ProfileCard struct { 323 UserDid string 324 + UserHandle string 325 FollowStatus db.FollowStatus 326 + AvatarUri string 327 + Followers int 328 + Following int 329 330 + Profile *db.Profile 331 } 332 333 + func (p *Pages) ProfilePage(w io.Writer, params ProfilePageParams) error { 334 + return p.execute("user/profile", w, params) 335 } 336 337 + type ReposPageParams struct { 338 + LoggedInUser *oauth.User 339 + Repos []db.Repo 340 + Card ProfileCard 341 342 + DidHandleMap map[string]string 343 } 344 345 + func (p *Pages) ReposPage(w io.Writer, params ReposPageParams) error { 346 + return p.execute("user/repos", w, params) 347 } 348 349 + type FollowFragmentParams struct { 350 + UserDid string 351 + FollowStatus db.FollowStatus 352 } 353 354 + func (p *Pages) FollowFragment(w io.Writer, params FollowFragmentParams) error { 355 + return p.executePlain("user/fragments/follow", w, params) 356 } 357 358 + type EditBioParams struct { 359 + LoggedInUser *oauth.User 360 + Profile *db.Profile 361 } 362 363 + func (p *Pages) EditBioFragment(w io.Writer, params EditBioParams) error { 364 + return p.executePlain("user/fragments/editBio", w, params) 365 } 366 367 + type EditPinsParams struct { 368 + LoggedInUser *oauth.User 369 + Profile *db.Profile 370 + AllRepos []PinnedRepo 371 + DidHandleMap map[string]string 372 } 373 374 + type PinnedRepo struct { 375 + IsPinned bool 376 + db.Repo 377 } 378 379 + func (p *Pages) EditPinsFragment(w io.Writer, params EditPinsParams) error { 380 + return p.executePlain("user/fragments/editPins", w, params) 381 } 382 383 + type RepoActionsFragmentParams struct { 384 + IsStarred bool 385 + RepoAt syntax.ATURI 386 + Stats db.RepoStats 387 } 388 389 + func (p *Pages) RepoActionsFragment(w io.Writer, params RepoActionsFragmentParams) error { 390 + return p.executePlain("repo/fragments/repoActions", w, params) 391 } 392 393 + type RepoDescriptionParams struct { 394 + RepoInfo repoinfo.RepoInfo 395 } 396 397 + func (p *Pages) EditRepoDescriptionFragment(w io.Writer, params RepoDescriptionParams) error { 398 + return p.executePlain("repo/fragments/editRepoDescription", w, params) 399 } 400 401 + func (p *Pages) RepoDescriptionFragment(w io.Writer, params RepoDescriptionParams) error { 402 + return p.executePlain("repo/fragments/repoDescription", w, params) 403 } 404 405 type RepoIndexParams struct { 406 + LoggedInUser *oauth.User 407 + RepoInfo repoinfo.RepoInfo 408 + Active string 409 + TagMap map[string][]string 410 + CommitsTrunc []*object.Commit 411 + TagsTrunc []*types.TagReference 412 + BranchesTrunc []types.Branch 413 types.RepoIndexResponse 414 HTMLReadme template.HTML 415 Raw bool ··· 422 return p.executeRepo("repo/empty", w, params) 423 } 424 425 + p.rctx.RepoInfo = params.RepoInfo 426 + p.rctx.RendererType = markup.RendererTypeRepoMarkdown 427 + 428 if params.ReadmeFileName != "" { 429 var htmlString string 430 ext := filepath.Ext(params.ReadmeFileName) 431 switch ext { 432 case ".md", ".markdown", ".mdown", ".mkdn", ".mkd": 433 + htmlString = p.rctx.RenderMarkdown(params.Readme) 434 params.Raw = false 435 params.HTMLReadme = template.HTML(bluemonday.UGCPolicy().Sanitize(htmlString)) 436 default: ··· 444 } 445 446 type RepoLogParams struct { 447 + LoggedInUser *oauth.User 448 + RepoInfo repoinfo.RepoInfo 449 + TagMap map[string][]string 450 types.RepoLogResponse 451 Active string 452 EmailToDidOrHandle map[string]string ··· 454 455 func (p *Pages) RepoLog(w io.Writer, params RepoLogParams) error { 456 params.Active = "overview" 457 + return p.executeRepo("repo/log", w, params) 458 } 459 460 type RepoCommitParams struct { 461 + LoggedInUser *oauth.User 462 + RepoInfo repoinfo.RepoInfo 463 + Active string 464 + EmailToDidOrHandle map[string]string 465 + 466 types.RepoCommitResponse 467 } 468 469 func (p *Pages) RepoCommit(w io.Writer, params RepoCommitParams) error { ··· 472 } 473 474 type RepoTreeParams struct { 475 + LoggedInUser *oauth.User 476 + RepoInfo repoinfo.RepoInfo 477 Active string 478 BreadCrumbs [][]string 479 BaseTreeLink string ··· 508 } 509 510 type RepoBranchesParams struct { 511 + LoggedInUser *oauth.User 512 + RepoInfo repoinfo.RepoInfo 513 + Active string 514 types.RepoBranchesResponse 515 } 516 517 func (p *Pages) RepoBranches(w io.Writer, params RepoBranchesParams) error { 518 + params.Active = "overview" 519 return p.executeRepo("repo/branches", w, params) 520 } 521 522 type RepoTagsParams struct { 523 + LoggedInUser *oauth.User 524 + RepoInfo repoinfo.RepoInfo 525 + Active string 526 types.RepoTagsResponse 527 + ArtifactMap map[plumbing.Hash][]db.Artifact 528 + DanglingArtifacts []db.Artifact 529 } 530 531 func (p *Pages) RepoTags(w io.Writer, params RepoTagsParams) error { 532 + params.Active = "overview" 533 return p.executeRepo("repo/tags", w, params) 534 } 535 536 + type RepoArtifactParams struct { 537 + LoggedInUser *oauth.User 538 + RepoInfo repoinfo.RepoInfo 539 + Artifact db.Artifact 540 + } 541 + 542 + func (p *Pages) RepoArtifactFragment(w io.Writer, params RepoArtifactParams) error { 543 + return p.executePlain("repo/fragments/artifact", w, params) 544 + } 545 + 546 type RepoBlobParams struct { 547 + LoggedInUser *oauth.User 548 + RepoInfo repoinfo.RepoInfo 549 + Active string 550 + BreadCrumbs [][]string 551 + ShowRendered bool 552 + RenderToggle bool 553 + RenderedContents template.HTML 554 types.RepoBlobResponse 555 } 556 557 func (p *Pages) RepoBlob(w io.Writer, params RepoBlobParams) error { 558 + var style *chroma.Style = styles.Get("catpuccin-latte") 559 + 560 + if params.ShowRendered { 561 + switch markup.GetFormat(params.Path) { 562 + case markup.FormatMarkdown: 563 + p.rctx.RepoInfo = params.RepoInfo 564 + p.rctx.RendererType = markup.RendererTypeRepoMarkdown 565 + params.RenderedContents = template.HTML(bluemonday.UGCPolicy().Sanitize(p.rctx.RenderMarkdown(params.Contents))) 566 + } 567 + } 568 569 if params.Lines < 5000 { 570 c := params.Contents 571 formatter := chromahtml.New( 572 + chromahtml.InlineCode(false), 573 chromahtml.WithLineNumbers(true), 574 chromahtml.WithLinkableLineNumbers(true, "L"), 575 chromahtml.Standalone(false), 576 + chromahtml.WithClasses(true), 577 ) 578 579 lexer := lexers.Get(filepath.Base(params.Path)) ··· 606 } 607 608 type RepoSettingsParams struct { 609 + LoggedInUser *oauth.User 610 + RepoInfo repoinfo.RepoInfo 611 Collaborators []Collaborator 612 Active string 613 + Branches []string 614 + DefaultBranch string 615 // TODO: use repoinfo.roles 616 IsCollaboratorInviteAllowed bool 617 } ··· 622 } 623 624 type RepoIssuesParams struct { 625 + LoggedInUser *oauth.User 626 + RepoInfo repoinfo.RepoInfo 627 + Active string 628 + Issues []db.Issue 629 + DidHandleMap map[string]string 630 + Page pagination.Page 631 FilteringByOpen bool 632 } 633 ··· 637 } 638 639 type RepoSingleIssueParams struct { 640 + LoggedInUser *oauth.User 641 + RepoInfo repoinfo.RepoInfo 642 Active string 643 Issue db.Issue 644 Comments []db.Comment ··· 659 } 660 661 type RepoNewIssueParams struct { 662 + LoggedInUser *oauth.User 663 + RepoInfo repoinfo.RepoInfo 664 Active string 665 } 666 ··· 669 return p.executeRepo("repo/issues/new", w, params) 670 } 671 672 + type EditIssueCommentParams struct { 673 + LoggedInUser *oauth.User 674 + RepoInfo repoinfo.RepoInfo 675 + Issue *db.Issue 676 + Comment *db.Comment 677 + } 678 + 679 + func (p *Pages) EditIssueCommentFragment(w io.Writer, params EditIssueCommentParams) error { 680 + return p.executePlain("repo/issues/fragments/editIssueComment", w, params) 681 + } 682 + 683 + type SingleIssueCommentParams struct { 684 + LoggedInUser *oauth.User 685 + DidHandleMap map[string]string 686 + RepoInfo repoinfo.RepoInfo 687 + Issue *db.Issue 688 + Comment *db.Comment 689 + } 690 + 691 + func (p *Pages) SingleIssueCommentFragment(w io.Writer, params SingleIssueCommentParams) error { 692 + return p.executePlain("repo/issues/fragments/issueComment", w, params) 693 + } 694 + 695 type RepoNewPullParams struct { 696 + LoggedInUser *oauth.User 697 + RepoInfo repoinfo.RepoInfo 698 Branches []types.Branch 699 Active string 700 } ··· 705 } 706 707 type RepoPullsParams struct { 708 + LoggedInUser *oauth.User 709 + RepoInfo repoinfo.RepoInfo 710 + Pulls []*db.Pull 711 Active string 712 DidHandleMap map[string]string 713 FilteringBy db.PullState ··· 718 return p.executeRepo("repo/pulls/pulls", w, params) 719 } 720 721 + type ResubmitResult uint64 722 + 723 + const ( 724 + ShouldResubmit ResubmitResult = iota 725 + ShouldNotResubmit 726 + Unknown 727 + ) 728 + 729 + func (r ResubmitResult) Yes() bool { 730 + return r == ShouldResubmit 731 + } 732 + func (r ResubmitResult) No() bool { 733 + return r == ShouldNotResubmit 734 + } 735 + func (r ResubmitResult) Unknown() bool { 736 + return r == Unknown 737 + } 738 739 + type RepoSinglePullParams struct { 740 + LoggedInUser *oauth.User 741 + RepoInfo repoinfo.RepoInfo 742 + Active string 743 + DidHandleMap map[string]string 744 + Pull *db.Pull 745 + MergeCheck types.MergeCheckResponse 746 + ResubmitCheck ResubmitResult 747 } 748 749 func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error { ··· 752 } 753 754 type RepoPullPatchParams struct { 755 + LoggedInUser *oauth.User 756 DidHandleMap map[string]string 757 + RepoInfo repoinfo.RepoInfo 758 Pull *db.Pull 759 + Diff *types.NiceDiff 760 Round int 761 Submission *db.PullSubmission 762 } ··· 766 return p.execute("repo/pulls/patch", w, params) 767 } 768 769 + type RepoPullInterdiffParams struct { 770 + LoggedInUser *oauth.User 771 + DidHandleMap map[string]string 772 + RepoInfo repoinfo.RepoInfo 773 + Pull *db.Pull 774 + Round int 775 + Interdiff *patchutil.InterdiffResult 776 + } 777 + 778 + // this name is a mouthful 779 + func (p *Pages) RepoPullInterdiffPage(w io.Writer, params RepoPullInterdiffParams) error { 780 + return p.execute("repo/pulls/interdiff", w, params) 781 + } 782 + 783 + type PullPatchUploadParams struct { 784 + RepoInfo repoinfo.RepoInfo 785 + } 786 + 787 + func (p *Pages) PullPatchUploadFragment(w io.Writer, params PullPatchUploadParams) error { 788 + return p.executePlain("repo/pulls/fragments/pullPatchUpload", w, params) 789 + } 790 + 791 + type PullCompareBranchesParams struct { 792 + RepoInfo repoinfo.RepoInfo 793 + Branches []types.Branch 794 + } 795 + 796 + func (p *Pages) PullCompareBranchesFragment(w io.Writer, params PullCompareBranchesParams) error { 797 + return p.executePlain("repo/pulls/fragments/pullCompareBranches", w, params) 798 + } 799 + 800 + type PullCompareForkParams struct { 801 + RepoInfo repoinfo.RepoInfo 802 + Forks []db.Repo 803 + } 804 + 805 + func (p *Pages) PullCompareForkFragment(w io.Writer, params PullCompareForkParams) error { 806 + return p.executePlain("repo/pulls/fragments/pullCompareForks", w, params) 807 + } 808 + 809 + type PullCompareForkBranchesParams struct { 810 + RepoInfo repoinfo.RepoInfo 811 + SourceBranches []types.Branch 812 + TargetBranches []types.Branch 813 + } 814 + 815 + func (p *Pages) PullCompareForkBranchesFragment(w io.Writer, params PullCompareForkBranchesParams) error { 816 + return p.executePlain("repo/pulls/fragments/pullCompareForksBranches", w, params) 817 + } 818 + 819 type PullResubmitParams struct { 820 + LoggedInUser *oauth.User 821 + RepoInfo repoinfo.RepoInfo 822 Pull *db.Pull 823 SubmissionId int 824 } 825 826 func (p *Pages) PullResubmitFragment(w io.Writer, params PullResubmitParams) error { 827 + return p.executePlain("repo/pulls/fragments/pullResubmit", w, params) 828 } 829 830 type PullActionsParams struct { 831 + LoggedInUser *oauth.User 832 + RepoInfo repoinfo.RepoInfo 833 + Pull *db.Pull 834 + RoundNumber int 835 + MergeCheck types.MergeCheckResponse 836 + ResubmitCheck ResubmitResult 837 } 838 839 func (p *Pages) PullActionsFragment(w io.Writer, params PullActionsParams) error { 840 + return p.executePlain("repo/pulls/fragments/pullActions", w, params) 841 } 842 843 type PullNewCommentParams struct { 844 + LoggedInUser *oauth.User 845 + RepoInfo repoinfo.RepoInfo 846 Pull *db.Pull 847 RoundNumber int 848 } 849 850 func (p *Pages) PullNewCommentFragment(w io.Writer, params PullNewCommentParams) error { 851 + return p.executePlain("repo/pulls/fragments/pullNewComment", w, params) 852 } 853 854 func (p *Pages) Static() http.Handler { 855 + if p.dev { 856 + return http.StripPrefix("/static/", http.FileServer(http.Dir("appview/pages/static"))) 857 + } 858 + 859 sub, err := fs.Sub(Files, "static") 860 if err != nil { 861 log.Fatalf("no static dir found? that's crazy: %v", err) ··· 866 867 func Cache(h http.Handler) http.Handler { 868 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 869 + path := strings.Split(r.URL.Path, "?")[0] 870 + 871 + if strings.HasSuffix(path, ".css") { 872 // on day for css files 873 w.Header().Set("Cache-Control", "public, max-age=86400") 874 } else { ··· 876 } 877 h.ServeHTTP(w, r) 878 }) 879 + } 880 + 881 + func CssContentHash() string { 882 + cssFile, err := Files.Open("static/tw.css") 883 + if err != nil { 884 + log.Printf("Error opening CSS file: %v", err) 885 + return "" 886 + } 887 + defer cssFile.Close() 888 + 889 + hasher := sha256.New() 890 + if _, err := io.Copy(hasher, cssFile); err != nil { 891 + log.Printf("Error hashing CSS file: %v", err) 892 + return "" 893 + } 894 + 895 + return hex.EncodeToString(hasher.Sum(nil))[:8] // Use first 8 chars of hash 896 } 897 898 func (p *Pages) Error500(w io.Writer) error {
+117
appview/pages/repoinfo/repoinfo.go
···
··· 1 + package repoinfo 2 + 3 + import ( 4 + "fmt" 5 + "path" 6 + "slices" 7 + "strings" 8 + 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + "tangled.sh/tangled.sh/core/appview/db" 11 + "tangled.sh/tangled.sh/core/appview/state/userutil" 12 + ) 13 + 14 + func (r RepoInfo) OwnerWithAt() string { 15 + if r.OwnerHandle != "" { 16 + return fmt.Sprintf("@%s", r.OwnerHandle) 17 + } else { 18 + return r.OwnerDid 19 + } 20 + } 21 + 22 + func (r RepoInfo) FullName() string { 23 + return path.Join(r.OwnerWithAt(), r.Name) 24 + } 25 + 26 + func (r RepoInfo) OwnerWithoutAt() string { 27 + if strings.HasPrefix(r.OwnerWithAt(), "@") { 28 + return strings.TrimPrefix(r.OwnerWithAt(), "@") 29 + } else { 30 + return userutil.FlattenDid(r.OwnerDid) 31 + } 32 + } 33 + 34 + func (r RepoInfo) FullNameWithoutAt() string { 35 + return path.Join(r.OwnerWithoutAt(), r.Name) 36 + } 37 + 38 + func (r RepoInfo) GetTabs() [][]string { 39 + tabs := [][]string{ 40 + {"overview", "/", "square-chart-gantt"}, 41 + {"issues", "/issues", "circle-dot"}, 42 + {"pulls", "/pulls", "git-pull-request"}, 43 + } 44 + 45 + if r.Roles.SettingsAllowed() { 46 + tabs = append(tabs, []string{"settings", "/settings", "cog"}) 47 + } 48 + 49 + return tabs 50 + } 51 + 52 + type RepoInfo struct { 53 + Name string 54 + OwnerDid string 55 + OwnerHandle string 56 + Description string 57 + Knot string 58 + RepoAt syntax.ATURI 59 + IsStarred bool 60 + Stats db.RepoStats 61 + Roles RolesInRepo 62 + Source *db.Repo 63 + SourceHandle string 64 + Ref string 65 + DisableFork bool 66 + } 67 + 68 + // each tab on a repo could have some metadata: 69 + // 70 + // issues -> number of open issues etc. 71 + // settings -> a warning icon to setup branch protection? idk 72 + // 73 + // we gather these bits of info here, because go templates 74 + // are difficult to program in 75 + func (r RepoInfo) TabMetadata() map[string]any { 76 + meta := make(map[string]any) 77 + 78 + if r.Stats.PullCount.Open > 0 { 79 + meta["pulls"] = r.Stats.PullCount.Open 80 + } 81 + 82 + if r.Stats.IssueCount.Open > 0 { 83 + meta["issues"] = r.Stats.IssueCount.Open 84 + } 85 + 86 + // more stuff? 87 + 88 + return meta 89 + } 90 + 91 + type RolesInRepo struct { 92 + Roles []string 93 + } 94 + 95 + func (r RolesInRepo) SettingsAllowed() bool { 96 + return slices.Contains(r.Roles, "repo:settings") 97 + } 98 + 99 + func (r RolesInRepo) CollaboratorInviteAllowed() bool { 100 + return slices.Contains(r.Roles, "repo:invite") 101 + } 102 + 103 + func (r RolesInRepo) RepoDeleteAllowed() bool { 104 + return slices.Contains(r.Roles, "repo:delete") 105 + } 106 + 107 + func (r RolesInRepo) IsOwner() bool { 108 + return slices.Contains(r.Roles, "repo:owner") 109 + } 110 + 111 + func (r RolesInRepo) IsCollaborator() bool { 112 + return slices.Contains(r.Roles, "repo:collaborator") 113 + } 114 + 115 + func (r RolesInRepo) IsPushAllowed() bool { 116 + return slices.Contains(r.Roles, "repo:push") 117 + }
-112
appview/pages/templates/fragments/diff.html
··· 1 - {{ define "fragments/diff" }} 2 - {{ $repo := index . 0 }} 3 - {{ $diff := index . 1 }} 4 - {{ $commit := $diff.Commit }} 5 - {{ $stat := $diff.Stat }} 6 - {{ $diff := $diff.Diff }} 7 - 8 - {{ $this := $commit.This }} 9 - {{ $parent := $commit.Parent }} 10 - 11 - {{ $last := sub (len $diff) 1 }} 12 - {{ range $idx, $hunk := $diff }} 13 - {{ with $hunk }} 14 - <section class="mt-6 border border-gray-200 w-full mx-auto rounded bg-white drop-shadow-sm"> 15 - <div id="file-{{ .Name.New }}"> 16 - <div id="diff-file"> 17 - <details open> 18 - <summary class="list-none cursor-pointer sticky top-0"> 19 - <div id="diff-file-header" class="rounded cursor-pointer bg-white flex justify-between"> 20 - <div id="left-side-items" class="p-2 flex gap-2 items-center"> 21 - {{ $markerstyle := "diff-type p-1 mr-1 font-mono text-sm rounded select-none" }} 22 - 23 - {{ if .IsNew }} 24 - <span class="bg-green-100 text-green-700 {{ $markerstyle }}">ADDED</span> 25 - {{ else if .IsDelete }} 26 - <span class="bg-red-100 text-red-700 {{ $markerstyle }}">DELETED</span> 27 - {{ else if .IsCopy }} 28 - <span class="bg-gray-100 text-gray-700 {{ $markerstyle }}">COPIED</span> 29 - {{ else if .IsRename }} 30 - <span class="bg-gray-100 text-gray-700 {{ $markerstyle }}">RENAMED</span> 31 - {{ else }} 32 - <span class="bg-gray-100 text-gray-700 {{ $markerstyle }}">MODIFIED</span> 33 - {{ end }} 34 - 35 - {{ if .IsDelete }} 36 - <a {{if $this }}href="/{{ $repo }}/blob/{{ $this }}/{{ .Name.Old }}"{{end}}> 37 - {{ .Name.Old }} 38 - </a> 39 - {{ else if (or .IsCopy .IsRename) }} 40 - <a {{if $parent}}href="/{{ $repo }}/blob/{{ $parent }}/{{ .Name.Old }}"{{end}}> 41 - {{ .Name.Old }} 42 - </a> 43 - {{ i "arrow-right" "w-4 h-4" }} 44 - <a {{if $this}}href="/{{ $repo }}/blob/{{ $this }}/{{ .Name.New }}"{{end}}> 45 - {{ .Name.New }} 46 - </a> 47 - {{ else }} 48 - <a {{if $this}}href="/{{ $repo }}/blob/{{ $this }}/{{ .Name.New }}"{{end}}> 49 - {{ .Name.New }} 50 - </a> 51 - {{ end }} 52 - </div> 53 - 54 - {{ $iconstyle := "p-1 mx-1 hover:bg-gray-100 rounded" }} 55 - <div id="right-side-items" class="p-2 flex items-center"> 56 - <a title="top of file" href="#file-{{ .Name.New }}" class="{{ $iconstyle }}">{{ i "arrow-up-to-line" "w-4 h-4" }}</a> 57 - {{ if gt $idx 0 }} 58 - {{ $prev := index $diff (sub $idx 1) }} 59 - <a title="previous file" href="#file-{{ $prev.Name.New }}" class="{{ $iconstyle }}">{{ i "arrow-up" "w-4 h-4" }}</a> 60 - {{ end }} 61 - 62 - {{ if lt $idx $last }} 63 - {{ $next := index $diff (add $idx 1) }} 64 - <a title="next file" href="#file-{{ $next.Name.New }}" class="{{ $iconstyle }}">{{ i "arrow-down" "w-4 h-4" }}</a> 65 - {{ end }} 66 - </div> 67 - 68 - </div> 69 - </summary> 70 - 71 - <div class="transition-all duration-700 ease-in-out"> 72 - {{ if .IsDelete }} 73 - <p class="text-center text-gray-400 p-4"> 74 - This file has been deleted in this commit. 75 - </p> 76 - {{ else }} 77 - {{ if .IsBinary }} 78 - <p class="text-center text-gray-400 p-4"> 79 - This is a binary file and will not be displayed. 80 - </p> 81 - {{ else }} 82 - <pre class="overflow-auto"> 83 - {{- range .TextFragments -}} 84 - <div class="bg-gray-100 text-gray-500 select-none">{{ .Header }}</div> 85 - {{- range .Lines -}} 86 - {{- if eq .Op.String "+" -}} 87 - <div class="bg-green-100 text-green-700 p-1 w-full min-w-fit"><span class="select-none mx-2">{{ .Op.String }}</span><span>{{ .Line }}</span></div> 88 - {{- end -}} 89 - 90 - {{- if eq .Op.String "-" -}} 91 - <div class="bg-red-100 text-red-700 p-1 w-full min-w-fit"><span class="select-none mx-2">{{ .Op.String }}</span><span>{{ .Line }}</span></div> 92 - {{- end -}} 93 - 94 - {{- if eq .Op.String " " -}} 95 - <div class="bg-white text-gray-500 px"><span class="select-none mx-2">{{ .Op.String }}</span><span>{{ .Line }}</span></div> 96 - {{- end -}} 97 - 98 - {{- end -}} 99 - {{- end -}} 100 - </pre> 101 - {{- end -}} 102 - {{ end }} 103 - </div> 104 - 105 - </details> 106 - 107 - </div> 108 - </div> 109 - </section> 110 - {{ end }} 111 - {{ end }} 112 - {{ end }}
···
-11
appview/pages/templates/fragments/editRepoDescription.html
··· 1 - {{ define "fragments/editRepoDescription" }} 2 - <form hx-put="/{{ .RepoInfo.FullName }}/description" hx-target="this" hx-swap="outerHTML" class="flex flex-wrap gap-2"> 3 - <input type="text" class="p-1" name="description" value="{{ .RepoInfo.Description }}"> 4 - <button type="submit" class="btn p-2 flex items-center gap-2 no-underline text-sm"> 5 - {{ i "check" "w-3 h-3" }} save 6 - </button> 7 - <button type="button" class="btn p-2 flex items-center gap-2 no-underline text-sm" hx-get="/{{ .RepoInfo.FullName }}/description" > 8 - {{ i "x" "w-3 h-3" }} cancel 9 - </button> 10 - </form> 11 - {{ end }}
···
-17
appview/pages/templates/fragments/follow.html
··· 1 - {{ define "fragments/follow" }} 2 - <button id="followBtn" 3 - class="btn mt-2 w-full" 4 - 5 - {{ if eq .FollowStatus.String "IsNotFollowing" }} 6 - hx-post="/follow?subject={{.UserDid}}" 7 - {{ else }} 8 - hx-delete="/follow?subject={{.UserDid}}" 9 - {{ end }} 10 - 11 - hx-trigger="click" 12 - hx-target="#followBtn" 13 - hx-swap="outerHTML" 14 - > 15 - {{ if eq .FollowStatus.String "IsNotFollowing" }}Follow{{ else }}Unfollow{{ end }} 16 - </button> 17 - {{ end }}
···
-72
appview/pages/templates/fragments/pullActions.html
··· 1 - {{ define "fragments/pullActions" }} 2 - {{ $lastIdx := sub (len .Pull.Submissions) 1 }} 3 - {{ $roundNumber := .RoundNumber }} 4 - 5 - {{ $isPushAllowed := .RepoInfo.Roles.IsPushAllowed }} 6 - {{ $isMerged := .Pull.State.IsMerged }} 7 - {{ $isClosed := .Pull.State.IsClosed }} 8 - {{ $isOpen := .Pull.State.IsOpen }} 9 - {{ $isConflicted := and .MergeCheck (or .MergeCheck.Error .MergeCheck.IsConflicted) }} 10 - {{ $isPullAuthor := and .LoggedInUser (eq .LoggedInUser.Did .Pull.OwnerDid) }} 11 - {{ $isLastRound := eq $roundNumber $lastIdx }} 12 - <div class="relative w-fit"> 13 - <div class="absolute left-8 -top-2 w-px h-2 bg-gray-300"></div> 14 - <div id="actions-{{$roundNumber}}" class="flex flex-wrap gap-2"> 15 - <button 16 - hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ $roundNumber }}/comment" 17 - hx-target="#actions-{{$roundNumber}}" 18 - hx-swap="outerHtml" 19 - class="btn p-2 flex items-center gap-2 no-underline hover:no-underline"> 20 - {{ i "message-square-plus" "w-4 h-4" }} 21 - <span>comment</span> 22 - </button> 23 - {{ if and $isPushAllowed $isOpen $isLastRound }} 24 - {{ $disabled := "" }} 25 - {{ if $isConflicted }} 26 - {{ $disabled = "disabled" }} 27 - {{ end }} 28 - <button 29 - hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/merge" 30 - hx-swap="none" 31 - hx-confirm="Are you sure you want to merge pull #{{ .Pull.PullId }} into the `{{ .Pull.TargetBranch }}` branch?" 32 - class="btn p-2 flex items-center gap-2" {{ $disabled }}> 33 - {{ i "git-merge" "w-4 h-4" }} 34 - <span>merge</span> 35 - </button> 36 - {{ end }} 37 - 38 - {{ if and $isPullAuthor $isOpen $isLastRound }} 39 - <button 40 - hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/resubmit" 41 - hx-target="#actions-{{$roundNumber}}" 42 - hx-swap="outerHtml" 43 - class="btn p-2 flex items-center gap-2"> 44 - {{ i "rotate-ccw" "w-4 h-4" }} 45 - <span>resubmit</span> 46 - </button> 47 - {{ end }} 48 - 49 - {{ if and (or $isPullAuthor $isPushAllowed) $isOpen $isLastRound }} 50 - <button 51 - hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/close" 52 - hx-swap="none" 53 - class="btn p-2 flex items-center gap-2"> 54 - {{ i "ban" "w-4 h-4" }} 55 - <span>close</span> 56 - </button> 57 - {{ end }} 58 - 59 - {{ if and (or $isPullAuthor $isPushAllowed) $isClosed $isLastRound }} 60 - <button 61 - hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/reopen" 62 - hx-swap="none" 63 - class="btn p-2 flex items-center gap-2"> 64 - {{ i "circle-dot" "w-4 h-4" }} 65 - <span>reopen</span> 66 - </button> 67 - {{ end }} 68 - </div> 69 - </div> 70 - {{ end }} 71 - 72 -
···
-32
appview/pages/templates/fragments/pullNewComment.html
··· 1 - {{ define "fragments/pullNewComment" }} 2 - <div 3 - id="pull-comment-card-{{ .RoundNumber }}" 4 - class="bg-white rounded drop-shadow-sm p-4 relative w-full flex flex-col gap-2"> 5 - <div class="text-sm text-gray-500"> 6 - {{ didOrHandle .LoggedInUser.Did .LoggedInUser.Handle }} 7 - </div> 8 - <form 9 - hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .RoundNumber }}/comment" 10 - hx-swap="none" 11 - class="w-full flex flex-wrap gap-2"> 12 - <textarea 13 - name="body" 14 - class="w-full p-2 rounded border border-gray-200" 15 - placeholder="Add to the discussion..."></textarea> 16 - <button type="submit" class="btn flex items-center gap-2"> 17 - {{ i "message-square" "w-4 h-4" }} comment 18 - </button> 19 - <button 20 - type="button" 21 - class="btn flex items-center gap-2" 22 - hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .RoundNumber }}/actions" 23 - hx-swap="outerHTML" 24 - hx-target="#pull-comment-card-{{ .RoundNumber }}"> 25 - {{ i "x" "w-4 h-4" }} 26 - <span>cancel</span> 27 - </button> 28 - <div id="pull-comment"></div> 29 - </form> 30 - </div> 31 - {{ end }} 32 -
···
-52
appview/pages/templates/fragments/pullResubmit.html
··· 1 - {{ define "fragments/pullResubmit" }} 2 - <div 3 - id="resubmit-pull-card" 4 - class="rounded relative border bg-amber-50 border-amber-200 px-6 py-2"> 5 - 6 - <div class="flex items-center gap-2 text-amber-500"> 7 - {{ i "pencil" "w-4 h-4" }} 8 - <span class="font-medium">resubmit your patch</span> 9 - </div> 10 - 11 - <div class="mt-2 text-sm text-gray-700"> 12 - You can update this patch to address any reviews. 13 - This will begin a new round of reviews, 14 - but you'll still be able to view your previous submissions and feedback. 15 - </div> 16 - 17 - <div class="mt-4 flex flex-col"> 18 - <form 19 - hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/resubmit" 20 - hx-swap="none" 21 - class="w-full flex flex-wrap gap-2"> 22 - <textarea 23 - name="patch" 24 - class="w-full p-2 mb-2 rounded border border-gray-200" 25 - placeholder="Paste your updated patch here." 26 - rows="15" 27 - >{{.Pull.LatestPatch}}</textarea> 28 - <button 29 - type="submit" 30 - class="btn flex items-center gap-2" 31 - {{ if or .Pull.State.IsClosed }} 32 - disabled 33 - {{ end }}> 34 - {{ i "rotate-ccw" "w-4 h-4" }} 35 - <span>resubmit</span> 36 - </button> 37 - <button 38 - type="button" 39 - class="btn flex items-center gap-2" 40 - hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .Pull.LastRoundNumber }}/actions" 41 - hx-swap="outerHTML" 42 - hx-target="#resubmit-pull-card"> 43 - {{ i "x" "w-4 h-4" }} 44 - <span>cancel</span> 45 - </button> 46 - </form> 47 - 48 - <div id="resubmit-error" class="error"></div> 49 - <div id="resubmit-success" class="success"></div> 50 - </div> 51 - </div> 52 - {{ end }}
···
-15
appview/pages/templates/fragments/repoDescription.html
··· 1 - {{ define "fragments/repoDescription" }} 2 - <span id="repo-description" class="flex flex-wrap items-center gap-2" hx-target="this" hx-swap="outerHTML"> 3 - {{ if .RepoInfo.Description }} 4 - {{ .RepoInfo.Description }} 5 - {{ else }} 6 - <span class="italic">this repo has no description</span> 7 - {{ end }} 8 - 9 - {{ if .RepoInfo.Roles.IsOwner }} 10 - <button class="btn p-2 flex items-center gap-2 no-underline text-sm" hx-get="/{{ .RepoInfo.FullName }}/description/edit"> 11 - {{ i "pencil" "w-3 h-3" }} edit 12 - </button> 13 - {{ end }} 14 - </span> 15 - {{ end }}
···
-28
appview/pages/templates/fragments/star.html
··· 1 - {{ define "fragments/star" }} 2 - <button id="starBtn" 3 - class="text-sm disabled:opacity-50 disabled:cursor-not-allowed" 4 - 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="#starBtn" 13 - hx-swap="outerHTML" 14 - hx-disabled-elt="#starBtn" 15 - > 16 - <div class="flex gap-2 items-center"> 17 - {{ if .IsStarred }} 18 - {{ i "star" "w-3 h-3 fill-current" }} 19 - {{ else }} 20 - {{ i "star" "w-3 h-3" }} 21 - {{ end }} 22 - <span> 23 - {{ .Stats.StarCount }} 24 - </span> 25 - </div> 26 - </button> 27 - {{ end }} 28 -
···
+92 -34
appview/pages/templates/knot.html
··· 1 - {{define "title"}}{{ .Registration.Domain }}{{end}} 2 3 - {{define "content"}} 4 - <h1>{{.Registration.Domain}}</h1> 5 - <p> 6 - <code> 7 - opened by: {{.Registration.ByDid}} 8 - {{ if eq $.LoggedInUser.Did $.Registration.ByDid }} 9 - (you) 10 - {{ end }} 11 - </code><br> 12 - <code>on: {{.Registration.Created}}</code><br> 13 - {{ if .Registration.Registered }} 14 - <code>registered on: {{.Registration.Registered}}</code> 15 - {{ else }} 16 - <code>pending registration</code> 17 - <button class="btn my-2" hx-post="/knots/{{.Domain}}/init" hx-swap="none">initialize</button> 18 {{ end }} 19 - </p> 20 - 21 {{ if .Registration.Registered }} 22 - <h3> members </h3> 23 - <ol> 24 - {{ range $.Members }} 25 - <li><a href="/{{.}}">{{.}}</a></li> 26 {{ else }} 27 - <p>no members</p> 28 {{ end }} 29 - {{ end }} 30 - </ol> 31 32 - {{ if $.IsOwner }} 33 - <h3>add member</h3> 34 - <form hx-put="/knots/{{.Registration.Domain}}/member"> 35 - <label for="member">did or handle:</label> 36 - <input type="text" id="member" name="member" required> 37 - <button class="btn my-2" type="text">add member</button> 38 - </form> 39 - {{ end }} 40 - {{end}}
··· 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 }}
+79 -84
appview/pages/templates/knots.html
··· 1 {{ define "title" }}knots{{ end }} 2 - 3 {{ define "content" }} 4 - <h1>knots</h1> 5 6 - <section class="mb-12"> 7 - <h2 class="text-2xl mb-4">register a knot</h2> 8 - <form hx-post="/knots/key" class="flex gap-4 items-end"> 9 - <div> 10 - <label for="domain" 11 - >Generate a key to start your knot with.</label 12 - > 13 - <input 14 - type="text" 15 - id="domain" 16 - name="domain" 17 - placeholder="knot.example.com" 18 - required 19 - /> 20 </div> 21 - <button class="btn" type="submit">generate key</button> 22 - </form> 23 </section> 24 25 - <section class="mb-12"> 26 - <h3 class="text-xl font-semibold mb-4">my knots</h3> 27 - <p>This is a list of knots</p> 28 - <ul id="my-knots" class="space-y-6"> 29 - {{ range .Registrations }} 30 - {{ if .Registered }} 31 - <li class="border rounded p-4 flex flex-col gap-2"> 32 - <div> 33 - <a href="/knots/{{ .Domain }}" class="font-semibold" 34 - >{{ .Domain }}</a 35 - > 36 - </div> 37 - <div class="text-gray-600"> 38 - Owned by 39 - {{ .ByDid }} 40 - </div> 41 - <div class="text-gray-600"> 42 - Registered on 43 - {{ .Registered }} 44 - </div> 45 - </li> 46 - {{ end }} 47 - {{ else }} 48 - <p class="text-gray-600">you don't have any knots yet</p> 49 - {{ end }} 50 - </ul> 51 </section> 52 - 53 - <section> 54 - <h3 class="text-xl font-semibold mb-4">pending registrations</h3> 55 - <ul id="pending-registrations" class="space-y-6"> 56 - {{ range .Registrations }} 57 - {{ if not .Registered }} 58 - <li class="border rounded p-4 flex flex-col gap-2"> 59 - <div> 60 - <a 61 - href="/knots/{{ .Domain }}" 62 - class="text-blue-600 hover:underline" 63 - >{{ .Domain }}</a 64 - > 65 - </div> 66 - <div class="text-gray-600"> 67 - Opened by 68 - {{ .ByDid }} 69 - </div> 70 - <div class="text-gray-600"> 71 - Created on 72 - {{ .Created }} 73 - </div> 74 - <div class="flex items-center gap-4 mt-2"> 75 - <span class="text-amber-600" 76 - >pending registration</span 77 - > 78 - <button 79 - class="btn" 80 - hx-post="/knots/{{ .Domain }}/init" 81 - > 82 - initialize 83 - </button> 84 - </div> 85 - </li> 86 - {{ end }} 87 - {{ else }} 88 - <p class="text-gray-600">no registrations yet</p> 89 - {{ end }} 90 - </ul> 91 - </section> 92 {{ end }}
··· 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 + > 14 + <input 15 + type="text" 16 + id="domain" 17 + name="domain" 18 + placeholder="knot.example.com" 19 + required 20 + class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400" 21 + /> 22 + <button class="btn dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600" type="submit"> 23 + generate key 24 + </button> 25 + <div id="settings-knots-error" class="error dark:text-red-400"></div> 26 + </form> 27 + </section> 28 29 + <h2 class="text-sm font-bold py-2 px-6 uppercase dark:text-gray-300">my knots</h2> 30 + <section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit"> 31 + <div id="knots-list" class="flex flex-col gap-6 mb-8"> 32 + {{ range .Registrations }} 33 + {{ if .Registered }} 34 + <div class="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-4"> 35 + <div class="flex flex-col gap-1"> 36 + <div class="inline-flex items-center gap-4"> 37 + {{ i "git-branch" "w-3 h-3 dark:text-gray-300" }} 38 + <a href="/knots/{{ .Domain }}"> 39 + <p class="font-bold dark:text-white">{{ .Domain }}</p> 40 + </a> 41 + </div> 42 + <p class="text-sm text-gray-500 dark:text-gray-400">owned by {{ .ByDid }}</p> 43 + <p class="text-sm text-gray-500 dark:text-gray-400">registered {{ .Registered | timeFmt }}</p> 44 </div> 45 + </div> 46 + {{ end }} 47 + {{ else }} 48 + <p class="text-sm text-gray-500 dark:text-gray-400">No knots registered</p> 49 + {{ end }} 50 + </div> 51 </section> 52 53 + <h2 class="text-sm font-bold py-2 px-6 uppercase dark:text-gray-300">pending registrations</h2> 54 + <section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit"> 55 + <div id="pending-knots-list" class="flex flex-col gap-6 mb-8"> 56 + {{ range .Registrations }} 57 + {{ if not .Registered }} 58 + <div class="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-4"> 59 + <div class="flex flex-col gap-1"> 60 + <div class="inline-flex items-center gap-4"> 61 + <p class="font-bold dark:text-white">{{ .Domain }}</p> 62 + <div class="inline-flex items-center gap-1"> 63 + <span class="text-xs bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 px-2 py-1 rounded"> 64 + pending 65 + </span> 66 + </div> 67 + </div> 68 + <p class="text-sm text-gray-500 dark:text-gray-400">opened by {{ .ByDid }}</p> 69 + <p class="text-sm text-gray-500 dark:text-gray-400">created {{ .Created | timeFmt }}</p> 70 + </div> 71 + <div class="flex gap-2 items-center"> 72 + <button 73 + class="btn dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 gap-2" 74 + hx-post="/knots/{{ .Domain }}/init"> 75 + {{ i "square-play" "w-5 h-5" }} 76 + <span class="hidden md:inline">initialize</span> 77 + </button> 78 + </div> 79 + </div> 80 + {{ end }} 81 + {{ else }} 82 + <p class="text-sm text-gray-500 dark:text-gray-400">No pending registrations</p> 83 + {{ end }} 84 + </div> 85 </section> 86 + </div> 87 {{ end }}
+6 -6
appview/pages/templates/layouts/base.html
··· 1 {{ define "layouts/base" }} 2 <!doctype html> 3 - <html lang="en"> 4 <head> 5 <meta charset="UTF-8" /> 6 <meta 7 name="viewport" 8 content="width=device-width, initial-scale=1.0" 9 /> 10 <script src="/static/htmx.min.js"></script> 11 - <link href="/static/tw.css" rel="stylesheet" type="text/css" /> 12 - 13 <title>{{ block "title" . }}{{ end }} ยท tangled</title> 14 {{ block "extrameta" . }}{{ end }} 15 </head> 16 - <body class="bg-slate-100"> 17 - <div class="container mx-auto px-1 pt-4 min-h-screen flex flex-col"> 18 - <header style="z-index: 5"> 19 {{ block "topbar" . }} 20 {{ template "layouts/topbar" . }} 21 {{ end }}
··· 1 {{ define "layouts/base" }} 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 name="htmx-config" content='{"includeIndicatorStyles": false}'> 11 <script src="/static/htmx.min.js"></script> 12 + <link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" /> 13 <title>{{ block "title" . }}{{ end }} ยท tangled</title> 14 {{ block "extrameta" . }}{{ end }} 15 </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" . }} 21 {{ end }}
+3 -3
appview/pages/templates/layouts/footer.html
··· 1 {{ define "layouts/footer" }} 2 - <div class="w-full p-4 bg-white rounded-t"> 3 - <div class="container mx-auto text-center text-gray-600 text-sm"> 4 - <span class="font-semibold italic">tangled</span> &mdash; made by <a href="/@oppili.bsky.social">@oppili.bsky.social</a> and <a href="/@icyphox.sh">@icyphox.sh</a> 5 </div> 6 </div> 7 {{ end }}
··· 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 }}
+34 -19
appview/pages/templates/layouts/repobase.html
··· 1 {{ define "title" }}{{ .RepoInfo.FullName }}{{ end }} 2 3 {{ define "content" }} 4 - <section id="repo-header" class="mb-4 py-2 px-6"> 5 - <p class="text-lg"> 6 - <a href="/{{ .RepoInfo.OwnerWithAt }}">{{ .RepoInfo.OwnerWithAt }}</a> 7 - <span class="select-none">/</span> 8 - <a href="/{{ .RepoInfo.FullName }}" class="font-bold">{{ .RepoInfo.Name }}</a> 9 - <span class="ml-3"> 10 - {{ template "fragments/star" .RepoInfo }} 11 - </span> 12 - </p> 13 - {{ template "fragments/repoDescription" . }} 14 - </section> 15 <section class="min-h-screen flex flex-col drop-shadow-sm"> 16 <nav class="w-full pl-4 overflow-auto"> 17 <div class="flex z-60"> 18 - {{ $activeTabStyles := "-mb-px bg-white" }} 19 {{ $tabs := .RepoInfo.GetTabs }} 20 {{ $tabmeta := .RepoInfo.TabMetadata }} 21 {{ range $item := $tabs }} 22 {{ $key := index $item 0 }} 23 {{ $value := index $item 1 }} 24 {{ $meta := index $tabmeta $key }} 25 <a 26 href="/{{ $.RepoInfo.FullName }}{{ $value }}" ··· 28 hx-boost="true" 29 > 30 <div 31 - class="px-4 py-1 mr-1 text-black min-w-[80px] text-center relative rounded-t whitespace-nowrap 32 {{ if eq $.Active $key }} 33 {{ $activeTabStyles }} 34 {{ else }} 35 - group-hover:bg-gray-200 36 {{ end }} 37 " 38 > 39 - {{ $key }} 40 - {{ if not (isNil $meta) }} 41 - <span class="bg-gray-200 rounded py-1/2 px-1 text-sm">{{ $meta }}</span> 42 - {{ end }} 43 </div> 44 </a> 45 {{ end }} 46 </div> 47 </nav> 48 <section 49 - class="bg-white p-6 rounded relative z-20 w-full mx-auto drop-shadow-sm" 50 > 51 {{ block "repoContent" . }}{{ end }} 52 </section>
··· 1 {{ define "title" }}{{ .RepoInfo.FullName }}{{ end }} 2 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> 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"> 27 <nav class="w-full pl-4 overflow-auto"> 28 <div class="flex z-60"> 29 + {{ $activeTabStyles := "-mb-px bg-white dark:bg-gray-800" }} 30 {{ $tabs := .RepoInfo.GetTabs }} 31 {{ $tabmeta := .RepoInfo.TabMetadata }} 32 {{ range $item := $tabs }} 33 {{ $key := index $item 0 }} 34 {{ $value := index $item 1 }} 35 + {{ $icon := index $item 2 }} 36 {{ $meta := index $tabmeta $key }} 37 <a 38 href="/{{ $.RepoInfo.FullName }}{{ $value }}" ··· 40 hx-boost="true" 41 > 42 <div 43 + class="px-4 py-1 mr-1 text-black dark:text-white min-w-[80px] text-center relative rounded-t whitespace-nowrap 44 {{ if eq $.Active $key }} 45 {{ $activeTabStyles }} 46 {{ else }} 47 + group-hover:bg-gray-200 dark:group-hover:bg-gray-700 48 {{ end }} 49 " 50 > 51 + <span class="flex items-center justify-center"> 52 + {{ i $icon "w-4 h-4 mr-2" }} 53 + {{ $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> 56 + {{ end }} 57 + </span> 58 </div> 59 </a> 60 {{ end }} 61 </div> 62 </nav> 63 <section 64 + class="bg-white dark:bg-gray-800 p-6 rounded relative z-20 w-full mx-auto drop-shadow-sm dark:text-white" 65 > 66 {{ block "repoContent" . }}{{ end }} 67 </section>
+8 -3
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 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"> ··· 28 {{ didOrHandle .Did .Handle }} 29 </summary> 30 <div 31 - class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white border border-gray-200" 32 > 33 <a href="/{{ didOrHandle .Did .Handle }}">profile</a> 34 <a href="/knots">knots</a> 35 <a href="/settings">settings</a> 36 - <a href="/logout" class="text-red-400 hover:text-red-700">logout</a> 37 </div> 38 </details> 39 {{ end }}
··· 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"> ··· 28 {{ didOrHandle .Did .Handle }} 29 </summary> 30 <div 31 + 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" 32 > 33 <a href="/{{ didOrHandle .Did .Handle }}">profile</a> 34 <a href="/knots">knots</a> 35 <a href="/settings">settings</a> 36 + <a href="#" 37 + hx-post="/logout" 38 + hx-swap="none" 39 + class="text-red-400 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"> 40 + logout 41 + </a> 42 </div> 43 </details> 44 {{ end }}
+27 -23
appview/pages/templates/repo/blob.html
··· 15 {{ $lines := split .Contents }} 16 {{ $tot_lines := len $lines }} 17 {{ $tot_chars := len (printf "%d" $tot_lines) }} 18 - {{ $code_number_style := "text-gray-400 left-0 bg-white text-right mr-6 select-none inline-block w-12" }} 19 {{ $linkstyle := "no-underline hover:underline" }} 20 - <div class="pb-2 text-base"> 21 - <div class="flex justify-between"> 22 - <div id="breadcrumbs"> 23 {{ range $idx, $value := .BreadCrumbs }} 24 {{ if ne $idx (sub (len $.BreadCrumbs) 1) }} 25 <a 26 href="{{ index . 1 }}" 27 - class="text-bold text-gray-500 {{ $linkstyle }}" 28 >{{ index . 0 }}</a 29 > 30 / 31 {{ else }} 32 - <span class="text-bold text-gray-500" 33 >{{ index . 0 }}</span 34 > 35 {{ end }} 36 {{ end }} 37 </div> 38 - <div id="file-info" class="text-gray-500 text-xs"> 39 - {{ .Lines }} lines 40 - <span class="select-none px-2 [&:before]:content-['ยท']"></span> 41 - {{ byteFmt .SizeHint }} 42 </div> 43 </div> 44 </div> 45 {{ if .IsBinary }} 46 - <p class="text-center text-gray-400"> 47 This is a binary file and will not be displayed. 48 </p> 49 {{ else }} 50 - <div class="overflow-auto relative text-ellipsis"> 51 - {{ range $idx, $line := $lines }} 52 - {{ $linenr := add $idx 1 }} 53 - <div class="flex"> 54 - <a href="#L{{ $linenr }}" id="L{{ $linenr }}" class="no-underline peer"> 55 - <span class="{{ $code_number_style }}" 56 - style="min-width: {{ $tot_chars }}ch;"> 57 - {{ $linenr }} 58 - </span> 59 - </a> 60 - <div class="whitespace-pre peer-target:bg-yellow-200">{{ $line | escapeHtml }}</div> 61 - </div> 62 {{ end }} 63 </div> 64 {{ end }}
··· 15 {{ $lines := split .Contents }} 16 {{ $tot_lines := len $lines }} 17 {{ $tot_chars := len (printf "%d" $tot_lines) }} 18 + {{ $code_number_style := "text-gray-400 dark:text-gray-500 left-0 bg-white dark:bg-gray-800 text-right mr-6 select-none inline-block w-12" }} 19 {{ $linkstyle := "no-underline hover:underline" }} 20 + <div class="pb-2 mb-3 text-base border-b border-gray-200 dark:border-gray-700"> 21 + <div class="flex flex-col md:flex-row md:justify-between gap-2"> 22 + <div id="breadcrumbs" class="overflow-x-auto whitespace-nowrap text-gray-400 dark:text-gray-500"> 23 {{ range $idx, $value := .BreadCrumbs }} 24 {{ if ne $idx (sub (len $.BreadCrumbs) 1) }} 25 <a 26 href="{{ index . 1 }}" 27 + class="text-bold text-gray-500 dark:text-gray-400 {{ $linkstyle }}" 28 >{{ index . 0 }}</a 29 > 30 / 31 {{ else }} 32 + <span class="text-bold text-black dark:text-white" 33 >{{ index . 0 }}</span 34 > 35 {{ end }} 36 {{ end }} 37 </div> 38 + <div id="file-info" class="text-gray-500 dark:text-gray-400 text-xs md:text-sm flex flex-wrap items-center gap-1 md:gap-0"> 39 + <span>at <a href="/{{ .RepoInfo.FullName }}/tree/{{ .Ref }}">{{ .Ref }}</a></span> 40 + <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span> 41 + <span>{{ .Lines }} lines</span> 42 + <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span> 43 + <span>{{ byteFmt .SizeHint }}</span> 44 + <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span> 45 + <a href="/{{ .RepoInfo.FullName }}/raw/{{ .Ref }}/{{ .Path }}">view raw</a> 46 + {{ if .RenderToggle }} 47 + <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span> 48 + <a 49 + href="/{{ .RepoInfo.FullName }}/blob/{{ .Ref }}/{{ .Path }}?code={{ .ShowRendered }}" 50 + hx-boost="true" 51 + >view {{ if .ShowRendered }}code{{ else }}rendered{{ end }}</a> 52 + {{ end }} 53 </div> 54 </div> 55 </div> 56 {{ if .IsBinary }} 57 + <p class="text-center text-gray-400 dark:text-gray-500"> 58 This is a binary file and will not be displayed. 59 </p> 60 {{ else }} 61 + <div class="overflow-auto relative"> 62 + {{ if .ShowRendered }} 63 + <div id="blob-contents" class="prose dark:prose-invert">{{ .RenderedContents }}</div> 64 + {{ else }} 65 + <div id="blob-contents" class="whitespace-pre peer-target:bg-yellow-200 dark:peer-target:bg-yellow-900">{{ $.Contents | escapeHtml }}</div> 66 {{ end }} 67 </div> 68 {{ end }}
+95 -10
appview/pages/templates/repo/branches.html
··· 1 {{ define "title" }} 2 - branches | {{ .RepoInfo.FullName }} 3 {{ end }} 4 5 {{ define "repoContent" }} 6 - <h3>branches</h3> 7 - <div class="refs"> 8 - {{ range .Branches }} 9 - <div> 10 - <strong>{{ .Name }}</strong> 11 - <a href="/{{ $.RepoInfo.FullName }}/tree/{{ .Name }}/">browse</a> 12 - <a href="/{{ $.RepoInfo.FullName }}/log/{{ .Name }}">log</a> 13 - </div> 14 - {{ end }} 15 </div> 16 {{ end }}
··· 1 {{ define "title" }} 2 + branches ยท {{ .RepoInfo.FullName }} 3 {{ end }} 4 5 {{ define "repoContent" }} 6 + <section id="branches-table" class="overflow-x-auto"> 7 + <h2 class="font-bold text-sm mb-4 uppercase dark:text-white"> 8 + Branches 9 + </h2> 10 + 11 + <!-- desktop view (hidden on small screens) --> 12 + <table class="w-full border-collapse hidden md:table"> 13 + <thead> 14 + <tr> 15 + <th class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold">Name</th> 16 + <th class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold">Commit</th> 17 + <th class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold">Message</th> 18 + <th class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold">Date</th> 19 + </tr> 20 + </thead> 21 + <tbody> 22 + {{ range $index, $branch := .Branches }} 23 + <tr class="{{ if ne $index (sub (len $.Branches) 1) }}border-b border-gray-200 dark:border-gray-700{{ end }}"> 24 + <td class="py-3 whitespace-nowrap"> 25 + <a href="/{{ $.RepoInfo.FullName }}/tree/{{ .Name | urlquery }}" class="no-underline hover:underline flex items-center gap-2"> 26 + <span class="dark:text-white"> 27 + {{ .Name }} 28 + </span> 29 + {{ if .IsDefault }} 30 + <span class=" 31 + text-sm rounded 32 + bg-gray-100 dark:bg-gray-700 text-black dark:text-white 33 + font-mono 34 + px-2 mx-1/2 35 + inline-flex items-center 36 + "> 37 + default 38 + </span> 39 + {{ end }} 40 + </a> 41 + </td> 42 + <td class="py-3 whitespace-nowrap"> 43 + {{ if .Commit }} 44 + <a href="/{{ $.RepoInfo.FullName }}/commits/{{ .Name | urlquery }}" class="font-mono text-gray-700 dark:text-gray-300 no-underline hover:underline">{{ slice .Commit.Hash.String 0 8 }}</a> 45 + {{ end }} 46 + </td> 47 + <td class="py-3 whitespace-nowrap"> 48 + {{ if .Commit }} 49 + {{ $messageParts := splitN .Commit.Message "\n\n" 2 }} 50 + <span class="text-gray-700 dark:text-gray-300">{{ index $messageParts 0 }}</span> 51 + {{ end }} 52 + </td> 53 + <td class="py-3 whitespace-nowrap text-gray-500 dark:text-gray-400"> 54 + {{ if .Commit }} 55 + {{ .Commit.Author.When | timeFmt }} 56 + {{ end }} 57 + </td> 58 + </tr> 59 + {{ end }} 60 + </tbody> 61 + </table> 62 + 63 + <!-- mobile view (visible only on small screens) --> 64 + <div class="md:hidden"> 65 + {{ range $index, $branch := .Branches }} 66 + <div class="relative p-2 {{ if ne $index (sub (len $.Branches) 1) }}border-b border-gray-200 dark:border-gray-700{{ end }}"> 67 + <div class="flex items-center justify-between"> 68 + <a href="/{{ $.RepoInfo.FullName }}/tree/{{ .Name | urlquery }}" class="no-underline hover:underline flex items-center gap-2"> 69 + <span class="dark:text-white font-medium"> 70 + {{ .Name }} 71 + </span> 72 + {{ if .IsDefault }} 73 + <span class=" 74 + text-xs rounded 75 + bg-gray-100 dark:bg-gray-700 text-black dark:text-white 76 + font-mono 77 + px-2 78 + inline-flex items-center 79 + "> 80 + default 81 + </span> 82 + {{ end }} 83 + </a> 84 + </div> 85 + 86 + {{ if .Commit }} 87 + <div class="text-xs text-gray-500 dark:text-gray-400 mt-1 flex items-center"> 88 + <span class="font-mono"> 89 + <a href="/{{ $.RepoInfo.FullName }}/commits/{{ .Name | urlquery }}" class="text-gray-500 dark:text-gray-400 no-underline hover:underline"> 90 + {{ slice .Commit.Hash.String 0 8 }} 91 + </a> 92 + </span> 93 + <div class="inline-block px-1 select-none after:content-['ยท']"></div> 94 + <span>{{ .Commit.Author.When | timeFmt }}</span> 95 + </div> 96 + {{ end }} 97 </div> 98 + {{ end }} 99 + </div> 100 + </section> 101 {{ end }}
+10 -27
appview/pages/templates/repo/commit.html
··· 4 5 {{ $repo := .RepoInfo.FullName }} 6 {{ $commit := .Diff.Commit }} 7 - {{ $stat := .Diff.Stat }} 8 - {{ $diff := .Diff.Diff }} 9 10 - <section class="commit"> 11 <div id="commit-message"> 12 {{ $messageParts := splitN $commit.Message "\n\n" 2 }} 13 <div> 14 <p class="pb-2">{{ index $messageParts 0 }}</p> 15 {{ if gt (len $messageParts) 1 }} 16 - <p class="mt-1 cursor-text pb-2 text-sm">{{ nl2br (unwrapText (index $messageParts 1)) }}</p> 17 {{ end }} 18 </div> 19 </div> 20 21 <div class="flex items-center"> 22 - <p class="text-sm text-gray-500"> 23 {{ $didOrHandle := index $.EmailToDidOrHandle $commit.Author.Email }} 24 25 {{ if $didOrHandle }} 26 - <a href="/{{ $didOrHandle }}" class="no-underline hover:underline text-gray-500">{{ $didOrHandle }}</a> 27 {{ else }} 28 - <a href="mailto:{{ $commit.Author.Email }}" class="no-underline hover:underline text-gray-500">{{ $commit.Author.Name }}</a> 29 {{ end }} 30 <span class="px-1 select-none before:content-['\00B7']"></span> 31 {{ timeFmt $commit.Author.When }} 32 <span class="px-1 select-none before:content-['\00B7']"></span> 33 - <span>{{ $stat.FilesChanged }}</span> files <span class="font-mono">(+{{ $stat.Insertions }}, -{{ $stat.Deletions }})</span> 34 - <span class="px-1 select-none before:content-['\00B7']"></span> 35 </p> 36 37 - <p class="flex items-center text-sm text-gray-500"> 38 - <a href="/{{ $repo }}/commit/{{ $commit.This }}" class="no-underline hover:underline text-gray-500">{{ slice $commit.This 0 8 }}</a> 39 {{ if $commit.Parent }} 40 {{ i "arrow-left" "w-3 h-3 mx-1" }} 41 - <a href="/{{ $repo }}/commit/{{ $commit.Parent }}" class="no-underline hover:underline text-gray-500">{{ slice $commit.Parent 0 8 }}</a> 42 {{ end }} 43 </p> 44 </div> 45 - 46 - <div class="diff-stat"> 47 - <br> 48 - <strong class="text-sm uppercase mb-4">Changed files</strong> 49 - {{ range $diff }} 50 - <ul> 51 - {{ if .IsDelete }} 52 - <li><a href="#file-{{ .Name.Old }}">{{ .Name.Old }}</a></li> 53 - {{ else }} 54 - <li><a href="#file-{{ .Name.New }}">{{ .Name.New }}</a></li> 55 - {{ end }} 56 - </ul> 57 - {{ end }} 58 - </div> 59 </section> 60 61 {{end}} 62 63 {{ define "repoAfter" }} 64 - {{ template "fragments/diff" (list .RepoInfo.FullName .Diff) }} 65 {{end}}
··· 4 5 {{ $repo := .RepoInfo.FullName }} 6 {{ $commit := .Diff.Commit }} 7 8 + <section class="commit dark:text-white"> 9 <div id="commit-message"> 10 {{ $messageParts := splitN $commit.Message "\n\n" 2 }} 11 <div> 12 <p class="pb-2">{{ index $messageParts 0 }}</p> 13 {{ if gt (len $messageParts) 1 }} 14 + <p class="mt-1 cursor-text pb-2 text-sm">{{ nl2br (index $messageParts 1) }}</p> 15 {{ end }} 16 </div> 17 </div> 18 19 <div class="flex items-center"> 20 + <p class="text-sm text-gray-500 dark:text-gray-300"> 21 {{ $didOrHandle := index $.EmailToDidOrHandle $commit.Author.Email }} 22 23 {{ if $didOrHandle }} 24 + <a href="/{{ $didOrHandle }}" class="no-underline hover:underline text-gray-500 dark:text-gray-300">{{ $didOrHandle }}</a> 25 {{ else }} 26 + <a href="mailto:{{ $commit.Author.Email }}" class="no-underline hover:underline text-gray-500 dark:text-gray-300">{{ $commit.Author.Name }}</a> 27 {{ end }} 28 <span class="px-1 select-none before:content-['\00B7']"></span> 29 {{ timeFmt $commit.Author.When }} 30 <span class="px-1 select-none before:content-['\00B7']"></span> 31 </p> 32 33 + <p class="flex items-center text-sm text-gray-500 dark:text-gray-300"> 34 + <a href="/{{ $repo }}/commit/{{ $commit.This }}" class="no-underline hover:underline text-gray-500 dark:text-gray-300">{{ slice $commit.This 0 8 }}</a> 35 {{ if $commit.Parent }} 36 {{ i "arrow-left" "w-3 h-3 mx-1" }} 37 + <a href="/{{ $repo }}/commit/{{ $commit.Parent }}" class="no-underline hover:underline text-gray-500 dark:text-gray-300">{{ slice $commit.Parent 0 8 }}</a> 38 {{ end }} 39 </p> 40 </div> 41 + 42 </section> 43 44 {{end}} 45 46 {{ define "repoAfter" }} 47 + {{ template "repo/fragments/diff" (list .RepoInfo.FullName .Diff) }} 48 {{end}}
+2 -21
appview/pages/templates/repo/empty.html
··· 2 3 {{ define "repoContent" }} 4 <main> 5 - <p class="text-center pt-5 text-gray-400"> 6 This is an empty repository. Push some commits here. 7 </p> 8 </main> 9 {{ end }} 10 11 {{ define "repoAfter" }} 12 - <section class="mt-4 p-6 rounded bg-white w-full mx-auto overflow-auto"> 13 - <strong>push</strong> 14 - <div class="py-2"> 15 - <code>git remote add origin git@{{.RepoInfo.Knot}}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code> 16 - </div> 17 - <strong>clone</strong> 18 - 19 - 20 - <div class="flex flex-col gap-2"> 21 - <div class="pt-2 flex flex-row gap-2"> 22 - <span class="bg-gray-100 p-1 mr-1 font-mono text-sm rounded select-none">HTTP</span> 23 - <code>git clone https://tangled.sh/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}</code> 24 - </div> 25 - <div class="pt-2 flex flex-row gap-2"> 26 - <span class="bg-gray-100 p-1 mr-1 font-mono text-sm rounded select-none">SSH</span><code>git clone git@{{.RepoInfo.Knot}}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code> 27 - </div> 28 - </div> 29 - <p class="py-2 text-gray-500">Note that for self-hosted knots, clone URLs may be different based on your setup.</p> 30 - </section> 31 - 32 {{ end }}
··· 2 3 {{ define "repoContent" }} 4 <main> 5 + <p class="text-center pt-5 text-gray-400 dark:text-gray-500"> 6 This is an empty repository. Push some commits here. 7 </p> 8 </main> 9 {{ end }} 10 11 {{ define "repoAfter" }} 12 + {{ template "repo/fragments/cloneInstructions" . }} 13 {{ end }}
+38
appview/pages/templates/repo/fork.html
···
··· 1 + {{ define "title" }}fork &middot; {{ .RepoInfo.FullName }}{{ end }} 2 + 3 + {{ define "content" }} 4 + <div class="p-6"> 5 + <p class="text-xl font-bold dark:text-white">Fork {{ .RepoInfo.FullName }}</p> 6 + </div> 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"> 9 + <fieldset class="space-y-3"> 10 + <legend class="dark:text-white">Select a knot to fork into</legend> 11 + <div class="space-y-2"> 12 + <div class="flex flex-col"> 13 + {{ range .Knots }} 14 + <div class="flex items-center"> 15 + <input 16 + type="radio" 17 + name="knot" 18 + value="{{ . }}" 19 + class="mr-2" 20 + id="domain-{{ . }}" 21 + /> 22 + <span class="dark:text-white">{{ . }}</span> 23 + </div> 24 + {{ else }} 25 + <p class="dark:text-white">No knots available.</p> 26 + {{ end }} 27 + </div> 28 + </div> 29 + <p class="text-sm text-gray-500 dark:text-gray-400">A knot hosts repository data. <a href="/knots" class="underline">Learn how to register your own knot.</a></p> 30 + </fieldset> 31 + 32 + <div class="space-y-2"> 33 + <button type="submit" class="btn">fork repo</button> 34 + <div id="repo" class="error"></div> 35 + </div> 36 + </form> 37 + </div> 38 + {{ end }}
+34
appview/pages/templates/repo/fragments/artifact.html
···
··· 1 + {{ define "repo/fragments/artifact" }} 2 + {{ $unique := .Artifact.BlobCid.String }} 3 + <div id="artifact-{{ $unique }}" class="flex items-center justify-between p-2 border-b border-gray-200 dark:border-gray-700"> 4 + <div id="left-side" class="flex items-center gap-2 min-w-0 max-w-[60%]"> 5 + {{ i "box" "w-4 h-4" }} 6 + <a href="/{{ .RepoInfo.FullName }}/tags/{{ .Artifact.Tag.String }}/download/{{ .Artifact.Name | urlquery }}" class="no-underline hover:no-underline"> 7 + {{ .Artifact.Name }} 8 + </a> 9 + <span class="text-gray-500 dark:text-gray-400 pl-2 text-sm">{{ byteFmt .Artifact.Size }}</span> 10 + </div> 11 + 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> 15 + 16 + <span class="select-none after:content-['ยท'] hidden md:inline"></span> 17 + <span class="truncate max-w-[100px] hidden md:inline">{{ .Artifact.MimeType }}</span> 18 + 19 + {{ if and .LoggedInUser (eq .LoggedInUser.Did .Artifact.Did) }} 20 + <button 21 + id="delete-{{ $unique }}" 22 + class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 gap-2" 23 + title="Delete artifact" 24 + hx-delete="/{{ .RepoInfo.FullName }}/tags/{{ .Artifact.Tag.String }}/{{ .Artifact.Name | urlquery }}" 25 + hx-swap="outerHTML" 26 + hx-target="#artifact-{{ $unique }}" 27 + hx-disabled-elt="#delete-{{ $unique }}" 28 + hx-confirm="Are you sure you want to delete the artifact '{{ .Artifact.Name }}'?"> 29 + {{ i "trash-2" "w-4 h-4" }} 30 + </button> 31 + {{ end }} 32 + </div> 33 + </div> 34 + {{ 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 }}
+163
appview/pages/templates/repo/fragments/diff.html
···
··· 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 }} 8 + 9 + {{ $this := $commit.This }} 10 + {{ $parent := $commit.Parent }} 11 + 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 }} 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> 29 + <summary class="list-none cursor-pointer sticky top-0"> 30 + <div id="diff-file-header" class="rounded cursor-pointer bg-white dark:bg-gray-800 flex justify-between"> 31 + <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> 48 + 49 + <div class="flex gap-2 items-center overflow-x-auto"> 50 + {{ if .IsDelete }} 51 + <a class="dark:text-white whitespace-nowrap overflow-x-auto" {{if $this }}href="/{{ $repo }}/blob/{{ $this }}/{{ .Name.Old }}"{{end}}> 52 + {{ .Name.Old }} 53 + </a> 54 + {{ 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> 62 + {{ else }} 63 + <a class="dark:text-white whitespace-nowrap overflow-x-auto" {{if $this}}href="/{{ $repo }}/blob/{{ $this }}/{{ .Name.New }}"{{end}}> 64 + {{ .Name.New }} 65 + </a> 66 + {{ end }} 67 + </div> 68 + </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 + </div> 85 + </summary> 86 + 87 + <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 }} 97 + <p class="text-center text-gray-400 dark:text-gray-500 p-4"> 98 + This is a binary file and will not be displayed. 99 + </p> 100 + {{ 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> 140 + {{- end -}} 141 + </div> 142 + 143 + </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> 161 + {{ end }} 162 + </div> 163 + {{ end }}
+11
appview/pages/templates/repo/fragments/editRepoDescription.html
···
··· 1 + {{ define "repo/fragments/editRepoDescription" }} 2 + <form hx-put="/{{ .RepoInfo.FullName }}/description" hx-target="this" hx-swap="outerHTML" class="flex flex-wrap gap-2"> 3 + <input type="text" class="p-1" name="description" value="{{ .RepoInfo.Description }}"> 4 + <button type="submit" class="btn p-1 flex items-center gap-2 no-underline text-sm"> 5 + {{ i "check" "w-3 h-3" }} save 6 + </button> 7 + <button type="button" class="btn p-1 flex items-center gap-2 no-underline text-sm" hx-get="/{{ .RepoInfo.FullName }}/description" > 8 + {{ i "x" "w-3 h-3" }} cancel 9 + </button> 10 + </form> 11 + {{ end }}
+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 +
+143
appview/pages/templates/repo/fragments/interdiff.html
···
··· 1 + {{ define "repo/fragments/interdiff" }} 2 + {{ $repo := index . 0 }} 3 + {{ $x := index . 1 }} 4 + {{ $fileTree := fileTree $x.AffectedFiles }} 5 + {{ $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> 15 + 16 + {{ $last := sub (len $diff) 1 }} 17 + {{ 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> 61 + {{ end }} 62 + </div> 63 + 64 + </div> 65 + </summary> 66 + 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 + </div> 122 + 123 + </details> 124 + 125 + </div> 126 + </div> 127 + </section> 128 + {{ end }} 129 + {{ end }} 130 + {{ end }} 131 + 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 }}
+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 }}
+15
appview/pages/templates/repo/fragments/repoDescription.html
···
··· 1 + {{ define "repo/fragments/repoDescription" }} 2 + <span id="repo-description" class="flex flex-wrap items-center gap-2 text-sm" hx-target="this" hx-swap="outerHTML"> 3 + {{ if .RepoInfo.Description }} 4 + {{ .RepoInfo.Description }} 5 + {{ else }} 6 + <span class="italic">this repo has no description</span> 7 + {{ end }} 8 + 9 + {{ if .RepoInfo.Roles.IsOwner }} 10 + <button class="flex items-center gap-2 no-underline text-sm" hx-get="/{{ .RepoInfo.FullName }}/description/edit"> 11 + {{ i "pencil" "w-3 h-3" }} 12 + </button> 13 + {{ end }} 14 + </span> 15 + {{ end }}
+298 -196
appview/pages/templates/repo/index.html
··· 1 {{ define "title" }}{{ .RepoInfo.FullName }} at {{ .Ref }}{{ end }} 2 3 - 4 {{ define "extrameta" }} 5 - <meta name="vcs:clone" content="https://tangled.sh/{{ .RepoInfo.FullName }}"/> 6 - <meta name="forge:summary" content="https://tangled.sh/{{ .RepoInfo.FullName }}"> 7 - <meta name="forge:dir" content="https://tangled.sh/{{ .RepoInfo.FullName }}/tree/{ref}/{path}"> 8 - <meta name="forge:file" content="https://tangled.sh/{{ .RepoInfo.FullName }}/blob/{ref}/{path}"> 9 - <meta name="forge:line" content="https://tangled.sh/{{ .RepoInfo.FullName }}/blob/{ref}/{path}#L{line}"> 10 - <meta name="go-import" content="tangled.sh/{{ .RepoInfo.FullNameWithoutAt }} git https://tangled.sh/{{ .RepoInfo.FullName }}"> 11 {{ end }} 12 13 - 14 {{ define "repoContent" }} 15 <main> 16 - {{ block "branchSelector" . }} {{ end }} 17 <div class="grid grid-cols-1 md:grid-cols-2 gap-2"> 18 - {{ block "fileTree" . }} {{ end }} 19 - {{ block "commitLog" . }} {{ end }} 20 </div> 21 </main> 22 {{ end }} 23 24 {{ define "branchSelector" }} 25 - <div class="flex justify-between pb-5"> 26 - <select 27 - onchange="window.location.href = '/{{ .RepoInfo.FullName }}/tree/' + encodeURIComponent(this.value)" 28 - class="p-1 border border-gray-200 bg-white" 29 - > 30 - <optgroup label="branches" class="bold text-sm"> 31 - {{ range .Branches }} 32 - <option 33 - value="{{ .Reference.Name }}" 34 - class="py-1" 35 - {{ if eq .Reference.Name $.Ref }} 36 - selected 37 - {{ end }} 38 - > 39 - {{ .Reference.Name }} 40 - </option> 41 - {{ end }} 42 - </optgroup> 43 - <optgroup label="tags" class="bold text-sm"> 44 - {{ range .Tags }} 45 - <option 46 - value="{{ .Reference.Name }}" 47 - class="py-1" 48 - {{ if eq .Reference.Name $.Ref }} 49 - selected 50 - {{ end }} 51 - > 52 - {{ .Reference.Name }} 53 - </option> 54 - {{ else }} 55 - <option class="py-1" disabled>no tags found</option> 56 - {{ end }} 57 - </optgroup> 58 - </select> 59 - <a 60 - href="/{{ .RepoInfo.FullName }}/commits/{{ .Ref | urlquery }}" 61 - class="ml-2 no-underline flex items-center gap-2 text-sm uppercase font-bold" 62 - > 63 - {{ i "logs" "w-4 h-4" }} 64 - {{ .TotalCommits }} 65 - {{ if eq .TotalCommits 1 }}commit{{ else }}commits{{ end }} 66 - </a> 67 - </div> 68 {{ end }} 69 70 {{ define "fileTree" }} 71 - <div id="file-tree" class="col-span-1 pr-2 md:border-r md:border-gray-200"> 72 - {{ $containerstyle := "py-1" }} 73 - {{ $linkstyle := "no-underline hover:underline" }} 74 75 - {{ range .Files }} 76 - {{ if not .IsFile }} 77 - <div class="{{ $containerstyle }}"> 78 - <div class="flex justify-between items-center"> 79 - <a 80 - href="/{{ $.RepoInfo.FullName }}/tree/{{ $.Ref | urlquery }}/{{ .Name }}" 81 - class="{{ $linkstyle }}" 82 - > 83 - <div class="flex items-center gap-2"> 84 - {{ i "folder" "w-3 h-3 fill-current" }} 85 - {{ .Name }} 86 - </div> 87 - </a> 88 89 - <time class="text-xs text-gray-500" 90 - >{{ timeFmt .LastCommit.When }}</time 91 - > 92 </div> 93 - </div> 94 {{ end }} 95 - {{ end }} 96 97 - {{ range .Files }} 98 - {{ if .IsFile }} 99 - <div class="{{ $containerstyle }}"> 100 - <div class="flex justify-between items-center"> 101 - <a 102 - href="/{{ $.RepoInfo.FullName }}/blob/{{ $.Ref | urlquery }}/{{ .Name }}" 103 - class="{{ $linkstyle }}" 104 - > 105 - <div class="flex items-center gap-2"> 106 - {{ i "file" "w-3 h-3" }}{{ .Name }} 107 - </div> 108 - </a> 109 110 - <time class="text-xs text-gray-500" 111 - >{{ timeFmt .LastCommit.When }}</time 112 - > 113 </div> 114 - </div> 115 {{ end }} 116 - {{ end }} 117 - </div> 118 {{ end }} 119 120 121 {{ define "commitLog" }} 122 - <div id="commit-log" class="hidden md:block md:col-span-1"> 123 - {{ range .Commits }} 124 - <div class="relative px-2 pb-8"> 125 - <div id="commit-message"> 126 - {{ $messageParts := splitN .Message "\n\n" 2 }} 127 - <div class="text-base cursor-pointer"> 128 - <div> 129 - <div> 130 - <a 131 - href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash.String }}" 132 - class="inline no-underline hover:underline" 133 - >{{ index $messageParts 0 }}</a 134 - > 135 - {{ if gt (len $messageParts) 1 }} 136 - 137 - <button 138 - class="py-1/2 px-1 bg-gray-200 hover:bg-gray-400 rounded" 139 - hx-on:click="this.parentElement.nextElementSibling.classList.toggle('hidden')" 140 - > 141 - {{ i "ellipsis" "w-3 h-3" }} 142 - </button> 143 - {{ end }} 144 - </div> 145 - {{ if gt (len $messageParts) 1 }} 146 - <p 147 - class="hidden mt-1 text-sm cursor-text pb-2" 148 - > 149 - {{ nl2br (unwrapText (index $messageParts 1)) }} 150 - </p> 151 - {{ end }} 152 - </div> 153 - </div> 154 - </div> 155 156 - <div class="text-xs text-gray-500"> 157 - <span class="font-mono"> 158 - <a 159 - href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash.String }}" 160 - class="text-gray-500 no-underline hover:underline" 161 - >{{ slice .Hash.String 0 8 }}</a 162 - > 163 - </span> 164 - <span 165 - class="mx-2 before:content-['ยท'] before:select-none" 166 - ></span> 167 - <span> 168 - {{ $didOrHandle := index $.EmailToDidOrHandle .Author.Email }} 169 - <a 170 - href="{{ if $didOrHandle }}/{{ $didOrHandle }}{{ else }}mailto:{{ .Author.Email }}{{ end }}" 171 - class="text-gray-500 no-underline hover:underline" 172 - >{{ if $didOrHandle }}{{ $didOrHandle }}{{ else }}{{ .Author.Name }}{{ end }}</a 173 - > 174 - </span> 175 - <div 176 - class="inline-block px-1 select-none after:content-['ยท']" 177 - ></div> 178 - <span>{{ timeFmt .Author.When }}</span> 179 - {{ $tagsForCommit := index $.TagMap .Hash.String }} 180 - {{ if gt (len $tagsForCommit) 0 }} 181 - <div 182 - class="inline-block px-1 select-none after:content-['ยท']" 183 - ></div> 184 - {{ end }} 185 - {{ range $tagsForCommit }} 186 - <span class="text-xs rounded bg-gray-100 text-black font-mono px-2 mx-1/2 inline-flex items-center"> 187 - {{ . }} 188 - </span> 189 - {{ end }} 190 - </div> 191 </div> 192 {{ end }} 193 </div> 194 {{ end }} 195 196 197 {{ define "repoAfter" }} 198 {{- if .HTMLReadme }} 199 - <section class="mt-4 p-6 rounded bg-white w-full mx-auto overflow-auto {{ if not .Raw }} prose {{ end }}"> 200 - <article class="{{ if .Raw }}whitespace-pre{{end}}"> 201 {{ if .Raw }} 202 - <pre>{{ .HTMLReadme }}</pre> 203 {{ else }} 204 {{ .HTMLReadme }} 205 {{ end }} ··· 207 </section> 208 {{- end -}} 209 210 - 211 - <section class="mt-4 p-6 rounded bg-white w-full mx-auto overflow-auto flex flex-col gap-4"> 212 - <div class="flex flex-col gap-2"> 213 - <strong>push</strong> 214 - <div class="md:pl-4 overflow-x-auto whitespace-nowrap"> 215 - <code>git remote add origin git@{{.RepoInfo.Knot}}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code> 216 - </div> 217 - </div> 218 - 219 - <div class="flex flex-col gap-2"> 220 - <strong>clone</strong> 221 - <div class="md:pl-4 flex flex-col gap-2"> 222 - 223 - <div class="flex items-center gap-3"> 224 - <span class="bg-gray-100 p-1 mr-1 font-mono text-sm rounded select-none">HTTP</span> 225 - <div class="overflow-x-auto whitespace-nowrap flex-1"> 226 - <code>git clone https://tangled.sh/{{ .RepoInfo.OwnerWithAt }}/{{ .RepoInfo.Name }}</code> 227 - </div> 228 - </div> 229 - 230 - <div class="flex items-center gap-3"> 231 - <span class="bg-gray-100 p-1 mr-1 font-mono text-sm rounded select-none">SSH</span> 232 - <div class="overflow-x-auto whitespace-nowrap flex-1"> 233 - <code>git clone git@{{.RepoInfo.Knot}}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code> 234 - </div> 235 - </div> 236 - </div> 237 - </div> 238 - 239 - 240 - <p class="py-2 text-gray-500">Note that for self-hosted knots, clone URLs may be different based on your setup.</p> 241 - </section> 242 {{ end }}
··· 1 {{ define "title" }}{{ .RepoInfo.FullName }} at {{ .Ref }}{{ end }} 2 3 {{ define "extrameta" }} 4 + <meta 5 + name="vcs:clone" 6 + content="https://tangled.sh/{{ .RepoInfo.FullName }}" 7 + /> 8 + <meta 9 + name="forge:summary" 10 + content="https://tangled.sh/{{ .RepoInfo.FullName }}" 11 + /> 12 + <meta 13 + name="forge:dir" 14 + content="https://tangled.sh/{{ .RepoInfo.FullName }}/tree/{ref}/{path}" 15 + /> 16 + <meta 17 + name="forge:file" 18 + content="https://tangled.sh/{{ .RepoInfo.FullName }}/blob/{ref}/{path}" 19 + /> 20 + <meta 21 + name="forge:line" 22 + content="https://tangled.sh/{{ .RepoInfo.FullName }}/blob/{ref}/{path}#L{line}" 23 + /> 24 + <meta 25 + name="go-import" 26 + content="tangled.sh/{{ .RepoInfo.FullNameWithoutAt }} git https://tangled.sh/{{ .RepoInfo.FullName }}" 27 + /> 28 {{ end }} 29 30 {{ define "repoContent" }} 31 <main> 32 + <div class="flex items-center justify-between pb-5"> 33 + {{ block "branchSelector" . }}{{ end }} 34 + <div class="flex md:hidden items-center gap-4"> 35 + <a href="/{{ .RepoInfo.FullName }}/commits/{{ .Ref | urlquery }}" class="inline-flex items-center text-sm gap-1"> 36 + {{ i "git-commit-horizontal" "w-4" "h-4" }} {{ .TotalCommits }} 37 + </a> 38 + <a href="/{{ .RepoInfo.FullName }}/branches" class="inline-flex items-center text-sm gap-1"> 39 + {{ i "git-branch" "w-4" "h-4" }} {{ len .Branches }} 40 + </a> 41 + <a href="/{{ .RepoInfo.FullName }}/tags" class="inline-flex items-center text-sm gap-1"> 42 + {{ i "tags" "w-4" "h-4" }} {{ len .Tags }} 43 + </a> 44 + </div> 45 + </div> 46 <div class="grid grid-cols-1 md:grid-cols-2 gap-2"> 47 + {{ block "fileTree" . }}{{ end }} 48 + {{ block "rightInfo" . }}{{ end }} 49 </div> 50 </main> 51 {{ end }} 52 53 {{ define "branchSelector" }} 54 + <select 55 + onchange="window.location.href = '/{{ .RepoInfo.FullName }}/tree/' + encodeURIComponent(this.value)" 56 + class="p-1 border max-w-32 border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700" 57 + > 58 + <optgroup label="branches ({{len .Branches}})" class="bold text-sm"> 59 + {{ range .Branches }} 60 + <option 61 + value="{{ .Reference.Name }}" 62 + class="py-1" 63 + {{ if eq .Reference.Name $.Ref }} 64 + selected 65 + {{ end }} 66 + > 67 + {{ .Reference.Name }} 68 + </option> 69 + {{ end }} 70 + </optgroup> 71 + <optgroup label="tags ({{len .Tags}})" class="bold text-sm"> 72 + {{ range .Tags }} 73 + <option 74 + value="{{ .Reference.Name }}" 75 + class="py-1" 76 + {{ if eq .Reference.Name $.Ref }} 77 + selected 78 + {{ end }} 79 + > 80 + {{ .Reference.Name }} 81 + </option> 82 + {{ else }} 83 + <option class="py-1" disabled>no tags found</option> 84 + {{ end }} 85 + </optgroup> 86 + </select> 87 {{ end }} 88 89 {{ define "fileTree" }} 90 + <div 91 + id="file-tree" 92 + class="col-span-1 pr-2 md:border-r md:border-gray-200 dark:md:border-gray-700" 93 + > 94 + {{ $containerstyle := "py-1" }} 95 + {{ $linkstyle := "no-underline hover:underline dark:text-white" }} 96 97 + {{ range .Files }} 98 + {{ if not .IsFile }} 99 + <div class="{{ $containerstyle }}"> 100 + <div class="flex justify-between items-center"> 101 + <a 102 + href="/{{ $.RepoInfo.FullName }}/tree/{{ $.Ref | urlquery }}/{{ .Name }}" 103 + class="{{ $linkstyle }}" 104 + > 105 + <div class="flex items-center gap-2"> 106 + {{ i "folder" "size-4 fill-current" }} 107 + {{ .Name }} 108 + </div> 109 + </a> 110 111 + <time class="text-xs text-gray-500 dark:text-gray-400" 112 + >{{ timeFmt .LastCommit.When }}</time 113 + > 114 + </div> 115 </div> 116 + {{ end }} 117 {{ end }} 118 119 + {{ range .Files }} 120 + {{ if .IsFile }} 121 + <div class="{{ $containerstyle }}"> 122 + <div class="flex justify-between items-center"> 123 + <a 124 + href="/{{ $.RepoInfo.FullName }}/blob/{{ $.Ref | urlquery }}/{{ .Name }}" 125 + class="{{ $linkstyle }}" 126 + > 127 + <div class="flex items-center gap-2"> 128 + {{ i "file" "size-4" }}{{ .Name }} 129 + </div> 130 + </a> 131 132 + <time class="text-xs text-gray-500 dark:text-gray-400" 133 + >{{ timeFmt .LastCommit.When }}</time 134 + > 135 + </div> 136 </div> 137 + {{ end }} 138 {{ end }} 139 + </div> 140 {{ end }} 141 142 + {{ define "rightInfo" }} 143 + <div id="right-info" class="hidden md:block col-span-1"> 144 + {{ block "commitLog" . }} {{ end }} 145 + {{ block "branchList" . }} {{ end }} 146 + {{ block "tagList" . }} {{ end }} 147 + </div> 148 + {{ end }} 149 150 {{ define "commitLog" }} 151 + <div id="commit-log" class="md:col-span-1 px-2 pb-4"> 152 + <div class="flex justify-between items-center"> 153 + <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"> 154 + <div class="flex gap-2 items-center font-bold"> 155 + {{ i "logs" "w-4 h-4" }} commits 156 + </div> 157 + <span class="hidden group-hover:flex gap-2 items-center text-sm text-gray-500 dark:text-gray-400 "> 158 + view {{ .TotalCommits }} commits {{ i "chevron-right" "w-4 h-4" }} 159 + </span> 160 + </a> 161 + </div> 162 + <div class="flex flex-col gap-6"> 163 + {{ range .CommitsTrunc }} 164 + <div> 165 + <div id="commit-message"> 166 + {{ $messageParts := splitN .Message "\n\n" 2 }} 167 + <div class="text-base cursor-pointer"> 168 + <div> 169 + <div> 170 + <a 171 + href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash.String }}" 172 + class="inline no-underline hover:underline dark:text-white" 173 + >{{ index $messageParts 0 }}</a 174 + > 175 + {{ if gt (len $messageParts) 1 }} 176 177 + <button 178 + class="py-1/2 px-1 bg-gray-200 hover:bg-gray-400 rounded dark:bg-gray-700 dark:hover:bg-gray-600" 179 + hx-on:click="this.parentElement.nextElementSibling.classList.toggle('hidden')" 180 + > 181 + {{ i "ellipsis" "w-3 h-3" }} 182 + </button> 183 + {{ end }} 184 + </div> 185 + {{ if gt (len $messageParts) 1 }} 186 + <p 187 + class="hidden mt-1 text-sm cursor-text pb-2 dark:text-gray-300" 188 + > 189 + {{ nl2br (index $messageParts 1) }} 190 + </p> 191 + {{ end }} 192 </div> 193 + </div> 194 + </div> 195 + 196 + <div class="text-xs text-gray-500 dark:text-gray-400"> 197 + <span class="font-mono"> 198 + <a 199 + href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash.String }}" 200 + class="text-gray-500 dark:text-gray-400 no-underline hover:underline" 201 + >{{ slice .Hash.String 0 8 }}</a 202 + ></span 203 + > 204 + <span 205 + class="mx-2 before:content-['ยท'] before:select-none" 206 + ></span> 207 + <span> 208 + {{ $didOrHandle := index $.EmailToDidOrHandle .Author.Email }} 209 + <a 210 + href="{{ if $didOrHandle }} 211 + /{{ $didOrHandle }} 212 + {{ else }} 213 + mailto:{{ .Author.Email }} 214 + {{ end }}" 215 + class="text-gray-500 dark:text-gray-400 no-underline hover:underline" 216 + >{{ if $didOrHandle }} 217 + {{ $didOrHandle }} 218 + {{ else }} 219 + {{ .Author.Name }} 220 + {{ end }}</a 221 + > 222 + </span> 223 + <div 224 + class="inline-block px-1 select-none after:content-['ยท']" 225 + ></div> 226 + <span>{{ timeFmt .Author.When }}</span> 227 + {{ $tagsForCommit := index $.TagMap .Hash.String }} 228 + {{ if gt (len $tagsForCommit) 0 }} 229 + <div 230 + class="inline-block px-1 select-none after:content-['ยท']" 231 + ></div> 232 + {{ end }} 233 + {{ range $tagsForCommit }} 234 + <span 235 + 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" 236 + > 237 + {{ . }} 238 + </span> 239 + {{ end }} 240 + </div> 241 + </div> 242 {{ end }} 243 + </div> 244 </div> 245 {{ end }} 246 247 + {{ define "branchList" }} 248 + {{ if gt (len .BranchesTrunc) 0 }} 249 + <div id="branches" class="md:col-span-1 px-2 py-4 border-t border-gray-200 dark:border-gray-700"> 250 + <a href="/{{ .RepoInfo.FullName }}/branches" class="flex text-black dark:text-white items-center gap-4 pb-2 no-underline hover:no-underline group"> 251 + <div class="flex gap-2 items-center font-bold"> 252 + {{ i "git-branch" "w-4 h-4" }} branches 253 + </div> 254 + <span class="hidden group-hover:flex gap-2 items-center text-sm text-gray-500 dark:text-gray-400 "> 255 + view {{ len .Branches }} branches {{ i "chevron-right" "w-4 h-4" }} 256 + </span> 257 + </a> 258 + <div class="flex flex-col gap-1"> 259 + {{ range .BranchesTrunc }} 260 + <div class="text-base flex items-center gap-2"> 261 + <a href="/{{ $.RepoInfo.FullName }}/tree/{{ .Reference.Name | urlquery }}" 262 + class="inline no-underline hover:underline dark:text-white"> 263 + {{ .Reference.Name }} 264 + </a> 265 + {{ if .Commit }} 266 + <span class="px-1 text-gray-500 dark:text-gray-400 select-none after:content-['ยท']"></span> 267 + <time class="text-xs text-gray-500 dark:text-gray-400">{{ timeFmt .Commit.Author.When }}</time> 268 + {{ end }} 269 + {{ if .IsDefault }} 270 + <span class="px-1 text-gray-500 dark:text-gray-400 select-none after:content-['ยท']"></span> 271 + <span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 text-xs font-mono">default</span> 272 + {{ end }} 273 + </div> 274 + {{ end }} 275 + </div> 276 + </div> 277 + {{ end }} 278 + {{ end }} 279 + 280 + {{ define "tagList" }} 281 + {{ if gt (len .TagsTrunc) 0 }} 282 + <div id="tags" class="md:col-span-1 px-2 py-4 border-t border-gray-200 dark:border-gray-700"> 283 + <div class="flex justify-between items-center"> 284 + <a href="/{{ .RepoInfo.FullName }}/tags" class="flex text-black dark:text-white items-center gap-4 pb-2 no-underline hover:no-underline group"> 285 + <div class="flex gap-2 items-center font-bold"> 286 + {{ i "tags" "w-4 h-4" }} tags 287 + </div> 288 + <span class="hidden group-hover:flex gap-2 items-center text-sm text-gray-500 dark:text-gray-400 "> 289 + view {{ len .Tags }} tags {{ i "chevron-right" "w-4 h-4" }} 290 + </span> 291 + </a> 292 + </div> 293 + <div class="flex flex-col gap-1"> 294 + {{ range $idx, $tag := .TagsTrunc }} 295 + {{ with $tag }} 296 + <div> 297 + <div class="text-base flex items-center gap-2"> 298 + <a href="/{{ $.RepoInfo.FullName }}/tree/{{ .Reference.Name | urlquery }}" 299 + class="inline no-underline hover:underline dark:text-white"> 300 + {{ .Reference.Name }} 301 + </a> 302 + </div> 303 + <div> 304 + {{ with .Tag }} 305 + <time class="text-xs text-gray-500 dark:text-gray-400">{{ timeFmt .Tagger.When }}</time> 306 + {{ end }} 307 + {{ if eq $idx 0 }} 308 + <span class="px-1 text-gray-500 dark:text-gray-400 select-none after:content-['ยท']"></span> 309 + <span class="bg-gray-200 dark:bg-gray-700 rounded py-1/2 px-1 text-xs font-mono">latest</span> 310 + {{ end }} 311 + </div> 312 + </div> 313 + {{ end }} 314 + {{ end }} 315 + </div> 316 + </div> 317 + {{ end }} 318 + {{ end }} 319 320 {{ define "repoAfter" }} 321 {{- if .HTMLReadme }} 322 + <section 323 + 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 }} 324 + prose dark:prose-invert dark:[&_pre]:bg-gray-900 325 + dark:[&_code]:text-gray-300 dark:[&_pre_code]:bg-gray-900 326 + dark:[&_pre]:border dark:[&_pre]:border-gray-700 327 + {{ end }}" 328 + > 329 + <article class="{{ if .Raw }}whitespace-pre{{ end }}"> 330 {{ if .Raw }} 331 + <pre 332 + class="dark:bg-gray-900 dark:text-gray-200 dark:border dark:border-gray-700 dark:p-4 dark:rounded" 333 + > 334 + {{ .HTMLReadme }}</pre 335 + > 336 {{ else }} 337 {{ .HTMLReadme }} 338 {{ end }} ··· 340 </section> 341 {{- end -}} 342 343 + {{ template "repo/fragments/cloneInstructions" . }} 344 {{ end }}
+52
appview/pages/templates/repo/issues/fragments/editIssueComment.html
···
··· 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> 24 + 25 + <button 26 + class="btn px-2 py-1 flex items-center gap-2 text-sm" 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 + </button> 33 + <button 34 + class="btn px-2 py-1 flex items-center gap-2 text-sm" 35 + hx-get="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/" 36 + hx-target="#comment-container-{{ .CommentId }}" 37 + hx-swap="outerHTML"> 38 + {{ i "x" "w-4 h-4" }} 39 + </button> 40 + <span id="comment-{{.CommentId}}-status"></span> 41 + </div> 42 + 43 + <div> 44 + <textarea 45 + id="edit-textarea-{{ .CommentId }}" 46 + name="body" 47 + class="w-full p-2 border rounded min-h-[100px]">{{ .Body }}</textarea> 48 + </div> 49 + </div> 50 + {{ end }} 51 + {{ end }} 52 +
+59
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" 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 + </button> 49 + {{ end }} 50 + 51 + </div> 52 + {{ if not .Deleted }} 53 + <div class="prose dark:prose-invert"> 54 + {{ .Body | markdown }} 55 + </div> 56 + {{ end }} 57 + </div> 58 + {{ end }} 59 + {{ end }}
+123 -72
appview/pages/templates/repo/issues/issue.html
··· 1 - {{ define "title" }}{{ .Issue.Title }} &middot; issue #{{ .Issue.IssueId }} &middot;{{ .RepoInfo.FullName }}{{ end }} 2 3 {{ define "repoContent" }} 4 <header class="pb-4"> 5 <h1 class="text-2xl"> 6 {{ .Issue.Title }} 7 - <span class="text-gray-500">#{{ .Issue.IssueId }}</span> 8 </h1> 9 </header> 10 11 - {{ $bgColor := "bg-gray-800" }} 12 {{ $icon := "ban" }} 13 {{ if eq .State "open" }} 14 - {{ $bgColor = "bg-green-600" }} 15 {{ $icon = "circle-dot" }} 16 {{ end }} 17 18 <section class="mt-2"> 19 <div class="inline-flex items-center gap-2"> 20 <div id="state" 21 - class="inline-flex items-center rounded px-3 py-1 {{ $bgColor }} text-sm"> 22 {{ i $icon "w-4 h-4 mr-1.5 text-white" }} 23 <span class="text-white">{{ .State }}</span> 24 </div> 25 - <span class="text-gray-500 text-sm"> 26 opened by 27 {{ $owner := didOrHandle .Issue.OwnerDid .IssueOwnerHandle }} 28 <a href="/{{ $owner }}" class="no-underline hover:underline" 29 >{{ $owner }}</a 30 > 31 <span class="px-1 select-none before:content-['\00B7']"></span> 32 - <time>{{ .Issue.Created | timeFmt }}</time> 33 </span> 34 </div> 35 36 {{ if .Issue.Body }} 37 - <article id="body" class="mt-4 prose"> 38 {{ .Issue.Body | markdown }} 39 </article> 40 {{ end }} ··· 42 {{ end }} 43 44 {{ define "repoAfter" }} 45 - {{ if gt (len .Comments) 0 }} 46 - <section id="comments" class="mt-8 space-y-4 relative"> 47 {{ range $index, $comment := .Comments }} 48 <div 49 id="comment-{{ .CommentId }}" 50 - class="rounded bg-white px-6 py-4 relative" 51 - > 52 - {{ if eq $index 0 }} 53 - <div class="absolute left-8 -top-8 w-px h-8 bg-gray-300" ></div> 54 - {{ else }} 55 - <div class="absolute left-8 -top-4 w-px h-4 bg-gray-300" ></div> 56 {{ end }} 57 - <div class="flex items-center gap-2 mb-2 text-gray-500"> 58 - {{ $owner := index $.DidHandleMap .OwnerDid }} 59 - <span class="text-sm"> 60 - <a 61 - href="/{{ $owner }}" 62 - class="no-underline hover:underline" 63 - >{{ $owner }}</a 64 - > 65 - </span> 66 - 67 - <span class="before:content-['ยท']"></span> 68 - <a 69 - href="#{{ .CommentId }}" 70 - class="text-gray-500 text-sm hover:text-gray-500 hover:underline no-underline" 71 - id="{{ .CommentId }}" 72 - > 73 - {{ .Created | timeFmt }} 74 - </a> 75 - </div> 76 - <div class="prose"> 77 - {{ .Body | markdown }} 78 - </div> 79 </div> 80 {{ end }} 81 </section> 82 - {{ end }} 83 84 {{ block "newComment" . }} {{ end }} 85 86 - {{ $isIssueAuthor := and .LoggedInUser (eq .LoggedInUser.Did .Issue.OwnerDid) }} 87 - {{ $isRepoCollaborator := .RepoInfo.Roles.IsCollaborator }} 88 - {{ if or $isIssueAuthor $isRepoCollaborator }} 89 - {{ $action := "close" }} 90 - {{ $icon := "circle-x" }} 91 - {{ $hoverColor := "red" }} 92 - {{ if eq .State "closed" }} 93 - {{ $action = "reopen" }} 94 - {{ $icon = "circle-dot" }} 95 - {{ $hoverColor = "green" }} 96 - {{ end }} 97 - <form 98 - hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/{{ $action }}" 99 - hx-swap="none" 100 - class="mt-8" 101 - > 102 - <button type="submit" class="btn hover:bg-{{ $hoverColor }}-300"> 103 - {{ i $icon "w-4 h-4 mr-2" }} 104 - <span class="text-black">{{ $action }}</span> 105 - </button> 106 - <div id="issue-action" class="error"></div> 107 - </form> 108 - {{ end }} 109 {{ end }} 110 111 {{ define "newComment" }} 112 {{ if .LoggedInUser }} 113 - <div class="bg-white rounded drop-shadow-sm py-4 px-6 relative w-full flex flex-col gap-2 mt-8"> 114 - <div class="absolute left-8 -top-8 w-px h-8 bg-gray-300" ></div> 115 - <div class="text-sm text-gray-500"> 116 {{ didOrHandle .LoggedInUser.Did .LoggedInUser.Handle }} 117 </div> 118 - <form hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment"> 119 <textarea 120 name="body" 121 - class="w-full p-2 rounded border border-gray-200" 122 - placeholder="Add to the discussion..." 123 ></textarea> 124 - <button type="submit" class="btn mt-2">comment</button> 125 <div id="issue-comment"></div> 126 - </form> 127 </div> 128 {{ else }} 129 - <div class="bg-white rounded drop-shadow-sm px-6 py-4 mt-8"> 130 - <div class="absolute left-8 -top-8 w-px h-8 bg-gray-300" ></div> 131 <a href="/login" class="underline">login</a> to join the discussion 132 </div> 133 {{ end }}
··· 1 + {{ define "title" }}{{ .Issue.Title }} &middot; issue #{{ .Issue.IssueId }} &middot; {{ .RepoInfo.FullName }}{{ end }} 2 3 {{ define "repoContent" }} 4 <header class="pb-4"> 5 <h1 class="text-2xl"> 6 {{ .Issue.Title }} 7 + <span class="text-gray-500 dark:text-gray-400">#{{ .Issue.IssueId }}</span> 8 </h1> 9 </header> 10 11 + {{ $bgColor := "bg-gray-800 dark:bg-gray-700" }} 12 {{ $icon := "ban" }} 13 {{ if eq .State "open" }} 14 + {{ $bgColor = "bg-green-600 dark:bg-green-700" }} 15 {{ $icon = "circle-dot" }} 16 {{ end }} 17 18 <section class="mt-2"> 19 <div class="inline-flex items-center gap-2"> 20 <div id="state" 21 + class="inline-flex items-center rounded px-3 py-1 {{ $bgColor }}"> 22 {{ i $icon "w-4 h-4 mr-1.5 text-white" }} 23 <span class="text-white">{{ .State }}</span> 24 </div> 25 + <span class="text-gray-500 dark:text-gray-400 text-sm"> 26 opened by 27 {{ $owner := didOrHandle .Issue.OwnerDid .IssueOwnerHandle }} 28 <a href="/{{ $owner }}" class="no-underline hover:underline" 29 >{{ $owner }}</a 30 > 31 <span class="px-1 select-none before:content-['\00B7']"></span> 32 + <time title="{{ .Issue.Created | longTimeFmt }}"> 33 + {{ .Issue.Created | timeFmt }} 34 + </time> 35 </span> 36 </div> 37 38 {{ if .Issue.Body }} 39 + <article id="body" class="mt-8 prose dark:prose-invert"> 40 {{ .Issue.Body | markdown }} 41 </article> 42 {{ end }} ··· 44 {{ end }} 45 46 {{ define "repoAfter" }} 47 + <section id="comments" class="my-2 mt-2 space-y-2 relative"> 48 {{ range $index, $comment := .Comments }} 49 <div 50 id="comment-{{ .CommentId }}" 51 + 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"> 52 + {{ if gt $index 0 }} 53 + <div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div> 54 {{ end }} 55 + {{ template "repo/issues/fragments/issueComment" (dict "RepoInfo" $.RepoInfo "LoggedInUser" $.LoggedInUser "DidHandleMap" $.DidHandleMap "Issue" $.Issue "Comment" .)}} 56 </div> 57 {{ end }} 58 </section> 59 60 {{ block "newComment" . }} {{ end }} 61 62 {{ end }} 63 64 {{ define "newComment" }} 65 {{ if .LoggedInUser }} 66 + <form 67 + id="comment-form" 68 + hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment" 69 + hx-on::after-request="if(event.detail.successful) this.reset()" 70 + > 71 + <div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-4 px-4 relative w-full md:w-3/5"> 72 + <div class="text-sm pb-2 text-gray-500 dark:text-gray-400"> 73 {{ didOrHandle .LoggedInUser.Did .LoggedInUser.Handle }} 74 </div> 75 <textarea 76 + id="comment-textarea" 77 name="body" 78 + class="w-full p-2 rounded border border-gray-200 dark:border-gray-700" 79 + placeholder="Add to the discussion. Markdown is supported." 80 + onkeyup="updateCommentForm()" 81 ></textarea> 82 <div id="issue-comment"></div> 83 + <div id="issue-action" class="error"></div> 84 + </div> 85 + 86 + <div class="flex gap-2 mt-2"> 87 + <button 88 + id="comment-button" 89 + hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment" 90 + type="submit" 91 + hx-disabled-elt="#comment-button" 92 + class="btn p-2 flex items-center gap-2 no-underline hover:no-underline" 93 + disabled 94 + > 95 + {{ i "message-square-plus" "w-4 h-4" }} 96 + comment 97 + </button> 98 + 99 + {{ $isIssueAuthor := and .LoggedInUser (eq .LoggedInUser.Did .Issue.OwnerDid) }} 100 + {{ $isRepoCollaborator := .RepoInfo.Roles.IsCollaborator }} 101 + {{ if and (or $isIssueAuthor $isRepoCollaborator) (eq .State "open") }} 102 + <button 103 + id="close-button" 104 + type="button" 105 + class="btn flex items-center gap-2" 106 + hx-trigger="click" 107 + > 108 + {{ i "ban" "w-4 h-4" }} 109 + close 110 + </button> 111 + <div 112 + id="close-with-comment" 113 + hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/comment" 114 + hx-trigger="click from:#close-button" 115 + hx-disabled-elt="#close-with-comment" 116 + hx-target="#issue-comment" 117 + hx-vals="js:{body: document.getElementById('comment-textarea').value.trim() !== '' ? document.getElementById('comment-textarea').value : ''}" 118 + hx-swap="none" 119 + > 120 + </div> 121 + <div 122 + id="close-issue" 123 + hx-disabled-elt="#close-issue" 124 + hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/close" 125 + hx-trigger="click from:#close-button" 126 + hx-target="#issue-action" 127 + hx-swap="none" 128 + > 129 + </div> 130 + <script> 131 + document.addEventListener('htmx:configRequest', function(evt) { 132 + if (evt.target.id === 'close-with-comment') { 133 + const commentText = document.getElementById('comment-textarea').value.trim(); 134 + if (commentText === '') { 135 + evt.detail.parameters = {}; 136 + evt.preventDefault(); 137 + } 138 + } 139 + }); 140 + </script> 141 + {{ else if and (or $isIssueAuthor $isRepoCollaborator) (eq .State "closed") }} 142 + <button 143 + type="button" 144 + class="btn flex items-center gap-2" 145 + hx-post="/{{ .RepoInfo.FullName }}/issues/{{ .Issue.IssueId }}/reopen" 146 + hx-swap="none" 147 + > 148 + {{ i "refresh-ccw-dot" "w-4 h-4" }} 149 + reopen 150 + </button> 151 + {{ end }} 152 + 153 + <script> 154 + function updateCommentForm() { 155 + const textarea = document.getElementById('comment-textarea'); 156 + const commentButton = document.getElementById('comment-button'); 157 + const closeButton = document.getElementById('close-button'); 158 + 159 + if (textarea.value.trim() !== '') { 160 + commentButton.removeAttribute('disabled'); 161 + } else { 162 + commentButton.setAttribute('disabled', ''); 163 + } 164 + 165 + if (closeButton) { 166 + if (textarea.value.trim() !== '') { 167 + closeButton.innerHTML = '{{ i "ban" "w-4 h-4" }} close with comment'; 168 + } else { 169 + closeButton.innerHTML = '{{ i "ban" "w-4 h-4" }} close'; 170 + } 171 + } 172 + } 173 + 174 + document.addEventListener('DOMContentLoaded', function() { 175 + updateCommentForm(); 176 + }); 177 + </script> 178 </div> 179 + </form> 180 {{ else }} 181 + <div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-4 px-4 relative w-fit"> 182 <a href="/login" class="underline">login</a> to join the discussion 183 </div> 184 {{ end }}
+71 -24
appview/pages/templates/repo/issues/issues.html
··· 1 {{ define "title" }}issues &middot; {{ .RepoInfo.FullName }}{{ end }} 2 3 {{ define "repoContent" }} 4 - <div class="flex justify-between items-center"> 5 - <p> 6 - filtering 7 - <select class="border px-1 bg-white border-gray-200" onchange="window.location.href = '/{{ .RepoInfo.FullName }}/issues?state=' + this.value"> 8 - <option value="open" {{ if .FilteringByOpen }}selected{{ end }}>open ({{ .RepoInfo.Stats.IssueCount.Open }})</option> 9 - <option value="closed" {{ if not .FilteringByOpen }}selected{{ end }}>closed ({{ .RepoInfo.Stats.IssueCount.Closed }})</option> 10 - </select> 11 - issues 12 - </p> 13 - <a 14 - href="/{{ .RepoInfo.FullName }}/issues/new" 15 - class="btn text-sm flex items-center gap-2 no-underline hover:no-underline"> 16 - {{ i "plus" "w-4 h-4" }} 17 - <span>new issue</span> 18 - </a> 19 - </div> 20 - <div class="error" id="issues"></div> 21 {{ end }} 22 23 {{ define "repoAfter" }} 24 <div class="flex flex-col gap-2 mt-2"> 25 {{ range .Issues }} 26 - <div class="rounded drop-shadow-sm bg-white px-6 py-4"> 27 <div class="pb-2"> 28 <a 29 href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}" ··· 33 <span class="text-gray-500">#{{ .IssueId }}</span> 34 </a> 35 </div> 36 - <p class="text-sm text-gray-500"> 37 - {{ $bgColor := "bg-gray-800" }} 38 {{ $icon := "ban" }} 39 {{ $state := "closed" }} 40 {{ if .Open }} 41 - {{ $bgColor = "bg-green-600" }} 42 {{ $icon = "circle-dot" }} 43 {{ $state = "open" }} 44 {{ end }} 45 46 <span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }} text-sm"> 47 - {{ i $icon "w-3 h-3 mr-1.5 text-white" }} 48 - <span class="text-white">{{ $state }}</span> 49 </span> 50 51 <span> ··· 64 {{ if eq .Metadata.CommentCount 1 }} 65 {{ $s = "" }} 66 {{ end }} 67 - <a href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}" class="text-gray-500">{{ .Metadata.CommentCount }} comment{{$s}}</a> 68 </span> 69 </p> 70 </div> 71 {{ end }} 72 </div> 73 {{ end }}
··· 1 {{ define "title" }}issues &middot; {{ .RepoInfo.FullName }}{{ end }} 2 3 {{ define "repoContent" }} 4 + <div class="flex justify-between items-center gap-4"> 5 + <div class="flex gap-4"> 6 + <a 7 + href="?state=open" 8 + class="flex items-center gap-2 {{ if .FilteringByOpen }}font-bold {{ else }}text-gray-500 dark:text-gray-400{{ end }}" 9 + > 10 + {{ i "circle-dot" "w-4 h-4" }} 11 + <span>{{ .RepoInfo.Stats.IssueCount.Open }} open</span> 12 + </a> 13 + <a 14 + href="?state=closed" 15 + class="flex items-center gap-2 {{ if not .FilteringByOpen }}font-bold {{ else }}text-gray-500 dark:text-gray-400{{ end }}" 16 + > 17 + {{ i "ban" "w-4 h-4" }} 18 + <span>{{ .RepoInfo.Stats.IssueCount.Closed }} closed</span> 19 + </a> 20 + </div> 21 + <a 22 + href="/{{ .RepoInfo.FullName }}/issues/new" 23 + class="btn text-sm flex items-center justify-center gap-2 no-underline hover:no-underline" 24 + > 25 + {{ i "circle-plus" "w-4 h-4" }} 26 + <span>new</span> 27 + </a> 28 + </div> 29 + <div class="error" id="issues"></div> 30 {{ end }} 31 32 {{ define "repoAfter" }} 33 <div class="flex flex-col gap-2 mt-2"> 34 {{ range .Issues }} 35 + <div class="rounded drop-shadow-sm bg-white px-6 py-4 dark:bg-gray-800 dark:border-gray-700"> 36 <div class="pb-2"> 37 <a 38 href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}" ··· 42 <span class="text-gray-500">#{{ .IssueId }}</span> 43 </a> 44 </div> 45 + <p class="text-sm text-gray-500 dark:text-gray-400"> 46 + {{ $bgColor := "bg-gray-800 dark:bg-gray-700" }} 47 {{ $icon := "ban" }} 48 {{ $state := "closed" }} 49 {{ if .Open }} 50 + {{ $bgColor = "bg-green-600 dark:bg-green-700" }} 51 {{ $icon = "circle-dot" }} 52 {{ $state = "open" }} 53 {{ end }} 54 55 <span class="inline-flex items-center rounded px-2 py-[5px] {{ $bgColor }} text-sm"> 56 + {{ i $icon "w-3 h-3 mr-1.5 text-white dark:text-white" }} 57 + <span class="text-white dark:text-white">{{ $state }}</span> 58 </span> 59 60 <span> ··· 73 {{ if eq .Metadata.CommentCount 1 }} 74 {{ $s = "" }} 75 {{ end }} 76 + <a href="/{{ $.RepoInfo.FullName }}/issues/{{ .IssueId }}" class="text-gray-500 dark:text-gray-400">{{ .Metadata.CommentCount }} comment{{$s}}</a> 77 </span> 78 </p> 79 </div> 80 {{ end }} 81 + </div> 82 + 83 + {{ block "pagination" . }} {{ end }} 84 + 85 + {{ end }} 86 + 87 + {{ define "pagination" }} 88 + <div class="flex justify-end mt-4 gap-2"> 89 + {{ $currentState := "closed" }} 90 + {{ if .FilteringByOpen }} 91 + {{ $currentState = "open" }} 92 + {{ end }} 93 + 94 + {{ if gt .Page.Offset 0 }} 95 + {{ $prev := .Page.Previous }} 96 + <a 97 + class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700" 98 + hx-boost="true" 99 + href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&offset={{ $prev.Offset }}&limit={{ $prev.Limit }}" 100 + > 101 + {{ i "chevron-left" "w-4 h-4" }} 102 + previous 103 + </a> 104 + {{ else }} 105 + <div></div> 106 + {{ end }} 107 + 108 + {{ if eq (len .Issues) .Page.Limit }} 109 + {{ $next := .Page.Next }} 110 + <a 111 + class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700" 112 + hx-boost="true" 113 + href = "/{{ $.RepoInfo.FullName }}/issues?state={{ $currentState }}&offset={{ $next.Offset }}&limit={{ $next.Limit }}" 114 + > 115 + next 116 + {{ i "chevron-right" "w-4 h-4" }} 117 + </a> 118 + {{ end }} 119 </div> 120 {{ end }}
+1 -1
appview/pages/templates/repo/issues/new.html
··· 1 - {{ define "title" }}new issue | {{ .RepoInfo.FullName }}{{ end }} 2 3 {{ define "repoContent" }} 4 <form
··· 1 + {{ define "title" }}new issue &middot; {{ .RepoInfo.FullName }}{{ end }} 2 3 {{ define "repoContent" }} 4 <form
+131 -136
appview/pages/templates/repo/log.html
··· 1 {{ define "title" }}commits &middot; {{ .RepoInfo.FullName }}{{ end }} 2 3 {{ define "repoContent" }} 4 - <section id="commit-message"> 5 - {{ $commit := index .Commits 0 }} 6 - {{ $messageParts := splitN $commit.Message "\n\n" 2 }} 7 - <div> 8 - <a href="/{{ $.RepoInfo.FullName }}/commit/{{ $commit.Hash.String }}"> 9 - <p class="pb-5">{{ index $messageParts 0 }}</p> 10 - {{ if gt (len $messageParts) 1 }} 11 - <p class="mt-1 text-sm cursor-text pb-5"> 12 - {{ nl2br (unwrapText (index $messageParts 1)) }} 13 - </p> 14 - {{ end }} 15 - </a> 16 - </div> 17 18 - <div class="text-sm text-gray-500"> 19 - <span class="font-mono"> 20 - <a 21 - href="/{{ $.RepoInfo.FullName }}/commit/{{ $commit.Hash.String }}" 22 - class="text-gray-500 no-underline hover:underline" 23 - >{{ slice $commit.Hash.String }}</a 24 - > 25 - </span> 26 - <span class="mx-2 before:content-['ยท'] before:select-none"></span> 27 - <span> 28 - {{ $didOrHandle := index $.EmailToDidOrHandle $commit.Author.Email }} 29 - {{ if $didOrHandle }} 30 - <a 31 - href="/{{ $didOrHandle }}" 32 - class="text-gray-500 no-underline hover:underline" 33 - >{{ $didOrHandle }}</a 34 - > 35 - {{ else }} 36 - <a 37 - href="mailto:{{ $commit.Author.Email }}" 38 - class="text-gray-500 no-underline hover:underline" 39 - >{{ $commit.Author.Name }}</a 40 - > 41 {{ end }} 42 - </span> 43 - <div 44 - class="inline-block px-1 select-none after:content-['ยท']" 45 - ></div> 46 - <span>{{ timeFmt $commit.Author.When }}</span> 47 - </div> 48 - </section> 49 - {{ end }} 50 51 - {{ define "repoAfter" }} 52 - <main> 53 - <div id="commit-log" class="flex-1 relative"> 54 - <div class="absolute left-8 top-0 bottom-0 w-px bg-gray-300"></div> 55 - {{ $end := length .Commits }} 56 - {{ $commits := subslice .Commits 1 $end }} 57 - {{ range $commits }} 58 - <div class="flex flex-row justify-between items-center"> 59 - <div 60 - class="relative w-full px-4 py-4 mt-4 rounded-sm bg-white" 61 - > 62 - <div id="commit-message"> 63 - {{ $messageParts := splitN .Message "\n\n" 2 }} 64 - <div class="text-base cursor-pointer"> 65 - <div> 66 - <div> 67 - <a 68 - href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash.String }}" 69 - class="inline no-underline hover:underline" 70 - >{{ index $messageParts 0 }}</a 71 - > 72 {{ if gt (len $messageParts) 1 }} 73 - 74 - <button 75 - class="py-1/2 px-1 bg-gray-200 hover:bg-gray-400 rounded" 76 - hx-on:click="this.parentElement.nextElementSibling.classList.toggle('hidden')" 77 - > 78 - {{ i "ellipsis" "w-3 h-3" }} 79 </button> 80 {{ end }} 81 </div> 82 {{ if gt (len $messageParts) 1 }} 83 - <p 84 - class="hidden mt-1 text-sm cursor-text pb-2" 85 - > 86 - {{ nl2br (unwrapText (index $messageParts 1)) }} 87 </p> 88 {{ end }} 89 </div> 90 </div> 91 </div> 92 93 - <div class="text-sm text-gray-500 mt-3"> 94 - <span class="font-mono"> 95 - <a 96 - href="/{{ $.RepoInfo.FullName }}/commit/{{ .Hash.String }}" 97 - class="text-gray-500 no-underline hover:underline" 98 - >{{ slice .Hash.String 0 8 }}</a 99 - > 100 - </span> 101 - <span 102 - class="mx-2 before:content-['ยท'] before:select-none" 103 - ></span> 104 - <span> 105 - {{ $didOrHandle := index $.EmailToDidOrHandle .Author.Email }} 106 - {{ if $didOrHandle }} 107 - <a 108 - href="/{{ $didOrHandle }}" 109 - class="text-gray-500 no-underline hover:underline" 110 - >{{ $didOrHandle }}</a 111 - > 112 - {{ else }} 113 - <a 114 - href="mailto:{{ .Author.Email }}" 115 - class="text-gray-500 no-underline hover:underline" 116 - >{{ .Author.Name }}</a 117 - > 118 - {{ end }} 119 - </span> 120 - <div 121 - class="inline-block px-1 select-none after:content-['ยท']" 122 - ></div> 123 - <span>{{ timeFmt .Author.When }}</span> 124 - </div> 125 - </div> 126 </div> 127 - {{ end }} 128 - </div> 129 130 - {{ $commits_len := len .Commits }} 131 - <div class="flex justify-end mt-4 gap-2"> 132 - {{ if gt .Page 1 }} 133 - <a 134 - class="btn flex items-center gap-2 no-underline hover:no-underline" 135 - hx-boost="true" 136 - onclick="window.location.href = window.location.pathname + '?page={{ sub .Page 1 }}'" 137 - > 138 - {{ i "chevron-left" "w-4 h-4" }} 139 - previous 140 - </a> 141 - {{ else }} 142 - <div></div> 143 - {{ end }} 144 145 - {{ if eq $commits_len 30 }} 146 - <a 147 - class="btn flex items-center gap-2 no-underline hover:no-underline" 148 - hx-boost="true" 149 - onclick="window.location.href = window.location.pathname + '?page={{ add .Page 1 }}'" 150 - > 151 - next 152 - {{ i "chevron-right" "w-4 h-4" }} 153 - </a> 154 - {{ end }} 155 - </div> 156 - </main> 157 {{ end }}
··· 1 {{ define "title" }}commits &middot; {{ .RepoInfo.FullName }}{{ end }} 2 3 {{ define "repoContent" }} 4 + <section id="commit-table" class="overflow-x-auto"> 5 + <h2 class="font-bold text-sm mb-4 uppercase dark:text-white"> 6 + commits 7 + </h2> 8 9 + <!-- desktop view (hidden on small screens) --> 10 + <table class="w-full border-collapse hidden md:table"> 11 + <thead> 12 + <tr> 13 + <th class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold">Author</th> 14 + <th class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold">Commit</th> 15 + <th class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold">Message</th> 16 + <th class="py-2 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold">Date</th> 17 + </tr> 18 + </thead> 19 + <tbody> 20 + {{ range $index, $commit := .Commits }} 21 + {{ $messageParts := splitN $commit.Message "\n\n" 2 }} 22 + <tr class="{{ if ne $index (sub (len $.Commits) 1) }}border-b border-gray-200 dark:border-gray-700{{ end }}"> 23 + <td class=" py-3 align-top"> 24 + {{ $didOrHandle := index $.EmailToDidOrHandle $commit.Author.Email }} 25 + {{ if $didOrHandle }} 26 + <a href="/{{ $didOrHandle }}" class="text-gray-700 dark:text-gray-300 no-underline hover:underline">{{ $didOrHandle }}</a> 27 + {{ else }} 28 + <a href="mailto:{{ $commit.Author.Email }}" class="text-gray-700 dark:text-gray-300 no-underline hover:underline">{{ $commit.Author.Name }}</a> 29 + {{ end }} 30 + </td> 31 + <td class=" py-3 align-top font-mono flex items-end"> 32 + <a href="/{{ $.RepoInfo.FullName }}/commit/{{ $commit.Hash.String }}" class="text-gray-700 dark:text-gray-300 no-underline hover:underline">{{ slice $commit.Hash.String 0 8 }}</a> 33 + <div class="inline-flex"> 34 + <button class="p-1 mx-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded" 35 + title="Copy SHA" 36 + 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)"> 37 + {{ i "copy" "w-4 h-4" }} 38 + </button> 39 + <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"> 40 + {{ i "folder-code" "w-4 h-4" }} 41 + </a> 42 + </div> 43 + </td> 44 + <td class=" py-3 align-top"> 45 + <div> 46 + <div class="flex items-center justify-start"> 47 + <a href="/{{ $.RepoInfo.FullName }}/commit/{{ $commit.Hash.String }}" class="dark:text-white no-underline hover:underline">{{ index $messageParts 0 }}</a> 48 + {{ if gt (len $messageParts) 1 }} 49 + <button class="ml-2 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> 50 + {{ end }} 51 + 52 + 53 + {{ if index $.TagMap $commit.Hash.String }} 54 + {{ range $tag := index $.TagMap $commit.Hash.String }} 55 + <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"> 56 + {{ $tag }} 57 + </span> 58 + {{ end }} 59 + {{ end }} 60 + 61 + </div> 62 + 63 + {{ if gt (len $messageParts) 1 }} 64 + <p class="hidden mt-1 text-sm text-gray-600 dark:text-gray-400">{{ nl2br (index $messageParts 1) }}</p> 65 + {{ end }} 66 + </td> 67 + <td class=" py-3 align-top text-gray-500 dark:text-gray-400">{{ timeFmt $commit.Author.When }}</td> 68 + </tr> 69 {{ end }} 70 + </tbody> 71 + </table> 72 73 + <!-- mobile view (visible only on small screens) --> 74 + <div class="md:hidden"> 75 + {{ range $index, $commit := .Commits }} 76 + <div class="relative p-2 {{ if ne $index (sub (len $.Commits) 1) }}border-b border-gray-200 dark:border-gray-700{{ end }}"> 77 + <div id="commit-message"> 78 + {{ $messageParts := splitN $commit.Message "\n\n" 2 }} 79 + <div class="text-base cursor-pointer"> 80 + <div> 81 + <div class="flex items-center justify-between"> 82 + <div class="flex-1"> 83 + <div class="inline"> 84 + <a href="/{{ $.RepoInfo.FullName }}/commit/{{ $commit.Hash.String }}" 85 + class="inline no-underline hover:underline dark:text-white"> 86 + {{ index $messageParts 0 }} 87 + </a> 88 {{ if gt (len $messageParts) 1 }} 89 + <button 90 + 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" 91 + hx-on:click="this.parentElement.nextElementSibling.classList.toggle('hidden')"> 92 + {{ i "ellipsis" "w-3 h-3" }} 93 </button> 94 {{ end }} 95 + 96 + {{ if index $.TagMap $commit.Hash.String }} 97 + {{ range $tag := index $.TagMap $commit.Hash.String }} 98 + <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"> 99 + {{ $tag }} 100 + </span> 101 + {{ end }} 102 + {{ end }} 103 </div> 104 + 105 {{ if gt (len $messageParts) 1 }} 106 + <p class="hidden mt-1 text-sm cursor-text pb-2 dark:text-gray-300"> 107 + {{ nl2br (index $messageParts 1) }} 108 </p> 109 {{ end }} 110 </div> 111 + <a href="/{{ $.RepoInfo.FullName }}/tree/{{ $commit.Hash.String }}" 112 + class="p-1 mr-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded" 113 + title="Browse repository at this commit"> 114 + {{ i "folder-code" "w-4 h-4" }} 115 + </a> 116 </div> 117 </div> 118 + </div> 119 + </div> 120 121 + <div class="text-xs text-gray-500 dark:text-gray-400"> 122 + <span class="font-mono"> 123 + <a href="/{{ $.RepoInfo.FullName }}/commit/{{ $commit.Hash.String }}" 124 + class="text-gray-500 dark:text-gray-400 no-underline hover:underline"> 125 + {{ slice $commit.Hash.String 0 8 }} 126 + </a> 127 + </span> 128 + <span class="mx-2 before:content-['ยท'] before:select-none"></span> 129 + <span> 130 + {{ $didOrHandle := index $.EmailToDidOrHandle $commit.Author.Email }} 131 + <a href="{{ if $didOrHandle }}/{{ $didOrHandle }}{{ else }}mailto:{{ $commit.Author.Email }}{{ end }}" 132 + class="text-gray-500 dark:text-gray-400 no-underline hover:underline"> 133 + {{ if $didOrHandle }}{{ $didOrHandle }}{{ else }}{{ $commit.Author.Name }}{{ end }} 134 + </a> 135 + </span> 136 + <div class="inline-block px-1 select-none after:content-['ยท']"></div> 137 + <span>{{ shortTimeFmt $commit.Author.When }}</span> 138 </div> 139 + </div> 140 + {{ end }} 141 + </div> 142 + </section> 143 144 + {{ end }} 145 146 + {{ define "repoAfter" }} 147 + {{ $commits_len := len .Commits }} 148 + <div class="flex justify-end mt-4 gap-2"> 149 + {{ if gt .Page 1 }}<a class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700" hx-boost="true" onclick="window.location.href = window.location.pathname + '?page={{ sub .Page 1 }}'">{{ i "chevron-left" "w-4 h-4" }} previous</a>{{ else }}<div></div>{{ end }} 150 + {{ if eq $commits_len 60 }}<a class="btn flex items-center gap-2 no-underline hover:no-underline dark:text-white dark:hover:bg-gray-700" hx-boost="true" onclick="window.location.href = window.location.pathname + '?page={{ add .Page 1 }}'">next {{ i "chevron-right" "w-4 h-4" }}</a>{{ end }} 151 + </div> 152 {{ end }}
+20 -15
appview/pages/templates/repo/new.html
··· 2 3 {{ define "content" }} 4 <div class="p-6"> 5 - <p class="text-xl font-bold">Create a new repository</p> 6 </div> 7 - <div class="p-6 bg-white drop-shadow-sm rounded"> 8 - <form hx-post="/repo/new" class="space-y-12" hx-swap="none"> 9 <div class="space-y-2"> 10 - <label for="name" class="-mb-1">Repository name</label> 11 <input 12 type="text" 13 id="name" 14 name="name" 15 required 16 - class="w-full max-w-md" 17 /> 18 - <p class="text-sm text-gray-500">All repositories are publicly visible.</p> 19 20 - <label for="branch">Default branch</label> 21 <input 22 type="text" 23 id="branch" 24 name="branch" 25 value="main" 26 required 27 - class="w-full max-w-md" 28 /> 29 30 - <label for="description">Description</label> 31 <input 32 type="text" 33 id="description" 34 name="description" 35 - class="w-full max-w-md" 36 /> 37 </div> 38 39 <fieldset class="space-y-3"> 40 - <legend>Select a knot</legend> 41 <div class="space-y-2"> 42 <div class="flex flex-col"> 43 {{ range .Knots }} ··· 49 class="mr-2" 50 id="domain-{{ . }}" 51 /> 52 - <span>{{ . }}</span> 53 </div> 54 {{ else }} 55 - <p>No knots available.</p> 56 {{ end }} 57 </div> 58 </div> 59 - <p class="text-sm text-gray-500">A knot hosts repository data. <a href="/knots" class="underline">Learn how to register your own knot.</a></p> 60 </fieldset> 61 62 <div class="space-y-2"> 63 - <button type="submit" class="btn">create repo</button> 64 <div id="repo" class="error"></div> 65 </div> 66 </form>
··· 2 3 {{ define "content" }} 4 <div class="p-6"> 5 + <p class="text-xl font-bold dark:text-white">Create a new repository</p> 6 </div> 7 + <div class="p-6 bg-white dark:bg-gray-800 drop-shadow-sm rounded"> 8 + <form hx-post="/repo/new" class="space-y-12" hx-swap="none" hx-indicator="#spinner"> 9 <div class="space-y-2"> 10 + <label for="name" class="-mb-1 dark:text-white">Repository name</label> 11 <input 12 type="text" 13 id="name" 14 name="name" 15 required 16 + class="w-full max-w-md dark:bg-gray-700 dark:text-white dark:border-gray-600" 17 /> 18 + <p class="text-sm text-gray-500 dark:text-gray-400">All repositories are publicly visible.</p> 19 20 + <label for="branch" class="dark:text-white">Default branch</label> 21 <input 22 type="text" 23 id="branch" 24 name="branch" 25 value="main" 26 required 27 + class="w-full max-w-md dark:bg-gray-700 dark:text-white dark:border-gray-600" 28 /> 29 30 + <label for="description" class="dark:text-white">Description</label> 31 <input 32 type="text" 33 id="description" 34 name="description" 35 + class="w-full max-w-md dark:bg-gray-700 dark:text-white dark:border-gray-600" 36 /> 37 </div> 38 39 <fieldset class="space-y-3"> 40 + <legend class="dark:text-white">Select a knot</legend> 41 <div class="space-y-2"> 42 <div class="flex flex-col"> 43 {{ range .Knots }} ··· 49 class="mr-2" 50 id="domain-{{ . }}" 51 /> 52 + <span class="dark:text-white">{{ . }}</span> 53 </div> 54 {{ else }} 55 + <p class="dark:text-white">No knots available.</p> 56 {{ end }} 57 </div> 58 </div> 59 + <p class="text-sm text-gray-500 dark:text-gray-400">A knot hosts repository data. <a href="/knots" class="underline">Learn how to register your own knot.</a></p> 60 </fieldset> 61 62 <div class="space-y-2"> 63 + <button type="submit" class="btn flex gap-2 items-center"> 64 + create repo 65 + <span id="spinner" class="group"> 66 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 67 + </span> 68 + </button> 69 <div id="repo" class="error"></div> 70 </div> 71 </form>
+95
appview/pages/templates/repo/pulls/fragments/pullActions.html
···
··· 1 + {{ define "repo/pulls/fragments/pullActions" }} 2 + {{ $lastIdx := sub (len .Pull.Submissions) 1 }} 3 + {{ $roundNumber := .RoundNumber }} 4 + 5 + {{ $isPushAllowed := .RepoInfo.Roles.IsPushAllowed }} 6 + {{ $isMerged := .Pull.State.IsMerged }} 7 + {{ $isClosed := .Pull.State.IsClosed }} 8 + {{ $isOpen := .Pull.State.IsOpen }} 9 + {{ $isConflicted := and .MergeCheck (or .MergeCheck.Error .MergeCheck.IsConflicted) }} 10 + {{ $isPullAuthor := and .LoggedInUser (eq .LoggedInUser.Did .Pull.OwnerDid) }} 11 + {{ $isLastRound := eq $roundNumber $lastIdx }} 12 + {{ $isSameRepoBranch := .Pull.IsBranchBased }} 13 + {{ $isUpToDate := .ResubmitCheck.No }} 14 + <div class="relative w-fit"> 15 + <div id="actions-{{$roundNumber}}" class="flex flex-wrap gap-2"> 16 + <button 17 + hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ $roundNumber }}/comment" 18 + hx-target="#actions-{{$roundNumber}}" 19 + hx-swap="outerHtml" 20 + class="btn p-2 flex items-center gap-2 no-underline hover:no-underline group"> 21 + {{ i "message-square-plus" "w-4 h-4" }} 22 + <span>comment</span> 23 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 24 + </button> 25 + {{ if and $isPushAllowed $isOpen $isLastRound }} 26 + {{ $disabled := "" }} 27 + {{ if $isConflicted }} 28 + {{ $disabled = "disabled" }} 29 + {{ end }} 30 + <button 31 + hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/merge" 32 + hx-swap="none" 33 + hx-confirm="Are you sure you want to merge pull #{{ .Pull.PullId }} into the `{{ .Pull.TargetBranch }}` branch?" 34 + class="btn p-2 flex items-center gap-2 group" {{ $disabled }}> 35 + {{ i "git-merge" "w-4 h-4" }} 36 + <span>merge</span> 37 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 38 + </button> 39 + {{ end }} 40 + 41 + {{ if and $isPullAuthor $isOpen $isLastRound }} 42 + {{ $disabled := "" }} 43 + {{ if $isUpToDate }} 44 + {{ $disabled = "disabled" }} 45 + {{ end }} 46 + <button id="resubmitBtn" 47 + {{ if not .Pull.IsPatchBased }} 48 + hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/resubmit" 49 + {{ else }} 50 + hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/resubmit" 51 + hx-target="#actions-{{$roundNumber}}" 52 + hx-swap="outerHtml" 53 + {{ end }} 54 + 55 + hx-disabled-elt="#resubmitBtn" 56 + class="btn p-2 flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed group" {{ $disabled }} 57 + 58 + {{ if $disabled }} 59 + title="Update this branch to resubmit this pull request" 60 + {{ else }} 61 + title="Resubmit this pull request" 62 + {{ end }} 63 + > 64 + {{ i "rotate-ccw" "w-4 h-4" }} 65 + <span>resubmit</span> 66 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 67 + </button> 68 + {{ end }} 69 + 70 + {{ if and (or $isPullAuthor $isPushAllowed) $isOpen $isLastRound }} 71 + <button 72 + hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/close" 73 + hx-swap="none" 74 + class="btn p-2 flex items-center gap-2 group"> 75 + {{ i "ban" "w-4 h-4" }} 76 + <span>close</span> 77 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 78 + </button> 79 + {{ end }} 80 + 81 + {{ if and (or $isPullAuthor $isPushAllowed) $isClosed $isLastRound }} 82 + <button 83 + hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/reopen" 84 + hx-swap="none" 85 + class="btn p-2 flex items-center gap-2 group"> 86 + {{ i "refresh-ccw-dot" "w-4 h-4" }} 87 + <span>reopen</span> 88 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 89 + </button> 90 + {{ end }} 91 + </div> 92 + </div> 93 + {{ end }} 94 + 95 +
+25
appview/pages/templates/repo/pulls/fragments/pullCompareBranches.html
···
··· 1 + {{ define "repo/pulls/fragments/pullCompareBranches" }} 2 + <div id="patch-upload"> 3 + <label for="targetBranch" class="dark:text-white" 4 + >select a branch</label 5 + > 6 + <div class="flex flex-wrap gap-2 items-center"> 7 + <select 8 + name="sourceBranch" 9 + class="p-1 border border-gray-200 bg-white dark:bg-gray-700 dark:text-white dark:border-gray-600" 10 + > 11 + <option disabled selected>source branch</option> 12 + {{ range .Branches }} 13 + <option value="{{ .Reference.Name }}" class="py-1"> 14 + {{ .Reference.Name }} 15 + </option> 16 + {{ end }} 17 + </select> 18 + </div> 19 + </div> 20 + 21 + <p class="mt-4"> 22 + Title and description are optional; if left out, they will be extracted 23 + from the first commit. 24 + </p> 25 + {{ end }}
+46
appview/pages/templates/repo/pulls/fragments/pullCompareForks.html
···
··· 1 + {{ define "repo/pulls/fragments/pullCompareForks" }} 2 + <div id="patch-upload"> 3 + <label for="forkSelect" class="dark:text-white" 4 + >select a fork to compare</label 5 + > 6 + <div class="flex flex-wrap gap-4 items-center mb-4"> 7 + <div class="flex flex-wrap gap-2 items-center"> 8 + <select 9 + id="forkSelect" 10 + name="fork" 11 + required 12 + class="p-1 border border-gray-200 bg-white dark:bg-gray-700 dark:text-white dark:border-gray-600" 13 + hx-get="/{{ $.RepoInfo.FullName }}/pulls/new/fork-branches" 14 + hx-target="#branch-selection" 15 + hx-vals='{"fork": this.value}' 16 + hx-swap="innerHTML" 17 + onchange="document.getElementById('hiddenForkInput').value = this.value;" 18 + > 19 + <option disabled selected>select a fork</option> 20 + {{ range .Forks }} 21 + <option value="{{ .Name }}" class="py-1"> 22 + {{ .Name }} 23 + </option> 24 + {{ end }} 25 + </select> 26 + 27 + <input 28 + type="hidden" 29 + id="hiddenForkInput" 30 + name="fork" 31 + value="" 32 + /> 33 + </div> 34 + 35 + <div id="branch-selection"> 36 + <div class="text-sm text-gray-500 dark:text-gray-400"> 37 + Select a fork first to view available branches 38 + </div> 39 + </div> 40 + </div> 41 + </div> 42 + <p class="mt-4"> 43 + Title and description are optional; if left out, they will be extracted 44 + from the first commit. 45 + </p> 46 + {{ end }}
+15
appview/pages/templates/repo/pulls/fragments/pullCompareForksBranches.html
···
··· 1 + {{ define "repo/pulls/fragments/pullCompareForksBranches" }} 2 + <div class="flex flex-wrap gap-2 items-center"> 3 + <select 4 + name="sourceBranch" 5 + class="p-1 border border-gray-200 bg-white dark:bg-gray-700 dark:text-white dark:border-gray-600" 6 + > 7 + <option disabled selected>source branch</option> 8 + {{ range .SourceBranches }} 9 + <option value="{{ .Reference.Name }}" class="py-1"> 10 + {{ .Reference.Name }} 11 + </option> 12 + {{ end }} 13 + </select> 14 + </div> 15 + {{ end }}
+68
appview/pages/templates/repo/pulls/fragments/pullHeader.html
···
··· 1 + {{ define "repo/pulls/fragments/pullHeader" }} 2 + <header class="pb-4"> 3 + <h1 class="text-2xl dark:text-white"> 4 + {{ .Pull.Title }} 5 + <span class="text-gray-500 dark:text-gray-400">#{{ .Pull.PullId }}</span> 6 + </h1> 7 + </header> 8 + 9 + {{ $bgColor := "bg-gray-800 dark:bg-gray-700" }} 10 + {{ $icon := "ban" }} 11 + 12 + {{ if .Pull.State.IsOpen }} 13 + {{ $bgColor = "bg-green-600 dark:bg-green-700" }} 14 + {{ $icon = "git-pull-request" }} 15 + {{ else if .Pull.State.IsMerged }} 16 + {{ $bgColor = "bg-purple-600 dark:bg-purple-700" }} 17 + {{ $icon = "git-merge" }} 18 + {{ end }} 19 + 20 + <section class="mt-2"> 21 + <div class="flex items-center gap-2"> 22 + <div 23 + id="state" 24 + class="inline-flex items-center rounded px-3 py-1 {{ $bgColor }}" 25 + > 26 + {{ i $icon "w-4 h-4 mr-1.5 text-white" }} 27 + <span class="text-white">{{ .Pull.State.String }}</span> 28 + </div> 29 + <span class="text-gray-500 dark:text-gray-400 text-sm"> 30 + opened by 31 + {{ $owner := index $.DidHandleMap .Pull.OwnerDid }} 32 + <a href="/{{ $owner }}" class="no-underline hover:underline" 33 + >{{ $owner }}</a 34 + > 35 + <span class="select-none before:content-['\00B7']"></span> 36 + <time>{{ .Pull.Created | timeFmt }}</time> 37 + <span class="select-none before:content-['\00B7']"></span> 38 + <span> 39 + targeting 40 + <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"> 41 + <a href="/{{ .RepoInfo.FullName }}/tree/{{ .Pull.TargetBranch }}" class="no-underline hover:underline">{{ .Pull.TargetBranch }}</a> 42 + </span> 43 + </span> 44 + {{ if not .Pull.IsPatchBased }} 45 + from 46 + <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"> 47 + {{ if .Pull.IsForkBased }} 48 + {{ if .Pull.PullSource.Repo }} 49 + <a href="/{{ $owner }}/{{ .Pull.PullSource.Repo.Name }}" class="no-underline hover:underline">{{ $owner }}/{{ .Pull.PullSource.Repo.Name }}</a>: 50 + {{- else -}} 51 + <span class="italic">[deleted fork]</span> 52 + {{- end -}} 53 + {{- end -}} 54 + {{- .Pull.PullSource.Branch -}} 55 + </span> 56 + {{ end }} 57 + </span> 58 + </div> 59 + 60 + {{ if .Pull.Body }} 61 + <article id="body" class="mt-8 prose dark:prose-invert"> 62 + {{ .Pull.Body | markdown }} 63 + </article> 64 + {{ end }} 65 + </section> 66 + 67 + 68 + {{ end }}
+32
appview/pages/templates/repo/pulls/fragments/pullNewComment.html
···
··· 1 + {{ define "repo/pulls/fragments/pullNewComment" }} 2 + <div 3 + id="pull-comment-card-{{ .RoundNumber }}" 4 + class="bg-white dark:bg-gray-800 rounded drop-shadow-sm p-4 relative w-full flex flex-col gap-2"> 5 + <div class="text-sm text-gray-500 dark:text-gray-400"> 6 + {{ didOrHandle .LoggedInUser.Did .LoggedInUser.Handle }} 7 + </div> 8 + <form 9 + hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .RoundNumber }}/comment" 10 + hx-swap="none" 11 + class="w-full flex flex-wrap gap-2"> 12 + <textarea 13 + name="body" 14 + class="w-full p-2 rounded border border-gray-200" 15 + placeholder="Add to the discussion..."></textarea> 16 + <button type="submit" class="btn flex items-center gap-2"> 17 + {{ i "message-square" "w-4 h-4" }} comment 18 + </button> 19 + <button 20 + type="button" 21 + class="btn flex items-center gap-2" 22 + hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .RoundNumber }}/actions" 23 + hx-swap="outerHTML" 24 + hx-target="#pull-comment-card-{{ .RoundNumber }}"> 25 + {{ i "x" "w-4 h-4" }} 26 + <span>cancel</span> 27 + </button> 28 + <div id="pull-comment"></div> 29 + </form> 30 + </div> 31 + {{ end }} 32 +
+21
appview/pages/templates/repo/pulls/fragments/pullPatchUpload.html
···
··· 1 + {{ define "repo/pulls/fragments/pullPatchUpload" }} 2 + <div id="patch-upload"> 3 + <p> 4 + You can paste a <code>git diff</code> or a 5 + <code>git format-patch</code> patch series here. 6 + </p> 7 + <textarea 8 + hx-trigger="keyup changed delay:500ms, paste delay:500ms" 9 + hx-post="/{{ .RepoInfo.FullName }}/pulls/new/validate-patch" 10 + hx-swap="none" 11 + name="patch" 12 + id="patch" 13 + rows="12" 14 + class="w-full mt-2 resize-y font-mono dark:bg-gray-700 dark:text-white dark:border-gray-600" 15 + placeholder="diff --git a/file.txt b/file.txt 16 + index 1234567..abcdefg 100644 17 + --- a/file.txt 18 + +++ b/file.txt" 19 + ></textarea> 20 + </div> 21 + {{ end }}
+52
appview/pages/templates/repo/pulls/fragments/pullResubmit.html
···
··· 1 + {{ define "repo/pulls/fragments/pullResubmit" }} 2 + <div 3 + id="resubmit-pull-card" 4 + class="rounded relative border bg-amber-50 dark:bg-amber-900 border-amber-200 dark:border-amber-500 px-6 py-2"> 5 + 6 + <div class="flex items-center gap-2 text-amber-500 dark:text-amber-50"> 7 + {{ i "pencil" "w-4 h-4" }} 8 + <span class="font-medium">resubmit your patch</span> 9 + </div> 10 + 11 + <div class="mt-2 text-sm text-gray-700 dark:text-gray-200"> 12 + You can update this patch to address any reviews. 13 + This will begin a new round of reviews, 14 + but you'll still be able to view your previous submissions and feedback. 15 + </div> 16 + 17 + <div class="mt-4 flex flex-col"> 18 + <form 19 + hx-post="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/resubmit" 20 + hx-swap="none" 21 + class="w-full flex flex-wrap gap-2"> 22 + <textarea 23 + name="patch" 24 + class="w-full p-2 mb-2" 25 + placeholder="Paste your updated patch here." 26 + rows="15" 27 + >{{.Pull.LatestPatch}}</textarea> 28 + <button 29 + type="submit" 30 + class="btn flex items-center gap-2" 31 + {{ if or .Pull.State.IsClosed }} 32 + disabled 33 + {{ end }}> 34 + {{ i "rotate-ccw" "w-4 h-4" }} 35 + <span>resubmit</span> 36 + </button> 37 + <button 38 + type="button" 39 + class="btn flex items-center gap-2" 40 + hx-get="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .Pull.LastRoundNumber }}/actions" 41 + hx-swap="outerHTML" 42 + hx-target="#resubmit-pull-card"> 43 + {{ i "x" "w-4 h-4" }} 44 + <span>cancel</span> 45 + </button> 46 + </form> 47 + 48 + <div id="resubmit-error" class="error"></div> 49 + <div id="resubmit-success" class="success"></div> 50 + </div> 51 + </div> 52 + {{ end }}
+25
appview/pages/templates/repo/pulls/interdiff.html
···
··· 1 + {{ define "title" }} 2 + interdiff of round #{{ .Round }} and #{{ sub .Round 1 }}; pull #{{ .Pull.PullId }} &middot; {{ .RepoInfo.FullName }} 3 + {{ end }} 4 + 5 + {{ define "content" }} 6 + <section class="rounded drop-shadow-sm bg-white dark:bg-gray-800 py-4 px-6 dark:text-white"> 7 + <header class="pb-2"> 8 + <div class="flex gap-3 items-center mb-3"> 9 + <a href="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/" class="flex items-center gap-2 font-medium"> 10 + {{ i "arrow-left" "w-5 h-5" }} 11 + back 12 + </a> 13 + <span class="select-none before:content-['\00B7']"></span> 14 + interdiff of round #{{ .Round }} and #{{ sub .Round 1 }} 15 + </div> 16 + <div class="border-t border-gray-200 dark:border-gray-700 my-2"></div> 17 + {{ template "repo/pulls/fragments/pullHeader" . }} 18 + </header> 19 + </section> 20 + 21 + <section> 22 + {{ template "repo/fragments/interdiff" (list .RepoInfo.FullName .Interdiff) }} 23 + </section> 24 + {{ end }} 25 +
+85 -39
appview/pages/templates/repo/pulls/new.html
··· 1 - {{ define "title" }}new pull | {{ .RepoInfo.FullName }}{{ end }} 2 3 {{ define "repoContent" }} 4 - <section class="prose"> 5 - <p> 6 - This is v1 of the pull request flow. Paste your patch in the form below. 7 - Here are the steps to get you started: 8 - <ul class="list-decimal pl-10 space-y-2 text-gray-700"> 9 - <li class="leading-relaxed">Clone this repository.</li> 10 - <li class="leading-relaxed">Make your changes in your local repository.</li> 11 - <li class="leading-relaxed">Grab the diff using <code class="bg-gray-100 px-1 py-0.5 rounded text-gray-800 font-mono text-sm">git diff</code>.</li> 12 - <li class="leading-relaxed">Paste the diff output in the form below.</li> 13 - </ul> 14 - </p> 15 - </section> 16 <form 17 hx-post="/{{ .RepoInfo.FullName }}/pulls/new" 18 class="mt-6 space-y-6" 19 hx-swap="none" 20 > 21 <div class="flex flex-col gap-4"> 22 - <div> 23 - <label for="title">write a title</label> 24 - <input type="text" name="title" id="title" class="w-full" /> 25 26 - <label for="targetBranch">select a target branch</label> 27 - <p class="text-gray-500"> 28 - The branch you want to make your change against. 29 - </p> 30 <select 31 name="targetBranch" 32 - class="p-1 mb-2 border border-gray-200 bg-white" 33 > 34 - <option disabled selected>select a branch</option> 35 {{ range .Branches }} 36 - <option value="{{ .Reference.Name }}" class="py-1"> 37 {{ .Reference.Name }} 38 </option> 39 {{ end }} 40 </select> 41 - <label for="body">add a description</label> 42 <textarea 43 name="body" 44 id="body" 45 rows="6" 46 - class="w-full resize-y" 47 placeholder="Describe your change. Markdown is supported." 48 ></textarea> 49 50 - <div class="mt-4"> 51 - <label for="patch">paste your patch here</label> 52 - <textarea 53 - name="patch" 54 - id="patch" 55 - rows="10" 56 - class="w-full resize-y font-mono" 57 - placeholder="Paste your git diff output here." 58 - ></textarea> 59 - </div> 60 - </div> 61 - <div> 62 - <button type="submit" class="btn">create</button> 63 </div> 64 </div> 65 - <div id="pull" class="error"></div> 66 </form> 67 {{ end }}
··· 1 + {{ define "title" }}new pull &middot; {{ .RepoInfo.FullName }}{{ end }} 2 3 {{ define "repoContent" }} 4 <form 5 hx-post="/{{ .RepoInfo.FullName }}/pulls/new" 6 class="mt-6 space-y-6" 7 hx-swap="none" 8 > 9 <div class="flex flex-col gap-4"> 10 + <label>configure your pull request</label> 11 12 + <p>First, choose a target branch on {{ .RepoInfo.FullName }}.</p> 13 + <div class="pb-2"> 14 <select 15 + required 16 name="targetBranch" 17 + class="p-1 border border-gray-200 bg-white dark:bg-gray-700 dark:text-white dark:border-gray-600" 18 > 19 + <option disabled selected>target branch</option> 20 {{ range .Branches }} 21 + <option value="{{ .Reference.Name }}" class="py-1" {{if .IsDefault}}selected{{end}}> 22 {{ .Reference.Name }} 23 </option> 24 {{ end }} 25 </select> 26 + </div> 27 + 28 + <p>Next, choose a pull strategy.</p> 29 + <nav class="flex space-x-4 items-end"> 30 + <button 31 + type="button" 32 + class="px-3 py-2 pb-2 btn" 33 + hx-get="/{{ .RepoInfo.FullName }}/pulls/new/patch-upload" 34 + hx-target="#patch-strategy" 35 + hx-swap="innerHTML" 36 + > 37 + paste patch 38 + </button> 39 + 40 + {{ if .RepoInfo.Roles.IsPushAllowed }} 41 + <span class="text-sm text-gray-500 dark:text-gray-400 pb-2"> 42 + or 43 + </span> 44 + <button 45 + type="button" 46 + class="px-3 py-2 pb-2 btn" 47 + hx-get="/{{ .RepoInfo.FullName }}/pulls/new/compare-branches" 48 + hx-target="#patch-strategy" 49 + hx-swap="innerHTML" 50 + > 51 + compare branches 52 + </button> 53 + {{ end }} 54 + 55 + 56 + <span class="text-sm text-gray-500 dark:text-gray-400 pb-2"> 57 + or 58 + </span> 59 + <button 60 + type="button" 61 + class="px-3 py-2 pb-2 btn" 62 + hx-get="/{{ .RepoInfo.FullName }}/pulls/new/compare-forks" 63 + hx-target="#patch-strategy" 64 + hx-swap="innerHTML" 65 + > 66 + compare forks 67 + </button> 68 + </nav> 69 + 70 + <section id="patch-strategy"> 71 + {{ template "repo/pulls/fragments/pullPatchUpload" . }} 72 + </section> 73 + 74 + <p id="patch-preview"></p> 75 + 76 + <div id="patch-error" class="error dark:text-red-300"></div> 77 + 78 + <div> 79 + <label for="title" class="dark:text-white">write a title</label> 80 + 81 + <input 82 + type="text" 83 + name="title" 84 + id="title" 85 + class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600" 86 + placeholder="One-line summary of your change." 87 + /> 88 + </div> 89 + 90 + <div> 91 + <label for="body" class="dark:text-white" 92 + >add a description</label 93 + > 94 + 95 <textarea 96 name="body" 97 id="body" 98 rows="6" 99 + class="w-full resize-y dark:bg-gray-700 dark:text-white dark:border-gray-600" 100 placeholder="Describe your change. Markdown is supported." 101 ></textarea> 102 + </div> 103 104 + <div class="flex justify-start items-center gap-2 mt-4"> 105 + <button type="submit" class="btn flex items-center gap-2"> 106 + {{ i "git-pull-request-create" "w-4 h-4" }} 107 + create pull 108 + </button> 109 </div> 110 </div> 111 + <div id="pull" class="error dark:text-red-300"></div> 112 </form> 113 {{ end }}
+22 -83
appview/pages/templates/repo/pulls/patch.html
··· 1 {{ define "title" }} 2 - {{ $oneIndexedRound := add .Round 1 }} 3 - patch of {{ .Pull.Title }} &middot; round #{{ $oneIndexedRound }} &middot; pull #{{ .Pull.PullId }} &middot; {{ .RepoInfo.FullName }} 4 {{ end }} 5 6 {{ define "content" }} 7 - {{ $oneIndexedRound := add .Round 1 }} 8 - {{ $stat := .Diff.Stat }} 9 - <div class="rounded drop-shadow-sm bg-white py-4 px-6"> 10 - <header class="pb-2"> 11 - <div class="flex gap-3 items-center mb-3"> 12 - <a href="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/" class="flex items-center gap-2 font-medium"> 13 - {{ i "arrow-left" "w-5 h-5" }} 14 - back 15 - </a> 16 - <span class="select-none before:content-['\00B7']"></span> 17 - round #{{ $oneIndexedRound }} 18 - </div> 19 - <div class="border-t border-gray-200 my-2"></div> 20 - <h1 class="text-2xl mt-3"> 21 - {{ .Pull.Title }} 22 - <span class="text-gray-500">#{{ .Pull.PullId }}</span> 23 - </h1> 24 - </header> 25 - 26 - {{ $bgColor := "bg-gray-800" }} 27 - {{ $icon := "ban" }} 28 - 29 - {{ if .Pull.State.IsOpen }} 30 - {{ $bgColor = "bg-green-600" }} 31 - {{ $icon = "git-pull-request" }} 32 - {{ else if .Pull.State.IsMerged }} 33 - {{ $bgColor = "bg-purple-600" }} 34 - {{ $icon = "git-merge" }} 35 - {{ end }} 36 - 37 - <section> 38 - <div class="flex items-center gap-2"> 39 - <div 40 - id="state" 41 - class="inline-flex items-center rounded px-3 py-1 {{ $bgColor }}" 42 - > 43 - {{ i $icon "w-4 h-4 mr-1.5 text-white" }} 44 - <span class="text-white">{{ .Pull.State.String }}</span> 45 - </div> 46 - <span class="text-gray-500 text-sm"> 47 - opened by 48 - {{ $owner := index $.DidHandleMap .Pull.OwnerDid }} 49 - <a href="/{{ $owner }}" class="no-underline hover:underline" 50 - >{{ $owner }}</a 51 - > 52 - <span class="select-none before:content-['\00B7']"></span> 53 - <time>{{ .Pull.Created | timeFmt }}</time> 54 - <span class="select-none before:content-['\00B7']"></span> 55 - <span>targeting branch 56 - <span class="text-xs rounded bg-gray-100 text-black font-mono px-2 mx-1/2 inline-flex items-center"> 57 - {{ .Pull.TargetBranch }} 58 - </span> 59 - </span> 60 - </span> 61 - </div> 62 - 63 - {{ if .Pull.Body }} 64 - <article id="body" class="mt-2 prose"> 65 - {{ .Pull.Body | markdown }} 66 - </article> 67 - {{ end }} 68 - </section> 69 - 70 - <div id="diff-stat"> 71 - <br> 72 - <strong class="text-sm uppercase mb-4">Changed files</strong> 73 - {{ range .Diff.Diff }} 74 - <ul> 75 - {{ if .IsDelete }} 76 - <li><a href="#file-{{ .Name.Old }}">{{ .Name.Old }}</a></li> 77 - {{ else }} 78 - <li><a href="#file-{{ .Name.New }}">{{ .Name.New }}</a></li> 79 - {{ end }} 80 - </ul> 81 - {{ end }} 82 - </div> 83 - </div> 84 - 85 - <section> 86 - {{ template "fragments/diff" (list .RepoInfo.FullName .Diff) }} 87 - </section> 88 {{ end }}
··· 1 {{ define "title" }} 2 + patch of {{ .Pull.Title }} &middot; round #{{ .Round }} &middot; pull #{{ .Pull.PullId }} &middot; {{ .RepoInfo.FullName }} 3 {{ end }} 4 5 {{ define "content" }} 6 + <section> 7 + <section 8 + class="bg-white dark:bg-gray-800 p-6 rounded relative z-20 w-full mx-auto drop-shadow-sm dark:text-white" 9 + > 10 + <div class="flex gap-3 items-center mb-3"> 11 + <a href="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/" class="flex items-center gap-2 font-medium"> 12 + {{ i "arrow-left" "w-5 h-5" }} 13 + back 14 + </a> 15 + <span class="select-none before:content-['\00B7']"></span> 16 + round<span class="flex items-center">{{ i "hash" "w-4 h-4" }}{{ .Round }}</span> 17 + <span class="select-none before:content-['\00B7']"></span> 18 + <a href="/{{ .RepoInfo.FullName }}/pulls/{{ .Pull.PullId }}/round/{{ .Round }}.patch"> 19 + view raw 20 + </a> 21 + </div> 22 + <div class="border-t border-gray-200 dark:border-gray-700 my-2"></div> 23 + {{ template "repo/pulls/fragments/pullHeader" . }} 24 + </section> 25 + {{ template "repo/fragments/diff" (list .RepoInfo.FullName .Diff) }} 26 + </section> 27 {{ end }}
+127 -93
appview/pages/templates/repo/pulls/pull.html
··· 3 {{ end }} 4 5 {{ define "repoContent" }} 6 - <header class="pb-4"> 7 - <h1 class="text-2xl"> 8 - {{ .Pull.Title }} 9 - <span class="text-gray-500">#{{ .Pull.PullId }}</span> 10 - </h1> 11 - </header> 12 - 13 - {{ $bgColor := "bg-gray-800" }} 14 - {{ $icon := "ban" }} 15 - 16 - {{ if .Pull.State.IsOpen }} 17 - {{ $bgColor = "bg-green-600" }} 18 - {{ $icon = "git-pull-request" }} 19 - {{ else if .Pull.State.IsMerged }} 20 - {{ $bgColor = "bg-purple-600" }} 21 - {{ $icon = "git-merge" }} 22 - {{ end }} 23 - 24 - <section> 25 - <div class="flex items-center gap-2"> 26 - <div 27 - id="state" 28 - class="inline-flex items-center rounded px-3 py-1 {{ $bgColor }}" 29 - > 30 - {{ i $icon "w-4 h-4 mr-1.5 text-white" }} 31 - <span class="text-white">{{ .Pull.State.String }}</span> 32 - </div> 33 - <span class="text-gray-500 text-sm"> 34 - opened by 35 - {{ $owner := index $.DidHandleMap .Pull.OwnerDid }} 36 - <a href="/{{ $owner }}" class="no-underline hover:underline" 37 - >{{ $owner }}</a 38 - > 39 - <span class="select-none before:content-['\00B7']"></span> 40 - <time>{{ .Pull.Created | timeFmt }}</time> 41 - <span class="select-none before:content-['\00B7']"></span> 42 - <span>targeting branch 43 - <span class="text-xs rounded bg-gray-100 text-black font-mono px-2 mx-1/2 inline-flex items-center"> 44 - {{ .Pull.TargetBranch }} 45 - </span> 46 - </span> 47 - </span> 48 - </div> 49 - 50 - {{ if .Pull.Body }} 51 - <article id="body" class="mt-2 prose"> 52 - {{ .Pull.Body | markdown }} 53 - </article> 54 - {{ end }} 55 - </section> 56 - 57 {{ end }} 58 59 {{ define "repoAfter" }} ··· 72 {{ $targetBranch := .Pull.TargetBranch }} 73 {{ $repoName := .RepoInfo.FullName }} 74 {{ range $idx, $item := .Pull.Submissions }} 75 - {{ $diff := $item.AsNiceDiff $targetBranch }} 76 {{ with $item }} 77 - {{ $oneIndexedRound := add .RoundNumber 1 }} 78 <details {{ if eq $idx $lastIdx }}open{{ end }}> 79 - <summary id="round-#{{ $oneIndexedRound }}" class="list-none cursor-pointer"> 80 <div class="flex flex-wrap gap-2 items-center"> 81 <!-- round number --> 82 - <div class="rounded bg-white drop-shadow-sm px-3 py-2"> 83 - #{{ $oneIndexedRound }} 84 </div> 85 <!-- round summary --> 86 - <div class="rounded drop-shadow-sm bg-white p-2 text-gray-500"> 87 <span> 88 {{ $owner := index $.DidHandleMap $.Pull.OwnerDid }} 89 {{ $re := "re" }} ··· 93 <span class="hidden md:inline">{{$re}}submitted</span> 94 by <a href="/{{ $owner }}">{{ $owner }}</a> 95 <span class="select-none before:content-['\00B7']"></span> 96 - <a class="text-gray-500 hover:text-gray-500" href="#round-#{{ $oneIndexedRound }}"><time>{{ .Created | shortTimeFmt }}</time></a> 97 <span class="select-none before:content-['ยท']"></span> 98 {{ $s := "s" }} 99 {{ if eq (len .Comments) 1 }} ··· 102 {{ len .Comments }} comment{{$s}} 103 </span> 104 </div> 105 - <!-- view patch --> 106 - <a class="btn flex items-center gap-2 no-underline hover:no-underline p-2" 107 hx-boost="true" 108 href="/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/round/{{.RoundNumber}}"> 109 - {{ i "file-diff" "w-4 h-4" }} <span class="hidden md:inline">view patch</span> 110 </a> 111 </div> 112 </summary> 113 - <div class="md:pl-12 flex flex-col gap-2 mt-2 relative"> 114 - {{ range .Comments }} 115 - <div id="comment-{{.ID}}" class="bg-white rounded drop-shadow-sm py-2 px-4 relative w-fit"> 116 - <div class="absolute left-8 -top-2 w-px h-2 bg-gray-300"></div> 117 - <div class="text-sm text-gray-500"> 118 - {{ $owner := index $.DidHandleMap .OwnerDid }} 119 <a href="/{{$owner}}">{{$owner}}</a> 120 <span class="before:content-['ยท']"></span> 121 - <a class="text-gray-500 hover:text-gray-500" href="#comment-{{.ID}}"><time>{{ .Created | shortTimeFmt }}</time></a> 122 </div> 123 - <div class="prose"> 124 - {{ .Body | markdown }} 125 </div> 126 </div> 127 {{ end }} 128 129 {{ if eq $lastIdx .RoundNumber }} 130 {{ block "mergeStatus" $ }} {{ end }} 131 {{ end }} 132 133 {{ if $.LoggedInUser }} 134 - {{ template "fragments/pullActions" (dict "LoggedInUser" $.LoggedInUser "Pull" $.Pull "RepoInfo" $.RepoInfo "RoundNumber" .RoundNumber "MergeCheck" $.MergeCheck) }} 135 {{ else }} 136 - <div class="bg-white rounded drop-shadow-sm px-6 py-4 w-fit"> 137 - <div class="absolute left-8 -top-2 w-px h-2 bg-gray-300"></div> 138 <a href="/login" class="underline">login</a> to join the discussion 139 </div> 140 {{ end }} 141 </div> 142 </details> 143 - <hr class="md:hidden"/> 144 {{ end }} 145 {{ end }} 146 {{ end }} 147 148 {{ define "mergeStatus" }} 149 {{ if .Pull.State.IsClosed }} 150 - <div class="bg-gray-50 border border-black rounded drop-shadow-sm px-6 py-2 relative w-fit"> 151 - <div class="absolute left-8 -top-2 w-px h-2 bg-gray-300"></div> 152 - <div class="flex items-center gap-2 text-black"> 153 {{ i "ban" "w-4 h-4" }} 154 <span class="font-medium">closed without merging</span 155 > 156 </div> 157 </div> 158 {{ else if .Pull.State.IsMerged }} 159 - <div class="bg-purple-50 border border-purple-500 rounded drop-shadow-sm px-6 py-2 relative w-fit"> 160 - <div class="absolute left-8 -top-2 w-px h-2 bg-gray-300"></div> 161 - <div class="flex items-center gap-2 text-purple-500"> 162 {{ i "git-merge" "w-4 h-4" }} 163 <span class="font-medium">pull request successfully merged</span 164 > 165 </div> 166 </div> 167 {{ else if and .MergeCheck .MergeCheck.Error }} 168 - <div class="bg-red-50 border border-red-500 rounded drop-shadow-sm px-6 py-2 relative w-fit"> 169 - <div class="absolute left-8 -top-2 w-px h-2 bg-gray-300"></div> 170 - <div class="flex items-center gap-2 text-red-500"> 171 {{ i "triangle-alert" "w-4 h-4" }} 172 <span class="font-medium">{{ .MergeCheck.Error }}</span> 173 </div> 174 </div> 175 {{ else if and .MergeCheck .MergeCheck.IsConflicted }} 176 - <div class="bg-red-50 border border-red-500 rounded drop-shadow-sm px-6 py-2 relative w-fit"> 177 - <div class="absolute left-8 -top-2 w-px h-2 bg-gray-300"></div> 178 - <div class="flex items-center gap-2 text-red-500"> 179 - {{ i "triangle-alert" "w-4 h-4" }} 180 - <span class="font-medium">merge conflicts detected</span> 181 - <ul class="text-sm space-y-1"> 182 {{ range .MergeCheck.Conflicts }} 183 {{ if .Filename }} 184 <li class="flex items-center"> 185 - {{ i "file-warning" "w-3 h-3 mr-1.5 text-red-500" }} 186 <span class="font-mono">{{ slice .Filename 0 (sub (len .Filename) 2) }}</span> 187 </li> 188 {{ end }} ··· 191 </div> 192 </div> 193 {{ else if .MergeCheck }} 194 - <div class="bg-green-50 border border-green-500 rounded drop-shadow-sm px-6 py-2 relative w-fit"> 195 - <div class="absolute left-8 -top-2 w-px h-2 bg-gray-300"></div> 196 - <div class="flex items-center gap-2 text-green-500"> 197 {{ i "circle-check-big" "w-4 h-4" }} 198 <span class="font-medium">no conflicts, ready to merge</span> 199 </div> 200 </div> 201 {{ end }} 202 {{ end }}
··· 3 {{ end }} 4 5 {{ define "repoContent" }} 6 + {{ template "repo/pulls/fragments/pullHeader" . }} 7 {{ end }} 8 9 {{ define "repoAfter" }} ··· 22 {{ $targetBranch := .Pull.TargetBranch }} 23 {{ $repoName := .RepoInfo.FullName }} 24 {{ range $idx, $item := .Pull.Submissions }} 25 {{ with $item }} 26 <details {{ if eq $idx $lastIdx }}open{{ end }}> 27 + <summary id="round-#{{ .RoundNumber }}" class="list-none cursor-pointer"> 28 <div class="flex flex-wrap gap-2 items-center"> 29 <!-- round number --> 30 + <div class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-3 py-2 dark:text-white"> 31 + <span class="flex items-center">{{ i "hash" "w-4 h-4" }}{{ .RoundNumber }}</span> 32 </div> 33 <!-- round summary --> 34 + <div class="rounded drop-shadow-sm bg-white dark:bg-gray-800 p-2 text-gray-500 dark:text-gray-400"> 35 <span> 36 {{ $owner := index $.DidHandleMap $.Pull.OwnerDid }} 37 {{ $re := "re" }} ··· 41 <span class="hidden md:inline">{{$re}}submitted</span> 42 by <a href="/{{ $owner }}">{{ $owner }}</a> 43 <span class="select-none before:content-['\00B7']"></span> 44 + <a class="text-gray-500 dark:text-gray-400 hover:text-gray-500" href="#round-#{{ .RoundNumber }}"><time>{{ .Created | shortTimeFmt }}</time></a> 45 <span class="select-none before:content-['ยท']"></span> 46 {{ $s := "s" }} 47 {{ if eq (len .Comments) 1 }} ··· 50 {{ len .Comments }} comment{{$s}} 51 </span> 52 </div> 53 + 54 + <a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group" 55 hx-boost="true" 56 href="/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/round/{{.RoundNumber}}"> 57 + {{ i "file-diff" "w-4 h-4" }} 58 + <span class="hidden md:inline">diff</span> 59 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 60 </a> 61 + {{ if not (eq .RoundNumber 0) }} 62 + <a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group" 63 + hx-boost="true" 64 + href="/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/round/{{.RoundNumber}}/interdiff"> 65 + {{ i "chevrons-left-right-ellipsis" "w-4 h-4 rotate-90" }} 66 + <span class="hidden md:inline">interdiff</span> 67 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 68 + </a> 69 + <span id="interdiff-error-{{.RoundNumber}}"></span> 70 + {{ end }} 71 </div> 72 </summary> 73 + 74 + {{ if .IsFormatPatch }} 75 + {{ $patches := .AsFormatPatch }} 76 + {{ $round := .RoundNumber }} 77 + <details class="group py-2 md:ml-[3.5rem] text-gray-500 dark:text-gray-400 flex flex-col gap-2 relative text-sm"> 78 + <summary class="py-1 list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400"> 79 + {{ $s := "s" }} 80 + {{ if eq (len $patches) 1 }} 81 + {{ $s = "" }} 82 + {{ end }} 83 + <div class="group-open:hidden flex items-center gap-2 ml-2"> 84 + {{ i "chevrons-up-down" "w-4 h-4" }} expand {{ len $patches }} commit{{$s}} 85 + </div> 86 + <div class="hidden group-open:flex items-center gap-2 ml-2"> 87 + {{ i "chevrons-down-up" "w-4 h-4" }} hide {{ len $patches }} commit{{$s}} 88 + </div> 89 + </summary> 90 + {{ range $patches }} 91 + <div id="commit-{{.SHA}}" class="py-1 px-2 relative w-full md:max-w-3/5 md:w-fit flex flex-col"> 92 + <div class="flex items-center gap-2"> 93 + {{ i "git-commit-horizontal" "w-4 h-4" }} 94 + <div class="text-sm text-gray-500 dark:text-gray-400"> 95 + <!-- attempt to resolve $fullRepo: this is possible only on non-deleted forks and branches --> 96 + {{ $fullRepo := "" }} 97 + {{ if and $.Pull.IsForkBased $.Pull.PullSource.Repo }} 98 + {{ $fullRepo = printf "%s/%s" $owner $.Pull.PullSource.Repo.Name }} 99 + {{ else if $.Pull.IsBranchBased }} 100 + {{ $fullRepo = $.RepoInfo.FullName }} 101 + {{ end }} 102 + 103 + <!-- if $fullRepo was resolved, link to it, otherwise just span without a link --> 104 + {{ if $fullRepo }} 105 + <a href="/{{ $fullRepo }}/commit/{{ .SHA }}" class="font-mono text-gray-500 dark:text-gray-400">{{ slice .SHA 0 8 }}</a> 106 + {{ else }} 107 + <span class="font-mono">{{ slice .SHA 0 8 }}</span> 108 + {{ end }} 109 + </div> 110 + <div class="flex items-center"> 111 + <span>{{ .Title }}</span> 112 + {{ if gt (len .Body) 0 }} 113 + <button 114 + 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" 115 + hx-on:click="document.getElementById('body-{{$round}}-{{.SHA}}').classList.toggle('hidden')" 116 + > 117 + {{ i "ellipsis" "w-3 h-3" }} 118 + </button> 119 + {{ end }} 120 + </div> 121 + </div> 122 + {{ if gt (len .Body) 0 }} 123 + <p id="body-{{$round}}-{{.SHA}}" class="hidden mt-1 text-sm pb-2"> 124 + {{ nl2br .Body }} 125 + </p> 126 + {{ end }} 127 + </div> 128 + {{ end }} 129 + </details> 130 + {{ end }} 131 + 132 + 133 + <div class="md:pl-[3.5rem] flex flex-col gap-2 mt-2 relative"> 134 + {{ range $cidx, $c := .Comments }} 135 + <div id="comment-{{$c.ID}}" 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"> 136 + {{ if gt $cidx 0 }} 137 + <div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div> 138 + {{ end }} 139 + <div class="text-sm text-gray-500 dark:text-gray-400"> 140 + {{ $owner := index $.DidHandleMap $c.OwnerDid }} 141 <a href="/{{$owner}}">{{$owner}}</a> 142 <span class="before:content-['ยท']"></span> 143 + <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> 144 </div> 145 + <div class="prose dark:prose-invert"> 146 + {{ $c.Body | markdown }} 147 </div> 148 </div> 149 {{ end }} 150 151 {{ if eq $lastIdx .RoundNumber }} 152 {{ block "mergeStatus" $ }} {{ end }} 153 + {{ block "resubmitStatus" $ }} {{ end }} 154 {{ end }} 155 156 {{ if $.LoggedInUser }} 157 + {{ template "repo/pulls/fragments/pullActions" (dict "LoggedInUser" $.LoggedInUser "Pull" $.Pull "RepoInfo" $.RepoInfo "RoundNumber" .RoundNumber "MergeCheck" $.MergeCheck "ResubmitCheck" $.ResubmitCheck) }} 158 {{ else }} 159 + <div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm px-6 py-4 w-fit dark:text-white"> 160 + <div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div> 161 <a href="/login" class="underline">login</a> to join the discussion 162 </div> 163 {{ end }} 164 </div> 165 </details> 166 + <hr class="md:hidden border-t border-gray-300 dark:border-gray-600"/> 167 {{ end }} 168 {{ end }} 169 {{ end }} 170 171 {{ define "mergeStatus" }} 172 {{ if .Pull.State.IsClosed }} 173 + <div class="bg-gray-50 dark:bg-gray-700 border border-black dark:border-gray-500 rounded drop-shadow-sm px-6 py-2 relative w-fit"> 174 + <div class="flex items-center gap-2 text-black dark:text-white"> 175 {{ i "ban" "w-4 h-4" }} 176 <span class="font-medium">closed without merging</span 177 > 178 </div> 179 </div> 180 {{ else if .Pull.State.IsMerged }} 181 + <div class="bg-purple-50 dark:bg-purple-900 border border-purple-500 rounded drop-shadow-sm px-6 py-2 relative w-fit"> 182 + <div class="flex items-center gap-2 text-purple-500 dark:text-purple-300"> 183 {{ i "git-merge" "w-4 h-4" }} 184 <span class="font-medium">pull request successfully merged</span 185 > 186 </div> 187 </div> 188 {{ else if and .MergeCheck .MergeCheck.Error }} 189 + <div class="bg-red-50 dark:bg-red-900 border border-red-500 rounded drop-shadow-sm px-6 py-2 relative w-fit"> 190 + <div class="flex items-center gap-2 text-red-500 dark:text-red-300"> 191 {{ i "triangle-alert" "w-4 h-4" }} 192 <span class="font-medium">{{ .MergeCheck.Error }}</span> 193 </div> 194 </div> 195 {{ else if and .MergeCheck .MergeCheck.IsConflicted }} 196 + <div class="bg-red-50 dark:bg-red-900 border border-red-500 rounded drop-shadow-sm px-6 py-2 relative w-fit"> 197 + <div class="flex flex-col gap-2 text-red-500 dark:text-red-300"> 198 + <div class="flex items-center gap-2"> 199 + {{ i "triangle-alert" "w-4 h-4" }} 200 + <span class="font-medium">merge conflicts detected</span> 201 + </div> 202 + <ul class="space-y-1"> 203 {{ range .MergeCheck.Conflicts }} 204 {{ if .Filename }} 205 <li class="flex items-center"> 206 + {{ i "file-warning" "w-4 h-4 mr-1.5 text-red-500 dark:text-red-300" }} 207 <span class="font-mono">{{ slice .Filename 0 (sub (len .Filename) 2) }}</span> 208 </li> 209 {{ end }} ··· 212 </div> 213 </div> 214 {{ else if .MergeCheck }} 215 + <div class="bg-green-50 dark:bg-green-900 border border-green-500 rounded drop-shadow-sm px-6 py-2 relative w-fit"> 216 + <div class="flex items-center gap-2 text-green-500 dark:text-green-300"> 217 {{ i "circle-check-big" "w-4 h-4" }} 218 <span class="font-medium">no conflicts, ready to merge</span> 219 </div> 220 </div> 221 {{ end }} 222 {{ end }} 223 + 224 + {{ define "resubmitStatus" }} 225 + {{ if .ResubmitCheck.Yes }} 226 + <div class="bg-amber-50 dark:bg-amber-900 border border-amber-500 rounded drop-shadow-sm px-6 py-2 relative w-fit"> 227 + <div class="flex items-center gap-2 text-amber-500 dark:text-amber-300"> 228 + {{ i "triangle-alert" "w-4 h-4" }} 229 + <span class="font-medium">this branch has been updated, consider resubmitting</span> 230 + </div> 231 + </div> 232 + {{ end }} 233 + {{ end }} 234 + 235 + {{ define "commits" }} 236 + {{ end }}
+67 -30
appview/pages/templates/repo/pulls/pulls.html
··· 2 3 {{ define "repoContent" }} 4 <div class="flex justify-between items-center"> 5 - <p> 6 - filtering 7 - <select 8 - class="border px-1 bg-white border-gray-200" 9 - onchange="window.location.href = '/{{ .RepoInfo.FullName }}/pulls?state=' + this.value" 10 > 11 - <option value="open" {{ if .FilteringBy.IsOpen }}selected{{ end }}> 12 - open ({{ .RepoInfo.Stats.PullCount.Open }}) 13 - </option> 14 - <option value="merged" {{ if .FilteringBy.IsMerged }}selected{{ end }}> 15 - merged ({{ .RepoInfo.Stats.PullCount.Merged }}) 16 - </option> 17 - <option value="closed" {{ if .FilteringBy.IsClosed }}selected{{ end }}> 18 - closed ({{ .RepoInfo.Stats.PullCount.Closed }}) 19 - </option> 20 - </select> 21 - pull requests 22 - </p> 23 <a 24 href="/{{ .RepoInfo.FullName }}/pulls/new" 25 class="btn text-sm flex items-center gap-2 no-underline hover:no-underline" 26 > 27 - {{ i "git-pull-request" "w-4 h-4" }} 28 - <span>new pull request</span> 29 </a> 30 </div> 31 <div class="error" id="pulls"></div> ··· 34 {{ define "repoAfter" }} 35 <div class="flex flex-col gap-2 mt-2"> 36 {{ range .Pulls }} 37 - <div class="rounded drop-shadow-sm bg-white px-6 py-4"> 38 <div class="pb-2"> 39 - <a href="/{{ $.RepoInfo.FullName }}/pulls/{{ .PullId }}"> 40 {{ .Title }} 41 - <span class="text-gray-500">#{{ .PullId }}</span> 42 </a> 43 </div> 44 - <p class="text-sm text-gray-500"> 45 - {{ $bgColor := "bg-gray-800" }} 46 {{ $icon := "ban" }} 47 48 {{ if .State.IsOpen }} 49 - {{ $bgColor = "bg-green-600" }} 50 {{ $icon = "git-pull-request" }} 51 {{ else if .State.IsMerged }} 52 - {{ $bgColor = "bg-purple-600" }} 53 {{ $icon = "git-merge" }} 54 {{ end }} 55 ··· 62 </span> 63 64 <span> 65 - {{ $owner := index $.DidHandleMap .OwnerDid }} 66 - <a href="/{{ $owner }}">{{ $owner }}</a> 67 </span> 68 69 <span class="before:content-['ยท']"> ··· 73 </span> 74 75 <span class="before:content-['ยท']"> 76 - targeting branch 77 - <span class="text-xs rounded bg-gray-100 text-black font-mono px-2 mx-1/2 inline-flex items-center"> 78 {{ .TargetBranch }} 79 </span> 80 </span> 81 </p> 82 </div>
··· 2 3 {{ define "repoContent" }} 4 <div class="flex justify-between items-center"> 5 + <div class="flex gap-4"> 6 + <a 7 + href="?state=open" 8 + class="flex items-center gap-2 {{ if .FilteringBy.IsOpen }}font-bold {{ else }}text-gray-500 dark:text-gray-400{{ end }}" 9 + > 10 + {{ i "git-pull-request" "w-4 h-4" }} 11 + <span>{{ .RepoInfo.Stats.PullCount.Open }} open</span> 12 + </a> 13 + <a 14 + href="?state=merged" 15 + class="flex items-center gap-2 {{ if .FilteringBy.IsMerged }}font-bold {{ else }}text-gray-500 dark:text-gray-400{{ end }}" 16 + > 17 + {{ i "git-merge" "w-4 h-4" }} 18 + <span>{{ .RepoInfo.Stats.PullCount.Merged }} merged</span> 19 + </a> 20 + <a 21 + href="?state=closed" 22 + class="flex items-center gap-2 {{ if .FilteringBy.IsClosed }}font-bold {{ else }}text-gray-500 dark:text-gray-400{{ end }}" 23 > 24 + {{ i "ban" "w-4 h-4" }} 25 + <span>{{ .RepoInfo.Stats.PullCount.Closed }} closed</span> 26 + </a> 27 + </div> 28 <a 29 href="/{{ .RepoInfo.FullName }}/pulls/new" 30 class="btn text-sm flex items-center gap-2 no-underline hover:no-underline" 31 > 32 + {{ i "git-pull-request-create" "w-4 h-4" }} 33 + <span>new</span> 34 </a> 35 </div> 36 <div class="error" id="pulls"></div> ··· 39 {{ define "repoAfter" }} 40 <div class="flex flex-col gap-2 mt-2"> 41 {{ range .Pulls }} 42 + <div class="rounded drop-shadow-sm bg-white dark:bg-gray-800 px-6 py-4"> 43 <div class="pb-2"> 44 + <a href="/{{ $.RepoInfo.FullName }}/pulls/{{ .PullId }}" class="dark:text-white"> 45 {{ .Title }} 46 + <span class="text-gray-500 dark:text-gray-400">#{{ .PullId }}</span> 47 </a> 48 </div> 49 + <p class="text-sm text-gray-500 dark:text-gray-400"> 50 + {{ $owner := index $.DidHandleMap .OwnerDid }} 51 + {{ $bgColor := "bg-gray-800 dark:bg-gray-700" }} 52 {{ $icon := "ban" }} 53 54 {{ if .State.IsOpen }} 55 + {{ $bgColor = "bg-green-600 dark:bg-green-700" }} 56 {{ $icon = "git-pull-request" }} 57 {{ else if .State.IsMerged }} 58 + {{ $bgColor = "bg-purple-600 dark:bg-purple-700" }} 59 {{ $icon = "git-merge" }} 60 {{ end }} 61 ··· 68 </span> 69 70 <span> 71 + <a href="/{{ $owner }}" class="dark:text-gray-300">{{ $owner }}</a> 72 </span> 73 74 <span class="before:content-['ยท']"> ··· 78 </span> 79 80 <span class="before:content-['ยท']"> 81 + targeting 82 + <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"> 83 {{ .TargetBranch }} 84 </span> 85 + </span> 86 + {{ if not .IsPatchBased }} 87 + from 88 + <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"> 89 + {{ if .IsForkBased }} 90 + {{ if .PullSource.Repo }} 91 + <a href="/{{ $owner }}/{{ .PullSource.Repo.Name }}" class="no-underline hover:underline">{{ $owner }}/{{ .PullSource.Repo.Name }}</a>: 92 + {{- else -}} 93 + <span class="italic">[deleted fork]</span> 94 + {{- end -}} 95 + {{- end -}} 96 + {{- .PullSource.Branch -}} 97 + </span> 98 + {{ end }} 99 + <span class="before:content-['ยท']"> 100 + {{ $latestRound := .LastRoundNumber }} 101 + {{ $lastSubmission := index .Submissions $latestRound }} 102 + round 103 + <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"> 104 + #{{ .LastRoundNumber }} 105 + </span> 106 + {{ $commentCount := len $lastSubmission.Comments }} 107 + {{ $s := "s" }} 108 + {{ if eq $commentCount 1 }} 109 + {{ $s = "" }} 110 + {{ end }} 111 + 112 + {{ if eq $commentCount 0 }} 113 + awaiting comments 114 + {{ else }} 115 + recieved {{ len $lastSubmission.Comments}} comment{{$s}} 116 + {{ end }} 117 </span> 118 </p> 119 </div>
+52 -8
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">Collaborators</header> 4 5 <div id="collaborator-list" class="flex flex-col gap-2 mb-2"> 6 {{ range .Collaborators }} 7 <div id="collaborator" class="mb-2"> 8 <a 9 href="/{{ didOrHandle .Did .Handle }}" 10 - class="no-underline hover:underline text-black" 11 > 12 {{ didOrHandle .Did .Handle }} 13 </a> 14 <div> 15 - <span class="text-sm text-gray-500"> 16 {{ .Role }} 17 </span> 18 </div> ··· 20 {{ end }} 21 </div> 22 23 - {{ if .IsCollaboratorInviteAllowed }} 24 - <h3>add collaborator</h3> 25 <form hx-put="/{{ $.RepoInfo.FullName }}/settings/collaborator"> 26 - <label for="collaborator">did or handle:</label> 27 - <input type="text" id="collaborator" name="collaborator" required /> 28 - <button class="btn my-2" type="text">add collaborator</button> 29 </form> 30 {{ end }} 31 {{ end }}
··· 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> ··· 22 {{ end }} 23 </div> 24 25 + {{ if .RepoInfo.Roles.CollaboratorInviteAllowed }} 26 <form hx-put="/{{ $.RepoInfo.FullName }}/settings/collaborator"> 27 + <label for="collaborator" class="dark:text-white" 28 + >add collaborator</label 29 + > 30 + <input 31 + type="text" 32 + id="collaborator" 33 + name="collaborator" 34 + required 35 + class="dark:bg-gray-700 dark:text-white" 36 + placeholder="enter did or handle" 37 + /> 38 + <button 39 + class="btn my-2 dark:text-white dark:hover:bg-gray-700" 40 + type="text" 41 + > 42 + add 43 + </button> 44 </form> 45 {{ end }} 46 + 47 + <form hx-put="/{{ $.RepoInfo.FullName }}/settings/branches/default" class="mt-6"> 48 + <label for="branch">default branch</label> 49 + <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"> 50 + {{ range .Branches }} 51 + <option 52 + value="{{ . }}" 53 + class="py-1" 54 + {{ if eq . $.DefaultBranch }} 55 + selected 56 + {{ end }} 57 + > 58 + {{ . }} 59 + </option> 60 + {{ end }} 61 + </select> 62 + <button class="btn my-2" type="text">save</button> 63 + </form> 64 + 65 + {{ if .RepoInfo.Roles.RepoDeleteAllowed }} 66 + <form hx-confirm="Are you sure you want to delete this repository?" hx-delete="/{{ $.RepoInfo.FullName }}/settings/delete" class="mt-6"> 67 + <label for="branch">delete repository</label> 68 + <button class="btn my-2" type="text">delete</button> 69 + <span> 70 + Deleting a repository is irreversible and permanent. 71 + </span> 72 + </form> 73 + {{ end }} 74 + 75 {{ end }}
+161 -13
appview/pages/templates/repo/tags.html
··· 1 {{ define "repoContent" }} 2 - {{ $name := .RepoInfo.Name }} 3 - <h3>tags</h3> 4 - <div class="refs"> 5 - {{ range .Tags }} 6 - <div> 7 - <strong>{{ .Ref.Name }}</strong> 8 - <a href="/{{ $name }}/tree/{{ .Ref.Name }}/">browse</a> 9 - <a href="/{{ $name }}/log/{{ .Ref.Name }}">log</a> 10 - <a href="/{{ $name }}/archive/{{ .Ref.Name }}.tar.gz">tar.gz</a> 11 - {{ if .Message }} 12 - <pre>{{ .Message }}</pre> 13 - {{ end }} 14 - </div> 15 {{ end }} 16 </div> 17 {{ end }}
··· 1 + {{ define "title" }} 2 + tags ยท {{ .RepoInfo.FullName }} 3 + {{ end }} 4 + 5 {{ define "repoContent" }} 6 + <section> 7 + <h2 class="mb-4 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold">tags</h2> 8 + <div class="flex flex-col py-2 gap-12 md:gap-0"> 9 + {{ range .Tags }} 10 + <div class="md:grid md:grid-cols-12 md:items-start flex flex-col"> 11 + <!-- Header column (top on mobile, left on md+) --> 12 + <div class="md:col-span-2 md:border-r border-b md:border-b-0 border-gray-200 dark:border-gray-700 w-full md:h-full"> 13 + <!-- Mobile layout: horizontal --> 14 + <div class="flex md:hidden flex-col py-2 px-2 text-xl"> 15 + <a href="/{{ $.RepoInfo.FullName }}/tree/{{ .Name | urlquery }}" class="no-underline hover:underline flex items-center gap-2 font-bold"> 16 + {{ i "tag" "w-4 h-4" }} 17 + {{ .Name }} 18 + </a> 19 + 20 + <div class="flex items-center gap-3 text-gray-500 dark:text-gray-400 text-sm"> 21 + {{ if .Tag }} 22 + <a href="/{{ $.RepoInfo.FullName }}/commit/{{ .Tag.Target.String }}" 23 + class="no-underline hover:underline text-gray-500 dark:text-gray-400"> 24 + {{ slice .Tag.Target.String 0 8 }} 25 + </a> 26 + 27 + <span class="px-1 text-gray-500 dark:text-gray-400 select-none after:content-['ยท']"></span> 28 + <span>{{ .Tag.Tagger.Name }}</span> 29 + 30 + <span class="px-1 text-gray-500 dark:text-gray-400 select-none after:content-['ยท']"></span> 31 + <time>{{ shortTimeFmt .Tag.Tagger.When }}</time> 32 + {{ end }} 33 + </div> 34 + </div> 35 + 36 + <!-- Desktop layout: vertical and left-aligned --> 37 + <div class="hidden md:block text-left px-2 pb-6"> 38 + <a href="/{{ $.RepoInfo.FullName }}/tree/{{ .Name | urlquery }}" class="no-underline hover:underline flex items-center gap-2 font-bold"> 39 + {{ i "tag" "w-4 h-4" }} 40 + {{ .Name }} 41 + </a> 42 + <div class="flex flex-grow flex-col text-gray-500 dark:text-gray-400 text-sm"> 43 + {{ if .Tag }} 44 + <a href="/{{ $.RepoInfo.FullName }}/commit/{{ .Tag.Target.String }}" 45 + class="no-underline hover:underline text-gray-500 dark:text-gray-400 flex items-center gap-2"> 46 + {{ i "git-commit-horizontal" "w-4 h-4" }} 47 + {{ slice .Tag.Target.String 0 8 }} 48 + </a> 49 + <span>{{ .Tag.Tagger.Name }}</span> 50 + <time>{{ timeFmt .Tag.Tagger.When }}</time> 51 + {{ end }} 52 + </div> 53 + </div> 54 + </div> 55 + 56 + <!-- Content column (bottom on mobile, right on md+) --> 57 + <div class="md:col-span-10 px-2 py-3 md:py-0 md:pb-6"> 58 + {{ if .Tag }} 59 + {{ $messageParts := splitN .Tag.Message "\n\n" 2 }} 60 + <p class="font-bold text-lg">{{ index $messageParts 0 }}</p> 61 + {{ if gt (len $messageParts) 1 }} 62 + <p class="cursor-text py-2">{{ nl2br (index $messageParts 1) }}</p> 63 + {{ end }} 64 + {{ block "artifacts" (list $ .) }} {{ end }} 65 + {{ else }} 66 + <p class="italic text-gray-500 dark:text-gray-400">no message</p> 67 {{ end }} 68 + </div> 69 </div> 70 + {{ else }} 71 + <p class="text-center text-gray-400 dark:text-gray-500 p-4"> 72 + This repository does not contain any tags. 73 + </p> 74 + {{ end }} 75 + </div> 76 + </section> 77 + {{ end }} 78 + 79 + {{ define "repoAfter" }} 80 + {{ if gt (len .DanglingArtifacts) 0 }} 81 + <section class="bg-white dark:bg-gray-800 p-6 mt-4"> 82 + {{ block "dangling" . }} {{ end }} 83 + </section> 84 + {{ end }} 85 + {{ end }} 86 + 87 + {{ define "artifacts" }} 88 + {{ $root := index . 0 }} 89 + {{ $tag := index . 1 }} 90 + {{ $isPushAllowed := $root.RepoInfo.Roles.IsPushAllowed }} 91 + {{ $artifacts := index $root.ArtifactMap $tag.Tag.Hash }} 92 + 93 + {{ if or (gt (len $artifacts) 0) $isPushAllowed }} 94 + <h2 class="my-4 text-sm text-left text-gray-700 dark:text-gray-300 uppercase font-bold">artifacts</h2> 95 + <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700"> 96 + {{ range $artifact := $artifacts }} 97 + {{ $args := dict "LoggedInUser" $root.LoggedInUser "RepoInfo" $root.RepoInfo "Artifact" $artifact }} 98 + {{ template "repo/fragments/artifact" $args }} 99 + {{ end }} 100 + {{ if $isPushAllowed }} 101 + {{ block "uploadArtifact" (list $root $tag) }} {{ end }} 102 + {{ end }} 103 + </div> 104 + {{ end }} 105 + {{ end }} 106 + 107 + {{ define "uploadArtifact" }} 108 + {{ $root := index . 0 }} 109 + {{ $tag := index . 1 }} 110 + {{ $unique := $tag.Tag.Target.String }} 111 + <form 112 + id="upload-{{$unique}}" 113 + method="post" 114 + enctype="multipart/form-data" 115 + hx-post="/{{ $root.RepoInfo.FullName }}/tags/{{ $tag.Name | urlquery }}/upload" 116 + hx-on::after-request="if(event.detail.successful) this.reset()" 117 + hx-disabled-elt="#upload-btn-{{$unique}}" 118 + hx-swap="beforebegin" 119 + hx-target="this" 120 + class="flex items-center gap-2 px-2"> 121 + <div class="flex-grow"> 122 + <input type="file" 123 + name="artifact" 124 + required 125 + class="block py-2 px-0 w-full border-none 126 + text-black dark:text-white 127 + bg-white dark:bg-gray-800 128 + file:mr-4 file:px-2 file:py-2 129 + file:rounded file:border-0 130 + file:text-sm file:font-medium 131 + file:text-gray-700 file:dark:text-gray-300 132 + file:bg-gray-200 file:dark:bg-gray-700 133 + file:hover:bg-gray-100 file:hover:dark:bg-gray-600 134 + "> 135 + </input> 136 + </div> 137 + <div class="flex justify-end"> 138 + <button 139 + type="submit" 140 + class="btn gap-2" 141 + id="upload-btn-{{$unique}}" 142 + title="Upload artifact"> 143 + {{ i "upload" "w-4 h-4" }} 144 + <span class="hidden md:inline">upload</span> 145 + </button> 146 + </div> 147 + </form> 148 + {{ end }} 149 + 150 + {{ define "dangling" }} 151 + {{ $root := . }} 152 + {{ $isPushAllowed := $root.RepoInfo.Roles.IsPushAllowed }} 153 + {{ $artifacts := $root.DanglingArtifacts }} 154 + 155 + {{ if and (gt (len $artifacts) 0) $isPushAllowed }} 156 + <h2 class="mb-2 text-sm text-left text-red-700 dark:text-red-400 uppercase font-bold">dangling artifacts</h2> 157 + <p class="mb-4">The tags that these artifacts were attached to have been deleted. These artifacts are only visible to collaborators.</p> 158 + <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700"> 159 + {{ range $artifact := $artifacts }} 160 + {{ $args := dict "LoggedInUser" $root.LoggedInUser "RepoInfo" $root.RepoInfo "Artifact" $artifact }} 161 + {{ template "repo/fragments/artifact" $args }} 162 + {{ end }} 163 + </div> 164 + {{ end }} 165 {{ end }}
+26 -24
appview/pages/templates/repo/tree.html
··· 17 {{ $containerstyle := "py-1" }} 18 {{ $linkstyle := "no-underline hover:underline" }} 19 20 - <div class="pb-2 text-base"> 21 - <div class="flex justify-between"> 22 - <div id="breadcrumbs"> 23 {{ range .BreadCrumbs }} 24 - <a href="{{ index . 1}}" class="text-bold text-gray-500 {{ $linkstyle }}">{{ index . 0 }}</a> / 25 {{ end }} 26 </div> 27 - <div id="dir-info"> 28 - <span class="text-gray-500 text-xs"> 29 - {{ $stats := .TreeStats }} 30 31 - {{ if eq $stats.NumFolders 1 }} 32 - {{ $stats.NumFolders }} folder 33 - <span class="px-1 select-none">ยท</span> 34 - {{ else if gt $stats.NumFolders 1 }} 35 - {{ $stats.NumFolders }} folders 36 - <span class="px-1 select-none">ยท</span> 37 - {{ end }} 38 39 - {{ if eq $stats.NumFiles 1 }} 40 - {{ $stats.NumFiles }} file 41 - {{ else if gt $stats.NumFiles 1 }} 42 - {{ $stats.NumFiles }} files 43 - {{ end }} 44 - </span> 45 </div> 46 </div> 47 </div> ··· 52 <div class="flex justify-between items-center"> 53 <a href="/{{ $.BaseTreeLink }}/{{ .Name }}" class="{{ $linkstyle }}"> 54 <div class="flex items-center gap-2"> 55 - {{ i "folder" "w-3 h-3 fill-current" }}{{ .Name }} 56 </div> 57 </a> 58 - <time class="text-xs text-gray-500">{{ timeFmt .LastCommit.When }}</time> 59 </div> 60 </div> 61 {{ end }} ··· 67 <div class="flex justify-between items-center"> 68 <a href="/{{ $.BaseBlobLink }}/{{ .Name }}" class="{{ $linkstyle }}"> 69 <div class="flex items-center gap-2"> 70 - {{ i "file" "w-3 h-3" }}{{ .Name }} 71 </div> 72 </a> 73 - <time class="text-xs text-gray-500">{{ timeFmt .LastCommit.When }}</time> 74 </div> 75 </div> 76 {{ end }}
··· 17 {{ $containerstyle := "py-1" }} 18 {{ $linkstyle := "no-underline hover:underline" }} 19 20 + <div class="pb-2 mb-3 text-base border-b border-gray-200 dark:border-gray-700"> 21 + <div class="flex flex-col md:flex-row md:justify-between gap-2"> 22 + <div id="breadcrumbs" class="overflow-x-auto whitespace-nowrap text-gray-400 dark:text-gray-500"> 23 {{ range .BreadCrumbs }} 24 + <a href="{{ index . 1}}" class="text-bold text-gray-500 dark:text-gray-400 {{ $linkstyle }}">{{ index . 0 }}</a> / 25 {{ end }} 26 </div> 27 + <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"> 28 + {{ $stats := .TreeStats }} 29 30 + <span>at <a href="/{{ $.RepoInfo.FullName }}/tree/{{ $.Ref }}">{{ $.Ref }}</a></span> 31 + {{ if eq $stats.NumFolders 1 }} 32 + <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span> 33 + <span>{{ $stats.NumFolders }} folder</span> 34 + {{ else if gt $stats.NumFolders 1 }} 35 + <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span> 36 + <span>{{ $stats.NumFolders }} folders</span> 37 + {{ end }} 38 39 + {{ if eq $stats.NumFiles 1 }} 40 + <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span> 41 + <span>{{ $stats.NumFiles }} file</span> 42 + {{ else if gt $stats.NumFiles 1 }} 43 + <span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span> 44 + <span>{{ $stats.NumFiles }} files</span> 45 + {{ end }} 46 + 47 </div> 48 </div> 49 </div> ··· 54 <div class="flex justify-between items-center"> 55 <a href="/{{ $.BaseTreeLink }}/{{ .Name }}" class="{{ $linkstyle }}"> 56 <div class="flex items-center gap-2"> 57 + {{ i "folder" "size-4 fill-current" }}{{ .Name }} 58 </div> 59 </a> 60 + <time class="text-xs text-gray-500 dark:text-gray-400">{{ timeFmt .LastCommit.When }}</time> 61 </div> 62 </div> 63 {{ end }} ··· 69 <div class="flex justify-between items-center"> 70 <a href="/{{ $.BaseBlobLink }}/{{ .Name }}" class="{{ $linkstyle }}"> 71 <div class="flex items-center gap-2"> 72 + {{ i "file" "size-4" }}{{ .Name }} 73 </div> 74 </a> 75 + <time class="text-xs text-gray-500 dark:text-gray-400">{{ timeFmt .LastCommit.When }}</time> 76 </div> 77 </div> 78 {{ end }}
+33 -33
appview/pages/templates/settings.html
··· 2 3 {{ define "content" }} 4 <div class="p-6"> 5 - <p class="text-xl font-bold">Settings</p> 6 </div> 7 <div class="flex flex-col"> 8 {{ block "profile" . }} {{ end }} ··· 12 {{ end }} 13 14 {{ define "profile" }} 15 - <h2 class="text-sm font-bold py-2 px-6 uppercase">profile</h2> 16 - <section class="rounded bg-white 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"> 18 {{ if .LoggedInUser.Handle }} 19 <dt class="font-bold">handle</dt> 20 <dd>@{{ .LoggedInUser.Handle }}</dd> ··· 28 {{ end }} 29 30 {{ define "keys" }} 31 - <h2 class="text-sm font-bold py-2 px-6 uppercase">ssh keys</h2> 32 - <section class="rounded bg-white drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit"> 33 - <p class="mb-8">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" }} 40 - <p class="font-bold">{{ .Name }}</p> 41 </div> 42 - <p class="text-sm text-gray-500">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">{{ .Key }}</code> 45 </div> 46 </div> 47 <button 48 - class="btn text-red-500 hover:text-red-700" 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 }}'?"> ··· 66 name="name" 67 placeholder="key name" 68 required 69 - class="w-full"/> 70 71 <input 72 id="key" 73 name="key" 74 placeholder="ssh-rsa AAAAAA..." 75 required 76 - class="w-full"/> 77 78 - <button class="btn" type="submit">add key</button> 79 80 - <div id="settings-keys" class="error"></div> 81 </form> 82 </section> 83 {{ end }} 84 85 {{ define "emails" }} 86 - <h2 class="text-sm font-bold py-2 px-6 uppercase">email addresses</h2> 87 - <section class="rounded bg-white drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit"> 88 - <p class="mb-8">Commits authored using emails listed here will be associated with your Tangled profile.</p> 89 <div id="email-list" class="flex flex-col gap-6 mb-8"> 90 {{ range $index, $email := .Emails }} 91 <div class="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-4"> 92 <div class="flex flex-col gap-2"> 93 <div class="inline-flex items-center gap-4"> 94 - {{ i "mail" "w-3 h-3" }} 95 - <p class="font-bold">{{ .Address }}</p> 96 <div class="inline-flex items-center gap-1"> 97 {{ if .Verified }} 98 - <span class="text-xs bg-green-100 text-green-800 px-2 py-1 rounded">verified</span> 99 {{ else }} 100 - <span class="text-xs bg-yellow-100 text-yellow-800 px-2 py-1 rounded">unverified</span> 101 {{ end }} 102 {{ if .Primary }} 103 - <span class="text-xs bg-blue-100 text-blue-800 px-2 py-1 rounded">primary</span> 104 {{ end }} 105 </div> 106 </div> 107 - <p class="text-sm text-gray-500">added {{ .CreatedAt | timeFmt }}</p> 108 </div> 109 <div class="flex gap-2 items-center"> 110 {{ if not .Verified }} 111 <button 112 - class="btn flex gap-2" 113 hx-post="/settings/emails/verify/resend" 114 hx-swap="none" 115 href="#" ··· 120 {{ end }} 121 {{ if and (not .Primary) .Verified }} 122 <a 123 - class="text-sm" 124 hx-post="/settings/emails/primary" 125 hx-swap="none" 126 href="#" ··· 132 <form hx-delete="/settings/emails" hx-confirm="Are you sure you wish to delete the email '{{ .Address }}'?"> 133 <input type="hidden" name="email" value="{{ .Address }}"> 134 <button 135 - class="btn text-red-500 hover:text-red-700" 136 title="Delete email" 137 type="submit"> 138 {{ i "trash-2" "w-5 h-5" }} ··· 155 name="email" 156 placeholder="your@email.com" 157 required 158 - class="w-full"/> 159 160 - <button class="btn" type="submit">add email</button> 161 162 - <div id="settings-emails-error" class="error"></div> 163 - <div id="settings-emails-success" class="success"></div> 164 165 </form> 166 </section> 167 - {{ 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 }} ··· 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> ··· 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" 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 }}'?"> ··· 66 name="name" 67 placeholder="key name" 68 required 69 + class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400"/> 70 71 <input 72 id="key" 73 name="key" 74 placeholder="ssh-rsa AAAAAA..." 75 required 76 + class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400"/> 77 78 + <button class="btn dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600" type="submit">add key</button> 79 80 + <div id="settings-keys" class="error dark:text-red-400"></div> 81 </form> 82 </section> 83 {{ end }} 84 85 {{ define "emails" }} 86 + <h2 class="text-sm font-bold py-2 px-6 uppercase dark:text-gray-300">email addresses</h2> 87 + <section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit"> 88 + <p class="mb-8 dark:text-gray-300">Commits authored using emails listed here will be associated with your Tangled profile.</p> 89 <div id="email-list" class="flex flex-col gap-6 mb-8"> 90 {{ range $index, $email := .Emails }} 91 <div class="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-4"> 92 <div class="flex flex-col gap-2"> 93 <div class="inline-flex items-center gap-4"> 94 + {{ i "mail" "w-3 h-3 dark:text-gray-300" }} 95 + <p class="font-bold dark:text-white">{{ .Address }}</p> 96 <div class="inline-flex items-center gap-1"> 97 {{ if .Verified }} 98 + <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> 99 {{ else }} 100 + <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> 101 {{ end }} 102 {{ if .Primary }} 103 + <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> 104 {{ end }} 105 </div> 106 </div> 107 + <p class="text-sm text-gray-500 dark:text-gray-400">added {{ .CreatedAt | timeFmt }}</p> 108 </div> 109 <div class="flex gap-2 items-center"> 110 {{ if not .Verified }} 111 <button 112 + class="btn flex gap-2 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600" 113 hx-post="/settings/emails/verify/resend" 114 hx-swap="none" 115 href="#" ··· 120 {{ end }} 121 {{ if and (not .Primary) .Verified }} 122 <a 123 + class="text-sm dark:text-blue-400 dark:hover:text-blue-300" 124 hx-post="/settings/emails/primary" 125 hx-swap="none" 126 href="#" ··· 132 <form hx-delete="/settings/emails" hx-confirm="Are you sure you wish to delete the email '{{ .Address }}'?"> 133 <input type="hidden" name="email" value="{{ .Address }}"> 134 <button 135 + class="btn text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300" 136 title="Delete email" 137 type="submit"> 138 {{ i "trash-2" "w-5 h-5" }} ··· 155 name="email" 156 placeholder="your@email.com" 157 required 158 + class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400"/> 159 160 + <button class="btn dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600" type="submit">add email</button> 161 162 + <div id="settings-emails-error" class="error dark:text-red-400"></div> 163 + <div id="settings-emails-success" class="success dark:text-green-400"></div> 164 165 </form> 166 </section> 167 + {{ end }}
+22 -14
appview/pages/templates/timeline.html
··· 17 {{ end }} 18 19 {{ define "hero" }} 20 - <div class="flex flex-col items-center justify-center text-center rounded drop-shadow bg-white text-black py-4 px-10"> 21 <div class="font-bold italic text-4xl mb-4"> 22 tangled 23 </div> 24 <div class="italic text-lg"> 25 tightly-knit social coding, <a href="/login" class="underline inline-flex gap-1 items-center">join now {{ i "arrow-right" "w-4 h-4" }}</a> 26 - <p class="pt-5 px-10 text-sm text-gray-500">Join our IRC channel: <a href="https://web.libera.chat/#tangled"><code>#tangled</code> on Libera Chat</a>. 27 Read an introduction to Tangled <a href="https://blog.tangled.sh/intro">here</a>.</p> 28 </div> 29 </div> ··· 32 {{ define "timeline" }} 33 <div> 34 <div class="p-6"> 35 - <p class="text-xl font-bold">Timeline</p> 36 </div> 37 38 <div class="flex flex-col gap-3 relative"> 39 - <div class="absolute left-8 top-0 bottom-0 w-px bg-gray-300"></div> 40 {{ range .Timeline }} 41 - <div class="px-6 py-2 bg-white rounded drop-shadow-sm w-fit"> 42 {{ if .Repo }} 43 {{ $userHandle := index $.DidHandleMap .Repo.Did }} 44 <div class="flex items-center"> 45 - <p class="text-gray-600"> 46 <a href="/{{ $userHandle }}" class="no-underline hover:underline">{{ $userHandle | truncateAt30 }}</a> 47 - created 48 - <a href="/{{ $userHandle }}/{{ .Repo.Name }}" class="no-underline hover:underline">{{ .Repo.Name }}</a> 49 - <time class="text-gray-700 text-xs">{{ .Repo.Created | timeFmt }}</time> 50 </p> 51 </div> 52 {{ else if .Follow }} 53 {{ $userHandle := index $.DidHandleMap .Follow.UserDid }} 54 {{ $subjectHandle := index $.DidHandleMap .Follow.SubjectDid }} 55 <div class="flex items-center"> 56 - <p class="text-gray-600"> 57 <a href="/{{ $userHandle }}" class="no-underline hover:underline">{{ $userHandle | truncateAt30 }}</a> 58 followed 59 <a href="/{{ $subjectHandle }}" class="no-underline hover:underline">{{ $subjectHandle | truncateAt30 }}</a> 60 - <time class="text-gray-700 text-xs">{{ .Follow.FollowedAt | timeFmt }}</time> 61 </p> 62 </div> 63 {{ else if .Star }} 64 {{ $starrerHandle := index $.DidHandleMap .Star.StarredByDid }} 65 {{ $repoOwnerHandle := index $.DidHandleMap .Star.Repo.Did }} 66 <div class="flex items-center"> 67 - <p class="text-gray-600"> 68 <a href="/{{ $starrerHandle }}" class="no-underline hover:underline">{{ $starrerHandle | truncateAt30 }}</a> 69 starred 70 <a href="/{{ $repoOwnerHandle }}/{{ .Star.Repo.Name }}" class="no-underline hover:underline">{{ $repoOwnerHandle | truncateAt30 }}/{{ .Star.Repo.Name }}</a> 71 - <time class="text-gray-700 text-xs">{{ .Star.Created | timeFmt }}</time> 72 </p> 73 </div> 74 {{ end }} ··· 77 </div> 78 </div> 79 {{ end }} 80 -
··· 17 {{ end }} 18 19 {{ define "hero" }} 20 + <div class="flex flex-col items-center justify-center text-center rounded drop-shadow bg-white dark:bg-gray-800 text-black dark:text-white py-4 px-10"> 21 <div class="font-bold italic text-4xl mb-4"> 22 tangled 23 </div> 24 <div class="italic text-lg"> 25 tightly-knit social coding, <a href="/login" class="underline inline-flex gap-1 items-center">join now {{ i "arrow-right" "w-4 h-4" }}</a> 26 + <p class="pt-5 px-10 text-sm text-gray-500 dark:text-gray-400">Join our <a href="https://chat.tangled.sh">Discord</a> or IRC channel: <a href="https://web.libera.chat/#tangled"><code>#tangled</code> on Libera Chat</a>. 27 Read an introduction to Tangled <a href="https://blog.tangled.sh/intro">here</a>.</p> 28 </div> 29 </div> ··· 32 {{ define "timeline" }} 33 <div> 34 <div class="p-6"> 35 + <p class="text-xl font-bold dark:text-white">Timeline</p> 36 </div> 37 38 <div class="flex flex-col gap-3 relative"> 39 + <div class="absolute left-8 top-0 bottom-0 w-px bg-gray-300 dark:bg-gray-600"></div> 40 {{ range .Timeline }} 41 + <div class="px-6 py-2 bg-white dark:bg-gray-800 rounded drop-shadow-sm w-fit"> 42 {{ if .Repo }} 43 {{ $userHandle := index $.DidHandleMap .Repo.Did }} 44 <div class="flex items-center"> 45 + <p class="text-gray-600 dark:text-gray-300"> 46 <a href="/{{ $userHandle }}" class="no-underline hover:underline">{{ $userHandle | truncateAt30 }}</a> 47 + {{ if .Source }} 48 + forked 49 + <a href="/{{ index $.DidHandleMap .Source.Did }}/{{ .Source.Name }}" class="no-underline hover:underline"> 50 + {{ index $.DidHandleMap .Source.Did }}/{{ .Source.Name }} 51 + </a> 52 + to 53 + <a href="/{{ $userHandle }}/{{ .Repo.Name }}" class="no-underline hover:underline">{{ .Repo.Name }}</a> 54 + {{ else }} 55 + created 56 + <a href="/{{ $userHandle }}/{{ .Repo.Name }}" class="no-underline hover:underline">{{ .Repo.Name }}</a> 57 + {{ end }} 58 + <time class="text-gray-700 dark:text-gray-400 text-xs">{{ .Repo.Created | timeFmt }}</time> 59 </p> 60 </div> 61 {{ else if .Follow }} 62 {{ $userHandle := index $.DidHandleMap .Follow.UserDid }} 63 {{ $subjectHandle := index $.DidHandleMap .Follow.SubjectDid }} 64 <div class="flex items-center"> 65 + <p class="text-gray-600 dark:text-gray-300"> 66 <a href="/{{ $userHandle }}" class="no-underline hover:underline">{{ $userHandle | truncateAt30 }}</a> 67 followed 68 <a href="/{{ $subjectHandle }}" class="no-underline hover:underline">{{ $subjectHandle | truncateAt30 }}</a> 69 + <time class="text-gray-700 dark:text-gray-400 text-xs">{{ .Follow.FollowedAt | timeFmt }}</time> 70 </p> 71 </div> 72 {{ else if .Star }} 73 {{ $starrerHandle := index $.DidHandleMap .Star.StarredByDid }} 74 {{ $repoOwnerHandle := index $.DidHandleMap .Star.Repo.Did }} 75 <div class="flex items-center"> 76 + <p class="text-gray-600 dark:text-gray-300"> 77 <a href="/{{ $starrerHandle }}" class="no-underline hover:underline">{{ $starrerHandle | truncateAt30 }}</a> 78 starred 79 <a href="/{{ $repoOwnerHandle }}/{{ .Star.Repo.Name }}" class="no-underline hover:underline">{{ $repoOwnerHandle | truncateAt30 }}/{{ .Star.Repo.Name }}</a> 80 + <time class="text-gray-700 dark:text-gray-400 text-xs">{{ .Star.Created | timeFmt }}</time> 81 </p> 82 </div> 83 {{ end }} ··· 86 </div> 87 </div> 88 {{ end }}
+6
appview/pages/templates/user/fragments/bluesky.html
···
··· 1 + {{ define "user/fragments/bluesky" }} 2 + <svg class="{{.}}" xmlns="http://www.w3.org/2000/svg" role="img" viewBox="-3 -3 30 30"> 3 + <title>Bluesky</title> 4 + <path fill="none" stroke="currentColor" d="M12 10.8c-1.087-2.114-4.046-6.053-6.798-7.995C2.566.944 1.561 1.266.902 1.565.139 1.908 0 3.08 0 3.768c0 .69.378 5.65.624 6.479.815 2.736 3.713 3.66 6.383 3.364.136-.02.275-.039.415-.056-.138.022-.276.04-.415.056-3.912.58-7.387 2.005-2.83 7.078 5.013 5.19 6.87-1.113 7.823-4.308.953 3.195 2.05 9.271 7.733 4.308 4.267-4.308 1.172-6.498-2.74-7.078a8.741 8.741 0 0 1-.415-.056c.14.017.279.036.415.056 2.67.297 5.568-.628 6.383-3.364.246-.828.624-5.79.624-6.478 0-.69-.139-1.861-.902-2.206-.659-.298-1.664-.62-4.3 1.24C16.046 4.748 13.087 8.687 12 10.8Z" stroke-width="2.25"/> 5 + </svg> 6 + {{ end }}
+111
appview/pages/templates/user/fragments/editBio.html
···
··· 1 + {{ define "user/fragments/editBio" }} 2 + <form 3 + hx-post="/profile/bio" 4 + class="flex flex-col gap-4 my-2 max-w-full" 5 + hx-disabled-elt="#save-btn,#cancel-btn" 6 + hx-swap="none" 7 + hx-indicator="#spinner"> 8 + <div class="flex flex-col gap-1"> 9 + {{ $description := "" }} 10 + {{ if and .Profile .Profile.Description }} 11 + {{ $description = .Profile.Description }} 12 + {{ end }} 13 + <label class="m-0 p-0" for="description">bio</label> 14 + <textarea 15 + type="text" 16 + class="py-1 px-1 w-full" 17 + name="description" 18 + rows="3" 19 + placeholder="write a bio">{{ $description }}</textarea> 20 + </div> 21 + 22 + <div class="flex flex-col gap-1"> 23 + <label class="m-0 p-0" for="location">location</label> 24 + <div class="flex items-center gap-2 w-full"> 25 + {{ $location := "" }} 26 + {{ if and .Profile .Profile.Location }} 27 + {{ $location = .Profile.Location }} 28 + {{ end }} 29 + <span class="flex-shrink-0">{{ i "map-pin" "size-4" }}</span> 30 + <input type="text" class="py-1 px-1 w-full" name="location" value="{{ $location }}"> 31 + </div> 32 + </div> 33 + 34 + <div class="flex flex-col gap-1"> 35 + <label class="m-0 p-0">social links</label> 36 + <div class="flex items-center gap-2 py-1"> 37 + {{ $includeBsky := false }} 38 + {{ if and .Profile .Profile.IncludeBluesky }} 39 + {{ $includeBsky = true }} 40 + {{ end }} 41 + <input type="checkbox" id="includeBluesky" name="includeBluesky" value="on" {{if $includeBsky}}checked{{end}}> 42 + <label for="includeBluesky" class="my-0 py-0 normal-case font-normal">Link to Bluesky account</label> 43 + </div> 44 + 45 + {{ $profile := .Profile }} 46 + {{ range $idx, $s := (sequence 5) }} 47 + {{ $link := "" }} 48 + {{ if and $profile $profile.Links }} 49 + {{ if lt $idx (len $profile.Links) }} 50 + {{ $link = index $profile.Links $idx }} 51 + {{ end }} 52 + {{ end }} 53 + 54 + <div class="flex items-center gap-2 w-full"> 55 + <span class="flex-shrink-0">{{ i "link" "size-4" }}</span> 56 + <input type="text" class="py-1 px-1 w-full" name="link{{$idx}}" value="{{ $link }}" placeholder="social link {{add $idx 1}}"> 57 + </div> 58 + {{ end }} 59 + </div> 60 + 61 + <div class="flex flex-col gap-1"> 62 + <label class="m-0 p-0">vanity stats</label> 63 + {{ range $idx, $s := (sequence 2) }} 64 + {{ $stat := "" }} 65 + {{ if and $profile $profile.Stats }} 66 + {{ if lt $idx (len $profile.Stats) }} 67 + {{ $s := index $profile.Stats $idx }} 68 + {{ $stat = $s.Kind }} 69 + {{ end }} 70 + {{ end }} 71 + 72 + {{ block "stat" (list $idx $stat) }} {{ end }} 73 + {{ end }} 74 + </div> 75 + 76 + <div class="flex items-center gap-2 justify-between"> 77 + <button id="save-btn" type="submit" class="btn p-1 w-full flex items-center gap-2 no-underline text-sm"> 78 + {{ i "check" "size-4" }} save 79 + <span id="spinner" class="group"> 80 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 81 + </span> 82 + </button> 83 + <a href="/{{.LoggedInUser.Did}}" class="w-full no-underline hover:no-underline"> 84 + <button id="cancel-btn" type="button" class="btn p-1 w-full flex items-center gap-2 no-underline text-sm"> 85 + {{ i "x" "size-4" }} cancel 86 + </button> 87 + </a> 88 + </div> 89 + </form> 90 + {{ end }} 91 + 92 + {{ define "stat" }} 93 + {{ $id := index . 0 }} 94 + {{ $stat := index . 1 }} 95 + <select class="stat-group w-full p-1 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700 text-sm" id="stat{{$id}}" name="stat{{$id}}"> 96 + <option value="">choose stat</option> 97 + {{ $stats := assoc 98 + "merged-pull-request-count" "Merged PR Count" 99 + "closed-pull-request-count" "Closed PR Count" 100 + "open-pull-request-count" "Open PR Count" 101 + "open-issue-count" "Open Issue Count" 102 + "closed-issue-count" "Closed Issue Count" 103 + "repository-count" "Repository Count" 104 + }} 105 + {{ range $s := $stats }} 106 + {{ $value := index $s 0 }} 107 + {{ $label := index $s 1 }} 108 + <option value="{{ $value }}"{{ if eq $stat $value }} selected{{ end }}>{{ $label }}</option> 109 + {{ end }} 110 + </select> 111 + {{ end }}
+42
appview/pages/templates/user/fragments/editPins.html
···
··· 1 + {{ define "user/fragments/editPins" }} 2 + {{ $profile := .Profile }} 3 + <form 4 + hx-post="/profile/pins" 5 + hx-disabled-elt="#save-btn,#cancel-btn" 6 + hx-swap="none" 7 + hx-indicator="#spinner"> 8 + <div class="flex items-center justify-between mb-2"> 9 + <p class="text-sm font-bold p-2 dark:text-white">SELECT PINNED REPOS</p> 10 + <div class="flex items-center gap-2"> 11 + <button id="save-btn" type="submit" class="btn px-2 flex items-center gap-2 no-underline text-sm"> 12 + {{ i "check" "w-3 h-3" }} save 13 + <span id="spinner" class="group"> 14 + {{ i "loader-circle" "w-3 h-3 animate-spin hidden group-[.htmx-request]:inline" }} 15 + </span> 16 + </button> 17 + <a href="/{{.LoggedInUser.Did}}" class="w-full no-underline hover:no-underline"> 18 + <button id="cancel-btn" type="button" class="btn px-2 w-full flex items-center gap-2 no-underline text-sm"> 19 + {{ i "x" "w-3 h-3" }} cancel 20 + </button> 21 + </a> 22 + </div> 23 + </div> 24 + <div id="repos" class="grid grid-cols-1 gap-1 mb-6 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700"> 25 + {{ range $idx, $r := .AllRepos }} 26 + <div class="flex items-center gap-2 text-base p-2 border-b border-gray-200 dark:border-gray-700"> 27 + <input type="checkbox" id="repo-{{$idx}}" name="pinnedRepo{{$idx}}" value="{{.RepoAt}}" {{if .IsPinned}}checked{{end}}> 28 + <label for="repo-{{$idx}}" class="my-0 py-0 normal-case font-normal w-full"> 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> 31 + <div class="flex gap-1 items-center"> 32 + {{ i "star" "size-4 fill-current" }} 33 + <span>{{ .RepoStats.StarCount }}</span> 34 + </div> 35 + </div> 36 + </label> 37 + </div> 38 + {{ end }} 39 + </div> 40 + 41 + </form> 42 + {{ end }}
+17
appview/pages/templates/user/fragments/follow.html
···
··· 1 + {{ define "user/fragments/follow" }} 2 + <button id="followBtn" 3 + class="btn mt-2 w-full" 4 + 5 + {{ if eq .FollowStatus.String "IsNotFollowing" }} 6 + hx-post="/follow?subject={{.UserDid}}" 7 + {{ else }} 8 + hx-delete="/follow?subject={{.UserDid}}" 9 + {{ end }} 10 + 11 + hx-trigger="click" 12 + hx-target="#followBtn" 13 + hx-swap="outerHTML" 14 + > 15 + {{ if eq .FollowStatus.String "IsNotFollowing" }}Follow{{ else }}Unfollow{{ end }} 16 + </button> 17 + {{ end }}
+97
appview/pages/templates/user/fragments/profileCard.html
···
··· 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"> 3 + <div class="grid grid-cols-3 md:grid-cols-1 gap-1 items-center"> 4 + <div id="avatar" class="col-span-1 flex justify-center items-center"> 5 + {{ if .AvatarUri }} 6 + <img class="w-3/4 rounded-full p-2" src="{{ .AvatarUri }}" /> 7 + {{ end }} 8 + </div> 9 + <div class="col-span-2"> 10 + <p title="{{ didOrHandle .UserDid .UserHandle }}" 11 + class="text-lg font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> 12 + {{ didOrHandle .UserDid .UserHandle }} 13 + </p> 14 + 15 + <div class="md:hidden"> 16 + {{ block "followerFollowing" (list .Followers .Following) }} {{ end }} 17 + </div> 18 + </div> 19 + <div class="col-span-3 md:col-span-full"> 20 + <div id="profile-bio" class="text-sm"> 21 + {{ $profile := .Profile }} 22 + {{ with .Profile }} 23 + 24 + {{ if .Description }} 25 + <p class="text-base pb-4 md:pb-2">{{ .Description }}</p> 26 + {{ end }} 27 + 28 + <div class="hidden md:block"> 29 + {{ block "followerFollowing" (list $.Followers $.Following) }} {{ end }} 30 + </div> 31 + 32 + <div class="flex flex-col gap-2 mb-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> 33 + {{ if .Location }} 34 + <div class="flex items-center gap-2"> 35 + <span class="flex-shrink-0">{{ i "map-pin" "size-4" }}</span> 36 + <span>{{ .Location }}</span> 37 + </div> 38 + {{ end }} 39 + {{ if .IncludeBluesky }} 40 + <div class="flex items-center gap-2"> 41 + <span class="flex-shrink-0">{{ template "user/fragments/bluesky" "w-4 h-4 text-black dark:text-white" }}</span> 42 + <a id="bluesky-link" href="https://bsky.app/profile/{{ $.UserDid }}">{{ didOrHandle $.UserDid $.UserHandle }}</a> 43 + </div> 44 + {{ end }} 45 + {{ range $link := .Links }} 46 + {{ if $link }} 47 + <div class="flex items-center gap-2"> 48 + <span class="flex-shrink-0">{{ i "link" "size-4" }}</span> 49 + <a href="{{ $link }}">{{ $link }}</a> 50 + </div> 51 + {{ end }} 52 + {{ end }} 53 + {{ if not $profile.IsStatsEmpty }} 54 + <div class="flex items-center justify-evenly gap-2 py-2"> 55 + {{ range $stat := .Stats }} 56 + {{ if $stat.Kind }} 57 + <div class="flex flex-col items-center gap-2"> 58 + <span class="text-xl font-bold">{{ $stat.Value }}</span> 59 + <span>{{ $stat.Kind.String }}</span> 60 + </div> 61 + {{ end }} 62 + {{ end }} 63 + </div> 64 + {{ end }} 65 + </div> 66 + {{ end }} 67 + {{ if ne .FollowStatus.String "IsSelf" }} 68 + {{ template "user/fragments/follow" . }} 69 + {{ else }} 70 + <button id="editBtn" 71 + class="btn mt-2 w-full flex items-center gap-2 group" 72 + hx-target="#profile-bio" 73 + hx-get="/profile/edit-bio" 74 + hx-swap="innerHTML"> 75 + {{ i "pencil" "w-4 h-4" }} 76 + edit 77 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 78 + </button> 79 + {{ end }} 80 + </div> 81 + <div id="update-profile" class="text-red-400 dark:text-red-500"></div> 82 + </div> 83 + </div> 84 + </div> 85 + {{ end }} 86 + 87 + {{ define "followerFollowing" }} 88 + {{ $followers := index . 0 }} 89 + {{ $following := index . 1 }} 90 + <div class="flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full text-sm"> 91 + <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 92 + <span id="followers">{{ $followers }} followers</span> 93 + <span class="select-none after:content-['ยท']"></span> 94 + <span id="following">{{ $following }} following</span> 95 + </div> 96 + {{ end }} 97 +
+25 -28
appview/pages/templates/user/login.html
··· 1 {{ define "user/login" }} 2 <!doctype html> 3 - <html lang="en"> 4 <head> 5 <meta charset="UTF-8" /> 6 <meta ··· 8 content="width=device-width, initial-scale=1.0" 9 /> 10 <script src="/static/htmx.min.js"></script> 11 - <link rel="stylesheet" href="/static/tw.css" type="text/css" /> 12 <title>login</title> 13 </head> 14 <body class="flex items-center justify-center min-h-screen"> 15 - <main class="max-w-64"> 16 - <h1 class="text-center text-2xl font-semibold italic"> 17 tangled 18 </h1> 19 - <h2 class="text-center text-xl italic"> 20 tightly-knit social coding. 21 </h2> 22 <form 23 - class="w-full mt-4" 24 hx-post="/login" 25 hx-swap="none" 26 - hx-disabled-elt="this" 27 > 28 <div class="flex flex-col"> 29 <label for="handle">handle</label> 30 - <input type="text" id="handle" name="handle" required /> 31 - <span class="text-xs text-gray-500 mt-1"> 32 - You need to use your 33 - <a href="https://bsky.app">Bluesky</a> handle to log 34 - in. 35 - </span> 36 - </div> 37 - 38 - <div class="flex flex-col mt-2"> 39 - <label for="app_password">app password</label> 40 <input 41 - type="password" 42 - id="app_password" 43 - name="app_password" 44 required 45 /> 46 - <span class="text-xs text-gray-500 mt-1"> 47 - Generate an app password 48 - <a 49 - href="https://bsky.app/settings/app-passwords" 50 - target="_blank" 51 - >here</a 52 - >. 53 </span> 54 </div> 55 ··· 57 class="btn w-full my-2 mt-6" 58 type="submit" 59 id="login-button" 60 > 61 <span>login</span> 62 </button> 63 </form> 64 <p class="text-sm text-gray-500"> 65 - Join our IRC channel: 66 <a href="https://web.libera.chat/#tangled" 67 ><code>#tangled</code> on Libera Chat</a 68 >.
··· 1 {{ define "user/login" }} 2 <!doctype html> 3 + <html lang="en" class="dark:bg-gray-900"> 4 <head> 5 <meta charset="UTF-8" /> 6 <meta ··· 8 content="width=device-width, initial-scale=1.0" 9 /> 10 <script src="/static/htmx.min.js"></script> 11 + <link 12 + rel="stylesheet" 13 + href="/static/tw.css?{{ cssContentHash }}" 14 + type="text/css" 15 + /> 16 <title>login</title> 17 </head> 18 <body class="flex items-center justify-center min-h-screen"> 19 + <main class="max-w-md px-6 -mt-4"> 20 + <h1 21 + class="text-center text-2xl font-semibold italic dark:text-white" 22 + > 23 tangled 24 </h1> 25 + <h2 class="text-center text-xl italic dark:text-white"> 26 tightly-knit social coding. 27 </h2> 28 <form 29 + class="mt-4 max-w-sm mx-auto" 30 hx-post="/login" 31 hx-swap="none" 32 + hx-disabled-elt="#login-button" 33 > 34 <div class="flex flex-col"> 35 <label for="handle">handle</label> 36 <input 37 + type="text" 38 + id="handle" 39 + name="handle" 40 + tabindex="1" 41 required 42 /> 43 + <span class="text-sm text-gray-500 mt-1"> 44 + Use your 45 + <a href="https://bsky.app">Bluesky</a> handle to log 46 + in. You will then be redirected to your PDS to 47 + complete authentication. 48 </span> 49 </div> 50 ··· 52 class="btn w-full my-2 mt-6" 53 type="submit" 54 id="login-button" 55 + tabindex="3" 56 > 57 <span>login</span> 58 </button> 59 </form> 60 <p class="text-sm text-gray-500"> 61 + Join our <a href="https://chat.tangled.sh">Discord</a> or 62 + IRC channel: 63 <a href="https://web.libera.chat/#tangled" 64 ><code>#tangled</code> on Libera Chat</a 65 >.
+287 -84
appview/pages/templates/user/profile.html
··· 1 - {{ define "title" }}{{ or .UserHandle .UserDid }}{{ end }} 2 3 {{ define "content" }} 4 - <div class="grid grid-cols-1 md:grid-cols-4 gap-6"> 5 - <div class="md:col-span-1"> 6 - {{ block "profileCard" . }}{{ end }} 7 </div> 8 9 - <div class="md:col-span-3"> 10 - {{ block "ownRepos" . }}{{ end }} 11 - {{ block "collaboratingRepos" . }}{{ end }} 12 </div> 13 - </div> 14 {{ end }} 15 16 - {{ define "profileCard" }} 17 - <div class="bg-white px-6 py-4 rounded drop-shadow-sm max-h-fit"> 18 - <div class="flex justify-center items-center"> 19 - {{ if .AvatarUri }} 20 - <img class="w-1/2 rounded-full p-2" src="{{ .AvatarUri }}" /> 21 {{ end }} 22 - </div> 23 - <p class="text-xl font-bold text-center"> 24 - {{ truncateAt30 (didOrHandle .UserDid .UserHandle) }} 25 - </p> 26 - <div class="text-sm text-center"> 27 - <span>{{ .ProfileStats.Followers }} followers</span> 28 - <div 29 - class="inline-block px-1 select-none after:content-['ยท']" 30 - ></div> 31 - <span>{{ .ProfileStats.Following }} following</span> 32 </div> 33 34 - {{ if ne .FollowStatus.String "IsSelf" }} 35 - {{ template "fragments/follow" . }} 36 {{ end }} 37 - </div> 38 {{ end }} 39 40 {{ define "ownRepos" }} 41 - <p class="text-sm font-bold py-2 px-6">REPOS</p> 42 - <div id="repos" class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6"> 43 - {{ range .Repos }} 44 - <div 45 - id="repo-card" 46 - class="py-4 px-6 drop-shadow-sm rounded bg-white" 47 - > 48 - <div id="repo-card-name" class="font-medium"> 49 - <a href="/@{{ or $.UserHandle $.UserDid }}/{{ .Name }}" 50 - >{{ .Name }}</a 51 - > 52 </div> 53 - {{ if .Description }} 54 - <div class="text-gray-600 text-sm"> 55 - {{ .Description }} 56 - </div> 57 - {{ end }} 58 - <div 59 - class="text-gray-400 pt-1 text-sm font-mono inline-flex gap-4 mt-auto" 60 - > 61 - 62 - {{ if .RepoStats.StarCount }} 63 - <div class="flex gap-1 items-center text-sm"> 64 - {{ i "star" "w-3 h-3 fill-current" }} 65 - <span>{{ .RepoStats.StarCount }}</span> 66 - </div> 67 - {{ end }} 68 - </div> 69 - </div> 70 - {{ else }} 71 - <p class="px-6">This user does not have any repos yet.</p> 72 - {{ end }} 73 - </div> 74 {{ end }} 75 76 {{ define "collaboratingRepos" }} 77 - <p class="text-sm font-bold py-2 px-6">COLLABORATING ON</p> 78 - <div id="collaborating" class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6"> 79 - {{ range .CollaboratingRepos }} 80 - <div 81 - id="repo-card" 82 - class="py-4 px-6 drop-shadow-sm rounded bg-white flex flex-col" 83 - > 84 - <div id="repo-card-name" class="font-medium"> 85 - <a href="/{{ index $.DidHandleMap .Did }}/{{ .Name }}"> 86 - {{ index $.DidHandleMap .Did }}/{{ .Name }} 87 - </a> 88 </div> 89 - {{ if .Description }} 90 - <div class="text-gray-600 text-sm"> 91 - {{ .Description }} 92 </div> 93 {{ end }} 94 - <div class="text-gray-400 pt-1 text-sm font-mono inline-flex gap-4 mt-auto"> 95 - 96 - {{ if .RepoStats.StarCount }} 97 - <div class="flex gap-1 items-center text-sm"> 98 - {{ i "star" "w-3 h-3 fill-current" }} 99 - <span>{{ .RepoStats.StarCount }}</span> 100 - </div> 101 - {{ end }} 102 - </div> 103 </div> 104 - {{ else }} 105 - <p class="px-6">This user is not collaborating.</p> 106 - {{ end }} 107 </div> 108 {{ end }}
··· 1 + {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }}{{ end }} 2 3 {{ define "content" }} 4 + <div class="grid grid-cols-1 md:grid-cols-8 gap-6"> 5 + <div class="md:col-span-2 order-1 md:order-1"> 6 + {{ template "user/fragments/profileCard" .Card }} 7 + </div> 8 + <div id="all-repos" class="md:col-span-3 order-2 md:order-2"> 9 + {{ block "ownRepos" . }}{{ end }} 10 + {{ block "collaboratingRepos" . }}{{ end }} 11 + </div> 12 + <div class="md:col-span-3 order-3 md:order-3"> 13 + {{ block "profileTimeline" . }}{{ end }} 14 + </div> 15 + </div> 16 + {{ end }} 17 + 18 + {{ define "profileTimeline" }} 19 + <p class="text-sm font-bold p-2 dark:text-white">ACTIVITY</p> 20 + <div class="flex flex-col gap-6 relative"> 21 + {{ with .ProfileTimeline }} 22 + {{ range $idx, $byMonth := .ByMonth }} 23 + {{ with $byMonth }} 24 + <div class="bg-white dark:bg-gray-800 px-6 py-4 rounded drop-shadow-sm"> 25 + {{ if eq $idx 0 }} 26 + 27 + {{ else }} 28 + {{ $s := "s" }} 29 + {{ if eq $idx 1 }} 30 + {{ $s = "" }} 31 + {{ end }} 32 + <p class="text-sm font-bold dark:text-white mb-2">{{$idx}} month{{$s}} ago</p> 33 + {{ end }} 34 + 35 + {{ if .IsEmpty }} 36 + <div class="text-gray-500 dark:text-gray-400"> 37 + No activity for this month 38 + </div> 39 + {{ else }} 40 + <div class="flex flex-col gap-1"> 41 + {{ block "repoEvents" (list .RepoEvents $.DidHandleMap) }} {{ end }} 42 + {{ block "issueEvents" (list .IssueEvents $.DidHandleMap) }} {{ end }} 43 + {{ block "pullEvents" (list .PullEvents $.DidHandleMap) }} {{ end }} 44 + </div> 45 + {{ end }} 46 </div> 47 48 + {{ end }} 49 + {{ else }} 50 + <p class="dark:text-white">This user does not have any activity yet.</p> 51 + {{ end }} 52 + {{ end }} 53 + </div> 54 + {{ end }} 55 + 56 + {{ define "repoEvents" }} 57 + {{ $items := index . 0 }} 58 + {{ $handleMap := index . 1 }} 59 + 60 + {{ if gt (len $items) 0 }} 61 + <details> 62 + <summary class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400"> 63 + <div class="flex flex-wrap items-center gap-2"> 64 + {{ i "book-plus" "w-4 h-4" }} 65 + created {{ len $items }} {{if eq (len $items) 1 }}repository{{else}}repositories{{end}} 66 </div> 67 + </summary> 68 + <div class="py-2 text-sm flex flex-col gap-3 mb-2"> 69 + {{ range $items }} 70 + <div class="flex flex-wrap items-center gap-2"> 71 + <span class="text-gray-500 dark:text-gray-400"> 72 + {{ if .Source }} 73 + {{ i "git-fork" "w-4 h-4" }} 74 + {{ else }} 75 + {{ i "book-plus" "w-4 h-4" }} 76 + {{ end }} 77 + </span> 78 + <a href="/{{ index $handleMap .Repo.Did }}/{{ .Repo.Name }}" class="no-underline hover:underline"> 79 + {{- .Repo.Name -}} 80 + </a> 81 + </div> 82 + {{ end }} 83 + </div> 84 + </details> 85 + {{ end }} 86 {{ end }} 87 88 + {{ define "issueEvents" }} 89 + {{ $i := index . 0 }} 90 + {{ $items := $i.Items }} 91 + {{ $stats := $i.Stats }} 92 + {{ $handleMap := index . 1 }} 93 + 94 + {{ if gt (len $items) 0 }} 95 + <details> 96 + <summary class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400"> 97 + <div class="flex flex-wrap items-center gap-2"> 98 + {{ i "circle-dot" "w-4 h-4" }} 99 + 100 + <div> 101 + created {{ len $items }} {{if eq (len $items) 1 }}issue{{else}}issues{{end}} 102 + </div> 103 + 104 + {{ if gt $stats.Open 0 }} 105 + <span class="px-2 py-1/2 text-sm rounded text-white bg-green-600 dark:bg-green-700"> 106 + {{$stats.Open}} open 107 + </span> 108 + {{ end }} 109 + 110 + {{ if gt $stats.Closed 0 }} 111 + <span class="px-2 py-1/2 text-sm rounded text-white bg-gray-800 dark:bg-gray-700"> 112 + {{$stats.Closed}} closed 113 + </span> 114 + {{ end }} 115 + 116 + </div> 117 + </summary> 118 + <div class="py-2 text-sm flex flex-col gap-3 mb-2"> 119 + {{ range $items }} 120 + {{ $repoOwner := index $handleMap .Metadata.Repo.Did }} 121 + {{ $repoName := .Metadata.Repo.Name }} 122 + {{ $repoUrl := printf "%s/%s" $repoOwner $repoName }} 123 + 124 + <div class="flex gap-2 text-gray-600 dark:text-gray-300"> 125 + {{ if .Open }} 126 + <span class="text-green-600 dark:text-green-500"> 127 + {{ i "circle-dot" "w-4 h-4" }} 128 + </span> 129 + {{ else }} 130 + <span class="text-gray-500 dark:text-gray-400"> 131 + {{ i "ban" "w-4 h-4" }} 132 + </span> 133 {{ end }} 134 + <div class="flex-none min-w-8 text-right"> 135 + <span class="text-gray-500 dark:text-gray-400">#{{ .IssueId }}</span> 136 + </div> 137 + <div class="break-words max-w-full"> 138 + <a href="/{{$repoUrl}}/issues/{{ .IssueId }}" class="no-underline hover:underline"> 139 + {{ .Title -}} 140 + </a> 141 + on 142 + <a href="/{{$repoUrl}}" class="no-underline hover:underline whitespace-nowrap"> 143 + {{$repoUrl}} 144 + </a> 145 + </div> 146 + </div> 147 + {{ end }} 148 + </div> 149 + </details> 150 + {{ end }} 151 + {{ end }} 152 + 153 + {{ define "pullEvents" }} 154 + {{ $i := index . 0 }} 155 + {{ $items := $i.Items }} 156 + {{ $stats := $i.Stats }} 157 + {{ $handleMap := index . 1 }} 158 + {{ if gt (len $items) 0 }} 159 + <details> 160 + <summary class="list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400"> 161 + <div class="flex flex-wrap items-center gap-2"> 162 + {{ i "git-pull-request" "w-4 h-4" }} 163 + 164 + <div> 165 + created {{ len $items }} {{if eq (len $items) 1 }}pull request{{else}}pull requests{{end}} 166 + </div> 167 + 168 + {{ if gt $stats.Open 0 }} 169 + <span class="px-2 py-1/2 text-sm rounded text-white bg-green-600 dark:bg-green-700"> 170 + {{$stats.Open}} open 171 + </span> 172 + {{ end }} 173 + 174 + {{ if gt $stats.Merged 0 }} 175 + <span class="px-2 py-1/2 text-sm rounded text-white bg-purple-600 dark:bg-purple-700"> 176 + {{$stats.Merged}} merged 177 + </span> 178 + {{ end }} 179 + 180 + 181 + {{ if gt $stats.Closed 0 }} 182 + <span class="px-2 py-1/2 text-sm rounded text-white bg-gray-800 dark:bg-gray-700"> 183 + {{$stats.Closed}} closed 184 + </span> 185 + {{ end }} 186 + 187 </div> 188 + </summary> 189 + <div class="py-2 text-sm flex flex-col gap-3 mb-2"> 190 + {{ range $items }} 191 + {{ $repoOwner := index $handleMap .Repo.Did }} 192 + {{ $repoName := .Repo.Name }} 193 + {{ $repoUrl := printf "%s/%s" $repoOwner $repoName }} 194 195 + <div class="flex gap-2 text-gray-600 dark:text-gray-300"> 196 + {{ if .State.IsOpen }} 197 + <span class="text-green-600 dark:text-green-500"> 198 + {{ i "git-pull-request" "w-4 h-4" }} 199 + </span> 200 + {{ else if .State.IsMerged }} 201 + <span class="text-purple-600 dark:text-purple-500"> 202 + {{ i "git-merge" "w-4 h-4" }} 203 + </span> 204 + {{ else }} 205 + <span class="text-gray-600 dark:text-gray-300"> 206 + {{ i "git-pull-request-closed" "w-4 h-4" }} 207 + </span> 208 + {{ end }} 209 + <div class="flex-none min-w-8 text-right"> 210 + <span class="text-gray-500 dark:text-gray-400">#{{ .PullId }}</span> 211 + </div> 212 + <div class="break-words max-w-full"> 213 + <a href="/{{$repoUrl}}/pulls/{{ .PullId }}" class="no-underline hover:underline"> 214 + {{ .Title -}} 215 + </a> 216 + on 217 + <a href="/{{$repoUrl}}" class="no-underline hover:underline whitespace-nowrap"> 218 + {{$repoUrl}} 219 + </a> 220 + </div> 221 + </div> 222 {{ end }} 223 + </div> 224 + </details> 225 + {{ end }} 226 {{ end }} 227 228 {{ define "ownRepos" }} 229 + <div class="text-sm font-bold p-2 pr-0 dark:text-white flex items-center justify-between gap-2"> 230 + <a href="/@{{ or $.Card.UserHandle $.Card.UserDid }}?tab=repos" 231 + class="flex text-black dark:text-white items-center gap-4 no-underline hover:no-underline group"> 232 + <span>PINNED REPOS</span> 233 + <span class="flex md:hidden group-hover:flex gap-2 items-center font-normal text-sm text-gray-500 dark:text-gray-400 "> 234 + view all {{ i "chevron-right" "w-4 h-4" }} 235 + </span> 236 + </a> 237 + {{ if and .LoggedInUser (eq .LoggedInUser.Did .Card.UserDid) }} 238 + <button 239 + hx-get="profile/edit-pins" 240 + hx-target="#all-repos" 241 + class="btn font-normal text-sm flex gap-2 items-center group"> 242 + {{ i "pencil" "w-3 h-3" }} 243 + edit 244 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 245 + </button> 246 + {{ end }} 247 + </div> 248 + <div id="repos" class="grid grid-cols-1 gap-4 mb-6"> 249 + {{ range .Repos }} 250 + <div 251 + id="repo-card" 252 + class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800"> 253 + <div id="repo-card-name" class="font-medium"> 254 + <a href="/@{{ or $.Card.UserHandle $.Card.UserDid }}/{{ .Name }}" 255 + >{{ .Name }}</a 256 + > 257 + </div> 258 + {{ if .Description }} 259 + <div class="text-gray-600 dark:text-gray-300 text-sm"> 260 + {{ .Description }} 261 + </div> 262 + {{ end }} 263 + <div class="text-gray-400 pt-1 text-sm font-mono inline-flex gap-4 mt-auto"> 264 + {{ if .RepoStats.StarCount }} 265 + <div class="flex gap-1 items-center text-sm"> 266 + {{ i "star" "w-3 h-3 fill-current" }} 267 + <span>{{ .RepoStats.StarCount }}</span> 268 </div> 269 + {{ end }} 270 + </div> 271 + </div> 272 + {{ else }} 273 + <p class="px-6 dark:text-white">This user does not have any repos yet.</p> 274 + {{ end }} 275 + </div> 276 {{ end }} 277 278 {{ define "collaboratingRepos" }} 279 + {{ if gt (len .CollaboratingRepos) 0 }} 280 + <p class="text-sm font-bold p-2 dark:text-white">COLLABORATING ON</p> 281 + <div id="collaborating" class="grid grid-cols-1 gap-4 mb-6"> 282 + {{ range .CollaboratingRepos }} 283 + <div 284 + id="repo-card" 285 + class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex flex-col"> 286 + <div id="repo-card-name" class="font-medium dark:text-white"> 287 + <a href="/{{ index $.DidHandleMap .Did }}/{{ .Name }}"> 288 + {{ index $.DidHandleMap .Did }}/{{ .Name }} 289 + </a> 290 + </div> 291 + {{ if .Description }} 292 + <div class="text-gray-600 dark:text-gray-300 text-sm"> 293 + {{ .Description }} 294 </div> 295 + {{ end }} 296 + <div class="text-gray-400 pt-1 text-sm font-mono inline-flex gap-4 mt-auto"> 297 + 298 + {{ if .RepoStats.StarCount }} 299 + <div class="flex gap-1 items-center text-sm"> 300 + {{ i "star" "w-3 h-3 fill-current" }} 301 + <span>{{ .RepoStats.StarCount }}</span> 302 </div> 303 {{ end }} 304 </div> 305 + </div> 306 + {{ else }} 307 + <p class="px-6 dark:text-white">This user is not collaborating.</p> 308 + {{ end }} 309 </div> 310 + {{ end }} 311 {{ end }}
+44
appview/pages/templates/user/repos.html
···
··· 1 + {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} ยท repos {{ end }} 2 + 3 + {{ define "content" }} 4 + <div class="grid grid-cols-1 md:grid-cols-8 gap-6"> 5 + <div class="md:col-span-2 order-1 md:order-1"> 6 + {{ template "user/fragments/profileCard" .Card }} 7 + </div> 8 + <div id="all-repos" class="md:col-span-6 order-2 md:order-2"> 9 + {{ block "ownRepos" . }}{{ end }} 10 + </div> 11 + </div> 12 + {{ end }} 13 + 14 + {{ define "ownRepos" }} 15 + <p class="text-sm font-bold p-2 dark:text-white">ALL REPOSITORIES</p> 16 + <div id="repos" class="grid grid-cols-1 gap-4 mb-6"> 17 + {{ range .Repos }} 18 + <div 19 + id="repo-card" 20 + class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800"> 21 + <div id="repo-card-name" class="font-medium"> 22 + <a href="/@{{ or $.Card.UserHandle $.Card.UserDid }}/{{ .Name }}" 23 + >{{ .Name }}</a 24 + > 25 + </div> 26 + {{ if .Description }} 27 + <div class="text-gray-600 dark:text-gray-300 text-sm"> 28 + {{ .Description }} 29 + </div> 30 + {{ end }} 31 + <div class="text-gray-400 pt-1 text-sm font-mono inline-flex gap-4 mt-auto"> 32 + {{ if .RepoStats.StarCount }} 33 + <div class="flex gap-1 items-center text-sm"> 34 + {{ i "star" "w-3 h-3 fill-current" }} 35 + <span>{{ .RepoStats.StarCount }}</span> 36 + </div> 37 + {{ end }} 38 + </div> 39 + </div> 40 + {{ else }} 41 + <p class="px-6 dark:text-white">This user does not have any repos yet.</p> 42 + {{ end }} 43 + </div> 44 + {{ end }}
+31
appview/pagination/page.go
···
··· 1 + package pagination 2 + 3 + type Page struct { 4 + Offset int // where to start from 5 + Limit int // number of items in a page 6 + } 7 + 8 + func FirstPage() Page { 9 + return Page{ 10 + Offset: 0, 11 + Limit: 10, 12 + } 13 + } 14 + 15 + func (p Page) Previous() Page { 16 + if p.Offset-p.Limit < 0 { 17 + return FirstPage() 18 + } else { 19 + return Page{ 20 + Offset: p.Offset - p.Limit, 21 + Limit: p.Limit, 22 + } 23 + } 24 + } 25 + 26 + func (p Page) Next() Page { 27 + return Page{ 28 + Offset: p.Offset + p.Limit, 29 + Limit: p.Limit, 30 + } 31 + }
+460
appview/settings/settings.go
···
··· 1 + package settings 2 + 3 + import ( 4 + "database/sql" 5 + "errors" 6 + "fmt" 7 + "log" 8 + "net/http" 9 + "net/url" 10 + "strings" 11 + "time" 12 + 13 + "github.com/go-chi/chi/v5" 14 + "tangled.sh/tangled.sh/core/api/tangled" 15 + "tangled.sh/tangled.sh/core/appview" 16 + "tangled.sh/tangled.sh/core/appview/db" 17 + "tangled.sh/tangled.sh/core/appview/email" 18 + "tangled.sh/tangled.sh/core/appview/middleware" 19 + "tangled.sh/tangled.sh/core/appview/oauth" 20 + "tangled.sh/tangled.sh/core/appview/pages" 21 + 22 + comatproto "github.com/bluesky-social/indigo/api/atproto" 23 + lexutil "github.com/bluesky-social/indigo/lex/util" 24 + "github.com/gliderlabs/ssh" 25 + "github.com/google/uuid" 26 + ) 27 + 28 + type Settings struct { 29 + Db *db.DB 30 + OAuth *oauth.OAuth 31 + Pages *pages.Pages 32 + Config *appview.Config 33 + } 34 + 35 + func (s *Settings) Router() http.Handler { 36 + r := chi.NewRouter() 37 + 38 + r.Use(middleware.AuthMiddleware(s.OAuth)) 39 + 40 + r.Get("/", s.settings) 41 + 42 + r.Route("/keys", func(r chi.Router) { 43 + r.Put("/", s.keys) 44 + r.Delete("/", s.keys) 45 + }) 46 + 47 + r.Route("/emails", func(r chi.Router) { 48 + r.Put("/", s.emails) 49 + r.Delete("/", s.emails) 50 + r.Get("/verify", s.emailsVerify) 51 + r.Post("/verify/resend", s.emailsVerifyResend) 52 + r.Post("/primary", s.emailsPrimary) 53 + }) 54 + 55 + return r 56 + } 57 + 58 + func (s *Settings) settings(w http.ResponseWriter, r *http.Request) { 59 + user := s.OAuth.GetUser(r) 60 + pubKeys, err := db.GetPublicKeys(s.Db, user.Did) 61 + if err != nil { 62 + log.Println(err) 63 + } 64 + 65 + emails, err := db.GetAllEmails(s.Db, user.Did) 66 + if err != nil { 67 + log.Println(err) 68 + } 69 + 70 + s.Pages.Settings(w, pages.SettingsParams{ 71 + LoggedInUser: user, 72 + PubKeys: pubKeys, 73 + Emails: emails, 74 + }) 75 + } 76 + 77 + // buildVerificationEmail creates an email.Email struct for verification emails 78 + func (s *Settings) buildVerificationEmail(emailAddr, did, code string) email.Email { 79 + verifyURL := s.verifyUrl(did, emailAddr, code) 80 + 81 + return email.Email{ 82 + APIKey: s.Config.Resend.ApiKey, 83 + From: "noreply@notifs.tangled.sh", 84 + To: emailAddr, 85 + Subject: "Verify your Tangled email", 86 + Text: `Click the link below (or copy and paste it into your browser) to verify your email address. 87 + ` + verifyURL, 88 + Html: `<p>Click the link (or copy and paste it into your browser) to verify your email address.</p> 89 + <p><a href="` + verifyURL + `">` + verifyURL + `</a></p>`, 90 + } 91 + } 92 + 93 + // sendVerificationEmail handles the common logic for sending verification emails 94 + func (s *Settings) sendVerificationEmail(w http.ResponseWriter, did, emailAddr, code string, errorContext string) error { 95 + emailToSend := s.buildVerificationEmail(emailAddr, did, code) 96 + 97 + err := email.SendEmail(emailToSend) 98 + if err != nil { 99 + log.Printf("sending email: %s", err) 100 + s.Pages.Notice(w, "settings-emails-error", fmt.Sprintf("Unable to send verification email at this moment, try again later. %s", errorContext)) 101 + return err 102 + } 103 + 104 + return nil 105 + } 106 + 107 + func (s *Settings) emails(w http.ResponseWriter, r *http.Request) { 108 + switch r.Method { 109 + case http.MethodGet: 110 + s.Pages.Notice(w, "settings-emails", "Unimplemented.") 111 + log.Println("unimplemented") 112 + return 113 + case http.MethodPut: 114 + did := s.OAuth.GetDid(r) 115 + emAddr := r.FormValue("email") 116 + emAddr = strings.TrimSpace(emAddr) 117 + 118 + if !email.IsValidEmail(emAddr) { 119 + s.Pages.Notice(w, "settings-emails-error", "Invalid email address.") 120 + return 121 + } 122 + 123 + // check if email already exists in database 124 + existingEmail, err := db.GetEmail(s.Db, did, emAddr) 125 + if err != nil && !errors.Is(err, sql.ErrNoRows) { 126 + log.Printf("checking for existing email: %s", err) 127 + s.Pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.") 128 + return 129 + } 130 + 131 + if err == nil { 132 + if existingEmail.Verified { 133 + s.Pages.Notice(w, "settings-emails-error", "This email is already verified.") 134 + return 135 + } 136 + 137 + s.Pages.Notice(w, "settings-emails-error", "This email is already added but not verified. Check your inbox for the verification link.") 138 + return 139 + } 140 + 141 + code := uuid.New().String() 142 + 143 + // Begin transaction 144 + tx, err := s.Db.Begin() 145 + if err != nil { 146 + log.Printf("failed to start transaction: %s", err) 147 + s.Pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.") 148 + return 149 + } 150 + defer tx.Rollback() 151 + 152 + if err := db.AddEmail(tx, db.Email{ 153 + Did: did, 154 + Address: emAddr, 155 + Verified: false, 156 + VerificationCode: code, 157 + }); err != nil { 158 + log.Printf("adding email: %s", err) 159 + s.Pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.") 160 + return 161 + } 162 + 163 + if err := s.sendVerificationEmail(w, did, emAddr, code, ""); err != nil { 164 + return 165 + } 166 + 167 + // Commit transaction 168 + if err := tx.Commit(); err != nil { 169 + log.Printf("failed to commit transaction: %s", err) 170 + s.Pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.") 171 + return 172 + } 173 + 174 + s.Pages.Notice(w, "settings-emails-success", "Click the link in the email we sent you to verify your email address.") 175 + return 176 + case http.MethodDelete: 177 + did := s.OAuth.GetDid(r) 178 + emailAddr := r.FormValue("email") 179 + emailAddr = strings.TrimSpace(emailAddr) 180 + 181 + // Begin transaction 182 + tx, err := s.Db.Begin() 183 + if err != nil { 184 + log.Printf("failed to start transaction: %s", err) 185 + s.Pages.Notice(w, "settings-emails-error", "Unable to delete email at this moment, try again later.") 186 + return 187 + } 188 + defer tx.Rollback() 189 + 190 + if err := db.DeleteEmail(tx, did, emailAddr); err != nil { 191 + log.Printf("deleting email: %s", err) 192 + s.Pages.Notice(w, "settings-emails-error", "Unable to delete email at this moment, try again later.") 193 + return 194 + } 195 + 196 + // Commit transaction 197 + if err := tx.Commit(); err != nil { 198 + log.Printf("failed to commit transaction: %s", err) 199 + s.Pages.Notice(w, "settings-emails-error", "Unable to delete email at this moment, try again later.") 200 + return 201 + } 202 + 203 + s.Pages.HxLocation(w, "/settings") 204 + return 205 + } 206 + } 207 + 208 + func (s *Settings) verifyUrl(did string, email string, code string) string { 209 + var appUrl string 210 + if s.Config.Core.Dev { 211 + appUrl = "http://" + s.Config.Core.ListenAddr 212 + } else { 213 + appUrl = "https://tangled.sh" 214 + } 215 + 216 + return fmt.Sprintf("%s/settings/emails/verify?did=%s&email=%s&code=%s", appUrl, url.QueryEscape(did), url.QueryEscape(email), url.QueryEscape(code)) 217 + } 218 + 219 + func (s *Settings) emailsVerify(w http.ResponseWriter, r *http.Request) { 220 + q := r.URL.Query() 221 + 222 + // Get the parameters directly from the query 223 + emailAddr := q.Get("email") 224 + did := q.Get("did") 225 + code := q.Get("code") 226 + 227 + valid, err := db.CheckValidVerificationCode(s.Db, did, emailAddr, code) 228 + if err != nil { 229 + log.Printf("checking email verification: %s", err) 230 + s.Pages.Notice(w, "settings-emails-error", "Error verifying email. Please try again later.") 231 + return 232 + } 233 + 234 + if !valid { 235 + s.Pages.Notice(w, "settings-emails-error", "Invalid verification code. Please request a new verification email.") 236 + return 237 + } 238 + 239 + // Mark email as verified in the database 240 + if err := db.MarkEmailVerified(s.Db, did, emailAddr); err != nil { 241 + log.Printf("marking email as verified: %s", err) 242 + s.Pages.Notice(w, "settings-emails-error", "Error updating email verification status. Please try again later.") 243 + return 244 + } 245 + 246 + http.Redirect(w, r, "/settings", http.StatusSeeOther) 247 + } 248 + 249 + func (s *Settings) emailsVerifyResend(w http.ResponseWriter, r *http.Request) { 250 + if r.Method != http.MethodPost { 251 + s.Pages.Notice(w, "settings-emails-error", "Invalid request method.") 252 + return 253 + } 254 + 255 + did := s.OAuth.GetDid(r) 256 + emAddr := r.FormValue("email") 257 + emAddr = strings.TrimSpace(emAddr) 258 + 259 + if !email.IsValidEmail(emAddr) { 260 + s.Pages.Notice(w, "settings-emails-error", "Invalid email address.") 261 + return 262 + } 263 + 264 + // Check if email exists and is unverified 265 + existingEmail, err := db.GetEmail(s.Db, did, emAddr) 266 + if err != nil { 267 + if errors.Is(err, sql.ErrNoRows) { 268 + s.Pages.Notice(w, "settings-emails-error", "Email not found. Please add it first.") 269 + } else { 270 + log.Printf("checking for existing email: %s", err) 271 + s.Pages.Notice(w, "settings-emails-error", "Unable to resend verification email at this moment, try again later.") 272 + } 273 + return 274 + } 275 + 276 + if existingEmail.Verified { 277 + s.Pages.Notice(w, "settings-emails-error", "This email is already verified.") 278 + return 279 + } 280 + 281 + // Check if last verification email was sent less than 10 minutes ago 282 + if existingEmail.LastSent != nil { 283 + timeSinceLastSent := time.Since(*existingEmail.LastSent) 284 + if timeSinceLastSent < 10*time.Minute { 285 + waitTime := 10*time.Minute - timeSinceLastSent 286 + s.Pages.Notice(w, "settings-emails-error", fmt.Sprintf("Please wait %d minutes before requesting another verification email.", int(waitTime.Minutes()+1))) 287 + return 288 + } 289 + } 290 + 291 + // Generate new verification code 292 + code := uuid.New().String() 293 + 294 + // Begin transaction 295 + tx, err := s.Db.Begin() 296 + if err != nil { 297 + log.Printf("failed to start transaction: %s", err) 298 + s.Pages.Notice(w, "settings-emails-error", "Unable to resend verification email at this moment, try again later.") 299 + return 300 + } 301 + defer tx.Rollback() 302 + 303 + // Update the verification code and last sent time 304 + if err := db.UpdateVerificationCode(tx, did, emAddr, code); err != nil { 305 + log.Printf("updating email verification: %s", err) 306 + s.Pages.Notice(w, "settings-emails-error", "Unable to resend verification email at this moment, try again later.") 307 + return 308 + } 309 + 310 + // Send verification email 311 + if err := s.sendVerificationEmail(w, did, emAddr, code, ""); err != nil { 312 + return 313 + } 314 + 315 + // Commit transaction 316 + if err := tx.Commit(); err != nil { 317 + log.Printf("failed to commit transaction: %s", err) 318 + s.Pages.Notice(w, "settings-emails-error", "Unable to resend verification email at this moment, try again later.") 319 + return 320 + } 321 + 322 + s.Pages.Notice(w, "settings-emails-success", "Verification email resent. Click the link in the email we sent you to verify your email address.") 323 + } 324 + 325 + func (s *Settings) emailsPrimary(w http.ResponseWriter, r *http.Request) { 326 + did := s.OAuth.GetDid(r) 327 + emailAddr := r.FormValue("email") 328 + emailAddr = strings.TrimSpace(emailAddr) 329 + 330 + if emailAddr == "" { 331 + s.Pages.Notice(w, "settings-emails-error", "Email address cannot be empty.") 332 + return 333 + } 334 + 335 + if err := db.MakeEmailPrimary(s.Db, did, emailAddr); err != nil { 336 + log.Printf("setting primary email: %s", err) 337 + s.Pages.Notice(w, "settings-emails-error", "Error setting primary email. Please try again later.") 338 + return 339 + } 340 + 341 + s.Pages.HxLocation(w, "/settings") 342 + } 343 + 344 + func (s *Settings) keys(w http.ResponseWriter, r *http.Request) { 345 + switch r.Method { 346 + case http.MethodGet: 347 + s.Pages.Notice(w, "settings-keys", "Unimplemented.") 348 + log.Println("unimplemented") 349 + return 350 + case http.MethodPut: 351 + did := s.OAuth.GetDid(r) 352 + key := r.FormValue("key") 353 + key = strings.TrimSpace(key) 354 + name := r.FormValue("name") 355 + client, err := s.OAuth.AuthorizedClient(r) 356 + if err != nil { 357 + s.Pages.Notice(w, "settings-keys", "Failed to authorize. Try again later.") 358 + return 359 + } 360 + 361 + _, _, _, _, err = ssh.ParseAuthorizedKey([]byte(key)) 362 + if err != nil { 363 + log.Printf("parsing public key: %s", err) 364 + s.Pages.Notice(w, "settings-keys", "That doesn't look like a valid public key. Make sure it's a <strong>public</strong> key.") 365 + return 366 + } 367 + 368 + rkey := appview.TID() 369 + 370 + tx, err := s.Db.Begin() 371 + if err != nil { 372 + log.Printf("failed to start tx; adding public key: %s", err) 373 + s.Pages.Notice(w, "settings-keys", "Unable to add public key at this moment, try again later.") 374 + return 375 + } 376 + defer tx.Rollback() 377 + 378 + if err := db.AddPublicKey(tx, did, name, key, rkey); err != nil { 379 + log.Printf("adding public key: %s", err) 380 + s.Pages.Notice(w, "settings-keys", "Failed to add public key.") 381 + return 382 + } 383 + 384 + // store in pds too 385 + resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 386 + Collection: tangled.PublicKeyNSID, 387 + Repo: did, 388 + Rkey: rkey, 389 + Record: &lexutil.LexiconTypeDecoder{ 390 + Val: &tangled.PublicKey{ 391 + CreatedAt: time.Now().Format(time.RFC3339), 392 + Key: key, 393 + Name: name, 394 + }}, 395 + }) 396 + // invalid record 397 + if err != nil { 398 + log.Printf("failed to create record: %s", err) 399 + s.Pages.Notice(w, "settings-keys", "Failed to create record.") 400 + return 401 + } 402 + 403 + log.Println("created atproto record: ", resp.Uri) 404 + 405 + err = tx.Commit() 406 + if err != nil { 407 + log.Printf("failed to commit tx; adding public key: %s", err) 408 + s.Pages.Notice(w, "settings-keys", "Unable to add public key at this moment, try again later.") 409 + return 410 + } 411 + 412 + s.Pages.HxLocation(w, "/settings") 413 + return 414 + 415 + case http.MethodDelete: 416 + did := s.OAuth.GetDid(r) 417 + q := r.URL.Query() 418 + 419 + name := q.Get("name") 420 + rkey := q.Get("rkey") 421 + key := q.Get("key") 422 + 423 + log.Println(name) 424 + log.Println(rkey) 425 + log.Println(key) 426 + 427 + client, err := s.OAuth.AuthorizedClient(r) 428 + if err != nil { 429 + log.Printf("failed to authorize client: %s", err) 430 + s.Pages.Notice(w, "settings-keys", "Failed to authorize client.") 431 + return 432 + } 433 + 434 + if err := db.DeletePublicKey(s.Db, did, name, key); err != nil { 435 + log.Printf("removing public key: %s", err) 436 + s.Pages.Notice(w, "settings-keys", "Failed to remove public key.") 437 + return 438 + } 439 + 440 + if rkey != "" { 441 + // remove from pds too 442 + _, err := client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 443 + Collection: tangled.PublicKeyNSID, 444 + Repo: did, 445 + Rkey: rkey, 446 + }) 447 + 448 + // invalid record 449 + if err != nil { 450 + log.Printf("failed to delete record from PDS: %s", err) 451 + s.Pages.Notice(w, "settings-keys", "Failed to remove key from PDS.") 452 + return 453 + } 454 + } 455 + log.Println("deleted successfully") 456 + 457 + s.Pages.HxLocation(w, "/settings") 458 + return 459 + } 460 + }
+296
appview/state/artifact.go
···
··· 1 + package state 2 + 3 + import ( 4 + "fmt" 5 + "log" 6 + "net/http" 7 + "net/url" 8 + "time" 9 + 10 + comatproto "github.com/bluesky-social/indigo/api/atproto" 11 + lexutil "github.com/bluesky-social/indigo/lex/util" 12 + "github.com/dustin/go-humanize" 13 + "github.com/go-chi/chi/v5" 14 + "github.com/go-git/go-git/v5/plumbing" 15 + "github.com/ipfs/go-cid" 16 + "tangled.sh/tangled.sh/core/api/tangled" 17 + "tangled.sh/tangled.sh/core/appview" 18 + "tangled.sh/tangled.sh/core/appview/db" 19 + "tangled.sh/tangled.sh/core/appview/knotclient" 20 + "tangled.sh/tangled.sh/core/appview/pages" 21 + "tangled.sh/tangled.sh/core/types" 22 + ) 23 + 24 + // TODO: proper statuses here on early exit 25 + func (s *State) AttachArtifact(w http.ResponseWriter, r *http.Request) { 26 + user := s.oauth.GetUser(r) 27 + tagParam := chi.URLParam(r, "tag") 28 + f, err := s.fullyResolvedRepo(r) 29 + if err != nil { 30 + log.Println("failed to get repo and knot", err) 31 + s.pages.Notice(w, "upload", "failed to upload artifact, error in repo resolution") 32 + return 33 + } 34 + 35 + tag, err := s.resolveTag(f, tagParam) 36 + if err != nil { 37 + log.Println("failed to resolve tag", err) 38 + s.pages.Notice(w, "upload", "failed to upload artifact, error in tag resolution") 39 + return 40 + } 41 + 42 + file, handler, err := r.FormFile("artifact") 43 + if err != nil { 44 + log.Println("failed to upload artifact", err) 45 + s.pages.Notice(w, "upload", "failed to upload artifact") 46 + return 47 + } 48 + defer file.Close() 49 + 50 + client, err := s.oauth.AuthorizedClient(r) 51 + if err != nil { 52 + log.Println("failed to get authorized client", err) 53 + s.pages.Notice(w, "upload", "failed to get authorized client") 54 + return 55 + } 56 + 57 + uploadBlobResp, err := client.RepoUploadBlob(r.Context(), file) 58 + if err != nil { 59 + log.Println("failed to upload blob", err) 60 + s.pages.Notice(w, "upload", "Failed to upload blob to your PDS. Try again later.") 61 + return 62 + } 63 + 64 + log.Println("uploaded blob", humanize.Bytes(uint64(uploadBlobResp.Blob.Size)), uploadBlobResp.Blob.Ref.String()) 65 + 66 + rkey := appview.TID() 67 + createdAt := time.Now() 68 + 69 + putRecordResp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 70 + Collection: tangled.RepoArtifactNSID, 71 + Repo: user.Did, 72 + Rkey: rkey, 73 + Record: &lexutil.LexiconTypeDecoder{ 74 + Val: &tangled.RepoArtifact{ 75 + Artifact: uploadBlobResp.Blob, 76 + CreatedAt: createdAt.Format(time.RFC3339), 77 + Name: handler.Filename, 78 + Repo: f.RepoAt.String(), 79 + Tag: tag.Tag.Hash[:], 80 + }, 81 + }, 82 + }) 83 + if err != nil { 84 + log.Println("failed to create record", err) 85 + s.pages.Notice(w, "upload", "Failed to create artifact record. Try again later.") 86 + return 87 + } 88 + 89 + log.Println(putRecordResp.Uri) 90 + 91 + tx, err := s.db.BeginTx(r.Context(), nil) 92 + if err != nil { 93 + log.Println("failed to start tx") 94 + s.pages.Notice(w, "upload", "Failed to create artifact. Try again later.") 95 + return 96 + } 97 + defer tx.Rollback() 98 + 99 + artifact := db.Artifact{ 100 + Did: user.Did, 101 + Rkey: rkey, 102 + RepoAt: f.RepoAt, 103 + Tag: tag.Tag.Hash, 104 + CreatedAt: createdAt, 105 + BlobCid: cid.Cid(uploadBlobResp.Blob.Ref), 106 + Name: handler.Filename, 107 + Size: uint64(uploadBlobResp.Blob.Size), 108 + MimeType: uploadBlobResp.Blob.MimeType, 109 + } 110 + 111 + err = db.AddArtifact(tx, artifact) 112 + if err != nil { 113 + log.Println("failed to add artifact record to db", err) 114 + s.pages.Notice(w, "upload", "Failed to create artifact. Try again later.") 115 + return 116 + } 117 + 118 + err = tx.Commit() 119 + if err != nil { 120 + log.Println("failed to add artifact record to db") 121 + s.pages.Notice(w, "upload", "Failed to create artifact. Try again later.") 122 + return 123 + } 124 + 125 + s.pages.RepoArtifactFragment(w, pages.RepoArtifactParams{ 126 + LoggedInUser: user, 127 + RepoInfo: f.RepoInfo(s, user), 128 + Artifact: artifact, 129 + }) 130 + } 131 + 132 + // TODO: proper statuses here on early exit 133 + func (s *State) DownloadArtifact(w http.ResponseWriter, r *http.Request) { 134 + tagParam := chi.URLParam(r, "tag") 135 + filename := chi.URLParam(r, "file") 136 + f, err := s.fullyResolvedRepo(r) 137 + if err != nil { 138 + log.Println("failed to get repo and knot", err) 139 + return 140 + } 141 + 142 + tag, err := s.resolveTag(f, tagParam) 143 + if err != nil { 144 + log.Println("failed to resolve tag", err) 145 + s.pages.Notice(w, "upload", "failed to upload artifact, error in tag resolution") 146 + return 147 + } 148 + 149 + client, err := s.oauth.AuthorizedClient(r) 150 + if err != nil { 151 + log.Println("failed to get authorized client", err) 152 + return 153 + } 154 + 155 + artifacts, err := db.GetArtifact( 156 + s.db, 157 + db.Filter("repo_at", f.RepoAt), 158 + db.Filter("tag", tag.Tag.Hash[:]), 159 + db.Filter("name", filename), 160 + ) 161 + if err != nil { 162 + log.Println("failed to get artifacts", err) 163 + return 164 + } 165 + if len(artifacts) != 1 { 166 + log.Printf("too many or too little artifacts found") 167 + return 168 + } 169 + 170 + artifact := artifacts[0] 171 + 172 + getBlobResp, err := client.SyncGetBlob(r.Context(), artifact.BlobCid.String(), artifact.Did) 173 + if err != nil { 174 + log.Println("failed to get blob from pds", err) 175 + return 176 + } 177 + 178 + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename)) 179 + w.Write(getBlobResp) 180 + } 181 + 182 + // TODO: proper statuses here on early exit 183 + func (s *State) DeleteArtifact(w http.ResponseWriter, r *http.Request) { 184 + user := s.oauth.GetUser(r) 185 + tagParam := chi.URLParam(r, "tag") 186 + filename := chi.URLParam(r, "file") 187 + f, err := s.fullyResolvedRepo(r) 188 + if err != nil { 189 + log.Println("failed to get repo and knot", err) 190 + return 191 + } 192 + 193 + client, _ := s.oauth.AuthorizedClient(r) 194 + 195 + tag := plumbing.NewHash(tagParam) 196 + 197 + artifacts, err := db.GetArtifact( 198 + s.db, 199 + db.Filter("repo_at", f.RepoAt), 200 + db.Filter("tag", tag[:]), 201 + db.Filter("name", filename), 202 + ) 203 + if err != nil { 204 + log.Println("failed to get artifacts", err) 205 + s.pages.Notice(w, "remove", "Failed to delete artifact. Try again later.") 206 + return 207 + } 208 + if len(artifacts) != 1 { 209 + s.pages.Notice(w, "remove", "Unable to find artifact.") 210 + return 211 + } 212 + 213 + artifact := artifacts[0] 214 + 215 + if user.Did != artifact.Did { 216 + log.Println("user not authorized to delete artifact", err) 217 + s.pages.Notice(w, "remove", "Unauthorized deletion of artifact.") 218 + return 219 + } 220 + 221 + _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 222 + Collection: tangled.RepoArtifactNSID, 223 + Repo: user.Did, 224 + Rkey: artifact.Rkey, 225 + }) 226 + if err != nil { 227 + log.Println("failed to get blob from pds", err) 228 + s.pages.Notice(w, "remove", "Failed to remove blob from PDS.") 229 + return 230 + } 231 + 232 + tx, err := s.db.BeginTx(r.Context(), nil) 233 + if err != nil { 234 + log.Println("failed to start tx") 235 + s.pages.Notice(w, "remove", "Failed to delete artifact. Try again later.") 236 + return 237 + } 238 + defer tx.Rollback() 239 + 240 + err = db.DeleteArtifact(tx, 241 + db.Filter("repo_at", f.RepoAt), 242 + db.Filter("tag", artifact.Tag[:]), 243 + db.Filter("name", filename), 244 + ) 245 + if err != nil { 246 + log.Println("failed to remove artifact record from db", err) 247 + s.pages.Notice(w, "remove", "Failed to delete artifact. Try again later.") 248 + return 249 + } 250 + 251 + err = tx.Commit() 252 + if err != nil { 253 + log.Println("failed to remove artifact record from db") 254 + s.pages.Notice(w, "remove", "Failed to delete artifact. Try again later.") 255 + return 256 + } 257 + 258 + w.Write([]byte{}) 259 + } 260 + 261 + func (s *State) resolveTag(f *FullyResolvedRepo, tagParam string) (*types.TagReference, error) { 262 + tagParam, err := url.QueryUnescape(tagParam) 263 + if err != nil { 264 + return nil, err 265 + } 266 + 267 + us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 268 + if err != nil { 269 + return nil, err 270 + } 271 + 272 + result, err := us.Tags(f.OwnerDid(), f.RepoName) 273 + if err != nil { 274 + log.Println("failed to reach knotserver", err) 275 + return nil, err 276 + } 277 + 278 + var tag *types.TagReference 279 + for _, t := range result.Tags { 280 + if t.Tag != nil { 281 + if t.Reference.Name == tagParam || t.Reference.Hash == tagParam { 282 + tag = t 283 + } 284 + } 285 + } 286 + 287 + if tag == nil { 288 + return nil, fmt.Errorf("invalid tag, only annotated tags are supported for artifacts") 289 + } 290 + 291 + if tag.Tag.Target.IsZero() { 292 + return nil, fmt.Errorf("invalid tag, only annotated tags are supported for artifacts") 293 + } 294 + 295 + return tag, nil 296 + }
+12 -7
appview/state/follow.go
··· 7 8 comatproto "github.com/bluesky-social/indigo/api/atproto" 9 lexutil "github.com/bluesky-social/indigo/lex/util" 10 - tangled "tangled.sh/tangled.sh/core/api/tangled" 11 "tangled.sh/tangled.sh/core/appview/db" 12 "tangled.sh/tangled.sh/core/appview/pages" 13 ) 14 15 func (s *State) Follow(w http.ResponseWriter, r *http.Request) { 16 - currentUser := s.auth.GetUser(r) 17 18 subject := r.URL.Query().Get("subject") 19 if subject == "" { ··· 31 return 32 } 33 34 - client, _ := s.auth.AuthorizedClient(r) 35 36 switch r.Method { 37 case http.MethodPost: 38 createdAt := time.Now().Format(time.RFC3339) 39 - rkey := s.TID() 40 - resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 41 Collection: tangled.GraphFollowNSID, 42 Repo: currentUser.Did, 43 Rkey: rkey, ··· 74 return 75 } 76 77 - _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 78 Collection: tangled.GraphFollowNSID, 79 Repo: currentUser.Did, 80 Rkey: follow.Rkey, ··· 85 return 86 } 87 88 - err = db.DeleteFollow(s.db, currentUser.Did, subjectIdent.DID.String()) 89 if err != nil { 90 log.Println("failed to delete follow from DB") 91 // this is not an issue, the firehose event might have already done this
··· 7 8 comatproto "github.com/bluesky-social/indigo/api/atproto" 9 lexutil "github.com/bluesky-social/indigo/lex/util" 10 + "tangled.sh/tangled.sh/core/api/tangled" 11 + "tangled.sh/tangled.sh/core/appview" 12 "tangled.sh/tangled.sh/core/appview/db" 13 "tangled.sh/tangled.sh/core/appview/pages" 14 ) 15 16 func (s *State) Follow(w http.ResponseWriter, r *http.Request) { 17 + currentUser := s.oauth.GetUser(r) 18 19 subject := r.URL.Query().Get("subject") 20 if subject == "" { ··· 32 return 33 } 34 35 + client, err := s.oauth.AuthorizedClient(r) 36 + if err != nil { 37 + log.Println("failed to authorize client") 38 + return 39 + } 40 41 switch r.Method { 42 case http.MethodPost: 43 createdAt := time.Now().Format(time.RFC3339) 44 + rkey := appview.TID() 45 + resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 46 Collection: tangled.GraphFollowNSID, 47 Repo: currentUser.Did, 48 Rkey: rkey, ··· 79 return 80 } 81 82 + _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 83 Collection: tangled.GraphFollowNSID, 84 Repo: currentUser.Did, 85 Rkey: follow.Rkey, ··· 90 return 91 } 92 93 + err = db.DeleteFollowByRkey(s.db, currentUser.Did, follow.Rkey) 94 if err != nil { 95 log.Println("failed to delete follow from DB") 96 // this is not an issue, the firehose event might have already done this
+2 -2
appview/state/git_http.go
··· 15 repo := chi.URLParam(r, "repo") 16 17 scheme := "https" 18 - if s.config.Dev { 19 scheme = "http" 20 } 21 targetURL := fmt.Sprintf("%s://%s/%s/%s/info/refs?%s", scheme, knot, user.DID, repo, r.URL.RawQuery) ··· 52 repo := chi.URLParam(r, "repo") 53 54 scheme := "https" 55 - if s.config.Dev { 56 scheme = "http" 57 } 58 targetURL := fmt.Sprintf("%s://%s/%s/%s/git-upload-pack?%s", scheme, knot, user.DID, repo, r.URL.RawQuery)
··· 15 repo := chi.URLParam(r, "repo") 16 17 scheme := "https" 18 + if s.config.Core.Dev { 19 scheme = "http" 20 } 21 targetURL := fmt.Sprintf("%s://%s/%s/%s/info/refs?%s", scheme, knot, user.DID, repo, r.URL.RawQuery) ··· 52 repo := chi.URLParam(r, "repo") 53 54 scheme := "https" 55 + if s.config.Core.Dev { 56 scheme = "http" 57 } 58 targetURL := fmt.Sprintf("%s://%s/%s/%s/git-upload-pack?%s", scheme, knot, user.DID, repo, r.URL.RawQuery)
-70
appview/state/jetstream.go
··· 1 - package state 2 - 3 - import ( 4 - "context" 5 - "encoding/json" 6 - "fmt" 7 - "log" 8 - 9 - "github.com/bluesky-social/indigo/atproto/syntax" 10 - "github.com/bluesky-social/jetstream/pkg/models" 11 - tangled "tangled.sh/tangled.sh/core/api/tangled" 12 - "tangled.sh/tangled.sh/core/appview/db" 13 - ) 14 - 15 - type Ingester func(ctx context.Context, e *models.Event) error 16 - 17 - func jetstreamIngester(d db.DbWrapper) Ingester { 18 - return func(ctx context.Context, e *models.Event) error { 19 - var err error 20 - defer func() { 21 - eventTime := e.TimeUS 22 - lastTimeUs := eventTime + 1 23 - if err := d.UpdateLastTimeUs(lastTimeUs); err != nil { 24 - err = fmt.Errorf("(deferred) failed to save last time us: %w", err) 25 - } 26 - }() 27 - 28 - if e.Kind != models.EventKindCommit { 29 - return nil 30 - } 31 - 32 - did := e.Did 33 - raw := json.RawMessage(e.Commit.Record) 34 - 35 - switch e.Commit.Collection { 36 - case tangled.GraphFollowNSID: 37 - record := tangled.GraphFollow{} 38 - err := json.Unmarshal(raw, &record) 39 - if err != nil { 40 - log.Println("invalid record") 41 - return err 42 - } 43 - err = db.AddFollow(d, did, record.Subject, e.Commit.RKey) 44 - if err != nil { 45 - return fmt.Errorf("failed to add follow to db: %w", err) 46 - } 47 - case tangled.FeedStarNSID: 48 - record := tangled.FeedStar{} 49 - err := json.Unmarshal(raw, &record) 50 - if err != nil { 51 - log.Println("invalid record") 52 - return err 53 - } 54 - 55 - subjectUri, err := syntax.ParseATURI(record.Subject) 56 - 57 - if err != nil { 58 - log.Println("invalid record") 59 - return err 60 - } 61 - 62 - err = db.AddStar(d, did, subjectUri, e.Commit.RKey) 63 - if err != nil { 64 - return fmt.Errorf("failed to add follow to db: %w", err) 65 - } 66 - } 67 - 68 - return err 69 - } 70 - }
···
+54 -98
appview/state/middleware.go
··· 2 3 import ( 4 "context" 5 "log" 6 "net/http" 7 "strconv" 8 "strings" 9 "time" 10 11 - comatproto "github.com/bluesky-social/indigo/api/atproto" 12 "github.com/bluesky-social/indigo/atproto/identity" 13 - "github.com/bluesky-social/indigo/xrpc" 14 "github.com/go-chi/chi/v5" 15 - "tangled.sh/tangled.sh/core/appview" 16 - "tangled.sh/tangled.sh/core/appview/auth" 17 "tangled.sh/tangled.sh/core/appview/db" 18 ) 19 20 - type Middleware func(http.Handler) http.Handler 21 - 22 - func AuthMiddleware(s *State) Middleware { 23 - return func(next http.Handler) http.Handler { 24 - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 25 - redirectFunc := func(w http.ResponseWriter, r *http.Request) { 26 - http.Redirect(w, r, "/login", http.StatusTemporaryRedirect) 27 - } 28 - if r.Header.Get("HX-Request") == "true" { 29 - redirectFunc = func(w http.ResponseWriter, _ *http.Request) { 30 - w.Header().Set("HX-Redirect", "/login") 31 - w.WriteHeader(http.StatusOK) 32 - } 33 - } 34 - 35 - session, err := s.auth.GetSession(r) 36 - if session.IsNew || err != nil { 37 - log.Printf("not logged in, redirecting") 38 - redirectFunc(w, r) 39 - return 40 - } 41 - 42 - authorized, ok := session.Values[appview.SessionAuthenticated].(bool) 43 - if !ok || !authorized { 44 - log.Printf("not logged in, redirecting") 45 - redirectFunc(w, r) 46 - return 47 - } 48 - 49 - // refresh if nearing expiry 50 - // TODO: dedup with /login 51 - expiryStr := session.Values[appview.SessionExpiry].(string) 52 - expiry, err := time.Parse(time.RFC3339, expiryStr) 53 - if err != nil { 54 - log.Println("invalid expiry time", err) 55 - redirectFunc(w, r) 56 - return 57 - } 58 - pdsUrl, ok1 := session.Values[appview.SessionPds].(string) 59 - did, ok2 := session.Values[appview.SessionDid].(string) 60 - refreshJwt, ok3 := session.Values[appview.SessionRefreshJwt].(string) 61 - 62 - if !ok1 || !ok2 || !ok3 { 63 - log.Println("invalid expiry time", err) 64 - redirectFunc(w, r) 65 - return 66 - } 67 - 68 - if time.Now().After(expiry) { 69 - log.Println("token expired, refreshing ...") 70 - 71 - client := xrpc.Client{ 72 - Host: pdsUrl, 73 - Auth: &xrpc.AuthInfo{ 74 - Did: did, 75 - AccessJwt: refreshJwt, 76 - RefreshJwt: refreshJwt, 77 - }, 78 - } 79 - atSession, err := comatproto.ServerRefreshSession(r.Context(), &client) 80 - if err != nil { 81 - log.Println("failed to refresh session", err) 82 - redirectFunc(w, r) 83 - return 84 - } 85 - 86 - sessionish := auth.RefreshSessionWrapper{atSession} 87 - 88 - err = s.auth.StoreSession(r, w, &sessionish, pdsUrl) 89 - if err != nil { 90 - log.Printf("failed to store session for did: %s\n: %s", atSession.Did, err) 91 - return 92 - } 93 - 94 - log.Println("successfully refreshed token") 95 - } 96 - 97 - next.ServeHTTP(w, r) 98 - }) 99 - } 100 - } 101 - 102 - func knotRoleMiddleware(s *State, group string) Middleware { 103 return func(next http.Handler) http.Handler { 104 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 105 // requires auth also 106 - actor := s.auth.GetUser(r) 107 if actor == nil { 108 // we need a logged in user 109 log.Printf("not logged in, redirecting") ··· 129 } 130 } 131 132 - func KnotOwner(s *State) Middleware { 133 return knotRoleMiddleware(s, "server:owner") 134 } 135 136 - func RepoPermissionMiddleware(s *State, requiredPerm string) Middleware { 137 return func(next http.Handler) http.Handler { 138 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 139 // requires auth also 140 - actor := s.auth.GetUser(r) 141 if actor == nil { 142 // we need a logged in user 143 log.Printf("not logged in, redirecting") 144 http.Error(w, "Forbiden", http.StatusUnauthorized) 145 return 146 } 147 - f, err := fullyResolvedRepo(r) 148 if err != nil { 149 http.Error(w, "malformed url", http.StatusBadRequest) 150 return 151 } 152 153 - ok, err := s.enforcer.E.Enforce(actor.Did, f.Knot, f.OwnerSlashRepo(), requiredPerm) 154 if err != nil || !ok { 155 // we need a logged in user 156 log.Printf("%s does not have perms of a %s in repo %s", actor.Did, requiredPerm, f.OwnerSlashRepo()) ··· 173 }) 174 } 175 176 - func ResolveIdent(s *State) Middleware { 177 return func(next http.Handler) http.Handler { 178 return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 179 didOrHandle := chi.URLParam(req, "user") 180 181 id, err := s.resolver.ResolveIdent(req.Context(), didOrHandle) 182 if err != nil { ··· 193 } 194 } 195 196 - func ResolveRepo(s *State) Middleware { 197 return func(next http.Handler) http.Handler { 198 return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 199 repoName := chi.URLParam(req, "repo") ··· 208 if err != nil { 209 // invalid did or handle 210 log.Println("failed to resolve repo") 211 - w.WriteHeader(http.StatusNotFound) 212 return 213 } 214 ··· 222 } 223 224 // middleware that is tacked on top of /{user}/{repo}/pulls/{pull} 225 - func ResolvePull(s *State) Middleware { 226 return func(next http.Handler) http.Handler { 227 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 228 - f, err := fullyResolvedRepo(r) 229 if err != nil { 230 log.Println("failed to fully resolve repo", err) 231 http.Error(w, "invalid repo url", http.StatusNotFound) ··· 252 }) 253 } 254 }
··· 2 3 import ( 4 "context" 5 + "fmt" 6 "log" 7 "net/http" 8 "strconv" 9 "strings" 10 "time" 11 12 + "slices" 13 + 14 "github.com/bluesky-social/indigo/atproto/identity" 15 "github.com/go-chi/chi/v5" 16 "tangled.sh/tangled.sh/core/appview/db" 17 + "tangled.sh/tangled.sh/core/appview/middleware" 18 ) 19 20 + func knotRoleMiddleware(s *State, group string) middleware.Middleware { 21 return func(next http.Handler) http.Handler { 22 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 23 // requires auth also 24 + actor := s.oauth.GetUser(r) 25 if actor == nil { 26 // we need a logged in user 27 log.Printf("not logged in, redirecting") ··· 47 } 48 } 49 50 + func KnotOwner(s *State) middleware.Middleware { 51 return knotRoleMiddleware(s, "server:owner") 52 } 53 54 + func RepoPermissionMiddleware(s *State, requiredPerm string) middleware.Middleware { 55 return func(next http.Handler) http.Handler { 56 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 57 // requires auth also 58 + actor := s.oauth.GetUser(r) 59 if actor == nil { 60 // we need a logged in user 61 log.Printf("not logged in, redirecting") 62 http.Error(w, "Forbiden", http.StatusUnauthorized) 63 return 64 } 65 + f, err := s.fullyResolvedRepo(r) 66 if err != nil { 67 http.Error(w, "malformed url", http.StatusBadRequest) 68 return 69 } 70 71 + ok, err := s.enforcer.E.Enforce(actor.Did, f.Knot, f.DidSlashRepo(), requiredPerm) 72 if err != nil || !ok { 73 // we need a logged in user 74 log.Printf("%s does not have perms of a %s in repo %s", actor.Did, requiredPerm, f.OwnerSlashRepo()) ··· 91 }) 92 } 93 94 + func ResolveIdent(s *State) middleware.Middleware { 95 + excluded := []string{"favicon.ico"} 96 + 97 return func(next http.Handler) http.Handler { 98 return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 99 didOrHandle := chi.URLParam(req, "user") 100 + if slices.Contains(excluded, didOrHandle) { 101 + next.ServeHTTP(w, req) 102 + return 103 + } 104 105 id, err := s.resolver.ResolveIdent(req.Context(), didOrHandle) 106 if err != nil { ··· 117 } 118 } 119 120 + func ResolveRepo(s *State) middleware.Middleware { 121 return func(next http.Handler) http.Handler { 122 return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 123 repoName := chi.URLParam(req, "repo") ··· 132 if err != nil { 133 // invalid did or handle 134 log.Println("failed to resolve repo") 135 + s.pages.Error404(w) 136 return 137 } 138 ··· 146 } 147 148 // middleware that is tacked on top of /{user}/{repo}/pulls/{pull} 149 + func ResolvePull(s *State) middleware.Middleware { 150 return func(next http.Handler) http.Handler { 151 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 152 + f, err := s.fullyResolvedRepo(r) 153 if err != nil { 154 log.Println("failed to fully resolve repo", err) 155 http.Error(w, "invalid repo url", http.StatusNotFound) ··· 176 }) 177 } 178 } 179 + 180 + // this should serve the go-import meta tag even if the path is technically 181 + // a 404 like tangled.sh/oppi.li/go-git/v5 182 + func GoImport(s *State) middleware.Middleware { 183 + return func(next http.Handler) http.Handler { 184 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 185 + f, err := s.fullyResolvedRepo(r) 186 + if err != nil { 187 + log.Println("failed to fully resolve repo", err) 188 + http.Error(w, "invalid repo url", http.StatusNotFound) 189 + return 190 + } 191 + 192 + fullName := f.OwnerHandle() + "/" + f.RepoName 193 + 194 + if r.Header.Get("User-Agent") == "Go-http-client/1.1" { 195 + if r.URL.Query().Get("go-get") == "1" { 196 + html := fmt.Sprintf( 197 + `<meta name="go-import" content="tangled.sh/%s git https://tangled.sh/%s"/>`, 198 + fullName, 199 + fullName, 200 + ) 201 + w.Header().Set("Content-Type", "text/html") 202 + w.Write([]byte(html)) 203 + return 204 + } 205 + } 206 + 207 + next.ServeHTTP(w, r) 208 + }) 209 + } 210 + }
+423
appview/state/profile.go
···
··· 1 + package state 2 + 3 + import ( 4 + "crypto/hmac" 5 + "crypto/sha256" 6 + "encoding/hex" 7 + "fmt" 8 + "log" 9 + "net/http" 10 + "slices" 11 + "strings" 12 + 13 + comatproto "github.com/bluesky-social/indigo/api/atproto" 14 + "github.com/bluesky-social/indigo/atproto/identity" 15 + "github.com/bluesky-social/indigo/atproto/syntax" 16 + lexutil "github.com/bluesky-social/indigo/lex/util" 17 + "github.com/go-chi/chi/v5" 18 + "tangled.sh/tangled.sh/core/api/tangled" 19 + "tangled.sh/tangled.sh/core/appview/db" 20 + "tangled.sh/tangled.sh/core/appview/pages" 21 + ) 22 + 23 + func (s *State) Profile(w http.ResponseWriter, r *http.Request) { 24 + tabVal := r.URL.Query().Get("tab") 25 + switch tabVal { 26 + case "": 27 + s.profilePage(w, r) 28 + case "repos": 29 + s.reposPage(w, r) 30 + } 31 + } 32 + 33 + func (s *State) profilePage(w http.ResponseWriter, r *http.Request) { 34 + didOrHandle := chi.URLParam(r, "user") 35 + if didOrHandle == "" { 36 + http.Error(w, "Bad request", http.StatusBadRequest) 37 + return 38 + } 39 + 40 + ident, ok := r.Context().Value("resolvedId").(identity.Identity) 41 + if !ok { 42 + s.pages.Error404(w) 43 + return 44 + } 45 + 46 + profile, err := db.GetProfile(s.db, ident.DID.String()) 47 + if err != nil { 48 + log.Printf("getting profile data for %s: %s", ident.DID.String(), err) 49 + } 50 + 51 + repos, err := db.GetAllReposByDid(s.db, ident.DID.String()) 52 + if err != nil { 53 + log.Printf("getting repos for %s: %s", ident.DID.String(), err) 54 + } 55 + 56 + // filter out ones that are pinned 57 + pinnedRepos := []db.Repo{} 58 + for i, r := range repos { 59 + // if this is a pinned repo, add it 60 + if slices.Contains(profile.PinnedRepos[:], r.RepoAt()) { 61 + pinnedRepos = append(pinnedRepos, r) 62 + } 63 + 64 + // if there are no saved pins, add the first 4 repos 65 + if profile.IsPinnedReposEmpty() && i < 4 { 66 + pinnedRepos = append(pinnedRepos, r) 67 + } 68 + } 69 + 70 + collaboratingRepos, err := db.CollaboratingIn(s.db, ident.DID.String()) 71 + if err != nil { 72 + log.Printf("getting collaborating repos for %s: %s", ident.DID.String(), err) 73 + } 74 + 75 + pinnedCollaboratingRepos := []db.Repo{} 76 + for _, r := range collaboratingRepos { 77 + // if this is a pinned repo, add it 78 + if slices.Contains(profile.PinnedRepos[:], r.RepoAt()) { 79 + pinnedCollaboratingRepos = append(pinnedCollaboratingRepos, r) 80 + } 81 + } 82 + 83 + timeline, err := db.MakeProfileTimeline(s.db, ident.DID.String()) 84 + if err != nil { 85 + log.Printf("failed to create profile timeline for %s: %s", ident.DID.String(), err) 86 + } 87 + 88 + var didsToResolve []string 89 + for _, r := range collaboratingRepos { 90 + didsToResolve = append(didsToResolve, r.Did) 91 + } 92 + for _, byMonth := range timeline.ByMonth { 93 + for _, pe := range byMonth.PullEvents.Items { 94 + didsToResolve = append(didsToResolve, pe.Repo.Did) 95 + } 96 + for _, ie := range byMonth.IssueEvents.Items { 97 + didsToResolve = append(didsToResolve, ie.Metadata.Repo.Did) 98 + } 99 + for _, re := range byMonth.RepoEvents { 100 + didsToResolve = append(didsToResolve, re.Repo.Did) 101 + if re.Source != nil { 102 + didsToResolve = append(didsToResolve, re.Source.Did) 103 + } 104 + } 105 + } 106 + 107 + resolvedIds := s.resolver.ResolveIdents(r.Context(), didsToResolve) 108 + didHandleMap := make(map[string]string) 109 + for _, identity := range resolvedIds { 110 + if !identity.Handle.IsInvalidHandle() { 111 + didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 112 + } else { 113 + didHandleMap[identity.DID.String()] = identity.DID.String() 114 + } 115 + } 116 + 117 + followers, following, err := db.GetFollowerFollowing(s.db, ident.DID.String()) 118 + if err != nil { 119 + log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err) 120 + } 121 + 122 + loggedInUser := s.oauth.GetUser(r) 123 + followStatus := db.IsNotFollowing 124 + if loggedInUser != nil { 125 + followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, ident.DID.String()) 126 + } 127 + 128 + profileAvatarUri := s.GetAvatarUri(ident.Handle.String()) 129 + s.pages.ProfilePage(w, pages.ProfilePageParams{ 130 + LoggedInUser: loggedInUser, 131 + Repos: pinnedRepos, 132 + CollaboratingRepos: pinnedCollaboratingRepos, 133 + DidHandleMap: didHandleMap, 134 + Card: pages.ProfileCard{ 135 + UserDid: ident.DID.String(), 136 + UserHandle: ident.Handle.String(), 137 + AvatarUri: profileAvatarUri, 138 + Profile: profile, 139 + FollowStatus: followStatus, 140 + Followers: followers, 141 + Following: following, 142 + }, 143 + ProfileTimeline: timeline, 144 + }) 145 + } 146 + 147 + func (s *State) reposPage(w http.ResponseWriter, r *http.Request) { 148 + ident, ok := r.Context().Value("resolvedId").(identity.Identity) 149 + if !ok { 150 + s.pages.Error404(w) 151 + return 152 + } 153 + 154 + profile, err := db.GetProfile(s.db, ident.DID.String()) 155 + if err != nil { 156 + log.Printf("getting profile data for %s: %s", ident.DID.String(), err) 157 + } 158 + 159 + repos, err := db.GetAllReposByDid(s.db, ident.DID.String()) 160 + if err != nil { 161 + log.Printf("getting repos for %s: %s", ident.DID.String(), err) 162 + } 163 + 164 + loggedInUser := s.oauth.GetUser(r) 165 + followStatus := db.IsNotFollowing 166 + if loggedInUser != nil { 167 + followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, ident.DID.String()) 168 + } 169 + 170 + followers, following, err := db.GetFollowerFollowing(s.db, ident.DID.String()) 171 + if err != nil { 172 + log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err) 173 + } 174 + 175 + profileAvatarUri := s.GetAvatarUri(ident.Handle.String()) 176 + 177 + s.pages.ReposPage(w, pages.ReposPageParams{ 178 + LoggedInUser: loggedInUser, 179 + Repos: repos, 180 + Card: pages.ProfileCard{ 181 + UserDid: ident.DID.String(), 182 + UserHandle: ident.Handle.String(), 183 + AvatarUri: profileAvatarUri, 184 + Profile: profile, 185 + FollowStatus: followStatus, 186 + Followers: followers, 187 + Following: following, 188 + }, 189 + }) 190 + } 191 + 192 + func (s *State) GetAvatarUri(handle string) string { 193 + secret := s.config.Avatar.SharedSecret 194 + h := hmac.New(sha256.New, []byte(secret)) 195 + h.Write([]byte(handle)) 196 + signature := hex.EncodeToString(h.Sum(nil)) 197 + return fmt.Sprintf("%s/%s/%s", s.config.Avatar.Host, signature, handle) 198 + } 199 + 200 + func (s *State) UpdateProfileBio(w http.ResponseWriter, r *http.Request) { 201 + user := s.oauth.GetUser(r) 202 + 203 + err := r.ParseForm() 204 + if err != nil { 205 + log.Println("invalid profile update form", err) 206 + s.pages.Notice(w, "update-profile", "Invalid form.") 207 + return 208 + } 209 + 210 + profile, err := db.GetProfile(s.db, user.Did) 211 + if err != nil { 212 + log.Printf("getting profile data for %s: %s", user.Did, err) 213 + } 214 + 215 + profile.Description = r.FormValue("description") 216 + profile.IncludeBluesky = r.FormValue("includeBluesky") == "on" 217 + profile.Location = r.FormValue("location") 218 + 219 + var links [5]string 220 + for i := range 5 { 221 + iLink := r.FormValue(fmt.Sprintf("link%d", i)) 222 + links[i] = iLink 223 + } 224 + profile.Links = links 225 + 226 + // Parse stats (exactly 2) 227 + stat0 := r.FormValue("stat0") 228 + stat1 := r.FormValue("stat1") 229 + 230 + if stat0 != "" { 231 + profile.Stats[0].Kind = db.VanityStatKind(stat0) 232 + } 233 + 234 + if stat1 != "" { 235 + profile.Stats[1].Kind = db.VanityStatKind(stat1) 236 + } 237 + 238 + if err := db.ValidateProfile(s.db, profile); err != nil { 239 + log.Println("invalid profile", err) 240 + s.pages.Notice(w, "update-profile", err.Error()) 241 + return 242 + } 243 + 244 + s.updateProfile(profile, w, r) 245 + return 246 + } 247 + 248 + func (s *State) UpdateProfilePins(w http.ResponseWriter, r *http.Request) { 249 + user := s.oauth.GetUser(r) 250 + 251 + err := r.ParseForm() 252 + if err != nil { 253 + log.Println("invalid profile update form", err) 254 + s.pages.Notice(w, "update-profile", "Invalid form.") 255 + return 256 + } 257 + 258 + profile, err := db.GetProfile(s.db, user.Did) 259 + if err != nil { 260 + log.Printf("getting profile data for %s: %s", user.Did, err) 261 + } 262 + 263 + i := 0 264 + var pinnedRepos [6]syntax.ATURI 265 + for key, values := range r.Form { 266 + if i >= 6 { 267 + log.Println("invalid pin update form", err) 268 + s.pages.Notice(w, "update-profile", "Only 6 repositories can be pinned at a time.") 269 + return 270 + } 271 + if strings.HasPrefix(key, "pinnedRepo") && len(values) > 0 && values[0] != "" && i < 6 { 272 + aturi, err := syntax.ParseATURI(values[0]) 273 + if err != nil { 274 + log.Println("invalid profile update form", err) 275 + s.pages.Notice(w, "update-profile", "Invalid form.") 276 + return 277 + } 278 + pinnedRepos[i] = aturi 279 + i++ 280 + } 281 + } 282 + profile.PinnedRepos = pinnedRepos 283 + 284 + s.updateProfile(profile, w, r) 285 + return 286 + } 287 + 288 + func (s *State) updateProfile(profile *db.Profile, w http.ResponseWriter, r *http.Request) { 289 + user := s.oauth.GetUser(r) 290 + tx, err := s.db.BeginTx(r.Context(), nil) 291 + if err != nil { 292 + log.Println("failed to start transaction", err) 293 + s.pages.Notice(w, "update-profile", "Failed to update profile, try again later.") 294 + return 295 + } 296 + 297 + client, err := s.oauth.AuthorizedClient(r) 298 + if err != nil { 299 + log.Println("failed to get authorized client", err) 300 + s.pages.Notice(w, "update-profile", "Failed to update profile, try again later.") 301 + return 302 + } 303 + 304 + // yeah... lexgen dose not support syntax.ATURI in the record for some reason, 305 + // nor does it support exact size arrays 306 + var pinnedRepoStrings []string 307 + for _, r := range profile.PinnedRepos { 308 + pinnedRepoStrings = append(pinnedRepoStrings, r.String()) 309 + } 310 + 311 + var vanityStats []string 312 + for _, v := range profile.Stats { 313 + vanityStats = append(vanityStats, string(v.Kind)) 314 + } 315 + 316 + ex, _ := client.RepoGetRecord(r.Context(), "", tangled.ActorProfileNSID, user.Did, "self") 317 + var cid *string 318 + if ex != nil { 319 + cid = ex.Cid 320 + } 321 + 322 + _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 323 + Collection: tangled.ActorProfileNSID, 324 + Repo: user.Did, 325 + Rkey: "self", 326 + Record: &lexutil.LexiconTypeDecoder{ 327 + Val: &tangled.ActorProfile{ 328 + Bluesky: profile.IncludeBluesky, 329 + Description: &profile.Description, 330 + Links: profile.Links[:], 331 + Location: &profile.Location, 332 + PinnedRepositories: pinnedRepoStrings, 333 + Stats: vanityStats[:], 334 + }}, 335 + SwapRecord: cid, 336 + }) 337 + if err != nil { 338 + log.Println("failed to update profile", err) 339 + s.pages.Notice(w, "update-profile", "Failed to update PDS, try again later.") 340 + return 341 + } 342 + 343 + err = db.UpsertProfile(tx, profile) 344 + if err != nil { 345 + log.Println("failed to update profile", err) 346 + s.pages.Notice(w, "update-profile", "Failed to update profile, try again later.") 347 + return 348 + } 349 + 350 + s.pages.HxRedirect(w, "/"+user.Did) 351 + return 352 + } 353 + 354 + func (s *State) EditBioFragment(w http.ResponseWriter, r *http.Request) { 355 + user := s.oauth.GetUser(r) 356 + 357 + profile, err := db.GetProfile(s.db, user.Did) 358 + if err != nil { 359 + log.Printf("getting profile data for %s: %s", user.Did, err) 360 + } 361 + 362 + s.pages.EditBioFragment(w, pages.EditBioParams{ 363 + LoggedInUser: user, 364 + Profile: profile, 365 + }) 366 + } 367 + 368 + func (s *State) EditPinsFragment(w http.ResponseWriter, r *http.Request) { 369 + user := s.oauth.GetUser(r) 370 + 371 + profile, err := db.GetProfile(s.db, user.Did) 372 + if err != nil { 373 + log.Printf("getting profile data for %s: %s", user.Did, err) 374 + } 375 + 376 + repos, err := db.GetAllReposByDid(s.db, user.Did) 377 + if err != nil { 378 + log.Printf("getting repos for %s: %s", user.Did, err) 379 + } 380 + 381 + collaboratingRepos, err := db.CollaboratingIn(s.db, user.Did) 382 + if err != nil { 383 + log.Printf("getting collaborating repos for %s: %s", user.Did, err) 384 + } 385 + 386 + allRepos := []pages.PinnedRepo{} 387 + 388 + for _, r := range repos { 389 + isPinned := slices.Contains(profile.PinnedRepos[:], r.RepoAt()) 390 + allRepos = append(allRepos, pages.PinnedRepo{ 391 + IsPinned: isPinned, 392 + Repo: r, 393 + }) 394 + } 395 + for _, r := range collaboratingRepos { 396 + isPinned := slices.Contains(profile.PinnedRepos[:], r.RepoAt()) 397 + allRepos = append(allRepos, pages.PinnedRepo{ 398 + IsPinned: isPinned, 399 + Repo: r, 400 + }) 401 + } 402 + 403 + var didsToResolve []string 404 + for _, r := range allRepos { 405 + didsToResolve = append(didsToResolve, r.Did) 406 + } 407 + resolvedIds := s.resolver.ResolveIdents(r.Context(), didsToResolve) 408 + didHandleMap := make(map[string]string) 409 + for _, identity := range resolvedIds { 410 + if !identity.Handle.IsInvalidHandle() { 411 + didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 412 + } else { 413 + didHandleMap[identity.DID.String()] = identity.DID.String() 414 + } 415 + } 416 + 417 + s.pages.EditPinsFragment(w, pages.EditPinsParams{ 418 + LoggedInUser: user, 419 + Profile: profile, 420 + AllRepos: allRepos, 421 + DidHandleMap: didHandleMap, 422 + }) 423 + }
+1068 -174
appview/state/pull.go
··· 1 package state 2 3 import ( 4 "encoding/json" 5 "fmt" 6 "io" 7 "log" 8 "net/http" 9 "strconv" 10 - "strings" 11 "time" 12 13 - "github.com/go-chi/chi/v5" 14 "tangled.sh/tangled.sh/core/api/tangled" 15 "tangled.sh/tangled.sh/core/appview/db" 16 "tangled.sh/tangled.sh/core/appview/pages" 17 "tangled.sh/tangled.sh/core/types" 18 19 comatproto "github.com/bluesky-social/indigo/api/atproto" 20 lexutil "github.com/bluesky-social/indigo/lex/util" 21 ) 22 23 // htmx fragment 24 func (s *State) PullActions(w http.ResponseWriter, r *http.Request) { 25 switch r.Method { 26 case http.MethodGet: 27 - user := s.auth.GetUser(r) 28 - f, err := fullyResolvedRepo(r) 29 if err != nil { 30 log.Println("failed to get repo and knot", err) 31 return ··· 50 } 51 52 mergeCheckResponse := s.mergeCheck(f, pull) 53 54 s.pages.PullActionsFragment(w, pages.PullActionsParams{ 55 - LoggedInUser: user, 56 - RepoInfo: f.RepoInfo(s, user), 57 - Pull: pull, 58 - RoundNumber: roundNumber, 59 - MergeCheck: mergeCheckResponse, 60 }) 61 return 62 } 63 } 64 65 func (s *State) RepoSinglePull(w http.ResponseWriter, r *http.Request) { 66 - user := s.auth.GetUser(r) 67 - f, err := fullyResolvedRepo(r) 68 if err != nil { 69 log.Println("failed to get repo and knot", err) 70 return ··· 105 } 106 107 mergeCheckResponse := s.mergeCheck(f, pull) 108 109 s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{ 110 - LoggedInUser: user, 111 - RepoInfo: f.RepoInfo(s, user), 112 - DidHandleMap: didHandleMap, 113 - Pull: *pull, 114 - MergeCheck: mergeCheckResponse, 115 }) 116 } 117 ··· 128 } 129 } 130 131 - ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev) 132 if err != nil { 133 log.Printf("failed to setup signed client for %s; ignoring: %v", f.Knot, err) 134 return types.MergeCheckResponse{ ··· 175 return mergeCheckResponse 176 } 177 178 func (s *State) RepoPullPatch(w http.ResponseWriter, r *http.Request) { 179 - user := s.auth.GetUser(r) 180 - f, err := fullyResolvedRepo(r) 181 if err != nil { 182 log.Println("failed to get repo and knot", err) 183 return ··· 209 } 210 } 211 212 s.pages.RepoPullPatchPage(w, pages.RepoPullPatchParams{ 213 LoggedInUser: user, 214 DidHandleMap: didHandleMap, ··· 216 Pull: pull, 217 Round: roundIdInt, 218 Submission: pull.Submissions[roundIdInt], 219 - Diff: pull.Submissions[roundIdInt].AsNiceDiff(pull.TargetBranch), 220 }) 221 222 } 223 224 func (s *State) RepoPulls(w http.ResponseWriter, r *http.Request) { 225 - user := s.auth.GetUser(r) 226 params := r.URL.Query() 227 228 state := db.PullOpen ··· 233 state = db.PullMerged 234 } 235 236 - f, err := fullyResolvedRepo(r) 237 if err != nil { 238 log.Println("failed to get repo and knot", err) 239 return ··· 246 return 247 } 248 249 identsToResolve := make([]string, len(pulls)) 250 for i, pull := range pulls { 251 identsToResolve[i] = pull.OwnerDid ··· 261 } 262 263 s.pages.RepoPulls(w, pages.RepoPullsParams{ 264 - LoggedInUser: s.auth.GetUser(r), 265 RepoInfo: f.RepoInfo(s, user), 266 Pulls: pulls, 267 DidHandleMap: didHandleMap, ··· 271 } 272 273 func (s *State) PullComment(w http.ResponseWriter, r *http.Request) { 274 - user := s.auth.GetUser(r) 275 - f, err := fullyResolvedRepo(r) 276 if err != nil { 277 log.Println("failed to get repo and knot", err) 278 return ··· 329 } 330 331 atUri := f.RepoAt.String() 332 - client, _ := s.auth.AuthorizedClient(r) 333 - atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 334 Collection: tangled.RepoPullCommentNSID, 335 Repo: user.Did, 336 - Rkey: s.TID(), 337 Record: &lexutil.LexiconTypeDecoder{ 338 Val: &tangled.RepoPullComment{ 339 Repo: &atUri, 340 - Pull: pullAt, 341 Owner: &ownerDid, 342 - Body: &body, 343 - CreatedAt: &createdAt, 344 }, 345 }, 346 }) 347 - log.Println(atResp.Uri) 348 if err != nil { 349 log.Println("failed to create pull comment", err) 350 s.pages.Notice(w, "pull-comment", "Failed to create comment.") ··· 379 } 380 381 func (s *State) NewPull(w http.ResponseWriter, r *http.Request) { 382 - user := s.auth.GetUser(r) 383 - f, err := fullyResolvedRepo(r) 384 if err != nil { 385 log.Println("failed to get repo and knot", err) 386 return ··· 388 389 switch r.Method { 390 case http.MethodGet: 391 - us, err := NewUnsignedClient(f.Knot, s.config.Dev) 392 if err != nil { 393 log.Printf("failed to create unsigned client for %s", f.Knot) 394 s.pages.Error503(w) ··· 423 title := r.FormValue("title") 424 body := r.FormValue("body") 425 targetBranch := r.FormValue("targetBranch") 426 patch := r.FormValue("patch") 427 428 - if title == "" || body == "" || patch == "" || targetBranch == "" { 429 - s.pages.Notice(w, "pull", "Title, body and patch diff are required.") 430 return 431 } 432 433 - // Validate patch format 434 - if !isPatchValid(patch) { 435 - s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 436 return 437 } 438 439 - tx, err := s.db.BeginTx(r.Context(), nil) 440 if err != nil { 441 - log.Println("failed to start tx") 442 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 443 return 444 } 445 - defer tx.Rollback() 446 447 - rkey := s.TID() 448 - initialSubmission := db.PullSubmission{ 449 - Patch: patch, 450 - } 451 - err = db.NewPull(tx, &db.Pull{ 452 - Title: title, 453 - Body: body, 454 - TargetBranch: targetBranch, 455 - OwnerDid: user.Did, 456 - RepoAt: f.RepoAt, 457 - Rkey: rkey, 458 - Submissions: []*db.PullSubmission{ 459 - &initialSubmission, 460 - }, 461 - }) 462 if err != nil { 463 - log.Println("failed to create pull request", err) 464 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 465 return 466 } 467 - client, _ := s.auth.AuthorizedClient(r) 468 - pullId, err := db.NextPullId(s.db, f.RepoAt) 469 - if err != nil { 470 - log.Println("failed to get pull id", err) 471 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 472 return 473 } 474 475 - atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 476 - Collection: tangled.RepoPullNSID, 477 - Repo: user.Did, 478 - Rkey: rkey, 479 - Record: &lexutil.LexiconTypeDecoder{ 480 - Val: &tangled.RepoPull{ 481 - Title: title, 482 - PullId: int64(pullId), 483 - TargetRepo: string(f.RepoAt), 484 - TargetBranch: targetBranch, 485 - Patch: patch, 486 - }, 487 - }, 488 - }) 489 490 - err = db.SetPullAt(s.db, f.RepoAt, pullId, atResp.Uri) 491 if err != nil { 492 - log.Println("failed to get pull id", err) 493 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 494 return 495 } 496 497 - s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pullId)) 498 return 499 } 500 } 501 502 func (s *State) ResubmitPull(w http.ResponseWriter, r *http.Request) { 503 - user := s.auth.GetUser(r) 504 - f, err := fullyResolvedRepo(r) 505 if err != nil { 506 log.Println("failed to get repo and knot", err) 507 return ··· 522 }) 523 return 524 case http.MethodPost: 525 - patch := r.FormValue("patch") 526 - 527 - if patch == "" { 528 - s.pages.Notice(w, "resubmit-error", "Patch is empty.") 529 return 530 } 531 532 - if patch == pull.LatestPatch() { 533 - s.pages.Notice(w, "resubmit-error", "Patch is identical to previous submission.") 534 - return 535 - } 536 537 - // Validate patch format 538 - if !isPatchValid(patch) { 539 - s.pages.Notice(w, "resubmit-error", "Invalid patch format. Please provide a valid diff.") 540 - return 541 - } 542 543 - tx, err := s.db.BeginTx(r.Context(), nil) 544 - if err != nil { 545 - log.Println("failed to start tx") 546 - s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 547 - return 548 - } 549 - defer tx.Rollback() 550 551 - err = db.ResubmitPull(tx, pull, patch) 552 - if err != nil { 553 - log.Println("failed to create pull request", err) 554 - s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 555 - return 556 - } 557 - client, _ := s.auth.AuthorizedClient(r) 558 559 - ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, pull.Rkey) 560 - if err != nil { 561 - // failed to get record 562 - s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.") 563 - return 564 - } 565 566 - _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 567 - Collection: tangled.RepoPullNSID, 568 - Repo: user.Did, 569 - Rkey: pull.Rkey, 570 - SwapRecord: ex.Cid, 571 - Record: &lexutil.LexiconTypeDecoder{ 572 - Val: &tangled.RepoPull{ 573 - Title: pull.Title, 574 - PullId: int64(pull.PullId), 575 - TargetRepo: string(f.RepoAt), 576 - TargetBranch: pull.TargetBranch, 577 - Patch: patch, // new patch 578 - }, 579 }, 580 - }) 581 - if err != nil { 582 - log.Println("failed to update record", err) 583 - s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.") 584 - return 585 - } 586 587 - if err = tx.Commit(); err != nil { 588 - log.Println("failed to commit transaction", err) 589 - s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull.") 590 - return 591 - } 592 593 - s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 594 return 595 } 596 } 597 598 func (s *State) MergePull(w http.ResponseWriter, r *http.Request) { 599 - f, err := fullyResolvedRepo(r) 600 if err != nil { 601 log.Println("failed to resolve repo:", err) 602 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") ··· 617 return 618 } 619 620 - ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev) 621 if err != nil { 622 log.Printf("failed to create signed client for %s: %s", f.Knot, err) 623 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") ··· 625 } 626 627 // Merge the pull request 628 - resp, err := ksClient.Merge([]byte(pull.LatestPatch()), f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.Title, pull.Body, "", "") 629 if err != nil { 630 log.Printf("failed to merge pull request: %s", err) 631 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") ··· 647 } 648 649 func (s *State) ClosePull(w http.ResponseWriter, r *http.Request) { 650 - user := s.auth.GetUser(r) 651 652 - f, err := fullyResolvedRepo(r) 653 if err != nil { 654 log.Println("malformed middleware") 655 return ··· 701 } 702 703 func (s *State) ReopenPull(w http.ResponseWriter, r *http.Request) { 704 - user := s.auth.GetUser(r) 705 706 - f, err := fullyResolvedRepo(r) 707 if err != nil { 708 log.Println("failed to resolve repo", err) 709 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") ··· 754 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 755 return 756 } 757 - 758 - // Very basic validation to check if it looks like a diff/patch 759 - // A valid patch usually starts with diff or --- lines 760 - func isPatchValid(patch string) bool { 761 - // Basic validation to check if it looks like a diff/patch 762 - // A valid patch usually starts with diff or --- lines 763 - if len(patch) == 0 { 764 - return false 765 - } 766 - 767 - lines := strings.Split(patch, "\n") 768 - if len(lines) < 2 { 769 - return false 770 - } 771 - 772 - // Check for common patch format markers 773 - firstLine := strings.TrimSpace(lines[0]) 774 - return strings.HasPrefix(firstLine, "diff ") || 775 - strings.HasPrefix(firstLine, "--- ") || 776 - strings.HasPrefix(firstLine, "Index: ") || 777 - strings.HasPrefix(firstLine, "+++ ") || 778 - strings.HasPrefix(firstLine, "@@ ") 779 - }
··· 1 package state 2 3 import ( 4 + "database/sql" 5 "encoding/json" 6 + "errors" 7 "fmt" 8 "io" 9 "log" 10 "net/http" 11 "strconv" 12 "time" 13 14 "tangled.sh/tangled.sh/core/api/tangled" 15 + "tangled.sh/tangled.sh/core/appview" 16 "tangled.sh/tangled.sh/core/appview/db" 17 + "tangled.sh/tangled.sh/core/appview/knotclient" 18 + "tangled.sh/tangled.sh/core/appview/oauth" 19 "tangled.sh/tangled.sh/core/appview/pages" 20 + "tangled.sh/tangled.sh/core/patchutil" 21 "tangled.sh/tangled.sh/core/types" 22 23 comatproto "github.com/bluesky-social/indigo/api/atproto" 24 + "github.com/bluesky-social/indigo/atproto/syntax" 25 lexutil "github.com/bluesky-social/indigo/lex/util" 26 + "github.com/go-chi/chi/v5" 27 ) 28 29 // htmx fragment 30 func (s *State) PullActions(w http.ResponseWriter, r *http.Request) { 31 switch r.Method { 32 case http.MethodGet: 33 + user := s.oauth.GetUser(r) 34 + f, err := s.fullyResolvedRepo(r) 35 if err != nil { 36 log.Println("failed to get repo and knot", err) 37 return ··· 56 } 57 58 mergeCheckResponse := s.mergeCheck(f, pull) 59 + resubmitResult := pages.Unknown 60 + if user.Did == pull.OwnerDid { 61 + resubmitResult = s.resubmitCheck(f, pull) 62 + } 63 64 s.pages.PullActionsFragment(w, pages.PullActionsParams{ 65 + LoggedInUser: user, 66 + RepoInfo: f.RepoInfo(s, user), 67 + Pull: pull, 68 + RoundNumber: roundNumber, 69 + MergeCheck: mergeCheckResponse, 70 + ResubmitCheck: resubmitResult, 71 }) 72 return 73 } 74 } 75 76 func (s *State) RepoSinglePull(w http.ResponseWriter, r *http.Request) { 77 + user := s.oauth.GetUser(r) 78 + f, err := s.fullyResolvedRepo(r) 79 if err != nil { 80 log.Println("failed to get repo and knot", err) 81 return ··· 116 } 117 118 mergeCheckResponse := s.mergeCheck(f, pull) 119 + resubmitResult := pages.Unknown 120 + if user != nil && user.Did == pull.OwnerDid { 121 + resubmitResult = s.resubmitCheck(f, pull) 122 + } 123 124 s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{ 125 + LoggedInUser: user, 126 + RepoInfo: f.RepoInfo(s, user), 127 + DidHandleMap: didHandleMap, 128 + Pull: pull, 129 + MergeCheck: mergeCheckResponse, 130 + ResubmitCheck: resubmitResult, 131 }) 132 } 133 ··· 144 } 145 } 146 147 + ksClient, err := knotclient.NewSignedClient(f.Knot, secret, s.config.Core.Dev) 148 if err != nil { 149 log.Printf("failed to setup signed client for %s; ignoring: %v", f.Knot, err) 150 return types.MergeCheckResponse{ ··· 191 return mergeCheckResponse 192 } 193 194 + func (s *State) resubmitCheck(f *FullyResolvedRepo, pull *db.Pull) pages.ResubmitResult { 195 + if pull.State == db.PullMerged || pull.PullSource == nil { 196 + return pages.Unknown 197 + } 198 + 199 + var knot, ownerDid, repoName string 200 + 201 + if pull.PullSource.RepoAt != nil { 202 + // fork-based pulls 203 + sourceRepo, err := db.GetRepoByAtUri(s.db, pull.PullSource.RepoAt.String()) 204 + if err != nil { 205 + log.Println("failed to get source repo", err) 206 + return pages.Unknown 207 + } 208 + 209 + knot = sourceRepo.Knot 210 + ownerDid = sourceRepo.Did 211 + repoName = sourceRepo.Name 212 + } else { 213 + // pulls within the same repo 214 + knot = f.Knot 215 + ownerDid = f.OwnerDid() 216 + repoName = f.RepoName 217 + } 218 + 219 + us, err := knotclient.NewUnsignedClient(knot, s.config.Core.Dev) 220 + if err != nil { 221 + log.Printf("failed to setup client for %s; ignoring: %v", knot, err) 222 + return pages.Unknown 223 + } 224 + 225 + resp, err := us.Branch(ownerDid, repoName, pull.PullSource.Branch) 226 + if err != nil { 227 + log.Println("failed to reach knotserver", err) 228 + return pages.Unknown 229 + } 230 + 231 + body, err := io.ReadAll(resp.Body) 232 + if err != nil { 233 + log.Printf("error reading response body: %v", err) 234 + return pages.Unknown 235 + } 236 + defer resp.Body.Close() 237 + 238 + var result types.RepoBranchResponse 239 + if err := json.Unmarshal(body, &result); err != nil { 240 + log.Println("failed to parse response:", err) 241 + return pages.Unknown 242 + } 243 + 244 + latestSubmission := pull.Submissions[pull.LastRoundNumber()] 245 + if latestSubmission.SourceRev != result.Branch.Hash { 246 + fmt.Println(latestSubmission.SourceRev, result.Branch.Hash) 247 + return pages.ShouldResubmit 248 + } 249 + 250 + return pages.ShouldNotResubmit 251 + } 252 + 253 func (s *State) RepoPullPatch(w http.ResponseWriter, r *http.Request) { 254 + user := s.oauth.GetUser(r) 255 + f, err := s.fullyResolvedRepo(r) 256 if err != nil { 257 log.Println("failed to get repo and knot", err) 258 return ··· 284 } 285 } 286 287 + diff := pull.Submissions[roundIdInt].AsNiceDiff(pull.TargetBranch) 288 + 289 s.pages.RepoPullPatchPage(w, pages.RepoPullPatchParams{ 290 LoggedInUser: user, 291 DidHandleMap: didHandleMap, ··· 293 Pull: pull, 294 Round: roundIdInt, 295 Submission: pull.Submissions[roundIdInt], 296 + Diff: &diff, 297 + }) 298 + 299 + } 300 + 301 + func (s *State) RepoPullInterdiff(w http.ResponseWriter, r *http.Request) { 302 + user := s.oauth.GetUser(r) 303 + 304 + f, err := s.fullyResolvedRepo(r) 305 + if err != nil { 306 + log.Println("failed to get repo and knot", err) 307 + return 308 + } 309 + 310 + pull, ok := r.Context().Value("pull").(*db.Pull) 311 + if !ok { 312 + log.Println("failed to get pull") 313 + s.pages.Notice(w, "pull-error", "Failed to get pull.") 314 + return 315 + } 316 + 317 + roundId := chi.URLParam(r, "round") 318 + roundIdInt, err := strconv.Atoi(roundId) 319 + if err != nil || roundIdInt >= len(pull.Submissions) { 320 + http.Error(w, "bad round id", http.StatusBadRequest) 321 + log.Println("failed to parse round id", err) 322 + return 323 + } 324 + 325 + if roundIdInt == 0 { 326 + http.Error(w, "bad round id", http.StatusBadRequest) 327 + log.Println("cannot interdiff initial submission") 328 + return 329 + } 330 + 331 + identsToResolve := []string{pull.OwnerDid} 332 + resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve) 333 + didHandleMap := make(map[string]string) 334 + for _, identity := range resolvedIds { 335 + if !identity.Handle.IsInvalidHandle() { 336 + didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 337 + } else { 338 + didHandleMap[identity.DID.String()] = identity.DID.String() 339 + } 340 + } 341 + 342 + currentPatch, err := pull.Submissions[roundIdInt].AsDiff(pull.TargetBranch) 343 + if err != nil { 344 + log.Println("failed to interdiff; current patch malformed") 345 + s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; current patch is invalid.") 346 + return 347 + } 348 + 349 + previousPatch, err := pull.Submissions[roundIdInt-1].AsDiff(pull.TargetBranch) 350 + if err != nil { 351 + log.Println("failed to interdiff; previous patch malformed") 352 + s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; previous patch is invalid.") 353 + return 354 + } 355 + 356 + interdiff := patchutil.Interdiff(previousPatch, currentPatch) 357 + 358 + s.pages.RepoPullInterdiffPage(w, pages.RepoPullInterdiffParams{ 359 + LoggedInUser: s.oauth.GetUser(r), 360 + RepoInfo: f.RepoInfo(s, user), 361 + Pull: pull, 362 + Round: roundIdInt, 363 + DidHandleMap: didHandleMap, 364 + Interdiff: interdiff, 365 }) 366 + return 367 + } 368 + 369 + func (s *State) RepoPullPatchRaw(w http.ResponseWriter, r *http.Request) { 370 + pull, ok := r.Context().Value("pull").(*db.Pull) 371 + if !ok { 372 + log.Println("failed to get pull") 373 + s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 374 + return 375 + } 376 377 + roundId := chi.URLParam(r, "round") 378 + roundIdInt, err := strconv.Atoi(roundId) 379 + if err != nil || roundIdInt >= len(pull.Submissions) { 380 + http.Error(w, "bad round id", http.StatusBadRequest) 381 + log.Println("failed to parse round id", err) 382 + return 383 + } 384 + 385 + identsToResolve := []string{pull.OwnerDid} 386 + resolvedIds := s.resolver.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 + w.Header().Set("Content-Type", "text/plain") 397 + w.Write([]byte(pull.Submissions[roundIdInt].Patch)) 398 } 399 400 func (s *State) RepoPulls(w http.ResponseWriter, r *http.Request) { 401 + user := s.oauth.GetUser(r) 402 params := r.URL.Query() 403 404 state := db.PullOpen ··· 409 state = db.PullMerged 410 } 411 412 + f, err := s.fullyResolvedRepo(r) 413 if err != nil { 414 log.Println("failed to get repo and knot", err) 415 return ··· 422 return 423 } 424 425 + for _, p := range pulls { 426 + var pullSourceRepo *db.Repo 427 + if p.PullSource != nil { 428 + if p.PullSource.RepoAt != nil { 429 + pullSourceRepo, err = db.GetRepoByAtUri(s.db, p.PullSource.RepoAt.String()) 430 + if err != nil { 431 + log.Printf("failed to get repo by at uri: %v", err) 432 + continue 433 + } else { 434 + p.PullSource.Repo = pullSourceRepo 435 + } 436 + } 437 + } 438 + } 439 + 440 identsToResolve := make([]string, len(pulls)) 441 for i, pull := range pulls { 442 identsToResolve[i] = pull.OwnerDid ··· 452 } 453 454 s.pages.RepoPulls(w, pages.RepoPullsParams{ 455 + LoggedInUser: s.oauth.GetUser(r), 456 RepoInfo: f.RepoInfo(s, user), 457 Pulls: pulls, 458 DidHandleMap: didHandleMap, ··· 462 } 463 464 func (s *State) PullComment(w http.ResponseWriter, r *http.Request) { 465 + user := s.oauth.GetUser(r) 466 + f, err := s.fullyResolvedRepo(r) 467 if err != nil { 468 log.Println("failed to get repo and knot", err) 469 return ··· 520 } 521 522 atUri := f.RepoAt.String() 523 + client, err := s.oauth.AuthorizedClient(r) 524 + if err != nil { 525 + log.Println("failed to get authorized client", err) 526 + s.pages.Notice(w, "pull-comment", "Failed to create comment.") 527 + return 528 + } 529 + atResp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 530 Collection: tangled.RepoPullCommentNSID, 531 Repo: user.Did, 532 + Rkey: appview.TID(), 533 Record: &lexutil.LexiconTypeDecoder{ 534 Val: &tangled.RepoPullComment{ 535 Repo: &atUri, 536 + Pull: string(pullAt), 537 Owner: &ownerDid, 538 + Body: body, 539 + CreatedAt: createdAt, 540 }, 541 }, 542 }) 543 if err != nil { 544 log.Println("failed to create pull comment", err) 545 s.pages.Notice(w, "pull-comment", "Failed to create comment.") ··· 574 } 575 576 func (s *State) NewPull(w http.ResponseWriter, r *http.Request) { 577 + user := s.oauth.GetUser(r) 578 + f, err := s.fullyResolvedRepo(r) 579 if err != nil { 580 log.Println("failed to get repo and knot", err) 581 return ··· 583 584 switch r.Method { 585 case http.MethodGet: 586 + us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 587 if err != nil { 588 log.Printf("failed to create unsigned client for %s", f.Knot) 589 s.pages.Error503(w) ··· 618 title := r.FormValue("title") 619 body := r.FormValue("body") 620 targetBranch := r.FormValue("targetBranch") 621 + fromFork := r.FormValue("fork") 622 + sourceBranch := r.FormValue("sourceBranch") 623 patch := r.FormValue("patch") 624 625 + if targetBranch == "" { 626 + s.pages.Notice(w, "pull", "Target branch is required.") 627 + return 628 + } 629 + 630 + // Determine PR type based on input parameters 631 + isPushAllowed := f.RepoInfo(s, user).Roles.IsPushAllowed() 632 + isBranchBased := isPushAllowed && sourceBranch != "" && fromFork == "" 633 + isForkBased := fromFork != "" && sourceBranch != "" 634 + isPatchBased := patch != "" && !isBranchBased && !isForkBased 635 + 636 + if isPatchBased && !patchutil.IsFormatPatch(patch) { 637 + if title == "" { 638 + s.pages.Notice(w, "pull", "Title is required for git-diff patches.") 639 + return 640 + } 641 + } 642 + 643 + // Validate we have at least one valid PR creation method 644 + if !isBranchBased && !isPatchBased && !isForkBased { 645 + s.pages.Notice(w, "pull", "Neither source branch nor patch supplied.") 646 return 647 } 648 649 + // Can't mix branch-based and patch-based approaches 650 + if isBranchBased && patch != "" { 651 + s.pages.Notice(w, "pull", "Cannot select both patch and source branch.") 652 return 653 } 654 655 + us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 656 if err != nil { 657 + log.Printf("failed to create unsigned client to %s: %v", f.Knot, err) 658 + s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.") 659 return 660 } 661 662 + caps, err := us.Capabilities() 663 if err != nil { 664 + log.Println("error fetching knot caps", f.Knot, err) 665 + s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.") 666 return 667 } 668 + 669 + if !caps.PullRequests.FormatPatch { 670 + s.pages.Notice(w, "pull", "This knot doesn't support format-patch. Unfortunately, there is no fallback for now.") 671 return 672 } 673 674 + // Handle the PR creation based on the type 675 + if isBranchBased { 676 + if !caps.PullRequests.BranchSubmissions { 677 + s.pages.Notice(w, "pull", "This knot doesn't support branch-based pull requests. Try another way?") 678 + return 679 + } 680 + s.handleBranchBasedPull(w, r, f, user, title, body, targetBranch, sourceBranch) 681 + } else if isForkBased { 682 + if !caps.PullRequests.ForkSubmissions { 683 + s.pages.Notice(w, "pull", "This knot doesn't support fork-based pull requests. Try another way?") 684 + return 685 + } 686 + s.handleForkBasedPull(w, r, f, user, fromFork, title, body, targetBranch, sourceBranch) 687 + } else if isPatchBased { 688 + if !caps.PullRequests.PatchSubmissions { 689 + s.pages.Notice(w, "pull", "This knot doesn't support patch-based pull requests. Send your patch over email.") 690 + return 691 + } 692 + s.handlePatchBasedPull(w, r, f, user, title, body, targetBranch, patch) 693 + } 694 + return 695 + } 696 + } 697 + 698 + func (s *State) handleBranchBasedPull(w http.ResponseWriter, r *http.Request, f *FullyResolvedRepo, user *oauth.User, title, body, targetBranch, sourceBranch string) { 699 + pullSource := &db.PullSource{ 700 + Branch: sourceBranch, 701 + } 702 + recordPullSource := &tangled.RepoPull_Source{ 703 + Branch: sourceBranch, 704 + } 705 + 706 + // Generate a patch using /compare 707 + ksClient, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 708 + if err != nil { 709 + log.Printf("failed to create signed client for %s: %s", f.Knot, err) 710 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 711 + return 712 + } 713 + 714 + comparison, err := ksClient.Compare(f.OwnerDid(), f.RepoName, targetBranch, sourceBranch) 715 + if err != nil { 716 + log.Println("failed to compare", err) 717 + s.pages.Notice(w, "pull", err.Error()) 718 + return 719 + } 720 + 721 + sourceRev := comparison.Rev2 722 + patch := comparison.Patch 723 + 724 + if !patchutil.IsPatchValid(patch) { 725 + s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 726 + return 727 + } 728 + 729 + s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, sourceRev, pullSource, recordPullSource) 730 + } 731 + 732 + func (s *State) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, f *FullyResolvedRepo, user *oauth.User, title, body, targetBranch, patch string) { 733 + if !patchutil.IsPatchValid(patch) { 734 + s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 735 + return 736 + } 737 + 738 + s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, "", nil, nil) 739 + } 740 + 741 + func (s *State) handleForkBasedPull(w http.ResponseWriter, r *http.Request, f *FullyResolvedRepo, user *oauth.User, forkRepo string, title, body, targetBranch, sourceBranch string) { 742 + fork, err := db.GetForkByDid(s.db, user.Did, forkRepo) 743 + if errors.Is(err, sql.ErrNoRows) { 744 + s.pages.Notice(w, "pull", "No such fork.") 745 + return 746 + } else if err != nil { 747 + log.Println("failed to fetch fork:", err) 748 + s.pages.Notice(w, "pull", "Failed to fetch fork.") 749 + return 750 + } 751 + 752 + secret, err := db.GetRegistrationKey(s.db, fork.Knot) 753 + if err != nil { 754 + log.Println("failed to fetch registration key:", err) 755 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 756 + return 757 + } 758 + 759 + sc, err := knotclient.NewSignedClient(fork.Knot, secret, s.config.Core.Dev) 760 + if err != nil { 761 + log.Println("failed to create signed client:", err) 762 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 763 + return 764 + } 765 + 766 + us, err := knotclient.NewUnsignedClient(fork.Knot, s.config.Core.Dev) 767 + if err != nil { 768 + log.Println("failed to create unsigned client:", err) 769 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 770 + return 771 + } 772 773 + resp, err := sc.NewHiddenRef(user.Did, fork.Name, sourceBranch, targetBranch) 774 + if err != nil { 775 + log.Println("failed to create hidden ref:", err, resp.StatusCode) 776 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 777 + return 778 + } 779 + 780 + switch resp.StatusCode { 781 + case 404: 782 + case 400: 783 + s.pages.Notice(w, "pull", "Branch based pull requests are not supported on this knot.") 784 + return 785 + } 786 + 787 + hiddenRef := fmt.Sprintf("hidden/%s/%s", sourceBranch, targetBranch) 788 + // We're now comparing the sourceBranch (on the fork) against the hiddenRef which is tracking 789 + // the targetBranch on the target repository. This code is a bit confusing, but here's an example: 790 + // hiddenRef: hidden/feature-1/main (on repo-fork) 791 + // targetBranch: main (on repo-1) 792 + // sourceBranch: feature-1 (on repo-fork) 793 + comparison, err := us.Compare(user.Did, fork.Name, hiddenRef, sourceBranch) 794 + if err != nil { 795 + log.Println("failed to compare across branches", err) 796 + s.pages.Notice(w, "pull", err.Error()) 797 + return 798 + } 799 + 800 + sourceRev := comparison.Rev2 801 + patch := comparison.Patch 802 + 803 + if !patchutil.IsPatchValid(patch) { 804 + s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 805 + return 806 + } 807 + 808 + forkAtUri, err := syntax.ParseATURI(fork.AtUri) 809 + if err != nil { 810 + log.Println("failed to parse fork AT URI", err) 811 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 812 + return 813 + } 814 + 815 + s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, sourceRev, &db.PullSource{ 816 + Branch: sourceBranch, 817 + RepoAt: &forkAtUri, 818 + }, &tangled.RepoPull_Source{Branch: sourceBranch, Repo: &fork.AtUri}) 819 + } 820 + 821 + func (s *State) createPullRequest( 822 + w http.ResponseWriter, 823 + r *http.Request, 824 + f *FullyResolvedRepo, 825 + user *oauth.User, 826 + title, body, targetBranch string, 827 + patch string, 828 + sourceRev string, 829 + pullSource *db.PullSource, 830 + recordPullSource *tangled.RepoPull_Source, 831 + ) { 832 + tx, err := s.db.BeginTx(r.Context(), nil) 833 + if err != nil { 834 + log.Println("failed to start tx") 835 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 836 + return 837 + } 838 + defer tx.Rollback() 839 + 840 + // We've already checked earlier if it's diff-based and title is empty, 841 + // so if it's still empty now, it's intentionally skipped owing to format-patch. 842 + if title == "" { 843 + formatPatches, err := patchutil.ExtractPatches(patch) 844 if err != nil { 845 + s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err)) 846 + return 847 + } 848 + if len(formatPatches) == 0 { 849 + s.pages.Notice(w, "pull", "No patches found in the supplied format-patch.") 850 return 851 } 852 853 + title = formatPatches[0].Title 854 + body = formatPatches[0].Body 855 + } 856 + 857 + rkey := appview.TID() 858 + initialSubmission := db.PullSubmission{ 859 + Patch: patch, 860 + SourceRev: sourceRev, 861 + } 862 + err = db.NewPull(tx, &db.Pull{ 863 + Title: title, 864 + Body: body, 865 + TargetBranch: targetBranch, 866 + OwnerDid: user.Did, 867 + RepoAt: f.RepoAt, 868 + Rkey: rkey, 869 + Submissions: []*db.PullSubmission{ 870 + &initialSubmission, 871 + }, 872 + PullSource: pullSource, 873 + }) 874 + if err != nil { 875 + log.Println("failed to create pull request", err) 876 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 877 + return 878 + } 879 + client, err := s.oauth.AuthorizedClient(r) 880 + if err != nil { 881 + log.Println("failed to get authorized client", err) 882 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 883 + return 884 + } 885 + pullId, err := db.NextPullId(tx, f.RepoAt) 886 + if err != nil { 887 + log.Println("failed to get pull id", err) 888 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 889 + return 890 + } 891 + 892 + _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 893 + Collection: tangled.RepoPullNSID, 894 + Repo: user.Did, 895 + Rkey: rkey, 896 + Record: &lexutil.LexiconTypeDecoder{ 897 + Val: &tangled.RepoPull{ 898 + Title: title, 899 + PullId: int64(pullId), 900 + TargetRepo: string(f.RepoAt), 901 + TargetBranch: targetBranch, 902 + Patch: patch, 903 + Source: recordPullSource, 904 + }, 905 + }, 906 + }) 907 + if err != nil { 908 + log.Println("failed to create pull request", err) 909 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 910 + return 911 + } 912 + 913 + if err = tx.Commit(); err != nil { 914 + log.Println("failed to create pull request", err) 915 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 916 return 917 } 918 + 919 + s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pullId)) 920 + } 921 + 922 + func (s *State) ValidatePatch(w http.ResponseWriter, r *http.Request) { 923 + _, err := s.fullyResolvedRepo(r) 924 + if err != nil { 925 + log.Println("failed to get repo and knot", err) 926 + return 927 + } 928 + 929 + patch := r.FormValue("patch") 930 + if patch == "" { 931 + s.pages.Notice(w, "patch-error", "Patch is required.") 932 + return 933 + } 934 + 935 + if patch == "" || !patchutil.IsPatchValid(patch) { 936 + s.pages.Notice(w, "patch-error", "Invalid patch format. Please provide a valid git diff or format-patch.") 937 + return 938 + } 939 + 940 + if patchutil.IsFormatPatch(patch) { 941 + s.pages.Notice(w, "patch-preview", "git-format-patch detected. Title and description are optional; if left out, they will be extracted from the first commit.") 942 + } else { 943 + s.pages.Notice(w, "patch-preview", "Regular git-diff detected. Please provide a title and description.") 944 + } 945 + } 946 + 947 + func (s *State) PatchUploadFragment(w http.ResponseWriter, r *http.Request) { 948 + user := s.oauth.GetUser(r) 949 + f, err := s.fullyResolvedRepo(r) 950 + if err != nil { 951 + log.Println("failed to get repo and knot", err) 952 + return 953 + } 954 + 955 + s.pages.PullPatchUploadFragment(w, pages.PullPatchUploadParams{ 956 + RepoInfo: f.RepoInfo(s, user), 957 + }) 958 + } 959 + 960 + func (s *State) CompareBranchesFragment(w http.ResponseWriter, r *http.Request) { 961 + user := s.oauth.GetUser(r) 962 + f, err := s.fullyResolvedRepo(r) 963 + if err != nil { 964 + log.Println("failed to get repo and knot", err) 965 + return 966 + } 967 + 968 + us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 969 + if err != nil { 970 + log.Printf("failed to create unsigned client for %s", f.Knot) 971 + s.pages.Error503(w) 972 + return 973 + } 974 + 975 + resp, err := us.Branches(f.OwnerDid(), f.RepoName) 976 + if err != nil { 977 + log.Println("failed to reach knotserver", err) 978 + return 979 + } 980 + 981 + body, err := io.ReadAll(resp.Body) 982 + if err != nil { 983 + log.Printf("Error reading response body: %v", err) 984 + return 985 + } 986 + 987 + var result types.RepoBranchesResponse 988 + err = json.Unmarshal(body, &result) 989 + if err != nil { 990 + log.Println("failed to parse response:", err) 991 + return 992 + } 993 + 994 + s.pages.PullCompareBranchesFragment(w, pages.PullCompareBranchesParams{ 995 + RepoInfo: f.RepoInfo(s, user), 996 + Branches: result.Branches, 997 + }) 998 + } 999 + 1000 + func (s *State) CompareForksFragment(w http.ResponseWriter, r *http.Request) { 1001 + user := s.oauth.GetUser(r) 1002 + f, err := s.fullyResolvedRepo(r) 1003 + if err != nil { 1004 + log.Println("failed to get repo and knot", err) 1005 + return 1006 + } 1007 + 1008 + forks, err := db.GetForksByDid(s.db, user.Did) 1009 + if err != nil { 1010 + log.Println("failed to get forks", err) 1011 + return 1012 + } 1013 + 1014 + s.pages.PullCompareForkFragment(w, pages.PullCompareForkParams{ 1015 + RepoInfo: f.RepoInfo(s, user), 1016 + Forks: forks, 1017 + }) 1018 + } 1019 + 1020 + func (s *State) CompareForksBranchesFragment(w http.ResponseWriter, r *http.Request) { 1021 + user := s.oauth.GetUser(r) 1022 + 1023 + f, err := s.fullyResolvedRepo(r) 1024 + if err != nil { 1025 + log.Println("failed to get repo and knot", err) 1026 + return 1027 + } 1028 + 1029 + forkVal := r.URL.Query().Get("fork") 1030 + 1031 + // fork repo 1032 + repo, err := db.GetRepo(s.db, user.Did, forkVal) 1033 + if err != nil { 1034 + log.Println("failed to get repo", user.Did, forkVal) 1035 + return 1036 + } 1037 + 1038 + sourceBranchesClient, err := knotclient.NewUnsignedClient(repo.Knot, s.config.Core.Dev) 1039 + if err != nil { 1040 + log.Printf("failed to create unsigned client for %s", repo.Knot) 1041 + s.pages.Error503(w) 1042 + return 1043 + } 1044 + 1045 + sourceResp, err := sourceBranchesClient.Branches(user.Did, repo.Name) 1046 + if err != nil { 1047 + log.Println("failed to reach knotserver for source branches", err) 1048 + return 1049 + } 1050 + 1051 + sourceBody, err := io.ReadAll(sourceResp.Body) 1052 + if err != nil { 1053 + log.Println("failed to read source response body", err) 1054 + return 1055 + } 1056 + defer sourceResp.Body.Close() 1057 + 1058 + var sourceResult types.RepoBranchesResponse 1059 + err = json.Unmarshal(sourceBody, &sourceResult) 1060 + if err != nil { 1061 + log.Println("failed to parse source branches response:", err) 1062 + return 1063 + } 1064 + 1065 + targetBranchesClient, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 1066 + if err != nil { 1067 + log.Printf("failed to create unsigned client for target knot %s", f.Knot) 1068 + s.pages.Error503(w) 1069 + return 1070 + } 1071 + 1072 + targetResp, err := targetBranchesClient.Branches(f.OwnerDid(), f.RepoName) 1073 + if err != nil { 1074 + log.Println("failed to reach knotserver for target branches", err) 1075 + return 1076 + } 1077 + 1078 + targetBody, err := io.ReadAll(targetResp.Body) 1079 + if err != nil { 1080 + log.Println("failed to read target response body", err) 1081 + return 1082 + } 1083 + defer targetResp.Body.Close() 1084 + 1085 + var targetResult types.RepoBranchesResponse 1086 + err = json.Unmarshal(targetBody, &targetResult) 1087 + if err != nil { 1088 + log.Println("failed to parse target branches response:", err) 1089 + return 1090 + } 1091 + 1092 + s.pages.PullCompareForkBranchesFragment(w, pages.PullCompareForkBranchesParams{ 1093 + RepoInfo: f.RepoInfo(s, user), 1094 + SourceBranches: sourceResult.Branches, 1095 + TargetBranches: targetResult.Branches, 1096 + }) 1097 } 1098 1099 func (s *State) ResubmitPull(w http.ResponseWriter, r *http.Request) { 1100 + user := s.oauth.GetUser(r) 1101 + f, err := s.fullyResolvedRepo(r) 1102 if err != nil { 1103 log.Println("failed to get repo and knot", err) 1104 return ··· 1119 }) 1120 return 1121 case http.MethodPost: 1122 + if pull.IsPatchBased() { 1123 + s.resubmitPatch(w, r) 1124 + return 1125 + } else if pull.IsBranchBased() { 1126 + s.resubmitBranch(w, r) 1127 + return 1128 + } else if pull.IsForkBased() { 1129 + s.resubmitFork(w, r) 1130 return 1131 } 1132 + } 1133 + } 1134 1135 + func (s *State) resubmitPatch(w http.ResponseWriter, r *http.Request) { 1136 + user := s.oauth.GetUser(r) 1137 + 1138 + pull, ok := r.Context().Value("pull").(*db.Pull) 1139 + if !ok { 1140 + log.Println("failed to get pull") 1141 + s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 1142 + return 1143 + } 1144 + 1145 + f, err := s.fullyResolvedRepo(r) 1146 + if err != nil { 1147 + log.Println("failed to get repo and knot", err) 1148 + return 1149 + } 1150 + 1151 + if user.Did != pull.OwnerDid { 1152 + log.Println("unauthorized user") 1153 + w.WriteHeader(http.StatusUnauthorized) 1154 + return 1155 + } 1156 + 1157 + patch := r.FormValue("patch") 1158 + 1159 + if err = validateResubmittedPatch(pull, patch); err != nil { 1160 + s.pages.Notice(w, "resubmit-error", err.Error()) 1161 + return 1162 + } 1163 + 1164 + tx, err := s.db.BeginTx(r.Context(), nil) 1165 + if err != nil { 1166 + log.Println("failed to start tx") 1167 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1168 + return 1169 + } 1170 + defer tx.Rollback() 1171 + 1172 + err = db.ResubmitPull(tx, pull, patch, "") 1173 + if err != nil { 1174 + log.Println("failed to resubmit pull request", err) 1175 + s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull request. Try again later.") 1176 + return 1177 + } 1178 + client, err := s.oauth.AuthorizedClient(r) 1179 + if err != nil { 1180 + log.Println("failed to get authorized client", err) 1181 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1182 + return 1183 + } 1184 + 1185 + ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoPullNSID, user.Did, pull.Rkey) 1186 + if err != nil { 1187 + // failed to get record 1188 + s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.") 1189 + return 1190 + } 1191 + 1192 + _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1193 + Collection: tangled.RepoPullNSID, 1194 + Repo: user.Did, 1195 + Rkey: pull.Rkey, 1196 + SwapRecord: ex.Cid, 1197 + Record: &lexutil.LexiconTypeDecoder{ 1198 + Val: &tangled.RepoPull{ 1199 + Title: pull.Title, 1200 + PullId: int64(pull.PullId), 1201 + TargetRepo: string(f.RepoAt), 1202 + TargetBranch: pull.TargetBranch, 1203 + Patch: patch, // new patch 1204 + }, 1205 + }, 1206 + }) 1207 + if err != nil { 1208 + log.Println("failed to update record", err) 1209 + s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.") 1210 + return 1211 + } 1212 + 1213 + if err = tx.Commit(); err != nil { 1214 + log.Println("failed to commit transaction", err) 1215 + s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull.") 1216 + return 1217 + } 1218 + 1219 + s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 1220 + return 1221 + } 1222 + 1223 + func (s *State) resubmitBranch(w http.ResponseWriter, r *http.Request) { 1224 + user := s.oauth.GetUser(r) 1225 + 1226 + pull, ok := r.Context().Value("pull").(*db.Pull) 1227 + if !ok { 1228 + log.Println("failed to get pull") 1229 + s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.") 1230 + return 1231 + } 1232 + 1233 + f, err := s.fullyResolvedRepo(r) 1234 + if err != nil { 1235 + log.Println("failed to get repo and knot", err) 1236 + return 1237 + } 1238 + 1239 + if user.Did != pull.OwnerDid { 1240 + log.Println("unauthorized user") 1241 + w.WriteHeader(http.StatusUnauthorized) 1242 + return 1243 + } 1244 + 1245 + if !f.RepoInfo(s, user).Roles.IsPushAllowed() { 1246 + log.Println("unauthorized user") 1247 + w.WriteHeader(http.StatusUnauthorized) 1248 + return 1249 + } 1250 + 1251 + ksClient, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 1252 + if err != nil { 1253 + log.Printf("failed to create client for %s: %s", f.Knot, err) 1254 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1255 + return 1256 + } 1257 1258 + comparison, err := ksClient.Compare(f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.PullSource.Branch) 1259 + if err != nil { 1260 + log.Printf("compare request failed: %s", err) 1261 + s.pages.Notice(w, "resubmit-error", err.Error()) 1262 + return 1263 + } 1264 + 1265 + sourceRev := comparison.Rev2 1266 + patch := comparison.Patch 1267 + 1268 + if err = validateResubmittedPatch(pull, patch); err != nil { 1269 + s.pages.Notice(w, "resubmit-error", err.Error()) 1270 + return 1271 + } 1272 + 1273 + if sourceRev == pull.Submissions[pull.LastRoundNumber()].SourceRev { 1274 + s.pages.Notice(w, "resubmit-error", "This branch has not changed since the last submission.") 1275 + return 1276 + } 1277 1278 + tx, err := s.db.BeginTx(r.Context(), nil) 1279 + if err != nil { 1280 + log.Println("failed to start tx") 1281 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1282 + return 1283 + } 1284 + defer tx.Rollback() 1285 1286 + err = db.ResubmitPull(tx, pull, patch, sourceRev) 1287 + if err != nil { 1288 + log.Println("failed to create pull request", err) 1289 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1290 + return 1291 + } 1292 + client, err := s.oauth.AuthorizedClient(r) 1293 + if err != nil { 1294 + log.Println("failed to authorize client") 1295 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1296 + return 1297 + } 1298 1299 + ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoPullNSID, user.Did, pull.Rkey) 1300 + if err != nil { 1301 + // failed to get record 1302 + s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.") 1303 + return 1304 + } 1305 1306 + recordPullSource := &tangled.RepoPull_Source{ 1307 + Branch: pull.PullSource.Branch, 1308 + } 1309 + _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1310 + Collection: tangled.RepoPullNSID, 1311 + Repo: user.Did, 1312 + Rkey: pull.Rkey, 1313 + SwapRecord: ex.Cid, 1314 + Record: &lexutil.LexiconTypeDecoder{ 1315 + Val: &tangled.RepoPull{ 1316 + Title: pull.Title, 1317 + PullId: int64(pull.PullId), 1318 + TargetRepo: string(f.RepoAt), 1319 + TargetBranch: pull.TargetBranch, 1320 + Patch: patch, // new patch 1321 + Source: recordPullSource, 1322 }, 1323 + }, 1324 + }) 1325 + if err != nil { 1326 + log.Println("failed to update record", err) 1327 + s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.") 1328 + return 1329 + } 1330 + 1331 + if err = tx.Commit(); err != nil { 1332 + log.Println("failed to commit transaction", err) 1333 + s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull.") 1334 + return 1335 + } 1336 + 1337 + s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 1338 + return 1339 + } 1340 + 1341 + func (s *State) resubmitFork(w http.ResponseWriter, r *http.Request) { 1342 + user := s.oauth.GetUser(r) 1343 + 1344 + pull, ok := r.Context().Value("pull").(*db.Pull) 1345 + if !ok { 1346 + log.Println("failed to get pull") 1347 + s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.") 1348 + return 1349 + } 1350 + 1351 + f, err := s.fullyResolvedRepo(r) 1352 + if err != nil { 1353 + log.Println("failed to get repo and knot", err) 1354 + return 1355 + } 1356 + 1357 + if user.Did != pull.OwnerDid { 1358 + log.Println("unauthorized user") 1359 + w.WriteHeader(http.StatusUnauthorized) 1360 + return 1361 + } 1362 + 1363 + forkRepo, err := db.GetRepoByAtUri(s.db, pull.PullSource.RepoAt.String()) 1364 + if err != nil { 1365 + log.Println("failed to get source repo", err) 1366 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1367 + return 1368 + } 1369 1370 + // extract patch by performing compare 1371 + ksClient, err := knotclient.NewUnsignedClient(forkRepo.Knot, s.config.Core.Dev) 1372 + if err != nil { 1373 + log.Printf("failed to create client for %s: %s", forkRepo.Knot, err) 1374 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1375 + return 1376 + } 1377 + 1378 + secret, err := db.GetRegistrationKey(s.db, forkRepo.Knot) 1379 + if err != nil { 1380 + log.Printf("failed to get registration key for %s: %s", forkRepo.Knot, err) 1381 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1382 + return 1383 + } 1384 1385 + // update the hidden tracking branch to latest 1386 + signedClient, err := knotclient.NewSignedClient(forkRepo.Knot, secret, s.config.Core.Dev) 1387 + if err != nil { 1388 + log.Printf("failed to create signed client for %s: %s", forkRepo.Knot, err) 1389 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1390 return 1391 } 1392 + 1393 + resp, err := signedClient.NewHiddenRef(forkRepo.Did, forkRepo.Name, pull.PullSource.Branch, pull.TargetBranch) 1394 + if err != nil || resp.StatusCode != http.StatusNoContent { 1395 + log.Printf("failed to update tracking branch: %s", err) 1396 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1397 + return 1398 + } 1399 + 1400 + hiddenRef := fmt.Sprintf("hidden/%s/%s", pull.PullSource.Branch, pull.TargetBranch) 1401 + comparison, err := ksClient.Compare(forkRepo.Did, forkRepo.Name, hiddenRef, pull.PullSource.Branch) 1402 + if err != nil { 1403 + log.Printf("failed to compare branches: %s", err) 1404 + s.pages.Notice(w, "resubmit-error", err.Error()) 1405 + return 1406 + } 1407 + 1408 + sourceRev := comparison.Rev2 1409 + patch := comparison.Patch 1410 + 1411 + if err = validateResubmittedPatch(pull, patch); err != nil { 1412 + s.pages.Notice(w, "resubmit-error", err.Error()) 1413 + return 1414 + } 1415 + 1416 + if sourceRev == pull.Submissions[pull.LastRoundNumber()].SourceRev { 1417 + s.pages.Notice(w, "resubmit-error", "This branch has not changed since the last submission.") 1418 + return 1419 + } 1420 + 1421 + tx, err := s.db.BeginTx(r.Context(), nil) 1422 + if err != nil { 1423 + log.Println("failed to start tx") 1424 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1425 + return 1426 + } 1427 + defer tx.Rollback() 1428 + 1429 + err = db.ResubmitPull(tx, pull, patch, sourceRev) 1430 + if err != nil { 1431 + log.Println("failed to create pull request", err) 1432 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1433 + return 1434 + } 1435 + client, err := s.oauth.AuthorizedClient(r) 1436 + if err != nil { 1437 + log.Println("failed to get client") 1438 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1439 + return 1440 + } 1441 + 1442 + ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoPullNSID, user.Did, pull.Rkey) 1443 + if err != nil { 1444 + // failed to get record 1445 + s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.") 1446 + return 1447 + } 1448 + 1449 + repoAt := pull.PullSource.RepoAt.String() 1450 + recordPullSource := &tangled.RepoPull_Source{ 1451 + Branch: pull.PullSource.Branch, 1452 + Repo: &repoAt, 1453 + } 1454 + _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1455 + Collection: tangled.RepoPullNSID, 1456 + Repo: user.Did, 1457 + Rkey: pull.Rkey, 1458 + SwapRecord: ex.Cid, 1459 + Record: &lexutil.LexiconTypeDecoder{ 1460 + Val: &tangled.RepoPull{ 1461 + Title: pull.Title, 1462 + PullId: int64(pull.PullId), 1463 + TargetRepo: string(f.RepoAt), 1464 + TargetBranch: pull.TargetBranch, 1465 + Patch: patch, // new patch 1466 + Source: recordPullSource, 1467 + }, 1468 + }, 1469 + }) 1470 + if err != nil { 1471 + log.Println("failed to update record", err) 1472 + s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.") 1473 + return 1474 + } 1475 + 1476 + if err = tx.Commit(); err != nil { 1477 + log.Println("failed to commit transaction", err) 1478 + s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull.") 1479 + return 1480 + } 1481 + 1482 + s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 1483 + return 1484 + } 1485 + 1486 + // validate a resubmission against a pull request 1487 + func validateResubmittedPatch(pull *db.Pull, patch string) error { 1488 + if patch == "" { 1489 + return fmt.Errorf("Patch is empty.") 1490 + } 1491 + 1492 + if patch == pull.LatestPatch() { 1493 + return fmt.Errorf("Patch is identical to previous submission.") 1494 + } 1495 + 1496 + if !patchutil.IsPatchValid(patch) { 1497 + return fmt.Errorf("Invalid patch format. Please provide a valid diff.") 1498 + } 1499 + 1500 + return nil 1501 } 1502 1503 func (s *State) MergePull(w http.ResponseWriter, r *http.Request) { 1504 + f, err := s.fullyResolvedRepo(r) 1505 if err != nil { 1506 log.Println("failed to resolve repo:", err) 1507 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") ··· 1522 return 1523 } 1524 1525 + ident, err := s.resolver.ResolveIdent(r.Context(), pull.OwnerDid) 1526 + if err != nil { 1527 + log.Printf("resolving identity: %s", err) 1528 + w.WriteHeader(http.StatusNotFound) 1529 + return 1530 + } 1531 + 1532 + email, err := db.GetPrimaryEmail(s.db, pull.OwnerDid) 1533 + if err != nil { 1534 + log.Printf("failed to get primary email: %s", err) 1535 + } 1536 + 1537 + ksClient, err := knotclient.NewSignedClient(f.Knot, secret, s.config.Core.Dev) 1538 if err != nil { 1539 log.Printf("failed to create signed client for %s: %s", f.Knot, err) 1540 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") ··· 1542 } 1543 1544 // Merge the pull request 1545 + resp, err := ksClient.Merge([]byte(pull.LatestPatch()), f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.Title, pull.Body, ident.Handle.String(), email.Address) 1546 if err != nil { 1547 log.Printf("failed to merge pull request: %s", err) 1548 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") ··· 1564 } 1565 1566 func (s *State) ClosePull(w http.ResponseWriter, r *http.Request) { 1567 + user := s.oauth.GetUser(r) 1568 1569 + f, err := s.fullyResolvedRepo(r) 1570 if err != nil { 1571 log.Println("malformed middleware") 1572 return ··· 1618 } 1619 1620 func (s *State) ReopenPull(w http.ResponseWriter, r *http.Request) { 1621 + user := s.oauth.GetUser(r) 1622 1623 + f, err := s.fullyResolvedRepo(r) 1624 if err != nil { 1625 log.Println("failed to resolve repo", err) 1626 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") ··· 1671 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 1672 return 1673 }
+1006 -99
appview/state/repo.go
··· 2 3 import ( 4 "context" 5 "encoding/json" 6 "fmt" 7 "io" 8 "log" 9 - "math/rand/v2" 10 "net/http" 11 "path" 12 "slices" ··· 14 "strings" 15 "time" 16 17 "github.com/bluesky-social/indigo/atproto/identity" 18 "github.com/bluesky-social/indigo/atproto/syntax" 19 securejoin "github.com/cyphar/filepath-securejoin" 20 "github.com/go-chi/chi/v5" 21 - "tangled.sh/tangled.sh/core/api/tangled" 22 - "tangled.sh/tangled.sh/core/appview/auth" 23 - "tangled.sh/tangled.sh/core/appview/db" 24 - "tangled.sh/tangled.sh/core/appview/pages" 25 - "tangled.sh/tangled.sh/core/types" 26 27 comatproto "github.com/bluesky-social/indigo/api/atproto" 28 lexutil "github.com/bluesky-social/indigo/lex/util" ··· 30 31 func (s *State) RepoIndex(w http.ResponseWriter, r *http.Request) { 32 ref := chi.URLParam(r, "ref") 33 - f, err := fullyResolvedRepo(r) 34 if err != nil { 35 log.Println("failed to fully resolve repo", err) 36 return 37 } 38 39 - us, err := NewUnsignedClient(f.Knot, s.config.Dev) 40 if err != nil { 41 log.Printf("failed to create unsigned client for %s", f.Knot) 42 s.pages.Error503(w) ··· 67 tagMap := make(map[string][]string) 68 for _, tag := range result.Tags { 69 hash := tag.Hash 70 tagMap[hash] = append(tagMap[hash], tag.Name) 71 } 72 ··· 75 tagMap[hash] = append(tagMap[hash], branch.Name) 76 } 77 78 - emails := uniqueEmails(result.Commits) 79 80 - user := s.auth.GetUser(r) 81 s.pages.RepoIndexPage(w, pages.RepoIndexParams{ 82 LoggedInUser: user, 83 RepoInfo: f.RepoInfo(s, user), 84 TagMap: tagMap, 85 RepoIndexResponse: result, 86 EmailToDidOrHandle: EmailToDidOrHandle(s, emails), 87 }) 88 return 89 } 90 91 func (s *State) RepoLog(w http.ResponseWriter, r *http.Request) { 92 - f, err := fullyResolvedRepo(r) 93 if err != nil { 94 log.Println("failed to fully resolve repo", err) 95 return ··· 105 106 ref := chi.URLParam(r, "ref") 107 108 - protocol := "http" 109 - if !s.config.Dev { 110 - protocol = "https" 111 } 112 113 - resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/log/%s?page=%d&per_page=30", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, page)) 114 if err != nil { 115 log.Println("failed to reach knotserver", err) 116 return ··· 129 return 130 } 131 132 - user := s.auth.GetUser(r) 133 s.pages.RepoLog(w, pages.RepoLogParams{ 134 LoggedInUser: user, 135 RepoInfo: f.RepoInfo(s, user), 136 RepoLogResponse: repolog, 137 EmailToDidOrHandle: EmailToDidOrHandle(s, uniqueEmails(repolog.Commits)), ··· 140 } 141 142 func (s *State) RepoDescriptionEdit(w http.ResponseWriter, r *http.Request) { 143 - f, err := fullyResolvedRepo(r) 144 if err != nil { 145 log.Println("failed to get repo and knot", err) 146 w.WriteHeader(http.StatusBadRequest) 147 return 148 } 149 150 - user := s.auth.GetUser(r) 151 s.pages.EditRepoDescriptionFragment(w, pages.RepoDescriptionParams{ 152 RepoInfo: f.RepoInfo(s, user), 153 }) ··· 155 } 156 157 func (s *State) RepoDescription(w http.ResponseWriter, r *http.Request) { 158 - f, err := fullyResolvedRepo(r) 159 if err != nil { 160 log.Println("failed to get repo and knot", err) 161 w.WriteHeader(http.StatusBadRequest) ··· 170 return 171 } 172 173 - user := s.auth.GetUser(r) 174 175 switch r.Method { 176 case http.MethodGet: ··· 179 }) 180 return 181 case http.MethodPut: 182 - user := s.auth.GetUser(r) 183 newDescription := r.FormValue("description") 184 - client, _ := s.auth.AuthorizedClient(r) 185 186 // optimistic update 187 err = db.UpdateDescription(s.db, string(repoAt), newDescription) ··· 194 // this is a bit of a pain because the golang atproto impl does not allow nil SwapRecord field 195 // 196 // SwapRecord is optional and should happen automagically, but given that it does not, we have to perform two requests 197 - ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, user.Did, rkey) 198 if err != nil { 199 // failed to get record 200 s.pages.Notice(w, "repo-notice", "Failed to update description, no record found on PDS.") 201 return 202 } 203 - _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 204 Collection: tangled.RepoNSID, 205 Repo: user.Did, 206 Rkey: rkey, ··· 210 Knot: f.Knot, 211 Name: f.RepoName, 212 Owner: user.Did, 213 - AddedAt: &f.AddedAt, 214 Description: &newDescription, 215 }, 216 }, ··· 234 } 235 236 func (s *State) RepoCommit(w http.ResponseWriter, r *http.Request) { 237 - f, err := fullyResolvedRepo(r) 238 if err != nil { 239 log.Println("failed to fully resolve repo", err) 240 return 241 } 242 ref := chi.URLParam(r, "ref") 243 protocol := "http" 244 - if !s.config.Dev { 245 protocol = "https" 246 } 247 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/commit/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref)) 248 if err != nil { 249 log.Println("failed to reach knotserver", err) ··· 263 return 264 } 265 266 - user := s.auth.GetUser(r) 267 s.pages.RepoCommit(w, pages.RepoCommitParams{ 268 LoggedInUser: user, 269 RepoInfo: f.RepoInfo(s, user), ··· 274 } 275 276 func (s *State) RepoTree(w http.ResponseWriter, r *http.Request) { 277 - f, err := fullyResolvedRepo(r) 278 if err != nil { 279 log.Println("failed to fully resolve repo", err) 280 return ··· 283 ref := chi.URLParam(r, "ref") 284 treePath := chi.URLParam(r, "*") 285 protocol := "http" 286 - if !s.config.Dev { 287 protocol = "https" 288 } 289 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/tree/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, treePath)) ··· 305 return 306 } 307 308 - user := s.auth.GetUser(r) 309 310 var breadcrumbs [][]string 311 - breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/%s/tree/%s", f.OwnerDid(), f.RepoName, ref)}) 312 if treePath != "" { 313 for idx, elem := range strings.Split(treePath, "/") { 314 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)}) 315 } 316 } 317 318 - baseTreeLink := path.Join(f.OwnerDid(), f.RepoName, "tree", ref, treePath) 319 - baseBlobLink := path.Join(f.OwnerDid(), f.RepoName, "blob", ref, treePath) 320 321 s.pages.RepoTree(w, pages.RepoTreeParams{ 322 LoggedInUser: user, ··· 330 } 331 332 func (s *State) RepoTags(w http.ResponseWriter, r *http.Request) { 333 - f, err := fullyResolvedRepo(r) 334 if err != nil { 335 log.Println("failed to get repo and knot", err) 336 return 337 } 338 339 - protocol := "http" 340 - if !s.config.Dev { 341 - protocol = "https" 342 } 343 344 - resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/tags", protocol, f.Knot, f.OwnerDid(), f.RepoName)) 345 if err != nil { 346 log.Println("failed to reach knotserver", err) 347 return 348 } 349 350 - body, err := io.ReadAll(resp.Body) 351 if err != nil { 352 - log.Printf("Error reading response body: %v", err) 353 return 354 } 355 356 - var result types.RepoTagsResponse 357 - err = json.Unmarshal(body, &result) 358 - if err != nil { 359 - log.Println("failed to parse response:", err) 360 - return 361 } 362 363 - user := s.auth.GetUser(r) 364 s.pages.RepoTags(w, pages.RepoTagsParams{ 365 - LoggedInUser: user, 366 - RepoInfo: f.RepoInfo(s, user), 367 - RepoTagsResponse: result, 368 }) 369 return 370 } 371 372 func (s *State) RepoBranches(w http.ResponseWriter, r *http.Request) { 373 - f, err := fullyResolvedRepo(r) 374 if err != nil { 375 log.Println("failed to get repo and knot", err) 376 return 377 } 378 379 - us, err := NewUnsignedClient(f.Knot, s.config.Dev) 380 if err != nil { 381 log.Println("failed to create unsigned client", err) 382 return ··· 401 return 402 } 403 404 - user := s.auth.GetUser(r) 405 s.pages.RepoBranches(w, pages.RepoBranchesParams{ 406 LoggedInUser: user, 407 RepoInfo: f.RepoInfo(s, user), ··· 411 } 412 413 func (s *State) RepoBlob(w http.ResponseWriter, r *http.Request) { 414 - f, err := fullyResolvedRepo(r) 415 if err != nil { 416 log.Println("failed to get repo and knot", err) 417 return ··· 420 ref := chi.URLParam(r, "ref") 421 filePath := chi.URLParam(r, "*") 422 protocol := "http" 423 - if !s.config.Dev { 424 protocol = "https" 425 } 426 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath)) ··· 443 } 444 445 var breadcrumbs [][]string 446 - breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/%s/tree/%s", f.OwnerDid(), f.RepoName, ref)}) 447 if filePath != "" { 448 for idx, elem := range strings.Split(filePath, "/") { 449 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)}) 450 } 451 } 452 453 - user := s.auth.GetUser(r) 454 s.pages.RepoBlob(w, pages.RepoBlobParams{ 455 LoggedInUser: user, 456 RepoInfo: f.RepoInfo(s, user), 457 RepoBlobResponse: result, 458 BreadCrumbs: breadcrumbs, 459 }) 460 return 461 } 462 463 func (s *State) AddCollaborator(w http.ResponseWriter, r *http.Request) { 464 - f, err := fullyResolvedRepo(r) 465 if err != nil { 466 log.Println("failed to get repo and knot", err) 467 return ··· 488 return 489 } 490 491 - ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev) 492 if err != nil { 493 log.Println("failed to create client to ", f.Knot) 494 return ··· 519 } 520 }() 521 522 - err = s.enforcer.AddCollaborator(collaboratorIdent.DID.String(), f.Knot, f.OwnerSlashRepo()) 523 if err != nil { 524 w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err))) 525 return ··· 549 550 } 551 552 func (s *State) RepoSettings(w http.ResponseWriter, r *http.Request) { 553 - f, err := fullyResolvedRepo(r) 554 if err != nil { 555 log.Println("failed to get repo and knot", err) 556 return ··· 559 switch r.Method { 560 case http.MethodGet: 561 // for now, this is just pubkeys 562 - user := s.auth.GetUser(r) 563 repoCollaborators, err := f.Collaborators(r.Context(), s) 564 if err != nil { 565 log.Println("failed to get collaborators", err) ··· 567 568 isCollaboratorInviteAllowed := false 569 if user != nil { 570 - ok, err := s.enforcer.IsCollaboratorInviteAllowed(user.Did, f.Knot, f.OwnerSlashRepo()) 571 if err == nil && ok { 572 isCollaboratorInviteAllowed = true 573 } 574 } 575 576 s.pages.RepoSettings(w, pages.RepoSettingsParams{ 577 LoggedInUser: user, 578 RepoInfo: f.RepoInfo(s, user), 579 Collaborators: repoCollaborators, 580 IsCollaboratorInviteAllowed: isCollaboratorInviteAllowed, 581 }) 582 } 583 } ··· 588 RepoName string 589 RepoAt syntax.ATURI 590 Description string 591 - AddedAt string 592 } 593 594 func (f *FullyResolvedRepo) OwnerDid() string { ··· 600 } 601 602 func (f *FullyResolvedRepo) OwnerSlashRepo() string { 603 p, _ := securejoin.SecureJoin(f.OwnerDid(), f.RepoName) 604 return p 605 } 606 607 func (f *FullyResolvedRepo) Collaborators(ctx context.Context, s *State) ([]pages.Collaborator, error) { 608 - repoCollaborators, err := s.enforcer.E.GetImplicitUsersForResourceByDomain(f.OwnerSlashRepo(), f.Knot) 609 if err != nil { 610 return nil, err 611 } ··· 648 return collaborators, nil 649 } 650 651 - func (f *FullyResolvedRepo) RepoInfo(s *State, u *auth.User) pages.RepoInfo { 652 isStarred := false 653 if u != nil { 654 isStarred = db.GetStarStatus(s.db, u.Did, syntax.ATURI(f.RepoAt)) ··· 666 if err != nil { 667 log.Println("failed to get issue count for ", f.RepoAt) 668 } 669 670 knot := f.Knot 671 - if knot == "knot1.tangled.sh" { 672 - knot = "tangled.sh" 673 } 674 675 - return pages.RepoInfo{ 676 OwnerDid: f.OwnerDid(), 677 OwnerHandle: f.OwnerHandle(), 678 Name: f.RepoName, 679 RepoAt: f.RepoAt, 680 Description: f.Description, 681 IsStarred: isStarred, 682 Knot: knot, 683 Roles: RolesInRepo(s, u, f), ··· 686 IssueCount: issueCount, 687 PullCount: pullCount, 688 }, 689 } 690 } 691 692 func (s *State) RepoSingleIssue(w http.ResponseWriter, r *http.Request) { 693 - user := s.auth.GetUser(r) 694 - f, err := fullyResolvedRepo(r) 695 if err != nil { 696 log.Println("failed to get repo and knot", err) 697 return ··· 744 } 745 746 func (s *State) CloseIssue(w http.ResponseWriter, r *http.Request) { 747 - user := s.auth.GetUser(r) 748 - f, err := fullyResolvedRepo(r) 749 if err != nil { 750 log.Println("failed to get repo and knot", err) 751 return ··· 780 781 closed := tangled.RepoIssueStateClosed 782 783 - client, _ := s.auth.AuthorizedClient(r) 784 - _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 785 Collection: tangled.RepoIssueStateNSID, 786 Repo: user.Did, 787 - Rkey: s.TID(), 788 Record: &lexutil.LexiconTypeDecoder{ 789 Val: &tangled.RepoIssueState{ 790 Issue: issue.IssueAt, 791 - State: &closed, 792 }, 793 }, 794 }) ··· 799 return 800 } 801 802 - err := db.CloseIssue(s.db, f.RepoAt, issueIdInt) 803 if err != nil { 804 log.Println("failed to close issue", err) 805 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") ··· 816 } 817 818 func (s *State) ReopenIssue(w http.ResponseWriter, r *http.Request) { 819 - user := s.auth.GetUser(r) 820 - f, err := fullyResolvedRepo(r) 821 if err != nil { 822 log.Println("failed to get repo and knot", err) 823 return ··· 863 } 864 } 865 866 - func (s *State) IssueComment(w http.ResponseWriter, r *http.Request) { 867 - user := s.auth.GetUser(r) 868 - f, err := fullyResolvedRepo(r) 869 if err != nil { 870 log.Println("failed to get repo and knot", err) 871 return ··· 887 return 888 } 889 890 - commentId := rand.IntN(1000000) 891 892 - err := db.NewComment(s.db, &db.Comment{ 893 OwnerDid: user.Did, 894 RepoAt: f.RepoAt, 895 Issue: issueIdInt, 896 CommentId: commentId, 897 Body: body, 898 }) 899 if err != nil { 900 log.Println("failed to create comment", err) ··· 913 } 914 915 atUri := f.RepoAt.String() 916 - client, _ := s.auth.AuthorizedClient(r) 917 - _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 918 Collection: tangled.RepoIssueCommentNSID, 919 Repo: user.Did, 920 - Rkey: s.TID(), 921 Record: &lexutil.LexiconTypeDecoder{ 922 Val: &tangled.RepoIssueComment{ 923 Repo: &atUri, 924 Issue: issueAt, 925 CommentId: &commentIdInt64, 926 Owner: &ownerDid, 927 - Body: &body, 928 - CreatedAt: &createdAt, 929 }, 930 }, 931 }) ··· 940 } 941 } 942 943 func (s *State) RepoIssues(w http.ResponseWriter, r *http.Request) { 944 params := r.URL.Query() 945 state := params.Get("state") ··· 953 isOpen = true 954 } 955 956 - user := s.auth.GetUser(r) 957 - f, err := fullyResolvedRepo(r) 958 if err != nil { 959 log.Println("failed to get repo and knot", err) 960 return 961 } 962 963 - issues, err := db.GetIssues(s.db, f.RepoAt, isOpen) 964 if err != nil { 965 log.Println("failed to get issues", err) 966 s.pages.Notice(w, "issues", "Failed to load issues. Try again later.") ··· 982 } 983 984 s.pages.RepoIssues(w, pages.RepoIssuesParams{ 985 - LoggedInUser: s.auth.GetUser(r), 986 RepoInfo: f.RepoInfo(s, user), 987 Issues: issues, 988 DidHandleMap: didHandleMap, 989 FilteringByOpen: isOpen, 990 }) 991 return 992 } 993 994 func (s *State) NewIssue(w http.ResponseWriter, r *http.Request) { 995 - user := s.auth.GetUser(r) 996 997 - f, err := fullyResolvedRepo(r) 998 if err != nil { 999 log.Println("failed to get repo and knot", err) 1000 return ··· 1040 return 1041 } 1042 1043 - client, _ := s.auth.AuthorizedClient(r) 1044 atUri := f.RepoAt.String() 1045 - resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1046 Collection: tangled.RepoIssueNSID, 1047 Repo: user.Did, 1048 - Rkey: s.TID(), 1049 Record: &lexutil.LexiconTypeDecoder{ 1050 Val: &tangled.RepoIssue{ 1051 Repo: atUri, ··· 1073 return 1074 } 1075 }
··· 2 3 import ( 4 "context" 5 + "database/sql" 6 "encoding/json" 7 + "errors" 8 "fmt" 9 "io" 10 "log" 11 + mathrand "math/rand/v2" 12 "net/http" 13 "path" 14 "slices" ··· 16 "strings" 17 "time" 18 19 + "tangled.sh/tangled.sh/core/api/tangled" 20 + "tangled.sh/tangled.sh/core/appview" 21 + "tangled.sh/tangled.sh/core/appview/db" 22 + "tangled.sh/tangled.sh/core/appview/knotclient" 23 + "tangled.sh/tangled.sh/core/appview/oauth" 24 + "tangled.sh/tangled.sh/core/appview/pages" 25 + "tangled.sh/tangled.sh/core/appview/pages/markup" 26 + "tangled.sh/tangled.sh/core/appview/pages/repoinfo" 27 + "tangled.sh/tangled.sh/core/appview/pagination" 28 + "tangled.sh/tangled.sh/core/types" 29 + 30 + "github.com/bluesky-social/indigo/atproto/data" 31 "github.com/bluesky-social/indigo/atproto/identity" 32 "github.com/bluesky-social/indigo/atproto/syntax" 33 securejoin "github.com/cyphar/filepath-securejoin" 34 "github.com/go-chi/chi/v5" 35 + "github.com/go-git/go-git/v5/plumbing" 36 37 comatproto "github.com/bluesky-social/indigo/api/atproto" 38 lexutil "github.com/bluesky-social/indigo/lex/util" ··· 40 41 func (s *State) RepoIndex(w http.ResponseWriter, r *http.Request) { 42 ref := chi.URLParam(r, "ref") 43 + f, err := s.fullyResolvedRepo(r) 44 if err != nil { 45 log.Println("failed to fully resolve repo", err) 46 return 47 } 48 49 + us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 50 if err != nil { 51 log.Printf("failed to create unsigned client for %s", f.Knot) 52 s.pages.Error503(w) ··· 77 tagMap := make(map[string][]string) 78 for _, tag := range result.Tags { 79 hash := tag.Hash 80 + if tag.Tag != nil { 81 + hash = tag.Tag.Target.String() 82 + } 83 tagMap[hash] = append(tagMap[hash], tag.Name) 84 } 85 ··· 88 tagMap[hash] = append(tagMap[hash], branch.Name) 89 } 90 91 + slices.SortFunc(result.Branches, func(a, b types.Branch) int { 92 + if a.Name == result.Ref { 93 + return -1 94 + } 95 + if a.IsDefault { 96 + return -1 97 + } 98 + if b.IsDefault { 99 + return 1 100 + } 101 + if a.Commit != nil { 102 + if a.Commit.Author.When.Before(b.Commit.Author.When) { 103 + return 1 104 + } else { 105 + return -1 106 + } 107 + } 108 + return strings.Compare(a.Name, b.Name) * -1 109 + }) 110 111 + commitCount := len(result.Commits) 112 + branchCount := len(result.Branches) 113 + tagCount := len(result.Tags) 114 + fileCount := len(result.Files) 115 + 116 + commitCount, branchCount, tagCount = balanceIndexItems(commitCount, branchCount, tagCount, fileCount) 117 + commitsTrunc := result.Commits[:min(commitCount, len(result.Commits))] 118 + tagsTrunc := result.Tags[:min(tagCount, len(result.Tags))] 119 + branchesTrunc := result.Branches[:min(branchCount, len(result.Branches))] 120 + 121 + emails := uniqueEmails(commitsTrunc) 122 + 123 + user := s.oauth.GetUser(r) 124 s.pages.RepoIndexPage(w, pages.RepoIndexParams{ 125 LoggedInUser: user, 126 RepoInfo: f.RepoInfo(s, user), 127 TagMap: tagMap, 128 RepoIndexResponse: result, 129 + CommitsTrunc: commitsTrunc, 130 + TagsTrunc: tagsTrunc, 131 + BranchesTrunc: branchesTrunc, 132 EmailToDidOrHandle: EmailToDidOrHandle(s, emails), 133 }) 134 return 135 } 136 137 func (s *State) RepoLog(w http.ResponseWriter, r *http.Request) { 138 + f, err := s.fullyResolvedRepo(r) 139 if err != nil { 140 log.Println("failed to fully resolve repo", err) 141 return ··· 151 152 ref := chi.URLParam(r, "ref") 153 154 + us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 155 + if err != nil { 156 + log.Println("failed to create unsigned client", err) 157 + return 158 } 159 160 + resp, err := us.Log(f.OwnerDid(), f.RepoName, ref, page) 161 if err != nil { 162 log.Println("failed to reach knotserver", err) 163 return ··· 176 return 177 } 178 179 + result, err := us.Tags(f.OwnerDid(), f.RepoName) 180 + if err != nil { 181 + log.Println("failed to reach knotserver", err) 182 + return 183 + } 184 + 185 + tagMap := make(map[string][]string) 186 + for _, tag := range result.Tags { 187 + hash := tag.Hash 188 + if tag.Tag != nil { 189 + hash = tag.Tag.Target.String() 190 + } 191 + tagMap[hash] = append(tagMap[hash], tag.Name) 192 + } 193 + 194 + user := s.oauth.GetUser(r) 195 s.pages.RepoLog(w, pages.RepoLogParams{ 196 LoggedInUser: user, 197 + TagMap: tagMap, 198 RepoInfo: f.RepoInfo(s, user), 199 RepoLogResponse: repolog, 200 EmailToDidOrHandle: EmailToDidOrHandle(s, uniqueEmails(repolog.Commits)), ··· 203 } 204 205 func (s *State) RepoDescriptionEdit(w http.ResponseWriter, r *http.Request) { 206 + f, err := s.fullyResolvedRepo(r) 207 if err != nil { 208 log.Println("failed to get repo and knot", err) 209 w.WriteHeader(http.StatusBadRequest) 210 return 211 } 212 213 + user := s.oauth.GetUser(r) 214 s.pages.EditRepoDescriptionFragment(w, pages.RepoDescriptionParams{ 215 RepoInfo: f.RepoInfo(s, user), 216 }) ··· 218 } 219 220 func (s *State) RepoDescription(w http.ResponseWriter, r *http.Request) { 221 + f, err := s.fullyResolvedRepo(r) 222 if err != nil { 223 log.Println("failed to get repo and knot", err) 224 w.WriteHeader(http.StatusBadRequest) ··· 233 return 234 } 235 236 + user := s.oauth.GetUser(r) 237 238 switch r.Method { 239 case http.MethodGet: ··· 242 }) 243 return 244 case http.MethodPut: 245 + user := s.oauth.GetUser(r) 246 newDescription := r.FormValue("description") 247 + client, err := s.oauth.AuthorizedClient(r) 248 + if err != nil { 249 + log.Println("failed to get client") 250 + s.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 251 + return 252 + } 253 254 // optimistic update 255 err = db.UpdateDescription(s.db, string(repoAt), newDescription) ··· 262 // this is a bit of a pain because the golang atproto impl does not allow nil SwapRecord field 263 // 264 // SwapRecord is optional and should happen automagically, but given that it does not, we have to perform two requests 265 + ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, user.Did, rkey) 266 if err != nil { 267 // failed to get record 268 s.pages.Notice(w, "repo-notice", "Failed to update description, no record found on PDS.") 269 return 270 } 271 + _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 272 Collection: tangled.RepoNSID, 273 Repo: user.Did, 274 Rkey: rkey, ··· 278 Knot: f.Knot, 279 Name: f.RepoName, 280 Owner: user.Did, 281 + CreatedAt: f.CreatedAt, 282 Description: &newDescription, 283 }, 284 }, ··· 302 } 303 304 func (s *State) RepoCommit(w http.ResponseWriter, r *http.Request) { 305 + f, err := s.fullyResolvedRepo(r) 306 if err != nil { 307 log.Println("failed to fully resolve repo", err) 308 return 309 } 310 ref := chi.URLParam(r, "ref") 311 protocol := "http" 312 + if !s.config.Core.Dev { 313 protocol = "https" 314 } 315 + 316 + if !plumbing.IsHash(ref) { 317 + s.pages.Error404(w) 318 + return 319 + } 320 + 321 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/commit/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref)) 322 if err != nil { 323 log.Println("failed to reach knotserver", err) ··· 337 return 338 } 339 340 + user := s.oauth.GetUser(r) 341 s.pages.RepoCommit(w, pages.RepoCommitParams{ 342 LoggedInUser: user, 343 RepoInfo: f.RepoInfo(s, user), ··· 348 } 349 350 func (s *State) RepoTree(w http.ResponseWriter, r *http.Request) { 351 + f, err := s.fullyResolvedRepo(r) 352 if err != nil { 353 log.Println("failed to fully resolve repo", err) 354 return ··· 357 ref := chi.URLParam(r, "ref") 358 treePath := chi.URLParam(r, "*") 359 protocol := "http" 360 + if !s.config.Core.Dev { 361 protocol = "https" 362 } 363 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/tree/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, treePath)) ··· 379 return 380 } 381 382 + // redirects tree paths trying to access a blob; in this case the result.Files is unpopulated, 383 + // so we can safely redirect to the "parent" (which is the same file). 384 + if len(result.Files) == 0 && result.Parent == treePath { 385 + http.Redirect(w, r, fmt.Sprintf("/%s/blob/%s/%s", f.OwnerSlashRepo(), ref, result.Parent), http.StatusFound) 386 + return 387 + } 388 + 389 + user := s.oauth.GetUser(r) 390 391 var breadcrumbs [][]string 392 + breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)}) 393 if treePath != "" { 394 for idx, elem := range strings.Split(treePath, "/") { 395 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)}) 396 } 397 } 398 399 + baseTreeLink := path.Join(f.OwnerSlashRepo(), "tree", ref, treePath) 400 + baseBlobLink := path.Join(f.OwnerSlashRepo(), "blob", ref, treePath) 401 402 s.pages.RepoTree(w, pages.RepoTreeParams{ 403 LoggedInUser: user, ··· 411 } 412 413 func (s *State) RepoTags(w http.ResponseWriter, r *http.Request) { 414 + f, err := s.fullyResolvedRepo(r) 415 if err != nil { 416 log.Println("failed to get repo and knot", err) 417 return 418 } 419 420 + us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 421 + if err != nil { 422 + log.Println("failed to create unsigned client", err) 423 + return 424 } 425 426 + result, err := us.Tags(f.OwnerDid(), f.RepoName) 427 if err != nil { 428 log.Println("failed to reach knotserver", err) 429 return 430 } 431 432 + artifacts, err := db.GetArtifact(s.db, db.Filter("repo_at", f.RepoAt)) 433 if err != nil { 434 + log.Println("failed grab artifacts", err) 435 return 436 } 437 438 + // convert artifacts to map for easy UI building 439 + artifactMap := make(map[plumbing.Hash][]db.Artifact) 440 + for _, a := range artifacts { 441 + artifactMap[a.Tag] = append(artifactMap[a.Tag], a) 442 + } 443 + 444 + var danglingArtifacts []db.Artifact 445 + for _, a := range artifacts { 446 + found := false 447 + for _, t := range result.Tags { 448 + if t.Tag != nil { 449 + if t.Tag.Hash == a.Tag { 450 + found = true 451 + } 452 + } 453 + } 454 + 455 + if !found { 456 + danglingArtifacts = append(danglingArtifacts, a) 457 + } 458 } 459 460 + user := s.oauth.GetUser(r) 461 s.pages.RepoTags(w, pages.RepoTagsParams{ 462 + LoggedInUser: user, 463 + RepoInfo: f.RepoInfo(s, user), 464 + RepoTagsResponse: *result, 465 + ArtifactMap: artifactMap, 466 + DanglingArtifacts: danglingArtifacts, 467 }) 468 return 469 } 470 471 func (s *State) RepoBranches(w http.ResponseWriter, r *http.Request) { 472 + f, err := s.fullyResolvedRepo(r) 473 if err != nil { 474 log.Println("failed to get repo and knot", err) 475 return 476 } 477 478 + us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 479 if err != nil { 480 log.Println("failed to create unsigned client", err) 481 return ··· 500 return 501 } 502 503 + slices.SortFunc(result.Branches, func(a, b types.Branch) int { 504 + if a.IsDefault { 505 + return -1 506 + } 507 + if b.IsDefault { 508 + return 1 509 + } 510 + if a.Commit != nil { 511 + if a.Commit.Author.When.Before(b.Commit.Author.When) { 512 + return 1 513 + } else { 514 + return -1 515 + } 516 + } 517 + return strings.Compare(a.Name, b.Name) * -1 518 + }) 519 + 520 + user := s.oauth.GetUser(r) 521 s.pages.RepoBranches(w, pages.RepoBranchesParams{ 522 LoggedInUser: user, 523 RepoInfo: f.RepoInfo(s, user), ··· 527 } 528 529 func (s *State) RepoBlob(w http.ResponseWriter, r *http.Request) { 530 + f, err := s.fullyResolvedRepo(r) 531 if err != nil { 532 log.Println("failed to get repo and knot", err) 533 return ··· 536 ref := chi.URLParam(r, "ref") 537 filePath := chi.URLParam(r, "*") 538 protocol := "http" 539 + if !s.config.Core.Dev { 540 protocol = "https" 541 } 542 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath)) ··· 559 } 560 561 var breadcrumbs [][]string 562 + breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)}) 563 if filePath != "" { 564 for idx, elem := range strings.Split(filePath, "/") { 565 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)}) 566 } 567 } 568 569 + showRendered := false 570 + renderToggle := false 571 + 572 + if markup.GetFormat(result.Path) == markup.FormatMarkdown { 573 + renderToggle = true 574 + showRendered = r.URL.Query().Get("code") != "true" 575 + } 576 + 577 + user := s.oauth.GetUser(r) 578 s.pages.RepoBlob(w, pages.RepoBlobParams{ 579 LoggedInUser: user, 580 RepoInfo: f.RepoInfo(s, user), 581 RepoBlobResponse: result, 582 BreadCrumbs: breadcrumbs, 583 + ShowRendered: showRendered, 584 + RenderToggle: renderToggle, 585 }) 586 return 587 } 588 589 + func (s *State) RepoBlobRaw(w http.ResponseWriter, r *http.Request) { 590 + f, err := s.fullyResolvedRepo(r) 591 + if err != nil { 592 + log.Println("failed to get repo and knot", err) 593 + return 594 + } 595 + 596 + ref := chi.URLParam(r, "ref") 597 + filePath := chi.URLParam(r, "*") 598 + 599 + protocol := "http" 600 + if !s.config.Core.Dev { 601 + protocol = "https" 602 + } 603 + resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath)) 604 + if err != nil { 605 + log.Println("failed to reach knotserver", err) 606 + return 607 + } 608 + 609 + body, err := io.ReadAll(resp.Body) 610 + if err != nil { 611 + log.Printf("Error reading response body: %v", err) 612 + return 613 + } 614 + 615 + var result types.RepoBlobResponse 616 + err = json.Unmarshal(body, &result) 617 + if err != nil { 618 + log.Println("failed to parse response:", err) 619 + return 620 + } 621 + 622 + if result.IsBinary { 623 + w.Header().Set("Content-Type", "application/octet-stream") 624 + w.Write(body) 625 + return 626 + } 627 + 628 + w.Header().Set("Content-Type", "text/plain") 629 + w.Write([]byte(result.Contents)) 630 + return 631 + } 632 + 633 func (s *State) AddCollaborator(w http.ResponseWriter, r *http.Request) { 634 + f, err := s.fullyResolvedRepo(r) 635 if err != nil { 636 log.Println("failed to get repo and knot", err) 637 return ··· 658 return 659 } 660 661 + ksClient, err := knotclient.NewSignedClient(f.Knot, secret, s.config.Core.Dev) 662 if err != nil { 663 log.Println("failed to create client to ", f.Knot) 664 return ··· 689 } 690 }() 691 692 + err = s.enforcer.AddCollaborator(collaboratorIdent.DID.String(), f.Knot, f.DidSlashRepo()) 693 if err != nil { 694 w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err))) 695 return ··· 719 720 } 721 722 + func (s *State) DeleteRepo(w http.ResponseWriter, r *http.Request) { 723 + user := s.oauth.GetUser(r) 724 + 725 + f, err := s.fullyResolvedRepo(r) 726 + if err != nil { 727 + log.Println("failed to get repo and knot", err) 728 + return 729 + } 730 + 731 + // remove record from pds 732 + xrpcClient, err := s.oauth.AuthorizedClient(r) 733 + if err != nil { 734 + log.Println("failed to get authorized client", err) 735 + return 736 + } 737 + repoRkey := f.RepoAt.RecordKey().String() 738 + _, err = xrpcClient.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 739 + Collection: tangled.RepoNSID, 740 + Repo: user.Did, 741 + Rkey: repoRkey, 742 + }) 743 + if err != nil { 744 + log.Printf("failed to delete record: %s", err) 745 + s.pages.Notice(w, "settings-delete", "Failed to delete repository from PDS.") 746 + return 747 + } 748 + log.Println("removed repo record ", f.RepoAt.String()) 749 + 750 + secret, err := db.GetRegistrationKey(s.db, f.Knot) 751 + if err != nil { 752 + log.Printf("no key found for domain %s: %s\n", f.Knot, err) 753 + return 754 + } 755 + 756 + ksClient, err := knotclient.NewSignedClient(f.Knot, secret, s.config.Core.Dev) 757 + if err != nil { 758 + log.Println("failed to create client to ", f.Knot) 759 + return 760 + } 761 + 762 + ksResp, err := ksClient.RemoveRepo(f.OwnerDid(), f.RepoName) 763 + if err != nil { 764 + log.Printf("failed to make request to %s: %s", f.Knot, err) 765 + return 766 + } 767 + 768 + if ksResp.StatusCode != http.StatusNoContent { 769 + log.Println("failed to remove repo from knot, continuing anyway ", f.Knot) 770 + } else { 771 + log.Println("removed repo from knot ", f.Knot) 772 + } 773 + 774 + tx, err := s.db.BeginTx(r.Context(), nil) 775 + if err != nil { 776 + log.Println("failed to start tx") 777 + w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err))) 778 + return 779 + } 780 + defer func() { 781 + tx.Rollback() 782 + err = s.enforcer.E.LoadPolicy() 783 + if err != nil { 784 + log.Println("failed to rollback policies") 785 + } 786 + }() 787 + 788 + // remove collaborator RBAC 789 + repoCollaborators, err := s.enforcer.E.GetImplicitUsersForResourceByDomain(f.DidSlashRepo(), f.Knot) 790 + if err != nil { 791 + s.pages.Notice(w, "settings-delete", "Failed to remove collaborators") 792 + return 793 + } 794 + for _, c := range repoCollaborators { 795 + did := c[0] 796 + s.enforcer.RemoveCollaborator(did, f.Knot, f.DidSlashRepo()) 797 + } 798 + log.Println("removed collaborators") 799 + 800 + // remove repo RBAC 801 + err = s.enforcer.RemoveRepo(f.OwnerDid(), f.Knot, f.DidSlashRepo()) 802 + if err != nil { 803 + s.pages.Notice(w, "settings-delete", "Failed to update RBAC rules") 804 + return 805 + } 806 + 807 + // remove repo from db 808 + err = db.RemoveRepo(tx, f.OwnerDid(), f.RepoName) 809 + if err != nil { 810 + s.pages.Notice(w, "settings-delete", "Failed to update appview") 811 + return 812 + } 813 + log.Println("removed repo from db") 814 + 815 + err = tx.Commit() 816 + if err != nil { 817 + log.Println("failed to commit changes", err) 818 + http.Error(w, err.Error(), http.StatusInternalServerError) 819 + return 820 + } 821 + 822 + err = s.enforcer.E.SavePolicy() 823 + if err != nil { 824 + log.Println("failed to update ACLs", err) 825 + http.Error(w, err.Error(), http.StatusInternalServerError) 826 + return 827 + } 828 + 829 + s.pages.HxRedirect(w, fmt.Sprintf("/%s", f.OwnerDid())) 830 + } 831 + 832 + func (s *State) SetDefaultBranch(w http.ResponseWriter, r *http.Request) { 833 + f, err := s.fullyResolvedRepo(r) 834 + if err != nil { 835 + log.Println("failed to get repo and knot", err) 836 + return 837 + } 838 + 839 + branch := r.FormValue("branch") 840 + if branch == "" { 841 + http.Error(w, "malformed form", http.StatusBadRequest) 842 + return 843 + } 844 + 845 + secret, err := db.GetRegistrationKey(s.db, f.Knot) 846 + if err != nil { 847 + log.Printf("no key found for domain %s: %s\n", f.Knot, err) 848 + return 849 + } 850 + 851 + ksClient, err := knotclient.NewSignedClient(f.Knot, secret, s.config.Core.Dev) 852 + if err != nil { 853 + log.Println("failed to create client to ", f.Knot) 854 + return 855 + } 856 + 857 + ksResp, err := ksClient.SetDefaultBranch(f.OwnerDid(), f.RepoName, branch) 858 + if err != nil { 859 + log.Printf("failed to make request to %s: %s", f.Knot, err) 860 + return 861 + } 862 + 863 + if ksResp.StatusCode != http.StatusNoContent { 864 + s.pages.Notice(w, "repo-settings", "Failed to set default branch. Try again later.") 865 + return 866 + } 867 + 868 + w.Write([]byte(fmt.Sprint("default branch set to: ", branch))) 869 + } 870 + 871 func (s *State) RepoSettings(w http.ResponseWriter, r *http.Request) { 872 + f, err := s.fullyResolvedRepo(r) 873 if err != nil { 874 log.Println("failed to get repo and knot", err) 875 return ··· 878 switch r.Method { 879 case http.MethodGet: 880 // for now, this is just pubkeys 881 + user := s.oauth.GetUser(r) 882 repoCollaborators, err := f.Collaborators(r.Context(), s) 883 if err != nil { 884 log.Println("failed to get collaborators", err) ··· 886 887 isCollaboratorInviteAllowed := false 888 if user != nil { 889 + ok, err := s.enforcer.IsCollaboratorInviteAllowed(user.Did, f.Knot, f.DidSlashRepo()) 890 if err == nil && ok { 891 isCollaboratorInviteAllowed = true 892 } 893 } 894 895 + var branchNames []string 896 + var defaultBranch string 897 + us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 898 + if err != nil { 899 + log.Println("failed to create unsigned client", err) 900 + } else { 901 + resp, err := us.Branches(f.OwnerDid(), f.RepoName) 902 + if err != nil { 903 + log.Println("failed to reach knotserver", err) 904 + } else { 905 + defer resp.Body.Close() 906 + 907 + body, err := io.ReadAll(resp.Body) 908 + if err != nil { 909 + log.Printf("Error reading response body: %v", err) 910 + } else { 911 + var result types.RepoBranchesResponse 912 + err = json.Unmarshal(body, &result) 913 + if err != nil { 914 + log.Println("failed to parse response:", err) 915 + } else { 916 + for _, branch := range result.Branches { 917 + branchNames = append(branchNames, branch.Name) 918 + } 919 + } 920 + } 921 + } 922 + 923 + defaultBranchResp, err := us.DefaultBranch(f.OwnerDid(), f.RepoName) 924 + if err != nil { 925 + log.Println("failed to reach knotserver", err) 926 + } else { 927 + defaultBranch = defaultBranchResp.Branch 928 + } 929 + } 930 s.pages.RepoSettings(w, pages.RepoSettingsParams{ 931 LoggedInUser: user, 932 RepoInfo: f.RepoInfo(s, user), 933 Collaborators: repoCollaborators, 934 IsCollaboratorInviteAllowed: isCollaboratorInviteAllowed, 935 + Branches: branchNames, 936 + DefaultBranch: defaultBranch, 937 }) 938 } 939 } ··· 944 RepoName string 945 RepoAt syntax.ATURI 946 Description string 947 + CreatedAt string 948 + Ref string 949 } 950 951 func (f *FullyResolvedRepo) OwnerDid() string { ··· 957 } 958 959 func (f *FullyResolvedRepo) OwnerSlashRepo() string { 960 + handle := f.OwnerId.Handle 961 + 962 + var p string 963 + if handle != "" && !handle.IsInvalidHandle() { 964 + p, _ = securejoin.SecureJoin(fmt.Sprintf("@%s", handle), f.RepoName) 965 + } else { 966 + p, _ = securejoin.SecureJoin(f.OwnerDid(), f.RepoName) 967 + } 968 + 969 + return p 970 + } 971 + 972 + func (f *FullyResolvedRepo) DidSlashRepo() string { 973 p, _ := securejoin.SecureJoin(f.OwnerDid(), f.RepoName) 974 return p 975 } 976 977 func (f *FullyResolvedRepo) Collaborators(ctx context.Context, s *State) ([]pages.Collaborator, error) { 978 + repoCollaborators, err := s.enforcer.E.GetImplicitUsersForResourceByDomain(f.DidSlashRepo(), f.Knot) 979 if err != nil { 980 return nil, err 981 } ··· 1018 return collaborators, nil 1019 } 1020 1021 + func (f *FullyResolvedRepo) RepoInfo(s *State, u *oauth.User) repoinfo.RepoInfo { 1022 isStarred := false 1023 if u != nil { 1024 isStarred = db.GetStarStatus(s.db, u.Did, syntax.ATURI(f.RepoAt)) ··· 1036 if err != nil { 1037 log.Println("failed to get issue count for ", f.RepoAt) 1038 } 1039 + source, err := db.GetRepoSource(s.db, f.RepoAt) 1040 + if errors.Is(err, sql.ErrNoRows) { 1041 + source = "" 1042 + } else if err != nil { 1043 + log.Println("failed to get repo source for ", f.RepoAt, err) 1044 + } 1045 + 1046 + var sourceRepo *db.Repo 1047 + if source != "" { 1048 + sourceRepo, err = db.GetRepoByAtUri(s.db, source) 1049 + if err != nil { 1050 + log.Println("failed to get repo by at uri", err) 1051 + } 1052 + } 1053 + 1054 + var sourceHandle *identity.Identity 1055 + if sourceRepo != nil { 1056 + sourceHandle, err = s.resolver.ResolveIdent(context.Background(), sourceRepo.Did) 1057 + if err != nil { 1058 + log.Println("failed to resolve source repo", err) 1059 + } 1060 + } 1061 1062 knot := f.Knot 1063 + var disableFork bool 1064 + us, err := knotclient.NewUnsignedClient(knot, s.config.Core.Dev) 1065 + if err != nil { 1066 + log.Printf("failed to create unsigned client for %s: %v", knot, err) 1067 + } else { 1068 + resp, err := us.Branches(f.OwnerDid(), f.RepoName) 1069 + if err != nil { 1070 + log.Printf("failed to get branches for %s/%s: %v", f.OwnerDid(), f.RepoName, err) 1071 + } else { 1072 + defer resp.Body.Close() 1073 + body, err := io.ReadAll(resp.Body) 1074 + if err != nil { 1075 + log.Printf("error reading branch response body: %v", err) 1076 + } else { 1077 + var branchesResp types.RepoBranchesResponse 1078 + if err := json.Unmarshal(body, &branchesResp); err != nil { 1079 + log.Printf("error parsing branch response: %v", err) 1080 + } else { 1081 + disableFork = false 1082 + } 1083 + 1084 + if len(branchesResp.Branches) == 0 { 1085 + disableFork = true 1086 + } 1087 + } 1088 + } 1089 } 1090 1091 + repoInfo := repoinfo.RepoInfo{ 1092 OwnerDid: f.OwnerDid(), 1093 OwnerHandle: f.OwnerHandle(), 1094 Name: f.RepoName, 1095 RepoAt: f.RepoAt, 1096 Description: f.Description, 1097 + Ref: f.Ref, 1098 IsStarred: isStarred, 1099 Knot: knot, 1100 Roles: RolesInRepo(s, u, f), ··· 1103 IssueCount: issueCount, 1104 PullCount: pullCount, 1105 }, 1106 + DisableFork: disableFork, 1107 } 1108 + 1109 + if sourceRepo != nil { 1110 + repoInfo.Source = sourceRepo 1111 + repoInfo.SourceHandle = sourceHandle.Handle.String() 1112 + } 1113 + 1114 + return repoInfo 1115 } 1116 1117 func (s *State) RepoSingleIssue(w http.ResponseWriter, r *http.Request) { 1118 + user := s.oauth.GetUser(r) 1119 + f, err := s.fullyResolvedRepo(r) 1120 if err != nil { 1121 log.Println("failed to get repo and knot", err) 1122 return ··· 1169 } 1170 1171 func (s *State) CloseIssue(w http.ResponseWriter, r *http.Request) { 1172 + user := s.oauth.GetUser(r) 1173 + f, err := s.fullyResolvedRepo(r) 1174 if err != nil { 1175 log.Println("failed to get repo and knot", err) 1176 return ··· 1205 1206 closed := tangled.RepoIssueStateClosed 1207 1208 + client, err := s.oauth.AuthorizedClient(r) 1209 + if err != nil { 1210 + log.Println("failed to get authorized client", err) 1211 + return 1212 + } 1213 + _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1214 Collection: tangled.RepoIssueStateNSID, 1215 Repo: user.Did, 1216 + Rkey: appview.TID(), 1217 Record: &lexutil.LexiconTypeDecoder{ 1218 Val: &tangled.RepoIssueState{ 1219 Issue: issue.IssueAt, 1220 + State: closed, 1221 }, 1222 }, 1223 }) ··· 1228 return 1229 } 1230 1231 + err = db.CloseIssue(s.db, f.RepoAt, issueIdInt) 1232 if err != nil { 1233 log.Println("failed to close issue", err) 1234 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") ··· 1245 } 1246 1247 func (s *State) ReopenIssue(w http.ResponseWriter, r *http.Request) { 1248 + user := s.oauth.GetUser(r) 1249 + f, err := s.fullyResolvedRepo(r) 1250 if err != nil { 1251 log.Println("failed to get repo and knot", err) 1252 return ··· 1292 } 1293 } 1294 1295 + func (s *State) NewIssueComment(w http.ResponseWriter, r *http.Request) { 1296 + user := s.oauth.GetUser(r) 1297 + f, err := s.fullyResolvedRepo(r) 1298 if err != nil { 1299 log.Println("failed to get repo and knot", err) 1300 return ··· 1316 return 1317 } 1318 1319 + commentId := mathrand.IntN(1000000) 1320 + rkey := appview.TID() 1321 1322 + err := db.NewIssueComment(s.db, &db.Comment{ 1323 OwnerDid: user.Did, 1324 RepoAt: f.RepoAt, 1325 Issue: issueIdInt, 1326 CommentId: commentId, 1327 Body: body, 1328 + Rkey: rkey, 1329 }) 1330 if err != nil { 1331 log.Println("failed to create comment", err) ··· 1344 } 1345 1346 atUri := f.RepoAt.String() 1347 + client, err := s.oauth.AuthorizedClient(r) 1348 + if err != nil { 1349 + log.Println("failed to get authorized client", err) 1350 + s.pages.Notice(w, "issue-comment", "Failed to create comment.") 1351 + return 1352 + } 1353 + _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1354 Collection: tangled.RepoIssueCommentNSID, 1355 Repo: user.Did, 1356 + Rkey: rkey, 1357 Record: &lexutil.LexiconTypeDecoder{ 1358 Val: &tangled.RepoIssueComment{ 1359 Repo: &atUri, 1360 Issue: issueAt, 1361 CommentId: &commentIdInt64, 1362 Owner: &ownerDid, 1363 + Body: body, 1364 + CreatedAt: createdAt, 1365 }, 1366 }, 1367 }) ··· 1376 } 1377 } 1378 1379 + func (s *State) IssueComment(w http.ResponseWriter, r *http.Request) { 1380 + user := s.oauth.GetUser(r) 1381 + f, err := s.fullyResolvedRepo(r) 1382 + if err != nil { 1383 + log.Println("failed to get repo and knot", err) 1384 + return 1385 + } 1386 + 1387 + issueId := chi.URLParam(r, "issue") 1388 + issueIdInt, err := strconv.Atoi(issueId) 1389 + if err != nil { 1390 + http.Error(w, "bad issue id", http.StatusBadRequest) 1391 + log.Println("failed to parse issue id", err) 1392 + return 1393 + } 1394 + 1395 + commentId := chi.URLParam(r, "comment_id") 1396 + commentIdInt, err := strconv.Atoi(commentId) 1397 + if err != nil { 1398 + http.Error(w, "bad comment id", http.StatusBadRequest) 1399 + log.Println("failed to parse issue id", err) 1400 + return 1401 + } 1402 + 1403 + issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt) 1404 + if err != nil { 1405 + log.Println("failed to get issue", err) 1406 + s.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 1407 + return 1408 + } 1409 + 1410 + comment, err := db.GetComment(s.db, f.RepoAt, issueIdInt, commentIdInt) 1411 + if err != nil { 1412 + http.Error(w, "bad comment id", http.StatusBadRequest) 1413 + return 1414 + } 1415 + 1416 + identity, err := s.resolver.ResolveIdent(r.Context(), comment.OwnerDid) 1417 + if err != nil { 1418 + log.Println("failed to resolve did") 1419 + return 1420 + } 1421 + 1422 + didHandleMap := make(map[string]string) 1423 + if !identity.Handle.IsInvalidHandle() { 1424 + didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 1425 + } else { 1426 + didHandleMap[identity.DID.String()] = identity.DID.String() 1427 + } 1428 + 1429 + s.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ 1430 + LoggedInUser: user, 1431 + RepoInfo: f.RepoInfo(s, user), 1432 + DidHandleMap: didHandleMap, 1433 + Issue: issue, 1434 + Comment: comment, 1435 + }) 1436 + } 1437 + 1438 + func (s *State) EditIssueComment(w http.ResponseWriter, r *http.Request) { 1439 + user := s.oauth.GetUser(r) 1440 + f, err := s.fullyResolvedRepo(r) 1441 + if err != nil { 1442 + log.Println("failed to get repo and knot", err) 1443 + return 1444 + } 1445 + 1446 + issueId := chi.URLParam(r, "issue") 1447 + issueIdInt, err := strconv.Atoi(issueId) 1448 + if err != nil { 1449 + http.Error(w, "bad issue id", http.StatusBadRequest) 1450 + log.Println("failed to parse issue id", err) 1451 + return 1452 + } 1453 + 1454 + commentId := chi.URLParam(r, "comment_id") 1455 + commentIdInt, err := strconv.Atoi(commentId) 1456 + if err != nil { 1457 + http.Error(w, "bad comment id", http.StatusBadRequest) 1458 + log.Println("failed to parse issue id", err) 1459 + return 1460 + } 1461 + 1462 + issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt) 1463 + if err != nil { 1464 + log.Println("failed to get issue", err) 1465 + s.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 1466 + return 1467 + } 1468 + 1469 + comment, err := db.GetComment(s.db, f.RepoAt, issueIdInt, commentIdInt) 1470 + if err != nil { 1471 + http.Error(w, "bad comment id", http.StatusBadRequest) 1472 + return 1473 + } 1474 + 1475 + if comment.OwnerDid != user.Did { 1476 + http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 1477 + return 1478 + } 1479 + 1480 + switch r.Method { 1481 + case http.MethodGet: 1482 + s.pages.EditIssueCommentFragment(w, pages.EditIssueCommentParams{ 1483 + LoggedInUser: user, 1484 + RepoInfo: f.RepoInfo(s, user), 1485 + Issue: issue, 1486 + Comment: comment, 1487 + }) 1488 + case http.MethodPost: 1489 + // extract form value 1490 + newBody := r.FormValue("body") 1491 + client, err := s.oauth.AuthorizedClient(r) 1492 + if err != nil { 1493 + log.Println("failed to get authorized client", err) 1494 + s.pages.Notice(w, "issue-comment", "Failed to create comment.") 1495 + return 1496 + } 1497 + rkey := comment.Rkey 1498 + 1499 + // optimistic update 1500 + edited := time.Now() 1501 + err = db.EditComment(s.db, comment.RepoAt, comment.Issue, comment.CommentId, newBody) 1502 + if err != nil { 1503 + log.Println("failed to perferom update-description query", err) 1504 + s.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 1505 + return 1506 + } 1507 + 1508 + // rkey is optional, it was introduced later 1509 + if comment.Rkey != "" { 1510 + // update the record on pds 1511 + ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueCommentNSID, user.Did, rkey) 1512 + if err != nil { 1513 + // failed to get record 1514 + log.Println(err, rkey) 1515 + s.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.") 1516 + return 1517 + } 1518 + value, _ := ex.Value.MarshalJSON() // we just did get record; it is valid json 1519 + record, _ := data.UnmarshalJSON(value) 1520 + 1521 + repoAt := record["repo"].(string) 1522 + issueAt := record["issue"].(string) 1523 + createdAt := record["createdAt"].(string) 1524 + commentIdInt64 := int64(commentIdInt) 1525 + 1526 + _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1527 + Collection: tangled.RepoIssueCommentNSID, 1528 + Repo: user.Did, 1529 + Rkey: rkey, 1530 + SwapRecord: ex.Cid, 1531 + Record: &lexutil.LexiconTypeDecoder{ 1532 + Val: &tangled.RepoIssueComment{ 1533 + Repo: &repoAt, 1534 + Issue: issueAt, 1535 + CommentId: &commentIdInt64, 1536 + Owner: &comment.OwnerDid, 1537 + Body: newBody, 1538 + CreatedAt: createdAt, 1539 + }, 1540 + }, 1541 + }) 1542 + if err != nil { 1543 + log.Println(err) 1544 + } 1545 + } 1546 + 1547 + // optimistic update for htmx 1548 + didHandleMap := map[string]string{ 1549 + user.Did: user.Handle, 1550 + } 1551 + comment.Body = newBody 1552 + comment.Edited = &edited 1553 + 1554 + // return new comment body with htmx 1555 + s.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ 1556 + LoggedInUser: user, 1557 + RepoInfo: f.RepoInfo(s, user), 1558 + DidHandleMap: didHandleMap, 1559 + Issue: issue, 1560 + Comment: comment, 1561 + }) 1562 + return 1563 + 1564 + } 1565 + 1566 + } 1567 + 1568 + func (s *State) DeleteIssueComment(w http.ResponseWriter, r *http.Request) { 1569 + user := s.oauth.GetUser(r) 1570 + f, err := s.fullyResolvedRepo(r) 1571 + if err != nil { 1572 + log.Println("failed to get repo and knot", err) 1573 + return 1574 + } 1575 + 1576 + issueId := chi.URLParam(r, "issue") 1577 + issueIdInt, err := strconv.Atoi(issueId) 1578 + if err != nil { 1579 + http.Error(w, "bad issue id", http.StatusBadRequest) 1580 + log.Println("failed to parse issue id", err) 1581 + return 1582 + } 1583 + 1584 + issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt) 1585 + if err != nil { 1586 + log.Println("failed to get issue", err) 1587 + s.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 1588 + return 1589 + } 1590 + 1591 + commentId := chi.URLParam(r, "comment_id") 1592 + commentIdInt, err := strconv.Atoi(commentId) 1593 + if err != nil { 1594 + http.Error(w, "bad comment id", http.StatusBadRequest) 1595 + log.Println("failed to parse issue id", err) 1596 + return 1597 + } 1598 + 1599 + comment, err := db.GetComment(s.db, f.RepoAt, issueIdInt, commentIdInt) 1600 + if err != nil { 1601 + http.Error(w, "bad comment id", http.StatusBadRequest) 1602 + return 1603 + } 1604 + 1605 + if comment.OwnerDid != user.Did { 1606 + http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 1607 + return 1608 + } 1609 + 1610 + if comment.Deleted != nil { 1611 + http.Error(w, "comment already deleted", http.StatusBadRequest) 1612 + return 1613 + } 1614 + 1615 + // optimistic deletion 1616 + deleted := time.Now() 1617 + err = db.DeleteComment(s.db, f.RepoAt, issueIdInt, commentIdInt) 1618 + if err != nil { 1619 + log.Println("failed to delete comment") 1620 + s.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment") 1621 + return 1622 + } 1623 + 1624 + // delete from pds 1625 + if comment.Rkey != "" { 1626 + client, err := s.oauth.AuthorizedClient(r) 1627 + if err != nil { 1628 + log.Println("failed to get authorized client", err) 1629 + s.pages.Notice(w, "issue-comment", "Failed to delete comment.") 1630 + return 1631 + } 1632 + _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 1633 + Collection: tangled.GraphFollowNSID, 1634 + Repo: user.Did, 1635 + Rkey: comment.Rkey, 1636 + }) 1637 + if err != nil { 1638 + log.Println(err) 1639 + } 1640 + } 1641 + 1642 + // optimistic update for htmx 1643 + didHandleMap := map[string]string{ 1644 + user.Did: user.Handle, 1645 + } 1646 + comment.Body = "" 1647 + comment.Deleted = &deleted 1648 + 1649 + // htmx fragment of comment after deletion 1650 + s.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ 1651 + LoggedInUser: user, 1652 + RepoInfo: f.RepoInfo(s, user), 1653 + DidHandleMap: didHandleMap, 1654 + Issue: issue, 1655 + Comment: comment, 1656 + }) 1657 + return 1658 + } 1659 + 1660 func (s *State) RepoIssues(w http.ResponseWriter, r *http.Request) { 1661 params := r.URL.Query() 1662 state := params.Get("state") ··· 1670 isOpen = true 1671 } 1672 1673 + page, ok := r.Context().Value("page").(pagination.Page) 1674 + if !ok { 1675 + log.Println("failed to get page") 1676 + page = pagination.FirstPage() 1677 + } 1678 + 1679 + user := s.oauth.GetUser(r) 1680 + f, err := s.fullyResolvedRepo(r) 1681 if err != nil { 1682 log.Println("failed to get repo and knot", err) 1683 return 1684 } 1685 1686 + issues, err := db.GetIssues(s.db, f.RepoAt, isOpen, page) 1687 if err != nil { 1688 log.Println("failed to get issues", err) 1689 s.pages.Notice(w, "issues", "Failed to load issues. Try again later.") ··· 1705 } 1706 1707 s.pages.RepoIssues(w, pages.RepoIssuesParams{ 1708 + LoggedInUser: s.oauth.GetUser(r), 1709 RepoInfo: f.RepoInfo(s, user), 1710 Issues: issues, 1711 DidHandleMap: didHandleMap, 1712 FilteringByOpen: isOpen, 1713 + Page: page, 1714 }) 1715 return 1716 } 1717 1718 func (s *State) NewIssue(w http.ResponseWriter, r *http.Request) { 1719 + user := s.oauth.GetUser(r) 1720 1721 + f, err := s.fullyResolvedRepo(r) 1722 if err != nil { 1723 log.Println("failed to get repo and knot", err) 1724 return ··· 1764 return 1765 } 1766 1767 + client, err := s.oauth.AuthorizedClient(r) 1768 + if err != nil { 1769 + log.Println("failed to get authorized client", err) 1770 + s.pages.Notice(w, "issues", "Failed to create issue.") 1771 + return 1772 + } 1773 atUri := f.RepoAt.String() 1774 + resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1775 Collection: tangled.RepoIssueNSID, 1776 Repo: user.Did, 1777 + Rkey: appview.TID(), 1778 Record: &lexutil.LexiconTypeDecoder{ 1779 Val: &tangled.RepoIssue{ 1780 Repo: atUri, ··· 1802 return 1803 } 1804 } 1805 + 1806 + func (s *State) ForkRepo(w http.ResponseWriter, r *http.Request) { 1807 + user := s.oauth.GetUser(r) 1808 + f, err := s.fullyResolvedRepo(r) 1809 + if err != nil { 1810 + log.Printf("failed to resolve source repo: %v", err) 1811 + return 1812 + } 1813 + 1814 + switch r.Method { 1815 + case http.MethodGet: 1816 + user := s.oauth.GetUser(r) 1817 + knots, err := s.enforcer.GetDomainsForUser(user.Did) 1818 + if err != nil { 1819 + s.pages.Notice(w, "repo", "Invalid user account.") 1820 + return 1821 + } 1822 + 1823 + s.pages.ForkRepo(w, pages.ForkRepoParams{ 1824 + LoggedInUser: user, 1825 + Knots: knots, 1826 + RepoInfo: f.RepoInfo(s, user), 1827 + }) 1828 + 1829 + case http.MethodPost: 1830 + 1831 + knot := r.FormValue("knot") 1832 + if knot == "" { 1833 + s.pages.Notice(w, "repo", "Invalid form submission&mdash;missing knot domain.") 1834 + return 1835 + } 1836 + 1837 + ok, err := s.enforcer.E.Enforce(user.Did, knot, knot, "repo:create") 1838 + if err != nil || !ok { 1839 + s.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.") 1840 + return 1841 + } 1842 + 1843 + forkName := fmt.Sprintf("%s", f.RepoName) 1844 + 1845 + // this check is *only* to see if the forked repo name already exists 1846 + // in the user's account. 1847 + existingRepo, err := db.GetRepo(s.db, user.Did, f.RepoName) 1848 + if err != nil { 1849 + if errors.Is(err, sql.ErrNoRows) { 1850 + // no existing repo with this name found, we can use the name as is 1851 + } else { 1852 + log.Println("error fetching existing repo from db", err) 1853 + s.pages.Notice(w, "repo", "Failed to fork this repository. Try again later.") 1854 + return 1855 + } 1856 + } else if existingRepo != nil { 1857 + // repo with this name already exists, append random string 1858 + forkName = fmt.Sprintf("%s-%s", forkName, randomString(3)) 1859 + } 1860 + secret, err := db.GetRegistrationKey(s.db, knot) 1861 + if err != nil { 1862 + s.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", knot)) 1863 + return 1864 + } 1865 + 1866 + client, err := knotclient.NewSignedClient(knot, secret, s.config.Core.Dev) 1867 + if err != nil { 1868 + s.pages.Notice(w, "repo", "Failed to reach knot server.") 1869 + return 1870 + } 1871 + 1872 + var uri string 1873 + if s.config.Core.Dev { 1874 + uri = "http" 1875 + } else { 1876 + uri = "https" 1877 + } 1878 + forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.RepoName) 1879 + sourceAt := f.RepoAt.String() 1880 + 1881 + rkey := appview.TID() 1882 + repo := &db.Repo{ 1883 + Did: user.Did, 1884 + Name: forkName, 1885 + Knot: knot, 1886 + Rkey: rkey, 1887 + Source: sourceAt, 1888 + } 1889 + 1890 + tx, err := s.db.BeginTx(r.Context(), nil) 1891 + if err != nil { 1892 + log.Println(err) 1893 + s.pages.Notice(w, "repo", "Failed to save repository information.") 1894 + return 1895 + } 1896 + defer func() { 1897 + tx.Rollback() 1898 + err = s.enforcer.E.LoadPolicy() 1899 + if err != nil { 1900 + log.Println("failed to rollback policies") 1901 + } 1902 + }() 1903 + 1904 + resp, err := client.ForkRepo(user.Did, forkSourceUrl, forkName) 1905 + if err != nil { 1906 + s.pages.Notice(w, "repo", "Failed to create repository on knot server.") 1907 + return 1908 + } 1909 + 1910 + switch resp.StatusCode { 1911 + case http.StatusConflict: 1912 + s.pages.Notice(w, "repo", "A repository with that name already exists.") 1913 + return 1914 + case http.StatusInternalServerError: 1915 + s.pages.Notice(w, "repo", "Failed to create repository on knot. Try again later.") 1916 + case http.StatusNoContent: 1917 + // continue 1918 + } 1919 + 1920 + xrpcClient, err := s.oauth.AuthorizedClient(r) 1921 + if err != nil { 1922 + log.Println("failed to get authorized client", err) 1923 + s.pages.Notice(w, "repo", "Failed to create repository.") 1924 + return 1925 + } 1926 + 1927 + createdAt := time.Now().Format(time.RFC3339) 1928 + atresp, err := xrpcClient.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1929 + Collection: tangled.RepoNSID, 1930 + Repo: user.Did, 1931 + Rkey: rkey, 1932 + Record: &lexutil.LexiconTypeDecoder{ 1933 + Val: &tangled.Repo{ 1934 + Knot: repo.Knot, 1935 + Name: repo.Name, 1936 + CreatedAt: createdAt, 1937 + Owner: user.Did, 1938 + Source: &sourceAt, 1939 + }}, 1940 + }) 1941 + if err != nil { 1942 + log.Printf("failed to create record: %s", err) 1943 + s.pages.Notice(w, "repo", "Failed to announce repository creation.") 1944 + return 1945 + } 1946 + log.Println("created repo record: ", atresp.Uri) 1947 + 1948 + repo.AtUri = atresp.Uri 1949 + err = db.AddRepo(tx, repo) 1950 + if err != nil { 1951 + log.Println(err) 1952 + s.pages.Notice(w, "repo", "Failed to save repository information.") 1953 + return 1954 + } 1955 + 1956 + // acls 1957 + p, _ := securejoin.SecureJoin(user.Did, forkName) 1958 + err = s.enforcer.AddRepo(user.Did, knot, p) 1959 + if err != nil { 1960 + log.Println(err) 1961 + s.pages.Notice(w, "repo", "Failed to set up repository permissions.") 1962 + return 1963 + } 1964 + 1965 + err = tx.Commit() 1966 + if err != nil { 1967 + log.Println("failed to commit changes", err) 1968 + http.Error(w, err.Error(), http.StatusInternalServerError) 1969 + return 1970 + } 1971 + 1972 + err = s.enforcer.E.SavePolicy() 1973 + if err != nil { 1974 + log.Println("failed to update ACLs", err) 1975 + http.Error(w, err.Error(), http.StatusInternalServerError) 1976 + return 1977 + } 1978 + 1979 + s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, forkName)) 1980 + return 1981 + } 1982 + }
+68 -8
appview/state/repo_util.go
··· 2 3 import ( 4 "context" 5 "fmt" 6 "log" 7 "net/http" 8 9 "github.com/bluesky-social/indigo/atproto/identity" 10 "github.com/bluesky-social/indigo/atproto/syntax" 11 "github.com/go-chi/chi/v5" 12 "github.com/go-git/go-git/v5/plumbing/object" 13 - "tangled.sh/tangled.sh/core/appview/auth" 14 "tangled.sh/tangled.sh/core/appview/db" 15 - "tangled.sh/tangled.sh/core/appview/pages" 16 ) 17 18 - func fullyResolvedRepo(r *http.Request) (*FullyResolvedRepo, error) { 19 repoName := chi.URLParam(r, "repo") 20 knot, ok := r.Context().Value("knot").(string) 21 if !ok { ··· 40 return nil, fmt.Errorf("malformed middleware") 41 } 42 43 // pass through values from the middleware 44 description, ok := r.Context().Value("repoDescription").(string) 45 addedAt, ok := r.Context().Value("repoAddedAt").(string) ··· 50 RepoName: repoName, 51 RepoAt: parsedRepoAt, 52 Description: description, 53 - AddedAt: addedAt, 54 }, nil 55 } 56 57 - func RolesInRepo(s *State, u *auth.User, f *FullyResolvedRepo) pages.RolesInRepo { 58 if u != nil { 59 - r := s.enforcer.GetPermissionsInRepo(u.Did, f.Knot, f.OwnerSlashRepo()) 60 - return pages.RolesInRepo{r} 61 } else { 62 - return pages.RolesInRepo{} 63 } 64 } 65 ··· 80 return uniqueEmails 81 } 82 83 func EmailToDidOrHandle(s *State, emails []string) map[string]string { 84 emailToDid, err := db.GetEmailToDid(s.db, emails, true) // only get verified emails for mapping 85 if err != nil { ··· 112 113 return emailToDidOrHandle 114 }
··· 2 3 import ( 4 "context" 5 + "crypto/rand" 6 "fmt" 7 "log" 8 + "math/big" 9 "net/http" 10 11 "github.com/bluesky-social/indigo/atproto/identity" 12 "github.com/bluesky-social/indigo/atproto/syntax" 13 "github.com/go-chi/chi/v5" 14 "github.com/go-git/go-git/v5/plumbing/object" 15 "tangled.sh/tangled.sh/core/appview/db" 16 + "tangled.sh/tangled.sh/core/appview/knotclient" 17 + "tangled.sh/tangled.sh/core/appview/oauth" 18 + "tangled.sh/tangled.sh/core/appview/pages/repoinfo" 19 ) 20 21 + func (s *State) fullyResolvedRepo(r *http.Request) (*FullyResolvedRepo, error) { 22 repoName := chi.URLParam(r, "repo") 23 knot, ok := r.Context().Value("knot").(string) 24 if !ok { ··· 43 return nil, fmt.Errorf("malformed middleware") 44 } 45 46 + ref := chi.URLParam(r, "ref") 47 + 48 + if ref == "" { 49 + us, err := knotclient.NewUnsignedClient(knot, s.config.Core.Dev) 50 + if err != nil { 51 + return nil, err 52 + } 53 + 54 + defaultBranch, err := us.DefaultBranch(id.DID.String(), repoName) 55 + if err != nil { 56 + return nil, err 57 + } 58 + 59 + ref = defaultBranch.Branch 60 + } 61 + 62 // pass through values from the middleware 63 description, ok := r.Context().Value("repoDescription").(string) 64 addedAt, ok := r.Context().Value("repoAddedAt").(string) ··· 69 RepoName: repoName, 70 RepoAt: parsedRepoAt, 71 Description: description, 72 + CreatedAt: addedAt, 73 + Ref: ref, 74 }, nil 75 } 76 77 + func RolesInRepo(s *State, u *oauth.User, f *FullyResolvedRepo) repoinfo.RolesInRepo { 78 if u != nil { 79 + r := s.enforcer.GetPermissionsInRepo(u.Did, f.Knot, f.DidSlashRepo()) 80 + return repoinfo.RolesInRepo{r} 81 } else { 82 + return repoinfo.RolesInRepo{} 83 } 84 } 85 ··· 100 return uniqueEmails 101 } 102 103 + func balanceIndexItems(commitCount, branchCount, tagCount, fileCount int) (commitsTrunc int, branchesTrunc int, tagsTrunc int) { 104 + if commitCount == 0 && tagCount == 0 && branchCount == 0 { 105 + return 106 + } 107 + 108 + // typically 1 item on right side = 2 files in height 109 + availableSpace := fileCount / 2 110 + 111 + // clamp tagcount 112 + if tagCount > 0 { 113 + tagsTrunc = 1 114 + availableSpace -= 1 // an extra subtracted for headers etc. 115 + } 116 + 117 + // clamp branchcount 118 + if branchCount > 0 { 119 + branchesTrunc = min(max(branchCount, 1), 2) 120 + availableSpace -= branchesTrunc // an extra subtracted for headers etc. 121 + } 122 + 123 + // show 124 + if commitCount > 0 { 125 + commitsTrunc = max(availableSpace, 3) 126 + } 127 + 128 + return 129 + } 130 + 131 func EmailToDidOrHandle(s *State, emails []string) map[string]string { 132 emailToDid, err := db.GetEmailToDid(s.db, emails, true) // only get verified emails for mapping 133 if err != nil { ··· 160 161 return emailToDidOrHandle 162 } 163 + 164 + func randomString(n int) string { 165 + const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" 166 + result := make([]byte, n) 167 + 168 + for i := 0; i < n; i++ { 169 + n, _ := rand.Int(rand.Reader, big.NewInt(int64(len(letters)))) 170 + result[i] = letters[n.Int64()] 171 + } 172 + 173 + return string(result) 174 + }
+96 -34
appview/state/router.go
··· 5 "strings" 6 7 "github.com/go-chi/chi/v5" 8 "tangled.sh/tangled.sh/core/appview/state/userutil" 9 ) 10 ··· 51 r.Use(StripLeadingAt) 52 53 r.With(ResolveIdent(s)).Route("/{user}", func(r chi.Router) { 54 - r.Get("/", s.ProfilePage) 55 r.With(ResolveRepo(s)).Route("/{repo}", func(r chi.Router) { 56 r.Get("/", s.RepoIndex) 57 r.Get("/commits/{ref}", s.RepoLog) 58 r.Route("/tree/{ref}", func(r chi.Router) { ··· 61 }) 62 r.Get("/commit/{ref}", s.RepoCommit) 63 r.Get("/branches", s.RepoBranches) 64 - r.Get("/tags", s.RepoTags) 65 r.Get("/blob/{ref}/*", s.RepoBlob) 66 67 r.Route("/issues", func(r chi.Router) { 68 - r.Get("/", s.RepoIssues) 69 r.Get("/{issue}", s.RepoSingleIssue) 70 71 r.Group(func(r chi.Router) { 72 - r.Use(AuthMiddleware(s)) 73 r.Get("/new", s.NewIssue) 74 r.Post("/new", s.NewIssue) 75 - r.Post("/{issue}/comment", s.IssueComment) 76 r.Post("/{issue}/close", s.CloseIssue) 77 r.Post("/{issue}/reopen", s.ReopenIssue) 78 }) 79 }) 80 81 r.Route("/pulls", func(r chi.Router) { 82 r.Get("/", s.RepoPulls) 83 - r.With(AuthMiddleware(s)).Route("/new", func(r chi.Router) { 84 r.Get("/", s.NewPull) 85 r.Post("/", s.NewPull) 86 }) 87 ··· 91 92 r.Route("/round/{round}", func(r chi.Router) { 93 r.Get("/", s.RepoPullPatch) 94 r.Get("/actions", s.PullActions) 95 - r.Route("/comment", func(r chi.Router) { 96 r.Get("/", s.PullComment) 97 r.Post("/", s.PullComment) 98 }) 99 }) 100 101 - // authorized requests below this point 102 r.Group(func(r chi.Router) { 103 - r.Use(AuthMiddleware(s)) 104 r.Route("/resubmit", func(r chi.Router) { 105 r.Get("/", s.ResubmitPull) 106 r.Post("/", s.ResubmitPull) 107 }) 108 - r.Route("/comment", func(r chi.Router) { 109 - r.Get("/", s.PullComment) 110 - r.Post("/", s.PullComment) 111 - }) 112 r.Post("/close", s.ClosePull) 113 r.Post("/reopen", s.ReopenPull) 114 // collaborators only ··· 127 128 // settings routes, needs auth 129 r.Group(func(r chi.Router) { 130 - r.Use(AuthMiddleware(s)) 131 // repo description can only be edited by owner 132 r.With(RepoPermissionMiddleware(s, "repo:owner")).Route("/description", func(r chi.Router) { 133 r.Put("/", s.RepoDescription) ··· 137 r.With(RepoPermissionMiddleware(s, "repo:settings")).Route("/settings", func(r chi.Router) { 138 r.Get("/", s.RepoSettings) 139 r.With(RepoPermissionMiddleware(s, "repo:invite")).Put("/collaborator", s.AddCollaborator) 140 }) 141 }) 142 }) ··· 156 157 r.Get("/", s.Timeline) 158 159 - r.With(AuthMiddleware(s)).Get("/logout", s.Logout) 160 - 161 - r.Route("/login", func(r chi.Router) { 162 - r.Get("/", s.Login) 163 - r.Post("/", s.Login) 164 - }) 165 166 r.Route("/knots", func(r chi.Router) { 167 - r.Use(AuthMiddleware(s)) 168 r.Get("/", s.Knots) 169 r.Post("/key", s.RegistrationKey) 170 ··· 182 183 r.Route("/repo", func(r chi.Router) { 184 r.Route("/new", func(r chi.Router) { 185 - r.Use(AuthMiddleware(s)) 186 r.Get("/", s.NewRepo) 187 r.Post("/", s.NewRepo) 188 }) 189 // r.Post("/import", s.ImportRepo) 190 }) 191 192 - r.With(AuthMiddleware(s)).Route("/follow", func(r chi.Router) { 193 r.Post("/", s.Follow) 194 r.Delete("/", s.Follow) 195 }) 196 197 - r.With(AuthMiddleware(s)).Route("/star", func(r chi.Router) { 198 r.Post("/", s.Star) 199 r.Delete("/", s.Star) 200 }) 201 202 - r.Route("/settings", func(r chi.Router) { 203 - r.Use(AuthMiddleware(s)) 204 - r.Get("/", s.Settings) 205 - r.Put("/keys", s.SettingsKeys) 206 - r.Delete("/keys", s.SettingsKeys) 207 - r.Put("/emails", s.SettingsEmails) 208 - r.Delete("/emails", s.SettingsEmails) 209 - r.Get("/emails/verify", s.SettingsEmailsVerify) 210 - r.Post("/emails/verify/resend", s.SettingsEmailsVerifyResend) 211 - r.Post("/emails/primary", s.SettingsEmailsPrimary) 212 }) 213 214 r.Get("/keys/{user}", s.Keys) 215 216 r.NotFound(func(w http.ResponseWriter, r *http.Request) { ··· 218 }) 219 return r 220 }
··· 5 "strings" 6 7 "github.com/go-chi/chi/v5" 8 + "github.com/gorilla/sessions" 9 + "tangled.sh/tangled.sh/core/appview/middleware" 10 + oauthhandler "tangled.sh/tangled.sh/core/appview/oauth/handler" 11 + "tangled.sh/tangled.sh/core/appview/settings" 12 "tangled.sh/tangled.sh/core/appview/state/userutil" 13 ) 14 ··· 55 r.Use(StripLeadingAt) 56 57 r.With(ResolveIdent(s)).Route("/{user}", func(r chi.Router) { 58 + r.Get("/", s.Profile) 59 + 60 r.With(ResolveRepo(s)).Route("/{repo}", func(r chi.Router) { 61 + r.Use(GoImport(s)) 62 + 63 r.Get("/", s.RepoIndex) 64 r.Get("/commits/{ref}", s.RepoLog) 65 r.Route("/tree/{ref}", func(r chi.Router) { ··· 68 }) 69 r.Get("/commit/{ref}", s.RepoCommit) 70 r.Get("/branches", s.RepoBranches) 71 + r.Route("/tags", func(r chi.Router) { 72 + r.Get("/", s.RepoTags) 73 + r.Route("/{tag}", func(r chi.Router) { 74 + r.Use(middleware.AuthMiddleware(s.oauth)) 75 + // require auth to download for now 76 + r.Get("/download/{file}", s.DownloadArtifact) 77 + 78 + // require repo:push to upload or delete artifacts 79 + // 80 + // additionally: only the uploader can truly delete an artifact 81 + // (record+blob will live on their pds) 82 + r.Group(func(r chi.Router) { 83 + r.With(RepoPermissionMiddleware(s, "repo:push")) 84 + r.Post("/upload", s.AttachArtifact) 85 + r.Delete("/{file}", s.DeleteArtifact) 86 + }) 87 + }) 88 + }) 89 r.Get("/blob/{ref}/*", s.RepoBlob) 90 + r.Get("/raw/{ref}/*", s.RepoBlobRaw) 91 92 r.Route("/issues", func(r chi.Router) { 93 + r.With(middleware.Paginate).Get("/", s.RepoIssues) 94 r.Get("/{issue}", s.RepoSingleIssue) 95 96 r.Group(func(r chi.Router) { 97 + r.Use(middleware.AuthMiddleware(s.oauth)) 98 r.Get("/new", s.NewIssue) 99 r.Post("/new", s.NewIssue) 100 + r.Post("/{issue}/comment", s.NewIssueComment) 101 + r.Route("/{issue}/comment/{comment_id}/", func(r chi.Router) { 102 + r.Get("/", s.IssueComment) 103 + r.Delete("/", s.DeleteIssueComment) 104 + r.Get("/edit", s.EditIssueComment) 105 + r.Post("/edit", s.EditIssueComment) 106 + }) 107 r.Post("/{issue}/close", s.CloseIssue) 108 r.Post("/{issue}/reopen", s.ReopenIssue) 109 }) 110 }) 111 112 + r.Route("/fork", func(r chi.Router) { 113 + r.Use(middleware.AuthMiddleware(s.oauth)) 114 + r.Get("/", s.ForkRepo) 115 + r.Post("/", s.ForkRepo) 116 + }) 117 + 118 r.Route("/pulls", func(r chi.Router) { 119 r.Get("/", s.RepoPulls) 120 + r.With(middleware.AuthMiddleware(s.oauth)).Route("/new", func(r chi.Router) { 121 r.Get("/", s.NewPull) 122 + r.Get("/patch-upload", s.PatchUploadFragment) 123 + r.Post("/validate-patch", s.ValidatePatch) 124 + r.Get("/compare-branches", s.CompareBranchesFragment) 125 + r.Get("/compare-forks", s.CompareForksFragment) 126 + r.Get("/fork-branches", s.CompareForksBranchesFragment) 127 r.Post("/", s.NewPull) 128 }) 129 ··· 133 134 r.Route("/round/{round}", func(r chi.Router) { 135 r.Get("/", s.RepoPullPatch) 136 + r.Get("/interdiff", s.RepoPullInterdiff) 137 r.Get("/actions", s.PullActions) 138 + r.With(middleware.AuthMiddleware(s.oauth)).Route("/comment", func(r chi.Router) { 139 r.Get("/", s.PullComment) 140 r.Post("/", s.PullComment) 141 }) 142 }) 143 144 + r.Route("/round/{round}.patch", func(r chi.Router) { 145 + r.Get("/", s.RepoPullPatchRaw) 146 + }) 147 + 148 r.Group(func(r chi.Router) { 149 + r.Use(middleware.AuthMiddleware(s.oauth)) 150 r.Route("/resubmit", func(r chi.Router) { 151 r.Get("/", s.ResubmitPull) 152 r.Post("/", s.ResubmitPull) 153 }) 154 r.Post("/close", s.ClosePull) 155 r.Post("/reopen", s.ReopenPull) 156 // collaborators only ··· 169 170 // settings routes, needs auth 171 r.Group(func(r chi.Router) { 172 + r.Use(middleware.AuthMiddleware(s.oauth)) 173 // repo description can only be edited by owner 174 r.With(RepoPermissionMiddleware(s, "repo:owner")).Route("/description", func(r chi.Router) { 175 r.Put("/", s.RepoDescription) ··· 179 r.With(RepoPermissionMiddleware(s, "repo:settings")).Route("/settings", func(r chi.Router) { 180 r.Get("/", s.RepoSettings) 181 r.With(RepoPermissionMiddleware(s, "repo:invite")).Put("/collaborator", s.AddCollaborator) 182 + r.With(RepoPermissionMiddleware(s, "repo:delete")).Delete("/delete", s.DeleteRepo) 183 + r.Put("/branches/default", s.SetDefaultBranch) 184 }) 185 }) 186 }) ··· 200 201 r.Get("/", s.Timeline) 202 203 + r.With(middleware.AuthMiddleware(s.oauth)).Post("/logout", s.Logout) 204 205 r.Route("/knots", func(r chi.Router) { 206 + r.Use(middleware.AuthMiddleware(s.oauth)) 207 r.Get("/", s.Knots) 208 r.Post("/key", s.RegistrationKey) 209 ··· 221 222 r.Route("/repo", func(r chi.Router) { 223 r.Route("/new", func(r chi.Router) { 224 + r.Use(middleware.AuthMiddleware(s.oauth)) 225 r.Get("/", s.NewRepo) 226 r.Post("/", s.NewRepo) 227 }) 228 // r.Post("/import", s.ImportRepo) 229 }) 230 231 + r.With(middleware.AuthMiddleware(s.oauth)).Route("/follow", func(r chi.Router) { 232 r.Post("/", s.Follow) 233 r.Delete("/", s.Follow) 234 }) 235 236 + r.With(middleware.AuthMiddleware(s.oauth)).Route("/star", func(r chi.Router) { 237 r.Post("/", s.Star) 238 r.Delete("/", s.Star) 239 }) 240 241 + r.Route("/profile", func(r chi.Router) { 242 + r.Use(middleware.AuthMiddleware(s.oauth)) 243 + r.Get("/edit-bio", s.EditBioFragment) 244 + r.Get("/edit-pins", s.EditPinsFragment) 245 + r.Post("/bio", s.UpdateProfileBio) 246 + r.Post("/pins", s.UpdateProfilePins) 247 }) 248 249 + r.Mount("/settings", s.SettingsRouter()) 250 + r.Mount("/", s.OAuthRouter()) 251 r.Get("/keys/{user}", s.Keys) 252 253 r.NotFound(func(w http.ResponseWriter, r *http.Request) { ··· 255 }) 256 return r 257 } 258 + 259 + func (s *State) OAuthRouter() http.Handler { 260 + oauth := &oauthhandler.OAuthHandler{ 261 + Config: s.config, 262 + Pages: s.pages, 263 + Resolver: s.resolver, 264 + Db: s.db, 265 + Store: sessions.NewCookieStore([]byte(s.config.Core.CookieSecret)), 266 + OAuth: s.oauth, 267 + Enforcer: s.enforcer, 268 + } 269 + 270 + return oauth.Router() 271 + } 272 + 273 + func (s *State) SettingsRouter() http.Handler { 274 + settings := &settings.Settings{ 275 + Db: s.db, 276 + OAuth: s.oauth, 277 + Pages: s.pages, 278 + Config: s.config, 279 + } 280 + 281 + return settings.Router() 282 + }
-416
appview/state/settings.go
··· 1 - package state 2 - 3 - import ( 4 - "database/sql" 5 - "errors" 6 - "fmt" 7 - "log" 8 - "net/http" 9 - "net/url" 10 - "strings" 11 - "time" 12 - 13 - comatproto "github.com/bluesky-social/indigo/api/atproto" 14 - lexutil "github.com/bluesky-social/indigo/lex/util" 15 - "github.com/gliderlabs/ssh" 16 - "github.com/google/uuid" 17 - "tangled.sh/tangled.sh/core/api/tangled" 18 - "tangled.sh/tangled.sh/core/appview/db" 19 - "tangled.sh/tangled.sh/core/appview/email" 20 - "tangled.sh/tangled.sh/core/appview/pages" 21 - ) 22 - 23 - func (s *State) Settings(w http.ResponseWriter, r *http.Request) { 24 - user := s.auth.GetUser(r) 25 - pubKeys, err := db.GetPublicKeys(s.db, user.Did) 26 - if err != nil { 27 - log.Println(err) 28 - } 29 - 30 - emails, err := db.GetAllEmails(s.db, user.Did) 31 - if err != nil { 32 - log.Println(err) 33 - } 34 - 35 - s.pages.Settings(w, pages.SettingsParams{ 36 - LoggedInUser: user, 37 - PubKeys: pubKeys, 38 - Emails: emails, 39 - }) 40 - } 41 - 42 - // buildVerificationEmail creates an email.Email struct for verification emails 43 - func (s *State) buildVerificationEmail(emailAddr, did, code string) email.Email { 44 - verifyURL := s.verifyUrl(did, emailAddr, code) 45 - 46 - return email.Email{ 47 - APIKey: s.config.ResendApiKey, 48 - From: "noreply@notifs.tangled.sh", 49 - To: emailAddr, 50 - Subject: "Verify your Tangled email", 51 - Text: `Click the link below (or copy and paste it into your browser) to verify your email address. 52 - ` + verifyURL, 53 - Html: `<p>Click the link (or copy and paste it into your browser) to verify your email address.</p> 54 - <p><a href="` + verifyURL + `">` + verifyURL + `</a></p>`, 55 - } 56 - } 57 - 58 - // sendVerificationEmail handles the common logic for sending verification emails 59 - func (s *State) sendVerificationEmail(w http.ResponseWriter, did, emailAddr, code string, errorContext string) error { 60 - emailToSend := s.buildVerificationEmail(emailAddr, did, code) 61 - 62 - err := email.SendEmail(emailToSend) 63 - if err != nil { 64 - log.Printf("sending email: %s", err) 65 - s.pages.Notice(w, "settings-emails-error", fmt.Sprintf("Unable to send verification email at this moment, try again later. %s", errorContext)) 66 - return err 67 - } 68 - 69 - return nil 70 - } 71 - 72 - func (s *State) SettingsEmails(w http.ResponseWriter, r *http.Request) { 73 - switch r.Method { 74 - case http.MethodGet: 75 - s.pages.Notice(w, "settings-emails", "Unimplemented.") 76 - log.Println("unimplemented") 77 - return 78 - case http.MethodPut: 79 - did := s.auth.GetDid(r) 80 - emAddr := r.FormValue("email") 81 - emAddr = strings.TrimSpace(emAddr) 82 - 83 - if !email.IsValidEmail(emAddr) { 84 - s.pages.Notice(w, "settings-emails-error", "Invalid email address.") 85 - return 86 - } 87 - 88 - // check if email already exists in database 89 - existingEmail, err := db.GetEmail(s.db, did, emAddr) 90 - if err != nil && !errors.Is(err, sql.ErrNoRows) { 91 - log.Printf("checking for existing email: %s", err) 92 - s.pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.") 93 - return 94 - } 95 - 96 - if err == nil { 97 - if existingEmail.Verified { 98 - s.pages.Notice(w, "settings-emails-error", "This email is already verified.") 99 - return 100 - } 101 - 102 - s.pages.Notice(w, "settings-emails-error", "This email is already added but not verified. Check your inbox for the verification link.") 103 - return 104 - } 105 - 106 - code := uuid.New().String() 107 - 108 - // Begin transaction 109 - tx, err := s.db.Begin() 110 - if err != nil { 111 - log.Printf("failed to start transaction: %s", err) 112 - s.pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.") 113 - return 114 - } 115 - defer tx.Rollback() 116 - 117 - if err := db.AddEmail(tx, db.Email{ 118 - Did: did, 119 - Address: emAddr, 120 - Verified: false, 121 - VerificationCode: code, 122 - }); err != nil { 123 - log.Printf("adding email: %s", err) 124 - s.pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.") 125 - return 126 - } 127 - 128 - if err := s.sendVerificationEmail(w, did, emAddr, code, ""); err != nil { 129 - return 130 - } 131 - 132 - // Commit transaction 133 - if err := tx.Commit(); err != nil { 134 - log.Printf("failed to commit transaction: %s", err) 135 - s.pages.Notice(w, "settings-emails-error", "Unable to add email at this moment, try again later.") 136 - return 137 - } 138 - 139 - s.pages.Notice(w, "settings-emails-success", "Click the link in the email we sent you to verify your email address.") 140 - return 141 - case http.MethodDelete: 142 - did := s.auth.GetDid(r) 143 - emailAddr := r.FormValue("email") 144 - emailAddr = strings.TrimSpace(emailAddr) 145 - 146 - // Begin transaction 147 - tx, err := s.db.Begin() 148 - if err != nil { 149 - log.Printf("failed to start transaction: %s", err) 150 - s.pages.Notice(w, "settings-emails-error", "Unable to delete email at this moment, try again later.") 151 - return 152 - } 153 - defer tx.Rollback() 154 - 155 - if err := db.DeleteEmail(tx, did, emailAddr); err != nil { 156 - log.Printf("deleting email: %s", err) 157 - s.pages.Notice(w, "settings-emails-error", "Unable to delete email at this moment, try again later.") 158 - return 159 - } 160 - 161 - // Commit transaction 162 - if err := tx.Commit(); err != nil { 163 - log.Printf("failed to commit transaction: %s", err) 164 - s.pages.Notice(w, "settings-emails-error", "Unable to delete email at this moment, try again later.") 165 - return 166 - } 167 - 168 - s.pages.HxLocation(w, "/settings") 169 - return 170 - } 171 - } 172 - 173 - func (s *State) verifyUrl(did string, email string, code string) string { 174 - var appUrl string 175 - if s.config.Dev { 176 - appUrl = "http://" + s.config.ListenAddr 177 - } else { 178 - appUrl = "https://tangled.sh" 179 - } 180 - 181 - return fmt.Sprintf("%s/settings/emails/verify?did=%s&email=%s&code=%s", appUrl, url.QueryEscape(did), url.QueryEscape(email), url.QueryEscape(code)) 182 - } 183 - 184 - func (s *State) SettingsEmailsVerify(w http.ResponseWriter, r *http.Request) { 185 - q := r.URL.Query() 186 - 187 - // Get the parameters directly from the query 188 - emailAddr := q.Get("email") 189 - did := q.Get("did") 190 - code := q.Get("code") 191 - 192 - valid, err := db.CheckValidVerificationCode(s.db, did, emailAddr, code) 193 - if err != nil { 194 - log.Printf("checking email verification: %s", err) 195 - s.pages.Notice(w, "settings-emails-error", "Error verifying email. Please try again later.") 196 - return 197 - } 198 - 199 - if !valid { 200 - s.pages.Notice(w, "settings-emails-error", "Invalid verification code. Please request a new verification email.") 201 - return 202 - } 203 - 204 - // Mark email as verified in the database 205 - if err := db.MarkEmailVerified(s.db, did, emailAddr); err != nil { 206 - log.Printf("marking email as verified: %s", err) 207 - s.pages.Notice(w, "settings-emails-error", "Error updating email verification status. Please try again later.") 208 - return 209 - } 210 - 211 - http.Redirect(w, r, "/settings", http.StatusSeeOther) 212 - } 213 - 214 - func (s *State) SettingsEmailsVerifyResend(w http.ResponseWriter, r *http.Request) { 215 - if r.Method != http.MethodPost { 216 - s.pages.Notice(w, "settings-emails-error", "Invalid request method.") 217 - return 218 - } 219 - 220 - did := s.auth.GetDid(r) 221 - emAddr := r.FormValue("email") 222 - emAddr = strings.TrimSpace(emAddr) 223 - 224 - if !email.IsValidEmail(emAddr) { 225 - s.pages.Notice(w, "settings-emails-error", "Invalid email address.") 226 - return 227 - } 228 - 229 - // Check if email exists and is unverified 230 - existingEmail, err := db.GetEmail(s.db, did, emAddr) 231 - if err != nil { 232 - if errors.Is(err, sql.ErrNoRows) { 233 - s.pages.Notice(w, "settings-emails-error", "Email not found. Please add it first.") 234 - } else { 235 - log.Printf("checking for existing email: %s", err) 236 - s.pages.Notice(w, "settings-emails-error", "Unable to resend verification email at this moment, try again later.") 237 - } 238 - return 239 - } 240 - 241 - if existingEmail.Verified { 242 - s.pages.Notice(w, "settings-emails-error", "This email is already verified.") 243 - return 244 - } 245 - 246 - // Check if last verification email was sent less than 10 minutes ago 247 - if existingEmail.LastSent != nil { 248 - timeSinceLastSent := time.Since(*existingEmail.LastSent) 249 - if timeSinceLastSent < 10*time.Minute { 250 - waitTime := 10*time.Minute - timeSinceLastSent 251 - s.pages.Notice(w, "settings-emails-error", fmt.Sprintf("Please wait %d minutes before requesting another verification email.", int(waitTime.Minutes()+1))) 252 - return 253 - } 254 - } 255 - 256 - // Generate new verification code 257 - code := uuid.New().String() 258 - 259 - // Begin transaction 260 - tx, err := s.db.Begin() 261 - if err != nil { 262 - log.Printf("failed to start transaction: %s", err) 263 - s.pages.Notice(w, "settings-emails-error", "Unable to resend verification email at this moment, try again later.") 264 - return 265 - } 266 - defer tx.Rollback() 267 - 268 - // Update the verification code and last sent time 269 - if err := db.UpdateVerificationCode(tx, did, emAddr, code); err != nil { 270 - log.Printf("updating email verification: %s", err) 271 - s.pages.Notice(w, "settings-emails-error", "Unable to resend verification email at this moment, try again later.") 272 - return 273 - } 274 - 275 - // Send verification email 276 - if err := s.sendVerificationEmail(w, did, emAddr, code, ""); err != nil { 277 - return 278 - } 279 - 280 - // Commit transaction 281 - if err := tx.Commit(); err != nil { 282 - log.Printf("failed to commit transaction: %s", err) 283 - s.pages.Notice(w, "settings-emails-error", "Unable to resend verification email at this moment, try again later.") 284 - return 285 - } 286 - 287 - s.pages.Notice(w, "settings-emails-success", "Verification email resent. Click the link in the email we sent you to verify your email address.") 288 - } 289 - 290 - func (s *State) SettingsEmailsPrimary(w http.ResponseWriter, r *http.Request) { 291 - did := s.auth.GetDid(r) 292 - emailAddr := r.FormValue("email") 293 - emailAddr = strings.TrimSpace(emailAddr) 294 - 295 - if emailAddr == "" { 296 - s.pages.Notice(w, "settings-emails-error", "Email address cannot be empty.") 297 - return 298 - } 299 - 300 - if err := db.MakeEmailPrimary(s.db, did, emailAddr); err != nil { 301 - log.Printf("setting primary email: %s", err) 302 - s.pages.Notice(w, "settings-emails-error", "Error setting primary email. Please try again later.") 303 - return 304 - } 305 - 306 - s.pages.HxLocation(w, "/settings") 307 - } 308 - 309 - func (s *State) SettingsKeys(w http.ResponseWriter, r *http.Request) { 310 - switch r.Method { 311 - case http.MethodGet: 312 - s.pages.Notice(w, "settings-keys", "Unimplemented.") 313 - log.Println("unimplemented") 314 - return 315 - case http.MethodPut: 316 - did := s.auth.GetDid(r) 317 - key := r.FormValue("key") 318 - key = strings.TrimSpace(key) 319 - name := r.FormValue("name") 320 - client, _ := s.auth.AuthorizedClient(r) 321 - 322 - _, _, _, _, err := ssh.ParseAuthorizedKey([]byte(key)) 323 - if err != nil { 324 - log.Printf("parsing public key: %s", err) 325 - s.pages.Notice(w, "settings-keys", "That doesn't look like a valid public key. Make sure it's a <strong>public</strong> key.") 326 - return 327 - } 328 - 329 - rkey := s.TID() 330 - 331 - tx, err := s.db.Begin() 332 - if err != nil { 333 - log.Printf("failed to start tx; adding public key: %s", err) 334 - s.pages.Notice(w, "settings-keys", "Unable to add public key at this moment, try again later.") 335 - return 336 - } 337 - defer tx.Rollback() 338 - 339 - if err := db.AddPublicKey(tx, did, name, key, rkey); err != nil { 340 - log.Printf("adding public key: %s", err) 341 - s.pages.Notice(w, "settings-keys", "Failed to add public key.") 342 - return 343 - } 344 - 345 - // store in pds too 346 - resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 347 - Collection: tangled.PublicKeyNSID, 348 - Repo: did, 349 - Rkey: rkey, 350 - Record: &lexutil.LexiconTypeDecoder{ 351 - Val: &tangled.PublicKey{ 352 - Created: time.Now().Format(time.RFC3339), 353 - Key: key, 354 - Name: name, 355 - }}, 356 - }) 357 - // invalid record 358 - if err != nil { 359 - log.Printf("failed to create record: %s", err) 360 - s.pages.Notice(w, "settings-keys", "Failed to create record.") 361 - return 362 - } 363 - 364 - log.Println("created atproto record: ", resp.Uri) 365 - 366 - err = tx.Commit() 367 - if err != nil { 368 - log.Printf("failed to commit tx; adding public key: %s", err) 369 - s.pages.Notice(w, "settings-keys", "Unable to add public key at this moment, try again later.") 370 - return 371 - } 372 - 373 - s.pages.HxLocation(w, "/settings") 374 - return 375 - 376 - case http.MethodDelete: 377 - did := s.auth.GetDid(r) 378 - q := r.URL.Query() 379 - 380 - name := q.Get("name") 381 - rkey := q.Get("rkey") 382 - key := q.Get("key") 383 - 384 - log.Println(name) 385 - log.Println(rkey) 386 - log.Println(key) 387 - 388 - client, _ := s.auth.AuthorizedClient(r) 389 - 390 - if err := db.RemovePublicKey(s.db, did, name, key); err != nil { 391 - log.Printf("removing public key: %s", err) 392 - s.pages.Notice(w, "settings-keys", "Failed to remove public key.") 393 - return 394 - } 395 - 396 - if rkey != "" { 397 - // remove from pds too 398 - _, err := comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 399 - Collection: tangled.PublicKeyNSID, 400 - Repo: did, 401 - Rkey: rkey, 402 - }) 403 - 404 - // invalid record 405 - if err != nil { 406 - log.Printf("failed to delete record from PDS: %s", err) 407 - s.pages.Notice(w, "settings-keys", "Failed to remove key from PDS.") 408 - return 409 - } 410 - } 411 - log.Println("deleted successfully") 412 - 413 - s.pages.HxLocation(w, "/settings") 414 - return 415 - } 416 - }
···
-270
appview/state/signer.go
··· 1 - package state 2 - 3 - import ( 4 - "bytes" 5 - "crypto/hmac" 6 - "crypto/sha256" 7 - "encoding/hex" 8 - "encoding/json" 9 - "fmt" 10 - "net/http" 11 - "net/url" 12 - "time" 13 - 14 - "tangled.sh/tangled.sh/core/types" 15 - ) 16 - 17 - type SignerTransport struct { 18 - Secret string 19 - } 20 - 21 - func (s SignerTransport) RoundTrip(req *http.Request) (*http.Response, error) { 22 - timestamp := time.Now().Format(time.RFC3339) 23 - mac := hmac.New(sha256.New, []byte(s.Secret)) 24 - message := req.Method + req.URL.Path + timestamp 25 - mac.Write([]byte(message)) 26 - signature := hex.EncodeToString(mac.Sum(nil)) 27 - req.Header.Set("X-Signature", signature) 28 - req.Header.Set("X-Timestamp", timestamp) 29 - return http.DefaultTransport.RoundTrip(req) 30 - } 31 - 32 - type SignedClient struct { 33 - Secret string 34 - Url *url.URL 35 - client *http.Client 36 - } 37 - 38 - func NewSignedClient(domain, secret string, dev bool) (*SignedClient, error) { 39 - client := &http.Client{ 40 - Timeout: 5 * time.Second, 41 - Transport: SignerTransport{ 42 - Secret: secret, 43 - }, 44 - } 45 - 46 - scheme := "https" 47 - if dev { 48 - scheme = "http" 49 - } 50 - url, err := url.Parse(fmt.Sprintf("%s://%s", scheme, domain)) 51 - if err != nil { 52 - return nil, err 53 - } 54 - 55 - signedClient := &SignedClient{ 56 - Secret: secret, 57 - client: client, 58 - Url: url, 59 - } 60 - 61 - return signedClient, nil 62 - } 63 - 64 - func (s *SignedClient) newRequest(method, endpoint string, body []byte) (*http.Request, error) { 65 - return http.NewRequest(method, s.Url.JoinPath(endpoint).String(), bytes.NewReader(body)) 66 - } 67 - 68 - func (s *SignedClient) Init(did string) (*http.Response, error) { 69 - const ( 70 - Method = "POST" 71 - Endpoint = "/init" 72 - ) 73 - 74 - body, _ := json.Marshal(map[string]any{ 75 - "did": did, 76 - }) 77 - 78 - req, err := s.newRequest(Method, Endpoint, body) 79 - if err != nil { 80 - return nil, err 81 - } 82 - 83 - return s.client.Do(req) 84 - } 85 - 86 - func (s *SignedClient) NewRepo(did, repoName, defaultBranch string) (*http.Response, error) { 87 - const ( 88 - Method = "PUT" 89 - Endpoint = "/repo/new" 90 - ) 91 - 92 - body, _ := json.Marshal(map[string]any{ 93 - "did": did, 94 - "name": repoName, 95 - "default_branch": defaultBranch, 96 - }) 97 - 98 - req, err := s.newRequest(Method, Endpoint, body) 99 - if err != nil { 100 - return nil, err 101 - } 102 - 103 - return s.client.Do(req) 104 - } 105 - 106 - func (s *SignedClient) RemoveRepo(did, repoName string) (*http.Response, error) { 107 - const ( 108 - Method = "DELETE" 109 - Endpoint = "/repo" 110 - ) 111 - 112 - body, _ := json.Marshal(map[string]any{ 113 - "did": did, 114 - "name": repoName, 115 - }) 116 - 117 - req, err := s.newRequest(Method, Endpoint, body) 118 - if err != nil { 119 - return nil, err 120 - } 121 - 122 - return s.client.Do(req) 123 - } 124 - 125 - func (s *SignedClient) AddMember(did string) (*http.Response, error) { 126 - const ( 127 - Method = "PUT" 128 - Endpoint = "/member/add" 129 - ) 130 - 131 - body, _ := json.Marshal(map[string]any{ 132 - "did": did, 133 - }) 134 - 135 - req, err := s.newRequest(Method, Endpoint, body) 136 - if err != nil { 137 - return nil, err 138 - } 139 - 140 - return s.client.Do(req) 141 - } 142 - 143 - func (s *SignedClient) AddCollaborator(ownerDid, repoName, memberDid string) (*http.Response, error) { 144 - const ( 145 - Method = "POST" 146 - ) 147 - endpoint := fmt.Sprintf("/%s/%s/collaborator/add", ownerDid, repoName) 148 - 149 - body, _ := json.Marshal(map[string]any{ 150 - "did": memberDid, 151 - }) 152 - 153 - req, err := s.newRequest(Method, endpoint, body) 154 - if err != nil { 155 - return nil, err 156 - } 157 - 158 - return s.client.Do(req) 159 - } 160 - 161 - func (s *SignedClient) Merge( 162 - patch []byte, 163 - ownerDid, targetRepo, branch, commitMessage, commitBody, authorName, authorEmail string, 164 - ) (*http.Response, error) { 165 - const ( 166 - Method = "POST" 167 - ) 168 - endpoint := fmt.Sprintf("/%s/%s/merge", ownerDid, targetRepo) 169 - 170 - mr := types.MergeRequest{ 171 - Branch: branch, 172 - CommitMessage: commitMessage, 173 - CommitBody: commitBody, 174 - AuthorName: authorName, 175 - AuthorEmail: authorEmail, 176 - Patch: string(patch), 177 - } 178 - 179 - body, _ := json.Marshal(mr) 180 - 181 - req, err := s.newRequest(Method, endpoint, body) 182 - if err != nil { 183 - return nil, err 184 - } 185 - 186 - return s.client.Do(req) 187 - } 188 - 189 - func (s *SignedClient) MergeCheck(patch []byte, ownerDid, targetRepo, branch string) (*http.Response, error) { 190 - const ( 191 - Method = "POST" 192 - ) 193 - endpoint := fmt.Sprintf("/%s/%s/merge/check", ownerDid, targetRepo) 194 - 195 - body, _ := json.Marshal(map[string]any{ 196 - "patch": string(patch), 197 - "branch": branch, 198 - }) 199 - 200 - req, err := s.newRequest(Method, endpoint, body) 201 - if err != nil { 202 - return nil, err 203 - } 204 - 205 - return s.client.Do(req) 206 - } 207 - 208 - type UnsignedClient struct { 209 - Url *url.URL 210 - client *http.Client 211 - } 212 - 213 - func NewUnsignedClient(domain string, dev bool) (*UnsignedClient, error) { 214 - client := &http.Client{ 215 - Timeout: 5 * time.Second, 216 - } 217 - 218 - scheme := "https" 219 - if dev { 220 - scheme = "http" 221 - } 222 - url, err := url.Parse(fmt.Sprintf("%s://%s", scheme, domain)) 223 - if err != nil { 224 - return nil, err 225 - } 226 - 227 - unsignedClient := &UnsignedClient{ 228 - client: client, 229 - Url: url, 230 - } 231 - 232 - return unsignedClient, nil 233 - } 234 - 235 - func (us *UnsignedClient) newRequest(method, endpoint string, body []byte) (*http.Request, error) { 236 - return http.NewRequest(method, us.Url.JoinPath(endpoint).String(), bytes.NewReader(body)) 237 - } 238 - 239 - func (us *UnsignedClient) Index(ownerDid, repoName, ref string) (*http.Response, error) { 240 - const ( 241 - Method = "GET" 242 - ) 243 - 244 - endpoint := fmt.Sprintf("/%s/%s/tree/%s", ownerDid, repoName, ref) 245 - if ref == "" { 246 - endpoint = fmt.Sprintf("/%s/%s", ownerDid, repoName) 247 - } 248 - 249 - req, err := us.newRequest(Method, endpoint, nil) 250 - if err != nil { 251 - return nil, err 252 - } 253 - 254 - return us.client.Do(req) 255 - } 256 - 257 - func (us *UnsignedClient) Branches(ownerDid, repoName string) (*http.Response, error) { 258 - const ( 259 - Method = "GET" 260 - ) 261 - 262 - endpoint := fmt.Sprintf("/%s/%s/branches", ownerDid, repoName) 263 - 264 - req, err := us.newRequest(Method, endpoint, nil) 265 - if err != nil { 266 - return nil, err 267 - } 268 - 269 - return us.client.Do(req) 270 - }
···
+15 -9
appview/state/star.go
··· 8 comatproto "github.com/bluesky-social/indigo/api/atproto" 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 lexutil "github.com/bluesky-social/indigo/lex/util" 11 - tangled "tangled.sh/tangled.sh/core/api/tangled" 12 "tangled.sh/tangled.sh/core/appview/db" 13 "tangled.sh/tangled.sh/core/appview/pages" 14 ) 15 16 func (s *State) Star(w http.ResponseWriter, r *http.Request) { 17 - currentUser := s.auth.GetUser(r) 18 19 subject := r.URL.Query().Get("subject") 20 if subject == "" { ··· 28 return 29 } 30 31 - client, _ := s.auth.AuthorizedClient(r) 32 33 switch r.Method { 34 case http.MethodPost: 35 createdAt := time.Now().Format(time.RFC3339) 36 - rkey := s.TID() 37 - resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 38 Collection: tangled.FeedStarNSID, 39 Repo: currentUser.Did, 40 Rkey: rkey, ··· 62 63 log.Println("created atproto record: ", resp.Uri) 64 65 - s.pages.StarFragment(w, pages.StarFragmentParams{ 66 IsStarred: true, 67 RepoAt: subjectUri, 68 Stats: db.RepoStats{ ··· 79 return 80 } 81 82 - _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 83 Collection: tangled.FeedStarNSID, 84 Repo: currentUser.Did, 85 Rkey: star.Rkey, ··· 90 return 91 } 92 93 - err = db.DeleteStar(s.db, currentUser.Did, subjectUri) 94 if err != nil { 95 log.Println("failed to delete star from DB") 96 // this is not an issue, the firehose event might have already done this ··· 99 starCount, err := db.GetStarCount(s.db, subjectUri) 100 if err != nil { 101 log.Println("failed to get star count for ", subjectUri) 102 } 103 104 - s.pages.StarFragment(w, pages.StarFragmentParams{ 105 IsStarred: false, 106 RepoAt: subjectUri, 107 Stats: db.RepoStats{
··· 8 comatproto "github.com/bluesky-social/indigo/api/atproto" 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 lexutil "github.com/bluesky-social/indigo/lex/util" 11 + "tangled.sh/tangled.sh/core/api/tangled" 12 + "tangled.sh/tangled.sh/core/appview" 13 "tangled.sh/tangled.sh/core/appview/db" 14 "tangled.sh/tangled.sh/core/appview/pages" 15 ) 16 17 func (s *State) Star(w http.ResponseWriter, r *http.Request) { 18 + currentUser := s.oauth.GetUser(r) 19 20 subject := r.URL.Query().Get("subject") 21 if subject == "" { ··· 29 return 30 } 31 32 + client, err := s.oauth.AuthorizedClient(r) 33 + if err != nil { 34 + log.Println("failed to authorize client", err) 35 + return 36 + } 37 38 switch r.Method { 39 case http.MethodPost: 40 createdAt := time.Now().Format(time.RFC3339) 41 + rkey := appview.TID() 42 + resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 43 Collection: tangled.FeedStarNSID, 44 Repo: currentUser.Did, 45 Rkey: rkey, ··· 67 68 log.Println("created atproto record: ", resp.Uri) 69 70 + s.pages.RepoActionsFragment(w, pages.RepoActionsFragmentParams{ 71 IsStarred: true, 72 RepoAt: subjectUri, 73 Stats: db.RepoStats{ ··· 84 return 85 } 86 87 + _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 88 Collection: tangled.FeedStarNSID, 89 Repo: currentUser.Did, 90 Rkey: star.Rkey, ··· 95 return 96 } 97 98 + err = db.DeleteStarByRkey(s.db, currentUser.Did, star.Rkey) 99 if err != nil { 100 log.Println("failed to delete star from DB") 101 // this is not an issue, the firehose event might have already done this ··· 104 starCount, err := db.GetStarCount(s.db, subjectUri) 105 if err != nil { 106 log.Println("failed to get star count for ", subjectUri) 107 + return 108 } 109 110 + s.pages.RepoActionsFragment(w, pages.RepoActionsFragmentParams{ 111 IsStarred: false, 112 RepoAt: subjectUri, 113 Stats: db.RepoStats{
+191 -199
appview/state/state.go
··· 17 lexutil "github.com/bluesky-social/indigo/lex/util" 18 securejoin "github.com/cyphar/filepath-securejoin" 19 "github.com/go-chi/chi/v5" 20 - tangled "tangled.sh/tangled.sh/core/api/tangled" 21 "tangled.sh/tangled.sh/core/appview" 22 - "tangled.sh/tangled.sh/core/appview/auth" 23 "tangled.sh/tangled.sh/core/appview/db" 24 "tangled.sh/tangled.sh/core/appview/pages" 25 "tangled.sh/tangled.sh/core/jetstream" 26 "tangled.sh/tangled.sh/core/rbac" ··· 28 29 type State struct { 30 db *db.DB 31 - auth *auth.Auth 32 enforcer *rbac.Enforcer 33 - tidClock *syntax.TIDClock 34 pages *pages.Pages 35 resolver *appview.Resolver 36 jc *jetstream.JetstreamClient ··· 38 } 39 40 func Make(config *appview.Config) (*State, error) { 41 - d, err := db.Make(config.DbPath) 42 if err != nil { 43 return nil, err 44 } 45 46 - auth, err := auth.Make(config.CookieSecret) 47 - if err != nil { 48 - return nil, err 49 - } 50 - 51 - enforcer, err := rbac.NewEnforcer(config.DbPath) 52 if err != nil { 53 return nil, err 54 } 55 56 clock := syntax.NewTIDClock(0) 57 58 - pgs := pages.NewPages() 59 60 resolver := appview.NewResolver() 61 62 wrapper := db.DbWrapper{d} 63 - jc, err := jetstream.NewJetstreamClient(config.JetstreamEndpoint, "appview", []string{tangled.GraphFollowNSID}, nil, slog.Default(), wrapper, false) 64 if err != nil { 65 return nil, fmt.Errorf("failed to create jetstream client: %w", err) 66 } 67 - err = jc.StartJetstream(context.Background(), jetstreamIngester(wrapper)) 68 if err != nil { 69 return nil, fmt.Errorf("failed to start jetstream watcher: %w", err) 70 } 71 72 state := &State{ 73 d, 74 - auth, 75 enforcer, 76 clock, 77 pgs, ··· 83 return state, nil 84 } 85 86 - func (s *State) TID() string { 87 - return s.tidClock.Next().String() 88 } 89 90 - func (s *State) Login(w http.ResponseWriter, r *http.Request) { 91 - ctx := r.Context() 92 93 - switch r.Method { 94 - case http.MethodGet: 95 - err := s.pages.Login(w, pages.LoginParams{}) 96 - if err != nil { 97 - log.Printf("rendering login page: %s", err) 98 - } 99 100 - return 101 - case http.MethodPost: 102 - handle := strings.TrimPrefix(r.FormValue("handle"), "@") 103 - appPassword := r.FormValue("app_password") 104 105 - resolved, err := s.resolver.ResolveIdent(ctx, handle) 106 - if err != nil { 107 - log.Println("failed to resolve handle:", err) 108 - s.pages.Notice(w, "login-msg", fmt.Sprintf("\"%s\" is an invalid handle.", handle)) 109 - return 110 - } 111 112 - atSession, err := s.auth.CreateInitialSession(ctx, resolved, appPassword) 113 - if err != nil { 114 - s.pages.Notice(w, "login-msg", "Invalid handle or password.") 115 - return 116 - } 117 - sessionish := auth.CreateSessionWrapper{ServerCreateSession_Output: atSession} 118 119 - err = s.auth.StoreSession(r, w, &sessionish, resolved.PDSEndpoint()) 120 - if err != nil { 121 - s.pages.Notice(w, "login-msg", "Failed to login, try again later.") 122 - return 123 - } 124 125 - log.Printf("successfully saved session for %s (%s)", atSession.Handle, atSession.Did) 126 127 - did := resolved.DID.String() 128 - defaultKnot := "knot1.tangled.sh" 129 130 - go func() { 131 - log.Printf("adding %s to default knot", did) 132 - err = s.enforcer.AddMember(defaultKnot, did) 133 - if err != nil { 134 - log.Println("failed to add user to knot1.tangled.sh: ", err) 135 - return 136 - } 137 - err = s.enforcer.E.SavePolicy() 138 - if err != nil { 139 - log.Println("failed to add user to knot1.tangled.sh: ", err) 140 - return 141 - } 142 143 - secret, err := db.GetRegistrationKey(s.db, defaultKnot) 144 - if err != nil { 145 - log.Println("failed to get registration key for knot1.tangled.sh") 146 - return 147 - } 148 - signedClient, err := NewSignedClient(defaultKnot, secret, s.config.Dev) 149 - resp, err := signedClient.AddMember(did) 150 - if err != nil { 151 - log.Println("failed to add user to knot1.tangled.sh: ", err) 152 - return 153 - } 154 155 - if resp.StatusCode != http.StatusNoContent { 156 - log.Println("failed to add user to knot1.tangled.sh: ", resp.StatusCode) 157 - return 158 - } 159 - }() 160 161 - s.pages.HxRedirect(w, "/") 162 - return 163 - } 164 - } 165 166 func (s *State) Logout(w http.ResponseWriter, r *http.Request) { 167 - s.auth.ClearSession(r, w) 168 - http.Redirect(w, r, "/login", http.StatusTemporaryRedirect) 169 } 170 171 func (s *State) Timeline(w http.ResponseWriter, r *http.Request) { 172 - user := s.auth.GetUser(r) 173 174 timeline, err := db.MakeTimeline(s.db) 175 if err != nil { ··· 181 for _, ev := range timeline { 182 if ev.Repo != nil { 183 didsToResolve = append(didsToResolve, ev.Repo.Did) 184 } 185 if ev.Follow != nil { 186 didsToResolve = append(didsToResolve, ev.Follow.UserDid, ev.Follow.SubjectDid) ··· 217 218 return 219 case http.MethodPost: 220 - session, err := s.auth.Store.Get(r, appview.SessionName) 221 if err != nil || session.IsNew { 222 log.Println("unauthorized attempt to generate registration key") 223 http.Error(w, "Forbidden", http.StatusUnauthorized) ··· 279 280 // create a signed request and check if a node responds to that 281 func (s *State) InitKnotServer(w http.ResponseWriter, r *http.Request) { 282 - user := s.auth.GetUser(r) 283 284 domain := chi.URLParam(r, "domain") 285 if domain == "" { ··· 294 return 295 } 296 297 - client, err := NewSignedClient(domain, secret, s.config.Dev) 298 if err != nil { 299 log.Println("failed to create client to ", domain) 300 } ··· 403 return 404 } 405 406 - user := s.auth.GetUser(r) 407 reg, err := db.RegistrationByDomain(s.db, domain) 408 if err != nil { 409 w.Write([]byte("failed to pull up registration info")) ··· 419 } 420 } 421 422 ok, err := s.enforcer.IsServerOwner(user.Did, domain) 423 isOwner := err == nil && ok 424 425 p := pages.KnotParams{ 426 LoggedInUser: user, 427 Registration: reg, 428 Members: members, 429 IsOwner: isOwner, ··· 435 // get knots registered by this user 436 func (s *State) Knots(w http.ResponseWriter, r *http.Request) { 437 // for now, this is just pubkeys 438 - user := s.auth.GetUser(r) 439 registrations, err := db.RegistrationsByDid(s.db, user.Did) 440 if err != nil { 441 log.Println(err) ··· 474 return 475 } 476 477 - memberDid := r.FormValue("member") 478 - if memberDid == "" { 479 http.Error(w, "malformed form", http.StatusBadRequest) 480 return 481 } 482 483 - memberIdent, err := s.resolver.ResolveIdent(r.Context(), memberDid) 484 if err != nil { 485 w.Write([]byte("failed to resolve member did to a handle")) 486 return 487 } 488 - log.Printf("adding %s to %s\n", memberIdent.Handle.String(), domain) 489 490 // announce this relation into the firehose, store into owners' pds 491 - client, _ := s.auth.AuthorizedClient(r) 492 - currentUser := s.auth.GetUser(r) 493 - addedAt := time.Now().Format(time.RFC3339) 494 - resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 495 Collection: tangled.KnotMemberNSID, 496 Repo: currentUser.Did, 497 - Rkey: s.TID(), 498 Record: &lexutil.LexiconTypeDecoder{ 499 Val: &tangled.KnotMember{ 500 - Member: memberIdent.DID.String(), 501 - Domain: domain, 502 - AddedAt: &addedAt, 503 }}, 504 }) 505 ··· 516 return 517 } 518 519 - ksClient, err := NewSignedClient(domain, secret, s.config.Dev) 520 if err != nil { 521 log.Println("failed to create client to ", domain) 522 return 523 } 524 525 - ksResp, err := ksClient.AddMember(memberIdent.DID.String()) 526 if err != nil { 527 log.Printf("failed to make request to %s: %s", domain, err) 528 return ··· 533 return 534 } 535 536 - err = s.enforcer.AddMember(domain, memberIdent.DID.String()) 537 if err != nil { 538 w.Write([]byte(fmt.Sprint("failed to add member: ", err))) 539 return 540 } 541 542 - w.Write([]byte(fmt.Sprint("added member: ", memberIdent.Handle.String()))) 543 } 544 545 func (s *State) RemoveMember(w http.ResponseWriter, r *http.Request) { 546 } 547 548 func (s *State) NewRepo(w http.ResponseWriter, r *http.Request) { 549 switch r.Method { 550 case http.MethodGet: 551 - user := s.auth.GetUser(r) 552 knots, err := s.enforcer.GetDomainsForUser(user.Did) 553 if err != nil { 554 s.pages.Notice(w, "repo", "Invalid user account.") ··· 561 }) 562 563 case http.MethodPost: 564 - user := s.auth.GetUser(r) 565 566 domain := r.FormValue("domain") 567 if domain == "" { ··· 575 return 576 } 577 578 - // Check for valid repository name (GitHub-like rules) 579 - // No spaces, only alphanumeric characters, dashes, and underscores 580 - for _, char := range repoName { 581 - if !((char >= 'a' && char <= 'z') || 582 - (char >= 'A' && char <= 'Z') || 583 - (char >= '0' && char <= '9') || 584 - char == '-' || char == '_' || char == '.') { 585 - s.pages.Notice(w, "repo", "Repository name can only contain alphanumeric characters, periods, hyphens, and underscores.") 586 - return 587 - } 588 } 589 590 defaultBranch := r.FormValue("branch") ··· 612 return 613 } 614 615 - client, err := NewSignedClient(domain, secret, s.config.Dev) 616 if err != nil { 617 s.pages.Notice(w, "repo", "Failed to connect to knot server.") 618 return 619 } 620 621 - rkey := s.TID() 622 repo := &db.Repo{ 623 Did: user.Did, 624 Name: repoName, ··· 627 Description: description, 628 } 629 630 - xrpcClient, _ := s.auth.AuthorizedClient(r) 631 632 - addedAt := time.Now().Format(time.RFC3339) 633 - atresp, err := comatproto.RepoPutRecord(r.Context(), xrpcClient, &comatproto.RepoPutRecord_Input{ 634 Collection: tangled.RepoNSID, 635 Repo: user.Did, 636 Rkey: rkey, 637 Record: &lexutil.LexiconTypeDecoder{ 638 Val: &tangled.Repo{ 639 - Knot: repo.Knot, 640 - Name: repoName, 641 - AddedAt: &addedAt, 642 - Owner: user.Did, 643 }}, 644 }) 645 if err != nil { ··· 714 return 715 } 716 } 717 - 718 - func (s *State) ProfilePage(w http.ResponseWriter, r *http.Request) { 719 - didOrHandle := chi.URLParam(r, "user") 720 - if didOrHandle == "" { 721 - http.Error(w, "Bad request", http.StatusBadRequest) 722 - return 723 - } 724 - 725 - ident, err := s.resolver.ResolveIdent(r.Context(), didOrHandle) 726 - if err != nil { 727 - log.Printf("resolving identity: %s", err) 728 - w.WriteHeader(http.StatusNotFound) 729 - return 730 - } 731 - 732 - repos, err := db.GetAllReposByDid(s.db, ident.DID.String()) 733 - if err != nil { 734 - log.Printf("getting repos for %s: %s", ident.DID.String(), err) 735 - } 736 - 737 - collaboratingRepos, err := db.CollaboratingIn(s.db, ident.DID.String()) 738 - if err != nil { 739 - log.Printf("getting collaborating repos for %s: %s", ident.DID.String(), err) 740 - } 741 - var didsToResolve []string 742 - for _, r := range collaboratingRepos { 743 - didsToResolve = append(didsToResolve, r.Did) 744 - } 745 - resolvedIds := s.resolver.ResolveIdents(r.Context(), didsToResolve) 746 - didHandleMap := make(map[string]string) 747 - for _, identity := range resolvedIds { 748 - if !identity.Handle.IsInvalidHandle() { 749 - didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 750 - } else { 751 - didHandleMap[identity.DID.String()] = identity.DID.String() 752 - } 753 - } 754 - 755 - followers, following, err := db.GetFollowerFollowing(s.db, ident.DID.String()) 756 - if err != nil { 757 - log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err) 758 - } 759 - 760 - loggedInUser := s.auth.GetUser(r) 761 - followStatus := db.IsNotFollowing 762 - if loggedInUser != nil { 763 - followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, ident.DID.String()) 764 - } 765 - 766 - profileAvatarUri, err := GetAvatarUri(ident.Handle.String()) 767 - if err != nil { 768 - log.Println("failed to fetch bsky avatar", err) 769 - } 770 - 771 - s.pages.ProfilePage(w, pages.ProfilePageParams{ 772 - LoggedInUser: loggedInUser, 773 - UserDid: ident.DID.String(), 774 - UserHandle: ident.Handle.String(), 775 - Repos: repos, 776 - CollaboratingRepos: collaboratingRepos, 777 - ProfileStats: pages.ProfileStats{ 778 - Followers: followers, 779 - Following: following, 780 - }, 781 - FollowStatus: db.FollowStatus(followStatus), 782 - DidHandleMap: didHandleMap, 783 - AvatarUri: profileAvatarUri, 784 - }) 785 - } 786 - 787 - func GetAvatarUri(handle string) (string, error) { 788 - return fmt.Sprintf("https://avatars.dog/%s@webp", handle), nil 789 - }
··· 17 lexutil "github.com/bluesky-social/indigo/lex/util" 18 securejoin "github.com/cyphar/filepath-securejoin" 19 "github.com/go-chi/chi/v5" 20 + "tangled.sh/tangled.sh/core/api/tangled" 21 "tangled.sh/tangled.sh/core/appview" 22 "tangled.sh/tangled.sh/core/appview/db" 23 + "tangled.sh/tangled.sh/core/appview/knotclient" 24 + "tangled.sh/tangled.sh/core/appview/oauth" 25 "tangled.sh/tangled.sh/core/appview/pages" 26 "tangled.sh/tangled.sh/core/jetstream" 27 "tangled.sh/tangled.sh/core/rbac" ··· 29 30 type State struct { 31 db *db.DB 32 + oauth *oauth.OAuth 33 enforcer *rbac.Enforcer 34 + tidClock syntax.TIDClock 35 pages *pages.Pages 36 resolver *appview.Resolver 37 jc *jetstream.JetstreamClient ··· 39 } 40 41 func Make(config *appview.Config) (*State, error) { 42 + d, err := db.Make(config.Core.DbPath) 43 if err != nil { 44 return nil, err 45 } 46 47 + enforcer, err := rbac.NewEnforcer(config.Core.DbPath) 48 if err != nil { 49 return nil, err 50 } 51 52 clock := syntax.NewTIDClock(0) 53 54 + pgs := pages.NewPages(config) 55 56 resolver := appview.NewResolver() 57 + 58 + oauth := oauth.NewOAuth(d, config) 59 60 wrapper := db.DbWrapper{d} 61 + jc, err := jetstream.NewJetstreamClient( 62 + config.Jetstream.Endpoint, 63 + "appview", 64 + []string{ 65 + tangled.GraphFollowNSID, 66 + tangled.FeedStarNSID, 67 + tangled.PublicKeyNSID, 68 + tangled.RepoArtifactNSID, 69 + tangled.ActorProfileNSID, 70 + }, 71 + nil, 72 + slog.Default(), 73 + wrapper, 74 + false, 75 + ) 76 if err != nil { 77 return nil, fmt.Errorf("failed to create jetstream client: %w", err) 78 } 79 + err = jc.StartJetstream(context.Background(), appview.Ingest(wrapper, enforcer)) 80 if err != nil { 81 return nil, fmt.Errorf("failed to start jetstream watcher: %w", err) 82 } 83 84 state := &State{ 85 d, 86 + oauth, 87 enforcer, 88 clock, 89 pgs, ··· 95 return state, nil 96 } 97 98 + func TID(c *syntax.TIDClock) string { 99 + return c.Next().String() 100 } 101 102 + // func (s *State) Login(w http.ResponseWriter, r *http.Request) { 103 + // ctx := r.Context() 104 105 + // switch r.Method { 106 + // case http.MethodGet: 107 + // err := s.pages.Login(w, pages.LoginParams{}) 108 + // if err != nil { 109 + // log.Printf("rendering login page: %s", err) 110 + // } 111 112 + // return 113 + // case http.MethodPost: 114 + // handle := strings.TrimPrefix(r.FormValue("handle"), "@") 115 + // appPassword := r.FormValue("app_password") 116 117 + // resolved, err := s.resolver.ResolveIdent(ctx, handle) 118 + // if err != nil { 119 + // log.Println("failed to resolve handle:", err) 120 + // s.pages.Notice(w, "login-msg", fmt.Sprintf("\"%s\" is an invalid handle.", handle)) 121 + // return 122 + // } 123 124 + // atSession, err := s.oauth.CreateInitialSession(ctx, resolved, appPassword) 125 + // if err != nil { 126 + // s.pages.Notice(w, "login-msg", "Invalid handle or password.") 127 + // return 128 + // } 129 + // sessionish := auth.CreateSessionWrapper{ServerCreateSession_Output: atSession} 130 131 + // err = s.oauth.StoreSession(r, w, &sessionish, resolved.PDSEndpoint()) 132 + // if err != nil { 133 + // s.pages.Notice(w, "login-msg", "Failed to login, try again later.") 134 + // return 135 + // } 136 137 + // log.Printf("successfully saved session for %s (%s)", atSession.Handle, atSession.Did) 138 139 + // did := resolved.DID.String() 140 + // defaultKnot := "knot1.tangled.sh" 141 142 + // go func() { 143 + // log.Printf("adding %s to default knot", did) 144 + // err = s.enforcer.AddMember(defaultKnot, did) 145 + // if err != nil { 146 + // log.Println("failed to add user to knot1.tangled.sh: ", err) 147 + // return 148 + // } 149 + // err = s.enforcer.E.SavePolicy() 150 + // if err != nil { 151 + // log.Println("failed to add user to knot1.tangled.sh: ", err) 152 + // return 153 + // } 154 155 + // secret, err := db.GetRegistrationKey(s.db, defaultKnot) 156 + // if err != nil { 157 + // log.Println("failed to get registration key for knot1.tangled.sh") 158 + // return 159 + // } 160 + // signedClient, err := NewSignedClient(defaultKnot, secret, s.config.Core.Dev) 161 + // resp, err := signedClient.AddMember(did) 162 + // if err != nil { 163 + // log.Println("failed to add user to knot1.tangled.sh: ", err) 164 + // return 165 + // } 166 167 + // if resp.StatusCode != http.StatusNoContent { 168 + // log.Println("failed to add user to knot1.tangled.sh: ", resp.StatusCode) 169 + // return 170 + // } 171 + // }() 172 173 + // s.pages.HxRedirect(w, "/") 174 + // return 175 + // } 176 + // } 177 178 func (s *State) Logout(w http.ResponseWriter, r *http.Request) { 179 + s.oauth.ClearSession(r, w) 180 + w.Header().Set("HX-Redirect", "/login") 181 + w.WriteHeader(http.StatusSeeOther) 182 } 183 184 func (s *State) Timeline(w http.ResponseWriter, r *http.Request) { 185 + user := s.oauth.GetUser(r) 186 187 timeline, err := db.MakeTimeline(s.db) 188 if err != nil { ··· 194 for _, ev := range timeline { 195 if ev.Repo != nil { 196 didsToResolve = append(didsToResolve, ev.Repo.Did) 197 + if ev.Source != nil { 198 + didsToResolve = append(didsToResolve, ev.Source.Did) 199 + } 200 } 201 if ev.Follow != nil { 202 didsToResolve = append(didsToResolve, ev.Follow.UserDid, ev.Follow.SubjectDid) ··· 233 234 return 235 case http.MethodPost: 236 + session, err := s.oauth.Store.Get(r, appview.SessionName) 237 if err != nil || session.IsNew { 238 log.Println("unauthorized attempt to generate registration key") 239 http.Error(w, "Forbidden", http.StatusUnauthorized) ··· 295 296 // create a signed request and check if a node responds to that 297 func (s *State) InitKnotServer(w http.ResponseWriter, r *http.Request) { 298 + user := s.oauth.GetUser(r) 299 300 domain := chi.URLParam(r, "domain") 301 if domain == "" { ··· 310 return 311 } 312 313 + client, err := knotclient.NewSignedClient(domain, secret, s.config.Core.Dev) 314 if err != nil { 315 log.Println("failed to create client to ", domain) 316 } ··· 419 return 420 } 421 422 + user := s.oauth.GetUser(r) 423 reg, err := db.RegistrationByDomain(s.db, domain) 424 if err != nil { 425 w.Write([]byte("failed to pull up registration info")) ··· 435 } 436 } 437 438 + var didsToResolve []string 439 + for _, m := range members { 440 + didsToResolve = append(didsToResolve, m) 441 + } 442 + didsToResolve = append(didsToResolve, reg.ByDid) 443 + resolvedIds := s.resolver.ResolveIdents(r.Context(), didsToResolve) 444 + didHandleMap := make(map[string]string) 445 + for _, identity := range resolvedIds { 446 + if !identity.Handle.IsInvalidHandle() { 447 + didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 448 + } else { 449 + didHandleMap[identity.DID.String()] = identity.DID.String() 450 + } 451 + } 452 + 453 ok, err := s.enforcer.IsServerOwner(user.Did, domain) 454 isOwner := err == nil && ok 455 456 p := pages.KnotParams{ 457 LoggedInUser: user, 458 + DidHandleMap: didHandleMap, 459 Registration: reg, 460 Members: members, 461 IsOwner: isOwner, ··· 467 // get knots registered by this user 468 func (s *State) Knots(w http.ResponseWriter, r *http.Request) { 469 // for now, this is just pubkeys 470 + user := s.oauth.GetUser(r) 471 registrations, err := db.RegistrationsByDid(s.db, user.Did) 472 if err != nil { 473 log.Println(err) ··· 506 return 507 } 508 509 + subjectIdentifier := r.FormValue("subject") 510 + if subjectIdentifier == "" { 511 http.Error(w, "malformed form", http.StatusBadRequest) 512 return 513 } 514 515 + subjectIdentity, err := s.resolver.ResolveIdent(r.Context(), subjectIdentifier) 516 if err != nil { 517 w.Write([]byte("failed to resolve member did to a handle")) 518 return 519 } 520 + log.Printf("adding %s to %s\n", subjectIdentity.Handle.String(), domain) 521 522 // announce this relation into the firehose, store into owners' pds 523 + client, err := s.oauth.AuthorizedClient(r) 524 + if err != nil { 525 + http.Error(w, "failed to authorize client", http.StatusInternalServerError) 526 + return 527 + } 528 + currentUser := s.oauth.GetUser(r) 529 + createdAt := time.Now().Format(time.RFC3339) 530 + resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 531 Collection: tangled.KnotMemberNSID, 532 Repo: currentUser.Did, 533 + Rkey: appview.TID(), 534 Record: &lexutil.LexiconTypeDecoder{ 535 Val: &tangled.KnotMember{ 536 + Subject: subjectIdentity.DID.String(), 537 + Domain: domain, 538 + CreatedAt: createdAt, 539 }}, 540 }) 541 ··· 552 return 553 } 554 555 + ksClient, err := knotclient.NewSignedClient(domain, secret, s.config.Core.Dev) 556 if err != nil { 557 log.Println("failed to create client to ", domain) 558 return 559 } 560 561 + ksResp, err := ksClient.AddMember(subjectIdentity.DID.String()) 562 if err != nil { 563 log.Printf("failed to make request to %s: %s", domain, err) 564 return ··· 569 return 570 } 571 572 + err = s.enforcer.AddMember(domain, subjectIdentity.DID.String()) 573 if err != nil { 574 w.Write([]byte(fmt.Sprint("failed to add member: ", err))) 575 return 576 } 577 578 + w.Write([]byte(fmt.Sprint("added member: ", subjectIdentity.Handle.String()))) 579 } 580 581 func (s *State) RemoveMember(w http.ResponseWriter, r *http.Request) { 582 } 583 584 + func validateRepoName(name string) error { 585 + // check for path traversal attempts 586 + if name == "." || name == ".." || 587 + strings.Contains(name, "/") || strings.Contains(name, "\\") { 588 + return fmt.Errorf("Repository name contains invalid path characters") 589 + } 590 + 591 + // check for sequences that could be used for traversal when normalized 592 + if strings.Contains(name, "./") || strings.Contains(name, "../") || 593 + strings.HasPrefix(name, ".") || strings.HasSuffix(name, ".") { 594 + return fmt.Errorf("Repository name contains invalid path sequence") 595 + } 596 + 597 + // then continue with character validation 598 + for _, char := range name { 599 + if !((char >= 'a' && char <= 'z') || 600 + (char >= 'A' && char <= 'Z') || 601 + (char >= '0' && char <= '9') || 602 + char == '-' || char == '_' || char == '.') { 603 + return fmt.Errorf("Repository name can only contain alphanumeric characters, periods, hyphens, and underscores") 604 + } 605 + } 606 + 607 + // additional check to prevent multiple sequential dots 608 + if strings.Contains(name, "..") { 609 + return fmt.Errorf("Repository name cannot contain sequential dots") 610 + } 611 + 612 + // if all checks pass 613 + return nil 614 + } 615 + 616 func (s *State) NewRepo(w http.ResponseWriter, r *http.Request) { 617 switch r.Method { 618 case http.MethodGet: 619 + user := s.oauth.GetUser(r) 620 knots, err := s.enforcer.GetDomainsForUser(user.Did) 621 if err != nil { 622 s.pages.Notice(w, "repo", "Invalid user account.") ··· 629 }) 630 631 case http.MethodPost: 632 + user := s.oauth.GetUser(r) 633 634 domain := r.FormValue("domain") 635 if domain == "" { ··· 643 return 644 } 645 646 + if err := validateRepoName(repoName); err != nil { 647 + s.pages.Notice(w, "repo", err.Error()) 648 + return 649 } 650 651 defaultBranch := r.FormValue("branch") ··· 673 return 674 } 675 676 + client, err := knotclient.NewSignedClient(domain, secret, s.config.Core.Dev) 677 if err != nil { 678 s.pages.Notice(w, "repo", "Failed to connect to knot server.") 679 return 680 } 681 682 + rkey := appview.TID() 683 repo := &db.Repo{ 684 Did: user.Did, 685 Name: repoName, ··· 688 Description: description, 689 } 690 691 + xrpcClient, err := s.oauth.AuthorizedClient(r) 692 + if err != nil { 693 + s.pages.Notice(w, "repo", "Failed to write record to PDS.") 694 + return 695 + } 696 697 + createdAt := time.Now().Format(time.RFC3339) 698 + atresp, err := xrpcClient.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 699 Collection: tangled.RepoNSID, 700 Repo: user.Did, 701 Rkey: rkey, 702 Record: &lexutil.LexiconTypeDecoder{ 703 Val: &tangled.Repo{ 704 + Knot: repo.Knot, 705 + Name: repoName, 706 + CreatedAt: createdAt, 707 + Owner: user.Did, 708 }}, 709 }) 710 if err != nil { ··· 779 return 780 } 781 }
+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 + }
+80
appview/xrpcclient/xrpc.go
···
··· 1 + package xrpcclient 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "io" 7 + 8 + "github.com/bluesky-social/indigo/api/atproto" 9 + "github.com/bluesky-social/indigo/xrpc" 10 + oauth "github.com/haileyok/atproto-oauth-golang" 11 + ) 12 + 13 + type Client struct { 14 + *oauth.XrpcClient 15 + authArgs *oauth.XrpcAuthedRequestArgs 16 + } 17 + 18 + func NewClient(client *oauth.XrpcClient, authArgs *oauth.XrpcAuthedRequestArgs) *Client { 19 + return &Client{ 20 + XrpcClient: client, 21 + authArgs: authArgs, 22 + } 23 + } 24 + 25 + func (c *Client) RepoPutRecord(ctx context.Context, input *atproto.RepoPutRecord_Input) (*atproto.RepoPutRecord_Output, error) { 26 + var out atproto.RepoPutRecord_Output 27 + if err := c.Do(ctx, c.authArgs, xrpc.Procedure, "application/json", "com.atproto.repo.putRecord", nil, input, &out); err != nil { 28 + return nil, err 29 + } 30 + 31 + return &out, nil 32 + } 33 + 34 + func (c *Client) RepoGetRecord(ctx context.Context, cid string, collection string, repo string, rkey string) (*atproto.RepoGetRecord_Output, error) { 35 + var out atproto.RepoGetRecord_Output 36 + 37 + params := map[string]interface{}{ 38 + "cid": cid, 39 + "collection": collection, 40 + "repo": repo, 41 + "rkey": rkey, 42 + } 43 + if err := c.Do(ctx, c.authArgs, xrpc.Query, "", "com.atproto.repo.getRecord", params, nil, &out); err != nil { 44 + return nil, err 45 + } 46 + 47 + return &out, nil 48 + } 49 + 50 + func (c *Client) RepoUploadBlob(ctx context.Context, input io.Reader) (*atproto.RepoUploadBlob_Output, error) { 51 + var out atproto.RepoUploadBlob_Output 52 + if err := c.Do(ctx, c.authArgs, xrpc.Procedure, "*/*", "com.atproto.repo.uploadBlob", nil, input, &out); err != nil { 53 + return nil, err 54 + } 55 + 56 + return &out, nil 57 + } 58 + 59 + func (c *Client) SyncGetBlob(ctx context.Context, cid string, did string) ([]byte, error) { 60 + buf := new(bytes.Buffer) 61 + 62 + params := map[string]interface{}{ 63 + "cid": cid, 64 + "did": did, 65 + } 66 + if err := c.Do(ctx, c.authArgs, xrpc.Query, "", "com.atproto.sync.getBlob", params, nil, buf); err != nil { 67 + return nil, err 68 + } 69 + 70 + return buf.Bytes(), nil 71 + } 72 + 73 + func (c *Client) RepoDeleteRecord(ctx context.Context, input *atproto.RepoDeleteRecord_Input) (*atproto.RepoDeleteRecord_Output, error) { 74 + var out atproto.RepoDeleteRecord_Output 75 + if err := c.Do(ctx, c.authArgs, xrpc.Procedure, "application/json", "com.atproto.repo.deleteRecord", nil, input, &out); err != nil { 76 + return nil, err 77 + } 78 + 79 + return &out, nil 80 + }
+172
avatar/.gitignore
···
··· 1 + # Logs 2 + 3 + logs 4 + _.log 5 + npm-debug.log_ 6 + yarn-debug.log* 7 + yarn-error.log* 8 + lerna-debug.log* 9 + .pnpm-debug.log* 10 + 11 + # Diagnostic reports (https://nodejs.org/api/report.html) 12 + 13 + report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json 14 + 15 + # Runtime data 16 + 17 + pids 18 + _.pid 19 + _.seed 20 + \*.pid.lock 21 + 22 + # Directory for instrumented libs generated by jscoverage/JSCover 23 + 24 + lib-cov 25 + 26 + # Coverage directory used by tools like istanbul 27 + 28 + coverage 29 + \*.lcov 30 + 31 + # nyc test coverage 32 + 33 + .nyc_output 34 + 35 + # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 36 + 37 + .grunt 38 + 39 + # Bower dependency directory (https://bower.io/) 40 + 41 + bower_components 42 + 43 + # node-waf configuration 44 + 45 + .lock-wscript 46 + 47 + # Compiled binary addons (https://nodejs.org/api/addons.html) 48 + 49 + build/Release 50 + 51 + # Dependency directories 52 + 53 + node_modules/ 54 + jspm_packages/ 55 + 56 + # Snowpack dependency directory (https://snowpack.dev/) 57 + 58 + web_modules/ 59 + 60 + # TypeScript cache 61 + 62 + \*.tsbuildinfo 63 + 64 + # Optional npm cache directory 65 + 66 + .npm 67 + 68 + # Optional eslint cache 69 + 70 + .eslintcache 71 + 72 + # Optional stylelint cache 73 + 74 + .stylelintcache 75 + 76 + # Microbundle cache 77 + 78 + .rpt2_cache/ 79 + .rts2_cache_cjs/ 80 + .rts2_cache_es/ 81 + .rts2_cache_umd/ 82 + 83 + # Optional REPL history 84 + 85 + .node_repl_history 86 + 87 + # Output of 'npm pack' 88 + 89 + \*.tgz 90 + 91 + # Yarn Integrity file 92 + 93 + .yarn-integrity 94 + 95 + # dotenv environment variable files 96 + 97 + .env 98 + .env.development.local 99 + .env.test.local 100 + .env.production.local 101 + .env.local 102 + 103 + # parcel-bundler cache (https://parceljs.org/) 104 + 105 + .cache 106 + .parcel-cache 107 + 108 + # Next.js build output 109 + 110 + .next 111 + out 112 + 113 + # Nuxt.js build / generate output 114 + 115 + .nuxt 116 + dist 117 + 118 + # Gatsby files 119 + 120 + .cache/ 121 + 122 + # Comment in the public line in if your project uses Gatsby and not Next.js 123 + 124 + # https://nextjs.org/blog/next-9-1#public-directory-support 125 + 126 + # public 127 + 128 + # vuepress build output 129 + 130 + .vuepress/dist 131 + 132 + # vuepress v2.x temp and cache directory 133 + 134 + .temp 135 + .cache 136 + 137 + # Docusaurus cache and generated files 138 + 139 + .docusaurus 140 + 141 + # Serverless directories 142 + 143 + .serverless/ 144 + 145 + # FuseBox cache 146 + 147 + .fusebox/ 148 + 149 + # DynamoDB Local files 150 + 151 + .dynamodb/ 152 + 153 + # TernJS port file 154 + 155 + .tern-port 156 + 157 + # Stores VSCode versions used for testing VSCode extensions 158 + 159 + .vscode-test 160 + 161 + # yarn v2 162 + 163 + .yarn/cache 164 + .yarn/unplugged 165 + .yarn/build-state.yml 166 + .yarn/install-state.gz 167 + .pnp.\* 168 + 169 + # wrangler project 170 + 171 + .dev.vars 172 + .wrangler/
+3024
avatar/package-lock.json
···
··· 1 + { 2 + "name": "avatar", 3 + "version": "0.0.0", 4 + "lockfileVersion": 3, 5 + "requires": true, 6 + "packages": { 7 + "": { 8 + "name": "avatar", 9 + "version": "0.0.0", 10 + "devDependencies": { 11 + "@cloudflare/vitest-pool-workers": "^0.8.19", 12 + "vitest": "~3.0.7", 13 + "wrangler": "^4.14.1" 14 + } 15 + }, 16 + "node_modules/@cloudflare/kv-asset-handler": { 17 + "version": "0.4.0", 18 + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.4.0.tgz", 19 + "integrity": "sha512-+tv3z+SPp+gqTIcImN9o0hqE9xyfQjI1XD9pL6NuKjua9B1y7mNYv0S9cP+QEbA4ppVgGZEmKOvHX5G5Ei1CVA==", 20 + "dev": true, 21 + "license": "MIT OR Apache-2.0", 22 + "dependencies": { 23 + "mime": "^3.0.0" 24 + }, 25 + "engines": { 26 + "node": ">=18.0.0" 27 + } 28 + }, 29 + "node_modules/@cloudflare/unenv-preset": { 30 + "version": "2.3.1", 31 + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.3.1.tgz", 32 + "integrity": "sha512-Xq57Qd+ADpt6hibcVBO0uLG9zzRgyRhfCUgBT9s+g3+3Ivg5zDyVgLFy40ES1VdNcu8rPNSivm9A+kGP5IVaPg==", 33 + "dev": true, 34 + "license": "MIT OR Apache-2.0", 35 + "peerDependencies": { 36 + "unenv": "2.0.0-rc.15", 37 + "workerd": "^1.20250320.0" 38 + }, 39 + "peerDependenciesMeta": { 40 + "workerd": { 41 + "optional": true 42 + } 43 + } 44 + }, 45 + "node_modules/@cloudflare/vitest-pool-workers": { 46 + "version": "0.8.24", 47 + "resolved": "https://registry.npmjs.org/@cloudflare/vitest-pool-workers/-/vitest-pool-workers-0.8.24.tgz", 48 + "integrity": "sha512-wT2PABJQ9YLYWrVu4CRZOjvmjHkdbMyLTZPU9n/7JEMM3pgG8dY41F1Rj31UsXRQaXX39A/CTPGlk58dcMUysA==", 49 + "dev": true, 50 + "license": "MIT", 51 + "dependencies": { 52 + "birpc": "0.2.14", 53 + "cjs-module-lexer": "^1.2.3", 54 + "devalue": "^4.3.0", 55 + "miniflare": "4.20250428.1", 56 + "semver": "^7.7.1", 57 + "wrangler": "4.14.1", 58 + "zod": "^3.22.3" 59 + }, 60 + "peerDependencies": { 61 + "@vitest/runner": "2.0.x - 3.1.x", 62 + "@vitest/snapshot": "2.0.x - 3.1.x", 63 + "vitest": "2.0.x - 3.1.x" 64 + } 65 + }, 66 + "node_modules/@cloudflare/workerd-darwin-64": { 67 + "version": "1.20250428.0", 68 + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20250428.0.tgz", 69 + "integrity": "sha512-6nVe9oV4Hdec6ctzMtW80TiDvNTd2oFPi3VsKqSDVaJSJbL+4b6seyJ7G/UEPI+si6JhHBSLV2/9lNXNGLjClA==", 70 + "cpu": [ 71 + "x64" 72 + ], 73 + "dev": true, 74 + "license": "Apache-2.0", 75 + "optional": true, 76 + "os": [ 77 + "darwin" 78 + ], 79 + "engines": { 80 + "node": ">=16" 81 + } 82 + }, 83 + "node_modules/@cloudflare/workerd-darwin-arm64": { 84 + "version": "1.20250428.0", 85 + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20250428.0.tgz", 86 + "integrity": "sha512-/TB7bh7SIJ5f+6r4PHsAz7+9Qal/TK1cJuKFkUno1kqGlZbdrMwH0ATYwlWC/nBFeu2FB3NUolsTntEuy23hnQ==", 87 + "cpu": [ 88 + "arm64" 89 + ], 90 + "dev": true, 91 + "license": "Apache-2.0", 92 + "optional": true, 93 + "os": [ 94 + "darwin" 95 + ], 96 + "engines": { 97 + "node": ">=16" 98 + } 99 + }, 100 + "node_modules/@cloudflare/workerd-linux-64": { 101 + "version": "1.20250428.0", 102 + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20250428.0.tgz", 103 + "integrity": "sha512-9eCbj+R3CKqpiXP6DfAA20DxKge+OTj7Hyw3ZewiEhWH9INIHiJwJQYybu4iq9kJEGjnGvxgguLFjSCWm26hgg==", 104 + "cpu": [ 105 + "x64" 106 + ], 107 + "dev": true, 108 + "license": "Apache-2.0", 109 + "optional": true, 110 + "os": [ 111 + "linux" 112 + ], 113 + "engines": { 114 + "node": ">=16" 115 + } 116 + }, 117 + "node_modules/@cloudflare/workerd-linux-arm64": { 118 + "version": "1.20250428.0", 119 + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20250428.0.tgz", 120 + "integrity": "sha512-D9NRBnW46nl1EQsP13qfkYb5lbt4C6nxl38SBKY/NOcZAUoHzNB5K0GaK8LxvpkM7X/97ySojlMfR5jh5DNXYQ==", 121 + "cpu": [ 122 + "arm64" 123 + ], 124 + "dev": true, 125 + "license": "Apache-2.0", 126 + "optional": true, 127 + "os": [ 128 + "linux" 129 + ], 130 + "engines": { 131 + "node": ">=16" 132 + } 133 + }, 134 + "node_modules/@cloudflare/workerd-windows-64": { 135 + "version": "1.20250428.0", 136 + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20250428.0.tgz", 137 + "integrity": "sha512-RQCRj28eitjKD0tmei6iFOuWqMuHMHdNGEigRmbkmuTlpbWHNAoHikgCzZQ/dkKDdatA76TmcpbyECNf31oaTA==", 138 + "cpu": [ 139 + "x64" 140 + ], 141 + "dev": true, 142 + "license": "Apache-2.0", 143 + "optional": true, 144 + "os": [ 145 + "win32" 146 + ], 147 + "engines": { 148 + "node": ">=16" 149 + } 150 + }, 151 + "node_modules/@cspotcode/source-map-support": { 152 + "version": "0.8.1", 153 + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", 154 + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", 155 + "dev": true, 156 + "license": "MIT", 157 + "dependencies": { 158 + "@jridgewell/trace-mapping": "0.3.9" 159 + }, 160 + "engines": { 161 + "node": ">=12" 162 + } 163 + }, 164 + "node_modules/@emnapi/runtime": { 165 + "version": "1.4.3", 166 + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.3.tgz", 167 + "integrity": "sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ==", 168 + "dev": true, 169 + "license": "MIT", 170 + "optional": true, 171 + "dependencies": { 172 + "tslib": "^2.4.0" 173 + } 174 + }, 175 + "node_modules/@esbuild/aix-ppc64": { 176 + "version": "0.25.3", 177 + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.3.tgz", 178 + "integrity": "sha512-W8bFfPA8DowP8l//sxjJLSLkD8iEjMc7cBVyP+u4cEv9sM7mdUCkgsj+t0n/BWPFtv7WWCN5Yzj0N6FJNUUqBQ==", 179 + "cpu": [ 180 + "ppc64" 181 + ], 182 + "dev": true, 183 + "license": "MIT", 184 + "optional": true, 185 + "os": [ 186 + "aix" 187 + ], 188 + "engines": { 189 + "node": ">=18" 190 + } 191 + }, 192 + "node_modules/@esbuild/android-arm": { 193 + "version": "0.25.3", 194 + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.3.tgz", 195 + "integrity": "sha512-PuwVXbnP87Tcff5I9ngV0lmiSu40xw1At6i3GsU77U7cjDDB4s0X2cyFuBiDa1SBk9DnvWwnGvVaGBqoFWPb7A==", 196 + "cpu": [ 197 + "arm" 198 + ], 199 + "dev": true, 200 + "license": "MIT", 201 + "optional": true, 202 + "os": [ 203 + "android" 204 + ], 205 + "engines": { 206 + "node": ">=18" 207 + } 208 + }, 209 + "node_modules/@esbuild/android-arm64": { 210 + "version": "0.25.3", 211 + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.3.tgz", 212 + "integrity": "sha512-XelR6MzjlZuBM4f5z2IQHK6LkK34Cvv6Rj2EntER3lwCBFdg6h2lKbtRjpTTsdEjD/WSe1q8UyPBXP1x3i/wYQ==", 213 + "cpu": [ 214 + "arm64" 215 + ], 216 + "dev": true, 217 + "license": "MIT", 218 + "optional": true, 219 + "os": [ 220 + "android" 221 + ], 222 + "engines": { 223 + "node": ">=18" 224 + } 225 + }, 226 + "node_modules/@esbuild/android-x64": { 227 + "version": "0.25.3", 228 + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.3.tgz", 229 + "integrity": "sha512-ogtTpYHT/g1GWS/zKM0cc/tIebFjm1F9Aw1boQ2Y0eUQ+J89d0jFY//s9ei9jVIlkYi8AfOjiixcLJSGNSOAdQ==", 230 + "cpu": [ 231 + "x64" 232 + ], 233 + "dev": true, 234 + "license": "MIT", 235 + "optional": true, 236 + "os": [ 237 + "android" 238 + ], 239 + "engines": { 240 + "node": ">=18" 241 + } 242 + }, 243 + "node_modules/@esbuild/darwin-arm64": { 244 + "version": "0.25.3", 245 + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.3.tgz", 246 + "integrity": "sha512-eESK5yfPNTqpAmDfFWNsOhmIOaQA59tAcF/EfYvo5/QWQCzXn5iUSOnqt3ra3UdzBv073ykTtmeLJZGt3HhA+w==", 247 + "cpu": [ 248 + "arm64" 249 + ], 250 + "dev": true, 251 + "license": "MIT", 252 + "optional": true, 253 + "os": [ 254 + "darwin" 255 + ], 256 + "engines": { 257 + "node": ">=18" 258 + } 259 + }, 260 + "node_modules/@esbuild/darwin-x64": { 261 + "version": "0.25.3", 262 + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.3.tgz", 263 + "integrity": "sha512-Kd8glo7sIZtwOLcPbW0yLpKmBNWMANZhrC1r6K++uDR2zyzb6AeOYtI6udbtabmQpFaxJ8uduXMAo1gs5ozz8A==", 264 + "cpu": [ 265 + "x64" 266 + ], 267 + "dev": true, 268 + "license": "MIT", 269 + "optional": true, 270 + "os": [ 271 + "darwin" 272 + ], 273 + "engines": { 274 + "node": ">=18" 275 + } 276 + }, 277 + "node_modules/@esbuild/freebsd-arm64": { 278 + "version": "0.25.3", 279 + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.3.tgz", 280 + "integrity": "sha512-EJiyS70BYybOBpJth3M0KLOus0n+RRMKTYzhYhFeMwp7e/RaajXvP+BWlmEXNk6uk+KAu46j/kaQzr6au+JcIw==", 281 + "cpu": [ 282 + "arm64" 283 + ], 284 + "dev": true, 285 + "license": "MIT", 286 + "optional": true, 287 + "os": [ 288 + "freebsd" 289 + ], 290 + "engines": { 291 + "node": ">=18" 292 + } 293 + }, 294 + "node_modules/@esbuild/freebsd-x64": { 295 + "version": "0.25.3", 296 + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.3.tgz", 297 + "integrity": "sha512-Q+wSjaLpGxYf7zC0kL0nDlhsfuFkoN+EXrx2KSB33RhinWzejOd6AvgmP5JbkgXKmjhmpfgKZq24pneodYqE8Q==", 298 + "cpu": [ 299 + "x64" 300 + ], 301 + "dev": true, 302 + "license": "MIT", 303 + "optional": true, 304 + "os": [ 305 + "freebsd" 306 + ], 307 + "engines": { 308 + "node": ">=18" 309 + } 310 + }, 311 + "node_modules/@esbuild/linux-arm": { 312 + "version": "0.25.3", 313 + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.3.tgz", 314 + "integrity": "sha512-dUOVmAUzuHy2ZOKIHIKHCm58HKzFqd+puLaS424h6I85GlSDRZIA5ycBixb3mFgM0Jdh+ZOSB6KptX30DD8YOQ==", 315 + "cpu": [ 316 + "arm" 317 + ], 318 + "dev": true, 319 + "license": "MIT", 320 + "optional": true, 321 + "os": [ 322 + "linux" 323 + ], 324 + "engines": { 325 + "node": ">=18" 326 + } 327 + }, 328 + "node_modules/@esbuild/linux-arm64": { 329 + "version": "0.25.3", 330 + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.3.tgz", 331 + "integrity": "sha512-xCUgnNYhRD5bb1C1nqrDV1PfkwgbswTTBRbAd8aH5PhYzikdf/ddtsYyMXFfGSsb/6t6QaPSzxtbfAZr9uox4A==", 332 + "cpu": [ 333 + "arm64" 334 + ], 335 + "dev": true, 336 + "license": "MIT", 337 + "optional": true, 338 + "os": [ 339 + "linux" 340 + ], 341 + "engines": { 342 + "node": ">=18" 343 + } 344 + }, 345 + "node_modules/@esbuild/linux-ia32": { 346 + "version": "0.25.3", 347 + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.3.tgz", 348 + "integrity": "sha512-yplPOpczHOO4jTYKmuYuANI3WhvIPSVANGcNUeMlxH4twz/TeXuzEP41tGKNGWJjuMhotpGabeFYGAOU2ummBw==", 349 + "cpu": [ 350 + "ia32" 351 + ], 352 + "dev": true, 353 + "license": "MIT", 354 + "optional": true, 355 + "os": [ 356 + "linux" 357 + ], 358 + "engines": { 359 + "node": ">=18" 360 + } 361 + }, 362 + "node_modules/@esbuild/linux-loong64": { 363 + "version": "0.25.3", 364 + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.3.tgz", 365 + "integrity": "sha512-P4BLP5/fjyihmXCELRGrLd793q/lBtKMQl8ARGpDxgzgIKJDRJ/u4r1A/HgpBpKpKZelGct2PGI4T+axcedf6g==", 366 + "cpu": [ 367 + "loong64" 368 + ], 369 + "dev": true, 370 + "license": "MIT", 371 + "optional": true, 372 + "os": [ 373 + "linux" 374 + ], 375 + "engines": { 376 + "node": ">=18" 377 + } 378 + }, 379 + "node_modules/@esbuild/linux-mips64el": { 380 + "version": "0.25.3", 381 + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.3.tgz", 382 + "integrity": "sha512-eRAOV2ODpu6P5divMEMa26RRqb2yUoYsuQQOuFUexUoQndm4MdpXXDBbUoKIc0iPa4aCO7gIhtnYomkn2x+bag==", 383 + "cpu": [ 384 + "mips64el" 385 + ], 386 + "dev": true, 387 + "license": "MIT", 388 + "optional": true, 389 + "os": [ 390 + "linux" 391 + ], 392 + "engines": { 393 + "node": ">=18" 394 + } 395 + }, 396 + "node_modules/@esbuild/linux-ppc64": { 397 + "version": "0.25.3", 398 + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.3.tgz", 399 + "integrity": "sha512-ZC4jV2p7VbzTlnl8nZKLcBkfzIf4Yad1SJM4ZMKYnJqZFD4rTI+pBG65u8ev4jk3/MPwY9DvGn50wi3uhdaghg==", 400 + "cpu": [ 401 + "ppc64" 402 + ], 403 + "dev": true, 404 + "license": "MIT", 405 + "optional": true, 406 + "os": [ 407 + "linux" 408 + ], 409 + "engines": { 410 + "node": ">=18" 411 + } 412 + }, 413 + "node_modules/@esbuild/linux-riscv64": { 414 + "version": "0.25.3", 415 + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.3.tgz", 416 + "integrity": "sha512-LDDODcFzNtECTrUUbVCs6j9/bDVqy7DDRsuIXJg6so+mFksgwG7ZVnTruYi5V+z3eE5y+BJZw7VvUadkbfg7QA==", 417 + "cpu": [ 418 + "riscv64" 419 + ], 420 + "dev": true, 421 + "license": "MIT", 422 + "optional": true, 423 + "os": [ 424 + "linux" 425 + ], 426 + "engines": { 427 + "node": ">=18" 428 + } 429 + }, 430 + "node_modules/@esbuild/linux-s390x": { 431 + "version": "0.25.3", 432 + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.3.tgz", 433 + "integrity": "sha512-s+w/NOY2k0yC2p9SLen+ymflgcpRkvwwa02fqmAwhBRI3SC12uiS10edHHXlVWwfAagYSY5UpmT/zISXPMW3tQ==", 434 + "cpu": [ 435 + "s390x" 436 + ], 437 + "dev": true, 438 + "license": "MIT", 439 + "optional": true, 440 + "os": [ 441 + "linux" 442 + ], 443 + "engines": { 444 + "node": ">=18" 445 + } 446 + }, 447 + "node_modules/@esbuild/linux-x64": { 448 + "version": "0.25.3", 449 + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.3.tgz", 450 + "integrity": "sha512-nQHDz4pXjSDC6UfOE1Fw9Q8d6GCAd9KdvMZpfVGWSJztYCarRgSDfOVBY5xwhQXseiyxapkiSJi/5/ja8mRFFA==", 451 + "cpu": [ 452 + "x64" 453 + ], 454 + "dev": true, 455 + "license": "MIT", 456 + "optional": true, 457 + "os": [ 458 + "linux" 459 + ], 460 + "engines": { 461 + "node": ">=18" 462 + } 463 + }, 464 + "node_modules/@esbuild/netbsd-arm64": { 465 + "version": "0.25.3", 466 + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.3.tgz", 467 + "integrity": "sha512-1QaLtOWq0mzK6tzzp0jRN3eccmN3hezey7mhLnzC6oNlJoUJz4nym5ZD7mDnS/LZQgkrhEbEiTn515lPeLpgWA==", 468 + "cpu": [ 469 + "arm64" 470 + ], 471 + "dev": true, 472 + "license": "MIT", 473 + "optional": true, 474 + "os": [ 475 + "netbsd" 476 + ], 477 + "engines": { 478 + "node": ">=18" 479 + } 480 + }, 481 + "node_modules/@esbuild/netbsd-x64": { 482 + "version": "0.25.3", 483 + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.3.tgz", 484 + "integrity": "sha512-i5Hm68HXHdgv8wkrt+10Bc50zM0/eonPb/a/OFVfB6Qvpiirco5gBA5bz7S2SHuU+Y4LWn/zehzNX14Sp4r27g==", 485 + "cpu": [ 486 + "x64" 487 + ], 488 + "dev": true, 489 + "license": "MIT", 490 + "optional": true, 491 + "os": [ 492 + "netbsd" 493 + ], 494 + "engines": { 495 + "node": ">=18" 496 + } 497 + }, 498 + "node_modules/@esbuild/openbsd-arm64": { 499 + "version": "0.25.3", 500 + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.3.tgz", 501 + "integrity": "sha512-zGAVApJEYTbOC6H/3QBr2mq3upG/LBEXr85/pTtKiv2IXcgKV0RT0QA/hSXZqSvLEpXeIxah7LczB4lkiYhTAQ==", 502 + "cpu": [ 503 + "arm64" 504 + ], 505 + "dev": true, 506 + "license": "MIT", 507 + "optional": true, 508 + "os": [ 509 + "openbsd" 510 + ], 511 + "engines": { 512 + "node": ">=18" 513 + } 514 + }, 515 + "node_modules/@esbuild/openbsd-x64": { 516 + "version": "0.25.3", 517 + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.3.tgz", 518 + "integrity": "sha512-fpqctI45NnCIDKBH5AXQBsD0NDPbEFczK98hk/aa6HJxbl+UtLkJV2+Bvy5hLSLk3LHmqt0NTkKNso1A9y1a4w==", 519 + "cpu": [ 520 + "x64" 521 + ], 522 + "dev": true, 523 + "license": "MIT", 524 + "optional": true, 525 + "os": [ 526 + "openbsd" 527 + ], 528 + "engines": { 529 + "node": ">=18" 530 + } 531 + }, 532 + "node_modules/@esbuild/sunos-x64": { 533 + "version": "0.25.3", 534 + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.3.tgz", 535 + "integrity": "sha512-ROJhm7d8bk9dMCUZjkS8fgzsPAZEjtRJqCAmVgB0gMrvG7hfmPmz9k1rwO4jSiblFjYmNvbECL9uhaPzONMfgA==", 536 + "cpu": [ 537 + "x64" 538 + ], 539 + "dev": true, 540 + "license": "MIT", 541 + "optional": true, 542 + "os": [ 543 + "sunos" 544 + ], 545 + "engines": { 546 + "node": ">=18" 547 + } 548 + }, 549 + "node_modules/@esbuild/win32-arm64": { 550 + "version": "0.25.3", 551 + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.3.tgz", 552 + "integrity": "sha512-YWcow8peiHpNBiIXHwaswPnAXLsLVygFwCB3A7Bh5jRkIBFWHGmNQ48AlX4xDvQNoMZlPYzjVOQDYEzWCqufMQ==", 553 + "cpu": [ 554 + "arm64" 555 + ], 556 + "dev": true, 557 + "license": "MIT", 558 + "optional": true, 559 + "os": [ 560 + "win32" 561 + ], 562 + "engines": { 563 + "node": ">=18" 564 + } 565 + }, 566 + "node_modules/@esbuild/win32-ia32": { 567 + "version": "0.25.3", 568 + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.3.tgz", 569 + "integrity": "sha512-qspTZOIGoXVS4DpNqUYUs9UxVb04khS1Degaw/MnfMe7goQ3lTfQ13Vw4qY/Nj0979BGvMRpAYbs/BAxEvU8ew==", 570 + "cpu": [ 571 + "ia32" 572 + ], 573 + "dev": true, 574 + "license": "MIT", 575 + "optional": true, 576 + "os": [ 577 + "win32" 578 + ], 579 + "engines": { 580 + "node": ">=18" 581 + } 582 + }, 583 + "node_modules/@esbuild/win32-x64": { 584 + "version": "0.25.3", 585 + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.3.tgz", 586 + "integrity": "sha512-ICgUR+kPimx0vvRzf+N/7L7tVSQeE3BYY+NhHRHXS1kBuPO7z2+7ea2HbhDyZdTephgvNvKrlDDKUexuCVBVvg==", 587 + "cpu": [ 588 + "x64" 589 + ], 590 + "dev": true, 591 + "license": "MIT", 592 + "optional": true, 593 + "os": [ 594 + "win32" 595 + ], 596 + "engines": { 597 + "node": ">=18" 598 + } 599 + }, 600 + "node_modules/@fastify/busboy": { 601 + "version": "2.1.1", 602 + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", 603 + "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", 604 + "dev": true, 605 + "license": "MIT", 606 + "engines": { 607 + "node": ">=14" 608 + } 609 + }, 610 + "node_modules/@img/sharp-darwin-arm64": { 611 + "version": "0.33.5", 612 + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", 613 + "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", 614 + "cpu": [ 615 + "arm64" 616 + ], 617 + "dev": true, 618 + "license": "Apache-2.0", 619 + "optional": true, 620 + "os": [ 621 + "darwin" 622 + ], 623 + "engines": { 624 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 625 + }, 626 + "funding": { 627 + "url": "https://opencollective.com/libvips" 628 + }, 629 + "optionalDependencies": { 630 + "@img/sharp-libvips-darwin-arm64": "1.0.4" 631 + } 632 + }, 633 + "node_modules/@img/sharp-darwin-x64": { 634 + "version": "0.33.5", 635 + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", 636 + "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", 637 + "cpu": [ 638 + "x64" 639 + ], 640 + "dev": true, 641 + "license": "Apache-2.0", 642 + "optional": true, 643 + "os": [ 644 + "darwin" 645 + ], 646 + "engines": { 647 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 648 + }, 649 + "funding": { 650 + "url": "https://opencollective.com/libvips" 651 + }, 652 + "optionalDependencies": { 653 + "@img/sharp-libvips-darwin-x64": "1.0.4" 654 + } 655 + }, 656 + "node_modules/@img/sharp-libvips-darwin-arm64": { 657 + "version": "1.0.4", 658 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", 659 + "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", 660 + "cpu": [ 661 + "arm64" 662 + ], 663 + "dev": true, 664 + "license": "LGPL-3.0-or-later", 665 + "optional": true, 666 + "os": [ 667 + "darwin" 668 + ], 669 + "funding": { 670 + "url": "https://opencollective.com/libvips" 671 + } 672 + }, 673 + "node_modules/@img/sharp-libvips-darwin-x64": { 674 + "version": "1.0.4", 675 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", 676 + "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", 677 + "cpu": [ 678 + "x64" 679 + ], 680 + "dev": true, 681 + "license": "LGPL-3.0-or-later", 682 + "optional": true, 683 + "os": [ 684 + "darwin" 685 + ], 686 + "funding": { 687 + "url": "https://opencollective.com/libvips" 688 + } 689 + }, 690 + "node_modules/@img/sharp-libvips-linux-arm": { 691 + "version": "1.0.5", 692 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", 693 + "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", 694 + "cpu": [ 695 + "arm" 696 + ], 697 + "dev": true, 698 + "license": "LGPL-3.0-or-later", 699 + "optional": true, 700 + "os": [ 701 + "linux" 702 + ], 703 + "funding": { 704 + "url": "https://opencollective.com/libvips" 705 + } 706 + }, 707 + "node_modules/@img/sharp-libvips-linux-arm64": { 708 + "version": "1.0.4", 709 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", 710 + "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", 711 + "cpu": [ 712 + "arm64" 713 + ], 714 + "dev": true, 715 + "license": "LGPL-3.0-or-later", 716 + "optional": true, 717 + "os": [ 718 + "linux" 719 + ], 720 + "funding": { 721 + "url": "https://opencollective.com/libvips" 722 + } 723 + }, 724 + "node_modules/@img/sharp-libvips-linux-s390x": { 725 + "version": "1.0.4", 726 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", 727 + "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", 728 + "cpu": [ 729 + "s390x" 730 + ], 731 + "dev": true, 732 + "license": "LGPL-3.0-or-later", 733 + "optional": true, 734 + "os": [ 735 + "linux" 736 + ], 737 + "funding": { 738 + "url": "https://opencollective.com/libvips" 739 + } 740 + }, 741 + "node_modules/@img/sharp-libvips-linux-x64": { 742 + "version": "1.0.4", 743 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", 744 + "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", 745 + "cpu": [ 746 + "x64" 747 + ], 748 + "dev": true, 749 + "license": "LGPL-3.0-or-later", 750 + "optional": true, 751 + "os": [ 752 + "linux" 753 + ], 754 + "funding": { 755 + "url": "https://opencollective.com/libvips" 756 + } 757 + }, 758 + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { 759 + "version": "1.0.4", 760 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", 761 + "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", 762 + "cpu": [ 763 + "arm64" 764 + ], 765 + "dev": true, 766 + "license": "LGPL-3.0-or-later", 767 + "optional": true, 768 + "os": [ 769 + "linux" 770 + ], 771 + "funding": { 772 + "url": "https://opencollective.com/libvips" 773 + } 774 + }, 775 + "node_modules/@img/sharp-libvips-linuxmusl-x64": { 776 + "version": "1.0.4", 777 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", 778 + "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", 779 + "cpu": [ 780 + "x64" 781 + ], 782 + "dev": true, 783 + "license": "LGPL-3.0-or-later", 784 + "optional": true, 785 + "os": [ 786 + "linux" 787 + ], 788 + "funding": { 789 + "url": "https://opencollective.com/libvips" 790 + } 791 + }, 792 + "node_modules/@img/sharp-linux-arm": { 793 + "version": "0.33.5", 794 + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", 795 + "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", 796 + "cpu": [ 797 + "arm" 798 + ], 799 + "dev": true, 800 + "license": "Apache-2.0", 801 + "optional": true, 802 + "os": [ 803 + "linux" 804 + ], 805 + "engines": { 806 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 807 + }, 808 + "funding": { 809 + "url": "https://opencollective.com/libvips" 810 + }, 811 + "optionalDependencies": { 812 + "@img/sharp-libvips-linux-arm": "1.0.5" 813 + } 814 + }, 815 + "node_modules/@img/sharp-linux-arm64": { 816 + "version": "0.33.5", 817 + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", 818 + "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", 819 + "cpu": [ 820 + "arm64" 821 + ], 822 + "dev": true, 823 + "license": "Apache-2.0", 824 + "optional": true, 825 + "os": [ 826 + "linux" 827 + ], 828 + "engines": { 829 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 830 + }, 831 + "funding": { 832 + "url": "https://opencollective.com/libvips" 833 + }, 834 + "optionalDependencies": { 835 + "@img/sharp-libvips-linux-arm64": "1.0.4" 836 + } 837 + }, 838 + "node_modules/@img/sharp-linux-s390x": { 839 + "version": "0.33.5", 840 + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", 841 + "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", 842 + "cpu": [ 843 + "s390x" 844 + ], 845 + "dev": true, 846 + "license": "Apache-2.0", 847 + "optional": true, 848 + "os": [ 849 + "linux" 850 + ], 851 + "engines": { 852 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 853 + }, 854 + "funding": { 855 + "url": "https://opencollective.com/libvips" 856 + }, 857 + "optionalDependencies": { 858 + "@img/sharp-libvips-linux-s390x": "1.0.4" 859 + } 860 + }, 861 + "node_modules/@img/sharp-linux-x64": { 862 + "version": "0.33.5", 863 + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", 864 + "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", 865 + "cpu": [ 866 + "x64" 867 + ], 868 + "dev": true, 869 + "license": "Apache-2.0", 870 + "optional": true, 871 + "os": [ 872 + "linux" 873 + ], 874 + "engines": { 875 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 876 + }, 877 + "funding": { 878 + "url": "https://opencollective.com/libvips" 879 + }, 880 + "optionalDependencies": { 881 + "@img/sharp-libvips-linux-x64": "1.0.4" 882 + } 883 + }, 884 + "node_modules/@img/sharp-linuxmusl-arm64": { 885 + "version": "0.33.5", 886 + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", 887 + "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", 888 + "cpu": [ 889 + "arm64" 890 + ], 891 + "dev": true, 892 + "license": "Apache-2.0", 893 + "optional": true, 894 + "os": [ 895 + "linux" 896 + ], 897 + "engines": { 898 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 899 + }, 900 + "funding": { 901 + "url": "https://opencollective.com/libvips" 902 + }, 903 + "optionalDependencies": { 904 + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" 905 + } 906 + }, 907 + "node_modules/@img/sharp-linuxmusl-x64": { 908 + "version": "0.33.5", 909 + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", 910 + "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", 911 + "cpu": [ 912 + "x64" 913 + ], 914 + "dev": true, 915 + "license": "Apache-2.0", 916 + "optional": true, 917 + "os": [ 918 + "linux" 919 + ], 920 + "engines": { 921 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 922 + }, 923 + "funding": { 924 + "url": "https://opencollective.com/libvips" 925 + }, 926 + "optionalDependencies": { 927 + "@img/sharp-libvips-linuxmusl-x64": "1.0.4" 928 + } 929 + }, 930 + "node_modules/@img/sharp-wasm32": { 931 + "version": "0.33.5", 932 + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", 933 + "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", 934 + "cpu": [ 935 + "wasm32" 936 + ], 937 + "dev": true, 938 + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", 939 + "optional": true, 940 + "dependencies": { 941 + "@emnapi/runtime": "^1.2.0" 942 + }, 943 + "engines": { 944 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 945 + }, 946 + "funding": { 947 + "url": "https://opencollective.com/libvips" 948 + } 949 + }, 950 + "node_modules/@img/sharp-win32-ia32": { 951 + "version": "0.33.5", 952 + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", 953 + "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", 954 + "cpu": [ 955 + "ia32" 956 + ], 957 + "dev": true, 958 + "license": "Apache-2.0 AND LGPL-3.0-or-later", 959 + "optional": true, 960 + "os": [ 961 + "win32" 962 + ], 963 + "engines": { 964 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 965 + }, 966 + "funding": { 967 + "url": "https://opencollective.com/libvips" 968 + } 969 + }, 970 + "node_modules/@img/sharp-win32-x64": { 971 + "version": "0.33.5", 972 + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", 973 + "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", 974 + "cpu": [ 975 + "x64" 976 + ], 977 + "dev": true, 978 + "license": "Apache-2.0 AND LGPL-3.0-or-later", 979 + "optional": true, 980 + "os": [ 981 + "win32" 982 + ], 983 + "engines": { 984 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 985 + }, 986 + "funding": { 987 + "url": "https://opencollective.com/libvips" 988 + } 989 + }, 990 + "node_modules/@jridgewell/resolve-uri": { 991 + "version": "3.1.2", 992 + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", 993 + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", 994 + "dev": true, 995 + "license": "MIT", 996 + "engines": { 997 + "node": ">=6.0.0" 998 + } 999 + }, 1000 + "node_modules/@jridgewell/sourcemap-codec": { 1001 + "version": "1.5.0", 1002 + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", 1003 + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", 1004 + "dev": true, 1005 + "license": "MIT" 1006 + }, 1007 + "node_modules/@jridgewell/trace-mapping": { 1008 + "version": "0.3.9", 1009 + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", 1010 + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", 1011 + "dev": true, 1012 + "license": "MIT", 1013 + "dependencies": { 1014 + "@jridgewell/resolve-uri": "^3.0.3", 1015 + "@jridgewell/sourcemap-codec": "^1.4.10" 1016 + } 1017 + }, 1018 + "node_modules/@rollup/rollup-android-arm-eabi": { 1019 + "version": "4.40.1", 1020 + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.40.1.tgz", 1021 + "integrity": "sha512-kxz0YeeCrRUHz3zyqvd7n+TVRlNyTifBsmnmNPtk3hQURUyG9eAB+usz6DAwagMusjx/zb3AjvDUvhFGDAexGw==", 1022 + "cpu": [ 1023 + "arm" 1024 + ], 1025 + "dev": true, 1026 + "license": "MIT", 1027 + "optional": true, 1028 + "os": [ 1029 + "android" 1030 + ] 1031 + }, 1032 + "node_modules/@rollup/rollup-android-arm64": { 1033 + "version": "4.40.1", 1034 + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.40.1.tgz", 1035 + "integrity": "sha512-PPkxTOisoNC6TpnDKatjKkjRMsdaWIhyuMkA4UsBXT9WEZY4uHezBTjs6Vl4PbqQQeu6oION1w2voYZv9yquCw==", 1036 + "cpu": [ 1037 + "arm64" 1038 + ], 1039 + "dev": true, 1040 + "license": "MIT", 1041 + "optional": true, 1042 + "os": [ 1043 + "android" 1044 + ] 1045 + }, 1046 + "node_modules/@rollup/rollup-darwin-arm64": { 1047 + "version": "4.40.1", 1048 + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.40.1.tgz", 1049 + "integrity": "sha512-VWXGISWFY18v/0JyNUy4A46KCFCb9NVsH+1100XP31lud+TzlezBbz24CYzbnA4x6w4hx+NYCXDfnvDVO6lcAA==", 1050 + "cpu": [ 1051 + "arm64" 1052 + ], 1053 + "dev": true, 1054 + "license": "MIT", 1055 + "optional": true, 1056 + "os": [ 1057 + "darwin" 1058 + ] 1059 + }, 1060 + "node_modules/@rollup/rollup-darwin-x64": { 1061 + "version": "4.40.1", 1062 + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.40.1.tgz", 1063 + "integrity": "sha512-nIwkXafAI1/QCS7pxSpv/ZtFW6TXcNUEHAIA9EIyw5OzxJZQ1YDrX+CL6JAIQgZ33CInl1R6mHet9Y/UZTg2Bw==", 1064 + "cpu": [ 1065 + "x64" 1066 + ], 1067 + "dev": true, 1068 + "license": "MIT", 1069 + "optional": true, 1070 + "os": [ 1071 + "darwin" 1072 + ] 1073 + }, 1074 + "node_modules/@rollup/rollup-freebsd-arm64": { 1075 + "version": "4.40.1", 1076 + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.40.1.tgz", 1077 + "integrity": "sha512-BdrLJ2mHTrIYdaS2I99mriyJfGGenSaP+UwGi1kB9BLOCu9SR8ZpbkmmalKIALnRw24kM7qCN0IOm6L0S44iWw==", 1078 + "cpu": [ 1079 + "arm64" 1080 + ], 1081 + "dev": true, 1082 + "license": "MIT", 1083 + "optional": true, 1084 + "os": [ 1085 + "freebsd" 1086 + ] 1087 + }, 1088 + "node_modules/@rollup/rollup-freebsd-x64": { 1089 + "version": "4.40.1", 1090 + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.40.1.tgz", 1091 + "integrity": "sha512-VXeo/puqvCG8JBPNZXZf5Dqq7BzElNJzHRRw3vjBE27WujdzuOPecDPc/+1DcdcTptNBep3861jNq0mYkT8Z6Q==", 1092 + "cpu": [ 1093 + "x64" 1094 + ], 1095 + "dev": true, 1096 + "license": "MIT", 1097 + "optional": true, 1098 + "os": [ 1099 + "freebsd" 1100 + ] 1101 + }, 1102 + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { 1103 + "version": "4.40.1", 1104 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.40.1.tgz", 1105 + "integrity": "sha512-ehSKrewwsESPt1TgSE/na9nIhWCosfGSFqv7vwEtjyAqZcvbGIg4JAcV7ZEh2tfj/IlfBeZjgOXm35iOOjadcg==", 1106 + "cpu": [ 1107 + "arm" 1108 + ], 1109 + "dev": true, 1110 + "license": "MIT", 1111 + "optional": true, 1112 + "os": [ 1113 + "linux" 1114 + ] 1115 + }, 1116 + "node_modules/@rollup/rollup-linux-arm-musleabihf": { 1117 + "version": "4.40.1", 1118 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.40.1.tgz", 1119 + "integrity": "sha512-m39iO/aaurh5FVIu/F4/Zsl8xppd76S4qoID8E+dSRQvTyZTOI2gVk3T4oqzfq1PtcvOfAVlwLMK3KRQMaR8lg==", 1120 + "cpu": [ 1121 + "arm" 1122 + ], 1123 + "dev": true, 1124 + "license": "MIT", 1125 + "optional": true, 1126 + "os": [ 1127 + "linux" 1128 + ] 1129 + }, 1130 + "node_modules/@rollup/rollup-linux-arm64-gnu": { 1131 + "version": "4.40.1", 1132 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.40.1.tgz", 1133 + "integrity": "sha512-Y+GHnGaku4aVLSgrT0uWe2o2Rq8te9hi+MwqGF9r9ORgXhmHK5Q71N757u0F8yU1OIwUIFy6YiJtKjtyktk5hg==", 1134 + "cpu": [ 1135 + "arm64" 1136 + ], 1137 + "dev": true, 1138 + "license": "MIT", 1139 + "optional": true, 1140 + "os": [ 1141 + "linux" 1142 + ] 1143 + }, 1144 + "node_modules/@rollup/rollup-linux-arm64-musl": { 1145 + "version": "4.40.1", 1146 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.40.1.tgz", 1147 + "integrity": "sha512-jEwjn3jCA+tQGswK3aEWcD09/7M5wGwc6+flhva7dsQNRZZTe30vkalgIzV4tjkopsTS9Jd7Y1Bsj6a4lzz8gQ==", 1148 + "cpu": [ 1149 + "arm64" 1150 + ], 1151 + "dev": true, 1152 + "license": "MIT", 1153 + "optional": true, 1154 + "os": [ 1155 + "linux" 1156 + ] 1157 + }, 1158 + "node_modules/@rollup/rollup-linux-loongarch64-gnu": { 1159 + "version": "4.40.1", 1160 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.40.1.tgz", 1161 + "integrity": "sha512-ySyWikVhNzv+BV/IDCsrraOAZ3UaC8SZB67FZlqVwXwnFhPihOso9rPOxzZbjp81suB1O2Topw+6Ug3JNegejQ==", 1162 + "cpu": [ 1163 + "loong64" 1164 + ], 1165 + "dev": true, 1166 + "license": "MIT", 1167 + "optional": true, 1168 + "os": [ 1169 + "linux" 1170 + ] 1171 + }, 1172 + "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { 1173 + "version": "4.40.1", 1174 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.40.1.tgz", 1175 + "integrity": "sha512-BvvA64QxZlh7WZWqDPPdt0GH4bznuL6uOO1pmgPnnv86rpUpc8ZxgZwcEgXvo02GRIZX1hQ0j0pAnhwkhwPqWg==", 1176 + "cpu": [ 1177 + "ppc64" 1178 + ], 1179 + "dev": true, 1180 + "license": "MIT", 1181 + "optional": true, 1182 + "os": [ 1183 + "linux" 1184 + ] 1185 + }, 1186 + "node_modules/@rollup/rollup-linux-riscv64-gnu": { 1187 + "version": "4.40.1", 1188 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.40.1.tgz", 1189 + "integrity": "sha512-EQSP+8+1VuSulm9RKSMKitTav89fKbHymTf25n5+Yr6gAPZxYWpj3DzAsQqoaHAk9YX2lwEyAf9S4W8F4l3VBQ==", 1190 + "cpu": [ 1191 + "riscv64" 1192 + ], 1193 + "dev": true, 1194 + "license": "MIT", 1195 + "optional": true, 1196 + "os": [ 1197 + "linux" 1198 + ] 1199 + }, 1200 + "node_modules/@rollup/rollup-linux-riscv64-musl": { 1201 + "version": "4.40.1", 1202 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.40.1.tgz", 1203 + "integrity": "sha512-n/vQ4xRZXKuIpqukkMXZt9RWdl+2zgGNx7Uda8NtmLJ06NL8jiHxUawbwC+hdSq1rrw/9CghCpEONor+l1e2gA==", 1204 + "cpu": [ 1205 + "riscv64" 1206 + ], 1207 + "dev": true, 1208 + "license": "MIT", 1209 + "optional": true, 1210 + "os": [ 1211 + "linux" 1212 + ] 1213 + }, 1214 + "node_modules/@rollup/rollup-linux-s390x-gnu": { 1215 + "version": "4.40.1", 1216 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.40.1.tgz", 1217 + "integrity": "sha512-h8d28xzYb98fMQKUz0w2fMc1XuGzLLjdyxVIbhbil4ELfk5/orZlSTpF/xdI9C8K0I8lCkq+1En2RJsawZekkg==", 1218 + "cpu": [ 1219 + "s390x" 1220 + ], 1221 + "dev": true, 1222 + "license": "MIT", 1223 + "optional": true, 1224 + "os": [ 1225 + "linux" 1226 + ] 1227 + }, 1228 + "node_modules/@rollup/rollup-linux-x64-gnu": { 1229 + "version": "4.40.1", 1230 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.40.1.tgz", 1231 + "integrity": "sha512-XiK5z70PEFEFqcNj3/zRSz/qX4bp4QIraTy9QjwJAb/Z8GM7kVUsD0Uk8maIPeTyPCP03ChdI+VVmJriKYbRHQ==", 1232 + "cpu": [ 1233 + "x64" 1234 + ], 1235 + "dev": true, 1236 + "license": "MIT", 1237 + "optional": true, 1238 + "os": [ 1239 + "linux" 1240 + ] 1241 + }, 1242 + "node_modules/@rollup/rollup-linux-x64-musl": { 1243 + "version": "4.40.1", 1244 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.40.1.tgz", 1245 + "integrity": "sha512-2BRORitq5rQ4Da9blVovzNCMaUlyKrzMSvkVR0D4qPuOy/+pMCrh1d7o01RATwVy+6Fa1WBw+da7QPeLWU/1mQ==", 1246 + "cpu": [ 1247 + "x64" 1248 + ], 1249 + "dev": true, 1250 + "license": "MIT", 1251 + "optional": true, 1252 + "os": [ 1253 + "linux" 1254 + ] 1255 + }, 1256 + "node_modules/@rollup/rollup-win32-arm64-msvc": { 1257 + "version": "4.40.1", 1258 + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.40.1.tgz", 1259 + "integrity": "sha512-b2bcNm9Kbde03H+q+Jjw9tSfhYkzrDUf2d5MAd1bOJuVplXvFhWz7tRtWvD8/ORZi7qSCy0idW6tf2HgxSXQSg==", 1260 + "cpu": [ 1261 + "arm64" 1262 + ], 1263 + "dev": true, 1264 + "license": "MIT", 1265 + "optional": true, 1266 + "os": [ 1267 + "win32" 1268 + ] 1269 + }, 1270 + "node_modules/@rollup/rollup-win32-ia32-msvc": { 1271 + "version": "4.40.1", 1272 + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.40.1.tgz", 1273 + "integrity": "sha512-DfcogW8N7Zg7llVEfpqWMZcaErKfsj9VvmfSyRjCyo4BI3wPEfrzTtJkZG6gKP/Z92wFm6rz2aDO7/JfiR/whA==", 1274 + "cpu": [ 1275 + "ia32" 1276 + ], 1277 + "dev": true, 1278 + "license": "MIT", 1279 + "optional": true, 1280 + "os": [ 1281 + "win32" 1282 + ] 1283 + }, 1284 + "node_modules/@rollup/rollup-win32-x64-msvc": { 1285 + "version": "4.40.1", 1286 + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.40.1.tgz", 1287 + "integrity": "sha512-ECyOuDeH3C1I8jH2MK1RtBJW+YPMvSfT0a5NN0nHfQYnDSJ6tUiZH3gzwVP5/Kfh/+Tt7tpWVF9LXNTnhTJ3kA==", 1288 + "cpu": [ 1289 + "x64" 1290 + ], 1291 + "dev": true, 1292 + "license": "MIT", 1293 + "optional": true, 1294 + "os": [ 1295 + "win32" 1296 + ] 1297 + }, 1298 + "node_modules/@types/estree": { 1299 + "version": "1.0.7", 1300 + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", 1301 + "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==", 1302 + "dev": true, 1303 + "license": "MIT" 1304 + }, 1305 + "node_modules/@vitest/expect": { 1306 + "version": "3.0.9", 1307 + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.0.9.tgz", 1308 + "integrity": "sha512-5eCqRItYgIML7NNVgJj6TVCmdzE7ZVgJhruW0ziSQV4V7PvLkDL1bBkBdcTs/VuIz0IxPb5da1IDSqc1TR9eig==", 1309 + "dev": true, 1310 + "license": "MIT", 1311 + "dependencies": { 1312 + "@vitest/spy": "3.0.9", 1313 + "@vitest/utils": "3.0.9", 1314 + "chai": "^5.2.0", 1315 + "tinyrainbow": "^2.0.0" 1316 + }, 1317 + "funding": { 1318 + "url": "https://opencollective.com/vitest" 1319 + } 1320 + }, 1321 + "node_modules/@vitest/mocker": { 1322 + "version": "3.0.9", 1323 + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.0.9.tgz", 1324 + "integrity": "sha512-ryERPIBOnvevAkTq+L1lD+DTFBRcjueL9lOUfXsLfwP92h4e+Heb+PjiqS3/OURWPtywfafK0kj++yDFjWUmrA==", 1325 + "dev": true, 1326 + "license": "MIT", 1327 + "dependencies": { 1328 + "@vitest/spy": "3.0.9", 1329 + "estree-walker": "^3.0.3", 1330 + "magic-string": "^0.30.17" 1331 + }, 1332 + "funding": { 1333 + "url": "https://opencollective.com/vitest" 1334 + }, 1335 + "peerDependencies": { 1336 + "msw": "^2.4.9", 1337 + "vite": "^5.0.0 || ^6.0.0" 1338 + }, 1339 + "peerDependenciesMeta": { 1340 + "msw": { 1341 + "optional": true 1342 + }, 1343 + "vite": { 1344 + "optional": true 1345 + } 1346 + } 1347 + }, 1348 + "node_modules/@vitest/pretty-format": { 1349 + "version": "3.1.2", 1350 + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.1.2.tgz", 1351 + "integrity": "sha512-R0xAiHuWeDjTSB3kQ3OQpT8Rx3yhdOAIm/JM4axXxnG7Q/fS8XUwggv/A4xzbQA+drYRjzkMnpYnOGAc4oeq8w==", 1352 + "dev": true, 1353 + "license": "MIT", 1354 + "dependencies": { 1355 + "tinyrainbow": "^2.0.0" 1356 + }, 1357 + "funding": { 1358 + "url": "https://opencollective.com/vitest" 1359 + } 1360 + }, 1361 + "node_modules/@vitest/runner": { 1362 + "version": "3.0.9", 1363 + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.0.9.tgz", 1364 + "integrity": "sha512-NX9oUXgF9HPfJSwl8tUZCMP1oGx2+Sf+ru6d05QjzQz4OwWg0psEzwY6VexP2tTHWdOkhKHUIZH+fS6nA7jfOw==", 1365 + "dev": true, 1366 + "license": "MIT", 1367 + "dependencies": { 1368 + "@vitest/utils": "3.0.9", 1369 + "pathe": "^2.0.3" 1370 + }, 1371 + "funding": { 1372 + "url": "https://opencollective.com/vitest" 1373 + } 1374 + }, 1375 + "node_modules/@vitest/snapshot": { 1376 + "version": "3.0.9", 1377 + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.0.9.tgz", 1378 + "integrity": "sha512-AiLUiuZ0FuA+/8i19mTYd+re5jqjEc2jZbgJ2up0VY0Ddyyxg/uUtBDpIFAy4uzKaQxOW8gMgBdAJJ2ydhu39A==", 1379 + "dev": true, 1380 + "license": "MIT", 1381 + "dependencies": { 1382 + "@vitest/pretty-format": "3.0.9", 1383 + "magic-string": "^0.30.17", 1384 + "pathe": "^2.0.3" 1385 + }, 1386 + "funding": { 1387 + "url": "https://opencollective.com/vitest" 1388 + } 1389 + }, 1390 + "node_modules/@vitest/snapshot/node_modules/@vitest/pretty-format": { 1391 + "version": "3.0.9", 1392 + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.0.9.tgz", 1393 + "integrity": "sha512-OW9F8t2J3AwFEwENg3yMyKWweF7oRJlMyHOMIhO5F3n0+cgQAJZBjNgrF8dLwFTEXl5jUqBLXd9QyyKv8zEcmA==", 1394 + "dev": true, 1395 + "license": "MIT", 1396 + "dependencies": { 1397 + "tinyrainbow": "^2.0.0" 1398 + }, 1399 + "funding": { 1400 + "url": "https://opencollective.com/vitest" 1401 + } 1402 + }, 1403 + "node_modules/@vitest/spy": { 1404 + "version": "3.0.9", 1405 + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.0.9.tgz", 1406 + "integrity": "sha512-/CcK2UDl0aQ2wtkp3YVWldrpLRNCfVcIOFGlVGKO4R5eajsH393Z1yiXLVQ7vWsj26JOEjeZI0x5sm5P4OGUNQ==", 1407 + "dev": true, 1408 + "license": "MIT", 1409 + "dependencies": { 1410 + "tinyspy": "^3.0.2" 1411 + }, 1412 + "funding": { 1413 + "url": "https://opencollective.com/vitest" 1414 + } 1415 + }, 1416 + "node_modules/@vitest/utils": { 1417 + "version": "3.0.9", 1418 + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.0.9.tgz", 1419 + "integrity": "sha512-ilHM5fHhZ89MCp5aAaM9uhfl1c2JdxVxl3McqsdVyVNN6JffnEen8UMCdRTzOhGXNQGo5GNL9QugHrz727Wnng==", 1420 + "dev": true, 1421 + "license": "MIT", 1422 + "dependencies": { 1423 + "@vitest/pretty-format": "3.0.9", 1424 + "loupe": "^3.1.3", 1425 + "tinyrainbow": "^2.0.0" 1426 + }, 1427 + "funding": { 1428 + "url": "https://opencollective.com/vitest" 1429 + } 1430 + }, 1431 + "node_modules/@vitest/utils/node_modules/@vitest/pretty-format": { 1432 + "version": "3.0.9", 1433 + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.0.9.tgz", 1434 + "integrity": "sha512-OW9F8t2J3AwFEwENg3yMyKWweF7oRJlMyHOMIhO5F3n0+cgQAJZBjNgrF8dLwFTEXl5jUqBLXd9QyyKv8zEcmA==", 1435 + "dev": true, 1436 + "license": "MIT", 1437 + "dependencies": { 1438 + "tinyrainbow": "^2.0.0" 1439 + }, 1440 + "funding": { 1441 + "url": "https://opencollective.com/vitest" 1442 + } 1443 + }, 1444 + "node_modules/acorn": { 1445 + "version": "8.14.0", 1446 + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", 1447 + "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", 1448 + "dev": true, 1449 + "license": "MIT", 1450 + "bin": { 1451 + "acorn": "bin/acorn" 1452 + }, 1453 + "engines": { 1454 + "node": ">=0.4.0" 1455 + } 1456 + }, 1457 + "node_modules/acorn-walk": { 1458 + "version": "8.3.2", 1459 + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.2.tgz", 1460 + "integrity": "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==", 1461 + "dev": true, 1462 + "license": "MIT", 1463 + "engines": { 1464 + "node": ">=0.4.0" 1465 + } 1466 + }, 1467 + "node_modules/as-table": { 1468 + "version": "1.0.55", 1469 + "resolved": "https://registry.npmjs.org/as-table/-/as-table-1.0.55.tgz", 1470 + "integrity": "sha512-xvsWESUJn0JN421Xb9MQw6AsMHRCUknCe0Wjlxvjud80mU4E6hQf1A6NzQKcYNmYw62MfzEtXc+badstZP3JpQ==", 1471 + "dev": true, 1472 + "license": "MIT", 1473 + "dependencies": { 1474 + "printable-characters": "^1.0.42" 1475 + } 1476 + }, 1477 + "node_modules/assertion-error": { 1478 + "version": "2.0.1", 1479 + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", 1480 + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", 1481 + "dev": true, 1482 + "license": "MIT", 1483 + "engines": { 1484 + "node": ">=12" 1485 + } 1486 + }, 1487 + "node_modules/birpc": { 1488 + "version": "0.2.14", 1489 + "resolved": "https://registry.npmjs.org/birpc/-/birpc-0.2.14.tgz", 1490 + "integrity": "sha512-37FHE8rqsYM5JEKCnXFyHpBCzvgHEExwVVTq+nUmloInU7l8ezD1TpOhKpS8oe1DTYFqEK27rFZVKG43oTqXRA==", 1491 + "dev": true, 1492 + "license": "MIT", 1493 + "funding": { 1494 + "url": "https://github.com/sponsors/antfu" 1495 + } 1496 + }, 1497 + "node_modules/blake3-wasm": { 1498 + "version": "2.1.5", 1499 + "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", 1500 + "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", 1501 + "dev": true, 1502 + "license": "MIT" 1503 + }, 1504 + "node_modules/cac": { 1505 + "version": "6.7.14", 1506 + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", 1507 + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", 1508 + "dev": true, 1509 + "license": "MIT", 1510 + "engines": { 1511 + "node": ">=8" 1512 + } 1513 + }, 1514 + "node_modules/chai": { 1515 + "version": "5.2.0", 1516 + "resolved": "https://registry.npmjs.org/chai/-/chai-5.2.0.tgz", 1517 + "integrity": "sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==", 1518 + "dev": true, 1519 + "license": "MIT", 1520 + "dependencies": { 1521 + "assertion-error": "^2.0.1", 1522 + "check-error": "^2.1.1", 1523 + "deep-eql": "^5.0.1", 1524 + "loupe": "^3.1.0", 1525 + "pathval": "^2.0.0" 1526 + }, 1527 + "engines": { 1528 + "node": ">=12" 1529 + } 1530 + }, 1531 + "node_modules/check-error": { 1532 + "version": "2.1.1", 1533 + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", 1534 + "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", 1535 + "dev": true, 1536 + "license": "MIT", 1537 + "engines": { 1538 + "node": ">= 16" 1539 + } 1540 + }, 1541 + "node_modules/cjs-module-lexer": { 1542 + "version": "1.4.3", 1543 + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", 1544 + "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", 1545 + "dev": true, 1546 + "license": "MIT" 1547 + }, 1548 + "node_modules/color": { 1549 + "version": "4.2.3", 1550 + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", 1551 + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", 1552 + "dev": true, 1553 + "license": "MIT", 1554 + "optional": true, 1555 + "dependencies": { 1556 + "color-convert": "^2.0.1", 1557 + "color-string": "^1.9.0" 1558 + }, 1559 + "engines": { 1560 + "node": ">=12.5.0" 1561 + } 1562 + }, 1563 + "node_modules/color-convert": { 1564 + "version": "2.0.1", 1565 + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", 1566 + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", 1567 + "dev": true, 1568 + "license": "MIT", 1569 + "optional": true, 1570 + "dependencies": { 1571 + "color-name": "~1.1.4" 1572 + }, 1573 + "engines": { 1574 + "node": ">=7.0.0" 1575 + } 1576 + }, 1577 + "node_modules/color-name": { 1578 + "version": "1.1.4", 1579 + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", 1580 + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", 1581 + "dev": true, 1582 + "license": "MIT", 1583 + "optional": true 1584 + }, 1585 + "node_modules/color-string": { 1586 + "version": "1.9.1", 1587 + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", 1588 + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", 1589 + "dev": true, 1590 + "license": "MIT", 1591 + "optional": true, 1592 + "dependencies": { 1593 + "color-name": "^1.0.0", 1594 + "simple-swizzle": "^0.2.2" 1595 + } 1596 + }, 1597 + "node_modules/cookie": { 1598 + "version": "0.7.2", 1599 + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", 1600 + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", 1601 + "dev": true, 1602 + "license": "MIT", 1603 + "engines": { 1604 + "node": ">= 0.6" 1605 + } 1606 + }, 1607 + "node_modules/data-uri-to-buffer": { 1608 + "version": "2.0.2", 1609 + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-2.0.2.tgz", 1610 + "integrity": "sha512-ND9qDTLc6diwj+Xe5cdAgVTbLVdXbtxTJRXRhli8Mowuaan+0EJOtdqJ0QCHNSSPyoXGx9HX2/VMnKeC34AChA==", 1611 + "dev": true, 1612 + "license": "MIT" 1613 + }, 1614 + "node_modules/debug": { 1615 + "version": "4.4.0", 1616 + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", 1617 + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", 1618 + "dev": true, 1619 + "license": "MIT", 1620 + "dependencies": { 1621 + "ms": "^2.1.3" 1622 + }, 1623 + "engines": { 1624 + "node": ">=6.0" 1625 + }, 1626 + "peerDependenciesMeta": { 1627 + "supports-color": { 1628 + "optional": true 1629 + } 1630 + } 1631 + }, 1632 + "node_modules/deep-eql": { 1633 + "version": "5.0.2", 1634 + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", 1635 + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", 1636 + "dev": true, 1637 + "license": "MIT", 1638 + "engines": { 1639 + "node": ">=6" 1640 + } 1641 + }, 1642 + "node_modules/defu": { 1643 + "version": "6.1.4", 1644 + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", 1645 + "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", 1646 + "dev": true, 1647 + "license": "MIT" 1648 + }, 1649 + "node_modules/detect-libc": { 1650 + "version": "2.0.4", 1651 + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", 1652 + "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", 1653 + "dev": true, 1654 + "license": "Apache-2.0", 1655 + "optional": true, 1656 + "engines": { 1657 + "node": ">=8" 1658 + } 1659 + }, 1660 + "node_modules/devalue": { 1661 + "version": "4.3.3", 1662 + "resolved": "https://registry.npmjs.org/devalue/-/devalue-4.3.3.tgz", 1663 + "integrity": "sha512-UH8EL6H2ifcY8TbD2QsxwCC/pr5xSwPvv85LrLXVihmHVC3T3YqTCIwnR5ak0yO1KYqlxrPVOA/JVZJYPy2ATg==", 1664 + "dev": true, 1665 + "license": "MIT" 1666 + }, 1667 + "node_modules/es-module-lexer": { 1668 + "version": "1.7.0", 1669 + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", 1670 + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", 1671 + "dev": true, 1672 + "license": "MIT" 1673 + }, 1674 + "node_modules/esbuild": { 1675 + "version": "0.25.3", 1676 + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.3.tgz", 1677 + "integrity": "sha512-qKA6Pvai73+M2FtftpNKRxJ78GIjmFXFxd/1DVBqGo/qNhLSfv+G12n9pNoWdytJC8U00TrViOwpjT0zgqQS8Q==", 1678 + "dev": true, 1679 + "hasInstallScript": true, 1680 + "license": "MIT", 1681 + "bin": { 1682 + "esbuild": "bin/esbuild" 1683 + }, 1684 + "engines": { 1685 + "node": ">=18" 1686 + }, 1687 + "optionalDependencies": { 1688 + "@esbuild/aix-ppc64": "0.25.3", 1689 + "@esbuild/android-arm": "0.25.3", 1690 + "@esbuild/android-arm64": "0.25.3", 1691 + "@esbuild/android-x64": "0.25.3", 1692 + "@esbuild/darwin-arm64": "0.25.3", 1693 + "@esbuild/darwin-x64": "0.25.3", 1694 + "@esbuild/freebsd-arm64": "0.25.3", 1695 + "@esbuild/freebsd-x64": "0.25.3", 1696 + "@esbuild/linux-arm": "0.25.3", 1697 + "@esbuild/linux-arm64": "0.25.3", 1698 + "@esbuild/linux-ia32": "0.25.3", 1699 + "@esbuild/linux-loong64": "0.25.3", 1700 + "@esbuild/linux-mips64el": "0.25.3", 1701 + "@esbuild/linux-ppc64": "0.25.3", 1702 + "@esbuild/linux-riscv64": "0.25.3", 1703 + "@esbuild/linux-s390x": "0.25.3", 1704 + "@esbuild/linux-x64": "0.25.3", 1705 + "@esbuild/netbsd-arm64": "0.25.3", 1706 + "@esbuild/netbsd-x64": "0.25.3", 1707 + "@esbuild/openbsd-arm64": "0.25.3", 1708 + "@esbuild/openbsd-x64": "0.25.3", 1709 + "@esbuild/sunos-x64": "0.25.3", 1710 + "@esbuild/win32-arm64": "0.25.3", 1711 + "@esbuild/win32-ia32": "0.25.3", 1712 + "@esbuild/win32-x64": "0.25.3" 1713 + } 1714 + }, 1715 + "node_modules/estree-walker": { 1716 + "version": "3.0.3", 1717 + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", 1718 + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", 1719 + "dev": true, 1720 + "license": "MIT", 1721 + "dependencies": { 1722 + "@types/estree": "^1.0.0" 1723 + } 1724 + }, 1725 + "node_modules/exit-hook": { 1726 + "version": "2.2.1", 1727 + "resolved": "https://registry.npmjs.org/exit-hook/-/exit-hook-2.2.1.tgz", 1728 + "integrity": "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw==", 1729 + "dev": true, 1730 + "license": "MIT", 1731 + "engines": { 1732 + "node": ">=6" 1733 + }, 1734 + "funding": { 1735 + "url": "https://github.com/sponsors/sindresorhus" 1736 + } 1737 + }, 1738 + "node_modules/expect-type": { 1739 + "version": "1.2.1", 1740 + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.1.tgz", 1741 + "integrity": "sha512-/kP8CAwxzLVEeFrMm4kMmy4CCDlpipyA7MYLVrdJIkV0fYF0UaigQHRsxHiuY/GEea+bh4KSv3TIlgr+2UL6bw==", 1742 + "dev": true, 1743 + "license": "Apache-2.0", 1744 + "engines": { 1745 + "node": ">=12.0.0" 1746 + } 1747 + }, 1748 + "node_modules/exsolve": { 1749 + "version": "1.0.5", 1750 + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.5.tgz", 1751 + "integrity": "sha512-pz5dvkYYKQ1AHVrgOzBKWeP4u4FRb3a6DNK2ucr0OoNwYIU4QWsJ+NM36LLzORT+z845MzKHHhpXiUF5nvQoJg==", 1752 + "dev": true, 1753 + "license": "MIT" 1754 + }, 1755 + "node_modules/fdir": { 1756 + "version": "6.4.4", 1757 + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz", 1758 + "integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==", 1759 + "dev": true, 1760 + "license": "MIT", 1761 + "peerDependencies": { 1762 + "picomatch": "^3 || ^4" 1763 + }, 1764 + "peerDependenciesMeta": { 1765 + "picomatch": { 1766 + "optional": true 1767 + } 1768 + } 1769 + }, 1770 + "node_modules/fsevents": { 1771 + "version": "2.3.3", 1772 + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", 1773 + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", 1774 + "dev": true, 1775 + "hasInstallScript": true, 1776 + "license": "MIT", 1777 + "optional": true, 1778 + "os": [ 1779 + "darwin" 1780 + ], 1781 + "engines": { 1782 + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" 1783 + } 1784 + }, 1785 + "node_modules/get-source": { 1786 + "version": "2.0.12", 1787 + "resolved": "https://registry.npmjs.org/get-source/-/get-source-2.0.12.tgz", 1788 + "integrity": "sha512-X5+4+iD+HoSeEED+uwrQ07BOQr0kEDFMVqqpBuI+RaZBpBpHCuXxo70bjar6f0b0u/DQJsJ7ssurpP0V60Az+w==", 1789 + "dev": true, 1790 + "license": "Unlicense", 1791 + "dependencies": { 1792 + "data-uri-to-buffer": "^2.0.0", 1793 + "source-map": "^0.6.1" 1794 + } 1795 + }, 1796 + "node_modules/glob-to-regexp": { 1797 + "version": "0.4.1", 1798 + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", 1799 + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", 1800 + "dev": true, 1801 + "license": "BSD-2-Clause" 1802 + }, 1803 + "node_modules/is-arrayish": { 1804 + "version": "0.3.2", 1805 + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", 1806 + "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==", 1807 + "dev": true, 1808 + "license": "MIT", 1809 + "optional": true 1810 + }, 1811 + "node_modules/loupe": { 1812 + "version": "3.1.3", 1813 + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.3.tgz", 1814 + "integrity": "sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==", 1815 + "dev": true, 1816 + "license": "MIT" 1817 + }, 1818 + "node_modules/magic-string": { 1819 + "version": "0.30.17", 1820 + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", 1821 + "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", 1822 + "dev": true, 1823 + "license": "MIT", 1824 + "dependencies": { 1825 + "@jridgewell/sourcemap-codec": "^1.5.0" 1826 + } 1827 + }, 1828 + "node_modules/mime": { 1829 + "version": "3.0.0", 1830 + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", 1831 + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", 1832 + "dev": true, 1833 + "license": "MIT", 1834 + "bin": { 1835 + "mime": "cli.js" 1836 + }, 1837 + "engines": { 1838 + "node": ">=10.0.0" 1839 + } 1840 + }, 1841 + "node_modules/miniflare": { 1842 + "version": "4.20250428.1", 1843 + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20250428.1.tgz", 1844 + "integrity": "sha512-M3qcJXjeAEimHrEeWXEhrJiC3YHB5M3QSqqK67pOTI+lHn0QyVG/2iFUjVJ/nv+i10uxeAEva8GRGeu+tKRCmQ==", 1845 + "dev": true, 1846 + "license": "MIT", 1847 + "dependencies": { 1848 + "@cspotcode/source-map-support": "0.8.1", 1849 + "acorn": "8.14.0", 1850 + "acorn-walk": "8.3.2", 1851 + "exit-hook": "2.2.1", 1852 + "glob-to-regexp": "0.4.1", 1853 + "stoppable": "1.1.0", 1854 + "undici": "^5.28.5", 1855 + "workerd": "1.20250428.0", 1856 + "ws": "8.18.0", 1857 + "youch": "3.3.4", 1858 + "zod": "3.22.3" 1859 + }, 1860 + "bin": { 1861 + "miniflare": "bootstrap.js" 1862 + }, 1863 + "engines": { 1864 + "node": ">=18.0.0" 1865 + } 1866 + }, 1867 + "node_modules/miniflare/node_modules/zod": { 1868 + "version": "3.22.3", 1869 + "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.3.tgz", 1870 + "integrity": "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug==", 1871 + "dev": true, 1872 + "license": "MIT", 1873 + "funding": { 1874 + "url": "https://github.com/sponsors/colinhacks" 1875 + } 1876 + }, 1877 + "node_modules/ms": { 1878 + "version": "2.1.3", 1879 + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", 1880 + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", 1881 + "dev": true, 1882 + "license": "MIT" 1883 + }, 1884 + "node_modules/mustache": { 1885 + "version": "4.2.0", 1886 + "resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz", 1887 + "integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==", 1888 + "dev": true, 1889 + "license": "MIT", 1890 + "bin": { 1891 + "mustache": "bin/mustache" 1892 + } 1893 + }, 1894 + "node_modules/nanoid": { 1895 + "version": "3.3.11", 1896 + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", 1897 + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", 1898 + "dev": true, 1899 + "funding": [ 1900 + { 1901 + "type": "github", 1902 + "url": "https://github.com/sponsors/ai" 1903 + } 1904 + ], 1905 + "license": "MIT", 1906 + "bin": { 1907 + "nanoid": "bin/nanoid.cjs" 1908 + }, 1909 + "engines": { 1910 + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" 1911 + } 1912 + }, 1913 + "node_modules/ohash": { 1914 + "version": "2.0.11", 1915 + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", 1916 + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", 1917 + "dev": true, 1918 + "license": "MIT" 1919 + }, 1920 + "node_modules/path-to-regexp": { 1921 + "version": "6.3.0", 1922 + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", 1923 + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", 1924 + "dev": true, 1925 + "license": "MIT" 1926 + }, 1927 + "node_modules/pathe": { 1928 + "version": "2.0.3", 1929 + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", 1930 + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", 1931 + "dev": true, 1932 + "license": "MIT" 1933 + }, 1934 + "node_modules/pathval": { 1935 + "version": "2.0.0", 1936 + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.0.tgz", 1937 + "integrity": "sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==", 1938 + "dev": true, 1939 + "license": "MIT", 1940 + "engines": { 1941 + "node": ">= 14.16" 1942 + } 1943 + }, 1944 + "node_modules/picocolors": { 1945 + "version": "1.1.1", 1946 + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", 1947 + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", 1948 + "dev": true, 1949 + "license": "ISC" 1950 + }, 1951 + "node_modules/picomatch": { 1952 + "version": "4.0.2", 1953 + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", 1954 + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", 1955 + "dev": true, 1956 + "license": "MIT", 1957 + "engines": { 1958 + "node": ">=12" 1959 + }, 1960 + "funding": { 1961 + "url": "https://github.com/sponsors/jonschlinkert" 1962 + } 1963 + }, 1964 + "node_modules/postcss": { 1965 + "version": "8.5.3", 1966 + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", 1967 + "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==", 1968 + "dev": true, 1969 + "funding": [ 1970 + { 1971 + "type": "opencollective", 1972 + "url": "https://opencollective.com/postcss/" 1973 + }, 1974 + { 1975 + "type": "tidelift", 1976 + "url": "https://tidelift.com/funding/github/npm/postcss" 1977 + }, 1978 + { 1979 + "type": "github", 1980 + "url": "https://github.com/sponsors/ai" 1981 + } 1982 + ], 1983 + "license": "MIT", 1984 + "dependencies": { 1985 + "nanoid": "^3.3.8", 1986 + "picocolors": "^1.1.1", 1987 + "source-map-js": "^1.2.1" 1988 + }, 1989 + "engines": { 1990 + "node": "^10 || ^12 || >=14" 1991 + } 1992 + }, 1993 + "node_modules/printable-characters": { 1994 + "version": "1.0.42", 1995 + "resolved": "https://registry.npmjs.org/printable-characters/-/printable-characters-1.0.42.tgz", 1996 + "integrity": "sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ==", 1997 + "dev": true, 1998 + "license": "Unlicense" 1999 + }, 2000 + "node_modules/rollup": { 2001 + "version": "4.40.1", 2002 + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.40.1.tgz", 2003 + "integrity": "sha512-C5VvvgCCyfyotVITIAv+4efVytl5F7wt+/I2i9q9GZcEXW9BP52YYOXC58igUi+LFZVHukErIIqQSWwv/M3WRw==", 2004 + "dev": true, 2005 + "license": "MIT", 2006 + "dependencies": { 2007 + "@types/estree": "1.0.7" 2008 + }, 2009 + "bin": { 2010 + "rollup": "dist/bin/rollup" 2011 + }, 2012 + "engines": { 2013 + "node": ">=18.0.0", 2014 + "npm": ">=8.0.0" 2015 + }, 2016 + "optionalDependencies": { 2017 + "@rollup/rollup-android-arm-eabi": "4.40.1", 2018 + "@rollup/rollup-android-arm64": "4.40.1", 2019 + "@rollup/rollup-darwin-arm64": "4.40.1", 2020 + "@rollup/rollup-darwin-x64": "4.40.1", 2021 + "@rollup/rollup-freebsd-arm64": "4.40.1", 2022 + "@rollup/rollup-freebsd-x64": "4.40.1", 2023 + "@rollup/rollup-linux-arm-gnueabihf": "4.40.1", 2024 + "@rollup/rollup-linux-arm-musleabihf": "4.40.1", 2025 + "@rollup/rollup-linux-arm64-gnu": "4.40.1", 2026 + "@rollup/rollup-linux-arm64-musl": "4.40.1", 2027 + "@rollup/rollup-linux-loongarch64-gnu": "4.40.1", 2028 + "@rollup/rollup-linux-powerpc64le-gnu": "4.40.1", 2029 + "@rollup/rollup-linux-riscv64-gnu": "4.40.1", 2030 + "@rollup/rollup-linux-riscv64-musl": "4.40.1", 2031 + "@rollup/rollup-linux-s390x-gnu": "4.40.1", 2032 + "@rollup/rollup-linux-x64-gnu": "4.40.1", 2033 + "@rollup/rollup-linux-x64-musl": "4.40.1", 2034 + "@rollup/rollup-win32-arm64-msvc": "4.40.1", 2035 + "@rollup/rollup-win32-ia32-msvc": "4.40.1", 2036 + "@rollup/rollup-win32-x64-msvc": "4.40.1", 2037 + "fsevents": "~2.3.2" 2038 + } 2039 + }, 2040 + "node_modules/semver": { 2041 + "version": "7.7.1", 2042 + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", 2043 + "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", 2044 + "dev": true, 2045 + "license": "ISC", 2046 + "bin": { 2047 + "semver": "bin/semver.js" 2048 + }, 2049 + "engines": { 2050 + "node": ">=10" 2051 + } 2052 + }, 2053 + "node_modules/sharp": { 2054 + "version": "0.33.5", 2055 + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", 2056 + "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", 2057 + "dev": true, 2058 + "hasInstallScript": true, 2059 + "license": "Apache-2.0", 2060 + "optional": true, 2061 + "dependencies": { 2062 + "color": "^4.2.3", 2063 + "detect-libc": "^2.0.3", 2064 + "semver": "^7.6.3" 2065 + }, 2066 + "engines": { 2067 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 2068 + }, 2069 + "funding": { 2070 + "url": "https://opencollective.com/libvips" 2071 + }, 2072 + "optionalDependencies": { 2073 + "@img/sharp-darwin-arm64": "0.33.5", 2074 + "@img/sharp-darwin-x64": "0.33.5", 2075 + "@img/sharp-libvips-darwin-arm64": "1.0.4", 2076 + "@img/sharp-libvips-darwin-x64": "1.0.4", 2077 + "@img/sharp-libvips-linux-arm": "1.0.5", 2078 + "@img/sharp-libvips-linux-arm64": "1.0.4", 2079 + "@img/sharp-libvips-linux-s390x": "1.0.4", 2080 + "@img/sharp-libvips-linux-x64": "1.0.4", 2081 + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", 2082 + "@img/sharp-libvips-linuxmusl-x64": "1.0.4", 2083 + "@img/sharp-linux-arm": "0.33.5", 2084 + "@img/sharp-linux-arm64": "0.33.5", 2085 + "@img/sharp-linux-s390x": "0.33.5", 2086 + "@img/sharp-linux-x64": "0.33.5", 2087 + "@img/sharp-linuxmusl-arm64": "0.33.5", 2088 + "@img/sharp-linuxmusl-x64": "0.33.5", 2089 + "@img/sharp-wasm32": "0.33.5", 2090 + "@img/sharp-win32-ia32": "0.33.5", 2091 + "@img/sharp-win32-x64": "0.33.5" 2092 + } 2093 + }, 2094 + "node_modules/siginfo": { 2095 + "version": "2.0.0", 2096 + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", 2097 + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", 2098 + "dev": true, 2099 + "license": "ISC" 2100 + }, 2101 + "node_modules/simple-swizzle": { 2102 + "version": "0.2.2", 2103 + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", 2104 + "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==", 2105 + "dev": true, 2106 + "license": "MIT", 2107 + "optional": true, 2108 + "dependencies": { 2109 + "is-arrayish": "^0.3.1" 2110 + } 2111 + }, 2112 + "node_modules/source-map": { 2113 + "version": "0.6.1", 2114 + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", 2115 + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", 2116 + "dev": true, 2117 + "license": "BSD-3-Clause", 2118 + "engines": { 2119 + "node": ">=0.10.0" 2120 + } 2121 + }, 2122 + "node_modules/source-map-js": { 2123 + "version": "1.2.1", 2124 + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", 2125 + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", 2126 + "dev": true, 2127 + "license": "BSD-3-Clause", 2128 + "engines": { 2129 + "node": ">=0.10.0" 2130 + } 2131 + }, 2132 + "node_modules/stackback": { 2133 + "version": "0.0.2", 2134 + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", 2135 + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", 2136 + "dev": true, 2137 + "license": "MIT" 2138 + }, 2139 + "node_modules/stacktracey": { 2140 + "version": "2.1.8", 2141 + "resolved": "https://registry.npmjs.org/stacktracey/-/stacktracey-2.1.8.tgz", 2142 + "integrity": "sha512-Kpij9riA+UNg7TnphqjH7/CzctQ/owJGNbFkfEeve4Z4uxT5+JapVLFXcsurIfN34gnTWZNJ/f7NMG0E8JDzTw==", 2143 + "dev": true, 2144 + "license": "Unlicense", 2145 + "dependencies": { 2146 + "as-table": "^1.0.36", 2147 + "get-source": "^2.0.12" 2148 + } 2149 + }, 2150 + "node_modules/std-env": { 2151 + "version": "3.9.0", 2152 + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.9.0.tgz", 2153 + "integrity": "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==", 2154 + "dev": true, 2155 + "license": "MIT" 2156 + }, 2157 + "node_modules/stoppable": { 2158 + "version": "1.1.0", 2159 + "resolved": "https://registry.npmjs.org/stoppable/-/stoppable-1.1.0.tgz", 2160 + "integrity": "sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw==", 2161 + "dev": true, 2162 + "license": "MIT", 2163 + "engines": { 2164 + "node": ">=4", 2165 + "npm": ">=6" 2166 + } 2167 + }, 2168 + "node_modules/tinybench": { 2169 + "version": "2.9.0", 2170 + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", 2171 + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", 2172 + "dev": true, 2173 + "license": "MIT" 2174 + }, 2175 + "node_modules/tinyexec": { 2176 + "version": "0.3.2", 2177 + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", 2178 + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", 2179 + "dev": true, 2180 + "license": "MIT" 2181 + }, 2182 + "node_modules/tinyglobby": { 2183 + "version": "0.2.13", 2184 + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.13.tgz", 2185 + "integrity": "sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==", 2186 + "dev": true, 2187 + "license": "MIT", 2188 + "dependencies": { 2189 + "fdir": "^6.4.4", 2190 + "picomatch": "^4.0.2" 2191 + }, 2192 + "engines": { 2193 + "node": ">=12.0.0" 2194 + }, 2195 + "funding": { 2196 + "url": "https://github.com/sponsors/SuperchupuDev" 2197 + } 2198 + }, 2199 + "node_modules/tinypool": { 2200 + "version": "1.0.2", 2201 + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.0.2.tgz", 2202 + "integrity": "sha512-al6n+QEANGFOMf/dmUMsuS5/r9B06uwlyNjZZql/zv8J7ybHCgoihBNORZCY2mzUuAnomQa2JdhyHKzZxPCrFA==", 2203 + "dev": true, 2204 + "license": "MIT", 2205 + "engines": { 2206 + "node": "^18.0.0 || >=20.0.0" 2207 + } 2208 + }, 2209 + "node_modules/tinyrainbow": { 2210 + "version": "2.0.0", 2211 + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", 2212 + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", 2213 + "dev": true, 2214 + "license": "MIT", 2215 + "engines": { 2216 + "node": ">=14.0.0" 2217 + } 2218 + }, 2219 + "node_modules/tinyspy": { 2220 + "version": "3.0.2", 2221 + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", 2222 + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", 2223 + "dev": true, 2224 + "license": "MIT", 2225 + "engines": { 2226 + "node": ">=14.0.0" 2227 + } 2228 + }, 2229 + "node_modules/tslib": { 2230 + "version": "2.8.1", 2231 + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", 2232 + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", 2233 + "dev": true, 2234 + "license": "0BSD", 2235 + "optional": true 2236 + }, 2237 + "node_modules/ufo": { 2238 + "version": "1.6.1", 2239 + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.1.tgz", 2240 + "integrity": "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==", 2241 + "dev": true, 2242 + "license": "MIT" 2243 + }, 2244 + "node_modules/undici": { 2245 + "version": "5.29.0", 2246 + "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz", 2247 + "integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==", 2248 + "dev": true, 2249 + "license": "MIT", 2250 + "dependencies": { 2251 + "@fastify/busboy": "^2.0.0" 2252 + }, 2253 + "engines": { 2254 + "node": ">=14.0" 2255 + } 2256 + }, 2257 + "node_modules/unenv": { 2258 + "version": "2.0.0-rc.15", 2259 + "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.15.tgz", 2260 + "integrity": "sha512-J/rEIZU8w6FOfLNz/hNKsnY+fFHWnu9MH4yRbSZF3xbbGHovcetXPs7sD+9p8L6CeNC//I9bhRYAOsBt2u7/OA==", 2261 + "dev": true, 2262 + "license": "MIT", 2263 + "dependencies": { 2264 + "defu": "^6.1.4", 2265 + "exsolve": "^1.0.4", 2266 + "ohash": "^2.0.11", 2267 + "pathe": "^2.0.3", 2268 + "ufo": "^1.5.4" 2269 + } 2270 + }, 2271 + "node_modules/vite": { 2272 + "version": "6.3.4", 2273 + "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.4.tgz", 2274 + "integrity": "sha512-BiReIiMS2fyFqbqNT/Qqt4CVITDU9M9vE+DKcVAsB+ZV0wvTKd+3hMbkpxz1b+NmEDMegpVbisKiAZOnvO92Sw==", 2275 + "dev": true, 2276 + "license": "MIT", 2277 + "dependencies": { 2278 + "esbuild": "^0.25.0", 2279 + "fdir": "^6.4.4", 2280 + "picomatch": "^4.0.2", 2281 + "postcss": "^8.5.3", 2282 + "rollup": "^4.34.9", 2283 + "tinyglobby": "^0.2.13" 2284 + }, 2285 + "bin": { 2286 + "vite": "bin/vite.js" 2287 + }, 2288 + "engines": { 2289 + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" 2290 + }, 2291 + "funding": { 2292 + "url": "https://github.com/vitejs/vite?sponsor=1" 2293 + }, 2294 + "optionalDependencies": { 2295 + "fsevents": "~2.3.3" 2296 + }, 2297 + "peerDependencies": { 2298 + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", 2299 + "jiti": ">=1.21.0", 2300 + "less": "*", 2301 + "lightningcss": "^1.21.0", 2302 + "sass": "*", 2303 + "sass-embedded": "*", 2304 + "stylus": "*", 2305 + "sugarss": "*", 2306 + "terser": "^5.16.0", 2307 + "tsx": "^4.8.1", 2308 + "yaml": "^2.4.2" 2309 + }, 2310 + "peerDependenciesMeta": { 2311 + "@types/node": { 2312 + "optional": true 2313 + }, 2314 + "jiti": { 2315 + "optional": true 2316 + }, 2317 + "less": { 2318 + "optional": true 2319 + }, 2320 + "lightningcss": { 2321 + "optional": true 2322 + }, 2323 + "sass": { 2324 + "optional": true 2325 + }, 2326 + "sass-embedded": { 2327 + "optional": true 2328 + }, 2329 + "stylus": { 2330 + "optional": true 2331 + }, 2332 + "sugarss": { 2333 + "optional": true 2334 + }, 2335 + "terser": { 2336 + "optional": true 2337 + }, 2338 + "tsx": { 2339 + "optional": true 2340 + }, 2341 + "yaml": { 2342 + "optional": true 2343 + } 2344 + } 2345 + }, 2346 + "node_modules/vite-node": { 2347 + "version": "3.0.9", 2348 + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.0.9.tgz", 2349 + "integrity": "sha512-w3Gdx7jDcuT9cNn9jExXgOyKmf5UOTb6WMHz8LGAm54eS1Elf5OuBhCxl6zJxGhEeIkgsE1WbHuoL0mj/UXqXg==", 2350 + "dev": true, 2351 + "license": "MIT", 2352 + "dependencies": { 2353 + "cac": "^6.7.14", 2354 + "debug": "^4.4.0", 2355 + "es-module-lexer": "^1.6.0", 2356 + "pathe": "^2.0.3", 2357 + "vite": "^5.0.0 || ^6.0.0" 2358 + }, 2359 + "bin": { 2360 + "vite-node": "vite-node.mjs" 2361 + }, 2362 + "engines": { 2363 + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" 2364 + }, 2365 + "funding": { 2366 + "url": "https://opencollective.com/vitest" 2367 + } 2368 + }, 2369 + "node_modules/vitest": { 2370 + "version": "3.0.9", 2371 + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.0.9.tgz", 2372 + "integrity": "sha512-BbcFDqNyBlfSpATmTtXOAOj71RNKDDvjBM/uPfnxxVGrG+FSH2RQIwgeEngTaTkuU/h0ScFvf+tRcKfYXzBybQ==", 2373 + "dev": true, 2374 + "license": "MIT", 2375 + "dependencies": { 2376 + "@vitest/expect": "3.0.9", 2377 + "@vitest/mocker": "3.0.9", 2378 + "@vitest/pretty-format": "^3.0.9", 2379 + "@vitest/runner": "3.0.9", 2380 + "@vitest/snapshot": "3.0.9", 2381 + "@vitest/spy": "3.0.9", 2382 + "@vitest/utils": "3.0.9", 2383 + "chai": "^5.2.0", 2384 + "debug": "^4.4.0", 2385 + "expect-type": "^1.1.0", 2386 + "magic-string": "^0.30.17", 2387 + "pathe": "^2.0.3", 2388 + "std-env": "^3.8.0", 2389 + "tinybench": "^2.9.0", 2390 + "tinyexec": "^0.3.2", 2391 + "tinypool": "^1.0.2", 2392 + "tinyrainbow": "^2.0.0", 2393 + "vite": "^5.0.0 || ^6.0.0", 2394 + "vite-node": "3.0.9", 2395 + "why-is-node-running": "^2.3.0" 2396 + }, 2397 + "bin": { 2398 + "vitest": "vitest.mjs" 2399 + }, 2400 + "engines": { 2401 + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" 2402 + }, 2403 + "funding": { 2404 + "url": "https://opencollective.com/vitest" 2405 + }, 2406 + "peerDependencies": { 2407 + "@edge-runtime/vm": "*", 2408 + "@types/debug": "^4.1.12", 2409 + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", 2410 + "@vitest/browser": "3.0.9", 2411 + "@vitest/ui": "3.0.9", 2412 + "happy-dom": "*", 2413 + "jsdom": "*" 2414 + }, 2415 + "peerDependenciesMeta": { 2416 + "@edge-runtime/vm": { 2417 + "optional": true 2418 + }, 2419 + "@types/debug": { 2420 + "optional": true 2421 + }, 2422 + "@types/node": { 2423 + "optional": true 2424 + }, 2425 + "@vitest/browser": { 2426 + "optional": true 2427 + }, 2428 + "@vitest/ui": { 2429 + "optional": true 2430 + }, 2431 + "happy-dom": { 2432 + "optional": true 2433 + }, 2434 + "jsdom": { 2435 + "optional": true 2436 + } 2437 + } 2438 + }, 2439 + "node_modules/why-is-node-running": { 2440 + "version": "2.3.0", 2441 + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", 2442 + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", 2443 + "dev": true, 2444 + "license": "MIT", 2445 + "dependencies": { 2446 + "siginfo": "^2.0.0", 2447 + "stackback": "0.0.2" 2448 + }, 2449 + "bin": { 2450 + "why-is-node-running": "cli.js" 2451 + }, 2452 + "engines": { 2453 + "node": ">=8" 2454 + } 2455 + }, 2456 + "node_modules/workerd": { 2457 + "version": "1.20250428.0", 2458 + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20250428.0.tgz", 2459 + "integrity": "sha512-JJNWkHkwPQKQdvtM9UORijgYdcdJsihA4SfYjwh02IUQsdMyZ9jizV1sX9yWi9B9ptlohTW8UNHJEATuphGgdg==", 2460 + "dev": true, 2461 + "hasInstallScript": true, 2462 + "license": "Apache-2.0", 2463 + "bin": { 2464 + "workerd": "bin/workerd" 2465 + }, 2466 + "engines": { 2467 + "node": ">=16" 2468 + }, 2469 + "optionalDependencies": { 2470 + "@cloudflare/workerd-darwin-64": "1.20250428.0", 2471 + "@cloudflare/workerd-darwin-arm64": "1.20250428.0", 2472 + "@cloudflare/workerd-linux-64": "1.20250428.0", 2473 + "@cloudflare/workerd-linux-arm64": "1.20250428.0", 2474 + "@cloudflare/workerd-windows-64": "1.20250428.0" 2475 + } 2476 + }, 2477 + "node_modules/wrangler": { 2478 + "version": "4.14.1", 2479 + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.14.1.tgz", 2480 + "integrity": "sha512-EU7IThP7i68TBftJJSveogvWZ5k/WRijcJh3UclDWiWWhDZTPbL6LOJEFhHKqFzHOaC4Y2Aewt48rfTz0e7oCw==", 2481 + "dev": true, 2482 + "license": "MIT OR Apache-2.0", 2483 + "dependencies": { 2484 + "@cloudflare/kv-asset-handler": "0.4.0", 2485 + "@cloudflare/unenv-preset": "2.3.1", 2486 + "blake3-wasm": "2.1.5", 2487 + "esbuild": "0.25.2", 2488 + "miniflare": "4.20250428.1", 2489 + "path-to-regexp": "6.3.0", 2490 + "unenv": "2.0.0-rc.15", 2491 + "workerd": "1.20250428.0" 2492 + }, 2493 + "bin": { 2494 + "wrangler": "bin/wrangler.js", 2495 + "wrangler2": "bin/wrangler.js" 2496 + }, 2497 + "engines": { 2498 + "node": ">=18.0.0" 2499 + }, 2500 + "optionalDependencies": { 2501 + "fsevents": "~2.3.2", 2502 + "sharp": "^0.33.5" 2503 + }, 2504 + "peerDependencies": { 2505 + "@cloudflare/workers-types": "^4.20250428.0" 2506 + }, 2507 + "peerDependenciesMeta": { 2508 + "@cloudflare/workers-types": { 2509 + "optional": true 2510 + } 2511 + } 2512 + }, 2513 + "node_modules/wrangler/node_modules/@esbuild/aix-ppc64": { 2514 + "version": "0.25.2", 2515 + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.2.tgz", 2516 + "integrity": "sha512-wCIboOL2yXZym2cgm6mlA742s9QeJ8DjGVaL39dLN4rRwrOgOyYSnOaFPhKZGLb2ngj4EyfAFjsNJwPXZvseag==", 2517 + "cpu": [ 2518 + "ppc64" 2519 + ], 2520 + "dev": true, 2521 + "license": "MIT", 2522 + "optional": true, 2523 + "os": [ 2524 + "aix" 2525 + ], 2526 + "engines": { 2527 + "node": ">=18" 2528 + } 2529 + }, 2530 + "node_modules/wrangler/node_modules/@esbuild/android-arm": { 2531 + "version": "0.25.2", 2532 + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.2.tgz", 2533 + "integrity": "sha512-NQhH7jFstVY5x8CKbcfa166GoV0EFkaPkCKBQkdPJFvo5u+nGXLEH/ooniLb3QI8Fk58YAx7nsPLozUWfCBOJA==", 2534 + "cpu": [ 2535 + "arm" 2536 + ], 2537 + "dev": true, 2538 + "license": "MIT", 2539 + "optional": true, 2540 + "os": [ 2541 + "android" 2542 + ], 2543 + "engines": { 2544 + "node": ">=18" 2545 + } 2546 + }, 2547 + "node_modules/wrangler/node_modules/@esbuild/android-arm64": { 2548 + "version": "0.25.2", 2549 + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.2.tgz", 2550 + "integrity": "sha512-5ZAX5xOmTligeBaeNEPnPaeEuah53Id2tX4c2CVP3JaROTH+j4fnfHCkr1PjXMd78hMst+TlkfKcW/DlTq0i4w==", 2551 + "cpu": [ 2552 + "arm64" 2553 + ], 2554 + "dev": true, 2555 + "license": "MIT", 2556 + "optional": true, 2557 + "os": [ 2558 + "android" 2559 + ], 2560 + "engines": { 2561 + "node": ">=18" 2562 + } 2563 + }, 2564 + "node_modules/wrangler/node_modules/@esbuild/android-x64": { 2565 + "version": "0.25.2", 2566 + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.2.tgz", 2567 + "integrity": "sha512-Ffcx+nnma8Sge4jzddPHCZVRvIfQ0kMsUsCMcJRHkGJ1cDmhe4SsrYIjLUKn1xpHZybmOqCWwB0zQvsjdEHtkg==", 2568 + "cpu": [ 2569 + "x64" 2570 + ], 2571 + "dev": true, 2572 + "license": "MIT", 2573 + "optional": true, 2574 + "os": [ 2575 + "android" 2576 + ], 2577 + "engines": { 2578 + "node": ">=18" 2579 + } 2580 + }, 2581 + "node_modules/wrangler/node_modules/@esbuild/darwin-arm64": { 2582 + "version": "0.25.2", 2583 + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.2.tgz", 2584 + "integrity": "sha512-MpM6LUVTXAzOvN4KbjzU/q5smzryuoNjlriAIx+06RpecwCkL9JpenNzpKd2YMzLJFOdPqBpuub6eVRP5IgiSA==", 2585 + "cpu": [ 2586 + "arm64" 2587 + ], 2588 + "dev": true, 2589 + "license": "MIT", 2590 + "optional": true, 2591 + "os": [ 2592 + "darwin" 2593 + ], 2594 + "engines": { 2595 + "node": ">=18" 2596 + } 2597 + }, 2598 + "node_modules/wrangler/node_modules/@esbuild/darwin-x64": { 2599 + "version": "0.25.2", 2600 + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.2.tgz", 2601 + "integrity": "sha512-5eRPrTX7wFyuWe8FqEFPG2cU0+butQQVNcT4sVipqjLYQjjh8a8+vUTfgBKM88ObB85ahsnTwF7PSIt6PG+QkA==", 2602 + "cpu": [ 2603 + "x64" 2604 + ], 2605 + "dev": true, 2606 + "license": "MIT", 2607 + "optional": true, 2608 + "os": [ 2609 + "darwin" 2610 + ], 2611 + "engines": { 2612 + "node": ">=18" 2613 + } 2614 + }, 2615 + "node_modules/wrangler/node_modules/@esbuild/freebsd-arm64": { 2616 + "version": "0.25.2", 2617 + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.2.tgz", 2618 + "integrity": "sha512-mLwm4vXKiQ2UTSX4+ImyiPdiHjiZhIaE9QvC7sw0tZ6HoNMjYAqQpGyui5VRIi5sGd+uWq940gdCbY3VLvsO1w==", 2619 + "cpu": [ 2620 + "arm64" 2621 + ], 2622 + "dev": true, 2623 + "license": "MIT", 2624 + "optional": true, 2625 + "os": [ 2626 + "freebsd" 2627 + ], 2628 + "engines": { 2629 + "node": ">=18" 2630 + } 2631 + }, 2632 + "node_modules/wrangler/node_modules/@esbuild/freebsd-x64": { 2633 + "version": "0.25.2", 2634 + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.2.tgz", 2635 + "integrity": "sha512-6qyyn6TjayJSwGpm8J9QYYGQcRgc90nmfdUb0O7pp1s4lTY+9D0H9O02v5JqGApUyiHOtkz6+1hZNvNtEhbwRQ==", 2636 + "cpu": [ 2637 + "x64" 2638 + ], 2639 + "dev": true, 2640 + "license": "MIT", 2641 + "optional": true, 2642 + "os": [ 2643 + "freebsd" 2644 + ], 2645 + "engines": { 2646 + "node": ">=18" 2647 + } 2648 + }, 2649 + "node_modules/wrangler/node_modules/@esbuild/linux-arm": { 2650 + "version": "0.25.2", 2651 + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.2.tgz", 2652 + "integrity": "sha512-UHBRgJcmjJv5oeQF8EpTRZs/1knq6loLxTsjc3nxO9eXAPDLcWW55flrMVc97qFPbmZP31ta1AZVUKQzKTzb0g==", 2653 + "cpu": [ 2654 + "arm" 2655 + ], 2656 + "dev": true, 2657 + "license": "MIT", 2658 + "optional": true, 2659 + "os": [ 2660 + "linux" 2661 + ], 2662 + "engines": { 2663 + "node": ">=18" 2664 + } 2665 + }, 2666 + "node_modules/wrangler/node_modules/@esbuild/linux-arm64": { 2667 + "version": "0.25.2", 2668 + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.2.tgz", 2669 + "integrity": "sha512-gq/sjLsOyMT19I8obBISvhoYiZIAaGF8JpeXu1u8yPv8BE5HlWYobmlsfijFIZ9hIVGYkbdFhEqC0NvM4kNO0g==", 2670 + "cpu": [ 2671 + "arm64" 2672 + ], 2673 + "dev": true, 2674 + "license": "MIT", 2675 + "optional": true, 2676 + "os": [ 2677 + "linux" 2678 + ], 2679 + "engines": { 2680 + "node": ">=18" 2681 + } 2682 + }, 2683 + "node_modules/wrangler/node_modules/@esbuild/linux-ia32": { 2684 + "version": "0.25.2", 2685 + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.2.tgz", 2686 + "integrity": "sha512-bBYCv9obgW2cBP+2ZWfjYTU+f5cxRoGGQ5SeDbYdFCAZpYWrfjjfYwvUpP8MlKbP0nwZ5gyOU/0aUzZ5HWPuvQ==", 2687 + "cpu": [ 2688 + "ia32" 2689 + ], 2690 + "dev": true, 2691 + "license": "MIT", 2692 + "optional": true, 2693 + "os": [ 2694 + "linux" 2695 + ], 2696 + "engines": { 2697 + "node": ">=18" 2698 + } 2699 + }, 2700 + "node_modules/wrangler/node_modules/@esbuild/linux-loong64": { 2701 + "version": "0.25.2", 2702 + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.2.tgz", 2703 + "integrity": "sha512-SHNGiKtvnU2dBlM5D8CXRFdd+6etgZ9dXfaPCeJtz+37PIUlixvlIhI23L5khKXs3DIzAn9V8v+qb1TRKrgT5w==", 2704 + "cpu": [ 2705 + "loong64" 2706 + ], 2707 + "dev": true, 2708 + "license": "MIT", 2709 + "optional": true, 2710 + "os": [ 2711 + "linux" 2712 + ], 2713 + "engines": { 2714 + "node": ">=18" 2715 + } 2716 + }, 2717 + "node_modules/wrangler/node_modules/@esbuild/linux-mips64el": { 2718 + "version": "0.25.2", 2719 + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.2.tgz", 2720 + "integrity": "sha512-hDDRlzE6rPeoj+5fsADqdUZl1OzqDYow4TB4Y/3PlKBD0ph1e6uPHzIQcv2Z65u2K0kpeByIyAjCmjn1hJgG0Q==", 2721 + "cpu": [ 2722 + "mips64el" 2723 + ], 2724 + "dev": true, 2725 + "license": "MIT", 2726 + "optional": true, 2727 + "os": [ 2728 + "linux" 2729 + ], 2730 + "engines": { 2731 + "node": ">=18" 2732 + } 2733 + }, 2734 + "node_modules/wrangler/node_modules/@esbuild/linux-ppc64": { 2735 + "version": "0.25.2", 2736 + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.2.tgz", 2737 + "integrity": "sha512-tsHu2RRSWzipmUi9UBDEzc0nLc4HtpZEI5Ba+Omms5456x5WaNuiG3u7xh5AO6sipnJ9r4cRWQB2tUjPyIkc6g==", 2738 + "cpu": [ 2739 + "ppc64" 2740 + ], 2741 + "dev": true, 2742 + "license": "MIT", 2743 + "optional": true, 2744 + "os": [ 2745 + "linux" 2746 + ], 2747 + "engines": { 2748 + "node": ">=18" 2749 + } 2750 + }, 2751 + "node_modules/wrangler/node_modules/@esbuild/linux-riscv64": { 2752 + "version": "0.25.2", 2753 + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.2.tgz", 2754 + "integrity": "sha512-k4LtpgV7NJQOml/10uPU0s4SAXGnowi5qBSjaLWMojNCUICNu7TshqHLAEbkBdAszL5TabfvQ48kK84hyFzjnw==", 2755 + "cpu": [ 2756 + "riscv64" 2757 + ], 2758 + "dev": true, 2759 + "license": "MIT", 2760 + "optional": true, 2761 + "os": [ 2762 + "linux" 2763 + ], 2764 + "engines": { 2765 + "node": ">=18" 2766 + } 2767 + }, 2768 + "node_modules/wrangler/node_modules/@esbuild/linux-s390x": { 2769 + "version": "0.25.2", 2770 + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.2.tgz", 2771 + "integrity": "sha512-GRa4IshOdvKY7M/rDpRR3gkiTNp34M0eLTaC1a08gNrh4u488aPhuZOCpkF6+2wl3zAN7L7XIpOFBhnaE3/Q8Q==", 2772 + "cpu": [ 2773 + "s390x" 2774 + ], 2775 + "dev": true, 2776 + "license": "MIT", 2777 + "optional": true, 2778 + "os": [ 2779 + "linux" 2780 + ], 2781 + "engines": { 2782 + "node": ">=18" 2783 + } 2784 + }, 2785 + "node_modules/wrangler/node_modules/@esbuild/linux-x64": { 2786 + "version": "0.25.2", 2787 + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.2.tgz", 2788 + "integrity": "sha512-QInHERlqpTTZ4FRB0fROQWXcYRD64lAoiegezDunLpalZMjcUcld3YzZmVJ2H/Cp0wJRZ8Xtjtj0cEHhYc/uUg==", 2789 + "cpu": [ 2790 + "x64" 2791 + ], 2792 + "dev": true, 2793 + "license": "MIT", 2794 + "optional": true, 2795 + "os": [ 2796 + "linux" 2797 + ], 2798 + "engines": { 2799 + "node": ">=18" 2800 + } 2801 + }, 2802 + "node_modules/wrangler/node_modules/@esbuild/netbsd-arm64": { 2803 + "version": "0.25.2", 2804 + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.2.tgz", 2805 + "integrity": "sha512-talAIBoY5M8vHc6EeI2WW9d/CkiO9MQJ0IOWX8hrLhxGbro/vBXJvaQXefW2cP0z0nQVTdQ/eNyGFV1GSKrxfw==", 2806 + "cpu": [ 2807 + "arm64" 2808 + ], 2809 + "dev": true, 2810 + "license": "MIT", 2811 + "optional": true, 2812 + "os": [ 2813 + "netbsd" 2814 + ], 2815 + "engines": { 2816 + "node": ">=18" 2817 + } 2818 + }, 2819 + "node_modules/wrangler/node_modules/@esbuild/netbsd-x64": { 2820 + "version": "0.25.2", 2821 + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.2.tgz", 2822 + "integrity": "sha512-voZT9Z+tpOxrvfKFyfDYPc4DO4rk06qamv1a/fkuzHpiVBMOhpjK+vBmWM8J1eiB3OLSMFYNaOaBNLXGChf5tg==", 2823 + "cpu": [ 2824 + "x64" 2825 + ], 2826 + "dev": true, 2827 + "license": "MIT", 2828 + "optional": true, 2829 + "os": [ 2830 + "netbsd" 2831 + ], 2832 + "engines": { 2833 + "node": ">=18" 2834 + } 2835 + }, 2836 + "node_modules/wrangler/node_modules/@esbuild/openbsd-arm64": { 2837 + "version": "0.25.2", 2838 + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.2.tgz", 2839 + "integrity": "sha512-dcXYOC6NXOqcykeDlwId9kB6OkPUxOEqU+rkrYVqJbK2hagWOMrsTGsMr8+rW02M+d5Op5NNlgMmjzecaRf7Tg==", 2840 + "cpu": [ 2841 + "arm64" 2842 + ], 2843 + "dev": true, 2844 + "license": "MIT", 2845 + "optional": true, 2846 + "os": [ 2847 + "openbsd" 2848 + ], 2849 + "engines": { 2850 + "node": ">=18" 2851 + } 2852 + }, 2853 + "node_modules/wrangler/node_modules/@esbuild/openbsd-x64": { 2854 + "version": "0.25.2", 2855 + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.2.tgz", 2856 + "integrity": "sha512-t/TkWwahkH0Tsgoq1Ju7QfgGhArkGLkF1uYz8nQS/PPFlXbP5YgRpqQR3ARRiC2iXoLTWFxc6DJMSK10dVXluw==", 2857 + "cpu": [ 2858 + "x64" 2859 + ], 2860 + "dev": true, 2861 + "license": "MIT", 2862 + "optional": true, 2863 + "os": [ 2864 + "openbsd" 2865 + ], 2866 + "engines": { 2867 + "node": ">=18" 2868 + } 2869 + }, 2870 + "node_modules/wrangler/node_modules/@esbuild/sunos-x64": { 2871 + "version": "0.25.2", 2872 + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.2.tgz", 2873 + "integrity": "sha512-cfZH1co2+imVdWCjd+D1gf9NjkchVhhdpgb1q5y6Hcv9TP6Zi9ZG/beI3ig8TvwT9lH9dlxLq5MQBBgwuj4xvA==", 2874 + "cpu": [ 2875 + "x64" 2876 + ], 2877 + "dev": true, 2878 + "license": "MIT", 2879 + "optional": true, 2880 + "os": [ 2881 + "sunos" 2882 + ], 2883 + "engines": { 2884 + "node": ">=18" 2885 + } 2886 + }, 2887 + "node_modules/wrangler/node_modules/@esbuild/win32-arm64": { 2888 + "version": "0.25.2", 2889 + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.2.tgz", 2890 + "integrity": "sha512-7Loyjh+D/Nx/sOTzV8vfbB3GJuHdOQyrOryFdZvPHLf42Tk9ivBU5Aedi7iyX+x6rbn2Mh68T4qq1SDqJBQO5Q==", 2891 + "cpu": [ 2892 + "arm64" 2893 + ], 2894 + "dev": true, 2895 + "license": "MIT", 2896 + "optional": true, 2897 + "os": [ 2898 + "win32" 2899 + ], 2900 + "engines": { 2901 + "node": ">=18" 2902 + } 2903 + }, 2904 + "node_modules/wrangler/node_modules/@esbuild/win32-ia32": { 2905 + "version": "0.25.2", 2906 + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.2.tgz", 2907 + "integrity": "sha512-WRJgsz9un0nqZJ4MfhabxaD9Ft8KioqU3JMinOTvobbX6MOSUigSBlogP8QB3uxpJDsFS6yN+3FDBdqE5lg9kg==", 2908 + "cpu": [ 2909 + "ia32" 2910 + ], 2911 + "dev": true, 2912 + "license": "MIT", 2913 + "optional": true, 2914 + "os": [ 2915 + "win32" 2916 + ], 2917 + "engines": { 2918 + "node": ">=18" 2919 + } 2920 + }, 2921 + "node_modules/wrangler/node_modules/@esbuild/win32-x64": { 2922 + "version": "0.25.2", 2923 + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.2.tgz", 2924 + "integrity": "sha512-kM3HKb16VIXZyIeVrM1ygYmZBKybX8N4p754bw390wGO3Tf2j4L2/WYL+4suWujpgf6GBYs3jv7TyUivdd05JA==", 2925 + "cpu": [ 2926 + "x64" 2927 + ], 2928 + "dev": true, 2929 + "license": "MIT", 2930 + "optional": true, 2931 + "os": [ 2932 + "win32" 2933 + ], 2934 + "engines": { 2935 + "node": ">=18" 2936 + } 2937 + }, 2938 + "node_modules/wrangler/node_modules/esbuild": { 2939 + "version": "0.25.2", 2940 + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.2.tgz", 2941 + "integrity": "sha512-16854zccKPnC+toMywC+uKNeYSv+/eXkevRAfwRD/G9Cleq66m8XFIrigkbvauLLlCfDL45Q2cWegSg53gGBnQ==", 2942 + "dev": true, 2943 + "hasInstallScript": true, 2944 + "license": "MIT", 2945 + "bin": { 2946 + "esbuild": "bin/esbuild" 2947 + }, 2948 + "engines": { 2949 + "node": ">=18" 2950 + }, 2951 + "optionalDependencies": { 2952 + "@esbuild/aix-ppc64": "0.25.2", 2953 + "@esbuild/android-arm": "0.25.2", 2954 + "@esbuild/android-arm64": "0.25.2", 2955 + "@esbuild/android-x64": "0.25.2", 2956 + "@esbuild/darwin-arm64": "0.25.2", 2957 + "@esbuild/darwin-x64": "0.25.2", 2958 + "@esbuild/freebsd-arm64": "0.25.2", 2959 + "@esbuild/freebsd-x64": "0.25.2", 2960 + "@esbuild/linux-arm": "0.25.2", 2961 + "@esbuild/linux-arm64": "0.25.2", 2962 + "@esbuild/linux-ia32": "0.25.2", 2963 + "@esbuild/linux-loong64": "0.25.2", 2964 + "@esbuild/linux-mips64el": "0.25.2", 2965 + "@esbuild/linux-ppc64": "0.25.2", 2966 + "@esbuild/linux-riscv64": "0.25.2", 2967 + "@esbuild/linux-s390x": "0.25.2", 2968 + "@esbuild/linux-x64": "0.25.2", 2969 + "@esbuild/netbsd-arm64": "0.25.2", 2970 + "@esbuild/netbsd-x64": "0.25.2", 2971 + "@esbuild/openbsd-arm64": "0.25.2", 2972 + "@esbuild/openbsd-x64": "0.25.2", 2973 + "@esbuild/sunos-x64": "0.25.2", 2974 + "@esbuild/win32-arm64": "0.25.2", 2975 + "@esbuild/win32-ia32": "0.25.2", 2976 + "@esbuild/win32-x64": "0.25.2" 2977 + } 2978 + }, 2979 + "node_modules/ws": { 2980 + "version": "8.18.0", 2981 + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", 2982 + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", 2983 + "dev": true, 2984 + "license": "MIT", 2985 + "engines": { 2986 + "node": ">=10.0.0" 2987 + }, 2988 + "peerDependencies": { 2989 + "bufferutil": "^4.0.1", 2990 + "utf-8-validate": ">=5.0.2" 2991 + }, 2992 + "peerDependenciesMeta": { 2993 + "bufferutil": { 2994 + "optional": true 2995 + }, 2996 + "utf-8-validate": { 2997 + "optional": true 2998 + } 2999 + } 3000 + }, 3001 + "node_modules/youch": { 3002 + "version": "3.3.4", 3003 + "resolved": "https://registry.npmjs.org/youch/-/youch-3.3.4.tgz", 3004 + "integrity": "sha512-UeVBXie8cA35DS6+nBkls68xaBBXCye0CNznrhszZjTbRVnJKQuNsyLKBTTL4ln1o1rh2PKtv35twV7irj5SEg==", 3005 + "dev": true, 3006 + "license": "MIT", 3007 + "dependencies": { 3008 + "cookie": "^0.7.1", 3009 + "mustache": "^4.2.0", 3010 + "stacktracey": "^2.1.8" 3011 + } 3012 + }, 3013 + "node_modules/zod": { 3014 + "version": "3.24.3", 3015 + "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.3.tgz", 3016 + "integrity": "sha512-HhY1oqzWCQWuUqvBFnsyrtZRhyPeR7SUGv+C4+MsisMuVfSPx8HpwWqH8tRahSlt6M3PiFAcoeFhZAqIXTxoSg==", 3017 + "dev": true, 3018 + "license": "MIT", 3019 + "funding": { 3020 + "url": "https://github.com/sponsors/colinhacks" 3021 + } 3022 + } 3023 + } 3024 + }
+16
avatar/package.json
···
··· 1 + { 2 + "name": "avatar", 3 + "version": "0.0.0", 4 + "private": true, 5 + "scripts": { 6 + "deploy": "wrangler deploy", 7 + "dev": "wrangler dev", 8 + "start": "wrangler dev", 9 + "test": "vitest" 10 + }, 11 + "devDependencies": { 12 + "@cloudflare/vitest-pool-workers": "^0.8.19", 13 + "vitest": "~3.0.7", 14 + "wrangler": "^4.14.1" 15 + } 16 + }
+11
avatar/readme.md
···
··· 1 + # avatar 2 + 3 + avatar is a small service that fetches your pretty Bluesky avatar and caches it on Cloudflare. 4 + It uses a shared secret `AVATAR_SHARED_SECRET` to ensure requests only originate from the trusted appview. 5 + 6 + It's deployed using `wrangler` like so: 7 + 8 + ``` 9 + npx wrangler deploy 10 + npx wrangler secrets put AVATAR_SHARED_SECRET 11 + ```
+88
avatar/src/index.js
···
··· 1 + export default { 2 + async fetch(request, env) { 3 + const url = new URL(request.url); 4 + const { pathname } = url; 5 + 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 + } 10 + 11 + const cache = caches.default; 12 + 13 + let cacheKey = request.url; 14 + let response = await cache.match(cacheKey); 15 + if (response) { 16 + return response; 17 + } 18 + 19 + const pathParts = pathname.slice(1).split('/'); 20 + if (pathParts.length < 2) { 21 + return new Response('Bad URL', { status: 400 }); 22 + } 23 + 24 + const [signatureHex, actor] = pathParts; 25 + 26 + const actorBytes = new TextEncoder().encode(actor); 27 + 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 + ); 35 + 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(''); 40 + 41 + console.log({ 42 + level: 'debug', 43 + message: 'avatar request for: ' + actor, 44 + computedSignature: computedSig, 45 + providedSignature: signatureHex, 46 + }); 47 + 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); 50 + 51 + if (!valid) { 52 + return new Response('Invalid signature', { status: 403 }); 53 + } 54 + 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; 59 + 60 + if (!avatar) { 61 + return new Response(`avatar not found for ${actor}.`, { status: 404 }); 62 + } 63 + 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 + } 69 + 70 + const avatarData = await avatarResponse.arrayBuffer(); 71 + const contentType = avatarResponse.headers.get('content-type') || 'image/jpeg'; 72 + 73 + response = new Response(avatarData, { 74 + headers: { 75 + 'Content-Type': contentType, 76 + 'Cache-Control': 'public, max-age=3600', 77 + }, 78 + }); 79 + 80 + // cache it in cf using request.url as the key 81 + await cache.put(cacheKey, response.clone()); 82 + 83 + return response; 84 + } catch (error) { 85 + return new Response(`error fetching avatar: ${error.message}`, { status: 500 }); 86 + } 87 + }, 88 + };
+15
avatar/wrangler.jsonc
···
··· 1 + { 2 + "$schema": "node_modules/wrangler/config-schema.json", 3 + "name": "avatar", 4 + "main": "src/index.js", 5 + "compatibility_date": "2025-05-03", 6 + "observability": { 7 + "enabled": true, 8 + }, 9 + "routes": [ 10 + { 11 + "pattern": "avatar.tangled.sh", 12 + "custom_domain": true, 13 + }, 14 + ], 15 + }
+174
camo/.gitignore
···
··· 1 + # Logs 2 + 3 + ./test.sh 4 + 5 + logs 6 + _.log 7 + npm-debug.log_ 8 + yarn-debug.log* 9 + yarn-error.log* 10 + lerna-debug.log* 11 + .pnpm-debug.log* 12 + 13 + # Diagnostic reports (https://nodejs.org/api/report.html) 14 + 15 + report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json 16 + 17 + # Runtime data 18 + 19 + pids 20 + _.pid 21 + _.seed 22 + \*.pid.lock 23 + 24 + # Directory for instrumented libs generated by jscoverage/JSCover 25 + 26 + lib-cov 27 + 28 + # Coverage directory used by tools like istanbul 29 + 30 + coverage 31 + \*.lcov 32 + 33 + # nyc test coverage 34 + 35 + .nyc_output 36 + 37 + # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 38 + 39 + .grunt 40 + 41 + # Bower dependency directory (https://bower.io/) 42 + 43 + bower_components 44 + 45 + # node-waf configuration 46 + 47 + .lock-wscript 48 + 49 + # Compiled binary addons (https://nodejs.org/api/addons.html) 50 + 51 + build/Release 52 + 53 + # Dependency directories 54 + 55 + node_modules/ 56 + jspm_packages/ 57 + 58 + # Snowpack dependency directory (https://snowpack.dev/) 59 + 60 + web_modules/ 61 + 62 + # TypeScript cache 63 + 64 + \*.tsbuildinfo 65 + 66 + # Optional npm cache directory 67 + 68 + .npm 69 + 70 + # Optional eslint cache 71 + 72 + .eslintcache 73 + 74 + # Optional stylelint cache 75 + 76 + .stylelintcache 77 + 78 + # Microbundle cache 79 + 80 + .rpt2_cache/ 81 + .rts2_cache_cjs/ 82 + .rts2_cache_es/ 83 + .rts2_cache_umd/ 84 + 85 + # Optional REPL history 86 + 87 + .node_repl_history 88 + 89 + # Output of 'npm pack' 90 + 91 + \*.tgz 92 + 93 + # Yarn Integrity file 94 + 95 + .yarn-integrity 96 + 97 + # dotenv environment variable files 98 + 99 + .env 100 + .env.development.local 101 + .env.test.local 102 + .env.production.local 103 + .env.local 104 + 105 + # parcel-bundler cache (https://parceljs.org/) 106 + 107 + .cache 108 + .parcel-cache 109 + 110 + # Next.js build output 111 + 112 + .next 113 + out 114 + 115 + # Nuxt.js build / generate output 116 + 117 + .nuxt 118 + dist 119 + 120 + # Gatsby files 121 + 122 + .cache/ 123 + 124 + # Comment in the public line in if your project uses Gatsby and not Next.js 125 + 126 + # https://nextjs.org/blog/next-9-1#public-directory-support 127 + 128 + # public 129 + 130 + # vuepress build output 131 + 132 + .vuepress/dist 133 + 134 + # vuepress v2.x temp and cache directory 135 + 136 + .temp 137 + .cache 138 + 139 + # Docusaurus cache and generated files 140 + 141 + .docusaurus 142 + 143 + # Serverless directories 144 + 145 + .serverless/ 146 + 147 + # FuseBox cache 148 + 149 + .fusebox/ 150 + 151 + # DynamoDB Local files 152 + 153 + .dynamodb/ 154 + 155 + # TernJS port file 156 + 157 + .tern-port 158 + 159 + # Stores VSCode versions used for testing VSCode extensions 160 + 161 + .vscode-test 162 + 163 + # yarn v2 164 + 165 + .yarn/cache 166 + .yarn/unplugged 167 + .yarn/build-state.yml 168 + .yarn/install-state.gz 169 + .pnp.\* 170 + 171 + # wrangler project 172 + 173 + .dev.vars 174 + .wrangler/
+3024
camo/package-lock.json
···
··· 1 + { 2 + "name": "camo", 3 + "version": "0.0.0", 4 + "lockfileVersion": 3, 5 + "requires": true, 6 + "packages": { 7 + "": { 8 + "name": "camo", 9 + "version": "0.0.0", 10 + "devDependencies": { 11 + "@cloudflare/vitest-pool-workers": "^0.8.19", 12 + "vitest": "~3.0.7", 13 + "wrangler": "^4.14.1" 14 + } 15 + }, 16 + "node_modules/@cloudflare/kv-asset-handler": { 17 + "version": "0.4.0", 18 + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.4.0.tgz", 19 + "integrity": "sha512-+tv3z+SPp+gqTIcImN9o0hqE9xyfQjI1XD9pL6NuKjua9B1y7mNYv0S9cP+QEbA4ppVgGZEmKOvHX5G5Ei1CVA==", 20 + "dev": true, 21 + "license": "MIT OR Apache-2.0", 22 + "dependencies": { 23 + "mime": "^3.0.0" 24 + }, 25 + "engines": { 26 + "node": ">=18.0.0" 27 + } 28 + }, 29 + "node_modules/@cloudflare/unenv-preset": { 30 + "version": "2.3.1", 31 + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.3.1.tgz", 32 + "integrity": "sha512-Xq57Qd+ADpt6hibcVBO0uLG9zzRgyRhfCUgBT9s+g3+3Ivg5zDyVgLFy40ES1VdNcu8rPNSivm9A+kGP5IVaPg==", 33 + "dev": true, 34 + "license": "MIT OR Apache-2.0", 35 + "peerDependencies": { 36 + "unenv": "2.0.0-rc.15", 37 + "workerd": "^1.20250320.0" 38 + }, 39 + "peerDependenciesMeta": { 40 + "workerd": { 41 + "optional": true 42 + } 43 + } 44 + }, 45 + "node_modules/@cloudflare/vitest-pool-workers": { 46 + "version": "0.8.24", 47 + "resolved": "https://registry.npmjs.org/@cloudflare/vitest-pool-workers/-/vitest-pool-workers-0.8.24.tgz", 48 + "integrity": "sha512-wT2PABJQ9YLYWrVu4CRZOjvmjHkdbMyLTZPU9n/7JEMM3pgG8dY41F1Rj31UsXRQaXX39A/CTPGlk58dcMUysA==", 49 + "dev": true, 50 + "license": "MIT", 51 + "dependencies": { 52 + "birpc": "0.2.14", 53 + "cjs-module-lexer": "^1.2.3", 54 + "devalue": "^4.3.0", 55 + "miniflare": "4.20250428.1", 56 + "semver": "^7.7.1", 57 + "wrangler": "4.14.1", 58 + "zod": "^3.22.3" 59 + }, 60 + "peerDependencies": { 61 + "@vitest/runner": "2.0.x - 3.1.x", 62 + "@vitest/snapshot": "2.0.x - 3.1.x", 63 + "vitest": "2.0.x - 3.1.x" 64 + } 65 + }, 66 + "node_modules/@cloudflare/workerd-darwin-64": { 67 + "version": "1.20250428.0", 68 + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20250428.0.tgz", 69 + "integrity": "sha512-6nVe9oV4Hdec6ctzMtW80TiDvNTd2oFPi3VsKqSDVaJSJbL+4b6seyJ7G/UEPI+si6JhHBSLV2/9lNXNGLjClA==", 70 + "cpu": [ 71 + "x64" 72 + ], 73 + "dev": true, 74 + "license": "Apache-2.0", 75 + "optional": true, 76 + "os": [ 77 + "darwin" 78 + ], 79 + "engines": { 80 + "node": ">=16" 81 + } 82 + }, 83 + "node_modules/@cloudflare/workerd-darwin-arm64": { 84 + "version": "1.20250428.0", 85 + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20250428.0.tgz", 86 + "integrity": "sha512-/TB7bh7SIJ5f+6r4PHsAz7+9Qal/TK1cJuKFkUno1kqGlZbdrMwH0ATYwlWC/nBFeu2FB3NUolsTntEuy23hnQ==", 87 + "cpu": [ 88 + "arm64" 89 + ], 90 + "dev": true, 91 + "license": "Apache-2.0", 92 + "optional": true, 93 + "os": [ 94 + "darwin" 95 + ], 96 + "engines": { 97 + "node": ">=16" 98 + } 99 + }, 100 + "node_modules/@cloudflare/workerd-linux-64": { 101 + "version": "1.20250428.0", 102 + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20250428.0.tgz", 103 + "integrity": "sha512-9eCbj+R3CKqpiXP6DfAA20DxKge+OTj7Hyw3ZewiEhWH9INIHiJwJQYybu4iq9kJEGjnGvxgguLFjSCWm26hgg==", 104 + "cpu": [ 105 + "x64" 106 + ], 107 + "dev": true, 108 + "license": "Apache-2.0", 109 + "optional": true, 110 + "os": [ 111 + "linux" 112 + ], 113 + "engines": { 114 + "node": ">=16" 115 + } 116 + }, 117 + "node_modules/@cloudflare/workerd-linux-arm64": { 118 + "version": "1.20250428.0", 119 + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20250428.0.tgz", 120 + "integrity": "sha512-D9NRBnW46nl1EQsP13qfkYb5lbt4C6nxl38SBKY/NOcZAUoHzNB5K0GaK8LxvpkM7X/97ySojlMfR5jh5DNXYQ==", 121 + "cpu": [ 122 + "arm64" 123 + ], 124 + "dev": true, 125 + "license": "Apache-2.0", 126 + "optional": true, 127 + "os": [ 128 + "linux" 129 + ], 130 + "engines": { 131 + "node": ">=16" 132 + } 133 + }, 134 + "node_modules/@cloudflare/workerd-windows-64": { 135 + "version": "1.20250428.0", 136 + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20250428.0.tgz", 137 + "integrity": "sha512-RQCRj28eitjKD0tmei6iFOuWqMuHMHdNGEigRmbkmuTlpbWHNAoHikgCzZQ/dkKDdatA76TmcpbyECNf31oaTA==", 138 + "cpu": [ 139 + "x64" 140 + ], 141 + "dev": true, 142 + "license": "Apache-2.0", 143 + "optional": true, 144 + "os": [ 145 + "win32" 146 + ], 147 + "engines": { 148 + "node": ">=16" 149 + } 150 + }, 151 + "node_modules/@cspotcode/source-map-support": { 152 + "version": "0.8.1", 153 + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", 154 + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", 155 + "dev": true, 156 + "license": "MIT", 157 + "dependencies": { 158 + "@jridgewell/trace-mapping": "0.3.9" 159 + }, 160 + "engines": { 161 + "node": ">=12" 162 + } 163 + }, 164 + "node_modules/@emnapi/runtime": { 165 + "version": "1.4.3", 166 + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.3.tgz", 167 + "integrity": "sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ==", 168 + "dev": true, 169 + "license": "MIT", 170 + "optional": true, 171 + "dependencies": { 172 + "tslib": "^2.4.0" 173 + } 174 + }, 175 + "node_modules/@esbuild/aix-ppc64": { 176 + "version": "0.25.3", 177 + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.3.tgz", 178 + "integrity": "sha512-W8bFfPA8DowP8l//sxjJLSLkD8iEjMc7cBVyP+u4cEv9sM7mdUCkgsj+t0n/BWPFtv7WWCN5Yzj0N6FJNUUqBQ==", 179 + "cpu": [ 180 + "ppc64" 181 + ], 182 + "dev": true, 183 + "license": "MIT", 184 + "optional": true, 185 + "os": [ 186 + "aix" 187 + ], 188 + "engines": { 189 + "node": ">=18" 190 + } 191 + }, 192 + "node_modules/@esbuild/android-arm": { 193 + "version": "0.25.3", 194 + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.3.tgz", 195 + "integrity": "sha512-PuwVXbnP87Tcff5I9ngV0lmiSu40xw1At6i3GsU77U7cjDDB4s0X2cyFuBiDa1SBk9DnvWwnGvVaGBqoFWPb7A==", 196 + "cpu": [ 197 + "arm" 198 + ], 199 + "dev": true, 200 + "license": "MIT", 201 + "optional": true, 202 + "os": [ 203 + "android" 204 + ], 205 + "engines": { 206 + "node": ">=18" 207 + } 208 + }, 209 + "node_modules/@esbuild/android-arm64": { 210 + "version": "0.25.3", 211 + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.3.tgz", 212 + "integrity": "sha512-XelR6MzjlZuBM4f5z2IQHK6LkK34Cvv6Rj2EntER3lwCBFdg6h2lKbtRjpTTsdEjD/WSe1q8UyPBXP1x3i/wYQ==", 213 + "cpu": [ 214 + "arm64" 215 + ], 216 + "dev": true, 217 + "license": "MIT", 218 + "optional": true, 219 + "os": [ 220 + "android" 221 + ], 222 + "engines": { 223 + "node": ">=18" 224 + } 225 + }, 226 + "node_modules/@esbuild/android-x64": { 227 + "version": "0.25.3", 228 + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.3.tgz", 229 + "integrity": "sha512-ogtTpYHT/g1GWS/zKM0cc/tIebFjm1F9Aw1boQ2Y0eUQ+J89d0jFY//s9ei9jVIlkYi8AfOjiixcLJSGNSOAdQ==", 230 + "cpu": [ 231 + "x64" 232 + ], 233 + "dev": true, 234 + "license": "MIT", 235 + "optional": true, 236 + "os": [ 237 + "android" 238 + ], 239 + "engines": { 240 + "node": ">=18" 241 + } 242 + }, 243 + "node_modules/@esbuild/darwin-arm64": { 244 + "version": "0.25.3", 245 + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.3.tgz", 246 + "integrity": "sha512-eESK5yfPNTqpAmDfFWNsOhmIOaQA59tAcF/EfYvo5/QWQCzXn5iUSOnqt3ra3UdzBv073ykTtmeLJZGt3HhA+w==", 247 + "cpu": [ 248 + "arm64" 249 + ], 250 + "dev": true, 251 + "license": "MIT", 252 + "optional": true, 253 + "os": [ 254 + "darwin" 255 + ], 256 + "engines": { 257 + "node": ">=18" 258 + } 259 + }, 260 + "node_modules/@esbuild/darwin-x64": { 261 + "version": "0.25.3", 262 + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.3.tgz", 263 + "integrity": "sha512-Kd8glo7sIZtwOLcPbW0yLpKmBNWMANZhrC1r6K++uDR2zyzb6AeOYtI6udbtabmQpFaxJ8uduXMAo1gs5ozz8A==", 264 + "cpu": [ 265 + "x64" 266 + ], 267 + "dev": true, 268 + "license": "MIT", 269 + "optional": true, 270 + "os": [ 271 + "darwin" 272 + ], 273 + "engines": { 274 + "node": ">=18" 275 + } 276 + }, 277 + "node_modules/@esbuild/freebsd-arm64": { 278 + "version": "0.25.3", 279 + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.3.tgz", 280 + "integrity": "sha512-EJiyS70BYybOBpJth3M0KLOus0n+RRMKTYzhYhFeMwp7e/RaajXvP+BWlmEXNk6uk+KAu46j/kaQzr6au+JcIw==", 281 + "cpu": [ 282 + "arm64" 283 + ], 284 + "dev": true, 285 + "license": "MIT", 286 + "optional": true, 287 + "os": [ 288 + "freebsd" 289 + ], 290 + "engines": { 291 + "node": ">=18" 292 + } 293 + }, 294 + "node_modules/@esbuild/freebsd-x64": { 295 + "version": "0.25.3", 296 + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.3.tgz", 297 + "integrity": "sha512-Q+wSjaLpGxYf7zC0kL0nDlhsfuFkoN+EXrx2KSB33RhinWzejOd6AvgmP5JbkgXKmjhmpfgKZq24pneodYqE8Q==", 298 + "cpu": [ 299 + "x64" 300 + ], 301 + "dev": true, 302 + "license": "MIT", 303 + "optional": true, 304 + "os": [ 305 + "freebsd" 306 + ], 307 + "engines": { 308 + "node": ">=18" 309 + } 310 + }, 311 + "node_modules/@esbuild/linux-arm": { 312 + "version": "0.25.3", 313 + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.3.tgz", 314 + "integrity": "sha512-dUOVmAUzuHy2ZOKIHIKHCm58HKzFqd+puLaS424h6I85GlSDRZIA5ycBixb3mFgM0Jdh+ZOSB6KptX30DD8YOQ==", 315 + "cpu": [ 316 + "arm" 317 + ], 318 + "dev": true, 319 + "license": "MIT", 320 + "optional": true, 321 + "os": [ 322 + "linux" 323 + ], 324 + "engines": { 325 + "node": ">=18" 326 + } 327 + }, 328 + "node_modules/@esbuild/linux-arm64": { 329 + "version": "0.25.3", 330 + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.3.tgz", 331 + "integrity": "sha512-xCUgnNYhRD5bb1C1nqrDV1PfkwgbswTTBRbAd8aH5PhYzikdf/ddtsYyMXFfGSsb/6t6QaPSzxtbfAZr9uox4A==", 332 + "cpu": [ 333 + "arm64" 334 + ], 335 + "dev": true, 336 + "license": "MIT", 337 + "optional": true, 338 + "os": [ 339 + "linux" 340 + ], 341 + "engines": { 342 + "node": ">=18" 343 + } 344 + }, 345 + "node_modules/@esbuild/linux-ia32": { 346 + "version": "0.25.3", 347 + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.3.tgz", 348 + "integrity": "sha512-yplPOpczHOO4jTYKmuYuANI3WhvIPSVANGcNUeMlxH4twz/TeXuzEP41tGKNGWJjuMhotpGabeFYGAOU2ummBw==", 349 + "cpu": [ 350 + "ia32" 351 + ], 352 + "dev": true, 353 + "license": "MIT", 354 + "optional": true, 355 + "os": [ 356 + "linux" 357 + ], 358 + "engines": { 359 + "node": ">=18" 360 + } 361 + }, 362 + "node_modules/@esbuild/linux-loong64": { 363 + "version": "0.25.3", 364 + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.3.tgz", 365 + "integrity": "sha512-P4BLP5/fjyihmXCELRGrLd793q/lBtKMQl8ARGpDxgzgIKJDRJ/u4r1A/HgpBpKpKZelGct2PGI4T+axcedf6g==", 366 + "cpu": [ 367 + "loong64" 368 + ], 369 + "dev": true, 370 + "license": "MIT", 371 + "optional": true, 372 + "os": [ 373 + "linux" 374 + ], 375 + "engines": { 376 + "node": ">=18" 377 + } 378 + }, 379 + "node_modules/@esbuild/linux-mips64el": { 380 + "version": "0.25.3", 381 + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.3.tgz", 382 + "integrity": "sha512-eRAOV2ODpu6P5divMEMa26RRqb2yUoYsuQQOuFUexUoQndm4MdpXXDBbUoKIc0iPa4aCO7gIhtnYomkn2x+bag==", 383 + "cpu": [ 384 + "mips64el" 385 + ], 386 + "dev": true, 387 + "license": "MIT", 388 + "optional": true, 389 + "os": [ 390 + "linux" 391 + ], 392 + "engines": { 393 + "node": ">=18" 394 + } 395 + }, 396 + "node_modules/@esbuild/linux-ppc64": { 397 + "version": "0.25.3", 398 + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.3.tgz", 399 + "integrity": "sha512-ZC4jV2p7VbzTlnl8nZKLcBkfzIf4Yad1SJM4ZMKYnJqZFD4rTI+pBG65u8ev4jk3/MPwY9DvGn50wi3uhdaghg==", 400 + "cpu": [ 401 + "ppc64" 402 + ], 403 + "dev": true, 404 + "license": "MIT", 405 + "optional": true, 406 + "os": [ 407 + "linux" 408 + ], 409 + "engines": { 410 + "node": ">=18" 411 + } 412 + }, 413 + "node_modules/@esbuild/linux-riscv64": { 414 + "version": "0.25.3", 415 + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.3.tgz", 416 + "integrity": "sha512-LDDODcFzNtECTrUUbVCs6j9/bDVqy7DDRsuIXJg6so+mFksgwG7ZVnTruYi5V+z3eE5y+BJZw7VvUadkbfg7QA==", 417 + "cpu": [ 418 + "riscv64" 419 + ], 420 + "dev": true, 421 + "license": "MIT", 422 + "optional": true, 423 + "os": [ 424 + "linux" 425 + ], 426 + "engines": { 427 + "node": ">=18" 428 + } 429 + }, 430 + "node_modules/@esbuild/linux-s390x": { 431 + "version": "0.25.3", 432 + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.3.tgz", 433 + "integrity": "sha512-s+w/NOY2k0yC2p9SLen+ymflgcpRkvwwa02fqmAwhBRI3SC12uiS10edHHXlVWwfAagYSY5UpmT/zISXPMW3tQ==", 434 + "cpu": [ 435 + "s390x" 436 + ], 437 + "dev": true, 438 + "license": "MIT", 439 + "optional": true, 440 + "os": [ 441 + "linux" 442 + ], 443 + "engines": { 444 + "node": ">=18" 445 + } 446 + }, 447 + "node_modules/@esbuild/linux-x64": { 448 + "version": "0.25.3", 449 + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.3.tgz", 450 + "integrity": "sha512-nQHDz4pXjSDC6UfOE1Fw9Q8d6GCAd9KdvMZpfVGWSJztYCarRgSDfOVBY5xwhQXseiyxapkiSJi/5/ja8mRFFA==", 451 + "cpu": [ 452 + "x64" 453 + ], 454 + "dev": true, 455 + "license": "MIT", 456 + "optional": true, 457 + "os": [ 458 + "linux" 459 + ], 460 + "engines": { 461 + "node": ">=18" 462 + } 463 + }, 464 + "node_modules/@esbuild/netbsd-arm64": { 465 + "version": "0.25.3", 466 + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.3.tgz", 467 + "integrity": "sha512-1QaLtOWq0mzK6tzzp0jRN3eccmN3hezey7mhLnzC6oNlJoUJz4nym5ZD7mDnS/LZQgkrhEbEiTn515lPeLpgWA==", 468 + "cpu": [ 469 + "arm64" 470 + ], 471 + "dev": true, 472 + "license": "MIT", 473 + "optional": true, 474 + "os": [ 475 + "netbsd" 476 + ], 477 + "engines": { 478 + "node": ">=18" 479 + } 480 + }, 481 + "node_modules/@esbuild/netbsd-x64": { 482 + "version": "0.25.3", 483 + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.3.tgz", 484 + "integrity": "sha512-i5Hm68HXHdgv8wkrt+10Bc50zM0/eonPb/a/OFVfB6Qvpiirco5gBA5bz7S2SHuU+Y4LWn/zehzNX14Sp4r27g==", 485 + "cpu": [ 486 + "x64" 487 + ], 488 + "dev": true, 489 + "license": "MIT", 490 + "optional": true, 491 + "os": [ 492 + "netbsd" 493 + ], 494 + "engines": { 495 + "node": ">=18" 496 + } 497 + }, 498 + "node_modules/@esbuild/openbsd-arm64": { 499 + "version": "0.25.3", 500 + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.3.tgz", 501 + "integrity": "sha512-zGAVApJEYTbOC6H/3QBr2mq3upG/LBEXr85/pTtKiv2IXcgKV0RT0QA/hSXZqSvLEpXeIxah7LczB4lkiYhTAQ==", 502 + "cpu": [ 503 + "arm64" 504 + ], 505 + "dev": true, 506 + "license": "MIT", 507 + "optional": true, 508 + "os": [ 509 + "openbsd" 510 + ], 511 + "engines": { 512 + "node": ">=18" 513 + } 514 + }, 515 + "node_modules/@esbuild/openbsd-x64": { 516 + "version": "0.25.3", 517 + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.3.tgz", 518 + "integrity": "sha512-fpqctI45NnCIDKBH5AXQBsD0NDPbEFczK98hk/aa6HJxbl+UtLkJV2+Bvy5hLSLk3LHmqt0NTkKNso1A9y1a4w==", 519 + "cpu": [ 520 + "x64" 521 + ], 522 + "dev": true, 523 + "license": "MIT", 524 + "optional": true, 525 + "os": [ 526 + "openbsd" 527 + ], 528 + "engines": { 529 + "node": ">=18" 530 + } 531 + }, 532 + "node_modules/@esbuild/sunos-x64": { 533 + "version": "0.25.3", 534 + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.3.tgz", 535 + "integrity": "sha512-ROJhm7d8bk9dMCUZjkS8fgzsPAZEjtRJqCAmVgB0gMrvG7hfmPmz9k1rwO4jSiblFjYmNvbECL9uhaPzONMfgA==", 536 + "cpu": [ 537 + "x64" 538 + ], 539 + "dev": true, 540 + "license": "MIT", 541 + "optional": true, 542 + "os": [ 543 + "sunos" 544 + ], 545 + "engines": { 546 + "node": ">=18" 547 + } 548 + }, 549 + "node_modules/@esbuild/win32-arm64": { 550 + "version": "0.25.3", 551 + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.3.tgz", 552 + "integrity": "sha512-YWcow8peiHpNBiIXHwaswPnAXLsLVygFwCB3A7Bh5jRkIBFWHGmNQ48AlX4xDvQNoMZlPYzjVOQDYEzWCqufMQ==", 553 + "cpu": [ 554 + "arm64" 555 + ], 556 + "dev": true, 557 + "license": "MIT", 558 + "optional": true, 559 + "os": [ 560 + "win32" 561 + ], 562 + "engines": { 563 + "node": ">=18" 564 + } 565 + }, 566 + "node_modules/@esbuild/win32-ia32": { 567 + "version": "0.25.3", 568 + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.3.tgz", 569 + "integrity": "sha512-qspTZOIGoXVS4DpNqUYUs9UxVb04khS1Degaw/MnfMe7goQ3lTfQ13Vw4qY/Nj0979BGvMRpAYbs/BAxEvU8ew==", 570 + "cpu": [ 571 + "ia32" 572 + ], 573 + "dev": true, 574 + "license": "MIT", 575 + "optional": true, 576 + "os": [ 577 + "win32" 578 + ], 579 + "engines": { 580 + "node": ">=18" 581 + } 582 + }, 583 + "node_modules/@esbuild/win32-x64": { 584 + "version": "0.25.3", 585 + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.3.tgz", 586 + "integrity": "sha512-ICgUR+kPimx0vvRzf+N/7L7tVSQeE3BYY+NhHRHXS1kBuPO7z2+7ea2HbhDyZdTephgvNvKrlDDKUexuCVBVvg==", 587 + "cpu": [ 588 + "x64" 589 + ], 590 + "dev": true, 591 + "license": "MIT", 592 + "optional": true, 593 + "os": [ 594 + "win32" 595 + ], 596 + "engines": { 597 + "node": ">=18" 598 + } 599 + }, 600 + "node_modules/@fastify/busboy": { 601 + "version": "2.1.1", 602 + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", 603 + "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", 604 + "dev": true, 605 + "license": "MIT", 606 + "engines": { 607 + "node": ">=14" 608 + } 609 + }, 610 + "node_modules/@img/sharp-darwin-arm64": { 611 + "version": "0.33.5", 612 + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", 613 + "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", 614 + "cpu": [ 615 + "arm64" 616 + ], 617 + "dev": true, 618 + "license": "Apache-2.0", 619 + "optional": true, 620 + "os": [ 621 + "darwin" 622 + ], 623 + "engines": { 624 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 625 + }, 626 + "funding": { 627 + "url": "https://opencollective.com/libvips" 628 + }, 629 + "optionalDependencies": { 630 + "@img/sharp-libvips-darwin-arm64": "1.0.4" 631 + } 632 + }, 633 + "node_modules/@img/sharp-darwin-x64": { 634 + "version": "0.33.5", 635 + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", 636 + "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", 637 + "cpu": [ 638 + "x64" 639 + ], 640 + "dev": true, 641 + "license": "Apache-2.0", 642 + "optional": true, 643 + "os": [ 644 + "darwin" 645 + ], 646 + "engines": { 647 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 648 + }, 649 + "funding": { 650 + "url": "https://opencollective.com/libvips" 651 + }, 652 + "optionalDependencies": { 653 + "@img/sharp-libvips-darwin-x64": "1.0.4" 654 + } 655 + }, 656 + "node_modules/@img/sharp-libvips-darwin-arm64": { 657 + "version": "1.0.4", 658 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", 659 + "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", 660 + "cpu": [ 661 + "arm64" 662 + ], 663 + "dev": true, 664 + "license": "LGPL-3.0-or-later", 665 + "optional": true, 666 + "os": [ 667 + "darwin" 668 + ], 669 + "funding": { 670 + "url": "https://opencollective.com/libvips" 671 + } 672 + }, 673 + "node_modules/@img/sharp-libvips-darwin-x64": { 674 + "version": "1.0.4", 675 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", 676 + "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", 677 + "cpu": [ 678 + "x64" 679 + ], 680 + "dev": true, 681 + "license": "LGPL-3.0-or-later", 682 + "optional": true, 683 + "os": [ 684 + "darwin" 685 + ], 686 + "funding": { 687 + "url": "https://opencollective.com/libvips" 688 + } 689 + }, 690 + "node_modules/@img/sharp-libvips-linux-arm": { 691 + "version": "1.0.5", 692 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", 693 + "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", 694 + "cpu": [ 695 + "arm" 696 + ], 697 + "dev": true, 698 + "license": "LGPL-3.0-or-later", 699 + "optional": true, 700 + "os": [ 701 + "linux" 702 + ], 703 + "funding": { 704 + "url": "https://opencollective.com/libvips" 705 + } 706 + }, 707 + "node_modules/@img/sharp-libvips-linux-arm64": { 708 + "version": "1.0.4", 709 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", 710 + "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", 711 + "cpu": [ 712 + "arm64" 713 + ], 714 + "dev": true, 715 + "license": "LGPL-3.0-or-later", 716 + "optional": true, 717 + "os": [ 718 + "linux" 719 + ], 720 + "funding": { 721 + "url": "https://opencollective.com/libvips" 722 + } 723 + }, 724 + "node_modules/@img/sharp-libvips-linux-s390x": { 725 + "version": "1.0.4", 726 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", 727 + "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", 728 + "cpu": [ 729 + "s390x" 730 + ], 731 + "dev": true, 732 + "license": "LGPL-3.0-or-later", 733 + "optional": true, 734 + "os": [ 735 + "linux" 736 + ], 737 + "funding": { 738 + "url": "https://opencollective.com/libvips" 739 + } 740 + }, 741 + "node_modules/@img/sharp-libvips-linux-x64": { 742 + "version": "1.0.4", 743 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", 744 + "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", 745 + "cpu": [ 746 + "x64" 747 + ], 748 + "dev": true, 749 + "license": "LGPL-3.0-or-later", 750 + "optional": true, 751 + "os": [ 752 + "linux" 753 + ], 754 + "funding": { 755 + "url": "https://opencollective.com/libvips" 756 + } 757 + }, 758 + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { 759 + "version": "1.0.4", 760 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", 761 + "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", 762 + "cpu": [ 763 + "arm64" 764 + ], 765 + "dev": true, 766 + "license": "LGPL-3.0-or-later", 767 + "optional": true, 768 + "os": [ 769 + "linux" 770 + ], 771 + "funding": { 772 + "url": "https://opencollective.com/libvips" 773 + } 774 + }, 775 + "node_modules/@img/sharp-libvips-linuxmusl-x64": { 776 + "version": "1.0.4", 777 + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", 778 + "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", 779 + "cpu": [ 780 + "x64" 781 + ], 782 + "dev": true, 783 + "license": "LGPL-3.0-or-later", 784 + "optional": true, 785 + "os": [ 786 + "linux" 787 + ], 788 + "funding": { 789 + "url": "https://opencollective.com/libvips" 790 + } 791 + }, 792 + "node_modules/@img/sharp-linux-arm": { 793 + "version": "0.33.5", 794 + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", 795 + "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", 796 + "cpu": [ 797 + "arm" 798 + ], 799 + "dev": true, 800 + "license": "Apache-2.0", 801 + "optional": true, 802 + "os": [ 803 + "linux" 804 + ], 805 + "engines": { 806 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 807 + }, 808 + "funding": { 809 + "url": "https://opencollective.com/libvips" 810 + }, 811 + "optionalDependencies": { 812 + "@img/sharp-libvips-linux-arm": "1.0.5" 813 + } 814 + }, 815 + "node_modules/@img/sharp-linux-arm64": { 816 + "version": "0.33.5", 817 + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", 818 + "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", 819 + "cpu": [ 820 + "arm64" 821 + ], 822 + "dev": true, 823 + "license": "Apache-2.0", 824 + "optional": true, 825 + "os": [ 826 + "linux" 827 + ], 828 + "engines": { 829 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 830 + }, 831 + "funding": { 832 + "url": "https://opencollective.com/libvips" 833 + }, 834 + "optionalDependencies": { 835 + "@img/sharp-libvips-linux-arm64": "1.0.4" 836 + } 837 + }, 838 + "node_modules/@img/sharp-linux-s390x": { 839 + "version": "0.33.5", 840 + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", 841 + "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", 842 + "cpu": [ 843 + "s390x" 844 + ], 845 + "dev": true, 846 + "license": "Apache-2.0", 847 + "optional": true, 848 + "os": [ 849 + "linux" 850 + ], 851 + "engines": { 852 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 853 + }, 854 + "funding": { 855 + "url": "https://opencollective.com/libvips" 856 + }, 857 + "optionalDependencies": { 858 + "@img/sharp-libvips-linux-s390x": "1.0.4" 859 + } 860 + }, 861 + "node_modules/@img/sharp-linux-x64": { 862 + "version": "0.33.5", 863 + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", 864 + "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", 865 + "cpu": [ 866 + "x64" 867 + ], 868 + "dev": true, 869 + "license": "Apache-2.0", 870 + "optional": true, 871 + "os": [ 872 + "linux" 873 + ], 874 + "engines": { 875 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 876 + }, 877 + "funding": { 878 + "url": "https://opencollective.com/libvips" 879 + }, 880 + "optionalDependencies": { 881 + "@img/sharp-libvips-linux-x64": "1.0.4" 882 + } 883 + }, 884 + "node_modules/@img/sharp-linuxmusl-arm64": { 885 + "version": "0.33.5", 886 + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", 887 + "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", 888 + "cpu": [ 889 + "arm64" 890 + ], 891 + "dev": true, 892 + "license": "Apache-2.0", 893 + "optional": true, 894 + "os": [ 895 + "linux" 896 + ], 897 + "engines": { 898 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 899 + }, 900 + "funding": { 901 + "url": "https://opencollective.com/libvips" 902 + }, 903 + "optionalDependencies": { 904 + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" 905 + } 906 + }, 907 + "node_modules/@img/sharp-linuxmusl-x64": { 908 + "version": "0.33.5", 909 + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", 910 + "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", 911 + "cpu": [ 912 + "x64" 913 + ], 914 + "dev": true, 915 + "license": "Apache-2.0", 916 + "optional": true, 917 + "os": [ 918 + "linux" 919 + ], 920 + "engines": { 921 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 922 + }, 923 + "funding": { 924 + "url": "https://opencollective.com/libvips" 925 + }, 926 + "optionalDependencies": { 927 + "@img/sharp-libvips-linuxmusl-x64": "1.0.4" 928 + } 929 + }, 930 + "node_modules/@img/sharp-wasm32": { 931 + "version": "0.33.5", 932 + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", 933 + "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", 934 + "cpu": [ 935 + "wasm32" 936 + ], 937 + "dev": true, 938 + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", 939 + "optional": true, 940 + "dependencies": { 941 + "@emnapi/runtime": "^1.2.0" 942 + }, 943 + "engines": { 944 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 945 + }, 946 + "funding": { 947 + "url": "https://opencollective.com/libvips" 948 + } 949 + }, 950 + "node_modules/@img/sharp-win32-ia32": { 951 + "version": "0.33.5", 952 + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", 953 + "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", 954 + "cpu": [ 955 + "ia32" 956 + ], 957 + "dev": true, 958 + "license": "Apache-2.0 AND LGPL-3.0-or-later", 959 + "optional": true, 960 + "os": [ 961 + "win32" 962 + ], 963 + "engines": { 964 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 965 + }, 966 + "funding": { 967 + "url": "https://opencollective.com/libvips" 968 + } 969 + }, 970 + "node_modules/@img/sharp-win32-x64": { 971 + "version": "0.33.5", 972 + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", 973 + "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", 974 + "cpu": [ 975 + "x64" 976 + ], 977 + "dev": true, 978 + "license": "Apache-2.0 AND LGPL-3.0-or-later", 979 + "optional": true, 980 + "os": [ 981 + "win32" 982 + ], 983 + "engines": { 984 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 985 + }, 986 + "funding": { 987 + "url": "https://opencollective.com/libvips" 988 + } 989 + }, 990 + "node_modules/@jridgewell/resolve-uri": { 991 + "version": "3.1.2", 992 + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", 993 + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", 994 + "dev": true, 995 + "license": "MIT", 996 + "engines": { 997 + "node": ">=6.0.0" 998 + } 999 + }, 1000 + "node_modules/@jridgewell/sourcemap-codec": { 1001 + "version": "1.5.0", 1002 + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", 1003 + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", 1004 + "dev": true, 1005 + "license": "MIT" 1006 + }, 1007 + "node_modules/@jridgewell/trace-mapping": { 1008 + "version": "0.3.9", 1009 + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", 1010 + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", 1011 + "dev": true, 1012 + "license": "MIT", 1013 + "dependencies": { 1014 + "@jridgewell/resolve-uri": "^3.0.3", 1015 + "@jridgewell/sourcemap-codec": "^1.4.10" 1016 + } 1017 + }, 1018 + "node_modules/@rollup/rollup-android-arm-eabi": { 1019 + "version": "4.40.1", 1020 + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.40.1.tgz", 1021 + "integrity": "sha512-kxz0YeeCrRUHz3zyqvd7n+TVRlNyTifBsmnmNPtk3hQURUyG9eAB+usz6DAwagMusjx/zb3AjvDUvhFGDAexGw==", 1022 + "cpu": [ 1023 + "arm" 1024 + ], 1025 + "dev": true, 1026 + "license": "MIT", 1027 + "optional": true, 1028 + "os": [ 1029 + "android" 1030 + ] 1031 + }, 1032 + "node_modules/@rollup/rollup-android-arm64": { 1033 + "version": "4.40.1", 1034 + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.40.1.tgz", 1035 + "integrity": "sha512-PPkxTOisoNC6TpnDKatjKkjRMsdaWIhyuMkA4UsBXT9WEZY4uHezBTjs6Vl4PbqQQeu6oION1w2voYZv9yquCw==", 1036 + "cpu": [ 1037 + "arm64" 1038 + ], 1039 + "dev": true, 1040 + "license": "MIT", 1041 + "optional": true, 1042 + "os": [ 1043 + "android" 1044 + ] 1045 + }, 1046 + "node_modules/@rollup/rollup-darwin-arm64": { 1047 + "version": "4.40.1", 1048 + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.40.1.tgz", 1049 + "integrity": "sha512-VWXGISWFY18v/0JyNUy4A46KCFCb9NVsH+1100XP31lud+TzlezBbz24CYzbnA4x6w4hx+NYCXDfnvDVO6lcAA==", 1050 + "cpu": [ 1051 + "arm64" 1052 + ], 1053 + "dev": true, 1054 + "license": "MIT", 1055 + "optional": true, 1056 + "os": [ 1057 + "darwin" 1058 + ] 1059 + }, 1060 + "node_modules/@rollup/rollup-darwin-x64": { 1061 + "version": "4.40.1", 1062 + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.40.1.tgz", 1063 + "integrity": "sha512-nIwkXafAI1/QCS7pxSpv/ZtFW6TXcNUEHAIA9EIyw5OzxJZQ1YDrX+CL6JAIQgZ33CInl1R6mHet9Y/UZTg2Bw==", 1064 + "cpu": [ 1065 + "x64" 1066 + ], 1067 + "dev": true, 1068 + "license": "MIT", 1069 + "optional": true, 1070 + "os": [ 1071 + "darwin" 1072 + ] 1073 + }, 1074 + "node_modules/@rollup/rollup-freebsd-arm64": { 1075 + "version": "4.40.1", 1076 + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.40.1.tgz", 1077 + "integrity": "sha512-BdrLJ2mHTrIYdaS2I99mriyJfGGenSaP+UwGi1kB9BLOCu9SR8ZpbkmmalKIALnRw24kM7qCN0IOm6L0S44iWw==", 1078 + "cpu": [ 1079 + "arm64" 1080 + ], 1081 + "dev": true, 1082 + "license": "MIT", 1083 + "optional": true, 1084 + "os": [ 1085 + "freebsd" 1086 + ] 1087 + }, 1088 + "node_modules/@rollup/rollup-freebsd-x64": { 1089 + "version": "4.40.1", 1090 + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.40.1.tgz", 1091 + "integrity": "sha512-VXeo/puqvCG8JBPNZXZf5Dqq7BzElNJzHRRw3vjBE27WujdzuOPecDPc/+1DcdcTptNBep3861jNq0mYkT8Z6Q==", 1092 + "cpu": [ 1093 + "x64" 1094 + ], 1095 + "dev": true, 1096 + "license": "MIT", 1097 + "optional": true, 1098 + "os": [ 1099 + "freebsd" 1100 + ] 1101 + }, 1102 + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { 1103 + "version": "4.40.1", 1104 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.40.1.tgz", 1105 + "integrity": "sha512-ehSKrewwsESPt1TgSE/na9nIhWCosfGSFqv7vwEtjyAqZcvbGIg4JAcV7ZEh2tfj/IlfBeZjgOXm35iOOjadcg==", 1106 + "cpu": [ 1107 + "arm" 1108 + ], 1109 + "dev": true, 1110 + "license": "MIT", 1111 + "optional": true, 1112 + "os": [ 1113 + "linux" 1114 + ] 1115 + }, 1116 + "node_modules/@rollup/rollup-linux-arm-musleabihf": { 1117 + "version": "4.40.1", 1118 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.40.1.tgz", 1119 + "integrity": "sha512-m39iO/aaurh5FVIu/F4/Zsl8xppd76S4qoID8E+dSRQvTyZTOI2gVk3T4oqzfq1PtcvOfAVlwLMK3KRQMaR8lg==", 1120 + "cpu": [ 1121 + "arm" 1122 + ], 1123 + "dev": true, 1124 + "license": "MIT", 1125 + "optional": true, 1126 + "os": [ 1127 + "linux" 1128 + ] 1129 + }, 1130 + "node_modules/@rollup/rollup-linux-arm64-gnu": { 1131 + "version": "4.40.1", 1132 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.40.1.tgz", 1133 + "integrity": "sha512-Y+GHnGaku4aVLSgrT0uWe2o2Rq8te9hi+MwqGF9r9ORgXhmHK5Q71N757u0F8yU1OIwUIFy6YiJtKjtyktk5hg==", 1134 + "cpu": [ 1135 + "arm64" 1136 + ], 1137 + "dev": true, 1138 + "license": "MIT", 1139 + "optional": true, 1140 + "os": [ 1141 + "linux" 1142 + ] 1143 + }, 1144 + "node_modules/@rollup/rollup-linux-arm64-musl": { 1145 + "version": "4.40.1", 1146 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.40.1.tgz", 1147 + "integrity": "sha512-jEwjn3jCA+tQGswK3aEWcD09/7M5wGwc6+flhva7dsQNRZZTe30vkalgIzV4tjkopsTS9Jd7Y1Bsj6a4lzz8gQ==", 1148 + "cpu": [ 1149 + "arm64" 1150 + ], 1151 + "dev": true, 1152 + "license": "MIT", 1153 + "optional": true, 1154 + "os": [ 1155 + "linux" 1156 + ] 1157 + }, 1158 + "node_modules/@rollup/rollup-linux-loongarch64-gnu": { 1159 + "version": "4.40.1", 1160 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.40.1.tgz", 1161 + "integrity": "sha512-ySyWikVhNzv+BV/IDCsrraOAZ3UaC8SZB67FZlqVwXwnFhPihOso9rPOxzZbjp81suB1O2Topw+6Ug3JNegejQ==", 1162 + "cpu": [ 1163 + "loong64" 1164 + ], 1165 + "dev": true, 1166 + "license": "MIT", 1167 + "optional": true, 1168 + "os": [ 1169 + "linux" 1170 + ] 1171 + }, 1172 + "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { 1173 + "version": "4.40.1", 1174 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.40.1.tgz", 1175 + "integrity": "sha512-BvvA64QxZlh7WZWqDPPdt0GH4bznuL6uOO1pmgPnnv86rpUpc8ZxgZwcEgXvo02GRIZX1hQ0j0pAnhwkhwPqWg==", 1176 + "cpu": [ 1177 + "ppc64" 1178 + ], 1179 + "dev": true, 1180 + "license": "MIT", 1181 + "optional": true, 1182 + "os": [ 1183 + "linux" 1184 + ] 1185 + }, 1186 + "node_modules/@rollup/rollup-linux-riscv64-gnu": { 1187 + "version": "4.40.1", 1188 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.40.1.tgz", 1189 + "integrity": "sha512-EQSP+8+1VuSulm9RKSMKitTav89fKbHymTf25n5+Yr6gAPZxYWpj3DzAsQqoaHAk9YX2lwEyAf9S4W8F4l3VBQ==", 1190 + "cpu": [ 1191 + "riscv64" 1192 + ], 1193 + "dev": true, 1194 + "license": "MIT", 1195 + "optional": true, 1196 + "os": [ 1197 + "linux" 1198 + ] 1199 + }, 1200 + "node_modules/@rollup/rollup-linux-riscv64-musl": { 1201 + "version": "4.40.1", 1202 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.40.1.tgz", 1203 + "integrity": "sha512-n/vQ4xRZXKuIpqukkMXZt9RWdl+2zgGNx7Uda8NtmLJ06NL8jiHxUawbwC+hdSq1rrw/9CghCpEONor+l1e2gA==", 1204 + "cpu": [ 1205 + "riscv64" 1206 + ], 1207 + "dev": true, 1208 + "license": "MIT", 1209 + "optional": true, 1210 + "os": [ 1211 + "linux" 1212 + ] 1213 + }, 1214 + "node_modules/@rollup/rollup-linux-s390x-gnu": { 1215 + "version": "4.40.1", 1216 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.40.1.tgz", 1217 + "integrity": "sha512-h8d28xzYb98fMQKUz0w2fMc1XuGzLLjdyxVIbhbil4ELfk5/orZlSTpF/xdI9C8K0I8lCkq+1En2RJsawZekkg==", 1218 + "cpu": [ 1219 + "s390x" 1220 + ], 1221 + "dev": true, 1222 + "license": "MIT", 1223 + "optional": true, 1224 + "os": [ 1225 + "linux" 1226 + ] 1227 + }, 1228 + "node_modules/@rollup/rollup-linux-x64-gnu": { 1229 + "version": "4.40.1", 1230 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.40.1.tgz", 1231 + "integrity": "sha512-XiK5z70PEFEFqcNj3/zRSz/qX4bp4QIraTy9QjwJAb/Z8GM7kVUsD0Uk8maIPeTyPCP03ChdI+VVmJriKYbRHQ==", 1232 + "cpu": [ 1233 + "x64" 1234 + ], 1235 + "dev": true, 1236 + "license": "MIT", 1237 + "optional": true, 1238 + "os": [ 1239 + "linux" 1240 + ] 1241 + }, 1242 + "node_modules/@rollup/rollup-linux-x64-musl": { 1243 + "version": "4.40.1", 1244 + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.40.1.tgz", 1245 + "integrity": "sha512-2BRORitq5rQ4Da9blVovzNCMaUlyKrzMSvkVR0D4qPuOy/+pMCrh1d7o01RATwVy+6Fa1WBw+da7QPeLWU/1mQ==", 1246 + "cpu": [ 1247 + "x64" 1248 + ], 1249 + "dev": true, 1250 + "license": "MIT", 1251 + "optional": true, 1252 + "os": [ 1253 + "linux" 1254 + ] 1255 + }, 1256 + "node_modules/@rollup/rollup-win32-arm64-msvc": { 1257 + "version": "4.40.1", 1258 + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.40.1.tgz", 1259 + "integrity": "sha512-b2bcNm9Kbde03H+q+Jjw9tSfhYkzrDUf2d5MAd1bOJuVplXvFhWz7tRtWvD8/ORZi7qSCy0idW6tf2HgxSXQSg==", 1260 + "cpu": [ 1261 + "arm64" 1262 + ], 1263 + "dev": true, 1264 + "license": "MIT", 1265 + "optional": true, 1266 + "os": [ 1267 + "win32" 1268 + ] 1269 + }, 1270 + "node_modules/@rollup/rollup-win32-ia32-msvc": { 1271 + "version": "4.40.1", 1272 + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.40.1.tgz", 1273 + "integrity": "sha512-DfcogW8N7Zg7llVEfpqWMZcaErKfsj9VvmfSyRjCyo4BI3wPEfrzTtJkZG6gKP/Z92wFm6rz2aDO7/JfiR/whA==", 1274 + "cpu": [ 1275 + "ia32" 1276 + ], 1277 + "dev": true, 1278 + "license": "MIT", 1279 + "optional": true, 1280 + "os": [ 1281 + "win32" 1282 + ] 1283 + }, 1284 + "node_modules/@rollup/rollup-win32-x64-msvc": { 1285 + "version": "4.40.1", 1286 + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.40.1.tgz", 1287 + "integrity": "sha512-ECyOuDeH3C1I8jH2MK1RtBJW+YPMvSfT0a5NN0nHfQYnDSJ6tUiZH3gzwVP5/Kfh/+Tt7tpWVF9LXNTnhTJ3kA==", 1288 + "cpu": [ 1289 + "x64" 1290 + ], 1291 + "dev": true, 1292 + "license": "MIT", 1293 + "optional": true, 1294 + "os": [ 1295 + "win32" 1296 + ] 1297 + }, 1298 + "node_modules/@types/estree": { 1299 + "version": "1.0.7", 1300 + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", 1301 + "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==", 1302 + "dev": true, 1303 + "license": "MIT" 1304 + }, 1305 + "node_modules/@vitest/expect": { 1306 + "version": "3.0.9", 1307 + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.0.9.tgz", 1308 + "integrity": "sha512-5eCqRItYgIML7NNVgJj6TVCmdzE7ZVgJhruW0ziSQV4V7PvLkDL1bBkBdcTs/VuIz0IxPb5da1IDSqc1TR9eig==", 1309 + "dev": true, 1310 + "license": "MIT", 1311 + "dependencies": { 1312 + "@vitest/spy": "3.0.9", 1313 + "@vitest/utils": "3.0.9", 1314 + "chai": "^5.2.0", 1315 + "tinyrainbow": "^2.0.0" 1316 + }, 1317 + "funding": { 1318 + "url": "https://opencollective.com/vitest" 1319 + } 1320 + }, 1321 + "node_modules/@vitest/mocker": { 1322 + "version": "3.0.9", 1323 + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.0.9.tgz", 1324 + "integrity": "sha512-ryERPIBOnvevAkTq+L1lD+DTFBRcjueL9lOUfXsLfwP92h4e+Heb+PjiqS3/OURWPtywfafK0kj++yDFjWUmrA==", 1325 + "dev": true, 1326 + "license": "MIT", 1327 + "dependencies": { 1328 + "@vitest/spy": "3.0.9", 1329 + "estree-walker": "^3.0.3", 1330 + "magic-string": "^0.30.17" 1331 + }, 1332 + "funding": { 1333 + "url": "https://opencollective.com/vitest" 1334 + }, 1335 + "peerDependencies": { 1336 + "msw": "^2.4.9", 1337 + "vite": "^5.0.0 || ^6.0.0" 1338 + }, 1339 + "peerDependenciesMeta": { 1340 + "msw": { 1341 + "optional": true 1342 + }, 1343 + "vite": { 1344 + "optional": true 1345 + } 1346 + } 1347 + }, 1348 + "node_modules/@vitest/pretty-format": { 1349 + "version": "3.1.2", 1350 + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.1.2.tgz", 1351 + "integrity": "sha512-R0xAiHuWeDjTSB3kQ3OQpT8Rx3yhdOAIm/JM4axXxnG7Q/fS8XUwggv/A4xzbQA+drYRjzkMnpYnOGAc4oeq8w==", 1352 + "dev": true, 1353 + "license": "MIT", 1354 + "dependencies": { 1355 + "tinyrainbow": "^2.0.0" 1356 + }, 1357 + "funding": { 1358 + "url": "https://opencollective.com/vitest" 1359 + } 1360 + }, 1361 + "node_modules/@vitest/runner": { 1362 + "version": "3.0.9", 1363 + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.0.9.tgz", 1364 + "integrity": "sha512-NX9oUXgF9HPfJSwl8tUZCMP1oGx2+Sf+ru6d05QjzQz4OwWg0psEzwY6VexP2tTHWdOkhKHUIZH+fS6nA7jfOw==", 1365 + "dev": true, 1366 + "license": "MIT", 1367 + "dependencies": { 1368 + "@vitest/utils": "3.0.9", 1369 + "pathe": "^2.0.3" 1370 + }, 1371 + "funding": { 1372 + "url": "https://opencollective.com/vitest" 1373 + } 1374 + }, 1375 + "node_modules/@vitest/snapshot": { 1376 + "version": "3.0.9", 1377 + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.0.9.tgz", 1378 + "integrity": "sha512-AiLUiuZ0FuA+/8i19mTYd+re5jqjEc2jZbgJ2up0VY0Ddyyxg/uUtBDpIFAy4uzKaQxOW8gMgBdAJJ2ydhu39A==", 1379 + "dev": true, 1380 + "license": "MIT", 1381 + "dependencies": { 1382 + "@vitest/pretty-format": "3.0.9", 1383 + "magic-string": "^0.30.17", 1384 + "pathe": "^2.0.3" 1385 + }, 1386 + "funding": { 1387 + "url": "https://opencollective.com/vitest" 1388 + } 1389 + }, 1390 + "node_modules/@vitest/snapshot/node_modules/@vitest/pretty-format": { 1391 + "version": "3.0.9", 1392 + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.0.9.tgz", 1393 + "integrity": "sha512-OW9F8t2J3AwFEwENg3yMyKWweF7oRJlMyHOMIhO5F3n0+cgQAJZBjNgrF8dLwFTEXl5jUqBLXd9QyyKv8zEcmA==", 1394 + "dev": true, 1395 + "license": "MIT", 1396 + "dependencies": { 1397 + "tinyrainbow": "^2.0.0" 1398 + }, 1399 + "funding": { 1400 + "url": "https://opencollective.com/vitest" 1401 + } 1402 + }, 1403 + "node_modules/@vitest/spy": { 1404 + "version": "3.0.9", 1405 + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.0.9.tgz", 1406 + "integrity": "sha512-/CcK2UDl0aQ2wtkp3YVWldrpLRNCfVcIOFGlVGKO4R5eajsH393Z1yiXLVQ7vWsj26JOEjeZI0x5sm5P4OGUNQ==", 1407 + "dev": true, 1408 + "license": "MIT", 1409 + "dependencies": { 1410 + "tinyspy": "^3.0.2" 1411 + }, 1412 + "funding": { 1413 + "url": "https://opencollective.com/vitest" 1414 + } 1415 + }, 1416 + "node_modules/@vitest/utils": { 1417 + "version": "3.0.9", 1418 + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.0.9.tgz", 1419 + "integrity": "sha512-ilHM5fHhZ89MCp5aAaM9uhfl1c2JdxVxl3McqsdVyVNN6JffnEen8UMCdRTzOhGXNQGo5GNL9QugHrz727Wnng==", 1420 + "dev": true, 1421 + "license": "MIT", 1422 + "dependencies": { 1423 + "@vitest/pretty-format": "3.0.9", 1424 + "loupe": "^3.1.3", 1425 + "tinyrainbow": "^2.0.0" 1426 + }, 1427 + "funding": { 1428 + "url": "https://opencollective.com/vitest" 1429 + } 1430 + }, 1431 + "node_modules/@vitest/utils/node_modules/@vitest/pretty-format": { 1432 + "version": "3.0.9", 1433 + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.0.9.tgz", 1434 + "integrity": "sha512-OW9F8t2J3AwFEwENg3yMyKWweF7oRJlMyHOMIhO5F3n0+cgQAJZBjNgrF8dLwFTEXl5jUqBLXd9QyyKv8zEcmA==", 1435 + "dev": true, 1436 + "license": "MIT", 1437 + "dependencies": { 1438 + "tinyrainbow": "^2.0.0" 1439 + }, 1440 + "funding": { 1441 + "url": "https://opencollective.com/vitest" 1442 + } 1443 + }, 1444 + "node_modules/acorn": { 1445 + "version": "8.14.0", 1446 + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", 1447 + "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", 1448 + "dev": true, 1449 + "license": "MIT", 1450 + "bin": { 1451 + "acorn": "bin/acorn" 1452 + }, 1453 + "engines": { 1454 + "node": ">=0.4.0" 1455 + } 1456 + }, 1457 + "node_modules/acorn-walk": { 1458 + "version": "8.3.2", 1459 + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.2.tgz", 1460 + "integrity": "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==", 1461 + "dev": true, 1462 + "license": "MIT", 1463 + "engines": { 1464 + "node": ">=0.4.0" 1465 + } 1466 + }, 1467 + "node_modules/as-table": { 1468 + "version": "1.0.55", 1469 + "resolved": "https://registry.npmjs.org/as-table/-/as-table-1.0.55.tgz", 1470 + "integrity": "sha512-xvsWESUJn0JN421Xb9MQw6AsMHRCUknCe0Wjlxvjud80mU4E6hQf1A6NzQKcYNmYw62MfzEtXc+badstZP3JpQ==", 1471 + "dev": true, 1472 + "license": "MIT", 1473 + "dependencies": { 1474 + "printable-characters": "^1.0.42" 1475 + } 1476 + }, 1477 + "node_modules/assertion-error": { 1478 + "version": "2.0.1", 1479 + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", 1480 + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", 1481 + "dev": true, 1482 + "license": "MIT", 1483 + "engines": { 1484 + "node": ">=12" 1485 + } 1486 + }, 1487 + "node_modules/birpc": { 1488 + "version": "0.2.14", 1489 + "resolved": "https://registry.npmjs.org/birpc/-/birpc-0.2.14.tgz", 1490 + "integrity": "sha512-37FHE8rqsYM5JEKCnXFyHpBCzvgHEExwVVTq+nUmloInU7l8ezD1TpOhKpS8oe1DTYFqEK27rFZVKG43oTqXRA==", 1491 + "dev": true, 1492 + "license": "MIT", 1493 + "funding": { 1494 + "url": "https://github.com/sponsors/antfu" 1495 + } 1496 + }, 1497 + "node_modules/blake3-wasm": { 1498 + "version": "2.1.5", 1499 + "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", 1500 + "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", 1501 + "dev": true, 1502 + "license": "MIT" 1503 + }, 1504 + "node_modules/cac": { 1505 + "version": "6.7.14", 1506 + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", 1507 + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", 1508 + "dev": true, 1509 + "license": "MIT", 1510 + "engines": { 1511 + "node": ">=8" 1512 + } 1513 + }, 1514 + "node_modules/chai": { 1515 + "version": "5.2.0", 1516 + "resolved": "https://registry.npmjs.org/chai/-/chai-5.2.0.tgz", 1517 + "integrity": "sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==", 1518 + "dev": true, 1519 + "license": "MIT", 1520 + "dependencies": { 1521 + "assertion-error": "^2.0.1", 1522 + "check-error": "^2.1.1", 1523 + "deep-eql": "^5.0.1", 1524 + "loupe": "^3.1.0", 1525 + "pathval": "^2.0.0" 1526 + }, 1527 + "engines": { 1528 + "node": ">=12" 1529 + } 1530 + }, 1531 + "node_modules/check-error": { 1532 + "version": "2.1.1", 1533 + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", 1534 + "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", 1535 + "dev": true, 1536 + "license": "MIT", 1537 + "engines": { 1538 + "node": ">= 16" 1539 + } 1540 + }, 1541 + "node_modules/cjs-module-lexer": { 1542 + "version": "1.4.3", 1543 + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", 1544 + "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", 1545 + "dev": true, 1546 + "license": "MIT" 1547 + }, 1548 + "node_modules/color": { 1549 + "version": "4.2.3", 1550 + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", 1551 + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", 1552 + "dev": true, 1553 + "license": "MIT", 1554 + "optional": true, 1555 + "dependencies": { 1556 + "color-convert": "^2.0.1", 1557 + "color-string": "^1.9.0" 1558 + }, 1559 + "engines": { 1560 + "node": ">=12.5.0" 1561 + } 1562 + }, 1563 + "node_modules/color-convert": { 1564 + "version": "2.0.1", 1565 + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", 1566 + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", 1567 + "dev": true, 1568 + "license": "MIT", 1569 + "optional": true, 1570 + "dependencies": { 1571 + "color-name": "~1.1.4" 1572 + }, 1573 + "engines": { 1574 + "node": ">=7.0.0" 1575 + } 1576 + }, 1577 + "node_modules/color-name": { 1578 + "version": "1.1.4", 1579 + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", 1580 + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", 1581 + "dev": true, 1582 + "license": "MIT", 1583 + "optional": true 1584 + }, 1585 + "node_modules/color-string": { 1586 + "version": "1.9.1", 1587 + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", 1588 + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", 1589 + "dev": true, 1590 + "license": "MIT", 1591 + "optional": true, 1592 + "dependencies": { 1593 + "color-name": "^1.0.0", 1594 + "simple-swizzle": "^0.2.2" 1595 + } 1596 + }, 1597 + "node_modules/cookie": { 1598 + "version": "0.7.2", 1599 + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", 1600 + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", 1601 + "dev": true, 1602 + "license": "MIT", 1603 + "engines": { 1604 + "node": ">= 0.6" 1605 + } 1606 + }, 1607 + "node_modules/data-uri-to-buffer": { 1608 + "version": "2.0.2", 1609 + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-2.0.2.tgz", 1610 + "integrity": "sha512-ND9qDTLc6diwj+Xe5cdAgVTbLVdXbtxTJRXRhli8Mowuaan+0EJOtdqJ0QCHNSSPyoXGx9HX2/VMnKeC34AChA==", 1611 + "dev": true, 1612 + "license": "MIT" 1613 + }, 1614 + "node_modules/debug": { 1615 + "version": "4.4.0", 1616 + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", 1617 + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", 1618 + "dev": true, 1619 + "license": "MIT", 1620 + "dependencies": { 1621 + "ms": "^2.1.3" 1622 + }, 1623 + "engines": { 1624 + "node": ">=6.0" 1625 + }, 1626 + "peerDependenciesMeta": { 1627 + "supports-color": { 1628 + "optional": true 1629 + } 1630 + } 1631 + }, 1632 + "node_modules/deep-eql": { 1633 + "version": "5.0.2", 1634 + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", 1635 + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", 1636 + "dev": true, 1637 + "license": "MIT", 1638 + "engines": { 1639 + "node": ">=6" 1640 + } 1641 + }, 1642 + "node_modules/defu": { 1643 + "version": "6.1.4", 1644 + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", 1645 + "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", 1646 + "dev": true, 1647 + "license": "MIT" 1648 + }, 1649 + "node_modules/detect-libc": { 1650 + "version": "2.0.4", 1651 + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", 1652 + "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", 1653 + "dev": true, 1654 + "license": "Apache-2.0", 1655 + "optional": true, 1656 + "engines": { 1657 + "node": ">=8" 1658 + } 1659 + }, 1660 + "node_modules/devalue": { 1661 + "version": "4.3.3", 1662 + "resolved": "https://registry.npmjs.org/devalue/-/devalue-4.3.3.tgz", 1663 + "integrity": "sha512-UH8EL6H2ifcY8TbD2QsxwCC/pr5xSwPvv85LrLXVihmHVC3T3YqTCIwnR5ak0yO1KYqlxrPVOA/JVZJYPy2ATg==", 1664 + "dev": true, 1665 + "license": "MIT" 1666 + }, 1667 + "node_modules/es-module-lexer": { 1668 + "version": "1.7.0", 1669 + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", 1670 + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", 1671 + "dev": true, 1672 + "license": "MIT" 1673 + }, 1674 + "node_modules/esbuild": { 1675 + "version": "0.25.3", 1676 + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.3.tgz", 1677 + "integrity": "sha512-qKA6Pvai73+M2FtftpNKRxJ78GIjmFXFxd/1DVBqGo/qNhLSfv+G12n9pNoWdytJC8U00TrViOwpjT0zgqQS8Q==", 1678 + "dev": true, 1679 + "hasInstallScript": true, 1680 + "license": "MIT", 1681 + "bin": { 1682 + "esbuild": "bin/esbuild" 1683 + }, 1684 + "engines": { 1685 + "node": ">=18" 1686 + }, 1687 + "optionalDependencies": { 1688 + "@esbuild/aix-ppc64": "0.25.3", 1689 + "@esbuild/android-arm": "0.25.3", 1690 + "@esbuild/android-arm64": "0.25.3", 1691 + "@esbuild/android-x64": "0.25.3", 1692 + "@esbuild/darwin-arm64": "0.25.3", 1693 + "@esbuild/darwin-x64": "0.25.3", 1694 + "@esbuild/freebsd-arm64": "0.25.3", 1695 + "@esbuild/freebsd-x64": "0.25.3", 1696 + "@esbuild/linux-arm": "0.25.3", 1697 + "@esbuild/linux-arm64": "0.25.3", 1698 + "@esbuild/linux-ia32": "0.25.3", 1699 + "@esbuild/linux-loong64": "0.25.3", 1700 + "@esbuild/linux-mips64el": "0.25.3", 1701 + "@esbuild/linux-ppc64": "0.25.3", 1702 + "@esbuild/linux-riscv64": "0.25.3", 1703 + "@esbuild/linux-s390x": "0.25.3", 1704 + "@esbuild/linux-x64": "0.25.3", 1705 + "@esbuild/netbsd-arm64": "0.25.3", 1706 + "@esbuild/netbsd-x64": "0.25.3", 1707 + "@esbuild/openbsd-arm64": "0.25.3", 1708 + "@esbuild/openbsd-x64": "0.25.3", 1709 + "@esbuild/sunos-x64": "0.25.3", 1710 + "@esbuild/win32-arm64": "0.25.3", 1711 + "@esbuild/win32-ia32": "0.25.3", 1712 + "@esbuild/win32-x64": "0.25.3" 1713 + } 1714 + }, 1715 + "node_modules/estree-walker": { 1716 + "version": "3.0.3", 1717 + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", 1718 + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", 1719 + "dev": true, 1720 + "license": "MIT", 1721 + "dependencies": { 1722 + "@types/estree": "^1.0.0" 1723 + } 1724 + }, 1725 + "node_modules/exit-hook": { 1726 + "version": "2.2.1", 1727 + "resolved": "https://registry.npmjs.org/exit-hook/-/exit-hook-2.2.1.tgz", 1728 + "integrity": "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw==", 1729 + "dev": true, 1730 + "license": "MIT", 1731 + "engines": { 1732 + "node": ">=6" 1733 + }, 1734 + "funding": { 1735 + "url": "https://github.com/sponsors/sindresorhus" 1736 + } 1737 + }, 1738 + "node_modules/expect-type": { 1739 + "version": "1.2.1", 1740 + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.1.tgz", 1741 + "integrity": "sha512-/kP8CAwxzLVEeFrMm4kMmy4CCDlpipyA7MYLVrdJIkV0fYF0UaigQHRsxHiuY/GEea+bh4KSv3TIlgr+2UL6bw==", 1742 + "dev": true, 1743 + "license": "Apache-2.0", 1744 + "engines": { 1745 + "node": ">=12.0.0" 1746 + } 1747 + }, 1748 + "node_modules/exsolve": { 1749 + "version": "1.0.5", 1750 + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.5.tgz", 1751 + "integrity": "sha512-pz5dvkYYKQ1AHVrgOzBKWeP4u4FRb3a6DNK2ucr0OoNwYIU4QWsJ+NM36LLzORT+z845MzKHHhpXiUF5nvQoJg==", 1752 + "dev": true, 1753 + "license": "MIT" 1754 + }, 1755 + "node_modules/fdir": { 1756 + "version": "6.4.4", 1757 + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz", 1758 + "integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==", 1759 + "dev": true, 1760 + "license": "MIT", 1761 + "peerDependencies": { 1762 + "picomatch": "^3 || ^4" 1763 + }, 1764 + "peerDependenciesMeta": { 1765 + "picomatch": { 1766 + "optional": true 1767 + } 1768 + } 1769 + }, 1770 + "node_modules/fsevents": { 1771 + "version": "2.3.3", 1772 + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", 1773 + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", 1774 + "dev": true, 1775 + "hasInstallScript": true, 1776 + "license": "MIT", 1777 + "optional": true, 1778 + "os": [ 1779 + "darwin" 1780 + ], 1781 + "engines": { 1782 + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" 1783 + } 1784 + }, 1785 + "node_modules/get-source": { 1786 + "version": "2.0.12", 1787 + "resolved": "https://registry.npmjs.org/get-source/-/get-source-2.0.12.tgz", 1788 + "integrity": "sha512-X5+4+iD+HoSeEED+uwrQ07BOQr0kEDFMVqqpBuI+RaZBpBpHCuXxo70bjar6f0b0u/DQJsJ7ssurpP0V60Az+w==", 1789 + "dev": true, 1790 + "license": "Unlicense", 1791 + "dependencies": { 1792 + "data-uri-to-buffer": "^2.0.0", 1793 + "source-map": "^0.6.1" 1794 + } 1795 + }, 1796 + "node_modules/glob-to-regexp": { 1797 + "version": "0.4.1", 1798 + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", 1799 + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", 1800 + "dev": true, 1801 + "license": "BSD-2-Clause" 1802 + }, 1803 + "node_modules/is-arrayish": { 1804 + "version": "0.3.2", 1805 + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", 1806 + "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==", 1807 + "dev": true, 1808 + "license": "MIT", 1809 + "optional": true 1810 + }, 1811 + "node_modules/loupe": { 1812 + "version": "3.1.3", 1813 + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.3.tgz", 1814 + "integrity": "sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==", 1815 + "dev": true, 1816 + "license": "MIT" 1817 + }, 1818 + "node_modules/magic-string": { 1819 + "version": "0.30.17", 1820 + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", 1821 + "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", 1822 + "dev": true, 1823 + "license": "MIT", 1824 + "dependencies": { 1825 + "@jridgewell/sourcemap-codec": "^1.5.0" 1826 + } 1827 + }, 1828 + "node_modules/mime": { 1829 + "version": "3.0.0", 1830 + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", 1831 + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", 1832 + "dev": true, 1833 + "license": "MIT", 1834 + "bin": { 1835 + "mime": "cli.js" 1836 + }, 1837 + "engines": { 1838 + "node": ">=10.0.0" 1839 + } 1840 + }, 1841 + "node_modules/miniflare": { 1842 + "version": "4.20250428.1", 1843 + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20250428.1.tgz", 1844 + "integrity": "sha512-M3qcJXjeAEimHrEeWXEhrJiC3YHB5M3QSqqK67pOTI+lHn0QyVG/2iFUjVJ/nv+i10uxeAEva8GRGeu+tKRCmQ==", 1845 + "dev": true, 1846 + "license": "MIT", 1847 + "dependencies": { 1848 + "@cspotcode/source-map-support": "0.8.1", 1849 + "acorn": "8.14.0", 1850 + "acorn-walk": "8.3.2", 1851 + "exit-hook": "2.2.1", 1852 + "glob-to-regexp": "0.4.1", 1853 + "stoppable": "1.1.0", 1854 + "undici": "^5.28.5", 1855 + "workerd": "1.20250428.0", 1856 + "ws": "8.18.0", 1857 + "youch": "3.3.4", 1858 + "zod": "3.22.3" 1859 + }, 1860 + "bin": { 1861 + "miniflare": "bootstrap.js" 1862 + }, 1863 + "engines": { 1864 + "node": ">=18.0.0" 1865 + } 1866 + }, 1867 + "node_modules/miniflare/node_modules/zod": { 1868 + "version": "3.22.3", 1869 + "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.3.tgz", 1870 + "integrity": "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug==", 1871 + "dev": true, 1872 + "license": "MIT", 1873 + "funding": { 1874 + "url": "https://github.com/sponsors/colinhacks" 1875 + } 1876 + }, 1877 + "node_modules/ms": { 1878 + "version": "2.1.3", 1879 + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", 1880 + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", 1881 + "dev": true, 1882 + "license": "MIT" 1883 + }, 1884 + "node_modules/mustache": { 1885 + "version": "4.2.0", 1886 + "resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz", 1887 + "integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==", 1888 + "dev": true, 1889 + "license": "MIT", 1890 + "bin": { 1891 + "mustache": "bin/mustache" 1892 + } 1893 + }, 1894 + "node_modules/nanoid": { 1895 + "version": "3.3.11", 1896 + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", 1897 + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", 1898 + "dev": true, 1899 + "funding": [ 1900 + { 1901 + "type": "github", 1902 + "url": "https://github.com/sponsors/ai" 1903 + } 1904 + ], 1905 + "license": "MIT", 1906 + "bin": { 1907 + "nanoid": "bin/nanoid.cjs" 1908 + }, 1909 + "engines": { 1910 + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" 1911 + } 1912 + }, 1913 + "node_modules/ohash": { 1914 + "version": "2.0.11", 1915 + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", 1916 + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", 1917 + "dev": true, 1918 + "license": "MIT" 1919 + }, 1920 + "node_modules/path-to-regexp": { 1921 + "version": "6.3.0", 1922 + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", 1923 + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", 1924 + "dev": true, 1925 + "license": "MIT" 1926 + }, 1927 + "node_modules/pathe": { 1928 + "version": "2.0.3", 1929 + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", 1930 + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", 1931 + "dev": true, 1932 + "license": "MIT" 1933 + }, 1934 + "node_modules/pathval": { 1935 + "version": "2.0.0", 1936 + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.0.tgz", 1937 + "integrity": "sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==", 1938 + "dev": true, 1939 + "license": "MIT", 1940 + "engines": { 1941 + "node": ">= 14.16" 1942 + } 1943 + }, 1944 + "node_modules/picocolors": { 1945 + "version": "1.1.1", 1946 + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", 1947 + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", 1948 + "dev": true, 1949 + "license": "ISC" 1950 + }, 1951 + "node_modules/picomatch": { 1952 + "version": "4.0.2", 1953 + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", 1954 + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", 1955 + "dev": true, 1956 + "license": "MIT", 1957 + "engines": { 1958 + "node": ">=12" 1959 + }, 1960 + "funding": { 1961 + "url": "https://github.com/sponsors/jonschlinkert" 1962 + } 1963 + }, 1964 + "node_modules/postcss": { 1965 + "version": "8.5.3", 1966 + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", 1967 + "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==", 1968 + "dev": true, 1969 + "funding": [ 1970 + { 1971 + "type": "opencollective", 1972 + "url": "https://opencollective.com/postcss/" 1973 + }, 1974 + { 1975 + "type": "tidelift", 1976 + "url": "https://tidelift.com/funding/github/npm/postcss" 1977 + }, 1978 + { 1979 + "type": "github", 1980 + "url": "https://github.com/sponsors/ai" 1981 + } 1982 + ], 1983 + "license": "MIT", 1984 + "dependencies": { 1985 + "nanoid": "^3.3.8", 1986 + "picocolors": "^1.1.1", 1987 + "source-map-js": "^1.2.1" 1988 + }, 1989 + "engines": { 1990 + "node": "^10 || ^12 || >=14" 1991 + } 1992 + }, 1993 + "node_modules/printable-characters": { 1994 + "version": "1.0.42", 1995 + "resolved": "https://registry.npmjs.org/printable-characters/-/printable-characters-1.0.42.tgz", 1996 + "integrity": "sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ==", 1997 + "dev": true, 1998 + "license": "Unlicense" 1999 + }, 2000 + "node_modules/rollup": { 2001 + "version": "4.40.1", 2002 + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.40.1.tgz", 2003 + "integrity": "sha512-C5VvvgCCyfyotVITIAv+4efVytl5F7wt+/I2i9q9GZcEXW9BP52YYOXC58igUi+LFZVHukErIIqQSWwv/M3WRw==", 2004 + "dev": true, 2005 + "license": "MIT", 2006 + "dependencies": { 2007 + "@types/estree": "1.0.7" 2008 + }, 2009 + "bin": { 2010 + "rollup": "dist/bin/rollup" 2011 + }, 2012 + "engines": { 2013 + "node": ">=18.0.0", 2014 + "npm": ">=8.0.0" 2015 + }, 2016 + "optionalDependencies": { 2017 + "@rollup/rollup-android-arm-eabi": "4.40.1", 2018 + "@rollup/rollup-android-arm64": "4.40.1", 2019 + "@rollup/rollup-darwin-arm64": "4.40.1", 2020 + "@rollup/rollup-darwin-x64": "4.40.1", 2021 + "@rollup/rollup-freebsd-arm64": "4.40.1", 2022 + "@rollup/rollup-freebsd-x64": "4.40.1", 2023 + "@rollup/rollup-linux-arm-gnueabihf": "4.40.1", 2024 + "@rollup/rollup-linux-arm-musleabihf": "4.40.1", 2025 + "@rollup/rollup-linux-arm64-gnu": "4.40.1", 2026 + "@rollup/rollup-linux-arm64-musl": "4.40.1", 2027 + "@rollup/rollup-linux-loongarch64-gnu": "4.40.1", 2028 + "@rollup/rollup-linux-powerpc64le-gnu": "4.40.1", 2029 + "@rollup/rollup-linux-riscv64-gnu": "4.40.1", 2030 + "@rollup/rollup-linux-riscv64-musl": "4.40.1", 2031 + "@rollup/rollup-linux-s390x-gnu": "4.40.1", 2032 + "@rollup/rollup-linux-x64-gnu": "4.40.1", 2033 + "@rollup/rollup-linux-x64-musl": "4.40.1", 2034 + "@rollup/rollup-win32-arm64-msvc": "4.40.1", 2035 + "@rollup/rollup-win32-ia32-msvc": "4.40.1", 2036 + "@rollup/rollup-win32-x64-msvc": "4.40.1", 2037 + "fsevents": "~2.3.2" 2038 + } 2039 + }, 2040 + "node_modules/semver": { 2041 + "version": "7.7.1", 2042 + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", 2043 + "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", 2044 + "dev": true, 2045 + "license": "ISC", 2046 + "bin": { 2047 + "semver": "bin/semver.js" 2048 + }, 2049 + "engines": { 2050 + "node": ">=10" 2051 + } 2052 + }, 2053 + "node_modules/sharp": { 2054 + "version": "0.33.5", 2055 + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", 2056 + "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", 2057 + "dev": true, 2058 + "hasInstallScript": true, 2059 + "license": "Apache-2.0", 2060 + "optional": true, 2061 + "dependencies": { 2062 + "color": "^4.2.3", 2063 + "detect-libc": "^2.0.3", 2064 + "semver": "^7.6.3" 2065 + }, 2066 + "engines": { 2067 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 2068 + }, 2069 + "funding": { 2070 + "url": "https://opencollective.com/libvips" 2071 + }, 2072 + "optionalDependencies": { 2073 + "@img/sharp-darwin-arm64": "0.33.5", 2074 + "@img/sharp-darwin-x64": "0.33.5", 2075 + "@img/sharp-libvips-darwin-arm64": "1.0.4", 2076 + "@img/sharp-libvips-darwin-x64": "1.0.4", 2077 + "@img/sharp-libvips-linux-arm": "1.0.5", 2078 + "@img/sharp-libvips-linux-arm64": "1.0.4", 2079 + "@img/sharp-libvips-linux-s390x": "1.0.4", 2080 + "@img/sharp-libvips-linux-x64": "1.0.4", 2081 + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", 2082 + "@img/sharp-libvips-linuxmusl-x64": "1.0.4", 2083 + "@img/sharp-linux-arm": "0.33.5", 2084 + "@img/sharp-linux-arm64": "0.33.5", 2085 + "@img/sharp-linux-s390x": "0.33.5", 2086 + "@img/sharp-linux-x64": "0.33.5", 2087 + "@img/sharp-linuxmusl-arm64": "0.33.5", 2088 + "@img/sharp-linuxmusl-x64": "0.33.5", 2089 + "@img/sharp-wasm32": "0.33.5", 2090 + "@img/sharp-win32-ia32": "0.33.5", 2091 + "@img/sharp-win32-x64": "0.33.5" 2092 + } 2093 + }, 2094 + "node_modules/siginfo": { 2095 + "version": "2.0.0", 2096 + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", 2097 + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", 2098 + "dev": true, 2099 + "license": "ISC" 2100 + }, 2101 + "node_modules/simple-swizzle": { 2102 + "version": "0.2.2", 2103 + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", 2104 + "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==", 2105 + "dev": true, 2106 + "license": "MIT", 2107 + "optional": true, 2108 + "dependencies": { 2109 + "is-arrayish": "^0.3.1" 2110 + } 2111 + }, 2112 + "node_modules/source-map": { 2113 + "version": "0.6.1", 2114 + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", 2115 + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", 2116 + "dev": true, 2117 + "license": "BSD-3-Clause", 2118 + "engines": { 2119 + "node": ">=0.10.0" 2120 + } 2121 + }, 2122 + "node_modules/source-map-js": { 2123 + "version": "1.2.1", 2124 + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", 2125 + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", 2126 + "dev": true, 2127 + "license": "BSD-3-Clause", 2128 + "engines": { 2129 + "node": ">=0.10.0" 2130 + } 2131 + }, 2132 + "node_modules/stackback": { 2133 + "version": "0.0.2", 2134 + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", 2135 + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", 2136 + "dev": true, 2137 + "license": "MIT" 2138 + }, 2139 + "node_modules/stacktracey": { 2140 + "version": "2.1.8", 2141 + "resolved": "https://registry.npmjs.org/stacktracey/-/stacktracey-2.1.8.tgz", 2142 + "integrity": "sha512-Kpij9riA+UNg7TnphqjH7/CzctQ/owJGNbFkfEeve4Z4uxT5+JapVLFXcsurIfN34gnTWZNJ/f7NMG0E8JDzTw==", 2143 + "dev": true, 2144 + "license": "Unlicense", 2145 + "dependencies": { 2146 + "as-table": "^1.0.36", 2147 + "get-source": "^2.0.12" 2148 + } 2149 + }, 2150 + "node_modules/std-env": { 2151 + "version": "3.9.0", 2152 + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.9.0.tgz", 2153 + "integrity": "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==", 2154 + "dev": true, 2155 + "license": "MIT" 2156 + }, 2157 + "node_modules/stoppable": { 2158 + "version": "1.1.0", 2159 + "resolved": "https://registry.npmjs.org/stoppable/-/stoppable-1.1.0.tgz", 2160 + "integrity": "sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw==", 2161 + "dev": true, 2162 + "license": "MIT", 2163 + "engines": { 2164 + "node": ">=4", 2165 + "npm": ">=6" 2166 + } 2167 + }, 2168 + "node_modules/tinybench": { 2169 + "version": "2.9.0", 2170 + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", 2171 + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", 2172 + "dev": true, 2173 + "license": "MIT" 2174 + }, 2175 + "node_modules/tinyexec": { 2176 + "version": "0.3.2", 2177 + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", 2178 + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", 2179 + "dev": true, 2180 + "license": "MIT" 2181 + }, 2182 + "node_modules/tinyglobby": { 2183 + "version": "0.2.13", 2184 + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.13.tgz", 2185 + "integrity": "sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==", 2186 + "dev": true, 2187 + "license": "MIT", 2188 + "dependencies": { 2189 + "fdir": "^6.4.4", 2190 + "picomatch": "^4.0.2" 2191 + }, 2192 + "engines": { 2193 + "node": ">=12.0.0" 2194 + }, 2195 + "funding": { 2196 + "url": "https://github.com/sponsors/SuperchupuDev" 2197 + } 2198 + }, 2199 + "node_modules/tinypool": { 2200 + "version": "1.0.2", 2201 + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.0.2.tgz", 2202 + "integrity": "sha512-al6n+QEANGFOMf/dmUMsuS5/r9B06uwlyNjZZql/zv8J7ybHCgoihBNORZCY2mzUuAnomQa2JdhyHKzZxPCrFA==", 2203 + "dev": true, 2204 + "license": "MIT", 2205 + "engines": { 2206 + "node": "^18.0.0 || >=20.0.0" 2207 + } 2208 + }, 2209 + "node_modules/tinyrainbow": { 2210 + "version": "2.0.0", 2211 + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", 2212 + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", 2213 + "dev": true, 2214 + "license": "MIT", 2215 + "engines": { 2216 + "node": ">=14.0.0" 2217 + } 2218 + }, 2219 + "node_modules/tinyspy": { 2220 + "version": "3.0.2", 2221 + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", 2222 + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", 2223 + "dev": true, 2224 + "license": "MIT", 2225 + "engines": { 2226 + "node": ">=14.0.0" 2227 + } 2228 + }, 2229 + "node_modules/tslib": { 2230 + "version": "2.8.1", 2231 + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", 2232 + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", 2233 + "dev": true, 2234 + "license": "0BSD", 2235 + "optional": true 2236 + }, 2237 + "node_modules/ufo": { 2238 + "version": "1.6.1", 2239 + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.1.tgz", 2240 + "integrity": "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==", 2241 + "dev": true, 2242 + "license": "MIT" 2243 + }, 2244 + "node_modules/undici": { 2245 + "version": "5.29.0", 2246 + "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz", 2247 + "integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==", 2248 + "dev": true, 2249 + "license": "MIT", 2250 + "dependencies": { 2251 + "@fastify/busboy": "^2.0.0" 2252 + }, 2253 + "engines": { 2254 + "node": ">=14.0" 2255 + } 2256 + }, 2257 + "node_modules/unenv": { 2258 + "version": "2.0.0-rc.15", 2259 + "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.15.tgz", 2260 + "integrity": "sha512-J/rEIZU8w6FOfLNz/hNKsnY+fFHWnu9MH4yRbSZF3xbbGHovcetXPs7sD+9p8L6CeNC//I9bhRYAOsBt2u7/OA==", 2261 + "dev": true, 2262 + "license": "MIT", 2263 + "dependencies": { 2264 + "defu": "^6.1.4", 2265 + "exsolve": "^1.0.4", 2266 + "ohash": "^2.0.11", 2267 + "pathe": "^2.0.3", 2268 + "ufo": "^1.5.4" 2269 + } 2270 + }, 2271 + "node_modules/vite": { 2272 + "version": "6.3.4", 2273 + "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.4.tgz", 2274 + "integrity": "sha512-BiReIiMS2fyFqbqNT/Qqt4CVITDU9M9vE+DKcVAsB+ZV0wvTKd+3hMbkpxz1b+NmEDMegpVbisKiAZOnvO92Sw==", 2275 + "dev": true, 2276 + "license": "MIT", 2277 + "dependencies": { 2278 + "esbuild": "^0.25.0", 2279 + "fdir": "^6.4.4", 2280 + "picomatch": "^4.0.2", 2281 + "postcss": "^8.5.3", 2282 + "rollup": "^4.34.9", 2283 + "tinyglobby": "^0.2.13" 2284 + }, 2285 + "bin": { 2286 + "vite": "bin/vite.js" 2287 + }, 2288 + "engines": { 2289 + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" 2290 + }, 2291 + "funding": { 2292 + "url": "https://github.com/vitejs/vite?sponsor=1" 2293 + }, 2294 + "optionalDependencies": { 2295 + "fsevents": "~2.3.3" 2296 + }, 2297 + "peerDependencies": { 2298 + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", 2299 + "jiti": ">=1.21.0", 2300 + "less": "*", 2301 + "lightningcss": "^1.21.0", 2302 + "sass": "*", 2303 + "sass-embedded": "*", 2304 + "stylus": "*", 2305 + "sugarss": "*", 2306 + "terser": "^5.16.0", 2307 + "tsx": "^4.8.1", 2308 + "yaml": "^2.4.2" 2309 + }, 2310 + "peerDependenciesMeta": { 2311 + "@types/node": { 2312 + "optional": true 2313 + }, 2314 + "jiti": { 2315 + "optional": true 2316 + }, 2317 + "less": { 2318 + "optional": true 2319 + }, 2320 + "lightningcss": { 2321 + "optional": true 2322 + }, 2323 + "sass": { 2324 + "optional": true 2325 + }, 2326 + "sass-embedded": { 2327 + "optional": true 2328 + }, 2329 + "stylus": { 2330 + "optional": true 2331 + }, 2332 + "sugarss": { 2333 + "optional": true 2334 + }, 2335 + "terser": { 2336 + "optional": true 2337 + }, 2338 + "tsx": { 2339 + "optional": true 2340 + }, 2341 + "yaml": { 2342 + "optional": true 2343 + } 2344 + } 2345 + }, 2346 + "node_modules/vite-node": { 2347 + "version": "3.0.9", 2348 + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.0.9.tgz", 2349 + "integrity": "sha512-w3Gdx7jDcuT9cNn9jExXgOyKmf5UOTb6WMHz8LGAm54eS1Elf5OuBhCxl6zJxGhEeIkgsE1WbHuoL0mj/UXqXg==", 2350 + "dev": true, 2351 + "license": "MIT", 2352 + "dependencies": { 2353 + "cac": "^6.7.14", 2354 + "debug": "^4.4.0", 2355 + "es-module-lexer": "^1.6.0", 2356 + "pathe": "^2.0.3", 2357 + "vite": "^5.0.0 || ^6.0.0" 2358 + }, 2359 + "bin": { 2360 + "vite-node": "vite-node.mjs" 2361 + }, 2362 + "engines": { 2363 + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" 2364 + }, 2365 + "funding": { 2366 + "url": "https://opencollective.com/vitest" 2367 + } 2368 + }, 2369 + "node_modules/vitest": { 2370 + "version": "3.0.9", 2371 + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.0.9.tgz", 2372 + "integrity": "sha512-BbcFDqNyBlfSpATmTtXOAOj71RNKDDvjBM/uPfnxxVGrG+FSH2RQIwgeEngTaTkuU/h0ScFvf+tRcKfYXzBybQ==", 2373 + "dev": true, 2374 + "license": "MIT", 2375 + "dependencies": { 2376 + "@vitest/expect": "3.0.9", 2377 + "@vitest/mocker": "3.0.9", 2378 + "@vitest/pretty-format": "^3.0.9", 2379 + "@vitest/runner": "3.0.9", 2380 + "@vitest/snapshot": "3.0.9", 2381 + "@vitest/spy": "3.0.9", 2382 + "@vitest/utils": "3.0.9", 2383 + "chai": "^5.2.0", 2384 + "debug": "^4.4.0", 2385 + "expect-type": "^1.1.0", 2386 + "magic-string": "^0.30.17", 2387 + "pathe": "^2.0.3", 2388 + "std-env": "^3.8.0", 2389 + "tinybench": "^2.9.0", 2390 + "tinyexec": "^0.3.2", 2391 + "tinypool": "^1.0.2", 2392 + "tinyrainbow": "^2.0.0", 2393 + "vite": "^5.0.0 || ^6.0.0", 2394 + "vite-node": "3.0.9", 2395 + "why-is-node-running": "^2.3.0" 2396 + }, 2397 + "bin": { 2398 + "vitest": "vitest.mjs" 2399 + }, 2400 + "engines": { 2401 + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" 2402 + }, 2403 + "funding": { 2404 + "url": "https://opencollective.com/vitest" 2405 + }, 2406 + "peerDependencies": { 2407 + "@edge-runtime/vm": "*", 2408 + "@types/debug": "^4.1.12", 2409 + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", 2410 + "@vitest/browser": "3.0.9", 2411 + "@vitest/ui": "3.0.9", 2412 + "happy-dom": "*", 2413 + "jsdom": "*" 2414 + }, 2415 + "peerDependenciesMeta": { 2416 + "@edge-runtime/vm": { 2417 + "optional": true 2418 + }, 2419 + "@types/debug": { 2420 + "optional": true 2421 + }, 2422 + "@types/node": { 2423 + "optional": true 2424 + }, 2425 + "@vitest/browser": { 2426 + "optional": true 2427 + }, 2428 + "@vitest/ui": { 2429 + "optional": true 2430 + }, 2431 + "happy-dom": { 2432 + "optional": true 2433 + }, 2434 + "jsdom": { 2435 + "optional": true 2436 + } 2437 + } 2438 + }, 2439 + "node_modules/why-is-node-running": { 2440 + "version": "2.3.0", 2441 + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", 2442 + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", 2443 + "dev": true, 2444 + "license": "MIT", 2445 + "dependencies": { 2446 + "siginfo": "^2.0.0", 2447 + "stackback": "0.0.2" 2448 + }, 2449 + "bin": { 2450 + "why-is-node-running": "cli.js" 2451 + }, 2452 + "engines": { 2453 + "node": ">=8" 2454 + } 2455 + }, 2456 + "node_modules/workerd": { 2457 + "version": "1.20250428.0", 2458 + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20250428.0.tgz", 2459 + "integrity": "sha512-JJNWkHkwPQKQdvtM9UORijgYdcdJsihA4SfYjwh02IUQsdMyZ9jizV1sX9yWi9B9ptlohTW8UNHJEATuphGgdg==", 2460 + "dev": true, 2461 + "hasInstallScript": true, 2462 + "license": "Apache-2.0", 2463 + "bin": { 2464 + "workerd": "bin/workerd" 2465 + }, 2466 + "engines": { 2467 + "node": ">=16" 2468 + }, 2469 + "optionalDependencies": { 2470 + "@cloudflare/workerd-darwin-64": "1.20250428.0", 2471 + "@cloudflare/workerd-darwin-arm64": "1.20250428.0", 2472 + "@cloudflare/workerd-linux-64": "1.20250428.0", 2473 + "@cloudflare/workerd-linux-arm64": "1.20250428.0", 2474 + "@cloudflare/workerd-windows-64": "1.20250428.0" 2475 + } 2476 + }, 2477 + "node_modules/wrangler": { 2478 + "version": "4.14.1", 2479 + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.14.1.tgz", 2480 + "integrity": "sha512-EU7IThP7i68TBftJJSveogvWZ5k/WRijcJh3UclDWiWWhDZTPbL6LOJEFhHKqFzHOaC4Y2Aewt48rfTz0e7oCw==", 2481 + "dev": true, 2482 + "license": "MIT OR Apache-2.0", 2483 + "dependencies": { 2484 + "@cloudflare/kv-asset-handler": "0.4.0", 2485 + "@cloudflare/unenv-preset": "2.3.1", 2486 + "blake3-wasm": "2.1.5", 2487 + "esbuild": "0.25.2", 2488 + "miniflare": "4.20250428.1", 2489 + "path-to-regexp": "6.3.0", 2490 + "unenv": "2.0.0-rc.15", 2491 + "workerd": "1.20250428.0" 2492 + }, 2493 + "bin": { 2494 + "wrangler": "bin/wrangler.js", 2495 + "wrangler2": "bin/wrangler.js" 2496 + }, 2497 + "engines": { 2498 + "node": ">=18.0.0" 2499 + }, 2500 + "optionalDependencies": { 2501 + "fsevents": "~2.3.2", 2502 + "sharp": "^0.33.5" 2503 + }, 2504 + "peerDependencies": { 2505 + "@cloudflare/workers-types": "^4.20250428.0" 2506 + }, 2507 + "peerDependenciesMeta": { 2508 + "@cloudflare/workers-types": { 2509 + "optional": true 2510 + } 2511 + } 2512 + }, 2513 + "node_modules/wrangler/node_modules/@esbuild/aix-ppc64": { 2514 + "version": "0.25.2", 2515 + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.2.tgz", 2516 + "integrity": "sha512-wCIboOL2yXZym2cgm6mlA742s9QeJ8DjGVaL39dLN4rRwrOgOyYSnOaFPhKZGLb2ngj4EyfAFjsNJwPXZvseag==", 2517 + "cpu": [ 2518 + "ppc64" 2519 + ], 2520 + "dev": true, 2521 + "license": "MIT", 2522 + "optional": true, 2523 + "os": [ 2524 + "aix" 2525 + ], 2526 + "engines": { 2527 + "node": ">=18" 2528 + } 2529 + }, 2530 + "node_modules/wrangler/node_modules/@esbuild/android-arm": { 2531 + "version": "0.25.2", 2532 + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.2.tgz", 2533 + "integrity": "sha512-NQhH7jFstVY5x8CKbcfa166GoV0EFkaPkCKBQkdPJFvo5u+nGXLEH/ooniLb3QI8Fk58YAx7nsPLozUWfCBOJA==", 2534 + "cpu": [ 2535 + "arm" 2536 + ], 2537 + "dev": true, 2538 + "license": "MIT", 2539 + "optional": true, 2540 + "os": [ 2541 + "android" 2542 + ], 2543 + "engines": { 2544 + "node": ">=18" 2545 + } 2546 + }, 2547 + "node_modules/wrangler/node_modules/@esbuild/android-arm64": { 2548 + "version": "0.25.2", 2549 + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.2.tgz", 2550 + "integrity": "sha512-5ZAX5xOmTligeBaeNEPnPaeEuah53Id2tX4c2CVP3JaROTH+j4fnfHCkr1PjXMd78hMst+TlkfKcW/DlTq0i4w==", 2551 + "cpu": [ 2552 + "arm64" 2553 + ], 2554 + "dev": true, 2555 + "license": "MIT", 2556 + "optional": true, 2557 + "os": [ 2558 + "android" 2559 + ], 2560 + "engines": { 2561 + "node": ">=18" 2562 + } 2563 + }, 2564 + "node_modules/wrangler/node_modules/@esbuild/android-x64": { 2565 + "version": "0.25.2", 2566 + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.2.tgz", 2567 + "integrity": "sha512-Ffcx+nnma8Sge4jzddPHCZVRvIfQ0kMsUsCMcJRHkGJ1cDmhe4SsrYIjLUKn1xpHZybmOqCWwB0zQvsjdEHtkg==", 2568 + "cpu": [ 2569 + "x64" 2570 + ], 2571 + "dev": true, 2572 + "license": "MIT", 2573 + "optional": true, 2574 + "os": [ 2575 + "android" 2576 + ], 2577 + "engines": { 2578 + "node": ">=18" 2579 + } 2580 + }, 2581 + "node_modules/wrangler/node_modules/@esbuild/darwin-arm64": { 2582 + "version": "0.25.2", 2583 + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.2.tgz", 2584 + "integrity": "sha512-MpM6LUVTXAzOvN4KbjzU/q5smzryuoNjlriAIx+06RpecwCkL9JpenNzpKd2YMzLJFOdPqBpuub6eVRP5IgiSA==", 2585 + "cpu": [ 2586 + "arm64" 2587 + ], 2588 + "dev": true, 2589 + "license": "MIT", 2590 + "optional": true, 2591 + "os": [ 2592 + "darwin" 2593 + ], 2594 + "engines": { 2595 + "node": ">=18" 2596 + } 2597 + }, 2598 + "node_modules/wrangler/node_modules/@esbuild/darwin-x64": { 2599 + "version": "0.25.2", 2600 + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.2.tgz", 2601 + "integrity": "sha512-5eRPrTX7wFyuWe8FqEFPG2cU0+butQQVNcT4sVipqjLYQjjh8a8+vUTfgBKM88ObB85ahsnTwF7PSIt6PG+QkA==", 2602 + "cpu": [ 2603 + "x64" 2604 + ], 2605 + "dev": true, 2606 + "license": "MIT", 2607 + "optional": true, 2608 + "os": [ 2609 + "darwin" 2610 + ], 2611 + "engines": { 2612 + "node": ">=18" 2613 + } 2614 + }, 2615 + "node_modules/wrangler/node_modules/@esbuild/freebsd-arm64": { 2616 + "version": "0.25.2", 2617 + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.2.tgz", 2618 + "integrity": "sha512-mLwm4vXKiQ2UTSX4+ImyiPdiHjiZhIaE9QvC7sw0tZ6HoNMjYAqQpGyui5VRIi5sGd+uWq940gdCbY3VLvsO1w==", 2619 + "cpu": [ 2620 + "arm64" 2621 + ], 2622 + "dev": true, 2623 + "license": "MIT", 2624 + "optional": true, 2625 + "os": [ 2626 + "freebsd" 2627 + ], 2628 + "engines": { 2629 + "node": ">=18" 2630 + } 2631 + }, 2632 + "node_modules/wrangler/node_modules/@esbuild/freebsd-x64": { 2633 + "version": "0.25.2", 2634 + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.2.tgz", 2635 + "integrity": "sha512-6qyyn6TjayJSwGpm8J9QYYGQcRgc90nmfdUb0O7pp1s4lTY+9D0H9O02v5JqGApUyiHOtkz6+1hZNvNtEhbwRQ==", 2636 + "cpu": [ 2637 + "x64" 2638 + ], 2639 + "dev": true, 2640 + "license": "MIT", 2641 + "optional": true, 2642 + "os": [ 2643 + "freebsd" 2644 + ], 2645 + "engines": { 2646 + "node": ">=18" 2647 + } 2648 + }, 2649 + "node_modules/wrangler/node_modules/@esbuild/linux-arm": { 2650 + "version": "0.25.2", 2651 + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.2.tgz", 2652 + "integrity": "sha512-UHBRgJcmjJv5oeQF8EpTRZs/1knq6loLxTsjc3nxO9eXAPDLcWW55flrMVc97qFPbmZP31ta1AZVUKQzKTzb0g==", 2653 + "cpu": [ 2654 + "arm" 2655 + ], 2656 + "dev": true, 2657 + "license": "MIT", 2658 + "optional": true, 2659 + "os": [ 2660 + "linux" 2661 + ], 2662 + "engines": { 2663 + "node": ">=18" 2664 + } 2665 + }, 2666 + "node_modules/wrangler/node_modules/@esbuild/linux-arm64": { 2667 + "version": "0.25.2", 2668 + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.2.tgz", 2669 + "integrity": "sha512-gq/sjLsOyMT19I8obBISvhoYiZIAaGF8JpeXu1u8yPv8BE5HlWYobmlsfijFIZ9hIVGYkbdFhEqC0NvM4kNO0g==", 2670 + "cpu": [ 2671 + "arm64" 2672 + ], 2673 + "dev": true, 2674 + "license": "MIT", 2675 + "optional": true, 2676 + "os": [ 2677 + "linux" 2678 + ], 2679 + "engines": { 2680 + "node": ">=18" 2681 + } 2682 + }, 2683 + "node_modules/wrangler/node_modules/@esbuild/linux-ia32": { 2684 + "version": "0.25.2", 2685 + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.2.tgz", 2686 + "integrity": "sha512-bBYCv9obgW2cBP+2ZWfjYTU+f5cxRoGGQ5SeDbYdFCAZpYWrfjjfYwvUpP8MlKbP0nwZ5gyOU/0aUzZ5HWPuvQ==", 2687 + "cpu": [ 2688 + "ia32" 2689 + ], 2690 + "dev": true, 2691 + "license": "MIT", 2692 + "optional": true, 2693 + "os": [ 2694 + "linux" 2695 + ], 2696 + "engines": { 2697 + "node": ">=18" 2698 + } 2699 + }, 2700 + "node_modules/wrangler/node_modules/@esbuild/linux-loong64": { 2701 + "version": "0.25.2", 2702 + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.2.tgz", 2703 + "integrity": "sha512-SHNGiKtvnU2dBlM5D8CXRFdd+6etgZ9dXfaPCeJtz+37PIUlixvlIhI23L5khKXs3DIzAn9V8v+qb1TRKrgT5w==", 2704 + "cpu": [ 2705 + "loong64" 2706 + ], 2707 + "dev": true, 2708 + "license": "MIT", 2709 + "optional": true, 2710 + "os": [ 2711 + "linux" 2712 + ], 2713 + "engines": { 2714 + "node": ">=18" 2715 + } 2716 + }, 2717 + "node_modules/wrangler/node_modules/@esbuild/linux-mips64el": { 2718 + "version": "0.25.2", 2719 + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.2.tgz", 2720 + "integrity": "sha512-hDDRlzE6rPeoj+5fsADqdUZl1OzqDYow4TB4Y/3PlKBD0ph1e6uPHzIQcv2Z65u2K0kpeByIyAjCmjn1hJgG0Q==", 2721 + "cpu": [ 2722 + "mips64el" 2723 + ], 2724 + "dev": true, 2725 + "license": "MIT", 2726 + "optional": true, 2727 + "os": [ 2728 + "linux" 2729 + ], 2730 + "engines": { 2731 + "node": ">=18" 2732 + } 2733 + }, 2734 + "node_modules/wrangler/node_modules/@esbuild/linux-ppc64": { 2735 + "version": "0.25.2", 2736 + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.2.tgz", 2737 + "integrity": "sha512-tsHu2RRSWzipmUi9UBDEzc0nLc4HtpZEI5Ba+Omms5456x5WaNuiG3u7xh5AO6sipnJ9r4cRWQB2tUjPyIkc6g==", 2738 + "cpu": [ 2739 + "ppc64" 2740 + ], 2741 + "dev": true, 2742 + "license": "MIT", 2743 + "optional": true, 2744 + "os": [ 2745 + "linux" 2746 + ], 2747 + "engines": { 2748 + "node": ">=18" 2749 + } 2750 + }, 2751 + "node_modules/wrangler/node_modules/@esbuild/linux-riscv64": { 2752 + "version": "0.25.2", 2753 + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.2.tgz", 2754 + "integrity": "sha512-k4LtpgV7NJQOml/10uPU0s4SAXGnowi5qBSjaLWMojNCUICNu7TshqHLAEbkBdAszL5TabfvQ48kK84hyFzjnw==", 2755 + "cpu": [ 2756 + "riscv64" 2757 + ], 2758 + "dev": true, 2759 + "license": "MIT", 2760 + "optional": true, 2761 + "os": [ 2762 + "linux" 2763 + ], 2764 + "engines": { 2765 + "node": ">=18" 2766 + } 2767 + }, 2768 + "node_modules/wrangler/node_modules/@esbuild/linux-s390x": { 2769 + "version": "0.25.2", 2770 + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.2.tgz", 2771 + "integrity": "sha512-GRa4IshOdvKY7M/rDpRR3gkiTNp34M0eLTaC1a08gNrh4u488aPhuZOCpkF6+2wl3zAN7L7XIpOFBhnaE3/Q8Q==", 2772 + "cpu": [ 2773 + "s390x" 2774 + ], 2775 + "dev": true, 2776 + "license": "MIT", 2777 + "optional": true, 2778 + "os": [ 2779 + "linux" 2780 + ], 2781 + "engines": { 2782 + "node": ">=18" 2783 + } 2784 + }, 2785 + "node_modules/wrangler/node_modules/@esbuild/linux-x64": { 2786 + "version": "0.25.2", 2787 + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.2.tgz", 2788 + "integrity": "sha512-QInHERlqpTTZ4FRB0fROQWXcYRD64lAoiegezDunLpalZMjcUcld3YzZmVJ2H/Cp0wJRZ8Xtjtj0cEHhYc/uUg==", 2789 + "cpu": [ 2790 + "x64" 2791 + ], 2792 + "dev": true, 2793 + "license": "MIT", 2794 + "optional": true, 2795 + "os": [ 2796 + "linux" 2797 + ], 2798 + "engines": { 2799 + "node": ">=18" 2800 + } 2801 + }, 2802 + "node_modules/wrangler/node_modules/@esbuild/netbsd-arm64": { 2803 + "version": "0.25.2", 2804 + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.2.tgz", 2805 + "integrity": "sha512-talAIBoY5M8vHc6EeI2WW9d/CkiO9MQJ0IOWX8hrLhxGbro/vBXJvaQXefW2cP0z0nQVTdQ/eNyGFV1GSKrxfw==", 2806 + "cpu": [ 2807 + "arm64" 2808 + ], 2809 + "dev": true, 2810 + "license": "MIT", 2811 + "optional": true, 2812 + "os": [ 2813 + "netbsd" 2814 + ], 2815 + "engines": { 2816 + "node": ">=18" 2817 + } 2818 + }, 2819 + "node_modules/wrangler/node_modules/@esbuild/netbsd-x64": { 2820 + "version": "0.25.2", 2821 + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.2.tgz", 2822 + "integrity": "sha512-voZT9Z+tpOxrvfKFyfDYPc4DO4rk06qamv1a/fkuzHpiVBMOhpjK+vBmWM8J1eiB3OLSMFYNaOaBNLXGChf5tg==", 2823 + "cpu": [ 2824 + "x64" 2825 + ], 2826 + "dev": true, 2827 + "license": "MIT", 2828 + "optional": true, 2829 + "os": [ 2830 + "netbsd" 2831 + ], 2832 + "engines": { 2833 + "node": ">=18" 2834 + } 2835 + }, 2836 + "node_modules/wrangler/node_modules/@esbuild/openbsd-arm64": { 2837 + "version": "0.25.2", 2838 + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.2.tgz", 2839 + "integrity": "sha512-dcXYOC6NXOqcykeDlwId9kB6OkPUxOEqU+rkrYVqJbK2hagWOMrsTGsMr8+rW02M+d5Op5NNlgMmjzecaRf7Tg==", 2840 + "cpu": [ 2841 + "arm64" 2842 + ], 2843 + "dev": true, 2844 + "license": "MIT", 2845 + "optional": true, 2846 + "os": [ 2847 + "openbsd" 2848 + ], 2849 + "engines": { 2850 + "node": ">=18" 2851 + } 2852 + }, 2853 + "node_modules/wrangler/node_modules/@esbuild/openbsd-x64": { 2854 + "version": "0.25.2", 2855 + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.2.tgz", 2856 + "integrity": "sha512-t/TkWwahkH0Tsgoq1Ju7QfgGhArkGLkF1uYz8nQS/PPFlXbP5YgRpqQR3ARRiC2iXoLTWFxc6DJMSK10dVXluw==", 2857 + "cpu": [ 2858 + "x64" 2859 + ], 2860 + "dev": true, 2861 + "license": "MIT", 2862 + "optional": true, 2863 + "os": [ 2864 + "openbsd" 2865 + ], 2866 + "engines": { 2867 + "node": ">=18" 2868 + } 2869 + }, 2870 + "node_modules/wrangler/node_modules/@esbuild/sunos-x64": { 2871 + "version": "0.25.2", 2872 + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.2.tgz", 2873 + "integrity": "sha512-cfZH1co2+imVdWCjd+D1gf9NjkchVhhdpgb1q5y6Hcv9TP6Zi9ZG/beI3ig8TvwT9lH9dlxLq5MQBBgwuj4xvA==", 2874 + "cpu": [ 2875 + "x64" 2876 + ], 2877 + "dev": true, 2878 + "license": "MIT", 2879 + "optional": true, 2880 + "os": [ 2881 + "sunos" 2882 + ], 2883 + "engines": { 2884 + "node": ">=18" 2885 + } 2886 + }, 2887 + "node_modules/wrangler/node_modules/@esbuild/win32-arm64": { 2888 + "version": "0.25.2", 2889 + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.2.tgz", 2890 + "integrity": "sha512-7Loyjh+D/Nx/sOTzV8vfbB3GJuHdOQyrOryFdZvPHLf42Tk9ivBU5Aedi7iyX+x6rbn2Mh68T4qq1SDqJBQO5Q==", 2891 + "cpu": [ 2892 + "arm64" 2893 + ], 2894 + "dev": true, 2895 + "license": "MIT", 2896 + "optional": true, 2897 + "os": [ 2898 + "win32" 2899 + ], 2900 + "engines": { 2901 + "node": ">=18" 2902 + } 2903 + }, 2904 + "node_modules/wrangler/node_modules/@esbuild/win32-ia32": { 2905 + "version": "0.25.2", 2906 + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.2.tgz", 2907 + "integrity": "sha512-WRJgsz9un0nqZJ4MfhabxaD9Ft8KioqU3JMinOTvobbX6MOSUigSBlogP8QB3uxpJDsFS6yN+3FDBdqE5lg9kg==", 2908 + "cpu": [ 2909 + "ia32" 2910 + ], 2911 + "dev": true, 2912 + "license": "MIT", 2913 + "optional": true, 2914 + "os": [ 2915 + "win32" 2916 + ], 2917 + "engines": { 2918 + "node": ">=18" 2919 + } 2920 + }, 2921 + "node_modules/wrangler/node_modules/@esbuild/win32-x64": { 2922 + "version": "0.25.2", 2923 + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.2.tgz", 2924 + "integrity": "sha512-kM3HKb16VIXZyIeVrM1ygYmZBKybX8N4p754bw390wGO3Tf2j4L2/WYL+4suWujpgf6GBYs3jv7TyUivdd05JA==", 2925 + "cpu": [ 2926 + "x64" 2927 + ], 2928 + "dev": true, 2929 + "license": "MIT", 2930 + "optional": true, 2931 + "os": [ 2932 + "win32" 2933 + ], 2934 + "engines": { 2935 + "node": ">=18" 2936 + } 2937 + }, 2938 + "node_modules/wrangler/node_modules/esbuild": { 2939 + "version": "0.25.2", 2940 + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.2.tgz", 2941 + "integrity": "sha512-16854zccKPnC+toMywC+uKNeYSv+/eXkevRAfwRD/G9Cleq66m8XFIrigkbvauLLlCfDL45Q2cWegSg53gGBnQ==", 2942 + "dev": true, 2943 + "hasInstallScript": true, 2944 + "license": "MIT", 2945 + "bin": { 2946 + "esbuild": "bin/esbuild" 2947 + }, 2948 + "engines": { 2949 + "node": ">=18" 2950 + }, 2951 + "optionalDependencies": { 2952 + "@esbuild/aix-ppc64": "0.25.2", 2953 + "@esbuild/android-arm": "0.25.2", 2954 + "@esbuild/android-arm64": "0.25.2", 2955 + "@esbuild/android-x64": "0.25.2", 2956 + "@esbuild/darwin-arm64": "0.25.2", 2957 + "@esbuild/darwin-x64": "0.25.2", 2958 + "@esbuild/freebsd-arm64": "0.25.2", 2959 + "@esbuild/freebsd-x64": "0.25.2", 2960 + "@esbuild/linux-arm": "0.25.2", 2961 + "@esbuild/linux-arm64": "0.25.2", 2962 + "@esbuild/linux-ia32": "0.25.2", 2963 + "@esbuild/linux-loong64": "0.25.2", 2964 + "@esbuild/linux-mips64el": "0.25.2", 2965 + "@esbuild/linux-ppc64": "0.25.2", 2966 + "@esbuild/linux-riscv64": "0.25.2", 2967 + "@esbuild/linux-s390x": "0.25.2", 2968 + "@esbuild/linux-x64": "0.25.2", 2969 + "@esbuild/netbsd-arm64": "0.25.2", 2970 + "@esbuild/netbsd-x64": "0.25.2", 2971 + "@esbuild/openbsd-arm64": "0.25.2", 2972 + "@esbuild/openbsd-x64": "0.25.2", 2973 + "@esbuild/sunos-x64": "0.25.2", 2974 + "@esbuild/win32-arm64": "0.25.2", 2975 + "@esbuild/win32-ia32": "0.25.2", 2976 + "@esbuild/win32-x64": "0.25.2" 2977 + } 2978 + }, 2979 + "node_modules/ws": { 2980 + "version": "8.18.0", 2981 + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", 2982 + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", 2983 + "dev": true, 2984 + "license": "MIT", 2985 + "engines": { 2986 + "node": ">=10.0.0" 2987 + }, 2988 + "peerDependencies": { 2989 + "bufferutil": "^4.0.1", 2990 + "utf-8-validate": ">=5.0.2" 2991 + }, 2992 + "peerDependenciesMeta": { 2993 + "bufferutil": { 2994 + "optional": true 2995 + }, 2996 + "utf-8-validate": { 2997 + "optional": true 2998 + } 2999 + } 3000 + }, 3001 + "node_modules/youch": { 3002 + "version": "3.3.4", 3003 + "resolved": "https://registry.npmjs.org/youch/-/youch-3.3.4.tgz", 3004 + "integrity": "sha512-UeVBXie8cA35DS6+nBkls68xaBBXCye0CNznrhszZjTbRVnJKQuNsyLKBTTL4ln1o1rh2PKtv35twV7irj5SEg==", 3005 + "dev": true, 3006 + "license": "MIT", 3007 + "dependencies": { 3008 + "cookie": "^0.7.1", 3009 + "mustache": "^4.2.0", 3010 + "stacktracey": "^2.1.8" 3011 + } 3012 + }, 3013 + "node_modules/zod": { 3014 + "version": "3.24.3", 3015 + "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.3.tgz", 3016 + "integrity": "sha512-HhY1oqzWCQWuUqvBFnsyrtZRhyPeR7SUGv+C4+MsisMuVfSPx8HpwWqH8tRahSlt6M3PiFAcoeFhZAqIXTxoSg==", 3017 + "dev": true, 3018 + "license": "MIT", 3019 + "funding": { 3020 + "url": "https://github.com/sponsors/colinhacks" 3021 + } 3022 + } 3023 + } 3024 + }
+16
camo/package.json
···
··· 1 + { 2 + "name": "camo", 3 + "version": "0.0.0", 4 + "private": true, 5 + "scripts": { 6 + "deploy": "wrangler deploy", 7 + "dev": "wrangler dev", 8 + "start": "wrangler dev", 9 + "test": "vitest" 10 + }, 11 + "devDependencies": { 12 + "@cloudflare/vitest-pool-workers": "^0.8.19", 13 + "vitest": "~3.0.7", 14 + "wrangler": "^4.14.1" 15 + } 16 + }
+17
camo/readme.md
···
··· 1 + # camo 2 + 3 + Camo is Tangled's "camouflage" service much like that of [GitHub's](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/about-anonymized-urls). 4 + 5 + Camo uses a shared secret `CAMO_SHARED_SECRET` to verify HMAC signatures. URLs are of the form: 6 + 7 + ``` 8 + https://camo.tangled.sh/<signature>/<hex-encoded-origin-url> 9 + ``` 10 + 11 + It's pretty barebones for the moment and doesn't support a whole lot of what the 12 + big G's does. Ours is a Cloudflare Worker, deployed using `wrangler` like so: 13 + 14 + ``` 15 + npx wrangler deploy 16 + npx wrangler secrets put CAMO_SHARED_SECRET 17 + ```
+101
camo/src/index.js
···
··· 1 + export default { 2 + async fetch(request, env) { 3 + const url = new URL(request.url); 4 + 5 + if (url.pathname === "/" || url.pathname === "") { 6 + return new Response( 7 + "This is Tangled's Camo service. It proxies images served from knots via Cloudflare.", 8 + ); 9 + } 10 + 11 + const cache = caches.default; 12 + 13 + const pathParts = url.pathname.slice(1).split("/"); 14 + if (pathParts.length < 2) { 15 + return new Response("Bad URL", { status: 400 }); 16 + } 17 + 18 + const [signatureHex, ...hexUrlParts] = pathParts; 19 + const hexUrl = hexUrlParts.join(""); 20 + const urlBytes = Uint8Array.from( 21 + hexUrl.match(/.{2}/g).map((b) => parseInt(b, 16)), 22 + ); 23 + const targetUrl = new TextDecoder().decode(urlBytes); 24 + 25 + // check if we have an entry in the cache with the target url 26 + let cacheKey = new Request(targetUrl); 27 + let response = await cache.match(cacheKey); 28 + if (response) { 29 + return response; 30 + } 31 + 32 + // else compute the signature 33 + const key = await crypto.subtle.importKey( 34 + "raw", 35 + new TextEncoder().encode(env.CAMO_SHARED_SECRET), 36 + { name: "HMAC", hash: "SHA-256" }, 37 + false, 38 + ["sign", "verify"], 39 + ); 40 + 41 + const computedSigBuffer = await crypto.subtle.sign("HMAC", key, urlBytes); 42 + const computedSig = Array.from(new Uint8Array(computedSigBuffer)) 43 + .map((b) => b.toString(16).padStart(2, "0")) 44 + .join(""); 45 + 46 + console.log({ 47 + level: "debug", 48 + message: "camo target: " + targetUrl, 49 + computedSignature: computedSig, 50 + providedSignature: signatureHex, 51 + targetUrl: targetUrl, 52 + }); 53 + 54 + const sigBytes = Uint8Array.from( 55 + signatureHex.match(/.{2}/g).map((b) => parseInt(b, 16)), 56 + ); 57 + const valid = await crypto.subtle.verify("HMAC", key, sigBytes, urlBytes); 58 + 59 + if (!valid) { 60 + return new Response("Invalid signature", { status: 403 }); 61 + } 62 + 63 + let parsedUrl; 64 + try { 65 + parsedUrl = new URL(targetUrl); 66 + if (!["https:", "http:"].includes(parsedUrl.protocol)) { 67 + return new Response("Only HTTP(S) allowed", { status: 400 }); 68 + } 69 + } catch { 70 + return new Response("Malformed URL", { status: 400 }); 71 + } 72 + 73 + // fetch from the parsed URL 74 + const res = await fetch(parsedUrl.toString(), { 75 + headers: { "User-Agent": "Tangled Camo v0.1.0" }, 76 + }); 77 + 78 + const allowedMimeTypes = require("./mimetypes.json"); 79 + 80 + const contentType = 81 + res.headers.get("Content-Type") || "application/octet-stream"; 82 + 83 + if (!allowedMimeTypes.includes(contentType.split(";")[0].trim())) { 84 + return new Response("Unsupported media type", { status: 415 }); 85 + } 86 + 87 + const headers = new Headers(); 88 + headers.set("Content-Type", contentType); 89 + headers.set("Cache-Control", "public, max-age=86400, immutable"); 90 + 91 + // serve and cache it with cf 92 + response = new Response(await res.arrayBuffer(), { 93 + status: res.status, 94 + headers, 95 + }); 96 + 97 + await cache.put(cacheKey, response.clone()); 98 + 99 + return response; 100 + }, 101 + };
+45
camo/src/mimetypes.json
···
··· 1 + [ 2 + "image/bmp", 3 + "image/cgm", 4 + "image/g3fax", 5 + "image/gif", 6 + "image/ief", 7 + "image/jp2", 8 + "image/jpeg", 9 + "image/jpg", 10 + "image/pict", 11 + "image/png", 12 + "image/prs.btif", 13 + "image/svg+xml", 14 + "image/tiff", 15 + "image/vnd.adobe.photoshop", 16 + "image/vnd.djvu", 17 + "image/vnd.dwg", 18 + "image/vnd.dxf", 19 + "image/vnd.fastbidsheet", 20 + "image/vnd.fpx", 21 + "image/vnd.fst", 22 + "image/vnd.fujixerox.edmics-mmr", 23 + "image/vnd.fujixerox.edmics-rlc", 24 + "image/vnd.microsoft.icon", 25 + "image/vnd.ms-modi", 26 + "image/vnd.net-fpx", 27 + "image/vnd.wap.wbmp", 28 + "image/vnd.xiff", 29 + "image/webp", 30 + "image/x-cmu-raster", 31 + "image/x-cmx", 32 + "image/x-icon", 33 + "image/x-macpaint", 34 + "image/x-pcx", 35 + "image/x-pict", 36 + "image/x-portable-anymap", 37 + "image/x-portable-bitmap", 38 + "image/x-portable-graymap", 39 + "image/x-portable-pixmap", 40 + "image/x-quicktime", 41 + "image/x-rgb", 42 + "image/x-xbitmap", 43 + "image/x-xpixmap", 44 + "image/x-xwindowdump" 45 + ]
+20
camo/wrangler.jsonc
···
··· 1 + /** 2 + * For more details on how to configure Wrangler, refer to: 3 + * https://developers.cloudflare.com/workers/wrangler/configuration/ 4 + */ 5 + { 6 + "$schema": "node_modules/wrangler/config-schema.json", 7 + "name": "camo", 8 + "main": "src/index.js", 9 + "compatibility_date": "2025-04-30", 10 + "observability": { 11 + "enabled": true, 12 + }, 13 + 14 + "routes": [ 15 + { 16 + "pattern": "camo.tangled.sh", 17 + "custom_domain": true, 18 + }, 19 + ], 20 + }
+2 -2
cmd/appview/main.go
··· 26 log.Fatal(err) 27 } 28 29 - log.Println("starting server on", c.ListenAddr) 30 - log.Println(http.ListenAndServe(c.ListenAddr, state.Router())) 31 }
··· 26 log.Fatal(err) 27 } 28 29 + log.Println("starting server on", c.Core.ListenAddr) 30 + log.Println(http.ListenAndServe(c.Core.ListenAddr, state.Router())) 31 }
+38
cmd/combinediff/main.go
···
··· 1 + package main 2 + 3 + import ( 4 + "fmt" 5 + "os" 6 + 7 + "github.com/bluekeyes/go-gitdiff/gitdiff" 8 + "tangled.sh/tangled.sh/core/patchutil" 9 + ) 10 + 11 + func main() { 12 + if len(os.Args) != 3 { 13 + fmt.Println("Usage: combinediff <patch1> <patch2>") 14 + os.Exit(1) 15 + } 16 + 17 + patch1, err := os.Open(os.Args[1]) 18 + if err != nil { 19 + fmt.Println(err) 20 + } 21 + patch2, err := os.Open(os.Args[2]) 22 + if err != nil { 23 + fmt.Println(err) 24 + } 25 + 26 + files1, _, err := gitdiff.Parse(patch1) 27 + if err != nil { 28 + fmt.Println(err) 29 + } 30 + 31 + files2, _, err := gitdiff.Parse(patch2) 32 + if err != nil { 33 + fmt.Println(err) 34 + } 35 + 36 + combined := patchutil.CombineDiff(files1, files2) 37 + fmt.Println(combined) 38 + }
+15 -12
cmd/gen.go
··· 2 3 import ( 4 cbg "github.com/whyrusleeping/cbor-gen" 5 - shtangled "tangled.sh/tangled.sh/core/api/tangled" 6 ) 7 8 func main() { ··· 14 if err := genCfg.WriteMapEncodersToFile( 15 "api/tangled/cbor_gen.go", 16 "tangled", 17 - shtangled.FeedStar{}, 18 - shtangled.GraphFollow{}, 19 - shtangled.KnotMember{}, 20 - shtangled.PublicKey{}, 21 - shtangled.RepoIssueComment{}, 22 - shtangled.RepoIssueState{}, 23 - shtangled.RepoIssue{}, 24 - shtangled.Repo{}, 25 - shtangled.RepoPull{}, 26 - shtangled.RepoPullStatus{}, 27 - shtangled.RepoPullComment{}, 28 ); err != nil { 29 panic(err) 30 }
··· 2 3 import ( 4 cbg "github.com/whyrusleeping/cbor-gen" 5 + "tangled.sh/tangled.sh/core/api/tangled" 6 ) 7 8 func main() { ··· 14 if err := genCfg.WriteMapEncodersToFile( 15 "api/tangled/cbor_gen.go", 16 "tangled", 17 + tangled.FeedStar{}, 18 + tangled.GraphFollow{}, 19 + tangled.KnotMember{}, 20 + tangled.PublicKey{}, 21 + tangled.RepoIssueComment{}, 22 + tangled.RepoIssueState{}, 23 + tangled.RepoIssue{}, 24 + tangled.Repo{}, 25 + tangled.RepoPull{}, 26 + tangled.RepoPull_Source{}, 27 + tangled.RepoPullStatus{}, 28 + tangled.RepoPullComment{}, 29 + tangled.RepoArtifact{}, 30 + tangled.ActorProfile{}, 31 ); err != nil { 32 panic(err) 33 }
+39
cmd/genjwks/main.go
···
··· 1 + // adapted from https://github.com/haileyok/atproto-oauth-golang 2 + 3 + package main 4 + 5 + import ( 6 + "crypto/ecdsa" 7 + "crypto/elliptic" 8 + "crypto/rand" 9 + "encoding/json" 10 + "fmt" 11 + "time" 12 + 13 + "github.com/lestrrat-go/jwx/v2/jwk" 14 + ) 15 + 16 + func main() { 17 + privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) 18 + if err != nil { 19 + panic(err) 20 + } 21 + 22 + key, err := jwk.FromRaw(privKey) 23 + if err != nil { 24 + panic(err) 25 + } 26 + 27 + kid := fmt.Sprintf("%d", time.Now().Unix()) 28 + 29 + if err := key.Set(jwk.KeyIDKey, kid); err != nil { 30 + panic(err) 31 + } 32 + 33 + b, err := json.Marshal(key) 34 + if err != nil { 35 + panic(err) 36 + } 37 + 38 + fmt.Println(string(b)) 39 + }
+38
cmd/interdiff/main.go
···
··· 1 + package main 2 + 3 + import ( 4 + "fmt" 5 + "os" 6 + 7 + "github.com/bluekeyes/go-gitdiff/gitdiff" 8 + "tangled.sh/tangled.sh/core/patchutil" 9 + ) 10 + 11 + func main() { 12 + if len(os.Args) != 3 { 13 + fmt.Println("Usage: interdiff <patch1> <patch2>") 14 + os.Exit(1) 15 + } 16 + 17 + patch1, err := os.Open(os.Args[1]) 18 + if err != nil { 19 + fmt.Println(err) 20 + } 21 + patch2, err := os.Open(os.Args[2]) 22 + if err != nil { 23 + fmt.Println(err) 24 + } 25 + 26 + files1, _, err := gitdiff.Parse(patch1) 27 + if err != nil { 28 + fmt.Println(err) 29 + } 30 + 31 + files2, _, err := gitdiff.Parse(patch2) 32 + if err != nil { 33 + fmt.Println(err) 34 + } 35 + 36 + interDiffResult := patchutil.Interdiff(files1, files2) 37 + fmt.Println(interDiffResult) 38 + }
-150
cmd/jstest/main.go
··· 1 - package main 2 - 3 - import ( 4 - "context" 5 - "flag" 6 - "log/slog" 7 - "os" 8 - "os/signal" 9 - "strings" 10 - "syscall" 11 - "time" 12 - 13 - "github.com/bluesky-social/jetstream/pkg/client" 14 - "github.com/bluesky-social/jetstream/pkg/models" 15 - "tangled.sh/tangled.sh/core/jetstream" 16 - ) 17 - 18 - // Simple in-memory implementation of DB interface 19 - type MemoryDB struct { 20 - lastTimeUs int64 21 - } 22 - 23 - func (m *MemoryDB) GetLastTimeUs() (int64, error) { 24 - if m.lastTimeUs == 0 { 25 - return time.Now().UnixMicro(), nil 26 - } 27 - return m.lastTimeUs, nil 28 - } 29 - 30 - func (m *MemoryDB) SaveLastTimeUs(ts int64) error { 31 - m.lastTimeUs = ts 32 - return nil 33 - } 34 - 35 - func (m *MemoryDB) UpdateLastTimeUs(ts int64) error { 36 - m.lastTimeUs = ts 37 - return nil 38 - } 39 - 40 - func main() { 41 - // Setup logger 42 - logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ 43 - Level: slog.LevelInfo, 44 - })) 45 - 46 - // Create in-memory DB 47 - db := &MemoryDB{} 48 - 49 - // Get query URL from flag 50 - var queryURL string 51 - flag.StringVar(&queryURL, "query-url", "", "Jetstream query URL containing DIDs") 52 - flag.Parse() 53 - 54 - if queryURL == "" { 55 - logger.Error("No query URL provided, use --query-url flag") 56 - os.Exit(1) 57 - } 58 - 59 - // Extract wantedDids parameters 60 - didParams := strings.Split(queryURL, "&wantedDids=") 61 - dids := make([]string, 0, len(didParams)-1) 62 - for i, param := range didParams { 63 - if i == 0 { 64 - // Skip the first part (the base URL with cursor) 65 - continue 66 - } 67 - dids = append(dids, param) 68 - } 69 - 70 - // Extract collections 71 - collections := []string{"sh.tangled.publicKey", "sh.tangled.knot.member"} 72 - 73 - // Create client configuration 74 - cfg := client.DefaultClientConfig() 75 - cfg.WebsocketURL = "wss://jetstream2.us-west.bsky.network/subscribe" 76 - cfg.WantedCollections = collections 77 - 78 - // Create jetstream client 79 - jsClient, err := jetstream.NewJetstreamClient( 80 - cfg.WebsocketURL, 81 - "tangled-jetstream", 82 - collections, 83 - cfg, 84 - logger, 85 - db, 86 - false, 87 - ) 88 - if err != nil { 89 - logger.Error("Failed to create jetstream client", "error", err) 90 - os.Exit(1) 91 - } 92 - 93 - // Update DIDs 94 - jsClient.UpdateDids(dids) 95 - 96 - // Create a context that will be canceled on SIGINT or SIGTERM 97 - ctx, cancel := context.WithCancel(context.Background()) 98 - defer cancel() 99 - 100 - // Setup signal handling with a buffered channel 101 - sigCh := make(chan os.Signal, 1) 102 - signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) 103 - 104 - // Process function for events 105 - processFunc := func(ctx context.Context, event *models.Event) error { 106 - // Log the event details 107 - logger.Info("Received event", 108 - "collection", event.Commit.Collection, 109 - "did", event.Did, 110 - "rkey", event.Commit.RKey, 111 - "action", event.Kind, 112 - "time_us", event.TimeUS, 113 - ) 114 - 115 - // Save the last time_us 116 - if err := db.UpdateLastTimeUs(event.TimeUS); err != nil { 117 - logger.Error("Failed to update last time_us", "error", err) 118 - } 119 - 120 - return nil 121 - } 122 - 123 - // Start jetstream 124 - if err := jsClient.StartJetstream(ctx, processFunc); err != nil { 125 - logger.Error("Failed to start jetstream", "error", err) 126 - os.Exit(1) 127 - } 128 - 129 - // Wait for signal instead of context.Done() 130 - sig := <-sigCh 131 - logger.Info("Received signal, shutting down", "signal", sig) 132 - cancel() // Cancel context after receiving signal 133 - 134 - // Shutdown gracefully with a timeout 135 - shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second) 136 - defer shutdownCancel() 137 - 138 - done := make(chan struct{}) 139 - go func() { 140 - jsClient.Shutdown() 141 - close(done) 142 - }() 143 - 144 - select { 145 - case <-done: 146 - logger.Info("Jetstream client shut down gracefully") 147 - case <-shutdownCtx.Done(): 148 - logger.Warn("Shutdown timed out, forcing exit") 149 - } 150 - }
···
+1 -1
cmd/knotserver/main.go
··· 49 jc, err := jetstream.NewJetstreamClient(c.Server.JetstreamEndpoint, "knotserver", []string{ 50 tangled.PublicKeyNSID, 51 tangled.KnotMemberNSID, 52 - }, nil, l, db, false) 53 if err != nil { 54 l.Error("failed to setup jetstream", "error", err) 55 }
··· 49 jc, err := jetstream.NewJetstreamClient(c.Server.JetstreamEndpoint, "knotserver", []string{ 50 tangled.PublicKeyNSID, 51 tangled.KnotMemberNSID, 52 + }, nil, l, db, true) 53 if err != nil { 54 l.Error("failed to setup jetstream", "error", err) 55 }
+50
docker/Dockerfile
···
··· 1 + FROM docker.io/golang:1.24-alpine3.21 AS build 2 + 3 + ENV CGO_ENABLED=1 4 + 5 + RUN apk add --no-cache gcc musl-dev 6 + 7 + WORKDIR /usr/src/app 8 + 9 + COPY go.mod go.sum ./ 10 + RUN go mod download 11 + 12 + COPY . . 13 + RUN go build -v \ 14 + -o /usr/local/bin/knotserver \ 15 + -ldflags='-s -w -extldflags "-static"' \ 16 + ./cmd/knotserver && \ 17 + go build -v \ 18 + -o /usr/local/bin/keyfetch \ 19 + ./cmd/keyfetch && \ 20 + go build -v \ 21 + -o /usr/local/bin/repoguard \ 22 + ./cmd/repoguard 23 + 24 + FROM docker.io/alpine:3.21 25 + 26 + LABEL org.opencontainers.image.title=Tangled 27 + LABEL org.opencontainers.image.description="Tangled is a decentralized and open code collaboration platform, built on atproto." 28 + LABEL org.opencontainers.image.vendor=Tangled.sh 29 + LABEL org.opencontainers.image.licenses=MIT 30 + LABEL org.opencontainers.image.url=https://tangled.sh 31 + LABEL org.opencontainers.image.source=https://tangled.sh/@tangled.sh/core 32 + 33 + RUN apk add --no-cache shadow s6-overlay execline openssh git && \ 34 + adduser --disabled-password git && \ 35 + # We need to set password anyway since otherwise ssh won't work 36 + head -c 32 /dev/random | base64 | tr -dc 'a-zA-Z0-9' | passwd git --stdin && \ 37 + mkdir /app && mkdir /home/git/repositories 38 + 39 + COPY --from=build /usr/local/bin/knotserver /usr/local/bin 40 + COPY --from=build /usr/local/bin/keyfetch /usr/local/libexec/tangled-keyfetch 41 + COPY --from=build /usr/local/bin/repoguard /home/git/repoguard 42 + COPY docker/rootfs/ . 43 + 44 + RUN chown root:root /usr/local/libexec/tangled-keyfetch && \ 45 + chmod 755 /usr/local/libexec/tangled-keyfetch 46 + 47 + EXPOSE 22 48 + EXPOSE 5555 49 + 50 + ENTRYPOINT ["/bin/sh", "-c", "chown git:git /home/git/repoguard && chown git:git /app && chown git:git /home/git/repositories && /init"]
+33
docker/docker-compose.yml
···
··· 1 + services: 2 + knot: 3 + build: 4 + context: .. 5 + dockerfile: docker/Dockerfile 6 + environment: 7 + KNOT_SERVER_HOSTNAME: ${KNOT_SERVER_HOSTNAME} 8 + KNOT_SERVER_SECRET: ${KNOT_SERVER_SECRET} 9 + KNOT_SERVER_DB_PATH: "/app/knotserver.db" 10 + KNOT_REPO_SCAN_PATH: "/home/git/repositories" 11 + volumes: 12 + - "./keys:/etc/ssh/keys" 13 + - "./repositories:/home/git/repositories" 14 + - "./server:/app" 15 + ports: 16 + - "2222:22" 17 + frontend: 18 + image: caddy:2-alpine 19 + command: > 20 + caddy 21 + reverse-proxy 22 + --from ${KNOT_SERVER_HOSTNAME} 23 + --to knot:5555 24 + depends_on: 25 + - knot 26 + ports: 27 + - "443:443" 28 + - "443:443/udp" 29 + volumes: 30 + - caddy_data:/data 31 + restart: always 32 + volumes: 33 + caddy_data:
+1
docker/rootfs/etc/s6-overlay/s6-rc.d/create-sshd-host-keys/type
···
··· 1 + oneshot
+1
docker/rootfs/etc/s6-overlay/s6-rc.d/create-sshd-host-keys/up
···
··· 1 + /etc/s6-overlay/scripts/create-sshd-host-keys
docker/rootfs/etc/s6-overlay/s6-rc.d/knotserver/dependencies.d/base

This is a binary file and will not be displayed.

+3
docker/rootfs/etc/s6-overlay/s6-rc.d/knotserver/run
···
··· 1 + #!/command/with-contenv ash 2 + 3 + exec s6-setuidgid git /usr/local/bin/knotserver
+1
docker/rootfs/etc/s6-overlay/s6-rc.d/knotserver/type
···
··· 1 + longrun
docker/rootfs/etc/s6-overlay/s6-rc.d/sshd/dependencies.d/base

This is a binary file and will not be displayed.

docker/rootfs/etc/s6-overlay/s6-rc.d/sshd/dependencies.d/create-sshd-host-keys

This is a binary file and will not be displayed.

+3
docker/rootfs/etc/s6-overlay/s6-rc.d/sshd/run
···
··· 1 + #!/usr/bin/execlineb -P 2 + 3 + /usr/sbin/sshd -e -D
+1
docker/rootfs/etc/s6-overlay/s6-rc.d/sshd/type
···
··· 1 + longrun
docker/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/knotserver

This is a binary file and will not be displayed.

docker/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/sshd

This is a binary file and will not be displayed.

+21
docker/rootfs/etc/s6-overlay/scripts/create-sshd-host-keys
···
··· 1 + #!/usr/bin/execlineb -P 2 + 3 + foreground { 4 + if -n { test -d /etc/ssh/keys } 5 + mkdir /etc/ssh/keys 6 + } 7 + 8 + foreground { 9 + if -n { test -f /etc/ssh/keys/ssh_host_rsa_key } 10 + ssh-keygen -t rsa -f /etc/ssh/keys/ssh_host_rsa_key -q -N "" 11 + } 12 + 13 + foreground { 14 + if -n { test -f /etc/ssh/keys/ssh_host_ecdsa_key } 15 + ssh-keygen -t rsa -f /etc/ssh/keys/ssh_host_ecdsa_key -q -N "" 16 + } 17 + 18 + foreground { 19 + if -n { test -f /etc/ssh/keys/ssh_host_ed25519_key } 20 + ssh-keygen -t rsa -f /etc/ssh/keys/ssh_host_ed25519_key -q -N "" 21 + }
+9
docker/rootfs/etc/ssh/sshd_config.d/tangled_sshd.conf
···
··· 1 + HostKey /etc/ssh/keys/ssh_host_rsa_key 2 + HostKey /etc/ssh/keys/ssh_host_ecdsa_key 3 + HostKey /etc/ssh/keys/ssh_host_ed25519_key 4 + 5 + PasswordAuthentication no 6 + 7 + Match User git 8 + AuthorizedKeysCommand /usr/local/libexec/tangled-keyfetch -git-dir /home/git/repositories 9 + AuthorizedKeysCommandUser nobody
+76
docs/contributing.md
···
··· 1 + # tangled contributing guide 2 + 3 + ## commit guidelines 4 + 5 + We follow a commit style similar to the Go project. Please keep commits: 6 + 7 + * **atomic**: each commit should represent one logical change 8 + * **descriptive**: the commit message should clearly describe what the 9 + change does and why it's needed 10 + 11 + ### message format 12 + 13 + ``` 14 + <service/top-level directory>: <affected package/directory>: <short summary of change> 15 + 16 + 17 + Optional longer description can go here, if necessary. Explain what the 18 + change does and why, especially if not obvious. Reference relevant 19 + issues or PRs when applicable. These can be links for now since we don't 20 + auto-link issues/PRs yet. 21 + ``` 22 + 23 + Here are some examples: 24 + 25 + ``` 26 + appview: state: fix token expiry check in middleware 27 + 28 + The previous check did not account for clock drift, leading to premature 29 + token invalidation. 30 + ``` 31 + 32 + ``` 33 + knotserver: git/service: improve error checking in upload-pack 34 + ``` 35 + 36 + ### general notes 37 + 38 + - PRs get merged "as-is" (fast-forward) -- like applying a patch-series 39 + using `git am`. At present, there is no squashing -- so please author 40 + your commits as they would appear on `master`, following the above 41 + guidelines. 42 + - Use the imperative mood in the summary line (e.g., "fix bug" not 43 + "fixed bug" or "fixes bug"). 44 + - Try to keep the summary line under 72 characters, but we aren't too 45 + fussed about this. 46 + - Don't include unrelated changes in the same commit. 47 + - Avoid noisy commit messages like "wip" or "final fix"โ€”rewrite history 48 + before submitting if necessary. 49 + 50 + ## proposals for bigger changes 51 + 52 + Small fixes like typos, minor bugs, or trivial refactors can be 53 + submitted directly as PRs. 54 + 55 + For larger changesโ€”especially those introducing new features, 56 + significant refactoring, or altering system behaviorโ€”please open a 57 + proposal first. This helps us evaluate the scope, design, and potential 58 + impact before implementation. 59 + 60 + ### proposal format 61 + 62 + Create a new issue titled: 63 + 64 + ``` 65 + proposal: <affected scope>: <summary of change> 66 + ``` 67 + 68 + In the description, explain: 69 + 70 + - What the change is 71 + - Why it's needed 72 + - How you plan to implement it (roughly) 73 + - Any open questions or tradeoffs 74 + 75 + We'll use the issue thread to discuss and refine the idea before moving 76 + forward.
+72
docs/hacking.md
···
··· 1 + # hacking on tangled 2 + 3 + We highly recommend [installing 4 + nix](https://nixos.org/download/) (the package manager) 5 + before working on the codebase. The nix flake provides a lot 6 + of helpers to get started and most importantly, builds and 7 + dev shells are entirely deterministic. 8 + 9 + To set up your dev environment: 10 + 11 + ```bash 12 + nix develop 13 + ``` 14 + 15 + Non-nix users can look at the `devShell` attribute in the 16 + `flake.nix` file to determine necessary dependencies. 17 + 18 + ## running the appview 19 + 20 + The nix flake also exposes a few `app` attributes (run `nix 21 + flake show` to see a full list of what the flake provides), 22 + one of the apps runs the appview with the `air` 23 + live-reloader: 24 + 25 + ```bash 26 + TANGLED_DEV=true nix run .#watch-appview 27 + 28 + # TANGLED_DB_PATH might be of interest to point to 29 + # different sqlite DBs 30 + 31 + # in a separate shell, you can live-reload tailwind 32 + nix run .#watch-tailwind 33 + ``` 34 + 35 + ## running a knotserver 36 + 37 + An end-to-end knotserver setup requires setting up a machine 38 + with `sshd`, `repoguard`, `keyfetch`, a git user, which is 39 + quite cumbersome and so the nix flake provides a 40 + `nixosConfiguration` to do so. 41 + 42 + To begin, head to `http://localhost:3000` in the browser and 43 + generate a knotserver secret. Replace the existing secret in 44 + `flake.nix` with the newly generated secret. 45 + 46 + You can now start a lightweight NixOS VM using 47 + `nixos-shell` like so: 48 + 49 + ```bash 50 + QEMU_NET_OPTS="hostfwd=tcp::6000-:6000,hostfwd=tcp::2222-:22" nixos-shell --flake .#knotVM 51 + 52 + # hit Ctrl-a + c + q to exit the VM 53 + ``` 54 + 55 + This starts a knotserver on port 6000 with `ssh` exposed on 56 + port 2222. You can push repositories to this VM with this 57 + ssh config block on your main machine: 58 + 59 + ```bash 60 + Host nixos-shell 61 + Hostname localhost 62 + Port 2222 63 + User git 64 + IdentityFile ~/.ssh/my_tangled_key 65 + ``` 66 + 67 + Set up a remote called `local-dev` on a git repo: 68 + 69 + ```bash 70 + git remote add local-dev git@nixos-shell:user/repo 71 + git push local-dev main 72 + ```
+190
docs/knot-hosting.md
···
··· 1 + # knot self-hosting guide 2 + 3 + So you want to run your own knot server? Great! Here are a few prerequisites: 4 + 5 + 1. A server of some kind (a VPS, a Raspberry Pi, etc.). Preferably running a Linux of some kind. 6 + 2. A (sub)domain name. People generally use `knot.example.com`. 7 + 3. A valid SSL certificate for your domain. 8 + 9 + There's a couple of ways to get started: 10 + * NixOS: refer to [flake.nix](https://tangled.sh/@tangled.sh/core/blob/master/flake.nix) 11 + * Docker: Documented below. 12 + * Manual: Documented below. 13 + 14 + ## docker setup 15 + 16 + Clone this repository: 17 + 18 + ``` 19 + git clone https://tangled.sh/@tangled.sh/core 20 + ``` 21 + 22 + Modify the `docker/docker-compose.yml`, specifically the 23 + `KNOT_SERVER_SECRET` and `KNOT_SERVER_HOSTNAME` env vars. Then run: 24 + 25 + ``` 26 + docker compose -f docker/docker-compose.yml up 27 + ``` 28 + 29 + ## manual setup 30 + 31 + First, clone this repository: 32 + 33 + ``` 34 + git clone https://tangled.sh/@tangled.sh/core 35 + ``` 36 + 37 + Then, build our binaries (you need to have Go installed): 38 + * `knotserver`: the main server program 39 + * `keyfetch`: utility to fetch ssh pubkeys 40 + * `repoguard`: enforces repository access control 41 + 42 + ``` 43 + cd core 44 + export CGO_ENABLED=1 45 + go build -o knot ./cmd/knotserver 46 + go build -o keyfetch ./cmd/keyfetch 47 + go build -o repoguard ./cmd/repoguard 48 + ``` 49 + 50 + Next, move the `keyfetch` binary to a location owned by `root` -- 51 + `/usr/local/libexec/tangled-keyfetch` is a good choice: 52 + 53 + ``` 54 + sudo mv keyfetch /usr/local/libexec/tangled-keyfetch 55 + sudo chown root:root /usr/local/libexec/tangled-keyfetch 56 + sudo chmod 755 /usr/local/libexec/tangled-keyfetch 57 + ``` 58 + 59 + This is necessary because SSH `AuthorizedKeysCommand` requires [really specific 60 + permissions](https://stackoverflow.com/a/27638306). Let's set that up: 61 + 62 + ``` 63 + sudo tee /etc/ssh/sshd_config.d/authorized_keys_command.conf <<EOF 64 + Match User git 65 + AuthorizedKeysCommand /usr/local/libexec/tangled-keyfetch 66 + AuthorizedKeysCommandUser nobody 67 + EOF 68 + ``` 69 + 70 + Next, create the `git` user: 71 + 72 + ``` 73 + sudo adduser git 74 + ``` 75 + 76 + Copy the `repoguard` binary to the `git` user's home directory: 77 + 78 + ``` 79 + sudo cp repoguard /home/git 80 + sudo chown git:git /home/git/repoguard 81 + ``` 82 + 83 + Now, let's set up the server. Copy the `knot` binary to 84 + `/usr/local/bin/knotserver`. Then, create `/home/git/.knot.env` with the 85 + following, updating the values as necessary. The `KNOT_SERVER_SECRET` can be 86 + obtaind from the [/knots](/knots) page on Tangled. 87 + 88 + ``` 89 + KNOT_REPO_SCAN_PATH=/home/git 90 + KNOT_SERVER_HOSTNAME=knot.example.com 91 + APPVIEW_ENDPOINT=https://tangled.sh 92 + KNOT_SERVER_SECRET=secret 93 + KNOT_SERVER_INTERNAL_LISTEN_ADDR=127.0.0.1:5444 94 + KNOT_SERVER_LISTEN_ADDR=127.0.0.1:5555 95 + ``` 96 + 97 + If you run a Linux distribution that uses systemd, you can use the provided 98 + service file to run the server. Copy 99 + [`knotserver.service`](https://tangled.sh/did:plc:wshs7t2adsemcrrd4snkeqli/core/blob/master/systemd/knotserver.service) 100 + to `/etc/systemd/system/`. Then, run: 101 + 102 + ``` 103 + systemctl enable knotserver 104 + systemctl start knotserver 105 + ``` 106 + 107 + You should now have a running knot server! You can finalize your registration by hitting the 108 + `initialize` button on the [/knots](/knots) page. 109 + 110 + ### custom paths 111 + 112 + (This section applies to manual setup only. Docker users should edit the mounts 113 + in `docker-compose.yml` instead.) 114 + 115 + Right now, the database and repositories of your knot lives in `/home/git`. You 116 + can move these paths if you'd like to store them in another folder. Be careful 117 + when adjusting these paths: 118 + 119 + * Stop your knot when moving data (e.g. `systemctl stop knotserver`) to prevent 120 + any possible side effects. Remember to restart it once you're done. 121 + * Make backups before moving in case something goes wrong. 122 + * Make sure the `git` user can read and write from the new paths. 123 + 124 + #### database 125 + 126 + As an example, let's say the current database is at `/home/git/knotserver.db`, 127 + and we want to move it to `/home/git/database/knotserver.db`. 128 + 129 + Copy the current database to the new location. Make sure to copy the `.db-shm` 130 + and `.db-wal` files if they exist. 131 + 132 + ``` 133 + mkdir /home/git/database 134 + cp /home/git/knotserver.db* /home/git/database 135 + ``` 136 + 137 + In the environment (e.g. `/home/git/.knot.env`), set `KNOT_SERVER_DB_PATH` to 138 + the new file path (_not_ the directory): 139 + 140 + ``` 141 + KNOT_SERVER_DB_PATH=/home/git/database/knotserver.db 142 + ``` 143 + 144 + #### repositories 145 + 146 + As an example, let's say the repositories are currently in `/home/git`, and we 147 + want to move them into `/home/git/repositories`. 148 + 149 + Create the new folder, then move the existing repositories (if there are any): 150 + 151 + ``` 152 + mkdir /home/git/repositories 153 + # move all DIDs into the new folder; these will vary for you! 154 + mv /home/git/did:plc:wshs7t2adsemcrrd4snkeqli /home/git/repositories 155 + ``` 156 + 157 + In the environment (e.g. `/home/git/.knot.env`), update `KNOT_REPO_SCAN_PATH` 158 + to the new directory: 159 + 160 + ``` 161 + KNOT_REPO_SCAN_PATH=/home/git/repositories 162 + ``` 163 + 164 + In your SSH config (e.g. `/etc/ssh/sshd_config.d/authorized_keys_command.conf`), 165 + update the `AuthorizedKeysCommand` line to use the new folder. For example: 166 + 167 + ``` 168 + Match User git 169 + AuthorizedKeysCommand /usr/local/libexec/tangled-keyfetch -git-dir /home/git/repositories 170 + AuthorizedKeysCommandUser nobody 171 + ``` 172 + 173 + Make sure to restart your SSH server! 174 + 175 + #### git 176 + 177 + The keyfetch executable takes multiple arguments to change certain paths. You 178 + can view a full list by running `/usr/local/libexec/tangled-keyfetch -h`. 179 + 180 + As an example, if you wanted to change the path to the repoguard executable, 181 + you would edit your SSH config (e.g. `/etc/ssh/sshd_config.d/authorized_keys_command.conf`) 182 + and update the `AuthorizedKeysCommand` line: 183 + 184 + ``` 185 + Match User git 186 + AuthorizedKeysCommand /usr/local/libexec/tangled-keyfetch -repoguard-path /path/to/repoguard 187 + AuthorizedKeysCommandUser nobody 188 + ``` 189 + 190 + Make sure to restart your SSH server!
+29 -17
flake.lock
··· 32 "url": "https://unpkg.com/htmx.org@2.0.4/dist/htmx.min.js" 33 } 34 }, 35 - "ia-fonts-src": { 36 "flake": false, 37 "locked": { 38 - "lastModified": 1686932517, 39 - "narHash": "sha256-2T165nFfCzO65/PIHauJA//S+zug5nUwPcg8NUEydfc=", 40 - "owner": "iaolo", 41 - "repo": "iA-Fonts", 42 - "rev": "f32c04c3058a75d7ce28919ce70fe8800817491b", 43 - "type": "github" 44 }, 45 "original": { 46 - "owner": "iaolo", 47 - "repo": "iA-Fonts", 48 - "type": "github" 49 } 50 }, 51 "indigo": { 52 "flake": false, 53 "locked": { 54 - "lastModified": 1738491661, 55 - "narHash": "sha256-+njDigkvjH4XmXZMog5Mp0K4x9mamHX6gSGJCZB9mE4=", 56 "owner": "oppiliappan", 57 "repo": "indigo", 58 - "rev": "feb802f02a462ac0a6392ffc3e40b0529f0cdf71", 59 "type": "github" 60 }, 61 "original": { ··· 64 "type": "github" 65 } 66 }, 67 "lucide-src": { 68 "flake": false, 69 "locked": { ··· 79 }, 80 "nixpkgs": { 81 "locked": { 82 - "lastModified": 1740938536, 83 - "narHash": "sha256-m6Lz7cRoZ8GS7tziYrNWv0WXTYtKx3oOC9Bwa6a13EA=", 84 "owner": "nixos", 85 "repo": "nixpkgs", 86 - "rev": "2ffed2bc3d27861b821f9bec127cf51a4dbfabb4", 87 "type": "github" 88 }, 89 "original": { 90 "owner": "nixos", 91 "repo": "nixpkgs", 92 "type": "github" 93 } ··· 96 "inputs": { 97 "gitignore": "gitignore", 98 "htmx-src": "htmx-src", 99 - "ia-fonts-src": "ia-fonts-src", 100 "indigo": "indigo", 101 "lucide-src": "lucide-src", 102 "nixpkgs": "nixpkgs" 103 }
··· 32 "url": "https://unpkg.com/htmx.org@2.0.4/dist/htmx.min.js" 33 } 34 }, 35 + "ibm-plex-mono-src": { 36 "flake": false, 37 "locked": { 38 + "lastModified": 1731402384, 39 + "narHash": "sha256-OwUmrPfEehLDz0fl2ChYLK8FQM2p0G1+EMrGsYEq+6g=", 40 + "type": "tarball", 41 + "url": "https://github.com/IBM/plex/releases/download/@ibm/plex-mono@1.1.0/ibm-plex-mono.zip" 42 }, 43 "original": { 44 + "type": "tarball", 45 + "url": "https://github.com/IBM/plex/releases/download/@ibm/plex-mono@1.1.0/ibm-plex-mono.zip" 46 } 47 }, 48 "indigo": { 49 "flake": false, 50 "locked": { 51 + "lastModified": 1745333930, 52 + "narHash": "sha256-83fIHqDE+dfnZ88HaNuwfKFO+R0RKAM1WxMfNh/Matk=", 53 "owner": "oppiliappan", 54 "repo": "indigo", 55 + "rev": "e4e59280737b8676611fc077a228d47b3e8e9491", 56 "type": "github" 57 }, 58 "original": { ··· 61 "type": "github" 62 } 63 }, 64 + "inter-fonts-src": { 65 + "flake": false, 66 + "locked": { 67 + "lastModified": 1731687360, 68 + "narHash": "sha256-5vdKKvHAeZi6igrfpbOdhZlDX2/5+UvzlnCQV6DdqoQ=", 69 + "type": "tarball", 70 + "url": "https://github.com/rsms/inter/releases/download/v4.1/Inter-4.1.zip" 71 + }, 72 + "original": { 73 + "type": "tarball", 74 + "url": "https://github.com/rsms/inter/releases/download/v4.1/Inter-4.1.zip" 75 + } 76 + }, 77 "lucide-src": { 78 "flake": false, 79 "locked": { ··· 89 }, 90 "nixpkgs": { 91 "locked": { 92 + "lastModified": 1746904237, 93 + "narHash": "sha256-3e+AVBczosP5dCLQmMoMEogM57gmZ2qrVSrmq9aResQ=", 94 "owner": "nixos", 95 "repo": "nixpkgs", 96 + "rev": "d89fc19e405cb2d55ce7cc114356846a0ee5e956", 97 "type": "github" 98 }, 99 "original": { 100 "owner": "nixos", 101 + "ref": "nixos-unstable", 102 "repo": "nixpkgs", 103 "type": "github" 104 } ··· 107 "inputs": { 108 "gitignore": "gitignore", 109 "htmx-src": "htmx-src", 110 + "ibm-plex-mono-src": "ibm-plex-mono-src", 111 "indigo": "indigo", 112 + "inter-fonts-src": "inter-fonts-src", 113 "lucide-src": "lucide-src", 114 "nixpkgs": "nixpkgs" 115 }
+85 -35
flake.nix
··· 2 description = "atproto github"; 3 4 inputs = { 5 - nixpkgs.url = "github:nixos/nixpkgs"; 6 indigo = { 7 url = "github:oppiliappan/indigo"; 8 flake = false; ··· 15 url = "https://github.com/lucide-icons/lucide/releases/download/0.483.0/lucide-icons-0.483.0.zip"; 16 flake = false; 17 }; 18 - ia-fonts-src = { 19 - url = "github:iaolo/iA-Fonts"; 20 flake = false; 21 }; 22 gitignore = { ··· 32 htmx-src, 33 lucide-src, 34 gitignore, 35 - ia-fonts-src, 36 }: let 37 supportedSystems = ["x86_64-linux" "x86_64-darwin" "aarch64-linux" "aarch64-darwin"]; 38 forAllSystems = nixpkgs.lib.genAttrs supportedSystems; ··· 44 inherit (gitignore.lib) gitignoreSource; 45 in { 46 overlays.default = final: prev: let 47 - goModHash = "sha256-3gmXhututsJTFVPQi2uekTBP/qSJGgsDsVr7YU+z7d0="; 48 buildCmdPackage = name: 49 final.buildGoModule { 50 pname = name; ··· 74 mkdir -p appview/pages/static/{fonts,icons} 75 cp -f ${htmx-src} appview/pages/static/htmx.min.js 76 cp -rf ${lucide-src}/*.svg appview/pages/static/icons/ 77 - cp -f ${ia-fonts-src}/"iA Writer Quattro"/Static/*.ttf appview/pages/static/fonts/ 78 - cp -f ${ia-fonts-src}/"iA Writer Mono"/Static/*.ttf appview/pages/static/fonts/ 79 ${pkgs.tailwindcss}/bin/tailwindcss -i input.css -o appview/pages/static/tw.css 80 popd 81 ''; ··· 117 }; 118 repoguard = buildCmdPackage "repoguard"; 119 keyfetch = buildCmdPackage "keyfetch"; 120 }; 121 packages = forAllSystems (system: { 122 inherit ··· 127 knotserver-unwrapped 128 repoguard 129 keyfetch 130 ; 131 }); 132 defaultPackage = forAllSystems (system: nixpkgsFor.${system}.appview); ··· 153 mkdir -p appview/pages/static/{fonts,icons} 154 cp -f ${htmx-src} appview/pages/static/htmx.min.js 155 cp -rf ${lucide-src}/*.svg appview/pages/static/icons/ 156 - cp -f ${ia-fonts-src}/"iA Writer Quattro"/Static/*.ttf appview/pages/static/fonts/ 157 - cp -f ${ia-fonts-src}/"iA Writer Mono"/Static/*.ttf appview/pages/static/fonts/ 158 ''; 159 }; 160 }); 161 apps = forAllSystems (system: let ··· 164 pkgs.writeShellScriptBin "run" 165 '' 166 ${pkgs.air}/bin/air -c /dev/null \ 167 - -build.cmd "${pkgs.tailwindcss}/bin/tailwindcss -i input.css -o ./appview/pages/static/tw.css && ${pkgs.go}/bin/go build -o ./out/${name}.out ./cmd/${name}/main.go" \ 168 -build.bin "./out/${name}.out" \ 169 - -build.include_ext "go,html,css" 170 ''; 171 in { 172 watch-appview = { ··· 176 watch-knotserver = { 177 type = "app"; 178 program = ''${air-watcher "knotserver"}/bin/run''; 179 }; 180 }); 181 ··· 230 pkgs, 231 lib, 232 ... 233 - }: 234 with lib; { 235 options = { 236 services.tangled-knotserver = { ··· 252 description = "User that hosts git repos and performs git operations"; 253 }; 254 255 repo = { 256 scanPath = mkOption { 257 type = types.path; 258 - default = "/home/git"; 259 description = "Path where repositories are scanned from"; 260 }; 261 ··· 287 288 dbPath = mkOption { 289 type = types.path; 290 - default = "knotserver.db"; 291 description = "Path to the database file"; 292 }; 293 ··· 306 }; 307 }; 308 309 - config = mkIf config.services.tangled-knotserver.enable { 310 environment.systemPackages = with pkgs; [git]; 311 312 system.activationScripts.gitConfig = '' 313 - mkdir -p /home/git/.config/git 314 - cat > /home/git/.config/git/config << EOF 315 [user] 316 name = Git User 317 email = git@example.com 318 EOF 319 - chown -R git:git /home/git/.config 320 ''; 321 322 - users.users.git = { 323 - isNormalUser = true; 324 - home = "/home/git"; 325 createHome = true; 326 - group = "git"; 327 }; 328 329 - users.groups.git = {}; 330 331 services.openssh = { 332 enable = true; 333 extraConfig = '' 334 - Match User git 335 AuthorizedKeysCommand /etc/ssh/keyfetch_wrapper 336 AuthorizedKeysCommandUser nobody 337 ''; ··· 343 #!${pkgs.stdenv.shell} 344 ${self.packages.${pkgs.system}.keyfetch}/bin/keyfetch \ 345 -repoguard-path ${self.packages.${pkgs.system}.repoguard}/bin/repoguard \ 346 -log-path /tmp/repoguard.log 347 ''; 348 }; ··· 352 after = ["network.target" "sshd.service"]; 353 wantedBy = ["multi-user.target"]; 354 serviceConfig = { 355 - User = "git"; 356 - WorkingDirectory = "/home/git"; 357 Environment = [ 358 - "KNOT_REPO_SCAN_PATH=${config.services.tangled-knotserver.repo.scanPath}" 359 - "APPVIEW_ENDPOINT=${config.services.tangled-knotserver.appviewEndpoint}" 360 - "KNOT_SERVER_INTERNAL_LISTEN_ADDR=${config.services.tangled-knotserver.server.internalListenAddr}" 361 - "KNOT_SERVER_LISTEN_ADDR=${config.services.tangled-knotserver.server.listenAddr}" 362 - "KNOT_SERVER_HOSTNAME=${config.services.tangled-knotserver.server.hostname}" 363 ]; 364 - EnvironmentFile = config.services.tangled-knotserver.server.secretFile; 365 ExecStart = "${self.packages.${pkgs.system}.knotserver}/bin/knotserver"; 366 Restart = "always"; 367 }; 368 }; 369 370 - networking.firewall.allowedTCPPorts = [22]; 371 }; 372 }; 373 ··· 381 ... 382 }: { 383 virtualisation.memorySize = 2048; 384 virtualisation.cores = 2; 385 services.getty.autologinUser = "root"; 386 environment.systemPackages = with pkgs; [curl vim git]; 387 - systemd.tmpfiles.rules = [ 388 - "w /var/lib/knotserver/secret 0660 git git - KNOT_SERVER_SECRET=6995e040e80e2d593b5e5e9ca611a70140b9ef8044add0a28b48b1ee34aa3e85" 389 ]; 390 services.tangled-knotserver = { 391 enable = true;
··· 2 description = "atproto github"; 3 4 inputs = { 5 + nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; 6 indigo = { 7 url = "github:oppiliappan/indigo"; 8 flake = false; ··· 15 url = "https://github.com/lucide-icons/lucide/releases/download/0.483.0/lucide-icons-0.483.0.zip"; 16 flake = false; 17 }; 18 + inter-fonts-src = { 19 + url = "https://github.com/rsms/inter/releases/download/v4.1/Inter-4.1.zip"; 20 + flake = false; 21 + }; 22 + ibm-plex-mono-src = { 23 + url = "https://github.com/IBM/plex/releases/download/%40ibm%2Fplex-mono%401.1.0/ibm-plex-mono.zip"; 24 flake = false; 25 }; 26 gitignore = { ··· 36 htmx-src, 37 lucide-src, 38 gitignore, 39 + inter-fonts-src, 40 + ibm-plex-mono-src, 41 }: let 42 supportedSystems = ["x86_64-linux" "x86_64-darwin" "aarch64-linux" "aarch64-darwin"]; 43 forAllSystems = nixpkgs.lib.genAttrs supportedSystems; ··· 49 inherit (gitignore.lib) gitignoreSource; 50 in { 51 overlays.default = final: prev: let 52 + goModHash = "sha256-zcfTNo7QsiihzLa4qHEX8uGGtbcmBn8TlSm0YHBRNw8="; 53 buildCmdPackage = name: 54 final.buildGoModule { 55 pname = name; ··· 79 mkdir -p appview/pages/static/{fonts,icons} 80 cp -f ${htmx-src} appview/pages/static/htmx.min.js 81 cp -rf ${lucide-src}/*.svg appview/pages/static/icons/ 82 + cp -f ${inter-fonts-src}/web/InterVariable*.woff2 appview/pages/static/fonts/ 83 + cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 appview/pages/static/fonts/ 84 + cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono-Regular.woff2 appview/pages/static/fonts/ 85 ${pkgs.tailwindcss}/bin/tailwindcss -i input.css -o appview/pages/static/tw.css 86 popd 87 ''; ··· 123 }; 124 repoguard = buildCmdPackage "repoguard"; 125 keyfetch = buildCmdPackage "keyfetch"; 126 + genjwks = buildCmdPackage "genjwks"; 127 }; 128 packages = forAllSystems (system: { 129 inherit ··· 134 knotserver-unwrapped 135 repoguard 136 keyfetch 137 + genjwks 138 ; 139 }); 140 defaultPackage = forAllSystems (system: nixpkgsFor.${system}.appview); ··· 161 mkdir -p appview/pages/static/{fonts,icons} 162 cp -f ${htmx-src} appview/pages/static/htmx.min.js 163 cp -rf ${lucide-src}/*.svg appview/pages/static/icons/ 164 + cp -f ${inter-fonts-src}/web/InterVariable*.woff2 appview/pages/static/fonts/ 165 + cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 appview/pages/static/fonts/ 166 + cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono-Regular.woff2 appview/pages/static/fonts/ 167 + export TANGLED_OAUTH_JWKS="$(${pkgs.genjwks}/bin/genjwks)" 168 ''; 169 + env.CGO_ENABLED = 1; 170 }; 171 }); 172 apps = forAllSystems (system: let ··· 175 pkgs.writeShellScriptBin "run" 176 '' 177 ${pkgs.air}/bin/air -c /dev/null \ 178 + -build.cmd "${pkgs.go}/bin/go build -o ./out/${name}.out ./cmd/${name}/main.go" \ 179 -build.bin "./out/${name}.out" \ 180 + -build.stop_on_error "true" \ 181 + -build.include_ext "go" 182 + ''; 183 + tailwind-watcher = 184 + pkgs.writeShellScriptBin "run" 185 + '' 186 + ${pkgs.tailwindcss}/bin/tailwindcss -w -i input.css -o ./appview/pages/static/tw.css 187 ''; 188 in { 189 watch-appview = { ··· 193 watch-knotserver = { 194 type = "app"; 195 program = ''${air-watcher "knotserver"}/bin/run''; 196 + }; 197 + watch-tailwind = { 198 + type = "app"; 199 + program = ''${tailwind-watcher}/bin/run''; 200 }; 201 }); 202 ··· 251 pkgs, 252 lib, 253 ... 254 + }: let 255 + cfg = config.services.tangled-knotserver; 256 + in 257 with lib; { 258 options = { 259 services.tangled-knotserver = { ··· 275 description = "User that hosts git repos and performs git operations"; 276 }; 277 278 + openFirewall = mkOption { 279 + type = types.bool; 280 + default = true; 281 + description = "Open port 22 in the firewall for ssh"; 282 + }; 283 + 284 + stateDir = mkOption { 285 + type = types.path; 286 + default = "/home/${cfg.gitUser}"; 287 + description = "Tangled knot data directory"; 288 + }; 289 + 290 repo = { 291 scanPath = mkOption { 292 type = types.path; 293 + default = cfg.stateDir; 294 description = "Path where repositories are scanned from"; 295 }; 296 ··· 322 323 dbPath = mkOption { 324 type = types.path; 325 + default = "${cfg.stateDir}/knotserver.db"; 326 description = "Path to the database file"; 327 }; 328 ··· 341 }; 342 }; 343 344 + config = mkIf cfg.enable { 345 environment.systemPackages = with pkgs; [git]; 346 347 system.activationScripts.gitConfig = '' 348 + mkdir -p "${cfg.repo.scanPath}" 349 + chown -R ${cfg.gitUser}:${cfg.gitUser} \ 350 + "${cfg.repo.scanPath}" 351 + 352 + mkdir -p "${cfg.stateDir}/.config/git" 353 + cat > "${cfg.stateDir}/.config/git/config" << EOF 354 [user] 355 name = Git User 356 email = git@example.com 357 EOF 358 + chown -R ${cfg.gitUser}:${cfg.gitUser} \ 359 + "${cfg.stateDir}" 360 ''; 361 362 + users.users.${cfg.gitUser} = { 363 + isSystemUser = true; 364 + useDefaultShell = true; 365 + home = cfg.stateDir; 366 createHome = true; 367 + group = cfg.gitUser; 368 }; 369 370 + users.groups.${cfg.gitUser} = {}; 371 372 services.openssh = { 373 enable = true; 374 extraConfig = '' 375 + Match User ${cfg.gitUser} 376 AuthorizedKeysCommand /etc/ssh/keyfetch_wrapper 377 AuthorizedKeysCommandUser nobody 378 ''; ··· 384 #!${pkgs.stdenv.shell} 385 ${self.packages.${pkgs.system}.keyfetch}/bin/keyfetch \ 386 -repoguard-path ${self.packages.${pkgs.system}.repoguard}/bin/repoguard \ 387 + -internal-api "http://${cfg.server.internalListenAddr}" \ 388 + -git-dir "${cfg.repo.scanPath}" \ 389 -log-path /tmp/repoguard.log 390 ''; 391 }; ··· 395 after = ["network.target" "sshd.service"]; 396 wantedBy = ["multi-user.target"]; 397 serviceConfig = { 398 + User = cfg.gitUser; 399 + WorkingDirectory = cfg.stateDir; 400 Environment = [ 401 + "KNOT_REPO_SCAN_PATH=${cfg.repo.scanPath}" 402 + "KNOT_REPO_MAIN_BRANCH=${cfg.repo.mainBranch}" 403 + "APPVIEW_ENDPOINT=${cfg.appviewEndpoint}" 404 + "KNOT_SERVER_INTERNAL_LISTEN_ADDR=${cfg.server.internalListenAddr}" 405 + "KNOT_SERVER_LISTEN_ADDR=${cfg.server.listenAddr}" 406 + "KNOT_SERVER_DB_PATH=${cfg.server.dbPath}" 407 + "KNOT_SERVER_HOSTNAME=${cfg.server.hostname}" 408 ]; 409 + EnvironmentFile = cfg.server.secretFile; 410 ExecStart = "${self.packages.${pkgs.system}.knotserver}/bin/knotserver"; 411 Restart = "always"; 412 }; 413 }; 414 415 + networking.firewall.allowedTCPPorts = mkIf cfg.openFirewall [22]; 416 }; 417 }; 418 ··· 426 ... 427 }: { 428 virtualisation.memorySize = 2048; 429 + virtualisation.diskSize = 10 * 1024; 430 virtualisation.cores = 2; 431 services.getty.autologinUser = "root"; 432 environment.systemPackages = with pkgs; [curl vim git]; 433 + systemd.tmpfiles.rules = let 434 + u = config.services.tangled-knotserver.gitUser; 435 + g = config.services.tangled-knotserver.gitUser; 436 + in [ 437 + "d /var/lib/knotserver 0770 ${u} ${g} - -" # Create the directory first 438 + "f+ /var/lib/knotserver/secret 0660 ${u} ${g} - KNOT_SERVER_SECRET=679f15000084699abc6a20d3ef449efa3656583f38e456a08f0638250688ff2e" 439 ]; 440 services.tangled-knotserver = { 441 enable = true;
+22 -16
go.mod
··· 1 module tangled.sh/tangled.sh/core 2 3 - go 1.23.0 4 5 - toolchain go1.23.6 6 7 require ( 8 github.com/Blank-Xu/sql-adapter v1.1.1 9 github.com/alecthomas/chroma/v2 v2.15.0 10 github.com/bluekeyes/go-gitdiff v0.8.1 11 - github.com/bluesky-social/indigo v0.0.0-20250123072624-9e3b84fdbb20 12 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 13 github.com/casbin/casbin/v2 v2.103.0 14 github.com/cyphar/filepath-securejoin v0.4.1 ··· 19 github.com/go-git/go-git/v5 v5.14.0 20 github.com/google/uuid v1.6.0 21 github.com/gorilla/sessions v1.4.0 22 - github.com/ipfs/go-cid v0.4.1 23 github.com/mattn/go-sqlite3 v1.14.24 24 github.com/microcosm-cc/bluemonday v1.0.27 25 github.com/resend/resend-go/v2 v2.15.0 26 github.com/sethvargo/go-envconfig v1.1.0 27 github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e 28 github.com/yuin/goldmark v1.4.13 29 - golang.org/x/crypto v0.36.0 30 golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 31 ) 32 ··· 42 github.com/casbin/govaluate v1.3.0 // indirect 43 github.com/cespare/xxhash/v2 v2.3.0 // indirect 44 github.com/cloudflare/circl v1.6.0 // indirect 45 - github.com/davecgh/go-spew v1.1.1 // indirect 46 github.com/dlclark/regexp2 v1.11.5 // indirect 47 github.com/emirpasic/gods v1.18.1 // indirect 48 github.com/felixge/httpsnoop v1.0.4 // indirect 49 github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect 50 github.com/go-git/go-billy/v5 v5.6.2 // indirect 51 - github.com/go-logr/logr v1.4.1 // indirect 52 github.com/go-logr/stdr v1.2.2 // indirect 53 github.com/goccy/go-json v0.10.2 // indirect 54 github.com/gogo/protobuf v1.3.2 // indirect 55 github.com/gorilla/css v1.0.1 // indirect 56 github.com/gorilla/securecookie v1.1.2 // indirect 57 github.com/gorilla/websocket v1.5.1 // indirect ··· 76 github.com/kevinburke/ssh_config v1.2.0 // indirect 77 github.com/klauspost/compress v1.17.9 // indirect 78 github.com/klauspost/cpuid/v2 v2.2.7 // indirect 79 github.com/mattn/go-isatty v0.0.20 // indirect 80 github.com/minio/sha256-simd v1.0.1 // indirect 81 github.com/mr-tron/base58 v1.2.0 // indirect ··· 87 github.com/opentracing/opentracing-go v1.2.0 // indirect 88 github.com/pjbgf/sha1cd v0.3.2 // indirect 89 github.com/pkg/errors v0.9.1 // indirect 90 - github.com/pmezard/go-difflib v1.0.0 // indirect 91 github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f // indirect 92 github.com/prometheus/client_golang v1.19.1 // indirect 93 github.com/prometheus/client_model v0.6.1 // indirect 94 github.com/prometheus/common v0.54.0 // indirect 95 github.com/prometheus/procfs v0.15.1 // indirect 96 github.com/sergi/go-diff v1.3.1 // indirect 97 github.com/skeema/knownhosts v1.3.1 // indirect 98 github.com/spaolacci/murmur3 v1.1.0 // indirect 99 - github.com/stretchr/testify v1.10.0 // indirect 100 github.com/xanzy/ssh-agent v0.3.3 // indirect 101 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect 102 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect 103 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 // indirect 104 - go.opentelemetry.io/otel v1.21.0 // indirect 105 - go.opentelemetry.io/otel/metric v1.21.0 // indirect 106 - go.opentelemetry.io/otel/trace v1.21.0 // indirect 107 go.uber.org/atomic v1.11.0 // indirect 108 go.uber.org/multierr v1.11.0 // indirect 109 go.uber.org/zap v1.26.0 // indirect 110 - golang.org/x/net v0.37.0 // indirect 111 - golang.org/x/sys v0.31.0 // indirect 112 - golang.org/x/time v0.5.0 // indirect 113 google.golang.org/protobuf v1.34.2 // indirect 114 gopkg.in/warnings.v0 v0.1.2 // indirect 115 - gopkg.in/yaml.v3 v3.0.1 // indirect 116 lukechampine.com/blake3 v1.2.1 // indirect 117 ) 118
··· 1 module tangled.sh/tangled.sh/core 2 3 + go 1.24.0 4 5 + toolchain go1.24.3 6 7 require ( 8 github.com/Blank-Xu/sql-adapter v1.1.1 9 github.com/alecthomas/chroma/v2 v2.15.0 10 github.com/bluekeyes/go-gitdiff v0.8.1 11 + github.com/bluesky-social/indigo v0.0.0-20250301025210-a4e0cc37e188 12 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 13 github.com/casbin/casbin/v2 v2.103.0 14 github.com/cyphar/filepath-securejoin v0.4.1 ··· 19 github.com/go-git/go-git/v5 v5.14.0 20 github.com/google/uuid v1.6.0 21 github.com/gorilla/sessions v1.4.0 22 + github.com/haileyok/atproto-oauth-golang v0.0.2 23 + github.com/ipfs/go-cid v0.5.0 24 + github.com/lestrrat-go/jwx/v2 v2.0.12 25 github.com/mattn/go-sqlite3 v1.14.24 26 github.com/microcosm-cc/bluemonday v1.0.27 27 github.com/resend/resend-go/v2 v2.15.0 28 github.com/sethvargo/go-envconfig v1.1.0 29 github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e 30 github.com/yuin/goldmark v1.4.13 31 golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 32 ) 33 ··· 43 github.com/casbin/govaluate v1.3.0 // indirect 44 github.com/cespare/xxhash/v2 v2.3.0 // indirect 45 github.com/cloudflare/circl v1.6.0 // indirect 46 + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect 47 github.com/dlclark/regexp2 v1.11.5 // indirect 48 github.com/emirpasic/gods v1.18.1 // indirect 49 github.com/felixge/httpsnoop v1.0.4 // indirect 50 github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect 51 github.com/go-git/go-billy/v5 v5.6.2 // indirect 52 + github.com/go-logr/logr v1.4.2 // indirect 53 github.com/go-logr/stdr v1.2.2 // indirect 54 github.com/goccy/go-json v0.10.2 // indirect 55 github.com/gogo/protobuf v1.3.2 // indirect 56 + github.com/golang-jwt/jwt/v5 v5.2.1 // indirect 57 github.com/gorilla/css v1.0.1 // indirect 58 github.com/gorilla/securecookie v1.1.2 // indirect 59 github.com/gorilla/websocket v1.5.1 // indirect ··· 78 github.com/kevinburke/ssh_config v1.2.0 // indirect 79 github.com/klauspost/compress v1.17.9 // indirect 80 github.com/klauspost/cpuid/v2 v2.2.7 // indirect 81 + github.com/lestrrat-go/blackmagic v1.0.2 // indirect 82 + github.com/lestrrat-go/httpcc v1.0.1 // indirect 83 + github.com/lestrrat-go/httprc v1.0.4 // indirect 84 + github.com/lestrrat-go/iter v1.0.2 // indirect 85 + github.com/lestrrat-go/option v1.0.1 // indirect 86 github.com/mattn/go-isatty v0.0.20 // indirect 87 github.com/minio/sha256-simd v1.0.1 // indirect 88 github.com/mr-tron/base58 v1.2.0 // indirect ··· 94 github.com/opentracing/opentracing-go v1.2.0 // indirect 95 github.com/pjbgf/sha1cd v0.3.2 // indirect 96 github.com/pkg/errors v0.9.1 // indirect 97 github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f // indirect 98 github.com/prometheus/client_golang v1.19.1 // indirect 99 github.com/prometheus/client_model v0.6.1 // indirect 100 github.com/prometheus/common v0.54.0 // indirect 101 github.com/prometheus/procfs v0.15.1 // indirect 102 + github.com/segmentio/asm v1.2.0 // indirect 103 github.com/sergi/go-diff v1.3.1 // indirect 104 github.com/skeema/knownhosts v1.3.1 // indirect 105 github.com/spaolacci/murmur3 v1.1.0 // indirect 106 github.com/xanzy/ssh-agent v0.3.3 // indirect 107 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect 108 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect 109 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 // indirect 110 + go.opentelemetry.io/otel v1.29.0 // indirect 111 + go.opentelemetry.io/otel/metric v1.29.0 // indirect 112 + go.opentelemetry.io/otel/trace v1.29.0 // indirect 113 go.uber.org/atomic v1.11.0 // indirect 114 go.uber.org/multierr v1.11.0 // indirect 115 go.uber.org/zap v1.26.0 // indirect 116 + golang.org/x/crypto v0.37.0 // indirect 117 + golang.org/x/net v0.39.0 // indirect 118 + golang.org/x/sys v0.32.0 // indirect 119 + golang.org/x/time v0.8.0 // indirect 120 google.golang.org/protobuf v1.34.2 // indirect 121 gopkg.in/warnings.v0 v0.1.2 // indirect 122 lukechampine.com/blake3 v1.2.1 // indirect 123 ) 124
+73 -28
go.sum
··· 26 github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 27 github.com/bluekeyes/go-gitdiff v0.8.1 h1:lL1GofKMywO17c0lgQmJYcKek5+s8X6tXVNOLxy4smI= 28 github.com/bluekeyes/go-gitdiff v0.8.1/go.mod h1:WWAk1Mc6EgWarCrPFO+xeYlujPu98VuLW3Tu+B/85AE= 29 - github.com/bluesky-social/indigo v0.0.0-20250123072624-9e3b84fdbb20 h1:yHusfYYi8odoCcsI6AurU+dRWb7itHAQNwt3/Rl9Vfs= 30 - github.com/bluesky-social/indigo v0.0.0-20250123072624-9e3b84fdbb20/go.mod h1:Qp4YqWf+AQ3TwQCxV5Ls8O2tXE55zVTGVs3zTmn7BOg= 31 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 h1:CFvRtYNSnWRAi/98M3O466t9dYuwtesNbu6FVPymRrA= 32 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1/go.mod h1:WiYEeyJSdUwqoaZ71KJSpTblemUCpwJfh5oVXplK6T4= 33 github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= ··· 52 github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= 53 github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= 54 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 55 - github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 56 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 57 github.com/dgraph-io/ristretto v0.2.0 h1:XAfl+7cmoUDWW/2Lx8TGZQjjxIQ2Ley9DSf52dru4WE= 58 github.com/dgraph-io/ristretto v0.2.0/go.mod h1:8uBHCU/PBV4Ag0CJrP47b9Ofby5dqWNh4FicAdoqFNU= 59 github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 h1:fAjc9m62+UWV/WAFKLNi6ZS0675eEUC9y3AlwSbQu1Y= ··· 82 github.com/go-git/go-git/v5 v5.6.1 h1:q4ZRqQl4pR/ZJHc1L5CFjGA1a10u76aV1iC+nh+bHsk= 83 github.com/go-git/go-git/v5 v5.6.1/go.mod h1:mvyoL6Unz0PiTQrGQfSfiLFhBH1c1e84ylC2MDs4ee8= 84 github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 85 - github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= 86 - github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 87 github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 88 github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 89 github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0= ··· 91 github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= 92 github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 93 github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 94 github.com/golang/mock v1.4.4 h1:l75CXGRSwbaYNpl/Z2X1XIIAMSCquvXgpVZDhwEIJsc= 95 github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= 96 github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= ··· 111 github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= 112 github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= 113 github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= 114 github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= 115 github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= 116 github.com/hashicorp/go-hclog v0.9.2 h1:CG6TE5H9/JXsFWJCfoIVpKFIkFe6ysEuHirp4DxCsHI= ··· 130 github.com/ipfs/bbloom v0.0.4/go.mod h1:cS9YprKXpoZ9lT0n/Mw/a6/aFV6DTjTLYHeA+gyqMG0= 131 github.com/ipfs/go-block-format v0.2.0 h1:ZqrkxBA2ICbDRbK8KJs/u0O3dlp6gmAuuXUJNiW1Ycs= 132 github.com/ipfs/go-block-format v0.2.0/go.mod h1:+jpL11nFx5A/SPpsoBn6Bzkra/zaArfSmsknbPMYgzM= 133 - github.com/ipfs/go-cid v0.4.1 h1:A/T3qGvxi4kpKWWcPC/PgbvDA2bjVLO7n4UeVwnbs/s= 134 - github.com/ipfs/go-cid v0.4.1/go.mod h1:uQHwDeX4c6CtyrFwdqyhpNcxVewur1M7l7fNU7LKwZk= 135 github.com/ipfs/go-datastore v0.6.0 h1:JKyz+Gvz1QEZw0LsX1IBn+JFCJQH4SJVFtM4uWU0Myk= 136 github.com/ipfs/go-datastore v0.6.0/go.mod h1:rt5M3nNbSO/8q1t4LNkLyUwRs8HupMeN/8O4Vn9YAT8= 137 github.com/ipfs/go-detect-race v0.0.1 h1:qX/xay2W3E4Q1U7d9lNs1sU9nvguX0a7319XbyQ6cOk= ··· 159 github.com/jbenet/goprocess v0.1.4 h1:DRGOFReOMqqDNXwW70QkacFW0YN9QnwLV0Vqk+3oU0o= 160 github.com/jbenet/goprocess v0.1.4/go.mod h1:5yspPrukOVuOLORacaBi858NqyClJPQxYZlqdZVfqY4= 161 github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4= 162 github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= 163 github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= 164 github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= ··· 177 github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 178 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 179 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 180 github.com/matryer/is v1.2.0 h1:92UTHpy8CDwaJ08GqLDzhhuixiBUUD1p3AU6PHddz4A= 181 github.com/matryer/is v1.2.0/go.mod h1:2fLPjFQM9rhQ15aVEtbuwhJinnOqrmgXPNdZsdwlWXA= 182 github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= ··· 212 github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 213 github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 214 github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 215 - github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 216 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 217 github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f h1:VXTQfuJj9vKR4TCkEuWIckKvdHFeJH/huIFJ9/cXOB0= 218 github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f/go.mod h1:/zvteZs/GwLtCgZ4BL6CBsk9IKIlexP43ObX9AxTqTw= 219 github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE= ··· 227 github.com/resend/resend-go/v2 v2.15.0 h1:B6oMEPf8IEQwn2Ovx/9yymkESLDSeNfLFaNMw+mzHhE= 228 github.com/resend/resend-go/v2 v2.15.0/go.mod h1:3YCb8c8+pLiqhtRFXTyFwlLvfjQtluxOr9HEh2BwCkQ= 229 github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= 230 - github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= 231 - github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= 232 github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 233 github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= 234 github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= 235 github.com/sethvargo/go-envconfig v1.1.0 h1:cWZiJxeTm7AlCvzGXrEXaSTCNgip5oJepekh/BOQuog= ··· 246 github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= 247 github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= 248 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 249 github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 250 github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 251 github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 252 github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 253 github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 254 github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 255 github.com/urfave/cli v1.22.10/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= ··· 270 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02/go.mod h1:JTnUj0mpYiAsuZLmKjTx/ex3AtMowcCgnE7YNyCEP0I= 271 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 h1:aFJWCqJMNjENlcleuuOkGAPH82y0yULBScfXcIEdS24= 272 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1/go.mod h1:sEGXWArGqc3tVa+ekntsN65DmVbVeW+7lTKTjZF3/Fo= 273 - go.opentelemetry.io/otel v1.21.0 h1:hzLeKBZEL7Okw2mGzZ0cc4k/A7Fta0uoPgaJCr8fsFc= 274 - go.opentelemetry.io/otel v1.21.0/go.mod h1:QZzNPQPm1zLX4gZK4cMi+71eaorMSGT3A4znnUvNNEo= 275 - go.opentelemetry.io/otel/metric v1.21.0 h1:tlYWfeo+Bocx5kLEloTjbcDwBuELRrIFxwdQ36PlJu4= 276 - go.opentelemetry.io/otel/metric v1.21.0/go.mod h1:o1p3CA8nNHW8j5yuQLdc1eeqEaPfzug24uvsyIEJRWM= 277 - go.opentelemetry.io/otel/trace v1.21.0 h1:WD9i5gzvoUPuXIXH24ZNBudiarZDKuekPqi/E8fpfLc= 278 - go.opentelemetry.io/otel/trace v1.21.0/go.mod h1:LGbsEB0f9LGjN+OZaQQ26sohbOmiMR+BaslueVtS/qQ= 279 go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= 280 go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= 281 go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= ··· 303 golang.org/x/crypto v0.0.0-20220826181053-bd7e27e6170d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= 304 golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw= 305 golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= 306 - golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= 307 - golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= 308 golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= 309 golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= 310 golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= ··· 314 golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 315 golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 316 golang.org/x/mod v0.6.0/go.mod h1:4mET923SAdbXp2ki8ey+zGs1SLqsuM2Y0uvdZR/fUNI= 317 golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 318 golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 319 golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= ··· 327 golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= 328 golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 329 golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 330 - golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c= 331 - golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= 332 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 333 golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 334 golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 335 golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 336 golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 337 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 338 golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 339 golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= ··· 348 golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 349 golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 350 golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 351 golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 352 golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 353 golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= ··· 357 golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 358 golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 359 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 360 - golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= 361 - golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 362 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 363 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 364 golang.org/x/term v0.0.0-20220722155259-a9ba230a4035/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 365 golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 366 golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 367 - golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= 368 - golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= 369 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 370 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 371 golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 372 golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 373 golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 374 golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 375 - golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= 376 - golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= 377 - golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= 378 - golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 379 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 380 golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 381 golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= ··· 389 golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= 390 golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 391 golang.org/x/tools v0.2.0/go.mod h1:y4OqIKeOV/fWJetJ8bXPU1sEVniLMIyDAZWeHdV+NTA= 392 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 393 golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 394 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
··· 26 github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 27 github.com/bluekeyes/go-gitdiff v0.8.1 h1:lL1GofKMywO17c0lgQmJYcKek5+s8X6tXVNOLxy4smI= 28 github.com/bluekeyes/go-gitdiff v0.8.1/go.mod h1:WWAk1Mc6EgWarCrPFO+xeYlujPu98VuLW3Tu+B/85AE= 29 + github.com/bluesky-social/indigo v0.0.0-20250301025210-a4e0cc37e188 h1:1sQaG37xk08/rpmdhrmMkfQWF9kZbnfHm9Zav3bbSMk= 30 + github.com/bluesky-social/indigo v0.0.0-20250301025210-a4e0cc37e188/go.mod h1:NVBwZvbBSa93kfyweAmKwOLYawdVHdwZ9s+GZtBBVLA= 31 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 h1:CFvRtYNSnWRAi/98M3O466t9dYuwtesNbu6FVPymRrA= 32 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1/go.mod h1:WiYEeyJSdUwqoaZ71KJSpTblemUCpwJfh5oVXplK6T4= 33 github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= ··· 52 github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= 53 github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= 54 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 55 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 56 + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 57 + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 58 + github.com/decred/dcrd/crypto/blake256 v1.0.1/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= 59 + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 h1:8UrgZ3GkP4i/CLijOJx79Yu+etlyjdBU4sfcs2WYQMs= 60 + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0= 61 github.com/dgraph-io/ristretto v0.2.0 h1:XAfl+7cmoUDWW/2Lx8TGZQjjxIQ2Ley9DSf52dru4WE= 62 github.com/dgraph-io/ristretto v0.2.0/go.mod h1:8uBHCU/PBV4Ag0CJrP47b9Ofby5dqWNh4FicAdoqFNU= 63 github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 h1:fAjc9m62+UWV/WAFKLNi6ZS0675eEUC9y3AlwSbQu1Y= ··· 86 github.com/go-git/go-git/v5 v5.6.1 h1:q4ZRqQl4pR/ZJHc1L5CFjGA1a10u76aV1iC+nh+bHsk= 87 github.com/go-git/go-git/v5 v5.6.1/go.mod h1:mvyoL6Unz0PiTQrGQfSfiLFhBH1c1e84ylC2MDs4ee8= 88 github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 89 + github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= 90 + github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 91 github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 92 github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 93 github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0= ··· 95 github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= 96 github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 97 github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 98 + github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= 99 + github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= 100 github.com/golang/mock v1.4.4 h1:l75CXGRSwbaYNpl/Z2X1XIIAMSCquvXgpVZDhwEIJsc= 101 github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= 102 github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= ··· 117 github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= 118 github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= 119 github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= 120 + github.com/haileyok/atproto-oauth-golang v0.0.2 h1:61KPkLB615LQXR2f5x1v3sf6vPe6dOXqNpTYCgZ0Fz8= 121 + github.com/haileyok/atproto-oauth-golang v0.0.2/go.mod h1:jcZ4GCjo5I5RuE/RsAXg1/b6udw7R4W+2rb/cGyTDK8= 122 github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= 123 github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= 124 github.com/hashicorp/go-hclog v0.9.2 h1:CG6TE5H9/JXsFWJCfoIVpKFIkFe6ysEuHirp4DxCsHI= ··· 138 github.com/ipfs/bbloom v0.0.4/go.mod h1:cS9YprKXpoZ9lT0n/Mw/a6/aFV6DTjTLYHeA+gyqMG0= 139 github.com/ipfs/go-block-format v0.2.0 h1:ZqrkxBA2ICbDRbK8KJs/u0O3dlp6gmAuuXUJNiW1Ycs= 140 github.com/ipfs/go-block-format v0.2.0/go.mod h1:+jpL11nFx5A/SPpsoBn6Bzkra/zaArfSmsknbPMYgzM= 141 + github.com/ipfs/go-cid v0.5.0 h1:goEKKhaGm0ul11IHA7I6p1GmKz8kEYniqFopaB5Otwg= 142 + github.com/ipfs/go-cid v0.5.0/go.mod h1:0L7vmeNXpQpUS9vt+yEARkJ8rOg43DF3iPgn4GIN0mk= 143 github.com/ipfs/go-datastore v0.6.0 h1:JKyz+Gvz1QEZw0LsX1IBn+JFCJQH4SJVFtM4uWU0Myk= 144 github.com/ipfs/go-datastore v0.6.0/go.mod h1:rt5M3nNbSO/8q1t4LNkLyUwRs8HupMeN/8O4Vn9YAT8= 145 github.com/ipfs/go-detect-race v0.0.1 h1:qX/xay2W3E4Q1U7d9lNs1sU9nvguX0a7319XbyQ6cOk= ··· 167 github.com/jbenet/goprocess v0.1.4 h1:DRGOFReOMqqDNXwW70QkacFW0YN9QnwLV0Vqk+3oU0o= 168 github.com/jbenet/goprocess v0.1.4/go.mod h1:5yspPrukOVuOLORacaBi858NqyClJPQxYZlqdZVfqY4= 169 github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4= 170 + github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 171 + github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 172 github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= 173 github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= 174 github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= ··· 187 github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 188 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 189 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 190 + github.com/lestrrat-go/blackmagic v1.0.1/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU= 191 + github.com/lestrrat-go/blackmagic v1.0.2 h1:Cg2gVSc9h7sz9NOByczrbUvLopQmXrfFx//N+AkAr5k= 192 + github.com/lestrrat-go/blackmagic v1.0.2/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU= 193 + github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= 194 + github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= 195 + github.com/lestrrat-go/httprc v1.0.4 h1:bAZymwoZQb+Oq8MEbyipag7iSq6YIga8Wj6GOiJGdI8= 196 + github.com/lestrrat-go/httprc v1.0.4/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo= 197 + github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI= 198 + github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4= 199 + github.com/lestrrat-go/jwx/v2 v2.0.12 h1:3d589+5w/b9b7S3DneICPW16AqTyYXB7VRjgluSDWeA= 200 + github.com/lestrrat-go/jwx/v2 v2.0.12/go.mod h1:Mq4KN1mM7bp+5z/W5HS8aCNs5RKZ911G/0y2qUjAQuQ= 201 + github.com/lestrrat-go/option v1.0.0/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= 202 + github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= 203 + github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= 204 github.com/matryer/is v1.2.0 h1:92UTHpy8CDwaJ08GqLDzhhuixiBUUD1p3AU6PHddz4A= 205 github.com/matryer/is v1.2.0/go.mod h1:2fLPjFQM9rhQ15aVEtbuwhJinnOqrmgXPNdZsdwlWXA= 206 github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= ··· 236 github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 237 github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 238 github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 239 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 240 + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 241 + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 242 github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f h1:VXTQfuJj9vKR4TCkEuWIckKvdHFeJH/huIFJ9/cXOB0= 243 github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f/go.mod h1:/zvteZs/GwLtCgZ4BL6CBsk9IKIlexP43ObX9AxTqTw= 244 github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE= ··· 252 github.com/resend/resend-go/v2 v2.15.0 h1:B6oMEPf8IEQwn2Ovx/9yymkESLDSeNfLFaNMw+mzHhE= 253 github.com/resend/resend-go/v2 v2.15.0/go.mod h1:3YCb8c8+pLiqhtRFXTyFwlLvfjQtluxOr9HEh2BwCkQ= 254 github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= 255 + github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= 256 + github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= 257 github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 258 + github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= 259 + github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= 260 github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= 261 github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= 262 github.com/sethvargo/go-envconfig v1.1.0 h1:cWZiJxeTm7AlCvzGXrEXaSTCNgip5oJepekh/BOQuog= ··· 273 github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= 274 github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= 275 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 276 + github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 277 + github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 278 github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 279 github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 280 github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 281 + github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 282 github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 283 + github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 284 + github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 285 + github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 286 github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 287 github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 288 github.com/urfave/cli v1.22.10/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= ··· 303 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02/go.mod h1:JTnUj0mpYiAsuZLmKjTx/ex3AtMowcCgnE7YNyCEP0I= 304 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 h1:aFJWCqJMNjENlcleuuOkGAPH82y0yULBScfXcIEdS24= 305 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1/go.mod h1:sEGXWArGqc3tVa+ekntsN65DmVbVeW+7lTKTjZF3/Fo= 306 + go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw= 307 + go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8= 308 + go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc= 309 + go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8= 310 + go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4= 311 + go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ= 312 go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= 313 go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= 314 go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= ··· 336 golang.org/x/crypto v0.0.0-20220826181053-bd7e27e6170d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= 337 golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw= 338 golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= 339 + golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= 340 + golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= 341 + golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= 342 golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= 343 golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= 344 golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= ··· 348 golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 349 golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 350 golang.org/x/mod v0.6.0/go.mod h1:4mET923SAdbXp2ki8ey+zGs1SLqsuM2Y0uvdZR/fUNI= 351 + golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 352 golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 353 golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 354 golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= ··· 362 golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= 363 golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 364 golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 365 + golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= 366 + golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= 367 + golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= 368 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 369 golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 370 golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 371 golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 372 golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 373 + golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 374 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 375 golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 376 golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= ··· 385 golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 386 golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 387 golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 388 + golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 389 golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 390 golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 391 golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= ··· 395 golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 396 golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 397 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 398 + golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 399 + golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 400 + golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= 401 + golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 402 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 403 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 404 golang.org/x/term v0.0.0-20220722155259-a9ba230a4035/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 405 golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 406 golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 407 + golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= 408 + golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU= 409 + golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= 410 + golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= 411 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 412 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 413 golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 414 golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 415 golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 416 golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 417 + golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 418 + golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= 419 + golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= 420 + golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= 421 + golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg= 422 + golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 423 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 424 golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 425 golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= ··· 433 golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= 434 golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 435 golang.org/x/tools v0.2.0/go.mod h1:y4OqIKeOV/fWJetJ8bXPU1sEVniLMIyDAZWeHdV+NTA= 436 + golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 437 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 438 golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 439 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+715 -87
input.css
··· 3 @tailwind utilities; 4 @layer base { 5 @font-face { 6 - font-family: "iA Writer Quattro S"; 7 - src: url("/static/fonts/iAWriterQuattroS-Regular.ttf") 8 - format("truetype"); 9 font-weight: normal; 10 font-style: normal; 11 font-display: swap; 12 - font-feature-settings: 13 - "calt" 1, 14 - "kern" 1; 15 } 16 - @font-face { 17 - font-family: "iA Writer Quattro S"; 18 - src: url("/static/fonts/iAWriterQuattroS-Bold.ttf") format("truetype"); 19 - font-weight: bold; 20 - font-style: normal; 21 - font-display: swap; 22 - font-feature-settings: 23 - "calt" 1, 24 - "kern" 1; 25 - } 26 @font-face { 27 - font-family: "iA Writer Quattro S"; 28 - src: url("/static/fonts/iAWriterQuattroS-Italic.ttf") format("truetype"); 29 - font-weight: normal; 30 font-style: italic; 31 font-display: swap; 32 - font-feature-settings: 33 - "calt" 1, 34 - "kern" 1; 35 - } 36 - @font-face { 37 - font-family: "iA Writer Quattro S"; 38 - src: url("/static/fonts/iAWriterQuattroS-BoldItalic.ttf") 39 - format("truetype"); 40 - font-weight: bold; 41 - font-style: italic; 42 - font-display: swap; 43 - font-feature-settings: 44 - "calt" 1, 45 - "kern" 1; 46 } 47 48 @font-face { 49 - font-family: "iA Writer Mono S"; 50 - src: url("/static/fonts/iAWriterMonoS-Regular.ttf") format("truetype"); 51 - font-weight: normal; 52 font-style: normal; 53 font-display: swap; 54 - font-feature-settings: 55 - "calt" 1, 56 - "kern" 1; 57 } 58 @font-face { 59 - font-family: "iA Writer Mono S"; 60 - src: url("/static/fonts/iAWriterMonoS-Bold.ttf") format("truetype"); 61 - font-weight: bold; 62 - font-style: normal; 63 - font-display: swap; 64 - font-feature-settings: 65 - "calt" 1, 66 - "kern" 1; 67 - } 68 - @font-face { 69 - font-family: "iA Writer Mono S"; 70 - src: url("/static/fonts/iAWriterMonoS-Italic.ttf") format("truetype"); 71 font-weight: normal; 72 font-style: italic; 73 font-display: swap; 74 - font-feature-settings: 75 - "calt" 1, 76 - "kern" 1; 77 - } 78 - @font-face { 79 - font-family: "iA Writer Mono S"; 80 - src: url("/static/fonts/iAWriterMonoS-BoldItalic.ttf") 81 - format("truetype"); 82 - font-weight: bold; 83 - font-style: italic; 84 - font-display: swap; 85 - font-feature-settings: 86 - "calt" 1, 87 - "kern" 1; 88 - } 89 - 90 - @font-face { 91 - font-family: "Inter"; 92 - font-style: normal; 93 - font-weight: 400; 94 - font-display: swap; 95 - font-feature-settings: 96 - "calt" 1, 97 - "kern" 1; 98 } 99 100 ::selection { 101 - @apply bg-yellow-400; 102 - @apply text-black; 103 - @apply bg-opacity-30; 104 } 105 106 @layer base { 107 html { 108 - letter-spacing: -0.01em; 109 - word-spacing: -0.07em; 110 font-size: 14px; 111 } 112 a { 113 - @apply no-underline text-black hover:underline hover:text-gray-800; 114 } 115 116 label { 117 - @apply block mb-2 text-gray-900 text-sm font-bold py-2 uppercase; 118 } 119 input { 120 - @apply bg-white border border-gray-400 rounded-sm focus:ring-black p-3; 121 } 122 textarea { 123 - @apply bg-white border border-gray-400 rounded-sm focus:ring-black p-3; 124 } 125 details summary::-webkit-details-marker { 126 display: none; ··· 141 focus-visible:before:outline-4 focus-visible:before:outline-gray-500 142 active:before:shadow-[inset_0_2px_2px_0_rgba(20,20,96,0.1)] 143 disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:before:border-gray-200 144 - disabled:hover:before:bg-white disabled:hover:before:shadow-none; 145 } 146 } 147 @layer utilities { 148 .error { 149 - @apply py-1 text-red-400; 150 } 151 .success { 152 - @apply py-1 text-gray-900; 153 } 154 } 155 }
··· 3 @tailwind utilities; 4 @layer base { 5 @font-face { 6 + font-family: "InterVariable"; 7 + src: url("/static/fonts/InterVariable.woff2") format("woff2"); 8 font-weight: normal; 9 font-style: normal; 10 font-display: swap; 11 } 12 + 13 @font-face { 14 + font-family: "InterVariable"; 15 + src: url("/static/fonts/InterVariable-Italic.woff2") format("woff2"); 16 + font-weight: 400; 17 font-style: italic; 18 font-display: swap; 19 } 20 21 @font-face { 22 + font-family: "InterVariable"; 23 + src: url("/static/fonts/InterVariable.woff2") format("woff2"); 24 + font-weight: 600; 25 font-style: normal; 26 font-display: swap; 27 } 28 + 29 @font-face { 30 + font-family: "IBMPlexMono"; 31 + src: url("/static/fonts/IBMPlexMono-Regular.woff2") format("woff2"); 32 font-weight: normal; 33 font-style: italic; 34 font-display: swap; 35 } 36 37 ::selection { 38 + @apply bg-yellow-400 text-black bg-opacity-30 dark:bg-yellow-600 dark:bg-opacity-50 dark:text-white; 39 } 40 41 @layer base { 42 html { 43 font-size: 14px; 44 } 45 + @supports (font-variation-settings: normal) { 46 + html { 47 + font-feature-settings: 48 + "ss01" 1, 49 + "kern" 1, 50 + "liga" 1, 51 + "cv05" 1, 52 + "tnum" 1; 53 + } 54 + } 55 + 56 a { 57 + @apply no-underline text-black hover:underline hover:text-gray-800 dark:text-white dark:hover:text-gray-300; 58 } 59 60 label { 61 + @apply block mb-2 text-gray-900 text-sm font-bold py-2 uppercase dark:text-gray-100; 62 } 63 input { 64 + @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; 65 } 66 textarea { 67 + @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; 68 } 69 details summary::-webkit-details-marker { 70 display: none; ··· 85 focus-visible:before:outline-4 focus-visible:before:outline-gray-500 86 active:before:shadow-[inset_0_2px_2px_0_rgba(20,20,96,0.1)] 87 disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:before:border-gray-200 88 + disabled:hover:before:bg-white disabled:hover:before:shadow-none 89 + dark:text-gray-100 dark:before:bg-gray-800 dark:before:border-gray-700 90 + dark:hover:before:border-gray-600 dark:hover:before:bg-gray-700 91 + dark:hover:before:shadow-[0_2px_2px_0_rgba(0,0,0,0.2),inset_0_-2px_0_0_#2d3748] 92 + dark:focus-visible:before:outline-gray-400 93 + dark:active:before:shadow-[inset_0_2px_2px_0_rgba(0,0,0,0.3)] 94 + dark:disabled:hover:before:bg-gray-800 dark:disabled:hover:before:border-gray-700; 95 } 96 } 97 @layer utilities { 98 .error { 99 + @apply py-1 text-red-400 dark:text-red-300; 100 } 101 .success { 102 + @apply py-1 text-gray-900 dark:text-gray-100; 103 } 104 } 105 } 106 + 107 + /* Background */ 108 + .bg { 109 + color: #4c4f69; 110 + background-color: #eff1f5; 111 + } 112 + /* PreWrapper */ 113 + .chroma { 114 + color: #4c4f69; 115 + background-color: #eff1f5; 116 + } 117 + /* Error */ 118 + .chroma .err { 119 + color: #d20f39; 120 + } 121 + /* LineLink */ 122 + .chroma .lnlinks { 123 + outline: none; 124 + text-decoration: none; 125 + color: inherit; 126 + } 127 + /* LineTableTD */ 128 + .chroma .lntd { 129 + vertical-align: top; 130 + padding: 0; 131 + margin: 0; 132 + border: 0; 133 + } 134 + /* LineTable */ 135 + .chroma .lntable { 136 + border-spacing: 0; 137 + padding: 0; 138 + margin: 0; 139 + border: 0; 140 + } 141 + /* LineHighlight */ 142 + .chroma .hl { 143 + background-color: #bcc0cc; 144 + } 145 + /* LineNumbersTable */ 146 + .chroma .lnt { 147 + white-space: pre; 148 + -webkit-user-select: none; 149 + user-select: none; 150 + margin-right: 0.4em; 151 + padding: 0 0.4em 0 0.4em; 152 + color: #8c8fa1; 153 + } 154 + /* LineNumbers */ 155 + .chroma .ln { 156 + white-space: pre; 157 + -webkit-user-select: none; 158 + user-select: none; 159 + margin-right: 0.4em; 160 + padding: 0 0.4em 0 0.4em; 161 + color: #8c8fa1; 162 + } 163 + /* Line */ 164 + .chroma .line { 165 + display: flex; 166 + } 167 + /* Keyword */ 168 + .chroma .k { 169 + color: #8839ef; 170 + } 171 + /* KeywordConstant */ 172 + .chroma .kc { 173 + color: #fe640b; 174 + } 175 + /* KeywordDeclaration */ 176 + .chroma .kd { 177 + color: #d20f39; 178 + } 179 + /* KeywordNamespace */ 180 + .chroma .kn { 181 + color: #179299; 182 + } 183 + /* KeywordPseudo */ 184 + .chroma .kp { 185 + color: #8839ef; 186 + } 187 + /* KeywordReserved */ 188 + .chroma .kr { 189 + color: #8839ef; 190 + } 191 + /* KeywordType */ 192 + .chroma .kt { 193 + color: #d20f39; 194 + } 195 + /* NameAttribute */ 196 + .chroma .na { 197 + color: #1e66f5; 198 + } 199 + /* NameBuiltin */ 200 + .chroma .nb { 201 + color: #04a5e5; 202 + } 203 + /* NameBuiltinPseudo */ 204 + .chroma .bp { 205 + color: #04a5e5; 206 + } 207 + /* NameClass */ 208 + .chroma .nc { 209 + color: #df8e1d; 210 + } 211 + /* NameConstant */ 212 + .chroma .no { 213 + color: #df8e1d; 214 + } 215 + /* NameDecorator */ 216 + .chroma .nd { 217 + color: #1e66f5; 218 + font-weight: bold; 219 + } 220 + /* NameEntity */ 221 + .chroma .ni { 222 + color: #179299; 223 + } 224 + /* NameException */ 225 + .chroma .ne { 226 + color: #fe640b; 227 + } 228 + /* NameFunction */ 229 + .chroma .nf { 230 + color: #1e66f5; 231 + } 232 + /* NameFunctionMagic */ 233 + .chroma .fm { 234 + color: #1e66f5; 235 + } 236 + /* NameLabel */ 237 + .chroma .nl { 238 + color: #04a5e5; 239 + } 240 + /* NameNamespace */ 241 + .chroma .nn { 242 + color: #fe640b; 243 + } 244 + /* NameProperty */ 245 + .chroma .py { 246 + color: #fe640b; 247 + } 248 + /* NameTag */ 249 + .chroma .nt { 250 + color: #8839ef; 251 + } 252 + /* NameVariable */ 253 + .chroma .nv { 254 + color: #dc8a78; 255 + } 256 + /* NameVariableClass */ 257 + .chroma .vc { 258 + color: #dc8a78; 259 + } 260 + /* NameVariableGlobal */ 261 + .chroma .vg { 262 + color: #dc8a78; 263 + } 264 + /* NameVariableInstance */ 265 + .chroma .vi { 266 + color: #dc8a78; 267 + } 268 + /* NameVariableMagic */ 269 + .chroma .vm { 270 + color: #dc8a78; 271 + } 272 + /* LiteralString */ 273 + .chroma .s { 274 + color: #40a02b; 275 + } 276 + /* LiteralStringAffix */ 277 + .chroma .sa { 278 + color: #d20f39; 279 + } 280 + /* LiteralStringBacktick */ 281 + .chroma .sb { 282 + color: #40a02b; 283 + } 284 + /* LiteralStringChar */ 285 + .chroma .sc { 286 + color: #40a02b; 287 + } 288 + /* LiteralStringDelimiter */ 289 + .chroma .dl { 290 + color: #1e66f5; 291 + } 292 + /* LiteralStringDoc */ 293 + .chroma .sd { 294 + color: #9ca0b0; 295 + } 296 + /* LiteralStringDouble */ 297 + .chroma .s2 { 298 + color: #40a02b; 299 + } 300 + /* LiteralStringEscape */ 301 + .chroma .se { 302 + color: #1e66f5; 303 + } 304 + /* LiteralStringHeredoc */ 305 + .chroma .sh { 306 + color: #9ca0b0; 307 + } 308 + /* LiteralStringInterpol */ 309 + .chroma .si { 310 + color: #40a02b; 311 + } 312 + /* LiteralStringOther */ 313 + .chroma .sx { 314 + color: #40a02b; 315 + } 316 + /* LiteralStringRegex */ 317 + .chroma .sr { 318 + color: #179299; 319 + } 320 + /* LiteralStringSingle */ 321 + .chroma .s1 { 322 + color: #40a02b; 323 + } 324 + /* LiteralStringSymbol */ 325 + .chroma .ss { 326 + color: #40a02b; 327 + } 328 + /* LiteralNumber */ 329 + .chroma .m { 330 + color: #fe640b; 331 + } 332 + /* LiteralNumberBin */ 333 + .chroma .mb { 334 + color: #fe640b; 335 + } 336 + /* LiteralNumberFloat */ 337 + .chroma .mf { 338 + color: #fe640b; 339 + } 340 + /* LiteralNumberHex */ 341 + .chroma .mh { 342 + color: #fe640b; 343 + } 344 + /* LiteralNumberInteger */ 345 + .chroma .mi { 346 + color: #fe640b; 347 + } 348 + /* LiteralNumberIntegerLong */ 349 + .chroma .il { 350 + color: #fe640b; 351 + } 352 + /* LiteralNumberOct */ 353 + .chroma .mo { 354 + color: #fe640b; 355 + } 356 + /* Operator */ 357 + .chroma .o { 358 + color: #04a5e5; 359 + font-weight: bold; 360 + } 361 + /* OperatorWord */ 362 + .chroma .ow { 363 + color: #04a5e5; 364 + font-weight: bold; 365 + } 366 + /* Comment */ 367 + .chroma .c { 368 + color: #9ca0b0; 369 + font-style: italic; 370 + } 371 + /* CommentHashbang */ 372 + .chroma .ch { 373 + color: #9ca0b0; 374 + font-style: italic; 375 + } 376 + /* CommentMultiline */ 377 + .chroma .cm { 378 + color: #9ca0b0; 379 + font-style: italic; 380 + } 381 + /* CommentSingle */ 382 + .chroma .c1 { 383 + color: #9ca0b0; 384 + font-style: italic; 385 + } 386 + /* CommentSpecial */ 387 + .chroma .cs { 388 + color: #9ca0b0; 389 + font-style: italic; 390 + } 391 + /* CommentPreproc */ 392 + .chroma .cp { 393 + color: #9ca0b0; 394 + font-style: italic; 395 + } 396 + /* CommentPreprocFile */ 397 + .chroma .cpf { 398 + color: #9ca0b0; 399 + font-weight: bold; 400 + font-style: italic; 401 + } 402 + /* GenericDeleted */ 403 + .chroma .gd { 404 + color: #d20f39; 405 + background-color: oklch(93.6% 0.032 17.717); 406 + } 407 + /* GenericEmph */ 408 + .chroma .ge { 409 + font-style: italic; 410 + } 411 + /* GenericError */ 412 + .chroma .gr { 413 + color: #d20f39; 414 + } 415 + /* GenericHeading */ 416 + .chroma .gh { 417 + color: #fe640b; 418 + font-weight: bold; 419 + } 420 + /* GenericInserted */ 421 + .chroma .gi { 422 + color: #40a02b; 423 + background-color: oklch(96.2% 0.044 156.743); 424 + } 425 + /* GenericStrong */ 426 + .chroma .gs { 427 + font-weight: bold; 428 + } 429 + /* GenericSubheading */ 430 + .chroma .gu { 431 + color: #fe640b; 432 + font-weight: bold; 433 + } 434 + /* GenericTraceback */ 435 + .chroma .gt { 436 + color: #d20f39; 437 + } 438 + /* GenericUnderline */ 439 + .chroma .gl { 440 + text-decoration: underline; 441 + } 442 + 443 + @media (prefers-color-scheme: dark) { 444 + /* Background */ 445 + .bg { 446 + color: #cad3f5; 447 + background-color: #24273a; 448 + } 449 + /* PreWrapper */ 450 + .chroma { 451 + color: #cad3f5; 452 + background-color: #24273a; 453 + } 454 + /* Error */ 455 + .chroma .err { 456 + color: #ed8796; 457 + } 458 + /* LineLink */ 459 + .chroma .lnlinks { 460 + outline: none; 461 + text-decoration: none; 462 + color: inherit; 463 + } 464 + /* LineTableTD */ 465 + .chroma .lntd { 466 + vertical-align: top; 467 + padding: 0; 468 + margin: 0; 469 + border: 0; 470 + } 471 + /* LineTable */ 472 + .chroma .lntable { 473 + border-spacing: 0; 474 + padding: 0; 475 + margin: 0; 476 + border: 0; 477 + } 478 + /* LineHighlight */ 479 + .chroma .hl { 480 + background-color: #494d64; 481 + } 482 + /* LineNumbersTable */ 483 + .chroma .lnt { 484 + white-space: pre; 485 + -webkit-user-select: none; 486 + user-select: none; 487 + margin-right: 0.4em; 488 + padding: 0 0.4em 0 0.4em; 489 + color: #8087a2; 490 + } 491 + /* LineNumbers */ 492 + .chroma .ln { 493 + white-space: pre; 494 + -webkit-user-select: none; 495 + user-select: none; 496 + margin-right: 0.4em; 497 + padding: 0 0.4em 0 0.4em; 498 + color: #8087a2; 499 + } 500 + /* Line */ 501 + .chroma .line { 502 + display: flex; 503 + } 504 + /* Keyword */ 505 + .chroma .k { 506 + color: #c6a0f6; 507 + } 508 + /* KeywordConstant */ 509 + .chroma .kc { 510 + color: #f5a97f; 511 + } 512 + /* KeywordDeclaration */ 513 + .chroma .kd { 514 + color: #ed8796; 515 + } 516 + /* KeywordNamespace */ 517 + .chroma .kn { 518 + color: #8bd5ca; 519 + } 520 + /* KeywordPseudo */ 521 + .chroma .kp { 522 + color: #c6a0f6; 523 + } 524 + /* KeywordReserved */ 525 + .chroma .kr { 526 + color: #c6a0f6; 527 + } 528 + /* KeywordType */ 529 + .chroma .kt { 530 + color: #ed8796; 531 + } 532 + /* NameAttribute */ 533 + .chroma .na { 534 + color: #8aadf4; 535 + } 536 + /* NameBuiltin */ 537 + .chroma .nb { 538 + color: #91d7e3; 539 + } 540 + /* NameBuiltinPseudo */ 541 + .chroma .bp { 542 + color: #91d7e3; 543 + } 544 + /* NameClass */ 545 + .chroma .nc { 546 + color: #eed49f; 547 + } 548 + /* NameConstant */ 549 + .chroma .no { 550 + color: #eed49f; 551 + } 552 + /* NameDecorator */ 553 + .chroma .nd { 554 + color: #8aadf4; 555 + font-weight: bold; 556 + } 557 + /* NameEntity */ 558 + .chroma .ni { 559 + color: #8bd5ca; 560 + } 561 + /* NameException */ 562 + .chroma .ne { 563 + color: #f5a97f; 564 + } 565 + /* NameFunction */ 566 + .chroma .nf { 567 + color: #8aadf4; 568 + } 569 + /* NameFunctionMagic */ 570 + .chroma .fm { 571 + color: #8aadf4; 572 + } 573 + /* NameLabel */ 574 + .chroma .nl { 575 + color: #91d7e3; 576 + } 577 + /* NameNamespace */ 578 + .chroma .nn { 579 + color: #f5a97f; 580 + } 581 + /* NameProperty */ 582 + .chroma .py { 583 + color: #f5a97f; 584 + } 585 + /* NameTag */ 586 + .chroma .nt { 587 + color: #c6a0f6; 588 + } 589 + /* NameVariable */ 590 + .chroma .nv { 591 + color: #f4dbd6; 592 + } 593 + /* NameVariableClass */ 594 + .chroma .vc { 595 + color: #f4dbd6; 596 + } 597 + /* NameVariableGlobal */ 598 + .chroma .vg { 599 + color: #f4dbd6; 600 + } 601 + /* NameVariableInstance */ 602 + .chroma .vi { 603 + color: #f4dbd6; 604 + } 605 + /* NameVariableMagic */ 606 + .chroma .vm { 607 + color: #f4dbd6; 608 + } 609 + /* LiteralString */ 610 + .chroma .s { 611 + color: #a6da95; 612 + } 613 + /* LiteralStringAffix */ 614 + .chroma .sa { 615 + color: #ed8796; 616 + } 617 + /* LiteralStringBacktick */ 618 + .chroma .sb { 619 + color: #a6da95; 620 + } 621 + /* LiteralStringChar */ 622 + .chroma .sc { 623 + color: #a6da95; 624 + } 625 + /* LiteralStringDelimiter */ 626 + .chroma .dl { 627 + color: #8aadf4; 628 + } 629 + /* LiteralStringDoc */ 630 + .chroma .sd { 631 + color: #6e738d; 632 + } 633 + /* LiteralStringDouble */ 634 + .chroma .s2 { 635 + color: #a6da95; 636 + } 637 + /* LiteralStringEscape */ 638 + .chroma .se { 639 + color: #8aadf4; 640 + } 641 + /* LiteralStringHeredoc */ 642 + .chroma .sh { 643 + color: #6e738d; 644 + } 645 + /* LiteralStringInterpol */ 646 + .chroma .si { 647 + color: #a6da95; 648 + } 649 + /* LiteralStringOther */ 650 + .chroma .sx { 651 + color: #a6da95; 652 + } 653 + /* LiteralStringRegex */ 654 + .chroma .sr { 655 + color: #8bd5ca; 656 + } 657 + /* LiteralStringSingle */ 658 + .chroma .s1 { 659 + color: #a6da95; 660 + } 661 + /* LiteralStringSymbol */ 662 + .chroma .ss { 663 + color: #a6da95; 664 + } 665 + /* LiteralNumber */ 666 + .chroma .m { 667 + color: #f5a97f; 668 + } 669 + /* LiteralNumberBin */ 670 + .chroma .mb { 671 + color: #f5a97f; 672 + } 673 + /* LiteralNumberFloat */ 674 + .chroma .mf { 675 + color: #f5a97f; 676 + } 677 + /* LiteralNumberHex */ 678 + .chroma .mh { 679 + color: #f5a97f; 680 + } 681 + /* LiteralNumberInteger */ 682 + .chroma .mi { 683 + color: #f5a97f; 684 + } 685 + /* LiteralNumberIntegerLong */ 686 + .chroma .il { 687 + color: #f5a97f; 688 + } 689 + /* LiteralNumberOct */ 690 + .chroma .mo { 691 + color: #f5a97f; 692 + } 693 + /* Operator */ 694 + .chroma .o { 695 + color: #91d7e3; 696 + font-weight: bold; 697 + } 698 + /* OperatorWord */ 699 + .chroma .ow { 700 + color: #91d7e3; 701 + font-weight: bold; 702 + } 703 + /* Comment */ 704 + .chroma .c { 705 + color: #6e738d; 706 + font-style: italic; 707 + } 708 + /* CommentHashbang */ 709 + .chroma .ch { 710 + color: #6e738d; 711 + font-style: italic; 712 + } 713 + /* CommentMultiline */ 714 + .chroma .cm { 715 + color: #6e738d; 716 + font-style: italic; 717 + } 718 + /* CommentSingle */ 719 + .chroma .c1 { 720 + color: #6e738d; 721 + font-style: italic; 722 + } 723 + /* CommentSpecial */ 724 + .chroma .cs { 725 + color: #6e738d; 726 + font-style: italic; 727 + } 728 + /* CommentPreproc */ 729 + .chroma .cp { 730 + color: #6e738d; 731 + font-style: italic; 732 + } 733 + /* CommentPreprocFile */ 734 + .chroma .cpf { 735 + color: #6e738d; 736 + font-weight: bold; 737 + font-style: italic; 738 + } 739 + /* GenericDeleted */ 740 + .chroma .gd { 741 + color: #ed8796; 742 + background-color: oklch(44.4% 0.177 26.899 / 0.5); 743 + } 744 + /* GenericEmph */ 745 + .chroma .ge { 746 + font-style: italic; 747 + } 748 + /* GenericError */ 749 + .chroma .gr { 750 + color: #ed8796; 751 + } 752 + /* GenericHeading */ 753 + .chroma .gh { 754 + color: #f5a97f; 755 + font-weight: bold; 756 + } 757 + /* GenericInserted */ 758 + .chroma .gi { 759 + color: #a6da95; 760 + background-color: oklch(44.8% 0.119 151.328 / 0.5); 761 + } 762 + /* GenericStrong */ 763 + .chroma .gs { 764 + font-weight: bold; 765 + } 766 + /* GenericSubheading */ 767 + .chroma .gu { 768 + color: #f5a97f; 769 + font-weight: bold; 770 + } 771 + /* GenericTraceback */ 772 + .chroma .gt { 773 + color: #ed8796; 774 + } 775 + /* GenericUnderline */ 776 + .chroma .gl { 777 + text-decoration: underline; 778 + } 779 + } 780 + 781 + .chroma .line:has(.ln:target) { 782 + @apply bg-amber-400/30 dark:bg-amber-500/20; 783 + }
+91 -24
jetstream/jetstream.go
··· 4 "context" 5 "fmt" 6 "log/slog" 7 "sync" 8 "time" 9 10 "github.com/bluesky-social/jetstream/pkg/client" ··· 16 type DB interface { 17 GetLastTimeUs() (int64, error) 18 SaveLastTimeUs(int64) error 19 - UpdateLastTimeUs(int64) error 20 } 21 22 type JetstreamClient struct { 23 cfg *client.ClientConfig 24 client *client.Client 25 ident string 26 l *slog.Logger 27 28 db DB 29 waitForDid bool 30 mu sync.RWMutex ··· 37 if did == "" { 38 return 39 } 40 j.mu.Lock() 41 - j.cfg.WantedDids = append(j.cfg.WantedDids, did) 42 j.mu.Unlock() 43 } 44 45 - func (j *JetstreamClient) UpdateDids(dids []string) { 46 - j.mu.Lock() 47 - for _, did := range dids { 48 - if did != "" { 49 - j.cfg.WantedDids = append(j.cfg.WantedDids, did) 50 - } 51 - } 52 - j.mu.Unlock() 53 54 - j.cancelMu.Lock() 55 - if j.cancel != nil { 56 - j.cancel() 57 } 58 - j.cancelMu.Unlock() 59 } 60 61 func NewJetstreamClient(endpoint, ident string, collections []string, cfg *client.ClientConfig, logger *slog.Logger, db DB, waitForDid bool) (*JetstreamClient, error) { ··· 66 } 67 68 return &JetstreamClient{ 69 - cfg: cfg, 70 - ident: ident, 71 - db: db, 72 - l: logger, 73 74 // This will make the goroutine in StartJetstream wait until 75 - // cfg.WantedDids has been populated, typically using UpdateDids. 76 waitForDid: waitForDid, 77 }, nil 78 } 79 80 // StartJetstream starts the jetstream client and processes events using the provided processFunc. 81 - // The caller is responsible for saving the last time_us to the database (just use your db.SaveLastTimeUs). 82 func (j *JetstreamClient) StartJetstream(ctx context.Context, processFunc func(context.Context, *models.Event) error) error { 83 logger := j.l 84 85 - sched := sequential.NewScheduler(j.ident, logger, processFunc) 86 87 client, err := client.NewClient(j.cfg, log.New("jetstream"), sched) 88 if err != nil { ··· 92 93 go func() { 94 if j.waitForDid { 95 - for len(j.cfg.WantedDids) == 0 { 96 time.Sleep(time.Second) 97 } 98 } 99 logger.Info("done waiting for did") 100 j.connectAndRead(ctx) 101 }() 102 ··· 130 } 131 } 132 133 func (j *JetstreamClient) getLastTimeUs(ctx context.Context) *int64 { 134 l := log.FromContext(ctx) 135 lastTimeUs, err := j.db.GetLastTimeUs() ··· 142 } 143 } 144 145 - // If last time is older than a week, start from now 146 if time.Now().UnixMicro()-lastTimeUs > 2*24*60*60*1000*1000 { 147 lastTimeUs = time.Now().UnixMicro() 148 l.Warn("last time us is older than 2 days; discarding that and starting from now") 149 - err = j.db.UpdateLastTimeUs(lastTimeUs) 150 if err != nil { 151 l.Error("failed to save last time us", "error", err) 152 } ··· 155 l.Info("found last time_us", "time_us", lastTimeUs) 156 return &lastTimeUs 157 }
··· 4 "context" 5 "fmt" 6 "log/slog" 7 + "os" 8 + "os/signal" 9 "sync" 10 + "syscall" 11 "time" 12 13 "github.com/bluesky-social/jetstream/pkg/client" ··· 19 type DB interface { 20 GetLastTimeUs() (int64, error) 21 SaveLastTimeUs(int64) error 22 } 23 24 + type Set[T comparable] map[T]struct{} 25 + 26 type JetstreamClient struct { 27 cfg *client.ClientConfig 28 client *client.Client 29 ident string 30 l *slog.Logger 31 32 + wantedDids Set[string] 33 db DB 34 waitForDid bool 35 mu sync.RWMutex ··· 42 if did == "" { 43 return 44 } 45 + 46 + j.l.Info("adding did to in-memory filter", "did", did) 47 j.mu.Lock() 48 + j.wantedDids[did] = struct{}{} 49 j.mu.Unlock() 50 } 51 52 + type processor func(context.Context, *models.Event) error 53 54 + func (j *JetstreamClient) withDidFilter(processFunc processor) processor { 55 + // empty filter => all dids allowed 56 + if len(j.wantedDids) == 0 { 57 + return processFunc 58 } 59 + // since this closure references j.WantedDids; it should auto-update 60 + // existing instances of the closure when j.WantedDids is mutated 61 + return func(ctx context.Context, evt *models.Event) error { 62 + if _, ok := j.wantedDids[evt.Did]; ok { 63 + return processFunc(ctx, evt) 64 + } else { 65 + return nil 66 + } 67 + } 68 } 69 70 func NewJetstreamClient(endpoint, ident string, collections []string, cfg *client.ClientConfig, logger *slog.Logger, db DB, waitForDid bool) (*JetstreamClient, error) { ··· 75 } 76 77 return &JetstreamClient{ 78 + cfg: cfg, 79 + ident: ident, 80 + db: db, 81 + l: logger, 82 + wantedDids: make(map[string]struct{}), 83 84 // This will make the goroutine in StartJetstream wait until 85 + // j.wantedDids has been populated, typically using addDids. 86 waitForDid: waitForDid, 87 }, nil 88 } 89 90 // StartJetstream starts the jetstream client and processes events using the provided processFunc. 91 + // The caller is responsible for saving the last time_us to the database (just use your db.UpdateLastTimeUs). 92 func (j *JetstreamClient) StartJetstream(ctx context.Context, processFunc func(context.Context, *models.Event) error) error { 93 logger := j.l 94 95 + sched := sequential.NewScheduler(j.ident, logger, j.withDidFilter(processFunc)) 96 97 client, err := client.NewClient(j.cfg, log.New("jetstream"), sched) 98 if err != nil { ··· 102 103 go func() { 104 if j.waitForDid { 105 + for len(j.wantedDids) == 0 { 106 time.Sleep(time.Second) 107 } 108 } 109 logger.Info("done waiting for did") 110 + 111 + go j.periodicLastTimeSave(ctx) 112 + j.saveIfKilled(ctx) 113 + 114 j.connectAndRead(ctx) 115 }() 116 ··· 144 } 145 } 146 147 + // save cursor periodically 148 + func (j *JetstreamClient) periodicLastTimeSave(ctx context.Context) { 149 + ticker := time.NewTicker(time.Minute) 150 + defer ticker.Stop() 151 + 152 + for { 153 + select { 154 + case <-ctx.Done(): 155 + return 156 + case <-ticker.C: 157 + j.db.SaveLastTimeUs(time.Now().UnixMicro()) 158 + } 159 + } 160 + } 161 + 162 func (j *JetstreamClient) getLastTimeUs(ctx context.Context) *int64 { 163 l := log.FromContext(ctx) 164 lastTimeUs, err := j.db.GetLastTimeUs() ··· 171 } 172 } 173 174 + // If last time is older than 2 days, start from now 175 if time.Now().UnixMicro()-lastTimeUs > 2*24*60*60*1000*1000 { 176 lastTimeUs = time.Now().UnixMicro() 177 l.Warn("last time us is older than 2 days; discarding that and starting from now") 178 + err = j.db.SaveLastTimeUs(lastTimeUs) 179 if err != nil { 180 l.Error("failed to save last time us", "error", err) 181 } ··· 184 l.Info("found last time_us", "time_us", lastTimeUs) 185 return &lastTimeUs 186 } 187 + 188 + func (j *JetstreamClient) saveIfKilled(ctx context.Context) context.Context { 189 + ctxWithCancel, cancel := context.WithCancel(ctx) 190 + 191 + sigChan := make(chan os.Signal, 1) 192 + 193 + signal.Notify(sigChan, 194 + syscall.SIGINT, 195 + syscall.SIGTERM, 196 + syscall.SIGQUIT, 197 + syscall.SIGHUP, 198 + syscall.SIGKILL, 199 + syscall.SIGSTOP, 200 + ) 201 + 202 + go func() { 203 + sig := <-sigChan 204 + j.l.Info("Received signal, initiating graceful shutdown", "signal", sig) 205 + 206 + lastTimeUs := time.Now().UnixMicro() 207 + if err := j.db.SaveLastTimeUs(lastTimeUs); err != nil { 208 + j.l.Error("Failed to save last time during shutdown", "error", err) 209 + } 210 + j.l.Info("Saved lastTimeUs before shutdown", "lastTimeUs", lastTimeUs) 211 + 212 + j.cancelMu.Lock() 213 + if j.cancel != nil { 214 + j.cancel() 215 + } 216 + j.cancelMu.Unlock() 217 + 218 + cancel() 219 + 220 + os.Exit(0) 221 + }() 222 + 223 + return ctxWithCancel 224 + }
+6 -10
knotserver/db/jetstream.go
··· 1 package db 2 3 func (d *DB) SaveLastTimeUs(lastTimeUs int64) error { 4 - _, err := d.db.Exec(`insert into _jetstream (last_time_us) values (?)`, lastTimeUs) 5 return err 6 } 7 8 - func (d *DB) UpdateLastTimeUs(lastTimeUs int64) error { 9 - _, err := d.db.Exec(`update _jetstream set last_time_us = ? where rowid = 1`, lastTimeUs) 10 - if err != nil { 11 - return err 12 - } 13 - return nil 14 - } 15 - 16 func (d *DB) GetLastTimeUs() (int64, error) { 17 var lastTimeUs int64 18 - row := d.db.QueryRow(`select last_time_us from _jetstream`) 19 err := row.Scan(&lastTimeUs) 20 return lastTimeUs, err 21 }
··· 1 package db 2 3 func (d *DB) SaveLastTimeUs(lastTimeUs int64) error { 4 + _, err := d.db.Exec(` 5 + insert into _jetstream (id, last_time_us) 6 + values (1, ?) 7 + on conflict(id) do update set last_time_us = excluded.last_time_us 8 + `, lastTimeUs) 9 return err 10 } 11 12 func (d *DB) GetLastTimeUs() (int64, error) { 13 var lastTimeUs int64 14 + row := d.db.QueryRow(`select last_time_us from _jetstream where id = 1;`) 15 err := row.Scan(&lastTimeUs) 16 return lastTimeUs, err 17 }
+11 -11
knotserver/db/pubkeys.go
··· 23 Did: did, 24 } 25 pk.Key = record["key"] 26 - pk.Created = record["created"] 27 28 return d.AddPublicKey(pk) 29 } 30 31 func (d *DB) AddPublicKey(pk PublicKey) error { 32 - if pk.Created == "" { 33 - pk.Created = time.Now().Format(time.RFC3339) 34 } 35 36 query := `insert or ignore into public_keys (did, key, created) values (?, ?, ?)` 37 - _, err := d.db.Exec(query, pk.Did, pk.Key, pk.Created) 38 return err 39 } 40 ··· 44 return err 45 } 46 47 - func (pk *PublicKey) JSON() map[string]interface{} { 48 - return map[string]interface{}{ 49 - "did": pk.Did, 50 - "key": pk.Key, 51 - "created": pk.Created, 52 } 53 } 54 ··· 63 64 for rows.Next() { 65 var publicKey PublicKey 66 - if err := rows.Scan(&publicKey.Key, &publicKey.Did, &publicKey.Created); err != nil { 67 return nil, err 68 } 69 keys = append(keys, publicKey) ··· 87 88 for rows.Next() { 89 var publicKey PublicKey 90 - if err := rows.Scan(&publicKey.Did, &publicKey.Key, &publicKey.Created); err != nil { 91 return nil, err 92 } 93 keys = append(keys, publicKey)
··· 23 Did: did, 24 } 25 pk.Key = record["key"] 26 + pk.CreatedAt = record["createdAt"] 27 28 return d.AddPublicKey(pk) 29 } 30 31 func (d *DB) AddPublicKey(pk PublicKey) error { 32 + if pk.CreatedAt == "" { 33 + pk.CreatedAt = time.Now().Format(time.RFC3339) 34 } 35 36 query := `insert or ignore into public_keys (did, key, created) values (?, ?, ?)` 37 + _, err := d.db.Exec(query, pk.Did, pk.Key, pk.CreatedAt) 38 return err 39 } 40 ··· 44 return err 45 } 46 47 + func (pk *PublicKey) JSON() map[string]any { 48 + return map[string]any{ 49 + "did": pk.Did, 50 + "key": pk.Key, 51 + "createdAt": pk.CreatedAt, 52 } 53 } 54 ··· 63 64 for rows.Next() { 65 var publicKey PublicKey 66 + if err := rows.Scan(&publicKey.Key, &publicKey.Did, &publicKey.CreatedAt); err != nil { 67 return nil, err 68 } 69 keys = append(keys, publicKey) ··· 87 88 for rows.Next() { 89 var publicKey PublicKey 90 + if err := rows.Scan(&publicKey.Did, &publicKey.Key, &publicKey.CreatedAt); err != nil { 91 return nil, err 92 } 93 keys = append(keys, publicKey)
+112 -10
knotserver/git/diff.go
··· 1 package git 2 3 import ( 4 "fmt" 5 "log" 6 "strings" 7 8 "github.com/bluekeyes/go-gitdiff/gitdiff" 9 "github.com/go-git/go-git/v5/plumbing/object" 10 "tangled.sh/tangled.sh/core/types" 11 ) 12 ··· 46 } 47 48 nd := types.NiceDiff{} 49 - nd.Commit.This = c.Hash.String() 50 - 51 - if parent.Hash.IsZero() { 52 - nd.Commit.Parent = "" 53 - } else { 54 - nd.Commit.Parent = parent.Hash.String() 55 - } 56 - nd.Commit.Author = c.Author 57 - nd.Commit.Message = c.Message 58 - 59 for _, d := range diffs { 60 ndiff := types.Diff{} 61 ndiff.Name.New = d.NewName ··· 82 } 83 84 nd.Stat.FilesChanged = len(diffs) 85 86 return &nd, nil 87 }
··· 1 package git 2 3 import ( 4 + "bytes" 5 "fmt" 6 "log" 7 + "os" 8 + "os/exec" 9 "strings" 10 11 "github.com/bluekeyes/go-gitdiff/gitdiff" 12 + "github.com/go-git/go-git/v5/plumbing" 13 "github.com/go-git/go-git/v5/plumbing/object" 14 + "tangled.sh/tangled.sh/core/patchutil" 15 "tangled.sh/tangled.sh/core/types" 16 ) 17 ··· 51 } 52 53 nd := types.NiceDiff{} 54 for _, d := range diffs { 55 ndiff := types.Diff{} 56 ndiff.Name.New = d.NewName ··· 77 } 78 79 nd.Stat.FilesChanged = len(diffs) 80 + nd.Commit.This = c.Hash.String() 81 + 82 + if parent.Hash.IsZero() { 83 + nd.Commit.Parent = "" 84 + } else { 85 + nd.Commit.Parent = parent.Hash.String() 86 + } 87 + nd.Commit.Author = c.Author 88 + nd.Commit.Message = c.Message 89 90 return &nd, nil 91 } 92 + 93 + func (g *GitRepo) DiffTree(commit1, commit2 *object.Commit) (*types.DiffTree, error) { 94 + tree1, err := commit1.Tree() 95 + if err != nil { 96 + return nil, err 97 + } 98 + 99 + tree2, err := commit2.Tree() 100 + if err != nil { 101 + return nil, err 102 + } 103 + 104 + diff, err := object.DiffTree(tree1, tree2) 105 + if err != nil { 106 + return nil, err 107 + } 108 + 109 + patch, err := diff.Patch() 110 + if err != nil { 111 + return nil, err 112 + } 113 + 114 + diffs, _, err := gitdiff.Parse(strings.NewReader(patch.String())) 115 + if err != nil { 116 + return nil, err 117 + } 118 + 119 + return &types.DiffTree{ 120 + Rev1: commit1.Hash.String(), 121 + Rev2: commit2.Hash.String(), 122 + Patch: patch.String(), 123 + Diff: diffs, 124 + }, nil 125 + } 126 + 127 + // FormatPatch generates a git-format-patch output between two commits, 128 + // and returns the raw format-patch series, a parsed FormatPatch and an error. 129 + func (g *GitRepo) FormatPatch(base, commit2 *object.Commit) (string, []patchutil.FormatPatch, error) { 130 + var stdout bytes.Buffer 131 + cmd := exec.Command( 132 + "git", 133 + "-C", 134 + g.path, 135 + "format-patch", 136 + fmt.Sprintf("%s..%s", base.Hash.String(), commit2.Hash.String()), 137 + "--stdout", 138 + ) 139 + cmd.Stdout = &stdout 140 + cmd.Stderr = os.Stderr 141 + err := cmd.Run() 142 + if err != nil { 143 + return "", nil, err 144 + } 145 + 146 + formatPatch, err := patchutil.ExtractPatches(stdout.String()) 147 + if err != nil { 148 + return "", nil, err 149 + } 150 + 151 + return stdout.String(), formatPatch, nil 152 + } 153 + 154 + func (g *GitRepo) MergeBase(commit1, commit2 *object.Commit) (*object.Commit, error) { 155 + isAncestor, err := commit1.IsAncestor(commit2) 156 + if err != nil { 157 + return nil, err 158 + } 159 + 160 + if isAncestor { 161 + return commit1, nil 162 + } 163 + 164 + mergeBase, err := commit1.MergeBase(commit2) 165 + if err != nil { 166 + return nil, err 167 + } 168 + 169 + if len(mergeBase) == 0 { 170 + return nil, fmt.Errorf("failed to find a merge-base") 171 + } 172 + 173 + return mergeBase[0], nil 174 + } 175 + 176 + func (g *GitRepo) ResolveRevision(revStr string) (*object.Commit, error) { 177 + rev, err := g.r.ResolveRevision(plumbing.Revision(revStr)) 178 + if err != nil { 179 + return nil, fmt.Errorf("resolving revision %s: %w", revStr, err) 180 + } 181 + 182 + commit, err := g.r.CommitObject(*rev) 183 + if err != nil { 184 + 185 + return nil, fmt.Errorf("getting commit for %s: %w", revStr, err) 186 + } 187 + 188 + return commit, nil 189 + }
+50
knotserver/git/fork.go
···
··· 1 + package git 2 + 3 + import ( 4 + "errors" 5 + "fmt" 6 + "os/exec" 7 + 8 + "github.com/go-git/go-git/v5" 9 + "github.com/go-git/go-git/v5/config" 10 + ) 11 + 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 { 19 + return fmt.Errorf("failed to bare clone repository: %w", err) 20 + } 21 + 22 + err = exec.Command("git", "-C", repoPath, "config", "receive.hideRefs", "refs/hidden").Run() 23 + if err != nil { 24 + return fmt.Errorf("failed to configure hidden refs: %w", err) 25 + } 26 + 27 + return nil 28 + } 29 + 30 + // TrackHiddenRemoteRef tracks a hidden remote in the repository. For example, 31 + // if the feature branch on the fork (forkRef) is feature-1, and the remoteRef, 32 + // i.e. the branch we want to merge into, is main, this will result in a refspec: 33 + // 34 + // +refs/heads/main:refs/hidden/feature-1/main 35 + func (g *GitRepo) TrackHiddenRemoteRef(forkRef, remoteRef string) error { 36 + fetchOpts := &git.FetchOptions{ 37 + RefSpecs: []config.RefSpec{ 38 + config.RefSpec(fmt.Sprintf("+refs/heads/%s:refs/hidden/%s/%s", remoteRef, forkRef, remoteRef)), 39 + }, 40 + RemoteName: "origin", 41 + } 42 + 43 + err := g.r.Fetch(fetchOpts) 44 + if errors.Is(git.NoErrAlreadyUpToDate, err) { 45 + return nil 46 + } else if err != nil { 47 + return fmt.Errorf("failed to fetch hidden remote: %s: %w", forkRef, err) 48 + } 49 + return nil 50 + }
+83 -5
knotserver/git/git.go
··· 37 } 38 39 var ( 40 - ErrBinaryFile = fmt.Errorf("binary file") 41 ) 42 43 type GitRepo struct { ··· 131 return &g, nil 132 } 133 134 func (g *GitRepo) Commits() ([]*object.Commit, error) { 135 ci, err := g.r.Log(&git.LogOptions{From: g.h}) 136 if err != nil { ··· 144 }) 145 146 return commits, nil 147 } 148 149 func (g *GitRepo) LastCommit() (*object.Commit, error) { ··· 179 } 180 } 181 182 func (g *GitRepo) Tags() ([]*TagReference, error) { 183 iter, err := g.r.Tags() 184 if err != nil { ··· 212 return tags, nil 213 } 214 215 - func (g *GitRepo) Branches() ([]*plumbing.Reference, error) { 216 bi, err := g.r.Branches() 217 if err != nil { 218 return nil, fmt.Errorf("branchs: %w", err) 219 } 220 221 - branches := []*plumbing.Reference{} 222 223 _ = bi.ForEach(func(ref *plumbing.Reference) error { 224 - branches = append(branches, ref) 225 return nil 226 }) 227 228 return branches, nil 229 } 230 231 func (g *GitRepo) FindMainBranch() (string, error) { ··· 308 } 309 cacheMu.RUnlock() 310 311 - cmd := exec.Command("git", "-C", g.path, "log", "-1", "--format=%H %ct", "--", path) 312 313 var out bytes.Buffer 314 cmd.Stdout = &out
··· 37 } 38 39 var ( 40 + ErrBinaryFile = fmt.Errorf("binary file") 41 + ErrNotBinaryFile = fmt.Errorf("not binary file") 42 ) 43 44 type GitRepo struct { ··· 132 return &g, nil 133 } 134 135 + func PlainOpen(path string) (*GitRepo, error) { 136 + var err error 137 + g := GitRepo{path: path} 138 + g.r, err = git.PlainOpen(path) 139 + if err != nil { 140 + return nil, fmt.Errorf("opening %s: %w", path, err) 141 + } 142 + return &g, nil 143 + } 144 + 145 func (g *GitRepo) Commits() ([]*object.Commit, error) { 146 ci, err := g.r.Log(&git.LogOptions{From: g.h}) 147 if err != nil { ··· 155 }) 156 157 return commits, nil 158 + } 159 + 160 + func (g *GitRepo) Commit(h plumbing.Hash) (*object.Commit, error) { 161 + return g.r.CommitObject(h) 162 } 163 164 func (g *GitRepo) LastCommit() (*object.Commit, error) { ··· 194 } 195 } 196 197 + func (g *GitRepo) RawContent(path string) ([]byte, error) { 198 + c, err := g.r.CommitObject(g.h) 199 + if err != nil { 200 + return nil, fmt.Errorf("commit object: %w", err) 201 + } 202 + 203 + tree, err := c.Tree() 204 + if err != nil { 205 + return nil, fmt.Errorf("file tree: %w", err) 206 + } 207 + 208 + file, err := tree.File(path) 209 + if err != nil { 210 + return nil, err 211 + } 212 + 213 + reader, err := file.Reader() 214 + if err != nil { 215 + return nil, fmt.Errorf("opening file reader: %w", err) 216 + } 217 + defer reader.Close() 218 + 219 + return io.ReadAll(reader) 220 + } 221 + 222 func (g *GitRepo) Tags() ([]*TagReference, error) { 223 iter, err := g.r.Tags() 224 if err != nil { ··· 252 return tags, nil 253 } 254 255 + func (g *GitRepo) Branches() ([]types.Branch, error) { 256 bi, err := g.r.Branches() 257 if err != nil { 258 return nil, fmt.Errorf("branchs: %w", err) 259 } 260 261 + branches := []types.Branch{} 262 + 263 + defaultBranch, err := g.FindMainBranch() 264 + if err != nil { 265 + return nil, fmt.Errorf("getting default branch", "error", err.Error()) 266 + } 267 268 _ = bi.ForEach(func(ref *plumbing.Reference) error { 269 + b := types.Branch{} 270 + b.Hash = ref.Hash().String() 271 + b.Name = ref.Name().Short() 272 + 273 + // resolve commit that this branch points to 274 + commit, _ := g.Commit(ref.Hash()) 275 + if commit != nil { 276 + b.Commit = commit 277 + } 278 + 279 + if defaultBranch != "" && defaultBranch == b.Name { 280 + b.IsDefault = true 281 + } 282 + 283 + branches = append(branches, b) 284 + 285 return nil 286 }) 287 288 return branches, nil 289 + } 290 + 291 + func (g *GitRepo) Branch(name string) (*plumbing.Reference, error) { 292 + ref, err := g.r.Reference(plumbing.NewBranchReferenceName(name), false) 293 + if err != nil { 294 + return nil, fmt.Errorf("branch: %w", err) 295 + } 296 + 297 + if !ref.Name().IsBranch() { 298 + return nil, fmt.Errorf("branch: %s is not a branch", ref.Name()) 299 + } 300 + 301 + return ref, nil 302 + } 303 + 304 + func (g *GitRepo) SetDefaultBranch(branch string) error { 305 + ref := plumbing.NewSymbolicReference(plumbing.HEAD, plumbing.NewBranchReferenceName(branch)) 306 + return g.r.Storer.SetReference(ref) 307 } 308 309 func (g *GitRepo) FindMainBranch() (string, error) { ··· 386 } 387 cacheMu.RUnlock() 388 389 + cmd := exec.Command("git", "-C", g.path, "log", g.h.String(), "-1", "--format=%H %ct", "--", path) 390 391 var out bytes.Buffer 392 cmd.Stdout = &out
+17 -2
knotserver/git/merge.go
··· 10 11 "github.com/go-git/go-git/v5" 12 "github.com/go-git/go-git/v5/plumbing" 13 ) 14 15 type ErrMerge struct { ··· 30 CommitBody string 31 AuthorName string 32 AuthorEmail string 33 } 34 35 func (e ErrMerge) Error() string { ··· 89 if checkOnly { 90 cmd = exec.Command("git", "-C", tmpDir, "apply", "--check", "-v", patchFile) 91 } else { 92 - exec.Command("git", "-C", tmpDir, "config", "advice.mergeConflict", "false").Run() 93 94 if opts != nil { 95 applyCmd := exec.Command("git", "-C", tmpDir, "apply", patchFile) 96 applyCmd.Stderr = &stderr ··· 153 } 154 155 func (g *GitRepo) MergeCheck(patchData []byte, targetBranch string) error { 156 patchFile, err := g.createTempFileWithPatch(patchData) 157 if err != nil { 158 return &ErrMerge{ ··· 171 } 172 defer os.RemoveAll(tmpDir) 173 174 - return g.applyPatch(tmpDir, patchFile, true, nil) 175 } 176 177 func (g *GitRepo) Merge(patchData []byte, targetBranch string) error {
··· 10 11 "github.com/go-git/go-git/v5" 12 "github.com/go-git/go-git/v5/plumbing" 13 + "tangled.sh/tangled.sh/core/patchutil" 14 ) 15 16 type ErrMerge struct { ··· 31 CommitBody string 32 AuthorName string 33 AuthorEmail string 34 + FormatPatch bool 35 } 36 37 func (e ErrMerge) Error() string { ··· 91 if checkOnly { 92 cmd = exec.Command("git", "-C", tmpDir, "apply", "--check", "-v", patchFile) 93 } else { 94 + // if patch is a format-patch, apply using 'git am' 95 + if opts.FormatPatch { 96 + amCmd := exec.Command("git", "-C", tmpDir, "am", patchFile) 97 + amCmd.Stderr = &stderr 98 + if err := amCmd.Run(); err != nil { 99 + return fmt.Errorf("patch application failed: %s", stderr.String()) 100 + } 101 + return nil 102 + } 103 104 + // else, apply using 'git apply' and commit it manually 105 + exec.Command("git", "-C", tmpDir, "config", "advice.mergeConflict", "false").Run() 106 if opts != nil { 107 applyCmd := exec.Command("git", "-C", tmpDir, "apply", patchFile) 108 applyCmd.Stderr = &stderr ··· 165 } 166 167 func (g *GitRepo) MergeCheck(patchData []byte, targetBranch string) error { 168 + var opts MergeOptions 169 + opts.FormatPatch = patchutil.IsFormatPatch(string(patchData)) 170 + 171 patchFile, err := g.createTempFileWithPatch(patchData) 172 if err != nil { 173 return &ErrMerge{ ··· 186 } 187 defer os.RemoveAll(tmpDir) 188 189 + return g.applyPatch(tmpDir, patchFile, true, &opts) 190 } 191 192 func (g *GitRepo) Merge(patchData []byte, targetBranch string) error {
+31 -24
knotserver/git/service/service.go
··· 8 "net/http" 9 "os/exec" 10 "strings" 11 "syscall" 12 ) 13 ··· 68 } 69 70 func (c *ServiceCommand) UploadPack() error { 71 - cmd := exec.Command("git", []string{ 72 - "-c", "uploadpack.allowFilter=true", 73 - "upload-pack", 74 - "--stateless-rpc", 75 - ".", 76 - }...) 77 cmd.Dir = c.Dir 78 cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} 79 80 - stdoutPipe, _ := cmd.StdoutPipe() 81 - cmd.Stderr = cmd.Stdout 82 - defer stdoutPipe.Close() 83 84 stdinPipe, err := cmd.StdinPipe() 85 if err != nil { 86 - return err 87 } 88 - defer stdinPipe.Close() 89 90 if err := cmd.Start(); err != nil { 91 - log.Printf("git: failed to start git-upload-pack: %s", err) 92 - return err 93 } 94 95 - if _, err := io.Copy(stdinPipe, c.Stdin); err != nil { 96 - log.Printf("git: failed to copy stdin: %s", err) 97 - return err 98 - } 99 - stdinPipe.Close() 100 101 - if _, err := io.Copy(newWriteFlusher(c.Stdout), stdoutPipe); err != nil { 102 - log.Printf("git: failed to copy stdout: %s", err) 103 - return err 104 - } 105 if err := cmd.Wait(); err != nil { 106 - log.Printf("git: failed to wait for git-upload-pack: %s", err) 107 - return err 108 } 109 110 return nil
··· 8 "net/http" 9 "os/exec" 10 "strings" 11 + "sync" 12 "syscall" 13 ) 14 ··· 69 } 70 71 func (c *ServiceCommand) UploadPack() error { 72 + var stderr bytes.Buffer 73 + 74 + cmd := exec.Command("git", "-c", "uploadpack.allowFilter=true", 75 + "upload-pack", "--stateless-rpc", ".") 76 cmd.Dir = c.Dir 77 cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} 78 79 + stdoutPipe, err := cmd.StdoutPipe() 80 + if err != nil { 81 + return fmt.Errorf("failed to create stdout pipe: %w", err) 82 + } 83 + 84 + cmd.Stderr = &stderr 85 86 stdinPipe, err := cmd.StdinPipe() 87 if err != nil { 88 + return fmt.Errorf("failed to create stdin pipe: %w", err) 89 } 90 91 if err := cmd.Start(); err != nil { 92 + return fmt.Errorf("failed to start git-upload-pack: %w", err) 93 } 94 95 + var wg sync.WaitGroup 96 + 97 + wg.Add(1) 98 + go func() { 99 + defer wg.Done() 100 + defer stdinPipe.Close() 101 + io.Copy(stdinPipe, c.Stdin) 102 + }() 103 + 104 + wg.Add(1) 105 + go func() { 106 + defer wg.Done() 107 + io.Copy(newWriteFlusher(c.Stdout), stdoutPipe) 108 + stdoutPipe.Close() 109 + }() 110 + 111 + wg.Wait() 112 113 if err := cmd.Wait(); err != nil { 114 + return fmt.Errorf("git-upload-pack failed: %w, stderr: %s", err, stderr.String()) 115 } 116 117 return nil
+8 -1
knotserver/git/tree.go
··· 2 3 import ( 4 "fmt" 5 6 "github.com/go-git/go-git/v5/plumbing/object" 7 "tangled.sh/tangled.sh/core/types" ··· 56 lastCommit, err := g.LastCommitForPath(fpath) 57 if err != nil { 58 fmt.Println("error getting last commit time:", err) 59 - continue 60 } 61 62 nts = append(nts, types.NiceTree{
··· 2 3 import ( 4 "fmt" 5 + "time" 6 7 "github.com/go-git/go-git/v5/plumbing/object" 8 "tangled.sh/tangled.sh/core/types" ··· 57 lastCommit, err := g.LastCommitForPath(fpath) 58 if err != nil { 59 fmt.Println("error getting last commit time:", err) 60 + // We don't want to skip the file, so worst case lets just 61 + // populate it with "defaults". 62 + lastCommit = &types.LastCommitInfo{ 63 + Hash: g.h, 64 + Message: "", 65 + When: time.Now(), 66 + } 67 } 68 69 nts = append(nts, types.NiceTree{
+22 -17
knotserver/git.go
··· 34 func (d *Handle) UploadPack(w http.ResponseWriter, r *http.Request) { 35 did := chi.URLParam(r, "did") 36 name := chi.URLParam(r, "name") 37 - repo, _ := securejoin.SecureJoin(d.c.Repo.ScanPath, filepath.Join(did, name)) 38 - 39 - w.Header().Set("content-type", "application/x-git-upload-pack-result") 40 - w.Header().Set("Connection", "Keep-Alive") 41 - w.Header().Set("Transfer-Encoding", "chunked") 42 - w.WriteHeader(http.StatusOK) 43 - 44 - cmd := service.ServiceCommand{ 45 - Dir: repo, 46 - Stdout: w, 47 } 48 49 - var reader io.ReadCloser 50 - reader = r.Body 51 - 52 if r.Header.Get("Content-Encoding") == "gzip" { 53 - reader, err := gzip.NewReader(r.Body) 54 if err != nil { 55 writeError(w, err.Error(), 500) 56 d.l.Error("git: failed to create gzip reader", "handler", "UploadPack", "error", err) 57 return 58 } 59 - defer reader.Close() 60 } 61 62 - cmd.Stdin = reader 63 if err := cmd.UploadPack(); err != nil { 64 - writeError(w, err.Error(), 500) 65 d.l.Error("git: failed to execute git-upload-pack", "handler", "UploadPack", "error", err) 66 return 67 }
··· 34 func (d *Handle) UploadPack(w http.ResponseWriter, r *http.Request) { 35 did := chi.URLParam(r, "did") 36 name := chi.URLParam(r, "name") 37 + repo, err := securejoin.SecureJoin(d.c.Repo.ScanPath, filepath.Join(did, name)) 38 + if err != nil { 39 + writeError(w, err.Error(), 500) 40 + d.l.Error("git: failed to secure join repo path", "handler", "UploadPack", "error", err) 41 + return 42 } 43 44 + var bodyReader io.ReadCloser = r.Body 45 if r.Header.Get("Content-Encoding") == "gzip" { 46 + gzipReader, err := gzip.NewReader(r.Body) 47 if err != nil { 48 writeError(w, err.Error(), 500) 49 d.l.Error("git: failed to create gzip reader", "handler", "UploadPack", "error", err) 50 return 51 } 52 + defer gzipReader.Close() 53 + bodyReader = gzipReader 54 + } 55 + 56 + w.Header().Set("Content-Type", "application/x-git-upload-pack-result") 57 + w.Header().Set("Connection", "Keep-Alive") 58 + 59 + d.l.Info("git: executing git-upload-pack", "handler", "UploadPack", "repo", repo) 60 + 61 + cmd := service.ServiceCommand{ 62 + Dir: repo, 63 + Stdout: w, 64 + Stdin: bodyReader, 65 } 66 67 + w.WriteHeader(http.StatusOK) 68 + 69 if err := cmd.UploadPack(); err != nil { 70 d.l.Error("git: failed to execute git-upload-pack", "handler", "UploadPack", "error", err) 71 return 72 }
+51 -2
knotserver/handler.go
··· 5 "fmt" 6 "log/slog" 7 "net/http" 8 9 "github.com/go-chi/chi/v5" 10 "tangled.sh/tangled.sh/core/jetstream" ··· 59 if err != nil { 60 return nil, fmt.Errorf("failed to get all Dids: %w", err) 61 } 62 if len(dids) > 0 { 63 h.knotInitialized = true 64 close(h.init) 65 - // h.jc.UpdateDids(dids) 66 } 67 68 r.Get("/", h.Index) 69 r.Route("/{did}", func(r chi.Router) { 70 // Repo routes 71 r.Route("/{name}", func(r chi.Router) { ··· 77 r.Get("/", h.RepoIndex) 78 r.Get("/info/refs", h.InfoRefs) 79 r.Post("/git-upload-pack", h.UploadPack) 80 81 r.Route("/merge", func(r chi.Router) { 82 r.With(h.VerifySignature) ··· 93 r.Get("/*", h.Blob) 94 }) 95 96 r.Get("/log/{ref}", h.Log) 97 r.Get("/archive/{file}", h.Archive) 98 r.Get("/commit/{ref}", h.Diff) 99 r.Get("/tags", h.Tags) 100 - r.Get("/branches", h.Branches) 101 }) 102 }) 103 ··· 106 r.Use(h.VerifySignature) 107 r.Put("/new", h.NewRepo) 108 r.Delete("/", h.RemoveRepo) 109 }) 110 111 r.Route("/member", func(r chi.Router) { ··· 124 125 return r, nil 126 }
··· 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" ··· 60 if err != nil { 61 return nil, fmt.Errorf("failed to get all Dids: %w", err) 62 } 63 + 64 if len(dids) > 0 { 65 h.knotInitialized = true 66 close(h.init) 67 + for _, d := range dids { 68 + h.jc.AddDid(d) 69 + } 70 } 71 72 r.Get("/", h.Index) 73 + r.Get("/capabilities", h.Capabilities) 74 + r.Get("/version", h.Version) 75 r.Route("/{did}", func(r chi.Router) { 76 // Repo routes 77 r.Route("/{name}", func(r chi.Router) { ··· 83 r.Get("/", h.RepoIndex) 84 r.Get("/info/refs", h.InfoRefs) 85 r.Post("/git-upload-pack", h.UploadPack) 86 + r.Get("/compare/{rev1}/{rev2}", h.Compare) // git diff-tree compare of two objects 87 + 88 + r.With(h.VerifySignature).Post("/hidden-ref/{forkRef}/{remoteRef}", h.NewHiddenRef) 89 90 r.Route("/merge", func(r chi.Router) { 91 r.With(h.VerifySignature) ··· 102 r.Get("/*", h.Blob) 103 }) 104 105 + r.Route("/raw/{ref}", func(r chi.Router) { 106 + r.Get("/*", h.BlobRaw) 107 + }) 108 + 109 r.Get("/log/{ref}", h.Log) 110 r.Get("/archive/{file}", h.Archive) 111 r.Get("/commit/{ref}", h.Diff) 112 r.Get("/tags", h.Tags) 113 + r.Route("/branches", func(r chi.Router) { 114 + r.Get("/", h.Branches) 115 + r.Get("/{branch}", h.Branch) 116 + r.Route("/default", func(r chi.Router) { 117 + r.Get("/", h.DefaultBranch) 118 + r.With(h.VerifySignature).Put("/", h.SetDefaultBranch) 119 + }) 120 + }) 121 }) 122 }) 123 ··· 126 r.Use(h.VerifySignature) 127 r.Put("/new", h.NewRepo) 128 r.Delete("/", h.RemoveRepo) 129 + r.Post("/fork", h.RepoFork) 130 }) 131 132 r.Route("/member", func(r chi.Router) { ··· 145 146 return r, nil 147 } 148 + 149 + // version is set during build time. 150 + var version string 151 + 152 + func (h *Handle) Version(w http.ResponseWriter, r *http.Request) { 153 + if version == "" { 154 + info, ok := debug.ReadBuildInfo() 155 + if !ok { 156 + http.Error(w, "failed to read build info", http.StatusInternalServerError) 157 + return 158 + } 159 + 160 + var modVer string 161 + for _, mod := range info.Deps { 162 + if mod.Path == "tangled.sh/tangled.sh/knotserver" { 163 + version = mod.Version 164 + break 165 + } 166 + } 167 + 168 + if modVer == "" { 169 + version = "unknown" 170 + } 171 + } 172 + 173 + w.Header().Set("Content-Type", "text/plain") 174 + fmt.Fprintf(w, "knotserver/%s", version) 175 + }
+4 -4
knotserver/jetstream.go
··· 43 return fmt.Errorf("failed to enforce permissions: %w", err) 44 } 45 46 - if err := h.e.AddMember(ThisServer, record.Member); 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.Member) 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 57 if err := h.fetchAndAddKeys(ctx, did); err != nil { 58 return fmt.Errorf("failed to fetch and add keys: %w", err) ··· 115 eventTime := event.TimeUS 116 lastTimeUs := eventTime + 1 117 fmt.Println("lastTimeUs", lastTimeUs) 118 - if err := h.db.UpdateLastTimeUs(lastTimeUs); err != nil { 119 err = fmt.Errorf("(deferred) failed to save last time us: %w", err) 120 } 121 - // h.jc.UpdateDids([]string{did}) 122 }() 123 124 raw := json.RawMessage(event.Commit.Record)
··· 43 return fmt.Errorf("failed to enforce permissions: %w", err) 44 } 45 46 + if err := h.e.AddMember(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) ··· 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)
+343 -21
knotserver/routes.go
··· 24 "github.com/go-git/go-git/v5/plumbing/object" 25 "tangled.sh/tangled.sh/core/knotserver/db" 26 "tangled.sh/tangled.sh/core/knotserver/git" 27 "tangled.sh/tangled.sh/core/types" 28 ) 29 30 func (h *Handle) Index(w http.ResponseWriter, r *http.Request) { 31 w.Write([]byte("This is a knot server. More info at https://tangled.sh")) 32 } 33 34 func (h *Handle) RepoIndex(w http.ResponseWriter, r *http.Request) { ··· 71 return 72 } 73 74 - bs := []types.Branch{} 75 - for _, branch := range branches { 76 - b := types.Branch{} 77 - b.Hash = branch.Hash().String() 78 - b.Name = branch.Name().Short() 79 - bs = append(bs, b) 80 - } 81 - 82 tags, err := gr.Tags() 83 if err != nil { 84 // Non-fatal, we *should* have at least one branch to show. ··· 138 Readme: readmeContent, 139 ReadmeFileName: readmeFile, 140 Files: files, 141 - Branches: bs, 142 Tags: rtags, 143 TotalCommits: total, 144 } ··· 180 return 181 } 182 183 func (h *Handle) Blob(w http.ResponseWriter, r *http.Request) { 184 treePath := chi.URLParam(r, "*") 185 ref := chi.URLParam(r, "ref") 186 ref, _ = url.PathUnescape(ref) 187 188 - l := h.l.With("handler", "FileContent", "ref", ref, "treePath", treePath) 189 190 path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 191 gr, err := git.Open(path, ref) ··· 271 272 func (h *Handle) Log(w http.ResponseWriter, r *http.Request) { 273 ref := chi.URLParam(r, "ref") 274 path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 275 276 l := h.l.With("handler", "Log", "ref", ref, "path", path) ··· 420 return 421 } 422 423 - bs := []types.Branch{} 424 - for _, branch := range branches { 425 - b := types.Branch{} 426 - b.Hash = branch.Hash().String() 427 - b.Name = branch.Name().Short() 428 - bs = append(bs, b) 429 } 430 431 - resp := types.RepoBranchesResponse{ 432 - Branches: bs, 433 } 434 435 writeJSON(w, resp) ··· 448 return 449 } 450 451 - data := make([]map[string]interface{}, 0) 452 for _, key := range keys { 453 j := key.JSON() 454 data = append(data, j) ··· 501 name := data.Name 502 defaultBranch := data.DefaultBranch 503 504 relativeRepoPath := filepath.Join(did, name) 505 repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) 506 err := git.InitBare(repoPath, defaultBranch) ··· 526 w.WriteHeader(http.StatusNoContent) 527 } 528 529 func (h *Handle) RemoveRepo(w http.ResponseWriter, r *http.Request) { 530 l := h.l.With("handler", "RemoveRepo") 531 ··· 585 notFound(w) 586 return 587 } 588 if err := gr.MergeWithOptions([]byte(patch), branch, mo); err != nil { 589 var mergeErr *git.ErrMerge 590 if errors.As(err, &mergeErr) { ··· 665 h.l.Error("git: failed to check merge", "handler", "MergeCheck", "error", err.Error()) 666 } 667 668 func (h *Handle) AddMember(w http.ResponseWriter, r *http.Request) { 669 l := h.l.With("handler", "AddMember") 670 ··· 684 writeError(w, err.Error(), http.StatusInternalServerError) 685 return 686 } 687 - 688 h.jc.AddDid(did) 689 if err := h.e.AddMember(ThisServer, did); err != nil { 690 l.Error("adding member", "error", err.Error()) 691 writeError(w, err.Error(), http.StatusInternalServerError) ··· 739 w.WriteHeader(http.StatusNoContent) 740 } 741 742 func (h *Handle) Init(w http.ResponseWriter, r *http.Request) { 743 l := h.l.With("handler", "Init") 744 ··· 768 writeError(w, err.Error(), http.StatusInternalServerError) 769 return 770 } 771 772 - // h.jc.UpdateDids([]string{data.Did}) 773 if err := h.e.AddOwner(ThisServer, data.Did); err != nil { 774 l.Error("adding owner", "error", err.Error()) 775 writeError(w, err.Error(), http.StatusInternalServerError) ··· 794 func (h *Handle) Health(w http.ResponseWriter, r *http.Request) { 795 w.Write([]byte("ok")) 796 }
··· 24 "github.com/go-git/go-git/v5/plumbing/object" 25 "tangled.sh/tangled.sh/core/knotserver/db" 26 "tangled.sh/tangled.sh/core/knotserver/git" 27 + "tangled.sh/tangled.sh/core/patchutil" 28 "tangled.sh/tangled.sh/core/types" 29 ) 30 31 func (h *Handle) Index(w http.ResponseWriter, r *http.Request) { 32 w.Write([]byte("This is a knot server. More info at https://tangled.sh")) 33 + } 34 + 35 + func (h *Handle) Capabilities(w http.ResponseWriter, r *http.Request) { 36 + w.Header().Set("Content-Type", "application/json") 37 + 38 + capabilities := map[string]any{ 39 + "pull_requests": map[string]any{ 40 + "format_patch": true, 41 + "patch_submissions": true, 42 + "branch_submissions": true, 43 + "fork_submissions": true, 44 + }, 45 + } 46 + 47 + jsonData, err := json.Marshal(capabilities) 48 + if err != nil { 49 + http.Error(w, "Failed to serialize JSON", http.StatusInternalServerError) 50 + return 51 + } 52 + 53 + w.Write(jsonData) 54 } 55 56 func (h *Handle) RepoIndex(w http.ResponseWriter, r *http.Request) { ··· 93 return 94 } 95 96 tags, err := gr.Tags() 97 if err != nil { 98 // Non-fatal, we *should* have at least one branch to show. ··· 152 Readme: readmeContent, 153 ReadmeFileName: readmeFile, 154 Files: files, 155 + Branches: branches, 156 Tags: rtags, 157 TotalCommits: total, 158 } ··· 194 return 195 } 196 197 + func (h *Handle) BlobRaw(w http.ResponseWriter, r *http.Request) { 198 + treePath := chi.URLParam(r, "*") 199 + ref := chi.URLParam(r, "ref") 200 + ref, _ = url.PathUnescape(ref) 201 + 202 + l := h.l.With("handler", "BlobRaw", "ref", ref, "treePath", treePath) 203 + 204 + path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 205 + gr, err := git.Open(path, ref) 206 + if err != nil { 207 + notFound(w) 208 + return 209 + } 210 + 211 + contents, err := gr.RawContent(treePath) 212 + if err != nil { 213 + writeError(w, err.Error(), http.StatusBadRequest) 214 + l.Error("file content", "error", err.Error()) 215 + return 216 + } 217 + 218 + mimeType := http.DetectContentType(contents) 219 + 220 + // exception for svg 221 + if strings.HasPrefix(mimeType, "text/xml") && filepath.Ext(treePath) == ".svg" { 222 + mimeType = "image/svg+xml" 223 + } 224 + 225 + if !strings.HasPrefix(mimeType, "image/") && !strings.HasPrefix(mimeType, "video/") { 226 + l.Error("attempted to serve non-image/video file", "mimetype", mimeType) 227 + writeError(w, "only image and video files can be accessed directly", http.StatusForbidden) 228 + return 229 + } 230 + 231 + w.Header().Set("Cache-Control", "public, max-age=86400") // cache for 24 hours 232 + w.Header().Set("ETag", fmt.Sprintf("%x", sha256.Sum256(contents))) 233 + w.Header().Set("Content-Type", mimeType) 234 + w.Write(contents) 235 + } 236 + 237 func (h *Handle) Blob(w http.ResponseWriter, r *http.Request) { 238 treePath := chi.URLParam(r, "*") 239 ref := chi.URLParam(r, "ref") 240 ref, _ = url.PathUnescape(ref) 241 242 + l := h.l.With("handler", "Blob", "ref", ref, "treePath", treePath) 243 244 path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 245 gr, err := git.Open(path, ref) ··· 325 326 func (h *Handle) Log(w http.ResponseWriter, r *http.Request) { 327 ref := chi.URLParam(r, "ref") 328 + ref, _ = url.PathUnescape(ref) 329 + 330 path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 331 332 l := h.l.With("handler", "Log", "ref", ref, "path", path) ··· 476 return 477 } 478 479 + resp := types.RepoBranchesResponse{ 480 + Branches: branches, 481 } 482 483 + writeJSON(w, resp) 484 + return 485 + } 486 + 487 + func (h *Handle) Branch(w http.ResponseWriter, r *http.Request) { 488 + path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 489 + branchName := chi.URLParam(r, "branch") 490 + branchName, _ = url.PathUnescape(branchName) 491 + 492 + l := h.l.With("handler", "Branch") 493 + 494 + gr, err := git.PlainOpen(path) 495 + if err != nil { 496 + notFound(w) 497 + return 498 + } 499 + 500 + ref, err := gr.Branch(branchName) 501 + if err != nil { 502 + l.Error("getting branch", "error", err.Error()) 503 + writeError(w, err.Error(), http.StatusInternalServerError) 504 + return 505 + } 506 + 507 + commit, err := gr.Commit(ref.Hash()) 508 + if err != nil { 509 + l.Error("getting commit object", "error", err.Error()) 510 + writeError(w, err.Error(), http.StatusInternalServerError) 511 + return 512 + } 513 + 514 + defaultBranch, err := gr.FindMainBranch() 515 + isDefault := false 516 + if err != nil { 517 + l.Error("getting default branch", "error", err.Error()) 518 + // do not quit though 519 + } else if defaultBranch == branchName { 520 + isDefault = true 521 + } 522 + 523 + resp := types.RepoBranchResponse{ 524 + Branch: types.Branch{ 525 + Reference: types.Reference{ 526 + Name: ref.Name().Short(), 527 + Hash: ref.Hash().String(), 528 + }, 529 + Commit: commit, 530 + IsDefault: isDefault, 531 + }, 532 } 533 534 writeJSON(w, resp) ··· 547 return 548 } 549 550 + data := make([]map[string]any, 0) 551 for _, key := range keys { 552 j := key.JSON() 553 data = append(data, j) ··· 600 name := data.Name 601 defaultBranch := data.DefaultBranch 602 603 + if err := validateRepoName(name); err != nil { 604 + l.Error("creating repo", "error", err.Error()) 605 + writeError(w, err.Error(), http.StatusBadRequest) 606 + return 607 + } 608 + 609 relativeRepoPath := filepath.Join(did, name) 610 repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) 611 err := git.InitBare(repoPath, defaultBranch) ··· 631 w.WriteHeader(http.StatusNoContent) 632 } 633 634 + func (h *Handle) RepoFork(w http.ResponseWriter, r *http.Request) { 635 + l := h.l.With("handler", "RepoFork") 636 + 637 + data := struct { 638 + Did string `json:"did"` 639 + Source string `json:"source"` 640 + Name string `json:"name,omitempty"` 641 + }{} 642 + 643 + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 644 + writeError(w, "invalid request body", http.StatusBadRequest) 645 + return 646 + } 647 + 648 + did := data.Did 649 + source := data.Source 650 + 651 + if did == "" || source == "" { 652 + l.Error("invalid request body, empty did or name") 653 + w.WriteHeader(http.StatusBadRequest) 654 + return 655 + } 656 + 657 + var name string 658 + if data.Name != "" { 659 + name = data.Name 660 + } else { 661 + name = filepath.Base(source) 662 + } 663 + 664 + relativeRepoPath := filepath.Join(did, name) 665 + repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) 666 + 667 + err := git.Fork(repoPath, source) 668 + if err != nil { 669 + l.Error("forking repo", "error", err.Error()) 670 + writeError(w, err.Error(), http.StatusInternalServerError) 671 + return 672 + } 673 + 674 + // add perms for this user to access the repo 675 + err = h.e.AddRepo(did, ThisServer, relativeRepoPath) 676 + if err != nil { 677 + l.Error("adding repo permissions", "error", err.Error()) 678 + writeError(w, err.Error(), http.StatusInternalServerError) 679 + return 680 + } 681 + 682 + w.WriteHeader(http.StatusNoContent) 683 + } 684 + 685 func (h *Handle) RemoveRepo(w http.ResponseWriter, r *http.Request) { 686 l := h.l.With("handler", "RemoveRepo") 687 ··· 741 notFound(w) 742 return 743 } 744 + 745 + mo.FormatPatch = patchutil.IsFormatPatch(patch) 746 + 747 if err := gr.MergeWithOptions([]byte(patch), branch, mo); err != nil { 748 var mergeErr *git.ErrMerge 749 if errors.As(err, &mergeErr) { ··· 824 h.l.Error("git: failed to check merge", "handler", "MergeCheck", "error", err.Error()) 825 } 826 827 + func (h *Handle) Compare(w http.ResponseWriter, r *http.Request) { 828 + rev1 := chi.URLParam(r, "rev1") 829 + rev1, _ = url.PathUnescape(rev1) 830 + 831 + rev2 := chi.URLParam(r, "rev2") 832 + rev2, _ = url.PathUnescape(rev2) 833 + 834 + l := h.l.With("handler", "Compare", "r1", rev1, "r2", rev2) 835 + 836 + path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 837 + gr, err := git.PlainOpen(path) 838 + if err != nil { 839 + notFound(w) 840 + return 841 + } 842 + 843 + commit1, err := gr.ResolveRevision(rev1) 844 + if err != nil { 845 + l.Error("error resolving revision 1", "msg", err.Error()) 846 + writeError(w, fmt.Sprintf("error resolving revision %s", rev1), http.StatusBadRequest) 847 + return 848 + } 849 + 850 + commit2, err := gr.ResolveRevision(rev2) 851 + if err != nil { 852 + l.Error("error resolving revision 2", "msg", err.Error()) 853 + writeError(w, fmt.Sprintf("error resolving revision %s", rev2), http.StatusBadRequest) 854 + return 855 + } 856 + 857 + mergeBase, err := gr.MergeBase(commit1, commit2) 858 + if err != nil { 859 + l.Error("failed to find merge-base", "msg", err.Error()) 860 + writeError(w, "failed to calculate diff", http.StatusBadRequest) 861 + return 862 + } 863 + 864 + rawPatch, formatPatch, err := gr.FormatPatch(mergeBase, commit2) 865 + if err != nil { 866 + l.Error("error comparing revisions", "msg", err.Error()) 867 + writeError(w, "error comparing revisions", http.StatusBadRequest) 868 + return 869 + } 870 + 871 + writeJSON(w, types.RepoFormatPatchResponse{ 872 + Rev1: commit1.Hash.String(), 873 + Rev2: commit2.Hash.String(), 874 + FormatPatch: formatPatch, 875 + Patch: rawPatch, 876 + }) 877 + return 878 + } 879 + 880 + func (h *Handle) NewHiddenRef(w http.ResponseWriter, r *http.Request) { 881 + l := h.l.With("handler", "NewHiddenRef") 882 + 883 + forkRef := chi.URLParam(r, "forkRef") 884 + forkRef, _ = url.PathUnescape(forkRef) 885 + 886 + remoteRef := chi.URLParam(r, "remoteRef") 887 + remoteRef, _ = url.PathUnescape(remoteRef) 888 + 889 + path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 890 + gr, err := git.PlainOpen(path) 891 + if err != nil { 892 + notFound(w) 893 + return 894 + } 895 + 896 + err = gr.TrackHiddenRemoteRef(forkRef, remoteRef) 897 + if err != nil { 898 + l.Error("error tracking hidden remote ref", "msg", err.Error()) 899 + writeError(w, "error tracking hidden remote ref", http.StatusBadRequest) 900 + return 901 + } 902 + 903 + w.WriteHeader(http.StatusNoContent) 904 + return 905 + } 906 + 907 func (h *Handle) AddMember(w http.ResponseWriter, r *http.Request) { 908 l := h.l.With("handler", "AddMember") 909 ··· 923 writeError(w, err.Error(), http.StatusInternalServerError) 924 return 925 } 926 h.jc.AddDid(did) 927 + 928 if err := h.e.AddMember(ThisServer, did); err != nil { 929 l.Error("adding member", "error", err.Error()) 930 writeError(w, err.Error(), http.StatusInternalServerError) ··· 978 w.WriteHeader(http.StatusNoContent) 979 } 980 981 + func (h *Handle) DefaultBranch(w http.ResponseWriter, r *http.Request) { 982 + l := h.l.With("handler", "DefaultBranch") 983 + path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 984 + 985 + gr, err := git.Open(path, "") 986 + if err != nil { 987 + notFound(w) 988 + return 989 + } 990 + 991 + branch, err := gr.FindMainBranch() 992 + if err != nil { 993 + writeError(w, err.Error(), http.StatusInternalServerError) 994 + l.Error("getting default branch", "error", err.Error()) 995 + return 996 + } 997 + 998 + writeJSON(w, types.RepoDefaultBranchResponse{ 999 + Branch: branch, 1000 + }) 1001 + } 1002 + 1003 + func (h *Handle) SetDefaultBranch(w http.ResponseWriter, r *http.Request) { 1004 + l := h.l.With("handler", "SetDefaultBranch") 1005 + path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r)) 1006 + 1007 + data := struct { 1008 + Branch string `json:"branch"` 1009 + }{} 1010 + 1011 + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 1012 + writeError(w, err.Error(), http.StatusBadRequest) 1013 + return 1014 + } 1015 + 1016 + gr, err := git.Open(path, "") 1017 + if err != nil { 1018 + notFound(w) 1019 + return 1020 + } 1021 + 1022 + err = gr.SetDefaultBranch(data.Branch) 1023 + if err != nil { 1024 + writeError(w, err.Error(), http.StatusInternalServerError) 1025 + l.Error("setting default branch", "error", err.Error()) 1026 + return 1027 + } 1028 + 1029 + w.WriteHeader(http.StatusNoContent) 1030 + } 1031 + 1032 func (h *Handle) Init(w http.ResponseWriter, r *http.Request) { 1033 l := h.l.With("handler", "Init") 1034 ··· 1058 writeError(w, err.Error(), http.StatusInternalServerError) 1059 return 1060 } 1061 + h.jc.AddDid(data.Did) 1062 1063 if err := h.e.AddOwner(ThisServer, data.Did); err != nil { 1064 l.Error("adding owner", "error", err.Error()) 1065 writeError(w, err.Error(), http.StatusInternalServerError) ··· 1084 func (h *Handle) Health(w http.ResponseWriter, r *http.Request) { 1085 w.Write([]byte("ok")) 1086 } 1087 + 1088 + func validateRepoName(name string) error { 1089 + // check for path traversal attempts 1090 + if name == "." || name == ".." || 1091 + strings.Contains(name, "/") || strings.Contains(name, "\\") { 1092 + return fmt.Errorf("Repository name contains invalid path characters") 1093 + } 1094 + 1095 + // check for sequences that could be used for traversal when normalized 1096 + if strings.Contains(name, "./") || strings.Contains(name, "../") || 1097 + strings.HasPrefix(name, ".") || strings.HasSuffix(name, ".") { 1098 + return fmt.Errorf("Repository name contains invalid path sequence") 1099 + } 1100 + 1101 + // then continue with character validation 1102 + for _, char := range name { 1103 + if !((char >= 'a' && char <= 'z') || 1104 + (char >= 'A' && char <= 'Z') || 1105 + (char >= '0' && char <= '9') || 1106 + char == '-' || char == '_' || char == '.') { 1107 + return fmt.Errorf("Repository name can only contain alphanumeric characters, periods, hyphens, and underscores") 1108 + } 1109 + } 1110 + 1111 + // additional check to prevent multiple sequential dots 1112 + if strings.Contains(name, "..") { 1113 + return fmt.Errorf("Repository name cannot contain sequential dots") 1114 + } 1115 + 1116 + // if all checks pass 1117 + return nil 1118 + }
+72
lexicons/actor/profile.json
···
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.actor.profile", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "A declaration of a Tangled account profile.", 8 + "key": "literal:self", 9 + "record": { 10 + "type": "object", 11 + "required": [ 12 + "bluesky" 13 + ], 14 + "properties": { 15 + "description": { 16 + "type": "string", 17 + "description": "Free-form profile description text.", 18 + "maxGraphemes": 256, 19 + "maxLength": 2560 20 + }, 21 + "links": { 22 + "type": "array", 23 + "minLength": 0, 24 + "maxLength": 5, 25 + "items": { 26 + "type": "string", 27 + "description": "Any URI, intended for social profiles or websites, can be used to link DIDs/AT-URIs too.", 28 + "format": "uri" 29 + } 30 + }, 31 + "stats": { 32 + "type": "array", 33 + "minLength": 0, 34 + "maxLength": 2, 35 + "items": { 36 + "type": "string", 37 + "description": "Vanity stats.", 38 + "enum": [ 39 + "merged-pull-request-count", 40 + "closed-pull-request-count", 41 + "open-pull-request-count", 42 + "open-issue-count", 43 + "closed-issue-count", 44 + "repository-count" 45 + ] 46 + } 47 + }, 48 + "bluesky": { 49 + "type": "boolean", 50 + "description": "Include link to this account on Bluesky." 51 + }, 52 + "location": { 53 + "type": "string", 54 + "description": "Free-form location text.", 55 + "maxGraphemes": 40, 56 + "maxLength": 400 57 + }, 58 + "pinnedRepositories": { 59 + "type": "array", 60 + "description": "Any ATURI, it is up to appviews to validate these fields.", 61 + "minLength": 0, 62 + "maxLength": 6, 63 + "items": { 64 + "type": "string", 65 + "format": "at-uri" 66 + } 67 + } 68 + } 69 + } 70 + } 71 + } 72 + }
+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 + }
+29
lexicons/feed/star.json
···
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.feed.star", 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 + "createdAt" 15 + ], 16 + "properties": { 17 + "subject": { 18 + "type": "string", 19 + "format": "at-uri" 20 + }, 21 + "createdAt": { 22 + "type": "string", 23 + "format": "datetime" 24 + } 25 + } 26 + } 27 + } 28 + } 29 + }
-30
lexicons/follow.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "sh.tangled.graph.follow", 4 - "needsCbor": true, 5 - "needsType": true, 6 - "defs": { 7 - "main": { 8 - "type": "record", 9 - "key": "tid", 10 - "record": { 11 - "type": "object", 12 - "required": [ 13 - "createdAt", 14 - "subject" 15 - ], 16 - "properties": { 17 - "createdAt": { 18 - "type": "string", 19 - "format": "datetime" 20 - }, 21 - "subject": { 22 - "type": "string", 23 - "format": "did" 24 - } 25 - } 26 - } 27 - } 28 - } 29 - } 30 -
···
+29
lexicons/graph/follow.json
···
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.graph.follow", 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 + "createdAt" 15 + ], 16 + "properties": { 17 + "subject": { 18 + "type": "string", 19 + "format": "did" 20 + }, 21 + "createdAt": { 22 + "type": "string", 23 + "format": "datetime" 24 + } 25 + } 26 + } 27 + } 28 + } 29 + }
+5 -1
lexicons/issue/comment.json
··· 9 "key": "tid", 10 "record": { 11 "type": "object", 12 - "required": ["issue"], 13 "properties": { 14 "issue": { 15 "type": "string",
··· 9 "key": "tid", 10 "record": { 11 "type": "object", 12 + "required": [ 13 + "issue", 14 + "body", 15 + "createdAt" 16 + ], 17 "properties": { 18 "issue": { 19 "type": "string",
+7 -1
lexicons/issue/issue.json
··· 9 "key": "tid", 10 "record": { 11 "type": "object", 12 - "required": ["repo", "issueId", "owner", "title"], 13 "properties": { 14 "repo": { 15 "type": "string",
··· 9 "key": "tid", 10 "record": { 11 "type": "object", 12 + "required": [ 13 + "repo", 14 + "issueId", 15 + "owner", 16 + "title", 17 + "createdAt" 18 + ], 19 "properties": { 20 "repo": { 21 "type": "string",
+4 -1
lexicons/issue/state.json
··· 9 "key": "tid", 10 "record": { 11 "type": "object", 12 - "required": ["issue"], 13 "properties": { 14 "issue": { 15 "type": "string",
··· 9 "key": "tid", 10 "record": { 11 "type": "object", 12 + "required": [ 13 + "issue", 14 + "state" 15 + ], 16 "properties": { 17 "issue": { 18 "type": "string",
+34
lexicons/knot/member.json
···
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.knot.member", 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 + "domain", 15 + "createdAt" 16 + ], 17 + "properties": { 18 + "subject": { 19 + "type": "string", 20 + "format": "did" 21 + }, 22 + "domain": { 23 + "type": "string", 24 + "description": "domain that this member now belongs to" 25 + }, 26 + "createdAt": { 27 + "type": "string", 28 + "format": "datetime" 29 + } 30 + } 31 + } 32 + } 33 + } 34 + }
-33
lexicons/member.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "sh.tangled.knot.member", 4 - "needsCbor": true, 5 - "needsType": true, 6 - "defs": { 7 - "main": { 8 - "type": "record", 9 - "key": "tid", 10 - "record": { 11 - "type": "object", 12 - "required": [ 13 - "member", 14 - "domain" 15 - ], 16 - "properties": { 17 - "member": { 18 - "type": "string", 19 - "format": "did" 20 - }, 21 - "domain": { 22 - "type": "string", 23 - "description": "domain that this member now belongs to" 24 - }, 25 - "addedAt": { 26 - "type": "string", 27 - "format": "datetime" 28 - } 29 - } 30 - } 31 - } 32 - } 33 - }
···
+2 -4
lexicons/publicKey.json
··· 12 "required": [ 13 "key", 14 "name", 15 - "created" 16 ], 17 "properties": { 18 "key": { 19 "type": "string", 20 "maxLength": 4096, 21 - "maxGraphemes": 4096, 22 "description": "public key contents" 23 }, 24 "name": { 25 "type": "string", 26 - "format": "string", 27 "description": "human-readable name for this key" 28 }, 29 - "created": { 30 "type": "string", 31 "format": "datetime", 32 "description": "key upload timestamp"
··· 12 "required": [ 13 "key", 14 "name", 15 + "createdAt" 16 ], 17 "properties": { 18 "key": { 19 "type": "string", 20 "maxLength": 4096, 21 "description": "public key contents" 22 }, 23 "name": { 24 "type": "string", 25 "description": "human-readable name for this key" 26 }, 27 + "createdAt": { 28 "type": "string", 29 "format": "datetime", 30 "description": "key upload timestamp"
+5 -1
lexicons/pulls/comment.json
··· 9 "key": "tid", 10 "record": { 11 "type": "object", 12 - "required": ["pull"], 13 "properties": { 14 "pull": { 15 "type": "string",
··· 9 "key": "tid", 10 "record": { 11 "type": "object", 12 + "required": [ 13 + "pull", 14 + "body", 15 + "createdAt" 16 + ], 17 "properties": { 18 "pull": { 19 "type": "string",
+30 -8
lexicons/pulls/pull.json
··· 9 "key": "tid", 10 "record": { 11 "type": "object", 12 - "required": ["targetRepo", "targetBranch", "pullId", "title", "patch"], 13 "properties": { 14 "targetRepo": { 15 "type": "string", ··· 18 "targetBranch": { 19 "type": "string" 20 }, 21 - "sourceRepo": { 22 - "type": "string", 23 - "format": "at-uri" 24 - }, 25 "pullId": { 26 "type": "integer" 27 }, ··· 31 "body": { 32 "type": "string" 33 }, 34 "createdAt": { 35 "type": "string", 36 "format": "datetime" 37 - }, 38 - "patch": { 39 - "type": "string" 40 } 41 } 42 } 43 }
··· 9 "key": "tid", 10 "record": { 11 "type": "object", 12 + "required": [ 13 + "targetRepo", 14 + "targetBranch", 15 + "pullId", 16 + "title", 17 + "patch", 18 + "createdAt" 19 + ], 20 "properties": { 21 "targetRepo": { 22 "type": "string", ··· 25 "targetBranch": { 26 "type": "string" 27 }, 28 "pullId": { 29 "type": "integer" 30 }, ··· 34 "body": { 35 "type": "string" 36 }, 37 + "patch": { 38 + "type": "string" 39 + }, 40 + "source": { 41 + "type": "ref", 42 + "ref": "#source" 43 + }, 44 "createdAt": { 45 "type": "string", 46 "format": "datetime" 47 } 48 + } 49 + } 50 + }, 51 + "source": { 52 + "type": "object", 53 + "required": [ 54 + "branch" 55 + ], 56 + "properties": { 57 + "branch": { 58 + "type": "string" 59 + }, 60 + "repo": { 61 + "type": "string", 62 + "format": "at-uri" 63 } 64 } 65 }
+4 -1
lexicons/pulls/state.json
··· 9 "key": "tid", 10 "record": { 11 "type": "object", 12 - "required": ["pull"], 13 "properties": { 14 "pull": { 15 "type": "string",
··· 9 "key": "tid", 10 "record": { 11 "type": "object", 12 + "required": [ 13 + "pull", 14 + "status" 15 + ], 16 "properties": { 17 "pull": { 18 "type": "string",
+17 -7
lexicons/repo.json
··· 9 "key": "tid", 10 "record": { 11 "type": "object", 12 - "required": ["name", "knot", "owner"], 13 "properties": { 14 "name": { 15 "type": "string", ··· 23 "type": "string", 24 "description": "knot where the repo was created" 25 }, 26 - "addedAt": { 27 - "type": "string", 28 - "format": "datetime" 29 - }, 30 "description": { 31 "type": "string", 32 "format": "datetime", 33 - "minLength": 1, 34 - "maxLength": 140 35 } 36 } 37 }
··· 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", ··· 28 "type": "string", 29 "description": "knot where the repo was created" 30 }, 31 "description": { 32 "type": "string", 33 "format": "datetime", 34 + "minGraphemes": 1, 35 + "maxGraphemes": 140 36 + }, 37 + "source": { 38 + "type": "string", 39 + "format": "uri", 40 + "description": "source of the repo" 41 + }, 42 + "createdAt": { 43 + "type": "string", 44 + "format": "datetime" 45 } 46 } 47 }
-31
lexicons/star.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "sh.tangled.feed.star", 4 - "needsCbor": true, 5 - "needsType": true, 6 - "defs": { 7 - "main": { 8 - "type": "record", 9 - "key": "tid", 10 - "record": { 11 - "type": "object", 12 - "required": [ 13 - "createdAt", 14 - "subject" 15 - ], 16 - "properties": { 17 - "createdAt": { 18 - "type": "string", 19 - "format": "datetime" 20 - }, 21 - "subject": { 22 - "type": "string", 23 - "format": "at-uri" 24 - } 25 - } 26 - } 27 - } 28 - } 29 - } 30 - 31 -
···
+172
patchutil/combinediff.go
···
··· 1 + package patchutil 2 + 3 + import ( 4 + "fmt" 5 + "strings" 6 + 7 + "github.com/bluekeyes/go-gitdiff/gitdiff" 8 + ) 9 + 10 + // original1 -> patch1 -> rev1 11 + // original2 -> patch2 -> rev2 12 + // 13 + // original2 must be equal to rev1, so we can merge them to get maximal context 14 + // 15 + // finally, 16 + // rev2' <- apply(patch2, merged) 17 + // combineddiff <- diff(rev2', original1) 18 + func combineFiles(file1, file2 *gitdiff.File) (*gitdiff.File, error) { 19 + fileName := bestName(file1) 20 + 21 + o1 := CreatePreImage(file1) 22 + r1 := CreatePostImage(file1) 23 + o2 := CreatePreImage(file2) 24 + 25 + merged, err := r1.Merge(&o2) 26 + if err != nil { 27 + return nil, err 28 + } 29 + 30 + r2Prime, err := merged.Apply(file2) 31 + if err != nil { 32 + return nil, err 33 + } 34 + 35 + // produce combined diff 36 + diff, err := Unified(o1.String(), fileName, r2Prime, fileName) 37 + if err != nil { 38 + return nil, err 39 + } 40 + 41 + parsed, _, err := gitdiff.Parse(strings.NewReader(diff)) 42 + 43 + if len(parsed) != 1 { 44 + // no diff? the second commit reverted the changes from the first 45 + return nil, nil 46 + } 47 + 48 + return parsed[0], nil 49 + } 50 + 51 + // use empty lines for lines we are unaware of 52 + // 53 + // this raises an error only if the two patches were invalid or non-contiguous 54 + func mergeLines(old, new string) (string, error) { 55 + var i, j int 56 + 57 + // TODO: use strings.Lines 58 + linesOld := strings.Split(old, "\n") 59 + linesNew := strings.Split(new, "\n") 60 + 61 + result := []string{} 62 + 63 + for i < len(linesOld) || j < len(linesNew) { 64 + if i >= len(linesOld) { 65 + // rest of the file is populated from `new` 66 + result = append(result, linesNew[j]) 67 + j++ 68 + continue 69 + } 70 + 71 + if j >= len(linesNew) { 72 + // rest of the file is populated from `old` 73 + result = append(result, linesOld[i]) 74 + i++ 75 + continue 76 + } 77 + 78 + oldLine := linesOld[i] 79 + newLine := linesNew[j] 80 + 81 + if oldLine != newLine && (oldLine != "" && newLine != "") { 82 + // context mismatch 83 + return "", fmt.Errorf("failed to merge files, found context mismatch at %d; oldLine: `%s`, newline: `%s`", i+1, oldLine, newLine) 84 + } 85 + 86 + if oldLine == newLine { 87 + result = append(result, oldLine) 88 + } else if oldLine == "" { 89 + result = append(result, newLine) 90 + } else if newLine == "" { 91 + result = append(result, oldLine) 92 + } 93 + i++ 94 + j++ 95 + } 96 + 97 + return strings.Join(result, "\n"), nil 98 + } 99 + 100 + func combineTwo(patch1, patch2 []*gitdiff.File) []*gitdiff.File { 101 + fileToIdx1 := make(map[string]int) 102 + fileToIdx2 := make(map[string]int) 103 + visited := make(map[string]struct{}) 104 + var result []*gitdiff.File 105 + 106 + for idx, f := range patch1 { 107 + fileToIdx1[bestName(f)] = idx 108 + } 109 + 110 + for idx, f := range patch2 { 111 + fileToIdx2[bestName(f)] = idx 112 + } 113 + 114 + for _, f1 := range patch1 { 115 + fileName := bestName(f1) 116 + if idx, ok := fileToIdx2[fileName]; ok { 117 + f2 := patch2[idx] 118 + 119 + // we have f1 and f2, combine them 120 + combined, err := combineFiles(f1, f2) 121 + if err != nil { 122 + fmt.Println(err) 123 + } 124 + 125 + // combined can be nil commit 2 reverted all changes from commit 1 126 + if combined != nil { 127 + result = append(result, combined) 128 + } 129 + 130 + } else { 131 + // only in patch1; add as-is 132 + result = append(result, f1) 133 + } 134 + 135 + visited[fileName] = struct{}{} 136 + } 137 + 138 + // for all files in patch2 that remain unvisited; we can just add them into the output 139 + for _, f2 := range patch2 { 140 + fileName := bestName(f2) 141 + if _, ok := visited[fileName]; ok { 142 + continue 143 + } 144 + 145 + result = append(result, f2) 146 + } 147 + 148 + return result 149 + } 150 + 151 + // pairwise combination from first to last patch 152 + func CombineDiff(patches ...[]*gitdiff.File) []*gitdiff.File { 153 + if len(patches) == 0 { 154 + return nil 155 + } 156 + 157 + if len(patches) == 1 { 158 + return patches[0] 159 + } 160 + 161 + combined := combineTwo(patches[0], patches[1]) 162 + 163 + newPatches := [][]*gitdiff.File{} 164 + newPatches = append(newPatches, combined) 165 + for i, p := range patches { 166 + if i >= 2 { 167 + newPatches = append(newPatches, p) 168 + } 169 + } 170 + 171 + return CombineDiff(newPatches...) 172 + }
+178
patchutil/image.go
···
··· 1 + package patchutil 2 + 3 + import ( 4 + "bytes" 5 + "fmt" 6 + "strings" 7 + 8 + "github.com/bluekeyes/go-gitdiff/gitdiff" 9 + ) 10 + 11 + type Line struct { 12 + LineNumber int64 13 + Content string 14 + IsUnknown bool 15 + } 16 + 17 + func NewLineAt(lineNumber int64, content string) Line { 18 + return Line{ 19 + LineNumber: lineNumber, 20 + Content: content, 21 + IsUnknown: false, 22 + } 23 + } 24 + 25 + type Image struct { 26 + File string 27 + Data []*Line 28 + } 29 + 30 + func (r *Image) String() string { 31 + var i, j int64 32 + var b strings.Builder 33 + for { 34 + i += 1 35 + 36 + if int(j) >= (len(r.Data)) { 37 + break 38 + } 39 + 40 + if r.Data[j].LineNumber == i { 41 + // b.WriteString(fmt.Sprintf("%d:", r.Data[j].LineNumber)) 42 + b.WriteString(r.Data[j].Content) 43 + j += 1 44 + } else { 45 + //b.WriteString(fmt.Sprintf("%d:\n", i)) 46 + b.WriteString("\n") 47 + } 48 + } 49 + 50 + return b.String() 51 + } 52 + 53 + func (r *Image) AddLine(line *Line) { 54 + r.Data = append(r.Data, line) 55 + } 56 + 57 + // rebuild the original file from a patch 58 + func CreatePreImage(file *gitdiff.File) Image { 59 + rf := Image{ 60 + File: bestName(file), 61 + } 62 + 63 + for _, fragment := range file.TextFragments { 64 + position := fragment.OldPosition 65 + for _, line := range fragment.Lines { 66 + switch line.Op { 67 + case gitdiff.OpContext: 68 + rl := NewLineAt(position, line.Line) 69 + rf.Data = append(rf.Data, &rl) 70 + position += 1 71 + case gitdiff.OpDelete: 72 + rl := NewLineAt(position, line.Line) 73 + rf.Data = append(rf.Data, &rl) 74 + position += 1 75 + case gitdiff.OpAdd: 76 + // do nothing here 77 + } 78 + } 79 + } 80 + 81 + return rf 82 + } 83 + 84 + // rebuild the revised file from a patch 85 + func CreatePostImage(file *gitdiff.File) Image { 86 + rf := Image{ 87 + File: bestName(file), 88 + } 89 + 90 + for _, fragment := range file.TextFragments { 91 + position := fragment.NewPosition 92 + for _, line := range fragment.Lines { 93 + switch line.Op { 94 + case gitdiff.OpContext: 95 + rl := NewLineAt(position, line.Line) 96 + rf.Data = append(rf.Data, &rl) 97 + position += 1 98 + case gitdiff.OpAdd: 99 + rl := NewLineAt(position, line.Line) 100 + rf.Data = append(rf.Data, &rl) 101 + position += 1 102 + case gitdiff.OpDelete: 103 + // do nothing here 104 + } 105 + } 106 + } 107 + 108 + return rf 109 + } 110 + 111 + type MergeError struct { 112 + msg string 113 + mismatchingLine int64 114 + } 115 + 116 + func (m MergeError) Error() string { 117 + return fmt.Sprintf("%s: %v", m.msg, m.mismatchingLine) 118 + } 119 + 120 + // best effort merging of two reconstructed files 121 + func (this *Image) Merge(other *Image) (*Image, error) { 122 + mergedFile := Image{} 123 + 124 + var i, j int64 125 + 126 + for int(i) < len(this.Data) || int(j) < len(other.Data) { 127 + if int(i) >= len(this.Data) { 128 + // first file is done; the rest of the lines from file 2 can go in 129 + mergedFile.AddLine(other.Data[j]) 130 + j++ 131 + continue 132 + } 133 + 134 + if int(j) >= len(other.Data) { 135 + // first file is done; the rest of the lines from file 2 can go in 136 + mergedFile.AddLine(this.Data[i]) 137 + i++ 138 + continue 139 + } 140 + 141 + line1 := this.Data[i] 142 + line2 := other.Data[j] 143 + 144 + if line1.LineNumber == line2.LineNumber { 145 + if line1.Content != line2.Content { 146 + return nil, MergeError{ 147 + msg: "mismatching lines, this patch might have undergone rebase", 148 + mismatchingLine: line1.LineNumber, 149 + } 150 + } else { 151 + mergedFile.AddLine(line1) 152 + } 153 + i++ 154 + j++ 155 + } else if line1.LineNumber < line2.LineNumber { 156 + mergedFile.AddLine(line1) 157 + i++ 158 + } else { 159 + mergedFile.AddLine(line2) 160 + j++ 161 + } 162 + } 163 + 164 + return &mergedFile, nil 165 + } 166 + 167 + func (r *Image) Apply(patch *gitdiff.File) (string, error) { 168 + original := r.String() 169 + var buffer bytes.Buffer 170 + reader := strings.NewReader(original) 171 + 172 + err := gitdiff.Apply(&buffer, reader, patch) 173 + if err != nil { 174 + return "", err 175 + } 176 + 177 + return buffer.String(), nil 178 + }
+244
patchutil/interdiff.go
···
··· 1 + package patchutil 2 + 3 + import ( 4 + "fmt" 5 + "strings" 6 + 7 + "github.com/bluekeyes/go-gitdiff/gitdiff" 8 + ) 9 + 10 + type InterdiffResult struct { 11 + Files []*InterdiffFile 12 + } 13 + 14 + func (i *InterdiffResult) AffectedFiles() []string { 15 + files := make([]string, len(i.Files)) 16 + for _, f := range i.Files { 17 + files = append(files, f.Name) 18 + } 19 + return files 20 + } 21 + 22 + func (i *InterdiffResult) String() string { 23 + var b strings.Builder 24 + for _, f := range i.Files { 25 + b.WriteString(f.String()) 26 + b.WriteString("\n") 27 + } 28 + 29 + return b.String() 30 + } 31 + 32 + type InterdiffFile struct { 33 + *gitdiff.File 34 + Name string 35 + Status InterdiffFileStatus 36 + } 37 + 38 + func (s *InterdiffFile) String() string { 39 + var b strings.Builder 40 + b.WriteString(s.Status.String()) 41 + b.WriteString(" ") 42 + 43 + if s.File != nil { 44 + b.WriteString(bestName(s.File)) 45 + b.WriteString("\n") 46 + b.WriteString(s.File.String()) 47 + } 48 + 49 + return b.String() 50 + } 51 + 52 + type InterdiffFileStatus struct { 53 + StatusKind StatusKind 54 + Error error 55 + } 56 + 57 + func (s *InterdiffFileStatus) String() string { 58 + kind := s.StatusKind.String() 59 + if s.Error != nil { 60 + return fmt.Sprintf("%s [%s]", kind, s.Error.Error()) 61 + } else { 62 + return kind 63 + } 64 + } 65 + 66 + func (s *InterdiffFileStatus) IsOk() bool { 67 + return s.StatusKind == StatusOk 68 + } 69 + 70 + func (s *InterdiffFileStatus) IsUnchanged() bool { 71 + return s.StatusKind == StatusUnchanged 72 + } 73 + 74 + func (s *InterdiffFileStatus) IsOnlyInOne() bool { 75 + return s.StatusKind == StatusOnlyInOne 76 + } 77 + 78 + func (s *InterdiffFileStatus) IsOnlyInTwo() bool { 79 + return s.StatusKind == StatusOnlyInTwo 80 + } 81 + 82 + func (s *InterdiffFileStatus) IsRebased() bool { 83 + return s.StatusKind == StatusRebased 84 + } 85 + 86 + func (s *InterdiffFileStatus) IsError() bool { 87 + return s.StatusKind == StatusError 88 + } 89 + 90 + type StatusKind int 91 + 92 + func (k StatusKind) String() string { 93 + switch k { 94 + case StatusOnlyInOne: 95 + return "only in one" 96 + case StatusOnlyInTwo: 97 + return "only in two" 98 + case StatusUnchanged: 99 + return "unchanged" 100 + case StatusRebased: 101 + return "rebased" 102 + case StatusError: 103 + return "error" 104 + default: 105 + return "changed" 106 + } 107 + } 108 + 109 + const ( 110 + StatusOk StatusKind = iota 111 + StatusOnlyInOne 112 + StatusOnlyInTwo 113 + StatusUnchanged 114 + StatusRebased 115 + StatusError 116 + ) 117 + 118 + func interdiffFiles(f1, f2 *gitdiff.File) *InterdiffFile { 119 + re1 := CreatePreImage(f1) 120 + re2 := CreatePreImage(f2) 121 + 122 + interdiffFile := InterdiffFile{ 123 + Name: bestName(f1), 124 + } 125 + 126 + merged, err := re1.Merge(&re2) 127 + if err != nil { 128 + interdiffFile.Status = InterdiffFileStatus{ 129 + StatusKind: StatusRebased, 130 + Error: err, 131 + } 132 + return &interdiffFile 133 + } 134 + 135 + rev1, err := merged.Apply(f1) 136 + if err != nil { 137 + interdiffFile.Status = InterdiffFileStatus{ 138 + StatusKind: StatusError, 139 + Error: err, 140 + } 141 + return &interdiffFile 142 + } 143 + 144 + rev2, err := merged.Apply(f2) 145 + if err != nil { 146 + interdiffFile.Status = InterdiffFileStatus{ 147 + StatusKind: StatusError, 148 + Error: err, 149 + } 150 + return &interdiffFile 151 + } 152 + 153 + diff, err := Unified(rev1, bestName(f1), rev2, bestName(f2)) 154 + if err != nil { 155 + interdiffFile.Status = InterdiffFileStatus{ 156 + StatusKind: StatusError, 157 + Error: err, 158 + } 159 + return &interdiffFile 160 + } 161 + 162 + parsed, _, err := gitdiff.Parse(strings.NewReader(diff)) 163 + if err != nil { 164 + interdiffFile.Status = InterdiffFileStatus{ 165 + StatusKind: StatusError, 166 + Error: err, 167 + } 168 + return &interdiffFile 169 + } 170 + 171 + if len(parsed) != 1 { 172 + // files are identical? 173 + interdiffFile.Status = InterdiffFileStatus{ 174 + StatusKind: StatusUnchanged, 175 + } 176 + return &interdiffFile 177 + } 178 + 179 + if interdiffFile.Status.StatusKind == StatusOk { 180 + interdiffFile.File = parsed[0] 181 + } 182 + 183 + return &interdiffFile 184 + } 185 + 186 + func Interdiff(patch1, patch2 []*gitdiff.File) *InterdiffResult { 187 + fileToIdx1 := make(map[string]int) 188 + fileToIdx2 := make(map[string]int) 189 + visited := make(map[string]struct{}) 190 + var result InterdiffResult 191 + 192 + for idx, f := range patch1 { 193 + fileToIdx1[bestName(f)] = idx 194 + } 195 + 196 + for idx, f := range patch2 { 197 + fileToIdx2[bestName(f)] = idx 198 + } 199 + 200 + for _, f1 := range patch1 { 201 + var interdiffFile *InterdiffFile 202 + 203 + fileName := bestName(f1) 204 + if idx, ok := fileToIdx2[fileName]; ok { 205 + f2 := patch2[idx] 206 + 207 + // we have f1 and f2, calculate interdiff 208 + interdiffFile = interdiffFiles(f1, f2) 209 + } else { 210 + // only in patch 1, this change would have to be "inverted" to dissapear 211 + // from patch 2, so we reverseDiff(f1) 212 + reverseDiff(f1) 213 + 214 + interdiffFile = &InterdiffFile{ 215 + File: f1, 216 + Name: fileName, 217 + Status: InterdiffFileStatus{ 218 + StatusKind: StatusOnlyInOne, 219 + }, 220 + } 221 + } 222 + 223 + result.Files = append(result.Files, interdiffFile) 224 + visited[fileName] = struct{}{} 225 + } 226 + 227 + // for all files in patch2 that remain unvisited; we can just add them into the output 228 + for _, f2 := range patch2 { 229 + fileName := bestName(f2) 230 + if _, ok := visited[fileName]; ok { 231 + continue 232 + } 233 + 234 + result.Files = append(result.Files, &InterdiffFile{ 235 + File: f2, 236 + Name: fileName, 237 + Status: InterdiffFileStatus{ 238 + StatusKind: StatusOnlyInTwo, 239 + }, 240 + }) 241 + } 242 + 243 + return &result 244 + }
+196
patchutil/patchutil.go
···
··· 1 + package patchutil 2 + 3 + import ( 4 + "fmt" 5 + "os" 6 + "os/exec" 7 + "regexp" 8 + "strings" 9 + 10 + "github.com/bluekeyes/go-gitdiff/gitdiff" 11 + ) 12 + 13 + type FormatPatch struct { 14 + Files []*gitdiff.File 15 + *gitdiff.PatchHeader 16 + } 17 + 18 + func ExtractPatches(formatPatch string) ([]FormatPatch, error) { 19 + patches := splitFormatPatch(formatPatch) 20 + 21 + result := []FormatPatch{} 22 + 23 + for _, patch := range patches { 24 + files, headerStr, err := gitdiff.Parse(strings.NewReader(patch)) 25 + if err != nil { 26 + return nil, fmt.Errorf("failed to parse patch: %w", err) 27 + } 28 + 29 + header, err := gitdiff.ParsePatchHeader(headerStr) 30 + if err != nil { 31 + return nil, fmt.Errorf("failed to parse patch header: %w", err) 32 + } 33 + 34 + result = append(result, FormatPatch{ 35 + Files: files, 36 + PatchHeader: header, 37 + }) 38 + } 39 + 40 + return result, nil 41 + } 42 + 43 + // IsPatchValid checks if the given patch string is valid. 44 + // It performs very basic sniffing for either git-diff or git-format-patch 45 + // header lines. For format patches, it attempts to extract and validate each one. 46 + func IsPatchValid(patch string) bool { 47 + if len(patch) == 0 { 48 + return false 49 + } 50 + 51 + lines := strings.Split(patch, "\n") 52 + if len(lines) < 2 { 53 + return false 54 + } 55 + 56 + firstLine := strings.TrimSpace(lines[0]) 57 + 58 + // check if it's a git diff 59 + if strings.HasPrefix(firstLine, "diff ") || 60 + strings.HasPrefix(firstLine, "--- ") || 61 + strings.HasPrefix(firstLine, "Index: ") || 62 + strings.HasPrefix(firstLine, "+++ ") || 63 + strings.HasPrefix(firstLine, "@@ ") { 64 + return true 65 + } 66 + 67 + // check if it's format-patch 68 + if strings.HasPrefix(firstLine, "From ") && strings.Contains(firstLine, " Mon Sep 17 00:00:00 2001") || 69 + strings.HasPrefix(firstLine, "From: ") { 70 + // ExtractPatches already runs it through gitdiff.Parse so if that errors, 71 + // it's safe to say it's broken. 72 + patches, err := ExtractPatches(patch) 73 + if err != nil { 74 + return false 75 + } 76 + return len(patches) > 0 77 + } 78 + 79 + return false 80 + } 81 + 82 + func IsFormatPatch(patch string) bool { 83 + lines := strings.Split(patch, "\n") 84 + if len(lines) < 2 { 85 + return false 86 + } 87 + 88 + firstLine := strings.TrimSpace(lines[0]) 89 + if strings.HasPrefix(firstLine, "From ") && strings.Contains(firstLine, " Mon Sep 17 00:00:00 2001") { 90 + return true 91 + } 92 + 93 + headerCount := 0 94 + for i := range min(10, len(lines)) { 95 + line := strings.TrimSpace(lines[i]) 96 + if strings.HasPrefix(line, "From: ") || 97 + strings.HasPrefix(line, "Date: ") || 98 + strings.HasPrefix(line, "Subject: ") || 99 + strings.HasPrefix(line, "commit ") { 100 + headerCount++ 101 + } 102 + } 103 + 104 + return headerCount >= 2 105 + } 106 + 107 + func splitFormatPatch(patchText string) []string { 108 + re := regexp.MustCompile(`(?m)^From [0-9a-f]{40} .*$`) 109 + 110 + indexes := re.FindAllStringIndex(patchText, -1) 111 + 112 + if len(indexes) == 0 { 113 + return []string{} 114 + } 115 + 116 + patches := make([]string, len(indexes)) 117 + 118 + for i := range indexes { 119 + startPos := indexes[i][0] 120 + endPos := len(patchText) 121 + 122 + if i < len(indexes)-1 { 123 + endPos = indexes[i+1][0] 124 + } 125 + 126 + patches[i] = strings.TrimSpace(patchText[startPos:endPos]) 127 + } 128 + return patches 129 + } 130 + 131 + func bestName(file *gitdiff.File) string { 132 + if file.IsDelete { 133 + return file.OldName 134 + } else { 135 + return file.NewName 136 + } 137 + } 138 + 139 + // in-place reverse of a diff 140 + func reverseDiff(file *gitdiff.File) { 141 + file.OldName, file.NewName = file.NewName, file.OldName 142 + file.OldMode, file.NewMode = file.NewMode, file.OldMode 143 + file.BinaryFragment, file.ReverseBinaryFragment = file.ReverseBinaryFragment, file.BinaryFragment 144 + 145 + for _, fragment := range file.TextFragments { 146 + // swap postions 147 + fragment.OldPosition, fragment.NewPosition = fragment.NewPosition, fragment.OldPosition 148 + fragment.OldLines, fragment.NewLines = fragment.NewLines, fragment.OldLines 149 + fragment.LinesAdded, fragment.LinesDeleted = fragment.LinesDeleted, fragment.LinesAdded 150 + 151 + for i := range fragment.Lines { 152 + switch fragment.Lines[i].Op { 153 + case gitdiff.OpAdd: 154 + fragment.Lines[i].Op = gitdiff.OpDelete 155 + case gitdiff.OpDelete: 156 + fragment.Lines[i].Op = gitdiff.OpAdd 157 + default: 158 + // do nothing 159 + } 160 + } 161 + } 162 + } 163 + 164 + func Unified(oldText, oldFile, newText, newFile string) (string, error) { 165 + oldTemp, err := os.CreateTemp("", "old_*") 166 + if err != nil { 167 + return "", fmt.Errorf("failed to create temp file for oldText: %w", err) 168 + } 169 + defer os.Remove(oldTemp.Name()) 170 + if _, err := oldTemp.WriteString(oldText); err != nil { 171 + return "", fmt.Errorf("failed to write to old temp file: %w", err) 172 + } 173 + oldTemp.Close() 174 + 175 + newTemp, err := os.CreateTemp("", "new_*") 176 + if err != nil { 177 + return "", fmt.Errorf("failed to create temp file for newText: %w", err) 178 + } 179 + defer os.Remove(newTemp.Name()) 180 + if _, err := newTemp.WriteString(newText); err != nil { 181 + return "", fmt.Errorf("failed to write to new temp file: %w", err) 182 + } 183 + newTemp.Close() 184 + 185 + cmd := exec.Command("diff", "-u", "--label", oldFile, "--label", newFile, oldTemp.Name(), newTemp.Name()) 186 + output, err := cmd.CombinedOutput() 187 + 188 + if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 { 189 + return string(output), nil 190 + } 191 + if err != nil { 192 + return "", fmt.Errorf("diff command failed: %w", err) 193 + } 194 + 195 + return string(output), nil 196 + }
+324
patchutil/patchutil_test.go
···
··· 1 + package patchutil 2 + 3 + import ( 4 + "reflect" 5 + "testing" 6 + ) 7 + 8 + func TestIsPatchValid(t *testing.T) { 9 + tests := []struct { 10 + name string 11 + patch string 12 + expected bool 13 + }{ 14 + { 15 + name: `empty patch`, 16 + patch: ``, 17 + expected: false, 18 + }, 19 + { 20 + name: `single line patch`, 21 + patch: `single line`, 22 + expected: false, 23 + }, 24 + { 25 + name: `valid diff patch`, 26 + patch: `diff --git a/file.txt b/file.txt 27 + index abc..def 100644 28 + --- a/file.txt 29 + +++ b/file.txt 30 + @@ -1,3 +1,3 @@ 31 + -old line 32 + +new line 33 + context`, 34 + expected: true, 35 + }, 36 + { 37 + name: `valid patch starting with ---`, 38 + patch: `--- a/file.txt 39 + +++ b/file.txt 40 + @@ -1,3 +1,3 @@ 41 + -old line 42 + +new line 43 + context`, 44 + expected: true, 45 + }, 46 + { 47 + name: `valid patch starting with Index`, 48 + patch: `Index: file.txt 49 + ========== 50 + --- a/file.txt 51 + +++ b/file.txt 52 + @@ -1,3 +1,3 @@ 53 + -old line 54 + +new line 55 + context`, 56 + expected: true, 57 + }, 58 + { 59 + name: `valid patch starting with +++`, 60 + patch: `+++ b/file.txt 61 + --- a/file.txt 62 + @@ -1,3 +1,3 @@ 63 + -old line 64 + +new line 65 + context`, 66 + expected: true, 67 + }, 68 + { 69 + name: `valid patch starting with @@`, 70 + patch: `@@ -1,3 +1,3 @@ 71 + -old line 72 + +new line 73 + context 74 + `, 75 + expected: true, 76 + }, 77 + { 78 + name: `valid format patch`, 79 + patch: `From 3c5035488318164b81f60fe3adcd6c9199d76331 Mon Sep 17 00:00:00 2001 80 + From: Author <author@example.com> 81 + Date: Wed, 16 Apr 2025 11:01:00 +0300 82 + Subject: [PATCH] Example patch 83 + 84 + diff --git a/file.txt b/file.txt 85 + index 123456..789012 100644 86 + --- a/file.txt 87 + +++ b/file.txt 88 + @@ -1 +1 @@ 89 + -old content 90 + +new content 91 + -- 92 + 2.48.1`, 93 + expected: true, 94 + }, 95 + { 96 + name: `invalid format patch`, 97 + patch: `From 1234567890123456789012345678901234567890 Mon Sep 17 00:00:00 2001 98 + From: Author <author@example.com> 99 + This is not a valid patch format`, 100 + expected: false, 101 + }, 102 + { 103 + name: `not a patch at all`, 104 + patch: `This is 105 + just some 106 + random text 107 + that isn't a patch`, 108 + expected: false, 109 + }, 110 + } 111 + 112 + for _, tt := range tests { 113 + t.Run(tt.name, func(t *testing.T) { 114 + result := IsPatchValid(tt.patch) 115 + if result != tt.expected { 116 + t.Errorf("IsPatchValid() = %v, want %v", result, tt.expected) 117 + } 118 + }) 119 + } 120 + } 121 + 122 + func TestSplitPatches(t *testing.T) { 123 + tests := []struct { 124 + name string 125 + input string 126 + expected []string 127 + }{ 128 + { 129 + name: "Empty input", 130 + input: "", 131 + expected: []string{}, 132 + }, 133 + { 134 + name: "No valid patches", 135 + input: "This is not a \nJust some random text", 136 + expected: []string{}, 137 + }, 138 + { 139 + name: "Single patch", 140 + input: `From 3c5035488318164b81f60fe3adcd6c9199d76331 Mon Sep 17 00:00:00 2001 141 + From: Author <author@example.com> 142 + Date: Wed, 16 Apr 2025 11:01:00 +0300 143 + Subject: [PATCH] Example patch 144 + 145 + diff --git a/file.txt b/file.txt 146 + index 123456..789012 100644 147 + --- a/file.txt 148 + +++ b/file.txt 149 + @@ -1 +1 @@ 150 + -old content 151 + +new content 152 + -- 153 + 2.48.1`, 154 + expected: []string{ 155 + `From 3c5035488318164b81f60fe3adcd6c9199d76331 Mon Sep 17 00:00:00 2001 156 + From: Author <author@example.com> 157 + Date: Wed, 16 Apr 2025 11:01:00 +0300 158 + Subject: [PATCH] Example patch 159 + 160 + diff --git a/file.txt b/file.txt 161 + index 123456..789012 100644 162 + --- a/file.txt 163 + +++ b/file.txt 164 + @@ -1 +1 @@ 165 + -old content 166 + +new content 167 + -- 168 + 2.48.1`, 169 + }, 170 + }, 171 + { 172 + name: "Two patches", 173 + input: `From 3c5035488318164b81f60fe3adcd6c9199d76331 Mon Sep 17 00:00:00 2001 174 + From: Author <author@example.com> 175 + Date: Wed, 16 Apr 2025 11:01:00 +0300 176 + Subject: [PATCH 1/2] First patch 177 + 178 + diff --git a/file1.txt b/file1.txt 179 + index 123456..789012 100644 180 + --- a/file1.txt 181 + +++ b/file1.txt 182 + @@ -1 +1 @@ 183 + -old content 184 + +new content 185 + -- 186 + 2.48.1 187 + From a9529f3b3a653329a5268f0f4067225480207e3c Mon Sep 17 00:00:00 2001 188 + From: Author <author@example.com> 189 + Date: Wed, 16 Apr 2025 11:03:11 +0300 190 + Subject: [PATCH 2/2] Second patch 191 + 192 + diff --git a/file2.txt b/file2.txt 193 + index abcdef..ghijkl 100644 194 + --- a/file2.txt 195 + +++ b/file2.txt 196 + @@ -1 +1 @@ 197 + -foo bar 198 + +baz qux 199 + -- 200 + 2.48.1`, 201 + expected: []string{ 202 + `From 3c5035488318164b81f60fe3adcd6c9199d76331 Mon Sep 17 00:00:00 2001 203 + From: Author <author@example.com> 204 + Date: Wed, 16 Apr 2025 11:01:00 +0300 205 + Subject: [PATCH 1/2] First patch 206 + 207 + diff --git a/file1.txt b/file1.txt 208 + index 123456..789012 100644 209 + --- a/file1.txt 210 + +++ b/file1.txt 211 + @@ -1 +1 @@ 212 + -old content 213 + +new content 214 + -- 215 + 2.48.1`, 216 + `From a9529f3b3a653329a5268f0f4067225480207e3c Mon Sep 17 00:00:00 2001 217 + From: Author <author@example.com> 218 + Date: Wed, 16 Apr 2025 11:03:11 +0300 219 + Subject: [PATCH 2/2] Second patch 220 + 221 + diff --git a/file2.txt b/file2.txt 222 + index abcdef..ghijkl 100644 223 + --- a/file2.txt 224 + +++ b/file2.txt 225 + @@ -1 +1 @@ 226 + -foo bar 227 + +baz qux 228 + -- 229 + 2.48.1`, 230 + }, 231 + }, 232 + { 233 + name: "Patches with additional text between them", 234 + input: `Some text before the patches 235 + 236 + From 3c5035488318164b81f60fe3adcd6c9199d76331 Mon Sep 17 00:00:00 2001 237 + From: Author <author@example.com> 238 + Subject: [PATCH] First patch 239 + 240 + diff content here 241 + -- 242 + 2.48.1 243 + 244 + Some text between patches 245 + 246 + From a9529f3b3a653329a5268f0f4067225480207e3c Mon Sep 17 00:00:00 2001 247 + From: Author <author@example.com> 248 + Subject: [PATCH] Second patch 249 + 250 + more diff content 251 + -- 252 + 2.48.1 253 + 254 + Text after patches`, 255 + expected: []string{ 256 + `From 3c5035488318164b81f60fe3adcd6c9199d76331 Mon Sep 17 00:00:00 2001 257 + From: Author <author@example.com> 258 + Subject: [PATCH] First patch 259 + 260 + diff content here 261 + -- 262 + 2.48.1 263 + 264 + Some text between patches`, 265 + `From a9529f3b3a653329a5268f0f4067225480207e3c Mon Sep 17 00:00:00 2001 266 + From: Author <author@example.com> 267 + Subject: [PATCH] Second patch 268 + 269 + more diff content 270 + -- 271 + 2.48.1 272 + 273 + Text after patches`, 274 + }, 275 + }, 276 + { 277 + name: "Patches with whitespace padding", 278 + input: ` 279 + 280 + From 3c5035488318164b81f60fe3adcd6c9199d76331 Mon Sep 17 00:00:00 2001 281 + From: Author <author@example.com> 282 + Subject: Patch 283 + 284 + content 285 + -- 286 + 2.48.1 287 + 288 + 289 + From a9529f3b3a653329a5268f0f4067225480207e3c Mon Sep 17 00:00:00 2001 290 + From: Author <author@example.com> 291 + Subject: Another patch 292 + 293 + content 294 + -- 295 + 2.48.1 296 + `, 297 + expected: []string{ 298 + `From 3c5035488318164b81f60fe3adcd6c9199d76331 Mon Sep 17 00:00:00 2001 299 + From: Author <author@example.com> 300 + Subject: Patch 301 + 302 + content 303 + -- 304 + 2.48.1`, 305 + `From a9529f3b3a653329a5268f0f4067225480207e3c Mon Sep 17 00:00:00 2001 306 + From: Author <author@example.com> 307 + Subject: Another patch 308 + 309 + content 310 + -- 311 + 2.48.1`, 312 + }, 313 + }, 314 + } 315 + 316 + for _, tt := range tests { 317 + t.Run(tt.name, func(t *testing.T) { 318 + result := splitFormatPatch(tt.input) 319 + if !reflect.DeepEqual(result, tt.expected) { 320 + t.Errorf("splitPatches() = %v, want %v", result, tt.expected) 321 + } 322 + }) 323 + } 324 + }
+55 -34
rbac/rbac.go
··· 3 import ( 4 "database/sql" 5 "fmt" 6 - "path" 7 "strings" 8 9 adapter "github.com/Blank-Xu/sql-adapter" ··· 26 e = some(where (p.eft == allow)) 27 28 [matchers] 29 - m = r.act == p.act && r.dom == p.dom && keyMatch2(r.obj, p.obj) && g(r.sub, p.sub, r.dom) 30 ` 31 ) 32 ··· 34 E *casbin.Enforcer 35 } 36 37 - func keyMatch2(key1 string, key2 string) bool { 38 - matched, _ := path.Match(key2, key1) 39 - return matched 40 - } 41 - 42 func NewEnforcer(path string) (*Enforcer, error) { 43 m, err := model.NewModelFromString(Model) 44 if err != nil { ··· 61 } 62 63 e.EnableAutoSave(false) 64 - 65 - e.AddFunction("keyMatch2", keyMatch2Func) 66 67 return &Enforcer{e}, nil 68 } ··· 96 return err 97 } 98 99 - func (e *Enforcer) AddRepo(member, domain, repo string) error { 100 - // sanity check, repo must be of the form ownerDid/repo 101 - if parts := strings.SplitN(repo, "/", 2); !strings.HasPrefix(parts[0], "did:") { 102 - return fmt.Errorf("invalid repo: %s", repo) 103 - } 104 - 105 - _, err := e.E.AddPolicies([][]string{ 106 {member, domain, repo, "repo:settings"}, 107 {member, domain, repo, "repo:push"}, 108 {member, domain, repo, "repo:owner"}, 109 {member, domain, repo, "repo:invite"}, 110 {member, domain, repo, "repo:delete"}, 111 {"server:owner", domain, repo, "repo:delete"}, // server owner can delete any repo 112 - }) 113 return err 114 } 115 116 func (e *Enforcer) AddCollaborator(collaborator, domain, repo string) error { 117 - // sanity check, repo must be of the form ownerDid/repo 118 - if parts := strings.SplitN(repo, "/", 2); !strings.HasPrefix(parts[0], "did:") { 119 - return fmt.Errorf("invalid repo: %s", repo) 120 } 121 122 - _, err := e.E.AddPolicies([][]string{ 123 - {collaborator, domain, repo, "repo:collaborator"}, 124 - {collaborator, domain, repo, "repo:settings"}, 125 - {collaborator, domain, repo, "repo:push"}, 126 - }) 127 return err 128 } 129 ··· 165 return e.E.Enforce(user, domain, repo, "repo:settings") 166 } 167 168 // given a repo, what permissions does this user have? repo:owner? repo:invite? etc. 169 func (e *Enforcer) GetPermissionsInRepo(user, domain, repo string) []string { 170 var permissions []string ··· 179 return permissions 180 } 181 182 - func (e *Enforcer) IsCollaboratorInviteAllowed(user, domain, repo string) (bool, error) { 183 - return e.E.Enforce(user, domain, repo, "repo:invite") 184 - } 185 186 - // keyMatch2Func is a wrapper for keyMatch2 to make it compatible with Casbin 187 - func keyMatch2Func(args ...interface{}) (interface{}, error) { 188 - name1 := args[0].(string) 189 - name2 := args[1].(string) 190 - 191 - return keyMatch2(name1, name2), nil 192 }
··· 3 import ( 4 "database/sql" 5 "fmt" 6 "strings" 7 8 adapter "github.com/Blank-Xu/sql-adapter" ··· 25 e = some(where (p.eft == allow)) 26 27 [matchers] 28 + m = r.act == p.act && r.dom == p.dom && r.obj == p.obj && g(r.sub, p.sub, r.dom) 29 ` 30 ) 31 ··· 33 E *casbin.Enforcer 34 } 35 36 func NewEnforcer(path string) (*Enforcer, error) { 37 m, err := model.NewModelFromString(Model) 38 if err != nil { ··· 55 } 56 57 e.EnableAutoSave(false) 58 59 return &Enforcer{e}, nil 60 } ··· 88 return err 89 } 90 91 + func repoPolicies(member, domain, repo string) [][]string { 92 + return [][]string{ 93 {member, domain, repo, "repo:settings"}, 94 {member, domain, repo, "repo:push"}, 95 {member, domain, repo, "repo:owner"}, 96 {member, domain, repo, "repo:invite"}, 97 {member, domain, repo, "repo:delete"}, 98 {"server:owner", domain, repo, "repo:delete"}, // server owner can delete any repo 99 + } 100 + } 101 + func (e *Enforcer) AddRepo(member, domain, repo string) error { 102 + err := checkRepoFormat(repo) 103 + if err != nil { 104 + return err 105 + } 106 + 107 + _, err = e.E.AddPolicies(repoPolicies(member, domain, repo)) 108 return err 109 } 110 + func (e *Enforcer) RemoveRepo(member, domain, repo string) error { 111 + err := checkRepoFormat(repo) 112 + if err != nil { 113 + return err 114 + } 115 + 116 + _, err = e.E.RemovePolicies(repoPolicies(member, domain, repo)) 117 + return err 118 + } 119 + 120 + var ( 121 + collaboratorPolicies = func(collaborator, domain, repo string) [][]string { 122 + return [][]string{ 123 + {collaborator, domain, repo, "repo:collaborator"}, 124 + {collaborator, domain, repo, "repo:settings"}, 125 + {collaborator, domain, repo, "repo:push"}, 126 + } 127 + } 128 + ) 129 130 func (e *Enforcer) AddCollaborator(collaborator, domain, repo string) error { 131 + err := checkRepoFormat(repo) 132 + if err != nil { 133 + return err 134 } 135 136 + _, err = e.E.AddPolicies(collaboratorPolicies(collaborator, domain, repo)) 137 + return err 138 + } 139 + 140 + func (e *Enforcer) RemoveCollaborator(collaborator, domain, repo string) error { 141 + err := checkRepoFormat(repo) 142 + if err != nil { 143 + return err 144 + } 145 + 146 + _, err = e.E.RemovePolicies(collaboratorPolicies(collaborator, domain, repo)) 147 return err 148 } 149 ··· 185 return e.E.Enforce(user, domain, repo, "repo:settings") 186 } 187 188 + func (e *Enforcer) IsCollaboratorInviteAllowed(user, domain, repo string) (bool, error) { 189 + return e.E.Enforce(user, domain, repo, "repo:invite") 190 + } 191 + 192 // given a repo, what permissions does this user have? repo:owner? repo:invite? etc. 193 func (e *Enforcer) GetPermissionsInRepo(user, domain, repo string) []string { 194 var permissions []string ··· 203 return permissions 204 } 205 206 + func checkRepoFormat(repo string) error { 207 + // sanity check, repo must be of the form ownerDid/repo 208 + if parts := strings.SplitN(repo, "/", 2); !strings.HasPrefix(parts[0], "did:") { 209 + return fmt.Errorf("invalid repo: %s", repo) 210 + } 211 212 + return nil 213 }
+8 -89
readme.md
··· 6 7 Read the introduction to Tangled [here](https://blog.tangled.sh/intro). 8 9 - ## knot self-hosting guide 10 11 - So you want to run your own knot server? Great! Here are a few prerequisites: 12 13 - 1. A server of some kind (a VPS, a Raspberry Pi, etc.). Preferably running a Linux of some kind. 14 - 2. A (sub)domain name. People generally use `knot.example.com`. 15 - 3. A valid SSL certificate for your domain. 16 17 - There's a couple of ways to get started: 18 - * NixOS: refer to [flake.nix](https://tangled.sh/@tangled.sh/core/blob/master/flake.nix) 19 - * Manual: Documented below. 20 - 21 - ### manual setup 22 - 23 - First, clone this repository: 24 - 25 - ``` 26 - git clone https://tangled.sh/@tangled.sh/core 27 - ``` 28 - 29 - Then, build our binaries (you need to have Go installed): 30 - * `knotserver`: the main server program 31 - * `keyfetch`: utility to fetch ssh pubkeys 32 - * `repoguard`: enforces repository access control 33 - 34 - ``` 35 - cd core 36 - export CGO_ENABLED=1 37 - go build -o knot ./cmd/knotserver 38 - go build -o keyfetch ./cmd/keyfetch 39 - go build -o repoguard ./cmd/repoguard 40 - ``` 41 - 42 - Next, move the `keyfetch` binary to a location owned by `root` -- 43 - `/usr/local/libexec/tangled-keyfetch` is a good choice: 44 - 45 - ``` 46 - sudo mv keyfetch /usr/local/libexec/tangled-keyfetch 47 - sudo chown root:root /usr/local/libexec/tangled-keyfetch 48 - sudo chmod 755 /usr/local/libexec/tangled-keyfetch 49 - ``` 50 - 51 - This is necessary because SSH `AuthorizedKeysCommand` requires [really specific 52 - permissions](https://stackoverflow.com/a/27638306). Let's set that up: 53 - 54 - ``` 55 - sudo tee /etc/ssh/sshd_config.d/authorized_keys_command.conf <<EOF 56 - Match User git 57 - AuthorizedKeysCommand /usr/local/libexec/tangled-keyfetch 58 - AuthorizedKeysCommandUser nobody 59 - EOF 60 - ``` 61 - 62 - Next, create the `git` user: 63 - 64 - ``` 65 - sudo adduser git 66 - ``` 67 - 68 - Copy the `repoguard` binary to the `git` user's home directory: 69 - 70 - ``` 71 - sudo cp repoguard /home/git 72 - sudo chown git:git /home/git/repoguard 73 - ``` 74 - 75 - Now, let's set up the server. Copy the `knot` binary to 76 - `/usr/local/bin/knotserver`. Then, create `/home/git/.knot.env` with the 77 - following, updating the values as necessary. The `KNOT_SERVER_SECRET` can be 78 - obtaind from the [/knots](/knots) page on Tangled. 79 - 80 - ``` 81 - KNOT_REPO_SCAN_PATH=/home/git 82 - KNOT_SERVER_HOSTNAME=knot.example.com 83 - APPVIEW_ENDPOINT=https://tangled.sh 84 - KNOT_SERVER_SECRET=secret 85 - KNOT_SERVER_INTERNAL_LISTEN_ADDR=127.0.0.1:5444 86 - KNOT_SERVER_LISTEN_ADDR=127.0.0.1:5555 87 - ``` 88 - 89 - If you run a Linux distribution that uses systemd, you can use the provided 90 - service file to run the server. Copy 91 - [`knotserver.service`](https://tangled.sh/did:plc:wshs7t2adsemcrrd4snkeqli/core/blob/master/systemd/knotserver.service) 92 - to `/etc/systemd/system/`. Then, run: 93 - 94 - ``` 95 - systemctl enable knotserver 96 - systemctl start knotserver 97 - ``` 98 - 99 - You should now have a running knot server! You can finalize your registration by hitting the 100 - `initialize` button on the [/knots](/knots) page.
··· 6 7 Read the introduction to Tangled [here](https://blog.tangled.sh/intro). 8 9 + ## docs 10 11 + * [knot hosting 12 + guide](https://tangled.sh/@tangled.sh/core/blob/master/docs/knot-hosting.md) 13 + * [contributing 14 + guide](https://tangled.sh/@tangled.sh/core/blob/master/docs/contributing.md)&mdash;**read this before opening a PR!** 15 16 + ## security 17 18 + If you've identified a security issue in Tangled, please email 19 + [security@tangled.sh](mailto:security@tangled.sh) with details!
+5
scripts/generate-jwks.sh
···
··· 1 + #! /usr/bin/env bash 2 + 3 + set -e 4 + 5 + go run ./cmd/genjwks/
+43 -9
tailwind.config.js
··· 1 /** @type {import('tailwindcss').Config} */ 2 - const colors = require('tailwindcss/colors') 3 4 module.exports = { 5 - content: ["./appview/pages/templates/**/*.html"], 6 theme: { 7 container: { 8 padding: "2rem", ··· 12 md: "600px", 13 lg: "800px", 14 xl: "1000px", 15 - "2xl": "1200px" 16 }, 17 }, 18 extend: { 19 fontFamily: { 20 - sans: ["iA Writer Quattro S", "Inter", "system-ui", "sans-serif", "ui-sans-serif"], 21 - mono: ["iA Writer Mono S", "ui-monospace", "SFMono-Regular", "Menlo", "Monaco", "Consolas", "Liberation Mono", "Courier New", "monospace"], 22 }, 23 typography: { 24 DEFAULT: { 25 css: { 26 - maxWidth: 'none', 27 pre: { 28 backgroundColor: colors.gray[100], 29 color: colors.black, 30 }, 31 }, 32 }, 33 }, 34 }, 35 }, 36 - plugins: [ 37 - require('@tailwindcss/typography'), 38 - ] 39 };
··· 1 /** @type {import('tailwindcss').Config} */ 2 + const colors = require("tailwindcss/colors"); 3 4 module.exports = { 5 + content: ["./appview/pages/templates/**/*.html", "./appview/pages/chroma.go"], 6 + darkMode: "media", 7 theme: { 8 container: { 9 padding: "2rem", ··· 13 md: "600px", 14 lg: "800px", 15 xl: "1000px", 16 + "2xl": "1200px", 17 }, 18 }, 19 extend: { 20 fontFamily: { 21 + sans: ["InterVariable", "system-ui", "sans-serif", "ui-sans-serif"], 22 + mono: [ 23 + "IBMPlexMono", 24 + "ui-monospace", 25 + "SFMono-Regular", 26 + "Menlo", 27 + "Monaco", 28 + "Consolas", 29 + "Liberation Mono", 30 + "Courier New", 31 + "monospace", 32 + ], 33 }, 34 typography: { 35 DEFAULT: { 36 css: { 37 + maxWidth: "none", 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": {}, 42 + }, 43 + code: { 44 + "@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": {}, 45 + }, 46 + "code::before": { 47 + content: '""', 48 + }, 49 + "code::after": { 50 + content: '""', 51 + }, 52 + blockquote: { 53 + quotes: "none", 54 + }, 55 + 'h1, h2, h3, h4': { 56 + "@apply mt-4 mb-2": {} 57 + }, 58 + h1: { 59 + "@apply mt-3 pb-3 border-b border-gray-300 dark:border-gray-600": {} 60 + }, 61 + h2: { 62 + "@apply mt-3 pb-3 border-b border-gray-200 dark:border-gray-700": {} 63 + }, 64 + h3: { 65 + "@apply mt-2": {} 66 }, 67 }, 68 }, 69 }, 70 }, 71 }, 72 + plugins: [require("@tailwindcss/typography")], 73 };
+10
types/capabilities.go
···
··· 1 + package types 2 + 3 + type Capabilities struct { 4 + PullRequests struct { 5 + FormatPatch bool `json:"format_patch"` 6 + PatchSubmissions bool `json:"patch_submissions"` 7 + BranchSubmissions bool `json:"branch_submissions"` 8 + ForkSubmissions bool `json:"fork_submissions"` 9 + } `json:"pull_requests"` 10 + }
+35
types/diff.go
··· 23 IsRename bool `json:"is_rename"` 24 } 25 26 // A nicer git diff representation. 27 type NiceDiff struct { 28 Commit struct { ··· 38 } `json:"stat"` 39 Diff []Diff `json:"diff"` 40 }
··· 23 IsRename bool `json:"is_rename"` 24 } 25 26 + type DiffStat struct { 27 + Insertions int64 28 + Deletions int64 29 + } 30 + 31 + func (d *Diff) Stats() DiffStat { 32 + var stats DiffStat 33 + for _, f := range d.TextFragments { 34 + stats.Insertions += f.LinesAdded 35 + stats.Deletions += f.LinesDeleted 36 + } 37 + return stats 38 + } 39 + 40 // A nicer git diff representation. 41 type NiceDiff struct { 42 Commit struct { ··· 52 } `json:"stat"` 53 Diff []Diff `json:"diff"` 54 } 55 + 56 + type DiffTree struct { 57 + Rev1 string `json:"rev1"` 58 + Rev2 string `json:"rev2"` 59 + Patch string `json:"patch"` 60 + Diff []*gitdiff.File `json:"diff"` 61 + } 62 + 63 + func (d *NiceDiff) ChangedFiles() []string { 64 + files := make([]string, len(d.Diff)) 65 + 66 + for i, f := range d.Diff { 67 + if f.IsDelete { 68 + files[i] = f.Name.Old 69 + } else { 70 + files[i] = f.Name.New 71 + } 72 + } 73 + 74 + return files 75 + }
+18
types/repo.go
··· 2 3 import ( 4 "github.com/go-git/go-git/v5/plumbing/object" 5 ) 6 7 type RepoIndexResponse struct { ··· 32 Diff *NiceDiff `json:"diff,omitempty"` 33 } 34 35 type RepoTreeResponse struct { 36 Ref string `json:"ref,omitempty"` 37 Parent string `json:"parent,omitempty"` ··· 53 54 type Branch struct { 55 Reference `json:"reference"` 56 } 57 58 type RepoTagsResponse struct { ··· 61 62 type RepoBranchesResponse struct { 63 Branches []Branch `json:"branches,omitempty"` 64 } 65 66 type RepoBlobResponse struct {
··· 2 3 import ( 4 "github.com/go-git/go-git/v5/plumbing/object" 5 + "tangled.sh/tangled.sh/core/patchutil" 6 ) 7 8 type RepoIndexResponse struct { ··· 33 Diff *NiceDiff `json:"diff,omitempty"` 34 } 35 36 + type RepoFormatPatchResponse struct { 37 + Rev1 string `json:"rev1,omitempty"` 38 + Rev2 string `json:"rev2,omitempty"` 39 + FormatPatch []patchutil.FormatPatch `json:"format_patch,omitempty"` 40 + Patch string `json:"patch,omitempty"` 41 + } 42 + 43 type RepoTreeResponse struct { 44 Ref string `json:"ref,omitempty"` 45 Parent string `json:"parent,omitempty"` ··· 61 62 type Branch struct { 63 Reference `json:"reference"` 64 + Commit *object.Commit `json:"commit,omitempty"` 65 + IsDefault bool `json:"is_deafult,omitempty"` 66 } 67 68 type RepoTagsResponse struct { ··· 71 72 type RepoBranchesResponse struct { 73 Branches []Branch `json:"branches,omitempty"` 74 + } 75 + 76 + type RepoBranchResponse struct { 77 + Branch Branch `json:"branch,omitempty"` 78 + } 79 + 80 + type RepoDefaultBranchResponse struct { 81 + Branch string `json:"branch,omitempty"` 82 } 83 84 type RepoBlobResponse struct {