audon/server.go

309 wiersze
8.3 KiB
Go

package main
import (
"context"
"crypto/rand"
"encoding/base32"
"encoding/gob"
"html/template"
"io"
"log"
"net/http"
"net/url"
"os"
"os/signal"
"strings"
"time"
"github.com/go-playground/validator/v10"
"github.com/go-redis/redis/v9"
"github.com/gorilla/sessions"
"github.com/jellydator/ttlcache/v3"
"github.com/labstack/echo-contrib/session"
"github.com/labstack/echo/v4"
lksdk "github.com/livekit/server-sdk-go"
"github.com/mattn/go-mastodon"
"github.com/nicksnyder/go-i18n/v2/i18n"
"github.com/rbcervilla/redisstore/v9"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
)
type (
Template struct {
templates *template.Template
}
TemplateData struct {
Config *AppConfigBase
Room *Room
}
CustomValidator struct {
validator *validator.Validate
}
M map[string]interface{}
)
var (
// mastAppConfigBase *mastodon.AppConfig = nil
mainDB *mongo.Database = nil
mainValidator = validator.New()
mainConfig *AppConfig
lkRoomServiceClient *lksdk.RoomServiceClient
localeBundle *i18n.Bundle
userSessionCache *ttlcache.Cache[string, *SessionData]
webhookTimerCache *ttlcache.Cache[string, *time.Timer]
orphanRooms *ttlcache.Cache[string, bool]
)
func init() {
gob.Register(&SessionData{})
gob.Register(&M{})
}
func main() {
var err error
log.Println("Audon server started.")
// Load config from environment variables and .env
log.Println("Loading Audon config values")
mainConfig, err = loadConfig(os.Getenv("AUDON_ENV"))
if err != nil {
log.Fatalf("Failed loading config values: %s\n", err.Error())
}
// Load locales
localeBundle = initLocaleBundle()
// Setup Livekit RoomService Client
lkURL := &url.URL{
Scheme: "https",
Host: mainConfig.Livekit.Host,
}
if mainConfig.Environment == "development" {
lkURL.Scheme = "http"
}
lkRoomServiceClient = lksdk.NewRoomServiceClient(lkURL.String(), mainConfig.Livekit.APIKey, mainConfig.Livekit.APISecret)
backContext, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// Setup database client
log.Println("Connecting to DB")
dbClient, err := mongo.Connect(backContext, options.Client().ApplyURI(mainConfig.MongoURL.String()))
defer dbClient.Disconnect(backContext)
if err != nil {
log.Fatalf("Failed connecting to DB: %s\n", err.Error())
}
mainDB = dbClient.Database(mainConfig.Database.Name)
err = createIndexes(backContext)
if err != nil {
log.Fatalf("Failed creating indexes: %s\n", err.Error())
}
// Setup redis client
redisClient := redis.NewClient(&redis.Options{
Addr: mainConfig.Redis.Host,
Username: mainConfig.Redis.User,
Password: mainConfig.Redis.Password,
DB: 0,
})
// Setup echo server
e := echo.New()
defer e.Close()
e.Validator = &CustomValidator{validator: mainValidator}
e.Renderer = &Template{
templates: template.Must(template.New("tmpl").Delims("{%", "%}").ParseGlob("audon-fe/dist/index.html")),
}
// Setup session middleware (currently Audon stores all client data in cookie)
log.Println("Connecting to Redis")
redisStore, err := redisstore.NewRedisStore(backContext, redisClient)
if err != nil {
log.Fatalf("Failed connecting to Redis: %s\n", err.Error())
}
defer redisStore.Close()
redisStore.KeyGen(func() (string, error) {
k := make([]byte, 64)
if _, err := rand.Read(k); err != nil {
return "", err
}
return strings.TrimRight(base32.StdEncoding.EncodeToString(k), "="), nil
})
redisStore.KeyPrefix("session_")
sessionOptions := sessions.Options{
Path: "/",
Domain: mainConfig.LocalDomain,
MaxAge: 86400 * 30,
HttpOnly: true,
SameSite: http.SameSiteDefaultMode,
Secure: true,
}
if mainConfig.Environment == "development" {
sessionOptions.Domain = ""
sessionOptions.SameSite = http.SameSiteDefaultMode
sessionOptions.Secure = false
sessionOptions.MaxAge = 3600 * 24
sessionOptions.HttpOnly = false
}
redisStore.Options(sessionOptions)
e.Use(session.Middleware(redisStore))
// Setup caches
userSessionCache = ttlcache.New(ttlcache.WithTTL[string, *SessionData](168 * time.Hour))
webhookTimerCache = ttlcache.New(ttlcache.WithTTL[string, *time.Timer](5 * time.Minute))
orphanRooms = ttlcache.New(ttlcache.WithTTL[string, bool](24 * time.Hour))
go userSessionCache.Start()
go webhookTimerCache.Start()
go orphanRooms.Start()
e.POST("/app/login", loginHandler)
e.GET("/app/oauth", oauthHandler)
e.GET("/app/verify", verifyHandler)
e.POST("/app/logout", logoutHandler)
e.GET("/app/preview/:id", previewRoomHandler)
e.GET("/app/user/:id", getUserHandler)
e.POST("/app/webhook", livekitWebhookHandler)
api := e.Group("/api", authMiddleware)
api.GET("/token", getUserTokenHandler)
api.GET("/room", getStatusHandler)
api.POST("/room", createRoomHandler)
api.DELETE("/room", leaveRoomHandler)
api.POST("/room/:id", joinRoomHandler)
api.PATCH("/room/:id", updateRoomHandler)
api.DELETE("/room/:id", closeRoomHandler)
api.PUT("/room/:id", updateRoleHandler)
e.Static("/assets", "audon-fe/dist/assets")
e.Static("/static", "audon-fe/dist/static")
e.Static("/storage", mainConfig.StorageDir)
e.GET("/r/:id", renderRoomHandler)
e.GET("/u/:webfinger", redirectUserHandler)
e.GET("/*", func(c echo.Context) error {
return c.Render(http.StatusOK, "tmpl", &TemplateData{Config: &mainConfig.AppConfigBase})
})
// e.File("/*", "audon-fe/dist/index.html")
// use anonymous func to support graceful shutdown
go func() {
if err := e.Start(":8100"); err != nil && err != http.ErrServerClosed {
e.Logger.Fatalf("Shutting down the server: %s\n", err.Error())
}
}()
// Wait for interrupt signal to gracefully shutdown the server with a timeout of 10 seconds.
// Use a buffered channel to avoid missing signals as recommended for signal.Notify
quit := make(chan os.Signal, 1)
signal.Notify(quit, os.Interrupt)
<-quit
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
e.Logger.Print("Attempting graceful shutdown")
defer shutdownCancel()
userSessionCache.DeleteAll()
webhookTimerCache.DeleteAll()
orphanRooms.DeleteAll()
if err := e.Shutdown(shutdownCtx); err != nil {
e.Logger.Fatalf("Failed shutting down gracefully: %s\n", err.Error())
}
}
func (t *Template) Render(w io.Writer, name string, data interface{}, c echo.Context) error {
return t.templates.ExecuteTemplate(w, name, data)
}
func (cv *CustomValidator) Validate(i interface{}) error {
if err := cv.validator.Struct(i); err != nil {
return wrapValidationError(err)
}
return nil
}
func getAppConfig(server string) (*mastodon.AppConfig, error) {
redirectURI := "urn:ietf:wg:oauth:2.0:oob"
u := &url.URL{
Host: mainConfig.LocalDomain,
Scheme: "https",
Path: "/",
}
u = u.JoinPath("app", "oauth")
redirectURI = u.String()
conf := &mastodon.AppConfig{
ClientName: "Audon",
// Scopes: "read:accounts read:follows write:accounts",
Scopes: "read:accounts read:follows",
Website: "https://codeberg.org/nmkj/audon",
RedirectURIs: redirectURI,
}
// mastAppConfigBase = conf
return &mastodon.AppConfig{
Server: server,
ClientName: conf.ClientName,
Scopes: conf.Scopes,
Website: conf.Website,
RedirectURIs: conf.RedirectURIs,
}, nil
}
func getSession(c echo.Context, sessionID string) (sess *sessions.Session, err error) {
sess, err = session.Get(sessionID, c)
if err != nil {
return nil, err
}
return sess, nil
}
// retrieve user's session, returns invalid cookie error if failed
func getSessionData(c echo.Context) (data *SessionData, err error) {
sess, err := getSession(c, SESSION_NAME)
if err != nil {
c.Logger().Error(err)
return nil, ErrSessionNotAvailable
}
val := sess.Values[SESSION_DATASTORE_NAME]
data, ok := val.(*SessionData)
if !ok {
return nil, ErrInvalidSession
}
return data, nil
}
// write user's session, returns error if failed
func writeSessionData(c echo.Context, data *SessionData) error {
sess, err := getSession(c, SESSION_NAME)
if err != nil {
return err
}
if data == nil {
sess.Values[SESSION_DATASTORE_NAME] = ""
} else {
sess.Values[SESSION_DATASTORE_NAME] = data
}
return sess.Save(c.Request(), c.Response())
}
// handler for GET to /app/verify
func verifyHandler(c echo.Context) (err error) {
valid, acc, _ := verifyTokenInSession(c)
if !valid {
return c.NoContent(http.StatusUnauthorized)
}
return c.JSON(http.StatusOK, acc)
}