Lasa is a stateless proxy that generates a RSS or an Atom feed from a Standard.site publication. lasa.anhgelus.world
rss atom atprotocol standard-site atproto
2
fork

Configure Feed

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

feat(lasad): use ljut and supports FastCGI

+96 -179
+1 -1
.gitignore
··· 20 20 testdata 21 21 build 22 22 test.toml 23 - config/ 23 + /config/
-121
cmd/internal/mux.go
··· 1 - package internal 2 - 3 - import ( 4 - "context" 5 - "errors" 6 - "fmt" 7 - "log/slog" 8 - "math" 9 - "net/http" 10 - "slices" 11 - "time" 12 - ) 13 - 14 - type StatusWriter struct { 15 - http.ResponseWriter 16 - Code int 17 - } 18 - 19 - func (w *StatusWriter) WriteHeader(statusCode int) { 20 - w.ResponseWriter.WriteHeader(statusCode) 21 - w.Code = statusCode 22 - } 23 - 24 - type Handler func(*StatusWriter, *http.Request) 25 - 26 - type Middleware func(Handler, *StatusWriter, *http.Request) 27 - 28 - type Mux struct { 29 - middlewares []func(Handler) Handler 30 - handler Handler 31 - } 32 - 33 - func NewMux(base *http.ServeMux) *Mux { 34 - return &Mux{handler: func(w *StatusWriter, r *http.Request) { 35 - base.ServeHTTP(w, r) 36 - }} 37 - } 38 - 39 - func (m *Mux) Handle() http.Handler { 40 - slices.Reverse(m.middlewares) 41 - for _, middle := range m.middlewares { 42 - m.handler = middle(m.handler) 43 - } 44 - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 45 - m.handler(&StatusWriter{ResponseWriter: w}, r) 46 - }) 47 - } 48 - 49 - func (m *Mux) Use(middlewares ...Middleware) { 50 - for _, middle := range middlewares { 51 - m.middlewares = append(m.middlewares, func(next Handler) Handler { 52 - return func(w *StatusWriter, r *http.Request) { 53 - middle(next, w, r) 54 - } 55 - }) 56 - } 57 - } 58 - 59 - func MiddlewareLog(cancelCause func(context.Context) context.CancelCauseFunc, logNotFound, logBadRequest bool) Middleware { 60 - return func(next Handler, w *StatusWriter, r *http.Request) { 61 - log := slog.With("uri", r.RequestURI) 62 - now := time.Now() 63 - defer func() { 64 - if err := recover(); err != nil { 65 - w.WriteHeader(http.StatusInternalServerError) 66 - log.Error("panic!", "error", err, "duration", time.Since(now)) 67 - switch e := err.(type) { 68 - case error: 69 - cancelCause(r.Context())(e) 70 - case string: 71 - cancelCause(r.Context())(errors.New(e)) 72 - default: 73 - log.Warn( 74 - "cannot set cancel cause, because error type is not supported", 75 - "type", fmt.Sprintf("%T", e), 76 - ) 77 - } 78 - } 79 - }() 80 - 81 - next(w, r) 82 - 83 - log = log.With("status", w.Code, "duration", time.Since(now)) 84 - if w.Code < 400 { 85 - log.Debug("handled") 86 - } else if w.Code < 500 { 87 - level := slog.LevelDebug 88 - if (w.Code == http.StatusNotFound && logNotFound) || 89 - (w.Code == http.StatusBadRequest && logBadRequest) || 90 - (w.Code != http.StatusNotFound && w.Code != http.StatusBadRequest && w.Code != http.StatusTooManyRequests) { 91 - level = slog.LevelWarn 92 - } 93 - log.Log(context.Background(), level, "invalid request") 94 - } else { 95 - log.Error("error while handling request") 96 - } 97 - } 98 - } 99 - 100 - func MiddlewareHeaders(domain string, cacheDur time.Duration) Middleware { 101 - if cacheDur == 0 { 102 - cacheDur = 15 * time.Minute 103 - } 104 - return func(next Handler, w *StatusWriter, r *http.Request) { 105 - // prevent tracking 106 - w.Header().Add("Referrer-Policy", "strict-origin-when-cross-origin") 107 - // prevent iframe 108 - w.Header().Add("X-Frame-Options", "deny") 109 - // prevent bad content being parsed 110 - w.Header().Add("X-Content-Type-Options", "nosniff") 111 - w.Header().Add("X-Permitted-Cross-Domain-Policies", "none") 112 - // content security, cors & co 113 - w.Header().Add("Content-Security-Policy", "default-src 'self' https://*."+domain+"; object-src 'none';") 114 - w.Header().Add("Access-Control-Allow-Origin", "https://"+domain) 115 - w.Header().Add("Cross-Origin-Resource-Policy", "same-origin") 116 - // caching 117 - w.Header().Add("Access-Control-Max-Age", fmt.Sprintf("%.0f", math.Floor(cacheDur.Seconds()))) 118 - 119 - next(w, r) 120 - } 121 - }
+2 -4
cmd/lasad/config.go
··· 6 6 "os" 7 7 8 8 "tangled.org/anhgelus.world/lasa/cmd/internal" 9 + "tangled.org/anhgelus.world/lasa/cmd/lasad/config" 9 10 ) 10 - 11 - //go:embed default.toml 12 - var defaultConfig []byte 13 11 14 12 func handleGenConfigHelp() { 15 13 internal.Usage( ··· 32 30 return 33 31 } 34 32 fmt.Println("writing default file at", configPath) 35 - err := os.WriteFile(configPath, defaultConfig, 0640) 33 + err := os.WriteFile(configPath, config.DefaultConfig, 0640) 36 34 if err != nil { 37 35 panic(err) 38 36 }
+12 -2
cmd/lasad/config/config.go
··· 2 2 3 3 import ( 4 4 "context" 5 + _ "embed" 5 6 "os" 6 7 "strconv" 7 8 "time" ··· 12 13 13 14 const DefaultPath = "/etc/lasad.toml" 14 15 16 + //go:embed default.toml 17 + var DefaultConfig []byte 18 + 15 19 type Config struct { 16 20 Domain string `toml:"domain"` 17 - Port uint `toml:"port"` 18 - Cache *Cache `toml:"cache"` 19 21 LegalNotice *string `toml:"legal_notice_url"` 20 22 LogNotFound bool `toml:"log_not_found"` 21 23 LogBadRequest bool `toml:"log_bad_request"` 24 + Listen Listen `toml:"listen"` 25 + Cache *Cache `toml:"cache"` 26 + } 27 + 28 + type Listen struct { 29 + TCP *string `toml:"tcp"` 30 + Unix *string `toml:"unix"` 31 + FastCGI bool `toml:"fastcgi"` 22 32 } 23 33 24 34 type Cache struct {
+7 -1
cmd/lasad/default.toml cmd/lasad/config/default.toml
··· 1 1 domain = "example.org" 2 - port = 8000 3 2 #legal_notice_url = "https://example.org/legal" 4 3 # if you want to log HTTP 404 responses 5 4 #log_not_found = true 6 5 # if you want to log HTTP 400 responses 7 6 #log_bad_request = true 7 + 8 + [listen] 9 + tcp = ":8000" 10 + # if you want to use a unix socket instead 11 + #unix = "/run/lasad.sock" 12 + # if you want to use FastCGI instead of HTTP 13 + #fastcgi = true 8 14 9 15 # if you are using redis 10 16 #[cache]
+22 -1
cmd/lasad/run.go
··· 5 5 "log/slog" 6 6 "net" 7 7 "net/http" 8 + "net/http/fcgi" 8 9 "os" 9 10 "os/signal" 10 11 "syscall" ··· 62 63 63 64 go func() { 64 65 slog.Info("starting") 65 - ch <- server.Run(ctx, cfg, client, cache, dur) 66 + if cfg.Listen.TCP == nil && cfg.Listen.Unix == nil { 67 + panic("no listen address set") 68 + } 69 + var l net.Listener 70 + if cfg.Listen.Unix != nil { 71 + l, err = net.Listen("unix", *cfg.Listen.Unix) 72 + } else { 73 + l, err = net.Listen("tcp", *cfg.Listen.TCP) 74 + } 75 + if err != nil { 76 + panic(err) 77 + } 78 + s, err := server.New(ctx, cfg, client, cache, dur) 79 + if err != nil { 80 + panic(err) 81 + } 82 + if cfg.Listen.FastCGI { 83 + ch <- fcgi.Serve(l, s.Handler()) 84 + } else { 85 + ch <- http.Serve(l, s.Handler()) 86 + } 66 87 }() 67 88 select { 68 89 case <-ctx.Done():
+2 -2
cmd/lasad/server/limiter.go
··· 8 8 "sync" 9 9 "time" 10 10 11 - "tangled.org/anhgelus.world/lasa/cmd/internal" 11 + "tangled.org/anhgelus.world/ljus" 12 12 ) 13 13 14 14 type limited struct { ··· 37 37 return ok && tm.time.Unix() > time.Now().Unix() 38 38 } 39 39 40 - func (l *Limiter) handle(w *internal.StatusWriter, r *http.Request) { 40 + func (l *Limiter) handle(w *ljus.StatusWriter, r *http.Request) { 41 41 addr := r.Header.Get("X-Real-Ip") 42 42 if addr == "" { 43 43 addr = r.Header.Get("X-Forwarded-For")
+47 -47
cmd/lasad/server/run.go
··· 4 4 "context" 5 5 "embed" 6 6 "errors" 7 - "fmt" 7 + "log/slog" 8 8 "net/http" 9 9 "strings" 10 10 "time" 11 11 12 12 "github.com/redis/go-redis/v9" 13 13 "tangled.org/anhgelus.world/lasa" 14 - "tangled.org/anhgelus.world/lasa/cmd/internal" 15 14 "tangled.org/anhgelus.world/lasa/cmd/lasad/config" 15 + "tangled.org/anhgelus.world/ljus" 16 + "tangled.org/anhgelus.world/ljus/middleware" 16 17 "tangled.org/anhgelus.world/xrpc" 17 18 ) 18 19 ··· 26 27 RKey string 27 28 } 28 29 29 - func Run(ctx context.Context, cfg *config.Config, client xrpc.Client, cache *redis.Client, dur time.Duration) error { 30 + func New(ctx context.Context, cfg *config.Config, client xrpc.Client, cache *redis.Client, dur time.Duration) (*ljus.Server, error) { 30 31 ctx = context.WithValue(ctx, keyCfg, cfg) 31 32 ctx = context.WithValue(ctx, keyClient, client) 32 33 ctx = context.WithValue(ctx, keyDir, NewDirectory(cache, dur)) 33 34 ctx = context.WithValue(ctx, keyLimiter, &Limiter{limited: make(map[string]*limited)}) 34 35 35 - mux := http.NewServeMux() 36 + mux := ljus.New() 37 + mux.Use(func(next ljus.Handler, w *ljus.StatusWriter, r *http.Request) { 38 + // not found favicon 39 + if strings.HasPrefix(r.RequestURI, "/favicon.ico") { 40 + w.WriteHeader(http.StatusNotFound) 41 + return 42 + } 43 + next(w, r) 44 + }) 45 + mux.Use(func(next ljus.Handler, w *ljus.StatusWriter, r *http.Request) { 46 + // timeouts request handling 47 + ctx, cancel := context.WithTimeoutCause(ctx, 15*time.Second, errors.New("handling timeouts")) 48 + defer cancel() 49 + 50 + var cancelCause context.CancelCauseFunc 51 + ctx, cancelCause = context.WithCancelCause(ctx) 52 + defer cancelCause(errors.New("handling finished")) 53 + 54 + ctx = context.WithValue(ctx, keyCancelCause, cancelCause) 55 + 56 + next(w, r.WithContext(ctx)) 57 + }) 58 + mux.Use(middleware.SecurityHeaders(cfg.Domain, dur)) 59 + mux.Use(middleware.Log(slog.Default(), func(ctx context.Context) context.CancelCauseFunc { 60 + return ctx.Value(keyCancelCause).(context.CancelCauseFunc) 61 + }, cfg.LogNotFound, cfg.LogBadRequest)) 62 + mux.Use(func(next ljus.Handler, w *ljus.StatusWriter, r *http.Request) { 63 + // rate limits 64 + limiter := ctx.Value(keyLimiter).(*Limiter) 65 + if limiter.isLimited(r) { 66 + w.WriteHeader(http.StatusTooManyRequests) 67 + return 68 + } 69 + next(w, r) 70 + limiter.handle(w, r) 71 + }) 72 + 36 73 mux.HandleFunc("GET /{id}/{rkey}/rss", func(w http.ResponseWriter, r *http.Request) { 37 74 dir := r.Context().Value(keyDir).(*Directory) 38 75 err := dir.Feed(r.Context(), w, r, "rss", lasa.GenerateRSS) ··· 40 77 HandleErrors(w, err) 41 78 return 42 79 } 43 - }) 80 + }).Name("rss") 44 81 mux.HandleFunc("GET /{id}/{rkey}/atom", func(w http.ResponseWriter, r *http.Request) { 45 82 dir := r.Context().Value(keyDir).(*Directory) 46 83 err := dir.Feed(r.Context(), w, r, "atom", lasa.GenerateAtom) ··· 48 85 HandleErrors(w, err) 49 86 return 50 87 } 51 - }) 88 + }).Name("atom") 52 89 mux.HandleFunc("GET /{id}/{$}", func(w http.ResponseWriter, r *http.Request) { 53 90 ctx := r.Context() 54 91 client := ctx.Value(keyClient).(xrpc.Client) ··· 64 101 return 65 102 } 66 103 w.Write(b) 67 - }) 104 + }).Name("list") 68 105 mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) { 69 106 b, err := files.ReadFile("index.html") 70 107 if err != nil { ··· 72 109 return 73 110 } 74 111 w.Write(b) 75 - }) 112 + }).Name("root") 76 113 mux.HandleFunc("GET /style.css", func(w http.ResponseWriter, r *http.Request) { 77 114 b, err := files.ReadFile("style.css") 78 115 if err != nil { ··· 81 118 } 82 119 w.Header().Add("Content-Type", "text/css") 83 120 w.Write(b) 84 - }) 85 - 86 - m := internal.NewMux(mux) 87 - m.Use(func(next internal.Handler, w *internal.StatusWriter, r *http.Request) { 88 - // not found favicon 89 - if strings.HasPrefix(r.RequestURI, "/favicon.ico") { 90 - w.WriteHeader(http.StatusNotFound) 91 - return 92 - } 93 - next(w, r) 94 - }) 95 - m.Use(func(next internal.Handler, w *internal.StatusWriter, r *http.Request) { 96 - // timeouts request handling 97 - ctx, cancel := context.WithTimeoutCause(ctx, 15*time.Second, errors.New("handling timeouts")) 98 - defer cancel() 121 + }).Name("css") 99 122 100 - var cancelCause context.CancelCauseFunc 101 - ctx, cancelCause = context.WithCancelCause(ctx) 102 - defer cancelCause(errors.New("handling finished")) 103 - 104 - ctx = context.WithValue(ctx, keyCancelCause, cancelCause) 105 - 106 - next(w, r.WithContext(ctx)) 107 - }) 108 - m.Use(internal.MiddlewareHeaders(cfg.Domain, dur)) 109 - m.Use(internal.MiddlewareLog(func(ctx context.Context) context.CancelCauseFunc { 110 - return ctx.Value(keyCancelCause).(context.CancelCauseFunc) 111 - }, cfg.LogNotFound, cfg.LogBadRequest)) 112 - m.Use(func(next internal.Handler, w *internal.StatusWriter, r *http.Request) { 113 - // rate limits 114 - limiter := ctx.Value(keyLimiter).(*Limiter) 115 - if limiter.isLimited(r) { 116 - w.WriteHeader(http.StatusTooManyRequests) 117 - return 118 - } 119 - next(w, r) 120 - limiter.handle(w, r) 121 - }) 122 - 123 - return http.ListenAndServe(fmt.Sprintf(":%d", cfg.Port), m.Handle()) 123 + return mux, nil 124 124 }
+1
go.mod
··· 7 7 github.com/nyttikord/logos v0.1.0 8 8 github.com/redis/go-redis/v9 v9.18.0 9 9 tangled.org/anhgelus.world/goat-site v0.1.3 10 + tangled.org/anhgelus.world/ljus v0.1.0 10 11 tangled.org/anhgelus.world/xrpc v0.4.0 11 12 ) 12 13
+2
go.sum
··· 32 32 pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= 33 33 tangled.org/anhgelus.world/goat-site v0.1.3 h1:TMwjKzOEeiJCTjH8qELZ1zG7C51UVbIKFgH3qdBMLQc= 34 34 tangled.org/anhgelus.world/goat-site v0.1.3/go.mod h1:/JOfXaSOhE/xwLoA1bJ6Nfi9hmYP+8tFa7CTNzG92SU= 35 + tangled.org/anhgelus.world/ljus v0.1.0 h1:9BrFQsCLCij8v+YDm1Y9foStd5MR+NDcNtEXD6xow4k= 36 + tangled.org/anhgelus.world/ljus v0.1.0/go.mod h1:/CN+a8MYyvUArsPUDhtfajjISA6V0hsAfIcCcQEIo4o= 35 37 tangled.org/anhgelus.world/xrpc v0.4.0 h1:Khf8/9I8J6IV1KLqLrqf9a1X6QJUQN3dXjcLZGp2zRg= 36 38 tangled.org/anhgelus.world/xrpc v0.4.0/go.mod h1:DW43uo9DKZHVN9fiH6lAYVQ+0cfSLoceo7aE5lE1jjw=