Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add webhooks for instant package updates - Fix #10 #414

Draft
wants to merge 1 commit into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions http-update.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Triggering version updates over HTTP

To queue an update of your package you can use the `POST /api/packages/:packageName/update` endpoint.

## `POST /api/packages/:packageName/update`

Queues an update for the specified package.

Query params:
`secret`: string (optional) provide the secret as query
`header`: string (optional) provide which header is used to check the secret (must start with X-)

Body params: (application/x-www-form-urlencoded, multipart/form-data or application/json)
`secret`: string (optional) provide the secret as body param
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, what's the use case for this? Isn't this prone to be over-used/falsely used?
(I'm referring to the API method itself)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wanted to make sure it's easy to incoperate this into any service you might be using for webhooks, so I'm offering to make the API callable with a secret per query string, body param or per HTTP Header.


## `POST /api/packages/:packageName/update/github`

Queues an update for the specified package. Compatible with GitHub webhooks and only triggers on `create` events. Must pass secret as query param and not in GitHub webhook settings.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, you still need to save the GitHub secret here to verify that it's the actual owner (though this is theoretically not needed).

Also, we need to rate-limit this.
And lastly it would be ideal to not use extra GitHub API requests for every event, but get as much of the info as possible.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wanted to implement github properly at first, but vibe.d with rest interface doesn't give me a way to get the full request body as string, which I need for the SHA1 HMAC verification with the GitHub secret field (at least I didn't find any way)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also I don't think any of the other dub API currently has rate limiting yet. This will only queue a recheck and is basically the same as spamming the "check for updates" button on your project page

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wanted to implement github properly at first,

Don't reinvent the wheel -> https://github.com/dlang/dlang-bot

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

basically the same as spamming the "check for updates" button on your project page

Fair point, but by providing hooks we make it a lot easier for people to spam / run us into the rate-limits of GitHub.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wanted to implement github properly at first,

Don't reinvent the wheel -> dlang/dlang-bot

I more accurately meant I wanted to support the "Secret" field on GitHub which sends a Signature of the body text as SHA1 HMAC with the Secret as password, but with vibe.d the rest interface implementation already ate the body so I couldn't access the raw string from code to hash. For hashing the stdlib provides everything needed

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Regarding the rate limiting, if we have web hook support, I would propose to stretch the polling over a much larger period than now (e.g. 6 hours vs. 30 minutes), so that it merely acts as a fallback in case the web hooks fail. This should reduce the risk of running into the rate limit considerable when compared to the current state.

Non-webhook repositories could be kept at the 30 min. polling interval, of course.

Regarding the signature issue, I believe this should work using an @before parameter that reads the body, validates the signature, and then parses and returns the JSON.


## `POST /api/packages/:packageName/update/gitlab`

Queues an update for the specified package. Compatible with GitLab webhooks and only triggers on `tag_push` events. The secret is specified in the GitLab control panel.

Calls `POST /api/packages/:packageName/update` with `header=X-Gitlab-Token` query param after hook parsing.
70 changes: 70 additions & 0 deletions source/dubregistry/api.d
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,18 @@ interface IPackages {
Json getInfo(string _name, string _version, bool minimize = false);

Json[string] getInfos(string[] packages, bool include_dependencies = false, bool minimize = false);

@path(":name/update")
string postUpdate(string _name, string secret = "");

@path(":name/update/github")
@headerParam("event", "X-GitHub-Event")
@queryParam("secret", "secret")
string postUpdateGithub(string _name, string secret, string event, Json hook = Json.init);

@path(":name/update/gitlab")
@headerParam("secret", "X-Gitlab-Token")
string postUpdateGitlab(string _name, string secret, string object_kind = "");
}

class LocalDubRegistryAPI : DubRegistryAPI {
Expand Down Expand Up @@ -176,6 +188,52 @@ override {
.check!(r => r !is null)(HTTPStatus.notFound, "None of the packages were found")
.byKeyValue.map!(p => tuple(p.key, p.value.info)).assocArray;
}

@before!updateSecretReader("secret")
string postUpdate(string _name, string secret = "")
{
if (!secret.length)
return "No secret sent";

string expected = m_registry.getPackageSecret(_name);
if (!expected.length || secret != expected)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

use constant-time comparison

return "Secret doesn't match";

m_registry.triggerPackageUpdate(_name);
return "Queued package update";
}

string postUpdateGithub(string _name, string secret, string event, Json hook = Json.init)
{
if (event == "create") {
return postUpdate(_name, secret);
} else if (event == "ping") {
enforceBadRequest(hook.type == Json.Type.object, "hook is not of type json");
auto eventsObj = *enforceBadRequest("events" in hook, "no events object sent in hook object");
enforceBadRequest(eventsObj.type == Json.Type.array, "Hook events must be of type array");
auto events = eventsObj.get!(Json[]);

foreach (ev; events)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can simplify the eventsObj to events and just iterate over Json here

if (ev.type == Json.Type.string && ev.get!string == "create")
return "valid";

string expected = m_registry.getPackageSecret(_name);
if (expected.length && secret.length && secret == expected)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

use constant-time compare

m_registry.addPackageError(_name,
"GitHub hook configuration is invalid. Hook is missing 'create' event. (Tags or branches)");
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this test code will never be run if configuration is valid


return "invalid hook - create event missing";
} else
return "ignored event " ~ event;
}

string postUpdateGitlab(string _name, string secret, string object_kind)
{
if (object_kind != "tag_push")
return "ignored event " ~ object_kind;

return postUpdate(_name, secret);
}
}

