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
+
FROM golang:alpine AS builder
WORKDIR /app
···
COPY . .
-
RUN CGO_ENABLED=0 go build -o at-shorter .
+
RUN CGO_ENABLED=0 go build -o at-shorter ./cmd/atshorter/main.go
FROM alpine:latest
RUN apk --no-cache add ca-certificates
-
WORKDIR /root/
+
WORKDIR /app/
COPY --from=builder /app/at-shorter .
-
CMD ["./at-shorter"]
+
ENTRYPOINT ["./at-shorter"]
+2 -1
cmd/atshorter/main.go
···
"repo:com.atshorter.shorturl?action=delete",
}
if host == "" {
+
host = fmt.Sprintf("http://127.0.0.1:%s", port)
config = oauth.NewLocalhostConfig(
-
fmt.Sprintf("http://127.0.0.1:%s/oauth-callback", port),
+
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
+
CreateURL(id, url, did, originHost 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())
+
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,
+
"originHost" 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)
+
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 FROM urls WHERE did = ?;"
+
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); err != nil {
+
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 FROM urls WHERE id = ?;"
+
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); err != nil {
+
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>
+
<a href="{{.OriginHost}}/a/{{.ID}}">{{.ID}}</a>
</td>
<td>
<a href="{{ .URL }}">{{.URL}}</a>
+10 -5
server.go
···
import (
"context"
+
"embed"
_ "embed"
"encoding/json"
"fmt"
···
var ErrorNotFound = fmt.Errorf("not found")
type Store interface {
-
CreateURL(id, url, did string, createdAt int64) error
+
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.ParseFiles("./html/home.html")
+
homeTemplate, err := template.ParseFS(htmlFolder, "html/home.html")
if err != nil {
-
return nil, fmt.Errorf("parsing home template: %w", err)
+
return nil, fmt.Errorf("error parsing templates: %w", err)
}
-
loginTemplate, err := template.ParseFiles("./html/login.html")
+
+
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", r.Host)
+
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
+
ID string
+
URL string
+
Did string
+
OriginHost 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()
+
record := ShortURLRecord{
+
URL: url,
+
CreatedAt: createdAt,
+
Origin: s.host,
+
}
+
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
-
},
+
"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(), createdAt.UnixMilli())
+
err = s.store.CreateURL(rkey, url, did.String(), s.host, createdAt.UnixMilli())
if err != nil {
slog.Error("store in local database", "error", err)
}