mirror of https://gitlab.com/ngerakines/tavern.git
471 lines
13 KiB
Go
471 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,
|
|
},
|
|
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}))
|
|
|
|
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),
|
|
}
|
|
|
|
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.GET("/notifications", h.notifications)
|
|
|
|
authenticated.GET("/utilities", h.utilities)
|
|
authenticated.POST("/utilities/webfinger", h.utilitiesWebfinger)
|
|
authenticated.POST("/utilities/crawl", h.utilitiesCrawl)
|
|
}
|
|
|
|
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))
|
|
}
|