A URL shortener service that uses ATProto to allow self hosting and ensuring the user owns their data

fixed the docker image and after testing in production made some adjustments and bug fixes

Signed-off-by: Will Andrews <did:plc:dadhhalkfcq3gucaq25hjqon>

+39 -44
+4 -4
Dockerfile
··· 1 - FROM golang:latest AS builder 2 3 WORKDIR /app 4 ··· 7 8 COPY . . 9 10 - RUN CGO_ENABLED=0 go build -o at-shorter . 11 12 FROM alpine:latest 13 14 RUN apk --no-cache add ca-certificates 15 16 - WORKDIR /root/ 17 COPY --from=builder /app/at-shorter . 18 19 - CMD ["./at-shorter"]
··· 1 + FROM golang:alpine AS builder 2 3 WORKDIR /app 4 ··· 7 8 COPY . . 9 10 + RUN CGO_ENABLED=0 go build -o at-shorter ./cmd/atshorter/main.go 11 12 FROM alpine:latest 13 14 RUN apk --no-cache add ca-certificates 15 16 + WORKDIR /app/ 17 COPY --from=builder /app/at-shorter . 18 19 + ENTRYPOINT ["./at-shorter"]
+2 -1
cmd/atshorter/main.go
··· 72 "repo:com.atshorter.shorturl?action=delete", 73 } 74 if host == "" { 75 config = oauth.NewLocalhostConfig( 76 - fmt.Sprintf("http://127.0.0.1:%s/oauth-callback", port), 77 scopes, 78 ) 79 slog.Info("configuring localhost OAuth client", "CallbackURL", config.CallbackURL)
··· 72 "repo:com.atshorter.shorturl?action=delete", 73 } 74 if host == "" { 75 + host = fmt.Sprintf("http://127.0.0.1:%s", port) 76 config = oauth.NewLocalhostConfig( 77 + fmt.Sprintf("%s/oauth-callback", host), 78 scopes, 79 ) 80 slog.Info("configuring localhost OAuth client", "CallbackURL", config.CallbackURL)
+2 -4
consumer.go
··· 58 } 59 60 type HandlerStore interface { 61 - CreateURL(id, url, did string, createdAt int64) error 62 DeleteURL(id, did string) error 63 } 64 ··· 94 return nil 95 } 96 97 - // TODO: if origin isn't this instance, ignore 98 - 99 - err := h.store.CreateURL(event.Commit.RKey, record.URL, event.Did, record.CreatedAt.UnixMilli()) 100 if err != nil { 101 // TODO: proper error handling in case this fails, we want to try again 102 slog.Error("failed to store short URL", "error", err)
··· 58 } 59 60 type HandlerStore interface { 61 + CreateURL(id, url, did, originHost string, createdAt int64) error 62 DeleteURL(id, did string) error 63 } 64 ··· 94 return nil 95 } 96 97 + err := h.store.CreateURL(event.Commit.RKey, record.URL, event.Did, record.Origin, record.CreatedAt.UnixMilli()) 98 if err != nil { 99 // TODO: proper error handling in case this fails, we want to try again 100 slog.Error("failed to store short URL", "error", err)
+8 -7
database/urls.go
··· 13 "id" TEXT NOT NULL PRIMARY KEY, 14 "url" TEXT NOT NULL, 15 "did" TEXT NOT NULL, 16 "createdAt" integer 17 );` 18 ··· 30 return nil 31 } 32 33 - func (d *DB) CreateURL(id, url, did string, createdAt int64) error { 34 - sql := `INSERT INTO urls (id, url, did, createdAt) VALUES (?, ?, ?, ?) ON CONFLICT(id) DO NOTHING;` 35 - _, err := d.db.Exec(sql, id, url, did, createdAt) 36 if err != nil { 37 // TODO: catch already exists 38 return fmt.Errorf("exec insert url: %w", err) ··· 42 } 43 44 func (d *DB) GetURLs(did string) ([]atshorter.ShortURL, error) { 45 - sql := "SELECT id, url, did FROM urls WHERE did = ?;" 46 rows, err := d.db.Query(sql, did) 47 if err != nil { 48 return nil, fmt.Errorf("run query to get URLS': %w", err) ··· 52 var results []atshorter.ShortURL 53 for rows.Next() { 54 var shortURL atshorter.ShortURL 55 - if err := rows.Scan(&shortURL.ID, &shortURL.URL, &shortURL.Did); err != nil { 56 return nil, fmt.Errorf("scan row: %w", err) 57 } 58 ··· 62 } 63 64 func (d *DB) GetURLByID(id string) (atshorter.ShortURL, error) { 65 - sql := "SELECT id, url, did FROM urls WHERE id = ?;" 66 rows, err := d.db.Query(sql, id) 67 if err != nil { 68 return atshorter.ShortURL{}, fmt.Errorf("run query to get URL by id': %w", err) ··· 71 72 var result atshorter.ShortURL 73 for rows.Next() { 74 - if err := rows.Scan(&result.ID, &result.URL, &result.Did); err != nil { 75 return atshorter.ShortURL{}, fmt.Errorf("scan row: %w", err) 76 } 77 return result, nil
··· 13 "id" TEXT NOT NULL PRIMARY KEY, 14 "url" TEXT NOT NULL, 15 "did" TEXT NOT NULL, 16 + "originHost" TEXT NOT NULL, 17 "createdAt" integer 18 );` 19 ··· 31 return nil 32 } 33 34 + func (d *DB) CreateURL(id, url, did, originHost string, createdAt int64) error { 35 + sql := `INSERT INTO urls (id, url, did, originHost, createdAt) VALUES (?, ?, ?, ?, ?) ON CONFLICT(id) DO NOTHING;` 36 + _, err := d.db.Exec(sql, id, url, did, originHost, createdAt) 37 if err != nil { 38 // TODO: catch already exists 39 return fmt.Errorf("exec insert url: %w", err) ··· 43 } 44 45 func (d *DB) GetURLs(did string) ([]atshorter.ShortURL, error) { 46 + sql := "SELECT id, url, did, originHost FROM urls WHERE did = ?;" 47 rows, err := d.db.Query(sql, did) 48 if err != nil { 49 return nil, fmt.Errorf("run query to get URLS': %w", err) ··· 53 var results []atshorter.ShortURL 54 for rows.Next() { 55 var shortURL atshorter.ShortURL 56 + if err := rows.Scan(&shortURL.ID, &shortURL.URL, &shortURL.Did, &shortURL.OriginHost); err != nil { 57 return nil, fmt.Errorf("scan row: %w", err) 58 } 59 ··· 63 } 64 65 func (d *DB) GetURLByID(id string) (atshorter.ShortURL, error) { 66 + sql := "SELECT id, url, did, originHost FROM urls WHERE id = ?;" 67 rows, err := d.db.Query(sql, id) 68 if err != nil { 69 return atshorter.ShortURL{}, fmt.Errorf("run query to get URL by id': %w", err) ··· 72 73 var result atshorter.ShortURL 74 for rows.Next() { 75 + if err := rows.Scan(&result.ID, &result.URL, &result.Did, &result.OriginHost); err != nil { 76 return atshorter.ShortURL{}, fmt.Errorf("scan row: %w", err) 77 } 78 return result, nil
+1 -1
html/home.html
··· 29 {{range .UsersShortURLs}} 30 <tr> 31 <td> 32 - <a href="http://127.0.0.1:8080/a/{{.ID}}">{{.ID}}</a> 33 </td> 34 <td> 35 <a href="{{ .URL }}">{{.URL}}</a>
··· 29 {{range .UsersShortURLs}} 30 <tr> 31 <td> 32 + <a href="{{.OriginHost}}/a/{{.ID}}">{{.ID}}</a> 33 </td> 34 <td> 35 <a href="{{ .URL }}">{{.URL}}</a>
+10 -5
server.go
··· 2 3 import ( 4 "context" 5 _ "embed" 6 "encoding/json" 7 "fmt" ··· 20 var ErrorNotFound = fmt.Errorf("not found") 21 22 type 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 ··· 40 mu sync.Mutex 41 } 42 43 func NewServer(host string, port string, 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 } ··· 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
··· 2 3 import ( 4 "context" 5 + "embed" 6 _ "embed" 7 "encoding/json" 8 "fmt" ··· 21 var ErrorNotFound = fmt.Errorf("not found") 22 23 type 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 ··· 41 mu sync.Mutex 42 } 43 44 + //go:embed html 45 + var htmlFolder embed.FS 46 + 47 func 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 } ··· 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
+12 -22
short_url_handler.go
··· 16 } 17 18 type ShortURL struct { 19 - ID string 20 - URL string 21 - Did string 22 } 23 24 func (s *Server) HandleRedirect(w http.ResponseWriter, r *http.Request) { ··· 39 return 40 } 41 42 - record, err := s.getUrlRecord(r.Context(), shortURL.Did, shortURL.ID) 43 - if err != nil { 44 - slog.Error("getting URL record from PDS", "error", err, "did", shortURL.Did, "id", shortURL.ID) 45 - http.Error(w, "error verifying short URl link", http.StatusInternalServerError) 46 - return 47 - } 48 - 49 - // TODO: use the host from the record to check that it was created using this host - otherwise it's a short URL 50 - // created by another hosted instance of this service 51 - 52 - slog.Info("got record from PDS", "record", record) 53 - 54 http.Redirect(w, r, shortURL.URL, http.StatusSeeOther) 55 return 56 } ··· 72 tmpl.Execute(w, data) 73 return 74 } 75 - 76 data.UsersShortURLs = usersURLs 77 78 tmpl.Execute(w, data) ··· 166 createdAt := time.Now() 167 api := session.APIClient() 168 169 bodyReq := map[string]any{ 170 "repo": api.AccountDID.String(), 171 "collection": "com.atshorter.shorturl", 172 "rkey": rkey, 173 - "record": map[string]any{ 174 - "url": url, 175 - "createdAt": createdAt, 176 - "orgin": "atshorter.com", // TODO: this needs to be pulled from the host env 177 - }, 178 } 179 err = api.Post(r.Context(), "com.atproto.repo.createRecord", bodyReq, nil) 180 if err != nil { ··· 183 return 184 } 185 186 - err = s.store.CreateURL(rkey, url, did.String(), createdAt.UnixMilli()) 187 if err != nil { 188 slog.Error("store in local database", "error", err) 189 }
··· 16 } 17 18 type ShortURL struct { 19 + ID string 20 + URL string 21 + Did string 22 + OriginHost string 23 } 24 25 func (s *Server) HandleRedirect(w http.ResponseWriter, r *http.Request) { ··· 40 return 41 } 42 43 http.Redirect(w, r, shortURL.URL, http.StatusSeeOther) 44 return 45 } ··· 61 tmpl.Execute(w, data) 62 return 63 } 64 data.UsersShortURLs = usersURLs 65 66 tmpl.Execute(w, data) ··· 154 createdAt := time.Now() 155 api := session.APIClient() 156 157 + record := ShortURLRecord{ 158 + URL: url, 159 + CreatedAt: createdAt, 160 + Origin: s.host, 161 + } 162 + 163 bodyReq := map[string]any{ 164 "repo": api.AccountDID.String(), 165 "collection": "com.atshorter.shorturl", 166 "rkey": rkey, 167 + "record": record, 168 } 169 err = api.Post(r.Context(), "com.atproto.repo.createRecord", bodyReq, nil) 170 if err != nil { ··· 173 return 174 } 175 176 + err = s.store.CreateURL(rkey, url, did.String(), s.host, createdAt.UnixMilli()) 177 if err != nil { 178 slog.Error("store in local database", "error", err) 179 }