Skip to content
Merged
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
334 changes: 332 additions & 2 deletions Quantum.PreviewViewer/Program.cs
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
using System.Text.Json;
using System.Text.Json.Nodes;

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

string repositoryRoot = PreviewViewerPaths.FindRepositoryRoot(app.Environment.ContentRootPath);
string configuredSnapshotRoot = app.Configuration["SnapshotRoot"] ?? Path.Combine("artifacts", "debug-viewport");
string snapshotRoot = PreviewViewerPaths.ResolveRepoPath(repositoryRoot, configuredSnapshotRoot);
string configuredStyleManifest = app.Configuration["PreviewStyleManifest"] ??
Path.Combine("Quantum.PreviewViewer", "preview-styles.json");
string styleManifestPath = PreviewViewerPaths.ResolveRepoPath(repositoryRoot, configuredStyleManifest);
string configuredStyleAssetRoot = app.Configuration["PreviewStyleAssetRoot"] ??
Path.GetDirectoryName(styleManifestPath) ??
repositoryRoot;
string styleAssetRoot = PreviewViewerPaths.ResolveRepoPath(repositoryRoot, configuredStyleAssetRoot);

app.UseDefaultFiles();
app.UseStaticFiles();
Expand Down Expand Up @@ -36,6 +44,36 @@
return Results.Text(json, "application/json");
});

app.MapGet("/api/styles", () =>
{
JsonObject manifest = PreviewStyleManifestCatalog.LoadManifest(
repositoryRoot,
styleManifestPath,
styleAssetRoot,
"/style-assets");

return Results.Json(manifest);
});

app.MapGet("/style-assets/{**assetPath}", (string assetPath) =>
{
if (!PreviewStyleAssetFiles.TryResolveStyleAssetFile(
styleAssetRoot,
assetPath,
out string resolvedPath,
out string contentType,
out string error))
{
int statusCode = error.Contains("not found", StringComparison.OrdinalIgnoreCase)
? StatusCodes.Status404NotFound
: StatusCodes.Status400BadRequest;

return Results.Problem(error, statusCode: statusCode);
}

return Results.File(resolvedPath, contentType, enableRangeProcessing: true);
});

app.MapGet("/api/health", () => Results.Json(new { status = "ok" }));

app.MapFallbackToFile("index.html");
Expand Down Expand Up @@ -85,8 +123,7 @@ public static bool TryResolveRepositoryFile(
}

string candidate = ResolveRepoPath(repositoryRoot, requestedPath);
string normalizedRoot = EnsureTrailingSeparator(Path.GetFullPath(repositoryRoot));
if (!candidate.StartsWith(normalizedRoot, StringComparison.OrdinalIgnoreCase))
if (!IsPathInsideDirectory(repositoryRoot, candidate))
{
error = "Snapshot path must resolve inside the repository. Use Open JSON for external files.";
return false;
Expand All @@ -108,6 +145,15 @@ public static string ToRepositoryRelativePath(string repositoryRoot, string path
return relativePath.Replace(Path.DirectorySeparatorChar, '/');
}

public static bool IsPathInsideDirectory(string rootDirectory, string candidatePath)
{
string normalizedRoot = EnsureTrailingSeparator(Path.GetFullPath(rootDirectory));
string normalizedCandidate = Path.GetFullPath(candidatePath);
string normalizedRootWithoutTrailingSeparator = normalizedRoot.TrimEnd(Path.DirectorySeparatorChar);
return string.Equals(normalizedCandidate, normalizedRootWithoutTrailingSeparator, StringComparison.OrdinalIgnoreCase) ||
normalizedCandidate.StartsWith(normalizedRoot, StringComparison.OrdinalIgnoreCase);
}

private static string EnsureTrailingSeparator(string path)
{
if (path.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal))
Expand All @@ -119,6 +165,290 @@ private static string EnsureTrailingSeparator(string path)
}
}

