--- /dev/null
+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,
+ })
+}
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 {
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
}
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)))
<script>
import { getTranslate } from '@tolgee/svelte';
+ import { API_URL } from '$lib/APIurl';
+ import axios from 'axios';
+ import { onDestroy, onMount } from 'svelte';
+ import { slide } from 'svelte/transition';
+ import { formatBytes } from '$lib/helpers';
+ import * as bootstrap from 'bootstrap';
+
const { t } = getTranslate();
+
+ let adminPassword = $state('');
+ let isAdminAuthenticated = $state(false);
+
+ let adminPasswordInput = $state(null);
+ let currentUser = $state('');
+
+ // Admin authentication state
+ let isCheckingAdminAuth = $state(false);
+ let adminAuthError = $state('');
+
+ // Admin data
+ let users = $state([]);
+ let isLoadingUsers = $state(false);
+ let deleteUserId = $state(null);
+ let isDeletingUser = $state(false);
+
+ onMount(() => {
+ currentUser = localStorage.getItem('user');
+ resetAdminState();
+ adminPasswordInput.focus();
+ });
+
+ onDestroy(() => {
+ resetAdminState();
+ });
+
+ // Admin login function
+ async function loginAsAdmin() {
+ if (isCheckingAdminAuth || !adminPassword.trim()) return;
+
+ isCheckingAdminAuth = true;
+ adminAuthError = '';
+
+ try {
+ const response = await axios.post(API_URL + '/admin/validate-password', {
+ password: adminPassword
+ });
+
+ if (response.data.valid) {
+ isAdminAuthenticated = true;
+ loadUsers(); // Load users immediately after successful login
+ } else {
+ adminAuthError = $t('settings.admin.invalid_password');
+ }
+ } catch (error) {
+ adminAuthError = $t('settings.admin.login_error');
+ } finally {
+ isCheckingAdminAuth = false;
+ }
+ }
+
+ // Function to make API calls with admin password
+ async function makeAdminApiCall(endpoint, data = {}) {
+ return axios.post(API_URL + endpoint, {
+ ...data,
+ admin_password: adminPassword
+ });
+ }
+
+ // Load all users with disk usage
+ async function loadUsers() {
+ if (isLoadingUsers || !isAdminAuthenticated) return;
+ isLoadingUsers = true;
+
+ try {
+ const response = await makeAdminApiCall('/admin/users');
+ users = response.data.users || [];
+ } catch (error) {
+ console.error('Error loading users:', error);
+ if (error.response?.status === 401) {
+ // Admin password became invalid, reset state
+ resetAdminState();
+ }
+ } finally {
+ isLoadingUsers = false;
+ }
+ }
+
+ // Delete user
+ async function deleteUser(userId, username) {
+ if (isDeletingUser) return;
+ isDeletingUser = true;
+
+ try {
+ const response = await makeAdminApiCall('/admin/delete-user', { user_id: userId });
+ if (response.data.success) {
+ users = users.filter((user) => user.id !== userId);
+ deleteUserId = null;
+
+ // toast
+ const toast = new bootstrap.Toast(document.getElementById('toastSuccessUserDelete'));
+ toast.show();
+ } else {
+ // toast
+ const toast = new bootstrap.Toast(document.getElementById('toastErrorUserDelete'));
+ toast.show();
+ }
+ } catch (error) {
+ console.error('Error deleting user:', error);
+ if (error.response?.status === 401) {
+ resetAdminState();
+ }
+ } finally {
+ isDeletingUser = false;
+ }
+ }
+
+ // Reset admin authentication
+ function resetAdminState() {
+ isAdminAuthenticated = false;
+ adminPassword = '';
+ adminAuthError = '';
+ users = [];
+ deleteUserId = null;
+ }
+
+ function confirmDeleteUser(userId) {
+ deleteUserId = deleteUserId === userId ? null : userId;
+ }
</script>
<div class="settings-admin">
- <h2 class="h4 mb-3">{$t('settings.admin.title')}</h2>
- <p class="text-muted">{$t('settings.admin.placeholder')}</p>
+ {#if !isAdminAuthenticated}
+ <!-- Admin Login Form -->
+ <div
+ class="d-flex flex-column align-items-center justify-content-center"
+ style="min-height: 400px;"
+ >
+ <div class="card" style="width: 100%; max-width: 400px;">
+ <div class="card-body">
+ <h4 class="card-title text-center mb-4">🔒 {$t('settings.admin.login_required')}</h4>
+ <p class="card-text text-center text-muted mb-4">
+ {$t('settings.admin.login_description')}
+ </p>
+
+ <form onsubmit={loginAsAdmin}>
+ <div class="form-floating mb-3">
+ <input
+ bind:this={adminPasswordInput}
+ type="password"
+ class="form-control"
+ id="adminPassword"
+ placeholder={$t('settings.admin.password')}
+ bind:value={adminPassword}
+ disabled={isCheckingAdminAuth}
+ />
+ <label for="adminPassword">{$t('settings.admin.password')}</label>
+ </div>
+
+ {#if adminAuthError}
+ <div class="alert alert-danger" transition:slide>
+ {adminAuthError}
+ </div>
+ {/if}
+
+ <button
+ type="submit"
+ class="btn btn-primary w-100"
+ disabled={isCheckingAdminAuth || !adminPassword.trim()}
+ >
+ {#if isCheckingAdminAuth}
+ <span class="spinner-border spinner-border-sm me-2"></span>
+ {/if}
+ {$t('settings.admin.check_password')}
+ </button>
+ </form>
+ </div>
+ </div>
+ </div>
+ {:else}
+ <!-- Admin Panel Content -->
+ <div class="admin-authenticated" transition:slide>
+ <h2 class="mb-4">⚙️ {$t('settings.admin.title')}</h2>
+
+ <!-- Admin status bar -->
+ <div
+ class="d-flex align-items-center mb-4 p-3 bg-success bg-opacity-10 border border-success rounded"
+ >
+ <span class="text-success me-3">🔓 {$t('settings.admin.authenticated')}</span>
+ <button class="btn btn-outline-secondary btn-sm ms-2" onclick={resetAdminState}>
+ {$t('settings.admin.logout')}
+ </button>
+ </div>
+
+ <!-- User management card -->
+ <div class="card">
+ <div class="card-header">
+ <h4 class="card-title mb-0">👥 {$t('settings.admin.user_management')}</h4>
+ </div>
+ <div class="card-body">
+ {#if isLoadingUsers}
+ <div class="text-center p-4">
+ <div class="spinner-border" role="status">
+ <span class="visually-hidden">Loading...</span>
+ </div>
+ <p class="mt-2 text-muted">{$t('settings.admin.loading_users')}</p>
+ </div>
+ {:else if users.length === 0}
+ <p class="text-muted">{$t('settings.admin.no_users')}</p>
+ {:else}
+ <div class="table-responsive">
+ <table class="table table-striped">
+ <thead>
+ <tr>
+ <th>{$t('settings.admin.id')}</th>
+ <th>{$t('settings.admin.username')}</th>
+ <th>{$t('settings.admin.disk_usage')}</th>
+ <th>{$t('settings.admin.delete_account')}</th>
+ </tr>
+ </thead>
+ <tbody>
+ {#each users as user}
+ <tr>
+ <td>{user.id}</td>
+ <td>
+ <strong>{user.username}</strong>
+ {#if user.username === currentUser}
+ <span class="badge bg-info text-dark ms-2">
+ {$t('settings.admin.me')}
+ </span>
+ {/if}
+ </td>
+ <td>{formatBytes(user.disk_usage || 0)}</td>
+ <td>
+ <button
+ class="btn btn-danger btn-sm"
+ onclick={() => confirmDeleteUser(user.id)}
+ >
+ 🗑️ {$t('settings.admin.delete')}
+ </button>
+ {#if user.username === currentUser}
+ <div class="form-text text-muted">
+ {$t('settings.admin.warning_delete_self')}
+ </div>
+ {/if}
+
+ {#if deleteUserId === user.id}
+ <div class="mt-2" transition:slide>
+ <div class="alert alert-danger">
+ <p class="mb-2">
+ <strong>{$t('settings.admin.confirm_delete')}</strong><br />
+ {$t('settings.admin.delete_warning', { username: user.username })}
+ </p>
+ <div class="d-flex gap-2">
+ <button
+ class="btn btn-secondary btn-sm"
+ onclick={() => (deleteUserId = null)}
+ >
+ {$t('settings.admin.cancel')}
+ </button>
+ <button
+ class="btn btn-danger btn-sm"
+ onclick={() => deleteUser(user.id, user.username)}
+ disabled={isDeletingUser}
+ >
+ {#if isDeletingUser}
+ <span class="spinner-border spinner-border-sm me-1"></span>
+ {/if}
+ {$t('settings.admin.delete')}
+ </button>
+ </div>
+ </div>
+ </div>
+ {/if}
+ </td>
+ </tr>
+ {/each}
+ </tbody>
+ </table>
+ </div>
+
+ <!-- Summary -->
+ <div class="mt-3">
+ <div class="row">
+ <div class="col-md-6">
+ <strong>{$t('settings.admin.total_users')}: </strong>
+ {users.length}
+ </div>
+ <div class="col-md-6">
+ <strong>{$t('settings.admin.total_disk_usage')}: </strong>
+ {formatBytes(users.reduce((sum, user) => sum + (user.disk_usage || 0), 0))}
+ </div>
+ </div>
+ </div>
+ {/if}
+
+ <div class="mt-3">
+ <button class="btn btn-outline-primary" onclick={loadUsers} disabled={isLoadingUsers}>
+ {#if isLoadingUsers}
+ <span class="spinner-border spinner-border-sm me-2"></span>
+ {/if}
+ {$t('settings.admin.refresh_users')}
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+ {/if}
+
+ <div class="toast-container position-fixed bottom-0 end-0 p-3">
+ <div
+ id="toastSuccessUserDelete"
+ class="toast align-items-center text-bg-success"
+ role="alert"
+ aria-live="assertive"
+ aria-atomic="true"
+ >
+ <div class="d-flex">
+ <div class="toast-body">
+ {$t('settings.statistics.toast_success_user_delete')}
+ </div>
+ </div>
+ </div>
+
+ <div
+ id="toastErrorUserDelete"
+ class="toast align-items-center text-bg-danger"
+ role="alert"
+ aria-live="assertive"
+ aria-atomic="true"
+ >
+ <div class="d-flex">
+ <div class="toast-body">
+ {$t('settings.statistics.toast_error_user_delete')}
+ </div>
+ </div>
+ </div>
+ </div>
</div>
<style>
.settings-admin {
min-height: 40vh;
}
+
+ .table th {
+ background-color: rgba(13, 110, 253, 0.1);
+ }
+
+ .admin-authenticated {
+ max-width: 100%;
+ }
+
+ .card-title {
+ color: var(--bs-primary);
+ }
</style>
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 = () => {
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) => ({
if (!years.includes(selectedYear) && years.length) selectedYear = years[years.length - 1];
buildYearData();
} catch (e) {
+ isLoading = false;
console.error('Failed loading statistics', e);
+ errorOnLoading = true;
}
});
<div class="settings-stats">
<h2 class=" mb-3">{$t('settings.statistics.title')}</h2>
- {#if years.length === 0}
- <div class="spinner-border" role="status">
- <span class="visually-hidden">Loading...</span>
+ {#if errorOnLoading}
+ <div class="text-center">
+ <p class="text-danger">{$t('settings.statistics.error_loading_data')}</p>
</div>
- {:else}
+ {/if}
+
+ {#if isLoading}
+ <div class="text-center">
+ <div class="spinner-border" role="status">
+ <span class="visually-hidden">Loading...</span>
+ </div>
+ <p class="mt-2 text-muted">{$t('settings.statistics.loading_data')}</p>
+ </div>
+ {:else if years.length !== 0}
<div class="year-selector d-flex align-items-center gap-2 mb-3 flex-wrap">
<button
class="btn btn-sm btn-outline-secondary"
- on:click={prevYear}
+ onclick={prevYear}
disabled={years.indexOf(selectedYear) === 0}
aria-label="previous year">«</button
>
<select
class="form-select form-select-sm year-dropdown"
bind:value={selectedYear}
- on:change={(e) => selectYear(+e.target.value)}
+ onchange={(e) => selectYear(+e.target.value)}
>
{#each years as y}
<option value={y}>{y}</option>
</select>
<button
class="btn btn-sm btn-outline-secondary"
- on:click={nextYear}
+ onclick={nextYear}
disabled={years.indexOf(selectedYear) === years.length - 1}
aria-label="next year">»</button
>
})()}
</li>
</ul>
+ {:else if years.length === 0}
+ <p class="text-info">{$t('settings.statistics.no_data')}</p>
{/if}
</div>
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');
}
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');
}
handleMigrationProgress(response.data.username);
} else {
- localStorage.setItem('user', JSON.stringify(response.data.username));
+ localStorage.setItem('user', response.data.username);
goto('/write');
}
})