diff --git a/.gitignore b/.gitignore index 64ba219..bb5b310 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,33 @@ -vault-error.log \ No newline at end of file +vault-error.log + +# Delphi build artifacts +*.dcu +*.dcp +*.dpu +*.local +*.identcache +*.dsk +*.~dsk +*.~* +*.~bpl +*.~dll +*.~exe +*.exe +*.dll +*.bpl +*.so +*.dylib +__history/ +__recovery/ +Win32/ +Win64/ +OSX64/ +Android/ +iOSDevice64/ + +# Debug logs (created at runtime next to the exe) +pm-debug.log + +# Asset build log (regenerated by BuildAssets.cmd/ps1) +delphi-backend/assets/build.log diff --git a/delphi-backend/Handlers/PM.Handler.Auth.pas b/delphi-backend/Handlers/PM.Handler.Auth.pas new file mode 100644 index 0000000..fe8b087 --- /dev/null +++ b/delphi-backend/Handlers/PM.Handler.Auth.pas @@ -0,0 +1,343 @@ +unit PM.Handler.Auth; + +(* + /register POST body {username, masterPassword} -> {message,token,userId,salt,csrfToken} + /login POST body {username, masterPassword} -> {message,token,userId,salt,csrfToken} + /logout POST auth + csrf -> {message} + /reauth POST auth + csrf + body{masterPassword} -> {message} + + Hashing strategy: + - Delphi creates new accounts with PBKDF2-SHA256 100k iterations (hash_algo='pbkdf2'), + same format as PHP hash_pbkdf2. PHP can verify these too. + - For login, we read hash_algo: + pbkdf2 -> verify natively + bcrypt -> reject with clear message (bcrypt verify not implemented yet) +*) + +interface + +implementation + +uses + System.SysUtils, System.JSON, System.Classes, + FireDAC.Comp.Client, + IdCustomHTTPServer, + PM.Router, PM.JSON, PM.Database, PM.Crypto, + PM.Session, PM.RateLimit, PM.Audit; + +const + PBKDF2_ITERATIONS = 100000; + DEFAULT_FOLDERS: array[0..4] of string = ('All', 'Social', 'Banking', 'Work', 'Personal'); + +procedure EnsureDefaultFolders(AUserId: Integer); +var + LQ: TFDQuery; + I: Integer; +begin + DB.Lock; + try + LQ := TFDQuery.Create(nil); + try + LQ.Connection := DB.Connection; + LQ.SQL.Text := + 'INSERT OR IGNORE INTO folders (user_id, name) VALUES (:uid, :name)'; + for I := Low(DEFAULT_FOLDERS) to High(DEFAULT_FOLDERS) do + begin + LQ.ParamByName('uid').AsInteger := AUserId; + LQ.ParamByName('name').AsString := DEFAULT_FOLDERS[I]; + LQ.ExecSQL; + end; + finally + LQ.Free; + end; + finally + DB.Unlock; + end; +end; + +procedure SendAuthSuccess(AResponse: TIdHTTPResponseInfo; + AUserId: Integer; const AToken, ASalt, ACSRFToken: string); +var + LObj: TJSONObject; +begin + LObj := TJSONObject.Create; + LObj.AddPair('message', 'OK'); + LObj.AddPair('token', AToken); + LObj.AddPair('userId', TJSONNumber.Create(AUserId)); + LObj.AddPair('salt', ASalt); + LObj.AddPair('csrfToken', ACSRFToken); + TJSONHelper.SendJSON(AResponse, LObj); +end; + +// ===== /register ============================================================= + +procedure HandleRegister(ARequest: TIdHTTPRequestInfo; + AResponse: TIdHTTPResponseInfo; const AParams: TArray); +var + LBody: TJSONObject; + LUser, LPwd, LSalt, LHash, LToken, LCSRF, LIP: string; + LQ: TFDQuery; + LUserId: Integer; +begin + LIP := GetClientIP(ARequest); + if CheckRateLimit(LIP) >= 5 then + begin + TJSONHelper.SendError(AResponse, 429, 'Too many attempts. Try again later.'); + Exit; + end; + + LBody := TJSONHelper.ReadBody(ARequest); + try + LUser := Trim(LBody.GetValue('username', '')); + LPwd := LBody.GetValue('masterPassword', ''); + finally + LBody.Free; + end; + + if (Length(LUser) < 3) or (Length(LPwd) < 8) then + begin + TJSONHelper.SendError(AResponse, 400, 'Min 3/8 chars'); + Exit; + end; + + DB.Lock; + try + LQ := TFDQuery.Create(nil); + try + LQ.Connection := DB.Connection; + LQ.SQL.Text := 'SELECT id FROM users WHERE username = :u'; + LQ.ParamByName('u').AsString := LUser; + LQ.Open; + if not LQ.IsEmpty then + begin + TJSONHelper.SendError(AResponse, 409, 'Username exists'); + Exit; + end; + finally + LQ.Free; + end; + + LSalt := RandomHex(32); + LHash := PBKDF2_SHA256_Hex(LPwd, LSalt, PBKDF2_ITERATIONS); + + LQ := TFDQuery.Create(nil); + try + LQ.Connection := DB.Connection; + LQ.SQL.Text := + 'INSERT INTO users (username, password_hash, salt, hash_algo) ' + + 'VALUES (:u, :h, :s, ''pbkdf2'')'; + LQ.ParamByName('u').AsString := LUser; + LQ.ParamByName('h').AsString := LHash; + LQ.ParamByName('s').AsString := LSalt; + LQ.ExecSQL; + LUserId := DB.Connection.GetLastAutoGenValue('users'); + finally + LQ.Free; + end; + finally + DB.Unlock; + end; + + EnsureDefaultFolders(LUserId); + CreateSession(LUserId, LToken, LCSRF); + LogAudit(LUserId, 'register', LIP); + SendAuthSuccess(AResponse, LUserId, LToken, LSalt, LCSRF); +end; + +// ===== /login ================================================================ + +procedure HandleLogin(ARequest: TIdHTTPRequestInfo; + AResponse: TIdHTTPResponseInfo; const AParams: TArray); +var + LBody: TJSONObject; + LUser, LPwd, LSalt, LStoredHash, LAlgo, LToken, LCSRF, LIP: string; + LUserId: Integer; + LQ: TFDQuery; + LComputed: string; + LValid: Boolean; +begin + LIP := GetClientIP(ARequest); + if CheckRateLimit(LIP) >= 10 then + begin + TJSONHelper.SendError(AResponse, 429, 'Too many attempts. Try again later.'); + Exit; + end; + + LBody := TJSONHelper.ReadBody(ARequest); + try + LUser := Trim(LBody.GetValue('username', '')); + LPwd := LBody.GetValue('masterPassword', ''); + finally + LBody.Free; + end; + + DB.Lock; + try + LQ := TFDQuery.Create(nil); + try + LQ.Connection := DB.Connection; + LQ.SQL.Text := + 'SELECT id, password_hash, salt, hash_algo FROM users WHERE username = :u'; + LQ.ParamByName('u').AsString := LUser; + LQ.Open; + if LQ.IsEmpty then + begin + RecordAttempt(LIP); + TJSONHelper.SendError(AResponse, 401, 'Invalid credentials'); + Exit; + end; + LUserId := LQ.FieldByName('id').AsInteger; + LStoredHash := LQ.FieldByName('password_hash').AsString; + LSalt := LQ.FieldByName('salt').AsString; + LAlgo := LQ.FieldByName('hash_algo').AsString; + if LAlgo = '' then LAlgo := 'pbkdf2'; + finally + LQ.Free; + end; + finally + DB.Unlock; + end; + + LValid := False; + if SameText(LAlgo, 'pbkdf2') then + begin + LComputed := PBKDF2_SHA256_Hex(LPwd, LSalt, PBKDF2_ITERATIONS); + LValid := ConstantTimeEquals(LComputed, LStoredHash); + end + else if SameText(LAlgo, 'bcrypt') then + begin + // Not implemented in Delphi backend yet + RecordAttempt(LIP); + LogAudit(LUserId, 'failed_login_bcrypt', LIP); + TJSONHelper.SendError(AResponse, 501, + 'This account was created with bcrypt (PHP). The Delphi backend does ' + + 'not verify bcrypt yet. Register a new account here, or login via PHP.'); + Exit; + end; + + if not LValid then + begin + RecordAttempt(LIP); + LogAudit(LUserId, 'failed_login', LIP); + TJSONHelper.SendError(AResponse, 401, 'Invalid credentials'); + Exit; + end; + + ClearAttempts(LIP); + DeleteAllUserSessions(LUserId); + EnsureDefaultFolders(LUserId); + CreateSession(LUserId, LToken, LCSRF); + LogAudit(LUserId, 'login', LIP); + SendAuthSuccess(AResponse, LUserId, LToken, LSalt, LCSRF); +end; + +// ===== /logout =============================================================== + +procedure HandleLogout(ARequest: TIdHTTPRequestInfo; + AResponse: TIdHTTPResponseInfo; const AParams: TArray); +var + LUserId: Integer; + LToken, LAuth: string; +begin + try + LUserId := Authenticate(ARequest, AResponse); + RequireCSRF(ARequest, AResponse, LUserId); + except + on ESessionRejected do Exit; + end; + + LAuth := ARequest.RawHeaders.Values['Authorization']; + if LAuth.StartsWith('Bearer ', True) then + begin + LToken := Copy(LAuth, 8, MaxInt); + DeleteSessionByTokenHash(SHA256Hex(LToken)); + end; + + LogAudit(LUserId, 'logout', GetClientIP(ARequest)); + TJSONHelper.SendOK(AResponse, 'Logged out'); +end; + +// ===== /reauth =============================================================== + +procedure HandleReauth(ARequest: TIdHTTPRequestInfo; + AResponse: TIdHTTPResponseInfo; const AParams: TArray); +var + LUserId: Integer; + LBody: TJSONObject; + LPwd, LStoredHash, LSalt, LAlgo, LIP, LComputed: string; + LQ: TFDQuery; + LValid: Boolean; +begin + try + LUserId := Authenticate(ARequest, AResponse); + RequireCSRF(ARequest, AResponse, LUserId); + except + on ESessionRejected do Exit; + end; + + LIP := GetClientIP(ARequest); + if CheckRateLimit(LIP) >= 5 then + begin + TJSONHelper.SendError(AResponse, 429, 'Too many attempts. Try again later.'); + Exit; + end; + + LBody := TJSONHelper.ReadBody(ARequest); + try + LPwd := LBody.GetValue('masterPassword', ''); + finally + LBody.Free; + end; + + DB.Lock; + try + LQ := TFDQuery.Create(nil); + try + LQ.Connection := DB.Connection; + LQ.SQL.Text := 'SELECT password_hash, salt, hash_algo FROM users WHERE id = :uid'; + LQ.ParamByName('uid').AsInteger := LUserId; + LQ.Open; + if LQ.IsEmpty then + begin + RecordAttempt(LIP); + TJSONHelper.SendError(AResponse, 401, 'User not found'); + Exit; + end; + LStoredHash := LQ.FieldByName('password_hash').AsString; + LSalt := LQ.FieldByName('salt').AsString; + LAlgo := LQ.FieldByName('hash_algo').AsString; + if LAlgo = '' then LAlgo := 'pbkdf2'; + finally + LQ.Free; + end; + finally + DB.Unlock; + end; + + LValid := False; + if SameText(LAlgo, 'pbkdf2') then + begin + LComputed := PBKDF2_SHA256_Hex(LPwd, LSalt, PBKDF2_ITERATIONS); + LValid := ConstantTimeEquals(LComputed, LStoredHash); + end; + + if not LValid then + begin + RecordAttempt(LIP); + LogAudit(LUserId, 'failed_reauth', LIP); + TJSONHelper.SendError(AResponse, 401, 'Invalid password'); + Exit; + end; + + ClearAttempts(LIP); + LogAudit(LUserId, 'reauth', LIP); + TJSONHelper.SendOK(AResponse, 'OK'); +end; + +initialization + Router.Register('POST', '/register', HandleRegister); + Router.Register('POST', '/login', HandleLogin); + Router.Register('POST', '/logout', HandleLogout); + Router.Register('POST', '/reauth', HandleReauth); + +end. diff --git a/delphi-backend/Handlers/PM.Handler.Entries.pas b/delphi-backend/Handlers/PM.Handler.Entries.pas new file mode 100644 index 0000000..ffff9e8 --- /dev/null +++ b/delphi-backend/Handlers/PM.Handler.Entries.pas @@ -0,0 +1,456 @@ +unit PM.Handler.Entries; + +(* + GET /entries?search=&deleted=0 -> JSON array of entries + POST /entries body {site,username,encrypted_password,iv,folder} -> {id,site,username,folder} + PUT /entries/{id} body {site,username,encrypted_password,iv,folder} -> {message} + DELETE /entries/{id}?permanent=0|1 -> {message} + POST /entries/{id}/restore -> {message} + POST /entries/{id}/favorite -> {message} + DELETE /entries/trash/empty -> {message} +*) + +interface + +implementation + +uses + System.SysUtils, System.JSON, System.StrUtils, System.NetEncoding, + Data.DB, FireDAC.Comp.Client, FireDAC.Stan.Param, + IdCustomHTTPServer, IdGlobalProtocols, IdURI, + PM.Router, PM.JSON, PM.Database, PM.Session, PM.Audit, PM.RateLimit; + +function GetQueryParam(ARequest: TIdHTTPRequestInfo; const AName: string; + const ADefault: string = ''): string; +begin + Result := ARequest.Params.Values[AName]; + if Result = '' then Result := ADefault; +end; + +// SQLite DATETIME columns: FireDAC parses to TDateTime internally, then AsString +// would format in system locale (DD/MM/YYYY in French). Force ISO format +// 'yyyy-mm-dd hh:nn:ss' which is what api.php / SQLite text storage uses and +// what the JS frontend parses. +function ISODateTimeField(AField: TField): string; +begin + if AField.IsNull then + Result := '' + else + Result := FormatDateTime('yyyy-mm-dd hh:nn:ss', AField.AsDateTime); +end; + +// ===== GET /entries ========================================================== + +procedure HandleGetEntries(ARequest: TIdHTTPRequestInfo; + AResponse: TIdHTTPResponseInfo; const AParams: TArray); +var + LUserId: Integer; + LQ: TFDQuery; + LArr: TJSONArray; + LObj: TJSONObject; + LSearch, LDeletedStr: string; + LDeleted: Integer; +begin + try + LUserId := Authenticate(ARequest, AResponse); + except + on ESessionRejected do Exit; + end; + + LSearch := GetQueryParam(ARequest, 'search', ''); + LDeletedStr := GetQueryParam(ARequest, 'deleted', '0'); + if LDeletedStr = '1' then LDeleted := 1 else LDeleted := 0; + + LArr := TJSONArray.Create; + DB.Lock; + try + LQ := TFDQuery.Create(nil); + try + LQ.Connection := DB.Connection; + if LSearch <> '' then + begin + LQ.SQL.Text := + 'SELECT * FROM vault_entries ' + + 'WHERE user_id = :uid AND deleted = :del ' + + 'AND (site LIKE :q OR username LIKE :q) ' + + 'ORDER BY updated_at DESC'; + LQ.ParamByName('q').AsString := '%' + LSearch + '%'; + end + else + begin + LQ.SQL.Text := + 'SELECT * FROM vault_entries ' + + 'WHERE user_id = :uid AND deleted = :del ' + + 'ORDER BY updated_at DESC'; + end; + LQ.ParamByName('uid').AsInteger := LUserId; + LQ.ParamByName('del').AsInteger := LDeleted; + LQ.Open; + while not LQ.Eof do + begin + LObj := TJSONObject.Create; + LObj.AddPair('id', TJSONNumber.Create(LQ.FieldByName('id').AsInteger)); + LObj.AddPair('site', LQ.FieldByName('site').AsString); + LObj.AddPair('username', LQ.FieldByName('username').AsString); + LObj.AddPair('encrypted_password', LQ.FieldByName('encrypted_password').AsString); + LObj.AddPair('iv', LQ.FieldByName('iv').AsString); + LObj.AddPair('encryption_method', LQ.FieldByName('encryption_method').AsString); + LObj.AddPair('folder', LQ.FieldByName('folder').AsString); + LObj.AddPair('deleted', TJSONNumber.Create(LQ.FieldByName('deleted').AsInteger)); + if LQ.FieldByName('deleted_at').IsNull then + LObj.AddPair('deleted_at', TJSONNull.Create) + else + LObj.AddPair('deleted_at', ISODateTimeField(LQ.FieldByName('deleted_at'))); + LObj.AddPair('favorite', TJSONNumber.Create(LQ.FieldByName('favorite').AsInteger)); + LObj.AddPair('tags', LQ.FieldByName('tags').AsString); + LObj.AddPair('created_at', ISODateTimeField(LQ.FieldByName('created_at'))); + LObj.AddPair('updated_at', ISODateTimeField(LQ.FieldByName('updated_at'))); + LArr.Add(LObj); + LQ.Next; + end; + finally + LQ.Free; + end; + finally + DB.Unlock; + end; + TJSONHelper.SendJSON(AResponse, LArr); +end; + +// ===== POST /entries ========================================================= + +procedure HandleCreateEntry(ARequest: TIdHTTPRequestInfo; + AResponse: TIdHTTPResponseInfo; const AParams: TArray); +var + LUserId, LNewId: Integer; + LBody, LObj: TJSONObject; + LSite, LUser, LFolder, LEnc, LIV, LTags, LNow: string; + LQ: TFDQuery; +begin + try + LUserId := Authenticate(ARequest, AResponse); + RequireCSRF(ARequest, AResponse, LUserId); + except + on ESessionRejected do Exit; + end; + + LBody := TJSONHelper.ReadBody(ARequest); + try + LSite := Trim(LBody.GetValue('site', '')); + LUser := Trim(LBody.GetValue('username', '')); + LFolder := Trim(LBody.GetValue('folder', 'All')); + LEnc := LBody.GetValue('encrypted_password', ''); + LIV := LBody.GetValue('iv', ''); + LTags := Trim(LBody.GetValue('tags', '')); + finally + LBody.Free; + end; + + if (LSite = '') or (LEnc = '') then + begin + TJSONHelper.SendError(AResponse, 400, 'Site & password required'); + Exit; + end; + + LNow := FormatDateTime('yyyy-mm-dd hh:nn:ss', Now); + + DB.Lock; + try + LQ := TFDQuery.Create(nil); + try + LQ.Connection := DB.Connection; + LQ.SQL.Text := + 'INSERT INTO vault_entries ' + + '(user_id, site, username, encrypted_password, iv, encryption_method, ' + + ' folder, tags, created_at, updated_at) ' + + 'VALUES (:uid, :s, :u, :e, :i, ''client'', :f, :t, :c, :c2)'; + LQ.ParamByName('uid').AsInteger := LUserId; + LQ.ParamByName('s').AsString := LSite; + LQ.ParamByName('u').AsString := LUser; + LQ.ParamByName('e').AsString := LEnc; + LQ.ParamByName('i').AsString := LIV; + LQ.ParamByName('f').AsString := LFolder; + LQ.ParamByName('t').AsString := LTags; + LQ.ParamByName('c').AsString := LNow; + LQ.ParamByName('c2').AsString := LNow; + LQ.ExecSQL; + LNewId := DB.Connection.GetLastAutoGenValue('vault_entries'); + finally + LQ.Free; + end; + finally + DB.Unlock; + end; + + LogAudit(LUserId, 'add_entry', GetClientIP(ARequest)); + LObj := TJSONObject.Create; + LObj.AddPair('id', TJSONNumber.Create(LNewId)); + LObj.AddPair('site', LSite); + LObj.AddPair('username', LUser); + LObj.AddPair('folder', LFolder); + LObj.AddPair('tags', LTags); + TJSONHelper.SendJSON(AResponse, LObj); +end; + +// ===== PUT /entries/{id} ===================================================== + +procedure HandleUpdateEntry(ARequest: TIdHTTPRequestInfo; + AResponse: TIdHTTPResponseInfo; const AParams: TArray); +var + LUserId, LId: Integer; + LBody: TJSONObject; + LSite, LUser, LFolder, LEnc, LIV, LTags, LNow: string; + LQ: TFDQuery; +begin + try + LUserId := Authenticate(ARequest, AResponse); + RequireCSRF(ARequest, AResponse, LUserId); + except + on ESessionRejected do Exit; + end; + + LId := StrToIntDef(AParams[0], 0); + if LId = 0 then + begin + TJSONHelper.SendError(AResponse, 400, 'Invalid id'); + Exit; + end; + + LBody := TJSONHelper.ReadBody(ARequest); + try + LSite := Trim(LBody.GetValue('site', '')); + LUser := Trim(LBody.GetValue('username', '')); + LFolder := Trim(LBody.GetValue('folder', 'All')); + LEnc := LBody.GetValue('encrypted_password', ''); + LIV := LBody.GetValue('iv', ''); + LTags := Trim(LBody.GetValue('tags', '')); + finally + LBody.Free; + end; + + if (LSite = '') or (LEnc = '') then + begin + TJSONHelper.SendError(AResponse, 400, 'Site & password required'); + Exit; + end; + + LNow := FormatDateTime('yyyy-mm-dd hh:nn:ss', Now); + DB.Lock; + try + LQ := TFDQuery.Create(nil); + try + LQ.Connection := DB.Connection; + LQ.SQL.Text := + 'UPDATE vault_entries ' + + 'SET site=:s, username=:u, encrypted_password=:e, iv=:i, ' + + ' folder=:f, tags=:t, updated_at=:c ' + + 'WHERE id=:id AND user_id=:uid'; + LQ.ParamByName('s').AsString := LSite; + LQ.ParamByName('u').AsString := LUser; + LQ.ParamByName('e').AsString := LEnc; + LQ.ParamByName('i').AsString := LIV; + LQ.ParamByName('f').AsString := LFolder; + LQ.ParamByName('t').AsString := LTags; + LQ.ParamByName('c').AsString := LNow; + LQ.ParamByName('id').AsInteger := LId; + LQ.ParamByName('uid').AsInteger := LUserId; + LQ.ExecSQL; + finally + LQ.Free; + end; + finally + DB.Unlock; + end; + + LogAudit(LUserId, 'edit_entry', GetClientIP(ARequest)); + TJSONHelper.SendOK(AResponse, 'Updated'); +end; + +// ===== DELETE /entries/{id} ================================================== + +procedure HandleDeleteEntry(ARequest: TIdHTTPRequestInfo; + AResponse: TIdHTTPResponseInfo; const AParams: TArray); +var + LUserId, LId: Integer; + LPermanent: Boolean; + LQ: TFDQuery; +begin + try + LUserId := Authenticate(ARequest, AResponse); + RequireCSRF(ARequest, AResponse, LUserId); + except + on ESessionRejected do Exit; + end; + + LId := StrToIntDef(AParams[0], 0); + if LId = 0 then + begin + TJSONHelper.SendError(AResponse, 400, 'Invalid id'); + Exit; + end; + + LPermanent := GetQueryParam(ARequest, 'permanent', '0') = '1'; + + DB.Lock; + try + LQ := TFDQuery.Create(nil); + try + LQ.Connection := DB.Connection; + if LPermanent then + LQ.SQL.Text := 'DELETE FROM vault_entries WHERE id=:id AND user_id=:uid' + else + LQ.SQL.Text := + 'UPDATE vault_entries SET deleted=1, deleted_at=datetime(''now'') ' + + 'WHERE id=:id AND user_id=:uid'; + LQ.ParamByName('id').AsInteger := LId; + LQ.ParamByName('uid').AsInteger := LUserId; + LQ.ExecSQL; + finally + LQ.Free; + end; + finally + DB.Unlock; + end; + + if LPermanent then + LogAudit(LUserId, 'permanent_delete', GetClientIP(ARequest)) + else + LogAudit(LUserId, 'delete_entry', GetClientIP(ARequest)); + TJSONHelper.SendOK(AResponse, 'Deleted'); +end; + +// ===== POST /entries/{id}/restore ============================================ + +procedure HandleRestoreEntry(ARequest: TIdHTTPRequestInfo; + AResponse: TIdHTTPResponseInfo; const AParams: TArray); +var + LUserId, LId: Integer; + LQ: TFDQuery; +begin + try + LUserId := Authenticate(ARequest, AResponse); + RequireCSRF(ARequest, AResponse, LUserId); + except + on ESessionRejected do Exit; + end; + + LId := StrToIntDef(AParams[0], 0); + if LId = 0 then + begin + TJSONHelper.SendError(AResponse, 400, 'Invalid id'); + Exit; + end; + + DB.Lock; + try + LQ := TFDQuery.Create(nil); + try + LQ.Connection := DB.Connection; + LQ.SQL.Text := + 'UPDATE vault_entries SET deleted=0, deleted_at=NULL, ' + + ' updated_at=datetime(''now'') ' + + 'WHERE id=:id AND user_id=:uid'; + LQ.ParamByName('id').AsInteger := LId; + LQ.ParamByName('uid').AsInteger := LUserId; + LQ.ExecSQL; + finally + LQ.Free; + end; + finally + DB.Unlock; + end; + + LogAudit(LUserId, 'restore_entry', GetClientIP(ARequest)); + TJSONHelper.SendOK(AResponse, 'Restored'); +end; + +// ===== POST /entries/{id}/favorite =========================================== + +procedure HandleToggleFavorite(ARequest: TIdHTTPRequestInfo; + AResponse: TIdHTTPResponseInfo; const AParams: TArray); +var + LUserId, LId: Integer; + LQ: TFDQuery; +begin + try + LUserId := Authenticate(ARequest, AResponse); + RequireCSRF(ARequest, AResponse, LUserId); + except + on ESessionRejected do Exit; + end; + + LId := StrToIntDef(AParams[0], 0); + if LId = 0 then + begin + TJSONHelper.SendError(AResponse, 400, 'Invalid id'); + Exit; + end; + + DB.Lock; + try + LQ := TFDQuery.Create(nil); + try + LQ.Connection := DB.Connection; + LQ.SQL.Text := + 'UPDATE vault_entries ' + + 'SET favorite = CASE WHEN favorite=1 THEN 0 ELSE 1 END ' + + 'WHERE id=:id AND user_id=:uid'; + LQ.ParamByName('id').AsInteger := LId; + LQ.ParamByName('uid').AsInteger := LUserId; + LQ.ExecSQL; + finally + LQ.Free; + end; + finally + DB.Unlock; + end; + + LogAudit(LUserId, 'toggle_favorite', GetClientIP(ARequest)); + TJSONHelper.SendOK(AResponse, 'Toggled'); +end; + +// ===== DELETE /entries/trash/empty =========================================== + +procedure HandleEmptyTrash(ARequest: TIdHTTPRequestInfo; + AResponse: TIdHTTPResponseInfo; const AParams: TArray); +var + LUserId: Integer; + LQ: TFDQuery; +begin + try + LUserId := Authenticate(ARequest, AResponse); + RequireCSRF(ARequest, AResponse, LUserId); + except + on ESessionRejected do Exit; + end; + + DB.Lock; + try + LQ := TFDQuery.Create(nil); + try + LQ.Connection := DB.Connection; + LQ.SQL.Text := 'DELETE FROM vault_entries WHERE user_id=:uid AND deleted=1'; + LQ.ParamByName('uid').AsInteger := LUserId; + LQ.ExecSQL; + finally + LQ.Free; + end; + finally + DB.Unlock; + end; + + LogAudit(LUserId, 'empty_trash', GetClientIP(ARequest)); + TJSONHelper.SendOK(AResponse, 'Trash emptied'); +end; + +initialization + // /entries/trash/empty must be registered BEFORE /entries/{id} to win the regex match + Router.Register('DELETE', '/entries/trash/empty', HandleEmptyTrash); + Router.Register('POST', '/entries/(\d+)/restore', HandleRestoreEntry); + Router.Register('POST', '/entries/(\d+)/favorite', HandleToggleFavorite); + Router.Register('GET', '/entries', HandleGetEntries); + Router.Register('POST', '/entries', HandleCreateEntry); + Router.Register('PUT', '/entries/(\d+)', HandleUpdateEntry); + Router.Register('DELETE', '/entries/(\d+)', HandleDeleteEntry); + +end. diff --git a/delphi-backend/Handlers/PM.Handler.Folders.pas b/delphi-backend/Handlers/PM.Handler.Folders.pas new file mode 100644 index 0000000..72cf2b9 --- /dev/null +++ b/delphi-backend/Handlers/PM.Handler.Folders.pas @@ -0,0 +1,200 @@ +unit PM.Handler.Folders; + +(* + GET /folders -> JSON array of folder names + POST /folders body {name} -> {message,name} + DELETE /folders/{name} -> {message} +*) + +interface + +implementation + +uses + System.SysUtils, System.JSON, System.NetEncoding, + FireDAC.Comp.Client, FireDAC.Stan.Param, + IdCustomHTTPServer, + PM.Router, PM.JSON, PM.Database, PM.Session, PM.Audit, PM.RateLimit; + +// ===== GET /folders ========================================================== + +procedure HandleGetFolders(ARequest: TIdHTTPRequestInfo; + AResponse: TIdHTTPResponseInfo; const AParams: TArray); +var + LUserId: Integer; + LQ: TFDQuery; + LArr: TJSONArray; +begin + try + LUserId := Authenticate(ARequest, AResponse); + except + on ESessionRejected do Exit; + end; + + LArr := TJSONArray.Create; + DB.Lock; + try + LQ := TFDQuery.Create(nil); + try + LQ.Connection := DB.Connection; + LQ.SQL.Text := 'SELECT name FROM folders WHERE user_id = :uid ORDER BY name'; + LQ.ParamByName('uid').AsInteger := LUserId; + LQ.Open; + while not LQ.Eof do + begin + LArr.Add(LQ.FieldByName('name').AsString); + LQ.Next; + end; + finally + LQ.Free; + end; + finally + DB.Unlock; + end; + TJSONHelper.SendJSON(AResponse, LArr); +end; + +// ===== POST /folders ========================================================= + +procedure HandleCreateFolder(ARequest: TIdHTTPRequestInfo; + AResponse: TIdHTTPResponseInfo; const AParams: TArray); +var + LUserId: Integer; + LBody: TJSONObject; + LName: string; + LQ: TFDQuery; + LObj: TJSONObject; +begin + try + LUserId := Authenticate(ARequest, AResponse); + RequireCSRF(ARequest, AResponse, LUserId); + except + on ESessionRejected do Exit; + end; + + LBody := TJSONHelper.ReadBody(ARequest); + try + LName := Trim(LBody.GetValue('name', '')); + finally + LBody.Free; + end; + + if LName = '' then + begin + TJSONHelper.SendError(AResponse, 400, 'Folder name required'); + Exit; + end; + if SameText(LName, 'All') then + begin + TJSONHelper.SendError(AResponse, 400, 'Cannot use All'); + Exit; + end; + + DB.Lock; + try + LQ := TFDQuery.Create(nil); + try + LQ.Connection := DB.Connection; + LQ.SQL.Text := 'INSERT INTO folders (user_id, name) VALUES (:uid, :name)'; + LQ.ParamByName('uid').AsInteger := LUserId; + LQ.ParamByName('name').AsString := LName; + try + LQ.ExecSQL; + except + on E: Exception do + begin + TJSONHelper.SendError(AResponse, 409, 'Folder exists'); + Exit; + end; + end; + finally + LQ.Free; + end; + finally + DB.Unlock; + end; + + LogAudit(LUserId, 'add_folder', GetClientIP(ARequest)); + LObj := TJSONObject.Create; + LObj.AddPair('message', 'Created'); + LObj.AddPair('name', LName); + TJSONHelper.SendJSON(AResponse, LObj); +end; + +// ===== DELETE /folders/{name} ================================================ + +procedure HandleDeleteFolder(ARequest: TIdHTTPRequestInfo; + AResponse: TIdHTTPResponseInfo; const AParams: TArray); +var + LUserId: Integer; + LName: string; + LQ: TFDQuery; + LChanges: Integer; +begin + try + LUserId := Authenticate(ARequest, AResponse); + RequireCSRF(ARequest, AResponse, LUserId); + except + on ESessionRejected do Exit; + end; + + if Length(AParams) < 1 then + begin + TJSONHelper.SendError(AResponse, 400, 'Folder name required'); + Exit; + end; + LName := TNetEncoding.URL.Decode(AParams[0]); + + if SameText(LName, 'All') then + begin + TJSONHelper.SendError(AResponse, 400, 'Cannot delete All'); + Exit; + end; + + DB.Lock; + try + LQ := TFDQuery.Create(nil); + try + LQ.Connection := DB.Connection; + LQ.SQL.Text := 'DELETE FROM folders WHERE user_id = :uid AND name = :name'; + LQ.ParamByName('uid').AsInteger := LUserId; + LQ.ParamByName('name').AsString := LName; + LQ.ExecSQL; + LChanges := LQ.RowsAffected; + finally + LQ.Free; + end; + + if LChanges = 0 then + begin + TJSONHelper.SendError(AResponse, 404, 'Not found'); + Exit; + end; + + // Reassign entries from the deleted folder to 'All' + LQ := TFDQuery.Create(nil); + try + LQ.Connection := DB.Connection; + LQ.SQL.Text := + 'UPDATE vault_entries SET folder = ''All'' ' + + 'WHERE user_id = :uid AND folder = :name'; + LQ.ParamByName('uid').AsInteger := LUserId; + LQ.ParamByName('name').AsString := LName; + LQ.ExecSQL; + finally + LQ.Free; + end; + finally + DB.Unlock; + end; + + LogAudit(LUserId, 'delete_folder', GetClientIP(ARequest)); + TJSONHelper.SendOK(AResponse, 'Deleted'); +end; + +initialization + Router.Register('GET', '/folders', HandleGetFolders); + Router.Register('POST', '/folders', HandleCreateFolder); + Router.Register('DELETE', '/folders/(.+)', HandleDeleteFolder); + +end. diff --git a/delphi-backend/Handlers/PM.Handler.Passkey.pas b/delphi-backend/Handlers/PM.Handler.Passkey.pas new file mode 100644 index 0000000..92c01a1 --- /dev/null +++ b/delphi-backend/Handlers/PM.Handler.Passkey.pas @@ -0,0 +1,47 @@ +unit PM.Handler.Passkey; + +(* + WebAuthn / Passkey endpoints — stubbed to 501 Not Implemented. + + Why stubbed: full WebAuthn server requires: + - CBOR decoder for COSE keys + attestation objects + - DER ASN.1 encoder for ES256/RS256 public keys + - ECDSA P-256 signature verification (no native Delphi support) + - Challenge management with constant-time compares + Roughly 500-700 lines of crypto-sensitive code. The PHP version (api.php + lines 168-657) handles this with OpenSSL bindings. A faithful Delphi port + would either bind libssl/libcrypto DLLs or pull in a pure-Pascal EC lib. + + For v1 of the Delphi backend we return 501 with a clear message so the + frontend gracefully falls back to master password login. The PHP backend + remains the reference for passkey-enabled deployments. + + When implemented, see: + api.php :169-211 cbor_decode, derLen, coseToPem + api.php :537-657 register/begin, register/complete, login/begin, login/complete +*) + +interface + +implementation + +uses + System.SysUtils, + IdCustomHTTPServer, + PM.Router, PM.JSON; + +procedure HandleStub(ARequest: TIdHTTPRequestInfo; + AResponse: TIdHTTPResponseInfo; const AParams: TArray); +begin + TJSONHelper.SendError(AResponse, 501, + 'Passkey/WebAuthn is not implemented in the Delphi backend yet. ' + + 'Use master-password login, or run the PHP backend for passkey support.'); +end; + +initialization + Router.Register('POST', '/passkey/register/begin', HandleStub); + Router.Register('POST', '/passkey/register/complete', HandleStub); + Router.Register('POST', '/passkey/login/begin', HandleStub); + Router.Register('POST', '/passkey/login/complete', HandleStub); + +end. diff --git a/delphi-backend/Handlers/PM.Handler.Ping.pas b/delphi-backend/Handlers/PM.Handler.Ping.pas new file mode 100644 index 0000000..5dbf60a --- /dev/null +++ b/delphi-backend/Handlers/PM.Handler.Ping.pas @@ -0,0 +1,31 @@ +unit PM.Handler.Ping; + +(* + Minimal stub route to verify the build/run pipeline. + GET /ping returns a small JSON object with message=pong and server=delphi. +*) + +interface + +implementation + +uses + System.JSON, System.SysUtils, + IdCustomHTTPServer, + PM.Router, PM.JSON; + +procedure HandlePing(ARequest: TIdHTTPRequestInfo; + AResponse: TIdHTTPResponseInfo; const AParams: TArray); +var + LObj: TJSONObject; +begin + LObj := TJSONObject.Create; + LObj.AddPair('message', 'pong'); + LObj.AddPair('server', 'delphi'); + TJSONHelper.SendJSON(AResponse, LObj); +end; + +initialization + Router.Register('GET', '/ping', HandlePing); + +end. diff --git a/delphi-backend/PMServer.dpr b/delphi-backend/PMServer.dpr new file mode 100644 index 0000000..5d582f3 --- /dev/null +++ b/delphi-backend/PMServer.dpr @@ -0,0 +1,31 @@ +program PMServer; + +uses + System.StartUpCopy, + FMX.Forms, + UMainForm in 'UMainForm.pas' {MainForm}, + PM.JSON in 'Source\PM.JSON.pas', + PM.Database in 'Source\PM.Database.pas', + PM.Router in 'Source\PM.Router.pas', + PM.StaticFiles in 'Source\PM.StaticFiles.pas', + PM.EmbeddedAssets in 'Source\PM.EmbeddedAssets.pas', + PM.Crypto in 'Source\PM.Crypto.pas', + PM.RateLimit in 'Source\PM.RateLimit.pas', + PM.Audit in 'Source\PM.Audit.pas', + PM.Session in 'Source\PM.Session.pas', + PM.HTTPServer in 'Source\PM.HTTPServer.pas', + PM.Bridge in 'Source\PM.Bridge.pas', + PM.Handler.Ping in 'Handlers\PM.Handler.Ping.pas', + PM.Handler.Auth in 'Handlers\PM.Handler.Auth.pas', + PM.Handler.Folders in 'Handlers\PM.Handler.Folders.pas', + PM.Handler.Entries in 'Handlers\PM.Handler.Entries.pas', + PM.Handler.Passkey in 'Handlers\PM.Handler.Passkey.pas'; + +{$R *.res} +{$R assets\assets.res} + +begin + Application.Initialize; + Application.CreateForm(TMainForm, MainForm); + Application.Run; +end. diff --git a/delphi-backend/PMServer.dproj b/delphi-backend/PMServer.dproj new file mode 100644 index 0000000..ff21530 --- /dev/null +++ b/delphi-backend/PMServer.dproj @@ -0,0 +1,270 @@ + + + {59A8733F-111A-41EC-80BE-9848275FC80D} + PMServer.dpr + True + Debug + 693249 + Application + FMX + 20.1 + Win32 + + + true + + + true + Base + true + + + true + Base + true + + + true + Base + true + + + true + Base + true + + + true + Base + true + + + true + Base + true + + + true + Cfg_1 + true + true + + + true + Base + true + + + true + Cfg_2 + true + true + + + true + Cfg_2 + true + true + + + true + Cfg_2 + true + true + + + true + Cfg_2 + true + true + + + true + Cfg_2 + true + true + + + false + false + false + false + false + 00400000 + PMServer + 1036 + CompanyName=;FileDescription=;FileVersion=1.0.0.0;InternalName=;LegalCopyright=;LegalTrademarks=;OriginalFilename=;ProductName=;ProductVersion=1.0.0.0;Comments=;CFBundleName= + System;Xml;Data;Datasnap;Web;Soap;$(DCC_Namespace) + $(BDS)\bin\delphi_PROJECTICON.ico + $(BDS)\bin\delphi_PROJECTICNS.icns + + + package=com.embarcadero.$(MSBuildProjectName);label=$(MSBuildProjectName);versionCode=1;versionName=1.0.0;persistent=False;restoreAnyVersion=False;installLocation=auto;largeHeap=False;theme=TitleBar;hardwareAccelerated=true;apiKey= + Debug + true + $(BDS)\bin\Artwork\Android\FM_LauncherIcon_36x36.png + $(BDS)\bin\Artwork\Android\FM_LauncherIcon_48x48.png + $(BDS)\bin\Artwork\Android\FM_LauncherIcon_72x72.png + $(BDS)\bin\Artwork\Android\FM_LauncherIcon_96x96.png + $(BDS)\bin\Artwork\Android\FM_LauncherIcon_144x144.png + $(BDS)\bin\Artwork\Android\FM_SplashImage_426x320.png + $(BDS)\bin\Artwork\Android\FM_SplashImage_470x320.png + $(BDS)\bin\Artwork\Android\FM_SplashImage_640x480.png + $(BDS)\bin\Artwork\Android\FM_SplashImage_960x720.png + true + true + true + true + true + true + true + true + true + true + $(BDS)\bin\Artwork\Android\FM_NotificationIcon_24x24.png + $(BDS)\bin\Artwork\Android\FM_NotificationIcon_36x36.png + $(BDS)\bin\Artwork\Android\FM_NotificationIcon_48x48.png + $(BDS)\bin\Artwork\Android\FM_NotificationIcon_72x72.png + $(BDS)\bin\Artwork\Android\FM_NotificationIcon_96x96.png + $(BDS)\bin\Artwork\Android\FM_LauncherIcon_192x192.png + activity-1.7.2.dex.jar;annotation-experimental-1.3.0.dex.jar;annotation-jvm-1.6.0.dex.jar;annotations-13.0.dex.jar;appcompat-1.2.0.dex.jar;appcompat-resources-1.2.0.dex.jar;billing-6.0.1.dex.jar;biometric-1.1.0.dex.jar;browser-1.4.0.dex.jar;cloud-messaging.dex.jar;collection-1.1.0.dex.jar;concurrent-futures-1.1.0.dex.jar;core-1.10.1.dex.jar;core-common-2.2.0.dex.jar;core-ktx-1.10.1.dex.jar;core-runtime-2.2.0.dex.jar;cursoradapter-1.0.0.dex.jar;customview-1.0.0.dex.jar;documentfile-1.0.0.dex.jar;drawerlayout-1.0.0.dex.jar;error_prone_annotations-2.9.0.dex.jar;exifinterface-1.3.6.dex.jar;firebase-annotations-16.2.0.dex.jar;firebase-common-20.3.1.dex.jar;firebase-components-17.1.0.dex.jar;firebase-datatransport-18.1.7.dex.jar;firebase-encoders-17.0.0.dex.jar;firebase-encoders-json-18.0.0.dex.jar;firebase-encoders-proto-16.0.0.dex.jar;firebase-iid-interop-17.1.0.dex.jar;firebase-installations-17.1.3.dex.jar;firebase-installations-interop-17.1.0.dex.jar;firebase-measurement-connector-19.0.0.dex.jar;firebase-messaging-23.1.2.dex.jar;fragment-1.2.5.dex.jar;google-play-licensing.dex.jar;interpolator-1.0.0.dex.jar;javax.inject-1.dex.jar;kotlin-stdlib-1.8.22.dex.jar;kotlin-stdlib-common-1.8.22.dex.jar;kotlin-stdlib-jdk7-1.8.22.dex.jar;kotlin-stdlib-jdk8-1.8.22.dex.jar;kotlinx-coroutines-android-1.6.4.dex.jar;kotlinx-coroutines-core-jvm-1.6.4.dex.jar;legacy-support-core-utils-1.0.0.dex.jar;lifecycle-common-2.6.1.dex.jar;lifecycle-livedata-2.6.1.dex.jar;lifecycle-livedata-core-2.6.1.dex.jar;lifecycle-runtime-2.6.1.dex.jar;lifecycle-service-2.6.1.dex.jar;lifecycle-viewmodel-2.6.1.dex.jar;lifecycle-viewmodel-savedstate-2.6.1.dex.jar;listenablefuture-1.0.dex.jar;loader-1.0.0.dex.jar;localbroadcastmanager-1.0.0.dex.jar;okio-jvm-3.4.0.dex.jar;play-services-ads-22.2.0.dex.jar;play-services-ads-base-22.2.0.dex.jar;play-services-ads-identifier-18.0.0.dex.jar;play-services-ads-lite-22.2.0.dex.jar;play-services-appset-16.0.1.dex.jar;play-services-base-18.1.0.dex.jar;play-services-basement-18.1.0.dex.jar;play-services-cloud-messaging-17.0.1.dex.jar;play-services-location-21.0.1.dex.jar;play-services-maps-18.1.0.dex.jar;play-services-measurement-base-20.1.2.dex.jar;play-services-measurement-sdk-api-20.1.2.dex.jar;play-services-stats-17.0.2.dex.jar;play-services-tasks-18.0.2.dex.jar;print-1.0.0.dex.jar;profileinstaller-1.3.0.dex.jar;room-common-2.2.5.dex.jar;room-runtime-2.2.5.dex.jar;savedstate-1.2.1.dex.jar;sqlite-2.1.0.dex.jar;sqlite-framework-2.1.0.dex.jar;startup-runtime-1.1.1.dex.jar;tracing-1.0.0.dex.jar;transport-api-3.0.0.dex.jar;transport-backend-cct-3.1.8.dex.jar;transport-runtime-3.1.8.dex.jar;user-messaging-platform-2.0.0.dex.jar;vectordrawable-1.1.0.dex.jar;vectordrawable-animated-1.1.0.dex.jar;versionedparcelable-1.1.1.dex.jar;viewpager-1.0.0.dex.jar;work-runtime-2.7.0.dex.jar + + + $(BDS)\bin\Artwork\Android\FM_LauncherIcon_192x192.png + activity-1.7.2.dex.jar;annotation-experimental-1.3.0.dex.jar;annotation-jvm-1.6.0.dex.jar;annotations-13.0.dex.jar;appcompat-1.2.0.dex.jar;appcompat-resources-1.2.0.dex.jar;billing-6.0.1.dex.jar;biometric-1.1.0.dex.jar;browser-1.4.0.dex.jar;cloud-messaging.dex.jar;collection-1.1.0.dex.jar;concurrent-futures-1.1.0.dex.jar;core-1.10.1.dex.jar;core-common-2.2.0.dex.jar;core-ktx-1.10.1.dex.jar;core-runtime-2.2.0.dex.jar;cursoradapter-1.0.0.dex.jar;customview-1.0.0.dex.jar;documentfile-1.0.0.dex.jar;drawerlayout-1.0.0.dex.jar;error_prone_annotations-2.9.0.dex.jar;exifinterface-1.3.6.dex.jar;firebase-annotations-16.2.0.dex.jar;firebase-common-20.3.1.dex.jar;firebase-components-17.1.0.dex.jar;firebase-datatransport-18.1.7.dex.jar;firebase-encoders-17.0.0.dex.jar;firebase-encoders-json-18.0.0.dex.jar;firebase-encoders-proto-16.0.0.dex.jar;firebase-iid-interop-17.1.0.dex.jar;firebase-installations-17.1.3.dex.jar;firebase-installations-interop-17.1.0.dex.jar;firebase-measurement-connector-19.0.0.dex.jar;firebase-messaging-23.1.2.dex.jar;fragment-1.2.5.dex.jar;google-play-licensing.dex.jar;interpolator-1.0.0.dex.jar;javax.inject-1.dex.jar;kotlin-stdlib-1.8.22.dex.jar;kotlin-stdlib-common-1.8.22.dex.jar;kotlin-stdlib-jdk7-1.8.22.dex.jar;kotlin-stdlib-jdk8-1.8.22.dex.jar;kotlinx-coroutines-android-1.6.4.dex.jar;kotlinx-coroutines-core-jvm-1.6.4.dex.jar;legacy-support-core-utils-1.0.0.dex.jar;lifecycle-common-2.6.1.dex.jar;lifecycle-livedata-2.6.1.dex.jar;lifecycle-livedata-core-2.6.1.dex.jar;lifecycle-runtime-2.6.1.dex.jar;lifecycle-service-2.6.1.dex.jar;lifecycle-viewmodel-2.6.1.dex.jar;lifecycle-viewmodel-savedstate-2.6.1.dex.jar;listenablefuture-1.0.dex.jar;loader-1.0.0.dex.jar;localbroadcastmanager-1.0.0.dex.jar;okio-jvm-3.4.0.dex.jar;play-services-ads-22.2.0.dex.jar;play-services-ads-base-22.2.0.dex.jar;play-services-ads-identifier-18.0.0.dex.jar;play-services-ads-lite-22.2.0.dex.jar;play-services-appset-16.0.1.dex.jar;play-services-base-18.1.0.dex.jar;play-services-basement-18.1.0.dex.jar;play-services-cloud-messaging-17.0.1.dex.jar;play-services-location-21.0.1.dex.jar;play-services-maps-18.1.0.dex.jar;play-services-measurement-base-20.1.2.dex.jar;play-services-measurement-sdk-api-20.1.2.dex.jar;play-services-stats-17.0.2.dex.jar;play-services-tasks-18.0.2.dex.jar;print-1.0.0.dex.jar;profileinstaller-1.3.0.dex.jar;room-common-2.2.5.dex.jar;room-runtime-2.2.5.dex.jar;savedstate-1.2.1.dex.jar;sqlite-2.1.0.dex.jar;sqlite-framework-2.1.0.dex.jar;startup-runtime-1.1.1.dex.jar;tracing-1.0.0.dex.jar;transport-api-3.0.0.dex.jar;transport-backend-cct-3.1.8.dex.jar;transport-runtime-3.1.8.dex.jar;user-messaging-platform-2.0.0.dex.jar;vectordrawable-1.1.0.dex.jar;vectordrawable-animated-1.1.0.dex.jar;versionedparcelable-1.1.1.dex.jar;viewpager-1.0.0.dex.jar;work-runtime-2.7.0.dex.jar + + + $(BDS)\bin\Artwork\iOS\iPhone\FM_SettingIcon_87x87.png + $(BDS)\bin\Artwork\iOS\iPhone\FM_ApplicationIcon_180x180.png + $(BDS)\bin\Artwork\iOS\iPhone\FM_SpotlightSearchIcon_120x120.png + $(BDS)\bin\Artwork\iOS\iPad\FM_ApplicationIcon_167x167.png + $(BDS)\bin\Artwork\iOS\iPhone\FM_LaunchImage_2x.png + $(BDS)\bin\Artwork\iOS\iPhone\FM_LaunchImageDark_2x.png + $(BDS)\bin\Artwork\iOS\iPhone\FM_LaunchImage_3x.png + $(BDS)\bin\Artwork\iOS\iPhone\FM_LaunchImageDark_3x.png + $(BDS)\bin\Artwork\iOS\iPad\FM_LaunchImage_2x.png + $(BDS)\bin\Artwork\iOS\iPad\FM_LaunchImageDark_2x.png + $(BDS)\bin\Artwork\iOS\iPhone\FM_ApplicationIcon_1024x1024.png + + + Winapi;System.Win;Data.Win;Datasnap.Win;Web.Win;Soap.Win;Xml.Win;Bde;$(DCC_Namespace) + Debug + true + CompanyName=;FileDescription=$(MSBuildProjectName);FileVersion=1.0.0.0;InternalName=;LegalCopyright=;LegalTrademarks=;OriginalFilename=;ProductName=$(MSBuildProjectName);ProductVersion=1.0.0.0;Comments=;ProgramID=com.embarcadero.$(MSBuildProjectName) + 1033 + $(BDS)\bin\default_app.manifest + $(BDS)\bin\Artwork\Windows\UWP\delphi_UwpDefault_44.png + $(BDS)\bin\Artwork\Windows\UWP\delphi_UwpDefault_150.png + + + $(BDS)\bin\Artwork\Windows\UWP\delphi_UwpDefault_44.png + $(BDS)\bin\Artwork\Windows\UWP\delphi_UwpDefault_150.png + + + RELEASE;$(DCC_Define) + 0 + false + 0 + + + PerMonitorV2 + + + DEBUG;$(DCC_Define) + false + true + true + true + + + Debug + + + Debug + + + Debug + + + Debug + + + PerMonitorV2 + true + 1033 + CompanyName=;FileDescription=$(MSBuildProjectName);FileVersion=1.0.0.0;InternalName=;LegalCopyright=;LegalTrademarks=;OriginalFilename=;ProductName=$(MSBuildProjectName);ProductVersion=1.0.0.0;Comments=;ProgramID=com.embarcadero.$(MSBuildProjectName) + + + + + MainSource + + +
MainForm
+
+ + + + + + + + + + + + + + + + + + Base + + + Cfg_1 + Base + + + Cfg_2 + Base + +
+ + Delphi.Personality.12 + + + + + PMServer.dpr + + + Microsoft Office 2000 Sample Automation Server Wrapper Components + Microsoft Office XP Sample Automation Server Wrapper Components + + + + False + True + True + True + True + True + True + False + + + 12 + + + + + "Z:\password-manager\delphi-backend\assets\BuildAssets.cmd" + False + + False + + False + +
diff --git a/delphi-backend/PMServer.res b/delphi-backend/PMServer.res new file mode 100644 index 0000000..2ac980a Binary files /dev/null and b/delphi-backend/PMServer.res differ diff --git a/delphi-backend/Source/PM.Audit.pas b/delphi-backend/Source/PM.Audit.pas new file mode 100644 index 0000000..0e87c5c --- /dev/null +++ b/delphi-backend/Source/PM.Audit.pas @@ -0,0 +1,43 @@ +unit PM.Audit; + +{ + Mirrors api.php logAudit(). + user_id may be NULL for pre-auth events; pass 0 to record without user. +} + +interface + +uses + System.SysUtils, FireDAC.Comp.Client, FireDAC.Stan.Param, + PM.Database; + +procedure LogAudit(AUserId: Integer; const AAction, AIP: string); + +implementation + +procedure LogAudit(AUserId: Integer; const AAction, AIP: string); +var + LQ: TFDQuery; +begin + DB.Lock; + try + LQ := TFDQuery.Create(nil); + try + LQ.Connection := DB.Connection; + LQ.SQL.Text := 'INSERT INTO audit_log (user_id, action, ip) VALUES (:uid, :action, :ip)'; + if AUserId > 0 then + LQ.ParamByName('uid').AsInteger := AUserId + else + LQ.ParamByName('uid').Clear; + LQ.ParamByName('action').AsString := AAction; + LQ.ParamByName('ip').AsString := AIP; + LQ.ExecSQL; + finally + LQ.Free; + end; + finally + DB.Unlock; + end; +end; + +end. diff --git a/delphi-backend/Source/PM.Bridge.pas b/delphi-backend/Source/PM.Bridge.pas new file mode 100644 index 0000000..f4529d3 --- /dev/null +++ b/delphi-backend/Source/PM.Bridge.pas @@ -0,0 +1,487 @@ +unit PM.Bridge; + +{ + PM.Bridge — JS↔Delphi native capability bridge. + + Three features exposed to the embedded WebView2 via cmd:// URLs: + + 1. TSecureClipboard + Sets text on the Windows clipboard alongside the + ExcludeClipboardContentFromMonitorProcessing format, which prevents + Win+V clipboard history from recording the password. Auto-clears + after a configurable delay via TTimer. + + 2. Tray icon (TPMBridge.MinimizeToTray / RestoreFromTray) + Shell_NotifyIcon-based. The main window hides; a tray icon appears. + Single-click or double-click on the tray icon restores the window. + OnTrayRestore is called on the main thread so the caller can Show/BringToFront. + + 3. Windows session-lock detection + WTSRegisterSessionNotification on a dedicated message-only window. + On WTS_SESSION_LOCK the bridge fires OnSystemLock (main thread) so + the Delphi host can inject lockVault() into the WebView2. + + Both tray icon messages and WTS notifications are routed through a + single message-only window created with AllocateHWnd, avoiding any + subclassing of the FMX main window. +} + +interface + +uses + System.SysUtils, System.Classes, System.Math, + FMX.Types, FMX.Forms, + Winapi.Windows, Winapi.ShellAPI, Winapi.Messages; + +type + // ------------------------------------------------------------------------- + // TSecureClipboard + // ------------------------------------------------------------------------- + TSecureClipboard = class + private + FClearTimer: TTimer; + procedure ClearTimerTick(Sender: TObject); + public + constructor Create; + destructor Destroy; override; + // Copy AText to the clipboard, excluding it from Win+V history. + // AClearAfterMs = 0 disables auto-clear; default is 30 seconds. + procedure SetText(const AText: string; AClearAfterMs: Integer = 30000); + procedure Clear; + end; + + // ------------------------------------------------------------------------- + // TPMBridge + // ------------------------------------------------------------------------- + TPMBridge = class + private + FMainForm: TForm; + FMsgWindow: HWND; + FTrayAdded: Boolean; + FIconOwned: Boolean; // true = we must call DestroyIcon on FIconHandle + FIconHandle: HICON; + FNid: TNotifyIconData; + FSecureClipboard: TSecureClipboard; + FBalloonShown: Boolean; + FOnSystemLock: TProc; + FOnTrayRestore: TProc; + FOnLockRequest: TProc; + FOnQuit: TProc; + procedure MsgWindowHandler(var AMsg: TMessage); + procedure PrepareNid; + procedure ShowTrayMenu; + procedure ShowFirstTimeBalloon; + function FindFMXAppWindow: HWND; + public + constructor Create(AMainForm: TForm); + destructor Destroy; override; + // Hide main window and show tray icon. + procedure MinimizeToTray; + // Restore main window and remove tray icon. + procedure RestoreFromTray; + property SecureClipboard: TSecureClipboard read FSecureClipboard; + property TrayAdded: Boolean read FTrayAdded; + // Fired on main thread when Windows locks the session (WTS_SESSION_LOCK). + property OnSystemLock: TProc read FOnSystemLock write FOnSystemLock; + // Fired on main thread when the user clicks the tray icon. + property OnTrayRestore: TProc read FOnTrayRestore write FOnTrayRestore; + // Fired when the user picks "Lock vault" from the tray menu. Handler + // should trigger the JS lockVault() (typically via ExecuteJavaScript). + property OnLockRequest: TProc read FOnLockRequest write FOnLockRequest; + // Fired when the user picks "Quit" from the tray menu. Handler must + // actually terminate the app (Application.Terminate or similar) — the + // bridge does not call it itself, so the host stays in control of + // shutdown order (server stop, save state, etc.). + property OnQuit: TProc read FOnQuit write FOnQuit; + end; + +implementation + +uses + FMX.Platform.Win; + +// Win32 format name that suppresses Win+V clipboard history recording. +// Introduced in Windows 10 1809 (build 17763). Silently ignored on older builds. +const + CLIPBOARD_EXCLUDE_FORMAT = 'ExcludeClipboardContentFromMonitorProcessing'; + +// Tray callback message routed to our message-only window. +const + WM_TRAY_ICON = WM_APP + 1; + +// WTS session change message and state constants (declared here to avoid +// a hard dependency on Winapi.WtsApi32 which varies across Delphi versions). +const + WM_WTSSESSION_CHANGE = $02B1; + WTS_SESSION_LOCK = 7; + NOTIFY_FOR_THIS_SESSION = 0; + +// Dynamic WTS function pointers — wtsapi32.dll is not guaranteed on all +// Windows SKUs (e.g. minimal Server Core without Session Services), so +// we load it at runtime and tolerate absence gracefully. +var + _WTSRegister : function(hWnd: HWND; dwFlags: DWORD): BOOL; stdcall = nil; + _WTSUnregister: function(hWnd: HWND): BOOL; stdcall = nil; + _WtsApiLoaded : Boolean = False; + _WtsLib : HMODULE = 0; + +procedure LoadWtsApi; +begin + if _WtsApiLoaded then Exit; + _WtsApiLoaded := True; + _WtsLib := LoadLibrary('wtsapi32.dll'); + if _WtsLib = 0 then Exit; + _WTSRegister := GetProcAddress(_WtsLib, 'WTSRegisterSessionNotification'); + _WTSUnregister := GetProcAddress(_WtsLib, 'WTSUnRegisterSessionNotification'); +end; + +// ============================================================================= +// TSecureClipboard +// ============================================================================= + +constructor TSecureClipboard.Create; +begin + inherited; + FClearTimer := TTimer.Create(nil); + FClearTimer.Enabled := False; + FClearTimer.OnTimer := ClearTimerTick; +end; + +destructor TSecureClipboard.Destroy; +begin + FClearTimer.Free; + inherited; +end; + +procedure TSecureClipboard.ClearTimerTick(Sender: TObject); +begin + FClearTimer.Enabled := False; + Clear; +end; + +procedure TSecureClipboard.SetText(const AText: string; AClearAfterMs: Integer); +var + CFExclude: UINT; + LMem: THandle; // HGLOBAL — renamed to avoid Pascal's case-insensitive + // collision with the HGLOBAL type identifier. + LDest: Pointer; + LByteCount: NativeUInt; +begin + FClearTimer.Enabled := False; + + // Register (or look up if already registered) the exclusion format. + CFExclude := RegisterClipboardFormat(CLIPBOARD_EXCLUDE_FORMAT); + + LByteCount := NativeUInt(Length(AText) + 1) * SizeOf(Char); + LMem := GlobalAlloc(GMEM_MOVEABLE, LByteCount); + if LMem = 0 then Exit; + + LDest := GlobalLock(LMem); + try + Move(PChar(AText)^, LDest^, LByteCount); + finally + GlobalUnlock(LMem); + end; + + if not OpenClipboard(0) then + begin + GlobalFree(LMem); + Exit; + end; + try + EmptyClipboard; + // CF_UNICODETEXT ownership is transferred to the clipboard on success. + if SetClipboardData(CF_UNICODETEXT, LMem) = 0 then + GlobalFree(LMem); + // Exclusion marker: presence of this format is the signal to Windows; + // the data handle is nil and ignored by the subsystem. + SetClipboardData(CFExclude, 0); + finally + CloseClipboard; + end; + + if AClearAfterMs > 0 then + begin + FClearTimer.Interval := AClearAfterMs; + FClearTimer.Enabled := True; + end; +end; + +procedure TSecureClipboard.Clear; +begin + if OpenClipboard(0) then + try + EmptyClipboard; + finally + CloseClipboard; + end; +end; + +// ============================================================================= +// TPMBridge +// ============================================================================= + +constructor TPMBridge.Create(AMainForm: TForm); +begin + inherited Create; + FMainForm := AMainForm; + FSecureClipboard := TSecureClipboard.Create; + FTrayAdded := False; + FBalloonShown := False; + + // Dedicated message-only window for tray + WTS notifications. + FMsgWindow := AllocateHWnd(MsgWindowHandler); + + PrepareNid; + + // Session-lock detection (fails silently if wtsapi32.dll is absent). + LoadWtsApi; + if Assigned(_WTSRegister) then + _WTSRegister(FMsgWindow, NOTIFY_FOR_THIS_SESSION); +end; + +destructor TPMBridge.Destroy; +begin + if Assigned(_WTSUnregister) then + _WTSUnregister(FMsgWindow); + + if FTrayAdded then + begin + Shell_NotifyIcon(NIM_DELETE, @FNid); + FTrayAdded := False; + end; + + if FIconOwned and (FIconHandle <> 0) then + DestroyIcon(FIconHandle); + + DeallocateHWnd(FMsgWindow); + FSecureClipboard.Free; + inherited; +end; + +procedure TPMBridge.PrepareNid; +var + LargeIcon, SmallIcon: HICON; +begin + // Attempt to extract the small (16×16) icon from the exe. + // ExtractIconEx returns the number of icons extracted. + LargeIcon := 0; + SmallIcon := 0; + FIconOwned := False; + if ExtractIconEx(PChar(ParamStr(0)), 0, LargeIcon, SmallIcon, 1) > 0 then + begin + if LargeIcon <> 0 then DestroyIcon(LargeIcon); // we only need the small one + if SmallIcon <> 0 then + begin + FIconHandle := SmallIcon; + FIconOwned := True; + end; + end; + if FIconHandle = 0 then + FIconHandle := LoadIcon(0, IDI_APPLICATION); // shared system icon, never destroy + + FillChar(FNid, SizeOf(FNid), 0); + FNid.cbSize := SizeOf(FNid); + FNid.Wnd := FMsgWindow; + FNid.uID := 1; + FNid.uFlags := NIF_ICON or NIF_MESSAGE or NIF_TIP; + FNid.uCallbackMessage := WM_TRAY_ICON; + FNid.hIcon := FIconHandle; + // szTip: array[0..127] of WideChar — copy tooltip text safely. + Move(PChar('Password Manager')^, FNid.szTip[0], + Min(Length('Password Manager'), High(FNid.szTip)) * SizeOf(Char)); +end; + +function MainFormHWND(AForm: TForm): HWND; +begin + Result := WindowHandleToPlatform(AForm.Handle).Wnd; +end; + +function TPMBridge.FindFMXAppWindow: HWND; +var + LWnd: HWND; + LWndPid, LCurrentPid: DWORD; +begin + // FMX on Windows creates a hidden per-process window of class "TFMAppClass" + // that owns the application's taskbar entry — NOT the form's HWND. + // Hiding the form (via ShowWindow / Visible := False / WS_EX_TOOLWINDOW / + // ITaskbarList.DeleteTab) is therefore ineffective at removing the taskbar + // entry: those calls target the wrong window. The correct fix is to find + // the TFMAppClass window owned by our process and hide IT. + // Reference: https://stackoverflow.com/q/16768986 + Result := 0; + LCurrentPid := GetCurrentProcessId; + LWnd := 0; + repeat + LWnd := FindWindowEx(0, LWnd, 'TFMAppClass', nil); + if LWnd <> 0 then + begin + LWndPid := 0; + GetWindowThreadProcessId(LWnd, LWndPid); + if LWndPid = LCurrentPid then + Exit(LWnd); + end; + until LWnd = 0; +end; + +procedure TPMBridge.MinimizeToTray; +var + LFormHwnd, LAppHwnd: HWND; +begin + if not FTrayAdded then + begin + if Shell_NotifyIcon(NIM_ADD, @FNid) then + FTrayAdded := True; + end; + + // Extra safety: clear the clipboard immediately when the user minimizes, + // rather than waiting for the 30s auto-clear timer to fire. A password + // the user just copied shouldn't sit in the clipboard while the app is + // out of sight. + FSecureClipboard.Clear; + + LFormHwnd := MainFormHWND(FMainForm); + LAppHwnd := FindFMXAppWindow; + + // 1. Hide the visible form via both FMX state and Win32 ShowWindow. + // Keeps the form invisible to the user. + FMainForm.Hide; + ShowWindow(LFormHwnd, SW_HIDE); + + // 2. Hide the FMX application proxy window (TFMAppClass). THIS is what + // removes the entry from the taskbar — the form's HWND was never the + // taskbar-visible one in FMX. + if LAppHwnd <> 0 then + ShowWindow(LAppHwnd, SW_HIDE); + + // 3. First-time only: pop a balloon notification so the user knows the + // app is still running in the tray (and didn't crash). + if not FBalloonShown then + begin + ShowFirstTimeBalloon; + FBalloonShown := True; + end; +end; + +procedure TPMBridge.ShowFirstTimeBalloon; +var + LBalloon: TNotifyIconData; +const + BALLOON_TITLE = 'Password Manager'; + BALLOON_TEXT = 'Still running in the tray — click the icon to restore, ' + + 'right-click for menu.'; +begin + // Build a separate TNotifyIconData with NIF_INFO set, NIM_MODIFY on the + // same uID. szInfo/szInfoTitle carry the balloon content. NIIF_INFO + // gives the system info icon — no scary warning glyph. + FillChar(LBalloon, SizeOf(LBalloon), 0); + LBalloon.cbSize := SizeOf(LBalloon); + LBalloon.Wnd := FMsgWindow; + LBalloon.uID := 1; + LBalloon.uFlags := NIF_INFO; + Move(PChar(BALLOON_TITLE)^, LBalloon.szInfoTitle[0], + Min(Length(BALLOON_TITLE), High(LBalloon.szInfoTitle)) * SizeOf(Char)); + Move(PChar(BALLOON_TEXT)^, LBalloon.szInfo[0], + Min(Length(BALLOON_TEXT), High(LBalloon.szInfo)) * SizeOf(Char)); + LBalloon.dwInfoFlags := NIIF_INFO; + Shell_NotifyIcon(NIM_MODIFY, @LBalloon); +end; + +procedure TPMBridge.RestoreFromTray; +var + LFormHwnd, LAppHwnd: HWND; +begin + if FTrayAdded then + begin + Shell_NotifyIcon(NIM_DELETE, @FNid); + FTrayAdded := False; + end; + + LFormHwnd := MainFormHWND(FMainForm); + LAppHwnd := FindFMXAppWindow; + + // Reverse order: show the app proxy first so the taskbar entry comes back, + // then show and foreground the form. + if LAppHwnd <> 0 then + ShowWindow(LAppHwnd, SW_SHOW); + + FMainForm.Show; + ShowWindow(LFormHwnd, SW_SHOW); + ShowWindow(LFormHwnd, SW_RESTORE); + SetForegroundWindow(LFormHwnd); +end; + +procedure TPMBridge.ShowTrayMenu; +const + ID_OPEN = 1; + ID_LOCK = 2; + ID_QUIT = 3; +var + LMenu: HMENU; + LPt: TPoint; + LCmd: Cardinal; +begin + LMenu := CreatePopupMenu; + if LMenu = 0 then Exit; + try + AppendMenu(LMenu, MF_STRING, ID_OPEN, 'Open'); + AppendMenu(LMenu, MF_STRING, ID_LOCK, 'Lock vault'); + AppendMenu(LMenu, MF_SEPARATOR, 0, nil); + AppendMenu(LMenu, MF_STRING, ID_QUIT, 'Quit'); + + GetCursorPos(LPt); + // SetForegroundWindow + WM_NULL post is the canonical Win32 workaround + // that lets TrackPopupMenu auto-dismiss when the user clicks elsewhere. + // Without it, the menu can become "sticky" on a hidden window. + SetForegroundWindow(FMsgWindow); + // Delphi's TrackPopupMenu is declared as returning BOOL, but with + // TPM_RETURNCMD it actually returns the selected menu item ID (or 0). + // Cast through the declared return type to read the real value. + LCmd := Cardinal(TrackPopupMenu(LMenu, + TPM_RETURNCMD or TPM_RIGHTBUTTON or TPM_NONOTIFY, + LPt.X, LPt.Y, 0, FMsgWindow, nil)); + PostMessage(FMsgWindow, WM_NULL, 0, 0); + + case LCmd of + ID_OPEN: if Assigned(FOnTrayRestore) then FOnTrayRestore(); + ID_LOCK: if Assigned(FOnLockRequest) then FOnLockRequest(); + ID_QUIT: if Assigned(FOnQuit) then FOnQuit(); + end; + finally + DestroyMenu(LMenu); + end; +end; + +procedure TPMBridge.MsgWindowHandler(var AMsg: TMessage); +var + LMouseEvent: Word; +begin + // AllocateHWnd creates the window on the thread that called it (here: the + // main thread, since TPMBridge.Create runs from FormCreate). Windows + // dispatches messages on the owning thread, so this handler is already + // on the main thread — no need to marshal via TThread.Queue/ForceQueue. + if AMsg.Msg = WM_TRAY_ICON then + begin + // For Shell_NotifyIcon callback messages, the mouse event is in the + // low word of LParam (regardless of NOTIFYICON_VERSION). Extracting + // it via LOWORD is more portable than comparing the full LPARAM. + LMouseEvent := Word(AMsg.LParam and $FFFF); + if (LMouseEvent = WM_LBUTTONUP) or (LMouseEvent = WM_LBUTTONDBLCLK) then + begin + if Assigned(FOnTrayRestore) then FOnTrayRestore(); + end + else if (LMouseEvent = WM_RBUTTONUP) or (LMouseEvent = WM_CONTEXTMENU) then + begin + ShowTrayMenu; + end; + end + else if AMsg.Msg = WM_WTSSESSION_CHANGE then + begin + if AMsg.WParam = WTS_SESSION_LOCK then + if Assigned(FOnSystemLock) then FOnSystemLock(); + end; + + AMsg.Result := DefWindowProc(FMsgWindow, AMsg.Msg, AMsg.WParam, AMsg.LParam); +end; + +end. diff --git a/delphi-backend/Source/PM.Crypto.pas b/delphi-backend/Source/PM.Crypto.pas new file mode 100644 index 0000000..b5cc5bb --- /dev/null +++ b/delphi-backend/Source/PM.Crypto.pas @@ -0,0 +1,269 @@ +unit PM.Crypto; + +{ + Cryptographic primitives for the password manager backend. + + - RandomBytes: cryptographically secure via Windows CNG (BCryptGenRandom) + - SHA256Hex / SHA256Bytes: identical to PHP hash('sha256', ...) + - PBKDF2_SHA256_Hex: identical to PHP hash_pbkdf2('sha256', pwd, salt, iters) + - ConstantTimeEquals: timing-safe comparison (api.php uses hash_equals()) + - BytesToHex / HexToBytes: PHP bin2hex / hex2bin equivalents + + NOTE on bcrypt: PHP api.php hashes new passwords with PASSWORD_BCRYPT. + This Delphi backend does NOT implement bcrypt verification yet (would take + ~250 lines for Blowfish + EKS). Accounts created here use pbkdf2 — which + PHP knows how to verify and migrate. Bcrypt accounts from PHP cannot login + here yet; the auth handler returns a clear error in that case. +} + +interface + +uses + System.SysUtils, System.Classes, System.NetEncoding, + System.Hash; + +function RandomBytes(ALen: Integer): TBytes; +function RandomHex(AByteLen: Integer): string; + +function BytesToHex(const ABytes: TBytes): string; +function HexToBytes(const AHex: string): TBytes; + +function SHA256Hex(const AInput: string): string; overload; +function SHA256Hex(const AInput: TBytes): string; overload; + +function PBKDF2_SHA256_Hex(const APassword, ASaltHex: string; + AIterations: Integer; ADKLenBytes: Integer = 32): string; + +// Sanity check at unit initialization — verifies PBKDF2-HMAC-SHA256 matches +// the reference (PHP-equivalent) output. Raises if implementation drifts. +procedure SelfTestCrypto; + +function ConstantTimeEquals(const A, B: string): Boolean; + +implementation + +uses + Winapi.Windows; + +// ===== Windows CNG random ==================================================== + +const + BCRYPT_USE_SYSTEM_PREFERRED_RNG = $00000002; + +function BCryptGenRandom(hAlgorithm: Pointer; pbBuffer: PByte; + cbBuffer: ULONG; dwFlags: ULONG): NTSTATUS; stdcall; + external 'bcrypt.dll' name 'BCryptGenRandom'; + +function RandomBytes(ALen: Integer): TBytes; +var + LStatus: NTSTATUS; +begin + SetLength(Result, ALen); + if ALen = 0 then Exit; + LStatus := BCryptGenRandom(nil, @Result[0], ALen, + BCRYPT_USE_SYSTEM_PREFERRED_RNG); + if LStatus <> 0 then + raise Exception.CreateFmt('BCryptGenRandom failed (0x%x)', [LStatus]); +end; + +function RandomHex(AByteLen: Integer): string; +begin + Result := BytesToHex(RandomBytes(AByteLen)); +end; + +// ===== Hex helpers (PHP bin2hex / hex2bin) =================================== + +function BytesToHex(const ABytes: TBytes): string; +const + HEX: array[0..15] of Char = + ('0','1','2','3','4','5','6','7','8','9','a','b','c','d','e','f'); +var + I: Integer; +begin + SetLength(Result, Length(ABytes) * 2); + for I := 0 to High(ABytes) do + begin + Result[(I * 2) + 1] := HEX[ABytes[I] shr 4]; + Result[(I * 2) + 2] := HEX[ABytes[I] and $0F]; + end; +end; + +function HexCharToInt(C: Char): Integer; inline; +begin + case C of + '0'..'9': Result := Ord(C) - Ord('0'); + 'a'..'f': Result := Ord(C) - Ord('a') + 10; + 'A'..'F': Result := Ord(C) - Ord('A') + 10; + else + raise Exception.Create('Invalid hex character: ' + C); + end; +end; + +function HexToBytes(const AHex: string): TBytes; +var + I, LLen: Integer; +begin + LLen := Length(AHex); + if Odd(LLen) then + raise Exception.Create('Hex string has odd length'); + SetLength(Result, LLen div 2); + for I := 0 to High(Result) do + Result[I] := (HexCharToInt(AHex[(I * 2) + 1]) shl 4) or + HexCharToInt(AHex[(I * 2) + 2]); +end; + +// ===== SHA256 ================================================================ + +function SHA256Hex(const AInput: string): string; +begin + Result := LowerCase(THashSHA2.GetHashString(AInput, THashSHA2.TSHA2Version.SHA256)); +end; + +function SHA256Hex(const AInput: TBytes): string; +var + H: THashSHA2; +begin + H := THashSHA2.Create(THashSHA2.TSHA2Version.SHA256); + if Length(AInput) > 0 then + H.Update(AInput); + Result := LowerCase(BytesToHex(H.HashAsBytes)); +end; + +// ===== PBKDF2-SHA256 ========================================================= +// Matches PHP hash_pbkdf2('sha256', $password, $salt, $iterations) which +// returns lowercase hex. $salt is whatever bytes you pass — api.php stores +// salt as bin2hex(random_bytes(32)), then passes that hex STRING as the salt +// argument to hash_pbkdf2. So the "salt" fed to PBKDF2 is the 64-char hex +// representation, NOT the 32 raw bytes. We replicate that quirk here. + +// Manual HMAC-SHA256 — avoid any ambiguity with System.Hash overloads. +// Verified against RFC 4231 test vectors. +function HMAC_SHA256(const AKey, AMsg: TBytes): TBytes; +const + BLOCK = 64; // SHA256 block size in bytes +var + LKey, LIpad, LOpad, LInner: TBytes; + I: Integer; + H: THashSHA2; +begin + // Step 1: derive working key + LKey := Copy(AKey, 0, Length(AKey)); + if Length(LKey) > BLOCK then + begin + H := THashSHA2.Create(THashSHA2.TSHA2Version.SHA256); + H.Update(LKey); + LKey := H.HashAsBytes; + end; + if Length(LKey) < BLOCK then + SetLength(LKey, BLOCK); // zero-padded to block size + + // Step 2: inner & outer pads + SetLength(LIpad, BLOCK); + SetLength(LOpad, BLOCK); + for I := 0 to BLOCK - 1 do + begin + LIpad[I] := LKey[I] xor $36; + LOpad[I] := LKey[I] xor $5C; + end; + + // Step 3: inner = SHA256(ipad || msg) + H := THashSHA2.Create(THashSHA2.TSHA2Version.SHA256); + H.Update(LIpad); + if Length(AMsg) > 0 then H.Update(AMsg); + LInner := H.HashAsBytes; + + // Step 4: result = SHA256(opad || inner) + H := THashSHA2.Create(THashSHA2.TSHA2Version.SHA256); + H.Update(LOpad); + H.Update(LInner); + Result := H.HashAsBytes; +end; + +function PBKDF2_SHA256_Hex(const APassword, ASaltHex: string; + AIterations: Integer; ADKLenBytes: Integer): string; +var + LPwd, LSalt, LU, LT, LBlock: TBytes; + LBlocks, I, J, K: Integer; + LCtr: array[0..3] of Byte; + LOut: TBytes; +begin + LPwd := TEncoding.UTF8.GetBytes(APassword); + // PHP behavior: pass salt argument as-is. api.php passes the hex string, + // so HMAC sees the ascii bytes of the hex. + LSalt := TEncoding.UTF8.GetBytes(ASaltHex); + + LBlocks := (ADKLenBytes + 31) div 32; // SHA256 block = 32 bytes + SetLength(LOut, 0); + + for I := 1 to LBlocks do + begin + LCtr[0] := (I shr 24) and $FF; + LCtr[1] := (I shr 16) and $FF; + LCtr[2] := (I shr 8) and $FF; + LCtr[3] := I and $FF; + + SetLength(LBlock, Length(LSalt) + 4); + if Length(LSalt) > 0 then + Move(LSalt[0], LBlock[0], Length(LSalt)); + Move(LCtr[0], LBlock[Length(LSalt)], 4); + + LU := HMAC_SHA256(LPwd, LBlock); + LT := Copy(LU, 0, Length(LU)); + + for J := 2 to AIterations do + begin + LU := HMAC_SHA256(LPwd, LU); + for K := 0 to High(LT) do + LT[K] := LT[K] xor LU[K]; + end; + + LOut := LOut + LT; + end; + + SetLength(LOut, ADKLenBytes); + Result := BytesToHex(LOut); +end; + +// ===== Timing-safe compare =================================================== + +function ConstantTimeEquals(const A, B: string): Boolean; +var + I, LDiff, LLen: Integer; +begin + LLen := Length(A); + if Length(B) <> LLen then Exit(False); + LDiff := 0; + for I := 1 to LLen do + LDiff := LDiff or (Ord(A[I]) xor Ord(B[I])); + Result := LDiff = 0; +end; + +// ===== Self-test ============================================================= + +procedure SelfTestCrypto; +const + // Test vector: password='password', salt='salt', iters=1, dkLen=32, sha256 + // Independently verified: matches PHP hash_pbkdf2('sha256','password','salt',1) + EXPECTED_1 = '120fb6cffcf8b32c43e7225256c4f837a86548c92ccc35480805987cb70be17b'; + // Same with iterations=2 + EXPECTED_2 = 'ae4d0c95af6b46d32d0adff928f06dd02a303f8ef3c251dfd6e2d85a95474c43'; +var + Got1, Got2: string; +begin + Got1 := PBKDF2_SHA256_Hex('password', 'salt', 1, 32); + if not SameText(Got1, EXPECTED_1) then + raise Exception.CreateFmt( + 'PBKDF2 self-test FAILED (iters=1):'#10' expected %s'#10' got %s', + [EXPECTED_1, Got1]); + + Got2 := PBKDF2_SHA256_Hex('password', 'salt', 2, 32); + if not SameText(Got2, EXPECTED_2) then + raise Exception.CreateFmt( + 'PBKDF2 self-test FAILED (iters=2):'#10' expected %s'#10' got %s', + [EXPECTED_2, Got2]); +end; + +initialization + SelfTestCrypto; + +end. diff --git a/delphi-backend/Source/PM.Database.pas b/delphi-backend/Source/PM.Database.pas new file mode 100644 index 0000000..7d9b316 --- /dev/null +++ b/delphi-backend/Source/PM.Database.pas @@ -0,0 +1,229 @@ +unit PM.Database; + +{ + SQLite connection (FireDAC) toward the shared vault.db file. + CreateSchema mirrors api.php (CREATE TABLE IF NOT EXISTS + ALTER migrations). + Per-thread connection is NOT implemented yet — single connection guarded by + TMonitor. Indy's TIdHTTPServer is thread-per-connection, so we serialize DB + access for safety until we move to a connection pool. +} + +interface + +uses + System.SysUtils, System.Classes, System.IOUtils, System.SyncObjs, + FireDAC.Comp.Client, FireDAC.Stan.Def, FireDAC.Stan.Async, + FireDAC.Phys.SQLite, FireDAC.DApt, FireDAC.Stan.Param, + FireDAC.FMXUI.Wait, FireDAC.Stan.Intf, FireDAC.UI.Intf, + Data.DB; + +type + TPMDatabase = class + private + FConn: TFDConnection; + FLock: TCriticalSection; + FDBPath: string; + function ColumnExists(const ATable, AColumn: string): Boolean; + procedure AddColumnIfMissing(const ATable, AColumn, ADef: string); + procedure CreateSchema; + procedure ApplyMigrations; + procedure CleanupExpired; + public + constructor Create(const ADBPath: string); + destructor Destroy; override; + procedure Lock; + procedure Unlock; + property Connection: TFDConnection read FConn; + property DBPath: string read FDBPath; + end; + +var + DB: TPMDatabase; + +procedure InitDatabase(const ADBPath: string); +procedure DoneDatabase; + +implementation + +constructor TPMDatabase.Create(const ADBPath: string); +begin + inherited Create; + FDBPath := ADBPath; + FLock := TCriticalSection.Create; + FConn := TFDConnection.Create(nil); + FConn.DriverName := 'SQLite'; + FConn.Params.Values['Database'] := FDBPath; + FConn.Params.Values['LockingMode'] := 'Normal'; + FConn.Params.Values['Synchronous'] := 'Normal'; + FConn.Params.Values['BusyTimeout'] := '5000'; + FConn.Params.Values['JournalMode'] := 'WAL'; + FConn.Open; + CreateSchema; + ApplyMigrations; + CleanupExpired; +end; + +destructor TPMDatabase.Destroy; +begin + FConn.Free; + FLock.Free; + inherited; +end; + +procedure TPMDatabase.Lock; +begin + FLock.Enter; +end; + +procedure TPMDatabase.Unlock; +begin + FLock.Leave; +end; + +procedure TPMDatabase.CreateSchema; +begin + FConn.ExecSQL( + 'CREATE TABLE IF NOT EXISTS users (' + + ' id INTEGER PRIMARY KEY AUTOINCREMENT,' + + ' username TEXT UNIQUE NOT NULL,' + + ' password_hash TEXT NOT NULL,' + + ' salt TEXT NOT NULL,' + + ' created_at DATETIME DEFAULT CURRENT_TIMESTAMP,' + + ' hash_algo TEXT DEFAULT ''pbkdf2''' + + ')'); + FConn.ExecSQL( + 'CREATE TABLE IF NOT EXISTS folders (' + + ' id INTEGER PRIMARY KEY AUTOINCREMENT,' + + ' user_id INTEGER NOT NULL,' + + ' name TEXT NOT NULL,' + + ' created_at DATETIME DEFAULT CURRENT_TIMESTAMP,' + + ' FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,' + + ' UNIQUE(user_id, name)' + + ')'); + FConn.ExecSQL( + 'CREATE TABLE IF NOT EXISTS vault_entries (' + + ' id INTEGER PRIMARY KEY AUTOINCREMENT,' + + ' user_id INTEGER NOT NULL,' + + ' site TEXT NOT NULL,' + + ' username TEXT NOT NULL,' + + ' encrypted_password TEXT NOT NULL,' + + ' iv TEXT NOT NULL,' + + ' encryption_method TEXT DEFAULT ''server'',' + + ' folder TEXT DEFAULT ''All'',' + + ' deleted INTEGER DEFAULT 0,' + + ' deleted_at DATETIME,' + + ' favorite INTEGER DEFAULT 0,' + + ' created_at DATETIME DEFAULT CURRENT_TIMESTAMP,' + + ' updated_at DATETIME DEFAULT CURRENT_TIMESTAMP' + + ')'); + FConn.ExecSQL( + 'CREATE TABLE IF NOT EXISTS sessions (' + + ' id INTEGER PRIMARY KEY AUTOINCREMENT,' + + ' user_id INTEGER NOT NULL,' + + ' token_hash TEXT UNIQUE NOT NULL,' + + ' csrf_token TEXT,' + + ' created_at DATETIME DEFAULT CURRENT_TIMESTAMP,' + + ' expires_at DATETIME NOT NULL,' + + ' FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE' + + ')'); + FConn.ExecSQL( + 'CREATE TABLE IF NOT EXISTS login_attempts (' + + ' id INTEGER PRIMARY KEY AUTOINCREMENT,' + + ' ip TEXT NOT NULL,' + + ' attempted_at DATETIME DEFAULT CURRENT_TIMESTAMP' + + ')'); + FConn.ExecSQL( + 'CREATE TABLE IF NOT EXISTS audit_log (' + + ' id INTEGER PRIMARY KEY AUTOINCREMENT,' + + ' user_id INTEGER,' + + ' action TEXT NOT NULL,' + + ' ip TEXT,' + + ' created_at DATETIME DEFAULT CURRENT_TIMESTAMP' + + ')'); + FConn.ExecSQL( + 'CREATE TABLE IF NOT EXISTS passkey_challenges (' + + ' id INTEGER PRIMARY KEY AUTOINCREMENT,' + + ' user_id INTEGER,' + + ' challenge BLOB NOT NULL,' + + ' type TEXT NOT NULL,' + + ' created_at DATETIME DEFAULT CURRENT_TIMESTAMP' + + ')'); + FConn.ExecSQL( + 'CREATE TABLE IF NOT EXISTS passkey_credentials (' + + ' id INTEGER PRIMARY KEY AUTOINCREMENT,' + + ' user_id INTEGER NOT NULL,' + + ' credential_id BLOB NOT NULL UNIQUE,' + + ' public_key BLOB NOT NULL,' + + ' counter INTEGER DEFAULT 0,' + + ' created_at DATETIME DEFAULT CURRENT_TIMESTAMP,' + + ' FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE' + + ')'); +end; + +function TPMDatabase.ColumnExists(const ATable, AColumn: string): Boolean; +var + LQ: TFDQuery; +begin + Result := False; + LQ := TFDQuery.Create(nil); + try + LQ.Connection := FConn; + // PRAGMA table_info returns one row per column with name in column 'name' + LQ.SQL.Text := 'PRAGMA table_info(' + ATable + ')'; + LQ.Open; + while not LQ.Eof do + begin + if SameText(LQ.FieldByName('name').AsString, AColumn) then + Exit(True); + LQ.Next; + end; + finally + LQ.Free; + end; +end; + +procedure TPMDatabase.AddColumnIfMissing(const ATable, AColumn, ADef: string); +begin + if not ColumnExists(ATable, AColumn) then + FConn.ExecSQL('ALTER TABLE ' + ATable + ' ADD COLUMN ' + AColumn + ' ' + ADef); +end; + +procedure TPMDatabase.ApplyMigrations; +begin + // Idempotent: only ALTER when the column is actually missing — no exception + // bubbling up to the debugger like api.php's try/catch did. + AddColumnIfMissing('vault_entries', 'encryption_method', 'TEXT DEFAULT ''server'''); + AddColumnIfMissing('vault_entries', 'folder', 'TEXT DEFAULT ''All'''); + AddColumnIfMissing('vault_entries', 'deleted', 'INTEGER DEFAULT 0'); + AddColumnIfMissing('vault_entries', 'deleted_at', 'DATETIME'); + AddColumnIfMissing('vault_entries', 'favorite', 'INTEGER DEFAULT 0'); + // UI V2: tags stored as comma-separated TEXT (e.g. "work,important,2fa"). + // Simple format, search via LIKE %tag%. Frontend handles parsing/joining. + AddColumnIfMissing('vault_entries', 'tags', 'TEXT DEFAULT '''''); + AddColumnIfMissing('users', 'hash_algo', 'TEXT DEFAULT ''pbkdf2'''); + AddColumnIfMissing('sessions', 'csrf_token', 'TEXT'); +end; + +procedure TPMDatabase.CleanupExpired; +begin + FConn.ExecSQL('DELETE FROM sessions WHERE expires_at < datetime(''now'')'); + FConn.ExecSQL('DELETE FROM login_attempts WHERE attempted_at < datetime(''now'', ''-15 minutes'')'); + FConn.ExecSQL('DELETE FROM audit_log WHERE created_at < datetime(''now'', ''-30 days'')'); + FConn.ExecSQL('DELETE FROM passkey_challenges WHERE created_at < datetime(''now'', ''-10 minutes'')'); +end; + +procedure InitDatabase(const ADBPath: string); +begin + if DB = nil then + DB := TPMDatabase.Create(ADBPath); +end; + +procedure DoneDatabase; +begin + FreeAndNil(DB); +end; + +initialization +finalization + DoneDatabase; +end. diff --git a/delphi-backend/Source/PM.EmbeddedAssets.pas b/delphi-backend/Source/PM.EmbeddedAssets.pas new file mode 100644 index 0000000..699f35c --- /dev/null +++ b/delphi-backend/Source/PM.EmbeddedAssets.pas @@ -0,0 +1,115 @@ +unit PM.EmbeddedAssets; + +(* + Serves the password-manager HTML/JS/CSS from Win32 RCDATA resources + embedded inside PMServer.exe by BuildAssets.ps1. + + Workflow: + 1. Edit ../../index.html, ../../js/*.js, ../../css/*.css + 2. Run delphi-backend/assets/BuildAssets.ps1 + 3. Rebuild — exe ships self-contained + 4. Run — TryServe pulls bytes from HInstance resources + + No disk I/O at runtime. No external files needed beside PMServer.exe. + + The manifest (URL path -> resource name) is generated alongside the .res: + delphi-backend/assets/assets.inc, included below via {$I}. If the file + does not exist (BuildAssets.ps1 never ran), the compile-time fallback + registers no assets and TryServe always returns False. +*) + +interface + +uses + System.SysUtils, System.Classes, + IdCustomHTTPServer; + +type + TEmbeddedAsset = record + UrlPath: string; + ResName: string; + end; + +function TryServeEmbedded(ARequest: TIdHTTPRequestInfo; + AResponse: TIdHTTPResponseInfo): Boolean; + +implementation + +uses + Winapi.Windows; + +// The manifest is auto-generated. A stub is checked in so the project +// compiles before BuildAssets.ps1 ever runs; running the script overwrites +// it with the real list. +{$I ..\assets\assets.inc} + +function MimeTypeFor(const AExt: string): string; +var + E: string; +begin + E := LowerCase(AExt); + if (E = '.html') or (E = '.htm') then Exit('text/html; charset=utf-8'); + if E = '.js' then Exit('application/javascript; charset=utf-8'); + if E = '.mjs' then Exit('application/javascript; charset=utf-8'); + if E = '.css' then Exit('text/css; charset=utf-8'); + if E = '.json' then Exit('application/json; charset=utf-8'); + if E = '.svg' then Exit('image/svg+xml'); + if E = '.png' then Exit('image/png'); + if E = '.jpg' then Exit('image/jpeg'); + if E = '.jpeg' then Exit('image/jpeg'); + if E = '.gif' then Exit('image/gif'); + if E = '.ico' then Exit('image/x-icon'); + if E = '.woff' then Exit('font/woff'); + if E = '.woff2' then Exit('font/woff2'); + Result := 'application/octet-stream'; +end; + +function FindResourceFor(const AUrlPath: string; out AResName: string): Boolean; +var + I: Integer; + LPath: string; +begin + LPath := AUrlPath; + if (LPath = '') or (LPath = '/') then LPath := '/index.html'; + for I := 0 to EMBEDDED_ASSET_COUNT - 1 do + if SameText(EMBEDDED_ASSETS[I].UrlPath, LPath) then + begin + AResName := EMBEDDED_ASSETS[I].ResName; + Exit(True); + end; + Result := False; +end; + +function TryServeEmbedded(ARequest: TIdHTTPRequestInfo; + AResponse: TIdHTTPResponseInfo): Boolean; +var + LResName, LExt: string; + LStream: TResourceStream; + LMS: TMemoryStream; +begin + Result := False; + if not SameText(ARequest.Command, 'GET') then Exit; + if not FindResourceFor(ARequest.Document, LResName) then Exit; + if FindResource(HInstance, PChar(LResName), RT_RCDATA) = 0 then Exit; + + LExt := ExtractFileExt(ARequest.Document); + if (LExt = '') and ((ARequest.Document = '') or (ARequest.Document = '/')) then + LExt := '.html'; + AResponse.ContentType := MimeTypeFor(LExt); + + LStream := TResourceStream.Create(HInstance, LResName, RT_RCDATA); + try + // Copy into a TMemoryStream so Indy can own and free it after the response. + LMS := TMemoryStream.Create; + LMS.CopyFrom(LStream, 0); + LMS.Position := 0; + AResponse.ContentStream := LMS; + AResponse.FreeContentStream := True; + finally + LStream.Free; + end; + AResponse.ResponseNo := 200; + Result := True; +end; + +end. diff --git a/delphi-backend/Source/PM.HTTPServer.pas b/delphi-backend/Source/PM.HTTPServer.pas new file mode 100644 index 0000000..1ac68d6 --- /dev/null +++ b/delphi-backend/Source/PM.HTTPServer.pas @@ -0,0 +1,209 @@ +unit PM.HTTPServer; + +{ + Indy TIdHTTPServer wrapper. + - Binds 127.0.0.1 ONLY (hardcoded — never expose on LAN) + - Sets security headers (CSP, HSTS, CORS localhost) + - Handles OPTIONS preflight + - Dispatches to PM.Router; 404 if no match +} + +interface + +uses + System.SysUtils, System.Classes, System.IOUtils, + IdHTTPServer, IdContext, IdCustomHTTPServer, IdSocketHandle, + PM.Router, PM.JSON, PM.Database, PM.StaticFiles, PM.EmbeddedAssets; + +type + TLogProc = reference to procedure(const AMsg: string); + + TPMHTTPServer = class + private + FServer: TIdHTTPServer; + FOnLog: TLogProc; + procedure HandleCommand(AContext: TIdContext; + ARequest: TIdHTTPRequestInfo; AResponse: TIdHTTPResponseInfo); + procedure HandleCommandOther(AContext: TIdContext; + ARequest: TIdHTTPRequestInfo; AResponse: TIdHTTPResponseInfo); + procedure HandleParseAuthentication(AContext: TIdContext; + const AAuthType, AAuthData: string; + var VUsername, VPassword: string; var VHandled: Boolean); + procedure HandleException(AContext: TIdContext; AException: Exception); + procedure ApplySecurityHeaders(ARequest: TIdHTTPRequestInfo; + AResponse: TIdHTTPResponseInfo); + procedure Log(const AMsg: string); + function GetActive: Boolean; + public + constructor Create; + destructor Destroy; override; + procedure Start(APort: Integer); + procedure Stop; + property Active: Boolean read GetActive; + property OnLog: TLogProc read FOnLog write FOnLog; + end; + +implementation + +constructor TPMHTTPServer.Create; +begin + inherited; + FServer := TIdHTTPServer.Create(nil); + FServer.OnCommandGet := HandleCommand; + FServer.OnCommandOther := HandleCommandOther; + // Tell Indy NOT to raise EIdHTTPUnsupportedAuthorisationScheme on 'Bearer'. + // We parse the Authorization header ourselves in PM.Session. + FServer.OnParseAuthentication := HandleParseAuthentication; + // Swallow harmless socket disconnect exceptions (10053 / 10054) — Edge + // Chromium pre-fetches and cancels connections, which is normal but noisy + // under the debugger. + FServer.OnException := HandleException; +end; + +procedure TPMHTTPServer.HandleException(AContext: TIdContext; + AException: Exception); +begin + // EIdSocketError with 10053/10054 = client aborted, expected. Log everything + // else. + if (AException.ClassName = 'EIdSocketError') + or (AException.ClassName = 'EIdConnClosedGracefully') then + Exit; + Log('Server exception: ' + AException.ClassName + ' - ' + AException.Message); +end; + +procedure TPMHTTPServer.HandleParseAuthentication(AContext: TIdContext; + const AAuthType, AAuthData: string; + var VUsername, VPassword: string; var VHandled: Boolean); +begin + // Accept any scheme silently; we read the raw header ourselves. + VHandled := True; +end; + +destructor TPMHTTPServer.Destroy; +begin + Stop; + FServer.Free; + inherited; +end; + +function TPMHTTPServer.GetActive: Boolean; +begin + Result := Assigned(FServer) and FServer.Active; +end; + +procedure TPMHTTPServer.Log(const AMsg: string); +begin + if Assigned(FOnLog) then FOnLog(AMsg); +end; + +procedure TPMHTTPServer.Start(APort: Integer); +var + LBinding: TIdSocketHandle; + LDBPath, LWebRoot: string; +begin + if FServer.Active then Exit; + + // Resolve vault.db AND the web root (parent of the exe = Z:\password-manager\) + LDBPath := TPath.GetFullPath(TPath.Combine(ExtractFilePath(ParamStr(0)), '..\vault.db')); + LWebRoot := TPath.GetFullPath(TPath.Combine(ExtractFilePath(ParamStr(0)), '..\')); + Log('Opening database: ' + LDBPath); + InitDatabase(LDBPath); + Log('Database ready.'); + Log('Web root: ' + LWebRoot); + InitStaticServer(LWebRoot); + + FServer.Bindings.Clear; + LBinding := FServer.Bindings.Add; + LBinding.IP := '127.0.0.1'; + LBinding.Port := APort; + + FServer.Active := True; + Log('Server started on http://127.0.0.1:' + IntToStr(APort)); +end; + +procedure TPMHTTPServer.Stop; +begin + if not Assigned(FServer) then Exit; + if FServer.Active then + begin + FServer.Active := False; + Log('Server stopped.'); + end; +end; + +procedure TPMHTTPServer.ApplySecurityHeaders(ARequest: TIdHTTPRequestInfo; + AResponse: TIdHTTPResponseInfo); +var + LOrigin: string; +begin + AResponse.CustomHeaders.Values['Strict-Transport-Security'] := + 'max-age=31536000; includeSubDomains'; + AResponse.CustomHeaders.Values['Content-Security-Policy'] := + 'default-src ''self''; script-src ''self'' ''unsafe-inline''; ' + + 'style-src ''self'' ''unsafe-inline''; connect-src ''self''; ' + + 'img-src ''self'' data:; font-src ''self''; form-action ''self''; ' + + 'frame-ancestors ''none''; base-uri ''self''; object-src ''none'''; + AResponse.CustomHeaders.Values['X-Content-Type-Options'] := 'nosniff'; + AResponse.CustomHeaders.Values['Referrer-Policy'] := 'no-referrer'; + + // CORS — accept only localhost / 127.0.0.1 origins (any port) + LOrigin := ARequest.RawHeaders.Values['Origin']; + if (LOrigin <> '') and ( + (Pos('http://localhost', LOrigin) = 1) or + (Pos('http://127.0.0.1', LOrigin) = 1) or + (Pos('https://localhost', LOrigin) = 1) or + (Pos('https://127.0.0.1', LOrigin) = 1) + ) then + begin + AResponse.CustomHeaders.Values['Access-Control-Allow-Origin'] := LOrigin; + AResponse.CustomHeaders.Values['Access-Control-Allow-Methods'] := + 'GET, POST, PUT, DELETE, OPTIONS'; + AResponse.CustomHeaders.Values['Access-Control-Allow-Headers'] := + 'Content-Type, Authorization, X-CSRF-Token'; + end; +end; + +procedure TPMHTTPServer.HandleCommand(AContext: TIdContext; + ARequest: TIdHTTPRequestInfo; AResponse: TIdHTTPResponseInfo); +begin + ApplySecurityHeaders(ARequest, AResponse); + try + // Order: API route → embedded resource (production) → disk static (dev) → 404 + if Router.DispatchRequest(ARequest, AResponse) then Exit; + if TryServeEmbedded(ARequest, AResponse) then Exit; + if Assigned(StaticServer) and StaticServer.TryServe(ARequest, AResponse) then Exit; + TJSONHelper.SendError(AResponse, 404, 'Not found'); + except + on E: Exception do + begin + Log('ERROR ' + ARequest.Command + ' ' + ARequest.Document + ' : ' + E.Message); + TJSONHelper.SendError(AResponse, 500, 'Internal server error'); + end; + end; +end; + +procedure TPMHTTPServer.HandleCommandOther(AContext: TIdContext; + ARequest: TIdHTTPRequestInfo; AResponse: TIdHTTPResponseInfo); +begin + ApplySecurityHeaders(ARequest, AResponse); + // OPTIONS preflight + if SameText(ARequest.Command, 'OPTIONS') then + begin + AResponse.ResponseNo := 204; + AResponse.ContentText := ''; + Exit; + end; + // Routes for PUT / DELETE go through here in Indy + try + if not Router.DispatchRequest(ARequest, AResponse) then + TJSONHelper.SendError(AResponse, 404, 'Not found'); + except + on E: Exception do + begin + Log('ERROR ' + ARequest.Command + ' ' + ARequest.Document + ' : ' + E.Message); + TJSONHelper.SendError(AResponse, 500, 'Internal server error'); + end; + end; +end; + +end. diff --git a/delphi-backend/Source/PM.JSON.pas b/delphi-backend/Source/PM.JSON.pas new file mode 100644 index 0000000..ae9d757 --- /dev/null +++ b/delphi-backend/Source/PM.JSON.pas @@ -0,0 +1,78 @@ +unit PM.JSON; + +interface + +uses + System.SysUtils, System.Classes, System.JSON, IdCustomHTTPServer; + +type + TJSONHelper = class + public + class function ReadBody(ARequest: TIdHTTPRequestInfo): TJSONObject; + class procedure SendJSON(AResponse: TIdHTTPResponseInfo; AObj: TJSONValue; + ACode: Integer = 200; AOwnsObj: Boolean = True); + class procedure SendError(AResponse: TIdHTTPResponseInfo; ACode: Integer; + const AMsg: string); + class procedure SendOK(AResponse: TIdHTTPResponseInfo; const AMessage: string = 'OK'); + end; + +implementation + +class function TJSONHelper.ReadBody(ARequest: TIdHTTPRequestInfo): TJSONObject; +var + S: string; + LSS: TStringStream; + LValue: TJSONValue; +begin + Result := nil; + if ARequest.PostStream = nil then Exit(TJSONObject.Create); + LSS := TStringStream.Create('', TEncoding.UTF8); + try + ARequest.PostStream.Position := 0; + LSS.CopyFrom(ARequest.PostStream); + S := LSS.DataString; + finally + LSS.Free; + end; + if Trim(S) = '' then Exit(TJSONObject.Create); + LValue := TJSONObject.ParseJSONValue(S); + if LValue is TJSONObject then + Result := TJSONObject(LValue) + else + begin + LValue.Free; + Result := TJSONObject.Create; + end; +end; + +class procedure TJSONHelper.SendJSON(AResponse: TIdHTTPResponseInfo; + AObj: TJSONValue; ACode: Integer; AOwnsObj: Boolean); +begin + AResponse.ResponseNo := ACode; + AResponse.ContentType := 'application/json; charset=utf-8'; + AResponse.CharSet := 'utf-8'; + AResponse.ContentText := AObj.ToJSON; + if AOwnsObj then AObj.Free; +end; + +class procedure TJSONHelper.SendError(AResponse: TIdHTTPResponseInfo; + ACode: Integer; const AMsg: string); +var + LObj: TJSONObject; +begin + LObj := TJSONObject.Create; + LObj.AddPair('error', AMsg); + SendJSON(AResponse, LObj, ACode); +end; + +class procedure TJSONHelper.SendOK(AResponse: TIdHTTPResponseInfo; + const AMessage: string); +var + LObj: TJSONObject; +begin + LObj := TJSONObject.Create; + LObj.AddPair('message', AMessage); + SendJSON(AResponse, LObj); +end; + +end. diff --git a/delphi-backend/Source/PM.RateLimit.pas b/delphi-backend/Source/PM.RateLimit.pas new file mode 100644 index 0000000..61f2e2d --- /dev/null +++ b/delphi-backend/Source/PM.RateLimit.pas @@ -0,0 +1,94 @@ +unit PM.RateLimit; + +{ + Mirrors api.php checkRateLimit / recordAttempt / clearAttempts. + 15-minute window. Caller decides the threshold (5 for register, 10 for login). +} + +interface + +uses + System.SysUtils, FireDAC.Comp.Client, FireDAC.Stan.Param, IdCustomHTTPServer, + PM.Database; + +function GetClientIP(ARequest: TIdHTTPRequestInfo): string; +function CheckRateLimit(const AIP: string): Integer; +procedure RecordAttempt(const AIP: string); +procedure ClearAttempts(const AIP: string); + +implementation + +function GetClientIP(ARequest: TIdHTTPRequestInfo): string; +begin + // api.php trusts X-Forwarded-For (security flaw H1 in audit). Since this + // server is loopback-only and not behind a proxy, prefer the actual peer IP. + Result := ARequest.RemoteIP; + if Result = '' then Result := 'unknown'; +end; + +function CheckRateLimit(const AIP: string): Integer; +var + LQ: TFDQuery; +begin + Result := 0; + DB.Lock; + try + LQ := TFDQuery.Create(nil); + try + LQ.Connection := DB.Connection; + LQ.SQL.Text := + 'SELECT COUNT(*) AS cnt FROM login_attempts ' + + 'WHERE ip = :ip ' + + 'AND attempted_at > datetime(''now'', ''-15 minutes'')'; + LQ.ParamByName('ip').AsString := AIP; + LQ.Open; + Result := LQ.FieldByName('cnt').AsInteger; + finally + LQ.Free; + end; + finally + DB.Unlock; + end; +end; + +procedure RecordAttempt(const AIP: string); +var + LQ: TFDQuery; +begin + DB.Lock; + try + LQ := TFDQuery.Create(nil); + try + LQ.Connection := DB.Connection; + LQ.SQL.Text := 'INSERT INTO login_attempts (ip) VALUES (:ip)'; + LQ.ParamByName('ip').AsString := AIP; + LQ.ExecSQL; + finally + LQ.Free; + end; + finally + DB.Unlock; + end; +end; + +procedure ClearAttempts(const AIP: string); +var + LQ: TFDQuery; +begin + DB.Lock; + try + LQ := TFDQuery.Create(nil); + try + LQ.Connection := DB.Connection; + LQ.SQL.Text := 'DELETE FROM login_attempts WHERE ip = :ip'; + LQ.ParamByName('ip').AsString := AIP; + LQ.ExecSQL; + finally + LQ.Free; + end; + finally + DB.Unlock; + end; +end; + +end. diff --git a/delphi-backend/Source/PM.Router.pas b/delphi-backend/Source/PM.Router.pas new file mode 100644 index 0000000..ffbc940 --- /dev/null +++ b/delphi-backend/Source/PM.Router.pas @@ -0,0 +1,104 @@ +unit PM.Router; + +{ + URL dispatcher. Mirrors the switch(true) pattern of api.php. + Each handler unit registers its routes here. The Router itself owns no state. +} + +interface + +uses + System.SysUtils, System.Classes, System.Generics.Collections, + System.RegularExpressions, + IdCustomHTTPServer; + +type + TRouteParams = TArray; + + TRouteHandler = reference to procedure( + ARequest: TIdHTTPRequestInfo; + AResponse: TIdHTTPResponseInfo; + const AParams: TRouteParams); + + TRoute = record + Method: string; + Pattern: string; // regex; ^ and $ added automatically + Regex: TRegEx; + Handler: TRouteHandler; + end; + + TPMRouter = class + private + FRoutes: TList; + public + constructor Create; + destructor Destroy; override; + procedure Register(const AMethod, APattern: string; + const AHandler: TRouteHandler); + function DispatchRequest(ARequest: TIdHTTPRequestInfo; + AResponse: TIdHTTPResponseInfo): Boolean; + end; + +var + Router: TPMRouter; + +implementation + +constructor TPMRouter.Create; +begin + inherited; + FRoutes := TList.Create; +end; + +destructor TPMRouter.Destroy; +begin + FRoutes.Free; + inherited; +end; + +procedure TPMRouter.Register(const AMethod, APattern: string; + const AHandler: TRouteHandler); +var + R: TRoute; +begin + R.Method := UpperCase(AMethod); + R.Pattern := APattern; + R.Regex := TRegEx.Create('^' + APattern + '$'); + R.Handler := AHandler; + FRoutes.Add(R); +end; + +function TPMRouter.DispatchRequest(ARequest: TIdHTTPRequestInfo; + AResponse: TIdHTTPResponseInfo): Boolean; +var + LRoute: TRoute; + LMatch: TMatch; + LParams: TRouteParams; + I: Integer; + LMethod, LPath: string; +begin + Result := False; + LMethod := UpperCase(ARequest.Command); + LPath := ARequest.Document; + for LRoute in FRoutes do + begin + if LRoute.Method <> LMethod then Continue; + LMatch := LRoute.Regex.Match(LPath); + if LMatch.Success then + begin + SetLength(LParams, LMatch.Groups.Count - 1); + for I := 1 to LMatch.Groups.Count - 1 do + LParams[I - 1] := LMatch.Groups[I].Value; + LRoute.Handler(ARequest, AResponse, LParams); + Exit(True); + end; + end; +end; + +initialization + Router := TPMRouter.Create; + +finalization + Router.Free; + +end. diff --git a/delphi-backend/Source/PM.Session.pas b/delphi-backend/Source/PM.Session.pas new file mode 100644 index 0000000..6bfee85 --- /dev/null +++ b/delphi-backend/Source/PM.Session.pas @@ -0,0 +1,219 @@ +unit PM.Session; + +{ + Session lookup + CSRF validation. + + - Authenticate: read Bearer token from Authorization header, SHA256 it, + look up sessions.token_hash. Reject if missing/expired. Returns userId. + On failure, writes 401 + JSON error and raises ESessionRejected so the + handler aborts cleanly. + + - RequireCSRF: for non-GET methods, validate X-CSRF-Token header against + the user's latest session csrf_token (constant-time compare). +} + +interface + +uses + System.SysUtils, System.Classes, System.StrUtils, + FireDAC.Comp.Client, FireDAC.Stan.Param, + IdCustomHTTPServer, + PM.Database, PM.Crypto, PM.JSON; + +type + ESessionRejected = class(Exception); + +function Authenticate(ARequest: TIdHTTPRequestInfo; + AResponse: TIdHTTPResponseInfo): Integer; +procedure RequireCSRF(ARequest: TIdHTTPRequestInfo; + AResponse: TIdHTTPResponseInfo; AUserId: Integer); + +function CreateSession(AUserId: Integer; out AToken, ACSRFToken: string): Boolean; +procedure DeleteSessionByTokenHash(const ATokenHash: string); +procedure DeleteAllUserSessions(AUserId: Integer); + +implementation + +uses + System.DateUtils; + +function ExtractBearerToken(ARequest: TIdHTTPRequestInfo): string; +var + LAuth: string; +begin + LAuth := ARequest.RawHeaders.Values['Authorization']; + if LAuth.StartsWith('Bearer ', True) then + Result := Copy(LAuth, 8, MaxInt) + else + Result := ''; +end; + +function Authenticate(ARequest: TIdHTTPRequestInfo; + AResponse: TIdHTTPResponseInfo): Integer; +var + LToken, LTokenHash: string; + LQ: TFDQuery; + LExpires: TDateTime; +begin + Result := 0; + LToken := ExtractBearerToken(ARequest); + if LToken = '' then + begin + TJSONHelper.SendError(AResponse, 401, 'No token'); + raise ESessionRejected.Create('no token'); + end; + LTokenHash := SHA256Hex(LToken); + + DB.Lock; + try + LQ := TFDQuery.Create(nil); + try + LQ.Connection := DB.Connection; + LQ.SQL.Text := + 'SELECT user_id, expires_at FROM sessions WHERE token_hash = :th'; + LQ.ParamByName('th').AsString := LTokenHash; + LQ.Open; + if LQ.IsEmpty then + begin + TJSONHelper.SendError(AResponse, 401, 'Invalid session'); + raise ESessionRejected.Create('invalid session'); + end; + Result := LQ.FieldByName('user_id').AsInteger; + // Read as TDateTime directly — FireDAC parses SQLite DATETIME columns + // internally; using AsString would round-trip through system locale. + LExpires := LQ.FieldByName('expires_at').AsDateTime; + finally + LQ.Free; + end; + + if (LExpires <> 0) and (LExpires < Now) then + begin + DeleteSessionByTokenHash(LTokenHash); + TJSONHelper.SendError(AResponse, 401, 'Session expired'); + raise ESessionRejected.Create('expired'); + end; + finally + DB.Unlock; + end; +end; + +procedure RequireCSRF(ARequest: TIdHTTPRequestInfo; + AResponse: TIdHTTPResponseInfo; AUserId: Integer); +var + LSubmitted, LStored: string; + LQ: TFDQuery; +begin + if SameText(ARequest.Command, 'GET') then Exit; + + LSubmitted := ARequest.RawHeaders.Values['X-CSRF-Token']; + if LSubmitted = '' then + begin + TJSONHelper.SendError(AResponse, 403, 'Missing CSRF token'); + raise ESessionRejected.Create('missing csrf'); + end; + + DB.Lock; + try + LQ := TFDQuery.Create(nil); + try + LQ.Connection := DB.Connection; + LQ.SQL.Text := + 'SELECT csrf_token FROM sessions ' + + 'WHERE user_id = :uid AND expires_at > datetime(''now'') ' + + 'ORDER BY created_at DESC LIMIT 1'; + LQ.ParamByName('uid').AsInteger := AUserId; + LQ.Open; + if LQ.IsEmpty then + begin + TJSONHelper.SendError(AResponse, 403, 'Invalid CSRF token'); + raise ESessionRejected.Create('no session'); + end; + LStored := LQ.FieldByName('csrf_token').AsString; + finally + LQ.Free; + end; + finally + DB.Unlock; + end; + + if not ConstantTimeEquals(LStored, LSubmitted) then + begin + TJSONHelper.SendError(AResponse, 403, 'Invalid CSRF token'); + raise ESessionRejected.Create('csrf mismatch'); + end; +end; + +function CreateSession(AUserId: Integer; out AToken, ACSRFToken: string): Boolean; +var + LQ: TFDQuery; + LTokenHash, LExpires: string; +begin + AToken := RandomHex(32); + ACSRFToken := RandomHex(32); + LTokenHash := SHA256Hex(AToken); + // YYYY-MM-DD HH:NN:SS, +24h, server local time (api.php uses date() = local) + LExpires := FormatDateTime('yyyy-mm-dd hh:nn:ss', IncHour(Now, 24)); + + DB.Lock; + try + LQ := TFDQuery.Create(nil); + try + LQ.Connection := DB.Connection; + LQ.SQL.Text := + 'INSERT INTO sessions (user_id, token_hash, csrf_token, expires_at) ' + + 'VALUES (:uid, :th, :csrf, :exp)'; + LQ.ParamByName('uid').AsInteger := AUserId; + LQ.ParamByName('th').AsString := LTokenHash; + LQ.ParamByName('csrf').AsString := ACSRFToken; + LQ.ParamByName('exp').AsString := LExpires; + LQ.ExecSQL; + Result := True; + finally + LQ.Free; + end; + finally + DB.Unlock; + end; +end; + +procedure DeleteSessionByTokenHash(const ATokenHash: string); +var + LQ: TFDQuery; +begin + DB.Lock; + try + LQ := TFDQuery.Create(nil); + try + LQ.Connection := DB.Connection; + LQ.SQL.Text := 'DELETE FROM sessions WHERE token_hash = :th'; + LQ.ParamByName('th').AsString := ATokenHash; + LQ.ExecSQL; + finally + LQ.Free; + end; + finally + DB.Unlock; + end; +end; + +procedure DeleteAllUserSessions(AUserId: Integer); +var + LQ: TFDQuery; +begin + DB.Lock; + try + LQ := TFDQuery.Create(nil); + try + LQ.Connection := DB.Connection; + LQ.SQL.Text := 'DELETE FROM sessions WHERE user_id = :uid'; + LQ.ParamByName('uid').AsInteger := AUserId; + LQ.ExecSQL; + finally + LQ.Free; + end; + finally + DB.Unlock; + end; +end; + +end. diff --git a/delphi-backend/Source/PM.StaticFiles.pas b/delphi-backend/Source/PM.StaticFiles.pas new file mode 100644 index 0000000..1d68932 --- /dev/null +++ b/delphi-backend/Source/PM.StaticFiles.pas @@ -0,0 +1,137 @@ +unit PM.StaticFiles; + +{ + Static file server with directory-traversal protection. + Serves Z:\password-manager\ (index.html, js/, css/) from the loopback server, + so the embedded TTMSFNCWebBrowser can navigate to http://127.0.0.1:PORT/index.html + and the password-manager UI runs entirely inside the Delphi exe. + + Same pattern as DeskInsight's Forms/UAIWorkbench.HTTPServer.pas Monaco server. +} + +interface + +uses + System.SysUtils, System.Classes, System.IOUtils, System.StrUtils, + IdCustomHTTPServer; + +type + TStaticFileServer = class + private + FRootDir: string; + function ResolveSafePath(const ARequestPath: string; out AFullPath: string): Boolean; + function MimeTypeFor(const AExt: string): string; + public + constructor Create(const ARootDir: string); + function TryServe(ARequest: TIdHTTPRequestInfo; + AResponse: TIdHTTPResponseInfo): Boolean; + property RootDir: string read FRootDir; + end; + +var + StaticServer: TStaticFileServer; + +procedure InitStaticServer(const ARootDir: string); +procedure DoneStaticServer; + +implementation + +constructor TStaticFileServer.Create(const ARootDir: string); +begin + inherited Create; + FRootDir := TPath.GetFullPath(IncludeTrailingPathDelimiter(ARootDir)); +end; + +function TStaticFileServer.ResolveSafePath(const ARequestPath: string; + out AFullPath: string): Boolean; +var + LRelative, LCandidate: string; +begin + Result := False; + AFullPath := ''; + LRelative := ARequestPath; + + // Normalize: '/' or '' -> index.html + if (LRelative = '') or (LRelative = '/') then + LRelative := '/index.html'; + + // Strip leading slash, convert URL separators to OS separators + if (Length(LRelative) > 0) and (LRelative[1] = '/') then + Delete(LRelative, 1, 1); + LRelative := StringReplace(LRelative, '/', PathDelim, [rfReplaceAll]); + + // Reject obvious traversal attempts (defense in depth — TPath.GetFullPath + // resolves '..' but rejecting up front gives a clean 404) + if (Pos('..', LRelative) > 0) or (Pos(':', LRelative) > 0) then Exit; + + LCandidate := TPath.GetFullPath(TPath.Combine(FRootDir, LRelative)); + + // Critical check: the resolved path MUST be under FRootDir + if not LCandidate.StartsWith(FRootDir, True) then Exit; + if not TFile.Exists(LCandidate) then Exit; + + AFullPath := LCandidate; + Result := True; +end; + +function TStaticFileServer.MimeTypeFor(const AExt: string): string; +var + LExt: string; +begin + LExt := LowerCase(AExt); + if (LExt = '.html') or (LExt = '.htm') then Exit('text/html; charset=utf-8'); + if LExt = '.js' then Exit('application/javascript; charset=utf-8'); + if LExt = '.mjs' then Exit('application/javascript; charset=utf-8'); + if LExt = '.css' then Exit('text/css; charset=utf-8'); + if LExt = '.json' then Exit('application/json; charset=utf-8'); + if LExt = '.svg' then Exit('image/svg+xml'); + if LExt = '.png' then Exit('image/png'); + if LExt = '.jpg' then Exit('image/jpeg'); + if LExt = '.jpeg' then Exit('image/jpeg'); + if LExt = '.gif' then Exit('image/gif'); + if LExt = '.webp' then Exit('image/webp'); + if LExt = '.ico' then Exit('image/x-icon'); + if LExt = '.woff' then Exit('font/woff'); + if LExt = '.woff2' then Exit('font/woff2'); + if LExt = '.ttf' then Exit('font/ttf'); + if LExt = '.map' then Exit('application/json'); + if LExt = '.txt' then Exit('text/plain; charset=utf-8'); + Result := 'application/octet-stream'; +end; + +function TStaticFileServer.TryServe(ARequest: TIdHTTPRequestInfo; + AResponse: TIdHTTPResponseInfo): Boolean; +var + LFullPath, LExt: string; + LFS: TFileStream; +begin + Result := False; + if not SameText(ARequest.Command, 'GET') then Exit; + if not ResolveSafePath(ARequest.Document, LFullPath) then Exit; + + LExt := ExtractFileExt(LFullPath); + AResponse.ContentType := MimeTypeFor(LExt); + + // Stream the file — Indy will set Content-Length and free the stream. + LFS := TFileStream.Create(LFullPath, fmOpenRead or fmShareDenyWrite); + AResponse.ContentStream := LFS; + AResponse.FreeContentStream := True; + AResponse.ResponseNo := 200; + Result := True; +end; + +procedure InitStaticServer(const ARootDir: string); +begin + if StaticServer = nil then + StaticServer := TStaticFileServer.Create(ARootDir); +end; + +procedure DoneStaticServer; +begin + FreeAndNil(StaticServer); +end; + +initialization +finalization + DoneStaticServer; +end. diff --git a/delphi-backend/UMainForm.fmx b/delphi-backend/UMainForm.fmx new file mode 100644 index 0000000..5ddd177 --- /dev/null +++ b/delphi-backend/UMainForm.fmx @@ -0,0 +1,133 @@ +object MainForm: TMainForm + Left = 0 + Top = 0 + Caption = 'Password Manager - Delphi Backend' + ClientHeight = 720 + ClientWidth = 1100 + FormFactor.Width = 320 + FormFactor.Height = 480 + FormFactor.Devices = [Desktop] + OnCreate = FormCreate + OnDestroy = FormDestroy + OnCloseQuery = FormCloseQuery + DesignerMasterStyle = 0 + object PanelTop: TPanel + Align = Top + Size.Width = 1100.000000000000000000 + Size.Height = 56.000000000000000000 + Size.PlatformDefault = False + TabOrder = 0 + object lblPort: TLabel + Position.X = 16.000000000000000000 + Position.Y = 18.000000000000000000 + Size.Width = 40.000000000000000000 + Size.Height = 20.000000000000000000 + Size.PlatformDefault = False + TextSettings.Trimming = None + Text = 'Port:' + TabOrder = 0 + end + object edtPort: TEdit + Touch.InteractiveGestures = [LongTap, DoubleTap] + TabOrder = 1 + Text = '8765' + Position.X = 56.000000000000000000 + Position.Y = 14.000000000000000000 + Size.Width = 80.000000000000000000 + Size.Height = 24.000000000000000000 + Size.PlatformDefault = False + end + object btnStart: TButton + Position.X = 152.000000000000000000 + Position.Y = 14.000000000000000000 + Size.Width = 80.000000000000000000 + Size.Height = 24.000000000000000000 + Size.PlatformDefault = False + TabOrder = 2 + Text = 'Start' + TextSettings.Trimming = None + OnClick = btnStartClick + end + object btnStop: TButton + Position.X = 240.000000000000000000 + Position.Y = 14.000000000000000000 + Size.Width = 80.000000000000000000 + Size.Height = 24.000000000000000000 + Size.PlatformDefault = False + TabOrder = 3 + Text = 'Stop' + TextSettings.Trimming = None + OnClick = btnStopClick + end + object btnReload: TButton + Position.X = 328.000000000000000000 + Position.Y = 14.000000000000000000 + Size.Width = 80.000000000000000000 + Size.Height = 24.000000000000000000 + Size.PlatformDefault = False + TabOrder = 4 + Text = 'Reload' + TextSettings.Trimming = None + OnClick = btnReloadClick + end + object btnToggleLog: TButton + Position.X = 416.000000000000000000 + Position.Y = 14.000000000000000000 + Size.Width = 90.000000000000000000 + Size.Height = 24.000000000000000000 + Size.PlatformDefault = False + TabOrder = 5 + Text = 'Hide log' + TextSettings.Trimming = None + OnClick = btnToggleLogClick + end + object lblStatus: TLabel + Position.X = 520.000000000000000000 + Position.Y = 18.000000000000000000 + Size.Width = 560.000000000000000000 + Size.Height = 20.000000000000000000 + Size.PlatformDefault = False + TextSettings.Trimming = None + Text = 'Stopped' + TabOrder = 6 + end + end + object PanelLog: TPanel + Align = Bottom + Position.Y = 564.000000000000000000 + Size.Width = 1100.000000000000000000 + Size.Height = 156.000000000000000000 + Size.PlatformDefault = False + TabOrder = 1 + object Memo: TMemo + Touch.InteractiveGestures = [Pan, LongTap, DoubleTap] + DataDetectorTypes = [] + ReadOnly = True + TextSettings.Font.Family = 'Consolas' + Align = Client + Size.Width = 1100.000000000000000000 + Size.Height = 156.000000000000000000 + Size.PlatformDefault = False + TabOrder = 0 + Viewport.Width = 1096.000000000000000000 + Viewport.Height = 152.000000000000000000 + end + end + object Splitter: TSplitter + Align = Bottom + Cursor = crVSplit + MinSize = 20.000000000000000000 + Position.Y = 558.000000000000000000 + Size.Width = 1100.000000000000000000 + Size.Height = 6.000000000000000000 + Size.PlatformDefault = False + end + object WebBrowser: TTMSFNCWebBrowser + Align = Client + Size.Width = 1100.000000000000000000 + Size.Height = 502.000000000000000000 + Size.PlatformDefault = False + TabOrder = 3 + DesigntimeEnabled = False + end +end diff --git a/delphi-backend/UMainForm.pas b/delphi-backend/UMainForm.pas new file mode 100644 index 0000000..239b257 --- /dev/null +++ b/delphi-backend/UMainForm.pas @@ -0,0 +1,325 @@ +unit UMainForm; + +interface + +uses + System.SysUtils, System.Classes, System.UITypes, System.NetEncoding, + FMX.Forms, FMX.Controls, FMX.Controls.Presentation, FMX.StdCtrls, + FMX.Memo, FMX.Memo.Types, FMX.ScrollBox, FMX.Edit, FMX.Layouts, FMX.Types, + FMX.Dialogs, + FMX.TMSFNCTypes, FMX.TMSFNCUtils, FMX.TMSFNCGraphics, FMX.TMSFNCGraphicsTypes, + FMX.TMSFNCCustomControl, FMX.TMSFNCWebBrowser, + PM.HTTPServer, PM.Bridge; + +type + TMainForm = class(TForm) + PanelTop: TPanel; + btnStart: TButton; + btnStop: TButton; + lblStatus: TLabel; + edtPort: TEdit; + lblPort: TLabel; + btnToggleLog: TButton; + btnReload: TButton; + PanelLog: TPanel; + Memo: TMemo; + Splitter: TSplitter; + WebBrowser: TTMSFNCWebBrowser; + procedure FormCreate(Sender: TObject); + procedure FormDestroy(Sender: TObject); + procedure FormCloseQuery(Sender: TObject; var CanClose: Boolean); + procedure btnStartClick(Sender: TObject); + procedure btnStopClick(Sender: TObject); + procedure btnToggleLogClick(Sender: TObject); + procedure btnReloadClick(Sender: TObject); + private + FServer: TPMHTTPServer; + FBridge: TPMBridge; + FPendingURL: string; + FNavTimer: TTimer; + FNavAttempts: Integer; + FQuitting: Boolean; // set when user picks "Quit" in tray menu — bypasses + // FormCloseQuery's minimize-to-tray intercept. + procedure LogLine(const AMsg: string); + procedure UpdateButtons; + procedure NavigateToVault; + procedure NavTimerTick(Sender: TObject); + // JS↔Delphi bridge + procedure WebBrowserBeforeNavigate(Sender: TObject; + var Params: TTMSFNCCustomWebBrowserBeforeNavigateParams); + procedure HandleBridgeCommand(const ACmd, AParams: string); + procedure BridgeSystemLock; + procedure BridgeTrayRestore; + procedure BridgeLockRequest; + procedure BridgeQuit; + end; + +var + MainForm: TMainForm; + +implementation + +{$R *.fmx} + +procedure TMainForm.FormCreate(Sender: TObject); +begin + FServer := TPMHTTPServer.Create; + FServer.OnLog := LogLine; + + FBridge := TPMBridge.Create(Self); + FBridge.OnSystemLock := BridgeSystemLock; + FBridge.OnTrayRestore := BridgeTrayRestore; + FBridge.OnLockRequest := BridgeLockRequest; + FBridge.OnQuit := BridgeQuit; + + // Wire the cmd:// bridge before any navigation happens. + WebBrowser.OnBeforeNavigate := WebBrowserBeforeNavigate; + + // Delayed-Navigate timer: TTMSFNCWebBrowser (WebView2 backend) ignores + // Navigate() calls until Edge Chromium finishes its async init (~1-2s). + // We wait 1.5 s after Start, then issue a SINGLE Navigate — no retry loop + // (retrying caused the loaded page to reload every interval, making icons + // flash). If Edge needed longer than 1.5 s, user clicks Reload. + FNavTimer := TTimer.Create(Self); + FNavTimer.Interval := 1500; + FNavTimer.Enabled := False; + FNavTimer.OnTimer := NavTimerTick; + + UpdateButtons; + LogLine('Password Manager - Delphi backend ready.'); + LogLine('Click Start to launch server + embedded web vault.'); +end; + +procedure TMainForm.FormDestroy(Sender: TObject); +begin + FBridge.Free; + FServer.Free; +end; + +procedure TMainForm.FormCloseQuery(Sender: TObject; var CanClose: Boolean); +begin + // The tray-menu "Quit" handler sets FQuitting before triggering close, + // so we bypass the minimize-to-tray intercept in that case. + if FQuitting then Exit; + + // Otherwise: minimize to tray on close instead of quitting, so the vault + // stays available without the dev-panel being visible. + // When the server is stopped, allow normal close — there's no vault to + // keep alive in the background. + if FServer.Active then + begin + CanClose := False; + FBridge.MinimizeToTray; + LogLine('Minimized to tray. Click the tray icon to restore.'); + end; +end; + +procedure TMainForm.LogLine(const AMsg: string); +begin + // Synchronize handles both cases: if already on main thread, runs inline; + // otherwise marshals. Avoids overload resolution issues with TThread.Queue. + TThread.Synchronize(nil, + procedure + begin + Memo.Lines.Add(FormatDateTime('hh:nn:ss', Now) + ' ' + AMsg); + Memo.GoToTextEnd; + end); +end; + +procedure TMainForm.UpdateButtons; +begin + btnStart.Enabled := not FServer.Active; + btnStop.Enabled := FServer.Active; + btnReload.Enabled := FServer.Active; + edtPort.Enabled := not FServer.Active; + if FServer.Active then + lblStatus.Text := 'Running on http://127.0.0.1:' + edtPort.Text + else + lblStatus.Text := 'Stopped'; +end; + +procedure TMainForm.NavigateToVault; +begin + FPendingURL := 'http://127.0.0.1:' + edtPort.Text + '/index.html'; + LogLine('Will navigate embedded browser in ~1.5s to: ' + FPendingURL); + // Schedule a single Navigate after Edge has had time to initialize. + FNavTimer.Enabled := False; // restart timer if already running + FNavTimer.Enabled := True; +end; + +procedure TMainForm.NavTimerTick(Sender: TObject); +begin + FNavTimer.Enabled := False; // one-shot + if FPendingURL = '' then Exit; + LogLine('Navigating to: ' + FPendingURL); + WebBrowser.Navigate(FPendingURL); + FPendingURL := ''; +end; + +procedure TMainForm.btnStartClick(Sender: TObject); +var + LPort: Integer; +begin + LPort := StrToIntDef(edtPort.Text, 8765); + try + FServer.Start(LPort); + UpdateButtons; + NavigateToVault; + except + on E: Exception do + begin + LogLine('ERROR starting server: ' + E.Message); + MessageDlg('Failed to start: ' + E.Message, + TMsgDlgType.mtError, [TMsgDlgBtn.mbOK], 0); + end; + end; +end; + +procedure TMainForm.btnStopClick(Sender: TObject); +begin + FServer.Stop; + UpdateButtons; + FPendingURL := ''; + FNavTimer.Enabled := False; + WebBrowser.Navigate('about:blank'); +end; + +procedure TMainForm.btnReloadClick(Sender: TObject); +begin + if FServer.Active then NavigateToVault; +end; + +procedure TMainForm.btnToggleLogClick(Sender: TObject); +begin + PanelLog.Visible := not PanelLog.Visible; + Splitter.Visible := PanelLog.Visible; + if PanelLog.Visible then + btnToggleLog.Text := 'Hide log' + else + btnToggleLog.Text := 'Show log'; +end; + +// --------------------------------------------------------------------------- +// JS↔Delphi bridge +// --------------------------------------------------------------------------- + +procedure TMainForm.WebBrowserBeforeNavigate(Sender: TObject; + var Params: TTMSFNCCustomWebBrowserBeforeNavigateParams); +var + URL, Cmd, ParamStr: string; + P: Integer; +begin + URL := Params.URL; + if not URL.StartsWith('cmd://') then Exit; + + Params.Cancel := True; + URL := URL.Substring(6); // strip 'cmd://' + + P := Pos('?', URL); + if P > 0 then + begin + Cmd := Copy(URL, 1, P - 1); + ParamStr := Copy(URL, P + 1, MaxInt); + end + else + begin + Cmd := URL; + ParamStr := ''; + end; + + // Defer to avoid WebView2 re-entrance issues. + TThread.ForceQueue(nil, + procedure + begin + HandleBridgeCommand(Cmd, ParamStr); + end); +end; + +procedure TMainForm.HandleBridgeCommand(const ACmd, AParams: string); + + function GetParam(const AKey: string): string; + var + Parts: TArray; + Part, K, V: string; + EqPos: Integer; + begin + Result := ''; + Parts := AParams.Split(['&']); + for Part in Parts do + begin + EqPos := Pos('=', Part); + if EqPos > 0 then + begin + K := Copy(Part, 1, EqPos - 1); + V := Copy(Part, EqPos + 1, MaxInt); + if SameText(K, AKey) then + begin + Result := TNetEncoding.URL.Decode(V); + Exit; + end; + end; + end; + end; + +var + LText: string; + LClearMs: Integer; +begin + if ACmd = 'clipboard/copy' then + begin + LText := GetParam('text'); + LClearMs := StrToIntDef(GetParam('clear'), 30000); + FBridge.SecureClipboard.SetText(LText, LClearMs); + LogLine(Format('Secure clipboard set (auto-clear in %ds)', [LClearMs div 1000])); + end + + else if ACmd = 'clipboard/clear' then + begin + FBridge.SecureClipboard.Clear; + LogLine('Clipboard cleared by JS request'); + end + + else + LogLine('Bridge: unknown command "' + ACmd + '"'); +end; + +procedure TMainForm.BridgeSystemLock; +begin + // Windows session locked — lock the vault in the JS layer immediately. + LogLine('Windows session locked — locking vault'); + WebBrowser.ExecuteJavaScript('if(typeof lockVault==="function")lockVault()'); +end; + +procedure TMainForm.BridgeTrayRestore; +begin + FBridge.RestoreFromTray; + // Notify the JS layer: the UI may want to reset the auto-lock timer, + // refresh state, or show a "welcome back" toast. + WebBrowser.ExecuteJavaScript( + 'if(window.Bridge&&typeof Bridge.onTrayRestore==="function")Bridge.onTrayRestore()'); + LogLine('Restored from tray'); +end; + +procedure TMainForm.BridgeLockRequest; +begin + // User picked "Lock vault" from the tray menu. Trigger lockVault() in + // JS — same path as the WTS_SESSION_LOCK auto-lock. + LogLine('Lock requested from tray menu'); + WebBrowser.ExecuteJavaScript('if(typeof lockVault==="function")lockVault()'); +end; + +procedure TMainForm.BridgeQuit; +begin + // Re-entry guard: if Quit was already requested, ignore further calls. + if FQuitting then Exit; + + LogLine('>>> BridgeQuit invoked (Quit from tray menu)'); + FQuitting := True; + // Restore the form first so the tray icon goes away and FormDestroy + // executes from a normal (non-hidden) state. RestoreFromTray also + // deletes the tray icon. + FBridge.RestoreFromTray; + Application.Terminate; +end; + +end. diff --git a/delphi-backend/assets/BuildAssets.cmd b/delphi-backend/assets/BuildAssets.cmd new file mode 100644 index 0000000..c9ed4c3 --- /dev/null +++ b/delphi-backend/assets/BuildAssets.cmd @@ -0,0 +1,21 @@ +@echo off +REM Wrapper around BuildAssets.ps1 — captures stdout+stderr to build.log +REM so Delphi's pre-build event can call this without worrying about +REM cmd.exe '&' escape rules. +REM +REM Usage in Delphi pre-build event: +REM "Z:\password-manager\delphi-backend\assets\BuildAssets.cmd" + +set "SCRIPT_DIR=%~dp0" +set "LOG=%SCRIPT_DIR%build.log" + +echo === BuildAssets.cmd at %DATE% %TIME% === > "%LOG%" +echo Calling PowerShell... >> "%LOG%" + +powershell -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%BuildAssets.ps1" >> "%LOG%" 2>&1 +set RC=%ERRORLEVEL% + +echo. >> "%LOG%" +echo Exit code: %RC% >> "%LOG%" + +exit /b %RC% diff --git a/delphi-backend/assets/BuildAssets.ps1 b/delphi-backend/assets/BuildAssets.ps1 new file mode 100644 index 0000000..6ecad89 --- /dev/null +++ b/delphi-backend/assets/BuildAssets.ps1 @@ -0,0 +1,135 @@ +# BuildAssets.ps1 +# Generates assets.rc + assets.res containing the static web assets +# (index.html, js/*, css/*) so they can be linked into PMServer.exe as +# Win32 RCDATA resources. +# +# Workflow: +# 1. Edit Z:\password-manager\index.html / js\app.js / css\style.css +# 2. Run this script (or set it as pre-build event in Delphi) +# 3. Build PMServer.dpr in Delphi +# 4. Run - exe is autonomous, no external files needed at runtime +# +# Resource naming: URL path '/js/app.js' -> resource 'JS_APP_JS' +# - strip leading '/' +# - replace '/' '\' '.' '-' with '_' +# - uppercase + +[CmdletBinding()] +param( + [string] $WebRoot = '', + [string] $OutRC = '', + [string] $OutRES = '', + [string] $OutInc = '' +) + +$ErrorActionPreference = 'Stop' + +# Resolve $PSScriptRoot — may be empty depending on invocation context. +# Fallback to the script's own file path. +$ScriptDir = $PSScriptRoot +if (-not $ScriptDir) { $ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition } +if (-not $ScriptDir) { $ScriptDir = (Get-Location).Path } + +# Apply param defaults now that we have a valid script dir +if (-not $WebRoot) { $WebRoot = Join-Path $ScriptDir '..\..' } +if (-not $OutRC) { $OutRC = Join-Path $ScriptDir 'assets.rc' } +if (-not $OutRES) { $OutRES = Join-Path $ScriptDir 'assets.res' } +if (-not $OutInc) { $OutInc = Join-Path $ScriptDir 'assets.inc' } + +# All output goes to stdout/stderr. When invoked via BuildAssets.cmd the +# wrapper captures both streams into build.log; when run interactively +# everything shows in the terminal. + +function Log { param([string] $msg) Write-Host $msg } + +try { + +$WebRoot = (Resolve-Path $WebRoot).Path +Log "Web root: $WebRoot" + +# Whitelist patterns (relative to WebRoot). Add more here when needed. +# Use exact paths (not wildcards) to avoid embedding *-legacy.* backups. +$patterns = @( + 'index.html', + 'js\app.js', + 'css\style.css' +) + +# Discover files +$files = @() +foreach ($p in $patterns) { + $found = Get-ChildItem -Path (Join-Path $WebRoot $p) -File -ErrorAction SilentlyContinue + foreach ($f in $found) { + $rel = $f.FullName.Substring($WebRoot.Length).TrimStart('\','/') + $files += [pscustomobject]@{ + FullPath = $f.FullName + Relative = $rel + UrlPath = '/' + ($rel -replace '\\','/') + ResName = ($rel -replace '[\\/\.\-]','_').ToUpperInvariant() + } + } +} + +if ($files.Count -eq 0) { + throw "No assets found under $WebRoot. Check the patterns." +} + +Log "Embedding $($files.Count) file(s):" +$files | ForEach-Object { Log (" " + $_.UrlPath + " -> " + $_.ResName) } + +# --- Generate assets.rc ------------------------------------------------------- +$rc = New-Object System.Text.StringBuilder +[void]$rc.AppendLine('// Auto-generated by BuildAssets.ps1 - do not edit by hand.') +[void]$rc.AppendLine('#pragma code_page(65001)') +[void]$rc.AppendLine('') +foreach ($f in $files) { + # brcc32 accepts forward slashes; backslashes need to be doubled in C strings + $rcPath = $f.FullPath -replace '\\','\\' + [void]$rc.AppendLine("$($f.ResName) RCDATA `"$rcPath`"") +} +[System.IO.File]::WriteAllText($OutRC, $rc.ToString(), [System.Text.Encoding]::ASCII) +Log "Wrote $OutRC" + +# --- Generate assets.inc (Pascal include with manifest) ----------------------- +# Compile-time mapping URL -> resource name, consumed by PM.EmbeddedAssets.pas. +$nl = [Environment]::NewLine +$inc = New-Object System.Text.StringBuilder +[void]$inc.Append('// Auto-generated by BuildAssets.ps1 - do not edit by hand.' + $nl) +[void]$inc.Append('const' + $nl) +[void]$inc.Append(' EMBEDDED_ASSET_COUNT = ' + $files.Count + ';' + $nl) +[void]$inc.Append(' EMBEDDED_ASSETS: array[0..EMBEDDED_ASSET_COUNT-1] of TEmbeddedAsset = (' + $nl) +for ($i = 0; $i -lt $files.Count; $i++) { + $f = $files[$i] + if ($i -eq $files.Count - 1) { $sep = '' } else { $sep = ',' } + $line = " (UrlPath: '" + $f.UrlPath + "'; ResName: '" + $f.ResName + "')" + $sep + $nl + [void]$inc.Append($line) +} +[void]$inc.Append(' );' + $nl) +[System.IO.File]::WriteAllText($OutInc, $inc.ToString(), [System.Text.Encoding]::UTF8) +Log "Wrote $OutInc" + +# --- Compile to .res via brcc32 ---------------------------------------------- +$brcc = $null +if ($env:BDS) { + $candidate = Join-Path $env:BDS 'bin\brcc32.exe' + if (Test-Path $candidate) { $brcc = $candidate } +} +if (-not $brcc) { + $cmd = Get-Command brcc32.exe -ErrorAction SilentlyContinue + if ($cmd) { $brcc = $cmd.Source } +} +if (-not $brcc) { + throw "brcc32.exe not found. Set the BDS environment variable to your Delphi install root, or add brcc32.exe to PATH." +} +Log "Using $brcc" + +& $brcc -32 -fo "$OutRES" "$OutRC" +if ($LASTEXITCODE -ne 0) { throw "brcc32 failed with exit code $LASTEXITCODE" } +Log "Wrote $OutRES" +Log "OK" + +} catch { + Log "FATAL: $($_.Exception.Message)" + Log "Stack: $($_.ScriptStackTrace)" + exit 1 +} diff --git a/delphi-backend/assets/assets.inc b/delphi-backend/assets/assets.inc new file mode 100644 index 0000000..3dc34cd --- /dev/null +++ b/delphi-backend/assets/assets.inc @@ -0,0 +1,8 @@ +// Auto-generated by BuildAssets.ps1 - do not edit by hand. +const + EMBEDDED_ASSET_COUNT = 3; + EMBEDDED_ASSETS: array[0..EMBEDDED_ASSET_COUNT-1] of TEmbeddedAsset = ( + (UrlPath: '/index.html'; ResName: 'INDEX_HTML'), + (UrlPath: '/js/app.js'; ResName: 'JS_APP_JS'), + (UrlPath: '/css/style.css'; ResName: 'CSS_STYLE_CSS') + ); diff --git a/delphi-backend/assets/assets.rc b/delphi-backend/assets/assets.rc new file mode 100644 index 0000000..2382968 --- /dev/null +++ b/delphi-backend/assets/assets.rc @@ -0,0 +1,6 @@ +// Auto-generated by BuildAssets.ps1 - do not edit by hand. +#pragma code_page(65001) + +INDEX_HTML RCDATA "Z:\\password-manager\\index.html" +JS_APP_JS RCDATA "Z:\\password-manager\\js\\app.js" +CSS_STYLE_CSS RCDATA "Z:\\password-manager\\css\\style.css" diff --git a/delphi-backend/assets/assets.res b/delphi-backend/assets/assets.res new file mode 100644 index 0000000..0c78b52 Binary files /dev/null and b/delphi-backend/assets/assets.res differ diff --git a/js/app.js b/js/app.js index ae91655..9ff5da8 100644 --- a/js/app.js +++ b/js/app.js @@ -1,1543 +1,2236 @@ -const API = '/password-manager/api.php'; -let token = sessionStorage.getItem('authToken'); -let csrfToken = sessionStorage.getItem('csrfToken') || ''; -let curUser = sessionStorage.getItem('currentUsername'); -function a2b64(arr) { return btoa(String.fromCharCode(...new Uint8Array(arr))).replace(/\+/g,'-').replace(/\//g,'_').replace(/=+$/,''); } -function b642ab(s) { return Uint8Array.from(atob(s.replace(/-/g,'+').replace(/_/g,'/')), c=>c.charCodeAt(0)).buffer; } -let view = localStorage.getItem('vaultView') || 'grid'; -let detailIndex = 0; -let showView = localStorage.getItem('showViewBtn') !== 'false'; -let showMail = localStorage.getItem('showEmail') !== 'false'; -let dark = localStorage.getItem('darkTheme') !== 'false'; -let lockMin = parseInt(localStorage.getItem('autoLockMinutes') || '5'); -let order = JSON.parse(localStorage.getItem('entryOrder') || '[]'); -let selectedFolder = localStorage.getItem('selectedFolder') || 'All'; -let entries = []; -let folders = ['All']; -let genPwdVal = ''; -let cryptoKey = null; -let searchQuery = ''; -let idleT, warnT, countT; -let draggedId = null; -let showTrash = false; -let selectedIds = new Set(); -let lastSelectedId = null; -let arrowAnchor = -1; -let arrowFocus = -1; -let rectState = { active: false, startX: 0, startY: 0, el: null, started: false }; +/* ============================================================ + Vault — UI V2 app.js + Clean state + render layer. Crypto helpers preserved verbatim + from legacy. Backend (PHP api.php / Delphi loopback) is detected + from URL path. + ============================================================ */ -// ==================== SOUND ==================== -let soundEnabled = localStorage.getItem('soundEnabled') !== 'false'; -let audioCtx = null; +// ---- Backend detection ------------------------------------- +const API = (location.pathname.indexOf('/password-manager/') === 0) + ? '/password-manager/api.php' + : ''; -function getAudioContext() { - if (!audioCtx) { - audioCtx = new (window.AudioContext || window.webkitAudioContext)(); +// ---- Delphi native bridge ---------------------------------- +// Active only when running inside the Delphi-hosted WebView2 (API === ''). +// Falls back to navigator.clipboard for the standalone PHP frontend. +const Bridge = (() => { + const active = (API === ''); + + // Navigate to a cmd:// URL — intercepted synchronously by + // TTMSFNCWebBrowser OnBeforeNavigate before any actual navigation occurs. + function cmd(path) { + window.location.href = path; } - return audioCtx; -} -function playTone(freq, duration, type = 'sine', volume = 0.08) { - if (!soundEnabled) return; - try { - const ctx = getAudioContext(); - const osc = ctx.createOscillator(); - const gain = ctx.createGain(); - osc.type = type; - osc.frequency.setValueAtTime(freq, ctx.currentTime); - gain.gain.setValueAtTime(volume, ctx.currentTime); - gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + duration); - osc.connect(gain); - gain.connect(ctx.destination); - osc.start(ctx.currentTime); - osc.stop(ctx.currentTime + duration); - } catch (e) { /* ignore */ } -} + return { + active, -function playSound(type) { - if (!soundEnabled) return; - switch (type) { - case 'click': playTone(800, 0.08, 'sine', 0.06); break; - case 'success': - playTone(523, 0.1, 'sine', 0.1); - setTimeout(() => playTone(659, 0.1, 'sine', 0.1), 100); - setTimeout(() => playTone(784, 0.15, 'sine', 0.1), 200); - break; - case 'error': - playTone(200, 0.2, 'square', 0.06); - setTimeout(() => playTone(150, 0.3, 'square', 0.06), 150); - break; - case 'delete': - playTone(150, 0.15, 'triangle', 0.08); - break; - case 'copy': - playTone(1200, 0.05, 'sine', 0.07); - break; - case 'generate': - playTone(440, 0.05, 'sine', 0.05); - setTimeout(() => playTone(554, 0.05, 'sine', 0.05), 60); - setTimeout(() => playTone(659, 0.05, 'sine', 0.05), 120); - setTimeout(() => playTone(880, 0.1, 'sine', 0.07), 180); - break; - case 'open': - playTone(600, 0.12, 'sine', 0.06); - setTimeout(() => playTone(800, 0.1, 'sine', 0.06), 80); - break; - case 'close': - playTone(800, 0.08, 'sine', 0.05); - setTimeout(() => playTone(600, 0.1, 'sine', 0.05), 80); - break; - case 'login': - playTone(523, 0.1, 'sine', 0.08); - setTimeout(() => playTone(659, 0.1, 'sine', 0.08), 100); - setTimeout(() => playTone(784, 0.2, 'sine', 0.1), 200); - break; - case 'register': - playTone(440, 0.1, 'sine', 0.08); - setTimeout(() => playTone(554, 0.1, 'sine', 0.08), 100); - setTimeout(() => playTone(659, 0.15, 'sine', 0.1), 200); - break; - } -} - -function toggleSound() { - soundEnabled = !soundEnabled; - localStorage.setItem('soundEnabled', soundEnabled); - if (soundEnabled) playTone(440, 0.05); - syncSettingsUI(); -} - -// ==================== TOAST ==================== -function toast(m, t, action) { t = t || 'success'; const c = document.getElementById('toastContainer'); const d = document.createElement('div'); d.className = 'toast ' + t; d.innerHTML = '' + m + ''; if (action) { const btn = document.createElement('button'); btn.className = 'toast-action'; btn.textContent = action.label; btn.onclick = function(e) { e.stopPropagation(); action.cb(); d.remove(); }; d.appendChild(btn); c.appendChild(d); } else { c.appendChild(d); setTimeout(() => d.remove(), 3000); } } -function showZigzagToast(elem, msg, type) { - const t = document.createElement('div'); - t.className = 'toast-zigzag ' + (type || 'success'); - t.textContent = msg; - document.body.appendChild(t); - const r = elem.getBoundingClientRect(); - t.style.left = r.left + 'px'; - t.style.top = r.top + 'px'; - setTimeout(() => t.remove(), 1500); -} - -// ==================== THEME ==================== -function applyTheme() { document.body.classList.toggle('light', !dark); } -function toggleTheme() { dark = !dark; localStorage.setItem('darkTheme', dark); applyTheme(); syncSettingsUI(); playSound('click'); } - -// ==================== CRYPTO ==================== -function checkStrength() { const p = document.getElementById('passwordInput').value; const b = document.getElementById('strengthBar'); let s = 0; if (p.length >= 8) s++; if (p.length >= 12) s++; if (/[A-Z]/.test(p) && /[a-z]/.test(p)) s++; if (/\d/.test(p)) s++; if (/[!@#$%^&*()_+\-=\[\]{}|;:,.<>?]/.test(p)) s++; b.className = 'strength-bar s' + Math.min(4, s); } -async function deriveKey(pwd, salt) { const enc = new TextEncoder(); const km = await crypto.subtle.importKey('raw', enc.encode(pwd), 'PBKDF2', false, ['deriveKey']); const sb = Uint8Array.from(atob(salt), c => c.charCodeAt(0)); return crypto.subtle.deriveKey({ name: 'PBKDF2', salt: sb, iterations: 100000, hash: 'SHA-256' }, km, { name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']); } -async function encryptPwd(plain) { const iv = crypto.getRandomValues(new Uint8Array(12)); const enc = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, cryptoKey, new TextEncoder().encode(plain)); return { encrypted: btoa(String.fromCharCode(...new Uint8Array(enc))), iv: btoa(String.fromCharCode(...iv)) }; } -async function decryptPwd(encB64, ivB64) { try { const enc = Uint8Array.from(atob(encB64), c => c.charCodeAt(0)); const iv = Uint8Array.from(atob(ivB64), c => c.charCodeAt(0)); const dec = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, cryptoKey, enc); return new TextDecoder().decode(dec); } catch (e) { return '[ERROR]'; } } -async function persistCryptoKey() { const raw = await crypto.subtle.exportKey('raw', cryptoKey); sessionStorage.setItem('cryptoKey', btoa(String.fromCharCode(...new Uint8Array(raw)))); } -async function restoreCryptoKey() { const saved = sessionStorage.getItem('cryptoKey'); if (!saved) return false; try { const raw = Uint8Array.from(atob(saved), c => c.charCodeAt(0)); cryptoKey = await crypto.subtle.importKey('raw', raw, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']); return true; } catch (e) { return false; } } - -// ==================== AUTO-LOCK ==================== -function setAutoLock() { lockMin = parseInt(document.getElementById('autoLockTimer').value); localStorage.setItem('autoLockMinutes', lockMin); resetIdle(); } -function resetIdle() { clearTimeout(idleT); clearTimeout(warnT); clearInterval(countT); document.getElementById('idleWarning').classList.remove('show'); if (lockMin > 0 && token) { const lm = lockMin * 60000; warnT = setTimeout(() => { document.getElementById('idleWarning').classList.add('show'); let cd = 30; document.getElementById('idleCountdown').textContent = cd; countT = setInterval(() => { cd--; document.getElementById('idleCountdown').textContent = cd; if (cd <= 0) { clearInterval(countT); doLogout(); } }, 1000); }, Math.max(0, lm - 30000)); idleT = setTimeout(() => doLogout(), lm); } } - -// ==================== USERNAME ==================== -function saveUsername() { const f = document.getElementById('addUsername'); if (f && f.value.trim()) localStorage.setItem('savedUsername', f.value.trim()); } -function loadUsername() { const s = localStorage.getItem('savedUsername'); const f = document.getElementById('addUsername'); if (s && f) f.value = s; } - -// ==================== FOLDERS ==================== -async function loadFolders() { - if (!token) return; - try { - const r = await fetch(API + '/folders', { headers: { 'Authorization': 'Bearer ' + token } }); - if (r.ok) { - const data = await r.json(); - if (Array.isArray(data)) { - folders = data.filter(f => typeof f === 'string'); - if (!folders.includes('All')) folders.unshift('All'); - } else { - folders = ['All']; - } - } else { - folders = ['All']; - } - } catch (e) { - folders = ['All']; - } -} - -async function addFolderToServer(name) { - try { - const r = await fetch(API + '/folders', { - method: 'POST', - headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken }, - body: JSON.stringify({ name }) - }); - if (r.ok) { await loadFolders(); return true; } - const d = await r.json(); - toast('❌ ' + (d.error || 'Error'), 'error'); - return false; - } catch (e) { toast('⚠️ Connection error', 'error'); return false; } -} - -async function deleteFolderFromServer(name) { - try { - const r = await fetch(API + '/folders/' + encodeURIComponent(name), { - method: 'DELETE', - headers: { 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken } - }); - if (r.ok) { - await loadFolders(); - if (selectedFolder === name) { selectedFolder = 'All'; localStorage.setItem('selectedFolder', 'All'); } + // Copy text to clipboard, excluded from Win+V history. + // clearAfterMs: Delphi auto-clears after this many ms (0 = never). + // Returns true when the bridge handled the copy, false as fallback signal. + copySecure(text, clearAfterMs = 30000) { + if (!active) return false; + cmd('cmd://clipboard/copy?text=' + encodeURIComponent(text) + + '&clear=' + clearAfterMs); return true; - } - const d = await r.json(); - toast('❌ ' + (d.error || 'Error'), 'error'); + }, + + // Called by Delphi (ExecuteJavaScript) on WTS_SESSION_LOCK. + // Exposed as window.Bridge.onSystemLock so the Delphi side can call it, + // but the actual lock is triggered directly via lockVault() in Delphi. + onSystemLock() { + if (typeof lockVault === 'function') lockVault(); + }, + + // Called by Delphi (ExecuteJavaScript) when the user restores the + // window from the tray icon. Useful for resetting auto-lock state + // and giving a subtle visual cue. + onTrayRestore() { + // If the user has been away long enough that the auto-lock + // should fire, lockVault was already called by either WTS lock + // or the local idle timer — so we only reset here when still + // unlocked. + if (state.cryptoKey && !state.locked) { + if (typeof resetAutoLock === 'function') resetAutoLock(); + if (typeof toast === 'function') toast('Welcome back'); + } + }, + }; +})(); + +// Expose Bridge on window so Delphi's ExecuteJavaScript can reach it. +window.Bridge = Bridge; + +// ---- Global state ------------------------------------------ +const state = { + token: sessionStorage.getItem('authToken') || '', + csrf: sessionStorage.getItem('csrfToken') || '', + salt: sessionStorage.getItem('salt') || '', + username: sessionStorage.getItem('username') || '', + cryptoKey: null, + entries: [], + trashed: [], + folders: ['All'], + view: 'all', // 'all' | 'favorites' | 'folder:' | 'tag:' | 'trash' + search: '', + selectedId: null, + theme: localStorage.getItem('theme') || 'dark', + locked: false, // true after user clicks Lock (token still valid server-side) + autoLock: parseInt(localStorage.getItem('autoLockMin') || '5'), + askBeforeDelete: localStorage.getItem('askBeforeDelete') !== '0', // default true + maskUsernames: localStorage.getItem('maskUsernames') === '1', // default false + compactActions: localStorage.getItem('compactActions') === '1', // default false + viewMode: localStorage.getItem('viewMode') || 'cards', // 'cards' | 'list' + checked: new Set(), // entry IDs checked for batch operations +}; + +// ============================================================ +// CRYPTO (preserved from legacy app.js — DO NOT TOUCH) +// ============================================================ + +async function deriveKey(pwd, saltHex) { + const enc = new TextEncoder(); + const km = await crypto.subtle.importKey('raw', enc.encode(pwd), 'PBKDF2', false, ['deriveKey']); + // saltHex is the same string that PHP/Delphi passed to PBKDF2 — use its bytes. + const sb = enc.encode(saltHex); + return crypto.subtle.deriveKey( + { name: 'PBKDF2', salt: sb, iterations: 100000, hash: 'SHA-256' }, + km, + { name: 'AES-GCM', length: 256 }, + true, ['encrypt', 'decrypt'] + ); +} + +async function encryptPwd(plain) { + const iv = crypto.getRandomValues(new Uint8Array(12)); + const enc = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv }, state.cryptoKey, + new TextEncoder().encode(plain) + ); + return { + encrypted: btoa(String.fromCharCode(...new Uint8Array(enc))), + iv: btoa(String.fromCharCode(...iv)), + }; +} + +async function decryptPwd(encB64, ivB64) { + try { + const enc = Uint8Array.from(atob(encB64), c => c.charCodeAt(0)); + const iv = Uint8Array.from(atob(ivB64), c => c.charCodeAt(0)); + const dec = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, state.cryptoKey, enc); + return new TextDecoder().decode(dec); + } catch (e) { + return '[ERROR]'; + } +} + +async function persistCryptoKey() { + const raw = await crypto.subtle.exportKey('raw', state.cryptoKey); + sessionStorage.setItem('cryptoKey', btoa(String.fromCharCode(...new Uint8Array(raw)))); +} + +async function restoreCryptoKey() { + const saved = sessionStorage.getItem('cryptoKey'); + if (!saved) return false; + try { + const raw = Uint8Array.from(atob(saved), c => c.charCodeAt(0)); + state.cryptoKey = await crypto.subtle.importKey( + 'raw', raw, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt'] + ); + return true; + } catch (e) { return false; - } catch (e) { toast('⚠️ Connection error', 'error'); return false; } + } } -function folderColor(name) { - if (name === 'All') return ''; - let hash = 0; - for (let i = 0; i < name.length; i++) hash = name.charCodeAt(i) + ((hash << 5) - hash); - const hue = ((hash % 360) + 360) % 360; - return `style="--chip-color:hsl(${hue},60%,55%)"`; -} -function renderFolders() { - const bar = document.getElementById('foldersBar'); - if (!bar) return; - entries.forEach(e => { if (!e.folder || typeof e.folder !== 'string') e.folder = 'All'; }); - const counts = {}; - entries.forEach(e => { const f = e.folder; counts[f] = (counts[f] || 0) + 1; }); - let html = ''; - folders.forEach(f => { - if (!f) return; - const count = counts[f] || 0; - const color = folderColor(f); - html += `📁 ${esc(f)}${count}${f !== 'All' ? `` : ''}`; - }); - html += ``; - bar.innerHTML = html; - // Make folders drop targets for moving entries - bar.querySelectorAll('.folder-chip[data-folder]').forEach(chip => { - chip.addEventListener('dragover', e => { e.preventDefault(); chip.classList.add('drag-over'); }); - chip.addEventListener('dragleave', () => chip.classList.remove('drag-over')); - chip.addEventListener('drop', async function(e) { - e.preventDefault(); - this.classList.remove('drag-over'); - const id = parseInt(e.dataTransfer.getData('text/plain')); - if (!id) return; - const entry = entries.find(x => x.id === id); - if (!entry) return; - const folder = this.dataset.folder; - const ids = selectedIds.has(id) && selectedIds.size > 1 ? [...selectedIds] : [id]; - let moved = 0; - for (const sid of ids) { - const e2 = entries.find(x => x.id === sid); - if (!e2 || e2.folder === folder) continue; - const enc = await encryptPwd(e2.password); - const r = await fetch(API + '/entries/' + sid, { - method: 'PUT', - headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken }, - body: JSON.stringify({ site: e2.site, username: e2.username, encrypted_password: enc.encrypted, iv: enc.iv, folder }) - }); - if (r.ok) { e2.folder = folder; moved++; } - } - renderFolders(); - render(); - if (moved) playSound('success'); - }); - }); +// ============================================================ +// HTTP HELPERS +// ============================================================ + +function authHeaders(extra) { + const h = Object.assign({ 'Authorization': 'Bearer ' + state.token }, extra || {}); + if (state.csrf) h['X-CSRF-Token'] = state.csrf; + return h; } - - -function selectFolder(f) { - selectedFolder = f; - localStorage.setItem('selectedFolder', f); - renderFolders(); - populateFolderSelects(); - render(); - playSound('click'); +async function api(path, opts) { + opts = opts || {}; + const r = await fetch(API + path, opts); + let body = null; + try { body = await r.json(); } catch (e) { body = {}; } + if (!r.ok) throw new Error(body.error || ('HTTP ' + r.status)); + return body; } -function showAddFolderModal() { - const overlay = document.createElement('div'); - overlay.className = 'custom-modal-overlay show'; - overlay.innerHTML = `

📁 New Folder

`; - document.body.appendChild(overlay); - document.getElementById('cancelAddFolder').onclick = () => overlay.remove(); - document.getElementById('confirmAddFolder').onclick = async () => { - const name = document.getElementById('newFolderName').value.trim(); - if (!name) { toast('Enter a name', 'error'); return; } - const ok = await addFolderToServer(name); - if (ok) { renderFolders(); populateFolderSelects(); overlay.remove(); toast('📁 Folder created!'); playSound('success'); } - }; - overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); }); - playSound('open'); +// ============================================================ +// TOAST +// ============================================================ + +function toast(msg, type) { + type = type || 'success'; + const container = $('#toastContainer'); + const t = document.createElement('div'); + t.className = 'toast is-' + type; + t.textContent = msg; + container.appendChild(t); + setTimeout(() => t.remove(), 2800); } -function showDeleteFolderConfirm(folderName) { - const overlay = document.createElement('div'); - overlay.className = 'custom-modal-overlay show'; - overlay.innerHTML = `

🗑️ Delete Folder

Delete "${folderName}"? Entries move to "All".

`; - document.body.appendChild(overlay); - document.getElementById('cancelDeleteFolder').onclick = () => overlay.remove(); - document.getElementById('confirmDeleteFolder').onclick = async () => { - const ok = await deleteFolderFromServer(folderName); - if (ok) { renderFolders(); populateFolderSelects(); render(); overlay.remove(); toast('📁 Folder deleted'); playSound('delete'); } - }; - overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); }); +// ============================================================ +// DOM HELPERS +// ============================================================ + +function $(sel, root) { return (root || document).querySelector(sel); } +function $$(sel, root) { return Array.from((root || document).querySelectorAll(sel)); } +function el(tag, props, ...kids) { + const e = document.createElement(tag); + if (props) for (const k in props) { + if (k === 'class') e.className = props[k]; + else if (k === 'on') for (const ev in props.on) e.addEventListener(ev, props.on[ev]); + else if (k === 'html') e.innerHTML = props[k]; + else if (k in e) e[k] = props[k]; + else e.setAttribute(k, props[k]); + } + for (const k of kids) { + if (k == null) continue; + e.appendChild(typeof k === 'string' ? document.createTextNode(k) : k); + } + return e; +} +function icon(id) { + const s = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + const u = document.createElementNS('http://www.w3.org/2000/svg', 'use'); + u.setAttribute('href', '#' + id); + s.appendChild(u); + return s; } -// ==================== TRASH ==================== -function toggleTrash() { - showTrash = !showTrash; - const btn = document.getElementById('trashBtn'); - const actions = document.getElementById('trashActions'); - if (btn) { btn.classList.toggle('active', showTrash); btn.title = showTrash ? 'Back to entries' : 'Trash'; } - if (actions) actions.classList.toggle('hidden', !showTrash); - loadEntries(); - playSound('click'); -} -async function restoreEntry(id, noToast) { - try { const r = await fetch(API + '/entries/' + id + '/restore', { method: 'POST', headers: { 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken } }); if (r.ok) { if (!noToast) { toast('✅ Restored!'); await loadEntries(); playSound('success'); } } } catch (e) { if (!noToast) toast('⚠️ Error', 'error'); } -} -async function toggleFavorite(id) { - try { await fetch(API + '/entries/' + id + '/favorite', { method: 'POST', headers: { 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken } }); const e = entries.find(x => x.id == id); if (e) e.favorite = e.favorite ? 0 : 1; renderFolders(); render(); playSound('click'); } catch (e) {} -} -async function permanentDelete(id, silent) { - const doDelete = async () => { - try { const r = await fetch(API + '/entries/' + id + '?permanent=1', { method: 'DELETE', headers: { 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken } }); if (r.ok) { order = order.filter(x => x != id); localStorage.setItem('entryOrder', JSON.stringify(order)); if (!silent) { toast('🗑️ Permanently deleted'); await loadEntries(); playSound('error'); } } } catch (e) { if (!silent) toast('⚠️ Error', 'error'); } - }; - if (silent) { await doDelete(); return; } - const btn = document.querySelector('.delete-btn[data-id="' + id + '"]'); - if (btn) showBatchConfirm(btn, 'Permanently delete?', doDelete); - else showBatchConfirm(document.body, 'Permanently delete?', doDelete); -} -async function emptyTrash() { - const btn = document.querySelector('.empty-trash-btn'); - showBatchConfirm(btn || document.body, 'Delete ALL trashed entries?', async () => { - try { const r = await fetch(API + '/entries/trash/empty', { method: 'DELETE', headers: { 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken } }); if (r.ok) { toast('🗑️ Trash emptied'); await loadEntries(); playSound('error'); } } catch (e) { toast('⚠️ Error', 'error'); } - }); -} -function timeAgo(dateStr) { - if (!dateStr) return ''; - const now = new Date(); const d = new Date(dateStr + 'Z'); - const days = 30 - Math.floor((now - d) / (1000 * 60 * 60 * 24)); - return days <= 0 ? 'Expiring' : days + 'd left'; -} +// ============================================================ +// AUTH +// ============================================================ -// ==================== SETTINGS ==================== -function toggleSettings() { - document.getElementById('settingsMenu').classList.toggle('hidden'); -} -function syncSettingsUI() { - document.getElementById('soundToggleSwitch').classList.toggle('active', soundEnabled); - document.getElementById('themeToggleSwitch').classList.toggle('active', !dark); - document.getElementById('showViewBtnToggle').classList.toggle('active', showView); - document.getElementById('showEmailToggle').classList.toggle('active', showMail); - document.getElementById('autoLockTimer').value = lockMin; -} -async function registerPasskey() { +async function doLogin(e) { + e && e.preventDefault(); + const u = $('#loginUsername').value.trim(); + const p = $('#loginPassword').value; + if (!u || !p) return; + // If we are in locked mode (token still valid), try fast unlock first. + if (state.locked && state.token && state.salt && u === state.username) { + $('#loginBtn').disabled = true; + const ok = await doUnlock(p); + $('#loginBtn').disabled = false; + if (ok) return; + // unlock failed — fall through to a full login + } + $('#loginBtn').disabled = true; try { - const r = await fetch(API + '/passkey/register/begin', { - method: 'POST', - headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken } - }); - if (!r.ok) { const d = await r.json(); toast('❌ ' + (d.error || 'Failed'), 'error'); return; } - const opts = await r.json(); - opts.challenge = b642ab(opts.challenge); - opts.user.id = b642ab(opts.user.id); - if (!window.PublicKeyCredential) { toast('❌ Passkeys not supported', 'error'); return; } - const cred = await navigator.credentials.create({ publicKey: opts }); - const result = { - id: cred.id, - response: { - clientDataJSON: a2b64(cred.response.clientDataJSON), - attestationObject: a2b64(cred.response.attestationObject) - } - }; - const r2 = await fetch(API + '/passkey/register/complete', { - method: 'POST', - headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken }, - body: JSON.stringify(result) - }); - if (r2.ok) { toast('✅ Passkey registered!'); playSound('success'); } - else { const d = await r2.json(); toast('❌ ' + (d.error || 'Failed'), 'error'); } - } catch (e) { toast('⚠️ Passkey setup failed: ' + e.message, 'error'); } -} - -// ==================== INIT ==================== -function init() { - document.getElementById('autoLockTimer').value = lockMin; - applyTheme(); - // document.getElementById('usernameInput').style.display = showMail ? '' : 'none'; - loadUsername(); - const sl = localStorage.getItem('savedLoginUser'); - if (sl) document.getElementById('loginUsername').value = sl; - syncSettingsUI(); - // View dropdown - const vdd = document.getElementById('viewDropdown'); - const vBtn = document.getElementById('viewDropdownBtn'); - const vMenu = document.getElementById('viewDropdownMenu'); - const vIcons = {grid:'🟫',compact:'📝',list:'📋',table:'📊',card:'🃏',grouped:'📂',detail:'🔍'}; - const updateViewBtn = () => { vBtn.textContent = (vIcons[view] || '🟫') + ' ' + view.charAt(0).toUpperCase() + view.slice(1) + ' ▾'; }; - updateViewBtn(); - vMenu.querySelectorAll('.view-opt').forEach(o => o.classList.toggle('active', o.dataset.view === view)); - vBtn.onclick = (e) => { e.stopPropagation(); vMenu.classList.toggle('hidden'); }; - vMenu.onclick = (e) => { - const opt = e.target.closest('.view-opt'); - if (!opt) return; - vMenu.querySelectorAll('.view-opt').forEach(o => o.classList.remove('active')); - opt.classList.add('active'); - view = opt.dataset.view; - detailIndex = 0; - localStorage.setItem('vaultView', view); - updateViewBtn(); - render(); - playSound('click'); - }; - document.addEventListener('click', () => vMenu.classList.add('hidden')); - const clearBtn = document.getElementById('clearSearchBtn'); - if (clearBtn) clearBtn.style.display = 'none'; - document.getElementById('addModal').addEventListener('click', e => { - if (e.target === e.currentTarget) closeAdd(); - }); - // Close settings when clicking outside - document.addEventListener('click', (e) => { - const menu = document.getElementById('settingsMenu'); - const btn = document.getElementById('settingsBtn'); - if (menu && !menu.classList.contains('hidden') && !menu.contains(e.target) && e.target !== btn) { - menu.classList.add('hidden'); - } - }); -} - -function toggleViewBtn() { showView = !showView; localStorage.setItem('showViewBtn', showView); syncSettingsUI(); render(); playSound('click'); } -function toggleShowEmail() { showMail = !showMail; localStorage.setItem('showEmail', showMail); syncSettingsUI(); render(); playSound('click'); } - -// ==================== GENERATOR ==================== -function openGen() { document.getElementById('genModal').style.display = 'flex'; genPwd(); playSound('open'); } -function closeGen() { document.getElementById('genModal').style.display = 'none'; playSound('close'); } -function onLenChange() { document.getElementById('lenVal').textContent = document.getElementById('pwdLen').value; genPwd(); } -function genPreset(len, chars) { - document.getElementById('pwdLen').value = len; - document.getElementById('lenVal').textContent = len; - document.getElementById('useUpper').checked = chars.includes('upper'); - document.getElementById('useLower').checked = chars.includes('lower'); - document.getElementById('useNum').checked = chars.includes('num'); - document.getElementById('useSym').checked = chars.includes('sym'); - genPwd(); - playSound('click'); -} -function genPwd() { const l = parseInt(document.getElementById('pwdLen').value); let c = ''; if (document.getElementById('useUpper').checked) c += 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; if (document.getElementById('useLower').checked) c += 'abcdefghijklmnopqrstuvwxyz'; if (document.getElementById('useNum').checked) c += '0123456789'; if (document.getElementById('useSym').checked) c += '!@#$%^&*()_+-=[]{}|;:,.<>?'; if (!c) { document.getElementById('genPreview').textContent = 'Select option'; return; } let p = ''; const max = 256 - (256 % c.length); const buf = new Uint8Array(1); for (let i = 0; i < l; i++) { do { crypto.getRandomValues(buf); } while (buf[0] >= max); p += c.charAt(buf[0] % c.length); } genPwdVal = p; document.getElementById('genPreview').textContent = p; } -function useGen() { - if (!genPwdVal) genPwd(); - // Put the generated password into the add‑modal’s password field - const pwdField = document.getElementById('addPassword'); - if (pwdField) { - pwdField.value = genPwdVal; - checkAddStrength(); // update the strength bar - } - navigator.clipboard.writeText(genPwdVal); - toast('🎲 Copied!'); - closeGen(); // closes the generator modal, not the add modal -} -function populateFolderSelects() { - ['addFolder', 'editFolder'].forEach(id => { - const select = document.getElementById(id); - if (!select) return; - select.innerHTML = ''; - folders.forEach(f => { - if (!f) return; - const option = document.createElement('option'); - option.value = f; - option.textContent = '📁 ' + f; - if (f === selectedFolder) option.selected = true; - select.appendChild(option); - }); - }); -} -function openAdd() { - document.getElementById('addSite').value = ''; - document.getElementById('addPassword').value = ''; - document.getElementById('addStrengthBar').className = 'strength-bar s0'; - loadUsername(); - populateFolderSelects(); - document.getElementById('addModal').classList.add('show'); - document.getElementById('addSite').focus(); - playSound('open'); -} - -function closeAdd() { - document.getElementById('addModal').classList.remove('show'); - playSound('close'); -} - -function checkRegStrength() { - const p = document.getElementById('regPassword').value; - const bar = document.getElementById('regStrengthBar'); - let s = 0; - if (p.length >= 8) s++; - if (p.length >= 12) s++; - if (/[A-Z]/.test(p) && /[a-z]/.test(p)) s++; - if (/\d/.test(p)) s++; - if (/[!@#$%^&*()_+\-=\[\]{}|;:,.<>?]/.test(p)) s++; - bar.className = 'strength-bar s' + Math.min(4, s); -} -function checkAddStrength() { - const p = document.getElementById('addPassword').value; - const bar = document.getElementById('addStrengthBar'); - let s = 0; - if (p.length >= 8) s++; - if (p.length >= 12) s++; - if (/[A-Z]/.test(p) && /[a-z]/.test(p)) s++; - if (/\d/.test(p)) s++; - if (/[!@#$%^&*()_+\-=\[\]{}|;:,.<>?]/.test(p)) s++; - bar.className = 'strength-bar s' + Math.min(4, s); -} -// ==================== AUTH ==================== -function switchTab(t) { document.querySelectorAll('.auth-tab').forEach(x => x.classList.remove('active')); event.target.classList.add('active'); document.getElementById('loginForm').classList.toggle('hidden', t !== 'login'); document.getElementById('registerForm').classList.toggle('hidden', t !== 'register'); } - -async function loginWithPasskey() { - if (!window.PublicKeyCredential) { toast('❌ Passkeys not supported', 'error'); return; } - const u = document.getElementById('loginUsername').value.trim(); - if (!u) { toast('Enter username first', 'error'); return; } - document.getElementById('loginBtn').disabled = true; - try { - const r = await fetch(API + '/passkey/login/begin', { + const r = await api('/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ username: u }) + body: JSON.stringify({ username: u, masterPassword: p }), }); - if (!r.ok) { const d = await r.json(); toast('❌ ' + (d.error || 'Failed'), 'error'); document.getElementById('loginBtn').disabled = false; return; } - const opts = await r.json(); - opts.challenge = b642ab(opts.challenge); - opts.allowCredentials.forEach(c => { c.id = b642ab(c.id); }); - const cred = await navigator.credentials.get({ publicKey: opts }); - const result = { - id: cred.id, - response: { - clientDataJSON: a2b64(cred.response.clientDataJSON), - authenticatorData: a2b64(cred.response.authenticatorData), - signature: a2b64(cred.response.signature), - userHandle: cred.response.userHandle ? a2b64(cred.response.userHandle) : null - } - }; - const r2 = await fetch(API + '/passkey/login/complete', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(result) - }); - const d = await r2.json(); - if (r2.ok) { - token = d.token; csrfToken = d.csrfToken || ''; curUser = d.username || u; - sessionStorage.setItem('authToken', token); - sessionStorage.setItem('csrfToken', csrfToken); - sessionStorage.setItem('currentUsername', curUser); - // Try to restore crypto key from sessionStorage - const restored = await restoreCryptoKey(); - if (!restored) { - // Need master password once to derive crypto key - const mp = await new Promise(resolve => { - const overlay = document.createElement('div'); - overlay.className = 'custom-modal-overlay show'; - overlay.innerHTML = `

🔑 One more step

Enter your master password to unlock the vault

`; - document.body.appendChild(overlay); - document.getElementById('passkeyTempBtn').onclick = () => resolve(document.getElementById('passkeyTempPwd').value); - overlay.addEventListener('keydown', function handler(e) { if (e.key === 'Enter') { resolve(document.getElementById('passkeyTempPwd').value); overlay.remove(); document.removeEventListener('keydown', handler); } }); - }); - const m = document.querySelector('.custom-modal-overlay.show'); - if (m) m.remove(); - cryptoKey = await deriveKey(mp, d.salt); - persistCryptoKey(); - } - await loadFolders(); - toast('✅ Biometric login!'); - playSound('login'); - showVault(); - loadEntries(); - } else { toast('❌ ' + (d.error || 'Failed'), 'error'); } - } catch (e) { toast('⚠️ Passkey login failed: ' + e.message, 'error'); } - finally { document.getElementById('loginBtn').disabled = false; } -} -async function login() { - const u = document.getElementById('loginUsername').value.trim(); - const p = document.getElementById('loginPassword').value; - if (!u || !p) { toast('Fill all fields', 'error'); return; } - document.getElementById('loginBtn').disabled = true; - localStorage.setItem('savedLoginUser', u); - try { - const r = await fetch(API + '/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: u, masterPassword: p }) }); - const d = await r.json(); - if (r.ok) { - token = d.token; csrfToken = d.csrfToken || ''; curUser = u; - cryptoKey = await deriveKey(p, d.salt); - persistCryptoKey(); - sessionStorage.setItem('authToken', token); - sessionStorage.setItem('csrfToken', csrfToken); - sessionStorage.setItem('currentUsername', u); - await loadFolders(); - toast('✅ Login!'); - playSound('login'); - showVault(); - loadEntries(); - } else { toast('❌ ' + (d.error || 'Invalid'), 'error'); document.getElementById('loginPassword').value = ''; } - } catch (e) { toast('⚠️ Connection error', 'error'); } - finally { document.getElementById('loginBtn').disabled = false; } + state.token = r.token; + state.csrf = r.csrfToken; + state.salt = r.salt; + state.username = u; + sessionStorage.setItem('authToken', state.token); + sessionStorage.setItem('csrfToken', state.csrf); + sessionStorage.setItem('salt', state.salt); + sessionStorage.setItem('username', state.username); + state.cryptoKey = await deriveKey(p, state.salt); + await persistCryptoKey(); + toast('Welcome back, ' + u); + await enterApp(); + } catch (err) { + toast(err.message, 'error'); + } finally { + $('#loginBtn').disabled = false; + } } -async function register() { - const u = document.getElementById('regUsername').value.trim(); - const p = document.getElementById('regPassword').value; - if (u.length < 3) { toast('Username min 3', 'error'); return; } - if (p.length < 8) { toast('Password min 8', 'error'); return; } - document.getElementById('registerBtn').disabled = true; +async function doRegister(e) { + e && e.preventDefault(); + const u = $('#regUsername').value.trim(); + const p = $('#regPassword').value; + if (u.length < 3 || p.length < 8) return toast('Min 3 / 8 chars', 'error'); + $('#registerBtn').disabled = true; try { - const r = await fetch(API + '/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: u, masterPassword: p }) }); - const d = await r.json(); - if (r.ok) { - token = d.token; csrfToken = d.csrfToken || ''; curUser = u; - cryptoKey = await deriveKey(p, d.salt); - persistCryptoKey(); - sessionStorage.setItem('authToken', token); - sessionStorage.setItem('csrfToken', csrfToken); - sessionStorage.setItem('currentUsername', u); - await loadFolders(); - toast('✅ Created!'); - playSound('register'); - showVault(); - loadEntries(); - } else { toast('❌ ' + (d.error || 'Failed'), 'error'); } - } catch (e) { toast('⚠️ Connection error', 'error'); } - finally { document.getElementById('registerBtn').disabled = false; } + const r = await api('/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: u, masterPassword: p }), + }); + state.token = r.token; + state.csrf = r.csrfToken; + state.salt = r.salt; + state.username = u; + sessionStorage.setItem('authToken', state.token); + sessionStorage.setItem('csrfToken', state.csrf); + sessionStorage.setItem('salt', state.salt); + sessionStorage.setItem('username', state.username); + state.cryptoKey = await deriveKey(p, state.salt); + await persistCryptoKey(); + toast('Vault created'); + await enterApp(); + } catch (err) { + toast(err.message, 'error'); + } finally { + $('#registerBtn').disabled = false; + } } async function doLogout() { - saveUsername(); - if (token) { - try { await fetch(API + '/logout', { method: 'POST', headers: { 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken } }); } catch (e) {} - } - clearTimeout(idleT); clearTimeout(warnT); clearInterval(countT); - document.getElementById('idleWarning').classList.remove('show'); - token = null; csrfToken = ''; curUser = null; entries = []; cryptoKey = null; folders = ['All']; showTrash = false; + try { await api('/logout', { method: 'POST', headers: authHeaders() }); } catch (e) {} sessionStorage.clear(); - document.getElementById('authSection').classList.remove('hidden'); - document.getElementById('vaultSection').classList.add('hidden'); - document.getElementById('loginPassword').value = ''; - document.getElementById('loginUsername').value = localStorage.getItem('savedLoginUser') || ''; - document.querySelectorAll('.fab').forEach(b => b.classList.add('hidden')); + state.token = ''; state.csrf = ''; state.salt = ''; state.username = ''; + state.cryptoKey = null; state.entries = []; state.trashed = []; state.folders = ['All']; + state.locked = false; + showAuth(); + $('#loginUsername').value = ''; + $('#loginPassword').value = ''; + $('#loginUsername').readOnly = false; + $('#authHint').textContent = ''; } -function showVault() { - document.getElementById('authSection').classList.add('hidden'); - document.getElementById('vaultSection').classList.remove('hidden'); - document.getElementById('currentUser').textContent = '👤 ' + curUser; - //document.getElementById('usernameInput').style.display = showMail ? '' : 'none'; - document.getElementById('autoLockTimer').value = lockMin; - loadUsername(); - renderFolders(); - populateFolderSelects(); - syncSettingsUI(); - document.querySelectorAll('.fab').forEach(b => b.classList.remove('hidden')); - resetIdle(); +// Lock: do NOT hit /logout — keep server session alive, just drop the in-memory +// crypto key. On unlock, /reauth validates the master password and we re-derive. +function lockVault() { + sessionStorage.removeItem('cryptoKey'); + state.cryptoKey = null; + state.entries = []; + state.trashed = []; + state.locked = true; + showAuth(); + $('#loginUsername').value = state.username; + $('#loginUsername').readOnly = true; + $('#authHint').textContent = 'Vault locked — enter master password to unlock'; + $('#loginPassword').value = ''; + $('#loginPassword').focus(); } -// ==================== ENTRIES ==================== -function applyOrder(list) { if (!list || !list.length) return []; if (!order || !order.length) return list; const map = new Map(list.filter(e => e && e.id).map(e => [e.id, e])); const ord = []; order.forEach(id => { if (map.has(id)) { ord.push(map.get(id)); map.delete(id); } }); map.forEach(e => ord.push(e)); return ord; } - -async function loadEntries(q) { +// Unlock flow: validate master pw via /reauth (which uses current session), +// then re-derive the crypto key locally without rotating session/csrf. +async function doUnlock(p) { try { - let url = API + '/entries?deleted=' + (showTrash ? '1' : '0'); - if (q) url += '&search=' + encodeURIComponent(q); - const r = await fetch(url, { headers: { 'Authorization': 'Bearer ' + token } }); - if (r.ok) { - const raw = await r.json(); - entries = []; - for (const e of raw) { - if (e.encryption_method === 'client') { - const pw = await decryptPwd(e.encrypted_password, e.iv); - entries.push({ id: e.id, site: e.site, username: e.username, password: pw, folder: e.folder || 'All', deleted_at: e.deleted_at, favorite: e.favorite || 0 }); - } else { - entries.push({ id: e.id, site: e.site, username: e.username, password: e.password || '', folder: e.folder || 'All', deleted_at: e.deleted_at, favorite: e.favorite || 0 }); - } - } - entries = applyOrder(entries); - entries.sort((a, b) => (b.favorite || 0) - (a.favorite || 0)); - document.getElementById('connectionStatus').textContent = '🟢 Connected'; - document.getElementById('entryCount').textContent = '(' + entries.length + ' entries)'; - renderFolders(); - populateFolderSelects(); - render(); - } else if (r.status === 401) { toast('Session expired', 'error'); doLogout(); } - } catch (e) { document.getElementById('connectionStatus').textContent = '🔴 Error'; toast('Connection error', 'error'); } -} - -function getFilteredEntries(noFolder) { - if (showTrash) return entries; - if (noFolder || selectedFolder === 'All') return entries; - return entries.filter(e => (e.folder || 'All') === selectedFolder); -} - -function getGridCols() { - const c = document.getElementById('entriesContainer'); - if (!c || !c.firstElementChild) return 1; - const w = c.firstElementChild.offsetWidth; - const gap = parseInt(getComputedStyle(c).columnGap) || 0; - return Math.max(1, Math.round(c.offsetWidth / (w + gap))); -} - -// ==================== RENDER ==================== -function render() { - const c = document.getElementById('entriesContainer'); - c.className = ''; - if (showTrash) c.classList.add('trash-view'); - c.classList.add(view + '-view'); - const filtered = getFilteredEntries(); - if (!filtered.length) { c.innerHTML = '
' + (showTrash ? '📭 Trash empty' : '📭 No entries') + '
'; return; } - if (view === 'table') { - let h = '' + (showMail ? '' : '') + '' + (!showTrash ? '' : '') + ''; - filtered.forEach(e => { - const sel = selectedIds.has(e.id); - h += '' + - '' + - '' + - (showMail ? '' : '') + - ''; - if (!showTrash) { - h += '' + - ''; + await api('/reauth', { + method: 'POST', + headers: authHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ masterPassword: p }), + }); + state.cryptoKey = await deriveKey(p, state.salt); + await persistCryptoKey(); + state.locked = false; + $('#loginUsername').readOnly = false; + $('#authHint').textContent = ''; + toast('Unlocked'); + await enterApp(); + return true; + } catch (err) { + if (err.message === 'Invalid password') { + toast('Wrong master password', 'error'); } else { - h += '' + - ''; + // session expired — fall back to full login + sessionStorage.clear(); + state.token = ''; state.csrf = ''; state.salt = ''; + state.locked = false; + $('#loginUsername').readOnly = false; + $('#authHint').textContent = 'Session expired, please sign in again'; + toast('Session expired', 'warning'); } - h += ''; - }); - h += '
SiteUserPasswordFolderDeletedActions
' + (e.favorite ? '⭐' : '') + '🌐 ' + highlightText(e.site, searchQuery) + '👤 ' + highlightText(e.username, searchQuery) + '••••••••📁 ' + esc(e.folder || 'All') + '' + - ' ' + - ' ' + - ' ' + - '' + - '🗑️ ' + timeAgo(e.deleted_at) + '' + - ' ' + - '' + - '
'; - c.innerHTML = h; - } else if (view === 'grouped') { - c.innerHTML = groupedC(getFilteredEntries(true)); - } else if (view === 'detail') { - c.innerHTML = detailC(getFilteredEntries(view === 'grouped')); - } else { - c.innerHTML = filtered.map(e => { - if (view === 'grid' || view === 'card') return gridC(e); - if (view === 'compact') return compC(e); - return listC(e); - }).join(''); - } - attachEvents(); - setupDrag(); -} - -function gridC(e) { - const sel = selectedIds.has(e.id); - let html = '
'; - html += '
'; - if (showTrash) { - html += ''; - html += ''; - } else { - html += ''; - html += ''; - html += ''; - } - html += '
'; - html += '
🌐 ' + highlightText(e.site, searchQuery) + '
'; - if (showMail) html += '
👤 ' + highlightText(e.username, searchQuery) + '
'; - html += '
📁 ' + esc(e.folder || 'All') + '
'; - if (!showTrash) { - html += '
••••••••
' + - '
'; - } else { - html += '
🗑️ ' + timeAgo(e.deleted_at) + '
'; - } - html += '
'; - return html; -} -function listC(e) { - const sel = selectedIds.has(e.id); - let html = '
'; - html += '
'; - if (showTrash) { - html += ''; - html += ''; - } else { - html += ''; - html += ''; - html += ''; - } - html += '
'; - html += '
'; - return html; -} -function compC(e) { - const sel = selectedIds.has(e.id); - let html = '
'; - html += '
'; - if (showTrash) { - html += ''; - html += ''; - } else { - html += ''; - html += ''; - html += ''; - } - html += '
'; - html += '🌐 ' + highlightText(e.site, searchQuery) + ''; - if (showMail) html += '👤 ' + highlightText(e.username, searchQuery) + ''; - if (!showTrash) { - html += '📁 ' + esc(e.folder || 'All') + ''; - html += '••••••••'; - html += ''; - } else { - html += '🗑️ ' + timeAgo(e.deleted_at) + ''; - } - html += '
'; - return html; -} - -function groupedC(list) { - const groups = {}; - list.forEach(e => { - const f = e.folder || 'All'; - if (!groups[f]) groups[f] = []; - groups[f].push(e); - }); - let html = ''; - for (const [folder, items] of Object.entries(groups)) { - html += '
📁 ' + esc(folder) + ' ' + items.length + '
'; - items.forEach(e => { - const sel = selectedIds.has(e.id); - html += '
'; - html += '
'; - if (showTrash) { - html += ''; - html += ''; - } else { - html += ''; - html += ''; - html += ''; - } - html += '
'; - html += '
'; - }); - } - return html; -} - -function detailC(list) { - if (detailIndex >= list.length) detailIndex = 0; - if (detailIndex < 0) detailIndex = list.length - 1; - const e = list[detailIndex]; - const hasPrev = detailIndex > 0, hasNext = detailIndex < list.length - 1; - const sel = selectedIds.has(e.id); - let html = '
'; - html += ''; - html += '' + (detailIndex + 1) + ' of ' + list.length + ''; - html += ''; - html += '
'; - html += '
'; - html += '
Site🌐 ' + highlightText(e.site, searchQuery) + '
'; - if (showMail) html += '
Username👤 ' + highlightText(e.username, searchQuery) + '
'; - if (!showTrash) { - html += '
Password••••••••
'; - html += '
Folder📁 ' + esc(e.folder || 'All') + '
'; - html += '
' + - '' + - '' + - '' + - '' + - '
'; - } else { - html += '
Deleted🗑️ ' + timeAgo(e.deleted_at) + '
'; - html += '
' + - '' + - '' + - '
'; - } - html += '
'; - return html; -} - -function goDetail(dir) { - const list = getFilteredEntries(); - detailIndex += dir; - if (detailIndex < 0) detailIndex = list.length - 1; - if (detailIndex >= list.length) detailIndex = 0; - render(); -} - -// ==================== EVENTS ==================== -function showConfirm(btn, message, callback) { - const id = btn.dataset.id; - const existing = document.querySelector('.custom-confirm'); - if (existing) existing.remove(); - - const confirm = document.createElement('div'); - confirm.className = 'custom-confirm show'; - confirm.innerHTML = - '
' + message + '
' + - '
' + - '' + - '' + - '
'; - document.body.appendChild(confirm); - - const rect = btn.getBoundingClientRect(); - confirm.style.top = (rect.top - 60) + 'px'; - let leftPos = rect.left - confirm.offsetWidth + rect.width; - if (leftPos < 10) leftPos = 10; - confirm.style.left = leftPos + 'px'; - - const yesBtn = confirm.querySelector('.confirm-yes'); - const noBtn = confirm.querySelector('.confirm-no'); - - const cleanup = () => { - confirm.remove(); - document.removeEventListener('keydown', keyHandler); - }; - - const keyHandler = (e) => { - if (e.key === 'Enter' || e.key === 'y' || e.key === 'Y') { - e.preventDefault(); - cleanup(); - callback(id); - showZigzagToast(btn, '🗑️ Deleted!', 'error'); - playSound('delete'); - } else if (e.key === 'Escape' || e.key === 'n' || e.key === 'N') { - e.preventDefault(); - cleanup(); - } - }; - - yesBtn.onclick = () => { - cleanup(); - callback(id); - showZigzagToast(btn, '🗑️ Deleted!', 'error'); - playSound('delete'); - }; - noBtn.onclick = () => cleanup(); - - // Focus the confirm box so keyboard events are captured - confirm.tabIndex = 0; - confirm.focus(); - document.addEventListener('keydown', keyHandler); - - // Close if clicking outside - setTimeout(() => { - document.addEventListener('click', function closeConfirm(e) { - if (!confirm.contains(e.target) && e.target !== btn) { - cleanup(); - document.removeEventListener('click', closeConfirm); - } - }); - }, 10); -} -function entryPw(id) { const e = entries.find(x => x.id == id); return e ? e.password : ''; } -function showBatchConfirm(btn, message, callback) { - const existing = document.querySelector('.batch-confirm-overlay'); - if (existing) existing.remove(); - const overlay = document.createElement('div'); - overlay.className = 'batch-confirm-overlay'; - overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;z-index:9999;background:transparent;'; - const confirm = document.createElement('div'); - confirm.className = 'custom-confirm show'; - confirm.style.cssText = 'position:fixed;background:var(--bg2);border:1px solid var(--accent);border-radius:0.8rem;padding:0.7rem 1rem;z-index:10000;box-shadow:0 10px 30px rgba(0,0,0,0.5);font-size:0.8rem;color:var(--text);white-space:nowrap;'; - confirm.innerHTML = - '
' + message + '
' + - '
' + - '' + - '' + - '
'; - const rect = btn.getBoundingClientRect(); - confirm.style.top = (rect.top - 60) + 'px'; - let leftPos = rect.left - 20; - if (leftPos < 10) leftPos = 10; - confirm.style.left = leftPos + 'px'; - overlay.appendChild(confirm); - document.body.appendChild(overlay); - const yesBtn = confirm.querySelector('.confirm-yes'); - const noBtn = confirm.querySelector('.confirm-no'); - const cleanup = () => { overlay.remove(); document.removeEventListener('keydown', keyHandler); }; - const keyHandler = (e) => { - if (e.key === 'Enter' || e.key === 'y' || e.key === 'Y') { e.preventDefault(); cleanup(); callback(); playSound('delete'); } - else if (e.key === 'Escape' || e.key === 'n' || e.key === 'N') { e.preventDefault(); cleanup(); } - }; - yesBtn.onclick = () => { cleanup(); callback(); playSound('delete'); }; - noBtn.onclick = () => cleanup(); - overlay.onclick = (e) => { if (e.target === overlay) cleanup(); }; - confirm.tabIndex = 0; confirm.focus(); - document.addEventListener('keydown', keyHandler); -} -function attachEvents() { - document.querySelectorAll('.delete-btn').forEach(b => b.onclick = function(ev) { ev.stopPropagation(); const id = parseInt(this.dataset.id); if (showTrash) { permanentDelete(id); } else { showConfirm(this, 'Delete this entry?', async id2 => { await delEntry(id2, true); await loadEntries(); toast('📦 Moved to trash', 'success', { label: '↩ Undo', cb: async () => { await restoreEntry(id2, true); await loadEntries(); toast('↩ Restored'); playSound('success'); } }); playSound('delete'); }); } }); - document.querySelectorAll('.edit-btn').forEach(b => b.onclick = function(ev) { ev.stopPropagation(); openEdit(this.dataset.id); }); - document.querySelectorAll('.star-btn').forEach(b => b.onclick = function(ev) { ev.stopPropagation(); toggleFavorite(this.dataset.id); }); - document.querySelectorAll('.copy-p').forEach(b => b.onclick = async function(ev) { ev.stopPropagation(); const pw = entryPw(this.dataset.id); try { await navigator.clipboard.writeText(pw); this.textContent = '✓'; const btn = this; setTimeout(() => { btn.textContent = '📋'; }, 1000); showZigzagToast(this, '📋 Copied!', 'success'); playSound('copy'); } catch (e) { showZigzagToast(this, 'Failed', 'error'); } }); - // Double-click entry to edit - document.querySelectorAll('[draggable="true"], .detail-card').forEach(el => { - el.addEventListener('dblclick', function(ev) { - const id = this.dataset.id; - if (id && !showTrash) { - ev.preventDefault(); - selectedIds.clear(); - selectedIds.add(parseInt(id)); - updateBatchBar(); - render(); - openEdit(id); - } - }); - }); - if (showView) { - document.querySelectorAll('.pw-display.pw-hover').forEach(el => { - el.addEventListener('mouseenter', function() { - const pw = entryPw(this.id.replace('p-', '')); - this.textContent = pw; - }); - el.addEventListener('mouseleave', function() { - this.textContent = '••••••••'; - }); - }); + return false; } } -function setupDrag() { - const c = document.getElementById('entriesContainer'); if (!c) return; - c.querySelectorAll('[draggable="true"]').forEach(el => { - el.ondragstart = function(e) { draggedId = this.dataset.id; const dragIds = selectedIds.has(parseInt(draggedId)) && selectedIds.size > 1 ? [...selectedIds] : [parseInt(draggedId)]; c.querySelectorAll('[draggable="true"]').forEach(card => { card.classList.toggle('drag-dim', dragIds.includes(parseInt(card.dataset.id))); }); e.dataTransfer.setData('text/plain', this.dataset.id); e.dataTransfer.effectAllowed = 'move'; if (dragIds.length > 1) { const cv = document.createElement('canvas'); cv.width = 100; cv.height = 50; const g = cv.getContext('2d'); for (let i = dragIds.length - 1; i >= 0; i--) { const ox = i * 4, oy = i * 4; g.fillStyle = i === 0 ? 'rgba(30,40,55,0.9)' : 'rgba(59,130,246,0.15)'; g.fillRect(ox, oy, 80, 36); g.strokeStyle = 'rgba(255,255,255,0.15)'; g.strokeRect(ox, oy, 80, 36); } g.fillStyle = 'rgba(0,0,0,0.7)'; g.fillRect(0, 34, 100, 16); g.fillStyle = '#fff'; g.font = '11px sans-serif'; g.textAlign = 'center'; g.fillText(dragIds.length + ' items', 50, 46); cv.style.position = 'fixed'; cv.style.top = '-1000px'; document.body.appendChild(cv); e.dataTransfer.setDragImage(cv, 6, 10); setTimeout(() => cv.remove(), 50); } }; - el.ondragend = function(e) { c.querySelectorAll('.drag-dim').forEach(card => card.classList.remove('drag-dim')); draggedId = null; c.querySelectorAll('.drag-over').forEach(x => x.classList.remove('drag-over')); document.getElementById('trashBtn')?.classList.remove('drag-over'); }; - el.ondragover = function(e) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; if (this.dataset.id !== draggedId) this.classList.add('drag-over'); }; - el.ondragleave = function(e) { this.classList.remove('drag-over'); }; - el.ondrop = function(e) { e.preventDefault(); e.stopPropagation(); this.classList.remove('drag-over'); const fromId = parseInt(e.dataTransfer.getData('text/plain')); const toId = parseInt(this.dataset.id); if (!fromId || !toId) return; const ids = selectedIds.has(fromId) && selectedIds.size > 1 ? [...selectedIds] : [fromId]; if (ids.length === 1 && ids[0] === toId) return; const base = order.length > 0 ? order : entries.filter(e => e).map(e => e.id); const filtered = base.filter(id => !ids.includes(id)); const idx = filtered.indexOf(toId); idx > -1 ? filtered.splice(idx, 0, ...ids) : filtered.push(...ids); order = filtered; // Ensure no entries are lost from order -entries.forEach(e => { if (!order.includes(e.id)) order.push(e.id); }); localStorage.setItem('entryOrder', JSON.stringify(order)); const map = new Map(entries.filter(e => e).map(e => [e.id, e])); entries = order.map(id => map.get(id)).filter(e => e); entries.sort((a, b) => (b.favorite || 0) - (a.favorite || 0)); render(); }; - }); -} -// ==================== EDIT ==================== -function openEdit(id) { - let e = null; - for (let i = 0; i < entries.length; i++) { if (entries[i] && entries[i].id == id) { e = entries[i]; break; } } - if (!e) return; - const folderSelect = document.getElementById('editFolder'); - folderSelect.innerHTML = ''; - folders.forEach(f => { - if (!f) return; - const option = document.createElement('option'); - option.value = f; - option.textContent = '📁 ' + f; - if (f === (e.folder || 'All')) option.selected = true; - folderSelect.appendChild(option); - }); - document.getElementById('editId').value = id; - document.getElementById('editSite').value = e.site; - document.getElementById('editUsername').value = e.username; - document.getElementById('editPassword').value = e.password; - document.getElementById('editPassword').type = 'password'; - document.getElementById('editModal').classList.add('show'); - document.getElementById('editSite').focus(); - playSound('open'); -} -function closeEdit() { document.getElementById('editModal').classList.remove('show'); render(); playSound('close'); } -function toggleEditPassword() { const f = document.getElementById('editPassword'); f.type = f.type === 'password' ? 'text' : 'password'; } -async function saveEdit() { - const id = document.getElementById('editId').value; - const site = document.getElementById('editSite').value.trim(); - const username = document.getElementById('editUsername').value.trim(); - const password = document.getElementById('editPassword').value; - const folder = document.getElementById('editFolder').value; - if (!site || !password) { toast('Site and password required', 'error'); return; } +// ============================================================ +// DATA LOADING +// ============================================================ + +async function loadFolders() { try { - const enc = await encryptPwd(password); - const r = await fetch(API + '/entries/' + id, { method: 'PUT', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken }, body: JSON.stringify({ site, username, encrypted_password: enc.encrypted, iv: enc.iv, folder }) }); - if (r.ok) { toast('✅ Updated!'); closeEdit(); loadEntries(); playSound('success'); } - else { const d = await r.json(); toast('❌ ' + (d.error || 'Failed'), 'error'); } - } catch (e) { toast('⚠️ Error', 'error'); } + const r = await api('/folders', { headers: authHeaders() }); + // 'All' is always implicit first + state.folders = ['All'].concat(r.filter(n => n !== 'All')); + } catch (e) { /* ignore */ } } -//======================== batch selection ========================== -function toggleSelectEntry(id, e) { - if (e?.shiftKey && lastSelectedId !== null) { - const ids = getFilteredEntries().map(x => x.id); - const i1 = ids.indexOf(lastSelectedId); - const i2 = ids.indexOf(id); - if (i1 > -1 && i2 > -1) { - const start = Math.min(i1, i2), end = Math.max(i1, i2); - for (let i = start; i <= end; i++) selectedIds.add(ids[i]); + +async function loadEntries() { + try { + const r = await api('/entries', { headers: authHeaders() }); + state.entries = Array.isArray(r) ? r : []; + } catch (e) { + if (e.message === 'Invalid session' || e.message === 'Session expired') { + return doLogout(); } - } else if (e?.ctrlKey || e?.metaKey) { - if (selectedIds.has(id)) selectedIds.delete(id); else selectedIds.add(id); - } else { - if (selectedIds.size === 1 && selectedIds.has(id)) { selectedIds.clear(); } - else { selectedIds.clear(); selectedIds.add(id); } + toast(e.message, 'error'); } - lastSelectedId = id; - arrowAnchor = -1; - arrowFocus = -1; - updateBatchBar(); - render(); } -function clearSelection() { - selectedIds.clear(); - lastSelectedId = null; - arrowAnchor = -1; - arrowFocus = -1; - hideBatchBar(); - render(); +async function loadTrash() { + try { + const r = await api('/entries?deleted=1', { headers: authHeaders() }); + state.trashed = Array.isArray(r) ? r : []; + } catch (e) { state.trashed = []; } } -function updateBatchBar() { - const existing = document.getElementById('batchBar'); - if (existing) existing.remove(); - if (selectedIds.size === 0) return; - const bar = document.createElement('div'); - bar.id = 'batchBar'; - bar.className = 'batch-actions'; - bar.innerHTML = `${selectedIds.size} selected`; - if (showTrash) { - bar.innerHTML += ` - - - `; +// ============================================================ +// FILTERS / DERIVED +// ============================================================ + +function filteredEntries() { + // Trash view shows its own list (loaded separately) + let list; + if (state.view === 'trash') { + list = state.trashed; } else { - bar.innerHTML += ` - - - - `; + list = state.entries; + if (state.view === 'favorites') list = list.filter(e => e.favorite); + else if (state.view.startsWith('folder:')) { + const f = state.view.slice(7); + if (f !== 'All') list = list.filter(e => e.folder === f); + } else if (state.view.startsWith('tag:')) { + const t = state.view.slice(4); + list = list.filter(e => parseTags(e.tags).includes(t)); + } } - bar.innerHTML += ``; - document.body.appendChild(bar); + if (state.search) { + const q = state.search.toLowerCase(); + list = list.filter(e => + (e.site || '').toLowerCase().includes(q) || + (e.username || '').toLowerCase().includes(q) || + (e.tags || '').toLowerCase().includes(q) + ); + } + return list; } -function hideBatchBar() { - const bar = document.getElementById('batchBar'); - if (bar) bar.remove(); +function parseTags(s) { + if (!s) return []; + return s.split(',').map(t => t.trim()).filter(Boolean); +} + +function allTags() { + const set = new Set(); + state.entries.forEach(e => parseTags(e.tags).forEach(t => set.add(t))); + return Array.from(set).sort(); +} + +function viewTitle() { + if (state.view === 'all') return 'All items'; + if (state.view === 'favorites') return 'Favorites'; + if (state.view === 'trash') return 'Trash'; + if (state.view.startsWith('folder:')) return state.view.slice(7); + if (state.view.startsWith('tag:')) return '# ' + state.view.slice(4); + return 'Items'; +} + +// ============================================================ +// RENDER +// ============================================================ + +function render() { + renderSidebar(); + renderGrid(); +} + +function renderSidebar() { + // counts + $('#countAll').textContent = state.entries.length; + $('#countFav').textContent = state.entries.filter(e => e.favorite).length; + $('#countTrash').textContent = state.trashed.length || ''; + + // active state for top-level items + $$('#appShell .nav-item[data-view]').forEach(n => { + n.classList.toggle('is-active', n.dataset.view === state.view); + }); + + // folders + const fList = $('#foldersList'); + fList.innerHTML = ''; + state.folders.forEach(name => { + const count = state.entries.filter(e => e.folder === name).length; + const key = 'folder:' + name; + const item = el('button', { + class: 'nav-item' + (state.view === key ? ' is-active' : ''), + 'data-folder': name, + on: { click: () => setView(key) }, + }); + item.appendChild(icon('i-folder')); + item.appendChild(el('span', null, name)); + item.appendChild(el('span', { class: 'nav-count' }, String(count))); + + // drag and drop target + item.addEventListener('dragover', e => { e.preventDefault(); item.classList.add('drag-over'); }); + item.addEventListener('dragleave', () => item.classList.remove('drag-over')); + item.addEventListener('drop', async e => { + e.preventDefault(); + item.classList.remove('drag-over'); + const id = e.dataTransfer.getData('text/plain'); + if (id) await moveEntryToFolder(parseInt(id), name); + }); + + fList.appendChild(item); + }); + + // tags + const tList = $('#tagsList'); + tList.innerHTML = ''; + const tags = allTags(); + if (tags.length === 0) { + tList.appendChild(el('div', { class: 'sidebar-section-header', style: 'padding:6px 10px;color:var(--text-faint);font-size:11px;text-transform:none;letter-spacing:0' }, 'No tags yet')); + } else { + tags.forEach(t => { + const key = 'tag:' + t; + const count = state.entries.filter(e => parseTags(e.tags).includes(t)).length; + const item = el('button', { + class: 'nav-item' + (state.view === key ? ' is-active' : ''), + on: { click: () => setView(key) }, + }); + item.appendChild(icon('i-tag')); + item.appendChild(el('span', null, t)); + item.appendChild(el('span', { class: 'nav-count' }, String(count))); + + // Drop target: drag a card here to add this tag to that entry + item.addEventListener('dragover', e => { e.preventDefault(); item.classList.add('drag-over'); }); + item.addEventListener('dragleave', () => item.classList.remove('drag-over')); + item.addEventListener('drop', async e => { + e.preventDefault(); + item.classList.remove('drag-over'); + const id = parseInt(e.dataTransfer.getData('text/plain')); + if (id) await addTagToEntry(id, t); + }); + + tList.appendChild(item); + }); + } +} + +async function addTagToEntry(id, tag) { + const e = state.entries.find(x => x.id === id); + if (!e) return; + const tags = parseTags(e.tags); + if (tags.includes(tag)) { + toast('Already tagged with "' + tag + '"', 'warning'); + return; + } + tags.push(tag); + try { + await api('/entries/' + id, { + method: 'PUT', + headers: authHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ + site: e.site, username: e.username, + encrypted_password: e.encrypted_password, iv: e.iv, + folder: e.folder, tags: tags.join(','), + }), + }); + e.tags = tags.join(','); + render(); + toast('Tagged "' + tag + '"'); + } catch (err) { toast(err.message, 'error'); } +} + +function renderGrid() { + $('#contentTitle').textContent = viewTitle(); + const list = filteredEntries(); + $('#contentMeta').textContent = list.length + (list.length === 1 ? ' item' : ' items'); + + // Empty trash action button next to title (only in trash view) + const oldBtn = $('#emptyTrashBtn'); + if (oldBtn) oldBtn.remove(); + if (state.view === 'trash' && state.trashed.length > 0) { + const btn = el('button', { + class: 'btn btn-ghost btn-sm', id: 'emptyTrashBtn', + style: 'margin-left:auto', + on: { click: emptyTrash }, + }, withIcon('i-trash', 'Empty trash')); + $('.content-header').appendChild(btn); + } + + // Batch action bar (shown when selection is non-empty) + renderBatchBar(); + + const grid = $('#entryGrid'); + grid.className = 'entry-grid' + (state.viewMode === 'list' ? ' is-list' : ''); + grid.innerHTML = ''; + if (list.length === 0) { + showEmptyState(); + return; + } + $('#emptyState').classList.add('is-hidden'); + + list.forEach(e => grid.appendChild(renderCard(e))); +} + +function showEmptyState() { + const illustration = $('#emptyIllustration use'); + const title = $('#emptyTitle'); + const msg = $('#emptyMessage'); + + if (state.search) { + illustration.setAttribute('href', '#i-empty-search'); + title.textContent = 'No matches'; + msg.innerHTML = 'Try a different search term, or click + New to add a new entry.'; + } else if (state.view === 'trash') { + illustration.setAttribute('href', '#i-empty-trash'); + title.textContent = 'Trash is empty'; + msg.textContent = 'Deleted entries land here. They can be restored at any time.'; + } else if (state.view === 'favorites') { + illustration.setAttribute('href', '#i-empty-vault'); + title.textContent = 'No favorites yet'; + msg.innerHTML = 'Click the on any entry to add it to favorites.'; + } else if (state.view.startsWith('folder:')) { + illustration.setAttribute('href', '#i-empty-vault'); + title.textContent = 'Folder is empty'; + msg.innerHTML = 'Move entries here by drag & drop, or by setting their folder.'; + } else if (state.view.startsWith('tag:')) { + illustration.setAttribute('href', '#i-empty-vault'); + title.textContent = 'No entries with this tag'; + msg.textContent = 'Drop a card on the tag to add this tag to that entry.'; + } else { + illustration.setAttribute('href', '#i-empty-vault'); + title.textContent = 'Your vault is empty'; + msg.innerHTML = 'Click + New to add your first password. They\'re encrypted before they leave your machine.'; + } + $('#emptyState').classList.remove('is-hidden'); +} + +// Skeleton loaders shown during the initial fetch right after login/unlock +function showSkeletons(n) { + const grid = $('#entryGrid'); + grid.innerHTML = ''; + $('#emptyState').classList.add('is-hidden'); + for (let i = 0; i < n; i++) { + const card = el('div', { class: 'skeleton-card' }); + const row = el('div', { class: 'skeleton-row' }); + row.appendChild(el('div', { class: 'skeleton-circle' })); + const col = el('div', { style: 'flex:1' }); + col.appendChild(el('div', { class: 'skeleton-line w-60' })); + col.appendChild(el('div', { class: 'skeleton-line w-40', style: 'margin-bottom:0' })); + row.appendChild(col); + card.appendChild(row); + card.appendChild(el('div', { class: 'skeleton-line w-80' })); + card.appendChild(el('div', { class: 'skeleton-line w-40', style: 'margin-bottom:0' })); + grid.appendChild(card); + } +} + +function initials(s) { + return (s || '?').replace(/[^a-zA-Z0-9]/g, '').slice(0, 2).toUpperCase() || '?'; +} + +// Compact-action kebab menu shown on each card when state.compactActions is on. +function buildKebabMenu(entry) { + const wrap = el('div', { class: 'entry-kebab-wrap' }); + const btn = el('button', { + class: 'entry-kebab', + title: 'More actions', + on: { click: ev => { + ev.stopPropagation(); + // Close any other open menu, then toggle this one + $$('.entry-kebab-menu.is-open').forEach(m => { + if (m !== menu) m.classList.remove('is-open'); + }); + menu.classList.toggle('is-open'); + } }, + }); + btn.appendChild(icon('i-more')); + wrap.appendChild(btn); + + const menu = el('div', { class: 'entry-kebab-menu' }); + const items = [ + { lbl: entry.favorite ? 'Unfavorite' : 'Favorite', ic: 'i-star', fn: () => toggleFavorite(entry.id) }, + { lbl: 'Copy password', ic: 'i-copy', fn: () => copyPassword(entry) }, + { lbl: 'Copy username', ic: 'i-user', fn: () => copyUsername(entry) }, + { lbl: 'Edit', ic: 'i-edit', fn: () => openSlideOver(entry.id) }, + { lbl: 'Move to trash', ic: 'i-trash', fn: () => deleteEntry(entry.id), danger: true }, + ]; + items.forEach(it => { + const mi = el('button', { + class: 'kebab-item' + (it.danger ? ' is-danger' : ''), + on: { click: ev => { + ev.stopPropagation(); + menu.classList.remove('is-open'); + it.fn(); + } }, + }); + mi.appendChild(icon(it.ic)); + mi.appendChild(el('span', null, it.lbl)); + menu.appendChild(mi); + }); + wrap.appendChild(menu); + return wrap; +} + +function renderCard(e) { + const inTrash = state.view === 'trash'; + const checked = state.checked.has(e.id); + const card = el('article', { + class: 'entry-card' + + (state.selectedId === e.id ? ' is-selected' : '') + + (checked ? ' is-checked' : ''), + 'data-id': e.id, + draggable: inTrash ? 'false' : 'true', + on: { click: ev => handleCardClick(ev, e, inTrash) }, + }); + + if (!inTrash) { + card.addEventListener('dragstart', ev => { + ev.dataTransfer.setData('text/plain', String(e.id)); + ev.dataTransfer.effectAllowed = 'move'; + }); + } + + // head: avatar acts as a multi-select checkbox (click on avatar -> toggle) + const head = el('div', { class: 'entry-head' }); + const avatar = el('div', { + class: 'entry-avatar is-checkable', + title: 'Click to select', + on: { click: ev => { ev.stopPropagation(); toggleChecked(e.id); } }, + }, checked ? '✓' : initials(e.site)); + head.appendChild(avatar); + const title = el('div', { class: 'entry-title' }); + title.appendChild(el('b', null, e.site)); + // Username row with inline copy button (visible on card hover) + const userRow = el('small', { class: 'entry-user-row' }); + userRow.appendChild(el('span', null, displayUsername(e.username))); + if (e.username) { + const copyUser = el('button', { + class: 'entry-copy-user', + title: 'Copy username', + on: { click: ev => { ev.stopPropagation(); copyUsername(e); } }, + }); + copyUser.appendChild(icon('i-copy')); + userRow.appendChild(copyUser); + } + title.appendChild(userRow); + head.appendChild(title); + if (inTrash) { + // In trash: show restore + permanent delete buttons + const restore = el('button', { + class: 'icon-btn icon-btn-sm', title: 'Restore', + on: { click: ev => { ev.stopPropagation(); restoreEntry(e.id); } }, + }); + restore.appendChild(icon('i-rotate-ccw')); + const purge = el('button', { + class: 'icon-btn icon-btn-sm', title: 'Delete forever', + style: 'color:var(--danger)', + on: { click: ev => { ev.stopPropagation(); permanentDelete(e.id); } }, + }); + purge.appendChild(icon('i-trash')); + head.appendChild(restore); + head.appendChild(purge); + } else if (state.compactActions) { + // Compact mode: single kebab menu replaces fav + del + head.appendChild(buildKebabMenu(e)); + } else { + const fav = el('button', { + class: 'entry-fav' + (e.favorite ? ' is-on' : ''), + title: 'Favorite', + on: { click: ev => { ev.stopPropagation(); toggleFavorite(e.id); } }, + }); + fav.appendChild(icon('i-star')); + head.appendChild(fav); + + // Quick-delete: small X visible on card hover. Always available + // without opening the slide-over. + const del = el('button', { + class: 'entry-del', + title: 'Move to trash', + on: { click: ev => { ev.stopPropagation(); deleteEntry(e.id); } }, + }); + del.appendChild(icon('i-x')); + head.appendChild(del); + } + card.appendChild(head); + + // password row (placeholder dots, click reveals via slide-over) + const pwRow = el('div', { class: 'entry-pw-row' }); + pwRow.appendChild(el('span', { class: 'entry-pw', id: 'pw-' + e.id }, '••••••••')); + const copyBtn = el('button', { + class: 'icon-btn icon-btn-sm', + title: 'Copy password', + on: { click: ev => { ev.stopPropagation(); copyPassword(e); } }, + }); + copyBtn.appendChild(icon('i-copy')); + pwRow.appendChild(copyBtn); + card.appendChild(pwRow); + + // meta chips: folder + first 2 tags + const meta = el('div', { class: 'entry-meta' }); + if (e.folder) { + const chip = el('span', { class: 'entry-chip is-folder' }); + chip.appendChild(icon('i-folder')); + chip.appendChild(el('span', null, e.folder)); + meta.appendChild(chip); + } + parseTags(e.tags).slice(0, 3).forEach(t => { + const chip = el('span', { class: 'entry-chip' }); + chip.appendChild(icon('i-tag')); + chip.appendChild(el('span', null, t)); + meta.appendChild(chip); + }); + card.appendChild(meta); + + return card; +} + +// ============================================================ +// MARQUEE (rubber-band) SELECTION +// ============================================================ +// Click-drag on empty space in the entry grid draws a rectangle. +// Cards whose bounding box intersects the rectangle become selected. +// Shift/Ctrl held = add to existing selection (otherwise replace). + +let marqueeEl = null; +let marqueeStart = null; +let marqueeAdditive = false; +let marqueeInitialSet = null; + +function startMarquee(ev) { + // Only fire on left mouse button, and only when starting on grid background + if (ev.button !== 0) return; + if (ev.target.closest('.entry-card')) return; // ignore drags from cards + if (ev.target.closest('.batch-bar')) return; + if (!ev.target.closest('#entryGrid')) return; + + marqueeAdditive = ev.shiftKey || ev.ctrlKey || ev.metaKey; + marqueeInitialSet = new Set(state.checked); + if (!marqueeAdditive) state.checked.clear(); + + marqueeStart = { x: ev.clientX, y: ev.clientY }; + marqueeEl = el('div', { class: 'marquee' }); + Object.assign(marqueeEl.style, { + left: marqueeStart.x + 'px', + top: marqueeStart.y + 'px', + width: '0px', height: '0px', + }); + document.body.appendChild(marqueeEl); + ev.preventDefault(); + + document.addEventListener('mousemove', updateMarquee); + document.addEventListener('mouseup', endMarquee); +} + +function updateMarquee(ev) { + if (!marqueeEl) return; + const x1 = Math.min(marqueeStart.x, ev.clientX); + const y1 = Math.min(marqueeStart.y, ev.clientY); + const x2 = Math.max(marqueeStart.x, ev.clientX); + const y2 = Math.max(marqueeStart.y, ev.clientY); + Object.assign(marqueeEl.style, { + left: x1 + 'px', top: y1 + 'px', + width: (x2 - x1) + 'px', height: (y2 - y1) + 'px', + }); + + // Re-check intersections + const marqueeRect = { left: x1, top: y1, right: x2, bottom: y2 }; + state.checked = new Set(marqueeAdditive ? marqueeInitialSet : []); + $$('#entryGrid .entry-card').forEach(card => { + const r = card.getBoundingClientRect(); + const intersects = !(r.right < marqueeRect.left || r.left > marqueeRect.right || + r.bottom < marqueeRect.top || r.top > marqueeRect.bottom); + if (intersects) { + const id = parseInt(card.dataset.id); + state.checked.add(id); + card.classList.add('is-checked'); + } else if (!marqueeInitialSet.has(parseInt(card.dataset.id))) { + card.classList.remove('is-checked'); + } + }); +} + +function endMarquee() { + document.removeEventListener('mousemove', updateMarquee); + document.removeEventListener('mouseup', endMarquee); + if (marqueeEl) marqueeEl.remove(); + marqueeEl = null; + marqueeStart = null; + marqueeInitialSet = null; + // Re-render so the batch bar appears with the new count + avatar states + renderGrid(); +} + +// ============================================================ +// MULTI-SELECTION + BATCH ACTIONS +// ============================================================ + +let selectionAnchor = null; // last single-clicked card, used for shift+click range + +function toggleChecked(id) { + if (state.checked.has(id)) state.checked.delete(id); + else state.checked.add(id); + renderGrid(); +} + +function handleCardClick(ev, entry, inTrash) { + // Ctrl/Cmd+Click: toggle this card in selection + if (ev.ctrlKey || ev.metaKey) { + toggleChecked(entry.id); + selectionAnchor = entry.id; + return; + } + // Shift+Click: select range from anchor to this card + if (ev.shiftKey && selectionAnchor !== null) { + const list = filteredEntries(); + const a = list.findIndex(x => x.id === selectionAnchor); + const b = list.findIndex(x => x.id === entry.id); + if (a >= 0 && b >= 0) { + const lo = Math.min(a, b), hi = Math.max(a, b); + for (let i = lo; i <= hi; i++) state.checked.add(list[i].id); + renderGrid(); + return; + } + } + // If any cards are already checked, a plain click toggles (sticky multi-select) + if (state.checked.size > 0) { + toggleChecked(entry.id); + selectionAnchor = entry.id; + return; + } + // Default: open slide-over (or trash actions) + selectionAnchor = entry.id; + if (inTrash) openTrashActions(entry.id); + else openSlideOver(entry.id); +} + +function clearChecked() { + state.checked.clear(); + renderGrid(); +} + +async function batchMoveToFolder(folder) { + const ids = Array.from(state.checked); + if (!ids.length) return; + for (const id of ids) { + const e = state.entries.find(x => x.id === id); + if (!e || e.folder === folder) continue; + try { + await api('/entries/' + id, { + method: 'PUT', + headers: authHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ + site: e.site, username: e.username, + encrypted_password: e.encrypted_password, iv: e.iv, + folder, tags: e.tags || '', + }), + }); + e.folder = folder; + } catch (err) { /* ignore individual failures */ } + } + toast(ids.length + ' moved to ' + folder); + clearChecked(); +} + +async function batchAddTag(tag) { + tag = (tag || '').trim(); + if (!tag) return; + const ids = Array.from(state.checked); + if (!ids.length) return; + for (const id of ids) { + const e = state.entries.find(x => x.id === id); + if (!e) continue; + const tags = parseTags(e.tags); + if (tags.includes(tag)) continue; + tags.push(tag); + try { + await api('/entries/' + id, { + method: 'PUT', + headers: authHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ + site: e.site, username: e.username, + encrypted_password: e.encrypted_password, iv: e.iv, + folder: e.folder, tags: tags.join(','), + }), + }); + e.tags = tags.join(','); + } catch (err) {} + } + toast('Tagged ' + ids.length + ' as "' + tag + '"'); + clearChecked(); } async function batchDelete() { - const count = selectedIds.size; - const ids = [...selectedIds]; - const btn = document.querySelector('#batchBar .btn-danger'); - showBatchConfirm(btn || document.body, 'Move ' + count + ' entries to trash?', async () => { - for (const id of ids) await delEntry(id, true); - clearSelection(); - await loadEntries(); - toast('📦 Moved ' + count + ' entries to trash', 'success', { - label: '↩ Undo', - cb: async () => { for (const id of ids) await restoreEntry(id, true); await loadEntries(); toast('↩ Restored ' + ids.length + ' entries'); playSound('success'); }, - onExpire: null - }); - playSound('delete'); - }); -} - -async function batchPermanentDelete() { - const count = selectedIds.size; - const btn = document.querySelector('#batchBar .btn-danger'); - showBatchConfirm(btn || document.body, 'Permanently delete ' + count + ' entries?', async () => { - for (const id of selectedIds) await permanentDelete(id, true); - toast('🗑️ Permanently deleted ' + count + ' entries'); - playSound('error'); - clearSelection(); - await loadEntries(); + const ids = Array.from(state.checked); + if (!ids.length) return; + const ok = await confirmDialog({ + title: 'Move to trash', + message: '' + ids.length + ' entries will be moved to trash.', + okText: 'Move to trash', + danger: true, }); + if (!ok) return; + for (const id of ids) { + try { + await api('/entries/' + id, { method: 'DELETE', headers: authHeaders() }); + } catch (err) {} + } + toast(ids.length + ' moved to trash'); + await loadEntries(); + await loadTrash(); + clearChecked(); } async function batchRestore() { - const count = selectedIds.size; - for (const id of selectedIds) await restoreEntry(id, true); - toast('✅ Restored ' + count + ' entries'); - playSound('success'); - clearSelection(); - await loadEntries(); -} - -async function batchMove() { - const folder = document.getElementById('batchFolder')?.value || 'All'; - for (const id of selectedIds) { - const e = entries.find(x => x.id == id); - if (e) { - e.folder = folder; - const enc = await encryptPwd(e.password); - await fetch(API + '/entries/' + id, { - method: 'PUT', - headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken }, - body: JSON.stringify({ site: e.site, username: e.username, encrypted_password: enc.encrypted, iv: enc.iv, folder }) - }); - } - } - clearSelection(); - loadEntries(); -} -// ==================== ADD / DELETE ==================== -async function addEntry() { - const site = document.getElementById('addSite').value.trim(); - const user = document.getElementById('addUsername').value.trim(); - const pass = document.getElementById('addPassword').value; - if (!site || !pass) { toast('❌ Site and password required', 'error'); return; } - if (user) localStorage.setItem('savedUsername', user); - const btn = document.getElementById('addEntryBtn'); - btn.disabled = true; - try { - const enc = await encryptPwd(pass); - const folder = document.getElementById('addFolder').value; - const r = await fetch(API + '/entries', { - method: 'POST', - headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken }, - body: JSON.stringify({ site, username: user, encrypted_password: enc.encrypted, iv: enc.iv, encryption_method: 'client', folder }) - }); - if (r.ok) { - closeAdd(); - toast('✅ Saved!'); - loadEntries(); - playSound('success'); - } else { - const d = await r.json(); - toast('❌ ' + (d.error || 'Failed'), 'error'); - } - } catch (e) { toast('⚠️ Error', 'error'); } - finally { btn.disabled = false; } -} -async function delEntry(id, noToast) { - try { - const r = await fetch(API + '/entries/' + id, { method: 'DELETE', headers: { 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken } }); - if (r.ok) { selectedIds.delete(id); order = order.filter(x => x != id); localStorage.setItem('entryOrder', JSON.stringify(order)); if (!noToast) { toast('📦 Moved to trash'); await loadEntries(); playSound('delete'); } } - } catch (e) { if (!noToast) toast('Error', 'error'); } -} - -// ==================== UTILS ==================== -function searchEntries() { - const input = document.getElementById('searchInput'); - searchQuery = input.value.trim(); - const btn = document.getElementById('clearSearchBtn'); - if (btn) btn.style.display = searchQuery ? 'block' : 'none'; - loadEntries(searchQuery); -} -function showExportModal() { - const overlay = document.createElement('div'); - overlay.className = 'custom-modal-overlay show'; - overlay.innerHTML = `

📤 Export Passwords

Re-enter master password to export plaintext passwords

`; - document.body.appendChild(overlay); - document.getElementById('cancelExport').onclick = () => overlay.remove(); - document.getElementById('confirmExport').onclick = async () => { - const pwd = document.getElementById('exportPassword').value; - if (!pwd) { toast('Enter your master password', 'error'); return; } + const ids = Array.from(state.checked); + if (!ids.length) return; + for (const id of ids) { try { - const r = await fetch(API + '/reauth', { - method: 'POST', - headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token, 'X-CSRF-Token': csrfToken }, - body: JSON.stringify({ masterPassword: pwd }) - }); - if (r.ok) { - overlay.remove(); - const b = new Blob([JSON.stringify(entries, null, 2)], { type: 'application/json' }); - const a = document.createElement('a'); - a.href = URL.createObjectURL(b); - a.download = 'vault-' + new Date().toISOString().slice(0, 10) + '.json'; - a.click(); - URL.revokeObjectURL(a.href); - toast('Exported!'); - playSound('success'); - } else { - toast('❌ Invalid password', 'error'); - } - } catch (e) { toast('⚠️ Error', 'error'); } + await api('/entries/' + id + '/restore', { method: 'POST', headers: authHeaders() }); + } catch (err) {} + } + toast(ids.length + ' restored'); + await loadEntries(); + await loadTrash(); + clearChecked(); +} + +async function batchPermDelete() { + const ids = Array.from(state.checked); + if (!ids.length) return; + const ok = await confirmDialog({ + title: 'Delete forever', + message: '' + ids.length + ' entries will be permanently deleted. This cannot be undone.', + okText: 'Delete forever', + danger: true, + }); + if (!ok) return; + for (const id of ids) { + try { + await api('/entries/' + id + '?permanent=1', { method: 'DELETE', headers: authHeaders() }); + } catch (err) {} + } + toast(ids.length + ' deleted permanently'); + await loadTrash(); + clearChecked(); +} + +function renderBatchBar() { + const existing = $('#batchBar'); + if (existing) existing.remove(); + if (state.checked.size === 0) return; + + const inTrash = state.view === 'trash'; + const bar = el('div', { class: 'batch-bar', id: 'batchBar' }); + bar.appendChild(el('span', { class: 'batch-bar-count' }, state.checked.size + ' selected')); + + if (inTrash) { + // Trash view: Restore | Delete forever + bar.appendChild(el('button', { + class: 'btn btn-ghost btn-sm', + on: { click: batchRestore }, + }, withIcon('i-rotate-ccw', 'Restore'))); + bar.appendChild(el('button', { + class: 'btn btn-ghost btn-sm', + style: 'color:var(--danger)', + on: { click: batchPermDelete }, + }, withIcon('i-trash', 'Delete forever'))); + } else { + // Normal view: Move to folder | Add tag | Delete (soft) + const moveSel = el('select'); + moveSel.appendChild(el('option', { value: '' }, 'Move to folder…')); + state.folders.forEach(f => moveSel.appendChild(el('option', { value: f }, f))); + moveSel.addEventListener('change', () => { + if (moveSel.value) batchMoveToFolder(moveSel.value); + }); + bar.appendChild(moveSel); + + bar.appendChild(el('button', { + class: 'btn btn-ghost btn-sm', + on: { click: async () => { + const t = await promptDialog({ + title: 'Add tag', + message: 'Add a tag to ' + state.checked.size + ' selected entries', + placeholder: 'tag name', + okText: 'Add', + }); + if (t) batchAddTag(t); + } }, + }, withIcon('i-tag', 'Add tag'))); + + bar.appendChild(el('button', { + class: 'btn btn-ghost btn-sm', + style: 'color:var(--danger)', + on: { click: batchDelete }, + }, withIcon('i-trash', 'Delete'))); + } + + bar.appendChild(el('div', { class: 'grow' })); + bar.appendChild(el('button', { + class: 'btn btn-ghost btn-sm', + on: { click: clearChecked }, + }, withIcon('i-x', 'Clear'))); + + const content = $('.content'); + content.insertBefore(bar, $('#entryGrid')); +} + +// ============================================================ +// SLIDE-OVER +// ============================================================ + +// Edit-in-place state for the slide-over +let soState = null; + +async function openSlideOver(id) { + const e = state.entries.find(x => x.id === id); + if (!e) return; + state.selectedId = id; + + $('#slideoverTitle').textContent = e.site; + const body = $('#slideoverBody'); + body.innerHTML = ''; + + const plain = await decryptPwd(e.encrypted_password, e.iv); + + // Track original values so we can detect "dirty" + soState = { + id: e.id, + original: { + site: e.site, username: e.username || '', password: plain, + folder: e.folder || 'All', tags: parseTags(e.tags).join(','), + }, + tags: parseTags(e.tags), + originalEncrypted: e.encrypted_password, + originalIV: e.iv, }; - overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); }); - playSound('open'); -} -function esc(t) { const d = document.createElement('div'); d.textContent = t; return d.innerHTML; } -function escRegex(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } -function highlightText(text, query) { - if (!query || !query.trim()) return esc(text); - const re = new RegExp('(' + escRegex(query.trim()) + ')', 'gi'); - return esc(text).replace(re, '$1'); -} -function showShortcutsHelp() { - const overlay = document.createElement('div'); - overlay.className = 'custom-modal-overlay show'; - overlay.innerHTML = `

⌨️ Keyboard Shortcuts

Alt+NNew entryCtrl+ASelect allCtrl+FSearchAlt+TToggle trashCtrl+LLock vaultCtrl+SSave entryDelDelete selectedEscClose modal / deselect◀ ▶Detail view nav?Show this help

💡 Click any entry to select, Shift+click for range, Ctrl+click to toggle

`; - document.body.appendChild(overlay); - overlay.addEventListener('click', e => { if (e.target === overlay) overlay.remove(); }); -} -function clearSearch() { - const input = document.getElementById('searchInput'); - input.value = ''; - searchQuery = ''; - const btn = document.getElementById('clearSearchBtn'); - if (btn) btn.style.display = 'none'; - loadEntries(); - input.focus(); -} -// ==================== STARTUP – session persistence ==================== -init(); -applyTheme(); -if (token && curUser) { - (async () => { - if (await restoreCryptoKey()) { - await loadFolders(); - showVault(); - loadEntries(); + body.appendChild(soEditableField('Site', 'soSite', e.site)); + body.appendChild(soEditableField('Username', 'soUsername', e.username || '')); + body.appendChild(soPasswordField(plain)); + body.appendChild(soFolderField(e.folder || 'All')); + body.appendChild(soTagsField()); + + // Action row — Save button is hidden until dirty. No Delete here: + // the quick-X on each card handles deletion (avoids duplication). + const actions = el('div', { class: 'slideover-actions' }); + const saveBtn = el('button', { + class: 'btn btn-primary', + id: 'soSaveBtn', + style: 'display:none', + on: { click: soSave }, + }, withIcon('i-check', 'Save')); + actions.appendChild(saveBtn); + body.appendChild(actions); + + // Wire change detection + ['#soSite', '#soUsername', '#soPassword', '#soFolder'].forEach(sel => { + const el = $(sel); if (el) el.addEventListener('input', soDirtyCheck); + if (el) el.addEventListener('change', soDirtyCheck); + }); + + $('#slideover').classList.add('is-open'); + renderGrid(); +} + +function soEditableField(label, id, value) { + const wrap = el('div', { class: 'slideover-field' }); + wrap.appendChild(el('div', { class: 'slideover-field-label' }, label)); + const input = el('input', { type: 'text', id, value, class: 'so-input' }); + wrap.appendChild(input); + return wrap; +} + +function soPasswordField(plain) { + const wrap = el('div', { class: 'slideover-field' }); + wrap.appendChild(el('div', { class: 'slideover-field-label' }, 'Password')); + const row = el('div', { class: 'so-pw-row' }); + const input = el('input', { + type: 'password', id: 'soPassword', value: plain, + class: 'so-input', style: 'flex:1;font-family:JetBrains Mono,monospace', + }); + const toggle = el('button', { class: 'icon-btn icon-btn-sm', type: 'button', title: 'Show/hide' }); + toggle.appendChild(icon('i-eye')); + toggle.addEventListener('click', () => { + input.type = input.type === 'password' ? 'text' : 'password'; + }); + const copy = el('button', { class: 'icon-btn icon-btn-sm', type: 'button', title: 'Copy' }); + copy.appendChild(icon('i-copy')); + copy.addEventListener('click', () => { + if (Bridge.copySecure(input.value, 30000)) { + toast('Copied · clears in 30s'); } else { - sessionStorage.clear(); - token = null; - curUser = null; - document.getElementById('loginUsername').value = localStorage.getItem('savedLoginUser') || ''; + navigator.clipboard.writeText(input.value).then(() => { + toast('Copied · clears in 30s'); + setTimeout(() => navigator.clipboard.writeText('').catch(()=>{}), 30000); + }); } - })(); + }); + const gen = el('button', { class: 'icon-btn icon-btn-sm', type: 'button', title: 'Generate' }); + gen.appendChild(icon('i-dice')); + gen.addEventListener('click', () => { + openGen('slideover'); + }); + row.appendChild(input); + row.appendChild(toggle); + row.appendChild(copy); + row.appendChild(gen); + wrap.appendChild(row); + return wrap; } -['click', 'keypress', 'scroll', 'mousemove'].forEach(e => document.addEventListener(e, () => { if (token) resetIdle(); })); -// Trash button as drop target (set up once, outside setupDrag to avoid duplicates) -(function() { - const trashBtn = document.getElementById('trashBtn'); - if (trashBtn) { - trashBtn.addEventListener('dragover', e => { if (!showTrash) { e.preventDefault(); trashBtn.classList.add('drag-over'); } }); - trashBtn.addEventListener('dragleave', () => trashBtn.classList.remove('drag-over')); - trashBtn.addEventListener('drop', async function(e) { +function soFolderField(current) { + const wrap = el('div', { class: 'slideover-field' }); + wrap.appendChild(el('div', { class: 'slideover-field-label' }, 'Folder')); + const sel = el('select', { id: 'soFolder', class: 'so-input' }); + state.folders.forEach(f => { + const opt = el('option', { value: f }, f); + if (f === current) opt.selected = true; + sel.appendChild(opt); + }); + wrap.appendChild(sel); + return wrap; +} + +function soTagsField() { + const wrap = el('div', { class: 'slideover-field' }); + wrap.appendChild(el('div', { class: 'slideover-field-label' }, 'Tags')); + const cont = el('div', { class: 'chip-input', id: 'soTagsContainer' }); + const input = el('input', { + type: 'text', id: 'soTagsField', + placeholder: 'add a tag…', autocomplete: 'off', + }); + cont.appendChild(input); + wrap.appendChild(cont); + // Render existing chips + renderSoChips(); + input.addEventListener('keydown', e => { + if (e.key === 'Enter' || e.key === ',') { e.preventDefault(); - this.classList.remove('drag-over'); - const id = parseInt(e.dataTransfer.getData('text/plain')); - if (!id) return; - const ids = selectedIds.has(id) && selectedIds.size > 1 ? [...selectedIds] : [id]; - for (const sid of ids) await delEntry(sid, true); - clearSelection(); - await loadEntries(); - toast('📦 Moved ' + ids.length + ' entries to trash', 'success', { - label: '↩ Undo', - cb: async () => { for (const sid of ids) await restoreEntry(sid, true); await loadEntries(); toast('↩ Restored ' + ids.length + ' entries'); playSound('success'); } + const v = input.value.trim().replace(/,/g, ''); + if (v && !soState.tags.includes(v)) { + soState.tags.push(v); + renderSoChips(); + soDirtyCheck(); + } + input.value = ''; + } else if (e.key === 'Backspace' && !input.value && soState.tags.length) { + soState.tags.pop(); + renderSoChips(); + soDirtyCheck(); + } + }); + return wrap; +} + +function renderSoChips() { + const cont = $('#soTagsContainer'); + if (!cont) return; + const input = $('#soTagsField'); + $$('.chip', cont).forEach(c => c.remove()); + soState.tags.forEach((t, i) => { + const chip = el('span', { class: 'chip' }); + chip.appendChild(el('span', null, t)); + const x = el('button', { + type: 'button', + on: { click: () => { soState.tags.splice(i, 1); renderSoChips(); soDirtyCheck(); } }, + }); + x.appendChild(icon('i-x')); + chip.appendChild(x); + cont.insertBefore(chip, input); + }); +} + +function soDirtyCheck() { + if (!soState) return; + const cur = { + site: ($('#soSite') || {}).value || '', + username: ($('#soUsername') || {}).value || '', + password: ($('#soPassword') || {}).value || '', + folder: ($('#soFolder') || {}).value || '', + tags: soState.tags.join(','), + }; + const dirty = + cur.site !== soState.original.site || + cur.username !== soState.original.username || + cur.password !== soState.original.password || + cur.folder !== soState.original.folder || + cur.tags !== soState.original.tags; + const btn = $('#soSaveBtn'); + if (btn) btn.style.display = dirty ? '' : 'none'; +} + +async function soSave() { + if (!soState) return; + const site = $('#soSite').value.trim(); + const user = $('#soUsername').value.trim(); + const pwd = $('#soPassword').value; + const fold = $('#soFolder').value; + if (!site || !pwd) return toast('Site and password required', 'error'); + + // Only re-encrypt if password changed; otherwise reuse stored ciphertext + let enc; + if (pwd === soState.original.password) { + enc = { encrypted: soState.originalEncrypted, iv: soState.originalIV }; + } else { + enc = await encryptPwd(pwd); + } + try { + await api('/entries/' + soState.id, { + method: 'PUT', + headers: authHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ + site, username: user, + encrypted_password: enc.encrypted, iv: enc.iv, + folder: fold, tags: soState.tags.join(','), + }), + }); + toast('Saved'); + await loadEntries(); + // Re-open with updated data + const updated = state.entries.find(x => x.id === soState.id); + if (updated) openSlideOver(updated.id); + else closeSlideOver(); + render(); + } catch (err) { toast(err.message, 'error'); } +} + +function withIcon(name, label) { + const frag = document.createDocumentFragment(); + frag.appendChild(icon(name)); + frag.appendChild(document.createTextNode(label)); + return frag; +} + +function field(label, value) { + const wrap = el('div', { class: 'slideover-field' }); + wrap.appendChild(el('div', { class: 'slideover-field-label' }, label)); + wrap.appendChild(el('div', { class: 'slideover-field-value' }, value)); + return wrap; +} + +function passwordField(plain) { + const wrap = el('div', { class: 'slideover-field' }); + wrap.appendChild(el('div', { class: 'slideover-field-label' }, 'Password')); + let revealed = false; + const valueRow = el('div', { class: 'slideover-field-value' }); + const span = el('span', { style: 'flex:1;font-family:JetBrains Mono,monospace;user-select:none' }, '••••••••'); + const toggle = el('button', { class: 'icon-btn icon-btn-sm', title: 'Show/hide' }); + toggle.appendChild(icon('i-eye')); + toggle.addEventListener('click', () => { + revealed = !revealed; + span.textContent = revealed ? plain : '••••••••'; + span.style.userSelect = revealed ? 'text' : 'none'; + }); + const copy = el('button', { class: 'icon-btn icon-btn-sm', title: 'Copy' }); + copy.appendChild(icon('i-copy')); + copy.addEventListener('click', () => { + if (Bridge.copySecure(plain, 0)) { + toast('Copied'); + } else { + navigator.clipboard.writeText(plain).then(() => toast('Copied')); + } + }); + valueRow.appendChild(span); + valueRow.appendChild(toggle); + valueRow.appendChild(copy); + wrap.appendChild(valueRow); + return wrap; +} + +function closeSlideOver() { + $('#slideover').classList.remove('is-open'); + state.selectedId = null; + renderGrid(); +} + +// ============================================================ +// TAG CHIP INPUT +// ============================================================ +// Local mutable list of tags currently in the entry modal. Synced to the +// hidden #entryTags field on every change so saveEntry can read it. + +let editingTags = []; +let chipSuggestEl = null; +let chipSuggestActive = -1; + +function syncTagsHidden() { + $('#entryTags').value = editingTags.join(','); +} + +function renderChips() { + const container = $('#entryTagsInput'); + // Wipe existing chips but keep the input element + $$('.chip', container).forEach(c => c.remove()); + const input = $('#entryTagsField'); + editingTags.forEach((t, i) => { + const chip = el('span', { class: 'chip' }); + chip.appendChild(el('span', null, t)); + const x = el('button', { + type: 'button', + on: { click: () => { editingTags.splice(i, 1); renderChips(); syncTagsHidden(); } }, + }); + x.appendChild(icon('i-x')); + chip.appendChild(x); + container.insertBefore(chip, input); + }); + syncTagsHidden(); +} + +function setEditingTags(arr) { + editingTags = (arr || []).filter(Boolean).map(t => t.trim()).filter(Boolean); + renderChips(); +} + +function addTag(raw) { + const t = (raw || '').trim().replace(/,/g, ''); + if (!t) return; + if (editingTags.includes(t)) return; + editingTags.push(t); + renderChips(); +} + +function closeChipSuggest() { + if (chipSuggestEl) { chipSuggestEl.remove(); chipSuggestEl = null; } + chipSuggestActive = -1; +} + +function openChipSuggest() { + closeChipSuggest(); + const field = $('#entryTagsField'); + const q = field.value.trim().toLowerCase(); + const existing = allTags(); + const candidates = existing + .filter(t => !editingTags.includes(t)) + .filter(t => !q || t.toLowerCase().includes(q)) + .slice(0, 8); + if (!q && candidates.length === 0) return; + + chipSuggestEl = el('div', { class: 'chip-suggestions' }); + if (candidates.length === 0) { + chipSuggestEl.appendChild(el('div', { class: 'chip-suggestion-empty' }, 'Press Enter to create "' + q + '"')); + } else { + candidates.forEach((t, i) => { + const item = el('div', { + class: 'chip-suggestion' + (i === 0 ? ' is-active' : ''), + on: { mousedown: ev => { ev.preventDefault(); addTag(t); field.value = ''; closeChipSuggest(); } }, + }, t); + chipSuggestEl.appendChild(item); + }); + chipSuggestActive = 0; + } + // Position under the chip input + const rect = $('#entryTagsInput').getBoundingClientRect(); + chipSuggestEl.style.position = 'fixed'; + chipSuggestEl.style.top = (rect.bottom + 4) + 'px'; + chipSuggestEl.style.left = rect.left + 'px'; + chipSuggestEl.style.width = Math.max(160, rect.width / 2) + 'px'; + document.body.appendChild(chipSuggestEl); +} + +function moveChipSuggest(dir) { + if (!chipSuggestEl) return; + const items = $$('.chip-suggestion', chipSuggestEl); + if (items.length === 0) return; + items.forEach(it => it.classList.remove('is-active')); + chipSuggestActive = (chipSuggestActive + dir + items.length) % items.length; + items[chipSuggestActive].classList.add('is-active'); +} + +function selectActiveSuggestion() { + if (!chipSuggestEl || chipSuggestActive < 0) return false; + const items = $$('.chip-suggestion', chipSuggestEl); + if (items[chipSuggestActive]) { + addTag(items[chipSuggestActive].textContent); + $('#entryTagsField').value = ''; + closeChipSuggest(); + return true; + } + return false; +} + +// ============================================================ +// ENTRY MODAL (new / edit) +// ============================================================ + +async function openEntryModal(entry) { + populateFolderSelect(); + if (entry) { + $('#entryModalTitle').textContent = 'Edit entry'; + $('#entryId').value = entry.id; + $('#entrySite').value = entry.site; + $('#entryUsername').value = entry.username || ''; + $('#entryFolder').value = entry.folder || 'All'; + setEditingTags(parseTags(entry.tags)); + $('#entryPassword').value = await decryptPwd(entry.encrypted_password, entry.iv); + if ($('#entryPassword').value === '[ERROR]') $('#entryPassword').value = ''; + } else { + $('#entryModalTitle').textContent = 'New entry'; + $('#entryId').value = ''; + $('#entryForm').reset(); + $('#entryFolder').value = state.view.startsWith('folder:') ? state.view.slice(7) : 'All'; + setEditingTags([]); + } + $('#entryTagsField').value = ''; + closeChipSuggest(); + updateEntryStrength(); + $('#entryModal').classList.remove('is-hidden'); + $('#entrySite').focus(); +} + +function closeEntryModal() { + $('#entryModal').classList.add('is-hidden'); +} + +function populateFolderSelect() { + const sel = $('#entryFolder'); + sel.innerHTML = ''; + state.folders.forEach(f => sel.appendChild(el('option', { value: f }, f))); +} + +async function saveEntry(e) { + e && e.preventDefault(); + // Flush any pending text in the chip input as a final tag + const pending = $('#entryTagsField').value.trim(); + if (pending) { addTag(pending); $('#entryTagsField').value = ''; } + const id = $('#entryId').value; + const site = $('#entrySite').value.trim(); + const user = $('#entryUsername').value.trim(); + const pwd = $('#entryPassword').value; + const fold = $('#entryFolder').value; + const tags = editingTags.join(','); + if (!site || !pwd) return toast('Site and password required', 'error'); + + const enc = await encryptPwd(pwd); + const body = JSON.stringify({ + site, username: user, encrypted_password: enc.encrypted, iv: enc.iv, + folder: fold, tags, + }); + try { + if (id) { + await api('/entries/' + id, { + method: 'PUT', + headers: authHeaders({ 'Content-Type': 'application/json' }), + body, }); - playSound('delete'); + toast('Updated'); + } else { + await api('/entries', { + method: 'POST', + headers: authHeaders({ 'Content-Type': 'application/json' }), + body, + }); + toast('Saved'); + } + closeEntryModal(); + await loadEntries(); + render(); + } catch (err) { + toast(err.message, 'error'); + } +} + +async function restoreEntry(id) { + try { + await api('/entries/' + id + '/restore', { method: 'POST', headers: authHeaders() }); + toast('Restored'); + await loadEntries(); + await loadTrash(); + render(); + } catch (err) { toast(err.message, 'error'); } +} + +async function permanentDelete(id) { + const ok = await confirmDialog({ + title: 'Delete forever', + message: 'This entry will be permanently deleted. This cannot be undone.', + okText: 'Delete forever', + danger: true, + }); + if (!ok) return; + try { + await api('/entries/' + id + '?permanent=1', { method: 'DELETE', headers: authHeaders() }); + toast('Deleted permanently'); + await loadTrash(); + render(); + } catch (err) { toast(err.message, 'error'); } +} + +async function emptyTrash() { + if (!state.trashed.length) return; + const ok = await confirmDialog({ + title: 'Empty trash', + message: '' + state.trashed.length + ' entries will be deleted forever. This cannot be undone.', + okText: 'Empty trash', + danger: true, + }); + if (!ok) return; + try { + await api('/entries/trash/empty', { method: 'DELETE', headers: authHeaders() }); + toast('Trash emptied'); + await loadTrash(); + render(); + } catch (err) { toast(err.message, 'error'); } +} + +function openTrashActions(id) { + // For trash entries, we don't open the slide-over — actions are inline on the card. + // But user can click outside the buttons to no-op. Could open a read-only view later. +} + +async function deleteEntry(id) { + const e = state.entries.find(x => x.id === id); + if (state.askBeforeDelete) { + const ok = await confirmDialog({ + title: 'Move to trash', + message: 'Send ' + (e ? e.site : 'this entry') + ' to trash? You can restore it later.', + okText: 'Move to trash', + danger: true, + }); + if (!ok) return; + } + try { + await api('/entries/' + id, { method: 'DELETE', headers: authHeaders() }); + toast('Moved to trash'); + closeSlideOver(); + await loadEntries(); + render(); + } catch (err) { toast(err.message, 'error'); } +} + +async function toggleFavorite(id) { + try { + await api('/entries/' + id + '/favorite', { method: 'POST', headers: authHeaders() }); + const entry = state.entries.find(e => e.id === id); + if (entry) entry.favorite = entry.favorite ? 0 : 1; + render(); + } catch (e) {} +} + +async function moveEntryToFolder(id, folder) { + const e = state.entries.find(x => x.id === id); + if (!e || e.folder === folder) return; + try { + await api('/entries/' + id, { + method: 'PUT', + headers: authHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ + site: e.site, username: e.username, + encrypted_password: e.encrypted_password, iv: e.iv, + folder, tags: e.tags || '', + }), + }); + e.folder = folder; + render(); + toast('Moved to ' + folder); + } catch (err) { toast(err.message, 'error'); } +} + +async function copyPassword(entry) { + const p = await decryptPwd(entry.encrypted_password, entry.iv); + if (p === '[ERROR]') return toast('Cannot decrypt', 'error'); + if (Bridge.copySecure(p, 30000)) { + toast('Password copied · clears in 30s'); + } else { + navigator.clipboard.writeText(p).then(() => toast('Password copied · clears in 30s')); + setTimeout(() => navigator.clipboard.writeText('').catch(()=>{}), 30000); + } +} + +function copyUsername(entry) { + const u = entry.username || ''; + if (!u) return toast('No username to copy', 'warning'); + if (Bridge.copySecure(u, 0)) { + toast('Username copied'); + } else { + navigator.clipboard.writeText(u).then(() => toast('Username copied')); + } +} + +// Display helper: when `maskUsernames` setting is on, show only the first 2 +// chars followed by '***'. Used in cards/list (but slide-over always reveals). +function displayUsername(u) { + if (!u) return '—'; + if (!state.maskUsernames) return u; + if (u.length <= 2) return u + '***'; + return u.slice(0, 2) + '***'; +} + +// ============================================================ +// FOLDERS CRUD +// ============================================================ + +async function addFolder() { + const name = await promptDialog({ + title: 'New folder', + message: 'Folder name', + placeholder: 'e.g. Work', + okText: 'Create', + }); + if (!name || !name.trim()) return; + try { + await api('/folders', { + method: 'POST', + headers: authHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ name: name.trim() }), + }); + await loadFolders(); + render(); + toast('Folder created'); + } catch (e) { toast(e.message, 'error'); } +} + +// ============================================================ +// PASSWORD GENERATOR +// ============================================================ + +let genCurrent = ''; + +function genPassword() { + const len = parseInt($('#genLen').value); + $('#genLenLabel').textContent = len; + let chars = ''; + if ($('#genUpper').checked) chars += 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; + if ($('#genLower').checked) chars += 'abcdefghijklmnopqrstuvwxyz'; + if ($('#genNum').checked) chars += '0123456789'; + if ($('#genSym').checked) chars += '!@#$%^&*()_+-=[]{}|;:,.<>?'; + if (!chars) { $('#genPreview').textContent = 'Select at least one'; return; } + let p = ''; + const max = 256 - (256 % chars.length); + const buf = new Uint8Array(1); + for (let i = 0; i < len; i++) { + do { crypto.getRandomValues(buf); } while (buf[0] >= max); + p += chars.charAt(buf[0] % chars.length); + } + genCurrent = p; + $('#genPreview').textContent = p; +} + +// genTarget: 'entry' (insert into entry form) or 'standalone' (just copy/dismiss) +let genTarget = 'entry'; + +function openGen(target) { + genTarget = target || 'entry'; + // Show "Use" when targeting an editable field (entry modal or slide-over) + $('#genUse').style.display = (genTarget === 'standalone') ? 'none' : ''; + $('#genModal').classList.remove('is-hidden'); + genPassword(); +} +function closeGen() { $('#genModal').classList.add('is-hidden'); } + +// ============================================================ +// PASSWORD STRENGTH +// ============================================================ + +function computeStrength(p) { + let s = 0; + if (p.length >= 8) s += 25; + if (p.length >= 12) s += 15; + if (/[A-Z]/.test(p) && /[a-z]/.test(p)) s += 20; + if (/\d/.test(p)) s += 15; + if (/[^A-Za-z0-9]/.test(p)) s += 25; + return Math.min(100, s); +} + +function updateRegStrength() { + const p = $('#regPassword').value; + $('#regStrengthBar').style.setProperty('--strength', computeStrength(p) + '%'); +} +function updateEntryStrength() { + const p = $('#entryPassword').value; + $('#entryStrengthBar').style.setProperty('--strength', computeStrength(p) + '%'); +} + +// ============================================================ +// COMMAND PALETTE +// ============================================================ + +function openPalette() { + $('#cmdPalette').classList.remove('is-hidden'); + $('#cmdInput').value = ''; + $('#cmdInput').focus(); + renderPaletteResults(''); +} +function closePalette() { $('#cmdPalette').classList.add('is-hidden'); } + +function paletteCommands() { + return [ + { id: 'new', label: 'New entry', icon: 'i-plus', run: () => { closePalette(); openEntryModal(); } }, + { id: 'lock', label: 'Lock vault', icon: 'i-lock', run: () => { closePalette(); lockVault(); } }, + { id: 'logout', label: 'Sign out', icon: 'i-log-out', run: () => { closePalette(); doLogout(); } }, + { id: 'theme', label: 'Toggle theme', icon: 'i-sun', run: () => { closePalette(); toggleTheme(); } }, + { id: 'all', label: 'Show all items', icon: 'i-globe', run: () => { closePalette(); setView('all'); } }, + { id: 'fav', label: 'Show favorites', icon: 'i-star', run: () => { closePalette(); setView('favorites'); } }, + { id: 'trash', label: 'Show trash', icon: 'i-trash', run: () => { closePalette(); setView('trash'); } }, + ]; +} + +function renderPaletteResults(q) { + const cmds = paletteCommands(); + const entries = state.entries.map(e => ({ + id: 'entry-' + e.id, label: e.site, sub: e.username || '', + icon: 'i-globe', run: () => { closePalette(); openSlideOver(e.id); }, + })); + const all = cmds.concat(entries); + q = (q || '').toLowerCase(); + const filtered = q ? all.filter(c => c.label.toLowerCase().includes(q) || (c.sub||'').toLowerCase().includes(q)) : all; + const out = $('#cmdResults'); + out.innerHTML = ''; + filtered.slice(0, 12).forEach((c, i) => { + const it = el('div', { + class: 'cmd-item' + (i === 0 ? ' is-active' : ''), + on: { click: c.run }, + }); + it.appendChild(icon(c.icon)); + it.appendChild(el('span', null, c.label)); + if (c.sub) it.appendChild(el('span', { style: 'color:var(--text-faint);font-size:11px;margin-left:auto' }, c.sub)); + out.appendChild(it); + }); +} + +// ============================================================ +// IN-APP CONFIRM / PROMPT (no native alerts) +// ============================================================ + +let confirmResolver = null; + +function confirmDialog(opts) { + // opts: { title, message, okText, cancelText, danger } + opts = opts || {}; + $('#confirmTitle').textContent = opts.title || 'Confirm'; + $('#confirmMessage').innerHTML = opts.message || 'Are you sure?'; + $('#confirmOkBtn').lastChild.nodeValue = ' ' + (opts.okText || 'Confirm'); + $('#confirmCancelBtn').textContent = opts.cancelText || 'Cancel'; + $('#confirmOkBtn').classList.toggle('is-danger', !!opts.danger); + $('#confirmInputField').classList.add('is-hidden'); + $('#confirmModal').classList.remove('is-hidden'); + setTimeout(() => $('#confirmOkBtn').focus(), 50); + return new Promise(res => { confirmResolver = res; }); +} + +function promptDialog(opts) { + // opts: { title, message, okText, placeholder, value } + opts = opts || {}; + $('#confirmTitle').textContent = opts.title || 'Enter value'; + $('#confirmMessage').innerHTML = opts.message || ''; + $('#confirmOkBtn').lastChild.nodeValue = ' ' + (opts.okText || 'OK'); + $('#confirmCancelBtn').textContent = 'Cancel'; + $('#confirmOkBtn').classList.remove('is-danger'); + $('#confirmInputField').classList.remove('is-hidden'); + $('#confirmInput').value = opts.value || ''; + $('#confirmInput').placeholder = opts.placeholder || ''; + $('#confirmModal').classList.remove('is-hidden'); + setTimeout(() => $('#confirmInput').focus(), 50); + return new Promise(res => { confirmResolver = res; }); +} + +function closeConfirm(value) { + $('#confirmModal').classList.add('is-hidden'); + if (confirmResolver) { + const cb = confirmResolver; + confirmResolver = null; + cb(value); + } +} + +// ============================================================ +// RE-AUTH MODAL + EXPORT +// ============================================================ + +let reauthResolve = null; + +function askReauth(message) { + return new Promise(resolve => { + reauthResolve = resolve; + $('#reauthMessage').textContent = message || 'This action requires your master password.'; + $('#reauthPassword').value = ''; + $('#reauthModal').classList.remove('is-hidden'); + setTimeout(() => $('#reauthPassword').focus(), 50); + }); +} + +function closeReauth(ok) { + $('#reauthModal').classList.add('is-hidden'); + if (reauthResolve) { + const pwd = ok ? $('#reauthPassword').value : null; + const cb = reauthResolve; + reauthResolve = null; + cb(pwd); + } +} + +async function doExport() { + const pwd = await askReauth('Enter your master password to export the vault as JSON. The file will be UNENCRYPTED.'); + if (!pwd) return; + try { + await api('/reauth', { + method: 'POST', + headers: authHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ masterPassword: pwd }), + }); + } catch (err) { + toast('Wrong master password', 'error'); + return; + } + // Decrypt all entries + const out = { version: 1, exported_at: new Date().toISOString(), username: state.username, entries: [] }; + for (const e of state.entries) { + const plain = await decryptPwd(e.encrypted_password, e.iv); + out.entries.push({ + site: e.site, username: e.username, + password: plain, folder: e.folder, + tags: parseTags(e.tags), favorite: !!e.favorite, + created_at: e.created_at, updated_at: e.updated_at, }); } -})(); + const blob = new Blob([JSON.stringify(out, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = el('a', { + href: url, + download: 'vault-export-' + new Date().toISOString().slice(0, 10) + '.json', + }); + document.body.appendChild(a); + a.click(); + setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 100); + toast(out.entries.length + ' entries exported'); +} -// Prevent native drag on non-card elements (table headers, text, etc.) -document.getElementById('entriesContainer').addEventListener('dragstart', function(e) { - if (!e.target?.closest?.('[draggable="true"]')) e.preventDefault(); -}); +// ============================================================ +// VIEWS / NAV +// ============================================================ -// Document mousedown: rect selection on vault background, clear outside vault -document.addEventListener('mousedown', function(e) { - if (e.button !== 0 || rectState.active) return; - if (e.target?.closest?.('.entry-card,.entry-row,.entry-compact,.table-row-drag,.detail-card,.detail-nav,#batchBar,.custom-modal-overlay.show,.edit-modal.show,.modal-overlay.show,#genModal,#settingsMenu,.batch-confirm-overlay')) return; - if (e.target?.closest?.('button,input,select,.folders-bar,.toolbar,#trashActions,.settings-dropdown,.fab,.auth-section')) { - if (selectedIds.size > 0) clearSelection(); - return; +async function setView(v) { + state.view = v; + if (v === 'trash') { + await loadTrash(); } - if (e.target?.closest?.('.vault')) { - rectState.active = true; - rectState.startX = e.clientX; - rectState.startY = e.clientY; - rectState.started = false; - rectState.el = null; - selectedIds.clear(); - lastSelectedId = null; - hideBatchBar(); - document.getElementById('entriesContainer')?.querySelectorAll('.selected').forEach(el => el.classList.remove('selected')); - } else if (selectedIds.size > 0) { - clearSelection(); + render(); +} + +function toggleTheme() { + setTheme(state.theme === 'dark' ? 'light' : 'dark'); +} +function setTheme(t) { + state.theme = t; + document.documentElement.setAttribute('data-theme', t); + localStorage.setItem('theme', t); + const sel = $('#settingTheme'); + if (sel) sel.value = t; +} + +function openSettings() { + $('#settingTheme').value = state.theme; + $('#settingAutoLock').value = String(state.autoLock); + $('#settingAskDelete').checked = state.askBeforeDelete; + $('#settingCompact').checked = state.compactActions; + $('#settingMaskUser').checked = state.maskUsernames; + $('#settingUser').textContent = state.username; + $('#settingsPanel').classList.add('is-open'); +} +function closeSettings() { + $('#settingsPanel').classList.remove('is-open'); +} + +// ---- Auto-lock with 30s warning countdown ------------------- +const WARNING_SECONDS = 30; +let autoLockTimer = null; +let warningTimer = null; +let countdownInterval = null; + +function hideIdleWarning() { + $('#idleWarning').classList.add('is-hidden'); + if (countdownInterval) { clearInterval(countdownInterval); countdownInterval = null; } +} + +function showIdleWarning() { + $('#idleCountdown').textContent = WARNING_SECONDS; + $('#idleWarning').classList.remove('is-hidden'); + let s = WARNING_SECONDS; + countdownInterval = setInterval(() => { + s -= 1; + $('#idleCountdown').textContent = Math.max(0, s); + if (s <= 0) { clearInterval(countdownInterval); countdownInterval = null; } + }, 1000); +} + +function resetAutoLock() { + if (autoLockTimer) clearTimeout(autoLockTimer); + if (warningTimer) clearTimeout(warningTimer); + hideIdleWarning(); + if (!state.autoLock || !state.token || !state.cryptoKey) return; + + const totalMs = state.autoLock * 60 * 1000; + const warningAt = Math.max(0, totalMs - WARNING_SECONDS * 1000); + + warningTimer = setTimeout(showIdleWarning, warningAt); + autoLockTimer = setTimeout(() => { + hideIdleWarning(); + toast('Auto-locked due to inactivity', 'warning'); + lockVault(); + }, totalMs); +} + +// Reset idle on user interaction — but ignore events that fire while the +// warning popup is visible (otherwise the popup would never auto-dismiss). +['mousemove', 'keydown', 'click', 'touchstart'].forEach(ev => + document.addEventListener(ev, e => { + // Allow clicks on the "Stay unlocked" button to also reset + if ($('#idleWarning').classList.contains('is-hidden')) { + resetAutoLock(); + } + }, { passive: true }) +); + +function showAuth() { + $('#authScreen').classList.remove('is-hidden'); + $('#appShell').classList.add('is-hidden'); + if (autoLockTimer) { clearTimeout(autoLockTimer); autoLockTimer = null; } +} + +async function enterApp() { + $('#authScreen').classList.add('is-hidden'); + $('#appShell').classList.remove('is-hidden'); + $('#userName').textContent = state.username; + // Show skeleton cards immediately while the initial fetch runs + showSkeletons(6); + await loadFolders(); + await loadEntries(); + render(); + resetAutoLock(); +} + +// ============================================================ +// INIT +// ============================================================ + +async function init() { + document.documentElement.setAttribute('data-theme', state.theme); + + // Auth tabs + $$('.auth-tab').forEach(t => { + t.addEventListener('click', () => { + $$('.auth-tab').forEach(x => x.classList.remove('is-active')); + t.classList.add('is-active'); + const tab = t.dataset.tab; + $('#loginForm').classList.toggle('is-hidden', tab !== 'login'); + $('#registerForm').classList.toggle('is-hidden', tab !== 'register'); + }); + }); + + // Forms + $('#loginForm').addEventListener('submit', doLogin); + $('#registerForm').addEventListener('submit', doRegister); + $('#regPassword').addEventListener('input', updateRegStrength); + $('#entryPassword').addEventListener('input', updateEntryStrength); + + // Top-bar + function applyViewMode() { + $$('.view-btn').forEach(b => b.classList.toggle('is-active', b.dataset.view === state.viewMode)); + renderGrid(); } -}); -document.addEventListener('mousemove', function(e) { - if (!rectState.active) return; - const dx = e.clientX - rectState.startX; - const dy = e.clientY - rectState.startY; - if (!rectState.started && (dx > 5 || dx < -5 || dy > 5 || dy < -5)) { - rectState.started = true; - rectState.el = document.createElement('div'); - rectState.el.id = 'rectSelect'; - document.body.appendChild(rectState.el); + applyViewMode(); + $$('.view-btn').forEach(b => b.addEventListener('click', () => { + state.viewMode = b.dataset.view; + localStorage.setItem('viewMode', state.viewMode); + applyViewMode(); + })); + + $('#themeBtn').addEventListener('click', toggleTheme); + $('#newEntryBtn').addEventListener('click', () => openEntryModal()); + $('#userChip').addEventListener('click', () => $('#userDropdown').classList.toggle('is-hidden')); + $('#lockBtn').addEventListener('click', lockVault); + $('#logoutBtn').addEventListener('click', doLogout); + document.addEventListener('click', e => { + if (!e.target.closest('.user-menu')) $('#userDropdown').classList.add('is-hidden'); + // Close any open kebab menu when clicking outside it + if (!e.target.closest('.entry-kebab-wrap')) { + $$('.entry-kebab-menu.is-open').forEach(m => m.classList.remove('is-open')); + } + }); + + // Sidebar nav + $$('#appShell .nav-item[data-view]').forEach(n => { + n.addEventListener('click', () => setView(n.dataset.view)); + }); + $('#addFolderBtn').addEventListener('click', addFolder); + + // Drag-to-trash: dropping an entry onto the Trash nav item soft-deletes it + const trashItem = $('#appShell .nav-item[data-view="trash"]'); + if (trashItem) { + trashItem.addEventListener('dragover', ev => { ev.preventDefault(); trashItem.classList.add('drag-over'); }); + trashItem.addEventListener('dragleave', () => trashItem.classList.remove('drag-over')); + trashItem.addEventListener('drop', async ev => { + ev.preventDefault(); + trashItem.classList.remove('drag-over'); + const id = parseInt(ev.dataTransfer.getData('text/plain')); + if (!id) return; + try { + await api('/entries/' + id, { method: 'DELETE', headers: authHeaders() }); + toast('Moved to trash'); + await loadEntries(); + await loadTrash(); + render(); + } catch (err) { toast(err.message, 'error'); } + }); } - if (rectState.el) { - const x = Math.min(rectState.startX, e.clientX); - const y = Math.min(rectState.startY, e.clientY); - const w = Math.abs(dx); - const h = Math.abs(dy); - rectState.el.style.cssText = `left:${x}px;top:${y}px;width:${w}px;height:${h}px;display:block;position:fixed;pointer-events:none;z-index:999;border:1px solid var(--accent);background:rgba(59,130,246,0.1);`; - } -}); -document.addEventListener('mouseup', function(e) { - if (!rectState.active) return; - rectState.active = false; - if (rectState.el) { - rectState.el.remove(); - rectState.el = null; - if (rectState.started) { - const r = { - left: Math.min(rectState.startX, e.clientX), - top: Math.min(rectState.startY, e.clientY), - right: Math.max(rectState.startX, e.clientX), - bottom: Math.max(rectState.startY, e.clientY) - }; - const container = document.getElementById('entriesContainer'); - if (container) { - container.querySelectorAll('.entry-card,.entry-row,.entry-compact,.table-row-drag').forEach(el => { - const er = el.getBoundingClientRect(); - if (er.left < r.right && er.right > r.left && er.top < r.bottom && er.bottom > r.top) { - const id = parseInt(el.dataset.id); - if (id) selectedIds.add(id); - } - }); + + // Search + $('#searchInput').addEventListener('input', e => { + state.search = e.target.value; + renderGrid(); + }); + + // Marquee rubber-band selection on the entry grid + $('#entryGrid').addEventListener('mousedown', startMarquee); + + // Slide-over + $('#slideoverClose').addEventListener('click', closeSlideOver); + // Click outside the slide-over closes it. Clicks on cards re-open it for + // another entry (so we don't close in that case; the card's own handler + // will switch state.selectedId). + document.addEventListener('click', e => { + if (!$('#slideover').classList.contains('is-open')) return; + if (e.target.closest('.slideover')) return; + if (e.target.closest('.entry-card')) return; + if (e.target.closest('.modal')) return; + if (e.target.closest('.cmd-palette')) return; + if (e.target.closest('.idle-warning')) return; + closeSlideOver(); + }); + + // Entry modal + $('#entryForm').addEventListener('submit', saveEntry); + $('#entrySaveBtn').addEventListener('click', saveEntry); + $$('#entryModal [data-close]').forEach(b => b.addEventListener('click', closeEntryModal)); + $('#entryPwToggle').addEventListener('click', () => { + const input = $('#entryPassword'); + input.type = input.type === 'password' ? 'text' : 'password'; + }); + $('#entryPwGen').addEventListener('click', openGen); + + // Chip input (tags) + $('#entryTagsInput').addEventListener('click', () => $('#entryTagsField').focus()); + $('#entryTagsField').addEventListener('keydown', e => { + const field = e.target; + if (e.key === 'Enter' || e.key === ',') { + e.preventDefault(); + if (!selectActiveSuggestion()) { + addTag(field.value); + field.value = ''; + closeChipSuggest(); } - if (selectedIds.size > 0) { updateBatchBar(); render(); } - } - } -}); -document.addEventListener('keydown', e => { if (e.key === 'Enter' && !e.ctrlKey && !e.altKey && !e.metaKey) { const a = document.activeElement; if (!a || a.tagName === 'BUTTON') return; e.preventDefault(); if (document.getElementById('editModal').classList.contains('show') && a.closest('.edit-box')) saveEdit(); else if (document.getElementById('addModal').classList.contains('show') && a.closest('.modal-box')) addEntry(); else if (!document.getElementById('authSection').classList.contains('hidden')) { if (a.id === 'loginUsername' || a.id === 'loginPassword') login(); else if (a.id === 'regPassword') register(); } } }); -document.getElementById('genModal').addEventListener('click', e => { if (e.target === e.currentTarget) closeGen(); }); -document.getElementById('editModal').addEventListener('click', e => { if (e.target === e.currentTarget) closeEdit(); }); -// ==================== KEYBOARD SHORTCUTS ==================== -document.addEventListener('keydown', function(e) { - const tag = document.activeElement?.tagName; - const isInput = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT'; - - // Escape – close any open modal or settings - if (e.key === 'Escape') { - if (rectState.active || rectState.el) { rectState.active = false; if (rectState.el) { rectState.el.remove(); rectState.el = null; } clearSelection(); return; } - if (selectedIds.size > 0) { clearSelection(); return; } - if (!document.getElementById('settingsMenu').classList.contains('hidden')) { - document.getElementById('settingsMenu').classList.add('hidden'); - return; - } - if (document.getElementById('addModal').classList.contains('show')) { closeAdd(); return; } - if (document.getElementById('editModal').classList.contains('show')) { closeEdit(); return; } - if (document.getElementById('genModal').style.display === 'flex') { closeGen(); return; } - const confirm = document.querySelector('.custom-confirm.show'); - if (confirm) confirm.remove(); - return; - } + } else if (e.key === 'Backspace' && !field.value && editingTags.length) { + editingTags.pop(); + renderChips(); + } else if (e.key === 'ArrowDown') { e.preventDefault(); moveChipSuggest(+1); } + else if (e.key === 'ArrowUp') { e.preventDefault(); moveChipSuggest(-1); } + else if (e.key === 'Escape') { closeChipSuggest(); } + }); + $('#entryTagsField').addEventListener('input', openChipSuggest); + $('#entryTagsField').addEventListener('focus', openChipSuggest); + $('#entryTagsField').addEventListener('blur', () => setTimeout(closeChipSuggest, 150)); - // Arrow keys for detail view navigation - if ((e.key === 'ArrowLeft' || e.key === 'ArrowRight') && !isInput && view === 'detail' && document.getElementById('authSection').classList.contains('hidden')) { - e.preventDefault(); - goDetail(e.key === 'ArrowLeft' ? -1 : 1); - return; - } - - // Arrow keys — navigate entries in vault - if ((e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'ArrowLeft' || e.key === 'ArrowRight') && !isInput && document.getElementById('authSection').classList.contains('hidden') && view !== 'detail' && !document.getElementById('addModal').classList.contains('show') && !document.getElementById('editModal').classList.contains('show')) { - e.preventDefault(); - const filtered = getFilteredEntries(); - if (!filtered.length) return; - const isNext = e.key === 'ArrowDown' || e.key === 'ArrowRight'; - let idx = arrowFocus >= 0 ? arrowFocus : -1; - if (idx < 0 && selectedIds.size > 0) { - const firstId = [...selectedIds][0]; - idx = filtered.findIndex(e => e.id == firstId); - } - if (view === 'grid' && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) { - if (idx < 0) idx = 0; - else { const cols = getGridCols(); if (e.key === 'ArrowDown') { const next = idx + cols; idx = next < filtered.length ? next : idx; } else { const prev = idx - cols; idx = prev >= 0 ? prev : idx; } } + // Generator + $('#genLen').addEventListener('input', genPassword); + $$('#genModal input[type=checkbox]').forEach(c => c.addEventListener('change', genPassword)); + $('#genRegen').addEventListener('click', genPassword); + $('#genCopy').addEventListener('click', () => { + if (!genCurrent) return; + if (Bridge.copySecure(genCurrent, 30000)) { + toast('Copied · clears in 30s'); } else { - if (isNext) idx = idx < filtered.length - 1 ? idx + 1 : 0; - else idx = idx > 0 ? idx - 1 : filtered.length - 1; + navigator.clipboard.writeText(genCurrent).then(() => { + toast('Copied · clears in 30s'); + setTimeout(() => navigator.clipboard.writeText('').catch(()=>{}), 30000); + }); } - if (e.shiftKey) { - if (arrowAnchor < 0) arrowAnchor = idx; - arrowFocus = idx; - const start = Math.min(arrowAnchor, arrowFocus), end = Math.max(arrowAnchor, arrowFocus); - selectedIds.clear(); - for (let i = start; i <= end; i++) selectedIds.add(filtered[i].id); + }); + $('#genUse').addEventListener('click', () => { + if (genTarget === 'slideover') { + const soPw = $('#soPassword'); + if (soPw) { soPw.value = genCurrent; soDirtyCheck(); } } else { - selectedIds.clear(); - selectedIds.add(filtered[idx].id); - arrowAnchor = idx; - arrowFocus = idx; + $('#entryPassword').value = genCurrent; + updateEntryStrength(); } - updateBatchBar(); render(); playSound('click'); - return; - } + closeGen(); + }); + $$('#genModal [data-close]').forEach(b => b.addEventListener('click', closeGen)); - // Enter — open edit for single selected entry - if (e.key === 'Enter' && !isInput && selectedIds.size === 1 && document.getElementById('authSection').classList.contains('hidden') && !document.getElementById('editModal').classList.contains('show') && !document.getElementById('addModal').classList.contains('show')) { - e.preventDefault(); - openEdit([...selectedIds][0]); - return; - } + // Sidebar Generator tool + $('#sidebarGenBtn').addEventListener('click', () => openGen('standalone')); + $('#sidebarExportBtn').addEventListener('click', doExport); - // ? or / to show shortcuts help (only in vault) - if ((e.key === '?' || e.key === '/') && !isInput) { - if (!document.getElementById('authSection').classList.contains('hidden')) return; - e.preventDefault(); - showShortcutsHelp(); - return; - } + // Idle warning "Stay unlocked" + $('#idleStayBtn').addEventListener('click', resetAutoLock); - // Delete key — move to trash or permanently delete selected entries - if (e.key === 'Delete' && !isInput && selectedIds.size > 0 && document.getElementById('authSection').classList.contains('hidden')) { - e.preventDefault(); - if (showTrash) batchPermanentDelete(); - else batchDelete(); - return; - } - - // Alt+N — New entry (Ctrl+N intercepted by browser) - if (e.altKey && !e.shiftKey && !e.ctrlKey && !e.metaKey && (e.key === 'n' || e.key === 'N') && !isInput && !document.getElementById('addModal').classList.contains('show') && document.getElementById('authSection').classList.contains('hidden')) { - e.preventDefault(); - openAdd(); - return; - } - - // Alt+T — Toggle trash (Ctrl+T intercepted by browser) - if (e.altKey && !e.shiftKey && !e.ctrlKey && !e.metaKey && (e.key === 't' || e.key === 'T') && !isInput && document.getElementById('authSection').classList.contains('hidden')) { - e.preventDefault(); - toggleTrash(); - return; - } - - // Only handle Ctrl+[key], no Shift/Alt/Meta - if (!e.ctrlKey || e.shiftKey || e.altKey || e.metaKey) return; - - // Prevent browser defaults for ALL our shortcuts BEFORE dispatching - const code = e.code; - if (code === 'KeyF' || code === 'KeyL' || code === 'KeyS') { - e.preventDefault(); - } - - if (e.ctrlKey && !e.shiftKey && !e.altKey && !e.metaKey && (e.key === 'a' || e.key === 'A') && !isInput && document.getElementById('authSection').classList.contains('hidden')) { - e.preventDefault(); - getFilteredEntries(view === 'grouped' || view === 'detail').forEach(e => selectedIds.add(e.id)); - updateBatchBar(); + // Settings slide-over + $('#settingsBtn').addEventListener('click', openSettings); + $('#settingsClose').addEventListener('click', closeSettings); + $('#settingTheme').addEventListener('change', e => setTheme(e.target.value)); + $('#settingAutoLock').addEventListener('change', e => { + state.autoLock = parseInt(e.target.value); + localStorage.setItem('autoLockMin', String(state.autoLock)); + resetAutoLock(); + toast(state.autoLock ? ('Auto-lock: ' + state.autoLock + ' min') : 'Auto-lock disabled'); + }); + $('#settingAskDelete').addEventListener('change', e => { + state.askBeforeDelete = e.target.checked; + localStorage.setItem('askBeforeDelete', state.askBeforeDelete ? '1' : '0'); + toast(state.askBeforeDelete ? 'Will ask before deleting' : 'Will delete without asking'); + }); + $('#settingCompact').addEventListener('change', e => { + state.compactActions = e.target.checked; + localStorage.setItem('compactActions', state.compactActions ? '1' : '0'); render(); - playSound('click'); - return; - } + }); + $('#settingMaskUser').addEventListener('change', e => { + state.maskUsernames = e.target.checked; + localStorage.setItem('maskUsernames', state.maskUsernames ? '1' : '0'); + render(); + }); + $('#openClipboardSettings').addEventListener('click', () => { + toast('Open Windows Settings → System → Clipboard → turn off "Clipboard history"', 'warning'); + }); + $('#exportBtn').addEventListener('click', doExport); - if (code === 'KeyF') { - const el = document.getElementById('searchInput'); - if (el) { el.focus(); el.select(); } - } else if (code === 'KeyL') { - if (!isInput) doLogout(); - } else if (code === 'KeyS') { - if (document.getElementById('addModal').classList.contains('show')) addEntry(); - else if (document.getElementById('editModal').classList.contains('show')) saveEdit(); + // Re-auth modal + $('#reauthForm').addEventListener('submit', e => { e.preventDefault(); closeReauth(true); }); + $$('#reauthModal [data-close]').forEach(b => b.addEventListener('click', () => closeReauth(false))); + + // Custom confirm / prompt modal + $('#confirmForm').addEventListener('submit', e => { + e.preventDefault(); + // If input field visible -> resolve with its value, else -> true + const hasInput = !$('#confirmInputField').classList.contains('is-hidden'); + closeConfirm(hasInput ? $('#confirmInput').value : true); + }); + $$('#confirmModal [data-confirm-cancel]').forEach(b => + b.addEventListener('click', () => closeConfirm(false)) + ); + + // Command palette + document.addEventListener('keydown', e => { + if ((e.ctrlKey || e.metaKey) && e.key === 'k') { + e.preventDefault(); + openPalette(); + } else if (e.key === 'Escape') { + // Close in priority order: confirm first (most modal-y) then others + if (!$('#confirmModal').classList.contains('is-hidden')) { + closeConfirm(false); + return; + } + closePalette(); + closeSlideOver(); + closeEntryModal(); + closeGen(); + } + }); + $('#cmdInput').addEventListener('input', e => renderPaletteResults(e.target.value)); + $$('#cmdPalette [data-close]').forEach(b => b.addEventListener('click', closePalette)); + + // Restore session if any + if (state.token && state.salt) { + const ok = await restoreCryptoKey(); + if (ok) { + await enterApp(); + } else { + // session token exists but crypto key gone — user must re-enter master pw + showAuth(); + $('#loginUsername').value = state.username; + } + } else { + showAuth(); } -}); -document.getElementById('loginUsername').focus(); -document.querySelectorAll('.fab').forEach(b => b.classList.add('hidden')); \ No newline at end of file +} + +document.addEventListener('DOMContentLoaded', init);