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