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

initial implementation (with rough looking UI) of an ATProto URL shortner service

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

willdot.net 50a8e205

+3
.gitignore
···
+
.env
+
database.db
+
at-shorter
+124
auth_handlers.go
···
+
package atshorter
+
+
import (
+
_ "embed"
+
"log/slog"
+
"net/http"
+
+
"github.com/bluesky-social/indigo/atproto/syntax"
+
)
+
+
const (
+
sessionName = "at-shorter"
+
)
+
+
type LoginData struct {
+
Handle string
+
Error string
+
}
+
+
func (s *Server) authMiddleware(next func(http.ResponseWriter, *http.Request)) func(http.ResponseWriter, *http.Request) {
+
return func(w http.ResponseWriter, r *http.Request) {
+
did, _ := s.currentSessionDID(r)
+
if did == nil {
+
http.Redirect(w, r, "/login", http.StatusFound)
+
return
+
}
+
+
next(w, r)
+
}
+
}
+
+
func (s *Server) HandleLogin(w http.ResponseWriter, r *http.Request) {
+
tmpl := s.getTemplate("login.html")
+
data := LoginData{}
+
tmpl.Execute(w, data)
+
}
+
+
func (s *Server) HandlePostLogin(w http.ResponseWriter, r *http.Request) {
+
tmpl := s.getTemplate("login.html")
+
data := LoginData{}
+
+
err := r.ParseForm()
+
if err != nil {
+
slog.Error("parsing form", "error", err)
+
data.Error = "error parsing data"
+
tmpl.Execute(w, data)
+
return
+
}
+
+
handle := r.FormValue("handle")
+
+
redirectURL, err := s.oauthClient.StartAuthFlow(r.Context(), handle)
+
if err != nil {
+
slog.Error("starting oauth flow", "error", err)
+
data.Error = "error logging in"
+
tmpl.Execute(w, data)
+
return
+
}
+
+
http.Redirect(w, r, redirectURL, http.StatusFound)
+
}
+
+
func (s *Server) handleOauthCallback(w http.ResponseWriter, r *http.Request) {
+
tmpl := s.getTemplate("login.html")
+
data := LoginData{}
+
+
sessData, err := s.oauthClient.ProcessCallback(r.Context(), r.URL.Query())
+
if err != nil {
+
slog.Error("processing OAuth callback", "error", err)
+
data.Error = "error logging in"
+
tmpl.Execute(w, data)
+
return
+
}
+
+
// create signed cookie session, indicating account DID
+
sess, _ := s.sessionStore.Get(r, sessionName)
+
sess.Values["account_did"] = sessData.AccountDID.String()
+
sess.Values["session_id"] = sessData.SessionID
+
if err := sess.Save(r, w); err != nil {
+
slog.Error("storing session data", "error", err)
+
data.Error = "error logging in"
+
tmpl.Execute(w, data)
+
return
+
}
+
+
http.Redirect(w, r, "/", http.StatusFound)
+
}
+
+
func (s *Server) HandleLogOut(w http.ResponseWriter, r *http.Request) {
+
did, sessionID := s.currentSessionDID(r)
+
if did != nil {
+
err := s.oauthClient.Store.DeleteSession(r.Context(), *did, sessionID)
+
if err != nil {
+
slog.Error("deleting oauth session", "error", err)
+
}
+
}
+
+
sess, _ := s.sessionStore.Get(r, sessionName)
+
sess.Values = make(map[any]any)
+
err := sess.Save(r, w)
+
if err != nil {
+
http.Error(w, err.Error(), http.StatusInternalServerError)
+
return
+
}
+
http.Redirect(w, r, "/", http.StatusFound)
+
}
+
+
func (s *Server) currentSessionDID(r *http.Request) (*syntax.DID, string) {
+
sess, _ := s.sessionStore.Get(r, sessionName)
+
accountDID, ok := sess.Values["account_did"].(string)
+
if !ok || accountDID == "" {
+
return nil, ""
+
}
+
did, err := syntax.ParseDID(accountDID)
+
if err != nil {
+
return nil, ""
+
}
+
sessionID, ok := sess.Values["session_id"].(string)
+
if !ok || sessionID == "" {
+
return nil, ""
+
}
+
+
return &did, sessionID
+
}
+130
cmd/atshorter/main.go
···
+
package main
+
+
import (
+
"context"
+
"errors"
+
"fmt"
+
"log"
+
"log/slog"
+
"net/http"
+
"os"
+
"os/signal"
+
"path"
+
"syscall"
+
"time"
+
+
"github.com/avast/retry-go/v4"
+
"github.com/bluesky-social/indigo/atproto/auth/oauth"
+
"github.com/joho/godotenv"
+
atshorter "tangled.sh/willdot.net/at-shorter-url"
+
"tangled.sh/willdot.net/at-shorter-url/database"
+
)
+
+
const (
+
defaultServerAddr = "wss://jetstream.atproto.tools/subscribe"
+
httpClientTimeoutDuration = time.Second * 5
+
transportIdleConnTimeoutDuration = time.Second * 90
+
)
+
+
func main() {
+
err := godotenv.Load(".env")
+
if err != nil {
+
if !os.IsNotExist(err) {
+
log.Fatal("Error loading .env file")
+
}
+
}
+
+
host := os.Getenv("HOST")
+
if host == "" {
+
slog.Warn("missing HOST env variable")
+
}
+
+
dbMountPath := os.Getenv("DATABASE_PATH")
+
if dbMountPath == "" {
+
slog.Error("DATABASE_PATH env not set")
+
return
+
}
+
+
dbFilename := path.Join(dbMountPath, "database.db")
+
db, err := database.New(dbFilename)
+
if err != nil {
+
slog.Error("create new database", "error", err)
+
return
+
}
+
defer db.Close()
+
+
var config oauth.ClientConfig
+
bind := ":8080"
+
scopes := []string{
+
"atproto",
+
"repo:com.atshorter.shorturl?action=create",
+
"repo:com.atshorter.shorturl?action=update",
+
"repo:com.atshorter.shorturl?action=delete",
+
}
+
if host == "" {
+
config = oauth.NewLocalhostConfig(
+
fmt.Sprintf("http://127.0.0.1%s/oauth-callback", bind),
+
scopes,
+
)
+
slog.Info("configuring localhost OAuth client", "CallbackURL", config.CallbackURL)
+
} else {
+
config = oauth.NewPublicConfig(
+
fmt.Sprintf("%s/oauth-client-metadata.json", host),
+
fmt.Sprintf("%s/oauth-callback", host),
+
scopes,
+
)
+
}
+
oauthClient := oauth.NewClientApp(&config, db)
+
+
httpClient := &http.Client{
+
Timeout: httpClientTimeoutDuration,
+
Transport: &http.Transport{
+
IdleConnTimeout: transportIdleConnTimeoutDuration,
+
},
+
}
+
+
server, err := atshorter.NewServer(host, 8080, db, oauthClient, httpClient)
+
if err != nil {
+
slog.Error("create new server", "error", err)
+
return
+
}
+
+
signals := make(chan os.Signal, 1)
+
signal.Notify(signals, syscall.SIGTERM, syscall.SIGINT)
+
+
ctx, cancel := context.WithCancel(context.Background())
+
defer cancel()
+
+
go func() {
+
<-signals
+
cancel()
+
_ = server.Stop(context.Background())
+
}()
+
+
go consumeLoop(ctx, db)
+
+
server.Run()
+
}
+
+
func consumeLoop(ctx context.Context, db *database.DB) {
+
jsServerAddr := os.Getenv("JS_SERVER_ADDR")
+
if jsServerAddr == "" {
+
jsServerAddr = defaultServerAddr
+
}
+
+
consumer := atshorter.NewConsumer(jsServerAddr, slog.Default(), db)
+
+
err := retry.Do(func() error {
+
err := consumer.Consume(ctx)
+
if err != nil {
+
if errors.Is(err, context.Canceled) {
+
return nil
+
}
+
slog.Error("consume loop", "error", err)
+
return err
+
}
+
return nil
+
}, retry.UntilSucceeded()) // retry indefinitly until context canceled
+
slog.Error(err.Error())
+
slog.Warn("exiting consume loop")
+
}
+116
consumer.go
···
+
package atshorter
+
+
import (
+
"context"
+
"encoding/json"
+
+
"fmt"
+
"log/slog"
+
"time"
+
+
"github.com/bluesky-social/jetstream/pkg/client"
+
"github.com/bluesky-social/jetstream/pkg/client/schedulers/sequential"
+
"github.com/bluesky-social/jetstream/pkg/models"
+
)
+
+
type consumer struct {
+
cfg *client.ClientConfig
+
handler handler
+
logger *slog.Logger
+
}
+
+
func NewConsumer(jsAddr string, logger *slog.Logger, store HandlerStore) *consumer {
+
cfg := client.DefaultClientConfig()
+
if jsAddr != "" {
+
cfg.WebsocketURL = jsAddr
+
}
+
cfg.WantedCollections = []string{
+
"com.atshorter.shorturl",
+
}
+
cfg.WantedDids = []string{} // TODO: possibly when self hosting, limit this to just a select few?
+
+
return &consumer{
+
cfg: cfg,
+
logger: logger,
+
handler: handler{
+
store: store,
+
},
+
}
+
}
+
+
func (c *consumer) Consume(ctx context.Context) error {
+
scheduler := sequential.NewScheduler("jetstream_at_shorter_url", c.logger, c.handler.HandleEvent)
+
defer scheduler.Shutdown()
+
+
client, err := client.NewClient(c.cfg, c.logger, scheduler)
+
if err != nil {
+
return fmt.Errorf("failed to create client: %w", err)
+
}
+
+
cursor := time.Now().Add(1 * -time.Minute).UnixMicro()
+
+
if err := client.ConnectAndRead(ctx, &cursor); err != nil {
+
return fmt.Errorf("connect and read: %w", err)
+
}
+
+
slog.Info("stopping consume")
+
return nil
+
}
+
+
type HandlerStore interface {
+
CreateURL(id, url, did string, createdAt int64) error
+
DeleteURL(id, did string) error
+
}
+
+
type handler struct {
+
store HandlerStore
+
}
+
+
func (h *handler) HandleEvent(ctx context.Context, event *models.Event) error {
+
if event.Commit == nil {
+
return nil
+
}
+
+
switch event.Commit.Operation {
+
case models.CommitOperationCreate:
+
return h.handleCreateEvent(ctx, event)
+
case models.CommitOperationDelete:
+
return h.handleDeleteEvent(ctx, event)
+
default:
+
return nil
+
}
+
}
+
+
type ShortURLRecord struct {
+
URL string `json:"url"`
+
CreatedAt time.Time `json:"createdAt"`
+
Origin string `json:"origin"`
+
}
+
+
func (h *handler) handleCreateEvent(_ context.Context, event *models.Event) error {
+
var record ShortURLRecord
+
if err := json.Unmarshal(event.Commit.Record, &record); err != nil {
+
slog.Error("unmarshal record", "error", err)
+
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)
+
}
+
+
return nil
+
}
+
+
func (h *handler) handleDeleteEvent(_ context.Context, event *models.Event) error {
+
err := h.store.DeleteURL(event.Commit.RKey, event.Did)
+
if err != nil {
+
// TODO: proper error handling in case this fails, we want to try again
+
slog.Error("failed to delete short URL from store", "error", err)
+
}
+
+
return nil
+
}
+71
database/database.go
···
+
package database
+
+
import (
+
"database/sql"
+
"errors"
+
"fmt"
+
"log/slog"
+
"os"
+
+
_ "github.com/glebarez/go-sqlite"
+
)
+
+
type DB struct {
+
db *sql.DB
+
}
+
+
func New(dbPath string) (*DB, error) {
+
if dbPath != ":memory:" {
+
err := createDbFile(dbPath)
+
if err != nil {
+
return nil, fmt.Errorf("create db file: %w", err)
+
}
+
}
+
+
db, err := sql.Open("sqlite", dbPath)
+
if err != nil {
+
return nil, fmt.Errorf("open database: %w", err)
+
}
+
+
err = db.Ping()
+
if err != nil {
+
return nil, fmt.Errorf("ping db: %w", err)
+
}
+
+
err = createOauthRequestsTable(db)
+
if err != nil {
+
return nil, fmt.Errorf("creating oauth requests table: %w", err)
+
}
+
+
err = createOauthSessionsTable(db)
+
if err != nil {
+
return nil, fmt.Errorf("creating oauth sessions table: %w", err)
+
}
+
+
err = createURLsTable(db)
+
if err != nil {
+
return nil, fmt.Errorf("creating status table: %w", err)
+
}
+
+
return &DB{db: db}, nil
+
}
+
+
func (d *DB) Close() {
+
err := d.db.Close()
+
if err != nil {
+
slog.Error("failed to close db", "error", err)
+
}
+
}
+
+
func createDbFile(dbFilename string) error {
+
if _, err := os.Stat(dbFilename); !errors.Is(err, os.ErrNotExist) {
+
return nil
+
}
+
+
f, err := os.Create(dbFilename)
+
if err != nil {
+
return fmt.Errorf("create db file : %w", err)
+
}
+
f.Close()
+
return nil
+
}
+110
database/oauth_requests.go
···
+
package database
+
+
import (
+
"context"
+
"database/sql"
+
"encoding/json"
+
"fmt"
+
"log/slog"
+
+
"github.com/bluesky-social/indigo/atproto/auth/oauth"
+
"github.com/bluesky-social/indigo/atproto/syntax"
+
)
+
+
func createOauthRequestsTable(db *sql.DB) error {
+
createOauthRequestsTableSQL := `CREATE TABLE IF NOT EXISTS oauthrequests (
+
"id" integer NOT NULL PRIMARY KEY AUTOINCREMENT,
+
"state" TEXT,
+
"authServerURL" TEXT,
+
"accountDID" TEXT,
+
"scope" TEXT,
+
"requestURI" TEXT,
+
"authServerTokenEndpoint" TEXT,
+
"pkceVerifier" TEXT,
+
"dpopAuthserverNonce" TEXT,
+
"dpopPrivateKeyMultibase" TEXT,
+
UNIQUE(state)
+
);`
+
+
slog.Info("Create oauthrequests table...")
+
statement, err := db.Prepare(createOauthRequestsTableSQL)
+
if err != nil {
+
return fmt.Errorf("prepare DB statement to create oauthrequests table: %w", err)
+
}
+
_, err = statement.Exec()
+
if err != nil {
+
return fmt.Errorf("exec sql statement to create oauthrequests table: %w", err)
+
}
+
slog.Info("oauthrequests table created")
+
+
return nil
+
}
+
+
func (d *DB) SaveAuthRequestInfo(ctx context.Context, info oauth.AuthRequestData) error {
+
did := ""
+
if info.AccountDID != nil {
+
did = info.AccountDID.String()
+
}
+
+
scopes, err := json.Marshal(info.Scopes)
+
if err != nil {
+
return fmt.Errorf("encoding scopes to JSON: %w", err)
+
}
+
+
sql := `INSERT INTO oauthrequests (state, authServerURL, accountDID, scope, requestURI, authServerTokenEndpoint, pkceVerifier, dpopAuthserverNonce, dpopPrivateKeyMultibase) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(state) DO NOTHING;`
+
_, err = d.db.Exec(sql, info.State, info.AuthServerURL, did, string(scopes), info.RequestURI, info.AuthServerTokenEndpoint, info.PKCEVerifier, info.DPoPAuthServerNonce, info.DPoPPrivateKeyMultibase)
+
if err != nil {
+
slog.Error("saving auth request info", "error", err)
+
return fmt.Errorf("exec insert oauth request: %w", err)
+
}
+
+
return nil
+
}
+
+
func (d *DB) GetAuthRequestInfo(ctx context.Context, state string) (*oauth.AuthRequestData, error) {
+
var oauthRequest oauth.AuthRequestData
+
sql := "SELECT state, authServerURL, accountDID, scope, requestURI, authServerTokenEndpoint, pkceVerifier, dpopAuthserverNonce, dpopPrivateKeyMultibase FROM oauthrequests where state = ?;"
+
rows, err := d.db.Query(sql, state)
+
if err != nil {
+
return nil, fmt.Errorf("run query to get oauth request: %w", err)
+
}
+
defer rows.Close()
+
+
var did string
+
var scopesStr string
+
+
for rows.Next() {
+
if err := rows.Scan(&oauthRequest.State, &oauthRequest.AuthServerURL, &did, &scopesStr, &oauthRequest.RequestURI, &oauthRequest.AuthServerTokenEndpoint, &oauthRequest.PKCEVerifier, &oauthRequest.DPoPAuthServerNonce, &oauthRequest.DPoPPrivateKeyMultibase); err != nil {
+
return nil, fmt.Errorf("scan row: %w", err)
+
}
+
+
if did != "" {
+
parsedDID, err := syntax.ParseDID(did)
+
if err != nil {
+
return nil, fmt.Errorf("invalid DID stored in record: %w", err)
+
}
+
oauthRequest.AccountDID = &parsedDID
+
}
+
+
if scopesStr != "" {
+
var scopes []string
+
err = json.Unmarshal([]byte(scopesStr), &scopes)
+
if err != nil {
+
return nil, fmt.Errorf("decode scopes in record: %w", err)
+
}
+
oauthRequest.Scopes = scopes
+
}
+
+
return &oauthRequest, nil
+
}
+
return nil, fmt.Errorf("not found")
+
}
+
+
func (d *DB) DeleteAuthRequestInfo(ctx context.Context, state string) error {
+
sql := "DELETE FROM oauthrequests WHERE state = ?;"
+
_, err := d.db.Exec(sql, state)
+
if err != nil {
+
return fmt.Errorf("exec delete oauth request: %w", err)
+
}
+
return nil
+
}
+97
database/oauth_sessions.go
···
+
package database
+
+
import (
+
"context"
+
"database/sql"
+
"encoding/json"
+
"fmt"
+
"log/slog"
+
+
"github.com/bluesky-social/indigo/atproto/auth/oauth"
+
"github.com/bluesky-social/indigo/atproto/syntax"
+
)
+
+
func createOauthSessionsTable(db *sql.DB) error {
+
createOauthSessionsTableSQL := `CREATE TABLE IF NOT EXISTS oauthsessions (
+
"id" integer NOT NULL PRIMARY KEY AUTOINCREMENT,
+
"accountDID" TEXT,
+
"sessionID" TEXT,
+
"hostURL" TEXT,
+
"authServerURL" TEXT,
+
"authServerTokenEndpoint" TEXT,
+
"scopes" TEXT,
+
"accessToken" TEXT,
+
"refreshToken" TEXT,
+
"dpopAuthServerNonce" TEXT,
+
"dpopHostNonce" TEXT,
+
"dpopPrivateKeyMultibase" TEXT,
+
UNIQUE(accountDID,sessionID)
+
);`
+
+
slog.Info("Create oauthsessions table...")
+
statement, err := db.Prepare(createOauthSessionsTableSQL)
+
if err != nil {
+
return fmt.Errorf("prepare DB statement to create oauthsessions table: %w", err)
+
}
+
_, err = statement.Exec()
+
if err != nil {
+
return fmt.Errorf("exec sql statement to create oauthsessions table: %w", err)
+
}
+
slog.Info("oauthsessions table created")
+
+
return nil
+
}
+
+
func (d *DB) SaveSession(ctx context.Context, sess oauth.ClientSessionData) error {
+
scopes, err := json.Marshal(sess.Scopes)
+
if err != nil {
+
return fmt.Errorf("marshalling scopes: %w", err)
+
}
+
+
sql := `INSERT INTO oauthsessions (accountDID, sessionID, hostURL, authServerURL, authServerTokenEndpoint, scopes, accessToken, refreshToken, dpopAuthServerNonce, dpopHostNonce, dpopPrivateKeyMultibase) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(accountDID,sessionID) DO NOTHING;`
+
_, err = d.db.Exec(sql, sess.AccountDID.String(), sess.SessionID, sess.HostURL, sess.AuthServerURL, sess.AuthServerTokenEndpoint, string(scopes), sess.AccessToken, sess.RefreshToken, sess.DPoPAuthServerNonce, sess.DPoPHostNonce, sess.DPoPPrivateKeyMultibase)
+
if err != nil {
+
slog.Error("saving session", "error", err)
+
return fmt.Errorf("exec insert oauth session: %w", err)
+
}
+
+
return nil
+
}
+
+
func (d *DB) GetSession(ctx context.Context, did syntax.DID, sessionID string) (*oauth.ClientSessionData, error) {
+
var session oauth.ClientSessionData
+
sql := "SELECT hostURL, authServerURL, authServerTokenEndpoint, scopes, accessToken, refreshToken, dpopAuthServerNonce, dpopHostNonce, dpopPrivateKeyMultibase FROM oauthsessions where accountDID = ? AND sessionID = ?;"
+
rows, err := d.db.Query(sql, did.String(), sessionID)
+
if err != nil {
+
return nil, fmt.Errorf("run query to get oauth session: %w", err)
+
}
+
defer rows.Close()
+
+
scopes := ""
+
for rows.Next() {
+
if err := rows.Scan(&session.HostURL, &session.AuthServerURL, &session.AuthServerTokenEndpoint, &scopes, &session.AccessToken, &session.RefreshToken, &session.DPoPAuthServerNonce, &session.DPoPHostNonce, &session.DPoPPrivateKeyMultibase); err != nil {
+
return nil, fmt.Errorf("scan row: %w", err)
+
}
+
session.AccountDID = did
+
+
var parsedScopes []string
+
err = json.Unmarshal([]byte(scopes), &parsedScopes)
+
if err != nil {
+
return nil, fmt.Errorf("parsing scopes: %w", err)
+
}
+
+
session.Scopes = parsedScopes
+
+
return &session, nil
+
}
+
return nil, fmt.Errorf("not found")
+
}
+
+
func (d *DB) DeleteSession(ctx context.Context, did syntax.DID, sessionID string) error {
+
sql := "DELETE FROM oauthsessions WHERE accountDID = ?;"
+
_, err := d.db.Exec(sql, did.String())
+
if err != nil {
+
return fmt.Errorf("exec delete oauth session: %w", err)
+
}
+
return nil
+
}
+89
database/urls.go
···
+
package database
+
+
import (
+
"database/sql"
+
"fmt"
+
"log/slog"
+
+
atshorter "tangled.sh/willdot.net/at-shorter-url"
+
)
+
+
func createURLsTable(db *sql.DB) error {
+
createURLsTableSQL := `CREATE TABLE IF NOT EXISTS urls (
+
"id" TEXT NOT NULL PRIMARY KEY,
+
"url" TEXT NOT NULL,
+
"did" TEXT NOT NULL,
+
"createdAt" integer
+
);`
+
+
slog.Info("Create urls table...")
+
statement, err := db.Prepare(createURLsTableSQL)
+
if err != nil {
+
return fmt.Errorf("prepare DB statement to create urls table: %w", err)
+
}
+
_, err = statement.Exec()
+
if err != nil {
+
return fmt.Errorf("exec sql statement to create urls table: %w", err)
+
}
+
slog.Info("status urls created")
+
+
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)
+
}
+
+
return nil
+
}
+
+
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)
+
}
+
defer rows.Close()
+
+
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)
+
}
+
+
results = append(results, shortURL)
+
}
+
return results, nil
+
}
+
+
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)
+
}
+
defer rows.Close()
+
+
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
+
}
+
return atshorter.ShortURL{}, atshorter.ErrorNotFound
+
}
+
+
func (s *DB) DeleteURL(id, did string) error {
+
sql := "DELETE FROM urls WHERE id = ? AND did = ?;"
+
_, err := s.db.Exec(sql, id, did)
+
if err != nil {
+
return fmt.Errorf("exec delete URL by id and DID: %w", err)
+
}
+
return nil
+
}
+5
example.env
···
+
PRIVATEJWKS="a generated JWKS key"
+
SESSION_KEY="some random secret"
+
HOST="the host of the service such as https://my-url-shortner.com"
+
DATABASE_PATH="./"
+
JS_SERVER_ADDR="set to a different Jetstream instance"
+64
go.mod
···
+
module tangled.sh/willdot.net/at-shorter-url
+
+
go 1.25.0
+
+
require (
+
github.com/avast/retry-go/v4 v4.6.1
+
github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e
+
github.com/bluesky-social/jetstream v0.0.0-20250815235753-306e46369336
+
github.com/glebarez/go-sqlite v1.22.0
+
github.com/gorilla/sessions v1.4.0
+
github.com/joho/godotenv v1.5.1
+
)
+
+
require (
+
github.com/beorn7/perks v1.0.1 // indirect
+
github.com/carlmjohnson/versioninfo v0.22.5 // indirect
+
github.com/cespare/xxhash/v2 v2.3.0 // indirect
+
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
+
github.com/dustin/go-humanize v1.0.1 // indirect
+
github.com/goccy/go-json v0.10.2 // indirect
+
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
+
github.com/google/go-querystring v1.1.0 // indirect
+
github.com/google/uuid v1.6.0 // indirect
+
github.com/gorilla/securecookie v1.1.2 // indirect
+
github.com/gorilla/websocket v1.5.1 // indirect
+
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
+
github.com/ipfs/go-cid v0.4.1 // indirect
+
github.com/klauspost/compress v1.18.0 // indirect
+
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
+
github.com/mattn/go-isatty v0.0.20 // indirect
+
github.com/minio/sha256-simd v1.0.1 // indirect
+
github.com/mr-tron/base58 v1.2.0 // indirect
+
github.com/multiformats/go-base32 v0.1.0 // indirect
+
github.com/multiformats/go-base36 v0.2.0 // indirect
+
github.com/multiformats/go-multibase v0.2.0 // indirect
+
github.com/multiformats/go-multihash v0.2.3 // indirect
+
github.com/multiformats/go-varint v0.0.7 // indirect
+
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
+
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
+
github.com/prometheus/client_golang v1.23.0 // indirect
+
github.com/prometheus/client_model v0.6.2 // indirect
+
github.com/prometheus/common v0.65.0 // indirect
+
github.com/prometheus/procfs v0.17.0 // indirect
+
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
+
github.com/spaolacci/murmur3 v1.1.0 // indirect
+
github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e // indirect
+
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect
+
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect
+
go.opentelemetry.io/otel v1.29.0 // indirect
+
go.opentelemetry.io/otel/metric v1.29.0 // indirect
+
go.opentelemetry.io/otel/trace v1.29.0 // indirect
+
go.uber.org/atomic v1.11.0 // indirect
+
golang.org/x/crypto v0.41.0 // indirect
+
golang.org/x/net v0.42.0 // indirect
+
golang.org/x/sys v0.35.0 // indirect
+
golang.org/x/time v0.12.0 // indirect
+
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect
+
google.golang.org/protobuf v1.36.7 // indirect
+
lukechampine.com/blake3 v1.2.1 // indirect
+
modernc.org/libc v1.37.6 // indirect
+
modernc.org/mathutil v1.6.0 // indirect
+
modernc.org/memory v1.7.2 // indirect
+
modernc.org/sqlite v1.28.0 // indirect
+
)
+174
go.sum
···
+
github.com/avast/retry-go/v4 v4.6.1 h1:VkOLRubHdisGrHnTu89g08aQEWEgRU7LVEop3GbIcMk=
+
github.com/avast/retry-go/v4 v4.6.1/go.mod h1:V6oF8njAwxJ5gRo1Q7Cxab24xs5NCWZBeaHHBklR8mA=
+
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
+
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
+
github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e h1:IutKPwmbU0LrYqw03EuwJtMdAe67rDTrL1U8S8dicRU=
+
github.com/bluesky-social/indigo v0.0.0-20251003000214-3259b215110e/go.mod h1:n6QE1NDPFoi7PRbMUZmc2y7FibCqiVU4ePpsvhHUBR8=
+
github.com/bluesky-social/jetstream v0.0.0-20250815235753-306e46369336 h1:NM3wfeFUrdjCE/xHLXQorwQvEKlI9uqnWl7L0Y9KA8U=
+
github.com/bluesky-social/jetstream v0.0.0-20250815235753-306e46369336/go.mod h1:3ihWQCbXeayg41G8lQ5DfB/3NnEhl0XX24eZ3mLpf7Q=
+
github.com/carlmjohnson/versioninfo v0.22.5 h1:O00sjOLUAFxYQjlN/bzYTuZiS0y6fWDQjMRvwtKgwwc=
+
github.com/carlmjohnson/versioninfo v0.22.5/go.mod h1:QT9mph3wcVfISUKd0i9sZfVrPviHuSF+cUtLjm2WSf8=
+
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
+
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
+
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
+
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
+
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
+
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
+
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
+
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
+
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
+
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
+
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
+
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
+
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
+
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
+
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
+
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
+
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
+
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
+
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
+
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
+
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
+
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
+
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
+
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
+
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
+
github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
+
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
+
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
+
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
+
github.com/hashicorp/go-retryablehttp v0.7.5 h1:bJj+Pj19UZMIweq/iie+1u5YCdGrnxCT9yvm0e+Nd5M=
+
github.com/hashicorp/go-retryablehttp v0.7.5/go.mod h1:Jy/gPYAdjqffZ/yFGCFV2doI5wjtH1ewM9u8iYVjtX8=
+
github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c=
+
github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
+
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
+
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
+
github.com/ipfs/bbloom v0.0.4 h1:Gi+8EGJ2y5qiD5FbsbpX/TMNcJw8gSqr7eyjHa4Fhvs=
+
github.com/ipfs/bbloom v0.0.4/go.mod h1:cS9YprKXpoZ9lT0n/Mw/a6/aFV6DTjTLYHeA+gyqMG0=
+
github.com/ipfs/go-block-format v0.2.0 h1:ZqrkxBA2ICbDRbK8KJs/u0O3dlp6gmAuuXUJNiW1Ycs=
+
github.com/ipfs/go-block-format v0.2.0/go.mod h1:+jpL11nFx5A/SPpsoBn6Bzkra/zaArfSmsknbPMYgzM=
+
github.com/ipfs/go-cid v0.4.1 h1:A/T3qGvxi4kpKWWcPC/PgbvDA2bjVLO7n4UeVwnbs/s=
+
github.com/ipfs/go-cid v0.4.1/go.mod h1:uQHwDeX4c6CtyrFwdqyhpNcxVewur1M7l7fNU7LKwZk=
+
github.com/ipfs/go-datastore v0.6.0 h1:JKyz+Gvz1QEZw0LsX1IBn+JFCJQH4SJVFtM4uWU0Myk=
+
github.com/ipfs/go-datastore v0.6.0/go.mod h1:rt5M3nNbSO/8q1t4LNkLyUwRs8HupMeN/8O4Vn9YAT8=
+
github.com/ipfs/go-ipfs-blockstore v1.3.1 h1:cEI9ci7V0sRNivqaOr0elDsamxXFxJMMMy7PTTDQNsQ=
+
github.com/ipfs/go-ipfs-blockstore v1.3.1/go.mod h1:KgtZyc9fq+P2xJUiCAzbRdhhqJHvsw8u2Dlqy2MyRTE=
+
github.com/ipfs/go-ipfs-ds-help v1.1.1 h1:B5UJOH52IbcfS56+Ul+sv8jnIV10lbjLF5eOO0C66Nw=
+
github.com/ipfs/go-ipfs-ds-help v1.1.1/go.mod h1:75vrVCkSdSFidJscs8n4W+77AtTpCIAdDGAwjitJMIo=
+
github.com/ipfs/go-ipfs-util v0.0.3 h1:2RFdGez6bu2ZlZdI+rWfIdbQb1KudQp3VGwPtdNCmE0=
+
github.com/ipfs/go-ipfs-util v0.0.3/go.mod h1:LHzG1a0Ig4G+iZ26UUOMjHd+lfM84LZCrn17xAKWBvs=
+
github.com/ipfs/go-ipld-cbor v0.1.0 h1:dx0nS0kILVivGhfWuB6dUpMa/LAwElHPw1yOGYopoYs=
+
github.com/ipfs/go-ipld-cbor v0.1.0/go.mod h1:U2aYlmVrJr2wsUBU67K4KgepApSZddGRDWBYR0H4sCk=
+
github.com/ipfs/go-ipld-format v0.6.0 h1:VEJlA2kQ3LqFSIm5Vu6eIlSxD/Ze90xtc4Meten1F5U=
+
github.com/ipfs/go-ipld-format v0.6.0/go.mod h1:g4QVMTn3marU3qXchwjpKPKgJv+zF+OlaKMyhJ4LHPg=
+
github.com/ipfs/go-log v1.0.5 h1:2dOuUCB1Z7uoczMWgAyDck5JLb72zHzrMnGnCNNbvY8=
+
github.com/ipfs/go-log v1.0.5/go.mod h1:j0b8ZoR+7+R99LD9jZ6+AJsrzkPbSXbZfGakb5JPtIo=
+
github.com/ipfs/go-log/v2 v2.5.1 h1:1XdUzF7048prq4aBjDQQ4SL5RxftpRGdXhNRwKSAlcY=
+
github.com/ipfs/go-log/v2 v2.5.1/go.mod h1:prSpmC1Gpllc9UYWxDiZDreBYw7zp4Iqp1kOLU9U5UI=
+
github.com/ipfs/go-metrics-interface v0.0.1 h1:j+cpbjYvu4R8zbleSs36gvB7jR+wsL2fGD6n0jO4kdg=
+
github.com/ipfs/go-metrics-interface v0.0.1/go.mod h1:6s6euYU4zowdslK0GKHmqaIZ3j/b/tL7HTWtJ4VPgWY=
+
github.com/jbenet/goprocess v0.1.4 h1:DRGOFReOMqqDNXwW70QkacFW0YN9QnwLV0Vqk+3oU0o=
+
github.com/jbenet/goprocess v0.1.4/go.mod h1:5yspPrukOVuOLORacaBi858NqyClJPQxYZlqdZVfqY4=
+
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
+
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
+
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
+
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
+
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
+
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
+
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+
github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM=
+
github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8=
+
github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o=
+
github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc=
+
github.com/multiformats/go-base32 v0.1.0 h1:pVx9xoSPqEIQG8o+UbAe7DNi51oej1NtK+aGkbLYxPE=
+
github.com/multiformats/go-base32 v0.1.0/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI=
+
github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9rQyccr0=
+
github.com/multiformats/go-base36 v0.2.0/go.mod h1:qvnKE++v+2MWCfePClUEjE78Z7P2a1UV0xHgWc0hkp4=
+
github.com/multiformats/go-multibase v0.2.0 h1:isdYCVLvksgWlMW9OZRYJEa9pZETFivncJHmHnnd87g=
+
github.com/multiformats/go-multibase v0.2.0/go.mod h1:bFBZX4lKCA/2lyOFSAoKH5SS6oPyjtnzK/XTFDPkNuk=
+
github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7BFvVU9RSh+U=
+
github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM=
+
github.com/multiformats/go-varint v0.0.7 h1:sWSGR+f/eu5ABZA2ZpYKBILXTTs9JWpdEM/nEGOHFS8=
+
github.com/multiformats/go-varint v0.0.7/go.mod h1:r8PUYw/fD/SjBCiKOoDlGF6QawOELpZAu9eioSos/OU=
+
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
+
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
+
github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs=
+
github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc=
+
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
+
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+
github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f h1:VXTQfuJj9vKR4TCkEuWIckKvdHFeJH/huIFJ9/cXOB0=
+
github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f/go.mod h1:/zvteZs/GwLtCgZ4BL6CBsk9IKIlexP43ObX9AxTqTw=
+
github.com/prometheus/client_golang v1.23.0 h1:ust4zpdl9r4trLY/gSjlm07PuiBq2ynaXXlptpfy8Uc=
+
github.com/prometheus/client_golang v1.23.0/go.mod h1:i/o0R9ByOnHX0McrTMTyhYvKE4haaf2mW08I+jGAjEE=
+
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
+
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
+
github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE=
+
github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
+
github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0=
+
github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw=
+
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
+
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+
github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI=
+
github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
+
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
+
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+
github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e h1:28X54ciEwwUxyHn9yrZfl5ojgF4CBNLWX7LR0rvBkf4=
+
github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so=
+
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRySiX3drau9C6Q5CAbNIApmLdat5jPMqChvDA=
+
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8=
+
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAFgcfgTnfJrmYKWhHnci3GjDqcZp1M3Q=
+
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02/go.mod h1:JTnUj0mpYiAsuZLmKjTx/ex3AtMowcCgnE7YNyCEP0I=
+
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 h1:aFJWCqJMNjENlcleuuOkGAPH82y0yULBScfXcIEdS24=
+
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1/go.mod h1:sEGXWArGqc3tVa+ekntsN65DmVbVeW+7lTKTjZF3/Fo=
+
go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw=
+
go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8=
+
go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc=
+
go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8=
+
go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4=
+
go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ=
+
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
+
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
+
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
+
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
+
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
+
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
+
go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo=
+
go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so=
+
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
+
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
+
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
+
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
+
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
+
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
+
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
+
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU=
+
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
+
google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A=
+
google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
+
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+
lukechampine.com/blake3 v1.2.1 h1:YuqqRuaqsGV71BV/nm9xlI0MKUv4QC54jQnBChWbGnI=
+
lukechampine.com/blake3 v1.2.1/go.mod h1:0OFRp7fBtAylGVCO40o87sbupkyIGgbpv1+M1k1LM6k=
+
modernc.org/libc v1.37.6 h1:orZH3c5wmhIQFTXF+Nt+eeauyd+ZIt2BX6ARe+kD+aw=
+
modernc.org/libc v1.37.6/go.mod h1:YAXkAZ8ktnkCKaN9sw/UDeUVkGYJ/YquGO4FTi5nmHE=
+
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
+
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
+
modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E=
+
modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E=
+
modernc.org/sqlite v1.28.0 h1:Zx+LyDDmXczNnEQdvPuEfcFVA2ZPyaD7UCZDjef3BHQ=
+
modernc.org/sqlite v1.28.0/go.mod h1:Qxpazz0zH8Z1xCFyi5GSL3FzbtZ3fvbjmywNogldEW0=
+230
html/app.css
···
+
body {
+
font-family: Arial, Helvetica, sans-serif;
+
+
--border-color: #ddd;
+
--gray-100: #fafafa;
+
--gray-500: #666;
+
--gray-700: #333;
+
--primary-100: #d2e7ff;
+
--primary-200: #b1d3fa;
+
--primary-400: #2e8fff;
+
--primary-500: #0078ff;
+
--primary-600: #0066db;
+
--error-500: #f00;
+
--error-100: #fee;
+
}
+
+
/*
+
Josh's Custom CSS Reset
+
https://www.joshwcomeau.com/css/custom-css-reset/
+
*/
+
*,
+
*::before,
+
*::after {
+
box-sizing: border-box;
+
}
+
* {
+
margin: 0;
+
}
+
body {
+
line-height: 1.5;
+
-webkit-font-smoothing: antialiased;
+
}
+
img,
+
picture,
+
video,
+
canvas,
+
svg {
+
display: block;
+
max-width: 100%;
+
}
+
input,
+
button,
+
textarea,
+
select {
+
font: inherit;
+
}
+
p,
+
h1,
+
h2,
+
h3,
+
h4,
+
h5,
+
h6 {
+
overflow-wrap: break-word;
+
}
+
#root,
+
#__next {
+
isolation: isolate;
+
}
+
+
/*
+
Common components
+
*/
+
button,
+
.button {
+
display: inline-block;
+
border: 0;
+
background-color: var(--primary-500);
+
border-radius: 50px;
+
color: #fff;
+
padding: 2px 10px;
+
cursor: pointer;
+
text-decoration: none;
+
}
+
button:hover,
+
.button:hover {
+
background: var(--primary-400);
+
}
+
+
/*
+
Custom components
+
*/
+
.error {
+
background-color: var(--error-100);
+
color: var(--error-500);
+
text-align: center;
+
padding: 1rem;
+
display: none;
+
}
+
.error.visible {
+
display: block;
+
}
+
+
#header {
+
background-color: #fff;
+
text-align: center;
+
padding: 0.5rem 0 1.5rem;
+
}
+
+
#header h1 {
+
font-size: 5rem;
+
}
+
+
.container {
+
display: flex;
+
flex-direction: column;
+
gap: 4px;
+
margin: 0 auto;
+
max-width: 600px;
+
padding: 20px;
+
}
+
+
.card {
+
/* border: 1px solid var(--border-color); */
+
border-radius: 6px;
+
padding: 10px 16px;
+
background-color: #fff;
+
}
+
.card > :first-child {
+
margin-top: 0;
+
}
+
.card > :last-child {
+
margin-bottom: 0;
+
}
+
+
.session-form {
+
display: flex;
+
flex-direction: row;
+
align-items: center;
+
justify-content: space-between;
+
}
+
+
.login-form {
+
display: flex;
+
flex-direction: row;
+
gap: 6px;
+
border: 1px solid var(--border-color);
+
border-radius: 6px;
+
padding: 10px 16px;
+
background-color: #fff;
+
}
+
+
.login-form input {
+
flex: 1;
+
border: 0;
+
}
+
+
.status-options {
+
display: flex;
+
flex-direction: row;
+
flex-wrap: wrap;
+
gap: 8px;
+
margin: 10px 0;
+
}
+
+
.status-option {
+
font-size: 2rem;
+
width: 3rem;
+
height: 3rem;
+
padding: 0;
+
background-color: #fff;
+
border: 1px solid var(--border-color);
+
border-radius: 3rem;
+
text-align: center;
+
box-shadow: 0 1px 4px #0001;
+
cursor: pointer;
+
}
+
+
.status-option:hover {
+
background-color: var(--primary-100);
+
box-shadow: 0 0 0 1px var(--primary-400);
+
}
+
+
.status-option.selected {
+
box-shadow: 0 0 0 1px var(--primary-500);
+
background-color: var(--primary-100);
+
}
+
+
.status-option.selected:hover {
+
background-color: var(--primary-200);
+
}
+
+
.status-line {
+
display: flex;
+
flex-direction: row;
+
align-items: center;
+
gap: 10px;
+
position: relative;
+
margin-top: 15px;
+
}
+
+
.status-line:not(.no-line)::before {
+
content: "";
+
position: absolute;
+
width: 2px;
+
background-color: var(--border-color);
+
left: 1.45rem;
+
bottom: calc(100% + 2px);
+
height: 15px;
+
}
+
+
.status-line .status {
+
font-size: 2rem;
+
background-color: #fff;
+
width: 3rem;
+
height: 3rem;
+
border-radius: 1.5rem;
+
text-align: center;
+
border: 1px solid var(--border-color);
+
}
+
+
.status-line .desc {
+
color: var(--gray-500);
+
}
+
+
.status-line .author {
+
color: var(--gray-700);
+
font-weight: 600;
+
text-decoration: none;
+
}
+
+
.status-line .author:hover {
+
text-decoration: underline;
+
}
+
+
.signup-cta {
+
text-align: center;
+
text-wrap: balance;
+
margin-top: 1rem;
+
}
+48
html/home.html
···
+
<!doctype html>
+
<html lang="en">
+
<head>
+
<title>AT-Shorter</title>
+
<link rel="icon" type="image/x-icon" href="/public/favicon.ico" />
+
<meta charset="UTF-8" />
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
+
<link href="/public/app.css" rel="stylesheet" />
+
</head>
+
<body>
+
<div id="header">
+
<h1>AT-Shorter</h1>
+
<p>Create you own short URLs</p>
+
</div>
+
<div class="container">
+
<div class="card">
+
<form action="/logout" method="post" class="session-form">
+
<div>Create your own short URL now!</div>
+
<div>
+
<button type="submit">Log out</button>
+
</div>
+
</form>
+
</div>
+
<form action="/create-url" method="post" class="status-options">
+
<label for="newURL">URL to shorten:</label>
+
<input type="text" id="newURL" name="newURL"><br><br>
+
<button type="submit">Create</button>
+
</form>
+
{{range .UsersShortURLs}}
+
<tr>
+
<td>
+
<a href="http://127.0.0.1:8080/a/{{.ID}}">{{.ID}}</a>
+
</td>
+
<td>
+
<a href="{{ .URL }}">{{.URL}}</a>
+
</td>
+
<form action="/delete/{{.ID}}" method="post" class="status-options">
+
<td>
+
<button>
+
<p class="text-sm">Delete</p>
+
</button>
+
</td>
+
</form>
+
</tr>
+
{{end}}
+
</div>
+
</body>
+
</html>
+39
html/login.html
···
+
<!doctype html>
+
<html lang="en">
+
<head>
+
<title>AT-Shorter</title>
+
<link rel="icon" type="image/x-icon" href="/public/favicon.ico" />
+
<meta charset="UTF-8" />
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
+
<link href="/public/app.css" rel="stylesheet" />
+
</head>
+
<body>
+
<div id="header">
+
<h1>AT-Shorter</h1>
+
<p>Create you own short URLs</p>
+
</div>
+
<div class="container">
+
<form action="/login" method="post" class="login-form">
+
<input
+
type="text"
+
name="handle"
+
placeholder="Enter your handle (eg alice.bsky.social)"
+
required
+
/>
+
<button type="submit">Log in</button>
+
</form>
+
{{if .Error}}
+
<div>{{ .Error }}</div>
+
{{else}}
+
<div>
+
<br />
+
</div>
+
{{end}}
+
<div class="signup-cta">
+
Don't have an account on the Atmosphere?
+
<a href="https://bsky.app">Sign up for Bluesky</a> to create one
+
now!
+
</div>
+
</div>
+
</body>
+
</html>
+192
server.go
···
+
package atshorter
+
+
import (
+
"context"
+
_ "embed"
+
"encoding/json"
+
"fmt"
+
"log/slog"
+
"net/http"
+
"os"
+
"sync"
+
"text/template"
+
+
"github.com/bluesky-social/indigo/atproto/auth/oauth"
+
"github.com/bluesky-social/indigo/atproto/identity"
+
"github.com/bluesky-social/indigo/atproto/syntax"
+
"github.com/gorilla/sessions"
+
)
+
+
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
+
}
+
+
type Server struct {
+
host string
+
httpserver *http.Server
+
sessionStore *sessions.CookieStore
+
templates []*template.Template
+
+
oauthClient *oauth.ClientApp
+
store Store
+
httpClient *http.Client
+
+
didHostCache map[string]string
+
mu sync.Mutex
+
}
+
+
func NewServer(host string, port int, 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)
+
}
+
+
templates := []*template.Template{
+
homeTemplate,
+
loginTemplate,
+
}
+
+
srv := &Server{
+
host: host,
+
oauthClient: oauthClient,
+
sessionStore: sessionStore,
+
templates: templates,
+
store: store,
+
httpClient: httpClient,
+
didHostCache: make(map[string]string),
+
}
+
+
mux := http.NewServeMux()
+
+
mux.HandleFunc("GET /login", srv.HandleLogin)
+
mux.HandleFunc("POST /login", srv.HandlePostLogin)
+
mux.HandleFunc("POST /logout", srv.HandleLogOut)
+
+
mux.HandleFunc("GET /", srv.authMiddleware(srv.HandleHome))
+
mux.HandleFunc("GET /a/{id}", srv.HandleRedirect)
+
mux.HandleFunc("POST /delete/{id}", srv.authMiddleware(srv.HandleDeleteURL))
+
mux.HandleFunc("POST /create-url", srv.authMiddleware(srv.HandleCreateShortURL))
+
+
mux.HandleFunc("GET /public/app.css", serveCSS)
+
mux.HandleFunc("GET /jwks.json", srv.serveJwks)
+
mux.HandleFunc("GET /oauth-client-metadata.json", srv.serveClientMetadata)
+
mux.HandleFunc("GET /oauth-callback", srv.handleOauthCallback)
+
+
addr := fmt.Sprintf("0.0.0.0:%d", port)
+
srv.httpserver = &http.Server{
+
Addr: addr,
+
Handler: mux,
+
}
+
+
return srv, nil
+
}
+
+
func (s *Server) Run() {
+
err := s.httpserver.ListenAndServe()
+
if err != nil {
+
slog.Error("listen and serve", "error", err)
+
}
+
}
+
+
func (s *Server) Stop(ctx context.Context) error {
+
return s.httpserver.Shutdown(ctx)
+
}
+
+
func (s *Server) getTemplate(name string) *template.Template {
+
for _, template := range s.templates {
+
if template.Name() == name {
+
return template
+
}
+
}
+
return nil
+
}
+
+
func (s *Server) serveJwks(w http.ResponseWriter, _ *http.Request) {
+
w.Header().Set("Content-Type", "application/json")
+
+
public := s.oauthClient.Config.PublicJWKS()
+
b, err := json.Marshal(public)
+
if err != nil {
+
slog.Error("failed to marshal oauth public JWKS", "error", err)
+
http.Error(w, "marshal public JWKS", http.StatusInternalServerError)
+
return
+
}
+
+
_, _ = w.Write(b)
+
}
+
+
//go:embed html/app.css
+
var cssFile []byte
+
+
func serveCSS(w http.ResponseWriter, r *http.Request) {
+
w.Header().Set("Content-Type", "text/css; charset=utf-8")
+
_, _ = w.Write(cssFile)
+
}
+
+
func (s *Server) serveClientMetadata(w http.ResponseWriter, r *http.Request) {
+
metadata := s.oauthClient.Config.ClientMetadata()
+
clientName := "at-shorter-url"
+
metadata.ClientName = &clientName
+
metadata.ClientURI = &s.host
+
if s.oauthClient.Config.IsConfidential() {
+
jwksURI := fmt.Sprintf("%s/jwks.json", r.Host)
+
metadata.JWKSURI = &jwksURI
+
}
+
+
b, err := json.Marshal(metadata)
+
if err != nil {
+
slog.Error("failed to marshal client metadata", "error", err)
+
http.Error(w, "marshal response", http.StatusInternalServerError)
+
return
+
}
+
w.Header().Set("Content-Type", "application/json")
+
_, _ = w.Write(b)
+
}
+
+
func (s *Server) lookupDidHost(ctx context.Context, didStr string) (string, error) {
+
cachedResult, ok := s.checkDidHostInCache(didStr)
+
if ok {
+
return cachedResult, nil
+
}
+
+
did, err := syntax.ParseAtIdentifier(didStr)
+
if err != nil {
+
return "", fmt.Errorf("parsing did: %w", err)
+
}
+
+
dir := identity.DefaultDirectory()
+
acc, err := dir.Lookup(ctx, *did)
+
if err != nil {
+
return "", fmt.Errorf("looking up did: %w", err)
+
}
+
+
s.addDidHostToCache(didStr, acc.PDSEndpoint())
+
+
return acc.PDSEndpoint(), nil
+
}
+
+
func (s *Server) checkDidHostInCache(did string) (string, bool) {
+
s.mu.Lock()
+
defer s.mu.Unlock()
+
+
endpoint, ok := s.didHostCache[did]
+
return endpoint, ok
+
}
+
+
func (s *Server) addDidHostToCache(did, host string) {
+
s.mu.Lock()
+
defer s.mu.Unlock()
+
+
s.didHostCache[did] = host
+
}
+224
short_url_handler.go
···
+
package atshorter
+
+
import (
+
"context"
+
"errors"
+
"fmt"
+
"log/slog"
+
"net/http"
+
"time"
+
+
"github.com/bluesky-social/indigo/atproto/client"
+
)
+
+
type HomeData struct {
+
UsersShortURLs []ShortURL
+
}
+
+
type ShortURL struct {
+
ID string
+
URL string
+
Did string
+
}
+
+
func (s *Server) HandleRedirect(w http.ResponseWriter, r *http.Request) {
+
id := r.PathValue("id")
+
if id == "" {
+
http.Redirect(w, r, "/", http.StatusSeeOther)
+
return
+
}
+
shortURL, err := s.store.GetURLByID(id)
+
if err != nil {
+
if errors.Is(err, ErrorNotFound) {
+
slog.Error("url with ID not found", "id", id)
+
http.Error(w, "not found", http.StatusNotFound)
+
return
+
}
+
slog.Error("getting URL by id", "id", id, "error", err)
+
http.Error(w, "error fetching URL for redirect", http.StatusInternalServerError)
+
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
+
}
+
+
func (s *Server) HandleHome(w http.ResponseWriter, r *http.Request) {
+
tmpl := s.getTemplate("home.html")
+
+
did, _ := s.currentSessionDID(r)
+
if did == nil {
+
http.Redirect(w, r, "/login", http.StatusFound)
+
return
+
}
+
+
data := HomeData{}
+
+
usersURLs, err := s.store.GetURLs(did.String())
+
if err != nil {
+
slog.Error("fetching URLs", "error", err)
+
tmpl.Execute(w, data)
+
return
+
}
+
+
data.UsersShortURLs = usersURLs
+
+
tmpl.Execute(w, data)
+
}
+
+
func (s *Server) HandleDeleteURL(w http.ResponseWriter, r *http.Request) {
+
id := r.PathValue("id")
+
if id == "" {
+
http.Redirect(w, r, "/", http.StatusSeeOther)
+
return
+
}
+
+
did, sessionID := s.currentSessionDID(r)
+
if did == nil {
+
http.Redirect(w, r, "/login", http.StatusFound)
+
return
+
}
+
+
shortURL, err := s.store.GetURLByID(id)
+
if err != nil {
+
slog.Error("looking up short URL", "error", err)
+
http.Redirect(w, r, "/", http.StatusSeeOther)
+
return
+
}
+
+
if shortURL.Did != did.String() {
+
slog.Error("tried to delete record that doesn't belong to user")
+
http.Error(w, "not authenticated", http.StatusUnauthorized)
+
return
+
}
+
+
session, err := s.oauthClient.ResumeSession(r.Context(), *did, sessionID)
+
if err != nil {
+
http.Error(w, "not authenticated", http.StatusUnauthorized)
+
return
+
}
+
+
api := session.APIClient()
+
+
bodyReq := map[string]any{
+
"repo": shortURL.Did,
+
"collection": "com.atshorter.shorturl",
+
"rkey": id,
+
}
+
err = api.Post(r.Context(), "com.atproto.repo.deleteRecord", bodyReq, nil)
+
if err != nil {
+
slog.Error("failed to delete short URL record", "error", err)
+
http.Redirect(w, r, "/", http.StatusFound)
+
return
+
}
+
+
err = s.store.DeleteURL(id, did.String())
+
if err != nil {
+
slog.Error("deleting URL from store", "error", err, "id", id, "did", did.String())
+
http.Redirect(w, r, "/", http.StatusSeeOther)
+
return
+
}
+
+
http.Redirect(w, r, "/", http.StatusSeeOther)
+
return
+
}
+
+
func (s *Server) HandleCreateShortURL(w http.ResponseWriter, r *http.Request) {
+
err := r.ParseForm()
+
if err != nil {
+
slog.Error("parsing form", "error", err)
+
http.Error(w, "parsing form", http.StatusBadRequest)
+
return
+
}
+
+
url := r.Form.Get("newURL")
+
if url == "" {
+
slog.Error("newURL not provided")
+
http.Error(w, "missing newURL", http.StatusBadRequest)
+
return
+
}
+
+
did, sessionID := s.currentSessionDID(r)
+
if did == nil {
+
http.Redirect(w, r, "/login", http.StatusFound)
+
return
+
}
+
+
session, err := s.oauthClient.ResumeSession(r.Context(), *did, sessionID)
+
if err != nil {
+
http.Error(w, "not authenticated", http.StatusUnauthorized)
+
return
+
}
+
+
rkey := TID()
+
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 {
+
slog.Error("failed to create new short URL record", "error", err)
+
http.Redirect(w, r, "/", http.StatusFound)
+
return
+
}
+
+
err = s.store.CreateURL(rkey, url, did.String(), createdAt.UnixMilli())
+
if err != nil {
+
slog.Error("store in local database", "error", err)
+
}
+
+
http.Redirect(w, r, "/", http.StatusFound)
+
}
+
+
type GetRecordResult struct {
+
URI string `json:"uri"`
+
CID string `json:"cid"`
+
Value ShortURLRecord `json:"value"`
+
}
+
+
func (s *Server) getUrlRecord(ctx context.Context, didStr, rkey string) (ShortURLRecord, error) {
+
host, err := s.lookupDidHost(ctx, didStr)
+
if err != nil {
+
return ShortURLRecord{}, fmt.Errorf("looking up did host: %w", err)
+
}
+
+
atClient := client.APIClient{
+
Client: s.httpClient,
+
Host: host,
+
}
+
+
params := map[string]any{
+
"repo": didStr,
+
"collection": "com.atshorter.shorturl",
+
"rkey": rkey,
+
}
+
+
var res GetRecordResult
+
err = atClient.Get(ctx, "com.atproto.repo.getRecord", params, &res)
+
if err != nil {
+
return ShortURLRecord{}, fmt.Errorf("calling getRecord: %w", err)
+
}
+
+
return res.Value, nil
+
}
+9
tid.go
···
+
package atshorter
+
+
import "github.com/bluesky-social/indigo/atproto/syntax"
+
+
var TIDClock = syntax.NewTIDClock(0)
+
+
func TID() string {
+
return TIDClock.Next().String()
+
}