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

Compare changes

Choose any two refs to compare.

+1
.gitignore
··· 13 13 *.qcow2 14 14 .DS_Store 15 15 .env 16 + *.rdb
+85
appview/config/config.go
··· 1 + package config 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "net/url" 7 + 8 + "github.com/sethvargo/go-envconfig" 9 + ) 10 + 11 + type CoreConfig struct { 12 + CookieSecret string `env:"COOKIE_SECRET, default=00000000000000000000000000000000"` 13 + DbPath string `env:"DB_PATH, default=appview.db"` 14 + ListenAddr string `env:"LISTEN_ADDR, default=0.0.0.0:3000"` 15 + AppviewHost string `env:"APPVIEW_HOST, default=https://tangled.sh"` 16 + Dev bool `env:"DEV, default=false"` 17 + } 18 + 19 + type OAuthConfig struct { 20 + Jwks string `env:"JWKS"` 21 + } 22 + 23 + type JetstreamConfig struct { 24 + Endpoint string `env:"ENDPOINT, default=wss://jetstream1.us-east.bsky.network/subscribe"` 25 + } 26 + 27 + type ResendConfig struct { 28 + ApiKey string `env:"API_KEY"` 29 + } 30 + 31 + type CamoConfig struct { 32 + Host string `env:"HOST, default=https://camo.tangled.sh"` 33 + SharedSecret string `env:"SHARED_SECRET"` 34 + } 35 + 36 + type AvatarConfig struct { 37 + Host string `env:"HOST, default=https://avatar.tangled.sh"` 38 + SharedSecret string `env:"SHARED_SECRET"` 39 + } 40 + 41 + type PosthogConfig struct { 42 + ApiKey string `env:"API_KEY"` 43 + Endpoint string `env:"ENDPOINT, default=https://eu.i.posthog.com"` 44 + } 45 + 46 + type RedisConfig struct { 47 + Addr string `env:"ADDR, default=localhost:6379"` 48 + Password string `env:"PASS"` 49 + DB int `env:"DB, default=0"` 50 + } 51 + 52 + func (cfg RedisConfig) ToURL() string { 53 + u := &url.URL{ 54 + Scheme: "redis", 55 + Host: cfg.Addr, 56 + Path: fmt.Sprintf("/%d", cfg.DB), 57 + } 58 + 59 + if cfg.Password != "" { 60 + u.User = url.UserPassword("", cfg.Password) 61 + } 62 + 63 + return u.String() 64 + } 65 + 66 + type Config struct { 67 + Core CoreConfig `env:",prefix=TANGLED_"` 68 + Jetstream JetstreamConfig `env:",prefix=TANGLED_JETSTREAM_"` 69 + Resend ResendConfig `env:",prefix=TANGLED_RESEND_"` 70 + Posthog PosthogConfig `env:",prefix=TANGLED_POSTHOG_"` 71 + Camo CamoConfig `env:",prefix=TANGLED_CAMO_"` 72 + Avatar AvatarConfig `env:",prefix=TANGLED_AVATAR_"` 73 + OAuth OAuthConfig `env:",prefix=TANGLED_OAUTH_"` 74 + Redis RedisConfig `env:",prefix=TANGLED_REDIS_"` 75 + } 76 + 77 + func LoadConfig(ctx context.Context) (*Config, error) { 78 + var cfg Config 79 + err := envconfig.Process(ctx, &cfg) 80 + if err != nil { 81 + return nil, err 82 + } 83 + 84 + return &cfg, nil 85 + }
-62
appview/config.go
··· 1 - package appview 2 - 3 - import ( 4 - "context" 5 - 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 PosthogConfig struct { 40 - ApiKey string `env:"API_KEY"` 41 - Endpoint string `env:"ENDPOINT, default=https://eu.i.posthog.com"` 42 - } 43 - 44 - type Config struct { 45 - Core CoreConfig `env:",prefix=TANGLED_"` 46 - Jetstream JetstreamConfig `env:",prefix=TANGLED_JETSTREAM_"` 47 - Resend ResendConfig `env:",prefix=TANGLED_RESEND_"` 48 - Posthog PosthogConfig `env:",prefix=TANGLED_POSTHOG_"` 49 - Camo CamoConfig `env:",prefix=TANGLED_CAMO_"` 50 - Avatar AvatarConfig `env:",prefix=TANGLED_AVATAR_"` 51 - OAuth OAuthConfig `env:",prefix=TANGLED_OAUTH_"` 52 - } 53 - 54 - func LoadConfig(ctx context.Context) (*Config, error) { 55 - var cfg Config 56 - err := envconfig.Process(ctx, &cfg) 57 - if err != nil { 58 - return nil, err 59 - } 60 - 61 - return &cfg, nil 62 - }
-15
appview/consts.go
··· 1 - package appview 2 - 3 - const ( 4 - SessionName = "appview-session" 5 - SessionHandle = "handle" 6 - SessionDid = "did" 7 - SessionPds = "pds" 8 - SessionAccessJwt = "accessJwt" 9 - SessionRefreshJwt = "refreshJwt" 10 - SessionExpiry = "expiry" 11 - SessionAuthenticated = "authenticated" 12 - 13 - SessionDpopPrivateJwk = "dpopPrivateJwk" 14 - SessionDpopAuthServerNonce = "dpopAuthServerNonce" 15 - )
+2 -69
appview/db/pulls.go
··· 9 9 "strings" 10 10 "time" 11 11 12 - "github.com/bluekeyes/go-gitdiff/gitdiff" 13 12 "github.com/bluesky-social/indigo/atproto/syntax" 14 13 "tangled.sh/tangled.sh/core/api/tangled" 15 14 "tangled.sh/tangled.sh/core/patchutil" ··· 203 202 return p.StackId != "" 204 203 } 205 204 206 - func (s PullSubmission) AsDiff(targetBranch string) ([]*gitdiff.File, error) { 207 - patch := s.Patch 208 - 209 - // if format-patch; then extract each patch 210 - var diffs []*gitdiff.File 211 - if patchutil.IsFormatPatch(patch) { 212 - patches, err := patchutil.ExtractPatches(patch) 213 - if err != nil { 214 - return nil, err 215 - } 216 - var ps [][]*gitdiff.File 217 - for _, p := range patches { 218 - ps = append(ps, p.Files) 219 - } 220 - 221 - diffs = patchutil.CombineDiff(ps...) 222 - } else { 223 - d, _, err := gitdiff.Parse(strings.NewReader(patch)) 224 - if err != nil { 225 - return nil, err 226 - } 227 - diffs = d 228 - } 229 - 230 - return diffs, nil 231 - } 232 - 233 - func (s PullSubmission) AsNiceDiff(targetBranch string) types.NiceDiff { 234 - diffs, err := s.AsDiff(targetBranch) 235 - if err != nil { 236 - log.Println(err) 237 - } 238 - 239 - nd := types.NiceDiff{} 240 - nd.Commit.Parent = targetBranch 241 - 242 - for _, d := range diffs { 243 - ndiff := types.Diff{} 244 - ndiff.Name.New = d.NewName 245 - ndiff.Name.Old = d.OldName 246 - ndiff.IsBinary = d.IsBinary 247 - ndiff.IsNew = d.IsNew 248 - ndiff.IsDelete = d.IsDelete 249 - ndiff.IsCopy = d.IsCopy 250 - ndiff.IsRename = d.IsRename 251 - 252 - for _, tf := range d.TextFragments { 253 - ndiff.TextFragments = append(ndiff.TextFragments, *tf) 254 - for _, l := range tf.Lines { 255 - switch l.Op { 256 - case gitdiff.OpAdd: 257 - nd.Stat.Insertions += 1 258 - case gitdiff.OpDelete: 259 - nd.Stat.Deletions += 1 260 - } 261 - } 262 - } 263 - 264 - nd.Diff = append(nd.Diff, ndiff) 265 - } 266 - 267 - nd.Stat.FilesChanged = len(diffs) 268 - 269 - return nd 270 - } 271 - 272 205 func (s PullSubmission) IsFormatPatch() bool { 273 206 return patchutil.IsFormatPatch(s.Patch) 274 207 } 275 208 276 - func (s PullSubmission) AsFormatPatch() []patchutil.FormatPatch { 209 + func (s PullSubmission) AsFormatPatch() []types.FormatPatch { 277 210 patches, err := patchutil.ExtractPatches(s.Patch) 278 211 if err != nil { 279 212 log.Println("error extracting patches from submission:", err) 280 - return []patchutil.FormatPatch{} 213 + return []types.FormatPatch{} 281 214 } 282 215 283 216 return patches
+101
appview/idresolver/resolver.go
··· 1 + package idresolver 2 + 3 + import ( 4 + "context" 5 + "net" 6 + "net/http" 7 + "sync" 8 + "time" 9 + 10 + "github.com/bluesky-social/indigo/atproto/identity" 11 + "github.com/bluesky-social/indigo/atproto/identity/redisdir" 12 + "github.com/bluesky-social/indigo/atproto/syntax" 13 + "github.com/carlmjohnson/versioninfo" 14 + "tangled.sh/tangled.sh/core/appview/config" 15 + ) 16 + 17 + type Resolver struct { 18 + directory identity.Directory 19 + } 20 + 21 + func BaseDirectory() identity.Directory { 22 + base := identity.BaseDirectory{ 23 + PLCURL: identity.DefaultPLCURL, 24 + HTTPClient: http.Client{ 25 + Timeout: time.Second * 10, 26 + Transport: &http.Transport{ 27 + // would want this around 100ms for services doing lots of handle resolution. Impacts PLC connections as well, but not too bad. 28 + IdleConnTimeout: time.Millisecond * 1000, 29 + MaxIdleConns: 100, 30 + }, 31 + }, 32 + Resolver: net.Resolver{ 33 + Dial: func(ctx context.Context, network, address string) (net.Conn, error) { 34 + d := net.Dialer{Timeout: time.Second * 3} 35 + return d.DialContext(ctx, network, address) 36 + }, 37 + }, 38 + TryAuthoritativeDNS: true, 39 + // primary Bluesky PDS instance only supports HTTP resolution method 40 + SkipDNSDomainSuffixes: []string{".bsky.social"}, 41 + UserAgent: "indigo-identity/" + versioninfo.Short(), 42 + } 43 + return &base 44 + } 45 + 46 + func RedisDirectory(url string) (identity.Directory, error) { 47 + return redisdir.NewRedisDirectory(BaseDirectory(), url, time.Hour*24, time.Hour*1, time.Hour*1, 10000) 48 + } 49 + 50 + func DefaultResolver() *Resolver { 51 + return &Resolver{ 52 + directory: identity.DefaultDirectory(), 53 + } 54 + } 55 + 56 + func RedisResolver(config config.RedisConfig) (*Resolver, error) { 57 + directory, err := RedisDirectory(config.ToURL()) 58 + if err != nil { 59 + return nil, err 60 + } 61 + return &Resolver{ 62 + directory: directory, 63 + }, nil 64 + } 65 + 66 + func (r *Resolver) ResolveIdent(ctx context.Context, arg string) (*identity.Identity, error) { 67 + id, err := syntax.ParseAtIdentifier(arg) 68 + if err != nil { 69 + return nil, err 70 + } 71 + 72 + return r.directory.Lookup(ctx, *id) 73 + } 74 + 75 + func (r *Resolver) ResolveIdents(ctx context.Context, idents []string) []*identity.Identity { 76 + results := make([]*identity.Identity, len(idents)) 77 + var wg sync.WaitGroup 78 + 79 + done := make(chan struct{}) 80 + defer close(done) 81 + 82 + for idx, ident := range idents { 83 + wg.Add(1) 84 + go func(index int, id string) { 85 + defer wg.Done() 86 + 87 + select { 88 + case <-ctx.Done(): 89 + results[index] = nil 90 + case <-done: 91 + results[index] = nil 92 + default: 93 + identity, _ := r.ResolveIdent(ctx, id) 94 + results[index] = identity 95 + } 96 + }(idx, ident) 97 + } 98 + 99 + wg.Wait() 100 + return results 101 + }
+757
appview/issues/issues.go
··· 1 + package issues 2 + 3 + import ( 4 + "fmt" 5 + "log" 6 + mathrand "math/rand/v2" 7 + "net/http" 8 + "slices" 9 + "strconv" 10 + "time" 11 + 12 + comatproto "github.com/bluesky-social/indigo/api/atproto" 13 + "github.com/bluesky-social/indigo/atproto/data" 14 + lexutil "github.com/bluesky-social/indigo/lex/util" 15 + "github.com/go-chi/chi/v5" 16 + "github.com/posthog/posthog-go" 17 + 18 + "tangled.sh/tangled.sh/core/api/tangled" 19 + "tangled.sh/tangled.sh/core/appview" 20 + "tangled.sh/tangled.sh/core/appview/config" 21 + "tangled.sh/tangled.sh/core/appview/db" 22 + "tangled.sh/tangled.sh/core/appview/idresolver" 23 + "tangled.sh/tangled.sh/core/appview/oauth" 24 + "tangled.sh/tangled.sh/core/appview/pages" 25 + "tangled.sh/tangled.sh/core/appview/pagination" 26 + "tangled.sh/tangled.sh/core/appview/reporesolver" 27 + ) 28 + 29 + type Issues struct { 30 + oauth *oauth.OAuth 31 + repoResolver *reporesolver.RepoResolver 32 + pages *pages.Pages 33 + idResolver *idresolver.Resolver 34 + db *db.DB 35 + config *config.Config 36 + posthog posthog.Client 37 + } 38 + 39 + func New( 40 + oauth *oauth.OAuth, 41 + repoResolver *reporesolver.RepoResolver, 42 + pages *pages.Pages, 43 + idResolver *idresolver.Resolver, 44 + db *db.DB, 45 + config *config.Config, 46 + posthog posthog.Client, 47 + ) *Issues { 48 + return &Issues{ 49 + oauth: oauth, 50 + repoResolver: repoResolver, 51 + pages: pages, 52 + idResolver: idResolver, 53 + db: db, 54 + config: config, 55 + posthog: posthog, 56 + } 57 + } 58 + 59 + func (rp *Issues) RepoSingleIssue(w http.ResponseWriter, r *http.Request) { 60 + user := rp.oauth.GetUser(r) 61 + f, err := rp.repoResolver.Resolve(r) 62 + if err != nil { 63 + log.Println("failed to get repo and knot", err) 64 + return 65 + } 66 + 67 + issueId := chi.URLParam(r, "issue") 68 + issueIdInt, err := strconv.Atoi(issueId) 69 + if err != nil { 70 + http.Error(w, "bad issue id", http.StatusBadRequest) 71 + log.Println("failed to parse issue id", err) 72 + return 73 + } 74 + 75 + issue, comments, err := db.GetIssueWithComments(rp.db, f.RepoAt, issueIdInt) 76 + if err != nil { 77 + log.Println("failed to get issue and comments", err) 78 + rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 79 + return 80 + } 81 + 82 + issueOwnerIdent, err := rp.idResolver.ResolveIdent(r.Context(), issue.OwnerDid) 83 + if err != nil { 84 + log.Println("failed to resolve issue owner", err) 85 + } 86 + 87 + identsToResolve := make([]string, len(comments)) 88 + for i, comment := range comments { 89 + identsToResolve[i] = comment.OwnerDid 90 + } 91 + resolvedIds := rp.idResolver.ResolveIdents(r.Context(), identsToResolve) 92 + didHandleMap := make(map[string]string) 93 + for _, identity := range resolvedIds { 94 + if !identity.Handle.IsInvalidHandle() { 95 + didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 96 + } else { 97 + didHandleMap[identity.DID.String()] = identity.DID.String() 98 + } 99 + } 100 + 101 + rp.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{ 102 + LoggedInUser: user, 103 + RepoInfo: f.RepoInfo(user), 104 + Issue: *issue, 105 + Comments: comments, 106 + 107 + IssueOwnerHandle: issueOwnerIdent.Handle.String(), 108 + DidHandleMap: didHandleMap, 109 + }) 110 + 111 + } 112 + 113 + func (rp *Issues) CloseIssue(w http.ResponseWriter, r *http.Request) { 114 + user := rp.oauth.GetUser(r) 115 + f, err := rp.repoResolver.Resolve(r) 116 + if err != nil { 117 + log.Println("failed to get repo and knot", err) 118 + return 119 + } 120 + 121 + issueId := chi.URLParam(r, "issue") 122 + issueIdInt, err := strconv.Atoi(issueId) 123 + if err != nil { 124 + http.Error(w, "bad issue id", http.StatusBadRequest) 125 + log.Println("failed to parse issue id", err) 126 + return 127 + } 128 + 129 + issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt) 130 + if err != nil { 131 + log.Println("failed to get issue", err) 132 + rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 133 + return 134 + } 135 + 136 + collaborators, err := f.Collaborators(r.Context()) 137 + if err != nil { 138 + log.Println("failed to fetch repo collaborators: %w", err) 139 + } 140 + isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 141 + return user.Did == collab.Did 142 + }) 143 + isIssueOwner := user.Did == issue.OwnerDid 144 + 145 + // TODO: make this more granular 146 + if isIssueOwner || isCollaborator { 147 + 148 + closed := tangled.RepoIssueStateClosed 149 + 150 + client, err := rp.oauth.AuthorizedClient(r) 151 + if err != nil { 152 + log.Println("failed to get authorized client", err) 153 + return 154 + } 155 + _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 156 + Collection: tangled.RepoIssueStateNSID, 157 + Repo: user.Did, 158 + Rkey: appview.TID(), 159 + Record: &lexutil.LexiconTypeDecoder{ 160 + Val: &tangled.RepoIssueState{ 161 + Issue: issue.IssueAt, 162 + State: closed, 163 + }, 164 + }, 165 + }) 166 + 167 + if err != nil { 168 + log.Println("failed to update issue state", err) 169 + rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 170 + return 171 + } 172 + 173 + err = db.CloseIssue(rp.db, f.RepoAt, issueIdInt) 174 + if err != nil { 175 + log.Println("failed to close issue", err) 176 + rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 177 + return 178 + } 179 + 180 + rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt)) 181 + return 182 + } else { 183 + log.Println("user is not permitted to close issue") 184 + http.Error(w, "for biden", http.StatusUnauthorized) 185 + return 186 + } 187 + } 188 + 189 + func (rp *Issues) ReopenIssue(w http.ResponseWriter, r *http.Request) { 190 + user := rp.oauth.GetUser(r) 191 + f, err := rp.repoResolver.Resolve(r) 192 + if err != nil { 193 + log.Println("failed to get repo and knot", err) 194 + return 195 + } 196 + 197 + issueId := chi.URLParam(r, "issue") 198 + issueIdInt, err := strconv.Atoi(issueId) 199 + if err != nil { 200 + http.Error(w, "bad issue id", http.StatusBadRequest) 201 + log.Println("failed to parse issue id", err) 202 + return 203 + } 204 + 205 + issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt) 206 + if err != nil { 207 + log.Println("failed to get issue", err) 208 + rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 209 + return 210 + } 211 + 212 + collaborators, err := f.Collaborators(r.Context()) 213 + if err != nil { 214 + log.Println("failed to fetch repo collaborators: %w", err) 215 + } 216 + isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 217 + return user.Did == collab.Did 218 + }) 219 + isIssueOwner := user.Did == issue.OwnerDid 220 + 221 + if isCollaborator || isIssueOwner { 222 + err := db.ReopenIssue(rp.db, f.RepoAt, issueIdInt) 223 + if err != nil { 224 + log.Println("failed to reopen issue", err) 225 + rp.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.") 226 + return 227 + } 228 + rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt)) 229 + return 230 + } else { 231 + log.Println("user is not the owner of the repo") 232 + http.Error(w, "forbidden", http.StatusUnauthorized) 233 + return 234 + } 235 + } 236 + 237 + func (rp *Issues) NewIssueComment(w http.ResponseWriter, r *http.Request) { 238 + user := rp.oauth.GetUser(r) 239 + f, err := rp.repoResolver.Resolve(r) 240 + if err != nil { 241 + log.Println("failed to get repo and knot", err) 242 + return 243 + } 244 + 245 + issueId := chi.URLParam(r, "issue") 246 + issueIdInt, err := strconv.Atoi(issueId) 247 + if err != nil { 248 + http.Error(w, "bad issue id", http.StatusBadRequest) 249 + log.Println("failed to parse issue id", err) 250 + return 251 + } 252 + 253 + switch r.Method { 254 + case http.MethodPost: 255 + body := r.FormValue("body") 256 + if body == "" { 257 + rp.pages.Notice(w, "issue", "Body is required") 258 + return 259 + } 260 + 261 + commentId := mathrand.IntN(1000000) 262 + rkey := appview.TID() 263 + 264 + err := db.NewIssueComment(rp.db, &db.Comment{ 265 + OwnerDid: user.Did, 266 + RepoAt: f.RepoAt, 267 + Issue: issueIdInt, 268 + CommentId: commentId, 269 + Body: body, 270 + Rkey: rkey, 271 + }) 272 + if err != nil { 273 + log.Println("failed to create comment", err) 274 + rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 275 + return 276 + } 277 + 278 + createdAt := time.Now().Format(time.RFC3339) 279 + commentIdInt64 := int64(commentId) 280 + ownerDid := user.Did 281 + issueAt, err := db.GetIssueAt(rp.db, f.RepoAt, issueIdInt) 282 + if err != nil { 283 + log.Println("failed to get issue at", err) 284 + rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 285 + return 286 + } 287 + 288 + atUri := f.RepoAt.String() 289 + client, err := rp.oauth.AuthorizedClient(r) 290 + if err != nil { 291 + log.Println("failed to get authorized client", err) 292 + rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 293 + return 294 + } 295 + _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 296 + Collection: tangled.RepoIssueCommentNSID, 297 + Repo: user.Did, 298 + Rkey: rkey, 299 + Record: &lexutil.LexiconTypeDecoder{ 300 + Val: &tangled.RepoIssueComment{ 301 + Repo: &atUri, 302 + Issue: issueAt, 303 + CommentId: &commentIdInt64, 304 + Owner: &ownerDid, 305 + Body: body, 306 + CreatedAt: createdAt, 307 + }, 308 + }, 309 + }) 310 + if err != nil { 311 + log.Println("failed to create comment", err) 312 + rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 313 + return 314 + } 315 + 316 + rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issueIdInt, commentId)) 317 + return 318 + } 319 + } 320 + 321 + func (rp *Issues) IssueComment(w http.ResponseWriter, r *http.Request) { 322 + user := rp.oauth.GetUser(r) 323 + f, err := rp.repoResolver.Resolve(r) 324 + if err != nil { 325 + log.Println("failed to get repo and knot", err) 326 + return 327 + } 328 + 329 + issueId := chi.URLParam(r, "issue") 330 + issueIdInt, err := strconv.Atoi(issueId) 331 + if err != nil { 332 + http.Error(w, "bad issue id", http.StatusBadRequest) 333 + log.Println("failed to parse issue id", err) 334 + return 335 + } 336 + 337 + commentId := chi.URLParam(r, "comment_id") 338 + commentIdInt, err := strconv.Atoi(commentId) 339 + if err != nil { 340 + http.Error(w, "bad comment id", http.StatusBadRequest) 341 + log.Println("failed to parse issue id", err) 342 + return 343 + } 344 + 345 + issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt) 346 + if err != nil { 347 + log.Println("failed to get issue", err) 348 + rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 349 + return 350 + } 351 + 352 + comment, err := db.GetComment(rp.db, f.RepoAt, issueIdInt, commentIdInt) 353 + if err != nil { 354 + http.Error(w, "bad comment id", http.StatusBadRequest) 355 + return 356 + } 357 + 358 + identity, err := rp.idResolver.ResolveIdent(r.Context(), comment.OwnerDid) 359 + if err != nil { 360 + log.Println("failed to resolve did") 361 + return 362 + } 363 + 364 + didHandleMap := make(map[string]string) 365 + if !identity.Handle.IsInvalidHandle() { 366 + didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 367 + } else { 368 + didHandleMap[identity.DID.String()] = identity.DID.String() 369 + } 370 + 371 + rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ 372 + LoggedInUser: user, 373 + RepoInfo: f.RepoInfo(user), 374 + DidHandleMap: didHandleMap, 375 + Issue: issue, 376 + Comment: comment, 377 + }) 378 + } 379 + 380 + func (rp *Issues) EditIssueComment(w http.ResponseWriter, r *http.Request) { 381 + user := rp.oauth.GetUser(r) 382 + f, err := rp.repoResolver.Resolve(r) 383 + if err != nil { 384 + log.Println("failed to get repo and knot", err) 385 + return 386 + } 387 + 388 + issueId := chi.URLParam(r, "issue") 389 + issueIdInt, err := strconv.Atoi(issueId) 390 + if err != nil { 391 + http.Error(w, "bad issue id", http.StatusBadRequest) 392 + log.Println("failed to parse issue id", err) 393 + return 394 + } 395 + 396 + commentId := chi.URLParam(r, "comment_id") 397 + commentIdInt, err := strconv.Atoi(commentId) 398 + if err != nil { 399 + http.Error(w, "bad comment id", http.StatusBadRequest) 400 + log.Println("failed to parse issue id", err) 401 + return 402 + } 403 + 404 + issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt) 405 + if err != nil { 406 + log.Println("failed to get issue", err) 407 + rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 408 + return 409 + } 410 + 411 + comment, err := db.GetComment(rp.db, f.RepoAt, issueIdInt, commentIdInt) 412 + if err != nil { 413 + http.Error(w, "bad comment id", http.StatusBadRequest) 414 + return 415 + } 416 + 417 + if comment.OwnerDid != user.Did { 418 + http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 419 + return 420 + } 421 + 422 + switch r.Method { 423 + case http.MethodGet: 424 + rp.pages.EditIssueCommentFragment(w, pages.EditIssueCommentParams{ 425 + LoggedInUser: user, 426 + RepoInfo: f.RepoInfo(user), 427 + Issue: issue, 428 + Comment: comment, 429 + }) 430 + case http.MethodPost: 431 + // extract form value 432 + newBody := r.FormValue("body") 433 + client, err := rp.oauth.AuthorizedClient(r) 434 + if err != nil { 435 + log.Println("failed to get authorized client", err) 436 + rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 437 + return 438 + } 439 + rkey := comment.Rkey 440 + 441 + // optimistic update 442 + edited := time.Now() 443 + err = db.EditComment(rp.db, comment.RepoAt, comment.Issue, comment.CommentId, newBody) 444 + if err != nil { 445 + log.Println("failed to perferom update-description query", err) 446 + rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 447 + return 448 + } 449 + 450 + // rkey is optional, it was introduced later 451 + if comment.Rkey != "" { 452 + // update the record on pds 453 + ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueCommentNSID, user.Did, rkey) 454 + if err != nil { 455 + // failed to get record 456 + log.Println(err, rkey) 457 + rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.") 458 + return 459 + } 460 + value, _ := ex.Value.MarshalJSON() // we just did get record; it is valid json 461 + record, _ := data.UnmarshalJSON(value) 462 + 463 + repoAt := record["repo"].(string) 464 + issueAt := record["issue"].(string) 465 + createdAt := record["createdAt"].(string) 466 + commentIdInt64 := int64(commentIdInt) 467 + 468 + _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 469 + Collection: tangled.RepoIssueCommentNSID, 470 + Repo: user.Did, 471 + Rkey: rkey, 472 + SwapRecord: ex.Cid, 473 + Record: &lexutil.LexiconTypeDecoder{ 474 + Val: &tangled.RepoIssueComment{ 475 + Repo: &repoAt, 476 + Issue: issueAt, 477 + CommentId: &commentIdInt64, 478 + Owner: &comment.OwnerDid, 479 + Body: newBody, 480 + CreatedAt: createdAt, 481 + }, 482 + }, 483 + }) 484 + if err != nil { 485 + log.Println(err) 486 + } 487 + } 488 + 489 + // optimistic update for htmx 490 + didHandleMap := map[string]string{ 491 + user.Did: user.Handle, 492 + } 493 + comment.Body = newBody 494 + comment.Edited = &edited 495 + 496 + // return new comment body with htmx 497 + rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ 498 + LoggedInUser: user, 499 + RepoInfo: f.RepoInfo(user), 500 + DidHandleMap: didHandleMap, 501 + Issue: issue, 502 + Comment: comment, 503 + }) 504 + return 505 + 506 + } 507 + 508 + } 509 + 510 + func (rp *Issues) DeleteIssueComment(w http.ResponseWriter, r *http.Request) { 511 + user := rp.oauth.GetUser(r) 512 + f, err := rp.repoResolver.Resolve(r) 513 + if err != nil { 514 + log.Println("failed to get repo and knot", err) 515 + return 516 + } 517 + 518 + issueId := chi.URLParam(r, "issue") 519 + issueIdInt, err := strconv.Atoi(issueId) 520 + if err != nil { 521 + http.Error(w, "bad issue id", http.StatusBadRequest) 522 + log.Println("failed to parse issue id", err) 523 + return 524 + } 525 + 526 + issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt) 527 + if err != nil { 528 + log.Println("failed to get issue", err) 529 + rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 530 + return 531 + } 532 + 533 + commentId := chi.URLParam(r, "comment_id") 534 + commentIdInt, err := strconv.Atoi(commentId) 535 + if err != nil { 536 + http.Error(w, "bad comment id", http.StatusBadRequest) 537 + log.Println("failed to parse issue id", err) 538 + return 539 + } 540 + 541 + comment, err := db.GetComment(rp.db, f.RepoAt, issueIdInt, commentIdInt) 542 + if err != nil { 543 + http.Error(w, "bad comment id", http.StatusBadRequest) 544 + return 545 + } 546 + 547 + if comment.OwnerDid != user.Did { 548 + http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 549 + return 550 + } 551 + 552 + if comment.Deleted != nil { 553 + http.Error(w, "comment already deleted", http.StatusBadRequest) 554 + return 555 + } 556 + 557 + // optimistic deletion 558 + deleted := time.Now() 559 + err = db.DeleteComment(rp.db, f.RepoAt, issueIdInt, commentIdInt) 560 + if err != nil { 561 + log.Println("failed to delete comment") 562 + rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment") 563 + return 564 + } 565 + 566 + // delete from pds 567 + if comment.Rkey != "" { 568 + client, err := rp.oauth.AuthorizedClient(r) 569 + if err != nil { 570 + log.Println("failed to get authorized client", err) 571 + rp.pages.Notice(w, "issue-comment", "Failed to delete comment.") 572 + return 573 + } 574 + _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 575 + Collection: tangled.GraphFollowNSID, 576 + Repo: user.Did, 577 + Rkey: comment.Rkey, 578 + }) 579 + if err != nil { 580 + log.Println(err) 581 + } 582 + } 583 + 584 + // optimistic update for htmx 585 + didHandleMap := map[string]string{ 586 + user.Did: user.Handle, 587 + } 588 + comment.Body = "" 589 + comment.Deleted = &deleted 590 + 591 + // htmx fragment of comment after deletion 592 + rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ 593 + LoggedInUser: user, 594 + RepoInfo: f.RepoInfo(user), 595 + DidHandleMap: didHandleMap, 596 + Issue: issue, 597 + Comment: comment, 598 + }) 599 + return 600 + } 601 + 602 + func (rp *Issues) RepoIssues(w http.ResponseWriter, r *http.Request) { 603 + params := r.URL.Query() 604 + state := params.Get("state") 605 + isOpen := true 606 + switch state { 607 + case "open": 608 + isOpen = true 609 + case "closed": 610 + isOpen = false 611 + default: 612 + isOpen = true 613 + } 614 + 615 + page, ok := r.Context().Value("page").(pagination.Page) 616 + if !ok { 617 + log.Println("failed to get page") 618 + page = pagination.FirstPage() 619 + } 620 + 621 + user := rp.oauth.GetUser(r) 622 + f, err := rp.repoResolver.Resolve(r) 623 + if err != nil { 624 + log.Println("failed to get repo and knot", err) 625 + return 626 + } 627 + 628 + issues, err := db.GetIssues(rp.db, f.RepoAt, isOpen, page) 629 + if err != nil { 630 + log.Println("failed to get issues", err) 631 + rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.") 632 + return 633 + } 634 + 635 + identsToResolve := make([]string, len(issues)) 636 + for i, issue := range issues { 637 + identsToResolve[i] = issue.OwnerDid 638 + } 639 + resolvedIds := rp.idResolver.ResolveIdents(r.Context(), identsToResolve) 640 + didHandleMap := make(map[string]string) 641 + for _, identity := range resolvedIds { 642 + if !identity.Handle.IsInvalidHandle() { 643 + didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 644 + } else { 645 + didHandleMap[identity.DID.String()] = identity.DID.String() 646 + } 647 + } 648 + 649 + rp.pages.RepoIssues(w, pages.RepoIssuesParams{ 650 + LoggedInUser: rp.oauth.GetUser(r), 651 + RepoInfo: f.RepoInfo(user), 652 + Issues: issues, 653 + DidHandleMap: didHandleMap, 654 + FilteringByOpen: isOpen, 655 + Page: page, 656 + }) 657 + return 658 + } 659 + 660 + func (rp *Issues) NewIssue(w http.ResponseWriter, r *http.Request) { 661 + user := rp.oauth.GetUser(r) 662 + 663 + f, err := rp.repoResolver.Resolve(r) 664 + if err != nil { 665 + log.Println("failed to get repo and knot", err) 666 + return 667 + } 668 + 669 + switch r.Method { 670 + case http.MethodGet: 671 + rp.pages.RepoNewIssue(w, pages.RepoNewIssueParams{ 672 + LoggedInUser: user, 673 + RepoInfo: f.RepoInfo(user), 674 + }) 675 + case http.MethodPost: 676 + title := r.FormValue("title") 677 + body := r.FormValue("body") 678 + 679 + if title == "" || body == "" { 680 + rp.pages.Notice(w, "issues", "Title and body are required") 681 + return 682 + } 683 + 684 + tx, err := rp.db.BeginTx(r.Context(), nil) 685 + if err != nil { 686 + rp.pages.Notice(w, "issues", "Failed to create issue, try again later") 687 + return 688 + } 689 + 690 + err = db.NewIssue(tx, &db.Issue{ 691 + RepoAt: f.RepoAt, 692 + Title: title, 693 + Body: body, 694 + OwnerDid: user.Did, 695 + }) 696 + if err != nil { 697 + log.Println("failed to create issue", err) 698 + rp.pages.Notice(w, "issues", "Failed to create issue.") 699 + return 700 + } 701 + 702 + issueId, err := db.GetIssueId(rp.db, f.RepoAt) 703 + if err != nil { 704 + log.Println("failed to get issue id", err) 705 + rp.pages.Notice(w, "issues", "Failed to create issue.") 706 + return 707 + } 708 + 709 + client, err := rp.oauth.AuthorizedClient(r) 710 + if err != nil { 711 + log.Println("failed to get authorized client", err) 712 + rp.pages.Notice(w, "issues", "Failed to create issue.") 713 + return 714 + } 715 + atUri := f.RepoAt.String() 716 + resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 717 + Collection: tangled.RepoIssueNSID, 718 + Repo: user.Did, 719 + Rkey: appview.TID(), 720 + Record: &lexutil.LexiconTypeDecoder{ 721 + Val: &tangled.RepoIssue{ 722 + Repo: atUri, 723 + Title: title, 724 + Body: &body, 725 + Owner: user.Did, 726 + IssueId: int64(issueId), 727 + }, 728 + }, 729 + }) 730 + if err != nil { 731 + log.Println("failed to create issue", err) 732 + rp.pages.Notice(w, "issues", "Failed to create issue.") 733 + return 734 + } 735 + 736 + err = db.SetIssueAt(rp.db, f.RepoAt, issueId, resp.Uri) 737 + if err != nil { 738 + log.Println("failed to set issue at", err) 739 + rp.pages.Notice(w, "issues", "Failed to create issue.") 740 + return 741 + } 742 + 743 + if !rp.config.Core.Dev { 744 + err = rp.posthog.Enqueue(posthog.Capture{ 745 + DistinctId: user.Did, 746 + Event: "new_issue", 747 + Properties: posthog.Properties{"repo_at": f.RepoAt.String(), "issue_id": issueId}, 748 + }) 749 + if err != nil { 750 + log.Println("failed to enqueue posthog event:", err) 751 + } 752 + } 753 + 754 + rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueId)) 755 + return 756 + } 757 + }
+34
appview/issues/router.go
··· 1 + package issues 2 + 3 + import ( 4 + "net/http" 5 + 6 + "github.com/go-chi/chi/v5" 7 + "tangled.sh/tangled.sh/core/appview/middleware" 8 + ) 9 + 10 + func (i *Issues) Router(mw *middleware.Middleware) http.Handler { 11 + r := chi.NewRouter() 12 + 13 + r.Route("/", func(r chi.Router) { 14 + r.With(middleware.Paginate).Get("/", i.RepoIssues) 15 + r.Get("/{issue}", i.RepoSingleIssue) 16 + 17 + r.Group(func(r chi.Router) { 18 + r.Use(middleware.AuthMiddleware(i.oauth)) 19 + r.Get("/new", i.NewIssue) 20 + r.Post("/new", i.NewIssue) 21 + r.Post("/{issue}/comment", i.NewIssueComment) 22 + r.Route("/{issue}/comment/{comment_id}/", func(r chi.Router) { 23 + r.Get("/", i.IssueComment) 24 + r.Delete("/", i.DeleteIssueComment) 25 + r.Get("/edit", i.EditIssueComment) 26 + r.Post("/edit", i.EditIssueComment) 27 + }) 28 + r.Post("/{issue}/close", i.CloseIssue) 29 + r.Post("/{issue}/reopen", i.ReopenIssue) 30 + }) 31 + }) 32 + 33 + return r 34 + }
+241 -2
appview/middleware/middleware.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "fmt" 5 6 "log" 6 7 "net/http" 8 + "slices" 7 9 "strconv" 10 + "strings" 11 + "time" 8 12 13 + "github.com/bluesky-social/indigo/atproto/identity" 14 + "github.com/go-chi/chi/v5" 15 + "tangled.sh/tangled.sh/core/appview/db" 16 + "tangled.sh/tangled.sh/core/appview/idresolver" 9 17 "tangled.sh/tangled.sh/core/appview/oauth" 18 + "tangled.sh/tangled.sh/core/appview/pages" 10 19 "tangled.sh/tangled.sh/core/appview/pagination" 20 + "tangled.sh/tangled.sh/core/appview/reporesolver" 21 + "tangled.sh/tangled.sh/core/rbac" 11 22 ) 12 23 13 - type Middleware func(http.Handler) http.Handler 24 + type Middleware struct { 25 + oauth *oauth.OAuth 26 + db *db.DB 27 + enforcer *rbac.Enforcer 28 + repoResolver *reporesolver.RepoResolver 29 + idResolver *idresolver.Resolver 30 + pages *pages.Pages 31 + } 32 + 33 + func New(oauth *oauth.OAuth, db *db.DB, enforcer *rbac.Enforcer, repoResolver *reporesolver.RepoResolver, idResolver *idresolver.Resolver, pages *pages.Pages) Middleware { 34 + return Middleware{ 35 + oauth: oauth, 36 + db: db, 37 + enforcer: enforcer, 38 + repoResolver: repoResolver, 39 + idResolver: idResolver, 40 + pages: pages, 41 + } 42 + } 43 + 44 + type middlewareFunc func(http.Handler) http.Handler 14 45 15 - func AuthMiddleware(a *oauth.OAuth) Middleware { 46 + func AuthMiddleware(a *oauth.OAuth) middlewareFunc { 16 47 return func(next http.Handler) http.Handler { 17 48 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 18 49 redirectFunc := func(w http.ResponseWriter, r *http.Request) { ··· 71 102 next.ServeHTTP(w, r.WithContext(ctx)) 72 103 }) 73 104 } 105 + 106 + func (mw Middleware) knotRoleMiddleware(group string) middlewareFunc { 107 + return func(next http.Handler) http.Handler { 108 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 109 + // requires auth also 110 + actor := mw.oauth.GetUser(r) 111 + if actor == nil { 112 + // we need a logged in user 113 + log.Printf("not logged in, redirecting") 114 + http.Error(w, "Forbiden", http.StatusUnauthorized) 115 + return 116 + } 117 + domain := chi.URLParam(r, "domain") 118 + if domain == "" { 119 + http.Error(w, "malformed url", http.StatusBadRequest) 120 + return 121 + } 122 + 123 + ok, err := mw.enforcer.E.HasGroupingPolicy(actor.Did, group, domain) 124 + if err != nil || !ok { 125 + // we need a logged in user 126 + log.Printf("%s does not have perms of a %s in domain %s", actor.Did, group, domain) 127 + http.Error(w, "Forbiden", http.StatusUnauthorized) 128 + return 129 + } 130 + 131 + next.ServeHTTP(w, r) 132 + }) 133 + } 134 + } 135 + 136 + func (mw Middleware) KnotOwner() middlewareFunc { 137 + return mw.knotRoleMiddleware("server:owner") 138 + } 139 + 140 + func (mw Middleware) RepoPermissionMiddleware(requiredPerm string) middlewareFunc { 141 + return func(next http.Handler) http.Handler { 142 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 143 + // requires auth also 144 + actor := mw.oauth.GetUser(r) 145 + if actor == nil { 146 + // we need a logged in user 147 + log.Printf("not logged in, redirecting") 148 + http.Error(w, "Forbiden", http.StatusUnauthorized) 149 + return 150 + } 151 + f, err := mw.repoResolver.Resolve(r) 152 + if err != nil { 153 + http.Error(w, "malformed url", http.StatusBadRequest) 154 + return 155 + } 156 + 157 + ok, err := mw.enforcer.E.Enforce(actor.Did, f.Knot, f.DidSlashRepo(), requiredPerm) 158 + if err != nil || !ok { 159 + // we need a logged in user 160 + log.Printf("%s does not have perms of a %s in repo %s", actor.Did, requiredPerm, f.OwnerSlashRepo()) 161 + http.Error(w, "Forbiden", http.StatusUnauthorized) 162 + return 163 + } 164 + 165 + next.ServeHTTP(w, r) 166 + }) 167 + } 168 + } 169 + 170 + func StripLeadingAt(next http.Handler) http.Handler { 171 + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 172 + path := req.URL.EscapedPath() 173 + if strings.HasPrefix(path, "/@") { 174 + req.URL.RawPath = "/" + strings.TrimPrefix(path, "/@") 175 + } 176 + next.ServeHTTP(w, req) 177 + }) 178 + } 179 + 180 + func (mw Middleware) ResolveIdent() middlewareFunc { 181 + excluded := []string{"favicon.ico"} 182 + 183 + return func(next http.Handler) http.Handler { 184 + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 185 + didOrHandle := chi.URLParam(req, "user") 186 + if slices.Contains(excluded, didOrHandle) { 187 + next.ServeHTTP(w, req) 188 + return 189 + } 190 + 191 + id, err := mw.idResolver.ResolveIdent(req.Context(), didOrHandle) 192 + if err != nil { 193 + // invalid did or handle 194 + log.Println("failed to resolve did/handle:", err) 195 + w.WriteHeader(http.StatusNotFound) 196 + return 197 + } 198 + 199 + ctx := context.WithValue(req.Context(), "resolvedId", *id) 200 + 201 + next.ServeHTTP(w, req.WithContext(ctx)) 202 + }) 203 + } 204 + } 205 + 206 + func (mw Middleware) ResolveRepo() middlewareFunc { 207 + return func(next http.Handler) http.Handler { 208 + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 209 + repoName := chi.URLParam(req, "repo") 210 + id, ok := req.Context().Value("resolvedId").(identity.Identity) 211 + if !ok { 212 + log.Println("malformed middleware") 213 + w.WriteHeader(http.StatusInternalServerError) 214 + return 215 + } 216 + 217 + repo, err := db.GetRepo(mw.db, id.DID.String(), repoName) 218 + if err != nil { 219 + // invalid did or handle 220 + log.Println("failed to resolve repo") 221 + mw.pages.Error404(w) 222 + return 223 + } 224 + 225 + ctx := context.WithValue(req.Context(), "knot", repo.Knot) 226 + ctx = context.WithValue(ctx, "repoAt", repo.AtUri) 227 + ctx = context.WithValue(ctx, "repoDescription", repo.Description) 228 + ctx = context.WithValue(ctx, "repoAddedAt", repo.Created.Format(time.RFC3339)) 229 + next.ServeHTTP(w, req.WithContext(ctx)) 230 + }) 231 + } 232 + } 233 + 234 + // middleware that is tacked on top of /{user}/{repo}/pulls/{pull} 235 + func (mw Middleware) ResolvePull() middlewareFunc { 236 + return func(next http.Handler) http.Handler { 237 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 238 + f, err := mw.repoResolver.Resolve(r) 239 + if err != nil { 240 + log.Println("failed to fully resolve repo", err) 241 + http.Error(w, "invalid repo url", http.StatusNotFound) 242 + return 243 + } 244 + 245 + prId := chi.URLParam(r, "pull") 246 + prIdInt, err := strconv.Atoi(prId) 247 + if err != nil { 248 + http.Error(w, "bad pr id", http.StatusBadRequest) 249 + log.Println("failed to parse pr id", err) 250 + return 251 + } 252 + 253 + pr, err := db.GetPull(mw.db, f.RepoAt, prIdInt) 254 + if err != nil { 255 + log.Println("failed to get pull and comments", err) 256 + return 257 + } 258 + 259 + ctx := context.WithValue(r.Context(), "pull", pr) 260 + 261 + if pr.IsStacked() { 262 + stack, err := db.GetStack(mw.db, pr.StackId) 263 + if err != nil { 264 + log.Println("failed to get stack", err) 265 + return 266 + } 267 + abandonedPulls, err := db.GetAbandonedPulls(mw.db, pr.StackId) 268 + if err != nil { 269 + log.Println("failed to get abandoned pulls", err) 270 + return 271 + } 272 + 273 + ctx = context.WithValue(ctx, "stack", stack) 274 + ctx = context.WithValue(ctx, "abandonedPulls", abandonedPulls) 275 + } 276 + 277 + next.ServeHTTP(w, r.WithContext(ctx)) 278 + }) 279 + } 280 + } 281 + 282 + // this should serve the go-import meta tag even if the path is technically 283 + // a 404 like tangled.sh/oppi.li/go-git/v5 284 + func (mw Middleware) GoImport() middlewareFunc { 285 + return func(next http.Handler) http.Handler { 286 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 287 + f, err := mw.repoResolver.Resolve(r) 288 + if err != nil { 289 + log.Println("failed to fully resolve repo", err) 290 + http.Error(w, "invalid repo url", http.StatusNotFound) 291 + return 292 + } 293 + 294 + fullName := f.OwnerHandle() + "/" + f.RepoName 295 + 296 + if r.Header.Get("User-Agent") == "Go-http-client/1.1" { 297 + if r.URL.Query().Get("go-get") == "1" { 298 + html := fmt.Sprintf( 299 + `<meta name="go-import" content="tangled.sh/%s git https://tangled.sh/%s"/>`, 300 + fullName, 301 + fullName, 302 + ) 303 + w.Header().Set("Content-Type", "text/html") 304 + w.Write([]byte(html)) 305 + return 306 + } 307 + } 308 + 309 + next.ServeHTTP(w, r) 310 + }) 311 + } 312 + }
+2 -2
appview/oauth/client/oauth_client.go
··· 1 1 package client 2 2 3 3 import ( 4 - oauth "github.com/haileyok/atproto-oauth-golang" 5 - "github.com/haileyok/atproto-oauth-golang/helpers" 4 + oauth "tangled.sh/icyphox.sh/atproto-oauth" 5 + "tangled.sh/icyphox.sh/atproto-oauth/helpers" 6 6 ) 7 7 8 8 type OAuthClient struct {
+15
appview/oauth/consts.go
··· 1 + package oauth 2 + 3 + const ( 4 + SessionName = "appview-session" 5 + SessionHandle = "handle" 6 + SessionDid = "did" 7 + SessionPds = "pds" 8 + SessionAccessJwt = "accessJwt" 9 + SessionRefreshJwt = "refreshJwt" 10 + SessionExpiry = "expiry" 11 + SessionAuthenticated = "authenticated" 12 + 13 + SessionDpopPrivateJwk = "dpopPrivateJwk" 14 + SessionDpopAuthServerNonce = "dpopAuthServerNonce" 15 + )
+71 -48
appview/oauth/handler/handler.go
··· 10 10 11 11 "github.com/go-chi/chi/v5" 12 12 "github.com/gorilla/sessions" 13 - "github.com/haileyok/atproto-oauth-golang/helpers" 14 13 "github.com/lestrrat-go/jwx/v2/jwk" 15 14 "github.com/posthog/posthog-go" 16 - "tangled.sh/tangled.sh/core/appview" 15 + "tangled.sh/icyphox.sh/atproto-oauth/helpers" 16 + "tangled.sh/tangled.sh/core/appview/config" 17 17 "tangled.sh/tangled.sh/core/appview/db" 18 + "tangled.sh/tangled.sh/core/appview/idresolver" 18 19 "tangled.sh/tangled.sh/core/appview/middleware" 19 20 "tangled.sh/tangled.sh/core/appview/oauth" 20 21 "tangled.sh/tangled.sh/core/appview/oauth/client" ··· 28 29 ) 29 30 30 31 type OAuthHandler struct { 31 - Config *appview.Config 32 - Pages *pages.Pages 33 - Resolver *appview.Resolver 34 - Db *db.DB 35 - Store *sessions.CookieStore 36 - OAuth *oauth.OAuth 37 - Enforcer *rbac.Enforcer 38 - Posthog posthog.Client 32 + config *config.Config 33 + pages *pages.Pages 34 + idResolver *idresolver.Resolver 35 + db *db.DB 36 + store *sessions.CookieStore 37 + oauth *oauth.OAuth 38 + enforcer *rbac.Enforcer 39 + posthog posthog.Client 40 + } 41 + 42 + func New( 43 + config *config.Config, 44 + pages *pages.Pages, 45 + idResolver *idresolver.Resolver, 46 + db *db.DB, 47 + store *sessions.CookieStore, 48 + oauth *oauth.OAuth, 49 + enforcer *rbac.Enforcer, 50 + posthog posthog.Client, 51 + ) *OAuthHandler { 52 + return &OAuthHandler{ 53 + config: config, 54 + pages: pages, 55 + idResolver: idResolver, 56 + db: db, 57 + store: store, 58 + oauth: oauth, 59 + enforcer: enforcer, 60 + posthog: posthog, 61 + } 39 62 } 40 63 41 64 func (o *OAuthHandler) Router() http.Handler { ··· 44 67 r.Get("/login", o.login) 45 68 r.Post("/login", o.login) 46 69 47 - r.With(middleware.AuthMiddleware(o.OAuth)).Post("/logout", o.logout) 70 + r.With(middleware.AuthMiddleware(o.oauth)).Post("/logout", o.logout) 48 71 49 72 r.Get("/oauth/client-metadata.json", o.clientMetadata) 50 73 r.Get("/oauth/jwks.json", o.jwks) ··· 55 78 func (o *OAuthHandler) clientMetadata(w http.ResponseWriter, r *http.Request) { 56 79 w.Header().Set("Content-Type", "application/json") 57 80 w.WriteHeader(http.StatusOK) 58 - json.NewEncoder(w).Encode(o.OAuth.ClientMetadata()) 81 + json.NewEncoder(w).Encode(o.oauth.ClientMetadata()) 59 82 } 60 83 61 84 func (o *OAuthHandler) jwks(w http.ResponseWriter, r *http.Request) { 62 - jwks := o.Config.OAuth.Jwks 85 + jwks := o.config.OAuth.Jwks 63 86 pubKey, err := pubKeyFromJwk(jwks) 64 87 if err != nil { 65 88 log.Printf("error parsing public key: %v", err) ··· 77 100 func (o *OAuthHandler) login(w http.ResponseWriter, r *http.Request) { 78 101 switch r.Method { 79 102 case http.MethodGet: 80 - o.Pages.Login(w, pages.LoginParams{}) 103 + o.pages.Login(w, pages.LoginParams{}) 81 104 case http.MethodPost: 82 105 handle := strings.TrimPrefix(r.FormValue("handle"), "@") 83 106 84 - resolved, err := o.Resolver.ResolveIdent(r.Context(), handle) 107 + resolved, err := o.idResolver.ResolveIdent(r.Context(), handle) 85 108 if err != nil { 86 109 log.Println("failed to resolve handle:", err) 87 - o.Pages.Notice(w, "login-msg", fmt.Sprintf("\"%s\" is an invalid handle.", handle)) 110 + o.pages.Notice(w, "login-msg", fmt.Sprintf("\"%s\" is an invalid handle.", handle)) 88 111 return 89 112 } 90 - self := o.OAuth.ClientMetadata() 113 + self := o.oauth.ClientMetadata() 91 114 oauthClient, err := client.NewClient( 92 115 self.ClientID, 93 - o.Config.OAuth.Jwks, 116 + o.config.OAuth.Jwks, 94 117 self.RedirectURIs[0], 95 118 ) 96 119 97 120 if err != nil { 98 121 log.Println("failed to create oauth client:", err) 99 - o.Pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 122 + o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 100 123 return 101 124 } 102 125 103 126 authServer, err := oauthClient.ResolvePdsAuthServer(r.Context(), resolved.PDSEndpoint()) 104 127 if err != nil { 105 128 log.Println("failed to resolve auth server:", err) 106 - o.Pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 129 + o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 107 130 return 108 131 } 109 132 110 133 authMeta, err := oauthClient.FetchAuthServerMetadata(r.Context(), authServer) 111 134 if err != nil { 112 135 log.Println("failed to fetch auth server metadata:", err) 113 - o.Pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 136 + o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 114 137 return 115 138 } 116 139 117 140 dpopKey, err := helpers.GenerateKey(nil) 118 141 if err != nil { 119 142 log.Println("failed to generate dpop key:", err) 120 - o.Pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 143 + o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 121 144 return 122 145 } 123 146 124 147 dpopKeyJson, err := json.Marshal(dpopKey) 125 148 if err != nil { 126 149 log.Println("failed to marshal dpop key:", err) 127 - o.Pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 150 + o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 128 151 return 129 152 } 130 153 131 154 parResp, err := oauthClient.SendParAuthRequest(r.Context(), authServer, authMeta, handle, oauthScope, dpopKey) 132 155 if err != nil { 133 156 log.Println("failed to send par auth request:", err) 134 - o.Pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 157 + o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 135 158 return 136 159 } 137 160 138 - err = db.SaveOAuthRequest(o.Db, db.OAuthRequest{ 161 + err = db.SaveOAuthRequest(o.db, db.OAuthRequest{ 139 162 Did: resolved.DID.String(), 140 163 PdsUrl: resolved.PDSEndpoint(), 141 164 Handle: handle, ··· 147 170 }) 148 171 if err != nil { 149 172 log.Println("failed to save oauth request:", err) 150 - o.Pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 173 + o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 151 174 return 152 175 } 153 176 ··· 156 179 query.Add("client_id", self.ClientID) 157 180 query.Add("request_uri", parResp.RequestUri) 158 181 u.RawQuery = query.Encode() 159 - o.Pages.HxRedirect(w, u.String()) 182 + o.pages.HxRedirect(w, u.String()) 160 183 } 161 184 } 162 185 163 186 func (o *OAuthHandler) callback(w http.ResponseWriter, r *http.Request) { 164 187 state := r.FormValue("state") 165 188 166 - oauthRequest, err := db.GetOAuthRequestByState(o.Db, state) 189 + oauthRequest, err := db.GetOAuthRequestByState(o.db, state) 167 190 if err != nil { 168 191 log.Println("failed to get oauth request:", err) 169 - o.Pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 192 + o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 170 193 return 171 194 } 172 195 173 196 defer func() { 174 - err := db.DeleteOAuthRequestByState(o.Db, state) 197 + err := db.DeleteOAuthRequestByState(o.db, state) 175 198 if err != nil { 176 199 log.Println("failed to delete oauth request for state:", state, err) 177 200 } ··· 181 204 errorDescription := r.FormValue("error_description") 182 205 if error != "" || errorDescription != "" { 183 206 log.Printf("error: %s, %s", error, errorDescription) 184 - o.Pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 207 + o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 185 208 return 186 209 } 187 210 188 211 code := r.FormValue("code") 189 212 if code == "" { 190 213 log.Println("missing code for state: ", state) 191 - o.Pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 214 + o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 192 215 return 193 216 } 194 217 195 218 iss := r.FormValue("iss") 196 219 if iss == "" { 197 220 log.Println("missing iss for state: ", state) 198 - o.Pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 221 + o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 199 222 return 200 223 } 201 224 202 - self := o.OAuth.ClientMetadata() 225 + self := o.oauth.ClientMetadata() 203 226 204 227 oauthClient, err := client.NewClient( 205 228 self.ClientID, 206 - o.Config.OAuth.Jwks, 229 + o.config.OAuth.Jwks, 207 230 self.RedirectURIs[0], 208 231 ) 209 232 210 233 if err != nil { 211 234 log.Println("failed to create oauth client:", err) 212 - o.Pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 235 + o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 213 236 return 214 237 } 215 238 216 239 jwk, err := helpers.ParseJWKFromBytes([]byte(oauthRequest.DpopPrivateJwk)) 217 240 if err != nil { 218 241 log.Println("failed to parse jwk:", err) 219 - o.Pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 242 + o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 220 243 return 221 244 } 222 245 ··· 230 253 ) 231 254 if err != nil { 232 255 log.Println("failed to get token:", err) 233 - o.Pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 256 + o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 234 257 return 235 258 } 236 259 237 260 if tokenResp.Scope != oauthScope { 238 261 log.Println("scope doesn't match:", tokenResp.Scope) 239 - o.Pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 262 + o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 240 263 return 241 264 } 242 265 243 - err = o.OAuth.SaveSession(w, r, oauthRequest, tokenResp) 266 + err = o.oauth.SaveSession(w, r, oauthRequest, tokenResp) 244 267 if err != nil { 245 268 log.Println("failed to save session:", err) 246 - o.Pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 269 + o.pages.Notice(w, "login-msg", "Failed to authenticate. Try again later.") 247 270 return 248 271 } 249 272 250 273 log.Println("session saved successfully") 251 274 go o.addToDefaultKnot(oauthRequest.Did) 252 275 253 - if !o.Config.Core.Dev { 254 - err = o.Posthog.Enqueue(posthog.Capture{ 276 + if !o.config.Core.Dev { 277 + err = o.posthog.Enqueue(posthog.Capture{ 255 278 DistinctId: oauthRequest.Did, 256 279 Event: "signin", 257 280 }) ··· 264 287 } 265 288 266 289 func (o *OAuthHandler) logout(w http.ResponseWriter, r *http.Request) { 267 - err := o.OAuth.ClearSession(r, w) 290 + err := o.oauth.ClearSession(r, w) 268 291 if err != nil { 269 292 log.Println("failed to clear session:", err) 270 293 http.Redirect(w, r, "/", http.StatusFound) ··· 291 314 defaultKnot := "knot1.tangled.sh" 292 315 293 316 log.Printf("adding %s to default knot", did) 294 - err := o.Enforcer.AddMember(defaultKnot, did) 317 + err := o.enforcer.AddMember(defaultKnot, did) 295 318 if err != nil { 296 319 log.Println("failed to add user to knot1.tangled.sh: ", err) 297 320 return 298 321 } 299 - err = o.Enforcer.E.SavePolicy() 322 + err = o.enforcer.E.SavePolicy() 300 323 if err != nil { 301 324 log.Println("failed to add user to knot1.tangled.sh: ", err) 302 325 return 303 326 } 304 327 305 - secret, err := db.GetRegistrationKey(o.Db, defaultKnot) 328 + secret, err := db.GetRegistrationKey(o.db, defaultKnot) 306 329 if err != nil { 307 330 log.Println("failed to get registration key for knot1.tangled.sh") 308 331 return 309 332 } 310 - signedClient, err := knotclient.NewSignedClient(defaultKnot, secret, o.Config.Core.Dev) 333 + signedClient, err := knotclient.NewSignedClient(defaultKnot, secret, o.config.Core.Dev) 311 334 resp, err := signedClient.AddMember(did) 312 335 if err != nil { 313 336 log.Println("failed to add user to knot1.tangled.sh: ", err)
+21 -21
appview/oauth/oauth.go
··· 8 8 "time" 9 9 10 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" 11 + oauth "tangled.sh/icyphox.sh/atproto-oauth" 12 + "tangled.sh/icyphox.sh/atproto-oauth/helpers" 13 + "tangled.sh/tangled.sh/core/appview/config" 14 14 "tangled.sh/tangled.sh/core/appview/db" 15 15 "tangled.sh/tangled.sh/core/appview/oauth/client" 16 16 xrpc "tangled.sh/tangled.sh/core/appview/xrpcclient" ··· 30 30 type OAuth struct { 31 31 Store *sessions.CookieStore 32 32 Db *db.DB 33 - Config *appview.Config 33 + Config *config.Config 34 34 } 35 35 36 - func NewOAuth(db *db.DB, config *appview.Config) *OAuth { 36 + func NewOAuth(db *db.DB, config *config.Config) *OAuth { 37 37 return &OAuth{ 38 38 Store: sessions.NewCookieStore([]byte(config.Core.CookieSecret)), 39 39 Db: db, ··· 43 43 44 44 func (o *OAuth) SaveSession(w http.ResponseWriter, r *http.Request, oreq db.OAuthRequest, oresp *oauth.TokenResponse) error { 45 45 // first we save the did in the user session 46 - userSession, err := o.Store.Get(r, appview.SessionName) 46 + userSession, err := o.Store.Get(r, SessionName) 47 47 if err != nil { 48 48 return err 49 49 } 50 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 51 + userSession.Values[SessionDid] = oreq.Did 52 + userSession.Values[SessionHandle] = oreq.Handle 53 + userSession.Values[SessionPds] = oreq.PdsUrl 54 + userSession.Values[SessionAuthenticated] = true 55 55 err = userSession.Save(r, w) 56 56 if err != nil { 57 57 return fmt.Errorf("error saving user session: %w", err) ··· 74 74 } 75 75 76 76 func (o *OAuth) ClearSession(r *http.Request, w http.ResponseWriter) error { 77 - userSession, err := o.Store.Get(r, appview.SessionName) 77 + userSession, err := o.Store.Get(r, SessionName) 78 78 if err != nil || userSession.IsNew { 79 79 return fmt.Errorf("error getting user session (or new session?): %w", err) 80 80 } 81 81 82 - did := userSession.Values[appview.SessionDid].(string) 82 + did := userSession.Values[SessionDid].(string) 83 83 84 84 err = db.DeleteOAuthSessionByDid(o.Db, did) 85 85 if err != nil { ··· 92 92 } 93 93 94 94 func (o *OAuth) GetSession(r *http.Request) (*db.OAuthSession, bool, error) { 95 - userSession, err := o.Store.Get(r, appview.SessionName) 95 + userSession, err := o.Store.Get(r, SessionName) 96 96 if err != nil || userSession.IsNew { 97 97 return nil, false, fmt.Errorf("error getting user session (or new session?): %w", err) 98 98 } 99 99 100 - did := userSession.Values[appview.SessionDid].(string) 101 - auth := userSession.Values[appview.SessionAuthenticated].(bool) 100 + did := userSession.Values[SessionDid].(string) 101 + auth := userSession.Values[SessionAuthenticated].(bool) 102 102 103 103 session, err := db.GetOAuthSessionByDid(o.Db, did) 104 104 if err != nil { ··· 155 155 } 156 156 157 157 func (a *OAuth) GetUser(r *http.Request) *User { 158 - clientSession, err := a.Store.Get(r, appview.SessionName) 158 + clientSession, err := a.Store.Get(r, SessionName) 159 159 160 160 if err != nil || clientSession.IsNew { 161 161 return nil 162 162 } 163 163 164 164 return &User{ 165 - Handle: clientSession.Values[appview.SessionHandle].(string), 166 - Did: clientSession.Values[appview.SessionDid].(string), 167 - Pds: clientSession.Values[appview.SessionPds].(string), 165 + Handle: clientSession.Values[SessionHandle].(string), 166 + Did: clientSession.Values[SessionDid].(string), 167 + Pds: clientSession.Values[SessionPds].(string), 168 168 } 169 169 } 170 170 171 171 func (a *OAuth) GetDid(r *http.Request) string { 172 - clientSession, err := a.Store.Get(r, appview.SessionName) 172 + clientSession, err := a.Store.Get(r, SessionName) 173 173 174 174 if err != nil || clientSession.IsNew { 175 175 return "" 176 176 } 177 177 178 - return clientSession.Values[appview.SessionDid].(string) 178 + return clientSession.Values[SessionDid].(string) 179 179 } 180 180 181 181 func (o *OAuth) AuthorizedClient(r *http.Request) (*xrpc.Client, error) {
+13 -4
appview/pages/funcmap.go
··· 7 7 "html/template" 8 8 "log" 9 9 "math" 10 + "net/url" 10 11 "path/filepath" 11 12 "reflect" 12 13 "strings" ··· 133 134 "sequence": func(n int) []struct{} { 134 135 return make([]struct{}, n) 135 136 }, 136 - "subslice": func(slice any, start, end int) any { 137 + // take atmost N items from this slice 138 + "take": func(slice any, n int) any { 137 139 v := reflect.ValueOf(slice) 138 140 if v.Kind() != reflect.Slice && v.Kind() != reflect.Array { 139 141 return nil 140 142 } 141 - if start < 0 || start > v.Len() || end > v.Len() || start > end { 143 + if v.Len() == 0 { 142 144 return nil 143 145 } 144 - return v.Slice(start, end).Interface() 146 + return v.Slice(0, min(n, v.Len()-1)).Interface() 145 147 }, 148 + 146 149 "markdown": func(text string) template.HTML { 147 150 rctx := &markup.RenderContext{RendererType: markup.RendererTypeDefault} 148 - return template.HTML(bluemonday.UGCPolicy().Sanitize(rctx.RenderMarkdown(text))) 151 + return template.HTML( 152 + rctx.RenderMarkdown(text, bluemonday.UGCPolicy().Sanitize), 153 + ) 149 154 }, 150 155 "isNil": func(t any) bool { 151 156 // returns false for other "zero" values ··· 178 183 }, 179 184 "cssContentHash": CssContentHash, 180 185 "fileTree": filetree.FileTree, 186 + "pathUnescape": func(s string) string { 187 + u, _ := url.PathUnescape(s) 188 + return u 189 + }, 181 190 } 182 191 } 183 192
+88 -4
appview/pages/markup/markdown.go
··· 9 9 "path" 10 10 "strings" 11 11 12 + "github.com/alecthomas/chroma/v2/styles" 12 13 "github.com/microcosm-cc/bluemonday" 13 14 "github.com/yuin/goldmark" 15 + "github.com/yuin/goldmark-highlighting/v2" 14 16 "github.com/yuin/goldmark/ast" 15 17 "github.com/yuin/goldmark/extension" 16 18 "github.com/yuin/goldmark/parser" ··· 18 20 "github.com/yuin/goldmark/text" 19 21 "github.com/yuin/goldmark/util" 20 22 htmlparse "golang.org/x/net/html" 23 + "golang.org/x/net/html/atom" 21 24 22 25 "tangled.sh/tangled.sh/core/appview/pages/repoinfo" 23 26 ) ··· 42 45 RendererType RendererType 43 46 } 44 47 45 - func (rctx *RenderContext) RenderMarkdown(source string) string { 48 + // RenderMarkdown renders the given markdown source into sanitized HTML. sanitizer is used to sanitize the HTML output. 49 + func (rctx *RenderContext) RenderMarkdown(source string, sanitizer func(string) string) string { 46 50 md := goldmark.New( 47 - goldmark.WithExtensions(extension.GFM), 51 + goldmark.WithExtensions(extension.GFM, 52 + highlighting.NewHighlighting( 53 + highlighting.WithCustomStyle( 54 + styles.Get("catppuccin-latte"), 55 + ), 56 + ), 57 + ), 48 58 goldmark.WithParserOptions( 49 59 parser.WithAutoHeadingID(), 50 60 ), ··· 66 76 return source 67 77 } 68 78 79 + sanitizedHtml := sanitizer(buf.String()) 80 + 69 81 var processed strings.Builder 70 - if err := postProcess(rctx, strings.NewReader(buf.String()), &processed); err != nil { 82 + if err := postProcessSanitizedHtml(rctx, strings.NewReader(sanitizedHtml), &processed); err != nil { 71 83 return source 72 84 } 73 85 74 86 return processed.String() 75 87 } 76 88 77 - func postProcess(ctx *RenderContext, input io.Reader, output io.Writer) error { 89 + // postProcessSanitizedHtml processes the HTML output from the markdown renderer. 90 + // WARNING: Do not insert raw HTML from user-controlled input. Sanitization already happened beforehand at this point. 91 + func postProcessSanitizedHtml(ctx *RenderContext, input io.Reader, output io.Writer) error { 78 92 node, err := htmlparse.Parse(io.MultiReader( 79 93 strings.NewReader("<html><body>"), 80 94 input, ··· 119 133 return nil 120 134 } 121 135 136 + // visitNode is called on every node of a SANITIZED html document. 137 + // WARNING: Do not insert raw HTML from user-controlled input. Sanitization already happened beforehand at this point. 122 138 func visitNode(ctx *RenderContext, node *htmlparse.Node) { 123 139 switch node.Type { 124 140 case htmlparse.ElementNode: ··· 136 152 node.Attr[i] = attr 137 153 } 138 154 } 155 + } 156 + 157 + if node.Data == "pre" { 158 + // TODO only show when :hover or :focus on the pre element 159 + button := &htmlparse.Node{ 160 + Type: htmlparse.ElementNode, 161 + DataAtom: atom.Button, 162 + Data: "button", 163 + Attr: []htmlparse.Attribute{ 164 + { 165 + Key: "class", 166 + Val: "absolute top-2 right-2 btn", 167 + }, 168 + { 169 + Key: "style", 170 + // FIXME .#watch-tailwind doesnt seem to catch top-2 and right-2, probably cuz it's not used anywhere inside of templates/ ? 171 + Val: "top: 0.5rem; right: 0.5rem;", 172 + }, 173 + { 174 + Key: "onclick", 175 + Val: ` 176 + navigator.clipboard.writeText(this.closest('pre').querySelector('code').innerText); 177 + this.innerText = 'Copied!'; 178 + setTimeout(() => { this.innerText = 'Copy' }, 1500); 179 + `, 180 + }, 181 + // FIXME: onload does not fire :/ 182 + // { 183 + // Key: "onload", 184 + // Val: "this.removeAttribute('aria-hidden')", 185 + // }, 186 + // { 187 + // Key: "aria-hidden", 188 + // Val: "true", 189 + // }, 190 + { 191 + Key: "title", 192 + Val: "Copy to clipboard", 193 + }, 194 + }, 195 + } 196 + 197 + // TODO 198 + // if copyIcon, err := icons.IconNode("copy", "h-4", "w-4"); err != nil { 199 + // button.AppendChild(copyIcon) 200 + // } else { 201 + button.AppendChild(&htmlparse.Node{ 202 + Type: htmlparse.TextNode, 203 + Data: "Copy", 204 + }) 205 + 206 + var classWasSetOnNode bool 207 + for i, attr := range node.Attr { 208 + if attr.Key == "class" { 209 + node.Attr[i].Val += " relative" 210 + classWasSetOnNode = true 211 + break 212 + } 213 + } 214 + 215 + if !classWasSetOnNode { 216 + node.Attr = append(node.Attr, htmlparse.Attribute{ 217 + Key: "class", 218 + Val: "relative", 219 + }) 220 + } 221 + 222 + node.AppendChild(button) 139 223 } 140 224 141 225 for n := node.FirstChild; n != nil; n = n.NextSibling {
+68 -6
appview/pages/pages.go
··· 15 15 "path/filepath" 16 16 "strings" 17 17 18 - "tangled.sh/tangled.sh/core/appview" 18 + "tangled.sh/tangled.sh/core/appview/config" 19 19 "tangled.sh/tangled.sh/core/appview/db" 20 20 "tangled.sh/tangled.sh/core/appview/oauth" 21 21 "tangled.sh/tangled.sh/core/appview/pages/markup" ··· 45 45 rctx *markup.RenderContext 46 46 } 47 47 48 - func NewPages(config *appview.Config) *Pages { 48 + func NewPages(config *config.Config) *Pages { 49 49 // initialized with safe defaults, can be overriden per use 50 50 rctx := &markup.RenderContext{ 51 51 IsDev: config.Core.Dev, ··· 432 432 ext := filepath.Ext(params.ReadmeFileName) 433 433 switch ext { 434 434 case ".md", ".markdown", ".mdown", ".mkdn", ".mkd": 435 - htmlString = p.rctx.RenderMarkdown(params.Readme) 435 + htmlString = p.rctx.RenderMarkdown(params.Readme, p.rctx.Sanitize) 436 436 params.Raw = false 437 - params.HTMLReadme = template.HTML(p.rctx.Sanitize(htmlString)) 437 + params.HTMLReadme = template.HTML(htmlString) 438 438 default: 439 439 htmlString = string(params.Readme) 440 440 params.Raw = true ··· 564 564 case markup.FormatMarkdown: 565 565 p.rctx.RepoInfo = params.RepoInfo 566 566 p.rctx.RendererType = markup.RendererTypeRepoMarkdown 567 - htmlString := p.rctx.RenderMarkdown(params.Contents) 568 - params.RenderedContents = template.HTML(p.rctx.Sanitize(htmlString)) 567 + htmlString := p.rctx.RenderMarkdown(params.Contents, p.rctx.Sanitize) 568 + params.RenderedContents = template.HTML(htmlString) 569 569 } 570 570 } 571 571 ··· 698 698 LoggedInUser *oauth.User 699 699 RepoInfo repoinfo.RepoInfo 700 700 Branches []types.Branch 701 + Strategy string 702 + SourceBranch string 703 + TargetBranch string 704 + Title string 705 + Body string 701 706 Active string 702 707 } 703 708 ··· 805 810 type PullCompareForkParams struct { 806 811 RepoInfo repoinfo.RepoInfo 807 812 Forks []db.Repo 813 + Selected string 808 814 } 809 815 810 816 func (p *Pages) PullCompareForkFragment(w io.Writer, params PullCompareForkParams) error { ··· 855 861 856 862 func (p *Pages) PullNewCommentFragment(w io.Writer, params PullNewCommentParams) error { 857 863 return p.executePlain("repo/pulls/fragments/pullNewComment", w, params) 864 + } 865 + 866 + type RepoCompareParams struct { 867 + LoggedInUser *oauth.User 868 + RepoInfo repoinfo.RepoInfo 869 + Forks []db.Repo 870 + Branches []types.Branch 871 + Tags []*types.TagReference 872 + Base string 873 + Head string 874 + Diff *types.NiceDiff 875 + 876 + Active string 877 + } 878 + 879 + func (p *Pages) RepoCompare(w io.Writer, params RepoCompareParams) error { 880 + params.Active = "overview" 881 + return p.executeRepo("repo/compare/compare", w, params) 882 + } 883 + 884 + type RepoCompareNewParams struct { 885 + LoggedInUser *oauth.User 886 + RepoInfo repoinfo.RepoInfo 887 + Forks []db.Repo 888 + Branches []types.Branch 889 + Tags []*types.TagReference 890 + Base string 891 + Head string 892 + 893 + Active string 894 + } 895 + 896 + func (p *Pages) RepoCompareNew(w io.Writer, params RepoCompareNewParams) error { 897 + params.Active = "overview" 898 + return p.executeRepo("repo/compare/new", w, params) 899 + } 900 + 901 + type RepoCompareAllowPullParams struct { 902 + LoggedInUser *oauth.User 903 + RepoInfo repoinfo.RepoInfo 904 + Base string 905 + Head string 906 + } 907 + 908 + func (p *Pages) RepoCompareAllowPullFragment(w io.Writer, params RepoCompareAllowPullParams) error { 909 + return p.executePlain("repo/fragments/compareAllowPull", w, params) 910 + } 911 + 912 + type RepoCompareDiffParams struct { 913 + LoggedInUser *oauth.User 914 + RepoInfo repoinfo.RepoInfo 915 + Diff types.NiceDiff 916 + } 917 + 918 + func (p *Pages) RepoCompareDiff(w io.Writer, params RepoCompareDiffParams) error { 919 + return p.executePlain("repo/fragments/diff", w, []any{params.RepoInfo.FullName, &params.Diff}) 858 920 } 859 921 860 922 func (p *Pages) Static() http.Handler {
+2 -2
appview/pages/templates/repo/blob.html
··· 24 24 <a 25 25 href="{{ index . 1 }}" 26 26 class="text-bold text-gray-500 dark:text-gray-400 {{ $linkstyle }}" 27 - >{{ index . 0 }}</a 27 + >{{ pathUnescape (index . 0) }}</a 28 28 > 29 29 / 30 30 {{ else }} 31 31 <span class="text-bold text-black dark:text-white" 32 - >{{ index . 0 }}</span 32 + >{{ pathUnescape (index . 0) }}</span 33 33 > 34 34 {{ end }} 35 35 {{ end }}
+15
appview/pages/templates/repo/compare/compare.html
··· 1 + {{ define "title" }} 2 + comparing {{ .Base }} and {{ .Head }} on {{ .RepoInfo.FullName }} 3 + {{ end }} 4 + 5 + {{ define "repoContent" }} 6 + {{ template "repo/fragments/compareForm" . }} 7 + {{ $isPushAllowed := and .LoggedInUser .RepoInfo.Roles.IsPushAllowed }} 8 + {{ if $isPushAllowed }} 9 + {{ template "repo/fragments/compareAllowPull" . }} 10 + {{ end }} 11 + {{ end }} 12 + 13 + {{ define "repoAfter" }} 14 + {{ template "repo/fragments/diff" (list .RepoInfo.FullName .Diff) }} 15 + {{ end }}
+31
appview/pages/templates/repo/compare/new.html
··· 1 + {{ define "title" }} 2 + compare refs on {{ .RepoInfo.FullName }} 3 + {{ end }} 4 + 5 + {{ define "repoContent" }} 6 + {{ template "repo/fragments/compareForm" . }} 7 + {{ end }} 8 + 9 + {{ define "repoAfter" }} 10 + <section class="p-6 mt-4 rounded-br rounded-bl bg-white dark:bg-gray-800 dark:text-white drop-shadow-sm w-full mx-auto"> 11 + <div class="flex flex-col items-center"> 12 + <p class="text-center text-black dark:text-white"> 13 + Recently updated branches in this repository: 14 + </p> 15 + {{ block "recentBranchList" $ }} {{ end }} 16 + </div> 17 + </section> 18 + {{ end }} 19 + 20 + {{ define "recentBranchList" }} 21 + <div class="mt-4 grid grid-cols-1 divide-y divide-gray-200 dark:divide-gray-700 rounded border border-gray-200 dark:border-gray-700 w-full md:w-1/2"> 22 + {{ range $br := take .Branches 5 }} 23 + <a href="/{{ $.RepoInfo.FullName }}/compare?head={{ $br.Name | urlquery }}" class="no-underline hover:no-underline"> 24 + <div class="flex items-center justify-between p-2"> 25 + {{ $br.Name }} 26 + <time class="text-gray-500 dark:text-gray-400">{{ timeFmt $br.Commit.Committer.When }}</time> 27 + </div> 28 + </a> 29 + {{ end }} 30 + </div> 31 + {{ end }}
+28
appview/pages/templates/repo/fragments/compareAllowPull.html
··· 1 + {{ define "repo/fragments/compareAllowPull" }} 2 + <div 3 + class="flex items-baseline justify-normal gap-4" 4 + id="allow-pull" 5 + hx-oob-swap="true" 6 + > 7 + <p> 8 + This comparison can be turned into a pull request to be reviewed and 9 + discussed. 10 + </p> 11 + 12 + {{ $newPullUrl := printf "/%s/pulls/new?strategy=branch&targetBranch=%s&sourceBranch=%s" .RepoInfo.FullName .Base .Head }} 13 + 14 + 15 + <div class="flex justify-start items-center gap-2 mt-2"> 16 + <a 17 + href="{{ $newPullUrl }}" 18 + class="btn flex items-center gap-2 no-underline hover:no-underline" 19 + > 20 + {{ i "git-pull-request-create" "w-4 h-4" }} 21 + create pull 22 + <span id="create-pull-spinner" class="group"> 23 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 24 + </span> 25 + </a> 26 + </div> 27 + </div> 28 + {{ end }}
+73
appview/pages/templates/repo/fragments/compareForm.html
··· 1 + {{ define "repo/fragments/compareForm" }} 2 + <div id="compare-select"> 3 + <h2 class="font-bold text-sm mb-2 uppercase dark:text-white"> 4 + Compare changes 5 + </h2> 6 + <p>Choose any two refs to compare.</p> 7 + 8 + <form id="compare-form" class="flex items-center gap-2 py-4"> 9 + <div> 10 + <span class="hidden md:inline">base:</span> 11 + {{ block "dropdown" (list $ "base" $.Base) }} {{ end }} 12 + </div> 13 + <span class="flex-shrink-0"> 14 + {{ i "arrow-left" "w-4 h-4" }} 15 + </span> 16 + <div> 17 + <span class="hidden md:inline">compare:</span> 18 + {{ block "dropdown" (list $ "head" $.Head) }} {{ end }} 19 + </div> 20 + <button 21 + id="compare-button" 22 + class="btn disabled:opacity-50 disabled:cursor-not-allowed" 23 + type="button" 24 + hx-boost="true" 25 + onclick=" 26 + const base = document.getElementById('base-select').value; 27 + const head = document.getElementById('head-select').value; 28 + window.location.href = `/{{$.RepoInfo.FullName}}/compare/${encodeURIComponent(base)}...${encodeURIComponent(head)}`; 29 + "> 30 + go 31 + </button> 32 + </form> 33 + </div> 34 + <script> 35 + const baseSelect = document.getElementById('base-select'); 36 + const headSelect = document.getElementById('head-select'); 37 + const compareButton = document.getElementById('compare-button'); 38 + 39 + function toggleButtonState() { 40 + compareButton.disabled = baseSelect.value === headSelect.value; 41 + } 42 + 43 + baseSelect.addEventListener('change', toggleButtonState); 44 + headSelect.addEventListener('change', toggleButtonState); 45 + 46 + // Run once on page load 47 + toggleButtonState(); 48 + </script> 49 + {{ end }} 50 + 51 + {{ define "dropdown" }} 52 + {{ $root := index . 0 }} 53 + {{ $name := index . 1 }} 54 + {{ $default := index . 2 }} 55 + <select name="{{$name}}" id="{{$name}}-select" class="p-1 border max-w-32 md:max-w-64 border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700"> 56 + <optgroup label="branches ({{ len $root.Branches }})" class="bold text-sm"> 57 + {{ range $root.Branches }} 58 + <option value="{{ .Reference.Name }}" class="py-1" {{if eq .Reference.Name $default}}selected{{end}}> 59 + {{ .Reference.Name }} 60 + </option> 61 + {{ end }} 62 + </optgroup> 63 + <optgroup label="tags ({{ len $root.Tags }})" class="bold text-sm"> 64 + {{ range $root.Tags }} 65 + <option value="{{ .Reference.Name }}" class="py-1" {{if eq .Reference.Name $default}}selected{{end}}> 66 + {{ .Reference.Name }} 67 + </option> 68 + {{ else }} 69 + <option class="py-1" disabled>no tags found</option> 70 + {{ end }} 71 + </optgroup> 72 + </select> 73 + {{ end }}
+14 -1
appview/pages/templates/repo/index.html
··· 66 66 {{ end }} 67 67 </optgroup> 68 68 </select> 69 + <div class="flex items-center gap-2"> 69 70 {{ $isOwner := and .LoggedInUser .RepoInfo.Roles.IsOwner }} 70 71 {{ $isCollaborator := and .LoggedInUser .RepoInfo.Roles.IsCollaborator }} 71 72 {{ if and (or $isOwner $isCollaborator) .ForkInfo .ForkInfo.IsFork }} ··· 99 100 <span>sync</span> 100 101 </button> 101 102 {{ end }} 102 - </div> 103 + <a 104 + href="/{{ .RepoInfo.FullName }}/compare?base={{ $.Ref | urlquery }}" 105 + class="btn flex items-center gap-2 no-underline hover:no-underline" 106 + title="Compare branches or tags" 107 + > 108 + {{ i "git-compare" "w-4 h-4" }} 109 + </a> 110 + </div> 111 + </div> 103 112 {{ end }} 104 113 105 114 {{ define "fileTree" }} ··· 124 133 </div> 125 134 </a> 126 135 136 + {{ if .LastCommit }} 127 137 <time class="text-xs text-gray-500 dark:text-gray-400" 128 138 >{{ timeFmt .LastCommit.When }}</time 129 139 > 140 + {{ end }} 130 141 </div> 131 142 </div> 132 143 {{ end }} ··· 145 156 </div> 146 157 </a> 147 158 159 + {{ if .LastCommit }} 148 160 <time class="text-xs text-gray-500 dark:text-gray-400" 149 161 >{{ timeFmt .LastCommit.When }}</time 150 162 > 163 + {{ end }} 151 164 </div> 152 165 </div> 153 166 {{ end }}
+9 -2
appview/pages/templates/repo/pulls/fragments/pullCompareBranches.html
··· 1 1 {{ define "repo/pulls/fragments/pullCompareBranches" }} 2 2 <div id="patch-upload"> 3 - <label for="targetBranch" class="dark:text-white">select a branch</label> 3 + <label for="targetBranch" class="dark:text-white">select a source branch</label> 4 4 <div class="flex flex-wrap gap-2 items-center"> 5 5 <select 6 6 name="sourceBranch" ··· 11 11 {{ $recent := index .Branches 0 }} 12 12 {{ range .Branches }} 13 13 {{ $isRecent := eq .Reference.Name $recent.Reference.Name }} 14 + {{ $preset := false }} 15 + {{ if $.SourceBranch }} 16 + {{ $preset = eq .Reference.Name $.SourceBranch }} 17 + {{ else }} 18 + {{ $preset = $isRecent }} 19 + {{ end }} 20 + 14 21 <option 15 22 value="{{ .Reference.Name }}" 16 - {{ if $isRecent }} 23 + {{ if $preset }} 17 24 selected 18 25 {{ end }} 19 26 class="py-1"
+2 -1
appview/pages/templates/repo/pulls/fragments/pullCompareForks.html
··· 3 3 <label for="forkSelect" class="dark:text-white" 4 4 >select a fork to compare</label 5 5 > 6 + selected: {{ .Selected }} 6 7 <div class="flex flex-wrap gap-4 items-center"> 7 8 <div class="flex flex-wrap gap-2 items-center"> 8 9 <select ··· 18 19 > 19 20 <option disabled selected>select a fork</option> 20 21 {{ range .Forks }} 21 - <option value="{{ .Name }}" class="py-1"> 22 + <option value="{{ .Name }}" {{ if eq .Name $.Selected }}selected{{ end }} class="py-1"> 22 23 {{ .Name }} 23 24 </option> 24 25 {{ end }}
+44 -4
appview/pages/templates/repo/pulls/new.html
··· 1 1 {{ define "title" }}new pull &middot; {{ .RepoInfo.FullName }}{{ end }} 2 2 3 3 {{ define "repoContent" }} 4 + <h2 class="font-bold text-sm mb-4 uppercase dark:text-white"> 5 + Create new pull request 6 + </h2> 7 + 4 8 <form 5 9 hx-post="/{{ .RepoInfo.FullName }}/pulls/new" 6 10 hx-indicator="#create-pull-spinner" ··· 16 20 class="p-1 border border-gray-200 bg-white dark:bg-gray-700 dark:text-white dark:border-gray-600" 17 21 > 18 22 <option disabled selected>target branch</option> 23 + 24 + 19 25 {{ range .Branches }} 20 - <option value="{{ .Reference.Name }}" class="py-1" {{if .IsDefault}}selected{{end}}> 26 + 27 + {{ $preset := false }} 28 + {{ if $.TargetBranch }} 29 + {{ $preset = eq .Reference.Name $.TargetBranch }} 30 + {{ else }} 31 + {{ $preset = .IsDefault }} 32 + {{ end }} 33 + 34 + <option value="{{ .Reference.Name }}" class="py-1" {{if $preset}}selected{{end}}> 21 35 {{ .Reference.Name }} 22 36 </option> 23 37 {{ end }} ··· 26 40 </div> 27 41 28 42 <div class="flex flex-col gap-2"> 29 - <p>Next, choose a pull strategy.</p> 43 + <h2 class="font-bold text-sm mb-4 uppercase dark:text-white"> 44 + Choose pull strategy 45 + </h2> 30 46 <nav class="flex space-x-4 items-center"> 31 47 <button 32 48 type="button" ··· 57 73 <span class="text-sm text-gray-500 dark:text-gray-400"> 58 74 or 59 75 </span> 76 + <script> 77 + function getQueryParams() { 78 + return Object.fromEntries(new URLSearchParams(window.location.search)); 79 + } 80 + </script> 81 + <!-- 82 + since compare-forks need the server to load forks, we 83 + hx-get this button; unlike simply loading the pullCompareForks template 84 + as we do for the rest of the gang below. the hx-vals thing just populates 85 + the query params so the forks page gets it. 86 + --> 60 87 <button 61 88 type="button" 62 89 class="btn" 63 90 hx-get="/{{ .RepoInfo.FullName }}/pulls/new/compare-forks" 64 91 hx-target="#patch-strategy" 65 92 hx-swap="innerHTML" 93 + {{ if eq .Strategy "fork" }} 94 + hx-trigger="click, load" 95 + hx-vals='js:{...getQueryParams()}' 96 + {{ end }} 66 97 > 67 98 compare forks 68 99 </button> 100 + 101 + 69 102 </nav> 70 103 <section id="patch-strategy" class="flex flex-col gap-2"> 71 - {{ template "repo/pulls/fragments/pullPatchUpload" . }} 104 + {{ if eq .Strategy "patch" }} 105 + {{ template "repo/pulls/fragments/pullPatchUpload" . }} 106 + {{ else if eq .Strategy "branch" }} 107 + {{ template "repo/pulls/fragments/pullCompareBranches" . }} 108 + {{ else }} 109 + {{ template "repo/pulls/fragments/pullPatchUpload" . }} 110 + {{ end }} 72 111 </section> 73 112 74 113 <div id="patch-error" class="error dark:text-red-300"></div> ··· 81 120 type="text" 82 121 name="title" 83 122 id="title" 123 + value="{{ .Title }}" 84 124 class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600" 85 125 placeholder="One-line summary of your change." 86 126 /> ··· 97 137 rows="6" 98 138 class="w-full resize-y dark:bg-gray-700 dark:text-white dark:border-gray-600" 99 139 placeholder="Describe your change. Markdown is supported." 100 - ></textarea> 140 + >{{ .Body }}</textarea> 101 141 </div> 102 142 103 143 <div class="flex justify-start items-center gap-2 mt-4">
+8 -4
appview/pages/templates/repo/tree.html
··· 1 - {{ define "title"}}{{ range .BreadCrumbs }}{{ index . 0}}/{{ end }} at {{ .Ref }} &middot; {{ .RepoInfo.FullName }}{{ end }} 1 + {{ define "title"}}{{ range .BreadCrumbs }}{{ pathUnescape (index . 0)}}/{{ end }} at {{ .Ref }} &middot; {{ .RepoInfo.FullName }}{{ end }} 2 2 3 3 4 4 {{ define "extrameta" }} ··· 26 26 <div class="flex flex-col md:flex-row md:justify-between gap-2"> 27 27 <div id="breadcrumbs" class="overflow-x-auto whitespace-nowrap text-gray-400 dark:text-gray-500"> 28 28 {{ range .BreadCrumbs }} 29 - <a href="{{ index . 1}}" class="text-bold text-gray-500 dark:text-gray-400 {{ $linkstyle }}">{{ index . 0 }}</a> / 29 + <a href="{{ index . 1}}" class="text-bold text-gray-500 dark:text-gray-400 {{ $linkstyle }}">{{ pathUnescape (index . 0) }}</a> / 30 30 {{ end }} 31 31 </div> 32 32 <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"> ··· 62 62 {{ i "folder" "size-4 fill-current" }}{{ .Name }} 63 63 </div> 64 64 </a> 65 - <time class="text-xs text-gray-500 dark:text-gray-400">{{ timeFmt .LastCommit.When }}</time> 65 + {{ if .LastCommit}} 66 + <time class="text-xs text-gray-500 dark:text-gray-400">{{ timeFmt .LastCommit.When }}</time> 67 + {{ end }} 66 68 </div> 67 69 </div> 68 70 {{ end }} ··· 77 79 {{ i "file" "size-4" }}{{ .Name }} 78 80 </div> 79 81 </a> 80 - <time class="text-xs text-gray-500 dark:text-gray-400">{{ timeFmt .LastCommit.When }}</time> 82 + {{ if .LastCommit}} 83 + <time class="text-xs text-gray-500 dark:text-gray-400">{{ timeFmt .LastCommit.When }}</time> 84 + {{ end }} 81 85 </div> 82 86 </div> 83 87 {{ end }}
+2123
appview/pulls/pulls.go
··· 1 + package pulls 2 + 3 + import ( 4 + "database/sql" 5 + "encoding/json" 6 + "errors" 7 + "fmt" 8 + "io" 9 + "log" 10 + "net/http" 11 + "sort" 12 + "strconv" 13 + "strings" 14 + "time" 15 + 16 + "tangled.sh/tangled.sh/core/api/tangled" 17 + "tangled.sh/tangled.sh/core/appview" 18 + "tangled.sh/tangled.sh/core/appview/config" 19 + "tangled.sh/tangled.sh/core/appview/db" 20 + "tangled.sh/tangled.sh/core/appview/idresolver" 21 + "tangled.sh/tangled.sh/core/appview/oauth" 22 + "tangled.sh/tangled.sh/core/appview/pages" 23 + "tangled.sh/tangled.sh/core/appview/reporesolver" 24 + "tangled.sh/tangled.sh/core/knotclient" 25 + "tangled.sh/tangled.sh/core/patchutil" 26 + "tangled.sh/tangled.sh/core/types" 27 + 28 + "github.com/bluekeyes/go-gitdiff/gitdiff" 29 + comatproto "github.com/bluesky-social/indigo/api/atproto" 30 + "github.com/bluesky-social/indigo/atproto/syntax" 31 + lexutil "github.com/bluesky-social/indigo/lex/util" 32 + "github.com/go-chi/chi/v5" 33 + "github.com/google/uuid" 34 + "github.com/posthog/posthog-go" 35 + ) 36 + 37 + type Pulls struct { 38 + oauth *oauth.OAuth 39 + repoResolver *reporesolver.RepoResolver 40 + pages *pages.Pages 41 + idResolver *idresolver.Resolver 42 + db *db.DB 43 + config *config.Config 44 + posthog posthog.Client 45 + } 46 + 47 + func New( 48 + oauth *oauth.OAuth, 49 + repoResolver *reporesolver.RepoResolver, 50 + pages *pages.Pages, 51 + resolver *idresolver.Resolver, 52 + db *db.DB, 53 + config *config.Config, 54 + ) *Pulls { 55 + return &Pulls{ 56 + oauth: oauth, 57 + repoResolver: repoResolver, 58 + pages: pages, 59 + idResolver: resolver, 60 + db: db, 61 + config: config, 62 + } 63 + } 64 + 65 + // htmx fragment 66 + func (s *Pulls) PullActions(w http.ResponseWriter, r *http.Request) { 67 + switch r.Method { 68 + case http.MethodGet: 69 + user := s.oauth.GetUser(r) 70 + f, err := s.repoResolver.Resolve(r) 71 + if err != nil { 72 + log.Println("failed to get repo and knot", err) 73 + return 74 + } 75 + 76 + pull, ok := r.Context().Value("pull").(*db.Pull) 77 + if !ok { 78 + log.Println("failed to get pull") 79 + s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 80 + return 81 + } 82 + 83 + // can be nil if this pull is not stacked 84 + stack, _ := r.Context().Value("stack").(db.Stack) 85 + 86 + roundNumberStr := chi.URLParam(r, "round") 87 + roundNumber, err := strconv.Atoi(roundNumberStr) 88 + if err != nil { 89 + roundNumber = pull.LastRoundNumber() 90 + } 91 + if roundNumber >= len(pull.Submissions) { 92 + http.Error(w, "bad round id", http.StatusBadRequest) 93 + log.Println("failed to parse round id", err) 94 + return 95 + } 96 + 97 + mergeCheckResponse := s.mergeCheck(f, pull, stack) 98 + resubmitResult := pages.Unknown 99 + if user.Did == pull.OwnerDid { 100 + resubmitResult = s.resubmitCheck(f, pull, stack) 101 + } 102 + 103 + s.pages.PullActionsFragment(w, pages.PullActionsParams{ 104 + LoggedInUser: user, 105 + RepoInfo: f.RepoInfo(user), 106 + Pull: pull, 107 + RoundNumber: roundNumber, 108 + MergeCheck: mergeCheckResponse, 109 + ResubmitCheck: resubmitResult, 110 + Stack: stack, 111 + }) 112 + return 113 + } 114 + } 115 + 116 + func (s *Pulls) RepoSinglePull(w http.ResponseWriter, r *http.Request) { 117 + user := s.oauth.GetUser(r) 118 + f, err := s.repoResolver.Resolve(r) 119 + if err != nil { 120 + log.Println("failed to get repo and knot", err) 121 + return 122 + } 123 + 124 + pull, ok := r.Context().Value("pull").(*db.Pull) 125 + if !ok { 126 + log.Println("failed to get pull") 127 + s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 128 + return 129 + } 130 + 131 + // can be nil if this pull is not stacked 132 + stack, _ := r.Context().Value("stack").(db.Stack) 133 + abandonedPulls, _ := r.Context().Value("abandonedPulls").([]*db.Pull) 134 + 135 + totalIdents := 1 136 + for _, submission := range pull.Submissions { 137 + totalIdents += len(submission.Comments) 138 + } 139 + 140 + identsToResolve := make([]string, totalIdents) 141 + 142 + // populate idents 143 + identsToResolve[0] = pull.OwnerDid 144 + idx := 1 145 + for _, submission := range pull.Submissions { 146 + for _, comment := range submission.Comments { 147 + identsToResolve[idx] = comment.OwnerDid 148 + idx += 1 149 + } 150 + } 151 + 152 + resolvedIds := s.idResolver.ResolveIdents(r.Context(), identsToResolve) 153 + didHandleMap := make(map[string]string) 154 + for _, identity := range resolvedIds { 155 + if !identity.Handle.IsInvalidHandle() { 156 + didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 157 + } else { 158 + didHandleMap[identity.DID.String()] = identity.DID.String() 159 + } 160 + } 161 + 162 + mergeCheckResponse := s.mergeCheck(f, pull, stack) 163 + resubmitResult := pages.Unknown 164 + if user != nil && user.Did == pull.OwnerDid { 165 + resubmitResult = s.resubmitCheck(f, pull, stack) 166 + } 167 + 168 + s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{ 169 + LoggedInUser: user, 170 + RepoInfo: f.RepoInfo(user), 171 + DidHandleMap: didHandleMap, 172 + Pull: pull, 173 + Stack: stack, 174 + AbandonedPulls: abandonedPulls, 175 + MergeCheck: mergeCheckResponse, 176 + ResubmitCheck: resubmitResult, 177 + }) 178 + } 179 + 180 + func (s *Pulls) mergeCheck(f *reporesolver.ResolvedRepo, pull *db.Pull, stack db.Stack) types.MergeCheckResponse { 181 + if pull.State == db.PullMerged { 182 + return types.MergeCheckResponse{} 183 + } 184 + 185 + secret, err := db.GetRegistrationKey(s.db, f.Knot) 186 + if err != nil { 187 + log.Printf("failed to get registration key: %v", err) 188 + return types.MergeCheckResponse{ 189 + Error: "failed to check merge status: this knot is unregistered", 190 + } 191 + } 192 + 193 + ksClient, err := knotclient.NewSignedClient(f.Knot, secret, s.config.Core.Dev) 194 + if err != nil { 195 + log.Printf("failed to setup signed client for %s; ignoring: %v", f.Knot, err) 196 + return types.MergeCheckResponse{ 197 + Error: "failed to check merge status", 198 + } 199 + } 200 + 201 + patch := pull.LatestPatch() 202 + if pull.IsStacked() { 203 + // combine patches of substack 204 + subStack := stack.Below(pull) 205 + // collect the portion of the stack that is mergeable 206 + mergeable := subStack.Mergeable() 207 + // combine each patch 208 + patch = mergeable.CombinedPatch() 209 + } 210 + 211 + resp, err := ksClient.MergeCheck([]byte(patch), f.OwnerDid(), f.RepoName, pull.TargetBranch) 212 + if err != nil { 213 + log.Println("failed to check for mergeability:", err) 214 + return types.MergeCheckResponse{ 215 + Error: "failed to check merge status", 216 + } 217 + } 218 + switch resp.StatusCode { 219 + case 404: 220 + return types.MergeCheckResponse{ 221 + Error: "failed to check merge status: this knot does not support PRs", 222 + } 223 + case 400: 224 + return types.MergeCheckResponse{ 225 + Error: "failed to check merge status: does this knot support PRs?", 226 + } 227 + } 228 + 229 + respBody, err := io.ReadAll(resp.Body) 230 + if err != nil { 231 + log.Println("failed to read merge check response body") 232 + return types.MergeCheckResponse{ 233 + Error: "failed to check merge status: knot is not speaking the right language", 234 + } 235 + } 236 + defer resp.Body.Close() 237 + 238 + var mergeCheckResponse types.MergeCheckResponse 239 + err = json.Unmarshal(respBody, &mergeCheckResponse) 240 + if err != nil { 241 + log.Println("failed to unmarshal merge check response", err) 242 + return types.MergeCheckResponse{ 243 + Error: "failed to check merge status: knot is not speaking the right language", 244 + } 245 + } 246 + 247 + return mergeCheckResponse 248 + } 249 + 250 + func (s *Pulls) resubmitCheck(f *reporesolver.ResolvedRepo, pull *db.Pull, stack db.Stack) pages.ResubmitResult { 251 + if pull.State == db.PullMerged || pull.State == db.PullDeleted || pull.PullSource == nil { 252 + return pages.Unknown 253 + } 254 + 255 + var knot, ownerDid, repoName string 256 + 257 + if pull.PullSource.RepoAt != nil { 258 + // fork-based pulls 259 + sourceRepo, err := db.GetRepoByAtUri(s.db, pull.PullSource.RepoAt.String()) 260 + if err != nil { 261 + log.Println("failed to get source repo", err) 262 + return pages.Unknown 263 + } 264 + 265 + knot = sourceRepo.Knot 266 + ownerDid = sourceRepo.Did 267 + repoName = sourceRepo.Name 268 + } else { 269 + // pulls within the same repo 270 + knot = f.Knot 271 + ownerDid = f.OwnerDid() 272 + repoName = f.RepoName 273 + } 274 + 275 + us, err := knotclient.NewUnsignedClient(knot, s.config.Core.Dev) 276 + if err != nil { 277 + log.Printf("failed to setup client for %s; ignoring: %v", knot, err) 278 + return pages.Unknown 279 + } 280 + 281 + result, err := us.Branch(ownerDid, repoName, pull.PullSource.Branch) 282 + if err != nil { 283 + log.Println("failed to reach knotserver", err) 284 + return pages.Unknown 285 + } 286 + 287 + latestSourceRev := pull.Submissions[pull.LastRoundNumber()].SourceRev 288 + 289 + if pull.IsStacked() && stack != nil { 290 + top := stack[0] 291 + latestSourceRev = top.Submissions[top.LastRoundNumber()].SourceRev 292 + } 293 + 294 + log.Println(latestSourceRev, result.Branch.Hash) 295 + 296 + if latestSourceRev != result.Branch.Hash { 297 + return pages.ShouldResubmit 298 + } 299 + 300 + return pages.ShouldNotResubmit 301 + } 302 + 303 + func (s *Pulls) RepoPullPatch(w http.ResponseWriter, r *http.Request) { 304 + user := s.oauth.GetUser(r) 305 + f, err := s.repoResolver.Resolve(r) 306 + if err != nil { 307 + log.Println("failed to get repo and knot", err) 308 + return 309 + } 310 + 311 + pull, ok := r.Context().Value("pull").(*db.Pull) 312 + if !ok { 313 + log.Println("failed to get pull") 314 + s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 315 + return 316 + } 317 + 318 + stack, _ := r.Context().Value("stack").(db.Stack) 319 + 320 + roundId := chi.URLParam(r, "round") 321 + roundIdInt, err := strconv.Atoi(roundId) 322 + if err != nil || roundIdInt >= len(pull.Submissions) { 323 + http.Error(w, "bad round id", http.StatusBadRequest) 324 + log.Println("failed to parse round id", err) 325 + return 326 + } 327 + 328 + identsToResolve := []string{pull.OwnerDid} 329 + resolvedIds := s.idResolver.ResolveIdents(r.Context(), identsToResolve) 330 + didHandleMap := make(map[string]string) 331 + for _, identity := range resolvedIds { 332 + if !identity.Handle.IsInvalidHandle() { 333 + didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 334 + } else { 335 + didHandleMap[identity.DID.String()] = identity.DID.String() 336 + } 337 + } 338 + 339 + patch := pull.Submissions[roundIdInt].Patch 340 + diff := patchutil.AsNiceDiff(patch, pull.TargetBranch) 341 + 342 + s.pages.RepoPullPatchPage(w, pages.RepoPullPatchParams{ 343 + LoggedInUser: user, 344 + DidHandleMap: didHandleMap, 345 + RepoInfo: f.RepoInfo(user), 346 + Pull: pull, 347 + Stack: stack, 348 + Round: roundIdInt, 349 + Submission: pull.Submissions[roundIdInt], 350 + Diff: &diff, 351 + }) 352 + 353 + } 354 + 355 + func (s *Pulls) RepoPullInterdiff(w http.ResponseWriter, r *http.Request) { 356 + user := s.oauth.GetUser(r) 357 + 358 + f, err := s.repoResolver.Resolve(r) 359 + if err != nil { 360 + log.Println("failed to get repo and knot", err) 361 + return 362 + } 363 + 364 + pull, ok := r.Context().Value("pull").(*db.Pull) 365 + if !ok { 366 + log.Println("failed to get pull") 367 + s.pages.Notice(w, "pull-error", "Failed to get pull.") 368 + return 369 + } 370 + 371 + roundId := chi.URLParam(r, "round") 372 + roundIdInt, err := strconv.Atoi(roundId) 373 + if err != nil || roundIdInt >= len(pull.Submissions) { 374 + http.Error(w, "bad round id", http.StatusBadRequest) 375 + log.Println("failed to parse round id", err) 376 + return 377 + } 378 + 379 + if roundIdInt == 0 { 380 + http.Error(w, "bad round id", http.StatusBadRequest) 381 + log.Println("cannot interdiff initial submission") 382 + return 383 + } 384 + 385 + identsToResolve := []string{pull.OwnerDid} 386 + resolvedIds := s.idResolver.ResolveIdents(r.Context(), identsToResolve) 387 + didHandleMap := make(map[string]string) 388 + for _, identity := range resolvedIds { 389 + if !identity.Handle.IsInvalidHandle() { 390 + didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 391 + } else { 392 + didHandleMap[identity.DID.String()] = identity.DID.String() 393 + } 394 + } 395 + 396 + currentPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt].Patch) 397 + if err != nil { 398 + log.Println("failed to interdiff; current patch malformed") 399 + s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; current patch is invalid.") 400 + return 401 + } 402 + 403 + previousPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt-1].Patch) 404 + if err != nil { 405 + log.Println("failed to interdiff; previous patch malformed") 406 + s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; previous patch is invalid.") 407 + return 408 + } 409 + 410 + interdiff := patchutil.Interdiff(previousPatch, currentPatch) 411 + 412 + s.pages.RepoPullInterdiffPage(w, pages.RepoPullInterdiffParams{ 413 + LoggedInUser: s.oauth.GetUser(r), 414 + RepoInfo: f.RepoInfo(user), 415 + Pull: pull, 416 + Round: roundIdInt, 417 + DidHandleMap: didHandleMap, 418 + Interdiff: interdiff, 419 + }) 420 + return 421 + } 422 + 423 + func (s *Pulls) RepoPullPatchRaw(w http.ResponseWriter, r *http.Request) { 424 + pull, ok := r.Context().Value("pull").(*db.Pull) 425 + if !ok { 426 + log.Println("failed to get pull") 427 + s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 428 + return 429 + } 430 + 431 + roundId := chi.URLParam(r, "round") 432 + roundIdInt, err := strconv.Atoi(roundId) 433 + if err != nil || roundIdInt >= len(pull.Submissions) { 434 + http.Error(w, "bad round id", http.StatusBadRequest) 435 + log.Println("failed to parse round id", err) 436 + return 437 + } 438 + 439 + identsToResolve := []string{pull.OwnerDid} 440 + resolvedIds := s.idResolver.ResolveIdents(r.Context(), identsToResolve) 441 + didHandleMap := make(map[string]string) 442 + for _, identity := range resolvedIds { 443 + if !identity.Handle.IsInvalidHandle() { 444 + didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 445 + } else { 446 + didHandleMap[identity.DID.String()] = identity.DID.String() 447 + } 448 + } 449 + 450 + w.Header().Set("Content-Type", "text/plain") 451 + w.Write([]byte(pull.Submissions[roundIdInt].Patch)) 452 + } 453 + 454 + func (s *Pulls) RepoPulls(w http.ResponseWriter, r *http.Request) { 455 + user := s.oauth.GetUser(r) 456 + params := r.URL.Query() 457 + 458 + state := db.PullOpen 459 + switch params.Get("state") { 460 + case "closed": 461 + state = db.PullClosed 462 + case "merged": 463 + state = db.PullMerged 464 + } 465 + 466 + f, err := s.repoResolver.Resolve(r) 467 + if err != nil { 468 + log.Println("failed to get repo and knot", err) 469 + return 470 + } 471 + 472 + pulls, err := db.GetPulls( 473 + s.db, 474 + db.FilterEq("repo_at", f.RepoAt), 475 + db.FilterEq("state", state), 476 + ) 477 + if err != nil { 478 + log.Println("failed to get pulls", err) 479 + s.pages.Notice(w, "pulls", "Failed to load pulls. Try again later.") 480 + return 481 + } 482 + 483 + for _, p := range pulls { 484 + var pullSourceRepo *db.Repo 485 + if p.PullSource != nil { 486 + if p.PullSource.RepoAt != nil { 487 + pullSourceRepo, err = db.GetRepoByAtUri(s.db, p.PullSource.RepoAt.String()) 488 + if err != nil { 489 + log.Printf("failed to get repo by at uri: %v", err) 490 + continue 491 + } else { 492 + p.PullSource.Repo = pullSourceRepo 493 + } 494 + } 495 + } 496 + } 497 + 498 + identsToResolve := make([]string, len(pulls)) 499 + for i, pull := range pulls { 500 + identsToResolve[i] = pull.OwnerDid 501 + } 502 + resolvedIds := s.idResolver.ResolveIdents(r.Context(), identsToResolve) 503 + didHandleMap := make(map[string]string) 504 + for _, identity := range resolvedIds { 505 + if !identity.Handle.IsInvalidHandle() { 506 + didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 507 + } else { 508 + didHandleMap[identity.DID.String()] = identity.DID.String() 509 + } 510 + } 511 + 512 + s.pages.RepoPulls(w, pages.RepoPullsParams{ 513 + LoggedInUser: s.oauth.GetUser(r), 514 + RepoInfo: f.RepoInfo(user), 515 + Pulls: pulls, 516 + DidHandleMap: didHandleMap, 517 + FilteringBy: state, 518 + }) 519 + return 520 + } 521 + 522 + func (s *Pulls) PullComment(w http.ResponseWriter, r *http.Request) { 523 + user := s.oauth.GetUser(r) 524 + f, err := s.repoResolver.Resolve(r) 525 + if err != nil { 526 + log.Println("failed to get repo and knot", err) 527 + return 528 + } 529 + 530 + pull, ok := r.Context().Value("pull").(*db.Pull) 531 + if !ok { 532 + log.Println("failed to get pull") 533 + s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 534 + return 535 + } 536 + 537 + roundNumberStr := chi.URLParam(r, "round") 538 + roundNumber, err := strconv.Atoi(roundNumberStr) 539 + if err != nil || roundNumber >= len(pull.Submissions) { 540 + http.Error(w, "bad round id", http.StatusBadRequest) 541 + log.Println("failed to parse round id", err) 542 + return 543 + } 544 + 545 + switch r.Method { 546 + case http.MethodGet: 547 + s.pages.PullNewCommentFragment(w, pages.PullNewCommentParams{ 548 + LoggedInUser: user, 549 + RepoInfo: f.RepoInfo(user), 550 + Pull: pull, 551 + RoundNumber: roundNumber, 552 + }) 553 + return 554 + case http.MethodPost: 555 + body := r.FormValue("body") 556 + if body == "" { 557 + s.pages.Notice(w, "pull", "Comment body is required") 558 + return 559 + } 560 + 561 + // Start a transaction 562 + tx, err := s.db.BeginTx(r.Context(), nil) 563 + if err != nil { 564 + log.Println("failed to start transaction", err) 565 + s.pages.Notice(w, "pull-comment", "Failed to create comment.") 566 + return 567 + } 568 + defer tx.Rollback() 569 + 570 + createdAt := time.Now().Format(time.RFC3339) 571 + ownerDid := user.Did 572 + 573 + pullAt, err := db.GetPullAt(s.db, f.RepoAt, pull.PullId) 574 + if err != nil { 575 + log.Println("failed to get pull at", err) 576 + s.pages.Notice(w, "pull-comment", "Failed to create comment.") 577 + return 578 + } 579 + 580 + atUri := f.RepoAt.String() 581 + client, err := s.oauth.AuthorizedClient(r) 582 + if err != nil { 583 + log.Println("failed to get authorized client", err) 584 + s.pages.Notice(w, "pull-comment", "Failed to create comment.") 585 + return 586 + } 587 + atResp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 588 + Collection: tangled.RepoPullCommentNSID, 589 + Repo: user.Did, 590 + Rkey: appview.TID(), 591 + Record: &lexutil.LexiconTypeDecoder{ 592 + Val: &tangled.RepoPullComment{ 593 + Repo: &atUri, 594 + Pull: string(pullAt), 595 + Owner: &ownerDid, 596 + Body: body, 597 + CreatedAt: createdAt, 598 + }, 599 + }, 600 + }) 601 + if err != nil { 602 + log.Println("failed to create pull comment", err) 603 + s.pages.Notice(w, "pull-comment", "Failed to create comment.") 604 + return 605 + } 606 + 607 + // Create the pull comment in the database with the commentAt field 608 + commentId, err := db.NewPullComment(tx, &db.PullComment{ 609 + OwnerDid: user.Did, 610 + RepoAt: f.RepoAt.String(), 611 + PullId: pull.PullId, 612 + Body: body, 613 + CommentAt: atResp.Uri, 614 + SubmissionId: pull.Submissions[roundNumber].ID, 615 + }) 616 + if err != nil { 617 + log.Println("failed to create pull comment", err) 618 + s.pages.Notice(w, "pull-comment", "Failed to create comment.") 619 + return 620 + } 621 + 622 + // Commit the transaction 623 + if err = tx.Commit(); err != nil { 624 + log.Println("failed to commit transaction", err) 625 + s.pages.Notice(w, "pull-comment", "Failed to create comment.") 626 + return 627 + } 628 + 629 + if !s.config.Core.Dev { 630 + err = s.posthog.Enqueue(posthog.Capture{ 631 + DistinctId: user.Did, 632 + Event: "new_pull_comment", 633 + Properties: posthog.Properties{"repo_at": f.RepoAt.String(), "pull_id": pull.PullId}, 634 + }) 635 + if err != nil { 636 + log.Println("failed to enqueue posthog event:", err) 637 + } 638 + } 639 + 640 + s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", f.OwnerSlashRepo(), pull.PullId, commentId)) 641 + return 642 + } 643 + } 644 + 645 + func (s *Pulls) NewPull(w http.ResponseWriter, r *http.Request) { 646 + user := s.oauth.GetUser(r) 647 + f, err := s.repoResolver.Resolve(r) 648 + if err != nil { 649 + log.Println("failed to get repo and knot", err) 650 + return 651 + } 652 + 653 + switch r.Method { 654 + case http.MethodGet: 655 + us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 656 + if err != nil { 657 + log.Printf("failed to create unsigned client for %s", f.Knot) 658 + s.pages.Error503(w) 659 + return 660 + } 661 + 662 + result, err := us.Branches(f.OwnerDid(), f.RepoName) 663 + if err != nil { 664 + log.Println("failed to fetch branches", err) 665 + return 666 + } 667 + 668 + // can be one of "patch", "branch" or "fork" 669 + strategy := r.URL.Query().Get("strategy") 670 + // ignored if strategy is "patch" 671 + sourceBranch := r.URL.Query().Get("sourceBranch") 672 + targetBranch := r.URL.Query().Get("targetBranch") 673 + 674 + s.pages.RepoNewPull(w, pages.RepoNewPullParams{ 675 + LoggedInUser: user, 676 + RepoInfo: f.RepoInfo(user), 677 + Branches: result.Branches, 678 + Strategy: strategy, 679 + SourceBranch: sourceBranch, 680 + TargetBranch: targetBranch, 681 + Title: r.URL.Query().Get("title"), 682 + Body: r.URL.Query().Get("body"), 683 + }) 684 + 685 + case http.MethodPost: 686 + title := r.FormValue("title") 687 + body := r.FormValue("body") 688 + targetBranch := r.FormValue("targetBranch") 689 + fromFork := r.FormValue("fork") 690 + sourceBranch := r.FormValue("sourceBranch") 691 + patch := r.FormValue("patch") 692 + 693 + if targetBranch == "" { 694 + s.pages.Notice(w, "pull", "Target branch is required.") 695 + return 696 + } 697 + 698 + // Determine PR type based on input parameters 699 + isPushAllowed := f.RepoInfo(user).Roles.IsPushAllowed() 700 + isBranchBased := isPushAllowed && sourceBranch != "" && fromFork == "" 701 + isForkBased := fromFork != "" && sourceBranch != "" 702 + isPatchBased := patch != "" && !isBranchBased && !isForkBased 703 + isStacked := r.FormValue("isStacked") == "on" 704 + 705 + if isPatchBased && !patchutil.IsFormatPatch(patch) { 706 + if title == "" { 707 + s.pages.Notice(w, "pull", "Title is required for git-diff patches.") 708 + return 709 + } 710 + } 711 + 712 + // Validate we have at least one valid PR creation method 713 + if !isBranchBased && !isPatchBased && !isForkBased { 714 + s.pages.Notice(w, "pull", "Neither source branch nor patch supplied.") 715 + return 716 + } 717 + 718 + // Can't mix branch-based and patch-based approaches 719 + if isBranchBased && patch != "" { 720 + s.pages.Notice(w, "pull", "Cannot select both patch and source branch.") 721 + return 722 + } 723 + 724 + us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 725 + if err != nil { 726 + log.Printf("failed to create unsigned client to %s: %v", f.Knot, err) 727 + s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.") 728 + return 729 + } 730 + 731 + caps, err := us.Capabilities() 732 + if err != nil { 733 + log.Println("error fetching knot caps", f.Knot, err) 734 + s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.") 735 + return 736 + } 737 + 738 + if !caps.PullRequests.FormatPatch { 739 + s.pages.Notice(w, "pull", "This knot doesn't support format-patch. Unfortunately, there is no fallback for now.") 740 + return 741 + } 742 + 743 + // Handle the PR creation based on the type 744 + if isBranchBased { 745 + if !caps.PullRequests.BranchSubmissions { 746 + s.pages.Notice(w, "pull", "This knot doesn't support branch-based pull requests. Try another way?") 747 + return 748 + } 749 + s.handleBranchBasedPull(w, r, f, user, title, body, targetBranch, sourceBranch, isStacked) 750 + } else if isForkBased { 751 + if !caps.PullRequests.ForkSubmissions { 752 + s.pages.Notice(w, "pull", "This knot doesn't support fork-based pull requests. Try another way?") 753 + return 754 + } 755 + s.handleForkBasedPull(w, r, f, user, fromFork, title, body, targetBranch, sourceBranch, isStacked) 756 + } else if isPatchBased { 757 + if !caps.PullRequests.PatchSubmissions { 758 + s.pages.Notice(w, "pull", "This knot doesn't support patch-based pull requests. Send your patch over email.") 759 + return 760 + } 761 + s.handlePatchBasedPull(w, r, f, user, title, body, targetBranch, patch, isStacked) 762 + } 763 + return 764 + } 765 + } 766 + 767 + func (s *Pulls) handleBranchBasedPull( 768 + w http.ResponseWriter, 769 + r *http.Request, 770 + f *reporesolver.ResolvedRepo, 771 + user *oauth.User, 772 + title, 773 + body, 774 + targetBranch, 775 + sourceBranch string, 776 + isStacked bool, 777 + ) { 778 + pullSource := &db.PullSource{ 779 + Branch: sourceBranch, 780 + } 781 + recordPullSource := &tangled.RepoPull_Source{ 782 + Branch: sourceBranch, 783 + } 784 + 785 + // Generate a patch using /compare 786 + ksClient, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 787 + if err != nil { 788 + log.Printf("failed to create signed client for %s: %s", f.Knot, err) 789 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 790 + return 791 + } 792 + 793 + comparison, err := ksClient.Compare(f.OwnerDid(), f.RepoName, targetBranch, sourceBranch) 794 + if err != nil { 795 + log.Println("failed to compare", 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 + s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, sourceRev, pullSource, recordPullSource, isStacked) 809 + } 810 + 811 + func (s *Pulls) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, f *reporesolver.ResolvedRepo, user *oauth.User, title, body, targetBranch, patch string, isStacked bool) { 812 + if !patchutil.IsPatchValid(patch) { 813 + s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 814 + return 815 + } 816 + 817 + s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, "", nil, nil, isStacked) 818 + } 819 + 820 + func (s *Pulls) handleForkBasedPull(w http.ResponseWriter, r *http.Request, f *reporesolver.ResolvedRepo, user *oauth.User, forkRepo string, title, body, targetBranch, sourceBranch string, isStacked bool) { 821 + fork, err := db.GetForkByDid(s.db, user.Did, forkRepo) 822 + if errors.Is(err, sql.ErrNoRows) { 823 + s.pages.Notice(w, "pull", "No such fork.") 824 + return 825 + } else if err != nil { 826 + log.Println("failed to fetch fork:", err) 827 + s.pages.Notice(w, "pull", "Failed to fetch fork.") 828 + return 829 + } 830 + 831 + secret, err := db.GetRegistrationKey(s.db, fork.Knot) 832 + if err != nil { 833 + log.Println("failed to fetch registration key:", err) 834 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 835 + return 836 + } 837 + 838 + sc, err := knotclient.NewSignedClient(fork.Knot, secret, s.config.Core.Dev) 839 + if err != nil { 840 + log.Println("failed to create signed client:", err) 841 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 842 + return 843 + } 844 + 845 + us, err := knotclient.NewUnsignedClient(fork.Knot, s.config.Core.Dev) 846 + if err != nil { 847 + log.Println("failed to create unsigned client:", err) 848 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 849 + return 850 + } 851 + 852 + resp, err := sc.NewHiddenRef(user.Did, fork.Name, sourceBranch, targetBranch) 853 + if err != nil { 854 + log.Println("failed to create hidden ref:", err, resp.StatusCode) 855 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 856 + return 857 + } 858 + 859 + switch resp.StatusCode { 860 + case 404: 861 + case 400: 862 + s.pages.Notice(w, "pull", "Branch based pull requests are not supported on this knot.") 863 + return 864 + } 865 + 866 + hiddenRef := fmt.Sprintf("hidden/%s/%s", sourceBranch, targetBranch) 867 + // We're now comparing the sourceBranch (on the fork) against the hiddenRef which is tracking 868 + // the targetBranch on the target repository. This code is a bit confusing, but here's an example: 869 + // hiddenRef: hidden/feature-1/main (on repo-fork) 870 + // targetBranch: main (on repo-1) 871 + // sourceBranch: feature-1 (on repo-fork) 872 + comparison, err := us.Compare(user.Did, fork.Name, hiddenRef, sourceBranch) 873 + if err != nil { 874 + log.Println("failed to compare across branches", err) 875 + s.pages.Notice(w, "pull", err.Error()) 876 + return 877 + } 878 + 879 + sourceRev := comparison.Rev2 880 + patch := comparison.Patch 881 + 882 + if !patchutil.IsPatchValid(patch) { 883 + s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 884 + return 885 + } 886 + 887 + forkAtUri, err := syntax.ParseATURI(fork.AtUri) 888 + if err != nil { 889 + log.Println("failed to parse fork AT URI", err) 890 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 891 + return 892 + } 893 + 894 + s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, sourceRev, &db.PullSource{ 895 + Branch: sourceBranch, 896 + RepoAt: &forkAtUri, 897 + }, &tangled.RepoPull_Source{Branch: sourceBranch, Repo: &fork.AtUri}, isStacked) 898 + } 899 + 900 + func (s *Pulls) createPullRequest( 901 + w http.ResponseWriter, 902 + r *http.Request, 903 + f *reporesolver.ResolvedRepo, 904 + user *oauth.User, 905 + title, body, targetBranch string, 906 + patch string, 907 + sourceRev string, 908 + pullSource *db.PullSource, 909 + recordPullSource *tangled.RepoPull_Source, 910 + isStacked bool, 911 + ) { 912 + if isStacked { 913 + // creates a series of PRs, each linking to the previous, identified by jj's change-id 914 + s.createStackedPulLRequest( 915 + w, 916 + r, 917 + f, 918 + user, 919 + targetBranch, 920 + patch, 921 + sourceRev, 922 + pullSource, 923 + ) 924 + return 925 + } 926 + 927 + client, err := s.oauth.AuthorizedClient(r) 928 + if err != nil { 929 + log.Println("failed to get authorized client", err) 930 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 931 + return 932 + } 933 + 934 + tx, err := s.db.BeginTx(r.Context(), nil) 935 + if err != nil { 936 + log.Println("failed to start tx") 937 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 938 + return 939 + } 940 + defer tx.Rollback() 941 + 942 + // We've already checked earlier if it's diff-based and title is empty, 943 + // so if it's still empty now, it's intentionally skipped owing to format-patch. 944 + if title == "" { 945 + formatPatches, err := patchutil.ExtractPatches(patch) 946 + if err != nil { 947 + s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err)) 948 + return 949 + } 950 + if len(formatPatches) == 0 { 951 + s.pages.Notice(w, "pull", "No patches found in the supplied format-patch.") 952 + return 953 + } 954 + 955 + title = formatPatches[0].Title 956 + body = formatPatches[0].Body 957 + } 958 + 959 + rkey := appview.TID() 960 + initialSubmission := db.PullSubmission{ 961 + Patch: patch, 962 + SourceRev: sourceRev, 963 + } 964 + err = db.NewPull(tx, &db.Pull{ 965 + Title: title, 966 + Body: body, 967 + TargetBranch: targetBranch, 968 + OwnerDid: user.Did, 969 + RepoAt: f.RepoAt, 970 + Rkey: rkey, 971 + Submissions: []*db.PullSubmission{ 972 + &initialSubmission, 973 + }, 974 + PullSource: pullSource, 975 + }) 976 + if err != nil { 977 + log.Println("failed to create pull request", err) 978 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 979 + return 980 + } 981 + pullId, err := db.NextPullId(tx, f.RepoAt) 982 + if err != nil { 983 + log.Println("failed to get pull id", err) 984 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 985 + return 986 + } 987 + 988 + _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 989 + Collection: tangled.RepoPullNSID, 990 + Repo: user.Did, 991 + Rkey: rkey, 992 + Record: &lexutil.LexiconTypeDecoder{ 993 + Val: &tangled.RepoPull{ 994 + Title: title, 995 + PullId: int64(pullId), 996 + TargetRepo: string(f.RepoAt), 997 + TargetBranch: targetBranch, 998 + Patch: patch, 999 + Source: recordPullSource, 1000 + }, 1001 + }, 1002 + }) 1003 + if err != nil { 1004 + log.Println("failed to create pull request", err) 1005 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1006 + return 1007 + } 1008 + 1009 + if err = tx.Commit(); err != nil { 1010 + log.Println("failed to create pull request", err) 1011 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1012 + return 1013 + } 1014 + 1015 + if !s.config.Core.Dev { 1016 + err = s.posthog.Enqueue(posthog.Capture{ 1017 + DistinctId: user.Did, 1018 + Event: "new_pull", 1019 + Properties: posthog.Properties{"repo_at": f.RepoAt.String(), "pull_id": pullId}, 1020 + }) 1021 + if err != nil { 1022 + log.Println("failed to enqueue posthog event:", err) 1023 + } 1024 + } 1025 + 1026 + s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pullId)) 1027 + } 1028 + 1029 + func (s *Pulls) createStackedPulLRequest( 1030 + w http.ResponseWriter, 1031 + r *http.Request, 1032 + f *reporesolver.ResolvedRepo, 1033 + user *oauth.User, 1034 + targetBranch string, 1035 + patch string, 1036 + sourceRev string, 1037 + pullSource *db.PullSource, 1038 + ) { 1039 + // run some necessary checks for stacked-prs first 1040 + 1041 + // must be branch or fork based 1042 + if sourceRev == "" { 1043 + log.Println("stacked PR from patch-based pull") 1044 + s.pages.Notice(w, "pull", "Stacking is only supported on branch and fork based pull-requests.") 1045 + return 1046 + } 1047 + 1048 + formatPatches, err := patchutil.ExtractPatches(patch) 1049 + if err != nil { 1050 + log.Println("failed to extract patches", err) 1051 + s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err)) 1052 + return 1053 + } 1054 + 1055 + // must have atleast 1 patch to begin with 1056 + if len(formatPatches) == 0 { 1057 + log.Println("empty patches") 1058 + s.pages.Notice(w, "pull", "No patches found in the generated format-patch.") 1059 + return 1060 + } 1061 + 1062 + // build a stack out of this patch 1063 + stackId := uuid.New() 1064 + stack, err := newStack(f, user, targetBranch, patch, pullSource, stackId.String()) 1065 + if err != nil { 1066 + log.Println("failed to create stack", err) 1067 + s.pages.Notice(w, "pull", fmt.Sprintf("Failed to create stack: %v", err)) 1068 + return 1069 + } 1070 + 1071 + client, err := s.oauth.AuthorizedClient(r) 1072 + if err != nil { 1073 + log.Println("failed to get authorized client", err) 1074 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1075 + return 1076 + } 1077 + 1078 + // apply all record creations at once 1079 + var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem 1080 + for _, p := range stack { 1081 + record := p.AsRecord() 1082 + write := comatproto.RepoApplyWrites_Input_Writes_Elem{ 1083 + RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{ 1084 + Collection: tangled.RepoPullNSID, 1085 + Rkey: &p.Rkey, 1086 + Value: &lexutil.LexiconTypeDecoder{ 1087 + Val: &record, 1088 + }, 1089 + }, 1090 + } 1091 + writes = append(writes, &write) 1092 + } 1093 + _, err = client.RepoApplyWrites(r.Context(), &comatproto.RepoApplyWrites_Input{ 1094 + Repo: user.Did, 1095 + Writes: writes, 1096 + }) 1097 + if err != nil { 1098 + log.Println("failed to create stacked pull request", err) 1099 + s.pages.Notice(w, "pull", "Failed to create stacked pull request. Try again later.") 1100 + return 1101 + } 1102 + 1103 + // create all pulls at once 1104 + tx, err := s.db.BeginTx(r.Context(), nil) 1105 + if err != nil { 1106 + log.Println("failed to start tx") 1107 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1108 + return 1109 + } 1110 + defer tx.Rollback() 1111 + 1112 + for _, p := range stack { 1113 + err = db.NewPull(tx, p) 1114 + if err != nil { 1115 + log.Println("failed to create pull request", err) 1116 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1117 + return 1118 + } 1119 + } 1120 + 1121 + if err = tx.Commit(); err != nil { 1122 + log.Println("failed to create pull request", err) 1123 + s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1124 + return 1125 + } 1126 + 1127 + s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls", f.OwnerSlashRepo())) 1128 + } 1129 + 1130 + func (s *Pulls) ValidatePatch(w http.ResponseWriter, r *http.Request) { 1131 + _, err := s.repoResolver.Resolve(r) 1132 + if err != nil { 1133 + log.Println("failed to get repo and knot", err) 1134 + return 1135 + } 1136 + 1137 + patch := r.FormValue("patch") 1138 + if patch == "" { 1139 + s.pages.Notice(w, "patch-error", "Patch is required.") 1140 + return 1141 + } 1142 + 1143 + if patch == "" || !patchutil.IsPatchValid(patch) { 1144 + s.pages.Notice(w, "patch-error", "Invalid patch format. Please provide a valid git diff or format-patch.") 1145 + return 1146 + } 1147 + 1148 + if patchutil.IsFormatPatch(patch) { 1149 + 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.") 1150 + } else { 1151 + s.pages.Notice(w, "patch-preview", "Regular git-diff detected. Please provide a title and description.") 1152 + } 1153 + } 1154 + 1155 + func (s *Pulls) PatchUploadFragment(w http.ResponseWriter, r *http.Request) { 1156 + user := s.oauth.GetUser(r) 1157 + f, err := s.repoResolver.Resolve(r) 1158 + if err != nil { 1159 + log.Println("failed to get repo and knot", err) 1160 + return 1161 + } 1162 + 1163 + s.pages.PullPatchUploadFragment(w, pages.PullPatchUploadParams{ 1164 + RepoInfo: f.RepoInfo(user), 1165 + }) 1166 + } 1167 + 1168 + func (s *Pulls) CompareBranchesFragment(w http.ResponseWriter, r *http.Request) { 1169 + user := s.oauth.GetUser(r) 1170 + f, err := s.repoResolver.Resolve(r) 1171 + if err != nil { 1172 + log.Println("failed to get repo and knot", err) 1173 + return 1174 + } 1175 + 1176 + us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 1177 + if err != nil { 1178 + log.Printf("failed to create unsigned client for %s", f.Knot) 1179 + s.pages.Error503(w) 1180 + return 1181 + } 1182 + 1183 + result, err := us.Branches(f.OwnerDid(), f.RepoName) 1184 + if err != nil { 1185 + log.Println("failed to reach knotserver", err) 1186 + return 1187 + } 1188 + 1189 + branches := result.Branches 1190 + sort.Slice(branches, func(i int, j int) bool { 1191 + return branches[i].Commit.Committer.When.After(branches[j].Commit.Committer.When) 1192 + }) 1193 + 1194 + withoutDefault := []types.Branch{} 1195 + for _, b := range branches { 1196 + if b.IsDefault { 1197 + continue 1198 + } 1199 + withoutDefault = append(withoutDefault, b) 1200 + } 1201 + 1202 + s.pages.PullCompareBranchesFragment(w, pages.PullCompareBranchesParams{ 1203 + RepoInfo: f.RepoInfo(user), 1204 + Branches: withoutDefault, 1205 + }) 1206 + } 1207 + 1208 + func (s *Pulls) CompareForksFragment(w http.ResponseWriter, r *http.Request) { 1209 + user := s.oauth.GetUser(r) 1210 + f, err := s.repoResolver.Resolve(r) 1211 + if err != nil { 1212 + log.Println("failed to get repo and knot", err) 1213 + return 1214 + } 1215 + 1216 + forks, err := db.GetForksByDid(s.db, user.Did) 1217 + if err != nil { 1218 + log.Println("failed to get forks", err) 1219 + return 1220 + } 1221 + 1222 + s.pages.PullCompareForkFragment(w, pages.PullCompareForkParams{ 1223 + RepoInfo: f.RepoInfo(user), 1224 + Forks: forks, 1225 + Selected: r.URL.Query().Get("fork"), 1226 + }) 1227 + } 1228 + 1229 + func (s *Pulls) CompareForksBranchesFragment(w http.ResponseWriter, r *http.Request) { 1230 + user := s.oauth.GetUser(r) 1231 + 1232 + f, err := s.repoResolver.Resolve(r) 1233 + if err != nil { 1234 + log.Println("failed to get repo and knot", err) 1235 + return 1236 + } 1237 + 1238 + forkVal := r.URL.Query().Get("fork") 1239 + 1240 + // fork repo 1241 + repo, err := db.GetRepo(s.db, user.Did, forkVal) 1242 + if err != nil { 1243 + log.Println("failed to get repo", user.Did, forkVal) 1244 + return 1245 + } 1246 + 1247 + sourceBranchesClient, err := knotclient.NewUnsignedClient(repo.Knot, s.config.Core.Dev) 1248 + if err != nil { 1249 + log.Printf("failed to create unsigned client for %s", repo.Knot) 1250 + s.pages.Error503(w) 1251 + return 1252 + } 1253 + 1254 + sourceResult, err := sourceBranchesClient.Branches(user.Did, repo.Name) 1255 + if err != nil { 1256 + log.Println("failed to reach knotserver for source branches", err) 1257 + return 1258 + } 1259 + 1260 + targetBranchesClient, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 1261 + if err != nil { 1262 + log.Printf("failed to create unsigned client for target knot %s", f.Knot) 1263 + s.pages.Error503(w) 1264 + return 1265 + } 1266 + 1267 + targetResult, err := targetBranchesClient.Branches(f.OwnerDid(), f.RepoName) 1268 + if err != nil { 1269 + log.Println("failed to reach knotserver for target branches", err) 1270 + return 1271 + } 1272 + 1273 + sourceBranches := sourceResult.Branches 1274 + sort.Slice(sourceBranches, func(i int, j int) bool { 1275 + return sourceBranches[i].Commit.Committer.When.After(sourceBranches[j].Commit.Committer.When) 1276 + }) 1277 + 1278 + s.pages.PullCompareForkBranchesFragment(w, pages.PullCompareForkBranchesParams{ 1279 + RepoInfo: f.RepoInfo(user), 1280 + SourceBranches: sourceBranches, 1281 + TargetBranches: targetResult.Branches, 1282 + }) 1283 + } 1284 + 1285 + func (s *Pulls) ResubmitPull(w http.ResponseWriter, r *http.Request) { 1286 + user := s.oauth.GetUser(r) 1287 + f, err := s.repoResolver.Resolve(r) 1288 + if err != nil { 1289 + log.Println("failed to get repo and knot", err) 1290 + return 1291 + } 1292 + 1293 + pull, ok := r.Context().Value("pull").(*db.Pull) 1294 + if !ok { 1295 + log.Println("failed to get pull") 1296 + s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 1297 + return 1298 + } 1299 + 1300 + switch r.Method { 1301 + case http.MethodGet: 1302 + s.pages.PullResubmitFragment(w, pages.PullResubmitParams{ 1303 + RepoInfo: f.RepoInfo(user), 1304 + Pull: pull, 1305 + }) 1306 + return 1307 + case http.MethodPost: 1308 + if pull.IsPatchBased() { 1309 + s.resubmitPatch(w, r) 1310 + return 1311 + } else if pull.IsBranchBased() { 1312 + s.resubmitBranch(w, r) 1313 + return 1314 + } else if pull.IsForkBased() { 1315 + s.resubmitFork(w, r) 1316 + return 1317 + } 1318 + } 1319 + } 1320 + 1321 + func (s *Pulls) resubmitPatch(w http.ResponseWriter, r *http.Request) { 1322 + user := s.oauth.GetUser(r) 1323 + 1324 + pull, ok := r.Context().Value("pull").(*db.Pull) 1325 + if !ok { 1326 + log.Println("failed to get pull") 1327 + s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 1328 + return 1329 + } 1330 + 1331 + f, err := s.repoResolver.Resolve(r) 1332 + if err != nil { 1333 + log.Println("failed to get repo and knot", err) 1334 + return 1335 + } 1336 + 1337 + if user.Did != pull.OwnerDid { 1338 + log.Println("unauthorized user") 1339 + w.WriteHeader(http.StatusUnauthorized) 1340 + return 1341 + } 1342 + 1343 + patch := r.FormValue("patch") 1344 + 1345 + s.resubmitPullHelper(w, r, f, user, pull, patch, "") 1346 + } 1347 + 1348 + func (s *Pulls) resubmitBranch(w http.ResponseWriter, r *http.Request) { 1349 + user := s.oauth.GetUser(r) 1350 + 1351 + pull, ok := r.Context().Value("pull").(*db.Pull) 1352 + if !ok { 1353 + log.Println("failed to get pull") 1354 + s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.") 1355 + return 1356 + } 1357 + 1358 + f, err := s.repoResolver.Resolve(r) 1359 + if err != nil { 1360 + log.Println("failed to get repo and knot", err) 1361 + return 1362 + } 1363 + 1364 + if user.Did != pull.OwnerDid { 1365 + log.Println("unauthorized user") 1366 + w.WriteHeader(http.StatusUnauthorized) 1367 + return 1368 + } 1369 + 1370 + if !f.RepoInfo(user).Roles.IsPushAllowed() { 1371 + log.Println("unauthorized user") 1372 + w.WriteHeader(http.StatusUnauthorized) 1373 + return 1374 + } 1375 + 1376 + ksClient, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 1377 + if err != nil { 1378 + log.Printf("failed to create client for %s: %s", f.Knot, err) 1379 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1380 + return 1381 + } 1382 + 1383 + comparison, err := ksClient.Compare(f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.PullSource.Branch) 1384 + if err != nil { 1385 + log.Printf("compare request failed: %s", err) 1386 + s.pages.Notice(w, "resubmit-error", err.Error()) 1387 + return 1388 + } 1389 + 1390 + sourceRev := comparison.Rev2 1391 + patch := comparison.Patch 1392 + 1393 + s.resubmitPullHelper(w, r, f, user, pull, patch, sourceRev) 1394 + } 1395 + 1396 + func (s *Pulls) resubmitFork(w http.ResponseWriter, r *http.Request) { 1397 + user := s.oauth.GetUser(r) 1398 + 1399 + pull, ok := r.Context().Value("pull").(*db.Pull) 1400 + if !ok { 1401 + log.Println("failed to get pull") 1402 + s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.") 1403 + return 1404 + } 1405 + 1406 + f, err := s.repoResolver.Resolve(r) 1407 + if err != nil { 1408 + log.Println("failed to get repo and knot", err) 1409 + return 1410 + } 1411 + 1412 + if user.Did != pull.OwnerDid { 1413 + log.Println("unauthorized user") 1414 + w.WriteHeader(http.StatusUnauthorized) 1415 + return 1416 + } 1417 + 1418 + forkRepo, err := db.GetRepoByAtUri(s.db, pull.PullSource.RepoAt.String()) 1419 + if err != nil { 1420 + log.Println("failed to get source repo", err) 1421 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1422 + return 1423 + } 1424 + 1425 + // extract patch by performing compare 1426 + ksClient, err := knotclient.NewUnsignedClient(forkRepo.Knot, s.config.Core.Dev) 1427 + if err != nil { 1428 + log.Printf("failed to create client for %s: %s", forkRepo.Knot, err) 1429 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1430 + return 1431 + } 1432 + 1433 + secret, err := db.GetRegistrationKey(s.db, forkRepo.Knot) 1434 + if err != nil { 1435 + log.Printf("failed to get registration key for %s: %s", forkRepo.Knot, err) 1436 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1437 + return 1438 + } 1439 + 1440 + // update the hidden tracking branch to latest 1441 + signedClient, err := knotclient.NewSignedClient(forkRepo.Knot, secret, s.config.Core.Dev) 1442 + if err != nil { 1443 + log.Printf("failed to create signed client for %s: %s", forkRepo.Knot, err) 1444 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1445 + return 1446 + } 1447 + 1448 + resp, err := signedClient.NewHiddenRef(forkRepo.Did, forkRepo.Name, pull.PullSource.Branch, pull.TargetBranch) 1449 + if err != nil || resp.StatusCode != http.StatusNoContent { 1450 + log.Printf("failed to update tracking branch: %s", err) 1451 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1452 + return 1453 + } 1454 + 1455 + hiddenRef := fmt.Sprintf("hidden/%s/%s", pull.PullSource.Branch, pull.TargetBranch) 1456 + comparison, err := ksClient.Compare(forkRepo.Did, forkRepo.Name, hiddenRef, pull.PullSource.Branch) 1457 + if err != nil { 1458 + log.Printf("failed to compare branches: %s", err) 1459 + s.pages.Notice(w, "resubmit-error", err.Error()) 1460 + return 1461 + } 1462 + 1463 + sourceRev := comparison.Rev2 1464 + patch := comparison.Patch 1465 + 1466 + s.resubmitPullHelper(w, r, f, user, pull, patch, sourceRev) 1467 + } 1468 + 1469 + // validate a resubmission against a pull request 1470 + func validateResubmittedPatch(pull *db.Pull, patch string) error { 1471 + if patch == "" { 1472 + return fmt.Errorf("Patch is empty.") 1473 + } 1474 + 1475 + if patch == pull.LatestPatch() { 1476 + return fmt.Errorf("Patch is identical to previous submission.") 1477 + } 1478 + 1479 + if !patchutil.IsPatchValid(patch) { 1480 + return fmt.Errorf("Invalid patch format. Please provide a valid diff.") 1481 + } 1482 + 1483 + return nil 1484 + } 1485 + 1486 + func (s *Pulls) resubmitPullHelper( 1487 + w http.ResponseWriter, 1488 + r *http.Request, 1489 + f *reporesolver.ResolvedRepo, 1490 + user *oauth.User, 1491 + pull *db.Pull, 1492 + patch string, 1493 + sourceRev string, 1494 + ) { 1495 + if pull.IsStacked() { 1496 + log.Println("resubmitting stacked PR") 1497 + s.resubmitStackedPullHelper(w, r, f, user, pull, patch, pull.StackId) 1498 + return 1499 + } 1500 + 1501 + if err := validateResubmittedPatch(pull, patch); err != nil { 1502 + s.pages.Notice(w, "resubmit-error", err.Error()) 1503 + return 1504 + } 1505 + 1506 + // validate sourceRev if branch/fork based 1507 + if pull.IsBranchBased() || pull.IsForkBased() { 1508 + if sourceRev == pull.Submissions[pull.LastRoundNumber()].SourceRev { 1509 + s.pages.Notice(w, "resubmit-error", "This branch has not changed since the last submission.") 1510 + return 1511 + } 1512 + } 1513 + 1514 + tx, err := s.db.BeginTx(r.Context(), nil) 1515 + if err != nil { 1516 + log.Println("failed to start tx") 1517 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1518 + return 1519 + } 1520 + defer tx.Rollback() 1521 + 1522 + err = db.ResubmitPull(tx, pull, patch, sourceRev) 1523 + if err != nil { 1524 + log.Println("failed to create pull request", err) 1525 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1526 + return 1527 + } 1528 + client, err := s.oauth.AuthorizedClient(r) 1529 + if err != nil { 1530 + log.Println("failed to authorize client") 1531 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1532 + return 1533 + } 1534 + 1535 + ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoPullNSID, user.Did, pull.Rkey) 1536 + if err != nil { 1537 + // failed to get record 1538 + s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.") 1539 + return 1540 + } 1541 + 1542 + var recordPullSource *tangled.RepoPull_Source 1543 + if pull.IsBranchBased() { 1544 + recordPullSource = &tangled.RepoPull_Source{ 1545 + Branch: pull.PullSource.Branch, 1546 + } 1547 + } 1548 + if pull.IsForkBased() { 1549 + repoAt := pull.PullSource.RepoAt.String() 1550 + recordPullSource = &tangled.RepoPull_Source{ 1551 + Branch: pull.PullSource.Branch, 1552 + Repo: &repoAt, 1553 + } 1554 + } 1555 + 1556 + _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1557 + Collection: tangled.RepoPullNSID, 1558 + Repo: user.Did, 1559 + Rkey: pull.Rkey, 1560 + SwapRecord: ex.Cid, 1561 + Record: &lexutil.LexiconTypeDecoder{ 1562 + Val: &tangled.RepoPull{ 1563 + Title: pull.Title, 1564 + PullId: int64(pull.PullId), 1565 + TargetRepo: string(f.RepoAt), 1566 + TargetBranch: pull.TargetBranch, 1567 + Patch: patch, // new patch 1568 + Source: recordPullSource, 1569 + }, 1570 + }, 1571 + }) 1572 + if err != nil { 1573 + log.Println("failed to update record", err) 1574 + s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.") 1575 + return 1576 + } 1577 + 1578 + if err = tx.Commit(); err != nil { 1579 + log.Println("failed to commit transaction", err) 1580 + s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull.") 1581 + return 1582 + } 1583 + 1584 + s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 1585 + return 1586 + } 1587 + 1588 + func (s *Pulls) resubmitStackedPullHelper( 1589 + w http.ResponseWriter, 1590 + r *http.Request, 1591 + f *reporesolver.ResolvedRepo, 1592 + user *oauth.User, 1593 + pull *db.Pull, 1594 + patch string, 1595 + stackId string, 1596 + ) { 1597 + targetBranch := pull.TargetBranch 1598 + 1599 + origStack, _ := r.Context().Value("stack").(db.Stack) 1600 + newStack, err := newStack(f, user, targetBranch, patch, pull.PullSource, stackId) 1601 + if err != nil { 1602 + log.Println("failed to create resubmitted stack", err) 1603 + s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1604 + return 1605 + } 1606 + 1607 + // find the diff between the stacks, first, map them by changeId 1608 + origById := make(map[string]*db.Pull) 1609 + newById := make(map[string]*db.Pull) 1610 + for _, p := range origStack { 1611 + origById[p.ChangeId] = p 1612 + } 1613 + for _, p := range newStack { 1614 + newById[p.ChangeId] = p 1615 + } 1616 + 1617 + // commits that got deleted: corresponding pull is closed 1618 + // commits that got added: new pull is created 1619 + // commits that got updated: corresponding pull is resubmitted & new round begins 1620 + // 1621 + // for commits that were unchanged: no changes, parent-change-id is updated as necessary 1622 + additions := make(map[string]*db.Pull) 1623 + deletions := make(map[string]*db.Pull) 1624 + unchanged := make(map[string]struct{}) 1625 + updated := make(map[string]struct{}) 1626 + 1627 + // pulls in orignal stack but not in new one 1628 + for _, op := range origStack { 1629 + if _, ok := newById[op.ChangeId]; !ok { 1630 + deletions[op.ChangeId] = op 1631 + } 1632 + } 1633 + 1634 + // pulls in new stack but not in original one 1635 + for _, np := range newStack { 1636 + if _, ok := origById[np.ChangeId]; !ok { 1637 + additions[np.ChangeId] = np 1638 + } 1639 + } 1640 + 1641 + // NOTE: this loop can be written in any of above blocks, 1642 + // but is written separately in the interest of simpler code 1643 + for _, np := range newStack { 1644 + if op, ok := origById[np.ChangeId]; ok { 1645 + // pull exists in both stacks 1646 + // TODO: can we avoid reparse? 1647 + origFiles, origHeaderStr, _ := gitdiff.Parse(strings.NewReader(op.LatestPatch())) 1648 + newFiles, newHeaderStr, _ := gitdiff.Parse(strings.NewReader(np.LatestPatch())) 1649 + 1650 + origHeader, _ := gitdiff.ParsePatchHeader(origHeaderStr) 1651 + newHeader, _ := gitdiff.ParsePatchHeader(newHeaderStr) 1652 + 1653 + patchutil.SortPatch(newFiles) 1654 + patchutil.SortPatch(origFiles) 1655 + 1656 + // text content of patch may be identical, but a jj rebase might have forwarded it 1657 + // 1658 + // we still need to update the hash in submission.Patch and submission.SourceRev 1659 + if patchutil.Equal(newFiles, origFiles) && 1660 + origHeader.Title == newHeader.Title && 1661 + origHeader.Body == newHeader.Body { 1662 + unchanged[op.ChangeId] = struct{}{} 1663 + } else { 1664 + updated[op.ChangeId] = struct{}{} 1665 + } 1666 + } 1667 + } 1668 + 1669 + tx, err := s.db.Begin() 1670 + if err != nil { 1671 + log.Println("failed to start transaction", err) 1672 + s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 1673 + return 1674 + } 1675 + defer tx.Rollback() 1676 + 1677 + // pds updates to make 1678 + var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem 1679 + 1680 + // deleted pulls are marked as deleted in the DB 1681 + for _, p := range deletions { 1682 + err := db.DeletePull(tx, p.RepoAt, p.PullId) 1683 + if err != nil { 1684 + log.Println("failed to delete pull", err, p.PullId) 1685 + s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 1686 + return 1687 + } 1688 + writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 1689 + RepoApplyWrites_Delete: &comatproto.RepoApplyWrites_Delete{ 1690 + Collection: tangled.RepoPullNSID, 1691 + Rkey: p.Rkey, 1692 + }, 1693 + }) 1694 + } 1695 + 1696 + // new pulls are created 1697 + for _, p := range additions { 1698 + err := db.NewPull(tx, p) 1699 + if err != nil { 1700 + log.Println("failed to create pull", err, p.PullId) 1701 + s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 1702 + return 1703 + } 1704 + 1705 + record := p.AsRecord() 1706 + writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 1707 + RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{ 1708 + Collection: tangled.RepoPullNSID, 1709 + Rkey: &p.Rkey, 1710 + Value: &lexutil.LexiconTypeDecoder{ 1711 + Val: &record, 1712 + }, 1713 + }, 1714 + }) 1715 + } 1716 + 1717 + // updated pulls are, well, updated; to start a new round 1718 + for id := range updated { 1719 + op, _ := origById[id] 1720 + np, _ := newById[id] 1721 + 1722 + submission := np.Submissions[np.LastRoundNumber()] 1723 + 1724 + // resubmit the old pull 1725 + err := db.ResubmitPull(tx, op, submission.Patch, submission.SourceRev) 1726 + 1727 + if err != nil { 1728 + log.Println("failed to update pull", err, op.PullId) 1729 + s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 1730 + return 1731 + } 1732 + 1733 + record := op.AsRecord() 1734 + record.Patch = submission.Patch 1735 + 1736 + writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 1737 + RepoApplyWrites_Update: &comatproto.RepoApplyWrites_Update{ 1738 + Collection: tangled.RepoPullNSID, 1739 + Rkey: op.Rkey, 1740 + Value: &lexutil.LexiconTypeDecoder{ 1741 + Val: &record, 1742 + }, 1743 + }, 1744 + }) 1745 + } 1746 + 1747 + // unchanged pulls are edited without starting a new round 1748 + // 1749 + // update source-revs & patches without advancing rounds 1750 + for changeId := range unchanged { 1751 + op, _ := origById[changeId] 1752 + np, _ := newById[changeId] 1753 + 1754 + origSubmission := op.Submissions[op.LastRoundNumber()] 1755 + newSubmission := np.Submissions[np.LastRoundNumber()] 1756 + 1757 + log.Println("moving unchanged change id : ", changeId) 1758 + 1759 + err := db.UpdatePull( 1760 + tx, 1761 + newSubmission.Patch, 1762 + newSubmission.SourceRev, 1763 + db.FilterEq("id", origSubmission.ID), 1764 + ) 1765 + 1766 + if err != nil { 1767 + log.Println("failed to update pull", err, op.PullId) 1768 + s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 1769 + return 1770 + } 1771 + 1772 + record := op.AsRecord() 1773 + record.Patch = newSubmission.Patch 1774 + 1775 + writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 1776 + RepoApplyWrites_Update: &comatproto.RepoApplyWrites_Update{ 1777 + Collection: tangled.RepoPullNSID, 1778 + Rkey: op.Rkey, 1779 + Value: &lexutil.LexiconTypeDecoder{ 1780 + Val: &record, 1781 + }, 1782 + }, 1783 + }) 1784 + } 1785 + 1786 + // update parent-change-id relations for the entire stack 1787 + for _, p := range newStack { 1788 + err := db.SetPullParentChangeId( 1789 + tx, 1790 + p.ParentChangeId, 1791 + // these should be enough filters to be unique per-stack 1792 + db.FilterEq("repo_at", p.RepoAt.String()), 1793 + db.FilterEq("owner_did", p.OwnerDid), 1794 + db.FilterEq("change_id", p.ChangeId), 1795 + ) 1796 + 1797 + if err != nil { 1798 + log.Println("failed to update pull", err, p.PullId) 1799 + s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 1800 + return 1801 + } 1802 + } 1803 + 1804 + err = tx.Commit() 1805 + if err != nil { 1806 + log.Println("failed to resubmit pull", err) 1807 + s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 1808 + return 1809 + } 1810 + 1811 + client, err := s.oauth.AuthorizedClient(r) 1812 + if err != nil { 1813 + log.Println("failed to authorize client") 1814 + s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1815 + return 1816 + } 1817 + 1818 + _, err = client.RepoApplyWrites(r.Context(), &comatproto.RepoApplyWrites_Input{ 1819 + Repo: user.Did, 1820 + Writes: writes, 1821 + }) 1822 + if err != nil { 1823 + log.Println("failed to create stacked pull request", err) 1824 + s.pages.Notice(w, "pull", "Failed to create stacked pull request. Try again later.") 1825 + return 1826 + } 1827 + 1828 + s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 1829 + return 1830 + } 1831 + 1832 + func (s *Pulls) MergePull(w http.ResponseWriter, r *http.Request) { 1833 + f, err := s.repoResolver.Resolve(r) 1834 + if err != nil { 1835 + log.Println("failed to resolve repo:", err) 1836 + s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1837 + return 1838 + } 1839 + 1840 + pull, ok := r.Context().Value("pull").(*db.Pull) 1841 + if !ok { 1842 + log.Println("failed to get pull") 1843 + s.pages.Notice(w, "pull-merge-error", "Failed to merge patch. Try again later.") 1844 + return 1845 + } 1846 + 1847 + var pullsToMerge db.Stack 1848 + pullsToMerge = append(pullsToMerge, pull) 1849 + if pull.IsStacked() { 1850 + stack, ok := r.Context().Value("stack").(db.Stack) 1851 + if !ok { 1852 + log.Println("failed to get stack") 1853 + s.pages.Notice(w, "pull-merge-error", "Failed to merge patch. Try again later.") 1854 + return 1855 + } 1856 + 1857 + // combine patches of substack 1858 + subStack := stack.StrictlyBelow(pull) 1859 + // collect the portion of the stack that is mergeable 1860 + mergeable := subStack.Mergeable() 1861 + // add to total patch 1862 + pullsToMerge = append(pullsToMerge, mergeable...) 1863 + } 1864 + 1865 + patch := pullsToMerge.CombinedPatch() 1866 + 1867 + secret, err := db.GetRegistrationKey(s.db, f.Knot) 1868 + if err != nil { 1869 + log.Printf("no registration key found for domain %s: %s\n", f.Knot, err) 1870 + s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1871 + return 1872 + } 1873 + 1874 + ident, err := s.idResolver.ResolveIdent(r.Context(), pull.OwnerDid) 1875 + if err != nil { 1876 + log.Printf("resolving identity: %s", err) 1877 + w.WriteHeader(http.StatusNotFound) 1878 + return 1879 + } 1880 + 1881 + email, err := db.GetPrimaryEmail(s.db, pull.OwnerDid) 1882 + if err != nil { 1883 + log.Printf("failed to get primary email: %s", err) 1884 + } 1885 + 1886 + ksClient, err := knotclient.NewSignedClient(f.Knot, secret, s.config.Core.Dev) 1887 + if err != nil { 1888 + log.Printf("failed to create signed client for %s: %s", f.Knot, err) 1889 + s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1890 + return 1891 + } 1892 + 1893 + // Merge the pull request 1894 + resp, err := ksClient.Merge([]byte(patch), f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.Title, pull.Body, ident.Handle.String(), email.Address) 1895 + if err != nil { 1896 + log.Printf("failed to merge pull request: %s", err) 1897 + s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1898 + return 1899 + } 1900 + 1901 + if resp.StatusCode != http.StatusOK { 1902 + log.Printf("knotserver returned non-OK status code for merge: %d", resp.StatusCode) 1903 + s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1904 + return 1905 + } 1906 + 1907 + tx, err := s.db.Begin() 1908 + if err != nil { 1909 + log.Println("failed to start transcation", err) 1910 + s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1911 + return 1912 + } 1913 + defer tx.Rollback() 1914 + 1915 + for _, p := range pullsToMerge { 1916 + err := db.MergePull(tx, f.RepoAt, p.PullId) 1917 + if err != nil { 1918 + log.Printf("failed to update pull request status in database: %s", err) 1919 + s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1920 + return 1921 + } 1922 + } 1923 + 1924 + err = tx.Commit() 1925 + if err != nil { 1926 + // TODO: this is unsound, we should also revert the merge from the knotserver here 1927 + log.Printf("failed to update pull request status in database: %s", err) 1928 + s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1929 + return 1930 + } 1931 + 1932 + s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.RepoName, pull.PullId)) 1933 + } 1934 + 1935 + func (s *Pulls) ClosePull(w http.ResponseWriter, r *http.Request) { 1936 + user := s.oauth.GetUser(r) 1937 + 1938 + f, err := s.repoResolver.Resolve(r) 1939 + if err != nil { 1940 + log.Println("malformed middleware") 1941 + return 1942 + } 1943 + 1944 + pull, ok := r.Context().Value("pull").(*db.Pull) 1945 + if !ok { 1946 + log.Println("failed to get pull") 1947 + s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 1948 + return 1949 + } 1950 + 1951 + // auth filter: only owner or collaborators can close 1952 + roles := f.RolesInRepo(user) 1953 + isCollaborator := roles.IsCollaborator() 1954 + isPullAuthor := user.Did == pull.OwnerDid 1955 + isCloseAllowed := isCollaborator || isPullAuthor 1956 + if !isCloseAllowed { 1957 + log.Println("failed to close pull") 1958 + s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.") 1959 + return 1960 + } 1961 + 1962 + // Start a transaction 1963 + tx, err := s.db.BeginTx(r.Context(), nil) 1964 + if err != nil { 1965 + log.Println("failed to start transaction", err) 1966 + s.pages.Notice(w, "pull-close", "Failed to close pull.") 1967 + return 1968 + } 1969 + defer tx.Rollback() 1970 + 1971 + var pullsToClose []*db.Pull 1972 + pullsToClose = append(pullsToClose, pull) 1973 + 1974 + // if this PR is stacked, then we want to close all PRs below this one on the stack 1975 + if pull.IsStacked() { 1976 + stack := r.Context().Value("stack").(db.Stack) 1977 + subStack := stack.StrictlyBelow(pull) 1978 + pullsToClose = append(pullsToClose, subStack...) 1979 + } 1980 + 1981 + for _, p := range pullsToClose { 1982 + // Close the pull in the database 1983 + err = db.ClosePull(tx, f.RepoAt, p.PullId) 1984 + if err != nil { 1985 + log.Println("failed to close pull", err) 1986 + s.pages.Notice(w, "pull-close", "Failed to close pull.") 1987 + return 1988 + } 1989 + } 1990 + 1991 + // Commit the transaction 1992 + if err = tx.Commit(); err != nil { 1993 + log.Println("failed to commit transaction", err) 1994 + s.pages.Notice(w, "pull-close", "Failed to close pull.") 1995 + return 1996 + } 1997 + 1998 + s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 1999 + return 2000 + } 2001 + 2002 + func (s *Pulls) ReopenPull(w http.ResponseWriter, r *http.Request) { 2003 + user := s.oauth.GetUser(r) 2004 + 2005 + f, err := s.repoResolver.Resolve(r) 2006 + if err != nil { 2007 + log.Println("failed to resolve repo", err) 2008 + s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") 2009 + return 2010 + } 2011 + 2012 + pull, ok := r.Context().Value("pull").(*db.Pull) 2013 + if !ok { 2014 + log.Println("failed to get pull") 2015 + s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 2016 + return 2017 + } 2018 + 2019 + // auth filter: only owner or collaborators can close 2020 + roles := f.RolesInRepo(user) 2021 + isCollaborator := roles.IsCollaborator() 2022 + isPullAuthor := user.Did == pull.OwnerDid 2023 + isCloseAllowed := isCollaborator || isPullAuthor 2024 + if !isCloseAllowed { 2025 + log.Println("failed to close pull") 2026 + s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.") 2027 + return 2028 + } 2029 + 2030 + // Start a transaction 2031 + tx, err := s.db.BeginTx(r.Context(), nil) 2032 + if err != nil { 2033 + log.Println("failed to start transaction", err) 2034 + s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") 2035 + return 2036 + } 2037 + defer tx.Rollback() 2038 + 2039 + var pullsToReopen []*db.Pull 2040 + pullsToReopen = append(pullsToReopen, pull) 2041 + 2042 + // if this PR is stacked, then we want to reopen all PRs above this one on the stack 2043 + if pull.IsStacked() { 2044 + stack := r.Context().Value("stack").(db.Stack) 2045 + subStack := stack.StrictlyAbove(pull) 2046 + pullsToReopen = append(pullsToReopen, subStack...) 2047 + } 2048 + 2049 + for _, p := range pullsToReopen { 2050 + // Close the pull in the database 2051 + err = db.ReopenPull(tx, f.RepoAt, p.PullId) 2052 + if err != nil { 2053 + log.Println("failed to close pull", err) 2054 + s.pages.Notice(w, "pull-close", "Failed to close pull.") 2055 + return 2056 + } 2057 + } 2058 + 2059 + // Commit the transaction 2060 + if err = tx.Commit(); err != nil { 2061 + log.Println("failed to commit transaction", err) 2062 + s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") 2063 + return 2064 + } 2065 + 2066 + s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 2067 + return 2068 + } 2069 + 2070 + func newStack(f *reporesolver.ResolvedRepo, user *oauth.User, targetBranch, patch string, pullSource *db.PullSource, stackId string) (db.Stack, error) { 2071 + formatPatches, err := patchutil.ExtractPatches(patch) 2072 + if err != nil { 2073 + return nil, fmt.Errorf("Failed to extract patches: %v", err) 2074 + } 2075 + 2076 + // must have atleast 1 patch to begin with 2077 + if len(formatPatches) == 0 { 2078 + return nil, fmt.Errorf("No patches found in the generated format-patch.") 2079 + } 2080 + 2081 + // the stack is identified by a UUID 2082 + var stack db.Stack 2083 + parentChangeId := "" 2084 + for _, fp := range formatPatches { 2085 + // all patches must have a jj change-id 2086 + changeId, err := fp.ChangeId() 2087 + if err != nil { 2088 + return nil, fmt.Errorf("Stacking is only supported if all patches contain a change-id commit header.") 2089 + } 2090 + 2091 + title := fp.Title 2092 + body := fp.Body 2093 + rkey := appview.TID() 2094 + 2095 + initialSubmission := db.PullSubmission{ 2096 + Patch: fp.Raw, 2097 + SourceRev: fp.SHA, 2098 + } 2099 + pull := db.Pull{ 2100 + Title: title, 2101 + Body: body, 2102 + TargetBranch: targetBranch, 2103 + OwnerDid: user.Did, 2104 + RepoAt: f.RepoAt, 2105 + Rkey: rkey, 2106 + Submissions: []*db.PullSubmission{ 2107 + &initialSubmission, 2108 + }, 2109 + PullSource: pullSource, 2110 + Created: time.Now(), 2111 + 2112 + StackId: stackId, 2113 + ChangeId: changeId, 2114 + ParentChangeId: parentChangeId, 2115 + } 2116 + 2117 + stack = append(stack, &pull) 2118 + 2119 + parentChangeId = changeId 2120 + } 2121 + 2122 + return stack, nil 2123 + }
+59
appview/pulls/router.go
··· 1 + package pulls 2 + 3 + import ( 4 + "net/http" 5 + 6 + "github.com/go-chi/chi/v5" 7 + "tangled.sh/tangled.sh/core/appview/middleware" 8 + ) 9 + 10 + func (s *Pulls) Router(mw *middleware.Middleware) http.Handler { 11 + r := chi.NewRouter() 12 + r.Get("/", s.RepoPulls) 13 + r.With(middleware.AuthMiddleware(s.oauth)).Route("/new", func(r chi.Router) { 14 + r.Get("/", s.NewPull) 15 + r.Get("/patch-upload", s.PatchUploadFragment) 16 + r.Post("/validate-patch", s.ValidatePatch) 17 + r.Get("/compare-branches", s.CompareBranchesFragment) 18 + r.Get("/compare-forks", s.CompareForksFragment) 19 + r.Get("/fork-branches", s.CompareForksBranchesFragment) 20 + r.Post("/", s.NewPull) 21 + }) 22 + 23 + r.Route("/{pull}", func(r chi.Router) { 24 + r.Use(mw.ResolvePull()) 25 + r.Get("/", s.RepoSinglePull) 26 + 27 + r.Route("/round/{round}", func(r chi.Router) { 28 + r.Get("/", s.RepoPullPatch) 29 + r.Get("/interdiff", s.RepoPullInterdiff) 30 + r.Get("/actions", s.PullActions) 31 + r.With(middleware.AuthMiddleware(s.oauth)).Route("/comment", func(r chi.Router) { 32 + r.Get("/", s.PullComment) 33 + r.Post("/", s.PullComment) 34 + }) 35 + }) 36 + 37 + r.Route("/round/{round}.patch", func(r chi.Router) { 38 + r.Get("/", s.RepoPullPatchRaw) 39 + }) 40 + 41 + r.Group(func(r chi.Router) { 42 + r.Use(middleware.AuthMiddleware(s.oauth)) 43 + r.Route("/resubmit", func(r chi.Router) { 44 + r.Get("/", s.ResubmitPull) 45 + r.Post("/", s.ResubmitPull) 46 + }) 47 + r.Post("/close", s.ClosePull) 48 + r.Post("/reopen", s.ReopenPull) 49 + // collaborators only 50 + r.Group(func(r chi.Router) { 51 + r.Use(mw.RepoPermissionMiddleware("repo:push")) 52 + r.Post("/merge", s.MergePull) 53 + // maybe lock, etc. 54 + }) 55 + }) 56 + }) 57 + return r 58 + 59 + }
+297
appview/repo/artifact.go
··· 1 + package repo 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/pages" 20 + "tangled.sh/tangled.sh/core/appview/reporesolver" 21 + "tangled.sh/tangled.sh/core/knotclient" 22 + "tangled.sh/tangled.sh/core/types" 23 + ) 24 + 25 + // TODO: proper statuses here on early exit 26 + func (rp *Repo) AttachArtifact(w http.ResponseWriter, r *http.Request) { 27 + user := rp.oauth.GetUser(r) 28 + tagParam := chi.URLParam(r, "tag") 29 + f, err := rp.repoResolver.Resolve(r) 30 + if err != nil { 31 + log.Println("failed to get repo and knot", err) 32 + rp.pages.Notice(w, "upload", "failed to upload artifact, error in repo resolution") 33 + return 34 + } 35 + 36 + tag, err := rp.resolveTag(f, tagParam) 37 + if err != nil { 38 + log.Println("failed to resolve tag", err) 39 + rp.pages.Notice(w, "upload", "failed to upload artifact, error in tag resolution") 40 + return 41 + } 42 + 43 + file, handler, err := r.FormFile("artifact") 44 + if err != nil { 45 + log.Println("failed to upload artifact", err) 46 + rp.pages.Notice(w, "upload", "failed to upload artifact") 47 + return 48 + } 49 + defer file.Close() 50 + 51 + client, err := rp.oauth.AuthorizedClient(r) 52 + if err != nil { 53 + log.Println("failed to get authorized client", err) 54 + rp.pages.Notice(w, "upload", "failed to get authorized client") 55 + return 56 + } 57 + 58 + uploadBlobResp, err := client.RepoUploadBlob(r.Context(), file) 59 + if err != nil { 60 + log.Println("failed to upload blob", err) 61 + rp.pages.Notice(w, "upload", "Failed to upload blob to your PDS. Try again later.") 62 + return 63 + } 64 + 65 + log.Println("uploaded blob", humanize.Bytes(uint64(uploadBlobResp.Blob.Size)), uploadBlobResp.Blob.Ref.String()) 66 + 67 + rkey := appview.TID() 68 + createdAt := time.Now() 69 + 70 + putRecordResp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 71 + Collection: tangled.RepoArtifactNSID, 72 + Repo: user.Did, 73 + Rkey: rkey, 74 + Record: &lexutil.LexiconTypeDecoder{ 75 + Val: &tangled.RepoArtifact{ 76 + Artifact: uploadBlobResp.Blob, 77 + CreatedAt: createdAt.Format(time.RFC3339), 78 + Name: handler.Filename, 79 + Repo: f.RepoAt.String(), 80 + Tag: tag.Tag.Hash[:], 81 + }, 82 + }, 83 + }) 84 + if err != nil { 85 + log.Println("failed to create record", err) 86 + rp.pages.Notice(w, "upload", "Failed to create artifact record. Try again later.") 87 + return 88 + } 89 + 90 + log.Println(putRecordResp.Uri) 91 + 92 + tx, err := rp.db.BeginTx(r.Context(), nil) 93 + if err != nil { 94 + log.Println("failed to start tx") 95 + rp.pages.Notice(w, "upload", "Failed to create artifact. Try again later.") 96 + return 97 + } 98 + defer tx.Rollback() 99 + 100 + artifact := db.Artifact{ 101 + Did: user.Did, 102 + Rkey: rkey, 103 + RepoAt: f.RepoAt, 104 + Tag: tag.Tag.Hash, 105 + CreatedAt: createdAt, 106 + BlobCid: cid.Cid(uploadBlobResp.Blob.Ref), 107 + Name: handler.Filename, 108 + Size: uint64(uploadBlobResp.Blob.Size), 109 + MimeType: uploadBlobResp.Blob.MimeType, 110 + } 111 + 112 + err = db.AddArtifact(tx, artifact) 113 + if err != nil { 114 + log.Println("failed to add artifact record to db", err) 115 + rp.pages.Notice(w, "upload", "Failed to create artifact. Try again later.") 116 + return 117 + } 118 + 119 + err = tx.Commit() 120 + if err != nil { 121 + log.Println("failed to add artifact record to db") 122 + rp.pages.Notice(w, "upload", "Failed to create artifact. Try again later.") 123 + return 124 + } 125 + 126 + rp.pages.RepoArtifactFragment(w, pages.RepoArtifactParams{ 127 + LoggedInUser: user, 128 + RepoInfo: f.RepoInfo(user), 129 + Artifact: artifact, 130 + }) 131 + } 132 + 133 + // TODO: proper statuses here on early exit 134 + func (rp *Repo) DownloadArtifact(w http.ResponseWriter, r *http.Request) { 135 + tagParam := chi.URLParam(r, "tag") 136 + filename := chi.URLParam(r, "file") 137 + f, err := rp.repoResolver.Resolve(r) 138 + if err != nil { 139 + log.Println("failed to get repo and knot", err) 140 + return 141 + } 142 + 143 + tag, err := rp.resolveTag(f, tagParam) 144 + if err != nil { 145 + log.Println("failed to resolve tag", err) 146 + rp.pages.Notice(w, "upload", "failed to upload artifact, error in tag resolution") 147 + return 148 + } 149 + 150 + client, err := rp.oauth.AuthorizedClient(r) 151 + if err != nil { 152 + log.Println("failed to get authorized client", err) 153 + return 154 + } 155 + 156 + artifacts, err := db.GetArtifact( 157 + rp.db, 158 + db.FilterEq("repo_at", f.RepoAt), 159 + db.FilterEq("tag", tag.Tag.Hash[:]), 160 + db.FilterEq("name", filename), 161 + ) 162 + if err != nil { 163 + log.Println("failed to get artifacts", err) 164 + return 165 + } 166 + if len(artifacts) != 1 { 167 + log.Printf("too many or too little artifacts found") 168 + return 169 + } 170 + 171 + artifact := artifacts[0] 172 + 173 + getBlobResp, err := client.SyncGetBlob(r.Context(), artifact.BlobCid.String(), artifact.Did) 174 + if err != nil { 175 + log.Println("failed to get blob from pds", err) 176 + return 177 + } 178 + 179 + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename)) 180 + w.Write(getBlobResp) 181 + } 182 + 183 + // TODO: proper statuses here on early exit 184 + func (rp *Repo) DeleteArtifact(w http.ResponseWriter, r *http.Request) { 185 + user := rp.oauth.GetUser(r) 186 + tagParam := chi.URLParam(r, "tag") 187 + filename := chi.URLParam(r, "file") 188 + f, err := rp.repoResolver.Resolve(r) 189 + if err != nil { 190 + log.Println("failed to get repo and knot", err) 191 + return 192 + } 193 + 194 + client, _ := rp.oauth.AuthorizedClient(r) 195 + 196 + tag := plumbing.NewHash(tagParam) 197 + 198 + artifacts, err := db.GetArtifact( 199 + rp.db, 200 + db.FilterEq("repo_at", f.RepoAt), 201 + db.FilterEq("tag", tag[:]), 202 + db.FilterEq("name", filename), 203 + ) 204 + if err != nil { 205 + log.Println("failed to get artifacts", err) 206 + rp.pages.Notice(w, "remove", "Failed to delete artifact. Try again later.") 207 + return 208 + } 209 + if len(artifacts) != 1 { 210 + rp.pages.Notice(w, "remove", "Unable to find artifact.") 211 + return 212 + } 213 + 214 + artifact := artifacts[0] 215 + 216 + if user.Did != artifact.Did { 217 + log.Println("user not authorized to delete artifact", err) 218 + rp.pages.Notice(w, "remove", "Unauthorized deletion of artifact.") 219 + return 220 + } 221 + 222 + _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 223 + Collection: tangled.RepoArtifactNSID, 224 + Repo: user.Did, 225 + Rkey: artifact.Rkey, 226 + }) 227 + if err != nil { 228 + log.Println("failed to get blob from pds", err) 229 + rp.pages.Notice(w, "remove", "Failed to remove blob from PDS.") 230 + return 231 + } 232 + 233 + tx, err := rp.db.BeginTx(r.Context(), nil) 234 + if err != nil { 235 + log.Println("failed to start tx") 236 + rp.pages.Notice(w, "remove", "Failed to delete artifact. Try again later.") 237 + return 238 + } 239 + defer tx.Rollback() 240 + 241 + err = db.DeleteArtifact(tx, 242 + db.FilterEq("repo_at", f.RepoAt), 243 + db.FilterEq("tag", artifact.Tag[:]), 244 + db.FilterEq("name", filename), 245 + ) 246 + if err != nil { 247 + log.Println("failed to remove artifact record from db", err) 248 + rp.pages.Notice(w, "remove", "Failed to delete artifact. Try again later.") 249 + return 250 + } 251 + 252 + err = tx.Commit() 253 + if err != nil { 254 + log.Println("failed to remove artifact record from db") 255 + rp.pages.Notice(w, "remove", "Failed to delete artifact. Try again later.") 256 + return 257 + } 258 + 259 + w.Write([]byte{}) 260 + } 261 + 262 + func (rp *Repo) resolveTag(f *reporesolver.ResolvedRepo, tagParam string) (*types.TagReference, error) { 263 + tagParam, err := url.QueryUnescape(tagParam) 264 + if err != nil { 265 + return nil, err 266 + } 267 + 268 + us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 269 + if err != nil { 270 + return nil, err 271 + } 272 + 273 + result, err := us.Tags(f.OwnerDid(), f.RepoName) 274 + if err != nil { 275 + log.Println("failed to reach knotserver", err) 276 + return nil, err 277 + } 278 + 279 + var tag *types.TagReference 280 + for _, t := range result.Tags { 281 + if t.Tag != nil { 282 + if t.Reference.Name == tagParam || t.Reference.Hash == tagParam { 283 + tag = t 284 + } 285 + } 286 + } 287 + 288 + if tag == nil { 289 + return nil, fmt.Errorf("invalid tag, only annotated tags are supported for artifacts") 290 + } 291 + 292 + if tag.Tag.Target.IsZero() { 293 + return nil, fmt.Errorf("invalid tag, only annotated tags are supported for artifacts") 294 + } 295 + 296 + return tag, nil 297 + }
+1359
appview/repo/repo.go
··· 1 + package repo 2 + 3 + import ( 4 + "database/sql" 5 + "encoding/json" 6 + "errors" 7 + "fmt" 8 + "io" 9 + "log" 10 + "net/http" 11 + "path" 12 + "slices" 13 + "sort" 14 + "strconv" 15 + "strings" 16 + "time" 17 + 18 + "tangled.sh/tangled.sh/core/api/tangled" 19 + "tangled.sh/tangled.sh/core/appview" 20 + "tangled.sh/tangled.sh/core/appview/config" 21 + "tangled.sh/tangled.sh/core/appview/db" 22 + "tangled.sh/tangled.sh/core/appview/idresolver" 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/reporesolver" 28 + "tangled.sh/tangled.sh/core/knotclient" 29 + "tangled.sh/tangled.sh/core/patchutil" 30 + "tangled.sh/tangled.sh/core/rbac" 31 + "tangled.sh/tangled.sh/core/types" 32 + 33 + securejoin "github.com/cyphar/filepath-securejoin" 34 + "github.com/go-chi/chi/v5" 35 + "github.com/go-git/go-git/v5/plumbing" 36 + "github.com/posthog/posthog-go" 37 + 38 + comatproto "github.com/bluesky-social/indigo/api/atproto" 39 + lexutil "github.com/bluesky-social/indigo/lex/util" 40 + ) 41 + 42 + type Repo struct { 43 + repoResolver *reporesolver.RepoResolver 44 + idResolver *idresolver.Resolver 45 + config *config.Config 46 + oauth *oauth.OAuth 47 + pages *pages.Pages 48 + db *db.DB 49 + enforcer *rbac.Enforcer 50 + posthog posthog.Client 51 + } 52 + 53 + func New( 54 + oauth *oauth.OAuth, 55 + repoResolver *reporesolver.RepoResolver, 56 + pages *pages.Pages, 57 + idResolver *idresolver.Resolver, 58 + db *db.DB, 59 + config *config.Config, 60 + posthog posthog.Client, 61 + enforcer *rbac.Enforcer, 62 + ) *Repo { 63 + return &Repo{oauth: oauth, 64 + repoResolver: repoResolver, 65 + pages: pages, 66 + idResolver: idResolver, 67 + config: config, 68 + db: db, 69 + posthog: posthog, 70 + enforcer: enforcer, 71 + } 72 + } 73 + 74 + func (rp *Repo) RepoIndex(w http.ResponseWriter, r *http.Request) { 75 + ref := chi.URLParam(r, "ref") 76 + f, err := rp.repoResolver.Resolve(r) 77 + if err != nil { 78 + log.Println("failed to fully resolve repo", err) 79 + return 80 + } 81 + 82 + us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 83 + if err != nil { 84 + log.Printf("failed to create unsigned client for %s", f.Knot) 85 + rp.pages.Error503(w) 86 + return 87 + } 88 + 89 + result, err := us.Index(f.OwnerDid(), f.RepoName, ref) 90 + if err != nil { 91 + rp.pages.Error503(w) 92 + log.Println("failed to reach knotserver", err) 93 + return 94 + } 95 + 96 + tagMap := make(map[string][]string) 97 + for _, tag := range result.Tags { 98 + hash := tag.Hash 99 + if tag.Tag != nil { 100 + hash = tag.Tag.Target.String() 101 + } 102 + tagMap[hash] = append(tagMap[hash], tag.Name) 103 + } 104 + 105 + for _, branch := range result.Branches { 106 + hash := branch.Hash 107 + tagMap[hash] = append(tagMap[hash], branch.Name) 108 + } 109 + 110 + slices.SortFunc(result.Branches, func(a, b types.Branch) int { 111 + if a.Name == result.Ref { 112 + return -1 113 + } 114 + if a.IsDefault { 115 + return -1 116 + } 117 + if b.IsDefault { 118 + return 1 119 + } 120 + if a.Commit != nil { 121 + if a.Commit.Committer.When.Before(b.Commit.Committer.When) { 122 + return 1 123 + } else { 124 + return -1 125 + } 126 + } 127 + return strings.Compare(a.Name, b.Name) * -1 128 + }) 129 + 130 + commitCount := len(result.Commits) 131 + branchCount := len(result.Branches) 132 + tagCount := len(result.Tags) 133 + fileCount := len(result.Files) 134 + 135 + commitCount, branchCount, tagCount = balanceIndexItems(commitCount, branchCount, tagCount, fileCount) 136 + commitsTrunc := result.Commits[:min(commitCount, len(result.Commits))] 137 + tagsTrunc := result.Tags[:min(tagCount, len(result.Tags))] 138 + branchesTrunc := result.Branches[:min(branchCount, len(result.Branches))] 139 + 140 + emails := uniqueEmails(commitsTrunc) 141 + 142 + user := rp.oauth.GetUser(r) 143 + repoInfo := f.RepoInfo(user) 144 + 145 + secret, err := db.GetRegistrationKey(rp.db, f.Knot) 146 + if err != nil { 147 + log.Printf("failed to get registration key for %s: %s", f.Knot, err) 148 + rp.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 149 + } 150 + 151 + signedClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev) 152 + if err != nil { 153 + log.Printf("failed to create signed client for %s: %s", f.Knot, err) 154 + return 155 + } 156 + 157 + var forkInfo *types.ForkInfo 158 + if user != nil && (repoInfo.Roles.IsOwner() || repoInfo.Roles.IsCollaborator()) { 159 + forkInfo, err = getForkInfo(repoInfo, rp, f, user, signedClient) 160 + if err != nil { 161 + log.Printf("Failed to fetch fork information: %v", err) 162 + return 163 + } 164 + } 165 + 166 + repoLanguages, err := signedClient.RepoLanguages(f.OwnerDid(), f.RepoName, ref) 167 + if err != nil { 168 + log.Printf("failed to compute language percentages: %s", err) 169 + // non-fatal 170 + } 171 + 172 + rp.pages.RepoIndexPage(w, pages.RepoIndexParams{ 173 + LoggedInUser: user, 174 + RepoInfo: repoInfo, 175 + TagMap: tagMap, 176 + RepoIndexResponse: *result, 177 + CommitsTrunc: commitsTrunc, 178 + TagsTrunc: tagsTrunc, 179 + ForkInfo: forkInfo, 180 + BranchesTrunc: branchesTrunc, 181 + EmailToDidOrHandle: EmailToDidOrHandle(rp, emails), 182 + Languages: repoLanguages, 183 + }) 184 + return 185 + } 186 + 187 + func getForkInfo( 188 + repoInfo repoinfo.RepoInfo, 189 + rp *Repo, 190 + f *reporesolver.ResolvedRepo, 191 + user *oauth.User, 192 + signedClient *knotclient.SignedClient, 193 + ) (*types.ForkInfo, error) { 194 + if user == nil { 195 + return nil, nil 196 + } 197 + 198 + forkInfo := types.ForkInfo{ 199 + IsFork: repoInfo.Source != nil, 200 + Status: types.UpToDate, 201 + } 202 + 203 + if !forkInfo.IsFork { 204 + forkInfo.IsFork = false 205 + return &forkInfo, nil 206 + } 207 + 208 + us, err := knotclient.NewUnsignedClient(repoInfo.Source.Knot, rp.config.Core.Dev) 209 + if err != nil { 210 + log.Printf("failed to create unsigned client for %s", repoInfo.Source.Knot) 211 + return nil, err 212 + } 213 + 214 + result, err := us.Branches(repoInfo.Source.Did, repoInfo.Source.Name) 215 + if err != nil { 216 + log.Println("failed to reach knotserver", err) 217 + return nil, err 218 + } 219 + 220 + if !slices.ContainsFunc(result.Branches, func(branch types.Branch) bool { 221 + return branch.Name == f.Ref 222 + }) { 223 + forkInfo.Status = types.MissingBranch 224 + return &forkInfo, nil 225 + } 226 + 227 + newHiddenRefResp, err := signedClient.NewHiddenRef(user.Did, repoInfo.Name, f.Ref, f.Ref) 228 + if err != nil || newHiddenRefResp.StatusCode != http.StatusNoContent { 229 + log.Printf("failed to update tracking branch: %s", err) 230 + return nil, err 231 + } 232 + 233 + hiddenRef := fmt.Sprintf("hidden/%s/%s", f.Ref, f.Ref) 234 + 235 + var status types.AncestorCheckResponse 236 + forkSyncableResp, err := signedClient.RepoForkAheadBehind(user.Did, string(f.RepoAt), repoInfo.Name, f.Ref, hiddenRef) 237 + if err != nil { 238 + log.Printf("failed to check if fork is ahead/behind: %s", err) 239 + return nil, err 240 + } 241 + 242 + if err := json.NewDecoder(forkSyncableResp.Body).Decode(&status); err != nil { 243 + log.Printf("failed to decode fork status: %s", err) 244 + return nil, err 245 + } 246 + 247 + forkInfo.Status = status.Status 248 + return &forkInfo, nil 249 + } 250 + 251 + func (rp *Repo) RepoLog(w http.ResponseWriter, r *http.Request) { 252 + f, err := rp.repoResolver.Resolve(r) 253 + if err != nil { 254 + log.Println("failed to fully resolve repo", err) 255 + return 256 + } 257 + 258 + page := 1 259 + if r.URL.Query().Get("page") != "" { 260 + page, err = strconv.Atoi(r.URL.Query().Get("page")) 261 + if err != nil { 262 + page = 1 263 + } 264 + } 265 + 266 + ref := chi.URLParam(r, "ref") 267 + 268 + us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 269 + if err != nil { 270 + log.Println("failed to create unsigned client", err) 271 + return 272 + } 273 + 274 + repolog, err := us.Log(f.OwnerDid(), f.RepoName, ref, page) 275 + if err != nil { 276 + log.Println("failed to reach knotserver", err) 277 + return 278 + } 279 + 280 + result, err := us.Tags(f.OwnerDid(), f.RepoName) 281 + if err != nil { 282 + log.Println("failed to reach knotserver", err) 283 + return 284 + } 285 + 286 + tagMap := make(map[string][]string) 287 + for _, tag := range result.Tags { 288 + hash := tag.Hash 289 + if tag.Tag != nil { 290 + hash = tag.Tag.Target.String() 291 + } 292 + tagMap[hash] = append(tagMap[hash], tag.Name) 293 + } 294 + 295 + user := rp.oauth.GetUser(r) 296 + rp.pages.RepoLog(w, pages.RepoLogParams{ 297 + LoggedInUser: user, 298 + TagMap: tagMap, 299 + RepoInfo: f.RepoInfo(user), 300 + RepoLogResponse: *repolog, 301 + EmailToDidOrHandle: EmailToDidOrHandle(rp, uniqueEmails(repolog.Commits)), 302 + }) 303 + return 304 + } 305 + 306 + func (rp *Repo) RepoDescriptionEdit(w http.ResponseWriter, r *http.Request) { 307 + f, err := rp.repoResolver.Resolve(r) 308 + if err != nil { 309 + log.Println("failed to get repo and knot", err) 310 + w.WriteHeader(http.StatusBadRequest) 311 + return 312 + } 313 + 314 + user := rp.oauth.GetUser(r) 315 + rp.pages.EditRepoDescriptionFragment(w, pages.RepoDescriptionParams{ 316 + RepoInfo: f.RepoInfo(user), 317 + }) 318 + return 319 + } 320 + 321 + func (rp *Repo) RepoDescription(w http.ResponseWriter, r *http.Request) { 322 + f, err := rp.repoResolver.Resolve(r) 323 + if err != nil { 324 + log.Println("failed to get repo and knot", err) 325 + w.WriteHeader(http.StatusBadRequest) 326 + return 327 + } 328 + 329 + repoAt := f.RepoAt 330 + rkey := repoAt.RecordKey().String() 331 + if rkey == "" { 332 + log.Println("invalid aturi for repo", err) 333 + w.WriteHeader(http.StatusInternalServerError) 334 + return 335 + } 336 + 337 + user := rp.oauth.GetUser(r) 338 + 339 + switch r.Method { 340 + case http.MethodGet: 341 + rp.pages.RepoDescriptionFragment(w, pages.RepoDescriptionParams{ 342 + RepoInfo: f.RepoInfo(user), 343 + }) 344 + return 345 + case http.MethodPut: 346 + user := rp.oauth.GetUser(r) 347 + newDescription := r.FormValue("description") 348 + client, err := rp.oauth.AuthorizedClient(r) 349 + if err != nil { 350 + log.Println("failed to get client") 351 + rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 352 + return 353 + } 354 + 355 + // optimistic update 356 + err = db.UpdateDescription(rp.db, string(repoAt), newDescription) 357 + if err != nil { 358 + log.Println("failed to perferom update-description query", err) 359 + rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 360 + return 361 + } 362 + 363 + // this is a bit of a pain because the golang atproto impl does not allow nil SwapRecord field 364 + // 365 + // SwapRecord is optional and should happen automagically, but given that it does not, we have to perform two requests 366 + ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, user.Did, rkey) 367 + if err != nil { 368 + // failed to get record 369 + rp.pages.Notice(w, "repo-notice", "Failed to update description, no record found on PDS.") 370 + return 371 + } 372 + _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 373 + Collection: tangled.RepoNSID, 374 + Repo: user.Did, 375 + Rkey: rkey, 376 + SwapRecord: ex.Cid, 377 + Record: &lexutil.LexiconTypeDecoder{ 378 + Val: &tangled.Repo{ 379 + Knot: f.Knot, 380 + Name: f.RepoName, 381 + Owner: user.Did, 382 + CreatedAt: f.CreatedAt, 383 + Description: &newDescription, 384 + }, 385 + }, 386 + }) 387 + 388 + if err != nil { 389 + log.Println("failed to perferom update-description query", err) 390 + // failed to get record 391 + rp.pages.Notice(w, "repo-notice", "Failed to update description, unable to save to PDS.") 392 + return 393 + } 394 + 395 + newRepoInfo := f.RepoInfo(user) 396 + newRepoInfo.Description = newDescription 397 + 398 + rp.pages.RepoDescriptionFragment(w, pages.RepoDescriptionParams{ 399 + RepoInfo: newRepoInfo, 400 + }) 401 + return 402 + } 403 + } 404 + 405 + func (rp *Repo) RepoCommit(w http.ResponseWriter, r *http.Request) { 406 + f, err := rp.repoResolver.Resolve(r) 407 + if err != nil { 408 + log.Println("failed to fully resolve repo", err) 409 + return 410 + } 411 + ref := chi.URLParam(r, "ref") 412 + protocol := "http" 413 + if !rp.config.Core.Dev { 414 + protocol = "https" 415 + } 416 + 417 + if !plumbing.IsHash(ref) { 418 + rp.pages.Error404(w) 419 + return 420 + } 421 + 422 + resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/commit/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref)) 423 + if err != nil { 424 + log.Println("failed to reach knotserver", err) 425 + return 426 + } 427 + 428 + body, err := io.ReadAll(resp.Body) 429 + if err != nil { 430 + log.Printf("Error reading response body: %v", err) 431 + return 432 + } 433 + 434 + var result types.RepoCommitResponse 435 + err = json.Unmarshal(body, &result) 436 + if err != nil { 437 + log.Println("failed to parse response:", err) 438 + return 439 + } 440 + 441 + user := rp.oauth.GetUser(r) 442 + rp.pages.RepoCommit(w, pages.RepoCommitParams{ 443 + LoggedInUser: user, 444 + RepoInfo: f.RepoInfo(user), 445 + RepoCommitResponse: result, 446 + EmailToDidOrHandle: EmailToDidOrHandle(rp, []string{result.Diff.Commit.Author.Email}), 447 + }) 448 + return 449 + } 450 + 451 + func (rp *Repo) RepoTree(w http.ResponseWriter, r *http.Request) { 452 + f, err := rp.repoResolver.Resolve(r) 453 + if err != nil { 454 + log.Println("failed to fully resolve repo", err) 455 + return 456 + } 457 + 458 + ref := chi.URLParam(r, "ref") 459 + treePath := chi.URLParam(r, "*") 460 + protocol := "http" 461 + if !rp.config.Core.Dev { 462 + protocol = "https" 463 + } 464 + resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/tree/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, treePath)) 465 + if err != nil { 466 + log.Println("failed to reach knotserver", err) 467 + return 468 + } 469 + 470 + body, err := io.ReadAll(resp.Body) 471 + if err != nil { 472 + log.Printf("Error reading response body: %v", err) 473 + return 474 + } 475 + 476 + var result types.RepoTreeResponse 477 + err = json.Unmarshal(body, &result) 478 + if err != nil { 479 + log.Println("failed to parse response:", err) 480 + return 481 + } 482 + 483 + // redirects tree paths trying to access a blob; in this case the result.Files is unpopulated, 484 + // so we can safely redirect to the "parent" (which is the same file). 485 + if len(result.Files) == 0 && result.Parent == treePath { 486 + http.Redirect(w, r, fmt.Sprintf("/%s/blob/%s/%s", f.OwnerSlashRepo(), ref, result.Parent), http.StatusFound) 487 + return 488 + } 489 + 490 + user := rp.oauth.GetUser(r) 491 + 492 + var breadcrumbs [][]string 493 + breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)}) 494 + if treePath != "" { 495 + for idx, elem := range strings.Split(treePath, "/") { 496 + breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)}) 497 + } 498 + } 499 + 500 + baseTreeLink := path.Join(f.OwnerSlashRepo(), "tree", ref, treePath) 501 + baseBlobLink := path.Join(f.OwnerSlashRepo(), "blob", ref, treePath) 502 + 503 + rp.pages.RepoTree(w, pages.RepoTreeParams{ 504 + LoggedInUser: user, 505 + BreadCrumbs: breadcrumbs, 506 + BaseTreeLink: baseTreeLink, 507 + BaseBlobLink: baseBlobLink, 508 + RepoInfo: f.RepoInfo(user), 509 + RepoTreeResponse: result, 510 + }) 511 + return 512 + } 513 + 514 + func (rp *Repo) RepoTags(w http.ResponseWriter, r *http.Request) { 515 + f, err := rp.repoResolver.Resolve(r) 516 + if err != nil { 517 + log.Println("failed to get repo and knot", err) 518 + return 519 + } 520 + 521 + us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 522 + if err != nil { 523 + log.Println("failed to create unsigned client", err) 524 + return 525 + } 526 + 527 + result, err := us.Tags(f.OwnerDid(), f.RepoName) 528 + if err != nil { 529 + log.Println("failed to reach knotserver", err) 530 + return 531 + } 532 + 533 + artifacts, err := db.GetArtifact(rp.db, db.FilterEq("repo_at", f.RepoAt)) 534 + if err != nil { 535 + log.Println("failed grab artifacts", err) 536 + return 537 + } 538 + 539 + // convert artifacts to map for easy UI building 540 + artifactMap := make(map[plumbing.Hash][]db.Artifact) 541 + for _, a := range artifacts { 542 + artifactMap[a.Tag] = append(artifactMap[a.Tag], a) 543 + } 544 + 545 + var danglingArtifacts []db.Artifact 546 + for _, a := range artifacts { 547 + found := false 548 + for _, t := range result.Tags { 549 + if t.Tag != nil { 550 + if t.Tag.Hash == a.Tag { 551 + found = true 552 + } 553 + } 554 + } 555 + 556 + if !found { 557 + danglingArtifacts = append(danglingArtifacts, a) 558 + } 559 + } 560 + 561 + user := rp.oauth.GetUser(r) 562 + rp.pages.RepoTags(w, pages.RepoTagsParams{ 563 + LoggedInUser: user, 564 + RepoInfo: f.RepoInfo(user), 565 + RepoTagsResponse: *result, 566 + ArtifactMap: artifactMap, 567 + DanglingArtifacts: danglingArtifacts, 568 + }) 569 + return 570 + } 571 + 572 + func (rp *Repo) RepoBranches(w http.ResponseWriter, r *http.Request) { 573 + f, err := rp.repoResolver.Resolve(r) 574 + if err != nil { 575 + log.Println("failed to get repo and knot", err) 576 + return 577 + } 578 + 579 + us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 580 + if err != nil { 581 + log.Println("failed to create unsigned client", err) 582 + return 583 + } 584 + 585 + result, err := us.Branches(f.OwnerDid(), f.RepoName) 586 + if err != nil { 587 + log.Println("failed to reach knotserver", err) 588 + return 589 + } 590 + 591 + slices.SortFunc(result.Branches, func(a, b types.Branch) int { 592 + if a.IsDefault { 593 + return -1 594 + } 595 + if b.IsDefault { 596 + return 1 597 + } 598 + if a.Commit != nil { 599 + if a.Commit.Committer.When.Before(b.Commit.Committer.When) { 600 + return 1 601 + } else { 602 + return -1 603 + } 604 + } 605 + return strings.Compare(a.Name, b.Name) * -1 606 + }) 607 + 608 + user := rp.oauth.GetUser(r) 609 + rp.pages.RepoBranches(w, pages.RepoBranchesParams{ 610 + LoggedInUser: user, 611 + RepoInfo: f.RepoInfo(user), 612 + RepoBranchesResponse: *result, 613 + }) 614 + return 615 + } 616 + 617 + func (rp *Repo) RepoBlob(w http.ResponseWriter, r *http.Request) { 618 + f, err := rp.repoResolver.Resolve(r) 619 + if err != nil { 620 + log.Println("failed to get repo and knot", err) 621 + return 622 + } 623 + 624 + ref := chi.URLParam(r, "ref") 625 + filePath := chi.URLParam(r, "*") 626 + protocol := "http" 627 + if !rp.config.Core.Dev { 628 + protocol = "https" 629 + } 630 + resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath)) 631 + if err != nil { 632 + log.Println("failed to reach knotserver", err) 633 + return 634 + } 635 + 636 + body, err := io.ReadAll(resp.Body) 637 + if err != nil { 638 + log.Printf("Error reading response body: %v", err) 639 + return 640 + } 641 + 642 + var result types.RepoBlobResponse 643 + err = json.Unmarshal(body, &result) 644 + if err != nil { 645 + log.Println("failed to parse response:", err) 646 + return 647 + } 648 + 649 + var breadcrumbs [][]string 650 + breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)}) 651 + if filePath != "" { 652 + for idx, elem := range strings.Split(filePath, "/") { 653 + breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)}) 654 + } 655 + } 656 + 657 + showRendered := false 658 + renderToggle := false 659 + 660 + if markup.GetFormat(result.Path) == markup.FormatMarkdown { 661 + renderToggle = true 662 + showRendered = r.URL.Query().Get("code") != "true" 663 + } 664 + 665 + user := rp.oauth.GetUser(r) 666 + rp.pages.RepoBlob(w, pages.RepoBlobParams{ 667 + LoggedInUser: user, 668 + RepoInfo: f.RepoInfo(user), 669 + RepoBlobResponse: result, 670 + BreadCrumbs: breadcrumbs, 671 + ShowRendered: showRendered, 672 + RenderToggle: renderToggle, 673 + }) 674 + return 675 + } 676 + 677 + func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) { 678 + f, err := rp.repoResolver.Resolve(r) 679 + if err != nil { 680 + log.Println("failed to get repo and knot", err) 681 + return 682 + } 683 + 684 + ref := chi.URLParam(r, "ref") 685 + filePath := chi.URLParam(r, "*") 686 + 687 + protocol := "http" 688 + if !rp.config.Core.Dev { 689 + protocol = "https" 690 + } 691 + resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath)) 692 + if err != nil { 693 + log.Println("failed to reach knotserver", err) 694 + return 695 + } 696 + 697 + body, err := io.ReadAll(resp.Body) 698 + if err != nil { 699 + log.Printf("Error reading response body: %v", err) 700 + return 701 + } 702 + 703 + var result types.RepoBlobResponse 704 + err = json.Unmarshal(body, &result) 705 + if err != nil { 706 + log.Println("failed to parse response:", err) 707 + return 708 + } 709 + 710 + if result.IsBinary { 711 + w.Header().Set("Content-Type", "application/octet-stream") 712 + w.Write(body) 713 + return 714 + } 715 + 716 + w.Header().Set("Content-Type", "text/plain") 717 + w.Write([]byte(result.Contents)) 718 + return 719 + } 720 + 721 + func (rp *Repo) AddCollaborator(w http.ResponseWriter, r *http.Request) { 722 + f, err := rp.repoResolver.Resolve(r) 723 + if err != nil { 724 + log.Println("failed to get repo and knot", err) 725 + return 726 + } 727 + 728 + collaborator := r.FormValue("collaborator") 729 + if collaborator == "" { 730 + http.Error(w, "malformed form", http.StatusBadRequest) 731 + return 732 + } 733 + 734 + collaboratorIdent, err := rp.idResolver.ResolveIdent(r.Context(), collaborator) 735 + if err != nil { 736 + w.Write([]byte("failed to resolve collaborator did to a handle")) 737 + return 738 + } 739 + log.Printf("adding %s to %s\n", collaboratorIdent.Handle.String(), f.Knot) 740 + 741 + // TODO: create an atproto record for this 742 + 743 + secret, err := db.GetRegistrationKey(rp.db, f.Knot) 744 + if err != nil { 745 + log.Printf("no key found for domain %s: %s\n", f.Knot, err) 746 + return 747 + } 748 + 749 + ksClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev) 750 + if err != nil { 751 + log.Println("failed to create client to ", f.Knot) 752 + return 753 + } 754 + 755 + ksResp, err := ksClient.AddCollaborator(f.OwnerDid(), f.RepoName, collaboratorIdent.DID.String()) 756 + if err != nil { 757 + log.Printf("failed to make request to %s: %s", f.Knot, err) 758 + return 759 + } 760 + 761 + if ksResp.StatusCode != http.StatusNoContent { 762 + w.Write([]byte(fmt.Sprint("knotserver failed to add collaborator: ", err))) 763 + return 764 + } 765 + 766 + tx, err := rp.db.BeginTx(r.Context(), nil) 767 + if err != nil { 768 + log.Println("failed to start tx") 769 + w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err))) 770 + return 771 + } 772 + defer func() { 773 + tx.Rollback() 774 + err = rp.enforcer.E.LoadPolicy() 775 + if err != nil { 776 + log.Println("failed to rollback policies") 777 + } 778 + }() 779 + 780 + err = rp.enforcer.AddCollaborator(collaboratorIdent.DID.String(), f.Knot, f.DidSlashRepo()) 781 + if err != nil { 782 + w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err))) 783 + return 784 + } 785 + 786 + err = db.AddCollaborator(rp.db, collaboratorIdent.DID.String(), f.OwnerDid(), f.RepoName, f.Knot) 787 + if err != nil { 788 + w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err))) 789 + return 790 + } 791 + 792 + err = tx.Commit() 793 + if err != nil { 794 + log.Println("failed to commit changes", err) 795 + http.Error(w, err.Error(), http.StatusInternalServerError) 796 + return 797 + } 798 + 799 + err = rp.enforcer.E.SavePolicy() 800 + if err != nil { 801 + log.Println("failed to update ACLs", err) 802 + http.Error(w, err.Error(), http.StatusInternalServerError) 803 + return 804 + } 805 + 806 + w.Write([]byte(fmt.Sprint("added collaborator: ", collaboratorIdent.Handle.String()))) 807 + 808 + } 809 + 810 + func (rp *Repo) DeleteRepo(w http.ResponseWriter, r *http.Request) { 811 + user := rp.oauth.GetUser(r) 812 + 813 + f, err := rp.repoResolver.Resolve(r) 814 + if err != nil { 815 + log.Println("failed to get repo and knot", err) 816 + return 817 + } 818 + 819 + // remove record from pds 820 + xrpcClient, err := rp.oauth.AuthorizedClient(r) 821 + if err != nil { 822 + log.Println("failed to get authorized client", err) 823 + return 824 + } 825 + repoRkey := f.RepoAt.RecordKey().String() 826 + _, err = xrpcClient.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 827 + Collection: tangled.RepoNSID, 828 + Repo: user.Did, 829 + Rkey: repoRkey, 830 + }) 831 + if err != nil { 832 + log.Printf("failed to delete record: %s", err) 833 + rp.pages.Notice(w, "settings-delete", "Failed to delete repository from PDS.") 834 + return 835 + } 836 + log.Println("removed repo record ", f.RepoAt.String()) 837 + 838 + secret, err := db.GetRegistrationKey(rp.db, f.Knot) 839 + if err != nil { 840 + log.Printf("no key found for domain %s: %s\n", f.Knot, err) 841 + return 842 + } 843 + 844 + ksClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev) 845 + if err != nil { 846 + log.Println("failed to create client to ", f.Knot) 847 + return 848 + } 849 + 850 + ksResp, err := ksClient.RemoveRepo(f.OwnerDid(), f.RepoName) 851 + if err != nil { 852 + log.Printf("failed to make request to %s: %s", f.Knot, err) 853 + return 854 + } 855 + 856 + if ksResp.StatusCode != http.StatusNoContent { 857 + log.Println("failed to remove repo from knot, continuing anyway ", f.Knot) 858 + } else { 859 + log.Println("removed repo from knot ", f.Knot) 860 + } 861 + 862 + tx, err := rp.db.BeginTx(r.Context(), nil) 863 + if err != nil { 864 + log.Println("failed to start tx") 865 + w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err))) 866 + return 867 + } 868 + defer func() { 869 + tx.Rollback() 870 + err = rp.enforcer.E.LoadPolicy() 871 + if err != nil { 872 + log.Println("failed to rollback policies") 873 + } 874 + }() 875 + 876 + // remove collaborator RBAC 877 + repoCollaborators, err := rp.enforcer.E.GetImplicitUsersForResourceByDomain(f.DidSlashRepo(), f.Knot) 878 + if err != nil { 879 + rp.pages.Notice(w, "settings-delete", "Failed to remove collaborators") 880 + return 881 + } 882 + for _, c := range repoCollaborators { 883 + did := c[0] 884 + rp.enforcer.RemoveCollaborator(did, f.Knot, f.DidSlashRepo()) 885 + } 886 + log.Println("removed collaborators") 887 + 888 + // remove repo RBAC 889 + err = rp.enforcer.RemoveRepo(f.OwnerDid(), f.Knot, f.DidSlashRepo()) 890 + if err != nil { 891 + rp.pages.Notice(w, "settings-delete", "Failed to update RBAC rules") 892 + return 893 + } 894 + 895 + // remove repo from db 896 + err = db.RemoveRepo(tx, f.OwnerDid(), f.RepoName) 897 + if err != nil { 898 + rp.pages.Notice(w, "settings-delete", "Failed to update appview") 899 + return 900 + } 901 + log.Println("removed repo from db") 902 + 903 + err = tx.Commit() 904 + if err != nil { 905 + log.Println("failed to commit changes", err) 906 + http.Error(w, err.Error(), http.StatusInternalServerError) 907 + return 908 + } 909 + 910 + err = rp.enforcer.E.SavePolicy() 911 + if err != nil { 912 + log.Println("failed to update ACLs", err) 913 + http.Error(w, err.Error(), http.StatusInternalServerError) 914 + return 915 + } 916 + 917 + rp.pages.HxRedirect(w, fmt.Sprintf("/%s", f.OwnerDid())) 918 + } 919 + 920 + func (rp *Repo) SetDefaultBranch(w http.ResponseWriter, r *http.Request) { 921 + f, err := rp.repoResolver.Resolve(r) 922 + if err != nil { 923 + log.Println("failed to get repo and knot", err) 924 + return 925 + } 926 + 927 + branch := r.FormValue("branch") 928 + if branch == "" { 929 + http.Error(w, "malformed form", http.StatusBadRequest) 930 + return 931 + } 932 + 933 + secret, err := db.GetRegistrationKey(rp.db, f.Knot) 934 + if err != nil { 935 + log.Printf("no key found for domain %s: %s\n", f.Knot, err) 936 + return 937 + } 938 + 939 + ksClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev) 940 + if err != nil { 941 + log.Println("failed to create client to ", f.Knot) 942 + return 943 + } 944 + 945 + ksResp, err := ksClient.SetDefaultBranch(f.OwnerDid(), f.RepoName, branch) 946 + if err != nil { 947 + log.Printf("failed to make request to %s: %s", f.Knot, err) 948 + return 949 + } 950 + 951 + if ksResp.StatusCode != http.StatusNoContent { 952 + rp.pages.Notice(w, "repo-settings", "Failed to set default branch. Try again later.") 953 + return 954 + } 955 + 956 + w.Write([]byte(fmt.Sprint("default branch set to: ", branch))) 957 + } 958 + 959 + func (rp *Repo) RepoSettings(w http.ResponseWriter, r *http.Request) { 960 + f, err := rp.repoResolver.Resolve(r) 961 + if err != nil { 962 + log.Println("failed to get repo and knot", err) 963 + return 964 + } 965 + 966 + switch r.Method { 967 + case http.MethodGet: 968 + // for now, this is just pubkeys 969 + user := rp.oauth.GetUser(r) 970 + repoCollaborators, err := f.Collaborators(r.Context()) 971 + if err != nil { 972 + log.Println("failed to get collaborators", err) 973 + } 974 + 975 + isCollaboratorInviteAllowed := false 976 + if user != nil { 977 + ok, err := rp.enforcer.IsCollaboratorInviteAllowed(user.Did, f.Knot, f.DidSlashRepo()) 978 + if err == nil && ok { 979 + isCollaboratorInviteAllowed = true 980 + } 981 + } 982 + 983 + us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 984 + if err != nil { 985 + log.Println("failed to create unsigned client", err) 986 + return 987 + } 988 + 989 + result, err := us.Branches(f.OwnerDid(), f.RepoName) 990 + if err != nil { 991 + log.Println("failed to reach knotserver", err) 992 + return 993 + } 994 + 995 + rp.pages.RepoSettings(w, pages.RepoSettingsParams{ 996 + LoggedInUser: user, 997 + RepoInfo: f.RepoInfo(user), 998 + Collaborators: repoCollaborators, 999 + IsCollaboratorInviteAllowed: isCollaboratorInviteAllowed, 1000 + Branches: result.Branches, 1001 + }) 1002 + } 1003 + } 1004 + 1005 + func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) { 1006 + user := rp.oauth.GetUser(r) 1007 + f, err := rp.repoResolver.Resolve(r) 1008 + if err != nil { 1009 + log.Printf("failed to resolve source repo: %v", err) 1010 + return 1011 + } 1012 + 1013 + switch r.Method { 1014 + case http.MethodPost: 1015 + secret, err := db.GetRegistrationKey(rp.db, f.Knot) 1016 + if err != nil { 1017 + rp.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %rp.", f.Knot)) 1018 + return 1019 + } 1020 + 1021 + client, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev) 1022 + if err != nil { 1023 + rp.pages.Notice(w, "repo", "Failed to reach knot server.") 1024 + return 1025 + } 1026 + 1027 + var uri string 1028 + if rp.config.Core.Dev { 1029 + uri = "http" 1030 + } else { 1031 + uri = "https" 1032 + } 1033 + forkName := fmt.Sprintf("%s", f.RepoName) 1034 + forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.RepoName) 1035 + 1036 + _, err = client.SyncRepoFork(user.Did, forkSourceUrl, forkName, f.Ref) 1037 + if err != nil { 1038 + rp.pages.Notice(w, "repo", "Failed to sync repository fork.") 1039 + return 1040 + } 1041 + 1042 + rp.pages.HxRefresh(w) 1043 + return 1044 + } 1045 + } 1046 + 1047 + func (rp *Repo) ForkRepo(w http.ResponseWriter, r *http.Request) { 1048 + user := rp.oauth.GetUser(r) 1049 + f, err := rp.repoResolver.Resolve(r) 1050 + if err != nil { 1051 + log.Printf("failed to resolve source repo: %v", err) 1052 + return 1053 + } 1054 + 1055 + switch r.Method { 1056 + case http.MethodGet: 1057 + user := rp.oauth.GetUser(r) 1058 + knots, err := rp.enforcer.GetDomainsForUser(user.Did) 1059 + if err != nil { 1060 + rp.pages.Notice(w, "repo", "Invalid user account.") 1061 + return 1062 + } 1063 + 1064 + rp.pages.ForkRepo(w, pages.ForkRepoParams{ 1065 + LoggedInUser: user, 1066 + Knots: knots, 1067 + RepoInfo: f.RepoInfo(user), 1068 + }) 1069 + 1070 + case http.MethodPost: 1071 + 1072 + knot := r.FormValue("knot") 1073 + if knot == "" { 1074 + rp.pages.Notice(w, "repo", "Invalid form submission&mdash;missing knot domain.") 1075 + return 1076 + } 1077 + 1078 + ok, err := rp.enforcer.E.Enforce(user.Did, knot, knot, "repo:create") 1079 + if err != nil || !ok { 1080 + rp.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.") 1081 + return 1082 + } 1083 + 1084 + forkName := fmt.Sprintf("%s", f.RepoName) 1085 + 1086 + // this check is *only* to see if the forked repo name already exists 1087 + // in the user's account. 1088 + existingRepo, err := db.GetRepo(rp.db, user.Did, f.RepoName) 1089 + if err != nil { 1090 + if errors.Is(err, sql.ErrNoRows) { 1091 + // no existing repo with this name found, we can use the name as is 1092 + } else { 1093 + log.Println("error fetching existing repo from db", err) 1094 + rp.pages.Notice(w, "repo", "Failed to fork this repository. Try again later.") 1095 + return 1096 + } 1097 + } else if existingRepo != nil { 1098 + // repo with this name already exists, append random string 1099 + forkName = fmt.Sprintf("%s-%s", forkName, randomString(3)) 1100 + } 1101 + secret, err := db.GetRegistrationKey(rp.db, knot) 1102 + if err != nil { 1103 + rp.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %rp.", knot)) 1104 + return 1105 + } 1106 + 1107 + client, err := knotclient.NewSignedClient(knot, secret, rp.config.Core.Dev) 1108 + if err != nil { 1109 + rp.pages.Notice(w, "repo", "Failed to reach knot server.") 1110 + return 1111 + } 1112 + 1113 + var uri string 1114 + if rp.config.Core.Dev { 1115 + uri = "http" 1116 + } else { 1117 + uri = "https" 1118 + } 1119 + forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.RepoName) 1120 + sourceAt := f.RepoAt.String() 1121 + 1122 + rkey := appview.TID() 1123 + repo := &db.Repo{ 1124 + Did: user.Did, 1125 + Name: forkName, 1126 + Knot: knot, 1127 + Rkey: rkey, 1128 + Source: sourceAt, 1129 + } 1130 + 1131 + tx, err := rp.db.BeginTx(r.Context(), nil) 1132 + if err != nil { 1133 + log.Println(err) 1134 + rp.pages.Notice(w, "repo", "Failed to save repository information.") 1135 + return 1136 + } 1137 + defer func() { 1138 + tx.Rollback() 1139 + err = rp.enforcer.E.LoadPolicy() 1140 + if err != nil { 1141 + log.Println("failed to rollback policies") 1142 + } 1143 + }() 1144 + 1145 + resp, err := client.ForkRepo(user.Did, forkSourceUrl, forkName) 1146 + if err != nil { 1147 + rp.pages.Notice(w, "repo", "Failed to create repository on knot server.") 1148 + return 1149 + } 1150 + 1151 + switch resp.StatusCode { 1152 + case http.StatusConflict: 1153 + rp.pages.Notice(w, "repo", "A repository with that name already exists.") 1154 + return 1155 + case http.StatusInternalServerError: 1156 + rp.pages.Notice(w, "repo", "Failed to create repository on knot. Try again later.") 1157 + case http.StatusNoContent: 1158 + // continue 1159 + } 1160 + 1161 + xrpcClient, err := rp.oauth.AuthorizedClient(r) 1162 + if err != nil { 1163 + log.Println("failed to get authorized client", err) 1164 + rp.pages.Notice(w, "repo", "Failed to create repository.") 1165 + return 1166 + } 1167 + 1168 + createdAt := time.Now().Format(time.RFC3339) 1169 + atresp, err := xrpcClient.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1170 + Collection: tangled.RepoNSID, 1171 + Repo: user.Did, 1172 + Rkey: rkey, 1173 + Record: &lexutil.LexiconTypeDecoder{ 1174 + Val: &tangled.Repo{ 1175 + Knot: repo.Knot, 1176 + Name: repo.Name, 1177 + CreatedAt: createdAt, 1178 + Owner: user.Did, 1179 + Source: &sourceAt, 1180 + }}, 1181 + }) 1182 + if err != nil { 1183 + log.Printf("failed to create record: %s", err) 1184 + rp.pages.Notice(w, "repo", "Failed to announce repository creation.") 1185 + return 1186 + } 1187 + log.Println("created repo record: ", atresp.Uri) 1188 + 1189 + repo.AtUri = atresp.Uri 1190 + err = db.AddRepo(tx, repo) 1191 + if err != nil { 1192 + log.Println(err) 1193 + rp.pages.Notice(w, "repo", "Failed to save repository information.") 1194 + return 1195 + } 1196 + 1197 + // acls 1198 + p, _ := securejoin.SecureJoin(user.Did, forkName) 1199 + err = rp.enforcer.AddRepo(user.Did, knot, p) 1200 + if err != nil { 1201 + log.Println(err) 1202 + rp.pages.Notice(w, "repo", "Failed to set up repository permissions.") 1203 + return 1204 + } 1205 + 1206 + err = tx.Commit() 1207 + if err != nil { 1208 + log.Println("failed to commit changes", err) 1209 + http.Error(w, err.Error(), http.StatusInternalServerError) 1210 + return 1211 + } 1212 + 1213 + err = rp.enforcer.E.SavePolicy() 1214 + if err != nil { 1215 + log.Println("failed to update ACLs", err) 1216 + http.Error(w, err.Error(), http.StatusInternalServerError) 1217 + return 1218 + } 1219 + 1220 + rp.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, forkName)) 1221 + return 1222 + } 1223 + } 1224 + 1225 + func (rp *Repo) RepoCompareNew(w http.ResponseWriter, r *http.Request) { 1226 + user := rp.oauth.GetUser(r) 1227 + f, err := rp.repoResolver.Resolve(r) 1228 + if err != nil { 1229 + log.Println("failed to get repo and knot", err) 1230 + return 1231 + } 1232 + 1233 + us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 1234 + if err != nil { 1235 + log.Printf("failed to create unsigned client for %s", f.Knot) 1236 + rp.pages.Error503(w) 1237 + return 1238 + } 1239 + 1240 + result, err := us.Branches(f.OwnerDid(), f.RepoName) 1241 + if err != nil { 1242 + rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1243 + log.Println("failed to reach knotserver", err) 1244 + return 1245 + } 1246 + branches := result.Branches 1247 + sort.Slice(branches, func(i int, j int) bool { 1248 + return branches[i].Commit.Committer.When.After(branches[j].Commit.Committer.When) 1249 + }) 1250 + 1251 + var defaultBranch string 1252 + for _, b := range branches { 1253 + if b.IsDefault { 1254 + defaultBranch = b.Name 1255 + } 1256 + } 1257 + 1258 + base := defaultBranch 1259 + head := defaultBranch 1260 + 1261 + params := r.URL.Query() 1262 + queryBase := params.Get("base") 1263 + queryHead := params.Get("head") 1264 + if queryBase != "" { 1265 + base = queryBase 1266 + } 1267 + if queryHead != "" { 1268 + head = queryHead 1269 + } 1270 + 1271 + tags, err := us.Tags(f.OwnerDid(), f.RepoName) 1272 + if err != nil { 1273 + rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1274 + log.Println("failed to reach knotserver", err) 1275 + return 1276 + } 1277 + 1278 + repoinfo := f.RepoInfo(user) 1279 + 1280 + rp.pages.RepoCompareNew(w, pages.RepoCompareNewParams{ 1281 + LoggedInUser: user, 1282 + RepoInfo: repoinfo, 1283 + Branches: branches, 1284 + Tags: tags.Tags, 1285 + Base: base, 1286 + Head: head, 1287 + }) 1288 + } 1289 + 1290 + func (rp *Repo) RepoCompare(w http.ResponseWriter, r *http.Request) { 1291 + user := rp.oauth.GetUser(r) 1292 + f, err := rp.repoResolver.Resolve(r) 1293 + if err != nil { 1294 + log.Println("failed to get repo and knot", err) 1295 + return 1296 + } 1297 + 1298 + // if user is navigating to one of 1299 + // /compare/{base}/{head} 1300 + // /compare/{base}...{head} 1301 + base := chi.URLParam(r, "base") 1302 + head := chi.URLParam(r, "head") 1303 + if base == "" && head == "" { 1304 + rest := chi.URLParam(r, "*") // master...feature/xyz 1305 + parts := strings.SplitN(rest, "...", 2) 1306 + if len(parts) == 2 { 1307 + base = parts[0] 1308 + head = parts[1] 1309 + } 1310 + } 1311 + 1312 + if base == "" || head == "" { 1313 + log.Printf("invalid comparison") 1314 + rp.pages.Error404(w) 1315 + return 1316 + } 1317 + 1318 + us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev) 1319 + if err != nil { 1320 + log.Printf("failed to create unsigned client for %s", f.Knot) 1321 + rp.pages.Error503(w) 1322 + return 1323 + } 1324 + 1325 + branches, err := us.Branches(f.OwnerDid(), f.RepoName) 1326 + if err != nil { 1327 + rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1328 + log.Println("failed to reach knotserver", err) 1329 + return 1330 + } 1331 + 1332 + tags, err := us.Tags(f.OwnerDid(), f.RepoName) 1333 + if err != nil { 1334 + rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1335 + log.Println("failed to reach knotserver", err) 1336 + return 1337 + } 1338 + 1339 + formatPatch, err := us.Compare(f.OwnerDid(), f.RepoName, base, head) 1340 + if err != nil { 1341 + rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.") 1342 + log.Println("failed to compare", err) 1343 + return 1344 + } 1345 + diff := patchutil.AsNiceDiff(formatPatch.Patch, base) 1346 + 1347 + repoinfo := f.RepoInfo(user) 1348 + 1349 + rp.pages.RepoCompare(w, pages.RepoCompareParams{ 1350 + LoggedInUser: user, 1351 + RepoInfo: repoinfo, 1352 + Branches: branches.Branches, 1353 + Tags: tags.Tags, 1354 + Base: base, 1355 + Head: head, 1356 + Diff: &diff, 1357 + }) 1358 + 1359 + }
+102
appview/repo/repo_util.go
··· 1 + package repo 2 + 3 + import ( 4 + "context" 5 + "crypto/rand" 6 + "fmt" 7 + "log" 8 + "math/big" 9 + 10 + "github.com/go-git/go-git/v5/plumbing/object" 11 + "tangled.sh/tangled.sh/core/appview/db" 12 + ) 13 + 14 + func uniqueEmails(commits []*object.Commit) []string { 15 + emails := make(map[string]struct{}) 16 + for _, commit := range commits { 17 + if commit.Author.Email != "" { 18 + emails[commit.Author.Email] = struct{}{} 19 + } 20 + if commit.Committer.Email != "" { 21 + emails[commit.Committer.Email] = struct{}{} 22 + } 23 + } 24 + var uniqueEmails []string 25 + for email := range emails { 26 + uniqueEmails = append(uniqueEmails, email) 27 + } 28 + return uniqueEmails 29 + } 30 + 31 + func balanceIndexItems(commitCount, branchCount, tagCount, fileCount int) (commitsTrunc int, branchesTrunc int, tagsTrunc int) { 32 + if commitCount == 0 && tagCount == 0 && branchCount == 0 { 33 + return 34 + } 35 + 36 + // typically 1 item on right side = 2 files in height 37 + availableSpace := fileCount / 2 38 + 39 + // clamp tagcount 40 + if tagCount > 0 { 41 + tagsTrunc = 1 42 + availableSpace -= 1 // an extra subtracted for headers etc. 43 + } 44 + 45 + // clamp branchcount 46 + if branchCount > 0 { 47 + branchesTrunc = min(max(branchCount, 1), 2) 48 + availableSpace -= branchesTrunc // an extra subtracted for headers etc. 49 + } 50 + 51 + // show 52 + if commitCount > 0 { 53 + commitsTrunc = max(availableSpace, 3) 54 + } 55 + 56 + return 57 + } 58 + 59 + func EmailToDidOrHandle(r *Repo, emails []string) map[string]string { 60 + emailToDid, err := db.GetEmailToDid(r.db, emails, true) // only get verified emails for mapping 61 + if err != nil { 62 + log.Printf("error fetching dids for emails: %v", err) 63 + return nil 64 + } 65 + 66 + var dids []string 67 + for _, v := range emailToDid { 68 + dids = append(dids, v) 69 + } 70 + resolvedIdents := r.idResolver.ResolveIdents(context.Background(), dids) 71 + 72 + didHandleMap := make(map[string]string) 73 + for _, identity := range resolvedIdents { 74 + if !identity.Handle.IsInvalidHandle() { 75 + didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 76 + } else { 77 + didHandleMap[identity.DID.String()] = identity.DID.String() 78 + } 79 + } 80 + 81 + // Create map of email to didOrHandle for commit display 82 + emailToDidOrHandle := make(map[string]string) 83 + for email, did := range emailToDid { 84 + if didOrHandle, ok := didHandleMap[did]; ok { 85 + emailToDidOrHandle[email] = didOrHandle 86 + } 87 + } 88 + 89 + return emailToDidOrHandle 90 + } 91 + 92 + func randomString(n int) string { 93 + const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" 94 + result := make([]byte, n) 95 + 96 + for i := 0; i < n; i++ { 97 + n, _ := rand.Int(rand.Reader, big.NewInt(int64(len(letters)))) 98 + result[i] = letters[n.Int64()] 99 + } 100 + 101 + return string(result) 102 + }
+80
appview/repo/router.go
··· 1 + package repo 2 + 3 + import ( 4 + "net/http" 5 + 6 + "github.com/go-chi/chi/v5" 7 + "tangled.sh/tangled.sh/core/appview/middleware" 8 + ) 9 + 10 + func (rp *Repo) Router(mw *middleware.Middleware) http.Handler { 11 + r := chi.NewRouter() 12 + r.Get("/", rp.RepoIndex) 13 + r.Get("/commits/{ref}", rp.RepoLog) 14 + r.Route("/tree/{ref}", func(r chi.Router) { 15 + r.Get("/", rp.RepoIndex) 16 + r.Get("/*", rp.RepoTree) 17 + }) 18 + r.Get("/commit/{ref}", rp.RepoCommit) 19 + r.Get("/branches", rp.RepoBranches) 20 + r.Route("/tags", func(r chi.Router) { 21 + r.Get("/", rp.RepoTags) 22 + r.Route("/{tag}", func(r chi.Router) { 23 + r.Use(middleware.AuthMiddleware(rp.oauth)) 24 + // require auth to download for now 25 + r.Get("/download/{file}", rp.DownloadArtifact) 26 + 27 + // require repo:push to upload or delete artifacts 28 + // 29 + // additionally: only the uploader can truly delete an artifact 30 + // (record+blob will live on their pds) 31 + r.Group(func(r chi.Router) { 32 + r.With(mw.RepoPermissionMiddleware("repo:push")) 33 + r.Post("/upload", rp.AttachArtifact) 34 + r.Delete("/{file}", rp.DeleteArtifact) 35 + }) 36 + }) 37 + }) 38 + r.Get("/blob/{ref}/*", rp.RepoBlob) 39 + r.Get("/raw/{ref}/*", rp.RepoBlobRaw) 40 + 41 + r.Route("/fork", func(r chi.Router) { 42 + r.Use(middleware.AuthMiddleware(rp.oauth)) 43 + r.Get("/", rp.ForkRepo) 44 + r.Post("/", rp.ForkRepo) 45 + r.With(mw.RepoPermissionMiddleware("repo:owner")).Route("/sync", func(r chi.Router) { 46 + r.Post("/", rp.SyncRepoFork) 47 + }) 48 + }) 49 + 50 + r.Route("/compare", func(r chi.Router) { 51 + r.Get("/", rp.RepoCompareNew) // start an new comparison 52 + 53 + // we have to wildcard here since we want to support GitHub's compare syntax 54 + // /compare/{ref1}...{ref2} 55 + // for example: 56 + // /compare/master...some/feature 57 + // /compare/master...example.com:another/feature <- this is a fork 58 + r.Get("/{base}/{head}", rp.RepoCompare) 59 + r.Get("/*", rp.RepoCompare) 60 + }) 61 + 62 + // settings routes, needs auth 63 + r.Group(func(r chi.Router) { 64 + r.Use(middleware.AuthMiddleware(rp.oauth)) 65 + // repo description can only be edited by owner 66 + r.With(mw.RepoPermissionMiddleware("repo:owner")).Route("/description", func(r chi.Router) { 67 + r.Put("/", rp.RepoDescription) 68 + r.Get("/", rp.RepoDescription) 69 + r.Get("/edit", rp.RepoDescriptionEdit) 70 + }) 71 + r.With(mw.RepoPermissionMiddleware("repo:settings")).Route("/settings", func(r chi.Router) { 72 + r.Get("/", rp.RepoSettings) 73 + r.With(mw.RepoPermissionMiddleware("repo:invite")).Put("/collaborator", rp.AddCollaborator) 74 + r.With(mw.RepoPermissionMiddleware("repo:delete")).Delete("/delete", rp.DeleteRepo) 75 + r.Put("/branches/default", rp.SetDefaultBranch) 76 + }) 77 + }) 78 + 79 + return r 80 + }
+301
appview/reporesolver/resolver.go
··· 1 + package reporesolver 2 + 3 + import ( 4 + "context" 5 + "database/sql" 6 + "errors" 7 + "fmt" 8 + "log" 9 + "net/http" 10 + "net/url" 11 + "path" 12 + "strings" 13 + 14 + "github.com/bluesky-social/indigo/atproto/identity" 15 + "github.com/bluesky-social/indigo/atproto/syntax" 16 + securejoin "github.com/cyphar/filepath-securejoin" 17 + "github.com/go-chi/chi/v5" 18 + "tangled.sh/tangled.sh/core/appview/config" 19 + "tangled.sh/tangled.sh/core/appview/db" 20 + "tangled.sh/tangled.sh/core/appview/idresolver" 21 + "tangled.sh/tangled.sh/core/appview/oauth" 22 + "tangled.sh/tangled.sh/core/appview/pages" 23 + "tangled.sh/tangled.sh/core/appview/pages/repoinfo" 24 + "tangled.sh/tangled.sh/core/knotclient" 25 + "tangled.sh/tangled.sh/core/rbac" 26 + ) 27 + 28 + type ResolvedRepo struct { 29 + Knot string 30 + OwnerId identity.Identity 31 + RepoName string 32 + RepoAt syntax.ATURI 33 + Description string 34 + CreatedAt string 35 + Ref string 36 + CurrentDir string 37 + 38 + rr *RepoResolver 39 + } 40 + 41 + type RepoResolver struct { 42 + config *config.Config 43 + enforcer *rbac.Enforcer 44 + idResolver *idresolver.Resolver 45 + execer db.Execer 46 + } 47 + 48 + func New(config *config.Config, enforcer *rbac.Enforcer, resolver *idresolver.Resolver, execer db.Execer) *RepoResolver { 49 + return &RepoResolver{config: config, enforcer: enforcer, idResolver: resolver, execer: execer} 50 + } 51 + 52 + func (rr *RepoResolver) Resolve(r *http.Request) (*ResolvedRepo, error) { 53 + repoName := chi.URLParam(r, "repo") 54 + knot, ok := r.Context().Value("knot").(string) 55 + if !ok { 56 + log.Println("malformed middleware") 57 + return nil, fmt.Errorf("malformed middleware") 58 + } 59 + id, ok := r.Context().Value("resolvedId").(identity.Identity) 60 + if !ok { 61 + log.Println("malformed middleware") 62 + return nil, fmt.Errorf("malformed middleware") 63 + } 64 + 65 + repoAt, ok := r.Context().Value("repoAt").(string) 66 + if !ok { 67 + log.Println("malformed middleware") 68 + return nil, fmt.Errorf("malformed middleware") 69 + } 70 + 71 + parsedRepoAt, err := syntax.ParseATURI(repoAt) 72 + if err != nil { 73 + log.Println("malformed repo at-uri") 74 + return nil, fmt.Errorf("malformed middleware") 75 + } 76 + 77 + ref := chi.URLParam(r, "ref") 78 + 79 + if ref == "" { 80 + us, err := knotclient.NewUnsignedClient(knot, rr.config.Core.Dev) 81 + if err != nil { 82 + return nil, err 83 + } 84 + 85 + defaultBranch, err := us.DefaultBranch(id.DID.String(), repoName) 86 + if err != nil { 87 + return nil, err 88 + } 89 + 90 + ref = defaultBranch.Branch 91 + } 92 + 93 + currentDir := path.Dir(extractPathAfterRef(r.URL.EscapedPath(), ref)) 94 + 95 + // pass through values from the middleware 96 + description, ok := r.Context().Value("repoDescription").(string) 97 + addedAt, ok := r.Context().Value("repoAddedAt").(string) 98 + 99 + return &ResolvedRepo{ 100 + Knot: knot, 101 + OwnerId: id, 102 + RepoName: repoName, 103 + RepoAt: parsedRepoAt, 104 + Description: description, 105 + CreatedAt: addedAt, 106 + Ref: ref, 107 + CurrentDir: currentDir, 108 + 109 + rr: rr, 110 + }, nil 111 + } 112 + 113 + func (f *ResolvedRepo) OwnerDid() string { 114 + return f.OwnerId.DID.String() 115 + } 116 + 117 + func (f *ResolvedRepo) OwnerHandle() string { 118 + return f.OwnerId.Handle.String() 119 + } 120 + 121 + func (f *ResolvedRepo) OwnerSlashRepo() string { 122 + handle := f.OwnerId.Handle 123 + 124 + var p string 125 + if handle != "" && !handle.IsInvalidHandle() { 126 + p, _ = securejoin.SecureJoin(fmt.Sprintf("@%s", handle), f.RepoName) 127 + } else { 128 + p, _ = securejoin.SecureJoin(f.OwnerDid(), f.RepoName) 129 + } 130 + 131 + return p 132 + } 133 + 134 + func (f *ResolvedRepo) DidSlashRepo() string { 135 + p, _ := securejoin.SecureJoin(f.OwnerDid(), f.RepoName) 136 + return p 137 + } 138 + 139 + func (f *ResolvedRepo) Collaborators(ctx context.Context) ([]pages.Collaborator, error) { 140 + repoCollaborators, err := f.rr.enforcer.E.GetImplicitUsersForResourceByDomain(f.DidSlashRepo(), f.Knot) 141 + if err != nil { 142 + return nil, err 143 + } 144 + 145 + var collaborators []pages.Collaborator 146 + for _, item := range repoCollaborators { 147 + // currently only two roles: owner and member 148 + var role string 149 + if item[3] == "repo:owner" { 150 + role = "owner" 151 + } else if item[3] == "repo:collaborator" { 152 + role = "collaborator" 153 + } else { 154 + continue 155 + } 156 + 157 + did := item[0] 158 + 159 + c := pages.Collaborator{ 160 + Did: did, 161 + Handle: "", 162 + Role: role, 163 + } 164 + collaborators = append(collaborators, c) 165 + } 166 + 167 + // populate all collborators with handles 168 + identsToResolve := make([]string, len(collaborators)) 169 + for i, collab := range collaborators { 170 + identsToResolve[i] = collab.Did 171 + } 172 + 173 + resolvedIdents := f.rr.idResolver.ResolveIdents(ctx, identsToResolve) 174 + for i, resolved := range resolvedIdents { 175 + if resolved != nil { 176 + collaborators[i].Handle = resolved.Handle.String() 177 + } 178 + } 179 + 180 + return collaborators, nil 181 + } 182 + 183 + // this function is a bit weird since it now returns RepoInfo from an entirely different 184 + // package. we should refactor this or get rid of RepoInfo entirely. 185 + func (f *ResolvedRepo) RepoInfo(user *oauth.User) repoinfo.RepoInfo { 186 + isStarred := false 187 + if user != nil { 188 + isStarred = db.GetStarStatus(f.rr.execer, user.Did, syntax.ATURI(f.RepoAt)) 189 + } 190 + 191 + starCount, err := db.GetStarCount(f.rr.execer, f.RepoAt) 192 + if err != nil { 193 + log.Println("failed to get star count for ", f.RepoAt) 194 + } 195 + issueCount, err := db.GetIssueCount(f.rr.execer, f.RepoAt) 196 + if err != nil { 197 + log.Println("failed to get issue count for ", f.RepoAt) 198 + } 199 + pullCount, err := db.GetPullCount(f.rr.execer, f.RepoAt) 200 + if err != nil { 201 + log.Println("failed to get issue count for ", f.RepoAt) 202 + } 203 + source, err := db.GetRepoSource(f.rr.execer, f.RepoAt) 204 + if errors.Is(err, sql.ErrNoRows) { 205 + source = "" 206 + } else if err != nil { 207 + log.Println("failed to get repo source for ", f.RepoAt, err) 208 + } 209 + 210 + var sourceRepo *db.Repo 211 + if source != "" { 212 + sourceRepo, err = db.GetRepoByAtUri(f.rr.execer, source) 213 + if err != nil { 214 + log.Println("failed to get repo by at uri", err) 215 + } 216 + } 217 + 218 + var sourceHandle *identity.Identity 219 + if sourceRepo != nil { 220 + sourceHandle, err = f.rr.idResolver.ResolveIdent(context.Background(), sourceRepo.Did) 221 + if err != nil { 222 + log.Println("failed to resolve source repo", err) 223 + } 224 + } 225 + 226 + knot := f.Knot 227 + var disableFork bool 228 + us, err := knotclient.NewUnsignedClient(knot, f.rr.config.Core.Dev) 229 + if err != nil { 230 + log.Printf("failed to create unsigned client for %s: %v", knot, err) 231 + } else { 232 + result, err := us.Branches(f.OwnerDid(), f.RepoName) 233 + if err != nil { 234 + log.Printf("failed to get branches for %s/%s: %v", f.OwnerDid(), f.RepoName, err) 235 + } 236 + 237 + if len(result.Branches) == 0 { 238 + disableFork = true 239 + } 240 + } 241 + 242 + repoInfo := repoinfo.RepoInfo{ 243 + OwnerDid: f.OwnerDid(), 244 + OwnerHandle: f.OwnerHandle(), 245 + Name: f.RepoName, 246 + RepoAt: f.RepoAt, 247 + Description: f.Description, 248 + Ref: f.Ref, 249 + IsStarred: isStarred, 250 + Knot: knot, 251 + Roles: f.RolesInRepo(user), 252 + Stats: db.RepoStats{ 253 + StarCount: starCount, 254 + IssueCount: issueCount, 255 + PullCount: pullCount, 256 + }, 257 + DisableFork: disableFork, 258 + CurrentDir: f.CurrentDir, 259 + } 260 + 261 + if sourceRepo != nil { 262 + repoInfo.Source = sourceRepo 263 + repoInfo.SourceHandle = sourceHandle.Handle.String() 264 + } 265 + 266 + return repoInfo 267 + } 268 + 269 + func (f *ResolvedRepo) RolesInRepo(u *oauth.User) repoinfo.RolesInRepo { 270 + if u != nil { 271 + r := f.rr.enforcer.GetPermissionsInRepo(u.Did, f.Knot, f.DidSlashRepo()) 272 + return repoinfo.RolesInRepo{r} 273 + } else { 274 + return repoinfo.RolesInRepo{} 275 + } 276 + } 277 + 278 + // extractPathAfterRef gets the actual repository path 279 + // after the ref. for example: 280 + // 281 + // /@icyphox.sh/foorepo/blob/main/abc/xyz/ => abc/xyz/ 282 + func extractPathAfterRef(fullPath, ref string) string { 283 + fullPath = strings.TrimPrefix(fullPath, "/") 284 + 285 + ref = url.PathEscape(ref) 286 + 287 + prefixes := []string{ 288 + fmt.Sprintf("blob/%s/", ref), 289 + fmt.Sprintf("tree/%s/", ref), 290 + fmt.Sprintf("raw/%s/", ref), 291 + } 292 + 293 + for _, prefix := range prefixes { 294 + idx := strings.Index(fullPath, prefix) 295 + if idx != -1 { 296 + return fullPath[idx+len(prefix):] 297 + } 298 + } 299 + 300 + return "" 301 + }
-56
appview/resolver.go
··· 1 - package appview 2 - 3 - import ( 4 - "context" 5 - "sync" 6 - 7 - "github.com/bluesky-social/indigo/atproto/identity" 8 - "github.com/bluesky-social/indigo/atproto/syntax" 9 - ) 10 - 11 - type Resolver struct { 12 - directory identity.Directory 13 - } 14 - 15 - func NewResolver() *Resolver { 16 - return &Resolver{ 17 - directory: identity.DefaultDirectory(), 18 - } 19 - } 20 - 21 - func (r *Resolver) ResolveIdent(ctx context.Context, arg string) (*identity.Identity, error) { 22 - id, err := syntax.ParseAtIdentifier(arg) 23 - if err != nil { 24 - return nil, err 25 - } 26 - 27 - return r.directory.Lookup(ctx, *id) 28 - } 29 - 30 - func (r *Resolver) ResolveIdents(ctx context.Context, idents []string) []*identity.Identity { 31 - results := make([]*identity.Identity, len(idents)) 32 - var wg sync.WaitGroup 33 - 34 - done := make(chan struct{}) 35 - defer close(done) 36 - 37 - for idx, ident := range idents { 38 - wg.Add(1) 39 - go func(index int, id string) { 40 - defer wg.Done() 41 - 42 - select { 43 - case <-ctx.Done(): 44 - results[index] = nil 45 - case <-done: 46 - results[index] = nil 47 - default: 48 - identity, _ := r.ResolveIdent(ctx, id) 49 - results[index] = identity 50 - } 51 - }(idx, ident) 52 - } 53 - 54 - wg.Wait() 55 - return results 56 - }
+2 -1
appview/settings/settings.go
··· 13 13 "github.com/go-chi/chi/v5" 14 14 "tangled.sh/tangled.sh/core/api/tangled" 15 15 "tangled.sh/tangled.sh/core/appview" 16 + "tangled.sh/tangled.sh/core/appview/config" 16 17 "tangled.sh/tangled.sh/core/appview/db" 17 18 "tangled.sh/tangled.sh/core/appview/email" 18 19 "tangled.sh/tangled.sh/core/appview/middleware" ··· 29 30 Db *db.DB 30 31 OAuth *oauth.OAuth 31 32 Pages *pages.Pages 32 - Config *appview.Config 33 + Config *config.Config 33 34 } 34 35 35 36 func (s *Settings) Router() http.Handler {
-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/pages" 20 - "tangled.sh/tangled.sh/core/knotclient" 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.FilterEq("repo_at", f.RepoAt), 158 - db.FilterEq("tag", tag.Tag.Hash[:]), 159 - db.FilterEq("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.FilterEq("repo_at", f.RepoAt), 200 - db.FilterEq("tag", tag[:]), 201 - db.FilterEq("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.FilterEq("repo_at", f.RepoAt), 242 - db.FilterEq("tag", artifact.Tag[:]), 243 - db.FilterEq("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 - }
+1 -1
appview/state/follow.go
··· 23 23 return 24 24 } 25 25 26 - subjectIdent, err := s.resolver.ResolveIdent(r.Context(), subject) 26 + subjectIdent, err := s.idResolver.ResolveIdent(r.Context(), subject) 27 27 if err != nil { 28 28 log.Println("failed to follow, invalid did") 29 29 }
-226
appview/state/middleware.go
··· 1 - package state 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") 28 - http.Error(w, "Forbiden", http.StatusUnauthorized) 29 - return 30 - } 31 - domain := chi.URLParam(r, "domain") 32 - if domain == "" { 33 - http.Error(w, "malformed url", http.StatusBadRequest) 34 - return 35 - } 36 - 37 - ok, err := s.enforcer.E.HasGroupingPolicy(actor.Did, group, domain) 38 - if err != nil || !ok { 39 - // we need a logged in user 40 - log.Printf("%s does not have perms of a %s in domain %s", actor.Did, group, domain) 41 - http.Error(w, "Forbiden", http.StatusUnauthorized) 42 - return 43 - } 44 - 45 - next.ServeHTTP(w, r) 46 - }) 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()) 75 - http.Error(w, "Forbiden", http.StatusUnauthorized) 76 - return 77 - } 78 - 79 - next.ServeHTTP(w, r) 80 - }) 81 - } 82 - } 83 - 84 - func StripLeadingAt(next http.Handler) http.Handler { 85 - return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 86 - path := req.URL.EscapedPath() 87 - if strings.HasPrefix(path, "/@") { 88 - req.URL.RawPath = "/" + strings.TrimPrefix(path, "/@") 89 - } 90 - next.ServeHTTP(w, req) 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 { 107 - // invalid did or handle 108 - log.Println("failed to resolve did/handle:", err) 109 - w.WriteHeader(http.StatusNotFound) 110 - return 111 - } 112 - 113 - ctx := context.WithValue(req.Context(), "resolvedId", *id) 114 - 115 - next.ServeHTTP(w, req.WithContext(ctx)) 116 - }) 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") 124 - id, ok := req.Context().Value("resolvedId").(identity.Identity) 125 - if !ok { 126 - log.Println("malformed middleware") 127 - w.WriteHeader(http.StatusInternalServerError) 128 - return 129 - } 130 - 131 - repo, err := db.GetRepo(s.db, id.DID.String(), repoName) 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 - 139 - ctx := context.WithValue(req.Context(), "knot", repo.Knot) 140 - ctx = context.WithValue(ctx, "repoAt", repo.AtUri) 141 - ctx = context.WithValue(ctx, "repoDescription", repo.Description) 142 - ctx = context.WithValue(ctx, "repoAddedAt", repo.Created.Format(time.RFC3339)) 143 - next.ServeHTTP(w, req.WithContext(ctx)) 144 - }) 145 - } 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) 156 - return 157 - } 158 - 159 - prId := chi.URLParam(r, "pull") 160 - prIdInt, err := strconv.Atoi(prId) 161 - if err != nil { 162 - http.Error(w, "bad pr id", http.StatusBadRequest) 163 - log.Println("failed to parse pr id", err) 164 - return 165 - } 166 - 167 - pr, err := db.GetPull(s.db, f.RepoAt, prIdInt) 168 - if err != nil { 169 - log.Println("failed to get pull and comments", err) 170 - return 171 - } 172 - 173 - ctx := context.WithValue(r.Context(), "pull", pr) 174 - 175 - if pr.IsStacked() { 176 - stack, err := db.GetStack(s.db, pr.StackId) 177 - if err != nil { 178 - log.Println("failed to get stack", err) 179 - return 180 - } 181 - abandonedPulls, err := db.GetAbandonedPulls(s.db, pr.StackId) 182 - if err != nil { 183 - log.Println("failed to get abandoned pulls", err) 184 - return 185 - } 186 - 187 - ctx = context.WithValue(ctx, "stack", stack) 188 - ctx = context.WithValue(ctx, "abandonedPulls", abandonedPulls) 189 - } 190 - 191 - next.ServeHTTP(w, r.WithContext(ctx)) 192 - }) 193 - } 194 - } 195 - 196 - // this should serve the go-import meta tag even if the path is technically 197 - // a 404 like tangled.sh/oppi.li/go-git/v5 198 - func GoImport(s *State) middleware.Middleware { 199 - return func(next http.Handler) http.Handler { 200 - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 201 - f, err := s.fullyResolvedRepo(r) 202 - if err != nil { 203 - log.Println("failed to fully resolve repo", err) 204 - http.Error(w, "invalid repo url", http.StatusNotFound) 205 - return 206 - } 207 - 208 - fullName := f.OwnerHandle() + "/" + f.RepoName 209 - 210 - if r.Header.Get("User-Agent") == "Go-http-client/1.1" { 211 - if r.URL.Query().Get("go-get") == "1" { 212 - html := fmt.Sprintf( 213 - `<meta name="go-import" content="tangled.sh/%s git https://tangled.sh/%s"/>`, 214 - fullName, 215 - fullName, 216 - ) 217 - w.Header().Set("Content-Type", "text/html") 218 - w.Write([]byte(html)) 219 - return 220 - } 221 - } 222 - 223 - next.ServeHTTP(w, r) 224 - }) 225 - } 226 - }
+2 -2
appview/state/profile.go
··· 105 105 } 106 106 } 107 107 108 - resolvedIds := s.resolver.ResolveIdents(r.Context(), didsToResolve) 108 + resolvedIds := s.idResolver.ResolveIdents(r.Context(), didsToResolve) 109 109 didHandleMap := make(map[string]string) 110 110 for _, identity := range resolvedIds { 111 111 if !identity.Handle.IsInvalidHandle() { ··· 415 415 for _, r := range allRepos { 416 416 didsToResolve = append(didsToResolve, r.Did) 417 417 } 418 - resolvedIds := s.resolver.ResolveIdents(r.Context(), didsToResolve) 418 + resolvedIds := s.idResolver.ResolveIdents(r.Context(), didsToResolve) 419 419 didHandleMap := make(map[string]string) 420 420 for _, identity := range resolvedIds { 421 421 if !identity.Handle.IsInvalidHandle() {
-2079
appview/state/pull.go
··· 1 - package state 2 - 3 - import ( 4 - "database/sql" 5 - "encoding/json" 6 - "errors" 7 - "fmt" 8 - "io" 9 - "log" 10 - "net/http" 11 - "sort" 12 - "strconv" 13 - "strings" 14 - "time" 15 - 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/oauth" 20 - "tangled.sh/tangled.sh/core/appview/pages" 21 - "tangled.sh/tangled.sh/core/knotclient" 22 - "tangled.sh/tangled.sh/core/patchutil" 23 - "tangled.sh/tangled.sh/core/types" 24 - 25 - "github.com/bluekeyes/go-gitdiff/gitdiff" 26 - comatproto "github.com/bluesky-social/indigo/api/atproto" 27 - "github.com/bluesky-social/indigo/atproto/syntax" 28 - lexutil "github.com/bluesky-social/indigo/lex/util" 29 - "github.com/go-chi/chi/v5" 30 - "github.com/google/uuid" 31 - "github.com/posthog/posthog-go" 32 - ) 33 - 34 - // htmx fragment 35 - func (s *State) PullActions(w http.ResponseWriter, r *http.Request) { 36 - switch r.Method { 37 - case http.MethodGet: 38 - user := s.oauth.GetUser(r) 39 - f, err := s.fullyResolvedRepo(r) 40 - if err != nil { 41 - log.Println("failed to get repo and knot", err) 42 - return 43 - } 44 - 45 - pull, ok := r.Context().Value("pull").(*db.Pull) 46 - if !ok { 47 - log.Println("failed to get pull") 48 - s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 49 - return 50 - } 51 - 52 - // can be nil if this pull is not stacked 53 - stack, _ := r.Context().Value("stack").(db.Stack) 54 - 55 - roundNumberStr := chi.URLParam(r, "round") 56 - roundNumber, err := strconv.Atoi(roundNumberStr) 57 - if err != nil { 58 - roundNumber = pull.LastRoundNumber() 59 - } 60 - if roundNumber >= len(pull.Submissions) { 61 - http.Error(w, "bad round id", http.StatusBadRequest) 62 - log.Println("failed to parse round id", err) 63 - return 64 - } 65 - 66 - mergeCheckResponse := s.mergeCheck(f, pull, stack) 67 - resubmitResult := pages.Unknown 68 - if user.Did == pull.OwnerDid { 69 - resubmitResult = s.resubmitCheck(f, pull, stack) 70 - } 71 - 72 - s.pages.PullActionsFragment(w, pages.PullActionsParams{ 73 - LoggedInUser: user, 74 - RepoInfo: f.RepoInfo(s, user), 75 - Pull: pull, 76 - RoundNumber: roundNumber, 77 - MergeCheck: mergeCheckResponse, 78 - ResubmitCheck: resubmitResult, 79 - Stack: stack, 80 - }) 81 - return 82 - } 83 - } 84 - 85 - func (s *State) RepoSinglePull(w http.ResponseWriter, r *http.Request) { 86 - user := s.oauth.GetUser(r) 87 - f, err := s.fullyResolvedRepo(r) 88 - if err != nil { 89 - log.Println("failed to get repo and knot", err) 90 - return 91 - } 92 - 93 - pull, ok := r.Context().Value("pull").(*db.Pull) 94 - if !ok { 95 - log.Println("failed to get pull") 96 - s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 97 - return 98 - } 99 - 100 - // can be nil if this pull is not stacked 101 - stack, _ := r.Context().Value("stack").(db.Stack) 102 - abandonedPulls, _ := r.Context().Value("abandonedPulls").([]*db.Pull) 103 - 104 - totalIdents := 1 105 - for _, submission := range pull.Submissions { 106 - totalIdents += len(submission.Comments) 107 - } 108 - 109 - identsToResolve := make([]string, totalIdents) 110 - 111 - // populate idents 112 - identsToResolve[0] = pull.OwnerDid 113 - idx := 1 114 - for _, submission := range pull.Submissions { 115 - for _, comment := range submission.Comments { 116 - identsToResolve[idx] = comment.OwnerDid 117 - idx += 1 118 - } 119 - } 120 - 121 - resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve) 122 - didHandleMap := make(map[string]string) 123 - for _, identity := range resolvedIds { 124 - if !identity.Handle.IsInvalidHandle() { 125 - didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 126 - } else { 127 - didHandleMap[identity.DID.String()] = identity.DID.String() 128 - } 129 - } 130 - 131 - mergeCheckResponse := s.mergeCheck(f, pull, stack) 132 - resubmitResult := pages.Unknown 133 - if user != nil && user.Did == pull.OwnerDid { 134 - resubmitResult = s.resubmitCheck(f, pull, stack) 135 - } 136 - 137 - s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{ 138 - LoggedInUser: user, 139 - RepoInfo: f.RepoInfo(s, user), 140 - DidHandleMap: didHandleMap, 141 - Pull: pull, 142 - Stack: stack, 143 - AbandonedPulls: abandonedPulls, 144 - MergeCheck: mergeCheckResponse, 145 - ResubmitCheck: resubmitResult, 146 - }) 147 - } 148 - 149 - func (s *State) mergeCheck(f *FullyResolvedRepo, pull *db.Pull, stack db.Stack) types.MergeCheckResponse { 150 - if pull.State == db.PullMerged { 151 - return types.MergeCheckResponse{} 152 - } 153 - 154 - secret, err := db.GetRegistrationKey(s.db, f.Knot) 155 - if err != nil { 156 - log.Printf("failed to get registration key: %v", err) 157 - return types.MergeCheckResponse{ 158 - Error: "failed to check merge status: this knot is unregistered", 159 - } 160 - } 161 - 162 - ksClient, err := knotclient.NewSignedClient(f.Knot, secret, s.config.Core.Dev) 163 - if err != nil { 164 - log.Printf("failed to setup signed client for %s; ignoring: %v", f.Knot, err) 165 - return types.MergeCheckResponse{ 166 - Error: "failed to check merge status", 167 - } 168 - } 169 - 170 - patch := pull.LatestPatch() 171 - if pull.IsStacked() { 172 - // combine patches of substack 173 - subStack := stack.Below(pull) 174 - // collect the portion of the stack that is mergeable 175 - mergeable := subStack.Mergeable() 176 - // combine each patch 177 - patch = mergeable.CombinedPatch() 178 - } 179 - 180 - resp, err := ksClient.MergeCheck([]byte(patch), f.OwnerDid(), f.RepoName, pull.TargetBranch) 181 - if err != nil { 182 - log.Println("failed to check for mergeability:", err) 183 - return types.MergeCheckResponse{ 184 - Error: "failed to check merge status", 185 - } 186 - } 187 - switch resp.StatusCode { 188 - case 404: 189 - return types.MergeCheckResponse{ 190 - Error: "failed to check merge status: this knot does not support PRs", 191 - } 192 - case 400: 193 - return types.MergeCheckResponse{ 194 - Error: "failed to check merge status: does this knot support PRs?", 195 - } 196 - } 197 - 198 - respBody, err := io.ReadAll(resp.Body) 199 - if err != nil { 200 - log.Println("failed to read merge check response body") 201 - return types.MergeCheckResponse{ 202 - Error: "failed to check merge status: knot is not speaking the right language", 203 - } 204 - } 205 - defer resp.Body.Close() 206 - 207 - var mergeCheckResponse types.MergeCheckResponse 208 - err = json.Unmarshal(respBody, &mergeCheckResponse) 209 - if err != nil { 210 - log.Println("failed to unmarshal merge check response", err) 211 - return types.MergeCheckResponse{ 212 - Error: "failed to check merge status: knot is not speaking the right language", 213 - } 214 - } 215 - 216 - return mergeCheckResponse 217 - } 218 - 219 - func (s *State) resubmitCheck(f *FullyResolvedRepo, pull *db.Pull, stack db.Stack) pages.ResubmitResult { 220 - if pull.State == db.PullMerged || pull.State == db.PullDeleted || pull.PullSource == nil { 221 - return pages.Unknown 222 - } 223 - 224 - var knot, ownerDid, repoName string 225 - 226 - if pull.PullSource.RepoAt != nil { 227 - // fork-based pulls 228 - sourceRepo, err := db.GetRepoByAtUri(s.db, pull.PullSource.RepoAt.String()) 229 - if err != nil { 230 - log.Println("failed to get source repo", err) 231 - return pages.Unknown 232 - } 233 - 234 - knot = sourceRepo.Knot 235 - ownerDid = sourceRepo.Did 236 - repoName = sourceRepo.Name 237 - } else { 238 - // pulls within the same repo 239 - knot = f.Knot 240 - ownerDid = f.OwnerDid() 241 - repoName = f.RepoName 242 - } 243 - 244 - us, err := knotclient.NewUnsignedClient(knot, s.config.Core.Dev) 245 - if err != nil { 246 - log.Printf("failed to setup client for %s; ignoring: %v", knot, err) 247 - return pages.Unknown 248 - } 249 - 250 - result, err := us.Branch(ownerDid, repoName, pull.PullSource.Branch) 251 - if err != nil { 252 - log.Println("failed to reach knotserver", err) 253 - return pages.Unknown 254 - } 255 - 256 - latestSourceRev := pull.Submissions[pull.LastRoundNumber()].SourceRev 257 - 258 - if pull.IsStacked() && stack != nil { 259 - top := stack[0] 260 - latestSourceRev = top.Submissions[top.LastRoundNumber()].SourceRev 261 - } 262 - 263 - log.Println(latestSourceRev, result.Branch.Hash) 264 - 265 - if latestSourceRev != result.Branch.Hash { 266 - return pages.ShouldResubmit 267 - } 268 - 269 - return pages.ShouldNotResubmit 270 - } 271 - 272 - func (s *State) RepoPullPatch(w http.ResponseWriter, r *http.Request) { 273 - user := s.oauth.GetUser(r) 274 - f, err := s.fullyResolvedRepo(r) 275 - if err != nil { 276 - log.Println("failed to get repo and knot", err) 277 - return 278 - } 279 - 280 - pull, ok := r.Context().Value("pull").(*db.Pull) 281 - if !ok { 282 - log.Println("failed to get pull") 283 - s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 284 - return 285 - } 286 - 287 - stack, _ := r.Context().Value("stack").(db.Stack) 288 - 289 - roundId := chi.URLParam(r, "round") 290 - roundIdInt, err := strconv.Atoi(roundId) 291 - if err != nil || roundIdInt >= len(pull.Submissions) { 292 - http.Error(w, "bad round id", http.StatusBadRequest) 293 - log.Println("failed to parse round id", err) 294 - return 295 - } 296 - 297 - identsToResolve := []string{pull.OwnerDid} 298 - resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve) 299 - didHandleMap := make(map[string]string) 300 - for _, identity := range resolvedIds { 301 - if !identity.Handle.IsInvalidHandle() { 302 - didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 303 - } else { 304 - didHandleMap[identity.DID.String()] = identity.DID.String() 305 - } 306 - } 307 - 308 - diff := pull.Submissions[roundIdInt].AsNiceDiff(pull.TargetBranch) 309 - 310 - s.pages.RepoPullPatchPage(w, pages.RepoPullPatchParams{ 311 - LoggedInUser: user, 312 - DidHandleMap: didHandleMap, 313 - RepoInfo: f.RepoInfo(s, user), 314 - Pull: pull, 315 - Stack: stack, 316 - Round: roundIdInt, 317 - Submission: pull.Submissions[roundIdInt], 318 - Diff: &diff, 319 - }) 320 - 321 - } 322 - 323 - func (s *State) RepoPullInterdiff(w http.ResponseWriter, r *http.Request) { 324 - user := s.oauth.GetUser(r) 325 - 326 - f, err := s.fullyResolvedRepo(r) 327 - if err != nil { 328 - log.Println("failed to get repo and knot", err) 329 - return 330 - } 331 - 332 - pull, ok := r.Context().Value("pull").(*db.Pull) 333 - if !ok { 334 - log.Println("failed to get pull") 335 - s.pages.Notice(w, "pull-error", "Failed to get pull.") 336 - return 337 - } 338 - 339 - roundId := chi.URLParam(r, "round") 340 - roundIdInt, err := strconv.Atoi(roundId) 341 - if err != nil || roundIdInt >= len(pull.Submissions) { 342 - http.Error(w, "bad round id", http.StatusBadRequest) 343 - log.Println("failed to parse round id", err) 344 - return 345 - } 346 - 347 - if roundIdInt == 0 { 348 - http.Error(w, "bad round id", http.StatusBadRequest) 349 - log.Println("cannot interdiff initial submission") 350 - return 351 - } 352 - 353 - identsToResolve := []string{pull.OwnerDid} 354 - resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve) 355 - didHandleMap := make(map[string]string) 356 - for _, identity := range resolvedIds { 357 - if !identity.Handle.IsInvalidHandle() { 358 - didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 359 - } else { 360 - didHandleMap[identity.DID.String()] = identity.DID.String() 361 - } 362 - } 363 - 364 - currentPatch, err := pull.Submissions[roundIdInt].AsDiff(pull.TargetBranch) 365 - if err != nil { 366 - log.Println("failed to interdiff; current patch malformed") 367 - s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; current patch is invalid.") 368 - return 369 - } 370 - 371 - previousPatch, err := pull.Submissions[roundIdInt-1].AsDiff(pull.TargetBranch) 372 - if err != nil { 373 - log.Println("failed to interdiff; previous patch malformed") 374 - s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; previous patch is invalid.") 375 - return 376 - } 377 - 378 - interdiff := patchutil.Interdiff(previousPatch, currentPatch) 379 - 380 - s.pages.RepoPullInterdiffPage(w, pages.RepoPullInterdiffParams{ 381 - LoggedInUser: s.oauth.GetUser(r), 382 - RepoInfo: f.RepoInfo(s, user), 383 - Pull: pull, 384 - Round: roundIdInt, 385 - DidHandleMap: didHandleMap, 386 - Interdiff: interdiff, 387 - }) 388 - return 389 - } 390 - 391 - func (s *State) RepoPullPatchRaw(w http.ResponseWriter, r *http.Request) { 392 - pull, ok := r.Context().Value("pull").(*db.Pull) 393 - if !ok { 394 - log.Println("failed to get pull") 395 - s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 396 - return 397 - } 398 - 399 - roundId := chi.URLParam(r, "round") 400 - roundIdInt, err := strconv.Atoi(roundId) 401 - if err != nil || roundIdInt >= len(pull.Submissions) { 402 - http.Error(w, "bad round id", http.StatusBadRequest) 403 - log.Println("failed to parse round id", err) 404 - return 405 - } 406 - 407 - identsToResolve := []string{pull.OwnerDid} 408 - resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve) 409 - didHandleMap := make(map[string]string) 410 - for _, identity := range resolvedIds { 411 - if !identity.Handle.IsInvalidHandle() { 412 - didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 413 - } else { 414 - didHandleMap[identity.DID.String()] = identity.DID.String() 415 - } 416 - } 417 - 418 - w.Header().Set("Content-Type", "text/plain") 419 - w.Write([]byte(pull.Submissions[roundIdInt].Patch)) 420 - } 421 - 422 - func (s *State) RepoPulls(w http.ResponseWriter, r *http.Request) { 423 - user := s.oauth.GetUser(r) 424 - params := r.URL.Query() 425 - 426 - state := db.PullOpen 427 - switch params.Get("state") { 428 - case "closed": 429 - state = db.PullClosed 430 - case "merged": 431 - state = db.PullMerged 432 - } 433 - 434 - f, err := s.fullyResolvedRepo(r) 435 - if err != nil { 436 - log.Println("failed to get repo and knot", err) 437 - return 438 - } 439 - 440 - pulls, err := db.GetPulls( 441 - s.db, 442 - db.FilterEq("repo_at", f.RepoAt), 443 - db.FilterEq("state", state), 444 - ) 445 - if err != nil { 446 - log.Println("failed to get pulls", err) 447 - s.pages.Notice(w, "pulls", "Failed to load pulls. Try again later.") 448 - return 449 - } 450 - 451 - for _, p := range pulls { 452 - var pullSourceRepo *db.Repo 453 - if p.PullSource != nil { 454 - if p.PullSource.RepoAt != nil { 455 - pullSourceRepo, err = db.GetRepoByAtUri(s.db, p.PullSource.RepoAt.String()) 456 - if err != nil { 457 - log.Printf("failed to get repo by at uri: %v", err) 458 - continue 459 - } else { 460 - p.PullSource.Repo = pullSourceRepo 461 - } 462 - } 463 - } 464 - } 465 - 466 - identsToResolve := make([]string, len(pulls)) 467 - for i, pull := range pulls { 468 - identsToResolve[i] = pull.OwnerDid 469 - } 470 - resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve) 471 - didHandleMap := make(map[string]string) 472 - for _, identity := range resolvedIds { 473 - if !identity.Handle.IsInvalidHandle() { 474 - didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 475 - } else { 476 - didHandleMap[identity.DID.String()] = identity.DID.String() 477 - } 478 - } 479 - 480 - s.pages.RepoPulls(w, pages.RepoPullsParams{ 481 - LoggedInUser: s.oauth.GetUser(r), 482 - RepoInfo: f.RepoInfo(s, user), 483 - Pulls: pulls, 484 - DidHandleMap: didHandleMap, 485 - FilteringBy: state, 486 - }) 487 - return 488 - } 489 - 490 - func (s *State) PullComment(w http.ResponseWriter, r *http.Request) { 491 - user := s.oauth.GetUser(r) 492 - f, err := s.fullyResolvedRepo(r) 493 - if err != nil { 494 - log.Println("failed to get repo and knot", err) 495 - return 496 - } 497 - 498 - pull, ok := r.Context().Value("pull").(*db.Pull) 499 - if !ok { 500 - log.Println("failed to get pull") 501 - s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 502 - return 503 - } 504 - 505 - roundNumberStr := chi.URLParam(r, "round") 506 - roundNumber, err := strconv.Atoi(roundNumberStr) 507 - if err != nil || roundNumber >= len(pull.Submissions) { 508 - http.Error(w, "bad round id", http.StatusBadRequest) 509 - log.Println("failed to parse round id", err) 510 - return 511 - } 512 - 513 - switch r.Method { 514 - case http.MethodGet: 515 - s.pages.PullNewCommentFragment(w, pages.PullNewCommentParams{ 516 - LoggedInUser: user, 517 - RepoInfo: f.RepoInfo(s, user), 518 - Pull: pull, 519 - RoundNumber: roundNumber, 520 - }) 521 - return 522 - case http.MethodPost: 523 - body := r.FormValue("body") 524 - if body == "" { 525 - s.pages.Notice(w, "pull", "Comment body is required") 526 - return 527 - } 528 - 529 - // Start a transaction 530 - tx, err := s.db.BeginTx(r.Context(), nil) 531 - if err != nil { 532 - log.Println("failed to start transaction", err) 533 - s.pages.Notice(w, "pull-comment", "Failed to create comment.") 534 - return 535 - } 536 - defer tx.Rollback() 537 - 538 - createdAt := time.Now().Format(time.RFC3339) 539 - ownerDid := user.Did 540 - 541 - pullAt, err := db.GetPullAt(s.db, f.RepoAt, pull.PullId) 542 - if err != nil { 543 - log.Println("failed to get pull at", err) 544 - s.pages.Notice(w, "pull-comment", "Failed to create comment.") 545 - return 546 - } 547 - 548 - atUri := f.RepoAt.String() 549 - client, err := s.oauth.AuthorizedClient(r) 550 - if err != nil { 551 - log.Println("failed to get authorized client", err) 552 - s.pages.Notice(w, "pull-comment", "Failed to create comment.") 553 - return 554 - } 555 - atResp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 556 - Collection: tangled.RepoPullCommentNSID, 557 - Repo: user.Did, 558 - Rkey: appview.TID(), 559 - Record: &lexutil.LexiconTypeDecoder{ 560 - Val: &tangled.RepoPullComment{ 561 - Repo: &atUri, 562 - Pull: string(pullAt), 563 - Owner: &ownerDid, 564 - Body: body, 565 - CreatedAt: createdAt, 566 - }, 567 - }, 568 - }) 569 - if err != nil { 570 - log.Println("failed to create pull comment", err) 571 - s.pages.Notice(w, "pull-comment", "Failed to create comment.") 572 - return 573 - } 574 - 575 - // Create the pull comment in the database with the commentAt field 576 - commentId, err := db.NewPullComment(tx, &db.PullComment{ 577 - OwnerDid: user.Did, 578 - RepoAt: f.RepoAt.String(), 579 - PullId: pull.PullId, 580 - Body: body, 581 - CommentAt: atResp.Uri, 582 - SubmissionId: pull.Submissions[roundNumber].ID, 583 - }) 584 - if err != nil { 585 - log.Println("failed to create pull comment", err) 586 - s.pages.Notice(w, "pull-comment", "Failed to create comment.") 587 - return 588 - } 589 - 590 - // Commit the transaction 591 - if err = tx.Commit(); err != nil { 592 - log.Println("failed to commit transaction", err) 593 - s.pages.Notice(w, "pull-comment", "Failed to create comment.") 594 - return 595 - } 596 - 597 - if !s.config.Core.Dev { 598 - err = s.posthog.Enqueue(posthog.Capture{ 599 - DistinctId: user.Did, 600 - Event: "new_pull_comment", 601 - Properties: posthog.Properties{"repo_at": f.RepoAt.String(), "pull_id": pull.PullId}, 602 - }) 603 - if err != nil { 604 - log.Println("failed to enqueue posthog event:", err) 605 - } 606 - } 607 - 608 - s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", f.OwnerSlashRepo(), pull.PullId, commentId)) 609 - return 610 - } 611 - } 612 - 613 - func (s *State) NewPull(w http.ResponseWriter, r *http.Request) { 614 - user := s.oauth.GetUser(r) 615 - f, err := s.fullyResolvedRepo(r) 616 - if err != nil { 617 - log.Println("failed to get repo and knot", err) 618 - return 619 - } 620 - 621 - switch r.Method { 622 - case http.MethodGet: 623 - us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 624 - if err != nil { 625 - log.Printf("failed to create unsigned client for %s", f.Knot) 626 - s.pages.Error503(w) 627 - return 628 - } 629 - 630 - result, err := us.Branches(f.OwnerDid(), f.RepoName) 631 - if err != nil { 632 - log.Println("failed to reach knotserver", err) 633 - return 634 - } 635 - 636 - s.pages.RepoNewPull(w, pages.RepoNewPullParams{ 637 - LoggedInUser: user, 638 - RepoInfo: f.RepoInfo(s, user), 639 - Branches: result.Branches, 640 - }) 641 - 642 - case http.MethodPost: 643 - title := r.FormValue("title") 644 - body := r.FormValue("body") 645 - targetBranch := r.FormValue("targetBranch") 646 - fromFork := r.FormValue("fork") 647 - sourceBranch := r.FormValue("sourceBranch") 648 - patch := r.FormValue("patch") 649 - 650 - if targetBranch == "" { 651 - s.pages.Notice(w, "pull", "Target branch is required.") 652 - return 653 - } 654 - 655 - // Determine PR type based on input parameters 656 - isPushAllowed := f.RepoInfo(s, user).Roles.IsPushAllowed() 657 - isBranchBased := isPushAllowed && sourceBranch != "" && fromFork == "" 658 - isForkBased := fromFork != "" && sourceBranch != "" 659 - isPatchBased := patch != "" && !isBranchBased && !isForkBased 660 - isStacked := r.FormValue("isStacked") == "on" 661 - 662 - if isPatchBased && !patchutil.IsFormatPatch(patch) { 663 - if title == "" { 664 - s.pages.Notice(w, "pull", "Title is required for git-diff patches.") 665 - return 666 - } 667 - } 668 - 669 - // Validate we have at least one valid PR creation method 670 - if !isBranchBased && !isPatchBased && !isForkBased { 671 - s.pages.Notice(w, "pull", "Neither source branch nor patch supplied.") 672 - return 673 - } 674 - 675 - // Can't mix branch-based and patch-based approaches 676 - if isBranchBased && patch != "" { 677 - s.pages.Notice(w, "pull", "Cannot select both patch and source branch.") 678 - return 679 - } 680 - 681 - us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 682 - if err != nil { 683 - log.Printf("failed to create unsigned client to %s: %v", f.Knot, err) 684 - s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.") 685 - return 686 - } 687 - 688 - caps, err := us.Capabilities() 689 - if err != nil { 690 - log.Println("error fetching knot caps", f.Knot, err) 691 - s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.") 692 - return 693 - } 694 - 695 - if !caps.PullRequests.FormatPatch { 696 - s.pages.Notice(w, "pull", "This knot doesn't support format-patch. Unfortunately, there is no fallback for now.") 697 - return 698 - } 699 - 700 - // Handle the PR creation based on the type 701 - if isBranchBased { 702 - if !caps.PullRequests.BranchSubmissions { 703 - s.pages.Notice(w, "pull", "This knot doesn't support branch-based pull requests. Try another way?") 704 - return 705 - } 706 - s.handleBranchBasedPull(w, r, f, user, title, body, targetBranch, sourceBranch, isStacked) 707 - } else if isForkBased { 708 - if !caps.PullRequests.ForkSubmissions { 709 - s.pages.Notice(w, "pull", "This knot doesn't support fork-based pull requests. Try another way?") 710 - return 711 - } 712 - s.handleForkBasedPull(w, r, f, user, fromFork, title, body, targetBranch, sourceBranch, isStacked) 713 - } else if isPatchBased { 714 - if !caps.PullRequests.PatchSubmissions { 715 - s.pages.Notice(w, "pull", "This knot doesn't support patch-based pull requests. Send your patch over email.") 716 - return 717 - } 718 - s.handlePatchBasedPull(w, r, f, user, title, body, targetBranch, patch, isStacked) 719 - } 720 - return 721 - } 722 - } 723 - 724 - func (s *State) handleBranchBasedPull( 725 - w http.ResponseWriter, 726 - r *http.Request, 727 - f *FullyResolvedRepo, 728 - user *oauth.User, 729 - title, 730 - body, 731 - targetBranch, 732 - sourceBranch string, 733 - isStacked bool, 734 - ) { 735 - pullSource := &db.PullSource{ 736 - Branch: sourceBranch, 737 - } 738 - recordPullSource := &tangled.RepoPull_Source{ 739 - Branch: sourceBranch, 740 - } 741 - 742 - // Generate a patch using /compare 743 - ksClient, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 744 - if err != nil { 745 - log.Printf("failed to create signed client for %s: %s", f.Knot, err) 746 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 747 - return 748 - } 749 - 750 - comparison, err := ksClient.Compare(f.OwnerDid(), f.RepoName, targetBranch, sourceBranch) 751 - if err != nil { 752 - log.Println("failed to compare", err) 753 - s.pages.Notice(w, "pull", err.Error()) 754 - return 755 - } 756 - 757 - sourceRev := comparison.Rev2 758 - patch := comparison.Patch 759 - 760 - if !patchutil.IsPatchValid(patch) { 761 - s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 762 - return 763 - } 764 - 765 - s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, sourceRev, pullSource, recordPullSource, isStacked) 766 - } 767 - 768 - func (s *State) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, f *FullyResolvedRepo, user *oauth.User, title, body, targetBranch, patch string, isStacked bool) { 769 - if !patchutil.IsPatchValid(patch) { 770 - s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 771 - return 772 - } 773 - 774 - s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, "", nil, nil, isStacked) 775 - } 776 - 777 - func (s *State) handleForkBasedPull(w http.ResponseWriter, r *http.Request, f *FullyResolvedRepo, user *oauth.User, forkRepo string, title, body, targetBranch, sourceBranch string, isStacked bool) { 778 - fork, err := db.GetForkByDid(s.db, user.Did, forkRepo) 779 - if errors.Is(err, sql.ErrNoRows) { 780 - s.pages.Notice(w, "pull", "No such fork.") 781 - return 782 - } else if err != nil { 783 - log.Println("failed to fetch fork:", err) 784 - s.pages.Notice(w, "pull", "Failed to fetch fork.") 785 - return 786 - } 787 - 788 - secret, err := db.GetRegistrationKey(s.db, fork.Knot) 789 - if err != nil { 790 - log.Println("failed to fetch registration key:", err) 791 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 792 - return 793 - } 794 - 795 - sc, err := knotclient.NewSignedClient(fork.Knot, secret, s.config.Core.Dev) 796 - if err != nil { 797 - log.Println("failed to create signed client:", err) 798 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 799 - return 800 - } 801 - 802 - us, err := knotclient.NewUnsignedClient(fork.Knot, s.config.Core.Dev) 803 - if err != nil { 804 - log.Println("failed to create unsigned client:", err) 805 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 806 - return 807 - } 808 - 809 - resp, err := sc.NewHiddenRef(user.Did, fork.Name, sourceBranch, targetBranch) 810 - if err != nil { 811 - log.Println("failed to create hidden ref:", err, resp.StatusCode) 812 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 813 - return 814 - } 815 - 816 - switch resp.StatusCode { 817 - case 404: 818 - case 400: 819 - s.pages.Notice(w, "pull", "Branch based pull requests are not supported on this knot.") 820 - return 821 - } 822 - 823 - hiddenRef := fmt.Sprintf("hidden/%s/%s", sourceBranch, targetBranch) 824 - // We're now comparing the sourceBranch (on the fork) against the hiddenRef which is tracking 825 - // the targetBranch on the target repository. This code is a bit confusing, but here's an example: 826 - // hiddenRef: hidden/feature-1/main (on repo-fork) 827 - // targetBranch: main (on repo-1) 828 - // sourceBranch: feature-1 (on repo-fork) 829 - comparison, err := us.Compare(user.Did, fork.Name, hiddenRef, sourceBranch) 830 - if err != nil { 831 - log.Println("failed to compare across branches", err) 832 - s.pages.Notice(w, "pull", err.Error()) 833 - return 834 - } 835 - 836 - sourceRev := comparison.Rev2 837 - patch := comparison.Patch 838 - 839 - if !patchutil.IsPatchValid(patch) { 840 - s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 841 - return 842 - } 843 - 844 - forkAtUri, err := syntax.ParseATURI(fork.AtUri) 845 - if err != nil { 846 - log.Println("failed to parse fork AT URI", err) 847 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 848 - return 849 - } 850 - 851 - s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, sourceRev, &db.PullSource{ 852 - Branch: sourceBranch, 853 - RepoAt: &forkAtUri, 854 - }, &tangled.RepoPull_Source{Branch: sourceBranch, Repo: &fork.AtUri}, isStacked) 855 - } 856 - 857 - func (s *State) createPullRequest( 858 - w http.ResponseWriter, 859 - r *http.Request, 860 - f *FullyResolvedRepo, 861 - user *oauth.User, 862 - title, body, targetBranch string, 863 - patch string, 864 - sourceRev string, 865 - pullSource *db.PullSource, 866 - recordPullSource *tangled.RepoPull_Source, 867 - isStacked bool, 868 - ) { 869 - if isStacked { 870 - // creates a series of PRs, each linking to the previous, identified by jj's change-id 871 - s.createStackedPulLRequest( 872 - w, 873 - r, 874 - f, 875 - user, 876 - targetBranch, 877 - patch, 878 - sourceRev, 879 - pullSource, 880 - ) 881 - return 882 - } 883 - 884 - client, err := s.oauth.AuthorizedClient(r) 885 - if err != nil { 886 - log.Println("failed to get authorized client", err) 887 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 888 - return 889 - } 890 - 891 - tx, err := s.db.BeginTx(r.Context(), nil) 892 - if err != nil { 893 - log.Println("failed to start tx") 894 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 895 - return 896 - } 897 - defer tx.Rollback() 898 - 899 - // We've already checked earlier if it's diff-based and title is empty, 900 - // so if it's still empty now, it's intentionally skipped owing to format-patch. 901 - if title == "" { 902 - formatPatches, err := patchutil.ExtractPatches(patch) 903 - if err != nil { 904 - s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err)) 905 - return 906 - } 907 - if len(formatPatches) == 0 { 908 - s.pages.Notice(w, "pull", "No patches found in the supplied format-patch.") 909 - return 910 - } 911 - 912 - title = formatPatches[0].Title 913 - body = formatPatches[0].Body 914 - } 915 - 916 - rkey := appview.TID() 917 - initialSubmission := db.PullSubmission{ 918 - Patch: patch, 919 - SourceRev: sourceRev, 920 - } 921 - err = db.NewPull(tx, &db.Pull{ 922 - Title: title, 923 - Body: body, 924 - TargetBranch: targetBranch, 925 - OwnerDid: user.Did, 926 - RepoAt: f.RepoAt, 927 - Rkey: rkey, 928 - Submissions: []*db.PullSubmission{ 929 - &initialSubmission, 930 - }, 931 - PullSource: pullSource, 932 - }) 933 - if err != nil { 934 - log.Println("failed to create pull request", err) 935 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 936 - return 937 - } 938 - pullId, err := db.NextPullId(tx, f.RepoAt) 939 - if err != nil { 940 - log.Println("failed to get pull id", err) 941 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 942 - return 943 - } 944 - 945 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 946 - Collection: tangled.RepoPullNSID, 947 - Repo: user.Did, 948 - Rkey: rkey, 949 - Record: &lexutil.LexiconTypeDecoder{ 950 - Val: &tangled.RepoPull{ 951 - Title: title, 952 - PullId: int64(pullId), 953 - TargetRepo: string(f.RepoAt), 954 - TargetBranch: targetBranch, 955 - Patch: patch, 956 - Source: recordPullSource, 957 - }, 958 - }, 959 - }) 960 - if err != nil { 961 - log.Println("failed to create pull request", err) 962 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 963 - return 964 - } 965 - 966 - if err = tx.Commit(); err != nil { 967 - log.Println("failed to create pull request", err) 968 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 969 - return 970 - } 971 - 972 - if !s.config.Core.Dev { 973 - err = s.posthog.Enqueue(posthog.Capture{ 974 - DistinctId: user.Did, 975 - Event: "new_pull", 976 - Properties: posthog.Properties{"repo_at": f.RepoAt.String(), "pull_id": pullId}, 977 - }) 978 - if err != nil { 979 - log.Println("failed to enqueue posthog event:", err) 980 - } 981 - } 982 - 983 - s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pullId)) 984 - } 985 - 986 - func (s *State) createStackedPulLRequest( 987 - w http.ResponseWriter, 988 - r *http.Request, 989 - f *FullyResolvedRepo, 990 - user *oauth.User, 991 - targetBranch string, 992 - patch string, 993 - sourceRev string, 994 - pullSource *db.PullSource, 995 - ) { 996 - // run some necessary checks for stacked-prs first 997 - 998 - // must be branch or fork based 999 - if sourceRev == "" { 1000 - log.Println("stacked PR from patch-based pull") 1001 - s.pages.Notice(w, "pull", "Stacking is only supported on branch and fork based pull-requests.") 1002 - return 1003 - } 1004 - 1005 - formatPatches, err := patchutil.ExtractPatches(patch) 1006 - if err != nil { 1007 - log.Println("failed to extract patches", err) 1008 - s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err)) 1009 - return 1010 - } 1011 - 1012 - // must have atleast 1 patch to begin with 1013 - if len(formatPatches) == 0 { 1014 - log.Println("empty patches") 1015 - s.pages.Notice(w, "pull", "No patches found in the generated format-patch.") 1016 - return 1017 - } 1018 - 1019 - // build a stack out of this patch 1020 - stackId := uuid.New() 1021 - stack, err := newStack(f, user, targetBranch, patch, pullSource, stackId.String()) 1022 - if err != nil { 1023 - log.Println("failed to create stack", err) 1024 - s.pages.Notice(w, "pull", fmt.Sprintf("Failed to create stack: %v", err)) 1025 - return 1026 - } 1027 - 1028 - client, err := s.oauth.AuthorizedClient(r) 1029 - if err != nil { 1030 - log.Println("failed to get authorized client", err) 1031 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1032 - return 1033 - } 1034 - 1035 - // apply all record creations at once 1036 - var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem 1037 - for _, p := range stack { 1038 - record := p.AsRecord() 1039 - write := comatproto.RepoApplyWrites_Input_Writes_Elem{ 1040 - RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{ 1041 - Collection: tangled.RepoPullNSID, 1042 - Rkey: &p.Rkey, 1043 - Value: &lexutil.LexiconTypeDecoder{ 1044 - Val: &record, 1045 - }, 1046 - }, 1047 - } 1048 - writes = append(writes, &write) 1049 - } 1050 - _, err = client.RepoApplyWrites(r.Context(), &comatproto.RepoApplyWrites_Input{ 1051 - Repo: user.Did, 1052 - Writes: writes, 1053 - }) 1054 - if err != nil { 1055 - log.Println("failed to create stacked pull request", err) 1056 - s.pages.Notice(w, "pull", "Failed to create stacked pull request. Try again later.") 1057 - return 1058 - } 1059 - 1060 - // create all pulls at once 1061 - tx, err := s.db.BeginTx(r.Context(), nil) 1062 - if err != nil { 1063 - log.Println("failed to start tx") 1064 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1065 - return 1066 - } 1067 - defer tx.Rollback() 1068 - 1069 - for _, p := range stack { 1070 - err = db.NewPull(tx, p) 1071 - if err != nil { 1072 - log.Println("failed to create pull request", err) 1073 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1074 - return 1075 - } 1076 - } 1077 - 1078 - if err = tx.Commit(); err != nil { 1079 - log.Println("failed to create pull request", err) 1080 - s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1081 - return 1082 - } 1083 - 1084 - s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls", f.OwnerSlashRepo())) 1085 - } 1086 - 1087 - func (s *State) ValidatePatch(w http.ResponseWriter, r *http.Request) { 1088 - _, err := s.fullyResolvedRepo(r) 1089 - if err != nil { 1090 - log.Println("failed to get repo and knot", err) 1091 - return 1092 - } 1093 - 1094 - patch := r.FormValue("patch") 1095 - if patch == "" { 1096 - s.pages.Notice(w, "patch-error", "Patch is required.") 1097 - return 1098 - } 1099 - 1100 - if patch == "" || !patchutil.IsPatchValid(patch) { 1101 - s.pages.Notice(w, "patch-error", "Invalid patch format. Please provide a valid git diff or format-patch.") 1102 - return 1103 - } 1104 - 1105 - if patchutil.IsFormatPatch(patch) { 1106 - 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.") 1107 - } else { 1108 - s.pages.Notice(w, "patch-preview", "Regular git-diff detected. Please provide a title and description.") 1109 - } 1110 - } 1111 - 1112 - func (s *State) PatchUploadFragment(w http.ResponseWriter, r *http.Request) { 1113 - user := s.oauth.GetUser(r) 1114 - f, err := s.fullyResolvedRepo(r) 1115 - if err != nil { 1116 - log.Println("failed to get repo and knot", err) 1117 - return 1118 - } 1119 - 1120 - s.pages.PullPatchUploadFragment(w, pages.PullPatchUploadParams{ 1121 - RepoInfo: f.RepoInfo(s, user), 1122 - }) 1123 - } 1124 - 1125 - func (s *State) CompareBranchesFragment(w http.ResponseWriter, r *http.Request) { 1126 - user := s.oauth.GetUser(r) 1127 - f, err := s.fullyResolvedRepo(r) 1128 - if err != nil { 1129 - log.Println("failed to get repo and knot", err) 1130 - return 1131 - } 1132 - 1133 - us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 1134 - if err != nil { 1135 - log.Printf("failed to create unsigned client for %s", f.Knot) 1136 - s.pages.Error503(w) 1137 - return 1138 - } 1139 - 1140 - result, err := us.Branches(f.OwnerDid(), f.RepoName) 1141 - if err != nil { 1142 - log.Println("failed to reach knotserver", err) 1143 - return 1144 - } 1145 - 1146 - branches := result.Branches 1147 - sort.Slice(branches, func(i int, j int) bool { 1148 - return branches[i].Commit.Committer.When.After(branches[j].Commit.Committer.When) 1149 - }) 1150 - 1151 - withoutDefault := []types.Branch{} 1152 - for _, b := range branches { 1153 - if b.IsDefault { 1154 - continue 1155 - } 1156 - withoutDefault = append(withoutDefault, b) 1157 - } 1158 - 1159 - s.pages.PullCompareBranchesFragment(w, pages.PullCompareBranchesParams{ 1160 - RepoInfo: f.RepoInfo(s, user), 1161 - Branches: withoutDefault, 1162 - }) 1163 - } 1164 - 1165 - func (s *State) CompareForksFragment(w http.ResponseWriter, r *http.Request) { 1166 - user := s.oauth.GetUser(r) 1167 - f, err := s.fullyResolvedRepo(r) 1168 - if err != nil { 1169 - log.Println("failed to get repo and knot", err) 1170 - return 1171 - } 1172 - 1173 - forks, err := db.GetForksByDid(s.db, user.Did) 1174 - if err != nil { 1175 - log.Println("failed to get forks", err) 1176 - return 1177 - } 1178 - 1179 - s.pages.PullCompareForkFragment(w, pages.PullCompareForkParams{ 1180 - RepoInfo: f.RepoInfo(s, user), 1181 - Forks: forks, 1182 - }) 1183 - } 1184 - 1185 - func (s *State) CompareForksBranchesFragment(w http.ResponseWriter, r *http.Request) { 1186 - user := s.oauth.GetUser(r) 1187 - 1188 - f, err := s.fullyResolvedRepo(r) 1189 - if err != nil { 1190 - log.Println("failed to get repo and knot", err) 1191 - return 1192 - } 1193 - 1194 - forkVal := r.URL.Query().Get("fork") 1195 - 1196 - // fork repo 1197 - repo, err := db.GetRepo(s.db, user.Did, forkVal) 1198 - if err != nil { 1199 - log.Println("failed to get repo", user.Did, forkVal) 1200 - return 1201 - } 1202 - 1203 - sourceBranchesClient, err := knotclient.NewUnsignedClient(repo.Knot, s.config.Core.Dev) 1204 - if err != nil { 1205 - log.Printf("failed to create unsigned client for %s", repo.Knot) 1206 - s.pages.Error503(w) 1207 - return 1208 - } 1209 - 1210 - sourceResult, err := sourceBranchesClient.Branches(user.Did, repo.Name) 1211 - if err != nil { 1212 - log.Println("failed to reach knotserver for source branches", err) 1213 - return 1214 - } 1215 - 1216 - targetBranchesClient, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 1217 - if err != nil { 1218 - log.Printf("failed to create unsigned client for target knot %s", f.Knot) 1219 - s.pages.Error503(w) 1220 - return 1221 - } 1222 - 1223 - targetResult, err := targetBranchesClient.Branches(f.OwnerDid(), f.RepoName) 1224 - if err != nil { 1225 - log.Println("failed to reach knotserver for target branches", err) 1226 - return 1227 - } 1228 - 1229 - sourceBranches := sourceResult.Branches 1230 - sort.Slice(sourceBranches, func(i int, j int) bool { 1231 - return sourceBranches[i].Commit.Committer.When.After(sourceBranches[j].Commit.Committer.When) 1232 - }) 1233 - 1234 - s.pages.PullCompareForkBranchesFragment(w, pages.PullCompareForkBranchesParams{ 1235 - RepoInfo: f.RepoInfo(s, user), 1236 - SourceBranches: sourceResult.Branches, 1237 - TargetBranches: targetResult.Branches, 1238 - }) 1239 - } 1240 - 1241 - func (s *State) ResubmitPull(w http.ResponseWriter, r *http.Request) { 1242 - user := s.oauth.GetUser(r) 1243 - f, err := s.fullyResolvedRepo(r) 1244 - if err != nil { 1245 - log.Println("failed to get repo and knot", err) 1246 - return 1247 - } 1248 - 1249 - pull, ok := r.Context().Value("pull").(*db.Pull) 1250 - if !ok { 1251 - log.Println("failed to get pull") 1252 - s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 1253 - return 1254 - } 1255 - 1256 - switch r.Method { 1257 - case http.MethodGet: 1258 - s.pages.PullResubmitFragment(w, pages.PullResubmitParams{ 1259 - RepoInfo: f.RepoInfo(s, user), 1260 - Pull: pull, 1261 - }) 1262 - return 1263 - case http.MethodPost: 1264 - if pull.IsPatchBased() { 1265 - s.resubmitPatch(w, r) 1266 - return 1267 - } else if pull.IsBranchBased() { 1268 - s.resubmitBranch(w, r) 1269 - return 1270 - } else if pull.IsForkBased() { 1271 - s.resubmitFork(w, r) 1272 - return 1273 - } 1274 - } 1275 - } 1276 - 1277 - func (s *State) resubmitPatch(w http.ResponseWriter, r *http.Request) { 1278 - user := s.oauth.GetUser(r) 1279 - 1280 - pull, ok := r.Context().Value("pull").(*db.Pull) 1281 - if !ok { 1282 - log.Println("failed to get pull") 1283 - s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 1284 - return 1285 - } 1286 - 1287 - f, err := s.fullyResolvedRepo(r) 1288 - if err != nil { 1289 - log.Println("failed to get repo and knot", err) 1290 - return 1291 - } 1292 - 1293 - if user.Did != pull.OwnerDid { 1294 - log.Println("unauthorized user") 1295 - w.WriteHeader(http.StatusUnauthorized) 1296 - return 1297 - } 1298 - 1299 - patch := r.FormValue("patch") 1300 - 1301 - s.resubmitPullHelper(w, r, f, user, pull, patch, "") 1302 - } 1303 - 1304 - func (s *State) resubmitBranch(w http.ResponseWriter, r *http.Request) { 1305 - user := s.oauth.GetUser(r) 1306 - 1307 - pull, ok := r.Context().Value("pull").(*db.Pull) 1308 - if !ok { 1309 - log.Println("failed to get pull") 1310 - s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.") 1311 - return 1312 - } 1313 - 1314 - f, err := s.fullyResolvedRepo(r) 1315 - if err != nil { 1316 - log.Println("failed to get repo and knot", err) 1317 - return 1318 - } 1319 - 1320 - if user.Did != pull.OwnerDid { 1321 - log.Println("unauthorized user") 1322 - w.WriteHeader(http.StatusUnauthorized) 1323 - return 1324 - } 1325 - 1326 - if !f.RepoInfo(s, user).Roles.IsPushAllowed() { 1327 - log.Println("unauthorized user") 1328 - w.WriteHeader(http.StatusUnauthorized) 1329 - return 1330 - } 1331 - 1332 - ksClient, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 1333 - if err != nil { 1334 - log.Printf("failed to create client for %s: %s", f.Knot, err) 1335 - s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1336 - return 1337 - } 1338 - 1339 - comparison, err := ksClient.Compare(f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.PullSource.Branch) 1340 - if err != nil { 1341 - log.Printf("compare request failed: %s", err) 1342 - s.pages.Notice(w, "resubmit-error", err.Error()) 1343 - return 1344 - } 1345 - 1346 - sourceRev := comparison.Rev2 1347 - patch := comparison.Patch 1348 - 1349 - s.resubmitPullHelper(w, r, f, user, pull, patch, sourceRev) 1350 - } 1351 - 1352 - func (s *State) resubmitFork(w http.ResponseWriter, r *http.Request) { 1353 - user := s.oauth.GetUser(r) 1354 - 1355 - pull, ok := r.Context().Value("pull").(*db.Pull) 1356 - if !ok { 1357 - log.Println("failed to get pull") 1358 - s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.") 1359 - return 1360 - } 1361 - 1362 - f, err := s.fullyResolvedRepo(r) 1363 - if err != nil { 1364 - log.Println("failed to get repo and knot", err) 1365 - return 1366 - } 1367 - 1368 - if user.Did != pull.OwnerDid { 1369 - log.Println("unauthorized user") 1370 - w.WriteHeader(http.StatusUnauthorized) 1371 - return 1372 - } 1373 - 1374 - forkRepo, err := db.GetRepoByAtUri(s.db, pull.PullSource.RepoAt.String()) 1375 - if err != nil { 1376 - log.Println("failed to get source repo", err) 1377 - s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1378 - return 1379 - } 1380 - 1381 - // extract patch by performing compare 1382 - ksClient, err := knotclient.NewUnsignedClient(forkRepo.Knot, s.config.Core.Dev) 1383 - if err != nil { 1384 - log.Printf("failed to create client for %s: %s", forkRepo.Knot, err) 1385 - s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1386 - return 1387 - } 1388 - 1389 - secret, err := db.GetRegistrationKey(s.db, forkRepo.Knot) 1390 - if err != nil { 1391 - log.Printf("failed to get registration key for %s: %s", forkRepo.Knot, err) 1392 - s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1393 - return 1394 - } 1395 - 1396 - // update the hidden tracking branch to latest 1397 - signedClient, err := knotclient.NewSignedClient(forkRepo.Knot, secret, s.config.Core.Dev) 1398 - if err != nil { 1399 - log.Printf("failed to create signed client for %s: %s", forkRepo.Knot, err) 1400 - s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1401 - return 1402 - } 1403 - 1404 - resp, err := signedClient.NewHiddenRef(forkRepo.Did, forkRepo.Name, pull.PullSource.Branch, pull.TargetBranch) 1405 - if err != nil || resp.StatusCode != http.StatusNoContent { 1406 - log.Printf("failed to update tracking branch: %s", err) 1407 - s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1408 - return 1409 - } 1410 - 1411 - hiddenRef := fmt.Sprintf("hidden/%s/%s", pull.PullSource.Branch, pull.TargetBranch) 1412 - comparison, err := ksClient.Compare(forkRepo.Did, forkRepo.Name, hiddenRef, pull.PullSource.Branch) 1413 - if err != nil { 1414 - log.Printf("failed to compare branches: %s", err) 1415 - s.pages.Notice(w, "resubmit-error", err.Error()) 1416 - return 1417 - } 1418 - 1419 - sourceRev := comparison.Rev2 1420 - patch := comparison.Patch 1421 - 1422 - s.resubmitPullHelper(w, r, f, user, pull, patch, sourceRev) 1423 - } 1424 - 1425 - // validate a resubmission against a pull request 1426 - func validateResubmittedPatch(pull *db.Pull, patch string) error { 1427 - if patch == "" { 1428 - return fmt.Errorf("Patch is empty.") 1429 - } 1430 - 1431 - if patch == pull.LatestPatch() { 1432 - return fmt.Errorf("Patch is identical to previous submission.") 1433 - } 1434 - 1435 - if !patchutil.IsPatchValid(patch) { 1436 - return fmt.Errorf("Invalid patch format. Please provide a valid diff.") 1437 - } 1438 - 1439 - return nil 1440 - } 1441 - 1442 - func (s *State) resubmitPullHelper( 1443 - w http.ResponseWriter, 1444 - r *http.Request, 1445 - f *FullyResolvedRepo, 1446 - user *oauth.User, 1447 - pull *db.Pull, 1448 - patch string, 1449 - sourceRev string, 1450 - ) { 1451 - if pull.IsStacked() { 1452 - log.Println("resubmitting stacked PR") 1453 - s.resubmitStackedPullHelper(w, r, f, user, pull, patch, pull.StackId) 1454 - return 1455 - } 1456 - 1457 - if err := validateResubmittedPatch(pull, patch); err != nil { 1458 - s.pages.Notice(w, "resubmit-error", err.Error()) 1459 - return 1460 - } 1461 - 1462 - // validate sourceRev if branch/fork based 1463 - if pull.IsBranchBased() || pull.IsForkBased() { 1464 - if sourceRev == pull.Submissions[pull.LastRoundNumber()].SourceRev { 1465 - s.pages.Notice(w, "resubmit-error", "This branch has not changed since the last submission.") 1466 - return 1467 - } 1468 - } 1469 - 1470 - tx, err := s.db.BeginTx(r.Context(), nil) 1471 - if err != nil { 1472 - log.Println("failed to start tx") 1473 - s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1474 - return 1475 - } 1476 - defer tx.Rollback() 1477 - 1478 - err = db.ResubmitPull(tx, pull, patch, sourceRev) 1479 - if err != nil { 1480 - log.Println("failed to create pull request", err) 1481 - s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1482 - return 1483 - } 1484 - client, err := s.oauth.AuthorizedClient(r) 1485 - if err != nil { 1486 - log.Println("failed to authorize client") 1487 - s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1488 - return 1489 - } 1490 - 1491 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoPullNSID, user.Did, pull.Rkey) 1492 - if err != nil { 1493 - // failed to get record 1494 - s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.") 1495 - return 1496 - } 1497 - 1498 - var recordPullSource *tangled.RepoPull_Source 1499 - if pull.IsBranchBased() { 1500 - recordPullSource = &tangled.RepoPull_Source{ 1501 - Branch: pull.PullSource.Branch, 1502 - } 1503 - } 1504 - if pull.IsForkBased() { 1505 - repoAt := pull.PullSource.RepoAt.String() 1506 - recordPullSource = &tangled.RepoPull_Source{ 1507 - Branch: pull.PullSource.Branch, 1508 - Repo: &repoAt, 1509 - } 1510 - } 1511 - 1512 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1513 - Collection: tangled.RepoPullNSID, 1514 - Repo: user.Did, 1515 - Rkey: pull.Rkey, 1516 - SwapRecord: ex.Cid, 1517 - Record: &lexutil.LexiconTypeDecoder{ 1518 - Val: &tangled.RepoPull{ 1519 - Title: pull.Title, 1520 - PullId: int64(pull.PullId), 1521 - TargetRepo: string(f.RepoAt), 1522 - TargetBranch: pull.TargetBranch, 1523 - Patch: patch, // new patch 1524 - Source: recordPullSource, 1525 - }, 1526 - }, 1527 - }) 1528 - if err != nil { 1529 - log.Println("failed to update record", err) 1530 - s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.") 1531 - return 1532 - } 1533 - 1534 - if err = tx.Commit(); err != nil { 1535 - log.Println("failed to commit transaction", err) 1536 - s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull.") 1537 - return 1538 - } 1539 - 1540 - s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 1541 - return 1542 - } 1543 - 1544 - func (s *State) resubmitStackedPullHelper( 1545 - w http.ResponseWriter, 1546 - r *http.Request, 1547 - f *FullyResolvedRepo, 1548 - user *oauth.User, 1549 - pull *db.Pull, 1550 - patch string, 1551 - stackId string, 1552 - ) { 1553 - targetBranch := pull.TargetBranch 1554 - 1555 - origStack, _ := r.Context().Value("stack").(db.Stack) 1556 - newStack, err := newStack(f, user, targetBranch, patch, pull.PullSource, stackId) 1557 - if err != nil { 1558 - log.Println("failed to create resubmitted stack", err) 1559 - s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1560 - return 1561 - } 1562 - 1563 - // find the diff between the stacks, first, map them by changeId 1564 - origById := make(map[string]*db.Pull) 1565 - newById := make(map[string]*db.Pull) 1566 - for _, p := range origStack { 1567 - origById[p.ChangeId] = p 1568 - } 1569 - for _, p := range newStack { 1570 - newById[p.ChangeId] = p 1571 - } 1572 - 1573 - // commits that got deleted: corresponding pull is closed 1574 - // commits that got added: new pull is created 1575 - // commits that got updated: corresponding pull is resubmitted & new round begins 1576 - // 1577 - // for commits that were unchanged: no changes, parent-change-id is updated as necessary 1578 - additions := make(map[string]*db.Pull) 1579 - deletions := make(map[string]*db.Pull) 1580 - unchanged := make(map[string]struct{}) 1581 - updated := make(map[string]struct{}) 1582 - 1583 - // pulls in orignal stack but not in new one 1584 - for _, op := range origStack { 1585 - if _, ok := newById[op.ChangeId]; !ok { 1586 - deletions[op.ChangeId] = op 1587 - } 1588 - } 1589 - 1590 - // pulls in new stack but not in original one 1591 - for _, np := range newStack { 1592 - if _, ok := origById[np.ChangeId]; !ok { 1593 - additions[np.ChangeId] = np 1594 - } 1595 - } 1596 - 1597 - // NOTE: this loop can be written in any of above blocks, 1598 - // but is written separately in the interest of simpler code 1599 - for _, np := range newStack { 1600 - if op, ok := origById[np.ChangeId]; ok { 1601 - // pull exists in both stacks 1602 - // TODO: can we avoid reparse? 1603 - origFiles, origHeaderStr, _ := gitdiff.Parse(strings.NewReader(op.LatestPatch())) 1604 - newFiles, newHeaderStr, _ := gitdiff.Parse(strings.NewReader(np.LatestPatch())) 1605 - 1606 - origHeader, _ := gitdiff.ParsePatchHeader(origHeaderStr) 1607 - newHeader, _ := gitdiff.ParsePatchHeader(newHeaderStr) 1608 - 1609 - patchutil.SortPatch(newFiles) 1610 - patchutil.SortPatch(origFiles) 1611 - 1612 - // text content of patch may be identical, but a jj rebase might have forwarded it 1613 - // 1614 - // we still need to update the hash in submission.Patch and submission.SourceRev 1615 - if patchutil.Equal(newFiles, origFiles) && 1616 - origHeader.Title == newHeader.Title && 1617 - origHeader.Body == newHeader.Body { 1618 - unchanged[op.ChangeId] = struct{}{} 1619 - } else { 1620 - updated[op.ChangeId] = struct{}{} 1621 - } 1622 - } 1623 - } 1624 - 1625 - tx, err := s.db.Begin() 1626 - if err != nil { 1627 - log.Println("failed to start transaction", err) 1628 - s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 1629 - return 1630 - } 1631 - defer tx.Rollback() 1632 - 1633 - // pds updates to make 1634 - var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem 1635 - 1636 - // deleted pulls are marked as deleted in the DB 1637 - for _, p := range deletions { 1638 - err := db.DeletePull(tx, p.RepoAt, p.PullId) 1639 - if err != nil { 1640 - log.Println("failed to delete pull", err, p.PullId) 1641 - s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 1642 - return 1643 - } 1644 - writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 1645 - RepoApplyWrites_Delete: &comatproto.RepoApplyWrites_Delete{ 1646 - Collection: tangled.RepoPullNSID, 1647 - Rkey: p.Rkey, 1648 - }, 1649 - }) 1650 - } 1651 - 1652 - // new pulls are created 1653 - for _, p := range additions { 1654 - err := db.NewPull(tx, p) 1655 - if err != nil { 1656 - log.Println("failed to create pull", err, p.PullId) 1657 - s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 1658 - return 1659 - } 1660 - 1661 - record := p.AsRecord() 1662 - writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 1663 - RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{ 1664 - Collection: tangled.RepoPullNSID, 1665 - Rkey: &p.Rkey, 1666 - Value: &lexutil.LexiconTypeDecoder{ 1667 - Val: &record, 1668 - }, 1669 - }, 1670 - }) 1671 - } 1672 - 1673 - // updated pulls are, well, updated; to start a new round 1674 - for id := range updated { 1675 - op, _ := origById[id] 1676 - np, _ := newById[id] 1677 - 1678 - submission := np.Submissions[np.LastRoundNumber()] 1679 - 1680 - // resubmit the old pull 1681 - err := db.ResubmitPull(tx, op, submission.Patch, submission.SourceRev) 1682 - 1683 - if err != nil { 1684 - log.Println("failed to update pull", err, op.PullId) 1685 - s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 1686 - return 1687 - } 1688 - 1689 - record := op.AsRecord() 1690 - record.Patch = submission.Patch 1691 - 1692 - writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 1693 - RepoApplyWrites_Update: &comatproto.RepoApplyWrites_Update{ 1694 - Collection: tangled.RepoPullNSID, 1695 - Rkey: op.Rkey, 1696 - Value: &lexutil.LexiconTypeDecoder{ 1697 - Val: &record, 1698 - }, 1699 - }, 1700 - }) 1701 - } 1702 - 1703 - // unchanged pulls are edited without starting a new round 1704 - // 1705 - // update source-revs & patches without advancing rounds 1706 - for changeId := range unchanged { 1707 - op, _ := origById[changeId] 1708 - np, _ := newById[changeId] 1709 - 1710 - origSubmission := op.Submissions[op.LastRoundNumber()] 1711 - newSubmission := np.Submissions[np.LastRoundNumber()] 1712 - 1713 - log.Println("moving unchanged change id : ", changeId) 1714 - 1715 - err := db.UpdatePull( 1716 - tx, 1717 - newSubmission.Patch, 1718 - newSubmission.SourceRev, 1719 - db.FilterEq("id", origSubmission.ID), 1720 - ) 1721 - 1722 - if err != nil { 1723 - log.Println("failed to update pull", err, op.PullId) 1724 - s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 1725 - return 1726 - } 1727 - 1728 - record := op.AsRecord() 1729 - record.Patch = newSubmission.Patch 1730 - 1731 - writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 1732 - RepoApplyWrites_Update: &comatproto.RepoApplyWrites_Update{ 1733 - Collection: tangled.RepoPullNSID, 1734 - Rkey: op.Rkey, 1735 - Value: &lexutil.LexiconTypeDecoder{ 1736 - Val: &record, 1737 - }, 1738 - }, 1739 - }) 1740 - } 1741 - 1742 - // update parent-change-id relations for the entire stack 1743 - for _, p := range newStack { 1744 - err := db.SetPullParentChangeId( 1745 - tx, 1746 - p.ParentChangeId, 1747 - // these should be enough filters to be unique per-stack 1748 - db.FilterEq("repo_at", p.RepoAt.String()), 1749 - db.FilterEq("owner_did", p.OwnerDid), 1750 - db.FilterEq("change_id", p.ChangeId), 1751 - ) 1752 - 1753 - if err != nil { 1754 - log.Println("failed to update pull", err, p.PullId) 1755 - s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 1756 - return 1757 - } 1758 - } 1759 - 1760 - err = tx.Commit() 1761 - if err != nil { 1762 - log.Println("failed to resubmit pull", err) 1763 - s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 1764 - return 1765 - } 1766 - 1767 - client, err := s.oauth.AuthorizedClient(r) 1768 - if err != nil { 1769 - log.Println("failed to authorize client") 1770 - s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1771 - return 1772 - } 1773 - 1774 - _, err = client.RepoApplyWrites(r.Context(), &comatproto.RepoApplyWrites_Input{ 1775 - Repo: user.Did, 1776 - Writes: writes, 1777 - }) 1778 - if err != nil { 1779 - log.Println("failed to create stacked pull request", err) 1780 - s.pages.Notice(w, "pull", "Failed to create stacked pull request. Try again later.") 1781 - return 1782 - } 1783 - 1784 - s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 1785 - return 1786 - } 1787 - 1788 - func (s *State) MergePull(w http.ResponseWriter, r *http.Request) { 1789 - f, err := s.fullyResolvedRepo(r) 1790 - if err != nil { 1791 - log.Println("failed to resolve repo:", err) 1792 - s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1793 - return 1794 - } 1795 - 1796 - pull, ok := r.Context().Value("pull").(*db.Pull) 1797 - if !ok { 1798 - log.Println("failed to get pull") 1799 - s.pages.Notice(w, "pull-merge-error", "Failed to merge patch. Try again later.") 1800 - return 1801 - } 1802 - 1803 - var pullsToMerge db.Stack 1804 - pullsToMerge = append(pullsToMerge, pull) 1805 - if pull.IsStacked() { 1806 - stack, ok := r.Context().Value("stack").(db.Stack) 1807 - if !ok { 1808 - log.Println("failed to get stack") 1809 - s.pages.Notice(w, "pull-merge-error", "Failed to merge patch. Try again later.") 1810 - return 1811 - } 1812 - 1813 - // combine patches of substack 1814 - subStack := stack.StrictlyBelow(pull) 1815 - // collect the portion of the stack that is mergeable 1816 - mergeable := subStack.Mergeable() 1817 - // add to total patch 1818 - pullsToMerge = append(pullsToMerge, mergeable...) 1819 - } 1820 - 1821 - patch := pullsToMerge.CombinedPatch() 1822 - 1823 - secret, err := db.GetRegistrationKey(s.db, f.Knot) 1824 - if err != nil { 1825 - log.Printf("no registration key found for domain %s: %s\n", f.Knot, err) 1826 - s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1827 - return 1828 - } 1829 - 1830 - ident, err := s.resolver.ResolveIdent(r.Context(), pull.OwnerDid) 1831 - if err != nil { 1832 - log.Printf("resolving identity: %s", err) 1833 - w.WriteHeader(http.StatusNotFound) 1834 - return 1835 - } 1836 - 1837 - email, err := db.GetPrimaryEmail(s.db, pull.OwnerDid) 1838 - if err != nil { 1839 - log.Printf("failed to get primary email: %s", err) 1840 - } 1841 - 1842 - ksClient, err := knotclient.NewSignedClient(f.Knot, secret, s.config.Core.Dev) 1843 - if err != nil { 1844 - log.Printf("failed to create signed client for %s: %s", f.Knot, err) 1845 - s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1846 - return 1847 - } 1848 - 1849 - // Merge the pull request 1850 - resp, err := ksClient.Merge([]byte(patch), f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.Title, pull.Body, ident.Handle.String(), email.Address) 1851 - if err != nil { 1852 - log.Printf("failed to merge pull request: %s", err) 1853 - s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1854 - return 1855 - } 1856 - 1857 - if resp.StatusCode != http.StatusOK { 1858 - log.Printf("knotserver returned non-OK status code for merge: %d", resp.StatusCode) 1859 - s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1860 - return 1861 - } 1862 - 1863 - tx, err := s.db.Begin() 1864 - if err != nil { 1865 - log.Println("failed to start transcation", err) 1866 - s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1867 - return 1868 - } 1869 - defer tx.Rollback() 1870 - 1871 - for _, p := range pullsToMerge { 1872 - err := db.MergePull(tx, f.RepoAt, p.PullId) 1873 - if err != nil { 1874 - log.Printf("failed to update pull request status in database: %s", err) 1875 - s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1876 - return 1877 - } 1878 - } 1879 - 1880 - err = tx.Commit() 1881 - if err != nil { 1882 - // TODO: this is unsound, we should also revert the merge from the knotserver here 1883 - log.Printf("failed to update pull request status in database: %s", err) 1884 - s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1885 - return 1886 - } 1887 - 1888 - s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.RepoName, pull.PullId)) 1889 - } 1890 - 1891 - func (s *State) ClosePull(w http.ResponseWriter, r *http.Request) { 1892 - user := s.oauth.GetUser(r) 1893 - 1894 - f, err := s.fullyResolvedRepo(r) 1895 - if err != nil { 1896 - log.Println("malformed middleware") 1897 - return 1898 - } 1899 - 1900 - pull, ok := r.Context().Value("pull").(*db.Pull) 1901 - if !ok { 1902 - log.Println("failed to get pull") 1903 - s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 1904 - return 1905 - } 1906 - 1907 - // auth filter: only owner or collaborators can close 1908 - roles := RolesInRepo(s, user, f) 1909 - isCollaborator := roles.IsCollaborator() 1910 - isPullAuthor := user.Did == pull.OwnerDid 1911 - isCloseAllowed := isCollaborator || isPullAuthor 1912 - if !isCloseAllowed { 1913 - log.Println("failed to close pull") 1914 - s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.") 1915 - return 1916 - } 1917 - 1918 - // Start a transaction 1919 - tx, err := s.db.BeginTx(r.Context(), nil) 1920 - if err != nil { 1921 - log.Println("failed to start transaction", err) 1922 - s.pages.Notice(w, "pull-close", "Failed to close pull.") 1923 - return 1924 - } 1925 - defer tx.Rollback() 1926 - 1927 - var pullsToClose []*db.Pull 1928 - pullsToClose = append(pullsToClose, pull) 1929 - 1930 - // if this PR is stacked, then we want to close all PRs below this one on the stack 1931 - if pull.IsStacked() { 1932 - stack := r.Context().Value("stack").(db.Stack) 1933 - subStack := stack.StrictlyBelow(pull) 1934 - pullsToClose = append(pullsToClose, subStack...) 1935 - } 1936 - 1937 - for _, p := range pullsToClose { 1938 - // Close the pull in the database 1939 - err = db.ClosePull(tx, f.RepoAt, p.PullId) 1940 - if err != nil { 1941 - log.Println("failed to close pull", err) 1942 - s.pages.Notice(w, "pull-close", "Failed to close pull.") 1943 - return 1944 - } 1945 - } 1946 - 1947 - // Commit the transaction 1948 - if err = tx.Commit(); err != nil { 1949 - log.Println("failed to commit transaction", err) 1950 - s.pages.Notice(w, "pull-close", "Failed to close pull.") 1951 - return 1952 - } 1953 - 1954 - s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 1955 - return 1956 - } 1957 - 1958 - func (s *State) ReopenPull(w http.ResponseWriter, r *http.Request) { 1959 - user := s.oauth.GetUser(r) 1960 - 1961 - f, err := s.fullyResolvedRepo(r) 1962 - if err != nil { 1963 - log.Println("failed to resolve repo", err) 1964 - s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") 1965 - return 1966 - } 1967 - 1968 - pull, ok := r.Context().Value("pull").(*db.Pull) 1969 - if !ok { 1970 - log.Println("failed to get pull") 1971 - s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 1972 - return 1973 - } 1974 - 1975 - // auth filter: only owner or collaborators can close 1976 - roles := RolesInRepo(s, user, f) 1977 - isCollaborator := roles.IsCollaborator() 1978 - isPullAuthor := user.Did == pull.OwnerDid 1979 - isCloseAllowed := isCollaborator || isPullAuthor 1980 - if !isCloseAllowed { 1981 - log.Println("failed to close pull") 1982 - s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.") 1983 - return 1984 - } 1985 - 1986 - // Start a transaction 1987 - tx, err := s.db.BeginTx(r.Context(), nil) 1988 - if err != nil { 1989 - log.Println("failed to start transaction", err) 1990 - s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") 1991 - return 1992 - } 1993 - defer tx.Rollback() 1994 - 1995 - var pullsToReopen []*db.Pull 1996 - pullsToReopen = append(pullsToReopen, pull) 1997 - 1998 - // if this PR is stacked, then we want to reopen all PRs above this one on the stack 1999 - if pull.IsStacked() { 2000 - stack := r.Context().Value("stack").(db.Stack) 2001 - subStack := stack.StrictlyAbove(pull) 2002 - pullsToReopen = append(pullsToReopen, subStack...) 2003 - } 2004 - 2005 - for _, p := range pullsToReopen { 2006 - // Close the pull in the database 2007 - err = db.ReopenPull(tx, f.RepoAt, p.PullId) 2008 - if err != nil { 2009 - log.Println("failed to close pull", err) 2010 - s.pages.Notice(w, "pull-close", "Failed to close pull.") 2011 - return 2012 - } 2013 - } 2014 - 2015 - // Commit the transaction 2016 - if err = tx.Commit(); err != nil { 2017 - log.Println("failed to commit transaction", err) 2018 - s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") 2019 - return 2020 - } 2021 - 2022 - s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 2023 - return 2024 - } 2025 - 2026 - func newStack(f *FullyResolvedRepo, user *oauth.User, targetBranch, patch string, pullSource *db.PullSource, stackId string) (db.Stack, error) { 2027 - formatPatches, err := patchutil.ExtractPatches(patch) 2028 - if err != nil { 2029 - return nil, fmt.Errorf("Failed to extract patches: %v", err) 2030 - } 2031 - 2032 - // must have atleast 1 patch to begin with 2033 - if len(formatPatches) == 0 { 2034 - return nil, fmt.Errorf("No patches found in the generated format-patch.") 2035 - } 2036 - 2037 - // the stack is identified by a UUID 2038 - var stack db.Stack 2039 - parentChangeId := "" 2040 - for _, fp := range formatPatches { 2041 - // all patches must have a jj change-id 2042 - changeId, err := fp.ChangeId() 2043 - if err != nil { 2044 - return nil, fmt.Errorf("Stacking is only supported if all patches contain a change-id commit header.") 2045 - } 2046 - 2047 - title := fp.Title 2048 - body := fp.Body 2049 - rkey := appview.TID() 2050 - 2051 - initialSubmission := db.PullSubmission{ 2052 - Patch: fp.Raw, 2053 - SourceRev: fp.SHA, 2054 - } 2055 - pull := db.Pull{ 2056 - Title: title, 2057 - Body: body, 2058 - TargetBranch: targetBranch, 2059 - OwnerDid: user.Did, 2060 - RepoAt: f.RepoAt, 2061 - Rkey: rkey, 2062 - Submissions: []*db.PullSubmission{ 2063 - &initialSubmission, 2064 - }, 2065 - PullSource: pullSource, 2066 - Created: time.Now(), 2067 - 2068 - StackId: stackId, 2069 - ChangeId: changeId, 2070 - ParentChangeId: parentChangeId, 2071 - } 2072 - 2073 - stack = append(stack, &pull) 2074 - 2075 - parentChangeId = changeId 2076 - } 2077 - 2078 - return stack, nil 2079 - }
-2056
appview/state/repo.go
··· 1 - package state 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" 15 - "strconv" 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/oauth" 23 - "tangled.sh/tangled.sh/core/appview/pages" 24 - "tangled.sh/tangled.sh/core/appview/pages/markup" 25 - "tangled.sh/tangled.sh/core/appview/pages/repoinfo" 26 - "tangled.sh/tangled.sh/core/appview/pagination" 27 - "tangled.sh/tangled.sh/core/knotclient" 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 - "github.com/posthog/posthog-go" 37 - 38 - comatproto "github.com/bluesky-social/indigo/api/atproto" 39 - lexutil "github.com/bluesky-social/indigo/lex/util" 40 - ) 41 - 42 - func (s *State) RepoIndex(w http.ResponseWriter, r *http.Request) { 43 - ref := chi.URLParam(r, "ref") 44 - f, err := s.fullyResolvedRepo(r) 45 - if err != nil { 46 - log.Println("failed to fully resolve repo", err) 47 - return 48 - } 49 - 50 - us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 51 - if err != nil { 52 - log.Printf("failed to create unsigned client for %s", f.Knot) 53 - s.pages.Error503(w) 54 - return 55 - } 56 - 57 - result, err := us.Index(f.OwnerDid(), f.RepoName, ref) 58 - if err != nil { 59 - s.pages.Error503(w) 60 - log.Println("failed to reach knotserver", err) 61 - return 62 - } 63 - 64 - tagMap := make(map[string][]string) 65 - for _, tag := range result.Tags { 66 - hash := tag.Hash 67 - if tag.Tag != nil { 68 - hash = tag.Tag.Target.String() 69 - } 70 - tagMap[hash] = append(tagMap[hash], tag.Name) 71 - } 72 - 73 - for _, branch := range result.Branches { 74 - hash := branch.Hash 75 - tagMap[hash] = append(tagMap[hash], branch.Name) 76 - } 77 - 78 - slices.SortFunc(result.Branches, func(a, b types.Branch) int { 79 - if a.Name == result.Ref { 80 - return -1 81 - } 82 - if a.IsDefault { 83 - return -1 84 - } 85 - if b.IsDefault { 86 - return 1 87 - } 88 - if a.Commit != nil { 89 - if a.Commit.Committer.When.Before(b.Commit.Committer.When) { 90 - return 1 91 - } else { 92 - return -1 93 - } 94 - } 95 - return strings.Compare(a.Name, b.Name) * -1 96 - }) 97 - 98 - commitCount := len(result.Commits) 99 - branchCount := len(result.Branches) 100 - tagCount := len(result.Tags) 101 - fileCount := len(result.Files) 102 - 103 - commitCount, branchCount, tagCount = balanceIndexItems(commitCount, branchCount, tagCount, fileCount) 104 - commitsTrunc := result.Commits[:min(commitCount, len(result.Commits))] 105 - tagsTrunc := result.Tags[:min(tagCount, len(result.Tags))] 106 - branchesTrunc := result.Branches[:min(branchCount, len(result.Branches))] 107 - 108 - emails := uniqueEmails(commitsTrunc) 109 - 110 - user := s.oauth.GetUser(r) 111 - repoInfo := f.RepoInfo(s, user) 112 - 113 - secret, err := db.GetRegistrationKey(s.db, f.Knot) 114 - if err != nil { 115 - log.Printf("failed to get registration key for %s: %s", f.Knot, err) 116 - s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 117 - } 118 - 119 - signedClient, err := knotclient.NewSignedClient(f.Knot, secret, s.config.Core.Dev) 120 - if err != nil { 121 - log.Printf("failed to create signed client for %s: %s", f.Knot, err) 122 - return 123 - } 124 - 125 - var forkInfo *types.ForkInfo 126 - if user != nil && (repoInfo.Roles.IsOwner() || repoInfo.Roles.IsCollaborator()) { 127 - forkInfo, err = getForkInfo(repoInfo, s, f, user, signedClient) 128 - if err != nil { 129 - log.Printf("Failed to fetch fork information: %v", err) 130 - return 131 - } 132 - } 133 - 134 - repoLanguages, err := signedClient.RepoLanguages(f.OwnerDid(), f.RepoName, ref) 135 - if err != nil { 136 - log.Printf("failed to compute language percentages: %s", err) 137 - // non-fatal 138 - } 139 - 140 - s.pages.RepoIndexPage(w, pages.RepoIndexParams{ 141 - LoggedInUser: user, 142 - RepoInfo: repoInfo, 143 - TagMap: tagMap, 144 - RepoIndexResponse: *result, 145 - CommitsTrunc: commitsTrunc, 146 - TagsTrunc: tagsTrunc, 147 - ForkInfo: forkInfo, 148 - BranchesTrunc: branchesTrunc, 149 - EmailToDidOrHandle: EmailToDidOrHandle(s, emails), 150 - Languages: repoLanguages, 151 - }) 152 - return 153 - } 154 - 155 - func getForkInfo( 156 - repoInfo repoinfo.RepoInfo, 157 - s *State, 158 - f *FullyResolvedRepo, 159 - user *oauth.User, 160 - signedClient *knotclient.SignedClient, 161 - ) (*types.ForkInfo, error) { 162 - if user == nil { 163 - return nil, nil 164 - } 165 - 166 - forkInfo := types.ForkInfo{ 167 - IsFork: repoInfo.Source != nil, 168 - Status: types.UpToDate, 169 - } 170 - 171 - if !forkInfo.IsFork { 172 - forkInfo.IsFork = false 173 - return &forkInfo, nil 174 - } 175 - 176 - us, err := knotclient.NewUnsignedClient(repoInfo.Source.Knot, s.config.Core.Dev) 177 - if err != nil { 178 - log.Printf("failed to create unsigned client for %s", repoInfo.Source.Knot) 179 - return nil, err 180 - } 181 - 182 - result, err := us.Branches(repoInfo.Source.Did, repoInfo.Source.Name) 183 - if err != nil { 184 - log.Println("failed to reach knotserver", err) 185 - return nil, err 186 - } 187 - 188 - if !slices.ContainsFunc(result.Branches, func(branch types.Branch) bool { 189 - return branch.Name == f.Ref 190 - }) { 191 - forkInfo.Status = types.MissingBranch 192 - return &forkInfo, nil 193 - } 194 - 195 - newHiddenRefResp, err := signedClient.NewHiddenRef(user.Did, repoInfo.Name, f.Ref, f.Ref) 196 - if err != nil || newHiddenRefResp.StatusCode != http.StatusNoContent { 197 - log.Printf("failed to update tracking branch: %s", err) 198 - return nil, err 199 - } 200 - 201 - hiddenRef := fmt.Sprintf("hidden/%s/%s", f.Ref, f.Ref) 202 - 203 - var status types.AncestorCheckResponse 204 - forkSyncableResp, err := signedClient.RepoForkAheadBehind(user.Did, string(f.RepoAt), repoInfo.Name, f.Ref, hiddenRef) 205 - if err != nil { 206 - log.Printf("failed to check if fork is ahead/behind: %s", err) 207 - return nil, err 208 - } 209 - 210 - if err := json.NewDecoder(forkSyncableResp.Body).Decode(&status); err != nil { 211 - log.Printf("failed to decode fork status: %s", err) 212 - return nil, err 213 - } 214 - 215 - forkInfo.Status = status.Status 216 - return &forkInfo, nil 217 - } 218 - 219 - func (s *State) RepoLog(w http.ResponseWriter, r *http.Request) { 220 - f, err := s.fullyResolvedRepo(r) 221 - if err != nil { 222 - log.Println("failed to fully resolve repo", err) 223 - return 224 - } 225 - 226 - page := 1 227 - if r.URL.Query().Get("page") != "" { 228 - page, err = strconv.Atoi(r.URL.Query().Get("page")) 229 - if err != nil { 230 - page = 1 231 - } 232 - } 233 - 234 - ref := chi.URLParam(r, "ref") 235 - 236 - us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 237 - if err != nil { 238 - log.Println("failed to create unsigned client", err) 239 - return 240 - } 241 - 242 - repolog, err := us.Log(f.OwnerDid(), f.RepoName, ref, page) 243 - if err != nil { 244 - log.Println("failed to reach knotserver", err) 245 - return 246 - } 247 - 248 - result, err := us.Tags(f.OwnerDid(), f.RepoName) 249 - if err != nil { 250 - log.Println("failed to reach knotserver", err) 251 - return 252 - } 253 - 254 - tagMap := make(map[string][]string) 255 - for _, tag := range result.Tags { 256 - hash := tag.Hash 257 - if tag.Tag != nil { 258 - hash = tag.Tag.Target.String() 259 - } 260 - tagMap[hash] = append(tagMap[hash], tag.Name) 261 - } 262 - 263 - user := s.oauth.GetUser(r) 264 - s.pages.RepoLog(w, pages.RepoLogParams{ 265 - LoggedInUser: user, 266 - TagMap: tagMap, 267 - RepoInfo: f.RepoInfo(s, user), 268 - RepoLogResponse: *repolog, 269 - EmailToDidOrHandle: EmailToDidOrHandle(s, uniqueEmails(repolog.Commits)), 270 - }) 271 - return 272 - } 273 - 274 - func (s *State) RepoDescriptionEdit(w http.ResponseWriter, r *http.Request) { 275 - f, err := s.fullyResolvedRepo(r) 276 - if err != nil { 277 - log.Println("failed to get repo and knot", err) 278 - w.WriteHeader(http.StatusBadRequest) 279 - return 280 - } 281 - 282 - user := s.oauth.GetUser(r) 283 - s.pages.EditRepoDescriptionFragment(w, pages.RepoDescriptionParams{ 284 - RepoInfo: f.RepoInfo(s, user), 285 - }) 286 - return 287 - } 288 - 289 - func (s *State) RepoDescription(w http.ResponseWriter, r *http.Request) { 290 - f, err := s.fullyResolvedRepo(r) 291 - if err != nil { 292 - log.Println("failed to get repo and knot", err) 293 - w.WriteHeader(http.StatusBadRequest) 294 - return 295 - } 296 - 297 - repoAt := f.RepoAt 298 - rkey := repoAt.RecordKey().String() 299 - if rkey == "" { 300 - log.Println("invalid aturi for repo", err) 301 - w.WriteHeader(http.StatusInternalServerError) 302 - return 303 - } 304 - 305 - user := s.oauth.GetUser(r) 306 - 307 - switch r.Method { 308 - case http.MethodGet: 309 - s.pages.RepoDescriptionFragment(w, pages.RepoDescriptionParams{ 310 - RepoInfo: f.RepoInfo(s, user), 311 - }) 312 - return 313 - case http.MethodPut: 314 - user := s.oauth.GetUser(r) 315 - newDescription := r.FormValue("description") 316 - client, err := s.oauth.AuthorizedClient(r) 317 - if err != nil { 318 - log.Println("failed to get client") 319 - s.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 320 - return 321 - } 322 - 323 - // optimistic update 324 - err = db.UpdateDescription(s.db, string(repoAt), newDescription) 325 - if err != nil { 326 - log.Println("failed to perferom update-description query", err) 327 - s.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 328 - return 329 - } 330 - 331 - // this is a bit of a pain because the golang atproto impl does not allow nil SwapRecord field 332 - // 333 - // SwapRecord is optional and should happen automagically, but given that it does not, we have to perform two requests 334 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, user.Did, rkey) 335 - if err != nil { 336 - // failed to get record 337 - s.pages.Notice(w, "repo-notice", "Failed to update description, no record found on PDS.") 338 - return 339 - } 340 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 341 - Collection: tangled.RepoNSID, 342 - Repo: user.Did, 343 - Rkey: rkey, 344 - SwapRecord: ex.Cid, 345 - Record: &lexutil.LexiconTypeDecoder{ 346 - Val: &tangled.Repo{ 347 - Knot: f.Knot, 348 - Name: f.RepoName, 349 - Owner: user.Did, 350 - CreatedAt: f.CreatedAt, 351 - Description: &newDescription, 352 - }, 353 - }, 354 - }) 355 - 356 - if err != nil { 357 - log.Println("failed to perferom update-description query", err) 358 - // failed to get record 359 - s.pages.Notice(w, "repo-notice", "Failed to update description, unable to save to PDS.") 360 - return 361 - } 362 - 363 - newRepoInfo := f.RepoInfo(s, user) 364 - newRepoInfo.Description = newDescription 365 - 366 - s.pages.RepoDescriptionFragment(w, pages.RepoDescriptionParams{ 367 - RepoInfo: newRepoInfo, 368 - }) 369 - return 370 - } 371 - } 372 - 373 - func (s *State) RepoCommit(w http.ResponseWriter, r *http.Request) { 374 - f, err := s.fullyResolvedRepo(r) 375 - if err != nil { 376 - log.Println("failed to fully resolve repo", err) 377 - return 378 - } 379 - ref := chi.URLParam(r, "ref") 380 - protocol := "http" 381 - if !s.config.Core.Dev { 382 - protocol = "https" 383 - } 384 - 385 - if !plumbing.IsHash(ref) { 386 - s.pages.Error404(w) 387 - return 388 - } 389 - 390 - resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/commit/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref)) 391 - if err != nil { 392 - log.Println("failed to reach knotserver", err) 393 - return 394 - } 395 - 396 - body, err := io.ReadAll(resp.Body) 397 - if err != nil { 398 - log.Printf("Error reading response body: %v", err) 399 - return 400 - } 401 - 402 - var result types.RepoCommitResponse 403 - err = json.Unmarshal(body, &result) 404 - if err != nil { 405 - log.Println("failed to parse response:", err) 406 - return 407 - } 408 - 409 - user := s.oauth.GetUser(r) 410 - s.pages.RepoCommit(w, pages.RepoCommitParams{ 411 - LoggedInUser: user, 412 - RepoInfo: f.RepoInfo(s, user), 413 - RepoCommitResponse: result, 414 - EmailToDidOrHandle: EmailToDidOrHandle(s, []string{result.Diff.Commit.Author.Email}), 415 - }) 416 - return 417 - } 418 - 419 - func (s *State) RepoTree(w http.ResponseWriter, r *http.Request) { 420 - f, err := s.fullyResolvedRepo(r) 421 - if err != nil { 422 - log.Println("failed to fully resolve repo", err) 423 - return 424 - } 425 - 426 - ref := chi.URLParam(r, "ref") 427 - treePath := chi.URLParam(r, "*") 428 - protocol := "http" 429 - if !s.config.Core.Dev { 430 - protocol = "https" 431 - } 432 - resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/tree/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, treePath)) 433 - if err != nil { 434 - log.Println("failed to reach knotserver", err) 435 - return 436 - } 437 - 438 - body, err := io.ReadAll(resp.Body) 439 - if err != nil { 440 - log.Printf("Error reading response body: %v", err) 441 - return 442 - } 443 - 444 - var result types.RepoTreeResponse 445 - err = json.Unmarshal(body, &result) 446 - if err != nil { 447 - log.Println("failed to parse response:", err) 448 - return 449 - } 450 - 451 - // redirects tree paths trying to access a blob; in this case the result.Files is unpopulated, 452 - // so we can safely redirect to the "parent" (which is the same file). 453 - if len(result.Files) == 0 && result.Parent == treePath { 454 - http.Redirect(w, r, fmt.Sprintf("/%s/blob/%s/%s", f.OwnerSlashRepo(), ref, result.Parent), http.StatusFound) 455 - return 456 - } 457 - 458 - user := s.oauth.GetUser(r) 459 - 460 - var breadcrumbs [][]string 461 - breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)}) 462 - if treePath != "" { 463 - for idx, elem := range strings.Split(treePath, "/") { 464 - breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)}) 465 - } 466 - } 467 - 468 - baseTreeLink := path.Join(f.OwnerSlashRepo(), "tree", ref, treePath) 469 - baseBlobLink := path.Join(f.OwnerSlashRepo(), "blob", ref, treePath) 470 - 471 - s.pages.RepoTree(w, pages.RepoTreeParams{ 472 - LoggedInUser: user, 473 - BreadCrumbs: breadcrumbs, 474 - BaseTreeLink: baseTreeLink, 475 - BaseBlobLink: baseBlobLink, 476 - RepoInfo: f.RepoInfo(s, user), 477 - RepoTreeResponse: result, 478 - }) 479 - return 480 - } 481 - 482 - func (s *State) RepoTags(w http.ResponseWriter, r *http.Request) { 483 - f, err := s.fullyResolvedRepo(r) 484 - if err != nil { 485 - log.Println("failed to get repo and knot", err) 486 - return 487 - } 488 - 489 - us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 490 - if err != nil { 491 - log.Println("failed to create unsigned client", err) 492 - return 493 - } 494 - 495 - result, err := us.Tags(f.OwnerDid(), f.RepoName) 496 - if err != nil { 497 - log.Println("failed to reach knotserver", err) 498 - return 499 - } 500 - 501 - artifacts, err := db.GetArtifact(s.db, db.FilterEq("repo_at", f.RepoAt)) 502 - if err != nil { 503 - log.Println("failed grab artifacts", err) 504 - return 505 - } 506 - 507 - // convert artifacts to map for easy UI building 508 - artifactMap := make(map[plumbing.Hash][]db.Artifact) 509 - for _, a := range artifacts { 510 - artifactMap[a.Tag] = append(artifactMap[a.Tag], a) 511 - } 512 - 513 - var danglingArtifacts []db.Artifact 514 - for _, a := range artifacts { 515 - found := false 516 - for _, t := range result.Tags { 517 - if t.Tag != nil { 518 - if t.Tag.Hash == a.Tag { 519 - found = true 520 - } 521 - } 522 - } 523 - 524 - if !found { 525 - danglingArtifacts = append(danglingArtifacts, a) 526 - } 527 - } 528 - 529 - user := s.oauth.GetUser(r) 530 - s.pages.RepoTags(w, pages.RepoTagsParams{ 531 - LoggedInUser: user, 532 - RepoInfo: f.RepoInfo(s, user), 533 - RepoTagsResponse: *result, 534 - ArtifactMap: artifactMap, 535 - DanglingArtifacts: danglingArtifacts, 536 - }) 537 - return 538 - } 539 - 540 - func (s *State) RepoBranches(w http.ResponseWriter, r *http.Request) { 541 - f, err := s.fullyResolvedRepo(r) 542 - if err != nil { 543 - log.Println("failed to get repo and knot", err) 544 - return 545 - } 546 - 547 - us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 548 - if err != nil { 549 - log.Println("failed to create unsigned client", err) 550 - return 551 - } 552 - 553 - result, err := us.Branches(f.OwnerDid(), f.RepoName) 554 - if err != nil { 555 - log.Println("failed to reach knotserver", err) 556 - return 557 - } 558 - 559 - slices.SortFunc(result.Branches, func(a, b types.Branch) int { 560 - if a.IsDefault { 561 - return -1 562 - } 563 - if b.IsDefault { 564 - return 1 565 - } 566 - if a.Commit != nil { 567 - if a.Commit.Committer.When.Before(b.Commit.Committer.When) { 568 - return 1 569 - } else { 570 - return -1 571 - } 572 - } 573 - return strings.Compare(a.Name, b.Name) * -1 574 - }) 575 - 576 - user := s.oauth.GetUser(r) 577 - s.pages.RepoBranches(w, pages.RepoBranchesParams{ 578 - LoggedInUser: user, 579 - RepoInfo: f.RepoInfo(s, user), 580 - RepoBranchesResponse: *result, 581 - }) 582 - return 583 - } 584 - 585 - func (s *State) RepoBlob(w http.ResponseWriter, r *http.Request) { 586 - f, err := s.fullyResolvedRepo(r) 587 - if err != nil { 588 - log.Println("failed to get repo and knot", err) 589 - return 590 - } 591 - 592 - ref := chi.URLParam(r, "ref") 593 - filePath := chi.URLParam(r, "*") 594 - protocol := "http" 595 - if !s.config.Core.Dev { 596 - protocol = "https" 597 - } 598 - resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath)) 599 - if err != nil { 600 - log.Println("failed to reach knotserver", err) 601 - return 602 - } 603 - 604 - body, err := io.ReadAll(resp.Body) 605 - if err != nil { 606 - log.Printf("Error reading response body: %v", err) 607 - return 608 - } 609 - 610 - var result types.RepoBlobResponse 611 - err = json.Unmarshal(body, &result) 612 - if err != nil { 613 - log.Println("failed to parse response:", err) 614 - return 615 - } 616 - 617 - var breadcrumbs [][]string 618 - breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)}) 619 - if filePath != "" { 620 - for idx, elem := range strings.Split(filePath, "/") { 621 - breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)}) 622 - } 623 - } 624 - 625 - showRendered := false 626 - renderToggle := false 627 - 628 - if markup.GetFormat(result.Path) == markup.FormatMarkdown { 629 - renderToggle = true 630 - showRendered = r.URL.Query().Get("code") != "true" 631 - } 632 - 633 - user := s.oauth.GetUser(r) 634 - s.pages.RepoBlob(w, pages.RepoBlobParams{ 635 - LoggedInUser: user, 636 - RepoInfo: f.RepoInfo(s, user), 637 - RepoBlobResponse: result, 638 - BreadCrumbs: breadcrumbs, 639 - ShowRendered: showRendered, 640 - RenderToggle: renderToggle, 641 - }) 642 - return 643 - } 644 - 645 - func (s *State) RepoBlobRaw(w http.ResponseWriter, r *http.Request) { 646 - f, err := s.fullyResolvedRepo(r) 647 - if err != nil { 648 - log.Println("failed to get repo and knot", err) 649 - return 650 - } 651 - 652 - ref := chi.URLParam(r, "ref") 653 - filePath := chi.URLParam(r, "*") 654 - 655 - protocol := "http" 656 - if !s.config.Core.Dev { 657 - protocol = "https" 658 - } 659 - resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath)) 660 - if err != nil { 661 - log.Println("failed to reach knotserver", err) 662 - return 663 - } 664 - 665 - body, err := io.ReadAll(resp.Body) 666 - if err != nil { 667 - log.Printf("Error reading response body: %v", err) 668 - return 669 - } 670 - 671 - var result types.RepoBlobResponse 672 - err = json.Unmarshal(body, &result) 673 - if err != nil { 674 - log.Println("failed to parse response:", err) 675 - return 676 - } 677 - 678 - if result.IsBinary { 679 - w.Header().Set("Content-Type", "application/octet-stream") 680 - w.Write(body) 681 - return 682 - } 683 - 684 - w.Header().Set("Content-Type", "text/plain") 685 - w.Write([]byte(result.Contents)) 686 - return 687 - } 688 - 689 - func (s *State) AddCollaborator(w http.ResponseWriter, r *http.Request) { 690 - f, err := s.fullyResolvedRepo(r) 691 - if err != nil { 692 - log.Println("failed to get repo and knot", err) 693 - return 694 - } 695 - 696 - collaborator := r.FormValue("collaborator") 697 - if collaborator == "" { 698 - http.Error(w, "malformed form", http.StatusBadRequest) 699 - return 700 - } 701 - 702 - collaboratorIdent, err := s.resolver.ResolveIdent(r.Context(), collaborator) 703 - if err != nil { 704 - w.Write([]byte("failed to resolve collaborator did to a handle")) 705 - return 706 - } 707 - log.Printf("adding %s to %s\n", collaboratorIdent.Handle.String(), f.Knot) 708 - 709 - // TODO: create an atproto record for this 710 - 711 - secret, err := db.GetRegistrationKey(s.db, f.Knot) 712 - if err != nil { 713 - log.Printf("no key found for domain %s: %s\n", f.Knot, err) 714 - return 715 - } 716 - 717 - ksClient, err := knotclient.NewSignedClient(f.Knot, secret, s.config.Core.Dev) 718 - if err != nil { 719 - log.Println("failed to create client to ", f.Knot) 720 - return 721 - } 722 - 723 - ksResp, err := ksClient.AddCollaborator(f.OwnerDid(), f.RepoName, collaboratorIdent.DID.String()) 724 - if err != nil { 725 - log.Printf("failed to make request to %s: %s", f.Knot, err) 726 - return 727 - } 728 - 729 - if ksResp.StatusCode != http.StatusNoContent { 730 - w.Write([]byte(fmt.Sprint("knotserver failed to add collaborator: ", err))) 731 - return 732 - } 733 - 734 - tx, err := s.db.BeginTx(r.Context(), nil) 735 - if err != nil { 736 - log.Println("failed to start tx") 737 - w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err))) 738 - return 739 - } 740 - defer func() { 741 - tx.Rollback() 742 - err = s.enforcer.E.LoadPolicy() 743 - if err != nil { 744 - log.Println("failed to rollback policies") 745 - } 746 - }() 747 - 748 - err = s.enforcer.AddCollaborator(collaboratorIdent.DID.String(), f.Knot, f.DidSlashRepo()) 749 - if err != nil { 750 - w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err))) 751 - return 752 - } 753 - 754 - err = db.AddCollaborator(s.db, collaboratorIdent.DID.String(), f.OwnerDid(), f.RepoName, f.Knot) 755 - if err != nil { 756 - w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err))) 757 - return 758 - } 759 - 760 - err = tx.Commit() 761 - if err != nil { 762 - log.Println("failed to commit changes", err) 763 - http.Error(w, err.Error(), http.StatusInternalServerError) 764 - return 765 - } 766 - 767 - err = s.enforcer.E.SavePolicy() 768 - if err != nil { 769 - log.Println("failed to update ACLs", err) 770 - http.Error(w, err.Error(), http.StatusInternalServerError) 771 - return 772 - } 773 - 774 - w.Write([]byte(fmt.Sprint("added collaborator: ", collaboratorIdent.Handle.String()))) 775 - 776 - } 777 - 778 - func (s *State) DeleteRepo(w http.ResponseWriter, r *http.Request) { 779 - user := s.oauth.GetUser(r) 780 - 781 - f, err := s.fullyResolvedRepo(r) 782 - if err != nil { 783 - log.Println("failed to get repo and knot", err) 784 - return 785 - } 786 - 787 - // remove record from pds 788 - xrpcClient, err := s.oauth.AuthorizedClient(r) 789 - if err != nil { 790 - log.Println("failed to get authorized client", err) 791 - return 792 - } 793 - repoRkey := f.RepoAt.RecordKey().String() 794 - _, err = xrpcClient.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 795 - Collection: tangled.RepoNSID, 796 - Repo: user.Did, 797 - Rkey: repoRkey, 798 - }) 799 - if err != nil { 800 - log.Printf("failed to delete record: %s", err) 801 - s.pages.Notice(w, "settings-delete", "Failed to delete repository from PDS.") 802 - return 803 - } 804 - log.Println("removed repo record ", f.RepoAt.String()) 805 - 806 - secret, err := db.GetRegistrationKey(s.db, f.Knot) 807 - if err != nil { 808 - log.Printf("no key found for domain %s: %s\n", f.Knot, err) 809 - return 810 - } 811 - 812 - ksClient, err := knotclient.NewSignedClient(f.Knot, secret, s.config.Core.Dev) 813 - if err != nil { 814 - log.Println("failed to create client to ", f.Knot) 815 - return 816 - } 817 - 818 - ksResp, err := ksClient.RemoveRepo(f.OwnerDid(), f.RepoName) 819 - if err != nil { 820 - log.Printf("failed to make request to %s: %s", f.Knot, err) 821 - return 822 - } 823 - 824 - if ksResp.StatusCode != http.StatusNoContent { 825 - log.Println("failed to remove repo from knot, continuing anyway ", f.Knot) 826 - } else { 827 - log.Println("removed repo from knot ", f.Knot) 828 - } 829 - 830 - tx, err := s.db.BeginTx(r.Context(), nil) 831 - if err != nil { 832 - log.Println("failed to start tx") 833 - w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err))) 834 - return 835 - } 836 - defer func() { 837 - tx.Rollback() 838 - err = s.enforcer.E.LoadPolicy() 839 - if err != nil { 840 - log.Println("failed to rollback policies") 841 - } 842 - }() 843 - 844 - // remove collaborator RBAC 845 - repoCollaborators, err := s.enforcer.E.GetImplicitUsersForResourceByDomain(f.DidSlashRepo(), f.Knot) 846 - if err != nil { 847 - s.pages.Notice(w, "settings-delete", "Failed to remove collaborators") 848 - return 849 - } 850 - for _, c := range repoCollaborators { 851 - did := c[0] 852 - s.enforcer.RemoveCollaborator(did, f.Knot, f.DidSlashRepo()) 853 - } 854 - log.Println("removed collaborators") 855 - 856 - // remove repo RBAC 857 - err = s.enforcer.RemoveRepo(f.OwnerDid(), f.Knot, f.DidSlashRepo()) 858 - if err != nil { 859 - s.pages.Notice(w, "settings-delete", "Failed to update RBAC rules") 860 - return 861 - } 862 - 863 - // remove repo from db 864 - err = db.RemoveRepo(tx, f.OwnerDid(), f.RepoName) 865 - if err != nil { 866 - s.pages.Notice(w, "settings-delete", "Failed to update appview") 867 - return 868 - } 869 - log.Println("removed repo from db") 870 - 871 - err = tx.Commit() 872 - if err != nil { 873 - log.Println("failed to commit changes", err) 874 - http.Error(w, err.Error(), http.StatusInternalServerError) 875 - return 876 - } 877 - 878 - err = s.enforcer.E.SavePolicy() 879 - if err != nil { 880 - log.Println("failed to update ACLs", err) 881 - http.Error(w, err.Error(), http.StatusInternalServerError) 882 - return 883 - } 884 - 885 - s.pages.HxRedirect(w, fmt.Sprintf("/%s", f.OwnerDid())) 886 - } 887 - 888 - func (s *State) SetDefaultBranch(w http.ResponseWriter, r *http.Request) { 889 - f, err := s.fullyResolvedRepo(r) 890 - if err != nil { 891 - log.Println("failed to get repo and knot", err) 892 - return 893 - } 894 - 895 - branch := r.FormValue("branch") 896 - if branch == "" { 897 - http.Error(w, "malformed form", http.StatusBadRequest) 898 - return 899 - } 900 - 901 - secret, err := db.GetRegistrationKey(s.db, f.Knot) 902 - if err != nil { 903 - log.Printf("no key found for domain %s: %s\n", f.Knot, err) 904 - return 905 - } 906 - 907 - ksClient, err := knotclient.NewSignedClient(f.Knot, secret, s.config.Core.Dev) 908 - if err != nil { 909 - log.Println("failed to create client to ", f.Knot) 910 - return 911 - } 912 - 913 - ksResp, err := ksClient.SetDefaultBranch(f.OwnerDid(), f.RepoName, branch) 914 - if err != nil { 915 - log.Printf("failed to make request to %s: %s", f.Knot, err) 916 - return 917 - } 918 - 919 - if ksResp.StatusCode != http.StatusNoContent { 920 - s.pages.Notice(w, "repo-settings", "Failed to set default branch. Try again later.") 921 - return 922 - } 923 - 924 - w.Write([]byte(fmt.Sprint("default branch set to: ", branch))) 925 - } 926 - 927 - func (s *State) RepoSettings(w http.ResponseWriter, r *http.Request) { 928 - f, err := s.fullyResolvedRepo(r) 929 - if err != nil { 930 - log.Println("failed to get repo and knot", err) 931 - return 932 - } 933 - 934 - switch r.Method { 935 - case http.MethodGet: 936 - // for now, this is just pubkeys 937 - user := s.oauth.GetUser(r) 938 - repoCollaborators, err := f.Collaborators(r.Context(), s) 939 - if err != nil { 940 - log.Println("failed to get collaborators", err) 941 - } 942 - 943 - isCollaboratorInviteAllowed := false 944 - if user != nil { 945 - ok, err := s.enforcer.IsCollaboratorInviteAllowed(user.Did, f.Knot, f.DidSlashRepo()) 946 - if err == nil && ok { 947 - isCollaboratorInviteAllowed = true 948 - } 949 - } 950 - 951 - us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 952 - if err != nil { 953 - log.Println("failed to create unsigned client", err) 954 - return 955 - } 956 - 957 - result, err := us.Branches(f.OwnerDid(), f.RepoName) 958 - if err != nil { 959 - log.Println("failed to reach knotserver", err) 960 - return 961 - } 962 - 963 - s.pages.RepoSettings(w, pages.RepoSettingsParams{ 964 - LoggedInUser: user, 965 - RepoInfo: f.RepoInfo(s, user), 966 - Collaborators: repoCollaborators, 967 - IsCollaboratorInviteAllowed: isCollaboratorInviteAllowed, 968 - Branches: result.Branches, 969 - }) 970 - } 971 - } 972 - 973 - type FullyResolvedRepo struct { 974 - Knot string 975 - OwnerId identity.Identity 976 - RepoName string 977 - RepoAt syntax.ATURI 978 - Description string 979 - CreatedAt string 980 - Ref string 981 - CurrentDir string 982 - } 983 - 984 - func (f *FullyResolvedRepo) OwnerDid() string { 985 - return f.OwnerId.DID.String() 986 - } 987 - 988 - func (f *FullyResolvedRepo) OwnerHandle() string { 989 - return f.OwnerId.Handle.String() 990 - } 991 - 992 - func (f *FullyResolvedRepo) OwnerSlashRepo() string { 993 - handle := f.OwnerId.Handle 994 - 995 - var p string 996 - if handle != "" && !handle.IsInvalidHandle() { 997 - p, _ = securejoin.SecureJoin(fmt.Sprintf("@%s", handle), f.RepoName) 998 - } else { 999 - p, _ = securejoin.SecureJoin(f.OwnerDid(), f.RepoName) 1000 - } 1001 - 1002 - return p 1003 - } 1004 - 1005 - func (f *FullyResolvedRepo) DidSlashRepo() string { 1006 - p, _ := securejoin.SecureJoin(f.OwnerDid(), f.RepoName) 1007 - return p 1008 - } 1009 - 1010 - func (f *FullyResolvedRepo) Collaborators(ctx context.Context, s *State) ([]pages.Collaborator, error) { 1011 - repoCollaborators, err := s.enforcer.E.GetImplicitUsersForResourceByDomain(f.DidSlashRepo(), f.Knot) 1012 - if err != nil { 1013 - return nil, err 1014 - } 1015 - 1016 - var collaborators []pages.Collaborator 1017 - for _, item := range repoCollaborators { 1018 - // currently only two roles: owner and member 1019 - var role string 1020 - if item[3] == "repo:owner" { 1021 - role = "owner" 1022 - } else if item[3] == "repo:collaborator" { 1023 - role = "collaborator" 1024 - } else { 1025 - continue 1026 - } 1027 - 1028 - did := item[0] 1029 - 1030 - c := pages.Collaborator{ 1031 - Did: did, 1032 - Handle: "", 1033 - Role: role, 1034 - } 1035 - collaborators = append(collaborators, c) 1036 - } 1037 - 1038 - // populate all collborators with handles 1039 - identsToResolve := make([]string, len(collaborators)) 1040 - for i, collab := range collaborators { 1041 - identsToResolve[i] = collab.Did 1042 - } 1043 - 1044 - resolvedIdents := s.resolver.ResolveIdents(ctx, identsToResolve) 1045 - for i, resolved := range resolvedIdents { 1046 - if resolved != nil { 1047 - collaborators[i].Handle = resolved.Handle.String() 1048 - } 1049 - } 1050 - 1051 - return collaborators, nil 1052 - } 1053 - 1054 - func (f *FullyResolvedRepo) RepoInfo(s *State, u *oauth.User) repoinfo.RepoInfo { 1055 - isStarred := false 1056 - if u != nil { 1057 - isStarred = db.GetStarStatus(s.db, u.Did, syntax.ATURI(f.RepoAt)) 1058 - } 1059 - 1060 - starCount, err := db.GetStarCount(s.db, f.RepoAt) 1061 - if err != nil { 1062 - log.Println("failed to get star count for ", f.RepoAt) 1063 - } 1064 - issueCount, err := db.GetIssueCount(s.db, f.RepoAt) 1065 - if err != nil { 1066 - log.Println("failed to get issue count for ", f.RepoAt) 1067 - } 1068 - pullCount, err := db.GetPullCount(s.db, f.RepoAt) 1069 - if err != nil { 1070 - log.Println("failed to get issue count for ", f.RepoAt) 1071 - } 1072 - source, err := db.GetRepoSource(s.db, f.RepoAt) 1073 - if errors.Is(err, sql.ErrNoRows) { 1074 - source = "" 1075 - } else if err != nil { 1076 - log.Println("failed to get repo source for ", f.RepoAt, err) 1077 - } 1078 - 1079 - var sourceRepo *db.Repo 1080 - if source != "" { 1081 - sourceRepo, err = db.GetRepoByAtUri(s.db, source) 1082 - if err != nil { 1083 - log.Println("failed to get repo by at uri", err) 1084 - } 1085 - } 1086 - 1087 - var sourceHandle *identity.Identity 1088 - if sourceRepo != nil { 1089 - sourceHandle, err = s.resolver.ResolveIdent(context.Background(), sourceRepo.Did) 1090 - if err != nil { 1091 - log.Println("failed to resolve source repo", err) 1092 - } 1093 - } 1094 - 1095 - knot := f.Knot 1096 - var disableFork bool 1097 - us, err := knotclient.NewUnsignedClient(knot, s.config.Core.Dev) 1098 - if err != nil { 1099 - log.Printf("failed to create unsigned client for %s: %v", knot, err) 1100 - } else { 1101 - result, err := us.Branches(f.OwnerDid(), f.RepoName) 1102 - if err != nil { 1103 - log.Printf("failed to get branches for %s/%s: %v", f.OwnerDid(), f.RepoName, err) 1104 - } 1105 - 1106 - if len(result.Branches) == 0 { 1107 - disableFork = true 1108 - } 1109 - } 1110 - 1111 - repoInfo := repoinfo.RepoInfo{ 1112 - OwnerDid: f.OwnerDid(), 1113 - OwnerHandle: f.OwnerHandle(), 1114 - Name: f.RepoName, 1115 - RepoAt: f.RepoAt, 1116 - Description: f.Description, 1117 - Ref: f.Ref, 1118 - IsStarred: isStarred, 1119 - Knot: knot, 1120 - Roles: RolesInRepo(s, u, f), 1121 - Stats: db.RepoStats{ 1122 - StarCount: starCount, 1123 - IssueCount: issueCount, 1124 - PullCount: pullCount, 1125 - }, 1126 - DisableFork: disableFork, 1127 - CurrentDir: f.CurrentDir, 1128 - } 1129 - 1130 - if sourceRepo != nil { 1131 - repoInfo.Source = sourceRepo 1132 - repoInfo.SourceHandle = sourceHandle.Handle.String() 1133 - } 1134 - 1135 - return repoInfo 1136 - } 1137 - 1138 - func (s *State) RepoSingleIssue(w http.ResponseWriter, r *http.Request) { 1139 - user := s.oauth.GetUser(r) 1140 - f, err := s.fullyResolvedRepo(r) 1141 - if err != nil { 1142 - log.Println("failed to get repo and knot", err) 1143 - return 1144 - } 1145 - 1146 - issueId := chi.URLParam(r, "issue") 1147 - issueIdInt, err := strconv.Atoi(issueId) 1148 - if err != nil { 1149 - http.Error(w, "bad issue id", http.StatusBadRequest) 1150 - log.Println("failed to parse issue id", err) 1151 - return 1152 - } 1153 - 1154 - issue, comments, err := db.GetIssueWithComments(s.db, f.RepoAt, issueIdInt) 1155 - if err != nil { 1156 - log.Println("failed to get issue and comments", err) 1157 - s.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 1158 - return 1159 - } 1160 - 1161 - issueOwnerIdent, err := s.resolver.ResolveIdent(r.Context(), issue.OwnerDid) 1162 - if err != nil { 1163 - log.Println("failed to resolve issue owner", err) 1164 - } 1165 - 1166 - identsToResolve := make([]string, len(comments)) 1167 - for i, comment := range comments { 1168 - identsToResolve[i] = comment.OwnerDid 1169 - } 1170 - resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve) 1171 - didHandleMap := make(map[string]string) 1172 - for _, identity := range resolvedIds { 1173 - if !identity.Handle.IsInvalidHandle() { 1174 - didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 1175 - } else { 1176 - didHandleMap[identity.DID.String()] = identity.DID.String() 1177 - } 1178 - } 1179 - 1180 - s.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{ 1181 - LoggedInUser: user, 1182 - RepoInfo: f.RepoInfo(s, user), 1183 - Issue: *issue, 1184 - Comments: comments, 1185 - 1186 - IssueOwnerHandle: issueOwnerIdent.Handle.String(), 1187 - DidHandleMap: didHandleMap, 1188 - }) 1189 - 1190 - } 1191 - 1192 - func (s *State) CloseIssue(w http.ResponseWriter, r *http.Request) { 1193 - user := s.oauth.GetUser(r) 1194 - f, err := s.fullyResolvedRepo(r) 1195 - if err != nil { 1196 - log.Println("failed to get repo and knot", err) 1197 - return 1198 - } 1199 - 1200 - issueId := chi.URLParam(r, "issue") 1201 - issueIdInt, err := strconv.Atoi(issueId) 1202 - if err != nil { 1203 - http.Error(w, "bad issue id", http.StatusBadRequest) 1204 - log.Println("failed to parse issue id", err) 1205 - return 1206 - } 1207 - 1208 - issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt) 1209 - if err != nil { 1210 - log.Println("failed to get issue", err) 1211 - s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 1212 - return 1213 - } 1214 - 1215 - collaborators, err := f.Collaborators(r.Context(), s) 1216 - if err != nil { 1217 - log.Println("failed to fetch repo collaborators: %w", err) 1218 - } 1219 - isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 1220 - return user.Did == collab.Did 1221 - }) 1222 - isIssueOwner := user.Did == issue.OwnerDid 1223 - 1224 - // TODO: make this more granular 1225 - if isIssueOwner || isCollaborator { 1226 - 1227 - closed := tangled.RepoIssueStateClosed 1228 - 1229 - client, err := s.oauth.AuthorizedClient(r) 1230 - if err != nil { 1231 - log.Println("failed to get authorized client", err) 1232 - return 1233 - } 1234 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1235 - Collection: tangled.RepoIssueStateNSID, 1236 - Repo: user.Did, 1237 - Rkey: appview.TID(), 1238 - Record: &lexutil.LexiconTypeDecoder{ 1239 - Val: &tangled.RepoIssueState{ 1240 - Issue: issue.IssueAt, 1241 - State: closed, 1242 - }, 1243 - }, 1244 - }) 1245 - 1246 - if err != nil { 1247 - log.Println("failed to update issue state", err) 1248 - s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 1249 - return 1250 - } 1251 - 1252 - err = db.CloseIssue(s.db, f.RepoAt, issueIdInt) 1253 - if err != nil { 1254 - log.Println("failed to close issue", err) 1255 - s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 1256 - return 1257 - } 1258 - 1259 - s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt)) 1260 - return 1261 - } else { 1262 - log.Println("user is not permitted to close issue") 1263 - http.Error(w, "for biden", http.StatusUnauthorized) 1264 - return 1265 - } 1266 - } 1267 - 1268 - func (s *State) ReopenIssue(w http.ResponseWriter, r *http.Request) { 1269 - user := s.oauth.GetUser(r) 1270 - f, err := s.fullyResolvedRepo(r) 1271 - if err != nil { 1272 - log.Println("failed to get repo and knot", err) 1273 - return 1274 - } 1275 - 1276 - issueId := chi.URLParam(r, "issue") 1277 - issueIdInt, err := strconv.Atoi(issueId) 1278 - if err != nil { 1279 - http.Error(w, "bad issue id", http.StatusBadRequest) 1280 - log.Println("failed to parse issue id", err) 1281 - return 1282 - } 1283 - 1284 - issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt) 1285 - if err != nil { 1286 - log.Println("failed to get issue", err) 1287 - s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.") 1288 - return 1289 - } 1290 - 1291 - collaborators, err := f.Collaborators(r.Context(), s) 1292 - if err != nil { 1293 - log.Println("failed to fetch repo collaborators: %w", err) 1294 - } 1295 - isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 1296 - return user.Did == collab.Did 1297 - }) 1298 - isIssueOwner := user.Did == issue.OwnerDid 1299 - 1300 - if isCollaborator || isIssueOwner { 1301 - err := db.ReopenIssue(s.db, f.RepoAt, issueIdInt) 1302 - if err != nil { 1303 - log.Println("failed to reopen issue", err) 1304 - s.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.") 1305 - return 1306 - } 1307 - s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt)) 1308 - return 1309 - } else { 1310 - log.Println("user is not the owner of the repo") 1311 - http.Error(w, "forbidden", http.StatusUnauthorized) 1312 - return 1313 - } 1314 - } 1315 - 1316 - func (s *State) NewIssueComment(w http.ResponseWriter, r *http.Request) { 1317 - user := s.oauth.GetUser(r) 1318 - f, err := s.fullyResolvedRepo(r) 1319 - if err != nil { 1320 - log.Println("failed to get repo and knot", err) 1321 - return 1322 - } 1323 - 1324 - issueId := chi.URLParam(r, "issue") 1325 - issueIdInt, err := strconv.Atoi(issueId) 1326 - if err != nil { 1327 - http.Error(w, "bad issue id", http.StatusBadRequest) 1328 - log.Println("failed to parse issue id", err) 1329 - return 1330 - } 1331 - 1332 - switch r.Method { 1333 - case http.MethodPost: 1334 - body := r.FormValue("body") 1335 - if body == "" { 1336 - s.pages.Notice(w, "issue", "Body is required") 1337 - return 1338 - } 1339 - 1340 - commentId := mathrand.IntN(1000000) 1341 - rkey := appview.TID() 1342 - 1343 - err := db.NewIssueComment(s.db, &db.Comment{ 1344 - OwnerDid: user.Did, 1345 - RepoAt: f.RepoAt, 1346 - Issue: issueIdInt, 1347 - CommentId: commentId, 1348 - Body: body, 1349 - Rkey: rkey, 1350 - }) 1351 - if err != nil { 1352 - log.Println("failed to create comment", err) 1353 - s.pages.Notice(w, "issue-comment", "Failed to create comment.") 1354 - return 1355 - } 1356 - 1357 - createdAt := time.Now().Format(time.RFC3339) 1358 - commentIdInt64 := int64(commentId) 1359 - ownerDid := user.Did 1360 - issueAt, err := db.GetIssueAt(s.db, f.RepoAt, issueIdInt) 1361 - if err != nil { 1362 - log.Println("failed to get issue at", err) 1363 - s.pages.Notice(w, "issue-comment", "Failed to create comment.") 1364 - return 1365 - } 1366 - 1367 - atUri := f.RepoAt.String() 1368 - client, err := s.oauth.AuthorizedClient(r) 1369 - if err != nil { 1370 - log.Println("failed to get authorized client", err) 1371 - s.pages.Notice(w, "issue-comment", "Failed to create comment.") 1372 - return 1373 - } 1374 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1375 - Collection: tangled.RepoIssueCommentNSID, 1376 - Repo: user.Did, 1377 - Rkey: rkey, 1378 - Record: &lexutil.LexiconTypeDecoder{ 1379 - Val: &tangled.RepoIssueComment{ 1380 - Repo: &atUri, 1381 - Issue: issueAt, 1382 - CommentId: &commentIdInt64, 1383 - Owner: &ownerDid, 1384 - Body: body, 1385 - CreatedAt: createdAt, 1386 - }, 1387 - }, 1388 - }) 1389 - if err != nil { 1390 - log.Println("failed to create comment", err) 1391 - s.pages.Notice(w, "issue-comment", "Failed to create comment.") 1392 - return 1393 - } 1394 - 1395 - s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issueIdInt, commentId)) 1396 - return 1397 - } 1398 - } 1399 - 1400 - func (s *State) IssueComment(w http.ResponseWriter, r *http.Request) { 1401 - user := s.oauth.GetUser(r) 1402 - f, err := s.fullyResolvedRepo(r) 1403 - if err != nil { 1404 - log.Println("failed to get repo and knot", err) 1405 - return 1406 - } 1407 - 1408 - issueId := chi.URLParam(r, "issue") 1409 - issueIdInt, err := strconv.Atoi(issueId) 1410 - if err != nil { 1411 - http.Error(w, "bad issue id", http.StatusBadRequest) 1412 - log.Println("failed to parse issue id", err) 1413 - return 1414 - } 1415 - 1416 - commentId := chi.URLParam(r, "comment_id") 1417 - commentIdInt, err := strconv.Atoi(commentId) 1418 - if err != nil { 1419 - http.Error(w, "bad comment id", http.StatusBadRequest) 1420 - log.Println("failed to parse issue id", err) 1421 - return 1422 - } 1423 - 1424 - issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt) 1425 - if err != nil { 1426 - log.Println("failed to get issue", err) 1427 - s.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 1428 - return 1429 - } 1430 - 1431 - comment, err := db.GetComment(s.db, f.RepoAt, issueIdInt, commentIdInt) 1432 - if err != nil { 1433 - http.Error(w, "bad comment id", http.StatusBadRequest) 1434 - return 1435 - } 1436 - 1437 - identity, err := s.resolver.ResolveIdent(r.Context(), comment.OwnerDid) 1438 - if err != nil { 1439 - log.Println("failed to resolve did") 1440 - return 1441 - } 1442 - 1443 - didHandleMap := make(map[string]string) 1444 - if !identity.Handle.IsInvalidHandle() { 1445 - didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 1446 - } else { 1447 - didHandleMap[identity.DID.String()] = identity.DID.String() 1448 - } 1449 - 1450 - s.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ 1451 - LoggedInUser: user, 1452 - RepoInfo: f.RepoInfo(s, user), 1453 - DidHandleMap: didHandleMap, 1454 - Issue: issue, 1455 - Comment: comment, 1456 - }) 1457 - } 1458 - 1459 - func (s *State) EditIssueComment(w http.ResponseWriter, r *http.Request) { 1460 - user := s.oauth.GetUser(r) 1461 - f, err := s.fullyResolvedRepo(r) 1462 - if err != nil { 1463 - log.Println("failed to get repo and knot", err) 1464 - return 1465 - } 1466 - 1467 - issueId := chi.URLParam(r, "issue") 1468 - issueIdInt, err := strconv.Atoi(issueId) 1469 - if err != nil { 1470 - http.Error(w, "bad issue id", http.StatusBadRequest) 1471 - log.Println("failed to parse issue id", err) 1472 - return 1473 - } 1474 - 1475 - commentId := chi.URLParam(r, "comment_id") 1476 - commentIdInt, err := strconv.Atoi(commentId) 1477 - if err != nil { 1478 - http.Error(w, "bad comment id", http.StatusBadRequest) 1479 - log.Println("failed to parse issue id", err) 1480 - return 1481 - } 1482 - 1483 - issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt) 1484 - if err != nil { 1485 - log.Println("failed to get issue", err) 1486 - s.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 1487 - return 1488 - } 1489 - 1490 - comment, err := db.GetComment(s.db, f.RepoAt, issueIdInt, commentIdInt) 1491 - if err != nil { 1492 - http.Error(w, "bad comment id", http.StatusBadRequest) 1493 - return 1494 - } 1495 - 1496 - if comment.OwnerDid != user.Did { 1497 - http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 1498 - return 1499 - } 1500 - 1501 - switch r.Method { 1502 - case http.MethodGet: 1503 - s.pages.EditIssueCommentFragment(w, pages.EditIssueCommentParams{ 1504 - LoggedInUser: user, 1505 - RepoInfo: f.RepoInfo(s, user), 1506 - Issue: issue, 1507 - Comment: comment, 1508 - }) 1509 - case http.MethodPost: 1510 - // extract form value 1511 - newBody := r.FormValue("body") 1512 - client, err := s.oauth.AuthorizedClient(r) 1513 - if err != nil { 1514 - log.Println("failed to get authorized client", err) 1515 - s.pages.Notice(w, "issue-comment", "Failed to create comment.") 1516 - return 1517 - } 1518 - rkey := comment.Rkey 1519 - 1520 - // optimistic update 1521 - edited := time.Now() 1522 - err = db.EditComment(s.db, comment.RepoAt, comment.Issue, comment.CommentId, newBody) 1523 - if err != nil { 1524 - log.Println("failed to perferom update-description query", err) 1525 - s.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") 1526 - return 1527 - } 1528 - 1529 - // rkey is optional, it was introduced later 1530 - if comment.Rkey != "" { 1531 - // update the record on pds 1532 - ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueCommentNSID, user.Did, rkey) 1533 - if err != nil { 1534 - // failed to get record 1535 - log.Println(err, rkey) 1536 - s.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.") 1537 - return 1538 - } 1539 - value, _ := ex.Value.MarshalJSON() // we just did get record; it is valid json 1540 - record, _ := data.UnmarshalJSON(value) 1541 - 1542 - repoAt := record["repo"].(string) 1543 - issueAt := record["issue"].(string) 1544 - createdAt := record["createdAt"].(string) 1545 - commentIdInt64 := int64(commentIdInt) 1546 - 1547 - _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1548 - Collection: tangled.RepoIssueCommentNSID, 1549 - Repo: user.Did, 1550 - Rkey: rkey, 1551 - SwapRecord: ex.Cid, 1552 - Record: &lexutil.LexiconTypeDecoder{ 1553 - Val: &tangled.RepoIssueComment{ 1554 - Repo: &repoAt, 1555 - Issue: issueAt, 1556 - CommentId: &commentIdInt64, 1557 - Owner: &comment.OwnerDid, 1558 - Body: newBody, 1559 - CreatedAt: createdAt, 1560 - }, 1561 - }, 1562 - }) 1563 - if err != nil { 1564 - log.Println(err) 1565 - } 1566 - } 1567 - 1568 - // optimistic update for htmx 1569 - didHandleMap := map[string]string{ 1570 - user.Did: user.Handle, 1571 - } 1572 - comment.Body = newBody 1573 - comment.Edited = &edited 1574 - 1575 - // return new comment body with htmx 1576 - s.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ 1577 - LoggedInUser: user, 1578 - RepoInfo: f.RepoInfo(s, user), 1579 - DidHandleMap: didHandleMap, 1580 - Issue: issue, 1581 - Comment: comment, 1582 - }) 1583 - return 1584 - 1585 - } 1586 - 1587 - } 1588 - 1589 - func (s *State) DeleteIssueComment(w http.ResponseWriter, r *http.Request) { 1590 - user := s.oauth.GetUser(r) 1591 - f, err := s.fullyResolvedRepo(r) 1592 - if err != nil { 1593 - log.Println("failed to get repo and knot", err) 1594 - return 1595 - } 1596 - 1597 - issueId := chi.URLParam(r, "issue") 1598 - issueIdInt, err := strconv.Atoi(issueId) 1599 - if err != nil { 1600 - http.Error(w, "bad issue id", http.StatusBadRequest) 1601 - log.Println("failed to parse issue id", err) 1602 - return 1603 - } 1604 - 1605 - issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt) 1606 - if err != nil { 1607 - log.Println("failed to get issue", err) 1608 - s.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 1609 - return 1610 - } 1611 - 1612 - commentId := chi.URLParam(r, "comment_id") 1613 - commentIdInt, err := strconv.Atoi(commentId) 1614 - if err != nil { 1615 - http.Error(w, "bad comment id", http.StatusBadRequest) 1616 - log.Println("failed to parse issue id", err) 1617 - return 1618 - } 1619 - 1620 - comment, err := db.GetComment(s.db, f.RepoAt, issueIdInt, commentIdInt) 1621 - if err != nil { 1622 - http.Error(w, "bad comment id", http.StatusBadRequest) 1623 - return 1624 - } 1625 - 1626 - if comment.OwnerDid != user.Did { 1627 - http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 1628 - return 1629 - } 1630 - 1631 - if comment.Deleted != nil { 1632 - http.Error(w, "comment already deleted", http.StatusBadRequest) 1633 - return 1634 - } 1635 - 1636 - // optimistic deletion 1637 - deleted := time.Now() 1638 - err = db.DeleteComment(s.db, f.RepoAt, issueIdInt, commentIdInt) 1639 - if err != nil { 1640 - log.Println("failed to delete comment") 1641 - s.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment") 1642 - return 1643 - } 1644 - 1645 - // delete from pds 1646 - if comment.Rkey != "" { 1647 - client, err := s.oauth.AuthorizedClient(r) 1648 - if err != nil { 1649 - log.Println("failed to get authorized client", err) 1650 - s.pages.Notice(w, "issue-comment", "Failed to delete comment.") 1651 - return 1652 - } 1653 - _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 1654 - Collection: tangled.GraphFollowNSID, 1655 - Repo: user.Did, 1656 - Rkey: comment.Rkey, 1657 - }) 1658 - if err != nil { 1659 - log.Println(err) 1660 - } 1661 - } 1662 - 1663 - // optimistic update for htmx 1664 - didHandleMap := map[string]string{ 1665 - user.Did: user.Handle, 1666 - } 1667 - comment.Body = "" 1668 - comment.Deleted = &deleted 1669 - 1670 - // htmx fragment of comment after deletion 1671 - s.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{ 1672 - LoggedInUser: user, 1673 - RepoInfo: f.RepoInfo(s, user), 1674 - DidHandleMap: didHandleMap, 1675 - Issue: issue, 1676 - Comment: comment, 1677 - }) 1678 - return 1679 - } 1680 - 1681 - func (s *State) RepoIssues(w http.ResponseWriter, r *http.Request) { 1682 - params := r.URL.Query() 1683 - state := params.Get("state") 1684 - isOpen := true 1685 - switch state { 1686 - case "open": 1687 - isOpen = true 1688 - case "closed": 1689 - isOpen = false 1690 - default: 1691 - isOpen = true 1692 - } 1693 - 1694 - page, ok := r.Context().Value("page").(pagination.Page) 1695 - if !ok { 1696 - log.Println("failed to get page") 1697 - page = pagination.FirstPage() 1698 - } 1699 - 1700 - user := s.oauth.GetUser(r) 1701 - f, err := s.fullyResolvedRepo(r) 1702 - if err != nil { 1703 - log.Println("failed to get repo and knot", err) 1704 - return 1705 - } 1706 - 1707 - issues, err := db.GetIssues(s.db, f.RepoAt, isOpen, page) 1708 - if err != nil { 1709 - log.Println("failed to get issues", err) 1710 - s.pages.Notice(w, "issues", "Failed to load issues. Try again later.") 1711 - return 1712 - } 1713 - 1714 - identsToResolve := make([]string, len(issues)) 1715 - for i, issue := range issues { 1716 - identsToResolve[i] = issue.OwnerDid 1717 - } 1718 - resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve) 1719 - didHandleMap := make(map[string]string) 1720 - for _, identity := range resolvedIds { 1721 - if !identity.Handle.IsInvalidHandle() { 1722 - didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 1723 - } else { 1724 - didHandleMap[identity.DID.String()] = identity.DID.String() 1725 - } 1726 - } 1727 - 1728 - s.pages.RepoIssues(w, pages.RepoIssuesParams{ 1729 - LoggedInUser: s.oauth.GetUser(r), 1730 - RepoInfo: f.RepoInfo(s, user), 1731 - Issues: issues, 1732 - DidHandleMap: didHandleMap, 1733 - FilteringByOpen: isOpen, 1734 - Page: page, 1735 - }) 1736 - return 1737 - } 1738 - 1739 - func (s *State) NewIssue(w http.ResponseWriter, r *http.Request) { 1740 - user := s.oauth.GetUser(r) 1741 - 1742 - f, err := s.fullyResolvedRepo(r) 1743 - if err != nil { 1744 - log.Println("failed to get repo and knot", err) 1745 - return 1746 - } 1747 - 1748 - switch r.Method { 1749 - case http.MethodGet: 1750 - s.pages.RepoNewIssue(w, pages.RepoNewIssueParams{ 1751 - LoggedInUser: user, 1752 - RepoInfo: f.RepoInfo(s, user), 1753 - }) 1754 - case http.MethodPost: 1755 - title := r.FormValue("title") 1756 - body := r.FormValue("body") 1757 - 1758 - if title == "" || body == "" { 1759 - s.pages.Notice(w, "issues", "Title and body are required") 1760 - return 1761 - } 1762 - 1763 - tx, err := s.db.BeginTx(r.Context(), nil) 1764 - if err != nil { 1765 - s.pages.Notice(w, "issues", "Failed to create issue, try again later") 1766 - return 1767 - } 1768 - 1769 - err = db.NewIssue(tx, &db.Issue{ 1770 - RepoAt: f.RepoAt, 1771 - Title: title, 1772 - Body: body, 1773 - OwnerDid: user.Did, 1774 - }) 1775 - if err != nil { 1776 - log.Println("failed to create issue", err) 1777 - s.pages.Notice(w, "issues", "Failed to create issue.") 1778 - return 1779 - } 1780 - 1781 - issueId, err := db.GetIssueId(s.db, f.RepoAt) 1782 - if err != nil { 1783 - log.Println("failed to get issue id", err) 1784 - s.pages.Notice(w, "issues", "Failed to create issue.") 1785 - return 1786 - } 1787 - 1788 - client, err := s.oauth.AuthorizedClient(r) 1789 - if err != nil { 1790 - log.Println("failed to get authorized client", err) 1791 - s.pages.Notice(w, "issues", "Failed to create issue.") 1792 - return 1793 - } 1794 - atUri := f.RepoAt.String() 1795 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 1796 - Collection: tangled.RepoIssueNSID, 1797 - Repo: user.Did, 1798 - Rkey: appview.TID(), 1799 - Record: &lexutil.LexiconTypeDecoder{ 1800 - Val: &tangled.RepoIssue{ 1801 - Repo: atUri, 1802 - Title: title, 1803 - Body: &body, 1804 - Owner: user.Did, 1805 - IssueId: int64(issueId), 1806 - }, 1807 - }, 1808 - }) 1809 - if err != nil { 1810 - log.Println("failed to create issue", err) 1811 - s.pages.Notice(w, "issues", "Failed to create issue.") 1812 - return 1813 - } 1814 - 1815 - err = db.SetIssueAt(s.db, f.RepoAt, issueId, resp.Uri) 1816 - if err != nil { 1817 - log.Println("failed to set issue at", err) 1818 - s.pages.Notice(w, "issues", "Failed to create issue.") 1819 - return 1820 - } 1821 - 1822 - if !s.config.Core.Dev { 1823 - err = s.posthog.Enqueue(posthog.Capture{ 1824 - DistinctId: user.Did, 1825 - Event: "new_issue", 1826 - Properties: posthog.Properties{"repo_at": f.RepoAt.String(), "issue_id": issueId}, 1827 - }) 1828 - if err != nil { 1829 - log.Println("failed to enqueue posthog event:", err) 1830 - } 1831 - } 1832 - 1833 - s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueId)) 1834 - return 1835 - } 1836 - } 1837 - 1838 - func (s *State) SyncRepoFork(w http.ResponseWriter, r *http.Request) { 1839 - user := s.oauth.GetUser(r) 1840 - f, err := s.fullyResolvedRepo(r) 1841 - if err != nil { 1842 - log.Printf("failed to resolve source repo: %v", err) 1843 - return 1844 - } 1845 - 1846 - switch r.Method { 1847 - case http.MethodPost: 1848 - secret, err := db.GetRegistrationKey(s.db, f.Knot) 1849 - if err != nil { 1850 - s.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", f.Knot)) 1851 - return 1852 - } 1853 - 1854 - client, err := knotclient.NewSignedClient(f.Knot, secret, s.config.Core.Dev) 1855 - if err != nil { 1856 - s.pages.Notice(w, "repo", "Failed to reach knot server.") 1857 - return 1858 - } 1859 - 1860 - var uri string 1861 - if s.config.Core.Dev { 1862 - uri = "http" 1863 - } else { 1864 - uri = "https" 1865 - } 1866 - forkName := fmt.Sprintf("%s", f.RepoName) 1867 - forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.RepoName) 1868 - 1869 - _, err = client.SyncRepoFork(user.Did, forkSourceUrl, forkName, f.Ref) 1870 - if err != nil { 1871 - s.pages.Notice(w, "repo", "Failed to sync repository fork.") 1872 - return 1873 - } 1874 - 1875 - s.pages.HxRefresh(w) 1876 - return 1877 - } 1878 - } 1879 - 1880 - func (s *State) ForkRepo(w http.ResponseWriter, r *http.Request) { 1881 - user := s.oauth.GetUser(r) 1882 - f, err := s.fullyResolvedRepo(r) 1883 - if err != nil { 1884 - log.Printf("failed to resolve source repo: %v", err) 1885 - return 1886 - } 1887 - 1888 - switch r.Method { 1889 - case http.MethodGet: 1890 - user := s.oauth.GetUser(r) 1891 - knots, err := s.enforcer.GetDomainsForUser(user.Did) 1892 - if err != nil { 1893 - s.pages.Notice(w, "repo", "Invalid user account.") 1894 - return 1895 - } 1896 - 1897 - s.pages.ForkRepo(w, pages.ForkRepoParams{ 1898 - LoggedInUser: user, 1899 - Knots: knots, 1900 - RepoInfo: f.RepoInfo(s, user), 1901 - }) 1902 - 1903 - case http.MethodPost: 1904 - 1905 - knot := r.FormValue("knot") 1906 - if knot == "" { 1907 - s.pages.Notice(w, "repo", "Invalid form submission&mdash;missing knot domain.") 1908 - return 1909 - } 1910 - 1911 - ok, err := s.enforcer.E.Enforce(user.Did, knot, knot, "repo:create") 1912 - if err != nil || !ok { 1913 - s.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.") 1914 - return 1915 - } 1916 - 1917 - forkName := fmt.Sprintf("%s", f.RepoName) 1918 - 1919 - // this check is *only* to see if the forked repo name already exists 1920 - // in the user's account. 1921 - existingRepo, err := db.GetRepo(s.db, user.Did, f.RepoName) 1922 - if err != nil { 1923 - if errors.Is(err, sql.ErrNoRows) { 1924 - // no existing repo with this name found, we can use the name as is 1925 - } else { 1926 - log.Println("error fetching existing repo from db", err) 1927 - s.pages.Notice(w, "repo", "Failed to fork this repository. Try again later.") 1928 - return 1929 - } 1930 - } else if existingRepo != nil { 1931 - // repo with this name already exists, append random string 1932 - forkName = fmt.Sprintf("%s-%s", forkName, randomString(3)) 1933 - } 1934 - secret, err := db.GetRegistrationKey(s.db, knot) 1935 - if err != nil { 1936 - s.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", knot)) 1937 - return 1938 - } 1939 - 1940 - client, err := knotclient.NewSignedClient(knot, secret, s.config.Core.Dev) 1941 - if err != nil { 1942 - s.pages.Notice(w, "repo", "Failed to reach knot server.") 1943 - return 1944 - } 1945 - 1946 - var uri string 1947 - if s.config.Core.Dev { 1948 - uri = "http" 1949 - } else { 1950 - uri = "https" 1951 - } 1952 - forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.RepoName) 1953 - sourceAt := f.RepoAt.String() 1954 - 1955 - rkey := appview.TID() 1956 - repo := &db.Repo{ 1957 - Did: user.Did, 1958 - Name: forkName, 1959 - Knot: knot, 1960 - Rkey: rkey, 1961 - Source: sourceAt, 1962 - } 1963 - 1964 - tx, err := s.db.BeginTx(r.Context(), nil) 1965 - if err != nil { 1966 - log.Println(err) 1967 - s.pages.Notice(w, "repo", "Failed to save repository information.") 1968 - return 1969 - } 1970 - defer func() { 1971 - tx.Rollback() 1972 - err = s.enforcer.E.LoadPolicy() 1973 - if err != nil { 1974 - log.Println("failed to rollback policies") 1975 - } 1976 - }() 1977 - 1978 - resp, err := client.ForkRepo(user.Did, forkSourceUrl, forkName) 1979 - if err != nil { 1980 - s.pages.Notice(w, "repo", "Failed to create repository on knot server.") 1981 - return 1982 - } 1983 - 1984 - switch resp.StatusCode { 1985 - case http.StatusConflict: 1986 - s.pages.Notice(w, "repo", "A repository with that name already exists.") 1987 - return 1988 - case http.StatusInternalServerError: 1989 - s.pages.Notice(w, "repo", "Failed to create repository on knot. Try again later.") 1990 - case http.StatusNoContent: 1991 - // continue 1992 - } 1993 - 1994 - xrpcClient, err := s.oauth.AuthorizedClient(r) 1995 - if err != nil { 1996 - log.Println("failed to get authorized client", err) 1997 - s.pages.Notice(w, "repo", "Failed to create repository.") 1998 - return 1999 - } 2000 - 2001 - createdAt := time.Now().Format(time.RFC3339) 2002 - atresp, err := xrpcClient.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 2003 - Collection: tangled.RepoNSID, 2004 - Repo: user.Did, 2005 - Rkey: rkey, 2006 - Record: &lexutil.LexiconTypeDecoder{ 2007 - Val: &tangled.Repo{ 2008 - Knot: repo.Knot, 2009 - Name: repo.Name, 2010 - CreatedAt: createdAt, 2011 - Owner: user.Did, 2012 - Source: &sourceAt, 2013 - }}, 2014 - }) 2015 - if err != nil { 2016 - log.Printf("failed to create record: %s", err) 2017 - s.pages.Notice(w, "repo", "Failed to announce repository creation.") 2018 - return 2019 - } 2020 - log.Println("created repo record: ", atresp.Uri) 2021 - 2022 - repo.AtUri = atresp.Uri 2023 - err = db.AddRepo(tx, repo) 2024 - if err != nil { 2025 - log.Println(err) 2026 - s.pages.Notice(w, "repo", "Failed to save repository information.") 2027 - return 2028 - } 2029 - 2030 - // acls 2031 - p, _ := securejoin.SecureJoin(user.Did, forkName) 2032 - err = s.enforcer.AddRepo(user.Did, knot, p) 2033 - if err != nil { 2034 - log.Println(err) 2035 - s.pages.Notice(w, "repo", "Failed to set up repository permissions.") 2036 - return 2037 - } 2038 - 2039 - err = tx.Commit() 2040 - if err != nil { 2041 - log.Println("failed to commit changes", err) 2042 - http.Error(w, err.Error(), http.StatusInternalServerError) 2043 - return 2044 - } 2045 - 2046 - err = s.enforcer.E.SavePolicy() 2047 - if err != nil { 2048 - log.Println("failed to update ACLs", err) 2049 - http.Error(w, err.Error(), http.StatusInternalServerError) 2050 - return 2051 - } 2052 - 2053 - s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, forkName)) 2054 - return 2055 - } 2056 - }
-205
appview/state/repo_util.go
··· 1 - package state 2 - 3 - import ( 4 - "context" 5 - "crypto/rand" 6 - "fmt" 7 - "log" 8 - "math/big" 9 - "net/http" 10 - "net/url" 11 - "path" 12 - "strings" 13 - 14 - "github.com/bluesky-social/indigo/atproto/identity" 15 - "github.com/bluesky-social/indigo/atproto/syntax" 16 - "github.com/go-chi/chi/v5" 17 - "github.com/go-git/go-git/v5/plumbing/object" 18 - "tangled.sh/tangled.sh/core/appview/db" 19 - "tangled.sh/tangled.sh/core/appview/oauth" 20 - "tangled.sh/tangled.sh/core/appview/pages/repoinfo" 21 - "tangled.sh/tangled.sh/core/knotclient" 22 - ) 23 - 24 - func (s *State) fullyResolvedRepo(r *http.Request) (*FullyResolvedRepo, error) { 25 - repoName := chi.URLParam(r, "repo") 26 - knot, ok := r.Context().Value("knot").(string) 27 - if !ok { 28 - log.Println("malformed middleware") 29 - return nil, fmt.Errorf("malformed middleware") 30 - } 31 - id, ok := r.Context().Value("resolvedId").(identity.Identity) 32 - if !ok { 33 - log.Println("malformed middleware") 34 - return nil, fmt.Errorf("malformed middleware") 35 - } 36 - 37 - repoAt, ok := r.Context().Value("repoAt").(string) 38 - if !ok { 39 - log.Println("malformed middleware") 40 - return nil, fmt.Errorf("malformed middleware") 41 - } 42 - 43 - parsedRepoAt, err := syntax.ParseATURI(repoAt) 44 - if err != nil { 45 - log.Println("malformed repo at-uri") 46 - return nil, fmt.Errorf("malformed middleware") 47 - } 48 - 49 - ref := chi.URLParam(r, "ref") 50 - 51 - if ref == "" { 52 - us, err := knotclient.NewUnsignedClient(knot, s.config.Core.Dev) 53 - if err != nil { 54 - return nil, err 55 - } 56 - 57 - defaultBranch, err := us.DefaultBranch(id.DID.String(), repoName) 58 - if err != nil { 59 - return nil, err 60 - } 61 - 62 - ref = defaultBranch.Branch 63 - } 64 - 65 - currentDir := path.Dir(extractPathAfterRef(r.URL.EscapedPath(), ref)) 66 - 67 - // pass through values from the middleware 68 - description, ok := r.Context().Value("repoDescription").(string) 69 - addedAt, ok := r.Context().Value("repoAddedAt").(string) 70 - 71 - return &FullyResolvedRepo{ 72 - Knot: knot, 73 - OwnerId: id, 74 - RepoName: repoName, 75 - RepoAt: parsedRepoAt, 76 - Description: description, 77 - CreatedAt: addedAt, 78 - Ref: ref, 79 - CurrentDir: currentDir, 80 - }, nil 81 - } 82 - 83 - func RolesInRepo(s *State, u *oauth.User, f *FullyResolvedRepo) repoinfo.RolesInRepo { 84 - if u != nil { 85 - r := s.enforcer.GetPermissionsInRepo(u.Did, f.Knot, f.DidSlashRepo()) 86 - return repoinfo.RolesInRepo{r} 87 - } else { 88 - return repoinfo.RolesInRepo{} 89 - } 90 - } 91 - 92 - // extractPathAfterRef gets the actual repository path 93 - // after the ref. for example: 94 - // 95 - // /@icyphox.sh/foorepo/blob/main/abc/xyz/ => abc/xyz/ 96 - func extractPathAfterRef(fullPath, ref string) string { 97 - fullPath = strings.TrimPrefix(fullPath, "/") 98 - 99 - ref = url.PathEscape(ref) 100 - 101 - prefixes := []string{ 102 - fmt.Sprintf("blob/%s/", ref), 103 - fmt.Sprintf("tree/%s/", ref), 104 - fmt.Sprintf("raw/%s/", ref), 105 - } 106 - 107 - for _, prefix := range prefixes { 108 - idx := strings.Index(fullPath, prefix) 109 - if idx != -1 { 110 - return fullPath[idx+len(prefix):] 111 - } 112 - } 113 - 114 - return "" 115 - } 116 - 117 - func uniqueEmails(commits []*object.Commit) []string { 118 - emails := make(map[string]struct{}) 119 - for _, commit := range commits { 120 - if commit.Author.Email != "" { 121 - emails[commit.Author.Email] = struct{}{} 122 - } 123 - if commit.Committer.Email != "" { 124 - emails[commit.Committer.Email] = struct{}{} 125 - } 126 - } 127 - var uniqueEmails []string 128 - for email := range emails { 129 - uniqueEmails = append(uniqueEmails, email) 130 - } 131 - return uniqueEmails 132 - } 133 - 134 - func balanceIndexItems(commitCount, branchCount, tagCount, fileCount int) (commitsTrunc int, branchesTrunc int, tagsTrunc int) { 135 - if commitCount == 0 && tagCount == 0 && branchCount == 0 { 136 - return 137 - } 138 - 139 - // typically 1 item on right side = 2 files in height 140 - availableSpace := fileCount / 2 141 - 142 - // clamp tagcount 143 - if tagCount > 0 { 144 - tagsTrunc = 1 145 - availableSpace -= 1 // an extra subtracted for headers etc. 146 - } 147 - 148 - // clamp branchcount 149 - if branchCount > 0 { 150 - branchesTrunc = min(max(branchCount, 1), 2) 151 - availableSpace -= branchesTrunc // an extra subtracted for headers etc. 152 - } 153 - 154 - // show 155 - if commitCount > 0 { 156 - commitsTrunc = max(availableSpace, 3) 157 - } 158 - 159 - return 160 - } 161 - 162 - func EmailToDidOrHandle(s *State, emails []string) map[string]string { 163 - emailToDid, err := db.GetEmailToDid(s.db, emails, true) // only get verified emails for mapping 164 - if err != nil { 165 - log.Printf("error fetching dids for emails: %v", err) 166 - return nil 167 - } 168 - 169 - var dids []string 170 - for _, v := range emailToDid { 171 - dids = append(dids, v) 172 - } 173 - resolvedIdents := s.resolver.ResolveIdents(context.Background(), dids) 174 - 175 - didHandleMap := make(map[string]string) 176 - for _, identity := range resolvedIdents { 177 - if !identity.Handle.IsInvalidHandle() { 178 - didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 179 - } else { 180 - didHandleMap[identity.DID.String()] = identity.DID.String() 181 - } 182 - } 183 - 184 - // Create map of email to didOrHandle for commit display 185 - emailToDidOrHandle := make(map[string]string) 186 - for email, did := range emailToDid { 187 - if didOrHandle, ok := didHandleMap[did]; ok { 188 - emailToDidOrHandle[email] = didOrHandle 189 - } 190 - } 191 - 192 - return emailToDidOrHandle 193 - } 194 - 195 - func randomString(n int) string { 196 - const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" 197 - result := make([]byte, n) 198 - 199 - for i := 0; i < n; i++ { 200 - n, _ := rand.Int(rand.Reader, big.NewInt(int64(len(letters)))) 201 - result[i] = letters[n.Int64()] 202 - } 203 - 204 - return string(result) 205 - }
+42 -141
appview/state/router.go
··· 6 6 7 7 "github.com/go-chi/chi/v5" 8 8 "github.com/gorilla/sessions" 9 + "tangled.sh/tangled.sh/core/appview/issues" 9 10 "tangled.sh/tangled.sh/core/appview/middleware" 10 11 oauthhandler "tangled.sh/tangled.sh/core/appview/oauth/handler" 12 + "tangled.sh/tangled.sh/core/appview/pulls" 13 + "tangled.sh/tangled.sh/core/appview/repo" 11 14 "tangled.sh/tangled.sh/core/appview/settings" 12 15 "tangled.sh/tangled.sh/core/appview/state/userutil" 13 16 ) 14 17 15 18 func (s *State) Router() http.Handler { 16 19 router := chi.NewRouter() 20 + middleware := middleware.New( 21 + s.oauth, 22 + s.db, 23 + s.enforcer, 24 + s.repoResolver, 25 + s.idResolver, 26 + s.pages, 27 + ) 17 28 18 29 router.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) { 19 30 pat := chi.URLParam(r, "*") 20 31 if strings.HasPrefix(pat, "did:") || strings.HasPrefix(pat, "@") { 21 - s.UserRouter().ServeHTTP(w, r) 32 + s.UserRouter(&middleware).ServeHTTP(w, r) 22 33 } else { 23 34 // Check if the first path element is a valid handle without '@' or a flattened DID 24 35 pathParts := strings.SplitN(pat, "/", 2) ··· 41 52 return 42 53 } 43 54 } 44 - s.StandardRouter().ServeHTTP(w, r) 55 + s.StandardRouter(&middleware).ServeHTTP(w, r) 45 56 } 46 57 }) 47 58 48 59 return router 49 60 } 50 61 51 - func (s *State) UserRouter() http.Handler { 62 + func (s *State) UserRouter(mw *middleware.Middleware) http.Handler { 52 63 r := chi.NewRouter() 53 64 54 65 // strip @ from user 55 - r.Use(StripLeadingAt) 66 + r.Use(middleware.StripLeadingAt) 56 67 57 - r.With(ResolveIdent(s)).Route("/{user}", func(r chi.Router) { 68 + r.With(mw.ResolveIdent()).Route("/{user}", func(r chi.Router) { 58 69 r.Get("/", s.Profile) 59 70 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) { 66 - r.Get("/", s.RepoIndex) 67 - r.Get("/*", s.RepoTree) 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 - }) 71 + r.With(mw.ResolveRepo()).Route("/{repo}", func(r chi.Router) { 72 + r.Use(mw.GoImport()) 111 73 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 - r.With(RepoPermissionMiddleware(s, "repo:owner")).Route("/sync", func(r chi.Router) { 117 - r.Post("/", s.SyncRepoFork) 118 - }) 119 - }) 120 - 121 - r.Route("/pulls", func(r chi.Router) { 122 - r.Get("/", s.RepoPulls) 123 - r.With(middleware.AuthMiddleware(s.oauth)).Route("/new", func(r chi.Router) { 124 - r.Get("/", s.NewPull) 125 - r.Get("/patch-upload", s.PatchUploadFragment) 126 - r.Post("/validate-patch", s.ValidatePatch) 127 - r.Get("/compare-branches", s.CompareBranchesFragment) 128 - r.Get("/compare-forks", s.CompareForksFragment) 129 - r.Get("/fork-branches", s.CompareForksBranchesFragment) 130 - r.Post("/", s.NewPull) 131 - }) 132 - 133 - r.Route("/{pull}", func(r chi.Router) { 134 - r.Use(ResolvePull(s)) 135 - r.Get("/", s.RepoSinglePull) 136 - 137 - r.Route("/round/{round}", func(r chi.Router) { 138 - r.Get("/", s.RepoPullPatch) 139 - r.Get("/interdiff", s.RepoPullInterdiff) 140 - r.Get("/actions", s.PullActions) 141 - r.With(middleware.AuthMiddleware(s.oauth)).Route("/comment", func(r chi.Router) { 142 - r.Get("/", s.PullComment) 143 - r.Post("/", s.PullComment) 144 - }) 145 - }) 146 - 147 - r.Route("/round/{round}.patch", func(r chi.Router) { 148 - r.Get("/", s.RepoPullPatchRaw) 149 - }) 150 - 151 - r.Group(func(r chi.Router) { 152 - r.Use(middleware.AuthMiddleware(s.oauth)) 153 - r.Route("/resubmit", func(r chi.Router) { 154 - r.Get("/", s.ResubmitPull) 155 - r.Post("/", s.ResubmitPull) 156 - }) 157 - r.Post("/close", s.ClosePull) 158 - r.Post("/reopen", s.ReopenPull) 159 - // collaborators only 160 - r.Group(func(r chi.Router) { 161 - r.Use(RepoPermissionMiddleware(s, "repo:push")) 162 - r.Post("/merge", s.MergePull) 163 - // maybe lock, etc. 164 - }) 165 - }) 166 - }) 167 - }) 74 + r.Mount("/", s.RepoRouter(mw)) 75 + r.Mount("/issues", s.IssuesRouter(mw)) 76 + r.Mount("/pulls", s.PullsRouter(mw)) 168 77 169 78 // These routes get proxied to the knot 170 79 r.Get("/info/refs", s.InfoRefs) 171 80 r.Post("/git-upload-pack", s.UploadPack) 172 81 r.Post("/git-receive-pack", s.ReceivePack) 173 82 174 - // settings routes, needs auth 175 - r.Group(func(r chi.Router) { 176 - r.Use(middleware.AuthMiddleware(s.oauth)) 177 - // repo description can only be edited by owner 178 - r.With(RepoPermissionMiddleware(s, "repo:owner")).Route("/description", func(r chi.Router) { 179 - r.Put("/", s.RepoDescription) 180 - r.Get("/", s.RepoDescription) 181 - r.Get("/edit", s.RepoDescriptionEdit) 182 - }) 183 - r.With(RepoPermissionMiddleware(s, "repo:settings")).Route("/settings", func(r chi.Router) { 184 - r.Get("/", s.RepoSettings) 185 - r.With(RepoPermissionMiddleware(s, "repo:invite")).Put("/collaborator", s.AddCollaborator) 186 - r.With(RepoPermissionMiddleware(s, "repo:delete")).Delete("/delete", s.DeleteRepo) 187 - r.Put("/branches/default", s.SetDefaultBranch) 188 - }) 189 - }) 190 83 }) 191 84 }) 192 85 ··· 197 90 return r 198 91 } 199 92 200 - func (s *State) StandardRouter() http.Handler { 93 + func (s *State) StandardRouter(mw *middleware.Middleware) http.Handler { 201 94 r := chi.NewRouter() 202 95 203 96 r.Handle("/static/*", s.pages.Static()) ··· 215 108 r.Post("/init", s.InitKnotServer) 216 109 r.Get("/", s.KnotServerInfo) 217 110 r.Route("/member", func(r chi.Router) { 218 - r.Use(KnotOwner(s)) 111 + r.Use(mw.KnotOwner()) 219 112 r.Get("/", s.ListMembers) 220 113 r.Put("/", s.AddMember) 221 114 r.Delete("/", s.RemoveMember) ··· 252 145 253 146 r.Mount("/settings", s.SettingsRouter()) 254 147 r.Mount("/", s.OAuthRouter()) 148 + 255 149 r.Get("/keys/{user}", s.Keys) 256 150 257 151 r.NotFound(func(w http.ResponseWriter, r *http.Request) { ··· 261 155 } 262 156 263 157 func (s *State) OAuthRouter() http.Handler { 264 - oauth := &oauthhandler.OAuthHandler{ 265 - Config: s.config, 266 - Pages: s.pages, 267 - Resolver: s.resolver, 268 - Db: s.db, 269 - Store: sessions.NewCookieStore([]byte(s.config.Core.CookieSecret)), 270 - OAuth: s.oauth, 271 - Enforcer: s.enforcer, 272 - Posthog: s.posthog, 273 - } 274 - 158 + store := sessions.NewCookieStore([]byte(s.config.Core.CookieSecret)) 159 + oauth := oauthhandler.New(s.config, s.pages, s.idResolver, s.db, store, s.oauth, s.enforcer, s.posthog) 275 160 return oauth.Router() 276 161 } 277 162 ··· 285 170 286 171 return settings.Router() 287 172 } 173 + 174 + func (s *State) IssuesRouter(mw *middleware.Middleware) http.Handler { 175 + issues := issues.New(s.oauth, s.repoResolver, s.pages, s.idResolver, s.db, s.config, s.posthog) 176 + return issues.Router(mw) 177 + 178 + } 179 + 180 + func (s *State) PullsRouter(mw *middleware.Middleware) http.Handler { 181 + pulls := pulls.New(s.oauth, s.repoResolver, s.pages, s.idResolver, s.db, s.config) 182 + return pulls.Router(mw) 183 + } 184 + 185 + func (s *State) RepoRouter(mw *middleware.Middleware) http.Handler { 186 + repo := repo.New(s.oauth, s.repoResolver, s.pages, s.idResolver, s.db, s.config, s.posthog, s.enforcer) 187 + return repo.Router(mw) 188 + }
+29 -18
appview/state/state.go
··· 20 20 "github.com/posthog/posthog-go" 21 21 "tangled.sh/tangled.sh/core/api/tangled" 22 22 "tangled.sh/tangled.sh/core/appview" 23 + "tangled.sh/tangled.sh/core/appview/config" 23 24 "tangled.sh/tangled.sh/core/appview/db" 25 + "tangled.sh/tangled.sh/core/appview/idresolver" 24 26 "tangled.sh/tangled.sh/core/appview/oauth" 25 27 "tangled.sh/tangled.sh/core/appview/pages" 28 + "tangled.sh/tangled.sh/core/appview/reporesolver" 26 29 "tangled.sh/tangled.sh/core/jetstream" 27 30 "tangled.sh/tangled.sh/core/knotclient" 28 31 "tangled.sh/tangled.sh/core/rbac" 29 32 ) 30 33 31 34 type State struct { 32 - db *db.DB 33 - oauth *oauth.OAuth 34 - enforcer *rbac.Enforcer 35 - tidClock syntax.TIDClock 36 - pages *pages.Pages 37 - resolver *appview.Resolver 38 - posthog posthog.Client 39 - jc *jetstream.JetstreamClient 40 - config *appview.Config 35 + db *db.DB 36 + oauth *oauth.OAuth 37 + enforcer *rbac.Enforcer 38 + tidClock syntax.TIDClock 39 + pages *pages.Pages 40 + idResolver *idresolver.Resolver 41 + posthog posthog.Client 42 + jc *jetstream.JetstreamClient 43 + config *config.Config 44 + repoResolver *reporesolver.RepoResolver 41 45 } 42 46 43 - func Make(config *appview.Config) (*State, error) { 47 + func Make(config *config.Config) (*State, error) { 44 48 d, err := db.Make(config.Core.DbPath) 45 49 if err != nil { 46 50 return nil, err ··· 55 59 56 60 pgs := pages.NewPages(config) 57 61 58 - resolver := appview.NewResolver() 62 + res, err := idresolver.RedisResolver(config.Redis) 63 + if err != nil { 64 + log.Printf("failed to create redis resolver: %v", err) 65 + res = idresolver.DefaultResolver() 66 + } 59 67 60 68 oauth := oauth.NewOAuth(d, config) 61 69 ··· 64 72 return nil, fmt.Errorf("failed to create posthog client: %w", err) 65 73 } 66 74 75 + repoResolver := reporesolver.New(config, enforcer, res, d) 76 + 67 77 wrapper := db.DbWrapper{d} 68 78 jc, err := jetstream.NewJetstreamClient( 69 79 config.Jetstream.Endpoint, ··· 94 104 enforcer, 95 105 clock, 96 106 pgs, 97 - resolver, 107 + res, 98 108 posthog, 99 109 jc, 100 110 config, 111 + repoResolver, 101 112 } 102 113 103 114 return state, nil ··· 138 149 } 139 150 } 140 151 141 - resolvedIds := s.resolver.ResolveIdents(r.Context(), didsToResolve) 152 + resolvedIds := s.idResolver.ResolveIdents(r.Context(), didsToResolve) 142 153 didHandleMap := make(map[string]string) 143 154 for _, identity := range resolvedIds { 144 155 if !identity.Handle.IsInvalidHandle() { ··· 165 176 166 177 return 167 178 case http.MethodPost: 168 - session, err := s.oauth.Store.Get(r, appview.SessionName) 179 + session, err := s.oauth.Store.Get(r, oauth.SessionName) 169 180 if err != nil || session.IsNew { 170 181 log.Println("unauthorized attempt to generate registration key") 171 182 http.Error(w, "Forbidden", http.StatusUnauthorized) 172 183 return 173 184 } 174 185 175 - did := session.Values[appview.SessionDid].(string) 186 + did := session.Values[oauth.SessionDid].(string) 176 187 177 188 // check if domain is valid url, and strip extra bits down to just host 178 189 domain := r.FormValue("domain") ··· 202 213 return 203 214 } 204 215 205 - id, err := s.resolver.ResolveIdent(r.Context(), user) 216 + id, err := s.idResolver.ResolveIdent(r.Context(), user) 206 217 if err != nil { 207 218 w.WriteHeader(http.StatusInternalServerError) 208 219 return ··· 372 383 didsToResolve = append(didsToResolve, m) 373 384 } 374 385 didsToResolve = append(didsToResolve, reg.ByDid) 375 - resolvedIds := s.resolver.ResolveIdents(r.Context(), didsToResolve) 386 + resolvedIds := s.idResolver.ResolveIdents(r.Context(), didsToResolve) 376 387 didHandleMap := make(map[string]string) 377 388 for _, identity := range resolvedIds { 378 389 if !identity.Handle.IsInvalidHandle() { ··· 444 455 return 445 456 } 446 457 447 - subjectIdentity, err := s.resolver.ResolveIdent(r.Context(), subjectIdentifier) 458 + subjectIdentity, err := s.idResolver.ResolveIdent(r.Context(), subjectIdentifier) 448 459 if err != nil { 449 460 w.Write([]byte("failed to resolve member did to a handle")) 450 461 return
+1 -1
appview/xrpcclient/xrpc.go
··· 7 7 8 8 "github.com/bluesky-social/indigo/api/atproto" 9 9 "github.com/bluesky-social/indigo/xrpc" 10 - oauth "github.com/haileyok/atproto-oauth-golang" 10 + oauth "tangled.sh/icyphox.sh/atproto-oauth" 11 11 ) 12 12 13 13 type Client struct {
+1 -1
avatar/src/index.js
··· 73 73 response = new Response(avatarData, { 74 74 headers: { 75 75 'Content-Type': contentType, 76 - 'Cache-Control': 'public, max-age=3600', 76 + 'Cache-Control': 'public, max-age=43200', // 12 h 77 77 }, 78 78 }); 79 79
+2 -2
cmd/appview/main.go
··· 7 7 "net/http" 8 8 "os" 9 9 10 - "tangled.sh/tangled.sh/core/appview" 10 + "tangled.sh/tangled.sh/core/appview/config" 11 11 "tangled.sh/tangled.sh/core/appview/state" 12 12 ) 13 13 14 14 func main() { 15 15 slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, nil))) 16 16 17 - c, err := appview.LoadConfig(context.Background()) 17 + c, err := config.LoadConfig(context.Background()) 18 18 if err != nil { 19 19 log.Println("failed to load config", "error", err) 20 20 return
+1 -1
cmd/genjwks/main.go
··· 1 - // adapted from https://github.com/haileyok/atproto-oauth-golang 1 + // adapted from https://tangled.sh/icyphox.sh/atproto-oauth 2 2 3 3 package main 4 4
-15
cmd/keyfetch/format.go
··· 1 - package main 2 - 3 - import ( 4 - "fmt" 5 - ) 6 - 7 - func formatKeyData(repoguardPath, gitDir, logPath, endpoint string, data []map[string]interface{}) string { 8 - var result string 9 - for _, entry := range data { 10 - result += fmt.Sprintf( 11 - `command="%s -base-dir %s -user %s -log-path %s -internal-api %s",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty %s`+"\n", 12 - repoguardPath, gitDir, entry["did"], logPath, endpoint, entry["key"]) 13 - } 14 - return result 15 - }
-46
cmd/keyfetch/main.go
··· 1 - // This program must be configured to run as the sshd AuthorizedKeysCommand. 2 - // The format looks something like this: 3 - // Match User git 4 - // AuthorizedKeysCommand /keyfetch -internal-api http://localhost:5444 -repoguard-path /home/git/repoguard 5 - // AuthorizedKeysCommandUser nobody 6 - // 7 - // The command and its parent directories must be owned by root and set to 0755. Hence, the ideal location for this is 8 - // somewhere already owned by root so you don't have to mess with directory perms. 9 - 10 - package main 11 - 12 - import ( 13 - "encoding/json" 14 - "flag" 15 - "fmt" 16 - "io" 17 - "log" 18 - "net/http" 19 - ) 20 - 21 - func main() { 22 - endpoint := flag.String("internal-api", "http://localhost:5444", "Internal API endpoint") 23 - repoguardPath := flag.String("repoguard-path", "/home/git/repoguard", "Path to the repoguard binary") 24 - gitDir := flag.String("git-dir", "/home/git", "Path to the git directory") 25 - logPath := flag.String("log-path", "/home/git/log", "Path to log file") 26 - flag.Parse() 27 - 28 - resp, err := http.Get(*endpoint + "/keys") 29 - if err != nil { 30 - log.Fatalf("error fetching keys: %v", err) 31 - } 32 - defer resp.Body.Close() 33 - 34 - body, err := io.ReadAll(resp.Body) 35 - if err != nil { 36 - log.Fatalf("error reading response body: %v", err) 37 - } 38 - 39 - var data []map[string]interface{} 40 - err = json.Unmarshal(body, &data) 41 - if err != nil { 42 - log.Fatalf("error unmarshalling response body: %v", err) 43 - } 44 - 45 - fmt.Print(formatKeyData(*repoguardPath, *gitDir, *logPath, *endpoint, data)) 46 - }
+33
cmd/knot/main.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "os" 6 + 7 + "github.com/urfave/cli/v3" 8 + "tangled.sh/tangled.sh/core/guard" 9 + "tangled.sh/tangled.sh/core/keyfetch" 10 + "tangled.sh/tangled.sh/core/knotserver" 11 + "tangled.sh/tangled.sh/core/log" 12 + ) 13 + 14 + func main() { 15 + cmd := &cli.Command{ 16 + Name: "knot", 17 + Usage: "knot administration and operation tool", 18 + Commands: []*cli.Command{ 19 + guard.Command(), 20 + knotserver.Command(), 21 + keyfetch.Command(), 22 + }, 23 + } 24 + 25 + ctx := context.Background() 26 + logger := log.New("knot") 27 + ctx = log.IntoContext(ctx, logger.With("command", cmd.Name)) 28 + 29 + if err := cmd.Run(ctx, os.Args); err != nil { 30 + logger.Error(err.Error()) 31 + os.Exit(-1) 32 + } 33 + }
-207
cmd/repoguard/main.go
··· 1 - package main 2 - 3 - import ( 4 - "context" 5 - "flag" 6 - "fmt" 7 - "log" 8 - "net/http" 9 - "net/url" 10 - "os" 11 - "os/exec" 12 - "strings" 13 - "time" 14 - 15 - securejoin "github.com/cyphar/filepath-securejoin" 16 - "tangled.sh/tangled.sh/core/appview" 17 - ) 18 - 19 - var ( 20 - logger *log.Logger 21 - logFile *os.File 22 - clientIP string 23 - 24 - // Command line flags 25 - incomingUser = flag.String("user", "", "Allowed git user") 26 - baseDirFlag = flag.String("base-dir", "/home/git", "Base directory for git repositories") 27 - logPathFlag = flag.String("log-path", "/var/log/git-wrapper.log", "Path to log file") 28 - endpoint = flag.String("internal-api", "http://localhost:5444", "Internal API endpoint") 29 - ) 30 - 31 - func main() { 32 - flag.Parse() 33 - 34 - defer cleanup() 35 - initLogger() 36 - 37 - // Get client IP from SSH environment 38 - if connInfo := os.Getenv("SSH_CONNECTION"); connInfo != "" { 39 - parts := strings.Fields(connInfo) 40 - if len(parts) > 0 { 41 - clientIP = parts[0] 42 - } 43 - } 44 - 45 - if *incomingUser == "" { 46 - exitWithLog("access denied: no user specified") 47 - } 48 - 49 - sshCommand := os.Getenv("SSH_ORIGINAL_COMMAND") 50 - 51 - logEvent("Connection attempt", map[string]interface{}{ 52 - "user": *incomingUser, 53 - "command": sshCommand, 54 - "client": clientIP, 55 - }) 56 - 57 - if sshCommand == "" { 58 - exitWithLog("access denied: we don't serve interactive shells :)") 59 - } 60 - 61 - cmdParts := strings.Fields(sshCommand) 62 - if len(cmdParts) < 2 { 63 - exitWithLog("invalid command format") 64 - } 65 - 66 - gitCommand := cmdParts[0] 67 - 68 - // did:foo/repo-name or 69 - // handle/repo-name or 70 - // any of the above with a leading slash (/) 71 - 72 - components := strings.Split(strings.TrimPrefix(strings.Trim(cmdParts[1], "'"), "/"), "/") 73 - logEvent("Command components", map[string]interface{}{ 74 - "components": components, 75 - }) 76 - if len(components) != 2 { 77 - exitWithLog("invalid repo format, needs <user>/<repo> or /<user>/<repo>") 78 - } 79 - 80 - didOrHandle := components[0] 81 - did := resolveToDid(didOrHandle) 82 - repoName := components[1] 83 - qualifiedRepoName, _ := securejoin.SecureJoin(did, repoName) 84 - 85 - validCommands := map[string]bool{ 86 - "git-receive-pack": true, 87 - "git-upload-pack": true, 88 - "git-upload-archive": true, 89 - } 90 - if !validCommands[gitCommand] { 91 - exitWithLog("access denied: invalid git command") 92 - } 93 - 94 - if gitCommand != "git-upload-pack" { 95 - if !isPushPermitted(*incomingUser, qualifiedRepoName) { 96 - logEvent("all infos", map[string]interface{}{ 97 - "did": *incomingUser, 98 - "reponame": qualifiedRepoName, 99 - }) 100 - exitWithLog("access denied: user not allowed") 101 - } 102 - } 103 - 104 - fullPath, _ := securejoin.SecureJoin(*baseDirFlag, qualifiedRepoName) 105 - 106 - logEvent("Processing command", map[string]interface{}{ 107 - "user": *incomingUser, 108 - "command": gitCommand, 109 - "repo": repoName, 110 - "fullPath": fullPath, 111 - "client": clientIP, 112 - }) 113 - 114 - if gitCommand == "git-upload-pack" { 115 - fmt.Fprintf(os.Stderr, "\x02%s\n", "Welcome to this knot!") 116 - } else { 117 - fmt.Fprintf(os.Stderr, "%s\n", "Welcome to this knot!") 118 - } 119 - 120 - cmd := exec.Command(gitCommand, fullPath) 121 - cmd.Stdout = os.Stdout 122 - cmd.Stderr = os.Stderr 123 - cmd.Stdin = os.Stdin 124 - 125 - if err := cmd.Run(); err != nil { 126 - exitWithLog(fmt.Sprintf("command failed: %v", err)) 127 - } 128 - 129 - logEvent("Command completed", map[string]interface{}{ 130 - "user": *incomingUser, 131 - "command": gitCommand, 132 - "repo": repoName, 133 - "success": true, 134 - }) 135 - } 136 - 137 - func resolveToDid(didOrHandle string) string { 138 - resolver := appview.NewResolver() 139 - ident, err := resolver.ResolveIdent(context.Background(), didOrHandle) 140 - if err != nil { 141 - exitWithLog(fmt.Sprintf("error resolving handle: %v", err)) 142 - } 143 - 144 - // did:plc:foobarbaz/repo 145 - return ident.DID.String() 146 - } 147 - 148 - func initLogger() { 149 - var err error 150 - logFile, err = os.OpenFile(*logPathFlag, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) 151 - if err != nil { 152 - fmt.Fprintf(os.Stderr, "failed to open log file: %v\n", err) 153 - os.Exit(1) 154 - } 155 - 156 - logger = log.New(logFile, "", 0) 157 - } 158 - 159 - func logEvent(event string, fields map[string]interface{}) { 160 - entry := fmt.Sprintf( 161 - "timestamp=%q event=%q", 162 - time.Now().Format(time.RFC3339), 163 - event, 164 - ) 165 - 166 - for k, v := range fields { 167 - entry += fmt.Sprintf(" %s=%q", k, v) 168 - } 169 - 170 - logger.Println(entry) 171 - } 172 - 173 - func exitWithLog(message string) { 174 - logEvent("Access denied", map[string]interface{}{ 175 - "error": message, 176 - }) 177 - logFile.Sync() 178 - fmt.Fprintf(os.Stderr, "error: %s\n", message) 179 - os.Exit(1) 180 - } 181 - 182 - func cleanup() { 183 - if logFile != nil { 184 - logFile.Sync() 185 - logFile.Close() 186 - } 187 - } 188 - 189 - func isPushPermitted(user, qualifiedRepoName string) bool { 190 - u, _ := url.Parse(*endpoint + "/push-allowed") 191 - q := u.Query() 192 - q.Add("user", user) 193 - q.Add("repo", qualifiedRepoName) 194 - u.RawQuery = q.Encode() 195 - 196 - req, err := http.Get(u.String()) 197 - if err != nil { 198 - exitWithLog(fmt.Sprintf("error verifying permissions: %v", err)) 199 - } 200 - 201 - logEvent("url", map[string]interface{}{ 202 - "url": u.String(), 203 - "status": req.Status, 204 - }) 205 - 206 - return req.StatusCode == http.StatusNoContent 207 - }
+7 -3
docs/contributing.md
··· 33 33 knotserver: git/service: improve error checking in upload-pack 34 34 ``` 35 35 36 - The affected package/directory can be truncated down to just the relevant dir 37 - should it be far too long. For example `pages/templates/repo/fragments` can 38 - simply be `repo/fragments`. 39 36 40 37 ### general notes 41 38 ··· 43 40 using `git am`. At present, there is no squashing -- so please author 44 41 your commits as they would appear on `master`, following the above 45 42 guidelines. 43 + - If there is a lot of nesting, for example "appview: 44 + pages/templates/repo/fragments: ...", these can be truncated down to 45 + just "appview: repo/fragments: ...". If the change affects a lot of 46 + subdirectories, you may abbreviate to just the top-level names, e.g. 47 + "appview: ..." or "knotserver: ...". 48 + - Keep commits lowercased with no trailing period. 46 49 - Use the imperative mood in the summary line (e.g., "fix bug" not 47 50 "fixed bug" or "fixes bug"). 48 51 - Try to keep the summary line under 72 characters, but we aren't too 49 52 fussed about this. 53 + - Follow the same formatting for PR titles if filled manually. 50 54 - Don't include unrelated changes in the same commit. 51 55 - Avoid noisy commit messages like "wip" or "final fix"โ€”rewrite history 52 56 before submitting if necessary.
+8 -8
docs/hacking.md
··· 32 32 nix run .#watch-tailwind 33 33 ``` 34 34 35 - ## running a knotserver 35 + ## running a knot 36 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 37 + An end-to-end knot setup requires setting up a machine with 38 + `sshd`, `AuthorizedKeysCommand`, and git user, which is 39 + quite cumbersome. So the nix flake provides a 40 40 `nixosConfiguration` to do so. 41 41 42 42 To begin, head to `http://localhost:3000` in the browser and 43 - generate a knotserver secret. Replace the existing secret in 43 + generate a knot secret. Replace the existing secret in 44 44 `flake.nix` with the newly generated secret. 45 45 46 46 You can now start a lightweight NixOS VM using ··· 52 52 # hit Ctrl-a + c + q to exit the VM 53 53 ``` 54 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: 55 + This starts a knot on port 6000 with `ssh` exposed on port 56 + 2222. You can push repositories to this VM with this ssh 57 + config block on your main machine: 58 58 59 59 ```bash 60 60 Host nixos-shell
+42 -47
flake.nix
··· 49 49 inherit (gitignore.lib) gitignoreSource; 50 50 in { 51 51 overlays.default = final: prev: let 52 - goModHash = "sha256-mzM0B0ObAahznsL0JXMkFWN1Oix/ObOErUPH31xUMjM="; 53 - buildCmdPackage = name: 54 - final.buildGoModule { 55 - pname = name; 56 - version = "0.1.0"; 57 - src = gitignoreSource ./.; 58 - subPackages = ["cmd/${name}"]; 59 - vendorHash = goModHash; 60 - env.CGO_ENABLED = 0; 61 - }; 52 + goModHash = "sha256-H2gBkkuJaZtHlvW33aWZu0pS9vsS/A2ojeEUbp6o7Go="; 62 53 in { 63 54 indigo-lexgen = final.buildGoModule { 64 55 pname = "indigo-lexgen"; ··· 92 83 stdenv = pkgsStatic.stdenv; 93 84 }; 94 85 95 - knotserver = with final; 86 + knot = with final; 96 87 final.pkgsStatic.buildGoModule { 97 - pname = "knotserver"; 88 + pname = "knot"; 98 89 version = "0.1.0"; 99 90 src = gitignoreSource ./.; 100 91 nativeBuildInputs = [final.makeWrapper]; 101 - subPackages = ["cmd/knotserver"]; 92 + subPackages = ["cmd/knot"]; 102 93 vendorHash = goModHash; 103 94 installPhase = '' 104 95 runHook preInstall 105 96 106 97 mkdir -p $out/bin 107 - cp $GOPATH/bin/knotserver $out/bin/knotserver 98 + cp $GOPATH/bin/knot $out/bin/knot 108 99 109 - wrapProgram $out/bin/knotserver \ 100 + wrapProgram $out/bin/knot \ 110 101 --prefix PATH : ${pkgs.git}/bin 111 102 112 103 runHook postInstall 113 104 ''; 114 105 env.CGO_ENABLED = 1; 115 106 }; 116 - knotserver-unwrapped = final.pkgsStatic.buildGoModule { 117 - pname = "knotserver"; 107 + knot-unwrapped = final.pkgsStatic.buildGoModule { 108 + pname = "knot"; 118 109 version = "0.1.0"; 119 110 src = gitignoreSource ./.; 120 - subPackages = ["cmd/knotserver"]; 111 + subPackages = ["cmd/knot"]; 121 112 vendorHash = goModHash; 122 113 env.CGO_ENABLED = 1; 123 114 }; 124 - repoguard = buildCmdPackage "repoguard"; 125 - keyfetch = buildCmdPackage "keyfetch"; 126 - genjwks = buildCmdPackage "genjwks"; 115 + genjwks = final.pkgsStatic.buildGoModule { 116 + pname = "genjwks"; 117 + version = "0.1.0"; 118 + src = gitignoreSource ./.; 119 + subPackages = ["cmd/genjwks"]; 120 + vendorHash = goModHash; 121 + env.CGO_ENABLED = 0; 122 + }; 127 123 }; 128 124 packages = forAllSystems (system: { 129 125 inherit 130 126 (nixpkgsFor."${system}") 131 127 indigo-lexgen 132 128 appview 133 - knotserver 134 - knotserver-unwrapped 135 - repoguard 136 - keyfetch 129 + knot 130 + knot-unwrapped 137 131 genjwks 138 132 ; 139 133 }); ··· 156 150 pkgs.websocat 157 151 pkgs.tailwindcss 158 152 pkgs.nixos-shell 153 + pkgs.redis 159 154 ]; 160 155 shellHook = '' 161 156 mkdir -p appview/pages/static/{fonts,icons} ··· 171 166 }); 172 167 apps = forAllSystems (system: let 173 168 pkgs = nixpkgsFor."${system}"; 174 - air-watcher = name: 169 + air-watcher = name: arg: 175 170 pkgs.writeShellScriptBin "run" 176 171 '' 177 172 ${pkgs.air}/bin/air -c /dev/null \ 178 173 -build.cmd "${pkgs.go}/bin/go build -o ./out/${name}.out ./cmd/${name}/main.go" \ 179 - -build.bin "./out/${name}.out" \ 174 + -build.bin "./out/${name}.out ${arg}" \ 180 175 -build.stop_on_error "true" \ 181 176 -build.include_ext "go" 182 177 ''; ··· 188 183 in { 189 184 watch-appview = { 190 185 type = "app"; 191 - program = ''${air-watcher "appview"}/bin/run''; 186 + program = ''${air-watcher "appview" ""}/bin/run''; 192 187 }; 193 - watch-knotserver = { 188 + watch-knot = { 194 189 type = "app"; 195 - program = ''${air-watcher "knotserver"}/bin/run''; 190 + program = ''${air-watcher "knot" "server"}/bin/run''; 196 191 }; 197 192 watch-tailwind = { 198 193 type = "app"; ··· 246 241 }; 247 242 }; 248 243 249 - nixosModules.knotserver = { 244 + nixosModules.knot = { 250 245 config, 251 246 pkgs, 252 247 lib, 253 248 ... 254 249 }: let 255 - cfg = config.services.tangled-knotserver; 250 + cfg = config.services.tangled-knot; 256 251 in 257 252 with lib; { 258 253 options = { 259 - services.tangled-knotserver = { 254 + services.tangled-knot = { 260 255 enable = mkOption { 261 256 type = types.bool; 262 257 default = false; 263 - description = "Enable a tangled knotserver"; 258 + description = "Enable a tangled knot"; 264 259 }; 265 260 266 261 appviewEndpoint = mkOption { ··· 382 377 mode = "0555"; 383 378 text = '' 384 379 #!${pkgs.stdenv.shell} 385 - ${self.packages.${pkgs.system}.keyfetch}/bin/keyfetch \ 386 - -repoguard-path ${self.packages.${pkgs.system}.repoguard}/bin/repoguard \ 380 + ${self.packages.${pkgs.system}.knot}/bin/knot keys \ 381 + -output authorized-keys \ 387 382 -internal-api "http://${cfg.server.internalListenAddr}" \ 388 383 -git-dir "${cfg.repo.scanPath}" \ 389 - -log-path /tmp/repoguard.log 384 + -log-path /tmp/knotguard.log 390 385 ''; 391 386 }; 392 387 393 - systemd.services.knotserver = { 394 - description = "knotserver service"; 388 + systemd.services.knot = { 389 + description = "knot service"; 395 390 after = ["network.target" "sshd.service"]; 396 391 wantedBy = ["multi-user.target"]; 397 392 serviceConfig = { ··· 407 402 "KNOT_SERVER_HOSTNAME=${cfg.server.hostname}" 408 403 ]; 409 404 EnvironmentFile = cfg.server.secretFile; 410 - ExecStart = "${self.packages.${pkgs.system}.knotserver}/bin/knotserver"; 405 + ExecStart = "${self.packages.${pkgs.system}.knot}/bin/knot server"; 411 406 Restart = "always"; 412 407 }; 413 408 }; ··· 419 414 nixosConfigurations.knotVM = nixpkgs.lib.nixosSystem { 420 415 system = "x86_64-linux"; 421 416 modules = [ 422 - self.nixosModules.knotserver 417 + self.nixosModules.knot 423 418 ({ 424 419 config, 425 420 pkgs, ··· 431 426 services.getty.autologinUser = "root"; 432 427 environment.systemPackages = with pkgs; [curl vim git]; 433 428 systemd.tmpfiles.rules = let 434 - u = config.services.tangled-knotserver.gitUser; 435 - g = config.services.tangled-knotserver.gitUser; 429 + u = config.services.tangled-knot.gitUser; 430 + g = config.services.tangled-knot.gitUser; 436 431 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=38a7c3237c2a585807e06a5bcfac92eb39442063f3da306b7acb15cfdc51d19d" 432 + "d /var/lib/knot 0770 ${u} ${g} - -" # Create the directory first 433 + "f+ /var/lib/knot/secret 0660 ${u} ${g} - KNOT_SERVER_SECRET=38a7c3237c2a585807e06a5bcfac92eb39442063f3da306b7acb15cfdc51d19d" 439 434 ]; 440 - services.tangled-knotserver = { 435 + services.tangled-knot = { 441 436 enable = true; 442 437 server = { 443 - secretFile = "/var/lib/knotserver/secret"; 438 + secretFile = "/var/lib/knot/secret"; 444 439 hostname = "localhost:6000"; 445 440 listenAddr = "0.0.0.0:6000"; 446 441 };
+48 -38
go.mod
··· 8 8 github.com/Blank-Xu/sql-adapter v1.1.1 9 9 github.com/alecthomas/chroma/v2 v2.15.0 10 10 github.com/bluekeyes/go-gitdiff v0.8.1 11 - github.com/bluesky-social/indigo v0.0.0-20250301025210-a4e0cc37e188 11 + github.com/bluesky-social/indigo v0.0.0-20250520232546-236dd575c91e 12 12 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 13 + github.com/carlmjohnson/versioninfo v0.22.5 13 14 github.com/casbin/casbin/v2 v2.103.0 14 15 github.com/cyphar/filepath-securejoin v0.4.1 15 16 github.com/dgraph-io/ristretto v0.2.0 ··· 20 21 github.com/go-git/go-git/v5 v5.14.0 21 22 github.com/google/uuid v1.6.0 22 23 github.com/gorilla/sessions v1.4.0 23 - github.com/haileyok/atproto-oauth-golang v0.0.2 24 24 github.com/ipfs/go-cid v0.5.0 25 - github.com/lestrrat-go/jwx/v2 v2.0.12 25 + github.com/lestrrat-go/jwx/v2 v2.1.6 26 26 github.com/mattn/go-sqlite3 v1.14.24 27 27 github.com/microcosm-cc/bluemonday v1.0.27 28 28 github.com/posthog/posthog-go v1.5.5 29 29 github.com/resend/resend-go/v2 v2.15.0 30 30 github.com/sethvargo/go-envconfig v1.1.0 31 - github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e 32 - github.com/yuin/goldmark v1.4.13 31 + github.com/urfave/cli/v3 v3.3.3 32 + github.com/whyrusleeping/cbor-gen v0.3.1 33 + github.com/yuin/goldmark v1.4.15 34 + github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc 33 35 golang.org/x/net v0.39.0 34 - golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 36 + golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da 37 + tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250526154904-3906c5336421 35 38 ) 36 39 37 40 require ( ··· 42 45 github.com/aymerick/douceur v0.2.0 // indirect 43 46 github.com/beorn7/perks v1.0.1 // indirect 44 47 github.com/bmatcuk/doublestar/v4 v4.7.1 // indirect 45 - github.com/carlmjohnson/versioninfo v0.22.5 // indirect 46 48 github.com/casbin/govaluate v1.3.0 // indirect 47 49 github.com/cespare/xxhash/v2 v2.3.0 // indirect 48 50 github.com/cloudflare/circl v1.6.0 // indirect 49 - github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect 51 + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect 52 + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect 50 53 github.com/dlclark/regexp2 v1.11.5 // indirect 51 54 github.com/emirpasic/gods v1.18.1 // indirect 52 55 github.com/felixge/httpsnoop v1.0.4 // indirect ··· 55 58 github.com/go-git/go-billy/v5 v5.6.2 // indirect 56 59 github.com/go-logr/logr v1.4.2 // indirect 57 60 github.com/go-logr/stdr v1.2.2 // indirect 58 - github.com/goccy/go-json v0.10.2 // indirect 61 + github.com/go-redis/cache/v9 v9.0.0 // indirect 62 + github.com/goccy/go-json v0.10.5 // indirect 59 63 github.com/gogo/protobuf v1.3.2 // indirect 60 - github.com/golang-jwt/jwt/v5 v5.2.1 // indirect 64 + github.com/golang-jwt/jwt/v5 v5.2.2 // indirect 61 65 github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect 62 66 github.com/gorilla/css v1.0.1 // indirect 63 67 github.com/gorilla/securecookie v1.1.2 // indirect 64 - github.com/gorilla/websocket v1.5.1 // indirect 68 + github.com/gorilla/websocket v1.5.3 // indirect 65 69 github.com/hashicorp/go-cleanhttp v0.5.2 // indirect 66 - github.com/hashicorp/go-retryablehttp v0.7.5 // indirect 70 + github.com/hashicorp/go-retryablehttp v0.7.7 // indirect 67 71 github.com/hashicorp/golang-lru v1.0.2 // indirect 68 72 github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect 69 73 github.com/ipfs/bbloom v0.0.4 // indirect 70 - github.com/ipfs/go-block-format v0.2.0 // indirect 71 - github.com/ipfs/go-datastore v0.6.0 // indirect 74 + github.com/ipfs/boxo v0.30.0 // indirect 75 + github.com/ipfs/go-block-format v0.2.1 // indirect 76 + github.com/ipfs/go-datastore v0.8.2 // indirect 72 77 github.com/ipfs/go-ipfs-blockstore v1.3.1 // indirect 73 78 github.com/ipfs/go-ipfs-ds-help v1.1.1 // indirect 74 - github.com/ipfs/go-ipfs-util v0.0.3 // indirect 75 - github.com/ipfs/go-ipld-cbor v0.1.0 // indirect 76 - github.com/ipfs/go-ipld-format v0.6.0 // indirect 79 + github.com/ipfs/go-ipld-cbor v0.2.0 // indirect 80 + github.com/ipfs/go-ipld-format v0.6.1 // indirect 77 81 github.com/ipfs/go-log v1.0.5 // indirect 78 - github.com/ipfs/go-log/v2 v2.5.1 // indirect 79 - github.com/ipfs/go-metrics-interface v0.0.1 // indirect 80 - github.com/jbenet/goprocess v0.1.4 // indirect 82 + github.com/ipfs/go-log/v2 v2.6.0 // indirect 83 + github.com/ipfs/go-metrics-interface v0.3.0 // indirect 81 84 github.com/kevinburke/ssh_config v1.2.0 // indirect 82 - github.com/klauspost/compress v1.17.9 // indirect 83 - github.com/klauspost/cpuid/v2 v2.2.7 // indirect 84 - github.com/lestrrat-go/blackmagic v1.0.2 // indirect 85 + github.com/klauspost/compress v1.18.0 // indirect 86 + github.com/klauspost/cpuid/v2 v2.2.10 // indirect 87 + github.com/lestrrat-go/blackmagic v1.0.3 // indirect 85 88 github.com/lestrrat-go/httpcc v1.0.1 // indirect 86 - github.com/lestrrat-go/httprc v1.0.4 // indirect 89 + github.com/lestrrat-go/httprc v1.0.6 // indirect 87 90 github.com/lestrrat-go/iter v1.0.2 // indirect 88 91 github.com/lestrrat-go/option v1.0.1 // indirect 89 92 github.com/mattn/go-isatty v0.0.20 // indirect ··· 94 97 github.com/multiformats/go-multibase v0.2.0 // indirect 95 98 github.com/multiformats/go-multihash v0.2.3 // indirect 96 99 github.com/multiformats/go-varint v0.0.7 // indirect 100 + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 97 101 github.com/opentracing/opentracing-go v1.2.0 // indirect 98 102 github.com/pjbgf/sha1cd v0.3.2 // indirect 99 103 github.com/pkg/errors v0.9.1 // indirect 100 104 github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f // indirect 101 - github.com/prometheus/client_golang v1.19.1 // indirect 102 - github.com/prometheus/client_model v0.6.1 // indirect 103 - github.com/prometheus/common v0.54.0 // indirect 104 - github.com/prometheus/procfs v0.15.1 // indirect 105 + github.com/prometheus/client_golang v1.22.0 // indirect 106 + github.com/prometheus/client_model v0.6.2 // indirect 107 + github.com/prometheus/common v0.63.0 // indirect 108 + github.com/prometheus/procfs v0.16.1 // indirect 109 + github.com/redis/go-redis/v9 v9.3.0 // indirect 105 110 github.com/segmentio/asm v1.2.0 // indirect 106 111 github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect 107 112 github.com/spaolacci/murmur3 v1.1.0 // indirect 113 + github.com/vmihailenco/go-tinylfu v0.2.2 // indirect 114 + github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect 115 + github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect 108 116 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect 109 117 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect 110 - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 // indirect 111 - go.opentelemetry.io/otel v1.29.0 // indirect 112 - go.opentelemetry.io/otel/metric v1.29.0 // indirect 113 - go.opentelemetry.io/otel/trace v1.29.0 // indirect 118 + go.opentelemetry.io/auto/sdk v1.1.0 // indirect 119 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect 120 + go.opentelemetry.io/otel v1.36.0 // indirect 121 + go.opentelemetry.io/otel/metric v1.36.0 // indirect 122 + go.opentelemetry.io/otel/trace v1.36.0 // indirect 114 123 go.uber.org/atomic v1.11.0 // indirect 115 124 go.uber.org/multierr v1.11.0 // indirect 116 - go.uber.org/zap v1.26.0 // indirect 117 - golang.org/x/crypto v0.37.0 // indirect 118 - golang.org/x/exp v0.0.0-20250218142911-aa4b98e5adaa // indirect 119 - golang.org/x/sys v0.32.0 // indirect 125 + go.uber.org/zap v1.27.0 // indirect 126 + golang.org/x/crypto v0.38.0 // indirect 127 + golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect 128 + golang.org/x/sync v0.13.0 // indirect 129 + golang.org/x/sys v0.33.0 // indirect 120 130 golang.org/x/time v0.8.0 // indirect 121 - google.golang.org/protobuf v1.34.2 // indirect 131 + google.golang.org/protobuf v1.36.6 // indirect 122 132 gopkg.in/warnings.v0 v0.1.2 // indirect 123 - lukechampine.com/blake3 v1.2.1 // indirect 133 + lukechampine.com/blake3 v1.4.1 // indirect 124 134 ) 125 135 126 136 replace github.com/sergi/go-diff => github.com/sergi/go-diff v1.1.0
+247 -115
go.sum
··· 17 17 github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= 18 18 github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= 19 19 github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= 20 - github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= 21 20 github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 22 21 github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 23 - github.com/bluesky-social/indigo v0.0.0-20250301025210-a4e0cc37e188 h1:1sQaG37xk08/rpmdhrmMkfQWF9kZbnfHm9Zav3bbSMk= 24 - github.com/bluesky-social/indigo v0.0.0-20250301025210-a4e0cc37e188/go.mod h1:NVBwZvbBSa93kfyweAmKwOLYawdVHdwZ9s+GZtBBVLA= 22 + github.com/bluesky-social/indigo v0.0.0-20250520232546-236dd575c91e h1:DVD+HxQsDCVJtAkjfIKZVaBNc3kayHaU+A2TJZkFdp4= 23 + github.com/bluesky-social/indigo v0.0.0-20250520232546-236dd575c91e/go.mod h1:ovyxp8AMO1Hoe838vMJUbqHTZaAR8ABM3g3TXu+A5Ng= 25 24 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1 h1:CFvRtYNSnWRAi/98M3O466t9dYuwtesNbu6FVPymRrA= 26 25 github.com/bluesky-social/jetstream v0.0.0-20241210005130-ea96859b93d1/go.mod h1:WiYEeyJSdUwqoaZ71KJSpTblemUCpwJfh5oVXplK6T4= 27 26 github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= 28 27 github.com/bmatcuk/doublestar/v4 v4.7.1 h1:fdDeAqgT47acgwd9bd9HxJRDmc9UAmPpc+2m0CXv75Q= 29 28 github.com/bmatcuk/doublestar/v4 v4.7.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= 29 + github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= 30 + github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= 31 + github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= 32 + github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= 30 33 github.com/carlmjohnson/versioninfo v0.22.5 h1:O00sjOLUAFxYQjlN/bzYTuZiS0y6fWDQjMRvwtKgwwc= 31 34 github.com/carlmjohnson/versioninfo v0.22.5/go.mod h1:QT9mph3wcVfISUKd0i9sZfVrPviHuSF+cUtLjm2WSf8= 32 35 github.com/casbin/casbin/v2 v2.100.0/go.mod h1:LO7YPez4dX3LgoTCqSQAleQDo0S0BeZBDxYnPUl95Ng= ··· 35 38 github.com/casbin/govaluate v1.2.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A= 36 39 github.com/casbin/govaluate v1.3.0 h1:VA0eSY0M2lA86dYd5kPPuNZMUD9QkWnOCnavGrw9myc= 37 40 github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A= 41 + github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 42 + github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 38 43 github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 39 44 github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 45 + github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= 46 + github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= 47 + github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= 40 48 github.com/cloudflare/circl v1.6.0 h1:cr5JKic4HI+LkINy2lg3W2jF8sHCVTBncJr5gIIq7qk= 41 49 github.com/cloudflare/circl v1.6.0/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= 42 50 github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= 51 + github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 43 52 github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= 44 53 github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= 45 54 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 46 55 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 47 56 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 48 57 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 49 - github.com/decred/dcrd/crypto/blake256 v1.0.1/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= 50 - github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 h1:8UrgZ3GkP4i/CLijOJx79Yu+etlyjdBU4sfcs2WYQMs= 51 - github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0= 58 + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= 59 + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= 52 60 github.com/dgraph-io/ristretto v0.2.0 h1:XAfl+7cmoUDWW/2Lx8TGZQjjxIQ2Ley9DSf52dru4WE= 53 61 github.com/dgraph-io/ristretto v0.2.0/go.mod h1:8uBHCU/PBV4Ag0CJrP47b9Ofby5dqWNh4FicAdoqFNU= 54 62 github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 h1:fAjc9m62+UWV/WAFKLNi6ZS0675eEUC9y3AlwSbQu1Y= 55 63 github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= 64 + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= 65 + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= 66 + github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= 56 67 github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= 57 68 github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= 58 69 github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= ··· 61 72 github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= 62 73 github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= 63 74 github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= 75 + github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= 76 + github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= 64 77 github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= 65 78 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= 79 + github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= 80 + github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= 81 + github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= 66 82 github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= 67 83 github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= 68 84 github.com/go-chi/chi/v5 v5.2.0 h1:Aj1EtB0qR2Rdo2dG4O94RIU35w2lvQSj6BRA4+qwFL0= ··· 80 96 github.com/go-git/go-git-fixtures/v5 v5.0.0-20241203230421-0753e18f8f03 h1:LumE+tQdnYW24a9RoO08w64LHTzkNkdUqBD/0QPtlEY= 81 97 github.com/go-git/go-git-fixtures/v5 v5.0.0-20241203230421-0753e18f8f03/go.mod h1:hMKrMnUE4W0SJ7bFyM00dyz/HoknZoptGWzrj6M+dEM= 82 98 github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 99 + github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 83 100 github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= 84 101 github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 85 102 github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 86 103 github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 104 + github.com/go-redis/cache/v9 v9.0.0 h1:0thdtFo0xJi0/WXbRVu8B066z8OvVymXTJGaXrVWnN0= 105 + github.com/go-redis/cache/v9 v9.0.0/go.mod h1:cMwi1N8ASBOufbIvk7cdXe2PbPjK/WMRL95FFHWsSgI= 106 + github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= 87 107 github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0= 88 - github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= 89 - github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= 108 + github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= 109 + github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= 90 110 github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 91 111 github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 92 - github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= 93 - github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= 112 + github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= 113 + github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= 94 114 github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= 95 115 github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= 96 116 github.com/golang/mock v1.4.4 h1:l75CXGRSwbaYNpl/Z2X1XIIAMSCquvXgpVZDhwEIJsc= 97 117 github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= 98 - github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 99 - github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 118 + github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 119 + github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= 120 + github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= 121 + github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= 122 + github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= 123 + github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= 124 + github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= 125 + github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= 126 + github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= 127 + github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 128 + github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 129 + github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 130 + github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 131 + github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 132 + github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 133 + github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 134 + github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 100 135 github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= 101 136 github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 137 + github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= 102 138 github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= 103 139 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 104 140 github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 105 - github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= 106 141 github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= 142 + github.com/gopherjs/gopherjs v0.0.0-20190430165422-3e4dfb77656c h1:7lF+Vz0LqiRidnzC1Oq86fpX1q/iEv2KJdrCtttYjT4= 143 + github.com/gopherjs/gopherjs v0.0.0-20190430165422-3e4dfb77656c/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= 107 144 github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= 108 145 github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= 109 146 github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= 110 147 github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= 111 148 github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= 112 149 github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= 113 - github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= 114 - github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= 115 - github.com/haileyok/atproto-oauth-golang v0.0.2 h1:61KPkLB615LQXR2f5x1v3sf6vPe6dOXqNpTYCgZ0Fz8= 116 - github.com/haileyok/atproto-oauth-golang v0.0.2/go.mod h1:jcZ4GCjo5I5RuE/RsAXg1/b6udw7R4W+2rb/cGyTDK8= 150 + github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= 151 + github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 117 152 github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= 118 153 github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= 119 - github.com/hashicorp/go-hclog v0.9.2 h1:CG6TE5H9/JXsFWJCfoIVpKFIkFe6ysEuHirp4DxCsHI= 120 - github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ= 121 - github.com/hashicorp/go-retryablehttp v0.7.5 h1:bJj+Pj19UZMIweq/iie+1u5YCdGrnxCT9yvm0e+Nd5M= 122 - github.com/hashicorp/go-retryablehttp v0.7.5/go.mod h1:Jy/gPYAdjqffZ/yFGCFV2doI5wjtH1ewM9u8iYVjtX8= 154 + github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= 155 + github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= 156 + github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU= 157 + github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= 123 158 github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c= 124 159 github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= 125 160 github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= 126 161 github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= 127 162 github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= 128 163 github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= 164 + github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= 165 + github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= 129 166 github.com/ipfs/bbloom v0.0.4 h1:Gi+8EGJ2y5qiD5FbsbpX/TMNcJw8gSqr7eyjHa4Fhvs= 130 167 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= 168 + github.com/ipfs/boxo v0.30.0 h1:7afsoxPGGqfoH7Dum/wOTGUB9M5fb8HyKPMlLfBvIEQ= 169 + github.com/ipfs/boxo v0.30.0/go.mod h1:BPqgGGyHB9rZZcPSzah2Dc9C+5Or3U1aQe7EH1H7370= 170 + github.com/ipfs/go-block-format v0.2.1 h1:96kW71XGNNa+mZw/MTzJrCpMhBWCrd9kBLoKm9Iip/Q= 171 + github.com/ipfs/go-block-format v0.2.1/go.mod h1:frtvXHMQhM6zn7HvEQu+Qz5wSTj+04oEH/I+NjDgEjk= 133 172 github.com/ipfs/go-cid v0.5.0 h1:goEKKhaGm0ul11IHA7I6p1GmKz8kEYniqFopaB5Otwg= 134 173 github.com/ipfs/go-cid v0.5.0/go.mod h1:0L7vmeNXpQpUS9vt+yEARkJ8rOg43DF3iPgn4GIN0mk= 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= 174 + github.com/ipfs/go-datastore v0.8.2 h1:Jy3wjqQR6sg/LhyY0NIePZC3Vux19nLtg7dx0TVqr6U= 175 + github.com/ipfs/go-datastore v0.8.2/go.mod h1:W+pI1NsUsz3tcsAACMtfC+IZdnQTnC/7VfPoJBQuts0= 137 176 github.com/ipfs/go-detect-race v0.0.1 h1:qX/xay2W3E4Q1U7d9lNs1sU9nvguX0a7319XbyQ6cOk= 138 177 github.com/ipfs/go-detect-race v0.0.1/go.mod h1:8BNT7shDZPo99Q74BpGMK+4D8Mn4j46UU0LZ723meps= 139 178 github.com/ipfs/go-ipfs-blockstore v1.3.1 h1:cEI9ci7V0sRNivqaOr0elDsamxXFxJMMMy7PTTDQNsQ= ··· 142 181 github.com/ipfs/go-ipfs-ds-help v1.1.1/go.mod h1:75vrVCkSdSFidJscs8n4W+77AtTpCIAdDGAwjitJMIo= 143 182 github.com/ipfs/go-ipfs-util v0.0.3 h1:2RFdGez6bu2ZlZdI+rWfIdbQb1KudQp3VGwPtdNCmE0= 144 183 github.com/ipfs/go-ipfs-util v0.0.3/go.mod h1:LHzG1a0Ig4G+iZ26UUOMjHd+lfM84LZCrn17xAKWBvs= 145 - github.com/ipfs/go-ipld-cbor v0.1.0 h1:dx0nS0kILVivGhfWuB6dUpMa/LAwElHPw1yOGYopoYs= 146 - github.com/ipfs/go-ipld-cbor v0.1.0/go.mod h1:U2aYlmVrJr2wsUBU67K4KgepApSZddGRDWBYR0H4sCk= 147 - github.com/ipfs/go-ipld-format v0.6.0 h1:VEJlA2kQ3LqFSIm5Vu6eIlSxD/Ze90xtc4Meten1F5U= 148 - github.com/ipfs/go-ipld-format v0.6.0/go.mod h1:g4QVMTn3marU3qXchwjpKPKgJv+zF+OlaKMyhJ4LHPg= 184 + github.com/ipfs/go-ipld-cbor v0.2.0 h1:VHIW3HVIjcMd8m4ZLZbrYpwjzqlVUfjLM7oK4T5/YF0= 185 + github.com/ipfs/go-ipld-cbor v0.2.0/go.mod h1:Cp8T7w1NKcu4AQJLqK0tWpd1nkgTxEVB5C6kVpLW6/0= 186 + github.com/ipfs/go-ipld-format v0.6.1 h1:lQLmBM/HHbrXvjIkrydRXkn+gc0DE5xO5fqelsCKYOQ= 187 + github.com/ipfs/go-ipld-format v0.6.1/go.mod h1:8TOH1Hj+LFyqM2PjSqI2/ZnyO0KlfhHbJLkbxFa61hs= 149 188 github.com/ipfs/go-log v1.0.5 h1:2dOuUCB1Z7uoczMWgAyDck5JLb72zHzrMnGnCNNbvY8= 150 189 github.com/ipfs/go-log v1.0.5/go.mod h1:j0b8ZoR+7+R99LD9jZ6+AJsrzkPbSXbZfGakb5JPtIo= 151 190 github.com/ipfs/go-log/v2 v2.1.3/go.mod h1:/8d0SH3Su5Ooc31QlL1WysJhvyOTDCjcCZ9Axpmri6g= 152 - github.com/ipfs/go-log/v2 v2.5.1 h1:1XdUzF7048prq4aBjDQQ4SL5RxftpRGdXhNRwKSAlcY= 153 - github.com/ipfs/go-log/v2 v2.5.1/go.mod h1:prSpmC1Gpllc9UYWxDiZDreBYw7zp4Iqp1kOLU9U5UI= 154 - github.com/ipfs/go-metrics-interface v0.0.1 h1:j+cpbjYvu4R8zbleSs36gvB7jR+wsL2fGD6n0jO4kdg= 155 - github.com/ipfs/go-metrics-interface v0.0.1/go.mod h1:6s6euYU4zowdslK0GKHmqaIZ3j/b/tL7HTWtJ4VPgWY= 156 - github.com/jbenet/go-cienv v0.1.0/go.mod h1:TqNnHUmJgXau0nCzC7kXWeotg3J9W34CUv5Djy1+FlA= 157 - github.com/jbenet/goprocess v0.1.4 h1:DRGOFReOMqqDNXwW70QkacFW0YN9QnwLV0Vqk+3oU0o= 158 - github.com/jbenet/goprocess v0.1.4/go.mod h1:5yspPrukOVuOLORacaBi858NqyClJPQxYZlqdZVfqY4= 191 + github.com/ipfs/go-log/v2 v2.6.0 h1:2Nu1KKQQ2ayonKp4MPo6pXCjqw1ULc9iohRqWV5EYqg= 192 + github.com/ipfs/go-log/v2 v2.6.0/go.mod h1:p+Efr3qaY5YXpx9TX7MoLCSEZX5boSWj9wh86P5HJa8= 193 + github.com/ipfs/go-metrics-interface v0.3.0 h1:YwG7/Cy4R94mYDUuwsBfeziJCVm9pBMJ6q/JR9V40TU= 194 + github.com/ipfs/go-metrics-interface v0.3.0/go.mod h1:OxxQjZDGocXVdyTPocns6cOLwHieqej/jos7H4POwoY= 195 + github.com/ipfs/go-test v0.2.1 h1:/D/a8xZ2JzkYqcVcV/7HYlCnc7bv/pKHQiX5TdClkPE= 196 + github.com/ipfs/go-test v0.2.1/go.mod h1:dzu+KB9cmWjuJnXFDYJwC25T3j1GcN57byN+ixmK39M= 159 197 github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 160 198 github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 161 199 github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= ··· 164 202 github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= 165 203 github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= 166 204 github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 167 - github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= 168 - github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= 169 - github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= 170 - github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= 205 + github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= 206 + github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= 207 + github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= 208 + github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= 209 + github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= 171 210 github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 211 + github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= 172 212 github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 173 213 github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 174 214 github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 175 215 github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 176 216 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 177 217 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 178 - github.com/lestrrat-go/blackmagic v1.0.1/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU= 179 - github.com/lestrrat-go/blackmagic v1.0.2 h1:Cg2gVSc9h7sz9NOByczrbUvLopQmXrfFx//N+AkAr5k= 180 - github.com/lestrrat-go/blackmagic v1.0.2/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU= 218 + github.com/lestrrat-go/blackmagic v1.0.3 h1:94HXkVLxkZO9vJI/w2u1T0DAoprShFd13xtnSINtDWs= 219 + github.com/lestrrat-go/blackmagic v1.0.3/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw= 181 220 github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= 182 221 github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= 183 - github.com/lestrrat-go/httprc v1.0.4 h1:bAZymwoZQb+Oq8MEbyipag7iSq6YIga8Wj6GOiJGdI8= 184 - github.com/lestrrat-go/httprc v1.0.4/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo= 222 + github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k= 223 + github.com/lestrrat-go/httprc v1.0.6/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo= 185 224 github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI= 186 225 github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4= 187 - github.com/lestrrat-go/jwx/v2 v2.0.12 h1:3d589+5w/b9b7S3DneICPW16AqTyYXB7VRjgluSDWeA= 188 - github.com/lestrrat-go/jwx/v2 v2.0.12/go.mod h1:Mq4KN1mM7bp+5z/W5HS8aCNs5RKZ911G/0y2qUjAQuQ= 189 - github.com/lestrrat-go/option v1.0.0/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= 226 + github.com/lestrrat-go/jwx/v2 v2.1.6 h1:hxM1gfDILk/l5ylers6BX/Eq1m/pnxe9NBwW6lVfecA= 227 + github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU= 190 228 github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= 191 229 github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= 192 - github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= 230 + github.com/libp2p/go-buffer-pool v0.1.0 h1:oK4mSFcQz7cTQIfqbe4MIj9gLW+mnanjyFtc6cdF0Y8= 231 + github.com/libp2p/go-buffer-pool v0.1.0/go.mod h1:N+vh8gMqimBzdKkSMVuydVDq+UV5QTWy5HSiZacSbPg= 232 + github.com/libp2p/go-libp2p v0.41.1 h1:8ecNQVT5ev/jqALTvisSJeVNvXYJyK4NhQx1nNRXQZE= 233 + github.com/libp2p/go-libp2p v0.41.1/go.mod h1:DcGTovJzQl/I7HMrby5ZRjeD0kQkGiy+9w6aEkSZpRI= 234 + github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 235 + github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 193 236 github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 194 237 github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 195 238 github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= ··· 204 247 github.com/multiformats/go-base32 v0.1.0/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI= 205 248 github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9rQyccr0= 206 249 github.com/multiformats/go-base36 v0.2.0/go.mod h1:qvnKE++v+2MWCfePClUEjE78Z7P2a1UV0xHgWc0hkp4= 250 + github.com/multiformats/go-multiaddr v0.15.0 h1:zB/HeaI/apcZiTDwhY5YqMvNVl/oQYvs3XySU+qeAVo= 251 + github.com/multiformats/go-multiaddr v0.15.0/go.mod h1:JSVUmXDjsVFiW7RjIFMP7+Ev+h1DTbiJgVeTV/tcmP0= 207 252 github.com/multiformats/go-multibase v0.2.0 h1:isdYCVLvksgWlMW9OZRYJEa9pZETFivncJHmHnnd87g= 208 253 github.com/multiformats/go-multibase v0.2.0/go.mod h1:bFBZX4lKCA/2lyOFSAoKH5SS6oPyjtnzK/XTFDPkNuk= 254 + github.com/multiformats/go-multicodec v0.9.0 h1:pb/dlPnzee/Sxv/j4PmkDRxCOi3hXTz3IbPKOXWJkmg= 255 + github.com/multiformats/go-multicodec v0.9.0/go.mod h1:L3QTQvMIaVBkXOXXtVmYE+LI16i14xuaojr/H7Ai54k= 209 256 github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7BFvVU9RSh+U= 210 257 github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM= 211 258 github.com/multiformats/go-varint v0.0.7 h1:sWSGR+f/eu5ABZA2ZpYKBILXTTs9JWpdEM/nEGOHFS8= 212 259 github.com/multiformats/go-varint v0.0.7/go.mod h1:r8PUYw/fD/SjBCiKOoDlGF6QawOELpZAu9eioSos/OU= 260 + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= 261 + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 262 + github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= 263 + github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= 264 + github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= 265 + github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= 266 + github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= 267 + github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= 268 + github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= 269 + github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= 270 + github.com/onsi/ginkgo/v2 v2.1.3/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c= 271 + github.com/onsi/ginkgo/v2 v2.1.4/go.mod h1:um6tUpWM/cxCK3/FK8BXqEiUMUwRgSM4JXG47RKZmLU= 272 + github.com/onsi/ginkgo/v2 v2.1.6/go.mod h1:MEH45j8TBi6u9BMogfbp0stKC5cdGjumZj5Y7AG4VIk= 273 + github.com/onsi/ginkgo/v2 v2.3.0/go.mod h1:Eew0uilEqZmIEZr8JrvYlvOM7Rr6xzTmMV8AyFNU9d0= 274 + github.com/onsi/ginkgo/v2 v2.4.0/go.mod h1:iHkDK1fKGcBoEHT5W7YBq4RFWaQulw+caOMkAt4OrFo= 275 + github.com/onsi/ginkgo/v2 v2.5.0/go.mod h1:Luc4sArBICYCS8THh8v3i3i5CuSZO+RaQRaJoeNwomw= 276 + github.com/onsi/ginkgo/v2 v2.7.0/go.mod h1:yjiuMwPokqY1XauOgju45q3sJt6VzQ/Fict1LFVcsAo= 277 + github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= 278 + github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= 279 + github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= 280 + github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro= 281 + github.com/onsi/gomega v1.20.1/go.mod h1:DtrZpjmvpn2mPm4YWQa0/ALMDj9v4YxLgojwPeREyVo= 282 + github.com/onsi/gomega v1.21.1/go.mod h1:iYAIXgPSaDHak0LCMA+AWBpIKBr8WZicMxnE8luStNc= 283 + github.com/onsi/gomega v1.22.1/go.mod h1:x6n7VNe4hw0vkyYUM4mjIXx3JbLiPaBPNgB7PRQ1tuM= 284 + github.com/onsi/gomega v1.24.0/go.mod h1:Z/NWtiqwBrwUt4/2loMmHL63EDLnYHmVbuBpDr2vQAg= 285 + github.com/onsi/gomega v1.24.1/go.mod h1:3AOiACssS3/MajrniINInwbfOOtfZvplPzuRSmvt1jM= 286 + github.com/onsi/gomega v1.25.0/go.mod h1:r+zV744Re+DiYCIPRlYOTxn0YkOLcAnW8k1xXdMPGhM= 213 287 github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= 214 288 github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= 215 289 github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs= ··· 230 304 github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f/go.mod h1:/zvteZs/GwLtCgZ4BL6CBsk9IKIlexP43ObX9AxTqTw= 231 305 github.com/posthog/posthog-go v1.5.5 h1:2o3j7IrHbTIfxRtj4MPaXKeimuTYg49onNzNBZbwksM= 232 306 github.com/posthog/posthog-go v1.5.5/go.mod h1:3RqUmSnPuwmeVj/GYrS75wNGqcAKdpODiwc83xZWgdE= 233 - github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE= 234 - github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho= 235 - github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= 236 - github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= 237 - github.com/prometheus/common v0.54.0 h1:ZlZy0BgJhTwVZUn7dLOkwCZHUkrAqd3WYtcFCWnM1D8= 238 - github.com/prometheus/common v0.54.0/go.mod h1:/TQgMJP5CuVYveyT7n/0Ix8yLNNXy9yRSkhnLTHPDIQ= 239 - github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= 240 - github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= 307 + github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q= 308 + github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= 309 + github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= 310 + github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= 311 + github.com/prometheus/common v0.63.0 h1:YR/EIY1o3mEFP/kZCD7iDMnLPlGyuU2Gb3HIcXnA98k= 312 + github.com/prometheus/common v0.63.0/go.mod h1:VVFF/fBIoToEnWRVkYoXEkq3R3paCoxG9PXP74SnV18= 313 + github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= 314 + github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= 315 + github.com/redis/go-redis/v9 v9.0.0-rc.4/go.mod h1:Vo3EsyWnicKnSKCA7HhgnvnyA74wOA69Cd2Meli5mmA= 316 + github.com/redis/go-redis/v9 v9.3.0 h1:RiVDjmig62jIWp7Kk4XVLs0hzV6pI3PyTnnL0cnn0u0= 317 + github.com/redis/go-redis/v9 v9.3.0/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M= 241 318 github.com/resend/resend-go/v2 v2.15.0 h1:B6oMEPf8IEQwn2Ovx/9yymkESLDSeNfLFaNMw+mzHhE= 242 319 github.com/resend/resend-go/v2 v2.15.0/go.mod h1:3YCb8c8+pLiqhtRFXTyFwlLvfjQtluxOr9HEh2BwCkQ= 243 320 github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= 321 + github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= 244 322 github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= 245 323 github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= 246 324 github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= ··· 260 338 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 261 339 github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 262 340 github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 263 - github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 264 341 github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 265 342 github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 343 + github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= 266 344 github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 267 345 github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 268 346 github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 269 347 github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 270 348 github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 271 - github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 272 349 github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 273 350 github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 274 351 github.com/urfave/cli v1.22.10/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= 352 + github.com/urfave/cli/v3 v3.3.3 h1:byCBaVdIXuLPIDm5CYZRVG6NvT7tv1ECqdU4YzlEa3I= 353 + github.com/urfave/cli/v3 v3.3.3/go.mod h1:FJSKtM/9AiiTOJL4fJ6TbMUkxBXn7GO9guZqoZtpYpo= 354 + github.com/vmihailenco/go-tinylfu v0.2.2 h1:H1eiG6HM36iniK6+21n9LLpzx1G9R3DJa2UjUjbynsI= 355 + github.com/vmihailenco/go-tinylfu v0.2.2/go.mod h1:CutYi2Q9puTxfcolkliPq4npPuofg9N9t8JVrjzwa3Q= 356 + github.com/vmihailenco/msgpack/v5 v5.3.4/go.mod h1:7xyJ9e+0+9SaZT0Wt1RGleJXzli6Q/V5KbhBonMG9jc= 357 + github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= 358 + github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= 359 + github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= 360 + github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= 275 361 github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0 h1:GDDkbFiaK8jsSDJfjId/PEGEShv6ugrt4kYsC5UIDaQ= 276 362 github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw= 277 - github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e h1:28X54ciEwwUxyHn9yrZfl5ojgF4CBNLWX7LR0rvBkf4= 278 - github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so= 363 + github.com/whyrusleeping/cbor-gen v0.3.1 h1:82ioxmhEYut7LBVGhGq8xoRkXPLElVuh5mV67AFfdv0= 364 + github.com/whyrusleeping/cbor-gen v0.3.1/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so= 279 365 github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 280 366 github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 281 - github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 282 - github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE= 367 + github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 283 368 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 369 + github.com/yuin/goldmark v1.4.15 h1:CFa84T0goNn/UIXYS+dmjjVxMyTAvpOmzld40N/nfK0= 370 + github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 371 + github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ= 372 + github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I= 284 373 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRySiX3drau9C6Q5CAbNIApmLdat5jPMqChvDA= 285 374 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8= 286 375 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAFgcfgTnfJrmYKWhHnci3GjDqcZp1M3Q= 287 376 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02/go.mod h1:JTnUj0mpYiAsuZLmKjTx/ex3AtMowcCgnE7YNyCEP0I= 288 - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 h1:aFJWCqJMNjENlcleuuOkGAPH82y0yULBScfXcIEdS24= 289 - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1/go.mod h1:sEGXWArGqc3tVa+ekntsN65DmVbVeW+7lTKTjZF3/Fo= 290 - go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw= 291 - go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8= 292 - go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc= 293 - go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8= 294 - go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4= 295 - go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ= 377 + go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= 378 + go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= 379 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= 380 + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= 381 + go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg= 382 + go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E= 383 + go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE= 384 + go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs= 385 + go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs= 386 + go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY= 387 + go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis= 388 + go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4= 389 + go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w= 390 + go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA= 296 391 go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= 297 392 go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= 298 393 go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= 299 394 go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= 300 - go.uber.org/goleak v1.1.11-0.20210813005559-691160354723/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= 301 - go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk= 302 - go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo= 395 + go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= 396 + go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= 303 397 go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU= 304 398 go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= 305 399 go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= 306 400 go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= 307 401 go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA= 308 402 go.uber.org/zap v1.16.0/go.mod h1:MA8QOfq0BHJwdXa996Y4dYkAqRKB8/1K1QMMZVaNZjQ= 309 - go.uber.org/zap v1.19.1/go.mod h1:j3DNczoxDZroyBnOT1L/Q79cfUMGZxlv/9dzN7SM1rI= 310 - go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo= 311 - go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= 403 + go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= 404 + go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= 312 405 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 313 406 golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 314 407 golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 315 408 golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 316 409 golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 317 - golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= 318 - golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= 319 - golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= 320 - golang.org/x/exp v0.0.0-20250218142911-aa4b98e5adaa h1:t2QcU6V556bFjYgu4L6C+6VrCPyJZ+eyRsABUPs1mz4= 321 - golang.org/x/exp v0.0.0-20250218142911-aa4b98e5adaa/go.mod h1:BHOTPb3L19zxehTsLoJXVaTktb06DFgmdW6Wb9s8jqk= 410 + golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw= 411 + golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= 412 + golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= 413 + golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= 414 + golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= 322 415 golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 323 416 golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= 324 417 golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 325 418 golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 326 - golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 419 + golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY= 327 420 golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 328 - golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 421 + golang.org/x/mod v0.6.0/go.mod h1:4mET923SAdbXp2ki8ey+zGs1SLqsuM2Y0uvdZR/fUNI= 422 + golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 423 + golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 329 424 golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 330 425 golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 331 426 golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 332 427 golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 428 + golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= 333 429 golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 334 430 golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 335 - golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= 431 + golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= 432 + golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 433 + golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= 434 + golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= 336 435 golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 337 - golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 338 - golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= 436 + golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= 437 + golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= 438 + golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE= 439 + golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws= 339 440 golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= 340 441 golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= 442 + golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 341 443 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 342 444 golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 343 445 golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 344 446 golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 345 447 golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 346 448 golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 449 + golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= 450 + golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 451 + golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 347 452 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 348 453 golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 454 + golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 455 + golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 456 + golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 457 + golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 458 + golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 349 459 golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 350 460 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 351 - golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 352 - golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 461 + golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 462 + golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 353 463 golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 354 - golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 355 - golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 464 + golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 465 + golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 466 + golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 467 + golang.org/x/sys v0.0.0-20220422013727-9388b58f7150/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 356 468 golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 357 469 golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 358 - golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 470 + golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 471 + golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 472 + golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 473 + golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 359 474 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 360 - golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 361 - golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 362 - golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= 363 - golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 475 + golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= 476 + golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 364 477 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 365 478 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/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.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= 368 - golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU= 369 - golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= 370 - golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= 479 + golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 480 + golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= 481 + golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA= 482 + golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ= 483 + golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= 484 + golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= 371 485 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 372 486 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 487 + golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 373 488 golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 374 - golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 375 - golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 376 - golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= 377 - golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= 378 - golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= 489 + golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 490 + golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 491 + golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 492 + golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= 493 + golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= 379 494 golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg= 380 495 golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 381 496 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= ··· 387 502 golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 388 503 golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 389 504 golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 505 + golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 390 506 golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 391 - golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= 507 + golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E= 392 508 golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 393 - golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 509 + golang.org/x/tools v0.2.0/go.mod h1:y4OqIKeOV/fWJetJ8bXPU1sEVniLMIyDAZWeHdV+NTA= 510 + golang.org/x/tools v0.4.0/go.mod h1:UE5sM2OK9E/d67R0ANs2xJizIymRP5gJU295PvKXxjQ= 394 511 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 395 512 golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 396 513 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 397 514 golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 398 - golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU= 399 - golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= 400 - google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= 401 - google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= 515 + golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY= 516 + golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= 517 + google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= 518 + google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= 519 + google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= 520 + google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= 521 + google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= 522 + google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= 523 + google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= 524 + google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= 525 + google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= 526 + google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= 527 + google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= 402 528 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 403 529 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 404 530 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 405 531 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 406 532 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 407 533 gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= 534 + gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= 535 + gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= 536 + gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= 408 537 gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= 409 538 gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= 410 539 gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 411 540 gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 412 541 gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 542 + gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 543 + gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 413 544 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 414 - gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 415 545 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 416 546 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 417 547 honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= 418 - lukechampine.com/blake3 v1.2.1 h1:YuqqRuaqsGV71BV/nm9xlI0MKUv4QC54jQnBChWbGnI= 419 - lukechampine.com/blake3 v1.2.1/go.mod h1:0OFRp7fBtAylGVCO40o87sbupkyIGgbpv1+M1k1LM6k= 548 + lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg= 549 + lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo= 550 + tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250526154904-3906c5336421 h1:ZQNKE1HKWjfQBizxDb2XLhrJcgZqE0MLFIU1iggaF90= 551 + tangled.sh/icyphox.sh/atproto-oauth v0.0.0-20250526154904-3906c5336421/go.mod h1:Wad0H70uyyY4qZryU/1Ic+QZyw41YxN5QfzNAEBaXkQ= 420 552 tangled.sh/oppi.li/go-gitdiff v0.8.2 h1:pASJJNWaFn6EmEIUNNjHZQ3stRu6BqTO2YyjKvTcxIc= 421 553 tangled.sh/oppi.li/go-gitdiff v0.8.2/go.mod h1:WWAk1Mc6EgWarCrPFO+xeYlujPu98VuLW3Tu+B/85AE=
+208
guard/guard.go
··· 1 + package guard 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "log/slog" 7 + "net/http" 8 + "net/url" 9 + "os" 10 + "os/exec" 11 + "strings" 12 + 13 + securejoin "github.com/cyphar/filepath-securejoin" 14 + "github.com/urfave/cli/v3" 15 + "tangled.sh/tangled.sh/core/appview/idresolver" 16 + "tangled.sh/tangled.sh/core/log" 17 + ) 18 + 19 + func Command() *cli.Command { 20 + return &cli.Command{ 21 + Name: "guard", 22 + Usage: "role-based access control for git over ssh (not for manual use)", 23 + Action: Run, 24 + Flags: []cli.Flag{ 25 + &cli.StringFlag{ 26 + Name: "user", 27 + Usage: "allowed git user", 28 + Required: true, 29 + }, 30 + &cli.StringFlag{ 31 + Name: "git-dir", 32 + Usage: "base directory for git repos", 33 + Value: "/home/git", 34 + }, 35 + &cli.StringFlag{ 36 + Name: "log-path", 37 + Usage: "path to log file", 38 + Value: "/home/git/guard.log", 39 + }, 40 + &cli.StringFlag{ 41 + Name: "internal-api", 42 + Usage: "internal API endpoint", 43 + Value: "http://localhost:5444", 44 + }, 45 + }, 46 + } 47 + } 48 + 49 + func Run(ctx context.Context, cmd *cli.Command) error { 50 + l := log.FromContext(ctx) 51 + 52 + incomingUser := cmd.String("user") 53 + gitDir := cmd.String("git-dir") 54 + logPath := cmd.String("log-path") 55 + endpoint := cmd.String("internal-api") 56 + 57 + logFile, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) 58 + if err != nil { 59 + l.Error("failed to open log file", "error", err) 60 + return err 61 + } else { 62 + fileHandler := slog.NewJSONHandler(logFile, &slog.HandlerOptions{Level: slog.LevelInfo}) 63 + l = slog.New(fileHandler) 64 + } 65 + 66 + var clientIP string 67 + if connInfo := os.Getenv("SSH_CONNECTION"); connInfo != "" { 68 + parts := strings.Fields(connInfo) 69 + if len(parts) > 0 { 70 + clientIP = parts[0] 71 + } 72 + } 73 + 74 + if incomingUser == "" { 75 + l.Error("access denied: no user specified") 76 + fmt.Fprintln(os.Stderr, "access denied: no user specified") 77 + return fmt.Errorf("access denied: no user specified") 78 + } 79 + 80 + sshCommand := os.Getenv("SSH_ORIGINAL_COMMAND") 81 + 82 + l.Info("connection attempt", 83 + "user", incomingUser, 84 + "command", sshCommand, 85 + "client", clientIP) 86 + 87 + if sshCommand == "" { 88 + l.Error("access denied: no interactive shells", "user", incomingUser) 89 + fmt.Fprintln(os.Stderr, "access denied: we don't serve interactive shells :)") 90 + os.Exit(-1) 91 + } 92 + 93 + cmdParts := strings.Fields(sshCommand) 94 + if len(cmdParts) < 2 { 95 + l.Error("invalid command format", "command", sshCommand) 96 + fmt.Fprintln(os.Stderr, "invalid command format") 97 + return fmt.Errorf("invalid command format") 98 + } 99 + 100 + gitCommand := cmdParts[0] 101 + 102 + // did:foo/repo-name or 103 + // handle/repo-name or 104 + // any of the above with a leading slash (/) 105 + 106 + components := strings.Split(strings.TrimPrefix(strings.Trim(cmdParts[1], "'"), "/"), "/") 107 + l.Info("command components", "components", components) 108 + 109 + if len(components) != 2 { 110 + l.Error("invalid repo format", "components", components) 111 + fmt.Fprintln(os.Stderr, "invalid repo format, needs <user>/<repo> or /<user>/<repo>") 112 + return fmt.Errorf("invalid repo format, needs <user>/<repo> or /<user>/<repo>") 113 + } 114 + 115 + didOrHandle := components[0] 116 + did := resolveToDid(ctx, l, didOrHandle) 117 + repoName := components[1] 118 + qualifiedRepoName, _ := securejoin.SecureJoin(did, repoName) 119 + 120 + validCommands := map[string]bool{ 121 + "git-receive-pack": true, 122 + "git-upload-pack": true, 123 + "git-upload-archive": true, 124 + } 125 + if !validCommands[gitCommand] { 126 + l.Error("access denied: invalid git command", "command", gitCommand) 127 + fmt.Fprintln(os.Stderr, "access denied: invalid git command") 128 + return fmt.Errorf("access denied: invalid git command") 129 + } 130 + 131 + if gitCommand != "git-upload-pack" { 132 + if !isPushPermitted(l, incomingUser, qualifiedRepoName, endpoint) { 133 + l.Error("access denied: user not allowed", 134 + "did", incomingUser, 135 + "reponame", qualifiedRepoName) 136 + fmt.Fprintln(os.Stderr, "access denied: user not allowed") 137 + return fmt.Errorf("access denied: user not allowed") 138 + } 139 + } 140 + 141 + fullPath, _ := securejoin.SecureJoin(gitDir, qualifiedRepoName) 142 + 143 + l.Info("processing command", 144 + "user", incomingUser, 145 + "command", gitCommand, 146 + "repo", repoName, 147 + "fullPath", fullPath, 148 + "client", clientIP) 149 + 150 + if gitCommand == "git-upload-pack" { 151 + fmt.Fprintf(os.Stderr, "\x02%s\n", "Welcome to this knot!") 152 + } else { 153 + fmt.Fprintf(os.Stderr, "%s\n", "Welcome to this knot!") 154 + } 155 + 156 + gitCmd := exec.Command(gitCommand, fullPath) 157 + gitCmd.Stdout = os.Stdout 158 + gitCmd.Stderr = os.Stderr 159 + gitCmd.Stdin = os.Stdin 160 + 161 + if err := gitCmd.Run(); err != nil { 162 + l.Error("command failed", "error", err) 163 + fmt.Fprintf(os.Stderr, "command failed: %v\n", err) 164 + return fmt.Errorf("command failed: %v", err) 165 + } 166 + 167 + l.Info("command completed", 168 + "user", incomingUser, 169 + "command", gitCommand, 170 + "repo", repoName, 171 + "success", true) 172 + 173 + return nil 174 + } 175 + 176 + func resolveToDid(ctx context.Context, l *slog.Logger, didOrHandle string) string { 177 + resolver := idresolver.DefaultResolver() 178 + ident, err := resolver.ResolveIdent(ctx, didOrHandle) 179 + if err != nil { 180 + l.Error("Error resolving handle", "error", err, "handle", didOrHandle) 181 + fmt.Fprintf(os.Stderr, "error resolving handle: %v\n", err) 182 + os.Exit(1) 183 + } 184 + 185 + // did:plc:foobarbaz/repo 186 + return ident.DID.String() 187 + } 188 + 189 + func isPushPermitted(l *slog.Logger, user, qualifiedRepoName, endpoint string) bool { 190 + u, _ := url.Parse(endpoint + "/push-allowed") 191 + q := u.Query() 192 + q.Add("user", user) 193 + q.Add("repo", qualifiedRepoName) 194 + u.RawQuery = q.Encode() 195 + 196 + req, err := http.Get(u.String()) 197 + if err != nil { 198 + l.Error("Error verifying permissions", "error", err) 199 + fmt.Fprintf(os.Stderr, "error verifying permissions: %v\n", err) 200 + os.Exit(1) 201 + } 202 + 203 + l.Info("Checking push permission", 204 + "url", u.String(), 205 + "status", req.Status) 206 + 207 + return req.StatusCode == http.StatusNoContent 208 + }
+82
hook/hook.go
··· 1 + package hook 2 + 3 + import ( 4 + "bufio" 5 + "context" 6 + "fmt" 7 + "net/http" 8 + "os" 9 + "strings" 10 + 11 + "github.com/urfave/cli/v3" 12 + ) 13 + 14 + // The hook command is nested like so: 15 + // 16 + // knot hook --[flags] [hook] 17 + func Command() *cli.Command { 18 + return &cli.Command{ 19 + Name: "hook", 20 + Usage: "run git hooks", 21 + Flags: []cli.Flag{ 22 + &cli.StringFlag{ 23 + Name: "git-dir", 24 + Usage: "base directory for git repos", 25 + }, 26 + &cli.StringFlag{ 27 + Name: "user-did", 28 + Usage: "git user's did", 29 + }, 30 + &cli.StringFlag{ 31 + Name: "user-handle", 32 + Usage: "git user's handle", 33 + }, 34 + &cli.StringFlag{ 35 + Name: "internal-api", 36 + Usage: "endpoint for the internal API", 37 + Value: "http://localhost:5444", 38 + }, 39 + }, 40 + Commands: []*cli.Command{ 41 + { 42 + Name: "post-recieve", 43 + Usage: "sends a post-recieve hook to the knot (waits for stdin)", 44 + Action: postRecieve, 45 + }, 46 + }, 47 + } 48 + } 49 + 50 + func postRecieve(ctx context.Context, cmd *cli.Command) error { 51 + gitDir := cmd.String("git-dir") 52 + userDid := cmd.String("user-did") 53 + userHandle := cmd.String("user-handle") 54 + endpoint := cmd.String("internal-api") 55 + 56 + payloadReader := bufio.NewReader(os.Stdin) 57 + payload, _ := payloadReader.ReadString('\n') 58 + 59 + client := &http.Client{} 60 + 61 + req, err := http.NewRequest("POST", endpoint+"/hooks/post-receive", strings.NewReader(payload)) 62 + if err != nil { 63 + return fmt.Errorf("failed to create request: %w", err) 64 + } 65 + 66 + req.Header.Set("Content-Type", "text/plain") 67 + req.Header.Set("X-Git-Dir", gitDir) 68 + req.Header.Set("X-Git-User-Did", userDid) 69 + req.Header.Set("X-Git-User-Handle", userHandle) 70 + 71 + resp, err := client.Do(req) 72 + if err != nil { 73 + return fmt.Errorf("failed to execute request: %w", err) 74 + } 75 + defer resp.Body.Close() 76 + 77 + if resp.StatusCode != http.StatusOK { 78 + return fmt.Errorf("unexpected status code: %d", resp.StatusCode) 79 + } 80 + 81 + return nil 82 + }
+6
input.css
··· 41 41 @layer base { 42 42 html { 43 43 font-size: 14px; 44 + scrollbar-gutter: stable; 44 45 } 45 46 @supports (font-variation-settings: normal) { 46 47 html { ··· 102 103 @apply py-1 text-gray-900 dark:text-gray-100; 103 104 } 104 105 } 106 + } 107 + 108 + /* Hidden elements */ 109 + [aria-hidden="true"] { 110 + display: none ; 105 111 } 106 112 107 113 /* Background */
+121
keyfetch/keyfetch.go
··· 1 + package keyfetch 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "io" 8 + "net/http" 9 + "os" 10 + "strings" 11 + 12 + "github.com/urfave/cli/v3" 13 + "tangled.sh/tangled.sh/core/log" 14 + ) 15 + 16 + func Command() *cli.Command { 17 + return &cli.Command{ 18 + Name: "keys", 19 + Usage: "fetch public keys from the knot server", 20 + Action: Run, 21 + Flags: []cli.Flag{ 22 + &cli.StringFlag{ 23 + Name: "output", 24 + Aliases: []string{"o"}, 25 + Usage: "output format (table, json, authorized-keys)", 26 + Value: "table", 27 + }, 28 + &cli.StringFlag{ 29 + Name: "internal-api", 30 + Usage: "internal API endpoint", 31 + Value: "http://localhost:5444", 32 + }, 33 + &cli.StringFlag{ 34 + Name: "git-dir", 35 + Usage: "base directory for git repos", 36 + Value: "/home/git", 37 + }, 38 + &cli.StringFlag{ 39 + Name: "log-path", 40 + Usage: "path to log file", 41 + Value: "/home/git/log", 42 + }, 43 + }, 44 + } 45 + } 46 + 47 + func Run(ctx context.Context, cmd *cli.Command) error { 48 + l := log.FromContext(ctx) 49 + 50 + internalApi := cmd.String("internal-api") 51 + gitDir := cmd.String("git-dir") 52 + logPath := cmd.String("log-path") 53 + output := cmd.String("output") 54 + 55 + executablePath, err := os.Executable() 56 + if err != nil { 57 + l.Error("error getting path of executable", "error", err) 58 + return err 59 + } 60 + 61 + resp, err := http.Get(internalApi + "/keys") 62 + if err != nil { 63 + l.Error("error reaching internal API endpoint; is the knot server running?", "error", err) 64 + return err 65 + } 66 + defer resp.Body.Close() 67 + 68 + body, err := io.ReadAll(resp.Body) 69 + if err != nil { 70 + l.Error("error reading response body", "error", err) 71 + return err 72 + } 73 + 74 + var data []map[string]any 75 + err = json.Unmarshal(body, &data) 76 + if err != nil { 77 + l.Error("error unmarshalling response body", "error", err) 78 + return err 79 + } 80 + 81 + switch output { 82 + case "json": 83 + prettyJSON, err := json.MarshalIndent(data, "", " ") 84 + if err != nil { 85 + l.Error("error pretty printing JSON", "error", err) 86 + return err 87 + } 88 + 89 + if _, err := os.Stdout.Write(prettyJSON); err != nil { 90 + l.Error("error writing to stdout", "error", err) 91 + return err 92 + } 93 + case "authorized-keys": 94 + formatted := formatKeyData(executablePath, gitDir, logPath, internalApi, data) 95 + _, err := os.Stdout.Write([]byte(formatted)) 96 + if err != nil { 97 + l.Error("error writing to stdout", "error", err) 98 + return err 99 + } 100 + case "table": 101 + fmt.Printf("%-40s %-40s\n", "DID", "KEY") 102 + fmt.Println(strings.Repeat("-", 80)) 103 + 104 + for _, entry := range data { 105 + did, _ := entry["did"].(string) 106 + key, _ := entry["key"].(string) 107 + fmt.Printf("%-40s %-40s\n", did, key) 108 + } 109 + } 110 + return nil 111 + } 112 + 113 + func formatKeyData(executablePath, gitDir, logPath, endpoint string, data []map[string]any) string { 114 + var result string 115 + for _, entry := range data { 116 + result += fmt.Sprintf( 117 + `command="%s guard -git-dir %s -user %s -log-path %s -internal-api %s",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty %s`+"\n", 118 + executablePath, gitDir, entry["did"], logPath, endpoint, entry["key"]) 119 + } 120 + return result 121 + }
+3 -3
knotserver/git/diff.go
··· 127 127 128 128 // FormatPatch generates a git-format-patch output between two commits, 129 129 // and returns the raw format-patch series, a parsed FormatPatch and an error. 130 - func (g *GitRepo) formatSinglePatch(base, commit2 plumbing.Hash, extraArgs ...string) (string, *patchutil.FormatPatch, error) { 130 + func (g *GitRepo) formatSinglePatch(base, commit2 plumbing.Hash, extraArgs ...string) (string, *types.FormatPatch, error) { 131 131 var stdout bytes.Buffer 132 132 133 133 args := []string{ ··· 222 222 return commits, nil 223 223 } 224 224 225 - func (g *GitRepo) FormatPatch(base, commit2 *object.Commit) (string, []patchutil.FormatPatch, error) { 225 + func (g *GitRepo) FormatPatch(base, commit2 *object.Commit) (string, []types.FormatPatch, error) { 226 226 // get list of commits between commir2 and base 227 227 commits, err := g.commitsBetween(commit2, base) 228 228 if err != nil { ··· 233 233 slices.Reverse(commits) 234 234 235 235 var allPatchesContent strings.Builder 236 - var allPatches []patchutil.FormatPatch 236 + var allPatches []types.FormatPatch 237 237 238 238 for _, commit := range commits { 239 239 changeId := ""
+47 -76
knotserver/git/git.go
··· 2 2 3 3 import ( 4 4 "archive/tar" 5 - "bytes" 6 5 "fmt" 7 6 "io" 8 7 "io/fs" ··· 11 10 "sort" 12 11 "strconv" 13 12 "strings" 14 - "sync" 15 13 "time" 16 14 17 - "github.com/dgraph-io/ristretto" 18 15 "github.com/go-git/go-git/v5" 19 16 "github.com/go-git/go-git/v5/plumbing" 20 17 "github.com/go-git/go-git/v5/plumbing/object" 21 18 "tangled.sh/tangled.sh/core/types" 22 19 ) 23 - 24 - var ( 25 - commitCache *ristretto.Cache 26 - cacheMu sync.RWMutex 27 - ) 28 - 29 - func init() { 30 - cache, _ := ristretto.NewCache(&ristretto.Config{ 31 - NumCounters: 1e7, 32 - MaxCost: 1 << 30, 33 - BufferItems: 64, 34 - TtlTickerDurationInSec: 120, 35 - }) 36 - commitCache = cache 37 - } 38 20 39 21 var ( 40 22 ErrBinaryFile = fmt.Errorf("binary file") ··· 142 124 return &g, nil 143 125 } 144 126 145 - func (g *GitRepo) Commits() ([]*object.Commit, error) { 146 - ci, err := g.r.Log(&git.LogOptions{From: g.h}) 127 + func (g *GitRepo) Commits(offset, limit int) ([]*object.Commit, error) { 128 + commits := []*object.Commit{} 129 + 130 + output, err := g.revList( 131 + fmt.Sprintf("--skip=%d", offset), 132 + fmt.Sprintf("--max-count=%d", limit), 133 + ) 147 134 if err != nil { 148 135 return nil, fmt.Errorf("commits from ref: %w", err) 149 136 } 150 137 151 - commits := []*object.Commit{} 152 - ci.ForEach(func(c *object.Commit) error { 153 - commits = append(commits, c) 154 - return nil 155 - }) 138 + lines := strings.Split(strings.TrimSpace(string(output)), "\n") 139 + if len(lines) == 1 && lines[0] == "" { 140 + return commits, nil 141 + } 142 + 143 + for _, item := range lines { 144 + obj, err := g.r.CommitObject(plumbing.NewHash(item)) 145 + if err != nil { 146 + continue 147 + } 148 + commits = append(commits, obj) 149 + } 156 150 157 151 return commits, nil 152 + } 153 + 154 + func (g *GitRepo) TotalCommits() (int, error) { 155 + output, err := g.revList( 156 + fmt.Sprintf("--count"), 157 + ) 158 + if err != nil { 159 + return 0, fmt.Errorf("failed to run rev-list", err) 160 + } 161 + 162 + count, err := strconv.Atoi(strings.TrimSpace(string(output))) 163 + if err != nil { 164 + return 0, err 165 + } 166 + 167 + return count, nil 168 + } 169 + 170 + func (g *GitRepo) revList(extraArgs ...string) ([]byte, error) { 171 + var args []string 172 + args = append(args, "rev-list") 173 + args = append(args, g.h.String()) 174 + args = append(args, extraArgs...) 175 + 176 + cmd := exec.Command("git", args...) 177 + cmd.Dir = g.path 178 + 179 + return cmd.Output() 158 180 } 159 181 160 182 func (g *GitRepo) Commit(h plumbing.Hash) (*object.Commit, error) { ··· 408 430 } 409 431 410 432 return nil 411 - } 412 - 413 - func (g *GitRepo) LastCommitForPath(path string) (*types.LastCommitInfo, error) { 414 - cacheKey := fmt.Sprintf("%s:%s", g.h.String(), path) 415 - cacheMu.RLock() 416 - if commitInfo, found := commitCache.Get(cacheKey); found { 417 - cacheMu.RUnlock() 418 - return commitInfo.(*types.LastCommitInfo), nil 419 - } 420 - cacheMu.RUnlock() 421 - 422 - cmd := exec.Command("git", "-C", g.path, "log", g.h.String(), "-1", "--format=%H %ct", "--", path) 423 - 424 - var out bytes.Buffer 425 - cmd.Stdout = &out 426 - cmd.Stderr = &out 427 - 428 - if err := cmd.Run(); err != nil { 429 - return nil, fmt.Errorf("failed to get commit hash: %w", err) 430 - } 431 - 432 - output := strings.TrimSpace(out.String()) 433 - if output == "" { 434 - return nil, fmt.Errorf("no commits found for path: %s", path) 435 - } 436 - 437 - parts := strings.SplitN(output, " ", 2) 438 - if len(parts) < 2 { 439 - return nil, fmt.Errorf("unexpected commit log format") 440 - } 441 - 442 - commitHash := parts[0] 443 - commitTimeUnix, err := strconv.ParseInt(parts[1], 10, 64) 444 - if err != nil { 445 - return nil, fmt.Errorf("parsing commit time: %w", err) 446 - } 447 - commitTime := time.Unix(commitTimeUnix, 0) 448 - 449 - hash := plumbing.NewHash(commitHash) 450 - 451 - commitInfo := &types.LastCommitInfo{ 452 - Hash: hash, 453 - Message: "", 454 - When: commitTime, 455 - } 456 - 457 - cacheMu.Lock() 458 - commitCache.Set(cacheKey, commitInfo, 1) 459 - cacheMu.Unlock() 460 - 461 - return commitInfo, nil 462 433 } 463 434 464 435 func newInfoWrapper(
+168
knotserver/git/last_commit.go
··· 1 + package git 2 + 3 + import ( 4 + "bufio" 5 + "context" 6 + "crypto/sha256" 7 + "fmt" 8 + "io" 9 + "os/exec" 10 + "path" 11 + "strings" 12 + "time" 13 + 14 + "github.com/dgraph-io/ristretto" 15 + "github.com/go-git/go-git/v5/plumbing" 16 + "github.com/go-git/go-git/v5/plumbing/object" 17 + ) 18 + 19 + var ( 20 + commitCache *ristretto.Cache 21 + ) 22 + 23 + func init() { 24 + cache, _ := ristretto.NewCache(&ristretto.Config{ 25 + NumCounters: 1e7, 26 + MaxCost: 1 << 30, 27 + BufferItems: 64, 28 + TtlTickerDurationInSec: 120, 29 + }) 30 + commitCache = cache 31 + } 32 + 33 + func (g *GitRepo) streamingGitLog(ctx context.Context, extraArgs ...string) (io.Reader, error) { 34 + args := []string{} 35 + args = append(args, "log") 36 + args = append(args, g.h.String()) 37 + args = append(args, extraArgs...) 38 + 39 + cmd := exec.CommandContext(ctx, "git", args...) 40 + cmd.Dir = g.path 41 + 42 + stdout, err := cmd.StdoutPipe() 43 + if err != nil { 44 + return nil, err 45 + } 46 + 47 + if err := cmd.Start(); err != nil { 48 + return nil, err 49 + } 50 + 51 + return stdout, nil 52 + } 53 + 54 + type commit struct { 55 + hash plumbing.Hash 56 + when time.Time 57 + files []string 58 + message string 59 + } 60 + 61 + func cacheKey(g *GitRepo, path string) string { 62 + sep := byte(':') 63 + hash := sha256.Sum256(fmt.Append([]byte{}, g.path, sep, g.h.String(), sep, path)) 64 + return fmt.Sprintf("%x", hash) 65 + } 66 + 67 + func (g *GitRepo) calculateCommitTimeIn(ctx context.Context, subtree *object.Tree, parent string, timeout time.Duration) (map[string]commit, error) { 68 + ctx, cancel := context.WithTimeout(ctx, timeout) 69 + defer cancel() 70 + return g.calculateCommitTime(ctx, subtree, parent) 71 + } 72 + 73 + func (g *GitRepo) calculateCommitTime(ctx context.Context, subtree *object.Tree, parent string) (map[string]commit, error) { 74 + filesToDo := make(map[string]struct{}) 75 + filesDone := make(map[string]commit) 76 + for _, e := range subtree.Entries { 77 + fpath := path.Clean(path.Join(parent, e.Name)) 78 + filesToDo[fpath] = struct{}{} 79 + } 80 + 81 + for _, e := range subtree.Entries { 82 + f := path.Clean(path.Join(parent, e.Name)) 83 + cacheKey := cacheKey(g, f) 84 + if cached, ok := commitCache.Get(cacheKey); ok { 85 + filesDone[f] = cached.(commit) 86 + delete(filesToDo, f) 87 + } else { 88 + filesToDo[f] = struct{}{} 89 + } 90 + } 91 + 92 + if len(filesToDo) == 0 { 93 + return filesDone, nil 94 + } 95 + 96 + ctx, cancel := context.WithCancel(ctx) 97 + defer cancel() 98 + 99 + pathSpec := "." 100 + if parent != "" { 101 + pathSpec = parent 102 + } 103 + output, err := g.streamingGitLog(ctx, "--pretty=format:%H,%ad,%s", "--date=iso", "--name-only", "--", pathSpec) 104 + if err != nil { 105 + return nil, err 106 + } 107 + 108 + reader := bufio.NewReader(output) 109 + var current commit 110 + for { 111 + line, err := reader.ReadString('\n') 112 + if err != nil && err != io.EOF { 113 + return nil, err 114 + } 115 + line = strings.TrimSpace(line) 116 + 117 + if line == "" { 118 + if !current.hash.IsZero() { 119 + // we have a fully parsed commit 120 + for _, f := range current.files { 121 + if _, ok := filesToDo[f]; ok { 122 + filesDone[f] = current 123 + delete(filesToDo, f) 124 + commitCache.Set(cacheKey(g, f), current, 0) 125 + } 126 + } 127 + 128 + if len(filesToDo) == 0 { 129 + cancel() 130 + break 131 + } 132 + current = commit{} 133 + } 134 + } else if current.hash.IsZero() { 135 + parts := strings.SplitN(line, ",", 3) 136 + if len(parts) == 3 { 137 + current.hash = plumbing.NewHash(parts[0]) 138 + current.when, _ = time.Parse("2006-01-02 15:04:05 -0700", parts[1]) 139 + current.message = parts[2] 140 + } 141 + } else { 142 + // all ancestors along this path should also be included 143 + file := path.Clean(line) 144 + ancestors := ancestors(file) 145 + current.files = append(current.files, file) 146 + current.files = append(current.files, ancestors...) 147 + } 148 + 149 + if err == io.EOF { 150 + break 151 + } 152 + } 153 + 154 + return filesDone, nil 155 + } 156 + 157 + func ancestors(p string) []string { 158 + var ancestors []string 159 + 160 + for { 161 + p = path.Dir(p) 162 + if p == "." || p == "/" { 163 + break 164 + } 165 + ancestors = append(ancestors, p) 166 + } 167 + return ancestors 168 + }
+20 -20
knotserver/git/tree.go
··· 1 1 package git 2 2 3 3 import ( 4 + "context" 4 5 "fmt" 6 + "path" 5 7 "time" 6 8 7 9 "github.com/go-git/go-git/v5/plumbing/object" 8 10 "tangled.sh/tangled.sh/core/types" 9 11 ) 10 12 11 - func (g *GitRepo) FileTree(path string) ([]types.NiceTree, error) { 13 + func (g *GitRepo) FileTree(ctx context.Context, path string) ([]types.NiceTree, error) { 12 14 c, err := g.r.CommitObject(g.h) 13 15 if err != nil { 14 16 return nil, fmt.Errorf("commit object: %w", err) ··· 21 23 } 22 24 23 25 if path == "" { 24 - files = g.makeNiceTree(tree, "") 26 + files = g.makeNiceTree(ctx, tree, "") 25 27 } else { 26 28 o, err := tree.FindEntry(path) 27 29 if err != nil { ··· 34 36 return nil, err 35 37 } 36 38 37 - files = g.makeNiceTree(subtree, path) 39 + files = g.makeNiceTree(ctx, subtree, path) 38 40 } 39 41 } 40 42 41 43 return files, nil 42 44 } 43 45 44 - func (g *GitRepo) makeNiceTree(t *object.Tree, parent string) []types.NiceTree { 46 + func (g *GitRepo) makeNiceTree(ctx context.Context, subtree *object.Tree, parent string) []types.NiceTree { 45 47 nts := []types.NiceTree{} 46 48 47 - for _, e := range t.Entries { 49 + times, err := g.calculateCommitTimeIn(ctx, subtree, parent, 2*time.Second) 50 + if err != nil { 51 + return nts 52 + } 53 + 54 + for _, e := range subtree.Entries { 48 55 mode, _ := e.Mode.ToOSFileMode() 49 - sz, _ := t.Size(e.Name) 56 + sz, _ := subtree.Size(e.Name) 50 57 51 - var fpath string 52 - if parent != "" { 53 - fpath = fmt.Sprintf("%s/%s", parent, e.Name) 54 - } else { 55 - fpath = e.Name 56 - } 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". 58 + fpath := path.Join(parent, e.Name) 59 + 60 + var lastCommit *types.LastCommitInfo 61 + if t, ok := times[fpath]; ok { 62 62 lastCommit = &types.LastCommitInfo{ 63 - Hash: g.h, 64 - Message: "", 65 - When: time.Now(), 63 + Hash: t.hash, 64 + Message: t.message, 65 + When: t.when, 66 66 } 67 67 } 68 68
+88 -49
knotserver/routes.go
··· 2 2 3 3 import ( 4 4 "compress/gzip" 5 + "context" 5 6 "crypto/hmac" 6 7 "crypto/sha256" 7 8 "encoding/hex" ··· 16 17 "path/filepath" 17 18 "strconv" 18 19 "strings" 20 + "sync" 19 21 20 22 securejoin "github.com/cyphar/filepath-securejoin" 21 23 "github.com/gliderlabs/ssh" ··· 87 89 } 88 90 } 89 91 90 - commits, err := gr.Commits() 91 - total := len(commits) 92 - if err != nil { 93 - writeError(w, err.Error(), http.StatusInternalServerError) 94 - l.Error("fetching commits", "error", err.Error()) 95 - return 96 - } 97 - if len(commits) > 10 { 98 - commits = commits[:10] 99 - } 92 + var ( 93 + commits []*object.Commit 94 + total int 95 + branches []types.Branch 96 + files []types.NiceTree 97 + tags []*git.TagReference 98 + ) 99 + 100 + var wg sync.WaitGroup 101 + errorsCh := make(chan error, 5) 102 + 103 + wg.Add(1) 104 + go func() { 105 + defer wg.Done() 106 + cs, err := gr.Commits(0, 60) 107 + if err != nil { 108 + errorsCh <- fmt.Errorf("commits: %w", err) 109 + return 110 + } 111 + commits = cs 112 + }() 113 + 114 + wg.Add(1) 115 + go func() { 116 + defer wg.Done() 117 + t, err := gr.TotalCommits() 118 + if err != nil { 119 + errorsCh <- fmt.Errorf("calculating total: %w", err) 120 + return 121 + } 122 + total = t 123 + }() 124 + 125 + wg.Add(1) 126 + go func() { 127 + defer wg.Done() 128 + bs, err := gr.Branches() 129 + if err != nil { 130 + errorsCh <- fmt.Errorf("fetching branches: %w", err) 131 + return 132 + } 133 + branches = bs 134 + }() 135 + 136 + wg.Add(1) 137 + go func() { 138 + defer wg.Done() 139 + ts, err := gr.Tags() 140 + if err != nil { 141 + errorsCh <- fmt.Errorf("fetching tags: %w", err) 142 + return 143 + } 144 + tags = ts 145 + }() 146 + 147 + wg.Add(1) 148 + go func() { 149 + defer wg.Done() 150 + fs, err := gr.FileTree(r.Context(), "") 151 + if err != nil { 152 + errorsCh <- fmt.Errorf("fetching filetree: %w", err) 153 + return 154 + } 155 + files = fs 156 + }() 157 + 158 + wg.Wait() 159 + close(errorsCh) 100 160 101 - branches, err := gr.Branches() 102 - if err != nil { 103 - l.Error("getting branches", "error", err.Error()) 161 + // show any errors 162 + for err := range errorsCh { 163 + l.Error("loading repo", "error", err.Error()) 104 164 writeError(w, err.Error(), http.StatusInternalServerError) 105 165 return 106 - } 107 - 108 - tags, err := gr.Tags() 109 - if err != nil { 110 - // Non-fatal, we *should* have at least one branch to show. 111 - l.Warn("getting tags", "error", err.Error()) 112 166 } 113 167 114 168 rtags := []*types.TagReference{} ··· 139 193 } 140 194 } 141 195 142 - files, err := gr.FileTree("") 143 - if err != nil { 144 - writeError(w, err.Error(), http.StatusInternalServerError) 145 - l.Error("file tree", "error", err.Error()) 146 - return 147 - } 148 - 149 196 if ref == "" { 150 197 mainBranch, err := gr.FindMainBranch() 151 198 if err != nil { ··· 187 234 return 188 235 } 189 236 190 - files, err := gr.FileTree(treePath) 237 + files, err := gr.FileTree(r.Context(), treePath) 191 238 if err != nil { 192 239 writeError(w, err.Error(), http.StatusInternalServerError) 193 240 l.Error("file tree", "error", err.Error()) ··· 349 396 return 350 397 } 351 398 352 - commits, err := gr.Commits() 353 - if err != nil { 354 - writeError(w, err.Error(), http.StatusInternalServerError) 355 - l.Error("fetching commits", "error", err.Error()) 356 - return 357 - } 358 - 359 399 // Get page parameters 360 400 page := 1 361 401 pageSize := 30 ··· 372 412 } 373 413 } 374 414 375 - // Calculate pagination 376 - start := (page - 1) * pageSize 377 - end := start + pageSize 378 - total := len(commits) 415 + // convert to offset/limit 416 + offset := (page - 1) * pageSize 417 + limit := pageSize 379 418 380 - if start >= total { 381 - commits = []*object.Commit{} 382 - } else { 383 - if end > total { 384 - end = total 385 - } 386 - commits = commits[start:end] 419 + commits, err := gr.Commits(offset, limit) 420 + if err != nil { 421 + writeError(w, err.Error(), http.StatusInternalServerError) 422 + l.Error("fetching commits", "error", err.Error()) 423 + return 387 424 } 425 + 426 + total := len(commits) 388 427 389 428 resp := types.RepoLogResponse{ 390 429 Commits: commits, ··· 730 769 731 770 languageFileCount := make(map[string]int) 732 771 733 - err = recurseEntireTree(gr, func(absPath string) { 772 + err = recurseEntireTree(r.Context(), gr, func(absPath string) { 734 773 lang, safe := enry.GetLanguageByExtension(absPath) 735 774 if len(lang) == 0 || !safe { 736 775 content, _ := gr.FileContentN(absPath, 1024) ··· 763 802 return 764 803 } 765 804 766 - func recurseEntireTree(git *git.GitRepo, callback func(absPath string), filePath string) error { 767 - files, err := git.FileTree(filePath) 805 + func recurseEntireTree(ctx context.Context, git *git.GitRepo, callback func(absPath string), filePath string) error { 806 + files, err := git.FileTree(ctx, filePath) 768 807 if err != nil { 769 808 log.Println(err) 770 809 return err ··· 773 812 for _, file := range files { 774 813 absPath := path.Join(filePath, file.Name) 775 814 if !file.IsFile { 776 - return recurseEntireTree(git, callback, absPath) 815 + return recurseEntireTree(ctx, git, callback, absPath) 777 816 } 778 817 callback(absPath) 779 818 }
+84
knotserver/server.go
··· 1 + package knotserver 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "net/http" 7 + 8 + "github.com/urfave/cli/v3" 9 + "tangled.sh/tangled.sh/core/api/tangled" 10 + "tangled.sh/tangled.sh/core/jetstream" 11 + "tangled.sh/tangled.sh/core/knotserver/config" 12 + "tangled.sh/tangled.sh/core/knotserver/db" 13 + "tangled.sh/tangled.sh/core/log" 14 + "tangled.sh/tangled.sh/core/rbac" 15 + ) 16 + 17 + func Command() *cli.Command { 18 + return &cli.Command{ 19 + Name: "server", 20 + Usage: "run a knot server", 21 + Action: Run, 22 + Description: ` 23 + Environment variables: 24 + KNOT_SERVER_SECRET (required) 25 + KNOT_SERVER_HOSTNAME (required) 26 + KNOT_SERVER_LISTEN_ADDR (default: 0.0.0.0:5555) 27 + KNOT_SERVER_INTERNAL_LISTEN_ADDR (default: 127.0.0.1:5444) 28 + KNOT_SERVER_DB_PATH (default: knotserver.db) 29 + KNOT_SERVER_JETSTREAM_ENDPOINT (default: wss://jetstream1.us-west.bsky.network/subscribe) 30 + KNOT_SERVER_DEV (default: false) 31 + KNOT_REPO_SCAN_PATH (default: /home/git) 32 + KNOT_REPO_README (comma-separated list) 33 + KNOT_REPO_MAIN_BRANCH (default: main) 34 + APPVIEW_ENDPOINT (default: https://tangled.sh) 35 + `, 36 + } 37 + } 38 + 39 + func Run(ctx context.Context, cmd *cli.Command) error { 40 + l := log.FromContext(ctx) 41 + 42 + c, err := config.Load(ctx) 43 + if err != nil { 44 + return fmt.Errorf("failed to load config: %w", err) 45 + } 46 + 47 + if c.Server.Dev { 48 + l.Info("running in dev mode, signature verification is disabled") 49 + } 50 + 51 + db, err := db.Setup(c.Server.DBPath) 52 + if err != nil { 53 + return fmt.Errorf("failed to load db: %w", err) 54 + } 55 + 56 + e, err := rbac.NewEnforcer(c.Server.DBPath) 57 + if err != nil { 58 + return fmt.Errorf("failed to setup rbac enforcer: %w", err) 59 + } 60 + 61 + e.E.EnableAutoSave(true) 62 + 63 + jc, err := jetstream.NewJetstreamClient(c.Server.JetstreamEndpoint, "knotserver", []string{ 64 + tangled.PublicKeyNSID, 65 + tangled.KnotMemberNSID, 66 + }, nil, l, db, true) 67 + if err != nil { 68 + l.Error("failed to setup jetstream", "error", err) 69 + } 70 + 71 + mux, err := Setup(ctx, c, db, e, jc, l) 72 + if err != nil { 73 + return fmt.Errorf("failed to setup server: %w", err) 74 + } 75 + imux := Internal(ctx, db, e) 76 + 77 + l.Info("starting internal server", "address", c.Server.InternalListenAddr) 78 + go http.ListenAndServe(c.Server.InternalListenAddr, imux) 79 + 80 + l.Info("starting main server", "address", c.Server.ListenAddr) 81 + l.Error("server error", "error", http.ListenAndServe(c.Server.ListenAddr, mux)) 82 + 83 + return nil 84 + }
+69 -16
patchutil/patchutil.go
··· 2 2 3 3 import ( 4 4 "fmt" 5 + "log" 5 6 "os" 6 7 "os/exec" 7 8 "regexp" ··· 9 10 "strings" 10 11 11 12 "github.com/bluekeyes/go-gitdiff/gitdiff" 13 + "tangled.sh/tangled.sh/core/types" 12 14 ) 13 15 14 - type FormatPatch struct { 15 - Files []*gitdiff.File 16 - *gitdiff.PatchHeader 17 - Raw string 18 - } 19 - 20 - func (f FormatPatch) ChangeId() (string, error) { 21 - if vals, ok := f.RawHeaders["Change-Id"]; ok && len(vals) == 1 { 22 - return vals[0], nil 23 - } 24 - return "", fmt.Errorf("no change-id found") 25 - } 26 - 27 - func ExtractPatches(formatPatch string) ([]FormatPatch, error) { 16 + func ExtractPatches(formatPatch string) ([]types.FormatPatch, error) { 28 17 patches := splitFormatPatch(formatPatch) 29 18 30 - result := []FormatPatch{} 19 + result := []types.FormatPatch{} 31 20 32 21 for _, patch := range patches { 33 22 files, headerStr, err := gitdiff.Parse(strings.NewReader(patch)) ··· 40 29 return nil, fmt.Errorf("failed to parse patch header: %w", err) 41 30 } 42 31 43 - result = append(result, FormatPatch{ 32 + result = append(result, types.FormatPatch{ 44 33 Files: files, 45 34 PatchHeader: header, 46 35 Raw: patch, ··· 263 252 return strings.Compare(bestName(a), bestName(b)) 264 253 }) 265 254 } 255 + 256 + func AsDiff(patch string) ([]*gitdiff.File, error) { 257 + // if format-patch; then extract each patch 258 + var diffs []*gitdiff.File 259 + if IsFormatPatch(patch) { 260 + patches, err := ExtractPatches(patch) 261 + if err != nil { 262 + return nil, err 263 + } 264 + var ps [][]*gitdiff.File 265 + for _, p := range patches { 266 + ps = append(ps, p.Files) 267 + } 268 + 269 + diffs = CombineDiff(ps...) 270 + } else { 271 + d, _, err := gitdiff.Parse(strings.NewReader(patch)) 272 + if err != nil { 273 + return nil, err 274 + } 275 + diffs = d 276 + } 277 + 278 + return diffs, nil 279 + } 280 + 281 + func AsNiceDiff(patch, targetBranch string) types.NiceDiff { 282 + diffs, err := AsDiff(patch) 283 + if err != nil { 284 + log.Println(err) 285 + } 286 + 287 + nd := types.NiceDiff{} 288 + nd.Commit.Parent = targetBranch 289 + 290 + for _, d := range diffs { 291 + ndiff := types.Diff{} 292 + ndiff.Name.New = d.NewName 293 + ndiff.Name.Old = d.OldName 294 + ndiff.IsBinary = d.IsBinary 295 + ndiff.IsNew = d.IsNew 296 + ndiff.IsDelete = d.IsDelete 297 + ndiff.IsCopy = d.IsCopy 298 + ndiff.IsRename = d.IsRename 299 + 300 + for _, tf := range d.TextFragments { 301 + ndiff.TextFragments = append(ndiff.TextFragments, *tf) 302 + for _, l := range tf.Lines { 303 + switch l.Op { 304 + case gitdiff.OpAdd: 305 + nd.Stat.Insertions += 1 306 + case gitdiff.OpDelete: 307 + nd.Stat.Deletions += 1 308 + } 309 + } 310 + } 311 + 312 + nd.Diff = append(nd.Diff, ndiff) 313 + } 314 + 315 + nd.Stat.FilesChanged = len(diffs) 316 + 317 + return nd 318 + }
+20
types/patch.go
··· 1 + package types 2 + 3 + import ( 4 + "fmt" 5 + 6 + "github.com/bluekeyes/go-gitdiff/gitdiff" 7 + ) 8 + 9 + type FormatPatch struct { 10 + Files []*gitdiff.File 11 + *gitdiff.PatchHeader 12 + Raw string 13 + } 14 + 15 + func (f FormatPatch) ChangeId() (string, error) { 16 + if vals, ok := f.RawHeaders["Change-Id"]; ok && len(vals) == 1 { 17 + return vals[0], nil 18 + } 19 + return "", fmt.Errorf("no change-id found") 20 + }
+5 -6
types/repo.go
··· 2 2 3 3 import ( 4 4 "github.com/go-git/go-git/v5/plumbing/object" 5 - "tangled.sh/tangled.sh/core/patchutil" 6 5 ) 7 6 8 7 type RepoIndexResponse struct { ··· 34 33 } 35 34 36 35 type RepoFormatPatchResponse struct { 37 - Rev1 string `json:"rev1,omitempty"` 38 - Rev2 string `json:"rev2,omitempty"` 39 - FormatPatch []patchutil.FormatPatch `json:"format_patch,omitempty"` 40 - MergeBase string `json:"merge_base,omitempty"` 41 - Patch string `json:"patch,omitempty"` 36 + Rev1 string `json:"rev1,omitempty"` 37 + Rev2 string `json:"rev2,omitempty"` 38 + FormatPatch []FormatPatch `json:"format_patch,omitempty"` 39 + MergeBase string `json:"merge_base,omitempty"` 40 + Patch string `json:"patch,omitempty"` 42 41 } 43 42 44 43 type RepoTreeResponse struct {