From: PhiTux Date: Tue, 9 Sep 2025 12:24:39 +0000 (+0200) Subject: added admin page X-Git-Url: http://git.99rst.org/?a=commitdiff_plain;h=649b90cef5674e07d229367252f64b3467854267;p=DailyTxT.git added admin page --- diff --git a/backend/handlers/admin.go b/backend/handlers/admin.go new file mode 100644 index 0000000..0049149 --- /dev/null +++ b/backend/handlers/admin.go @@ -0,0 +1,174 @@ +package handlers + +import ( + "encoding/json" + "log" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/phitux/dailytxt/backend/utils" +) + +type AdminRequest struct { + AdminPassword string `json:"admin_password"` +} + +type AdminUserResponse struct { + ID int `json:"id"` + Username string `json:"username"` + DiskUsage int64 `json:"disk_usage"` +} + +// ValidateAdminPassword validates the admin password +func ValidateAdminPassword(w http.ResponseWriter, r *http.Request) { + adminPassword := os.Getenv("ADMIN_PASSWORD") + if adminPassword == "" { + json.NewEncoder(w).Encode(map[string]bool{ + "valid": false, + }) + return + } + + var req struct { + Password string `json:"password"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request", http.StatusBadRequest) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]bool{ + "valid": req.Password == adminPassword, + }) +} + +// validateAdminPasswordInRequest checks if the admin password in request is valid +func validateAdminPasswordInRequest(r *http.Request) bool { + adminPassword := os.Getenv("ADMIN_PASSWORD") + if adminPassword == "" { + return false + } + + var req AdminRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + return false + } + + return req.AdminPassword == adminPassword +} + +// GetAllUsers returns all users with their disk usage +func GetAllUsers(w http.ResponseWriter, r *http.Request) { + if !validateAdminPasswordInRequest(r) { + http.Error(w, "Invalid admin password", http.StatusUnauthorized) + return + } + + // Read users.json + users, err := utils.GetUsers() + if err != nil { + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + // get users + usersList, ok := users["users"].([]any) + if !ok || len(usersList) == 0 { + utils.Logger.Printf("No users found.") + } + + // Calculate disk usage for each user + var adminUsers []AdminUserResponse + for _, u := range usersList { + user, ok := u.(map[string]any) + if !ok { + continue + } + + userID, ok := user["user_id"].(float64) + if !ok { + continue + } + + username, _ := user["username"].(string) + + // Calculate disk usage for this user + diskUsage := calculateUserDiskUsage(int(userID)) + + adminUsers = append(adminUsers, AdminUserResponse{ + ID: int(userID), + Username: username, + DiskUsage: diskUsage, + }) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "users": adminUsers, + }) +} + +// calculateUserDiskUsage calculates the total disk usage for a user +func calculateUserDiskUsage(userID int) int64 { + userDataDir := filepath.Join(utils.Settings.DataPath, strconv.Itoa(userID)) + var totalSize int64 + + // Calculate size recursively + err := filepath.Walk(userDataDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil // Continue on errors + } + if !info.IsDir() { + totalSize += info.Size() + } + return nil + }) + + if err != nil { + log.Printf("Error calculating disk usage for user %d: %v", userID, err) + } + + return totalSize +} + +// DeleteUser deletes a user and all their data +func DeleteUser(w http.ResponseWriter, r *http.Request) { + var req struct { + AdminPassword string `json:"admin_password"` + UserID int `json:"user_id"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request", http.StatusBadRequest) + return + } + + // Validate admin password + adminPassword := os.Getenv("ADMIN_PASSWORD") + if adminPassword == "" || req.AdminPassword != adminPassword { + http.Error(w, "Invalid admin password", http.StatusUnauthorized) + return + } + + // Use the shared delete function from users.go + if err := deleteUserByID(req.UserID); err != nil { + log.Printf("Error deleting user %d: %v", req.UserID, err) + errMsg := err.Error() + if strings.Contains(errMsg, "not found") { + http.Error(w, "User not found", http.StatusNotFound) + } else { + http.Error(w, "Error deleting user", http.StatusInternalServerError) + } + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]bool{ + "success": true, + }) +} diff --git a/backend/handlers/users.go b/backend/handlers/users.go index 4127b13..09bf0fc 100644 --- a/backend/handlers/users.go +++ b/backend/handlers/users.go @@ -861,60 +861,25 @@ type DeleteAccountRequest struct { Password string `json:"password"` } -func DeleteAccount(w http.ResponseWriter, r *http.Request) { - // Get user ID from context - userID, ok := r.Context().Value(utils.UserIDKey).(int) - if !ok { - utils.JSONResponse(w, http.StatusUnauthorized, map[string]any{ - "success": false, - "message": "User not authenticated", - }) - return - } - - // Parse request body - var req DeleteAccountRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - utils.JSONResponse(w, http.StatusBadRequest, map[string]any{ - "success": false, - "message": "Invalid request body", - }) - return - } - - derived_key, _, err := utils.CheckPasswordForUser(userID, req.Password) - if err != nil || len(derived_key) == 0 { - utils.JSONResponse(w, http.StatusOK, map[string]any{ - "success": false, - "message": "Error checking password", - "password_incorrect": true, - }) - return - } - +// deleteUserByID deletes a user by their user ID from the system +// This is a shared function used by both DeleteAccount and admin DeleteUser +func deleteUserByID(userID int) error { utils.UsersFileMutex.Lock() defer utils.UsersFileMutex.Unlock() // Get User data users, err := utils.GetUsers() if err != nil { - utils.JSONResponse(w, http.StatusOK, map[string]any{ - "success": false, - "message": fmt.Sprintf("Error retrieving users: %v", err), - }) - return + return fmt.Errorf("error retrieving users: %v", err) } usersList, ok := users["users"].([]any) if !ok { - utils.JSONResponse(w, http.StatusOK, map[string]any{ - "success": false, - "message": "Users data is not in the correct format", - }) - return + return fmt.Errorf("users data is not in the correct format") } // Remove user from users list var newUsersList []any + userFound := false for _, u := range usersList { uMap, ok := u.(map[string]any) if !ok { @@ -924,27 +889,67 @@ func DeleteAccount(w http.ResponseWriter, r *http.Request) { if id, ok := uMap["user_id"].(float64); !ok || int(id) != userID { utils.Logger.Printf("Keeping user with ID %f (%d)", id, userID) newUsersList = append(newUsersList, u) + } else { + userFound = true } } + + if !userFound { + return fmt.Errorf("user with ID %d not found", userID) + } + users["users"] = newUsersList if err := utils.WriteUsers(users); err != nil { - utils.JSONResponse(w, http.StatusOK, map[string]any{ + return fmt.Errorf("error writing users data: %v", err) + } + + // Delete directory of the user with all his data + if err := utils.DeleteUserData(userID); err != nil { + return fmt.Errorf("error deleting user data of ID %d: %v", userID, err) + } + + return nil +} + +func DeleteAccount(w http.ResponseWriter, r *http.Request) { + // Get user ID from context + userID, ok := r.Context().Value(utils.UserIDKey).(int) + if !ok { + utils.JSONResponse(w, http.StatusUnauthorized, map[string]any{ "success": false, - "message": fmt.Sprintf("Error writing users data: %v", err), + "message": "User not authenticated", + }) + return + } + + // Parse request body + var req DeleteAccountRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + utils.JSONResponse(w, http.StatusBadRequest, map[string]any{ + "success": false, + "message": "Invalid request body", }) return } - utils.UsersFileMutex.Unlock() + derived_key, _, err := utils.CheckPasswordForUser(userID, req.Password) + if err != nil || len(derived_key) == 0 { + utils.JSONResponse(w, http.StatusOK, map[string]any{ + "success": false, + "message": "Error checking password", + "password_incorrect": true, + }) + return + } - // Delete directory of the user with all his data - if err := utils.DeleteUserData(userID); err != nil { + // Use the shared delete function + if err := deleteUserByID(userID); err != nil { utils.JSONResponse(w, http.StatusOK, map[string]any{ "success": false, - "message": fmt.Sprintf("Error deleting user data of ID %d (account already deleted): %v", userID, err), + "message": err.Error(), }) - utils.Logger.Printf("Error deleting user data of ID %d (You can savely delete the directory with the same id): %v", userID, err) + utils.Logger.Printf("Error deleting user account ID %d: %v", userID, err) return } diff --git a/backend/main.go b/backend/main.go index ea344c5..c040b86 100644 --- a/backend/main.go +++ b/backend/main.go @@ -90,6 +90,11 @@ func main() { mux.HandleFunc("GET /logs/deleteDay", middleware.RequireAuth(handlers.DeleteDay)) mux.HandleFunc("GET /logs/exportData", middleware.RequireAuth(handlers.ExportData)) + // Admin routes + mux.HandleFunc("POST /admin/validate-password", handlers.ValidateAdminPassword) + mux.HandleFunc("POST /admin/users", handlers.GetAllUsers) + mux.HandleFunc("POST /admin/delete-user", handlers.DeleteUser) + // Create a handler chain with Timeout, Logger and CORS middleware // Timeout middleware will be executed first, then Logger, then CORS handler := timeoutMiddleware(middleware.Logger(middleware.CORS(mux))) diff --git a/frontend/src/lib/settings/Admin.svelte b/frontend/src/lib/settings/Admin.svelte index 2576bf7..6bae561 100644 --- a/frontend/src/lib/settings/Admin.svelte +++ b/frontend/src/lib/settings/Admin.svelte @@ -1,15 +1,358 @@
-

