kittemail/kittemail-prefork/main.go

779 lines
25 KiB
Go

package main
import (
"bytes"
"crypto/rand"
"errors"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"crypto/ed25519"
"database/sql"
"encoding/json"
"html/template"
"io/fs"
"net/http"
"net/url"
library "git.ailur.dev/ailur/fg-library/v2"
nucleusLibrary "git.ailur.dev/ailur/fg-nucleus-library"
"github.com/emersion/go-sasl"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
_ "github.com/lib/pq"
)
var ServiceInformation = library.Service{
Name: "kittemail",
Permissions: library.Permissions{
Authenticate: true, // This service does require authentication
Database: true, // This service does require database access
BlobStorage: true, // This service does require reserving blob storage space
InterServiceCommunication: true, // This service does require inter-service communication
Resources: true, // This service does require its HTTP templates and static files
},
ServiceID: uuid.MustParse("338350fc-14d5-469d-999b-950033fb6b7c"),
}
func getUUIDBytes(uuidString string) []byte {
uuidBytes := uuid.MustParse(uuidString)
return uuidBytes[:]
}
func randomChars(length int) (string, error) {
var saltChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
if length <= 0 {
return "", errors.New("salt length must be greater than 0")
}
salt := make([]byte, length)
randomBytes := make([]byte, length)
_, err := rand.Read(randomBytes)
if err != nil {
return "", err
}
for i := range salt {
salt[i] = saltChars[int(randomBytes[i])%len(saltChars)]
}
return string(salt), nil
}
func logFunc(message string, messageType uint64, information library.ServiceInitializationInformation) {
// Log the message to the logger service
information.Outbox <- library.InterServiceMessage{
ServiceID: ServiceInformation.ServiceID,
ForServiceID: uuid.MustParse("00000000-0000-0000-0000-000000000002"), // Logger service
MessageType: messageType,
SentAt: time.Now(),
Message: message,
}
}
func renderTemplate(statusCode int, w http.ResponseWriter, data any, templatePath string, information library.ServiceInitializationInformation) {
var err error
var requestedTemplate *template.Template
// Output ls of the resource directory
requestedTemplate, err = template.ParseFS(information.ResourceDir, "templates/"+templatePath)
if err != nil {
logFunc(err.Error(), 2, information)
renderString(500, w, "Sorry, something went wrong on our end. Error code: 01. Please report to the administrator.", information)
} else {
w.WriteHeader(statusCode)
err = requestedTemplate.Execute(w, data)
if err != nil {
logFunc(err.Error(), 2, information)
renderString(500, w, "Sorry, something went wrong on our end. Error code: 02. Please report to the administrator.", information)
}
}
}
func renderString(statusCode int, w http.ResponseWriter, data string, information library.ServiceInitializationInformation) {
w.WriteHeader(statusCode)
_, err := w.Write([]byte(data))
if err != nil {
logFunc(err.Error(), 2, information)
}
}
func renderJSON(statusCode int, w http.ResponseWriter, data any, information library.ServiceInitializationInformation) {
w.WriteHeader(statusCode)
err := json.NewEncoder(w).Encode(data)
if err != nil {
logFunc(err.Error(), 2, information)
}
}
func verifyJwt(token string, publicKey ed25519.PublicKey, conn library.Database, checkInUsers bool) (jwt.MapClaims, bool) {
parsedToken, err := jwt.Parse(token, func(token *jwt.Token) (interface{}, error) {
return publicKey, nil
})
if err != nil {
println(err.Error())
return nil, false
}
if !parsedToken.Valid {
return nil, false
}
claims, ok := parsedToken.Claims.(jwt.MapClaims)
if !ok {
return nil, false
}
// Check if the token expired
date, err := claims.GetExpirationTime()
if err != nil || date.Before(time.Now()) || claims["sub"] == nil || claims["isOpenID"] == nil || claims["isOpenID"].(bool) {
return claims, false
}
// Check if the token is in users
if checkInUsers {
var idCheck []byte
err = conn.DB.QueryRow("SELECT id FROM users WHERE id = $1", getUUIDBytes(claims["sub"].(string))).Scan(&idCheck)
if err != nil || claims["sub"] != uuid.Must(uuid.FromBytes(idCheck)).String() {
return claims, false
}
}
return claims, true
}
func initDb(conn library.Database, information library.ServiceInitializationInformation) {
if conn.DBType == library.Sqlite {
// Create the users table
_, err := conn.DB.Exec("CREATE TABLE IF NOT EXISTS users (id BLOB isPrimary KEY, subscription INTEGER NOT NULL, createdAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP)")
if err != nil {
logFunc(err.Error(), 3, information)
}
// Create the emails table
_, err = conn.DB.Exec("CREATE TABLE IF NOT EXISTS emails (username TEXT NOT NULL, address TEXT NOT NULL, isPrimary INTEGER NOT NULL, owner BLOB NOT NULL, createdAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP, password TEXT NOT NULL)")
if err != nil {
logFunc(err.Error(), 3, information)
}
} else {
// Create the users table
_, err := conn.DB.Exec("CREATE TABLE IF NOT EXISTS users (id BYTEA isPrimary KEY, subscription INTEGER NOT NULL, createdAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP)")
if err != nil {
logFunc(err.Error(), 3, information)
}
// Create the emails table
_, err = conn.DB.Exec("CREATE TABLE IF NOT EXISTS emails (username TEXT NOT NULL, address TEXT NOT NULL, isPrimary BOOLEAN NOT NULL, owner BYTEA NOT NULL, createdAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP, password TEXT NOT NULL)")
if err != nil {
logFunc(err.Error(), 3, information)
}
}
}
func getSuperuserCommand(information library.ServiceInitializationInformation) string {
if os.Getuid() != 0 {
// Check if pkexec, sudo or doas is installed
_, err := os.Stat("/usr/bin/pkexec")
if os.IsNotExist(err) {
_, err = os.Stat("/usr/bin/sudo")
if os.IsNotExist(err) {
_, err = os.Stat("/usr/bin/doas")
if os.IsNotExist(err) {
logFunc("No superuser command found", 3, information)
} else if err != nil {
logFunc(err.Error(), 3, information)
} else {
return "doas"
}
} else if err != nil {
logFunc(err.Error(), 3, information)
} else {
return "sudo"
}
} else if err != nil {
logFunc(err.Error(), 3, information)
} else {
return "pkexec"
}
}
return ""
}
func startDovecotListener(information library.ServiceInitializationInformation, database library.Database) {
// Create a new SASL server
server := sasl.NewPlainServer(func(identity, username, password string) error {
// Check if the user exists
var idCheck []byte
err := database.DB.QueryRow("SELECT id FROM users WHERE id = $1", getUUIDBytes(username)).Scan(&idCheck)
if err != nil || !bytes.Equal(idCheck, getUUIDBytes(username)) {
return errors.New("user not found")
}
// Check if the password is correct
var dbPassword string
err = database.DB.QueryRow("SELECT password FROM emails WHERE owner = $1 AND isPrimary = true", getUUIDBytes(username)).Scan(&dbPassword)
if err != nil || dbPassword != password {
return errors.New("invalid password")
}
return nil
})
server.Next()
}
func Main(information library.ServiceInitializationInformation) {
var doveConn *sql.DB
// doveConn, err := sql.Open("postgres", information.Configuration["connectionString"].(string))
// if err != nil {
// logFunc(err.Error(), 3, information)
// }
var err error
var conn library.Database
hostName := information.Configuration["hostName"].(string)
// Initiate a connection to the database
// Call service ID 1 to get the database connection information
information.Outbox <- library.InterServiceMessage{
ServiceID: ServiceInformation.ServiceID,
ForServiceID: uuid.MustParse("00000000-0000-0000-0000-000000000001"), // Service initialization service
MessageType: 1, // Request connection information
SentAt: time.Now(),
Message: nil,
}
// Wait for the response
response := <-information.Inbox
if response.MessageType == 2 {
// This is the connection information
// Set up the database connection
conn = response.Message.(library.Database)
initDb(conn, information)
} else {
// This is an error message
// Log the error message to the logger service
logFunc(response.Message.(error).Error(), 3, information)
}
// Ask the authentication service for the public key
information.Outbox <- library.InterServiceMessage{
ServiceID: ServiceInformation.ServiceID,
ForServiceID: uuid.MustParse("00000000-0000-0000-0000-000000000004"), // Authentication service
MessageType: 2, // Request public key
SentAt: time.Now(),
Message: nil,
}
var publicKey ed25519.PublicKey = nil
// 3 second timeout
go func() {
time.Sleep(3 * time.Second)
if publicKey == nil {
logFunc("Timeout while waiting for the public key from the authentication service", 3, information)
}
}()
// Wait for the response
response = <-information.Inbox
if response.MessageType == 2 {
// This is the public key
publicKey = response.Message.(ed25519.PublicKey)
} else {
// This is an error message
// Log the error message to the logger service
logFunc(response.Message.(error).Error(), 3, information)
}
// Ask the authentication service for the OAuth host name
information.Outbox <- library.InterServiceMessage{
ServiceID: ServiceInformation.ServiceID,
ForServiceID: uuid.MustParse("00000000-0000-0000-0000-000000000004"), // Authentication service
MessageType: 0, // Request OAuth host name
SentAt: time.Now(),
Message: nil,
}
var oauthHostName string
// 3 second timeout
go func() {
time.Sleep(3 * time.Second)
if oauthHostName == "" {
logFunc("Timeout while waiting for the OAuth host name from the authentication service", 3, information)
}
}()
// Wait for the response
response = <-information.Inbox
if response.MessageType == 0 {
// This is the OAuth host name
oauthHostName = response.Message.(string)
} else {
// This is an error message
// Log the error message to the logger service
logFunc(response.Message.(error).Error(), 3, information)
}
// Ask the authentication service to create a new OAuth2 client
urlPath, err := url.JoinPath(hostName, "/oauth")
if err != nil {
logFunc(err.Error(), 3, information)
}
information.Outbox <- library.InterServiceMessage{
ServiceID: ServiceInformation.ServiceID,
ForServiceID: uuid.MustParse("00000000-0000-0000-0000-000000000004"), // Authentication service
MessageType: 1, // Create OAuth2 client
SentAt: time.Now(),
Message: nucleusLibrary.OAuthInformation{
Name: "Kittemail",
RedirectUri: urlPath,
KeyShareUri: "",
Scopes: []string{"openid"},
},
}
var oauthResponse nucleusLibrary.OAuthResponse
// 3 second timeout
go func() {
time.Sleep(3 * time.Second)
if oauthResponse == (nucleusLibrary.OAuthResponse{}) {
logFunc("Timeout while waiting for the OAuth response from the authentication service", 3, information)
}
}()
// Wait for the response
response = <-information.Inbox
switch response.MessageType {
case 0:
// Success, set the OAuth response
oauthResponse = response.Message.(nucleusLibrary.OAuthResponse)
logFunc("Initialized with App ID: "+oauthResponse.AppID, 0, information)
case 1:
// An error which is their fault
logFunc(response.Message.(error).Error(), 3, information)
case 2:
// An error which is our fault
logFunc(response.Message.(error).Error(), 3, information)
default:
// An unknown error
logFunc("Unknown error", 3, information)
}
// Start the Dovecot listener
go startDovecotListener(information, conn)
// Set up the router
router := information.Router
// Set up the static routes
staticDir, err := fs.Sub(information.ResourceDir, "static")
if err != nil {
logFunc(err.Error(), 3, information)
} else {
router.Handle("/kt-static/*", http.StripPrefix("/kt-static/", http.FileServerFS(staticDir)))
}
// Set up the API routes
router.Post("/api/signup", func(w http.ResponseWriter, r *http.Request) {
var request struct {
Username string `json:"username"`
Domain string `json:"domain"`
Token string `json:"token"`
Storage float64 `json:"storage"`
}
err := json.NewDecoder(r.Body).Decode(&request)
if err != nil {
renderJSON(400, w, map[string]interface{}{"error": "Invalid request"}, information)
return
}
if request.Storage < 0 {
renderJSON(400, w, map[string]interface{}{"error": "Invalid storage"}, information)
return
}
// Validate if the username is valid
if len(request.Username) < 3 || len(request.Username) > 20 {
renderJSON(400, w, map[string]interface{}{"error": "Invalid username"}, information)
return
}
// Validate if the domain is valid
switch request.Domain {
case "cta.social", "ailur.dev":
break
default:
renderJSON(400, w, map[string]interface{}{"error": "Invalid domain"}, information)
return
}
// Check if the user is authenticated
claims, ok := verifyJwt(request.Token, publicKey, conn, false)
if !ok {
renderJSON(401, w, map[string]interface{}{"error": "Invalid token"}, information)
return
}
// Check if the user already exists
userId := uuid.MustParse(claims["sub"].(string))
var idCheck []byte
err = conn.DB.QueryRow("SELECT id FROM users WHERE id = $1", userId[:]).Scan(&idCheck)
if err == nil && bytes.Equal(idCheck, userId[:]) {
renderJSON(409, w, map[string]interface{}{"error": "User already exists"}, information)
return
}
// Check if the email already exists
var emailCheck string
err = conn.DB.QueryRow("SELECT address FROM emails WHERE username = $1 AND address = $2", request.Username, request.Domain).Scan(&emailCheck)
if err == nil && emailCheck == request.Domain {
renderJSON(409, w, map[string]interface{}{"error": "Email already exists"}, information)
return
}
// Reserve the space for the user
information.Outbox <- library.InterServiceMessage{
ServiceID: ServiceInformation.ServiceID,
ForServiceID: uuid.MustParse("00000000-0000-0000-0000-000000000003"), // Blob storage service
MessageType: 2, // Reserve space
SentAt: time.Now(),
Message: nucleusLibrary.Quota{
User: userId,
Bytes: int64(request.Storage),
},
}
// 3 second timeout
var fileSpaceReserved bool
go func() {
time.Sleep(3 * time.Second)
if !fileSpaceReserved {
logFunc("Timeout while waiting for the file space to be reserved", 3, information)
renderJSON(500, w, map[string]interface{}{"error": "Internal server error", "code": "00"}, information)
// Panic to stop the function
panic("Timeout while waiting for the file space to be reserved")
}
}()
// Wait for the response
response = <-information.Inbox
if response.MessageType == 0 {
// Success
fileSpaceReserved = true
} else if response.MessageType == 1 {
// An error
renderJSON(500, w, map[string]interface{}{"error": "Internal server error", "code": "01"}, information)
return
} else if response.MessageType == 2 {
// Invalid request
renderJSON(400, w, map[string]interface{}{"error": response.Message.(error).Error()}, information)
return
}
// Insert the user into the database
_, err = conn.DB.Exec("INSERT INTO users (id, subscription) VALUES ($1, 0)", userId[:])
if err != nil {
renderJSON(500, w, map[string]interface{}{"error": "Internal server error", "code": "02"}, information)
return
}
// Insert the email into the database
password, err := randomChars(16)
if err != nil {
renderJSON(500, w, map[string]interface{}{"error": "Internal server error", "code": "03"}, information)
return
}
_, err = conn.DB.Exec("INSERT INTO emails (username, address, isPrimary, owner, password) VALUES ($1, $2, true, $3, $4)", request.Username, request.Domain, userId[:], password)
if err != nil {
renderJSON(500, w, map[string]interface{}{"error": "Internal server error", "code": "04"}, information)
return
}
// Contact dovecot to create the user
_, err = doveConn.Exec("INSERT INTO users (email, maildir, quota_rule) VALUES ($1, $2, $3)", request.Username+"@"+request.Domain, userId.String(), "*:storage="+strconv.FormatInt(int64(request.Storage), 10)+"B", userId)
if err != nil {
renderJSON(500, w, map[string]interface{}{"error": "Internal server error", "code": "05"}, information)
return
}
renderJSON(200, w, map[string]interface{}{"password": password}, information)
})
router.Post("/api/email/info", func(w http.ResponseWriter, r *http.Request) {
var request struct {
Token string `json:"token"`
Username string `json:"username"`
Address string `json:"address"`
}
err := json.NewDecoder(r.Body).Decode(&request)
if err != nil {
renderJSON(400, w, map[string]interface{}{"error": "Invalid request"}, information)
return
}
// Check if the user is authenticated
claims, ok := verifyJwt(request.Token, publicKey, conn, true)
if !ok {
renderJSON(401, w, map[string]interface{}{"error": "Invalid token"}, information)
return
}
// Get the email information
var username, address, password string
var isPrimary bool
err = conn.DB.QueryRow("SELECT username, address, password, isPrimary FROM emails WHERE username = $1 AND address = $2 AND owner = $3", request.Username, request.Address, getUUIDBytes(claims["sub"].(string))).Scan(&username, &address, &password, &isPrimary)
if err != nil {
renderJSON(404, w, map[string]interface{}{"error": "Email not found"}, information)
return
}
// Return the email information
renderJSON(200, w, map[string]interface{}{
"username": username,
"address": address,
"password": password,
"isPrimary": isPrimary,
}, information)
})
router.Post("/api/email/resetLoginCode", func(w http.ResponseWriter, r *http.Request) {
var request struct {
Token string `json:"token"`
Username string `json:"username"`
Address string `json:"address"`
}
err := json.NewDecoder(r.Body).Decode(&request)
if err != nil {
renderJSON(400, w, map[string]interface{}{"error": "Invalid request"}, information)
return
}
// Check if the user is authenticated
claims, ok := verifyJwt(request.Token, publicKey, conn, true)
if !ok {
renderJSON(401, w, map[string]interface{}{"error": "Invalid token"}, information)
return
}
// Generate a new password
password, err := randomChars(16)
if err != nil {
renderJSON(500, w, map[string]interface{}{"error": "Internal server error", "code": "05"}, information)
return
}
// Change the password
_, err = conn.DB.Exec("UPDATE emails SET password = $1 WHERE username = $2 AND address = $3 AND owner = $4", password, request.Username, request.Address, getUUIDBytes(claims["sub"].(string)))
if err != nil {
renderJSON(500, w, map[string]interface{}{"error": "Internal server error", "code": "06"}, information)
return
}
renderJSON(200, w, nil, information)
})
router.Post("/api/email/delete", func(w http.ResponseWriter, r *http.Request) {
var request struct {
Token string `json:"token"`
Username string `json:"username"`
Address string `json:"address"`
}
err := json.NewDecoder(r.Body).Decode(&request)
if err != nil {
renderJSON(400, w, map[string]interface{}{"error": "Invalid request"}, information)
return
}
// Check if the user is authenticated
claims, ok := verifyJwt(request.Token, publicKey, conn, true)
if !ok {
renderJSON(401, w, map[string]interface{}{"error": "Invalid token"}, information)
return
}
userID := uuid.MustParse(claims["sub"].(string))
// Check if it's the isPrimary email
var isPrimary bool
err = conn.DB.QueryRow("SELECT isPrimary FROM emails WHERE username = $1 AND address = $2 AND owner = $3", request.Username, request.Address, userID[:]).Scan(&isPrimary)
if err != nil {
renderJSON(404, w, map[string]interface{}{"error": "Email not found"}, information)
return
}
if !isPrimary {
// Delete the email
_, err = conn.DB.Exec("DELETE FROM emails WHERE username = $1 AND address = $2 AND owner = $3", request.Username, request.Address, userID[:])
if err != nil {
renderJSON(500, w, map[string]interface{}{"error": "Internal server error", "code": "07"}, information)
return
}
} else {
// Get the user's quota
var quota string
err = doveConn.QueryRow("SELECT quota_rule FROM users WHERE id = $1", userID.String()).Scan(&quota)
if err != nil {
renderJSON(500, w, map[string]interface{}{"error": "Internal server error", "code": "08"}, information)
return
}
// Parse the quota
quotaInt, err := strconv.ParseInt(strings.TrimSuffix(strings.TrimPrefix(quota, "*:storage="), "B"), 10, 64)
if err != nil {
renderJSON(500, w, map[string]interface{}{"error": "Internal server error", "code": "09"}, information)
return
}
// Delete the maildir
_, err = doveConn.Exec("DELETE FROM users WHERE maildir = $1", userID.String())
if err != nil {
renderJSON(500, w, map[string]interface{}{"error": "Internal server error", "code": "08"}, information)
return
}
err = os.RemoveAll(filepath.Join("/home/mailboxes/maildir/", userID.String()))
if err != nil {
renderJSON(500, w, map[string]interface{}{"error": "Internal server error", "code": "09"}, information)
return
}
// Delete the emails
_, err = conn.DB.Exec("DELETE FROM emails WHERE owner = $1", userID[:])
if err != nil {
renderJSON(500, w, map[string]interface{}{"error": "Internal server error", "code": "0A"}, information)
return
}
// Delete the user
_, err = conn.DB.Exec("DELETE FROM users WHERE id = $1", userID[:])
if err != nil {
renderJSON(500, w, map[string]interface{}{"error": "Internal server error", "code": "0B"}, information)
return
}
// Release the space
information.Outbox <- library.InterServiceMessage{
ServiceID: ServiceInformation.ServiceID,
ForServiceID: uuid.MustParse("00000000-0000-0000-0000-000000000003"), // Blob storage service
MessageType: 2, // Add space, but we can use a negative number to release space
SentAt: time.Now(),
Message: nucleusLibrary.Quota{
User: userID,
Bytes: -quotaInt,
},
}
// 3 second timeout
var fileSpaceReleased bool
go func() {
time.Sleep(3 * time.Second)
if !fileSpaceReleased {
logFunc("Timeout while waiting for the file space to be released", 3, information)
renderJSON(500, w, map[string]interface{}{"error": "Internal server error", "code": "0C"}, information)
// Panic to stop the function
panic("Timeout while waiting for the file space to be released")
}
}()
// Wait for the response
response = <-information.Inbox
if response.MessageType == 0 {
// Success
fileSpaceReleased = true
}
}
renderJSON(200, w, nil, information)
})
router.Post("/api/email/list", func(w http.ResponseWriter, r *http.Request) {
var request struct {
Token string `json:"token"`
}
err := json.NewDecoder(r.Body).Decode(&request)
if err != nil {
renderJSON(400, w, map[string]interface{}{"error": "Invalid request"}, information)
return
}
// Check if the user is authenticated
claims, ok := verifyJwt(request.Token, publicKey, conn, true)
if !ok {
renderJSON(401, w, map[string]interface{}{"error": "Invalid token"}, information)
return
}
// Get the emails
rows, err := conn.DB.Query("SELECT username, address, isPrimary FROM emails WHERE owner = $1", getUUIDBytes(claims["sub"].(string)))
if err != nil {
renderJSON(500, w, map[string]interface{}{"error": "Internal server error", "code": "0D"}, information)
return
}
var emails []map[string]interface{}
for rows.Next() {
var username, address string
var isPrimary bool
err = rows.Scan(&username, &address, &isPrimary)
if err != nil {
renderJSON(500, w, map[string]interface{}{"error": "Internal server error", "code": "0E"}, information)
return
}
emails = append(emails, map[string]interface{}{
"username": username,
"address": address,
"isPrimary": isPrimary,
})
}
err = rows.Close()
if err != nil {
renderJSON(500, w, map[string]interface{}{"error": "Internal server error", "code": "0F"}, information)
return
}
renderJSON(200, w, emails, information)
})
// TODO: Once OAuth2 RFC is approved and all the EMail clients support it, switch out the Email-specific password for OAuth2
// TODO: Add automatic alias addition, deletion and listing
// TODO: Add subscription management when Ailur becomes incorporated
// Set up the routes
router.Get("/", func(w http.ResponseWriter, r *http.Request) {
renderTemplate(200, w, nil, "index.html", information)
})
router.Get("/signup", func(w http.ResponseWriter, r *http.Request) {
renderTemplate(200, w, nil, "signup.html", information)
})
router.Get("/oauth", func(w http.ResponseWriter, r *http.Request) {
renderTemplate(200, w, map[string]interface{}{
"ClientId": oauthResponse.AppID,
"AuthorizationUri": oauthHostName,
}, "oauth.html", information)
})
router.Get("/settings", func(w http.ResponseWriter, r *http.Request) {
renderTemplate(200, w, nil, "settings.html", information)
})
router.Get("/quickstart", func(w http.ResponseWriter, r *http.Request) {
renderTemplate(200, w, nil, "quickstart.html", information)
})
router.Get("/autoconfig.xml", func(w http.ResponseWriter, r *http.Request) {
renderTemplate(200, w, nil, "autoconfig.xml", information)
})
}