Write on the margins of the internet. Powered by the AT Protocol.
margin.at
extension
web
atproto
comments
1package api
2
3import (
4 "encoding/json"
5 "fmt"
6 "net/http"
7 "net/url"
8 "strings"
9 "time"
10
11 "github.com/go-chi/chi/v5"
12
13 "margin.at/internal/db"
14 "margin.at/internal/xrpc"
15)
16
17type UpdateProfileRequest struct {
18 Bio string `json:"bio"`
19 Website string `json:"website"`
20 Links []string `json:"links"`
21}
22
23func (h *Handler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
24 session, err := h.refresher.GetSessionWithAutoRefresh(r)
25 if err != nil {
26 http.Error(w, err.Error(), http.StatusUnauthorized)
27 return
28 }
29
30 var req UpdateProfileRequest
31 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
32 http.Error(w, "Invalid request body", http.StatusBadRequest)
33 return
34 }
35
36 record := &xrpc.MarginProfileRecord{
37 Type: xrpc.CollectionProfile,
38 Bio: req.Bio,
39 Website: req.Website,
40 Links: req.Links,
41 CreatedAt: time.Now().UTC().Format(time.RFC3339),
42 }
43
44 if err := record.Validate(); err != nil {
45 http.Error(w, err.Error(), http.StatusBadRequest)
46 return
47 }
48
49 err = h.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error {
50 _, err := client.PutRecord(r.Context(), did, xrpc.CollectionProfile, "self", record)
51 return err
52 })
53
54 if err != nil {
55 http.Error(w, "Failed to update profile: "+err.Error(), http.StatusInternalServerError)
56 return
57 }
58
59 linksJSON, _ := json.Marshal(req.Links)
60 profile := &db.Profile{
61 URI: fmt.Sprintf("at://%s/%s/self", session.DID, xrpc.CollectionProfile),
62 AuthorDID: session.DID,
63 Bio: &req.Bio,
64 Website: &req.Website,
65 LinksJSON: stringPtr(string(linksJSON)),
66 CreatedAt: time.Now(),
67 IndexedAt: time.Now(),
68 }
69 h.db.UpsertProfile(profile)
70
71 w.Header().Set("Content-Type", "application/json")
72 w.WriteHeader(http.StatusOK)
73 json.NewEncoder(w).Encode(req)
74}
75
76func stringPtr(s string) *string {
77 return &s
78}
79
80func (h *Handler) GetProfile(w http.ResponseWriter, r *http.Request) {
81 did := chi.URLParam(r, "did")
82 if decoded, err := url.QueryUnescape(did); err == nil {
83 did = decoded
84 }
85
86 if did == "" {
87 http.Error(w, "DID required", http.StatusBadRequest)
88 return
89 }
90
91 if !strings.HasPrefix(did, "did:") {
92 var resolvedDID string
93 err := h.db.QueryRow("SELECT did FROM sessions WHERE handle = $1 LIMIT 1", did).Scan(&resolvedDID)
94 if err == nil {
95 did = resolvedDID
96 } else {
97 resolvedDID, err = xrpc.ResolveHandle(did)
98 if err == nil {
99 did = resolvedDID
100 }
101 }
102 }
103
104 profile, err := h.db.GetProfile(did)
105 if err != nil {
106 http.Error(w, "Failed to fetch profile", http.StatusInternalServerError)
107 return
108 }
109
110 if profile == nil {
111 w.Header().Set("Content-Type", "application/json")
112 if did != "" && strings.HasPrefix(did, "did:") {
113 json.NewEncoder(w).Encode(map[string]string{"did": did})
114 } else {
115 w.Write([]byte("{}"))
116 }
117 return
118 }
119
120 resp := struct {
121 URI string `json:"uri"`
122 DID string `json:"did"`
123 Bio string `json:"bio"`
124 Website string `json:"website"`
125 Links []string `json:"links"`
126 CreatedAt string `json:"createdAt"`
127 IndexedAt string `json:"indexedAt"`
128 }{
129 URI: profile.URI,
130 DID: profile.AuthorDID,
131 CreatedAt: profile.CreatedAt.Format(time.RFC3339),
132 IndexedAt: profile.IndexedAt.Format(time.RFC3339),
133 }
134
135 if profile.Bio != nil {
136 resp.Bio = *profile.Bio
137 }
138 if profile.Website != nil {
139 resp.Website = *profile.Website
140 }
141 if profile.LinksJSON != nil && *profile.LinksJSON != "" {
142 _ = json.Unmarshal([]byte(*profile.LinksJSON), &resp.Links)
143 }
144 if resp.Links == nil {
145 resp.Links = []string{}
146 }
147
148 w.Header().Set("Content-Type", "application/json")
149 json.NewEncoder(w).Encode(resp)
150}