tavern/web/command.go

482 lines
13 KiB
Go

package web
import (
"context"
"fmt"
"html/template"
"net/http"
"os"
"os/signal"
"path/filepath"
"runtime"
"strings"
"time"
"github.com/foolin/goview"
"github.com/foolin/goview/supports/ginview"
"github.com/getsentry/sentry-go"
sentrygin "github.com/getsentry/sentry-go/gin"
"github.com/gin-contrib/cors"
"github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/cookie"
"github.com/gin-contrib/static"
ginzap "github.com/gin-contrib/zap"
"github.com/gin-gonic/gin"
ut "github.com/go-playground/universal-translator"
"github.com/oklog/run"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/urfave/cli/v2"
"go.uber.org/zap"
"github.com/ngerakines/tavern/asset"
"github.com/ngerakines/tavern/common"
"github.com/ngerakines/tavern/config"
"github.com/ngerakines/tavern/g"
"github.com/ngerakines/tavern/job"
"github.com/ngerakines/tavern/metrics"
"github.com/ngerakines/tavern/storage"
)
var Command = cli.Command{
Name: "server",
Usage: "Run the web server.",
Flags: []cli.Flag{
&config.EnvironmentFlag,
&config.ListenFlag,
&config.DomainFlag,
&config.DatabaseFlag,
&config.TranslationsFlag,
&config.SecretFlag,
&cli.StringFlag{
Name: "admin-name",
Usage: "The name of the admin user",
EnvVars: []string{"ADMIN_NAME"},
Value: "nick",
},
&config.EnableSVGerFlag,
&config.SVGerEndpointFlag,
&config.EnableSentryFlag,
&config.SentryFlag,
&config.AssetStorageFlag,
&config.AssetStorageFileBaseFlag,
&config.AssetStorageRemoteAllowFlag,
&config.AssetStorageRemoteDenyFlag,
&config.AssetStorageRemoteMaxFlag,
&config.AllowAutoAcceptFollowersFlag,
&config.AllowFollowObjectFlag,
&config.AllowInboxForwardingFlag,
&config.EnablePublisherFlag,
&config.PublisherLocationFlag,
&config.EnableGroupsFlag,
&config.AllowAutoAcceptGroupFollowersFlag,
&config.AllowRemoteFollowersFlag,
&config.DefaultGroupMemberRoleFlag,
&config.DefaultDirectoryOptInFlag,
},
Action: serverCommandAction,
}
func serverCommandAction(cliCtx *cli.Context) error {
logger, err := config.Logger(cliCtx)
if err != nil {
return err
}
domain := cliCtx.String("domain")
siteBase := fmt.Sprintf("https://%s", domain)
logger.Info("Starting",
zap.String("command", cliCtx.Command.Name),
zap.String("GOOS", runtime.GOOS),
zap.String("site", siteBase),
zap.String("env", cliCtx.String("environment")))
ohioLocal, err = time.LoadLocation("America/New_York")
if err != nil {
return err
}
sentryConfig, err := config.NewSentryConfig(cliCtx)
if err != nil {
return err
}
svgerConfig := config.NewSVGerConfig(cliCtx)
fedConfig, err := config.NewFedConfig(cliCtx)
if err != nil {
return err
}
groupConfig, err := config.NewGroupConfig(cliCtx)
if err != nil {
return err
}
publisherConfig := config.NewPublisherConfig(cliCtx)
if sentryConfig.Enabled {
err = sentry.Init(sentry.ClientOptions{
Dsn: sentryConfig.Key,
Environment: cliCtx.String("environment"),
Release: fmt.Sprintf("%s-%s", g.Release, g.GitCommit),
})
if err != nil {
return err
}
sentry.ConfigureScope(func(scope *sentry.Scope) {
scope.SetTags(map[string]string{"container": "server"})
})
defer sentry.Recover()
}
db, dbClose, err := config.DB(cliCtx, logger)
if err != nil {
return err
}
defer dbClose()
registry := prometheus.NewRegistry()
if err = registry.Register(prometheus.NewGoCollector()); err != nil {
return err
}
fact := promauto.With(registry)
s := storage.DefaultStorage(storage.WrapMetricDriver(fact, "tavern", "web", storage.LoggingSQLDriver{Driver: db, Logger: logger}))
serverActorRowID, err := s.ActorRowIDForActorID(context.Background(), fmt.Sprintf("https://%s/server", domain))
if err != nil {
return err
}
utrans, err := config.Trans(cliCtx)
if err != nil {
return err
}
assetStorageConfig := config.AssetStorage(cliCtx)
var assetStorage asset.Storage
switch assetStorageConfig.Type {
case "file":
assetStorage = asset.FileStorage{
Base: assetStorageConfig.FileBasePath,
}
default:
return fmt.Errorf("unknown asset storage type: %s", assetStorageConfig.Type)
}
r := gin.New()
if sentryConfig.Enabled {
r.Use(sentrygin.New(sentrygin.Options{
Repanic: true,
}))
}
viewEngine := ginview.New(goview.Config{
Root: "templates",
Extension: ".html",
Master: filepath.Join("layouts", "master"),
Partials: []string{
"partials/flashes",
"partials/dashboard_menu",
"partials/activity_feed",
"partials/activity_create_note",
"partials/activity_announce_note",
"partials/activity_tombstone",
"partials/view_object",
"partials/object_feed",
"partials/paged",
},
Funcs: template.FuncMap{
"date": tmplDate,
"odate": tmplOptionalDate,
"time": tmplTime,
"otime": tmplOptionalTime,
"datetime": tmplDateTime,
"odatetime": tmplOptionalDateTime,
"shortUUID": tmplShortUUID,
"short": tmplShort,
"title": strings.Title,
"a": tmplAnchor,
"toHTML": tmplToHTML,
"strong": tmplStrong,
"wrapMaybe": tmplWrapMaybe,
"url": tmplUrlGen(siteBase),
"dict": tmplDict,
"lookupStr": tmplLookupStr,
"lookupBool": tmplLookupBool,
"urlEncode": tmplUrlEncode,
"pretty": tmplPretty,
"tavern_version": func() string { return g.Version() },
},
DisableCache: true,
})
viewEngine.SetFileHandler(TemplateFileHandler())
r.HTMLRender = viewEngine
r.Use(func(i *gin.Context) {
nonce := i.GetHeader("X-Request-ID")
if nonce != "" {
i.Set("nonce", nonce)
}
i.Next()
})
r.Use(ginzap.Ginzap(logger, time.RFC3339, true))
corsConfig := cors.DefaultConfig()
corsConfig.AllowOrigins = []string{siteBase}
corsConfig.AllowMethods = []string{"GET", "POST"}
corsConfig.AllowHeaders = []string{"Origin", "Content-Length", "Content-Type"}
corsConfig.MaxAge = 12 * time.Hour
r.Use(cors.New(corsConfig))
r.GET("/health", func(i *gin.Context) {
i.Data(200, "text/plain", []byte("OK"))
})
r.Use(static.Serve("/public", static.LocalFile("./public", false)))
secret, err := config.Secret(cliCtx)
if err != nil {
return err
}
webFingerQueue := common.NewStringQueue()
crawlQueue := common.NewStringQueue()
assetQueue := common.NewStringQueue()
{
tavernInfoG := fact.NewGauge(prometheus.GaugeOpts{
Namespace: "tavern",
Subsystem: "web",
Name: "info",
Help: "",
ConstLabels: map[string]string{"buildtime": g.BuildTime, "version": g.Version(), "ua": g.UserAgent()},
})
tavernInfoG.Set(1)
}
httpClient := common.InstrumentedDefaultHTTPClient(fact, "web", "client")
var svgConv SVGConverter
if svgerConfig.Enabled {
svgConv = DefaultSVGerClient(svgerConfig.Location, httpClient)
}
h := handler{
storage: s,
logger: logger,
domain: domain,
sentryConfig: sentryConfig,
fedConfig: fedConfig,
groupConfig: groupConfig,
webFingerQueue: webFingerQueue,
crawlQueue: crawlQueue,
assetQueue: assetQueue,
adminUser: cliCtx.String("admin-name"),
url: tmplUrlGen(siteBase),
svgConverter: svgConv,
assetStorage: assetStorage,
httpClient: httpClient,
metricFactory: promauto.With(registry),
publisherClient: newPublisherClient(logger, httpClient, publisherConfig),
serverActorRowID: serverActorRowID,
}
configI18nMiddleware(sentryConfig, logger, utrans, domain, r)
mm := metrics.NewMetricsMiddleware("web", "server", promauto.With(registry))
r.Use(mm.Handle)
root := r.Group("/")
{
root.GET("/.well-known/webfinger", h.webFinger)
root.GET("/.well-known/nodeinfo", h.nodeInfo)
root.GET("/nodeinfo/2.0", h.nodeInfoDetails)
root.GET("/about", h.tavernAbout)
root.GET("/terms", h.tavernTerms)
root.GET("/usage", h.tavernUsage)
}
apiV1 := r.Group("/api/v1")
{
apiV1.GET("/instance", h.apiV1Instance)
apiV1.GET("/instance/peers", h.apiV1InstancePeers)
}
root.GET("/asset/image/:checksum", h.viewAsset)
root.GET("/asset/thumbnail/:checksum", h.viewThumbnail)
root.GET("/asset/blurhash/:blurHash", h.viewBlur)
if publisherConfig.Enabled {
r.POST("/webhooks/publisher", h.publisherWebhook)
}
promhandler := promhttp.InstrumentMetricHandler(
registry, promhttp.HandlerFor(registry, promhttp.HandlerOpts{}),
)
root.GET("/metrics", func(c *gin.Context) {
promhandler.ServeHTTP(c.Writer, c.Request)
})
root.GET("/avatar/svg/:domain/:name", h.avatarSVG)
if svgerConfig.Enabled {
root.GET("/avatar/png/:domain/:name", h.avatarPNG)
}
authenticated := r.Group("/")
{
configSessionMiddleware(secret, domain, authenticated)
authenticated.GET("/activity/:activity", h.getActivity)
authenticated.GET("/object/:object/replies", h.getObjectReplies)
authenticated.GET("/object/:object", h.getObject)
authenticated.GET("/tags/:tag", h.getTaggedObjects)
userActor := authenticated.Group("/users")
{
userActor.GET("/:name", h.userActorInfo)
userActor.POST("/:name/inbox", h.userActorInbox)
userActor.GET("/:name/outbox", h.userActorOutbox)
userActor.GET("/:name/following", h.userActorFollowing)
userActor.GET("/:name/followers", h.userActorFollowers)
}
if groupConfig.EnableGroups {
groupActor := authenticated.Group("/groups")
{
groupActor.GET("/:name", h.groupActorInfo)
groupActor.POST("/:name", h.configureGroup)
groupActor.POST("/:name/inbox", h.groupActorInbox)
groupActor.GET("/:name/outbox", h.groupActorOutbox)
groupActor.GET("/:name/following", h.groupActorFollowing)
groupActor.GET("/:name/followers", h.groupActorFollowers)
}
}
authenticated.GET("/", h.home)
authenticated.POST("/signin", h.signin)
authenticated.GET("/signout", h.signout)
authenticated.GET("/feed", h.viewFeed)
authenticated.GET("/feed/mine", h.viewMyFeed)
authenticated.GET("/feed/local", h.viewLocalFeed)
manage := authenticated.Group("/manage")
{
manageNetwork := manage.Group("/network")
{
manageNetwork.GET("/", h.dashboardNetwork)
manageNetwork.POST("/follow", h.networkFollow)
manageNetwork.POST("/unfollow", h.networkUnfollow)
manageNetwork.POST("/accept", h.networkAccept)
manageNetwork.POST("/reject", h.networkReject)
}
if groupConfig.EnableGroups {
manageGroups := manage.Group("/groups")
{
manageGroups.GET("/", h.manageGroups)
manageGroups.POST("/create", h.dashboardGroupsCreate)
}
}
}
authenticated.GET("/compose", h.compose)
authenticated.POST("/compose/create/note", h.createNote)
authenticated.POST("/dashboard/notes/announce/note", h.announceNote)
authenticated.POST("/notes/delete", h.deleteNote)
authenticated.GET("/configure", h.configure)
authenticated.POST("/configure/user", h.saveUserSettings)
authenticated.POST("/configure/acl", h.createACL)
authenticated.POST("/configure/acl-validate", h.validateACL)
authenticated.GET("/notifications", h.notifications)
authenticated.GET("/utilities", h.utilities)
authenticated.POST("/utilities/webfinger", h.utilitiesWebfinger)
authenticated.POST("/utilities/crawl", h.utilitiesCrawl)
authenticated.GET("/directory/groups", h.groupDirectory)
}
var group run.Group
srv := &http.Server{
Addr: config.ListenAddress(cliCtx),
Handler: r,
}
parentCtx, parentCtxCancel := context.WithCancel(context.Background())
defer parentCtxCancel()
group.Add(func() error {
logger.Info("starting http service", zap.String("address", srv.Addr))
return srv.ListenAndServe()
}, func(error) {
httpCancelCtx, httpCancelCtxCancel := context.WithTimeout(context.Background(), 30*time.Second)
defer httpCancelCtxCancel()
logger.Info("stopping http service")
if err := srv.Shutdown(httpCancelCtx); err != nil {
logger.Info("error stopping http service:", zap.Error(err))
}
})
webFingerJob := job.NewWebFingerWorker(logger, webFingerQueue, s, fedConfig, httpClient)
job.RunWorker(&group, logger, webFingerJob, parentCtx)
crawlJob := job.NewCrawlWorker(logger, crawlQueue, s, assetStorage, assetStorageConfig, fedConfig, httpClient)
job.RunWorker(&group, logger, crawlJob, parentCtx)
assetJob := job.NewAssetDownloadJob(logger, assetQueue, s, assetStorage, assetStorageConfig, fedConfig, httpClient)
job.RunWorker(&group, logger, assetJob, parentCtx)
quit := make(chan os.Signal)
signal.Notify(quit, os.Interrupt)
group.Add(func() error {
logger.Info("starting signal listener")
<-quit
return nil
}, func(error) {
logger.Info("stopping signal listener")
close(quit)
})
return group.Run()
}
func configI18nMiddleware(sentryCfg config.SentryConfig, logger *zap.Logger, utrans *ut.UniversalTranslator, domain string, r gin.IRouter) {
im := i18nMiddleware{
utrans: utrans,
logger: logger,
domain: domain,
enableSentry: sentryCfg.Enabled,
}
r.Use(im.findLocale)
}
func configSessionMiddleware(secret []byte, domain string, r gin.IRouter) {
store := cookie.NewStore(secret)
store.Options(sessions.Options{
Path: "/",
Domain: domain,
MaxAge: 86400 * 30,
Secure: true,
HttpOnly: true,
})
r.Use(sessions.Sessions("sess", store))
}