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