Compare commits
4 Commits
6c8ad05872
...
db98fa240e
| Author | SHA1 | Date | |
|---|---|---|---|
|
db98fa240e
|
|||
|
d96937aabc
|
|||
|
dc0be3467f
|
|||
|
6101de741f
|
@@ -9,6 +9,7 @@ using Microsoft.EntityFrameworkCore.Design;
|
||||
using Microsoft.EntityFrameworkCore.Query;
|
||||
using NodaTime;
|
||||
using Quartz;
|
||||
using TaskStatus = DysonNetwork.Drive.Storage.Model.TaskStatus;
|
||||
|
||||
namespace DysonNetwork.Drive;
|
||||
|
||||
@@ -150,26 +151,41 @@ public class AppDatabaseRecyclingJob(AppDatabase db, ILogger<AppDatabaseRecyclin
|
||||
}
|
||||
}
|
||||
|
||||
public class UploadTaskCleanupJob(
|
||||
public class PersistentTaskCleanupJob(
|
||||
IServiceProvider serviceProvider,
|
||||
ILogger<UploadTaskCleanupJob> logger
|
||||
ILogger<PersistentTaskCleanupJob> logger
|
||||
) : IJob
|
||||
{
|
||||
public async Task Execute(IJobExecutionContext context)
|
||||
{
|
||||
logger.LogInformation("Cleaning up stale upload tasks...");
|
||||
logger.LogInformation("Cleaning up stale persistent tasks...");
|
||||
|
||||
// Get the PersistentUploadService from DI
|
||||
// Get the PersistentTaskService from DI
|
||||
using var scope = serviceProvider.CreateScope();
|
||||
var persistentUploadService = scope.ServiceProvider.GetService(typeof(DysonNetwork.Drive.Storage.PersistentUploadService));
|
||||
var persistentTaskService = scope.ServiceProvider.GetService(typeof(PersistentTaskService));
|
||||
|
||||
if (persistentUploadService is DysonNetwork.Drive.Storage.PersistentUploadService service)
|
||||
if (persistentTaskService is PersistentTaskService service)
|
||||
{
|
||||
await service.CleanupStaleTasksAsync();
|
||||
// Clean up tasks for all users (you might want to add user-specific logic here)
|
||||
// For now, we'll clean up tasks older than 30 days for all users
|
||||
var cutoff = SystemClock.Instance.GetCurrentInstant() - Duration.FromDays(30);
|
||||
var tasksToClean = await service.GetUserTasksAsync(
|
||||
Guid.Empty, // This would need to be adjusted for multi-user cleanup
|
||||
status: TaskStatus.Completed | TaskStatus.Failed | TaskStatus.Cancelled | TaskStatus.Expired
|
||||
);
|
||||
|
||||
var cleanedCount = 0;
|
||||
foreach (var task in tasksToClean.Items.Where(t => t.UpdatedAt < cutoff))
|
||||
{
|
||||
await service.CancelTaskAsync(task.TaskId); // Or implement a proper cleanup method
|
||||
cleanedCount++;
|
||||
}
|
||||
|
||||
logger.LogInformation("Cleaned up {Count} stale persistent tasks", cleanedCount);
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogWarning("PersistentUploadService not found in DI container");
|
||||
logger.LogWarning("PersistentTaskService not found in DI container");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ builder.ConfigureAppKestrel(builder.Configuration, maxRequestBodySize: long.MaxV
|
||||
builder.Services.AddAppServices(builder.Configuration);
|
||||
builder.Services.AddAppAuthentication();
|
||||
builder.Services.AddDysonAuth();
|
||||
builder.Services.AddRingService();
|
||||
builder.Services.AddAccountService();
|
||||
|
||||
builder.Services.AddAppFlushHandlers();
|
||||
|
||||
@@ -22,6 +22,13 @@ public static class ScheduledJobsConfiguration
|
||||
.ForJob(cloudFileUnusedRecyclingJob)
|
||||
.WithIdentity("CloudFileUnusedRecyclingTrigger")
|
||||
.WithCronSchedule("0 0 0 * * ?"));
|
||||
|
||||
var persistentTaskCleanupJob = new JobKey("PersistentTaskCleanup");
|
||||
q.AddJob<PersistentTaskCleanupJob>(opts => opts.WithIdentity(persistentTaskCleanupJob));
|
||||
q.AddTrigger(opts => opts
|
||||
.ForJob(persistentTaskCleanupJob)
|
||||
.WithIdentity("PersistentTaskCleanupTrigger")
|
||||
.WithCronSchedule("0 0 2 * * ?")); // Run daily at 2 AM
|
||||
});
|
||||
services.AddQuartzHostedService(q => q.WaitForJobsToComplete = true);
|
||||
|
||||
|
||||
@@ -55,11 +55,12 @@ public static class ServiceCollectionExtensions
|
||||
{
|
||||
services.AddScoped<Storage.FileService>();
|
||||
services.AddScoped<Storage.FileReferenceService>();
|
||||
services.AddScoped<Storage.PersistentTaskService>();
|
||||
services.AddScoped<Billing.UsageService>();
|
||||
services.AddScoped<Billing.QuotaService>();
|
||||
|
||||
services.AddHostedService<BroadcastEventHandler>();
|
||||
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,8 +126,7 @@ public class FileService(
|
||||
private async Task<FilePool> ValidateAndGetPoolAsync(string filePool)
|
||||
{
|
||||
var pool = await GetPoolAsync(Guid.Parse(filePool));
|
||||
if (pool is null) throw new InvalidOperationException("Pool not found");
|
||||
return pool;
|
||||
return pool ?? throw new InvalidOperationException("Pool not found: " + filePool);
|
||||
}
|
||||
|
||||
private async Task<SnFileBundle?> ValidateAndGetBundleAsync(string? fileBundleId, Guid accountId)
|
||||
@@ -135,12 +134,10 @@ public class FileService(
|
||||
if (fileBundleId is null) return null;
|
||||
|
||||
var bundle = await GetBundleAsync(Guid.Parse(fileBundleId), accountId);
|
||||
if (bundle is null) throw new InvalidOperationException("Bundle not found");
|
||||
|
||||
return bundle;
|
||||
return bundle ?? throw new InvalidOperationException("Bundle not found: " + fileBundleId);
|
||||
}
|
||||
|
||||
private Instant? CalculateFinalExpiration(Instant? expiredAt, FilePool pool, SnFileBundle? bundle)
|
||||
private static Instant? CalculateFinalExpiration(Instant? expiredAt, FilePool pool, SnFileBundle? bundle)
|
||||
{
|
||||
var finalExpiredAt = expiredAt;
|
||||
|
||||
|
||||
@@ -24,12 +24,14 @@ public class FileUploadController(
|
||||
AppDatabase db,
|
||||
PermissionService.PermissionServiceClient permission,
|
||||
QuotaService quotaService,
|
||||
PersistentUploadService persistentUploadService
|
||||
PersistentTaskService persistentTaskService,
|
||||
ILogger<FileUploadController> logger
|
||||
)
|
||||
: ControllerBase
|
||||
{
|
||||
private readonly string _tempPath =
|
||||
configuration.GetValue<string>("Storage:Uploads") ?? Path.Combine(Path.GetTempPath(), "multipart-uploads");
|
||||
private readonly ILogger<FileUploadController> _logger = logger;
|
||||
|
||||
private const long DefaultChunkSize = 1024 * 1024 * 5; // 5MB
|
||||
|
||||
@@ -75,7 +77,7 @@ public class FileUploadController(
|
||||
var taskId = await Nanoid.GenerateAsync();
|
||||
|
||||
// Create persistent upload task
|
||||
var persistentTask = await persistentUploadService.CreateUploadTaskAsync(taskId, request, accountId);
|
||||
var persistentTask = await persistentTaskService.CreateUploadTaskAsync(taskId, request, accountId);
|
||||
|
||||
return Ok(new CreateUploadTaskResponse
|
||||
{
|
||||
@@ -231,7 +233,7 @@ public class FileUploadController(
|
||||
var chunk = request.Chunk;
|
||||
|
||||
// Check if chunk is already uploaded (resumable upload)
|
||||
if (await persistentUploadService.IsChunkUploadedAsync(taskId, chunkIndex))
|
||||
if (await persistentTaskService.IsChunkUploadedAsync(taskId, chunkIndex))
|
||||
{
|
||||
return Ok(new { message = "Chunk already uploaded" });
|
||||
}
|
||||
@@ -247,7 +249,7 @@ public class FileUploadController(
|
||||
await chunk.CopyToAsync(stream);
|
||||
|
||||
// Update persistent task progress
|
||||
await persistentUploadService.UpdateChunkProgressAsync(taskId, chunkIndex);
|
||||
await persistentTaskService.UpdateChunkProgressAsync(taskId, chunkIndex);
|
||||
|
||||
return Ok();
|
||||
}
|
||||
@@ -256,7 +258,7 @@ public class FileUploadController(
|
||||
public async Task<IActionResult> CompleteUpload(string taskId)
|
||||
{
|
||||
// Get persistent task
|
||||
var persistentTask = await persistentUploadService.GetUploadTaskAsync(taskId);
|
||||
var persistentTask = await persistentTaskService.GetUploadTaskAsync(taskId);
|
||||
if (persistentTask is null)
|
||||
return new ObjectResult(ApiError.NotFound("Upload task")) { StatusCode = 404 };
|
||||
|
||||
@@ -292,27 +294,30 @@ public class FileUploadController(
|
||||
);
|
||||
|
||||
// Mark task as completed
|
||||
await persistentUploadService.MarkTaskCompletedAsync(taskId);
|
||||
await persistentTaskService.MarkTaskCompletedAsync(taskId);
|
||||
|
||||
// Send completion notification
|
||||
await persistentUploadService.SendUploadCompletedNotificationAsync(persistentTask, fileId);
|
||||
await persistentTaskService.SendUploadCompletedNotificationAsync(persistentTask, fileId);
|
||||
|
||||
return Ok(cloudFile);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Log the actual exception for debugging
|
||||
_logger.LogError(ex, "Failed to complete upload for task {TaskId}. Error: {ErrorMessage}", taskId, ex.Message);
|
||||
|
||||
// Mark task as failed
|
||||
await persistentUploadService.MarkTaskFailedAsync(taskId);
|
||||
await persistentTaskService.MarkTaskFailedAsync(taskId);
|
||||
|
||||
// Send failure notification
|
||||
await persistentUploadService.SendUploadFailedNotificationAsync(persistentTask, ex.Message);
|
||||
await persistentTaskService.SendUploadFailedNotificationAsync(persistentTask, ex.Message);
|
||||
|
||||
await CleanupTempFiles(taskPath, mergedFilePath);
|
||||
|
||||
return new ObjectResult(new ApiError
|
||||
{
|
||||
Code = "UPLOAD_FAILED",
|
||||
Message = "Failed to complete file upload.",
|
||||
Message = $"Failed to complete file upload: {ex.Message}",
|
||||
Status = 500
|
||||
}) { StatusCode = 500 };
|
||||
}
|
||||
@@ -372,7 +377,7 @@ public class FileUploadController(
|
||||
return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 };
|
||||
|
||||
var accountId = Guid.Parse(currentUser.Id);
|
||||
var tasks = await persistentUploadService.GetUserTasksAsync(accountId, status, sortBy, sortDescending, offset, limit);
|
||||
var tasks = await persistentTaskService.GetUserUploadTasksAsync(accountId, status, sortBy, sortDescending, offset, limit);
|
||||
|
||||
Response.Headers.Append("X-Total", tasks.TotalCount.ToString());
|
||||
|
||||
@@ -403,7 +408,7 @@ public class FileUploadController(
|
||||
if (currentUser is null)
|
||||
return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 };
|
||||
|
||||
var task = await persistentUploadService.GetUploadTaskAsync(taskId);
|
||||
var task = await persistentTaskService.GetUploadTaskAsync(taskId);
|
||||
if (task is null)
|
||||
return new ObjectResult(ApiError.NotFound("Upload task")) { StatusCode = 404 };
|
||||
|
||||
@@ -411,7 +416,7 @@ public class FileUploadController(
|
||||
if (task.AccountId != Guid.Parse(currentUser.Id))
|
||||
return new ObjectResult(ApiError.Unauthorized(forbidden: true)) { StatusCode = 403 };
|
||||
|
||||
var progress = await persistentUploadService.GetUploadProgressAsync(taskId);
|
||||
var progress = await persistentTaskService.GetUploadProgressAsync(taskId);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
@@ -434,7 +439,7 @@ public class FileUploadController(
|
||||
if (currentUser is null)
|
||||
return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 };
|
||||
|
||||
var task = await persistentUploadService.GetUploadTaskAsync(taskId);
|
||||
var task = await persistentTaskService.GetUploadTaskAsync(taskId);
|
||||
if (task is null)
|
||||
return new ObjectResult(ApiError.NotFound("Upload task")) { StatusCode = 404 };
|
||||
|
||||
@@ -470,7 +475,7 @@ public class FileUploadController(
|
||||
if (currentUser is null)
|
||||
return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 };
|
||||
|
||||
var task = await persistentUploadService.GetUploadTaskAsync(taskId);
|
||||
var task = await persistentTaskService.GetUploadTaskAsync(taskId);
|
||||
if (task is null)
|
||||
return new ObjectResult(ApiError.NotFound("Upload task")) { StatusCode = 404 };
|
||||
|
||||
@@ -479,7 +484,7 @@ public class FileUploadController(
|
||||
return new ObjectResult(ApiError.Unauthorized(forbidden: true)) { StatusCode = 403 };
|
||||
|
||||
// Mark as failed (cancelled)
|
||||
await persistentUploadService.MarkTaskFailedAsync(taskId);
|
||||
await persistentTaskService.MarkTaskFailedAsync(taskId);
|
||||
|
||||
// Clean up temp files
|
||||
var taskPath = Path.Combine(_tempPath, taskId);
|
||||
@@ -496,7 +501,7 @@ public class FileUploadController(
|
||||
return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 };
|
||||
|
||||
var accountId = Guid.Parse(currentUser.Id);
|
||||
var stats = await persistentUploadService.GetUserUploadStatsAsync(accountId);
|
||||
var stats = await persistentTaskService.GetUserUploadStatsAsync(accountId);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
@@ -519,7 +524,7 @@ public class FileUploadController(
|
||||
return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 };
|
||||
|
||||
var accountId = Guid.Parse(currentUser.Id);
|
||||
var cleanedCount = await persistentUploadService.CleanupUserFailedTasksAsync(accountId);
|
||||
var cleanedCount = await persistentTaskService.CleanupUserFailedTasksAsync(accountId);
|
||||
|
||||
return Ok(new { message = $"Cleaned up {cleanedCount} failed tasks" });
|
||||
}
|
||||
@@ -532,7 +537,7 @@ public class FileUploadController(
|
||||
return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 };
|
||||
|
||||
var accountId = Guid.Parse(currentUser.Id);
|
||||
var tasks = await persistentUploadService.GetRecentUserTasksAsync(accountId, limit);
|
||||
var tasks = await persistentTaskService.GetRecentUserTasksAsync(accountId, limit);
|
||||
|
||||
return Ok(tasks.Select(t => new
|
||||
{
|
||||
@@ -554,7 +559,7 @@ public class FileUploadController(
|
||||
if (currentUser is null)
|
||||
return new ObjectResult(ApiError.Unauthorized()) { StatusCode = 401 };
|
||||
|
||||
var task = await persistentUploadService.GetUploadTaskAsync(taskId);
|
||||
var task = await persistentTaskService.GetUploadTaskAsync(taskId);
|
||||
if (task is null)
|
||||
return new ObjectResult(ApiError.NotFound("Upload task")) { StatusCode = 404 };
|
||||
|
||||
|
||||
@@ -1,10 +1,87 @@
|
||||
using DysonNetwork.Shared.Models;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using Google.Protobuf.Collections;
|
||||
using NodaTime;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace DysonNetwork.Drive.Storage.Model;
|
||||
|
||||
// File Upload Task Parameters
|
||||
public class FileUploadParameters
|
||||
{
|
||||
public string FileName { get; set; } = string.Empty;
|
||||
public long FileSize { get; set; }
|
||||
public string ContentType { get; set; } = string.Empty;
|
||||
public long ChunkSize { get; set; } = 5242880L;
|
||||
public int ChunksCount { get; set; }
|
||||
public int ChunksUploaded { get; set; }
|
||||
public Guid PoolId { get; set; }
|
||||
public Guid? BundleId { get; set; }
|
||||
public string? EncryptPassword { get; set; }
|
||||
public string Hash { get; set; } = string.Empty;
|
||||
public List<int> UploadedChunks { get; set; } = [];
|
||||
}
|
||||
|
||||
// File Move Task Parameters
|
||||
public class FileMoveParameters
|
||||
{
|
||||
public List<string> FileIds { get; set; } = [];
|
||||
public Guid TargetPoolId { get; set; }
|
||||
public Guid? TargetBundleId { get; set; }
|
||||
public int FilesProcessed { get; set; }
|
||||
}
|
||||
|
||||
// File Compression Task Parameters
|
||||
public class FileCompressParameters
|
||||
{
|
||||
public List<string> FileIds { get; set; } = [];
|
||||
public string CompressionFormat { get; set; } = "zip";
|
||||
public int CompressionLevel { get; set; } = 6;
|
||||
public string? OutputFileName { get; set; }
|
||||
public int FilesProcessed { get; set; }
|
||||
public string? ResultFileId { get; set; }
|
||||
}
|
||||
|
||||
// Bulk Operation Task Parameters
|
||||
public class BulkOperationParameters
|
||||
{
|
||||
public string OperationType { get; set; } = string.Empty;
|
||||
public List<string> TargetIds { get; set; } = [];
|
||||
public Dictionary<string, object?> OperationParameters { get; set; } = new();
|
||||
public int ItemsProcessed { get; set; }
|
||||
public Dictionary<string, object?>? OperationResults { get; set; }
|
||||
}
|
||||
|
||||
// Storage Migration Task Parameters
|
||||
public class StorageMigrationParameters
|
||||
{
|
||||
public Guid SourcePoolId { get; set; }
|
||||
public Guid TargetPoolId { get; set; }
|
||||
public List<string> FileIds { get; set; } = new();
|
||||
public bool PreserveOriginals { get; set; } = true;
|
||||
public long TotalBytesToTransfer { get; set; }
|
||||
public long BytesTransferred { get; set; }
|
||||
public int FilesMigrated { get; set; }
|
||||
}
|
||||
|
||||
// Helper class for parameter operations using GrpcTypeHelper
|
||||
public static class ParameterHelper
|
||||
{
|
||||
public static T? Typed<T>(Dictionary<string, object?> parameters)
|
||||
{
|
||||
var rawParams = GrpcTypeHelper.ConvertObjectToByteString(parameters);
|
||||
return GrpcTypeHelper.ConvertByteStringToObject<T>(rawParams);
|
||||
}
|
||||
|
||||
public static Dictionary<string, object?> Untyped<T>(T parameters)
|
||||
{
|
||||
var rawParams = GrpcTypeHelper.ConvertObjectToByteString(parameters);
|
||||
return GrpcTypeHelper.ConvertByteStringToObject<Dictionary<string, object?>>(rawParams) ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
public class CreateUploadTaskRequest
|
||||
{
|
||||
public string Hash { get; set; } = null!;
|
||||
@@ -46,14 +123,11 @@ public class PersistentTask : ModelBase
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
[MaxLength(64)]
|
||||
public string TaskId { get; set; } = null!;
|
||||
[MaxLength(64)] public string TaskId { get; set; } = null!;
|
||||
|
||||
[MaxLength(256)]
|
||||
public string Name { get; set; } = null!;
|
||||
[MaxLength(256)] public string Name { get; set; } = null!;
|
||||
|
||||
[MaxLength(1024)]
|
||||
public string? Description { get; set; }
|
||||
[MaxLength(1024)] public string? Description { get; set; }
|
||||
|
||||
public TaskType Type { get; set; }
|
||||
|
||||
@@ -65,15 +139,12 @@ public class PersistentTask : ModelBase
|
||||
public double Progress { get; set; }
|
||||
|
||||
// Task-specific parameters stored as JSON
|
||||
[Column(TypeName = "jsonb")]
|
||||
public Dictionary<string, object?> Parameters { get; set; } = new();
|
||||
[Column(TypeName = "jsonb")] public Dictionary<string, object?> Parameters { get; set; } = new();
|
||||
|
||||
// Task results/output stored as JSON
|
||||
[Column(TypeName = "jsonb")]
|
||||
public Dictionary<string, object?> Results { get; set; } = new();
|
||||
[Column(TypeName = "jsonb")] public Dictionary<string, object?> Results { get; set; } = new();
|
||||
|
||||
[MaxLength(1024)]
|
||||
public string? ErrorMessage { get; set; }
|
||||
[MaxLength(1024)] public string? ErrorMessage { get; set; }
|
||||
|
||||
public Instant? StartedAt { get; set; }
|
||||
public Instant? CompletedAt { get; set; }
|
||||
@@ -86,6 +157,24 @@ public class PersistentTask : ModelBase
|
||||
|
||||
// Estimated duration in seconds
|
||||
public long? EstimatedDurationSeconds { get; set; }
|
||||
|
||||
// Helper methods for parameter management using GrpcTypeHelper
|
||||
public MapField<string, Google.Protobuf.WellKnownTypes.Value> GetParametersAsGrpcMap()
|
||||
{
|
||||
var nonNullableParameters = Parameters.ToDictionary(kvp => kvp.Key, kvp => kvp.Value ?? string.Empty);
|
||||
return GrpcTypeHelper.ConvertToValueMap(nonNullableParameters);
|
||||
}
|
||||
|
||||
public void SetParametersFromGrpcMap(MapField<string, Google.Protobuf.WellKnownTypes.Value> map)
|
||||
{
|
||||
Parameters = GrpcTypeHelper.ConvertFromValueMap(map);
|
||||
}
|
||||
|
||||
public MapField<string, Google.Protobuf.WellKnownTypes.Value> GetResultsAsGrpcMap()
|
||||
{
|
||||
var nonNullableResults = Results.ToDictionary(kvp => kvp.Key, kvp => kvp.Value ?? string.Empty);
|
||||
return GrpcTypeHelper.ConvertToValueMap(nonNullableResults);
|
||||
}
|
||||
}
|
||||
|
||||
// Backward compatibility - UploadTask inherits from PersistentTask
|
||||
@@ -97,82 +186,138 @@ public class PersistentUploadTask : PersistentTask
|
||||
Name = "File Upload";
|
||||
}
|
||||
|
||||
// Convenience properties using typed parameters
|
||||
[NotMapped]
|
||||
public FileUploadParameters TypedParameters
|
||||
{
|
||||
get => ParameterHelper.Typed<FileUploadParameters>(Parameters)!;
|
||||
set => Parameters = ParameterHelper.Untyped(value);
|
||||
}
|
||||
|
||||
[MaxLength(256)]
|
||||
public string FileName
|
||||
{
|
||||
get => Parameters.GetValueOrDefault("fileName") as string ?? string.Empty;
|
||||
set => Parameters["fileName"] = value;
|
||||
get => TypedParameters.FileName;
|
||||
set
|
||||
{
|
||||
var parameters = TypedParameters;
|
||||
parameters.FileName = value;
|
||||
TypedParameters = parameters;
|
||||
}
|
||||
}
|
||||
|
||||
public long FileSize
|
||||
{
|
||||
get => Convert.ToInt64(Parameters.GetValueOrDefault("fileSize") ?? 0L);
|
||||
set => Parameters["fileSize"] = value;
|
||||
get => TypedParameters.FileSize;
|
||||
set
|
||||
{
|
||||
var parameters = TypedParameters;
|
||||
parameters.FileSize = value;
|
||||
TypedParameters = parameters;
|
||||
}
|
||||
}
|
||||
|
||||
[MaxLength(128)]
|
||||
public string ContentType
|
||||
{
|
||||
get => Parameters.GetValueOrDefault("contentType") as string ?? string.Empty;
|
||||
set => Parameters["contentType"] = value;
|
||||
get => TypedParameters.ContentType;
|
||||
set
|
||||
{
|
||||
var parameters = TypedParameters;
|
||||
parameters.ContentType = value;
|
||||
TypedParameters = parameters;
|
||||
}
|
||||
}
|
||||
|
||||
public long ChunkSize
|
||||
{
|
||||
get => Convert.ToInt64(Parameters.GetValueOrDefault("chunkSize") ?? 5242880L);
|
||||
set => Parameters["chunkSize"] = value;
|
||||
get => TypedParameters.ChunkSize;
|
||||
set
|
||||
{
|
||||
var parameters = TypedParameters;
|
||||
parameters.ChunkSize = value;
|
||||
TypedParameters = parameters;
|
||||
}
|
||||
}
|
||||
|
||||
public int ChunksCount
|
||||
{
|
||||
get => Convert.ToInt32(Parameters.GetValueOrDefault("chunksCount") ?? 0);
|
||||
set => Parameters["chunksCount"] = value;
|
||||
get => TypedParameters.ChunksCount;
|
||||
set
|
||||
{
|
||||
var parameters = TypedParameters;
|
||||
parameters.ChunksCount = value;
|
||||
TypedParameters = parameters;
|
||||
}
|
||||
}
|
||||
|
||||
public int ChunksUploaded
|
||||
{
|
||||
get => Convert.ToInt32(Parameters.GetValueOrDefault("chunksUploaded") ?? 0);
|
||||
get => TypedParameters.ChunksUploaded;
|
||||
set
|
||||
{
|
||||
Parameters["chunksUploaded"] = value;
|
||||
var parameters = TypedParameters;
|
||||
parameters.ChunksUploaded = value;
|
||||
TypedParameters = parameters;
|
||||
Progress = ChunksCount > 0 ? (double)value / ChunksCount * 100 : 0;
|
||||
}
|
||||
}
|
||||
|
||||
public Guid PoolId
|
||||
{
|
||||
get => Guid.Parse(Parameters.GetValueOrDefault("poolId") as string ?? Guid.Empty.ToString());
|
||||
set => Parameters["poolId"] = value.ToString();
|
||||
get => TypedParameters.PoolId;
|
||||
set
|
||||
{
|
||||
var parameters = TypedParameters;
|
||||
parameters.PoolId = value;
|
||||
TypedParameters = parameters;
|
||||
}
|
||||
}
|
||||
|
||||
public Guid? BundleId
|
||||
{
|
||||
get
|
||||
get => TypedParameters.BundleId;
|
||||
set
|
||||
{
|
||||
var bundleIdStr = Parameters.GetValueOrDefault("bundleId") as string;
|
||||
return string.IsNullOrEmpty(bundleIdStr) ? null : Guid.Parse(bundleIdStr);
|
||||
var parameters = TypedParameters;
|
||||
parameters.BundleId = value;
|
||||
TypedParameters = parameters;
|
||||
}
|
||||
set => Parameters["bundleId"] = value?.ToString();
|
||||
}
|
||||
|
||||
[MaxLength(256)]
|
||||
public string? EncryptPassword
|
||||
{
|
||||
get => Parameters.GetValueOrDefault("encryptPassword") as string;
|
||||
set => Parameters["encryptPassword"] = value;
|
||||
get => TypedParameters.EncryptPassword;
|
||||
set
|
||||
{
|
||||
var parameters = TypedParameters;
|
||||
parameters.EncryptPassword = value;
|
||||
TypedParameters = parameters;
|
||||
}
|
||||
}
|
||||
|
||||
public string Hash
|
||||
{
|
||||
get => Parameters.GetValueOrDefault("hash") as string ?? string.Empty;
|
||||
set => Parameters["hash"] = value;
|
||||
get => TypedParameters.Hash;
|
||||
set
|
||||
{
|
||||
var parameters = TypedParameters;
|
||||
parameters.Hash = value;
|
||||
TypedParameters = parameters;
|
||||
}
|
||||
}
|
||||
|
||||
// JSON array of uploaded chunk indices for resumability
|
||||
public List<int> UploadedChunks
|
||||
{
|
||||
get => Parameters.GetValueOrDefault("uploadedChunks") as List<int> ?? [];
|
||||
set => Parameters["uploadedChunks"] = value;
|
||||
get => TypedParameters.UploadedChunks;
|
||||
set
|
||||
{
|
||||
var parameters = TypedParameters;
|
||||
parameters.UploadedChunks = value;
|
||||
TypedParameters = parameters;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,6 +335,7 @@ public enum TaskType
|
||||
Custom
|
||||
}
|
||||
|
||||
[Flags]
|
||||
public enum TaskStatus
|
||||
{
|
||||
Pending,
|
||||
@@ -210,34 +356,54 @@ public class FileMoveTask : PersistentTask
|
||||
Name = "Move Files";
|
||||
}
|
||||
|
||||
// Convenience properties using typed parameters
|
||||
public FileMoveParameters TypedParameters
|
||||
{
|
||||
get => ParameterHelper.Typed<FileMoveParameters>(Parameters)!;
|
||||
set => Parameters = ParameterHelper.Untyped(value);
|
||||
}
|
||||
|
||||
public List<string> FileIds
|
||||
{
|
||||
get => Parameters.GetValueOrDefault("fileIds") as List<string> ?? [];
|
||||
set => Parameters["fileIds"] = value;
|
||||
get => TypedParameters.FileIds;
|
||||
set
|
||||
{
|
||||
var parameters = TypedParameters;
|
||||
parameters.FileIds = value;
|
||||
TypedParameters = parameters;
|
||||
}
|
||||
}
|
||||
|
||||
public Guid TargetPoolId
|
||||
{
|
||||
get => Guid.Parse(Parameters.GetValueOrDefault("targetPoolId") as string ?? Guid.Empty.ToString());
|
||||
set => Parameters["targetPoolId"] = value.ToString();
|
||||
get => TypedParameters.TargetPoolId;
|
||||
set
|
||||
{
|
||||
var parameters = TypedParameters;
|
||||
parameters.TargetPoolId = value;
|
||||
TypedParameters = parameters;
|
||||
}
|
||||
}
|
||||
|
||||
public Guid? TargetBundleId
|
||||
{
|
||||
get
|
||||
get => TypedParameters.TargetBundleId;
|
||||
set
|
||||
{
|
||||
var bundleIdStr = Parameters.GetValueOrDefault("targetBundleId") as string;
|
||||
return string.IsNullOrEmpty(bundleIdStr) ? null : Guid.Parse(bundleIdStr);
|
||||
var parameters = TypedParameters;
|
||||
parameters.TargetBundleId = value;
|
||||
TypedParameters = parameters;
|
||||
}
|
||||
set => Parameters["targetBundleId"] = value?.ToString();
|
||||
}
|
||||
|
||||
public int FilesProcessed
|
||||
{
|
||||
get => Convert.ToInt32(Parameters.GetValueOrDefault("filesProcessed") ?? 0);
|
||||
get => TypedParameters.FilesProcessed;
|
||||
set
|
||||
{
|
||||
Parameters["filesProcessed"] = value;
|
||||
var parameters = TypedParameters;
|
||||
parameters.FilesProcessed = value;
|
||||
TypedParameters = parameters;
|
||||
Progress = FileIds.Count > 0 ? (double)value / FileIds.Count * 100 : 0;
|
||||
}
|
||||
}
|
||||
@@ -252,45 +418,79 @@ public class FileCompressTask : PersistentTask
|
||||
Name = "Compress Files";
|
||||
}
|
||||
|
||||
// Convenience properties using typed parameters
|
||||
public FileCompressParameters TypedParameters
|
||||
{
|
||||
get => ParameterHelper.Typed<FileCompressParameters>(Parameters)!;
|
||||
set => Parameters = ParameterHelper.Untyped(value);
|
||||
}
|
||||
|
||||
public List<string> FileIds
|
||||
{
|
||||
get => Parameters.GetValueOrDefault("fileIds") as List<string> ?? [];
|
||||
set => Parameters["fileIds"] = value;
|
||||
get => TypedParameters.FileIds;
|
||||
set
|
||||
{
|
||||
var parameters = TypedParameters;
|
||||
parameters.FileIds = value;
|
||||
TypedParameters = parameters;
|
||||
}
|
||||
}
|
||||
|
||||
[MaxLength(32)]
|
||||
public string CompressionFormat
|
||||
{
|
||||
get => Parameters.GetValueOrDefault("compressionFormat") as string ?? "zip";
|
||||
set => Parameters["compressionFormat"] = value;
|
||||
get => TypedParameters.CompressionFormat;
|
||||
set
|
||||
{
|
||||
var parameters = TypedParameters;
|
||||
parameters.CompressionFormat = value;
|
||||
TypedParameters = parameters;
|
||||
}
|
||||
}
|
||||
|
||||
public int CompressionLevel
|
||||
{
|
||||
get => Convert.ToInt32(Parameters.GetValueOrDefault("compressionLevel") ?? 6);
|
||||
set => Parameters["compressionLevel"] = value;
|
||||
get => TypedParameters.CompressionLevel;
|
||||
set
|
||||
{
|
||||
var parameters = TypedParameters;
|
||||
parameters.CompressionLevel = value;
|
||||
TypedParameters = parameters;
|
||||
}
|
||||
}
|
||||
|
||||
public string? OutputFileName
|
||||
{
|
||||
get => Parameters.GetValueOrDefault("outputFileName") as string;
|
||||
set => Parameters["outputFileName"] = value;
|
||||
get => TypedParameters.OutputFileName;
|
||||
set
|
||||
{
|
||||
var parameters = TypedParameters;
|
||||
parameters.OutputFileName = value;
|
||||
TypedParameters = parameters;
|
||||
}
|
||||
}
|
||||
|
||||
public int FilesProcessed
|
||||
{
|
||||
get => Convert.ToInt32(Parameters.GetValueOrDefault("filesProcessed") ?? 0);
|
||||
get => TypedParameters.FilesProcessed;
|
||||
set
|
||||
{
|
||||
Parameters["filesProcessed"] = value;
|
||||
var parameters = TypedParameters;
|
||||
parameters.FilesProcessed = value;
|
||||
TypedParameters = parameters;
|
||||
Progress = FileIds.Count > 0 ? (double)value / FileIds.Count * 100 : 0;
|
||||
}
|
||||
}
|
||||
|
||||
public string? ResultFileId
|
||||
{
|
||||
get => Results.GetValueOrDefault("resultFileId") as string;
|
||||
set => Results["resultFileId"] = value;
|
||||
get => TypedParameters.ResultFileId;
|
||||
set
|
||||
{
|
||||
var parameters = TypedParameters;
|
||||
parameters.ResultFileId = value;
|
||||
TypedParameters = parameters;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -303,41 +503,70 @@ public class BulkOperationTask : PersistentTask
|
||||
Name = "Bulk Operation";
|
||||
}
|
||||
|
||||
// Convenience properties using typed parameters
|
||||
public BulkOperationParameters TypedParameters
|
||||
{
|
||||
get => ParameterHelper.Typed<BulkOperationParameters>(Parameters)!;
|
||||
set => Parameters = ParameterHelper.Untyped(value);
|
||||
}
|
||||
|
||||
[MaxLength(128)]
|
||||
public string OperationType
|
||||
{
|
||||
get => Parameters.GetValueOrDefault("operationType") as string ?? string.Empty;
|
||||
set => Parameters["operationType"] = value;
|
||||
get => TypedParameters.OperationType;
|
||||
set
|
||||
{
|
||||
var parameters = TypedParameters;
|
||||
parameters.OperationType = value;
|
||||
TypedParameters = parameters;
|
||||
}
|
||||
}
|
||||
|
||||
public List<string> TargetIds
|
||||
{
|
||||
get => Parameters.GetValueOrDefault("targetIds") as List<string> ?? [];
|
||||
set => Parameters["targetIds"] = value;
|
||||
get => TypedParameters.TargetIds;
|
||||
set
|
||||
{
|
||||
var parameters = TypedParameters;
|
||||
parameters.TargetIds = value;
|
||||
TypedParameters = parameters;
|
||||
}
|
||||
}
|
||||
|
||||
[Column(TypeName = "jsonb")]
|
||||
public Dictionary<string, object?> OperationParameters
|
||||
{
|
||||
get => Parameters.GetValueOrDefault("operationParameters") as Dictionary<string, object?> ?? new();
|
||||
set => Parameters["operationParameters"] = value;
|
||||
get => TypedParameters.OperationParameters;
|
||||
set
|
||||
{
|
||||
var parameters = TypedParameters;
|
||||
parameters.OperationParameters = value;
|
||||
TypedParameters = parameters;
|
||||
}
|
||||
}
|
||||
|
||||
public int ItemsProcessed
|
||||
{
|
||||
get => Convert.ToInt32(Parameters.GetValueOrDefault("itemsProcessed") ?? 0);
|
||||
get => TypedParameters.ItemsProcessed;
|
||||
set
|
||||
{
|
||||
Parameters["itemsProcessed"] = value;
|
||||
var parameters = TypedParameters;
|
||||
parameters.ItemsProcessed = value;
|
||||
TypedParameters = parameters;
|
||||
Progress = TargetIds.Count > 0 ? (double)value / TargetIds.Count * 100 : 0;
|
||||
}
|
||||
}
|
||||
|
||||
[Column(TypeName = "jsonb")]
|
||||
public Dictionary<string, object?> OperationResults
|
||||
public Dictionary<string, object?>? OperationResults
|
||||
{
|
||||
get => Results.GetValueOrDefault("operationResults") as Dictionary<string, object?> ?? new();
|
||||
set => Results["operationResults"] = value;
|
||||
get => TypedParameters.OperationResults;
|
||||
set
|
||||
{
|
||||
var parameters = TypedParameters;
|
||||
parameters.OperationResults = value;
|
||||
TypedParameters = parameters;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -350,50 +579,89 @@ public class StorageMigrationTask : PersistentTask
|
||||
Name = "Storage Migration";
|
||||
}
|
||||
|
||||
// Convenience properties using typed parameters
|
||||
public StorageMigrationParameters TypedParameters
|
||||
{
|
||||
get => ParameterHelper.Typed<StorageMigrationParameters>(Parameters)!;
|
||||
set => Parameters = ParameterHelper.Untyped(value);
|
||||
}
|
||||
|
||||
public Guid SourcePoolId
|
||||
{
|
||||
get => Guid.Parse(Parameters.GetValueOrDefault("sourcePoolId") as string ?? Guid.Empty.ToString());
|
||||
set => Parameters["sourcePoolId"] = value.ToString();
|
||||
get => TypedParameters.SourcePoolId;
|
||||
set
|
||||
{
|
||||
var parameters = TypedParameters;
|
||||
parameters.SourcePoolId = value;
|
||||
TypedParameters = parameters;
|
||||
}
|
||||
}
|
||||
|
||||
public Guid TargetPoolId
|
||||
{
|
||||
get => Guid.Parse(Parameters.GetValueOrDefault("targetPoolId") as string ?? Guid.Empty.ToString());
|
||||
set => Parameters["targetPoolId"] = value.ToString();
|
||||
get => TypedParameters.TargetPoolId;
|
||||
set
|
||||
{
|
||||
var parameters = TypedParameters;
|
||||
parameters.TargetPoolId = value;
|
||||
TypedParameters = parameters;
|
||||
}
|
||||
}
|
||||
|
||||
public List<string> FileIds
|
||||
{
|
||||
get => Parameters.GetValueOrDefault("fileIds") as List<string> ?? [];
|
||||
set => Parameters["fileIds"] = value;
|
||||
get => TypedParameters.FileIds;
|
||||
set
|
||||
{
|
||||
var parameters = TypedParameters;
|
||||
parameters.FileIds = value;
|
||||
TypedParameters = parameters;
|
||||
}
|
||||
}
|
||||
|
||||
public bool PreserveOriginals
|
||||
{
|
||||
get => Convert.ToBoolean(Parameters.GetValueOrDefault("preserveOriginals") ?? true);
|
||||
set => Parameters["preserveOriginals"] = value;
|
||||
get => TypedParameters.PreserveOriginals;
|
||||
set
|
||||
{
|
||||
var parameters = TypedParameters;
|
||||
parameters.PreserveOriginals = value;
|
||||
TypedParameters = parameters;
|
||||
}
|
||||
}
|
||||
|
||||
public long TotalBytesToTransfer
|
||||
{
|
||||
get => Convert.ToInt64(Parameters.GetValueOrDefault("totalBytesToTransfer") ?? 0L);
|
||||
set => Parameters["totalBytesToTransfer"] = value;
|
||||
get => TypedParameters.TotalBytesToTransfer;
|
||||
set
|
||||
{
|
||||
var parameters = TypedParameters;
|
||||
parameters.TotalBytesToTransfer = value;
|
||||
TypedParameters = parameters;
|
||||
}
|
||||
}
|
||||
|
||||
public long BytesTransferred
|
||||
{
|
||||
get => Convert.ToInt64(Parameters.GetValueOrDefault("bytesTransferred") ?? 0L);
|
||||
get => TypedParameters.BytesTransferred;
|
||||
set
|
||||
{
|
||||
Parameters["bytesTransferred"] = value;
|
||||
var parameters = TypedParameters;
|
||||
parameters.BytesTransferred = value;
|
||||
TypedParameters = parameters;
|
||||
Progress = TotalBytesToTransfer > 0 ? (double)value / TotalBytesToTransfer * 100 : 0;
|
||||
}
|
||||
}
|
||||
|
||||
public int FilesMigrated
|
||||
{
|
||||
get => Convert.ToInt32(Parameters.GetValueOrDefault("filesMigrated") ?? 0);
|
||||
set => Parameters["filesMigrated"] = value;
|
||||
get => TypedParameters.FilesMigrated;
|
||||
set
|
||||
{
|
||||
var parameters = TypedParameters;
|
||||
parameters.FilesMigrated = value;
|
||||
TypedParameters = parameters;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -404,4 +672,4 @@ public enum UploadTaskStatus
|
||||
Completed = TaskStatus.Completed,
|
||||
Failed = TaskStatus.Failed,
|
||||
Expired = TaskStatus.Expired
|
||||
}
|
||||
}
|
||||
@@ -233,17 +233,17 @@ public class PersistentTaskService(
|
||||
? query.OrderByDescending(t => t.Progress)
|
||||
: query.OrderBy(t => t.Progress);
|
||||
break;
|
||||
case "createdat":
|
||||
case "created":
|
||||
orderedQuery = sortDescending
|
||||
? query.OrderByDescending(t => t.CreatedAt)
|
||||
: query.OrderBy(t => t.CreatedAt);
|
||||
break;
|
||||
case "updatedat":
|
||||
case "updated":
|
||||
orderedQuery = sortDescending
|
||||
? query.OrderByDescending(t => t.UpdatedAt)
|
||||
: query.OrderBy(t => t.UpdatedAt);
|
||||
break;
|
||||
case "lastactivity":
|
||||
case "activity":
|
||||
default:
|
||||
orderedQuery = sortDescending
|
||||
? query.OrderByDescending(t => t.LastActivity)
|
||||
@@ -344,7 +344,7 @@ public class PersistentTaskService(
|
||||
TaskId = task.TaskId,
|
||||
Name = task.Name,
|
||||
Type = task.Type.ToString(),
|
||||
CreatedAt = task.CreatedAt.ToString("O", null)
|
||||
CreatedAt = task.CreatedAt.ToString("%O", null)
|
||||
};
|
||||
|
||||
var packet = new WebSocketPacket
|
||||
@@ -380,7 +380,7 @@ public class PersistentTaskService(
|
||||
Type = task.Type.ToString(),
|
||||
Progress = newProgress,
|
||||
Status = task.Status.ToString(),
|
||||
LastActivity = task.LastActivity.ToString("O", null)
|
||||
LastActivity = task.LastActivity.ToString("%O", null)
|
||||
};
|
||||
|
||||
var packet = new WebSocketPacket
|
||||
@@ -410,7 +410,7 @@ public class PersistentTaskService(
|
||||
TaskId = task.TaskId,
|
||||
Name = task.Name,
|
||||
Type = task.Type.ToString(),
|
||||
CompletedAt = task.CompletedAt?.ToString("O", null) ?? task.UpdatedAt.ToString("O", null),
|
||||
CompletedAt = task.CompletedAt?.ToString("%O", null) ?? task.UpdatedAt.ToString("%O", null),
|
||||
Results = task.Results
|
||||
};
|
||||
|
||||
@@ -458,7 +458,7 @@ public class PersistentTaskService(
|
||||
TaskId = task.TaskId,
|
||||
Name = task.Name,
|
||||
Type = task.Type.ToString(),
|
||||
FailedAt = task.UpdatedAt.ToString("O", null),
|
||||
FailedAt = task.UpdatedAt.ToString("%O", null),
|
||||
ErrorMessage = task.ErrorMessage ?? "Task failed due to an unknown error"
|
||||
};
|
||||
|
||||
@@ -504,6 +504,8 @@ public class PersistentTaskService(
|
||||
private async Task SetCacheAsync(PersistentTask task)
|
||||
{
|
||||
var cacheKey = $"{CacheKeyPrefix}{task.TaskId}";
|
||||
|
||||
// Cache the entire task object directly - this includes all properties including Parameters dictionary
|
||||
await cache.SetAsync(cacheKey, task, CacheDuration);
|
||||
}
|
||||
|
||||
@@ -514,6 +516,475 @@ public class PersistentTaskService(
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Upload-Specific Methods
|
||||
|
||||
/// <summary>
|
||||
/// Gets the first available pool ID, or creates a default one if none exist
|
||||
/// </summary>
|
||||
private async Task<Guid> GetFirstAvailablePoolIdAsync()
|
||||
{
|
||||
// Try to get the first available pool
|
||||
var firstPool = await db.Pools
|
||||
.Where(p => p.PolicyConfig.PublicUsable)
|
||||
.OrderBy(p => p.CreatedAt)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
if (firstPool != null)
|
||||
{
|
||||
return firstPool.Id;
|
||||
}
|
||||
|
||||
// If no pools exist, create a default one
|
||||
logger.LogWarning("No pools found in database. Creating default pool...");
|
||||
|
||||
var defaultPoolId = Guid.NewGuid();
|
||||
var defaultPool = new DysonNetwork.Shared.Models.FilePool
|
||||
{
|
||||
Id = defaultPoolId,
|
||||
Name = "Default Storage Pool",
|
||||
Description = "Automatically created default storage pool",
|
||||
StorageConfig = new DysonNetwork.Shared.Models.RemoteStorageConfig
|
||||
{
|
||||
Region = "auto",
|
||||
Bucket = "solar-network-development",
|
||||
Endpoint = "localhost:9000",
|
||||
SecretId = "littlesheep",
|
||||
SecretKey = "password",
|
||||
EnableSigned = true,
|
||||
EnableSsl = false
|
||||
},
|
||||
BillingConfig = new DysonNetwork.Shared.Models.BillingConfig
|
||||
{
|
||||
CostMultiplier = 1.0
|
||||
},
|
||||
PolicyConfig = new DysonNetwork.Shared.Models.PolicyConfig
|
||||
{
|
||||
EnableFastUpload = true,
|
||||
EnableRecycle = true,
|
||||
PublicUsable = true,
|
||||
AllowEncryption = true,
|
||||
AllowAnonymous = true,
|
||||
AcceptTypes = new List<string> { "*/*" },
|
||||
MaxFileSize = 1024L * 1024 * 1024 * 10, // 10GB
|
||||
RequirePrivilege = 0
|
||||
},
|
||||
IsHidden = false,
|
||||
AccountId = null,
|
||||
CreatedAt = SystemClock.Instance.GetCurrentInstant(),
|
||||
UpdatedAt = SystemClock.Instance.GetCurrentInstant()
|
||||
};
|
||||
|
||||
db.Pools.Add(defaultPool);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
logger.LogInformation("Created default pool with ID: {PoolId}", defaultPoolId);
|
||||
return defaultPoolId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new persistent upload task
|
||||
/// </summary>
|
||||
public async Task<PersistentUploadTask> CreateUploadTaskAsync(
|
||||
string taskId,
|
||||
CreateUploadTaskRequest request,
|
||||
Guid accountId
|
||||
)
|
||||
{
|
||||
var chunkSize = request.ChunkSize ?? 1024 * 1024 * 5; // 5MB default
|
||||
var chunksCount = (int)Math.Ceiling((double)request.FileSize / chunkSize);
|
||||
|
||||
// Use default pool if no pool is specified, or find first available pool
|
||||
var poolId = request.PoolId ?? await GetFirstAvailablePoolIdAsync();
|
||||
|
||||
var uploadTask = new PersistentUploadTask
|
||||
{
|
||||
TaskId = taskId,
|
||||
FileName = request.FileName,
|
||||
FileSize = request.FileSize,
|
||||
ContentType = request.ContentType,
|
||||
ChunkSize = chunkSize,
|
||||
ChunksCount = chunksCount,
|
||||
ChunksUploaded = 0,
|
||||
PoolId = poolId,
|
||||
BundleId = request.BundleId,
|
||||
EncryptPassword = request.EncryptPassword,
|
||||
ExpiredAt = request.ExpiredAt,
|
||||
Hash = request.Hash,
|
||||
AccountId = accountId,
|
||||
Status = TaskStatus.InProgress,
|
||||
UploadedChunks = [],
|
||||
LastActivity = SystemClock.Instance.GetCurrentInstant()
|
||||
};
|
||||
|
||||
db.Tasks.Add(uploadTask);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await SetCacheAsync(uploadTask);
|
||||
await SendTaskCreatedNotificationAsync(uploadTask);
|
||||
return uploadTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets an existing upload task by ID
|
||||
/// </summary>
|
||||
public async Task<PersistentUploadTask?> GetUploadTaskAsync(string taskId)
|
||||
{
|
||||
var cacheKey = $"{CacheKeyPrefix}{taskId}";
|
||||
var cachedTask = await cache.GetAsync<PersistentUploadTask>(cacheKey);
|
||||
if (cachedTask is not null)
|
||||
return cachedTask;
|
||||
|
||||
var task = await db.Tasks
|
||||
.OfType<PersistentUploadTask>()
|
||||
.FirstOrDefaultAsync(t => t.TaskId == taskId && t.Status == TaskStatus.InProgress);
|
||||
|
||||
if (task is not null)
|
||||
await SetCacheAsync(task);
|
||||
|
||||
return task;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates chunk upload progress
|
||||
/// </summary>
|
||||
public async Task UpdateChunkProgressAsync(string taskId, int chunkIndex)
|
||||
{
|
||||
var task = await GetUploadTaskAsync(taskId);
|
||||
if (task is null) return;
|
||||
|
||||
if (!task.UploadedChunks.Contains(chunkIndex))
|
||||
{
|
||||
var previousProgress = task.ChunksCount > 0 ? (double)task.ChunksUploaded / task.ChunksCount * 100 : 0;
|
||||
|
||||
task.UploadedChunks.Add(chunkIndex);
|
||||
task.ChunksUploaded = task.UploadedChunks.Count;
|
||||
task.LastActivity = SystemClock.Instance.GetCurrentInstant();
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
await SetCacheAsync(task);
|
||||
|
||||
// Send real-time progress update
|
||||
var newProgress = task.ChunksCount > 0 ? (double)task.ChunksUploaded / task.ChunksCount * 100 : 0;
|
||||
await SendUploadProgressUpdateAsync(task, newProgress, previousProgress);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a chunk has already been uploaded
|
||||
/// </summary>
|
||||
public async Task<bool> IsChunkUploadedAsync(string taskId, int chunkIndex)
|
||||
{
|
||||
var task = await GetUploadTaskAsync(taskId);
|
||||
return task?.UploadedChunks.Contains(chunkIndex) ?? false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets upload progress as percentage
|
||||
/// </summary>
|
||||
public async Task<double> GetUploadProgressAsync(string taskId)
|
||||
{
|
||||
var task = await GetUploadTaskAsync(taskId);
|
||||
if (task is null || task.ChunksCount == 0) return 0;
|
||||
|
||||
return (double)task.ChunksUploaded / task.ChunksCount * 100;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets user upload tasks with filtering and pagination
|
||||
/// </summary>
|
||||
public async Task<(List<PersistentUploadTask> Items, int TotalCount)> GetUserUploadTasksAsync(
|
||||
Guid accountId,
|
||||
UploadTaskStatus? status = null,
|
||||
string? sortBy = "lastActivity",
|
||||
bool sortDescending = true,
|
||||
int offset = 0,
|
||||
int limit = 50
|
||||
)
|
||||
{
|
||||
var query = db.Tasks.OfType<PersistentUploadTask>().Where(t => t.AccountId == accountId);
|
||||
|
||||
// Apply status filter
|
||||
if (status.HasValue)
|
||||
{
|
||||
query = query.Where(t => t.Status == (TaskStatus)status.Value);
|
||||
}
|
||||
|
||||
// Get total count
|
||||
var totalCount = await query.CountAsync();
|
||||
|
||||
// Apply sorting
|
||||
IOrderedQueryable<PersistentUploadTask> orderedQuery;
|
||||
switch (sortBy?.ToLower())
|
||||
{
|
||||
case "filename":
|
||||
orderedQuery = sortDescending
|
||||
? query.OrderByDescending(t => t.FileName)
|
||||
: query.OrderBy(t => t.FileName);
|
||||
break;
|
||||
case "filesize":
|
||||
orderedQuery = sortDescending
|
||||
? query.OrderByDescending(t => t.FileSize)
|
||||
: query.OrderBy(t => t.FileSize);
|
||||
break;
|
||||
case "createdat":
|
||||
orderedQuery = sortDescending
|
||||
? query.OrderByDescending(t => t.CreatedAt)
|
||||
: query.OrderBy(t => t.CreatedAt);
|
||||
break;
|
||||
case "updatedat":
|
||||
orderedQuery = sortDescending
|
||||
? query.OrderByDescending(t => t.UpdatedAt)
|
||||
: query.OrderBy(t => t.UpdatedAt);
|
||||
break;
|
||||
case "lastactivity":
|
||||
default:
|
||||
orderedQuery = sortDescending
|
||||
? query.OrderByDescending(t => t.LastActivity)
|
||||
: query.OrderBy(t => t.LastActivity);
|
||||
break;
|
||||
}
|
||||
|
||||
// Apply pagination
|
||||
var items = await orderedQuery
|
||||
.Skip(offset)
|
||||
.Take(limit)
|
||||
.ToListAsync();
|
||||
|
||||
return (items, totalCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets upload statistics for a user
|
||||
/// </summary>
|
||||
public async Task<UserUploadStats> GetUserUploadStatsAsync(Guid accountId)
|
||||
{
|
||||
var tasks = await db.Tasks
|
||||
.OfType<PersistentUploadTask>()
|
||||
.Where(t => t.AccountId == accountId)
|
||||
.ToListAsync();
|
||||
|
||||
var stats = new UserUploadStats
|
||||
{
|
||||
TotalTasks = tasks.Count,
|
||||
InProgressTasks = tasks.Count(t => t.Status == Model.TaskStatus.InProgress),
|
||||
CompletedTasks = tasks.Count(t => t.Status == Model.TaskStatus.Completed),
|
||||
FailedTasks = tasks.Count(t => t.Status == Model.TaskStatus.Failed),
|
||||
ExpiredTasks = tasks.Count(t => t.Status == Model.TaskStatus.Expired),
|
||||
TotalUploadedBytes = tasks.Sum(t => (long)t.ChunksUploaded * t.ChunkSize),
|
||||
AverageProgress = tasks.Any(t => t.Status == Model.TaskStatus.InProgress)
|
||||
? tasks.Where(t => t.Status == Model.TaskStatus.InProgress)
|
||||
.Average(t => t.ChunksCount > 0 ? (double)t.ChunksUploaded / t.ChunksCount * 100 : 0)
|
||||
: 0,
|
||||
RecentActivity = tasks.OrderByDescending(t => t.LastActivity)
|
||||
.Take(5)
|
||||
.Select(t => new RecentActivity
|
||||
{
|
||||
TaskId = t.TaskId,
|
||||
FileName = t.FileName,
|
||||
Status = (UploadTaskStatus)t.Status,
|
||||
LastActivity = t.LastActivity,
|
||||
Progress = t.ChunksCount > 0 ? (double)t.ChunksUploaded / t.ChunksCount * 100 : 0
|
||||
})
|
||||
.ToList()
|
||||
};
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cleans up failed tasks for a user
|
||||
/// </summary>
|
||||
public async Task<int> CleanupUserFailedTasksAsync(Guid accountId)
|
||||
{
|
||||
var failedTasks = await db.Tasks
|
||||
.OfType<PersistentUploadTask>()
|
||||
.Where(t => t.AccountId == accountId &&
|
||||
(t.Status == Model.TaskStatus.Failed || t.Status == Model.TaskStatus.Expired))
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var task in failedTasks)
|
||||
{
|
||||
await RemoveCacheAsync(task.TaskId);
|
||||
|
||||
// Clean up temp files
|
||||
var taskPath = Path.Combine(Path.GetTempPath(), "multipart-uploads", task.TaskId);
|
||||
if (Directory.Exists(taskPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Delete(taskPath, true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to cleanup temp files for task {TaskId}", task.TaskId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
db.Tasks.RemoveRange(failedTasks);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return failedTasks.Count;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets recent tasks for a user
|
||||
/// </summary>
|
||||
public async Task<List<PersistentUploadTask>> GetRecentUserTasksAsync(Guid accountId, int limit = 10)
|
||||
{
|
||||
return await db.Tasks
|
||||
.OfType<PersistentUploadTask>()
|
||||
.Where(t => t.AccountId == accountId)
|
||||
.OrderByDescending(t => t.LastActivity)
|
||||
.Take(limit)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends upload completion notification
|
||||
/// </summary>
|
||||
public async Task SendUploadCompletedNotificationAsync(PersistentUploadTask task, string fileId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var completionData = new UploadCompletionData
|
||||
{
|
||||
TaskId = task.TaskId,
|
||||
FileId = fileId,
|
||||
FileName = task.FileName,
|
||||
FileSize = task.FileSize,
|
||||
CompletedAt = SystemClock.Instance.GetCurrentInstant().ToString("%O", null)
|
||||
};
|
||||
|
||||
// Send WebSocket notification
|
||||
var wsPacket = new WebSocketPacket
|
||||
{
|
||||
Type = "upload.completed",
|
||||
Data = Google.Protobuf.ByteString.CopyFromUtf8(System.Text.Json.JsonSerializer.Serialize(completionData))
|
||||
};
|
||||
|
||||
await ringService.PushWebSocketPacketAsync(new PushWebSocketPacketRequest
|
||||
{
|
||||
UserId = task.AccountId.ToString(),
|
||||
Packet = wsPacket
|
||||
});
|
||||
|
||||
// Send push notification
|
||||
var pushNotification = new PushNotification
|
||||
{
|
||||
Topic = "upload",
|
||||
Title = "Upload Completed",
|
||||
Subtitle = task.FileName,
|
||||
Body = $"Your file '{task.FileName}' has been uploaded successfully.",
|
||||
IsSavable = true
|
||||
};
|
||||
|
||||
await ringService.SendPushNotificationToUserAsync(new SendPushNotificationToUserRequest
|
||||
{
|
||||
UserId = task.AccountId.ToString(),
|
||||
Notification = pushNotification
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to send upload completion notification for task {TaskId}", task.TaskId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends upload failure notification
|
||||
/// </summary>
|
||||
public async Task SendUploadFailedNotificationAsync(PersistentUploadTask task, string? errorMessage = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var failureData = new UploadFailureData
|
||||
{
|
||||
TaskId = task.TaskId,
|
||||
FileName = task.FileName,
|
||||
FileSize = task.FileSize,
|
||||
FailedAt = SystemClock.Instance.GetCurrentInstant().ToString("%O", null),
|
||||
ErrorMessage = errorMessage ?? "Upload failed due to an unknown error"
|
||||
};
|
||||
|
||||
// Send WebSocket notification
|
||||
var wsPacket = new WebSocketPacket
|
||||
{
|
||||
Type = "upload.failed",
|
||||
Data = Google.Protobuf.ByteString.CopyFromUtf8(System.Text.Json.JsonSerializer.Serialize(failureData))
|
||||
};
|
||||
|
||||
await ringService.PushWebSocketPacketAsync(new PushWebSocketPacketRequest
|
||||
{
|
||||
UserId = task.AccountId.ToString(),
|
||||
Packet = wsPacket
|
||||
});
|
||||
|
||||
// Send push notification
|
||||
var pushNotification = new PushNotification
|
||||
{
|
||||
Topic = "upload",
|
||||
Title = "Upload Failed",
|
||||
Subtitle = task.FileName,
|
||||
Body = $"Your file '{task.FileName}' upload has failed. You can try again.",
|
||||
IsSavable = true
|
||||
};
|
||||
|
||||
await ringService.SendPushNotificationToUserAsync(new SendPushNotificationToUserRequest
|
||||
{
|
||||
UserId = task.AccountId.ToString(),
|
||||
Notification = pushNotification
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to send upload failure notification for task {TaskId}", task.TaskId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends real-time upload progress update via WebSocket
|
||||
/// </summary>
|
||||
private async Task SendUploadProgressUpdateAsync(PersistentUploadTask task, double newProgress, double previousProgress)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Only send significant progress updates (every 5% or major milestones)
|
||||
if (Math.Abs(newProgress - previousProgress) < 5 && newProgress < 100)
|
||||
return;
|
||||
|
||||
var progressData = new UploadProgressData
|
||||
{
|
||||
TaskId = task.TaskId,
|
||||
FileName = task.FileName,
|
||||
FileSize = task.FileSize,
|
||||
ChunksUploaded = task.ChunksUploaded,
|
||||
ChunksTotal = task.ChunksCount,
|
||||
Progress = newProgress,
|
||||
Status = task.Status.ToString(),
|
||||
LastActivity = task.LastActivity.ToString("%O", null)
|
||||
};
|
||||
|
||||
var packet = new WebSocketPacket
|
||||
{
|
||||
Type = "upload.progress",
|
||||
Data = Google.Protobuf.ByteString.CopyFromUtf8(System.Text.Json.JsonSerializer.Serialize(progressData))
|
||||
};
|
||||
|
||||
await ringService.PushWebSocketPacketAsync(new PushWebSocketPacketRequest
|
||||
{
|
||||
UserId = task.AccountId.ToString(),
|
||||
Packet = packet
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to send upload progress update for task {TaskId}", task.TaskId);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
#region Data Transfer Objects
|
||||
@@ -579,3 +1050,58 @@ public class TaskActivity
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Upload-Specific Data Transfer Objects
|
||||
|
||||
public class UploadProgressData
|
||||
{
|
||||
public string TaskId { get; set; } = null!;
|
||||
public string FileName { get; set; } = null!;
|
||||
public long FileSize { get; set; }
|
||||
public int ChunksUploaded { get; set; }
|
||||
public int ChunksTotal { get; set; }
|
||||
public double Progress { get; set; }
|
||||
public string Status { get; set; } = null!;
|
||||
public string LastActivity { get; set; } = null!;
|
||||
}
|
||||
|
||||
public class UploadCompletionData
|
||||
{
|
||||
public string TaskId { get; set; } = null!;
|
||||
public string FileId { get; set; } = null!;
|
||||
public string FileName { get; set; } = null!;
|
||||
public long FileSize { get; set; }
|
||||
public string CompletedAt { get; set; } = null!;
|
||||
}
|
||||
|
||||
public class UploadFailureData
|
||||
{
|
||||
public string TaskId { get; set; } = null!;
|
||||
public string FileName { get; set; } = null!;
|
||||
public long FileSize { get; set; }
|
||||
public string FailedAt { get; set; } = null!;
|
||||
public string ErrorMessage { get; set; } = null!;
|
||||
}
|
||||
|
||||
public class UserUploadStats
|
||||
{
|
||||
public int TotalTasks { get; set; }
|
||||
public int InProgressTasks { get; set; }
|
||||
public int CompletedTasks { get; set; }
|
||||
public int FailedTasks { get; set; }
|
||||
public int ExpiredTasks { get; set; }
|
||||
public long TotalUploadedBytes { get; set; }
|
||||
public double AverageProgress { get; set; }
|
||||
public List<RecentActivity> RecentActivity { get; set; } = new();
|
||||
}
|
||||
|
||||
public class RecentActivity
|
||||
{
|
||||
public string TaskId { get; set; } = null!;
|
||||
public string FileName { get; set; } = null!;
|
||||
public UploadTaskStatus Status { get; set; }
|
||||
public Instant LastActivity { get; set; }
|
||||
public double Progress { get; set; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -1,567 +0,0 @@
|
||||
using DysonNetwork.Drive.Storage.Model;
|
||||
using DysonNetwork.Shared.Cache;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NodaTime;
|
||||
using System.Text.Json;
|
||||
using TaskStatus = DysonNetwork.Drive.Storage.Model.TaskStatus;
|
||||
|
||||
namespace DysonNetwork.Drive.Storage;
|
||||
|
||||
public class PersistentUploadService(
|
||||
AppDatabase db,
|
||||
ICacheService cache,
|
||||
ILogger<PersistentUploadService> logger,
|
||||
RingService.RingServiceClient ringService
|
||||
)
|
||||
{
|
||||
private const string CacheKeyPrefix = "upload:task:";
|
||||
private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(30);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new persistent upload task
|
||||
/// </summary>
|
||||
public async Task<PersistentUploadTask> CreateUploadTaskAsync(
|
||||
string taskId,
|
||||
CreateUploadTaskRequest request,
|
||||
Guid accountId
|
||||
)
|
||||
{
|
||||
var chunkSize = request.ChunkSize ?? 1024 * 1024 * 5; // 5MB default
|
||||
var chunksCount = (int)Math.Ceiling((double)request.FileSize / chunkSize);
|
||||
|
||||
var uploadTask = new PersistentUploadTask
|
||||
{
|
||||
TaskId = taskId,
|
||||
FileName = request.FileName,
|
||||
FileSize = request.FileSize,
|
||||
ContentType = request.ContentType,
|
||||
ChunkSize = chunkSize,
|
||||
ChunksCount = chunksCount,
|
||||
ChunksUploaded = 0,
|
||||
PoolId = request.PoolId.Value,
|
||||
BundleId = request.BundleId,
|
||||
EncryptPassword = request.EncryptPassword,
|
||||
ExpiredAt = request.ExpiredAt,
|
||||
Hash = request.Hash,
|
||||
AccountId = accountId,
|
||||
Status = Model.TaskStatus.InProgress,
|
||||
UploadedChunks = new List<int>(),
|
||||
LastActivity = SystemClock.Instance.GetCurrentInstant()
|
||||
};
|
||||
|
||||
db.UploadTasks.Add(uploadTask);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await SetCacheAsync(uploadTask);
|
||||
return uploadTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets an existing upload task by ID
|
||||
/// </summary>
|
||||
public async Task<PersistentUploadTask?> GetUploadTaskAsync(string taskId)
|
||||
{
|
||||
var cacheKey = $"{CacheKeyPrefix}{taskId}";
|
||||
var cachedTask = await cache.GetAsync<PersistentUploadTask>(cacheKey);
|
||||
if (cachedTask is not null)
|
||||
return cachedTask;
|
||||
|
||||
var task = await db.Tasks
|
||||
.OfType<PersistentUploadTask>()
|
||||
.FirstOrDefaultAsync(t => t.TaskId == taskId && t.Status == TaskStatus.InProgress);
|
||||
|
||||
if (task is not null)
|
||||
await SetCacheAsync(task);
|
||||
|
||||
return task;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates chunk upload progress
|
||||
/// </summary>
|
||||
public async Task UpdateChunkProgressAsync(string taskId, int chunkIndex)
|
||||
{
|
||||
var task = await GetUploadTaskAsync(taskId);
|
||||
if (task is null) return;
|
||||
|
||||
if (!task.UploadedChunks.Contains(chunkIndex))
|
||||
{
|
||||
var previousProgress = task.ChunksCount > 0 ? (double)task.ChunksUploaded / task.ChunksCount * 100 : 0;
|
||||
|
||||
task.UploadedChunks.Add(chunkIndex);
|
||||
task.ChunksUploaded = task.UploadedChunks.Count;
|
||||
task.LastActivity = SystemClock.Instance.GetCurrentInstant();
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
await SetCacheAsync(task);
|
||||
|
||||
// Send real-time progress update
|
||||
var newProgress = task.ChunksCount > 0 ? (double)task.ChunksUploaded / task.ChunksCount * 100 : 0;
|
||||
await SendUploadProgressUpdateAsync(task, newProgress, previousProgress);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks an upload task as completed
|
||||
/// </summary>
|
||||
public async Task MarkTaskCompletedAsync(string taskId)
|
||||
{
|
||||
var task = await GetUploadTaskAsync(taskId);
|
||||
if (task is null) return;
|
||||
|
||||
task.Status = Model.TaskStatus.Completed;
|
||||
task.LastActivity = SystemClock.Instance.GetCurrentInstant();
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
await RemoveCacheAsync(taskId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks an upload task as failed
|
||||
/// </summary>
|
||||
public async Task MarkTaskFailedAsync(string taskId)
|
||||
{
|
||||
var task = await GetUploadTaskAsync(taskId);
|
||||
if (task is null) return;
|
||||
|
||||
task.Status = Model.TaskStatus.Failed;
|
||||
task.LastActivity = SystemClock.Instance.GetCurrentInstant();
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
await RemoveCacheAsync(taskId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all resumable tasks for an account
|
||||
/// </summary>
|
||||
public async Task<List<PersistentUploadTask>> GetResumableTasksAsync(Guid accountId)
|
||||
{
|
||||
return await db.Tasks
|
||||
.OfType<PersistentUploadTask>()
|
||||
.Where(t => t.AccountId == accountId &&
|
||||
t.Status == Model.TaskStatus.InProgress &&
|
||||
t.LastActivity > SystemClock.Instance.GetCurrentInstant() - Duration.FromHours(24))
|
||||
.OrderByDescending(t => t.LastActivity)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets user tasks with filtering and pagination
|
||||
/// </summary>
|
||||
public async Task<(List<PersistentUploadTask> Items, int TotalCount)> GetUserTasksAsync(
|
||||
Guid accountId,
|
||||
UploadTaskStatus? status = null,
|
||||
string? sortBy = "lastActivity",
|
||||
bool sortDescending = true,
|
||||
int offset = 0,
|
||||
int limit = 50
|
||||
)
|
||||
{
|
||||
var query = db.Tasks.OfType<PersistentUploadTask>().Where(t => t.AccountId == accountId);
|
||||
|
||||
// Apply status filter
|
||||
if (status.HasValue)
|
||||
{
|
||||
query = query.Where(t => t.Status == (TaskStatus)status.Value);
|
||||
}
|
||||
|
||||
// Get total count
|
||||
var totalCount = await query.CountAsync();
|
||||
|
||||
// Apply sorting
|
||||
IOrderedQueryable<PersistentUploadTask> orderedQuery;
|
||||
switch (sortBy?.ToLower())
|
||||
{
|
||||
case "filename":
|
||||
orderedQuery = sortDescending
|
||||
? query.OrderByDescending(t => t.FileName)
|
||||
: query.OrderBy(t => t.FileName);
|
||||
break;
|
||||
case "filesize":
|
||||
orderedQuery = sortDescending
|
||||
? query.OrderByDescending(t => t.FileSize)
|
||||
: query.OrderBy(t => t.FileSize);
|
||||
break;
|
||||
case "createdat":
|
||||
orderedQuery = sortDescending
|
||||
? query.OrderByDescending(t => t.CreatedAt)
|
||||
: query.OrderBy(t => t.CreatedAt);
|
||||
break;
|
||||
case "updatedat":
|
||||
orderedQuery = sortDescending
|
||||
? query.OrderByDescending(t => t.UpdatedAt)
|
||||
: query.OrderBy(t => t.UpdatedAt);
|
||||
break;
|
||||
case "lastactivity":
|
||||
default:
|
||||
orderedQuery = sortDescending
|
||||
? query.OrderByDescending(t => t.LastActivity)
|
||||
: query.OrderBy(t => t.LastActivity);
|
||||
break;
|
||||
}
|
||||
|
||||
// Apply pagination
|
||||
var items = await orderedQuery
|
||||
.Skip(offset)
|
||||
.Take(limit)
|
||||
.ToListAsync();
|
||||
|
||||
return (items, totalCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a chunk has already been uploaded
|
||||
/// </summary>
|
||||
public async Task<bool> IsChunkUploadedAsync(string taskId, int chunkIndex)
|
||||
{
|
||||
var task = await GetUploadTaskAsync(taskId);
|
||||
return task?.UploadedChunks.Contains(chunkIndex) ?? false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cleans up expired/stale upload tasks
|
||||
/// </summary>
|
||||
public async Task CleanupStaleTasksAsync()
|
||||
{
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
var staleThreshold = now - Duration.FromHours(24); // 24 hours
|
||||
|
||||
var staleTasks = await db.Tasks
|
||||
.OfType<PersistentUploadTask>()
|
||||
.Where(t => t.Status == Model.TaskStatus.InProgress &&
|
||||
t.LastActivity < staleThreshold)
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var task in staleTasks)
|
||||
{
|
||||
task.Status = Model.TaskStatus.Expired;
|
||||
await RemoveCacheAsync(task.TaskId);
|
||||
|
||||
// Clean up temp files
|
||||
var taskPath = Path.Combine(Path.GetTempPath(), "multipart-uploads", task.TaskId);
|
||||
if (Directory.Exists(taskPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Delete(taskPath, true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to cleanup temp files for task {TaskId}", task.TaskId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
if (staleTasks.Any())
|
||||
{
|
||||
logger.LogInformation("Cleaned up {Count} stale upload tasks", staleTasks.Count);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets upload progress as percentage
|
||||
/// </summary>
|
||||
public async Task<double> GetUploadProgressAsync(string taskId)
|
||||
{
|
||||
var task = await GetUploadTaskAsync(taskId);
|
||||
if (task is null || task.ChunksCount == 0) return 0;
|
||||
|
||||
return (double)task.ChunksUploaded / task.ChunksCount * 100;
|
||||
}
|
||||
|
||||
private async Task SetCacheAsync(PersistentUploadTask task)
|
||||
{
|
||||
var cacheKey = $"{CacheKeyPrefix}{task.TaskId}";
|
||||
await cache.SetAsync(cacheKey, task, CacheDuration);
|
||||
}
|
||||
|
||||
private async Task RemoveCacheAsync(string taskId)
|
||||
{
|
||||
var cacheKey = $"{CacheKeyPrefix}{taskId}";
|
||||
await cache.RemoveAsync(cacheKey);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets upload statistics for a user
|
||||
/// </summary>
|
||||
public async Task<UserUploadStats> GetUserUploadStatsAsync(Guid accountId)
|
||||
{
|
||||
var tasks = await db.Tasks
|
||||
.OfType<PersistentUploadTask>()
|
||||
.Where(t => t.AccountId == accountId)
|
||||
.ToListAsync();
|
||||
|
||||
var stats = new UserUploadStats
|
||||
{
|
||||
TotalTasks = tasks.Count,
|
||||
InProgressTasks = tasks.Count(t => t.Status == Model.TaskStatus.InProgress),
|
||||
CompletedTasks = tasks.Count(t => t.Status == Model.TaskStatus.Completed),
|
||||
FailedTasks = tasks.Count(t => t.Status == Model.TaskStatus.Failed),
|
||||
ExpiredTasks = tasks.Count(t => t.Status == Model.TaskStatus.Expired),
|
||||
TotalUploadedBytes = tasks.Sum(t => (long)t.ChunksUploaded * t.ChunkSize),
|
||||
AverageProgress = tasks.Any(t => t.Status == Model.TaskStatus.InProgress)
|
||||
? tasks.Where(t => t.Status == Model.TaskStatus.InProgress)
|
||||
.Average(t => t.ChunksCount > 0 ? (double)t.ChunksUploaded / t.ChunksCount * 100 : 0)
|
||||
: 0,
|
||||
RecentActivity = tasks.OrderByDescending(t => t.LastActivity)
|
||||
.Take(5)
|
||||
.Select(t => new RecentActivity
|
||||
{
|
||||
TaskId = t.TaskId,
|
||||
FileName = t.FileName,
|
||||
Status = (UploadTaskStatus)t.Status,
|
||||
LastActivity = t.LastActivity,
|
||||
Progress = t.ChunksCount > 0 ? (double)t.ChunksUploaded / t.ChunksCount * 100 : 0
|
||||
})
|
||||
.ToList()
|
||||
};
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cleans up failed tasks for a user
|
||||
/// </summary>
|
||||
public async Task<int> CleanupUserFailedTasksAsync(Guid accountId)
|
||||
{
|
||||
var failedTasks = await db.Tasks
|
||||
.OfType<PersistentUploadTask>()
|
||||
.Where(t => t.AccountId == accountId &&
|
||||
(t.Status == Model.TaskStatus.Failed || t.Status == Model.TaskStatus.Expired))
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var task in failedTasks)
|
||||
{
|
||||
await RemoveCacheAsync(task.TaskId);
|
||||
|
||||
// Clean up temp files
|
||||
var taskPath = Path.Combine(Path.GetTempPath(), "multipart-uploads", task.TaskId);
|
||||
if (Directory.Exists(taskPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Delete(taskPath, true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to cleanup temp files for task {TaskId}", task.TaskId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
db.Tasks.RemoveRange(failedTasks);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return failedTasks.Count;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets recent tasks for a user
|
||||
/// </summary>
|
||||
public async Task<List<PersistentUploadTask>> GetRecentUserTasksAsync(Guid accountId, int limit = 10)
|
||||
{
|
||||
return await db.Tasks
|
||||
.OfType<PersistentUploadTask>()
|
||||
.Where(t => t.AccountId == accountId)
|
||||
.OrderByDescending(t => t.LastActivity)
|
||||
.Take(limit)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends real-time upload progress update via WebSocket
|
||||
/// </summary>
|
||||
private async Task SendUploadProgressUpdateAsync(PersistentUploadTask task, double newProgress, double previousProgress)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Only send significant progress updates (every 5% or major milestones)
|
||||
if (Math.Abs(newProgress - previousProgress) < 5 && newProgress < 100)
|
||||
return;
|
||||
|
||||
var progressData = new UploadProgressData
|
||||
{
|
||||
TaskId = task.TaskId,
|
||||
FileName = task.FileName,
|
||||
FileSize = task.FileSize,
|
||||
ChunksUploaded = task.ChunksUploaded,
|
||||
ChunksTotal = task.ChunksCount,
|
||||
Progress = newProgress,
|
||||
Status = task.Status.ToString(),
|
||||
LastActivity = task.LastActivity.ToString("O", null)
|
||||
};
|
||||
|
||||
var packet = new WebSocketPacket
|
||||
{
|
||||
Type = "upload.progress",
|
||||
Data = Google.Protobuf.ByteString.CopyFromUtf8(System.Text.Json.JsonSerializer.Serialize(progressData))
|
||||
};
|
||||
|
||||
await ringService.PushWebSocketPacketAsync(new PushWebSocketPacketRequest
|
||||
{
|
||||
UserId = task.AccountId.ToString(),
|
||||
Packet = packet
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to send upload progress update for task {TaskId}", task.TaskId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends upload completion notification
|
||||
/// </summary>
|
||||
public async Task SendUploadCompletedNotificationAsync(PersistentUploadTask task, string fileId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var completionData = new UploadCompletionData
|
||||
{
|
||||
TaskId = task.TaskId,
|
||||
FileId = fileId,
|
||||
FileName = task.FileName,
|
||||
FileSize = task.FileSize,
|
||||
CompletedAt = SystemClock.Instance.GetCurrentInstant().ToString("O", null)
|
||||
};
|
||||
|
||||
// Send WebSocket notification
|
||||
var wsPacket = new WebSocketPacket
|
||||
{
|
||||
Type = "upload.completed",
|
||||
Data = Google.Protobuf.ByteString.CopyFromUtf8(System.Text.Json.JsonSerializer.Serialize(completionData))
|
||||
};
|
||||
|
||||
await ringService.PushWebSocketPacketAsync(new PushWebSocketPacketRequest
|
||||
{
|
||||
UserId = task.AccountId.ToString(),
|
||||
Packet = wsPacket
|
||||
});
|
||||
|
||||
// Send push notification
|
||||
var pushNotification = new PushNotification
|
||||
{
|
||||
Topic = "upload",
|
||||
Title = "Upload Completed",
|
||||
Subtitle = task.FileName,
|
||||
Body = $"Your file '{task.FileName}' has been uploaded successfully.",
|
||||
IsSavable = true
|
||||
};
|
||||
|
||||
await ringService.SendPushNotificationToUserAsync(new SendPushNotificationToUserRequest
|
||||
{
|
||||
UserId = task.AccountId.ToString(),
|
||||
Notification = pushNotification
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to send upload completion notification for task {TaskId}", task.TaskId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends upload failure notification
|
||||
/// </summary>
|
||||
public async Task SendUploadFailedNotificationAsync(PersistentUploadTask task, string? errorMessage = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var failureData = new UploadFailureData
|
||||
{
|
||||
TaskId = task.TaskId,
|
||||
FileName = task.FileName,
|
||||
FileSize = task.FileSize,
|
||||
FailedAt = SystemClock.Instance.GetCurrentInstant().ToString("O", null),
|
||||
ErrorMessage = errorMessage ?? "Upload failed due to an unknown error"
|
||||
};
|
||||
|
||||
// Send WebSocket notification
|
||||
var wsPacket = new WebSocketPacket
|
||||
{
|
||||
Type = "upload.failed",
|
||||
Data = Google.Protobuf.ByteString.CopyFromUtf8(System.Text.Json.JsonSerializer.Serialize(failureData))
|
||||
};
|
||||
|
||||
await ringService.PushWebSocketPacketAsync(new PushWebSocketPacketRequest
|
||||
{
|
||||
UserId = task.AccountId.ToString(),
|
||||
Packet = wsPacket
|
||||
});
|
||||
|
||||
// Send push notification
|
||||
var pushNotification = new PushNotification
|
||||
{
|
||||
Topic = "upload",
|
||||
Title = "Upload Failed",
|
||||
Subtitle = task.FileName,
|
||||
Body = $"Your file '{task.FileName}' upload has failed. You can try again.",
|
||||
IsSavable = true
|
||||
};
|
||||
|
||||
await ringService.SendPushNotificationToUserAsync(new SendPushNotificationToUserRequest
|
||||
{
|
||||
UserId = task.AccountId.ToString(),
|
||||
Notification = pushNotification
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to send upload failure notification for task {TaskId}", task.TaskId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class UploadProgressData
|
||||
{
|
||||
public string TaskId { get; set; } = null!;
|
||||
public string FileName { get; set; } = null!;
|
||||
public long FileSize { get; set; }
|
||||
public int ChunksUploaded { get; set; }
|
||||
public int ChunksTotal { get; set; }
|
||||
public double Progress { get; set; }
|
||||
public string Status { get; set; } = null!;
|
||||
public string LastActivity { get; set; } = null!;
|
||||
}
|
||||
|
||||
public class UploadCompletionData
|
||||
{
|
||||
public string TaskId { get; set; } = null!;
|
||||
public string FileId { get; set; } = null!;
|
||||
public string FileName { get; set; } = null!;
|
||||
public long FileSize { get; set; }
|
||||
public string CompletedAt { get; set; } = null!;
|
||||
}
|
||||
|
||||
public class UploadFailureData
|
||||
{
|
||||
public string TaskId { get; set; } = null!;
|
||||
public string FileName { get; set; } = null!;
|
||||
public long FileSize { get; set; }
|
||||
public string FailedAt { get; set; } = null!;
|
||||
public string ErrorMessage { get; set; } = null!;
|
||||
}
|
||||
|
||||
public class UserUploadStats
|
||||
{
|
||||
public int TotalTasks { get; set; }
|
||||
public int InProgressTasks { get; set; }
|
||||
public int CompletedTasks { get; set; }
|
||||
public int FailedTasks { get; set; }
|
||||
public int ExpiredTasks { get; set; }
|
||||
public long TotalUploadedBytes { get; set; }
|
||||
public double AverageProgress { get; set; }
|
||||
public List<RecentActivity> RecentActivity { get; set; } = new();
|
||||
}
|
||||
|
||||
public class RecentActivity
|
||||
{
|
||||
public string TaskId { get; set; } = null!;
|
||||
public string FileName { get; set; } = null!;
|
||||
public UploadTaskStatus Status { get; set; }
|
||||
public Instant LastActivity { get; set; }
|
||||
public double Progress { get; set; }
|
||||
}
|
||||
@@ -32,7 +32,7 @@
|
||||
},
|
||||
"Storage": {
|
||||
"Uploads": "Uploads",
|
||||
"PreferredRemote": "2adceae3-981a-4564-9b8d-5d71a211c873",
|
||||
"PreferredRemote": "c53136a6-9152-4ecb-9f88-43c41438c23e",
|
||||
"Remote": [
|
||||
{
|
||||
"Id": "minio",
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Globalization;
|
||||
using DysonNetwork.Pass.Auth.OpenId;
|
||||
using DysonNetwork.Pass.Localization;
|
||||
using DysonNetwork.Pass.Mailer;
|
||||
using DysonNetwork.Pass.Resources.Emails;
|
||||
using DysonNetwork.Shared.Cache;
|
||||
using DysonNetwork.Shared.Models;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
@@ -447,10 +448,10 @@ public class AccountService(
|
||||
}
|
||||
|
||||
await mailer
|
||||
.SendTemplatedEmailAsync<Emails.VerificationEmail, VerificationEmailModel>(
|
||||
.SendTemplatedEmailAsync<FactorCodeEmail, VerificationEmailModel>(
|
||||
account.Nick,
|
||||
contact.Content,
|
||||
emailLocalizer["EmailCodeTitle"],
|
||||
emailLocalizer["CodeEmailTitle"],
|
||||
new VerificationEmailModel
|
||||
{
|
||||
Name = account.Name,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using DysonNetwork.Pass.Emails;
|
||||
using DysonNetwork.Pass.Mailer;
|
||||
using DysonNetwork.Pass.Resources.Emails;
|
||||
using DysonNetwork.Shared.Cache;
|
||||
using DysonNetwork.Shared.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -94,10 +94,10 @@ public class MagicSpellService(
|
||||
switch (spell.Type)
|
||||
{
|
||||
case MagicSpellType.AccountActivation:
|
||||
await email.SendTemplatedEmailAsync<LandingEmail, LandingEmailModel>(
|
||||
await email.SendTemplatedEmailAsync<RegistrationConfirmEmail, LandingEmailModel>(
|
||||
contact.Account.Nick,
|
||||
contact.Content,
|
||||
localizer["EmailLandingTitle"],
|
||||
localizer["RegConfirmTitle"],
|
||||
new LandingEmailModel
|
||||
{
|
||||
Name = contact.Account.Name,
|
||||
@@ -109,7 +109,7 @@ public class MagicSpellService(
|
||||
await email.SendTemplatedEmailAsync<AccountDeletionEmail, AccountDeletionEmailModel>(
|
||||
contact.Account.Nick,
|
||||
contact.Content,
|
||||
localizer["EmailAccountDeletionTitle"],
|
||||
localizer["AccountDeletionTitle"],
|
||||
new AccountDeletionEmailModel
|
||||
{
|
||||
Name = contact.Account.Name,
|
||||
@@ -121,7 +121,7 @@ public class MagicSpellService(
|
||||
await email.SendTemplatedEmailAsync<PasswordResetEmail, PasswordResetEmailModel>(
|
||||
contact.Account.Nick,
|
||||
contact.Content,
|
||||
localizer["EmailPasswordResetTitle"],
|
||||
localizer["PasswordResetTitle"],
|
||||
new PasswordResetEmailModel
|
||||
{
|
||||
Name = contact.Account.Name,
|
||||
@@ -135,7 +135,7 @@ public class MagicSpellService(
|
||||
await email.SendTemplatedEmailAsync<ContactVerificationEmail, ContactVerificationEmailModel>(
|
||||
contact.Account.Nick,
|
||||
contactMethod!,
|
||||
localizer["EmailContactVerificationTitle"],
|
||||
localizer["ContractVerificationTitle"],
|
||||
new ContactVerificationEmailModel
|
||||
{
|
||||
Name = contact.Account.Name,
|
||||
|
||||
@@ -131,4 +131,21 @@
|
||||
<LastGenOutput>SharedResource.Designer.cs</LastGenOutput>
|
||||
</EmbeddedResource>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<_ContentIncludedByDefault Remove="Emails\AccountDeletionEmail.razor" />
|
||||
<_ContentIncludedByDefault Remove="Emails\ContactVerificationEmail.razor" />
|
||||
<_ContentIncludedByDefault Remove="Emails\EmailLayout.razor" />
|
||||
<_ContentIncludedByDefault Remove="Emails\FactorCodeEmail.razor" />
|
||||
<_ContentIncludedByDefault Remove="Emails\PasswordResetEmail.razor" />
|
||||
<_ContentIncludedByDefault Remove="Emails\RegistrationConfirmEmail.razor" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<AdditionalFiles Include="Resources\Emails\AccountDeletionEmail.razor" />
|
||||
<AdditionalFiles Include="Resources\Emails\ContactVerificationEmail.razor" />
|
||||
<AdditionalFiles Include="Resources\Emails\EmailLayout.razor" />
|
||||
<AdditionalFiles Include="Resources\Emails\PasswordResetEmail.razor" />
|
||||
<AdditionalFiles Include="Resources\Emails\RegistrationConfirmEmail.razor" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
@using DysonNetwork.Pass.Localization
|
||||
@using Microsoft.Extensions.Localization
|
||||
|
||||
<EmailLayout>
|
||||
<tr>
|
||||
<td class="wrapper">
|
||||
<p class="font-bold">@(Localizer["AccountDeletionHeader"])</p>
|
||||
<p>@(Localizer["AccountDeletionPara1"]) @@@Name,</p>
|
||||
<p>@(Localizer["AccountDeletionPara2"])</p>
|
||||
<p>@(Localizer["AccountDeletionPara3"])</p>
|
||||
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="btn btn-primary">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left">
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="@Link" target="_blank">
|
||||
@(Localizer["AccountDeletionButton"])
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p>@(Localizer["AccountDeletionPara4"])</p>
|
||||
</td>
|
||||
</tr>
|
||||
</EmailLayout>
|
||||
|
||||
@code {
|
||||
[Parameter] public required string Name { get; set; }
|
||||
[Parameter] public required string Link { get; set; }
|
||||
|
||||
[Inject] IStringLocalizer<EmailResource> Localizer { get; set; } = null!;
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
@using DysonNetwork.Pass.Localization
|
||||
@using Microsoft.Extensions.Localization
|
||||
@using EmailResource = DysonNetwork.Pass.Localization.EmailResource
|
||||
|
||||
<EmailLayout>
|
||||
<tr>
|
||||
<td class="wrapper">
|
||||
<p class="font-bold">@(Localizer["ContactVerificationHeader"])</p>
|
||||
<p>@(Localizer["ContactVerificationPara1"]) @Name,</p>
|
||||
<p>@(Localizer["ContactVerificationPara2"])</p>
|
||||
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="btn btn-primary">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left">
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="@Link" target="_blank">
|
||||
@(Localizer["ContactVerificationButton"])
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p>@(Localizer["ContactVerificationPara3"])</p>
|
||||
<p>@(Localizer["ContactVerificationPara4"])</p>
|
||||
</td>
|
||||
</tr>
|
||||
</EmailLayout>
|
||||
|
||||
@code {
|
||||
[Parameter] public required string Name { get; set; }
|
||||
[Parameter] public required string Link { get; set; }
|
||||
|
||||
[Inject] IStringLocalizer<EmailResource> Localizer { get; set; } = null!;
|
||||
}
|
||||
@@ -1,337 +0,0 @@
|
||||
@inherits LayoutComponentBase
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||
<style media="all" type="text/css">
|
||||
body {
|
||||
font-family: Helvetica, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
font-size: 16px;
|
||||
line-height: 1.3;
|
||||
-ms-text-size-adjust: 100%;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: separate;
|
||||
mso-table-lspace: 0pt;
|
||||
mso-table-rspace: 0pt;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
table td {
|
||||
font-family: Helvetica, sans-serif;
|
||||
font-size: 16px;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #f4f5f6;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.body {
|
||||
background-color: #f4f5f6;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.container {
|
||||
margin: 0 auto !important;
|
||||
max-width: 600px;
|
||||
padding: 0;
|
||||
padding-top: 24px;
|
||||
width: 600px;
|
||||
}
|
||||
|
||||
.content {
|
||||
box-sizing: border-box;
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
max-width: 600px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.main {
|
||||
background: #ffffff;
|
||||
border: 1px solid #eaebed;
|
||||
border-radius: 16px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
box-sizing: border-box;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
clear: both;
|
||||
padding-top: 24px;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.footer td,
|
||||
.footer p,
|
||||
.footer span,
|
||||
.footer a {
|
||||
color: #9a9ea6;
|
||||
font-size: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
p {
|
||||
font-family: Helvetica, sans-serif;
|
||||
font-size: 16px;
|
||||
font-weight: normal;
|
||||
margin: 0;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #0867ec;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.btn {
|
||||
box-sizing: border-box;
|
||||
min-width: 100% !important;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.btn > tbody > tr > td {
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
.btn table {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.btn table td {
|
||||
background-color: #ffffff;
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.btn a {
|
||||
background-color: #ffffff;
|
||||
border: solid 2px #0867ec;
|
||||
border-radius: 4px;
|
||||
box-sizing: border-box;
|
||||
color: #0867ec;
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
margin: 0;
|
||||
padding: 12px 24px;
|
||||
text-decoration: none;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.btn-primary table td {
|
||||
background-color: #0867ec;
|
||||
}
|
||||
|
||||
.btn-primary a {
|
||||
background-color: #0867ec;
|
||||
border-color: #0867ec;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.font-bold {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.verification-code
|
||||
{
|
||||
font-family: "Courier New", Courier, monospace;
|
||||
font-size: 24px;
|
||||
letter-spacing: 0.5em;
|
||||
}
|
||||
|
||||
@@media all {
|
||||
.btn-primary table td:hover {
|
||||
background-color: #ec0867 !important;
|
||||
}
|
||||
|
||||
.btn-primary a:hover {
|
||||
background-color: #ec0867 !important;
|
||||
border-color: #ec0867 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.last {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.first {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.align-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.align-right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.align-left {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.text-link {
|
||||
color: #0867ec !important;
|
||||
text-decoration: underline !important;
|
||||
}
|
||||
|
||||
.clear {
|
||||
clear: both;
|
||||
}
|
||||
|
||||
.mt0 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.mb0 {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.preheader {
|
||||
color: transparent;
|
||||
display: none;
|
||||
height: 0;
|
||||
max-height: 0;
|
||||
max-width: 0;
|
||||
opacity: 0;
|
||||
overflow: hidden;
|
||||
mso-hide: all;
|
||||
visibility: hidden;
|
||||
width: 0;
|
||||
}
|
||||
|
||||
.powered-by a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@@media only screen and (max-width: 640px) {
|
||||
.main p,
|
||||
.main td,
|
||||
.main span {
|
||||
font-size: 16px !important;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
padding: 8px !important;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.container {
|
||||
padding: 0 !important;
|
||||
padding-top: 8px !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.main {
|
||||
border-left-width: 0 !important;
|
||||
border-radius: 0 !important;
|
||||
border-right-width: 0 !important;
|
||||
}
|
||||
|
||||
.btn table {
|
||||
max-width: 100% !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.btn a {
|
||||
font-size: 16px !important;
|
||||
max-width: 100% !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@media all {
|
||||
.ExternalClass {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ExternalClass,
|
||||
.ExternalClass p,
|
||||
.ExternalClass span,
|
||||
.ExternalClass font,
|
||||
.ExternalClass td,
|
||||
.ExternalClass div {
|
||||
line-height: 100%;
|
||||
}
|
||||
|
||||
.apple-link a {
|
||||
color: inherit !important;
|
||||
font-family: inherit !important;
|
||||
font-size: inherit !important;
|
||||
font-weight: inherit !important;
|
||||
line-height: inherit !important;
|
||||
text-decoration: none !important;
|
||||
}
|
||||
|
||||
#MessageViewBody a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
font-size: inherit;
|
||||
font-family: inherit;
|
||||
font-weight: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="body">
|
||||
<tr>
|
||||
<td> </td>
|
||||
<td class="container">
|
||||
<div class="content">
|
||||
|
||||
<!-- START CENTERED WHITE CONTAINER -->
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="main">
|
||||
<!-- START MAIN CONTENT AREA -->
|
||||
@ChildContent
|
||||
<!-- END MAIN CONTENT AREA -->
|
||||
</table>
|
||||
|
||||
<!-- START FOOTER -->
|
||||
<div class="footer">
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td class="content-block">
|
||||
<span class="apple-link">Solar Network</span>
|
||||
<br> Solsynth LLC © @(DateTime.Now.Year)
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="content-block powered-by">
|
||||
Powered by <a href="https://github.com/solsynth/dysonnetwork">Dyson Network</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- END FOOTER -->
|
||||
|
||||
<!-- END CENTERED WHITE CONTAINER --></div>
|
||||
</td>
|
||||
<td> </td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@code {
|
||||
[Parameter] public RenderFragment? ChildContent { get; set; }
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
@using DysonNetwork.Pass.Localization
|
||||
@using Microsoft.Extensions.Localization
|
||||
@using EmailResource = DysonNetwork.Pass.Localization.EmailResource
|
||||
|
||||
<EmailLayout>
|
||||
<tr>
|
||||
<td class="wrapper">
|
||||
<p class="font-bold">@(Localizer["LandingHeader1"])</p>
|
||||
<p>@(Localizer["LandingPara1"]) @@@Name,</p>
|
||||
<p>@(Localizer["LandingPara2"])</p>
|
||||
<p>@(Localizer["LandingPara3"])</p>
|
||||
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="btn btn-primary">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left">
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="@Link" target="_blank">
|
||||
@(Localizer["LandingButton1"])
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p>@(Localizer["LandingPara4"])</p>
|
||||
</td>
|
||||
</tr>
|
||||
</EmailLayout>
|
||||
|
||||
@code {
|
||||
[Parameter] public required string Name { get; set; }
|
||||
[Parameter] public required string Link { get; set; }
|
||||
|
||||
[Inject] IStringLocalizer<EmailResource> Localizer { get; set; } = null!;
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
@using DysonNetwork.Pass.Localization
|
||||
@using Microsoft.Extensions.Localization
|
||||
@using EmailResource = DysonNetwork.Pass.Localization.EmailResource
|
||||
|
||||
<EmailLayout>
|
||||
<tr>
|
||||
<td class="wrapper">
|
||||
<p class="font-bold">@(Localizer["PasswordResetHeader"])</p>
|
||||
<p>@(Localizer["PasswordResetPara1"]) @@@Name,</p>
|
||||
<p>@(Localizer["PasswordResetPara2"])</p>
|
||||
<p>@(Localizer["PasswordResetPara3"])</p>
|
||||
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="btn btn-primary">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left">
|
||||
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="@Link" target="_blank">
|
||||
@(Localizer["PasswordResetButton"])
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p>@(Localizer["PasswordResetPara4"])</p>
|
||||
</td>
|
||||
</tr>
|
||||
</EmailLayout>
|
||||
|
||||
@code {
|
||||
[Parameter] public required string Name { get; set; }
|
||||
[Parameter] public required string Link { get; set; }
|
||||
|
||||
[Inject] IStringLocalizer<EmailResource> Localizer { get; set; } = null!;
|
||||
[Inject] IStringLocalizer<SharedResource> LocalizerShared { get; set; } = null!;
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
@using DysonNetwork.Pass.Localization
|
||||
@using Microsoft.Extensions.Localization
|
||||
@using EmailResource = DysonNetwork.Pass.Localization.EmailResource
|
||||
|
||||
<EmailLayout>
|
||||
<tr>
|
||||
<td class="wrapper">
|
||||
<p class="font-bold">@(Localizer["CodeHeader1"])</p>
|
||||
<p>@(Localizer["CodePara1"]) @@@Name,</p>
|
||||
<p>@(Localizer["CodePara2"])</p>
|
||||
<p>@(Localizer["CodePara3"])</p>
|
||||
|
||||
<p class="verification-code">@Code</p>
|
||||
|
||||
<p>@(Localizer["CodePara4"])</p>
|
||||
<p>@(Localizer["CodePara5"])</p>
|
||||
</td>
|
||||
</tr>
|
||||
</EmailLayout>
|
||||
|
||||
@code {
|
||||
[Parameter] public required string Name { get; set; }
|
||||
[Parameter] public required string Code { get; set; }
|
||||
|
||||
[Inject] IStringLocalizer<EmailResource> Localizer { get; set; } = null!;
|
||||
[Inject] IStringLocalizer<SharedResource> LocalizerShared { get; set; } = null!;
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: npm
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: daily
|
||||
open-pull-requests-limit: 10
|
||||
1
DysonNetwork.Pass/Mailart/.gitignore
vendored
1
DysonNetwork.Pass/Mailart/.gitignore
vendored
@@ -1,3 +1,4 @@
|
||||
.idea
|
||||
.vscode
|
||||
node_modules
|
||||
build_production
|
||||
|
||||
86
DysonNetwork.Pass/Mailart/emails/confirm-alt.html
Normal file
86
DysonNetwork.Pass/Mailart/emails/confirm-alt.html
Normal file
@@ -0,0 +1,86 @@
|
||||
---
|
||||
bodyClass: bg-slate-50
|
||||
preheader: Registeration Confirmation
|
||||
---
|
||||
|
||||
<x-main>
|
||||
<div class="bg-slate-50 sm:px-4 font-inter">
|
||||
<table align="center" class="m-0 mx-auto">
|
||||
<tr>
|
||||
<td class="w-[552px] max-w-full">
|
||||
<x-spacer height="24px" />
|
||||
|
||||
<table class="w-full">
|
||||
<tr>
|
||||
<td
|
||||
class="py-6 px-9 sm:p-6 bg-white [border:1px_solid_theme(colors.slate.200)] rounded-lg"
|
||||
>
|
||||
<a href="https://solian.app">
|
||||
<img
|
||||
src="https://solian.app/favicon.png"
|
||||
src-production="https://solian.app/favicon.png"
|
||||
width="70"
|
||||
alt="Solar Network Logo"
|
||||
/>
|
||||
</a>
|
||||
|
||||
<x-spacer height="24px" />
|
||||
|
||||
<h1 class="m-0 mb-6 text-2xl/8 text-slate-900 font-semibold">
|
||||
Dear, {{ Name }}
|
||||
</h1>
|
||||
|
||||
<p class="m-0 mb-6 text-base/6 text-slate-600">
|
||||
We're happy to have you joining our community! Please confirm
|
||||
your registeration in order to activate your account to unlock
|
||||
all the features available:
|
||||
</p>
|
||||
|
||||
<x-button
|
||||
href="{{ URL }}"
|
||||
class="bg-slate-950 hover:bg-slate-800"
|
||||
>
|
||||
Confirm
|
||||
</x-button>
|
||||
|
||||
<x-spacer height="24px" />
|
||||
|
||||
<p class="m-0 text-base/6 text-slate-600">
|
||||
Thanks,
|
||||
<br />
|
||||
<span class="font-semibold">Solar Network Team</span>
|
||||
</p>
|
||||
|
||||
<x-divider />
|
||||
|
||||
<p class="m-0 text-xs/5 text-slate-600 mso-break-all mb-4">
|
||||
The link will expires in 24 hours, if you didn't request
|
||||
this code, you can safety ignore this email.
|
||||
</p>
|
||||
|
||||
<p class="m-0 text-xs/5 text-slate-600 mso-break-all">
|
||||
If you're having trouble clicking the "Confirm" button, copy
|
||||
and paste the following URL into your web browser:
|
||||
<a href="{{ URL }}" class="text-slate-800 underline"
|
||||
>{{ URL }}</a
|
||||
>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<table class="w-full">
|
||||
<tr>
|
||||
<td class="py-6 px-9 sm:px-6">
|
||||
<p class="m-0 text-xs text-slate-500">
|
||||
© {{ new Date().getFullYear() }} Solsynth LLC. All rights
|
||||
reserved.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</x-main>
|
||||
@@ -27,7 +27,7 @@ preheader: Registeration Confirmation
|
||||
<x-spacer height="24px" />
|
||||
|
||||
<h1 class="m-0 mb-6 text-2xl/8 text-slate-900 font-semibold">
|
||||
Hello there!
|
||||
Dear, {{ Name }}
|
||||
</h1>
|
||||
|
||||
<p class="m-0 mb-6 text-base/6 text-slate-600">
|
||||
74
DysonNetwork.Pass/Mailart/emails/factor-code.html
Normal file
74
DysonNetwork.Pass/Mailart/emails/factor-code.html
Normal file
@@ -0,0 +1,74 @@
|
||||
---
|
||||
bodyClass: bg-slate-50
|
||||
preheader: Registeration Confirmation
|
||||
---
|
||||
|
||||
<x-main>
|
||||
<div class="bg-slate-50 sm:px-4 font-inter">
|
||||
<table align="center" class="m-0 mx-auto">
|
||||
<tr>
|
||||
<td class="w-[552px] max-w-full">
|
||||
<x-spacer height="24px" />
|
||||
|
||||
<table class="w-full">
|
||||
<tr>
|
||||
<td
|
||||
class="py-6 px-9 sm:p-6 bg-white [border:1px_solid_theme(colors.slate.200)] rounded-lg"
|
||||
>
|
||||
<a href="https://solian.app">
|
||||
<img
|
||||
src="https://solian.app/favicon.png"
|
||||
src-production="https://solian.app/favicon.png"
|
||||
width="70"
|
||||
alt="Solar Network Logo"
|
||||
/>
|
||||
</a>
|
||||
|
||||
<x-spacer height="24px" />
|
||||
|
||||
<h1 class="m-0 mb-6 text-2xl/8 text-slate-900 font-semibold">
|
||||
Dear, {{ Name }}
|
||||
</h1>
|
||||
|
||||
<p class="m-0 mb-6 text-base/6 text-slate-600">
|
||||
Someone trying to use email auth factor to authorize an access
|
||||
request. If that is you, enter the code below to continue.
|
||||
</p>
|
||||
|
||||
<p class="m-0 text-3xl text-base/6 font-mono text-slate-600 font-bold tracking-2">
|
||||
000000
|
||||
</p>
|
||||
|
||||
<x-spacer height="24px" />
|
||||
|
||||
<p class="m-0 text-base/6 text-slate-600">
|
||||
Thanks,
|
||||
<br />
|
||||
<span class="font-semibold">Solar Network Team</span>
|
||||
</p>
|
||||
|
||||
<x-divider />
|
||||
|
||||
<p class="m-0 text-xs/5 text-slate-600 mso-break-all">
|
||||
The code will expires in 30 minutes, if you didn't request
|
||||
this code, you can safety ignore this email.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<table class="w-full">
|
||||
<tr>
|
||||
<td class="py-6 px-9 sm:px-6">
|
||||
<p class="m-0 text-xs text-slate-500">
|
||||
© {{ new Date().getFullYear() }} Solsynth LLC. All rights
|
||||
reserved.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</x-main>
|
||||
112
DysonNetwork.Pass/Resources/Emails/AccountDeletionEmail.razor
Normal file
112
DysonNetwork.Pass/Resources/Emails/AccountDeletionEmail.razor
Normal file
@@ -0,0 +1,112 @@
|
||||
@using DysonNetwork.Pass.Localization
|
||||
@using Microsoft.Extensions.Localization
|
||||
|
||||
<EmailLayout>
|
||||
<div style="display: none">
|
||||
@Localizer["AccountDeletionHeader"]
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
</div>
|
||||
<div role="article" aria-roledescription="email" aria-label lang="en">
|
||||
<div class="sm-px-4"
|
||||
style="background-color: #f8fafc; font-family: Inter, ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif">
|
||||
<table align="center" style="margin: 0 auto" cellpadding="0" cellspacing="0" role="none">
|
||||
<tr>
|
||||
<td style="width: 552px; max-width: 100%">
|
||||
<div role="separator" style="line-height: 24px">‍</div>
|
||||
<table style="width: 100%" cellpadding="0" cellspacing="0" role="none">
|
||||
<tr>
|
||||
<td class="sm-p-6"
|
||||
style="border-radius: 8px; background-color: #fffffe; padding: 24px 36px; border: 1px solid #e2e8f0">
|
||||
<a href="https://solian.app">
|
||||
<img src="https://solian.app/favicon.png" width="70" alt="Solar Network Logo"
|
||||
style="max-width: 100%; vertical-align: middle">
|
||||
</a>
|
||||
<div role="separator" style="line-height: 24px">‍</div>
|
||||
<h1 style="margin: 0 0 24px; font-size: 24px; line-height: 32px; font-weight: 600; color: #0f172a">
|
||||
@Localizer["UsernameFormat", Name]
|
||||
</h1>
|
||||
<p style="margin: 0 0 24px; font-size: 16px; line-height: 24px; color: #475569">
|
||||
@Localizer["AccountDeletionBody"]
|
||||
</p>
|
||||
<div>
|
||||
<a href="@Link"
|
||||
style="display: inline-block; text-decoration: none; padding: 16px 24px; font-size: 16px; line-height: 1; border-radius: 4px; color: #fffffe; background-color: #020617"
|
||||
class="hover-bg-slate-800">
|
||||
<!--[if mso]><i style="mso-font-width: 150%; mso-text-raise: 31px" hidden> </i><![endif]-->
|
||||
<span style="mso-text-raise: 16px">@Localizer["AccountDeletionButton"]</span>
|
||||
<!--[if mso]><i hidden style="mso-font-width: 150%"> ​</i><![endif]-->
|
||||
</a>
|
||||
</div>
|
||||
<div role="separator" style="line-height: 24px">‍</div>
|
||||
<p style="margin: 0; font-size: 16px; line-height: 24px; color: #475569">
|
||||
Thanks,
|
||||
<br>
|
||||
<span style="font-weight: 600">Solar Network Team</span>
|
||||
</p>
|
||||
<div role="separator"
|
||||
style="height: 1px; line-height: 1px; background-color: #cbd5e1; margin-top: 24px; margin-bottom: 24px">
|
||||
‍
|
||||
</div>
|
||||
<p class="mso-break-all" style="margin: 0 0 16px; font-size: 12px; line-height: 20px; color: #475569">
|
||||
@Localizer["AccountDeletionHint"]
|
||||
</p>
|
||||
<p class="mso-break-all"
|
||||
style="margin: 0; font-size: 12px; line-height: 20px; color: #475569">
|
||||
@Localizer["AlternativeLinkHint"]
|
||||
<a href="@Link" style="color: #1e293b; text-decoration: underline">@Link</a>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<table style="width: 100%" cellpadding="0" cellspacing="0" role="none">
|
||||
<tr>
|
||||
<td class="sm-px-6" style="padding: 24px 36px">
|
||||
<p style="margin: 0; font-size: 12px; color: #64748b">
|
||||
© 2025 Solsynth LLC. All rights
|
||||
reserved.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</EmailLayout>
|
||||
|
||||
@code {
|
||||
[Parameter] public required string Name { get; set; }
|
||||
[Parameter] public required string Link { get; set; }
|
||||
|
||||
[Inject] IStringLocalizer<EmailResource> Localizer { get; set; } = null!;
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
@using DysonNetwork.Pass.Localization
|
||||
@using Microsoft.Extensions.Localization
|
||||
@using EmailResource = DysonNetwork.Pass.Localization.EmailResource
|
||||
|
||||
<EmailLayout>
|
||||
<div style="display: none">
|
||||
@Localizer["ContactVerificationHeader"]
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
</div>
|
||||
<div role="article" aria-roledescription="email" aria-label lang="en">
|
||||
<div class="sm-px-4"
|
||||
style="background-color: #f8fafc; font-family: Inter, ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif">
|
||||
<table align="center" style="margin: 0 auto" cellpadding="0" cellspacing="0" role="none">
|
||||
<tr>
|
||||
<td style="width: 552px; max-width: 100%">
|
||||
<div role="separator" style="line-height: 24px">‍</div>
|
||||
<table style="width: 100%" cellpadding="0" cellspacing="0" role="none">
|
||||
<tr>
|
||||
<td class="sm-p-6"
|
||||
style="border-radius: 8px; background-color: #fffffe; padding: 24px 36px; border: 1px solid #e2e8f0">
|
||||
<a href="https://solian.app">
|
||||
<img src="https://solian.app/favicon.png" width="70" alt="Solar Network Logo"
|
||||
style="max-width: 100%; vertical-align: middle">
|
||||
</a>
|
||||
<div role="separator" style="line-height: 24px">‍</div>
|
||||
<h1 style="margin: 0 0 24px; font-size: 24px; line-height: 32px; font-weight: 600; color: #0f172a">
|
||||
@Localizer["UsernameFormat", Name]
|
||||
</h1>
|
||||
<p style="margin: 0 0 24px; font-size: 16px; line-height: 24px; color: #475569">
|
||||
@Localizer["ContactVerificationBody"]
|
||||
</p>
|
||||
<div>
|
||||
<a href="@Link"
|
||||
style="display: inline-block; text-decoration: none; padding: 16px 24px; font-size: 16px; line-height: 1; border-radius: 4px; color: #fffffe; background-color: #020617"
|
||||
class="hover-bg-slate-800">
|
||||
<!--[if mso]><i style="mso-font-width: 150%; mso-text-raise: 31px" hidden> </i><![endif]-->
|
||||
<span style="mso-text-raise: 16px">@Localizer["ContactVerificationButton"]</span>
|
||||
<!--[if mso]><i hidden style="mso-font-width: 150%"> ​</i><![endif]-->
|
||||
</a>
|
||||
</div>
|
||||
<div role="separator" style="line-height: 24px">‍</div>
|
||||
<p style="margin: 0; font-size: 16px; line-height: 24px; color: #475569">
|
||||
Thanks,
|
||||
<br>
|
||||
<span style="font-weight: 600">Solar Network Team</span>
|
||||
</p>
|
||||
<div role="separator"
|
||||
style="height: 1px; line-height: 1px; background-color: #cbd5e1; margin-top: 24px; margin-bottom: 24px">
|
||||
‍
|
||||
</div>
|
||||
<p class="mso-break-all" style="margin: 0 0 16px; font-size: 12px; line-height: 20px; color: #475569">
|
||||
@Localizer["ContactVerificationHint"]
|
||||
</p>
|
||||
<p class="mso-break-all"
|
||||
style="margin: 0; font-size: 12px; line-height: 20px; color: #475569">
|
||||
@Localizer["AlternativeLinkHint"]
|
||||
<a href="@Link" style="color: #1e293b; text-decoration: underline">@Link</a>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<table style="width: 100%" cellpadding="0" cellspacing="0" role="none">
|
||||
<tr>
|
||||
<td class="sm-px-6" style="padding: 24px 36px">
|
||||
<p style="margin: 0; font-size: 12px; color: #64748b">
|
||||
© 2025 Solsynth LLC. All rights
|
||||
reserved.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</EmailLayout>
|
||||
|
||||
@code {
|
||||
[Parameter] public required string Name { get; set; }
|
||||
[Parameter] public required string Link { get; set; }
|
||||
|
||||
[Inject] IStringLocalizer<EmailResource> Localizer { get; set; } = null!;
|
||||
}
|
||||
65
DysonNetwork.Pass/Resources/Emails/EmailLayout.razor
Normal file
65
DysonNetwork.Pass/Resources/Emails/EmailLayout.razor
Normal file
@@ -0,0 +1,65 @@
|
||||
@inherits LayoutComponentBase
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" xmlns:v="urn:schemas-microsoft-com:vml">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="x-apple-disable-message-reformatting">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="format-detection" content="telephone=no, date=no, address=no, email=no, url=no">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<meta name="supported-color-schemes" content="light dark">
|
||||
<!--[if mso]>
|
||||
<noscript>
|
||||
<xml>
|
||||
<o:OfficeDocumentSettings xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||
</o:OfficeDocumentSettings>
|
||||
</xml>
|
||||
</noscript>
|
||||
<style>
|
||||
td, th, div, p, a, h1, h2, h3, h4, h5, h6 {
|
||||
font-family: "Segoe UI", sans-serif;
|
||||
mso-line-height-rule: exactly;
|
||||
}
|
||||
|
||||
.mso-break-all {
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
<![endif]-->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap" rel="stylesheet"
|
||||
media="screen">
|
||||
<style>
|
||||
.hover-bg-slate-800:hover {
|
||||
background-color: #1e293b !important
|
||||
}
|
||||
|
||||
@@media (max-width: 600px) {
|
||||
.sm-p-6 {
|
||||
padding: 24px !important
|
||||
}
|
||||
|
||||
.sm-px-4 {
|
||||
padding-left: 16px !important;
|
||||
padding-right: 16px !important
|
||||
}
|
||||
|
||||
.sm-px-6 {
|
||||
padding-left: 24px !important;
|
||||
padding-right: 24px !important
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body
|
||||
style="margin: 0; width: 100%; background-color: #f8fafc; padding: 0; -webkit-font-smoothing: antialiased; word-break: break-word">
|
||||
@ChildContent
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@code {
|
||||
[Parameter] public RenderFragment? ChildContent { get; set; }
|
||||
}
|
||||
107
DysonNetwork.Pass/Resources/Emails/FactorCodeEmail.razor
Normal file
107
DysonNetwork.Pass/Resources/Emails/FactorCodeEmail.razor
Normal file
@@ -0,0 +1,107 @@
|
||||
@using DysonNetwork.Pass.Localization
|
||||
@using Microsoft.Extensions.Localization
|
||||
@using EmailResource = DysonNetwork.Pass.Localization.EmailResource
|
||||
|
||||
<EmailLayout>
|
||||
<div style="display: none">
|
||||
@Localizer["CodeEmailHeader"]
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
</div>
|
||||
<div role="article" aria-roledescription="email" aria-label lang="en">
|
||||
<div class="sm-px-4"
|
||||
style="background-color: #f8fafc; font-family: Inter, ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif">
|
||||
<table align="center" style="margin: 0 auto" cellpadding="0" cellspacing="0" role="none">
|
||||
<tr>
|
||||
<td style="width: 552px; max-width: 100%">
|
||||
<div role="separator" style="line-height: 24px">‍</div>
|
||||
<table style="width: 100%" cellpadding="0" cellspacing="0" role="none">
|
||||
<tr>
|
||||
<td class="sm-p-6"
|
||||
style="border-radius: 8px; background-color: #fffffe; padding: 24px 36px; border: 1px solid #e2e8f0">
|
||||
<a href="https://solian.app">
|
||||
<img src="https://solian.app/favicon.png" width="70" alt="Solar Network Logo"
|
||||
style="max-width: 100%; vertical-align: middle">
|
||||
</a>
|
||||
<div role="separator" style="line-height: 24px">‍</div>
|
||||
<h1 style="margin: 0 0 24px; font-size: 24px; line-height: 32px; font-weight: 600; color: #0f172a">
|
||||
@Localizer["UsernameFormat", Name]
|
||||
</h1>
|
||||
<p style="margin: 0 0 24px; font-size: 16px; line-height: 24px; color: #475569">
|
||||
@Localizer["CodeEmailBody"]
|
||||
</p>
|
||||
<p style="margin: 0; font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 16px; line-height: 24px; font-weight: 700; letter-spacing: 8px; color: #475569">
|
||||
@Code
|
||||
</p>
|
||||
<div role="separator" style="line-height: 24px">‍</div>
|
||||
<p style="margin: 0; font-size: 16px; line-height: 24px; color: #475569">
|
||||
Thanks,
|
||||
<br>
|
||||
<span style="font-weight: 600">Solar Network Team</span>
|
||||
</p>
|
||||
<div role="separator"
|
||||
style="height: 1px; line-height: 1px; background-color: #cbd5e1; margin-top: 24px; margin-bottom: 24px">
|
||||
‍
|
||||
</div>
|
||||
<p class="mso-break-all" style="margin: 0 0 16px; font-size: 12px; line-height: 20px; color: #475569">
|
||||
@Localizer["CodeEmailHint"]
|
||||
</p>
|
||||
<p class="mso-break-all"
|
||||
style="margin: 0; font-size: 12px; line-height: 20px; color: #475569">
|
||||
@Localizer["CodeEmailHintSecondary"]
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<table style="width: 100%" cellpadding="0" cellspacing="0" role="none">
|
||||
<tr>
|
||||
<td class="sm-px-6" style="padding: 24px 36px">
|
||||
<p style="margin: 0; font-size: 12px; color: #64748b">
|
||||
© 2025 Solsynth LLC. All rights
|
||||
reserved.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</EmailLayout>
|
||||
|
||||
@code {
|
||||
[Parameter] public required string Name { get; set; }
|
||||
[Parameter] public required string Code { get; set; }
|
||||
|
||||
[Inject] IStringLocalizer<EmailResource> Localizer { get; set; } = null!;
|
||||
[Inject] IStringLocalizer<SharedResource> LocalizerShared { get; set; } = null!;
|
||||
}
|
||||
114
DysonNetwork.Pass/Resources/Emails/PasswordResetEmail.razor
Normal file
114
DysonNetwork.Pass/Resources/Emails/PasswordResetEmail.razor
Normal file
@@ -0,0 +1,114 @@
|
||||
@using DysonNetwork.Pass.Localization
|
||||
@using Microsoft.Extensions.Localization
|
||||
@using EmailResource = DysonNetwork.Pass.Localization.EmailResource
|
||||
|
||||
<EmailLayout>
|
||||
<div style="display: none">
|
||||
@Localizer["PasswordResetHeader"]
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
</div>
|
||||
<div role="article" aria-roledescription="email" aria-label lang="en">
|
||||
<div class="sm-px-4"
|
||||
style="background-color: #f8fafc; font-family: Inter, ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif">
|
||||
<table align="center" style="margin: 0 auto" cellpadding="0" cellspacing="0" role="none">
|
||||
<tr>
|
||||
<td style="width: 552px; max-width: 100%">
|
||||
<div role="separator" style="line-height: 24px">‍</div>
|
||||
<table style="width: 100%" cellpadding="0" cellspacing="0" role="none">
|
||||
<tr>
|
||||
<td class="sm-p-6"
|
||||
style="border-radius: 8px; background-color: #fffffe; padding: 24px 36px; border: 1px solid #e2e8f0">
|
||||
<a href="https://solian.app">
|
||||
<img src="https://solian.app/favicon.png" width="70" alt="Solar Network Logo"
|
||||
style="max-width: 100%; vertical-align: middle">
|
||||
</a>
|
||||
<div role="separator" style="line-height: 24px">‍</div>
|
||||
<h1 style="margin: 0 0 24px; font-size: 24px; line-height: 32px; font-weight: 600; color: #0f172a">
|
||||
@Localizer["UsernameFormat", Name]
|
||||
</h1>
|
||||
<p style="margin: 0 0 24px; font-size: 16px; line-height: 24px; color: #475569">
|
||||
@Localizer["PasswordResetBody"]
|
||||
</p>
|
||||
<div>
|
||||
<a href="@Link"
|
||||
style="display: inline-block; text-decoration: none; padding: 16px 24px; font-size: 16px; line-height: 1; border-radius: 4px; color: #fffffe; background-color: #020617"
|
||||
class="hover-bg-slate-800">
|
||||
<!--[if mso]><i style="mso-font-width: 150%; mso-text-raise: 31px" hidden> </i><![endif]-->
|
||||
<span style="mso-text-raise: 16px">@Localizer["PasswordResetButton"]</span>
|
||||
<!--[if mso]><i hidden style="mso-font-width: 150%"> ​</i><![endif]-->
|
||||
</a>
|
||||
</div>
|
||||
<div role="separator" style="line-height: 24px">‍</div>
|
||||
<p style="margin: 0; font-size: 16px; line-height: 24px; color: #475569">
|
||||
Thanks,
|
||||
<br>
|
||||
<span style="font-weight: 600">Solar Network Team</span>
|
||||
</p>
|
||||
<div role="separator"
|
||||
style="height: 1px; line-height: 1px; background-color: #cbd5e1; margin-top: 24px; margin-bottom: 24px">
|
||||
‍
|
||||
</div>
|
||||
<p class="mso-break-all" style="margin: 0 0 16px; font-size: 12px; line-height: 20px; color: #475569">
|
||||
@Localizer["PasswordResetHint"]
|
||||
</p>
|
||||
<p class="mso-break-all"
|
||||
style="margin: 0; font-size: 12px; line-height: 20px; color: #475569">
|
||||
@Localizer["AlternativeLinkHint"]
|
||||
<a href="@Link" style="color: #1e293b; text-decoration: underline">@Link</a>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<table style="width: 100%" cellpadding="0" cellspacing="0" role="none">
|
||||
<tr>
|
||||
<td class="sm-px-6" style="padding: 24px 36px">
|
||||
<p style="margin: 0; font-size: 12px; color: #64748b">
|
||||
© 2025 Solsynth LLC. All rights
|
||||
reserved.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</EmailLayout>
|
||||
|
||||
@code {
|
||||
[Parameter] public required string Name { get; set; }
|
||||
[Parameter] public required string Link { get; set; }
|
||||
|
||||
[Inject] IStringLocalizer<EmailResource> Localizer { get; set; } = null!;
|
||||
[Inject] IStringLocalizer<SharedResource> LocalizerShared { get; set; } = null!;
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
@using Microsoft.Extensions.Localization
|
||||
@using EmailResource = DysonNetwork.Pass.Localization.EmailResource
|
||||
|
||||
<EmailLayout>
|
||||
<div style="display: none">
|
||||
@Localizer["RegConfirmHeader"]
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
 ͏  ͏  ͏  ͏  ͏
|
||||
</div>
|
||||
<div role="article" aria-roledescription="email" aria-label lang="en">
|
||||
<div class="sm-px-4"
|
||||
style="background-color: #f8fafc; font-family: Inter, ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif">
|
||||
<table align="center" style="margin: 0 auto" cellpadding="0" cellspacing="0" role="none">
|
||||
<tr>
|
||||
<td style="width: 552px; max-width: 100%">
|
||||
<div role="separator" style="line-height: 24px">‍</div>
|
||||
<table style="width: 100%" cellpadding="0" cellspacing="0" role="none">
|
||||
<tr>
|
||||
<td class="sm-p-6"
|
||||
style="border-radius: 8px; background-color: #fffffe; padding: 24px 36px; border: 1px solid #e2e8f0">
|
||||
<a href="https://solian.app">
|
||||
<img src="https://solian.app/favicon.png" width="70" alt="Solar Network Logo"
|
||||
style="max-width: 100%; vertical-align: middle">
|
||||
</a>
|
||||
<div role="separator" style="line-height: 24px">‍</div>
|
||||
<h1 style="margin: 0 0 24px; font-size: 24px; line-height: 32px; font-weight: 600; color: #0f172a">
|
||||
@Localizer["UsernameFormat", Name]
|
||||
</h1>
|
||||
<p style="margin: 0 0 24px; font-size: 16px; line-height: 24px; color: #475569">
|
||||
@Localizer["RegConfirmBody"]
|
||||
</p>
|
||||
<div>
|
||||
<a href="@Link"
|
||||
style="display: inline-block; text-decoration: none; padding: 16px 24px; font-size: 16px; line-height: 1; border-radius: 4px; color: #fffffe; background-color: #020617"
|
||||
class="hover-bg-slate-800">
|
||||
<!--[if mso]><i style="mso-font-width: 150%; mso-text-raise: 31px" hidden> </i><![endif]-->
|
||||
<span style="mso-text-raise: 16px">@Localizer["RegConfirmButton"]</span>
|
||||
<!--[if mso]><i hidden style="mso-font-width: 150%"> ​</i><![endif]-->
|
||||
</a>
|
||||
</div>
|
||||
<div role="separator" style="line-height: 24px">‍</div>
|
||||
<p style="margin: 0; font-size: 16px; line-height: 24px; color: #475569">
|
||||
Thanks,
|
||||
<br>
|
||||
<span style="font-weight: 600">Solar Network Team</span>
|
||||
</p>
|
||||
<div role="separator"
|
||||
style="height: 1px; line-height: 1px; background-color: #cbd5e1; margin-top: 24px; margin-bottom: 24px">
|
||||
‍
|
||||
</div>
|
||||
<p class="mso-break-all"
|
||||
style="margin: 0; font-size: 12px; line-height: 20px; color: #475569">
|
||||
@Localizer["AlternativeLinkHint"]
|
||||
<a href="@Link" style="color: #1e293b; text-decoration: underline">@Link</a>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<table style="width: 100%" cellpadding="0" cellspacing="0" role="none">
|
||||
<tr>
|
||||
<td class="sm-px-6" style="padding: 24px 36px">
|
||||
<p style="margin: 0; font-size: 12px; color: #64748b">
|
||||
© 2025 Solsynth LLC. All rights
|
||||
reserved.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</EmailLayout>
|
||||
|
||||
@code {
|
||||
[Parameter] public required string Name { get; set; }
|
||||
[Parameter] public required string Link { get; set; }
|
||||
|
||||
[Inject] IStringLocalizer<EmailResource> Localizer { get; set; } = null!;
|
||||
}
|
||||
@@ -18,49 +18,34 @@
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<data name="LandingHeader1" xml:space="preserve">
|
||||
<data name="RegConfirmHeader" xml:space="preserve">
|
||||
<value>Welcome to the Solar Network!</value>
|
||||
</data>
|
||||
<data name="LandingPara1" xml:space="preserve">
|
||||
<value>Dear, </value>
|
||||
<data name="RegConfirmBody" xml:space="preserve">
|
||||
<value>We're happy to have you joining our community! Please confirm your registeration in order to activate your account to unlock all the features available.</value>
|
||||
</data>
|
||||
<data name="LandingPara2" xml:space="preserve">
|
||||
<value>Thank you for creating an account on the Solar Network. We're excited to have you join our community!</value>
|
||||
</data>
|
||||
<data name="LandingPara3" xml:space="preserve">
|
||||
<value>To access all features and ensure the security of your account, please confirm your registration by clicking the button below:</value>
|
||||
</data>
|
||||
<data name="LandingButton1" xml:space="preserve">
|
||||
<data name="RegConfirmButton" xml:space="preserve">
|
||||
<value>Confirm Registration</value>
|
||||
</data>
|
||||
<data name="LandingPara4" xml:space="preserve">
|
||||
<value>If you didn't create this account, please ignore this email.</value>
|
||||
</data>
|
||||
<data name="EmailLandingTitle" xml:space="preserve">
|
||||
<data name="RegConfirmTitle" xml:space="preserve">
|
||||
<value>Confirm your registration</value>
|
||||
</data>
|
||||
<data name="AccountDeletionHeader" xml:space="preserve">
|
||||
<value>Account Deletion Confirmation</value>
|
||||
</data>
|
||||
<data name="AccountDeletionPara1" xml:space="preserve">
|
||||
<value>Dear, </value>
|
||||
</data>
|
||||
<data name="AccountDeletionPara2" xml:space="preserve">
|
||||
<value>We've received a request to delete your Solar Network account. We're sorry to see you go.</value>
|
||||
</data>
|
||||
<data name="AccountDeletionPara3" xml:space="preserve">
|
||||
<value>To confirm your account deletion, please click the button below. Please note that this action is permanent and cannot be undone.</value>
|
||||
<data name="AccountDeletionBody" xml:space="preserve">
|
||||
<value>We've received a request to delete your Solar Network account. We're sorry to see you go. To confirm your account deletion, please click the button below. Please note that this action is permanent and cannot be undone.</value>
|
||||
</data>
|
||||
<data name="AccountDeletionButton" xml:space="preserve">
|
||||
<value>Confirm Account Deletion</value>
|
||||
</data>
|
||||
<data name="AccountDeletionPara4" xml:space="preserve">
|
||||
<data name="AccountDeletionHint" xml:space="preserve">
|
||||
<value>If you did not request to delete your account, please ignore this email or contact our support team immediately.</value>
|
||||
</data>
|
||||
<data name="EmailAccountDeletionTitle" xml:space="preserve">
|
||||
<value>Confirm your account deletion</value>
|
||||
</data>
|
||||
<data name="EmailPasswordResetTitle" xml:space="preserve">
|
||||
<data name="PasswordResetTitle" xml:space="preserve">
|
||||
<value>Reset your password</value>
|
||||
</data>
|
||||
<data name="PasswordResetButton" xml:space="preserve">
|
||||
@@ -69,61 +54,46 @@
|
||||
<data name="PasswordResetHeader" xml:space="preserve">
|
||||
<value>Password Reset Request</value>
|
||||
</data>
|
||||
<data name="PasswordResetPara1" xml:space="preserve">
|
||||
<value>Dear,</value>
|
||||
</data>
|
||||
<data name="PasswordResetPara2" xml:space="preserve">
|
||||
<value>We recieved a request to reset your Solar Network account password.</value>
|
||||
</data>
|
||||
<data name="PasswordResetPara3" xml:space="preserve">
|
||||
<value>You can click the button below to continue reset your password.</value>
|
||||
</data>
|
||||
<data name="PasswordResetPara4" xml:space="preserve">
|
||||
<data name="PasswordResetHint" xml:space="preserve">
|
||||
<value>If you didn't request this, you can ignore this email safety.</value>
|
||||
</data>
|
||||
<data name="EmailVerificationTitle" xml:space="preserve">
|
||||
<value>Verify your email address</value>
|
||||
<data name="ContractMethodVerificationTitle" xml:space="preserve">
|
||||
<value>Verify Contact Method</value>
|
||||
</data>
|
||||
<data name="ContactVerificationHeader" xml:space="preserve">
|
||||
<value>Verify Your Contact Information</value>
|
||||
<value>Verify Contact Method</value>
|
||||
</data>
|
||||
<data name="ContactVerificationPara1" xml:space="preserve">
|
||||
<value>Dear, </value>
|
||||
</data>
|
||||
<data name="ContactVerificationPara2" xml:space="preserve">
|
||||
<value>Thank you for updating your contact information on the Solar Network. To ensure your account security, we need to verify this change.</value>
|
||||
</data>
|
||||
<data name="ContactVerificationPara3" xml:space="preserve">
|
||||
<value>Please click the button below to verify your contact information:</value>
|
||||
<data name="ContactVerificationBody" xml:space="preserve">
|
||||
<value>Thank you for updating your contact method on the Solar Network. To ensure your account security, we need to verify this change. Please click the button below to verify your contact method:</value>
|
||||
</data>
|
||||
<data name="ContactVerificationButton" xml:space="preserve">
|
||||
<value>Verify Contact Information</value>
|
||||
<value>Verify</value>
|
||||
</data>
|
||||
<data name="ContactVerificationPara4" xml:space="preserve">
|
||||
<data name="ContactVerificationHint" xml:space="preserve">
|
||||
<value>If you didn't request this change, please contact our support team immediately.</value>
|
||||
</data>
|
||||
<data name="EmailContactVerificationTitle" xml:space="preserve">
|
||||
<value>Verify your contact information</value>
|
||||
</data>
|
||||
<data name="EmailCodeTitle" xml:space="preserve">
|
||||
<data name="CodeEmailTitle" xml:space="preserve">
|
||||
<value>Your email one-time-password</value>
|
||||
</data>
|
||||
<data name="CodeHeader1" xml:space="preserve">
|
||||
<data name="CodeEmailHeader" xml:space="preserve">
|
||||
<value>Email One-time-password</value>
|
||||
</data>
|
||||
<data name="CodePara1" xml:space="preserve">
|
||||
<value>Dear, </value>
|
||||
<data name="CodeEmailBody" xml:space="preserve">
|
||||
<value>Someone trying to use email auth factor to authorize an access request. If that is you, enter the code below to continue.</value>
|
||||
</data>
|
||||
<data name="CodePara2" xml:space="preserve">
|
||||
<value>Someone trying to use email auth factor to authorize an access request</value>
|
||||
</data>
|
||||
<data name="CodePara3" xml:space="preserve">
|
||||
<value>If that is you, enter the code below to continue</value>
|
||||
</data>
|
||||
<data name="CodePara4" xml:space="preserve">
|
||||
<data name="CodeEmailHint" xml:space="preserve">
|
||||
<value>This code will expire in 30 minutes.</value>
|
||||
</data>
|
||||
<data name="CodePara5" xml:space="preserve">
|
||||
<data name="CodeEmailHintSecondary" xml:space="preserve">
|
||||
<value>If you didn't request this, you can ignore this email safely</value>
|
||||
</data>
|
||||
<data name="UsernameFormat" xml:space="preserve">
|
||||
<value>Dear, {0}</value>
|
||||
</data>
|
||||
<data name="AlternativeLinkHint" xml:space="preserve">
|
||||
<value>If you're having trouble clicking the button, copy and paste the following URL into your web browser:</value>
|
||||
</data>
|
||||
<data name="PasswordResetBody" xml:space="preserve">
|
||||
<value>We recieved a request to reset your Solar Network account password. Click the button below to continue and reset it.</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -11,43 +11,28 @@
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<data name="LandingHeader1" xml:space="preserve">
|
||||
<data name="RegConfirmHeader" xml:space="preserve">
|
||||
<value>欢迎来到 Solar Network!</value>
|
||||
</data>
|
||||
<data name="LandingPara1" xml:space="preserve">
|
||||
<data name="RegConfirmBody" xml:space="preserve">
|
||||
<value>尊敬的 </value>
|
||||
</data>
|
||||
<data name="LandingButton1" xml:space="preserve">
|
||||
<data name="RegConfirmButton" xml:space="preserve">
|
||||
<value>确认注册</value>
|
||||
</data>
|
||||
<data name="LandingPara2" xml:space="preserve">
|
||||
<value>感谢你在 Solar Network 上注册帐号,我们很激动你即将加入我们的社区!</value>
|
||||
</data>
|
||||
<data name="LandingPara3" xml:space="preserve">
|
||||
<value>点击下方按钮来确认你的注册以获得所有功能的权限。</value>
|
||||
</data>
|
||||
<data name="LandingPara4" xml:space="preserve">
|
||||
<value>如果你并没有注册帐号,你可以忽略此邮件。</value>
|
||||
</data>
|
||||
<data name="EmailLandingTitle" xml:space="preserve">
|
||||
<data name="RegConfirmTitle" xml:space="preserve">
|
||||
<value>确认你的注册</value>
|
||||
</data>
|
||||
<data name="AccountDeletionHeader" xml:space="preserve">
|
||||
<value>账户删除确认</value>
|
||||
</data>
|
||||
<data name="AccountDeletionPara1" xml:space="preserve">
|
||||
<value>尊敬的 </value>
|
||||
</data>
|
||||
<data name="AccountDeletionPara2" xml:space="preserve">
|
||||
<value>我们收到了删除您 Solar Network 账户的请求。我们很遗憾看到您的离开。</value>
|
||||
</data>
|
||||
<data name="AccountDeletionPara3" xml:space="preserve">
|
||||
<value>请点击下方按钮确认删除您的账户。请注意,此操作是永久性的,无法撤销。</value>
|
||||
<data name="AccountDeletionBody" xml:space="preserve">
|
||||
<value>我们收到了删除您 Solar Network 账户的请求。我们很遗憾看到您的离开。请点击下方按钮确认删除您的账户。请注意,此操作是永久性的,无法撤销。</value>
|
||||
</data>
|
||||
<data name="AccountDeletionButton" xml:space="preserve">
|
||||
<value>确认删除账户</value>
|
||||
</data>
|
||||
<data name="AccountDeletionPara4" xml:space="preserve">
|
||||
<data name="AccountDeletionHint" xml:space="preserve">
|
||||
<value>如果您并未请求删除账户,请忽略此邮件或立即联系我们的支持团队。</value>
|
||||
</data>
|
||||
<data name="EmailAccountDeletionTitle" xml:space="preserve">
|
||||
@@ -56,67 +41,52 @@
|
||||
<data name="PasswordResetHeader" xml:space="preserve">
|
||||
<value>密码重置请求</value>
|
||||
</data>
|
||||
<data name="PasswordResetPara1" xml:space="preserve">
|
||||
<value>尊敬的 </value>
|
||||
</data>
|
||||
<data name="PasswordResetPara2" xml:space="preserve">
|
||||
<value>我们收到了重置您 Solar Network 账户密码的请求。</value>
|
||||
</data>
|
||||
<data name="PasswordResetPara3" xml:space="preserve">
|
||||
<value>请点击下方按钮重置您的密码。此链接将在24小时后失效。</value>
|
||||
</data>
|
||||
<data name="PasswordResetButton" xml:space="preserve">
|
||||
<value>重置密码</value>
|
||||
</data>
|
||||
<data name="PasswordResetPara4" xml:space="preserve">
|
||||
<data name="PasswordResetHint" xml:space="preserve">
|
||||
<value>如果您并未请求重置密码,你可以安全地忽略此邮件。</value>
|
||||
</data>
|
||||
<data name="EmailPasswordResetTitle" xml:space="preserve">
|
||||
<data name="PasswordResetTitle" xml:space="preserve">
|
||||
<value>重置您的密码</value>
|
||||
</data>
|
||||
<data name="EmailVerificationTitle" xml:space="preserve">
|
||||
<value>验证您的电子邮箱</value>
|
||||
<data name="ContractMethodVerificationTitle" xml:space="preserve">
|
||||
<value>验证您的联系方式</value>
|
||||
</data>
|
||||
<data name="ContactVerificationHeader" xml:space="preserve">
|
||||
<value>验证您的联系信息</value>
|
||||
<value>验证您的联系方式</value>
|
||||
</data>
|
||||
<data name="ContactVerificationPara1" xml:space="preserve">
|
||||
<value>尊敬的 </value>
|
||||
</data>
|
||||
<data name="ContactVerificationPara2" xml:space="preserve">
|
||||
<value>感谢您更新 Solar Network 上的联系信息。为确保您的账户安全,我们需要验证此更改。</value>
|
||||
</data>
|
||||
<data name="ContactVerificationPara3" xml:space="preserve">
|
||||
<value>请点击下方按钮验证您的联系信息:</value>
|
||||
<data name="ContactVerificationBody" xml:space="preserve">
|
||||
<value>感谢您更新 Solar Network 上的联系方式。为确保您的账户安全,我们需要验证此更改。请点击下方按钮验证您的联系方式。</value>
|
||||
</data>
|
||||
<data name="ContactVerificationButton" xml:space="preserve">
|
||||
<value>验证联系信息</value>
|
||||
<value>验证</value>
|
||||
</data>
|
||||
<data name="ContactVerificationPara4" xml:space="preserve">
|
||||
<data name="ContactVerificationHint" xml:space="preserve">
|
||||
<value>如果您没有请求此更改,请立即联系我们的支持团队。</value>
|
||||
</data>
|
||||
<data name="EmailContactVerificationTitle" xml:space="preserve">
|
||||
<value>验证您的联系信息</value>
|
||||
</data>
|
||||
<data name="EmailCodeTitle" xml:space="preserve">
|
||||
<data name="CodeEmailTitle" xml:space="preserve">
|
||||
<value>您的一次性邮件验证码</value>
|
||||
</data>
|
||||
<data name="CodeHeader1" xml:space="preserve">
|
||||
<data name="CodeEmailHeader" xml:space="preserve">
|
||||
<value>邮件一次性验证码</value>
|
||||
</data>
|
||||
<data name="CodePara1" xml:space="preserve">
|
||||
<value>尊敬的</value>
|
||||
<data name="CodeEmailBody" xml:space="preserve">
|
||||
<value>有人正在尝试使用邮件验证码作为验证因子授权访问。如果那位用户就是您,输入下方的验证码来继续访问。</value>
|
||||
</data>
|
||||
<data name="CodePara2" xml:space="preserve">
|
||||
<value>有人正在尝试使用邮件验证码作为验证因子授权访问</value>
|
||||
</data>
|
||||
<data name="CodePara3" xml:space="preserve">
|
||||
<value>如果那位用户就是您,输入下方的验证码来继续访问</value>
|
||||
</data>
|
||||
<data name="CodePara4" xml:space="preserve">
|
||||
<data name="CodeEmailHint" xml:space="preserve">
|
||||
<value>验证码会在 30 分钟后过期</value>
|
||||
</data>
|
||||
<data name="CodePara5" xml:space="preserve">
|
||||
<data name="CodeEmailHintSecondary" xml:space="preserve">
|
||||
<value>如果您未申请过本验证码,您可以安全的忽略这封邮件</value>
|
||||
</data>
|
||||
<data name="UsernameFormat" xml:space="preserve">
|
||||
<value>尊敬的 {0}</value>
|
||||
</data>
|
||||
<data name="AlternativeLinkHint" xml:space="preserve">
|
||||
<value>如果您无法点击上面的按钮,您也可以尝试复制下面的连接到浏览器访问</value>
|
||||
</data>
|
||||
<data name="PasswordResetBody" xml:space="preserve">
|
||||
<value>我们收到了重置您 Solar Network 账户密码的请求。请点击下方按钮重置您的密码</value>
|
||||
</data>
|
||||
</root>
|
||||
Reference in New Issue
Block a user