Compare commits

8 Commits

Author SHA1 Message Date
acite
8465ec5b2a [doc] Icon& Doc 2025-09-14 23:19:16 +08:00
acite
ec7306ade2 [doc] Description Updated 2025-09-14 14:34:16 +08:00
acite
9aa987d52a [optimize] merge network write 2025-09-14 00:52:08 +08:00
acite
e174238d3c [feat] Abyss Protocol authentication 2025-09-13 17:01:12 +08:00
acite
cad92f8fa5 [fix] No tags in Comic Class 2025-09-13 14:46:45 +08:00
acite
9b6a4a9982 [fix] Bulk query permission 2025-09-13 14:31:59 +08:00
acite
197cf525fb [feat] Bulk query 2025-09-13 13:06:55 +08:00
acite
ae93b75e41 [merge] Merge branch 'dev-abyss' 2025-09-13 00:37:21 +08:00
12 changed files with 301 additions and 31 deletions

View File

@@ -11,7 +11,7 @@
<component name="ChangeListManager">
<list default="true" id="bf317275-3039-49bb-a475-725a800a0cce" name="Changes" comment="">
<change beforePath="$PROJECT_DIR$/.idea/.idea.Abyss/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/.idea.Abyss/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/Abyss/Components/Services/UserService.cs" beforeDir="false" afterPath="$PROJECT_DIR$/Abyss/Components/Services/UserService.cs" afterDir="false" />
<change beforePath="$PROJECT_DIR$/README.md" beforeDir="false" afterPath="$PROJECT_DIR$/README.md" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
@@ -85,8 +85,8 @@
&quot;RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252&quot;: &quot;true&quot;,
&quot;RunOnceActivity.git.unshallow&quot;: &quot;true&quot;,
&quot;XThreadsFramesViewSplitterKey&quot;: &quot;0.30266345&quot;,
&quot;git-widget-placeholder&quot;: &quot;dev-abyss&quot;,
&quot;last_opened_file_path&quot;: &quot;/storage/Images/31/summary.json&quot;,
&quot;git-widget-placeholder&quot;: &quot;main&quot;,
&quot;last_opened_file_path&quot;: &quot;/home/acite/embd/WebProjects/Abyss/README.md&quot;,
&quot;node.js.detected.package.eslint&quot;: &quot;true&quot;,
&quot;node.js.detected.package.tslint&quot;: &quot;true&quot;,
&quot;node.js.selected.package.eslint&quot;: &quot;(autodetect)&quot;,
@@ -201,6 +201,15 @@
<workItem from="1757687641035" duration="2969000" />
<workItem from="1757693751836" duration="667000" />
<workItem from="1757694833696" duration="11000" />
<workItem from="1757695721386" duration="749000" />
<workItem from="1757702942841" duration="32000" />
<workItem from="1757735249561" duration="5523000" />
<workItem from="1757742881713" duration="2285000" />
<workItem from="1757745929389" duration="93000" />
<workItem from="1757751423586" duration="2687000" />
<workItem from="1757782027930" duration="308000" />
<workItem from="1757830765557" duration="1218000" />
<workItem from="1757862781213" duration="341000" />
</task>
<servers />
</component>

View File

