This commit is contained in:
Sebastian Frank 2021-03-22 15:59:05 +01:00
parent dd27483b16
commit 2ee7f650db
46 changed files with 5636 additions and 0 deletions

76
.drone.yml Normal file
View File

@ -0,0 +1,76 @@
kind: pipeline
type: docker
name: default
steps:
- name: load dependencies
image: node
commands:
- yarn install
- name: modify master config
image: bash
commands:
- bash scripts/modify-config.sh master __MASTER_URL__
when:
branch: [master]
- name: modify dev config
image: bash
commands:
- bash scripts/modify-config.sh dev __DEV_URL__
when:
branch: [dev]
- name: build
image: node
commands:
- yarn build
- name: build ssr
image: node
commands:
- yarn build:server
- name: build legacy
image: node
commands:
- yarn build:legacy
- name: modify html
image: bash
commands:
- bash scripts/preload-meta.sh public/spa.html
- bash scripts/preload-meta.sh public/spa.html > dist/spa.html
- export stamp=`date +%s`
- echo $$stamp
- sed -i s/__TIMESTAMP__/$$stamp/g dist/spa.html
- sed -i s/__TIMESTAMP__/$$stamp/g dist/serviceworker.js
- cat dist/serviceworker.js
- cp dist/spa.html api/templates/spa.html
- cat dist/spa.html
- name: deploy master
image: instrumentisto/rsync-ssh
environment:
RSYNC_USER: USER_PROJECT_master
RSYNC_PASS:
from_secret: rsync_master
commands:
- apk add --no-cache sshpass
- scripts/deploy.sh ftp1.webmakers.de $${RSYNC_USER} $${RSYNC_PASS}
when:
branch: [master]
event: [push]
- name: deploy dev
image: instrumentisto/rsync-ssh
environment:
RSYNC_USER: USER_PROJECT_dev
RSYNC_PASS:
from_secret: rsync_dev
commands:
- apk add --no-cache sshpass
- scripts/deploy.sh ftp1.webmakers.de $${RSYNC_USER} $${RSYNC_PASS}
when:
branch: [dev]
event: [push]

11
.gitignore vendored Normal file
View File

@ -0,0 +1,11 @@
_temp/
node_modules/
dist/
build/
build_ssr/
stat/
yarn-error.log
/media/
/test.js
/api/templates/spa.html
/api/hooks/lib/app.server*

16
.prettierrc Normal file
View File

@ -0,0 +1,16 @@
{
"printWidth": 80,
"tabWidth": 4,
"singleQuote": false,
"trailingComma": "es5",
"semi": false,
"newline-before-return": true,
"no-duplicate-variable": [true, "check-parameters"],
"no-var-keyword": true,
"svelteSortOrder": "markup-styles-scripts",
"svelteStrictMode": true,
"svelteBracketNewLine": true,
"svelteAllowShorthand": true,
"svelteIndentScriptAndStyle": true
}

28
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,28 @@
{
"eslint.alwaysShowStatus": true,
"tslint.autoFixOnSave": true,
"editor.formatOnSave": true,
"editor.formatOnPaste": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"[markdown]": {
"editor.wordWrap": "on",
"editor.defaultFormatter": "vscode.markdown-language-features"
},
"[typescriptreact]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"liveServer.settings.root": "/dist",
"liveServer.settings.file": "spa.html",
"liveServer.settings.port": 5501,
"liveServer.settings.proxy": {
"enable": true,
"baseUri": "/api",
"proxyUri": "http://127.0.0.1:8080/api/v1/_/__NAMESPACE__"
},
"extensions.ignoreRecommendations": true,
"files.autoSave": "off",
"typescript.tsc.autoDetect": "off",
"npm.autoDetect": "off",
"debug.allowBreakpointsEverywhere": true,
"html.autoClosingTags": false
}

3
api/.htaccess Normal file
View File

@ -0,0 +1,3 @@
<FilesMatch "\.(ya?ml|env)$">
Require all denied
</FilesMatch>

0
api/attachments/AGB.pdf Normal file
View File

View File

@ -0,0 +1,91 @@
########################################################################
# contact_form
########################################################################
name: contact_form
uploadPath: ../media/contact_form
meta:
label: { de: "Kontaktformular", en: "Contact Form" }
muiIcon: email
rowIdentTpl: { twig: "{{ email }} - {{ subject }}" }
views:
- type: simpleList
mediaQuery: "(max-width: 600px)"
primaryText: email
secondaryText: subject
tertiaryText: insertTime
- type: table
columns:
- insertTime
- email
- subject
permissions:
public:
methods:
get: false
post: false
put: false
delete: false
user:
methods:
get: true
post: false
put: false
delete: false
# token als Zusatzsicherung gegen Spam, mehr siehe Hook
"token:${PUBLIC_TOKEN}":
methods:
get: false
post: true
put: false
delete: false
hooks:
post:
create:
type: javascript
file: hooks/contact_form/post_create.js
return:
type: javascript
file: hooks/contact_form/post_return.js
fields:
- name: firstname
type: string
meta:
label: { de: "Vorname", en: "firstname" }
- name: lastname
type: string
meta:
label: { de: "Nachname", en: "lastname" }
- name: email
type: string
meta:
label: { de: "Email", en: "email" }
- name: postcode
type: string
meta:
label: { de: "Postleitzahl", en: "postcode" }
- name: subject
type: string
meta:
label: { de: "Betreff", en: "subject" }
- name: message
type: string
meta:
label: { de: "Nachricht", en: "message" }
- name: files
type: object[]
meta:
label: { de: "Dateien", en: "files" }
subFields:
- name: file
type: file
meta:
label: { de: "", en: "" }
- name: title
type: string
meta:
label: { de: "Dateititel", en: "file title" }

164
api/collections/content.yml Normal file
View File

