Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
name: CI

on:
push:
branches: [main]
pull_request:
workflow_dispatch:

permissions:
contents: read

jobs:
test:
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v7
- uses: actions/setup-dotnet@v5
with:
dotnet-version: '10.0.x'
- run: dotnet test tests/MyNotes.Tests/MyNotes.Tests.csproj --configuration Release
- run: dotnet publish MyNotes.csproj --configuration Release --output publish
4 changes: 2 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
# Build output
bin/
obj/
data/static-cache/
data/
releases/

# Local IDE/editor state
.vs/
Expand All @@ -21,5 +22,4 @@ appsettings.json
appsettings.*.json
!appsettings.example.json

docs/
wip.md
16 changes: 16 additions & 0 deletions Config/ServerConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ public static class ServerConfig
{
public static int GrpcPort { get; private set; } = 9831;
public static int HttpPort { get; private set; } = 9832;
public static string CertificatePath { get; private set; } = "";
public static string CertificatePassword { get; private set; } = "";
public static string MasterVersion { get; private set; } = "9c23d88a191e4648f0a28f4bbd4edb6403d5adf99ac5793efd7cc1cb3d49104c";
public static string InitialDataGroup { get; private set; } = "cbt_0.1.8_";
public static IReadOnlyList<long> InitialStampIds { get; private set; } = [];
Expand All @@ -15,11 +17,17 @@ public static class ServerConfig
public static int OfficialAssetRequestTimeoutSeconds { get; private set; } = 30;
public static bool StaticAssetCacheEnabled { get; private set; }
public static string StaticAssetCacheDirectory { get; private set; } = "data/static-cache";
public static string StaticAssetLocalDirectory { get; private set; } = "";
public static bool StaticAssetUpstreamEnabled { get; private set; } = true;
public static string ResourceReleaseManifest { get; private set; } = "";
public static string ResourceVersion { get; private set; } = "";

public static void Load(IConfiguration configuration)
{
GrpcPort = configuration.GetValue("Server:GrpcPort", configuration.GetValue("Server:Port", GrpcPort));
HttpPort = configuration.GetValue("Server:HttpPort", HttpPort);
CertificatePath = configuration.GetValue("Server:CertificatePath", "") ?? "";
CertificatePassword = configuration.GetValue("Server:CertificatePassword", "") ?? "";
MasterVersion = configuration.GetValue("Game:MasterVersion", MasterVersion) ?? MasterVersion;
InitialDataGroup = configuration.GetValue("Game:InitialDataGroup", InitialDataGroup) ?? InitialDataGroup;
InitialStampIds = configuration.GetSection("Game:InitialStampIds").Get<long[]>()?
Expand All @@ -41,6 +49,14 @@ public static void Load(IConfiguration configuration)
StaticAssetCacheDirectory = configuration.GetValue(
"Static:CacheDirectory",
StaticAssetCacheDirectory) ?? StaticAssetCacheDirectory;
StaticAssetLocalDirectory = configuration.GetValue("Static:LocalDirectory", "") ?? "";
StaticAssetUpstreamEnabled = configuration.GetValue("Static:UpstreamEnabled", true);
ResourceReleaseManifest = configuration.GetValue("Resources:ReleaseManifest", "") ?? "";
ResourceVersion = configuration.GetValue("Resources:ResourceVersion", "") ?? "";
if (OfficialAssetRequestTimeoutSeconds <= 0)
throw new InvalidOperationException("Static:OfficialAssetRequestTimeoutSeconds must be positive");
if (RequireBasicAuth && !HasBasicAuth)
throw new InvalidOperationException("Basic authentication requires a configured user and password");
}

public static bool HasBasicAuth =>
Expand Down
56 changes: 43 additions & 13 deletions Controllers/GrpcEndpointExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,28 +34,55 @@ public static IEndpointRouteBuilder MapGrpcUnary(
{
endpoints.MapPost(pattern, async context =>
{
if (!IsAuthorized(context))
try
{
await GrpcFraming.WriteErrorAsync(context, "16", "unauthenticated");
return;
}
if (context.Request.Protocol != "HTTP/2")
{
await GrpcFraming.WriteErrorAsync(context, "12", "grpc requires HTTP/2");
return;
}
if (!IsAuthorized(context))
{
await GrpcFraming.WriteErrorAsync(context, "16", "unauthenticated");
return;
}

var frame = await GrpcFraming.ReadRequestAsync(context.Request, context.RequestAborted);
if (frame.Compressed)
{
await GrpcFraming.WriteErrorAsync(context, "12", "compressed grpc messages are not supported");
return;
}
if (!GrpcFraming.IsGrpcRequest(context.Request))
{
await GrpcFraming.WriteErrorAsync(context, "3", "grpc content type is required");
return;
}

var frame = await GrpcFraming.ReadRequestAsync(context.Request, context.RequestAborted);
if (frame.Compressed)
{
await GrpcFraming.WriteErrorAsync(context, "12", "compressed grpc messages are not supported");
return;
}

try
{
var response = await handler(context, frame.Payload);
await GrpcFraming.WriteResponseAsync(context, response);
}
catch (GrpcStatusException exception)
{
await GrpcFraming.WriteErrorAsync(context, exception.Status, exception.Message);
}
catch (GrpcFrameException exception)
{
await GrpcFraming.WriteErrorAsync(context, exception.Status, exception.Message);
}
catch (InvalidProtocolBufferException)
{
await GrpcFraming.WriteErrorAsync(context, "3", "invalid protobuf message");
}
catch (BadHttpRequestException exception) when (exception.StatusCode is 400 or 413)
{
await GrpcFraming.WriteErrorAsync(context, exception.StatusCode == 413 ? "8" : "3", "invalid grpc request body");
}
catch (UnauthorizedAccessException)
{
await GrpcFraming.WriteErrorAsync(context, "16", "unauthenticated");
}
});

return endpoints;
Expand All @@ -65,7 +92,10 @@ private static bool IsAuthorized(HttpContext context)
{
var request = context.Request;
if (!request.Headers.TryGetValue("authorization", out var value))
return true;
return !ServerConfig.RequireBasicAuth;

if (value.Count != 1)
return false;

var authorization = value.ToString();
if (ServerConfig.IsExpectedBasicAuth(authorization))
Expand Down
27 changes: 22 additions & 5 deletions Controllers/StaticEndpointExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ public static IEndpointRouteBuilder MapStaticAssetEndpoints(this IEndpointRouteB

MapAndroidAssetProxy(endpoints, "/asset/Android/{**assetPath}");
MapAndroidAssetProxy(endpoints, "/Android/{**assetPath}");
endpoints.MapMethods("/master/{version}/{fileName}", [HttpMethods.Get, HttpMethods.Head],
(string version, string fileName, ResourceReleaseService release) =>
release.FindMaster(version, fileName) is { } path
? Results.File(path, "application/octet-stream", enableRangeProcessing: true)
: Results.NotFound());

return endpoints;
}
Expand All @@ -23,10 +28,22 @@ private static void MapAndroidAssetProxy(IEndpointRouteBuilder endpoints, string
endpoints.MapMethods(
pattern,
[HttpMethods.Get, HttpMethods.Head],
(HttpContext context, StaticAssetService assets, string assetPath) =>
assets.ProxyAndroidAssetAsync(
context,
assetPath,
HttpMethods.IsHead(context.Request.Method)));
async (HttpContext context, StaticAssetService assets, ResourceReleaseService release, string assetPath) =>
{
if (release.FindAndroid(assetPath) is { } path)
await Results.File(path, "application/octet-stream", enableRangeProcessing: true).ExecuteAsync(context);
else if (release.Enabled && IsCatalogMarker(assetPath))
context.Response.StatusCode = StatusCodes.Status404NotFound;
else
await assets.ProxyAndroidAssetAsync(context, assetPath, HttpMethods.IsHead(context.Request.Method));
});
}

private static bool IsCatalogMarker(string assetPath)
{
var name = assetPath.Split('/').Last();
return name.StartsWith("catalog", StringComparison.OrdinalIgnoreCase) &&
(name.EndsWith(".bin", StringComparison.OrdinalIgnoreCase) ||
name.EndsWith(".hash", StringComparison.OrdinalIgnoreCase) || !name.Contains('.'));
}
}
10 changes: 9 additions & 1 deletion Middleware/RequestLoggingScopeMiddleware.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,15 @@ public sealed class RequestLoggingScopeMiddleware(RequestDelegate next, ILogger<
public async Task Invoke(HttpContext context)
{
if (context.Request.Headers.TryGetValue("authorization", out var authHeader))
logger.LogDebug("authorization header present: {Prefix}", authHeader.ToString().Split(' ')[0]);
{
var authorization = authHeader.ToString();
var scheme = authorization.StartsWith("Basic ", StringComparison.OrdinalIgnoreCase)
? "Basic"
: authorization.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)
? "Bearer"
: "Other";
logger.LogDebug("authorization header present: {Scheme}", scheme);
}

if (context.Request.Headers.TryGetValue("user-agent", out var userAgent))
logger.LogDebug("user-agent: {UserAgent}", userAgent.ToString());
Expand Down
3 changes: 3 additions & 0 deletions Models/PlayerRecord.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ public sealed class PlayerRecord
public string InitialDataGroup { get; init; } = "";
public long FavoriteMemberCardId { get; set; }
public byte[] LiveSettingAll { get; set; } = [];
internal object LiveStateLock { get; } = new();
internal (long MusicId, int Difficulty)? ActiveFreeLive { get; set; }
internal Dictionary<(long MusicId, int Difficulty), LiveScore> LiveScores { get; } = [];
internal object DeckStateLock { get; } = new();
internal Dictionary<int, Deck> DeckOverrides { get; } = [];
internal int MainDeckOverride { get; set; }
Expand Down
153 changes: 153 additions & 0 deletions Models/Protocol/App/Protobuf/Chat/ClientSyncMsgRequest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
// <auto-generated />
#nullable enable

using Google.Protobuf;
using Google.Protobuf.Collections;
using Google.Protobuf.Reflection;

namespace App.Protobuf.Chat;

public sealed partial class ClientSyncMsgRequest : IMessage<global::App.Protobuf.Chat.ClientSyncMsgRequest>
{
private static readonly MessageParser<global::App.Protobuf.Chat.ClientSyncMsgRequest> _parser = new(() => new global::App.Protobuf.Chat.ClientSyncMsgRequest());
private UnknownFieldSet? _unknownFields;

public static MessageParser<global::App.Protobuf.Chat.ClientSyncMsgRequest> Parser => _parser;
public static MessageDescriptor Descriptor => null!;
MessageDescriptor IMessage.Descriptor => Descriptor;

public ClientSyncMsgRequest()
{
}

public ClientSyncMsgRequest(ClientSyncMsgRequest other) : this()
{
type_ = other.type_;
lastMsgId_ = other.lastMsgId_;
receiver_ = other.receiver_;
_unknownFields = UnknownFieldSet.Clone(other._unknownFields);
}

public ClientSyncMsgRequest Clone() => new(this);
public const int TypeFieldNumber = 1;
private int type_ = 0;
public int Type
{
get => type_;
set => type_ = value;
}

public const int LastMsgIdFieldNumber = 2;
private long lastMsgId_ = 0;
public long LastMsgId
{
get => lastMsgId_;
set => lastMsgId_ = value;
}

public const int ReceiverFieldNumber = 3;
private long receiver_ = 0;
public long Receiver
{
get => receiver_;
set => receiver_ = value;
}

public override bool Equals(object? other) => Equals(other as ClientSyncMsgRequest);

public bool Equals(ClientSyncMsgRequest? other)
{
if (ReferenceEquals(other, null)) return false;
if (ReferenceEquals(other, this)) return true;
if (!Equals(Type, other.Type)) return false;
if (!Equals(LastMsgId, other.LastMsgId)) return false;
if (!Equals(Receiver, other.Receiver)) return false;
return Equals(_unknownFields, other._unknownFields);
}

public override int GetHashCode()
{
var hash = 1;
if (Type != 0) hash ^= Type.GetHashCode();
if (LastMsgId != 0) hash ^= LastMsgId.GetHashCode();
if (Receiver != 0) hash ^= Receiver.GetHashCode();
if (_unknownFields != null) hash ^= _unknownFields.GetHashCode();
return hash;
}

public override string ToString() => base.ToString() ?? string.Empty;

public void WriteTo(CodedOutputStream output)
{
if (Type != 0)
{
output.WriteRawTag(0x08);
output.WriteInt32(Type);
}
if (LastMsgId != 0)
{
output.WriteRawTag(0x10);
output.WriteInt64(LastMsgId);
}
if (Receiver != 0)
{
output.WriteRawTag(0x18);
output.WriteInt64(Receiver);
}
_unknownFields?.WriteTo(output);
}

public int CalculateSize()
{
var size = 0;
if (Type != 0)
{
size += CodedOutputStream.ComputeTagSize(1);
size += CodedOutputStream.ComputeInt32Size(Type);
}
if (LastMsgId != 0)
{
size += CodedOutputStream.ComputeTagSize(2);
size += CodedOutputStream.ComputeInt64Size(LastMsgId);
}
if (Receiver != 0)
{
size += CodedOutputStream.ComputeTagSize(3);
size += CodedOutputStream.ComputeInt64Size(Receiver);
}
if (_unknownFields != null) size += _unknownFields.CalculateSize();
return size;
}

public void MergeFrom(ClientSyncMsgRequest? other)
{
if (other == null) return;
if (other.Type != 0) Type = other.Type;
if (other.LastMsgId != 0) LastMsgId = other.LastMsgId;
if (other.Receiver != 0) Receiver = other.Receiver;
_unknownFields = UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields);
}

public void MergeFrom(CodedInputStream input)
{
uint tag;
while ((tag = input.ReadTag()) != 0)
{
switch (tag)
{
case 8u:
Type = input.ReadInt32();
break;
case 16u:
LastMsgId = input.ReadInt64();
break;
case 24u:
Receiver = input.ReadInt64();
break;
default:
_unknownFields = UnknownFieldSet.MergeFieldFrom(_unknownFields, input);
break;
}
}
}
}
Loading
Loading