[add] function implementation
This commit is contained in:
24
Abyss/Abyss.csproj
Normal file
24
Abyss/Abyss.csproj
Normal file
@@ -0,0 +1,24 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="K4os.Hash.xxHash" Version="1.0.8" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.8"/>
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="9.0.8" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
<PackageReference Include="NSec.Cryptography" Version="25.4.0" />
|
||||
<PackageReference Include="sqlite-net-pcl" Version="1.9.172" />
|
||||
<PackageReference Include="Standart.Hash.xxHash" Version="4.0.5" />
|
||||
<PackageReference Include="System.IO.Hashing" Version="10.0.0-preview.7.25380.108" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="Components\Controllers\Media\" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
19
Abyss/Components/Controllers/AbyssController.cs
Normal file
19
Abyss/Components/Controllers/AbyssController.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
using Abyss.Components.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
|
||||
namespace Abyss.Components.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class AbyssController(ILogger<AbyssController> logger, ConfigureService config) : Controller
|
||||
{
|
||||
private ILogger<AbyssController> _logger = logger;
|
||||
private ConfigureService _config = config;
|
||||
|
||||
[HttpGet]
|
||||
public IActionResult GetCollection()
|
||||
{
|
||||
return Ok($"Abyss {_config.Version}. \nMediaRoot: {_config.MediaRoot}");
|
||||
}
|
||||
}
|
||||
59
Abyss/Components/Controllers/Media/ImageController.cs
Normal file
59
Abyss/Components/Controllers/Media/ImageController.cs
Normal file
@@ -0,0 +1,59 @@
|
||||
using Abyss.Components.Services;
|
||||
using Abyss.Components.Static;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace Abyss.Components.Controllers.Media;
|
||||
using System.IO;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class ImageController(ILogger<ImageController> logger, ResourceService rs, ConfigureService config) : Controller
|
||||
{
|
||||
public readonly string ImageFolder = Path.Combine(config.MediaRoot, "Images");
|
||||
|
||||
[HttpPost("init")]
|
||||
public async Task<IActionResult> InitAsync(string token, string owner)
|
||||
{
|
||||
var r = await rs.Initialize(ImageFolder, token, owner, Ip);
|
||||
if(r) return Ok(r);
|
||||
return StatusCode(403, new { message = "403 Denied" });
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> QueryCollections(string token)
|
||||
{
|
||||
var r = await rs.Query(ImageFolder, token, Ip);
|
||||
|
||||
if(r == null)
|
||||
return StatusCode(401, new { message = "Unauthorized" });
|
||||
|
||||
return Ok(r);
|
||||
}
|
||||
|
||||
[HttpGet("{id}")]
|
||||
public async Task<IActionResult> Query(string id, string token)
|
||||
{
|
||||
var d = Helpers.SafePathCombine(ImageFolder, [id, "summary.json"]);
|
||||
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 Ok(await System.IO.File.ReadAllTextAsync(d));
|
||||
}
|
||||
|
||||
[HttpGet("{id}/{file}")]
|
||||
public async Task<IActionResult> Get(string id, string file, string token)
|
||||
{
|
||||
var d = Helpers.SafePathCombine(ImageFolder, [id, file]);
|
||||
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, "image/jpeg", enableRangeProcessing: true);
|
||||
}
|
||||
|
||||
private string Ip => HttpContext.Connection.RemoteIpAddress?.ToString() ?? "127.0.0.1";
|
||||
}
|
||||
94
Abyss/Components/Controllers/Media/VideoController.cs
Normal file
94
Abyss/Components/Controllers/Media/VideoController.cs
Normal file
@@ -0,0 +1,94 @@
|
||||
using System.Diagnostics;
|
||||
using Abyss.Components.Services;
|
||||
using Abyss.Components.Static;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Abyss.Components.Controllers.Media;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class VideoController(ILogger<VideoController> logger, ResourceService rs, ConfigureService config) : Controller
|
||||
{
|
||||
private ILogger<VideoController> _logger = logger;
|
||||
|
||||
public readonly string VideoFolder = Path.Combine(config.MediaRoot, "Videos");
|
||||
|
||||
[HttpPost("init")]
|
||||
public async Task<IActionResult> InitAsync(string token, string owner)
|
||||
{
|
||||
var r = await rs.Initialize(VideoFolder, token, owner, Ip);
|
||||
if(r) return Ok(r);
|
||||
return StatusCode(403, new { message = "403 Denied" });
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetClass(string token)
|
||||
{
|
||||
var r = await rs.Query(VideoFolder, token, Ip);
|
||||
|
||||
if(r == null)
|
||||
return StatusCode(401, new { message = "Unauthorized" });
|
||||
|
||||
return Ok(r);
|
||||
}
|
||||
|
||||
[HttpGet("{klass}")]
|
||||
public async Task<IActionResult> QueryClass(string klass, string token)
|
||||
{
|
||||
var d = Helpers.SafePathCombine(VideoFolder, klass);
|
||||
if (d == null) return StatusCode(403, new { message = "403 Denied" });
|
||||
var r = await rs.Query(d, token, Ip);
|
||||
if (r == null) return StatusCode(401, new { message = "Unauthorized" });
|
||||
|
||||
return Ok(r);
|
||||
}
|
||||
|
||||
[HttpGet("{klass}/{id}")]
|
||||
public async Task<IActionResult> QueryVideo(string klass, string id, string token)
|
||||
{
|
||||
var d = Helpers.SafePathCombine(VideoFolder, [klass, id, "summary.json"]);
|
||||
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 Ok(await System.IO.File.ReadAllTextAsync(d));
|
||||
}
|
||||
|
||||
[HttpGet("{klass}/{id}/cover")]
|
||||
public async Task<IActionResult> Cover(string klass, string id, string token)
|
||||
{
|
||||
var d = Helpers.SafePathCombine(VideoFolder, [klass, id, "cover.jpg"]);
|
||||
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, "image/jpeg", enableRangeProcessing: true);
|
||||
}
|
||||
|
||||
[HttpGet("{klass}/{id}/gallery/{pic}")]
|
||||
public async Task<IActionResult> Gallery(string klass, string id, string pic, string token)
|
||||
{
|
||||
var d = Helpers.SafePathCombine(VideoFolder, [klass, id, "gallery", pic]);
|
||||
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, "image/jpeg", enableRangeProcessing: true);
|
||||
}
|
||||
|
||||
[HttpGet("{klass}/{id}/av")]
|
||||
public async Task<IActionResult> Av(string klass, string id, string token)
|
||||
{
|
||||
var d = Helpers.SafePathCombine(VideoFolder, [klass, id, "video.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";
|
||||
}
|
||||
13
Abyss/Components/Controllers/Security/README.md
Normal file
13
Abyss/Components/Controllers/Security/README.md
Normal file
@@ -0,0 +1,13 @@
|
||||
明确几个此目录下的API的开发理念:
|
||||
- 永远不传输私钥
|
||||
|
||||
root用户的私钥仅通过服务器shell配置
|
||||
私钥在客户端生成,仅将公钥传输到服务器
|
||||
token通过挑战-响应机制创建,加密传输
|
||||
|
||||
|
||||
- 用户管理
|
||||
|
||||
创建任何新用户都必须通过一个已有用户的token,且新用户权限等级不大于该用户
|
||||
root用户的权限等级为 **114514**
|
||||
|
||||
107
Abyss/Components/Controllers/Security/UserController.cs
Normal file
107
Abyss/Components/Controllers/Security/UserController.cs
Normal file
@@ -0,0 +1,107 @@
|
||||
|
||||
// UserController.cs
|
||||
|
||||
using System.Text.RegularExpressions;
|
||||
using Abyss.Components.Services;
|
||||
using Abyss.Model;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
|
||||
namespace Abyss.Components.Controllers.Security;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
[EnableRateLimiting("Fixed")]
|
||||
public class UserController(UserService user, ILogger<UserController> logger) : Controller
|
||||
{
|
||||
private readonly ILogger<UserController> _logger = logger;
|
||||
private readonly UserService _user = user;
|
||||
|
||||
[HttpGet("{user}")]
|
||||
public async Task<IActionResult> Challenge(string user)
|
||||
{
|
||||
var c = await _user.Challenge(user);
|
||||
if(c == null)
|
||||
return StatusCode(403, new { message = "Access forbidden" });
|
||||
|
||||
return Ok(c);
|
||||
}
|
||||
|
||||
[HttpPost("{user}")]
|
||||
public async Task<IActionResult> Challenge(string user, [FromBody] ChallengeResponse response)
|
||||
{
|
||||
var r = await _user.Verify(user, response.Response, Ip);
|
||||
if(r == null)
|
||||
return StatusCode(403, new { message = "Access forbidden" });
|
||||
|
||||
return Ok(r);
|
||||
}
|
||||
|
||||
[HttpPost("validate")]
|
||||
public IActionResult Validate(string token)
|
||||
{
|
||||
var u = _user.Validate(token, Ip);
|
||||
if (u == null)
|
||||
{
|
||||
return StatusCode(401, new { message = "Invalid" });
|
||||
}
|
||||
|
||||
return Ok(u);
|
||||
}
|
||||
|
||||
[HttpPost("destroy")]
|
||||
public IActionResult Destroy(string token)
|
||||
{
|
||||
var u = _user.Validate(token, Ip);
|
||||
if (u == null)
|
||||
{
|
||||
return StatusCode(401, new { message = "Invalid" });
|
||||
}
|
||||
|
||||
_user.Destroy(token);
|
||||
return Ok("Success");
|
||||
}
|
||||
|
||||
[HttpPatch("{user}")]
|
||||
public async Task<IActionResult> Create(string user, [FromBody] UserCreating creating)
|
||||
{
|
||||
// Valid token
|
||||
var r = await _user.Verify(user, creating.Response, Ip);
|
||||
if(r == null)
|
||||
return StatusCode(403, new { message = "Denied" });
|
||||
|
||||
// User exists ?
|
||||
var cu = await _user.QueryUser(creating.Name);
|
||||
if(cu != null)
|
||||
return StatusCode(403, new { message = "Denied" });
|
||||
|
||||
// Valid username string
|
||||
if(!IsAlphanumeric(creating.Name))
|
||||
return StatusCode(403, new { message = "Denied" });
|
||||
|
||||
// Valid parent && Privilege
|
||||
var ou = await _user.QueryUser(_user.Validate(r, Ip) ?? "");
|
||||
if(creating.Parent != (_user.Validate(r, Ip) ?? "") || creating.Privilege > ou?.Privilege)
|
||||
return StatusCode(403, new { message = "Denied" });
|
||||
|
||||
await _user.CreateUser(new User()
|
||||
{
|
||||
Name = creating.Name,
|
||||
Parent = _user.Validate(r, Ip) ?? "",
|
||||
Privilege = creating.Privilege,
|
||||
PublicKey = creating.PublicKey,
|
||||
} );
|
||||
|
||||
_user.Destroy(r);
|
||||
return Ok("Success");
|
||||
}
|
||||
|
||||
public static bool IsAlphanumeric(string input)
|
||||
{
|
||||
if (string.IsNullOrEmpty(input))
|
||||
return false;
|
||||
return Regex.IsMatch(input, @"^[a-zA-Z0-9]+$");
|
||||
}
|
||||
|
||||
private string Ip => HttpContext.Connection.RemoteIpAddress?.ToString() ?? "127.0.0.1";
|
||||
}
|
||||
9
Abyss/Components/Services/ConfigureService.cs
Normal file
9
Abyss/Components/Services/ConfigureService.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace Abyss.Components.Services;
|
||||
|
||||
public class ConfigureService
|
||||
{
|
||||
public string MediaRoot { get; set; } = Environment.GetEnvironmentVariable("MEDIA_ROOT") ?? "/opt";
|
||||
public string Version { get; } = "Alpha v0.1";
|
||||
public string UserDatabase { get; set; } = "user.db";
|
||||
public string RaDatabase { get; set; } = "ra.db";
|
||||
}
|
||||
338
Abyss/Components/Services/ResourceService.cs
Normal file
338
Abyss/Components/Services/ResourceService.cs
Normal file
@@ -0,0 +1,338 @@
|
||||
|
||||
// ResourceService.cs
|
||||
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using Abyss.Components.Static;
|
||||
using Abyss.Model;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using SQLite;
|
||||
using System.IO.Hashing;
|
||||
|
||||
namespace Abyss.Components.Services;
|
||||
|
||||
public enum OperationType
|
||||
{
|
||||
Read, // Query, Read
|
||||
Write, // Write, Delete
|
||||
Security // Chown, Chmod
|
||||
}
|
||||
|
||||
public class ResourceService
|
||||
{
|
||||
private readonly ILogger<ResourceService> _logger;
|
||||
private readonly ConfigureService _config;
|
||||
private readonly IMemoryCache _cache;
|
||||
private readonly UserService _user;
|
||||
private readonly SQLiteAsyncConnection _database;
|
||||
|
||||
private static readonly Regex PermissionRegex =
|
||||
new Regex(@"^([r-][w-]),([r-][w-]),([r-][w-])$", RegexOptions.Compiled);
|
||||
|
||||
public ResourceService(ILogger<ResourceService> logger, ConfigureService config, IMemoryCache cache,
|
||||
UserService user)
|
||||
{
|
||||
_logger = logger;
|
||||
_config = config;
|
||||
_cache = cache;
|
||||
_user = user;
|
||||
|
||||
_database = new SQLiteAsyncConnection(config.RaDatabase, SQLiteOpenFlags.ReadWrite | SQLiteOpenFlags.Create);
|
||||
_database.CreateTableAsync<ResourceAttribute>().Wait();
|
||||
}
|
||||
|
||||
// Create UID only for resources, without considering advanced hash security such as adding salt
|
||||
private string Uid(string path)
|
||||
{
|
||||
var b = Encoding.UTF8.GetBytes(path);
|
||||
var r = XxHash128.Hash(b, 0x11451419);
|
||||
return Convert.ToBase64String(r ?? []);
|
||||
}
|
||||
|
||||
public async Task<bool> Valid(string path, string token, OperationType type, string ip)
|
||||
{
|
||||
// Path is abs path here, due to Helpers.SafePathCombine
|
||||
if (!path.StartsWith(Path.GetFullPath(_config.MediaRoot), StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
|
||||
path = Path.GetRelativePath(_config.MediaRoot, path);
|
||||
|
||||
string? username = _user.Validate(token, ip);
|
||||
if (username == null)
|
||||
{
|
||||
// No permission granted for invalid tokens
|
||||
_logger.LogError($"Invalid token: {token}");
|
||||
return false;
|
||||
}
|
||||
|
||||
User? user = await _user.QueryUser(username);
|
||||
if (user == null || user.Name != username)
|
||||
{
|
||||
_logger.LogError($"Verification failed: {token}");
|
||||
return false; // Two-factor authentication
|
||||
}
|
||||
|
||||
var parts = path.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
|
||||
.Where(p => !string.IsNullOrEmpty(p))
|
||||
.ToArray();
|
||||
for (int i = 0; i < parts.Length - 1; i++)
|
||||
{
|
||||
var subPath = Path.Combine(parts.Take(i + 1).ToArray());
|
||||
var uidDir = Uid(subPath);
|
||||
var raDir = await _database.Table<ResourceAttribute>().Where(r => r.Uid == uidDir)
|
||||
.FirstOrDefaultAsync();
|
||||
if (raDir == null)
|
||||
{
|
||||
_logger.LogError($"Permission denied: {username} has no read access to parent directory {subPath}");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!await CheckPermission(user, raDir, OperationType.Read))
|
||||
{
|
||||
_logger.LogError($"Permission denied: {username} has no read access to parent directory {subPath}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
var uid = Uid(path);
|
||||
ResourceAttribute? ra = await _database.Table<ResourceAttribute>().Where(r => r.Uid == uid)
|
||||
.FirstOrDefaultAsync();
|
||||
if (ra == null)
|
||||
{
|
||||
_logger.LogError($"Permission check failed: User: {username}, Resource: {path}, Type: {type.ToString()} ");
|
||||
return false;
|
||||
}
|
||||
|
||||
var l = await CheckPermission(user, ra, type);
|
||||
if (!l)
|
||||
{
|
||||
_logger.LogError($"Permission check failed: User: {username}, Resource: {path}, Type: {type.ToString()} ");
|
||||
}
|
||||
|
||||
return l;
|
||||
}
|
||||
|
||||
private async Task<bool> CheckPermission(User? user, ResourceAttribute? ra, OperationType type)
|
||||
{
|
||||
if (user == null || ra == null) return false;
|
||||
|
||||
if(!PermissionRegex.IsMatch(ra.Permission)) return false;
|
||||
|
||||
var perms = ra.Permission.Split(',');
|
||||
if (perms.Length != 3) return false;
|
||||
|
||||
var owner = await _user.QueryUser(ra.Owner);
|
||||
if (owner == null) return false;
|
||||
|
||||
bool isOwner = ra.Owner == user.Name;
|
||||
bool isPeer = !isOwner && user.Privilege == owner.Privilege;
|
||||
bool isOther = !isOwner && !isPeer;
|
||||
|
||||
string currentPerm;
|
||||
if (isOwner) currentPerm = perms[0];
|
||||
else if (isPeer) currentPerm = perms[1];
|
||||
else if (isOther) currentPerm = perms[2];
|
||||
else return false;
|
||||
|
||||
switch (type)
|
||||
{
|
||||
case OperationType.Read:
|
||||
return currentPerm.Contains('r') || (user.Privilege > owner.Privilege);
|
||||
case OperationType.Write:
|
||||
return currentPerm.Contains('w') || (user.Privilege > owner.Privilege);
|
||||
case OperationType.Security:
|
||||
return (isOwner && currentPerm.Contains('w')) || user.Name == "root";
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<string[]?> Query(string path, string token, string ip)
|
||||
{
|
||||
if(!await Valid(path, token, OperationType.Read, ip))
|
||||
return null;
|
||||
|
||||
if (Helpers.GetPathType(path) != PathType.Directory)
|
||||
return null;
|
||||
|
||||
var files = Directory.EnumerateFileSystemEntries(path, "*", SearchOption.TopDirectoryOnly);
|
||||
return files.Select(x => Path.GetRelativePath(path, x)).ToArray();
|
||||
}
|
||||
|
||||
public async Task<bool> Get(string path, string token, string ip)
|
||||
{
|
||||
return await Valid(path, token, OperationType.Read, ip);
|
||||
}
|
||||
|
||||
public async Task<bool> Initialize(string path, string token, string username, string ip)
|
||||
{
|
||||
// 1. Authorization: Verify the operation is performed by 'root'
|
||||
var requester = _user.Validate(token, ip);
|
||||
if (requester != "root")
|
||||
{
|
||||
_logger.LogWarning($"Permission denied: Non-root user '{requester ?? "unknown"}' attempted to initialize resources.");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2. Validation: Ensure the target path and owner are valid
|
||||
if (!Directory.Exists(path))
|
||||
{
|
||||
_logger.LogError($"Initialization failed: Path '{path}' does not exist or is not a directory.");
|
||||
return false;
|
||||
}
|
||||
|
||||
var ownerUser = await _user.QueryUser(username);
|
||||
if (ownerUser == null)
|
||||
{
|
||||
_logger.LogError($"Initialization failed: Owner user '{username}' does not exist.");
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// 3. Traversal: Get the root directory and all its descendants (files and subdirectories)
|
||||
var allPaths = Directory.EnumerateFileSystemEntries(path, "*", SearchOption.AllDirectories).Prepend(path);
|
||||
|
||||
// 4. Filtering: Identify which paths are not yet in the database
|
||||
var newResources = new List<ResourceAttribute>();
|
||||
foreach (var p in allPaths)
|
||||
{
|
||||
var currentPath = Path.GetRelativePath(_config.MediaRoot, p);
|
||||
var uid = Uid(currentPath);
|
||||
var existing = await _database.Table<ResourceAttribute>().Where(r => r.Uid == uid).FirstOrDefaultAsync();
|
||||
|
||||
// If it's not in the database, add it to our list for batch insertion
|
||||
if (existing == null)
|
||||
{
|
||||
newResources.Add(new ResourceAttribute
|
||||
{
|
||||
Uid = uid,
|
||||
Name = currentPath,
|
||||
Owner = username,
|
||||
Permission = "rw,--,--"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Database Insertion: Add all new resources in a single, efficient transaction
|
||||
if (newResources.Any())
|
||||
{
|
||||
await _database.InsertAllAsync(newResources);
|
||||
_logger.LogInformation($"Successfully initialized {newResources.Count} new resources under '{path}' for user '{username}'.");
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation($"No new resources to initialize under '{path}'. All items already exist in the database.");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, $"An error occurred during resource initialization for path '{path}'.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> Put(string path, string token, string ip)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public async Task<bool> Delete(string path, string token, string ip)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public async Task<bool> Chmod(string path, string token, string permission, string ip)
|
||||
{
|
||||
if(!await Valid(path, token, OperationType.Security, ip))
|
||||
return false;
|
||||
|
||||
// Validate the permission format using the existing regex
|
||||
if (!PermissionRegex.IsMatch(permission))
|
||||
{
|
||||
_logger.LogError($"Invalid permission format: {permission}");
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
path = Path.GetRelativePath(_config.MediaRoot, path);
|
||||
var uid = Uid(path);
|
||||
var resource = await _database.Table<ResourceAttribute>().Where(r => r.Uid == uid).FirstOrDefaultAsync();
|
||||
|
||||
if (resource == null)
|
||||
{
|
||||
_logger.LogError($"Resource not found: {path}");
|
||||
return false;
|
||||
}
|
||||
|
||||
resource.Permission = permission;
|
||||
var rowsAffected = await _database.UpdateAsync(resource);
|
||||
|
||||
if (rowsAffected > 0)
|
||||
{
|
||||
_logger.LogInformation($"Successfully changed permissions for '{path}' to '{permission}'");
|
||||
return true;
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogError($"Failed to update permissions for: {path}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, $"Error changing permissions for: {path}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public async Task<bool> Chown(string path, string token, string owner, string ip)
|
||||
{
|
||||
if(!await Valid(path, token, OperationType.Security, ip))
|
||||
return false;
|
||||
|
||||
// Validate that the new owner exists
|
||||
var newOwner = await _user.QueryUser(owner);
|
||||
if (newOwner == null)
|
||||
{
|
||||
_logger.LogError($"New owner '{owner}' does not exist");
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
path = Path.GetRelativePath(_config.MediaRoot, path);
|
||||
var uid = Uid(path);
|
||||
var resource = await _database.Table<ResourceAttribute>().Where(r => r.Uid == uid).FirstOrDefaultAsync();
|
||||
|
||||
if (resource == null)
|
||||
{
|
||||
_logger.LogError($"Resource not found: {path}");
|
||||
return false;
|
||||
}
|
||||
|
||||
resource.Owner = owner;
|
||||
var rowsAffected = await _database.UpdateAsync(resource);
|
||||
|
||||
if (rowsAffected > 0)
|
||||
{
|
||||
_logger.LogInformation($"Successfully changed ownership of '{path}' to '{owner}'");
|
||||
return true;
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogError($"Failed to change ownership for: {path}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, $"Error changing ownership for: {path}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
177
Abyss/Components/Services/UserService.cs
Normal file
177
Abyss/Components/Services/UserService.cs
Normal file
@@ -0,0 +1,177 @@
|
||||
|
||||
// UserService.cs
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Abyss.Model;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using NSec.Cryptography;
|
||||
using SQLite;
|
||||
|
||||
namespace Abyss.Components.Services;
|
||||
|
||||
public class UserService
|
||||
{
|
||||
private readonly ILogger<UserService> _logger;
|
||||
private readonly ConfigureService _config;
|
||||
private readonly IMemoryCache _cache;
|
||||
private readonly SQLiteAsyncConnection _database;
|
||||
|
||||
public UserService(ILogger<UserService> logger, ConfigureService config, IMemoryCache cache)
|
||||
{
|
||||
_logger = logger;
|
||||
_config = config;
|
||||
_cache = cache;
|
||||
|
||||
_database = new SQLiteAsyncConnection(config.UserDatabase, SQLiteOpenFlags.ReadWrite | SQLiteOpenFlags.Create);
|
||||
_database.CreateTableAsync<User>().Wait();
|
||||
var rootUser = _database.Table<User>().Where(x => x.Name == "root").FirstOrDefaultAsync().Result;
|
||||
|
||||
if (rootUser == null)
|
||||
{
|
||||
var key = GenerateKeyPair();
|
||||
string privateKeyBase64 = Convert.ToBase64String(key.Export(KeyBlobFormat.RawPrivateKey));
|
||||
string publicKeyBase64 = Convert.ToBase64String(key.Export(KeyBlobFormat.RawPublicKey));
|
||||
|
||||
var s = GenerateRandomAsciiString(8);
|
||||
Console.WriteLine($"Enter the following string to create a root user: '{s}'");
|
||||
|
||||
if (Console.ReadLine() != s)
|
||||
{
|
||||
throw (new Exception("Invalid Input"));
|
||||
}
|
||||
|
||||
Console.WriteLine($"Created root user. Please keep the key safe.");
|
||||
Console.WriteLine("key: '" + privateKeyBase64 + "'");
|
||||
_database.InsertAsync(new User()
|
||||
{
|
||||
Name = "root",
|
||||
Parent = "root",
|
||||
PublicKey = publicKeyBase64,
|
||||
Privilege = 1145141919,
|
||||
}).Wait();
|
||||
|
||||
Console.ReadKey();
|
||||
}
|
||||
}
|
||||
public async Task<string?> Challenge(string user)
|
||||
{
|
||||
var u = await _database.Table<User>().Where(x => x.Name == user).FirstOrDefaultAsync();
|
||||
|
||||
if (u == null) // Error: User not exists
|
||||
return null;
|
||||
if (_cache.TryGetValue(u.Name, out var challenge)) // The previous challenge has not yet expired
|
||||
_cache.Remove(u.Name);
|
||||
|
||||
var c = Convert.ToBase64String(Encoding.UTF8.GetBytes(GenerateRandomAsciiString(32)));
|
||||
_cache.Set(u.Name,c, DateTimeOffset.Now.AddMinutes(1));
|
||||
return c;
|
||||
}
|
||||
|
||||
// The challenge source and response source are not necessarily required to be the same,
|
||||
// but the source that obtains the token must be the same as the source that uses the token in the future
|
||||
public async Task<string?> Verify(string user, string response, string ip)
|
||||
{
|
||||
var u = await _database.Table<User>().Where(x => x.Name == user).FirstOrDefaultAsync();
|
||||
if (u == null) // Error: User not exists
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (_cache.TryGetValue(u.Name, out string? challenge))
|
||||
{
|
||||
bool isVerified = VerifySignature(
|
||||
PublicKey.Import(
|
||||
SignatureAlgorithm.Ed25519,
|
||||
Convert.FromBase64String(u.PublicKey),
|
||||
KeyBlobFormat.RawPublicKey),
|
||||
Convert.FromBase64String(challenge ?? ""),
|
||||
Convert.FromBase64String(response));
|
||||
|
||||
if (!isVerified)
|
||||
{
|
||||
// Verification failed, set the challenge string to random to prevent duplicate verification
|
||||
_cache.Set(u.Name, $"failed : {GenerateRandomAsciiString(32)}", DateTimeOffset.Now.AddMinutes(1));
|
||||
return null;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Remove the challenge string and create a session
|
||||
_cache.Remove(u.Name);
|
||||
var s = GenerateRandomAsciiString(64);
|
||||
_cache.Set(s, $"{u.Name}@{ip}", DateTimeOffset.Now.AddDays(1));
|
||||
_logger.LogInformation($"Verified {u.Name}@{ip}");
|
||||
return s;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public string? Validate(string token, string ip)
|
||||
{
|
||||
if (_cache.TryGetValue(token, out string? userAndIp))
|
||||
{
|
||||
if (ip != userAndIp?.Split('@')[1])
|
||||
{
|
||||
_logger.LogError($"Token used from another Host: {token}");
|
||||
Destroy(token);
|
||||
return null;
|
||||
}
|
||||
_logger.LogInformation($"Validated {userAndIp}");
|
||||
return userAndIp?.Split('@')[0];
|
||||
}
|
||||
_logger.LogWarning($"Validation failed {token}");
|
||||
return null;
|
||||
}
|
||||
|
||||
public void Destroy(string token)
|
||||
{
|
||||
_cache.Remove(token);
|
||||
}
|
||||
|
||||
public async Task<User?> QueryUser(string user)
|
||||
{
|
||||
var u = await _database.Table<User>().Where(x => x.Name == user).FirstOrDefaultAsync();
|
||||
return u;
|
||||
}
|
||||
|
||||
public async Task CreateUser(User user)
|
||||
{
|
||||
await _database.InsertAsync(user);
|
||||
_logger.LogInformation($"Created user: {user.Name}, Parent: {user.Parent}, Privilege: {user.Privilege}");
|
||||
}
|
||||
|
||||
static Key GenerateKeyPair()
|
||||
{
|
||||
var algorithm = SignatureAlgorithm.Ed25519;
|
||||
var creationParameters = new KeyCreationParameters
|
||||
{
|
||||
ExportPolicy = KeyExportPolicies.AllowPlaintextExport
|
||||
};
|
||||
return Key.Create(algorithm, creationParameters);
|
||||
}
|
||||
|
||||
public static string GenerateRandomAsciiString(int length)
|
||||
{
|
||||
const string asciiChars = "-0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
||||
|
||||
using (var rng = RandomNumberGenerator.Create())
|
||||
{
|
||||
byte[] randomBytes = new byte[length];
|
||||
rng.GetBytes(randomBytes);
|
||||
|
||||
char[] result = new char[length];
|
||||
for (int i = 0; i < length; i++)
|
||||
{
|
||||
result[i] = asciiChars[randomBytes[i] % asciiChars.Length];
|
||||
}
|
||||
return new string(result);
|
||||
}
|
||||
}
|
||||
|
||||
static bool VerifySignature(PublicKey publicKey, byte[] data, byte[] signature)
|
||||
{
|
||||
var algorithm = SignatureAlgorithm.Ed25519;
|
||||
return algorithm.Verify(publicKey, data, signature);
|
||||
}
|
||||
}
|
||||
60
Abyss/Components/Static/Helpers.cs
Normal file
60
Abyss/Components/Static/Helpers.cs
Normal file
@@ -0,0 +1,60 @@
|
||||
|
||||
namespace Abyss.Components.Static;
|
||||
|
||||
public static class Helpers
|
||||
{
|
||||
public static string? SafePathCombine(string basePath, params string[] pathParts)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(basePath))
|
||||
return null;
|
||||
|
||||
if (basePath.Contains("..") || pathParts.Any(p => p.Contains("..")))
|
||||
return null;
|
||||
|
||||
string combinedPath = Path.Combine(basePath, Path.Combine(pathParts));
|
||||
string fullPath = Path.GetFullPath(combinedPath);
|
||||
|
||||
if (!fullPath.StartsWith(Path.GetFullPath(basePath), StringComparison.OrdinalIgnoreCase))
|
||||
return null;
|
||||
|
||||
return fullPath;
|
||||
}
|
||||
|
||||
public static PathType GetPathType(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
var attributes = File.GetAttributes(path);
|
||||
if ((attributes & FileAttributes.Directory) == FileAttributes.Directory)
|
||||
{
|
||||
return PathType.Directory;
|
||||
}
|
||||
else
|
||||
{
|
||||
return PathType.File;
|
||||
}
|
||||
}
|
||||
catch (FileNotFoundException)
|
||||
{
|
||||
return PathType.NotFound;
|
||||
}
|
||||
catch (DirectoryNotFoundException)
|
||||
{
|
||||
return PathType.NotFound;
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return PathType.AccessDenied;
|
||||
}
|
||||
|
||||
return PathType.NotFound;
|
||||
}
|
||||
}
|
||||
|
||||
public enum PathType
|
||||
{
|
||||
File,
|
||||
Directory,
|
||||
NotFound,
|
||||
AccessDenied
|
||||
}
|
||||
6
Abyss/Model/ChallengeResponse.cs
Normal file
6
Abyss/Model/ChallengeResponse.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace Abyss.Model;
|
||||
|
||||
public class ChallengeResponse
|
||||
{
|
||||
public string Response { get; set; } = "";
|
||||
}
|
||||
9
Abyss/Model/ResourceAttribute.cs
Normal file
9
Abyss/Model/ResourceAttribute.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace Abyss.Model;
|
||||
|
||||
public class ResourceAttribute
|
||||
{
|
||||
public string Uid { get; set; } = "@";
|
||||
public string Name { get; set; } = "@";
|
||||
public string Owner { get; set; } = "@";
|
||||
public string Permission { get; set; } = "--,--,--";
|
||||
}
|
||||
9
Abyss/Model/User.cs
Normal file
9
Abyss/Model/User.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace Abyss.Model;
|
||||
|
||||
public class User
|
||||
{
|
||||
public string Name { get; set; } = "";
|
||||
public string Parent { get; set; } = "";
|
||||
public string PublicKey { get; set; } = "";
|
||||
public int Privilege { get; set; }
|
||||
}
|
||||
10
Abyss/Model/UserCreating.cs
Normal file
10
Abyss/Model/UserCreating.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace Abyss.Model;
|
||||
|
||||
public class UserCreating
|
||||
{
|
||||
public string Response { get; set; } = "";
|
||||
public string Name { get; set; } = "";
|
||||
public string Parent { get; set; } = "";
|
||||
public string PublicKey { get; set; } = "";
|
||||
public int Privilege { get; set; }
|
||||
}
|
||||
57
Abyss/Program.cs
Normal file
57
Abyss/Program.cs
Normal file
@@ -0,0 +1,57 @@
|
||||
using System.Threading.RateLimiting;
|
||||
using Abyss.Components.Services;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
|
||||
namespace Abyss;
|
||||
|
||||
public class Program
|
||||
{
|
||||
public static void Main(string[] args)
|
||||
{
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Services.AddAuthorization();
|
||||
builder.Services.AddMemoryCache();
|
||||
builder.Services.AddOpenApi();
|
||||
builder.Services.AddControllers();
|
||||
builder.Services.AddSingleton<ConfigureService>();
|
||||
builder.Services.AddSingleton<UserService>();
|
||||
builder.Services.AddSingleton<ResourceService>();
|
||||
|
||||
builder.Services.AddRateLimiter(options =>
|
||||
{
|
||||
options.AddFixedWindowLimiter("Fixed", policyOptions =>
|
||||
{
|
||||
// 时间窗口长度
|
||||
policyOptions.Window = TimeSpan.FromSeconds(30);
|
||||
policyOptions.PermitLimit = 10;
|
||||
policyOptions.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
|
||||
policyOptions.QueueLimit = 0;
|
||||
});
|
||||
|
||||
options.OnRejected = async (context, token) =>
|
||||
{
|
||||
context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
|
||||
await context.HttpContext.Response.WriteAsync("Too many requests. Please try later.", token);
|
||||
};
|
||||
});
|
||||
|
||||
builder.Services.BuildServiceProvider().GetRequiredService<UserService>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.MapOpenApi();
|
||||
}
|
||||
|
||||
// app.UseHttpsRedirection();
|
||||
|
||||
app.UseAuthorization();
|
||||
app.MapStaticAssets();
|
||||
app.MapControllers();
|
||||
|
||||
app.UseRateLimiter();
|
||||
app.Run();
|
||||
}
|
||||
}
|
||||
24
Abyss/Properties/launchSettings.json
Normal file
24
Abyss/Properties/launchSettings.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "http://192.168.1.244:5198",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development",
|
||||
"MEDIA_ROOT" : "/storage"
|
||||
}
|
||||
},
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "https://localhost:7013;http://localhost:5198",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
8
Abyss/appsettings.Development.json
Normal file
8
Abyss/appsettings.Development.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
9
Abyss/appsettings.json
Normal file
9
Abyss/appsettings.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
Reference in New Issue
Block a user