@ -0,0 +1,164 @@
###############################################################
# Content Blöcke
###############################################################
# Name/URL-Anteil der Kollektion
name: content
uploadPath: ../media/content
# Metaangaben zur Kollektion welche in der Admin-UI verwendet werden können
meta:
# Navigationseintrag in der Admin-UI
label: { de: "Inhaltsblöcke", en: "Content Blocks" }
# Icon (Material UI) für den Navigationseintrag
muiIcon: web
# Identifizierung eines Eintrags für z.B. Select-Boxen in der Admin-UI
rowIdentTpl: { twig: "{{ path }}" }
# Standardsortierung der Liste
defaultSort: { field: "path", order: "ASC" }
# Admin-Backend Ansichten
defaultImageFilter: s
views:
# Mobile Darstellung
- type: simpleList
mediaQuery: "(max-width:599px)"
primaryText: path
# Desktop
- type: table
mediaQuery: "(min-width:600px)"
columns:
- path
imageFilter:
xs:
- fit: true
height: 90
width: 90
resampling: lanczos
quality: 60
s:
- fit: true
height: 300
width: 300
resampling: lanczos
quality: 60
m:
- fit: true
height: 600
width: 600
resampling: lanczos
quality: 60
l:
- fit: true
height: 1200
width: 1200
resampling: lanczos
quality: 60
xl:
- fit: true
height: 2000
width: 2000
resampling: lanczos
quality: 60
# Zugriff auf diese Kollektion
permissions:
# öffentlicher Zugriff
public:
methods:
# Liste und Einzeleinträge lesen
get: true
# neuen Eintrag anlegen
post: false
# Eintrag editieren
put: false
# Eintrag löschen
delete: false
# zum Projekt zugeordneter Benutzer ohne Zusatzberechtigungen
user:
methods:
get: true
post: true
put: true
delete: true
hooks:
post:
return:
type: javascript
file: hooks/content/post_return.js
put:
return:
type: javascript
file: hooks/content/put_return.js
delete:
return:
type: javascript
file: hooks/content/delete_return.js
# Feldliste der Kollektion
fields:
- name: path
type: string
index: [ single, unique ]
meta:
label: { de: "Pfad", en: "Path" }
- name: blocks
type: object[]
meta:
label: { de: "Oben", en: "Top" }
subFields:
- name: layout
type: number
meta:
widget: select
label: { de: "Layout", en: "layout" }
choices:
- { id: 1, name: { de: "Bild links", en: "image left" } }
- { id: 2, name: { de: "Bild rechts", en: "image right" } }
- {
id: 3,
name:
{ de: "Bild über Text", en: "image above text" },
}
- {
id: 4,
name:
{ de: "Bild unter Text", en: "image below text" },
}
- name: title
type: string
meta:
label: { de: "Titel", en: "title" }
- name: subtitle
type: string
meta:
label: { de: "Untertitel", en: "subtitle" }
- name: text
type: string
meta:
widget: richtext
label: { de: "Text", en: "text" }
- name: button_text
type: string
meta:
label: { de: "Button-Text", en: "button text" }
- name: button_url
type: string
meta:
label: { de: "Button-URL", en: "button URL" }
- name: images
type: object[]
meta:
label: { de: "Bild(er)", en: "image(s)" }
subFields:
- name: file
type: file
meta:
widget: image
label: { de: "Datei", en: "file" }
- name: label
type: string
meta:
label: { de: "Label", en: "label" }

59
api/collections/ssr.yml Normal file
View File

@ -0,0 +1,59 @@
########################################################################
# SSR Dummy collections
########################################################################
name: ssr
meta:
label: { de: "SSR Dummy", en: "ssr dummy" }
muiIcon: http
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: false
put: false
delete: false
hooks:
get:
read:
type: javascript
file: hooks/ssr/get_read.js
post:
create:
type: javascript
file: hooks/ssr/post_create.js
# we only need hooks
fields:
- name: path
type: string
index: [single, unique]
- name: content
type: string

14
api/config.yml Normal file
View File

@ -0,0 +1,14 @@
namespace: __NAMESPACE__
# Metaangaben zum Projekt welche in der UI verwendet werden können
meta:
# wird in der Admin-UI zum Projekt aufgegeben
imageUrl: https://__MASTER_URL__/media/api-pic.jpg
# Liste aller möglichen Kollektionen (Listen, Seiten...) zum Projekt
collections:
- !include collections/content.yml
- !include collections/contact_form.yml
- !include collections/cache.yml
- !include collections/webhook.yml
- !include collections/ssr.yml

3
api/config.yml.env Normal file
View File

@ -0,0 +1,3 @@
PUBLIC_TOKEN=__PUBLIC_TOKEN__
SSR_TOKEN=__SSR_TOKEN__
PAYPAL_TOKEN=__PAYPAL_TOKEN__

17
api/hooks/config.js Normal file
View File

@ -0,0 +1,17 @@
module.exports = {
projectName: "__PROJECT_NAME__",
operatorEmail: "__OPERATOR_EMAIL_",
apiBase: "http://localhost:8080/api/v1/_/__NAMESPACE__/",
frontendBase: "http://localhost:5501/",
publicToken: "__PUBLIC_TOKEN__",
ssrToken: "__SSR_TOKEN__",
ssrValidatePath: function(path) {
// TODO 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
return path.match(/^\/(home|service)\/?$/)
}
}

View File

@ -0,0 +1,25 @@
// @ts-check
/**
* @typedef {import('../types') }
*/
var utils = require("../lib/utils")
;(function () {
if (utils.isPublicToken(context)) {
// js captcha
var checksum = context.request().query("cs")
var email = context.data.email
if (!email || (email.length * 1000).toString(16) + "x" !== checksum) {
throw {
status: 403,
error: "forbidden data",
}
}
}
/** @type {import('../types').HookResponse} */
// @ts-ignore
var response = null
return response
})()

