···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.
···7 "tangled.org/core/api/tangled"
8)
900000000000000000010type Profile struct {
11 // ids
12 ID int
13 Did string
1415 // data
16+ Avatar string // CID of the avatar blob
17+ Description string
18+ IncludeBluesky bool
19+ Location string
20+ Links [5]string
21+ Stats [2]VanityStat
22+ PinnedRepos [6]syntax.ATURI
23+ Pronouns string
24}
2526func (p Profile) IsLinksEmpty() bool {
+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+}
+52
appview/oauth/handler.go
···10 "slices"
11 "time"
12013 "github.com/bluesky-social/indigo/atproto/auth/oauth"
014 "github.com/go-chi/chi/v5"
15 "github.com/posthog/posthog-go"
16 "tangled.org/core/api/tangled"
17 "tangled.org/core/appview/db"
018 "tangled.org/core/consts"
19 "tangled.org/core/orm"
20 "tangled.org/core/tid"
···82 }
8384 o.Logger.Debug("session saved successfully")
085 go o.addToDefaultKnot(sessData.AccountDID.String())
86 go o.addToDefaultSpindle(sessData.AccountDID.String())
08788 if !o.Config.Core.Dev {
89 err = o.Posthog.Enqueue(posthog.Capture{
···187 }
188189 l.Debug("successfully addeds to default Knot")
00000000000000000000000000000000000000000000000190}
191192// create a session using apppasswords
···10 "slices"
11 "time"
1213+ comatproto "github.com/bluesky-social/indigo/api/atproto"
14 "github.com/bluesky-social/indigo/atproto/auth/oauth"
15+ lexutil "github.com/bluesky-social/indigo/lex/util"
16 "github.com/go-chi/chi/v5"
17 "github.com/posthog/posthog-go"
18 "tangled.org/core/api/tangled"
19 "tangled.org/core/appview/db"
20+ "tangled.org/core/appview/models"
21 "tangled.org/core/consts"
22 "tangled.org/core/orm"
23 "tangled.org/core/tid"
···85 }
8687 o.Logger.Debug("session saved successfully")
88+89 go o.addToDefaultKnot(sessData.AccountDID.String())
90 go o.addToDefaultSpindle(sessData.AccountDID.String())
91+ go o.ensureTangledProfile(sessData)
9293 if !o.Config.Core.Dev {
94 err = o.Posthog.Enqueue(posthog.Capture{
···192 }
193194 l.Debug("successfully addeds to default Knot")
195+}
196+197+func (o *OAuth) ensureTangledProfile(sessData *oauth.ClientSessionData) {
198+ ctx := context.Background()
199+ did := sessData.AccountDID.String()
200+ l := o.Logger.With("did", did)
201+202+ _, err := db.GetProfile(o.Db, did)
203+ if err == nil {
204+ l.Debug("profile already exists in DB")
205+ return
206+ }
207+208+ l.Debug("creating empty Tangled profile")
209+210+ sess, err := o.ClientApp.ResumeSession(ctx, sessData.AccountDID, sessData.SessionID)
211+ if err != nil {
212+ l.Error("failed to resume session for profile creation", "err", err)
213+ return
214+ }
215+ client := sess.APIClient()
216+217+ _, err = comatproto.RepoPutRecord(ctx, client, &comatproto.RepoPutRecord_Input{
218+ Collection: tangled.ActorProfileNSID,
219+ Repo: did,
220+ Rkey: "self",
221+ Record: &lexutil.LexiconTypeDecoder{Val: &tangled.ActorProfile{}},
222+ })
223+224+ if err != nil {
225+ l.Error("failed to create empty profile on PDS", "err", err)
226+ return
227+ }
228+229+ tx, err := o.Db.BeginTx(ctx, nil)
230+ if err != nil {
231+ l.Error("failed to start transaction", "err", err)
232+ return
233+ }
234+235+ emptyProfile := &models.Profile{Did: did}
236+ if err := db.UpsertProfile(tx, emptyProfile); err != nil {
237+ l.Error("failed to create empty profile in DB", "err", err)
238+ return
239+ }
240+241+ l.Debug("successfully created empty Tangled profile on PDS and DB")
242}
243244// create a session using apppasswords
···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"
···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, {