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/api | |
| 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/api')
| -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 |
12 files changed, 1207 insertions, 1 deletions
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 +{ + private const string PluginColumns = "publisher, name, description, authors, license, homepage, repository"; + + /// <inheritdoc/> + public async ValueTask<IReadOnlyList<TazelPlugin>> ListAsync(CancellationToken cancellationToken) + { + await using NpgsqlConnection connection = await dataSource.OpenConnectionAsync(cancellationToken); + List<TazelPlugin> plugins = []; + await using (NpgsqlCommand command = new( + $"SELECT {PluginColumns} FROM viget_tazel_plugins ORDER BY publisher, name", connection)) + await using (NpgsqlDataReader reader = await command.ExecuteReaderAsync(cancellationToken)) + { + while (await reader.ReadAsync(cancellationToken)) + { + plugins.Add(ReadPlugin(reader, [])); + } + } + + List<TazelPlugin> withVersions = []; + foreach (TazelPlugin plugin in plugins) + { + withVersions.Add(plugin with + { + Versions = await VersionsAsync(connection, null, plugin.Publisher, plugin.Name, cancellationToken) + }); + } + + return withVersions; + } + + /// <inheritdoc/> + public async ValueTask<TazelPlugin?> FindAsync(string publisher, string name, CancellationToken cancellationToken) + { + await using NpgsqlConnection connection = await dataSource.OpenConnectionAsync(cancellationToken); + TazelPlugin? plugin; + await using (NpgsqlCommand command = new( + $"SELECT {PluginColumns} FROM viget_tazel_plugins WHERE publisher = $1 AND name = $2", connection)) + { + command.Parameters.AddWithValue(publisher); + command.Parameters.AddWithValue(name); + await using NpgsqlDataReader reader = await command.ExecuteReaderAsync(cancellationToken); + plugin = await reader.ReadAsync(cancellationToken) ? ReadPlugin(reader, []) : null; + } + + return plugin is null + ? null + : plugin with { Versions = await VersionsAsync(connection, null, publisher, name, cancellationToken) }; + } + + /// <inheritdoc/> + public async ValueTask<AddVersionResult> AddVersionAsync( + TazelPluginDescriptor descriptor, + string sha256, + long size, + DateTimeOffset publishedAt, + CancellationToken cancellationToken) + { + await using NpgsqlConnection connection = await dataSource.OpenConnectionAsync(cancellationToken); + await using NpgsqlTransaction transaction = await connection.BeginTransactionAsync(cancellationToken); + await using (NpgsqlCommand taken = new( + """ + SELECT count(*) FROM viget_tazel_plugins + WHERE upper(publisher) = upper($1) AND upper(name) = upper($2) AND (publisher <> $1 OR name <> $2) + """, + connection, + transaction)) + { + taken.Parameters.AddWithValue(descriptor.Publisher); + taken.Parameters.AddWithValue(descriptor.Name); + if ((long)(await taken.ExecuteScalarAsync(cancellationToken))! > 0) + { + return AddVersionResult.NameTaken; + } + } + + await using (NpgsqlCommand plugin = new( + $""" + INSERT INTO viget_tazel_plugins ({PluginColumns}, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + ON CONFLICT (publisher, name) DO UPDATE SET + description = excluded.description, + authors = excluded.authors, + license = excluded.license, + homepage = excluded.homepage, + repository = excluded.repository, + updated_at = excluded.updated_at + """, + connection, + transaction)) + { + plugin.Parameters.AddWithValue(descriptor.Publisher); + plugin.Parameters.AddWithValue(descriptor.Name); + plugin.Parameters.AddWithValue((object?)descriptor.Description ?? DBNull.Value); + plugin.Parameters.AddWithValue(descriptor.Authors.ToArray()); + plugin.Parameters.AddWithValue((object?)descriptor.License ?? DBNull.Value); + plugin.Parameters.AddWithValue((object?)descriptor.Homepage ?? DBNull.Value); + plugin.Parameters.AddWithValue((object?)descriptor.Repository ?? DBNull.Value); + plugin.Parameters.AddWithValue(publishedAt); + await plugin.ExecuteNonQueryAsync(cancellationToken); + } + + await using (NpgsqlCommand version = new( + """ + INSERT INTO viget_tazel_plugin_versions (publisher, name, version, sha256, api_version, size, published_at) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT (publisher, name, version) DO NOTHING + """, + connection, + transaction)) + { + version.Parameters.AddWithValue(descriptor.Publisher); + version.Parameters.AddWithValue(descriptor.Name); + version.Parameters.AddWithValue(descriptor.Version); + version.Parameters.AddWithValue(sha256); + version.Parameters.AddWithValue(descriptor.ApiVersion); + version.Parameters.AddWithValue(size); + version.Parameters.AddWithValue(publishedAt); + if (await version.ExecuteNonQueryAsync(cancellationToken) == 0) + { + // The plugin row is left as it was: the transaction is rolled back with the refusal. + return AddVersionResult.VersionExists; + } + } + + await transaction.CommitAsync(cancellationToken); + return AddVersionResult.Added; + } + + /// <inheritdoc/> + public async ValueTask<bool> SetYankedAsync( + string publisher, + string name, + string version, + bool yanked, + CancellationToken cancellationToken) + { + await using NpgsqlConnection connection = await dataSource.OpenConnectionAsync(cancellationToken); + await using NpgsqlCommand command = new( + """ + UPDATE viget_tazel_plugin_versions SET yanked = $4 + WHERE publisher = $1 AND name = $2 AND version = $3 + """, + connection); + command.Parameters.AddWithValue(publisher); + command.Parameters.AddWithValue(name); + command.Parameters.AddWithValue(version); + command.Parameters.AddWithValue(yanked); + return await command.ExecuteNonQueryAsync(cancellationToken) == 1; + } + + private static TazelPlugin ReadPlugin(NpgsqlDataReader reader, IReadOnlyList<TazelPluginVersion> versions) => new( + reader.GetString(0), + reader.GetString(1), + reader.IsDBNull(2) ? null : reader.GetString(2), + reader.GetFieldValue<string[]>(3), + reader.IsDBNull(4) ? null : reader.GetString(4), + reader.IsDBNull(5) ? null : reader.GetString(5), + reader.IsDBNull(6) ? null : reader.GetString(6), + versions); + + private static async Task<IReadOnlyList<TazelPluginVersion>> VersionsAsync( + NpgsqlConnection connection, + NpgsqlTransaction? transaction, + string publisher, + string name, + CancellationToken cancellationToken) + { + await using NpgsqlCommand command = new( + """ + SELECT version, sha256, api_version, size, published_at, yanked FROM viget_tazel_plugin_versions + WHERE publisher = $1 AND name = $2 ORDER BY published_at DESC, version DESC + """, + connection, + transaction); + command.Parameters.AddWithValue(publisher); + command.Parameters.AddWithValue(name); + await using NpgsqlDataReader reader = await command.ExecuteReaderAsync(cancellationToken); + List<TazelPluginVersion> versions = []; + while (await reader.ReadAsync(cancellationToken)) + { + versions.Add(new TazelPluginVersion( + reader.GetString(0), + reader.GetString(1), + reader.GetInt32(2), + reader.GetInt64(3), + reader.GetFieldValue<DateTimeOffset>(4), + reader.GetBoolean(5))); + } + + return versions; + } +} diff --git a/apps/api/ViGet/TazelPluginCatalog.cs b/apps/api/ViGet/TazelPluginCatalog.cs new file mode 100644 index 0000000..e6fa2e9 --- /dev/null +++ b/apps/api/ViGet/TazelPluginCatalog.cs @@ -0,0 +1,177 @@ +// SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com> +// SPDX-License-Identifier: AGPL-3.0-or-later WITH AdditionRef-Progmasoft-Patent-Grant-1.1 + +namespace Progmasoft.Progmaweb.Api.ViGet; + +/// <summary>One published version of a Tazel plugin.</summary> +/// <param name="Version">The version.</param> +/// <param name="Sha256">The SHA-256 digest of its JAR, in lower-case hex.</param> +/// <param name="ApiVersion">The plugin API of Tazel it was built for.</param> +/// <param name="Size">The size of its JAR in bytes.</param> +/// <param name="PublishedAt">When it was published.</param> +/// <param name="Yanked">Whether it was yanked: Tazel takes it only when a project names it.</param> +internal sealed record TazelPluginVersion( + string Version, + string Sha256, + int ApiVersion, + long Size, + DateTimeOffset PublishedAt, + bool Yanked); + +/// <summary>A Tazel plugin of the catalog, described by the descriptor of its newest publication.</summary> +/// <param name="Publisher">The Progmasoft Account it is published under.</param> +/// <param name="Name">Its name.</param> +/// <param name="Description">What it does.</param> +/// <param name="Authors">Its authors.</param> +/// <param name="License">An SPDX license expression.</param> +/// <param name="Homepage">An https address of the plugin.</param> +/// <param name="Repository">An https address of its sources.</param> +/// <param name="Versions">Its versions, newest publication first.</param> +internal sealed record TazelPlugin( + string Publisher, + string Name, + string? Description, + IReadOnlyList<string> Authors, + string? License, + string? Homepage, + string? Repository, + IReadOnlyList<TazelPluginVersion> Versions) +{ + /// <summary>The newest version that is not yanked, or the newest of all when every one is.</summary> + public TazelPluginVersion Latest => + Versions.Where(version => !version.Yanked).DefaultIfEmpty(Versions[0]).First(); +} + +/// <summary>What adding a version to the catalog did.</summary> +internal enum AddVersionResult +{ + /// <summary>The version was added.</summary> + Added, + + /// <summary>The plugin has this version already; a version is never replaced.</summary> + VersionExists, + + /// <summary>A plugin of the publisher has the same name in other letter case.</summary> + NameTaken, +} + +/// <summary>The Tazel plugins of ViGet: their descriptions and versions. The JARs live in files beside it.</summary> +internal interface ITazelPluginCatalog +{ + /// <summary>Every plugin, ordered by publisher and name.</summary> + ValueTask<IReadOnlyList<TazelPlugin>> ListAsync(CancellationToken cancellationToken); + + /// <summary>A plugin by its exact publisher and name.</summary> + ValueTask<TazelPlugin?> FindAsync(string publisher, string name, CancellationToken cancellationToken); + + /// <summary>Adds a version, and describes the plugin with the descriptor of that version.</summary> + ValueTask<AddVersionResult> AddVersionAsync( + TazelPluginDescriptor descriptor, + string sha256, + long size, + DateTimeOffset publishedAt, + CancellationToken cancellationToken); + + /// <summary>Yanks a version, or takes the yank back.</summary> + /// <returns>Whether the plugin has the version.</returns> + ValueTask<bool> SetYankedAsync( + string publisher, + string name, + string version, + bool yanked, + CancellationToken cancellationToken); +} + +/// <summary>A catalog that lives as long as the process, for development and tests.</summary> +internal sealed class InMemoryTazelPluginCatalog : ITazelPluginCatalog +{ + private readonly Lock gate = new(); + private readonly Dictionary<(string Publisher, string Name), TazelPlugin> plugins = []; + + /// <inheritdoc/> + public ValueTask<IReadOnlyList<TazelPlugin>> ListAsync(CancellationToken cancellationToken) + { + lock (gate) + { + return ValueTask.FromResult<IReadOnlyList<TazelPlugin>>(plugins.Values + .OrderBy(plugin => plugin.Publisher, StringComparer.Ordinal) + .ThenBy(plugin => plugin.Name, StringComparer.Ordinal) + .ToList()); + } + } + + /// <inheritdoc/> + public ValueTask<TazelPlugin?> FindAsync(string publisher, string name, CancellationToken cancellationToken) + { + lock (gate) + { + return ValueTask.FromResult(plugins.GetValueOrDefault((publisher, name))); + } + } + + /// <inheritdoc/> + public ValueTask<AddVersionResult> AddVersionAsync( + TazelPluginDescriptor descriptor, + string sha256, + long size, + DateTimeOffset publishedAt, + CancellationToken cancellationToken) + { + lock (gate) + { + (string, string) key = (descriptor.Publisher, descriptor.Name); + if (!plugins.ContainsKey(key) && plugins.Keys.Any(existing => + string.Equals(existing.Publisher, descriptor.Publisher, StringComparison.OrdinalIgnoreCase) && + string.Equals(existing.Name, descriptor.Name, StringComparison.OrdinalIgnoreCase))) + { + return ValueTask.FromResult(AddVersionResult.NameTaken); + } + + List<TazelPluginVersion> versions = plugins.TryGetValue(key, out TazelPlugin? existingPlugin) + ? [.. existingPlugin.Versions] + : []; + if (versions.Any(version => version.Version == descriptor.Version)) + { + return ValueTask.FromResult(AddVersionResult.VersionExists); + } + + versions.Insert( + 0, new TazelPluginVersion(descriptor.Version, sha256, descriptor.ApiVersion, size, publishedAt, false)); + plugins[key] = new TazelPlugin( + descriptor.Publisher, + descriptor.Name, + descriptor.Description, + descriptor.Authors, + descriptor.License, + descriptor.Homepage, + descriptor.Repository, + versions); + return ValueTask.FromResult(AddVersionResult.Added); + } + } + + /// <inheritdoc/> + public ValueTask<bool> SetYankedAsync( + string publisher, + string name, + string version, + bool yanked, + CancellationToken cancellationToken) + { + lock (gate) + { + if (!plugins.TryGetValue((publisher, name), out TazelPlugin? plugin) || + plugin.Versions.All(existing => existing.Version != |