View File

@ -0,0 +1,37 @@
// @ts-check
/**
* @typedef {import('../types') }
*/
var config = require("../config")
var utils = require("../lib/utils")
;(function () {
if (utils.isPublicToken(context)) {
var emailFrom = context.data.email
var emailFromName =
(context.data.firstname || "") +
(context.data.firstname && context.data.lastname && " ") +
(context.data.lastname || "")
context.mail({
to: config.operatorEmail,
from: emailFrom,
fromName: emailFromName,
subject: utils.tpl(
context,
"templates/operator_contact_form_subject.de.txt"
),
html: utils.tpl(
context,
"templates/operator_contact_form_body.de.html"
),
// attach: ["attachments/AGB.pdf"],
})
}
/** @type {import('../types').HookResponse} */
// @ts-ignore
var response = null
return response
})()

View File

@ -0,0 +1,10 @@
// @ts-check
/**
* @typedef {import('../types') }
*/
var utils = require("../lib/utils")
;(function () {
utils.clearSSRCache()
})()

View File

@ -0,0 +1,10 @@
// @ts-check
/**
* @typedef {import('../types') }
*/
var utils = require("../lib/utils")
;(function () {
utils.clearSSRCache()
})()

View File

@ -0,0 +1,10 @@
// @ts-check
/**
* @typedef {import('../types') }
*/
var utils = require("../lib/utils")
;(function () {
utils.clearSSRCache()
})()

253
api/hooks/lib/utils.js Normal file
View File

