[feat] Partially implemented transmission system

This commit is contained in:
acite
2025-08-28 00:34:26 +08:00
parent 64aa7a2fdd
commit e27058626e
18 changed files with 512 additions and 64 deletions

View File

@@ -93,16 +93,5 @@ public class VideoController(ILogger<VideoController> logger, ResourceService rs
return PhysicalFile(d, "video/mp4", enableRangeProcessing: true);
}
[HttpGet("{klass}/{id}/nv")]
public async Task<IActionResult> Nv(string klass, string id, string token)
{
var d = Helpers.SafePathCombine(VideoFolder, [klass, id, "video.a.mp4"]);
if (d == null) return StatusCode(403, new { message = "403 Denied" });
var r = await rs.Get(d, token, Ip);
if (!r) return StatusCode(403, new { message = "403 Denied" });
return PhysicalFile(d, "video/mp4", enableRangeProcessing: true);
}
private string Ip => HttpContext.Connection.RemoteIpAddress?.ToString() ?? "127.0.0.1";
}

View File

@@ -0,0 +1,60 @@
using Abyss.Components.Services;
using Abyss.Components.Static;
using Abyss.Model;
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace Abyss.Components.Controllers.Task;
[ApiController]
[Route("api/[controller]")]
public class TaskController(ILogger<TaskController> logger, ConfigureService config, TaskService taskService) : Controller
{
public readonly string TaskFolder = Path.Combine(config.MediaRoot, "Tasks");
[HttpGet]
public async Task<IActionResult> Query(string token)
{
// If the token is invalid, an empty list will be returned, which is part of the design
return Json(await taskService.Query(token, Ip));
}
[HttpPost]
public async Task<IActionResult> Create(string token, [FromBody] TaskCreation creation)
{
var r = await taskService.Create(token, Ip, creation);
if(r == null)
{
return BadRequest();
}
return Ok(JsonConvert.SerializeObject(r, Formatting.Indented));
}
[HttpGet("{id}")]
public async Task<IActionResult> GetTask(string id)
{
throw new NotImplementedException();
}
[HttpPatch("{id}")]
public async Task<IActionResult> PutChip(string id)
{
throw new NotImplementedException();
}
[HttpPost("{id}")]
public async Task<IActionResult> VerifyChip(string id)
{
throw new NotImplementedException();
}
[HttpDelete("{id}")]
public async Task<IActionResult> DeleteTask(string id)
{
throw new NotImplementedException();
}
private string Ip => HttpContext.Connection.RemoteIpAddress?.ToString() ?? "127.0.0.1";
}

View File

@@ -39,6 +39,11 @@ public class ResourceService
_database = new SQLiteAsyncConnection(config.RaDatabase, SQLiteOpenFlags.ReadWrite | SQLiteOpenFlags.Create);
_database.CreateTableAsync<ResourceAttribute>().Wait();
var tasksPath = Helpers.SafePathCombine(_config.MediaRoot, "Tasks");
if(tasksPath != null)
InsertRaRow(tasksPath, "root", "rw,r-,r-", true).Wait();
}
// Create UID only for resources, without considering advanced hash security such as adding salt
@@ -335,4 +340,34 @@ public class ResourceService
return false;
}
}
private async Task<bool> InsertRaRow(string fullPath, string owner, string permission, bool update = false)
{
if (!PermissionRegex.IsMatch(permission))
{
_logger.LogError($"Invalid permission format: {permission}");
return false;
}
var path = Path.GetRelativePath(_config.MediaRoot, fullPath);
if (update)
return await _database.InsertOrReplaceAsync(new ResourceAttribute()
{
Uid = Uid(path),
Name = path,
Owner = owner,
Permission = permission,
}) == 1;
else
{
return await _database.InsertAsync(new ResourceAttribute()
{
Uid = Uid(path),
Name = path,
Owner = owner,
Permission = permission,
}) == 1;
}
}
}

View File

