From: PhiTux Date: Wed, 9 Jul 2025 17:47:09 +0000 (+0200) Subject: added go backend X-Git-Url: http://git.99rst.org/?a=commitdiff_plain;h=4e8e0f2c20d1ee61210a09dfe3c38367a5c678e6;p=DailyTxT.git added go backend --- diff --git a/backend/.gitignore b/backend-python/.gitignore similarity index 100% rename from backend/.gitignore rename to backend-python/.gitignore diff --git a/backend/requirements.txt b/backend-python/requirements.txt similarity index 100% rename from backend/requirements.txt rename to backend-python/requirements.txt diff --git a/backend/server/__init__.py b/backend-python/server/__init__.py similarity index 100% rename from backend/server/__init__.py rename to backend-python/server/__init__.py diff --git a/backend/server/main.py b/backend-python/server/main.py similarity index 100% rename from backend/server/main.py rename to backend-python/server/main.py diff --git a/backend/server/routers/__init__.py b/backend-python/server/routers/__init__.py similarity index 100% rename from backend/server/routers/__init__.py rename to backend-python/server/routers/__init__.py diff --git a/backend/server/routers/logs.py b/backend-python/server/routers/logs.py similarity index 100% rename from backend/server/routers/logs.py rename to backend-python/server/routers/logs.py diff --git a/backend/server/routers/users.py b/backend-python/server/routers/users.py similarity index 100% rename from backend/server/routers/users.py rename to backend-python/server/routers/users.py diff --git a/backend/server/utils/fileHandling.py b/backend-python/server/utils/fileHandling.py similarity index 100% rename from backend/server/utils/fileHandling.py rename to backend-python/server/utils/fileHandling.py diff --git a/backend/server/utils/security.py b/backend-python/server/utils/security.py similarity index 100% rename from backend/server/utils/security.py rename to backend-python/server/utils/security.py diff --git a/backend/server/utils/settings.py b/backend-python/server/utils/settings.py similarity index 100% rename from backend/server/utils/settings.py rename to backend-python/server/utils/settings.py diff --git a/backend/.envrc b/backend/.envrc new file mode 100644 index 0000000..67689f2 --- /dev/null +++ b/backend/.envrc @@ -0,0 +1,7 @@ +#export VIRTUAL_ENV=."venv" +#layout python python3.12 +export DATA_PATH=~/git/DailyTxT/backend/data +export SECRET_TOKEN=secret +export ALLOWED_HOSTS='["http://localhost:5173","http://127.0.0.1:5173","http://lab:5173"]' +export INDENT=4 +export DEVELOPMENT=true diff --git a/backend/backend b/backend/backend new file mode 100755 index 0000000..e3261cb Binary files /dev/null and b/backend/backend differ diff --git a/backend/data/1/2025/07.json b/backend/data/1/2025/07.json new file mode 100644 index 0000000..31e36ad --- /dev/null +++ b/backend/data/1/2025/07.json @@ -0,0 +1,26 @@ +{ + "days": [ + { + "date_written": "PQKE7k8vv4ywUxaSNmPTWELeu6AWlK7tCfVvmz9zAurW1n5Z1ikH0a82c3Cg", + "day": 9, + "history": [ + { + "date_written": "W4jLc1ViEbYH0VJkIcny2M8zIpK8dLqs_hSkM20Xh1FZR5sNrfRBMM67X0yl", + "text": "zFodQaGCYCSFznwtPpGOlp5Vm891UdhkWHC0RLIBnCM=", + "version": 1 + }, + { + "date_written": "0eQZARHYhhPgyJsMhyrUhrzBClx6_6ecnSQFCXLwJZJLz9sw4_Z6GM5olUtF", + "text": "q0maYDSZh1fxlJEsKm86IfJIPeeAj9lzg6uLohUyRfc8", + "version": 2 + }, + { + "date_written": "_tHe4SzxePVShjhgI56FOanKI-NzzH0ULLoX7aiEsFU3QRkwdVfgy8LVsnPk", + "text": "jxexvwudf9VtmEecZumS3CX-fJYb7QBNUYfpf651UaPT", + "version": 3 + } + ], + "text": "ixfArbVLC4AFBvakTv4rPKqY1-QR5UU0hTQqQA25Emc=" + } + ] +} diff --git a/backend/data/users.json b/backend/data/users.json new file mode 100644 index 0000000..d0ed21e --- /dev/null +++ b/backend/data/users.json @@ -0,0 +1 @@ +{"id_counter":1,"users":[{"dailytxt_version":2,"enc_enc_key":"F72a95Ro/OP1Oa0dwNtbFUrpw9o7f1X3P73fNwj+ZzYqPGj8j94D48ewYc+CZ4Db3NrtBipVRKOXrKyq","password":"SgmHrbBGbhaQ4ibSq4GNcD3PES46kg4pB0xqBZdAjB0=","salt":"dj9yX4A0r6U6zkG7Ln4LhA==","user_id":1,"username":"username"}]} diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000..d2211d1 --- /dev/null +++ b/backend/go.mod @@ -0,0 +1,10 @@ +module github.com/phitux/dailytxt/backend + +go 1.22 + +require ( + github.com/golang-jwt/jwt/v5 v5.2.0 + golang.org/x/crypto v0.19.0 +) + +require golang.org/x/sys v0.17.0 // indirect diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 0000000..b22b2a2 --- /dev/null +++ b/backend/go.sum @@ -0,0 +1,6 @@ +github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw= +github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +golang.org/x/crypto v0.19.0 h1:ENy+Az/9Y1vSrlrvBSyna3PITt4tiZLf7sgCjZBX7Wo= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= diff --git a/backend/handlers/additional.go b/backend/handlers/additional.go new file mode 100644 index 0000000..42da002 --- /dev/null +++ b/backend/handlers/additional.go @@ -0,0 +1,1327 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "strings" + + "github.com/phitux/dailytxt/backend/utils" +) + +// EditTagRequest represents the edit tag request +type EditTagRequest struct { + ID int `json:"id"` + Icon string `json:"icon"` + Name string `json:"name"` + Color string `json:"color"` +} + +// EditTag handles editing a tag +func EditTag(w http.ResponseWriter, r *http.Request) { + // Get user ID and derived key from context + userID, ok := r.Context().Value(utils.UserIDKey).(int) + if !ok { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + derivedKey, ok := r.Context().Value(utils.DerivedKeyKey).(string) + if !ok { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + // Parse request body + var req EditTagRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + // Get tags + content, err := utils.GetTags(userID) + if err != nil { + http.Error(w, fmt.Sprintf("Error retrieving tags: %v", err), http.StatusInternalServerError) + return + } + + // Check if tags exist + tags, ok := content["tags"].([]any) + if !ok { + http.Error(w, "Tag not found - json error", http.StatusInternalServerError) + return + } + + // Get encryption key + encKey, err := utils.GetEncryptionKey(userID, derivedKey) + if err != nil { + http.Error(w, fmt.Sprintf("Error getting encryption key: %v", err), http.StatusInternalServerError) + return + } + + // Find and update tag + found := false + for i, tagInterface := range tags { + tag, ok := tagInterface.(map[string]any) + if !ok { + continue + } + + if id, ok := tag["id"].(float64); ok && int(id) == req.ID { + // Encrypt tag data + encIcon, err := utils.EncryptText(req.Icon, encKey) + if err != nil { + http.Error(w, fmt.Sprintf("Error encrypting tag icon: %v", err), http.StatusInternalServerError) + return + } + + encName, err := utils.EncryptText(req.Name, encKey) + if err != nil { + http.Error(w, fmt.Sprintf("Error encrypting tag name: %v", err), http.StatusInternalServerError) + return + } + + encColor, err := utils.EncryptText(req.Color, encKey) + if err != nil { + http.Error(w, fmt.Sprintf("Error encrypting tag color: %v", err), http.StatusInternalServerError) + return + } + + // Update tag + tag["icon"] = encIcon + tag["name"] = encName + tag["color"] = encColor + tags[i] = tag + found = true + break + } + } + + if !found { + http.Error(w, "Tag not found - not in tags", http.StatusInternalServerError) + return + } + + // Write tags + if err := utils.WriteTags(userID, content); err != nil { + http.Error(w, fmt.Sprintf("Failed to write tag - error writing tags: %v", err), http.StatusInternalServerError) + return + } + + // Return success + utils.JSONResponse(w, http.StatusOK, map[string]bool{ + "success": true, + }) +} + +// DeleteTag handles deleting a tag +func DeleteTag(w http.ResponseWriter, r *http.Request) { + // Get user ID from context + userID, ok := r.Context().Value(utils.UserIDKey).(int) + if !ok { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + // Get tag ID + idStr := r.URL.Query().Get("id") + if idStr == "" { + http.Error(w, "Missing id parameter", http.StatusBadRequest) + return + } + id, err := strconv.Atoi(idStr) + if err != nil { + http.Error(w, "Invalid id parameter", http.StatusBadRequest) + return + } + + // Get all years and months + years, err := utils.GetYears(userID) + if err != nil { + http.Error(w, fmt.Sprintf("Error retrieving years: %v", err), http.StatusInternalServerError) + return + } + + // Remove tag from all logs + for _, year := range years { + yearInt, _ := strconv.Atoi(year) + months, err := utils.GetMonths(userID, year) + if err != nil { + continue + } + + for _, month := range months { + monthInt, _ := strconv.Atoi(month) + content, err := utils.GetMonth(userID, yearInt, monthInt) + if err != nil { + continue + } + + days, ok := content["days"].([]any) + if !ok { + continue + } + + // Check each day for the tag + modified := false + for i, dayInterface := range days { + day, ok := dayInterface.(map[string]any) + if !ok { + continue + } + + tags, ok := day["tags"].([]any) + if !ok { + continue + } + + // Find and remove the tag + for j, tagID := range tags { + if tagIDFloat, ok := tagID.(float64); ok && int(tagIDFloat) == id { + // Remove tag + tags = append(tags[:j], tags[j+1:]...) + day["tags"] = tags + days[i] = day + modified = true + break + } + } + } + + // Write updated month if modified + if modified { + content["days"] = days + if err := utils.WriteMonth(userID, yearInt, monthInt, content); err != nil { + http.Error(w, fmt.Sprintf("Failed to delete tag - error writing log: %v", err), http.StatusInternalServerError) + return + } + } + } + } + + // Get tags + content, err := utils.GetTags(userID) + if err != nil { + http.Error(w, fmt.Sprintf("Error retrieving tags: %v", err), http.StatusInternalServerError) + return + } + + // Check if tags exist + tags, ok := content["tags"].([]any) + if !ok { + http.Error(w, "Tag not found - json error", http.StatusInternalServerError) + return + } + + // Find and remove tag + found := false + for i, tagInterface := range tags { + tag, ok := tagInterface.(map[string]any) + if !ok { + continue + } + + if tagID, ok := tag["id"].(float64); ok && int(tagID) == id { + // Remove tag + tags = append(tags[:i], tags[i+1:]...) + content["tags"] = tags + found = true + break + } + } + + if !found { + http.Error(w, "Tag not found - not in tags", http.StatusInternalServerError) + return + } + + // Write tags + if err := utils.WriteTags(userID, content); err != nil { + http.Error(w, fmt.Sprintf("Failed to delete tag - error writing tags: %v", err), http.StatusInternalServerError) + return + } + + // Return success + utils.JSONResponse(w, http.StatusOK, map[string]bool{ + "success": true, + }) +} + +// TagLogRequest represents the tag log request +type TagLogRequest struct { + Day int `json:"day"` + Month int `json:"month"` + Year int `json:"year"` + TagID int `json:"tag_id"` +} + +// AddTagToLog handles adding a tag to a log +func AddTagToLog(w http.ResponseWriter, r *http.Request) { + // Get user ID from context + userID, ok := r.Context().Value(utils.UserIDKey).(int) + if !ok { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + // Parse request body + var req TagLogRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + // Get month data + content, err := utils.GetMonth(userID, req.Year, req.Month) + if err != nil { + http.Error(w, fmt.Sprintf("Error retrieving month data: %v", err), http.StatusInternalServerError) + return + } + + // Get or create days array + days, ok := content["days"].([]any) + if !ok { + days = []any{} + } + + // Find day + dayFound := false + for i, dayInterface := range days { + day, ok := dayInterface.(map[string]any) + if !ok { + continue + } + + dayNum, ok := day["day"].(float64) + if !ok || int(dayNum) != req.Day { + continue + } + + // Day found, add tag + dayFound = true + tags, ok := day["tags"].([]any) + if !ok { + tags = []any{} + } + + // Check if tag already exists + tagExists := false + for _, tagID := range tags { + if tagIDFloat, ok := tagID.(float64); ok && int(tagIDFloat) == req.TagID { + tagExists = true + break + } + } + + if !tagExists { + tags = append(tags, float64(req.TagID)) + day["tags"] = tags + days[i] = day + } + break + } + + if !dayFound { + // Create new day with tag + days = append(days, map[string]any{ + "day": req.Day, + "tags": []any{float64(req.TagID)}, + }) + } + + // Update days array + content["days"] = days + + // Write month data + if err := utils.WriteMonth(userID, req.Year, req.Month, content); err != nil { + http.Error(w, fmt.Sprintf("Failed to write tag - error writing log: %v", err), http.StatusInternalServerError) + return + } + + // Return success + utils.JSONResponse(w, http.StatusOK, map[string]bool{ + "success": true, + }) +} + +// RemoveTagFromLog handles removing a tag from a log +func RemoveTagFromLog(w http.ResponseWriter, r *http.Request) { + // Get user ID from context + userID, ok := r.Context().Value(utils.UserIDKey).(int) + if !ok { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + // Parse request body + var req TagLogRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + // Get month data + content, err := utils.GetMonth(userID, req.Year, req.Month) + if err != nil { + http.Error(w, fmt.Sprintf("Error retrieving month data: %v", err), http.StatusInternalServerError) + return + } + + // Check if days exist + days, ok := content["days"].([]any) + if !ok { + http.Error(w, "Day not found - json error", http.StatusInternalServerError) + return + } + + // Find day + found := false + for i, dayInterface := range days { + day, ok := dayInterface.(map[string]any) + if !ok { + continue + } + + dayNum, ok := day["day"].(float64) + if !ok || int(dayNum) != req.Day { + continue + } + + // Day found, check for tags + tags, ok := day["tags"].([]any) + if !ok { + http.Error(w, "Failed to remove tag - not found in log", http.StatusInternalServerError) + return + } + + // Find and remove tag + for j, tagID := range tags { + if tagIDFloat, ok := tagID.(float64); ok && int(tagIDFloat) == req.TagID { + // Remove tag + tags = append(tags[:j], tags[j+1:]...) + day["tags"] = tags + days[i] = day + found = true + break + } + } + + if !found { + http.Error(w, "Failed to remove tag - not found in log", http.StatusInternalServerError) + return + } + break + } + + if !found { + http.Error(w, "Failed to remove tag - not found in log", http.StatusInternalServerError) + return + } + + // Update days array + content["days"] = days + + // Write month data + if err := utils.WriteMonth(userID, req.Year, req.Month, content); err != nil { + http.Error(w, fmt.Sprintf("Failed to remove tag - error writing log: %v", err), http.StatusInternalServerError) + return + } + + // Return success + utils.JSONResponse(w, http.StatusOK, map[string]bool{ + "success": true, + }) +} + +// LoadMonthForReading handles loading a month for reading +func LoadMonthForReading(w http.ResponseWriter, r *http.Request) { + // Get user ID and derived key from context + userID, ok := r.Context().Value(utils.UserIDKey).(int) + if !ok { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + derivedKey, ok := r.Context().Value(utils.DerivedKeyKey).(string) + if !ok { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + // Get parameters from URL + monthStr := r.URL.Query().Get("month") + if monthStr == "" { + http.Error(w, "Missing month parameter", http.StatusBadRequest) + return + } + month, err := strconv.Atoi(monthStr) + if err != nil { + http.Error(w, "Invalid month parameter", http.StatusBadRequest) + return + } + + yearStr := r.URL.Query().Get("year") + if yearStr == "" { + http.Error(w, "Missing year parameter", http.StatusBadRequest) + return + } + year, err := strconv.Atoi(yearStr) + if err != nil { + http.Error(w, "Invalid year parameter", http.StatusBadRequest) + return + } + + // Get month data + content, err := utils.GetMonth(userID, year, month) + if err != nil { + http.Error(w, fmt.Sprintf("Error retrieving month data: %v", err), http.StatusInternalServerError) + return + } + + // Get encryption key + encKey, err := utils.GetEncryptionKey(userID, derivedKey) + if err != nil { + http.Error(w, fmt.Sprintf("Error getting encryption key: %v", err), http.StatusInternalServerError) + return + } + + // Check if days exist + days, ok := content["days"].([]any) + if !ok { + utils.JSONResponse(w, http.StatusOK, []any{}) + return + } + + // Process days + result := []any{} + for _, dayInterface := range days { + day, ok := dayInterface.(map[string]any) + if !ok { + continue + } + + dayNum, ok := day["day"].(float64) + if !ok { + continue + } + + // Create result day + resultDay := map[string]any{ + "day": int(dayNum), + } + + // Decrypt text and date_written + if text, ok := day["text"].(string); ok && text != "" { + decryptedText, err := utils.DecryptText(text, encKey) + if err != nil { + http.Error(w, fmt.Sprintf("Error decrypting text: %v", err), http.StatusInternalServerError) + return + } + resultDay["text"] = decryptedText + + if dateWritten, ok := day["date_written"].(string); ok && dateWritten != "" { + decryptedDate, err := utils.DecryptText(dateWritten, encKey) + if err != nil { + http.Error(w, fmt.Sprintf("Error decrypting date_written: %v", err), http.StatusInternalServerError) + return + } + resultDay["date_written"] = decryptedDate + } + } + + // Get tags + if tags, ok := day["tags"].([]any); ok && len(tags) > 0 { + resultDay["tags"] = tags + } + + // Decrypt filenames if files exist + if filesList, ok := day["files"].([]any); ok && len(filesList) > 0 { + files := []any{} + for _, fileInterface := range filesList { + file, ok := fileInterface.(map[string]any) + if !ok { + continue + } + + if encFilename, ok := file["enc_filename"].(string); ok { + decryptedFilename, err := utils.DecryptText(encFilename, encKey) + if err != nil { + http.Error(w, fmt.Sprintf("Error decrypting filename: %v", err), http.StatusInternalServerError) + return + } + fileCopy := make(map[string]any) + for k, v := range file { + fileCopy[k] = v + } + fileCopy["filename"] = decryptedFilename + files = append(files, fileCopy) + } + } + resultDay["files"] = files + } + + // Add day to result if it has content + if _, hasText := resultDay["text"]; hasText { + result = append(result, resultDay) + } else if _, hasFiles := resultDay["files"]; hasFiles { + result = append(result, resultDay) + } else if _, hasTags := resultDay["tags"]; hasTags { + result = append(result, resultDay) + } + } + + // Sort by day + /* + sort.Slice(result, func(i, j int) bool { + dayI := result[i].(map[string]any)["day"].(int) + dayJ := result[j].(map[string]any)["day"].(int) + return dayI < dayJ + }) + */ + + // Return result + utils.JSONResponse(w, http.StatusOK, result) +} + +// UploadFile handles uploading a file +func UploadFile(w http.ResponseWriter, r *http.Request) { + // Get user ID and derived key from context + userID, ok := r.Context().Value(utils.UserIDKey).(int) + if !ok { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + derivedKey, ok := r.Context().Value(utils.DerivedKeyKey).(string) + if !ok { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + // Parse form + if err := r.ParseMultipartForm(10 << 20); err != nil { // 10 MB max + http.Error(w, fmt.Sprintf("Error parsing form: %v", err), http.StatusBadRequest) + return + } + + // Get form values + dayStr := r.FormValue("day") + if dayStr == "" { + http.Error(w, "Missing day parameter", http.StatusBadRequest) + return + } + day, err := strconv.Atoi(dayStr) + if err != nil { + http.Error(w, "Invalid day parameter", http.StatusBadRequest) + return + } + + monthStr := r.FormValue("month") + if monthStr == "" { + http.Error(w, "Missing month parameter", http.StatusBadRequest) + return + } + month, err := strconv.Atoi(monthStr) + if err != nil { + http.Error(w, "Invalid month parameter", http.StatusBadRequest) + return + } + + yearStr := r.FormValue("year") + if yearStr == "" { + http.Error(w, "Missing year parameter", http.StatusBadRequest) + return + } + year, err := strconv.Atoi(yearStr) + if err != nil { + http.Error(w, "Invalid year parameter", http.StatusBadRequest) + return + } + + uuid := r.FormValue("uuid") + if uuid == "" { + http.Error(w, "Missing uuid parameter", http.StatusBadRequest) + return + } + + // Get file + file, header, err := r.FormFile("file") + if err != nil { + http.Error(w, fmt.Sprintf("Error getting file: %v", err), http.StatusBadRequest) + return + } + defer file.Close() + + // Read file + fileBytes, err := io.ReadAll(file) + if err != nil { + http.Error(w, fmt.Sprintf("Error reading file: %v", err), http.StatusInternalServerError) + return + } + + // Get encryption key + encKey, err := utils.GetEncryptionKey(userID, derivedKey) + if err != nil { + http.Error(w, fmt.Sprintf("Error getting encryption key: %v", err), http.StatusInternalServerError) + return + } + + // Encrypt file + encryptedFile, err := utils.EncryptFile(fileBytes, encKey) + if err != nil { + http.Error(w, fmt.Sprintf("Error encrypting file: %v", err), http.StatusInternalServerError) + return + } + + // Write file + if err := utils.WriteFile(encryptedFile, userID, uuid); err != nil { + http.Error(w, fmt.Sprintf("Error writing file: %v", err), http.StatusInternalServerError) + return + } + + // Get month data + content, err := utils.GetMonth(userID, year, month) + if err != nil { + http.Error(w, fmt.Sprintf("Error retrieving month data: %v", err), http.StatusInternalServerError) + return + } + + // Encrypt filename + encFilename, err := utils.EncryptText(header.Filename, encKey) + if err != nil { + http.Error(w, fmt.Sprintf("Error encrypting filename: %v", err), http.StatusInternalServerError) + return + } + + // Create new file entry + newFile := map[string]any{ + "enc_filename": encFilename, + "uuid_filename": uuid, + "size": header.Size, + } + + // Add file to day + days, ok := content["days"].([]any) + if !ok { + days = []any{} + } + + dayFound := false + for i, dayInterface := range days { + dayObj, ok := dayInterface.(map[string]any) + if !ok { + continue + } + + dayNum, ok := dayObj["day"].(float64) + if !ok || int(dayNum) != day { + continue + } + + // Add file to existing day + dayFound = true + files, ok := dayObj["files"].([]any) + if !ok { + files = []any{} + } + files = append(files, newFile) + dayObj["files"] = files + days[i] = dayObj + break + } + + if !dayFound { + // Create new day with file + days = append(days, map[string]any{ + "day": day, + "files": []any{newFile}, + }) + } + + // Update days array + content["days"] = days + + // Write month data + if err := utils.WriteMonth(userID, year, month, content); err != nil { + // Cleanup on error + utils.RemoveFile(userID, uuid) + http.Error(w, fmt.Sprintf("Error writing month data: %v", err), http.StatusInternalServerError) + return + } + + // Return success + utils.JSONResponse(w, http.StatusOK, map[string]bool{ + "success": true, + }) +} + +// DownloadFile handles downloading a file +func DownloadFile(w http.ResponseWriter, r *http.Request) { + // Get user ID and derived key from context + userID, ok := r.Context().Value(utils.UserIDKey).(int) + if !ok { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + derivedKey, ok := r.Context().Value(utils.DerivedKeyKey).(string) + if !ok { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + // Get uuid parameter + uuid := r.URL.Query().Get("uuid") + if uuid == "" { + http.Error(w, "Missing uuid parameter", http.StatusBadRequest) + return + } + + // Get encryption key + encKey, err := utils.GetEncryptionKey(userID, derivedKey) + if err != nil { + http.Error(w, fmt.Sprintf("Error getting encryption key: %v", err), http.StatusInternalServerError) + return + } + + // Read file + encryptedFile, err := utils.ReadFile(userID, uuid) + if err != nil { + http.Error(w, fmt.Sprintf("Error reading file: %v", err), http.StatusInternalServerError) + return + } + + // Decrypt file + decryptedFile, err := utils.DecryptFile(encryptedFile, encKey) + if err != nil { + http.Error(w, fmt.Sprintf("Error decrypting file: %v", err), http.StatusInternalServerError) + return + } + + // Set response headers for streaming + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("Content-Disposition", "attachment") + + // Write file to response + if _, err := w.Write(decryptedFile); err != nil { + http.Error(w, fmt.Sprintf("Error writing response: %v", err), http.StatusInternalServerError) + return + } +} + +// DeleteFile handles deleting a file +func DeleteFile(w http.ResponseWriter, r *http.Request) { + // Get user ID from context + userID, ok := r.Context().Value(utils.UserIDKey).(int) + if !ok { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + // Get parameters + uuid := r.URL.Query().Get("uuid") + if uuid == "" { + http.Error(w, "Missing uuid parameter", http.StatusBadRequest) + return + } + + dayStr := r.URL.Query().Get("day") + if dayStr == "" { + http.Error(w, "Missing day parameter", http.StatusBadRequest) + return + } + day, err := strconv.Atoi(dayStr) + if err != nil { + http.Error(w, "Invalid day parameter", http.StatusBadRequest) + return + } + + monthStr := r.URL.Query().Get("month") + if monthStr == "" { + http.Error(w, "Missing month parameter", http.StatusBadRequest) + return + } + month, err := strconv.Atoi(monthStr) + if err != nil { + http.Error(w, "Invalid month parameter", http.StatusBadRequest) + return + } + + yearStr := r.URL.Query().Get("year") + if yearStr == "" { + http.Error(w, "Missing year parameter", http.StatusBadRequest) + return + } + year, err := strconv.Atoi(yearStr) + if err != nil { + http.Error(w, "Invalid year parameter", http.StatusBadRequest) + return + } + + // Get month data + content, err := utils.GetMonth(userID, year, month) + if err != nil { + http.Error(w, fmt.Sprintf("Error retrieving month data: %v", err), http.StatusInternalServerError) + return + } + + // Check if days exist + days, ok := content["days"].([]any) + if !ok { + http.Error(w, "Day not found - json error", http.StatusInternalServerError) + return + } + + // Find day and file + fileFound := false + for i, dayInterface := range days { + dayObj, ok := dayInterface.(map[string]any) + if !ok { + continue + } + + dayNum, ok := dayObj["day"].(float64) + if !ok || int(dayNum) != day { + continue + } + + // Check for files + files, ok := dayObj["files"].([]any) + if !ok { + continue + } + + // Find file + for j, fileInterface := range files { + file, ok := fileInterface.(map[string]any) + if !ok { + continue + } + + uuidFilename, ok := file["uuid_filename"].(string) + if !ok || uuidFilename != uuid { + continue + } + + // Remove file from array + if err := utils.RemoveFile(userID, uuid); err != nil { + http.Error(w, fmt.Sprintf("Failed to delete file: %v", err), http.StatusInternalServerError) + return + } + + files = append(files[:j], files[j+1:]...) + dayObj["files"] = files + days[i] = dayObj + fileFound = true + break + } + + if fileFound { + break + } + } + + if !fileFound { + http.Error(w, "Failed to delete file - not found in log", http.StatusInternalServerError) + return + } + + // Update days array + content["days"] = days + + // Write month data + if err := utils.WriteMonth(userID, year, month, content); err != nil { + http.Error(w, fmt.Sprintf("Failed to write changes of deleted file: %v", err), http.StatusInternalServerError) + return + } + + // Return success + utils.JSONResponse(w, http.StatusOK, map[string]bool{ + "success": true, + }) +} + +// GetHistory handles retrieving log history +func GetHistory(w http.ResponseWriter, r *http.Request) { + // Get user ID and derived key from context + userID, ok := r.Context().Value(utils.UserIDKey).(int) + if !ok { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + derivedKey, ok := r.Context().Value(utils.DerivedKeyKey).(string) + if !ok { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + // Get parameters + dayStr := r.URL.Query().Get("day") + if dayStr == "" { + http.Error(w, "Missing day parameter", http.StatusBadRequest) + return + } + day, err := strconv.Atoi(dayStr) + if err != nil { + http.Error(w, "Invalid day parameter", http.StatusBadRequest) + return + } + + monthStr := r.URL.Query().Get("month") + if monthStr == "" { + http.Error(w, "Missing month parameter", http.StatusBadRequest) + return + } + month, err := strconv.Atoi(monthStr) + if err != nil { + http.Error(w, "Invalid month parameter", http.StatusBadRequest) + return + } + + yearStr := r.URL.Query().Get("year") + if yearStr == "" { + http.Error(w, "Missing year parameter", http.StatusBadRequest) + return + } + year, err := strconv.Atoi(yearStr) + if err != nil { + http.Error(w, "Invalid year parameter", http.StatusBadRequest) + return + } + + // Get encryption key + encKey, err := utils.GetEncryptionKey(userID, derivedKey) + if err != nil { + http.Error(w, fmt.Sprintf("Error getting encryption key: %v", err), http.StatusInternalServerError) + return + } + + // Get month data + content, err := utils.GetMonth(userID, year, month) + if err != nil { + http.Error(w, fmt.Sprintf("Error retrieving month data: %v", err), http.StatusInternalServerError) + return + } + + // Check if days exist + days, ok := content["days"].([]any) + if !ok { + utils.JSONResponse(w, http.StatusOK, []any{}) + return + } + + // Find day + for _, dayInterface := range days { + dayObj, ok := dayInterface.(map[string]any) + if !ok { + continue + } + + dayNum, ok := dayObj["day"].(float64) + if !ok || int(dayNum) != day { + continue + } + + // Check for history + history, ok := dayObj["history"].([]any) + if !ok || len(history) == 0 { + utils.JSONResponse(w, http.StatusOK, []any{}) + return + } + + // Decrypt history entries + result := []any{} + for _, historyInterface := range history { + historyEntry, ok := historyInterface.(map[string]any) + if !ok { + continue + } + + text, ok := historyEntry["text"].(string) + if !ok { + continue + } + + dateWritten, ok := historyEntry["date_written"].(string) + if !ok { + continue + } + + // Decrypt text and date + decryptedText, err := utils.DecryptText(text, encKey) + if err != nil { + http.Error(w, fmt.Sprintf("Error decrypting history text: %v", err), http.StatusInternalServerError) + return + } + + decryptedDate, err := utils.DecryptText(dateWritten, encKey) + if err != nil { + http.Error(w, fmt.Sprintf("Error decrypting history date: %v", err), http.StatusInternalServerError) + return + } + + result = append(result, map[string]any{ + "text": decryptedText, + "date_written": decryptedDate, + }) + } + + // Return history + utils.JSONResponse(w, http.StatusOK, result) + return + } + + // Day not found + utils.JSONResponse(w, http.StatusOK, []any{}) +} + +// BookmarkDay handles bookmarking a day +func BookmarkDay(w http.ResponseWriter, r *http.Request) { + // Get user ID from context + userID, ok := r.Context().Value(utils.UserIDKey).(int) + if !ok { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + // Get parameters + dayStr := r.URL.Query().Get("day") + if dayStr == "" { + http.Error(w, "Missing day parameter", http.StatusBadRequest) + return + } + day, err := strconv.Atoi(dayStr) + if err != nil { + http.Error(w, "Invalid day parameter", http.StatusBadRequest) + return + } + + monthStr := r.URL.Query().Get("month") + if monthStr == "" { + http.Error(w, "Missing month parameter", http.StatusBadRequest) + return + } + month, err := strconv.Atoi(monthStr) + if err != nil { + http.Error(w, "Invalid month parameter", http.StatusBadRequest) + return + } + + yearStr := r.URL.Query().Get("year") + if yearStr == "" { + http.Error(w, "Missing year parameter", http.StatusBadRequest) + return + } + year, err := strconv.Atoi(yearStr) + if err != nil { + http.Error(w, "Invalid year parameter", http.StatusBadRequest) + return + } + + // Get month data + content, err := utils.GetMonth(userID, year, month) + if err != nil { + http.Error(w, fmt.Sprintf("Error retrieving month data: %v", err), http.StatusInternalServerError) + return + } + + // Get or create days array + days, ok := content["days"].([]any) + if !ok { + days = []any{} + } + + // Find day + dayFound := false + bookmarked := true + for i, dayInterface := range days { + dayObj, ok := dayInterface.(map[string]any) + if !ok { + continue + } + + dayNum, ok := dayObj["day"].(float64) + if !ok || int(dayNum) != day { + continue + } + + // Day found, toggle bookmark + dayFound = true + if bookmark, ok := dayObj["bookmarked"].(bool); ok && bookmark { + dayObj["bookmarked"] = false + bookmarked = false + } else { + dayObj["bookmarked"] = true + } + days[i] = dayObj + break + } + + if !dayFound { + // Create new day with bookmark + days = append(days, map[string]any{ + "day": day, + "bookmarked": true, + }) + } + + // Update days array + content["days"] = days + + // Write month data + if err := utils.WriteMonth(userID, year, month, content); err != nil { + http.Error(w, fmt.Sprintf("Failed to bookmark day - error writing log: %v", err), http.StatusInternalServerError) + return + } + + // Return success + utils.JSONResponse(w, http.StatusOK, map[string]any{ + "success": true, + "bookmarked": bookmarked, + }) +} + +// SearchTag handles searching logs by tag +func SearchTag(w http.ResponseWriter, r *http.Request) { + // Get user ID and derived key from context + userID, ok := r.Context().Value(utils.UserIDKey).(int) + if !ok { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + derivedKey, ok := r.Context().Value(utils.DerivedKeyKey).(string) + if !ok { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + // Get parameters + tagIDStr := r.URL.Query().Get("tag_id") + if tagIDStr == "" { + http.Error(w, "Missing tag_id parameter", http.StatusBadRequest) + return + } + tagID, err := strconv.Atoi(tagIDStr) + if err != nil { + http.Error(w, "Invalid tag_id parameter", http.StatusBadRequest) + return + } + + // Get encryption key + encKey, err := utils.GetEncryptionKey(userID, derivedKey) + if err != nil { + http.Error(w, fmt.Sprintf("Error getting encryption key: %v", err), http.StatusInternalServerError) + return + } + + // Get all years and months + years, err := utils.GetYears(userID) + if err != nil { + http.Error(w, fmt.Sprintf("Error retrieving years: %v", err), http.StatusInternalServerError) + return + } + + // Search for tag + results := []any{} + for _, year := range years { + yearInt, _ := strconv.Atoi(year) + months, err := utils.GetMonths(userID, year) + if err != nil { + continue + } + + for _, month := range months { + monthInt, _ := strconv.Atoi(month) + content, err := utils.GetMonth(userID, yearInt, monthInt) + if err != nil { + continue + } + + days, ok := content["days"].([]any) + if !ok { + continue + } + + // Check each day for the tag + for _, dayInterface := range days { + day, ok := dayInterface.(map[string]any) + if !ok { + continue + } + + dayNum, ok := day["day"].(float64) + if !ok { + continue + } + + tags, ok := day["tags"].([]any) + if !ok { + continue + } + + // Check if tag is in tags + found := false + for _, t := range tags { + if tagIDFloat, ok := t.(float64); ok && int(tagIDFloat) == tagID { + found = true + break + } + } + + if !found { + continue + } + + // Get text snippet + context := "" + if text, ok := day["text"].(string); ok && text != "" { + decryptedText, err := utils.DecryptText(text, encKey) + if err != nil { + continue + } + // Get first few words + words := strings.Fields(decryptedText) + if len(words) > 5 { + context = strings.Join(words[:5], " ") + } else { + context = decryptedText + } + } + + // Add to results + results = append(results, map[string]any{ + "year": yearInt, + "month": monthInt, + "day": int(dayNum), + "text": context, + }) + } + } + } + + // Sort results by date + /* + sort.Slice(results, func(i, j int) bool { + ri := results[i].(map[string]any) + rj := results[j].(map[string]any) + + yearI := ri["year"].(int) + yearJ := rj["year"].(int) + if yearI != yearJ { + return yearI < yearJ + } + + monthI := ri["month"].(int) + monthJ := rj["month"].(int) + if monthI != monthJ { + return monthI < monthJ + } + + dayI := ri["day"].(int) + dayJ := rj["day"].(int) + return dayI < dayJ + }) + */ + + // Return results + utils.JSONResponse(w, http.StatusOK, results) +} diff --git a/backend/handlers/logs.go b/backend/handlers/logs.go new file mode 100644 index 0000000..1eda899 --- /dev/null +++ b/backend/handlers/logs.go @@ -0,0 +1,1105 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "html" + "net/http" + "os" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + + "github.com/phitux/dailytxt/backend/utils" +) + +// LogRequest represents the log request body +type LogRequest struct { + Day int `json:"day"` + Month int `json:"month"` + Year int `json:"year"` + Text string `json:"text"` + DateWritten string `json:"date_written"` +} + +// SaveLog handles saving a log entry +func SaveLog(w http.ResponseWriter, r *http.Request) { + // Get user ID and derived key from context + userID, ok := r.Context().Value(utils.UserIDKey).(int) + if !ok { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + derivedKey, ok := r.Context().Value(utils.DerivedKeyKey).(string) + if !ok { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + // Parse request body + var req LogRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + // Get month data + content, err := utils.GetMonth(userID, req.Year, req.Month) + if err != nil { + http.Error(w, fmt.Sprintf("Error retrieving month data: %v", err), http.StatusInternalServerError) + return + } + + // Check if there's a previous log to move to history + historyAvailable := false + days, ok := content["days"].([]any) + if ok { + for i, dayInterface := range days { + day, ok := dayInterface.(map[string]any) + if !ok { + continue + } + + dayNum, ok := day["day"].(float64) + if !ok || int(dayNum) != req.Day { + continue + } + + // If this day has text, move it to history + if text, ok := day["text"].(string); ok && text != "" { + historyAvailable = true + historyVersion := 0 + + // Get or create history array + var history []any + if historyArray, ok := day["history"].([]any); ok { + history = historyArray + // Find highest version + for _, historyItem := range history { + if historyMap, ok := historyItem.(map[string]any); ok { + if version, ok := historyMap["version"].(float64); ok && int(version) > historyVersion { + historyVersion = int(version) + } + } + } + } else { + history = []any{} + } + + historyVersion++ + history = append(history, map[string]any{ + "version": historyVersion, + "text": day["text"], + "date_written": day["date_written"], + }) + + day["history"] = history + days[i] = day + } + break + } + content["days"] = days + } + + // Get encryption key + encKey, err := utils.GetEncryptionKey(userID, derivedKey) + if err != nil { + http.Error(w, fmt.Sprintf("Error getting encryption key: %v", err), http.StatusInternalServerError) + return + } + + // Encrypt text and date_written + encryptedText, err := utils.EncryptText(req.Text, encKey) + if err != nil { + http.Error(w, fmt.Sprintf("Error encrypting text: %v", err), http.StatusInternalServerError) + return + } + + encryptedDateWritten, err := utils.EncryptText(html.EscapeString(req.DateWritten), encKey) + if err != nil { + http.Error(w, fmt.Sprintf("Error encrypting date_written: %v", err), http.StatusInternalServerError) + return + } + + // Save new log + found := false + if days, ok := content["days"].([]any); ok { + for i, dayInterface := range days { + day, ok := dayInterface.(map[string]any) + if !ok { + continue + } + + dayNum, ok := day["day"].(float64) + if !ok || int(dayNum) != req.Day { + continue + } + + // Update existing day + day["text"] = encryptedText + day["date_written"] = encryptedDateWritten + days[i] = day + found = true + break + } + + if !found { + // Add new day + days = append(days, map[string]any{ + "day": req.Day, + "text": encryptedText, + "date_written": encryptedDateWritten, + }) + } + + content["days"] = days + } else { + // Create new days array + content["days"] = []any{ + map[string]any{ + "day": req.Day, + "text": encryptedText, + "date_written": encryptedDateWritten, + }, + } + } + + // Write month data + if err := utils.WriteMonth(userID, req.Year, req.Month, content); err != nil { + http.Error(w, fmt.Sprintf("Error writing month data: %v", err), http.StatusInternalServerError) + return + } + + // Return success + utils.JSONResponse(w, http.StatusOK, map[string]any{ + "success": true, + "history_available": historyAvailable, + }) +} + +// GetLog handles retrieving a log entry +func GetLog(w http.ResponseWriter, r *http.Request) { + // Get user ID and derived key from context + userID, ok := r.Context().Value(utils.UserIDKey).(int) + if !ok { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + derivedKey, ok := r.Context().Value(utils.DerivedKeyKey).(string) + if !ok { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + // Get parameters from URL + year, err := strconv.Atoi(r.URL.Query().Get("year")) + if err != nil { + http.Error(w, "Invalid year parameter", http.StatusBadRequest) + return + } + month, err := strconv.Atoi(r.URL.Query().Get("month")) + if err != nil { + http.Error(w, "Invalid month parameter", http.StatusBadRequest) + return + } + dayValue, err := strconv.Atoi(r.URL.Query().Get("day")) + if err != nil { + http.Error(w, "Invalid day parameter", http.StatusBadRequest) + return + } + + // Get month data + content, err := utils.GetMonth(userID, year, month) + if err != nil { + http.Error(w, fmt.Sprintf("Error retrieving month data: %v", err), http.StatusInternalServerError) + return + } + + // Default empty response + dummy := map[string]any{ + "text": "", + "date_written": "", + "files": []any{}, + "tags": []any{}, + } + + // Check if days exist + days, ok := content["days"].([]any) + if !ok { + utils.JSONResponse(w, http.StatusOK, dummy) + return + } + + // Find the day + for _, dayInterface := range days { + day, ok := dayInterface.(map[string]any) + if !ok { + continue + } + + dayNum, ok := day["day"].(float64) + if !ok || int(dayNum) != dayValue { + continue + } + + // Get encryption key + encKey, err := utils.GetEncryptionKey(userID, derivedKey) + if err != nil { + http.Error(w, fmt.Sprintf("Error getting encryption key: %v", err), http.StatusInternalServerError) + return + } + + // Decrypt text and date_written + text := "" + dateWritten := "" + historyAvailable := false + + if encryptedText, ok := day["text"].(string); ok && encryptedText != "" { + decryptedText, err := utils.DecryptText(encryptedText, encKey) + if err != nil { + http.Error(w, fmt.Sprintf("Error decrypting text: %v", err), http.StatusInternalServerError) + return + } + text = decryptedText + } + + if encryptedDate, ok := day["date_written"].(string); ok && encryptedDate != "" { + decryptedDate, err := utils.DecryptText(encryptedDate, encKey) + if err != nil { + http.Error(w, fmt.Sprintf("Error decrypting date_written: %v", err), http.StatusInternalServerError) + return + } + dateWritten = decryptedDate + } + + // Check for history + if history, ok := day["history"].([]any); ok && len(history) > 0 { + historyAvailable = true + } + + // Decrypt filenames if files exist + files := []any{} + if filesList, ok := day["files"].([]any); ok { + for _, fileInterface := range filesList { + file, ok := fileInterface.(map[string]any) + if !ok { + continue + } + + if encFilename, ok := file["enc_filename"].(string); ok { + decryptedFilename, err := utils.DecryptText(encFilename, encKey) + if err != nil { + http.Error(w, fmt.Sprintf("Error decrypting filename: %v", err), http.StatusInternalServerError) + return + } + fileCopy := make(map[string]any) + for k, v := range file { + fileCopy[k] = v + } + fileCopy["filename"] = decryptedFilename + files = append(files, fileCopy) + } + } + } + + // Get tags + tags := []any{} + if tagsList, ok := day["tags"].([]any); ok { + tags = tagsList + } + + // Return log data + utils.JSONResponse(w, http.StatusOK, map[string]any{ + "text": text, + "date_written": dateWritten, + "files": files, + "tags": tags, + "history_available": historyAvailable, + }) + return + } + + // If day not found, return empty response + utils.JSONResponse(w, http.StatusOK, dummy) +} + +// GetMarkedDays handles retrieving a month's logs +func GetMarkedDays(w http.ResponseWriter, r *http.Request) { + // Get user ID and derived key from context + userID, ok := r.Context().Value(utils.UserIDKey).(int) + if !ok { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + // Get parameters from URL + year, err := strconv.Atoi(r.URL.Query().Get("year")) + if err != nil { + http.Error(w, "Invalid year parameter", http.StatusBadRequest) + return + } + month, err := strconv.Atoi(r.URL.Query().Get("month")) + if err != nil { + http.Error(w, "Invalid month parameter", http.StatusBadRequest) + return + } + + // Get month data + content, err := utils.GetMonth(userID, year, month) + if err != nil { + http.Error(w, fmt.Sprintf("Error retrieving month data: %v", err), http.StatusInternalServerError) + return + } + + // Extract days with logs, files, and bookmarks + daysWithLogs := []int{} + daysWithFiles := []int{} + daysBookmarked := []int{} + + if days, ok := content["days"].([]any); ok { + for _, dayInterface := range days { + day, ok := dayInterface.(map[string]any) + if !ok { + continue + } + + dayNum, ok := day["day"].(float64) + if !ok { + continue + } + + // Check for text + if _, ok := day["text"].(string); ok { + daysWithLogs = append(daysWithLogs, int(dayNum)) + } + + // Check for files + if files, ok := day["files"].([]any); ok && len(files) > 0 { + daysWithFiles = append(daysWithFiles, int(dayNum)) + } + + // Check if bookmarked + if bookmarked, ok := day["bookmarked"].(bool); ok && bookmarked { + daysBookmarked = append(daysBookmarked, int(dayNum)) + } + } + } + + // Return month data + utils.JSONResponse(w, http.StatusOK, map[string]any{ + "days_with_logs": daysWithLogs, + "days_with_files": daysWithFiles, + "days_bookmarked": daysBookmarked, + }) +} + +// GetTags handles retrieving a user's tags +func GetTags(w http.ResponseWriter, r *http.Request) { + // Get user ID and derived key from context + userID, ok := r.Context().Value(utils.UserIDKey).(int) + if !ok { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + derivedKey, ok := r.Context().Value(utils.DerivedKeyKey).(string) + if !ok { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + // Get tags + content, err := utils.GetTags(userID) + if err != nil { + http.Error(w, fmt.Sprintf("Error retrieving tags: %v", err), http.StatusInternalServerError) + return + } + + // If no tags, return empty array + if tags, ok := content["tags"].([]any); !ok || len(tags) == 0 { + utils.JSONResponse(w, http.StatusOK, []any{}) + return + } + + // Get encryption key + encKey, err := utils.GetEncryptionKey(userID, derivedKey) + if err != nil { + http.Error(w, fmt.Sprintf("Error getting encryption key: %v", err), http.StatusInternalServerError) + return + } + + // Decrypt tag data + tags := content["tags"].([]any) + result := []any{} + + for _, tagInterface := range tags { + tag, ok := tagInterface.(map[string]any) + if !ok { + continue + } + + // Decrypt icon, name, and color + if encIcon, ok := tag["icon"].(string); ok { + decryptedIcon, err := utils.DecryptText(encIcon, encKey) + if err != nil { + http.Error(w, fmt.Sprintf("Error decrypting tag icon: %v", err), http.StatusInternalServerError) + return + } + tag["icon"] = decryptedIcon + } + + if encName, ok := tag["name"].(string); ok { + decryptedName, err := utils.DecryptText(encName, encKey) + if err != nil { + http.Error(w, fmt.Sprintf("Error decrypting tag name: %v", err), http.StatusInternalServerError) + return + } + tag["name"] = decryptedName + } + + if encColor, ok := tag["color"].(string); ok { + decryptedColor, err := utils.DecryptText(encColor, encKey) + if err != nil { + http.Error(w, fmt.Sprintf("Error decrypting tag color: %v", err), http.StatusInternalServerError) + return + } + tag["color"] = decryptedColor + } + + result = append(result, tag) + } + + // Return tags + utils.JSONResponse(w, http.StatusOK, result) +} + +// TagRequest represents a tag request +type TagRequest struct { + Icon string `json:"icon"` + Name string `json:"name"` + Color string `json:"color"` +} + +// SaveTags handles saving a new tag +func SaveTags(w http.ResponseWriter, r *http.Request) { + // Get user ID and derived key from context + userID, ok := r.Context().Value(utils.UserIDKey).(int) + if !ok { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + derivedKey, ok := r.Context().Value(utils.DerivedKeyKey).(string) + if !ok { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + // Parse request body + var req TagRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + // Get tags + content, err := utils.GetTags(userID) + if err != nil { + http.Error(w, fmt.Sprintf("Error retrieving tags: %v", err), http.StatusInternalServerError) + return + } + + // Create tags array if it doesn't exist + if _, ok := content["tags"]; !ok { + content["tags"] = []any{} + } + if _, ok := content["next_id"]; !ok { + content["next_id"] = 1 + } + + // Get encryption key + encKey, err := utils.GetEncryptionKey(userID, derivedKey) + if err != nil { + http.Error(w, fmt.Sprintf("Error getting encryption key: %v", err), http.StatusInternalServerError) + return + } + + // Encrypt tag data + encIcon, err := utils.EncryptText(req.Icon, encKey) + if err != nil { + http.Error(w, fmt.Sprintf("Error encrypting tag icon: %v", err), http.StatusInternalServerError) + return + } + + encName, err := utils.EncryptText(req.Name, encKey) + if err != nil { + http.Error(w, fmt.Sprintf("Error encrypting tag name: %v", err), http.StatusInternalServerError) + return + } + + encColor, err := utils.EncryptText(req.Color, encKey) + if err != nil { + http.Error(w, fmt.Sprintf("Error encrypting tag color: %v", err), http.StatusInternalServerError) + return + } + + // Create new tag + nextID, ok := content["next_id"].(float64) + if !ok { + nextID = 1 + } + + newTag := map[string]any{ + "id": int(nextID), + "icon": encIcon, + "name": encName, + "color": encColor, + } + + // Add tag to tags array + tags, ok := content["tags"].([]any) + if !ok { + tags = []any{} + } + tags = append(tags, newTag) + content["tags"] = tags + content["next_id"] = nextID + 1 + + // Write tags + if err := utils.WriteTags(userID, content); err != nil { + http.Error(w, fmt.Sprintf("Error writing tags: %v", err), http.StatusInternalServerError) + return + } + + // Return success + utils.JSONResponse(w, http.StatusOK, map[string]bool{ + "success": true, + }) +} + +// TemplatesRequest represents a templates request +type TemplatesRequest struct { + Templates []struct { + Name string `json:"name"` + Text string `json:"text"` + } `json:"templates"` +} + +// GetTemplates handles retrieving a user's templates +func GetTemplates(w http.ResponseWriter, r *http.Request) { + // Get user ID and derived key from context + userID, ok := r.Context().Value(utils.UserIDKey).(int) + if !ok { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + derivedKey, ok := r.Context().Value(utils.DerivedKeyKey).(string) + if !ok { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + // Get templates + content, err := utils.GetTemplates(userID) + if err != nil { + http.Error(w, fmt.Sprintf("Error retrieving templates: %v", err), http.StatusInternalServerError) + return + } + + // If no templates, return empty array + if templates, ok := content["templates"].([]any); !ok || len(templates) == 0 { + utils.JSONResponse(w, http.StatusOK, []any{}) + return + } + + // Get encryption key + encKey, err := utils.GetEncryptionKey(userID, derivedKey) + if err != nil { + http.Error(w, fmt.Sprintf("Error getting encryption key: %v", err), http.StatusInternalServerError) + return + } + + // Decrypt template data + templates := content["templates"].([]any) + result := []any{} + + for _, templateInterface := range templates { + template, ok := templateInterface.(map[string]any) + if !ok { + continue + } + + // Decrypt name and text + if encName, ok := template["name"].(string); ok { + decryptedName, err := utils.DecryptText(encName, encKey) + if err != nil { + http.Error(w, fmt.Sprintf("Error decrypting template name: %v", err), http.StatusInternalServerError) + return + } + template["name"] = decryptedName + } + + if encText, ok := template["text"].(string); ok { + decryptedText, err := utils.DecryptText(encText, encKey) + if err != nil { + http.Error(w, fmt.Sprintf("Error decrypting template text: %v", err), http.StatusInternalServerError) + return + } + template["text"] = decryptedText + } + + result = append(result, template) + } + + // Return templates + utils.JSONResponse(w, http.StatusOK, result) +} + +// SaveTemplates handles saving templates +func SaveTemplates(w http.ResponseWriter, r *http.Request) { + // Get user ID and derived key from context + userID, ok := r.Context().Value(utils.UserIDKey).(int) + if !ok { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + derivedKey, ok := r.Context().Value(utils.DerivedKeyKey).(string) + if !ok { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + // Parse request body + var req TemplatesRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + // Get encryption key + encKey, err := utils.GetEncryptionKey(userID, derivedKey) + if err != nil { + http.Error(w, fmt.Sprintf("Error getting encryption key: %v", err), http.StatusInternalServerError) + return + } + + // Create new templates content + content := map[string]any{ + "templates": []any{}, + } + + // Encrypt template data + templates := []any{} + for _, template := range req.Templates { + encName, err := utils.EncryptText(template.Name, encKey) + if err != nil { + http.Error(w, fmt.Sprintf("Error encrypting template name: %v", err), http.StatusInternalServerError) + return + } + + encText, err := utils.EncryptText(template.Text, encKey) + if err != nil { + http.Error(w, fmt.Sprintf("Error encrypting template text: %v", err), http.StatusInternalServerError) + return + } + + templates = append(templates, map[string]any{ + "name": encName, + "text": encText, + }) + } + + content["templates"] = templates + + // Write templates + if err := utils.WriteTemplates(userID, content); err != nil { + http.Error(w, fmt.Sprintf("Error writing templates: %v", err), http.StatusInternalServerError) + return + } + + // Return success + utils.JSONResponse(w, http.StatusOK, map[string]bool{ + "success": true, + }) +} + +// GetOnThisDay handles retrieving logs from previous years on the same day +func GetOnThisDay(w http.ResponseWriter, r *http.Request) { + // Get user ID and derived key from context + userID, ok := r.Context().Value(utils.UserIDKey).(int) + if !ok { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + derivedKey, ok := r.Context().Value(utils.DerivedKeyKey).(string) + if !ok { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + // Get parameters from URL + month, err := strconv.Atoi(r.PathValue("month")) + if err != nil { + http.Error(w, "Invalid month parameter", http.StatusBadRequest) + return + } + day, err := strconv.Atoi(r.PathValue("day")) + if err != nil { + http.Error(w, "Invalid day parameter", http.StatusBadRequest) + return + } + + // Get query parameters + lastYears := r.URL.Query().Get("last_years") + if lastYears == "" { + http.Error(w, "Missing last_years parameter", http.StatusBadRequest) + return + } + + // Parse years + yearStr := strings.Split(lastYears, ",") + years := []int{} + currentYear, err := strconv.Atoi(r.URL.Query().Get("year")) + if err != nil { + http.Error(w, "Invalid year parameter", http.StatusBadRequest) + return + } + + for _, y := range yearStr { + if val, err := strconv.Atoi(y); err == nil { + years = append(years, currentYear-val) + } + } + + // Get encryption key + encKey, err := utils.GetEncryptionKey(userID, derivedKey) + if err != nil { + http.Error(w, fmt.Sprintf("Error getting encryption key: %v", err), http.StatusInternalServerError) + return + } + + // Get logs from previous years + results := []any{} + for _, year := range years { + content, err := utils.GetMonth(userID, year, month) + if err != nil { + continue + } + + days, ok := content["days"].([]any) + if !ok { + continue + } + + for _, dayInterface := range days { + dayLog, ok := dayInterface.(map[string]any) + if !ok { + continue + } + + dayNum, ok := dayLog["day"].(float64) + if !ok || int(dayNum) != day { + continue + } + + text, ok := dayLog["text"].(string) + if !ok || text == "" { + continue + } + + // Decrypt text + decryptedText, err := utils.DecryptText(text, encKey) + if err != nil { + continue + } + + results = append(results, map[string]any{ + "years_old": currentYear - year, + "day": day, + "month": month, + "year": year, + "text": decryptedText, + }) + break + } + } + + // Return results + utils.JSONResponse(w, http.StatusOK, results) +} + +// Helper functions for search +func getStartIndex(text string, index int) int { + if index == 0 { + return 0 + } + + for i := 0; i < 3; i++ { + startIndex := strings.LastIndex(text[:index-1], " ") + index = startIndex + if startIndex == -1 { + return 0 + } + } + + return index + 1 +} + +func getEndIndex(text string, index int) int { + if index == len(text)-1 { + return len(text) + } + + for i := 0; i < 3; i++ { + endIndex := strings.Index(text[index+1:], " ") + if endIndex == -1 { + return len(text) + } + index = index + 1 + endIndex + } + + return index +} + +func getContext(text, searchString string, exact bool) string { + // Replace whitespace with non-breaking space + re := regexp.MustCompile(`\s+`) + text = re.ReplaceAllString(text, " ") + + var pos int + if exact { + pos = strings.Index(text, searchString) + } else { + pos = strings.Index(strings.ToLower(text), strings.ToLower(searchString)) + } + + if pos == -1 { + return "Dailytxt: Error formatting..." + } + + start := getStartIndex(text, pos) + end := getEndIndex(text, pos+len(searchString)-1) + return text[start:pos] + "" + text[pos:pos+len(searchString)] + "" + text[pos+len(searchString):end] +} + +// Search handles searching logs for text +func Search(w http.ResponseWriter, r *http.Request) { + // Get user ID and derived key from context + userID, ok := r.Context().Value(utils.UserIDKey).(int) + if !ok { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + derivedKey, ok := r.Context().Value(utils.DerivedKeyKey).(string) + if !ok { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + // Get query parameter + searchString := r.URL.Query().Get("q") + if searchString == "" { + http.Error(w, "Missing search parameter", http.StatusBadRequest) + return + } + + // Get encryption key + encKey, err := utils.GetEncryptionKey(userID, derivedKey) + if err != nil { + http.Error(w, fmt.Sprintf("Error getting encryption key: %v", err), http.StatusInternalServerError) + return + } + + // Get user directory + userDir := filepath.Join(utils.Settings.DataPath, strconv.Itoa(userID)) + results := []any{} + + // Traverse all years and months + yearEntries, err := os.ReadDir(userDir) + if err != nil { + http.Error(w, fmt.Sprintf("Error reading user directory: %v", err), http.StatusInternalServerError) + return + } + + // Regex to match year directories (4 digits) + yearRegex := regexp.MustCompile(`^\d{4}$`) + + for _, yearEntry := range yearEntries { + if !yearEntry.IsDir() || !yearRegex.MatchString(yearEntry.Name()) { + continue + } + year := yearEntry.Name() + + // Read month files in year directory + yearDir := filepath.Join(userDir, year) + monthEntries, err := os.ReadDir(yearDir) + if err != nil { + continue + } + + // Regex to match month files (2 digits + .json) + monthRegex := regexp.MustCompile(`^(\d{2})\.json$`) + + for _, monthEntry := range monthEntries { + if monthEntry.IsDir() { + continue + } + + matches := monthRegex.FindStringSubmatch(monthEntry.Name()) + if len(matches) != 2 { + continue + } + month := matches[1] + + // Get month content + monthInt, _ := strconv.Atoi(month) + yearInt, _ := strconv.Atoi(year) + content, err := utils.GetMonth(userID, yearInt, monthInt) + if err != nil { + continue + } + + days, ok := content["days"].([]any) + if !ok { + continue + } + + // Process each day + for _, dayInterface := range days { + dayLog, ok := dayInterface.(map[string]any) + if !ok { + continue + } + + dayNum, ok := dayLog["day"].(float64) + if !ok { + continue + } + day := int(dayNum) + + // Check text + if text, ok := dayLog["text"].(string); ok { + decryptedText, err := utils.DecryptText(text, encKey) + if err != nil { + continue + } + + // Apply search logic + if strings.HasPrefix(searchString, "\"") && strings.HasSuffix(searchString, "\"") { + // Exact match + searchTerm := searchString[1 : len(searchString)-1] + if strings.Contains(decryptedText, searchTerm) { + context := getContext(decryptedText, searchTerm, true) + results = append(results, map[string]any{ + "year": year, + "month": month, + "day": day, + "text": context, + }) + } + } else if strings.Contains(searchString, "|") { + // OR search + words := strings.Split(searchString, "|") + for _, word := range words { + wordTrimmed := strings.TrimSpace(word) + if strings.Contains(strings.ToLower(decryptedText), strings.ToLower(wordTrimmed)) { + context := getContext(decryptedText, wordTrimmed, false) + results = append(results, map[string]any{ + "year": year, + "month": month, + "day": day, + "text": context, + }) + break + } + } + } else if strings.Contains(searchString, " ") { + // AND search + words := strings.Split(searchString, " ") + allWordsMatch := true + for _, word := range words { + wordTrimmed := strings.TrimSpace(word) + if !strings.Contains(strings.ToLower(decryptedText), strings.ToLower(wordTrimmed)) { + allWordsMatch = false + break + } + } + if allWordsMatch { + context := getContext(decryptedText, strings.TrimSpace(words[0]), false) + results = append(results, map[string]any{ + "year": year, + "month": month, + "day": day, + "text": context, + }) + } + } else { + // Simple search + if strings.Contains(strings.ToLower(decryptedText), strings.ToLower(searchString)) { + context := getContext(decryptedText, searchString, false) + results = append(results, map[string]any{ + "year": year, + "month": month, + "day": day, + "text": context, + }) + } + } + } + + // Check filenames + if files, ok := dayLog["files"].([]any); ok { + for _, fileInterface := range files { + file, ok := fileInterface.(map[string]any) + if !ok { + continue + } + + if encFilename, ok := file["enc_filename"].(string); ok { + decryptedFilename, err := utils.DecryptText(encFilename, encKey) + if err != nil { + continue + } + + if strings.Contains(strings.ToLower(decryptedFilename), strings.ToLower(searchString)) { + context := "📎 " + decryptedFilename + results = append(results, map[string]any{ + "year": year, + "month": month, + "day": day, + "text": context, + }) + break + } + } + } + } + } + } + } + + // Sort results by date + sort.Slice(results, func(i, j int) bool { + ri := results[i].(map[string]any) + rj := results[j].(map[string]any) + + yearI, _ := strconv.Atoi(ri["year"].(string)) + yearJ, _ := strconv.Atoi(rj["year"].(string)) + if yearI != yearJ { + return yearI < yearJ + } + + monthI, _ := strconv.Atoi(ri["month"].(string)) + monthJ, _ := strconv.Atoi(rj["month"].(string)) + if monthI != monthJ { + return monthI < monthJ + } + + dayI := ri["day"].(int) + dayJ := rj["day"].(int) + return dayI < dayJ + }) + + // Return results + utils.JSONResponse(w, http.StatusOK, results) +} diff --git a/backend/handlers/users.go b/backend/handlers/users.go new file mode 100644 index 0000000..448e454 --- /dev/null +++ b/backend/handlers/users.go @@ -0,0 +1,457 @@ +package handlers + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/phitux/dailytxt/backend/utils" +) + +// LoginRequest represents the login request body +type LoginRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} + +// Login handles user login +func Login(w http.ResponseWriter, r *http.Request) { + // Parse the request body + var req LoginRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + // Get users + users, err := utils.GetUsers() + if err != nil { + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + // Check if user exists + usersList, ok := users["users"].([]any) + if !ok || len(usersList) == 0 { + utils.Logger.Printf("Login failed. User '%s' not found", req.Username) + http.Error(w, "User/Password combination not found", http.StatusNotFound) + return + } + + // Find user + var userID int + var hashedPassword string + var salt string + found := false + + for _, u := range usersList { + user, ok := u.(map[string]any) + if !ok { + continue + } + + if username, ok := user["username"].(string); ok && username == req.Username { + found = true + if id, ok := user["user_id"].(float64); ok { + userID = int(id) + } + if pwd, ok := user["password"].(string); ok { + hashedPassword = pwd + } + if s, ok := user["salt"].(string); ok { + salt = s + } + break + } + } + + if !found { + utils.Logger.Printf("Login failed. User '%s' not found", req.Username) + http.Error(w, "User/Password combination not found", http.StatusNotFound) + return + } + + // Verify password + if !utils.VerifyPassword(req.Password, hashedPassword, salt) { + utils.Logger.Printf("Login failed. Password for user '%s' is incorrect", req.Username) + http.Error(w, "User/Password combination not found", http.StatusNotFound) + return + } + + // Get intermediate key + derivedKey, err := utils.DeriveKeyFromPassword(req.Password, salt) + if err != nil { + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + derivedKeyBase64 := base64.StdEncoding.EncodeToString(derivedKey) + + // Create JWT token + token, err := utils.GenerateToken(userID, req.Username, derivedKeyBase64) + if err != nil { + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + // Set cookie + http.SetCookie(w, &http.Cookie{ + Name: "token", + Value: token, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + Path: "/", + Expires: time.Now().Add(time.Duration(utils.Settings.LogoutAfterDays) * 24 * time.Hour), + }) + + // Return success + utils.JSONResponse(w, http.StatusOK, map[string]string{ + "username": req.Username, + }) +} + +// RegisterRequest represents the register request body +type RegisterRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} + +// Register handles user registration +func Register(w http.ResponseWriter, r *http.Request) { + // Parse the request body + var req RegisterRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + // Get users + users, err := utils.GetUsers() + if err != nil { + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + // Check if username already exists + if len(users) > 0 { + usersList, ok := users["users"].([]any) + if !ok { + utils.Logger.Printf("users.json is not in the correct format. Key 'users' is missing or not a list.") + http.Error(w, "users.json is not in the correct format", http.StatusInternalServerError) + return + } + + for _, u := range usersList { + user, ok := u.(map[string]any) + if !ok { + continue + } + + if username, ok := user["username"].(string); ok && username == req.Username { + utils.Logger.Printf("Registration failed. Username '%s' already exists", req.Username) + http.Error(w, "Username already exists", http.StatusBadRequest) + return + } + } + } + + // Create new user data + hashedPassword, salt, err := utils.HashPassword(req.Password) + if err != nil { + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + // Create encryption key + derivedKey, err := utils.DeriveKeyFromPassword(req.Password, salt) + if err != nil { + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + // Generate a new random encryption key + encryptionKey := make([]byte, 32) + if _, err := utils.RandRead(encryptionKey); err != nil { + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + // Encrypt the encryption key with the derived key + aead, err := utils.CreateAEAD(derivedKey) + if err != nil { + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + nonce := make([]byte, aead.NonceSize()) + if _, err := utils.RandRead(nonce); err != nil { + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + encryptedKey := aead.Seal(nonce, nonce, encryptionKey, nil) + encEncKey := base64.StdEncoding.EncodeToString(encryptedKey) + + // Create or update users + if len(users) == 0 { + users = map[string]any{ + "id_counter": 1, + "users": []map[string]any{ + { + "user_id": 1, + "dailytxt_version": 2, + "username": req.Username, + "password": hashedPassword, + "salt": salt, + "enc_enc_key": encEncKey, + }, + }, + } + } else { + // Increment ID counter + idCounter, ok := users["id_counter"].(float64) + if !ok { + idCounter = 1 + } + idCounter++ + users["id_counter"] = idCounter + + // Add new user + usersList, ok := users["users"].([]any) + if !ok { + usersList = []any{} + } + + usersList = append(usersList, map[string]any{ + "user_id": int(idCounter), + "dailytxt_version": 2, + "username": req.Username, + "password": hashedPassword, + "salt": salt, + "enc_enc_key": encEncKey, + }) + + users["users"] = usersList + } + + // Write users to file + if err := utils.WriteUsers(users); err != nil { + http.Error(w, "Internal Server Error when trying to write users.json", http.StatusInternalServerError) + return + } + + // Return success + utils.JSONResponse(w, http.StatusOK, map[string]bool{ + "success": true, + }) +} + +// Logout handles user logout +func Logout(w http.ResponseWriter, r *http.Request) { + // Delete token cookie + http.SetCookie(w, &http.Cookie{ + Name: "token", + Value: "", + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + Path: "/", + Expires: time.Unix(0, 0), + MaxAge: -1, + }) + + // Return success + utils.JSONResponse(w, http.StatusOK, map[string]bool{ + "success": true, + }) +} + +// CheckLogin checks if user is logged in +func CheckLogin(w http.ResponseWriter, r *http.Request) { + // Get token from cookie + cookie, err := r.Cookie("token") + if err != nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + // Validate JWT token + claims, err := utils.ValidateToken(cookie.Value) + if err != nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + // Return user info + utils.JSONResponse(w, http.StatusOK, map[string]any{ + "user_id": claims.UserID, + "username": claims.Username, + }) +} + +// GetUserSettings retrieves user settings +func GetUserSettings(w http.ResponseWriter, r *http.Request) { + // Get user ID from context + userID, ok := r.Context().Value(utils.UserIDKey).(int) + if !ok { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + // Get derived key from context + derivedKey, ok := r.Context().Value(utils.DerivedKeyKey).(string) + if !ok { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + // Get user settings + encryptedSettings, err := utils.GetUserSettings(userID) + if err != nil { + http.Error(w, fmt.Sprintf("Error retrieving user settings: %v", err), http.StatusInternalServerError) + return + } + + // Default settings + defaultSettings := map[string]any{ + "autoloadImagesByDefault": false, + "setAutoloadImagesPerDevice": true, + "useOnThisDay": true, + "onThisDayYears": []int{1, 5, 10}, + "useBrowserTimezone": true, + "timezone": "UTC", + } + + // If no settings found, return defaults + if len(encryptedSettings) == 0 { + utils.JSONResponse(w, http.StatusOK, defaultSettings) + return + } + + // Decrypt settings + encKey, err := utils.GetEncryptionKey(userID, derivedKey) + if err != nil { + http.Error(w, fmt.Sprintf("Error getting encryption key: %v", err), http.StatusInternalServerError) + return + } + + decryptedSettings, err := utils.DecryptText(encryptedSettings, encKey) + if err != nil { + http.Error(w, fmt.Sprintf("Error decrypting settings: %v", err), http.StatusInternalServerError) + return + } + + // Parse JSON + var settings map[string]any + if err := json.Unmarshal([]byte(decryptedSettings), &settings); err != nil { + http.Error(w, fmt.Sprintf("Error parsing settings: %v", err), http.StatusInternalServerError) + return + } + + // Apply defaults for missing keys + for key, value := range defaultSettings { + if _, exists := settings[key]; !exists { + settings[key] = value + } + } + + // Return settings + utils.JSONResponse(w, http.StatusOK, settings) +} + +// SaveUserSettings saves user settings +func SaveUserSettings(w http.ResponseWriter, r *http.Request) { + // Get user ID from context + userID, ok := r.Context().Value(utils.UserIDKey).(int) + if !ok { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + // Get derived key from context + derivedKey, ok := r.Context().Value(utils.DerivedKeyKey).(string) + if !ok { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + // Parse request body + var newSettings map[string]any + if err := json.NewDecoder(r.Body).Decode(&newSettings); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + // Get existing settings + encryptedSettings, err := utils.GetUserSettings(userID) + if err != nil { + http.Error(w, fmt.Sprintf("Error retrieving user settings: %v", err), http.StatusInternalServerError) + return + } + + // Get encryption key + encKey, err := utils.GetEncryptionKey(userID, derivedKey) + if err != nil { + http.Error(w, fmt.Sprintf("Error getting encryption key: %v", err), http.StatusInternalServerError) + return + } + + // Current settings + var currentSettings map[string]any + + // If settings exist, decrypt them + if len(encryptedSettings) > 0 { + decryptedSettings, err := utils.DecryptText(encryptedSettings, encKey) + if err != nil { + http.Error(w, fmt.Sprintf("Error decrypting settings: %v", err), http.StatusInternalServerError) + return + } + + // Parse JSON + if err := json.Unmarshal([]byte(decryptedSettings), ¤tSettings); err != nil { + http.Error(w, fmt.Sprintf("Error parsing settings: %v", err), http.StatusInternalServerError) + return + } + } + + // If no settings or empty, use defaults + if len(currentSettings) == 0 { + currentSettings = map[string]any{ + "autoloadImagesByDefault": false, + "setAutoloadImagesPerDevice": true, + "useOnThisDay": true, + "onThisDayYears": []int{1, 5, 10}, + "useBrowserTimezone": true, + "timezone": "UTC", + } + } + + // Update settings + for key, value := range newSettings { + currentSettings[key] = value + } + + // Encrypt settings + settingsJSON, err := json.Marshal(currentSettings) + if err != nil { + http.Error(w, fmt.Sprintf("Error encoding settings: %v", err), http.StatusInternalServerError) + return + } + + encryptedNewSettings, err := utils.EncryptText(string(settingsJSON), encKey) + if err != nil { + http.Error(w, fmt.Sprintf("Error encrypting settings: %v", err), http.StatusInternalServerError) + return + } + + // Write settings + if err := utils.WriteUserSettings(userID, encryptedNewSettings); err != nil { + http.Error(w, fmt.Sprintf("Error writing settings: %v", err), http.StatusInternalServerError) + return + } + + // Return success + utils.JSONResponse(w, http.StatusOK, map[string]bool{ + "success": true, + }) +} diff --git a/backend/main.go b/backend/main.go new file mode 100644 index 0000000..d9c13ba --- /dev/null +++ b/backend/main.go @@ -0,0 +1,95 @@ +package main + +import ( + "context" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/phitux/dailytxt/backend/handlers" + "github.com/phitux/dailytxt/backend/middleware" + "github.com/phitux/dailytxt/backend/utils" +) + +func main() { + // Setup logging + logger := log.New(os.Stdout, "dailytxt: ", log.LstdFlags|log.Lmicroseconds|log.Lshortfile) + logger.Println("Server starting...") + + // Load settings + if err := utils.InitSettings(); err != nil { + logger.Fatalf("Failed to initialize settings: %v", err) + } + + // Create a new router + mux := http.NewServeMux() + + // Register routes + mux.HandleFunc("POST /users/login", handlers.Login) + mux.HandleFunc("POST /users/register", handlers.Register) + mux.HandleFunc("GET /users/logout", handlers.Logout) + mux.HandleFunc("GET /users/check", middleware.RequireAuth(handlers.CheckLogin)) + mux.HandleFunc("GET /users/getUserSettings", middleware.RequireAuth(handlers.GetUserSettings)) + mux.HandleFunc("POST /users/saveUserSettings", middleware.RequireAuth(handlers.SaveUserSettings)) + + mux.HandleFunc("POST /logs/saveLog", middleware.RequireAuth(handlers.SaveLog)) + mux.HandleFunc("GET /logs/getLog", middleware.RequireAuth(handlers.GetLog)) + mux.HandleFunc("GET /logs/getMarkedDays", middleware.RequireAuth(handlers.GetMarkedDays)) + mux.HandleFunc("GET /logs/getTags", middleware.RequireAuth(handlers.GetTags)) + mux.HandleFunc("POST /logs/saveNewTag", middleware.RequireAuth(handlers.SaveTags)) + mux.HandleFunc("POST /logs/editTag", middleware.RequireAuth(handlers.EditTag)) + mux.HandleFunc("GET /logs/deleteTag", middleware.RequireAuth(handlers.DeleteTag)) + mux.HandleFunc("POST /logs/addTagToLog", middleware.RequireAuth(handlers.AddTagToLog)) + mux.HandleFunc("POST /logs/removeTagFromLog", middleware.RequireAuth(handlers.RemoveTagFromLog)) + mux.HandleFunc("GET /logs/getTemplates", middleware.RequireAuth(handlers.GetTemplates)) + mux.HandleFunc("POST /logs/saveTemplates", middleware.RequireAuth(handlers.SaveTemplates)) + mux.HandleFunc("GET /logs/getOnThisDay", middleware.RequireAuth(handlers.GetOnThisDay)) + mux.HandleFunc("GET /logs/searchString", middleware.RequireAuth(handlers.Search)) + mux.HandleFunc("GET /logs/searchTag", middleware.RequireAuth(handlers.SearchTag)) + mux.HandleFunc("GET /logs/loadMonthForReading", middleware.RequireAuth(handlers.LoadMonthForReading)) + mux.HandleFunc("POST /logs/uploadFile", middleware.RequireAuth(handlers.UploadFile)) + mux.HandleFunc("GET /logs/downloadFile", middleware.RequireAuth(handlers.DownloadFile)) + mux.HandleFunc("GET /logs/deleteFile", middleware.RequireAuth(handlers.DeleteFile)) + mux.HandleFunc("GET /logs/getHistory", middleware.RequireAuth(handlers.GetHistory)) + mux.HandleFunc("GET /logs/bookmarkDay", middleware.RequireAuth(handlers.BookmarkDay)) + + // Create a handler with CORS middleware + handler := middleware.CORS(mux) + + // Create the server + server := &http.Server{ + Addr: ":8000", + Handler: handler, + ReadTimeout: 15 * time.Second, + WriteTimeout: 15 * time.Second, + IdleTimeout: 60 * time.Second, + } + + // Start the server in a goroutine + go func() { + logger.Println("Server listening on :8000") + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + logger.Fatalf("Failed to start server: %v", err) + } + }() + + // Setup graceful shutdown + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + logger.Println("Shutting down server...") + + // Create a deadline to wait for + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + // Doesn't block if no connections, otherwise wait until the timeout + if err := server.Shutdown(ctx); err != nil { + logger.Fatalf("Server forced to shutdown: %v", err) + } + + logger.Println("Server stopped gracefully") +} diff --git a/backend/middleware/middleware.go b/backend/middleware/middleware.go new file mode 100644 index 0000000..a8721df --- /dev/null +++ b/backend/middleware/middleware.go @@ -0,0 +1,88 @@ +package middleware + +import ( + "context" + "net/http" + "strings" + + "github.com/phitux/dailytxt/backend/utils" +) + +// CORS middleware handles Cross-Origin Resource Sharing +func CORS(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Get origin from request + origin := r.Header.Get("Origin") + + // Check if origin is in allowed hosts + allowed := false + for _, host := range utils.Settings.AllowedHosts { + if origin == host { + allowed = true + break + } + } + + // Set CORS headers if origin is allowed + if allowed { + w.Header().Set("Access-Control-Allow-Origin", origin) + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") + w.Header().Set("Access-Control-Allow-Credentials", "true") + } + + // Handle preflight requests + if r.Method == "OPTIONS" { + w.WriteHeader(http.StatusOK) + return + } + + next.ServeHTTP(w, r) + }) +} + +// RequireAuth middleware checks if user is authenticated +func RequireAuth(next http.HandlerFunc) http.HandlerFunc { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Get token from cookie + cookie, err := r.Cookie("token") + if err != nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + utils.Logger.Printf("Unauthorized access attempt, no cookie found: %s %s", r.Method, r.URL.Path) + return + } + + // Validate JWT token + claims, err := utils.ValidateToken(cookie.Value) + if err != nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + utils.Logger.Printf("Unauthorized access attempt, invalid token: %s %s", r.Method, r.URL.Path) + return + } + + // Add user info to request context + ctx := context.WithValue(r.Context(), utils.UserIDKey, claims.UserID) + ctx = context.WithValue(ctx, utils.UsernameKey, claims.Username) + ctx = context.WithValue(ctx, utils.DerivedKeyKey, claims.DerivedKey) + + // Continue with the next handler + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// Logger middleware logs all requests +func Logger(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Skip logging for static files + if strings.HasPrefix(r.URL.Path, "/static/") { + next.ServeHTTP(w, r) + return + } + + // Log request + utils.Logger.Printf("Request: %s %s", r.Method, r.URL.Path) + + // Continue with next handler + next.ServeHTTP(w, r) + }) +} diff --git a/backend/utils/file_handling.go b/backend/utils/file_handling.go new file mode 100644 index 0000000..f8989dd --- /dev/null +++ b/backend/utils/file_handling.go @@ -0,0 +1,623 @@ +package utils + +import ( + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "strconv" + "strings" + + "golang.org/x/crypto/chacha20poly1305" +) + +// GetUsers retrieves the users from the users.json file +func GetUsers() (map[string]any, error) { + // Try to open the users.json file + filePath := filepath.Join(Settings.DataPath, "users.json") + file, err := os.Open(filePath) + if err != nil { + if os.IsNotExist(err) { + Logger.Printf("users.json - File not found") + return map[string]any{}, nil + } + Logger.Printf("Error opening users.json: %v", err) + return nil, fmt.Errorf("internal server error when trying to open users.json") + } + defer file.Close() + + // Read the file content + var content map[string]any + decoder := json.NewDecoder(file) + if err := decoder.Decode(&content); err != nil { + if err == io.EOF { + return map[string]any{}, nil + } + Logger.Printf("Error decoding users.json: %v", err) + return nil, fmt.Errorf("internal server error when trying to decode users.json") + } + + return content, nil +} + +// WriteUsers writes the users to the users.json file +func WriteUsers(content map[string]any) error { + // Create the users.json file + filePath := filepath.Join(Settings.DataPath, "users.json") + file, err := os.Create(filePath) + if err != nil { + Logger.Printf("Error creating users.json: %v", err) + return fmt.Errorf("internal server error when trying to create users.json") + } + defer file.Close() + + // Write the content to the file + var encoder *json.Encoder + if Settings.Development && Settings.Indent > 0 { + encoder = json.NewEncoder(file) + encoder.SetIndent("", fmt.Sprintf("%*s", Settings.Indent, "")) + } else { + encoder = json.NewEncoder(file) + } + + if err := encoder.Encode(content); err != nil { + Logger.Printf("Error encoding users.json: %v", err) + return fmt.Errorf("internal server error when trying to encode users.json") + } + + return nil +} + +// GetMonth retrieves the logs for a specific month +func GetMonth(userID int, year, month int) (map[string]any, error) { + // Try to open the month.json file + filePath := filepath.Join(Settings.DataPath, fmt.Sprintf("%d/%d/%02d.json", userID, year, month)) + file, err := os.Open(filePath) + if err != nil { + if os.IsNotExist(err) { + Logger.Printf("%s - File not found", filePath) + return map[string]any{}, nil + } + Logger.Printf("Error opening %s: %v", filePath, err) + return nil, fmt.Errorf("internal server error when trying to open %d/%02d.json", year, month) + } + defer file.Close() + + // Read the file content + var content map[string]any + decoder := json.NewDecoder(file) + if err := decoder.Decode(&content); err != nil { + if err == io.EOF { + return map[string]any{}, nil + } + Logger.Printf("Error decoding %s: %v", filePath, err) + return nil, fmt.Errorf("internal server error when trying to decode %d/%02d.json", year, month) + } + + return content, nil +} + +// WriteMonth writes the logs for a specific month +func WriteMonth(userID int, year, month int, content map[string]any) error { + // Create the directory if it doesn't exist + dirPath := filepath.Join(Settings.DataPath, fmt.Sprintf("%d/%d", userID, year)) + if err := os.MkdirAll(dirPath, 0755); err != nil { + Logger.Printf("Error creating directory %s: %v", dirPath, err) + return fmt.Errorf("internal server error when trying to create directory %d/%d", userID, year) + } + + // Create the month.json file + filePath := filepath.Join(dirPath, fmt.Sprintf("%02d.json", month)) + file, err := os.Create(filePath) + if err != nil { + Logger.Printf("Error creating %s: %v", filePath, err) + return fmt.Errorf("internal server error when trying to create %d/%02d.json", year, month) + } + defer file.Close() + + // Write the content to the file + var encoder *json.Encoder + if Settings.Development && Settings.Indent > 0 { + encoder = json.NewEncoder(file) + encoder.SetIndent("", fmt.Sprintf("%*s", Settings.Indent, "")) + } else { + encoder = json.NewEncoder(file) + } + + if err := encoder.Encode(content); err != nil { + Logger.Printf("Error encoding %s: %v", filePath, err) + return fmt.Errorf("internal server error when trying to encode %d/%02d.json", year, month) + } + + return nil +} + +// GetTags retrieves the tags for a specific user +func GetTags(userID int) (map[string]any, error) { + // Try to open the tags.json file + filePath := filepath.Join(Settings.DataPath, fmt.Sprintf("%d/tags.json", userID)) + file, err := os.Open(filePath) + if err != nil { + if os.IsNotExist(err) { + Logger.Printf("%s - File not found", filePath) + return map[string]any{}, nil + } + Logger.Printf("Error opening %s: %v", filePath, err) + return nil, fmt.Errorf("internal server error when trying to open tags.json") + } + defer file.Close() + + // Read the file content + var content map[string]any + decoder := json.NewDecoder(file) + if err := decoder.Decode(&content); err != nil { + if err == io.EOF { + return map[string]any{}, nil + } + Logger.Printf("Error decoding %s: %v", filePath, err) + return nil, fmt.Errorf("internal server error when trying to decode tags.json") + } + + return content, nil +} + +// WriteTags writes the tags for a specific user +func WriteTags(userID int, content map[string]any) error { + // Create the directory if it doesn't exist + dirPath := filepath.Join(Settings.DataPath, fmt.Sprintf("%d", userID)) + if err := os.MkdirAll(dirPath, 0755); err != nil { + Logger.Printf("Error creating directory %s: %v", dirPath, err) + return fmt.Errorf("internal server error when trying to create directory %d", userID) + } + + // Create the tags.json file + filePath := filepath.Join(dirPath, "tags.json") + file, err := os.Create(filePath) + if err != nil { + Logger.Printf("Error creating %s: %v", filePath, err) + return fmt.Errorf("internal server error when trying to create tags.json") + } + defer file.Close() + + // Write the content to the file + var encoder *json.Encoder + if Settings.Development && Settings.Indent > 0 { + encoder = json.NewEncoder(file) + encoder.SetIndent("", fmt.Sprintf("%*s", Settings.Indent, "")) + } else { + encoder = json.NewEncoder(file) + } + + if err := encoder.Encode(content); err != nil { + Logger.Printf("Error encoding %s: %v", filePath, err) + return fmt.Errorf("internal server error when trying to encode tags.json") + } + + return nil +} + +// RandRead is a helper function for reading random bytes +func RandRead(b []byte) (int, error) { + return rand.Read(b) +} + +// GetUserSettings retrieves the settings for a specific user +func GetUserSettings(userID int) (string, error) { + // Try to open the settings.encrypted file + filePath := filepath.Join(Settings.DataPath, fmt.Sprintf("%d/settings.encrypted", userID)) + file, err := os.Open(filePath) + if err != nil { + if os.IsNotExist(err) { + Logger.Printf("%s - File not found", filePath) + return "", nil + } + Logger.Printf("Error opening %s: %v", filePath, err) + return "", fmt.Errorf("internal server error when trying to open settings.encrypted") + } + defer file.Close() + + // Read the file content + content, err := io.ReadAll(file) + if err != nil { + Logger.Printf("Error reading %s: %v", filePath, err) + return "", fmt.Errorf("internal server error when trying to read settings.encrypted") + } + + return string(content), nil +} + +// WriteUserSettings writes the settings for a specific user +func WriteUserSettings(userID int, content string) error { + // Create the directory if it doesn't exist + dirPath := filepath.Join(Settings.DataPath, fmt.Sprintf("%d", userID)) + if err := os.MkdirAll(dirPath, 0755); err != nil { + Logger.Printf("Error creating directory %s: %v", dirPath, err) + return fmt.Errorf("internal server error when trying to create directory %d", userID) + } + + // Create the settings.encrypted file + filePath := filepath.Join(dirPath, "settings.encrypted") + file, err := os.Create(filePath) + if err != nil { + Logger.Printf("Error creating %s: %v", filePath, err) + return fmt.Errorf("internal server error when trying to create settings.encrypted") + } + defer file.Close() + + // Write the content to the file + if _, err := file.WriteString(content); err != nil { + Logger.Printf("Error writing %s: %v", filePath, err) + return fmt.Errorf("internal server error when trying to write settings.encrypted") + } + + return nil +} + +// GetTemplates retrieves the templates for a specific user +func GetTemplates(userID int) (map[string]any, error) { + // Try to open the templates.json file + filePath := filepath.Join(Settings.DataPath, fmt.Sprintf("%d/templates.json", userID)) + file, err := os.Open(filePath) + if err != nil { + if os.IsNotExist(err) { + Logger.Printf("%s - File not found", filePath) + return map[string]any{}, nil + } + Logger.Printf("Error opening %s: %v", filePath, err) + return nil, fmt.Errorf("internal server error when trying to open templates.json") + } + defer file.Close() + + // Read the file content + var content map[string]any + decoder := json.NewDecoder(file) + if err := decoder.Decode(&content); err != nil { + if err == io.EOF { + return map[string]any{}, nil + } + Logger.Printf("Error decoding %s: %v", filePath, err) + return nil, fmt.Errorf("internal server error when trying to decode templates.json") + } + + return content, nil +} + +// WriteTemplates writes the templates for a specific user +func WriteTemplates(userID int, content map[string]any) error { + // Create the directory if it doesn't exist + dirPath := filepath.Join(Settings.DataPath, fmt.Sprintf("%d", userID)) + if err := os.MkdirAll(dirPath, 0755); err != nil { + Logger.Printf("Error creating directory %s: %v", dirPath, err) + return fmt.Errorf("internal server error when trying to create directory %d", userID) + } + + // Create the templates.json file + filePath := filepath.Join(dirPath, "templates.json") + file, err := os.Create(filePath) + if err != nil { + Logger.Printf("Error creating %s: %v", filePath, err) + return fmt.Errorf("internal server error when trying to create templates.json") + } + defer file.Close() + + // Write the content to the file + var encoder *json.Encoder + if Settings.Development && Settings.Indent > 0 { + encoder = json.NewEncoder(file) + encoder.SetIndent("", fmt.Sprintf("%*s", Settings.Indent, "")) + } else { + encoder = json.NewEncoder(file) + } + + if err := encoder.Encode(content); err != nil { + Logger.Printf("Error encoding %s: %v", filePath, err) + return fmt.Errorf("internal server error when trying to encode templates.json") + } + + return nil +} + +// WriteFile writes a file for a specific user +func WriteFile(content []byte, userID int, uuid string) error { + // Create the directory if it doesn't exist + dirPath := filepath.Join(Settings.DataPath, fmt.Sprintf("%d/files", userID)) + if err := os.MkdirAll(dirPath, 0755); err != nil { + Logger.Printf("Error creating directory %s: %v", dirPath, err) + return fmt.Errorf("internal server error when trying to create directory %d/files", userID) + } + + // Create the file + filePath := filepath.Join(dirPath, uuid) + file, err := os.Create(filePath) + if err != nil { + Logger.Printf("Error creating %s: %v", filePath, err) + return fmt.Errorf("internal server error when trying to create file %s", uuid) + } + defer file.Close() + + // Write the content to the file + if _, err := file.Write(content); err != nil { + Logger.Printf("Error writing %s: %v", filePath, err) + return fmt.Errorf("internal server error when trying to write file %s", uuid) + } + + return nil +} + +// ReadFile reads a file for a specific user +func ReadFile(userID int, uuid string) ([]byte, error) { + // Try to open the file + filePath := filepath.Join(Settings.DataPath, fmt.Sprintf("%d/files/%s", userID, uuid)) + file, err := os.Open(filePath) + if err != nil { + if os.IsNotExist(err) { + Logger.Printf("%s - File not found", filePath) + return nil, fmt.Errorf("file not found") + } + Logger.Printf("Error opening %s: %v", filePath, err) + return nil, fmt.Errorf("internal server error when trying to open file %s", uuid) + } + defer file.Close() + + // Read the file content + content, err := io.ReadAll(file) + if err != nil { + Logger.Printf("Error reading %s: %v", filePath, err) + return nil, fmt.Errorf("internal server error when trying to read file %s", uuid) + } + + return content, nil +} + +// RemoveFile removes a file for a specific user +func RemoveFile(userID int, uuid string) error { + // Try to remove the file + filePath := filepath.Join(Settings.DataPath, fmt.Sprintf("%d/files/%s", userID, uuid)) + if err := os.Remove(filePath); err != nil { + Logger.Printf("Error removing %s: %v", filePath, err) + return fmt.Errorf("internal server error when trying to remove file %s", uuid) + } + + return nil +} + +// GetYears returns the years available for a specific user +func GetYears(userID int) ([]string, error) { + // Try to read the user directory + dirPath := filepath.Join(Settings.DataPath, fmt.Sprintf("%d", userID)) + entries, err := os.ReadDir(dirPath) + if err != nil { + if os.IsNotExist(err) { + Logger.Printf("%s - Directory not found", dirPath) + return []string{}, nil + } + Logger.Printf("Error reading directory %s: %v", dirPath, err) + return nil, fmt.Errorf("internal server error when trying to read directory %d", userID) + } + + // Filter years + years := []string{} + for _, entry := range entries { + if entry.IsDir() && len(entry.Name()) == 4 { + // Check if the name is a valid year (4 digits) + if _, err := strconv.Atoi(entry.Name()); err == nil { + years = append(years, entry.Name()) + } + } + } + + return years, nil +} + +// GetMonths returns the months available for a specific user and year +func GetMonths(userID int, year string) ([]string, error) { + // Try to read the year directory + dirPath := filepath.Join(Settings.DataPath, fmt.Sprintf("%d/%s", userID, year)) + entries, err := os.ReadDir(dirPath) + if err != nil { + if os.IsNotExist(err) { + Logger.Printf("%s - Directory not found", dirPath) + return []string{}, nil + } + Logger.Printf("Error reading directory %s: %v", dirPath, err) + return nil, fmt.Errorf("internal server error when trying to read directory %d/%s", userID, year) + } + + // Filter months + months := []string{} + for _, entry := range entries { + if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".json") { + // Extract month from filename (remove .json) + month := strings.TrimSuffix(entry.Name(), ".json") + months = append(months, month) + } + } + + return months, nil +} + +// CreateAEAD creates an AEAD cipher for encryption/decryption +func CreateAEAD(key []byte) (cipher.AEAD, error) { + return chacha20poly1305.New(key) +} + +// EncryptText encrypts text using the provided key +func EncryptText(text, key string) (string, error) { + // Decode key + keyBytes, err := base64.URLEncoding.DecodeString(key) + if err != nil { + return "", fmt.Errorf("error decoding key: %v", err) + } + + // Create AEAD cipher + aead, err := chacha20poly1305.New(keyBytes) + if err != nil { + return "", fmt.Errorf("error creating cipher: %v", err) + } + + // Create nonce + nonce := make([]byte, aead.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return "", fmt.Errorf("error creating nonce: %v", err) + } + + // Encrypt text + ciphertext := aead.Seal(nonce, nonce, []byte(text), nil) + return base64.URLEncoding.EncodeToString(ciphertext), nil +} + +// DecryptText decrypts text using the provided key +func DecryptText(ciphertext, key string) (string, error) { + // Decode key and ciphertext + keyBytes, err := base64.URLEncoding.DecodeString(key) + if err != nil { + return "", fmt.Errorf("error decoding key: %v", err) + } + + ciphertextBytes, err := base64.URLEncoding.DecodeString(ciphertext) + if err != nil { + return "", fmt.Errorf("error decoding ciphertext: %v", err) + } + + // Create AEAD cipher + aead, err := chacha20poly1305.New(keyBytes) + if err != nil { + return "", fmt.Errorf("error creating cipher: %v", err) + } + + // Extract nonce from ciphertext + if len(ciphertextBytes) < aead.NonceSize() { + return "", fmt.Errorf("ciphertext too short") + } + nonce, ciphertextBytes := ciphertextBytes[:aead.NonceSize()], ciphertextBytes[aead.NonceSize():] + + // Decrypt text + plaintext, err := aead.Open(nil, nonce, ciphertextBytes, nil) + if err != nil { + return "", fmt.Errorf("error decrypting ciphertext: %v", err) + } + + return string(plaintext), nil +} + +// EncryptFile encrypts a file using the provided key +func EncryptFile(data []byte, key string) ([]byte, error) { + // Decode key + keyBytes, err := base64.URLEncoding.DecodeString(key) + if err != nil { + return nil, fmt.Errorf("error decoding key: %v", err) + } + + // Create AEAD cipher + aead, err := chacha20poly1305.New(keyBytes) + if err != nil { + return nil, fmt.Errorf("error creating cipher: %v", err) + } + + // Create nonce + nonce := make([]byte, aead.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return nil, fmt.Errorf("error creating nonce: %v", err) + } + + // Encrypt file + ciphertext := aead.Seal(nonce, nonce, data, nil) + return ciphertext, nil +} + +// DecryptFile decrypts a file using the provided key +func DecryptFile(ciphertext []byte, key string) ([]byte, error) { + // Decode key + keyBytes, err := base64.URLEncoding.DecodeString(key) + if err != nil { + return nil, fmt.Errorf("error decoding key: %v", err) + } + + // Create AEAD cipher + aead, err := chacha20poly1305.New(keyBytes) + if err != nil { + return nil, fmt.Errorf("error creating cipher: %v", err) + } + + // Extract nonce from ciphertext + if len(ciphertext) < aead.NonceSize() { + return nil, fmt.Errorf("ciphertext too short") + } + nonce, ciphertext := ciphertext[:aead.NonceSize()], ciphertext[aead.NonceSize():] + + // Decrypt file + plaintext, err := aead.Open(nil, nonce, ciphertext, nil) + if err != nil { + return nil, fmt.Errorf("error decrypting ciphertext: %v", err) + } + + return plaintext, nil +} + +// GetEncryptionKey retrieves the encryption key for a specific user +func GetEncryptionKey(userID int, derivedKey string) (string, error) { + // Get users + users, err := GetUsers() + if err != nil { + return "", fmt.Errorf("error retrieving users: %v", err) + } + + // Find user + usersList, ok := users["users"].([]any) + if !ok { + return "", fmt.Errorf("users.json is not in the correct format") + } + + for _, u := range usersList { + user, ok := u.(map[string]any) + if !ok { + continue + } + + if id, ok := user["user_id"].(float64); ok && int(id) == userID { + encEncKey, ok := user["enc_enc_key"].(string) + if !ok { + return "", fmt.Errorf("user data is not in the correct format") + } + + // Decode derived key + derivedKeyBytes, err := base64.StdEncoding.DecodeString(derivedKey) + if err != nil { + return "", fmt.Errorf("error decoding derived key: %v", err) + } + + // Create Fernet cipher + aead, err := CreateAEAD(derivedKeyBytes) + if err != nil { + return "", fmt.Errorf("error creating cipher: %v", err) + } + + // Decode encrypted key + encEncKeyBytes, err := base64.StdEncoding.DecodeString(encEncKey) + if err != nil { + return "", fmt.Errorf("error decoding encrypted key: %v", err) + } + + // Extract nonce from encrypted key + if len(encEncKeyBytes) < aead.NonceSize() { + return "", fmt.Errorf("encrypted key too short") + } + nonce, encKeyBytes := encEncKeyBytes[:aead.NonceSize()], encEncKeyBytes[aead.NonceSize():] + + // Decrypt key + keyBytes, err := aead.Open(nil, nonce, encKeyBytes, nil) + if err != nil { + return "", fmt.Errorf("error decrypting key: %v", err) + } + + // Return base64-encoded key + return base64.URLEncoding.EncodeToString(keyBytes), nil + } + } + + return "", fmt.Errorf("user not found") +} diff --git a/backend/utils/security.go b/backend/utils/security.go new file mode 100644 index 0000000..07e9e85 --- /dev/null +++ b/backend/utils/security.go @@ -0,0 +1,243 @@ +package utils + +import ( + "crypto/rand" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "time" + + "github.com/golang-jwt/jwt/v5" + "golang.org/x/crypto/argon2" +) + +// Global logger +var Logger *log.Logger + +func init() { + // Initialize logger + Logger = log.New(os.Stdout, "dailytxt: ", log.LstdFlags|log.Lmicroseconds|log.Lshortfile) +} + +// ContextKey is a type for context keys +type ContextKey string + +// Context keys +const ( + UserIDKey ContextKey = "userID" + UsernameKey ContextKey = "username" + DerivedKeyKey ContextKey = "derivedKey" +) + +// Settings holds the application settings +type AppSettings struct { + DataPath string `json:"data_path"` + Development bool `json:"development"` + SecretToken string `json:"secret_token"` + LogoutAfterDays int `json:"logout_after_days"` + AllowedHosts []string `json:"allowed_hosts"` + Indent int `json:"indent"` +} + +// Global settings +var Settings AppSettings + +// InitSettings loads the application settings +func InitSettings() error { + // Default settings + Settings = AppSettings{ + DataPath: "/data", + Development: false, + SecretToken: generateSecretToken(), + LogoutAfterDays: 30, + AllowedHosts: []string{"http://localhost:5173", "http://127.0.0.1:5173"}, + Indent: 0, + } + + fmt.Print("\nDetected following settings:\n================\n") + + // Override with environment variables if available + if dataPath := os.Getenv("DATA_PATH"); dataPath != "" { + Settings.DataPath = dataPath + } + fmt.Printf("Data Path: %s\n", Settings.DataPath) + + if os.Getenv("DEVELOPMENT") == "true" { + Settings.Development = true + } + fmt.Printf("Development Mode: %t\n", Settings.Development) + + if secretToken := os.Getenv("SECRET_TOKEN"); secretToken != "" { + Settings.SecretToken = secretToken + } + fmt.Printf("Secret Token: %s\n", Settings.SecretToken) + + if logoutDays := os.Getenv("LOGOUT_AFTER_DAYS"); logoutDays != "" { + // Parse logoutDays to int + var days int + if _, err := fmt.Sscanf(logoutDays, "%d", &days); err == nil { + Settings.LogoutAfterDays = days + } + } + fmt.Printf("Logout After Days: %d\n", Settings.LogoutAfterDays) + + if indent := os.Getenv("INDENT"); indent != "" { + // Parse indent to int + var ind int + if _, err := fmt.Sscanf(indent, "%d", &ind); err == nil { + Settings.Indent = ind + } + } + fmt.Printf("Indent: %d\n================\n\n", Settings.Indent) + + // Create data directory if it doesn't exist + if err := os.MkdirAll(Settings.DataPath, 0755); err != nil { + return fmt.Errorf("failed to create data directory: %v", err) + } + + return nil +} + +// Claims represents the JWT claims +type Claims struct { + UserID int `json:"user_id"` + Username string `json:"name"` + DerivedKey string `json:"derived_key"` + jwt.RegisteredClaims +} + +// GenerateToken creates a new JWT token +func GenerateToken(userID int, username, derivedKey string) (string, error) { + // Create expiration time + expirationTime := time.Now().Add(time.Duration(Settings.LogoutAfterDays) * 24 * time.Hour) + + // Create claims + claims := &Claims{ + UserID: userID, + Username: username, + DerivedKey: derivedKey, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(expirationTime), + }, + } + + // Create token + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + + // Sign token with secret key + tokenString, err := token.SignedString([]byte(Settings.SecretToken)) + if err != nil { + return "", err + } + + return tokenString, nil +} + +// ValidateToken validates a JWT token and returns the claims +func ValidateToken(tokenString string) (*Claims, error) { + // Parse token + claims := &Claims{} + token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (any, error) { + // Validate signing method + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + + return []byte(Settings.SecretToken), nil + }) + + if err != nil { + return nil, err + } + + if !token.Valid { + return nil, fmt.Errorf("invalid token") + } + + return claims, nil +} + +// HashPassword hashes a password using Argon2 +func HashPassword(password string) (string, string, error) { + // Generate a random salt + salt := make([]byte, 16) + if _, err := io.ReadFull(rand.Reader, salt); err != nil { + return "", "", err + } + + // Hash password + hash := argon2.IDKey([]byte(password), salt, 2, 64*1024, 4, 32) + + // Encode salt and hash to base64 + saltBase64 := base64.StdEncoding.EncodeToString(salt) + hashBase64 := base64.StdEncoding.EncodeToString(hash) + + return hashBase64, saltBase64, nil +} + +// VerifyPassword verifies if a password matches a hash +func VerifyPassword(password, hashBase64, saltBase64 string) bool { + // Decode salt and hash + salt, err := base64.StdEncoding.DecodeString(saltBase64) + if err != nil { + return false + } + + _, err = base64.StdEncoding.DecodeString(hashBase64) + if err != nil { + return false + } + + // Hash the provided password with the same salt + hash := argon2.IDKey([]byte(password), salt, 2, 64*1024, 4, 32) + + // Compare hashes + return base64.StdEncoding.EncodeToString(hash) == hashBase64 +} + +// DeriveKeyFromPassword derives a key from a password and salt +func DeriveKeyFromPassword(password, saltBase64 string) ([]byte, error) { + // Decode salt + salt, err := base64.StdEncoding.DecodeString(saltBase64) + if err != nil { + return nil, err + } + + // Derive key + key := argon2.IDKey([]byte(password), salt, 2, 64*1024, 4, 32) + return key, nil +} + +// GenerateSecretToken generates a secure random token +func generateSecretToken() string { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + Logger.Fatalf("Failed to generate secret token: %v", err) + } + return base64.URLEncoding.EncodeToString(b) +} + +// JSONResponse sends a JSON response with the given status code and data +func JSONResponse(w http.ResponseWriter, statusCode int, data any) { + // Set content type + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + + // Encode data to JSON + var encoder *json.Encoder + if Settings.Development && Settings.Indent > 0 { + encoder = json.NewEncoder(w) + encoder.SetIndent("", fmt.Sprintf("%*s", Settings.Indent, "")) + } else { + encoder = json.NewEncoder(w) + } + + if err := encoder.Encode(data); err != nil { + Logger.Printf("Error encoding JSON response: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + } +}