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>

Changed files
+39 -44
cmd
atshorter
database
html
+4 -4
Dockerfile
···
-
FROM golang:latest AS builder
WORKDIR /app
···
COPY . .
-
RUN CGO_ENABLED=0 go build -o at-shorter .
FROM alpine:latest
RUN apk --no-cache add ca-certificates
-
WORKDIR /root/
COPY --from=builder /app/at-shorter .
-
CMD ["./at-shorter"]
···
+
FROM golang:alpine AS builder
WORKDIR /app
···
COPY . .
+
RUN CGO_ENABLED=0 go build -o at-shorter ./cmd/atshorter/main.go
FROM alpine:latest
RUN apk --no-cache add ca-certificates
+
WORKDIR /app/
COPY --from=builder /app/at-shorter .
+
ENTRYPOINT ["./at-shorter"]
+2 -1
cmd/atshorter/main.go
···
"repo:com.atshorter.shorturl?action=delete",
}
if host == "" {
config = oauth.NewLocalhostConfig(
-
fmt.Sprintf("http://127.0.0.1:%s/oauth-callback", port),
scopes,
)
slog.Info("configuring localhost OAuth client", "CallbackURL", config.CallbackURL)
···
"repo:com.atshorter.shorturl?action=delete",
}
if host == "" {
+
host = fmt.Sprintf("http://127.0.0.1:%s", port)
config = oauth.NewLocalhostConfig(
+
fmt.Sprintf("%s/oauth-callback", host),
scopes,
)
slog.Info("configuring localhost OAuth client", "CallbackURL", config.CallbackURL)
+2 -4
consumer.go
···
}
type HandlerStore interface {
-
CreateURL(id, url, did string, createdAt int64) error
DeleteURL(id, did string) error
}
···
return nil
}
-
// TODO: if origin isn't this instance, ignore
-
-
err := h.store.CreateURL(event.Commit.RKey, record.URL, event.Did, record.CreatedAt.UnixMilli())
if err != nil {
// TODO: proper error handling in case this fails, we want to try again
slog.Error("failed to store short URL", "error", err)
···
}
type HandlerStore interface {
+
CreateURL(id, url, did, originHost string, createdAt int64) error
DeleteURL(id, did string) error
}
···
return nil
}
+
err := h.store.CreateURL(event.Commit.RKey, record.URL, event.Did, record.Origin, record.CreatedAt.UnixMilli())
if err != nil {
// TODO: proper error handling in case this fails, we want to try again
slog.Error("failed to store short URL", "error", err)
+8 -7
database/urls.go
···
"id" TEXT NOT NULL PRIMARY KEY,
"url" TEXT NOT NULL,
"did" TEXT NOT NULL,
"createdAt" integer
);`
···
return nil
}
-
func (d *DB) CreateURL(id, url, did string, createdAt int64) error {
-
sql := `INSERT INTO urls (id, url, did, createdAt) VALUES (?, ?, ?, ?) ON CONFLICT(id) DO NOTHING;`
-
_, err := d.db.Exec(sql, id, url, did, createdAt)
if err != nil {
// TODO: catch already exists
return fmt.Errorf("exec insert url: %w", err)
···
}
func (d *DB) GetURLs(did string) ([]atshorter.ShortURL, error) {
-
sql := "SELECT id, url, did FROM urls WHERE did = ?;"
rows, err := d.db.Query(sql, did)
if err != nil {
return nil, fmt.Errorf("run query to get URLS': %w", err)
···
var results []atshorter.ShortURL
for rows.Next() {
var shortURL atshorter.ShortURL
-
if err := rows.Scan(&shortURL.ID, &shortURL.URL, &shortURL.Did); err != nil {
return nil, fmt.Errorf("scan row: %w", err)
}
···
}
func (d *DB) GetURLByID(id string) (atshorter.ShortURL, error) {
-
sql := "SELECT id, url, did FROM urls WHERE id = ?;"
rows, err := d.db.Query(sql, id)
if err != nil {
return atshorter.ShortURL{}, fmt.Errorf("run query to get URL by id': %w", err)
···
var result atshorter.ShortURL
for rows.Next() {
-
if err := rows.Scan(&result.ID, &result.URL, &result.Did); err != nil {
return atshorter.ShortURL{}, fmt.Errorf("scan row: %w", err)
}
return result, nil
···
"id" TEXT NOT NULL PRIMARY KEY,
"url" TEXT NOT NULL,
"did" TEXT NOT NULL,
+
"originHost" TEXT NOT NULL,
"createdAt" integer
);`
···
return nil
}
+
func (d *DB) CreateURL(id, url, did, originHost string, createdAt int64) error {
+
sql := `INSERT INTO urls (id, url, did, originHost, createdAt) VALUES (?, ?, ?, ?, ?) ON CONFLICT(id) DO NOTHING;`
+
_, err := d.db.Exec(sql, id, url, did, originHost, createdAt)
if err != nil {
// TODO: catch already exists
return fmt.Errorf("exec insert url: %w", err)
···
}
func (d *DB) GetURLs(did string) ([]atshorter.ShortURL, error) {
+
sql := "SELECT id, url, did, originHost FROM urls WHERE did = ?;"
rows, err := d.db.Query(sql, did)
if err != nil {
return nil, fmt.Errorf("run query to get URLS': %w", err)
···
var results []atshorter.ShortURL
for rows.Next() {
var shortURL atshorter.ShortURL
+
if err := rows.Scan(&shortURL.ID, &shortURL.URL, &shortURL.Did, &shortURL.OriginHost); err != nil {
return nil, fmt.Errorf("scan row: %w", err)
}
···
}
func (d *DB) GetURLByID(id string) (atshorter.ShortURL, error) {
+
sql := "SELECT id, url, did, originHost FROM urls WHERE id = ?;"
rows, err := d.db.Query(sql, id)
if err != nil {
return atshorter.ShortURL{}, fmt.Errorf("run query to get URL by id': %w", err)
···
var result atshorter.ShortURL
for rows.Next() {
+
if err := rows.Scan(&result.ID, &result.URL, &result.Did, &result.OriginHost); err != nil {
return atshorter.ShortURL{}, fmt.Errorf("scan row: %w", err)
}
return result, nil
+1 -1
html/home.html
···
{{range .UsersShortURLs}}
<tr>
<td>
-
<a href="http://127.0.0.1:8080/a/{{.ID}}">{{.ID}}</a>
</td>
<td>
<a href="{{ .URL }}">{{.URL}}</a>
···
{{range .UsersShortURLs}}
<tr>
<td>
+
<a href="{{.OriginHost}}/a/{{.ID}}">{{.ID}}</a>
</td>
<td>
<a href="{{ .URL }}">{{.URL}}</a>
+10 -5
server.go
···
import (
"context"
_ "embed"
"encoding/json"
"fmt"
···
var ErrorNotFound = fmt.Errorf("not found")
type Store interface {
-
CreateURL(id, url, did string, createdAt int64) error
GetURLs(did string) ([]ShortURL, error)
GetURLByID(id string) (ShortURL, error)
DeleteURL(id, did string) error
···
mu sync.Mutex
}
func NewServer(host string, port string, store Store, oauthClient *oauth.ClientApp, httpClient *http.Client) (*Server, error) {
sessionStore := sessions.NewCookieStore([]byte(os.Getenv("SESSION_KEY")))
-
homeTemplate, err := template.ParseFiles("./html/home.html")
if err != nil {
-
return nil, fmt.Errorf("parsing home template: %w", err)
}
-
loginTemplate, err := template.ParseFiles("./html/login.html")
if err != nil {
return nil, fmt.Errorf("parsing login template: %w", err)
}
···
metadata.ClientName = &clientName
metadata.ClientURI = &s.host
if s.oauthClient.Config.IsConfidential() {
-
jwksURI := fmt.Sprintf("%s/jwks.json", r.Host)
metadata.JWKSURI = &jwksURI
}
···
import (
"context"
+
"embed"
_ "embed"
"encoding/json"
"fmt"
···
var ErrorNotFound = fmt.Errorf("not found")
type Store interface {
+
CreateURL(id, url, did, originHost string, createdAt int64) error
GetURLs(did string) ([]ShortURL, error)
GetURLByID(id string) (ShortURL, error)
DeleteURL(id, did string) error
···
mu sync.Mutex
}
+
//go:embed html
+
var htmlFolder embed.FS
+
func NewServer(host string, port string, store Store, oauthClient *oauth.ClientApp, httpClient *http.Client) (*Server, error) {
sessionStore := sessions.NewCookieStore([]byte(os.Getenv("SESSION_KEY")))
+
homeTemplate, err := template.ParseFS(htmlFolder, "html/home.html")
if err != nil {
+
return nil, fmt.Errorf("error parsing templates: %w", err)
}
+
+
loginTemplate, err := template.ParseFS(htmlFolder, "html/login.html")
if err != nil {
return nil, fmt.Errorf("parsing login template: %w", err)
}
···
metadata.ClientName = &clientName
metadata.ClientURI = &s.host
if s.oauthClient.Config.IsConfidential() {
+
jwksURI := fmt.Sprintf("%s/jwks.json", s.host)
metadata.JWKSURI = &jwksURI
}
+12 -22
short_url_handler.go
···
}
type ShortURL struct {
-
ID string
-
URL string
-
Did string
}
func (s *Server) HandleRedirect(w http.ResponseWriter, r *http.Request) {
···
return
}
-
record, err := s.getUrlRecord(r.Context(), shortURL.Did, shortURL.ID)
-
if err != nil {
-
slog.Error("getting URL record from PDS", "error", err, "did", shortURL.Did, "id", shortURL.ID)
-
http.Error(w, "error verifying short URl link", http.StatusInternalServerError)
-
return
-
}
-
-
// TODO: use the host from the record to check that it was created using this host - otherwise it's a short URL
-
// created by another hosted instance of this service
-
-
slog.Info("got record from PDS", "record", record)
-
http.Redirect(w, r, shortURL.URL, http.StatusSeeOther)
return
}
···
tmpl.Execute(w, data)
return
}
-
data.UsersShortURLs = usersURLs
tmpl.Execute(w, data)
···
createdAt := time.Now()
api := session.APIClient()
bodyReq := map[string]any{
"repo": api.AccountDID.String(),
"collection": "com.atshorter.shorturl",
"rkey": rkey,
-
"record": map[string]any{
-
"url": url,
-
"createdAt": createdAt,
-
"orgin": "atshorter.com", // TODO: this needs to be pulled from the host env
-
},
}
err = api.Post(r.Context(), "com.atproto.repo.createRecord", bodyReq, nil)
if err != nil {
···
return
}
-
err = s.store.CreateURL(rkey, url, did.String(), createdAt.UnixMilli())
if err != nil {
slog.Error("store in local database", "error", err)
}
···
}
type ShortURL struct {
+
ID string
+
URL string
+
Did string
+
OriginHost string
}
func (s *Server) HandleRedirect(w http.ResponseWriter, r *http.Request) {
···
return
}
http.Redirect(w, r, shortURL.URL, http.StatusSeeOther)
return
}
···
tmpl.Execute(w, data)
return
}
data.UsersShortURLs = usersURLs
tmpl.Execute(w, data)
···
createdAt := time.Now()
api := session.APIClient()
+
record := ShortURLRecord{
+
URL: url,
+
CreatedAt: createdAt,
+
Origin: s.host,
+
}
+
bodyReq := map[string]any{
"repo": api.AccountDID.String(),
"collection": "com.atshorter.shorturl",
"rkey": rkey,
+
"record": record,
}
err = api.Post(r.Context(), "com.atproto.repo.createRecord", bodyReq, nil)
if err != nil {
···
return
}
+
err = s.store.CreateURL(rkey, url, did.String(), s.host, createdAt.UnixMilli())
if err != nil {
slog.Error("store in local database", "error", err)
}