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 +
+ + Example App +
" +eos"; + + struct App { + @MicroFormatProperty(MicroFormatProperty.Type.Url) + string logo; + @MicroFormatProperty(MicroFormatProperty.Type.PlainText) + string name; + } + + auto ts = parsePage!App(page, URL("https://example.com/")); + assert(ts[0] == App ("https://example.com:443/logo.png", "Example App")); +} diff --git a/source/nl/netsoj/chris/blog/model/article.d b/source/nl/netsoj/chris/blog/model/article.d index ef4b838..b22677b 100644 --- a/source/nl/netsoj/chris/blog/model/article.d +++ b/source/nl/netsoj/chris/blog/model/article.d @@ -1,3 +1,5 @@ +module nl.netsoj.chris.blog.model.article; + import std.file; import std.stdio; import std.string; @@ -7,8 +9,8 @@ import std.experimental.logger; import dyaml; import vibe.d; -import page; -import utils; +import nl.netsoj.chris.blog.model.page; +import nl.netsoj.chris.blog.utils; /** diff --git a/source/nl/netsoj/chris/blog/model/page.d b/source/nl/netsoj/chris/blog/model/page.d index c3e71e5..e0b14d8 100644 --- a/source/nl/netsoj/chris/blog/model/page.d +++ b/source/nl/netsoj/chris/blog/model/page.d @@ -1,3 +1,5 @@ +module nl.netsoj.chris.blog.model.page; + import std.exception; import std.experimental.logger; import std.file; @@ -7,7 +9,7 @@ import std.stdio; import dyaml; import vibe.vibe; -import utils; +import nl.netsoj.chris.blog.utils; /** diff --git a/source/nl/netsoj/chris/blog/model/project.d b/source/nl/netsoj/chris/blog/model/project.d index f96d8d0..1b02a91 100644 --- a/source/nl/netsoj/chris/blog/model/project.d +++ b/source/nl/netsoj/chris/blog/model/project.d @@ -1,3 +1,5 @@ +module nl.netsoj.chris.blog.model.project; + import std.array; import std.algorithm; import std.typecons; @@ -5,9 +7,9 @@ import std.typecons; import dyaml; import vibe.vibe; -import page; -import utils; -import staticpaths; +import nl.netsoj.chris.blog.model.page; +import nl.netsoj.chris.blog.staticpaths; +import nl.netsoj.chris.blog.utils; /** * Represents a project, like an unfinished application diff --git a/source/nl/netsoj/chris/blog/package.d b/source/nl/netsoj/chris/blog/package.d new file mode 100644 index 0000000..2ff5397 --- /dev/null +++ b/source/nl/netsoj/chris/blog/package.d @@ -0,0 +1 @@ +module nl.netsoj.chris.blog; diff --git a/source/nl/netsoj/chris/blog/staticpaths.d b/source/nl/netsoj/chris/blog/staticpaths.d index 663cc5d..36f16b3 100644 --- a/source/nl/netsoj/chris/blog/staticpaths.d +++ b/source/nl/netsoj/chris/blog/staticpaths.d @@ -1,3 +1,5 @@ +module nl.netsoj.chris.blog.staticpaths; + /** * Paths to static data. */ diff --git a/source/nl/netsoj/chris/blog/url.d b/source/nl/netsoj/chris/blog/url.d new file mode 100644 index 0000000..d069c36 --- /dev/null +++ b/source/nl/netsoj/chris/blog/url.d @@ -0,0 +1,27 @@ +module nl.netsoj.chris.blog.url; + +import std.exception; + +import vibe.inet.url; + +URL resolveURL(URL source, string other) { + URL otherUrl = URL(other); + if (otherUrl.schema.length > 0 || otherUrl.host.length > 0) { + return otherUrl; + } + + if (otherUrl.schema.length == 0) { + enforce(source.schema.length > 0, "Source URL must have a scheme to resolve the other URL"); + otherUrl.schema = source.schema; + } + + if (otherUrl.host.length == 0) { + otherUrl.host = source.host; + otherUrl.port = source.port; + if (!otherUrl.path.absolute) { + otherUrl.path = source.path ~ otherUrl.path; + } + } + + return otherUrl; +} diff --git a/source/nl/netsoj/chris/blog/utils.d b/source/nl/netsoj/chris/blog/utils.d index bb1a681..953e9ff 100644 --- a/source/nl/netsoj/chris/blog/utils.d +++ b/source/nl/netsoj/chris/blog/utils.d @@ -1,3 +1,5 @@ +module nl.netsoj.chris.blog.utils; + import std.algorithm; import std.array; import std.conv; diff --git a/source/nl/netsoj/chris/blog/watcher.d b/source/nl/netsoj/chris/blog/watcher.d index 6f0d399..6acf760 100644 --- a/source/nl/netsoj/chris/blog/watcher.d +++ b/source/nl/netsoj/chris/blog/watcher.d @@ -1,3 +1,5 @@ +module nl.netsoj.chris.blog.watcher; + import std.array; import std.algorithm; import std.experimental.logger; @@ -7,14 +9,14 @@ import std.traits; import vibe.d; -import cache; -import page; +import nl.netsoj.chris.blog.cache; +import nl.netsoj.chris.blog.model.page; /** * Loads pages into memory and sets up a "watcher" to watch a directory for file changes. */ void initPages(T, C)(C *cache, const string directory) - if (isImplicitlyConvertible!(T, Page)) { + if (is(T : Page)) { bool addPage(string path) { try { @@ -22,7 +24,7 @@ void initPages(T, C)(C *cache, const string directory) logf("Added %s", newPage.slug); cache.addItem(newPage); return true; - } catch (page.ArticleParseException e) { + } catch (ArticleParseException e) { logf("Could not parse %s: %s", path, e); return false; } catch (Exception e) { @@ -72,7 +74,7 @@ void initPages(T, C)(C *cache, const string directory) try { newPage = new T(change.path.toString()); cache.changeItem(newPage); - } catch(page.ArticleParseException e) { + } catch(ArticleParseException e) { warningf("Could not parse %s", change.path.toString()); } catch (Exception e) { warningf("Error while updating %s: %s", change.path.toString(), e.msg); diff --git a/views/pages/article-list.dt b/views/pages/article-list.dt index 03e6662..4846256 100644 --- a/views/pages/article-list.dt +++ b/views/pages/article-list.dt @@ -12,7 +12,7 @@ block content - if (articleList.length == 0) p No posts found - else - - import utils; + - import nl.netsoj.chris.blog.utils; - foreach(article; articleList) article header diff --git a/views/pages/article.dt b/views/pages/article.dt index 835f174..81d4502 100644 --- a/views/pages/article.dt +++ b/views/pages/article.dt @@ -14,7 +14,7 @@ block extra_meta_data block content article(itemscope, itemtype="https://schema.org/BlogPosting", lang="#{content.language}") - - import utils; + - import nl.netsoj.chris.blog.utils; header h1.title(itemprop="headline") #{content.title} p.subtitle diff --git a/views/pages/indieauth.dt b/views/pages/indieauth.dt new file mode 100644 index 0000000..4e01fc7 --- /dev/null +++ b/views/pages/indieauth.dt @@ -0,0 +1,21 @@ +extends parts/page + +block header + title Aanmelden - Netsoj.nl + +block sidebar + +block content + - if (app.richInfo) + header.project-header + img.project-icon(src="#{app.logo}", alt="Icon of #{app.name}") + div + h1.title Aanmelden met #[a(href="#{app.clientId}") #{app.name}] + span.project-description #{app.clientId} + - else + header + h1.title Aanmelden met #[a(href="#{app.clientId}") #{app.name}] + + p #{app.name} wil uw identiteit bevestigen. + + p #[a(href="#{app.redirectUri}") Aanvaarden en aanmelden]  #[a(href="javascript:history.back();") Weigeren] diff --git a/views/parts/page.dt b/views/parts/page.dt index bf91a60..924b43c 100644 --- a/views/parts/page.dt +++ b/views/parts/page.dt @@ -1,6 +1,6 @@ doctype html html(prefix="og: http://ogp.me/ns#") - - import constants; + - import nl.netsoj.chris.blog.constants; - import vibe.d; head //- Kick off loading the css as fast as possible