summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorRaphael <mail@raphaelkabo.com>2023-10-09 11:10:51 +0100
committerGitHub <noreply@github.com>2023-10-09 11:10:51 +0100
commitcc6fcb4c405d8cffacbf9b1082abf61e918482fa (patch)
tree693f324550dccedd50b6313165b88281a8ebcac8
parent25fcdd1023550631f5fec6f750829fe09a311d66 (diff)
parent31022a7d323a351041b7b8508fb56c14fd699580 (diff)
Merge pull request #114 from lowercasename/rk/static-pages
Static pages
-rw-r--r--config/config.example.toml15
-rw-r--r--package.json4
-rw-r--r--pnpm-lock.yaml251
-rwxr-xr-xpublic/css/style.css9
-rwxr-xr-xsrc/app.ts2
-rw-r--r--src/lib/config.ts23
-rwxr-xr-xsrc/routes.js6
-rw-r--r--src/routes/activitypub.ts19
-rw-r--r--src/routes/event.ts4
-rw-r--r--src/routes/frontend.ts27
-rw-r--r--src/routes/static.ts36
-rw-r--r--src/util/config.ts19
-rw-r--r--src/util/markdown.ts16
-rw-r--r--static/privacy-policy.md1
-rwxr-xr-xviews/layouts/main.handlebars23
-rwxr-xr-xviews/newevent.handlebars3
-rw-r--r--views/static.handlebars10
17 files changed, 401 insertions, 67 deletions
diff --git a/config/config.example.toml b/config/config.example.toml
index 1ffbeb7..8f5a09d 100644
--- a/config/config.example.toml
+++ b/config/config.example.toml
@@ -31,3 +31,18 @@ smtp_password = ""
[sendgrid]
api_key = ""
+
+# Links to static pages (for example a privacy policy) or an external community page,
+# which will be displayed in the footer.
+# If paths begin with a slash, they are treated as internal and will open the specified
+# Markdown or text file. If they are absolute (begin with https://), they will simply
+# link to the specified URL.
+
+# [[static_pages]]
+# title = "Privacy Policy"
+# path = "/privacy"
+# filename = "privacy-policy.md"
+
+# [[static_pages]]
+# title = "External Link"
+# path = "https://example.com"
diff --git a/package.json b/package.json
index 0a783ce..d573f7c 100644
--- a/package.json
+++ b/package.json
@@ -21,6 +21,7 @@
"dependencies": {
"@sendgrid/mail": "^6.5.5",
"cors": "^2.8.5",
+ "dompurify": "^3.0.6",
"express": "^4.18.2",
"express-fileupload": "^1.4.1",
"express-handlebars": "^6.0.7",
@@ -30,6 +31,7 @@
"ical": "^0.6.0",
"ical-generator": "^1.15.4",
"jimp": "^0.16.13",
+ "jsdom": "^22.1.0",
"marked": "^9.1.0",
"moment-timezone": "^0.5.43",
"mongoose": "^5.13.20",
@@ -45,8 +47,10 @@
"wait-on": "^7.0.1"
},
"devDependencies": {
+ "@types/dompurify": "^3.0.3",
"@types/express": "^4.17.18",
"@types/ical": "^0.8.1",
+ "@types/jsdom": "^21.1.3",
"@types/multer": "^1.4.8",
"@types/node": "^20.8.2",
"@types/nodemailer": "^6.4.11",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 9c1c8e7..f303bc0 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -11,6 +11,9 @@ dependencies:
cors:
specifier: ^2.8.5
version: 2.8.5
+ dompurify:
+ specifier: ^3.0.6
+ version: 3.0.6
express:
specifier: ^4.18.2
version: 4.18.2
@@ -38,6 +41,9 @@ dependencies:
jimp:
specifier: ^0.16.13
version: 0.16.13
+ jsdom:
+ specifier: ^22.1.0
+ version: 22.1.0
marked:
specifier: ^9.1.0
version: 9.1.0
@@ -79,12 +85,18 @@ dependencies:
version: 7.0.1
devDependencies:
+ '@types/dompurify':
+ specifier: ^3.0.3
+ version: 3.0.3
'@types/express':
specifier: ^4.17.18
version: 4.17.18
'@types/ical':
specifier: ^0.8.1
version: 0.8.1
+ '@types/jsdom':
+ specifier: ^21.1.3
+ version: 21.1.3
'@types/multer':
specifier: ^1.4.8
version: 1.4.8
@@ -670,6 +682,11 @@ packages:
resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==}
dev: false
+ /@tootallnate/once@2.0.0:
+ resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==}
+ engines: {node: '>= 10'}
+ dev: false
+
/@types/body-parser@1.19.3:
resolution: {integrity: sha512-oyl4jvAfTGX9Bt6Or4H9ni1Z447/tQuxnZsytsCaExKlmJiU8sFgnIBRzJUpKwB5eWn9HuBYlUlVA74q/yN0eQ==}
dependencies:
@@ -693,6 +710,12 @@ packages:
'@types/node': 20.8.2
dev: true
+ /@types/dompurify@3.0.3:
+ resolution: {integrity: sha512-odiGr/9/qMqjcBOe5UhcNLOFHSYmKFOyr+bJ/Xu3Qp4k1pNPAlNLUVNNLcLfjQI7+W7ObX58EdD3H+3p3voOvA==}
+ dependencies:
+ '@types/trusted-types': 2.0.4
+ dev: true
+
/@types/express-serve-static-core@4.17.37:
resolution: {integrity: sha512-ZohaCYTgGFcOP7u6aJOhY9uIZQgZ2vxC2yWoArY+FeDXlqeH66ZVBjgvg+RLVAS/DWNq4Ap9ZXu1+SUQiiWYMg==}
dependencies:
@@ -721,6 +744,14 @@ packages:
rrule: 2.6.4
dev: true
+ /@types/jsdom@21.1.3:
+ resolution: {integrity: sha512-1zzqSP+iHJYV4lB3lZhNBa012pubABkj9yG/GuXuf6LZH1cSPIJBqFDrm5JX65HHt6VOnNYdTui/0ySerRbMgA==}
+ dependencies:
+ '@types/node': 20.8.2
+ '@types/tough-cookie': 4.0.3
+ parse5: 7.1.2
+ dev: true
+
/@types/mime@1.3.3:
resolution: {integrity: sha512-Ys+/St+2VF4+xuY6+kDIXGxbNRO0mesVg0bbxEfB97Od1Vjpjx9KD1qxs64Gcb3CWPirk9Xe+PT4YiiHQ9T+eg==}
dev: true
@@ -801,7 +832,10 @@ packages:
/@types/tough-cookie@4.0.3:
resolution: {integrity: sha512-THo502dA5PzG/sfQH+42Lw3fvmYkceefOspdCwpHRul8ik2Jv1K8I5OZz1AT3/rs46kwgMCe9bSBmDLYkkOMGg==}
- dev: false
+
+ /@types/trusted-types@2.0.4:
+ resolution: {integrity: sha512-IDaobHimLQhjwsQ/NMwRVfa/yL7L/wriQPMhw1ZJall0KX6E1oxk29XMDeilW5qTIg5aoiqf5Udy8U/51aNoQQ==}
+ dev: true
/@types/yauzl@2.10.1:
resolution: {integrity: sha512-CHzgNU3qYBnp/O4S3yv2tXPlvMTq0YWSTVg2/JYLqWZGHwwgJGAwd00poay/11asPq8wLFwHzubyInqHIFmmiw==}
@@ -811,6 +845,10 @@ packages:
dev: true
optional: true
+ /abab@2.0.6:
+ resolution: {integrity: sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==}
+ dev: false
+
/abbrev@1.1.1:
resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==}
dev: true
@@ -837,6 +875,15 @@ packages:
hasBin: true
dev: true
+ /agent-base@6.0.2:
+ resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
+ engines: {node: '>= 6.0.0'}
+ dependencies:
+ debug: 4.3.4(supports-color@8.1.1)
+ transitivePeerDependencies:
+ - supports-color
+ dev: false
+
/aggregate-error@3.1.0:
resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==}
engines: {node: '>=8'}
@@ -1275,6 +1322,13 @@ packages:
which: 2.0.2
dev: true
+ /cssstyle@3.0.0:
+ resolution: {integrity: sha512-N4u2ABATi3Qplzf0hWbVCdjenim8F3ojEXpBDF5hBpjzW182MjNGLqfmQ0SkSPeQ+V86ZXgeH8aXj6kayd4jgg==}
+ engines: {node: '>=14'}
+ dependencies:
+ rrweb-cssom: 0.6.0
+ dev: false
+
/cypress@13.3.0:
resolution: {integrity: sha512-mpI8qcTwLGiA4zEQvTC/U1xGUezVV4V8HQCOYjlEOrVmU1etVvxOjkCXHGwrlYdZU/EPmUiWfsO3yt1o+Q2bgw==}
engines: {node: ^16.0.0 || ^18.0.0 || >=20.0.0}
@@ -1332,6 +1386,15 @@ packages:
dependencies:
assert-plus: 1.0.0
+ /data-urls@4.0.0:
+ resolution: {integrity: sha512-/mMTei/JXPqvFqQtfyTowxmJVwr2PVAeCcDxyFf6LhoOu/09TX2OX3kb2wzi4DMXcfj4OItwDOnhl5oziPnT6g==}
+ engines: {node: '>=14'}
+ dependencies:
+ abab: 2.0.6
+ whatwg-mimetype: 3.0.0
+ whatwg-url: 12.0.1
+ dev: false
+
/dayjs@1.11.10:
resolution: {integrity: sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ==}
dev: true
@@ -1393,7 +1456,10 @@ packages:
dependencies:
ms: 2.1.2
supports-color: 8.1.1
- dev: true
+
+ /decimal.js@10.4.3:
+ resolution: {integrity: sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==}
+ dev: false
/deep-is@0.1.4:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
@@ -1464,6 +1530,13 @@ packages:
resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==}
dev: false
+ /domexception@4.0.0:
+ resolution: {integrity: sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==}
+ engines: {node: '>=12'}
+ dependencies:
+ webidl-conversions: 7.0.0
+ dev: false
+
/domhandler@5.0.3:
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
engines: {node: '>= 4'}
@@ -1471,6 +1544,10 @@ packages:
domelementtype: 2.3.0
dev: false
+ /dompurify@3.0.6:
+ resolution: {integrity: sha512-ilkD8YEnnGh1zJ240uJsW7AzE+2qpbOUYjacomn3AvJ6J4JhKGSZ2nh4wUIXPZrEPppaCLx5jFe8T89Rk8tQ7w==}
+ dev: false
+
/domutils@3.1.0:
resolution: {integrity: sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==}
dependencies:
@@ -1515,7 +1592,6 @@ packages:
/entities@4.5.0:
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
engines: {node: '>=0.12'}
- dev: false
/escape-html@1.0.3:
resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
@@ -2060,7 +2136,6 @@ packages:
/has-flag@4.0.0:
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
engines: {node: '>=8'}
- dev: true
/has-property-descriptors@1.0.0:
resolution: {integrity: sha512-62DVLZGoiEBDHQyqG4w9xCuZ7eJEwNmJRWw2VY84Oedb7WFcA27fiEVe8oUQx9hAUJ4ekurquucTGwsyO1XGdQ==}
@@ -2082,6 +2157,13 @@ packages:
dependencies:
function-bind: 1.1.1
+ /html-encoding-sniffer@3.0.0:
+ resolution: {integrity: sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==}
+ engines: {node: '>=12'}
+ dependencies:
+ whatwg-encoding: 2.0.0
+ dev: false
+
/htmlparser2@8.0.2:
resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==}
dependencies:
@@ -2102,6 +2184,17 @@ packages:
toidentifier: 1.0.1
dev: false
+ /http-proxy-agent@5.0.0:
+ resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==}
+ engines: {node: '>= 6'}
+ dependencies:
+ '@tootallnate/once': 2.0.0
+ agent-base: 6.0.2
+ debug: 4.3.4(supports-color@8.1.1)
+ transitivePeerDependencies:
+ - supports-color
+ dev: false
+
/http-signature@1.2.0:
resolution: {integrity: sha512-CAbnr6Rz4CYQkLYUtSNXxQPUH2gK8f3iWexVlsnMeD+GjlsQ0Xsy1cOX+mN3dtxYomRy21CiOzU8Uhw6OwncEQ==}
engines: {node: '>=0.8', npm: '>=1.3.7'}
@@ -2120,6 +2213,16 @@ packages:
sshpk: 1.17.0
dev: true
+ /https-proxy-agent@5.0.1:
+ resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
+ engines: {node: '>= 6'}
+ dependencies:
+ agent-base: 6.0.2
+ debug: 4.3.4(supports-color@8.1.1)
+ transitivePeerDependencies:
+ - supports-color
+ dev: false
+
/human-signals@1.1.1:
resolution: {integrity: sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==}
engines: {node: '>=8.12.0'}
@@ -2149,6 +2252,13 @@ packages:
safer-buffer: 2.1.2
dev: false
+ /iconv-lite@0.6.3:
+ resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
+ engines: {node: '>=0.10.0'}
+ dependencies:
+ safer-buffer: 2.1.2
+ dev: false
+
/ieee754@1.2.1:
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
@@ -2270,6 +2380,10 @@ packages:
engines: {node: '>=0.10.0'}
dev: false
+ /is-potential-custom-element-name@1.0.1:
+ resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
+ dev: false
+
/is-stream@2.0.1:
resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==}
engines: {node: '>=8'}
@@ -2328,6 +2442,44 @@ packages:
/jsbn@0.1.1:
resolution: {integrity: sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==}
+ /jsdom@22.1.0:
+ resolution: {integrity: sha512-/9AVW7xNbsBv6GfWho4TTNjEo9fe6Zhf9O7s0Fhhr3u+awPwAJMKwAMXnkk5vBxflqLW9hTHX/0cs+P3gW+cQw==}
+ engines: {node: '>=16'}
+ peerDependencies:
+ canvas: ^2.5.0
+ peerDependenciesMeta:
+ canvas:
+ optional: true
+ dependencies:
+ abab: 2.0.6
+ cssstyle: 3.0.0
+ data-urls: 4.0.0
+ decimal.js: 10.4.3
+ domexception: 4.0.0
+ form-data: 4.0.0
+ html-encoding-sniffer: 3.0.0
+ http-proxy-agent: 5.0.0
+ https-proxy-agent: 5.0.1
+ is-potential-custom-element-name: 1.0.1
+ nwsapi: 2.2.7
+ parse5: 7.1.2
+ rrweb-cssom: 0.6.0
+ saxes: 6.0.0
+ symbol-tree: 3.2.4
+ tough-cookie: 4.1.3
+ w3c-xmlserializer: 4.0.0
+ webidl-conversions: 7.0.0
+ whatwg-encoding: 2.0.0
+ whatwg-mimetype: 3.0.0
+ whatwg-url: 12.0.1
+ ws: 8.14.2
+ xml-name-validator: 4.0.0
+ transitivePeerDependencies:
+ - bufferutil
+ - supports-color
+ - utf-8-validate
+ dev: false
+
/json-buffer@3.0.1:
resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==}
dev: true
@@ -2755,6 +2907,10 @@ packages:
path-key: 3.1.1
dev: true
+ /nwsapi@2.2.7:
+ resolution: {integrity: sha512-ub5E4+FBPKwAZx0UwIQOjYWGHTEq5sPqHQNRN8Z9e4A7u3Tj1weLJsL59yH9vmvqEtBHaOmT6cYQKIZOxp35FQ==}
+ dev: false
+
/oauth-sign@0.9.0:
resolution: {integrity: sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==}
dev: false
@@ -2883,6 +3039,11 @@ packages:
resolution: {integrity: sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==}
dev: false
+ /parse5@7.1.2:
+ resolution: {integrity: sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==}
+ dependencies:
+ entities: 4.5.0
+
/parseurl@1.3.3:
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
engines: {node: '>= 0.8'}
@@ -3033,7 +3194,6 @@ packages:
/querystringify@2.2.0:
resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==}
- dev: true
/queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
@@ -3163,7 +3323,6 @@ packages:
/requires-port@1.0.0:
resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==}
- dev: true
/resolve-from@4.0.0:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
@@ -3208,6 +3367,10 @@ packages:
luxon: 1.28.1
dev: true
+ /rrweb-cssom@0.6.0:
+ resolution: {integrity: sha512-APM0Gt1KoXBz0iIkkdB/kfvGOwC4UuJFeG/c+yV7wSc7q96cG/kJ0HiYCnzivD9SB53cLV1MlHFNfOuPaadYSw==}
+ dev: false
+
/run-parallel@1.2.0:
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
dependencies:
@@ -3253,6 +3416,13 @@ packages:
resolution: {integrity: sha512-0s+oAmw9zLl1V1cS9BtZN7JAd0cW5e0QH4W3LWEK6a4LaLEA2OTpGYWDY+6XasBLtz6wkm3u1xRw95mRuJ59WA==}
dev: false
+ /saxes@6.0.0:
+ resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
+ engines: {node: '>=v12.22.7'}
+ dependencies:
+ xmlchars: 2.2.0
+ dev: false
+
/semver@5.7.2:
resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==}
hasBin: true
@@ -3474,7 +3644,10 @@ packages:
engines: {node: '>=10'}
dependencies:
has-flag: 4.0.0
- dev: true
+
+ /symbol-tree@3.2.4:
+ resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
+ dev: false
/text-table@0.2.0:
resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==}
@@ -3550,7 +3723,13 @@ packages:
punycode: 2.3.0
universalify: 0.2.0
url-parse: 1.5.10
- dev: true
+
+ /tr46@4.1.1:
+ resolution: {integrity: sha512-2lv/66T7e5yNyhAAC4NaKe5nVavzuGJQVVtRYLyQ2OI8tsJ61PMLlelehb0wi2Hx6+hT/OJUWZcw8MjlSRnxvw==}
+ engines: {node: '>=14'}
+ dependencies:
+ punycode: 2.3.0
+ dev: false
/tslib@1.14.1:
resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==}
@@ -3624,7 +3803,6 @@ packages:
/universalify@0.2.0:
resolution: {integrity: sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==}
engines: {node: '>= 4.0.0'}
- dev: true
/universalify@2.0.0:
resolution: {integrity: sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==}
@@ -3651,7 +3829,6 @@ packages:
dependencies:
querystringify: 2.2.0
requires-port: 1.0.0
- dev: true
/utif@2.0.1:
resolution: {integrity: sha512-Z/S1fNKCicQTf375lIP9G8Sa1H/phcysstNrrSdZKj1f9g58J4NMgb5IgiEZN9/nLMPDwF0W7hdOe9Qq2IYoLg==}
@@ -3697,6 +3874,13 @@ packages:
core-util-is: 1.0.2
extsprintf: 1.3.0
+ /w3c-xmlserializer@4.0.0:
+ resolution: {integrity: sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==}
+ engines: {node: '>=14'}
+ dependencies:
+ xml-name-validator: 4.0.0
+ dev: false
+
/wait-on@7.0.1:
resolution: {integrity: sha512-9AnJE9qTjRQOlTZIldAaf/da2eW0eSRSgcqq85mXQja/DW3MriHxkpODDSUEg+Gri/rKEcXUZHe+cevvYItaog==}
engines: {node: '>=12.0.0'}
@@ -3711,6 +3895,31 @@ packages:
- debug
dev: false
+ /webidl-conversions@7.0.0:
+ resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==}
+ engines: {node: '>=12'}
+ dev: false
+
+ /whatwg-encoding@2.0.0:
+ resolution: {integrity: sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==}
+ engines: {node: '>=12'}
+ dependencies:
+ iconv-lite: 0.6.3
+ dev: false
+
+ /whatwg-mimetype@3.0.0:
+ resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==}
+ engines: {node: '>=12'}
+ dev: false
+
+ /whatwg-url@12.0.1:
+ resolution: {integrity: sha512-Ed/LrqB8EPlGxjS+TrsXcpUond1mhccS3pchLhzSgPCnTimUCKj3IZE75pAs5m6heB2U2TMerKFUXheyHY+VDQ==}
+ engines: {node: '>=14'}
+ dependencies:
+ tr46: 4.1.1
+ webidl-conversions: 7.0.0
+ dev: false
+
/which@2.0.2:
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
engines: {node: '>= 8'}
@@ -3744,6 +3953,19 @@ packages:
/wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
+ /ws@8.14.2:
+ resolution: {integrity: sha512-wEBG1ftX4jcglPxgFCMJmZ2PLtSbJ2Peg6TmpJFTbe9GZYOQCDPdMYu/Tm0/bGZkw8paZnJY45J4K2PZrLYq8g==}
+ engines: {node: '>=10.0.0'}
+ peerDependencies:
+ bufferutil: ^4.0.1
+ utf-8-validate: '>=5.0.2'
+ peerDependenciesMeta:
+ bufferutil:
+ optional: true
+ utf-8-validate:
+ optional: true
+ dev: false
+
/xhr@2.6.0:
resolution: {integrity: sha512-/eCGLb5rxjx5e3mF1A7s+pLlR6CGyqWN91fv1JgER5mVWg1MZmlhBvy9kjcsOdRk8RrIujotWyJamfyrp+WIcA==}
dependencies:
@@ -3753,6 +3975,11 @@ packages:
xtend: 4.0.2
dev: false
+ /xml-name-validator@4.0.0:
+ resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==}
+ engines: {node: '>=12'}
+ dev: false
+
/xml-parse-from-string@1.0.1:
resolution: {integrity: sha512-ErcKwJTF54uRzzNMXq2X5sMIy88zJvfN2DmdoQvy7PAFJ+tPRU6ydWuOKNMyfmOjdyBQTFREi60s0Y0SyI0G0g==}
dev: false
@@ -3770,6 +3997,10 @@ packages:
engines: {node: '>=4.0'}
dev: false
+ /xmlchars@2.2.0:
+ resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
+ dev: false
+
/xtend@4.0.2:
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
engines: {node: '>=0.4'}
diff --git a/public/css/style.css b/public/css/style.css
index 0f149e8..8e6322e 100755
--- a/public/css/style.css
+++ b/public/css/style.css
@@ -54,6 +54,10 @@ body, html {
padding: 5px 0;
}
+#footerContainer p {
+ margin-bottom: 0.25rem;
+}
+
#sidebar {
background: #f5f5f5;
border-bottom: 2px solid #e0e0e0;
@@ -396,3 +400,8 @@ label:not(.form-check-label) {
input[type="datetime-local"] {
max-width: 20rem;
}
+
+article.static-page header {
+ margin-bottom: 1rem;
+ border-bottom: 1px solid #e0e0e0;
+}
diff --git a/src/app.ts b/src/app.ts
index c43f31d..3370d27 100755
--- a/src/app.ts
+++ b/src/app.ts
@@ -6,6 +6,7 @@ import frontend from "./routes/frontend.js";
import activitypub from "./routes/activitypub.js";
import event from "./routes/event.js";
import group from "./routes/group.js";
+import staticPages from "./routes/static.js";
import { initEmailService } from "./lib/email.js";
@@ -53,6 +54,7 @@ app.use(express.json({ type: "application/json" }));
app.use(express.urlencoded({ extended: true }));
// Router //
+app.use("/", staticPages);
app.use("/", frontend);
app.use("/", activitypub);
app.use("/", event);
diff --git a/src/lib/config.ts b/src/lib/config.ts
index 7b35b98..6f142e5 100644
--- a/src/lib/config.ts
+++ b/src/lib/config.ts
@@ -2,6 +2,12 @@ import fs from "fs";
import toml from "toml";
import { exitWithError } from "./process.js";
+interface StaticPage {
+ title: string;
+ path: string;
+ filename: string;
+}
+
interface GathioConfig {
general: {
domain: string;
@@ -25,9 +31,21 @@ interface GathioConfig {
sendgrid?: {
api_key: string;
};
+ static_pages: StaticPage[];
+}
+
+interface FrontendConfig {
+ domain: string;
+ siteName: string;
+ isFederated: boolean;
+ emailLogoUrl: string;
+ showKofi: boolean;
+ showInstanceInformation: boolean;
+ staticPages: StaticPage[];
+ version: string;
}
-export const publicConfig = () => {
+export const frontendConfig = (): FrontendConfig => {
const config = getConfig();
return {
domain: config.general.domain,
@@ -35,6 +53,9 @@ export const publicConfig = () => {
isFederated: config.general.is_federated,
emailLogoUrl: config.general.email_logo_url,
showKofi: config.general.show_kofi,
+ showInstanceInformation: config.static_pages?.length > 0,
+ staticPages: config.static_pages,
+ version: process.env.npm_package_version || "unknown",
};
};
diff --git a/src/routes.js b/src/routes.js
index 5371e0e..d59a738 100755
--- a/src/routes.js
+++ b/src/routes.js
@@ -2,7 +2,7 @@ import fs from "fs";
import express from "express";
import { customAlphabet } from "nanoid";
import randomstring from "randomstring";
-import { getConfig } from "./lib/config.js";
+import { frontendConfig, getConfig } from "./lib/config.js";
import { addToLog } from "./helpers.js";
import moment from "moment-timezone";
import crypto from "crypto";
@@ -1505,9 +1505,7 @@ router.post("/activitypub/inbox", (req, res) => {
});
router.use(function (req, res, next) {
- res.status(404);
- res.render("404", { url: req.url });
- return;
+ return res.status(404).render("404", frontendConfig());
});
addToLog("startup", "success", "Started up successfully");
diff --git a/src/routes/activitypub.ts b/src/routes/activitypub.ts
index 2c4231a..2b8fb4a 100644
--- a/src/routes/activitypub.ts
+++ b/src/routes/activitypub.ts
@@ -1,7 +1,7 @@
import { Router, Request, Response, NextFunction } from "express";
import { createFeaturedPost, createWebfinger } from "../activitypub.js";
import { acceptsActivityPub } from "../lib/activitypub.js";
-import getConfig from "../lib/config.js";
+import getConfig, { frontendConfig } from "../lib/config.js";
import Event from "../models/Event.js";
import { addToLog } from "../helpers.js";
@@ -15,8 +15,7 @@ const send404IfNotFederated = (
next: NextFunction,
) => {
if (!config.general.is_federated) {
- res.status(404).render("404", { url: req.url });
- return;
+ return res.status(404).render("404", frontendConfig());
}
next();
};
@@ -49,10 +48,10 @@ router.get("/:eventID/m/:hash", async (req: Request, res: Response) => {
id: eventID,
});
if (!event) {
- return res.status(404).render("404", { url: req.url });
+ return res.status(404).render("404", frontendConfig());
} else {
if (!event.activityPubMessages) {
- return res.status(404).render("404", { url: req.url });
+ return res.status(404).render("404", frontendConfig());
}
const message = event.activityPubMessages.find(
(el) => el.id === id,
@@ -69,7 +68,7 @@ router.get("/:eventID/m/:hash", async (req: Request, res: Response) => {
);
}
} else {
- return res.status(404).render("404", { url: req.url });
+ return res.status(404).render("404", frontendConfig());
}
}
} catch (err) {
@@ -81,7 +80,7 @@ router.get("/:eventID/m/:hash", async (req: Request, res: Response) => {
" failed with error: " +
err,
);
- return res.status(404).render("404", { url: req.url });
+ return res.status(404).render("404", frontendConfig());
}
});
@@ -103,7 +102,7 @@ router.get("/.well-known/webfinger", async (req, res) => {
const event = await Event.findOne({ id: eventID });
if (!event) {
- return res.status(404).render("404", { url: req.url });
+ return res.status(404).render("404", frontendConfig());
} else {
if (acceptsActivityPub(req)) {
res.header(
@@ -122,7 +121,7 @@ router.get("/.well-known/webfinger", async (req, res) => {
"error",
`Attempt to render webfinger for ${resource} failed with error: ${err}`,
);
- return res.status(404).render("404", { url: req.url });
+ return res.status(404).render("404", frontendConfig());
}
}
});
@@ -167,7 +166,7 @@ router.get("/:eventID/followers", async (req, res) => {
"error",
`Attempt to render followers for ${eventID} failed with error: ${err}`,
);
- return res.status(404).render("404", { url: req.url });
+ return res.status(404).render("404", frontendConfig());
}
});
diff --git a/src/routes/event.ts b/src/routes/event.ts
index 2245009..cfd877e 100644
--- a/src/routes/event.ts
+++ b/src/routes/event.ts
@@ -2,7 +2,6 @@ import { Router, Response, Request } from "express";
import multer from "multer";
import Jimp from "jimp";
import moment from "moment-timezone";
-import { marked } from "marked";
import {
generateEditToken,
generateEventID,
@@ -26,6 +25,7 @@ import getConfig from "../lib/config.js";
import { sendEmailFromTemplate } from "../lib/email.js";
import crypto from "crypto";
import ical from "ical";
+import { markdownToSanitizedHTML } from "../util/markdown.js";
const config = getConfig();
@@ -148,7 +148,7 @@ router.post(
eventID,
config.general.domain,
publicKey,
- marked.parse(eventData.eventDescription),
+ markdownToSanitizedHTML(eventData.eventDescription),
eventData.eventName,
eventData.eventLocation,
eventImageFilename,
diff --git a/src/routes/frontend.ts b/src/routes/frontend.ts
index c9594ef..c405572 100644
--- a/src/routes/frontend.ts
+++ b/src/routes/frontend.ts
@@ -1,9 +1,8 @@
import { Router, Request, Response } from "express";
import moment from "moment-timezone";
import { marked } from "marked";
-import { frontendConfig } from "../util/config.js";
-import { renderPlain } from "../util/markdown.js";
-import getConfig from "../lib/config.js";
+import { markdownToSanitizedHTML, renderPlain } from "../util/markdown.js";
+import getConfig, { frontendConfig } from "../lib/config.js";
import { addToLog, exportICal } from "../helpers.js";
import Event from "../models/Event.js";
import EventGroup, { IEventGroup } from "../models/EventGroup.js";
@@ -30,9 +29,7 @@ router.get("/:eventID", async (req: Request, res: Response) => {
.lean() // Required, see: https://stackoverflow.com/questions/59690923/handlebars-access-has-been-denied-to-resolve-the-property-from-because-it-is
.populate("eventGroup");
if (!event) {
- res.status(404);
- res.render("404", { url: req.url });
- return;
+ return res.status(404).render("404", frontendConfig());
}
const parsedLocation = event.location.replace(/\s+/g, "+");
let displayDate;
@@ -94,7 +91,7 @@ router.get("/:eventID", async (req: Request, res: Response) => {
eventHasBegun = true;
}
let fromNow = moment.tz(event.start, event.timezone).fromNow();
- let parsedDescription = marked.parse(event.description);
+ let parsedDescription = markdownToSanitizedHTML(event.description);
let eventEditToken = event.editToken;
let escapedName = event.name.replace(/\s+/g, "+");
@@ -252,7 +249,7 @@ router.get("/:eventID", async (req: Request, res: Response) => {
err,
);
console.log(err);
- res.status(404).render("404", { url: req.url });
+ return res.status(404).render("404", frontendConfig());
}
});
@@ -263,9 +260,11 @@ router.get("/group/:eventGroupID", async (req: Request, res: Response) => {
}).lean();
if (!eventGroup) {
- return res.status(404).render("404", { url: req.url });
+ return res.status(404).render("404", frontendConfig());
}
- const parsedDescription = marked.parse(eventGroup.description);
+ const parsedDescription = markdownToSanitizedHTML(
+ eventGroup.description,
+ );
const eventGroupEditToken = eventGroup.editToken;
const escapedName = eventGroup.name.replace(/\s+/g, "+");
const eventGroupHasCoverImage = !!eventGroup.image;
@@ -364,7 +363,7 @@ router.get("/group/:eventGroupID", async (req: Request, res: Response) => {
`Attempt to display event group ${req.params.eventGroupID} failed with error: ${err}`,
);
console.log(err);
- return res.status(404).render("404", { url: req.url });
+ return res.status(404).render("404", frontendConfig());
}
});
@@ -391,7 +390,7 @@ router.get(
`Attempt to display event group feed for ${req.params.eventGroupID} failed with error: ${err}`,
);
console.log(err);
- res.status(404).render("404", { url: req.url });
+ return res.status(404).render("404", frontendConfig());
}
},
);
@@ -413,7 +412,7 @@ router.get("/export/event/:eventID", async (req: Request, res: Response) => {
`Attempt to export event ${req.params.eventID} failed with error: ${err}`,
);
console.log(err);
- res.status(404).render("404", { url: req.url });
+ return res.status(404).render("404", frontendConfig());
}
});
@@ -439,7 +438,7 @@ router.get(
`Attempt to export event group ${req.params.eventGroupID} failed with error: ${err}`,
);
console.log(err);
- res.status(404).render("404", { url: req.url });
+ return res.status(404).render("404", frontendConfig());
}
},
);
diff --git a/src/routes/static.ts b/src/routes/static.ts
new file mode 100644
index 0000000..33f0225
--- /dev/null
+++ b/src/routes/static.ts
@@ -0,0 +1,36 @@
+import { Router, Request, Response } from "express";
+import fs from "fs";
+import getConfig, { frontendConfig } from "../lib/config.js";
+import { markdownToSanitizedHTML } from "../util/markdown.js";
+
+const config = getConfig();
+const router = Router();
+
+if (config.static_pages?.length) {
+ config.static_pages
+ .filter((page) => page.path?.startsWith("/") && page.filename)
+ .forEach((page) => {
+ router.get(page.path, (_: Request, res: Response) => {
+ try {
+ if (fs.existsSync(`./static/${page.filename}`)) {
+ const fileBody = fs.readFileSync(
+ `./static/${page.filename}`,
+ "utf-8",
+ );
+ const parsed = markdownToSanitizedHTML(fileBody);
+ return res.render("static", {
+ title: page.title,
+ content: parsed,
+ ...frontendConfig(),
+ });
+ }
+ return res.status(404).render("404", frontendConfig());
+ } catch (err) {
+ console.error(err);
+ return res.status(404).render("404", frontendConfig());
+ }
+ });
+ });
+}
+
+export default router;
diff --git a/src/util/config.ts b/src/util/config.ts
deleted file mode 100644
index d1fd05b..0000000
--- a/src/util/config.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-import getConfig from "../lib/config.js";
-
-const config = getConfig();
-
-interface FrontendConfig {
- domain: string;
- email: string;
- siteName: string;
- showKofi: boolean;
- isFederated: boolean;
-}
-
-export const frontendConfig = (): FrontendConfig => ({
- domain: config.general.domain,
- email: config.general.email,
- siteName: config.general.site_name,
- showKofi: config.general.show_kofi,
- isFederated: config.general.is_federated,
-});
diff --git a/src/util/markdown.ts b/src/util/markdown.ts
index 9f5d384..bab50bd 100644
--- a/src/util/markdown.ts
+++ b/src/util/markdown.ts
@@ -1,7 +1,6 @@
-// Extra marked renderer (used to render plaintext event description for page metadata)
-// Adapted from https://dustinpfister.github.io/2017/11/19/nodejs-marked/
-
import { marked } from "marked";
+import { JSDOM } from "jsdom";
+import DOMPurify from "dompurify";
// &#63; to ? helper
function htmlEscapeToText(text: string) {
@@ -14,6 +13,9 @@ function htmlEscapeToText(text: string) {
});
}
+// Extra marked renderer (used to render plaintext event description for page metadata)
+// Adapted from https://dustinpfister.github.io/2017/11/19/nodejs-marked/
+
export const renderPlain = () => {
var render = new marked.Renderer();
// render just the text of a link, strong, em
@@ -42,3 +44,11 @@ export const renderPlain = () => {
};
return render;
};
+
+export const markdownToSanitizedHTML = (markdown: string) => {
+ const html = marked.parse(markdown);
+ const window = new JSDOM("").window;
+ const purify = DOMPurify(window);
+ const clean = purify.sanitize(html);
+ return clean;
+};
diff --git a/static/privacy-policy.md b/static/privacy-policy.md
new file mode 100644
index 0000000..507ef47
--- /dev/null
+++ b/static/privacy-policy.md
@@ -0,0 +1 @@
+This is an example privacy policy. You should edit this file - feel free to take inspiration from the [gath.io instance privacy policy](https://gath.io/privacy).
diff --git a/views/layouts/main.handlebars b/views/layouts/main.handlebars
index daa5a37..fb493a4 100755
--- a/views/layouts/main.handlebars
+++ b/views/layouts/main.handlebars
@@ -68,12 +68,27 @@
{{{body}}}
</div>
<div id="footerContainer">
- <small class="text-muted">
- Version 1.3.0 &middot; <a href="https://github.com/lowercasename/gathio">GitHub</a> &middot; Made with <i class="far fa-heart"></i> by <a href="https://raphaelkabo.com">Raphael</a><br />
- </small>
+ {{#if showInstanceInformation}}
+ <p class="small text-muted">
+ <strong>{{domain}}</strong>
+ {{#each staticPages}}
+ {{#if @first}}
+ &middot;
+ {{/if}}
+
+ <a href="{{this.path}}">{{this.title}}</a>
+
+ {{#unless @last}}
+ &middot;
+ {{/unless}}
+ {{/each}}
+ </p>
+ {{/if}}
+ <p class="small text-muted">
+ <strong>Gathio</strong> version {{version}} &middot; <a href="https://github.com/lowercasename/gathio">GitHub</a> &middot; Made with <i class="far fa-heart"></i> by <a href="https://raphaelkabo.com">Raphael</a> and <a href="https://github.com/lowercasename/gathio/graphs/contributors">contributors</a>
+ </p>
</div>
</div>
</div>
</div>
- </body>
</html>
diff --git a/views/newevent.handlebars b/views/newevent.handlebars
index 349c355..a3b35b3 100755
--- a/views/newevent.handlebars
+++ b/views/newevent.handlebars
@@ -1,3 +1,4 @@
+<article>
<div class="container mb-4">
<div class="row">
<div class="col-sm-4 p-2">
@@ -51,5 +52,7 @@
</form>
</div>
+</article>
+
<script src="/js/generate-timezones.js"></script>
<script src="/js/modules/new.js"></script> \ No newline at end of file
diff --git a/views/static.handlebars b/views/static.handlebars
new file mode 100644
index 0000000..d28d8f2
--- /dev/null
+++ b/views/static.handlebars
@@ -0,0 +1,10 @@
+
+<article class="static-page">
+ <header>
+ <h1>{{title}}</h1>
+ </header>
+ <main>
+ {{{content}}}
+ </main>
+</article>
+