// Package http implements the HTTP-based api for the mediocre-blog. package http import ( "context" "embed" "encoding/json" "errors" "fmt" "html/template" "net" "net/http" "net/url" "os" "strings" "time" lru "github.com/hashicorp/golang-lru" "github.com/mediocregopher/blog.mediocregopher.com/srv/cfg" "github.com/mediocregopher/blog.mediocregopher.com/srv/http/apiutil" "github.com/mediocregopher/blog.mediocregopher.com/srv/mailinglist" "github.com/mediocregopher/blog.mediocregopher.com/srv/post" "github.com/mediocregopher/blog.mediocregopher.com/srv/pow" "github.com/mediocregopher/mediocre-go-lib/v2/mctx" "github.com/mediocregopher/mediocre-go-lib/v2/mlog" ) //go:embed static var staticFS embed.FS // Params are used to instantiate a new API instance. All fields are required // unless otherwise noted. type Params struct { Logger *mlog.Logger PowManager pow.Manager PostStore post.Store PostAssetStore post.AssetStore PostDraftStore post.DraftStore MailingList mailinglist.MailingList // PublicURL is the base URL which site visitors can navigate to. PublicURL *url.URL // ListenProto and ListenAddr are passed into net.Listen to create the // API's listener. Both "tcp" and "unix" protocols are explicitly // supported. ListenProto, ListenAddr string // AuthUsers keys are usernames which are allowed to edit server-side data, // and the values are the password hash which accompanies those users. The // password hash must have been produced by NewPasswordHash. AuthUsers map[string]string // AuthRatelimit indicates how much time must pass between subsequent auth // attempts. AuthRatelimit time.Duration // GeminiGatewayURL will be used to translate links for `gemini://` into // `http(s)://`. See gmi.GemtextToMarkdown. GeminiGatewayURL *url.URL } // SetupCfg implement the cfg.Cfger interface. func (p *Params) SetupCfg(cfg *cfg.Cfg) { publicURLStr := cfg.String("http-public-url", "http://localhost:4000", "URL this service is accessible at") geminiGatewayURLStr := cfg.String("http-gemini-gateway-url", "", "Optional URL to prefix to all gemini:// links, to make them accessible over https") cfg.StringVar(&p.ListenProto, "http-listen-proto", "tcp", "Protocol to listen for HTTP requests with") cfg.StringVar(&p.ListenAddr, "http-listen-addr", ":4000", "Address/unix socket path to listen for HTTP requests on") httpAuthUsersStr := cfg.String("http-auth-users", "{}", "JSON object with usernames as values and password hashes (produced by the hash-password binary) as values. Denotes users which are able to edit server-side data") httpAuthRatelimitStr := cfg.String("http-auth-ratelimit", "5s", "Minimum duration which must be waited between subsequent auth attempts") cfg.OnInit(func(context.Context) error { err := json.Unmarshal([]byte(*httpAuthUsersStr), &p.AuthUsers) if err != nil { return fmt.Errorf("unmarshaling -http-auth-users: %w", err) } if p.AuthRatelimit, err = time.ParseDuration(*httpAuthRatelimitStr); err != nil { return fmt.Errorf("unmarshaling -http-auth-ratelimit: %w", err) } *publicURLStr = strings.TrimSuffix(*publicURLStr, "/") if p.PublicURL, err = url.Parse(*publicURLStr); err != nil { return fmt.Errorf("parsing -http-public-url: %w", err) } if *geminiGatewayURLStr != "" { if p.GeminiGatewayURL, err = url.Parse(*geminiGatewayURLStr); err != nil { return fmt.Errorf("parsing -http-gemini-gateway-url: %w", err) } } return nil }) } // Annotate implements mctx.Annotator interface. func (p *Params) Annotate(a mctx.Annotations) { a["httpPublicURL"] = p.PublicURL a["httpListenProto"] = p.ListenProto a["httpListenAddr"] = p.ListenAddr a["httpAuthRatelimit"] = p.AuthRatelimit } // API will listen on the port configured for it, and serve HTTP requests for // the mediocre-blog. type API interface { Shutdown(ctx context.Context) error } type api struct { params Params srv *http.Server redirectTpl *template.Template auther Auther } // New initializes and returns a new API instance, including setting up all // listening ports. func New(params Params) (API, error) { l, err := net.Listen(params.ListenProto, params.ListenAddr) if err != nil { return nil, fmt.Errorf("creating listen socket: %w", err) } if params.ListenProto == "unix" { if err := os.Chmod(params.ListenAddr, 0777); err != nil { return nil, fmt.Errorf("chmod-ing unix socket: %w", err) } } a := &api{ params: params, auther: NewAuther(params.AuthUsers, params.AuthRatelimit), } a.redirectTpl = a.mustParseTpl("redirect.html") a.srv = &http.Server{Handler: a.handler()} go func() { err := a.srv.Serve(l) if err != nil && !errors.Is(err, http.ErrServerClosed) { ctx := mctx.WithAnnotator(context.Background(), &a.params) params.Logger.Fatal(ctx, "serving http server", err) } }() return a, nil } func (a *api) Shutdown(ctx context.Context) error { defer a.auther.Close() if err := a.srv.Shutdown(ctx); err != nil { return err } return nil } func (a *api) apiHandler() http.Handler { mux := http.NewServeMux() mux.Handle("/pow/challenge", a.newPowChallengeHandler()) mux.Handle("/pow/check", a.requirePowMiddleware( http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {}), ), ) mux.Handle("/mailinglist/subscribe", a.requirePowMiddleware(a.mailingListSubscribeHandler())) mux.Handle("/mailinglist/finalize", a.mailingListFinalizeHandler()) mux.Handle("/mailinglist/unsubscribe", a.mailingListUnsubscribeHandler()) return apiutil.MethodMux(map[string]http.Handler{ "POST": mux, }) } func (a *api) blogHandler() http.Handler { cache, err := lru.New(5000) // instantiating the lru cache can't realistically fail if err != nil { panic(err) } mux := http.NewServeMux() mux.Handle("/posts/", http.StripPrefix("/posts", apiutil.MethodMux(map[string]http.Handler{ "GET": a.getPostsHandler(), "EDIT": a.editPostHandler(false), "MANAGE": a.managePostsHandler(), "POST": a.postPostHandler(), "DELETE": a.deletePostHandler(false), "PREVIEW": a.previewPostHandler(), }), )) mux.Handle("/assets/", http.StripPrefix("/assets", apiutil.MethodMux(map[string]http.Handler{ "GET": a.getPostAssetHandler(), "MANAGE": a.managePostAssetsHandler(), "POST": a.postPostAssetHandler(), "DELETE": a.deletePostAssetHandler(), }), )) mux.Handle("/drafts/", http.StripPrefix("/drafts", // everything to do with drafts is protected authMiddleware(a.auther)( apiutil.MethodMux(map[string]http.Handler{ "EDIT": a.editPostHandler(true), "MANAGE": a.manageDraftPostsHandler(), "POST": a.postDraftPostHandler(), "DELETE": a.deletePostHandler(true), "PREVIEW": a.previewPostHandler(), }), ), )) mux.Handle("/static/", http.FileServer(http.FS(staticFS))) mux.Handle("/follow", a.renderDumbTplHandler("follow.html")) mux.Handle("/admin", a.renderDumbTplHandler("admin.html")) mux.Handle("/mailinglist/unsubscribe", a.renderDumbTplHandler("unsubscribe.html")) mux.Handle("/mailinglist/finalize", a.renderDumbTplHandler("finalize.html")) mux.Handle("/feed.xml", a.renderFeedHandler()) mux.Handle("/", a.renderIndexHandler()) readOnlyMiddlewares := []middleware{ logReqMiddleware, // only log GETs on cache miss cacheMiddleware(cache), } readWriteMiddlewares := []middleware{ purgeCacheOnOKMiddleware(cache), authMiddleware(a.auther), } h := apiutil.MethodMux(map[string]http.Handler{ "GET": applyMiddlewares(mux, readOnlyMiddlewares...), "MANAGE": applyMiddlewares(mux, readOnlyMiddlewares...), "EDIT": applyMiddlewares(mux, readOnlyMiddlewares...), "*": applyMiddlewares(mux, readWriteMiddlewares...), }) return h } func (a *api) handler() http.Handler { mux := http.NewServeMux() mux.Handle("/api/", applyMiddlewares( http.StripPrefix("/api", a.apiHandler()), logReqMiddleware, )) mux.Handle("/", a.blogHandler()) noCacheMiddleware := addResponseHeadersMiddleware(map[string]string{ "Cache-Control": "no-store, max-age=0", "Pragma": "no-cache", "Expires": "0", }) h := applyMiddlewares( apiutil.MethodMux(map[string]http.Handler{ "GET": applyMiddlewares(mux), "MANAGE": applyMiddlewares(mux, noCacheMiddleware), "EDIT": applyMiddlewares(mux, noCacheMiddleware), "*": applyMiddlewares( mux, a.checkCSRFMiddleware, noCacheMiddleware, ), }), setLoggerMiddleware(a.params.Logger), ) return h }