From 1f0e92afc06cba2f89fdbef98bfd454f080c1b9d Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Tue, 4 Feb 2025 23:28:36 +0800 Subject: [PATCH 01/34] Fix upgrade not redirecting the user correctly --- frontend/src/components/BillingPage.js | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/BillingPage.js b/frontend/src/components/BillingPage.js index d4033f4..9e720e0 100644 --- a/frontend/src/components/BillingPage.js +++ b/frontend/src/components/BillingPage.js @@ -78,7 +78,7 @@ const BillingPage = ({ user, onUpgradeSuccess }) => { try { validateCard(); const token = localStorage.getItem('token'); - + const paymentResponse = await fetch('http://localhost:8080/api/payment/upgrade', { method: 'POST', headers: { @@ -98,14 +98,26 @@ const BillingPage = ({ user, onUpgradeSuccess }) => { countryCode: billingInfo.countryCode }) }); - + if (!paymentResponse.ok) { const errorData = await paymentResponse.json(); throw new Error(errorData.error || 'Payment processing failed'); } - + + // Update local user data + const currentUser = JSON.parse(localStorage.getItem('user')); + const updatedUser = { + ...currentUser, + role: 'premium_user', + subscription_status: 'premium' + }; + localStorage.setItem('user', JSON.stringify(updatedUser)); + + // Call the upgrade success callback if (onUpgradeSuccess) onUpgradeSuccess(); - navigate('/premium-dashboard?upgraded=true'); + + // Force redirect and reload + window.location.href = '/premium-dashboard?upgraded=true'; } catch (err) { setError(err.message); } finally { From 5b9eeafe5b12663c9ec63e4ab12af35cfed5566e Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Tue, 4 Feb 2025 23:31:06 +0800 Subject: [PATCH 02/34] Fix minor inconsistancy --- create_test_users.sql | 4 ++-- database-setup.sql | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/create_test_users.sql b/create_test_users.sql index dcbb994..98652dd 100644 --- a/create_test_users.sql +++ b/create_test_users.sql @@ -5,7 +5,7 @@ CREATE PROCEDURE create_test_data() BEGIN -- Clear existing data SET FOREIGN_KEY_CHECKS = 0; - TRUNCATE TABLE feedback; + TRUNCATE TABLE feedbacks; TRUNCATE TABLE user_subscriptions; TRUNCATE TABLE subscription_plans; TRUNCATE TABLE share_access_logs; @@ -14,7 +14,7 @@ BEGIN TRUNCATE TABLE key_fragments; TRUNCATE TABLE files; TRUNCATE TABLE folders; - TRUNCATE TABLE key_rotation_history; + TRUNCATE TABLE key_rotation_histories; TRUNCATE TABLE password_history; TRUNCATE TABLE server_master_keys; TRUNCATE TABLE users; diff --git a/database-setup.sql b/database-setup.sql index a4697a5..f4cc5a1 100644 --- a/database-setup.sql +++ b/database-setup.sql @@ -250,8 +250,8 @@ CREATE INDEX idx_file_shares_link ON file_shares(share_link); CREATE INDEX idx_share_access_logs_share_id ON share_access_logs(share_id); CREATE INDEX idx_activity_logs_user_id ON activity_logs(user_id); CREATE INDEX idx_activity_logs_created_at ON activity_logs(created_at); -CREATE INDEX idx_feedback_user_id ON feedback(user_id); -CREATE INDEX idx_feedback_status ON feedback(status); +CREATE INDEX idx_feedback_user_id ON feedbacks(user_id); +CREATE INDEX idx_feedback_status ON feedbacks(status); CREATE INDEX idx_files_is_shared ON files(is_shared); CREATE INDEX idx_files_key_version ON files(master_key_version); CREATE INDEX idx_key_fragments_key_version ON key_fragments(master_key_version); From 79b858468deb9e0aaad99c7cb884bd9dc8139a97 Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Wed, 5 Feb 2025 10:34:48 +0800 Subject: [PATCH 03/34] Update email service --- backend/services/email.go | 53 ++++++++++++++++++++------------------- 1 file changed, 27 insertions(+), 26 deletions(-) diff --git a/backend/services/email.go b/backend/services/email.go index b9a05a2..2131154 100644 --- a/backend/services/email.go +++ b/backend/services/email.go @@ -57,32 +57,33 @@ func validateConfig(config SMTPConfig) error { } func (s *SMTPEmailService) connect() error { - s.mu.Lock() - defer s.mu.Unlock() - - // Establish a plaintext connection first - conn, err := smtp.Dial(fmt.Sprintf("%s:%d", s.config.Host, s.config.Port)) - if err != nil { - return fmt.Errorf("failed to establish SMTP connection: %w", err) - } - - // Send the STARTTLS command to upgrade the connection to TLS - if err := conn.StartTLS(&tls.Config{ - ServerName: s.config.Host, - MinVersion: tls.VersionTLS12, - }); err != nil { - conn.Close() - return fmt.Errorf("failed to upgrade to TLS: %w", err) - } - - // Authenticate with the SMTP server - if err := conn.Auth(s.auth); err != nil { - conn.Close() - return fmt.Errorf("authentication failed: %w", err) - } - - s.client = conn - return nil + s.mu.Lock() + defer s.mu.Unlock() + + tlsConfig := &tls.Config{ + ServerName: s.config.Host, + MinVersion: tls.VersionTLS12, + } + + conn, err := tls.Dial("tcp", fmt.Sprintf("%s:%d", s.config.Host, s.config.Port), tlsConfig) + if err != nil { + return fmt.Errorf("failed to establish TLS connection: %w", err) + } + + client, err := smtp.NewClient(conn, s.config.Host) + if err != nil { + conn.Close() + return fmt.Errorf("failed to create SMTP client: %w", err) + } + + // Authenticate + if err := client.Auth(s.auth); err != nil { + client.Close() + return fmt.Errorf("authentication failed: %w", err) + } + + s.client = client + return nil } func (s *SMTPEmailService) SendEmail(to, subject, body string) error { if err := s.connect(); err != nil { From d17d3f3c3bf61e075159cd31520d329722b73592 Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Wed, 5 Feb 2025 15:44:11 +0800 Subject: [PATCH 04/34] Implement mobile user support --- frontend/src/components/EndUserDashboard.js | 464 +++++++++++------- .../src/components/PremiumUserDashboard.js | 433 ++++++++++------ 2 files changed, 567 insertions(+), 330 deletions(-) diff --git a/frontend/src/components/EndUserDashboard.js b/frontend/src/components/EndUserDashboard.js index 4d3f123..4a40258 100644 --- a/frontend/src/components/EndUserDashboard.js +++ b/frontend/src/components/EndUserDashboard.js @@ -1,5 +1,16 @@ import React, { useState, useEffect } from 'react'; -import { Settings as SettingsIcon, ChevronDown, ChevronRight, Search, Upload, Folder, ChevronLeft, MoreVertical } from 'lucide-react'; +import { + Menu, + Settings as SettingsIcon, + ChevronDown, + ChevronRight, + Search, + Upload, + Folder, + ChevronLeft, + MoreVertical, + X +} from 'lucide-react'; import UploadFile from './EndUser/UploadFile'; import ViewFile from './EndUser/ViewFile'; import ViewFolder from './EndUser/ViewFolder'; @@ -25,8 +36,8 @@ const EndUserDashboard = ({ user, onLogout }) => { const [error, setError] = useState(null); const [refreshTrigger, setRefreshTrigger] = useState(0); const [isMassUploadModalOpen, setIsMassUploadModalOpen] = useState(false); - - + const [isSidebarOpen, setIsSidebarOpen] = useState(false); + const [isSearchOpen, setIsSearchOpen] = useState(false); useEffect(() => { if (selectedSection === 'Dashboard') { @@ -34,6 +45,17 @@ const EndUserDashboard = ({ user, onLogout }) => { } }, [selectedSection]); + useEffect(() => { + const handleResize = () => { + if (window.innerWidth >= 768) { + setIsSidebarOpen(false); + } + }; + + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, []); + const refreshCurrentView = async () => { if (currentFolder) { await fetchFolderContents(currentFolder.id); @@ -60,12 +82,11 @@ const EndUserDashboard = ({ user, onLogout }) => { credentials: 'include' }); - if (response.status === 401) { - onLogout(); - return; - } - if (!response.ok) { + if (response.status === 401) { + onLogout(); + return; + } throw new Error('Failed to fetch folders'); } @@ -97,12 +118,11 @@ const EndUserDashboard = ({ user, onLogout }) => { credentials: 'include' }); - if (response.status === 401) { - onLogout(); - return; - } - if (!response.ok) { + if (response.status === 401) { + onLogout(); + return; + } throw new Error('Failed to fetch folder contents'); } @@ -163,23 +183,23 @@ const EndUserDashboard = ({ user, onLogout }) => { if (!currentFolder) return null; return ( -
+
{folderPath.map((folder, index) => ( - + -
- -
- - {/* Recent Files Section */} -
-

Recent

- -
- - ); - - - - return ( -
-
-
- SafeSplit Logo +
+
+
+
+

+ {currentFolder ? currentFolder.name : 'Folders'} +

+ + ({folders.length} {folders.length === 1 ? 'folder' : 'folders'}) + +
+
- - + +
-
-
+
+

Recent

+ +
+
+ ); + + const Sidebar = () => ( +
+
+ SafeSplit Logo + +
+ +
+ + {isFilesOpen && ( +
    + {['Uploaded Files', 'Shared Files', 'Archives'].map((section) => ( +
  • + +
  • + ))} +
+ )} + + +
  • + +
  • + + + +
    +
    + +
    +
    + ); -
    -
    -
    -

    {selectedSection}

    + return ( +
    + + + {/* Overlay for mobile sidebar */} + {isSidebarOpen && ( +
    setIsSidebarOpen(false)} + /> + )} + +
    + {/* Mobile Header */} +
    +
    + +

    {selectedSection}

    {selectedSection !== 'Settings' && selectedSection !== 'Contact Us' && ( -
    -
    - - setIsSearchOpen(!isSearchOpen)} + className="p-2 hover:bg-gray-100 rounded" + > + + + )} +
    + + {/* Mobile Search Bar */} + {isSearchOpen && selectedSection !== 'Settings' && selectedSection !== 'Contact Us' && ( +
    +
    + + +
    +
    + )} +
    + +
    +
    + {/* Desktop Header */} +
    +

    {selectedSection}

    + {selectedSection !== 'Settings' && selectedSection !== 'Contact Us' && ( +
    +
    + + +
    + setIsUploadModalOpen(true)} + onMassUpload={() => setIsMassUploadModalOpen(true)} + /> +
    + )} +
    + + {/* Mobile Upload Button */} + {selectedSection !== 'Settings' && selectedSection !== 'Contact Us' && ( +
    + setIsUploadModalOpen(true)} + onMassUpload={() => setIsMassUploadModalOpen(true)} />
    - - setIsUploadModalOpen(true)} - onMassUpload={() => setIsMassUploadModalOpen(true)} - /> -
    - )} -
    + )} + + {/* Breadcrumbs */} + {renderBreadcrumbs()} + + {/* Main Content */} + {error && ( +
    + {error} +
    + )} - {selectedSection === 'Settings' && } - {selectedSection === 'Contact Us' && console.log("Form Submitted:", formData)} />} - {selectedSection === 'Dashboard' && renderDashboard()} - {selectedSection !== 'Settings' && - selectedSection !== 'Contact Us' && - selectedSection !== 'Dashboard' && ( - - )} + {isLoading ? ( +
    +
    +
    + ) : ( + <> + {selectedSection === 'Settings' && } + {selectedSection === 'Contact Us' && console.log("Form Submitted:", formData)} />} + {selectedSection === 'Dashboard' && renderDashboard()} + {selectedSection !== 'Settings' && + selectedSection !== 'Contact Us' && + selectedSection !== 'Dashboard' && ( + + )} + + )} +
    + + {/* Modals */} setIsMassUploadModalOpen(false)} diff --git a/frontend/src/components/PremiumUserDashboard.js b/frontend/src/components/PremiumUserDashboard.js index 228b855..7c583cf 100644 --- a/frontend/src/components/PremiumUserDashboard.js +++ b/frontend/src/components/PremiumUserDashboard.js @@ -1,5 +1,17 @@ import React, { useState, useEffect } from 'react'; -import { Settings, ChevronDown, ChevronRight, Search, Upload, Folder, ChevronLeft, MoreVertical } from 'lucide-react'; +import { + Menu, + Settings, + ChevronDown, + ChevronRight, + Search, + Upload, + Folder, + ChevronLeft, + MoreVertical, + X, + Trash2 +} from 'lucide-react'; import UploadFile from './EndUser/UploadFile'; import ViewFile from './EndUser/ViewFile'; import ViewFolder from './EndUser/ViewFolder'; @@ -7,7 +19,7 @@ import SettingsPage from './EndUser/Settings'; import ContactUs from './EndUser/ContactUs'; import CreateFolder from './EndUser/CreateFolder'; import DeleteFolder from './EndUser/DeleteFolder'; -import TrashBin from './PremiumUser/TrashBin'; +import TrashBin from './PremiumUser/TrashBin'; import MassUploadFile from './EndUser/MassUploadFile'; import UploadButton from './EndUser/UploadButton'; @@ -26,6 +38,8 @@ const PremiumUserDashboard = ({ user, onLogout }) => { const [folderToDelete, setFolderToDelete] = useState(null); const [error, setError] = useState(null); const [refreshTrigger, setRefreshTrigger] = useState(0); + const [isSidebarOpen, setIsSidebarOpen] = useState(false); + const [isSearchOpen, setIsSearchOpen] = useState(false); useEffect(() => { if (selectedSection === 'Dashboard') { @@ -33,6 +47,17 @@ const PremiumUserDashboard = ({ user, onLogout }) => { } }, [selectedSection]); + useEffect(() => { + const handleResize = () => { + if (window.innerWidth >= 768) { + setIsSidebarOpen(false); + } + }; + + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, []); + const refreshCurrentView = async () => { if (currentFolder) { await fetchFolderContents(currentFolder.id); @@ -162,23 +187,23 @@ const PremiumUserDashboard = ({ user, onLogout }) => { if (!currentFolder) return null; return ( -
    +
    {folderPath.map((folder, index) => ( - + @@ -233,152 +258,270 @@ const PremiumUserDashboard = ({ user, onLogout }) => {
    ); - return ( -
    -
    -
    - SafeSplit Logo -
    - - + +
    +
    + +
    +
    + ); -
    -
    -
    -

    {selectedSection}

    + return ( +
    + + + {/* Overlay for mobile sidebar */} + {isSidebarOpen && ( +
    setIsSidebarOpen(false)} + /> + )} + +
    + {/* Mobile Header */} +
    +
    + +

    {selectedSection}

    {selectedSection !== 'Settings' && selectedSection !== 'Contact Us' && ( -
    -
    - - -
    - - setIsUploadModalOpen(true)} - onMassUpload={() => setIsMassUploadModalOpen(true)} - /> -
    + )}
    - {selectedSection === 'Settings' && } - {selectedSection === 'Contact Us' && console.log("Form Submitted:", formData)} />} - {selectedSection === 'Dashboard' && renderDashboard()} - {['Uploaded Files', 'Shared Files', 'Archives'].includes(selectedSection) && ( - + {/* Mobile Search Bar */} + {isSearchOpen && selectedSection !== 'Settings' && selectedSection !== 'Contact Us' && ( +
    +
    + + +
    +
    )} - {selectedSection === 'Trash Bin' && ( - + + {/* Mobile Upload Button */} + {selectedSection !== 'Settings' && selectedSection !== 'Contact Us' && ( +
    + setIsUploadModalOpen(true)} + onMassUpload={() => setIsMassUploadModalOpen(true)} + /> +
    )}
    + +
    +
    + {/* Desktop Header */} +
    +

    {selectedSection}

    + {selectedSection !== 'Settings' && selectedSection !== 'Contact Us' && ( +
    +
    + + +
    + setIsUploadModalOpen(true)} + onMassUpload={() => setIsMassUploadModalOpen(true)} + /> +
    + )} +
    + + {/* Breadcrumbs */} + {renderBreadcrumbs()} + {/* Content */} + {error && ( +
    + {error} + +
    + )} + + {isLoading ? ( +
    +
    +
    + ) : ( + <> + {selectedSection === 'Settings' && } + {selectedSection === 'Contact Us' && console.log("Form Submitted:", formData)} />} + {selectedSection === 'Dashboard' && renderDashboard()} + {['Uploaded Files', 'Shared Files', 'Archives'].includes(selectedSection) && ( + + )} + {selectedSection === 'Trash Bin' && ( +
    + +
    + )} + + )} +
    +
    + + {/* Floating error notification for mobile */} + {error && ( +
    + {error} + +
    + )}
    + {/* Modals */} setIsMassUploadModalOpen(false)} @@ -423,26 +566,6 @@ const PremiumUserDashboard = ({ user, onLogout }) => { } }} /> - - {error && ( -
    - {error} - -
    - )} - - {isLoading && ( -
    -
    -
    -
    -
    - )}
    ); }; From 130b72821fdd6275ac3ae21ca2f3b1c828c44b0c Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Wed, 5 Feb 2025 15:49:15 +0800 Subject: [PATCH 05/34] Update create_test_users.sql --- create_test_users.sql | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/create_test_users.sql b/create_test_users.sql index 98652dd..6559048 100644 --- a/create_test_users.sql +++ b/create_test_users.sql @@ -71,7 +71,7 @@ BEGIN ), ( 'sys_admin', - 'sys_admin@example.com', + 'sys_admin@safesplit.xyz', '$2a$10$b.WsKp9GR.8pcdQjxMggGeCtTL7nvuc1oW2LfZu0FrM5SLv3dhkge', UNHEX('6293E61742A9A26D16ABC91564FE26157923B855F58535AD73E9720C60F94C22'), -- salt UNHEX('6293E61742A9A26D16ABC91564FE2615'), -- nonce @@ -85,7 +85,7 @@ BEGIN ), ( 'super_admin', - 'super_admin@example.com', + 'super_admin@safesplit.xyz', '$2a$10$b.WsKp9GR.8pcdQjxMggGeCtTL7nvuc1oW2LfZu0FrM5SLv3dhkge', UNHEX('B5F83100C6B4F1FF3865A9FB3A32CBB1EF4770A734026D0AE0451737109750C7'), -- salt UNHEX('B5F83100C6B4F1FF3865A9FB3A32CBB1'), -- nonce From d5073ff694f8a2cc7ea1bf6b08b560754f095ee4 Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Wed, 5 Feb 2025 16:14:47 +0800 Subject: [PATCH 06/34] Delete settings for SA --- backend/services/two_factor_auth.go | 4 +-- frontend/src/components/SysAdminDashboard.js | 30 +++++--------------- 2 files changed, 9 insertions(+), 25 deletions(-) diff --git a/backend/services/two_factor_auth.go b/backend/services/two_factor_auth.go index 7b9065c..75abec5 100644 --- a/backend/services/two_factor_auth.go +++ b/backend/services/two_factor_auth.go @@ -109,9 +109,9 @@ func (s *TwoFactorAuthService) SendTwoFactorToken(userID uint, email string) err body := fmt.Sprintf(`Hello, Your two-factor authentication code is: -**%s** +%s -This code will expire in **10 minutes**. Please use it to complete your login process. +This code will expire in 10 minutes. Please use it to complete your login process. If you didn't request this code, please ignore this email or contact our support team immediately. diff --git a/frontend/src/components/SysAdminDashboard.js b/frontend/src/components/SysAdminDashboard.js index dc82367..107afbd 100644 --- a/frontend/src/components/SysAdminDashboard.js +++ b/frontend/src/components/SysAdminDashboard.js @@ -4,7 +4,6 @@ import { ChevronDown, ChevronRight, HardDrive, - Settings } from 'lucide-react'; import ViewUserAccounts from './SysAdmin/ViewUserAccounts'; import ViewStorage from './SysAdmin/ViewStorage'; @@ -105,8 +104,6 @@ const SysAdminDashboard = ({ user, onLogout }) => { return ; case 'Reports': return ; - case 'Settings': - return
    Settings
    ; default: return renderDashboardContent(); } @@ -220,30 +217,17 @@ const SysAdminDashboard = ({ user, onLogout }) => { View Storage - -
  • - -
  • - - -
    + +
    {/* Main Content */} From daa246520a065ed24cb491eaaf97fae7a6cd1c3b Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Wed, 5 Feb 2025 16:45:38 +0800 Subject: [PATCH 07/34] Enforce 2FA for admin and super admin, update view account to display accurate info, delete super admin settings --- .../SuperAdmin/SuperAdminLoginController.go | 40 ++++- backend/models/user.go | 42 ++++-- .../src/components/SuperAdminDashboard.js | 15 +- .../src/components/SysAdmin/ViewUserAction.js | 141 ++++++++++++++++-- .../src/components/auth/SuperAdminLogin.js | 65 ++++++-- 5 files changed, 245 insertions(+), 58 deletions(-) diff --git a/backend/controllers/SuperAdmin/SuperAdminLoginController.go b/backend/controllers/SuperAdmin/SuperAdminLoginController.go index 76ef0d8..51dbbf1 100644 --- a/backend/controllers/SuperAdmin/SuperAdminLoginController.go +++ b/backend/controllers/SuperAdmin/SuperAdminLoginController.go @@ -12,6 +12,12 @@ type LoginController struct { userModel *models.UserModel } +type LoginRequest struct { + Email string `json:"email" binding:"required"` + Password string `json:"password" binding:"required"` + TwoFactorCode string `json:"two_factor_code"` +} + func NewLoginController(userModel *models.UserModel) *LoginController { return &LoginController{ userModel: userModel, @@ -19,24 +25,44 @@ func NewLoginController(userModel *models.UserModel) *LoginController { } func (c *LoginController) Login(ctx *gin.Context) { - var loginReq struct { - Email string `json:"email" binding:"required"` - Password string `json:"password" binding:"required"` - } - + var loginReq LoginRequest if err := ctx.ShouldBindJSON(&loginReq); err != nil { ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - // Authenticate super admin + // First authenticate super admin credentials user, err := c.userModel.AuthenticateSuperAdmin(loginReq.Email, loginReq.Password) if err != nil { ctx.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid super admin credentials"}) return } - // Generate token + // Always require 2FA for super admin + if loginReq.TwoFactorCode == "" { + // Initiate 2FA if code not provided + if err := c.userModel.InitiateEmailTwoFactor(user.ID); err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{ + "error": "Failed to send 2FA code", + }) + return + } + + ctx.JSON(http.StatusAccepted, gin.H{ + "message": "2FA required", + "requires_2fa": true, + "user_id": user.ID, + }) + return + } + + // Verify 2FA code + if err := c.userModel.VerifyEmailTwoFactor(user.ID, loginReq.TwoFactorCode); err != nil { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid 2FA code"}) + return + } + + // Generate token after successful 2FA token, err := config.GenerateToken(user.ID, user.Role) if err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Error generating token"}) diff --git a/backend/models/user.go b/backend/models/user.go index 52be796..70d309e 100644 --- a/backend/models/user.go +++ b/backend/models/user.go @@ -168,7 +168,10 @@ func (m *UserModel) Authenticate(email, password string) (*User, error) { return nil, errors.New("invalid credentials") } - // Reset failed attempts and update login time + if user.Role == RoleSysAdmin || user.TwoFactorEnabled { + return &user, nil + } + return m.handleSuccessfulLogin(&user) } @@ -407,15 +410,30 @@ func (m *UserModel) CreateSysAdmin(creator *User, newAdmin *User) (*User, error) return nil, errors.New("unauthorized: only super admins can create system administrators") } - // Ensure the new user is created as a sys_admin - newAdmin.Role = RoleSysAdmin + err := m.db.Transaction(func(tx *gorm.DB) error { + newAdmin.Role = RoleSysAdmin + + if err := tx.Create(newAdmin).Error; err != nil { + return fmt.Errorf("failed to create system administrator: %v", err) + } + + if err := tx.Model(newAdmin).Update("two_factor_enabled", true).Error; err != nil { + return fmt.Errorf("failed to enable 2FA: %v", err) + } + + return nil + }) + + if err != nil { + return nil, err + } - // Create the new admin user - if err := m.db.Create(newAdmin).Error; err != nil { - return nil, fmt.Errorf("failed to create system administrator: %v", err) + var createdAdmin User + if err := m.db.First(&createdAdmin, newAdmin.ID).Error; err != nil { + return nil, fmt.Errorf("failed to load created admin: %v", err) } - return newAdmin, nil + return &createdAdmin, nil } // View Sys admin account @@ -826,7 +844,7 @@ func (m *UserModel) ResetPasswordWithFragments( // Decrypt fragment with current decrypted master key decryptedFragment, err := services.DecryptMasterKey( fragment.Data, - userMasterKey, + userMasterKey, fragment.EncryptionNonce, ) if err != nil { @@ -842,10 +860,10 @@ func (m *UserModel) ResetPasswordWithFragments( return fmt.Errorf("failed to generate nonce for fragment: %w", err) } - // Re-encrypt with same decrypted master key + // Re-encrypt with same decrypted master key newEncryptedFragment, err := services.EncryptMasterKey( decryptedFragment, - userMasterKey, + userMasterKey, newFragmentNonce, ) if err != nil { @@ -930,7 +948,7 @@ func (m *UserModel) updateKeyFragments( // Decrypt fragment using old master key decryptedFragment, err := services.DecryptMasterKey( fragment.Data, - oldMasterKey, + oldMasterKey, fragment.EncryptionNonce, ) if err != nil { @@ -949,7 +967,7 @@ func (m *UserModel) updateKeyFragments( // Re-encrypt fragment with new decrypted master key newEncryptedFragment, err := services.EncryptMasterKey( decryptedFragment, - decryptedMasterKey, + decryptedMasterKey, newNonce, ) if err != nil { diff --git a/frontend/src/components/SuperAdminDashboard.js b/frontend/src/components/SuperAdminDashboard.js index 4304fbf..74aba96 100644 --- a/frontend/src/components/SuperAdminDashboard.js +++ b/frontend/src/components/SuperAdminDashboard.js @@ -1,5 +1,5 @@ import React, { useState } from 'react'; -import { Users, ChevronDown, ChevronRight, MoreVertical, Settings, FileText, UserPlus } from 'lucide-react'; +import { Users, ChevronDown, ChevronRight, MoreVertical, FileText, UserPlus } from 'lucide-react'; import CreateSysAdminForm from './SuperAdmin/CreateSysAdminForm'; import ViewSysAdminAccount from './SuperAdmin/ViewSysAdminAccount'; import SystemLogs from './SuperAdmin/SystemLogs'; @@ -149,23 +149,10 @@ const SuperAdminDashboard = ({ user, onLogout }) => { System Logs - -
  • - -
  • -
    From 4c17ca64ca9fff41ec947e66ce39a21bd0a398ae Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Sun, 9 Feb 2025 16:15:25 +0800 Subject: [PATCH 08/34] Implement share recipient via email, fix corrupted file with share file, implement 2fa verification with share. --- .../EndUser/ShareFileController.go | 503 +++++++++++------- .../AdvancedShareFileController.go | 428 +++++++++------ backend/jobs/Subcription.go | 1 - backend/middleware/auth_middleware.go | 7 +- backend/models/feedback.go | 89 ++-- backend/models/file.go | 41 +- backend/models/file_share.go | 328 +++++------- database-setup.sql | 4 +- frontend/src/App.js | 37 +- .../src/components/EndUser/ShareFileAction.js | 470 ++++++++-------- .../components/EndUser/SharedFileAccess.js | 317 +++++++---- 11 files changed, 1246 insertions(+), 979 deletions(-) diff --git a/backend/controllers/EndUser/ShareFileController.go b/backend/controllers/EndUser/ShareFileController.go index aba4759..51a7223 100644 --- a/backend/controllers/EndUser/ShareFileController.go +++ b/backend/controllers/EndUser/ShareFileController.go @@ -6,22 +6,28 @@ import ( "log" "net/http" "net/url" + "os" "safesplit/models" "safesplit/services" + "strconv" "strings" + "time" "github.com/gin-gonic/gin" ) type ShareFileController struct { - fileModel *models.FileModel - fileShareModel *models.FileShareModel - keyFragmentModel *models.KeyFragmentModel - encryptionService *services.EncryptionService - activityLogModel *models.ActivityLogModel - rsService *services.ReedSolomonService - userModel *models.UserModel - serverKeyModel *models.ServerMasterKeyModel + fileModel *models.FileModel + fileShareModel *models.FileShareModel + keyFragmentModel *models.KeyFragmentModel + encryptionService *services.EncryptionService + activityLogModel *models.ActivityLogModel + rsService *services.ReedSolomonService + userModel *models.UserModel + serverKeyModel *models.ServerMasterKeyModel + twoFactorService *services.TwoFactorAuthService + emailService *services.SMTPEmailService + compressionService *services.CompressionService } func NewShareFileController( @@ -33,340 +39,434 @@ func NewShareFileController( rsService *services.ReedSolomonService, userModel *models.UserModel, serverKeyModel *models.ServerMasterKeyModel, + twoFactorService *services.TwoFactorAuthService, + emailService *services.SMTPEmailService, + compressionService *services.CompressionService, ) *ShareFileController { return &ShareFileController{ - fileModel: fileModel, - fileShareModel: fileShareModel, - keyFragmentModel: keyFragmentModel, - encryptionService: encryptionService, - activityLogModel: activityLogModel, - rsService: rsService, - userModel: userModel, - serverKeyModel: serverKeyModel, + fileModel: fileModel, + fileShareModel: fileShareModel, + keyFragmentModel: keyFragmentModel, + encryptionService: encryptionService, + activityLogModel: activityLogModel, + rsService: rsService, + userModel: userModel, + serverKeyModel: serverKeyModel, + twoFactorService: twoFactorService, + emailService: emailService, + compressionService: compressionService, } } type CreateShareRequest struct { - FileID uint `json:"file_id" binding:"required"` - Password string `json:"password" binding:"required,min=6"` + ShareType models.ShareType `json:"share_type" binding:"required"` + Password string `json:"password" binding:"required,min=6"` + Email string `json:"email,omitempty"` } type AccessShareRequest struct { Password string `json:"password" binding:"required"` + Email string `json:"email,omitempty"` +} + +type TwoFactorRequest struct { + Code string `json:"code" binding:"required"` + Password string `json:"password" binding:"required"` } func (c *ShareFileController) CreateShare(ctx *gin.Context) { - log.Printf("Received normal share creation request for file ID: %v", ctx.Param("id")) + // Get file ID from URL parameter + fileID := ctx.Param("id") + if fileID == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "File ID is required"}) + return + } + + // Convert string ID to uint + id, err := strconv.ParseUint(fileID, 10, 64) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid file ID"}) + return + } + var req CreateShareRequest if err := ctx.ShouldBindJSON(&req); err != nil { - ctx.JSON(http.StatusBadRequest, gin.H{ - "status": "error", - "error": "Invalid request data", - }) + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request data"}) return } - // Get current user - user, exists := ctx.Get("user") - if !exists { - ctx.JSON(http.StatusUnauthorized, gin.H{ - "status": "error", - "error": "Unauthorized access", - }) + if req.ShareType == models.RecipientShare && req.Email == "" { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Email required for recipient share"}) return } - currentUser := user.(*models.User) - // Get file and verify ownership - file, err := c.fileModel.GetFileForDownload(req.FileID, currentUser.ID) + user := ctx.MustGet("user").(*models.User) + file, err := c.fileModel.GetFileForDownload(uint(id), user.ID) if err != nil { - ctx.JSON(http.StatusNotFound, gin.H{ - "status": "error", - "error": "File not found or access denied", - }) + ctx.JSON(http.StatusNotFound, gin.H{"error": "File not found"}) return } - // Derive user's KEK - kek, err := services.DeriveKeyEncryptionKey(currentUser.Password, currentUser.MasterKeySalt) + kek, err := services.DeriveKeyEncryptionKey(user.Password, user.MasterKeySalt) if err != nil { - log.Printf("Failed to derive KEK: %v", err) - ctx.JSON(http.StatusInternalServerError, gin.H{ - "status": "error", - "error": "Failed to process encryption", - }) + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Encryption failed"}) return } - // Decrypt user's master key - decryptedMasterKey, err := services.DecryptMasterKey( - currentUser.EncryptedMasterKey, - kek, - currentUser.MasterKeyNonce, - ) + decryptedMasterKey, err := services.DecryptMasterKey(user.EncryptedMasterKey, kek, user.MasterKeyNonce) if err != nil { - log.Printf("Failed to decrypt master key: %v", err) - ctx.JSON(http.StatusInternalServerError, gin.H{ - "status": "error", - "error": "Failed to process encryption", - }) + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Decryption failed"}) return } - // Use first 32 bytes of decrypted master key userMasterKey := decryptedMasterKey[:32] - - // Get fragments fragments, err := c.keyFragmentModel.GetUserFragmentsForFile(file.ID) if err != nil || len(fragments) == 0 { - log.Printf("Failed to retrieve key fragments for file %d: %v", file.ID, err) - ctx.JSON(http.StatusInternalServerError, gin.H{ - "status": "error", - "error": "Failed to retrieve key fragments", - }) + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get fragments"}) return } - log.Printf("Creating share for file %d with %d fragments", file.ID, len(fragments)) - // Get first fragment and remember its index userFragment := fragments[0] - log.Printf("Selected user fragment with index %d for sharing", userFragment.FragmentIndex) - - // Decrypt the fragment we'll share using master key decryptedFragment, err := services.DecryptMasterKey( userFragment.Data, userMasterKey, userFragment.EncryptionNonce, ) if err != nil { - log.Printf("Failed to decrypt fragment: %v", err) - ctx.JSON(http.StatusInternalServerError, gin.H{ - "status": "error", - "error": "Failed to process share creation", - }) + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Fragment decryption failed"}) return } - // Encrypt decrypted fragment with share password encryptedFragment, err := c.encryptionService.EncryptKeyFragment( decryptedFragment, []byte(req.Password), ) if err != nil { - log.Printf("Failed to encrypt key fragment: %v", err) - ctx.JSON(http.StatusInternalServerError, gin.H{ - "status": "error", - "error": "Failed to process share encryption", - }) + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Fragment encryption failed"}) return } - // Create share record with original fragment index share := &models.FileShare{ FileID: file.ID, - SharedBy: currentUser.ID, + SharedBy: user.ID, EncryptedKeyFragment: encryptedFragment, - FragmentIndex: userFragment.FragmentIndex, // Store original index + FragmentIndex: userFragment.FragmentIndex, IsActive: true, + ShareType: req.ShareType, + Email: req.Email, } if err := c.fileShareModel.CreateFileShare(share, req.Password); err != nil { - log.Printf("Failed to create file share: %v", err) - ctx.JSON(http.StatusInternalServerError, gin.H{ - "status": "error", - "error": "Failed to create share", - }) + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Share creation failed"}) return } - if err := c.activityLogModel.LogActivity(&models.ActivityLog{ - UserID: currentUser.ID, + // Get base URL from environment variable + baseURL := os.Getenv("BASE_URL") + if baseURL == "" { + baseURL = "http://localhost:8080" + } + + // Create the complete share URL + shareURL := fmt.Sprintf("%s/api/files/share/%s", baseURL, share.ShareLink) + + if req.ShareType == models.RecipientShare { + // Get base URL from environment variable + baseURL := os.Getenv("BASE_URL") + if baseURL == "" { + baseURL = "http://localhost:3000" + } + + // Create the frontend share URL (not the API URL) + shareURL := fmt.Sprintf("%s/protected-share/%s", baseURL, share.ShareLink) + + emailBody := fmt.Sprintf(`Hello, + + You have received a secure file share from %s. + + File: %s + Access Link: %s + + This link requires a password and email verification to access. + Please use the same email address this message was sent to when accessing the file. + + Best regards, + SafeSplit Team`, user.Username, file.OriginalName, shareURL) + + if err := c.emailService.SendEmail( + req.Email, + "Secure File Share Received", + emailBody, + ); err != nil { + log.Printf("Failed to send email: %v", err) + } + } + + c.activityLogModel.LogActivity(&models.ActivityLog{ + UserID: user.ID, ActivityType: "share", FileID: &file.ID, IPAddress: ctx.ClientIP(), Status: "success", - }); err != nil { - log.Printf("Failed to log share activity: %v", err) - } + Details: fmt.Sprintf("Created %s share", req.ShareType), + }) ctx.JSON(http.StatusOK, gin.H{ "status": "success", "data": gin.H{ - "share_link": share.ShareLink, + "share_link": shareURL, + "raw_link": share.ShareLink, + "requires_2fa": req.ShareType == models.RecipientShare, }, }) } func (c *ShareFileController) AccessShare(ctx *gin.Context) { shareLink := ctx.Param("shareLink") - log.Printf("Received normal share access request for link: %s", shareLink) + + // For GET requests, return file info and requirements + if ctx.Request.Method == "GET" { + share, err := c.fileShareModel.GetShareByLink(shareLink) + if err != nil { + ctx.JSON(http.StatusUnauthorized, gin.H{ + "status": "error", + "error": "Invalid share"}) + return + } + + // Get file info + file, err := c.fileModel.GetFileByID(share.FileID) + if err != nil { + ctx.JSON(http.StatusNotFound, gin.H{ + "status": "error", + "error": "File not found"}) + return + } + + ctx.JSON(http.StatusOK, gin.H{ + "status": "success", + "data": gin.H{ + "requires_password": true, + "requires_2fa": share.ShareType == models.RecipientShare, + "recipient_share": share.ShareType == models.RecipientShare, + "file_name": file.OriginalName, + "file_size": file.Size, + "mime_type": file.MimeType, + "created_at": share.CreatedAt, + "expires_at": share.ExpiresAt, + "download_count": share.DownloadCount, + "max_downloads": share.MaxDownloads, + }, + }) + return + } + + // Handle POST request var req AccessShareRequest if err := ctx.ShouldBindJSON(&req); err != nil { ctx.JSON(http.StatusBadRequest, gin.H{ "status": "error", - "error": "Invalid password", - }) + "error": "Invalid request"}) return } - // Get and validate share - share, err := c.fileShareModel.ValidateShare(shareLink, req.Password) + share, err := c.fileShareModel.GetShareByLink(shareLink) if err != nil { ctx.JSON(http.StatusUnauthorized, gin.H{ "status": "error", - "error": "Invalid share link or password", - }) + "error": "Invalid share"}) + return + } + + // Check if share has expired + if share.ExpiresAt != nil && time.Now().After(*share.ExpiresAt) { + ctx.JSON(http.StatusForbidden, gin.H{ + "status": "error", + "error": "Share link has expired"}) return } - log.Printf("Processing share access for link: %s", shareLink) + // Check if maximum downloads reached + if share.MaxDownloads != nil && share.DownloadCount >= *share.MaxDownloads { + ctx.JSON(http.StatusForbidden, gin.H{ + "status": "error", + "error": "Maximum number of downloads reached"}) + return + } - // Get file metadata - file, err := c.fileModel.GetFileByID(share.FileID) - if err != nil { - log.Printf("Failed to get file %d: %v", share.FileID, err) - ctx.JSON(http.StatusNotFound, gin.H{ + // Check if share is still active + if !share.IsActive { + ctx.JSON(http.StatusForbidden, gin.H{ "status": "error", - "error": "File not found", + "error": "Share link is no longer active"}) + return + } + + // Validate share based on type + if share.ShareType == models.RecipientShare { + // Validate password only + share, validationErr := c.fileShareModel.ValidateRecipientShare(shareLink, req.Password) + if validationErr != nil { + ctx.JSON(http.StatusUnauthorized, gin.H{ + "status": "error", + "error": "Invalid password"}) + return + } + + // Send 2FA to the email associated with the share + if err := c.twoFactorService.SendTwoFactorToken(share.ID, share.Email); err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{ + "status": "error", + "error": "Failed to send 2FA code"}) + return + } + + ctx.JSON(http.StatusOK, gin.H{ + "status": "success", + "message": "2FA code sent to registered email", + "data": gin.H{ + "share_id": share.ID, + }, }) return + } else { + // For normal shares, just validate password + share, err := c.fileShareModel.ValidateShare(shareLink, req.Password) + if err != nil { + ctx.JSON(http.StatusUnauthorized, gin.H{ + "status": "error", + "error": "Invalid password"}) + return + } + c.processFileAccess(ctx, share, req.Password) + } +} +func (c *ShareFileController) Verify2FAAndDownload(ctx *gin.Context) { + shareLink := ctx.Param("shareLink") + var req TwoFactorRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{ + "status": "error", + "error": "Invalid request data"}) + return } - // Get server fragments - serverFragments, err := c.keyFragmentModel.GetServerFragmentsForFile(share.FileID) + share, err := c.fileShareModel.GetShareByLink(shareLink) if err != nil { - log.Printf("Failed to get server fragments: %v", err) - ctx.JSON(http.StatusInternalServerError, gin.H{ + ctx.JSON(http.StatusUnauthorized, gin.H{ "status": "error", - "error": "Failed to process file access", - }) + "error": "Invalid share"}) return } - log.Printf("Retrieved %d server fragments", len(serverFragments)) + // Verify share type + if share.ShareType != models.RecipientShare { + ctx.JSON(http.StatusBadRequest, gin.H{ + "status": "error", + "error": "2FA verification only required for recipient shares"}) + return + } - // Verify we have enough fragments - if len(serverFragments)+1 < int(file.Threshold) { // +1 for shared fragment - log.Printf("Insufficient fragments: have %d server + 1 shared, need %d", - len(serverFragments), file.Threshold) - ctx.JSON(http.StatusInternalServerError, gin.H{ + // Verify 2FA code + if err := c.twoFactorService.VerifyToken(share.ID, req.Code); err != nil { + ctx.JSON(http.StatusUnauthorized, gin.H{ "status": "error", - "error": "Insufficient fragments to reconstruct file", - }) + "error": "Invalid 2FA code"}) + return + } + + // Process file download + c.processFileAccess(ctx, share, req.Password) +} + +func (c *ShareFileController) processFileAccess(ctx *gin.Context, share *models.FileShare, password string) { + file, err := c.fileModel.GetFileByID(share.FileID) + if err != nil { + ctx.JSON(http.StatusNotFound, gin.H{"error": "File not found"}) + return + } + + serverFragments, err := c.keyFragmentModel.GetServerFragmentsForFile(share.FileID) + if err != nil || len(serverFragments)+1 < int(file.Threshold) { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Insufficient fragments"}) return } - // Get server key for decrypting server fragments serverKey, err := c.serverKeyModel.GetActive() if err != nil { - log.Printf("Failed to get server key: %v", err) - ctx.JSON(http.StatusInternalServerError, gin.H{ - "status": "error", - "error": "Failed to process decryption", - }) + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Server key error"}) return } serverKeyData, err := c.serverKeyModel.GetServerKey(serverKey.KeyID) if err != nil { - log.Printf("Failed to get server key data: %v", err) - ctx.JSON(http.StatusInternalServerError, gin.H{ - "status": "error", - "error": "Failed to get server key", - }) + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Server key data error"}) return } - // Decrypt shared fragment sharedDecryptedFragment, err := c.encryptionService.DecryptKeyFragment( share.EncryptedKeyFragment, - []byte(req.Password), + []byte(password), ) if err != nil { - log.Printf("Failed to decrypt shared fragment: %v", err) - ctx.JSON(http.StatusInternalServerError, gin.H{ - "status": "error", - "error": "Failed to process file decryption", - }) + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Fragment decryption failed"}) return } - // We need threshold number of unique shares shares := make([]services.KeyShare, file.Threshold) usedIndices := make(map[int]bool) - // Add the shared fragment first with its original index shares[0] = services.KeyShare{ - Index: share.FragmentIndex, // Use stored original index + Index: share.FragmentIndex, Value: hex.EncodeToString(sharedDecryptedFragment), } usedIndices[share.FragmentIndex] = true - log.Printf("Added shared fragment with original index %d", share.FragmentIndex) - // Add server fragments with unique indices - sharesAdded := uint(1) // Start at 1 since we added shared fragment + sharesAdded := uint(1) for i := 0; i < len(serverFragments) && sharesAdded < file.Threshold; i++ { fragment := serverFragments[i] - - // Skip if we've used this index if usedIndices[fragment.FragmentIndex] { continue } - // Decrypt server fragment decryptedFragment, err := services.DecryptMasterKey( fragment.Data, serverKeyData, fragment.EncryptionNonce, ) if err != nil { - log.Printf("Failed to decrypt server fragment %d: %v", i, err) continue } shares[sharesAdded] = services.KeyShare{ - Index: fragment.FragmentIndex, // Use original server fragment index + Index: fragment.FragmentIndex, Value: hex.EncodeToString(decryptedFragment), NodeIndex: fragment.NodeIndex, FragmentPath: fragment.FragmentPath, } usedIndices[fragment.FragmentIndex] = true - log.Printf("Added server fragment %d with original index %d", i, fragment.FragmentIndex) sharesAdded++ } - // Verify we have enough unique shares if sharesAdded < file.Threshold { - log.Printf("Failed to get enough unique shares: have %d, need %d", sharesAdded, file.Threshold) - ctx.JSON(http.StatusInternalServerError, gin.H{ - "status": "error", - "error": "Failed to get enough unique shares", - }) + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Insufficient unique shares"}) return } - // Get encrypted file data var encryptedData []byte var retrievalErr error if file.IsSharded { - log.Printf("Retrieving sharded data for file %d", file.ID) encryptedData, retrievalErr = c.getShardedData(file) } else { - log.Printf("Reading file content from path: %s", file.FilePath) encryptedData, retrievalErr = c.fileModel.ReadFileContent(file.FilePath) } if retrievalErr != nil { - log.Printf("Failed to retrieve file data: %v", retrievalErr) - ctx.JSON(http.StatusInternalServerError, gin.H{ - "status": "error", - "error": "Failed to read file data", - }) + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "File retrieval failed"}) return } - // Decrypt the file decryptedData, err := c.encryptionService.DecryptFileWithType( encryptedData, file.EncryptionIV, @@ -376,72 +476,63 @@ func (c *ShareFileController) AccessShare(ctx *gin.Context) { file.EncryptionType, ) if err != nil { - log.Printf("Failed to decrypt file data: %v", err) - ctx.JSON(http.StatusInternalServerError, gin.H{ - "status": "error", - "error": "Failed to decrypt file", - }) + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "File decryption failed"}) return } - // Log share access - if err := c.activityLogModel.LogActivity(&models.ActivityLog{ + // Handle decompression if the file is compressed + if file.IsCompressed { + log.Printf("Decompressing data for file ID: %d", file.ID) + decryptedData, err = c.compressionService.Decompress(decryptedData) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to decompress file"}) + return + } + } + + if err := c.fileShareModel.IncrementDownloadCount(share.ID); err != nil { + log.Printf("Failed to increment download count: %v", err) + } + + c.activityLogModel.LogActivity(&models.ActivityLog{ UserID: share.SharedBy, ActivityType: "download", FileID: &file.ID, IPAddress: ctx.ClientIP(), Status: "success", - Details: fmt.Sprintf("Shared file download using %d fragments", file.Threshold), - }); err != nil { - log.Printf("Failed to log share download activity: %v", err) - } + Details: fmt.Sprintf("Download with %d fragments", file.Threshold), + }) - // Send file response c.sendFileResponse(ctx, file, decryptedData) } - func (c *ShareFileController) getShardedData(file *models.File) ([]byte, error) { fileShards, err := c.rsService.RetrieveShards(file.ID, int(file.DataShardCount+file.ParityShardCount)) if err != nil { return nil, fmt.Errorf("failed to retrieve shards: %w", err) } - validShards := 0 - for i, shard := range fileShards.Shards { - if shard != nil { - validShards++ - log.Printf("Shard %d: %d bytes", i, len(shard)) - } else { - log.Printf("Shard %d: Missing", i) - } - } - if !c.rsService.ValidateShards(fileShards.Shards, int(file.DataShardCount)) { - return nil, fmt.Errorf("insufficient shards for reconstruction: have %d, need %d", - validShards, file.DataShardCount) + return nil, fmt.Errorf("insufficient shards for reconstruction") } - reconstructed, err := c.rsService.ReconstructFile(fileShards.Shards, + return c.rsService.ReconstructFile(fileShards.Shards, int(file.DataShardCount), int(file.ParityShardCount)) - if err != nil { - return nil, fmt.Errorf("failed to reconstruct file: %w", err) - } - - log.Printf("Successfully reconstructed file data: %d bytes", len(reconstructed)) - return reconstructed, nil } func (c *ShareFileController) sendFileResponse(ctx *gin.Context, file *models.File, data []byte) { - sanitizedFilename := strings.ReplaceAll(file.OriginalName, `"`, `\"`) - encodedFilename := url.QueryEscape(sanitizedFilename) - - ctx.Header("Access-Control-Expose-Headers", "Content-Disposition, Content-Type, Content-Length") - ctx.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"; filename*=UTF-8''%s`, - sanitizedFilename, - encodedFilename)) + escapedName := strings.ReplaceAll(file.OriginalName, `"`, `\"`) + utf8Name := url.PathEscape(file.OriginalName) + ctx.Header("Content-Disposition", fmt.Sprintf( + `attachment; filename="%s"; filename*=UTF-8''%s`, + escapedName, + utf8Name, + )) ctx.Header("Content-Type", file.MimeType) ctx.Header("Content-Length", fmt.Sprintf("%d", len(data))) - + ctx.Header("X-Original-Filename", escapedName) + ctx.Header("Access-Control-Expose-Headers", "Content-Disposition, Content-Type, Content-Length, X-Original-Filename") + ctx.Header("Content-Description", "File Transfer") + ctx.Header("Content-Transfer-Encoding", "binary") log.Printf("Sending file response: %s (Size: %d bytes)", file.OriginalName, len(data)) ctx.Data(http.StatusOK, file.MimeType, data) } diff --git a/backend/controllers/PremiumUser/AdvancedShareFileController.go b/backend/controllers/PremiumUser/AdvancedShareFileController.go index 511c2b1..037057b 100644 --- a/backend/controllers/PremiumUser/AdvancedShareFileController.go +++ b/backend/controllers/PremiumUser/AdvancedShareFileController.go @@ -6,6 +6,8 @@ import ( "log" "net/http" "net/url" + "os" + "strconv" "strings" "time" @@ -16,14 +18,17 @@ import ( ) type ShareFileController struct { - fileModel *models.FileModel - fileShareModel *models.FileShareModel - keyFragmentModel *models.KeyFragmentModel - encryptionService *services.EncryptionService - activityLogModel *models.ActivityLogModel - rsService *services.ReedSolomonService - userModel *models.UserModel - serverKeyModel *models.ServerMasterKeyModel + fileModel *models.FileModel + fileShareModel *models.FileShareModel + keyFragmentModel *models.KeyFragmentModel + encryptionService *services.EncryptionService + activityLogModel *models.ActivityLogModel + rsService *services.ReedSolomonService + userModel *models.UserModel + serverKeyModel *models.ServerMasterKeyModel + twoFactorService *services.TwoFactorAuthService + emailService *services.SMTPEmailService + compressionService *services.CompressionService } func NewShareFileController( @@ -35,32 +40,46 @@ func NewShareFileController( rsService *services.ReedSolomonService, userModel *models.UserModel, serverKeyModel *models.ServerMasterKeyModel, + twoFactorService *services.TwoFactorAuthService, + emailService *services.SMTPEmailService, + compressionService *services.CompressionService, ) *ShareFileController { return &ShareFileController{ - fileModel: fileModel, - fileShareModel: fileShareModel, - keyFragmentModel: keyFragmentModel, - encryptionService: encryptionService, - activityLogModel: activityLogModel, - rsService: rsService, - userModel: userModel, - serverKeyModel: serverKeyModel, + fileModel: fileModel, + fileShareModel: fileShareModel, + keyFragmentModel: keyFragmentModel, + encryptionService: encryptionService, + activityLogModel: activityLogModel, + rsService: rsService, + userModel: userModel, + serverKeyModel: serverKeyModel, + twoFactorService: twoFactorService, + emailService: emailService, + compressionService: compressionService, } } type CreateShareRequest struct { - FileID uint `json:"file_id" binding:"required"` - Password string `json:"password" binding:"required,min=6"` - ExpiresAt *time.Time `json:"expires_at"` - MaxDownloads *int `json:"max_downloads"` + Password string `json:"password" binding:"required,min=6"` + ExpiresAt *time.Time `json:"expires_at"` + MaxDownloads *int `json:"max_downloads"` + ShareType models.ShareType `json:"share_type" binding:"required"` + Email string `json:"email,omitempty"` } type AccessShareRequest struct { Password string `json:"password" binding:"required"` + Email string `json:"email,omitempty"` +} + +type TwoFactorRequest struct { + Code string `json:"code" binding:"required"` + Password string `json:"password" binding:"required"` } func (c *ShareFileController) CreateShare(ctx *gin.Context) { - log.Printf("Received premium share creation request for file ID: %v", ctx.Param("id")) + log.Printf("Received advanced share creation request for file ID: %v", ctx.Param("id")) + var req CreateShareRequest if err := ctx.ShouldBindJSON(&req); err != nil { ctx.JSON(http.StatusBadRequest, gin.H{ @@ -70,27 +89,31 @@ func (c *ShareFileController) CreateShare(ctx *gin.Context) { return } - // Validate expiry date if provided - if req.ExpiresAt != nil && req.ExpiresAt.Before(time.Now()) { + if req.ShareType == models.RecipientShare && req.Email == "" { ctx.JSON(http.StatusBadRequest, gin.H{ "status": "error", - "error": "Expiry date cannot be in the past", + "error": "Email required for recipient share", }) return } - user, exists := ctx.Get("user") - if !exists { - ctx.JSON(http.StatusUnauthorized, gin.H{ + if req.ExpiresAt != nil && req.ExpiresAt.Before(time.Now()) { + ctx.JSON(http.StatusBadRequest, gin.H{ "status": "error", - "error": "Unauthorized access", + "error": "Expiry date cannot be in the past", }) return } - currentUser := user.(*models.User) - // Get file and verify ownership - file, err := c.fileModel.GetFileForDownload(req.FileID, currentUser.ID) + user := ctx.MustGet("user").(*models.User) + + fileID, err := strconv.ParseUint(ctx.Param("id"), 10, 64) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid file ID"}) + return + } + + file, err := c.fileModel.GetFileForDownload(uint(fileID), user.ID) if err != nil { ctx.JSON(http.StatusNotFound, gin.H{ "status": "error", @@ -99,8 +122,7 @@ func (c *ShareFileController) CreateShare(ctx *gin.Context) { return } - // Derive user's KEK - kek, err := services.DeriveKeyEncryptionKey(currentUser.Password, currentUser.MasterKeySalt) + kek, err := services.DeriveKeyEncryptionKey(user.Password, user.MasterKeySalt) if err != nil { log.Printf("Failed to derive KEK: %v", err) ctx.JSON(http.StatusInternalServerError, gin.H{ @@ -110,11 +132,10 @@ func (c *ShareFileController) CreateShare(ctx *gin.Context) { return } - // Decrypt user's master key decryptedMasterKey, err := services.DecryptMasterKey( - currentUser.EncryptedMasterKey, + user.EncryptedMasterKey, kek, - currentUser.MasterKeyNonce, + user.MasterKeyNonce, ) if err != nil { log.Printf("Failed to decrypt master key: %v", err) @@ -125,10 +146,8 @@ func (c *ShareFileController) CreateShare(ctx *gin.Context) { return } - // Use first 32 bytes of decrypted master key userMasterKey := decryptedMasterKey[:32] - // Get fragments fragments, err := c.keyFragmentModel.GetUserFragmentsForFile(file.ID) if err != nil || len(fragments) == 0 { log.Printf("Failed to retrieve key fragments for file %d: %v", file.ID, err) @@ -139,11 +158,7 @@ func (c *ShareFileController) CreateShare(ctx *gin.Context) { return } - log.Printf("Creating premium share for file %d with %d fragments", file.ID, len(fragments)) userFragment := fragments[0] - log.Printf("Selected user fragment with index %d for sharing", userFragment.FragmentIndex) - - // Decrypt the fragment using master key decryptedFragment, err := services.DecryptMasterKey( userFragment.Data, userMasterKey, @@ -158,7 +173,6 @@ func (c *ShareFileController) CreateShare(ctx *gin.Context) { return } - // Encrypt fragment with share password encryptedFragment, err := c.encryptionService.EncryptKeyFragment( decryptedFragment, []byte(req.Password), @@ -172,15 +186,16 @@ func (c *ShareFileController) CreateShare(ctx *gin.Context) { return } - // Create premium share record share := &models.FileShare{ FileID: file.ID, - SharedBy: currentUser.ID, + SharedBy: user.ID, EncryptedKeyFragment: encryptedFragment, FragmentIndex: userFragment.FragmentIndex, ExpiresAt: req.ExpiresAt, MaxDownloads: req.MaxDownloads, IsActive: true, + ShareType: req.ShareType, + Email: req.Email, } if err := c.fileShareModel.CreateFileShare(share, req.Password); err != nil { @@ -192,152 +207,238 @@ func (c *ShareFileController) CreateShare(ctx *gin.Context) { return } - // Log premium share creation - if err := c.activityLogModel.LogActivity(&models.ActivityLog{ - UserID: currentUser.ID, + if req.ShareType == models.RecipientShare { + baseURL := os.Getenv("BASE_URL") + if baseURL == "" { + baseURL = "http://localhost:3000" + } + + shareURL := fmt.Sprintf("%s/protected-share/%s", baseURL, share.ShareLink) + + emailBody := fmt.Sprintf(`Hello, + +You have received a secure file share from %s. + +File: %s +Access Link: %s + +This link requires a password and email verification to access. +Please use the same email address this message was sent to when accessing the file. + +Best regards, +SafeSplit Team`, user.Username, file.OriginalName, shareURL) + + if err := c.emailService.SendEmail( + req.Email, + "Secure File Share Received", + emailBody, + ); err != nil { + log.Printf("Failed to send email: %v", err) + } + } + + c.activityLogModel.LogActivity(&models.ActivityLog{ + UserID: user.ID, ActivityType: "share", FileID: &file.ID, IPAddress: ctx.ClientIP(), Status: "success", - Details: fmt.Sprintf("Premium share created (Expires: %v, Max Downloads: %v)", req.ExpiresAt, req.MaxDownloads), - }); err != nil { - log.Printf("Failed to log share activity: %v", err) + Details: fmt.Sprintf("Created %s share with premium features", req.ShareType), + }) + + baseURL := os.Getenv("BASE_URL") + if baseURL == "" { + baseURL = "http://localhost:3000" + } + + // Determine share path based on type + sharePath := "/premium/share/" + if req.ShareType == models.RecipientShare { + sharePath = "/protected-share/" } + shareURL := fmt.Sprintf("%s%s%s", baseURL, sharePath, share.ShareLink) + ctx.JSON(http.StatusOK, gin.H{ "status": "success", "data": gin.H{ - "share_link": share.ShareLink, + "share_link": shareURL, + "raw_link": share.ShareLink, + "requires_2fa": req.ShareType == models.RecipientShare, }, }) } func (c *ShareFileController) AccessShare(ctx *gin.Context) { shareLink := ctx.Param("shareLink") - log.Printf("Received premium share access request for link: %s", shareLink) + log.Printf("Received share access request for link: %s", shareLink) + + if ctx.Request.Method == "GET" { + share, err := c.fileShareModel.GetShareByLink(shareLink) + if err != nil { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid share"}) + return + } + + file, err := c.fileModel.GetFileByID(share.FileID) + if err != nil { + ctx.JSON(http.StatusNotFound, gin.H{"error": "File not found"}) + return + } + + ctx.JSON(http.StatusOK, gin.H{ + "status": "success", + "data": gin.H{ + "requires_password": true, + "requires_2fa": share.ShareType == models.RecipientShare, + "recipient_share": share.ShareType == models.RecipientShare, + "file_name": file.OriginalName, + "file_size": file.Size, + "mime_type": file.MimeType, + "created_at": share.CreatedAt, + "expires_at": share.ExpiresAt, + "download_count": share.DownloadCount, + "max_downloads": share.MaxDownloads, + }, + }) + return + } + var req AccessShareRequest if err := ctx.ShouldBindJSON(&req); err != nil { - ctx.JSON(http.StatusBadRequest, gin.H{ - "status": "error", - "error": "Invalid password", - }) + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"}) return } - // Validate share with premium features - share, err := c.fileShareModel.ValidateShare(shareLink, req.Password) + share, err := c.fileShareModel.GetShareByLink(shareLink) if err != nil { - ctx.JSON(http.StatusUnauthorized, gin.H{ - "status": "error", - "error": "Invalid share link or password", - }) + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid share"}) return } - // Check if share has expired - if share.ExpiresAt != nil && time.Now().After(*share.ExpiresAt) { - ctx.JSON(http.StatusForbidden, gin.H{ - "status": "error", - "error": "Share link has expired", + if share.ShareType == models.RecipientShare { + // Validate password only - no email needed since we have the share + share, validationErr := c.fileShareModel.ValidateRecipientShare(shareLink, req.Password) + if validationErr != nil { + ctx.JSON(http.StatusUnauthorized, gin.H{ + "status": "error", + "error": "Invalid password"}) + return + } + + // Send 2FA to the email associated with the share + if err := c.twoFactorService.SendTwoFactorToken(share.ID, share.Email); err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{ + "status": "error", + "error": "Failed to send 2FA code"}) + return + } + + ctx.JSON(http.StatusOK, gin.H{ + "status": "success", + "message": "2FA code sent to registered email", + "data": gin.H{ + "share_id": share.ID, + }, }) return } - // Check if maximum downloads reached - if share.MaxDownloads != nil && share.DownloadCount >= *share.MaxDownloads { - ctx.JSON(http.StatusForbidden, gin.H{ - "status": "error", - "error": "Maximum number of downloads reached", - }) + if err := c.validatePremiumShare(share); err != nil { + ctx.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) return } - // Check if share is still active + c.processFileAccess(ctx, share, req.Password) +} + +func (c *ShareFileController) validatePremiumShare(share *models.FileShare) error { + if share.ExpiresAt != nil && time.Now().After(*share.ExpiresAt) { + return fmt.Errorf("share link has expired") + } + + if share.MaxDownloads != nil && share.DownloadCount >= *share.MaxDownloads { + return fmt.Errorf("maximum number of downloads reached") + } + if !share.IsActive { - ctx.JSON(http.StatusForbidden, gin.H{ - "status": "error", - "error": "Share link is no longer active", - }) - return + return fmt.Errorf("share link is no longer active") } - log.Printf("Processing premium share access for link: %s", shareLink) + return nil +} - // Get file metadata - file, err := c.fileModel.GetFileByID(share.FileID) - if err != nil { - log.Printf("Failed to get file %d: %v", share.FileID, err) - ctx.JSON(http.StatusNotFound, gin.H{ - "status": "error", - "error": "File not found", - }) +func (c *ShareFileController) Verify2FAAndDownload(ctx *gin.Context) { + shareLink := ctx.Param("shareLink") + var req TwoFactorRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"}) return } - // Get server fragments - serverFragments, err := c.keyFragmentModel.GetServerFragmentsForFile(share.FileID) + share, err := c.fileShareModel.GetShareByLink(shareLink) if err != nil { - log.Printf("Failed to get server fragments: %v", err) - ctx.JSON(http.StatusInternalServerError, gin.H{ - "status": "error", - "error": "Failed to process file access", - }) + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid share"}) + return + } + + if share.ShareType != models.RecipientShare { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "2FA verification only required for recipient shares"}) return } - log.Printf("Retrieved %d server fragments", len(serverFragments)) + if err := c.twoFactorService.VerifyToken(share.ID, req.Code); err != nil { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid 2FA code"}) + return + } - // Verify we have enough fragments - if len(serverFragments)+1 < int(file.Threshold) { - log.Printf("Insufficient fragments: have %d server + 1 shared, need %d", - len(serverFragments), file.Threshold) - ctx.JSON(http.StatusInternalServerError, gin.H{ - "status": "error", - "error": "Insufficient fragments to reconstruct file", - }) + if err := c.validatePremiumShare(share); err != nil { + ctx.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) + return + } + + c.processFileAccess(ctx, share, req.Password) +} + +func (c *ShareFileController) processFileAccess(ctx *gin.Context, share *models.FileShare, password string) { + file, err := c.fileModel.GetFileByID(share.FileID) + if err != nil { + ctx.JSON(http.StatusNotFound, gin.H{"error": "File not found"}) + return + } + + serverFragments, err := c.keyFragmentModel.GetServerFragmentsForFile(share.FileID) + if err != nil || len(serverFragments)+1 < int(file.Threshold) { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Insufficient fragments"}) return } - // Get server key for decrypting server fragments serverKey, err := c.serverKeyModel.GetActive() if err != nil { - log.Printf("Failed to get server key: %v", err) - ctx.JSON(http.StatusInternalServerError, gin.H{ - "status": "error", - "error": "Failed to process decryption", - }) + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Server key error"}) return } serverKeyData, err := c.serverKeyModel.GetServerKey(serverKey.KeyID) if err != nil { log.Printf("Failed to get server key data: %v", err) - ctx.JSON(http.StatusInternalServerError, gin.H{ - "status": "error", - "error": "Failed to get server key", - }) + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get server key"}) return } - // Decrypt shared fragment sharedDecryptedFragment, err := c.encryptionService.DecryptKeyFragment( share.EncryptedKeyFragment, - []byte(req.Password), + []byte(password), ) if err != nil { log.Printf("Failed to decrypt shared fragment: %v", err) - ctx.JSON(http.StatusInternalServerError, gin.H{ - "status": "error", - "error": "Failed to process file decryption", - }) + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to process file decryption"}) return } - // Prepare key shares array shares := make([]services.KeyShare, file.Threshold) usedIndices := make(map[int]bool) - // Add shared fragment first shares[0] = services.KeyShare{ Index: share.FragmentIndex, Value: hex.EncodeToString(sharedDecryptedFragment), @@ -345,11 +446,9 @@ func (c *ShareFileController) AccessShare(ctx *gin.Context) { usedIndices[share.FragmentIndex] = true log.Printf("Added shared fragment with index %d", share.FragmentIndex) - // Add server fragments sharesAdded := uint(1) for i := 0; i < len(serverFragments) && sharesAdded < file.Threshold; i++ { fragment := serverFragments[i] - if usedIndices[fragment.FragmentIndex] { continue } @@ -371,20 +470,15 @@ func (c *ShareFileController) AccessShare(ctx *gin.Context) { FragmentPath: fragment.FragmentPath, } usedIndices[fragment.FragmentIndex] = true - log.Printf("Added server fragment %d with index %d", i, fragment.FragmentIndex) sharesAdded++ } if sharesAdded < file.Threshold { log.Printf("Failed to get enough unique shares: have %d, need %d", sharesAdded, file.Threshold) - ctx.JSON(http.StatusInternalServerError, gin.H{ - "status": "error", - "error": "Failed to get enough unique shares", - }) + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get enough unique shares"}) return } - // Get encrypted file data var encryptedData []byte var retrievalErr error @@ -398,14 +492,10 @@ func (c *ShareFileController) AccessShare(ctx *gin.Context) { if retrievalErr != nil { log.Printf("Failed to retrieve file data: %v", retrievalErr) - ctx.JSON(http.StatusInternalServerError, gin.H{ - "status": "error", - "error": "Failed to read file data", - }) + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read file data"}) return } - // Decrypt the file with encryption type decryptedData, err := c.encryptionService.DecryptFileWithType( encryptedData, file.EncryptionIV, @@ -416,38 +506,32 @@ func (c *ShareFileController) AccessShare(ctx *gin.Context) { ) if err != nil { log.Printf("Failed to decrypt file data: %v", err) - ctx.JSON(http.StatusInternalServerError, gin.H{ - "status": "error", - "error": "Failed to decrypt file", - }) + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to decrypt file"}) return } - // Log premium share access - if err := c.activityLogModel.LogActivity(&models.ActivityLog{ - UserID: share.SharedBy, - ActivityType: "download", - FileID: &file.ID, - IPAddress: ctx.ClientIP(), - Status: "success", - Details: fmt.Sprintf("Premium shared file download using %d fragments", file.Threshold), - }); err != nil { - log.Printf("Failed to log share download activity: %v", err) + if file.IsCompressed { + log.Printf("Decompressing data for file ID: %d", file.ID) + decryptedData, err = c.compressionService.Decompress(decryptedData) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to decompress file"}) + return + } } - // Update premium share status - should be before sending response - log.Printf("Incrementing download count for share ID %d", share.ID) if err := c.fileShareModel.IncrementDownloadCount(share.ID); err != nil { log.Printf("Failed to increment download count: %v", err) - ctx.JSON(http.StatusInternalServerError, gin.H{ - "status": "error", - "error": "Failed to update download count", - }) - return } - log.Printf("Successfully incremented download count for share ID %d", share.ID) - // Only proceed to send file if increment succeeded + c.activityLogModel.LogActivity(&models.ActivityLog{ + UserID: share.SharedBy, + ActivityType: "download", + FileID: &file.ID, + IPAddress: ctx.ClientIP(), + Status: "success", + Details: fmt.Sprintf("Download with %d fragments", file.Threshold), + }) + c.sendFileResponse(ctx, file, decryptedData) } @@ -457,7 +541,6 @@ func (c *ShareFileController) getShardedData(file *models.File) ([]byte, error) return nil, fmt.Errorf("failed to retrieve shards: %w", err) } - // Log shard information for debugging validShards := 0 for i, shard := range fileShards.Shards { if shard != nil { @@ -468,13 +551,11 @@ func (c *ShareFileController) getShardedData(file *models.File) ([]byte, error) } } - // Validate we have enough shards for reconstruction if !c.rsService.ValidateShards(fileShards.Shards, int(file.DataShardCount)) { return nil, fmt.Errorf("insufficient shards for reconstruction: have %d, need %d", validShards, file.DataShardCount) } - // Reconstruct file from shards reconstructed, err := c.rsService.ReconstructFile(fileShards.Shards, int(file.DataShardCount), int(file.ParityShardCount)) if err != nil { @@ -486,18 +567,19 @@ func (c *ShareFileController) getShardedData(file *models.File) ([]byte, error) } func (c *ShareFileController) sendFileResponse(ctx *gin.Context, file *models.File, data []byte) { - sanitizedFilename := strings.ReplaceAll(file.OriginalName, `"`, `\"`) - encodedFilename := url.QueryEscape(sanitizedFilename) - - ctx.Header("Access-Control-Expose-Headers", "Content-Disposition, Content-Type, Content-Length") - ctx.Header("Content-Description", "File Transfer") - ctx.Header("Content-Transfer-Encoding", "binary") - ctx.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"; filename*=UTF-8''%s`, - sanitizedFilename, encodedFilename)) + escapedName := strings.ReplaceAll(file.OriginalName, `"`, `\"`) + utf8Name := url.PathEscape(file.OriginalName) + ctx.Header("Content-Disposition", fmt.Sprintf( + `attachment; filename="%s"; filename*=UTF-8''%s`, + escapedName, + utf8Name, + )) ctx.Header("Content-Type", file.MimeType) ctx.Header("Content-Length", fmt.Sprintf("%d", len(data))) - ctx.Header("X-Original-Filename", url.QueryEscape(file.OriginalName)) - - log.Printf("Sending file response: %s (Size: %d bytes)", file.OriginalName, len(data)) + ctx.Header("X-Original-Filename", escapedName) + ctx.Header("Access-Control-Expose-Headers", "Content-Disposition, Content-Type, Content-Length, X-Original-Filename") + ctx.Header("Content-Description", "File Transfer") + ctx.Header("Content-Transfer-Encoding", "binary") + log.Printf("Sending file response: %s (Size: %d bytes)", file.OriginalName, len(data)) ctx.Data(http.StatusOK, file.MimeType, data) } diff --git a/backend/jobs/Subcription.go b/backend/jobs/Subcription.go index 3d7d58f..5ea69fa 100644 --- a/backend/jobs/Subcription.go +++ b/backend/jobs/Subcription.go @@ -18,7 +18,6 @@ func (h *SubscriptionHandler) ProcessExpiredSubscriptions() error { return h.db.Transaction(func(tx *gorm.DB) error { now := time.Now() - // Update all expired subscriptions regardless of storage result := tx.Exec(` UPDATE users u INNER JOIN billing_profiles bp ON u.id = bp.user_id diff --git a/backend/middleware/auth_middleware.go b/backend/middleware/auth_middleware.go index 7c39730..44361ac 100644 --- a/backend/middleware/auth_middleware.go +++ b/backend/middleware/auth_middleware.go @@ -15,7 +15,7 @@ func AuthMiddleware(userModel *models.UserModel) gin.HandlerFunc { return func(c *gin.Context) { fmt.Printf("Starting auth middleware\n") authHeader := c.GetHeader("Authorization") - fmt.Println("Authorization header:", authHeader) // Debug log + fmt.Println("Authorization header:", authHeader) if authHeader == "" { fmt.Println("Missing Authorization header") @@ -33,7 +33,7 @@ func AuthMiddleware(userModel *models.UserModel) gin.HandlerFunc { } tokenStr := bearerToken[1] - fmt.Println("Token:", tokenStr) // Debug log + fmt.Println("Token:", tokenStr) token, err := jwt.Parse(tokenStr, func(token *jwt.Token) (interface{}, error) { if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { @@ -72,7 +72,7 @@ func AuthMiddleware(userModel *models.UserModel) gin.HandlerFunc { return } - fmt.Println("User ID from token claims:", uint(userID)) // Debug log + fmt.Println("User ID from token claims:", uint(userID)) user, err := userModel.FindByID(uint(userID)) if err != nil { @@ -89,7 +89,6 @@ func AuthMiddleware(userModel *models.UserModel) gin.HandlerFunc { return } - // Set both "user" and "user_id" in context fmt.Printf("Setting userID in context: %d\n", user.ID) c.Set("user", user) c.Set("user_id", user.ID) diff --git a/backend/models/feedback.go b/backend/models/feedback.go index 9ece2c8..544ba27 100644 --- a/backend/models/feedback.go +++ b/backend/models/feedback.go @@ -1,8 +1,9 @@ package models import ( + "fmt" "time" - "fmt" + "gorm.io/gorm" ) @@ -13,36 +14,32 @@ const ( FeedbackTypeFeedback FeedbackType = "feedback" FeedbackTypeSuspiciousActivity FeedbackType = "suspicious_activity" - FeedbackStatusPending FeedbackStatus = "pending" - FeedbackStatusInReview FeedbackStatus = "in_review" - FeedbackStatusResolved FeedbackStatus = "resolved" + FeedbackStatusPending FeedbackStatus = "pending" + FeedbackStatusInReview FeedbackStatus = "in_review" + FeedbackStatusResolved FeedbackStatus = "resolved" ) -// Feedback represents the feedback table in the database type Feedback struct { ID uint `json:"id" gorm:"primaryKey"` - UserID uint `json:"user_id"` - Type FeedbackType `json:"type" gorm:"type:enum('feedback','suspicious_activity')"` - Subject string `json:"subject" gorm:"size:255;not null"` - Message string `json:"message" gorm:"type:text;not null"` - Details string `json:"details" gorm:"type:text"` + UserID uint `json:"user_id"` + Type FeedbackType `json:"type" gorm:"type:enum('feedback','suspicious_activity')"` + Subject string `json:"subject" gorm:"size:255;not null"` + Message string `json:"message" gorm:"type:text;not null"` + Details string `json:"details" gorm:"type:text"` Status FeedbackStatus `json:"status" gorm:"type:enum('pending','in_review','resolved');default:pending"` - CreatedAt time.Time `json:"created_at" gorm:"default:CURRENT_TIMESTAMP"` - UpdatedAt time.Time `json:"updated_at" gorm:"default:CURRENT_TIMESTAMP;ON UPDATE CURRENT_TIMESTAMP"` - User User `json:"user" gorm:"foreignKey:UserID"` + CreatedAt time.Time `json:"created_at" gorm:"default:CURRENT_TIMESTAMP"` + UpdatedAt time.Time `json:"updated_at" gorm:"default:CURRENT_TIMESTAMP;ON UPDATE CURRENT_TIMESTAMP"` + User User `json:"user" gorm:"foreignKey:UserID"` } -// FeedbackModel handles database operations for feedback type FeedbackModel struct { db *gorm.DB } -// NewFeedbackModel creates a new FeedbackModel instance func NewFeedbackModel(db *gorm.DB) *FeedbackModel { return &FeedbackModel{db: db} } -// Create adds a new feedback entry func (m *FeedbackModel) Create(feedback *Feedback) error { return m.db.Create(feedback).Error } @@ -79,49 +76,43 @@ func (m *FeedbackModel) GetAllByUserAndType(userID uint, feedbackType FeedbackTy // GetAll retrieves all feedback entries with optional filters func (m *FeedbackModel) GetAll(filters map[string]interface{}, page, pageSize int) ([]Feedback, int64, error) { - var feedbacks []Feedback - var total int64 - - query := m.db.Model(&Feedback{}).Preload("User") - - // Apply filters - if feedbackType, ok := filters["type"].(FeedbackType); ok { - query = query.Where("type = ?", feedbackType) - } - if status, ok := filters["status"].(string); ok { - query = query.Where("status = ?", status) - } - if userID, ok := filters["user_id"].(uint); ok { - query = query.Where("user_id = ?", userID) - } - - // Get total count - query.Count(&total) - - // Apply pagination - offset := (page - 1) * pageSize - err := query. - Order("created_at DESC"). - Offset(offset). - Limit(pageSize). - Find(&feedbacks).Error - - return feedbacks, total, err + var feedbacks []Feedback + var total int64 + + query := m.db.Model(&Feedback{}).Preload("User") + + if feedbackType, ok := filters["type"].(FeedbackType); ok { + query = query.Where("type = ?", feedbackType) + } + if status, ok := filters["status"].(string); ok { + query = query.Where("status = ?", status) + } + if userID, ok := filters["user_id"].(uint); ok { + query = query.Where("user_id = ?", userID) + } + + query.Count(&total) + + offset := (page - 1) * pageSize + err := query. + Order("created_at DESC"). + Offset(offset). + Limit(pageSize). + Find(&feedbacks).Error + + return feedbacks, total, err } -// UpdateStatus updates the status of a feedback entry func (m *FeedbackModel) UpdateStatus(id uint, status FeedbackStatus) error { return m.db.Model(&Feedback{}). Where("id = ?", id). Update("status", status).Error } -// Delete removes a feedback entry func (m *FeedbackModel) Delete(id uint) error { return m.db.Delete(&Feedback{}, id).Error } -// GetByStatus retrieves all feedback entries with a specific status func (m *FeedbackModel) GetByStatus(status FeedbackStatus) ([]Feedback, error) { var feedbacks []Feedback if err := m.db.Where("status = ?", status).Find(&feedbacks).Error; err != nil { @@ -171,7 +162,7 @@ func (m *FeedbackModel) UpdateStatusWithComment(id uint, status FeedbackStatus, // Append comment to details with timestamp timestamp := time.Now().Format(time.RFC3339) newDetails := fmt.Sprintf("%s\n[%s] Status changed to %s: %s", - feedback.Details, // Keep existing details + feedback.Details, timestamp, status, comment, @@ -185,4 +176,4 @@ func (m *FeedbackModel) UpdateStatusWithComment(id uint, status FeedbackStatus, return nil }) -} \ No newline at end of file +} diff --git a/backend/models/file.go b/backend/models/file.go index c77b882..9765d6f 100644 --- a/backend/models/file.go +++ b/backend/models/file.go @@ -382,11 +382,9 @@ func (m *FileModel) DeleteFile(fileID, userID uint, ipAddress string) error { } // Keep shards for potential recovery - // We'll only delete physical files for non-sharded files if !file.IsSharded && file.FilePath != "" { if err := os.Remove(file.FilePath); err != nil && !os.IsNotExist(err) { log.Printf("Failed to delete file content - Path: %s, Error: %v", file.FilePath, err) - // Don't rollback here as the file might have been already moved/deleted log.Printf("Continuing deletion process despite file removal error") } } @@ -460,6 +458,44 @@ func (m *FileModel) ArchiveFile(fileID, userID uint, ipAddress string) error { return nil } +func (m *FileModel) UnarchiveFile(fileID, userID uint, ipAddress string) error { + tx := m.db.Begin() + + // Unarchive the file + result := tx.Model(&File{}). + Where("id = ? AND user_id = ? AND is_archived = ?", fileID, userID, true). + Update("is_archived", false) + + if result.Error != nil { + tx.Rollback() + return fmt.Errorf("failed to unarchive file: %w", result.Error) + } + + if result.RowsAffected == 0 { + tx.Rollback() + return fmt.Errorf("file not found or not archived") + } + + // Log activity + activity := &ActivityLog{ + UserID: userID, + ActivityType: "unarchive", + FileID: &fileID, + IPAddress: ipAddress, + Status: "success", + } + + if err := tx.Create(activity).Error; err != nil { + tx.Rollback() + return fmt.Errorf("failed to log activity: %w", err) + } + + if err := tx.Commit().Error; err != nil { + return fmt.Errorf("failed to complete unarchive operation: %w", err) + } + + return nil +} // Storage management func (m *FileModel) GetUserStorageInfo(userID uint) (used int64, quota int64, err error) { @@ -798,7 +834,6 @@ func (m *FileModel) PermanentlyDeleteFile(fileID, userID uint, ipAddress string) return nil } -// PermanentDeletionLog represents an audit log for permanent deletions type PermanentDeletionLog struct { ID uint `gorm:"primaryKey"` UserID uint `gorm:"not null"` diff --git a/backend/models/file_share.go b/backend/models/file_share.go index 5964968..e394694 100644 --- a/backend/models/file_share.go +++ b/backend/models/file_share.go @@ -1,234 +1,188 @@ package models import ( - "crypto/rand" - "encoding/base64" - "fmt" - "log" - "time" - - "golang.org/x/crypto/bcrypt" - "gorm.io/gorm" + "crypto/rand" + "encoding/base64" + "fmt" + "time" + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" +) + +type ShareType string + +const ( + NormalShare ShareType = "normal" + RecipientShare ShareType = "recipient" ) type FileShare struct { - ID uint `json:"id" gorm:"primaryKey"` - FileID uint `json:"file_id"` - SharedBy uint `json:"shared_by"` - ShareLink string `json:"share_link" gorm:"unique"` - PasswordHash string `json:"-"` - PasswordSalt string `json:"-"` - EncryptedKeyFragment []byte `json:"-" gorm:"type:mediumblob"` - FragmentIndex int `json:"-" gorm:"not null"` - ExpiresAt *time.Time `json:"expires_at"` - MaxDownloads *int `json:"max_downloads"` - DownloadCount int `json:"download_count" gorm:"default:0"` - IsActive bool `json:"is_active" gorm:"default:true"` - CreatedAt time.Time `json:"created_at"` - File File `json:"file" gorm:"foreignKey:FileID"` + ID uint `json:"id" gorm:"primaryKey"` + FileID uint `json:"file_id"` + SharedBy uint `json:"shared_by"` + ShareLink string `json:"share_link" gorm:"unique"` + PasswordHash string `json:"-"` + PasswordSalt string `json:"-"` + EncryptedKeyFragment []byte `json:"-" gorm:"type:mediumblob"` + FragmentIndex int `json:"-" gorm:"not null"` + ExpiresAt *time.Time `json:"expires_at"` + MaxDownloads *int `json:"max_downloads"` + DownloadCount int `json:"download_count" gorm:"default:0"` + IsActive bool `json:"is_active" gorm:"default:true"` + CreatedAt time.Time `json:"created_at"` + File File `json:"file" gorm:"foreignKey:FileID"` + ShareType ShareType `json:"share_type" gorm:"type:varchar(20);default:'normal'"` + Email string `json:"email,omitempty"` } type FileShareModel struct { - db *gorm.DB + db *gorm.DB } func NewFileShareModel(db *gorm.DB) *FileShareModel { - return &FileShareModel{db: db} + return &FileShareModel{db: db} } func generateShareLink() (string, error) { - bytes := make([]byte, 32) - if _, err := rand.Read(bytes); err != nil { - return "", err - } - return base64.URLEncoding.EncodeToString(bytes), nil + bytes := make([]byte, 32) + if _, err := rand.Read(bytes); err != nil { + return "", err + } + return base64.URLEncoding.EncodeToString(bytes), nil } -func (m *FileShareModel) CreateFileShareWithStatus(share *FileShare, password string) error { - // Generate password salt - salt := make([]byte, 16) - if _, err := rand.Read(salt); err != nil { - return fmt.Errorf("failed to generate salt: %w", err) - } - share.PasswordSalt = base64.StdEncoding.EncodeToString(salt) - - // Hash password with salt - hashedPassword, err := bcrypt.GenerateFromPassword( - []byte(password+share.PasswordSalt), - bcrypt.DefaultCost, - ) - if err != nil { - return fmt.Errorf("failed to hash password: %w", err) - } - share.PasswordHash = string(hashedPassword) - - // Generate unique share link - shareLink, err := generateShareLink() - if err != nil { - return fmt.Errorf("failed to generate share link: %w", err) - } - share.ShareLink = shareLink - - // Start transaction - tx := m.db.Begin() - if tx.Error != nil { - return fmt.Errorf("failed to start transaction: %w", tx.Error) - } - - // Create share record within transaction - if err := tx.Create(share).Error; err != nil { - tx.Rollback() - return fmt.Errorf("failed to create share record: %w", err) - } - - // Update file's IsShared status - if err := tx.Model(&File{}).Where("id = ?", share.FileID).Update("is_shared", true).Error; err != nil { - tx.Rollback() - return fmt.Errorf("failed to update file status: %w", err) - } - - // Commit transaction - if err := tx.Commit().Error; err != nil { - tx.Rollback() - return fmt.Errorf("failed to commit transaction: %w", err) - } +func (m *FileShareModel) CreateFileShare(share *FileShare, password string) error { + if share.ShareType == RecipientShare && share.Email == "" { + return fmt.Errorf("email required for recipient share") + } + + salt := make([]byte, 16) + if _, err := rand.Read(salt); err != nil { + return fmt.Errorf("failed to generate salt: %w", err) + } + share.PasswordSalt = base64.StdEncoding.EncodeToString(salt) + + hashedPassword, err := bcrypt.GenerateFromPassword( + []byte(password+share.PasswordSalt), + bcrypt.DefaultCost, + ) + if err != nil { + return fmt.Errorf("failed to hash password: %w", err) + } + share.PasswordHash = string(hashedPassword) + + shareLink, err := generateShareLink() + if err != nil { + return fmt.Errorf("failed to generate share link: %w", err) + } + share.ShareLink = shareLink + + tx := m.db.Begin() + if tx.Error != nil { + return fmt.Errorf("failed to start transaction: %w", tx.Error) + } + + if err := tx.Create(share).Error; err != nil { + tx.Rollback() + return fmt.Errorf("failed to create share record: %w", err) + } + + if err := tx.Model(&File{}).Where("id = ?", share.FileID).Update("is_shared", true).Error; err != nil { + tx.Rollback() + return fmt.Errorf("failed to update file status: %w", err) + } + + return tx.Commit().Error +} - return nil +func (m *FileShareModel) ValidateShare(shareLink string, password string) (*FileShare, error) { + var share FileShare + if err := m.db.Where("share_link = ? AND is_active = ? AND share_type = ?", + shareLink, true, NormalShare).Preload("File").First(&share).Error; err != nil { + return nil, fmt.Errorf("share not found or inactive") + } + + if err := bcrypt.CompareHashAndPassword( + []byte(share.PasswordHash), + []byte(password+share.PasswordSalt), + ); err != nil { + return nil, fmt.Errorf("invalid password") + } + + return &share, nil } -func (m *FileShareModel) ValidateShareAccess(shareLink string, password string) (*FileShare, error) { +func (m *FileShareModel) ValidateRecipientShare(shareLink string, password string) (*FileShare, error) { var share FileShare - if err := m.db.Where("share_link = ? AND is_active = ?", shareLink, true). - Preload("File").First(&share).Error; err != nil { - return nil, fmt.Errorf("share not found or inactive") + if err := m.db.Where("share_link = ? AND is_active = ? AND share_type = ?", + shareLink, true, RecipientShare).Preload("File").First(&share).Error; err != nil { + return nil, fmt.Errorf("share not found or invalid") } - - // Check expiration + if share.ExpiresAt != nil && share.ExpiresAt.Before(time.Now()) { share.IsActive = false m.db.Save(&share) return nil, fmt.Errorf("share has expired") } - - // Check download limit + if share.MaxDownloads != nil && share.DownloadCount >= *share.MaxDownloads { share.IsActive = false m.db.Save(&share) return nil, fmt.Errorf("download limit exceeded") } - - // Verify password + if err := bcrypt.CompareHashAndPassword( []byte(share.PasswordHash), []byte(password+share.PasswordSalt), ); err != nil { return nil, fmt.Errorf("invalid password") } - - return &share, nil -} - -// CreateFileShare creates a basic file share with just password protection -func (m *FileShareModel) CreateFileShare(share *FileShare, password string) error { - // Generate password salt - salt := make([]byte, 16) - if _, err := rand.Read(salt); err != nil { - return fmt.Errorf("failed to generate salt: %w", err) - } - share.PasswordSalt = base64.StdEncoding.EncodeToString(salt) - - // Hash password with salt - hashedPassword, err := bcrypt.GenerateFromPassword( - []byte(password+share.PasswordSalt), - bcrypt.DefaultCost, - ) - if err != nil { - return fmt.Errorf("failed to hash password: %w", err) - } - share.PasswordHash = string(hashedPassword) - - // Generate unique share link - shareLink, err := generateShareLink() - if err != nil { - return fmt.Errorf("failed to generate share link: %w", err) - } - share.ShareLink = shareLink - - // Start transaction - tx := m.db.Begin() - if tx.Error != nil { - return fmt.Errorf("failed to start transaction: %w", tx.Error) - } - - // Create share record within transaction - if err := tx.Create(share).Error; err != nil { - tx.Rollback() - return fmt.Errorf("failed to create share record: %w", err) - } - - // Update file's IsShared status - if err := tx.Model(&File{}).Where("id = ?", share.FileID).Update("is_shared", true).Error; err != nil { - tx.Rollback() - return fmt.Errorf("failed to update file status: %w", err) - } - - return tx.Commit().Error -} - -// ValidateShare validates a share without checking expiry or download count -func (m *FileShareModel) ValidateShare(shareLink string, password string) (*FileShare, error) { - var share FileShare - if err := m.db.Where("share_link = ? AND is_active = ?", shareLink, true). - Preload("File").First(&share).Error; err != nil { - return nil, fmt.Errorf("share not found or inactive") - } - - // Verify password - if err := bcrypt.CompareHashAndPassword( - []byte(share.PasswordHash), - []byte(password+share.PasswordSalt), - ); err != nil { - return nil, fmt.Errorf("invalid password") - } - + return &share, nil + } + func (m *FileShareModel) ValidatePassword(shareLink string, password string) error { + var share FileShare + if err := m.db.Where("share_link = ?", shareLink).First(&share).Error; err != nil { + return fmt.Errorf("share not found") + } + + if err := bcrypt.CompareHashAndPassword( + []byte(share.PasswordHash), + []byte(password+share.PasswordSalt), + ); err != nil { + return fmt.Errorf("invalid password") + } + + return nil } func (m *FileShareModel) IncrementDownloadCount(shareID uint) error { - log.Printf("Starting IncrementDownloadCount for share ID %d", shareID) + tx := m.db.Begin() + if tx.Error != nil { + return fmt.Errorf("failed to start transaction: %w", tx.Error) + } + defer tx.Rollback() - // Start transaction - tx := m.db.Begin() - if tx.Error != nil { - log.Printf("Failed to start transaction: %v", tx.Error) - return fmt.Errorf("failed to start transaction: %w", tx.Error) - } - defer tx.Rollback() // rollback if not committed + result := tx.Model(&FileShare{}). + Where("id = ?", shareID). + Update("download_count", gorm.Expr("download_count + ?", 1)) - log.Printf("Started transaction for share ID %d", shareID) + if result.Error != nil { + return fmt.Errorf("failed to increment download count: %w", result.Error) + } - result := tx.Model(&FileShare{}). - Where("id = ?", shareID). - Update("download_count", gorm.Expr("download_count + ?", 1)) - - if result.Error != nil { - log.Printf("Error during update: %v", result.Error) - return fmt.Errorf("failed to increment download count: %w", result.Error) - } - - log.Printf("Update query executed, affected rows: %d", result.RowsAffected) - - if result.RowsAffected == 0 { - log.Printf("No rows affected for share ID %d", shareID) - return fmt.Errorf("no share found with ID %d", shareID) - } - - // Commit transaction - if err := tx.Commit().Error; err != nil { - log.Printf("Failed to commit transaction: %v", err) - return fmt.Errorf("failed to commit transaction: %w", err) - } + if result.RowsAffected == 0 { + return fmt.Errorf("no share found with ID %d", shareID) + } - log.Printf("Successfully committed download count increment for share ID %d", shareID) - return nil + return tx.Commit().Error } +func (m *FileShareModel) GetShareByLink(shareLink string) (*FileShare, error) { + var share FileShare + err := m.db.Where("share_link = ? AND is_active = ?", shareLink, true). + Preload("File").First(&share).Error + if err != nil { + return nil, fmt.Errorf("share not found or inactive") + } + return &share, nil +} \ No newline at end of file diff --git a/database-setup.sql b/database-setup.sql index f4cc5a1..bad5e8c 100644 --- a/database-setup.sql +++ b/database-setup.sql @@ -170,6 +170,8 @@ CREATE TABLE file_shares ( download_count INT DEFAULT 0, -- Current downloads is_active BOOLEAN DEFAULT TRUE, -- Share status created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + share_type VARCHAR(20) NOT NULL DEFAULT 'normal', -- Share type (normal/recipient) + email VARCHAR(255) NULL, -- Recipient email for recipient shares FOREIGN KEY (file_id) REFERENCES files(id) ON DELETE CASCADE, FOREIGN KEY (shared_by) REFERENCES users(id) ON DELETE CASCADE ); @@ -190,7 +192,7 @@ CREATE TABLE activity_logs ( id INT AUTO_INCREMENT PRIMARY KEY, user_id INT NOT NULL, -- User performing action activity_type ENUM('upload', 'download', 'delete', 'share', 'login', - 'logout', 'archive', 'restore', 'encrypt', 'decrypt') NOT NULL, + 'logout', 'archive', 'restore', 'encrypt', 'decrypt','unarchive') NOT NULL, file_id INT, -- Associated file folder_id INT, -- Associated folder ip_address VARCHAR(45), -- User's IP diff --git a/frontend/src/App.js b/frontend/src/App.js index 333037a..8e394c6 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -23,7 +23,6 @@ function App() { setUser(null); }; - // Helper function to check if user has access to current route const isRouteAccessible = (allowedRoles) => { return user && allowedRoles.includes(user.role); }; @@ -31,7 +30,6 @@ function App() { return (
    - {/* Show NavigationBar only when user is not logged in */} {!user && } @@ -46,39 +44,24 @@ function App() { {/* Authentication Routes */} - ) : ( - - ) - } + element={user ? : } /> - ) : ( - - ) - } + element={user ? : } /> - ) : ( - - ) - } + element={user ? : } /> - } /> - } /> + {/* Share Access Routes */} + } /> + } /> + } /> + } /> {/* Protected Routes */} } /> - - {/* Catch all route */} } /> diff --git a/frontend/src/components/EndUser/ShareFileAction.js b/frontend/src/components/EndUser/ShareFileAction.js index a976f17..fc18959 100644 --- a/frontend/src/components/EndUser/ShareFileAction.js +++ b/frontend/src/components/EndUser/ShareFileAction.js @@ -2,239 +2,243 @@ import React, { useState } from 'react'; import { Share2, Copy, X } from 'lucide-react'; const ShareFileAction = ({ file, user }) => { - const [isModalOpen, setIsModalOpen] = useState(false); - const [shareLink, setShareLink] = useState(''); - const [password, setPassword] = useState(''); - const [expiresAt, setExpiresAt] = useState(''); - const [maxDownloads, setMaxDownloads] = useState(''); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(''); - const [copySuccess, setCopySuccess] = useState(false); - - const isPremium = user?.role === 'premium_user'; - - const handleShare = async (e) => { - e.preventDefault(); - setIsLoading(true); - setError(''); - - try { - const token = localStorage.getItem('token'); - if (!token) { - setError('Please log in to share files.'); - return; - } - - // Format the date if it exists - const formattedExpiresAt = expiresAt - ? new Date(expiresAt).toISOString() - : null; - - console.log('Debug expiry:', { - originalExpiresAt: expiresAt, - formattedExpiresAt: formattedExpiresAt - }); - - const shareData = { - file_id: file.id, - password: password, - ...(isPremium && { - expires_at: formattedExpiresAt, - max_downloads: maxDownloads ? parseInt(maxDownloads) : null - }) - }; - - console.log('Sending share data:', shareData); - - const endpoint = isPremium - ? `http://localhost:8080/api/premium/shares/files/${file.id}` - : `http://localhost:8080/api/files/${file.id}/share`; - - const response = await fetch(endpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}`, - }, - body: JSON.stringify(shareData), - }); - - if (!response.ok) { - const errorData = await response.json(); - console.error('Share creation failed:', { - status: response.status, - error: errorData - }); - throw new Error(errorData.error || 'Failed to create share link'); - } - - const { data } = await response.json(); - const baseUrl = isPremium - ? `http://localhost:3000/premium/share/` - : `http://localhost:3000/share/`; - setShareLink(baseUrl + data.share_link); - } catch (error) { - setError(error.message); - } finally { - setIsLoading(false); - } - }; - - const copyToClipboard = async () => { - try { - await navigator.clipboard.writeText(shareLink); - setCopySuccess(true); - setTimeout(() => setCopySuccess(false), 2000); - } catch (err) { - console.error('Failed to copy:', err); - setError('Failed to copy to clipboard'); - } - }; - - const closeModal = () => { - setIsModalOpen(false); - setShareLink(''); - setPassword(''); - setExpiresAt(''); - setMaxDownloads(''); - setError(''); - setCopySuccess(false); - }; - - return ( - <> - - - {isModalOpen && ( -
    -
    -
    -
    -

    Share {file.original_name}

    - {isPremium && ( - Premium Share - )} -
    - -
    - - {error && ( -
    - {error} -
    - )} - - {!shareLink ? ( -
    -
    - - setPassword(e.target.value)} - required - minLength={6} - className="w-full px-3 py-2 border rounded-md" - /> -
    - - {isPremium && ( - <> -
    - - setExpiresAt(e.target.value)} - className="w-full px-3 py-2 border rounded-md" - /> -
    - -
    - - setMaxDownloads(e.target.value)} - min="1" - className="w-full px-3 py-2 border rounded-md" - /> -
    - - )} - - -
    - ) : ( -
    -
    - - -
    - {copySuccess && ( -

    - Link copied to clipboard! -

    - )} -
    -

    Password: {password}

    - {isPremium && expiresAt && ( -

    - Expires: {new Date(expiresAt).toLocaleString()} -

    - )} - {isPremium && maxDownloads && ( -

    - Max Downloads: {maxDownloads} -

    - )} -
    -
    - )} -
    -
    - )} - - ); + const [isModalOpen, setIsModalOpen] = useState(false); + const [shareLink, setShareLink] = useState(''); + const [password, setPassword] = useState(''); + const [email, setEmail] = useState(''); + const [expiresAt, setExpiresAt] = useState(''); + const [maxDownloads, setMaxDownloads] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(''); + const [copySuccess, setCopySuccess] = useState(false); + + const isPremium = user?.role === 'premium_user'; + + const handleShare = async (e) => { + e.preventDefault(); + setIsLoading(true); + setError(''); + + try { + const token = localStorage.getItem('token'); + if (!token) { + setError('Please log in to share files.'); + return; + } + + // Determine share type based on email presence + const shareType = email ? 'recipient' : 'normal'; + + const shareData = { + password: password, + share_type: shareType, + ...(email && { email }), + ...(isPremium && { + expires_at: expiresAt ? new Date(expiresAt).toISOString() : null, + max_downloads: maxDownloads ? parseInt(maxDownloads) : null + }) + }; + + const endpoint = isPremium + ? `http://localhost:8080/api/premium/shares/files/${file.id}` + : `http://localhost:8080/api/files/${file.id}/share`; + + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify(shareData), + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || 'Failed to create share link'); + } + + if (data.status === "success") { + const baseUrl = 'http://localhost:3000'; + const sharePath = isPremium + ? email ? '/protected-share/' : '/premium/share/' + : email ? '/protected-share/' : '/share/'; + const shareUrl = `${baseUrl}${sharePath}${data.data.raw_link}`; + setShareLink(shareUrl); + } else { + throw new Error(data.error || 'Failed to create share link'); + } + + } catch (error) { + setError(error.message); + } finally { + setIsLoading(false); + } + }; + + const copyToClipboard = async () => { + try { + await navigator.clipboard.writeText(shareLink); + setCopySuccess(true); + setTimeout(() => setCopySuccess(false), 2000); + } catch (err) { + setError('Failed to copy to clipboard'); + } + }; + + const closeModal = () => { + setIsModalOpen(false); + setShareLink(''); + setPassword(''); + setEmail(''); + setExpiresAt(''); + setMaxDownloads(''); + setError(''); + setCopySuccess(false); + }; + + return ( + <> + + + {isModalOpen && ( +
    +
    +
    +
    +

    Share {file.original_name}

    + {isPremium && ( + Premium Share + )} +
    + +
    + + {error && ( +
    + {error} +
    + )} + + {!shareLink ? ( +
    +
    + + setPassword(e.target.value)} + required + minLength={6} + className="w-full px-3 py-2 border rounded-md" + placeholder="Minimum 6 characters" + /> +
    + +
    + + setEmail(e.target.value)} + className="w-full px-3 py-2 border rounded-md" + placeholder="Enter recipient email" + /> + {email && ( +

    + Recipient will receive email verification +

    + )} +
    + + {isPremium && ( + <> +
    + + setExpiresAt(e.target.value)} + className="w-full px-3 py-2 border rounded-md" + /> +
    + +
    + + setMaxDownloads(e.target.value)} + min="1" + className="w-full px-3 py-2 border rounded-md" + placeholder="Unlimited if not set" + /> +
    + + )} + + +
    + ) : ( +
    +
    + + +
    + {copySuccess && ( +

    Copied to clipboard!

    + )} +
    +

    Password: {password}

    + {email &&

    Recipient: {email}

    } + {isPremium && expiresAt && ( +

    Expires: {new Date(expiresAt).toLocaleString()}

    + )} + {isPremium && maxDownloads && ( +

    Max Downloads: {maxDownloads}

    + )} +
    +
    + )} +
    +
    + )} + + ); }; export default ShareFileAction; \ No newline at end of file diff --git a/frontend/src/components/EndUser/SharedFileAccess.js b/frontend/src/components/EndUser/SharedFileAccess.js index cfcf615..d6f92a3 100644 --- a/frontend/src/components/EndUser/SharedFileAccess.js +++ b/frontend/src/components/EndUser/SharedFileAccess.js @@ -1,27 +1,45 @@ -import React, { useState } from 'react'; -import { Download, Lock } from 'lucide-react'; +import React, { useState, useEffect } from 'react'; +import { Download, Lock, KeyRound } from 'lucide-react'; +import { useLocation, useParams } from 'react-router-dom'; const SharedFileAccess = () => { const [password, setPassword] = useState(''); + const [verificationCode, setVerificationCode] = useState(''); + const [showVerification, setShowVerification] = useState(false); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(''); + const [fileInfo, setFileInfo] = useState(null); const [showPassword, setShowPassword] = useState(false); - // Determine if it's a premium share by checking URL path - const isPremiumShare = window.location.pathname.includes('/premium/share/'); - const shareLink = window.location.pathname.split('/').pop(); + const location = useLocation(); + const { shareId } = useParams(); + const isPremiumShare = location.pathname.includes('/premium/share/'); + const isProtectedShare = location.pathname.includes('/protected-share/'); + + useEffect(() => { + fetchFileInfo(); + }, []); + + const fetchFileInfo = async () => { + try { + const endpoint = `http://localhost:8080/api/${isPremiumShare ? 'premium/shares' : 'files/share'}/${shareId}`; + const response = await fetch(endpoint); + if (response.ok) { + const data = await response.json(); + setFileInfo(data.data); + } + } catch (error) { + console.error('Error fetching file info:', error); + } + }; const handleSubmit = async (e) => { e.preventDefault(); setIsLoading(true); setError(''); - + try { - // Use different endpoints based on share type - const endpoint = isPremiumShare - ? `http://localhost:8080/api/premium/shares/${shareLink}` - : `http://localhost:8080/api/files/share/${shareLink}`; - + const endpoint = `http://localhost:8080/api/${isPremiumShare ? 'premium/shares' : 'files/share'}/${shareId}`; const response = await fetch(endpoint, { method: 'POST', headers: { @@ -29,112 +47,187 @@ const SharedFileAccess = () => { }, body: JSON.stringify({ password }), }); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error || 'Failed to access file'); + + const contentType = response.headers.get('Content-Type'); + + // If response is JSON + if (contentType && contentType.includes('application/json')) { + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || 'Failed to access file'); + } + + if (data.message?.includes('2FA code sent')) { + setShowVerification(true); + return; + } + } + // If response is a file + else if (response.ok) { + await handleDownload(response); + } + // If error response + else { + throw new Error('Failed to access file'); } - - // Extract filename from Content-Disposition header - const disposition = response.headers.get('Content-Disposition'); - let filename = 'download'; - if (disposition) { - const matches = disposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/); - if (matches && matches[1]) { - filename = matches[1].replace(/['"]/g, ''); + + } catch (error) { + setError(error.message); + } finally { + setIsLoading(false); + } + }; + + const handleVerification = async (e) => { + e.preventDefault(); + setIsLoading(true); + setError(''); + + try { + const endpoint = `http://localhost:8080/api/${isPremiumShare ? 'premium/shares' : 'files/share'}/${shareId}/verify`; + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + code: verificationCode, + password + }), + }); + + const contentType = response.headers.get('Content-Type'); + + // If response is JSON (error case) + if (contentType && contentType.includes('application/json')) { + const data = await response.json(); + if (!response.ok) { + throw new Error(data.error || 'Verification failed'); } } - - const blob = await response.blob(); - - // Create download link - const downloadUrl = window.URL.createObjectURL(blob); - const link = document.createElement('a'); - link.href = downloadUrl; - link.download = decodeURIComponent(filename); - document.body.appendChild(link); - link.click(); - - // Cleanup - document.body.removeChild(link); - window.URL.revokeObjectURL(downloadUrl); - + // If response is a file + else if (response.ok) { + await handleDownload(response); + } + // If error response + else { + throw new Error('Verification failed'); + } + } catch (error) { - console.error('Download error:', error); setError(error.message); } finally { setIsLoading(false); } }; + + const handleDownload = async (response) => { + const blob = await response.blob(); + const disposition = response.headers.get('Content-Disposition'); + let filename = 'download'; + + if (disposition) { + const utf8FilenameMatch = disposition.match(/filename\*=UTF-8''([^;]+)/i); + if (utf8FilenameMatch) { + filename = decodeURIComponent(utf8FilenameMatch[1]); + } else { + const asciiFilenameMatch = disposition.match(/filename="([^"]+)"/i); + if (asciiFilenameMatch) { + filename = asciiFilenameMatch[1]; + } + } + } + + const url = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.URL.revokeObjectURL(url); + }; return (
    - + {showVerification ? ( + + ) : ( + + )}

    - Access Shared File + {showVerification ? 'Verify Access' : 'Access Shared File'}

    -

    - Enter the password provided by the file owner to access this file. - {isPremiumShare && ( - - Premium Share - - )} -

    + {fileInfo && ( +
    +

    {fileInfo.file_name}

    +

    Size: {(fileInfo.file_size / 1024 / 1024).toFixed(2)} MB

    + {isPremiumShare && fileInfo.expires_at && ( +

    Expires: {new Date(fileInfo.expires_at).toLocaleString()}

    + )} + {isPremiumShare && fileInfo.max_downloads && ( +

    Downloads: {fileInfo.download_count} / {fileInfo.max_downloads}

    + )} +
    + )}
    -
    - {error && ( -
    -
    -
    - - - -
    -
    -

    {error}

    -
    + {error && ( +
    +
    +
    + + + +
    +
    +

    {error}

    - )} +
    + )} -
    -
    - setPassword(e.target.value)} - /> - + {!showVerification ? ( + +
    +
    + +
    + setPassword(e.target.value)} + /> + +
    +
    -
    -
    ) : (
    @@ -155,8 +248,42 @@ const SharedFileAccess = () => {
    )} -
    - + + ) : ( +
    +
    + + setVerificationCode(e.target.value)} + /> +
    + + +
    + )}
    ); From b3940eb82f245fe730b29fe82f21c695a5618e4f Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Sun, 9 Feb 2025 16:27:21 +0800 Subject: [PATCH 09/34] Implement email verification for share file --- .../EndUser/ShareFileController.go | 11 +++++- backend/controllers/LogoutController.go | 2 -- .../AdvancedShareFileController.go | 22 +++++++----- backend/services/two_factor_auth.go | 34 +++++++++++++++++++ 4 files changed, 58 insertions(+), 11 deletions(-) diff --git a/backend/controllers/EndUser/ShareFileController.go b/backend/controllers/EndUser/ShareFileController.go index 51a7223..cb184d6 100644 --- a/backend/controllers/EndUser/ShareFileController.go +++ b/backend/controllers/EndUser/ShareFileController.go @@ -278,6 +278,15 @@ func (c *ShareFileController) AccessShare(ctx *gin.Context) { return } + // Get file info early to use in verification + file, err := c.fileModel.GetFileByID(share.FileID) + if err != nil { + ctx.JSON(http.StatusNotFound, gin.H{ + "status": "error", + "error": "File not found"}) + return + } + // Check if share has expired if share.ExpiresAt != nil && time.Now().After(*share.ExpiresAt) { ctx.JSON(http.StatusForbidden, gin.H{ @@ -314,7 +323,7 @@ func (c *ShareFileController) AccessShare(ctx *gin.Context) { } // Send 2FA to the email associated with the share - if err := c.twoFactorService.SendTwoFactorToken(share.ID, share.Email); err != nil { + if err := c.twoFactorService.SendShareVerificationToken(share.ID, share.Email, file.OriginalName); err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{ "status": "error", "error": "Failed to send 2FA code"}) diff --git a/backend/controllers/LogoutController.go b/backend/controllers/LogoutController.go index e04e35f..93262df 100644 --- a/backend/controllers/LogoutController.go +++ b/backend/controllers/LogoutController.go @@ -16,8 +16,6 @@ func NewLogoutController(userModel *models.UserModel) *LogoutController { } func (c *LogoutController) Logout(ctx *gin.Context) { - // Since we're using JWT, just return success - // Frontend will handle token removal ctx.JSON(http.StatusOK, gin.H{ "message": "Successfully logged out", }) diff --git a/backend/controllers/PremiumUser/AdvancedShareFileController.go b/backend/controllers/PremiumUser/AdvancedShareFileController.go index 037057b..68849a9 100644 --- a/backend/controllers/PremiumUser/AdvancedShareFileController.go +++ b/backend/controllers/PremiumUser/AdvancedShareFileController.go @@ -316,6 +316,13 @@ func (c *ShareFileController) AccessShare(ctx *gin.Context) { return } + // Get file info early for use in verification + file, err := c.fileModel.GetFileByID(share.FileID) + if err != nil { + ctx.JSON(http.StatusNotFound, gin.H{"error": "File not found"}) + return + } + if share.ShareType == models.RecipientShare { // Validate password only - no email needed since we have the share share, validationErr := c.fileShareModel.ValidateRecipientShare(shareLink, req.Password) @@ -326,17 +333,17 @@ func (c *ShareFileController) AccessShare(ctx *gin.Context) { return } - // Send 2FA to the email associated with the share - if err := c.twoFactorService.SendTwoFactorToken(share.ID, share.Email); err != nil { + // Send verification code to the email associated with the share + if err := c.twoFactorService.SendShareVerificationToken(share.ID, share.Email, file.OriginalName); err != nil { ctx.JSON(http.StatusInternalServerError, gin.H{ "status": "error", - "error": "Failed to send 2FA code"}) + "error": "Failed to send verification code"}) return } ctx.JSON(http.StatusOK, gin.H{ "status": "success", - "message": "2FA code sent to registered email", + "message": "Verification code sent to registered email", "data": gin.H{ "share_id": share.ID, }, @@ -351,7 +358,6 @@ func (c *ShareFileController) AccessShare(ctx *gin.Context) { c.processFileAccess(ctx, share, req.Password) } - func (c *ShareFileController) validatePremiumShare(share *models.FileShare) error { if share.ExpiresAt != nil && time.Now().After(*share.ExpiresAt) { return fmt.Errorf("share link has expired") @@ -576,10 +582,10 @@ func (c *ShareFileController) sendFileResponse(ctx *gin.Context, file *models.Fi )) ctx.Header("Content-Type", file.MimeType) ctx.Header("Content-Length", fmt.Sprintf("%d", len(data))) - ctx.Header("X-Original-Filename", escapedName) - ctx.Header("Access-Control-Expose-Headers", "Content-Disposition, Content-Type, Content-Length, X-Original-Filename") + ctx.Header("X-Original-Filename", escapedName) + ctx.Header("Access-Control-Expose-Headers", "Content-Disposition, Content-Type, Content-Length, X-Original-Filename") ctx.Header("Content-Description", "File Transfer") ctx.Header("Content-Transfer-Encoding", "binary") - log.Printf("Sending file response: %s (Size: %d bytes)", file.OriginalName, len(data)) + log.Printf("Sending file response: %s (Size: %d bytes)", file.OriginalName, len(data)) ctx.Data(http.StatusOK, file.MimeType, data) } diff --git a/backend/services/two_factor_auth.go b/backend/services/two_factor_auth.go index 75abec5..1ba1ccd 100644 --- a/backend/services/two_factor_auth.go +++ b/backend/services/two_factor_auth.go @@ -120,6 +120,40 @@ Safesplit team`, token) return s.emailSender.SendEmail(email, subject, body) } +func (s *TwoFactorAuthService) SendShareVerificationToken(shareID uint, email, fileName string) error { + if !s.rateLimiter.Allow(shareID) { + return errors.New("rate limit exceeded") + } + + token, err := generateToken() + if err != nil { + return fmt.Errorf("failed to generate token: %w", err) + } + + s.mu.Lock() + s.tokens[shareID] = &TwoFactorToken{ + Token: token, + ExpiresAt: time.Now().Add(tokenExpiry), + } + s.attempts[shareID] = 0 + s.mu.Unlock() + + subject := "Verify Your Access to Shared File" + body := fmt.Sprintf(`Hello, + +A file "%s" has been shared with you. To access this file, please use the following verification code: + +Verification Code: %s + +This code will expire in 10 minutes. Please use it along with your password to access the shared file. + +If you didn't expect to receive this file share, please ignore this email. + +Best regards, +SafeSplit Team`, fileName, token) + + return s.emailSender.SendEmail(email, subject, body) +} func (s *TwoFactorAuthService) VerifyToken(userID uint, token string) error { s.mu.Lock() From 37b030f8ac7b1743e20d5faba2bfddcb2037848e Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Sun, 9 Feb 2025 16:29:54 +0800 Subject: [PATCH 10/34] Implement unarchive file and mass unarchive file backend and frontend --- .../EndUser/UnarchiveFileController.go | 61 ++++++++++++++ .../EndUser/massUnarchiveFileController.go | 56 +++++++++++++ backend/main.go | 4 +- backend/models/activity_log.go | 2 - backend/models/billing.go | 12 --- backend/routes/routes.go | 82 +++++++++++-------- .../src/components/EndUser/FileActions.js | 46 +++++++++-- .../components/EndUser/UnarchiveFileAction.js | 77 +++++++++++++++++ 8 files changed, 282 insertions(+), 58 deletions(-) create mode 100644 backend/controllers/EndUser/UnarchiveFileController.go create mode 100644 backend/controllers/EndUser/massUnarchiveFileController.go create mode 100644 frontend/src/components/EndUser/UnarchiveFileAction.js diff --git a/backend/controllers/EndUser/UnarchiveFileController.go b/backend/controllers/EndUser/UnarchiveFileController.go new file mode 100644 index 0000000..c422daf --- /dev/null +++ b/backend/controllers/EndUser/UnarchiveFileController.go @@ -0,0 +1,61 @@ +package EndUser + +import ( + "net/http" + "safesplit/models" + "strconv" + + "github.com/gin-gonic/gin" +) + +type UnarchiveFileController struct { + fileModel *models.FileModel +} + +func NewUnarchiveFileController(fileModel *models.FileModel) *UnarchiveFileController { + return &UnarchiveFileController{ + fileModel: fileModel, + } +} + +func (c *UnarchiveFileController) Unarchive(ctx *gin.Context) { + // Get user ID from context + userID := ctx.GetUint("user_id") + if userID == 0 { + ctx.JSON(http.StatusUnauthorized, gin.H{ + "status": "error", + "error": "Unauthorized access", + }) + return + } + + // Parse file ID from URL parameter + fileID, err := strconv.ParseUint(ctx.Param("id"), 10, 32) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{ + "status": "error", + "error": "Invalid file ID", + }) + return + } + + // Call the model method to unarchive the file + err = c.fileModel.UnarchiveFile(uint(fileID), userID, ctx.ClientIP()) + if err != nil { + status := http.StatusInternalServerError + if err.Error() == "file not found or not archived" { + status = http.StatusNotFound + } + ctx.JSON(status, gin.H{ + "status": "error", + "error": err.Error(), + }) + return + } + + // Return success response + ctx.JSON(http.StatusOK, gin.H{ + "status": "success", + "message": "File unarchived successfully", + }) +} \ No newline at end of file diff --git a/backend/controllers/EndUser/massUnarchiveFileController.go b/backend/controllers/EndUser/massUnarchiveFileController.go new file mode 100644 index 0000000..a3be774 --- /dev/null +++ b/backend/controllers/EndUser/massUnarchiveFileController.go @@ -0,0 +1,56 @@ +package EndUser + +import ( + "net/http" + "safesplit/models" + + "github.com/gin-gonic/gin" +) + +type MassUnarchiveFileController struct { + fileModel *models.FileModel +} + +func NewMassUnarchiveFileController(fileModel *models.FileModel) *MassUnarchiveFileController { + return &MassUnarchiveFileController{ + fileModel: fileModel, + } +} + +func (c *MassUnarchiveFileController) Unarchive(ctx *gin.Context) { + userID := ctx.GetUint("user_id") + if userID == 0 { + ctx.JSON(http.StatusUnauthorized, gin.H{ + "status": "error", + "error": "Unauthorized access", + }) + return + } + + var request struct { + FileIDs []uint `json:"file_ids" binding:"required"` + } + + if err := ctx.ShouldBindJSON(&request); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{ + "status": "error", + "error": "Invalid request body", + }) + return + } + + results := make(map[uint]string) + for _, fileID := range request.FileIDs { + err := c.fileModel.UnarchiveFile(fileID, userID, ctx.ClientIP()) + if err != nil { + results[fileID] = err.Error() + } else { + results[fileID] = "success" + } + } + + ctx.JSON(http.StatusOK, gin.H{ + "status": "success", + "results": results, + }) +} \ No newline at end of file diff --git a/backend/main.go b/backend/main.go index 8ab61eb..1371e20 100644 --- a/backend/main.go +++ b/backend/main.go @@ -11,6 +11,7 @@ import ( "safesplit/services" "strconv" "time" + "github.com/joho/godotenv" "github.com/gin-contrib/cors" @@ -110,7 +111,7 @@ func main() { ) // Start cleanup scheduler for deleted files go func() { - ticker := time.NewTicker(24 * time.Hour) + ticker := time.NewTicker(24 * time.Hour) defer ticker.Stop() log.Println("Starting file cleanup scheduler...") @@ -145,6 +146,7 @@ func main() { compressionService, rsService, twoFactorService, + emailService, ) // Set up the Gin router with default middleware diff --git a/backend/models/activity_log.go b/backend/models/activity_log.go index b74e17f..3210a1b 100644 --- a/backend/models/activity_log.go +++ b/backend/models/activity_log.go @@ -48,10 +48,8 @@ func (m *ActivityLogModel) GetSystemLogs(filters map[string]interface{}, page, p query = query.Where("user_id = ?", userID) } - // Get total count query.Count(&total) - // Apply pagination offset := (page - 1) * pageSize err := query. Order("created_at DESC"). diff --git a/backend/models/billing.go b/backend/models/billing.go index f66e765..e7ccb5b 100644 --- a/backend/models/billing.go +++ b/backend/models/billing.go @@ -78,18 +78,15 @@ func NewBillingModel(db *gorm.DB, userModel *UserModel) *BillingModel { // CreateBillingProfile creates a new billing profile for a user func (m *BillingModel) CreateBillingProfile(profile *BillingProfile) error { return m.db.Transaction(func(tx *gorm.DB) error { - // Check if user already has a billing profile var existingProfile BillingProfile if err := tx.Where("user_id = ?", profile.UserID).First(&existingProfile).Error; err == nil { return errors.New("user already has a billing profile") } - // Generate customer ID only if not provided if profile.CustomerID == "" { profile.CustomerID = fmt.Sprintf("CUST_%d_%s", profile.UserID, time.Now().Format("20060102")) } - // Create the billing profile if err := tx.Create(profile).Error; err != nil { return fmt.Errorf("failed to create billing profile: %v", err) } @@ -98,7 +95,6 @@ func (m *BillingModel) CreateBillingProfile(profile *BillingProfile) error { }) } -// updateBillingProfile updates or creates a billing profile func (m *BillingModel) UpdateBillingProfile(profile *BillingProfile) error { return m.db.Transaction(func(tx *gorm.DB) error { updates := map[string]interface{}{ @@ -127,7 +123,6 @@ func (m *BillingModel) UpdateBillingProfile(profile *BillingProfile) error { return nil }) } -// GetUserBillingProfile retrieves a user's billing profile func (m *BillingModel) GetUserBillingProfile(userID uint) (*BillingProfile, error) { var profile BillingProfile if err := m.db.Where("user_id = ?", userID).First(&profile).Error; err != nil { @@ -136,7 +131,6 @@ func (m *BillingModel) GetUserBillingProfile(userID uint) (*BillingProfile, erro return &profile, nil } -// GetUserWithBilling retrieves user and their billing information func (m *BillingModel) GetUserWithBilling(userID uint) (*UserBillingInfo, error) { var info UserBillingInfo @@ -155,7 +149,6 @@ func (m *BillingModel) GetUserWithBilling(userID uint) (*UserBillingInfo, error) return &info, nil } -// UpdateSubscriptionStatus updates subscription and billing status func (m *BillingModel) UpdateSubscriptionStatus(userID uint, status string) error { return m.db.Transaction(func(tx *gorm.DB) error { var user User @@ -190,7 +183,6 @@ func (m *BillingModel) UpdateSubscriptionStatus(userID uint, status string) erro }) } -// CancelSubscription cancels user's subscription func (m *BillingModel) CancelSubscription(userID uint) error { return m.db.Transaction(func(tx *gorm.DB) error { var user User @@ -198,12 +190,10 @@ func (m *BillingModel) CancelSubscription(userID uint) error { return err } - // Check storage quota before scheduling downgrade if user.StorageUsed > DefaultStorageQuota { return ErrStorageExceedsQuota } - // Keep subscription active until next billing date var profile BillingProfile if err := tx.Where("user_id = ?", userID).First(&profile).Error; err != nil { return err @@ -216,7 +206,6 @@ func (m *BillingModel) CancelSubscription(userID uint) error { }) } -// GetSubscriptionStats gets billing statistics func (m *BillingModel) GetSubscriptionStats() (map[string]interface{}, error) { var stats map[string]interface{} @@ -232,7 +221,6 @@ func (m *BillingModel) GetSubscriptionStats() (map[string]interface{}, error) { return stats, err } -// GetExpiringSubscriptions gets subscriptions expiring soon func (m *BillingModel) GetExpiringSubscriptions(days int) ([]BillingProfile, error) { var profiles []BillingProfile expiryDate := time.Now().AddDate(0, 0, days) diff --git a/backend/routes/routes.go b/backend/routes/routes.go index 13b7c62..5aa5a20 100644 --- a/backend/routes/routes.go +++ b/backend/routes/routes.go @@ -34,7 +34,9 @@ type EndUserHandlers struct { DeleteFileController *EndUser.DeleteFileController MassDeleteFileController *EndUser.MassDeleteFileController ArchiveFileController *EndUser.ArchiveFileController + UnarchiveFileController *EndUser.UnarchiveFileController MassArchiveController *EndUser.MassArchiveFileController + MassUnarchiveController *EndUser.MassUnarchiveFileController ShareFileController *EndUser.ShareFileController CreateFolderController *EndUser.CreateFolderController ViewFolderController *EndUser.ViewFolderController @@ -67,8 +69,8 @@ type SysAdminHandlers struct { ViewDeletedUserAccountController *SysAdmin.ViewDeletedUserAccountController ViewUserStorageController *SysAdmin.ViewUserStorageController ViewUserAccountDetailsController *SysAdmin.ViewUserAccountDetailsController - ViewFeedbacksController *SysAdmin.ViewFeedbacksController - ViewReportsController *SysAdmin.ViewReportsController + ViewFeedbacksController *SysAdmin.ViewFeedbacksController + ViewReportsController *SysAdmin.ViewReportsController } func NewRouteHandlers( @@ -89,6 +91,7 @@ func NewRouteHandlers( compressionService *services.CompressionService, rsService *services.ReedSolomonService, twoFactorService *services.TwoFactorAuthService, + emailService *services.SMTPEmailService, ) *RouteHandlers { superAdminLoginController := SuperAdmin.NewLoginController(userModel) return &RouteHandlers{ @@ -110,8 +113,8 @@ func NewRouteHandlers( ViewDeletedUserAccountController: SysAdmin.NewViewDeletedUserAccountController(userModel), ViewUserStorageController: SysAdmin.NewViewUserStorageController(userModel), ViewUserAccountDetailsController: SysAdmin.NewViewUserAccountDetailsController(userModel, billingModel), - ViewFeedbacksController: SysAdmin.NewViewFeedbacksController(feedbackModel), - ViewReportsController: SysAdmin.NewViewReportsController(feedbackModel, userModel), + ViewFeedbacksController: SysAdmin.NewViewFeedbacksController(feedbackModel), + ViewReportsController: SysAdmin.NewViewReportsController(feedbackModel, userModel), }, EndUserHandlers: &EndUserHandlers{ UploadFileController: EndUser.NewFileController(fileModel, userModel, activityLogModel, encryptionService, shamirService, keyFragmentModel, compressionService, folderModel, rsService, serverMasterKeyModel), @@ -122,8 +125,10 @@ func NewRouteHandlers( DeleteFileController: EndUser.NewDeleteFileController(fileModel), MassDeleteFileController: EndUser.NewMassDeleteFileController(fileModel), ArchiveFileController: EndUser.NewArchiveFileController(fileModel), + UnarchiveFileController: EndUser.NewUnarchiveFileController(fileModel), MassArchiveController: EndUser.NewMassArchiveFileController(fileModel), - ShareFileController: EndUser.NewShareFileController(fileModel, fileShareModel, keyFragmentModel, encryptionService, activityLogModel, rsService, userModel, serverMasterKeyModel), + MassUnarchiveController: EndUser.NewMassUnarchiveFileController(fileModel), + ShareFileController: EndUser.NewShareFileController(fileModel, fileShareModel, keyFragmentModel, encryptionService, activityLogModel, rsService, userModel, serverMasterKeyModel, twoFactorService, emailService, compressionService), CreateFolderController: EndUser.NewCreateFolderController(folderModel, activityLogModel), ViewFolderController: EndUser.NewViewFolderController(folderModel, fileModel), DeleteFolderController: EndUser.NewDeleteFolderController(folderModel, activityLogModel), @@ -131,13 +136,13 @@ func NewRouteHandlers( ViewStorageController: EndUser.NewViewStorageController(fileModel, userModel), PaymentController: EndUser.NewPaymentController(billingModel), SubscriptionController: EndUser.NewSubscriptionController(billingModel), - ReportController: EndUser.NewReportController(feedbackModel, fileModel), - FeedbackController: EndUser.NewFeedbackController(feedbackModel), + ReportController: EndUser.NewReportController(feedbackModel, fileModel), + FeedbackController: EndUser.NewFeedbackController(feedbackModel), }, PremiumUserHandlers: &PremiumUserHandlers{ FragmentController: PremiumUser.NewFragmentController(keyFragmentModel, fileModel), FileRecoveryController: PremiumUser.NewFileRecoveryController(fileModel), - AdvancedShareFileController: PremiumUser.NewShareFileController(fileModel, fileShareModel, keyFragmentModel, encryptionService, activityLogModel, rsService, userModel, serverMasterKeyModel), + AdvancedShareFileController: PremiumUser.NewShareFileController(fileModel, fileShareModel, keyFragmentModel, encryptionService, activityLogModel, rsService, userModel, serverMasterKeyModel, twoFactorService, emailService, compressionService), }, } } @@ -157,8 +162,17 @@ func setupPublicRoutes(api *gin.RouterGroup, handlers *RouteHandlers) { api.POST("/login", handlers.LoginController.Login) api.POST("/super-login", handlers.SuperAdminLoginController.Login) api.POST("/register", handlers.CreateAccountController.CreateAccount) + + // Public share routes + api.GET("/files/share/:shareLink", handlers.EndUserHandlers.ShareFileController.AccessShare) api.POST("/files/share/:shareLink", handlers.EndUserHandlers.ShareFileController.AccessShare) + api.POST("/files/share/:shareLink/verify", handlers.EndUserHandlers.ShareFileController.Verify2FAAndDownload) + + // Premium share routes + api.GET("/premium/shares/:shareLink", handlers.PremiumUserHandlers.AdvancedShareFileController.AccessShare) api.POST("/premium/shares/:shareLink", handlers.PremiumUserHandlers.AdvancedShareFileController.AccessShare) + api.POST("/premium/shares/:shareLink/verify", handlers.PremiumUserHandlers.AdvancedShareFileController.Verify2FAAndDownload) + api.GET("/health", func(c *gin.Context) { c.JSON(200, gin.H{"status": "ok"}) }) @@ -195,7 +209,6 @@ func setupProtectedRoutes(protected *gin.RouterGroup, handlers *RouteHandlers) { func setupEndUserRoutes(protected *gin.RouterGroup, handlers *EndUserHandlers) { protected.PUT("/reset-password", handlers.PasswordResetController.ResetPassword) - // Existing files routes files := protected.Group("/files") { files.GET("", handlers.ViewFilesController.ListUserFiles) @@ -208,9 +221,10 @@ func setupEndUserRoutes(protected *gin.RouterGroup, handlers *EndUserHandlers) { files.DELETE("/:id", handlers.DeleteFileController.Delete) files.POST("/mass-delete", handlers.MassDeleteFileController.Delete) files.PUT("/:id/archive", handlers.ArchiveFileController.Archive) + files.PUT("/:id/unarchive", handlers.UnarchiveFileController.Unarchive) files.POST("/mass-archive", handlers.MassArchiveController.Archive) + files.POST("/mass-unarchive", handlers.MassUnarchiveController.Unarchive) files.POST("/:id/share", handlers.ShareFileController.CreateShare) - files.GET("/share/:shareLink", handlers.ShareFileController.AccessShare) } folders := protected.Group("/folders") @@ -231,18 +245,18 @@ func setupEndUserRoutes(protected *gin.RouterGroup, handlers *EndUserHandlers) { payment.POST("/cancel", handlers.SubscriptionController.CancelSubscription) } feedback := protected.Group("/feedback") - { - feedback.POST("", handlers.FeedbackController.SubmitFeedback) - feedback.GET("", handlers.FeedbackController.GetUserFeedback) - feedback.GET("/categories", handlers.FeedbackController.GetFeedbackCategories) - } + { + feedback.POST("", handlers.FeedbackController.SubmitFeedback) + feedback.GET("", handlers.FeedbackController.GetUserFeedback) + feedback.GET("/categories", handlers.FeedbackController.GetFeedbackCategories) + } - reports := protected.Group("/reports") - { - reports.POST("/file/:id", handlers.ReportController.ReportFile) - reports.POST("/share/:shareLink", handlers.ReportController.ReportShare) - reports.GET("", handlers.ReportController.GetUserReports) - } + reports := protected.Group("/reports") + { + reports.POST("/file/:id", handlers.ReportController.ReportFile) + reports.POST("/share/:shareLink", handlers.ReportController.ReportShare) + reports.GET("", handlers.ReportController.GetUserReports) + } } func setupPremiumUserRoutes(premium *gin.RouterGroup, handlers *PremiumUserHandlers) { @@ -285,18 +299,18 @@ func setupSysAdminRoutes(sysAdmin *gin.RouterGroup, handlers *SysAdminHandlers) sysAdmin.GET("/storage/stats", handlers.ViewUserStorageController.GetStorageStats) feedback := sysAdmin.Group("/feedback") - { - feedback.GET("", handlers.ViewFeedbacksController.GetAllFeedbacks) - feedback.GET("/:id", handlers.ViewFeedbacksController.GetFeedback) - feedback.PUT("/:id/status", handlers.ViewFeedbacksController.UpdateFeedbackStatus) - feedback.GET("/stats", handlers.ViewFeedbacksController.GetFeedbackStats) - } + { + feedback.GET("", handlers.ViewFeedbacksController.GetAllFeedbacks) + feedback.GET("/:id", handlers.ViewFeedbacksController.GetFeedback) + feedback.PUT("/:id/status", handlers.ViewFeedbacksController.UpdateFeedbackStatus) + feedback.GET("/stats", handlers.ViewFeedbacksController.GetFeedbackStats) + } - reports := sysAdmin.Group("/reports") - { - reports.GET("", handlers.ViewReportsController.GetAllReports) - reports.GET("/:id", handlers.ViewReportsController.GetReportDetails) - reports.PUT("/:id/status", handlers.ViewReportsController.UpdateReportStatus) - reports.GET("/stats", handlers.ViewReportsController.GetReportStats) - } + reports := sysAdmin.Group("/reports") + { + reports.GET("", handlers.ViewReportsController.GetAllReports) + reports.GET("/:id", handlers.ViewReportsController.GetReportDetails) + reports.PUT("/:id/status", handlers.ViewReportsController.UpdateReportStatus) + reports.GET("/stats", handlers.ViewReportsController.GetReportStats) + } } diff --git a/frontend/src/components/EndUser/FileActions.js b/frontend/src/components/EndUser/FileActions.js index e73d593..aaa7964 100644 --- a/frontend/src/components/EndUser/FileActions.js +++ b/frontend/src/components/EndUser/FileActions.js @@ -1,13 +1,29 @@ -import React, { useState } from 'react'; +import React, { useState, useRef, useEffect } from 'react'; import { Download, Trash2, Share2, Archive, MoreVertical, Check } from 'lucide-react'; import DownloadFileAction from './DownloadFileAction'; import DeleteFileAction from './DeleteFileAction'; import ShareFileAction from './ShareFileAction'; import ArchiveFileAction from './ArchiveFileAction'; -import ReportFileAction from './ReportFileAction'; +import UnarchiveFileAction from './UnarchiveFileAction'; +import ReportFileAction from './ReportFileAction'; const FileActions = ({ file, user, onRefresh, onAction, isSelectable = false, selected = false, onSelect, selectedFiles = [] }) => { const [showActions, setShowActions] = useState(false); + const actionsRef = useRef(null); + + useEffect(() => { + const handleClickOutside = (event) => { + if (actionsRef.current && !actionsRef.current.contains(event.target)) { + setShowActions(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, []); const handleClick = (e) => { if (isSelectable) { @@ -18,8 +34,12 @@ const FileActions = ({ file, user, onRefresh, onAction, isSelectable = false, se } }; + const allFilesArchived = selectedFiles.length > 0 + ? selectedFiles.every(f => f.is_archived) + : file.is_archived; + return ( -
    +
    )}
    diff --git a/frontend/src/components/EndUser/UnarchiveFileAction.js b/frontend/src/components/EndUser/UnarchiveFileAction.js new file mode 100644 index 0000000..df6a06f --- /dev/null +++ b/frontend/src/components/EndUser/UnarchiveFileAction.js @@ -0,0 +1,77 @@ +import React, { useState } from 'react'; +import { Archive, Loader } from 'lucide-react'; + +const UnarchiveFileAction = ({ file, selectedFiles = [], onRefresh }) => { + const [isUnarchiving, setIsUnarchiving] = useState(false); + + const handleUnarchive = async () => { + const files = selectedFiles.length > 0 ? selectedFiles : [file]; + const confirmMessage = `Are you sure you want to unarchive ${files.length > 1 ? `these ${files.length} files` : 'this file'}?`; + + if (!window.confirm(confirmMessage)) return; + + setIsUnarchiving(true); + + try { + const token = localStorage.getItem('token'); + + if (files.length === 1) { + const response = await fetch(`http://localhost:8080/api/files/${files[0].id}/unarchive`, { + method: 'PUT', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + if (!response.ok) throw new Error('Failed to unarchive file'); + } else { + const response = await fetch('http://localhost:8080/api/files/mass-unarchive', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + file_ids: files.map(f => f.id) + }) + }); + + if (!response.ok) throw new Error('Failed to unarchive files'); + + const result = await response.json(); + const failedUnarchives = result.data?.unarchive_status?.filter( + status => status.status === 'error' + ); + + if (failedUnarchives?.length > 0) { + throw new Error(`Failed to unarchive ${failedUnarchives.length} files`); + } + } + + onRefresh?.(); + } catch (error) { + console.error('Unarchive error:', error); + alert(error.message || 'Failed to unarchive file(s)'); + } finally { + setIsUnarchiving(false); + } + }; + + return ( + + ); +}; + +export default UnarchiveFileAction; \ No newline at end of file From bdf0cd6202ec38c6fc821d8c871c2b0691a6de23 Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Sun, 9 Feb 2025 16:30:35 +0800 Subject: [PATCH 11/34] Fix redirection issue with dashboard page after file deletion --- .../components/EndUser/DeleteFileAction.js | 32 ++++++++++++++----- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/frontend/src/components/EndUser/DeleteFileAction.js b/frontend/src/components/EndUser/DeleteFileAction.js index 23db347..0d40bde 100644 --- a/frontend/src/components/EndUser/DeleteFileAction.js +++ b/frontend/src/components/EndUser/DeleteFileAction.js @@ -1,23 +1,29 @@ -import React from 'react'; -import { Trash2 } from 'lucide-react'; +import React, { useState } from 'react'; +import { Trash2, Loader } from 'lucide-react'; + +const DeleteFileAction = ({ file, selectedFiles = [], onRefresh }) => { + const [isDeleting, setIsDeleting] = useState(false); -const DeleteFileAction = ({ file, selectedFiles = [] }) => { const handleDelete = async () => { const files = selectedFiles.length > 0 ? selectedFiles : [file]; const confirmMessage = `Are you sure you want to delete ${files.length > 1 ? 'these files' : 'this file'}?`; if (!window.confirm(confirmMessage)) return; + setIsDeleting(true); + try { const token = localStorage.getItem('token'); if (files.length === 1) { - await fetch(`http://localhost:8080/api/files/${files[0].id}`, { + const response = await fetch(`http://localhost:8080/api/files/${files[0].id}`, { method: 'DELETE', headers: { 'Authorization': `Bearer ${token}` }, }); + + if (!response.ok) throw new Error('Failed to delete file'); } else { - await fetch('http://localhost:8080/api/files/mass-delete', { + const response = await fetch('http://localhost:8080/api/files/mass-delete', { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, @@ -27,20 +33,30 @@ const DeleteFileAction = ({ file, selectedFiles = [] }) => { file_ids: files.map(f => f.id) }) }); + + if (!response.ok) throw new Error('Failed to delete files'); } - window.location.reload(); + onRefresh?.(); } catch (error) { console.error('Delete error:', error); + alert(error.message || 'Failed to delete file(s)'); + } finally { + setIsDeleting(false); } }; return ( ); From d7516698713cf27fca53c24c755a572318598c66 Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Sun, 9 Feb 2025 16:31:23 +0800 Subject: [PATCH 12/34] Fix billing cycle loading issue on Settings --- frontend/src/components/EndUser/Settings.js | 87 ++++++++++++--------- 1 file changed, 51 insertions(+), 36 deletions(-) diff --git a/frontend/src/components/EndUser/Settings.js b/frontend/src/components/EndUser/Settings.js index 09cb937..d8a8089 100644 --- a/frontend/src/components/EndUser/Settings.js +++ b/frontend/src/components/EndUser/Settings.js @@ -7,7 +7,7 @@ import TwoFactorSettings from './TwoFactorAuthentication'; const Settings = ({ user: initialUser, onUserUpdate }) => { const [activeTab, setActiveTab] = useState('account'); const [currentUser, setCurrentUser] = useState(initialUser?.data?.user || {}); - const [billingProfile, setBillingProfile] = useState(initialUser?.data?.billing_profile || {}); + const [billingProfile, setBillingProfile] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); const [showCancelModal, setShowCancelModal] = useState(false); @@ -29,7 +29,9 @@ const Settings = ({ user: initialUser, onUserUpdate }) => { if (response.ok) { const data = await response.json(); setCurrentUser(data.data.user); - setBillingProfile(data.data.billing_profile); + if (data.data.billing_profile) { + setBillingProfile(data.data.billing_profile); + } if (onUserUpdate) onUserUpdate(data); } } catch (error) { @@ -89,11 +91,52 @@ const Settings = ({ user: initialUser, onUserUpdate }) => {
    ); - const formatBytes = (bytes) => { - const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; - if (bytes === 0) return '0 Bytes'; - const i = parseInt(Math.floor(Math.log(bytes) / Math.log(1024))); - return Math.round(bytes / Math.pow(1024, i), 2) + ' ' + sizes[i]; + const renderAccountDetails = () => { + return ( +
    +

    Username: {currentUser.username}

    +

    Email: {currentUser.email}

    +

    Subscription Plan: {currentUser.subscription_status || 'Free'}

    + + {/* Only show billing details if billing profile exists */} + {billingProfile && ( + <> +

    Billing Cycle: {billingProfile.billing_cycle}

    +

    + Next Billing Date: {' '} + {billingProfile.next_billing_date + ? new Date(billingProfile.next_billing_date).toLocaleDateString() + : 'N/A' + } +

    + + )} + + {/* Show cancel button only if user has an active premium subscription */} + {currentUser.subscription_status === 'premium' && + billingProfile?.billing_status === 'active' && ( +
    + +
    + )} + + {/* Show cancelled subscription message if applicable */} + {billingProfile?.billing_status === 'cancelled' && ( +
    +

    + Your subscription is cancelled and will end on{' '} + {new Date(billingProfile.next_billing_date).toLocaleDateString()} +

    +
    + )} +
    + ); }; return ( @@ -143,35 +186,7 @@ const Settings = ({ user: initialUser, onUserUpdate }) => { {activeTab === 'account' && (

    Account Details

    -
    -

    Username: {currentUser.username}

    -

    Email: {currentUser.email}

    -

    Subscription Plan: {currentUser.subscription_status}

    - {billingProfile && ( - <> -

    Billing Cycle: {billingProfile.billing_cycle}

    -

    Next Billing Date: {new Date(billingProfile.next_billing_date).toLocaleDateString()}

    - - )} - {currentUser.subscription_status === 'premium' && billingProfile?.billing_status === 'active' && ( -
    - -
    - )} - {billingProfile?.billing_status === 'cancelled' && ( -
    -

    - Your subscription is cancelled and will end on {new Date(billingProfile.next_billing_date).toLocaleDateString()} -

    -
    - )} -
    + {renderAccountDetails()}
    )} From cc51fc442919cad9a15e00ca2f8941e75ef97757 Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Sun, 9 Feb 2025 16:32:30 +0800 Subject: [PATCH 13/34] Fix drag and drop issue for single upload --- frontend/src/components/EndUser/UploadFile.js | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/EndUser/UploadFile.js b/frontend/src/components/EndUser/UploadFile.js index b72ac93..0ba9d03 100644 --- a/frontend/src/components/EndUser/UploadFile.js +++ b/frontend/src/components/EndUser/UploadFile.js @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { Upload, X, Info, FolderIcon, Lock } from 'lucide-react'; const UploadFile = ({ isOpen, onClose, onUpload, currentFolder }) => { @@ -44,11 +44,22 @@ const UploadFile = ({ isOpen, onClose, onUpload, currentFolder }) => { } }; - const handleFileSelect = (event) => { + const handleFileSelect = useCallback((event) => { const file = event.target.files[0]; setSelectedFile(file); setError(''); - }; + }, []); + + const handleDrop = useCallback((event) => { + event.preventDefault(); + const file = event.dataTransfer.files[0]; // Take only the first file + setSelectedFile(file); + setError(''); + }, []); + + const handleDragOver = useCallback((event) => { + event.preventDefault(); + }, []); const handleUpload = async () => { if (!selectedFile) { @@ -176,7 +187,11 @@ const UploadFile = ({ isOpen, onClose, onUpload, currentFolder }) => {
    -
    +
    Date: Sun, 9 Feb 2025 16:33:09 +0800 Subject: [PATCH 14/34] Implement multi selection on Folders and visual upgrade --- frontend/src/components/EndUser/ViewFolder.js | 107 +++++++++++++++--- 1 file changed, 89 insertions(+), 18 deletions(-) diff --git a/frontend/src/components/EndUser/ViewFolder.js b/frontend/src/components/EndUser/ViewFolder.js index 985cf3f..8ff4145 100644 --- a/frontend/src/components/EndUser/ViewFolder.js +++ b/frontend/src/components/EndUser/ViewFolder.js @@ -1,5 +1,5 @@ import React, { useState, useEffect } from 'react'; -import { Folder, MoreVertical, ChevronLeft, File } from 'lucide-react'; +import { Folder, MoreVertical, ChevronLeft, File, Loader } from 'lucide-react'; import FileActions from './FileActions'; const ViewFolder = ({ @@ -8,6 +8,7 @@ const ViewFolder = ({ onFolderDelete, onBackClick, selectedSection, + user, showActions = true, refreshTrigger = 0 }) => { @@ -15,6 +16,8 @@ const ViewFolder = ({ const [files, setFiles] = useState([]); const [error, setError] = useState(null); const [loading, setLoading] = useState(true); + const [selectedFiles, setSelectedFiles] = useState(new Set()); + const [isSelectionMode, setIsSelectionMode] = useState(false); const fetchContents = async () => { try { @@ -38,7 +41,6 @@ const ViewFolder = ({ const data = await response.json(); - // Handle different response structures if (currentFolder) { setFolders(data.data.folder.sub_folders || []); setFiles(data.data.folder.files || []); @@ -60,6 +62,8 @@ const ViewFolder = ({ useEffect(() => { fetchContents(); + setSelectedFiles(new Set()); + setIsSelectionMode(false); }, [currentFolder, selectedSection, refreshTrigger]); const formatFileSize = (bytes) => { @@ -79,6 +83,31 @@ const ViewFolder = ({ }); }; + const handleFileSelection = (fileId) => { + setSelectedFiles(prev => { + const newSelected = new Set(prev); + if (newSelected.has(fileId)) { + newSelected.delete(fileId); + } else { + newSelected.add(fileId); + } + return newSelected; + }); + }; + + const handleBulkSelection = (event) => { + if (event.target.checked) { + const visibleFileIds = filteredFiles.map(file => file.id); + setSelectedFiles(new Set(visibleFileIds)); + } else { + setSelectedFiles(new Set()); + } + }; + + const getSelectedFiles = () => { + return filteredFiles.filter(file => selectedFiles.has(file.id)); + }; + // Filter files based on selectedSection const filteredFiles = files.filter(file => { if (selectedSection === 'Archives') { @@ -92,7 +121,7 @@ const ViewFolder = ({ if (loading) { return (
    -
    + Loading contents...
    ); @@ -126,6 +155,20 @@ const ViewFolder = ({
    )} + {selectedFiles.size > 0 && ( +
    + + {selectedFiles.size} files selected + + +
    + )} + {folders.length === 0 && filteredFiles.length === 0 ? (
    {currentFolder @@ -137,20 +180,26 @@ const ViewFolder = ({ {folders.length > 0 && (

    Folders

    -
    - {folders.map((folder) => ( +
    + {folders.map((folder) => (
    {showActions && ( )}
    @@ -172,18 +224,32 @@ const ViewFolder = ({ {filteredFiles.length > 0 && (

    Files

    -
    +
    -
    Name
    +
    + 0 && selectedFiles.size === filteredFiles.length} + className="rounded" + /> + Name +
    Size
    Last Modified
    Actions
    {filteredFiles.map((file) => (
    -
    - - +
    + handleFileSelection(file.id)} + className="rounded" + /> + + {file.original_name || file.name}
    @@ -196,7 +262,12 @@ const ViewFolder = ({
    handleFileSelection(file.id)} + selectedFiles={getSelectedFiles()} />
    From ecdf075b33af6c52c87bd811027ea9257bc60c30 Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Wed, 12 Feb 2025 17:23:05 +0800 Subject: [PATCH 15/34] Fix server master key generation --- backend/models/server_master_key.go | 60 +++++++---------------------- 1 file changed, 13 insertions(+), 47 deletions(-) diff --git a/backend/models/server_master_key.go b/backend/models/server_master_key.go index 026148c..3944994 100644 --- a/backend/models/server_master_key.go +++ b/backend/models/server_master_key.go @@ -4,7 +4,6 @@ import ( "crypto/rand" "encoding/hex" "fmt" - "log" "safesplit/utils" "time" @@ -14,7 +13,7 @@ import ( type ServerMasterKey struct { ID uint `json:"id" gorm:"primaryKey"` KeyID string `json:"key_id" gorm:"type:varchar(64);unique;not null"` - EncryptedKey []byte `json:"-" gorm:"type:binary(64);not null"` + EncryptedKey []byte `json:"-" gorm:"type:binary(32);not null"` KeyNonce []byte `json:"-" gorm:"type:binary(16);not null"` IsActive bool `json:"is_active" gorm:"default:true"` CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"` @@ -40,51 +39,40 @@ func generateKeyID() (string, error) { // Initialize generates and stores the first server master key if none exists func (m *ServerMasterKeyModel) Initialize() error { - // Check if there's already an active key var count int64 if err := m.db.Model(&ServerMasterKey{}).Where("is_active = ?", true).Count(&count).Error; err != nil { return fmt.Errorf("failed to check existing keys: %w", err) } if count > 0 { - return nil // Server key already exists + return nil } - // Generate a new 32-byte master key masterKey := make([]byte, 32) if _, err := rand.Read(masterKey); err != nil { return fmt.Errorf("failed to generate master key: %w", err) } - // For 64-byte storage, pad with zeros - paddedKey := make([]byte, 64) - copy(paddedKey, masterKey) - keyID, err := generateKeyID() if err != nil { return fmt.Errorf("failed to generate key ID: %w", err) } - // Generate 16-byte nonce - nonce := make([]byte, 16) - if _, err := rand.Read(nonce); err != nil { + nonce, err := utils.GenerateNonce() + if err != nil { return fmt.Errorf("failed to generate nonce: %w", err) } now := time.Now() serverKey := &ServerMasterKey{ KeyID: keyID, - EncryptedKey: paddedKey, // Use padded 64-byte key - KeyNonce: nonce, // 16-byte nonce + EncryptedKey: masterKey, + KeyNonce: nonce, IsActive: true, ActivatedAt: &now, } - if err := m.db.Create(serverKey).Error; err != nil { - return fmt.Errorf("failed to store server master key: %w", err) - } - - return nil + return m.db.Create(serverKey).Error } // GetServerKey retrieves and processes the server key for encryption @@ -94,26 +82,11 @@ func (m *ServerMasterKeyModel) GetServerKey(keyID string) ([]byte, error) { return nil, fmt.Errorf("failed to get server key: %w", err) } - // Add debug logging - log.Printf("Retrieved key length: %d bytes", len(key.EncryptedKey)) - log.Printf("Raw key bytes: %v", key.EncryptedKey) - - // Handle hex-encoded string if that's what we're getting - if len(key.EncryptedKey) == 64 { - // Use first 32 bytes - log.Printf("Using first 32 bytes of 64-byte key") - return key.EncryptedKey[:32], nil - } - - // For any other length, try to decode if it's hex-encoded - if decoded, err := hex.DecodeString(string(key.EncryptedKey)); err == nil { - if len(decoded) >= 32 { - log.Printf("Decoded hex string to %d bytes, using first 32", len(decoded)) - return decoded[:32], nil - } + if len(key.EncryptedKey) != 32 { + return nil, fmt.Errorf("invalid key length: got %d, expected 32 bytes", len(key.EncryptedKey)) } - return nil, fmt.Errorf("invalid server key length in database: got %d bytes, need 64 for raw or hex-encoded key", len(key.EncryptedKey)) + return key.EncryptedKey, nil } // GetActive retrieves the current active server master key @@ -128,14 +101,12 @@ func (m *ServerMasterKeyModel) GetActive() (*ServerMasterKey, error) { // Rotate generates a new master key and retires the old one func (m *ServerMasterKeyModel) Rotate() error { return m.db.Transaction(func(tx *gorm.DB) error { - // Get current active key var currentKey ServerMasterKey if err := tx.Where("is_active = ? AND retired_at IS NULL", true).First(¤tKey).Error; err != nil { return fmt.Errorf("failed to get current server key: %w", err) } - // Generate new 64-byte key - masterKey := make([]byte, 64) + masterKey := make([]byte, 32) if _, err := rand.Read(masterKey); err != nil { return fmt.Errorf("failed to generate master key: %w", err) } @@ -163,15 +134,10 @@ func (m *ServerMasterKeyModel) Rotate() error { return fmt.Errorf("failed to create new key: %w", err) } - // Retire old key - if err := tx.Model(¤tKey).Updates(map[string]interface{}{ + return tx.Model(¤tKey).Updates(map[string]interface{}{ "is_active": false, "retired_at": now, - }).Error; err != nil { - return fmt.Errorf("failed to retire old key: %w", err) - } - - return nil + }).Error }) } From adf1f99bf1fc786c8b65210d3af341345ffc8744 Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Wed, 12 Feb 2025 17:24:04 +0800 Subject: [PATCH 16/34] Fix 2FA enable button and add new disable flow ( now required 2FA code for disabling) --- .../EndUser/TwoFactorAuthController.go | 213 ++++++++++++------ backend/routes/routes.go | 7 +- database-setup.sql | 2 +- .../EndUser/TwoFactorAuthentication.js | 163 ++++++++++++-- 4 files changed, 287 insertions(+), 98 deletions(-) diff --git a/backend/controllers/EndUser/TwoFactorAuthController.go b/backend/controllers/EndUser/TwoFactorAuthController.go index 3635fb7..9b4f7a0 100644 --- a/backend/controllers/EndUser/TwoFactorAuthController.go +++ b/backend/controllers/EndUser/TwoFactorAuthController.go @@ -1,90 +1,159 @@ package EndUser import ( - "fmt" - "log" - "net/http" - "safesplit/models" + "fmt" + "log" + "net/http" + "safesplit/models" + "safesplit/services" - "github.com/gin-gonic/gin" + "github.com/gin-gonic/gin" ) type TwoFactorController struct { - userModel *models.UserModel + userModel *models.UserModel + twoFactorService *services.TwoFactorAuthService } -func NewTwoFactorController(userModel *models.UserModel) *TwoFactorController { - return &TwoFactorController{ - userModel: userModel, - } +func NewTwoFactorController(userModel *models.UserModel, twoFactorService *services.TwoFactorAuthService) *TwoFactorController { + return &TwoFactorController{ + userModel: userModel, + twoFactorService: twoFactorService, + } } func (c *TwoFactorController) EnableEmailTwoFactor(ctx *gin.Context) { - userID, exists := ctx.Get("user_id") - if !exists { - ctx.JSON(http.StatusBadRequest, gin.H{"error": "User ID not found in context"}) - return - } - - uid, ok := userID.(uint) - if !ok { - ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID type"}) - return - } - - log.Printf("Enabling 2FA for user ID: %d", uid) - - if err := c.userModel.EnableEmailTwoFactor(uid); err != nil { - ctx.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to enable 2FA: %v", err)}) - return - } - - ctx.JSON(http.StatusOK, gin.H{"message": "2FA enabled successfully"}) + userID, exists := ctx.Get("user_id") + if !exists { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "User ID not found in context"}) + return + } + + uid, ok := userID.(uint) + if !ok { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID type"}) + return + } + + log.Printf("Enabling 2FA for user ID: %d", uid) + + if err := c.userModel.EnableEmailTwoFactor(uid); err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to enable 2FA: %v", err)}) + return + } + + ctx.JSON(http.StatusOK, gin.H{"message": "2FA enabled successfully"}) } -func (c *TwoFactorController) DisableEmailTwoFactor(ctx *gin.Context) { - userID, exists := ctx.Get("user_id") - if !exists { - ctx.JSON(http.StatusBadRequest, gin.H{"error": "User ID not found in context"}) - return - } - - uid, ok := userID.(uint) - if !ok { - ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID type"}) - return - } - - log.Printf("Disabling 2FA for user ID: %d", uid) - - if err := c.userModel.DisableEmailTwoFactor(uid); err != nil { - ctx.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to disable 2FA: %v", err)}) - return - } +func (c *TwoFactorController) InitiateDisable2FA(ctx *gin.Context) { + userID, exists := ctx.Get("user_id") + if !exists { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "User ID not found in context"}) + return + } + + uid, ok := userID.(uint) + if !ok { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID type"}) + return + } + + // Get user's email + user, err := c.userModel.FindByID(uid) + if err != nil { + ctx.JSON(http.StatusNotFound, gin.H{"error": "User not found"}) + return + } + + // Verify 2FA is enabled + if !user.TwoFactorEnabled { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "2FA is not enabled"}) + return + } + + log.Printf("Initiating 2FA disable for user ID: %d", uid) + + // Send verification code + if err := c.twoFactorService.SendTwoFactorToken(uid, user.Email); err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to send verification code: %v", err)}) + return + } + + ctx.JSON(http.StatusOK, gin.H{"message": "Verification code sent to your email"}) +} - ctx.JSON(http.StatusOK, gin.H{"message": "2FA disabled successfully"}) +func (c *TwoFactorController) VerifyAndDisable2FA(ctx *gin.Context) { + userID, exists := ctx.Get("user_id") + if !exists { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "User ID not found in context"}) + return + } + + uid, ok := userID.(uint) + if !ok { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID type"}) + return + } + + // Get verification code from request + var req struct { + Code string `json:"code" binding:"required"` + } + + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + + // Verify 2FA is still enabled + user, err := c.userModel.FindByID(uid) + if err != nil { + ctx.JSON(http.StatusNotFound, gin.H{"error": "User not found"}) + return + } + + if !user.TwoFactorEnabled { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "2FA is not enabled"}) + return + } + + // Verify the code + if err := c.twoFactorService.VerifyToken(uid, req.Code); err != nil { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or expired verification code"}) + return + } + + log.Printf("Disabling 2FA for user ID: %d after verification", uid) + + // Disable 2FA + if err := c.userModel.DisableEmailTwoFactor(uid); err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to disable 2FA: %v", err)}) + return + } + + ctx.JSON(http.StatusOK, gin.H{"message": "2FA disabled successfully"}) } func (c *TwoFactorController) GetTwoFactorStatus(ctx *gin.Context) { - userID, exists := ctx.Get("user_id") - if !exists { - ctx.JSON(http.StatusBadRequest, gin.H{"error": "User ID not found in context"}) - return - } - - uid, ok := userID.(uint) - if !ok { - ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID type"}) - return - } - - user, err := c.userModel.FindByID(uid) - if err != nil { - ctx.JSON(http.StatusNotFound, gin.H{"error": "User not found"}) - return - } - - ctx.JSON(http.StatusOK, gin.H{ - "two_factor_enabled": user.TwoFactorEnabled, - }) -} + userID, exists := ctx.Get("user_id") + if !exists { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "User ID not found in context"}) + return + } + + uid, ok := userID.(uint) + if !ok { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID type"}) + return + } + + user, err := c.userModel.FindByID(uid) + if err != nil { + ctx.JSON(http.StatusNotFound, gin.H{"error": "User not found"}) + return + } + + ctx.JSON(http.StatusOK, gin.H{ + "two_factor_enabled": user.TwoFactorEnabled, + }) +} \ No newline at end of file diff --git a/backend/routes/routes.go b/backend/routes/routes.go index 5aa5a20..b0ac66f 100644 --- a/backend/routes/routes.go +++ b/backend/routes/routes.go @@ -98,7 +98,7 @@ func NewRouteHandlers( LoginController: controllers.NewLoginController(userModel, billingModel), SuperAdminLoginController: superAdminLoginController, CreateAccountController: controllers.NewCreateAccountController(userModel, passwordHistoryModel), - TwoFactorController: EndUser.NewTwoFactorController(userModel), + TwoFactorController: EndUser.NewTwoFactorController(userModel, twoFactorService), SuperAdminHandlers: &SuperAdminHandlers{ LoginController: superAdminLoginController, CreateSysAdminController: SuperAdmin.NewCreateSysAdminController(userModel), @@ -184,9 +184,10 @@ func setupProtectedRoutes(protected *gin.RouterGroup, handlers *RouteHandlers) { // 2FA routes twoFactor := protected.Group("/2fa") { - twoFactor.POST("/enable", handlers.TwoFactorController.EnableEmailTwoFactor) - twoFactor.POST("/disable", handlers.TwoFactorController.DisableEmailTwoFactor) twoFactor.GET("/status", handlers.TwoFactorController.GetTwoFactorStatus) + twoFactor.POST("/enable", handlers.TwoFactorController.EnableEmailTwoFactor) + twoFactor.POST("/disable/initiate", handlers.TwoFactorController.InitiateDisable2FA) + twoFactor.POST("/disable/verify", handlers.TwoFactorController.VerifyAndDisable2FA) } // End User routes should be first as they're most commonly accessed diff --git a/database-setup.sql b/database-setup.sql index bad5e8c..2bfbd7f 100644 --- a/database-setup.sql +++ b/database-setup.sql @@ -38,7 +38,7 @@ CREATE TABLE users ( CREATE TABLE server_master_keys ( id INT AUTO_INCREMENT PRIMARY KEY, key_id VARCHAR(64) NOT NULL UNIQUE, -- Unique identifier for the key - encrypted_key BINARY(64) NOT NULL, -- Encrypted server master key + encrypted_key BINARY(32) NOT NULL, -- Encrypted server master key key_nonce BINARY(16) NOT NULL, -- Nonce for key encryption is_active BOOLEAN DEFAULT TRUE, -- Whether this is the current active key created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, diff --git a/frontend/src/components/EndUser/TwoFactorAuthentication.js b/frontend/src/components/EndUser/TwoFactorAuthentication.js index 1b3a746..a9ee2ae 100644 --- a/frontend/src/components/EndUser/TwoFactorAuthentication.js +++ b/frontend/src/components/EndUser/TwoFactorAuthentication.js @@ -1,10 +1,14 @@ import React, { useState, useEffect } from 'react'; +import { Loader } from 'lucide-react'; const TwoFactorAuthentication = () => { const [isEnabled, setIsEnabled] = useState(false); - const [loading, setLoading] = useState(false); + const [loading, setLoading] = useState(true); const [error, setError] = useState(''); const [message, setMessage] = useState(''); + const [showVerification, setShowVerification] = useState(false); + const [verificationCode, setVerificationCode] = useState(''); + const [isVerifying, setIsVerifying] = useState(false); useEffect(() => { fetchTwoFactorStatus(); @@ -17,20 +21,53 @@ const TwoFactorAuthentication = () => { 'Authorization': `Bearer ${localStorage.getItem('token')}` } }); + + if (!response.ok) { + throw new Error('Failed to fetch 2FA status'); + } + const data = await response.json(); setIsEnabled(data.two_factor_enabled); } catch (err) { setError('Failed to fetch 2FA status'); + } finally { + setLoading(false); } }; - const handleToggle2FA = async () => { - setLoading(true); + const handleEnable2FA = async () => { setError(''); setMessage(''); + setLoading(true); + + try { + const response = await fetch('http://localhost:8080/api/2fa/enable', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${localStorage.getItem('token')}` + } + }); + + if (!response.ok) { + throw new Error(await response.text()); + } + + setIsEnabled(true); + setMessage('2FA has been enabled'); + } catch (err) { + setError(err.message || 'Failed to enable 2FA'); + } finally { + setLoading(false); + } + }; + const initiateDisable2FA = async () => { + setError(''); + setMessage(''); + setLoading(true); + try { - const response = await fetch(`http://localhost:8080/api/2fa/${isEnabled ? 'disable' : 'enable'}`, { + const response = await fetch('http://localhost:8080/api/2fa/disable/initiate', { method: 'POST', headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` @@ -41,15 +78,61 @@ const TwoFactorAuthentication = () => { throw new Error(await response.text()); } - setIsEnabled(!isEnabled); - setMessage(isEnabled ? '2FA has been disabled' : '2FA has been enabled'); + setShowVerification(true); + setMessage('Please check your email for the verification code'); } catch (err) { - setError(err.message || 'Failed to update 2FA settings'); + setError(err.message || 'Failed to initiate 2FA disable'); } finally { setLoading(false); } }; + const handleVerifyAndDisable = async () => { + setError(''); + setMessage(''); + setIsVerifying(true); + + try { + const response = await fetch('http://localhost:8080/api/2fa/disable/verify', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${localStorage.getItem('token')}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ code: verificationCode }) + }); + + if (!response.ok) { + throw new Error(await response.text()); + } + + setIsEnabled(false); + setShowVerification(false); + setVerificationCode(''); + setMessage('2FA has been disabled'); + } catch (err) { + setError(err.message || 'Invalid verification code'); + } finally { + setIsVerifying(false); + } + }; + + const handleCancel = () => { + setShowVerification(false); + setVerificationCode(''); + setError(''); + setMessage(''); + }; + + if (loading && !isEnabled && !error) { + return ( +
    + + Loading 2FA status... +
    + ); + } + return (

    Two-Factor Authentication

    @@ -73,21 +156,57 @@ const TwoFactorAuthentication = () => { : "Enable two-factor authentication to add an extra layer of security to your account."}

    - + {showVerification ? ( +
    +
    + + setVerificationCode(e.target.value)} + placeholder="Enter verification code" + className="w-full p-2 border rounded focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500" + maxLength={6} + /> +
    +
    + + +
    +
    + ) : ( + + )}
    ); From 3c9a66947e95df5edef0c6357139e6b0376fe08f Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Wed, 12 Feb 2025 17:25:30 +0800 Subject: [PATCH 17/34] Fix selected items not cleared, add auto close action after download, fix account details --- .../components/EndUser/ArchiveFileAction.js | 3 +- .../components/EndUser/DeleteFileAction.js | 3 +- .../components/EndUser/DownloadFileAction.js | 28 ++++++++++---- .../src/components/EndUser/FileActions.js | 37 +++++++++++++++++-- frontend/src/components/EndUser/Settings.js | 2 +- .../components/EndUser/UnarchiveFileAction.js | 4 +- frontend/src/components/EndUser/ViewFile.js | 1 + 7 files changed, 62 insertions(+), 16 deletions(-) diff --git a/frontend/src/components/EndUser/ArchiveFileAction.js b/frontend/src/components/EndUser/ArchiveFileAction.js index 7e25811..4cac405 100644 --- a/frontend/src/components/EndUser/ArchiveFileAction.js +++ b/frontend/src/components/EndUser/ArchiveFileAction.js @@ -1,7 +1,7 @@ import React, { useState } from 'react'; import { Archive, Loader } from 'lucide-react'; -const ArchiveFileAction = ({ file, selectedFiles = [], onRefresh }) => { +const ArchiveFileAction = ({ file, selectedFiles = [], onRefresh, onClearSelection }) => { const [isArchiving, setIsArchiving] = useState(false); const handleArchive = async () => { @@ -49,6 +49,7 @@ const ArchiveFileAction = ({ file, selectedFiles = [], onRefresh }) => { } } + onClearSelection?.(); onRefresh?.(); } catch (error) { console.error('Archive error:', error); diff --git a/frontend/src/components/EndUser/DeleteFileAction.js b/frontend/src/components/EndUser/DeleteFileAction.js index 0d40bde..b4c25dd 100644 --- a/frontend/src/components/EndUser/DeleteFileAction.js +++ b/frontend/src/components/EndUser/DeleteFileAction.js @@ -1,7 +1,7 @@ import React, { useState } from 'react'; import { Trash2, Loader } from 'lucide-react'; -const DeleteFileAction = ({ file, selectedFiles = [], onRefresh }) => { +const DeleteFileAction = ({ file, selectedFiles = [], onRefresh, onClearSelection }) => { const [isDeleting, setIsDeleting] = useState(false); const handleDelete = async () => { @@ -37,6 +37,7 @@ const DeleteFileAction = ({ file, selectedFiles = [], onRefresh }) => { if (!response.ok) throw new Error('Failed to delete files'); } + onClearSelection?.(); onRefresh?.(); } catch (error) { console.error('Delete error:', error); diff --git a/frontend/src/components/EndUser/DownloadFileAction.js b/frontend/src/components/EndUser/DownloadFileAction.js index 1e4993b..fa90691 100644 --- a/frontend/src/components/EndUser/DownloadFileAction.js +++ b/frontend/src/components/EndUser/DownloadFileAction.js @@ -1,13 +1,15 @@ -import React from 'react'; -import { Download } from 'lucide-react'; +import React, { useState } from 'react'; +import { Download, Loader } from 'lucide-react'; + +const DownloadFileAction = ({ file, selectedFiles = [], onClearSelection, onClose }) => { + const [isDownloading, setIsDownloading] = useState(false); -const DownloadFileAction = ({ file, selectedFiles = [] }) => { const handleDownload = async () => { + setIsDownloading(true); try { const token = localStorage.getItem('token'); const filesToDownload = selectedFiles.length > 0 ? selectedFiles : [file]; - // If multiple files selected, get download status first if (selectedFiles.length > 0) { const statusResponse = await fetch('http://localhost:8080/api/files/mass-download', { method: 'POST', @@ -27,7 +29,6 @@ const DownloadFileAction = ({ file, selectedFiles = [] }) => { result => result.status === 'success' ); - // Download each available file for (const fileStatus of availableFiles) { try { const response = await fetch(`http://localhost:8080/api/files/mass-download/${fileStatus.file_id}`, { @@ -51,8 +52,9 @@ const DownloadFileAction = ({ file, selectedFiles = [] }) => { console.error(`Error downloading file ${fileStatus.file_id}:`, error); } } + + onClearSelection?.(); } else { - // Single file download const response = await fetch(`http://localhost:8080/api/files/${file.id}/download`, { headers: { 'Authorization': `Bearer ${token}`, @@ -71,17 +73,27 @@ const DownloadFileAction = ({ file, selectedFiles = [] }) => { window.URL.revokeObjectURL(url); document.body.removeChild(a); } + + onClose?.(); } catch (error) { console.error('Download error:', error); + alert(error.message || 'Failed to download file(s)'); + } finally { + setIsDownloading(false); } }; return (
    )}
    diff --git a/frontend/src/components/EndUser/Settings.js b/frontend/src/components/EndUser/Settings.js index d8a8089..7aa29f4 100644 --- a/frontend/src/components/EndUser/Settings.js +++ b/frontend/src/components/EndUser/Settings.js @@ -96,7 +96,7 @@ const Settings = ({ user: initialUser, onUserUpdate }) => {

    Username: {currentUser.username}

    Email: {currentUser.email}

    -

    Subscription Plan: {currentUser.subscription_status || 'Free'}

    +

    Subscription Plan: {currentUser.subscription_status}

    {/* Only show billing details if billing profile exists */} {billingProfile && ( diff --git a/frontend/src/components/EndUser/UnarchiveFileAction.js b/frontend/src/components/EndUser/UnarchiveFileAction.js index df6a06f..f77a7b0 100644 --- a/frontend/src/components/EndUser/UnarchiveFileAction.js +++ b/frontend/src/components/EndUser/UnarchiveFileAction.js @@ -1,7 +1,7 @@ import React, { useState } from 'react'; import { Archive, Loader } from 'lucide-react'; -const UnarchiveFileAction = ({ file, selectedFiles = [], onRefresh }) => { +const UnarchiveFileAction = ({ file, selectedFiles = [], onRefresh, onClearSelection, onClose }) => { const [isUnarchiving, setIsUnarchiving] = useState(false); const handleUnarchive = async () => { @@ -49,6 +49,8 @@ const UnarchiveFileAction = ({ file, selectedFiles = [], onRefresh }) => { } } + onClearSelection?.(); + onClose?.(); onRefresh?.(); } catch (error) { console.error('Unarchive error:', error); diff --git a/frontend/src/components/EndUser/ViewFile.js b/frontend/src/components/EndUser/ViewFile.js index f6d7a04..2250dc9 100644 --- a/frontend/src/components/EndUser/ViewFile.js +++ b/frontend/src/components/EndUser/ViewFile.js @@ -338,6 +338,7 @@ const ViewFile = ({ selected={selectedFiles.has(file.id)} onSelect={() => handleFileSelection(file.id)} selectedFiles={getSelectedFiles()} + onClearSelection={() => setSelectedFiles(new Set())} />
    )} From e2b83ae084cfaed43f8e5672a16f3bfab476827f Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Wed, 12 Feb 2025 18:24:11 +0800 Subject: [PATCH 18/34] Delete empty table --- database-setup.sql | 24 +----------------------- 1 file changed, 1 insertion(+), 23 deletions(-) diff --git a/database-setup.sql b/database-setup.sql index 2bfbd7f..6850bf5 100644 --- a/database-setup.sql +++ b/database-setup.sql @@ -176,16 +176,6 @@ CREATE TABLE file_shares ( FOREIGN KEY (shared_by) REFERENCES users(id) ON DELETE CASCADE ); --- Share access logs table -CREATE TABLE share_access_logs ( - id INT AUTO_INCREMENT PRIMARY KEY, - share_id INT NOT NULL, -- Associated share - ip_address VARCHAR(45) NOT NULL, -- Access IP - access_timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - status ENUM('success', 'failed') NOT NULL, -- Access result - failure_reason VARCHAR(255), -- Failure reason - FOREIGN KEY (share_id) REFERENCES file_shares(id) ON DELETE CASCADE -); -- Activity logs table CREATE TABLE activity_logs ( @@ -216,19 +206,7 @@ CREATE TABLE subscription_plans ( updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ); --- User subscriptions table -CREATE TABLE user_subscriptions ( - id INT AUTO_INCREMENT PRIMARY KEY, - user_id INT NOT NULL, -- Subscribed user - plan_id INT NOT NULL, -- Selected plan - start_date TIMESTAMP NOT NULL, -- Subscription start - end_date TIMESTAMP NOT NULL, -- Subscription end - status ENUM('active', 'cancelled', 'expired') NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, - FOREIGN KEY (plan_id) REFERENCES subscription_plans(id) -); + -- Feedback table CREATE TABLE feedbacks ( From 4a07c9a773805cd9d9eea26f93578296ae7479c6 Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Wed, 12 Feb 2025 21:47:48 +0800 Subject: [PATCH 19/34] Implement 2FA verification for enable 2FA --- .../EndUser/TwoFactorAuthController.go | 118 ++++++++++++++---- backend/routes/routes.go | 3 +- .../EndUser/TwoFactorAuthentication.js | 54 ++++---- 3 files changed, 122 insertions(+), 53 deletions(-) diff --git a/backend/controllers/EndUser/TwoFactorAuthController.go b/backend/controllers/EndUser/TwoFactorAuthController.go index 9b4f7a0..28966be 100644 --- a/backend/controllers/EndUser/TwoFactorAuthController.go +++ b/backend/controllers/EndUser/TwoFactorAuthController.go @@ -11,18 +11,18 @@ import ( ) type TwoFactorController struct { - userModel *models.UserModel + userModel *models.UserModel twoFactorService *services.TwoFactorAuthService } func NewTwoFactorController(userModel *models.UserModel, twoFactorService *services.TwoFactorAuthService) *TwoFactorController { return &TwoFactorController{ - userModel: userModel, + userModel: userModel, twoFactorService: twoFactorService, } } -func (c *TwoFactorController) EnableEmailTwoFactor(ctx *gin.Context) { +func (c *TwoFactorController) GetTwoFactorStatus(ctx *gin.Context) { userID, exists := ctx.Get("user_id") if !exists { ctx.JSON(http.StatusBadRequest, gin.H{"error": "User ID not found in context"}) @@ -35,17 +35,18 @@ func (c *TwoFactorController) EnableEmailTwoFactor(ctx *gin.Context) { return } - log.Printf("Enabling 2FA for user ID: %d", uid) - - if err := c.userModel.EnableEmailTwoFactor(uid); err != nil { - ctx.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to enable 2FA: %v", err)}) + user, err := c.userModel.FindByID(uid) + if err != nil { + ctx.JSON(http.StatusNotFound, gin.H{"error": "User not found"}) return } - ctx.JSON(http.StatusOK, gin.H{"message": "2FA enabled successfully"}) + ctx.JSON(http.StatusOK, gin.H{ + "two_factor_enabled": user.TwoFactorEnabled, + }) } -func (c *TwoFactorController) InitiateDisable2FA(ctx *gin.Context) { +func (c *TwoFactorController) InitiateEnable2FA(ctx *gin.Context) { userID, exists := ctx.Get("user_id") if !exists { ctx.JSON(http.StatusBadRequest, gin.H{"error": "User ID not found in context"}) @@ -65,13 +66,13 @@ func (c *TwoFactorController) InitiateDisable2FA(ctx *gin.Context) { return } - // Verify 2FA is enabled - if !user.TwoFactorEnabled { - ctx.JSON(http.StatusBadRequest, gin.H{"error": "2FA is not enabled"}) + // Verify 2FA is not already enabled + if user.TwoFactorEnabled { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "2FA is already enabled"}) return } - log.Printf("Initiating 2FA disable for user ID: %d", uid) + log.Printf("Initiating 2FA enable for user ID: %d", uid) // Send verification code if err := c.twoFactorService.SendTwoFactorToken(uid, user.Email); err != nil { @@ -82,7 +83,7 @@ func (c *TwoFactorController) InitiateDisable2FA(ctx *gin.Context) { ctx.JSON(http.StatusOK, gin.H{"message": "Verification code sent to your email"}) } -func (c *TwoFactorController) VerifyAndDisable2FA(ctx *gin.Context) { +func (c *TwoFactorController) VerifyAndEnable2FA(ctx *gin.Context) { userID, exists := ctx.Get("user_id") if !exists { ctx.JSON(http.StatusBadRequest, gin.H{"error": "User ID not found in context"}) @@ -95,7 +96,6 @@ func (c *TwoFactorController) VerifyAndDisable2FA(ctx *gin.Context) { return } - // Get verification code from request var req struct { Code string `json:"code" binding:"required"` } @@ -105,15 +105,15 @@ func (c *TwoFactorController) VerifyAndDisable2FA(ctx *gin.Context) { return } - // Verify 2FA is still enabled + // Verify 2FA is not already enabled user, err := c.userModel.FindByID(uid) if err != nil { ctx.JSON(http.StatusNotFound, gin.H{"error": "User not found"}) return } - if !user.TwoFactorEnabled { - ctx.JSON(http.StatusBadRequest, gin.H{"error": "2FA is not enabled"}) + if user.TwoFactorEnabled { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "2FA is already enabled"}) return } @@ -123,18 +123,18 @@ func (c *TwoFactorController) VerifyAndDisable2FA(ctx *gin.Context) { return } - log.Printf("Disabling 2FA for user ID: %d after verification", uid) + log.Printf("Enabling 2FA for user ID: %d after verification", uid) - // Disable 2FA - if err := c.userModel.DisableEmailTwoFactor(uid); err != nil { - ctx.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to disable 2FA: %v", err)}) + // Enable 2FA + if err := c.userModel.EnableEmailTwoFactor(uid); err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to enable 2FA: %v", err)}) return } - ctx.JSON(http.StatusOK, gin.H{"message": "2FA disabled successfully"}) + ctx.JSON(http.StatusOK, gin.H{"message": "2FA enabled successfully"}) } -func (c *TwoFactorController) GetTwoFactorStatus(ctx *gin.Context) { +func (c *TwoFactorController) InitiateDisable2FA(ctx *gin.Context) { userID, exists := ctx.Get("user_id") if !exists { ctx.JSON(http.StatusBadRequest, gin.H{"error": "User ID not found in context"}) @@ -147,13 +147,77 @@ func (c *TwoFactorController) GetTwoFactorStatus(ctx *gin.Context) { return } + // Get user's email user, err := c.userModel.FindByID(uid) if err != nil { ctx.JSON(http.StatusNotFound, gin.H{"error": "User not found"}) return } - ctx.JSON(http.StatusOK, gin.H{ - "two_factor_enabled": user.TwoFactorEnabled, - }) + // Verify 2FA is enabled + if !user.TwoFactorEnabled { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "2FA is not enabled"}) + return + } + + log.Printf("Initiating 2FA disable for user ID: %d", uid) + + // Send verification code + if err := c.twoFactorService.SendTwoFactorToken(uid, user.Email); err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to send verification code: %v", err)}) + return + } + + ctx.JSON(http.StatusOK, gin.H{"message": "Verification code sent to your email"}) +} + +func (c *TwoFactorController) VerifyAndDisable2FA(ctx *gin.Context) { + userID, exists := ctx.Get("user_id") + if !exists { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "User ID not found in context"}) + return + } + + uid, ok := userID.(uint) + if !ok { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID type"}) + return + } + + var req struct { + Code string `json:"code" binding:"required"` + } + + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + + // Verify 2FA is still enabled + user, err := c.userModel.FindByID(uid) + if err != nil { + ctx.JSON(http.StatusNotFound, gin.H{"error": "User not found"}) + return + } + + if !user.TwoFactorEnabled { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "2FA is not enabled"}) + return + } + + // Verify the code + if err := c.twoFactorService.VerifyToken(uid, req.Code); err != nil { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or expired verification code"}) + return + } + + log.Printf("Disabling 2FA for user ID: %d after verification", uid) + + // Disable 2FA + if err := c.userModel.DisableEmailTwoFactor(uid); err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to disable 2FA: %v", err)}) + return + } + + ctx.JSON(http.StatusOK, gin.H{"message": "2FA disabled successfully"}) } \ No newline at end of file diff --git a/backend/routes/routes.go b/backend/routes/routes.go index b0ac66f..980a589 100644 --- a/backend/routes/routes.go +++ b/backend/routes/routes.go @@ -185,7 +185,8 @@ func setupProtectedRoutes(protected *gin.RouterGroup, handlers *RouteHandlers) { twoFactor := protected.Group("/2fa") { twoFactor.GET("/status", handlers.TwoFactorController.GetTwoFactorStatus) - twoFactor.POST("/enable", handlers.TwoFactorController.EnableEmailTwoFactor) + twoFactor.POST("/enable/initiate", handlers.TwoFactorController.InitiateEnable2FA) + twoFactor.POST("/enable/verify", handlers.TwoFactorController.VerifyAndEnable2FA) twoFactor.POST("/disable/initiate", handlers.TwoFactorController.InitiateDisable2FA) twoFactor.POST("/disable/verify", handlers.TwoFactorController.VerifyAndDisable2FA) } diff --git a/frontend/src/components/EndUser/TwoFactorAuthentication.js b/frontend/src/components/EndUser/TwoFactorAuthentication.js index a9ee2ae..2198f0b 100644 --- a/frontend/src/components/EndUser/TwoFactorAuthentication.js +++ b/frontend/src/components/EndUser/TwoFactorAuthentication.js @@ -3,12 +3,13 @@ import { Loader } from 'lucide-react'; const TwoFactorAuthentication = () => { const [isEnabled, setIsEnabled] = useState(false); - const [loading, setLoading] = useState(true); + const [loading, setLoading] = useState(false); const [error, setError] = useState(''); const [message, setMessage] = useState(''); const [showVerification, setShowVerification] = useState(false); const [verificationCode, setVerificationCode] = useState(''); const [isVerifying, setIsVerifying] = useState(false); + const [verificationMode, setVerificationMode] = useState(null); useEffect(() => { fetchTwoFactorStatus(); @@ -30,18 +31,16 @@ const TwoFactorAuthentication = () => { setIsEnabled(data.two_factor_enabled); } catch (err) { setError('Failed to fetch 2FA status'); - } finally { - setLoading(false); } }; - const handleEnable2FA = async () => { + const initiateEnable2FA = async () => { setError(''); setMessage(''); setLoading(true); try { - const response = await fetch('http://localhost:8080/api/2fa/enable', { + const response = await fetch('http://localhost:8080/api/2fa/enable/initiate', { method: 'POST', headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` @@ -52,10 +51,11 @@ const TwoFactorAuthentication = () => { throw new Error(await response.text()); } - setIsEnabled(true); - setMessage('2FA has been enabled'); + setVerificationMode('enable'); + setShowVerification(true); + setMessage('Please check your email for the verification code'); } catch (err) { - setError(err.message || 'Failed to enable 2FA'); + setError(err.message || 'Failed to initiate 2FA enable'); } finally { setLoading(false); } @@ -78,6 +78,7 @@ const TwoFactorAuthentication = () => { throw new Error(await response.text()); } + setVerificationMode('disable'); setShowVerification(true); setMessage('Please check your email for the verification code'); } catch (err) { @@ -87,13 +88,17 @@ const TwoFactorAuthentication = () => { } }; - const handleVerifyAndDisable = async () => { + const handleVerify = async () => { setError(''); setMessage(''); setIsVerifying(true); + const endpoint = verificationMode === 'enable' + ? 'http://localhost:8080/api/2fa/enable/verify' + : 'http://localhost:8080/api/2fa/disable/verify'; + try { - const response = await fetch('http://localhost:8080/api/2fa/disable/verify', { + const response = await fetch(endpoint, { method: 'POST', headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}`, @@ -106,10 +111,11 @@ const TwoFactorAuthentication = () => { throw new Error(await response.text()); } - setIsEnabled(false); + setIsEnabled(verificationMode === 'enable'); setShowVerification(false); setVerificationCode(''); - setMessage('2FA has been disabled'); + setVerificationMode(null); + setMessage(`2FA has been ${verificationMode === 'enable' ? 'enabled' : 'disabled'}`); } catch (err) { setError(err.message || 'Invalid verification code'); } finally { @@ -120,19 +126,11 @@ const TwoFactorAuthentication = () => { const handleCancel = () => { setShowVerification(false); setVerificationCode(''); + setVerificationMode(null); setError(''); setMessage(''); }; - if (loading && !isEnabled && !error) { - return ( -
    - - Loading 2FA status... -
    - ); - } - return (

    Two-Factor Authentication

    @@ -174,12 +172,18 @@ const TwoFactorAuthentication = () => {
    ) : (
    @@ -88,9 +151,11 @@ function LoginForm({ onLogin }) {
    diff --git a/frontend/src/services/authService.js b/frontend/src/services/authService.js index 234fa6c..9d056a0 100644 --- a/frontend/src/services/authService.js +++ b/frontend/src/services/authService.js @@ -57,8 +57,31 @@ export const login = async (email, password, twoFactorCode = '') => { }; } + if (response.status === 429) { + throw { + response: { + status: 429, + data: { + error: data.error, + status: 'locked', + locked_until: data.locked_until, + remaining_minutes: data.remaining_minutes + } + } + }; + } + if (!response.ok) { - throw new Error(data.error || 'Login failed'); + throw { + response: { + status: response.status, + data: { + error: data.error, + status: data.status, + remaining_attempts: data.remaining_attempts + } + } + }; } localStorage.setItem('token', data.token); @@ -70,7 +93,11 @@ export const login = async (email, password, twoFactorCode = '') => { return data.data; } catch (error) { console.error('Login error:', error); - throw error; + throw error.response ? error : { + response: { + data: { error: error.message || 'Login failed' } + } + }; } }; From a5c450b5f264d36ef5703ea46e0c189bbb6d35db Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Thu, 13 Feb 2025 22:23:17 +0800 Subject: [PATCH 22/34] Fix login frontend --- database-setup.sql | 12 -------- frontend/src/components/auth/LoginForm.js | 20 ++++++------- frontend/src/services/authService.js | 35 +++++++---------------- 3 files changed, 20 insertions(+), 47 deletions(-) diff --git a/database-setup.sql b/database-setup.sql index 6850bf5..97e4423 100644 --- a/database-setup.sql +++ b/database-setup.sql @@ -48,18 +48,6 @@ CREATE TABLE server_master_keys ( INDEX idx_key_id (key_id) ); --- Key rotation history table --- Purpose: Tracks key rotation events -CREATE TABLE key_rotation_histories ( - id INT AUTO_INCREMENT PRIMARY KEY, - user_id INT NOT NULL, - old_key_version INT NOT NULL, - new_key_version INT NOT NULL, - rotation_type ENUM('automatic', 'manual', 'forced') NOT NULL, - rotated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, - INDEX idx_user_rotation (user_id, rotated_at) -); -- Password history table CREATE TABLE password_history ( diff --git a/frontend/src/components/auth/LoginForm.js b/frontend/src/components/auth/LoginForm.js index 04f59f0..b0e3ad7 100644 --- a/frontend/src/components/auth/LoginForm.js +++ b/frontend/src/components/auth/LoginForm.js @@ -41,16 +41,16 @@ function LoginForm({ onLogin }) { setRemainingAttempts(null); try { - const response = await login(formData.email, formData.password, formData.twoFactorCode); - - if (response.requires_2fa) { - setRequires2FA(true); - setError('Please check your email for the 2FA code'); - } else { - onLogin(response.data.user); - const dashboardRoute = getDashboardByRole(response.data.user.role); - navigate(dashboardRoute); - } + const response = await login(formData.email, formData.password, formData.twoFactorCode); + + if (response.requires_2fa) { + setRequires2FA(true); + setError('Please check your email for the 2FA code'); + } else { + onLogin(response.user); + const dashboardRoute = getDashboardByRole(response.user.role); + navigate(dashboardRoute); + } } catch (err) { const responseData = err.response?.data; console.log('Full error response:', { diff --git a/frontend/src/services/authService.js b/frontend/src/services/authService.js index 9d056a0..2487149 100644 --- a/frontend/src/services/authService.js +++ b/frontend/src/services/authService.js @@ -56,30 +56,12 @@ export const login = async (email, password, twoFactorCode = '') => { user_id: data.user_id }; } - - if (response.status === 429) { - throw { - response: { - status: 429, - data: { - error: data.error, - status: 'locked', - locked_until: data.locked_until, - remaining_minutes: data.remaining_minutes - } - } - }; - } if (!response.ok) { throw { response: { status: response.status, - data: { - error: data.error, - status: data.status, - remaining_attempts: data.remaining_attempts - } + data: data } }; } @@ -89,14 +71,17 @@ export const login = async (email, password, twoFactorCode = '') => { if (data.data.billing_profile) { localStorage.setItem('billing', JSON.stringify(data.data.billing_profile)); } - - return data.data; + + return { + user: data.data.user, + billing_profile: data.data.billing_profile + }; } catch (error) { console.error('Login error:', error); - throw error.response ? error : { - response: { - data: { error: error.message || 'Login failed' } - } + throw error.response ? error : { + response: { + data: { error: error.message || 'Login failed' } + } }; } }; From b48aa557ec8175580d372fb1924aff3442d33193 Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Thu, 13 Feb 2025 22:50:34 +0800 Subject: [PATCH 23/34] Fix issue with checkbox --- .../components/SysAdmin/UpdateUserAction.js | 21 ++++++++++---- .../SysAdmin/UpdateUserPermissions.js | 29 ++++++++++--------- 2 files changed, 31 insertions(+), 19 deletions(-) diff --git a/frontend/src/components/SysAdmin/UpdateUserAction.js b/frontend/src/components/SysAdmin/UpdateUserAction.js index 7ba7de6..a5dde90 100644 --- a/frontend/src/components/SysAdmin/UpdateUserAction.js +++ b/frontend/src/components/SysAdmin/UpdateUserAction.js @@ -44,7 +44,18 @@ const UpdateUserAction = ({ isOpen, onClose, userId, currentUser, onUpdateSucces e.preventDefault(); setLoading(true); setError(null); - + + const updatedUser = { + ...currentUser, + username: formData.username, + email: formData.email, + subscription_status: formData.account_type, + read_access: formData.read_access, + write_access: formData.write_access + }; + + onUpdateSuccess(updatedUser); + try { const response = await fetch(`http://localhost:8080/api/system/users/${userId}`, { method: 'PUT', @@ -60,14 +71,14 @@ const UpdateUserAction = ({ isOpen, onClose, userId, currentUser, onUpdateSucces write_access: formData.write_access }), }); - + const data = await response.json(); - + if (!response.ok) { + onUpdateSuccess(currentUser); throw new Error(data.error || 'Failed to update user account'); } - - onUpdateSuccess(data.user); + onClose(); } catch (err) { console.error('Error updating user:', err); diff --git a/frontend/src/components/SysAdmin/UpdateUserPermissions.js b/frontend/src/components/SysAdmin/UpdateUserPermissions.js index 9de0b61..3870469 100644 --- a/frontend/src/components/SysAdmin/UpdateUserPermissions.js +++ b/frontend/src/components/SysAdmin/UpdateUserPermissions.js @@ -23,7 +23,6 @@ const UpdateUserPermissions = () => { } const data = await response.json(); - // Ensure we're accessing the correct data structure const usersList = Array.isArray(data.data) ? data.data : Array.isArray(data) ? data : []; setUsers(usersList); @@ -43,6 +42,14 @@ const UpdateUserPermissions = () => { setSaving(userId); setError(null); + setUsers(prevUsers => + prevUsers.map(user => + user.id === userId + ? { ...user, read_access: updates.readAccess, write_access: updates.writeAccess } + : user + ) + ); + try { const currentUser = users.find(u => u.id === userId); if (!currentUser) { @@ -65,23 +72,17 @@ const UpdateUserPermissions = () => { }); if (!response.ok) { + setUsers(prevUsers => + prevUsers.map(user => + user.id === userId + ? { ...user, read_access: currentUser.read_access, write_access: currentUser.write_access } + : user + ) + ); const errorData = await response.json(); throw new Error(errorData.error || 'Failed to update permissions'); } - const data = await response.json(); - - // Handle different response data structures - const updatedUser = data.data?.user || data.user || data; - - setUsers(prevUsers => - prevUsers.map(user => - user.id === userId - ? { ...user, ...updatedUser } - : user - ) - ); - setSuccessMessage('Permissions updated successfully'); setTimeout(() => setSuccessMessage(''), 3000); } catch (err) { From adb8104fc4b6830b37a8b0a57b91e89222e1d084 Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Fri, 14 Feb 2025 10:31:07 +0800 Subject: [PATCH 24/34] Implement edit billing details --- .../PremiumUser/UpdateBillingController.go | 111 +++++++++ backend/models/billing.go | 28 +++ backend/routes/routes.go | 17 +- frontend/src/components/EndUser/Settings.js | 233 ++++++++++++++++-- 4 files changed, 365 insertions(+), 24 deletions(-) create mode 100644 backend/controllers/PremiumUser/UpdateBillingController.go diff --git a/backend/controllers/PremiumUser/UpdateBillingController.go b/backend/controllers/PremiumUser/UpdateBillingController.go new file mode 100644 index 0000000..ad51ddd --- /dev/null +++ b/backend/controllers/PremiumUser/UpdateBillingController.go @@ -0,0 +1,111 @@ +package PremiumUser + +import ( + "net/http" + "safesplit/models" + "github.com/gin-gonic/gin" +) + +type UpdateBillingController struct { + billingModel *models.BillingModel +} + +func NewUpdateBillingController(billingModel *models.BillingModel) *UpdateBillingController { + return &UpdateBillingController{ + billingModel: billingModel, + } +} + +type UpdateBillingRequest struct { + BillingName string `json:"billing_name" binding:"required"` + BillingEmail string `json:"billing_email" binding:"required,email"` + BillingAddress string `json:"billing_address" binding:"required"` + CountryCode string `json:"country_code" binding:"required,len=2"` + DefaultPaymentMethod string `json:"default_payment_method" binding:"required,oneof=credit_card bank_account paypal"` + BillingCycle string `json:"billing_cycle" binding:"required,oneof=monthly yearly"` + Currency string `json:"currency" binding:"required,len=3"` +} + +// UpdateBillingDetails updates the billing profile for a premium user +func (c *UpdateBillingController) UpdateBillingDetails(ctx *gin.Context) { + user, exists := ctx.Get("user") + if !exists { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required"}) + return + } + + premiumUser, ok := user.(*models.User) + if !ok { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "invalid user data"}) + return + } + + var req UpdateBillingRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid request data"}) + return + } + + // Get existing billing profile or create new one + profile, err := c.billingModel.GetUserBillingProfile(premiumUser.ID) + if err != nil { + // Create new profile if doesn't exist + profile = &models.BillingProfile{ + UserID: premiumUser.ID, + BillingStatus: models.BillingStatusActive, + } + } + + // Update profile with new details + profile.BillingName = req.BillingName + profile.BillingEmail = req.BillingEmail + profile.BillingAddress = req.BillingAddress + profile.CountryCode = req.CountryCode + profile.DefaultPaymentMethod = req.DefaultPaymentMethod + profile.BillingCycle = req.BillingCycle + profile.Currency = req.Currency + + var updateErr error + if profile.ID == 0 { + updateErr = c.billingModel.CreateBillingProfile(profile) + } else { + updateErr = c.billingModel.UpdateBillingProfile(profile) + } + + if updateErr != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{ + "error": "Failed to update billing details", + }) + return + } + + ctx.JSON(http.StatusOK, gin.H{ + "message": "Billing details updated successfully", + "data": profile, + }) +} + +// GetBillingDetails retrieves the current billing profile +func (c *UpdateBillingController) GetBillingDetails(ctx *gin.Context) { + user, exists := ctx.Get("user") + if !exists { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required"}) + return + } + + premiumUser, ok := user.(*models.User) + if !ok { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "invalid user data"}) + return + } + + profile, err := c.billingModel.GetUserBillingProfile(premiumUser.ID) + if err != nil { + ctx.JSON(http.StatusNotFound, gin.H{"error": "billing profile not found"}) + return + } + + ctx.JSON(http.StatusOK, gin.H{ + "data": profile, + }) +} \ No newline at end of file diff --git a/backend/models/billing.go b/backend/models/billing.go index e7ccb5b..4b10eaf 100644 --- a/backend/models/billing.go +++ b/backend/models/billing.go @@ -148,6 +148,34 @@ func (m *BillingModel) GetUserWithBilling(userID uint) (*UserBillingInfo, error) return &info, nil } +func (m *BillingModel) GetAllBillingRecords(status, cycle string, page, pageSize int) ([]BillingProfile, int64, error) { + var profiles []BillingProfile + var totalCount int64 + + query := m.db.Model(&BillingProfile{}) + + if status != "" { + query = query.Where("billing_status = ?", status) + } + if cycle != "" { + query = query.Where("billing_cycle = ?", cycle) + } + + if err := query.Count(&totalCount).Error; err != nil { + return nil, 0, err + } + + offset := (page - 1) * pageSize + err := query.Offset(offset).Limit(pageSize). + Preload("User"). + Find(&profiles).Error + + if err != nil { + return nil, 0, err + } + + return profiles, totalCount, nil +} func (m *BillingModel) UpdateSubscriptionStatus(userID uint, status string) error { return m.db.Transaction(func(tx *gorm.DB) error { diff --git a/backend/routes/routes.go b/backend/routes/routes.go index 799f0cc..22a5f44 100644 --- a/backend/routes/routes.go +++ b/backend/routes/routes.go @@ -51,6 +51,7 @@ type EndUserHandlers struct { type PremiumUserHandlers struct { FileRecoveryController *PremiumUser.FileRecoveryController AdvancedShareFileController *PremiumUser.ShareFileController + UpdateBillingController *PremiumUser.UpdateBillingController } type SuperAdminHandlers struct { @@ -70,6 +71,7 @@ type SysAdminHandlers struct { ViewUserAccountDetailsController *SysAdmin.ViewUserAccountDetailsController ViewFeedbacksController *SysAdmin.ViewFeedbacksController ViewReportsController *SysAdmin.ViewReportsController + ViewBillingRecordsController *SysAdmin.ViewBillingRecordsController } func NewRouteHandlers( @@ -113,6 +115,7 @@ func NewRouteHandlers( ViewUserAccountDetailsController: SysAdmin.NewViewUserAccountDetailsController(userModel, billingModel), ViewFeedbacksController: SysAdmin.NewViewFeedbacksController(feedbackModel), ViewReportsController: SysAdmin.NewViewReportsController(feedbackModel, userModel), + ViewBillingRecordsController: SysAdmin.NewViewBillingRecordsController(billingModel), }, EndUserHandlers: &EndUserHandlers{ UploadFileController: EndUser.NewFileController(fileModel, userModel, activityLogModel, encryptionService, shamirService, keyFragmentModel, compressionService, folderModel, rsService, serverMasterKeyModel), @@ -140,6 +143,7 @@ func NewRouteHandlers( PremiumUserHandlers: &PremiumUserHandlers{ FileRecoveryController: PremiumUser.NewFileRecoveryController(fileModel), AdvancedShareFileController: PremiumUser.NewShareFileController(fileModel, fileShareModel, keyFragmentModel, encryptionService, activityLogModel, rsService, userModel, serverMasterKeyModel, twoFactorService, emailService, compressionService), + UpdateBillingController: PremiumUser.NewUpdateBillingController(billingModel), }, } } @@ -259,7 +263,7 @@ func setupEndUserRoutes(protected *gin.RouterGroup, handlers *EndUserHandlers) { } func setupPremiumUserRoutes(premium *gin.RouterGroup, handlers *PremiumUserHandlers) { - + recovery := premium.Group("/recovery") { recovery.GET("/files", handlers.FileRecoveryController.ListRecoverableFiles) @@ -269,6 +273,11 @@ func setupPremiumUserRoutes(premium *gin.RouterGroup, handlers *PremiumUserHandl { shares.POST("/files/:id", handlers.AdvancedShareFileController.CreateShare) } + billing := premium.Group("/billing") + { + billing.GET("/details", handlers.UpdateBillingController.GetBillingDetails) + billing.PUT("/details", handlers.UpdateBillingController.UpdateBillingDetails) + } } func setupSuperAdminRoutes(superAdmin *gin.RouterGroup, handlers *SuperAdminHandlers) { @@ -308,4 +317,10 @@ func setupSysAdminRoutes(sysAdmin *gin.RouterGroup, handlers *SysAdminHandlers) reports.PUT("/:id/status", handlers.ViewReportsController.UpdateReportStatus) reports.GET("/stats", handlers.ViewReportsController.GetReportStats) } + billing := sysAdmin.Group("/billing") + { + billing.GET("/records", handlers.ViewBillingRecordsController.GetAllBillingRecords) + billing.GET("/stats", handlers.ViewBillingRecordsController.GetBillingStats) + billing.GET("/expiring", handlers.ViewBillingRecordsController.GetExpiringSubscriptions) + } } diff --git a/frontend/src/components/EndUser/Settings.js b/frontend/src/components/EndUser/Settings.js index 7aa29f4..dc34c79 100644 --- a/frontend/src/components/EndUser/Settings.js +++ b/frontend/src/components/EndUser/Settings.js @@ -12,11 +12,35 @@ const Settings = ({ user: initialUser, onUserUpdate }) => { const [error, setError] = useState(''); const [showCancelModal, setShowCancelModal] = useState(false); const [cancelInfo, setCancelInfo] = useState(null); + const [isEditing, setIsEditing] = useState(false); + const [formData, setFormData] = useState({ + billing_name: '', + billing_email: '', + billing_address: '', + country_code: '', + default_payment_method: 'credit_card', + billing_cycle: 'monthly', + currency: 'USD' + }); useEffect(() => { fetchUserData(); }, []); + useEffect(() => { + if (billingProfile) { + setFormData({ + billing_name: billingProfile.billing_name || '', + billing_email: billingProfile.billing_email || '', + billing_address: billingProfile.billing_address || '', + country_code: billingProfile.country_code || '', + default_payment_method: billingProfile.default_payment_method || 'credit_card', + billing_cycle: billingProfile.billing_cycle || 'monthly', + currency: billingProfile.currency || 'USD' + }); + } + }, [billingProfile]); + const fetchUserData = async () => { try { const token = localStorage.getItem('token'); @@ -68,6 +92,38 @@ const Settings = ({ user: initialUser, onUserUpdate }) => { } }; + const handleUpdateBilling = async (e) => { + e.preventDefault(); + setLoading(true); + setError(''); + + try { + const token = localStorage.getItem('token'); + const response = await fetch('http://localhost:8080/api/premium/billing/details', { + method: 'PUT', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(formData) + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || 'Failed to update billing details'); + } + + setBillingProfile(data.data); + setIsEditing(false); + await fetchUserData(); + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + } + }; + const CancelModal = () => (
    @@ -98,7 +154,6 @@ const Settings = ({ user: initialUser, onUserUpdate }) => {

    Email: {currentUser.email}

    Subscription Plan: {currentUser.subscription_status}

    - {/* Only show billing details if billing profile exists */} {billingProfile && ( <>

    Billing Cycle: {billingProfile.billing_cycle}

    @@ -112,7 +167,6 @@ const Settings = ({ user: initialUser, onUserUpdate }) => { )} - {/* Show cancel button only if user has an active premium subscription */} {currentUser.subscription_status === 'premium' && billingProfile?.billing_status === 'active' && (
    @@ -126,7 +180,6 @@ const Settings = ({ user: initialUser, onUserUpdate }) => {
    )} - {/* Show cancelled subscription message if applicable */} {billingProfile?.billing_status === 'cancelled' && (

    @@ -139,6 +192,130 @@ const Settings = ({ user: initialUser, onUserUpdate }) => { ); }; + const renderBillingDetails = () => { + if (!isEditing) { + return ( +

    +
    +

    Billing Details

    + +
    + + {billingProfile ? ( +
    +

    Billing Name: {billingProfile.billing_name}

    +

    Billing Email: {billingProfile.billing_email}

    +

    Billing Address: {billingProfile.billing_address}

    +

    Country: {billingProfile.country_code}

    +

    Payment Method: {billingProfile.default_payment_method}

    +

    Billing Cycle: {billingProfile.billing_cycle}

    +

    Currency: {billingProfile.currency}

    +
    + ) : ( +

    No billing profile found. Click Edit to set up billing details.

    + )} +
    + ); + } + + return ( +
    +

    Edit Billing Details

    + +
    + + setFormData(prev => ({ ...prev, billing_name: e.target.value }))} + className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500" + required + /> +
    + +
    + + setFormData(prev => ({ ...prev, billing_email: e.target.value }))} + className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500" + required + /> +
    + +
    + +