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/ViGet | |
| 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/ViGet')
| -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 |
8 files changed, 1166 insertions, 0 deletions
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 != version)) + { + return ValueTask.FromResult(false); + } + + plugins[(publisher, name)] = plugin with + { + Versions = [.. plugin.Versions.Select(existing => + existing.Version == version ? existing with { Yanked = yanked } : existing)] + }; + return ValueTask.FromResult(true); + } + } +} diff --git a/apps/api/ViGet/TazelPluginDescriptor.cs b/apps/api/ViGet/TazelPluginDescriptor.cs new file mode 100644 index 0000000..eddd541 --- /dev/null +++ b/apps/api/ViGet/TazelPluginDescriptor.cs @@ -0,0 +1,134 @@ +// 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.Text; +using System.Text.RegularExpressions; + +namespace Progmasoft.Progmaweb.Api.ViGet; + +/// <summary> +/// The descriptor of a Tazel plugin, <c>META-INF/tazel-plugin.properties</c> of its JAR, as Tazel reads it, with the +/// fields ViGet shows on the page of the plugin. +/// </summary> +/// <param name="Publisher">The Progmasoft Account the plugin is published under.</param> +/// <param name="Name">The name of the plugin; one identifier.</param> +/// <param name="Version">An exact semantic version.</param> +/// <param name="ApiVersion">The plugin API of Tazel it was built for.</param> +/// <param name="Description">What the plugin does.</param> +/// <param name="Authors">The authors, in the order of the descriptor.</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> +internal sealed partial record TazelPluginDescriptor( + string Publisher, + string Name, + string Version, + int ApiVersion, + string? Description, + IReadOnlyList<string> Authors, + string? License, + string? Homepage, + string? Repository) +{ + /// <summary>Where the descriptor lies in the JAR.</summary> + public const string DescriptorEntry = "META-INF/tazel-plugin.properties"; + + /// <summary>The service entry that names the class Tazel loads.</summary> + public const string ServiceEntry = "META-INF/services/com.progmasoft.api.tazel.dsl.plugin.ProjectPlugin"; + + /// <summary>Whether a text is one identifier, as Tazel requires of a publisher and a plugin name.</summary> + public static bool IsIdentifier(string value) => IdentifierPattern().IsMatch(value); + + /// <summary>Whether a text is an exact semantic version, as Tazel requires of a plugin version.</summary> + public static bool IsVersion(string value) => VersionPattern().IsMatch(value); + + /// <summary>Reads and checks the descriptor of a plugin JAR.</summary> + /// <param name="jar">The bytes of the JAR.</param> + /// <param name="descriptor">The descriptor, when the JAR is a valid plugin.</param> + /// <returns>Why the JAR is refused, or <see langword="null"/> when it is valid.</returns> + public static string? TryRead(byte[] jar, out TazelPluginDescriptor? descriptor) + { + descriptor = null; + IReadOnlyDictionary<string, string> properties; + try + { + using ZipArchive archive = new(new MemoryStream(jar, writable: false), ZipArchiveMode.Read); + ZipArchiveEntry? entry = archive.GetEntry(DescriptorEntry); + if (entry is null) + { + return $"The JAR has no {DescriptorEntry}."; + } + + ZipArchiveEntry? service = archive.GetEntry(ServiceEntry); + if (service is null || service.Length == 0) + { + return $"The JAR has no {ServiceEntry}."; + } + + using StreamReader reader = new(entry.Open(), Encoding.Latin1); + properties = JavaProperties.Parse(reader.ReadToEnd()); + } + catch (InvalidDataException) + { + return "The file is not a JAR."; + } + catch (FormatException error) + { + return $"The descriptor is not a properties file: {error.Message}"; + } + + string? Text(string key) => + properties.TryGetValue(key, out string? value) && !string.IsNullOrWhiteSpace(value) ? value.Trim() : null; + + string? publisher = Text("publisher"); + string? name = Text("name"); + string? version = Text("version"); + if (publisher is null || !IsIdentifier(publisher)) + { + return "The descriptor needs a publisher that is one identifier."; + } + + if (name is null || !IsIdentifier(name)) + { + return "The descriptor needs a name that is one identifier."; + } + + if (version is null || !IsVersion(version)) + { + return "The descriptor needs an exact semantic version."; + } + + if (!int.TryParse(Text("apiVersion"), out int apiVersion) || apiVersion < 1) + { + return "The descriptor needs an API version."; + } + + List<string> authors = []; + for (int index = 0; Text($"author.{index}") is { } author; index++) + { + authors.Add(author); + } + + string? homepage = Text("homepage"); + string? repository = Text("repository"); + foreach ((string key, string? address) in new[] { ("homepage", homepage), ("repository", repository) }) + { + if (address is not null && + (!Uri.TryCreate(address, UriKind.Absolute, out Uri? uri) || uri.Scheme != Uri.UriSchemeHttps)) + { + return $"The {key} of the descriptor is not an https address."; + } + } + + descriptor = new TazelPluginDescriptor( + publisher, name, version, apiVersion, Text("description"), authors, Text("license"), homepage, repository); + return null; + } + + [GeneratedRegex("^[A-Za-z_][A-Za-z0-9_]*$")] + private static partial Regex IdentifierPattern(); + + [GeneratedRegex(@"^(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)(?:-[0-9A-Za-z.-]+)?$")] + private static partial Regex Ve |