···18// RECORDTYPE: ActorProfile
19type ActorProfile struct {
20 LexiconTypeID string `json:"$type,const=sh.tangled.actor.profile" cborgen:"$type,const=sh.tangled.actor.profile"`
0021 // bluesky: Include link to this account on Bluesky.
22 Bluesky bool `json:"bluesky" cborgen:"bluesky"`
23 // description: Free-form profile description text.
···18// RECORDTYPE: ActorProfile
19type ActorProfile struct {
20 LexiconTypeID string `json:"$type,const=sh.tangled.actor.profile" cborgen:"$type,const=sh.tangled.actor.profile"`
21+ // avatar: Small image to be displayed next to posts from account. AKA, 'profile picture'
22+ Avatar *util.LexBlob `json:"avatar,omitempty" cborgen:"avatar,omitempty"`
23 // bluesky: Include link to this account on Bluesky.
24 Bluesky bool `json:"bluesky" cborgen:"bluesky"`
25 // description: Free-form profile description text.
···1+// Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT.
2+3+package tangled
4+5+// schema: sh.tangled.repo.tag
6+7+import (
8+ "bytes"
9+ "context"
10+11+ "github.com/bluesky-social/indigo/lex/util"
12+)
13+14+const (
15+ RepoTagNSID = "sh.tangled.repo.tag"
16+)
17+18+// RepoTag calls the XRPC method "sh.tangled.repo.tag".
19+//
20+// repo: Repository identifier in format 'did:plc:.../repoName'
21+// tag: Name of tag, such as v1.3.0
22+func RepoTag(ctx context.Context, c util.LexClient, repo string, tag string) ([]byte, error) {
23+ buf := new(bytes.Buffer)
24+25+ params := map[string]interface{}{}
26+ params["repo"] = repo
27+ params["tag"] = tag
28+ if err := c.LexDo(ctx, util.Query, "", "sh.tangled.repo.tag", params, nil, buf); err != nil {
29+ return nil, err
30+ }
31+32+ return buf.Bytes(), nil
33+}
+14-2
api/tangled/repotree.go
···1617// RepoTree_LastCommit is a "lastCommit" in the sh.tangled.repo.tree schema.
18type RepoTree_LastCommit struct {
019 // hash: Commit hash
20 Hash string `json:"hash" cborgen:"hash"`
21 // message: Commit message
···27// RepoTree_Output is the output of a sh.tangled.repo.tree call.
28type RepoTree_Output struct {
29 // dotdot: Parent directory path
30- Dotdot *string `json:"dotdot,omitempty" cborgen:"dotdot,omitempty"`
31- Files []*RepoTree_TreeEntry `json:"files" cborgen:"files"`
032 // parent: The parent path in the tree
33 Parent *string `json:"parent,omitempty" cborgen:"parent,omitempty"`
34 // readme: Readme for this file tree
···43 Contents string `json:"contents" cborgen:"contents"`
44 // filename: Name of the readme file
45 Filename string `json:"filename" cborgen:"filename"`
000000000046}
4748// RepoTree_TreeEntry is a "treeEntry" in the sh.tangled.repo.tree schema.
···1617// RepoTree_LastCommit is a "lastCommit" in the sh.tangled.repo.tree schema.
18type RepoTree_LastCommit struct {
19+ Author *RepoTree_Signature `json:"author,omitempty" cborgen:"author,omitempty"`
20 // hash: Commit hash
21 Hash string `json:"hash" cborgen:"hash"`
22 // message: Commit message
···28// RepoTree_Output is the output of a sh.tangled.repo.tree call.
29type RepoTree_Output struct {
30 // dotdot: Parent directory path
31+ Dotdot *string `json:"dotdot,omitempty" cborgen:"dotdot,omitempty"`
32+ Files []*RepoTree_TreeEntry `json:"files" cborgen:"files"`
33+ LastCommit *RepoTree_LastCommit `json:"lastCommit,omitempty" cborgen:"lastCommit,omitempty"`
34 // parent: The parent path in the tree
35 Parent *string `json:"parent,omitempty" cborgen:"parent,omitempty"`
36 // readme: Readme for this file tree
···45 Contents string `json:"contents" cborgen:"contents"`
46 // filename: Name of the readme file
47 Filename string `json:"filename" cborgen:"filename"`
48+}
49+50+// RepoTree_Signature is a "signature" in the sh.tangled.repo.tree schema.
51+type RepoTree_Signature struct {
52+ // email: Author email
53+ Email string `json:"email" cborgen:"email"`
54+ // name: Author name
55+ Name string `json:"name" cborgen:"name"`
56+ // when: Author timestamp
57+ When string `json:"when" cborgen:"when"`
58}
5960// RepoTree_TreeEntry is a "treeEntry" in the sh.tangled.repo.tree schema.
+12-1
appview/config/config.go
···13 CookieSecret string `env:"COOKIE_SECRET, default=00000000000000000000000000000000"`
14 DbPath string `env:"DB_PATH, default=appview.db"`
15 ListenAddr string `env:"LISTEN_ADDR, default=0.0.0.0:3000"`
16- AppviewHost string `env:"APPVIEW_HOST, default=https://tangled.org"`
17 AppviewName string `env:"APPVIEW_Name, default=Tangled"`
18 Dev bool `env:"DEV, default=false"`
19 DisallowedNicknamesFile string `env:"DISALLOWED_NICKNAMES_FILE"`
···2324 // uhhhh this is because knot1 is under icy's did
25 TmpAltAppPassword string `env:"ALT_APP_PASSWORD"`
0000000000026}
2728type OAuthConfig struct {
···260 did text not null,
261262 -- data
0263 description text not null,
264 include_bluesky integer not null default 0,
265 location text,
···1078 // transfer data, constructing pull_at from pulls table
1079 _, err = tx.Exec(`
1080 insert into pull_submissions_new (id, pull_at, round_number, patch, created)
1081- select
1082 ps.id,
1083 'at://' || p.owner_did || '/sh.tangled.repo.pull/' || p.rkey,
1084 ps.round_number,
···11691170 create index if not exists idx_stars_created on stars(created);
1171 create index if not exists idx_stars_subject_at_created on stars(subject_at, created);
00000000000000000000000000000001172 `)
1173 return err
1174 })
···260 did text not null,
261262 -- data
263+ avatar text,
264 description text not null,
265 include_bluesky integer not null default 0,
266 location text,
···1079 // transfer data, constructing pull_at from pulls table
1080 _, err = tx.Exec(`
1081 insert into pull_submissions_new (id, pull_at, round_number, patch, created)
1082+ select
1083 ps.id,
1084 'at://' || p.owner_did || '/sh.tangled.repo.pull/' || p.rkey,
1085 ps.round_number,
···11701171 create index if not exists idx_stars_created on stars(created);
1172 create index if not exists idx_stars_subject_at_created on stars(subject_at, created);
1173+ `)
1174+ return err
1175+ })
1176+1177+ orm.RunMigration(conn, logger, "add-avatar-to-profile", func(tx *sql.Tx) error {
1178+ _, err := tx.Exec(`
1179+ alter table profile add column avatar text;
1180+ `)
1181+ return err
1182+ })
1183+1184+ orm.RunMigration(conn, logger, "remove-profile-stats-column-constraint", func(tx *sql.Tx) error {
1185+ _, err := tx.Exec(`
1186+ -- create new table without the check constraint
1187+ create table profile_stats_new (
1188+ id integer primary key autoincrement,
1189+ did text not null,
1190+ kind text not null, -- no constraint this time
1191+ foreign key (did) references profile(did) on delete cascade
1192+ );
1193+1194+ -- copy data from old table
1195+ insert into profile_stats_new (id, did, kind)
1196+ select id, did, kind
1197+ from profile_stats;
1198+1199+ -- drop old table
1200+ drop table profile_stats;
1201+1202+ -- rename new table
1203+ alter table profile_stats_new rename to profile_stats;
1204 `)
1205 return err
1206 })
+40-3
appview/db/profile.go
···98 })
99 }
10000000000000000000000000101 return &timeline, nil
102}
103···135 _, err = tx.Exec(
136 `insert or replace into profile (
137 did,
0138 description,
139 include_bluesky,
140 location,
141 pronouns
142 )
143- values (?, ?, ?, ?, ?)`,
144 profile.Did,
0145 profile.Description,
146 includeBskyValue,
147 profile.Location,
···324func GetProfile(e Execer, did string) (*models.Profile, error) {
325 var profile models.Profile
326 var pronouns sql.Null[string]
0327328 profile.Did = did
329330 includeBluesky := 0
331332 err := e.QueryRow(
333- `select description, include_bluesky, location, pronouns from profile where did = ?`,
334 did,
335- ).Scan(&profile.Description, &includeBluesky, &profile.Location, &pronouns)
336 if err == sql.ErrNoRows {
337 profile := models.Profile{}
338 profile.Did = did
···349350 if pronouns.Valid {
351 profile.Pronouns = pronouns.V
0000352 }
353354 rows, err := e.Query(`select link from profile_links where did = ?`, did)
···420 case models.VanityStatRepositoryCount:
421 query = `select count(id) from repos where did = ?`
422 args = append(args, did)
0000000423 }
424425 var result uint64
···98 })
99 }
100101+ punchcard, err := MakePunchcard(
102+ e,
103+ orm.FilterEq("did", forDid),
104+ orm.FilterGte("date", time.Now().AddDate(0, -TimeframeMonths, 0)),
105+ )
106+ if err != nil {
107+ return nil, fmt.Errorf("error getting commits by did: %w", err)
108+ }
109+ for _, punch := range punchcard.Punches {
110+ if punch.Date.After(now) {
111+ continue
112+ }
113+114+ monthsAgo := monthsBetween(punch.Date, now)
115+ if monthsAgo >= TimeframeMonths {
116+ // shouldn't happen; but times are weird
117+ continue
118+ }
119+120+ idx := monthsAgo
121+ timeline.ByMonth[idx].Commits += punch.Count
122+ }
123+124 return &timeline, nil
125}
126···158 _, err = tx.Exec(
159 `insert or replace into profile (
160 did,
161+ avatar,
162 description,
163 include_bluesky,
164 location,
165 pronouns
166 )
167+ values (?, ?, ?, ?, ?, ?)`,
168 profile.Did,
169+ profile.Avatar,
170 profile.Description,
171 includeBskyValue,
172 profile.Location,
···349func GetProfile(e Execer, did string) (*models.Profile, error) {
350 var profile models.Profile
351 var pronouns sql.Null[string]
352+ var avatar sql.Null[string]
353354 profile.Did = did
355356 includeBluesky := 0
357358 err := e.QueryRow(
359+ `select avatar, description, include_bluesky, location, pronouns from profile where did = ?`,
360 did,
361+ ).Scan(&avatar, &profile.Description, &includeBluesky, &profile.Location, &pronouns)
362 if err == sql.ErrNoRows {
363 profile := models.Profile{}
364 profile.Did = did
···375376 if pronouns.Valid {
377 profile.Pronouns = pronouns.V
378+ }
379+380+ if avatar.Valid {
381+ profile.Avatar = avatar.V
382 }
383384 rows, err := e.Query(`select link from profile_links where did = ?`, did)
···450 case models.VanityStatRepositoryCount:
451 query = `select count(id) from repos where did = ?`
452 args = append(args, did)
453+ case models.VanityStatStarCount:
454+ query = `select count(id) from stars where subject_at like 'at://' || ? || '%'`
455+ args = append(args, did)
456+ case models.VanityStatNone:
457+ return 0, nil
458+ default:
459+ return 0, fmt.Errorf("invalid vanity stat kind: %s", stat)
460 }
461462 var result uint64
+7-1
appview/ingester.go
···285 return err
286 }
28700000288 description := ""
289 if record.Description != nil {
290 description = *record.Description
···312 var stats [2]models.VanityStat
313 for i, s := range record.Stats {
314 if i < 2 {
315- stats[i].Kind = models.VanityStatKind(s)
316 }
317 }
318···325326 profile := models.Profile{
327 Did: did,
0328 Description: description,
329 IncludeBluesky: includeBluesky,
330 Location: location,
···285 return err
286 }
287288+ avatar := ""
289+ if record.Avatar != nil {
290+ avatar = record.Avatar.Ref.String()
291+ }
292+293 description := ""
294 if record.Description != nil {
295 description = *record.Description
···317 var stats [2]models.VanityStat
318 for i, s := range record.Stats {
319 if i < 2 {
320+ stats[i].Kind = models.ParseVanityStatKind(s)
321 }
322 }
323···330331 profile := models.Profile{
332 Did: did,
333+ Avatar: avatar,
334 Description: description,
335 IncludeBluesky: includeBluesky,
336 Location: location,
···13 Did string
1415 // data
16+ Avatar string // CID of the avatar blob
17 Description string
18 IncludeBluesky bool
19 Location string
···59 VanityStatOpenIssueCount VanityStatKind = "open-issue-count"
60 VanityStatClosedIssueCount VanityStatKind = "closed-issue-count"
61 VanityStatRepositoryCount VanityStatKind = "repository-count"
62+ VanityStatStarCount VanityStatKind = "star-count"
63+ VanityStatNone VanityStatKind = ""
64)
6566+func ParseVanityStatKind(s string) VanityStatKind {
67+ switch s {
68+ case "merged-pull-request-count":
69+ return VanityStatMergedPRCount
70+ case "closed-pull-request-count":
71+ return VanityStatClosedPRCount
72+ case "open-pull-request-count":
73+ return VanityStatOpenPRCount
74+ case "open-issue-count":
75+ return VanityStatOpenIssueCount
76+ case "closed-issue-count":
77+ return VanityStatClosedIssueCount
78+ case "repository-count":
79+ return VanityStatRepositoryCount
80+ case "star-count":
81+ return VanityStatStarCount
82+ default:
83+ return VanityStatNone
84+ }
85+}
86+87func (v VanityStatKind) String() string {
88 switch v {
89 case VanityStatMergedPRCount:
···98 return "Closed Issues"
99 case VanityStatRepositoryCount:
100 return "Repositories"
101+ case VanityStatStarCount:
102+ return "Stars Received"
103+ default:
104+ return ""
105 }
0106}
107108type VanityStat struct {
+4-1
appview/models/repo.go
···130131 // current display mode
132 ShowingRendered bool // currently in rendered mode
133- ShowingText bool // currently in text/code mode
134135 // content type flags
136 ContentType BlobContentType
···151 // no view available, only raw
152 return !(b.HasRenderedView || b.HasTextView)
153}
0000
···130131 // current display mode
132 ShowingRendered bool // currently in rendered mode
0133134 // content type flags
135 ContentType BlobContentType
···150 // no view available, only raw
151 return !(b.HasRenderedView || b.HasTextView)
152}
153+154+func (b BlobView) ShowingText() bool {
155+ return !b.ShowingRendered
156+}
···5051 for _, tt := range tests {
52 t.Run(tt.name, func(t *testing.T) {
53- md := NewMarkdown()
5455 var buf bytes.Buffer
56 if err := md.Convert([]byte(tt.markdown), &buf); err != nil {
···105106 for _, tt := range tests {
107 t.Run(tt.name, func(t *testing.T) {
108- md := NewMarkdown()
109110 var buf bytes.Buffer
111 if err := md.Convert([]byte(tt.markdown), &buf); err != nil {
···5051 for _, tt := range tests {
52 t.Run(tt.name, func(t *testing.T) {
53+ md := NewMarkdown("tangled.org")
5455 var buf bytes.Buffer
56 if err := md.Convert([]byte(tt.markdown), &buf); err != nil {
···105106 for _, tt := range tests {
107 t.Run(tt.name, func(t *testing.T) {
108+ md := NewMarkdown("tangled.org")
109110 var buf bytes.Buffer
111 if err := md.Convert([]byte(tt.markdown), &buf); err != nil {
+4-7
appview/pages/markup/reference_link.go
···18// like issues, PRs, comments or even @-mentions
19// This funciton doesn't actually check for the existence of records in the DB
20// or the PDS; it merely returns a list of what are presumed to be references.
21-func FindReferences(baseUrl string, source string) ([]string, []models.ReferenceLink) {
22 var (
23 refLinkSet = make(map[models.ReferenceLink]struct{})
24 mentionsSet = make(map[string]struct{})
25- md = NewMarkdown()
26 sourceBytes = []byte(source)
27 root = md.Parser().Parse(text.NewReader(sourceBytes))
28 )
29- // trim url scheme. the SSL shouldn't matter
30- baseUrl = strings.TrimPrefix(baseUrl, "https://")
31- baseUrl = strings.TrimPrefix(baseUrl, "http://")
3233 ast.Walk(root, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
34 if !entering {
···41 return ast.WalkSkipChildren, nil
42 case ast.KindLink:
43 dest := string(n.(*ast.Link).Destination)
44- ref := parseTangledLink(baseUrl, dest)
45 if ref != nil {
46 refLinkSet[*ref] = struct{}{}
47 }
···50 an := n.(*ast.AutoLink)
51 if an.AutoLinkType == ast.AutoLinkURL {
52 dest := string(an.URL(sourceBytes))
53- ref := parseTangledLink(baseUrl, dest)
54 if ref != nil {
55 refLinkSet[*ref] = struct{}{}
56 }
···18// like issues, PRs, comments or even @-mentions
19// This funciton doesn't actually check for the existence of records in the DB
20// or the PDS; it merely returns a list of what are presumed to be references.
21+func FindReferences(host string, source string) ([]string, []models.ReferenceLink) {
22 var (
23 refLinkSet = make(map[models.ReferenceLink]struct{})
24 mentionsSet = make(map[string]struct{})
25+ md = NewMarkdown(host)
26 sourceBytes = []byte(source)
27 root = md.Parser().Parse(text.NewReader(sourceBytes))
28 )
0002930 ast.Walk(root, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
31 if !entering {
···38 return ast.WalkSkipChildren, nil
39 case ast.KindLink:
40 dest := string(n.(*ast.Link).Destination)
41+ ref := parseTangledLink(host, dest)
42 if ref != nil {
43 refLinkSet[*ref] = struct{}{}
44 }
···47 an := n.(*ast.AutoLink)
48 if an.AutoLinkType == ast.AutoLinkURL {
49 dest := string(an.URL(sourceBytes))
50+ ref := parseTangledLink(host, dest)
51 if ref != nil {
52 refLinkSet[*ref] = struct{}{}
53 }
···30 <div class="mx-6">
31 These services may not be fully accessible until upgraded.
32 <a class="underline text-red-800 dark:text-red-200"
33- href="https://docs.tangled.org/migrating-knots-spindles.html#migrating-knots-spindles">
34 Click to read the upgrade guide</a>.
35 </div>
36 </details>
···30 <div class="mx-6">
31 These services may not be fully accessible until upgraded.
32 <a class="underline text-red-800 dark:text-red-200"
33+ href="https://docs.tangled.org/migrating-knots-and-spindles.html">
34 Click to read the upgrade guide</a>.
35 </div>
36 </details>
···21 <div class="col-span-1 md:col-span-2">
22 <h2 class="text-sm pb-2 uppercase font-bold">SSH Keys</h2>
23 <p class="text-gray-500 dark:text-gray-400">
24- SSH public keys added here will be broadcasted to knots that you are a member of,
25 allowing you to push to repositories there.
26 </p>
27 </div>
···63 hx-swap="none"
64 class="flex flex-col gap-2"
65>
66- <p class="uppercase p-0">ADD SSH KEY</p>
0067 <p class="text-sm text-gray-500 dark:text-gray-400">SSH keys allow you to push to repositories in knots you're a member of.</p>
68 <input
69 type="text"
···21 <div class="col-span-1 md:col-span-2">
22 <h2 class="text-sm pb-2 uppercase font-bold">SSH Keys</h2>
23 <p class="text-gray-500 dark:text-gray-400">
24+ SSH public keys added here will be broadcasted to knots that you are a member of,
25 allowing you to push to repositories there.
26 </p>
27 </div>
···63 hx-swap="none"
64 class="flex flex-col gap-2"
65>
66+ <label for="key-name" class="uppercase p-0">
67+ add ssh key
68+ </label>
69 <p class="text-sm text-gray-500 dark:text-gray-400">SSH keys allow you to push to repositories in knots you're a member of.</p>
70 <input
71 type="text"
+43-60
appview/pages/templates/user/signup.html
···1-{{ define "user/signup" }}
2- <!doctype html>
3- <html lang="en" class="dark:bg-gray-900">
4- <head>
5- <meta charset="UTF-8" />
6- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7- <meta property="og:title" content="signup ยท tangled" />
8- <meta property="og:url" content="https://tangled.org/signup" />
9- <meta property="og:description" content="sign up for tangled" />
10- <script src="/static/htmx.min.js"></script>
11- <link rel="manifest" href="/pwa-manifest.json" />
12- <link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" />
13- <title>sign up · tangled</title>
1415- <script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
16- </head>
17- <body class="flex items-center justify-center min-h-screen">
18- <main class="max-w-md px-6 -mt-4">
19- <h1 class="flex place-content-center text-2xl font-semibold italic dark:text-white" >
20- {{ template "fragments/logotype" }}
21- </h1>
22- <h2 class="text-center text-xl italic dark:text-white">tightly-knit social coding.</h2>
23- <form
24- class="mt-4 max-w-sm mx-auto"
25- hx-post="/signup"
26- hx-swap="none"
27- hx-disabled-elt="#signup-button"
28- >
29- <div class="flex flex-col mt-2">
30- <label for="email">email</label>
31- <input
32- type="email"
33- id="email"
34- name="email"
35- tabindex="4"
36- required
37- placeholder="jason@bourne.co"
38- />
39- </div>
40- <span class="text-sm text-gray-500 mt-1">
41- You will receive an email with an invite code. Enter your
42- invite code, desired username, and password in the next
43- page to complete your registration.
44- </span>
45- <div class="w-full mt-4 text-center">
46- <div class="cf-turnstile" data-sitekey="{{ .CloudflareSiteKey }}" data-size="flexible"></div>
47- </div>
48- <button class="btn text-base w-full my-2 mt-6" type="submit" id="signup-button" tabindex="7" >
49- <span>join now</span>
50- </button>
51- <p class="text-sm text-gray-500">
52- Already have an AT Protocol account? <a href="/login" class="underline">Login to Tangled</a>.
53- </p>
5455- <p id="signup-msg" class="error w-full"></p>
56- <p class="text-sm text-gray-500 pt-4">
57- By signing up, you agree to our <a href="/terms" class="underline">Terms of Service</a> and <a href="/privacy" class="underline">Privacy Policy</a>.
58- </p>
59- </form>
60- </main>
61- </body>
62- </html>
63{{ end }}
···1+{{ define "title" }} signup {{ end }}
00000000000023+{{ define "extrameta" }}
4+ <script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
5+{{ end }}
6+7+{{ define "content" }}
8+ <form
9+ class="mt-4 max-w-sm mx-auto group"
10+ hx-post="/signup"
11+ hx-swap="none"
12+ hx-disabled-elt="#signup-button"
13+ >
14+ <div class="flex flex-col mt-2">
15+ <label for="email">email</label>
16+ <input
17+ type="email"
18+ id="email"
19+ name="email"
20+ tabindex="4"
21+ required
22+ placeholder="jason@bourne.co"
23+ />
24+ </div>
25+ <span class="text-sm text-gray-500 mt-1">
26+ You will receive an email with an invite code. Enter your
27+ invite code, desired username, and password in the next
28+ page to complete your registration.
29+ </span>
30+ <div class="w-full mt-4 text-center">
31+ <div class="cf-turnstile" data-sitekey="{{ .CloudflareSiteKey }}" data-size="flexible"></div>
32+ </div>
33+ <button class="btn text-base w-full my-2 mt-6" type="submit" id="signup-button" tabindex="7" >
34+ {{ i "loader-circle" "size-4 animate-spin hidden group-[.htmx-request]:inline" }}
35+ <span class="inline group-[.htmx-request]:hidden">join now</span>
36+ </button>
37+ <p class="text-sm text-gray-500">
38+ Already have an AT Protocol account? <a href="/login" class="underline">Login to Tangled</a>.
39+ </p>
004041+ <p id="signup-msg" class="error w-full"></p>
42+ <p class="text-sm text-gray-500 pt-4">
43+ By signing up, you agree to our <a href="/terms" class="underline">Terms of Service</a> and <a href="/privacy" class="underline">Privacy Policy</a>.
44+ </p>
45+ </form>
00046{{ end }}
···0000000000000000000000000000000001export default {
2 async fetch(request, env) {
3 // Helper function to generate a color from a string
···14 return color;
15 };
16000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000017 const url = new URL(request.url);
18 const { pathname, searchParams } = url;
1920 if (!pathname || pathname === "/") {
21- return new Response(`This is Tangled's avatar service. It fetches your pretty avatar from Bluesky and caches it on Cloudflare.
22-You can't use this directly unfortunately since all requests are signed and may only originate from the appview.`);
0023 }
2425 const size = searchParams.get("size");
···68 }
6970 try {
71- const profileResponse = await fetch(
72- `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${actor}`,
73- );
74- const profile = await profileResponse.json();
75- const avatar = profile.avatar;
000000000007677- let avatarUrl = profile.avatar;
00007879 if (!avatarUrl) {
80 // Generate a random color based on the actor string
00000081 const bgColor = stringToColor(actor);
82 const size = resizeToTiny ? 32 : 128;
83 const svg = `<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" xmlns="http://www.w3.org/2000/svg"><rect width="${size}" height="${size}" fill="${bgColor}"/></svg>`;
···93 return response;
94 }
9596- // Resize if requested
97 let avatarResponse;
98 if (resizeToTiny) {
99 avatarResponse = await fetch(avatarUrl, {
···1+import {
2+ LocalActorResolver,
3+ CompositeHandleResolver,
4+ DohJsonHandleResolver,
5+ WellKnownHandleResolver,
6+ CompositeDidDocumentResolver,
7+ PlcDidDocumentResolver,
8+ WebDidDocumentResolver,
9+} from "@atcute/identity-resolver";
10+11+// Initialize resolvers for Cloudflare Workers
12+const handleResolver = new CompositeHandleResolver({
13+ strategy: "race",
14+ methods: {
15+ dns: new DohJsonHandleResolver({
16+ dohUrl: "https://cloudflare-dns.com/dns-query",
17+ }),
18+ http: new WellKnownHandleResolver(),
19+ },
20+});
21+22+const didDocumentResolver = new CompositeDidDocumentResolver({
23+ methods: {
24+ plc: new PlcDidDocumentResolver(),
25+ web: new WebDidDocumentResolver(),
26+ },
27+});
28+29+const actorResolver = new LocalActorResolver({
30+ handleResolver,
31+ didDocumentResolver,
32+});
33+34export default {
35 async fetch(request, env) {
36 // Helper function to generate a color from a string
···47 return color;
48 };
4950+ // Helper function to fetch Tangled profile from PDS
51+ const getTangledAvatarFromPDS = async (actor) => {
52+ try {
53+ // Resolve the identity
54+ const identity = await actorResolver.resolve(actor);
55+ if (!identity) {
56+ console.log({
57+ level: "debug",
58+ message: "failed to resolve identity",
59+ actor: actor,
60+ });
61+ return null;
62+ }
63+64+ const did = identity.did;
65+ const pdsEndpoint = identity.pds.replace(/\/$/, ""); // Remove trailing slash
66+67+ if (!pdsEndpoint) {
68+ console.log({
69+ level: "debug",
70+ message: "no PDS endpoint found",
71+ actor: actor,
72+ did: did,
73+ });
74+ return null;
75+ }
76+77+ const profileUrl = `${pdsEndpoint}/xrpc/com.atproto.repo.getRecord?repo=${did}&collection=sh.tangled.actor.profile&rkey=self`;
78+79+ // Fetch the Tangled profile record from PDS
80+ const profileResponse = await fetch(profileUrl);
81+82+ if (!profileResponse.ok) {
83+ console.log({
84+ level: "debug",
85+ message: "no Tangled profile found on PDS",
86+ actor: actor,
87+ status: profileResponse.status,
88+ });
89+ return null;
90+ }
91+92+ const profileData = await profileResponse.json();
93+ const avatarBlob = profileData?.value?.avatar;
94+95+ if (!avatarBlob) {
96+ console.log({
97+ level: "debug",
98+ message: "Tangled profile has no avatar",
99+ actor: actor,
100+ });
101+ return null;
102+ }
103+104+ // Extract CID from blob reference object
105+ // The ref might be an object with $link property or a string
106+ let avatarCID;
107+ if (typeof avatarBlob.ref === "string") {
108+ avatarCID = avatarBlob.ref;
109+ } else if (avatarBlob.ref?.$link) {
110+ avatarCID = avatarBlob.ref.$link;
111+ } else if (typeof avatarBlob === "string") {
112+ avatarCID = avatarBlob;
113+ }
114+115+ if (!avatarCID || typeof avatarCID !== "string") {
116+ console.log({
117+ level: "warn",
118+ message: "could not extract valid CID from avatar blob",
119+ actor: actor,
120+ avatarBlob: avatarBlob,
121+ avatarBlobRef: avatarBlob.ref,
122+ });
123+ return null;
124+ }
125+126+ // Construct blob URL (pdsEndpoint already has trailing slash removed)
127+ const blobUrl = `${pdsEndpoint}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${avatarCID}`;
128+129+ return blobUrl;
130+ } catch (e) {
131+ console.log({
132+ level: "warn",
133+ message: "error fetching Tangled avatar from PDS",
134+ actor: actor,
135+ error: e.message,
136+ });
137+ return null;
138+ }
139+ };
140+141 const url = new URL(request.url);
142 const { pathname, searchParams } = url;
143144 if (!pathname || pathname === "/") {
145+ return new Response(
146+ `This is Tangled's avatar service. It fetches your pretty avatar from your PDS, Bluesky, or generates a placeholder.
147+You can't use this directly unfortunately since all requests are signed and may only originate from the appview.`,
148+ );
149 }
150151 const size = searchParams.get("size");
···194 }
195196 try {
197+ let avatarUrl = null;
198+199+ // Try to get Tangled avatar from user's PDS first
200+ avatarUrl = await getTangledAvatarFromPDS(actor);
201+202+ // If no Tangled avatar, fall back to Bluesky
203+ if (!avatarUrl) {
204+ console.log({
205+ level: "debug",
206+ message: "no Tangled avatar, falling back to Bluesky",
207+ actor: actor,
208+ });
209+210+ const profileResponse = await fetch(
211+ `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${actor}`,
212+ );
213214+ if (profileResponse.ok) {
215+ const profile = await profileResponse.json();
216+ avatarUrl = profile.avatar;
217+ }
218+ }
219220 if (!avatarUrl) {
221 // Generate a random color based on the actor string
222+ console.log({
223+ level: "debug",
224+ message: "no avatar found, generating placeholder",
225+ actor: actor,
226+ });
227+228 const bgColor = stringToColor(actor);
229 const size = resizeToTiny ? 32 : 128;
230 const svg = `<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" xmlns="http://www.w3.org/2000/svg"><rect width="${size}" height="${size}" fill="${bgColor}"/></svg>`;
···240 return response;
241 }
242243+ // Fetch and optionally resize the avatar
244 let avatarResponse;
245 if (resizeToTiny) {
246 avatarResponse = await fetch(avatarUrl, {
···375KNOT_SERVER_LISTEN_ADDR=127.0.0.1:5555
376```
377378-If you run a Linux distribution that uses systemd, you can use the provided
379-service file to run the server. Copy
380-[`knotserver.service`](/systemd/knotserver.service)
381to `/etc/systemd/system/`. Then, run:
382383```
···501502Note that you should add a newline at the end if setting a non-empty message
503since the knot won't do this for you.
00000000000000000000000000000504505# Spindles
506···692 NODE_ENV: "production"
693 MY_ENV_VAR: "MY_ENV_VALUE"
694```
000000000000000000000000000000000695696### Steps
697···1528Refer to the [jujutsu
1529documentation](https://jj-vcs.github.io/jj/latest/config/#commit-trailers)
1530for more information.
000000000000000000000000000000000000000000000000000000
···375KNOT_SERVER_LISTEN_ADDR=127.0.0.1:5555
376```
377378+If you run a Linux distribution that uses systemd, you can
379+use the provided service file to run the server. Copy
380+[`knotserver.service`](https://tangled.org/tangled.org/core/blob/master/systemd/knotserver.service)
381to `/etc/systemd/system/`. Then, run:
382383```
···501502Note that you should add a newline at the end if setting a non-empty message
503since the knot won't do this for you.
504+505+## Troubleshooting
506+507+If you run your own knot, you may run into some of these
508+common issues. You can always join the
509+[IRC](https://web.libera.chat/#tangled) or
510+[Discord](https://chat.tangled.org/) if this section does
511+not help.
512+513+### Unable to push
514+515+If you are unable to push to your knot or repository:
516+517+1. First, ensure that you have added your SSH public key to
518+ your account
519+2. Check to see that your knot has synced the key by running
520+ `knot keys`
521+3. Check to see if git is supplying the correct private key
522+ when pushing: `GIT_SSH_COMMAND="ssh -v" git push ...`
523+4. Check to see if `sshd` on the knot is rejecting the push
524+ for some reason: `journalctl -xeu ssh` (or `sshd`,
525+ depending on your machine). These logs are unavailable if
526+ using docker.
527+5. Check to see if the knot itself is rejecting the push,
528+ depending on your setup, the logs might be in one of the
529+ following paths:
530+ * `/tmp/knotguard.log`
531+ * `/home/git/log`
532+ * `/home/git/guard.log`
533534# Spindles
535···721 NODE_ENV: "production"
722 MY_ENV_VAR: "MY_ENV_VALUE"
723```
724+725+By default, the following environment variables set:
726+727+- `CI` - Always set to `true` to indicate a CI environment
728+- `TANGLED_PIPELINE_ID` - The AT URI of the current pipeline
729+- `TANGLED_REPO_KNOT` - The repository's knot hostname
730+- `TANGLED_REPO_DID` - The DID of the repository owner
731+- `TANGLED_REPO_NAME` - The name of the repository
732+- `TANGLED_REPO_DEFAULT_BRANCH` - The default branch of the
733+ repository
734+- `TANGLED_REPO_URL` - The full URL to the repository
735+736+These variables are only available when the pipeline is
737+triggered by a push:
738+739+- `TANGLED_REF` - The full git reference (e.g.,
740+ `refs/heads/main` or `refs/tags/v1.0.0`)
741+- `TANGLED_REF_NAME` - The short name of the reference
742+ (e.g., `main` or `v1.0.0`)
743+- `TANGLED_REF_TYPE` - The type of reference, either
744+ `branch` or `tag`
745+- `TANGLED_SHA` - The commit SHA that triggered the pipeline
746+- `TANGLED_COMMIT_SHA` - Alias for `TANGLED_SHA`
747+748+These variables are only available when the pipeline is
749+triggered by a pull request:
750+751+- `TANGLED_PR_SOURCE_BRANCH` - The source branch of the pull
752+ request
753+- `TANGLED_PR_TARGET_BRANCH` - The target branch of the pull
754+ request
755+- `TANGLED_PR_SOURCE_SHA` - The commit SHA of the source
756+ branch
757758### Steps
759···1590Refer to the [jujutsu
1591documentation](https://jj-vcs.github.io/jj/latest/config/#commit-trailers)
1592for more information.
1593+1594+# Troubleshooting guide
1595+1596+## Login issues
1597+1598+Owing to the distributed nature of OAuth on AT Protocol, you
1599+may run into issues with logging in. If you run a
1600+self-hosted PDS:
1601+1602+- You may need to ensure that your PDS is timesynced using
1603+ NTP:
1604+ * Enable the `ntpd` service
1605+ * Run `ntpd -qg` to synchronize your clock
1606+- You may need to increase the default request timeout:
1607+ `NODE_OPTIONS="--network-family-autoselection-attempt-timeout=500"`
1608+1609+## Empty punchcard
1610+1611+For Tangled to register commits that you make across the
1612+network, you need to setup one of following:
1613+1614+- The committer email should be a verified email associated
1615+ to your account. You can add and verify emails on the
1616+ settings page.
1617+- Or, the committer email should be set to your account's
1618+ DID: `git config user.email "did:plc:foobar". You can find
1619+ your account's DID on the settings page
1620+1621+## Commit is not marked as verified
1622+1623+Presently, Tangled only supports SSH commit signatures.
1624+1625+To sign commits using an SSH key with git:
1626+1627+```
1628+git config --global gpg.format ssh
1629+git config --global user.signingkey ~/.ssh/tangled-key
1630+```
1631+1632+To sign commits using an SSH key with jj, add this to your
1633+config:
1634+1635+```
1636+[signing]
1637+behavior = "own"
1638+backend = "ssh"
1639+key = "~/.ssh/tangled-key"
1640+```
1641+1642+## Self-hosted knot issues
1643+1644+If you need help troubleshooting a self-hosted knot, check
1645+out the [knot troubleshooting
1646+guide](/knot-self-hosting-guide.html#troubleshooting).