package notesium import ( "bytes" "encoding/json" "fmt" "io" "net/http" "os" "path/filepath" "strings" "time" ) type NoteResponse struct { Note Path string `json:"Path"` Content string `json:"Content"` } type NotePost struct { Content string `json:"Content"` Ctime time.Time `json:"Ctime"` } type NotePatch struct { Content string `json:"Content"` LastMtime time.Time `json:"LastMtime"` } type NoteDelete struct { LastMtime time.Time `json:"LastMtime"` } type ErrorResponse struct { Error string `json:"Error"` Code int `json:"Code"` } func respondWithError(w http.ResponseWriter, errMsg string, statusCode int) { errorResponse := ErrorResponse{Error: errMsg, Code: statusCode} errorJSON, _ := json.Marshal(errorResponse) w.Header().Set("Content-Type", "application/json") w.WriteHeader(statusCode) w.Write(errorJSON) } func apiHeartbeat(w http.ResponseWriter, r *http.Request) { w.Write([]byte("Heartbeat received.")) } func apiList(w http.ResponseWriter, r *http.Request) { jsonResponse, err := json.Marshal(noteCache) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") w.Write(jsonResponse) } func apiNote(dir string, w http.ResponseWriter, r *http.Request, readOnly bool) { pathSegments := strings.Split(r.URL.Path, "/") var filename string if len(pathSegments) >= 4 { filename = pathSegments[3] } switch r.Method { case "GET": if filename == "" { respondWithError(w, "Filename not specified", http.StatusBadRequest) return } case "POST": if readOnly { respondWithError(w, "NOTESIUM_DIR is set to read-only mode", http.StatusForbidden) return } if filename != "" { respondWithError(w, "Filename should not be specified", http.StatusBadRequest) return } body, err := io.ReadAll(r.Body) if err != nil { respondWithError(w, err.Error(), http.StatusBadRequest) return } defer r.Body.Close() var notePost NotePost if err := json.Unmarshal(body, ¬ePost); err != nil { respondWithError(w, err.Error(), http.StatusBadRequest) return } if notePost.Ctime.IsZero() { respondWithError(w, "Ctime field is required", http.StatusBadRequest) return } epochInt := notePost.Ctime.Unix() epochHex := fmt.Sprintf("%x", epochInt) filename = fmt.Sprintf("%s.md", epochHex) path := filepath.Join(dir, filename) if _, err := os.Stat(path); err == nil { respondWithError(w, "File already exists", http.StatusConflict) return } if err := os.WriteFile(path, []byte(notePost.Content), 0644); err != nil { respondWithError(w, "Error writing file: "+err.Error(), http.StatusInternalServerError) return } noteCache = nil populateCache(dir) case "PATCH": if readOnly { respondWithError(w, "NOTESIUM_DIR is set to read-only mode", http.StatusForbidden) return } if filename == "" { respondWithError(w, "Filename not specified", http.StatusBadRequest) return } if _, ok := noteCache[filename]; !ok { respondWithError(w, "Note not found", http.StatusNotFound) return } body, err := io.ReadAll(r.Body) if err != nil { respondWithError(w, err.Error(), http.StatusBadRequest) return } defer r.Body.Close() var notePatch NotePatch if err := json.Unmarshal(body, ¬ePatch); err != nil { respondWithError(w, err.Error(), http.StatusBadRequest) return } if notePatch.Content == "" { respondWithError(w, "Content field is required", http.StatusBadRequest) return } if notePatch.LastMtime.IsZero() { respondWithError(w, "LastMtime field is required", http.StatusBadRequest) return } path := filepath.Join(dir, filename) info, err := os.Stat(path) if err != nil { if os.IsNotExist(err) { respondWithError(w, "File does not exist: "+err.Error(), http.StatusNotFound) } else { respondWithError(w, "Error accessing file: "+err.Error(), http.StatusInternalServerError) } return } if !info.ModTime().Truncate(time.Second).UTC().Equal(notePatch.LastMtime.Truncate(time.Second).UTC()) { respondWithError(w, "Refusing to overwrite. File changed on disk.", http.StatusConflict) return } if err := os.WriteFile(path, []byte(notePatch.Content), 0644); err != nil { respondWithError(w, "Error writing file: "+err.Error(), http.StatusInternalServerError) return } noteCache = nil populateCache(dir) case "DELETE": if readOnly { respondWithError(w, "NOTESIUM_DIR is set to read-only mode", http.StatusForbidden) return } if filename == "" { respondWithError(w, "Filename not specified", http.StatusBadRequest) return } body, err := io.ReadAll(r.Body) if err != nil { respondWithError(w, err.Error(), http.StatusBadRequest) return } defer r.Body.Close() var noteDelete NoteDelete if err := json.Unmarshal(body, ¬eDelete); err != nil { respondWithError(w, err.Error(), http.StatusBadRequest) return } if noteDelete.LastMtime.IsZero() { respondWithError(w, "LastMtime field is required", http.StatusBadRequest) return } note, ok := noteCache[filename] if !ok { respondWithError(w, "Note not found", http.StatusNotFound) return } path := filepath.Join(dir, filename) info, err := os.Stat(path) if err != nil { if os.IsNotExist(err) { respondWithError(w, "File does not exist: "+err.Error(), http.StatusNotFound) } else { respondWithError(w, "Error accessing file: "+err.Error(), http.StatusInternalServerError) } return } if !info.ModTime().Truncate(time.Second).UTC().Equal(noteDelete.LastMtime.Truncate(time.Second).UTC()) { respondWithError(w, "Refusing to delete. File changed on disk.", http.StatusConflict) return } if len(note.IncomingLinks) > 0 { respondWithError(w, "Refusing to delete. Note has IncomingLinks.", http.StatusConflict) return } err = os.Remove(path) if err != nil { respondWithError(w, "Error deleting file: "+err.Error(), http.StatusInternalServerError) return } noteCache = nil populateCache(dir) response := map[string]any{ "Filename": filename, "Deleted": true, } jsonResponse, _ := json.Marshal(response) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) w.Write(jsonResponse) return default: respondWithError(w, "Method not supported", http.StatusMethodNotAllowed) } note, ok := noteCache[filename] if !ok { respondWithError(w, "Note not found", http.StatusNotFound) return } path := filepath.Join(dir, filename) content, err := os.ReadFile(path) if err != nil { respondWithError(w, err.Error(), http.StatusInternalServerError) return } noteResponse := NoteResponse{ Note: *note, Path: path, Content: string(content), } jsonResponse, err := json.Marshal(noteResponse) if err != nil { respondWithError(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") w.Write(jsonResponse) } func apiRuntime(dir string, w http.ResponseWriter, _ *http.Request, opts webOptions) { runtimeResponse := GetRuntimeInfo(dir, opts) jsonResponse, err := json.Marshal(runtimeResponse) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") w.Write(jsonResponse) } //////////////////////////////// experimental //////////////////////////////// type bufferedResponseWriter struct { buf bytes.Buffer w http.ResponseWriter } func (rw *bufferedResponseWriter) Write(p []byte) (n int, err error) { return rw.buf.Write(p) } func (rw *bufferedResponseWriter) Flush() { rw.w.Write(rw.buf.Bytes()) if f, ok := rw.w.(http.Flusher); ok { f.Flush() } } func apiRaw(dir string, w http.ResponseWriter, r *http.Request) { pathSegments := strings.Split(r.URL.Path, "/") if len(pathSegments) < 4 || pathSegments[3] == "" { http.Error(w, "no command specified", http.StatusNotFound) return } command := pathSegments[3] args := []string{command} queryParameters := r.URL.Query() for key, values := range queryParameters { for _, value := range values { if value == "true" { arg := fmt.Sprintf("--%s", key) args = append(args, arg) } else if value != "" && value != "false" { arg := fmt.Sprintf("--%s=%s", key, value) args = append(args, arg) } } } cmd, err := parseOptions(args) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } writer := &bufferedResponseWriter{w: w} defer writer.Flush() switch cmd.Name { case "new": notesiumNew(dir, cmd.Options.(newOptions), writer) case "list": notesiumList(dir, cmd.Options.(listOptions), writer) case "links": notesiumLinks(dir, cmd.Options.(linksOptions), writer) case "lines": notesiumLines(dir, cmd.Options.(linesOptions), writer) case "stats": notesiumStats(dir, cmd.Options.(statsOptions), writer) case "version": notesiumVersion(cmd.Options.(versionOptions), writer) default: http.Error(w, fmt.Sprintf("unrecognized command: %s", command), http.StatusBadRequest) return } }