public static class PreviewStyleManifestCatalog
{
public const int CurrentVersion = 1;
public const string DebugBoxesTrainStyleId = "debug-boxes";

public static JsonObject LoadManifest(
string repositoryRoot,
string styleManifestPath,
string styleAssetRoot,
string assetRequestPathPrefix)
{
JsonObject manifest;
if (!File.Exists(styleManifestPath))
{
manifest = CreateDefaultManifest("Style manifest was not found. Using debug train boxes.");
AddRepositoryMetadata(manifest, repositoryRoot, styleManifestPath, styleAssetRoot);
return manifest;
}

try
{
using FileStream stream = File.OpenRead(styleManifestPath);
manifest = JsonNode.Parse(stream) as JsonObject ??
CreateDefaultManifest("Style manifest root must be a JSON object. Using debug train boxes.");
}
catch (IOException)
{
manifest = CreateDefaultManifest("Style manifest could not be read. Using debug train boxes.");
}
catch (JsonException)
{
manifest = CreateDefaultManifest("Style manifest is invalid JSON. Using debug train boxes.");
}
catch (UnauthorizedAccessException)
{
manifest = CreateDefaultManifest("Style manifest was not accessible. Using debug train boxes.");
}

AddRepositoryMetadata(manifest, repositoryRoot, styleManifestPath, styleAssetRoot);
EnsureDefaults(manifest);
AddAssetUrls(manifest, styleAssetRoot, assetRequestPathPrefix.TrimEnd('/'), GetDiagnostics(manifest));
return manifest;
}

private static void AddRepositoryMetadata(
JsonObject manifest,
string repositoryRoot,
string styleManifestPath,
string styleAssetRoot)
{
manifest["manifestPath"] = PreviewViewerPaths.IsPathInsideDirectory(repositoryRoot, styleManifestPath)
? PreviewViewerPaths.ToRepositoryRelativePath(repositoryRoot, styleManifestPath)
: styleManifestPath;
manifest["assetRoot"] = PreviewViewerPaths.IsPathInsideDirectory(repositoryRoot, styleAssetRoot)
? PreviewViewerPaths.ToRepositoryRelativePath(repositoryRoot, styleAssetRoot)
: styleAssetRoot;
}

private static void EnsureDefaults(JsonObject manifest)
{
manifest["version"] ??= CurrentVersion;
JsonArray trainStyles = GetOrCreateArray(manifest, "trainStyles");
if (trainStyles.Count == 0)
{
trainStyles.Add(CreateDebugBoxesTrainStyle());
}

JsonObject? firstTrainStyle = trainStyles[0] as JsonObject;
string defaultTrainStyle = TryGetString(manifest, "defaultTrainStyle") ??
TryGetString(firstTrainStyle, "id") ??
DebugBoxesTrainStyleId;
manifest["defaultTrainStyle"] = defaultTrainStyle;
manifest["trackStyles"] ??= new JsonArray();

foreach (JsonNode? trainStyle in trainStyles)
{
if (trainStyle is JsonObject trainStyleObject)
{
trainStyleObject["roles"] ??= new JsonObject();
}
}
}

private static void AddAssetUrls(
JsonNode? node,
string styleAssetRoot,
string assetRequestPathPrefix,
JsonArray diagnostics)
{
if (node is JsonObject jsonObject)
{
if (TryGetString(jsonObject, "asset") is { Length: > 0 } assetPath &&
TryBuildStyleAssetUrl(styleAssetRoot, assetPath, assetRequestPathPrefix, diagnostics, out string assetUrl))
{
jsonObject["assetUrl"] = assetUrl;
}

foreach (KeyValuePair<string, JsonNode?> property in jsonObject.ToList())
{
AddAssetUrls(property.Value, styleAssetRoot, assetRequestPathPrefix, diagnostics);
}
}
else if (node is JsonArray jsonArray)
{
foreach (JsonNode? item in jsonArray)
{
AddAssetUrls(item, styleAssetRoot, assetRequestPathPrefix, diagnostics);
}
}
}

private static bool TryBuildStyleAssetUrl(
string styleAssetRoot,
string assetPath,
string assetRequestPathPrefix,
JsonArray diagnostics,
out string assetUrl)
{
assetUrl = string.Empty;
string extension = Path.GetExtension(assetPath);
if (!PreviewStyleAssetFiles.IsPrimaryModelExtension(extension))
{
diagnostics.Add(CreateDiagnostic(
"warning",
$"Style asset '{assetPath}' is not a supported .glb or .gltf model."));
return false;
}

string resolvedPath = PreviewViewerPaths.ResolveRepoPath(styleAssetRoot, assetPath);
if (!PreviewViewerPaths.IsPathInsideDirectory(styleAssetRoot, resolvedPath))
{
diagnostics.Add(CreateDiagnostic(
"warning",
$"Style asset '{assetPath}' must resolve inside the configured preview asset root."));
return false;
}

if (!File.Exists(resolvedPath))
{
diagnostics.Add(CreateDiagnostic(
"warning",
$"Style asset '{assetPath}' was not found. Matching train boxes will use debug rendering."));
}

string relativePath = Path.GetRelativePath(styleAssetRoot, resolvedPath)
.Replace(Path.DirectorySeparatorChar, '/')
.Replace(Path.AltDirectorySeparatorChar, '/');
assetUrl = $"{assetRequestPathPrefix}/{EscapePathSegments(relativePath)}";
return true;
}

private static JsonObject CreateDefaultManifest(string diagnosticMessage)
{
return new JsonObject
{
["version"] = CurrentVersion,
["defaultTrainStyle"] = DebugBoxesTrainStyleId,
["trainStyles"] = new JsonArray(CreateDebugBoxesTrainStyle()),
["trackStyles"] = new JsonArray(),
["diagnostics"] = new JsonArray(CreateDiagnostic("warning", diagnosticMessage))
};
}

private static JsonObject CreateDebugBoxesTrainStyle()
{
return new JsonObject
{
["id"] = DebugBoxesTrainStyleId,
["name"] = "Debug boxes",
["roles"] = new JsonObject()
};
}

private static JsonArray GetDiagnostics(JsonObject manifest)
{
return GetOrCreateArray(manifest, "diagnostics");
}

private static JsonArray GetOrCreateArray(JsonObject manifest, string propertyName)
{
if (manifest[propertyName] is JsonArray array)
{
return array;
}

array = new JsonArray();
manifest[propertyName] = array;
return array;
}

private static JsonObject CreateDiagnostic(string severity, string message)
{
return new JsonObject
{
["severity"] = severity,
["message"] = message
};
}

private static string? TryGetString(JsonObject? jsonObject, string propertyName)
{
if (jsonObject == null ||
jsonObject[propertyName] is not JsonValue value ||
!value.TryGetValue(out string? result) ||
string.IsNullOrWhiteSpace(result))
{
return null;
}

return result;
}

private static string EscapePathSegments(string path)
{
return string.Join(
'/',
path.Split('/', StringSplitOptions.RemoveEmptyEntries).Select(Uri.EscapeDataString));
}
}

