package main import ( "compress/flate" "fmt" "log" "net/http" "net/url" "strconv" "strings" "sync" "github.com/caddyserver/certmagic" "github.com/dchest/captcha" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" servertiming "github.com/mitchellh/go-server-timing" ) const ( contentType = "Content-Type" charsetUtf8Suffix = "; charset=utf-8" contentTypeHTML = "text/html" contentTypeJSON = "application/json" contentTypeWWWForm = "application/x-www-form-urlencoded" contentTypeMultipartForm = "multipart/form-data" contentTypeAS = "application/activity+json" contentTypeRSS = "application/rss+xml" contentTypeATOM = "application/atom+xml" contentTypeJSONFeed = "application/feed+json" contentTypeHTMLUTF8 = contentTypeHTML + charsetUtf8Suffix contentTypeJSONUTF8 = contentTypeJSON + charsetUtf8Suffix contentTypeASUTF8 = contentTypeAS + charsetUtf8Suffix userAgent = "User-Agent" appUserAgent = "GoBlog" ) var d *dynamicHandler func startServer() (err error) { // Start d = &dynamicHandler{} // Set basic middlewares var finalHandler http.Handler = d if appConfig.Server.PublicHTTPS || appConfig.Server.SecurityHeaders { finalHandler = securityHeaders(finalHandler) } finalHandler = servertiming.Middleware(finalHandler, nil) finalHandler = middleware.Heartbeat("/ping")(finalHandler) finalHandler = middleware.Compress(flate.DefaultCompression)(finalHandler) finalHandler = middleware.Recoverer(finalHandler) if appConfig.Server.Logging { finalHandler = logMiddleware(finalHandler) } // Create routers that don't change err = buildStaticHandlersRouters() if err != nil { return } // Load router err = reloadRouter() if err != nil { return } // Start Onion service if appConfig.Server.Tor { go func() { torErr := startOnionService(finalHandler) log.Println("Tor failed:", torErr.Error()) }() } // Start HTTP(s) server localAddress := ":" + strconv.Itoa(appConfig.Server.Port) if appConfig.Server.PublicHTTPS { certmagic.Default.Storage = &certmagic.FileStorage{Path: "data/https"} certmagic.DefaultACME.Agreed = true certmagic.DefaultACME.Email = appConfig.Server.LetsEncryptMail certmagic.DefaultACME.CA = certmagic.LetsEncryptProductionCA hosts := []string{appConfig.Server.publicHostname} if appConfig.Server.shortPublicHostname != "" { hosts = append(hosts, appConfig.Server.shortPublicHostname) } err = certmagic.HTTPS(hosts, finalHandler) } else { err = http.ListenAndServe(localAddress, finalHandler) } return } func reloadRouter() error { h, err := buildDynamicRouter() if err != nil { return err } d.swapHandler(h) purgeCache() return nil } const ( paginationPath = "/page/{page:[0-9-]+}" feedPath = ".{feed:rss|json|atom}" ) var ( privateMode = false privateModeHandler = []func(http.Handler) http.Handler{} captchaHandler http.Handler micropubRouter, indieAuthRouter, webmentionsRouter, notificationsRouter, activitypubRouter, editorRouter, commentsRouter, searchRouter *chi.Mux setBlogMiddlewares = map[string]func(http.Handler) http.Handler{} sectionMiddlewares = map[string]func(http.Handler) http.Handler{} taxonomyMiddlewares = map[string]func(http.Handler) http.Handler{} photosMiddlewares = map[string]func(http.Handler) http.Handler{} searchMiddlewares = map[string]func(http.Handler) http.Handler{} customPagesMiddlewares = map[string]func(http.Handler) http.Handler{} commentsMiddlewares = map[string]func(http.Handler) http.Handler{} ) func buildStaticHandlersRouters() error { if pm := appConfig.PrivateMode; pm != nil && pm.Enabled { privateMode = true privateModeHandler = append(privateModeHandler, authMiddleware) } captchaHandler = captcha.Server(500, 250) micropubRouter = chi.NewRouter() micropubRouter.Use(checkIndieAuth) micropubRouter.Get("/", serveMicropubQuery) micropubRouter.Post("/", serveMicropubPost) micropubRouter.Post(micropubMediaSubPath, serveMicropubMedia) indieAuthRouter = chi.NewRouter() indieAuthRouter.Get("/", indieAuthRequest) indieAuthRouter.With(authMiddleware).Post("/accept", indieAuthAccept) indieAuthRouter.Post("/", indieAuthVerification) indieAuthRouter.Get("/token", indieAuthToken) indieAuthRouter.Post("/token", indieAuthToken) webmentionsRouter = chi.NewRouter() webmentionsRouter.Post("/", handleWebmention) webmentionsRouter.Group(func(r chi.Router) { // Authenticated routes r.Use(authMiddleware) r.Get("/", webmentionAdmin) r.Get(paginationPath, webmentionAdmin) r.Post("/delete", webmentionAdminDelete) r.Post("/approve", webmentionAdminApprove) }) notificationsRouter = chi.NewRouter() notificationsRouter.Use(authMiddleware) notificationsRouter.Get("/", notificationsAdmin) notificationsRouter.Get(paginationPath, notificationsAdmin) if ap := appConfig.ActivityPub; ap != nil && ap.Enabled { activitypubRouter = chi.NewRouter() activitypubRouter.Post("/inbox/{blog}", apHandleInbox) activitypubRouter.Post("/{blog}/inbox", apHandleInbox) } editorRouter = chi.NewRouter() editorRouter.Use(authMiddleware) editorRouter.Get("/", serveEditor) editorRouter.Post("/", serveEditorPost) commentsRouter = chi.NewRouter() commentsRouter.Use(privateModeHandler...) commentsRouter.With(cacheMiddleware).Get("/{id:[0-9]+}", serveComment) commentsRouter.With(captchaMiddleware).Post("/", createComment) commentsRouter.Group(func(r chi.Router) { // Admin r.Use(authMiddleware) r.Get("/", commentsAdmin) r.Get(paginationPath, commentsAdmin) r.Post("/delete", commentsAdminDelete) }) searchRouter = chi.NewRouter() searchRouter.Use(privateModeHandler...) searchRouter.Use(cacheMiddleware) searchRouter.Get("/", serveSearch) searchRouter.Post("/", serveSearch) searchResultPath := "/" + searchPlaceholder searchRouter.Get(searchResultPath, serveSearchResult) searchRouter.Get(searchResultPath+feedPath, serveSearchResult) searchRouter.Get(searchResultPath+paginationPath, serveSearchResult) for blog, blogConfig := range appConfig.Blogs { sbm := middleware.WithValue(blogContextKey, blog) setBlogMiddlewares[blog] = sbm blogPath := blogPath(blog) for _, section := range blogConfig.Sections { if section.Name != "" { secPath := blogPath + "/" + section.Name sectionMiddlewares[secPath] = middleware.WithValue(indexConfigKey, &indexConfig{ path: secPath, section: section, }) } } for _, taxonomy := range blogConfig.Taxonomies { if taxonomy.Name != "" { taxPath := blogPath + "/" + taxonomy.Name taxonomyMiddlewares[taxPath] = middleware.WithValue(taxonomyContextKey, taxonomy) } } if blogConfig.Photos != nil && blogConfig.Photos.Enabled { photosMiddlewares[blog] = middleware.WithValue(indexConfigKey, &indexConfig{ path: blogPath + blogConfig.Photos.Path, parameter: blogConfig.Photos.Parameter, title: blogConfig.Photos.Title, description: blogConfig.Photos.Description, summaryTemplate: templatePhotosSummary, }) } if blogConfig.Search != nil && blogConfig.Search.Enabled { searchMiddlewares[blog] = middleware.WithValue(pathContextKey, blogPath+blogConfig.Search.Path) } for _, cp := range blogConfig.CustomPages { customPagesMiddlewares[cp.Path] = middleware.WithValue(customPageContextKey, cp) } if commentsConfig := blogConfig.Comments; commentsConfig != nil && commentsConfig.Enabled { commentsMiddlewares[blog] = middleware.WithValue(pathContextKey, blogPath+"/comment") } } return nil } var ( taxValueMiddlewares = map[string]func(http.Handler) http.Handler{} ) func buildDynamicRouter() (*chi.Mux, error) { r := chi.NewRouter() // Basic middleware r.Use(redirectShortDomain) r.Use(middleware.RedirectSlashes) r.Use(middleware.CleanPath) r.Use(middleware.GetHead) if !appConfig.Cache.Enable { r.Use(middleware.NoCache) } // No Index Header if privateMode { r.Use(noIndexHeader) } // Login middleware etc. r.Use(checkIsLogin) r.Use(checkIsCaptcha) r.Use(checkLoggedIn) // Logout r.With(authMiddleware).Get("/login", serveLogin) r.With(authMiddleware).Get("/logout", serveLogout) // Micropub r.Mount(micropubPath, micropubRouter) // IndieAuth r.Mount("/indieauth", indieAuthRouter) // ActivityPub and stuff if ap := appConfig.ActivityPub; ap != nil && ap.Enabled { r.Mount("/activitypub", activitypubRouter) r.With(cacheMiddleware).Get("/.well-known/webfinger", apHandleWebfinger) r.With(cacheMiddleware).Get("/.well-known/host-meta", handleWellKnownHostMeta) r.With(cacheMiddleware).Get("/.well-known/nodeinfo", serveNodeInfoDiscover) r.With(cacheMiddleware).Get("/nodeinfo", serveNodeInfo) } // Webmentions r.Mount(webmentionPath, webmentionsRouter) // Notifications r.Mount(notificationsPath, notificationsRouter) // Posts pp, err := allPostPaths(statusPublished) if err != nil { return nil, err } r.Group(func(r chi.Router) { r.Use(privateModeHandler...) r.Use(checkActivityStreamsRequest, cacheMiddleware) for _, path := range pp { r.Get(path, servePost) } }) // Drafts dp, err := allPostPaths(statusDraft) if err != nil { return nil, err } r.Group(func(r chi.Router) { r.Use(authMiddleware) for _, path := range dp { r.Get(path, servePost) } }) // Post aliases allPostAliases, err := allPostAliases() if err != nil { return nil, err } r.Group(func(r chi.Router) { r.Use(privateModeHandler...) r.Use(cacheMiddleware) for _, path := range allPostAliases { r.Get(path, servePostAlias) } }) // Assets for _, path := range allAssetPaths() { r.Get(path, serveAsset) } // Static files for _, path := range allStaticPaths() { r.Get(path, serveStaticFile) } // Media files r.With(privateModeHandler...).Get(`/m/{file:[0-9a-fA-F]+(\.[0-9a-zA-Z]+)?}`, serveMediaFile) // Captcha r.Handle("/captcha/*", captchaHandler) // Short paths r.With(privateModeHandler...).With(cacheMiddleware).Get("/s/{id:[0-9a-fA-F]+}", redirectToLongPath) for blog, blogConfig := range appConfig.Blogs { blogPath := blogPath(blog) sbm := setBlogMiddlewares[blog] // Sections r.Group(func(r chi.Router) { r.Use(privateModeHandler...) r.Use(cacheMiddleware, sbm) for _, section := range blogConfig.Sections { if section.Name != "" { secPath := blogPath + "/" + section.Name r.Group(func(r chi.Router) { r.Use(sectionMiddlewares[secPath]) r.Get(secPath, serveIndex) r.Get(secPath+feedPath, serveIndex) r.Get(secPath+paginationPath, serveIndex) }) } } }) // Taxonomies for _, taxonomy := range blogConfig.Taxonomies { if taxonomy.Name != "" { taxPath := blogPath + "/" + taxonomy.Name taxValues, err := allTaxonomyValues(blog, taxonomy.Name) if err != nil { return nil, err } r.Group(func(r chi.Router) { r.Use(privateModeHandler...) r.Use(cacheMiddleware, sbm) r.With(taxonomyMiddlewares[taxPath]).Get(taxPath, serveTaxonomy) for _, tv := range taxValues { r.Group(func(r chi.Router) { vPath := taxPath + "/" + urlize(tv) if _, ok := taxValueMiddlewares[vPath]; !ok { taxValueMiddlewares[vPath] = middleware.WithValue(indexConfigKey, &indexConfig{ path: vPath, tax: taxonomy, taxValue: tv, }) } r.Use(taxValueMiddlewares[vPath]) r.Get(vPath, serveIndex) r.Get(vPath+feedPath, serveIndex) r.Get(vPath+paginationPath, serveIndex) }) } }) } } // Photos if blogConfig.Photos != nil && blogConfig.Photos.Enabled { r.Group(func(r chi.Router) { r.Use(privateModeHandler...) r.Use(cacheMiddleware, sbm, photosMiddlewares[blog]) photoPath := blogPath + blogConfig.Photos.Path r.Get(photoPath, serveIndex) r.Get(photoPath+feedPath, serveIndex) r.Get(photoPath+paginationPath, serveIndex) }) } // Search if blogConfig.Search != nil && blogConfig.Search.Enabled { searchPath := blogPath + blogConfig.Search.Path r.With(sbm, searchMiddlewares[blog]).Mount(searchPath, searchRouter) } // Stats if blogConfig.BlogStats != nil && blogConfig.BlogStats.Enabled { statsPath := blogPath + blogConfig.BlogStats.Path r.With(privateModeHandler...).With(cacheMiddleware, sbm).Get(statsPath, serveBlogStats) } // Date archives r.Group(func(r chi.Router) { r.Use(privateModeHandler...) r.Use(cacheMiddleware, sbm) yearRegex := `/{year:x|\d\d\d\d}` monthRegex := `/{month:x|\d\d}` dayRegex := `/{day:\d\d}` yearPath := blogPath + yearRegex r.Get(yearPath, serveDate) r.Get(yearPath+feedPath, serveDate) r.Get(yearPath+paginationPath, serveDate) monthPath := yearPath + monthRegex r.Get(monthPath, serveDate) r.Get(monthPath+feedPath, serveDate) r.Get(monthPath+paginationPath, serveDate) dayPath := monthPath + dayRegex r.Get(dayPath, serveDate) r.Get(dayPath+feedPath, serveDate) r.Get(dayPath+paginationPath, serveDate) }) // Blog if !blogConfig.PostAsHome { r.Group(func(r chi.Router) { r.Use(privateModeHandler...) r.Use(sbm) r.With(checkActivityStreamsRequest, cacheMiddleware).Get(blogConfig.Path, serveHome) r.With(cacheMiddleware).Get(blogConfig.Path+feedPath, serveHome) r.With(cacheMiddleware).Get(blogPath+paginationPath, serveHome) }) } // Custom pages for _, cp := range blogConfig.CustomPages { scp := customPagesMiddlewares[cp.Path] if cp.Cache { r.With(privateModeHandler...).With(cacheMiddleware, sbm, scp).Get(cp.Path, serveCustomPage) } else { r.With(privateModeHandler...).With(sbm, scp).Get(cp.Path, serveCustomPage) } } // Random post if rp := blogConfig.RandomPost; rp != nil && rp.Enabled { randomPath := rp.Path if randomPath == "" { randomPath = "/random" } r.With(privateModeHandler...).With(sbm).Get(blogPath+randomPath, redirectToRandomPost) } // Editor r.With(sbm).Mount(blogPath+"/editor", editorRouter) // Comments if commentsConfig := blogConfig.Comments; commentsConfig != nil && commentsConfig.Enabled { commentsPath := blogPath + "/comment" r.With(sbm, commentsMiddlewares[blog]).Mount(commentsPath, commentsRouter) } } // Sitemap r.With(privateModeHandler...).With(cacheMiddleware).Get(sitemapPath, serveSitemap) // Robots.txt - doesn't need cache, because it's too simple if !privateMode { r.Get("/robots.txt", serveRobotsTXT) } else { r.Get("/robots.txt", servePrivateRobotsTXT) } // Check redirects, then serve 404 r.With(cacheMiddleware, checkRegexRedirects).NotFound(serve404) r.MethodNotAllowed(serveNotAllowed) return r, nil } func blogPath(blog string) string { blogPath := appConfig.Blogs[blog].Path if blogPath == "/" { return "" } return blogPath } const blogContextKey requestContextKey = "blog" const pathContextKey requestContextKey = "httpPath" var cspDomains = "" func refreshCSPDomains() { cspDomains = "" if mp := appConfig.Micropub.MediaStorage; mp != nil && mp.MediaURL != "" { if u, err := url.Parse(mp.MediaURL); err == nil { cspDomains += " " + u.Hostname() } } if len(appConfig.Server.CSPDomains) > 0 { cspDomains += " " + strings.Join(appConfig.Server.CSPDomains, " ") } } func securityHeaders(next http.Handler) http.Handler { refreshCSPDomains() return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Strict-Transport-Security", "max-age=31536000;") w.Header().Set("Referrer-Policy", "no-referrer") w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("X-Frame-Options", "SAMEORIGIN") w.Header().Set("X-Xss-Protection", "1; mode=block") w.Header().Set("Content-Security-Policy", "default-src 'self'"+cspDomains) if appConfig.Server.Tor && torAddress != "" { w.Header().Set("Onion-Location", fmt.Sprintf("http://%v%v", torAddress, r.URL.Path)) } next.ServeHTTP(w, r) }) } func noIndexHeader(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Robots-Tag", "noindex") next.ServeHTTP(w, r) }) } type dynamicHandler struct { router *chi.Mux mutex sync.RWMutex } func (d *dynamicHandler) swapHandler(h *chi.Mux) { d.mutex.Lock() d.router = h d.mutex.Unlock() } func (d *dynamicHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // Fix to use Path routing instead of RawPath routing in Chi r.URL.RawPath = "" // Serve request d.mutex.RLock() router := d.router d.mutex.RUnlock() router.ServeHTTP(w, r) }