diff --git a/dub.json b/dub.json index da535bb..0ff17ad 100644 --- a/dub.json +++ b/dub.json @@ -5,11 +5,16 @@ "copyright": "Copyright © 2019, Chris Josten", "dependencies": { "dyaml": "~>0.8.0", + "htmld": "~>0.3.7", "vibe-d": "~>0.9.0" }, "description": "A blog based on Markdown and JSON", "license": "AGPLv3", + "mainSourceFile": "source/nl/netsoj/chris/blog/main.d", "name": "mijnblog", - "targetType": "executable", - "stringImportPaths": ["views", "translations"] -} + "stringImportPaths": [ + "views", + "translations" + ], + "targetType": "executable" +} \ No newline at end of file diff --git a/dub.selections.json b/dub.selections.json index 5518a38..d511768 100644 --- a/dub.selections.json +++ b/dub.selections.json @@ -7,7 +7,9 @@ "dyaml": "0.8.3", "eventcore": "0.9.13", "fswatch": "0.5.0", + "htmld": "0.3.7", "libasync": "0.8.6", + "libdominator": "1.1.7", "libevent": "2.0.2+2.0.16", "memutils": "1.0.4", "mir-linux-kernel": "1.0.1", diff --git a/source/nl/netsoj/chris/blog/cache.d b/source/nl/netsoj/chris/blog/cache.d index 50e2e87..46d4e3f 100644 --- a/source/nl/netsoj/chris/blog/cache.d +++ b/source/nl/netsoj/chris/blog/cache.d @@ -1,9 +1,14 @@ +/** + * Implements and holds caches for several pages. + */ +module nl.netsoj.chris.blog.cache; + import std.experimental.logger; import std.traits; -import article; -import page; -import project; +import nl.netsoj.chris.blog.model.article; +import nl.netsoj.chris.blog.model.page; +import nl.netsoj.chris.blog.model.project; /** @@ -21,7 +26,7 @@ GenericCache!(Project, "a.title < b.title") projects; * again if needed. */ struct GenericCache(T, string sortOrder) - if (isImplicitlyConvertible!(T, Page)) { + if (is(T : Page)) { public: void addItem(T item) { diff --git a/source/nl/netsoj/chris/blog/constants.d b/source/nl/netsoj/chris/blog/constants.d index 8b665d3..ee4b395 100644 --- a/source/nl/netsoj/chris/blog/constants.d +++ b/source/nl/netsoj/chris/blog/constants.d @@ -1,8 +1,14 @@ +module nl.netsoj.chris.blog.constants; + /** * Constants which are passed to templates while rendering. */ class Constants { public static immutable string SITE_NAME = "Chris Josten's site"; - public static immutable string SITE_URL = "https://chris.netsoj.nl"; + debug { + public static immutable string SITE_URL = "https://kortstondig.chris.netsoj.nl"; + } else { + public static immutable string SITE_URL = "https://chris.netsoj.nl"; + } public static immutable string COPYRIGHT = "© Chris Josten, 2020"; } diff --git a/source/nl/netsoj/chris/blog/interfaces/http.d b/source/nl/netsoj/chris/blog/interfaces/http.d index 0437bcb..dfcb0cd 100644 --- a/source/nl/netsoj/chris/blog/interfaces/http.d +++ b/source/nl/netsoj/chris/blog/interfaces/http.d @@ -1,9 +1,15 @@ +module nl.netsoj.chris.blog.interfaces.http; + +import std.experimental.logger; + import vibe.d; -import cache; -import article; -import page; -import project; +import nl.netsoj.chris.blog.interfaces.indieauth; +import nl.netsoj.chris.blog.model.article; +import nl.netsoj.chris.blog.model.page; +import nl.netsoj.chris.blog.model.project; +import nl.netsoj.chris.blog.cache; +import nl.netsoj.chris.blog.constants; /** * Output types for the content. @@ -69,29 +75,41 @@ struct TranslateContext { * templateName = The name of the template to render. */ string singleResponseMixin(string arrayName, string templateName) { - return `string slug = req.params["slug"]; - OutputType outputType = getOutputType(slug); + return `string slug = req.params["slug"]; + OutputType outputType = getOutputType(slug); - enforceHTTP(slug in ` ~ arrayName ~ `, HTTPStatus.notFound, "Page not found"); - auto content = ` ~ arrayName ~ `[slug]; - switch(outputType) with (OutputType) { - case MARKDOWN: - res.writeBody(content.contentSource, MIME_MARKDOWN); - break; - default: - case HTML: - render!("` ~ templateName ~ `", content); - break; - }`; - } + enforceHTTP(slug in ` ~ arrayName ~ `, HTTPStatus.notFound, "Page not found"); + auto content = ` ~ arrayName ~ `[slug]; + switch(outputType) with (OutputType) { + case MARKDOWN: + res.writeBody(content.contentSource, MIME_MARKDOWN); + break; + default: + case HTML: + render!("` ~ templateName ~ `", content); + break; + }`; +} @translationContext!TranslateContext class MijnBlog { public: + + this() { + m_indieAuth = new IndieAuth(); + } + + /** + * IndieAuth subinterface + */ + @safe + @path("/indieweb") + @property IndieAuth indieweb() { return m_indieAuth; }; + /** - * Generates response for /posts/:slug and /palen/:slug. - */ + * Generates response for /posts/:slug and /palen/:slug. + */ @path("/posts/:slug") void getArticleSingle(string _slug, HTTPServerRequest req, HTTPServerResponse res) { //getSingle!(Article, "pages/article.dt")(articles, req, res); @@ -140,8 +158,13 @@ public: addCachingHeader(res); // If no slug is supplied, it will be adjusted to "index" req.params.addField("slug", "index"); + res.headers.addField("Link", "<" ~ Constants.SITE_URL ~ "/indieweb/auth>; rel=\"authorization_endpoint\""); + res.headers.addField("Link", "<" ~ Constants.SITE_URL ~ "/indieweb/token>; rel=\"token_endpoint\""); + res.headers.addField("Link", "<" ~ Constants.SITE_URL ~ "/indieweb/micropub>; rel=\"micropub\""); mixin(singleResponseMixin("pages", "pages/page.dt")); - } + } +private: + IndieAuth m_indieAuth; } /** @@ -149,7 +172,9 @@ public: */ @safe void errorPage(HTTPServerRequest req, HTTPServerResponse res, HTTPServerErrorInfo error) { - render!("pages/error.dt", error)(res); + //render!("pages/error.dt", error)(res); + import std.conv; + res.writeBody(text("Error ", error.code, ": ", error.message, "\n\n", error.debugMessage), "text/plain"); } @trusted @@ -171,4 +196,7 @@ void startHTTPServer() { router.registerWebInterface(new MijnBlog); listenHTTP(settings, router); + foreach(route; router.getAllRoutes()) { + infof("Path: %s", route); + } } diff --git a/source/nl/netsoj/chris/blog/interfaces/indieauth.d b/source/nl/netsoj/chris/blog/interfaces/indieauth.d new file mode 100644 index 0000000..841f73c --- /dev/null +++ b/source/nl/netsoj/chris/blog/interfaces/indieauth.d @@ -0,0 +1,92 @@ +module nl.netsoj.chris.blog.interfaces.indieauth; + +import nl.netsoj.chris.blog.interfaces.http; +import nl.netsoj.chris.blog.interfaces.micropub; +import nl.netsoj.chris.blog.microformats.parser; + +import mfd = nl.netsoj.chris.blog.microformats.definitions; + +import vibe.d; + +struct App { + string logo;; + string name; + string url; + string clientId; + string redirectUri; + bool richInfo = false; +} + +/** + * An implementation of https://www.w3.org/TR/indieauth/ + */ +@translationContext!TranslateContext +class IndieAuth { +public: + this() { + m_microPub = new MicroPub(); + } + + @queryParam("response_type", "response_type") + @queryParam("me", "me") + @queryParam("client_id", "client_id") + @queryParam("redirect_uri", "redirect_uri") + @queryParam("state", "state") + void getAuth(HTTPServerRequest req, HTTPServerResponse res, + string response_type, string me, string client_id, string redirect_uri, string state) { + enforceHTTP(response_type == "code", HTTPStatus.badRequest); + + URL source = URL(client_id); + HTTPClientResponse response = requestHTTP(source); + mfd.App[] parsedApps = parsePage!(mfd.App)(response.bodyReader.readAllUTF8, source); + + App app; + app.name = client_id; + app.richInfo = false; + if (parsedApps.length > 0) { + auto parsedApp = parsedApps[0]; + app.logo = parsedApp.logo; + app.name = parsedApp.name; + app.richInfo = true; + } + + app.clientId = client_id; + URL redirectUrl = URL(redirect_uri); + redirectUrl.queryString = redirectUrl.queryString ~ "%scode=%s&state=%s".format( + (redirectUrl.queryString.length == 0 ? "" : "&"), + "123456", state); + app.redirectUri = redirectUrl.toString(); + render!("pages/indieauth.dt", app); + } + + @safe + @path("/") + void getIndex(HTTPServerResponse res) { + res.writeBody("IndieAuth root", "text/plain"); + } + + @safe + void postToken(HTTPServerRequest req, HTTPServerResponse res, + string grant_type, string code, string client_id, string redirect_uri, string me) { + + struct OkResponse { + string access_token; + string token_type = "Bearer"; + string me; + string scope_; + } + + OkResponse response; + response.me = me; + response.scope_ = "foo"; + response.access_token = "baz"; + + res.writeJsonBody(response, HTTPStatus.ok); + } + + @safe @property + MicroPub micropub() { return m_microPub; }; + +private: + MicroPub m_microPub; +} diff --git a/source/nl/netsoj/chris/blog/interfaces/micropub.d b/source/nl/netsoj/chris/blog/interfaces/micropub.d new file mode 100644 index 0000000..7e6ece2 --- /dev/null +++ b/source/nl/netsoj/chris/blog/interfaces/micropub.d @@ -0,0 +1,14 @@ +module nl.netsoj.chris.blog.interfaces.micropub; + +import vibe.d; + +import nl.netsoj.chris.blog.interfaces.http; + +@translationContext!TranslateContext +class MicroPub { + + @path("/") + void getIndex(HTTPServerRequest req, HTTPServerResponse res) { + res.writeBody("MicroPub endpoint", "text/plain"); + } +} diff --git a/source/nl/netsoj/chris/blog/main.d b/source/nl/netsoj/chris/blog/main.d index f647a10..5174e36 100644 --- a/source/nl/netsoj/chris/blog/main.d +++ b/source/nl/netsoj/chris/blog/main.d @@ -1,13 +1,14 @@ +module nl.netsoj.chris.blog.main; + import std.experimental.logger; import vibe.d; -import article; -import page; -import project; - -import cache; -import http; -import watcher; +import nl.netsoj.chris.blog.interfaces.http; +import nl.netsoj.chris.blog.model.article; +import nl.netsoj.chris.blog.model.page; +import nl.netsoj.chris.blog.model.project; +import nl.netsoj.chris.blog.cache; +import nl.netsoj.chris.blog.watcher; void main() { diff --git a/source/nl/netsoj/chris/blog/microformats/definitions.d b/source/nl/netsoj/chris/blog/microformats/definitions.d new file mode 100644 index 0000000..66c0417 --- /dev/null +++ b/source/nl/netsoj/chris/blog/microformats/definitions.d @@ -0,0 +1,12 @@ +module nl.netsoj.chris.blog.microformats.definitions; + +import nl.netsoj.chris.blog.microformats.parser; + +struct App { + @MicroFormat(MicroFormat.Type.Url) + string logo; + @MicroFormat(MicroFormat.Type.PlainText) + string name; + + string url; +} diff --git a/source/nl/netsoj/chris/blog/microformats/parser.d b/source/nl/netsoj/chris/blog/microformats/parser.d new file mode 100644 index 0000000..d72279a --- /dev/null +++ b/source/nl/netsoj/chris/blog/microformats/parser.d @@ -0,0 +1,116 @@ +/** + * Parser for microformats. + * Standards: http://microformats.org/wiki/microformats2-parsing + */ +module nl.netsoj.chris.blog.microformats.parser; + +import std.exception; +import std.traits; + +import vibe.inet.url; + +import html; + +import nl.netsoj.chris.blog.url; + +class MicroFormatParseException : Exception { + mixin basicExceptionCtors; +} + + +struct MicroFormatProperty { + enum Type { + RootClass, + PlainText, + Url, + DateTime, + EmbeddedMarkup + }; + + Type type = Type.PlainText; + + string getClassPrefix() pure { + final switch(type) { + case Type.RootClass: + return "h-"; + case Type.PlainText: + return "p-"; + case Type.Url: + return "u-"; + case Type.DateTime: + return "dt-"; + case Type.EmbeddedMarkup: + return "e-"; + } + } +}; + +/** + * Parses a web page to extract to the given microformat model out of it + */ +T[] parsePage(T)(string source, URL url) { + return parsePage!T(createDocument(source), url); +} + +T[] parsePage(T)(Document page, URL url) + if (isAggregateType!T) { + import std.algorithm; + import std.array; + import std.conv; + import std.range; + import std.string; + + string rootClass = "h-" ~ T.stringof.toLower; + + return page.querySelectorAll(".%s".format(rootClass)).map!((node){ + alias PropType = MicroFormatProperty.Type; + T instance = T(); + MicroFormatProperty uda; + string propertyClass; + Node propNode; + static foreach(sym; getSymbolsByUDA!(T, MicroFormatProperty)) { + uda = getUDAs!(sym, MicroFormatProperty)[0]; + propertyClass = uda.getClassPrefix() ~ sym.stringof.toLower; + + propNode = page.querySelector(".%s".format(propertyClass), node); + switch (uda.type) { + case PropType.PlainText: + if (propNode.firstChild && propNode.firstChild.isTextNode()) { + __traits(getMember, instance, sym.stringof) = to!string(propNode.text); + } + break; + case PropType.Url: + if (propNode.tag == "a" && propNode.hasAttr("href")) { + __traits(getMember, instance, sym.stringof) = resolveURL(url, to!string(propNode["href"])).toString; + } else if (propNode.tag == "img" && propNode.hasAttr("src")) { + __traits(getMember, instance, sym.stringof) = resolveURL(url, to!string(propNode["src"])).toString; + } + break; + default: + break; + } + } + + return instance; + }).array; +} + +unittest { + import std.stdio; + string page = q"eos +