public static class PreviewStyleAssetFiles
{
private static readonly IReadOnlyDictionary<string, string> ContentTypes =
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
[".glb"] = "model/gltf-binary",
[".gltf"] = "model/gltf+json",
[".bin"] = "application/octet-stream",
[".png"] = "image/png",
[".jpg"] = "image/jpeg",
[".jpeg"] = "image/jpeg",
[".webp"] = "image/webp",
[".ktx2"] = "image/ktx2"
};

public static bool TryResolveStyleAssetFile(
string styleAssetRoot,
string requestedAssetPath,
out string resolvedPath,
out string contentType,
out string error)
{
resolvedPath = string.Empty;
contentType = string.Empty;
error = string.Empty;

if (string.IsNullOrWhiteSpace(requestedAssetPath))
{
error = "Style asset path is required.";
return false;
}

string extension = Path.GetExtension(requestedAssetPath);
if (!ContentTypes.TryGetValue(extension, out string? resolvedContentType))
{
error = "Style assets must be .glb, .gltf, .bin, or common image texture files.";
return false;
}

string candidate = PreviewViewerPaths.ResolveRepoPath(styleAssetRoot, requestedAssetPath);
if (!PreviewViewerPaths.IsPathInsideDirectory(styleAssetRoot, candidate))
{
error = "Style asset path must resolve inside the configured preview asset root.";
return false;
}

if (!File.Exists(candidate))
{
error = "Style asset file was not found.";
return false;
}

resolvedPath = candidate;
contentType = resolvedContentType;
return true;
}

public static bool IsPrimaryModelExtension(string extension)
{
return string.Equals(extension, ".glb", StringComparison.OrdinalIgnoreCase) ||
string.Equals(extension, ".gltf", StringComparison.OrdinalIgnoreCase);
}
}

public static class SnapshotCatalog
{
public const string ContractName = "quantum.debug_viewport_snapshot";
Expand Down
Loading
Loading