mirror of
https://github.com/aliasvault/aliasvault.git
synced 2026-02-19 15:39:13 -05:00
1531 lines
59 KiB
C#
1531 lines
59 KiB
C#
//-----------------------------------------------------------------------
|
|
// <copyright file="ItemService.cs" company="aliasvault">
|
|
// Copyright (c) aliasvault. All rights reserved.
|
|
// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information.
|
|
// </copyright>
|
|
//-----------------------------------------------------------------------
|
|
|
|
namespace AliasVault.Client.Services;
|
|
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Globalization;
|
|
using System.Linq;
|
|
using System.Net.Http;
|
|
using System.Net.Http.Json;
|
|
using System.Text.Json;
|
|
using System.Threading.Tasks;
|
|
using AliasClientDb;
|
|
using AliasClientDb.Models;
|
|
using AliasVault.Client.Main.Models;
|
|
using AliasVault.Client.Utilities;
|
|
using AliasVault.Shared.Models.WebApi.Favicon;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
/// <summary>
|
|
/// Service class for Item operations.
|
|
/// </summary>
|
|
public sealed class ItemService(HttpClient httpClient, DbService dbService, Config config, JsInteropService jsInteropService)
|
|
{
|
|
/// <summary>
|
|
/// The default service URL used as placeholder in forms. When this value is set, the URL field is considered empty
|
|
/// and a null value is stored in the database.
|
|
/// </summary>
|
|
public const string DefaultServiceUrl = "https://";
|
|
|
|
/// <summary>
|
|
/// Generates a random password for an item using the specified settings.
|
|
/// </summary>
|
|
/// <param name="settings">PasswordSettings model.</param>
|
|
/// <returns>Random password.</returns>
|
|
public async Task<string> GenerateRandomPasswordAsync(PasswordSettings settings)
|
|
{
|
|
// Sanity check: if all settings are false, then default to use lowercase letters only.
|
|
if (!settings.UseLowercase && !settings.UseUppercase && !settings.UseNumbers && !settings.UseSpecialChars && !settings.UseNonAmbiguousChars)
|
|
{
|
|
settings.UseLowercase = true;
|
|
}
|
|
|
|
return await jsInteropService.GenerateRandomPasswordAsync(settings);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Generates a random identity for an item.
|
|
/// </summary>
|
|
/// <param name="item">The item to update with random identity.</param>
|
|
/// <returns>Task with the updated item.</returns>
|
|
public async Task<Item> GenerateRandomIdentityAsync(Item item)
|
|
{
|
|
const int MaxAttempts = 5;
|
|
var attempts = 0;
|
|
bool isEmailTaken;
|
|
|
|
do
|
|
{
|
|
// Convert age range to birthdate options using shared JS utility
|
|
var birthdateOptions = await jsInteropService.ConvertAgeRangeToBirthdateOptionsAsync(dbService.Settings.DefaultIdentityAgeRange);
|
|
|
|
// Get the effective identity language (smart default based on UI language if no explicit override is set)
|
|
var identityLanguage = await GetEffectiveIdentityLanguageAsync();
|
|
|
|
// Generate a random identity using the TypeScript library
|
|
var identity = await jsInteropService.GenerateRandomIdentityAsync(identityLanguage, dbService.Settings.DefaultIdentityGender, birthdateOptions);
|
|
|
|
// Set username field
|
|
SetFieldValue(item, FieldKey.LoginUsername, identity.NickName);
|
|
|
|
// Set alias fields
|
|
SetFieldValue(item, FieldKey.AliasFirstName, identity.FirstName);
|
|
SetFieldValue(item, FieldKey.AliasLastName, identity.LastName);
|
|
SetFieldValue(item, FieldKey.AliasGender, identity.Gender);
|
|
|
|
if (!string.IsNullOrEmpty(identity.BirthDate))
|
|
{
|
|
// Parse the birthdate and format as yyyy-MM-dd
|
|
if (DateTime.TryParse(identity.BirthDate, out var birthDate))
|
|
{
|
|
SetFieldValue(item, FieldKey.AliasBirthdate, birthDate.ToString("yyyy-MM-dd"));
|
|
}
|
|
else
|
|
{
|
|
SetFieldValue(item, FieldKey.AliasBirthdate, identity.BirthDate);
|
|
}
|
|
}
|
|
|
|
// Set the email
|
|
var emailDomain = GetDefaultEmailDomain();
|
|
var email = $"{identity.EmailPrefix}@{emailDomain}";
|
|
SetFieldValue(item, FieldKey.LoginEmail, email);
|
|
|
|
// Check if email is already taken
|
|
try
|
|
{
|
|
var response = await httpClient.PostAsync($"v1/Identity/CheckEmail/{email}", null);
|
|
var result = await response.Content.ReadFromJsonAsync<Dictionary<string, bool>>();
|
|
isEmailTaken = result?["isTaken"] ?? false;
|
|
}
|
|
catch
|
|
{
|
|
// If the API call fails, assume email is not taken to allow operation to continue
|
|
isEmailTaken = false;
|
|
}
|
|
|
|
attempts++;
|
|
}
|
|
while (isEmailTaken && attempts < MaxAttempts);
|
|
|
|
// Generate password using the TypeScript library
|
|
var passwordSettings = dbService.Settings.PasswordSettings;
|
|
var password = await jsInteropService.GenerateRandomPasswordAsync(passwordSettings);
|
|
SetFieldValue(item, FieldKey.LoginPassword, password);
|
|
|
|
return item;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the default email domain based on settings and available domains.
|
|
/// </summary>
|
|
/// <returns>Default email domain.</returns>
|
|
public string GetDefaultEmailDomain()
|
|
{
|
|
var defaultDomain = dbService.Settings.DefaultEmailDomain;
|
|
|
|
// Function to check if a domain is valid (not disabled, not hidden, and exists in domain lists)
|
|
bool IsValidDomain(string domain) =>
|
|
!string.IsNullOrEmpty(domain) &&
|
|
domain != "DISABLED.TLD" &&
|
|
!config.HiddenPrivateEmailDomains.Contains(domain) &&
|
|
(config.PublicEmailDomains.Contains(domain) || config.PrivateEmailDomains.Contains(domain));
|
|
|
|
// Get the first valid domain from private or public domains (excluding hidden ones)
|
|
string GetFirstValidDomain() =>
|
|
config.PrivateEmailDomains.Find(IsValidDomain) ??
|
|
config.PublicEmailDomains.FirstOrDefault() ??
|
|
"example.com";
|
|
|
|
// Use the default domain if it's valid (not hidden), otherwise get the first valid domain
|
|
string domainToUse = IsValidDomain(defaultDomain) ? defaultDomain : GetFirstValidDomain();
|
|
|
|
return domainToUse;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Insert new item into database.
|
|
/// </summary>
|
|
/// <param name="item">Item to insert.</param>
|
|
/// <param name="saveToDb">Whether to commit changes to database. Defaults to true.</param>
|
|
/// <param name="extractFavicon">Whether to extract the favicon from the service URL. Defaults to true.</param>
|
|
/// <returns>Guid of inserted entry.</returns>
|
|
public async Task<Guid> InsertEntryAsync(Item item, bool saveToDb = true, bool extractFavicon = true)
|
|
{
|
|
var context = await dbService.GetDbContextAsync();
|
|
|
|
// Try to extract favicon from service URL
|
|
if (extractFavicon)
|
|
{
|
|
await ExtractFaviconAsync(item);
|
|
}
|
|
|
|
// Clean up email if it starts with @ (placeholder not filled)
|
|
var email = GetFieldValue(item, FieldKey.LoginEmail);
|
|
if (email != null && email.StartsWith('@'))
|
|
{
|
|
RemoveFieldValue(item, FieldKey.LoginEmail);
|
|
}
|
|
|
|
// If the URL equals the placeholder, remove it
|
|
var url = GetFieldValue(item, FieldKey.LoginUrl);
|
|
if (url == DefaultServiceUrl)
|
|
{
|
|
RemoveFieldValue(item, FieldKey.LoginUrl);
|
|
}
|
|
|
|
var currentDateTime = DateTime.UtcNow;
|
|
item.Id = Guid.NewGuid();
|
|
item.CreatedAt = currentDateTime;
|
|
item.UpdatedAt = currentDateTime;
|
|
|
|
// Set timestamps on all field values and their FieldDefinitions
|
|
foreach (var fv in item.FieldValues)
|
|
{
|
|
fv.Id = Guid.NewGuid();
|
|
fv.ItemId = item.Id;
|
|
fv.CreatedAt = currentDateTime;
|
|
fv.UpdatedAt = currentDateTime;
|
|
|
|
// If this field value has a new FieldDefinition (custom field), ensure its timestamps are set
|
|
if (fv.FieldDefinition != null)
|
|
{
|
|
if (fv.FieldDefinition.CreatedAt == default)
|
|
{
|
|
fv.FieldDefinition.CreatedAt = currentDateTime;
|
|
}
|
|
|
|
if (fv.FieldDefinition.UpdatedAt == default)
|
|
{
|
|
fv.FieldDefinition.UpdatedAt = currentDateTime;
|
|
}
|
|
|
|
// Add the FieldDefinition explicitly to ensure it's tracked
|
|
context.FieldDefinitions.Add(fv.FieldDefinition);
|
|
}
|
|
}
|
|
|
|
// Set timestamps on attachments
|
|
foreach (var attachment in item.Attachments)
|
|
{
|
|
attachment.ItemId = item.Id;
|
|
attachment.CreatedAt = currentDateTime;
|
|
attachment.UpdatedAt = currentDateTime;
|
|
}
|
|
|
|
// Set timestamps on TOTP codes
|
|
foreach (var totpCode in item.TotpCodes)
|
|
{
|
|
totpCode.ItemId = item.Id;
|
|
totpCode.CreatedAt = currentDateTime;
|
|
totpCode.UpdatedAt = currentDateTime;
|
|
}
|
|
|
|
// Create history records for fields with EnableHistory=true
|
|
await CreateInitialFieldHistoryAsync(context, item, currentDateTime);
|
|
|
|
context.Items.Add(item);
|
|
|
|
// Save the database to the server if saveToDb is true.
|
|
if (saveToDb && !await dbService.SaveDatabaseAsync())
|
|
{
|
|
// If saving database to server failed, return empty guid to indicate error.
|
|
return Guid.Empty;
|
|
}
|
|
|
|
return item.Id;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Update an existing item in database.
|
|
/// </summary>
|
|
/// <param name="item">Item to update.</param>
|
|
/// <returns>Guid of updated entry.</returns>
|
|
public async Task<Guid> UpdateEntryAsync(Item item)
|
|
{
|
|
var context = await dbService.GetDbContextAsync();
|
|
|
|
// Try to extract favicon from service URL
|
|
await ExtractFaviconAsync(item);
|
|
|
|
// Get the existing entry.
|
|
var existingItem = await LoadEntryAsync(item.Id);
|
|
if (existingItem is null)
|
|
{
|
|
throw new InvalidOperationException("Item not found.");
|
|
}
|
|
|
|
// Clean up email if it starts with @ (placeholder not filled)
|
|
var email = GetFieldValue(item, FieldKey.LoginEmail);
|
|
if (email != null && email.StartsWith('@'))
|
|
{
|
|
RemoveFieldValue(item, FieldKey.LoginEmail);
|
|
}
|
|
|
|
// If the URL equals the placeholder, remove it
|
|
var url = GetFieldValue(item, FieldKey.LoginUrl);
|
|
if (url == DefaultServiceUrl)
|
|
{
|
|
RemoveFieldValue(item, FieldKey.LoginUrl);
|
|
}
|
|
|
|
var updateDateTime = DateTime.UtcNow;
|
|
|
|
// Check if item type has changed
|
|
var typeChanged = existingItem.ItemType != item.ItemType;
|
|
|
|
// Update basic item info only if values changed
|
|
var itemLevelChanged = existingItem.Name != item.Name || existingItem.ItemType != item.ItemType || existingItem.LogoId != item.LogoId || existingItem.FolderId != item.FolderId;
|
|
|
|
if (itemLevelChanged)
|
|
{
|
|
existingItem.Name = item.Name;
|
|
existingItem.ItemType = item.ItemType;
|
|
existingItem.LogoId = item.LogoId;
|
|
existingItem.FolderId = item.FolderId;
|
|
existingItem.UpdatedAt = updateDateTime;
|
|
}
|
|
|
|
// Clear inapplicable fields/relations when type changes
|
|
if (typeChanged && item.ItemType != null)
|
|
{
|
|
ClearInapplicableData(context, existingItem, item.ItemType, updateDateTime);
|
|
}
|
|
|
|
// Track history for fields with EnableHistory=true before updating
|
|
await TrackFieldHistoryAsync(context, existingItem, item, updateDateTime);
|
|
|
|
// Update field values
|
|
UpdateFieldValues(context, existingItem, item, updateDateTime);
|
|
|
|
// Update attachments
|
|
UpdateAttachments(context, existingItem, item, updateDateTime);
|
|
|
|
// Update TOTP codes
|
|
UpdateTotpCodes(context, existingItem, item, updateDateTime);
|
|
|
|
if (!await dbService.SaveDatabaseAsync())
|
|
{
|
|
return Guid.Empty;
|
|
}
|
|
|
|
return existingItem.Id;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Load existing item from database.
|
|
/// </summary>
|
|
/// <param name="itemId">Id of item to load.</param>
|
|
/// <returns>Item object or null if not found.</returns>
|
|
public async Task<Item?> LoadEntryAsync(Guid itemId)
|
|
{
|
|
var context = await dbService.GetDbContextAsync();
|
|
|
|
var item = await context.Items
|
|
.Include(x => x.FieldValues.Where(fv => !fv.IsDeleted))
|
|
.ThenInclude(fv => fv.FieldDefinition)
|
|
.Include(x => x.Logo)
|
|
.Include(x => x.Attachments.Where(a => !a.IsDeleted))
|
|
.Include(x => x.TotpCodes.Where(t => !t.IsDeleted))
|
|
.Include(x => x.Passkeys.Where(p => !p.IsDeleted))
|
|
.AsSplitQuery()
|
|
.Where(x => x.Id == itemId)
|
|
.Where(x => !x.IsDeleted)
|
|
.FirstOrDefaultAsync();
|
|
|
|
return item;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Load all items from database.
|
|
/// </summary>
|
|
/// <returns>List of all items.</returns>
|
|
public async Task<List<Item>> LoadAllAsync()
|
|
{
|
|
var context = await dbService.GetDbContextAsync();
|
|
|
|
var items = await context.Items
|
|
.Include(x => x.FieldValues.Where(fv => !fv.IsDeleted))
|
|
.ThenInclude(fv => fv.FieldDefinition)
|
|
.Include(x => x.Logo)
|
|
.Include(x => x.Attachments.Where(a => !a.IsDeleted))
|
|
.Include(x => x.TotpCodes.Where(t => !t.IsDeleted))
|
|
.Include(x => x.Passkeys.Where(p => !p.IsDeleted))
|
|
.AsSplitQuery()
|
|
.Where(x => !x.IsDeleted)
|
|
.Where(x => x.DeletedAt == null) // Exclude items in trash
|
|
.ToListAsync();
|
|
|
|
return items;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get list with all item entries for display.
|
|
/// </summary>
|
|
/// <returns>List of ItemListEntry objects.</returns>
|
|
public async Task<List<ItemListEntry>?> GetListAsync()
|
|
{
|
|
var context = await dbService.GetDbContextAsync();
|
|
|
|
// Retrieve all items from client DB.
|
|
var items = await context.Items
|
|
.Include(x => x.FieldValues.Where(fv => !fv.IsDeleted))
|
|
.Include(x => x.Logo)
|
|
.Include(x => x.Folder)
|
|
.Include(x => x.Passkeys.Where(p => !p.IsDeleted))
|
|
.Include(x => x.Attachments.Where(a => !a.IsDeleted))
|
|
.Include(x => x.TotpCodes.Where(t => !t.IsDeleted))
|
|
.AsSplitQuery()
|
|
.Where(x => !x.IsDeleted)
|
|
.Where(x => x.DeletedAt == null) // Exclude items in trash
|
|
.ToListAsync();
|
|
|
|
// Map to ItemListEntry with proper boolean logic
|
|
return items.Select(x => new ItemListEntry
|
|
{
|
|
Id = x.Id,
|
|
ItemType = x.ItemType ?? AliasClientDb.Models.ItemType.Login,
|
|
Logo = x.Logo?.FileData,
|
|
Service = x.Name,
|
|
Username = GetFieldValue(x, FieldKey.LoginUsername),
|
|
Email = GetFieldValue(x, FieldKey.LoginEmail),
|
|
CardNumber = GetFieldValue(x, FieldKey.CardNumber),
|
|
CreatedAt = x.CreatedAt,
|
|
HasPasskey = x.Passkeys != null && x.Passkeys.Any(p => !p.IsDeleted),
|
|
HasAlias = !string.IsNullOrWhiteSpace(GetFieldValue(x, FieldKey.AliasFirstName)) ||
|
|
!string.IsNullOrWhiteSpace(GetFieldValue(x, FieldKey.AliasLastName)) ||
|
|
!string.IsNullOrWhiteSpace(GetFieldValue(x, FieldKey.AliasGender)) ||
|
|
!string.IsNullOrWhiteSpace(GetFieldValue(x, FieldKey.AliasBirthdate)),
|
|
HasUsernameOrPassword = !string.IsNullOrWhiteSpace(GetFieldValue(x, FieldKey.LoginUsername)) ||
|
|
!string.IsNullOrWhiteSpace(GetFieldValue(x, FieldKey.LoginPassword)),
|
|
HasAttachment = x.Attachments != null && x.Attachments.Any(a => !a.IsDeleted),
|
|
HasTotp = x.TotpCodes != null && x.TotpCodes.Any(t => !t.IsDeleted),
|
|
FolderId = x.FolderId,
|
|
FolderName = x.Folder?.Name,
|
|
}).ToList();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Soft deletes an existing item from database by moving it to trash.
|
|
/// </summary>
|
|
/// <param name="id">Id of item to delete.</param>
|
|
/// <returns>Bool which indicates if deletion and saving database was successful.</returns>
|
|
public async Task<bool> TrashItemAsync(Guid id)
|
|
{
|
|
var context = await dbService.GetDbContextAsync();
|
|
|
|
var item = await context.Items
|
|
.Include(x => x.Passkeys)
|
|
.Where(x => x.Id == id)
|
|
.FirstAsync();
|
|
|
|
var deleteDateTime = DateTime.UtcNow;
|
|
|
|
// Move to trash (soft delete)
|
|
item.DeletedAt = deleteDateTime;
|
|
item.UpdatedAt = deleteDateTime;
|
|
|
|
return await dbService.SaveDatabaseAsync();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Soft deletes an existing item from database by moving it to trash.
|
|
/// Syncs to server in the background without blocking the UI.
|
|
/// </summary>
|
|
/// <param name="id">Id of item to delete.</param>
|
|
/// <returns>Task that completes after local mutation.</returns>
|
|
public async Task TrashItemInBackgroundAsync(Guid id)
|
|
{
|
|
var context = await dbService.GetDbContextAsync();
|
|
|
|
var item = await context.Items
|
|
.Include(x => x.Passkeys)
|
|
.Where(x => x.Id == id)
|
|
.FirstAsync();
|
|
|
|
var deleteDateTime = DateTime.UtcNow;
|
|
|
|
// Move to trash (soft delete)
|
|
item.DeletedAt = deleteDateTime;
|
|
item.UpdatedAt = deleteDateTime;
|
|
|
|
// Save locally and sync to server in background
|
|
await context.SaveChangesAsync();
|
|
dbService.SaveDatabaseInBackground();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Restores an item from the trash (clears DeletedAt).
|
|
/// </summary>
|
|
/// <param name="id">Id of item to restore.</param>
|
|
/// <returns>Bool which indicates if restoration was successful.</returns>
|
|
public async Task<bool> RestoreItemAsync(Guid id)
|
|
{
|
|
var context = await dbService.GetDbContextAsync();
|
|
|
|
var item = await context.Items
|
|
.Where(x => x.Id == id && x.DeletedAt != null && !x.IsDeleted)
|
|
.FirstOrDefaultAsync();
|
|
|
|
if (item == null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var restoreDateTime = DateTime.UtcNow;
|
|
|
|
// Restore from trash (clear DeletedAt)
|
|
item.DeletedAt = null;
|
|
item.UpdatedAt = restoreDateTime;
|
|
|
|
return await dbService.SaveDatabaseAsync();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Restores an item from the trash (clears DeletedAt).
|
|
/// Syncs to server in the background without blocking the UI.
|
|
/// </summary>
|
|
/// <param name="id">Id of item to restore.</param>
|
|
/// <returns>True if item was found and restored locally, false if not found.</returns>
|
|
public async Task<bool> RestoreItemInBackgroundAsync(Guid id)
|
|
{
|
|
var context = await dbService.GetDbContextAsync();
|
|
|
|
var item = await context.Items
|
|
.Where(x => x.Id == id && x.DeletedAt != null && !x.IsDeleted)
|
|
.FirstOrDefaultAsync();
|
|
|
|
if (item == null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var restoreDateTime = DateTime.UtcNow;
|
|
|
|
// Restore from trash (clear DeletedAt)
|
|
item.DeletedAt = null;
|
|
item.UpdatedAt = restoreDateTime;
|
|
|
|
// Save locally and sync to server in background
|
|
await context.SaveChangesAsync();
|
|
dbService.SaveDatabaseInBackground();
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets all items that are in the trash (DeletedAt is set but IsDeleted is false).
|
|
/// </summary>
|
|
/// <returns>List of trashed items.</returns>
|
|
public async Task<List<Item>> GetRecentlyDeletedAsync()
|
|
{
|
|
var context = await dbService.GetDbContextAsync();
|
|
|
|
var items = await context.Items
|
|
.Include(x => x.FieldValues.Where(fv => !fv.IsDeleted))
|
|
.ThenInclude(fv => fv.FieldDefinition)
|
|
.Include(x => x.Logo)
|
|
.AsSplitQuery()
|
|
.Where(x => !x.IsDeleted && x.DeletedAt != null)
|
|
.OrderByDescending(x => x.DeletedAt)
|
|
.ToListAsync();
|
|
|
|
return items;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Permanently deletes an item (sets IsDeleted = true).
|
|
/// </summary>
|
|
/// <param name="id">Id of item to permanently delete.</param>
|
|
/// <returns>Bool which indicates if deletion was successful.</returns>
|
|
public async Task<bool> PermanentlyDeleteItemAsync(Guid id)
|
|
{
|
|
var context = await dbService.GetDbContextAsync();
|
|
|
|
var item = await context.Items
|
|
.Include(x => x.FieldValues)
|
|
.Include(x => x.Passkeys)
|
|
.Include(x => x.Attachments)
|
|
.Include(x => x.TotpCodes)
|
|
.Where(x => x.Id == id)
|
|
.FirstAsync();
|
|
|
|
var deleteDateTime = DateTime.UtcNow;
|
|
|
|
// Mark item and all related entities as deleted
|
|
item.IsDeleted = true;
|
|
item.UpdatedAt = deleteDateTime;
|
|
|
|
foreach (var fv in item.FieldValues)
|
|
{
|
|
fv.IsDeleted = true;
|
|
fv.UpdatedAt = deleteDateTime;
|
|
}
|
|
|
|
foreach (var passkey in item.Passkeys)
|
|
{
|
|
passkey.IsDeleted = true;
|
|
passkey.UpdatedAt = deleteDateTime;
|
|
}
|
|
|
|
foreach (var attachment in item.Attachments)
|
|
{
|
|
attachment.IsDeleted = true;
|
|
attachment.UpdatedAt = deleteDateTime;
|
|
}
|
|
|
|
foreach (var totp in item.TotpCodes)
|
|
{
|
|
totp.IsDeleted = true;
|
|
totp.UpdatedAt = deleteDateTime;
|
|
}
|
|
|
|
// Also delete field histories (queried separately since no navigation property)
|
|
var fieldHistories = await context.FieldHistories
|
|
.Where(fh => fh.ItemId == id && !fh.IsDeleted)
|
|
.ToListAsync();
|
|
|
|
foreach (var history in fieldHistories)
|
|
{
|
|
history.IsDeleted = true;
|
|
history.UpdatedAt = deleteDateTime;
|
|
}
|
|
|
|
return await dbService.SaveDatabaseAsync();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Permanently deletes an item (sets IsDeleted = true).
|
|
/// Syncs to server in the background without blocking the UI.
|
|
/// </summary>
|
|
/// <param name="id">Id of item to permanently delete.</param>
|
|
/// <returns>Task that completes after local mutation.</returns>
|
|
public async Task PermanentlyDeleteItemInBackgroundAsync(Guid id)
|
|
{
|
|
var context = await dbService.GetDbContextAsync();
|
|
|
|
var item = await context.Items
|
|
.Include(x => x.FieldValues)
|
|
.Include(x => x.Passkeys)
|
|
.Include(x => x.Attachments)
|
|
.Include(x => x.TotpCodes)
|
|
.Where(x => x.Id == id)
|
|
.FirstAsync();
|
|
|
|
var deleteDateTime = DateTime.UtcNow;
|
|
|
|
// Mark item and all related entities as deleted
|
|
item.IsDeleted = true;
|
|
item.UpdatedAt = deleteDateTime;
|
|
|
|
foreach (var fv in item.FieldValues)
|
|
{
|
|
fv.IsDeleted = true;
|
|
fv.UpdatedAt = deleteDateTime;
|
|
}
|
|
|
|
foreach (var passkey in item.Passkeys)
|
|
{
|
|
passkey.IsDeleted = true;
|
|
passkey.UpdatedAt = deleteDateTime;
|
|
}
|
|
|
|
foreach (var attachment in item.Attachments)
|
|
{
|
|
attachment.IsDeleted = true;
|
|
attachment.UpdatedAt = deleteDateTime;
|
|
}
|
|
|
|
foreach (var totp in item.TotpCodes)
|
|
{
|
|
totp.IsDeleted = true;
|
|
totp.UpdatedAt = deleteDateTime;
|
|
}
|
|
|
|
// Also delete field histories (queried separately since no navigation property)
|
|
var fieldHistories = await context.FieldHistories
|
|
.Where(fh => fh.ItemId == id && !fh.IsDeleted)
|
|
.ToListAsync();
|
|
|
|
foreach (var history in fieldHistories)
|
|
{
|
|
history.IsDeleted = true;
|
|
history.UpdatedAt = deleteDateTime;
|
|
}
|
|
|
|
// Save locally and sync to server in background
|
|
await context.SaveChangesAsync();
|
|
dbService.SaveDatabaseInBackground();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Hard delete all items from the database. This permanently removes all item records
|
|
/// (including soft-deleted ones) from the database for a complete vault reset.
|
|
/// Also removes all folders.
|
|
/// </summary>
|
|
/// <returns>True if successful, false otherwise.</returns>
|
|
public async Task<bool> HardDeleteAllItemsAsync()
|
|
{
|
|
var context = await dbService.GetDbContextAsync();
|
|
|
|
// Hard delete all related entities and items.
|
|
context.Attachments.RemoveRange(context.Attachments);
|
|
context.FieldValues.RemoveRange(context.FieldValues);
|
|
context.FieldHistories.RemoveRange(context.FieldHistories);
|
|
context.TotpCodes.RemoveRange(context.TotpCodes);
|
|
context.Passkeys.RemoveRange(context.Passkeys);
|
|
context.Items.RemoveRange(context.Items);
|
|
context.Logos.RemoveRange(context.Logos);
|
|
context.Folders.RemoveRange(context.Folders);
|
|
|
|
// Save changes locally
|
|
await context.SaveChangesAsync();
|
|
|
|
// Save the database to server
|
|
return await dbService.SaveDatabaseAsync();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Deletes a passkey by marking it as deleted.
|
|
/// </summary>
|
|
/// <param name="passkeyId">The ID of the passkey to delete.</param>
|
|
/// <returns>A value indicating whether the deletion was successful.</returns>
|
|
public async Task<bool> DeletePasskeyAsync(Guid passkeyId)
|
|
{
|
|
var context = await dbService.GetDbContextAsync();
|
|
var passkey = await context.Passkeys.FirstOrDefaultAsync(p => p.Id == passkeyId);
|
|
|
|
if (passkey != null)
|
|
{
|
|
var deleteDateTime = DateTime.UtcNow;
|
|
passkey.IsDeleted = true;
|
|
passkey.UpdatedAt = deleteDateTime;
|
|
await context.SaveChangesAsync();
|
|
|
|
// Save to server
|
|
return await dbService.SaveDatabaseAsync();
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get field history for a specific item and field.
|
|
/// </summary>
|
|
/// <param name="itemId">The item ID.</param>
|
|
/// <param name="fieldKey">The field key.</param>
|
|
/// <returns>List of field history records.</returns>
|
|
public async Task<List<FieldHistory>> GetFieldHistoryAsync(Guid itemId, string fieldKey)
|
|
{
|
|
var context = await dbService.GetDbContextAsync();
|
|
|
|
return await context.FieldHistories
|
|
.Where(fh => fh.ItemId == itemId && fh.FieldKey == fieldKey && !fh.IsDeleted)
|
|
.OrderByDescending(fh => fh.ChangedAt)
|
|
.Take(10)
|
|
.ToListAsync();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get field history count for a specific item and field.
|
|
/// Returns the count only if history is meaningful (more than 1 record,
|
|
/// or 1 record with value different from current).
|
|
/// </summary>
|
|
/// <param name="itemId">The item ID.</param>
|
|
/// <param name="fieldKey">The field key.</param>
|
|
/// <param name="currentValue">The current field value to compare against.</param>
|
|
/// <returns>Number of history records if meaningful, 0 otherwise.</returns>
|
|
public async Task<int> GetFieldHistoryCountAsync(Guid itemId, string fieldKey, string? currentValue = null)
|
|
{
|
|
var context = await dbService.GetDbContextAsync();
|
|
|
|
var historyRecords = await context.FieldHistories
|
|
.Where(fh => fh.ItemId == itemId && fh.FieldKey == fieldKey && !fh.IsDeleted)
|
|
.OrderByDescending(fh => fh.ChangedAt)
|
|
.Take(2) // Only need to check first 2 records
|
|
.ToListAsync();
|
|
|
|
var count = historyRecords.Count;
|
|
|
|
if (count > 1)
|
|
{
|
|
// More than 1 history record - always show icon
|
|
return await context.FieldHistories
|
|
.Where(fh => fh.ItemId == itemId && fh.FieldKey == fieldKey && !fh.IsDeleted)
|
|
.CountAsync();
|
|
}
|
|
|
|
if (count == 1 && currentValue != null)
|
|
{
|
|
// Single history record - check if value differs from current
|
|
var historyValue = historyRecords[0].ValueSnapshot;
|
|
var currentValueJson = JsonSerializer.Serialize(new[] { currentValue }.Where(v => !string.IsNullOrWhiteSpace(v)));
|
|
|
|
// Only show icon if history value differs from current value
|
|
if (currentValueJson != historyValue)
|
|
{
|
|
return 1;
|
|
}
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Delete a specific field history record.
|
|
/// </summary>
|
|
/// <param name="historyId">The ID of the history record to delete.</param>
|
|
/// <returns>True if deleted, false otherwise.</returns>
|
|
public async Task<bool> DeleteFieldHistoryAsync(Guid historyId)
|
|
{
|
|
var context = await dbService.GetDbContextAsync();
|
|
|
|
var history = await context.FieldHistories
|
|
.FirstOrDefaultAsync(fh => fh.Id == historyId && !fh.IsDeleted);
|
|
|
|
if (history == null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
history.IsDeleted = true;
|
|
history.UpdatedAt = DateTime.UtcNow;
|
|
|
|
await context.SaveChangesAsync();
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets a field value from an item by field key.
|
|
/// </summary>
|
|
/// <param name="item">The item to get the field from.</param>
|
|
/// <param name="fieldKey">The field key.</param>
|
|
/// <returns>The field value or null.</returns>
|
|
#pragma warning disable SA1204 // Static members should appear before non-static members
|
|
public static string? GetFieldValue(Item item, string fieldKey)
|
|
#pragma warning restore SA1204
|
|
{
|
|
return item.FieldValues
|
|
.FirstOrDefault(fv => fv.FieldKey == fieldKey && !fv.IsDeleted)
|
|
?.Value;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets all field values for a multi-value field key.
|
|
/// </summary>
|
|
/// <param name="item">The item to get the fields from.</param>
|
|
/// <param name="fieldKey">The field key.</param>
|
|
/// <returns>List of field values.</returns>
|
|
public static List<string> GetFieldValues(Item item, string fieldKey)
|
|
{
|
|
return item.FieldValues
|
|
.Where(fv => fv.FieldKey == fieldKey && !fv.IsDeleted)
|
|
.OrderBy(fv => fv.Weight)
|
|
.Select(fv => fv.Value ?? string.Empty)
|
|
.ToList();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sets or updates a field value on an item.
|
|
/// </summary>
|
|
/// <param name="item">The item to update.</param>
|
|
/// <param name="fieldKey">The field key.</param>
|
|
/// <param name="value">The value to set.</param>
|
|
public static void SetFieldValue(Item item, string fieldKey, string? value)
|
|
{
|
|
if (string.IsNullOrEmpty(value))
|
|
{
|
|
RemoveFieldValue(item, fieldKey);
|
|
return;
|
|
}
|
|
|
|
var existingField = item.FieldValues.FirstOrDefault(fv => fv.FieldKey == fieldKey && !fv.IsDeleted);
|
|
if (existingField != null)
|
|
{
|
|
existingField.Value = value;
|
|
existingField.UpdatedAt = DateTime.UtcNow;
|
|
}
|
|
else
|
|
{
|
|
item.FieldValues.Add(new FieldValue
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
ItemId = item.Id,
|
|
FieldKey = fieldKey,
|
|
Value = value,
|
|
Weight = 0,
|
|
CreatedAt = DateTime.UtcNow,
|
|
UpdatedAt = DateTime.UtcNow,
|
|
});
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Removes a field value from an item.
|
|
/// </summary>
|
|
/// <param name="item">The item to update.</param>
|
|
/// <param name="fieldKey">The field key to remove.</param>
|
|
public static void RemoveFieldValue(Item item, string fieldKey)
|
|
{
|
|
var existingField = item.FieldValues.FirstOrDefault(fv => fv.FieldKey == fieldKey && !fv.IsDeleted);
|
|
if (existingField != null)
|
|
{
|
|
existingField.IsDeleted = true;
|
|
existingField.UpdatedAt = DateTime.UtcNow;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Update the field values for an item.
|
|
/// This follows the same pattern as the browser extension's ItemRepository.updateFieldValues().
|
|
/// We query existing values, compare with new values, and add/update/delete as needed.
|
|
/// </summary>
|
|
/// <param name="context">The database context.</param>
|
|
/// <param name="existingItem">The existing item in the database (tracked by EF).</param>
|
|
/// <param name="newItem">The new item with updated field values (not tracked).</param>
|
|
/// <param name="updateDateTime">The timestamp for updates.</param>
|
|
private static void UpdateFieldValues(AliasClientDbContext context, Item existingItem, Item newItem, DateTime updateDateTime)
|
|
{
|
|
// Get the existing tracked field values from the existingItem (loaded with Include)
|
|
var existingFields = existingItem.FieldValues.Where(fv => !fv.IsDeleted).ToList();
|
|
|
|
// Group existing fields by key to support multi-value fields
|
|
var existingByFieldKey = existingFields
|
|
.Where(f => f.FieldKey != null)
|
|
.GroupBy(f => f.FieldKey!)
|
|
.ToDictionary(g => g.Key, g => g.OrderBy(f => f.Weight).ToList());
|
|
|
|
var existingByFieldDefId = existingFields
|
|
.Where(f => f.FieldDefinitionId != null)
|
|
.ToDictionary(f => f.FieldDefinitionId!.Value, f => f);
|
|
|
|
var processedFieldKeys = new HashSet<string>();
|
|
var processedFieldDefIds = new HashSet<Guid>();
|
|
|
|
// Group new fields by key to handle multi-value fields
|
|
var newFieldsByKey = newItem.FieldValues
|
|
.Where(fv => !fv.IsDeleted && fv.FieldKey != null)
|
|
.GroupBy(f => f.FieldKey!)
|
|
.ToDictionary(g => g.Key, g => g.OrderBy(f => f.Weight).ToList());
|
|
|
|
// Process new system field values (grouped by key for multi-value support)
|
|
foreach (var kvp in newFieldsByKey)
|
|
{
|
|
var fieldKey = kvp.Key;
|
|
var newValues = kvp.Value;
|
|
processedFieldKeys.Add(fieldKey);
|
|
|
|
if (existingByFieldKey.TryGetValue(fieldKey, out var existingValues))
|
|
{
|
|
// Update existing values - match by position (Weight)
|
|
for (int i = 0; i < newValues.Count; i++)
|
|
{
|
|
var newField = newValues[i];
|
|
if (i < existingValues.Count)
|
|
{
|
|
// Update existing field value
|
|
var existingField = existingValues[i];
|
|
if (existingField.Value != newField.Value || existingField.Weight != i)
|
|
{
|
|
existingField.Value = newField.Value;
|
|
existingField.Weight = i;
|
|
existingField.UpdatedAt = updateDateTime;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
// Insert new value (more values than before)
|
|
context.FieldValues.Add(new FieldValue
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
ItemId = existingItem.Id,
|
|
FieldKey = fieldKey,
|
|
FieldDefinitionId = null,
|
|
Value = newField.Value,
|
|
Weight = i,
|
|
CreatedAt = updateDateTime,
|
|
UpdatedAt = updateDateTime,
|
|
IsDeleted = false,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Soft-delete extra existing values (fewer values than before)
|
|
for (int i = newValues.Count; i < existingValues.Count; i++)
|
|
{
|
|
existingValues[i].IsDeleted = true;
|
|
existingValues[i].UpdatedAt = updateDateTime;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
// Insert all new values (field didn't exist before)
|
|
for (int i = 0; i < newValues.Count; i++)
|
|
{
|
|
var newField = newValues[i];
|
|
context.FieldValues.Add(new FieldValue
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
ItemId = existingItem.Id,
|
|
FieldKey = fieldKey,
|
|
FieldDefinitionId = null,
|
|
Value = newField.Value,
|
|
Weight = i,
|
|
CreatedAt = updateDateTime,
|
|
UpdatedAt = updateDateTime,
|
|
IsDeleted = false,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Process new custom field values
|
|
foreach (var newField in newItem.FieldValues.Where(fv => !fv.IsDeleted && fv.FieldDefinitionId != null))
|
|
{
|
|
processedFieldDefIds.Add(newField.FieldDefinitionId!.Value);
|
|
|
|
if (existingByFieldDefId.TryGetValue(newField.FieldDefinitionId.Value, out var existingField))
|
|
{
|
|
// Update existing field value
|
|
if (existingField.Value != newField.Value || existingField.Weight != newField.Weight)
|
|
{
|
|
existingField.Value = newField.Value;
|
|
existingField.Weight = newField.Weight;
|
|
existingField.UpdatedAt = updateDateTime;
|
|
}
|
|
|
|
// Also update the FieldDefinition label if it has changed
|
|
if (newField.FieldDefinition != null && existingField.FieldDefinition != null)
|
|
{
|
|
if (existingField.FieldDefinition.Label != newField.FieldDefinition.Label)
|
|
{
|
|
existingField.FieldDefinition.Label = newField.FieldDefinition.Label;
|
|
existingField.FieldDefinition.UpdatedAt = updateDateTime;
|
|
}
|
|
}
|
|
}
|
|
else
|
|
{
|
|
// New custom field - need to add FieldDefinition first if provided
|
|
if (newField.FieldDefinition != null)
|
|
{
|
|
context.FieldDefinitions.Add(new FieldDefinition
|
|
{
|
|
Id = newField.FieldDefinition.Id,
|
|
FieldType = newField.FieldDefinition.FieldType,
|
|
Label = newField.FieldDefinition.Label,
|
|
IsMultiValue = newField.FieldDefinition.IsMultiValue,
|
|
IsHidden = newField.FieldDefinition.IsHidden,
|
|
EnableHistory = newField.FieldDefinition.EnableHistory,
|
|
Weight = newField.FieldDefinition.Weight,
|
|
ApplicableToTypes = newField.FieldDefinition.ApplicableToTypes,
|
|
CreatedAt = updateDateTime,
|
|
UpdatedAt = updateDateTime,
|
|
IsDeleted = false,
|
|
});
|
|
}
|
|
|
|
// Add the FieldValue
|
|
context.FieldValues.Add(new FieldValue
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
ItemId = existingItem.Id,
|
|
FieldKey = null,
|
|
FieldDefinitionId = newField.FieldDefinitionId,
|
|
Value = newField.Value,
|
|
Weight = newField.Weight,
|
|
CreatedAt = updateDateTime,
|
|
UpdatedAt = updateDateTime,
|
|
IsDeleted = false,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Soft-delete removed system fields (all values for a field key)
|
|
foreach (var existingFieldKey in existingByFieldKey.Keys)
|
|
{
|
|
if (!processedFieldKeys.Contains(existingFieldKey))
|
|
{
|
|
foreach (var existingField in existingByFieldKey[existingFieldKey])
|
|
{
|
|
existingField.IsDeleted = true;
|
|
existingField.UpdatedAt = updateDateTime;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Soft-delete removed custom fields
|
|
foreach (var existingField in existingFields.Where(f => f.FieldDefinitionId != null))
|
|
{
|
|
if (!processedFieldDefIds.Contains(existingField.FieldDefinitionId!.Value))
|
|
{
|
|
existingField.IsDeleted = true;
|
|
existingField.UpdatedAt = updateDateTime;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Clears data that is not applicable to the new item type.
|
|
/// Called when an item's type changes (e.g., Login to Note).
|
|
/// </summary>
|
|
/// <param name="context">The database context.</param>
|
|
/// <param name="item">The item to clear data from.</param>
|
|
/// <param name="newType">The new item type.</param>
|
|
/// <param name="updateDateTime">The timestamp for updates.</param>
|
|
private static void ClearInapplicableData(AliasClientDbContext context, Item item, string newType, DateTime updateDateTime)
|
|
{
|
|
// Define field keys by category
|
|
var loginFieldKeys = new[]
|
|
{
|
|
FieldKey.LoginUsername,
|
|
FieldKey.LoginPassword,
|
|
FieldKey.LoginEmail,
|
|
FieldKey.LoginUrl,
|
|
};
|
|
|
|
var aliasFieldKeys = new[]
|
|
{
|
|
FieldKey.AliasFirstName,
|
|
FieldKey.AliasLastName,
|
|
FieldKey.AliasGender,
|
|
FieldKey.AliasBirthdate,
|
|
};
|
|
|
|
var cardFieldKeys = new[]
|
|
{
|
|
FieldKey.CardNumber,
|
|
FieldKey.CardCardholderName,
|
|
FieldKey.CardExpiryMonth,
|
|
FieldKey.CardExpiryYear,
|
|
FieldKey.CardCvv,
|
|
FieldKey.CardPin,
|
|
};
|
|
|
|
// Determine what to clear based on new type
|
|
var fieldKeysToClear = new List<string>();
|
|
|
|
switch (newType)
|
|
{
|
|
case ItemType.Note:
|
|
// Note only has notes.content - clear everything else
|
|
fieldKeysToClear.AddRange(loginFieldKeys);
|
|
fieldKeysToClear.AddRange(aliasFieldKeys);
|
|
fieldKeysToClear.AddRange(cardFieldKeys);
|
|
|
|
// Clear logo reference for Notes
|
|
item.LogoId = null;
|
|
|
|
// Soft-delete all TOTP codes for Notes
|
|
foreach (var totp in item.TotpCodes.Where(t => !t.IsDeleted))
|
|
{
|
|
totp.IsDeleted = true;
|
|
totp.UpdatedAt = updateDateTime;
|
|
}
|
|
|
|
// Soft-delete all passkeys for Notes
|
|
foreach (var passkey in item.Passkeys.Where(p => !p.IsDeleted))
|
|
{
|
|
passkey.IsDeleted = true;
|
|
passkey.UpdatedAt = updateDateTime;
|
|
}
|
|
|
|
break;
|
|
|
|
case ItemType.CreditCard:
|
|
// Credit card keeps card fields and notes - clear login/alias fields
|
|
fieldKeysToClear.AddRange(loginFieldKeys);
|
|
fieldKeysToClear.AddRange(aliasFieldKeys);
|
|
|
|
// Clear logo reference for CreditCards (uses brand detection icon)
|
|
item.LogoId = null;
|
|
|
|
// Soft-delete all TOTP codes for CreditCards
|
|
foreach (var totp in item.TotpCodes.Where(t => !t.IsDeleted))
|
|
{
|
|
totp.IsDeleted = true;
|
|
totp.UpdatedAt = updateDateTime;
|
|
}
|
|
|
|
// Soft-delete all passkeys for CreditCards
|
|
foreach (var passkey in item.Passkeys.Where(p => !p.IsDeleted))
|
|
{
|
|
passkey.IsDeleted = true;
|
|
passkey.UpdatedAt = updateDateTime;
|
|
}
|
|
|
|
break;
|
|
|
|
case ItemType.Login:
|
|
case ItemType.Alias:
|
|
// Login/Alias can have everything except card fields
|
|
fieldKeysToClear.AddRange(cardFieldKeys);
|
|
break;
|
|
}
|
|
|
|
// Soft-delete the inapplicable field values
|
|
foreach (var fieldKey in fieldKeysToClear)
|
|
{
|
|
var fieldValue = item.FieldValues.FirstOrDefault(fv => fv.FieldKey == fieldKey && !fv.IsDeleted);
|
|
if (fieldValue != null)
|
|
{
|
|
fieldValue.IsDeleted = true;
|
|
fieldValue.UpdatedAt = updateDateTime;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Update the attachments for an item.
|
|
/// </summary>
|
|
/// <param name="context">The database context.</param>
|
|
/// <param name="existingItem">The existing item in the database.</param>
|
|
/// <param name="newItem">The new item with updated attachments.</param>
|
|
/// <param name="updateDateTime">The timestamp for updates.</param>
|
|
private static void UpdateAttachments(DbContext context, Item existingItem, Item newItem, DateTime updateDateTime)
|
|
{
|
|
// Mark existing attachments as deleted if they're not in the new list (excluding already deleted ones)
|
|
var attachmentsToRemove = existingItem.Attachments
|
|
.Where(existingAttachment => !newItem.Attachments.Any(a => a.Id == existingAttachment.Id && !a.IsDeleted))
|
|
.ToList();
|
|
|
|
foreach (var attachmentToRemove in attachmentsToRemove)
|
|
{
|
|
attachmentToRemove.IsDeleted = true;
|
|
attachmentToRemove.UpdatedAt = updateDateTime;
|
|
}
|
|
|
|
// Process attachments from the new item (excluding deleted ones - they're handled above)
|
|
foreach (var attachment in newItem.Attachments.Where(a => !a.IsDeleted))
|
|
{
|
|
if (attachment.Id != Guid.Empty)
|
|
{
|
|
var existingAttachment = existingItem.Attachments.FirstOrDefault(a => a.Id == attachment.Id);
|
|
if (existingAttachment != null)
|
|
{
|
|
context.Entry(existingAttachment).CurrentValues.SetValues(attachment);
|
|
existingAttachment.UpdatedAt = updateDateTime;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
// Create a new attachment entity and add via context to ensure proper tracking
|
|
var newAttachment = new Attachment
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
ItemId = existingItem.Id,
|
|
Filename = attachment.Filename,
|
|
Blob = attachment.Blob,
|
|
CreatedAt = updateDateTime,
|
|
UpdatedAt = updateDateTime,
|
|
IsDeleted = false,
|
|
};
|
|
context.Add(newAttachment);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Update the TOTP codes for an item.
|
|
/// </summary>
|
|
/// <param name="context">The database context.</param>
|
|
/// <param name="existingItem">The existing item in the database.</param>
|
|
/// <param name="newItem">The new item with updated TOTP codes.</param>
|
|
/// <param name="updateDateTime">The timestamp for updates.</param>
|
|
private static void UpdateTotpCodes(DbContext context, Item existingItem, Item newItem, DateTime updateDateTime)
|
|
{
|
|
// Mark existing TOTP codes as deleted if they're not in the new list (excluding already deleted ones)
|
|
var totpCodesToRemove = existingItem.TotpCodes
|
|
.Where(existingTotp => !newItem.TotpCodes.Any(t => t.Id == existingTotp.Id && !t.IsDeleted))
|
|
.ToList();
|
|
|
|
foreach (var totpToRemove in totpCodesToRemove)
|
|
{
|
|
totpToRemove.IsDeleted = true;
|
|
totpToRemove.UpdatedAt = updateDateTime;
|
|
}
|
|
|
|
// Process TOTP codes from the new item (excluding deleted ones - they're handled above)
|
|
foreach (var totpCode in newItem.TotpCodes.Where(t => !t.IsDeleted))
|
|
{
|
|
if (totpCode.Id != Guid.Empty)
|
|
{
|
|
var existingTotpCode = existingItem.TotpCodes.FirstOrDefault(t => t.Id == totpCode.Id);
|
|
if (existingTotpCode != null)
|
|
{
|
|
context.Entry(existingTotpCode).CurrentValues.SetValues(totpCode);
|
|
existingTotpCode.UpdatedAt = updateDateTime;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
// Create a new TOTP code entity and add via context to ensure proper tracking
|
|
var newTotpCode = new TotpCode
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
ItemId = existingItem.Id,
|
|
Name = totpCode.Name,
|
|
SecretKey = totpCode.SecretKey,
|
|
CreatedAt = updateDateTime,
|
|
UpdatedAt = updateDateTime,
|
|
IsDeleted = false,
|
|
};
|
|
context.Add(newTotpCode);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Extract favicon from service URL if available. If successful, links the item to the logo.
|
|
/// Checks for existing logo first to avoid unnecessary API calls (deduplication).
|
|
/// If URL is empty or just the placeholder, clears any existing logo from the item.
|
|
/// </summary>
|
|
/// <param name="item">The Item to extract the favicon for.</param>
|
|
/// <returns>Task.</returns>
|
|
private async Task ExtractFaviconAsync(Item item)
|
|
{
|
|
// Try to extract favicon from service URL
|
|
var url = GetFieldValue(item, FieldKey.LoginUrl);
|
|
if (url != null && !string.IsNullOrEmpty(url) && url != DefaultServiceUrl)
|
|
{
|
|
try
|
|
{
|
|
// Extract and normalize domain for deduplication
|
|
var domain = new Uri(url).Host.ToLowerInvariant();
|
|
if (domain.StartsWith("www."))
|
|
{
|
|
domain = domain[4..];
|
|
}
|
|
|
|
var context = await dbService.GetDbContextAsync();
|
|
|
|
// Check if logo already exists for this source (deduplication)
|
|
var existingLogo = await context.Logos.FirstOrDefaultAsync(l => l.Source == domain);
|
|
|
|
if (existingLogo != null)
|
|
{
|
|
// Reuse existing logo - no need to fetch
|
|
item.LogoId = existingLogo.Id;
|
|
return;
|
|
}
|
|
|
|
// No existing logo - fetch from API
|
|
var apiReturn = await httpClient.GetFromJsonAsync<FaviconExtractModel>($"v1/Favicon/Extract?url={Uri.EscapeDataString(url)}");
|
|
if (apiReturn?.Image is not null)
|
|
{
|
|
// Create new logo
|
|
var newLogo = new Logo
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
Source = domain,
|
|
FileData = apiReturn.Image,
|
|
MimeType = "image/png",
|
|
FetchedAt = DateTime.UtcNow,
|
|
CreatedAt = DateTime.UtcNow,
|
|
UpdatedAt = DateTime.UtcNow,
|
|
};
|
|
context.Logos.Add(newLogo);
|
|
item.LogoId = newLogo.Id;
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// Ignore favicon extraction errors
|
|
}
|
|
}
|
|
else
|
|
{
|
|
// URL is empty or just the placeholder - clear any existing logo
|
|
item.LogoId = null;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the effective identity generator language to use.
|
|
/// If user has explicitly set a language preference, use that.
|
|
/// Otherwise, intelligently match the UI language to an available identity generator language.
|
|
/// Falls back to "en" if no match is found.
|
|
/// </summary>
|
|
/// <returns>The identity generator language code to use.</returns>
|
|
private async Task<string> GetEffectiveIdentityLanguageAsync()
|
|
{
|
|
var explicitLanguage = dbService.Settings.DefaultIdentityLanguage;
|
|
|
|
// If user has explicitly set a language preference, use it
|
|
if (!string.IsNullOrWhiteSpace(explicitLanguage))
|
|
{
|
|
return explicitLanguage;
|
|
}
|
|
|
|
// Otherwise, try to match UI language to an identity generator language
|
|
var uiLanguage = dbService.Settings.AppLanguage;
|
|
var mappedLanguage = await jsInteropService.MapUiLanguageToIdentityLanguageAsync(uiLanguage);
|
|
|
|
// Return the mapped language, or fall back to "en" if no match found
|
|
return mappedLanguage ?? "en";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Track field history for fields with EnableHistory=true.
|
|
/// Compares old values with new values and creates history records.
|
|
///
|
|
/// This saves the NEW value to history on every change. Since each value is saved
|
|
/// when it's set, we don't need to save the old value (it was already saved when
|
|
/// it was first set). This ensures that during merge conflicts, no values are ever
|
|
/// lost since history records sync independently via LWW and each has a unique ID.
|
|
/// </summary>
|
|
/// <param name="context">The database context.</param>
|
|
/// <param name="existingItem">The existing item in the database.</param>
|
|
/// <param name="newItem">The new item with updated field values.</param>
|
|
/// <param name="updateDateTime">The timestamp for updates.</param>
|
|
/// <returns>Task.</returns>
|
|
#pragma warning disable SA1204 // Static members should appear before non-static members
|
|
private static async Task TrackFieldHistoryAsync(AliasClientDbContext context, Item existingItem, Item newItem, DateTime updateDateTime)
|
|
#pragma warning restore SA1204
|
|
{
|
|
// Maximum number of history records to keep per field
|
|
const int MaxFieldHistoryRecords = 10;
|
|
|
|
// Get the existing tracked field values from the existingItem (loaded with Include)
|
|
var existingFields = existingItem.FieldValues.Where(fv => !fv.IsDeleted).ToList();
|
|
|
|
// Group existing fields by key to support multi-value fields
|
|
var existingByFieldKey = existingFields
|
|
.Where(f => f.FieldKey != null)
|
|
.GroupBy(f => f.FieldKey!)
|
|
.ToDictionary(g => g.Key, g => g.OrderBy(f => f.Weight).Select(f => f.Value ?? string.Empty).ToList());
|
|
|
|
// Group new fields by key to handle multi-value fields
|
|
var newFieldsByKey = newItem.FieldValues
|
|
.Where(fv => !fv.IsDeleted && fv.FieldKey != null)
|
|
.GroupBy(f => f.FieldKey!)
|
|
.ToDictionary(g => g.Key, g => g.OrderBy(f => f.Weight).Select(f => f.Value ?? string.Empty).ToList());
|
|
|
|
foreach (var (fieldKey, newValues) in newFieldsByKey)
|
|
{
|
|
// Check if history tracking is enabled for this field
|
|
var fieldDef = SystemFieldRegistry.GetSystemField(fieldKey);
|
|
if (fieldDef == null || !fieldDef.EnableHistory)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var oldValues = existingByFieldKey.TryGetValue(fieldKey, out var values) ? values : new List<string>();
|
|
|
|
// Filter out empty values for comparison
|
|
var filteredNewValues = newValues.Where(v => !string.IsNullOrWhiteSpace(v)).ToList();
|
|
|
|
// Check if values have changed
|
|
var valuesChanged = oldValues.Count != filteredNewValues.Count ||
|
|
!oldValues.SequenceEqual(filteredNewValues);
|
|
|
|
// Save new values to history when they change (ensures they survive merge conflicts)
|
|
if (valuesChanged && filteredNewValues.Count > 0)
|
|
{
|
|
var valueSnapshot = System.Text.Json.JsonSerializer.Serialize(filteredNewValues);
|
|
var historyRecord = new FieldHistory
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
ItemId = existingItem.Id,
|
|
FieldKey = fieldKey,
|
|
FieldDefinitionId = null,
|
|
ValueSnapshot = valueSnapshot,
|
|
ChangedAt = updateDateTime,
|
|
CreatedAt = updateDateTime,
|
|
UpdatedAt = updateDateTime,
|
|
IsDeleted = false,
|
|
};
|
|
context.FieldHistories.Add(historyRecord);
|
|
|
|
// Prune old history records to keep only the most recent
|
|
await PruneFieldHistoryAsync(context, existingItem.Id, fieldKey, MaxFieldHistoryRecords, updateDateTime);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Create initial field history records for fields with EnableHistory=true when creating a new item.
|
|
/// This ensures the initial value is captured in history for merge conflict resolution.
|
|
/// </summary>
|
|
/// <param name="context">The database context.</param>
|
|
/// <param name="item">The new item being created.</param>
|
|
/// <param name="createDateTime">The timestamp for creation.</param>
|
|
/// <returns>Task.</returns>
|
|
private static Task CreateInitialFieldHistoryAsync(AliasClientDbContext context, Item item, DateTime createDateTime)
|
|
{
|
|
// Group field values by key to handle multi-value fields
|
|
var fieldsByKey = item.FieldValues
|
|
.Where(fv => !fv.IsDeleted && fv.FieldKey != null)
|
|
.GroupBy(f => f.FieldKey!)
|
|
.ToDictionary(g => g.Key, g => g.OrderBy(f => f.Weight).Select(f => f.Value ?? string.Empty).ToList());
|
|
|
|
foreach (var (fieldKey, values) in fieldsByKey)
|
|
{
|
|
// Check if history tracking is enabled for this field
|
|
var fieldDef = SystemFieldRegistry.GetSystemField(fieldKey);
|
|
if (fieldDef == null || !fieldDef.EnableHistory)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
// Filter out empty values
|
|
var filteredValues = values.Where(v => !string.IsNullOrWhiteSpace(v)).ToList();
|
|
|
|
if (filteredValues.Count > 0)
|
|
{
|
|
var valueSnapshot = System.Text.Json.JsonSerializer.Serialize(filteredValues);
|
|
var historyRecord = new FieldHistory
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
ItemId = item.Id,
|
|
FieldKey = fieldKey,
|
|
FieldDefinitionId = null,
|
|
ValueSnapshot = valueSnapshot,
|
|
ChangedAt = createDateTime,
|
|
CreatedAt = createDateTime,
|
|
UpdatedAt = createDateTime,
|
|
IsDeleted = false,
|
|
};
|
|
context.FieldHistories.Add(historyRecord);
|
|
}
|
|
}
|
|
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Prune old field history records.
|
|
/// Keeps only the most recent MaxFieldHistoryRecords records.
|
|
/// </summary>
|
|
/// <param name="context">The database context.</param>
|
|
/// <param name="itemId">The item ID.</param>
|
|
/// <param name="fieldKey">The field key.</param>
|
|
/// <param name="maxRecords">Maximum number of records to keep.</param>
|
|
/// <param name="updateDateTime">The timestamp for updates.</param>
|
|
/// <returns>Task.</returns>
|
|
private static async Task PruneFieldHistoryAsync(AliasClientDbContext context, Guid itemId, string fieldKey, int maxRecords, DateTime updateDateTime)
|
|
{
|
|
// Get all history records for this field, ordered by ChangedAt descending
|
|
var historyRecords = await context.FieldHistories
|
|
.Where(fh => fh.ItemId == itemId && fh.FieldKey == fieldKey && !fh.IsDeleted)
|
|
.OrderByDescending(fh => fh.ChangedAt)
|
|
.ToListAsync();
|
|
|
|
// If we have more than maxRecords, soft-delete the old ones
|
|
if (historyRecords.Count > maxRecords)
|
|
{
|
|
var recordsToDelete = historyRecords.Skip(maxRecords);
|
|
foreach (var record in recordsToDelete)
|
|
{
|
|
record.IsDeleted = true;
|
|
record.UpdatedAt = updateDateTime;
|
|
}
|
|
}
|
|
}
|
|
}
|