+1
-1
.air/appview.toml
+1
-1
.air/appview.toml
+3
.gitignore
+3
.gitignore
+1
-1
appview/db/follow.go
+1
-1
appview/db/follow.go
-62
appview/db/migrations/20250305_113405.sql
-62
appview/db/migrations/20250305_113405.sql
···
1
-
-- Simplified SQLite Database Migration Script for Issues and Comments
2
-
3
-
-- Migration for issues table
4
-
CREATE TABLE issues_new (
5
-
id integer primary key autoincrement,
6
-
owner_did text not null,
7
-
repo_at text not null,
8
-
issue_id integer not null,
9
-
title text not null,
10
-
body text not null,
11
-
open integer not null default 1,
12
-
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
13
-
issue_at text,
14
-
unique(repo_at, issue_id),
15
-
foreign key (repo_at) references repos(at_uri) on delete cascade
16
-
);
17
-
18
-
-- Migrate data to new issues table
19
-
INSERT INTO issues_new (
20
-
id, owner_did, repo_at, issue_id,
21
-
title, body, open, created, issue_at
22
-
)
23
-
SELECT
24
-
id, owner_did, repo_at, issue_id,
25
-
title, body, open, created, issue_at
26
-
FROM issues;
27
-
28
-
-- Drop old issues table
29
-
DROP TABLE issues;
30
-
31
-
-- Rename new issues table
32
-
ALTER TABLE issues_new RENAME TO issues;
33
-
34
-
-- Migration for comments table
35
-
CREATE TABLE comments_new (
36
-
id integer primary key autoincrement,
37
-
owner_did text not null,
38
-
issue_id integer not null,
39
-
repo_at text not null,
40
-
comment_id integer not null,
41
-
comment_at text not null,
42
-
body text not null,
43
-
created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
44
-
unique(issue_id, comment_id),
45
-
foreign key (repo_at, issue_id) references issues(repo_at, issue_id) on delete cascade
46
-
);
47
-
48
-
-- Migrate data to new comments table
49
-
INSERT INTO comments_new (
50
-
id, owner_did, issue_id, repo_at,
51
-
comment_id, comment_at, body, created
52
-
)
53
-
SELECT
54
-
id, owner_did, issue_id, repo_at,
55
-
comment_id, comment_at, body, created
56
-
FROM comments;
57
-
58
-
-- Drop old comments table
59
-
DROP TABLE comments;
60
-
61
-
-- Rename new comments table
62
-
ALTER TABLE comments_new RENAME TO comments;
-66
appview/db/migrations/validate.sql
-66
appview/db/migrations/validate.sql
···
1
-
-- Validation Queries for Database Migration
2
-
3
-
-- 1. Verify Issues Table Structure
4
-
PRAGMA table_info(issues);
5
-
6
-
-- 2. Verify Comments Table Structure
7
-
PRAGMA table_info(comments);
8
-
9
-
-- 3. Check Total Row Count Consistency
10
-
SELECT
11
-
'Issues Row Count' AS check_type,
12
-
(SELECT COUNT(*) FROM issues) AS row_count
13
-
UNION ALL
14
-
SELECT
15
-
'Comments Row Count' AS check_type,
16
-
(SELECT COUNT(*) FROM comments) AS row_count;
17
-
18
-
-- 4. Verify Unique Constraint on Issues
19
-
SELECT
20
-
repo_at,
21
-
issue_id,
22
-
COUNT(*) as duplicate_count
23
-
FROM issues
24
-
GROUP BY repo_at, issue_id
25
-
HAVING duplicate_count > 1;
26
-
27
-
-- 5. Verify Foreign Key Integrity for Comments
28
-
SELECT
29
-
'Orphaned Comments' AS check_type,
30
-
COUNT(*) AS orphaned_count
31
-
FROM comments c
32
-
LEFT JOIN issues i ON c.repo_at = i.repo_at AND c.issue_id = i.issue_id
33
-
WHERE i.id IS NULL;
34
-
35
-
-- 6. Check Foreign Key Constraint
36
-
PRAGMA foreign_key_list(comments);
37
-
38
-
-- 7. Sample Data Integrity Check
39
-
SELECT
40
-
'Sample Issues' AS check_type,
41
-
repo_at,
42
-
issue_id,
43
-
title,
44
-
created
45
-
FROM issues
46
-
LIMIT 5;
47
-
48
-
-- 8. Sample Comments Data Integrity Check
49
-
SELECT
50
-
'Sample Comments' AS check_type,
51
-
repo_at,
52
-
issue_id,
53
-
comment_id,
54
-
body,
55
-
created
56
-
FROM comments
57
-
LIMIT 5;
58
-
59
-
-- 9. Verify Constraint on Comments (Issue ID and Comment ID Uniqueness)
60
-
SELECT
61
-
issue_id,
62
-
comment_id,
63
-
COUNT(*) as duplicate_count
64
-
FROM comments
65
-
GROUP BY issue_id, comment_id
66
-
HAVING duplicate_count > 1;
+2
-2
appview/db/repos.go
+2
-2
appview/db/repos.go
···
391
391
var description, spindle sql.NullString
392
392
393
393
row := e.QueryRow(`
394
-
select did, name, knot, created, at_uri, description, spindle
394
+
select did, name, knot, created, at_uri, description, spindle
395
395
from repos
396
396
where did = ? and name = ?
397
397
`,
···
556
556
return err
557
557
}
558
558
559
-
func UpdateSpindle(e Execer, repoAt, spindle string) error {
559
+
func UpdateSpindle(e Execer, repoAt string, spindle *string) error {
560
560
_, err := e.Exec(
561
561
`update repos set spindle = ? where at_uri = ?`, spindle, repoAt)
562
562
return err
+1
-1
appview/db/timeline.go
+1
-1
appview/db/timeline.go
+1
-1
appview/middleware/middleware.go
+1
-1
appview/middleware/middleware.go
···
183
183
id, err := mw.idResolver.ResolveIdent(req.Context(), didOrHandle)
184
184
if err != nil {
185
185
// invalid did or handle
186
-
log.Println("failed to resolve did/handle:", err)
186
+
log.Printf("failed to resolve did/handle '%s': %s\n", didOrHandle, err)
187
187
mw.pages.Error404(w)
188
188
return
189
189
}
+3
appview/pages/funcmap.go
+3
appview/pages/funcmap.go
+13
-2
appview/pages/templates/layouts/topbar.html
+13
-2
appview/pages/templates/layouts/topbar.html
···
24
24
{{ end }}
25
25
26
26
{{ define "newButton" }}
27
-
<details class="relative inline-block text-left">
27
+
<details class="relative inline-block text-left nav-dropdown">
28
28
<summary class="btn-create py-0 cursor-pointer list-none flex items-center gap-2">
29
29
{{ i "plus" "w-4 h-4" }} new
30
30
</summary>
···
42
42
{{ end }}
43
43
44
44
{{ define "dropDown" }}
45
-
<details class="relative inline-block text-left">
45
+
<details class="relative inline-block text-left nav-dropdown">
46
46
<summary
47
47
class="cursor-pointer list-none flex items-center"
48
48
>
···
66
66
</a>
67
67
</div>
68
68
</details>
69
+
70
+
<script>
71
+
document.addEventListener('click', function(event) {
72
+
const dropdowns = document.querySelectorAll('.nav-dropdown');
73
+
dropdowns.forEach(function(dropdown) {
74
+
if (!dropdown.contains(event.target)) {
75
+
dropdown.removeAttribute('open');
76
+
}
77
+
});
78
+
});
79
+
</script>
69
80
{{ end }}
+5
-3
appview/pages/templates/repo/empty.html
+5
-3
appview/pages/templates/repo/empty.html
···
32
32
<div class="py-6 w-fit flex flex-col gap-4">
33
33
<p>This is an empty repository. To get started:</p>
34
34
{{ $bullet := "mx-2 text-xs bg-gray-200 dark:bg-gray-600 rounded-full size-5 flex items-center justify-center font-mono inline-flex align-middle" }}
35
-
<p><span class="{{$bullet}}">1</span>Add a public key to your account from the <a href="/settings" class="underline">settings</a> page</p>
36
-
<p><span class="{{$bullet}}">2</span>Configure your remote to <span class="font-mono p-1 rounded bg-gray-100 dark:bg-gray-700 ">git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}<span></p>
37
-
<p><span class="{{$bullet}}">3</span>Push!</p>
35
+
36
+
<p><span class="{{$bullet}}">1</span>First, generate a new <a href="https://git-scm.com/book/en/v2/Git-on-the-Server-Generating-Your-SSH-Public-Key" class="underline">SSH key pair</a>.</p>
37
+
<p><span class="{{$bullet}}">2</span>Then add the public key to your account from the <a href="/settings" class="underline">settings</a> page.</p>
38
+
<p><span class="{{$bullet}}">3</span>Configure your remote to <code>git@{{ $knot }}:{{ .RepoInfo.OwnerHandle }}/{{ .RepoInfo.Name }}</code></p>
39
+
<p><span class="{{$bullet}}">4</span>Push!</p>
38
40
</div>
39
41
</div>
40
42
{{ else }}
+5
-1
appview/pages/templates/repo/pipelines/workflow.html
+5
-1
appview/pages/templates/repo/pipelines/workflow.html
···
19
19
20
20
{{ define "sidebar" }}
21
21
{{ $active := .Workflow }}
22
+
23
+
{{ $activeTab := "bg-white dark:bg-gray-700 drop-shadow-sm" }}
24
+
{{ $inactiveTab := "bg-gray-100 dark:bg-gray-800" }}
25
+
22
26
{{ with .Pipeline }}
23
27
{{ $id := .Id }}
24
28
<div class="sticky top-2 grid grid-cols-1 rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700">
25
29
{{ range $name, $all := .Statuses }}
26
30
<a href="/{{ $.RepoInfo.FullName }}/pipelines/{{ $id }}/workflow/{{ $name }}" class="no-underline hover:no-underline hover:bg-gray-100/25 hover:dark:bg-gray-700/25">
27
31
<div
28
-
class="flex gap-2 items-center justify-between p-2 {{ if eq $name $active }}bg-gray-100/50 dark:bg-gray-700/50{{ end }}">
32
+
class="flex gap-2 items-center justify-between p-2 {{ if eq $name $active }} {{ $activeTab }} {{ else }} {{ $inactiveTab }} {{ end }}">
29
33
{{ $lastStatus := $all.Latest }}
30
34
{{ $kind := $lastStatus.Status.String }}
31
35
+9
-4
appview/pages/templates/repo/settings/pipelines.html
+9
-4
appview/pages/templates/repo/settings/pipelines.html
···
34
34
{{ else }}
35
35
<form hx-post="/{{ $.RepoInfo.FullName }}/settings/spindle" class="col-span-1 md:col-span-1 md:justify-self-end group flex gap-2 items-stretch">
36
36
<select
37
-
id="spindle"
37
+
id="spindle"
38
38
name="spindle"
39
-
required
39
+
required
40
40
class="p-1 max-w-64 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700">
41
-
<option value="" disabled>
41
+
{{/* For some reason, we can't use an empty string in a <select> in all scenarios unless it is preceded by a disabled select?? No idea, could just be a Firefox thing? */}}
42
+
<option value="[[none]]" class="py-1" {{ if not $.CurrentSpindle }}selected{{ end }}>
43
+
{{ if not $.CurrentSpindle }}
42
44
Choose a spindle
45
+
{{ else }}
46
+
Disable pipelines
47
+
{{ end }}
43
48
</option>
44
49
{{ range $.Spindles }}
45
50
<option value="{{ . }}" class="py-1" {{ if eq . $.CurrentSpindle }}selected{{ end }}>
···
82
87
{{ end }}
83
88
84
89
{{ define "addSecretButton" }}
85
-
<button
90
+
<button
86
91
class="btn flex items-center gap-2"
87
92
popovertarget="add-secret-modal"
88
93
popovertargetaction="toggle">
-168
appview/pages/templates/repo/settings.html
-168
appview/pages/templates/repo/settings.html
···
1
-
{{ define "title" }}settings · {{ .RepoInfo.FullName }}{{ end }}
2
-
3
-
{{ define "repoContent" }}
4
-
{{ template "collaboratorSettings" . }}
5
-
{{ template "branchSettings" . }}
6
-
{{ template "dangerZone" . }}
7
-
{{ template "spindleSelector" . }}
8
-
{{ template "spindleSecrets" . }}
9
-
{{ end }}
10
-
11
-
{{ define "collaboratorSettings" }}
12
-
<header class="font-bold text-sm mb-4 uppercase dark:text-white">
13
-
Collaborators
14
-
</header>
15
-
16
-
<div id="collaborator-list" class="flex flex-col gap-2 mb-2">
17
-
{{ range .Collaborators }}
18
-
<div id="collaborator" class="mb-2">
19
-
<a
20
-
href="/{{ didOrHandle .Did .Handle }}"
21
-
class="no-underline hover:underline text-black dark:text-white"
22
-
>
23
-
{{ didOrHandle .Did .Handle }}
24
-
</a>
25
-
<div>
26
-
<span class="text-sm text-gray-500 dark:text-gray-400">
27
-
{{ .Role }}
28
-
</span>
29
-
</div>
30
-
</div>
31
-
{{ end }}
32
-
</div>
33
-
34
-
{{ if .RepoInfo.Roles.CollaboratorInviteAllowed }}
35
-
<form
36
-
hx-put="/{{ $.RepoInfo.FullName }}/settings/collaborator"
37
-
class="group"
38
-
>
39
-
<label for="collaborator" class="dark:text-white">
40
-
add collaborator
41
-
</label>
42
-
<input
43
-
type="text"
44
-
id="collaborator"
45
-
name="collaborator"
46
-
required
47
-
class="dark:bg-gray-700 dark:text-white"
48
-
placeholder="enter did or handle">
49
-
<button class="btn my-2 flex gap-2 items-center dark:text-white dark:hover:bg-gray-700" type="text">
50
-
<span>add</span>
51
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
52
-
</button>
53
-
</form>
54
-
{{ end }}
55
-
{{ end }}
56
-
57
-
{{ define "dangerZone" }}
58
-
{{ if .RepoInfo.Roles.RepoDeleteAllowed }}
59
-
<form
60
-
hx-confirm="Are you sure you want to delete this repository?"
61
-
hx-delete="/{{ $.RepoInfo.FullName }}/settings/delete"
62
-
class="mt-6"
63
-
hx-indicator="#delete-repo-spinner">
64
-
<label for="branch">delete repository</label>
65
-
<button class="btn my-2 flex items-center" type="text">
66
-
<span>delete</span>
67
-
<span id="delete-repo-spinner" class="group">
68
-
{{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
69
-
</span>
70
-
</button>
71
-
<span>
72
-
Deleting a repository is irreversible and permanent.
73
-
</span>
74
-
</form>
75
-
{{ end }}
76
-
{{ end }}
77
-
78
-
{{ define "branchSettings" }}
79
-
<form hx-put="/{{ $.RepoInfo.FullName }}/settings/branches/default" class="mt-6 group">
80
-
<label for="branch">default branch</label>
81
-
<div class="flex gap-2 items-center">
82
-
<select id="branch" name="branch" required class="p-1 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700">
83
-
<option value="" disabled selected >
84
-
Choose a default branch
85
-
</option>
86
-
{{ range .Branches }}
87
-
<option value="{{ .Name }}" class="py-1" {{ if .IsDefault }}selected{{ end }} >
88
-
{{ .Name }}
89
-
</option>
90
-
{{ end }}
91
-
</select>
92
-
<button class="btn my-2 flex gap-2 items-center" type="submit">
93
-
<span>save</span>
94
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
95
-
</button>
96
-
</div>
97
-
</form>
98
-
{{ end }}
99
-
100
-
{{ define "spindleSelector" }}
101
-
{{ if .RepoInfo.Roles.IsOwner }}
102
-
<form hx-post="/{{ $.RepoInfo.FullName }}/settings/spindle" class="mt-6 group" >
103
-
<label for="spindle">spindle</label>
104
-
<div class="flex gap-2 items-center">
105
-
<select id="spindle" name="spindle" required class="p-1 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700">
106
-
<option value="" selected >
107
-
None
108
-
</option>
109
-
{{ range .Spindles }}
110
-
<option value="{{ . }}" class="py-1" {{ if eq . $.CurrentSpindle }}selected{{ end }}>
111
-
{{ . }}
112
-
</option>
113
-
{{ end }}
114
-
</select>
115
-
<button class="btn my-2 flex gap-2 items-center" type="submit">
116
-
<span>save</span>
117
-
{{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
118
-
</button>
119
-
</div>
120
-
</form>
121
-
{{ end }}
122
-
{{ end }}
123
-
124
-
{{ define "spindleSecrets" }}
125
-
{{ if $.CurrentSpindle }}
126
-
<header class="font-bold text-sm mb-4 uppercase dark:text-white">
127
-
Secrets
128
-
</header>
129
-
130
-
<div id="secret-list" class="flex flex-col gap-2 mb-2">
131
-
{{ range $idx, $secret := .Secrets }}
132
-
{{ with $secret }}
133
-
<div id="secret-{{$idx}}" class="mb-2">
134
-
{{ .Key }} created on {{ .CreatedAt }} by {{ .CreatedBy }}
135
-
</div>
136
-
{{ end }}
137
-
{{ end }}
138
-
</div>
139
-
<form
140
-
hx-put="/{{ $.RepoInfo.FullName }}/settings/secrets"
141
-
class="mt-6"
142
-
hx-indicator="#add-secret-spinner">
143
-
<label for="key">secret key</label>
144
-
<input
145
-
type="text"
146
-
id="key"
147
-
name="key"
148
-
required
149
-
class="dark:bg-gray-700 dark:text-white"
150
-
placeholder="SECRET_KEY" />
151
-
<label for="value">secret value</label>
152
-
<input
153
-
type="text"
154
-
id="value"
155
-
name="value"
156
-
required
157
-
class="dark:bg-gray-700 dark:text-white"
158
-
placeholder="SECRET VALUE" />
159
-
160
-
<button class="btn my-2 flex items-center" type="text">
161
-
<span>add</span>
162
-
<span id="add-secret-spinner" class="group">
163
-
{{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }}
164
-
</span>
165
-
</button>
166
-
</form>
167
-
{{ end }}
168
-
{{ end }}
+3
-2
appview/pages/templates/repo/tree.html
+3
-2
appview/pages/templates/repo/tree.html
···
61
61
62
62
{{ if .IsFile }}
63
63
{{ $icon = "file" }}
64
-
{{ $iconStyle = "size-4" }}
64
+
{{ $iconStyle = "flex-shrink-0 size-4" }}
65
65
{{ end }}
66
66
<a href="{{ $link }}" class="{{ $linkstyle }}">
67
67
<div class="flex items-center gap-2">
68
-
{{ i $icon $iconStyle }}{{ .Name }}
68
+
{{ i $icon $iconStyle }}
69
+
<span class="truncate">{{ .Name }}</span>
69
70
</div>
70
71
</a>
71
72
</div>
+13
-10
appview/pages/templates/strings/string.html
+13
-10
appview/pages/templates/strings/string.html
···
19
19
<div>
20
20
<a href="/strings/{{ $ownerId }}">{{ $ownerId }}</a>
21
21
<span class="select-none">/</span>
22
-
<a href="/{{ $ownerId }}/{{ .String.Rkey }}" class="font-bold">{{ .String.Filename }}</a>
22
+
<a href="/strings/{{ $ownerId }}/{{ .String.Rkey }}" class="font-bold">{{ .String.Filename }}</a>
23
23
</div>
24
24
{{ if and .LoggedInUser (eq .LoggedInUser.Did .String.Did) }}
25
25
<div class="flex gap-2 text-base">
···
44
44
</div>
45
45
{{ end }}
46
46
</div>
47
-
<span class="flex items-center">
47
+
<span>
48
48
{{ with .String.Description }}
49
49
{{ . }}
50
-
<span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span>
51
-
{{ end }}
52
-
53
-
{{ with .String.Edited }}
54
-
<span>edited {{ template "repo/fragments/shortTimeAgo" . }}</span>
55
-
{{ else }}
56
-
{{ template "repo/fragments/shortTimeAgo" .String.Created }}
57
50
{{ end }}
58
51
</span>
59
52
</section>
60
53
<section class="bg-white dark:bg-gray-800 px-6 py-4 rounded relative w-full dark:text-white">
61
54
<div class="flex justify-between items-center text-gray-500 dark:text-gray-400 text-sm md:text-base pb-2 mb-3 text-base border-b border-gray-200 dark:border-gray-700">
62
-
<span>{{ .String.Filename }}</span>
55
+
<span>
56
+
{{ .String.Filename }}
57
+
<span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span>
58
+
<span>
59
+
{{ with .String.Edited }}
60
+
edited {{ template "repo/fragments/shortTimeAgo" . }}
61
+
{{ else }}
62
+
{{ template "repo/fragments/shortTimeAgo" .String.Created }}
63
+
{{ end }}
64
+
</span>
65
+
</span>
63
66
<div>
64
67
<span>{{ .Stats.LineCount }} lines</span>
65
68
<span class="select-none px-1 md:px-2 [&:before]:content-['ยท']"></span>
+7
-4
appview/pages/templates/user/fragments/profileCard.html
+7
-4
appview/pages/templates/user/fragments/profileCard.html
···
7
7
</div>
8
8
</div>
9
9
<div class="col-span-2">
10
-
<p title="{{ didOrHandle .UserDid .UserHandle }}"
11
-
class="text-lg font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">
12
-
{{ didOrHandle .UserDid .UserHandle }}
13
-
</p>
10
+
<div class="flex items-center flex-row flex-nowrap gap-2">
11
+
<p title="{{ didOrHandle .UserDid .UserHandle }}"
12
+
class="text-lg font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap">
13
+
{{ didOrHandle .UserDid .UserHandle }}
14
+
</p>
15
+
<a href="/{{ didOrHandle .UserDid .UserHandle }}/feed.atom">{{ i "rss" "size-4" }}</a>
16
+
</div>
14
17
15
18
<div class="md:hidden">
16
19
{{ block "followerFollowing" (list .Followers .Following) }} {{ end }}
+5
-4
appview/pages/templates/user/fragments/repoCard.html
+5
-4
appview/pages/templates/user/fragments/repoCard.html
···
28
28
{{ define "repoStats" }}
29
29
<div class="text-gray-400 pt-4 text-sm font-mono inline-flex gap-4 mt-auto">
30
30
{{ with .Language }}
31
-
<div class="flex gap-2 items-center text-sm">
32
-
<div class="size-2 rounded-full" style="background-color: {{ langColor . }};"></div>
33
-
<span>{{ . }}</span>
34
-
</div>
31
+
<div class="flex gap-2 items-center text-sm">
32
+
<div class="size-2 rounded-full"
33
+
style="background: radial-gradient(circle at 35% 35%, color-mix(in srgb, {{ langColor . }} 70%, white), {{ langColor . }} 30%, color-mix(in srgb, {{ langColor . }} 85%, black));"></div>
34
+
<span>{{ . }}</span>
35
+
</div>
35
36
{{ end }}
36
37
{{ with .StarCount }}
37
38
<div class="flex gap-1 items-center text-sm">
+1
-1
appview/pages/templates/user/signup.html
+1
-1
appview/pages/templates/user/signup.html
···
42
42
</button>
43
43
</form>
44
44
<p class="text-sm text-gray-500">
45
-
Already have an account? <a href="/login" class="underline">Login to Tangled</a>.
45
+
Already have an ATProto account? <a href="/login" class="underline">Login to Tangled</a>.
46
46
</p>
47
47
48
48
<p id="signup-msg" class="error w-full"></p>
+44
-15
appview/repo/repo.go
+44
-15
appview/repo/repo.go
···
81
81
}
82
82
}
83
83
84
+
func (rp *Repo) DownloadArchive(w http.ResponseWriter, r *http.Request) {
85
+
refParam := chi.URLParam(r, "ref")
86
+
f, err := rp.repoResolver.Resolve(r)
87
+
if err != nil {
88
+
log.Println("failed to get repo and knot", err)
89
+
return
90
+
}
91
+
92
+
var uri string
93
+
if rp.config.Core.Dev {
94
+
uri = "http"
95
+
} else {
96
+
uri = "https"
97
+
}
98
+
url := fmt.Sprintf("%s://%s/%s/%s/archive/%s.tar.gz", uri, f.Knot, f.OwnerDid(), f.RepoName, url.PathEscape(refParam))
99
+
100
+
http.Redirect(w, r, url, http.StatusFound)
101
+
}
102
+
84
103
func (rp *Repo) RepoLog(w http.ResponseWriter, r *http.Request) {
85
104
f, err := rp.repoResolver.Resolve(r)
86
105
if err != nil {
···
657
676
}
658
677
659
678
newSpindle := r.FormValue("spindle")
679
+
removingSpindle := newSpindle == "[[none]]" // see pages/templates/repo/settings/pipelines.html for more info on why we use this value
660
680
client, err := rp.oauth.AuthorizedClient(r)
661
681
if err != nil {
662
682
fail("Failed to authorize. Try again later.", err)
663
683
return
664
684
}
665
685
666
-
// ensure that this is a valid spindle for this user
667
-
validSpindles, err := rp.enforcer.GetSpindlesForUser(user.Did)
668
-
if err != nil {
669
-
fail("Failed to find spindles. Try again later.", err)
670
-
return
686
+
if !removingSpindle {
687
+
// ensure that this is a valid spindle for this user
688
+
validSpindles, err := rp.enforcer.GetSpindlesForUser(user.Did)
689
+
if err != nil {
690
+
fail("Failed to find spindles. Try again later.", err)
691
+
return
692
+
}
693
+
694
+
if !slices.Contains(validSpindles, newSpindle) {
695
+
fail("Failed to configure spindle.", fmt.Errorf("%s is not a valid spindle: %q", newSpindle, validSpindles))
696
+
return
697
+
}
671
698
}
672
699
673
-
if !slices.Contains(validSpindles, newSpindle) {
674
-
fail("Failed to configure spindle.", fmt.Errorf("%s is not a valid spindle: %q", newSpindle, validSpindles))
675
-
return
700
+
spindlePtr := &newSpindle
701
+
if removingSpindle {
702
+
spindlePtr = nil
676
703
}
677
704
678
705
// optimistic update
679
-
err = db.UpdateSpindle(rp.db, string(repoAt), newSpindle)
706
+
err = db.UpdateSpindle(rp.db, string(repoAt), spindlePtr)
680
707
if err != nil {
681
708
fail("Failed to update spindle. Try again later.", err)
682
709
return
···
699
726
Owner: user.Did,
700
727
CreatedAt: f.CreatedAt,
701
728
Description: &f.Description,
702
-
Spindle: &newSpindle,
729
+
Spindle: spindlePtr,
703
730
},
704
731
},
705
732
})
···
709
736
return
710
737
}
711
738
712
-
// add this spindle to spindle stream
713
-
rp.spindlestream.AddSource(
714
-
context.Background(),
715
-
eventconsumer.NewSpindleSource(newSpindle),
716
-
)
739
+
if !removingSpindle {
740
+
// add this spindle to spindle stream
741
+
rp.spindlestream.AddSource(
742
+
context.Background(),
743
+
eventconsumer.NewSpindleSource(newSpindle),
744
+
)
745
+
}
717
746
718
747
rp.pages.HxRefresh(w)
719
748
}
+4
appview/repo/router.go
+4
appview/repo/router.go
···
38
38
r.Get("/blob/{ref}/*", rp.RepoBlob)
39
39
r.Get("/raw/{ref}/*", rp.RepoBlobRaw)
40
40
41
+
// intentionally doesn't use /* as this isn't
42
+
// a file path
43
+
r.Get("/archive/{ref}", rp.DownloadArchive)
44
+
41
45
r.Route("/fork", func(r chi.Router) {
42
46
r.Use(middleware.AuthMiddleware(rp.oauth))
43
47
r.Get("/", rp.ForkRepo)
+114
-2
appview/state/profile.go
+114
-2
appview/state/profile.go
···
1
1
package state
2
2
3
3
import (
4
+
"context"
4
5
"fmt"
5
6
"log"
6
7
"net/http"
···
13
14
"github.com/bluesky-social/indigo/atproto/syntax"
14
15
lexutil "github.com/bluesky-social/indigo/lex/util"
15
16
"github.com/go-chi/chi/v5"
17
+
"github.com/gorilla/feeds"
16
18
"tangled.sh/tangled.sh/core/api/tangled"
17
19
"tangled.sh/tangled.sh/core/appview/db"
18
20
"tangled.sh/tangled.sh/core/appview/pages"
···
116
118
}
117
119
}
118
120
119
-
followers, following, err := db.GetFollowerFollowing(s.db, ident.DID.String())
121
+
followers, following, err := db.GetFollowerFollowingCount(s.db, ident.DID.String())
120
122
if err != nil {
121
123
log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err)
122
124
}
···
184
186
followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, ident.DID.String())
185
187
}
186
188
187
-
followers, following, err := db.GetFollowerFollowing(s.db, ident.DID.String())
189
+
followers, following, err := db.GetFollowerFollowingCount(s.db, ident.DID.String())
188
190
if err != nil {
189
191
log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err)
190
192
}
···
202
204
Following: following,
203
205
},
204
206
})
207
+
}
208
+
209
+
func (s *State) feedFromRequest(w http.ResponseWriter, r *http.Request) *feeds.Feed {
210
+
ident, ok := r.Context().Value("resolvedId").(identity.Identity)
211
+
if !ok {
212
+
s.pages.Error404(w)
213
+
return nil
214
+
}
215
+
216
+
feed, err := s.GetProfileFeed(r.Context(), ident.Handle.String(), ident.DID.String())
217
+
if err != nil {
218
+
s.pages.Error500(w)
219
+
return nil
220
+
}
221
+
222
+
return feed
223
+
}
224
+
225
+
func (s *State) AtomFeedPage(w http.ResponseWriter, r *http.Request) {
226
+
feed := s.feedFromRequest(w, r)
227
+
if feed == nil {
228
+
return
229
+
}
230
+
231
+
atom, err := feed.ToAtom()
232
+
if err != nil {
233
+
s.pages.Error500(w)
234
+
return
235
+
}
236
+
237
+
w.Header().Set("content-type", "application/atom+xml")
238
+
w.Write([]byte(atom))
239
+
}
240
+
241
+
func (s *State) GetProfileFeed(ctx context.Context, handle string, did string) (*feeds.Feed, error) {
242
+
timeline, err := db.MakeProfileTimeline(s.db, did)
243
+
if err != nil {
244
+
return nil, err
245
+
}
246
+
247
+
author := &feeds.Author{
248
+
Name: fmt.Sprintf("@%s", handle),
249
+
}
250
+
feed := &feeds.Feed{
251
+
Title: fmt.Sprintf("timeline feed for %s", author.Name),
252
+
Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s", s.config.Core.AppviewHost, handle), Type: "text/html", Rel: "alternate"},
253
+
Items: make([]*feeds.Item, 0),
254
+
Updated: time.UnixMilli(0),
255
+
Author: author,
256
+
}
257
+
for _, byMonth := range timeline.ByMonth {
258
+
for _, pull := range byMonth.PullEvents.Items {
259
+
owner, err := s.idResolver.ResolveIdent(ctx, pull.Repo.Did)
260
+
if err != nil {
261
+
return nil, err
262
+
}
263
+
feed.Items = append(feed.Items, &feeds.Item{
264
+
Title: fmt.Sprintf("%s created pull request '%s' in @%s/%s", author.Name, pull.Title, owner.Handle, pull.Repo.Name),
265
+
Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s/pulls/%d", s.config.Core.AppviewHost, owner.Handle, pull.Repo.Name, pull.PullId), Type: "text/html", Rel: "alternate"},
266
+
Created: pull.Created,
267
+
Author: author,
268
+
})
269
+
for _, submission := range pull.Submissions {
270
+
feed.Items = append(feed.Items, &feeds.Item{
271
+
Title: fmt.Sprintf("%s submitted pull request '%s' (round #%d) in @%s/%s", author.Name, pull.Title, submission.RoundNumber, owner.Handle, pull.Repo.Name),
272
+
Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s/pulls/%d", s.config.Core.AppviewHost, owner.Handle, pull.Repo.Name, pull.PullId), Type: "text/html", Rel: "alternate"},
273
+
Created: submission.Created,
274
+
Author: author,
275
+
})
276
+
}
277
+
}
278
+
for _, issue := range byMonth.IssueEvents.Items {
279
+
owner, err := s.idResolver.ResolveIdent(ctx, issue.Metadata.Repo.Did)
280
+
if err != nil {
281
+
return nil, err
282
+
}
283
+
feed.Items = append(feed.Items, &feeds.Item{
284
+
Title: fmt.Sprintf("%s created issue '%s' in @%s/%s", author.Name, issue.Title, owner.Handle, issue.Metadata.Repo.Name),
285
+
Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s/issues/%d", s.config.Core.AppviewHost, owner.Handle, issue.Metadata.Repo.Name, issue.IssueId), Type: "text/html", Rel: "alternate"},
286
+
Created: issue.Created,
287
+
Author: author,
288
+
})
289
+
}
290
+
for _, repo := range byMonth.RepoEvents {
291
+
var title string
292
+
if repo.Source != nil {
293
+
id, err := s.idResolver.ResolveIdent(ctx, repo.Source.Did)
294
+
if err != nil {
295
+
return nil, err
296
+
}
297
+
title = fmt.Sprintf("%s forked repository @%s/%s to '%s'", author.Name, id.Handle, repo.Source.Name, repo.Repo.Name)
298
+
} else {
299
+
title = fmt.Sprintf("%s created repository '%s'", author.Name, repo.Repo.Name)
300
+
}
301
+
feed.Items = append(feed.Items, &feeds.Item{
302
+
Title: title,
303
+
Link: &feeds.Link{Href: fmt.Sprintf("%s/@%s/%s", s.config.Core.AppviewHost, handle, repo.Repo.Name), Type: "text/html", Rel: "alternate"},
304
+
Created: repo.Repo.Created,
305
+
Author: author,
306
+
})
307
+
}
308
+
}
309
+
slices.SortFunc(feed.Items, func(a *feeds.Item, b *feeds.Item) int {
310
+
return int(b.Created.UnixMilli()) - int(a.Created.UnixMilli())
311
+
})
312
+
if len(feed.Items) > 0 {
313
+
feed.Updated = feed.Items[0].Created
314
+
}
315
+
316
+
return feed, nil
205
317
}
206
318
207
319
func (s *State) UpdateProfileBio(w http.ResponseWriter, r *http.Request) {
+1
appview/state/router.go
+1
appview/state/router.go
+6
-1
appview/strings/strings.go
+6
-1
appview/strings/strings.go
···
99
99
w.WriteHeader(http.StatusInternalServerError)
100
100
return
101
101
}
102
+
if len(strings) < 1 {
103
+
l.Error("string not found")
104
+
s.Pages.Error404(w)
105
+
return
106
+
}
102
107
if len(strings) != 1 {
103
108
l.Error("incorrect number of records returned", "len(strings)", len(strings))
104
109
w.WriteHeader(http.StatusInternalServerError)
···
177
182
followStatus = db.GetFollowStatus(s.Db, loggedInUser.Did, id.DID.String())
178
183
}
179
184
180
-
followers, following, err := db.GetFollowerFollowing(s.Db, id.DID.String())
185
+
followers, following, err := db.GetFollowerFollowingCount(s.Db, id.DID.String())
181
186
if err != nil {
182
187
l.Error("failed to get follow stats", "err", err)
183
188
}
+4
cmd/genjwks/main.go
+4
cmd/genjwks/main.go
+8
-15
docs/contributing.md
+8
-15
docs/contributing.md
···
115
115
If you're submitting a PR with multiple commits, make sure each one is
116
116
signed.
117
117
118
-
For [jj](https://jj-vcs.github.io/jj/latest/) users, you can add this to
119
-
your jj config:
118
+
For [jj](https://jj-vcs.github.io/jj/latest/) users, you can run the following command
119
+
to make it sign off commits in the tangled repo:
120
120
121
-
```
122
-
ui.should-sign-off = true
123
-
```
124
-
125
-
and to your `templates.draft_commit_description`, add the following `if`
126
-
block:
127
-
128
-
```
129
-
if(
130
-
config("ui.should-sign-off").as_boolean() && !description.contains("Signed-off-by: " ++ author.name()),
131
-
"\nSigned-off-by: " ++ author.name() ++ " <" ++ author.email() ++ ">",
132
-
),
121
+
```shell
122
+
# Safety check, should say "No matching config key..."
123
+
jj config list templates.commit_trailers
124
+
# The command below may need to be adjusted if the command above returned something.
125
+
jj config set --repo templates.commit_trailers "format_signed_off_by_trailer(self)"
133
126
```
134
127
135
128
Refer to the [jj
136
-
documentation](https://jj-vcs.github.io/jj/latest/config/#default-description)
129
+
documentation](https://jj-vcs.github.io/jj/latest/config/#commit-trailers)
137
130
for more information.
+20
-15
docs/hacking.md
+20
-15
docs/hacking.md
···
56
56
`nixosConfiguration` to do so.
57
57
58
58
To begin, head to `http://localhost:3000/knots` in the browser
59
-
and generate a knot secret. Replace the existing secret in
60
-
`nix/vm.nix` (`KNOT_SERVER_SECRET`) with the newly generated
61
-
secret.
59
+
and create a knot with hostname `localhost:6000`. This will
60
+
generate a knot secret. Set `$TANGLED_VM_KNOT_SECRET` to it,
61
+
ideally in a `.envrc` with [direnv](https://direnv.net) so you
62
+
don't lose it.
62
63
63
-
You can now start a lightweight NixOS VM using
64
-
`nixos-shell` like so:
64
+
You will also need to set the `$TANGLED_VM_SPINDLE_OWNER`
65
+
variable to some value. If you don't want to [set up a
66
+
spindle](#running-a-spindle), you can use any placeholder
67
+
value.
68
+
69
+
You can now start a lightweight NixOS VM like so:
65
70
66
71
```bash
67
-
nix run .#vm
68
-
# or nixos-shell --flake .#vm
72
+
nix run --impure .#vm
69
73
70
-
# hit Ctrl-a + c + q to exit the VM
74
+
# type `poweroff` at the shell to exit the VM
71
75
```
72
76
73
77
This starts a knot on port 6000, a spindle on port 6555
···
91
95
92
96
## running a spindle
93
97
94
-
Be sure to change the `owner` field for the spindle in
95
-
`nix/vm.nix` to your own DID. The above VM should already
96
-
be running a spindle on `localhost:6555`. You can head to
97
-
the spindle dashboard on `http://localhost:3000/spindles`,
98
-
and register a spindle with hostname `localhost:6555`. It
99
-
should instantly be verified. You can then configure each
100
-
repository to use this spindle and run CI jobs.
98
+
You will need to find out your DID by entering your login handle into
99
+
<https://pdsls.dev/>. Set `$TANGLED_VM_SPINDLE_OWNER` to your DID.
100
+
101
+
The above VM should already be running a spindle on `localhost:6555`.
102
+
You can head to the spindle dashboard on `http://localhost:3000/spindles`,
103
+
and register a spindle with hostname `localhost:6555`. It should instantly
104
+
be verified. You can then configure each repository to use this spindle
105
+
and run CI jobs.
101
106
102
107
Of interest when debugging spindles:
103
108
+10
-4
docs/knot-hosting.md
+10
-4
docs/knot-hosting.md
···
2
2
3
3
So you want to run your own knot server? Great! Here are a few prerequisites:
4
4
5
-
1. A server of some kind (a VPS, a Raspberry Pi, etc.). Preferably running a Linux of some kind.
5
+
1. A server of some kind (a VPS, a Raspberry Pi, etc.). Preferably running a Linux distribution of some kind.
6
6
2. A (sub)domain name. People generally use `knot.example.com`.
7
7
3. A valid SSL certificate for your domain.
8
8
···
59
59
EOF
60
60
```
61
61
62
+
Then, reload `sshd`:
63
+
64
+
```
65
+
sudo systemctl reload ssh
66
+
```
67
+
62
68
Next, create the `git` user. We'll use the `git` user's home directory
63
69
to store repositories:
64
70
···
67
73
```
68
74
69
75
Create `/home/git/.knot.env` with the following, updating the values as
70
-
necessary. The `KNOT_SERVER_SECRET` can be obtaind from the
71
-
[/knots](/knots) page on Tangled.
76
+
necessary. The `KNOT_SERVER_SECRET` can be obtained from the
77
+
[/knots](https://tangled.sh/knots) page on Tangled.
72
78
73
79
```
74
80
KNOT_REPO_SCAN_PATH=/home/git
···
123
129
knot domain.
124
130
125
131
You should now have a running knot server! You can finalize your registration by hitting the
126
-
`initialize` button on the [/knots](/knots) page.
132
+
`initialize` button on the [/knots](https://tangled.sh/knots) page.
127
133
128
134
### custom paths
129
135
+3
-3
flake.lock
+3
-3
flake.lock
···
26
26
]
27
27
},
28
28
"locked": {
29
-
"lastModified": 1751702058,
30
-
"narHash": "sha256-/GTdqFzFw/Y9DSNAfzvzyCMlKjUyRKMPO+apIuaTU4A=",
29
+
"lastModified": 1754078208,
30
+
"narHash": "sha256-YVoIFDCDpYuU3riaDEJ3xiGdPOtsx4sR5eTzHTytPV8=",
31
31
"owner": "nix-community",
32
32
"repo": "gomod2nix",
33
-
"rev": "664ad7a2df4623037e315e4094346bff5c44e9ee",
33
+
"rev": "7f963246a71626c7fc70b431a315c4388a0c95cf",
34
34
"type": "github"
35
35
},
36
36
"original": {
+63
-21
flake.nix
+63
-21
flake.nix
···
75
75
};
76
76
genjwks = self.callPackage ./nix/pkgs/genjwks.nix {};
77
77
lexgen = self.callPackage ./nix/pkgs/lexgen.nix {inherit indigo;};
78
-
appview = self.callPackage ./nix/pkgs/appview.nix {
78
+
appview-static-files = self.callPackage ./nix/pkgs/appview-static-files.nix {
79
79
inherit htmx-src htmx-ws-src lucide-src inter-fonts-src ibm-plex-mono-src;
80
80
};
81
+
appview = self.callPackage ./nix/pkgs/appview.nix {};
81
82
spindle = self.callPackage ./nix/pkgs/spindle.nix {};
82
83
knot-unwrapped = self.callPackage ./nix/pkgs/knot-unwrapped.nix {};
83
84
knot = self.callPackage ./nix/pkgs/knot.nix {};
···
93
94
staticPackages = mkPackageSet pkgs.pkgsStatic;
94
95
crossPackages = mkPackageSet pkgs.pkgsCross.gnu64.pkgsStatic;
95
96
in {
96
-
appview = packages.appview;
97
-
lexgen = packages.lexgen;
98
-
knot = packages.knot;
99
-
knot-unwrapped = packages.knot-unwrapped;
100
-
spindle = packages.spindle;
101
-
genjwks = packages.genjwks;
102
-
sqlite-lib = packages.sqlite-lib;
97
+
inherit (packages) appview appview-static-files lexgen genjwks spindle knot knot-unwrapped sqlite-lib;
103
98
104
99
pkgsStatic-appview = staticPackages.appview;
105
100
pkgsStatic-knot = staticPackages.knot;
···
132
127
pkgs.tailwindcss
133
128
pkgs.nixos-shell
134
129
pkgs.redis
130
+
pkgs.coreutils # for those of us who are on systems that use busybox (alpine)
135
131
packages'.lexgen
136
132
];
137
133
shellHook = ''
138
-
mkdir -p appview/pages/static/{fonts,icons}
139
-
cp -f ${htmx-src} appview/pages/static/htmx.min.js
140
-
cp -f ${htmx-ws-src} appview/pages/static/htmx-ext-ws.min.js
141
-
cp -rf ${lucide-src}/*.svg appview/pages/static/icons/
142
-
cp -f ${inter-fonts-src}/web/InterVariable*.woff2 appview/pages/static/fonts/
143
-
cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 appview/pages/static/fonts/
144
-
cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono-Regular.woff2 appview/pages/static/fonts/
134
+
mkdir -p appview/pages/static
135
+
# no preserve is needed because watch-tailwind will want to be able to overwrite
136
+
cp -fr --no-preserve=ownership ${packages'.appview-static-files}/* appview/pages/static
145
137
export TANGLED_OAUTH_JWKS="$(${packages'.genjwks}/bin/genjwks)"
146
138
'';
147
139
env.CGO_ENABLED = 1;
···
149
141
});
150
142
apps = forAllSystems (system: let
151
143
pkgs = nixpkgsFor."${system}";
144
+
packages' = self.packages.${system};
152
145
air-watcher = name: arg:
153
146
pkgs.writeShellScriptBin "run"
154
147
''
···
167
160
in {
168
161
watch-appview = {
169
162
type = "app";
170
-
program = ''${air-watcher "appview" ""}/bin/run'';
163
+
program = toString (pkgs.writeShellScript "watch-appview" ''
164
+
echo "copying static files to appview/pages/static..."
165
+
${pkgs.coreutils}/bin/cp -fr --no-preserve=ownership ${packages'.appview-static-files}/* appview/pages/static
166
+
${air-watcher "appview" ""}/bin/run
167
+
'');
171
168
};
172
169
watch-knot = {
173
170
type = "app";
···
177
174
type = "app";
178
175
program = ''${tailwind-watcher}/bin/run'';
179
176
};
180
-
vm = {
177
+
vm = let
178
+
guestSystem =
179
+
if pkgs.stdenv.hostPlatform.isAarch64
180
+
then "aarch64-linux"
181
+
else "x86_64-linux";
182
+
in {
181
183
type = "app";
182
-
program = toString (pkgs.writeShellScript "vm" ''
183
-
${pkgs.nixos-shell}/bin/nixos-shell --flake .#vm
184
-
'');
184
+
program =
185
+
(pkgs.writeShellApplication {
186
+
name = "launch-vm";
187
+
text = ''
188
+
rootDir=$(jj --ignore-working-copy root || git rev-parse --show-toplevel) || (echo "error: can't find repo root?"; exit 1)
189
+
cd "$rootDir"
190
+
191
+
mkdir -p nix/vm-data/{knot,repos,spindle,spindle-logs}
192
+
193
+
export TANGLED_VM_DATA_DIR="$rootDir/nix/vm-data"
194
+
exec ${pkgs.lib.getExe
195
+
(import ./nix/vm.nix {
196
+
inherit nixpkgs self;
197
+
system = guestSystem;
198
+
hostSystem = system;
199
+
}).config.system.build.vm}
200
+
'';
201
+
})
202
+
+ /bin/launch-vm;
185
203
};
186
204
gomod2nix = {
187
205
type = "app";
···
189
207
${gomod2nix.legacyPackages.${system}.gomod2nix}/bin/gomod2nix generate --outdir ./nix
190
208
'');
191
209
};
210
+
lexgen = {
211
+
type = "app";
212
+
program =
213
+
(pkgs.writeShellApplication {
214
+
name = "lexgen";
215
+
text = ''
216
+
if ! command -v lexgen > /dev/null; then
217
+
echo "error: must be executed from devshell"
218
+
exit 1
219
+
fi
220
+
221
+
rootDir=$(jj --ignore-working-copy root || git rev-parse --show-toplevel) || (echo "error: can't find repo root?"; exit 1)
222
+
cd "$rootDir"
223
+
224
+
rm api/tangled/*
225
+
lexgen --build-file lexicon-build-config.json lexicons
226
+
sed -i.bak 's/\tutil/\/\/\tutil/' api/tangled/*
227
+
${pkgs.gotools}/bin/goimports -w api/tangled/*
228
+
go run cmd/gen.go
229
+
lexgen --build-file lexicon-build-config.json lexicons
230
+
rm api/tangled/*.bak
231
+
'';
232
+
})
233
+
+ /bin/lexgen;
234
+
};
192
235
});
193
236
194
237
nixosModules.appview = {
···
218
261
219
262
services.tangled-spindle.package = lib.mkDefault self.packages.${pkgs.system}.spindle;
220
263
};
221
-
nixosConfigurations.vm = import ./nix/vm.nix {inherit self nixpkgs;};
222
264
};
223
265
}
+1
go.mod
+1
go.mod
···
88
88
github.com/golang/mock v1.6.0 // indirect
89
89
github.com/google/go-querystring v1.1.0 // indirect
90
90
github.com/gorilla/css v1.0.1 // indirect
91
+
github.com/gorilla/feeds v1.2.0 // indirect
91
92
github.com/gorilla/securecookie v1.1.2 // indirect
92
93
github.com/hashicorp/errwrap v1.1.0 // indirect
93
94
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
+2
go.sum
+2
go.sum
···
173
173
github.com/gopherjs/gopherjs v0.0.0-20190430165422-3e4dfb77656c/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
174
174
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
175
175
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
176
+
github.com/gorilla/feeds v1.2.0 h1:O6pBiXJ5JHhPvqy53NsjKOThq+dNFm8+DFrxBEdzSCc=
177
+
github.com/gorilla/feeds v1.2.0/go.mod h1:WMib8uJP3BbY+X8Szd1rA5Pzhdfh+HCCAYT2z7Fza6Y=
176
178
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
177
179
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
178
180
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
+12
input.css
+12
input.css
···
70
70
details summary::-webkit-details-marker {
71
71
display: none;
72
72
}
73
+
74
+
code {
75
+
@apply px-1 font-mono rounded bg-gray-100 dark:bg-gray-700;
76
+
}
73
77
}
74
78
75
79
@layer components {
···
102
106
display: inline;
103
107
margin: 0;
104
108
vertical-align: middle;
109
+
}
110
+
111
+
.prose input {
112
+
@apply inline-block my-0 mb-1 mx-1;
113
+
}
114
+
115
+
.prose input[type="checkbox"] {
116
+
@apply disabled:accent-blue-500 checked:accent-blue-500 disabled:checked:accent-blue-500;
105
117
}
106
118
}
107
119
@layer utilities {
+19
-12
knotserver/git/post_receive.go
+19
-12
knotserver/git/post_receive.go
···
3
3
import (
4
4
"bufio"
5
5
"context"
6
+
"errors"
6
7
"fmt"
7
8
"io"
8
9
"strings"
···
57
58
ByEmail map[string]int
58
59
}
59
60
60
-
func (g *GitRepo) RefUpdateMeta(line PostReceiveLine) RefUpdateMeta {
61
+
func (g *GitRepo) RefUpdateMeta(line PostReceiveLine) (RefUpdateMeta, error) {
62
+
var errs error
63
+
61
64
commitCount, err := g.newCommitCount(line)
62
-
if err != nil {
63
-
// TODO: log this
64
-
}
65
+
errors.Join(errs, err)
65
66
66
67
isDefaultRef, err := g.isDefaultBranch(line)
67
-
if err != nil {
68
-
// TODO: log this
69
-
}
68
+
errors.Join(errs, err)
70
69
71
70
ctx, cancel := context.WithTimeout(context.Background(), time.Second*2)
72
71
defer cancel()
73
72
breakdown, err := g.AnalyzeLanguages(ctx)
74
-
if err != nil {
75
-
// TODO: log this
76
-
}
73
+
errors.Join(errs, err)
77
74
78
75
return RefUpdateMeta{
79
76
CommitCount: commitCount,
80
77
IsDefaultRef: isDefaultRef,
81
78
LangBreakdown: breakdown,
82
-
}
79
+
}, errs
83
80
}
84
81
85
82
func (g *GitRepo) newCommitCount(line PostReceiveLine) (CommitCount, error) {
···
95
92
args := []string{fmt.Sprintf("--max-count=%d", 100)}
96
93
97
94
if line.OldSha.IsZero() {
98
-
// just git rev-list <newsha>
95
+
// git rev-list <newsha> ^other-branches --not ^this-branch
99
96
args = append(args, line.NewSha.String())
97
+
98
+
branches, _ := g.Branches()
99
+
for _, b := range branches {
100
+
if !strings.Contains(line.Ref, b.Name) {
101
+
args = append(args, fmt.Sprintf("^%s", b.Name))
102
+
}
103
+
}
104
+
105
+
args = append(args, "--not")
106
+
args = append(args, fmt.Sprintf("^%s", line.Ref))
100
107
} else {
101
108
// git rev-list <oldsha>..<newsha>
102
109
args = append(args, fmt.Sprintf("%s..%s", line.OldSha.String(), line.NewSha.String()))
+5
-2
knotserver/internal.go
+5
-2
knotserver/internal.go
···
3
3
import (
4
4
"context"
5
5
"encoding/json"
6
+
"errors"
6
7
"fmt"
7
8
"log/slog"
8
9
"net/http"
···
145
146
return fmt.Errorf("failed to open git repo at ref %s: %w", line.Ref, err)
146
147
}
147
148
148
-
meta := gr.RefUpdateMeta(line)
149
+
var errs error
150
+
meta, err := gr.RefUpdateMeta(line)
151
+
errors.Join(errs, err)
149
152
150
153
metaRecord := meta.AsRecord()
151
154
···
169
172
EventJson: string(eventJson),
170
173
}
171
174
172
-
return h.db.InsertEvent(event, h.n)
175
+
return errors.Join(errs, h.db.InsertEvent(event, h.n))
173
176
}
174
177
175
178
func (h *InternalHandle) triggerPipeline(clientMsgs *[]string, line git.PostReceiveLine, gitUserDid, repoDid, repoName string, pushOptions PushOptions) error {
+11
-3
knotserver/routes.go
+11
-3
knotserver/routes.go
···
361
361
362
362
ref := strings.TrimSuffix(file, ".tar.gz")
363
363
364
+
unescapedRef, err := url.PathUnescape(ref)
365
+
if err != nil {
366
+
notFound(w)
367
+
return
368
+
}
369
+
370
+
safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(unescapedRef).Short(), "/", "-")
371
+
364
372
// This allows the browser to use a proper name for the file when
365
373
// downloading
366
-
filename := fmt.Sprintf("%s-%s.tar.gz", name, ref)
374
+
filename := fmt.Sprintf("%s-%s.tar.gz", name, safeRefFilename)
367
375
setContentDisposition(w, filename)
368
376
setGZipMIME(w)
369
377
370
378
path, _ := securejoin.SecureJoin(h.c.Repo.ScanPath, didPath(r))
371
-
gr, err := git.Open(path, ref)
379
+
gr, err := git.Open(path, unescapedRef)
372
380
if err != nil {
373
381
notFound(w)
374
382
return
···
377
385
gw := gzip.NewWriter(w)
378
386
defer gw.Close()
379
387
380
-
prefix := fmt.Sprintf("%s-%s", name, ref)
388
+
prefix := fmt.Sprintf("%s-%s", name, safeRefFilename)
381
389
err = gr.WriteTar(gw, prefix)
382
390
if err != nil {
383
391
// once we start writing to the body we can't report error anymore
+3
nix/gomod2nix.toml
+3
nix/gomod2nix.toml
···
181
181
[mod."github.com/gorilla/css"]
182
182
version = "v1.0.1"
183
183
hash = "sha256-6JwNHqlY2NpZ0pSQTyYPSpiNqjXOdFHqrUT10sv3y8A="
184
+
[mod."github.com/gorilla/feeds"]
185
+
version = "v1.2.0"
186
+
hash = "sha256-ptczizo27t6Bsq6rHJ4WiHmBRP54UC5yNfHghAqOBQk="
184
187
[mod."github.com/gorilla/securecookie"]
185
188
version = "v1.1.2"
186
189
hash = "sha256-KeMHNM9emxX+N0WYiZsTii7n8sNsmjWwbnQ9SaJfTKE="
+14
nix/modules/appview.nix
+14
nix/modules/appview.nix
···
27
27
default = "00000000000000000000000000000000";
28
28
description = "Cookie secret";
29
29
};
30
+
environmentFile = mkOption {
31
+
type = with types; nullOr path;
32
+
default = null;
33
+
example = "/etc/tangled-appview.env";
34
+
description = ''
35
+
Additional environment file as defined in {manpage}`systemd.exec(5)`.
36
+
37
+
Sensitive secrets such as {env}`TANGLED_COOKIE_SECRET` may be
38
+
passed to the service without makeing them world readable in the
39
+
nix store.
40
+
41
+
'';
42
+
};
30
43
};
31
44
};
32
45
···
39
52
ListenStream = "0.0.0.0:${toString cfg.port}";
40
53
ExecStart = "${cfg.package}/bin/appview";
41
54
Restart = "always";
55
+
EnvironmentFile = optional (cfg.environmentFile != null) cfg.environmentFile;
42
56
};
43
57
44
58
environment = {
+27
-24
nix/modules/knot.nix
+27
-24
nix/modules/knot.nix
···
126
126
cfg.package
127
127
];
128
128
129
-
system.activationScripts.gitConfig = let
130
-
setMotd =
131
-
if cfg.motdFile != null && cfg.motd != null
132
-
then throw "motdFile and motd cannot be both set"
133
-
else ''
134
-
${optionalString (cfg.motdFile != null) "cat ${cfg.motdFile} > ${cfg.stateDir}/motd"}
135
-
${optionalString (cfg.motd != null) ''printf "${cfg.motd}" > ${cfg.stateDir}/motd''}
136
-
'';
137
-
in ''
138
-
mkdir -p "${cfg.repo.scanPath}"
139
-
chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.repo.scanPath}"
140
-
141
-
mkdir -p "${cfg.stateDir}/.config/git"
142
-
cat > "${cfg.stateDir}/.config/git/config" << EOF
143
-
[user]
144
-
name = Git User
145
-
email = git@example.com
146
-
[receive]
147
-
advertisePushOptions = true
148
-
EOF
149
-
${setMotd}
150
-
chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.stateDir}"
151
-
'';
152
-
153
129
users.users.${cfg.gitUser} = {
154
130
isSystemUser = true;
155
131
useDefaultShell = true;
···
185
161
description = "knot service";
186
162
after = ["network.target" "sshd.service"];
187
163
wantedBy = ["multi-user.target"];
164
+
enableStrictShellChecks = true;
165
+
166
+
preStart = let
167
+
setMotd =
168
+
if cfg.motdFile != null && cfg.motd != null
169
+
then throw "motdFile and motd cannot be both set"
170
+
else ''
171
+
${optionalString (cfg.motdFile != null) "cat ${cfg.motdFile} > ${cfg.stateDir}/motd"}
172
+
${optionalString (cfg.motd != null) ''printf "${cfg.motd}" > ${cfg.stateDir}/motd''}
173
+
'';
174
+
in ''
175
+
mkdir -p "${cfg.repo.scanPath}"
176
+
chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.repo.scanPath}"
177
+
178
+
mkdir -p "${cfg.stateDir}/.config/git"
179
+
cat > "${cfg.stateDir}/.config/git/config" << EOF
180
+
[user]
181
+
name = Git User
182
+
email = git@example.com
183
+
[receive]
184
+
advertisePushOptions = true
185
+
EOF
186
+
${setMotd}
187
+
chown -R ${cfg.gitUser}:${cfg.gitUser} "${cfg.stateDir}"
188
+
'';
189
+
188
190
serviceConfig = {
189
191
User = cfg.gitUser;
192
+
PermissionsStartOnly = true;
190
193
WorkingDirectory = cfg.stateDir;
191
194
Environment = [
192
195
"KNOT_REPO_SCAN_PATH=${cfg.repo.scanPath}"
+29
nix/pkgs/appview-static-files.nix
+29
nix/pkgs/appview-static-files.nix
···
1
+
{
2
+
runCommandLocal,
3
+
htmx-src,
4
+
htmx-ws-src,
5
+
lucide-src,
6
+
inter-fonts-src,
7
+
ibm-plex-mono-src,
8
+
sqlite-lib,
9
+
tailwindcss,
10
+
src,
11
+
}:
12
+
runCommandLocal "appview-static-files" {
13
+
# TOOD(winter): figure out why this is even required after
14
+
# changing the libraries that the tailwindcss binary loads
15
+
sandboxProfile = ''
16
+
(allow file-read* (subpath "/System/Library/OpenSSL"))
17
+
'';
18
+
} ''
19
+
mkdir -p $out/{fonts,icons} && cd $out
20
+
cp -f ${htmx-src} htmx.min.js
21
+
cp -f ${htmx-ws-src} htmx-ext-ws.min.js
22
+
cp -rf ${lucide-src}/*.svg icons/
23
+
cp -f ${inter-fonts-src}/web/InterVariable*.woff2 fonts/
24
+
cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 fonts/
25
+
cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono-Regular.woff2 fonts/
26
+
# tailwindcss -c $src/tailwind.config.js -i $src/input.css -o tw.css won't work
27
+
# for whatever reason (produces broken css), so we are doing this instead
28
+
cd ${src} && ${tailwindcss}/bin/tailwindcss -i input.css -o $out/tw.css
29
+
''
+3
-14
nix/pkgs/appview.nix
+3
-14
nix/pkgs/appview.nix
···
1
1
{
2
2
buildGoApplication,
3
3
modules,
4
-
htmx-src,
5
-
htmx-ws-src,
6
-
lucide-src,
7
-
inter-fonts-src,
8
-
ibm-plex-mono-src,
9
-
tailwindcss,
4
+
appview-static-files,
10
5
sqlite-lib,
11
6
src,
12
7
}:
···
17
12
18
13
postUnpack = ''
19
14
pushd source
20
-
mkdir -p appview/pages/static/{fonts,icons}
21
-
cp -f ${htmx-src} appview/pages/static/htmx.min.js
22
-
cp -f ${htmx-ws-src} appview/pages/static/htmx-ext-ws.min.js
23
-
cp -rf ${lucide-src}/*.svg appview/pages/static/icons/
24
-
cp -f ${inter-fonts-src}/web/InterVariable*.woff2 appview/pages/static/fonts/
25
-
cp -f ${inter-fonts-src}/web/InterDisplay*.woff2 appview/pages/static/fonts/
26
-
cp -f ${ibm-plex-mono-src}/fonts/complete/woff2/IBMPlexMono-Regular.woff2 appview/pages/static/fonts/
27
-
${tailwindcss}/bin/tailwindcss -i input.css -o appview/pages/static/tw.css
15
+
mkdir -p appview/pages/static
16
+
cp -frv ${appview-static-files}/* appview/pages/static
28
17
popd
29
18
'';
30
19
+8
-3
nix/pkgs/genjwks.nix
+8
-3
nix/pkgs/genjwks.nix
···
1
1
{
2
-
src,
3
2
buildGoApplication,
4
3
modules,
5
4
}:
6
5
buildGoApplication {
7
6
pname = "genjwks";
8
7
version = "0.1.0";
9
-
inherit src modules;
10
-
subPackages = ["cmd/genjwks"];
8
+
src = ../../cmd/genjwks;
9
+
postPatch = ''
10
+
ln -s ${../../go.mod} ./go.mod
11
+
'';
12
+
postInstall = ''
13
+
mv $out/bin/core $out/bin/genjwks
14
+
'';
15
+
inherit modules;
11
16
doCheck = false;
12
17
CGO_ENABLED = 0;
13
18
}
+119
-66
nix/vm.nix
+119
-66
nix/vm.nix
···
1
1
{
2
2
nixpkgs,
3
+
system,
4
+
hostSystem,
3
5
self,
4
-
}:
5
-
nixpkgs.lib.nixosSystem {
6
-
system = "x86_64-linux";
7
-
modules = [
8
-
self.nixosModules.knot
9
-
self.nixosModules.spindle
10
-
({
11
-
config,
12
-
pkgs,
13
-
...
14
-
}: {
15
-
virtualisation = {
16
-
memorySize = 2048;
17
-
diskSize = 10 * 1024;
18
-
cores = 2;
19
-
forwardPorts = [
20
-
# ssh
21
-
{
22
-
from = "host";
23
-
host.port = 2222;
24
-
guest.port = 22;
25
-
}
26
-
# knot
27
-
{
28
-
from = "host";
29
-
host.port = 6000;
30
-
guest.port = 6000;
31
-
}
32
-
# spindle
33
-
{
34
-
from = "host";
35
-
host.port = 6555;
36
-
guest.port = 6555;
37
-
}
38
-
];
39
-
};
40
-
services.getty.autologinUser = "root";
41
-
environment.systemPackages = with pkgs; [curl vim git];
42
-
systemd.tmpfiles.rules = let
43
-
u = config.services.tangled-knot.gitUser;
44
-
g = config.services.tangled-knot.gitUser;
45
-
in [
46
-
"d /var/lib/knot 0770 ${u} ${g} - -" # Create the directory first
47
-
"f+ /var/lib/knot/secret 0660 ${u} ${g} - KNOT_SERVER_SECRET=168c426fa6d9829fcbe85c96bdf144e800fb9737d6ca87f21acc543b1aa3e440"
48
-
];
49
-
services.tangled-knot = {
50
-
enable = true;
51
-
motd = "Welcome to the development knot!\n";
52
-
server = {
53
-
secretFile = "/var/lib/knot/secret";
54
-
hostname = "localhost:6000";
55
-
listenAddr = "0.0.0.0:6000";
6
+
}: let
7
+
envVar = name: let
8
+
var = builtins.getEnv name;
9
+
in
10
+
if var == ""
11
+
then throw "\$${name} must be defined, see docs/hacking.md for more details"
12
+
else var;
13
+
in
14
+
nixpkgs.lib.nixosSystem {
15
+
inherit system;
16
+
modules = [
17
+
self.nixosModules.knot
18
+
self.nixosModules.spindle
19
+
({
20
+
lib,
21
+
config,
22
+
pkgs,
23
+
...
24
+
}: {
25
+
virtualisation.vmVariant.virtualisation = {
26
+
host.pkgs = import nixpkgs {system = hostSystem;};
27
+
28
+
graphics = false;
29
+
memorySize = 2048;
30
+
diskSize = 10 * 1024;
31
+
cores = 2;
32
+
forwardPorts = [
33
+
# ssh
34
+
{
35
+
from = "host";
36
+
host.port = 2222;
37
+
guest.port = 22;
38
+
}
39
+
# knot
40
+
{
41
+
from = "host";
42
+
host.port = 6000;
43
+
guest.port = 6000;
44
+
}
45
+
# spindle
46
+
{
47
+
from = "host";
48
+
host.port = 6555;
49
+
guest.port = 6555;
50
+
}
51
+
];
52
+
sharedDirectories = {
53
+
# We can't use the 9p mounts directly for most of these
54
+
# as SQLite is incompatible with them. So instead we
55
+
# mount the shared directories to a different location
56
+
# and copy the contents around on service start/stop.
57
+
knotData = {
58
+
source = "$TANGLED_VM_DATA_DIR/knot";
59
+
target = "/mnt/knot-data";
60
+
};
61
+
spindleData = {
62
+
source = "$TANGLED_VM_DATA_DIR/spindle";
63
+
target = "/mnt/spindle-data";
64
+
};
65
+
spindleLogs = {
66
+
source = "$TANGLED_VM_DATA_DIR/spindle-logs";
67
+
target = "/var/log/spindle";
68
+
};
69
+
};
70
+
};
71
+
# This is fine because any and all ports that are forwarded to host are explicitly marked above, we don't need a separate guest firewall
72
+
networking.firewall.enable = false;
73
+
services.getty.autologinUser = "root";
74
+
environment.systemPackages = with pkgs; [curl vim git sqlite litecli];
75
+
services.tangled-knot = {
76
+
enable = true;
77
+
motd = "Welcome to the development knot!\n";
78
+
server = {
79
+
secretFile = builtins.toFile "knot-secret" ("KNOT_SERVER_SECRET=" + (envVar "TANGLED_VM_KNOT_SECRET"));
80
+
hostname = "localhost:6000";
81
+
listenAddr = "0.0.0.0:6000";
82
+
};
83
+
};
84
+
services.tangled-spindle = {
85
+
enable = true;
86
+
server = {
87
+
owner = envVar "TANGLED_VM_SPINDLE_OWNER";
88
+
hostname = "localhost:6555";
89
+
listenAddr = "0.0.0.0:6555";
90
+
dev = true;
91
+
secrets = {
92
+
provider = "sqlite";
93
+
};
94
+
};
95
+
};
96
+
users = {
97
+
# So we don't have to deal with permission clashing between
98
+
# blank disk VMs and existing state
99
+
users.${config.services.tangled-knot.gitUser}.uid = 666;
100
+
groups.${config.services.tangled-knot.gitUser}.gid = 666;
101
+
102
+
# TODO: separate spindle user
56
103
};
57
-
};
58
-
services.tangled-spindle = {
59
-
enable = true;
60
-
server = {
61
-
owner = "did:plc:qfpnj4og54vl56wngdriaxug";
62
-
hostname = "localhost:6555";
63
-
listenAddr = "0.0.0.0:6555";
64
-
dev = true;
65
-
secrets = {
66
-
provider = "sqlite";
104
+
systemd.services = let
105
+
mkDataSyncScripts = source: target: {
106
+
enableStrictShellChecks = true;
107
+
108
+
preStart = lib.mkBefore ''
109
+
mkdir -p ${target}
110
+
${lib.getExe pkgs.rsync} -a ${source}/ ${target}
111
+
'';
112
+
113
+
postStop = lib.mkAfter ''
114
+
${lib.getExe pkgs.rsync} -a ${target}/ ${source}
115
+
'';
116
+
117
+
serviceConfig.PermissionsStartOnly = true;
67
118
};
119
+
in {
120
+
knot = mkDataSyncScripts "/mnt/knot-data" config.services.tangled-knot.stateDir;
121
+
spindle = mkDataSyncScripts "/mnt/spindle-data" (builtins.dirOf config.services.tangled-spindle.server.dbPath);
68
122
};
69
-
};
70
-
})
71
-
];
72
-
}
123
+
})
124
+
];
125
+
}
+4
spindle/server.go
+4
spindle/server.go
···
242
242
return fmt.Errorf("no repo data found")
243
243
}
244
244
245
+
if src.Key() != tpl.TriggerMetadata.Repo.Knot {
246
+
return fmt.Errorf("repo knot does not match event source: %s != %s", src.Key(), tpl.TriggerMetadata.Repo.Knot)
247
+
}
248
+
245
249
// filter by repos
246
250
_, err = s.db.GetRepo(
247
251
tpl.TriggerMetadata.Repo.Knot,
+6
tailwind.config.js
+6
tailwind.config.js
···
40
40
color: colors.black,
41
41
"@apply font-normal text-black bg-gray-100 dark:bg-gray-900 dark:text-gray-300 dark:border-gray-700 dark:border": {},
42
42
},
43
+
li: {
44
+
"@apply inline-block w-full my-0 py-0": {},
45
+
},
46
+
"ul, ol": {
47
+
"@apply my-1 py-0": {},
48
+
},
43
49
code: {
44
50
"@apply font-normal font-mono p-1 rounded text-black bg-gray-100 dark:bg-gray-900 dark:text-gray-300 dark:border-gray-700": {},
45
51
},