From fdb96f3a86acb7f368ea3979f8a7461afa0dc9a7 Mon Sep 17 00:00:00 2001
From: Sebastian Frank <sebastian@webmakers.de>
Date: Tue, 21 Feb 2023 13:00:56 +0000
Subject: [PATCH] added ssr code

---
 api/collections/ssr.yml    |  62 +++++++++++++
 api/config.yml             |   6 +-
 api/hooks/config.js        |  27 ++++++
 api/hooks/lib/util.js      |   0
 api/hooks/lib/utils.js     |  53 +++++++++++
 api/hooks/ssr/get_read.js  | 183 +++++++++++++++++++++++++++++++++++++
 api/hooks/ssr/post_bind.js |  16 ++++
 frontend/src/ssr.ts        |   3 +
 8 files changed, 348 insertions(+), 2 deletions(-)
 create mode 100644 api/collections/ssr.yml
 delete mode 100644 api/hooks/lib/util.js
 create mode 100644 api/hooks/lib/utils.js
 create mode 100644 api/hooks/ssr/get_read.js
 create mode 100644 api/hooks/ssr/post_bind.js
 create mode 100644 frontend/src/ssr.ts

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