From 6dc0f11aecc441e1296c149dbac39de85d5eeb84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6nke=20Ludwig?= Date: Sun, 24 Sep 2023 16:33:46 +0200 Subject: [PATCH 01/11] Upgrade dependencies. --- dub.selections.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/dub.selections.json b/dub.selections.json index b90bde8b..04eeb0f4 100644 --- a/dub.selections.json +++ b/dub.selections.json @@ -5,20 +5,20 @@ "botan": "1.12.19", "botan-math": "1.0.3", "diet-ng": "1.8.1", - "dub": "1.33.0", - "eventcore": "0.9.25", + "dub": "1.33.1", + "eventcore": "0.9.26", "libasync": "0.8.6", "libev": "5.0.0+4.04", "libevent": "2.0.2+2.0.16", "memutils": "1.0.9", "mir-linux-kernel": "1.0.1", - "openssl": "3.3.0", + "openssl": "3.3.3", "openssl-static": "1.0.2+3.0.8", "stdx-allocator": "2.77.5", "taggedalgebraic": "0.11.22", "uritemplate": "1.0.0", "userman": "0.4.2", - "vibe-core": "2.2.0", - "vibe-d": "0.9.7-alpha.3" + "vibe-core": "2.2.1", + "vibe-d": "0.9.7" } } From c770e4cb1eb6d5f82b3cc7e0438fd47de19c02c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6nke=20Ludwig?= Date: Sun, 24 Sep 2023 18:33:58 +0200 Subject: [PATCH 02/11] Generalize RepositoryFactory to a RepositoryProvider interface. Will allow implementing additional provider specific functionality later. --- source/app.d | 6 ++-- source/dubregistry/repositories/bitbucket.d | 29 +++++++++++++----- source/dubregistry/repositories/github.d | 32 +++++++++++++++----- source/dubregistry/repositories/gitlab.d | 32 +++++++++++++++----- source/dubregistry/repositories/repository.d | 19 +++++++----- 5 files changed, 83 insertions(+), 35 deletions(-) diff --git a/source/app.d b/source/app.d index 9372c61c..18e886c9 100644 --- a/source/app.d +++ b/source/app.d @@ -129,9 +129,9 @@ void main() } } - GithubRepository.register(appConfig.ghauth); - BitbucketRepository.register(appConfig.bbuser, appConfig.bbpassword); - if (appConfig.glurl.length) GitLabRepository.register(appConfig.glauth, appConfig.glurl); + GithubRepositoryProvider.register(appConfig.ghauth); + BitbucketRepositoryProvider.register(appConfig.bbuser, appConfig.bbpassword); + if (appConfig.glurl.length) GitLabRepositoryProvider.register(appConfig.glauth, appConfig.glurl); auto router = new URLRouter; if (s_mirror.length) router.any("*", (req, res) { req.params["mirror"] = s_mirror; }); diff --git a/source/dubregistry/repositories/bitbucket.d b/source/dubregistry/repositories/bitbucket.d index 502fcf81..c3a07942 100644 --- a/source/dubregistry/repositories/bitbucket.d +++ b/source/dubregistry/repositories/bitbucket.d @@ -16,6 +16,27 @@ import vibe.core.stream; import vibe.data.json; import vibe.inet.url; +class BitbucketRepositoryProvider : RepositoryProvider { + private { + string m_user, m_password; + } +@safe: + private this(string user, string password) + { + m_user = user; + m_password = password; + } + + static void register(string user, string password) + { + addRepositoryProvider("bitbucket", new BitbucketRepositoryProvider(user, password)); + } + + Repository getRepository(DbRepository repo) + @safe { + return new BitbucketRepository(repo.owner, repo.project, m_user, m_password); + } +} class BitbucketRepository : Repository { @safe: @@ -27,14 +48,6 @@ class BitbucketRepository : Repository { string m_authPassword; } - static void register(string user, string password) - { - Repository factory(DbRepository info) @safe { - return new BitbucketRepository(info.owner, info.project, user, password); - } - addRepositoryFactory("bitbucket", &factory); - } - this(string owner, string project, string auth_user, string auth_password) { m_owner = owner; diff --git a/source/dubregistry/repositories/github.d b/source/dubregistry/repositories/github.d index 9928700c..3da67eb8 100644 --- a/source/dubregistry/repositories/github.d +++ b/source/dubregistry/repositories/github.d @@ -17,6 +17,30 @@ import vibe.http.client : HTTPClientRequest; import vibe.inet.url; +class GithubRepositoryProvider : RepositoryProvider { + private { + string m_token; + } +@safe: + + private this(string token) + { + m_token = token; + } + + static void register(string token) + { + auto h = new GithubRepositoryProvider(token); + addRepositoryProvider("github", h); + } + + Repository getRepository(DbRepository repo) + @safe { + return new GithubRepository(repo.owner, repo.project, m_token); + } +} + + class GithubRepository : Repository { @safe: private { @@ -25,14 +49,6 @@ class GithubRepository : Repository { string m_authToken; } - static void register(string token) - { - Repository factory(DbRepository info) @safe { - return new GithubRepository(info.owner, info.project, token); - } - addRepositoryFactory("github", &factory); - } - this(string owner, string project, string auth_token) { m_owner = owner; diff --git a/source/dubregistry/repositories/gitlab.d b/source/dubregistry/repositories/gitlab.d index 2c80718e..b1f72296 100644 --- a/source/dubregistry/repositories/gitlab.d +++ b/source/dubregistry/repositories/gitlab.d @@ -19,6 +19,30 @@ import vibe.inet.url; import vibe.textfilter.urlencode; +class GitLabRepositoryProvider : RepositoryProvider { + private { + string m_token; + string m_url; + } + + private this(string token, string url) + { + m_token = token; + m_url = url; + } + + static void register(string auth_token, string url) + { + addRepositoryProvider("gitlab", new GitLabRepositoryProvider(auth_token, url)); + } + + Repository getRepository(DbRepository repo) + @safe { + return new GitLabRepository(repo.owner, repo.project, m_token, m_url.length ? URL(m_url) : URL("https://gitlab.com/")); + } + +} + class GitLabRepository : Repository { @safe: @@ -29,14 +53,6 @@ class GitLabRepository : Repository { string m_authToken; } - static void register(string auth_token, string url) - { - Repository factory(DbRepository info) @safe { - return new GitLabRepository(info.owner, info.project, auth_token, url.length ? URL(url) : URL("https://gitlab.com/")); - } - addRepositoryFactory("gitlab", &factory); - } - this(string owner, string projectPath, string auth_token, URL base_url) { m_owner = owner; diff --git a/source/dubregistry/repositories/repository.d b/source/dubregistry/repositories/repository.d index 30dc2a43..9accdf31 100644 --- a/source/dubregistry/repositories/repository.d +++ b/source/dubregistry/repositories/repository.d @@ -19,26 +19,29 @@ Repository getRepository(DbRepository repinfo) return *pr; logDebug("Returning new repository: %s", repinfo); - auto pf = repinfo.kind in s_repositoryFactories; + auto pf = repinfo.kind in s_repositoryProviders; enforce(pf, "Unknown repository type: "~repinfo.kind); - auto rep = (*pf)(repinfo); + auto rep = pf.getRepository(repinfo); s_repositories[repinfo] = rep; return rep; } -void addRepositoryFactory(string kind, RepositoryFactory factory) +void addRepositoryProvider(string kind, RepositoryProvider factory) @safe { - assert(kind !in s_repositoryFactories); - s_repositoryFactories[kind] = factory; + assert(kind !in s_repositoryProviders); + s_repositoryProviders[kind] = factory; } bool supportsRepositoryKind(string kind) @safe { - return (kind in s_repositoryFactories) !is null; + return (kind in s_repositoryProviders) !is null; } -alias RepositoryFactory = Repository delegate(DbRepository) @safe; + +interface RepositoryProvider { + Repository getRepository(DbRepository repo) @safe; +} interface Repository { @safe: @@ -115,5 +118,5 @@ package Json readJson(string url, bool sanitize = false, bool cache_priority = f private { Repository[DbRepository] s_repositories; - RepositoryFactory[string] s_repositoryFactories; + RepositoryProvider[string] s_repositoryProviders; } From e7cfb60bec7bf968e6ae663923d91b62789179f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6nke=20Ludwig?= Date: Sun, 24 Sep 2023 18:53:55 +0200 Subject: [PATCH 03/11] Generalize repository URL parsing to all repository types and URLs. --- source/app.d | 4 +- source/dubregistry/dbcontroller.d | 50 -------------------- source/dubregistry/repositories/bitbucket.d | 42 ++++++++++++++++ source/dubregistry/repositories/github.d | 42 ++++++++++++++++ source/dubregistry/repositories/gitlab.d | 44 ++++++++++++++++- source/dubregistry/repositories/repository.d | 33 +++++++++++++ source/dubregistry/web.d | 12 +++-- 7 files changed, 171 insertions(+), 56 deletions(-) diff --git a/source/app.d b/source/app.d index 18e886c9..eec17106 100644 --- a/source/app.d +++ b/source/app.d @@ -65,6 +65,8 @@ version (linux) { // generate dummy data for e.g. Heroku's preview apps void defaultInit(UserManController userMan, DubRegistry registry) { + import dubregistry.repositories.repository : parseRepositoryURL; + if (environment.get("GENERATE_DEFAULT_DATA", "0") == "1" && registry.getPackageDump().empty && userMan.getUserCount() == 0) { @@ -81,7 +83,7 @@ void defaultInit(UserManController userMan, DubRegistry registry) foreach (url; packages) { DbRepository repo; - repo.parseURL(URL(url)); + parseRepositoryURL(URL(url), repo); registry.addPackage(repo, userId); } } diff --git a/source/dubregistry/dbcontroller.d b/source/dubregistry/dbcontroller.d index 6601d4ea..2eec5af6 100644 --- a/source/dubregistry/dbcontroller.d +++ b/source/dubregistry/dbcontroller.d @@ -543,56 +543,6 @@ struct DbRepository { string kind; string owner; string project; - - void parseURL(URL url) - { - string host = url.host; - if (!url.schema.among!("http", "https")) - throw new Exception("Invalid Repository Schema (only supports http and https)"); - if (host.endsWith(".github.com") || host == "github.com" || host == "github") { - kind = "github"; - } else if (host.endsWith(".gitlab.com") || host == "gitlab.com" || host == "gitlab") { - kind = "gitlab"; - } else if (host.endsWith(".bitbucket.org") || host == "bitbucket.org" || host == "bitbucket") { - kind = "bitbucket"; - } else { - throw new Exception("Please input a valid project URL to a GitHub, GitLab or BitBucket project."); - } - auto path = url.path.relativeTo(InetPath("/")).bySegment; - if (path.empty) - throw new Exception("Invalid Repository URL (no path)"); - if (path.empty || path.front.name.empty) - throw new Exception("Invalid Repository URL (missing owner)"); - owner = path.front.name.to!string; - path.popFront; - if (path.empty || path.front.name.empty) - throw new Exception("Invalid Repository URL (missing project)"); - - if(kind == "gitlab") // Allow any number of segments, as GitLab's subgroups can be nested - project = path.map!"a.name".join("/"); - else - project = path.front.name.to!string; - path.popFront; - if (!path.empty && kind != "gitlab") - throw new Exception("Invalid Repository URL (got more than owner and project)"); - } - - unittest { - DbRepository r; - r.parseURL(URL("https://github.com/foo/bar")); - assert(r == DbRepository("github", "foo", "bar")); - r.parseURL(URL("http://bitbucket.org/bar/baz/")); - assert(r == DbRepository("bitbucket", "bar", "baz")); - r.parseURL(URL("https://gitlab.com/foo/bar")); - assert(r == DbRepository("gitlab", "foo", "bar")); - r.parseURL(URL("https://gitlab.com/group/subgroup/subsubgroup/project")); - assert(r == DbRepository("gitlab", "group", "subgroup/subsubgroup/project")); - assertThrown(r.parseURL(URL("ftp://github.com/foo/bar"))); - assertThrown(r.parseURL(URL("ftp://github.com/foo/bar"))); - assertThrown(r.parseURL(URL("http://github.com/foo/"))); - assertThrown(r.parseURL(URL("http://github.com/"))); - assertThrown(r.parseURL(URL("http://github.com/foo/bar/baz"))); - } } struct DbPackageFile { diff --git a/source/dubregistry/repositories/bitbucket.d b/source/dubregistry/repositories/bitbucket.d index c3a07942..257d09f5 100644 --- a/source/dubregistry/repositories/bitbucket.d +++ b/source/dubregistry/repositories/bitbucket.d @@ -32,6 +32,48 @@ class BitbucketRepositoryProvider : RepositoryProvider { addRepositoryProvider("bitbucket", new BitbucketRepositoryProvider(user, password)); } + bool parseRepositoryURL(URL url, out DbRepository repo) + @safe { + import std.algorithm.searching : endsWith; + import std.conv : to; + + string host = url.host; + if (!host.endsWith(".bitbucket.org") && host != "bitbucket.org" && host != "bitbucket") + return false; + + repo.kind = "bitbucket"; + + auto path = url.path.relativeTo(InetPath("/")).bySegment; + if (path.empty) + throw new Exception("Invalid Repository URL (no path)"); + if (path.front.name.empty) + throw new Exception("Invalid Repository URL (missing owner)"); + repo.owner = path.front.name.to!string; + path.popFront; + if (path.empty || path.front.name.empty) + throw new Exception("Invalid Repository URL (missing project)"); + + repo.project = path.front.name.to!string; + path.popFront; + if (!path.empty) + throw new Exception("Invalid Repository URL (got more than owner and project)"); + + return true; + } + + unittest { + import std.exception : assertThrown; + + auto h = new BitbucketRepositoryProvider(null, null); + DbRepository r; + assert(!h.parseRepositoryURL(URL("https://github.com/foo/bar"), r)); + assert(h.parseRepositoryURL(URL("http://bitbucket.org/bar/baz/"), r)); + assert(r == DbRepository("bitbucket", "bar", "baz")); + assertThrown(h.parseRepositoryURL(URL("http://bitbucket.org/foo/"), r)); + assertThrown(h.parseRepositoryURL(URL("http://bitbucket.org/"), r)); + assertThrown(h.parseRepositoryURL(URL("http://bitbucket.org/foo/bar/baz"), r)); + } + Repository getRepository(DbRepository repo) @safe { return new BitbucketRepository(repo.owner, repo.project, m_user, m_password); diff --git a/source/dubregistry/repositories/github.d b/source/dubregistry/repositories/github.d index 3da67eb8..3b31260a 100644 --- a/source/dubregistry/repositories/github.d +++ b/source/dubregistry/repositories/github.d @@ -34,6 +34,48 @@ class GithubRepositoryProvider : RepositoryProvider { addRepositoryProvider("github", h); } + bool parseRepositoryURL(URL url, out DbRepository repo) + @safe { + import std.algorithm.searching : endsWith; + import std.conv : to; + + string host = url.host; + if (!host.endsWith(".github.com") && host != "github.com" && host != "github") + return false; + + repo.kind = "github"; + + auto path = url.path.relativeTo(InetPath("/")).bySegment; + if (path.empty) + throw new Exception("Invalid Repository URL (no path)"); + if (path.front.name.empty) + throw new Exception("Invalid Repository URL (missing owner)"); + repo.owner = path.front.name.to!string; + path.popFront; + if (path.empty || path.front.name.empty) + throw new Exception("Invalid Repository URL (missing project)"); + + repo.project = path.front.name.to!string; + path.popFront(); + if (!path.empty) + throw new Exception("Invalid Repository URL (got more than owner and project)"); + + return true; + } + + unittest { + import std.exception : assertThrown; + + auto h = new GithubRepositoryProvider(null); + DbRepository r; + assert(h.parseRepositoryURL(URL("https://github.com/foo/bar"), r)); + assert(r == DbRepository("github", "foo", "bar")); + assert(!h.parseRepositoryURL(URL("http://bitbucket.org/bar/baz/"), r)); + assertThrown(h.parseRepositoryURL(URL("http://github.com/foo/"), r)); + assertThrown(h.parseRepositoryURL(URL("http://github.com/"), r)); + assertThrown(h.parseRepositoryURL(URL("http://github.com/foo/bar/baz"), r)); + } + Repository getRepository(DbRepository repo) @safe { return new GithubRepository(repo.owner, repo.project, m_token); diff --git a/source/dubregistry/repositories/gitlab.d b/source/dubregistry/repositories/gitlab.d index b1f72296..33e1ba6e 100644 --- a/source/dubregistry/repositories/gitlab.d +++ b/source/dubregistry/repositories/gitlab.d @@ -36,11 +36,53 @@ class GitLabRepositoryProvider : RepositoryProvider { addRepositoryProvider("gitlab", new GitLabRepositoryProvider(auth_token, url)); } + bool parseRepositoryURL(URL url, out DbRepository repo) + @safe { + import std.algorithm.iteration : map; + import std.algorithm.searching : endsWith; + import std.array : join; + import std.conv : to; + + string host = url.host; + if (!host.endsWith(".gitlab.com") && host != "gitlab.com" && host != "gitlab") + return false; + + repo.kind = "gitlab"; + + auto path = url.path.relativeTo(InetPath("/")).bySegment; + if (path.empty) + throw new Exception("Invalid Repository URL (no path)"); + if (path.front.name.empty) + throw new Exception("Invalid Repository URL (missing owner)"); + repo.owner = path.front.name.to!string; + path.popFront; + if (path.empty || path.front.name.empty) + throw new Exception("Invalid Repository URL (missing project)"); + + repo.project = path.map!"a.name".join("/"); + + // Allow any number of segments, as GitLab's subgroups can be nested + return true; + } + + unittest { + import std.exception : assertThrown; + + auto h = new GitLabRepositoryProvider(null, "https://gitlab.com/"); + DbRepository r; + assert(!h.parseRepositoryURL(URL("https://github.com/foo/bar"), r)); + assert(h.parseRepositoryURL(URL("https://gitlab.com/foo/bar"), r)); + assert(r == DbRepository("gitlab", "foo", "bar")); + assert(h.parseRepositoryURL(URL("https://gitlab.com/group/subgroup/subsubgroup/project"), r)); + assert(r == DbRepository("gitlab", "group", "subgroup/subsubgroup/project")); + assertThrown(h.parseRepositoryURL(URL("http://gitlab.com/foo/"), r)); + assertThrown(h.parseRepositoryURL(URL("http://gitlab.com/"), r)); + } + Repository getRepository(DbRepository repo) @safe { return new GitLabRepository(repo.owner, repo.project, m_token, m_url.length ? URL(m_url) : URL("https://gitlab.com/")); } - } class GitLabRepository : Repository { diff --git a/source/dubregistry/repositories/repository.d b/source/dubregistry/repositories/repository.d index 9accdf31..11dc844a 100644 --- a/source/dubregistry/repositories/repository.d +++ b/source/dubregistry/repositories/repository.d @@ -37,9 +37,42 @@ bool supportsRepositoryKind(string kind) return (kind in s_repositoryProviders) !is null; } +/** Attempts to parse a URL that points to a repository. + + Throws: + Will throw an exception if the URL corresponds to a registered + repository provider, but does not point to a repository. + + Returns: + `true` is returned $(EM iff) the URL corresponds to any registered + repository provider. +*/ +bool parseRepositoryURL(URL url, out DbRepository repo) +{ + foreach (kind, h; s_repositoryProviders) + if (h.parseRepositoryURL(url, repo)) { + assert(repo.kind == kind); + return true; + } + return false; +} interface RepositoryProvider { + /** Attempts to parse a URL that points to a repository. + + Throws: + Will throw an exception if the URL corresponds to the repository + provider, but does not point to a repository. + + Returns: + `true` is returned $(EM iff) the URL corresponds to the repository + provider. + */ + bool parseRepositoryURL(URL url, out DbRepository repo) @safe; + + /** Creates a `Repository` instance corresponding to the given repository. + */ Repository getRepository(DbRepository repo) @safe; } diff --git a/source/dubregistry/web.d b/source/dubregistry/web.d index f451c744..213f85e2 100644 --- a/source/dubregistry/web.d +++ b/source/dubregistry/web.d @@ -637,11 +637,15 @@ class DubRegistryFullWebFrontend : DubRegistryWebFrontend { @auth @path("/register_package") @errorDisplay!getRegisterPackage void postRegisterPackage(string url, User _user, bool ignore_fork = false) { - import std.algorithm.searching : canFind; + import std.algorithm.comparison : among; + import dubregistry.repositories.repository : parseRepositoryURL; + + auto urls = parseUserURL(url, "https"); + if (!urls.schema.among!("http", "https")) + throw new Exception("Invalid repository schema: Only 'http' and 'https' are supported"); DbRepository rep; - if (!url.canFind("://")) - url = "https://" ~ url; - rep.parseURL(URL.fromString(url)); + if (!parseRepositoryURL(urls, rep)) // FIXME: query the actually registered providers here + throw new Exception("The provided URL does not match any supported provider (GitHub/BitBucket/GitLab)"); string kind = rep.kind; string owner = rep.owner; From b140eff8936006bd08a317b97ff4f2b8c0def4ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6nke=20Ludwig?= Date: Sun, 24 Sep 2023 19:00:27 +0200 Subject: [PATCH 04/11] Add doc comment for addRepositoryProvider. --- source/dubregistry/repositories/repository.d | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/source/dubregistry/repositories/repository.d b/source/dubregistry/repositories/repository.d index 11dc844a..d9f8f509 100644 --- a/source/dubregistry/repositories/repository.d +++ b/source/dubregistry/repositories/repository.d @@ -26,6 +26,13 @@ Repository getRepository(DbRepository repinfo) return rep; } + +/** Adds a new provider to support for accessing repositories. + + Note that currently only one provider instance of each `kind` may be used, + because the `kind` value is used to identify the provider as opposed to its + URL. +*/ void addRepositoryProvider(string kind, RepositoryProvider factory) @safe { assert(kind !in s_repositoryProviders); From e178f5fb11f12215bb41ee73ad6c051358181bb0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6nke=20Ludwig?= Date: Sun, 24 Sep 2023 16:27:07 +0200 Subject: [PATCH 05/11] Add Gitea support. Fixes #344. --- source/app.d | 2 + source/dubregistry/config.d | 4 + source/dubregistry/repositories/gitea.d | 284 ++++++++++++++++++++++++ 3 files changed, 290 insertions(+) create mode 100644 source/dubregistry/repositories/gitea.d diff --git a/source/app.d b/source/app.d index eec17106..d5a951ab 100644 --- a/source/app.d +++ b/source/app.d @@ -10,6 +10,7 @@ import dubregistry.mirror; import dubregistry.repositories.bitbucket; import dubregistry.repositories.github; import dubregistry.repositories.gitlab; +import dubregistry.repositories.gitea; import dubregistry.registry; import dubregistry.web; import dubregistry.api; @@ -134,6 +135,7 @@ void main() GithubRepositoryProvider.register(appConfig.ghauth); BitbucketRepositoryProvider.register(appConfig.bbuser, appConfig.bbpassword); if (appConfig.glurl.length) GitLabRepositoryProvider.register(appConfig.glauth, appConfig.glurl); + if (appConfig.giteaurl.length) GiteaRepositoryProvider.register(appConfig.giteaauth, appConfig.giteaurl); auto router = new URLRouter; if (s_mirror.length) router.any("*", (req, res) { req.params["mirror"] = s_mirror; }); diff --git a/source/dubregistry/config.d b/source/dubregistry/config.d index c4f85629..76016e5c 100644 --- a/source/dubregistry/config.d +++ b/source/dubregistry/config.d @@ -28,6 +28,10 @@ public struct AppConfig string bbuser; @Name("bitbucket-password") @Optional string bbpassword; + @Name("gitea-url") @Optional + string giteaurl; + @Name("gitea-auth") @Optional + string giteaauth; @Name("enforce-certificate-trust") bool enforceCertificateTrust = false; diff --git a/source/dubregistry/repositories/gitea.d b/source/dubregistry/repositories/gitea.d new file mode 100644 index 00000000..d04b6102 --- /dev/null +++ b/source/dubregistry/repositories/gitea.d @@ -0,0 +1,284 @@ +/** + Copyright: © 2023 Sönke Ludwig + License: Subject to the terms of the GNU GPLv3 license, as written in the included LICENSE.txt file. + Authors: Sönke Ludwig +*/ +module dubregistry.repositories.gitea; + +import dubregistry.cache; +import dubregistry.dbcontroller : DbRepository; +import dubregistry.repositories.repository; +import std.string : startsWith; +import std.typecons; +import vibe.core.log; +import vibe.core.stream; +import vibe.data.json; +import vibe.http.client : HTTPClientRequest; +import vibe.inet.url; + +class GiteaRepositoryProvider : RepositoryProvider { + private { + string m_token; + string m_url; + } +@safe: + + private this(string token, string url) + { + m_token = token; + m_url = url.length ? url : "https://gitea.com/"; + } + + static void register(string token, string url) + { + auto h = new GiteaRepositoryProvider(token, url); + addRepositoryProvider("gitea", h); + } + + bool parseRepositoryURL(URL url, out DbRepository repo) + @safe { + import std.algorithm.searching : endsWith; + import std.conv : to; + + string host = url.host; + if (!url.startsWith(URL(m_url)) && !host.endsWith(".gitea.com") && host != "gitea.com" && host != "gitea") + return false; + + repo.kind = "gitea"; + + auto path = url.path.relativeTo(InetPath("/")).bySegment; + + if (path.empty) throw new Exception("Invalid Repository URL (no path)"); + if (path.front.name.empty) throw new Exception("Invalid Repository URL (missing owner)"); + repo.owner = path.front.name.to!string; + path.popFront; + + if (path.empty || path.front.name.empty) + throw new Exception("Invalid Repository URL (missing project)"); + repo.project = path.front.name.to!string; + path.popFront; + + if (!path.empty) throw new Exception("Invalid Repository URL (got more than owner and project)"); + + return true; + } + + unittest { + import std.exception : assertThrown; + + auto h = new GiteaRepositoryProvider(null, "https://example.org"); + DbRepository r; + assert(h.parseRepositoryURL(URL("https://example.org/foo/bar"), r)); + assert(r == DbRepository("gitea", "foo", "bar")); + assert(h.parseRepositoryURL(URL("https://gitea.com/foo/bar"), r)); + assert(r == DbRepository("gitea", "foo", "bar")); + assert(!h.parseRepositoryURL(URL("http://bitbucket.org/bar/baz/"), r)); + assertThrown(h.parseRepositoryURL(URL("http://gitea.com/foo/"), r)); + assertThrown(h.parseRepositoryURL(URL("http://gitea.com/"), r)); + assertThrown(h.parseRepositoryURL(URL("http://gitea.com/foo/bar/baz"), r)); + } + + Repository getRepository(DbRepository repo) + @safe { + return new GiteaRepository(repo.owner, repo.project, m_token, m_url); + } +} + + +class GiteaRepository : Repository { +@safe: + private { + string m_owner; + string m_project; + string m_authToken; + string m_url; + } + + this(string owner, string project, string auth_token, string url) + { + assert(url.length > 0, "Missing URL for Gitea repository"); + + m_owner = owner; + m_project = project; + m_authToken = auth_token; + m_url = url; + if (m_url[$-1] != '/') m_url ~= "/"; + } + + RefInfo[] getTags() + { + import std.datetime.systime : SysTime; + import std.conv: text; + RefInfo[] ret; + Json[] tags; + try tags = readPagedListFromRepo("/tags?per_page=100"); + catch( Exception e ) { throw new Exception("Failed to get tags: "~e.msg); } + foreach_reverse (tag; tags) { + try { + auto tagname = tag["name"].get!string; + Json commit = readJsonFromRepo("/commits/"~tag["commit"]["sha"].get!string~"/status", true, true); + ret ~= RefInfo(tagname, tag["commit"]["sha"].get!string, SysTime.fromISOExtString(commit["statuses"][0]["created_at"].get!string)); + logDebug("Found tag for %s/%s: %s", m_owner, m_project, tagname); + } catch( Exception e ){ + throw new Exception("Failed to process tag "~tag["name"].get!string~": "~e.msg); + } + } + return ret; + } + + RefInfo[] getBranches() + { + import std.datetime.systime : SysTime; + + Json branches = readJsonFromRepo("/branches"); + RefInfo[] ret; + foreach_reverse( branch; branches ){ + auto branchname = branch["name"].get!string; + Json commit = readJsonFromRepo("/commits/"~branch["commit"]["id"].get!string~"/status", true, true); + ret ~= RefInfo(branchname, branch["commit"]["id"].get!string, SysTime.fromISOExtString(commit["statuses"][0]["created_at"].get!string)); + logDebug("Found branch for %s/%s: %s", m_owner, m_project, branchname); + } + return ret; + } + + RepositoryInfo getInfo() + { + auto nfo = readJsonFromRepo(""); + RepositoryInfo ret; + ret.isFork = nfo["fork"].opt!bool; + ret.stats.stars = nfo["stars_count"].opt!uint; + ret.stats.watchers = nfo["watchers_count"].opt!uint; + ret.stats.forks = nfo["forks_count"].opt!uint; + ret.stats.issues = nfo["open_issues_count"].opt!uint; // conflates PRs and Issues + return ret; + } + + RepositoryFile[] listFiles(string commit_sha, InetPath path) + { + assert(path.absolute, "Passed relative path to listFiles."); + auto url = "/contents"~path.toString()~"?ref="~commit_sha; + auto ls = readJsonFromRepo(url).get!(Json[]); + RepositoryFile[] ret; + ret.reserve(ls.length); + foreach (entry; ls) { + string type = entry["type"].get!string; + RepositoryFile file; + if (type == "dir") { + file.type = RepositoryFile.Type.directory; + } + else if (type == "file") { + file.type = RepositoryFile.Type.file; + file.size = entry["size"].get!size_t; + } + else continue; + file.commitSha = commit_sha; + file.path = InetPath("/" ~ entry["path"].get!string); + ret ~= file; + } + return ret; + } + + void readFile(string commit_sha, InetPath path, scope void delegate(scope InputStream) @safe reader) + { + assert(path.absolute, "Passed relative path to readFile."); + auto url = getContentURLPrefix()~m_owner~"/"~m_project~"/raw/commit/"~commit_sha~path.toString(); + downloadCached(url, (scope input) { + reader(input); + }, true, &addAuthentication); + } + + string getDownloadUrl(string ver) + { + import std.uri : encodeComponent; + if( ver.startsWith("~") ) ver = ver[1 .. $]; + else ver = ver; + auto venc = () @trusted { return encodeComponent(ver); } (); + return m_url~m_owner~"/"~m_project~"/archive/"~venc~".zip"; + } + + void download(string ver, scope void delegate(scope InputStream) @safe del) + { + downloadCached(getDownloadUrl(ver), del, false, &addAuthentication); + } + + private Json readJsonFromRepo(string api_path, bool sanitize = false, bool cache_priority = false) + { + return readJson(getAPIURLPrefix()~"repos/"~m_owner~"/"~m_project~api_path, + sanitize, cache_priority, &addAuthentication); + } + + private Json[] readPagedListFromRepo(string api_path, bool sanitize = false, bool cache_priority = false) + { + return readPagedList(getAPIURLPrefix()~"repos/"~m_owner~"/"~m_project~api_path, + sanitize, cache_priority, &addAuthentication); + } + + private void addAuthentication(scope HTTPClientRequest req) + { + req.headers["Authorization"] = "token " ~ m_authToken; + } + + private string getAPIURLPrefix() + { + return m_url ~ "api/v1/"; + } + + private string getContentURLPrefix() + { + return m_url; + } +} + +package Json[] readPagedList(string url, bool sanitize = false, bool cache_priority = false, RequestModifier request_modifier = null) +@safe { + import dubregistry.internal.utils : black; + import std.array : appender; + import std.format : format; + import vibe.stream.operations : readAllUTF8; + + auto ret = appender!(Json[]); + Exception ex; + string next = url; + + NextLoop: while (next.length) { + logDiagnostic("Getting paged JSON response from %s", next.black); + foreach (i; 0 .. 2) { + try { + downloadCached(next, (scope input, scope headers) { + scope (failure) clearCacheEntry(url); + next = getNextLink(headers); + + auto text = input.readAllUTF8(sanitize); + ret ~= parseJsonString(text).get!(Json[]); + }, ["Link"], cache_priority, request_modifier); + continue NextLoop; + } catch (FileNotFoundException e) { + throw e; + } catch (Exception e) { + logDiagnostic("Failed to parse downloaded JSON document (attempt #%s): %s", i+1, e.msg); + ex = e; + } + } + throw new Exception(format("Failed to read JSON from %s: %s", url.black, ex.msg), __FILE__, __LINE__, ex); + } + + return ret.data; +} + +private string getNextLink(scope string[string] headers) +@safe { + import uritemplate : expandTemplateURIString; + import std.algorithm : endsWith, splitter, startsWith; + + static immutable string startPart = `<`; + static immutable string endPart = `>; rel="next"`; + + if (auto link = "Link" in headers) { + foreach (part; (*link).splitter(", ")) { + if (part.startsWith(startPart) && part.endsWith(endPart)) { + return expandTemplateURIString(part[startPart.length .. $ - endPart.length], null); + } + } + } + return null; +} From e14f7b16a9c1d4f294ce8b6f6c92f00fb8f9c2ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6nke=20Ludwig?= Date: Sun, 24 Sep 2023 20:53:10 +0200 Subject: [PATCH 06/11] Fix runtime error when accessing packages with no shared users. --- source/dubregistry/dbcontroller.d | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/dubregistry/dbcontroller.d b/source/dubregistry/dbcontroller.d index 2eec5af6..122ac6a9 100644 --- a/source/dubregistry/dbcontroller.d +++ b/source/dubregistry/dbcontroller.d @@ -144,7 +144,7 @@ class DbController { { static struct PO { BsonObjectID owner; - DbPackage.SharedUser[] sharedUsers; + @optional DbPackage.SharedUser[] sharedUsers; } auto p = m_packages.findOne!PO(["name": package_name], ["owner": 1, "sharedUsers": 1]); From 916fa6ef7169f6561eed18dbf4e9a30990960b3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6nke=20Ludwig?= Date: Sun, 24 Sep 2023 20:54:36 +0200 Subject: [PATCH 07/11] Allow choosing Gitea repository kind on package management page. --- views/my_packages.package.dt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/views/my_packages.package.dt b/views/my_packages.package.dt index d7ab6a26..3c4300be 100644 --- a/views/my_packages.package.dt +++ b/views/my_packages.package.dt @@ -131,6 +131,8 @@ block body option(value="bitbucket", selected=rkind == "bitbucket", disabled=!permSource) Bitbucket - if (supportsRepositoryKind("gitlab")) option(value="gitlab", selected=rkind == "gitlab", disabled=!permSource) GitLab + - if (supportsRepositoryKind("gitea")) + option(value="gitea", selected=rkind == "gitea", disabled=!permSource) Gitea p label(for="owner") Repository owner: input(type="text", name="owner", value=pack["repository"]["owner"].get!string, disabled=!permSource) From 7727749c9aab5e301e54b630d597bde799f3700c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6nke=20Ludwig?= Date: Sun, 24 Sep 2023 21:13:07 +0200 Subject: [PATCH 08/11] Make tag and branch listing more robust for Gitea. Sometimes the commit/statuses endpoint results in an empty response. But since the required information is already in the branch/tag response, we can just read it from there instead. --- source/dubregistry/repositories/gitea.d | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/source/dubregistry/repositories/gitea.d b/source/dubregistry/repositories/gitea.d index d04b6102..601da213 100644 --- a/source/dubregistry/repositories/gitea.d +++ b/source/dubregistry/repositories/gitea.d @@ -116,8 +116,7 @@ class GiteaRepository : Repository { foreach_reverse (tag; tags) { try { auto tagname = tag["name"].get!string; - Json commit = readJsonFromRepo("/commits/"~tag["commit"]["sha"].get!string~"/status", true, true); - ret ~= RefInfo(tagname, tag["commit"]["sha"].get!string, SysTime.fromISOExtString(commit["statuses"][0]["created_at"].get!string)); + ret ~= RefInfo(tagname, tag["commit"]["sha"].get!string, SysTime.fromISOExtString(tag["commit"]["created"].get!string)); logDebug("Found tag for %s/%s: %s", m_owner, m_project, tagname); } catch( Exception e ){ throw new Exception("Failed to process tag "~tag["name"].get!string~": "~e.msg); @@ -134,8 +133,7 @@ class GiteaRepository : Repository { RefInfo[] ret; foreach_reverse( branch; branches ){ auto branchname = branch["name"].get!string; - Json commit = readJsonFromRepo("/commits/"~branch["commit"]["id"].get!string~"/status", true, true); - ret ~= RefInfo(branchname, branch["commit"]["id"].get!string, SysTime.fromISOExtString(commit["statuses"][0]["created_at"].get!string)); + ret ~= RefInfo(branchname, branch["commit"]["id"].get!string, SysTime.fromISOExtString(branch["commit"]["timestamp"].get!string)); logDebug("Found branch for %s/%s: %s", m_owner, m_project, branchname); } return ret; From 71ee13795bec116f8b85615c3a4be9dd7f49397c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6nke=20Ludwig?= Date: Sun, 24 Sep 2023 21:15:18 +0200 Subject: [PATCH 09/11] Remove matching gitea.com for GiteaRepositoryProviders targeting a different URL. --- source/dubregistry/repositories/gitea.d | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/source/dubregistry/repositories/gitea.d b/source/dubregistry/repositories/gitea.d index 601da213..4e812c13 100644 --- a/source/dubregistry/repositories/gitea.d +++ b/source/dubregistry/repositories/gitea.d @@ -41,7 +41,7 @@ class GiteaRepositoryProvider : RepositoryProvider { import std.conv : to; string host = url.host; - if (!url.startsWith(URL(m_url)) && !host.endsWith(".gitea.com") && host != "gitea.com" && host != "gitea") + if (!url.startsWith(URL(m_url))) return false; repo.kind = "gitea"; @@ -70,12 +70,12 @@ class GiteaRepositoryProvider : RepositoryProvider { DbRepository r; assert(h.parseRepositoryURL(URL("https://example.org/foo/bar"), r)); assert(r == DbRepository("gitea", "foo", "bar")); - assert(h.parseRepositoryURL(URL("https://gitea.com/foo/bar"), r)); + assert(h.parseRepositoryURL(URL("https://example.org/foo/bar"), r)); assert(r == DbRepository("gitea", "foo", "bar")); assert(!h.parseRepositoryURL(URL("http://bitbucket.org/bar/baz/"), r)); - assertThrown(h.parseRepositoryURL(URL("http://gitea.com/foo/"), r)); - assertThrown(h.parseRepositoryURL(URL("http://gitea.com/"), r)); - assertThrown(h.parseRepositoryURL(URL("http://gitea.com/foo/bar/baz"), r)); + assertThrown(h.parseRepositoryURL(URL("https://example.org/foo/"), r)); + assertThrown(h.parseRepositoryURL(URL("https://example.org/"), r)); + assertThrown(h.parseRepositoryURL(URL("https://example.org/foo/bar/baz"), r)); } Repository getRepository(DbRepository repo) From ec9c139066f7317317459985e92560f8c9f09f10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6nke=20Ludwig?= Date: Mon, 25 Sep 2023 16:09:33 +0200 Subject: [PATCH 10/11] Fix accessing zipballs of private Gitea repositories. Needs to proxy from the Gitea API instead of redirecting to the web interface download URL. --- source/dubregistry/repositories/gitea.d | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/source/dubregistry/repositories/gitea.d b/source/dubregistry/repositories/gitea.d index 4e812c13..f84111e7 100644 --- a/source/dubregistry/repositories/gitea.d +++ b/source/dubregistry/repositories/gitea.d @@ -92,6 +92,7 @@ class GiteaRepository : Repository { string m_project; string m_authToken; string m_url; + bool m_public; } this(string owner, string project, string auth_token, string url) @@ -103,6 +104,7 @@ class GiteaRepository : Repository { m_authToken = auth_token; m_url = url; if (m_url[$-1] != '/') m_url ~= "/"; + m_public = m_url == "https://gitea.com/"; // TODO: determine from repsitory } RefInfo[] getTags() @@ -188,7 +190,8 @@ class GiteaRepository : Repository { string getDownloadUrl(string ver) { import std.uri : encodeComponent; - if( ver.startsWith("~") ) ver = ver[1 .. $]; + if (!m_public) return null; + if (ver.startsWith("~")) ver = ver[1 .. $]; else ver = ver; auto venc = () @trusted { return encodeComponent(ver); } (); return m_url~m_owner~"/"~m_project~"/archive/"~venc~".zip"; @@ -196,7 +199,13 @@ class GiteaRepository : Repository { void download(string ver, scope void delegate(scope InputStream) @safe del) { - downloadCached(getDownloadUrl(ver), del, false, &addAuthentication); + import std.uri : encodeComponent; + if (ver.startsWith("~")) ver = ver[1 .. $]; + else ver = ver; + auto venc = () @trusted { return encodeComponent(ver); } (); + auto url = getAPIURLPrefix()~"repos/"~m_owner~"/"~m_project~"/archive/"~venc~".zip"; + + downloadCached(url, del, false, &addAuthentication); } private Json readJsonFromRepo(string api_path, bool sanitize = false, bool cache_priority = false) From 2a28612d81de0cf2a96221515f49bad03e1e25f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6nke=20Ludwig?= Date: Mon, 25 Sep 2023 16:09:54 +0200 Subject: [PATCH 11/11] Use cache priority for file listings to avoid unnecessary traffic. --- source/dubregistry/repositories/gitea.d | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/dubregistry/repositories/gitea.d b/source/dubregistry/repositories/gitea.d index f84111e7..6d45ea1c 100644 --- a/source/dubregistry/repositories/gitea.d +++ b/source/dubregistry/repositories/gitea.d @@ -157,7 +157,7 @@ class GiteaRepository : Repository { { assert(path.absolute, "Passed relative path to listFiles."); auto url = "/contents"~path.toString()~"?ref="~commit_sha; - auto ls = readJsonFromRepo(url).get!(Json[]); + auto ls = readJsonFromRepo(url, false, true).get!(Json[]); RepositoryFile[] ret; ret.reserve(ls.length); foreach (entry; ls) {