@@ -9,6 +9,8 @@ using Newtonsoft.Json.Linq;
namespace Abyss.Components.Controllers.Media;
using System.IO;
using Task = System.Threading.Tasks.Task;
[ApiController]
[Route("api/[controller]")]
public class ImageController(ILogger<ImageController> logger, ResourceService rs, ConfigureService config) : BaseController
@@ -46,6 +48,25 @@ public class ImageController(ILogger<ImageController> logger, ResourceService rs
return Ok(await System.IO.File.ReadAllTextAsync(d));
}
[HttpPost("bulkquery")]
public async Task<IActionResult> QueryBulk([FromQuery] string token, [FromBody] string[] id)
{
List<string> result = new List<string>();
var db = id.Select(x => Helpers.SafePathCombine(ImageFolder, [x, "summary.json"])).ToArray();
if (db.Any(x => x == null))
return BadRequest();
if(!await rs.GetAll(db!, token, Ip))
return StatusCode(403, new { message = "403 Denied" });
var rc = db.Select(x => System.IO.File.ReadAllTextAsync(x!)).ToArray();
string[] rcs = await Task.WhenAll(rc);
var rjs = rcs.Select(JsonConvert.DeserializeObject<Comic>).Select(x => x!).ToArray();
return Ok(JsonConvert.SerializeObject(rjs));
}
[HttpPost("{id}/bookmark")]
public async Task<IActionResult> Bookmark(string id, string token, [FromBody] Bookmark bookmark)
{

View File

@@ -8,6 +8,8 @@ using Newtonsoft.Json;
namespace Abyss.Components.Controllers.Media;
using Task = System.Threading.Tasks.Task;
[ApiController]
[Route("api/[controller]")]
public class VideoController(ILogger<VideoController> logger, ResourceService rs, ConfigureService config) : BaseController
@@ -73,6 +75,25 @@ public class VideoController(ILogger<VideoController> logger, ResourceService rs
return Ok(await System.IO.File.ReadAllTextAsync(d));
}
[HttpPost("{klass}/bulkquery")]
public async Task<IActionResult> QueryBulk([FromQuery] string token, [FromBody] string[] id, [FromRoute] string klass)
{
List<string> result = new List<string>();
var db = id.Select(x => Helpers.SafePathCombine(VideoFolder, [klass, x, "summary.json"])).ToArray();
if(db.Any(x => x == null))
return BadRequest();
if(!await rs.GetAll(db!, token, Ip))
return StatusCode(403, new { message = "403 Denied" });
var rc = db.Select(x => System.IO.File.ReadAllTextAsync(x!)).ToArray();
string[] rcs = await Task.WhenAll(rc);
var rjs = rcs.Select(JsonConvert.DeserializeObject<Video>).Select(x => x!).ToList();
return Ok(JsonConvert.SerializeObject(rjs));
}
[HttpGet("{klass}/{id}/cover")]
public async Task<IActionResult> Cover(string klass, string id, string token)
{
@@ -82,8 +103,6 @@ public class VideoController(ILogger<VideoController> logger, ResourceService rs
var r = await rs.Get(d, token, Ip);
if (!r) return StatusCode(403, new { message = "403 Denied" });
_logger.LogInformation($"Cover found for {id}");
return PhysicalFile(d, "image/jpeg", enableRangeProcessing: true);
}

View File

@@ -5,7 +5,7 @@ using Abyss.Components.Tools;
namespace Abyss.Components.Services;
public class AbyssService(ILogger<AbyssService> logger, ConfigureService config) : IHostedService, IDisposable
public class AbyssService(ILogger<AbyssService> logger, ConfigureService config, UserService user) : IHostedService, IDisposable
{
private Task? _executingTask;
private CancellationTokenSource? _cts;
@@ -41,6 +41,7 @@ public class AbyssService(ILogger<AbyssService> logger, ConfigureService config)
int bytesRead = await upstream.ReadAsync(buffer, 0, buffer.Length, token);
if (bytesRead == 0)
break;
await client.WriteAsync(buffer, 0, bytesRead, token);
}
});
@@ -51,10 +52,9 @@ public class AbyssService(ILogger<AbyssService> logger, ConfigureService config)
private async Task ClientHandlerAsync(TcpClient client, CancellationToken cancellationToken)
{
var stream = await client.GetAbyssStreamAsync(ct: cancellationToken);
try
{
await using var stream = await client.GetAbyssStreamAsync(ct: cancellationToken, us: user);
var request = HttpHelper.Parse(await HttpReader.ReadHttpMessageAsync(stream, cancellationToken));
var port = 80;
var sp = request.RequestUri?.ToString().Split(':') ?? [];
@@ -136,7 +136,6 @@ public class AbyssService(ILogger<AbyssService> logger, ConfigureService config)
}
finally
{
stream.Close();
client.Close();
client.Dispose();
}

View File

@@ -44,7 +44,7 @@ public class ResourceService
{
InsertRaRow(tasksPath, "root", "rw,r-,r-", true).Wait();
}
var livePath = Helpers.SafePathCombine(_config.MediaRoot, "Live");
if (livePath != null)
{
@@ -60,6 +60,121 @@ public class ResourceService
return Convert.ToBase64String(r ?? []);
}
public async Task<bool> ValidAll(string[] paths, string token, OperationType type, string ip)
{
if (paths == null || paths.Length == 0)
{
_logger.LogError("ValidAll called with empty path set");
return false;
}
var mediaRootFull = Path.GetFullPath(_config.MediaRoot);
// 1. basic path checks & normalize to relative
var relPaths = new List<string>(paths.Length);
foreach (var p in paths)
{
if (p == null || !p.StartsWith(mediaRootFull, StringComparison.OrdinalIgnoreCase))
{
_logger.LogError($"Path outside media root or null: {p}");
return false;
}
relPaths.Add(Path.GetRelativePath(_config.MediaRoot, Path.GetFullPath(p)));
}
// 2. validate token and user once
string? username = _user.Validate(token, ip);
if (username == null)
{
_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;
}
// 3. build uid -> required ops map (avoid duplicate Uid calculations)
var uidToOps = new Dictionary<string, HashSet<OperationType>>(StringComparer.OrdinalIgnoreCase);
foreach (var rel in relPaths)
{
var parts = rel
.Split(new[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar },
StringSplitOptions.RemoveEmptyEntries)
.Where(s => !string.IsNullOrEmpty(s))
.ToArray();
// parents (each prefix) require Read
for (int i = 0; i < parts.Length - 1; i++)
{
var subPath = Path.Combine(parts.Take(i + 1).ToArray());
var uidDir = Uid(subPath);
if (!uidToOps.TryGetValue(uidDir, out var ops))
{
ops = new HashSet<OperationType>();
uidToOps[uidDir] = ops;
}
ops.Add(OperationType.Read);
}
// resource itself requires requested 'type'
var resourcePath = (parts.Length == 0) ? string.Empty : Path.Combine(parts);
var uidRes = Uid(resourcePath);
if (!uidToOps.TryGetValue(uidRes, out var resOps))
{
resOps = new HashSet<OperationType>();
uidToOps[uidRes] = resOps;
}
resOps.Add(type);
}
// 4. batch query DB for all UIDs
var uidsNeeded = uidToOps.Keys.ToList();
var rasList = await _database.Table<ResourceAttribute>()
.Where(r => uidsNeeded.Contains(r.Uid))
.ToListAsync();
var raDict = rasList.ToDictionary(r => r.Uid, StringComparer.OrdinalIgnoreCase);
// 5. check each uid once per required operation (cache results per uid+op)
var permCache = new Dictionary<(string uid, OperationType op), bool>(); // avoid repeated CheckPermission
foreach (var kv in uidToOps)
{
var uid = kv.Key;
if (!raDict.TryGetValue(uid, out var ra) || ra == null)
{
// find an example path string for logging would require reverse map; keep uid for clarity
_logger.LogError($"Permission check failed (missing resource attribute): User: {username}, Uid: {uid}");
return false;
}
foreach (var op in kv.Value)
{
var key = (uid, op);
if (!permCache.TryGetValue(key, out var ok))
{
ok = await CheckPermission(user, ra, op);
permCache[key] = ok;
}
if (!ok)
{
_logger.LogError($"Permission check failed: User: {username}, Uid: {uid}, Type: {op}");
return false;
}
}
}
return true;
}
public async Task<bool> Valid(string path, string token, OperationType type, string ip)
{
// Path is abs path here, due to Helpers.SafePathCombine
@@ -175,6 +290,11 @@ public class ResourceService
return await Valid(path, token, OperationType.Read, ip);
}
public async Task<bool> GetAll(string[] path, string token, string ip)
{
return await ValidAll(path, token, OperationType.Read, ip);
}
public async Task<bool> Update(string path, string token, string ip)
{
return await Valid(path, token, OperationType.Write, ip);
@@ -267,13 +387,14 @@ public class ResourceService
{
throw new NotImplementedException();
}
public async Task<bool> Exclude(string path, string token, string ip)
{
var requester = _user.Validate(token, ip);
if (requester != "root")
{
_logger.LogWarning($"Permission denied: Non-root user '{requester ?? "unknown"}' attempted to exclude resource '{path}'.");
_logger.LogWarning(
$"Permission denied: Non-root user '{requester ?? "unknown"}' attempted to exclude resource '{path}'.");
return false;
}

View File

@@ -174,11 +174,38 @@ public class UserService
}
}
static bool VerifySignature(PublicKey publicKey, byte[] data, byte[] signature)
public static bool VerifySignature(PublicKey publicKey, byte[] data, byte[] signature)
{
var algorithm = SignatureAlgorithm.Ed25519;
return algorithm.Verify(publicKey, data, signature);
}
public async Task<bool> VerifyAny(byte[] data, byte[] signature)
{
var users = await _database.Table<User>().ToListAsync();
foreach (var u in users)
{
try
{
var pubKeyBytes = Convert.FromBase64String(u.PublicKey);
var pubKey = PublicKey.Import(
SignatureAlgorithm.Ed25519,
pubKeyBytes,
KeyBlobFormat.RawPublicKey);
if (VerifySignature(pubKey, data, signature))
{
_logger.LogInformation($"Signature verified using user {u.Name}");
return true;
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, $"Failed to import public key for {u.Name}");
}
}
return false;
}
public string CreateToken(string user, string ip, TimeSpan lifetime)
{

View File

@@ -5,17 +5,21 @@
using System.Buffers;
using System.Buffers.Binary;
using System.Collections.Concurrent;
using System.Data;
using System.Net.Sockets;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Text;
using Abyss.Components.Services;
using Microsoft.AspNetCore.Authentication;
using NSec.Cryptography;
using ChaCha20Poly1305 = System.Security.Cryptography.ChaCha20Poly1305;
namespace Abyss.Components.Tools
{
// TODO: (complete) Since C25519 has already been used for user authentication,
// TODO: (complete) why not use that public key to verify user identity when establishing a secure channel here?
public sealed class AbyssStream : NetworkStream, IDisposable
{
private const int PublicKeyLength = 32;
@@ -61,7 +65,7 @@ namespace Abyss.Components.Tools
/// Handshake: X25519 public exchange (raw) -> shared secret -> HKDF -> AEAD key + saltA + saltB
/// send/recv salts are assigned deterministically by lexicographic comparison of raw public keys.
/// </summary>
public static async Task<AbyssStream> CreateAsync(TcpClient client, byte[]? privateKeyRaw = null, CancellationToken cancellationToken = default)
public static async Task<AbyssStream> CreateAsync(TcpClient client, UserService us, byte[]? privateKeyRaw = null, CancellationToken cancellationToken = default)
{
if (client == null) throw new ArgumentNullException(nameof(client));
var socket = client.Client ?? throw new ArgumentException("TcpClient has no underlying socket");
@@ -102,6 +106,27 @@ namespace Abyss.Components.Tools
await ReadExactFromSocketAsync(socket, remotePublic, 0, PublicKeyLength, cancellationToken).ConfigureAwait(false);
var ch = Encoding.UTF8.GetBytes(UserService.GenerateRandomAsciiString(32));
sent = 0;
while (sent < ch.Length)
{
var toSend = new ReadOnlyMemory<byte>(ch, sent, ch.Length - sent);
sent += await socket.SendAsync(toSend, SocketFlags.None, cancellationToken).ConfigureAwait(false);
}
var rch = new byte[64];
await ReadExactFromSocketAsync(socket, rch, 0, 64, cancellationToken).ConfigureAwait(false);
bool rau = await us.VerifyAny(ch, rch);
if (!rau) throw new AuthenticationFailureException("");
var ack = Encoding.UTF8.GetBytes(UserService.GenerateRandomAsciiString(16));
sent = 0;
while (sent < ack.Length)
{
var toSend = new ReadOnlyMemory<byte>(ack, sent, ack.Length - sent);
sent += await socket.SendAsync(toSend, SocketFlags.None, cancellationToken).ConfigureAwait(false);
}
// 3) Compute shared secret (X25519)
PublicKey remotePub;
try
@@ -394,18 +419,21 @@ namespace Abyss.Components.Tools
}
var payloadLen = unchecked((uint)(ciphertext.Length + tag.Length));
var header = new byte[4];
BinaryPrimitives.WriteUInt32BigEndian(header, payloadLen);
await base.WriteAsync(header, 0, header.Length, cancellationToken).ConfigureAwait(false);
var packet = new byte[4 + payloadLen];
BinaryPrimitives.WriteUInt32BigEndian(packet.AsSpan(0, 4), payloadLen);
if (ciphertext.Length > 0)
await base.WriteAsync(ciphertext, 0, ciphertext.Length, cancellationToken).ConfigureAwait(false);
await base.WriteAsync(tag, 0, tag.Length, cancellationToken).ConfigureAwait(false);
ciphertext.CopyTo(packet.AsSpan(4));
tag.CopyTo(packet.AsSpan(4 + ciphertext.Length));
await base.WriteAsync(packet, 0, packet.Length, cancellationToken).ConfigureAwait(false);
await base.FlushAsync(cancellationToken).ConfigureAwait(false);
Array.Clear(nonce, 0, nonce.Length);
Array.Clear(tag, 0, tag.Length);
Array.Clear(ciphertext, 0, ciphertext.Length);
Array.Clear(packet, 0, packet.Length);
}
protected override void Dispose(bool disposing)
@@ -518,7 +546,7 @@ namespace Abyss.Components.Tools
public static class TcpClientAbyssExtensions
{
public static Task<AbyssStream> GetAbyssStreamAsync(this TcpClient client, byte[]? privateKeyRaw = null, CancellationToken ct = default)
=> AbyssStream.CreateAsync(client, privateKeyRaw, ct);
public static Task<AbyssStream> GetAbyssStreamAsync(this TcpClient client, UserService us, byte[]? privateKeyRaw = null, CancellationToken ct = default)
=> AbyssStream.CreateAsync(client, us, privateKeyRaw, ct);
}
}

View File

@@ -11,7 +11,9 @@ public class Comic
[JsonProperty("bookmarks")]
public List<Bookmark> Bookmarks { get; set; } = new();
[JsonProperty("author")]
public string Author { get; set; } = "";
public string Author { get; set; } = "";
[JsonProperty("tags")]
public List<string> Tags { get; set; } = new();
[JsonProperty("list")]
public List<string> List { get; set; } = new();
}

View File

@@ -9,7 +9,8 @@
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"MEDIA_ROOT" : "/storage",
"ALLOWED_PORTS" : "3000"
"ALLOWED_PORTS" : "3000",
"DEBUG_MODE": "Debug"
}
}
}

