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