// Package gmi implements the gemini-based api for the mediocre-blog. package gmi import ( "bytes" "context" "errors" "fmt" "io" "mime" "net/url" "os" "path" "path/filepath" "strings" "git.sr.ht/~adnano/go-gemini" "git.sr.ht/~adnano/go-gemini/certificate" "github.com/mediocregopher/blog.mediocregopher.com/srv/cache" "github.com/mediocregopher/blog.mediocregopher.com/srv/cfg" "github.com/mediocregopher/blog.mediocregopher.com/srv/post" "github.com/mediocregopher/blog.mediocregopher.com/srv/post/asset" "github.com/mediocregopher/mediocre-go-lib/v2/mctx" "github.com/mediocregopher/mediocre-go-lib/v2/mlog" ) // Params are used to instantiate a new API instance. All fields are required // unless otherwise noted. type Params struct { Logger *mlog.Logger Cache cache.Cache PostStore post.Store PostAssetLoader asset.Loader PublicURL *url.URL ListenAddr string CertificatesPath string HTTPPublicURL *url.URL } // SetupCfg implement the cfg.Cfger interface. func (p *Params) SetupCfg(cfg *cfg.Cfg) { publicURLStr := cfg.String("gemini-public-url", "gemini://localhost:2065", "URL this service is accessible at") cfg.StringVar(&p.ListenAddr, "gemini-listen-addr", ":2065", "Address to listen for HTTP requests on") cfg.StringVar(&p.CertificatesPath, "gemini-certificates-path", "", "Path to directory where gemini certs should be created/stored") cfg.OnInit(func(context.Context) error { if p.CertificatesPath == "" { return errors.New("-gemini-certificates-path is required") } var err error *publicURLStr = strings.TrimSuffix(*publicURLStr, "/") if p.PublicURL, err = url.Parse(*publicURLStr); err != nil { return fmt.Errorf("parsing -gemini-public-url: %w", err) } return nil }) } // Annotate implements mctx.Annotator interface. func (p *Params) Annotate(a mctx.Annotations) { a["geminiPublicURL"] = p.PublicURL a["geminiListenAddr"] = p.ListenAddr a["geminiCertificatesPath"] = p.CertificatesPath } // API will listen on the port configured for it, and serve gemini requests for // the mediocre-blog. type API interface { Shutdown(ctx context.Context) error } type api struct { params Params srv *gemini.Server } // New initializes and returns a new API instance, including setting up all // listening ports. func New(params Params) (API, error) { if err := os.MkdirAll(params.CertificatesPath, 0700); err != nil { return nil, fmt.Errorf("creating certificate directory %q: %w", params.CertificatesPath, err) } certStore := new(certificate.Store) certStore.Load(params.CertificatesPath) certStore.Register(params.PublicURL.Hostname()) a := &api{ params: params, } handler, err := a.handler() if err != nil { return nil, fmt.Errorf("constructing handler: %w", err) } a.srv = &gemini.Server{ Addr: params.ListenAddr, Handler: handler, GetCertificate: certStore.Get, } go func() { err := a.srv.ListenAndServe(context.Background()) if err != nil && !errors.Is(err, context.Canceled) { ctx := mctx.WithAnnotator(context.Background(), &a.params) a.params.Logger.Fatal(ctx, "serving gemini server", err) } }() return a, nil } func (a *api) Shutdown(ctx context.Context) error { return a.srv.Shutdown(ctx) } func (a *api) logReqMiddleware(h gemini.Handler) gemini.Handler { type logCtxKey string return gemini.HandlerFunc(func( ctx context.Context, rw gemini.ResponseWriter, r *gemini.Request, ) { ctx = mctx.Annotate(ctx, logCtxKey("url"), r.URL.String(), ) h.ServeGemini(ctx, rw, r) a.params.Logger.Info(ctx, "handled gemini request") }) } func indexMiddleware(h gemini.Handler) gemini.Handler { return gemini.HandlerFunc(func( ctx context.Context, rw gemini.ResponseWriter, r *gemini.Request, ) { if strings.HasSuffix(r.URL.Path, "/") { r.URL.Path += "index.gmi" } h.ServeGemini(ctx, rw, r) }) } func feedMiddleware(h gemini.Handler) gemini.Handler { return gemini.HandlerFunc(func( ctx context.Context, rw gemini.ResponseWriter, r *gemini.Request, ) { rw = forceResponseWriterMediaType(rw, "application/atom+xml") h.ServeGemini(ctx, rw, r) }) } func postsMiddleware(tplHandler gemini.Handler) gemini.Handler { return gemini.HandlerFunc(func( ctx context.Context, rw gemini.ResponseWriter, r *gemini.Request, ) { id := path.Base(r.URL.Path) id = strings.TrimSuffix(id, ".gmi") if id == "index" { tplHandler.ServeGemini(ctx, rw, r) return } query := r.URL.Query() query.Set("id", id) r.URL.RawQuery = query.Encode() r.URL.Path = "/posts/post.gmi" tplHandler.ServeGemini(ctx, rw, r) }) } func (a *api) assetsMiddleware() gemini.Handler { return gemini.HandlerFunc(func( ctx context.Context, rw gemini.ResponseWriter, r *gemini.Request, ) { path := strings.TrimPrefix(r.URL.Path, "/assets/") mimeType := mime.TypeByExtension(filepath.Ext(path)) ctx = mctx.Annotate(ctx, "assetPath", path, "mimeType", mimeType) if mimeType != "" { rw.SetMediaType(mimeType) } buf := new(bytes.Buffer) err := a.params.PostAssetLoader.Load(path, buf, asset.LoadOpts{}) if errors.Is(err, asset.ErrNotFound) { rw.WriteHeader(gemini.StatusNotFound, "Asset not found, sorry!") return } else if err != nil { a.params.Logger.Error(ctx, "error fetching asset", err) rw.WriteHeader(gemini.StatusTemporaryFailure, err.Error()) return } if _, err := io.Copy(rw, buf); err != nil { a.params.Logger.Error(ctx, "error copying asset", err) rw.WriteHeader(gemini.StatusTemporaryFailure, err.Error()) return } }) } func (a *api) handler() (gemini.Handler, error) { tplHandler, err := a.tplHandler() if err != nil { return nil, fmt.Errorf("generating tpl handler: %w", err) } mux := new(gemini.Mux) mux.Handle("/posts/", postsMiddleware(tplHandler)) mux.Handle("/assets/", a.assetsMiddleware()) mux.Handle("/feed.xml", feedMiddleware(tplHandler)) mux.Handle("/", tplHandler) var h gemini.Handler h = mux h = indexMiddleware(h) h = a.logReqMiddleware(h) h = cacheMiddleware(a.params.Cache)(h) return h, nil }