From 3be7368d17a82af63049777b5fe6fa6dd6385d86 Mon Sep 17 00:00:00 2001 From: scanash00 Date: Sun, 25 Jan 2026 02:16:26 -0900 Subject: [PATCH] Implement profile editing, links, bio, and cool stuff --- backend/cmd/server/main.go | 2 + backend/internal/api/profile.go | 150 ++++++++++++++++++ backend/internal/db/db.go | 58 +++++++ backend/internal/firehose/ingester.go | 57 +++++++ backend/internal/xrpc/records.go | 19 +++ backend/internal/xrpc/utils.go | 27 ++++ .../margin/authFull.json} | 3 +- lexicons/at/margin/profile.json | 40 +++++ web/src/api/client.js | 11 ++ web/src/components/EditProfileModal.jsx | 145 +++++++++++++++++ web/src/components/Icons.jsx | 26 +++ web/src/css/modals.css | 70 ++++++++ web/src/css/profile.css | 56 ++++++- web/src/pages/Profile.jsx | 129 +++++++++++++-- web/src/utils/formatting.js | 23 +++ 15 files changed, 797 insertions(+), 19 deletions(-) create mode 100644 backend/internal/api/profile.go rename lexicons/{at.margin.authFull.json => at/margin/authFull.json} (90%) create mode 100644 lexicons/at/margin/profile.json create mode 100644 web/src/components/EditProfileModal.jsx create mode 100644 web/src/utils/formatting.js diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index ee3034f..016b15b 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -109,6 +109,8 @@ func main() { r.Get("/{handle}/bookmark/{rkey}", ogHandler.HandleAnnotationPage) r.Get("/api/tags/trending", handler.HandleGetTrendingTags) + r.Put("/api/profile", handler.UpdateProfile) + r.Get("/api/profile/{did}", handler.GetProfile) r.Get("/collection/{uri}", ogHandler.HandleCollectionPage) r.Get("/{handle}/collection/{rkey}", ogHandler.HandleCollectionPage) diff --git a/backend/internal/api/profile.go b/backend/internal/api/profile.go new file mode 100644 index 0000000..602415f --- /dev/null +++ b/backend/internal/api/profile.go @@ -0,0 +1,150 @@ +package api + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "github.com/go-chi/chi/v5" + + "margin.at/internal/db" + "margin.at/internal/xrpc" +) + +type UpdateProfileRequest struct { + Bio string `json:"bio"` + Website string `json:"website"` + Links []string `json:"links"` +} + +func (h *Handler) UpdateProfile(w http.ResponseWriter, r *http.Request) { + session, err := h.refresher.GetSessionWithAutoRefresh(r) + if err != nil { + http.Error(w, err.Error(), http.StatusUnauthorized) + return + } + + var req UpdateProfileRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + record := &xrpc.MarginProfileRecord{ + Type: xrpc.CollectionProfile, + Bio: req.Bio, + Website: req.Website, + Links: req.Links, + CreatedAt: time.Now().UTC().Format(time.RFC3339), + } + + if err := record.Validate(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + err = h.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error { + _, err := client.PutRecord(r.Context(), did, xrpc.CollectionProfile, "self", record) + return err + }) + + if err != nil { + http.Error(w, "Failed to update profile: "+err.Error(), http.StatusInternalServerError) + return + } + + linksJSON, _ := json.Marshal(req.Links) + profile := &db.Profile{ + URI: fmt.Sprintf("at://%s/%s/self", session.DID, xrpc.CollectionProfile), + AuthorDID: session.DID, + Bio: &req.Bio, + Website: &req.Website, + LinksJSON: stringPtr(string(linksJSON)), + CreatedAt: time.Now(), + IndexedAt: time.Now(), + } + h.db.UpsertProfile(profile) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(req) +} + +func stringPtr(s string) *string { + return &s +} + +func (h *Handler) GetProfile(w http.ResponseWriter, r *http.Request) { + did := chi.URLParam(r, "did") + if decoded, err := url.QueryUnescape(did); err == nil { + did = decoded + } + + if did == "" { + http.Error(w, "DID required", http.StatusBadRequest) + return + } + + if !strings.HasPrefix(did, "did:") { + var resolvedDID string + err := h.db.QueryRow("SELECT did FROM sessions WHERE handle = $1 LIMIT 1", did).Scan(&resolvedDID) + if err == nil { + did = resolvedDID + } else { + resolvedDID, err = xrpc.ResolveHandle(did) + if err == nil { + did = resolvedDID + } + } + } + + profile, err := h.db.GetProfile(did) + if err != nil { + http.Error(w, "Failed to fetch profile", http.StatusInternalServerError) + return + } + + if profile == nil { + w.Header().Set("Content-Type", "application/json") + if did != "" && strings.HasPrefix(did, "did:") { + json.NewEncoder(w).Encode(map[string]string{"did": did}) + } else { + w.Write([]byte("{}")) + } + return + } + + resp := struct { + URI string `json:"uri"` + DID string `json:"did"` + Bio string `json:"bio"` + Website string `json:"website"` + Links []string `json:"links"` + CreatedAt string `json:"createdAt"` + IndexedAt string `json:"indexedAt"` + }{ + URI: profile.URI, + DID: profile.AuthorDID, + CreatedAt: profile.CreatedAt.Format(time.RFC3339), + IndexedAt: profile.IndexedAt.Format(time.RFC3339), + } + + if profile.Bio != nil { + resp.Bio = *profile.Bio + } + if profile.Website != nil { + resp.Website = *profile.Website + } + if profile.LinksJSON != nil && *profile.LinksJSON != "" { + _ = json.Unmarshal([]byte(*profile.LinksJSON), &resp.Links) + } + if resp.Links == nil { + resp.Links = []string{} + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) +} diff --git a/backend/internal/db/db.go b/backend/internal/db/db.go index bcfab5a..03a7f44 100644 --- a/backend/internal/db/db.go +++ b/backend/internal/db/db.go @@ -129,6 +129,17 @@ type APIKey struct { LastUsedAt *time.Time `json:"lastUsedAt,omitempty"` } +type Profile struct { + URI string `json:"uri"` + AuthorDID string `json:"authorDid"` + Bio *string `json:"bio,omitempty"` + Website *string `json:"website,omitempty"` + LinksJSON *string `json:"links,omitempty"` + CreatedAt time.Time `json:"createdAt"` + IndexedAt time.Time `json:"indexedAt"` + CID *string `json:"cid,omitempty"` +} + func New(dsn string) (*DB, error) { driver := "sqlite3" if strings.HasPrefix(dsn, "postgres://") || strings.HasPrefix(dsn, "postgresql://") { @@ -328,6 +339,18 @@ func (db *DB) Migrate() error { db.Exec(`CREATE INDEX IF NOT EXISTS idx_api_keys_owner ON api_keys(owner_did)`) db.Exec(`CREATE INDEX IF NOT EXISTS idx_api_keys_hash ON api_keys(key_hash)`) + db.Exec(`CREATE TABLE IF NOT EXISTS profiles ( + uri TEXT PRIMARY KEY, + author_did TEXT NOT NULL, + bio TEXT, + website TEXT, + links_json TEXT, + created_at ` + dateType + ` NOT NULL, + indexed_at ` + dateType + ` NOT NULL, + cid TEXT + )`) + db.Exec(`CREATE INDEX IF NOT EXISTS idx_profiles_author_did ON profiles(author_did)`) + db.runMigrations() db.Exec(`CREATE TABLE IF NOT EXISTS cursors ( @@ -365,6 +388,39 @@ func (db *DB) SetCursor(id string, cursor int64) error { return err } +func (db *DB) GetProfile(did string) (*Profile, error) { + var p Profile + err := db.QueryRow("SELECT uri, author_did, bio, website, links_json, created_at, indexed_at FROM profiles WHERE author_did = $1", did).Scan( + &p.URI, &p.AuthorDID, &p.Bio, &p.Website, &p.LinksJSON, &p.CreatedAt, &p.IndexedAt, + ) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + return &p, nil +} + +func (db *DB) UpsertProfile(p *Profile) error { + query := ` + INSERT INTO profiles (uri, author_did, bio, website, links_json, created_at, indexed_at) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT(uri) DO UPDATE SET + bio = EXCLUDED.bio, + website = EXCLUDED.website, + links_json = EXCLUDED.links_json, + indexed_at = EXCLUDED.indexed_at + ` + _, err := db.Exec(db.Rebind(query), p.URI, p.AuthorDID, p.Bio, p.Website, p.LinksJSON, p.CreatedAt, p.IndexedAt) + return err +} + +func (db *DB) DeleteProfile(uri string) error { + _, err := db.Exec("DELETE FROM profiles WHERE uri = $1", uri) + return err +} + func (db *DB) runMigrations() { db.Exec(`ALTER TABLE sessions ADD COLUMN dpop_key TEXT`) @@ -386,6 +442,8 @@ func (db *DB) runMigrations() { db.Exec(`UPDATE annotations SET target_title = title WHERE target_title IS NULL AND title IS NOT NULL`) db.Exec(`UPDATE annotations SET motivation = 'commenting' WHERE motivation IS NULL`) + db.Exec(`ALTER TABLE profiles ADD COLUMN website TEXT`) + if db.driver == "postgres" { db.Exec(`ALTER TABLE cursors ALTER COLUMN last_cursor TYPE BIGINT`) } diff --git a/backend/internal/firehose/ingester.go b/backend/internal/firehose/ingester.go index 1b6eca5..146922c 100644 --- a/backend/internal/firehose/ingester.go +++ b/backend/internal/firehose/ingester.go @@ -23,6 +23,7 @@ const ( CollectionLike = "at.margin.like" CollectionCollection = "at.margin.collection" CollectionCollectionItem = "at.margin.collectionItem" + CollectionProfile = "at.margin.profile" ) var RelayURL = "wss://jetstream2.us-east.bsky.network/subscribe" @@ -50,6 +51,7 @@ func NewIngester(database *db.DB, syncService *internal_sync.Service) *Ingester i.RegisterHandler(CollectionLike, i.handleLike) i.RegisterHandler(CollectionCollection, i.handleCollection) i.RegisterHandler(CollectionCollectionItem, i.handleCollectionItem) + i.RegisterHandler(CollectionProfile, i.handleProfile) return i } @@ -231,6 +233,8 @@ func (i *Ingester) handleDelete(collection, uri string) { i.db.DeleteCollection(uri) case CollectionCollectionItem: i.db.RemoveFromCollection(uri) + case CollectionProfile: + i.db.DeleteProfile(uri) } } @@ -630,3 +634,56 @@ func (i *Ingester) handleCollectionItem(event *FirehoseEvent) { log.Printf("Indexed collection item from %s", event.Repo) } } + +func (i *Ingester) handleProfile(event *FirehoseEvent) { + if event.Rkey != "self" { + return + } + + var record struct { + Bio string `json:"bio"` + Website string `json:"website"` + Links []string `json:"links"` + CreatedAt string `json:"createdAt"` + } + + if err := json.Unmarshal(event.Record, &record); err != nil { + return + } + + uri := fmt.Sprintf("at://%s/%s/%s", event.Repo, event.Collection, event.Rkey) + + createdAt, err := time.Parse(time.RFC3339, record.CreatedAt) + if err != nil { + createdAt = time.Now() + } + + var bioPtr, websitePtr, linksJSONPtr *string + if record.Bio != "" { + bioPtr = &record.Bio + } + if record.Website != "" { + websitePtr = &record.Website + } + if len(record.Links) > 0 { + linksBytes, _ := json.Marshal(record.Links) + linksStr := string(linksBytes) + linksJSONPtr = &linksStr + } + + profile := &db.Profile{ + URI: uri, + AuthorDID: event.Repo, + Bio: bioPtr, + Website: websitePtr, + LinksJSON: linksJSONPtr, + CreatedAt: createdAt, + IndexedAt: time.Now(), + } + + if err := i.db.UpsertProfile(profile); err != nil { + log.Printf("Failed to index profile: %v", err) + } else { + log.Printf("Indexed profile from %s", event.Repo) + } +} diff --git a/backend/internal/xrpc/records.go b/backend/internal/xrpc/records.go index 8e3f260..fd990c2 100644 --- a/backend/internal/xrpc/records.go +++ b/backend/internal/xrpc/records.go @@ -15,6 +15,7 @@ const ( CollectionLike = "at.margin.like" CollectionCollection = "at.margin.collection" CollectionCollectionItem = "at.margin.collectionItem" + CollectionProfile = "at.margin.profile" ) const ( @@ -362,3 +363,21 @@ func NewCollectionItemRecord(collection, annotation string, position int) *Colle CreatedAt: time.Now().UTC().Format(time.RFC3339), } } + +type MarginProfileRecord struct { + Type string `json:"$type"` + Bio string `json:"bio,omitempty"` + Website string `json:"website,omitempty"` + Links []string `json:"links,omitempty"` + CreatedAt string `json:"createdAt"` +} + +func (r *MarginProfileRecord) Validate() error { + if len(r.Bio) > 5000 { + return fmt.Errorf("bio too long") + } + if len(r.Links) > 20 { + return fmt.Errorf("too many links") + } + return nil +} diff --git a/backend/internal/xrpc/utils.go b/backend/internal/xrpc/utils.go index d95bae6..c413869 100644 --- a/backend/internal/xrpc/utils.go +++ b/backend/internal/xrpc/utils.go @@ -49,3 +49,30 @@ func ResolveDIDToPDS(did string) (string, error) { } return "", nil } +func ResolveHandle(handle string) (string, error) { + if strings.HasPrefix(handle, "did:") { + return handle, nil + } + + url := fmt.Sprintf("https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=%s", handle) + client := &http.Client{ + Timeout: 5 * time.Second, + } + resp, err := client.Get(url) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return "", fmt.Errorf("failed to resolve handle: %d", resp.StatusCode) + } + + var result struct { + DID string `json:"did"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", err + } + return result.DID, nil +} diff --git a/lexicons/at.margin.authFull.json b/lexicons/at/margin/authFull.json similarity index 90% rename from lexicons/at.margin.authFull.json rename to lexicons/at/margin/authFull.json index 419d7dc..0de19bd 100644 --- a/lexicons/at.margin.authFull.json +++ b/lexicons/at/margin/authFull.json @@ -20,7 +20,8 @@ "at.margin.reply", "at.margin.like", "at.margin.collection", - "at.margin.collectionItem" + "at.margin.collectionItem", + "at.margin.profile" ] } ] diff --git a/lexicons/at/margin/profile.json b/lexicons/at/margin/profile.json new file mode 100644 index 0000000..cd1f4da --- /dev/null +++ b/lexicons/at/margin/profile.json @@ -0,0 +1,40 @@ +{ + "lexicon": 1, + "id": "at.margin.profile", + "defs": { + "main": { + "type": "record", + "description": "A profile for a user on the Margin network.", + "key": "literal:self", + "record": { + "type": "object", + "required": ["createdAt"], + "properties": { + "bio": { + "type": "string", + "maxLength": 5000, + "description": "User biography or description." + }, + "website": { + "type": "string", + "maxLength": 1000, + "description": "User website URL." + }, + "links": { + "type": "array", + "description": "List of other relevant links (e.g. GitHub, Bluesky, etc).", + "items": { + "type": "string", + "maxLength": 1000 + }, + "maxLength": 20 + }, + "createdAt": { + "type": "string", + "format": "datetime" + } + } + } + } + } +} diff --git a/web/src/api/client.js b/web/src/api/client.js index 2de87d5..41d000a 100644 --- a/web/src/api/client.js +++ b/web/src/api/client.js @@ -57,6 +57,10 @@ export async function getAnnotation(uri) { return request(`${API_BASE}/annotation?uri=${encodeURIComponent(uri)}`); } +export async function getProfile(did) { + return request(`${API_BASE}/profile/${encodeURIComponent(did)}`); +} + export async function getUserAnnotations(did, limit = 50, offset = 0) { return request( `${API_BASE}/users/${encodeURIComponent(did)}/annotations?limit=${limit}&offset=${offset}`, @@ -164,6 +168,13 @@ export async function updateCollection(uri, name, description, icon) { }); } +export async function updateProfile({ bio, website, links }) { + return request(`${API_BASE}/profile`, { + method: "PUT", + body: JSON.stringify({ bio, website, links }), + }); +} + export async function createCollection(name, description, icon) { return request(`${API_BASE}/collections`, { method: "POST", diff --git a/web/src/components/EditProfileModal.jsx b/web/src/components/EditProfileModal.jsx new file mode 100644 index 0000000..7b61dae --- /dev/null +++ b/web/src/components/EditProfileModal.jsx @@ -0,0 +1,145 @@ +import { useState } from "react"; +import { updateProfile } from "../api/client"; + +export default function EditProfileModal({ profile, onClose, onUpdate }) { + const [bio, setBio] = useState(profile?.bio || ""); + const [website, setWebsite] = useState(profile?.website || ""); + const [links, setLinks] = useState(profile?.links || []); + const [newLink, setNewLink] = useState(""); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + const handleSubmit = async (e) => { + e.preventDefault(); + setSaving(true); + setError(null); + + try { + await updateProfile({ bio, website, links }); + onUpdate(); + onClose(); + } catch (err) { + setError(err.message); + } finally { + setSaving(false); + } + }; + + const addLink = () => { + if (!newLink) return; + + if (!links.includes(newLink)) { + setLinks([...links, newLink]); + setNewLink(""); + setError(null); + } + }; + + const removeLink = (index) => { + setLinks(links.filter((_, i) => i !== index)); + }; + + return ( +
+
e.stopPropagation()}> +
+

Edit Profile

+ +
+
+ {error &&
{error}
} + +
+ +