+7
.gitignore
+7
.gitignore
+13
Makefile
+13
Makefile
···
1
+
css:
2
+
tailwindcss -i app.css -o public/styles.css --watch
3
+
4
+
templ:
5
+
templ generate --watch --proxy="http://localhost:8090" --open-browser=false -v
6
+
air:
7
+
air
8
+
dev:
9
+
make -j3 templ css air
10
+
11
+
docker:
12
+
@docker build -f Dockerfile -t willdot/templ-demo .
13
+
@docker push willdot/templ-demo
+33
air.toml
+33
air.toml
···
1
+
root = "."
2
+
tmp_dir = "tmp"
3
+
4
+
[build]
5
+
bin = "./tmp/main"
6
+
cmd = "go build -tags dev -o ./tmp/main ./"
7
+
8
+
delay = 20
9
+
exclude_dir = ["assets", "tmp", "vendor"]
10
+
exclude_file = []
11
+
exclude_regex = [".*_templ.go"]
12
+
exclude_unchanged = false
13
+
follow_symlink = false
14
+
full_bin = ""
15
+
include_dir = []
16
+
include_ext = ["go", "tpl", "tmpl", "templ", "html"]
17
+
kill_delay = "0s"
18
+
log = "build-errors.log"
19
+
send_interrupt = false
20
+
stop_on_error = true
21
+
22
+
[color]
23
+
app = ""
24
+
build = "yellow"
25
+
main = "magenta"
26
+
runner = "green"
27
+
watcher = "cyan"
28
+
29
+
[log]
30
+
time = false
31
+
32
+
[misc]
33
+
clean_on_exit = false
+65
-3
auth.go
+65
-3
auth.go
···
2
2
3
3
import (
4
4
"fmt"
5
+
"log/slog"
5
6
"net/http"
7
+
"strconv"
6
8
"strings"
9
+
"time"
7
10
8
11
"github.com/bluesky-social/indigo/atproto/crypto"
9
12
"github.com/bluesky-social/indigo/atproto/identity"
10
13
"github.com/bluesky-social/indigo/atproto/syntax"
11
14
"github.com/golang-jwt/jwt/v5"
15
+
"github.com/willdot/bskyfeedgen/frontend"
12
16
)
13
17
14
18
// The contents of this file have been borrowed from here: https://github.com/orthanc/bluesky-go-feeds/blob/f719f113f1afc9080e50b4b1f5ca239aa3073c79/web/auth.go#L20-L46
···
28
32
}
29
33
30
34
func (m *AtProtoSigningMethod) Verify(signingString string, signature []byte, key interface{}) error {
31
-
return key.(crypto.PublicKey).HashAndVerifyLenient([]byte(signingString), signature)
35
+
err := key.(crypto.PublicKey).HashAndVerifyLenient([]byte(signingString), signature)
36
+
return err
32
37
}
33
38
34
39
func (m *AtProtoSigningMethod) Sign(signingString string, key interface{}) ([]byte, error) {
···
45
50
jwt.RegisterSigningMethod(ES256.Alg(), func() jwt.SigningMethod {
46
51
return &ES256
47
52
})
53
+
48
54
}
49
55
50
56
var directory = identity.DefaultDirectory()
···
57
63
}
58
64
token := strings.TrimSpace(strings.Replace(headerValues[0], "Bearer ", "", 1))
59
65
60
-
validMethods := jwt.WithValidMethods([]string{ES256, ES256K})
61
-
62
66
keyfunc := func(token *jwt.Token) (interface{}, error) {
63
67
did := syntax.DID(token.Claims.(jwt.MapClaims)["iss"].(string))
64
68
identity, err := directory.LookupDID(r.Context(), did)
···
71
75
}
72
76
return key, nil
73
77
}
78
+
79
+
validMethods := jwt.WithValidMethods([]string{ES256, ES256K})
74
80
75
81
parsedToken, err := jwt.ParseWithClaims(token, jwt.MapClaims{}, keyfunc, validMethods)
76
82
if err != nil {
···
89
95
90
96
return string(syntax.DID(issVal)), nil
91
97
}
98
+
99
+
const (
100
+
jwtCookieName = "JWT"
101
+
didCookieName = "DID"
102
+
)
103
+
104
+
func (s *Server) authMiddleware(next func(http.ResponseWriter, *http.Request)) func(http.ResponseWriter, *http.Request) {
105
+
return func(w http.ResponseWriter, r *http.Request) {
106
+
jwtCookie, err := r.Cookie(jwtCookieName)
107
+
if err != nil {
108
+
slog.Error("read JWT cookie", "error", err)
109
+
frontend.Login("", "").Render(r.Context(), w)
110
+
return
111
+
}
112
+
if jwtCookie == nil {
113
+
slog.Error("missing JWT cookie")
114
+
frontend.Login("", "").Render(r.Context(), w)
115
+
return
116
+
}
117
+
118
+
didCookie, err := r.Cookie(didCookieName)
119
+
if err != nil {
120
+
slog.Error("read DID cookie", "error", err)
121
+
frontend.Login("", "").Render(r.Context(), w)
122
+
return
123
+
}
124
+
if didCookie == nil {
125
+
slog.Error("missing DID cookie")
126
+
frontend.Login("", "").Render(r.Context(), w)
127
+
return
128
+
}
129
+
130
+
claims := jwt.MapClaims{}
131
+
_, _, err = jwt.NewParser().ParseUnverified(jwtCookie.Value, &claims)
132
+
if err != nil {
133
+
slog.Error("parsing JWT", "error", err)
134
+
frontend.Login("", "").Render(r.Context(), w)
135
+
return
136
+
}
137
+
138
+
if expiry, ok := claims["exp"].(string); ok {
139
+
expiryInt, err := strconv.Atoi(expiry)
140
+
if err != nil {
141
+
slog.Error("invalid claims from token", "error", err)
142
+
frontend.Login("", "").Render(r.Context(), w)
143
+
return
144
+
}
145
+
146
+
if time.Now().Unix() > int64(expiryInt) {
147
+
frontend.Login("", "").Render(r.Context(), w)
148
+
return
149
+
}
150
+
}
151
+
next(w, r)
152
+
}
153
+
}
+5
feedgenerator.go
+5
feedgenerator.go
···
11
11
12
12
type feedStore interface {
13
13
GetUsersFeed(usersDID string, cursor int64, limit int) ([]store.FeedPost, error)
14
+
GetSubscriptionsForUser(ctx context.Context, userDID string) ([]store.Subscription, error)
14
15
}
15
16
16
17
type FeedGenerator struct {
···
59
60
}
60
61
return resp, nil
61
62
}
63
+
64
+
func (f *FeedGenerator) GetSubscriptionsForUser(ctx context.Context, userDID string) ([]store.Subscription, error) {
65
+
return f.store.GetSubscriptionsForUser(ctx, userDID)
66
+
}
+22
frontend/base.templ
+22
frontend/base.templ
···
1
+
package frontend
2
+
3
+
templ Base() {
4
+
<!DOCTYPE html>
5
+
<html lang="en">
6
+
<head>
7
+
<title>Redirecter</title>
8
+
<link rel="icon" type="image/x-icon" href="/public/favicon.ico"/>
9
+
<meta charset="UTF-8"/>
10
+
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
11
+
<link href="/public/styles.css" rel="stylesheet"/>
12
+
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
13
+
<script src="https://unpkg.com/htmx.org"></script>
14
+
<script src="https://unpkg.com/htmx.org@1.9.9" defer></script>
15
+
<script src="https://unpkg.com/htmx.org@1.9.12/dist/ext/json-enc.js"></script>
16
+
</head>
17
+
<body class="antialiased">
18
+
@Nav()
19
+
{ children... }
20
+
</body>
21
+
</html>
22
+
}
+51
frontend/home.templ
+51
frontend/home.templ
···
1
+
package frontend
2
+
3
+
templ Home() {
4
+
@Base()
5
+
}
6
+
7
+
templ Account() {
8
+
@Base()
9
+
}
10
+
11
+
templ Something(name, email string) {
12
+
@Base()
13
+
<div class="relative flex justify-center overflow-hidden bg-gray-50 py-6 sm:py-12">
14
+
<div class="flex-1 mx-auto w-full max-w-md bg-white px-6 pt-6 pb-6 shadow-xl ring-1 ring-gray-900/5 sm:rounded-xl sm:px-8">
15
+
<div class="w-full">
16
+
<div class="text-center">
17
+
<h1 class="text-3xl font-semibold text-gray-900">Your username is</h1>
18
+
<p class="mt-2 text-gray-500">{ name }</p>
19
+
</div>
20
+
<div class="text-center">
21
+
<h1 class="text-3xl font-semibold text-gray-900">Your email is</h1>
22
+
<p class="mt-2 text-gray-500">{ email }</p>
23
+
</div>
24
+
</div>
25
+
</div>
26
+
<div class="flex-1 mx-auto w-full max-w-md bg-white px-6 pt-6 pb-6 shadow-xl ring-1 ring-gray-900/5 sm:rounded-xl sm:px-8">
27
+
<div class="w-full">
28
+
<div class="text-center">
29
+
<h1 class="text-3xl font-semibold text-gray-900">Your username is</h1>
30
+
<p class="mt-2 text-gray-500">{ name }</p>
31
+
</div>
32
+
<div class="text-center">
33
+
<h1 class="text-3xl font-semibold text-gray-900">Your email is</h1>
34
+
<p class="mt-2 text-gray-500">{ email }</p>
35
+
</div>
36
+
</div>
37
+
</div>
38
+
<div class="flex-1 mx-auto w-full max-w-md bg-white px-6 pt-6 pb-6 shadow-xl ring-1 ring-gray-900/5 sm:rounded-xl sm:px-8">
39
+
<div class="w-full">
40
+
<div class="text-center">
41
+
<h1 class="text-3xl font-semibold text-gray-900">Your username is</h1>
42
+
<p class="mt-2 text-gray-500">{ name }</p>
43
+
</div>
44
+
<div class="text-center">
45
+
<h1 class="text-3xl font-semibold text-gray-900">Your email is</h1>
46
+
<p class="mt-2 text-gray-500">{ email }</p>
47
+
</div>
48
+
</div>
49
+
</div>
50
+
</div>
51
+
}
+48
frontend/login.templ
+48
frontend/login.templ
···
1
+
package frontend
2
+
3
+
templ Login(handle, errorMsg string) {
4
+
@Base()
5
+
@LoginForm("", "")
6
+
}
7
+
8
+
templ LoginForm(handle, errorMsg string) {
9
+
<form class="h-screen flex items-center justify-center" id="login-form" hx-swap="outerHTML" hx-post="/login" hx-ext="json-enc">
10
+
<div class="w-full max-w-sm">
11
+
<div class="md:flex md:items-center mb-6">
12
+
<div class="md:w-1/3">
13
+
<label class="block text-gray-500 font-bold md:text-right mb-1 md:mb-0 pr-4" for="handle">
14
+
Bsky Handle
15
+
</label>
16
+
</div>
17
+
<div class="md:w-2/3">
18
+
<input class="bg-gray-200 appearance-none border-2 border-gray-200 rounded w-full py-2 px-4 text-gray-700 leading-tight focus:outline-none focus:bg-white focus:border-blue-500" id="handle" name="handle" type="text" value={ handle }/>
19
+
</div>
20
+
</div>
21
+
<div class="md:flex md:items-center mb-6">
22
+
<div class="md:w-1/3">
23
+
<label class="block text-gray-500 font-bold md:text-right mb-1 md:mb-0 pr-4" for="appPassword">
24
+
App Password
25
+
</label>
26
+
</div>
27
+
<div class="md:w-2/3">
28
+
<input class="bg-gray-200 appearance-none border-2 border-gray-200 rounded w-full py-2 px-4 text-gray-700 leading-tight focus:outline-none focus:bg-white focus:border-blue-500" id="appPassword" name="appPassword" type="password"/>
29
+
</div>
30
+
</div>
31
+
<div class="md:flex md:items-center">
32
+
<div class="md:w-1/3"></div>
33
+
<div class="md:w-1/3">
34
+
<button class="shadow bg-blue-500 hover:bg-blue-400 focus:shadow-outline focus:outline-none text-white font-bold py-2 px-4 rounded" type="submit" form="login-form">
35
+
Login
36
+
</button>
37
+
</div>
38
+
if errorMsg != "" {
39
+
<div class="md:w-1/3" id="error-message">
40
+
<label class="text-red-500 font-bold">
41
+
{ errorMsg }
42
+
</label>
43
+
</div>
44
+
}
45
+
</div>
46
+
</div>
47
+
</form>
48
+
}
+13
frontend/subscriptions.templ
+13
frontend/subscriptions.templ
···
1
+
package frontend
2
+
3
+
templ Subscriptions(errorMsg string, subscriptions []string) {
4
+
@Base()
5
+
if errorMsg != "" {
6
+
<div role="alert">
7
+
<div class="border border-t-0 border-red-400 rounded-b bg-red-100 px-4 py-3 text-red-700">
8
+
<p>{ errorMsg }</p>
9
+
</div>
10
+
</div>
11
+
}
12
+
<p>Subscriptions</p>
13
+
}
+11
-5
go.mod
+11
-5
go.mod
···
1
1
module github.com/willdot/bskyfeedgen
2
2
3
-
go 1.22.0
3
+
go 1.23.3
4
+
5
+
toolchain go1.23.4
4
6
5
7
require (
8
+
github.com/a-h/templ v0.2.793
6
9
github.com/avast/retry-go/v4 v4.6.0
10
+
github.com/axzilla/templui v0.25.0
7
11
github.com/bluesky-social/indigo v0.0.0-20241031232035-1a73c3fb6841
8
12
github.com/bluesky-social/jetstream v0.0.0-20241031234625-0ab10bd041fe
9
13
github.com/bugsnag/bugsnag-go/v2 v2.5.1
10
14
github.com/glebarez/go-sqlite v1.22.0
11
15
github.com/golang-jwt/jwt/v5 v5.2.1
16
+
github.com/joho/godotenv v1.5.1
12
17
github.com/stretchr/testify v1.9.0
13
18
)
14
19
15
20
require (
21
+
github.com/Oudwins/tailwind-merge-go v0.2.0 // indirect
16
22
github.com/beorn7/perks v1.0.1 // indirect
17
23
github.com/bugsnag/panicwrap v1.3.4 // indirect
18
24
github.com/carlmjohnson/versioninfo v0.22.5 // indirect
···
73
79
go.opentelemetry.io/otel/trace v1.21.0 // indirect
74
80
go.uber.org/atomic v1.11.0 // indirect
75
81
go.uber.org/multierr v1.11.0 // indirect
76
-
go.uber.org/zap v1.26.0 // indirect
77
-
golang.org/x/crypto v0.22.0 // indirect
78
-
golang.org/x/net v0.24.0 // indirect
79
-
golang.org/x/sys v0.22.0 // indirect
82
+
go.uber.org/zap v1.27.0 // indirect
83
+
golang.org/x/crypto v0.26.0 // indirect
84
+
golang.org/x/net v0.28.0 // indirect
85
+
golang.org/x/sys v0.23.0 // indirect
80
86
golang.org/x/time v0.5.0 // indirect
81
87
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect
82
88
google.golang.org/protobuf v1.34.2 // indirect
+18
-10
go.sum
+18
-10
go.sum
···
1
1
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
2
+
github.com/Oudwins/tailwind-merge-go v0.2.0 h1:rtVHgYmLwwae4P+K6//ceRuUdyz3Bny6fo4664fOEmo=
3
+
github.com/Oudwins/tailwind-merge-go v0.2.0/go.mod h1:kkZodgOPvZQ8f7SIrlWkG/w1g9JTbtnptnePIh3V72U=
4
+
github.com/a-h/templ v0.2.793 h1:Io+/ocnfGWYO4VHdR0zBbf39PQlnzVCVVD+wEEs6/qY=
5
+
github.com/a-h/templ v0.2.793/go.mod h1:lq48JXoUvuQrU0VThrK31yFwdRjTCnIE5bcPCM9IP1w=
2
6
github.com/avast/retry-go/v4 v4.6.0 h1:K9xNA+KeB8HHc2aWFuLb25Offp+0iVRXEvFx8IinRJA=
3
7
github.com/avast/retry-go/v4 v4.6.0/go.mod h1:gvWlPhBVsvBbLkVGDg/KwvBv0bEkCOLRRSHKIr2PyOE=
8
+
github.com/axzilla/templui v0.25.0 h1:l+zW4Suic5ONZNO9wql1l3zOEM07S7zEl3/c636o+mQ=
9
+
github.com/axzilla/templui v0.25.0/go.mod h1:PcNr8hOXnpVGdGsSPpXbkbtrLUlx5/Ok8Jv4TGKhN9A=
4
10
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
5
11
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
6
12
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
···
91
97
github.com/jbenet/go-cienv v0.1.0/go.mod h1:TqNnHUmJgXau0nCzC7kXWeotg3J9W34CUv5Djy1+FlA=
92
98
github.com/jbenet/goprocess v0.1.4 h1:DRGOFReOMqqDNXwW70QkacFW0YN9QnwLV0Vqk+3oU0o=
93
99
github.com/jbenet/goprocess v0.1.4/go.mod h1:5yspPrukOVuOLORacaBi858NqyClJPQxYZlqdZVfqY4=
100
+
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
101
+
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
94
102
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
95
103
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
96
104
github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0 h1:iQTw/8FWTuc7uiaSepXwyf3o52HaUYcV+Tu66S3F5GA=
···
187
195
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
188
196
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
189
197
go.uber.org/goleak v1.1.11-0.20210813005559-691160354723/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
190
-
go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk=
191
-
go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo=
198
+
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
199
+
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
192
200
go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU=
193
201
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
194
202
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
···
196
204
go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA=
197
205
go.uber.org/zap v1.16.0/go.mod h1:MA8QOfq0BHJwdXa996Y4dYkAqRKB8/1K1QMMZVaNZjQ=
198
206
go.uber.org/zap v1.19.1/go.mod h1:j3DNczoxDZroyBnOT1L/Q79cfUMGZxlv/9dzN7SM1rI=
199
-
go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo=
200
-
go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so=
207
+
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
208
+
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
201
209
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
202
210
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
203
211
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
204
212
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
205
-
golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30=
206
-
golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
213
+
golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
214
+
golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
207
215
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
208
216
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
209
217
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
···
215
223
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
216
224
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
217
225
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
218
-
golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w=
219
-
golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
226
+
golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE=
227
+
golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg=
220
228
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
221
229
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
222
230
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
···
230
238
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
231
239
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
232
240
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
233
-
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
234
-
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
241
+
golang.org/x/sys v0.23.0 h1:YfKFowiIMvtgl1UERQoTPPToxltDeZfbj4H7dVUCwmM=
242
+
golang.org/x/sys v0.23.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
235
243
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
236
244
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
237
245
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+7
main.go
+7
main.go
···
3
3
import (
4
4
"context"
5
5
"errors"
6
+
"log"
6
7
"log/slog"
7
8
"os"
8
9
"os/signal"
···
12
13
13
14
"github.com/avast/retry-go/v4"
14
15
"github.com/bugsnag/bugsnag-go/v2"
16
+
"github.com/joho/godotenv"
15
17
"github.com/willdot/bskyfeedgen/store"
16
18
)
17
19
···
21
23
22
24
func main() {
23
25
configureLogger()
26
+
27
+
err := godotenv.Load()
28
+
if err != nil {
29
+
log.Fatal("Error loading .env file")
30
+
}
24
31
25
32
signals := make(chan os.Signal, 1)
26
33
signal.Notify(signals, syscall.SIGTERM, syscall.SIGINT)
+917
public/styles.css
+917
public/styles.css
···
1
+
*, ::before, ::after {
2
+
--tw-border-spacing-x: 0;
3
+
--tw-border-spacing-y: 0;
4
+
--tw-translate-x: 0;
5
+
--tw-translate-y: 0;
6
+
--tw-rotate: 0;
7
+
--tw-skew-x: 0;
8
+
--tw-skew-y: 0;
9
+
--tw-scale-x: 1;
10
+
--tw-scale-y: 1;
11
+
--tw-pan-x: ;
12
+
--tw-pan-y: ;
13
+
--tw-pinch-zoom: ;
14
+
--tw-scroll-snap-strictness: proximity;
15
+
--tw-gradient-from-position: ;
16
+
--tw-gradient-via-position: ;
17
+
--tw-gradient-to-position: ;
18
+
--tw-ordinal: ;
19
+
--tw-slashed-zero: ;
20
+
--tw-numeric-figure: ;
21
+
--tw-numeric-spacing: ;
22
+
--tw-numeric-fraction: ;
23
+
--tw-ring-inset: ;
24
+
--tw-ring-offset-width: 0px;
25
+
--tw-ring-offset-color: #fff;
26
+
--tw-ring-color: rgb(59 130 246 / 0.5);
27
+
--tw-ring-offset-shadow: 0 0 #0000;
28
+
--tw-ring-shadow: 0 0 #0000;
29
+
--tw-shadow: 0 0 #0000;
30
+
--tw-shadow-colored: 0 0 #0000;
31
+
--tw-blur: ;
32
+
--tw-brightness: ;
33
+
--tw-contrast: ;
34
+
--tw-grayscale: ;
35
+
--tw-hue-rotate: ;
36
+
--tw-invert: ;
37
+
--tw-saturate: ;
38
+
--tw-sepia: ;
39
+
--tw-drop-shadow: ;
40
+
--tw-backdrop-blur: ;
41
+
--tw-backdrop-brightness: ;
42
+
--tw-backdrop-contrast: ;
43
+
--tw-backdrop-grayscale: ;
44
+
--tw-backdrop-hue-rotate: ;
45
+
--tw-backdrop-invert: ;
46
+
--tw-backdrop-opacity: ;
47
+
--tw-backdrop-saturate: ;
48
+
--tw-backdrop-sepia: ;
49
+
--tw-contain-size: ;
50
+
--tw-contain-layout: ;
51
+
--tw-contain-paint: ;
52
+
--tw-contain-style: ;
53
+
}
54
+
55
+
::backdrop {
56
+
--tw-border-spacing-x: 0;
57
+
--tw-border-spacing-y: 0;
58
+
--tw-translate-x: 0;
59
+
--tw-translate-y: 0;
60
+
--tw-rotate: 0;
61
+
--tw-skew-x: 0;
62
+
--tw-skew-y: 0;
63
+
--tw-scale-x: 1;
64
+
--tw-scale-y: 1;
65
+
--tw-pan-x: ;
66
+
--tw-pan-y: ;
67
+
--tw-pinch-zoom: ;
68
+
--tw-scroll-snap-strictness: proximity;
69
+
--tw-gradient-from-position: ;
70
+
--tw-gradient-via-position: ;
71
+
--tw-gradient-to-position: ;
72
+
--tw-ordinal: ;
73
+
--tw-slashed-zero: ;
74
+
--tw-numeric-figure: ;
75
+
--tw-numeric-spacing: ;
76
+
--tw-numeric-fraction: ;
77
+
--tw-ring-inset: ;
78
+
--tw-ring-offset-width: 0px;
79
+
--tw-ring-offset-color: #fff;
80
+
--tw-ring-color: rgb(59 130 246 / 0.5);
81
+
--tw-ring-offset-shadow: 0 0 #0000;
82
+
--tw-ring-shadow: 0 0 #0000;
83
+
--tw-shadow: 0 0 #0000;
84
+
--tw-shadow-colored: 0 0 #0000;
85
+
--tw-blur: ;
86
+
--tw-brightness: ;
87
+
--tw-contrast: ;
88
+
--tw-grayscale: ;
89
+
--tw-hue-rotate: ;
90
+
--tw-invert: ;
91
+
--tw-saturate: ;
92
+
--tw-sepia: ;
93
+
--tw-drop-shadow: ;
94
+
--tw-backdrop-blur: ;
95
+
--tw-backdrop-brightness: ;
96
+
--tw-backdrop-contrast: ;
97
+
--tw-backdrop-grayscale: ;
98
+
--tw-backdrop-hue-rotate: ;
99
+
--tw-backdrop-invert: ;
100
+
--tw-backdrop-opacity: ;
101
+
--tw-backdrop-saturate: ;
102
+
--tw-backdrop-sepia: ;
103
+
--tw-contain-size: ;
104
+
--tw-contain-layout: ;
105
+
--tw-contain-paint: ;
106
+
--tw-contain-style: ;
107
+
}
108
+
109
+
/*
110
+
! tailwindcss v3.4.16 | MIT License | https://tailwindcss.com
111
+
*/
112
+
113
+
/*
114
+
1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4)
115
+
2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116)
116
+
*/
117
+
118
+
*,
119
+
::before,
120
+
::after {
121
+
box-sizing: border-box;
122
+
/* 1 */
123
+
border-width: 0;
124
+
/* 2 */
125
+
border-style: solid;
126
+
/* 2 */
127
+
border-color: #e5e7eb;
128
+
/* 2 */
129
+
}
130
+
131
+
::before,
132
+
::after {
133
+
--tw-content: '';
134
+
}
135
+
136
+
/*
137
+
1. Use a consistent sensible line-height in all browsers.
138
+
2. Prevent adjustments of font size after orientation changes in iOS.
139
+
3. Use a more readable tab size.
140
+
4. Use the user's configured `sans` font-family by default.
141
+
5. Use the user's configured `sans` font-feature-settings by default.
142
+
6. Use the user's configured `sans` font-variation-settings by default.
143
+
7. Disable tap highlights on iOS
144
+
*/
145
+
146
+
html,
147
+
:host {
148
+
line-height: 1.5;
149
+
/* 1 */
150
+
-webkit-text-size-adjust: 100%;
151
+
/* 2 */
152
+
-moz-tab-size: 4;
153
+
/* 3 */
154
+
-o-tab-size: 4;
155
+
tab-size: 4;
156
+
/* 3 */
157
+
font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
158
+
/* 4 */
159
+
font-feature-settings: normal;
160
+
/* 5 */
161
+
font-variation-settings: normal;
162
+
/* 6 */
163
+
-webkit-tap-highlight-color: transparent;
164
+
/* 7 */
165
+
}
166
+
167
+
/*
168
+
1. Remove the margin in all browsers.
169
+
2. Inherit line-height from `html` so users can set them as a class directly on the `html` element.
170
+
*/
171
+
172
+
body {
173
+
margin: 0;
174
+
/* 1 */
175
+
line-height: inherit;
176
+
/* 2 */
177
+
}
178
+
179
+
/*
180
+
1. Add the correct height in Firefox.
181
+
2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655)
182
+
3. Ensure horizontal rules are visible by default.
183
+
*/
184
+
185
+
hr {
186
+
height: 0;
187
+
/* 1 */
188
+
color: inherit;
189
+
/* 2 */
190
+
border-top-width: 1px;
191
+
/* 3 */
192
+
}
193
+
194
+
/*
195
+
Add the correct text decoration in Chrome, Edge, and Safari.
196
+
*/
197
+
198
+
abbr:where([title]) {
199
+
-webkit-text-decoration: underline dotted;
200
+
text-decoration: underline dotted;
201
+
}
202
+
203
+
/*
204
+
Remove the default font size and weight for headings.
205
+
*/
206
+
207
+
h1,
208
+
h2,
209
+
h3,
210
+
h4,
211
+
h5,
212
+
h6 {
213
+
font-size: inherit;
214
+
font-weight: inherit;
215
+
}
216
+
217
+
/*
218
+
Reset links to optimize for opt-in styling instead of opt-out.
219
+
*/
220
+
221
+
a {
222
+
color: inherit;
223
+
text-decoration: inherit;
224
+
}
225
+
226
+
/*
227
+
Add the correct font weight in Edge and Safari.
228
+
*/
229
+
230
+
b,
231
+
strong {
232
+
font-weight: bolder;
233
+
}
234
+
235
+
/*
236
+
1. Use the user's configured `mono` font-family by default.
237
+
2. Use the user's configured `mono` font-feature-settings by default.
238
+
3. Use the user's configured `mono` font-variation-settings by default.
239
+
4. Correct the odd `em` font sizing in all browsers.
240
+
*/
241
+
242
+
code,
243
+
kbd,
244
+
samp,
245
+
pre {
246
+
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
247
+
/* 1 */
248
+
font-feature-settings: normal;
249
+
/* 2 */
250
+
font-variation-settings: normal;
251
+
/* 3 */
252
+
font-size: 1em;
253
+
/* 4 */
254
+
}
255
+
256
+
/*
257
+
Add the correct font size in all browsers.
258
+
*/
259
+
260
+
small {
261
+
font-size: 80%;
262
+
}
263
+
264
+
/*
265
+
Prevent `sub` and `sup` elements from affecting the line height in all browsers.
266
+
*/
267
+
268
+
sub,
269
+
sup {
270
+
font-size: 75%;
271
+
line-height: 0;
272
+
position: relative;
273
+
vertical-align: baseline;
274
+
}
275
+
276
+
sub {
277
+
bottom: -0.25em;
278
+
}
279
+
280
+
sup {
281
+
top: -0.5em;
282
+
}
283
+
284
+
/*
285
+
1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297)
286
+
2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016)
287
+
3. Remove gaps between table borders by default.
288
+
*/
289
+
290
+
table {
291
+
text-indent: 0;
292
+
/* 1 */
293
+
border-color: inherit;
294
+
/* 2 */
295
+
border-collapse: collapse;
296
+
/* 3 */
297
+
}
298
+
299
+
/*
300
+
1. Change the font styles in all browsers.
301
+
2. Remove the margin in Firefox and Safari.
302
+
3. Remove default padding in all browsers.
303
+
*/
304
+
305
+
button,
306
+
input,
307
+
optgroup,
308
+
select,
309
+
textarea {
310
+
font-family: inherit;
311
+
/* 1 */
312
+
font-feature-settings: inherit;
313
+
/* 1 */
314
+
font-variation-settings: inherit;
315
+
/* 1 */
316
+
font-size: 100%;
317
+
/* 1 */
318
+
font-weight: inherit;
319
+
/* 1 */
320
+
line-height: inherit;
321
+
/* 1 */
322
+
letter-spacing: inherit;
323
+
/* 1 */
324
+
color: inherit;
325
+
/* 1 */
326
+
margin: 0;
327
+
/* 2 */
328
+
padding: 0;
329
+
/* 3 */
330
+
}
331
+
332
+
/*
333
+
Remove the inheritance of text transform in Edge and Firefox.
334
+
*/
335
+
336
+
button,
337
+
select {
338
+
text-transform: none;
339
+
}
340
+
341
+
/*
342
+
1. Correct the inability to style clickable types in iOS and Safari.
343
+
2. Remove default button styles.
344
+
*/
345
+
346
+
button,
347
+
input:where([type='button']),
348
+
input:where([type='reset']),
349
+
input:where([type='submit']) {
350
+
-webkit-appearance: button;
351
+
/* 1 */
352
+
background-color: transparent;
353
+
/* 2 */
354
+
background-image: none;
355
+
/* 2 */
356
+
}
357
+
358
+
/*
359
+
Use the modern Firefox focus style for all focusable elements.
360
+
*/
361
+
362
+
:-moz-focusring {
363
+
outline: auto;
364
+
}
365
+
366
+
/*
367
+
Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737)
368
+
*/
369
+
370
+
:-moz-ui-invalid {
371
+
box-shadow: none;
372
+
}
373
+
374
+
/*
375
+
Add the correct vertical alignment in Chrome and Firefox.
376
+
*/
377
+
378
+
progress {
379
+
vertical-align: baseline;
380
+
}
381
+
382
+
/*
383
+
Correct the cursor style of increment and decrement buttons in Safari.
384
+
*/
385
+
386
+
::-webkit-inner-spin-button,
387
+
::-webkit-outer-spin-button {
388
+
height: auto;
389
+
}
390
+
391
+
/*
392
+
1. Correct the odd appearance in Chrome and Safari.
393
+
2. Correct the outline style in Safari.
394
+
*/
395
+
396
+
[type='search'] {
397
+
-webkit-appearance: textfield;
398
+
/* 1 */
399
+
outline-offset: -2px;
400
+
/* 2 */
401
+
}
402
+
403
+
/*
404
+
Remove the inner padding in Chrome and Safari on macOS.
405
+
*/
406
+
407
+
::-webkit-search-decoration {
408
+
-webkit-appearance: none;
409
+
}
410
+
411
+
/*
412
+
1. Correct the inability to style clickable types in iOS and Safari.
413
+
2. Change font properties to `inherit` in Safari.
414
+
*/
415
+
416
+
::-webkit-file-upload-button {
417
+
-webkit-appearance: button;
418
+
/* 1 */
419
+
font: inherit;
420
+
/* 2 */
421
+
}
422
+
423
+
/*
424
+
Add the correct display in Chrome and Safari.
425
+
*/
426
+
427
+
summary {
428
+
display: list-item;
429
+
}
430
+
431
+
/*
432
+
Removes the default spacing and border for appropriate elements.
433
+
*/
434
+
435
+
blockquote,
436
+
dl,
437
+
dd,
438
+
h1,
439
+
h2,
440
+
h3,
441
+
h4,
442
+
h5,
443
+
h6,
444
+
hr,
445
+
figure,
446
+
p,
447
+
pre {
448
+
margin: 0;
449
+
}
450
+
451
+
fieldset {
452
+
margin: 0;
453
+
padding: 0;
454
+
}
455
+
456
+
legend {
457
+
padding: 0;
458
+
}
459
+
460
+
ol,
461
+
ul,
462
+
menu {
463
+
list-style: none;
464
+
margin: 0;
465
+
padding: 0;
466
+
}
467
+
468
+
/*
469
+
Reset default styling for dialogs.
470
+
*/
471
+
472
+
dialog {
473
+
padding: 0;
474
+
}
475
+
476
+
/*
477
+
Prevent resizing textareas horizontally by default.
478
+
*/
479
+
480
+
textarea {
481
+
resize: vertical;
482
+
}
483
+
484
+
/*
485
+
1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300)
486
+
2. Set the default placeholder color to the user's configured gray 400 color.
487
+
*/
488
+
489
+
input::-moz-placeholder, textarea::-moz-placeholder {
490
+
opacity: 1;
491
+
/* 1 */
492
+
color: #9ca3af;
493
+
/* 2 */
494
+
}
495
+
496
+
input::placeholder,
497
+
textarea::placeholder {
498
+
opacity: 1;
499
+
/* 1 */
500
+
color: #9ca3af;
501
+
/* 2 */
502
+
}
503
+
504
+
/*
505
+
Set the default cursor for buttons.
506
+
*/
507
+
508
+
button,
509
+
[role="button"] {
510
+
cursor: pointer;
511
+
}
512
+
513
+
/*
514
+
Make sure disabled buttons don't get the pointer cursor.
515
+
*/
516
+
517
+
:disabled {
518
+
cursor: default;
519
+
}
520
+
521
+
/*
522
+
1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14)
523
+
2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210)
524
+
This can trigger a poorly considered lint error in some tools but is included by design.
525
+
*/
526
+
527
+
img,
528
+
svg,
529
+
video,
530
+
canvas,
531
+
audio,
532
+
iframe,
533
+
embed,
534
+
object {
535
+
display: block;
536
+
/* 1 */
537
+
vertical-align: middle;
538
+
/* 2 */
539
+
}
540
+
541
+
/*
542
+
Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14)
543
+
*/
544
+
545
+
img,
546
+
video {
547
+
max-width: 100%;
548
+
height: auto;
549
+
}
550
+
551
+
/* Make elements with the HTML hidden attribute stay hidden by default */
552
+
553
+
[hidden]:where(:not([hidden="until-found"])) {
554
+
display: none;
555
+
}
556
+
557
+
.relative {
558
+
position: relative;
559
+
}
560
+
561
+
.sticky {
562
+
position: sticky;
563
+
}
564
+
565
+
.top-0 {
566
+
top: 0px;
567
+
}
568
+
569
+
.mx-auto {
570
+
margin-left: auto;
571
+
margin-right: auto;
572
+
}
573
+
574
+
.mb-1 {
575
+
margin-bottom: 0.25rem;
576
+
}
577
+
578
+
.mb-6 {
579
+
margin-bottom: 1.5rem;
580
+
}
581
+
582
+
.mt-2 {
583
+
margin-top: 0.5rem;
584
+
}
585
+
586
+
.block {
587
+
display: block;
588
+
}
589
+
590
+
.flex {
591
+
display: flex;
592
+
}
593
+
594
+
.table {
595
+
display: table;
596
+
}
597
+
598
+
.contents {
599
+
display: contents;
600
+
}
601
+
602
+
.h-screen {
603
+
height: 100vh;
604
+
}
605
+
606
+
.w-3\/12 {
607
+
width: 25%;
608
+
}
609
+
610
+
.w-full {
611
+
width: 100%;
612
+
}
613
+
614
+
.max-w-md {
615
+
max-width: 28rem;
616
+
}
617
+
618
+
.max-w-sm {
619
+
max-width: 24rem;
620
+
}
621
+
622
+
.flex-1 {
623
+
flex: 1 1 0%;
624
+
}
625
+
626
+
.appearance-none {
627
+
-webkit-appearance: none;
628
+
-moz-appearance: none;
629
+
appearance: none;
630
+
}
631
+
632
+
.items-center {
633
+
align-items: center;
634
+
}
635
+
636
+
.justify-end {
637
+
justify-content: flex-end;
638
+
}
639
+
640
+
.justify-center {
641
+
justify-content: center;
642
+
}
643
+
644
+
.justify-between {
645
+
justify-content: space-between;
646
+
}
647
+
648
+
.overflow-hidden {
649
+
overflow: hidden;
650
+
}
651
+
652
+
.rounded {
653
+
border-radius: 0.25rem;
654
+
}
655
+
656
+
.rounded-b {
657
+
border-bottom-right-radius: 0.25rem;
658
+
border-bottom-left-radius: 0.25rem;
659
+
}
660
+
661
+
.border {
662
+
border-width: 1px;
663
+
}
664
+
665
+
.border-2 {
666
+
border-width: 2px;
667
+
}
668
+
669
+
.border-t-0 {
670
+
border-top-width: 0px;
671
+
}
672
+
673
+
.border-gray-200 {
674
+
--tw-border-opacity: 1;
675
+
border-color: rgb(229 231 235 / var(--tw-border-opacity, 1));
676
+
}
677
+
678
+
.border-red-400 {
679
+
--tw-border-opacity: 1;
680
+
border-color: rgb(248 113 113 / var(--tw-border-opacity, 1));
681
+
}
682
+
683
+
.bg-blue-500 {
684
+
--tw-bg-opacity: 1;
685
+
background-color: rgb(59 130 246 / var(--tw-bg-opacity, 1));
686
+
}
687
+
688
+
.bg-gray-200 {
689
+
--tw-bg-opacity: 1;
690
+
background-color: rgb(229 231 235 / var(--tw-bg-opacity, 1));
691
+
}
692
+
693
+
.bg-gray-50 {
694
+
--tw-bg-opacity: 1;
695
+
background-color: rgb(249 250 251 / var(--tw-bg-opacity, 1));
696
+
}
697
+
698
+
.bg-red-100 {
699
+
--tw-bg-opacity: 1;
700
+
background-color: rgb(254 226 226 / var(--tw-bg-opacity, 1));
701
+
}
702
+
703
+
.bg-white {
704
+
--tw-bg-opacity: 1;
705
+
background-color: rgb(255 255 255 / var(--tw-bg-opacity, 1));
706
+
}
707
+
708
+
.p-4 {
709
+
padding: 1rem;
710
+
}
711
+
712
+
.px-4 {
713
+
padding-left: 1rem;
714
+
padding-right: 1rem;
715
+
}
716
+
717
+
.px-6 {
718
+
padding-left: 1.5rem;
719
+
padding-right: 1.5rem;
720
+
}
721
+
722
+
.px-8 {
723
+
padding-left: 2rem;
724
+
padding-right: 2rem;
725
+
}
726
+
727
+
.py-2 {
728
+
padding-top: 0.5rem;
729
+
padding-bottom: 0.5rem;
730
+
}
731
+
732
+
.py-3 {
733
+
padding-top: 0.75rem;
734
+
padding-bottom: 0.75rem;
735
+
}
736
+
737
+
.py-6 {
738
+
padding-top: 1.5rem;
739
+
padding-bottom: 1.5rem;
740
+
}
741
+
742
+
.pb-6 {
743
+
padding-bottom: 1.5rem;
744
+
}
745
+
746
+
.pr-4 {
747
+
padding-right: 1rem;
748
+
}
749
+
750
+
.pt-6 {
751
+
padding-top: 1.5rem;
752
+
}
753
+
754
+
.text-center {
755
+
text-align: center;
756
+
}
757
+
758
+
.text-right {
759
+
text-align: right;
760
+
}
761
+
762
+
.text-3xl {
763
+
font-size: 1.875rem;
764
+
line-height: 2.25rem;
765
+
}
766
+
767
+
.text-lg {
768
+
font-size: 1.125rem;
769
+
line-height: 1.75rem;
770
+
}
771
+
772
+
.font-bold {
773
+
font-weight: 700;
774
+
}
775
+
776
+
.font-semibold {
777
+
font-weight: 600;
778
+
}
779
+
780
+
.leading-tight {
781
+
line-height: 1.25;
782
+
}
783
+
784
+
.text-blue-500 {
785
+
--tw-text-opacity: 1;
786
+
color: rgb(59 130 246 / var(--tw-text-opacity, 1));
787
+
}
788
+
789
+
.text-gray-500 {
790
+
--tw-text-opacity: 1;
791
+
color: rgb(107 114 128 / var(--tw-text-opacity, 1));
792
+
}
793
+
794
+
.text-gray-700 {
795
+
--tw-text-opacity: 1;
796
+
color: rgb(55 65 81 / var(--tw-text-opacity, 1));
797
+
}
798
+
799
+
.text-gray-900 {
800
+
--tw-text-opacity: 1;
801
+
color: rgb(17 24 39 / var(--tw-text-opacity, 1));
802
+
}
803
+
804
+
.text-red-500 {
805
+
--tw-text-opacity: 1;
806
+
color: rgb(239 68 68 / var(--tw-text-opacity, 1));
807
+
}
808
+
809
+
.text-red-700 {
810
+
--tw-text-opacity: 1;
811
+
color: rgb(185 28 28 / var(--tw-text-opacity, 1));
812
+
}
813
+
814
+
.text-white {
815
+
--tw-text-opacity: 1;
816
+
color: rgb(255 255 255 / var(--tw-text-opacity, 1));
817
+
}
818
+
819
+
.antialiased {
820
+
-webkit-font-smoothing: antialiased;
821
+
-moz-osx-font-smoothing: grayscale;
822
+
}
823
+
824
+
.shadow {
825
+
--tw-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
826
+
--tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color);
827
+
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
828
+
}
829
+
830
+
.shadow-md {
831
+
--tw-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
832
+
--tw-shadow-colored: 0 4px 6px -1px var(--tw-shadow-color), 0 2px 4px -2px var(--tw-shadow-color);
833
+
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
834
+
}
835
+
836
+
.shadow-xl {
837
+
--tw-shadow: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
838
+
--tw-shadow-colored: 0 20px 25px -5px var(--tw-shadow-color), 0 8px 10px -6px var(--tw-shadow-color);
839
+
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
840
+
}
841
+
842
+
.ring-1 {
843
+
--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
844
+
--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);
845
+
box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000);
846
+
}
847
+
848
+
.ring-gray-900\/5 {
849
+
--tw-ring-color: rgb(17 24 39 / 0.05);
850
+
}
851
+
852
+
.hover\:bg-blue-400:hover {
853
+
--tw-bg-opacity: 1;
854
+
background-color: rgb(96 165 250 / var(--tw-bg-opacity, 1));
855
+
}
856
+
857
+
.hover\:text-blue-800:hover {
858
+
--tw-text-opacity: 1;
859
+
color: rgb(30 64 175 / var(--tw-text-opacity, 1));
860
+
}
861
+
862
+
.focus\:border-blue-500:focus {
863
+
--tw-border-opacity: 1;
864
+
border-color: rgb(59 130 246 / var(--tw-border-opacity, 1));
865
+
}
866
+
867
+
.focus\:bg-white:focus {
868
+
--tw-bg-opacity: 1;
869
+
background-color: rgb(255 255 255 / var(--tw-bg-opacity, 1));
870
+
}
871
+
872
+
.focus\:outline-none:focus {
873
+
outline: 2px solid transparent;
874
+
outline-offset: 2px;
875
+
}
876
+
877
+
@media (min-width: 640px) {
878
+
.sm\:rounded-xl {
879
+
border-radius: 0.75rem;
880
+
}
881
+
882
+
.sm\:px-8 {
883
+
padding-left: 2rem;
884
+
padding-right: 2rem;
885
+
}
886
+
887
+
.sm\:py-12 {
888
+
padding-top: 3rem;
889
+
padding-bottom: 3rem;
890
+
}
891
+
}
892
+
893
+
@media (min-width: 768px) {
894
+
.md\:mb-0 {
895
+
margin-bottom: 0px;
896
+
}
897
+
898
+
.md\:flex {
899
+
display: flex;
900
+
}
901
+
902
+
.md\:w-1\/3 {
903
+
width: 33.333333%;
904
+
}
905
+
906
+
.md\:w-2\/3 {
907
+
width: 66.666667%;
908
+
}
909
+
910
+
.md\:items-center {
911
+
align-items: center;
912
+
}
913
+
914
+
.md\:text-right {
915
+
text-align: right;
916
+
}
917
+
}
+224
-7
server.go
+224
-7
server.go
···
1
1
package main
2
2
3
3
import (
4
+
"bytes"
4
5
"context"
6
+
_ "embed"
5
7
"encoding/json"
6
8
"fmt"
9
+
"io"
7
10
"log/slog"
8
11
"net/http"
12
+
"os"
9
13
"strconv"
14
+
"strings"
15
+
16
+
"github.com/willdot/bskyfeedgen/frontend"
17
+
"github.com/willdot/bskyfeedgen/store"
18
+
)
19
+
20
+
const (
21
+
bskyBaseURL = "https://bsky.social/xrpc"
10
22
)
11
23
12
24
type Feeder interface {
13
25
GetFeed(ctx context.Context, userDID, feed, cursor string, limit int) (FeedReponse, error)
26
+
GetSubscriptionsForUser(ctx context.Context, userDID string) ([]store.Subscription, error)
14
27
}
15
28
16
29
type Server struct {
17
-
httpsrv *http.Server
18
-
feeder Feeder
19
-
feedHost string
20
-
feedDidBase string
30
+
httpsrv *http.Server
31
+
feeder Feeder
32
+
feedHost string
33
+
feedDidBase string
34
+
jwtSecretKey string
21
35
}
22
36
23
37
func NewServer(port int, feeder Feeder, feedHost, feedDidBase string) *Server {
38
+
secretKey := os.Getenv("JWT_SECRET")
39
+
if secretKey == "" {
40
+
secretKey = "TEST_KEY"
41
+
}
42
+
24
43
srv := &Server{
25
-
feeder: feeder,
26
-
feedHost: feedHost,
27
-
feedDidBase: feedDidBase,
44
+
feeder: feeder,
45
+
feedHost: feedHost,
46
+
feedDidBase: feedDidBase,
47
+
jwtSecretKey: secretKey,
28
48
}
29
49
30
50
mux := http.NewServeMux()
51
+
mux.HandleFunc("/public/styles.css", serveCSS)
31
52
mux.HandleFunc("/xrpc/app.bsky.feed.getFeedSkeleton", srv.HandleGetFeedSkeleton)
32
53
mux.HandleFunc("/xrpc/app.bsky.feed.describeFeedGenerator", srv.HandleDescribeFeedGenerator)
33
54
mux.HandleFunc("/.well-known/did.json", srv.HandleWellKnown)
55
+
56
+
mux.HandleFunc("/", srv.authMiddleware(srv.HandleSubscriptions))
57
+
mux.HandleFunc("/login", srv.HandleLogin)
58
+
59
+
// mux.HandleFunc("/subscriptions", srv.HandleSubscriptions)
60
+
34
61
addr := fmt.Sprintf("0.0.0.0:%d", port)
35
62
36
63
httpSrv := http.Server{
···
52
79
53
80
func (s *Server) Stop(ctx context.Context) error {
54
81
return s.httpsrv.Shutdown(ctx)
82
+
}
83
+
84
+
//go:embed public/styles.css
85
+
var cssFile []byte
86
+
87
+
func serveCSS(w http.ResponseWriter, r *http.Request) {
88
+
w.Header().Set("Content-Type", "text/css; charset=utf-8")
89
+
w.Write(cssFile)
55
90
}
56
91
57
92
type FeedReponse struct {
···
186
221
187
222
w.Write(b)
188
223
}
224
+
225
+
func (s *Server) HandleSubscriptions(w http.ResponseWriter, r *http.Request) {
226
+
didCookie, err := r.Cookie(didCookieName)
227
+
if err != nil {
228
+
slog.Error("read DID cookie", "error", err)
229
+
frontend.Login("", "").Render(r.Context(), w)
230
+
return
231
+
}
232
+
if didCookie == nil {
233
+
slog.Error("missing DID cookie")
234
+
frontend.Login("", "").Render(r.Context(), w)
235
+
return
236
+
}
237
+
238
+
usersDid := didCookie.Value
239
+
240
+
slog.Info("did request", "did", usersDid)
241
+
242
+
subs, err := s.feeder.GetSubscriptionsForUser(r.Context(), usersDid)
243
+
if err != nil {
244
+
slog.Error("error getting subscriptions for user", "error", err)
245
+
frontend.Subscriptions("failed to get subscriptions", []string{}).Render(r.Context(), w)
246
+
return
247
+
}
248
+
249
+
sanitizedURIs := make([]string, 0, len(subs))
250
+
for _, sub := range subs {
251
+
splitStr := strings.Split(sub.SubscribedPostURI, "/")
252
+
253
+
if len(splitStr) != 5 {
254
+
slog.Error("subscription URI was not expected - expected to have 5 strings after spliting by /", "uri", sub.SubscribedPostURI)
255
+
continue
256
+
}
257
+
258
+
did := splitStr[2]
259
+
260
+
handle, err := resolveDid(did)
261
+
if err != nil {
262
+
slog.Error("resolving did", "error", err, "did", did)
263
+
handle = did
264
+
}
265
+
266
+
uri := fmt.Sprintf("https://bsky.app/profile/%s/post/%s", handle, splitStr[4])
267
+
sanitizedURIs = append(sanitizedURIs, uri)
268
+
}
269
+
270
+
frontend.Subscriptions("", sanitizedURIs).Render(r.Context(), w)
271
+
}
272
+
273
+
func resolveDid(did string) (string, error) {
274
+
resp, err := http.DefaultClient.Get(fmt.Sprintf("https://plc.directory/%s", did))
275
+
if err != nil {
276
+
return "", fmt.Errorf("error making request to resolve did: %w", err)
277
+
}
278
+
defer resp.Body.Close()
279
+
280
+
if resp.StatusCode != http.StatusOK {
281
+
return "", fmt.Errorf("got response %d", resp.StatusCode)
282
+
}
283
+
284
+
type resolvedDid struct {
285
+
Aka []string `json:"alsoKnownAs"`
286
+
}
287
+
288
+
b, err := io.ReadAll(resp.Body)
289
+
if err != nil {
290
+
return "", fmt.Errorf("reading response body: %w", err)
291
+
}
292
+
293
+
var resolved resolvedDid
294
+
err = json.Unmarshal(b, &resolved)
295
+
if err != nil {
296
+
return "", fmt.Errorf("decode response body: %w", err)
297
+
}
298
+
299
+
if len(resolved.Aka) == 0 {
300
+
return "", nil
301
+
}
302
+
303
+
res := strings.ReplaceAll(resolved.Aka[0], "at://", "")
304
+
305
+
return res, nil
306
+
}
307
+
308
+
type loginRequest struct {
309
+
Handle string `json:"handle"`
310
+
AppPassword string `json:"appPassword"`
311
+
}
312
+
313
+
type BskyAuth struct {
314
+
AccessJwt string `json:"accessJwt"`
315
+
Did string `json:"did"`
316
+
}
317
+
318
+
func (s *Server) HandleLogin(w http.ResponseWriter, r *http.Request) {
319
+
b, err := io.ReadAll(r.Body)
320
+
if err != nil {
321
+
slog.Error("failed to read body", "error", err)
322
+
frontend.LoginForm("", "bad request").Render(r.Context(), w)
323
+
return
324
+
}
325
+
326
+
var loginReq loginRequest
327
+
err = json.Unmarshal(b, &loginReq)
328
+
if err != nil {
329
+
slog.Error("failed to unmarshal body", "error", err)
330
+
frontend.LoginForm("", "bad request").Render(r.Context(), w)
331
+
return
332
+
}
333
+
url := fmt.Sprintf("%s/com.atproto.server.createsession", bskyBaseURL)
334
+
335
+
requestData := map[string]interface{}{
336
+
"identifier": loginReq.Handle,
337
+
"password": loginReq.AppPassword,
338
+
}
339
+
340
+
data, err := json.Marshal(requestData)
341
+
if err != nil {
342
+
slog.Error("failed marshal POST request to sign into Bsky", "error", err)
343
+
frontend.LoginForm(loginReq.Handle, "internal error").Render(r.Context(), w)
344
+
return
345
+
}
346
+
347
+
reader := bytes.NewReader(data)
348
+
349
+
req, err := http.NewRequest("POST", url, reader)
350
+
if err != nil {
351
+
slog.Error("failed to create POST request to sign into Bsky", "error", err)
352
+
frontend.LoginForm(loginReq.Handle, "internal error").Render(r.Context(), w)
353
+
return
354
+
}
355
+
356
+
req.Header.Add("Content-Type", "application/json")
357
+
358
+
// TODO: create a client somewhere
359
+
res, err := http.DefaultClient.Do(req)
360
+
if err != nil {
361
+
slog.Error("failed to make POST request to sign into Bsky", "error", err)
362
+
frontend.LoginForm(loginReq.Handle, "internal error").Render(r.Context(), w)
363
+
return
364
+
}
365
+
366
+
defer res.Body.Close()
367
+
368
+
slog.Info("bsky resp", "code", res.StatusCode)
369
+
370
+
if res.StatusCode != 200 {
371
+
slog.Error("failed to log into bluesky", "status code", res.StatusCode)
372
+
frontend.LoginForm(loginReq.Handle, "not authorized").Render(r.Context(), w)
373
+
return
374
+
}
375
+
376
+
resBody, err := io.ReadAll(res.Body)
377
+
if err != nil {
378
+
slog.Error("failed read response from Bsky login", "error", err)
379
+
frontend.LoginForm(loginReq.Handle, "internal error").Render(r.Context(), w)
380
+
return
381
+
}
382
+
383
+
var loginResp BskyAuth
384
+
err = json.Unmarshal(resBody, &loginResp)
385
+
if err != nil {
386
+
slog.Error("failed unmarshal response from Bsky login", "error", err)
387
+
frontend.LoginForm(loginReq.Handle, "internal error").Render(r.Context(), w)
388
+
return
389
+
}
390
+
391
+
http.SetCookie(w, &http.Cookie{
392
+
Name: jwtCookieName,
393
+
Value: loginResp.AccessJwt,
394
+
})
395
+
396
+
http.SetCookie(w, &http.Cookie{
397
+
Name: didCookieName,
398
+
Value: loginResp.Did,
399
+
})
400
+
401
+
ctx := context.WithValue(r.Context(), frontend.ContextUsernameKey, loginReq.Handle)
402
+
r = r.WithContext(ctx)
403
+
404
+
http.Redirect(w, r, "/", http.StatusOK)
405
+
}
+21
store/subscription.go
+21
store/subscription.go
···
1
1
package store
2
2
3
3
import (
4
+
"context"
4
5
"database/sql"
5
6
"fmt"
6
7
"log/slog"
···
94
95
}
95
96
return nil
96
97
}
98
+
99
+
func (s *Store) GetSubscriptionsForUser(ctx context.Context, userDID string) ([]Subscription, error) {
100
+
sql := "SELECT subscribedPostURI FROM subscriptions WHERE userDID = ?;"
101
+
rows, err := s.db.Query(sql, userDID)
102
+
if err != nil {
103
+
return nil, fmt.Errorf("run query to get subscribed posts for user: %w", err)
104
+
}
105
+
defer rows.Close()
106
+
107
+
var results []Subscription
108
+
for rows.Next() {
109
+
var subscription Subscription
110
+
if err := rows.Scan(&subscription.SubscribedPostURI); err != nil {
111
+
return nil, fmt.Errorf("scan row: %w", err)
112
+
}
113
+
114
+
results = append(results, subscription)
115
+
}
116
+
return results, nil
117
+
}