A URL shortener service that uses ATProto to allow self hosting and ensuring the user owns their data
1package atshorter
2
3import (
4 "context"
5 "embed"
6 _ "embed"
7 "encoding/json"
8 "fmt"
9 "log/slog"
10 "net/http"
11 "os"
12 "sync"
13 "text/template"
14
15 "github.com/bluesky-social/indigo/atproto/auth/oauth"
16 "github.com/bluesky-social/indigo/atproto/identity"
17 "github.com/bluesky-social/indigo/atproto/syntax"
18 "github.com/gorilla/sessions"
19)
20
21var ErrorNotFound = fmt.Errorf("not found")
22
23type Store interface {
24 CreateURL(id, url, did, originHost string, createdAt int64) error
25 GetURLs(did string) ([]ShortURL, error)
26 GetURLByID(id string) (ShortURL, error)
27 DeleteURL(id, did string) error
28}
29
30type Server struct {
31 host string
32 usersDID string
33 httpserver *http.Server
34 sessionStore *sessions.CookieStore
35 templates []*template.Template
36
37 oauthClient *oauth.ClientApp
38 store Store
39 httpClient *http.Client
40
41 didHostCache map[string]string
42 mu sync.Mutex
43}
44
45//go:embed html
46var htmlFolder embed.FS
47
48func NewServer(host string, port string, store Store, oauthClient *oauth.ClientApp, httpClient *http.Client, usersDID string) (*Server, error) {
49 sessionStore := sessions.NewCookieStore([]byte(os.Getenv("SESSION_KEY")))
50
51 homeTemplate, err := template.ParseFS(htmlFolder, "html/home.html")
52 if err != nil {
53 return nil, fmt.Errorf("error parsing templates: %w", err)
54 }
55
56 loginTemplate, err := template.ParseFS(htmlFolder, "html/login.html")
57 if err != nil {
58 return nil, fmt.Errorf("parsing login template: %w", err)
59 }
60
61 templates := []*template.Template{
62 homeTemplate,
63 loginTemplate,
64 }
65
66 srv := &Server{
67 host: host,
68 usersDID: usersDID,
69 oauthClient: oauthClient,
70 sessionStore: sessionStore,
71 templates: templates,
72 store: store,
73 httpClient: httpClient,
74 didHostCache: make(map[string]string),
75 }
76
77 mux := http.NewServeMux()
78
79 mux.HandleFunc("GET /login", srv.HandleLogin)
80 mux.HandleFunc("POST /login", srv.HandlePostLogin)
81 mux.HandleFunc("POST /logout", srv.HandleLogOut)
82
83 mux.HandleFunc("GET /", srv.authMiddleware(srv.HandleHome))
84 mux.HandleFunc("GET /a/{id}", srv.HandleRedirect)
85 mux.HandleFunc("POST /delete/{id}", srv.authMiddleware(srv.HandleDeleteURL))
86 mux.HandleFunc("POST /create-url", srv.authMiddleware(srv.HandleCreateShortURL))
87
88 mux.HandleFunc("GET /public/app.css", serveCSS)
89 mux.HandleFunc("GET /jwks.json", srv.serveJwks)
90 mux.HandleFunc("GET /oauth-client-metadata.json", srv.serveClientMetadata)
91 mux.HandleFunc("GET /oauth-callback", srv.handleOauthCallback)
92
93 addr := fmt.Sprintf("0.0.0.0:%s", port)
94 srv.httpserver = &http.Server{
95 Addr: addr,
96 Handler: mux,
97 }
98
99 return srv, nil
100}
101
102func (s *Server) Run() {
103 err := s.httpserver.ListenAndServe()
104 if err != nil {
105 slog.Error("listen and serve", "error", err)
106 }
107}
108
109func (s *Server) Stop(ctx context.Context) error {
110 return s.httpserver.Shutdown(ctx)
111}
112
113func (s *Server) getTemplate(name string) *template.Template {
114 for _, template := range s.templates {
115 if template.Name() == name {
116 return template
117 }
118 }
119 return nil
120}
121
122func (s *Server) serveJwks(w http.ResponseWriter, _ *http.Request) {
123 w.Header().Set("Content-Type", "application/json")
124
125 public := s.oauthClient.Config.PublicJWKS()
126 b, err := json.Marshal(public)
127 if err != nil {
128 slog.Error("failed to marshal oauth public JWKS", "error", err)
129 http.Error(w, "marshal public JWKS", http.StatusInternalServerError)
130 return
131 }
132
133 _, _ = w.Write(b)
134}
135
136//go:embed html/app.css
137var cssFile []byte
138
139func serveCSS(w http.ResponseWriter, r *http.Request) {
140 w.Header().Set("Content-Type", "text/css; charset=utf-8")
141 _, _ = w.Write(cssFile)
142}
143
144func (s *Server) serveClientMetadata(w http.ResponseWriter, r *http.Request) {
145 metadata := s.oauthClient.Config.ClientMetadata()
146 clientName := "at-shorter-url"
147 metadata.ClientName = &clientName
148 metadata.ClientURI = &s.host
149 if s.oauthClient.Config.IsConfidential() {
150 jwksURI := fmt.Sprintf("%s/jwks.json", s.host)
151 metadata.JWKSURI = &jwksURI
152 }
153
154 b, err := json.Marshal(metadata)
155 if err != nil {
156 slog.Error("failed to marshal client metadata", "error", err)
157 http.Error(w, "marshal response", http.StatusInternalServerError)
158 return
159 }
160 w.Header().Set("Content-Type", "application/json")
161 _, _ = w.Write(b)
162}
163
164func (s *Server) lookupDidHost(ctx context.Context, didStr string) (string, error) {
165 cachedResult, ok := s.checkDidHostInCache(didStr)
166 if ok {
167 return cachedResult, nil
168 }
169
170 did, err := syntax.ParseAtIdentifier(didStr)
171 if err != nil {
172 return "", fmt.Errorf("parsing did: %w", err)
173 }
174
175 dir := identity.DefaultDirectory()
176 acc, err := dir.Lookup(ctx, *did)
177 if err != nil {
178 return "", fmt.Errorf("looking up did: %w", err)
179 }
180
181 s.addDidHostToCache(didStr, acc.PDSEndpoint())
182
183 return acc.PDSEndpoint(), nil
184}
185
186func (s *Server) checkDidHostInCache(did string) (string, bool) {
187 s.mu.Lock()
188 defer s.mu.Unlock()
189
190 endpoint, ok := s.didHostCache[did]
191 return endpoint, ok
192}
193
194func (s *Server) addDidHostToCache(did, host string) {
195 s.mu.Lock()
196 defer s.mu.Unlock()
197
198 s.didHostCache[did] = host
199}