@@ -0,0 +1,247 @@
using Abyss.Components.Static;
using Abyss.Model;
using Newtonsoft.Json;
using SQLite;
using Task = Abyss.Model.Task;
namespace Abyss.Components.Services;
public class TaskService(ILogger<TaskService> logger, ConfigureService config, ResourceService rs, UserService user)
{
public readonly string TaskFolder = Path.Combine(config.MediaRoot, "Tasks");
public readonly string VideoFolder = Path.Combine(config.MediaRoot, "Videos");
private const ulong MaxChunkSize = 20 * 1024 * 1024;
public async Task<List<String>> Query(string token, string ip)
{
var r = await rs.Query(TaskFolder, token, ip);
var u = user.Validate(token, ip);
List<string> s = new();
foreach (var i in r ?? [])
{
var p = Helpers.SafePathCombine(TaskFolder, [i, "task.json"]);
var c = JsonConvert.DeserializeObject<Model.Task>(await System.IO.File.ReadAllTextAsync(p ?? ""));
if(c?.Owner == u) s.Add(i);
}
return s;
}
public async Task<TaskCreationResponse?> Create(string token, string ip, TaskCreation creation)
{
if(creation.Name.Length > 64 || creation.Klass.Length > 16 || creation.Size > 10UL * 1024UL * 1024UL * 1024UL || creation.Author.Length > 32)
return null;
if(creation.Name == "" || creation.Klass == "")
return null;
if(!IsFileNameSafe(creation.Klass))
return null;
if (GetAvailableFreeSpace(TaskFolder) - (long)creation.Size < 10 * 1024L * 1024L * 1024L)
{ // Reserve 10GB of space
return null;
}
switch ((TaskType)creation.Type)
{
case TaskType.Image:
return await CreateImageTask(token, ip, creation);
case TaskType.Video:
return await CreateVideoTask(token, ip, creation);
default:
return null;
}
}
private async Task<TaskCreationResponse?> CreateVideoTask(string token, string ip, TaskCreation creation)
{
if(!await rs.Valid(VideoFolder, token, OperationType.Write, ip))
return null;
var u = user.Validate(token, ip);
if(u == null)
return null;
var r = new TaskCreationResponse()
{
Id = GenerateUniqueId(TaskFolder),
Chips = SliceFile(creation.Size)
};
Directory.CreateDirectory(Path.Combine(TaskFolder, r.Id.ToString()));
Directory.CreateDirectory(Path.Combine(TaskFolder, r.Id.ToString(), "gallery"));
// It shouldn't be a problem to spell it directly like this, as all the parameters are generated by myself
Task v = new Task()
{
Name = creation.Name,
Owner = u,
Class = creation.Klass,
Id = r.Id,
Type = TaskType.Video
};
await System.IO.File.WriteAllTextAsync(
Path.Combine(TaskFolder, r.Id.ToString(), "task.json"),
JsonConvert.SerializeObject(v, Formatting.Indented));
using (var connection = new SQLiteConnection(Path.Combine(TaskFolder, r.Id.ToString(), "task.db")))
{
connection.CreateTable<Chip>();
connection.InsertAll(r.Chips.Select(x => new Chip()
{
Addr = x.Addr,
Hash = "",
Id = x.Id,
Size = x.Size,
State = ChipState.Created
}));
connection.Close();
}
CreateEmptyFile(Path.Combine(TaskFolder, r.Id.ToString(), "video.mp4"), (long)creation.Size);
return r;
}
private async Task<TaskCreationResponse?> CreateImageTask(string token, string ip, TaskCreation creation)
{
throw new NotImplementedException();
}
public static uint GenerateUniqueId(string parentDirectory)
{
string[] directories = Directory.GetDirectories(parentDirectory);
HashSet<uint> existingIds = new HashSet<uint>();
foreach (string dirPath in directories)
{
string dirName = new DirectoryInfo(dirPath).Name;
if (uint.TryParse(dirName, out uint id))
{
if (id != 0)
{
existingIds.Add(id);
}
}
}
uint newId = 1;
while (existingIds.Contains(newId))
{
newId++;
if (newId == uint.MaxValue)
{
return 0;
}
}
return newId;
}
public static List<ChipDesc> SliceFile(ulong fileSize)
{
var tasks = new List<ChipDesc>();
if (fileSize == 0)
{
return tasks;
}
ulong remainingSize = fileSize;
ulong currentAddr = 0;
uint id = 0;
while (remainingSize > 0)
{
ulong chunkSize = remainingSize > MaxChunkSize ? MaxChunkSize : remainingSize;
tasks.Add(new ChipDesc
{
Id = id,
Addr = currentAddr,
Size = chunkSize
});
currentAddr += chunkSize;
remainingSize -= chunkSize;
id++;
}
return tasks;
}
public static bool IsFileNameSafe(string fileName)
{
if (string.IsNullOrWhiteSpace(fileName))
{
return false;
}
if (fileName.Contains(Path.DirectorySeparatorChar) ||
fileName.Contains(Path.AltDirectorySeparatorChar))
{
return false;
}
char[] invalidChars = Path.GetInvalidFileNameChars();
if (fileName.Any(c => invalidChars.Contains(c)))
{
return false;
}
string[] reservedNames = {
"CON", "PRN", "AUX", "NUL",
"COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
"LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9"
};
string nameWithoutExtension = Path.GetFileNameWithoutExtension(fileName).ToUpperInvariant();
if (reservedNames.Contains(nameWithoutExtension))
{
return false;
}
return true;
}
public static void CreateEmptyFile(string filePath, long sizeInBytes)
{
using (FileStream fs = new FileStream(filePath, FileMode.Create, FileAccess.Write))
{
fs.SetLength(sizeInBytes);
}
}
public static long GetAvailableFreeSpace(string directoryPath)
{
try
{
if (string.IsNullOrEmpty(directoryPath))
{
return -1;
}
string rootPath = Path.GetPathRoot(directoryPath) ?? "";
if (string.IsNullOrEmpty(rootPath))
{
return -1;
}
DriveInfo driveInfo = new DriveInfo(rootPath);
if (driveInfo.IsReady)
{
return driveInfo.AvailableFreeSpace;
}
else
{
return -1;
}
}
catch (Exception ex)
{
return -1;
}
}
}

