+85
appview/config/config.go
+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
-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
-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
+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
+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
+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
+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
+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
+2
-2
appview/oauth/client/oauth_client.go
+15
appview/oauth/consts.go
+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
+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
+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
+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
+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
+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, ¶ms.Diff})
858
920
}
859
921
860
922
func (p *Pages) Static() http.Handler {
+2
-2
appview/pages/templates/repo/blob.html
+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
+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
+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
+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
+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
+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
+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
+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
+44
-4
appview/pages/templates/repo/pulls/new.html
···
1
1
{{ define "title" }}new pull · {{ .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
+8
-4
appview/pages/templates/repo/tree.html
···
1
-
{{ define "title"}}{{ range .BreadCrumbs }}{{ index . 0}}/{{ end }} at {{ .Ref }} · {{ .RepoInfo.FullName }}{{ end }}
1
+
{{ define "title"}}{{ range .BreadCrumbs }}{{ pathUnescape (index . 0)}}/{{ end }} at {{ .Ref }} · {{ .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
+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
+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
+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
+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—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
+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
+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
+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
-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
+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
-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
+1
-1
appview/state/follow.go
-226
appview/state/middleware.go
-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
+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
-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
-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—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
-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
+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
+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
+1
-1
appview/xrpcclient/xrpc.go
+1
-1
avatar/src/index.js
+1
-1
avatar/src/index.js
+2
-2
cmd/appview/main.go
+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
-1
cmd/genjwks/main.go
-15
cmd/keyfetch/format.go
-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
-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
+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
-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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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 {