2626 lines
34 KiB
Go
2626 lines
34 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"crypto/subtle"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"html/template"
|
|
"mime"
|
|
"mime/quotedprintable"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/kardianos/service"
|
|
)
|
|
|
|
const sessionCookieName = "ric930_session"
|
|
|
|
var errInvalidEmailID = errors.New(
|
|
"invalid email id",
|
|
)
|
|
|
|
type EmailFile struct {
|
|
Name string `json:"name"`
|
|
ID string `json:"id"`
|
|
Size int64 `json:"size"`
|
|
ModTime time.Time `json:"mod_time"`
|
|
From string `json:"from"`
|
|
To string `json:"to"`
|
|
Subject string `json:"subject"`
|
|
Date time.Time `json:"date"`
|
|
}
|
|
|
|
type DayEmails struct {
|
|
Date string `json:"date"`
|
|
Emails []EmailFile `json:"emails"`
|
|
}
|
|
|
|
type cachedStaticFile struct {
|
|
Data []byte
|
|
ContentType string
|
|
}
|
|
|
|
type webSession struct {
|
|
LastActivity time.Time
|
|
CSRFToken string
|
|
}
|
|
|
|
type sessionStore struct {
|
|
mu sync.Mutex
|
|
sessions map[string]*webSession
|
|
timeout time.Duration
|
|
}
|
|
|
|
type loginAttempt struct {
|
|
Failures int
|
|
WindowStart time.Time
|
|
BlockedUntil time.Time
|
|
LastSeen time.Time
|
|
}
|
|
|
|
type loginRateLimiter struct {
|
|
mu sync.Mutex
|
|
|
|
attempts map[string]*loginAttempt
|
|
|
|
maxAttempts int
|
|
window time.Duration
|
|
lockout time.Duration
|
|
}
|
|
|
|
type WebServer struct {
|
|
config *Config
|
|
smtpServer *SMTPServer
|
|
logger service.Logger
|
|
server *http.Server
|
|
|
|
template *template.Template
|
|
templateErr error
|
|
|
|
loginTemplate *template.Template
|
|
loginTemplateErr error
|
|
|
|
staticFiles map[string]cachedStaticFile
|
|
|
|
sessions *sessionStore
|
|
loginLimiter *loginRateLimiter
|
|
}
|
|
|
|
func newSessionStore(
|
|
timeout time.Duration,
|
|
) *sessionStore {
|
|
return &sessionStore{
|
|
sessions: make(
|
|
map[string]*webSession,
|
|
),
|
|
timeout: timeout,
|
|
}
|
|
}
|
|
|
|
func generateSecureToken() (
|
|
string,
|
|
error,
|
|
) {
|
|
data := make([]byte, 32)
|
|
|
|
if _, err := rand.Read(data); err != nil {
|
|
return "", fmt.Errorf(
|
|
"failed to generate secure token: %w",
|
|
err,
|
|
)
|
|
}
|
|
|
|
return base64.RawURLEncoding.
|
|
EncodeToString(data), nil
|
|
}
|
|
|
|
func (s *sessionStore) create() (
|
|
string,
|
|
error,
|
|
) {
|
|
sessionID, err := generateSecureToken()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
csrfToken, err := generateSecureToken()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
now := time.Now()
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
for id, session := range s.sessions {
|
|
if now.Sub(session.LastActivity) >
|
|
s.timeout {
|
|
|
|
delete(
|
|
s.sessions,
|
|
id,
|
|
)
|
|
}
|
|
}
|
|
|
|
s.sessions[sessionID] = &webSession{
|
|
LastActivity: now,
|
|
CSRFToken: csrfToken,
|
|
}
|
|
|
|
return sessionID, nil
|
|
}
|
|
|
|
func (s *sessionStore) validate(
|
|
sessionID string,
|
|
) bool {
|
|
if sessionID == "" {
|
|
return false
|
|
}
|
|
|
|
now := time.Now()
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
session, ok := s.sessions[sessionID]
|
|
if !ok {
|
|
return false
|
|
}
|
|
|
|
if now.Sub(session.LastActivity) >
|
|
s.timeout {
|
|
|
|
delete(
|
|
s.sessions,
|
|
sessionID,
|
|
)
|
|
|
|
return false
|
|
}
|
|
|
|
// Sliding inactivity timeout.
|
|
session.LastActivity = now
|
|
|
|
return true
|
|
}
|
|
|
|
func (s *sessionStore) csrfToken(
|
|
sessionID string,
|
|
) (string, bool) {
|
|
if sessionID == "" {
|
|
return "", false
|
|
}
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
session, ok := s.sessions[sessionID]
|
|
if !ok {
|
|
return "", false
|
|
}
|
|
|
|
return session.CSRFToken, true
|
|
}
|
|
|
|
func (s *sessionStore) validateCSRF(
|
|
sessionID string,
|
|
token string,
|
|
) bool {
|
|
if sessionID == "" ||
|
|
token == "" {
|
|
|
|
return false
|
|
}
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
session, ok := s.sessions[sessionID]
|
|
if !ok {
|
|
return false
|
|
}
|
|
|
|
return constantTimeStringEqual(
|
|
session.CSRFToken,
|
|
token,
|
|
)
|
|
}
|
|
|
|
func (s *sessionStore) delete(
|
|
sessionID string,
|
|
) {
|
|
if sessionID == "" {
|
|
return
|
|
}
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
delete(
|
|
s.sessions,
|
|
sessionID,
|
|
)
|
|
}
|
|
|
|
func newLoginRateLimiter(
|
|
maxAttempts int,
|
|
window time.Duration,
|
|
lockout time.Duration,
|
|
) *loginRateLimiter {
|
|
return &loginRateLimiter{
|
|
attempts: make(
|
|
map[string]*loginAttempt,
|
|
),
|
|
maxAttempts: maxAttempts,
|
|
window: window,
|
|
lockout: lockout,
|
|
}
|
|
}
|
|
|
|
func (l *loginRateLimiter) allow(
|
|
key string,
|
|
) (bool, time.Duration) {
|
|
now := time.Now()
|
|
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
|
|
for id, attempt := range l.attempts {
|
|
if attempt.BlockedUntil.After(now) {
|
|
continue
|
|
}
|
|
|
|
if now.Sub(attempt.LastSeen) >
|
|
l.window+l.lockout {
|
|
|
|
delete(
|
|
l.attempts,
|
|
id,
|
|
)
|
|
}
|
|
}
|
|
|
|
attempt, ok := l.attempts[key]
|
|
if !ok {
|
|
return true, 0
|
|
}
|
|
|
|
attempt.LastSeen = now
|
|
|
|
if attempt.BlockedUntil.After(now) {
|
|
return false,
|
|
time.Until(
|
|
attempt.BlockedUntil,
|
|
)
|
|
}
|
|
|
|
if now.Sub(attempt.WindowStart) >
|
|
l.window {
|
|
|
|
attempt.Failures = 0
|
|
attempt.WindowStart = now
|
|
attempt.BlockedUntil = time.Time{}
|
|
}
|
|
|
|
return true, 0
|
|
}
|
|
|
|
func (l *loginRateLimiter) failure(
|
|
key string,
|
|
) {
|
|
now := time.Now()
|
|
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
|
|
attempt, ok := l.attempts[key]
|
|
if !ok {
|
|
attempt = &loginAttempt{
|
|
WindowStart: now,
|
|
}
|
|
|
|
l.attempts[key] = attempt
|
|
}
|
|
|
|
if now.Sub(attempt.WindowStart) >
|
|
l.window {
|
|
|
|
attempt.Failures = 0
|
|
attempt.WindowStart = now
|
|
attempt.BlockedUntil = time.Time{}
|
|
}
|
|
|
|
attempt.Failures++
|
|
attempt.LastSeen = now
|
|
|
|
if attempt.Failures >=
|
|
l.maxAttempts {
|
|
|
|
attempt.BlockedUntil =
|
|
now.Add(l.lockout)
|
|
}
|
|
}
|
|
|
|
func (l *loginRateLimiter) success(
|
|
key string,
|
|
) {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
|
|
delete(
|
|
l.attempts,
|
|
key,
|
|
)
|
|
}
|
|
|
|
func remoteIP(
|
|
remoteAddr string,
|
|
) string {
|
|
host, _, err := net.SplitHostPort(
|
|
remoteAddr,
|
|
)
|
|
|
|
if err == nil {
|
|
return host
|
|
}
|
|
|
|
return remoteAddr
|
|
}
|
|
|
|
func NewWebServer(
|
|
cfg *Config,
|
|
smtp *SMTPServer,
|
|
) *WebServer {
|
|
sessionTimeout :=
|
|
30 * time.Minute
|
|
|
|
if cfg.Web.Auth.SessionTimeout != "" {
|
|
if parsed, err :=
|
|
time.ParseDuration(
|
|
cfg.Web.Auth.SessionTimeout,
|
|
); err == nil &&
|
|
parsed > 0 {
|
|
|
|
sessionTimeout = parsed
|
|
}
|
|
}
|
|
|
|
loginWindow :=
|
|
5 * time.Minute
|
|
|
|
if cfg.Web.Auth.LoginWindow != "" {
|
|
if parsed, err :=
|
|
time.ParseDuration(
|
|
cfg.Web.Auth.LoginWindow,
|
|
); err == nil &&
|
|
parsed > 0 {
|
|
|
|
loginWindow = parsed
|
|
}
|
|
}
|
|
|
|
loginLockout :=
|
|
15 * time.Minute
|
|
|
|
if cfg.Web.Auth.LoginLockout != "" {
|
|
if parsed, err :=
|
|
time.ParseDuration(
|
|
cfg.Web.Auth.LoginLockout,
|
|
); err == nil &&
|
|
parsed > 0 {
|
|
|
|
loginLockout = parsed
|
|
}
|
|
}
|
|
|
|
maxAttempts :=
|
|
cfg.Web.Auth.LoginMaxAttempts
|
|
|
|
if maxAttempts <= 0 {
|
|
maxAttempts = 5
|
|
}
|
|
|
|
return &WebServer{
|
|
config: cfg,
|
|
smtpServer: smtp,
|
|
|
|
staticFiles: make(
|
|
map[string]cachedStaticFile,
|
|
),
|
|
|
|
sessions: newSessionStore(
|
|
sessionTimeout,
|
|
),
|
|
|
|
loginLimiter: newLoginRateLimiter(
|
|
maxAttempts,
|
|
loginWindow,
|
|
loginLockout,
|
|
),
|
|
}
|
|
}
|
|
|
|
func decodeRFC2047(
|
|
encoded string,
|
|
) string {
|
|
decoder := new(
|
|
mime.WordDecoder,
|
|
)
|
|
|
|
decoded, err :=
|
|
decoder.Decode(encoded)
|
|
|
|
if err == nil {
|
|
return decoded
|
|
}
|
|
|
|
if strings.Contains(
|
|
encoded,
|
|
"=?UTF-8?B?",
|
|
) {
|
|
parts := strings.Split(
|
|
encoded,
|
|
"?B?",
|
|
)
|
|
|
|
if len(parts) >= 2 {
|
|
b64part := strings.TrimSuffix(
|
|
parts[1],
|
|
"?=",
|
|
)
|
|
|
|
if decodedBytes, err :=
|
|
base64.StdEncoding.
|
|
DecodeString(
|
|
b64part,
|
|
); err == nil {
|
|
|
|
return string(
|
|
decodedBytes,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
return encoded
|
|
}
|
|
|
|
func decodeQuotedPrintable(
|
|
data string,
|
|
) string {
|
|
reader :=
|
|
quotedprintable.NewReader(
|
|
strings.NewReader(data),
|
|
)
|
|
|
|
buf := new(bytes.Buffer)
|
|
|
|
_, _ = buf.ReadFrom(reader)
|
|
|
|
return buf.String()
|
|
}
|
|
|
|
func parseEmailHeaders(
|
|
content []byte,
|
|
) (
|
|
from string,
|
|
to string,
|
|
subject string,
|
|
date time.Time,
|
|
) {
|
|
from = "Unknown"
|
|
to = "Unknown"
|
|
subject = "No subject"
|
|
date = time.Now()
|
|
|
|
lines := strings.Split(
|
|
string(content),
|
|
"\n",
|
|
)
|
|
|
|
for _, line := range lines {
|
|
line = strings.TrimSpace(
|
|
line,
|
|
)
|
|
|
|
lowerLine :=
|
|
strings.ToLower(line)
|
|
|
|
switch {
|
|
case strings.HasPrefix(
|
|
lowerLine,
|
|
"from:",
|
|
):
|
|
raw := strings.TrimSpace(
|
|
line[5:],
|
|
)
|
|
|
|
raw = strings.Trim(
|
|
raw,
|
|
"\"<> ",
|
|
)
|
|
|
|
from = decodeRFC2047(raw)
|
|
|
|
if from == "" {
|
|
from = "Unknown"
|
|
}
|
|
|
|
case strings.HasPrefix(
|
|
lowerLine,
|
|
"to:",
|
|
):
|
|
raw := strings.TrimSpace(
|
|
line[3:],
|
|
)
|
|
|
|
raw = strings.Trim(
|
|
raw,
|
|
"\"<> ",
|
|
)
|
|
|
|
to = decodeRFC2047(raw)
|
|
|
|
if to == "" {
|
|
to = "Unknown"
|
|
}
|
|
|
|
case strings.HasPrefix(
|
|
lowerLine,
|
|
"subject:",
|
|
):
|
|
raw := strings.TrimSpace(
|
|
line[8:],
|
|
)
|
|
|
|
subject =
|
|
decodeRFC2047(raw)
|
|
|
|
if subject == "" {
|
|
subject = "No subject"
|
|
}
|
|
|
|
case strings.HasPrefix(
|
|
lowerLine,
|
|
"date:",
|
|
):
|
|
dateStr :=
|
|
strings.TrimSpace(
|
|
line[5:],
|
|
)
|
|
|
|
formats := []string{
|
|
time.RFC1123,
|
|
time.RFC1123Z,
|
|
time.RFC822,
|
|
time.RFC822Z,
|
|
|
|
"Mon, 2 Jan 2006 15:04:05 -0700",
|
|
"Mon, 2 Jan 2006 15:04:05 MST",
|
|
"2 Jan 2006 15:04:05 -0700",
|
|
}
|
|
|
|
for _, format := range formats {
|
|
|
|
parsedDate, err :=
|
|
time.Parse(
|
|
format,
|
|
dateStr,
|
|
)
|
|
|
|
if err == nil {
|
|
date = parsedDate
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
func extractMIMEPartBody(
|
|
part string,
|
|
) string {
|
|
if idx := strings.Index(
|
|
part,
|
|
"\r\n\r\n",
|
|
); idx != -1 {
|
|
|
|
return strings.TrimSuffix(
|
|
part[idx+4:],
|
|
"--",
|
|
)
|
|
}
|
|
|
|
if idx := strings.Index(
|
|
part,
|
|
"\n\n",
|
|
); idx != -1 {
|
|
|
|
return strings.TrimSuffix(
|
|
part[idx+2:],
|
|
"--",
|
|
)
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
func extractEmailBody(
|
|
content []byte,
|
|
) string {
|
|
str := string(content)
|
|
boundary := ""
|
|
|
|
lines := strings.Split(
|
|
str,
|
|
"\n",
|
|
)
|
|
|
|
for i, line := range lines {
|
|
lowerLine :=
|
|
strings.ToLower(line)
|
|
|
|
if !strings.HasPrefix(
|
|
lowerLine,
|
|
"content-type:",
|
|
) {
|
|
continue
|
|
}
|
|
|
|
if idx := strings.Index(
|
|
line,
|
|
"boundary=",
|
|
); idx != -1 {
|
|
|
|
boundary = strings.Trim(
|
|
line[idx+9:],
|
|
"\" \r\n",
|
|
)
|
|
|
|
break
|
|
}
|
|
|
|
if i+1 < len(lines) &&
|
|
strings.Contains(
|
|
strings.ToLower(
|
|
lines[i+1],
|
|
),
|
|
"boundary=",
|
|
) {
|
|
|
|
nextLine := lines[i+1]
|
|
|
|
if idx := strings.Index(
|
|
nextLine,
|
|
"boundary=",
|
|
); idx != -1 {
|
|
|
|
boundary =
|
|
strings.Trim(
|
|
nextLine[idx+9:],
|
|
"\" \r\n",
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
if boundary != "" {
|
|
parts := strings.Split(
|
|
str,
|
|
"--"+boundary,
|
|
)
|
|
|
|
// Сначала text/plain.
|
|
for _, part := range parts {
|
|
if !strings.Contains(
|
|
strings.ToLower(part),
|
|
"content-type: text/plain",
|
|
) {
|
|
continue
|
|
}
|
|
|
|
body :=
|
|
extractMIMEPartBody(
|
|
part,
|
|
)
|
|
|
|
if body == "" {
|
|
continue
|
|
}
|
|
|
|
if strings.Contains(
|
|
strings.ToLower(part),
|
|
"content-transfer-encoding: quoted-printable",
|
|
) {
|
|
body =
|
|
decodeQuotedPrintable(
|
|
body,
|
|
)
|
|
}
|
|
|
|
return strings.TrimSpace(
|
|
body,
|
|
)
|
|
}
|
|
|
|
// Затем text/html.
|
|
for _, part := range parts {
|
|
if !strings.Contains(
|
|
strings.ToLower(part),
|
|
"content-type: text/html",
|
|
) {
|
|
continue
|
|
}
|
|
|
|
body :=
|
|
extractMIMEPartBody(
|
|
part,
|
|
)
|
|
|
|
if body == "" {
|
|
continue
|
|
}
|
|
|
|
if strings.Contains(
|
|
strings.ToLower(part),
|
|
"content-transfer-encoding: quoted-printable",
|
|
) {
|
|
body =
|
|
decodeQuotedPrintable(
|
|
body,
|
|
)
|
|
}
|
|
|
|
return strings.TrimSpace(
|
|
body,
|
|
)
|
|
}
|
|
}
|
|
|
|
body :=
|
|
extractMIMEPartBody(str)
|
|
|
|
if body != "" {
|
|
if strings.Contains(
|
|
strings.ToLower(str),
|
|
"content-transfer-encoding: quoted-printable",
|
|
) {
|
|
body =
|
|
decodeQuotedPrintable(
|
|
body,
|
|
)
|
|
}
|
|
|
|
return strings.TrimSpace(
|
|
body,
|
|
)
|
|
}
|
|
|
|
return "No content"
|
|
}
|
|
|
|
func listEmails(
|
|
storageDir string,
|
|
) ([]DayEmails, error) {
|
|
days := make([]DayEmails, 0)
|
|
|
|
entries, err :=
|
|
os.ReadDir(storageDir)
|
|
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return days, nil
|
|
}
|
|
|
|
return nil, err
|
|
}
|
|
|
|
for _, entry := range entries {
|
|
if !entry.IsDir() {
|
|
continue
|
|
}
|
|
|
|
if _, err := time.Parse(
|
|
"2006-01-02",
|
|
entry.Name(),
|
|
); err != nil {
|
|
continue
|
|
}
|
|
|
|
dayPath := filepath.Join(
|
|
storageDir,
|
|
entry.Name(),
|
|
)
|
|
|
|
files, err :=
|
|
os.ReadDir(dayPath)
|
|
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
emails :=
|
|
make([]EmailFile, 0)
|
|
|
|
for _, file := range files {
|
|
if file.IsDir() {
|
|
continue
|
|
}
|
|
|
|
if file.Type()&
|
|
os.ModeSymlink != 0 {
|
|
|
|
continue
|
|
}
|
|
|
|
if !strings.EqualFold(
|
|
filepath.Ext(
|
|
file.Name(),
|
|
),
|
|
".eml",
|
|
) {
|
|
continue
|
|
}
|
|
|
|
filePath :=
|
|
filepath.Join(
|
|
dayPath,
|
|
file.Name(),
|
|
)
|
|
|
|
info, err :=
|
|
file.Info()
|
|
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
content, err :=
|
|
os.ReadFile(
|
|
filePath,
|
|
)
|
|
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
from,
|
|
to,
|
|
subject,
|
|
date :=
|
|
parseEmailHeaders(
|
|
content,
|
|
)
|
|
|
|
emailID :=
|
|
filepath.ToSlash(
|
|
filepath.Join(
|
|
entry.Name(),
|
|
file.Name(),
|
|
),
|
|
)
|
|
|
|
emails = append(
|
|
emails,
|
|
EmailFile{
|
|
Name: file.Name(),
|
|
ID: emailID,
|
|
|
|
Size: info.Size(),
|
|
|
|
ModTime: info.ModTime(),
|
|
|
|
From: from,
|
|
To: to,
|
|
Subject: subject,
|
|
Date: date,
|
|
},
|
|
)
|
|
}
|
|
|
|
sort.Slice(
|
|
emails,
|
|
func(i, j int) bool {
|
|
return emails[i].
|
|
ModTime.
|
|
After(
|
|
emails[j].
|
|
ModTime,
|
|
)
|
|
},
|
|
)
|
|
|
|
if len(emails) > 0 {
|
|
days = append(
|
|
days,
|
|
DayEmails{
|
|
Date: entry.Name(),
|
|
|
|
Emails: emails,
|
|
},
|
|
)
|
|
}
|
|
}
|
|
|
|
sort.Slice(
|
|
days,
|
|
func(i, j int) bool {
|
|
return days[i].Date >
|
|
days[j].Date
|
|
},
|
|
)
|
|
|
|
return days, nil
|
|
}
|
|
|
|
func resolveEmailPath(
|
|
storageDir string,
|
|
id string,
|
|
) (string, error) {
|
|
id = strings.TrimSpace(id)
|
|
|
|
if id == "" {
|
|
return "", fmt.Errorf(
|
|
"%w: empty id",
|
|
errInvalidEmailID,
|
|
)
|
|
}
|
|
|
|
if strings.Contains(
|
|
id,
|
|
"\\",
|
|
) {
|
|
return "", fmt.Errorf(
|
|
"%w: backslash is not allowed",
|
|
errInvalidEmailID,
|
|
)
|
|
}
|
|
|
|
parts := strings.Split(
|
|
id,
|
|
"/",
|
|
)
|
|
|
|
if len(parts) != 2 {
|
|
return "", fmt.Errorf(
|
|
"%w: invalid path structure",
|
|
errInvalidEmailID,
|
|
)
|
|
}
|
|
|
|
day := parts[0]
|
|
filename := parts[1]
|
|
|
|
if _, err := time.Parse(
|
|
"2006-01-02",
|
|
day,
|
|
); err != nil {
|
|
return "", fmt.Errorf(
|
|
"%w: invalid date directory",
|
|
errInvalidEmailID,
|
|
)
|
|
}
|
|
|
|
if filename == "" ||
|
|
filename == "." ||
|
|
filename == ".." ||
|
|
filepath.Base(filename) !=
|
|
filename {
|
|
|
|
return "", fmt.Errorf(
|
|
"%w: invalid filename",
|
|
errInvalidEmailID,
|
|
)
|
|
}
|
|
|
|
if !strings.EqualFold(
|
|
filepath.Ext(filename),
|
|
".eml",
|
|
) {
|
|
return "", fmt.Errorf(
|
|
"%w: only .eml is allowed",
|
|
errInvalidEmailID,
|
|
)
|
|
}
|
|
|
|
storageAbs, err :=
|
|
filepath.Abs(storageDir)
|
|
|
|
if err != nil {
|
|
return "", fmt.Errorf(
|
|
"cannot resolve storage directory: %w",
|
|
err,
|
|
)
|
|
}
|
|
|
|
storageResolved, err :=
|
|
filepath.EvalSymlinks(
|
|
storageAbs,
|
|
)
|
|
|
|
if err != nil {
|
|
return "", fmt.Errorf(
|
|
"cannot resolve storage directory: %w",
|
|
err,
|
|
)
|
|
}
|
|
|
|
candidate := filepath.Join(
|
|
storageAbs,
|
|
day,
|
|
filename,
|
|
)
|
|
|
|
candidateAbs, err :=
|
|
filepath.Abs(candidate)
|
|
|
|
if err != nil {
|
|
return "", fmt.Errorf(
|
|
"cannot resolve email path: %w",
|
|
err,
|
|
)
|
|
}
|
|
|
|
candidateResolved, err :=
|
|
filepath.EvalSymlinks(
|
|
candidateAbs,
|
|
)
|
|
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return "", os.ErrNotExist
|
|
}
|
|
|
|
return "", fmt.Errorf(
|
|
"cannot resolve email file: %w",
|
|
err,
|
|
)
|
|
}
|
|
|
|
rel, err := filepath.Rel(
|
|
storageResolved,
|
|
candidateResolved,
|
|
)
|
|
|
|
if err != nil {
|
|
return "", fmt.Errorf(
|
|
"cannot validate email path: %w",
|
|
err,
|
|
)
|
|
}
|
|
|
|
if rel == ".." ||
|
|
strings.HasPrefix(
|
|
rel,
|
|
".."+
|
|
string(
|
|
os.PathSeparator,
|
|
),
|
|
) ||
|
|
filepath.IsAbs(rel) {
|
|
|
|
return "", fmt.Errorf(
|
|
"%w: path escapes storage directory",
|
|
errInvalidEmailID,
|
|
)
|
|
}
|
|
|
|
info, err :=
|
|
os.Stat(
|
|
candidateResolved,
|
|
)
|
|
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return "", os.ErrNotExist
|
|
}
|
|
|
|
return "", fmt.Errorf(
|
|
"cannot access email: %w",
|
|
err,
|
|
)
|
|
}
|
|
|
|
if info.IsDir() {
|
|
return "", fmt.Errorf(
|
|
"%w: email points to directory",
|
|
errInvalidEmailID,
|
|
)
|
|
}
|
|
|
|
return candidateResolved, nil
|
|
}
|
|
|
|
func (w *WebServer) loadWebAssets() {
|
|
w.staticFiles =
|
|
make(
|
|
map[string]cachedStaticFile,
|
|
)
|
|
|
|
webDir :=
|
|
w.config.Web.Directory
|
|
|
|
// index.html
|
|
templatePath :=
|
|
filepath.Join(
|
|
webDir,
|
|
"index.html",
|
|
)
|
|
|
|
templateData, err :=
|
|
os.ReadFile(
|
|
templatePath,
|
|
)
|
|
|
|
if err != nil {
|
|
w.template = nil
|
|
|
|
w.templateErr =
|
|
fmt.Errorf(
|
|
"web template not found: %s: %w",
|
|
templatePath,
|
|
err,
|
|
)
|
|
|
|
w.logger.Errorf(
|
|
"%v",
|
|
w.templateErr,
|
|
)
|
|
} else {
|
|
tmpl, err :=
|
|
template.New(
|
|
"index.html",
|
|
).Parse(
|
|
string(
|
|
templateData,
|
|
),
|
|
)
|
|
|
|
if err != nil {
|
|
w.template = nil
|
|
|
|
w.templateErr =
|
|
fmt.Errorf(
|
|
"failed to parse web template %s: %w",
|
|
templatePath,
|
|
err,
|
|
)
|
|
|
|
w.logger.Errorf(
|
|
"%v",
|
|
w.templateErr,
|
|
)
|
|
} else {
|
|
w.template = tmpl
|
|
w.templateErr = nil
|
|
|
|
w.logger.Infof(
|
|
"Web template loaded: %s (%d bytes)",
|
|
templatePath,
|
|
len(templateData),
|
|
)
|
|
}
|
|
}
|
|
|
|
// login.html
|
|
loginTemplatePath :=
|
|
filepath.Join(
|
|
webDir,
|
|
"login.html",
|
|
)
|
|
|
|
loginTemplateData, err :=
|
|
os.ReadFile(
|
|
loginTemplatePath,
|
|
)
|
|
|
|
if err != nil {
|
|
w.loginTemplate = nil
|
|
|
|
w.loginTemplateErr =
|
|
fmt.Errorf(
|
|
"login template not found: %s: %w",
|
|
loginTemplatePath,
|
|
err,
|
|
)
|
|
|
|
w.logger.Errorf(
|
|
"%v",
|
|
w.loginTemplateErr,
|
|
)
|
|
} else {
|
|
tmpl, err :=
|
|
template.New(
|
|
"login.html",
|
|
).Parse(
|
|
string(
|
|
loginTemplateData,
|
|
),
|
|
)
|
|
|
|
if err != nil {
|
|
w.loginTemplate = nil
|
|
|
|
w.loginTemplateErr =
|
|
fmt.Errorf(
|
|
"failed to parse login template %s: %w",
|
|
loginTemplatePath,
|
|
err,
|
|
)
|
|
|
|
w.logger.Errorf(
|
|
"%v",
|
|
w.loginTemplateErr,
|
|
)
|
|
} else {
|
|
w.loginTemplate = tmpl
|
|
|
|
w.loginTemplateErr = nil
|
|
|
|
w.logger.Infof(
|
|
"Login template loaded: %s (%d bytes)",
|
|
loginTemplatePath,
|
|
len(
|
|
loginTemplateData,
|
|
),
|
|
)
|
|
}
|
|
}
|
|
|
|
assets := []struct {
|
|
Name string
|
|
ContentType string
|
|
}{
|
|
{
|
|
Name: "app.css",
|
|
|
|
ContentType: "text/css; charset=utf-8",
|
|
},
|
|
{
|
|
Name: "app.js",
|
|
|
|
ContentType: "application/javascript; charset=utf-8",
|
|
},
|
|
{
|
|
Name: "tailwind.js",
|
|
|
|
ContentType: "application/javascript; charset=utf-8",
|
|
},
|
|
}
|
|
|
|
for _, asset := range assets {
|
|
|
|
filePath :=
|
|
filepath.Join(
|
|
webDir,
|
|
asset.Name,
|
|
)
|
|
|
|
data, err :=
|
|
os.ReadFile(
|
|
filePath,
|
|
)
|
|
|
|
if err != nil {
|
|
w.logger.Warningf(
|
|
"Cannot load web asset %s: %v",
|
|
filePath,
|
|
err,
|
|
)
|
|
|
|
continue
|
|
}
|
|
|
|
urlPath :=
|
|
"/static/" +
|
|
asset.Name
|
|
|
|
w.staticFiles[urlPath] =
|
|
cachedStaticFile{
|
|
Data: data,
|
|
|
|
ContentType: asset.ContentType,
|
|
}
|
|
|
|
w.logger.Infof(
|
|
"Web asset loaded: %s (%d bytes)",
|
|
filePath,
|
|
len(data),
|
|
)
|
|
}
|
|
}
|
|
|
|
func (w *WebServer) handleStatic(
|
|
wr http.ResponseWriter,
|
|
r *http.Request,
|
|
) {
|
|
if r.Method != http.MethodGet &&
|
|
r.Method != http.MethodHead {
|
|
|
|
wr.Header().Set(
|
|
"Allow",
|
|
"GET, HEAD",
|
|
)
|
|
|
|
http.Error(
|
|
wr,
|
|
"Method not allowed",
|
|
http.StatusMethodNotAllowed,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
file, ok := w.staticFiles[r.URL.Path]
|
|
|
|
if !ok {
|
|
http.NotFound(
|
|
wr,
|
|
r,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
wr.Header().Set(
|
|
"Content-Type",
|
|
file.ContentType,
|
|
)
|
|
|
|
if r.Method == http.MethodHead {
|
|
return
|
|
}
|
|
|
|
if _, err := wr.Write(file.Data); err != nil {
|
|
w.logger.Errorf(
|
|
"Failed to send static file %s: %v",
|
|
r.URL.Path,
|
|
err,
|
|
)
|
|
}
|
|
}
|
|
|
|
func constantTimeStringEqual(
|
|
a string,
|
|
b string,
|
|
) bool {
|
|
aHash :=
|
|
sha256.Sum256(
|
|
[]byte(a),
|
|
)
|
|
|
|
bHash :=
|
|
sha256.Sum256(
|
|
[]byte(b),
|
|
)
|
|
|
|
return subtle.
|
|
ConstantTimeCompare(
|
|
aHash[:],
|
|
bHash[:],
|
|
) == 1
|
|
}
|
|
|
|
func (w *WebServer) setSessionCookie(
|
|
wr http.ResponseWriter,
|
|
sessionID string,
|
|
) {
|
|
http.SetCookie(
|
|
wr,
|
|
&http.Cookie{
|
|
Name: sessionCookieName,
|
|
|
|
Value: sessionID,
|
|
|
|
Path: "/",
|
|
|
|
HttpOnly: true,
|
|
|
|
Secure: w.config.Web.Auth.
|
|
CookieSecure,
|
|
|
|
SameSite: http.SameSiteStrictMode,
|
|
},
|
|
)
|
|
}
|
|
|
|
func (w *WebServer) clearSessionCookie(
|
|
wr http.ResponseWriter,
|
|
) {
|
|
http.SetCookie(
|
|
wr,
|
|
&http.Cookie{
|
|
Name: sessionCookieName,
|
|
|
|
Value: "",
|
|
|
|
Path: "/",
|
|
|
|
HttpOnly: true,
|
|
|
|
Secure: w.config.Web.Auth.
|
|
CookieSecure,
|
|
|
|
SameSite: http.SameSiteStrictMode,
|
|
|
|
MaxAge: -1,
|
|
|
|
Expires: time.Unix(1, 0),
|
|
},
|
|
)
|
|
}
|
|
|
|
type loginPageData struct {
|
|
Title string
|
|
Error string
|
|
}
|
|
|
|
func (w *WebServer) renderLogin(
|
|
wr http.ResponseWriter,
|
|
status int,
|
|
errorMessage string,
|
|
) {
|
|
if w.loginTemplate == nil {
|
|
message :=
|
|
"Login template is unavailable"
|
|
|
|
if w.loginTemplateErr != nil {
|
|
message =
|
|
w.loginTemplateErr.
|
|
Error()
|
|
}
|
|
|
|
http.Error(
|
|
wr,
|
|
message,
|
|
http.StatusInternalServerError,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
wr.Header().Set(
|
|
"Content-Type",
|
|
"text/html; charset=utf-8",
|
|
)
|
|
|
|
wr.Header().Set(
|
|
"Cache-Control",
|
|
"no-store",
|
|
)
|
|
|
|
wr.WriteHeader(status)
|
|
|
|
if err :=
|
|
w.loginTemplate.Execute(
|
|
wr,
|
|
loginPageData{
|
|
Title: "Fake SMTP Server - Login",
|
|
|
|
Error: errorMessage,
|
|
},
|
|
); err != nil {
|
|
|
|
w.logger.Errorf(
|
|
"Login template execution error: %v",
|
|
err,
|
|
)
|
|
}
|
|
}
|
|
|
|
func (w *WebServer) handleLogin(
|
|
wr http.ResponseWriter,
|
|
r *http.Request,
|
|
) {
|
|
if !w.config.Web.Auth.Enabled {
|
|
http.Redirect(
|
|
wr,
|
|
r,
|
|
"/",
|
|
http.StatusSeeOther,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
if cookie, err :=
|
|
r.Cookie(
|
|
sessionCookieName,
|
|
); err == nil {
|
|
|
|
if w.sessions.validate(
|
|
cookie.Value,
|
|
) {
|
|
http.Redirect(
|
|
wr,
|
|
r,
|
|
"/",
|
|
http.StatusSeeOther,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
w.clearSessionCookie(
|
|
wr,
|
|
)
|
|
}
|
|
|
|
w.renderLogin(
|
|
wr,
|
|
http.StatusOK,
|
|
"",
|
|
)
|
|
|
|
case http.MethodPost:
|
|
clientIP :=
|
|
remoteIP(
|
|
r.RemoteAddr,
|
|
)
|
|
|
|
allowed, retryAfter :=
|
|
w.loginLimiter.allow(
|
|
clientIP,
|
|
)
|
|
|
|
if !allowed {
|
|
seconds := int64(
|
|
(retryAfter + time.Second - 1) / time.Second,
|
|
)
|
|
|
|
if seconds < 1 {
|
|
seconds = 1
|
|
}
|
|
|
|
wr.Header().Set(
|
|
"Retry-After",
|
|
strconv.FormatInt(
|
|
seconds,
|
|
10,
|
|
),
|
|
)
|
|
|
|
w.logger.Warningf(
|
|
"Web login temporarily blocked for %s",
|
|
clientIP,
|
|
)
|
|
|
|
w.renderLogin(
|
|
wr,
|
|
http.StatusTooManyRequests,
|
|
"Too many failed login attempts. Try again later.",
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
r.Body =
|
|
http.MaxBytesReader(
|
|
wr,
|
|
r.Body,
|
|
16*1024,
|
|
)
|
|
|
|
if err :=
|
|
r.ParseForm(); err != nil {
|
|
|
|
w.renderLogin(
|
|
wr,
|
|
http.StatusBadRequest,
|
|
"Invalid request",
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
username :=
|
|
r.FormValue(
|
|
"username",
|
|
)
|
|
|
|
password :=
|
|
r.FormValue(
|
|
"password",
|
|
)
|
|
|
|
validUsername :=
|
|
constantTimeStringEqual(
|
|
username,
|
|
w.config.Web.Auth.
|
|
Username,
|
|
)
|
|
|
|
validPassword :=
|
|
constantTimeStringEqual(
|
|
password,
|
|
w.config.Web.Auth.
|
|
Password,
|
|
)
|
|
|
|
if !validUsername ||
|
|
!validPassword {
|
|
|
|
w.loginLimiter.failure(
|
|
clientIP,
|
|
)
|
|
|
|
w.logger.Warningf(
|
|
"Failed web login from %s",
|
|
clientIP,
|
|
)
|
|
|
|
w.renderLogin(
|
|
wr,
|
|
http.StatusUnauthorized,
|
|
"Invalid username or password",
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
w.loginLimiter.success(
|
|
clientIP,
|
|
)
|
|
|
|
// Защита от session fixation:
|
|
// старую сессию удаляем.
|
|
if oldCookie, err :=
|
|
r.Cookie(
|
|
sessionCookieName,
|
|
); err == nil {
|
|
|
|
w.sessions.delete(
|
|
oldCookie.Value,
|
|
)
|
|
}
|
|
|
|
sessionID, err :=
|
|
w.sessions.create()
|
|
|
|
if err != nil {
|
|
w.logger.Errorf(
|
|
"Failed to create session: %v",
|
|
err,
|
|
)
|
|
|
|
http.Error(
|
|
wr,
|
|
"Failed to create session",
|
|
http.StatusInternalServerError,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
w.setSessionCookie(
|
|
wr,
|
|
sessionID,
|
|
)
|
|
|
|
w.logger.Infof(
|
|
"Web login successful from %s",
|
|
clientIP,
|
|
)
|
|
|
|
http.Redirect(
|
|
wr,
|
|
r,
|
|
"/",
|
|
http.StatusSeeOther,
|
|
)
|
|
|
|
default:
|
|
wr.Header().Set(
|
|
"Allow",
|
|
"GET, POST",
|
|
)
|
|
|
|
http.Error(
|
|
wr,
|
|
"Method not allowed",
|
|
http.StatusMethodNotAllowed,
|
|
)
|
|
}
|
|
}
|
|
|
|
func (w *WebServer) handleLogout(
|
|
wr http.ResponseWriter,
|
|
r *http.Request,
|
|
) {
|
|
if r.Method !=
|
|
http.MethodPost {
|
|
|
|
wr.Header().Set(
|
|
"Allow",
|
|
http.MethodPost,
|
|
)
|
|
|
|
http.Error(
|
|
wr,
|
|
"Method not allowed",
|
|
http.StatusMethodNotAllowed,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
r.Body =
|
|
http.MaxBytesReader(
|
|
wr,
|
|
r.Body,
|
|
8*1024,
|
|
)
|
|
|
|
if err :=
|
|
r.ParseForm(); err != nil {
|
|
|
|
http.Error(
|
|
wr,
|
|
"Invalid request",
|
|
http.StatusBadRequest,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
cookie, err :=
|
|
r.Cookie(
|
|
sessionCookieName,
|
|
)
|
|
|
|
if err != nil {
|
|
w.clearSessionCookie(
|
|
wr,
|
|
)
|
|
|
|
http.Redirect(
|
|
wr,
|
|
r,
|
|
"/login",
|
|
http.StatusSeeOther,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
csrfToken :=
|
|
r.FormValue(
|
|
"csrf_token",
|
|
)
|
|
|
|
if !w.sessions.validateCSRF(
|
|
cookie.Value,
|
|
csrfToken,
|
|
) {
|
|
w.logger.Warningf(
|
|
"Rejected logout with invalid CSRF token from %s",
|
|
r.RemoteAddr,
|
|
)
|
|
|
|
http.Error(
|
|
wr,
|
|
"Invalid CSRF token",
|
|
http.StatusForbidden,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
w.sessions.delete(
|
|
cookie.Value,
|
|
)
|
|
|
|
w.clearSessionCookie(
|
|
wr,
|
|
)
|
|
|
|
http.Redirect(
|
|
wr,
|
|
r,
|
|
"/login",
|
|
http.StatusSeeOther,
|
|
)
|
|
}
|
|
|
|
func (w *WebServer) sessionAuth(
|
|
next http.Handler,
|
|
) http.Handler {
|
|
return http.HandlerFunc(
|
|
func(
|
|
wr http.ResponseWriter,
|
|
r *http.Request,
|
|
) {
|
|
if !w.config.Web.Auth.Enabled {
|
|
next.ServeHTTP(
|
|
wr,
|
|
r,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
// Login должен быть публичным.
|
|
if r.URL.Path ==
|
|
"/login" {
|
|
|
|
next.ServeHTTP(
|
|
wr,
|
|
r,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
// Статика нужна странице login.
|
|
if strings.HasPrefix(
|
|
r.URL.Path,
|
|
"/static/",
|
|
) {
|
|
next.ServeHTTP(
|
|
wr,
|
|
r,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
cookie, err :=
|
|
r.Cookie(
|
|
sessionCookieName,
|
|
)
|
|
|
|
if err == nil &&
|
|
w.sessions.validate(
|
|
cookie.Value,
|
|
) {
|
|
|
|
next.ServeHTTP(
|
|
wr,
|
|
r,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
w.clearSessionCookie(
|
|
wr,
|
|
)
|
|
|
|
if strings.HasPrefix(
|
|
r.URL.Path,
|
|
"/api/",
|
|
) {
|
|
http.Error(
|
|
wr,
|
|
"Session expired",
|
|
http.StatusUnauthorized,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
http.Redirect(
|
|
wr,
|
|
r,
|
|
"/login",
|
|
http.StatusSeeOther,
|
|
)
|
|
},
|
|
)
|
|
}
|
|
|
|
func (w *WebServer) readEmailByID(
|
|
id string,
|
|
) (
|
|
[]byte,
|
|
string,
|
|
error,
|
|
) {
|
|
filePath, err :=
|
|
resolveEmailPath(
|
|
w.config.SMTP.StorageDir,
|
|
id,
|
|
)
|
|
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
|
|
content, err :=
|
|
os.ReadFile(
|
|
filePath,
|
|
)
|
|
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
|
|
return content,
|
|
filePath,
|
|
nil
|
|
}
|
|
|
|
func (w *WebServer) handleEmailError(
|
|
wr http.ResponseWriter,
|
|
r *http.Request,
|
|
id string,
|
|
err error,
|
|
) {
|
|
if errors.Is(
|
|
err,
|
|
os.ErrNotExist,
|
|
) {
|
|
http.NotFound(
|
|
wr,
|
|
r,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
if errors.Is(
|
|
err,
|
|
errInvalidEmailID,
|
|
) {
|
|
w.logger.Warningf(
|
|
"Rejected email id %q from %s: %v",
|
|
id,
|
|
r.RemoteAddr,
|
|
err,
|
|
)
|
|
|
|
http.Error(
|
|
wr,
|
|
"Invalid email id",
|
|
http.StatusBadRequest,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
w.logger.Errorf(
|
|
"Error accessing email %q: %v",
|
|
id,
|
|
err,
|
|
)
|
|
|
|
http.Error(
|
|
wr,
|
|
"Error reading email",
|
|
http.StatusInternalServerError,
|
|
)
|
|
}
|
|
|
|
func (w *WebServer) handleIndex(
|
|
wr http.ResponseWriter,
|
|
r *http.Request,
|
|
) {
|
|
if r.URL.Path != "/" {
|
|
http.NotFound(
|
|
wr,
|
|
r,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
if r.Method != http.MethodGet &&
|
|
r.Method != http.MethodHead {
|
|
|
|
wr.Header().Set(
|
|
"Allow",
|
|
"GET, HEAD",
|
|
)
|
|
|
|
http.Error(
|
|
wr,
|
|
"Method not allowed",
|
|
http.StatusMethodNotAllowed,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
if w.template == nil {
|
|
message :=
|
|
"Web template is unavailable"
|
|
|
|
if w.templateErr != nil {
|
|
message =
|
|
w.templateErr.Error()
|
|
}
|
|
|
|
http.Error(
|
|
wr,
|
|
message,
|
|
http.StatusInternalServerError,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
days, err :=
|
|
listEmails(
|
|
w.config.SMTP.StorageDir,
|
|
)
|
|
|
|
if err != nil {
|
|
w.logger.Errorf(
|
|
"Error listing emails: %v",
|
|
err,
|
|
)
|
|
|
|
http.Error(
|
|
wr,
|
|
"Error listing emails",
|
|
http.StatusInternalServerError,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
csrfToken := ""
|
|
|
|
if w.config.Web.Auth.Enabled {
|
|
if cookie, err :=
|
|
r.Cookie(
|
|
sessionCookieName,
|
|
); err == nil {
|
|
|
|
if token, ok :=
|
|
w.sessions.csrfToken(
|
|
cookie.Value,
|
|
); ok {
|
|
|
|
csrfToken = token
|
|
}
|
|
}
|
|
}
|
|
|
|
data := struct {
|
|
Days []DayEmails
|
|
Title string
|
|
CSRFToken string
|
|
}{
|
|
Days: days,
|
|
|
|
Title: "Fake SMTP Server - Emails",
|
|
|
|
CSRFToken: csrfToken,
|
|
}
|
|
|
|
wr.Header().Set(
|
|
"Content-Type",
|
|
"text/html; charset=utf-8",
|
|
)
|
|
|
|
wr.Header().Set(
|
|
"Cache-Control",
|
|
"no-store",
|
|
)
|
|
|
|
if r.Method ==
|
|
http.MethodHead {
|
|
|
|
return
|
|
}
|
|
|
|
if err :=
|
|
w.template.Execute(
|
|
wr,
|
|
data,
|
|
); err != nil {
|
|
|
|
w.logger.Errorf(
|
|
"Template execution error: %v",
|
|
err,
|
|
)
|
|
}
|
|
}
|
|
|
|
func (w *WebServer) handleEmailsAPI(
|
|
wr http.ResponseWriter,
|
|
r *http.Request,
|
|
) {
|
|
if r.Method !=
|
|
http.MethodGet {
|
|
|
|
wr.Header().Set(
|
|
"Allow",
|
|
http.MethodGet,
|
|
)
|
|
|
|
http.Error(
|
|
wr,
|
|
"Method not allowed",
|
|
http.StatusMethodNotAllowed,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
days, err :=
|
|
listEmails(
|
|
w.config.SMTP.StorageDir,
|
|
)
|
|
|
|
if err != nil {
|
|
w.logger.Errorf(
|
|
"Error listing emails: %v",
|
|
err,
|
|
)
|
|
|
|
wr.Header().Set(
|
|
"Content-Type",
|
|
"application/json; charset=utf-8",
|
|
)
|
|
|
|
wr.WriteHeader(
|
|
http.StatusInternalServerError,
|
|
)
|
|
|
|
_ = json.NewEncoder(
|
|
wr,
|
|
).Encode(
|
|
map[string]string{
|
|
"error": "Error listing emails",
|
|
},
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
wr.Header().Set(
|
|
"Content-Type",
|
|
"application/json; charset=utf-8",
|
|
)
|
|
|
|
if err :=
|
|
json.NewEncoder(
|
|
wr,
|
|
).Encode(
|
|
days,
|
|
); err != nil {
|
|
|
|
w.logger.Errorf(
|
|
"Failed to encode emails JSON: %v",
|
|
err,
|
|
)
|
|
}
|
|
}
|
|
|
|
func (w *WebServer) handleEmailAPI(
|
|
wr http.ResponseWriter,
|
|
r *http.Request,
|
|
) {
|
|
if r.Method !=
|
|
http.MethodGet {
|
|
|
|
wr.Header().Set(
|
|
"Allow",
|
|
http.MethodGet,
|
|
)
|
|
|
|
http.Error(
|
|
wr,
|
|
"Method not allowed",
|
|
http.StatusMethodNotAllowed,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
id :=
|
|
r.URL.Query().
|
|
Get("id")
|
|
|
|
if id == "" {
|
|
http.Error(
|
|
wr,
|
|
"Missing id parameter",
|
|
http.StatusBadRequest,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
content, _, err :=
|
|
w.readEmailByID(id)
|
|
|
|
if err != nil {
|
|
w.handleEmailError(
|
|
wr,
|
|
r,
|
|
id,
|
|
err,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
body :=
|
|
extractEmailBody(
|
|
content,
|
|
)
|
|
|
|
wr.Header().Set(
|
|
"Content-Type",
|
|
"text/html; charset=utf-8",
|
|
)
|
|
|
|
if _, err :=
|
|
wr.Write(
|
|
[]byte(body),
|
|
); err != nil {
|
|
|
|
w.logger.Errorf(
|
|
"Failed to send email %q: %v",
|
|
id,
|
|
err,
|
|
)
|
|
}
|
|
}
|
|
|
|
func (w *WebServer) handleViewAPI(
|
|
wr http.ResponseWriter,
|
|
r *http.Request,
|
|
) {
|
|
if r.Method !=
|
|
http.MethodGet {
|
|
|
|
wr.Header().Set(
|
|
"Allow",
|
|
http.MethodGet,
|
|
)
|
|
|
|
http.Error(
|
|
wr,
|
|
"Method not allowed",
|
|
http.StatusMethodNotAllowed,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
id :=
|
|
r.URL.Query().
|
|
Get("id")
|
|
|
|
if id == "" {
|
|
http.Error(
|
|
wr,
|
|
"Missing id parameter",
|
|
http.StatusBadRequest,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
content, _, err :=
|
|
w.readEmailByID(id)
|
|
|
|
if err != nil {
|
|
w.handleEmailError(
|
|
wr,
|
|
r,
|
|
id,
|
|
err,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
body :=
|
|
extractEmailBody(
|
|
content,
|
|
)
|
|
|
|
wr.Header().Set(
|
|
"Content-Type",
|
|
"text/html; charset=utf-8",
|
|
)
|
|
|
|
if _, err :=
|
|
wr.Write(
|
|
[]byte(body),
|
|
); err != nil {
|
|
|
|
w.logger.Errorf(
|
|
"Failed to send email view %q: %v",
|
|
id,
|
|
err,
|
|
)
|
|
}
|
|
}
|
|
|
|
func (w *WebServer) handleDownloadAPI(
|
|
wr http.ResponseWriter,
|
|
r *http.Request,
|
|
) {
|
|
if r.Method !=
|
|
http.MethodGet {
|
|
|
|
wr.Header().Set(
|
|
"Allow",
|
|
http.MethodGet,
|
|
)
|
|
|
|
http.Error(
|
|
wr,
|
|
"Method not allowed",
|
|
http.StatusMethodNotAllowed,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
id :=
|
|
r.URL.Query().
|
|
Get("id")
|
|
|
|
if id == "" {
|
|
http.Error(
|
|
wr,
|
|
"Missing id parameter",
|
|
http.StatusBadRequest,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
content,
|
|
filePath,
|
|
err :=
|
|
w.readEmailByID(id)
|
|
|
|
if err != nil {
|
|
w.handleEmailError(
|
|
wr,
|
|
r,
|
|
id,
|
|
err,
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
filename :=
|
|
filepath.Base(
|
|
filePath,
|
|
)
|
|
|
|
disposition :=
|
|
mime.FormatMediaType(
|
|
"attachment",
|
|
map[string]string{
|
|
"filename": filename,
|
|
},
|
|
)
|
|
|
|
wr.Header().Set(
|
|
"Content-Type",
|
|
"message/rfc822",
|
|
)
|
|
|
|
wr.Header().Set(
|
|
"Content-Disposition",
|
|
disposition,
|
|
)
|
|
|
|
wr.Header().Set(
|
|
"Content-Length",
|
|
strconv.Itoa(
|
|
len(content),
|
|
),
|
|
)
|
|
|
|
if _, err :=
|
|
wr.Write(content); err != nil {
|
|
|
|
w.logger.Errorf(
|
|
"Failed to download email %q: %v",
|
|
id,
|
|
err,
|
|
)
|
|
}
|
|
}
|
|
|
|
func (w *WebServer) Start() error {
|
|
if !w.config.Web.Enabled {
|
|
w.logger.Info(
|
|
"Web server disabled in config",
|
|
)
|
|
|
|
return nil
|
|
}
|
|
|
|
// HTML/CSS/JS читаются один раз при запуске.
|
|
w.loadWebAssets()
|
|
|
|
mux := http.NewServeMux()
|
|
|
|
mux.HandleFunc(
|
|
"/static/",
|
|
w.handleStatic,
|
|
)
|
|
|
|
mux.HandleFunc(
|
|
"/login",
|
|
w.handleLogin,
|
|
)
|
|
|
|
mux.HandleFunc(
|
|
"/logout",
|
|
w.handleLogout,
|
|
)
|
|
|
|
mux.HandleFunc(
|
|
"/api/emails",
|
|
w.handleEmailsAPI,
|
|
)
|
|
|
|
mux.HandleFunc(
|
|
"/api/email",
|
|
w.handleEmailAPI,
|
|
)
|
|
|
|
mux.HandleFunc(
|
|
"/api/view",
|
|
w.handleViewAPI,
|
|
)
|
|
|
|
mux.HandleFunc(
|
|
"/api/download",
|
|
w.handleDownloadAPI,
|
|
)
|
|
|
|
mux.HandleFunc(
|
|
"/",
|
|
w.handleIndex,
|
|
)
|
|
|
|
addr := fmt.Sprintf(
|
|
":%d",
|
|
w.config.Web.Port,
|
|
)
|
|
|
|
w.server =
|
|
&http.Server{
|
|
Addr: addr,
|
|
|
|
Handler: w.sessionAuth(mux),
|
|
|
|
ReadTimeout: 10 * time.Second,
|
|
|
|
WriteTimeout: 10 * time.Second,
|
|
|
|
IdleTimeout: 60 * time.Second,
|
|
|
|
ReadHeaderTimeout: 5 * time.Second,
|
|
}
|
|
|
|
// net.Listen выполняется синхронно,
|
|
// поэтому занятый порт сразу вернёт ошибку из Start().
|
|
listener, err :=
|
|
net.Listen(
|
|
"tcp",
|
|
addr,
|
|
)
|
|
|
|
if err != nil {
|
|
w.server = nil
|
|
|
|
return fmt.Errorf(
|
|
"failed to listen on %s: %w",
|
|
addr,
|
|
err,
|
|
)
|
|
}
|
|
|
|
w.logger.Infof(
|
|
"Web server listening on http://0.0.0.0:%d",
|
|
w.config.Web.Port,
|
|
)
|
|
|
|
w.logger.Infof(
|
|
"Web directory: %s",
|
|
w.config.Web.Directory,
|
|
)
|
|
|
|
w.logger.Infof(
|
|
"Web Auth enabled: %v",
|
|
w.config.Web.Auth.Enabled,
|
|
)
|
|
|
|
if w.config.Web.Auth.Enabled {
|
|
w.logger.Infof(
|
|
"Web session inactivity timeout: %s",
|
|
w.config.Web.Auth.SessionTimeout,
|
|
)
|
|
|
|
w.logger.Infof(
|
|
"Web session cookie Secure: %v",
|
|
w.config.Web.Auth.CookieSecure,
|
|
)
|
|
|
|
w.logger.Infof(
|
|
"Web login rate limit: %d attempts / %s, lockout %s",
|
|
w.config.Web.Auth.LoginMaxAttempts,
|
|
w.config.Web.Auth.LoginWindow,
|
|
w.config.Web.Auth.LoginLockout,
|
|
)
|
|
}
|
|
|
|
go func() {
|
|
err :=
|
|
w.server.Serve(
|
|
listener,
|
|
)
|
|
|
|
if err != nil &&
|
|
!errors.Is(
|
|
err,
|
|
http.ErrServerClosed,
|
|
) {
|
|
|
|
w.logger.Errorf(
|
|
"Web server error: %v",
|
|
err,
|
|
)
|
|
}
|
|
}()
|
|
|
|
return nil
|
|
}
|
|
|
|
func (w *WebServer) Stop() error {
|
|
if w.server == nil {
|
|
return nil
|
|
}
|
|
|
|
w.logger.Info(
|
|
"Stopping web server...",
|
|
)
|
|
|
|
ctx, cancel :=
|
|
context.WithTimeout(
|
|
context.Background(),
|
|
5*time.Second,
|
|
)
|
|
|
|
defer cancel()
|
|
|
|
err :=
|
|
w.server.Shutdown(ctx)
|
|
|
|
w.server = nil
|
|
|
|
if err != nil {
|
|
return fmt.Errorf(
|
|
"failed to stop web server: %w",
|
|
err,
|
|
)
|
|
}
|
|
|
|
return nil
|
|
}
|