{$t('settings.admin.title')}

-

{$t('settings.admin.placeholder')}

+ {#if !isAdminAuthenticated} + +
+
+
+

🔒 {$t('settings.admin.login_required')}

+

+ {$t('settings.admin.login_description')} +

+ +
+
+ + +
+ + {#if adminAuthError} +
+ {adminAuthError} +
+ {/if} + + +
+
+
+
+ {:else} + +
+

⚙️ {$t('settings.admin.title')}

+ + +
+ 🔓 {$t('settings.admin.authenticated')} + +
+ + +
+
+

👥 {$t('settings.admin.user_management')}

+
+
+ {#if isLoadingUsers} +
+
+ Loading... +
+

{$t('settings.admin.loading_users')}

+
+ {:else if users.length === 0} +

{$t('settings.admin.no_users')}

+ {:else} +
+ + + + + + + + + + + {#each users as user} + + + + + + + {/each} + +
{$t('settings.admin.id')}{$t('settings.admin.username')}{$t('settings.admin.disk_usage')}{$t('settings.admin.delete_account')}
{user.id} + {user.username} + {#if user.username === currentUser} + + {$t('settings.admin.me')} + + {/if} + {formatBytes(user.disk_usage || 0)} + + {#if user.username === currentUser} +
+ {$t('settings.admin.warning_delete_self')} +
+ {/if} + + {#if deleteUserId === user.id} +
+
+

+ {$t('settings.admin.confirm_delete')}
+ {$t('settings.admin.delete_warning', { username: user.username })} +

+
+ + +
+
+
+ {/if} +
+
+ + +
+
+
+ {$t('settings.admin.total_users')}: + {users.length} +
+
+ {$t('settings.admin.total_disk_usage')}: + {formatBytes(users.reduce((sum, user) => sum + (user.disk_usage || 0), 0))} +
+
+
+ {/if} + +
+ +
+
+
+
+ {/if} + +
+ + + +
diff --git a/frontend/src/lib/settings/Statistics.svelte b/frontend/src/lib/settings/Statistics.svelte index 6136ecc..3fbc414 100644 --- a/frontend/src/lib/settings/Statistics.svelte +++ b/frontend/src/lib/settings/Statistics.svelte @@ -16,21 +16,21 @@ const tolgee = getTolgee(['language']); // Raw day stats from backend - let dayStats = []; + let dayStats = $state([]); // Derived years list & selected year - let years = []; - let selectedYear = new Date().getFullYear(); + let years = $state([]); + let selectedYear = $state(new Date().getFullYear()); // Heatmap data (weeks -> days) - let weeks = []; + let weeks = $state([]); let maxWordCountYear = 0; let minWordCountYear = 0; // smallest > 0 value // Filter stats for selected year // Iterate all days of the year - let legendRanges = []; + let legendRanges = $state([]); // Bootstrap tooltip support - let heatmapEl; + let heatmapEl = $state(null); let dayMap = new Map(); // key -> day data const buildYearData = () => { @@ -176,9 +176,13 @@ day: '2-digit' }); + let errorOnLoading = $state(false); + let isLoading = $state(false); onMount(async () => { try { + isLoading = true; const resp = await axios.get(API_URL + '/users/statistics'); + isLoading = false; dayStats = resp.data; // Normalize key names to camelCase if backend sent lower-case dayStats = dayStats.map((d) => ({ @@ -196,7 +200,9 @@ if (!years.includes(selectedYear) && years.length) selectedYear = years[years.length - 1]; buildYearData(); } catch (e) { + isLoading = false; console.error('Failed loading statistics', e); + errorOnLoading = true; } }); @@ -369,22 +375,31 @@

{$t('settings.statistics.title')}

- {#if years.length === 0} -
- Loading... + {#if errorOnLoading} +
+

{$t('settings.statistics.error_loading_data')}

- {:else} + {/if} + + {#if isLoading} +
+
+ Loading... +
+

{$t('settings.statistics.loading_data')}

+
+ {:else if years.length !== 0}
@@ -605,6 +620,8 @@ })()} + {:else if years.length === 0} +

{$t('settings.statistics.no_data')}

{/if}
diff --git a/frontend/src/routes/(authed)/read/+page.js b/frontend/src/routes/(authed)/read/+page.js index d48ea95..68c6d4d 100644 --- a/frontend/src/routes/(authed)/read/+page.js +++ b/frontend/src/routes/(authed)/read/+page.js @@ -1,7 +1,7 @@ import {redirect} from '@sveltejs/kit' export const load = () => { - const user = JSON.parse(localStorage.getItem('user')); + const user = localStorage.getItem('user'); if (!user) { throw redirect(307, '/login'); } diff --git a/frontend/src/routes/(authed)/write/+page.js b/frontend/src/routes/(authed)/write/+page.js index d48ea95..68c6d4d 100644 --- a/frontend/src/routes/(authed)/write/+page.js +++ b/frontend/src/routes/(authed)/write/+page.js @@ -1,7 +1,7 @@ import {redirect} from '@sveltejs/kit' export const load = () => { - const user = JSON.parse(localStorage.getItem('user')); + const user = localStorage.getItem('user'); if (!user) { throw redirect(307, '/login'); } diff --git a/frontend/src/routes/login/+page.svelte b/frontend/src/routes/login/+page.svelte index 314535a..a2ba50e 100644 --- a/frontend/src/routes/login/+page.svelte +++ b/frontend/src/routes/login/+page.svelte @@ -166,7 +166,7 @@ handleMigrationProgress(response.data.username); } else { - localStorage.setItem('user', JSON.stringify(response.data.username)); + localStorage.setItem('user', response.data.username); goto('/write'); } })