@ -0,0 +1,253 @@
// @ts-check
/**
* @typedef {import('../types') }
*/
var config = require("../config")
/**
*
* @param {any} str
*/
function log(str) {
console.log(JSON.stringify(str, undefined, 4))
}
function rand() {
return Math.random().toString(36).substr(2) // remove `0.`
}
/**
* @returns {string}
*/
function randomToken() {
return rand() + rand()
}
/**
*
* @param {import('../types').HookContext} c
* @returns {boolean}
*/
function isPublicToken(c) {
var r = c.request()
var t = r.query("token") || r.header("token")
return t == config.publicToken
}
/**
*
* @param {import('../types').HookContext} c
* @returns {boolean}
*/
function isSsrToken(c) {
var r = c.request()
var t = r.query("token") || r.header("token")
return t == config.ssrToken
}
/**
*
* @param {import('../types').HookContext} c
* @param {string} filename
* @returns {string}
*/
function tpl(c, filename) {
return c.template(c.file(filename), {
context: c,
config: config,
/**
* @param {number} v
* @param {number} vat
*/
formatPrice: function (v, vat) {
if (vat) {
v *= vat / 100 + 1
}
return v.toFixed(2).toString().replace(".", ",")
},
})
}
/**
*
* Base64 encode / decode
* http://www.webtoolkit.info/
*
**/
var Base64 = {
// private property
_keyStr:
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=",
// public method for encoding
encode: function (input) {
var output = ""
var chr1, chr2, chr3, enc1, enc2, enc3, enc4
var i = 0
input = Base64._utf8_encode(input)
while (i < input.length) {
chr1 = input.charCodeAt(i++)
chr2 = input.charCodeAt(i++)
chr3 = input.charCodeAt(i++)
enc1 = chr1 >> 2
enc2 = ((chr1 & 3) << 4) | (chr2 >> 4)
enc3 = ((chr2 & 15) << 2) | (chr3 >> 6)
enc4 = chr3 & 63
if (isNaN(chr2)) {
enc3 = enc4 = 64
} else if (isNaN(chr3)) {
enc4 = 64
}
output =
output +
this._keyStr.charAt(enc1) +
this._keyStr.charAt(enc2) +
this._keyStr.charAt(enc3) +
this._keyStr.charAt(enc4)
}
return output
},
// public method for decoding
decode: function (input) {
var output = ""
var chr1, chr2, chr3
var enc1, enc2, enc3, enc4
var i = 0
input = input.replace(/[^A-Za-z0-9\+\/\=]/g, "")
while (i < input.length) {
enc1 = this._keyStr.indexOf(input.charAt(i++))
enc2 = this._keyStr.indexOf(input.charAt(i++))
enc3 = this._keyStr.indexOf(input.charAt(i++))
enc4 = this._keyStr.indexOf(input.charAt(i++))
chr1 = (enc1 << 2) | (enc2 >> 4)
chr2 = ((enc2 & 15) << 4) | (enc3 >> 2)
chr3 = ((enc3 & 3) << 6) | enc4
output = output + String.fromCharCode(chr1)
if (enc3 != 64) {
output = output + String.fromCharCode(chr2)
}
if (enc4 != 64) {
output = output + String.fromCharCode(chr3)
}
}
output = Base64._utf8_decode(output)
return output
},
// private method for UTF-8 encoding
_utf8_encode: function (string) {
string = string.replace(/\r\n/g, "\n")
var utftext = ""
for (var n = 0; n < string.length; n++) {
var c = string.charCodeAt(n)
if (c < 128) {
utftext += String.fromCharCode(c)
} else if (c > 127 && c < 2048) {
utftext += String.fromCharCode((c >> 6) | 192)
utftext += String.fromCharCode((c & 63) | 128)
} else {
utftext += String.fromCharCode((c >> 12) | 224)
utftext += String.fromCharCode(((c >> 6) & 63) | 128)
utftext += String.fromCharCode((c & 63) | 128)
}
}
return utftext
},
// private method for UTF-8 decoding
_utf8_decode: function (utftext) {
var string = ""
var i = 0
var c = 0
var c1 = 0
var c2 = 0
var c3 = 0
while (i < utftext.length) {
c = utftext.charCodeAt(i)
if (c < 128) {
string += String.fromCharCode(c)
i++
} else if (c > 191 && c < 224) {
c2 = utftext.charCodeAt(i + 1)
string += String.fromCharCode(((c & 31) << 6) | (c2 & 63))
i += 2
} else {
c2 = utftext.charCodeAt(i + 1)
c3 = utftext.charCodeAt(i + 2)
string += String.fromCharCode(
((c & 15) << 12) | ((c2 & 63) << 6) | (c3 & 63)
)
i += 3
}
}
return string
},
}
/**
*
* @param {string} d
*/
function parseDate(d) {
return new Date(
d
.toString()
// go time objects output is not parseable by goja, so fix it
.replace(
/[T ](\d{2}:\d{2}:\d{2})(?:\.\d+)? ([\+\-])(\d{4}).*$/,
"T$1$2$3"
)
)
}
/**
* clear SSR cache
*/
function clearSSRCache() {
var info = context.deleteDocuments("ssr", {})
context.header("X-SSR-Cleared", info.removed)
}
/**
* 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
}
module.exports = {
log: log,
randomToken: randomToken,
isPublicToken: isPublicToken,
isSsrToken: isSsrToken,
tpl: tpl,
Base64: Base64,
parseDate: parseDate,
clearSSRCache: clearSSRCache,
obj2str: obj2str,
ssrValidatePath: config.ssRValidatePath,
}

210
api/hooks/ssr/get_read.js Normal file
View File

@ -0,0 +1,210 @@
// @ts-check
/**
* @typedef {import('../types') }
*/
var utils = require("../lib/utils")
;(function () {
var request = context.request()
var url = request.query("url")
var noCache = request.query("noCache")
var trace_id = context.sentryTraceId()
function addSentryTrace(content) {
return content.replace(
"</head>",
'<meta name="sentry-trace" content="' + trace_id + '" /></head>'
)
}
context.header("sentry-trace", trace_id)
if (url) {
var comment = ""
url = url.split("?")[0]
comment += "url: " + url
if (url && url.length > 1) {
url = url.replace(/\/$/, "")
}
if (url == "/noindex" || !url) {
url = "/" // see .htaccess
}
var cache =
!noCache &&
context.readCollection("ssr", {
filter: {
path: url,
},
})
if (cache && cache.length) {
// use cache
throw {
status: 200,
html: addSentryTrace(cache[0].content),
}
}
// validate url
var status = 200
var pNorender = false
var pNotfound = false
var pR = utils.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 {
// if error, output plain html without prerendering
// @ts-ignore
context.ssrCache = {}
// @ts-ignore
context.ssrFetch = function (endpoint, options) {
var data
if (
endpoint == "product" ||
endpoint == "category" ||
endpoint == "country" ||
endpoint == "content"
) {
var _options = Object.assign({}, options)
if (_options.sort) _options.sort = [_options.sort]
try {
/*console.log(
"SSR",
endpoint,
JSON.stringify(_options)
)*/
var goSlice = context.readCollection(
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.readCollectionCount(
endpoint,
_options || {}
)
}
var r = { data: data, count: count }
// @ts-ignore
context.ssrCache[
utils.obj2str({ endpoint: endpoint, options: options })
] = r
return r
}
// @ts-ignore
var app = require("../lib/app.server")
var rendered = app.default.render({
url: url,
})
head = rendered.head
html = rendered.html
head +=
"\n\n" +
"<script>window.__SSR_CACHE__ = " +
// @ts-ignore
JSON.stringify(context.ssrCache) +
"</script>"
cacheIt = true
} catch (e) {
utils.log(e)
for (var property in e) {
utils.log(property + ": " + e[property])
}
error = JSON.stringify(e)
}
}
var tpl = context.file("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 + "-->" : ""
)
if (cacheIt && !noCache) {
// save cache
context.createDocument("ssr", {
path: url,
content: tpl,
})
}
tpl.replace(
"</head>",
'<meta name="sentry-trace" content="' + trace_id + '" /></head>'
)
throw {
status: status,
html: addSentryTrace(tpl),
}
} else {
var auth = context.auth()
if (!auth || auth.role !== 0) {
// only admins are allowed
throw {
status: 403,
message: "invalid auth",
auth: auth,
}
}
}
})()
/*
require("../lib/hook.test")
console.log("hook test ende")
throw {
status: 500,
msg: "TEST",
}
*/

View File

@ -0,0 +1,11 @@
// @ts-check
/**
* @typedef {import('../types') }
*/
;(function () {
throw {
status: 500,
message: "ssr is only a dummy collection",
}
})()

247
api/hooks/types.d.ts vendored Normal file
View File

@ -0,0 +1,247 @@
export interface CollectionDocument {
id?: string
insertTime?: Date
updateTime?: Date
[key: string]: any
}
export interface ReadCollectionOptions {
filter?: {
[key: string]: any
}
selector?: {
[key: string]: any
}
projection?: string
offset?: number
limit?: number
sort?: string[]
}
interface GetHookData {
/**
* true if only one document was requested via /COLLECTION/ID
*/
one?: boolean
/**
* get list of documents (only valid after stage "read" in "get" hook)
*/
results(): CollectionDocument[]
/**
* filter map only valid for "get" hooks
*/
filter?: {
[key: string]: any
}
/**
* selector map only valid for "get" hooks
*/
selector?: {
[key: string]: any
}
/**
* offset only valid for "get" hooks
*/
offset?: number
/**
* limit only valid for "get" hooks
*/
limit?: number
/**
* sort only valid for "get" hooks
*/
sort?: string[] | string
}
interface PostHookData {
/**
* post data only valid in "post" and "put" hooks
*/
data?: CollectionDocument
}
export interface HookContext extends GetHookData, PostHookData {
request(): {
method: string
remoteAddr: string
host: string
url: string
path: string
param(p: string): string
query(q: string): string
header(h: string): string
body(): string
}
/**
* read results from a collection
*
* @param colName collection name
* @param options options map
*/
readCollection(
colName: string,
options?: ReadCollectionOptions
): CollectionDocument[]
/**
* read count of documents for filter from a collection
*
* @param colName collection name
* @param options options map (only filter is valid)
*/
readCollectionCount(
colName: string,
options?: ReadCollectionOptions
): number
/**
* create a document in a collection
*
* @param colName collection name
* @param data data map
*/
createDocument(
colName: string,
data: CollectionDocument
): CollectionDocument
/**
* update a document in a collection
*
* @param colName collection name
* @param id id of entry
* @param data new/changed data
*/
updateDocument(
colName: string,
id: string,
data: CollectionDocument
): CollectionDocument
/**
* deletes one document by id from collection
*
* @param colName collection name
* @param id id of entry
*/
deleteDocument(colName: string, id: string): { message: "ok" }
/**
* deletes documents by filter from collection
*
* @param colName collection name
* @param options options map, only filter valid
*/
deleteDocuments(
colName: string,
options?: ReadCollectionOptions
): { message: "ok"; removed: number }
/**
* send an email
*
* @param options email options map
*/
mail(options: {
to: string | string[]
cc?: string | string[]
bcc?: string | string[]
subject?: string
from: string
fromName?: string
plain?: string
html?: string
attach?: string | string[]
}): void
/**
* execute a template code and return result
*
* @param code template code
* @param contextData template context map
*/
template(
code: string,
contextData?: {
[key: string]: any
}
): string
/**
* read a file relative to config dir and return its content
*
* @param path relative file path
*/
file(path: string): string
/**
* http request
*
* @param url url for request
* @param options request options
*/
fetch(
url: string,
options?: {
method?: string
headers?: { [key: string]: string }
body?: string
}
): {
status: number
statusText: string
headers: { [key: string]: string }
trailer: { [key: string]: string }
url: string
body: {
text(): string
json(): any
}
}
/**
* dumps data to header and server log
*
* @param toDump data to dump
*/
dump(...toDump: any): void
/**
* set response header
*
* @param name header name
* @param value value
*/
header(name: string, value: any): void
/**
* get JWT authentication
*/
auth(): {
id: string
username: string
role: number
permissions: string[]
}
/**
* get Sentry trace id
*/
sentryTraceId(): string
}
export interface HookException {
status?: number
html?: string
[key: string]: any
}
export interface HookResponse extends GetHookData, PostHookData {
data?: CollectionDocument
results?: any
}
declare global {
var context: HookContext
}

View File

@ -0,0 +1,24 @@
<html>
<body>
<h1>Kontaktformular</h1>
<label>Vorname:</label> {{context.data.firstname}}<br />
<label>Nachname:</label> {{context.data.lastname}}<br />
<label>Email:</label> {{context.data.email}}<br />
<label>PLZ:</label> {{context.data.postcode}}<br />
<h3>Nachricht</h3>
<div style="white-space: pre-wrap">{{context.data.message}}</div>
<h4>Dateien</h4>
<ul>
{% for f in context.data.files %}
<li>
<a
href="{{config.apiBase}}contact_form/{{context.data.id}}/{{f.file.src}}"
>{{config.apiBase}}contact_form/{{context.data.id}}/{{f.file.src}}</a
>
</li>
{% endfor %}
</ul>
</body>
</html>

View File

@ -0,0 +1 @@
{{ config.projectName }} Kontaktformular: {{context.data.subject}}

25
babel.config.json Normal file
View File

@ -0,0 +1,25 @@
{
"sourceMaps": "inline",
"inputSourceMap": true,
"presets": [
[
"@babel/preset-env",
{
"useBuiltIns": "usage",
"corejs": {
"version": "3",
"proposals": true
},
"targets": ">0.5%, IE 11, not dead"
}
]
],
"plugins": [
[
"@babel/plugin-transform-spread",
{
"loose": true
}
]
]
}

71
esbuild.config.js Normal file
View File

@ -0,0 +1,71 @@
const resolvePlugin = {
name: "resolvePlugin",
setup(build) {
let path = require("path")
// url in css does not resolve via esbuild-svelte correctly
build.onResolve({ filter: /.*/, namespace: "fakecss" }, (args) => {
// console.log(args)
if (args.path.match(/^\./))
return { path: path.dirname(args.importer) + "/" + args.path }
// return { path: path.join(args.resolveDir, "public", args.path) }
})
},
}
////////////////////////// esbuild-svelte
const sveltePlugin = require("esbuild-svelte")
const distDir = "dist"
console.log("copy public dir...")
const copydir = require("copy-dir")
copydir.sync(__dirname + "/public", __dirname + "/" + distDir)
/*copydir.sync(
__dirname + "/public/index.html",
__dirname + "/" + distDir + "/template.html"
)*/
const svelteConfig = require("./svelte.config")
//const esbuildSvelte = require("esbuild-svelte")({
const esbuildSvelte = sveltePlugin({
compileOptions: {
css: false,
hydratable: true,
dev: (process.argv?.length > 2 ? process.argv[2] : "build") !== "build",
},
preprocess: svelteConfig.preprocess,
cache: true,
})
const options = {
color: true,
entryPoints: ["./src/index.ts"],
outfile: "./" + distDir + "/_dist_/index.mjs",
metafile: "./" + distDir + "/_dist_/meta.json",
format: "esm",
minify: true,
bundle: true,
splitting: false,
plugins: [esbuildSvelte, resolvePlugin],
loader: {
".woff2": "file",
},
sourcemap: true,
target: ["es2020", "chrome61", "firefox60", "safari11", "edge16"],
}
module.exports = {
sveltePlugin: sveltePlugin,
resolvePlugin: resolvePlugin,
options: options,
watch: {
path: [__dirname + "/src/**/*"],
},
serve: {
onRequest(args) {
console.log(args)
},
},
}

8
esbuild.config.legacy.js Normal file
View File

@ -0,0 +1,8 @@
const config = require("./esbuild.config.js")
config.options.sourcemap = "inline"
config.options.minify = false
config.options.format = "iife"
config.options.outfile = __dirname + "/_temp/index.js"
module.exports = config

25
esbuild.config.server.js Normal file
View File

@ -0,0 +1,25 @@
const config = require("./esbuild.config.js")
const svelteConfig = require("./svelte.config")
config.options.sourcemap = "inline"
config.options.minify = false
config.options.platform = "node"
config.options.format = "cjs"
config.options.entryPoints = ["./src/ssr.ts"]
config.options.outfile = __dirname + "/_temp/app.server.js"
config.options.plugins = [
config.sveltePlugin({
compileOptions: {
generate: "ssr",
css: false,
hydratable: true,
dev:
(process.argv?.length > 2 ? process.argv[2] : "build") !==
"build",
},
preprocess: svelteConfig.preprocess,
}),
config.resolvePlugin,
]
module.exports = config

48
package.json Normal file
View File

@ -0,0 +1,48 @@
{
"name": "wmbasic-svelte-starter",
"version": "1.0.0",
"main": "src/index.js",
"author": "Sebastian Frank (Webmakers GmbH)",
"private": true,
"license": "MIT",
"scripts": {
"validate": "svelte-check && tsc --noEmit",
"dev": "node scripts/esbuild-wrapper.js watch",
"build": "node scripts/esbuild-wrapper.js build",
"build:legacy": "node scripts/esbuild-wrapper.js build esbuild.config.legacy.js && babel _temp/index.js -o _temp/index.babeled.js && esbuild _temp/index.babeled.js --outfile=dist/_dist_/index.es5.js --target=es5 --bundle --minify --sourcemap",
"build:server": "node scripts/esbuild-wrapper.js build esbuild.config.server.js && babel _temp/app.server.js -o _temp/app.server.babeled.js && esbuild _temp/app.server.babeled.js --outfile=api/hooks/lib/app.server.js --target=es5 --bundle --sourcemap --platform=node",
"build:test": "node scripts/esbuild-wrapper.js build esbuild.config.test.js && babel --config-file ./babel.config.test.json _temp/hook.test.js -o _temp/hook.test.babeled.js && esbuild _temp/hook.test.babeled.js --outfile=api/hooks/lib/hook.test.js --target=es5 --bundle --sourcemap --platform=node"
},
"devDependencies": {
"@babel/cli": "^7.12.8",
"@babel/core": "^7.12.9",
"@babel/plugin-transform-spread": "^7.12.1",
"@babel/preset-env": "^7.12.7",
"@prerenderer/prerenderer": "^0.7.2",
"@prerenderer/renderer-jsdom": "^0.2.0",
"@prerenderer/renderer-puppeteer": "^0.2.0",
"@tsconfig/svelte": "^1.0.10",
"chokidar": "^3.4.3",
"copy-dir": "^1.3.0",
"esbuild": "^0.8.17",
"esbuild-svelte": "^0.4.0",
"less": "^3.12.2",
"postcss": "^8.1.10",
"prettier": "^2.2.0",
"prettier-plugin-svelte": "^1.4.1",
"sass": "^1.30.0",
"svelte": "^3.29.7",
"svelte-check": "^1.1.11",
"svelte-preprocess": "^4.0.8",
"svelte-preprocess-esbuild": "^1.0.4",
"svelte-routing": "^1.4.2",
"svelte-scrollto": "^0.2.0",
"tslib": "^2.0.3",
"typescript": "^4.1.2"
},
"dependencies": {
"@sentry/browser": "^6.2.1",
"@sentry/tracing": "^6.2.1",
"core-js": "3"
}
}

13
public/.htaccess Normal file
View File

@ -0,0 +1,13 @@
AddType application/javascript .mjs
#DirectoryIndex index.html spa.html
DirectoryIndex noindex
<ifModule mod_rewrite.c>
RewriteEngine On
RewriteBase /
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^/?(.*)$ http://wmbasic_api:8080/api/v1/_/__NAMESPACE__/ssr?token=__SSR_TOKEN__&url=/$1 [P,QSA,L]
#RewriteRule (.*) /spa.html [QSA,L]
</ifModule>

16
public/serviceworker.js Normal file
View File

@ -0,0 +1,16 @@
const cacheName = "site-cache-v1"
const assetsToCache = [
"/",
"/_dist_/index.mjs.css?t=__TIMESTAMP__",
"/_dist_/index.mjs?t=__TIMESTAMP__",
]
self.addEventListener("install", (event) => {
self.skipWaiting() // skip waiting
event.waitUntil(
caches.open(cacheName).then((cache) => {
return cache.addAll(assetsToCache)
})
)
})
self.addEventListener("fetch", (event) => {})

20
public/site.webmanifest Executable file
View File

@ -0,0 +1,20 @@
{
"name": "__PROJECT_NAME__",
"short_name": "__PROJECT_NAME__",
"start_url": "/",
"icons": [
{
"src": "/favicon/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/favicon/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}

56
public/spa.html Normal file
View File

@ -0,0 +1,56 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>__PROJECT_NAME__</title>
<base href="/" />
<link rel="stylesheet" href="/_dist_/index.mjs.css?t=__TIMESTAMP__" />
<!--
<link
rel="shortcut icon"
type="image/x-icon"
href="/favicon/favicon.ico"
/>
<link
rel="apple-touch-icon"
sizes="180x180"
href="/favicon/apple-touch-icon.png"
/>
<link
rel="icon"
type="image/png"
sizes="32x32"
href="/favicon/favicon-32x32.png"
/>
<link
rel="icon"
type="image/png"
sizes="16x16"
href="/favicon/favicon-16x16.png"
/>
<link rel="manifest" href="/site.webmanifest" />
<link
rel="mask-icon"
href="/favicon/safari-pinned-tab.svg"
color="#c4102d"
/>
<meta name="msapplication-TileColor" content="#c4102d" />
<meta name="theme-color" content="#ffffff" />
-->
<!--HEAD-->
<!--PRELOAD-->
</head>
<body>
<div id="appContainer"><!--HTML--></div>
<script type="module" src="/_dist_/index.mjs?t=__TIMESTAMP__"></script>
<script nomodule src="/_dist_/index.es5.js?t=__TIMESTAMP__"></script>
</body>
<!--SSR.ERROR-->
<!--SSR.COMMENT-->
</html>

40
scripts/deploy.sh Executable file
View File

@ -0,0 +1,40 @@
#!/bin/sh
host=$1
user=$2
pass=$3
if [ "$host" == "" ]; then
echo "missing host"
exit 1
fi
if [ "$user" == "" ]; then
echo "missing username"
exit 1
fi
if [ "$pass" == "" ]; then
echo "missing password"
exit 1
fi
echo "sync frontend"
rsync -rlcgD --perms -i -u -v --stats --progress \
--delete \
-e "sshpass -p $pass ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -p 22222" \
dist/ \
$user@$host:./frontend/
echo "sync api config"
rsync -rlcgD --perms -i -u -v --stats --progress \
--delete \
-e "sshpass -p $pass ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -p 22222" \
api/ \
$user@$host:./api/
echo "create media directory"
mkdir media
chmod 770 media
rsync -rlcgD --perms -i -u -v --stats --progress \
-e "sshpass -p $pass ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -p 22222" \
media \
$user@$host:./

View File

@ -0,0 +1,51 @@
const esbuild = require("esbuild")
const config = require(process.cwd() +
(process.argv?.length > 3 ? "/" + process.argv[3] : "/esbuild.config.js"))
const { watch } = require("chokidar")
function log(str, clear) {
if (clear) {
process.stdout.cursorTo(0, 0)
process.stdout.clearScreenDown()
}
console.log("\x1b[36m%s\x1b[0m", str)
}
let buildResults
async function build(catchError) {
log((buildResults ? "re" : "") + "building...")
const timerStart = Date.now()
try {
buildResults = buildResults
? await buildResults.rebuild()
: await esbuild.build(config.options)
} catch (e) {
console.log(e)
if (!catchError) throw e
}
const timerEnd = Date.now()
log(`built in ${timerEnd - timerStart}ms.`)
}
switch (process.argv?.length > 2 ? process.argv[2] : "build") {
case "serve":
console.log("\x1b[36m%s\x1b[0mserving...")
esbuild.serve(config.serve, config.options).catch((err) => {
console.error(err)
process.exit(1)
})
break
case "watch":
config.options.incremental = true
build(true)
const watcher = watch(config.watch.path)
log("watching files...")
watcher.on("change", function (path) {
log(`${path} changed`, true)
build(true)
})
break
default:
build()
}

35
scripts/modify-config.sh Executable file
View File

@ -0,0 +1,35 @@
#!/bin/bash
branch=$1
url=$2
paypal=sandbox
if [ "$branch" == "" ]; then
echo missing branch name
exit 1
fi
if [ "$url" == "" ]; then
echo missing url
exit 1
fi
if [ "$branch" == "master" ]; then
paypal=live
fi
sed -i 's#\(apiBase:\).*#apiBase:"'$url'/api/",#g' api/hooks/config.js
sed -i 's#\(frontendBase:\).*#frontendBase:"'$url'/",#g' api/hooks/config.js
sed -i 's#\(pppReturnURL:\).*#pppReturnURL:"'$url'/checkout/overview\?type=paypal",#g' api/hooks/config.js
sed -i 's#\(pppCancelURL:\).*#pppCancelURL:"'$url'/checkout/payment\?type=paypal",#g' api/hooks/config.js
sed -i 's#\(var paypalMode *=\).*#var paypalMode = "'$paypal'"#g' api/hooks/config.js
cat api/hooks/config.js
sed -i 's#\(apiBaseURL.*\)"http.*"#\1"'$url'/api/"#g' src/config.ts
sed -i 's#\(sentryEnvironment.*\)".*"#\1"'$branch'"#g' src/config.ts
if [ "$branch" == "master" || "$branch" == "dev" ]; then
sed -i 's#//\( sentry\\.init.*\)#\1#g' src/config.ts
fi
cat src/config.ts

16
scripts/preload-meta.sh Executable file
View File

@ -0,0 +1,16 @@
#!/bin/bash
if [ "$1" == "" ]; then
echo template filename required
exit 1
fi
preload=$(for f in dist/_dist_/*.woff2; do
echo "<link rel=\"preload\" href=\"/_dist_/`basename $f`\" as=\"font\" type=\"font/woff2\" crossorigin />"
done)
template="`cat $1 | sed -e 's#<!--PRELOAD-->#\$preload#'`"
eval "cat <<EOF
$template
EOF"

32
src/components/App.svelte Normal file
View File

@ -0,0 +1,32 @@
<h1>__PROJECT_TITLE__</h1>
<style lang="less">
h1 {
color: red;
}
</style>
<script lang="typescript">
import { scrollToTop } from "svelte-scrollto"
import { location } from "../store"
export let url = ""
if (url) {
// ssr
let l = url.split("?")
$location = {
path: l[0],
search: l.length > 1 ? l[1] : "",
categoryPath: l[0].replace(/\/\d{4,99}[^\/]+$/, ""),
push: false,
pop: false,
}
}
// scroll to top on new site
location.subscribe((l) => {
if (l.push) scrollToTop()
})
if (typeof window !== "undefined") console.log("App initialized")
</script>

22
src/config.ts Normal file
View File

@ -0,0 +1,22 @@
// @ts-check
import * as sentry from "./sentry"
export const title = "__PROJECT_TITLE__"
let _apiBaseURL =
typeof window !== "undefined" && window.localStorage?.getItem("apiBase")
export const apiBaseURL = _apiBaseURL || "/api/"
export const sentryDSN =
"https://95fad64e48484bd7b3e52e56416ac38e@sentry.basehosts.de/2"
export const sentryTracingOrigins = [
"localhost",
apiBaseURL,
// more api trace urls here
/^\//,
]
export const sentryEnvironment = "local"
// need to execute early for fetch wrapping
// will be uncommented via drone CI
// sentry.init(sentryDSN, sentryTracingOrigins, sentryEnvironment)

96
src/index.ts Normal file
View File

@ -0,0 +1,96 @@
import App from "./components/App.svelte"
import { location } from "./store"
import { apiBaseURL } from "./config"
console.log("API Base: ", apiBaseURL)
// update location store
const publishLocation = (_p?: string) => {
let _s: string
if (_p) {
const parts = _p.split("?")
_p = parts.shift()
_s = parts.join()
if (_s) _s = "?" + _s
}
const newLocation = {
path:
_p || (typeof window !== "undefined" && window.location?.pathname),
search:
_s || (typeof window !== "undefined" && window.location?.search),
push: !!_p,
pop: !_p,
categoryPath: "",
}
newLocation.categoryPath = newLocation.path.replace(/\/\d{4,99}[^\/]+$/, "")
location.set(newLocation)
}
// history proxy for location store update
if (typeof history !== "undefined") {
if (typeof Proxy !== "undefined") {
// modern browser
const historyApply = (target, thisArg, argumentsList) => {
publishLocation(
argumentsList && argumentsList.length >= 2 && argumentsList[2]
)
Reflect.apply(target, thisArg, argumentsList)
}
history.pushState = new Proxy(history.pushState, {
apply: historyApply,
})
history.replaceState = new Proxy(history.replaceState, {
apply: historyApply,
})
} else {
// ie11
const pushStateFn = history.pushState
const replaceStateFn = history.replaceState
history.pushState = function (data: any, title: string, url?: string) {
publishLocation(url)
return pushStateFn.apply(history, arguments)
}
history.replaceState = function (
data: any,
title: string,
url?: string
) {
publishLocation(url)
return replaceStateFn.apply(history, arguments)
}
}
} // else ssr -> no history handling
typeof window !== "undefined" &&
window.addEventListener("popstate", (event) => {
publishLocation()
})
let appContainer = document?.getElementById("appContainer")
const hydrate = true //import.meta?.env?.MODE !== "development"
console.log("Features: ", { hydrate })
const app = new App({
target: appContainer,
props: {},
hydrate,
})
export default app
// PWA
if (typeof navigator !== "undefined" && "serviceWorker" in navigator) {
console.log("Registering service worker")
navigator.serviceWorker
.register("/serviceworker.js", { scope: "/" })
.then((reg) => {
console.log("Registration succeeded. Scope is " + reg.scope)
})
.catch((e) => {
console.log("Registration failed:", e)
})
}

24
src/sentry.ts Normal file
View File

@ -0,0 +1,24 @@
import * as Sentry from "@sentry/browser"
import { Integrations } from "@sentry/tracing"
export const init = (dsn, tracingOrigins, environment) => {
if (typeof window !== "undefined") {
Sentry.init({
dsn: dsn,
integrations: [
new Integrations.BrowserTracing({
tracingOrigins: tracingOrigins,
traceFetch: false,
traceXHR: false,
}),
],
environment: environment,
tracesSampleRate: 1.0,
debug: false,
})
console.log("Sentry initialized")
}
}
export const currentTransaction = () =>
Sentry.getCurrentHub().getScope().getTransaction()

3
src/ssr.ts Normal file
View File

@ -0,0 +1,3 @@
import App from "./components/App.svelte"
export default App

12
src/store.ts Normal file
View File

@ -0,0 +1,12 @@
import { writable, get } from "svelte/store"
const initLoc = {
path: (typeof window !== "undefined" && window.location?.pathname) || "/",
search: (typeof window !== "undefined" && window.location?.search) || "",
push: false,
pop: false,
categoryPath: "",
}
initLoc.categoryPath = initLoc.path.replace(/\/\d{4,99}[^\/]+$/, "")
export const location = writable(initLoc)

16
svelte.config.js Normal file
View File

@ -0,0 +1,16 @@
const { typescript } = require("svelte-preprocess-esbuild")
const sveltePreprocess = require("svelte-preprocess")
module.exports = {
preprocess: [
typescript({
sourcemap: true,
}),
sveltePreprocess({
sourceMap: true,
typescript: false,
/* scss: {
includePaths: ["src/theme"],
}, */
}),
],
}

20
tsconfig.json Normal file
View File

@ -0,0 +1,20 @@
{
"extends": "@tsconfig/svelte/tsconfig.json",
"include": ["src/**/*", "types/**/*"],
"compilerOptions": {
"module": "esnext",
"typeRoots": ["./node_modules/@types", "./types"],
"target": "esnext",
"moduleResolution": "node",
"jsx": "preserve",
"noEmit": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"useDefineForClassFields": true,
"allowSyntheticDefaultImports": true,
"importsNotUsedAsValues": "error"
}
}

16
types/global.d.ts vendored Normal file
View File

@ -0,0 +1,16 @@
interface ContentBlock {
layout: 1 | 2 | 3 | 4
title?: string
subtitle?: string
text?: string
button_text?: string
button_url?: string
images?: ImageEntry[]
}
interface Content {
id: string
path: string
blocks: ContentBlock[]
}

3651
yarn.lock Normal file

File diff suppressed because it is too large Load Diff