private:
Expand All @@ -187,6 +245,18 @@ private:
}
}

private string updateSecretReader(scope HTTPServerRequest req, scope HTTPServerResponse res)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this needs a better name

{
string header = req.query.get("header", "");
if (header.length)
return req.headers.get(header);

string ret = req.contentType == "application/json" ? req.json["secret"].get!string : req.form.get("secret", "");
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

probably want to tryIndex the JSON

if (ret.length)
return ret;

return req.query.get("secret", "");
}

private auto ref T check(alias cond, T)(auto ref T t, HTTPStatus status, string msg)
{
Expand Down
27 changes: 27 additions & 0 deletions source/dubregistry/dbcontroller.d
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,16 @@ class DbController {
m_packages.update(["name": packname], ["$set": ["errors": error]]);
}

void addPackageError(string packname, string error)
{
m_packages.update(["name": packname], ["$push": ["errors": error]]);
}

void clearPackageErrors(string packname)
{
m_packages.update(["name": packname], ["$set": ["errors": Bson.emptyArray]]);
}

void setPackageCategories(string packname, string[] categories...)
{
m_packages.update(["name": packname], ["$set": ["categories": categories]]);
Expand Down Expand Up @@ -251,6 +261,21 @@ class DbController {
return data.get.data.rawData;
}

string getPackageSecret(string packname)
{
auto ret = m_packages.findOne(["name": packname]).tryIndex("secret");
if (ret.isNull) return null;
else return ret.get.get!string;
}

void setPackageSecret(string packname, string secret)
{
if (secret.length)
m_packages.update(["name": packname], ["$set": ["secret": secret]]);
else
m_packages.update(["name": packname], ["$unset": ["secret": 0]]);
}

void addVersion(string packname, DbPackageVersion ver)
{
assert(ver.version_.startsWith("~") || ver.version_.isValidVersion());
Expand Down Expand Up @@ -496,6 +521,8 @@ struct DbPackage {
@optional BsonObjectID logo; // reference to m_files
@optional string documentationURL;
@optional float textScore = 0; // for FTS textScore in searchPackages
/// Random secret string used for package management API (webhooks)
@optional string secret;
}

struct DbRepository {
Expand Down
38 changes: 37 additions & 1 deletion source/dubregistry/registry.d
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,7 @@ class DubRegistry {
.map!(pack => getPackageInfo(pack, flags));
}

auto getPackageInfo(DbPackage pack, PackageInfoFlags flags = PackageInfoFlags.none)
PackageInfo getPackageInfo(DbPackage pack, PackageInfoFlags flags = PackageInfoFlags.none)
{
auto rep = getRepository(pack.repository);

Expand All @@ -261,6 +261,8 @@ class DubRegistry {
}
if (flags & PackageInfoFlags.includeErrors)
nfo["errors"] = serializeToJson(pack.errors);
if (flags & PackageInfoFlags.includeAdmin)
ret.secret = pack.secret;

ret.info = nfo;

Expand Down Expand Up @@ -429,6 +431,38 @@ class DubRegistry {
return m_db.getPackageLogo(pack_name, rev);
}

void addPackageError(string pack_name, string error)
{
m_db.addPackageError(pack_name, error);
}

void clearPackageErrors(string pack_name)
{
m_db.clearPackageErrors(pack_name);
}

void unsetPackageSecret(string pack_name)
{
m_db.setPackageSecret(pack_name, null);
}

string getPackageSecret(string pack_name)
{
return m_db.getPackageSecret(pack_name);
}

void regenPackageSecret(string pack_name)
{
import std.ascii : lowerHexDigits;
import std.random : uniform;

char[24] token;
foreach (ref c; token)
c = lowerHexDigits[uniform(0, $)];
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TODO: use secure random instead of uniform


m_db.setPackageSecret(pack_name, token[].idup);
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can just allocate in the first place instead of stack allocating & idup

}

void updatePackages()
{
logDiagnostic("Triggering package update...");
Expand Down Expand Up @@ -733,6 +767,7 @@ struct PackageVersionInfo {
struct PackageInfo {
PackageVersionInfo[] versions;
BsonObjectID logo;
string secret;
Json info; /// JSON package information, as reported to the client
}

Expand All @@ -743,6 +778,7 @@ enum PackageInfoFlags
includeDependencies = 1 << 0, /// include package info of dependencies
includeErrors = 1 << 1, /// include package errors
minimize = 1 << 2, /// return only minimal information (for dependency resolver)
includeAdmin = 1 << 3, /// include package admin information such as secrets
}

/// Computes a package score from given package stats and global distributions of those stats.
Expand Down
22 changes: 20 additions & 2 deletions source/dubregistry/web.d
Original file line number Diff line number Diff line change
Expand Up @@ -649,13 +649,13 @@ class DubRegistryFullWebFrontend : DubRegistryWebFrontend {
{
enforceUserPackage(_user, _packname);
auto packageName = _packname;
auto nfo = m_registry.getPackageInfo(packageName, PackageInfoFlags.includeErrors);
auto nfo = m_registry.getPackageInfo(packageName, PackageInfoFlags.includeErrors | PackageInfoFlags.includeAdmin);
if (nfo.info.type == Json.Type.null_) return;
auto categories = m_categories;
auto registry = m_registry;
auto user = _user;
auto error = _error;
render!("my_packages.package.dt", packageName, categories, user, registry, error);
render!("my_packages.package.dt", packageName, categories, user, registry, error, nfo);
}

@auth @path("/my_packages/:packname/update")
Expand Down Expand Up @@ -754,6 +754,24 @@ class DubRegistryFullWebFrontend : DubRegistryWebFrontend {
redirect("/my_packages/"~_packname);
}

@auth @path("/my_packages/:packname/regen_secret")
void postPackageRegenSecret(string _packname, User _user)
{
enforceUserPackage(_user, _packname);
m_registry.regenPackageSecret(_packname);

redirect("/my_packages/"~_packname~"#repository");
}

@auth @path("/my_packages/:packname/unset_secret")
void postPackageUnsetSecret(string _packname, User _user)
{
enforceUserPackage(_user, _packname);
m_registry.unsetPackageSecret(_packname);

redirect("/my_packages/"~_packname~"#repository");
}

@path("/docs/commandline")
void getCommandLineDocs()
{
Expand Down
38 changes: 34 additions & 4 deletions views/my_packages.package.dt
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,7 @@ block body
- import dubregistry.registry : PackageInfoFlags;
- import dubregistry.web : Category;

- auto pack = registry.getPackageInfo(packageName, PackageInfoFlags.includeErrors).info;

- auto pack = nfo.info;
h1 Edit package #[a.blind(href="#{req.rootDir}packages/#{packageName}")= packageName]
- auto latest = pack["versions"].length ? pack["versions"][pack["versions"].length-1] : Json(null);
- if (latest.type == Json.Type.Object)
Expand Down Expand Up @@ -82,9 +81,10 @@ block body
p Package is scheduled for an automatic update check. Still have to wait for one more package.
- else if (update_check_index > 1)
p Package is scheduled for an automatic update check. Still have to wait for #{update_check_index} other packages to complete.
form.inputForm(method="POST", action="#{req.rootDir}my_packages/#{packageName}/update")
p
.updates
form.inputForm(method="POST", action="#{req.rootDir}my_packages/#{packageName}/update")
button(type="submit") Trigger manual update
a(href="#repository"): button Setup update hook

section.pkgconfig#tab-categories
a#categories(name="categories")
Expand Down Expand Up @@ -116,6 +116,36 @@ block body
a#repository(name="repository")
h2 Repository

h3 Update Hooks
p You can POST to a special endpoint on the dub registry to trigger a manual package update.
p Setup these hooks in GitHub, GitLab or some other service you use to trigger when you push a new tag to make them available on the registry as quickly as possible.
- auto secret = nfo.secret;
.inputForm
- if (secret.length)
- auto apiUrl = req.fullURL;
- apiUrl.path = typeof(apiUrl.path).init;
p
label(for="generic") Generic update webhook: (POST)
input#generic(type="text", readonly, value="#{apiUrl}/api/packages/#{packageName}/update")
p
label(for="generic") GitHub webhook:
input#generic(type="text", readonly, value="#{apiUrl}/api/packages/#{packageName}/update/github?secret=#{secret}")
p
label(for="generic") GitLab webhook:
input#generic(type="text", readonly, value="#{apiUrl}/api/packages/#{packageName}/update/gitlab")
hr
p
label(for="generic") Secret:
input#generic(type="text", readonly, value=secret)
hr
form(method="POST", action="#{req.rootDir}my_packages/#{packageName}/unset_secret", onsubmit="confirm('Are you sure you want to revoke the secret and disable webhook support? Existing webhooks will not work anymore.')")
button(type="submit") Revoke Webhooks
form(method="POST", action="#{req.rootDir}my_packages/#{packageName}/regen_secret", onsubmit="confirm('Are you sure you want to regenerate the secret? Existing webhooks will not work anymore.')")
button(type="submit") Invalidate Secret
- else
form(method="POST", action="#{req.rootDir}my_packages/#{packageName}/regen_secret")
button(type="submit") Enable Webhooks

h3 Transfer Repository
.inputForm
form(method="POST", action="#{req.rootDir}my_packages/#{packageName}/set_repository")
Expand Down