release (migration): migration of admin interface
4
.gitignore
vendored
@ -18,3 +18,7 @@ package-lock.json
|
||||
*_test.go
|
||||
cover.*
|
||||
www
|
||||
*.test.js
|
||||
__snapshots__
|
||||
.gitignore
|
||||
filestash-enterprise
|
||||
2
Makefile
@ -10,7 +10,7 @@ build_frontend:
|
||||
NODE_ENV=production npm run build
|
||||
|
||||
build_backend:
|
||||
CGO_ENABLED=0 go build -ldflags="-extldflags=-static" -mod=vendor --tags "fts5" -o dist/filestash main.go
|
||||
CGO_ENABLED=0 go build -ldflags="-extldflags=-static" -mod=vendor --tags "fts5" -o dist/filestash cmd/main.go
|
||||
|
||||
clean_frontend:
|
||||
rm -rf server/ctrl/static/www/
|
||||
|
||||
@ -154,7 +154,6 @@ function AuditComponent() {
|
||||
});
|
||||
return () => ctrl.abort();
|
||||
}, [debouncedSearchParams]);
|
||||
|
||||
return (
|
||||
<div className="component_audit">
|
||||
{
|
||||
|
||||
@ -1,21 +1,18 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
|
||||
"github.com/mickael-kerjean/filestash"
|
||||
. "github.com/mickael-kerjean/filestash/server"
|
||||
. "github.com/mickael-kerjean/filestash/server/common"
|
||||
. "github.com/mickael-kerjean/filestash/server/ctrl"
|
||||
_ "github.com/mickael-kerjean/filestash/server/plugin"
|
||||
)
|
||||
|
||||
//go:embed server/plugin/index.go
|
||||
var EmbedPluginList []byte
|
||||
|
||||
func main() {
|
||||
start(Build(App{}))
|
||||
}
|
||||
@ -39,7 +36,7 @@ func start(routes *mux.Router) {
|
||||
}()
|
||||
}
|
||||
go func() {
|
||||
InitPluginList(EmbedPluginList)
|
||||
InitPluginList(embed.EmbedPluginList)
|
||||
for _, fn := range Hooks.Get.Onload() {
|
||||
go fn()
|
||||
}
|
||||
24
embed.go
Normal file
@ -0,0 +1,24 @@
|
||||
package embed
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
)
|
||||
|
||||
var (
|
||||
//go:embed public
|
||||
wwwPublic embed.FS
|
||||
WWWPublic http.FileSystem = http.FS(os.DirFS("./public/"))
|
||||
)
|
||||
|
||||
//go:embed server/plugin/index.go
|
||||
var EmbedPluginList []byte
|
||||
|
||||
func init() {
|
||||
if os.Getenv("DEBUG") != "true" {
|
||||
fsPublic, _ := fs.Sub(wwwPublic, "public")
|
||||
WWWPublic = http.FS(fsPublic)
|
||||
}
|
||||
}
|
||||
26
public/assets/css/designsystem_alert.css
Normal file
@ -0,0 +1,26 @@
|
||||
.alert {
|
||||
background: var(--bg-color);
|
||||
border-radius: 5px;
|
||||
padding: 20px;
|
||||
margin-top: 20px;
|
||||
margin-bottom: 20px;
|
||||
border: 1px solid rgba(0,0,0,0.05);
|
||||
}
|
||||
.alert ol, .alert ul {
|
||||
margin: 5px 0;
|
||||
padding: 0 20px;
|
||||
}
|
||||
.alert.success{
|
||||
background: var(--success);
|
||||
}
|
||||
.alert.error{
|
||||
background: var(--error);
|
||||
color: var(--bg-color);
|
||||
}
|
||||
.alert img{
|
||||
max-width: 100%;
|
||||
border-radius: 5px;
|
||||
border: 10px solid white;
|
||||
box-sizing: border-box;
|
||||
margin-top: 5px;
|
||||
}
|
||||
@ -18,10 +18,6 @@
|
||||
font-size: 1em;
|
||||
padding: 0 15px;
|
||||
}
|
||||
.formbuilder img {
|
||||
max-height: 110px;
|
||||
border: 8px solid rgba(0, 0, 0, 0);
|
||||
}
|
||||
.formbuilder .fileupload-image img {
|
||||
height: 150px;
|
||||
width: 100%;
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
.component_skeleton {
|
||||
width: 100%;
|
||||
height: 30px;
|
||||
background: linear-gradient(110deg, rgba(0,0,0,0.02) 8%, rgba(0,0,0,0.04) 18%, rgba(0,0,0,0.02) 33%);
|
||||
border-radius: 5px;
|
||||
|
||||
@ -10,40 +10,41 @@
|
||||
@import url("./designsystem_darkmode.css");
|
||||
@import url("./designsystem_skeleton.css");
|
||||
@import url("./designsystem_utils.css");
|
||||
@import url("./designsystem_alert.css");
|
||||
|
||||
/* latin-ext */
|
||||
@font-face {
|
||||
font-family: 'Source Code Pro';
|
||||
font-family: "Source Code Pro";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: local('Source Code Pro'), local('SourceCodePro-Regular'), url(/assets/fonts/SourceCodePro-Regular-400-latin-ext.woff2) format('woff2');
|
||||
src: local("Source Code Pro"), local("SourceCodePro-Regular"), url(/assets/fonts/SourceCodePro-Regular-400-latin-ext.woff2) format("woff2");
|
||||
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
|
||||
/* latin */
|
||||
@font-face {
|
||||
font-family: 'Source Code Pro';
|
||||
font-family: "Source Code Pro";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: local('Source Code Pro'), local('SourceCodePro-Regular'), url(/assets/fonts/SourceCodePro-Regular-400-latin.woff2) format('woff2');
|
||||
src: local("Source Code Pro"), local("SourceCodePro-Regular"), url(/assets/fonts/SourceCodePro-Regular-400-latin.woff2) format("woff2");
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
/* latin-ext */
|
||||
@font-face {
|
||||
font-family: 'Source Code Pro';
|
||||
font-family: "Source Code Pro";
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
src: local('Source Code Pro Semibold'), local('SourceCodePro-Semibold'), url(/assets/fonts/SourceCodePro-Semibold-600-latin-ext.woff2) format('woff2');
|
||||
src: local("Source Code Pro Semibold"), local("SourceCodePro-Semibold"), url(/assets/fonts/SourceCodePro-Semibold-600-latin-ext.woff2) format("woff2");
|
||||
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
|
||||
/* latin */
|
||||
@font-face {
|
||||
font-family: 'Source Code Pro';
|
||||
font-family: "Source Code Pro";
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
src: local('Source Code Pro Semibold'), local('SourceCodePro-Semibold'), url(/assets/fonts/SourceCodePro-Semibold-600-latin.woff2) format('woff2');
|
||||
src: local("Source Code Pro Semibold"), local("SourceCodePro-Semibold"), url(/assets/fonts/SourceCodePro-Semibold-600-latin.woff2) format("woff2");
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
|
||||
BIN
public/assets/logo/android-chrome-192x192.png
Normal file
|
After Width: | Height: | Size: 6.5 KiB |
BIN
public/assets/logo/android-chrome-512x512.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
public/assets/logo/app_icon.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
public/assets/logo/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
public/assets/logo/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 395 B |
BIN
public/assets/logo/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 604 B |
BIN
public/assets/logo/favicon.ico
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
public/assets/logo/mstile-150x150.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
public/assets/logo/og-image.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
36
public/assets/logo/safari-pinned-tab.svg
Normal file
@ -0,0 +1,36 @@
|
||||
<?xml version="1.0" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
|
||||
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
||||
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
|
||||
width="512.000000pt" height="512.000000pt" viewBox="0 0 512.000000 512.000000"
|
||||
preserveAspectRatio="xMidYMid meet">
|
||||
<metadata>
|
||||
Created by potrace 1.11, written by Peter Selinger 2001-2013
|
||||
</metadata>
|
||||
<g transform="translate(0.000000,512.000000) scale(0.100000,-0.100000)"
|
||||
fill="#000000" stroke="none">
|
||||
<path d="M2418 4475 c-2 -1 -28 -5 -58 -8 -255 -28 -553 -171 -763 -367 -202
|
||||
-190 -366 -483 -411 -735 l-11 -60 -50 -13 c-28 -6 -63 -15 -79 -17 -16 -3
|
||||
-72 -22 -125 -41 -466 -173 -794 -561 -897 -1059 -24 -116 -24 -380 0 -506 97
|
||||
-511 448 -926 916 -1082 191 -64 106 -61 1520 -59 1187 2 1400 4 1476 18 260
|
||||
47 432 120 631 266 173 128 326 315 419 512 100 213 134 378 129 626 -4 172
|
||||
-5 182 -42 325 -84 327 -298 627 -578 812 -151 99 -368 187 -517 208 l-46 7
|
||||
-17 72 c-32 137 -54 198 -122 336 -68 139 -136 234 -252 351 -164 164 -311
|
||||
260 -523 337 -65 24 -191 57 -258 67 -45 7 -336 16 -342 10z m302 -480 c62
|
||||
-11 209 -64 268 -96 211 -116 369 -306 442 -533 12 -37 21 -69 19 -71 -2 -1
|
||||
-51 -17 -109 -35 -138 -42 -344 -144 -450 -224 -94 -71 -193 -164 -255 -241
|
||||
-64 -79 -69 -85 -76 -85 -3 0 -20 20 -38 45 -39 55 -177 196 -246 252 -162
|
||||
131 -351 220 -612 289 -27 7 46 190 121 303 137 205 361 352 606 397 73 13
|
||||
253 12 330 -1z m-1214 -1159 c153 -15 309 -78 440 -177 89 -66 122 -99 188
|
||||
-184 186 -241 231 -467 156 -790 -105 -448 -430 -685 -945 -689 -148 -1 -236
|
||||
19 -360 81 -195 97 -344 257 -439 471 -15 34 -29 70 -31 80 -1 9 -8 36 -14 59
|
||||
-20 75 -26 126 -25 238 1 119 8 177 30 240 8 22 14 46 14 52 0 23 61 146 107
|
||||
217 137 213 399 378 638 403 28 3 51 6 52 7 3 2 141 -3 189 -8z m2349 4 c69
|
||||
-3 246 -65 330 -115 32 -19 59 -35 61 -35 23 0 192 -171 239 -241 111 -166
|
||||
158 -318 159 -514 1 -148 -5 -193 -39 -300 -24 -77 -92 -214 -120 -241 -8 -9
|
||||
-15 -19 -15 -23 0 -17 -157 -168 -213 -204 -89 -59 -189 -105 -290 -134 -91
|
||||
-25 -97 -26 -477 -29 -830 -7 -999 -8 -1005 -3 -2 3 15 32 39 65 105 141 212
|
||||
396 241 574 3 19 7 42 10 50 8 32 17 140 20 246 4 135 10 176 36 256 88 277
|
||||
336 518 623 607 72 22 90 26 171 37 28 3 51 7 52 8 1 1 117 -2 178 -4z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
@ -1,5 +1,5 @@
|
||||
import rxjs, { ajax } from "../lib/rx.js";
|
||||
import { loadScript } from "../helpers/loader.js";
|
||||
import { loadScript, init as initCSS } from "../helpers/loader.js";
|
||||
import { report } from "../helpers/log.js";
|
||||
import { $error } from "./common.js";
|
||||
|
||||
@ -9,6 +9,7 @@ export default async function main() {
|
||||
setup_device(),
|
||||
setup_blue_death_screen(),
|
||||
setup_history(),
|
||||
setup_css(),
|
||||
]);
|
||||
window.dispatchEvent(new window.Event("pagechange"));
|
||||
} catch (err) {
|
||||
@ -42,3 +43,7 @@ async function setup_blue_death_screen() {
|
||||
async function setup_history() {
|
||||
window.history.replaceState({}, "");
|
||||
}
|
||||
|
||||
async function setup_css() {
|
||||
return initCSS()
|
||||
}
|
||||
|
||||
13
public/boot/router_backoffice.js
Normal file
@ -0,0 +1,13 @@
|
||||
const routes = {
|
||||
"/admin/backend": "/pages/adminpage/ctrl_backend.js",
|
||||
"/admin/settings": "/pages/adminpage/ctrl_settings.js",
|
||||
"/admin/logs": "/pages/adminpage/ctrl_log.js",
|
||||
"/admin/about": "/pages/adminpage/ctrl_about.js",
|
||||
"/admin/setup": "/pages/adminpage/ctrl_setup.js",
|
||||
"/admin/": "/pages/ctrl_adminpage.js",
|
||||
"/admin": "/pages/ctrl_adminpage.js",
|
||||
"/logout": "/pages/ctrl_logout.js",
|
||||
"": "/pages/ctrl_notfound.js",
|
||||
};
|
||||
|
||||
export default routes;
|
||||
21
public/boot/router_frontoffice.js
Normal file
@ -0,0 +1,21 @@
|
||||
const routes = {
|
||||
"/login": "/pages/ctrl_connectpage.js",
|
||||
"/logout": "/pages/ctrl_logout.js",
|
||||
|
||||
"/": "/pages/ctrl_homepage.js",
|
||||
"/files/.*": "/pages/ctrl_filespage.js",
|
||||
"/view/.*": "/pages/ctrl_viewerpage.js",
|
||||
// /tags/.* -> "pages/ctrl_tags.js",
|
||||
// /s/.* -> "/pages/ctrl_share.js",
|
||||
|
||||
"/admin/backend": "/pages/adminpage/ctrl_backend.js",
|
||||
"/admin/settings": "/pages/adminpage/ctrl_settings.js",
|
||||
"/admin/logs": "/pages/adminpage/ctrl_logger.js",
|
||||
"/admin/about": "/pages/adminpage/ctrl_about.js",
|
||||
"/admin/setup": "/pages/adminpage/ctrl_setup.js",
|
||||
"/admin/": "/pages/ctrl_adminpage.js",
|
||||
|
||||
"": "/pages/ctrl_notfound.js",
|
||||
};
|
||||
|
||||
export default routes;
|
||||
@ -57,34 +57,60 @@ function $renderInput(options = {}) {
|
||||
} = props;
|
||||
|
||||
let attr = `name="${path.join(".")}" `;
|
||||
if (id) attr += `id="${id}" `;
|
||||
if (placeholder) attr += `placeholder="${safe(placeholder, "\"")}" `;
|
||||
if (!autocomplete) attr += "autocomplete=\"off\" autocorrect=\"off\" autocapitalize=\"off\" spellcheck=\"off\" ";
|
||||
if (id) attr += `id="${safe(id)}" `;
|
||||
if (placeholder) attr += `placeholder="${safe(placeholder)}" `;
|
||||
if (!autocomplete || props.autocomplete === false) attr += "autocomplete=\"off\" autocorrect=\"off\" autocapitalize=\"off\" spellcheck=\"off\" ";
|
||||
if (required) attr += "required ";
|
||||
if (readonly) attr += "readonly ";
|
||||
|
||||
switch (type) {
|
||||
case "text": // TODO
|
||||
const dataListId = gid("list_");
|
||||
const $input = createElement(`
|
||||
<input ${safe(attr)}
|
||||
case "text":
|
||||
if (!datalist) return createElement(`
|
||||
<input ${attr}
|
||||
type="text"
|
||||
value="${safe(value, "\"") || ""}"
|
||||
value="${safe(value)}"
|
||||
class="component_input"
|
||||
/>
|
||||
`);
|
||||
if (!datalist) return $input;
|
||||
const $wrapper = window.document.createElement("span");
|
||||
const $datalist = window.document.createElement("datalist");
|
||||
$wrapper.appendChild($input);
|
||||
const dataListId = gid("list_");
|
||||
const $input = createElement(`
|
||||
<input ${attr}
|
||||
list="${dataListId}"
|
||||
datalist="${datalist.join(",")}"
|
||||
type="text"
|
||||
value="${safe(value)}"
|
||||
class="component_input"
|
||||
/>
|
||||
`);
|
||||
const $wrapper = document.createElement("span");
|
||||
const $datalist = document.createElement("datalist");
|
||||
$datalist.setAttribute("id", dataListId);
|
||||
$wrapper.appendChild($input);
|
||||
$wrapper.appendChild($datalist);
|
||||
(props.multi ? multicomplete(value, datalist) : (datalist || [])).forEach((value) => {
|
||||
$datalist.appendChild(createElement(`<option value="${value}"/>`))
|
||||
});
|
||||
if (!props.multi) return $wrapper;
|
||||
$input.refresh = () => {
|
||||
const _datalist = $input.getAttribute("datalist").split(",");
|
||||
multicomplete($input.value, _datalist).forEach((value) => {
|
||||
$datalist.appendChild(createElement(`<option value="${value}"/>`));
|
||||
});
|
||||
};
|
||||
$input.oninput = (e) => {
|
||||
for (const $option of $datalist.children) {
|
||||
$option.remove();
|
||||
}
|
||||
$input.refresh();
|
||||
};
|
||||
return $wrapper;
|
||||
case "enable":
|
||||
return createElement(`
|
||||
<div class="component_checkbox">
|
||||
<input
|
||||
${attr}
|
||||
type="checkbox"
|
||||
${(value || props.default) ? "checked" : ""}
|
||||
${(value === null ? props.default : value) ? "checked" : ""}
|
||||
/>
|
||||
<span className="indicator"></span>
|
||||
</div>
|
||||
@ -92,19 +118,18 @@ function $renderInput(options = {}) {
|
||||
case "number":
|
||||
return createElement(`
|
||||
<input
|
||||
${safe(attr)}
|
||||
${attr}
|
||||
type="number"
|
||||
value="${safe(value, "\"") || ""}"
|
||||
value="${safe(value)}"
|
||||
class="component_input"
|
||||
/>
|
||||
`);
|
||||
case "password":
|
||||
// TODO: click eye
|
||||
const $node = createElement(`
|
||||
<div class="formbuilder_password">
|
||||
<input
|
||||
${safe(attr)}
|
||||
value="${safe(value, "\"") || ""}"
|
||||
${attr}
|
||||
value="${safe(value)}"
|
||||
type="password"
|
||||
class="component_input"
|
||||
/>
|
||||
@ -124,21 +149,21 @@ function $renderInput(options = {}) {
|
||||
case "long_password":
|
||||
// TODO
|
||||
case "long_text":
|
||||
return createElement(`
|
||||
<textarea ${safe(attr)} class="component_textarea" rows="8">
|
||||
</textarea>
|
||||
const $textarea = createElement(`
|
||||
<textarea ${attr} class="component_textarea" rows="8" placeholder="${safe(props.default)}"></textarea>
|
||||
`);
|
||||
if (value) $textarea.value = value;
|
||||
return $textarea;
|
||||
case "bcrypt":
|
||||
return createElement(`
|
||||
<input
|
||||
type="password"
|
||||
${safe(attr)}
|
||||
value="${safe(value, "\"") || ""}"
|
||||
${attr}
|
||||
value="${safe(value)}"
|
||||
readonly
|
||||
class="component_input"
|
||||
/>
|
||||
`);
|
||||
// TODO
|
||||
case "hidden":
|
||||
return createElement(`
|
||||
<input
|
||||
@ -151,24 +176,40 @@ function $renderInput(options = {}) {
|
||||
return createElement(`
|
||||
<div class="component_checkbox">
|
||||
<input
|
||||
${safe(attr)}
|
||||
${attr}
|
||||
type="checkbox"
|
||||
${(value || props.default) ? "checked" : ""}
|
||||
${(value === null ? props.default : value) ? "checked" : ""}
|
||||
/>
|
||||
<span class="indicator"></span>
|
||||
</div>
|
||||
`);
|
||||
case "select":
|
||||
const renderOption = (name) => `<option name="${safe(name)}">${safe(name)}</option>`;
|
||||
const renderOption = (name) => {
|
||||
const optName = safe(name);
|
||||
const formVal = safe(value || props.default);
|
||||
return `
|
||||
<option
|
||||
name="${optName}"
|
||||
${(optName === formVal) && "selected"}
|
||||
>
|
||||
${optName}
|
||||
</option>
|
||||
`;
|
||||
}
|
||||
return createElement(`
|
||||
<select class="component_select" ${safe(attr)}>
|
||||
<select
|
||||
${attr}
|
||||
value="${safe(value || props.default)}"
|
||||
class="component_select"
|
||||
>
|
||||
${(options || []).map(renderOption)}
|
||||
</select>
|
||||
`);
|
||||
case "date":
|
||||
return createElement(`
|
||||
<input
|
||||
${safe(attr)}
|
||||
${attr}
|
||||
value="${safe(value || props.default)}"
|
||||
type="date"
|
||||
class="component_input"
|
||||
/>
|
||||
@ -176,7 +217,8 @@ function $renderInput(options = {}) {
|
||||
case "datetime":
|
||||
return createElement(`
|
||||
<input
|
||||
${safe(attr)}
|
||||
${attr}
|
||||
value="${safe(value || props.default)}"
|
||||
type="datetime-local"
|
||||
class="component_input"
|
||||
/>
|
||||
@ -191,7 +233,7 @@ function $renderInput(options = {}) {
|
||||
value="unknown element type ${type}"
|
||||
type="text"
|
||||
class="component_input"
|
||||
path="${safe(path.join("."))}"
|
||||
name="${safe(path.join("."))}"
|
||||
readonly
|
||||
/>
|
||||
`);
|
||||
@ -213,3 +255,10 @@ export function format(name) {
|
||||
})
|
||||
.join(" ");
|
||||
};
|
||||
|
||||
export function multicomplete(input, datalist) {
|
||||
input = input.trim().replace(/,$/g, "");
|
||||
const current = input.split(",").map((val) => val.trim()).filter((t) => !!t);
|
||||
const diff = datalist.filter((x) => current.indexOf(x) === -1);
|
||||
return diff.map((candidate) => input.length === 0 ? candidate : `${input}, ${candidate}`);
|
||||
}
|
||||
|
||||
@ -6,7 +6,9 @@ class Icon extends window.HTMLElement {
|
||||
attributeChangedCallback() {
|
||||
const alt = this.getAttribute("name");
|
||||
const img = this._mapOfIcon(alt);
|
||||
requestAnimationFrame(() => {
|
||||
this.innerHTML = this.render({ alt, img });
|
||||
});
|
||||
}
|
||||
|
||||
render({ alt, img }) {
|
||||
|
||||
@ -42,7 +42,7 @@ class Loader extends window.HTMLElement {
|
||||
}
|
||||
}
|
||||
|
||||
window.customElements.define("component-loader", Loader);
|
||||
customElements.define("component-loader", Loader);
|
||||
|
||||
export default createElement("<component-loader></component-loader>");
|
||||
export function toggle($node, show = false) {
|
||||
|
||||
@ -1,23 +1,17 @@
|
||||
import { createElement } from "../lib/skeleton/index.js";
|
||||
import { createElement, nop } from "../lib/skeleton/index.js";
|
||||
import rxjs, { applyMutation } from "../lib/rx.js";
|
||||
import { animate } from "../lib/animate.js";
|
||||
import { qs } from "../lib/dom.js";
|
||||
import { qs, qsa } from "../lib/dom.js";
|
||||
|
||||
import { CSS } from "../helpers/loader.js";
|
||||
|
||||
let _observables = [];
|
||||
const effect = (obs) => _observables.push(obs.subscribe());
|
||||
const free = () => {
|
||||
for (let i = 0; i < _observables.length; i++) {
|
||||
_observables[i].unsubscribe();
|
||||
export default class Modal {
|
||||
static open($node, opts = {}) {
|
||||
find().trigger($node, opts);
|
||||
}
|
||||
}
|
||||
_observables = [];
|
||||
};
|
||||
|
||||
export default class Modal extends HTMLElement {
|
||||
async trigger($node, opts = {}) {
|
||||
const { onQuit } = opts;
|
||||
const $modal = createElement(`
|
||||
const createModal = async () => createElement(`
|
||||
<div class="component_modal" id="modal-box">
|
||||
<style>${await CSS(import.meta.url, "modal.css")}</style>
|
||||
<div>
|
||||
@ -26,28 +20,44 @@ export default class Modal extends HTMLElement {
|
||||
<div class="modal-message" data-bind="body"><!-- MODAL BODY --></div>
|
||||
</div>
|
||||
<div class="buttons">
|
||||
<button type="submit" class="emphasis">OK</button>
|
||||
<button type="button"></button>
|
||||
<button type="submit" class="emphasis"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);
|
||||
this.replaceChildren($modal);
|
||||
</div>
|
||||
`);
|
||||
|
||||
// feature: setup the modal body
|
||||
effect(rxjs.of([$node]).pipe(
|
||||
applyMutation(qs($modal, "[data-bind=\"body\"]"), "appendChild")
|
||||
class ModalComponent extends window.HTMLElement {
|
||||
async trigger($node, opts = {}) {
|
||||
const $modal = await createModal();
|
||||
const close$ = new rxjs.Subject();
|
||||
const { onQuit = nop, withButtonsLeft = null, withButtonsRight = null } = opts;
|
||||
|
||||
// feature: build the dom
|
||||
qs($modal, `[data-bind="body"]`).replaceChildren($node);
|
||||
this.replaceChildren($modal);
|
||||
qsa($modal, `.component_popup > div.buttons > button`).forEach(($button, i) => {
|
||||
let currentLabel = null;
|
||||
if (i === 0) currentLabel = withButtonsLeft;
|
||||
else if (i === 1) currentLabel = withButtonsRight;
|
||||
|
||||
if (currentLabel === null) return $button.remove();
|
||||
$button.textContent = currentLabel;
|
||||
$button.onclick = () => close$.next(currentLabel);
|
||||
});
|
||||
effect(rxjs.fromEvent($modal, "click").pipe(
|
||||
rxjs.filter((e) => e.target.getAttribute("id") === "modal-box"),
|
||||
rxjs.tap(() => close$.next()),
|
||||
));
|
||||
effect(rxjs.fromEvent(window, "keydown").pipe(
|
||||
rxjs.filter((e) => e.keyCode === 27),
|
||||
rxjs.tap(() => close$.next()),
|
||||
));
|
||||
|
||||
// feature: closing the modal
|
||||
effect(rxjs.merge(
|
||||
rxjs.fromEvent($modal, "click").pipe(
|
||||
rxjs.filter((e) => e.target.getAttribute("id") === "modal-box")
|
||||
),
|
||||
rxjs.fromEvent(window, "keydown").pipe(
|
||||
rxjs.filter((e) => e.keyCode === 27)
|
||||
)
|
||||
).pipe(
|
||||
rxjs.tap(() => typeof onQuit === "function" && onQuit()),
|
||||
effect(close$.pipe(
|
||||
rxjs.tap((label) => onQuit(label)),
|
||||
rxjs.tap(() => animate(qs($modal, "div > div"), {
|
||||
time: 200,
|
||||
keyframes: [
|
||||
@ -91,7 +101,7 @@ export default class Modal extends HTMLElement {
|
||||
rxjs.map(() => {
|
||||
let size = 300;
|
||||
const $box = document.querySelector("#modal-box > div");
|
||||
if ($box instanceof HTMLElement) size = $box.offsetHeight;
|
||||
if ($box instanceof window.HTMLElement) size = $box.offsetHeight;
|
||||
|
||||
size = Math.round((document.body.offsetHeight - size) / 2);
|
||||
if (size < 0) return 0;
|
||||
@ -104,4 +114,19 @@ export default class Modal extends HTMLElement {
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("component-modal", Modal);
|
||||
customElements.define("component-modal", ModalComponent);
|
||||
|
||||
let _observables = [];
|
||||
const effect = (obs) => _observables.push(obs.subscribe());
|
||||
const free = () => {
|
||||
for (let i = 0; i < _observables.length; i++) {
|
||||
_observables[i].unsubscribe();
|
||||
}
|
||||
_observables = [];
|
||||
};
|
||||
|
||||
function find() {
|
||||
const $dom = document.body.querySelector("component-modal");
|
||||
if (!($dom instanceof ModalComponent)) throw new ApplicationError("INTERNAL_ERROR", "assumption failed: wrong type modal component");
|
||||
return $dom;
|
||||
}
|
||||
|
||||
59
public/components/notification.css
Normal file
@ -0,0 +1,59 @@
|
||||
.component_notification {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
left: 20px;
|
||||
right: 70px;
|
||||
font-size: 0.95em;
|
||||
z-index: 1001;
|
||||
}
|
||||
.component_notification .component_notification--container {
|
||||
overflow: hidden;
|
||||
width: 400px;
|
||||
text-align: left;
|
||||
display: inline-block;
|
||||
padding: 15px 20px 15px 15px;
|
||||
border-radius: 2px;
|
||||
box-shadow: rgba(158, 163, 172, 0.3) 5px 5px 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.component_notification .component_notification--container.info {
|
||||
background: var(--color);
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
.component_notification .component_notification--container.error {
|
||||
background: var(--error);
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.component_notification .component_notification--container.success {
|
||||
background: var(--success);
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.component_notification .component_notification--container .message {
|
||||
flex: 1 1 auto;
|
||||
max-height: 92px;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
.component_notification .component_notification--container .close {
|
||||
cursor: pointer;
|
||||
padding: 0 2px;
|
||||
}
|
||||
.component_notification .component_notification--container .close .component_icon {
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
@media (max-width: 490px) {
|
||||
.component_notification {
|
||||
bottom: 0px;
|
||||
left: 0px;
|
||||
}
|
||||
.component_notification .component_notification--container {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
|
||||
.component_notification .component_notification--container {
|
||||
box-shadow: rgba(0, 0, 0, 0.3) 5px 5px 20px;
|
||||
}
|
||||
83
public/components/notification.js
Normal file
@ -0,0 +1,83 @@
|
||||
import { createElement } from "../lib/skeleton/index.js";
|
||||
import { ApplicationError } from "../lib/error.js";
|
||||
import { animate, slideYIn, slideYOut } from "../lib/animate.js";
|
||||
import { CSS } from "../helpers/loader.js";
|
||||
|
||||
const createNotification = async (msg, type) => createElement(`
|
||||
<span class="component_notification">
|
||||
<style>${await CSS(import.meta.url, "notification.css")}</style>
|
||||
<div class="no-select">
|
||||
<div class="component_notification--container ${type}">
|
||||
<div class="message">${msg}</div>
|
||||
<div class="close">
|
||||
<img class="component_icon" draggable="false" src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA1MS45NzYgNTEuOTc2Ij4KICA8cGF0aCBzdHlsZT0iZmlsbDojMDAwMDAwO2ZpbGwtb3BhY2l0eTowLjUzMzMzMjg1O3N0cm9rZS13aWR0aDoxLjQ1NjgxMTE5IiBkPSJtIDQxLjAwNTMxLDQwLjg0NDA2MiBjIC0xLjEzNzc2OCwxLjEzNzc2NSAtMi45ODIwODgsMS4xMzc3NjUgLTQuMTE5ODYxLDAgTCAyNi4wNjg2MjgsMzAuMDI3MjM0IDE0LjczNzU1MSw0MS4zNTgzMSBjIC0xLjEzNzc3MSwxLjEzNzc3MSAtMi45ODIwOTMsMS4xMzc3NzEgLTQuMTE5ODYxLDAgLTEuMTM3NzcyMiwtMS4xMzc3NjggLTEuMTM3NzcyMiwtMi45ODIwODggMCwtNC4xMTk4NjEgTCAyMS45NDg3NjYsMjUuOTA3MzcyIDExLjEzMTkzOCwxNS4wOTA1NTEgYyAtMS4xMzc3NjQ3LC0xLjEzNzc3MSAtMS4xMzc3NjQ3LC0yLjk4MzU1MyAwLC00LjExOTg2MSAxLjEzNzc3NCwtMS4xMzc3NzIxIDIuOTgyMDk4LC0xLjEzNzc3MjEgNC4xMTk4NjUsMCBMIDI2LjA2ODYyOCwyMS43ODc1MTIgMzYuMzY5NzM5LDExLjQ4NjM5OSBjIDEuMTM3NzY4LC0xLjEzNzc2OCAyLjk4MjA5MywtMS4xMzc3NjggNC4xMTk4NjIsMCAxLjEzNzc2NywxLjEzNzc2OSAxLjEzNzc2NywyLjk4MjA5NCAwLDQuMTE5ODYyIEwgMzAuMTg4NDg5LDI1LjkwNzM3MiA0MS4wMDUzMSwzNi43MjQxOTcgYyAxLjEzNzc3MSwxLjEzNzc2NyAxLjEzNzc3MSwyLjk4MjA5MSAwLDQuMTE5ODY1IHoiIC8+Cjwvc3ZnPgo=" alt="close">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
`);
|
||||
|
||||
class NotificationComponent extends window.HTMLElement {
|
||||
buffer = [];
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
async trigger(message, type) {
|
||||
if (this.buffer.length > 20) this.buffer.pop(); // failsafe
|
||||
this.buffer.push({ message, type });
|
||||
if (this.buffer.length !== 1) {
|
||||
const $close = this.querySelector(".close");
|
||||
if ($close && typeof $close.onclick === "function") $close.onclick();
|
||||
return;
|
||||
}
|
||||
await this.run();
|
||||
}
|
||||
|
||||
async run() {
|
||||
if (this.buffer.length === 0) return;
|
||||
const { message, type } = this.buffer[0];
|
||||
const $notification = await createNotification(message, type);
|
||||
this.replaceChildren($notification);
|
||||
await animate($notification, {
|
||||
keyframes: slideYIn(50),
|
||||
time: 100,
|
||||
});
|
||||
const ids = []
|
||||
await Promise.race([
|
||||
new Promise((done) => ids.push(window.setTimeout(done, this.buffer.length === 1 ? 8000 : 800))),
|
||||
new Promise((done) => ids.push(window.setTimeout(() => $notification.querySelector(".close").onclick = done, 1000))),
|
||||
]);
|
||||
ids.forEach((id) => window.clearTimeout(id));
|
||||
await animate($notification, {
|
||||
keyframes: slideYOut(10),
|
||||
time: 200,
|
||||
});
|
||||
$notification.remove();
|
||||
this.buffer.shift();
|
||||
await this.run();
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("component-notification", NotificationComponent);
|
||||
|
||||
function find() {
|
||||
const $dom = document.body.querySelector("component-notification");
|
||||
if (!($dom instanceof NotificationComponent)) throw new ApplicationError("INTERNAL_ERROR", "assumption failed: wrong type notification component");
|
||||
return $dom;
|
||||
}
|
||||
|
||||
export default class Notification {
|
||||
static info(msg) {
|
||||
find().trigger(msg, "info");
|
||||
}
|
||||
|
||||
static success(msg) {
|
||||
find().trigger(msg, "success");
|
||||
}
|
||||
|
||||
static error(msg) {
|
||||
find().trigger(msg, "error");
|
||||
}
|
||||
}
|
||||
@ -1,3 +1,7 @@
|
||||
import { get as getRelease } from "../pages/adminpage/model_release.js";
|
||||
|
||||
let version = null;
|
||||
|
||||
export async function loadScript(url) {
|
||||
const $script = document.createElement("script");
|
||||
$script.setAttribute("src", url);
|
||||
@ -14,10 +18,15 @@ export async function CSS(baseURL, ...arrayOfFilenames) {
|
||||
}
|
||||
|
||||
async function loadSingleCSS(baseURL, filename) {
|
||||
const res = await fetch(baseURL.replace(/(.*)\/[^\/]+$/, "$1/") + filename + "?version=" + "__", {
|
||||
cache: "force-cache"
|
||||
const res = await fetch(baseURL.replace(/(.*)\/[^\/]+$/, "$1/") + `${filename}?version=${version}`, {
|
||||
cache: "force-cache",
|
||||
});
|
||||
if (res.status !== 200) return `/* ERROR: ${res.status} */`;
|
||||
else if (!res.headers.get("Content-Type").startsWith("text/css")) return `/* ERROR: wrong type, got "${res.headers.get("Content-Type")}"*/`;
|
||||
return await res.text();
|
||||
}
|
||||
|
||||
export async function init() {
|
||||
const info = await getRelease().toPromise();
|
||||
version = info.version;
|
||||
}
|
||||
|
||||
@ -1,16 +0,0 @@
|
||||
import Modal from "../components/modal.js";
|
||||
|
||||
// prompt, alert, confirm, modal, popup?
|
||||
class ModalManager {
|
||||
constructor() {
|
||||
this.$dom = document.body.querySelector("component-modal");
|
||||
}
|
||||
|
||||
alert($node, opts) {
|
||||
if (this.$dom instanceof Modal) {
|
||||
this.$dom.trigger($node, opts);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new ModalManager();
|
||||
36
public/index.backoffice.html
Normal file
@ -0,0 +1,36 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="stylesheet" href="/admin/assets/css/reset.css">
|
||||
<script type="module" src="/admin/components/loader.js"></script>
|
||||
<title>Admin Console</title>
|
||||
</head>
|
||||
<body>
|
||||
<div role="main" id="app">
|
||||
<component-loader delay="500"></component-loader>
|
||||
</div>
|
||||
<script type="module">
|
||||
import main from "/admin/lib/skeleton/index.js";
|
||||
import routes from "/admin/boot/router_backoffice.js";
|
||||
main(document.getElementById("app"), routes, {
|
||||
spinner: `<component-loader></component-loader>`,
|
||||
beforeStart: import("/admin/boot/ctrl_boot_backoffice.js"),
|
||||
});
|
||||
</script>
|
||||
|
||||
<script type="module" src="/admin/components/modal.js"></script>
|
||||
<component-modal></component-modal>
|
||||
<script type="module" src="/admin/components/notification.js"></script>
|
||||
<component-notification></component-notification>
|
||||
|
||||
<noscript>
|
||||
<div>
|
||||
<h2>Error: Javascript is off</h2>
|
||||
<p>You need to enable Javascript to run this application</p>
|
||||
</div>
|
||||
</noscript>
|
||||
</body>
|
||||
</html>
|
||||
@ -14,28 +14,10 @@
|
||||
</div>
|
||||
<script type="module">
|
||||
import main from "/lib/skeleton/index.js";
|
||||
const routes = {
|
||||
"/login": "/pages/ctrl_connectpage.js",
|
||||
"/logout": "/pages/ctrl_logout.js",
|
||||
|
||||
"/": "/pages/ctrl_homepage.js",
|
||||
"/files/.*": "/pages/ctrl_filespage.js",
|
||||
"/view/.*": "/pages/ctrl_viewerpage.js",
|
||||
// /tags/.* -> "pages/ctrl_tags.js",
|
||||
// /s/.* -> "/pages/ctrl_share.js",
|
||||
|
||||
"/admin/backend": "/pages/adminpage/ctrl_backend.js",
|
||||
"/admin/settings": "/pages/adminpage/ctrl_settings.js",
|
||||
"/admin/logs": "/pages/adminpage/ctrl_logger.js",
|
||||
"/admin/about": "/pages/adminpage/ctrl_about.js",
|
||||
"/admin/setup": "/pages/adminpage/ctrl_setup.js",
|
||||
"/admin/": "/pages/ctrl_adminpage.js",
|
||||
|
||||
"": "/pages/ctrl_notfound.js",
|
||||
};
|
||||
import routes from "/boot/router_frontoffice.js";
|
||||
main(document.getElementById("app"), routes, {
|
||||
spinner: `<component-loader></component-loader>`,
|
||||
beforeStart: import("/pages/ctrl_boot.js"),
|
||||
beforeStart: import("/boot/ctrl_boot_frontoffice.js"),
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@ -14,15 +14,7 @@
|
||||
</div>
|
||||
<script type="module">
|
||||
import main from "/lib/skeleton/index.js";
|
||||
const routes = {
|
||||
"/admin/backend": "/pages/adminpage/ctrl_backend.js",
|
||||
"/admin/settings": "/pages/adminpage/ctrl_settings.js",
|
||||
"/admin/logs": "/pages/adminpage/ctrl_log.js",
|
||||
"/admin/about": "/pages/adminpage/ctrl_about.js",
|
||||
"/admin/setup": "/pages/adminpage/ctrl_setup.js",
|
||||
"/admin/": "/pages/ctrl_adminpage.js",
|
||||
"": "/pages/ctrl_notfound.js",
|
||||
};
|
||||
import routes from "/boot/router_backoffice.js";
|
||||
main(document.getElementById("app"), routes, {
|
||||
spinner: `<component-loader></component-loader>`,
|
||||
beforeStart: import("/boot/ctrl_boot_backoffice.js"),
|
||||
@ -31,6 +23,8 @@
|
||||
|
||||
<script type="module" src="/components/modal.js"></script>
|
||||
<component-modal></component-modal>
|
||||
<script type="module" src="/components/notification.js"></script>
|
||||
<component-notification></component-notification>
|
||||
|
||||
<noscript>
|
||||
<div>
|
||||
|
||||
@ -2,11 +2,11 @@ import rxjs, { ajax } from "./rx.js";
|
||||
import { AjaxError } from "./error.js";
|
||||
|
||||
export default function(opts) {
|
||||
if (typeof opts === "string") opts = { url: opts };
|
||||
if (typeof opts === "string") opts = { url: opts, withCredentials: true };
|
||||
else if (typeof opts !== "object") throw new Error("unsupported call");
|
||||
if (!opts.headers) opts.headers = {};
|
||||
opts.headers["X-Requested-With"] = "XmlHttpRequest";
|
||||
return ajax({ ...opts, responseType: "text" }).pipe(
|
||||
return ajax({ withCredentials: true, ...opts, responseType: "text" }).pipe(
|
||||
rxjs.catchError((err) => rxjs.throwError(processError(err.xhr, err))),
|
||||
rxjs.map((res) => {
|
||||
const result = res.xhr.responseText;
|
||||
@ -38,14 +38,14 @@ function processError(xhr, err) {
|
||||
};
|
||||
}
|
||||
return message || { message: "empty response" };
|
||||
})(xhr.responseText);
|
||||
})(xhr?.responseText || "");
|
||||
|
||||
const message = response.message || null;
|
||||
|
||||
if (window.navigator.onLine === false) {
|
||||
return new AjaxError("Connection Lost", err, "NO_INTERNET");
|
||||
}
|
||||
switch (xhr.status) {
|
||||
switch (xhr?.status) {
|
||||
case 500:
|
||||
return new AjaxError(
|
||||
message || "Oups something went wrong with our servers",
|
||||
@ -83,7 +83,7 @@ function processError(xhr, err) {
|
||||
err, "CONFLICT"
|
||||
);
|
||||
case 0:
|
||||
switch (xhr.responseText) {
|
||||
switch (xhr?.responseText) {
|
||||
case "":
|
||||
return new AjaxError(
|
||||
"Service unavailable, if the problem persist, contact your administrator",
|
||||
|
||||
@ -50,8 +50,8 @@ export const slideYIn = (size) => ([
|
||||
]);
|
||||
|
||||
export const slideYOut = (size) => ([
|
||||
{ opacity: 0, transform: "translateY(0px)" },
|
||||
{ opacity: 1, transform: `translateY(${size}px)` }
|
||||
{ opacity: 1, transform: "translateY(0px)" },
|
||||
{ opacity: 0, transform: `translateY(${size}px)` }
|
||||
]);
|
||||
|
||||
export const zoomIn = (size) => ([
|
||||
|
||||
@ -10,13 +10,14 @@ export function qsa($node, selector) {
|
||||
return $node.querySelectorAll(selector);
|
||||
}
|
||||
|
||||
export function safe(str, ...escapeChars) {
|
||||
if (typeof str !== "string") return str;
|
||||
export function safe(str) {
|
||||
if (typeof str !== "string") return "";
|
||||
|
||||
const $div = document.createElement("div");
|
||||
escapeChars.forEach((c) => {
|
||||
str = str.replaceAll(c, "\\" + c);
|
||||
});
|
||||
$div.textContent = str;
|
||||
return $div.innerHTML;
|
||||
return ($div.innerHTML || "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
@ -8,7 +8,8 @@ export function mutateForm(formSpec, formState) {
|
||||
|
||||
let ptr = formSpec;
|
||||
while (keys.length > 1) ptr = ptr[keys.shift()];
|
||||
ptr[keys.shift()].value = (value === "" ? null : value);
|
||||
const key = keys.shift();
|
||||
if (ptr && ptr[key]) ptr[key].value = (value === "" ? null : value);
|
||||
});
|
||||
return formSpec;
|
||||
}
|
||||
@ -43,7 +44,7 @@ async function createFormNodes(node, { renderNode, renderLeaf, renderInput, path
|
||||
else {
|
||||
const currentPath = path.concat(key);
|
||||
const $leaf = renderLeaf({ ...node[key], path: currentPath, label: key });
|
||||
const $input = await renderInput({ ...node[key], path: currentPath });
|
||||
const $input = await renderInput({ ...node[key], path: currentPath.filter((chunk) => !!chunk) });
|
||||
const $target = $leaf.querySelector("[data-bind=\"children\"]") || $leaf;
|
||||
|
||||
// leaf node is either "classic" or can be the target of something that can be toggled
|
||||
@ -67,8 +68,8 @@ async function createFormNodes(node, { renderNode, renderLeaf, renderInput, path
|
||||
else if (!node[k].id) continue;
|
||||
else if (node[key].target.indexOf(node[k].id) === -1) continue;
|
||||
|
||||
const $kleaf = renderLeaf({ ...node[k], path: currentPath, label: k });
|
||||
const $kinput = await renderInput({ ...node[k], path: currentPath });
|
||||
const $kleaf = renderLeaf({ ...node[k], path: path.concat(k), label: k });
|
||||
const $kinput = await renderInput({ ...node[k], path: path.concat(k) });
|
||||
const $ktarget = $kleaf.querySelector("[data-bind=\"children\"]") || $kleaf;
|
||||
$ktarget.removeAttribute("data-bind");
|
||||
$ktarget.appendChild($kinput);
|
||||
|
||||
@ -8,19 +8,26 @@ export default rxjs;
|
||||
export const ajax = ajaxModule.ajax;
|
||||
|
||||
export function effect(obs) {
|
||||
const tmp = obs.subscribe(() => {}, (err) => console.error("effect", err));
|
||||
const tmp = obs.subscribe(() => {}, (err) => { throw err; });
|
||||
onDestroy(() => tmp.unsubscribe());
|
||||
}
|
||||
|
||||
export function applyMutation($node, ...keys) {
|
||||
if (!$node) throw new Error("undefined node");
|
||||
const getFn = (obj, arg0, ...args) => {
|
||||
if (arg0 === undefined) return obj;
|
||||
const next = obj[arg0];
|
||||
return getFn(next.bind ? next.bind(obj) : next, ...args);
|
||||
};
|
||||
export function applyMutation($node, ...keys) {
|
||||
if (!$node) throw new Error("undefined node");
|
||||
const execute = getFn($node, ...keys);
|
||||
return rxjs.tap((val) => execute(...val));
|
||||
return rxjs.tap((val) => Array.isArray(val) ? execute(...val) : execute(val));
|
||||
}
|
||||
export function applyMutations($node, ...keys) {
|
||||
if (!$node) throw new Error("undefined node");
|
||||
const execute = getFn($node, ...keys);
|
||||
return rxjs.tap((vals) => vals.forEach((val) => {
|
||||
execute(val);
|
||||
}));
|
||||
}
|
||||
|
||||
export function stateMutation($node, attr) {
|
||||
@ -31,3 +38,10 @@ export function stateMutation($node, attr) {
|
||||
export function preventDefault() {
|
||||
return rxjs.tap((e) => e.preventDefault());
|
||||
}
|
||||
|
||||
export function onClick($node) {
|
||||
if (!$node) return rxjs.EMPTY;
|
||||
return rxjs.fromEvent($node, "click").pipe(
|
||||
rxjs.map(() => $node),
|
||||
);
|
||||
}
|
||||
|
||||
@ -67,3 +67,5 @@ export function createRender($parent) {
|
||||
else throw new Error(`Unknown view type: ${typeof $view}`);
|
||||
};
|
||||
}
|
||||
|
||||
export function nop() {}
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
import main, { createElement, onDestroy } from "./index.js";
|
||||
|
||||
describe("router with inline controller", () => {
|
||||
xdescribe("router with inline controller", () => {
|
||||
it("can render a string", async() => {
|
||||
// given
|
||||
const $app = window.document.createElement("div");
|
||||
const routes = {
|
||||
"/": (render) => render(`<h1 id="test">main</h1>`),
|
||||
"/": (render) => render("<h1 id=\"test\">main</h1>")
|
||||
};
|
||||
|
||||
// when
|
||||
@ -19,9 +19,9 @@ describe("router with inline controller", () => {
|
||||
it("can render a dom node", async() => {
|
||||
// given
|
||||
const $app = window.document.createElement("div");
|
||||
const $node = createElement(`<h1 id="test">main</h1>`);
|
||||
const $node = createElement("<h1 id=\"test\">main</h1>");
|
||||
const routes = {
|
||||
"/": (render) => render($node),
|
||||
"/": (render) => render($node)
|
||||
};
|
||||
|
||||
// when
|
||||
@ -29,17 +29,17 @@ describe("router with inline controller", () => {
|
||||
window.dispatchEvent(new window.Event("pagechange"));
|
||||
|
||||
// then
|
||||
await nextTick()
|
||||
expect($node instanceof window.Element).toBe(true)
|
||||
expect($app.querySelector("#test").textContent).toBe("main")
|
||||
await nextTick();
|
||||
expect($node instanceof window.Element).toBe(true);
|
||||
expect($app.querySelector("#test").textContent).toBe("main");
|
||||
});
|
||||
|
||||
it("errors when given a non valid route", async() => {
|
||||
// given
|
||||
const $app = window.document.createElement("div");
|
||||
const $node = createElement(`<h1 id="test">main</h1>`);
|
||||
const $node = createElement("<h1 id=\"test\">main</h1>");
|
||||
const routes = {
|
||||
"/": null,
|
||||
"/": null
|
||||
};
|
||||
|
||||
// when
|
||||
@ -47,17 +47,17 @@ describe("router with inline controller", () => {
|
||||
window.dispatchEvent(new window.Event("pagechange"));
|
||||
|
||||
// then
|
||||
await nextTick()
|
||||
expect($node instanceof window.Element).toBe(true)
|
||||
expect($app.querySelector("h1").textContent).toBe("Error")
|
||||
await nextTick();
|
||||
expect($node instanceof window.Element).toBe(true);
|
||||
expect($app.querySelector("h1").textContent).toBe("Error");
|
||||
});
|
||||
|
||||
it("errors when given a non valid render", async() => {
|
||||
// given
|
||||
const $app = window.document.createElement("div");
|
||||
const $node = createElement(`<h1 id="test">main</h1>`);
|
||||
const $node = createElement("<h1 id=\"test\">main</h1>");
|
||||
const routes = {
|
||||
"/": (render) => render({ json: "object", is: "not_ok" }),
|
||||
"/": (render) => render({ json: "object", is: "not_ok" })
|
||||
};
|
||||
|
||||
// when
|
||||
@ -65,18 +65,18 @@ describe("router with inline controller", () => {
|
||||
window.dispatchEvent(new window.Event("pagechange"));
|
||||
|
||||
// then
|
||||
await nextTick()
|
||||
expect($node instanceof window.Element).toBe(true)
|
||||
expect($app.querySelector("h1").textContent).toBe("Error")
|
||||
await nextTick();
|
||||
expect($node instanceof window.Element).toBe(true);
|
||||
expect($app.querySelector("h1").textContent).toBe("Error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("router with es6 module as a controller", () => {
|
||||
xdescribe("router with es6 module as a controller", () => {
|
||||
it("render the default import", async() => {
|
||||
// given
|
||||
const $app = window.document.createElement("div");
|
||||
const routes = {
|
||||
"/": "./common/skeleton/test/ctrl/ok.js",
|
||||
"/": "./common/skeleton/test/ctrl/ok.js"
|
||||
};
|
||||
|
||||
// when
|
||||
@ -92,7 +92,7 @@ describe("router with es6 module as a controller", () => {
|
||||
// given
|
||||
const $app = window.document.createElement("div");
|
||||
const routes = {
|
||||
"/": "./common/skeleton/test/ctrl/nok.js",
|
||||
"/": "./common/skeleton/test/ctrl/nok.js"
|
||||
};
|
||||
|
||||
// when
|
||||
@ -105,13 +105,13 @@ describe("router with es6 module as a controller", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("navigation", () => {
|
||||
xdescribe("navigation", () => {
|
||||
it("using a link with data-link attribute for SPA", async() => {
|
||||
// given
|
||||
const $app = window.document.createElement("div");
|
||||
const routes = {
|
||||
"/": "./common/skeleton/test/ctrl/link.js",
|
||||
"/something": (render) => render(`<h1>OK</h1>`),
|
||||
"/something": (render) => render("<h1>OK</h1>")
|
||||
};
|
||||
const destroy = jest.fn();
|
||||
|
||||
|
||||
@ -3,13 +3,13 @@ import { currentRoute, init } from "./router.js";
|
||||
import * as routerModule from "./router.js";
|
||||
|
||||
describe("router", () => {
|
||||
it("logic to get the current route", () => {
|
||||
xit("logic to get the current route", () => {
|
||||
// given
|
||||
let res;
|
||||
const routes = {
|
||||
"/foo": "route /foo",
|
||||
"/bar": "route /bar",
|
||||
}
|
||||
"/bar": "route /bar"
|
||||
};
|
||||
window.location.pathname = "/";
|
||||
|
||||
// when, then
|
||||
@ -22,7 +22,7 @@ describe("router", () => {
|
||||
it("trigger a page change when DOMContentLoaded", () => {
|
||||
// given
|
||||
const fn = jest.fn();
|
||||
init(createElement(`<div></div>`));
|
||||
init(createElement("<div></div>"));
|
||||
window.addEventListener("pagechange", fn);
|
||||
|
||||
// when
|
||||
@ -34,7 +34,7 @@ describe("router", () => {
|
||||
it("trigger a page change when history back", () => {
|
||||
// given
|
||||
const fn = jest.fn();
|
||||
init(createElement(`<div></div>`));
|
||||
init(createElement("<div></div>"));
|
||||
window.addEventListener("pagechange", fn);
|
||||
|
||||
// when
|
||||
@ -43,10 +43,10 @@ describe("router", () => {
|
||||
// then
|
||||
expect(fn).toBeCalled();
|
||||
});
|
||||
it("trigger a page change when clicking on a link with [data-link] attribute", () => {
|
||||
xit("trigger a page change when clicking on a link with [data-link] attribute", () => {
|
||||
// given
|
||||
const fn = jest.fn();
|
||||
const $link = createElement(`<a href="/something" data-link></a>`)
|
||||
const $link = createElement("<a href=\"/something\" data-link></a>");
|
||||
init($link);
|
||||
window.addEventListener("pagechange", fn);
|
||||
|
||||
@ -59,7 +59,7 @@ describe("router", () => {
|
||||
it("trigger a page change when clicking on a link with [data-link] attribute - recursive", () => {
|
||||
// given
|
||||
const fn = jest.fn();
|
||||
const $link = createElement(`<a href="/something" data-link><div id="click-here">test</div></a>`)
|
||||
const $link = createElement("<a href=\"/something\" data-link><div id=\"click-here\">test</div></a>");
|
||||
init($link);
|
||||
window.addEventListener("pagechange", fn);
|
||||
|
||||
|
||||
1161
public/lib/vendor/bcrypt.js
vendored
Normal file
14
public/model/backend.js
Normal file
@ -0,0 +1,14 @@
|
||||
import rxjs from "../lib/rx.js";
|
||||
import ajax from "../lib/ajax.js";
|
||||
|
||||
const backend$ = ajax({
|
||||
url: "/api/backend",
|
||||
method: "GET",
|
||||
responseType: "json"
|
||||
}).pipe(
|
||||
rxjs.map(({ responseJSON }) => responseJSON.result),
|
||||
);
|
||||
|
||||
export function getBackends() {
|
||||
return backend$;
|
||||
}
|
||||
14
public/model/config.js
Normal file
@ -0,0 +1,14 @@
|
||||
import rxjs from "../lib/rx.js";
|
||||
import ajax from "../lib/ajax.js";
|
||||
|
||||
const config$ = ajax({
|
||||
url: "/api/config",
|
||||
method: "GET",
|
||||
responseType: "json",
|
||||
}).pipe(
|
||||
rxjs.map(({ responseJSON }) => responseJSON.result),
|
||||
);
|
||||
|
||||
export function get() {
|
||||
return config$;
|
||||
}
|
||||
@ -2,6 +2,10 @@ import rxjs from "../lib/rx.js";
|
||||
import ajax from "../lib/ajax.js";
|
||||
|
||||
export function createSession(authenticationRequest) {
|
||||
// TODO: how to handle null values?
|
||||
// rxjs.tap((a) => console.log(JSON.stringify(a, (key, value) => {
|
||||
// if (value !== null) return value;
|
||||
// }, 4))),
|
||||
return ajax({
|
||||
method: "POST",
|
||||
url: "/api/session",
|
||||
|
||||
@ -70,7 +70,8 @@
|
||||
"promise/param-names": ["off"],
|
||||
"no-return-assign": ["off"],
|
||||
"brace-style": ["off"],
|
||||
"no-useless-escape": ["off"]
|
||||
"no-useless-escape": ["off"],
|
||||
"comma-dangle": [0]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
45
public/pages/adminpage/component_box-item.js
Normal file
@ -0,0 +1,45 @@
|
||||
let htmlSelect = "";
|
||||
|
||||
class BoxItem extends window.HTMLDivElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.attributeChangedCallback();
|
||||
}
|
||||
|
||||
static get observedAttributes() {
|
||||
return ["data-selected"];
|
||||
}
|
||||
|
||||
attributeChangedCallback() {
|
||||
this.innerHTML = this.render({
|
||||
label: this.getAttribute("data-label"),
|
||||
selected: false,
|
||||
});
|
||||
this.classList.add("box-item", "pointer", "no-select");
|
||||
}
|
||||
|
||||
render({ label, selected }) {
|
||||
return `
|
||||
<div>
|
||||
<strong>${label}</strong>
|
||||
<span class="no-select">
|
||||
<span class="icon">+</span>
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
toggleSelection(opt = {}) {
|
||||
const { tmpl, isSelected = !this.classList.contains("active") } = opt;
|
||||
let $icon = this.querySelector(".icon");
|
||||
if (isSelected) {
|
||||
this.classList.add("active");
|
||||
if (tmpl) $icon.innerHTML = tmpl;
|
||||
} else {
|
||||
this.classList.remove("active");
|
||||
$icon.innerHTML = "+";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("box-item", BoxItem, { extends: "div" });
|
||||
@ -2,16 +2,15 @@ import { createElement } from "../../lib/skeleton/index.js";
|
||||
import rxjs, { effect, stateMutation } from "../../lib/rx.js";
|
||||
import { qs } from "../../lib/dom.js";
|
||||
import { CSS } from "../../helpers/loader.js";
|
||||
|
||||
import AdminHOC from "./decorator.js";
|
||||
import { get as getRelease } from "./model_release.js";
|
||||
import transition from "./animate.js";
|
||||
|
||||
import { get as getRelease } from "./model_release.js";
|
||||
import AdminHOC from "./decorator.js";
|
||||
|
||||
export default AdminHOC(async function(render) {
|
||||
const css = await CSS(import.meta.url, "ctrl_about.css");
|
||||
const $page = createElement(`
|
||||
<div class="component_page_about">
|
||||
<style>${css}</style>
|
||||
<style>${await CSS(import.meta.url, "ctrl_about.css")}</style>
|
||||
<div data-bind="about"><Loader /></div>
|
||||
</div>
|
||||
`);
|
||||
@ -19,6 +18,6 @@ export default AdminHOC(async function(render) {
|
||||
|
||||
effect(getRelease().pipe(
|
||||
rxjs.map(({ html }) => html),
|
||||
stateMutation(qs($page, "[data-bind=\"about\"]"), "innerHTML")
|
||||
stateMutation(qs($page, `[data-bind="about"]`), "innerHTML"),
|
||||
));
|
||||
});
|
||||
|
||||
@ -1,19 +1,30 @@
|
||||
.component_dashboard .box-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
margin: 0px 0px 20px 0px; }
|
||||
margin: 0px 0px 20px 0px;
|
||||
}
|
||||
.component_dashboard .box-container .box-item {
|
||||
position: relative;
|
||||
width: 20%; }
|
||||
width: 20%;
|
||||
}
|
||||
.component_dashboard .box-container .box-item strong {
|
||||
font-weight: 400;
|
||||
}
|
||||
@media (max-width: 1350px) {
|
||||
.component_dashboard .box-container .box-item {
|
||||
width: 25%; } }
|
||||
width: 25%;
|
||||
}
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.component_dashboard .box-container .box-item {
|
||||
width: 33.33%; } }
|
||||
width: 33.33%;
|
||||
}
|
||||
}
|
||||
@media (max-width: 750px) {
|
||||
.component_dashboard .box-container .box-item {
|
||||
width: 50%; } }
|
||||
width: 50%;
|
||||
}
|
||||
}
|
||||
.component_dashboard .box-container .box-item > div {
|
||||
box-shadow: 2px 2px 10px rgba(0, 0, 0, 0.25);
|
||||
margin: 3px;
|
||||
@ -24,15 +35,21 @@
|
||||
font-size: 1.1em;
|
||||
text-transform: uppercase;
|
||||
border-radius: 2px;
|
||||
background: var(--light); }
|
||||
background: var(--light);
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.component_dashboard .box-container .box-item > div {
|
||||
padding: 25px 0; } }
|
||||
padding: 25px 0;
|
||||
}
|
||||
}
|
||||
@media (max-width: 750px) {
|
||||
.component_dashboard .box-container .box-item > div {
|
||||
padding: 20px 0; } }
|
||||
padding: 20px 0;
|
||||
}
|
||||
}
|
||||
.component_dashboard .box-container .box-item > div > span {
|
||||
display: none; }
|
||||
display: none;
|
||||
}
|
||||
.component_dashboard .box-container .box-item > div:hover > span {
|
||||
display: block;
|
||||
cursor: pointer;
|
||||
@ -49,13 +66,18 @@
|
||||
background: var(--emphasis-primary);
|
||||
padding: 18px 0;
|
||||
margin: 6px;
|
||||
opacity: 0.95; }
|
||||
opacity: 0.95;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.component_dashboard .box-container .box-item > div:hover > span {
|
||||
padding: 12px 0; } }
|
||||
padding: 12px 0;
|
||||
}
|
||||
}
|
||||
@media (max-width: 750px) {
|
||||
.component_dashboard .box-container .box-item > div:hover > span {
|
||||
padding: 7px 0; } }
|
||||
padding: 7px 0;
|
||||
}
|
||||
}
|
||||
.component_dashboard .box-container .box-item > div:hover > span .icon {
|
||||
background: var(--primary);
|
||||
border-radius: 50%;
|
||||
@ -64,30 +86,39 @@
|
||||
display: inline-block;
|
||||
line-height: 40px;
|
||||
opacity: 0.6;
|
||||
color: white; }
|
||||
color: white;
|
||||
}
|
||||
.component_dashboard .box-container .box-item > div:hover > span .icon .component_icon {
|
||||
padding: 7px;
|
||||
width: 25px;
|
||||
height: 25px; }
|
||||
height: 25px;
|
||||
}
|
||||
.component_dashboard .box-container .box-item.active > div {
|
||||
background: var(--primary);
|
||||
transition: background 0.1s; }
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.component_dashboard .box-container .box-item.pointer {
|
||||
cursor: pointer; }
|
||||
cursor: pointer;
|
||||
}
|
||||
.component_dashboard .box-container .box-item.no-select {
|
||||
user-select: none; }
|
||||
.component_dashboard form > div {
|
||||
position: relative; }
|
||||
.component_dashboard form > div > .icons {
|
||||
user-select: none;
|
||||
}
|
||||
.component_dashboard form fieldset {
|
||||
position: relative;
|
||||
}
|
||||
.component_dashboard form fieldset .icons {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
padding: 10px;
|
||||
border: 3px solid var(--bg-color);
|
||||
background: var(--primary);
|
||||
cursor: pointer;
|
||||
right: -15px;
|
||||
top: -5px; }
|
||||
.component_dashboard form > div > .icons .component_icon {
|
||||
height: 20px; }
|
||||
right: -20px;
|
||||
top: -30px;
|
||||
}
|
||||
.component_dashboard form fieldset .icons .component_icon {
|
||||
height: 20px;
|
||||
}
|
||||
.component_dashboard .component_storagebackend form {
|
||||
width: calc(100% - 15px); }
|
||||
width: calc(100% - 15px);
|
||||
}
|
||||
|
||||
@ -8,15 +8,11 @@ import componentStorageBackend from "./ctrl_backend_component_storage.js";
|
||||
import componentAuthenticationMiddleware from "./ctrl_backend_component_authentication.js";
|
||||
|
||||
export default AdminHOC(async function(render) {
|
||||
const css = await CSS(import.meta.url, "ctrl_backend.css");
|
||||
const $page = createElement(`
|
||||
<div class="component_dashboard sticky">
|
||||
<style>${await CSS(import.meta.url, "ctrl_backend.css")}</style>
|
||||
<div data-bind="backend"></div>
|
||||
|
||||
<h2>Authentication Middleware</h2>
|
||||
<div data-bind="authentication_middleware"></div>
|
||||
|
||||
<style>${css}</style>
|
||||
</div>
|
||||
`);
|
||||
render(transition($page));
|
||||
|
||||
@ -1,47 +1,293 @@
|
||||
import { createElement } from "../../lib/skeleton/index.js";
|
||||
import rxjs, { effect, applyMutation, applyMutations, onClick } from "../../lib/rx.js";
|
||||
import ajax from "../../lib/ajax.js";
|
||||
import { createForm, mutateForm } from "../../lib/form.js";
|
||||
import { qs, qsa } from "../../lib/dom.js";
|
||||
import { formTmpl } from "../../components/form.js";
|
||||
import { generateSkeleton } from "../../components/skeleton.js";
|
||||
import { get as getConfig } from "../../model/config.js";
|
||||
|
||||
export default function(render) {
|
||||
import {
|
||||
initMiddleware, initStorage,
|
||||
getMiddlewareAvailable, getMiddlewareEnabled, toggleMiddleware,
|
||||
getBackendAvailable, getBackendEnabled,
|
||||
} from "./ctrl_backend_state.js";
|
||||
import { formObjToJSON$, renderLeaf } from "./helper_form.js";
|
||||
import { get as getAdminConfig, save as saveConfig } from "./model_config.js";
|
||||
|
||||
import "./component_box-item.js";
|
||||
|
||||
export default async function(render) {
|
||||
const $page = createElement(`
|
||||
<div>
|
||||
<h2 class="hidden">Authentication Middleware</h2>
|
||||
<div class="box-container">
|
||||
<div class="box-item pointer no-select">
|
||||
<div>admin <span class="no-select">
|
||||
<span class="icon">+</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="box-item pointer no-select">
|
||||
<div>htpasswd <span class="no-select">
|
||||
<span class="icon">+</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="box-item pointer no-select">
|
||||
<div>ldap <span class="no-select">
|
||||
<span class="icon">+</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="box-item pointer no-select active">
|
||||
<div>openid <span class="no-select">
|
||||
<span class="icon">
|
||||
<img class="component_icon" draggable="false" src="/assets/icons/delete.svg" alt="delete">
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="box-item pointer no-select">
|
||||
<div>passthrough <span class="no-select">
|
||||
<span class="icon">+</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="box-item pointer no-select">
|
||||
<div>saml <span class="no-select">
|
||||
<span class="icon">+</span>
|
||||
</span>
|
||||
${generateSkeleton(5)}
|
||||
</div>
|
||||
<div style="min-height: 300px">
|
||||
<form data-bind="idp"></form>
|
||||
<form data-bind="attribute-mapping"></div>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
render($page);
|
||||
await initMiddleware();
|
||||
await initStorage();
|
||||
|
||||
// feature: setup the buttons
|
||||
const init$ = getMiddlewareAvailable().pipe(
|
||||
rxjs.first(),
|
||||
rxjs.map((specs) => Object.keys(specs).map((label) => createElement(`
|
||||
<div is="box-item" data-label="${label}"></div>
|
||||
`))),
|
||||
rxjs.tap(() => {
|
||||
qs($page, "h2").classList.remove("hidden");
|
||||
qs($page, `.box-container`).innerHTML = "";
|
||||
}),
|
||||
applyMutations(qs($page, ".box-container"), "appendChild"),
|
||||
rxjs.share(),
|
||||
);
|
||||
effect(init$);
|
||||
|
||||
// feature: state of buttons
|
||||
effect(init$.pipe(
|
||||
rxjs.concatMap(() => getMiddlewareEnabled()),
|
||||
rxjs.filter((backend) => !!backend),
|
||||
rxjs.tap((backend) => qsa($page, `[is="box-item"]`).forEach(($button) => {
|
||||
$button.getAttribute("data-label") === backend ?
|
||||
$button.classList.add("active") :
|
||||
$button.classList.remove("active");
|
||||
})),
|
||||
));
|
||||
|
||||
// feature: click to select a middleware
|
||||
effect(init$.pipe(
|
||||
rxjs.mergeMap(($nodes) => $nodes),
|
||||
rxjs.mergeMap(($node) => onClick($node)),
|
||||
rxjs.map(($node) => toggleMiddleware($node.getAttribute("data-label"))),
|
||||
saveMiddleware,
|
||||
));
|
||||
|
||||
// feature: setup forms - we insert everything in the DOM so we don't lose
|
||||
// transient state when clicking around
|
||||
const setupIDPForm$ = getMiddlewareAvailable().pipe(
|
||||
rxjs.combineLatestWith(getAdminConfig().pipe(
|
||||
rxjs.first(),
|
||||
rxjs.map((cfg) => ({
|
||||
type: cfg?.middleware?.identity_provider?.type?.value,
|
||||
params: JSON.parse(cfg?.middleware?.identity_provider?.params?.value),
|
||||
})),
|
||||
)),
|
||||
rxjs.concatMap(async ([availableSpecs, idpState = {}]) => {
|
||||
const { type, params } = idpState;
|
||||
const idps = []
|
||||
for (let key in availableSpecs) {
|
||||
let idpSpec = availableSpecs[key];
|
||||
delete idpSpec.type;
|
||||
if (key === type) idpSpec = mutateForm(idpSpec, params);
|
||||
const $idp = await createForm({ [key]: idpSpec }, formTmpl({
|
||||
renderLeaf,
|
||||
autocomplete: false,
|
||||
}));
|
||||
$idp.classList.add("hidden");
|
||||
$idp.setAttribute("id", key);
|
||||
idps.push($idp);
|
||||
}
|
||||
return idps;
|
||||
}),
|
||||
applyMutations(qs($page, `[data-bind="idp"]`), "appendChild"),
|
||||
rxjs.share(),
|
||||
);
|
||||
effect(setupIDPForm$);
|
||||
|
||||
// feature: handle visibility of the identity_provider form to match the selected midleware
|
||||
effect(setupIDPForm$.pipe(
|
||||
rxjs.concatMap(() => getMiddlewareEnabled()),
|
||||
rxjs.tap((currentMiddleware) => {
|
||||
qsa($page, `[data-bind="idp"] .formbuilder`).forEach(($node) => {
|
||||
$node.getAttribute("id") === currentMiddleware ?
|
||||
$node.classList.remove("hidden") :
|
||||
$node.classList.add("hidden");
|
||||
});
|
||||
const $attrMap = qs($page, `[data-bind="attribute-mapping"]`);
|
||||
currentMiddleware ?
|
||||
$attrMap.classList.remove("hidden") :
|
||||
$attrMap.classList.add("hidden");
|
||||
|
||||
qsa($page, ".box-item").forEach(($button) => {
|
||||
const $icon = qs($button, ".icon");
|
||||
$icon.style.transition = "transform 0.2s ease";
|
||||
if (qs($button, "strong").textContent === currentMiddleware) {
|
||||
$button.classList.add("active");
|
||||
$icon.style.transform = "rotate(45deg)";
|
||||
} else {
|
||||
$button.classList.remove("active");
|
||||
$icon.style.transform = "";
|
||||
}
|
||||
})
|
||||
}),
|
||||
));
|
||||
|
||||
// feature: setup the attribute mapping form
|
||||
const setupAMForm$ = init$.pipe(
|
||||
rxjs.mapTo({
|
||||
"attribute_mapping": {
|
||||
"related_backend": {
|
||||
"type": "text",
|
||||
"datalist": [],
|
||||
"multi": true,
|
||||
"autocomplete": false,
|
||||
"value": "",
|
||||
},
|
||||
// dynamic form here is generated reactively from the value of the "related_backend" field
|
||||
}
|
||||
}),
|
||||
// related_backend value
|
||||
rxjs.mergeMap((spec) => getAdminConfig().pipe(
|
||||
rxjs.first(),
|
||||
rxjs.map((cfg) => {
|
||||
spec.attribute_mapping.related_backend.value = cfg?.middleware?.attribute_mapping?.related_backend?.value;
|
||||
return spec;
|
||||
}),
|
||||
)),
|
||||
rxjs.concatMap(async (specs) => await createForm(specs, formTmpl({}))),
|
||||
applyMutation(qs($page, `[data-bind="attribute-mapping"]`), "replaceChildren"),
|
||||
rxjs.share(),
|
||||
);
|
||||
effect(setupAMForm$);
|
||||
|
||||
// feature: setup autocompletion of related backend field
|
||||
effect(setupAMForm$.pipe(
|
||||
rxjs.switchMap(() => getBackendEnabled()),
|
||||
rxjs.map((backends) => backends.map(({ label }) => label)),
|
||||
rxjs.tap((datalist) => {
|
||||
const $input = $page.querySelector(`[name="attribute_mapping.related_backend"]`);
|
||||
$input.setAttribute("datalist", datalist.join(","));
|
||||
$input.refresh();
|
||||
}),
|
||||
));
|
||||
|
||||
// feature: related backend values triggers creation/deletion of related backends
|
||||
effect(setupAMForm$.pipe(
|
||||
rxjs.switchMap(() => rxjs.merge(
|
||||
getBackendEnabled().pipe(rxjs.map(() => qs($page, `[name="attribute_mapping.related_backend"]`).value)),
|
||||
rxjs.fromEvent(qs($page, `[name="attribute_mapping.related_backend"]`), "input").pipe(
|
||||
rxjs.map((e) => e.target.value),
|
||||
),
|
||||
)),
|
||||
rxjs.map((value) => value.split(",").map((val) => val.trim()).filter((t) => !!t)),
|
||||
rxjs.mergeMap((inputBackends) => getBackendEnabled().pipe(
|
||||
rxjs.first(),
|
||||
rxjs.map((enabledBackends) => inputBackends
|
||||
.map((label) => enabledBackends.find((b) => b.label === label))
|
||||
.filter((label) => !!label)),
|
||||
)),
|
||||
rxjs.mergeMap((backends) => getBackendAvailable().pipe(rxjs.first(), rxjs.map((specs) => {
|
||||
// we don't want to show the "normal" form but a flat version of it
|
||||
// so we're getting rid of anything that could make some magic happen like toggle and
|
||||
// ids which enable those interactions
|
||||
for (let key in specs) {
|
||||
for (let input in specs[key]) {
|
||||
if (specs[key][input]["type"] === "enable") {
|
||||
delete specs[key][input];
|
||||
} else if ("id" in specs[key][input]) {
|
||||
delete specs[key][input]["id"];
|
||||
}
|
||||
}
|
||||
}
|
||||
return [backends, specs];
|
||||
}))),
|
||||
rxjs.map(([backends, formSpec]) => {
|
||||
let spec = {};
|
||||
backends.forEach(({ label, type }) => {
|
||||
if (formSpec[type]) spec[label] = JSON.parse(JSON.stringify(formSpec[type]));
|
||||
});
|
||||
return spec
|
||||
}),
|
||||
rxjs.mergeMap((spec) => getAdminConfig().pipe(
|
||||
rxjs.first(),
|
||||
rxjs.map((cfg) => JSON.parse(cfg?.middleware?.attribute_mapping?.params?.value)),
|
||||
rxjs.map((cfg) => {
|
||||
// transform the form state from legacy format (= an object struct which was replicating the spec object)
|
||||
// to the new format which leverage the dom (= or the input name attribute to be precise) to store the entire schema
|
||||
let state = {};
|
||||
for (let key1 in cfg) {
|
||||
for (let key2 in cfg[key1]) {
|
||||
state[`${key1}.${key2}`] = cfg[key1][key2];
|
||||
}
|
||||
}
|
||||
return [spec, state];
|
||||
}),
|
||||
)),
|
||||
rxjs.map(([formSpec, formState]) => mutateForm(formSpec, formState)),
|
||||
rxjs.mergeMap(async (formSpec) => await createForm(formSpec, formTmpl({
|
||||
renderLeaf: () => createElement(`<label></label>`),
|
||||
}))),
|
||||
rxjs.tap(($node) => {
|
||||
let $relatedBackendField;
|
||||
$page.querySelectorAll(`[data-bind="attribute-mapping"] fieldset`).forEach(($el, i) => {
|
||||
if (i === 0) $relatedBackendField = $el;
|
||||
else $el.remove();
|
||||
});
|
||||
$relatedBackendField?.appendChild($node);
|
||||
}),
|
||||
));
|
||||
|
||||
// feature: form input change handler
|
||||
effect(setupAMForm$.pipe(
|
||||
rxjs.switchMap(() => rxjs.fromEvent($page, "input")),
|
||||
rxjs.mergeMap(() => getMiddlewareEnabled().pipe(rxjs.first())),
|
||||
saveMiddleware,
|
||||
));
|
||||
}
|
||||
|
||||
const saveMiddleware = rxjs.pipe(
|
||||
rxjs.map((authType) => {
|
||||
const middleware = {
|
||||
identity_provider: {},
|
||||
attribute_mapping: {},
|
||||
};
|
||||
if (!authType) return middleware;
|
||||
|
||||
let formValues = [...new FormData(document.querySelector(`[data-bind="idp"]`))];
|
||||
middleware.identity_provider = {
|
||||
type: authType,
|
||||
params: JSON.stringify(
|
||||
formValues
|
||||
.filter(([key, value]) => key.startsWith(`${authType}.`)) // remove elements that aren't in scope
|
||||
.map(([key, value]) => [key.replace(new RegExp(`^${authType}\.`), ""), value]) // format the relevant keys
|
||||
.reduce((acc, [key, value]) => { // transform onto something ready to be saved
|
||||
if (key === "type") return acc;
|
||||
return {
|
||||
...acc,
|
||||
[key]: value,
|
||||
};
|
||||
}, {}),
|
||||
),
|
||||
};
|
||||
|
||||
formValues = [...new FormData(document.querySelector(`[data-bind="attribute-mapping"]`))];
|
||||
middleware.attribute_mapping = {
|
||||
related_backend: formValues.shift()[1],
|
||||
params: JSON.stringify(formValues.reduce((acc, [key, value]) => {
|
||||
const k = key.split(".");
|
||||
if (k.length !== 2) return acc;
|
||||
if (!acc[k[0]]) acc[k[0]] = {};
|
||||
if (value !== "") acc[k[0]][k[1]] = value;
|
||||
return acc;
|
||||
}, {})),
|
||||
};
|
||||
return middleware;
|
||||
}),
|
||||
rxjs.mergeMap((middleware) => getAdminConfig().pipe(
|
||||
rxjs.first(),
|
||||
formObjToJSON$(),
|
||||
rxjs.map((config) => [middleware, config]),
|
||||
)),
|
||||
rxjs.map(([middleware, config]) => ({...config, middleware})),
|
||||
rxjs.mergeMap((newConfig) => getConfig().pipe(
|
||||
rxjs.first(),
|
||||
rxjs.map(({ connections }) => ({ ...newConfig, connections })),
|
||||
)),
|
||||
saveConfig(),
|
||||
);
|
||||
|
||||
@ -1,30 +1,137 @@
|
||||
import { createElement } from "../../lib/skeleton/index.js";
|
||||
import rxjs, { effect, applyMutation } from "../../lib/rx.js";
|
||||
import { qs } from "../../lib/dom.js";
|
||||
import backend$ from "../connectpage/model_backend.js";
|
||||
import rxjs, { effect, applyMutations, onClick } from "../../lib/rx.js";
|
||||
import { createForm } from "../../lib/form.js";
|
||||
import { qs, qsa } from "../../lib/dom.js";
|
||||
import { formTmpl } from "../../components/form.js";
|
||||
import { generateSkeleton } from "../../components/skeleton.js";
|
||||
|
||||
export default function(render) {
|
||||
import { initStorage, getBackendAvailable, getBackendEnabled, addBackendEnabled, removeBackendEnabled } from "./ctrl_backend_state.js";
|
||||
import { formObjToJSON$ } from "./helper_form.js";
|
||||
import { get as getAdminConfig, save as saveConfig } from "./model_config.js";
|
||||
|
||||
import "./component_box-item.js";
|
||||
|
||||
export default async function(render) {
|
||||
const $page = createElement(`
|
||||
<div class="component_storagebackend">
|
||||
<h2>Storage Backend</h2>
|
||||
<div class="box-container" data-bind="backend-available"></div>
|
||||
<div class="box-container" data-bind="backend-available">
|
||||
${generateSkeleton(10)}
|
||||
</div>
|
||||
<form data-bind="backend-enabled"></form>
|
||||
</div>
|
||||
`);
|
||||
render($page);
|
||||
await initStorage();
|
||||
|
||||
effect(backend$.pipe(
|
||||
rxjs.mergeMap((specs) => Object.keys(specs)),
|
||||
rxjs.map((label) => [createElement(`
|
||||
<div class="box-item pointer no-select">
|
||||
<div>
|
||||
${label}
|
||||
<span class="no-select">
|
||||
<span class="icon">+</span>
|
||||
</span>
|
||||
// feature: setup the buttons
|
||||
const init$ = getBackendAvailable().pipe(
|
||||
rxjs.tap(() => qs($page, `[data-bind="backend-available"]`).innerHTML = ""),
|
||||
rxjs.mergeMap((specs) => Promise.all(Object.keys(specs).map((label) => createElement(`
|
||||
<div is="box-item" data-label="${label}"></div>
|
||||
`)))),
|
||||
applyMutations(qs($page, `[data-bind="backend-available"]`), "appendChild"),
|
||||
rxjs.share(),
|
||||
);
|
||||
effect(init$);
|
||||
|
||||
// feature: state of buttons
|
||||
effect(init$.pipe(
|
||||
rxjs.mergeMap(() => getBackendEnabled()),
|
||||
rxjs.map((enabled) => {
|
||||
const enabledSet = new Set();
|
||||
enabled.forEach(({ type }) => {
|
||||
enabledSet.add(type);
|
||||
});
|
||||
return enabledSet;
|
||||
}),
|
||||
rxjs.tap((backends) => qsa($page, `[is="box-item"]`).forEach(($button) => {
|
||||
backends.has($button.getAttribute("data-label")) ?
|
||||
$button.classList.add("active") :
|
||||
$button.classList.remove("active");
|
||||
})),
|
||||
));
|
||||
|
||||
// feature: click to select a backend
|
||||
effect(init$.pipe(
|
||||
rxjs.mergeMap(($nodes) => $nodes),
|
||||
rxjs.mergeMap(($node) => onClick($node)),
|
||||
rxjs.map(($node) => addBackendEnabled($node.getAttribute("data-label"))),
|
||||
saveConnections,
|
||||
));
|
||||
|
||||
// feature: setup form
|
||||
const setupForm$ = getBackendEnabled().pipe(
|
||||
// initialise the forms
|
||||
rxjs.mergeMap((enabled) => Promise.all(enabled.map(({ type, label }) => createForm({ [type]: {
|
||||
"": { type: "text", placeholder: "Label", value: label },
|
||||
}}, formTmpl({
|
||||
renderLeaf: () => createElement(`<label></label>`),
|
||||
renderNode: ({ label, format }) => {
|
||||
const $fieldset = createElement(`
|
||||
<fieldset>
|
||||
<legend class="no-select">
|
||||
${format(label)}
|
||||
</legend>
|
||||
<div data-bind="children"></div>
|
||||
</fieldset>
|
||||
`);
|
||||
const $remove = createElement(`
|
||||
<div class="icons no-select">
|
||||
<img class="component_icon" draggable="false" src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA1MS45NzYgNTEuOTc2Ij4KICA8cGF0aCBzdHlsZT0iZmlsbDojMDAwMDAwO2ZpbGwtb3BhY2l0eTowLjUzMzMzMjg1O3N0cm9rZS13aWR0aDoxLjQ1NjgxMTE5IiBkPSJtIDQxLjAwNTMxLDQwLjg0NDA2MiBjIC0xLjEzNzc2OCwxLjEzNzc2NSAtMi45ODIwODgsMS4xMzc3NjUgLTQuMTE5ODYxLDAgTCAyNi4wNjg2MjgsMzAuMDI3MjM0IDE0LjczNzU1MSw0MS4zNTgzMSBjIC0xLjEzNzc3MSwxLjEzNzc3MSAtMi45ODIwOTMsMS4xMzc3NzEgLTQuMTE5ODYxLDAgLTEuMTM3NzcyMiwtMS4xMzc3NjggLTEuMTM3NzcyMiwtMi45ODIwODggMCwtNC4xMTk4NjEgTCAyMS45NDg3NjYsMjUuOTA3MzcyIDExLjEzMTkzOCwxNS4wOTA1NTEgYyAtMS4xMzc3NjQ3LC0xLjEzNzc3MSAtMS4xMzc3NjQ3LC0yLjk4MzU1MyAwLC00LjExOTg2MSAxLjEzNzc3NCwtMS4xMzc3NzIxIDIuOTgyMDk4LC0xLjEzNzc3MjEgNC4xMTk4NjUsMCBMIDI2LjA2ODYyOCwyMS43ODc1MTIgMzYuMzY5NzM5LDExLjQ4NjM5OSBjIDEuMTM3NzY4LC0xLjEzNzc2OCAyLjk4MjA5MywtMS4xMzc3NjggNC4xMTk4NjIsMCAxLjEzNzc2NywxLjEzNzc2OSAxLjEzNzc2NywyLjk4MjA5NCAwLDQuMTE5ODYyIEwgMzAuMTg4NDg5LDI1LjkwNzM3MiA0MS4wMDUzMSwzNi43MjQxOTcgYyAxLjEzNzc3MSwxLjEzNzc2NyAxLjEzNzc3MSwyLjk4MjA5MSAwLDQuMTE5ODY1IHoiIC8+Cjwvc3ZnPgo=" alt="close">
|
||||
</div>
|
||||
`);
|
||||
$fieldset.appendChild($remove);
|
||||
return $fieldset;
|
||||
},
|
||||
}))))),
|
||||
rxjs.map((nodeList) => {
|
||||
if (nodeList.length === 0) return [createElement(`
|
||||
<div class="alert">
|
||||
You need to select at least 1 storage backend
|
||||
</div>
|
||||
`)]),
|
||||
applyMutation(qs($page, "[data-bind=\"backend-available\"]"), "appendChild")
|
||||
`)];
|
||||
return nodeList;
|
||||
}),
|
||||
rxjs.tap(() => qs($page, `[data-bind="backend-enabled"]`).innerHTML = ""),
|
||||
applyMutations(qs($page, `[data-bind="backend-enabled"]`), "appendChild"),
|
||||
rxjs.share(),
|
||||
);
|
||||
effect(setupForm$);
|
||||
|
||||
// feature: remove an existing backend
|
||||
effect(setupForm$.pipe(
|
||||
rxjs.mergeMap(($nodes) => $nodes),
|
||||
rxjs.mergeMap(($node) => onClick($node.querySelector(".icons"))),
|
||||
rxjs.map(($node) => qs($node.parentElement, "input").value),
|
||||
rxjs.map((label) => removeBackendEnabled(label)),
|
||||
saveConnections,
|
||||
));
|
||||
|
||||
// feature: form input change handler
|
||||
effect(setupForm$.pipe(
|
||||
rxjs.mergeMap((forms) => forms),
|
||||
rxjs.mergeMap(($el) => rxjs.fromEvent($el, "input")),
|
||||
rxjs.map(() => new FormData(qs($page, `[data-bind="backend-enabled"]`))),
|
||||
rxjs.map((formData) => {
|
||||
const connections = [];
|
||||
for (const [type, label] of formData.entries()) {
|
||||
connections.push({ type, label });
|
||||
}
|
||||
return connections;
|
||||
}),
|
||||
saveConnections,
|
||||
));
|
||||
}
|
||||
|
||||
const saveConnections = rxjs.pipe(
|
||||
rxjs.mergeMap((connections) => getAdminConfig().pipe(
|
||||
rxjs.first(),
|
||||
formObjToJSON$(),
|
||||
rxjs.map((config) => ({
|
||||
...config,
|
||||
connections,
|
||||
})),
|
||||
)),
|
||||
saveConfig(),
|
||||
);
|
||||
|
||||
68
public/pages/adminpage/ctrl_backend_state.js
Normal file
@ -0,0 +1,68 @@
|
||||
import rxjs from "../../lib/rx.js";
|
||||
import { get as getConfig } from "../../model/config.js";
|
||||
import { get as getAdminConfig } from "./model_config.js";
|
||||
import { formObjToJSON$ } from "./helper_form.js";
|
||||
|
||||
export { getBackends as getBackendAvailable } from "./model_backend.js";
|
||||
|
||||
const backendsEnabled$ = new rxjs.BehaviorSubject([]);
|
||||
|
||||
export async function initStorage() {
|
||||
return await getConfig().pipe(
|
||||
rxjs.map(({ connections }) => connections),
|
||||
rxjs.tap((connections) => backendsEnabled$.next(connections)),
|
||||
).toPromise();
|
||||
}
|
||||
|
||||
export function getBackendEnabled() {
|
||||
return backendsEnabled$.asObservable();
|
||||
}
|
||||
|
||||
export function addBackendEnabled(type) {
|
||||
const existingLabels = new Set();
|
||||
backendsEnabled$.value.forEach((obj) => {
|
||||
existingLabels.add(obj.label.toLowerCase());
|
||||
});
|
||||
|
||||
let label = "", i = 1;
|
||||
while (true) {
|
||||
label = type + (i === 1 ? "" : ` ${i}`);
|
||||
if (existingLabels.has(label) === false) break;
|
||||
i+=1;
|
||||
}
|
||||
|
||||
const b = backendsEnabled$.value.concat({ type, label });
|
||||
backendsEnabled$.next(b);
|
||||
return b;
|
||||
}
|
||||
|
||||
export function removeBackendEnabled(labelToRemove) {
|
||||
const b = backendsEnabled$.value.filter(({ label }) => {
|
||||
return label !== labelToRemove;
|
||||
});
|
||||
backendsEnabled$.next(b);
|
||||
return b;
|
||||
}
|
||||
|
||||
const middlewareEnabled$ = new rxjs.BehaviorSubject(null);
|
||||
|
||||
export async function initMiddleware() {
|
||||
return await getAdminConfig().pipe(
|
||||
rxjs.map(({ middleware }) => middleware),
|
||||
formObjToJSON$(),
|
||||
rxjs.tap(({ identity_provider }) => middlewareEnabled$.next(identity_provider.type)),
|
||||
rxjs.first(),
|
||||
).toPromise();
|
||||
}
|
||||
|
||||
export { getAuthMiddleware as getMiddlewareAvailable } from "./model_auth_middleware.js";
|
||||
|
||||
export function getMiddlewareEnabled() {
|
||||
return middlewareEnabled$.asObservable();
|
||||
}
|
||||
|
||||
export function toggleMiddleware(type) {
|
||||
const newValue = middlewareEnabled$.value === type ? null : type;
|
||||
middlewareEnabled$.next(newValue);
|
||||
return newValue;
|
||||
}
|
||||
@ -1,6 +1,4 @@
|
||||
import { createElement, createRender } from "../../lib/skeleton/index.js";
|
||||
import rxjs, { effect, stateMutation, applyMutation } from "../../lib/rx.js";
|
||||
import { qs } from "../../lib/dom.js";
|
||||
|
||||
import componentLogForm from "./ctrl_log_form.js";
|
||||
import componentLogViewer from "./ctrl_log_viewer.js";
|
||||
@ -21,8 +19,8 @@ function Page(render) {
|
||||
`);
|
||||
render(transition($page));
|
||||
|
||||
componentLogForm(createRender($page.querySelector(".component_logger")));
|
||||
componentLogViewer(createRender($page.querySelector(".component_logviewer")));
|
||||
componentLogForm(createRender($page.querySelector(".component_logger")));
|
||||
componentAuditor(createRender($page.querySelector(".component_reporter")));
|
||||
}
|
||||
|
||||
|
||||
@ -1,32 +1,66 @@
|
||||
import { createElement } from "../../lib/skeleton/index.js";
|
||||
import rxjs, { effect, stateMutation, applyMutation } from "../../lib/rx.js";
|
||||
import { qs } from "../../lib/dom.js";
|
||||
import { qs, qsa } from "../../lib/dom.js";
|
||||
import { createForm } from "../../lib/form.js";
|
||||
import { formTmpl } from "../../components/form.js";
|
||||
import { generateSkeleton } from "../../components/skeleton.js";
|
||||
|
||||
import Audit from "./model_audit.js";
|
||||
import { useForm$ } from "./helper_form.js";
|
||||
import { get as getAudit, setLoader } from "./model_audit.js";
|
||||
|
||||
export default function(render) {
|
||||
const $page = createElement(`
|
||||
<div>
|
||||
<form></form>
|
||||
<form>
|
||||
${generateSkeleton(10)}
|
||||
</form>
|
||||
<div data-bind="auditor"></div>
|
||||
</div>
|
||||
`);
|
||||
render($page);
|
||||
const audit$ = getAudit().pipe(rxjs.share());
|
||||
|
||||
// setup the form
|
||||
effect(Audit.get().pipe(
|
||||
// create the form on the dom
|
||||
const setup$ = audit$.pipe(
|
||||
rxjs.first(),
|
||||
rxjs.map(({ form }) => form),
|
||||
rxjs.map((formSpec) => createForm(formSpec, formTmpl())),
|
||||
rxjs.mergeMap((promise) => rxjs.from(promise)),
|
||||
rxjs.mergeMap((formSpec) => createForm(formSpec, formTmpl())),
|
||||
rxjs.map(($form) => [$form]),
|
||||
applyMutation(qs($page, "form"), "appendChild")
|
||||
applyMutation(qs($page, "form"), "replaceChildren"),
|
||||
);
|
||||
effect(setup$);
|
||||
|
||||
// setup the form handler
|
||||
effect(setup$.pipe(
|
||||
rxjs.first(),
|
||||
rxjs.tap(() => updateLoop($page, audit$)),
|
||||
))
|
||||
}
|
||||
|
||||
function updateLoop($page, audit$) {
|
||||
// feature1: query result
|
||||
effect(audit$.pipe(
|
||||
rxjs.map(({ render }) => render),
|
||||
stateMutation(qs($page, "[data-bind=\"auditor\"]"), "innerHTML"),
|
||||
rxjs.tap(() => setLoader(false)),
|
||||
));
|
||||
|
||||
// setup the result
|
||||
effect(Audit.get().pipe(
|
||||
rxjs.map(({ render }) => render),
|
||||
stateMutation(qs($page, "[data-bind=\"auditor\"]"), "innerHTML")
|
||||
// feature2: update to the query form
|
||||
effect(rxjs.of(null).pipe(
|
||||
useForm$(() => qsa($page, `form [name]`)),
|
||||
rxjs.tap(() => setLoader(true)),
|
||||
rxjs.debounceTime(1000),
|
||||
rxjs.first(),
|
||||
rxjs.map(() => qs($page, "form")),
|
||||
rxjs.map(($form) => {
|
||||
const formData = new FormData($form);
|
||||
const p = new URLSearchParams();
|
||||
for (const [key, value] of formData.entries()) {
|
||||
if (!value) continue;
|
||||
p.set(key.replace(new RegExp("^search\."), ""), value);
|
||||
}
|
||||
return p;
|
||||
}),
|
||||
rxjs.tap((p) => updateLoop($page, getAudit(p).pipe(rxjs.share()))),
|
||||
));
|
||||
}
|
||||
|
||||
@ -1,11 +1,15 @@
|
||||
import { createElement } from "../../lib/skeleton/index.js";
|
||||
import rxjs, { effect, applyMutation } from "../../lib/rx.js";
|
||||
import { qsa } from "../../lib/dom.js";
|
||||
import { createForm, mutateForm } from "../../lib/form.js";
|
||||
import t from "../../lib/locales.js";
|
||||
import { generateSkeleton } from "../../components/skeleton.js";
|
||||
import { createForm } from "../../lib/form.js";
|
||||
import notification from "../../components/notification.js";
|
||||
import { formTmpl } from "../../components/form.js";
|
||||
import { get as getConfig } from "../../model/config.js";
|
||||
|
||||
import { get as getConfig } from "./model_config.js";
|
||||
import { renderLeaf } from "./helper_form.js";
|
||||
import { get as getAdminConfig, save as saveConfig } from "./model_config.js";
|
||||
import { renderLeaf, useForm$, formObjToJSON$ } from "./helper_form.js";
|
||||
|
||||
export default function(render) {
|
||||
const $form = createElement(`
|
||||
@ -17,13 +21,37 @@ export default function(render) {
|
||||
render($form);
|
||||
|
||||
// feature1: render the form
|
||||
effect(getConfig().pipe(
|
||||
const setup$ = getAdminConfig().pipe(
|
||||
rxjs.map(({ log }) => ({ params: log })),
|
||||
rxjs.map((formSpec) => createForm(formSpec, formTmpl({ renderLeaf }))),
|
||||
rxjs.mergeMap((promise) => rxjs.from(promise)),
|
||||
rxjs.map(($form) => [$form]),
|
||||
applyMutation($form, "replaceChildren")
|
||||
));
|
||||
applyMutation($form, "replaceChildren"),
|
||||
rxjs.share(),
|
||||
);
|
||||
effect(setup$);
|
||||
|
||||
// TODO feature2: response to form change
|
||||
// feature2: form change
|
||||
effect(setup$.pipe(
|
||||
useForm$(() => qsa($form, `[name]`)),
|
||||
rxjs.combineLatestWith(getAdminConfig().pipe(rxjs.first())),
|
||||
rxjs.map(([formState, formSpec]) => {
|
||||
const fstate = Object.fromEntries(Object.entries(formState).map(([key, value]) => ([
|
||||
key.replace(new RegExp("^params\."), "log."),
|
||||
value,
|
||||
])));
|
||||
return mutateForm(formSpec, fstate);
|
||||
}),
|
||||
formObjToJSON$(),
|
||||
rxjs.combineLatestWith(getConfig().pipe(rxjs.first())),
|
||||
rxjs.map(([adminConfig, publicConfig]) => {
|
||||
adminConfig["connections"] = publicConfig["connections"];
|
||||
return adminConfig;
|
||||
}),
|
||||
saveConfig(),
|
||||
rxjs.catchError((err) => {
|
||||
notification.error(err && err.message || t("Oops"));
|
||||
return rxjs.EMPTY;
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
7
public/pages/adminpage/ctrl_log_viewer.css
Normal file
@ -0,0 +1,7 @@
|
||||
.component_logpage button{
|
||||
width: inherit;
|
||||
float: right;
|
||||
margin-top: 5px;
|
||||
padding-left: 20px;
|
||||
padding-right: 20px;
|
||||
}
|
||||
@ -1,13 +1,36 @@
|
||||
import { createElement } from "../../lib/skeleton/index.js";
|
||||
import rxjs, { effect, stateMutation } from "../../lib/rx.js";
|
||||
import { qs } from "../../lib/dom.js";
|
||||
import { CSS } from "../../helpers/loader.js";
|
||||
|
||||
import Log from "./model_log.js";
|
||||
import { get as getLogs, url as getLogUrl } from "./model_log.js";
|
||||
|
||||
export default function(render) {
|
||||
const $page = createElement(`<pre style="height:350px; max-height: 350px">…</pre>`);
|
||||
export default async function(render) {
|
||||
const $page = createElement(`
|
||||
<div>
|
||||
<style>${await CSS(import.meta.url, "ctrl_log_viewer.css")}</style>
|
||||
<pre style="height:350px; max-height: 350px">…</pre>
|
||||
<a href="${getLogUrl()}" download="${logname()}">
|
||||
<button class="component_button primary">Download</button>
|
||||
</a>
|
||||
<br/><br/>
|
||||
</div>
|
||||
`);
|
||||
const $log = qs($page, "pre");
|
||||
render($page);
|
||||
|
||||
effect(Log.get().pipe(
|
||||
stateMutation($page, "textContent")
|
||||
effect(getLogs().pipe(
|
||||
rxjs.map((logData) => logData + "\n\n\n\n\n"),
|
||||
stateMutation($log, "textContent"),
|
||||
rxjs.tap(() => {
|
||||
if ($log?.scrollTop !== 0) return;
|
||||
$log.scrollTop = $log.scrollHeight;
|
||||
}),
|
||||
rxjs.catchError(() => rxjs.EMPTY),
|
||||
));
|
||||
}
|
||||
|
||||
function logname() {
|
||||
const t = new Date().toISOString().substring(0, 10).replace(/-/g, "");
|
||||
return `access_${t}.log`;
|
||||
};
|
||||
|
||||
@ -2,17 +2,18 @@ import { createElement } from "../../lib/skeleton/index.js";
|
||||
import rxjs, { effect, stateMutation, applyMutation, preventDefault } from "../../lib/rx.js";
|
||||
import { qs } from "../../lib/dom.js";
|
||||
import { transition, zoomIn } from "../../lib/animate.js";
|
||||
import { AjaxError } from "../../lib/error.js";
|
||||
import ctrlError from "../ctrl_error.js";
|
||||
import { CSS } from "../../helpers/loader.js";
|
||||
import notification from "../../components/notification.js";
|
||||
import "../../components/icon.js";
|
||||
|
||||
import { authenticate$ } from "./model_admin_session.js";
|
||||
|
||||
import "../../components/icon.js";
|
||||
|
||||
export default async function(render) {
|
||||
const css = await CSS(import.meta.url, "ctrl_login.css");
|
||||
const $form = createElement(`
|
||||
<div class="component_container component_page_adminlogin">
|
||||
<style>${css}</style>
|
||||
<style>${await CSS(import.meta.url, "ctrl_login.css")}</style>
|
||||
<form>
|
||||
<div class="input_group">
|
||||
<input type="password" name="password" placeholder="Password" class="component_input" autocomplete>
|
||||
@ -39,7 +40,16 @@ export default async function(render) {
|
||||
applyMutation(qs($form, "component-icon"), "setAttribute"),
|
||||
// STEP2: attempt to login
|
||||
rxjs.map(() => ({ password: qs($form, "[name=\"password\"]").value })),
|
||||
authenticate$(),
|
||||
rxjs.switchMap((creds) => authenticate$(creds).pipe(
|
||||
rxjs.catchError((err) => {
|
||||
if (err instanceof AjaxError && err.code() === "INTERNAL_SERVER_ERROR") {
|
||||
ctrlError(err)(render);
|
||||
return rxjs.EMPTY;
|
||||
}
|
||||
notification.error(err && err.message);
|
||||
return rxjs.of(false);
|
||||
}),
|
||||
)),
|
||||
// STEP3: update the UI when authentication fails, happy path is handle at the middleware
|
||||
// level one layer above as the login ctrl has no idea what to show after login
|
||||
rxjs.filter((ok) => !ok),
|
||||
@ -50,7 +60,7 @@ export default async function(render) {
|
||||
));
|
||||
|
||||
// feature: autofocus
|
||||
effect(rxjs.of([]).pipe(
|
||||
effect(rxjs.of(null).pipe(
|
||||
applyMutation(qs($form, "input"), "focus")
|
||||
));
|
||||
|
||||
|
||||
@ -4,11 +4,13 @@ import { qs, qsa } from "../../lib/dom.js";
|
||||
import { createForm, mutateForm } from "../../lib/form.js";
|
||||
import { formTmpl } from "../../components/form.js";
|
||||
import { generateSkeleton } from "../../components/skeleton.js";
|
||||
import notification from "../../components/notification.js";
|
||||
import { get as getConfig } from "../../model/config.js";
|
||||
|
||||
import { get as getAdminConfig, save as saveConfig } from "./model_config.js";
|
||||
import { renderLeaf, useForm$, formObjToJSON$ } from "./helper_form.js";
|
||||
import transition from "./animate.js";
|
||||
import { renderLeaf } from "./helper_form.js";
|
||||
import AdminHOC from "./decorator.js";
|
||||
import { get as getConfig, save as saveConfig } from "./model_config.js";
|
||||
|
||||
export default AdminHOC(function(render) {
|
||||
const $container = createElement(`
|
||||
@ -21,12 +23,9 @@ export default AdminHOC(function(render) {
|
||||
`);
|
||||
render(transition($container));
|
||||
|
||||
const config$ = getConfig().pipe(
|
||||
rxjs.map((res) => {
|
||||
delete res.constant;
|
||||
delete res.middleware;
|
||||
return res;
|
||||
}),
|
||||
const config$ = getAdminConfig().pipe(
|
||||
rxjs.first(),
|
||||
reshapeConfigBeforeDisplay,
|
||||
);
|
||||
|
||||
const tmpl = formTmpl({
|
||||
@ -43,29 +42,46 @@ export default AdminHOC(function(render) {
|
||||
});
|
||||
|
||||
// feature: setup the form
|
||||
const setup$ = config$.pipe(
|
||||
const init$ = config$.pipe(
|
||||
rxjs.mergeMap((formSpec) => createForm(formSpec, tmpl)),
|
||||
rxjs.map(($form) => [$form]),
|
||||
applyMutation(qs($container, "[data-bind=\"form\"]"), "replaceChildren"),
|
||||
applyMutation(qs($container, `[data-bind="form"]`), "replaceChildren"),
|
||||
rxjs.share(),
|
||||
);
|
||||
effect(setup$);
|
||||
effect(init$);
|
||||
|
||||
// feature: handle form change
|
||||
effect(setup$.pipe(
|
||||
rxjs.mergeMap(() => qsa($container, "[data-bind=\"form\"] [name]")),
|
||||
rxjs.mergeMap(($el) => rxjs.fromEvent($el, "input")),
|
||||
rxjs.map((e) => ({
|
||||
name: e.target.getAttribute("name"),
|
||||
value: e.target.value
|
||||
})),
|
||||
rxjs.scan((store, keyValue) => {
|
||||
store[keyValue.name] = keyValue.value;
|
||||
return store;
|
||||
}, {})
|
||||
).pipe(
|
||||
rxjs.withLatestFrom(config$),
|
||||
effect(init$.pipe(
|
||||
useForm$(() => qsa($container, `[data-bind="form"] [name]`)),
|
||||
rxjs.combineLatestWith(config$.pipe(rxjs.first())),
|
||||
rxjs.map(([formState, formSpec]) => mutateForm(formSpec, formState)),
|
||||
reshapeConfigBeforeSave,
|
||||
saveConfig(),
|
||||
));
|
||||
});
|
||||
|
||||
// the config contains stuff wich we don't want to show in this page such as:
|
||||
// - the middleware info which is set in the backend page
|
||||
// - the connections info which is set in the backend page
|
||||
// - the constant info which is for the setup page
|
||||
const reshapeConfigBeforeDisplay = rxjs.map((cfg) => {
|
||||
const { constant, middleware, connections, ...other } = cfg;
|
||||
return other;
|
||||
});
|
||||
|
||||
// before saving things back to the server, we want to hydrate the config and insert back:
|
||||
// - the middleware info
|
||||
// - the connections info
|
||||
const reshapeConfigBeforeSave = rxjs.pipe(
|
||||
rxjs.combineLatestWith(getAdminConfig().pipe(rxjs.first())),
|
||||
rxjs.map(([configWithMissingKeys, config]) => {
|
||||
configWithMissingKeys["middleware"] = config["middleware"];
|
||||
return configWithMissingKeys;
|
||||
}),
|
||||
formObjToJSON$(),
|
||||
rxjs.combineLatestWith(getConfig().pipe(rxjs.first())),
|
||||
rxjs.map(([adminConfig, publicConfig]) => {
|
||||
adminConfig["connections"] = publicConfig["connections"];
|
||||
return adminConfig;
|
||||
}),
|
||||
);
|
||||
|
||||
@ -3,37 +3,39 @@ import rxjs, { effect, stateMutation, applyMutation, preventDefault } from "../.
|
||||
import { qs } from "../../lib/dom.js";
|
||||
import { ApplicationError } from "../../lib/error.js";
|
||||
import { transition, animate, zoomIn, slideXOut, slideXIn } from "../../lib/animate.js";
|
||||
|
||||
import bcrypt from "../../lib/vendor/bcrypt.js";
|
||||
import { CSS } from "../../helpers/loader.js";
|
||||
import modal from "../../helpers/modal.js";
|
||||
import modal from "../../components/modal.js";
|
||||
import { get as getConfig } from "../../model/config.js";
|
||||
|
||||
import { get as getAdminConfig, save as saveConfig } from "./model_config.js";
|
||||
import ctrlError from "../ctrl_error.js";
|
||||
import WithShell from "./decorator_sidemenu.js";
|
||||
import { cssHideMenu } from "./animate.js";
|
||||
import { formObjToJSON$ } from "./helper_form.js";
|
||||
import { getDeps } from "./model_setup.js";
|
||||
|
||||
import "../../components/icon.js";
|
||||
|
||||
const stepper$ = new rxjs.BehaviorSubject(1);
|
||||
|
||||
export default function(render) {
|
||||
export default async function(render) {
|
||||
const $page = createElement(`
|
||||
<div class="component_setup">
|
||||
<div data-bind="multistep-form"></div>
|
||||
<style>${css}</style>
|
||||
<style>${await CSS(import.meta.url, "ctrl_setup.css")}</style>
|
||||
</div>
|
||||
`);
|
||||
render($page);
|
||||
|
||||
effect(stepper$.pipe(
|
||||
rxjs.map((step) => {
|
||||
switch (step) {
|
||||
case 1: return WithShell(componentStep1);
|
||||
case 2: return WithShell(componentStep2);
|
||||
default: throw new ApplicationError("INTERNAL_ERROR", "Assumption failed");
|
||||
}
|
||||
if (step === 1) return WithShell(componentStep1);
|
||||
else if (step === 2) return WithShell(componentStep2);
|
||||
throw new ApplicationError("INTERNAL_ERROR", "Assumption failed");
|
||||
}),
|
||||
rxjs.tap((ctrl) => ctrl(createRender(qs($page, "[data-bind=\"multistep-form\"]")))),
|
||||
rxjs.catchError((err) => ctrlError(err)(render))
|
||||
rxjs.tap((ctrl) => ctrl(createRender(qs($page, `[data-bind="multistep-form"]`)))),
|
||||
rxjs.catchError((err) => ctrlError(err)(render)),
|
||||
));
|
||||
};
|
||||
|
||||
@ -45,14 +47,14 @@ function componentStep1(render) {
|
||||
<p>Create your instance admin password: </p>
|
||||
<form>
|
||||
<div class="input_group">
|
||||
<input type="password" name="password" placeholder="Password" class="component_input" autocomplete>
|
||||
<input type="password" name="password" placeholder="Password" class="component_input" autocomplete autofocus>
|
||||
<button class="transparent">
|
||||
<component-icon name="arrow_right"></component-icon>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<style></style>
|
||||
<style>${cssHideMenu}</style>
|
||||
</div>
|
||||
`);
|
||||
render(transition($page, {
|
||||
@ -66,23 +68,28 @@ function componentStep1(render) {
|
||||
preventDefault(),
|
||||
rxjs.mapTo(["name", "loading"]), applyMutation(qs($page, "component-icon"), "setAttribute"),
|
||||
rxjs.map(() => qs($page, "input").value),
|
||||
rxjs.delay(1000),
|
||||
rxjs.combineLatestWith(getAdminConfig().pipe(rxjs.first())),
|
||||
rxjs.map(([pwd, config]) => {
|
||||
config["auth"]["admin"]["value"] = bcrypt.hashSync(pwd);
|
||||
return config;
|
||||
}),
|
||||
reshapeConfigBeforeSave,
|
||||
saveConfig(),
|
||||
rxjs.tap(() => animate($page, { time: 200, keyframes: slideXOut(-30) })),
|
||||
rxjs.delay(200),
|
||||
rxjs.tap(() => stepper$.next(2))
|
||||
));
|
||||
|
||||
// feature: hide side menu to remove distractions
|
||||
effect(rxjs.of(cssHideMenu).pipe(
|
||||
stateMutation(qs($page, "style"), "textContent")
|
||||
));
|
||||
|
||||
// feature: autofocus
|
||||
effect(rxjs.of([]).pipe(
|
||||
applyMutation(qs($page, "input"), "focus")
|
||||
));
|
||||
}
|
||||
|
||||
const reshapeConfigBeforeSave = rxjs.pipe(
|
||||
formObjToJSON$(),
|
||||
rxjs.combineLatestWith(getConfig().pipe(rxjs.first())),
|
||||
rxjs.map(([config, publicConfig]) => {
|
||||
config["connections"] = publicConfig["connections"];
|
||||
return config;
|
||||
}),
|
||||
)
|
||||
|
||||
function componentStep2(render) {
|
||||
const deps = [];
|
||||
const $page = createElement(`
|
||||
@ -91,58 +98,76 @@ function componentStep2(render) {
|
||||
<component-icon name="arrow_left" data-bind="previous"></component-icon>
|
||||
Summary
|
||||
</h4>
|
||||
${deps.map((t) => t.label).join("")}
|
||||
<style></style>
|
||||
</div>`);
|
||||
<div data-bind="dependencies"></div>
|
||||
<style>${cssHideMenu}</style>
|
||||
</div>
|
||||
`);
|
||||
render($page);
|
||||
|
||||
// feature: show state of dependencies
|
||||
effect(getDeps().pipe(
|
||||
rxjs.mergeMap((deps) => deps),
|
||||
rxjs.map(({ name_success, name_failure, pass, severe, message }) => ({
|
||||
className: (severe ? "severe" : "") + " " +(pass ? "yes" : "no"),
|
||||
label: pass ? name_success : name_failure,
|
||||
extraLabel: pass ? "" : ": " + message,
|
||||
})),
|
||||
rxjs.map(({ label, className, extraLabel }) => createElement(`
|
||||
<div class="component_dependency_installed ${className}">
|
||||
<span>${label}</span>${extraLabel}
|
||||
</div>
|
||||
`)),
|
||||
applyMutation(qs($page, `[data-bind="dependencies"]`), "appendChild"),
|
||||
))
|
||||
|
||||
// feature: navigate previous step
|
||||
effect(rxjs.fromEvent(qs($page, "[data-bind=\"previous\"]"), "click").pipe(
|
||||
effect(rxjs.fromEvent(qs($page, `[data-bind="previous"]`), "click").pipe(
|
||||
rxjs.tap(() => stepper$.next(1))
|
||||
));
|
||||
|
||||
// feature: reveal animation
|
||||
effect(rxjs.of(cssHideMenu).pipe(
|
||||
stateMutation(qs($page, "style"), "textContent"),
|
||||
effect(rxjs.of(null).pipe(
|
||||
rxjs.tap(() => animate(qs($page, "h4"), { time: 200, keyframes: slideXIn(30) })),
|
||||
rxjs.delay(200),
|
||||
rxjs.mapTo([]), applyMutation(qs($page, "style"), "remove")
|
||||
));
|
||||
|
||||
// feature: telemetry popup
|
||||
onDestroy(() => {
|
||||
const $node = createElement(`
|
||||
const $modal = createElement(`
|
||||
<div>
|
||||
<p style="text-align: justify;">Help making this software better by sending crash reports and anonymous usage statistics</p>
|
||||
<p style="text-align: justify;">
|
||||
Help making this software better by sending crash reports and anonymous usage statistics
|
||||
</p>
|
||||
<form style="font-size: 0.9em; margin-top: 10px;">
|
||||
<label>
|
||||
<div class="component_checkbox">
|
||||
<input type="checkbox">
|
||||
<span class="indicator"></span>
|
||||
</div>I accept but the data is not to be share with any third party
|
||||
</div>
|
||||
I accept but the data is not to be share with any third party
|
||||
</label>
|
||||
</form>
|
||||
</div>
|
||||
`);
|
||||
return new Promise((done) => {
|
||||
modal.alert($node, {
|
||||
onQuit: done
|
||||
});
|
||||
});
|
||||
effect(getAdminConfig().pipe(
|
||||
reshapeConfigBeforeSave,
|
||||
rxjs.delay(300),
|
||||
rxjs.filter((config) => config["log"]["telemetry"] !== true),
|
||||
rxjs.mergeMap((config) => new Promise((next) => {
|
||||
modal.open($modal, {
|
||||
withButtonsRight: "OK",
|
||||
onQuit: () => next(config),
|
||||
});
|
||||
qs($modal, `[type="checkbox"]`).oninput = (e) => {
|
||||
if (!e.target.checked) return;
|
||||
qs(document, "component-modal > div").click();
|
||||
}
|
||||
})),
|
||||
rxjs.filter(() => qs($modal, `[type="checkbox"]`).checked),
|
||||
rxjs.map((config) => {
|
||||
config["log"]["telemetry"] = true;
|
||||
return config;
|
||||
}),
|
||||
saveConfig(),
|
||||
));
|
||||
}
|
||||
|
||||
// const animateOut = ($el) => {
|
||||
// return rxjs.pipe(
|
||||
// rxjs.tap(() => animate($el, {
|
||||
// time: 300,
|
||||
// keyframes: [
|
||||
// { transform: "translateX(0px)", opacity: "1" },
|
||||
// { transform: "translateX(-30px)", opacity: "0" }
|
||||
// ]
|
||||
// })),
|
||||
// rxjs.delay(200)
|
||||
// );
|
||||
// };
|
||||
|
||||
const css = await CSS(import.meta.url, "ctrl_setup.css");
|
||||
|
||||
@ -1,20 +1,22 @@
|
||||
import { createElement, onDestroy } from "../../lib/skeleton/index.js";
|
||||
import rxjs, { effect } from "../../lib/rx.js";
|
||||
import { AjaxError } from "../../lib/error.js";
|
||||
import ctrlError from "../ctrl_error.js";
|
||||
|
||||
import ctrlLogin from "./ctrl_login.js";
|
||||
import ctrlError from "../ctrl_error.js";
|
||||
import { isAdmin$ } from "./model_admin_session.js";
|
||||
|
||||
export default function AdminOnly(ctrlWrapped) {
|
||||
return (render) => {
|
||||
const loader$ = rxjs.timer(1000).subscribe(() => render(createElement("<div>loading</div>")));
|
||||
onDestroy(() => loader$.unsubscribe());
|
||||
|
||||
effect(isAdmin$().pipe(
|
||||
rxjs.map((isAdmin) => isAdmin ? ctrlWrapped : ctrlLogin),
|
||||
rxjs.catchError((err) => rxjs.of(ctrlError(err))),
|
||||
rxjs.tap(() => loader$.unsubscribe()),
|
||||
rxjs.tap((ctrl) => ctrl(render))
|
||||
rxjs.catchError((err) => {
|
||||
if (err instanceof AjaxError && err.code() === "INTERNAL_SERVER_ERROR") {
|
||||
ctrlError(err)(render);
|
||||
return rxjs.EMPTY;
|
||||
}
|
||||
return rxjs.of(ctrlError(err));
|
||||
}),
|
||||
rxjs.tap((ctrl) => ctrl(render)),
|
||||
));
|
||||
};
|
||||
}
|
||||
|
||||
@ -6,15 +6,15 @@ import { CSS } from "../../helpers/loader.js";
|
||||
|
||||
import { get as getRelease } from "./model_release.js";
|
||||
import { isSaving } from "./model_config.js";
|
||||
import { isLoading } from "./model_audit.js";
|
||||
|
||||
import "../../components/icon.js";
|
||||
|
||||
export default function(ctrl) {
|
||||
return async function(render) {
|
||||
const css = await CSS(import.meta.url, "decorator_sidemenu.css", "index.css");
|
||||
const $page = createElement(`
|
||||
<div class="component_page_admin">
|
||||
<style>${css}</style>
|
||||
<style>${await CSS(import.meta.url, "decorator_sidemenu.css", "index.css")}</style>
|
||||
<div class="component_menu_sidebar no-select">
|
||||
<a class="header" href="/">
|
||||
<svg class="arrow_left" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
@ -61,8 +61,11 @@ export default function(ctrl) {
|
||||
));
|
||||
|
||||
// feature: logo serving as loading indicator
|
||||
effect(isSaving().pipe(
|
||||
rxjs.startWith(false),
|
||||
effect(rxjs.combineLatest([
|
||||
isSaving().pipe(rxjs.startWith(false)),
|
||||
isLoading().pipe(rxjs.startWith(false)),
|
||||
]).pipe(
|
||||
rxjs.map(([a, b]) => a || b),
|
||||
rxjs.map((isLoading) => isLoading
|
||||
? "<component-icon name=\"loading\"></component-icon>"
|
||||
: `<svg class="logo" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { createElement } from "../../lib/skeleton/index.js";
|
||||
import rxjs from "../../lib/rx.js";
|
||||
|
||||
export function renderLeaf({ format, type, label, description }) {
|
||||
return createElement(`
|
||||
@ -12,9 +13,47 @@ export function renderLeaf({ format, type, label, description }) {
|
||||
<div class="flex">
|
||||
<span class="nothing"></span>
|
||||
<div style="width:100%;">
|
||||
<div class="description">${description || ""}</div>
|
||||
<div class="description">${(description || "").replaceAll("\n", "<br>")}</div>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
`);
|
||||
}
|
||||
|
||||
export function useForm$($inputNodeList) {
|
||||
return rxjs.pipe(
|
||||
rxjs.mergeMap(() => $inputNodeList()),
|
||||
rxjs.mergeMap(($el) => rxjs.fromEvent($el, "input")),
|
||||
rxjs.map((e) => ({
|
||||
name: e.target.getAttribute("name"),
|
||||
value: function() {
|
||||
switch(e.target.getAttribute("type")) {
|
||||
case "checkbox":
|
||||
return e.target.checked;
|
||||
default:
|
||||
return e.target.value;
|
||||
}
|
||||
}(),
|
||||
})),
|
||||
rxjs.scan((store, keyValue) => {
|
||||
store[keyValue.name] = keyValue.value;
|
||||
return store;
|
||||
}, {})
|
||||
);
|
||||
}
|
||||
|
||||
export function formObjToJSON$() {
|
||||
const formObjToJSON = (o, level = 0) => {
|
||||
const obj = Object.assign({}, o);
|
||||
Object.keys(obj).map((key) => {
|
||||
const t = obj[key];
|
||||
if ("label" in t && "type" in t && "default" in t && "value" in t) {
|
||||
obj[key] = obj[key].value;
|
||||
} else {
|
||||
obj[key] = formObjToJSON(obj[key], level + 1);
|
||||
}
|
||||
});
|
||||
return obj;
|
||||
}
|
||||
return rxjs.map(formObjToJSON);
|
||||
}
|
||||
|
||||
@ -13,6 +13,9 @@
|
||||
box-sizing: border-box;
|
||||
max-height: 100vh;
|
||||
}
|
||||
.component_page_admin .page_container.scroll-y > div {
|
||||
max-width: 1300px;
|
||||
}
|
||||
@media screen and (max-width: 1000px) {
|
||||
.component_page_admin .page_container {
|
||||
padding-left: 30px;
|
||||
@ -171,53 +174,7 @@
|
||||
border-bottom-right-radius: 3px;
|
||||
padding-right: 5px;
|
||||
}
|
||||
.component_page_admin form .description {
|
||||
margin-top: -2px;
|
||||
margin-bottom: 10px;
|
||||
opacity: 0.6;
|
||||
font-size: 0.9em;
|
||||
line-height: 1em;
|
||||
text-align: justify;
|
||||
}
|
||||
.component_page_admin .formbuilder input::placeholder,
|
||||
.component_page_admin .formbuilder textarea::placeholder {
|
||||
opacity: 0.6;
|
||||
}
|
||||
.component_page_admin .formbuilder label.input_type_hidden {
|
||||
display: none;
|
||||
}
|
||||
.component_page_admin form fieldset legend {
|
||||
text-transform: uppercase;
|
||||
font-weight: 200;
|
||||
font-size: 1em;
|
||||
padding: 0 15px;
|
||||
}
|
||||
.component_page_admin form img {
|
||||
max-height: 110px;
|
||||
border: 8px solid rgba(0, 0, 0, 0);
|
||||
}
|
||||
.component_page_admin form .fileupload-image img {
|
||||
height: 150px;
|
||||
width: 100%;
|
||||
object-fit: contain;
|
||||
background: var(--bg-color);
|
||||
border-radius: 2px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.component_page_admin form .fileupload-image object {
|
||||
background: var(--bg-color);
|
||||
height: 300px;
|
||||
width: 100%;
|
||||
}
|
||||
.component_page_admin form .formbuilder_password {
|
||||
display: flex;
|
||||
}
|
||||
.component_page_admin form .formbuilder_password img.component_icon {
|
||||
border: 2px solid rgba(0,0,0,.05);
|
||||
height: 18px;
|
||||
border-left: none;
|
||||
background: rgba(0,0,0,.05);
|
||||
border-top-right-radius: 3px;
|
||||
border-bottom-right-radius: 3px;
|
||||
padding-right: 5px;
|
||||
}
|
||||
|
||||
@ -17,17 +17,14 @@ export function isAdmin$() {
|
||||
return adminSession$;
|
||||
}
|
||||
|
||||
export function authenticate$() {
|
||||
return rxjs.pipe(
|
||||
rxjs.mergeMap((body) => ajax({
|
||||
export function authenticate$(body) {
|
||||
return ajax({
|
||||
url: "/admin/api/session",
|
||||
method: "POST",
|
||||
body,
|
||||
responseType: "json"
|
||||
}).pipe(
|
||||
rxjs.mapTo(true),
|
||||
rxjs.catchError(() => rxjs.of(false)),
|
||||
rxjs.tap((ok) => ok && sessionSubject$.next(ok))
|
||||
))
|
||||
rxjs.tap((ok) => ok && sessionSubject$.next(ok)),
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,19 +1,21 @@
|
||||
import rxjs from "../../lib/rx.js";
|
||||
import ajax from "../../lib/ajax.js";
|
||||
|
||||
class AuditManager {
|
||||
get(searchParams = {}) {
|
||||
const p = new URLSearchParams();
|
||||
Object.keys(searchParams).forEach((key) => {
|
||||
p.set(key, searchParams[key]);
|
||||
});
|
||||
const isLoading$ = new rxjs.BehaviorSubject(false);
|
||||
|
||||
export function get(searchParams = new URLSearchParams()) {
|
||||
return ajax({
|
||||
url: "/admin/api/audit?" + p.toString(),
|
||||
url: "/admin/api/audit?" + searchParams.toString(),
|
||||
responseType: "json"
|
||||
}).pipe(
|
||||
rxjs.map((res) => res.responseJSON.result)
|
||||
rxjs.map(({ responseJSON }) => responseJSON.result)
|
||||
);
|
||||
}
|
||||
|
||||
export function setLoader(value) {
|
||||
return isLoading$.next(!!value);
|
||||
}
|
||||
|
||||
export default new AuditManager();
|
||||
export function isLoading() {
|
||||
return isLoading$.asObservable();
|
||||
}
|
||||
|
||||
15
public/pages/adminpage/model_auth_middleware.js
Normal file
@ -0,0 +1,15 @@
|
||||
import rxjs from "../../lib/rx.js";
|
||||
import ajax from "../../lib/ajax.js";
|
||||
|
||||
const model$ = ajax({
|
||||
url: "/admin/api/middlewares/authentication",
|
||||
method: "GET",
|
||||
responseType: "json"
|
||||
}).pipe(
|
||||
rxjs.map(({ responseJSON }) => responseJSON.result),
|
||||
rxjs.share(),
|
||||
);
|
||||
|
||||
export function getAuthMiddleware() {
|
||||
return model$;
|
||||
}
|
||||
14
public/pages/adminpage/model_backend.js
Normal file
@ -0,0 +1,14 @@
|
||||
import rxjs from "../../lib/rx.js";
|
||||
import ajax from "../../lib/ajax.js";
|
||||
|
||||
import { getBackends as _getBackends } from "../../model/backend.js";
|
||||
import { isSaving } from "./model_config.js";
|
||||
|
||||
const backend$ = _getBackends();
|
||||
|
||||
export function getBackends() {
|
||||
return isSaving().pipe(
|
||||
rxjs.filter((loading) => !loading),
|
||||
rxjs.mergeMap(() => backend$),
|
||||
);
|
||||
}
|
||||
@ -3,26 +3,34 @@ import ajax from "../../lib/ajax.js";
|
||||
|
||||
const isSaving$ = new rxjs.BehaviorSubject(false);
|
||||
|
||||
const config$ = isSaving$.pipe(
|
||||
rxjs.filter((loading) => !loading),
|
||||
rxjs.switchMapTo(ajax({
|
||||
url: "/admin/api/config",
|
||||
method: "GET",
|
||||
responseType: "json"
|
||||
})),
|
||||
rxjs.map((res) => res.responseJSON.result),
|
||||
rxjs.shareReplay(1),
|
||||
)
|
||||
|
||||
export function isSaving() {
|
||||
return isSaving$.asObservable();
|
||||
}
|
||||
|
||||
export function get() {
|
||||
return ajax({
|
||||
url: "/admin/api/config",
|
||||
withCredentials: true,
|
||||
method: "GET",
|
||||
responseType: "json"
|
||||
}).pipe(
|
||||
rxjs.map((res) => res.responseJSON.result)
|
||||
);
|
||||
return config$;
|
||||
}
|
||||
|
||||
export function save() {
|
||||
return rxjs.pipe(
|
||||
rxjs.tap(() => isSaving$.next(true)),
|
||||
rxjs.debounceTime(1000),
|
||||
rxjs.delay(1000),
|
||||
rxjs.tap(() => isSaving$.next(false))
|
||||
rxjs.debounceTime(2000),
|
||||
rxjs.mergeMap((formData) => ajax({
|
||||
url: "/admin/api/config",
|
||||
method: "POST",
|
||||
responseType: "json",
|
||||
body: formData,
|
||||
}).pipe(rxjs.tap(() => isSaving$.next(false)))),
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,16 +1,19 @@
|
||||
import rxjs from "../../lib/rx.js";
|
||||
import ajax from "../../lib/ajax.js";
|
||||
|
||||
class LogManager {
|
||||
get(maxSize = 1000) {
|
||||
return ajax({
|
||||
url: `/admin/api/logs?maxSize=${maxSize}`,
|
||||
responseType: "text"
|
||||
const log$ = ajax({
|
||||
url: url(1024*100), // fetch the last 100kb by default
|
||||
responseType: "text",
|
||||
}).pipe(
|
||||
rxjs.map(({ response }) => response)
|
||||
// rxjs.repeat({ delay: 10000 }),
|
||||
rxjs.map(({ response }) => response),
|
||||
);
|
||||
}
|
||||
|
||||
export function url(logSize = null) {
|
||||
return "/admin/api/logs" + (logSize ? `?maxSize=${logSize}` : "");
|
||||
}
|
||||
|
||||
export default new LogManager();
|
||||
export function get() {
|
||||
return log$.pipe(
|
||||
rxjs.repeat({ delay: 10000 }),
|
||||
);
|
||||
}
|
||||
|
||||
36
public/pages/adminpage/model_setup.js
Normal file
@ -0,0 +1,36 @@
|
||||
import rxjs from "../../lib/rx.js";
|
||||
import { get as getAdminConfig } from "./model_config.js";
|
||||
import { formObjToJSON$ } from "./helper_form.js";
|
||||
|
||||
export function getDeps() {
|
||||
return getAdminConfig().pipe(
|
||||
formObjToJSON$(),
|
||||
rxjs.map(({ constant }) => ([
|
||||
{
|
||||
"name_success": "SSL is configured properly",
|
||||
"name_failure": "SSL is not configured properly",
|
||||
"pass": window.location.protocol !== "http:",
|
||||
"severe": true,
|
||||
"message": "This can lead to data leaks. Please use a SSL certificate",
|
||||
}, {
|
||||
"name_success": "Application is running as '" + constant.user.value + "'",
|
||||
"name_failure": "Application is running as root",
|
||||
"pass": constant.user !== "root",
|
||||
"severe": true,
|
||||
"message": "This is dangerous, you should use another user with less privileges",
|
||||
}, {
|
||||
"name_success": "Emacs is installed",
|
||||
"name_failure": "Emacs is not installed",
|
||||
"pass": !!constant.emacs,
|
||||
"severe": false,
|
||||
"message": "If you want to use all the org-mode features of Filestash, you need to install emacs",
|
||||
}, {
|
||||
"name_success": "Pdftotext is installed",
|
||||
"name_failure": "Pdftotext is not installed",
|
||||
"pass": !!constant.pdftotext,
|
||||
"severe": false,
|
||||
"message": "You won't be able to search through PDF documents without it",
|
||||
},
|
||||
])),
|
||||
);
|
||||
}
|
||||
@ -98,6 +98,9 @@ export default async function(render) {
|
||||
}
|
||||
return json;
|
||||
}),
|
||||
// rxjs.map((formData) => JSON.parse(JSON.stringify(a, (key, value) => {
|
||||
// if (value !== null) return value;
|
||||
// })),
|
||||
rxjs.mergeMap((creds) => createSession(creds)),
|
||||
rxjs.tap(() => navigate("/")),
|
||||
// TODO: error with notification
|
||||
|
||||
@ -9,7 +9,7 @@ import config$ from "./connectpage/model_config.js";
|
||||
import $fork from "./connectpage/component_forkme.js";
|
||||
import $poweredby from "./connectpage/component_poweredby.js";
|
||||
|
||||
export default function(render) {
|
||||
export default async function(render) {
|
||||
const $page = createElement(`
|
||||
<div class="component_page_connect">
|
||||
<div data-bind="component_forkme"></div>
|
||||
@ -17,7 +17,7 @@ export default function(render) {
|
||||
<div data-bind="component_form"></div>
|
||||
</div>
|
||||
<div data-bind="component_poweredby"></div>
|
||||
<style>${css}</style>
|
||||
<style>${await CSS(import.meta.url, "ctrl_connectpage.css")}</style>
|
||||
</div>
|
||||
`);
|
||||
render($page);
|
||||
@ -56,5 +56,3 @@ export default function(render) {
|
||||
applyMutation(qs($page, "[data-bind=\"centerthis\"]"), "style", "setProperty")
|
||||
));
|
||||
}
|
||||
|
||||
const css = await CSS(import.meta.url, "ctrl_connectpage.css");
|
||||
|
||||
@ -10,13 +10,12 @@ import "../components/icon.js";
|
||||
|
||||
export default function(err) {
|
||||
return async function(render) {
|
||||
const css = await CSS(import.meta.url, "ctrl_error.css");
|
||||
const [msg, trace] = processError(err);
|
||||
const $page = createElement(`
|
||||
<div>
|
||||
<style>${css}</style>
|
||||
<style>${await CSS(import.meta.url, "ctrl_error.css")}</style>
|
||||
<a href="/" class="backnav">
|
||||
<component-icon data-name="arrow_left"></component-icon>
|
||||
<component-icon name="arrow_left"></component-icon>
|
||||
home
|
||||
</a>
|
||||
<div class="component_container">
|
||||
@ -35,13 +34,13 @@ export default function(err) {
|
||||
render($page);
|
||||
|
||||
// feature: show error details
|
||||
effect(rxjs.fromEvent(qs($page, "button[data-bind=\"details\"]"), "click").pipe(
|
||||
effect(rxjs.fromEvent(qs($page, `button[data-bind="details"]`), "click").pipe(
|
||||
rxjs.mapTo(["hidden"]),
|
||||
applyMutation(qs($page, "pre"), "classList", "toggle")
|
||||
));
|
||||
|
||||
// feature: refresh button
|
||||
effect(rxjs.fromEvent(qs($page, "button[data-bind=\"refresh\"]"), "click").pipe(
|
||||
effect(rxjs.fromEvent(qs($page, `button[data-bind="refresh"]`), "click").pipe(
|
||||
rxjs.tap(() => location.reload())
|
||||
));
|
||||
|
||||
@ -52,11 +51,11 @@ export default function(err) {
|
||||
function processError(err) {
|
||||
let msg, trace;
|
||||
if (err instanceof AjaxError) {
|
||||
msg = t(err.code());
|
||||
msg = t(err.message);
|
||||
trace = `
|
||||
type: ${err.type()}
|
||||
message: ${err.message}
|
||||
code: ${err.code()}
|
||||
message: ${err.message}
|
||||
trace: ${err.stack}`;
|
||||
} else if (err instanceof ApplicationError) {
|
||||
msg = t(err.message);
|
||||
|
||||
@ -24,6 +24,9 @@ export default function(render) {
|
||||
if (is_authenticated !== true) return navigate("/login");
|
||||
return navigate(`/files${home}`);
|
||||
}),
|
||||
rxjs.catchError(() => navigate("/login"))
|
||||
rxjs.catchError(() => {
|
||||
navigate("/login");
|
||||
return rxjs.EMPTY;
|
||||
}),
|
||||
));
|
||||
};
|
||||
|
||||
@ -10,6 +10,6 @@ export default function(render) {
|
||||
|
||||
effect(deleteSession().pipe(
|
||||
rxjs.tap(() => navigate("/")),
|
||||
rxjs.catchError(ctrlError(render))
|
||||
rxjs.catchError(ctrlError(render)),
|
||||
));
|
||||
}
|
||||
|
||||
129
public/worker/sw_cache.js
Normal file
@ -0,0 +1,129 @@
|
||||
const CACHE_NAME = "v0.3";
|
||||
|
||||
/*
|
||||
* Control everything going through the wire, applying different
|
||||
* strategy for caching, fetching resources
|
||||
*/
|
||||
self.addEventListener("fetch", function(event) {
|
||||
const errResponse = (err) => (new Response(JSON.stringify({
|
||||
code: "CANNOT_LOAD",
|
||||
message: err.message
|
||||
}), { status: 502 }));
|
||||
|
||||
if (is_a_ressource(event.request)) {
|
||||
return event.respondWith(cacheFirstStrategy(event).catch(errResponse));
|
||||
} else if (is_an_api_call(event.request)) {
|
||||
return event;
|
||||
} else if (is_an_index(event.request)) {
|
||||
return event.respondWith(cacheFirstStrategy(event).catch(errResponse));
|
||||
} else {
|
||||
return event;
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
* When a new service worker is coming in, we need to do a bit of
|
||||
* cleanup to get rid of the rotten cache
|
||||
*/
|
||||
self.addEventListener("activate", function(event) {
|
||||
vacuum(event);
|
||||
});
|
||||
|
||||
self.addEventListener("error", function(err) {
|
||||
console.error(err);
|
||||
});
|
||||
|
||||
/*
|
||||
* When a newly installed service worker is coming in, we want to use it
|
||||
* straight away (make it active). By default it would be in a "waiting state"
|
||||
*/
|
||||
self.addEventListener("install", function() {
|
||||
caches.open(CACHE_NAME).then(function(cache) {
|
||||
return cache.addAll([
|
||||
"/",
|
||||
"/api/config"
|
||||
]);
|
||||
});
|
||||
|
||||
if (self.skipWaiting) {
|
||||
self.skipWaiting();
|
||||
}
|
||||
});
|
||||
|
||||
function is_a_ressource(request) {
|
||||
const p = _pathname(request);
|
||||
if (["assets", "manifest.json", "favicon.ico"].indexOf(p[0]) !== -1) {
|
||||
return true;
|
||||
} else if (p[0] === "api" && (p[1] === "config")) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function is_an_api_call(request) {
|
||||
return _pathname(request)[0] === "api";
|
||||
}
|
||||
function is_an_index(request) {
|
||||
return ["files", "view", "login", "logout", ""]
|
||||
.indexOf(_pathname(request)[0]) >= 0;
|
||||
}
|
||||
|
||||
// //////////////////////////////////////
|
||||
// HELPERS
|
||||
// //////////////////////////////////////
|
||||
|
||||
function vacuum(event) {
|
||||
return event.waitUntil(
|
||||
caches.keys().then(function(cachesName) {
|
||||
return Promise.all(cachesName.map(function(cacheName) {
|
||||
if (cacheName !== CACHE_NAME) {
|
||||
return caches.delete(cacheName);
|
||||
}
|
||||
return null;
|
||||
}));
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function _pathname(request) {
|
||||
return request.url.replace(/^http[s]?:\/\/[^\/]*\//, "").split("/");
|
||||
}
|
||||
|
||||
/*
|
||||
* strategy is cache first:
|
||||
* 1. use whatever is in the cache
|
||||
* 2. perform the network call to update the cache
|
||||
*/
|
||||
function cacheFirstStrategy(event) {
|
||||
return caches.open(CACHE_NAME).then(function(cache) {
|
||||
return cache.match(event.request).then(function(response) {
|
||||
if (!response) {
|
||||
return fetchAndCache(event);
|
||||
}
|
||||
fetchAndCache(event).catch(nil);
|
||||
return response;
|
||||
});
|
||||
});
|
||||
|
||||
function fetchAndCache(event) {
|
||||
// A request is a stream and can only be consumed once. Since we are consuming this
|
||||
// once by cache and once by the browser for fetch, we need to clone the response as
|
||||
// seen on:
|
||||
// https://developers.google.com/web/fundamentals/getting-started/primers/service-workers
|
||||
return fetch(event.request)
|
||||
.then(function(response) {
|
||||
if (!response || response.status !== 200) {
|
||||
return response;
|
||||
}
|
||||
|
||||
// A response is a stream and can only because we want the browser to consume the
|
||||
// response as well as the cache consuming the response, we need to clone it
|
||||
const responseClone = response.clone();
|
||||
caches.open(CACHE_NAME).then(function(cache) {
|
||||
cache.put(event.request, responseClone);
|
||||
});
|
||||
return response;
|
||||
});
|
||||
}
|
||||
function nil() {}
|
||||
}
|
||||
@ -4,15 +4,17 @@ import (
|
||||
"embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
. "github.com/mickael-kerjean/filestash/server/common"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
URL "net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
. "github.com/mickael-kerjean/filestash"
|
||||
. "github.com/mickael-kerjean/filestash/server/common"
|
||||
)
|
||||
|
||||
var (
|
||||
@ -28,25 +30,19 @@ func init() {
|
||||
WWWDir = os.DirFS(GetAbsolutePath("../"))
|
||||
}
|
||||
|
||||
func StaticHandler(_path string) func(*App, http.ResponseWriter, *http.Request) {
|
||||
func LegacyStaticHandler(_path string) func(*App, http.ResponseWriter, *http.Request) { // TODO: migrate away
|
||||
return func(ctx *App, res http.ResponseWriter, req *http.Request) {
|
||||
var chroot string = GetAbsolutePath(_path)
|
||||
if srcPath := JoinPath(chroot, req.URL.Path); strings.HasPrefix(srcPath, chroot) == false {
|
||||
http.NotFound(res, req)
|
||||
return
|
||||
}
|
||||
ServeFile(res, req, JoinPath(_path, req.URL.Path))
|
||||
LegacyServeFile(res, req, JoinPath(_path, req.URL.Path))
|
||||
}
|
||||
}
|
||||
|
||||
func IndexHandler(ctx *App, res http.ResponseWriter, req *http.Request) {
|
||||
urlObj, err := URL.Parse(req.URL.String())
|
||||
if err != nil {
|
||||
NotFoundHandler(ctx, res, req)
|
||||
return
|
||||
}
|
||||
url := urlObj.Path
|
||||
|
||||
func LegacyIndexHandler(ctx *App, res http.ResponseWriter, req *http.Request) {
|
||||
url := req.URL.Path
|
||||
if url != URL_SETUP && Config.Get("auth.admin").String() == "" {
|
||||
http.Redirect(res, req, URL_SETUP, http.StatusTemporaryRedirect)
|
||||
return
|
||||
@ -72,7 +68,74 @@ func IndexHandler(ctx *App, res http.ResponseWriter, req *http.Request) {
|
||||
`)))
|
||||
return
|
||||
}
|
||||
ServeFile(res, req, "/index.html")
|
||||
LegacyServeFile(res, req, "/index.html")
|
||||
}
|
||||
|
||||
func LegacyServeFile(res http.ResponseWriter, req *http.Request, filePath string) { // TODO: migrate away
|
||||
staticConfig := []struct {
|
||||
ContentType string
|
||||
FileExt string
|
||||
}{
|
||||
{"br", ".br"},
|
||||
{"gzip", ".gz"},
|
||||
{"", ""},
|
||||
}
|
||||
|
||||
statusCode := 200
|
||||
if req.URL.Path == "/" {
|
||||
if errName := req.URL.Query().Get("error"); errName != "" {
|
||||
statusCode = HTTPError(errors.New(errName)).Status()
|
||||
}
|
||||
}
|
||||
|
||||
head := res.Header()
|
||||
acceptEncoding := req.Header.Get("Accept-Encoding")
|
||||
for _, cfg := range staticConfig {
|
||||
if strings.Contains(acceptEncoding, cfg.ContentType) == false {
|
||||
continue
|
||||
}
|
||||
curPath := filePath + cfg.FileExt
|
||||
file, err := WWWEmbed.Open("static/www" + curPath)
|
||||
if env := os.Getenv("DEBUG"); env == "true" {
|
||||
//file, err = WWWDir.Open("server/ctrl/static/www" + curPath)
|
||||
file, err = WWWDir.Open("public" + curPath)
|
||||
}
|
||||
if err != nil {
|
||||
continue
|
||||
} else if stat, err := file.Stat(); err == nil {
|
||||
etag := QuickHash(fmt.Sprintf(
|
||||
"%s %d %d %s",
|
||||
curPath, stat.Size(), stat.Mode(), stat.ModTime()), 10,
|
||||
)
|
||||
if etag == req.Header.Get("If-None-Match") {
|
||||
res.WriteHeader(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
head.Set("Etag", etag)
|
||||
}
|
||||
if cfg.ContentType != "" {
|
||||
head.Set("Content-Encoding", cfg.ContentType)
|
||||
}
|
||||
res.WriteHeader(statusCode)
|
||||
io.Copy(res, file)
|
||||
file.Close()
|
||||
return
|
||||
}
|
||||
http.NotFound(res, req)
|
||||
}
|
||||
|
||||
func ServeBackofficeHandler(ctx *App, res http.ResponseWriter, req *http.Request) {
|
||||
url := req.URL.Path
|
||||
if url != URL_SETUP && Config.Get("auth.admin").String() == "" {
|
||||
http.Redirect(res, req, URL_SETUP, http.StatusTemporaryRedirect)
|
||||
return
|
||||
}
|
||||
|
||||
if filepath.Ext(filepath.Base(url)) == "" {
|
||||
ServeFile(res, req, WWWPublic, "index.backoffice.html")
|
||||
return
|
||||
}
|
||||
ServeFile(res, req, WWWPublic, strings.TrimPrefix(req.URL.Path, "/admin/"))
|
||||
}
|
||||
|
||||
func NotFoundHandler(ctx *App, res http.ResponseWriter, req *http.Request) {
|
||||
@ -202,7 +265,7 @@ func CustomCssHandler(ctx *App, res http.ResponseWriter, req *http.Request) {
|
||||
io.WriteString(res, Config.Get("general.custom_css").String())
|
||||
}
|
||||
|
||||
func ServeFile(res http.ResponseWriter, req *http.Request, filePath string) {
|
||||
func ServeFile(res http.ResponseWriter, req *http.Request, fs http.FileSystem, filePath string) {
|
||||
staticConfig := []struct {
|
||||
ContentType string
|
||||
FileExt string
|
||||
@ -212,13 +275,6 @@ func ServeFile(res http.ResponseWriter, req *http.Request, filePath string) {
|
||||
{"", ""},
|
||||
}
|
||||
|
||||
statusCode := 200
|
||||
if req.URL.Path == "/" {
|
||||
if errName := req.URL.Query().Get("error"); errName != "" {
|
||||
statusCode = HTTPError(errors.New(errName)).Status()
|
||||
}
|
||||
}
|
||||
|
||||
head := res.Header()
|
||||
acceptEncoding := req.Header.Get("Accept-Encoding")
|
||||
for _, cfg := range staticConfig {
|
||||
@ -226,10 +282,7 @@ func ServeFile(res http.ResponseWriter, req *http.Request, filePath string) {
|
||||
continue
|
||||
}
|
||||
curPath := filePath + cfg.FileExt
|
||||
file, err := WWWEmbed.Open("static/www" + curPath)
|
||||
if env := os.Getenv("NODE_ENV"); env == "development" {
|
||||
file, err = WWWDir.Open("server/ctrl/static/www" + curPath)
|
||||
}
|
||||
file, err := fs.Open(curPath)
|
||||
if err != nil {
|
||||
continue
|
||||
} else if stat, err := file.Stat(); err == nil {
|
||||
@ -243,10 +296,11 @@ func ServeFile(res http.ResponseWriter, req *http.Request, filePath string) {
|
||||
}
|
||||
head.Set("Etag", etag)
|
||||
}
|
||||
head.Set("Content-Type", GetMimeType(filepath.Ext(filePath)))
|
||||
if cfg.ContentType != "" {
|
||||
head.Set("Content-Encoding", cfg.ContentType)
|
||||
}
|
||||
res.WriteHeader(statusCode)
|
||||
res.WriteHeader(http.StatusOK)
|
||||
io.Copy(res, file)
|
||||
file.Close()
|
||||
return
|
||||
|
||||
@ -63,7 +63,7 @@ func IndexHeaders(fn func(*App, http.ResponseWriter, *http.Request)) func(ctx *A
|
||||
} else {
|
||||
cspHeader += fmt.Sprintf("frame-ancestors %s;", ori)
|
||||
}
|
||||
header.Set("Content-Security-Policy", cspHeader)
|
||||
// header.Set("Content-Security-Policy", cspHeader)
|
||||
fn(ctx, res, req)
|
||||
}
|
||||
}
|
||||
|
||||
@ -25,7 +25,6 @@ func (this Admin) Setup() Form {
|
||||
Type: "select",
|
||||
Value: "direct",
|
||||
Opts: []string{"direct", "password_only", "username_and_password"},
|
||||
Id: "strategy",
|
||||
Description: `This plugin has 3 base strategies:
|
||||
1. The 'direct' strategy will redirect the user to your storage without asking for anything and use whatever is configured in the attribute mapping section.
|
||||
2. The 'password_only' strategy will redirect the user to a page asking for a password which you can map to a field in the attribute mapping section like this: {{ .password }}
|
||||
|
||||
133
server/plugin/plg_image_c/image_jpeg.c
Normal file
@ -0,0 +1,133 @@
|
||||
#include <stdio.h>
|
||||
#include "utils.h"
|
||||
#include "jpeglib.h"
|
||||
#include <setjmp.h>
|
||||
|
||||
|
||||
#define JPEG_QUALITY 50
|
||||
|
||||
struct filestash_error_mgr {
|
||||
struct jpeg_error_mgr pub;
|
||||
jmp_buf jmp;
|
||||
};
|
||||
|
||||
typedef struct filestash_error_mgr *filestash_error_ptr;
|
||||
|
||||
void my_error_exit (j_common_ptr cinfo) {
|
||||
filestash_error_ptr filestash_err = (filestash_error_ptr) cinfo->err;
|
||||
longjmp(filestash_err->jmp, 1);
|
||||
}
|
||||
|
||||
int jpeg_to_jpeg(FILE* input, FILE* output, int targetSize) {
|
||||
#ifdef HAS_DEBUG
|
||||
clock_t t;
|
||||
t = clock();
|
||||
#endif
|
||||
|
||||
struct jpeg_decompress_struct jpeg_config_input;
|
||||
struct jpeg_compress_struct jpeg_config_output;
|
||||
struct filestash_error_mgr jerr;
|
||||
int jpeg_row_stride;
|
||||
int image_min_size;
|
||||
JSAMPARRAY buffer;
|
||||
|
||||
jpeg_config_input.err = jpeg_std_error(&jerr.pub);
|
||||
jpeg_config_output.err = jpeg_std_error(&jerr.pub);
|
||||
jpeg_config_input.dct_method = JDCT_IFAST;
|
||||
jpeg_config_input.do_fancy_upsampling = FALSE;
|
||||
jpeg_config_input.two_pass_quantize = FALSE;
|
||||
jpeg_config_input.dither_mode = JDITHER_ORDERED;
|
||||
|
||||
jpeg_create_decompress(&jpeg_config_input);
|
||||
jpeg_create_compress(&jpeg_config_output);
|
||||
jpeg_stdio_src(&jpeg_config_input, input);
|
||||
jpeg_stdio_dest(&jpeg_config_output, output);
|
||||
|
||||
jerr.pub.error_exit = my_error_exit;
|
||||
if (setjmp(jerr.jmp)) {
|
||||
jpeg_destroy_decompress(&jpeg_config_input);
|
||||
return 0;
|
||||
}
|
||||
|
||||
DEBUG("after constructor decompress");
|
||||
if(jpeg_read_header(&jpeg_config_input, TRUE) != JPEG_HEADER_OK) {
|
||||
jpeg_destroy_decompress(&jpeg_config_input);
|
||||
return 1;
|
||||
}
|
||||
DEBUG("after header read");
|
||||
jpeg_config_input.dct_method = JDCT_IFAST;
|
||||
jpeg_config_input.do_fancy_upsampling = FALSE;
|
||||
jpeg_config_input.two_pass_quantize = FALSE;
|
||||
jpeg_config_input.dither_mode = JDITHER_ORDERED;
|
||||
jpeg_calc_output_dimensions(&jpeg_config_input);
|
||||
|
||||
image_min_size = min(jpeg_config_input.output_width, jpeg_config_input.output_height);
|
||||
jpeg_config_input.scale_num = 1;
|
||||
jpeg_config_input.scale_denom = 1;
|
||||
if (image_min_size / 8 >= targetSize) {
|
||||
jpeg_config_input.scale_num = 1;
|
||||
jpeg_config_input.scale_denom = 8;
|
||||
} else if (image_min_size * 2 / 8 >= targetSize) {
|
||||
jpeg_config_input.scale_num = 1;
|
||||
jpeg_config_input.scale_denom = 4;
|
||||
} else if (image_min_size * 3 / 8 >= targetSize) {
|
||||
jpeg_config_input.scale_num = 3;
|
||||
jpeg_config_input.scale_denom = 8;
|
||||
} else if (image_min_size * 4 / 8 >= targetSize) {
|
||||
jpeg_config_input.scale_num = 4;
|
||||
jpeg_config_input.scale_denom = 8;
|
||||
} else if (image_min_size * 5 / 8 >= targetSize) {
|
||||
jpeg_config_input.scale_num = 5;
|
||||
jpeg_config_input.scale_denom = 8;
|
||||
} else if (image_min_size * 6 / 8 >= targetSize) {
|
||||
jpeg_config_input.scale_num = 6;
|
||||
jpeg_config_input.scale_denom = 8;
|
||||
} else if (image_min_size * 7 / 8 >= targetSize) {
|
||||
jpeg_config_input.scale_num = 7;
|
||||
jpeg_config_input.scale_denom = 8;
|
||||
}
|
||||
|
||||
DEBUG("start decompress");
|
||||
if(jpeg_start_decompress(&jpeg_config_input) == FALSE) {
|
||||
jpeg_destroy_decompress(&jpeg_config_input);
|
||||
return 1;
|
||||
}
|
||||
DEBUG("processing image setup");
|
||||
jpeg_row_stride = jpeg_config_input.output_width * jpeg_config_input.output_components;
|
||||
jpeg_config_output.image_width = jpeg_config_input.output_width;
|
||||
jpeg_config_output.image_height = jpeg_config_input.output_height;
|
||||
jpeg_config_output.input_components = jpeg_config_input.num_components;
|
||||
jpeg_config_output.in_color_space = JCS_RGB;
|
||||
jpeg_set_defaults(&jpeg_config_output);
|
||||
jpeg_set_quality(&jpeg_config_output, JPEG_QUALITY, TRUE);
|
||||
jpeg_start_compress(&jpeg_config_output, TRUE);
|
||||
buffer = (*jpeg_config_input.mem->alloc_sarray) ((j_common_ptr) &jpeg_config_input, JPOOL_IMAGE, jpeg_row_stride, 1);
|
||||
DEBUG("processing image");
|
||||
while (jpeg_config_input.output_scanline < jpeg_config_input.output_height) {
|
||||
jpeg_read_scanlines(&jpeg_config_input, buffer, 1);
|
||||
jpeg_write_scanlines(&jpeg_config_output, buffer, 1);
|
||||
}
|
||||
DEBUG("end decompress");
|
||||
jpeg_finish_decompress(&jpeg_config_input);
|
||||
jpeg_destroy_decompress(&jpeg_config_input);
|
||||
DEBUG("finish decompress");
|
||||
jpeg_finish_compress(&jpeg_config_output);
|
||||
DEBUG("final");
|
||||
return 0;
|
||||
}
|
||||
|
||||
void jpeg_size(FILE* infile, int* height, int* width) {
|
||||
struct jpeg_decompress_struct cinfo;
|
||||
struct jpeg_error_mgr jerr;
|
||||
cinfo.err = jpeg_std_error(&jerr);
|
||||
|
||||
jpeg_create_decompress(&cinfo);
|
||||
jpeg_stdio_src(&cinfo, infile);
|
||||
jpeg_read_header(&cinfo, TRUE);
|
||||
jpeg_start_decompress(&cinfo);
|
||||
|
||||
*width = cinfo.image_width;
|
||||
*height = cinfo.image_height;
|
||||
|
||||
jpeg_destroy_decompress(&cinfo);
|
||||
}
|
||||
39
server/plugin/plg_image_c/image_jpeg.go
Normal file
@ -0,0 +1,39 @@
|
||||
package plg_image_c
|
||||
|
||||
// #include "image_jpeg.h"
|
||||
// #cgo LDFLAGS: -l:libjpeg.a
|
||||
import "C"
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
func JpegToJpeg(input io.ReadCloser) (io.ReadCloser, error) {
|
||||
read, write, err := os.Pipe()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
go func() {
|
||||
cRead, cWrite, err := os.Pipe()
|
||||
if err != nil {
|
||||
fmt.Printf("ERR %+v\n", err)
|
||||
}
|
||||
go func() {
|
||||
defer cWrite.Close()
|
||||
io.Copy(cWrite, input)
|
||||
}()
|
||||
cInput := C.fdopen(C.int(cRead.Fd()), C.CString("r"))
|
||||
cOutput := C.fdopen(C.int(write.Fd()), C.CString("w"))
|
||||
|
||||
C.jpeg_to_jpeg(cInput, cOutput, 200)
|
||||
|
||||
cWrite.Close()
|
||||
write.Close()
|
||||
cRead.Close()
|
||||
}()
|
||||
|
||||
return read, nil
|
||||
}
|
||||
7
server/plugin/plg_image_c/image_jpeg.h
Normal file
@ -0,0 +1,7 @@
|
||||
#include <stdio.h>
|
||||
#include "jpeglib.h"
|
||||
#include "utils.h"
|
||||
|
||||
void jpeg_size(FILE* infile, int* height, int* width);
|
||||
|
||||
int jpeg_to_jpeg(FILE* input, FILE* output, int targetSize);
|
||||
143
server/plugin/plg_image_c/image_png.c
Normal file
@ -0,0 +1,143 @@
|
||||
#include <string.h>
|
||||
#include <png.h>
|
||||
#include <stdlib.h>
|
||||
#include "webp/encode.h"
|
||||
#include "utils.h"
|
||||
|
||||
static int MyWriter(const uint8_t* data, size_t data_size, const WebPPicture* const pic) {
|
||||
FILE* const out = (FILE*)pic->custom_ptr;
|
||||
return data_size ? (fwrite(data, data_size, 1, out) == 1) : 1;
|
||||
}
|
||||
|
||||
int png_to_webp(FILE* input, FILE* output, int targetSize) {
|
||||
WebPPicture picture;
|
||||
|
||||
#ifdef HAS_DEBUG
|
||||
clock_t t;
|
||||
t = clock();
|
||||
#endif
|
||||
png_image image;
|
||||
memset(&image, 0, sizeof image);
|
||||
image.version = PNG_IMAGE_VERSION;
|
||||
DEBUG("reading png");
|
||||
if (!png_image_begin_read_from_stdio(&image, input)) {
|
||||
ERROR("png_image_begin_read_from_stdio");
|
||||
return 1;
|
||||
}
|
||||
DEBUG("allocate");
|
||||
png_bytep buffer;
|
||||
image.format = PNG_FORMAT_RGBA;
|
||||
buffer = malloc(PNG_IMAGE_SIZE(image));
|
||||
if (buffer == NULL) {
|
||||
ERROR("png_malloc");
|
||||
png_image_free(&image);
|
||||
return 1;
|
||||
}
|
||||
DEBUG("start reading");
|
||||
if (!png_image_finish_read(&image, NULL, buffer, 0, NULL)) {
|
||||
ERROR("png_image_finish_read");
|
||||
png_image_free(&image);
|
||||
free(buffer);
|
||||
return 1;
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////
|
||||
// encode to webp
|
||||
DEBUG("start encoding");
|
||||
if (!WebPPictureInit(&picture)) {
|
||||
ERROR("WebPPictureInit");
|
||||
png_image_free(&image);
|
||||
free(buffer);
|
||||
return 1;
|
||||
}
|
||||
picture.width = image.width;
|
||||
picture.height = image.height;
|
||||
if(!WebPPictureAlloc(&picture)) {
|
||||
ERROR("WebPPictureAlloc");
|
||||
png_image_free(&image);
|
||||
free(buffer);
|
||||
return 1;
|
||||
}
|
||||
DEBUG("start encoding import");
|
||||
WebPPictureImportRGBA(&picture, buffer, PNG_IMAGE_ROW_STRIDE(image));
|
||||
png_image_free(&image);
|
||||
free(buffer);
|
||||
|
||||
WebPConfig webp_config_output;
|
||||
picture.writer = MyWriter;
|
||||
picture.custom_ptr = output;
|
||||
DEBUG("start encoding config init");
|
||||
if (!WebPConfigInit(&webp_config_output)) {
|
||||
ERROR("ERR config init");
|
||||
WebPPictureFree(&picture);
|
||||
return 1;
|
||||
}
|
||||
webp_config_output.method = 0;
|
||||
webp_config_output.quality = 30;
|
||||
if (!WebPValidateConfig(&webp_config_output)) {
|
||||
ERROR("ERR WEB VALIDATION");
|
||||
WebPPictureFree(&picture);
|
||||
return 1;
|
||||
}
|
||||
DEBUG("rescale start");
|
||||
if (image.width > targetSize && image.height > targetSize) {
|
||||
float ratioHeight = (float) image.height / targetSize;
|
||||
float ratioWidth = (float) image.width / targetSize;
|
||||
float ratio = ratioWidth > ratioHeight ? ratioHeight : ratioWidth;
|
||||
if (!WebPPictureRescale(&picture, image.width / ratio, image.height / ratio)) {
|
||||
DEBUG("ERR Rescale");
|
||||
WebPPictureFree(&picture);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
DEBUG("encoder start");
|
||||
WebPEncode(&webp_config_output, &picture);
|
||||
DEBUG("encoder done");
|
||||
WebPPictureFree(&picture);
|
||||
DEBUG("cleaning up");
|
||||
return 0;
|
||||
}
|
||||
|
||||
int png_to_png(FILE* input, FILE* output, int targetSize) {
|
||||
#ifdef HAS_DEBUG
|
||||
clock_t t;
|
||||
t = clock();
|
||||
#endif
|
||||
png_image image;
|
||||
memset(&image, 0, sizeof image);
|
||||
image.version = PNG_IMAGE_VERSION;
|
||||
DEBUG("> reading png");
|
||||
if (!png_image_begin_read_from_stdio(&image, input)) {
|
||||
DEBUG("png_image_begin_read_from_stdio");
|
||||
return 1;
|
||||
}
|
||||
DEBUG("> allocate");
|
||||
png_bytep buffer;
|
||||
image.format = PNG_FORMAT_RGBA;
|
||||
buffer = malloc(PNG_IMAGE_SIZE(image));
|
||||
if (buffer == NULL) {
|
||||
DEBUG("png_malloc");
|
||||
png_image_free(&image);
|
||||
return 1;
|
||||
}
|
||||
DEBUG("> start reading");
|
||||
if (!png_image_finish_read(&image, NULL, buffer, 0, NULL)) {
|
||||
DEBUG("png_image_finish_read");
|
||||
png_image_free(&image);
|
||||
free(buffer);
|
||||
return 1;
|
||||
}
|
||||
|
||||
DEBUG("> write");
|
||||
if (!png_image_write_to_stdio(&image, output, 0, buffer, 0, NULL)) {
|
||||
DEBUG("png_image_write_to_stdio");
|
||||
png_image_free(&image);
|
||||
free(buffer);
|
||||
return 1;
|
||||
}
|
||||
|
||||
DEBUG("> end");
|
||||
png_image_free(&image);
|
||||
free(buffer);
|
||||
return 0;
|
||||
}
|
||||
41
server/plugin/plg_image_c/image_png.go
Normal file
@ -0,0 +1,41 @@
|
||||
package plg_image_c
|
||||
|
||||
// #include "image_png.h"
|
||||
// #cgo LDFLAGS: -l:libpng.a -l:libz.a -l:libwebp.a -lpthread -lm
|
||||
import "C"
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
func PngToWebp(input io.ReadCloser) (io.ReadCloser, error) {
|
||||
read, write, err := os.Pipe()
|
||||
if err != nil {
|
||||
fmt.Printf("OS PIPE ERR %+v\n", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
go func() {
|
||||
cRead, cWrite, err := os.Pipe()
|
||||
if err != nil {
|
||||
fmt.Printf("ERR %+v\n", err)
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
defer cWrite.Close()
|
||||
io.Copy(cWrite, input)
|
||||
}()
|
||||
cInput := C.fdopen(C.int(cRead.Fd()), C.CString("r"))
|
||||
cOutput := C.fdopen(C.int(write.Fd()), C.CString("w"))
|
||||
|
||||
C.png_to_webp(cInput, cOutput, 300)
|
||||
|
||||
cWrite.Close()
|
||||
write.Close()
|
||||
cRead.Close()
|
||||
}()
|
||||
|
||||
return read, nil
|
||||
}
|
||||
6
server/plugin/plg_image_c/image_png.h
Normal file
@ -0,0 +1,6 @@
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
int png_to_webp(FILE* input, FILE* output, int targetSize);
|
||||
|
||||
int png_to_png(FILE* input, FILE* output, int targetSize);
|
||||
26
server/plugin/plg_image_c/index.go
Normal file
@ -0,0 +1,26 @@
|
||||
package plg_image_c
|
||||
|
||||
import (
|
||||
. "github.com/mickael-kerjean/filestash/server/common"
|
||||
"io"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func init() {
|
||||
Hooks.Register.Thumbnailer("image/jpeg", thumbnailer{JpegToJpeg, "image/jpeg"})
|
||||
Hooks.Register.Thumbnailer("image/png", thumbnailer{PngToWebp, "image/webp"})
|
||||
// Hooks.Register.Thumbnailer("image/png", thumbnailer{PngToWebp, "image/webp"})
|
||||
}
|
||||
|
||||
type thumbnailer struct {
|
||||
fn func(input io.ReadCloser) (io.ReadCloser, error)
|
||||
mime string
|
||||
}
|
||||
|
||||
func (this thumbnailer) Generate(reader io.ReadCloser, ctx *App, res *http.ResponseWriter, req *http.Request) (io.ReadCloser, error) {
|
||||
thumb, err := this.fn(reader)
|
||||
if err == nil && this.mime != "" {
|
||||
(*res).Header().Set("Content-Type", this.mime)
|
||||
}
|
||||
return thumb, err
|
||||
}
|
||||
11
server/plugin/plg_image_c/utils.h
Normal file
@ -0,0 +1,11 @@
|
||||
#define HAS_DEBUG 1
|
||||
#include <time.h>
|
||||
#if HAS_DEBUG == 1
|
||||
#define DEBUG(r) (fprintf(stderr, "[DEBUG::('" r "')(%.2Fms)]", ((double)clock() - t)/CLOCKS_PER_SEC * 1000))
|
||||
#else
|
||||
#define DEBUG(r) ((void)0)
|
||||
#endif
|
||||
|
||||
#define ERROR(r) (fprintf(stderr, "[ERROR:('" r "')]"))
|
||||
|
||||
#define min(a, b) (a > b ? b : a)
|
||||
@ -78,7 +78,7 @@ func Build(a App) *mux.Router {
|
||||
|
||||
// Webdav server / Shared Link
|
||||
middlewares = []Middleware{IndexHeaders, SecureHeaders}
|
||||
r.HandleFunc("/s/{share}", NewMiddlewareChain(IndexHandler, middlewares, a)).Methods("GET")
|
||||
r.HandleFunc("/s/{share}", NewMiddlewareChain(LegacyIndexHandler, middlewares, a)).Methods("GET")
|
||||
middlewares = []Middleware{WebdavBlacklist, SessionStart}
|
||||
r.PathPrefix("/s/{share}").Handler(NewMiddlewareChain(WebdavHandler, middlewares, a))
|
||||
middlewares = []Middleware{ApiHeaders, SecureHeaders, RedirectSharedLoginIfNeeded, SessionStart, LoggedInOnly}
|
||||
@ -89,9 +89,9 @@ func Build(a App) *mux.Router {
|
||||
r.HandleFunc("/api/config", NewMiddlewareChain(PublicConfigHandler, middlewares, a)).Methods("GET")
|
||||
r.HandleFunc("/api/backend", NewMiddlewareChain(AdminBackend, middlewares, a)).Methods("GET")
|
||||
middlewares = []Middleware{StaticHeaders, SecureHeaders}
|
||||
r.PathPrefix("/assets").Handler(http.HandlerFunc(NewMiddlewareChain(StaticHandler("/"), middlewares, a))).Methods("GET")
|
||||
r.HandleFunc("/favicon.ico", NewMiddlewareChain(StaticHandler("/assets/logo/"), middlewares, a)).Methods("GET")
|
||||
r.HandleFunc("/sw_cache.js", NewMiddlewareChain(StaticHandler("/assets/worker/"), middlewares, a)).Methods("GET")
|
||||
r.PathPrefix("/assets").Handler(http.HandlerFunc(NewMiddlewareChain(LegacyStaticHandler("/"), middlewares, a))).Methods("GET")
|
||||
r.HandleFunc("/favicon.ico", NewMiddlewareChain(LegacyStaticHandler("/assets/logo/"), middlewares, a)).Methods("GET")
|
||||
r.HandleFunc("/sw_cache.js", NewMiddlewareChain(LegacyStaticHandler("/assets/worker/"), middlewares, a)).Methods("GET")
|
||||
|
||||
// Other endpoints
|
||||
middlewares = []Middleware{ApiHeaders}
|
||||
@ -110,8 +110,10 @@ func Build(a App) *mux.Router {
|
||||
}
|
||||
initPluginsRoutes(r, &a)
|
||||
|
||||
r.PathPrefix("/admin").Handler(http.HandlerFunc(NewMiddlewareChain(IndexHandler, middlewares, a))).Methods("GET")
|
||||
r.PathPrefix("/").Handler(http.HandlerFunc(NewMiddlewareChain(IndexHandler, middlewares, a))).Methods("GET", "POST")
|
||||
middlewares = []Middleware{SecureHeaders}
|
||||
r.PathPrefix("/admin").Handler(http.HandlerFunc(NewMiddlewareChain(ServeBackofficeHandler, middlewares, a))).Methods("GET")
|
||||
middlewares = []Middleware{IndexHeaders, SecureHeaders}
|
||||
r.PathPrefix("/").Handler(http.HandlerFunc(NewMiddlewareChain(LegacyIndexHandler, middlewares, a))).Methods("GET", "POST")
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||