View File

@@ -1,16 +1,59 @@
_<div align="center">
<div align="center">
# Abyss (Server for Aether)
[![Plugin Version](https://img.shields.io/badge/Alpha-v0.1-red.svg?style=for-the-badge&color=76bad9)](https://github.com/rootacite/Abyss)
_🚀This is the server of the multimedia application Aether, which can also be extended to other purposes🚀_
🚀This is the server of the multimedia application Aether, which can also be extended to other purposes🚀
<img src="abyss_clip.png" width="25%" alt="Logo">
</div>
<br/>
<br/>
<br/>
---
## Description
**Abyss** is a modern, self-hosted media server and secure proxy platform built with **.NET 9**. It is designed to provide a highly secure, extensible, and efficient solution for managing and streaming media content (images, videos, live streams) while enforcing fine-grained access control and cryptographic authentication.
### 🎯 Key Features
- **Media Management**: Organize and serve images, videos, and live streams with structured directory support.
- **User Authentication**: Challenge-response authentication using **Ed25519** signatures. No private keys are ever transmitted.
- **Role-Based Access Control (RBAC)**: Hierarchical user privileges with configurable permissions for resources.
- **Secure Proxy**: Built-in HTTP/S proxy with end-to-end encrypted tunneling using **X25519** key exchange and **ChaCha20-Poly1305** encryption.
- **Resource-Level Permissions**: Fine-grained control over files and directories using a SQLite-based attribute system.
- **Task System**: Support for background tasks such as media uploads and processing with chunk-based integrity validation.
- **RESTful API**: Fully documented API endpoints for media access, user management, and task control.
### 🛠️ Technology Stack
- **Backend**: ASP.NET Core 9, MVC, Dependency Injection
- **Database**: SQLite with async ORM support
- **Cryptography**: NSec.Cryptography (Ed25519, X25519), ChaCha20Poly1305
- **Media Handling**: Range requests, MIME type detection, chunked uploads
- **Security**: Rate limiting, IP binding, token expiration, secure headers
### 🔐 Security Highlights
- Zero-trust architecture: All requests require valid tokens bound to IP addresses.
- No plaintext private key transmission.
- All media and metadata access is validated against a permission database.
- Secure tunneling with forward secrecy via ephemeral key exchange.
### 📦 Use Cases
- Personal media library with access control
- Secure internal video streaming platform
- Proxy server with authenticated tunneling
- Task-driven media processing pipeline
### 🌱 Extensibility
Abyss is designed with modularity in mind. Its service-based architecture allows easy integration of new media types, authentication providers, or storage backends.
---
## Development environment
@@ -326,4 +369,4 @@ These endpoints provide access to static image resources. A valid token is requi
- [ ] Add P/D method to all controllers to achieve dynamic modification of media items
- [x] Implement identity management module
- [ ] Add a description of the media library directory structure in the READMD document
- [x] Add API interface instructions in the READMD document_
- [x] Add API interface instructions in the READMD document

BIN
abyss.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

BIN
abyss_clip.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 510 KiB