View File

@@ -7,6 +7,7 @@ using Abyss.Model;
using Microsoft.Extensions.Caching.Memory;
using NSec.Cryptography;
using SQLite;
using Task = System.Threading.Tasks.Task;
namespace Abyss.Components.Services;
@@ -27,6 +28,8 @@ public class UserService
_database.CreateTableAsync<User>().Wait();
var rootUser = _database.Table<User>().Where(x => x.Name == "root").FirstOrDefaultAsync().Result;
_cache.Set("acite", $"acite@127.0.0.1", DateTimeOffset.Now.AddDays(1));
if (rootUser == null)
{
var key = GenerateKeyPair();

View File

@@ -67,7 +67,6 @@ public static class StringArrayExtensions
{
public static string[] SortLikeWindows(this string[] array)
{
if (array == null) return null;
if (array.Length == 0) return array;
Array.Sort(array, new WindowsFileNameComparer());
@@ -76,7 +75,6 @@ public static class StringArrayExtensions
public static string[] SortLikeWindowsDescending(this string[] array)
{
if (array == null) return null;
if (array.Length == 0) return array;
Array.Sort(array, new WindowsFileNameComparerDescending());
@@ -85,14 +83,14 @@ public static class StringArrayExtensions
public static void SortLikeWindowsInPlace(this string[] array)
{
if (array == null || array.Length == 0) return;
if (array.Length == 0) return;
Array.Sort(array, new WindowsFileNameComparer());
}
public static void SortLikeWindowsDescendingInPlace(this string[] array)
{
if (array == null || array.Length == 0) return;
if (array.Length == 0) return;
Array.Sort(array, new WindowsFileNameComparerDescending());
}
@@ -100,8 +98,8 @@ public static class StringArrayExtensions
public class WindowsFileNameComparer : IComparer<string>
{
private static readonly Regex _regex = new Regex(@"(\d+|\D+)", RegexOptions.Compiled);
private static readonly CompareInfo _compareInfo = CultureInfo.InvariantCulture.CompareInfo;
private static readonly Regex Regex = new Regex(@"(\d+|\D+)", RegexOptions.Compiled);
private static readonly CompareInfo CompareInfo = CultureInfo.InvariantCulture.CompareInfo;
public int Compare(string? x, string? y)
{
@@ -110,8 +108,8 @@ public class WindowsFileNameComparer : IComparer<string>
if (y == null) return 1;
if (ReferenceEquals(x, y)) return 0;
var partsX = _regex.Matches(x);
var partsY = _regex.Matches(y);
var partsX = Regex.Matches(x);
var partsY = Regex.Matches(y);
int minLength = Math.Min(partsX.Count, partsY.Count);
@@ -130,7 +128,7 @@ public class WindowsFileNameComparer : IComparer<string>
int comparison;
if (ContainsChinese(partX) || ContainsChinese(partY))
{
comparison = _compareInfo.Compare(partX, partY, CompareOptions.IgnoreCase | CompareOptions.IgnoreNonSpace);
comparison = CompareInfo.Compare(partX, partY, CompareOptions.IgnoreCase | CompareOptions.IgnoreNonSpace);
}
else
{
@@ -161,20 +159,20 @@ public class WindowsFileNameComparer : IComparer<string>
public class WindowsFileNameComparerDescending : IComparer<string>
{
private static readonly WindowsFileNameComparer _ascendingComparer = new WindowsFileNameComparer();
private static readonly WindowsFileNameComparer AscendingComparer = new WindowsFileNameComparer();
public int Compare(string x, string y)
public int Compare(string? x, string? y)
{
return _ascendingComparer.Compare(y, x);
return AscendingComparer.Compare(y, x);
}
}
public static class StringNaturalCompare
{
private static readonly WindowsFileNameComparer _comparer = new WindowsFileNameComparer();
private static readonly WindowsFileNameComparer Comparer = new WindowsFileNameComparer();
public static int Compare(string x, string y)
{
return _comparer.Compare(x, y);
return Comparer.Compare(x, y);
}
}

View File

@@ -0,0 +1,6 @@
namespace Abyss.Components.Tools;
public class TemporaryDB
{
}

17
Abyss/Model/Chip.cs Normal file
View File

@@ -0,0 +1,17 @@
namespace Abyss.Model;
public enum ChipState
{
Created,
Uploaded,
Verified
}
public class Chip
{
public uint Id { get; set; }
public ulong Addr { get; set; }
public ulong Size { get; set; }
public string Hash { get; set; } = "";
public ChipState State { get; set; }
}

7
Abyss/Model/Comment.cs Normal file
View File

@@ -0,0 +1,7 @@
namespace Abyss.Model;
public class Comment
{
public string username = "";
public string text = "";
}

16
Abyss/Model/Task.cs Normal file
View File

@@ -0,0 +1,16 @@
namespace Abyss.Model;
public enum TaskType
{
Video = 1,
Image = 2,
}
public class Task
{
public uint Id;
public string Owner = "";
public string Class = "";
public string Name = "";
public TaskType Type;
}

View File

@@ -0,0 +1,23 @@
namespace Abyss.Model;
public class TaskCreation
{
public int Type { get; set; }
public ulong Size { get; set; }
public string Klass { get; set; } = "";
public string Name { get; set; } = "";
public string Author { get; set; } = "";
}
public class ChipDesc
{
public uint Id;
public ulong Addr;
public ulong Size;
}
public class TaskCreationResponse // As Array
{
public uint Id;
public List<ChipDesc> Chips = new();
}

12
Abyss/Model/Video.cs Normal file
View File

@@ -0,0 +1,12 @@
namespace Abyss.Model;
public class Video
{
public string name;
public ulong duration;
public List<string> gallery = new();
public List<Comment> comment = new();
public bool star;
public uint like;
public string author;
}

View File

@@ -1,4 +1,5 @@
using System.Threading.RateLimiting;
using Abyss.Components.Controllers.Task;
using Abyss.Components.Services;
using Microsoft.AspNetCore.RateLimiting;
@@ -17,6 +18,8 @@ public class Program
builder.Services.AddSingleton<ConfigureService>();
builder.Services.AddSingleton<UserService>();
builder.Services.AddSingleton<ResourceService>();
builder.Services.AddSingleton<TaskController>();
builder.Services.AddSingleton<TaskService>();
builder.Services.AddRateLimiter(options =>
{

View File

@@ -17,7 +17,8 @@
"launchBrowser": false,
"applicationUrl": "https://localhost:7013;http://localhost:5198",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
"ASPNETCORE_ENVIRONMENT": "Development",
"MEDIA_ROOT" : "/storage"
}
}
}

View File

@@ -1,8 +0,0 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}