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