A URL shortener service that uses ATProto to allow self hosting and ensuring the user owns their data
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}