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

Configure Feed

Select the types of activity you want to include in your feed.

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

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

willdot.net 50a8e205

+1725
+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 + }