diff --git a/api/collections/ssr.yml b/api/collections/ssr.yml new file mode 100644 index 0000000..5dde647 --- /dev/null +++ b/api/collections/ssr.yml @@ -0,0 +1,62 @@ +######################################################################## +# SSR Dummy collections +######################################################################## + +name: ssr +meta: + label: { de: "SSR Dummy", en: "ssr dummy" } + muiIcon: server + rowIdentTpl: { twig: "{{ id }}" } + views: + - type: simpleList + mediaQuery: "(max-width: 600px)" + primaryText: id + secondaryText: insertTime + tertiaryText: path + - type: table + columns: + - id + - insertTime + - path + +permissions: + public: + methods: + get: false + post: false + put: false + delete: false + user: + methods: + get: false + post: false + put: false + delete: false + "token:${SSR_TOKEN}": + methods: + # only via url= + get: true + post: true + put: false + delete: false + +hooks: + get: + read: + type: javascript + file: hooks/ssr/get_read.js + post: + bind: + type: javascript + file: hooks/ssr/post_bind.js + +# we only need hooks +fields: + - name: path + type: string + index: [single, unique] + - name: content + type: string + meta: + inputProps: + multiline: true diff --git a/api/config.yml b/api/config.yml index 4c1f471..b40976f 100644 --- a/api/config.yml +++ b/api/config.yml @@ -20,7 +20,7 @@ meta: description: code-server # Pfad zu einer Bilddatei die als Projektbild im tibi-admin verwendet wird - imageUrl: + imageUrl: eval: "$projectBase + '_/assets/img/pic.jpg'" # Liste möglicher Berechtigungen, die Benutzern zugeordnet werden können @@ -43,6 +43,8 @@ meta: collections: - !include collections/democol.yml - !include collections/medialib.yml + # Dummy Kollektion für Hooks, die für serverseitiges Rendering benötigt werden + - !include collections/ssr.yml # Unter "jobs" können Jobs definiert werden, die regelmäßig ausgeführt werden sollen. jobs: @@ -53,4 +55,4 @@ jobs: assets: - !include assets/demoassets.yml - name: img - path: img \ No newline at end of file + path: img diff --git a/api/hooks/config.js b/api/hooks/config.js index e69de29..50301c9 100644 --- a/api/hooks/config.js +++ b/api/hooks/config.js @@ -0,0 +1,27 @@ +module.exports = { + ssrValidatePath: function (path) { + // validate if path ssr rendering is ok, -1 = NOTFOUND, 0 = NO SSR, 1 = SSR + // pe. use context.readCollection("product", {filter: {path: path}}) ... to validate dynamic urls + + // / is de home + if (path == "/") return 1 + + // all other sites are in db + path = path?.replace(/^\//, "") + + // filter for path or alternativePaths + const resp = context.db.find("content", { + filter: { + $or: [{ path }, { "alternativePaths.path": path }], + }, + selector: { _id: 1 }, + }) + if (resp && resp.length) { + return 1 + } + + // not found + return -1 + }, + ssrAllowedAPIEndpoints: ["content", "medialib"], +} diff --git a/api/hooks/lib/util.js b/api/hooks/lib/util.js deleted file mode 100644 index e69de29..0000000 diff --git a/api/hooks/lib/utils.js b/api/hooks/lib/utils.js new file mode 100644 index 0000000..922ef24 --- /dev/null +++ b/api/hooks/lib/utils.js @@ -0,0 +1,53 @@ +/** + * + * @param {any} str + */ +function log(str) { + console.log(JSON.stringify(str, undefined, 4)) +} + +/** + * convert object to string + * @param {any} obj object + */ +function obj2str(obj) { + if (Array.isArray(obj)) { + return JSON.stringify( + obj.map(function (idx) { + return obj2str(idx) + }) + ) + } else if (typeof obj === "object" && obj !== null) { + var elements = Object.keys(obj) + .sort() + .map(function (key) { + var val = obj2str(obj[key]) + if (val) { + return key + ":" + val + } + }) + + var elementsCleaned = [] + for (var i = 0; i < elements.length; i++) { + if (elements[i]) elementsCleaned.push(elements[i]) + } + + return "{" + elementsCleaned.join("|") + "}" + } + + if (obj) return obj +} + +/** + * clear SSR cache + */ +function clearSSRCache() { + var info = context.db.deleteMany("ssr", {}) + context.response.header("X-SSR-Cleared", info.removed) +} + +module.exports = { + log, + clearSSRCache, + obj2str, +} diff --git a/api/hooks/ssr/get_read.js b/api/hooks/ssr/get_read.js new file mode 100644 index 0000000..90bff2e --- /dev/null +++ b/api/hooks/ssr/get_read.js @@ -0,0 +1,183 @@ +const { ssrValidatePath, ssrAllowedAPIEndpoints } = require("../config") + +const { obj2str, log } = require("../lib/utils") +;(function () { + /** @type {HookResponse} */ + var response = null + + var request = context.request() + var url = request.query("url") + var noCache = request.query("noCache") + + // add sentry trace id to head + var trace_id = context.debug.sentryTraceId() + function addSentryTrace(content) { + return content.replace("</head>", '<meta name="sentry-trace" content="' + trace_id + '" /></head>') + } + context.response.header("sentry-trace", trace_id) + + if (url) { + // comment will be printed to html later + var comment = "" + + url = url.split("?")[0] + comment += "url: " + url + + if (url && url.length > 1) { + url = url.replace(/\/$/, "") + } + if (url == "/noindex" || !url) { + url = "/" // see .htaccess + } + + // check if url is in cache + var cache = + !noCache && + context.db.find("ssr", { + filter: { + path: url, + }, + }) + if (cache && cache.length) { + // use cache + throw { + status: 200, + log: false, + html: addSentryTrace(cache[0].content), + } + } + + // validate url + var status = 200 + + var pNorender = false + var pNotfound = false + + var pR = ssrValidatePath(url) + if (pR < 0) { + pNotfound = true + } else if (!pR) { + pNorender = true + } + + var head = "" + var html = "" + var error = "" + + comment += ", path: " + url + + var cacheIt = false + if (pNorender) { + html = "<!-- NO SSR RENDERING -->" + } else if (pNotfound) { + status = 404 + html = "404 NOT FOUND" + } else { + // try rendering, if error output plain html + try { + // @ts-ignore + context.ssrCache = {} + // @ts-ignore + context.ssrFetch = function (endpoint, options) { + var data + if (ssrAllowedAPIEndpoints.indexOf(endpoint) > -1) { + var _options = Object.assign({}, options) + + if (_options.sort) _options.sort = [_options.sort] + + try { + /*console.log( + "SSR", + endpoint, + JSON.stringify(_options) + )*/ + var goSlice = context.db.find(endpoint, _options || {}) + // need to deep copy, so shift and delete on pure js is possible + data = JSON.parse(JSON.stringify(goSlice)) + } catch (e) { + console.log("ERROR", JSON.stringify(e)) + data = [] + } + } else { + console.log("SSR forbidden", endpoint) + data = [] + } + + var count = (data && data.length) || 0 + if (options && count == options.limit) { + // read count from db + count = context.db.count(endpoint, _options || {}) + } + var r = { data: data, count: count } + + // @ts-ignore + context.ssrCache[obj2str({ endpoint: endpoint, options: options })] = r + + return r + } + + // include App.svelte and render it + // @ts-ignore + var app = require("../lib/app.server") + var rendered = app.default.render({ + url: url, + }) + head = rendered.head + html = rendered.html + + // add ssrCache to head + head += + "\n\n" + + "<script>window.__SSR_CACHE__ = " + + // @ts-ignore + JSON.stringify(context.ssrCache) + + "</script>" + + // status from webapp + // @ts-ignore + if (context.is404) { + status = 404 + } else { + cacheIt = true + } + } catch (e) { + // save error for later insert into html + log(e.message) + log(e.stack) + error = "error: " + e.message + "\n\n" + e.stack + } + } + + // read html template and replace placeholders + var tpl = context.fs.readFile("templates/spa.html") + tpl = tpl.replace("<!--HEAD-->", head) + tpl = tpl.replace("<!--HTML-->", html) + tpl = tpl.replace("<!--SSR.ERROR-->", error ? "<!--" + error + "-->" : "") + tpl = tpl.replace("<!--SSR.COMMENT-->", comment ? "<!--" + comment + "-->" : "") + + // save cache if adviced + if (cacheIt && !noCache) { + context.db.create("ssr", { + path: url, + content: tpl, + }) + } + + // return html + throw { + status: status, + log: false, + html: addSentryTrace(tpl), + } + } else { + // only admins are allowed to get without url parameter + var auth = context.user.auth() + if (!auth || auth.role !== 0) { + throw { + status: 403, + message: "invalid auth", + auth: auth, + } + } + } +})() diff --git a/api/hooks/ssr/post_bind.js b/api/hooks/ssr/post_bind.js new file mode 100644 index 0000000..e018b3b --- /dev/null +++ b/api/hooks/ssr/post_bind.js @@ -0,0 +1,16 @@ +const utils = require("../lib/utils") + +;(function () { + if (context.request().query("clear")) { + utils.clearSSRCache() + throw { + status: 200, + message: "cache cleared", + } + } + + throw { + status: 500, + message: "ssr is only a dummy collection", + } +})() diff --git a/frontend/src/ssr.ts b/frontend/src/ssr.ts new file mode 100644 index 0000000..a4eea78 --- /dev/null +++ b/frontend/src/ssr.ts @@ -0,0 +1,3 @@ +import App from "./components/App.svelte" + +export default App