diff options
| author | Leitwolf11 | 2026-10-29 09:53:57 +0300 |
|---|---|---|
| committer | Leitwolf11 | 2026-10-29 09:53:57 +0300 |
| commit | 58fa0d19d223aaebf9973ebd446c4bb855351ef9 (patch) | |
| tree | 6fce1c3d9f2255e80ec92c3040e7280c0ce131ae /apps | |
| parent | f22418235a0a87bc05b3bf9079fcdefba002d3b2 (diff) | |
| download | progmaweb-58fa0d19d223aaebf9973ebd446c4bb855351ef9.tar.gz | |
Add the Tazel plugin catalog of ViGet to the API
ViGet opens in stages: first only Progmasoft publishes Tazel plugins, later
every account. This adds the catalog and the server-side publishing for the
first stage; nothing publishes through the API yet.
- ViGet/: the catalog of Tazel plugins (in memory, or in PostgreSQL beside
the accounts with its own numbered schema steps), the descriptor read from
the JAR (META-INF/tazel-plugin.properties, with a reader of the Java
properties format, and the ProjectPlugin service entry), and publishing:
the publisher must be in ViGet:TazelPublishers (Progmasoft for now) and an
existing account of that exact name, a version is never replaced, names
that differ only in letter case are refused, and ViGet needs a
description, an author and a license. The JAR is kept at
dslplugins/tazel/<Publisher>/<Name>/<Version>/<Name>-<Version>.jar below
ViGet:ArtifactRoot.
- Read endpoints under /api/v1/viget/dslplugins/tazel: the list, a plugin,
and index.json, the index Tazel reads.
- 'viget publish-tazel-plugin <jar>' and 'viget yank-tazel-plugin' run with
the configuration of the service instead of the service.
- The account store can find an account by name.
The pages, the Nginx routes and the deployment follow.
Diffstat (limited to 'apps')
| -rw-r--r-- | apps/api-tests/AccountServiceTests.cs | 6 | ||||
| -rw-r--r-- | apps/api-tests/AccountStoreSelectionTests.cs | 5 | ||||
| -rw-r--r-- | apps/api-tests/ViGetTests.cs | 269 | ||||
| -rw-r--r-- | apps/api/Accounts/IAccountStore.cs | 7 | ||||
| -rw-r--r-- | apps/api/Accounts/InMemoryAccountStore.cs | 13 | ||||
| -rw-r--r-- | apps/api/Accounts/PostgresAccountStore.cs | 6 | ||||
| -rw-r--r-- | apps/api/Program.cs | 16 | ||||
| -rw-r--r-- | apps/api/ViGet/JavaProperties.cs | 149 | ||||
| -rw-r--r-- | apps/api/ViGet/PostgresTazelPluginCatalog.cs | 309 | ||||
| -rw-r--r-- | apps/api/ViGet/TazelPluginCatalog.cs | 177 | ||||
| -rw-r--r-- | apps/api/ViGet/TazelPluginDescriptor.cs | 134 | ||||
| -rw-r--r-- | apps/api/ViGet/TazelPluginPublishing.cs | 148 | ||||
| -rw-r--r-- | apps/api/ViGet/ViGetCommand.cs | 85 | ||||
| -rw-r--r-- | apps/api/ViGet/ViGetEndpoints.cs | 107 | ||||
| -rw-r--r-- | apps/api/ViGet/ViGetRegistration.cs | 57 |
15 files changed, 1487 insertions, 1 deletions
diff --git a/apps/api-tests/AccountServiceTests.cs b/apps/api-tests/AccountServiceTests.cs index 28fd44f..0ff7b33 100644 --- a/apps/api-tests/AccountServiceTests.cs +++ b/apps/api-tests/AccountServiceTests.cs @@ -151,6 +151,12 @@ public sealed class AccountServiceTests public ValueTask<AccountRecord?> FindByIdAsync(Guid id, CancellationToken cancellationToken) => ValueTask.FromResult<AccountRecord?>(passwordAccount.Id == id ? passwordAccount : null); + public ValueTask<AccountRecord?> FindByAccountNameAsync( + string normalizedAccountName, + CancellationToken cancellationToken) => + ValueTask.FromResult<AccountRecord?>( + passwordAccount.NormalizedAccountName == normalizedAccountName ? passwordAccount : null); + public ValueTask<AccountRecord?> FindByGoogleSubjectAsync( string googleSubject, CancellationToken cancellationToken) => diff --git a/apps/api-tests/AccountStoreSelectionTests.cs b/apps/api-tests/AccountStoreSelectionTests.cs index 05ed31b..1087575 100644 --- a/apps/api-tests/AccountStoreSelectionTests.cs +++ b/apps/api-tests/AccountStoreSelectionTests.cs @@ -134,6 +134,11 @@ public sealed class ExpiredSessionCleanupTests public ValueTask<AccountRecord?> FindByIdAsync(Guid id, CancellationToken cancellationToken) => throw new NotSupportedException(); + public ValueTask<AccountRecord?> FindByAccountNameAsync( + string normalizedAccountName, + CancellationToken cancellationToken) => + throw new NotSupportedException(); + public ValueTask StoreSessionAsync(SessionRecord session, CancellationToken cancellationToken) => throw new NotSupportedException(); diff --git a/apps/api-tests/ViGetTests.cs b/apps/api-tests/ViGetTests.cs new file mode 100644 index 0000000..603ef2f --- /dev/null +++ b/apps/api-tests/ViGetTests.cs @@ -0,0 +1,269 @@ +// SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com> +// SPDX-License-Identifier: AGPL-3.0-or-later WITH AdditionRef-Progmasoft-Patent-Grant-1.1 + +using System.IO.Compression; +using System.Net; +using System.Security.Cryptography; +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Progmasoft.Progmaweb.Api.Accounts; +using Progmasoft.Progmaweb.Api.ViGet; + +namespace Progmasoft.Progmaweb.Api.Tests; + +/// <summary>The Tazel plugin catalog of ViGet: the descriptor, publishing, the endpoints and the command.</summary> +public sealed class ViGetTests : IDisposable +{ + private readonly string artifactRoot = Path.Combine(Path.GetTempPath(), "viget-" + Guid.NewGuid().ToString("N")); + + /// <inheritdoc/> + public void Dispose() + { + if (Directory.Exists(artifactRoot)) + { + Directory.Delete(artifactRoot, recursive: true); + } + } + + [Fact] + public void PropertiesAreReadAsJavaReadsThem() + { + IReadOnlyDictionary<string, string> properties = JavaProperties.Parse( + "# a comment\n" + + "! another\n" + + "publisher=Progmasoft\n" + + "homepage = https\\://example.com/a\n" + + "description:Builds C \\u0026 C++,\\\n" + + " on every host.\n" + + "key\\ with\\ spaces value\n" + + "empty=\n"); + Assert.Equal("Progmasoft", properties["publisher"]); + Assert.Equal("https://example.com/a", properties["homepage"]); + Assert.Equal("Builds C & C++,on every host.", properties["description"]); + Assert.Equal("value", properties["key with spaces"]); + Assert.Equal(string.Empty, properties["empty"]); + Assert.Throws<FormatException>(() => JavaProperties.Parse("broken=\\u12")); + } + + [Fact] + public void TheDescriptorOfAPluginJarIsReadAndChecked() + { + Assert.Null(TazelPluginDescriptor.TryRead(Jar(), out TazelPluginDescriptor? descriptor)); + Assert.Equal( + new TazelPluginDescriptor( + "Progmasoft", "CMake", "0.1.0", 2, "Builds CMake projects.", ["First Author", "Second Author"], + "MPL-2.0", "https://example.com/cmake", null), + descriptor!, + new DescriptorComparer()); + + Assert.Equal("The JAR has no META-INF/services/com.progmasoft.api.tazel.dsl.plugin.ProjectPlugin.", + TazelPluginDescriptor.TryRead(Jar(service: false), out _)); + Assert.Equal("The descriptor needs an exact semantic version.", + TazelPluginDescriptor.TryRead(Jar(version: "1.0"), out _)); + Assert.Equal("The descriptor needs a name that is one identifier.", + TazelPluginDescriptor.TryRead(Jar(name: "c-make"), out _)); + Assert.Equal("The homepage of the descriptor is not an https address.", + TazelPluginDescriptor.TryRead(Jar(homepage: "http\\://example.com"), out _)); + Assert.Equal("The file is not a JAR.", TazelPluginDescriptor.TryRead("not a zip"u8.ToArray(), out _)); + } + + [Fact] + public async Task OnlyAnAllowedPublisherWithAnAccountPublishes() + { + (TazelPluginPublishing publishing, InMemoryAccountStore accounts, ITazelPluginCatalog catalog) = Publishing(); + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + + PublishResult noAccount = await publishing.PublishAsync(Jar(), cancellationToken); + Assert.Equal("No Progmasoft Account is named 'Progmasoft'.", noAccount.Error); + + await AddAccountAsync(accounts, "Progmasoft"); + await AddAccountAsync(accounts, "Someone"); + PublishResult notAllowed = await publishing.PublishAsync(Jar(publisher: "Someone"), cancellationToken); + Assert.Equal( + "Publishing Tazel plugins is open to Progmasoft for now; 'Someone' cannot publish yet.", + notAllowed.Error); + + byte[] jar = Jar(); + PublishResult published = await publishing.PublishAsync(jar, cancellationToken); + Assert.True(published.Published, published.Error); + string file = Path.Combine(artifactRoot, "dslplugins", "tazel", "Progmasoft", "CMake", "0.1.0", "CMake-0.1.0.jar"); + Assert.Equal(jar, await File.ReadAllBytesAsync(file, cancellationToken)); + TazelPlugin plugin = (await catalog.FindAsync("Progmasoft", "CMake", cancellationToken))!; + Assert.Equal(Convert.ToHexStringLower(SHA256.HashData(jar)), plugin.Versions.Single().Sha256); + Assert.Equal("MPL-2.0", plugin.License); + + PublishResult again = await publishing.PublishAsync(Jar(description: "Another build."), cancellationToken); + Assert.Equal("Progmasoft.CMake 0.1.0 is published already.", again.Error); + Assert.Equal(jar, await File.ReadAllBytesAsync(file, cancellationToken)); + + PublishResult folded = await publishing.PublishAsync(Jar(name: "CMAKE", version: "0.2.0"), cancellationToken); + Assert.Equal( + "Progmasoft has a plugin whose name differs from 'CMAKE' only in letter case.", folded.Error); + Assert.False(File.Exists(Path.Combine( + artifactRoot, "dslplugins", "tazel", "Progmasoft", "CMAKE", "0.2.0", "CMAKE-0.2.0.jar"))); + } + + [Fact] + public async Task ViGetNeedsADescriptionAnAuthorAndALicense() + { + (TazelPluginPublishing publishing, InMemoryAccountStore accounts, _) = Publishing(); + await AddAccountAsync(accounts, "Progmasoft"); + PublishResult result = await publishing.PublishAsync(Jar(license: null), TestContext.Current.CancellationToken); + Assert.Equal( + "ViGet needs a description, at least one author and a license in the descriptor.", result.Error); + } + + [Fact] + public async Task TheIndexIsWhatTazelReads() + { + await using AccountApiFactory factory = new(); + ITazelPluginCatalog catalog = factory.Services.GetRequiredService<ITazelPluginCatalog>(); + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + Assert.Null(TazelPluginDescriptor.TryRead(Jar(), out TazelPluginDescriptor? descriptor)); + await catalog.AddVersionAsync(descriptor!, new string('a', 64), 10, DateTimeOffset.UnixEpoch, cancellationToken); + await catalog.AddVersionAsync( + descriptor! with { Version = "0.2.0" }, new string('b', 64), 10, DateTimeOffset.UnixEpoch.AddDays(1), + cancellationToken); + await catalog.SetYankedAsync("Progmasoft", "CMake", "0.2.0", true, cancellationToken); + using HttpClient client = factory.CreateClient(); + + using JsonDocument index = JsonDocument.Parse(await client.GetStringAsync( + "/api/v1/viget/dslplugins/tazel/Progmasoft/CMake/index.json", cancellationToken)); + Assert.Equal("Progmasoft", index.RootElement.GetProperty("publisher").GetString()); + Assert.Equal("CMake", index.RootElement.GetProperty("name").GetString()); + JsonElement[] versions = [.. index.RootElement.GetProperty("versions").EnumerateArray()]; + Assert.Equal(["0.2.0", "0.1.0"], versions.Select(version => version.GetProperty("version").GetString())); + Assert.True(versions[0].GetProperty("yanked").GetBoolean()); + Assert.Equal(2, versions[1].GetProperty("apiVersion").GetInt32()); + Assert.Equal(new string('a', 64), versions[1].GetProperty("sha256").GetString()); + + using JsonDocument detail = JsonDocument.Parse(await client.GetStringAsync( + "/api/v1/viget/dslplugins/tazel/Progmasoft/CMake", cancellationToken)); + Assert.Equal("0.1.0", detail.RootElement.GetProperty("latestVersion").GetString()); + Assert.Equal( + "/dslplugins/tazel/Progmasoft/CMake/0.1.0/CMake-0.1.0.jar", + detail.RootElement.GetProperty("versions")[1].GetProperty("path").GetString()); + + using JsonDocument list = JsonDocument.Parse( + await client.GetStringAsync("/api/v1/viget/dslplugins/tazel", cancellationToken)); + Assert.Equal("CMake", list.RootElement.GetProperty("plugins")[0].GetProperty("name").GetString()); + + Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync( + "/api/v1/viget/dslplugins/tazel/Progmasoft/Missing/index.json", cancellationToken)).StatusCode); + Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync( + "/api/v1/viget/dslplugins/tazel/Progmasoft/not-a-name/index.json", cancellationToken)).StatusCode); + } + + [Fact] + public async Task TheCommandPublishesAndYanks() + { + (TazelPluginPublishing publishing, InMemoryAccountStore accounts, ITazelPluginCatalog catalog) = Publishing(); + await AddAccountAsync(accounts, "Progmasoft"); + ServiceProvider services = new ServiceCollection() + .AddSingleton(publishing) + .AddSingleton(catalog) + .BuildServiceProvider(); + string jar = Path.Combine(artifactRoot, "upload.jar"); + Directory.CreateDirectory(artifactRoot); + await File.WriteAllBytesAsync(jar, Jar(), TestContext.Current.CancellationToken); + StringWriter output = new(); + StringWriter errors = new(); + + Assert.Equal(0, await ViGetCommand.RunAsync( + services, ["publish-tazel-plugin", jar], output, errors, TestContext.Current.CancellationToken)); + Assert.Contains("Published Progmasoft.CMake 0.1.0", output.ToString(), StringComparison.Ordinal); + Assert.Equal(0, await ViGetCommand.RunAsync( + services, ["yank-tazel-plugin", "Progmasoft", "CMake", "0.1.0"], output, errors, + TestContext.Current.CancellationToken)); + Assert.True((await catalog.FindAsync("Progmasoft", "CMake", TestContext.Current.CancellationToken))! + .Versions.Single().Yanked); + Assert.Equal(1, await ViGetCommand.RunAsync( + services, ["publish-tazel-plugin", jar], output, errors, TestContext.Current.CancellationToken)); + Assert.Equal(2, await ViGetCommand.RunAsync( + services, ["publish"], output, errors, TestContext.Current.CancellationToken)); + } + + private (TazelPluginPublishing, InMemoryAccountStore, ITazelPluginCatalog) Publishing() + { + InMemoryAccountStore accounts = new(); + InMemoryTazelPluginCatalog catalog = new(); + TazelPluginPublishing publishing = new( + catalog, + accounts, + Options.Create(new ViGetOptions { ArtifactRoot = artifactRoot }), + TimeProvider.System); + return (publishing, accounts, catalog); + } + + private static async Task AddAccountAsync(InMemoryAccountStore accounts, string name) => + await accounts.CreateAsync( + new AccountRecord( + Guid.NewGuid(), + name, + AccountNamePolicy.NormalizeForLookup(name), + $"{name}@example.com", + $"{name}@example.com".ToUpperInvariant(), + null, + null, + DateTimeOffset.UnixEpoch), + TestContext.Current.CancellationToken); + + private static byte[] Jar( + string publisher = "Progmasoft", + string name = "CMake", + string version = "0.1.0", + string? description = "Builds CMake projects.", + string? license = "MPL-2.0", + string homepage = "https\\://example.com/cmake", + bool service = true) + { + using MemoryStream bytes = new(); + using (ZipArchive archive = new(bytes, ZipArchiveMode.Create, leaveOpen: true)) + { + List<string> lines = + [ + "publisher=" + publisher, + "name=" + name, + "version=" + version, + "apiVersion=2", + "author.0=First Author", + "author.1=Second Author", + "homepage=" + homepage + ]; + if (description is not null) + { + lines.Add("description=" + description); + } + + if (license is not null) + { + lines.Add("license=" + license); + } + + using (StreamWriter writer = new(archive.CreateEntry(TazelPluginDescriptor.DescriptorEntry).Open())) + { + writer.Write(string.Join('\n', lines) + "\n"); + } + + if (service) + { + using StreamWriter writer = new(archive.CreateEntry(TazelPluginDescriptor.ServiceEntry).Open()); + writer.Write("com.example.CMakePlugin\n"); + } + } + + return bytes.ToArray(); + } + + /// <summary>Compares descriptors by value, with their authors as sequences.</summary> + private sealed class DescriptorComparer : IEqualityComparer<TazelPluginDescriptor> + { + public bool Equals(TazelPluginDescriptor? left, TazelPluginDescriptor? right) => + left is not null && right is not null && + left with { Authors = [] } == right with { Authors = [] } && + left.Authors.SequenceEqual(right.Authors); + + public int GetHashCode(TazelPluginDescriptor descriptor) => descriptor.Name.GetHashCode(StringComparison.Ordinal); + } +} diff --git a/apps/api/Accounts/IAccountStore.cs b/apps/api/Accounts/IAccountStore.cs index efb7c5b..ccb4c02 100644 --- a/apps/api/Accounts/IAccountStore.cs +++ b/apps/api/Accounts/IAccountStore.cs @@ -31,6 +31,13 @@ internal interface IAccountStore /// <param name="cancellationToken">Cancels the operation.</param> /// <returns>The account, or <see langword="null"/> when it does not exist.</returns> ValueTask<AccountRecord?> FindByIdAsync(Guid id, CancellationToken cancellationToken); + /// <summary>Finds an account by its normalized name.</summary> + /// <param name="normalizedAccountName"> + /// Upper-case account name, as <see cref="AccountNamePolicy.NormalizeForLookup"/> produces it. + /// </param> + /// <param name="cancellationToken">Cancels the operation.</param> + /// <returns>The account, or <see langword="null"/> when no account has this name.</returns> + ValueTask<AccountRecord?> FindByAccountNameAsync(string normalizedAccountName, CancellationToken cancellationToken); /// <summary>Stores a session, replacing one with the same token digest.</summary> /// <param name="session">The session to store.</param> /// <param name="cancellationToken">Cancels the operation.</param> diff --git a/apps/api/Accounts/InMemoryAccountStore.cs b/apps/api/Accounts/InMemoryAccountStore.cs index 8606443..2f9af9d 100644 --- a/apps/api/Accounts/InMemoryAccountStore.cs +++ b/apps/api/Accounts/InMemoryAccountStore.cs @@ -82,6 +82,19 @@ internal sealed class InMemoryAccountStore : IAccountStore } /// <inheritdoc/> + public ValueTask<AccountRecord?> FindByAccountNameAsync( + string normalizedAccountName, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + lock (gate) + { + return ValueTask.FromResult( + accountIdsByName.TryGetValue(normalizedAccountName, out Guid id) ? accountsById[id] : null); + } + } + + /// <inheritdoc/> public ValueTask<AccountRecord?> FindByIdAsync(Guid id, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); diff --git a/apps/api/Accounts/PostgresAccountStore.cs b/apps/api/Accounts/PostgresAccountStore.cs index a0e3444..3a3ea82 100644 --- a/apps/api/Accounts/PostgresAccountStore.cs +++ b/apps/api/Accounts/PostgresAccountStore.cs @@ -98,6 +98,12 @@ internal sealed class PostgresAccountStore(NpgsqlDataSource dataSource) : IAccou FindAccountAsync("google_subject", googleSubject, cancellationToken); /// <inheritdoc/> + public ValueTask<AccountRecord?> FindByAccountNameAsync( + string normalizedAccountName, + CancellationToken cancellationToken) => + FindAccountAsync("normalized_account_name", normalizedAccountName, cancellationToken); + + /// <inheritdoc/> public ValueTask<AccountRecord?> FindByIdAsync(Guid id, CancellationToken cancellationToken) => FindAccountAsync("id", id, cancellationToken); diff --git a/apps/api/Program.cs b/apps/api/Program.cs index 476cbdb..873c120 100644 --- a/apps/api/Program.cs +++ b/apps/api/Program.cs @@ -9,8 +9,12 @@ using Microsoft.AspNetCore.HttpOverrides; using Progmasoft.Progmaweb.Api.Accounts; using Progmasoft.OAuth; using Progmasoft.Progmaweb.Api.Security; +using Progmasoft.Progmaweb.Api.ViGet; -WebApplicationBuilder builder = WebApplication.CreateBuilder(args); +// `viget ...` runs a command of ViGet with the configuration of the service instead of the service itself; its +// arguments are not configuration. +bool vigetCommand = ViGetCommand.IsCommand(args); +WebApplicationBuilder builder = WebApplication.CreateBuilder(vigetCommand ? [] : args); builder.Services.Configure<AccountSessionOptions>(builder.Configuration.GetSection(AccountSessionOptions.SectionName)); builder.Services.AddSingleton(TimeProvider.System); @@ -21,6 +25,8 @@ builder.Services.AddSingleton<AccountService>(); builder.Services.AddSingleton<SessionService>(); // Progmasoft OAuth, the OAuth 2.1 and OpenID Connect provider on its own host; see OAuth/OAuthRegistration.cs. builder.Services.AddProgmasoftOAuth(builder.Configuration, accountStore, builder.Environment.IsProduction()); +// ViGet: the Tazel plugin catalog beside the accounts; see ViGet/ViGetEndpoints.cs. +builder.Services.AddViGet(builder.Configuration, accountStore); builder.Services.AddAuthorization(); AuthenticationBuilder authentication = builder.Services .AddAuthentication() @@ -103,6 +109,13 @@ builder.WebHost.ConfigureKestrel(options => options.Limits.MaxRequestBodySize = WebApplication app = builder.Build(); +if (vigetCommand) +{ + Environment.ExitCode = await ViGetCommand.RunAsync( + app.Services, args[1..], Console.Out, Console.Error, CancellationToken.None); + return; +} + if (accountStore is AccountStoreKind.Memory) { AccountStoreRegistration.WarnThatAccountsAreInMemory(app.Logger); @@ -134,6 +147,7 @@ app.MapGet("/api/v1/status", () => Results.Ok(new })); app.MapAccountEndpoints(); app.MapProgmasoftOAuth(); +app.MapViGetEndpoints(); app.Run(); diff --git a/apps/api/ViGet/JavaProperties.cs b/apps/api/ViGet/JavaProperties.cs new file mode 100644 index 0000000..e55991e --- /dev/null +++ b/apps/api/ViGet/JavaProperties.cs @@ -0,0 +1,149 @@ +// SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com> +// SPDX-License-Identifier: AGPL-3.0-or-later WITH AdditionRef-Progmasoft-Patent-Grant-1.1 + +using System.Globalization; +using System.Text; + +namespace Progmasoft.Progmaweb.Api.ViGet; + +/// <summary>Reads the text of a Java <c>.properties</c> file, as <c>java.util.Properties.load</c> does.</summary> +/// <remarks> +/// A logical line may continue over several lines with a trailing backslash; <c>#</c> and <c>!</c> start a comment +/// line; a key ends at the first unescaped <c>=</c>, <c>:</c> or white space; and <c>\t</c>, <c>\n</c>, <c>\r</c>, +/// <c>\f</c> and <c>\uXXXX</c> are escapes, while a backslash before any other character stands for that character. +/// A later line with the same key replaces an earlier one. +/// </remarks> +internal static class JavaProperties +{ + /// <summary>Reads the properties of a text.</summary> + /// <param name="text">The text of the file, already decoded.</param> + /// <returns>The properties by key.</returns> + /// <exception cref="FormatException">A <c>\u</c> escape is not followed by four hexadecimal digits.</exception> + public static IReadOnlyDictionary<string, string> Parse(string text) + { + Dictionary<string, string> properties = new(StringComparer.Ordinal); + foreach (string line in LogicalLines(text)) + { + int index = 0; + while (index < line.Length && IsBlank(line[index])) + { + index++; + } + + if (index == line.Length || line[index] is '#' or '!') + { + continue; + } + + int keyStart = index; + while (index < line.Length && !IsKeyEnd(line[index])) + { + index += line[index] == '\\' ? 2 : 1; + } + + string key = Unescape(line[keyStart..Math.Min(index, line.Length)]); + while (index < line.Length && IsBlank(line[index])) + { + index++; + } + + if (index < line.Length && line[index] is '=' or ':') + { + index++; + while (index < line.Length && IsBlank(line[index])) + { + index++; + } + } + + properties[key] = Unescape(line[index..]); + } + + return properties; + } + + /// <summary>Joins the physical lines that a trailing backslash continues.</summary> + private static IEnumerable<string> LogicalLines(string text) + { + string[] lines = text.Replace("\r\n", "\n", StringComparison.Ordinal).Replace('\r', '\n').Split('\n'); + StringBuilder current = new(); + bool continuing = false; + foreach (string physical in lines) + { + // A continued line loses the white space it starts with. + string line = continuing ? physical.TrimStart(' ', '\t', '\f') : physical; + int backslashes = 0; + for (int position = line.Length - 1; position >= 0 && line[position] == '\\'; position--) + { + backslashes++; + } + + continuing = backslashes % 2 == 1; + current.Append(continuing ? line[..^1] : line); + if (!continuing) + { + yield return current.ToString(); + current.Clear(); + } + } + + if (current.Length > 0) + { + yield return current.ToString(); + } + } + + private static bool IsBlank(char character) => character is ' ' or '\t' or '\f'; + + private static bool IsKeyEnd(char character) => character is '=' or ':' || IsBlank(character); + + private static string Unescape(string text) + { + StringBuilder result = new(text.Length); + for (int index = 0; index < text.Length; index++) + { + char character = text[index]; + if (character != '\\' || index + 1 == text.Length) + { + result.Append(character); + continue; + } + + char escaped = text[++index]; + switch (escaped) + { + case 't': + result.Append('\t'); + break; + case 'n': + result.Append('\n'); + break; + case 'r': + result.Append('\r'); + break; + case 'f': + result.Append('\f'); + break; + case 'u': + if (text.Length - index - 1 < 4 || + !int.TryParse( + text.AsSpan(index + 1, 4), + NumberStyles.AllowHexSpecifier, + CultureInfo.InvariantCulture, + out int code)) + { + throw new FormatException("A \\u escape needs four hexadecimal digits."); + } + + result.Append((char)code); + index += 4; + break; + default: + result.Append(escaped); + break; + } + } + + return result.ToString(); + } +} diff --git a/apps/api/ViGet/PostgresTazelPluginCatalog.cs b/apps/api/ViGet/PostgresTazelPluginCatalog.cs new file mode 100644 index 0000000..07fa86f --- /dev/null +++ b/apps/api/ViGet/PostgresTazelPluginCatalog.cs @@ -0,0 +1,309 @@ +// SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com> +// SPDX-License-Identifier: AGPL-3.0-or-later WITH AdditionRef-Progmasoft-Patent-Grant-1.1 + +using Npgsql; + +namespace Progmasoft.Progmaweb.Api.ViGet; + +/// <summary>The tables of <see cref="PostgresTazelPluginCatalog"/> and the steps that bring a database to them.</summary> +/// <remarks> +/// The same numbered steps as the account tables, in a version table of their own, <c>viget_schema_versions</c>. +/// A step is never edited after it has been released. +/// </remarks> +internal static class ViGetSchema +{ + /// <summary>Key of the advisory lock that lets one process at a time change these tables.</summary> + private const long MigrationLockKey = 0x5669476574; + + private static readonly string[] Steps = + [ + // 1: Tazel plugins and their versions. + // + // A plugin is named by its publisher and its name as they are spelled; the unique index on their upper + // case keeps two plugins from differing only in letter case. A version is never removed, only yanked. + """ + CREATE TABLE viget_tazel_plugins ( + publisher text NOT NULL, + name text NOT NULL, + description text, + authors text[] NOT NULL, + license text, + homepage text, + repository text, + updated_at timestamptz NOT NULL, + PRIMARY KEY (publisher, name) + ); + + CREATE UNIQUE INDEX viget_tazel_plugins_folded_key ON viget_tazel_plugins (upper(publisher), upper(name)); + + CREATE TABLE viget_tazel_plugin_versions ( + publisher text NOT NULL, + name text NOT NULL, + version text NOT NULL, + sha256 text NOT NULL, + api_version integer NOT NULL, + size bigint NOT NULL, + published_at timestamptz NOT NULL, + yanked boolean NOT NULL DEFAULT false, + PRIMARY KEY (publisher, name, version), + FOREIGN KEY (publisher, name) REFERENCES viget_tazel_plugins (publisher, name) + ); + """ + ]; + + /// <summary>The version a database has after every step.</summary> + public static int CurrentVersion => Steps.Length; + + /// <summary>Takes the steps a database has not taken yet, in one transaction.</summary> + /// <returns>The number of steps that were taken.</returns> + /// <exception cref="InvalidOperationException">The database has taken steps this program does not know.</exception> + public static async Task<int> ApplyAsync(NpgsqlDataSource dataSource, CancellationToken cancellationToken) + { + await using NpgsqlConnection connection = await dataSource.OpenConnectionAsync(cancellationToken); + await using NpgsqlTransaction transaction = await connection.BeginTransactionAsync(cancellationToken); + await ExecuteAsync(connection, transaction, $"SELECT pg_advisory_xact_lock({MigrationLockKey})", cancellationToken); + await ExecuteAsync( + connection, + transaction, + """ + CREATE TABLE IF NOT EXISTS viget_schema_versions ( + version integer PRIMARY KEY, + applied_at timestamptz NOT NULL DEFAULT now() + ) + """, + cancellationToken); + + int version; + await using (NpgsqlCommand read = new( + "SELECT coalesce(max(version), 0) FROM viget_schema_versions", connection, transaction)) + { + version = (int)(await read.ExecuteScalarAsync(cancellationToken))!; + } + + if (version > Steps.Length) + { + throw new InvalidOperationException( + $"The ViGet tables are at schema version {version}, and this program knows version {Steps.Length}."); + } + + int taken = 0; + for (int step = version; step < Steps.Length; step++) + { + await ExecuteAsync(connection, transaction, Steps[step], cancellationToken); + await using NpgsqlCommand record = new( + "INSERT INTO viget_schema_versions (version) VALUES ($1)", connection, transaction); + record.Parameters.AddWithValue(step + 1); + await record.ExecuteNonQueryAsync(cancellationToken); + taken++; + } + + await transaction.CommitAsync(cancellationToken); + return taken; + } + + private static async Task ExecuteAsync( + NpgsqlConnection connection, + NpgsqlTransaction transaction, + string sql, + CancellationToken cancellationToken) + { + await using NpgsqlCommand command = new(sql, connection, transaction); + await command.ExecuteNonQueryAsync(cancellationToken); + } +} + +/// <summary>The Tazel plugins of ViGet in PostgreSQL, beside the accounts.</summary> +/// <param name="dataSource">Connections to the database of the accounts.</param> +internal sealed class PostgresTazelPluginCatalog(NpgsqlDataSource dataSource) : ITazelPluginCatalog +{ |