|
- package alps
-
- import (
- "encoding/json"
- "fmt"
- "log"
- "net/http"
- "net/url"
- "strings"
- "sync"
- "time"
-
- "git.sr.ht/~sircmpwn/dowork"
- "github.com/fernet/fernet-go"
- "github.com/labstack/echo/v4"
- )
-
- const (
- cookieName = "alps_session"
- loginTokenCookieName = "alps_login_token"
- )
-
- // Server holds all the alps server state.
- type Server struct {
- e *echo.Echo
- Sessions *SessionManager
- Options *Options
- Queue *work.Queue
-
- mutex sync.RWMutex // used for server reload
- plugins []Plugin
-
- // maps protocols to URLs (protocol can be empty for auto-discovery)
- upstreams map[string]*url.URL
-
- imap struct {
- host string
- tls bool
- insecure bool
- }
- smtp struct {
- host string
- tls bool
- insecure bool
- }
- }
-
- func newServer(e *echo.Echo, options *Options) (*Server, error) {
- s := &Server{e: e, Options: options}
-
- s.upstreams = make(map[string]*url.URL, len(options.Upstreams))
- for _, upstream := range options.Upstreams {
- u, err := parseUpstream(upstream)
- if err != nil {
- return nil, fmt.Errorf("failed to parse upstream %q: %v", upstream, err)
- }
- if _, ok := s.upstreams[u.Scheme]; ok {
- return nil, fmt.Errorf("found two upstream servers for scheme %q", u.Scheme)
- }
- s.upstreams[u.Scheme] = u
- }
-
- if err := s.parseIMAPUpstream(); err != nil {
- return nil, err
- }
- if err := s.parseSMTPUpstream(); err != nil {
- return nil, err
- }
-
- s.Sessions = newSessionManager(s.dialIMAP, s.dialSMTP, e.Logger, options.Debug)
- s.Queue = work.NewQueue("alps")
-
- return s, nil
- }
-
- func parseUpstream(s string) (*url.URL, error) {
- if !strings.ContainsAny(s, ":/") {
- // This is a raw domain name, make it an URL with an empty scheme
- s = "//" + s
- }
- return url.Parse(s)
- }
-
- type NoUpstreamError struct {
- schemes []string
- }
-
- func (err *NoUpstreamError) Error() string {
- return fmt.Sprintf("no upstream server configured for schemes %v", err.schemes)
- }
-
- // Upstream retrieves the configured upstream server URL for the provided
- // schemes. If no configured upstream server matches, a *NoUpstreamError is
- // returned. An empty URL.Scheme means that the caller needs to perform
- // auto-discovery with URL.Host.
- func (s *Server) Upstream(schemes ...string) (*url.URL, error) {
- var urls []*url.URL
- for _, scheme := range append(schemes, "") {
- u, ok := s.upstreams[scheme]
- if ok {
- urls = append(urls, u)
- }
- }
- if len(urls) == 0 {
- return nil, &NoUpstreamError{schemes}
- }
- if len(urls) > 1 {
- return nil, fmt.Errorf("multiple upstream servers are configured for schemes %v", schemes)
- }
- return urls[0], nil
- }
-
- func (s *Server) parseIMAPUpstream() error {
- u, err := s.Upstream("imap", "imaps", "imap+insecure")
- if err != nil {
- return fmt.Errorf("failed to parse upstream IMAP server: %v", err)
- }
-
- if u.Scheme == "" {
- u, err = discoverIMAP(u.Host)
- if err != nil {
- return fmt.Errorf("failed to discover IMAP server: %v", err)
- }
- }
-
- switch u.Scheme {
- case "imaps":
- s.imap.tls = true
- case "imap+insecure":
- s.imap.insecure = true
- }
-
- s.imap.host = u.Host
- if !strings.ContainsRune(s.imap.host, ':') {
- if u.Scheme == "imaps" {
- s.imap.host += ":993"
- } else {
- s.imap.host += ":143"
- }
- }
-
- c, err := s.dialIMAP()
- if err != nil {
- return fmt.Errorf("failed to connect to IMAP server: %v", err)
- }
- c.Close()
-
- s.e.Logger.Printf("Configured upstream IMAP server: %v", u)
- return nil
- }
-
- func (s *Server) parseSMTPUpstream() error {
- u, err := s.Upstream("smtp", "smtps", "smtp+insecure")
- if _, ok := err.(*NoUpstreamError); ok {
- return nil
- } else if err != nil {
- return fmt.Errorf("failed to parse upstream SMTP server: %v", err)
- }
-
- if u.Scheme == "" {
- u, err = discoverSMTP(u.Host)
- if err != nil {
- s.e.Logger.Printf("Failed to discover SMTP server: %v", err)
- return nil
- }
- }
-
- switch u.Scheme {
- case "smtps":
- s.smtp.tls = true
- case "smtp+insecure":
- s.smtp.insecure = true
- }
-
- s.smtp.host = u.Host
- if !strings.ContainsRune(s.smtp.host, ':') {
- if u.Scheme == "smtps" {
- s.smtp.host += ":465"
- } else {
- s.smtp.host += ":587"
- }
- }
-
- c, err := s.dialSMTP()
- if err != nil {
- return fmt.Errorf("failed to connect to SMTP server: %v", err)
- }
- c.Close()
-
- s.e.Logger.Printf("Configured upstream SMTP server: %v", u)
- return nil
- }
-
- func (s *Server) load() error {
- var plugins []Plugin
- for _, load := range pluginLoaders {
- l, err := load(s)
- if err != nil {
- return fmt.Errorf("failed to load plugins: %v", err)
- }
- for _, p := range l {
- s.e.Logger.Printf("Loaded plugin %q", p.Name())
- }
- plugins = append(plugins, l...)
- }
-
- renderer := newRenderer(s.e.Logger, s.Options.Theme)
- if err := renderer.Load(plugins); err != nil {
- return fmt.Errorf("failed to load templates: %v", err)
- }
-
- // Once we've loaded plugins and templates from disk (which can take time),
- // swap them in the Server struct
- s.mutex.Lock()
- defer s.mutex.Unlock()
-
- // Close previous plugins
- for _, p := range s.plugins {
- if err := p.Close(); err != nil {
- s.e.Logger.Printf("Failed to unload plugin %q: %v", p.Name(), err)
- }
- }
-
- s.plugins = plugins
- s.e.Renderer = renderer
-
- for _, p := range plugins {
- p.SetRoutes(s.e.Group(""))
- }
-
- return nil
- }
-
- // Reload loads Lua plugins and templates from disk.
- func (s *Server) Reload() error {
- s.e.Logger.Printf("Reloading server")
- return s.load()
- }
-
- // Logger returns this server's logger.
- func (s *Server) Logger() echo.Logger {
- return s.e.Logger
- }
-
- // Context is the context used by HTTP handlers.
- //
- // Use a type assertion to get it from a echo.Context:
- //
- // ctx := ectx.(*alps.Context)
- type Context struct {
- echo.Context
- Server *Server
- Session *Session // nil if user isn't logged in
- }
-
- var aLongTimeAgo = time.Unix(233431200, 0)
-
- // SetSession sets a cookie for the provided session. Passing a nil session
- // unsets the cookie.
- func (ctx *Context) SetSession(s *Session) {
- cookie := http.Cookie{
- Name: cookieName,
- HttpOnly: true,
- // TODO: domain, secure
- }
- if s != nil {
- cookie.Value = s.token
- } else {
- cookie.Expires = aLongTimeAgo // unset the cookie
- }
- ctx.SetCookie(&cookie)
- }
-
- type loginToken struct {
- Username string
- Password string
- }
-
- func (ctx *Context) SetLoginToken(username, password string) {
- cookie := http.Cookie{
- Expires: time.Now().Add(30 * 24 * time.Hour),
- Name: loginTokenCookieName,
- HttpOnly: true,
- Path: "/login",
- }
- if username == "" {
- cookie.Expires = aLongTimeAgo // unset the cookie
- ctx.SetCookie(&cookie)
- return
- }
-
- loginToken := loginToken{username, password}
- payload, err := json.Marshal(loginToken)
- if err != nil {
- panic(err) // Should never happen
- }
- fkey := ctx.Server.Options.LoginKey
- if fkey == nil {
- return
- }
-
- bytes, err := fernet.EncryptAndSign(payload, fkey)
- if err != nil {
- log.Printf("Warning: login token encryption failed: %v", err)
- return
- }
-
- cookie.Value = string(bytes)
- ctx.SetCookie(&cookie)
- }
-
- func (ctx *Context) GetLoginToken() (string, string) {
- cookie, err := ctx.Cookie(loginTokenCookieName)
- if err != nil || cookie == nil {
- return "", ""
- }
-
- fkey := ctx.Server.Options.LoginKey
- if fkey == nil {
- return "", ""
- }
-
- bytes := fernet.VerifyAndDecrypt([]byte(cookie.Value),
- 24*time.Hour*30, []*fernet.Key{fkey})
- if bytes == nil {
- return "", ""
- }
-
- var token loginToken
- err = json.Unmarshal(bytes, &token)
- if err != nil {
- panic(err) // Should never happen
- }
-
- return token.Username, token.Password
- }
-
- func isPublic(path string) bool {
- if strings.HasPrefix(path, "/plugins/") {
- parts := strings.Split(path, "/")
- return len(parts) >= 4 && parts[3] == "assets"
- }
- return path == "/login" || strings.HasPrefix(path, "/themes/")
- }
-
- func redirectToLogin(ctx *Context) error {
- path := ctx.Request().URL.Path
- to := "/login"
- if path != "/" && path != "/login" {
- to += "?next=" + url.QueryEscape(ctx.Request().URL.String())
- }
- return ctx.Redirect(http.StatusFound, to)
- }
-
- func handleUnauthenticated(next echo.HandlerFunc, ctx *Context) error {
- // Require auth for all requests except /login and assets
- if isPublic(ctx.Request().URL.Path) {
- return next(ctx)
- } else {
- return redirectToLogin(ctx)
- }
- }
-
- type Options struct {
- Upstreams []string
- Theme string
- Debug bool
- LoginKey *fernet.Key
- }
-
- // New creates a new server.
- func New(e *echo.Echo, options *Options) (*Server, error) {
- s, err := newServer(e, options)
- if err != nil {
- return nil, err
- }
-
- if err := s.load(); err != nil {
- return nil, err
- }
-
- e.HTTPErrorHandler = func(err error, c echo.Context) {
- code := http.StatusInternalServerError
- if he, ok := err.(*echo.HTTPError); ok {
- code = he.Code
- } else {
- c.Logger().Error(err)
- }
- // TODO: hide internal errors
- c.String(code, err.Error())
- }
-
- e.Pre(func(next echo.HandlerFunc) echo.HandlerFunc {
- return func(ectx echo.Context) error {
- s.mutex.RLock()
- err := next(ectx)
- s.mutex.RUnlock()
- return err
- }
- })
-
- e.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
- return func(ectx echo.Context) error {
- // `style-src 'unsafe-inline'` is required for e-mails with
- // embedded stylesheets
- ectx.Response().Header().Set("Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline'")
- // DNS prefetching has privacy implications
- ectx.Response().Header().Set("X-DNS-Prefetch-Control", "off")
- return next(ectx)
- }
- })
-
- e.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
- return func(ectx echo.Context) error {
- ctx := &Context{Context: ectx, Server: s}
- ctx.Set("context", ctx)
-
- cookie, err := ctx.Cookie(cookieName)
- if err == http.ErrNoCookie {
- return handleUnauthenticated(next, ctx)
- } else if err != nil {
- return err
- }
-
- ctx.Session, err = ctx.Server.Sessions.get(cookie.Value)
- if err == errSessionExpired {
- ctx.SetSession(nil)
- return handleUnauthenticated(next, ctx)
- } else if err != nil {
- return err
- }
- ctx.Session.ping()
-
- return next(ctx)
- }
- })
-
- e.Static("/themes", "themes")
-
- return s, nil
- }
|