A URL shortener service that uses ATProto to allow self hosting and ensuring the user owns their data
at main 3.3 kB view raw
1package main 2 3import ( 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 23const ( 24 defaultServerAddr = "wss://jetstream.atproto.tools/subscribe" 25 httpClientTimeoutDuration = time.Second * 5 26 transportIdleConnTimeoutDuration = time.Second * 90 27 defaultPort = "8080" 28) 29 30func main() { 31 envLocation := os.Getenv("ENV_LOCATION") 32 if envLocation == "" { 33 envLocation = ".env" 34 } 35 36 err := godotenv.Load(envLocation) 37 if err != nil { 38 if !os.IsNotExist(err) { 39 log.Fatal("Error loading .env file") 40 } 41 } 42 43 host := os.Getenv("HOST") 44 if host == "" { 45 slog.Warn("missing HOST env variable") 46 } 47 48 dbMountPath := os.Getenv("DATABASE_PATH") 49 if dbMountPath == "" { 50 slog.Error("DATABASE_PATH env not set") 51 return 52 } 53 54 usersDID := os.Getenv("DID") 55 if usersDID == "" { 56 slog.Error("DID env not set") 57 return 58 } 59 60 dbFilename := path.Join(dbMountPath, "database.db") 61 db, err := database.New(dbFilename) 62 if err != nil { 63 slog.Error("create new database", "error", err) 64 return 65 } 66 defer db.Close() 67 68 var config oauth.ClientConfig 69 port := os.Getenv("PORT") 70 if port == "" { 71 port = defaultPort 72 } 73 74 scopes := []string{ 75 "atproto", 76 "repo:com.atshorter.shorturl?action=create", 77 "repo:com.atshorter.shorturl?action=update", 78 "repo:com.atshorter.shorturl?action=delete", 79 } 80 if host == "" { 81 host = fmt.Sprintf("http://127.0.0.1:%s", port) 82 config = oauth.NewLocalhostConfig( 83 fmt.Sprintf("%s/oauth-callback", host), 84 scopes, 85 ) 86 slog.Info("configuring localhost OAuth client", "CallbackURL", config.CallbackURL) 87 } else { 88 config = oauth.NewPublicConfig( 89 fmt.Sprintf("%s/oauth-client-metadata.json", host), 90 fmt.Sprintf("%s/oauth-callback", host), 91 scopes, 92 ) 93 } 94 oauthClient := oauth.NewClientApp(&config, db) 95 96 httpClient := &http.Client{ 97 Timeout: httpClientTimeoutDuration, 98 Transport: &http.Transport{ 99 IdleConnTimeout: transportIdleConnTimeoutDuration, 100 }, 101 } 102 103 server, err := atshorter.NewServer(host, port, db, oauthClient, httpClient, usersDID) 104 if err != nil { 105 slog.Error("create new server", "error", err) 106 return 107 } 108 109 signals := make(chan os.Signal, 1) 110 signal.Notify(signals, syscall.SIGTERM, syscall.SIGINT) 111 112 ctx, cancel := context.WithCancel(context.Background()) 113 defer cancel() 114 115 go func() { 116 <-signals 117 cancel() 118 _ = server.Stop(context.Background()) 119 }() 120 121 go consumeLoop(ctx, db, usersDID) 122 123 server.Run() 124} 125 126func consumeLoop(ctx context.Context, db *database.DB, did string) { 127 jsServerAddr := os.Getenv("JS_SERVER_ADDR") 128 if jsServerAddr == "" { 129 jsServerAddr = defaultServerAddr 130 } 131 132 consumer := atshorter.NewConsumer(jsServerAddr, slog.Default(), db, did) 133 134 err := retry.Do(func() error { 135 err := consumer.Consume(ctx) 136 if err != nil { 137 if errors.Is(err, context.Canceled) { 138 return nil 139 } 140 slog.Error("consume loop", "error", err) 141 return err 142 } 143 return nil 144 }, retry.UntilSucceeded()) // retry indefinitly until context canceled 145 slog.Error(err.Error()) 146 slog.Warn("exiting consume loop") 147}