aboutsummaryrefslogblamecommitdiff
path: root/server.go
blob: 526db9fe9ea2e3b19604faf31cc71e14ab3277f1 (plain) (tree)
1
2
3
4
5
6
7
8
9


               
             
                  
                 
                 
              
              
 
                                     

 

                                    
                                             
                    
                           
                                
 


                                                         
 


                                                                            
                     

                               
                             
         




                               
                           

 














































                                                                                                    
                                                                

















                                                                                                          
                       
                                                                                  

         






                                                                                    



                                                         
                     



                                      
                                    

         
                                                                   


                  





                                                                                  

         







                                                                                    



                                                         
                     



                                      
                                    

         
                                                                   


                  





















                                                                                   






                                                                                            
                           
                                 









                                                    
                                 

                                             

 





                                                      
                    
                       
                                                       



                                          

                                                                           
                                            
                              
                                     


                                       


                                      

                                                                 
                              

 
                                 




                                                                      

 

















                                                                            
                     

                          
 
 
                            
                                                           
                                       
                       
                               

         

                                        

         










                                                              








                                                            

                                                            


                                                                                                                                       





                                                            
                                                                 
                                               


                                                             
                                                                       



                                              
                                                                                
                                                     
                                                   
                                                                       


                                              
                                          




                                        
                                     
 
                     
 
package koushin

import (
	"fmt"
	"net/http"
	"net/url"
	"strings"
	"sync"
	"time"

	"github.com/labstack/echo/v4"
)

const cookieName = "koushin_session"

// Server holds all the koushin server state.
type Server struct {
	e        *echo.Echo
	Sessions *SessionManager

	mutex      sync.RWMutex // used for server reload
	plugins    []Plugin
	luaPlugins []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
	}
	defaultTheme string
}

func newServer(e *echo.Echo, options *Options) (*Server, error) {
	s := &Server{e: e, defaultTheme: options.Theme}

	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)

	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)
		}
	}

	s.imap.host = u.Host
	switch u.Scheme {
	case "imap":
		// This space is intentionally left blank
	case "imaps":
		s.imap.tls = true
	case "imap+insecure":
		s.imap.insecure = true
	default:
		panic("unreachable")
	}

	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
		}
	}

	s.smtp.host = u.Host
	switch u.Scheme {
	case "smtp":
		// This space is intentionally left blank
	case "smtps":
		s.smtp.tls = true
	case "smtp+insecure":
		s.smtp.insecure = true
	default:
		panic("unreachable")
	}

	s.e.Logger.Printf("Configured upstream SMTP server: %v", u)
	return nil
}

func (s *Server) load() error {
	plugins := append([]Plugin(nil), plugins...)
	for _, p := range plugins {
		s.e.Logger.Printf("Registered plugin '%v'", p.Name())
	}

	luaPlugins, err := loadAllLuaPlugins(s.e.Logger)
	if err != nil {
		return fmt.Errorf("failed to load plugins: %v", err)
	}
	plugins = append(plugins, luaPlugins...)

	renderer := newRenderer(s.e.Logger, s.defaultTheme)
	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 Lua plugins
	for _, p := range s.luaPlugins {
		if err := p.Close(); err != nil {
			s.e.Logger.Printf("Failed to unload plugin '%v': %v", p.Name(), err)
		}
	}

	s.plugins = plugins
	s.luaPlugins = luaPlugins
	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()
}

// Context is the context used by HTTP handlers.
//
// Use a type assertion to get it from a echo.Context:
//
//     ctx := ectx.(*koushin.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)
}

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
}

// 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'")
			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
}