Notes2/main.go
Magnus Åhall 47c4c99b66 File sync
2026-07-10 06:32:31 +02:00

626 lines
16 KiB
Go

package main
import (
// Internal
"notes2/authentication"
"notes2/html_template"
appUser "notes2/user"
// Standard
"bufio"
"context"
"embed"
"encoding/json"
"flag"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"path"
"regexp"
"strconv"
"strings"
"text/template"
"time"
)
const VERSION = "v30"
const CONTEXT_USER = 1
const SYNC_PAGINATION = 1
var (
FlagGenerate bool
FlagLoremIpsum bool
FlagDev bool
FlagConfig string
FlagCreateUser string
FlagChangePassword string
FlagVersion bool
Webengine HTMLTemplate.Engine
config Config
Log *slog.Logger
AuthManager authentication.Manager
RxpBearerToken *regexp.Regexp
//go:embed views
ViewFS embed.FS
//go:embed static
StaticFS embed.FS
//go:embed sql
SqlFS embed.FS
)
func init() { // {{{
// Configuration filename to use with a somewhat sane default.
cfgDir, err := os.UserConfigDir()
if err != nil {
panic(err)
}
cfgFilename := path.Join(cfgDir, "notes2.json")
flag.StringVar(&FlagConfig, "config", cfgFilename, "Configuration file")
flag.BoolVar(&FlagDev, "dev", false, "Use local files instead of embedded files")
flag.BoolVar(&FlagGenerate, "generate", false, "Generate test data")
flag.BoolVar(&FlagLoremIpsum, "lorem-ipsum", false, "Replace all G- nodes with lorem ipsum paragraphs")
flag.StringVar(&FlagCreateUser, "create-user", "", "Username for creating a new user")
flag.StringVar(&FlagChangePassword, "change-password", "", "Change the password for the given username")
flag.BoolVar(&FlagVersion, "version", false, "Print version and exit")
flag.Parse()
RxpBearerToken = regexp.MustCompile("(?i)^\\s*Bearer\\s+(.*?)\\s*$")
} // }}}
func initLog() { // {{{
opts := slog.HandlerOptions{}
opts.Level = slog.LevelInfo
Log = slog.New(slog.NewJSONHandler(os.Stdout, &opts))
} // }}}
func main() { // {{{
if FlagVersion {
fmt.Println(VERSION)
return
}
initLog()
Log.Info("application", "version", VERSION)
err := readConfig(FlagConfig)
if err != nil {
Log.Error("config", "error", err)
os.Exit(1)
}
// dbschema uses the embedded SQL files to keep the database schema up to date.
err = initDB()
if err != nil {
Log.Error("database", "error", err)
os.Exit(1)
}
// The session manager contains authentication, authorization and session settings.
AuthManager, err = authentication.NewManager(db, Log, config.JWT.Secret, config.JWT.ExpireDays)
// Generate test data?
if FlagGenerate {
err := TestData()
if err != nil {
fmt.Printf("%s\n", err)
return
}
return
}
if FlagLoremIpsum {
err := LoremIpsum()
if err != nil {
fmt.Printf("%s\n", err)
return
}
return
}
// A new user?
if FlagCreateUser != "" {
createNewUser(FlagCreateUser)
return
}
// Forgotten the password?
if FlagChangePassword != "" {
changePassword(FlagChangePassword)
return
}
// The webengine takes layouts, pages and components and renders them into HTML.
Webengine, err = HTMLTemplate.NewEngine(ViewFS, StaticFS, FlagDev)
if err != nil {
panic(err)
}
http.HandleFunc("/", rootHandler)
http.HandleFunc("/login", pageLogin)
http.HandleFunc("/sync", pageSync)
http.HandleFunc("/offline", pageOffline)
http.HandleFunc("/user/authenticate", AuthManager.AuthenticationHandler)
http.HandleFunc("GET /user/preferences", authenticated(actionUserGetPreferences))
http.HandleFunc("POST /user/preferences", authenticated(actionUserSetPreferences))
http.HandleFunc("/sync/from_server/count/{sequence}", authenticated(actionSyncFromServerCount))
http.HandleFunc("/sync/from_server/{sequence}/{offset}", authenticated(actionSyncFromServer))
http.HandleFunc("/sync/to_server", authenticated(actionSyncToServer))
http.HandleFunc("/sync/file/upload/{uuid}", authenticated(actionSyncFileUpload))
http.HandleFunc("/node/retrieve/{uuid}", authenticated(actionNodeRetrieve))
http.HandleFunc("/node/history/retrieve/{uuid}/{offset}", authenticated(actionNodeHistoryRetrieve))
http.HandleFunc("/node/history/count/{uuid}", authenticated(actionNodeHistoryCount))
http.HandleFunc("/file/count", authenticated(actionFileCount))
http.HandleFunc("/file/server_uuids/{offset}", authenticated(actionFileServerUUIDs))
http.HandleFunc("/file/uuid/{uuid}", authenticated(actionFile))
http.HandleFunc("/file/uuid/{uuid}/metadata", authenticated(actionFileMetadata))
http.HandleFunc("/service_worker.js", pageServiceWorker)
listen := fmt.Sprintf("%s:%d", config.Network.Address, config.Network.Port)
Log.Info("webserver", "listen_address", listen)
http.ListenAndServe(listen, nil)
} // }}}
func authenticated(fn func(http.ResponseWriter, *http.Request)) func(http.ResponseWriter, *http.Request) { // {{{
return func(w http.ResponseWriter, r *http.Request) {
failed := func(err error) {
j, _ := json.Marshal(struct {
OK bool
Error string
AuthFailed bool
}{false, err.Error(), true})
w.Write(j)
}
// The Bearer token is extracted.
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
authHeader = "Bearer " + r.URL.Query().Get("jwt")
}
authParts := RxpBearerToken.FindStringSubmatch(authHeader)
if len(authParts) != 2 {
failed(fmt.Errorf("Authorization missing or invalid"))
return
}
token := authParts[1]
// Token signature is verified with the application secret key.
claims, err := AuthManager.VerifyToken(token)
if err != nil {
failed(err)
return
}
// User object is added to the context for the next handler.
user := appUser.NewUser(claims)
r = r.WithContext(context.WithValue(r.Context(), CONTEXT_USER, user))
Log.Debug("webserver", "op", "request", "method", r.Method, "url", r.URL.String(), "username", user.Username, "client", user.ClientUUID)
fn(w, r)
}
} // }}}
func rootHandler(w http.ResponseWriter, r *http.Request) { // {{{
// All URLs not specifically handled are routed to this function.
// Everything going here should be a static resource.
if r.URL.Path == "/" {
page := NewPage("notes2")
err := Webengine.Render(page, w, r)
if err != nil {
w.Write([]byte(err.Error()))
return
}
return
}
Webengine.StaticResource(w, r)
} // }}}
func httpError(w http.ResponseWriter, err error) { // {{{
j, _ := json.Marshal(struct {
OK bool
Error string
}{false, err.Error()})
w.Write(j)
} // }}}
func pageServiceWorker(w http.ResponseWriter, r *http.Request) { // {{{
w.Header().Add("Content-Type", "text/javascript; charset=utf-8")
var tmpl *template.Template
var err error
if FlagDev {
tmpl, err = template.ParseFiles("static/service_worker.js")
} else {
tmpl, err = template.ParseFS(StaticFS, "static/service_worker.js")
}
if err != nil {
w.Write([]byte(err.Error()))
return
}
err = tmpl.Execute(w, struct {
VERSION string
DevMode bool
}{VERSION, FlagDev})
if err != nil {
w.Write([]byte(err.Error()))
return
}
} // }}}
func pageOffline(w http.ResponseWriter, r *http.Request) { // {{{
page := NewPage("offline")
err := Webengine.Render(page, w, r)
if err != nil {
w.Write([]byte(err.Error()))
return
}
} // }}}
func pageLogin(w http.ResponseWriter, r *http.Request) { // {{{
page := NewPage("login")
err := Webengine.Render(page, w, r)
if err != nil {
w.Write([]byte(err.Error()))
return
}
} // }}}
func pageSync(w http.ResponseWriter, r *http.Request) { // {{{
page := NewPage("sync")
err := Webengine.Render(page, w, r)
if err != nil {
w.Write([]byte(err.Error()))
return
}
} // }}}
func actionSyncFromServer(w http.ResponseWriter, r *http.Request) { // {{{
// XXX - DELETE ME!
time.Sleep(time.Millisecond * 200)
// The purpose of the Client UUID is to avoid
// sending nodes back once again to a client that
// just created or modified it.
user := getUserSession(r)
changedFrom, _ := strconv.Atoi(r.PathValue("sequence"))
offset, _ := strconv.Atoi(r.PathValue("offset"))
nodes, maxSeq, moreRowsExist, err := Nodes(user.UserID, offset, uint64(changedFrom), user.ClientUUID)
if err != nil {
Log.Error("/sync/from_server", "error", err)
httpError(w, err)
return
}
j, _ := json.Marshal(struct {
OK bool
Nodes []Node
MaxSeq uint64
Continue bool
}{true, nodes, maxSeq, moreRowsExist})
w.Write(j)
} // }}}
func actionSyncFromServerCount(w http.ResponseWriter, r *http.Request) { // {{{
// The purpose of the Client UUID is to avoid
// sending nodes back once again to a client that
// just created or modified it.
user := getUserSession(r)
changedFrom, _ := strconv.Atoi(r.PathValue("sequence"))
count, err := NodesCount(user.UserID, uint64(changedFrom), user.ClientUUID)
if err != nil {
Log.Error("/sync/from_server/count", "error", err)
httpError(w, err)
return
}
j, _ := json.Marshal(struct {
OK bool
Count int
}{
true,
count,
})
w.Write(j)
} // }}}
func actionNodeRetrieve(w http.ResponseWriter, r *http.Request) { // {{{
user := getUserSession(r)
var err error
uuid := r.PathValue("uuid")
node, err := RetrieveNode(user.UserID, uuid)
if err != nil {
responseError(w, err)
return
}
responseData(w, map[string]interface{}{
"OK": true,
"Node": node,
})
} // }}}
func actionNodeHistoryRetrieve(w http.ResponseWriter, r *http.Request) { // {{{
user := getUserSession(r)
var err error
uuid := r.PathValue("uuid")
offset, err := strconv.Atoi(r.PathValue("offset"))
if err != nil {
responseError(w, err)
return
}
nodes, hasMore, err := RetrieveNodeHistory(user.UserID, uuid, offset)
if err != nil {
responseError(w, err)
return
}
responseData(w, map[string]any{
"OK": true,
"Nodes": nodes,
"HasMore": hasMore,
})
} // }}}
func actionNodeHistoryCount(w http.ResponseWriter, r *http.Request) { // {{{
user := getUserSession(r)
var err error
uuid := r.PathValue("uuid")
count, err := RetrieveNodeHistoryCount(user.UserID, uuid)
if err != nil {
responseError(w, err)
return
}
responseData(w, map[string]any{
"OK": true,
"Count": count,
})
} // }}}
func actionSyncToServer(w http.ResponseWriter, r *http.Request) { // {{{
user := getUserSession(r)
body, _ := io.ReadAll(r.Body)
var request struct {
NodeData string
}
err := json.Unmarshal(body, &request)
if err != nil {
httpError(w, err)
return
}
_, err = db.Exec(`CALL add_nodes($1, $2::uuid, $3::jsonb)`, user.UserID, user.ClientUUID, request.NodeData)
if err != nil {
Log.Error("sync", "error", err, "user_id", user.UserID, "client_uuid", user.ClientUUID, "node_data", request.NodeData)
httpError(w, err)
return
}
responseData(w, map[string]any{
"OK": true,
})
} // }}}
func actionSyncFileUpload(w http.ResponseWriter, r *http.Request) { // {{{
uuid := r.PathValue("uuid")
session := getUserSession(r)
sentBytes, _ := strconv.Atoi(r.Header.Get("X-File-Sent-Bytes"))
totalBytes, _ := strconv.Atoi(r.Header.Get("X-File-Total-Bytes"))
// First block comes with file metadata.
file := File{}
if sentBytes == 0 {
file.UUID = uuid
file.Name = r.Header.Get("X-File-Name")
file.Size, _ = strconv.Atoi(r.Header.Get("X-File-Size"))
file.Type = r.Header.Get("X-File-Type")
modded, _ := strconv.ParseInt(r.Header.Get("X-File-Modified"), 10, 64)
file.Modified = time.UnixMilli(modded)
err := AddFileToDb(session.UserID, &file)
if err != nil {
Log.Error("upload", "op", "AddFileToDB", "error", err)
httpError(w, err)
return
}
fileUUIDCache[uuid] = file.ID
}
data, _ := io.ReadAll(r.Body)
var err error
switch sentBytes {
case 0:
// First block of data.
Log.Info("file_upload", "status", "new", "user", session.UserID, "uuid", uuid, "total", totalBytes)
id := fileUUIDCache[uuid]
err = FileWriteData(session.UserID, id, data, true)
case totalBytes:
// Last block of data - sent with 0 length data.
Log.Info("file_upload", "status", "done", "user", session.UserID, "uuid", uuid, "total", totalBytes)
id := fileUUIDCache[uuid]
delete(fileUUIDCache, uuid)
_, err = db.Exec(`UPDATE public.file SET upload_complete = true WHERE id = $1`, id)
default:
id := fileUUIDCache[uuid]
err = FileWriteData(session.UserID, id, data, false)
}
if err != nil {
Log.Error("upload", "error", err)
httpError(w, err)
return
}
responseData(w, map[string]any{
"OK": true,
})
} // }}}
func actionUserGetPreferences(w http.ResponseWriter, r *http.Request) { // {{{
user := getUserSession(r)
prefs, err := user.Preferences()
if err != nil {
httpError(w, err)
return
}
responseData(w, map[string]any{
"OK": true,
"Preferences": prefs,
})
} // }}}
func actionUserSetPreferences(w http.ResponseWriter, r *http.Request) { // {{{
session := getUserSession(r)
// Verify the "default" profile is still there.
var newPrefs map[string]appUser.UserPreferences
body, _ := io.ReadAll(r.Body)
err := json.Unmarshal(body, &newPrefs)
if err != nil {
httpError(w, err)
return
}
if _, found := newPrefs["default"]; !found {
httpError(w, fmt.Errorf("'default' profile missing."))
return
}
err = session.SetPreferences(newPrefs)
if err != nil {
httpError(w, err)
return
}
responseData(w, map[string]any{
"OK": true,
})
} // }}}
func actionFileCount(w http.ResponseWriter, r *http.Request) { // {{{
session := getUserSession(r)
count, err := FileCountForUser(session.UserID)
if err != nil {
Log.Error("file_count", "error", err)
httpError(w, err)
return
}
responseData(w, map[string]any{
"OK": true,
"Count": count,
})
} // }}}
func actionFileServerUUIDs(w http.ResponseWriter, r *http.Request) { // {{{
session := getUserSession(r)
offset, _ := strconv.Atoi(r.PathValue("offset"))
uuids, more, err := FileServerUUIDsForUser(session.UserID, offset)
if err != nil {
Log.Error("file_server_uuids", "error", err)
httpError(w, err)
return
}
responseData(w, map[string]any{
"OK": true,
"UUIDs": uuids,
"MoreRowsExist": more,
})
} // }}}
func actionFile(w http.ResponseWriter, r *http.Request) { // {{{
uuid := r.PathValue("uuid")
session := getUserSession(r)
f, md, err := FileForUser(session.UserID, uuid)
if err != nil {
Log.Error("file_metadata", "error", err)
httpError(w, err)
return
}
w.Header().Add("X-File-Name", md.Name)
w.Header().Add("X-File-Modified", fmt.Sprintf("%d", md.Modified.UnixMilli()))
w.Header().Add("Content-Type", md.Type)
http.ServeContent(w, r, md.Name, md.Modified, f)
} // }}}
func actionFileMetadata(w http.ResponseWriter, r *http.Request) { // {{{
uuid := r.PathValue("uuid")
session := getUserSession(r)
md, err := FileMetadata(session.UserID, uuid)
if err != nil {
Log.Error("file_metadata", "error", err)
httpError(w, err)
return
}
responseData(w, map[string]any{
"OK": true,
"Metadata": md,
})
} // }}}
func createNewUser(username string) { // {{{
reader := bufio.NewReader(os.Stdin)
fmt.Print("\nPassword: ")
pwd, _ := reader.ReadString('\n')
pwd = strings.Trim(pwd, "\r\n")
fmt.Print("Name: ")
name, _ := reader.ReadString('\n')
name = strings.Trim(name, "\r\n")
alreadyExists, err := AuthManager.CreateUser(username, pwd, name)
if alreadyExists {
fmt.Printf("\nUser '%s' already exists\n", username)
return
}
if err != nil {
Log.Error("create_user", "error", err)
return
}
fmt.Printf("\nUser '%s' with username '%s' is created.\n", name, username)
} // }}}
func changePassword(username string) { // {{{
reader := bufio.NewReader(os.Stdin)
fmt.Print("\nPassword: ")
newPwd, _ := reader.ReadString('\n')
newPwd = strings.Trim(newPwd, "\r\n")
hasChanged, err := AuthManager.ChangePassword(username, "", newPwd, true)
if !hasChanged {
fmt.Printf("Invalid user\n")
return
}
if err != nil {
Log.Error("change_password", "error", err)
return
}
fmt.Printf("\nPassword changed\n")
} // }}}
func getUserSession(r *http.Request) appUser.UserSession { // {{{
user, _ := r.Context().Value(CONTEXT_USER).(appUser.UserSession)
user.Db = db
return user
} // }}}