Monorepo for Tangled tangled.org

appview: implement repo fork

authored by anirudh.fi and committed by oppi.li 4fac8249 dc6c6d17

+58 -1
api/tangled/cbor_gen.go
··· 1753 1753 } 1754 1754 1755 1755 cw := cbg.NewCborWriter(w) 1756 - fieldCount := 6 1756 + fieldCount := 7 1757 1757 1758 1758 if t.AddedAt == nil { 1759 1759 fieldCount-- 1760 1760 } 1761 1761 1762 1762 if t.Description == nil { 1763 + fieldCount-- 1764 + } 1765 + 1766 + if t.Source == nil { 1763 1767 fieldCount-- 1764 1768 } 1765 1769 ··· 1855 1859 return err 1856 1860 } 1857 1861 1862 + // t.Source (string) (string) 1863 + if t.Source != nil { 1864 + 1865 + if len("source") > 1000000 { 1866 + return xerrors.Errorf("Value in field \"source\" was too long") 1867 + } 1868 + 1869 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("source"))); err != nil { 1870 + return err 1871 + } 1872 + if _, err := cw.WriteString(string("source")); err != nil { 1873 + return err 1874 + } 1875 + 1876 + if t.Source == nil { 1877 + if _, err := cw.Write(cbg.CborNull); err != nil { 1878 + return err 1879 + } 1880 + } else { 1881 + if len(*t.Source) > 1000000 { 1882 + return xerrors.Errorf("Value in field t.Source was too long") 1883 + } 1884 + 1885 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Source))); err != nil { 1886 + return err 1887 + } 1888 + if _, err := cw.WriteString(string(*t.Source)); err != nil { 1889 + return err 1890 + } 1891 + } 1892 + } 1893 + 1858 1894 // t.AddedAt (string) (string) 1859 1895 if t.AddedAt != nil { 1860 1896 ··· 2005 2041 } 2006 2042 2007 2043 t.Owner = string(sval) 2044 + } 2045 + // t.Source (string) (string) 2046 + case "source": 2047 + 2048 + { 2049 + b, err := cr.ReadByte() 2050 + if err != nil { 2051 + return err 2052 + } 2053 + if b != cbg.CborNull[0] { 2054 + if err := cr.UnreadByte(); err != nil { 2055 + return err 2056 + } 2057 + 2058 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 2059 + if err != nil { 2060 + return err 2061 + } 2062 + 2063 + t.Source = (*string)(&sval) 2064 + } 2008 2065 } 2009 2066 // t.AddedAt (string) (string) 2010 2067 case "addedAt":
+2
api/tangled/tangledrepo.go
··· 25 25 // name: name of the repo 26 26 Name string `json:"name" cborgen:"name"` 27 27 Owner string `json:"owner" cborgen:"owner"` 28 + // source: source of the repo 29 + Source *string `json:"source,omitempty" cborgen:"source,omitempty"` 28 30 }
+7
appview/db/db.go
··· 273 273 return err 274 274 }) 275 275 276 + runMigration(db, "add-source-to-repos", func(tx *sql.Tx) error { 277 + _, err := tx.Exec(` 278 + alter table repos add column source text; 279 + `) 280 + return err 281 + }) 282 + 276 283 return &DB{db}, nil 277 284 } 278 285
+31 -9
appview/db/repos.go
··· 3 3 import ( 4 4 "database/sql" 5 5 "time" 6 + 7 + "github.com/bluesky-social/indigo/atproto/syntax" 6 8 ) 7 9 8 10 type Repo struct { ··· 16 18 17 19 // optionally, populate this when querying for reverse mappings 18 20 RepoStats *RepoStats 21 + 22 + // optional 23 + Source string 19 24 } 20 25 21 26 func GetAllRepos(e Execer, limit int) ([]Repo, error) { 22 27 var repos []Repo 23 28 24 29 rows, err := e.Query( 25 - `select did, name, knot, rkey, description, created 30 + `select did, name, knot, rkey, description, created, source 26 31 from repos 27 32 order by created desc 28 33 limit ? ··· 37 42 for rows.Next() { 38 43 var repo Repo 39 44 err := scanRepo( 40 - rows, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &repo.Description, &repo.Created, 45 + rows, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &repo.Description, &repo.Created, &repo.Source, 41 46 ) 42 47 if err != nil { 43 48 return nil, err ··· 63 68 r.rkey, 64 69 r.description, 65 70 r.created, 66 - count(s.id) as star_count 71 + count(s.id) as star_count, 72 + r.source 67 73 from 68 74 repos r 69 75 left join ··· 159 165 160 166 func AddRepo(e Execer, repo *Repo) error { 161 167 _, 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, 168 + `insert into repos 169 + (did, name, knot, rkey, at_uri, description, source) 170 + values (?, ?, ?, ?, ?, ?, ?)`, 171 + repo.Did, repo.Name, repo.Knot, repo.Rkey, repo.AtUri, repo.Description, repo.Source, 166 172 ) 167 173 return err 168 174 } ··· 172 178 return err 173 179 } 174 180 181 + func GetRepoSource(e Execer, repoAt syntax.ATURI) (string, error) { 182 + var source string 183 + err := e.QueryRow(`select source from repos where at_uri = ?`, repoAt).Scan(&source) 184 + if err != nil { 185 + return "", err 186 + } 187 + return source, nil 188 + } 189 + 175 190 func AddCollaborator(e Execer, collaborator, repoOwnerDid, repoName, repoKnot string) error { 176 191 _, err := e.Exec( 177 192 `insert into collaborators (did, repo) ··· 249 264 PullCount PullCount 250 265 } 251 266 252 - func scanRepo(rows *sql.Rows, did, name, knot, rkey, description *string, created *time.Time) error { 267 + func scanRepo(rows *sql.Rows, did, name, knot, rkey, description *string, created *time.Time, source *string) error { 253 268 var createdAt string 254 269 var nullableDescription sql.NullString 255 - if err := rows.Scan(did, name, knot, rkey, &nullableDescription, &createdAt); err != nil { 270 + var nullableSource sql.NullString 271 + if err := rows.Scan(did, name, knot, rkey, &nullableDescription, &createdAt, &nullableSource); err != nil { 256 272 return err 257 273 } 258 274 ··· 267 283 *created = time.Now() 268 284 } else { 269 285 *created = createdAtTime 286 + } 287 + 288 + if nullableSource.Valid { 289 + *source = nullableSource.String 290 + } else { 291 + *source = "" 270 292 } 271 293 272 294 return nil
+17
appview/db/timeline.go
··· 9 9 *Repo 10 10 *Follow 11 11 *Star 12 + 12 13 EventAt time.Time 14 + 15 + // optional: populate only if Repo is a fork 16 + Source *Repo 13 17 } 14 18 15 19 // TODO: this gathers heterogenous events from different sources and aggregates ··· 34 38 } 35 39 36 40 for _, repo := range repos { 41 + if repo.Source != "" { 42 + sourceRepo, err := GetRepoByAtUri(e, repo.Source) 43 + if err != nil { 44 + return nil, err 45 + } 46 + 47 + events = append(events, TimelineEvent{ 48 + Repo: &repo, 49 + EventAt: repo.Created, 50 + Source: sourceRepo, 51 + }) 52 + } 53 + 37 54 events = append(events, TimelineEvent{ 38 55 Repo: &repo, 39 56 EventAt: repo.Created,
+21 -9
appview/pages/pages.go
··· 158 158 return p.execute("repo/new", w, params) 159 159 } 160 160 161 + type ForkRepoParams struct { 162 + LoggedInUser *auth.User 163 + Knots []string 164 + RepoInfo RepoInfo 165 + } 166 + 167 + func (p *Pages) ForkRepo(w io.Writer, params ForkRepoParams) error { 168 + return p.execute("repo/fork", w, params) 169 + } 170 + 161 171 type ProfilePageParams struct { 162 172 LoggedInUser *auth.User 163 173 UserDid string ··· 212 222 } 213 223 214 224 type RepoInfo struct { 215 - Name string 216 - OwnerDid string 217 - OwnerHandle string 218 - Description string 219 - Knot string 220 - RepoAt syntax.ATURI 221 - IsStarred bool 222 - Stats db.RepoStats 223 - Roles RolesInRepo 225 + Name string 226 + OwnerDid string 227 + OwnerHandle string 228 + Description string 229 + Knot string 230 + RepoAt syntax.ATURI 231 + IsStarred bool 232 + Stats db.RepoStats 233 + Roles RolesInRepo 234 + Source *db.Repo 235 + SourceHandle string 224 236 } 225 237 226 238 type RolesInRepo struct {
+10
appview/pages/templates/layouts/repobase.html
··· 2 2 3 3 {{ define "content" }} 4 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 }} 5 15 <p class="text-lg"> 6 16 <a href="/{{ .RepoInfo.OwnerWithAt }}">{{ .RepoInfo.OwnerWithAt }}</a> 7 17 <span class="select-none">/</span>
+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 }}
+20 -5
appview/pages/templates/repo/settings.html
··· 1 1 {{ define "title" }}settings &middot; {{ .RepoInfo.FullName }}{{ end }} 2 2 {{ define "repoContent" }} 3 - <header class="font-bold text-sm mb-4 uppercase dark:text-white">Collaborators</header> 3 + <header class="font-bold text-sm mb-4 uppercase dark:text-white"> 4 + Collaborators 5 + </header> 4 6 5 7 <div id="collaborator-list" class="flex flex-col gap-2 mb-2"> 6 8 {{ range .Collaborators }} ··· 21 23 </div> 22 24 23 25 {{ if .IsCollaboratorInviteAllowed }} 24 - <h3 class="dark:text-white">add collaborator</h3> 25 26 <form hx-put="/{{ $.RepoInfo.FullName }}/settings/collaborator"> 26 - <label for="collaborator" class="dark:text-white">did or handle:</label> 27 - <input type="text" id="collaborator" name="collaborator" required class="dark:bg-gray-700 dark:text-white" /> 28 - <button class="btn my-2 dark:text-white dark:hover:bg-gray-700" type="text">add collaborator</button> 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> 29 44 </form> 30 45 {{ end }} 31 46
+7
appview/pages/templates/timeline.html
··· 44 44 <div class="flex items-center"> 45 45 <p class="text-gray-600 dark:text-gray-300"> 46 46 <a href="/{{ $userHandle }}" class="no-underline hover:underline">{{ $userHandle | truncateAt30 }}</a> 47 + {{ if .Source }} 48 + forked 49 + <a href="/{{ $userHandle }}/{{ .Repo.Name }}" class="no-underline hover:underline">{{ .Repo.Name }}</a> 50 + from 51 + <a href="/{{ index $.DidHandleMap .Source.Did }}/{{ .Source.Name }}" class="no-underline hover:underline">{{ index $.DidHandleMap .Source.Did }}/{{ .Source.Name }}</a> 52 + {{ else }} 47 53 created 48 54 <a href="/{{ $userHandle }}/{{ .Repo.Name }}" class="no-underline hover:underline">{{ .Repo.Name }}</a> 55 + {{ end }} 49 56 <time class="text-gray-700 dark:text-gray-400 text-xs">{{ .Repo.Created | timeFmt }}</time> 50 57 </p> 51 58 </div>
+191 -2
appview/state/repo.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "database/sql" 5 6 "encoding/json" 7 + "errors" 6 8 "fmt" 7 9 "io" 8 10 "log" 9 - "math/rand/v2" 11 + mathrand "math/rand/v2" 10 12 "net/http" 11 13 "path" 12 14 "slices" ··· 801 803 if err != nil { 802 804 log.Println("failed to get issue count for ", f.RepoAt) 803 805 } 806 + source, err := db.GetRepoSource(s.db, f.RepoAt) 807 + if errors.Is(err, sql.ErrNoRows) { 808 + source = "" 809 + } else if err != nil { 810 + log.Println("failed to get repo source for ", f.RepoAt) 811 + } 812 + 813 + var sourceRepo *db.Repo 814 + if source != "" { 815 + sourceRepo, err = db.GetRepoByAtUri(s.db, source) 816 + if err != nil { 817 + log.Println("failed to get repo by at uri", err) 818 + } 819 + } 804 820 805 821 knot := f.Knot 806 822 if knot == "knot1.tangled.sh" { 807 823 knot = "tangled.sh" 824 + } 825 + 826 + var sourceHandle *identity.Identity 827 + if sourceRepo != nil { 828 + sourceHandle, err = s.resolver.ResolveIdent(context.Background(), sourceRepo.Did) 829 + if err != nil { 830 + log.Println("failed to resolve source repo", err) 831 + } 808 832 } 809 833 810 834 return pages.RepoInfo{ ··· 821 845 IssueCount: issueCount, 822 846 PullCount: pullCount, 823 847 }, 848 + Source: sourceRepo, 849 + SourceHandle: sourceHandle.Handle.String(), 824 850 } 825 851 } 826 852 ··· 1022 1048 return 1023 1049 } 1024 1050 1025 - commentId := rand.IntN(1000000) 1051 + commentId := mathrand.IntN(1000000) 1026 1052 rkey := s.TID() 1027 1053 1028 1054 err := db.NewIssueComment(s.db, &db.Comment{ ··· 1481 1507 return 1482 1508 } 1483 1509 } 1510 + 1511 + func (s *State) ForkRepo(w http.ResponseWriter, r *http.Request) { 1512 + user := s.auth.GetUser(r) 1513 + f, err := fullyResolvedRepo(r) 1514 + if err != nil { 1515 + log.Printf("failed to resolve source repo: %v", err) 1516 + return 1517 + } 1518 + 1519 + switch r.Method { 1520 + case http.MethodGet: 1521 + user := s.auth.GetUser(r) 1522 + knots, err := s.enforcer.GetDomainsForUser(user.Did) 1523 + if err != nil { 1524 + s.pages.Notice(w, "repo", "Invalid user account.") 1525 + return 1526 + } 1527 + 1528 + s.pages.ForkRepo(w, pages.ForkRepoParams{ 1529 + LoggedInUser: user, 1530 + Knots: knots, 1531 + RepoInfo: f.RepoInfo(s, user), 1532 + }) 1533 + 1534 + case http.MethodPost: 1535 + 1536 + knot := r.FormValue("knot") 1537 + if knot == "" { 1538 + s.pages.Notice(w, "repo", "Invalid form submission&mdash;missing knot domain.") 1539 + return 1540 + } 1541 + 1542 + ok, err := s.enforcer.E.Enforce(user.Did, knot, knot, "repo:create") 1543 + if err != nil || !ok { 1544 + s.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.") 1545 + return 1546 + } 1547 + 1548 + forkName := fmt.Sprintf("%s", f.RepoName) 1549 + 1550 + existingRepo, err := db.GetRepo(s.db, user.Did, f.RepoName) 1551 + if err == nil && existingRepo != nil { 1552 + forkName = fmt.Sprintf("%s-%s", forkName, randomString(3)) 1553 + } 1554 + 1555 + secret, err := db.GetRegistrationKey(s.db, knot) 1556 + if err != nil { 1557 + s.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", knot)) 1558 + return 1559 + } 1560 + 1561 + client, err := NewSignedClient(knot, secret, s.config.Dev) 1562 + if err != nil { 1563 + s.pages.Notice(w, "repo", "Failed to connect to knot server.") 1564 + return 1565 + } 1566 + 1567 + var uri string 1568 + if s.config.Dev { 1569 + uri = "http" 1570 + } else { 1571 + uri = "https" 1572 + } 1573 + sourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, knot, f.OwnerDid(), f.RepoName) 1574 + sourceAt := f.RepoAt.String() 1575 + 1576 + rkey := s.TID() 1577 + repo := &db.Repo{ 1578 + Did: user.Did, 1579 + Name: forkName, 1580 + Knot: knot, 1581 + Rkey: rkey, 1582 + Source: sourceAt, 1583 + } 1584 + 1585 + tx, err := s.db.BeginTx(r.Context(), nil) 1586 + if err != nil { 1587 + log.Println(err) 1588 + s.pages.Notice(w, "repo", "Failed to save repository information.") 1589 + return 1590 + } 1591 + defer func() { 1592 + tx.Rollback() 1593 + err = s.enforcer.E.LoadPolicy() 1594 + if err != nil { 1595 + log.Println("failed to rollback policies") 1596 + } 1597 + }() 1598 + 1599 + resp, err := client.ForkRepo(user.Did, sourceUrl, forkName) 1600 + if err != nil { 1601 + s.pages.Notice(w, "repo", "Failed to create repository on knot server.") 1602 + return 1603 + } 1604 + 1605 + switch resp.StatusCode { 1606 + case http.StatusConflict: 1607 + s.pages.Notice(w, "repo", "A repository with that name already exists.") 1608 + return 1609 + case http.StatusInternalServerError: 1610 + s.pages.Notice(w, "repo", "Failed to create repository on knot. Try again later.") 1611 + case http.StatusNoContent: 1612 + // continue 1613 + } 1614 + 1615 + xrpcClient, _ := s.auth.AuthorizedClient(r) 1616 + 1617 + addedAt := time.Now().Format(time.RFC3339) 1618 + atresp, err := comatproto.RepoPutRecord(r.Context(), xrpcClient, &comatproto.RepoPutRecord_Input{ 1619 + Collection: tangled.RepoNSID, 1620 + Repo: user.Did, 1621 + Rkey: rkey, 1622 + Record: &lexutil.LexiconTypeDecoder{ 1623 + Val: &tangled.Repo{ 1624 + Knot: repo.Knot, 1625 + Name: repo.Name, 1626 + AddedAt: &addedAt, 1627 + Owner: user.Did, 1628 + Source: &sourceAt, 1629 + }}, 1630 + }) 1631 + if err != nil { 1632 + log.Printf("failed to create record: %s", err) 1633 + s.pages.Notice(w, "repo", "Failed to announce repository creation.") 1634 + return 1635 + } 1636 + log.Println("created repo record: ", atresp.Uri) 1637 + 1638 + repo.AtUri = atresp.Uri 1639 + err = db.AddRepo(tx, repo) 1640 + if err != nil { 1641 + log.Println(err) 1642 + s.pages.Notice(w, "repo", "Failed to save repository information.") 1643 + return 1644 + } 1645 + 1646 + // acls 1647 + p, _ := securejoin.SecureJoin(user.Did, forkName) 1648 + err = s.enforcer.AddRepo(user.Did, knot, p) 1649 + if err != nil { 1650 + log.Println(err) 1651 + s.pages.Notice(w, "repo", "Failed to set up repository permissions.") 1652 + return 1653 + } 1654 + 1655 + err = tx.Commit() 1656 + if err != nil { 1657 + log.Println("failed to commit changes", err) 1658 + http.Error(w, err.Error(), http.StatusInternalServerError) 1659 + return 1660 + } 1661 + 1662 + err = s.enforcer.E.SavePolicy() 1663 + if err != nil { 1664 + log.Println("failed to update ACLs", err) 1665 + http.Error(w, err.Error(), http.StatusInternalServerError) 1666 + return 1667 + } 1668 + 1669 + s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, forkName)) 1670 + return 1671 + } 1672 + }
+14
appview/state/repo_util.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "crypto/rand" 5 6 "fmt" 6 7 "log" 8 + "math/big" 7 9 "net/http" 8 10 9 11 "github.com/bluesky-social/indigo/atproto/identity" ··· 112 114 113 115 return emailToDidOrHandle 114 116 } 117 + 118 + func randomString(n int) string { 119 + const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" 120 + result := make([]byte, n) 121 + 122 + for i := 0; i < n; i++ { 123 + n, _ := rand.Int(rand.Reader, big.NewInt(int64(len(letters)))) 124 + result[i] = letters[n.Int64()] 125 + } 126 + 127 + return string(result) 128 + }
+6
appview/state/router.go
··· 85 85 }) 86 86 }) 87 87 88 + r.Route("/fork", func(r chi.Router) { 89 + r.Use(AuthMiddleware(s)) 90 + r.Get("/", s.ForkRepo) 91 + r.Post("/", s.ForkRepo) 92 + }) 93 + 88 94 r.Route("/pulls", func(r chi.Router) { 89 95 r.Get("/", s.RepoPulls) 90 96 r.With(AuthMiddleware(s)).Route("/new", func(r chi.Router) {
+20
appview/state/signer.go
··· 103 103 return s.client.Do(req) 104 104 } 105 105 106 + func (s *SignedClient) ForkRepo(ownerDid, source, name string) (*http.Response, error) { 107 + const ( 108 + Method = "POST" 109 + Endpoint = "/repo/fork" 110 + ) 111 + 112 + body, _ := json.Marshal(map[string]any{ 113 + "did": ownerDid, 114 + "source": source, 115 + "name": name, 116 + }) 117 + 118 + req, err := s.newRequest(Method, Endpoint, body) 119 + if err != nil { 120 + return nil, err 121 + } 122 + 123 + return s.client.Do(req) 124 + } 125 + 106 126 func (s *SignedClient) RemoveRepo(did, repoName string) (*http.Response, error) { 107 127 const ( 108 128 Method = "DELETE"
+3
appview/state/state.go
··· 190 190 for _, ev := range timeline { 191 191 if ev.Repo != nil { 192 192 didsToResolve = append(didsToResolve, ev.Repo.Did) 193 + if ev.Source != nil { 194 + didsToResolve = append(didsToResolve, ev.Source.Did) 195 + } 193 196 } 194 197 if ev.Follow != nil { 195 198 didsToResolve = append(didsToResolve, ev.Follow.UserDid, ev.Follow.SubjectDid)
+20
knotserver/git/fork.go
··· 1 + package git 2 + 3 + import ( 4 + "fmt" 5 + 6 + "github.com/go-git/go-git/v5" 7 + ) 8 + 9 + func Fork(repoPath, source string) error { 10 + _, err := git.PlainClone(repoPath, true, &git.CloneOptions{ 11 + URL: source, 12 + Depth: 1, 13 + SingleBranch: false, 14 + }) 15 + 16 + if err != nil { 17 + return fmt.Errorf("failed to bare clone repository: %w", err) 18 + } 19 + return nil 20 + }
+1
knotserver/handler.go
··· 120 120 r.Use(h.VerifySignature) 121 121 r.Put("/new", h.NewRepo) 122 122 r.Delete("/", h.RemoveRepo) 123 + r.Post("/fork", h.RepoFork) 123 124 }) 124 125 125 126 r.Route("/member", func(r chi.Router) {
+43
knotserver/routes.go
··· 577 577 w.WriteHeader(http.StatusNoContent) 578 578 } 579 579 580 + func (h *Handle) RepoFork(w http.ResponseWriter, r *http.Request) { 581 + l := h.l.With("handler", "RepoFork") 582 + 583 + data := struct { 584 + Did string `json:"did"` 585 + Source string `json:"source"` 586 + Name string `json:"name,omitempty"` 587 + }{} 588 + 589 + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { 590 + writeError(w, "invalid request body", http.StatusBadRequest) 591 + return 592 + } 593 + 594 + did := data.Did 595 + source := data.Source 596 + 597 + if did == "" || source == "" { 598 + l.Error("invalid request body, empty did or name") 599 + w.WriteHeader(http.StatusBadRequest) 600 + return 601 + } 602 + 603 + var name string 604 + if data.Name != "" { 605 + name = data.Name 606 + } else { 607 + name = filepath.Base(source) 608 + } 609 + 610 + relativeRepoPath := filepath.Join(did, name) 611 + repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath) 612 + 613 + err := git.Fork(repoPath, source) 614 + if err != nil { 615 + l.Error("forking repo", "error", err.Error()) 616 + writeError(w, err.Error(), http.StatusInternalServerError) 617 + return 618 + } 619 + 620 + w.WriteHeader(http.StatusNoContent) 621 + } 622 + 580 623 func (h *Handle) RemoveRepo(w http.ResponseWriter, r *http.Request) { 581 624 l := h.l.With("handler", "RemoveRepo") 582 625
+5
lexicons/repo.json
··· 32 32 "format": "datetime", 33 33 "minLength": 1, 34 34 "maxLength": 140 35 + }, 36 + "source": { 37 + "type": "string", 38 + "format": "uri", 39 + "description": "source of the repo" 35 40 } 36 41 } 37 42 }