From 4fac824966bef8dccd8d45008781237bca0d5a6f Mon Sep 17 00:00:00 2001
From: Anirudh Oppiliappan
Date: Tue, 1 Apr 2025 12:44:33 +0300
Subject: [PATCH] appview: implement repo fork
---
api/tangled/cbor_gen.go | 59 +++++-
api/tangled/tangledrepo.go | 2 +
appview/db/db.go | 7 +
appview/db/repos.go | 40 +++-
appview/db/timeline.go | 17 ++
appview/pages/pages.go | 30 ++-
appview/pages/templates/layouts/repobase.html | 10 +
appview/pages/templates/repo/fork.html | 38 ++++
appview/pages/templates/repo/settings.html | 25 ++-
appview/pages/templates/timeline.html | 7 +
appview/state/repo.go | 193 +++++++++++++++++-
appview/state/repo_util.go | 14 ++
appview/state/router.go | 6 +
appview/state/signer.go | 20 ++
appview/state/state.go | 3 +
knotserver/git/fork.go | 20 ++
knotserver/handler.go | 1 +
knotserver/routes.go | 43 ++++
lexicons/repo.json | 5 +
19 files changed, 514 insertions(+), 26 deletions(-)
create mode 100644 appview/pages/templates/repo/fork.html
create mode 100644 knotserver/git/fork.go
diff --git a/api/tangled/cbor_gen.go b/api/tangled/cbor_gen.go
index 5c7fa1d7..61cab855 100644
--- a/api/tangled/cbor_gen.go
+++ b/api/tangled/cbor_gen.go
@@ -1753,7 +1753,7 @@ func (t *Repo) MarshalCBOR(w io.Writer) error {
}
cw := cbg.NewCborWriter(w)
- fieldCount := 6
+ fieldCount := 7
if t.AddedAt == nil {
fieldCount--
@@ -1763,6 +1763,10 @@ func (t *Repo) MarshalCBOR(w io.Writer) error {
fieldCount--
}
+ if t.Source == nil {
+ fieldCount--
+ }
+
if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil {
return err
}
@@ -1855,6 +1859,38 @@ func (t *Repo) MarshalCBOR(w io.Writer) error {
return err
}
+ // t.Source (string) (string)
+ if t.Source != nil {
+
+ if len("source") > 1000000 {
+ return xerrors.Errorf("Value in field \"source\" was too long")
+ }
+
+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("source"))); err != nil {
+ return err
+ }
+ if _, err := cw.WriteString(string("source")); err != nil {
+ return err
+ }
+
+ if t.Source == nil {
+ if _, err := cw.Write(cbg.CborNull); err != nil {
+ return err
+ }
+ } else {
+ if len(*t.Source) > 1000000 {
+ return xerrors.Errorf("Value in field t.Source was too long")
+ }
+
+ if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(*t.Source))); err != nil {
+ return err
+ }
+ if _, err := cw.WriteString(string(*t.Source)); err != nil {
+ return err
+ }
+ }
+ }
+
// t.AddedAt (string) (string)
if t.AddedAt != nil {
@@ -2006,6 +2042,27 @@ func (t *Repo) UnmarshalCBOR(r io.Reader) (err error) {
t.Owner = string(sval)
}
+ // t.Source (string) (string)
+ case "source":
+
+ {
+ b, err := cr.ReadByte()
+ if err != nil {
+ return err
+ }
+ if b != cbg.CborNull[0] {
+ if err := cr.UnreadByte(); err != nil {
+ return err
+ }
+
+ sval, err := cbg.ReadStringWithMax(cr, 1000000)
+ if err != nil {
+ return err
+ }
+
+ t.Source = (*string)(&sval)
+ }
+ }
// t.AddedAt (string) (string)
case "addedAt":
diff --git a/api/tangled/tangledrepo.go b/api/tangled/tangledrepo.go
index 2970a7c0..36eaccac 100644
--- a/api/tangled/tangledrepo.go
+++ b/api/tangled/tangledrepo.go
@@ -25,4 +25,6 @@ type Repo struct {
// name: name of the repo
Name string `json:"name" cborgen:"name"`
Owner string `json:"owner" cborgen:"owner"`
+ // source: source of the repo
+ Source *string `json:"source,omitempty" cborgen:"source,omitempty"`
}
diff --git a/appview/db/db.go b/appview/db/db.go
index 644ee10a..2c3ed401 100644
--- a/appview/db/db.go
+++ b/appview/db/db.go
@@ -273,6 +273,13 @@ func Make(dbPath string) (*DB, error) {
return err
})
+ runMigration(db, "add-source-to-repos", func(tx *sql.Tx) error {
+ _, err := tx.Exec(`
+ alter table repos add column source text;
+ `)
+ return err
+ })
+
return &DB{db}, nil
}
diff --git a/appview/db/repos.go b/appview/db/repos.go
index c2492771..cacdce05 100644
--- a/appview/db/repos.go
+++ b/appview/db/repos.go
@@ -3,6 +3,8 @@ package db
import (
"database/sql"
"time"
+
+ "github.com/bluesky-social/indigo/atproto/syntax"
)
type Repo struct {
@@ -16,13 +18,16 @@ type Repo struct {
// optionally, populate this when querying for reverse mappings
RepoStats *RepoStats
+
+ // optional
+ Source string
}
func GetAllRepos(e Execer, limit int) ([]Repo, error) {
var repos []Repo
rows, err := e.Query(
- `select did, name, knot, rkey, description, created
+ `select did, name, knot, rkey, description, created, source
from repos
order by created desc
limit ?
@@ -37,7 +42,7 @@ func GetAllRepos(e Execer, limit int) ([]Repo, error) {
for rows.Next() {
var repo Repo
err := scanRepo(
- rows, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &repo.Description, &repo.Created,
+ rows, &repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &repo.Description, &repo.Created, &repo.Source,
)
if err != nil {
return nil, err
@@ -63,7 +68,8 @@ func GetAllReposByDid(e Execer, did string) ([]Repo, error) {
r.rkey,
r.description,
r.created,
- count(s.id) as star_count
+ count(s.id) as star_count,
+ r.source
from
repos r
left join
@@ -159,10 +165,10 @@ func GetRepoByAtUri(e Execer, atUri string) (*Repo, error) {
func AddRepo(e Execer, repo *Repo) error {
_, err := e.Exec(
- `insert into repos
- (did, name, knot, rkey, at_uri, description)
- values (?, ?, ?, ?, ?, ?)`,
- repo.Did, repo.Name, repo.Knot, repo.Rkey, repo.AtUri, repo.Description,
+ `insert into repos
+ (did, name, knot, rkey, at_uri, description, source)
+ values (?, ?, ?, ?, ?, ?, ?)`,
+ repo.Did, repo.Name, repo.Knot, repo.Rkey, repo.AtUri, repo.Description, repo.Source,
)
return err
}
@@ -172,6 +178,15 @@ func RemoveRepo(e Execer, did, name, rkey string) error {
return err
}
+func GetRepoSource(e Execer, repoAt syntax.ATURI) (string, error) {
+ var source string
+ err := e.QueryRow(`select source from repos where at_uri = ?`, repoAt).Scan(&source)
+ if err != nil {
+ return "", err
+ }
+ return source, nil
+}
+
func AddCollaborator(e Execer, collaborator, repoOwnerDid, repoName, repoKnot string) error {
_, err := e.Exec(
`insert into collaborators (did, repo)
@@ -249,10 +264,11 @@ type RepoStats struct {
PullCount PullCount
}
-func scanRepo(rows *sql.Rows, did, name, knot, rkey, description *string, created *time.Time) error {
+func scanRepo(rows *sql.Rows, did, name, knot, rkey, description *string, created *time.Time, source *string) error {
var createdAt string
var nullableDescription sql.NullString
- if err := rows.Scan(did, name, knot, rkey, &nullableDescription, &createdAt); err != nil {
+ var nullableSource sql.NullString
+ if err := rows.Scan(did, name, knot, rkey, &nullableDescription, &createdAt, &nullableSource); err != nil {
return err
}
@@ -269,5 +285,11 @@ func scanRepo(rows *sql.Rows, did, name, knot, rkey, description *string, create
*created = createdAtTime
}
+ if nullableSource.Valid {
+ *source = nullableSource.String
+ } else {
+ *source = ""
+ }
+
return nil
}
diff --git a/appview/db/timeline.go b/appview/db/timeline.go
index 961db89b..b5223bc4 100644
--- a/appview/db/timeline.go
+++ b/appview/db/timeline.go
@@ -9,7 +9,11 @@ type TimelineEvent struct {
*Repo
*Follow
*Star
+
EventAt time.Time
+
+ // optional: populate only if Repo is a fork
+ Source *Repo
}
// TODO: this gathers heterogenous events from different sources and aggregates
@@ -34,6 +38,19 @@ func MakeTimeline(e Execer) ([]TimelineEvent, error) {
}
for _, repo := range repos {
+ if repo.Source != "" {
+ sourceRepo, err := GetRepoByAtUri(e, repo.Source)
+ if err != nil {
+ return nil, err
+ }
+
+ events = append(events, TimelineEvent{
+ Repo: &repo,
+ EventAt: repo.Created,
+ Source: sourceRepo,
+ })
+ }
+
events = append(events, TimelineEvent{
Repo: &repo,
EventAt: repo.Created,
diff --git a/appview/pages/pages.go b/appview/pages/pages.go
index 93bbccaa..925517e9 100644
--- a/appview/pages/pages.go
+++ b/appview/pages/pages.go
@@ -158,6 +158,16 @@ func (p *Pages) NewRepo(w io.Writer, params NewRepoParams) error {
return p.execute("repo/new", w, params)
}
+type ForkRepoParams struct {
+ LoggedInUser *auth.User
+ Knots []string
+ RepoInfo RepoInfo
+}
+
+func (p *Pages) ForkRepo(w io.Writer, params ForkRepoParams) error {
+ return p.execute("repo/fork", w, params)
+}
+
type ProfilePageParams struct {
LoggedInUser *auth.User
UserDid string
@@ -212,15 +222,17 @@ func (p *Pages) RepoDescriptionFragment(w io.Writer, params RepoDescriptionParam
}
type RepoInfo struct {
- Name string
- OwnerDid string
- OwnerHandle string
- Description string
- Knot string
- RepoAt syntax.ATURI
- IsStarred bool
- Stats db.RepoStats
- Roles RolesInRepo
+ Name string
+ OwnerDid string
+ OwnerHandle string
+ Description string
+ Knot string
+ RepoAt syntax.ATURI
+ IsStarred bool
+ Stats db.RepoStats
+ Roles RolesInRepo
+ Source *db.Repo
+ SourceHandle string
}
type RolesInRepo struct {
diff --git a/appview/pages/templates/layouts/repobase.html b/appview/pages/templates/layouts/repobase.html
index 28efb0b6..e367c07d 100644
--- a/appview/pages/templates/layouts/repobase.html
+++ b/appview/pages/templates/layouts/repobase.html
@@ -2,6 +2,16 @@
{{ define "content" }}
+ {{ end }}
{{ .RepoInfo.OwnerWithAt }}
/
diff --git a/appview/pages/templates/repo/fork.html b/appview/pages/templates/repo/fork.html
new file mode 100644
index 00000000..cb1b81bb
--- /dev/null
+++ b/appview/pages/templates/repo/fork.html
@@ -0,0 +1,38 @@
+{{ define "title" }}fork · {{ .RepoInfo.FullName }}{{ end }}
+
+{{ define "content" }}
+
+
Fork {{ .RepoInfo.FullName }}
+
+
+{{ end }}
diff --git a/appview/pages/templates/repo/settings.html b/appview/pages/templates/repo/settings.html
index f034e9aa..ceef3b21 100644
--- a/appview/pages/templates/repo/settings.html
+++ b/appview/pages/templates/repo/settings.html
@@ -1,6 +1,8 @@
{{ define "title" }}settings · {{ .RepoInfo.FullName }}{{ end }}
{{ define "repoContent" }}
-
+
{{ range .Collaborators }}
@@ -21,11 +23,24 @@
{{ if .IsCollaboratorInviteAllowed }}
- add collaborator
{{ end }}
diff --git a/appview/pages/templates/timeline.html b/appview/pages/templates/timeline.html
index 88b6bfd2..d3d0941e 100644
--- a/appview/pages/templates/timeline.html
+++ b/appview/pages/templates/timeline.html
@@ -44,8 +44,15 @@
diff --git a/appview/state/repo.go b/appview/state/repo.go
index 61458395..ebd0c872 100644
--- a/appview/state/repo.go
+++ b/appview/state/repo.go
@@ -2,11 +2,13 @@ package state
import (
"context"
+ "database/sql"
"encoding/json"
+ "errors"
"fmt"
"io"
"log"
- "math/rand/v2"
+ mathrand "math/rand/v2"
"net/http"
"path"
"slices"
@@ -801,12 +803,34 @@ func (f *FullyResolvedRepo) RepoInfo(s *State, u *auth.User) pages.RepoInfo {
if err != nil {
log.Println("failed to get issue count for ", f.RepoAt)
}
+ source, err := db.GetRepoSource(s.db, f.RepoAt)
+ if errors.Is(err, sql.ErrNoRows) {
+ source = ""
+ } else if err != nil {
+ log.Println("failed to get repo source for ", f.RepoAt)
+ }
+
+ var sourceRepo *db.Repo
+ if source != "" {
+ sourceRepo, err = db.GetRepoByAtUri(s.db, source)
+ if err != nil {
+ log.Println("failed to get repo by at uri", err)
+ }
+ }
knot := f.Knot
if knot == "knot1.tangled.sh" {
knot = "tangled.sh"
}
+ var sourceHandle *identity.Identity
+ if sourceRepo != nil {
+ sourceHandle, err = s.resolver.ResolveIdent(context.Background(), sourceRepo.Did)
+ if err != nil {
+ log.Println("failed to resolve source repo", err)
+ }
+ }
+
return pages.RepoInfo{
OwnerDid: f.OwnerDid(),
OwnerHandle: f.OwnerHandle(),
@@ -821,6 +845,8 @@ func (f *FullyResolvedRepo) RepoInfo(s *State, u *auth.User) pages.RepoInfo {
IssueCount: issueCount,
PullCount: pullCount,
},
+ Source: sourceRepo,
+ SourceHandle: sourceHandle.Handle.String(),
}
}
@@ -1022,7 +1048,7 @@ func (s *State) NewIssueComment(w http.ResponseWriter, r *http.Request) {
return
}
- commentId := rand.IntN(1000000)
+ commentId := mathrand.IntN(1000000)
rkey := s.TID()
err := db.NewIssueComment(s.db, &db.Comment{
@@ -1481,3 +1507,166 @@ func (s *State) NewIssue(w http.ResponseWriter, r *http.Request) {
return
}
}
+
+func (s *State) ForkRepo(w http.ResponseWriter, r *http.Request) {
+ user := s.auth.GetUser(r)
+ f, err := fullyResolvedRepo(r)
+ if err != nil {
+ log.Printf("failed to resolve source repo: %v", err)
+ return
+ }
+
+ switch r.Method {
+ case http.MethodGet:
+ user := s.auth.GetUser(r)
+ knots, err := s.enforcer.GetDomainsForUser(user.Did)
+ if err != nil {
+ s.pages.Notice(w, "repo", "Invalid user account.")
+ return
+ }
+
+ s.pages.ForkRepo(w, pages.ForkRepoParams{
+ LoggedInUser: user,
+ Knots: knots,
+ RepoInfo: f.RepoInfo(s, user),
+ })
+
+ case http.MethodPost:
+
+ knot := r.FormValue("knot")
+ if knot == "" {
+ s.pages.Notice(w, "repo", "Invalid form submission—missing knot domain.")
+ return
+ }
+
+ ok, err := s.enforcer.E.Enforce(user.Did, knot, knot, "repo:create")
+ if err != nil || !ok {
+ s.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.")
+ return
+ }
+
+ forkName := fmt.Sprintf("%s", f.RepoName)
+
+ existingRepo, err := db.GetRepo(s.db, user.Did, f.RepoName)
+ if err == nil && existingRepo != nil {
+ forkName = fmt.Sprintf("%s-%s", forkName, randomString(3))
+ }
+
+ secret, err := db.GetRegistrationKey(s.db, knot)
+ if err != nil {
+ s.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", knot))
+ return
+ }
+
+ client, err := NewSignedClient(knot, secret, s.config.Dev)
+ if err != nil {
+ s.pages.Notice(w, "repo", "Failed to connect to knot server.")
+ return
+ }
+
+ var uri string
+ if s.config.Dev {
+ uri = "http"
+ } else {
+ uri = "https"
+ }
+ sourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, knot, f.OwnerDid(), f.RepoName)
+ sourceAt := f.RepoAt.String()
+
+ rkey := s.TID()
+ repo := &db.Repo{
+ Did: user.Did,
+ Name: forkName,
+ Knot: knot,
+ Rkey: rkey,
+ Source: sourceAt,
+ }
+
+ tx, err := s.db.BeginTx(r.Context(), nil)
+ if err != nil {
+ log.Println(err)
+ s.pages.Notice(w, "repo", "Failed to save repository information.")
+ return
+ }
+ defer func() {
+ tx.Rollback()
+ err = s.enforcer.E.LoadPolicy()
+ if err != nil {
+ log.Println("failed to rollback policies")
+ }
+ }()
+
+ resp, err := client.ForkRepo(user.Did, sourceUrl, forkName)
+ if err != nil {
+ s.pages.Notice(w, "repo", "Failed to create repository on knot server.")
+ return
+ }
+
+ switch resp.StatusCode {
+ case http.StatusConflict:
+ s.pages.Notice(w, "repo", "A repository with that name already exists.")
+ return
+ case http.StatusInternalServerError:
+ s.pages.Notice(w, "repo", "Failed to create repository on knot. Try again later.")
+ case http.StatusNoContent:
+ // continue
+ }
+
+ xrpcClient, _ := s.auth.AuthorizedClient(r)
+
+ addedAt := time.Now().Format(time.RFC3339)
+ atresp, err := comatproto.RepoPutRecord(r.Context(), xrpcClient, &comatproto.RepoPutRecord_Input{
+ Collection: tangled.RepoNSID,
+ Repo: user.Did,
+ Rkey: rkey,
+ Record: &lexutil.LexiconTypeDecoder{
+ Val: &tangled.Repo{
+ Knot: repo.Knot,
+ Name: repo.Name,
+ AddedAt: &addedAt,
+ Owner: user.Did,
+ Source: &sourceAt,
+ }},
+ })
+ if err != nil {
+ log.Printf("failed to create record: %s", err)
+ s.pages.Notice(w, "repo", "Failed to announce repository creation.")
+ return
+ }
+ log.Println("created repo record: ", atresp.Uri)
+
+ repo.AtUri = atresp.Uri
+ err = db.AddRepo(tx, repo)
+ if err != nil {
+ log.Println(err)
+ s.pages.Notice(w, "repo", "Failed to save repository information.")
+ return
+ }
+
+ // acls
+ p, _ := securejoin.SecureJoin(user.Did, forkName)
+ err = s.enforcer.AddRepo(user.Did, knot, p)
+ if err != nil {
+ log.Println(err)
+ s.pages.Notice(w, "repo", "Failed to set up repository permissions.")
+ return
+ }
+
+ err = tx.Commit()
+ if err != nil {
+ log.Println("failed to commit changes", err)
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ err = s.enforcer.E.SavePolicy()
+ if err != nil {
+ log.Println("failed to update ACLs", err)
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, forkName))
+ return
+ }
+}
diff --git a/appview/state/repo_util.go b/appview/state/repo_util.go
index d7066234..b5be04c3 100644
--- a/appview/state/repo_util.go
+++ b/appview/state/repo_util.go
@@ -2,8 +2,10 @@ package state
import (
"context"
+ "crypto/rand"
"fmt"
"log"
+ "math/big"
"net/http"
"github.com/bluesky-social/indigo/atproto/identity"
@@ -112,3 +114,15 @@ func EmailToDidOrHandle(s *State, emails []string) map[string]string {
return emailToDidOrHandle
}
+
+func randomString(n int) string {
+ const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
+ result := make([]byte, n)
+
+ for i := 0; i < n; i++ {
+ n, _ := rand.Int(rand.Reader, big.NewInt(int64(len(letters))))
+ result[i] = letters[n.Int64()]
+ }
+
+ return string(result)
+}
diff --git a/appview/state/router.go b/appview/state/router.go
index 45d1e910..a7822318 100644
--- a/appview/state/router.go
+++ b/appview/state/router.go
@@ -85,6 +85,12 @@ func (s *State) UserRouter() http.Handler {
})
})
+ r.Route("/fork", func(r chi.Router) {
+ r.Use(AuthMiddleware(s))
+ r.Get("/", s.ForkRepo)
+ r.Post("/", s.ForkRepo)
+ })
+
r.Route("/pulls", func(r chi.Router) {
r.Get("/", s.RepoPulls)
r.With(AuthMiddleware(s)).Route("/new", func(r chi.Router) {
diff --git a/appview/state/signer.go b/appview/state/signer.go
index 4aa7493b..7127cb20 100644
--- a/appview/state/signer.go
+++ b/appview/state/signer.go
@@ -103,6 +103,26 @@ func (s *SignedClient) NewRepo(did, repoName, defaultBranch string) (*http.Respo
return s.client.Do(req)
}
+func (s *SignedClient) ForkRepo(ownerDid, source, name string) (*http.Response, error) {
+ const (
+ Method = "POST"
+ Endpoint = "/repo/fork"
+ )
+
+ body, _ := json.Marshal(map[string]any{
+ "did": ownerDid,
+ "source": source,
+ "name": name,
+ })
+
+ req, err := s.newRequest(Method, Endpoint, body)
+ if err != nil {
+ return nil, err
+ }
+
+ return s.client.Do(req)
+}
+
func (s *SignedClient) RemoveRepo(did, repoName string) (*http.Response, error) {
const (
Method = "DELETE"
diff --git a/appview/state/state.go b/appview/state/state.go
index 6f468880..1893edc8 100644
--- a/appview/state/state.go
+++ b/appview/state/state.go
@@ -190,6 +190,9 @@ func (s *State) Timeline(w http.ResponseWriter, r *http.Request) {
for _, ev := range timeline {
if ev.Repo != nil {
didsToResolve = append(didsToResolve, ev.Repo.Did)
+ if ev.Source != nil {
+ didsToResolve = append(didsToResolve, ev.Source.Did)
+ }
}
if ev.Follow != nil {
didsToResolve = append(didsToResolve, ev.Follow.UserDid, ev.Follow.SubjectDid)
diff --git a/knotserver/git/fork.go b/knotserver/git/fork.go
new file mode 100644
index 00000000..b75c28c2
--- /dev/null
+++ b/knotserver/git/fork.go
@@ -0,0 +1,20 @@
+package git
+
+import (
+ "fmt"
+
+ "github.com/go-git/go-git/v5"
+)
+
+func Fork(repoPath, source string) error {
+ _, err := git.PlainClone(repoPath, true, &git.CloneOptions{
+ URL: source,
+ Depth: 1,
+ SingleBranch: false,
+ })
+
+ if err != nil {
+ return fmt.Errorf("failed to bare clone repository: %w", err)
+ }
+ return nil
+}
diff --git a/knotserver/handler.go b/knotserver/handler.go
index 84a8062d..499c169d 100644
--- a/knotserver/handler.go
+++ b/knotserver/handler.go
@@ -120,6 +120,7 @@ func Setup(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, j
r.Use(h.VerifySignature)
r.Put("/new", h.NewRepo)
r.Delete("/", h.RemoveRepo)
+ r.Post("/fork", h.RepoFork)
})
r.Route("/member", func(r chi.Router) {
diff --git a/knotserver/routes.go b/knotserver/routes.go
index 5fa7a52d..fe97d1fd 100644
--- a/knotserver/routes.go
+++ b/knotserver/routes.go
@@ -577,6 +577,49 @@ func (h *Handle) NewRepo(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
+func (h *Handle) RepoFork(w http.ResponseWriter, r *http.Request) {
+ l := h.l.With("handler", "RepoFork")
+
+ data := struct {
+ Did string `json:"did"`
+ Source string `json:"source"`
+ Name string `json:"name,omitempty"`
+ }{}
+
+ if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
+ writeError(w, "invalid request body", http.StatusBadRequest)
+ return
+ }
+
+ did := data.Did
+ source := data.Source
+
+ if did == "" || source == "" {
+ l.Error("invalid request body, empty did or name")
+ w.WriteHeader(http.StatusBadRequest)
+ return
+ }
+
+ var name string
+ if data.Name != "" {
+ name = data.Name
+ } else {
+ name = filepath.Base(source)
+ }
+
+ relativeRepoPath := filepath.Join(did, name)
+ repoPath, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, relativeRepoPath)
+
+ err := git.Fork(repoPath, source)
+ if err != nil {
+ l.Error("forking repo", "error", err.Error())
+ writeError(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ w.WriteHeader(http.StatusNoContent)
+}
+
func (h *Handle) RemoveRepo(w http.ResponseWriter, r *http.Request) {
l := h.l.With("handler", "RemoveRepo")
diff --git a/lexicons/repo.json b/lexicons/repo.json
index 578facd4..42612b91 100644
--- a/lexicons/repo.json
+++ b/lexicons/repo.json
@@ -32,6 +32,11 @@
"format": "datetime",
"minLength": 1,
"maxLength": 140
+ },
+ "source": {
+ "type": "string",
+ "format": "uri",
+ "description": "source of the repo"
}
}
}
--
2.51.2