345 lines
8.3 KiB
Go
345 lines
8.3 KiB
Go
package main
|
|
|
|
import (
|
|
// Internal
|
|
"notes2/authentication"
|
|
"notes2/html_template"
|
|
"os"
|
|
|
|
// Standard
|
|
"bufio"
|
|
"context"
|
|
"embed"
|
|
"encoding/json"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"path"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"text/template"
|
|
)
|
|
|
|
const VERSION = "v1"
|
|
const CONTEXT_USER = 1
|
|
const SYNC_PAGINATION = 250
|
|
|
|
var (
|
|
FlagGenerate bool
|
|
FlagDev bool
|
|
FlagConfig string
|
|
FlagCreateUser string
|
|
FlagChangePassword string
|
|
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.StringVar(&FlagCreateUser, "create-user", "", "Username for creating a new user")
|
|
flag.StringVar(&FlagChangePassword, "change-password", "", "Change the password for the given username")
|
|
flag.Parse()
|
|
|
|
RxpBearerToken = regexp.MustCompile("(?i)^\\s*Bearer\\s+(.*?)\\s*$")
|
|
} // }}}
|
|
func initLog() { // {{{
|
|
opts := slog.HandlerOptions{}
|
|
opts.Level = slog.LevelDebug
|
|
Log = slog.New(slog.NewJSONHandler(os.Stdout, &opts))
|
|
} // }}}
|
|
func main() { // {{{
|
|
initLog()
|
|
err := readConfig()
|
|
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
|
|
}
|
|
|
|
// 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("/notes2", pageNotes2)
|
|
http.HandleFunc("/login", pageLogin)
|
|
http.HandleFunc("/sync", pageSync)
|
|
|
|
http.HandleFunc("/user/authenticate", AuthManager.AuthenticationHandler)
|
|
|
|
http.HandleFunc("/sync/node/{sequence}/{offset}", authenticated(actionSyncNode))
|
|
|
|
http.HandleFunc("/node/retrieve/{uuid}", authenticated(actionNodeRetrieve))
|
|
|
|
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")
|
|
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 := NewUser(claims)
|
|
r = r.WithContext(context.WithValue(r.Context(), CONTEXT_USER, user))
|
|
|
|
Log.Info("webserver", "op", "request", "method", r.Method, "url", r.URL.String(), "username", user.Username)
|
|
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 == "/" {
|
|
http.Redirect(w, r, "/notes2", http.StatusSeeOther)
|
|
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 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 pageNotes2(w http.ResponseWriter, r *http.Request) { // {{{
|
|
page := NewPage("notes2")
|
|
|
|
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 actionSyncNode(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.
|
|
request := struct {
|
|
ClientUUID string
|
|
}{}
|
|
body, _ := io.ReadAll(r.Body)
|
|
err := json.Unmarshal(body, &request)
|
|
if err != nil {
|
|
Log.Error("/node/tree", "error", err)
|
|
httpError(w, err)
|
|
return
|
|
}
|
|
|
|
user := getUser(r)
|
|
changedFrom, _ := strconv.Atoi(r.PathValue("sequence"))
|
|
offset, _ := strconv.Atoi(r.PathValue("offset"))
|
|
|
|
nodes, maxSeq, moreRowsExist, err := Nodes(user.ID, offset, uint64(changedFrom), request.ClientUUID)
|
|
if err != nil {
|
|
Log.Error("/node/tree", "error", err)
|
|
httpError(w, err)
|
|
return
|
|
}
|
|
|
|
Log.Debug("/node/tree", "num_nodes", len(nodes), "maxSeq", maxSeq)
|
|
foo, _ := json.Marshal(nodes)
|
|
os.WriteFile(fmt.Sprintf("/tmp/nodes-%d.json", offset), foo, 0644)
|
|
|
|
j, _ := json.Marshal(struct {
|
|
OK bool
|
|
Nodes []Node
|
|
MaxSeq uint64
|
|
Continue bool
|
|
}{true, nodes, maxSeq, moreRowsExist})
|
|
w.Write(j)
|
|
} // }}}
|
|
func actionNodeRetrieve(w http.ResponseWriter, r *http.Request) { // {{{
|
|
user := getUser(r)
|
|
var err error
|
|
|
|
uuid := r.PathValue("uuid")
|
|
node, err := RetrieveNode(user.ID, uuid)
|
|
if err != nil {
|
|
responseError(w, err)
|
|
return
|
|
}
|
|
|
|
responseData(w, map[string]interface{}{
|
|
"OK": true,
|
|
"Node": node,
|
|
})
|
|
} // }}}
|
|
|
|
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 getUser(r *http.Request) User { // {{{
|
|
user, _ := r.Context().Value(CONTEXT_USER).(User)
|
|
return user
|
|
} // }}}
|