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