c5afd1a61b
The session manager has been upgraded to deal with reconnections. Each session has its own expiration timer. Each time a request is received, the expiration timer is reset. A session can be closed (this is used when the user wants to logout). When the IMAP connection is closed by the server, it's set to nil in the session. The next time an IMAP command needs to be issued, the connection is re-established. Closes: https://todo.sr.ht/~sircmpwn/koushin/30
209 lines
4.1 KiB
Go
209 lines
4.1 KiB
Go
package koushin
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/labstack/echo/v4"
|
|
)
|
|
|
|
const cookieName = "koushin_session"
|
|
|
|
const messagesPerPage = 50
|
|
|
|
type Server struct {
|
|
sessions *SessionManager
|
|
|
|
imap struct {
|
|
host string
|
|
tls bool
|
|
insecure bool
|
|
}
|
|
|
|
smtp struct {
|
|
host string
|
|
tls bool
|
|
insecure bool
|
|
}
|
|
|
|
plugins []Plugin
|
|
}
|
|
|
|
func (s *Server) parseIMAPURL(imapURL string) error {
|
|
u, err := url.Parse(imapURL)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to parse IMAP server URL: %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:
|
|
return fmt.Errorf("unrecognized IMAP URL scheme: %s", u.Scheme)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) parseSMTPURL(smtpURL string) error {
|
|
u, err := url.Parse(smtpURL)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to parse SMTP server URL: %v", err)
|
|
}
|
|
|
|
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:
|
|
return fmt.Errorf("unrecognized SMTP URL scheme: %s", u.Scheme)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func newServer(imapURL, smtpURL string) (*Server, error) {
|
|
s := &Server{}
|
|
s.sessions = NewSessionManager(s.connectIMAP)
|
|
|
|
if err := s.parseIMAPURL(imapURL); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if smtpURL != "" {
|
|
if err := s.parseSMTPURL(smtpURL); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
return s, nil
|
|
}
|
|
|
|
type context struct {
|
|
echo.Context
|
|
server *Server
|
|
session *Session
|
|
}
|
|
|
|
var aLongTimeAgo = time.Unix(233431200, 0)
|
|
|
|
func (c *context) setToken(token string) {
|
|
cookie := http.Cookie{
|
|
Name: cookieName,
|
|
Value: token,
|
|
HttpOnly: true,
|
|
// TODO: domain, secure
|
|
}
|
|
if token == "" {
|
|
cookie.Expires = aLongTimeAgo // unset the cookie
|
|
}
|
|
c.SetCookie(&cookie)
|
|
}
|
|
|
|
func isPublic(path string) bool {
|
|
return path == "/login" || strings.HasPrefix(path, "/assets/") ||
|
|
strings.HasPrefix(path, "/themes/")
|
|
}
|
|
|
|
type Options struct {
|
|
IMAPURL, SMTPURL string
|
|
Theme string
|
|
}
|
|
|
|
func New(e *echo.Echo, options *Options) error {
|
|
s, err := newServer(options.IMAPURL, options.SMTPURL)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
s.plugins, err = loadAllLuaPlugins(e.Logger)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to load plugins: %v", err)
|
|
}
|
|
|
|
e.Renderer, err = loadTemplates(e.Logger, options.Theme, s.plugins)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to load templates: %v", 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.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 {
|
|
// Require auth for all pages except /login
|
|
if isPublic(ctx.Path()) {
|
|
return next(ctx)
|
|
} else {
|
|
return ctx.Redirect(http.StatusFound, "/login")
|
|
}
|
|
} else if err != nil {
|
|
return err
|
|
}
|
|
|
|
ctx.session, err = ctx.server.sessions.Get(cookie.Value)
|
|
if err == ErrSessionExpired {
|
|
ctx.setToken("")
|
|
return ctx.Redirect(http.StatusFound, "/login")
|
|
} else if err != nil {
|
|
return err
|
|
}
|
|
ctx.session.Ping()
|
|
|
|
return next(ctx)
|
|
}
|
|
})
|
|
|
|
e.GET("/mailbox/:mbox", handleGetMailbox)
|
|
|
|
e.GET("/message/:mbox/:uid", func(ectx echo.Context) error {
|
|
ctx := ectx.(*context)
|
|
return handleGetPart(ctx, false)
|
|
})
|
|
e.GET("/message/:mbox/:uid/raw", func(ectx echo.Context) error {
|
|
ctx := ectx.(*context)
|
|
return handleGetPart(ctx, true)
|
|
})
|
|
|
|
e.GET("/login", handleLogin)
|
|
e.POST("/login", handleLogin)
|
|
|
|
e.GET("/logout", handleLogout)
|
|
|
|
e.GET("/compose", handleCompose)
|
|
e.POST("/compose", handleCompose)
|
|
|
|
e.GET("/message/:mbox/:uid/reply", handleCompose)
|
|
e.POST("/message/:mbox/:uid/reply", handleCompose)
|
|
|
|
e.Static("/assets", "public/assets")
|
|
e.Static("/themes", "public/themes")
|
|
|
|
return nil
|
|
}
|