package main import ( // External "git.gibonuddevalla.se/go/webservice" "git.gibonuddevalla.se/go/wrappederror" // Internal "git.gibonuddevalla.se/go/webservice/session" "notes/notification" // Standard "crypto/md5" "embed" "encoding/hex" "encoding/json" "flag" "fmt" "io" "log/slog" "net/http" "os" "path/filepath" "strconv" "strings" "time" ) const LISTEN_HOST = "0.0.0.0" var ( flagPort int flagVersion bool flagCreateUser bool flagCheckLocal bool flagConfig string service *webservice.Service connectionManager ConnectionManager notificationManager notification.Manager static http.Handler config Config logger *slog.Logger schedulers map[int]Schedule VERSION string //go:embed version sql/* embeddedSQL embed.FS //go:embed static staticFS embed.FS ) func sqlProvider(dbname string, version int) (sql []byte, found bool) { // {{{ var err error sql, err = embeddedSQL.ReadFile(fmt.Sprintf("sql/%05d.sql", version)) if err != nil { return } found = true return } // }}} func logCallback(e WrappedError.Error) { // {{{ now := time.Now() out := struct { Year int Month int Day int Time string Error any }{now.Year(), int(now.Month()), now.Day(), now.Format("15:04:05"), e} j, _ := json.Marshal(out) file, err := os.OpenFile("/tmp/notes.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600) if err != nil { logger.Error("log", "error", err) return } file.Write(j) file.Write([]byte("\n")) file.Close() } // }}} func init() { // {{{ version, _ := embeddedSQL.ReadFile("version") VERSION = strings.TrimSpace(string(version)) opt := slog.HandlerOptions{} opt.Level = slog.LevelDebug logger = slog.New(slog.NewJSONHandler(os.Stdout, &opt)) schedulers = make(map[int]Schedule, 512) configFilename := os.Getenv("HOME") + "/.config/notes.yaml" flag.IntVar(&flagPort, "port", 1371, "TCP port to listen on") flag.BoolVar(&flagVersion, "version", false, "Shows Notes version and exists") flag.BoolVar(&flagCreateUser, "createuser", false, "Create a user and exit") flag.BoolVar(&flagCheckLocal, "checklocal", false, "Check for local static file before embedded") flag.StringVar(&flagConfig, "config", configFilename, "Filename of configuration file") flag.Parse() } // }}} func main() { // {{{ WrappedError.Init() WrappedError.SetLogCallback(logCallback) var err error if flagVersion { fmt.Printf("%s\n", VERSION) os.Exit(0) } logger.Info("application", "version", VERSION) config, err = ConfigRead(flagConfig) if err != nil { logger.Error("application", "error", err) os.Exit(1) } service, err = webservice.New(flagConfig, VERSION, logger) if err != nil { logger.Error("application", "error", err) os.Exit(1) } service.SetDatabase(sqlProvider) service.SetStaticDirectory("static", true) service.SetStaticFS(staticFS, "static") service.Register("/node/upload", true, true, nodeUpload) service.Register("/node/tree", true, true, nodeTree) service.Register("/node/retrieve", true, true, nodeRetrieve) service.Register("/node/create", true, true, nodeCreate) service.Register("/node/update", true, true, nodeUpdate) service.Register("/node/rename", true, true, nodeRename) service.Register("/node/delete", true, true, nodeDelete) service.Register("/node/download", true, true, nodeDownload) service.Register("/node/search", true, true, nodeSearch) service.Register("/node/checklist_group/add", true, true, nodeChecklistGroupAdd) service.Register("/node/checklist_group/item_add", true, true, nodeChecklistGroupItemAdd) service.Register("/node/checklist_group/label", true, true, nodeChecklistGroupLabel) service.Register("/node/checklist_group/delete", true, true, nodeChecklistGroupDelete) service.Register("/node/checklist_item/state", true, true, nodeChecklistItemState) service.Register("/node/checklist_item/label", true, true, nodeChecklistItemLabel) service.Register("/node/checklist_item/delete", true, true, nodeChecklistItemDelete) service.Register("/node/checklist_item/move", true, true, nodeChecklistItemMove) service.Register("/key/retrieve", true, true, keyRetrieve) service.Register("/key/create", true, true, keyCreate) service.Register("/key/counter", true, true, keyCounter) service.Register("/notification/ack", false, false, notificationAcknowledge) service.Register("/", false, false, service.StaticHandler) if flagCreateUser { service.CreateUserPrompt() os.Exit(0) } go scheduleHandler() if err = service.InitDatabaseConnection(); err != nil { logger.Error("application", "error", err) os.Exit(1) } err = InitNotificationManager() if err != nil { logger.Error("application", "error", err) os.Exit(1) } err = service.Start() if err != nil { logger.Error("webserver", "error", err) os.Exit(1) } } // }}} func scheduleHandler() { // {{{ // Wait for the approximate minute. //wait := 60000 - time.Now().Sub(time.Now().Truncate(time.Minute)).Milliseconds() //time.Sleep(time.Millisecond * time.Duration(wait)) tick := time.NewTicker(time.Minute) tick = time.NewTicker(time.Second*5) for { <-tick.C for _, event := range ExpiredSchedules() { notificationManager.Send( event.UserID, event.ScheduleUUID, []byte( fmt.Sprintf( "%s\n%s", event.Time.Format("2006-01-02 15:04"), event.Description, ), ), ) } } } // }}} func nodeTree(w http.ResponseWriter, r *http.Request, sess *session.T) { // {{{ logger.Info("webserver", "request", "/node/tree") var err error req := struct{ StartNodeID int }{} if err = parseRequest(r, &req); err != nil { responseError(w, err) return } nodes, err := NodeTree(sess.UserID, req.StartNodeID) if err != nil { responseError(w, err) return } responseData(w, map[string]interface{}{ "OK": true, "Nodes": nodes, }) } // }}} func nodeRetrieve(w http.ResponseWriter, r *http.Request, sess *session.T) { // {{{ logger.Info("webserver", "request", "/node/retrieve") var err error req := struct{ ID int }{} if err = parseRequest(r, &req); err != nil { responseError(w, err) return } node, err := RetrieveNode(sess.UserID, req.ID) if err != nil { responseError(w, err) return } responseData(w, map[string]interface{}{ "OK": true, "Node": node, }) } // }}} func nodeCreate(w http.ResponseWriter, r *http.Request, sess *session.T) { // {{{ logger.Info("webserver", "request", "/node/create") var err error req := struct { Name string ParentID int }{} if err = parseRequest(r, &req); err != nil { responseError(w, err) return } node, err := CreateNode(sess.UserID, req.ParentID, req.Name) if err != nil { responseError(w, err) return } responseData(w, map[string]interface{}{ "OK": true, "Node": node, }) } // }}} func nodeUpdate(w http.ResponseWriter, r *http.Request, sess *session.T) { // {{{ logger.Info("webserver", "request", "/node/update") var err error req := struct { NodeID int Content string CryptoKeyID int Markdown bool }{} if err = parseRequest(r, &req); err != nil { responseError(w, err) return } err = UpdateNode(sess.UserID, req.NodeID, req.Content, req.CryptoKeyID, req.Markdown) if err != nil { responseError(w, err) return } responseData(w, map[string]interface{}{ "OK": true, }) } // }}} func nodeRename(w http.ResponseWriter, r *http.Request, sess *session.T) { // {{{ var err error logger.Info("webserver", "request", "/node/rename") req := struct { NodeID int Name string }{} if err = parseRequest(r, &req); err != nil { responseError(w, err) return } err = RenameNode(sess.UserID, req.NodeID, req.Name) if err != nil { responseError(w, err) return } responseData(w, map[string]interface{}{ "OK": true, }) } // }}} func nodeDelete(w http.ResponseWriter, r *http.Request, sess *session.T) { // {{{ var err error logger.Info("webserver", "request", "/node/delete") req := struct { NodeID int }{} if err = parseRequest(r, &req); err != nil { responseError(w, err) return } err = DeleteNode(sess.UserID, req.NodeID) if err != nil { responseError(w, err) return } responseData(w, map[string]interface{}{ "OK": true, }) } // }}} func nodeUpload(w http.ResponseWriter, r *http.Request, sess *session.T) { // {{{ logger.Info("webserver", "request", "/node/upload") var err error // Parse our multipart form, 10 << 20 specifies a maximum // upload of 10 MB files. r.Body = http.MaxBytesReader(w, r.Body, 128<<20+512) r.ParseMultipartForm(128 << 20) // FormFile returns the first file for the given key `myFile` // it also returns the FileHeader so we can get the Filename, // the Header and the size of the file file, handler, err := r.FormFile("file") if err != nil { responseError(w, err) return } defer file.Close() // Read metadata of file for database, and also file contents // for MD5, which is used to store the file on disk. fileBytes, err := io.ReadAll(file) if err != nil { responseError(w, err) return } md5sumBytes := md5.Sum(fileBytes) md5sum := hex.EncodeToString(md5sumBytes[:]) var nodeID int if nodeID, err = strconv.Atoi(r.PostFormValue("NodeID")); err != nil { responseError(w, err) return } nodeFile := File{ NodeID: nodeID, Filename: handler.Filename, Size: handler.Size, MIME: handler.Header.Get("Content-Type"), MD5: md5sum, } if err = AddFile(sess.UserID, &nodeFile); err != nil { responseError(w, err) return } // Files are stored in a directory structure composed of // the first three characters in the md5sum, which is statistically // distributed by design, making sure there aren't too many files in // a single directory. path := filepath.Join( config.Application.Directories.Upload, md5sum[0:1], md5sum[1:2], md5sum[2:3], ) if err = os.MkdirAll(path, 0755); err != nil { responseError(w, err) return } path = filepath.Join( config.Application.Directories.Upload, md5sum[0:1], md5sum[1:2], md5sum[2:3], md5sum, ) if err = os.WriteFile(path, fileBytes, 0644); err != nil { responseError(w, err) return } responseData(w, map[string]interface{}{ "OK": true, "File": nodeFile, }) } // }}} func nodeDownload(w http.ResponseWriter, r *http.Request, sess *session.T) { // {{{ logger.Info("webserver", "request", "/node/download") var err error var files []File req := struct { NodeID int FileID int }{} if err = parseRequest(r, &req); err != nil { responseError(w, err) return } files, err = Files(sess.UserID, req.NodeID, req.FileID) if err != nil { responseError(w, err) return } if len(files) != 1 { responseError(w, fmt.Errorf("File not found")) return } var file *os.File fname := filepath.Join( config.Application.Directories.Upload, files[0].MD5[0:1], files[0].MD5[1:2], files[0].MD5[2:3], files[0].MD5, ) file, err = os.Open(fname) if err != nil { responseError(w, err) return } var finfo os.FileInfo finfo, err = file.Stat() if err != nil { responseError(w, err) return } w.Header().Add("Content-Type", files[0].MIME) w.Header().Add("Content-Disposition", fmt.Sprintf(`inline; filename="%s"`, files[0].Filename)) w.Header().Add("Content-Length", strconv.Itoa(int(finfo.Size()))) read := 1 var buf []byte for read > 0 { buf = make([]byte, 65536) read, err = file.Read(buf) if read > 0 { w.Write(buf[0:read]) } } } // }}} func nodeFiles(w http.ResponseWriter, r *http.Request, sess *session.T) { // {{{ logger.Info("webserver", "request", "/node/files") var err error var files []File req := struct { NodeID int }{} if err = parseRequest(r, &req); err != nil { responseError(w, err) return } files, err = Files(sess.UserID, req.NodeID, 0) if err != nil { responseError(w, err) return } responseData(w, map[string]interface{}{ "OK": true, "Files": files, }) } // }}} func nodeSearch(w http.ResponseWriter, r *http.Request, sess *session.T) { // {{{ logger.Info("webserver", "request", "/node/search") var err error var nodes []Node req := struct { Search string }{} if err = parseRequest(r, &req); err != nil { responseError(w, err) return } nodes, err = SearchNodes(sess.UserID, req.Search) if err != nil { responseError(w, err) return } responseData(w, map[string]interface{}{ "OK": true, "Nodes": nodes, }) } // }}} func nodeChecklistGroupAdd(w http.ResponseWriter, r *http.Request, sess *session.T) { // {{{ var err error req := struct { NodeID int Label string }{} if err = parseRequest(r, &req); err != nil { responseError(w, err) return } var group ChecklistGroup group, err = ChecklistGroupAdd(sess.UserID, req.NodeID, req.Label) if err != nil { responseError(w, err) return } group.Items = []ChecklistItem{} responseData(w, map[string]interface{}{ "OK": true, "Group": group, }) } // }}} func nodeChecklistGroupItemAdd(w http.ResponseWriter, r *http.Request, sess *session.T) { // {{{ var err error req := struct { ChecklistGroupID int Label string }{} if err = parseRequest(r, &req); err != nil { responseError(w, err) return } var item ChecklistItem item, err = ChecklistGroupItemAdd(sess.UserID, req.ChecklistGroupID, req.Label) if err != nil { responseError(w, err) return } responseData(w, map[string]interface{}{ "OK": true, "Item": item, }) } // }}} func nodeChecklistGroupLabel(w http.ResponseWriter, r *http.Request, sess *session.T) { // {{{ var err error req := struct { ChecklistGroupID int Label string }{} if err = parseRequest(r, &req); err != nil { responseError(w, err) return } var item ChecklistItem item, err = ChecklistGroupLabel(sess.UserID, req.ChecklistGroupID, req.Label) if err != nil { responseError(w, err) return } responseData(w, map[string]interface{}{ "OK": true, "Item": item, }) } // }}} func nodeChecklistGroupDelete(w http.ResponseWriter, r *http.Request, sess *session.T) { // {{{ var err error req := struct { ChecklistGroupID int }{} if err = parseRequest(r, &req); err != nil { responseError(w, err) return } err = ChecklistGroupDelete(sess.UserID, req.ChecklistGroupID) if err != nil { logger.Error("checklist", "error", err) responseError(w, err) return } responseData(w, map[string]interface{}{ "OK": true, }) } // }}} func nodeChecklistItemState(w http.ResponseWriter, r *http.Request, sess *session.T) { // {{{ var err error req := struct { ChecklistItemID int State bool }{} if err = parseRequest(r, &req); err != nil { responseError(w, err) return } err = ChecklistItemState(sess.UserID, req.ChecklistItemID, req.State) if err != nil { responseError(w, err) return } responseData(w, map[string]interface{}{ "OK": true, }) } // }}} func nodeChecklistItemLabel(w http.ResponseWriter, r *http.Request, sess *session.T) { // {{{ var err error req := struct { ChecklistItemID int Label string }{} if err = parseRequest(r, &req); err != nil { responseError(w, err) return } err = ChecklistItemLabel(sess.UserID, req.ChecklistItemID, req.Label) if err != nil { responseError(w, err) return } responseData(w, map[string]interface{}{ "OK": true, }) } // }}} func nodeChecklistItemDelete(w http.ResponseWriter, r *http.Request, sess *session.T) { // {{{ var err error req := struct { ChecklistItemID int }{} if err = parseRequest(r, &req); err != nil { responseError(w, err) return } err = ChecklistItemDelete(sess.UserID, req.ChecklistItemID) if err != nil { logger.Error("checklist", "error", err) responseError(w, err) return } responseData(w, map[string]interface{}{ "OK": true, }) } // }}} func nodeChecklistItemMove(w http.ResponseWriter, r *http.Request, sess *session.T) { // {{{ var err error req := struct { ChecklistItemID int AfterItemID int }{} if err = parseRequest(r, &req); err != nil { responseError(w, err) return } err = ChecklistItemMove(sess.UserID, req.ChecklistItemID, req.AfterItemID) if err != nil { logger.Error("checklist", "error", err) responseError(w, err) return } responseData(w, map[string]interface{}{ "OK": true, }) } // }}} func keyRetrieve(w http.ResponseWriter, r *http.Request, sess *session.T) { // {{{ logger.Info("webserver", "request", "/key/retrieve") var err error keys, err := Keys(sess.UserID) if err != nil { responseError(w, err) return } responseData(w, map[string]interface{}{ "OK": true, "Keys": keys, }) } // }}} func keyCreate(w http.ResponseWriter, r *http.Request, sess *session.T) { // {{{ logger.Info("webserver", "request", "/key/create") var err error req := struct { Description string Key string }{} if err = parseRequest(r, &req); err != nil { responseError(w, err) return } key, err := KeyCreate(sess.UserID, req.Description, req.Key) if err != nil { responseError(w, err) return } responseData(w, map[string]interface{}{ "OK": true, "Key": key, }) } // }}} func keyCounter(w http.ResponseWriter, r *http.Request, sess *session.T) { // {{{ logger.Info("webserver", "request", "/key/counter") var err error counter, err := KeyCounter() if err != nil { responseError(w, err) return } responseData(w, map[string]interface{}{ "OK": true, // Javascript uses int32, thus getting a string counter for Javascript BigInt to parse. "Counter": strconv.FormatInt(counter, 10), }) } // }}} func notificationAcknowledge(w http.ResponseWriter, r *http.Request, sess *session.T) { // {{{ logger.Info("webserver", "request", "/notification/ack") var err error err = AcknowledgeNotification(r.URL.Query().Get("uuid")) if err != nil { responseError(w, err) return } responseData(w, map[string]interface{}{ "OK": true, }) } // }}} // vim: foldmethod=marker