|
|
|
// 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
|
|
|
|
}
|