- all authorization rules (owner/creator, per-channel read/write grants, who may remove whom, delete rules, structure management) live in ChannelPolicy; a test fails if they are reimplemented elsewhere - ChannelInfo carries what the caller may do (can_*), member and invitation lists say who can be removed; clients display it and never derive it - the host no longer stores or calls the file service: it only signs short-lived tickets from space permissions (create/put/get/delete/domain), and keeps an opaque fileDomain reference on the channel - a whole file is registered in one request (last frame header + per-frame length and digest) instead of one request per 1 MiB frame; the signed manifest still has to match every digest - drop file quota/threshold, receipts, reconcile and delete-queue code from the host - contract generator emits constants for enums, error codes, token roles, permissions and share states
107 lines
8.1 KiB
C#
107 lines
8.1 KiB
C#
using System.Reflection;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Text.Json.Serialization;
|
|
using System.Text.Json.Nodes;
|
|
using WebDrop.Api;
|
|
using WebDrop.Core;
|
|
|
|
// Build-time reflection only; production serialization remains source-generated and AOT-safe.
|
|
var root = Path.GetFullPath(args.FirstOrDefault(a => a != "--check") ?? ".");
|
|
string[] sources = ["server/src/WebDrop.Api/Json/ApiJson.cs", "server/src/WebDrop.Core/Channels/Dtos.cs", "server/src/WebDrop.Core/Channels/Settings.cs", "server/src/WebDrop.Core/Common/Enums.cs", "server/src/WebDrop.Core/Spaces/Workspace.cs", "server/src/WebDrop.Core/Accounts/AccountProfile.cs", "server/src/WebDrop.Api/Passkeys/PasskeyDtos.cs", "server/src/WebDrop.Core/Spaces/WorkspaceCrypto.cs", "server/src/WebDrop.Api/Endpoints/Endpoints.WorkspaceCrypto.cs"];
|
|
var digest = Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(string.Join("", sources.Select(p => p + "\n" + File.ReadAllText(Path.Combine(root, p)).Replace("\r\n", "\n") + "\n")))));
|
|
var pending = new SortedDictionary<string, Type>();
|
|
var done = new HashSet<Type>();
|
|
foreach (var entry in typeof(ApiJson).GetCustomAttributesData().Where(a => a.AttributeType == typeof(JsonSerializableAttribute))) Add((Type)entry.ConstructorArguments[0].Value!);
|
|
var output = new StringBuilder($"// Generated from backend DTOs; do not edit.\n// backend-contract-sha256: {digest}\n\n");
|
|
var nullable = new NullabilityInfoContext();
|
|
var definitions = new JsonObject();
|
|
while (pending.Count > 0)
|
|
{
|
|
var type = pending.First().Value;
|
|
pending.Remove(type.Name);
|
|
if (!done.Add(type)) continue;
|
|
if (type.IsEnum)
|
|
{
|
|
var values = type.GetFields(BindingFlags.Public | BindingFlags.Static).Select(f => "'" + (f.GetCustomAttribute<JsonStringEnumMemberNameAttribute>()?.Name ?? f.Name) + "'");
|
|
output.AppendLine($"export type {type.Name} = {string.Join(" | ", values)};");
|
|
output.AppendLine($"export const {type.Name} = {{ {string.Join(", ", type.GetFields(BindingFlags.Public | BindingFlags.Static).Select(f => f.Name + ": '" + (f.GetCustomAttribute<JsonStringEnumMemberNameAttribute>()?.Name ?? f.Name) + "'"))} }} as const;\n");
|
|
definitions[type.Name] = new JsonObject { ["type"] = "string", ["enum"] = new JsonArray(type.GetFields(BindingFlags.Public | BindingFlags.Static).Select(f => JsonValue.Create(f.GetCustomAttribute<JsonStringEnumMemberNameAttribute>()?.Name ?? f.Name) as JsonNode).ToArray()) };
|
|
continue;
|
|
}
|
|
output.AppendLine($"export interface {type.Name} {{");
|
|
var properties = new JsonObject();
|
|
var required = new JsonArray();
|
|
foreach (var prop in type.GetProperties().OrderBy(p => p.GetCustomAttribute<JsonPropertyNameAttribute>()?.Name ?? p.Name, StringComparer.Ordinal))
|
|
{
|
|
var ignore = prop.GetCustomAttribute<JsonIgnoreAttribute>();
|
|
if (ignore?.Condition == JsonIgnoreCondition.Always) continue;
|
|
var jsonName = prop.GetCustomAttribute<JsonPropertyNameAttribute>()?.Name;
|
|
if (jsonName is null) throw new InvalidOperationException($"Wire property needs an explicit JSON name: {type.Name}.{prop.Name}");
|
|
var isNullable = Nullable.GetUnderlyingType(prop.PropertyType) is not null || nullable.Create(prop).ReadState == NullabilityState.Nullable;
|
|
var optional = isNullable && ignore?.Condition != JsonIgnoreCondition.Never;
|
|
output.AppendLine($" {jsonName}{(optional ? "?" : "")}: {Ts(prop.PropertyType)}{(isNullable ? " | null" : "")};");
|
|
properties[jsonName] = isNullable ? new JsonObject { ["anyOf"] = new JsonArray(Schema(prop.PropertyType), new JsonObject { ["type"] = "null" }) } : Schema(prop.PropertyType);
|
|
if (!optional) required.Add(jsonName);
|
|
}
|
|
output.AppendLine("}");
|
|
definitions[type.Name] = new JsonObject { ["type"] = "object", ["properties"] = properties, ["required"] = required };
|
|
}
|
|
// 不在 DTO 里、但前端要用的取值:错误码、令牌角色、空间权限、分享状态。一律由后端定义、生成常量
|
|
output.AppendLine("/** 错误响应里的 error 取值 */");
|
|
output.AppendLine($"export const ErrorCode = {{ {string.Join(", ", Enum.GetValues<ErrorCode>().Select(c => c + ": '" + c.ToWire() + "'"))} }} as const;");
|
|
output.AppendLine("export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode];\n");
|
|
output.AppendLine("/** X-WD-Role 请求头:这次请求出示哪张令牌 */");
|
|
output.AppendLine($"export const TokenRole = {{ {string.Join(", ", Enum.GetValues<Role>().Where(r => r != Role.None).Select(r => r + ": '" + r.ToString().ToLowerInvariant() + "'"))} }} as const;");
|
|
output.AppendLine("export type TokenRole = (typeof TokenRole)[keyof typeof TokenRole];\n");
|
|
output.AppendLine("/** 空间权限(身份组授予) */");
|
|
output.AppendLine($"export const WorkspacePermission = {{ {string.Join(", ", typeof(WorkspacePermission).GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy).Where(f => f.IsLiteral && f.FieldType == typeof(string) && f.Name != "Colors").Select(f => f.Name + ": '" + f.GetRawConstantValue() + "'"))} }} as const;");
|
|
output.AppendLine("export type WorkspacePermission = (typeof WorkspacePermission)[keyof typeof WorkspacePermission];\n");
|
|
output.AppendLine("/** 便捷分享的状态 */");
|
|
output.AppendLine($"export const ShareState = {{ {string.Join(", ", typeof(ShareState).GetFields(BindingFlags.Public | BindingFlags.Static).Select(f => f.Name + ": '" + f.GetRawConstantValue() + "'"))} }} as const;");
|
|
output.AppendLine("export type ShareState = (typeof ShareState)[keyof typeof ShareState];");
|
|
var path = Path.Combine(root, "web/src/api/generated.ts");
|
|
var text = output.ToString().Replace("\r\n", "\n");
|
|
if (args.Contains("--check"))
|
|
{
|
|
if (!File.Exists(path) || File.ReadAllText(path).Replace("\r\n", "\n") != text) throw new InvalidOperationException("Generated API types are stale. Run ContractGenerator.");
|
|
}
|
|
else { Directory.CreateDirectory(Path.GetDirectoryName(path)!); File.WriteAllText(path, text, new UTF8Encoding(false)); }
|
|
var schemaPath = Path.Combine(root, "contracts/api.schemas.json");
|
|
var schemaText = new JsonObject { ["$schema"] = "https://json-schema.org/draft/2020-12/schema", ["x-backend-contract-sha256"] = digest, ["$defs"] = definitions }.ToJsonString(new() { WriteIndented = true }).Replace("\r\n", "\n") + "\n";
|
|
if (args.Contains("--check"))
|
|
{
|
|
if (!File.Exists(schemaPath) || File.ReadAllText(schemaPath).Replace("\r\n", "\n") != schemaText) throw new InvalidOperationException("Generated API schemas are stale.");
|
|
}
|
|
else File.WriteAllText(schemaPath, schemaText, new UTF8Encoding(false));
|
|
Console.WriteLine($"Backend API types {(args.Contains("--check") ? "verified" : "generated")}.");
|
|
|
|
void Add(Type type)
|
|
{
|
|
type = Nullable.GetUnderlyingType(type) ?? type;
|
|
if (type.IsArray) { Add(type.GetElementType()!); return; }
|
|
if (type.IsGenericType) { foreach (var arg in type.GetGenericArguments()) Add(arg); return; }
|
|
if ((type.Namespace is "WebDrop.Api" or "WebDrop.Core") && !done.Contains(type)) pending[type.Name] = type;
|
|
}
|
|
string Ts(Type type)
|
|
{
|
|
type = Nullable.GetUnderlyingType(type) ?? type;
|
|
if (type == typeof(string)) return "string";
|
|
if (type == typeof(bool)) return "boolean";
|
|
if (type.IsPrimitive || type == typeof(decimal)) return "number";
|
|
if (type.IsArray) { Add(type.GetElementType()!); return Ts(type.GetElementType()!) + "[]"; }
|
|
if (type.IsGenericType && type.GetGenericArguments().Length == 1) { var item = type.GetGenericArguments()[0]; Add(item); return Ts(item) + "[]"; }
|
|
Add(type);
|
|
return type.Name;
|
|
}
|
|
JsonObject Schema(Type type)
|
|
{
|
|
type = Nullable.GetUnderlyingType(type) ?? type;
|
|
if (type == typeof(string)) return new() { ["type"] = "string" };
|
|
if (type == typeof(bool)) return new() { ["type"] = "boolean" };
|
|
if (type.IsPrimitive || type == typeof(decimal)) return new() { ["type"] = type == typeof(float) || type == typeof(double) || type == typeof(decimal) ? "number" : "integer" };
|
|
if (type.IsArray) return new() { ["type"] = "array", ["items"] = Schema(type.GetElementType()!) };
|
|
if (type.IsGenericType && type.GetGenericArguments().Length == 1) return new() { ["type"] = "array", ["items"] = Schema(type.GetGenericArguments()[0]) };
|
|
return new() { ["$ref"] = "#/$defs/" + type.Name };
|
|
}
|