This commit is contained in:
Chris Josten 2021-11-18 19:28:24 +01:00
parent 35d5b02b5e
commit f405f99729
22 changed files with 393 additions and 51 deletions

View file

@ -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"
}

View file

@ -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",

View file

@ -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) {

View file

@ -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 = "&copy; Chris Josten, 2020";
}

View file

@ -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);
}
}

View file

@ -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;
}

View file

@ -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");
}
}

View file

@ -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() {

View file

@ -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;
}

View file

@ -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
<div class="h-app">
<img src="/logo.png" class="u-logo">
<a href="/" class="u-url p-name">Example App</a>
</div>"
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"));
}

View file

@ -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;
/**

View file

@ -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;
/**

View file

@ -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

View file

@ -0,0 +1 @@
module nl.netsoj.chris.blog;

View file

@ -1,3 +1,5 @@
module nl.netsoj.chris.blog.staticpaths;
/**
* Paths to static data.
*/

View file

@ -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;
}

View file

@ -1,3 +1,5 @@
module nl.netsoj.chris.blog.utils;
import std.algorithm;
import std.array;
import std.conv;

View file

@ -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);

View file

@ -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

View file

@ -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

21
views/pages/indieauth.dt Normal file
View file

@ -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]&nbsp; #[a(href="javascript:history.back();") Weigeren]

View file

@ -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