+2
appview/db/follow.go
+2
appview/db/follow.go
+1
appview/db/issues.go
+1
appview/db/issues.go
+1
-1
appview/db/language.go
+1
-1
appview/db/language.go
+5
appview/db/profile.go
+5
appview/db/profile.go
···
230
if err != nil {
231
return nil, err
232
}
233
234
profileMap := make(map[string]*models.Profile)
235
for rows.Next() {
···
270
if err != nil {
271
return nil, err
272
}
273
idxs := make(map[string]int)
274
for did := range profileMap {
275
idxs[did] = 0
···
290
if err != nil {
291
return nil, err
292
}
293
idxs = make(map[string]int)
294
for did := range profileMap {
295
idxs[did] = 0
···
230
if err != nil {
231
return nil, err
232
}
233
+
defer rows.Close()
234
235
profileMap := make(map[string]*models.Profile)
236
for rows.Next() {
···
271
if err != nil {
272
return nil, err
273
}
274
+
defer rows.Close()
275
+
276
idxs := make(map[string]int)
277
for did := range profileMap {
278
idxs[did] = 0
···
293
if err != nil {
294
return nil, err
295
}
296
+
defer rows.Close()
297
+
298
idxs = make(map[string]int)
299
for did := range profileMap {
300
idxs[did] = 0
+1
appview/db/registration.go
+1
appview/db/registration.go
+11
-1
appview/db/repos.go
+11
-1
appview/db/repos.go
···
56
limitClause,
57
)
58
rows, err := e.Query(repoQuery, args...)
59
-
60
if err != nil {
61
return nil, fmt.Errorf("failed to execute repo query: %w ", err)
62
}
63
64
for rows.Next() {
65
var repo models.Repo
···
128
if err != nil {
129
return nil, fmt.Errorf("failed to execute labels query: %w ", err)
130
}
131
for rows.Next() {
132
var repoat, labelat string
133
if err := rows.Scan(&repoat, &labelat); err != nil {
···
165
if err != nil {
166
return nil, fmt.Errorf("failed to execute lang query: %w ", err)
167
}
168
for rows.Next() {
169
var repoat, lang string
170
if err := rows.Scan(&repoat, &lang); err != nil {
···
191
if err != nil {
192
return nil, fmt.Errorf("failed to execute star-count query: %w ", err)
193
}
194
for rows.Next() {
195
var repoat string
196
var count int
···
220
if err != nil {
221
return nil, fmt.Errorf("failed to execute issue-count query: %w ", err)
222
}
223
for rows.Next() {
224
var repoat string
225
var open, closed int
···
261
if err != nil {
262
return nil, fmt.Errorf("failed to execute pulls-count query: %w ", err)
263
}
264
for rows.Next() {
265
var repoat string
266
var open, merged, closed, deleted int
···
56
limitClause,
57
)
58
rows, err := e.Query(repoQuery, args...)
59
if err != nil {
60
return nil, fmt.Errorf("failed to execute repo query: %w ", err)
61
}
62
+
defer rows.Close()
63
64
for rows.Next() {
65
var repo models.Repo
···
128
if err != nil {
129
return nil, fmt.Errorf("failed to execute labels query: %w ", err)
130
}
131
+
defer rows.Close()
132
+
133
for rows.Next() {
134
var repoat, labelat string
135
if err := rows.Scan(&repoat, &labelat); err != nil {
···
167
if err != nil {
168
return nil, fmt.Errorf("failed to execute lang query: %w ", err)
169
}
170
+
defer rows.Close()
171
+
172
for rows.Next() {
173
var repoat, lang string
174
if err := rows.Scan(&repoat, &lang); err != nil {
···
195
if err != nil {
196
return nil, fmt.Errorf("failed to execute star-count query: %w ", err)
197
}
198
+
defer rows.Close()
199
+
200
for rows.Next() {
201
var repoat string
202
var count int
···
226
if err != nil {
227
return nil, fmt.Errorf("failed to execute issue-count query: %w ", err)
228
}
229
+
defer rows.Close()
230
+
231
for rows.Next() {
232
var repoat string
233
var open, closed int
···
269
if err != nil {
270
return nil, fmt.Errorf("failed to execute pulls-count query: %w ", err)
271
}
272
+
defer rows.Close()
273
+
274
for rows.Next() {
275
var repoat string
276
var open, merged, closed, deleted int
+1
appview/db/star.go
+1
appview/db/star.go
-1
appview/notify/merged_notifier.go
-1
appview/notify/merged_notifier.go
+6
-1
appview/pages/funcmap.go
+6
-1
appview/pages/funcmap.go
···
25
"github.com/dustin/go-humanize"
26
"github.com/go-enry/go-enry/v2"
27
"github.com/yuin/goldmark"
28
"tangled.org/core/appview/filetree"
29
"tangled.org/core/appview/models"
30
"tangled.org/core/appview/pages/markup"
···
261
},
262
"description": func(text string) template.HTML {
263
p.rctx.RendererType = markup.RendererTypeDefault
264
-
htmlString := p.rctx.RenderMarkdownWith(text, goldmark.New())
265
sanitized := p.rctx.SanitizeDescription(htmlString)
266
return template.HTML(sanitized)
267
},
···
25
"github.com/dustin/go-humanize"
26
"github.com/go-enry/go-enry/v2"
27
"github.com/yuin/goldmark"
28
+
emoji "github.com/yuin/goldmark-emoji"
29
"tangled.org/core/appview/filetree"
30
"tangled.org/core/appview/models"
31
"tangled.org/core/appview/pages/markup"
···
262
},
263
"description": func(text string) template.HTML {
264
p.rctx.RendererType = markup.RendererTypeDefault
265
+
htmlString := p.rctx.RenderMarkdownWith(text, goldmark.New(
266
+
goldmark.WithExtensions(
267
+
emoji.Emoji,
268
+
),
269
+
))
270
sanitized := p.rctx.SanitizeDescription(htmlString)
271
return template.HTML(sanitized)
272
},
+2
appview/pages/markup/markdown.go
+2
appview/pages/markup/markdown.go
···
13
chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
14
"github.com/alecthomas/chroma/v2/styles"
15
"github.com/yuin/goldmark"
16
highlighting "github.com/yuin/goldmark-highlighting/v2"
17
"github.com/yuin/goldmark/ast"
18
"github.com/yuin/goldmark/extension"
···
66
),
67
callout.CalloutExtention,
68
textension.AtExt,
69
),
70
goldmark.WithParserOptions(
71
parser.WithAutoHeadingID(),
···
13
chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
14
"github.com/alecthomas/chroma/v2/styles"
15
"github.com/yuin/goldmark"
16
+
"github.com/yuin/goldmark-emoji"
17
highlighting "github.com/yuin/goldmark-highlighting/v2"
18
"github.com/yuin/goldmark/ast"
19
"github.com/yuin/goldmark/extension"
···
67
),
68
callout.CalloutExtention,
69
textension.AtExt,
70
+
emoji.Emoji,
71
),
72
goldmark.WithParserOptions(
73
parser.WithAutoHeadingID(),
+1
-1
appview/pages/pages.go
+1
-1
appview/pages/pages.go
+5
appview/pages/templates/fragments/starBtn-oob.html
+5
appview/pages/templates/fragments/starBtn-oob.html
+1
-3
appview/pages/templates/fragments/starBtn.html
+1
-3
appview/pages/templates/fragments/starBtn.html
···
1
{{ define "fragments/starBtn" }}
2
<button
3
id="starBtn"
4
class="btn disabled:opacity-50 disabled:cursor-not-allowed flex gap-2 items-center group"
···
10
{{ end }}
11
12
hx-trigger="click"
13
-
hx-target="this"
14
-
hx-swap="outerHTML"
15
-
hx-swap-oob='outerHTML:#starBtn[data-star-subject-at="{{ .SubjectAt }}"]'
16
hx-disabled-elt="#starBtn"
17
>
18
{{ if .IsStarred }}
···
1
{{ define "fragments/starBtn" }}
2
+
{{/* NOTE: this fragment is always replaced with hx-swap-oob */}}
3
<button
4
id="starBtn"
5
class="btn disabled:opacity-50 disabled:cursor-not-allowed flex gap-2 items-center group"
···
11
{{ end }}
12
13
hx-trigger="click"
14
hx-disabled-elt="#starBtn"
15
>
16
{{ if .IsStarred }}
+1
-1
appview/pages/templates/knots/index.html
+1
-1
appview/pages/templates/knots/index.html
+1
-1
appview/pages/templates/repo/empty.html
+1
-1
appview/pages/templates/repo/empty.html
+6
-6
appview/pages/templates/repo/fragments/backlinks.html
+6
-6
appview/pages/templates/repo/fragments/backlinks.html
···
14
<div class="flex gap-2 items-center">
15
{{ if .State.IsClosed }}
16
<span class="text-gray-500 dark:text-gray-400">
17
-
{{ i "ban" "w-4 h-4" }}
18
</span>
19
{{ else if eq .Kind.String "issues" }}
20
<span class="text-green-600 dark:text-green-500">
21
-
{{ i "circle-dot" "w-4 h-4" }}
22
</span>
23
{{ else if .State.IsOpen }}
24
<span class="text-green-600 dark:text-green-500">
25
-
{{ i "git-pull-request" "w-4 h-4" }}
26
</span>
27
{{ else if .State.IsMerged }}
28
<span class="text-purple-600 dark:text-purple-500">
29
-
{{ i "git-merge" "w-4 h-4" }}
30
</span>
31
{{ else }}
32
<span class="text-gray-600 dark:text-gray-300">
33
-
{{ i "git-pull-request-closed" "w-4 h-4" }}
34
</span>
35
{{ end }}
36
-
<a href="{{ . }}"><span class="text-gray-500 dark:text-gray-400">#{{ .SubjectId }}</span> {{ .Title }}</a>
37
</div>
38
{{ if not (eq $.RepoInfo.FullName $repoUrl) }}
39
<div>
···
14
<div class="flex gap-2 items-center">
15
{{ if .State.IsClosed }}
16
<span class="text-gray-500 dark:text-gray-400">
17
+
{{ i "ban" "size-3" }}
18
</span>
19
{{ else if eq .Kind.String "issues" }}
20
<span class="text-green-600 dark:text-green-500">
21
+
{{ i "circle-dot" "size-3" }}
22
</span>
23
{{ else if .State.IsOpen }}
24
<span class="text-green-600 dark:text-green-500">
25
+
{{ i "git-pull-request" "size-3" }}
26
</span>
27
{{ else if .State.IsMerged }}
28
<span class="text-purple-600 dark:text-purple-500">
29
+
{{ i "git-merge" "size-3" }}
30
</span>
31
{{ else }}
32
<span class="text-gray-600 dark:text-gray-300">
33
+
{{ i "git-pull-request-closed" "size-3" }}
34
</span>
35
{{ end }}
36
+
<a href="{{ . }}" class="line-clamp-1 text-sm"><span class="text-gray-500 dark:text-gray-400">#{{ .SubjectId }}</span> {{ .Title }}</a>
37
</div>
38
{{ if not (eq $.RepoInfo.FullName $repoUrl) }}
39
<div>
+1
-1
appview/pages/templates/strings/string.html
+1
-1
appview/pages/templates/strings/string.html
···
17
<span class="select-none">/</span>
18
<a href="/strings/{{ $ownerId }}/{{ .String.Rkey }}" class="font-bold">{{ .String.Filename }}</a>
19
</div>
20
-
<div class="flex gap-2 text-base">
21
{{ if and .LoggedInUser (eq .LoggedInUser.Did .String.Did) }}
22
<a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group"
23
hx-boost="true"
···
17
<span class="select-none">/</span>
18
<a href="/strings/{{ $ownerId }}/{{ .String.Rkey }}" class="font-bold">{{ .String.Filename }}</a>
19
</div>
20
+
<div class="flex gap-2 items-stretch text-base">
21
{{ if and .LoggedInUser (eq .LoggedInUser.Did .String.Did) }}
22
<a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group"
23
hx-boost="true"
+2
-2
appview/pages/templates/user/fragments/followCard.html
+2
-2
appview/pages/templates/user/fragments/followCard.html
···
6
<img class="object-cover rounded-full p-2" src="{{ fullAvatar $userIdent }}" alt="{{ $userIdent }}" />
7
</div>
8
9
-
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-2 w-full">
10
<div class="flex-1 min-h-0 justify-around flex flex-col">
11
<a href="/{{ $userIdent }}">
12
<span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $userIdent | truncateAt30 }}</span>
13
</a>
14
{{ with .Profile }}
15
-
<p class="text-sm pb-2 md:pb-2">{{.Description}}</p>
16
{{ end }}
17
<div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full">
18
<span class="flex-shrink-0">{{ i "users" "size-4" }}</span>
···
6
<img class="object-cover rounded-full p-2" src="{{ fullAvatar $userIdent }}" alt="{{ $userIdent }}" />
7
</div>
8
9
+
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-2 w-full min-w-0">
10
<div class="flex-1 min-h-0 justify-around flex flex-col">
11
<a href="/{{ $userIdent }}">
12
<span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $userIdent | truncateAt30 }}</span>
13
</a>
14
{{ with .Profile }}
15
+
<p class="text-sm pb-2 md:pb-2 break-words">{{.Description}}</p>
16
{{ end }}
17
<div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full">
18
<span class="flex-shrink-0">{{ i "users" "size-4" }}</span>
+8
appview/pulls/pulls.go
+8
appview/pulls/pulls.go
···
1366
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1367
return
1368
}
1369
}
1370
1371
if err = tx.Commit(); err != nil {
1372
log.Println("failed to create pull request", err)
1373
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1374
return
1375
}
1376
1377
ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo)
···
1366
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1367
return
1368
}
1369
+
1370
}
1371
1372
if err = tx.Commit(); err != nil {
1373
log.Println("failed to create pull request", err)
1374
s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1375
return
1376
+
}
1377
+
1378
+
// notify about each pull
1379
+
//
1380
+
// this is performed after tx.Commit, because it could result in a locked DB otherwise
1381
+
for _, p := range stack {
1382
+
s.notifier.NewPull(r.Context(), p)
1383
}
1384
1385
ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo)
+17
appview/state/git_http.go
+17
appview/state/git_http.go
···
25
26
}
27
28
+
func (s *State) UploadArchive(w http.ResponseWriter, r *http.Request) {
29
+
user, ok := r.Context().Value("resolvedId").(identity.Identity)
30
+
if !ok {
31
+
http.Error(w, "failed to resolve user", http.StatusInternalServerError)
32
+
return
33
+
}
34
+
repo := r.Context().Value("repo").(*models.Repo)
35
+
36
+
scheme := "https"
37
+
if s.config.Core.Dev {
38
+
scheme = "http"
39
+
}
40
+
41
+
targetURL := fmt.Sprintf("%s://%s/%s/%s/git-upload-archive?%s", scheme, repo.Knot, user.DID, repo.Name, r.URL.RawQuery)
42
+
s.proxyRequest(w, r, targetURL)
43
+
}
44
+
45
func (s *State) UploadPack(w http.ResponseWriter, r *http.Request) {
46
user, ok := r.Context().Value("resolvedId").(identity.Identity)
47
if !ok {
+1
appview/state/router.go
+1
appview/state/router.go
+9
-9
flake.lock
+9
-9
flake.lock
···
35
"systems": "systems"
36
},
37
"locked": {
38
-
"lastModified": 1694529238,
39
-
"narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=",
40
"owner": "numtide",
41
"repo": "flake-utils",
42
-
"rev": "ff7b65b44d01cf9ba6a71320833626af21126384",
43
"type": "github"
44
},
45
"original": {
···
56
]
57
},
58
"locked": {
59
-
"lastModified": 1754078208,
60
-
"narHash": "sha256-YVoIFDCDpYuU3riaDEJ3xiGdPOtsx4sR5eTzHTytPV8=",
61
"owner": "nix-community",
62
"repo": "gomod2nix",
63
-
"rev": "7f963246a71626c7fc70b431a315c4388a0c95cf",
64
"type": "github"
65
},
66
"original": {
···
150
},
151
"nixpkgs": {
152
"locked": {
153
-
"lastModified": 1765186076,
154
-
"narHash": "sha256-hM20uyap1a0M9d344I692r+ik4gTMyj60cQWO+hAYP8=",
155
"owner": "nixos",
156
"repo": "nixpkgs",
157
-
"rev": "addf7cf5f383a3101ecfba091b98d0a1263dc9b8",
158
"type": "github"
159
},
160
"original": {
···
35
"systems": "systems"
36
},
37
"locked": {
38
+
"lastModified": 1731533236,
39
+
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
40
"owner": "numtide",
41
"repo": "flake-utils",
42
+
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
43
"type": "github"
44
},
45
"original": {
···
56
]
57
},
58
"locked": {
59
+
"lastModified": 1763982521,
60
+
"narHash": "sha256-ur4QIAHwgFc0vXiaxn5No/FuZicxBr2p0gmT54xZkUQ=",
61
"owner": "nix-community",
62
"repo": "gomod2nix",
63
+
"rev": "02e63a239d6eabd595db56852535992c898eba72",
64
"type": "github"
65
},
66
"original": {
···
150
},
151
"nixpkgs": {
152
"locked": {
153
+
"lastModified": 1766070988,
154
+
"narHash": "sha256-G/WVghka6c4bAzMhTwT2vjLccg/awmHkdKSd2JrycLc=",
155
"owner": "nixos",
156
"repo": "nixpkgs",
157
+
"rev": "c6245e83d836d0433170a16eb185cefe0572f8b8",
158
"type": "github"
159
},
160
"original": {
+2
-1
go.mod
+2
-1
go.mod
···
45
github.com/urfave/cli/v3 v3.3.3
46
github.com/whyrusleeping/cbor-gen v0.3.1
47
github.com/yuin/goldmark v1.7.13
48
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
49
gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab
50
golang.org/x/crypto v0.40.0
51
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b
52
golang.org/x/image v0.31.0
53
golang.org/x/net v0.42.0
54
-
golang.org/x/sync v0.17.0
55
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da
56
gopkg.in/yaml.v3 v3.0.1
57
)
···
203
go.uber.org/atomic v1.11.0 // indirect
204
go.uber.org/multierr v1.11.0 // indirect
205
go.uber.org/zap v1.27.0 // indirect
206
golang.org/x/sys v0.34.0 // indirect
207
golang.org/x/text v0.29.0 // indirect
208
golang.org/x/time v0.12.0 // indirect
···
45
github.com/urfave/cli/v3 v3.3.3
46
github.com/whyrusleeping/cbor-gen v0.3.1
47
github.com/yuin/goldmark v1.7.13
48
+
github.com/yuin/goldmark-emoji v1.0.6
49
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
50
gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab
51
golang.org/x/crypto v0.40.0
52
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b
53
golang.org/x/image v0.31.0
54
golang.org/x/net v0.42.0
55
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da
56
gopkg.in/yaml.v3 v3.0.1
57
)
···
203
go.uber.org/atomic v1.11.0 // indirect
204
go.uber.org/multierr v1.11.0 // indirect
205
go.uber.org/zap v1.27.0 // indirect
206
+
golang.org/x/sync v0.17.0 // indirect
207
golang.org/x/sys v0.34.0 // indirect
208
golang.org/x/text v0.29.0 // indirect
209
golang.org/x/time v0.12.0 // indirect
+2
go.sum
+2
go.sum
···
505
github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
506
github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
507
github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
508
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ=
509
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I=
510
gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab h1:gK9tS6QJw5F0SIhYJnGG2P83kuabOdmWBbSmZhJkz2A=
···
505
github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
506
github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
507
github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
508
+
github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
509
+
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
510
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ=
511
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I=
512
gitlab.com/staticnoise/goldmark-callout v0.0.0-20240609120641-6366b799e4ab h1:gK9tS6QJw5F0SIhYJnGG2P83kuabOdmWBbSmZhJkz2A=
+4
-4
hook/hook.go
+4
-4
hook/hook.go
···
48
},
49
Commands: []*cli.Command{
50
{
51
-
Name: "post-recieve",
52
-
Usage: "sends a post-recieve hook to the knot (waits for stdin)",
53
-
Action: postRecieve,
54
},
55
},
56
}
57
}
58
59
-
func postRecieve(ctx context.Context, cmd *cli.Command) error {
60
gitDir := cmd.String("git-dir")
61
userDid := cmd.String("user-did")
62
userHandle := cmd.String("user-handle")
···
48
},
49
Commands: []*cli.Command{
50
{
51
+
Name: "post-receive",
52
+
Usage: "sends a post-receive hook to the knot (waits for stdin)",
53
+
Action: postReceive,
54
},
55
},
56
}
57
}
58
59
+
func postReceive(ctx context.Context, cmd *cli.Command) error {
60
gitDir := cmd.String("git-dir")
61
userDid := cmd.String("user-did")
62
userHandle := cmd.String("user-handle")
+1
-1
hook/setup.go
+1
-1
hook/setup.go
···
138
option_var="GIT_PUSH_OPTION_$i"
139
push_options+=(-push-option "${!option_var}")
140
done
141
-
%s hook -git-dir "$GIT_DIR" -user-did "$GIT_USER_DID" -user-handle "$GIT_USER_HANDLE" -internal-api "%s" "${push_options[@]}" post-recieve
142
`, executablePath, config.internalApi)
143
144
return os.WriteFile(hookPath, []byte(hookContent), 0755)
···
138
option_var="GIT_PUSH_OPTION_$i"
139
push_options+=(-push-option "${!option_var}")
140
done
141
+
%s hook -git-dir "$GIT_DIR" -user-did "$GIT_USER_DID" -user-handle "$GIT_USER_HANDLE" -internal-api "%s" "${push_options[@]}" post-receive
142
`, executablePath, config.internalApi)
143
144
return os.WriteFile(hookPath, []byte(hookContent), 0755)
+24
-7
knotserver/db/db.go
+24
-7
knotserver/db/db.go
···
1
package db
2
3
import (
4
"database/sql"
5
"strings"
6
7
_ "github.com/mattn/go-sqlite3"
8
)
9
10
type DB struct {
11
-
db *sql.DB
12
}
13
14
-
func Setup(dbPath string) (*DB, error) {
15
// https://github.com/mattn/go-sqlite3#connection-string
16
opts := []string{
17
"_foreign_keys=1",
···
20
"_auto_vacuum=incremental",
21
}
22
23
db, err := sql.Open("sqlite3", dbPath+"?"+strings.Join(opts, "&"))
24
if err != nil {
25
return nil, err
26
}
27
28
-
// NOTE: If any other migration is added here, you MUST
29
-
// copy the pattern in appview: use a single sql.Conn
30
-
// for every migration.
31
32
-
_, err = db.Exec(`
33
create table if not exists known_dids (
34
did text primary key
35
);
···
55
created integer not null default (strftime('%s', 'now')),
56
primary key (rkey, nsid)
57
);
58
`)
59
if err != nil {
60
return nil, err
61
}
62
63
-
return &DB{db: db}, nil
64
}
···
1
package db
2
3
import (
4
+
"context"
5
"database/sql"
6
+
"log/slog"
7
"strings"
8
9
_ "github.com/mattn/go-sqlite3"
10
+
"tangled.org/core/log"
11
)
12
13
type DB struct {
14
+
db *sql.DB
15
+
logger *slog.Logger
16
}
17
18
+
func Setup(ctx context.Context, dbPath string) (*DB, error) {
19
// https://github.com/mattn/go-sqlite3#connection-string
20
opts := []string{
21
"_foreign_keys=1",
···
24
"_auto_vacuum=incremental",
25
}
26
27
+
logger := log.FromContext(ctx)
28
+
logger = log.SubLogger(logger, "db")
29
+
30
db, err := sql.Open("sqlite3", dbPath+"?"+strings.Join(opts, "&"))
31
if err != nil {
32
return nil, err
33
}
34
35
+
conn, err := db.Conn(ctx)
36
+
if err != nil {
37
+
return nil, err
38
+
}
39
+
defer conn.Close()
40
41
+
_, err = conn.ExecContext(ctx, `
42
create table if not exists known_dids (
43
did text primary key
44
);
···
64
created integer not null default (strftime('%s', 'now')),
65
primary key (rkey, nsid)
66
);
67
+
68
+
create table if not exists migrations (
69
+
id integer primary key autoincrement,
70
+
name text unique
71
+
);
72
`)
73
if err != nil {
74
return nil, err
75
}
76
77
+
return &DB{
78
+
db: db,
79
+
logger: logger,
80
+
}, nil
81
}
+13
-1
knotserver/git/service/service.go
+13
-1
knotserver/git/service/service.go
···
95
return c.RunService(cmd)
96
}
97
98
+
func (c *ServiceCommand) UploadArchive() error {
99
+
cmd := exec.Command("git", []string{
100
+
"upload-archive",
101
+
".",
102
+
}...)
103
+
104
+
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
105
+
cmd.Env = append(cmd.Env, fmt.Sprintf("GIT_PROTOCOL=%s", c.GitProtocol))
106
+
cmd.Dir = c.Dir
107
+
108
+
return c.RunService(cmd)
109
+
}
110
+
111
func (c *ServiceCommand) UploadPack() error {
112
cmd := exec.Command("git", []string{
113
"upload-pack",
114
"--stateless-rpc",
115
".",
+47
knotserver/git.go
+47
knotserver/git.go
···
56
}
57
}
58
59
+
func (h *Knot) UploadArchive(w http.ResponseWriter, r *http.Request) {
60
+
did := chi.URLParam(r, "did")
61
+
name := chi.URLParam(r, "name")
62
+
repo, err := securejoin.SecureJoin(h.c.Repo.ScanPath, filepath.Join(did, name))
63
+
if err != nil {
64
+
gitError(w, err.Error(), http.StatusInternalServerError)
65
+
h.l.Error("git: failed to secure join repo path", "handler", "UploadPack", "error", err)
66
+
return
67
+
}
68
+
69
+
const expectedContentType = "application/x-git-upload-archive-request"
70
+
contentType := r.Header.Get("Content-Type")
71
+
if contentType != expectedContentType {
72
+
gitError(w, fmt.Sprintf("Expected Content-Type: '%s', but received '%s'.", expectedContentType, contentType), http.StatusUnsupportedMediaType)
73
+
}
74
+
75
+
var bodyReader io.ReadCloser = r.Body
76
+
if r.Header.Get("Content-Encoding") == "gzip" {
77
+
gzipReader, err := gzip.NewReader(r.Body)
78
+
if err != nil {
79
+
gitError(w, err.Error(), http.StatusInternalServerError)
80
+
h.l.Error("git: failed to create gzip reader", "handler", "UploadArchive", "error", err)
81
+
return
82
+
}
83
+
defer gzipReader.Close()
84
+
bodyReader = gzipReader
85
+
}
86
+
87
+
w.Header().Set("Content-Type", "application/x-git-upload-archive-result")
88
+
89
+
h.l.Info("git: executing git-upload-archive", "handler", "UploadArchive", "repo", repo)
90
+
91
+
cmd := service.ServiceCommand{
92
+
GitProtocol: r.Header.Get("Git-Protocol"),
93
+
Dir: repo,
94
+
Stdout: w,
95
+
Stdin: bodyReader,
96
+
}
97
+
98
+
w.WriteHeader(http.StatusOK)
99
+
100
+
if err := cmd.UploadArchive(); err != nil {
101
+
h.l.Error("git: failed to execute git-upload-pack", "handler", "UploadPack", "error", err)
102
+
return
103
+
}
104
+
}
105
+
106
func (h *Knot) UploadPack(w http.ResponseWriter, r *http.Request) {
107
did := chi.URLParam(r, "did")
108
name := chi.URLParam(r, "name")
+1
knotserver/router.go
+1
knotserver/router.go
+1
-1
knotserver/server.go
+1
-1
knotserver/server.go
+3
nix/gomod2nix.toml
+3
nix/gomod2nix.toml
···
530
[mod."github.com/yuin/goldmark"]
531
version = "v1.7.13"
532
hash = "sha256-vBCxZrPYPc8x/nvAAv3Au59dCCyfS80Vw3/a9EXK7TE="
533
+
[mod."github.com/yuin/goldmark-emoji"]
534
+
version = "v1.0.6"
535
+
hash = "sha256-+d6bZzOPE+JSFsZbQNZMCWE+n3jgcQnkPETVk47mxSY="
536
[mod."github.com/yuin/goldmark-highlighting/v2"]
537
version = "v2.0.0-20230729083705-37449abec8cc"
538
hash = "sha256-HpiwU7jIeDUAg2zOpTIiviQir8dpRPuXYh2nqFFccpg="
+1
spindle/db/repos.go
+1
spindle/db/repos.go
+22
-21
spindle/engine/engine.go
+22
-21
spindle/engine/engine.go
···
3
import (
4
"context"
5
"errors"
6
-
"fmt"
7
"log/slog"
8
9
securejoin "github.com/cyphar/filepath-securejoin"
10
-
"golang.org/x/sync/errgroup"
11
"tangled.org/core/notifier"
12
"tangled.org/core/spindle/config"
13
"tangled.org/core/spindle/db"
···
31
}
32
}
33
34
-
eg, ctx := errgroup.WithContext(ctx)
35
for eng, wfs := range pipeline.Workflows {
36
workflowTimeout := eng.WorkflowTimeout()
37
l.Info("using workflow timeout", "timeout", workflowTimeout)
38
39
for _, w := range wfs {
40
-
eg.Go(func() error {
41
wid := models.WorkflowId{
42
PipelineId: pipelineId,
43
Name: w.Name,
···
45
46
err := db.StatusRunning(wid, n)
47
if err != nil {
48
-
return err
49
}
50
51
err = eng.SetupWorkflow(ctx, wid, &w)
···
61
62
dbErr := db.StatusFailed(wid, err.Error(), -1, n)
63
if dbErr != nil {
64
-
return dbErr
65
}
66
-
return err
67
}
68
defer eng.DestroyWorkflow(ctx, wid)
69
70
-
wfLogger, err := models.NewWorkflowLogger(cfg.Server.LogDir, wid)
71
if err != nil {
72
l.Warn("failed to setup step logger; logs will not be persisted", "error", err)
73
wfLogger = nil
···
99
if errors.Is(err, ErrTimedOut) {
100
dbErr := db.StatusTimeout(wid, n)
101
if dbErr != nil {
102
-
return dbErr
103
}
104
} else {
105
dbErr := db.StatusFailed(wid, err.Error(), -1, n)
106
if dbErr != nil {
107
-
return dbErr
108
}
109
}
110
-
111
-
return fmt.Errorf("starting steps image: %w", err)
112
}
113
}
114
115
err = db.StatusSuccess(wid, n)
116
if err != nil {
117
-
return err
118
}
119
-
120
-
return nil
121
-
})
122
}
123
}
124
125
-
if err := eg.Wait(); err != nil {
126
-
l.Error("failed to run one or more workflows", "err", err)
127
-
} else {
128
-
l.Info("successfully ran full pipeline")
129
-
}
130
}
···
3
import (
4
"context"
5
"errors"
6
"log/slog"
7
+
"sync"
8
9
securejoin "github.com/cyphar/filepath-securejoin"
10
"tangled.org/core/notifier"
11
"tangled.org/core/spindle/config"
12
"tangled.org/core/spindle/db"
···
30
}
31
}
32
33
+
var wg sync.WaitGroup
34
for eng, wfs := range pipeline.Workflows {
35
workflowTimeout := eng.WorkflowTimeout()
36
l.Info("using workflow timeout", "timeout", workflowTimeout)
37
38
for _, w := range wfs {
39
+
wg.Add(1)
40
+
go func() {
41
+
defer wg.Done()
42
+
43
wid := models.WorkflowId{
44
PipelineId: pipelineId,
45
Name: w.Name,
···
47
48
err := db.StatusRunning(wid, n)
49
if err != nil {
50
+
l.Error("failed to set workflow status to running", "wid", wid, "err", err)
51
+
return
52
}
53
54
err = eng.SetupWorkflow(ctx, wid, &w)
···
64
65
dbErr := db.StatusFailed(wid, err.Error(), -1, n)
66
if dbErr != nil {
67
+
l.Error("failed to set workflow status to failed", "wid", wid, "err", dbErr)
68
}
69
+
return
70
}
71
defer eng.DestroyWorkflow(ctx, wid)
72
73
+
secretValues := make([]string, len(allSecrets))
74
+
for i, s := range allSecrets {
75
+
secretValues[i] = s.Value
76
+
}
77
+
wfLogger, err := models.NewWorkflowLogger(cfg.Server.LogDir, wid, secretValues)
78
if err != nil {
79
l.Warn("failed to setup step logger; logs will not be persisted", "error", err)
80
wfLogger = nil
···
106
if errors.Is(err, ErrTimedOut) {
107
dbErr := db.StatusTimeout(wid, n)
108
if dbErr != nil {
109
+
l.Error("failed to set workflow status to timeout", "wid", wid, "err", dbErr)
110
}
111
} else {
112
dbErr := db.StatusFailed(wid, err.Error(), -1, n)
113
if dbErr != nil {
114
+
l.Error("failed to set workflow status to failed", "wid", wid, "err", dbErr)
115
}
116
}
117
+
return
118
}
119
}
120
121
err = db.StatusSuccess(wid, n)
122
if err != nil {
123
+
l.Error("failed to set workflow status to success", "wid", wid, "err", err)
124
}
125
+
}()
126
}
127
}
128
129
+
wg.Wait()
130
+
l.Info("all workflows completed")
131
}
+6
-1
spindle/models/logger.go
+6
-1
spindle/models/logger.go
···
12
type WorkflowLogger struct {
13
file *os.File
14
encoder *json.Encoder
15
}
16
17
-
func NewWorkflowLogger(baseDir string, wid WorkflowId) (*WorkflowLogger, error) {
18
path := LogFilePath(baseDir, wid)
19
20
file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
···
25
return &WorkflowLogger{
26
file: file,
27
encoder: json.NewEncoder(file),
28
}, nil
29
}
30
···
62
63
func (w *dataWriter) Write(p []byte) (int, error) {
64
line := strings.TrimRight(string(p), "\r\n")
65
entry := NewDataLogLine(w.idx, line, w.stream)
66
if err := w.logger.encoder.Encode(entry); err != nil {
67
return 0, err
···
12
type WorkflowLogger struct {
13
file *os.File
14
encoder *json.Encoder
15
+
mask *SecretMask
16
}
17
18
+
func NewWorkflowLogger(baseDir string, wid WorkflowId, secretValues []string) (*WorkflowLogger, error) {
19
path := LogFilePath(baseDir, wid)
20
21
file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
···
26
return &WorkflowLogger{
27
file: file,
28
encoder: json.NewEncoder(file),
29
+
mask: NewSecretMask(secretValues),
30
}, nil
31
}
32
···
64
65
func (w *dataWriter) Write(p []byte) (int, error) {
66
line := strings.TrimRight(string(p), "\r\n")
67
+
if w.logger.mask != nil {
68
+
line = w.logger.mask.Mask(line)
69
+
}
70
entry := NewDataLogLine(w.idx, line, w.stream)
71
if err := w.logger.encoder.Encode(entry); err != nil {
72
return 0, err
+51
spindle/models/secret_mask.go
+51
spindle/models/secret_mask.go
···
···
1
+
package models
2
+
3
+
import (
4
+
"encoding/base64"
5
+
"strings"
6
+
)
7
+
8
+
// SecretMask replaces secret values in strings with "***".
9
+
type SecretMask struct {
10
+
replacer *strings.Replacer
11
+
}
12
+
13
+
// NewSecretMask creates a mask for the given secret values.
14
+
// Also registers base64-encoded variants of each secret.
15
+
func NewSecretMask(values []string) *SecretMask {
16
+
var pairs []string
17
+
18
+
for _, value := range values {
19
+
if value == "" {
20
+
continue
21
+
}
22
+
23
+
pairs = append(pairs, value, "***")
24
+
25
+
b64 := base64.StdEncoding.EncodeToString([]byte(value))
26
+
if b64 != value {
27
+
pairs = append(pairs, b64, "***")
28
+
}
29
+
30
+
b64NoPad := strings.TrimRight(b64, "=")
31
+
if b64NoPad != b64 && b64NoPad != value {
32
+
pairs = append(pairs, b64NoPad, "***")
33
+
}
34
+
}
35
+
36
+
if len(pairs) == 0 {
37
+
return nil
38
+
}
39
+
40
+
return &SecretMask{
41
+
replacer: strings.NewReplacer(pairs...),
42
+
}
43
+
}
44
+
45
+
// Mask replaces all registered secret values with "***".
46
+
func (m *SecretMask) Mask(input string) string {
47
+
if m == nil || m.replacer == nil {
48
+
return input
49
+
}
50
+
return m.replacer.Replace(input)
51
+
}
+135
spindle/models/secret_mask_test.go
+135
spindle/models/secret_mask_test.go
···
···
1
+
package models
2
+
3
+
import (
4
+
"encoding/base64"
5
+
"testing"
6
+
)
7
+
8
+
func TestSecretMask_BasicMasking(t *testing.T) {
9
+
mask := NewSecretMask([]string{"mysecret123"})
10
+
11
+
input := "The password is mysecret123 in this log"
12
+
expected := "The password is *** in this log"
13
+
14
+
result := mask.Mask(input)
15
+
if result != expected {
16
+
t.Errorf("expected %q, got %q", expected, result)
17
+
}
18
+
}
19
+
20
+
func TestSecretMask_Base64Encoded(t *testing.T) {
21
+
secret := "mysecret123"
22
+
mask := NewSecretMask([]string{secret})
23
+
24
+
b64 := base64.StdEncoding.EncodeToString([]byte(secret))
25
+
input := "Encoded: " + b64
26
+
expected := "Encoded: ***"
27
+
28
+
result := mask.Mask(input)
29
+
if result != expected {
30
+
t.Errorf("expected %q, got %q", expected, result)
31
+
}
32
+
}
33
+
34
+
func TestSecretMask_Base64NoPadding(t *testing.T) {
35
+
// "test" encodes to "dGVzdA==" with padding
36
+
secret := "test"
37
+
mask := NewSecretMask([]string{secret})
38
+
39
+
b64NoPad := "dGVzdA" // base64 without padding
40
+
input := "Token: " + b64NoPad
41
+
expected := "Token: ***"
42
+
43
+
result := mask.Mask(input)
44
+
if result != expected {
45
+
t.Errorf("expected %q, got %q", expected, result)
46
+
}
47
+
}
48
+
49
+
func TestSecretMask_MultipleSecrets(t *testing.T) {
50
+
mask := NewSecretMask([]string{"password1", "apikey123"})
51
+
52
+
input := "Using password1 and apikey123 for auth"
53
+
expected := "Using *** and *** for auth"
54
+
55
+
result := mask.Mask(input)
56
+
if result != expected {
57
+
t.Errorf("expected %q, got %q", expected, result)
58
+
}
59
+
}
60
+
61
+
func TestSecretMask_MultipleOccurrences(t *testing.T) {
62
+
mask := NewSecretMask([]string{"secret"})
63
+
64
+
input := "secret appears twice: secret"
65
+
expected := "*** appears twice: ***"
66
+
67
+
result := mask.Mask(input)
68
+
if result != expected {
69
+
t.Errorf("expected %q, got %q", expected, result)
70
+
}
71
+
}
72
+
73
+
func TestSecretMask_ShortValues(t *testing.T) {
74
+
mask := NewSecretMask([]string{"abc", "xy", ""})
75
+
76
+
if mask == nil {
77
+
t.Fatal("expected non-nil mask")
78
+
}
79
+
80
+
input := "abc xy test"
81
+
expected := "*** *** test"
82
+
result := mask.Mask(input)
83
+
if result != expected {
84
+
t.Errorf("expected %q, got %q", expected, result)
85
+
}
86
+
}
87
+
88
+
func TestSecretMask_NilMask(t *testing.T) {
89
+
var mask *SecretMask
90
+
91
+
input := "some input text"
92
+
result := mask.Mask(input)
93
+
if result != input {
94
+
t.Errorf("expected %q, got %q", input, result)
95
+
}
96
+
}
97
+
98
+
func TestSecretMask_EmptyInput(t *testing.T) {
99
+
mask := NewSecretMask([]string{"secret"})
100
+
101
+
result := mask.Mask("")
102
+
if result != "" {
103
+
t.Errorf("expected empty string, got %q", result)
104
+
}
105
+
}
106
+
107
+
func TestSecretMask_NoMatch(t *testing.T) {
108
+
mask := NewSecretMask([]string{"secretvalue"})
109
+
110
+
input := "nothing to mask here"
111
+
result := mask.Mask(input)
112
+
if result != input {
113
+
t.Errorf("expected %q, got %q", input, result)
114
+
}
115
+
}
116
+
117
+
func TestSecretMask_EmptySecretsList(t *testing.T) {
118
+
mask := NewSecretMask([]string{})
119
+
120
+
if mask != nil {
121
+
t.Error("expected nil mask for empty secrets list")
122
+
}
123
+
}
124
+
125
+
func TestSecretMask_EmptySecretsFiltered(t *testing.T) {
126
+
mask := NewSecretMask([]string{"ab", "validpassword", "", "xyz"})
127
+
128
+
input := "Using validpassword here"
129
+
expected := "Using *** here"
130
+
131
+
result := mask.Mask(input)
132
+
if result != expected {
133
+
t.Errorf("expected %q, got %q", expected, result)
134
+
}
135
+
}