release (migration): migration of admin interface

This commit is contained in:
Mickael Kerjean 2023-10-07 22:47:37 +11:00
parent 8a4bb24a2d
commit d9202c7f15
91 changed files with 3562 additions and 702 deletions

4
.gitignore vendored
View file

@ -18,3 +18,7 @@ package-lock.json
*_test.go *_test.go
cover.* cover.*
www www
*.test.js
__snapshots__
.gitignore
filestash-enterprise

View file

@ -10,7 +10,7 @@ build_frontend:
NODE_ENV=production npm run build NODE_ENV=production npm run build
build_backend: 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: clean_frontend:
rm -rf server/ctrl/static/www/ rm -rf server/ctrl/static/www/

View file

@ -154,7 +154,6 @@ function AuditComponent() {
}); });
return () => ctrl.abort(); return () => ctrl.abort();
}, [debouncedSearchParams]); }, [debouncedSearchParams]);
return ( return (
<div className="component_audit"> <div className="component_audit">
{ {

View file

@ -1,21 +1,18 @@
package main package main
import ( import (
_ "embed"
"os" "os"
"sync" "sync"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/mickael-kerjean/filestash"
. "github.com/mickael-kerjean/filestash/server" . "github.com/mickael-kerjean/filestash/server"
. "github.com/mickael-kerjean/filestash/server/common" . "github.com/mickael-kerjean/filestash/server/common"
. "github.com/mickael-kerjean/filestash/server/ctrl" . "github.com/mickael-kerjean/filestash/server/ctrl"
_ "github.com/mickael-kerjean/filestash/server/plugin" _ "github.com/mickael-kerjean/filestash/server/plugin"
) )
//go:embed server/plugin/index.go
var EmbedPluginList []byte
func main() { func main() {
start(Build(App{})) start(Build(App{}))
} }
@ -39,7 +36,7 @@ func start(routes *mux.Router) {
}() }()
} }
go func() { go func() {
InitPluginList(EmbedPluginList) InitPluginList(embed.EmbedPluginList)
for _, fn := range Hooks.Get.Onload() { for _, fn := range Hooks.Get.Onload() {
go fn() go fn()
} }

24
embed.go Normal file
View 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)
}
}

View 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;
}

View file

@ -18,10 +18,6 @@
font-size: 1em; font-size: 1em;
padding: 0 15px; padding: 0 15px;
} }
.formbuilder img {
max-height: 110px;
border: 8px solid rgba(0, 0, 0, 0);
}
.formbuilder .fileupload-image img { .formbuilder .fileupload-image img {
height: 150px; height: 150px;
width: 100%; width: 100%;

View file

@ -1,4 +1,5 @@
.component_skeleton { .component_skeleton {
width: 100%;
height: 30px; 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%); 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; border-radius: 5px;

View file

@ -10,40 +10,41 @@
@import url("./designsystem_darkmode.css"); @import url("./designsystem_darkmode.css");
@import url("./designsystem_skeleton.css"); @import url("./designsystem_skeleton.css");
@import url("./designsystem_utils.css"); @import url("./designsystem_utils.css");
@import url("./designsystem_alert.css");
/* latin-ext */ /* latin-ext */
@font-face { @font-face {
font-family: 'Source Code Pro'; font-family: "Source Code Pro";
font-style: normal; font-style: normal;
font-weight: 400; 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; 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 */ /* latin */
@font-face { @font-face {
font-family: 'Source Code Pro'; font-family: "Source Code Pro";
font-style: normal; font-style: normal;
font-weight: 400; 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; 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 */ /* latin-ext */
@font-face { @font-face {
font-family: 'Source Code Pro'; font-family: "Source Code Pro";
font-style: normal; font-style: normal;
font-weight: 600; 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; 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 */ /* latin */
@font-face { @font-face {
font-family: 'Source Code Pro'; font-family: "Source Code Pro";
font-style: normal; font-style: normal;
font-weight: 600; 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; 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;
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 395 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 604 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View 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

View file

@ -1,5 +1,5 @@
import rxjs, { ajax } from "../lib/rx.js"; 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 { report } from "../helpers/log.js";
import { $error } from "./common.js"; import { $error } from "./common.js";
@ -9,6 +9,7 @@ export default async function main() {
setup_device(), setup_device(),
setup_blue_death_screen(), setup_blue_death_screen(),
setup_history(), setup_history(),
setup_css(),
]); ]);
window.dispatchEvent(new window.Event("pagechange")); window.dispatchEvent(new window.Event("pagechange"));
} catch (err) { } catch (err) {
@ -42,3 +43,7 @@ async function setup_blue_death_screen() {
async function setup_history() { async function setup_history() {
window.history.replaceState({}, ""); window.history.replaceState({}, "");
} }
async function setup_css() {
return initCSS()
}

View 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;

View 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;

View file

@ -57,34 +57,60 @@ function $renderInput(options = {}) {
} = props; } = props;
let attr = `name="${path.join(".")}" `; let attr = `name="${path.join(".")}" `;
if (id) attr += `id="${id}" `; if (id) attr += `id="${safe(id)}" `;
if (placeholder) attr += `placeholder="${safe(placeholder, "\"")}" `; if (placeholder) attr += `placeholder="${safe(placeholder)}" `;
if (!autocomplete) attr += "autocomplete=\"off\" autocorrect=\"off\" autocapitalize=\"off\" spellcheck=\"off\" "; if (!autocomplete || props.autocomplete === false) attr += "autocomplete=\"off\" autocorrect=\"off\" autocapitalize=\"off\" spellcheck=\"off\" ";
if (required) attr += "required "; if (required) attr += "required ";
if (readonly) attr += "readonly "; if (readonly) attr += "readonly ";
switch (type) { switch (type) {
case "text": // TODO case "text":
const dataListId = gid("list_"); if (!datalist) return createElement(`
const $input = createElement(` <input ${attr}
<input ${safe(attr)}
type="text" type="text"
value="${safe(value, "\"") || ""}" value="${safe(value)}"
class="component_input" class="component_input"
/> />
`); `);
if (!datalist) return $input; const dataListId = gid("list_");
const $wrapper = window.document.createElement("span"); const $input = createElement(`
const $datalist = window.document.createElement("datalist"); <input ${attr}
$wrapper.appendChild($input); 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); $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; return $wrapper;
case "enable": case "enable":
return createElement(` return createElement(`
<div class="component_checkbox"> <div class="component_checkbox">
<input <input
${attr}
type="checkbox" type="checkbox"
${(value || props.default) ? "checked" : ""} ${(value === null ? props.default : value) ? "checked" : ""}
/> />
<span className="indicator"></span> <span className="indicator"></span>
</div> </div>
@ -92,19 +118,18 @@ function $renderInput(options = {}) {
case "number": case "number":
return createElement(` return createElement(`
<input <input
${safe(attr)} ${attr}
type="number" type="number"
value="${safe(value, "\"") || ""}" value="${safe(value)}"
class="component_input" class="component_input"
/> />
`); `);
case "password": case "password":
// TODO: click eye
const $node = createElement(` const $node = createElement(`
<div class="formbuilder_password"> <div class="formbuilder_password">
<input <input
${safe(attr)} ${attr}
value="${safe(value, "\"") || ""}" value="${safe(value)}"
type="password" type="password"
class="component_input" class="component_input"
/> />
@ -124,21 +149,21 @@ function $renderInput(options = {}) {
case "long_password": case "long_password":
// TODO // TODO
case "long_text": case "long_text":
return createElement(` const $textarea = createElement(`
<textarea ${safe(attr)} class="component_textarea" rows="8"> <textarea ${attr} class="component_textarea" rows="8" placeholder="${safe(props.default)}"></textarea>
</textarea>
`); `);
if (value) $textarea.value = value;
return $textarea;
case "bcrypt": case "bcrypt":
return createElement(` return createElement(`
<input <input
type="password" type="password"
${safe(attr)} ${attr}
value="${safe(value, "\"") || ""}" value="${safe(value)}"
readonly readonly
class="component_input" class="component_input"
/> />
`); `);
// TODO
case "hidden": case "hidden":
return createElement(` return createElement(`
<input <input
@ -151,24 +176,40 @@ function $renderInput(options = {}) {
return createElement(` return createElement(`
<div class="component_checkbox"> <div class="component_checkbox">
<input <input
${safe(attr)} ${attr}
type="checkbox" type="checkbox"
${(value || props.default) ? "checked" : ""} ${(value === null ? props.default : value) ? "checked" : ""}
/> />
<span class="indicator"></span> <span class="indicator"></span>
</div> </div>
`); `);
case "select": 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(` return createElement(`
<select class="component_select" ${safe(attr)}> <select
${attr}
value="${safe(value || props.default)}"
class="component_select"
>
${(options || []).map(renderOption)} ${(options || []).map(renderOption)}
</select> </select>
`); `);
case "date": case "date":
return createElement(` return createElement(`
<input <input
${safe(attr)} ${attr}
value="${safe(value || props.default)}"
type="date" type="date"
class="component_input" class="component_input"
/> />
@ -176,7 +217,8 @@ function $renderInput(options = {}) {
case "datetime": case "datetime":
return createElement(` return createElement(`
<input <input
${safe(attr)} ${attr}
value="${safe(value || props.default)}"
type="datetime-local" type="datetime-local"
class="component_input" class="component_input"
/> />
@ -191,7 +233,7 @@ function $renderInput(options = {}) {
value="unknown element type ${type}" value="unknown element type ${type}"
type="text" type="text"
class="component_input" class="component_input"
path="${safe(path.join("."))}" name="${safe(path.join("."))}"
readonly readonly
/> />
`); `);
@ -213,3 +255,10 @@ export function format(name) {
}) })
.join(" "); .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}`);
}

View file

@ -6,7 +6,9 @@ class Icon extends window.HTMLElement {
attributeChangedCallback() { attributeChangedCallback() {
const alt = this.getAttribute("name"); const alt = this.getAttribute("name");
const img = this._mapOfIcon(alt); const img = this._mapOfIcon(alt);
requestAnimationFrame(() => {
this.innerHTML = this.render({ alt, img }); this.innerHTML = this.render({ alt, img });
});
} }
render({ alt, img }) { render({ alt, img }) {

View file

@ -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 default createElement("<component-loader></component-loader>");
export function toggle($node, show = false) { export function toggle($node, show = false) {

View file

@ -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 rxjs, { applyMutation } from "../lib/rx.js";
import { animate } from "../lib/animate.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"; import { CSS } from "../helpers/loader.js";
let _observables = []; export default class Modal {
const effect = (obs) => _observables.push(obs.subscribe()); static open($node, opts = {}) {
const free = () => { find().trigger($node, opts);
for (let i = 0; i < _observables.length; i++) { }
_observables[i].unsubscribe();
} }
_observables = [];
};
export default class Modal extends HTMLElement { const createModal = async () => createElement(`
async trigger($node, opts = {}) {
const { onQuit } = opts;
const $modal = createElement(`
<div class="component_modal" id="modal-box"> <div class="component_modal" id="modal-box">
<style>${await CSS(import.meta.url, "modal.css")}</style> <style>${await CSS(import.meta.url, "modal.css")}</style>
<div> <div>
@ -26,28 +20,44 @@ export default class Modal extends HTMLElement {
<div class="modal-message" data-bind="body"><!-- MODAL BODY --></div> <div class="modal-message" data-bind="body"><!-- MODAL BODY --></div>
</div> </div>
<div class="buttons"> <div class="buttons">
<button type="submit" class="emphasis">OK</button> <button type="button"></button>
<button type="submit" class="emphasis"></button>
</div> </div>
</div> </div>
</div> </div>
</div>`); </div>
this.replaceChildren($modal); `);
// feature: setup the modal body class ModalComponent extends window.HTMLElement {
effect(rxjs.of([$node]).pipe( async trigger($node, opts = {}) {
applyMutation(qs($modal, "[data-bind=\"body\"]"), "appendChild") 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 // feature: closing the modal
effect(rxjs.merge( effect(close$.pipe(
rxjs.fromEvent($modal, "click").pipe( rxjs.tap((label) => onQuit(label)),
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()),
rxjs.tap(() => animate(qs($modal, "div > div"), { rxjs.tap(() => animate(qs($modal, "div > div"), {
time: 200, time: 200,
keyframes: [ keyframes: [
@ -91,7 +101,7 @@ export default class Modal extends HTMLElement {
rxjs.map(() => { rxjs.map(() => {
let size = 300; let size = 300;
const $box = document.querySelector("#modal-box > div"); 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); size = Math.round((document.body.offsetHeight - size) / 2);
if (size < 0) return 0; 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;
}

View 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;
}

View 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");
}
}

View file

@ -1,3 +1,7 @@
import { get as getRelease } from "../pages/adminpage/model_release.js";
let version = null;
export async function loadScript(url) { export async function loadScript(url) {
const $script = document.createElement("script"); const $script = document.createElement("script");
$script.setAttribute("src", url); $script.setAttribute("src", url);
@ -14,10 +18,15 @@ export async function CSS(baseURL, ...arrayOfFilenames) {
} }
async function loadSingleCSS(baseURL, filename) { async function loadSingleCSS(baseURL, filename) {
const res = await fetch(baseURL.replace(/(.*)\/[^\/]+$/, "$1/") + filename + "?version=" + "__", { const res = await fetch(baseURL.replace(/(.*)\/[^\/]+$/, "$1/") + `${filename}?version=${version}`, {
cache: "force-cache" cache: "force-cache",
}); });
if (res.status !== 200) return `/* ERROR: ${res.status} */`; 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")}"*/`; else if (!res.headers.get("Content-Type").startsWith("text/css")) return `/* ERROR: wrong type, got "${res.headers.get("Content-Type")}"*/`;
return await res.text(); return await res.text();
} }
export async function init() {
const info = await getRelease().toPromise();
version = info.version;
}

View file

@ -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();

View 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>

View file

@ -14,28 +14,10 @@
</div> </div>
<script type="module"> <script type="module">
import main from "/lib/skeleton/index.js"; import main from "/lib/skeleton/index.js";
const routes = { import routes from "/boot/router_frontoffice.js";
"/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",
};
main(document.getElementById("app"), routes, { main(document.getElementById("app"), routes, {
spinner: `<component-loader></component-loader>`, spinner: `<component-loader></component-loader>`,
beforeStart: import("/pages/ctrl_boot.js"), beforeStart: import("/boot/ctrl_boot_frontoffice.js"),
}); });
</script> </script>

View file

@ -14,15 +14,7 @@
</div> </div>
<script type="module"> <script type="module">
import main from "/lib/skeleton/index.js"; import main from "/lib/skeleton/index.js";
const routes = { import routes from "/boot/router_backoffice.js";
"/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",
};
main(document.getElementById("app"), routes, { main(document.getElementById("app"), routes, {
spinner: `<component-loader></component-loader>`, spinner: `<component-loader></component-loader>`,
beforeStart: import("/boot/ctrl_boot_backoffice.js"), beforeStart: import("/boot/ctrl_boot_backoffice.js"),
@ -31,6 +23,8 @@
<script type="module" src="/components/modal.js"></script> <script type="module" src="/components/modal.js"></script>
<component-modal></component-modal> <component-modal></component-modal>
<script type="module" src="/components/notification.js"></script>
<component-notification></component-notification>
<noscript> <noscript>
<div> <div>

View file

@ -2,11 +2,11 @@ import rxjs, { ajax } from "./rx.js";
import { AjaxError } from "./error.js"; import { AjaxError } from "./error.js";
export default function(opts) { 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"); else if (typeof opts !== "object") throw new Error("unsupported call");
if (!opts.headers) opts.headers = {}; if (!opts.headers) opts.headers = {};
opts.headers["X-Requested-With"] = "XmlHttpRequest"; 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.catchError((err) => rxjs.throwError(processError(err.xhr, err))),
rxjs.map((res) => { rxjs.map((res) => {
const result = res.xhr.responseText; const result = res.xhr.responseText;
@ -38,14 +38,14 @@ function processError(xhr, err) {
}; };
} }
return message || { message: "empty response" }; return message || { message: "empty response" };
})(xhr.responseText); })(xhr?.responseText || "");
const message = response.message || null; const message = response.message || null;
if (window.navigator.onLine === false) { if (window.navigator.onLine === false) {
return new AjaxError("Connection Lost", err, "NO_INTERNET"); return new AjaxError("Connection Lost", err, "NO_INTERNET");
} }
switch (xhr.status) { switch (xhr?.status) {
case 500: case 500:
return new AjaxError( return new AjaxError(
message || "Oups something went wrong with our servers", message || "Oups something went wrong with our servers",
@ -83,7 +83,7 @@ function processError(xhr, err) {
err, "CONFLICT" err, "CONFLICT"
); );
case 0: case 0:
switch (xhr.responseText) { switch (xhr?.responseText) {
case "": case "":
return new AjaxError( return new AjaxError(
"Service unavailable, if the problem persist, contact your administrator", "Service unavailable, if the problem persist, contact your administrator",

View file

@ -50,8 +50,8 @@ export const slideYIn = (size) => ([
]); ]);
export const slideYOut = (size) => ([ export const slideYOut = (size) => ([
{ opacity: 0, transform: "translateY(0px)" }, { opacity: 1, transform: "translateY(0px)" },
{ opacity: 1, transform: `translateY(${size}px)` } { opacity: 0, transform: `translateY(${size}px)` }
]); ]);
export const zoomIn = (size) => ([ export const zoomIn = (size) => ([

View file

@ -10,13 +10,14 @@ export function qsa($node, selector) {
return $node.querySelectorAll(selector); return $node.querySelectorAll(selector);
} }
export function safe(str, ...escapeChars) { export function safe(str) {
if (typeof str !== "string") return str; if (typeof str !== "string") return "";
const $div = document.createElement("div"); const $div = document.createElement("div");
escapeChars.forEach((c) => {
str = str.replaceAll(c, "\\" + c);
});
$div.textContent = str; $div.textContent = str;
return $div.innerHTML; return ($div.innerHTML || "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
} }

View file

@ -8,7 +8,8 @@ export function mutateForm(formSpec, formState) {
let ptr = formSpec; let ptr = formSpec;
while (keys.length > 1) ptr = ptr[keys.shift()]; 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; return formSpec;
} }
@ -43,7 +44,7 @@ async function createFormNodes(node, { renderNode, renderLeaf, renderInput, path
else { else {
const currentPath = path.concat(key); const currentPath = path.concat(key);
const $leaf = renderLeaf({ ...node[key], path: currentPath, label: 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; const $target = $leaf.querySelector("[data-bind=\"children\"]") || $leaf;
// leaf node is either "classic" or can be the target of something that can be toggled // 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[k].id) continue;
else if (node[key].target.indexOf(node[k].id) === -1) continue; else if (node[key].target.indexOf(node[k].id) === -1) continue;
const $kleaf = renderLeaf({ ...node[k], path: currentPath, label: k }); const $kleaf = renderLeaf({ ...node[k], path: path.concat(k), label: k });
const $kinput = await renderInput({ ...node[k], path: currentPath }); const $kinput = await renderInput({ ...node[k], path: path.concat(k) });
const $ktarget = $kleaf.querySelector("[data-bind=\"children\"]") || $kleaf; const $ktarget = $kleaf.querySelector("[data-bind=\"children\"]") || $kleaf;
$ktarget.removeAttribute("data-bind"); $ktarget.removeAttribute("data-bind");
$ktarget.appendChild($kinput); $ktarget.appendChild($kinput);

View file

@ -8,19 +8,26 @@ export default rxjs;
export const ajax = ajaxModule.ajax; export const ajax = ajaxModule.ajax;
export function effect(obs) { export function effect(obs) {
const tmp = obs.subscribe(() => {}, (err) => console.error("effect", err)); const tmp = obs.subscribe(() => {}, (err) => { throw err; });
onDestroy(() => tmp.unsubscribe()); onDestroy(() => tmp.unsubscribe());
} }
export function applyMutation($node, ...keys) {
if (!$node) throw new Error("undefined node");
const getFn = (obj, arg0, ...args) => { const getFn = (obj, arg0, ...args) => {
if (arg0 === undefined) return obj; if (arg0 === undefined) return obj;
const next = obj[arg0]; const next = obj[arg0];
return getFn(next.bind ? next.bind(obj) : next, ...args); 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); 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) { export function stateMutation($node, attr) {
@ -31,3 +38,10 @@ export function stateMutation($node, attr) {
export function preventDefault() { export function preventDefault() {
return rxjs.tap((e) => e.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),
);
}

View file

@ -67,3 +67,5 @@ export function createRender($parent) {
else throw new Error(`Unknown view type: ${typeof $view}`); else throw new Error(`Unknown view type: ${typeof $view}`);
}; };
} }
export function nop() {}

View file

@ -1,11 +1,11 @@
import main, { createElement, onDestroy } from "./index.js"; import main, { createElement, onDestroy } from "./index.js";
describe("router with inline controller", () => { xdescribe("router with inline controller", () => {
it("can render a string", async() => { it("can render a string", async() => {
// given // given
const $app = window.document.createElement("div"); const $app = window.document.createElement("div");
const routes = { const routes = {
"/": (render) => render(`<h1 id="test">main</h1>`), "/": (render) => render("<h1 id=\"test\">main</h1>")
}; };
// when // when
@ -19,9 +19,9 @@ describe("router with inline controller", () => {
it("can render a dom node", async() => { it("can render a dom node", async() => {
// given // given
const $app = window.document.createElement("div"); const $app = window.document.createElement("div");
const $node = createElement(`<h1 id="test">main</h1>`); const $node = createElement("<h1 id=\"test\">main</h1>");
const routes = { const routes = {
"/": (render) => render($node), "/": (render) => render($node)
}; };
// when // when
@ -29,17 +29,17 @@ describe("router with inline controller", () => {
window.dispatchEvent(new window.Event("pagechange")); window.dispatchEvent(new window.Event("pagechange"));
// then // then
await nextTick() await nextTick();
expect($node instanceof window.Element).toBe(true) expect($node instanceof window.Element).toBe(true);
expect($app.querySelector("#test").textContent).toBe("main") expect($app.querySelector("#test").textContent).toBe("main");
}); });
it("errors when given a non valid route", async() => { it("errors when given a non valid route", async() => {
// given // given
const $app = window.document.createElement("div"); const $app = window.document.createElement("div");
const $node = createElement(`<h1 id="test">main</h1>`); const $node = createElement("<h1 id=\"test\">main</h1>");
const routes = { const routes = {
"/": null, "/": null
}; };
// when // when
@ -47,17 +47,17 @@ describe("router with inline controller", () => {
window.dispatchEvent(new window.Event("pagechange")); window.dispatchEvent(new window.Event("pagechange"));
// then // then
await nextTick() await nextTick();
expect($node instanceof window.Element).toBe(true) expect($node instanceof window.Element).toBe(true);
expect($app.querySelector("h1").textContent).toBe("Error") expect($app.querySelector("h1").textContent).toBe("Error");
}); });
it("errors when given a non valid render", async() => { it("errors when given a non valid render", async() => {
// given // given
const $app = window.document.createElement("div"); const $app = window.document.createElement("div");
const $node = createElement(`<h1 id="test">main</h1>`); const $node = createElement("<h1 id=\"test\">main</h1>");
const routes = { const routes = {
"/": (render) => render({ json: "object", is: "not_ok" }), "/": (render) => render({ json: "object", is: "not_ok" })
}; };
// when // when
@ -65,18 +65,18 @@ describe("router with inline controller", () => {
window.dispatchEvent(new window.Event("pagechange")); window.dispatchEvent(new window.Event("pagechange"));
// then // then
await nextTick() await nextTick();
expect($node instanceof window.Element).toBe(true) expect($node instanceof window.Element).toBe(true);
expect($app.querySelector("h1").textContent).toBe("Error") 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() => { it("render the default import", async() => {
// given // given
const $app = window.document.createElement("div"); const $app = window.document.createElement("div");
const routes = { const routes = {
"/": "./common/skeleton/test/ctrl/ok.js", "/": "./common/skeleton/test/ctrl/ok.js"
}; };
// when // when
@ -92,7 +92,7 @@ describe("router with es6 module as a controller", () => {
// given // given
const $app = window.document.createElement("div"); const $app = window.document.createElement("div");
const routes = { const routes = {
"/": "./common/skeleton/test/ctrl/nok.js", "/": "./common/skeleton/test/ctrl/nok.js"
}; };
// when // 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() => { it("using a link with data-link attribute for SPA", async() => {
// given // given
const $app = window.document.createElement("div"); const $app = window.document.createElement("div");
const routes = { const routes = {
"/": "./common/skeleton/test/ctrl/link.js", "/": "./common/skeleton/test/ctrl/link.js",
"/something": (render) => render(`<h1>OK</h1>`), "/something": (render) => render("<h1>OK</h1>")
}; };
const destroy = jest.fn(); const destroy = jest.fn();

View file

@ -3,13 +3,13 @@ import { currentRoute, init } from "./router.js";
import * as routerModule from "./router.js"; import * as routerModule from "./router.js";
describe("router", () => { describe("router", () => {
it("logic to get the current route", () => { xit("logic to get the current route", () => {
// given // given
let res; let res;
const routes = { const routes = {
"/foo": "route /foo", "/foo": "route /foo",
"/bar": "route /bar", "/bar": "route /bar"
} };
window.location.pathname = "/"; window.location.pathname = "/";
// when, then // when, then
@ -22,7 +22,7 @@ describe("router", () => {
it("trigger a page change when DOMContentLoaded", () => { it("trigger a page change when DOMContentLoaded", () => {
// given // given
const fn = jest.fn(); const fn = jest.fn();
init(createElement(`<div></div>`)); init(createElement("<div></div>"));
window.addEventListener("pagechange", fn); window.addEventListener("pagechange", fn);
// when // when
@ -34,7 +34,7 @@ describe("router", () => {
it("trigger a page change when history back", () => { it("trigger a page change when history back", () => {
// given // given
const fn = jest.fn(); const fn = jest.fn();
init(createElement(`<div></div>`)); init(createElement("<div></div>"));
window.addEventListener("pagechange", fn); window.addEventListener("pagechange", fn);
// when // when
@ -43,10 +43,10 @@ describe("router", () => {
// then // then
expect(fn).toBeCalled(); 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 // given
const fn = jest.fn(); const fn = jest.fn();
const $link = createElement(`<a href="/something" data-link></a>`) const $link = createElement("<a href=\"/something\" data-link></a>");
init($link); init($link);
window.addEventListener("pagechange", fn); 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", () => { it("trigger a page change when clicking on a link with [data-link] attribute - recursive", () => {
// given // given
const fn = jest.fn(); 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); init($link);
window.addEventListener("pagechange", fn); window.addEventListener("pagechange", fn);

1161
public/lib/vendor/bcrypt.js vendored Normal file

File diff suppressed because it is too large Load diff

14
public/model/backend.js Normal file
View 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
View 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$;
}

View file

@ -2,6 +2,10 @@ import rxjs from "../lib/rx.js";
import ajax from "../lib/ajax.js"; import ajax from "../lib/ajax.js";
export function createSession(authenticationRequest) { 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({ return ajax({
method: "POST", method: "POST",
url: "/api/session", url: "/api/session",

View file

@ -70,7 +70,8 @@
"promise/param-names": ["off"], "promise/param-names": ["off"],
"no-return-assign": ["off"], "no-return-assign": ["off"],
"brace-style": ["off"], "brace-style": ["off"],
"no-useless-escape": ["off"] "no-useless-escape": ["off"],
"comma-dangle": [0]
} }
} }
} }

View 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" });

View file

@ -2,16 +2,15 @@ import { createElement } from "../../lib/skeleton/index.js";
import rxjs, { effect, stateMutation } from "../../lib/rx.js"; import rxjs, { effect, stateMutation } from "../../lib/rx.js";
import { qs } from "../../lib/dom.js"; import { qs } from "../../lib/dom.js";
import { CSS } from "../../helpers/loader.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 transition from "./animate.js";
import { get as getRelease } from "./model_release.js";
import AdminHOC from "./decorator.js";
export default AdminHOC(async function(render) { export default AdminHOC(async function(render) {
const css = await CSS(import.meta.url, "ctrl_about.css");
const $page = createElement(` const $page = createElement(`
<div class="component_page_about"> <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 data-bind="about"><Loader /></div>
</div> </div>
`); `);
@ -19,6 +18,6 @@ export default AdminHOC(async function(render) {
effect(getRelease().pipe( effect(getRelease().pipe(
rxjs.map(({ html }) => html), rxjs.map(({ html }) => html),
stateMutation(qs($page, "[data-bind=\"about\"]"), "innerHTML") stateMutation(qs($page, `[data-bind="about"]`), "innerHTML"),
)); ));
}); });

View file

@ -1,19 +1,30 @@
.component_dashboard .box-container { .component_dashboard .box-container {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
margin: 0px 0px 20px 0px; } margin: 0px 0px 20px 0px;
}
.component_dashboard .box-container .box-item { .component_dashboard .box-container .box-item {
position: relative; position: relative;
width: 20%; } width: 20%;
}
.component_dashboard .box-container .box-item strong {
font-weight: 400;
}
@media (max-width: 1350px) { @media (max-width: 1350px) {
.component_dashboard .box-container .box-item { .component_dashboard .box-container .box-item {
width: 25%; } } width: 25%;
}
}
@media (max-width: 900px) { @media (max-width: 900px) {
.component_dashboard .box-container .box-item { .component_dashboard .box-container .box-item {
width: 33.33%; } } width: 33.33%;
}
}
@media (max-width: 750px) { @media (max-width: 750px) {
.component_dashboard .box-container .box-item { .component_dashboard .box-container .box-item {
width: 50%; } } width: 50%;
}
}
.component_dashboard .box-container .box-item > div { .component_dashboard .box-container .box-item > div {
box-shadow: 2px 2px 10px rgba(0, 0, 0, 0.25); box-shadow: 2px 2px 10px rgba(0, 0, 0, 0.25);
margin: 3px; margin: 3px;
@ -24,15 +35,21 @@
font-size: 1.1em; font-size: 1.1em;
text-transform: uppercase; text-transform: uppercase;
border-radius: 2px; border-radius: 2px;
background: var(--light); } background: var(--light);
}
@media (max-width: 900px) { @media (max-width: 900px) {
.component_dashboard .box-container .box-item > div { .component_dashboard .box-container .box-item > div {
padding: 25px 0; } } padding: 25px 0;
}
}
@media (max-width: 750px) { @media (max-width: 750px) {
.component_dashboard .box-container .box-item > div { .component_dashboard .box-container .box-item > div {
padding: 20px 0; } } padding: 20px 0;
}
}
.component_dashboard .box-container .box-item > div > span { .component_dashboard .box-container .box-item > div > span {
display: none; } display: none;
}
.component_dashboard .box-container .box-item > div:hover > span { .component_dashboard .box-container .box-item > div:hover > span {
display: block; display: block;
cursor: pointer; cursor: pointer;
@ -49,13 +66,18 @@
background: var(--emphasis-primary); background: var(--emphasis-primary);
padding: 18px 0; padding: 18px 0;
margin: 6px; margin: 6px;
opacity: 0.95; } opacity: 0.95;
}
@media (max-width: 900px) { @media (max-width: 900px) {
.component_dashboard .box-container .box-item > div:hover > span { .component_dashboard .box-container .box-item > div:hover > span {
padding: 12px 0; } } padding: 12px 0;
}
}
@media (max-width: 750px) { @media (max-width: 750px) {
.component_dashboard .box-container .box-item > div:hover > span { .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 { .component_dashboard .box-container .box-item > div:hover > span .icon {
background: var(--primary); background: var(--primary);
border-radius: 50%; border-radius: 50%;
@ -64,30 +86,39 @@
display: inline-block; display: inline-block;
line-height: 40px; line-height: 40px;
opacity: 0.6; opacity: 0.6;
color: white; } color: white;
}
.component_dashboard .box-container .box-item > div:hover > span .icon .component_icon { .component_dashboard .box-container .box-item > div:hover > span .icon .component_icon {
padding: 7px; padding: 7px;
width: 25px; width: 25px;
height: 25px; } height: 25px;
}
.component_dashboard .box-container .box-item.active > div { .component_dashboard .box-container .box-item.active > div {
background: var(--primary); background: var(--primary);
transition: background 0.1s; } transition: background 0.1s;
}
.component_dashboard .box-container .box-item.pointer { .component_dashboard .box-container .box-item.pointer {
cursor: pointer; } cursor: pointer;
}
.component_dashboard .box-container .box-item.no-select { .component_dashboard .box-container .box-item.no-select {
user-select: none; } user-select: none;
.component_dashboard form > div { }
position: relative; } .component_dashboard form fieldset {
.component_dashboard form > div > .icons { position: relative;
}
.component_dashboard form fieldset .icons {
position: absolute; position: absolute;
border-radius: 50%; border-radius: 50%;
padding: 10px; padding: 10px;
border: 3px solid var(--bg-color); border: 3px solid var(--bg-color);
background: var(--primary); background: var(--primary);
cursor: pointer; cursor: pointer;
right: -15px; right: -20px;
top: -5px; } top: -30px;
.component_dashboard form > div > .icons .component_icon { }
height: 20px; } .component_dashboard form fieldset .icons .component_icon {
height: 20px;
}
.component_dashboard .component_storagebackend form { .component_dashboard .component_storagebackend form {
width: calc(100% - 15px); } width: calc(100% - 15px);
}

View file

@ -8,15 +8,11 @@ import componentStorageBackend from "./ctrl_backend_component_storage.js";
import componentAuthenticationMiddleware from "./ctrl_backend_component_authentication.js"; import componentAuthenticationMiddleware from "./ctrl_backend_component_authentication.js";
export default AdminHOC(async function(render) { export default AdminHOC(async function(render) {
const css = await CSS(import.meta.url, "ctrl_backend.css");
const $page = createElement(` const $page = createElement(`
<div class="component_dashboard sticky"> <div class="component_dashboard sticky">
<style>${await CSS(import.meta.url, "ctrl_backend.css")}</style>
<div data-bind="backend"></div> <div data-bind="backend"></div>
<h2>Authentication Middleware</h2>
<div data-bind="authentication_middleware"></div> <div data-bind="authentication_middleware"></div>
<style>${css}</style>
</div> </div>
`); `);
render(transition($page)); render(transition($page));

View file

@ -1,47 +1,293 @@
import { createElement } from "../../lib/skeleton/index.js"; 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(` const $page = createElement(`
<div>
<h2 class="hidden">Authentication Middleware</h2>
<div class="box-container"> <div class="box-container">
<div class="box-item pointer no-select"> ${generateSkeleton(5)}
<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>
</div> </div>
<div style="min-height: 300px">
<form data-bind="idp"></form>
<form data-bind="attribute-mapping"></div>
</div> </div>
</div> </div>
`); `);
render($page); 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(),
);

View file

@ -1,30 +1,137 @@
import { createElement } from "../../lib/skeleton/index.js"; import { createElement } from "../../lib/skeleton/index.js";
import rxjs, { effect, applyMutation } from "../../lib/rx.js"; import rxjs, { effect, applyMutations, onClick } from "../../lib/rx.js";
import { qs } from "../../lib/dom.js"; import { createForm } from "../../lib/form.js";
import backend$ from "../connectpage/model_backend.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(` const $page = createElement(`
<div class="component_storagebackend"> <div class="component_storagebackend">
<h2>Storage Backend</h2> <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> <form data-bind="backend-enabled"></form>
</div> </div>
`); `);
render($page); render($page);
await initStorage();
effect(backend$.pipe( // feature: setup the buttons
rxjs.mergeMap((specs) => Object.keys(specs)), const init$ = getBackendAvailable().pipe(
rxjs.map((label) => [createElement(` rxjs.tap(() => qs($page, `[data-bind="backend-available"]`).innerHTML = ""),
<div class="box-item pointer no-select"> rxjs.mergeMap((specs) => Promise.all(Object.keys(specs).map((label) => createElement(`
<div> <div is="box-item" data-label="${label}"></div>
${label} `)))),
<span class="no-select"> applyMutations(qs($page, `[data-bind="backend-available"]`), "appendChild"),
<span class="icon">+</span> rxjs.share(),
</span> );
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> </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> </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(),
);

View 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;
}

View file

@ -1,6 +1,4 @@
import { createElement, createRender } from "../../lib/skeleton/index.js"; 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 componentLogForm from "./ctrl_log_form.js";
import componentLogViewer from "./ctrl_log_viewer.js"; import componentLogViewer from "./ctrl_log_viewer.js";
@ -21,8 +19,8 @@ function Page(render) {
`); `);
render(transition($page)); render(transition($page));
componentLogForm(createRender($page.querySelector(".component_logger")));
componentLogViewer(createRender($page.querySelector(".component_logviewer"))); componentLogViewer(createRender($page.querySelector(".component_logviewer")));
componentLogForm(createRender($page.querySelector(".component_logger")));
componentAuditor(createRender($page.querySelector(".component_reporter"))); componentAuditor(createRender($page.querySelector(".component_reporter")));
} }

View file

@ -1,32 +1,66 @@
import { createElement } from "../../lib/skeleton/index.js"; import { createElement } from "../../lib/skeleton/index.js";
import rxjs, { effect, stateMutation, applyMutation } from "../../lib/rx.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 { createForm } from "../../lib/form.js";
import { formTmpl } from "../../components/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) { export default function(render) {
const $page = createElement(` const $page = createElement(`
<div> <div>
<form></form> <form>
${generateSkeleton(10)}
</form>
<div data-bind="auditor"></div> <div data-bind="auditor"></div>
</div> </div>
`); `);
render($page); render($page);
const audit$ = getAudit().pipe(rxjs.share());
// setup the form // create the form on the dom
effect(Audit.get().pipe( const setup$ = audit$.pipe(
rxjs.first(),
rxjs.map(({ form }) => form), rxjs.map(({ form }) => form),
rxjs.map((formSpec) => createForm(formSpec, formTmpl())), rxjs.mergeMap((formSpec) => createForm(formSpec, formTmpl())),
rxjs.mergeMap((promise) => rxjs.from(promise)),
rxjs.map(($form) => [$form]), 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 // feature2: update to the query form
effect(Audit.get().pipe( effect(rxjs.of(null).pipe(
rxjs.map(({ render }) => render), useForm$(() => qsa($page, `form [name]`)),
stateMutation(qs($page, "[data-bind=\"auditor\"]"), "innerHTML") 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()))),
)); ));
} }

View file

@ -1,11 +1,15 @@
import { createElement } from "../../lib/skeleton/index.js"; import { createElement } from "../../lib/skeleton/index.js";
import rxjs, { effect, applyMutation } from "../../lib/rx.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 { generateSkeleton } from "../../components/skeleton.js";
import { createForm } from "../../lib/form.js"; import notification from "../../components/notification.js";
import { formTmpl } from "../../components/form.js"; import { formTmpl } from "../../components/form.js";
import { get as getConfig } from "../../model/config.js";
import { get as getConfig } from "./model_config.js"; import { get as getAdminConfig, save as saveConfig } from "./model_config.js";
import { renderLeaf } from "./helper_form.js"; import { renderLeaf, useForm$, formObjToJSON$ } from "./helper_form.js";
export default function(render) { export default function(render) {
const $form = createElement(` const $form = createElement(`
@ -17,13 +21,37 @@ export default function(render) {
render($form); render($form);
// feature1: render the form // feature1: render the form
effect(getConfig().pipe( const setup$ = getAdminConfig().pipe(
rxjs.map(({ log }) => ({ params: log })), rxjs.map(({ log }) => ({ params: log })),
rxjs.map((formSpec) => createForm(formSpec, formTmpl({ renderLeaf }))), rxjs.map((formSpec) => createForm(formSpec, formTmpl({ renderLeaf }))),
rxjs.mergeMap((promise) => rxjs.from(promise)), rxjs.mergeMap((promise) => rxjs.from(promise)),
rxjs.map(($form) => [$form]), 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;
}),
));
} }

View file

@ -0,0 +1,7 @@
.component_logpage button{
width: inherit;
float: right;
margin-top: 5px;
padding-left: 20px;
padding-right: 20px;
}

View file

@ -1,13 +1,36 @@
import { createElement } from "../../lib/skeleton/index.js"; import { createElement } from "../../lib/skeleton/index.js";
import rxjs, { effect, stateMutation } from "../../lib/rx.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) { export default async function(render) {
const $page = createElement(`<pre style="height:350px; max-height: 350px">…</pre>`); 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); render($page);
effect(Log.get().pipe( effect(getLogs().pipe(
stateMutation($page, "textContent") 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`;
};

View file

@ -2,17 +2,18 @@ import { createElement } from "../../lib/skeleton/index.js";
import rxjs, { effect, stateMutation, applyMutation, preventDefault } from "../../lib/rx.js"; import rxjs, { effect, stateMutation, applyMutation, preventDefault } from "../../lib/rx.js";
import { qs } from "../../lib/dom.js"; import { qs } from "../../lib/dom.js";
import { transition, zoomIn } from "../../lib/animate.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 { CSS } from "../../helpers/loader.js";
import notification from "../../components/notification.js";
import "../../components/icon.js";
import { authenticate$ } from "./model_admin_session.js"; import { authenticate$ } from "./model_admin_session.js";
import "../../components/icon.js";
export default async function(render) { export default async function(render) {
const css = await CSS(import.meta.url, "ctrl_login.css");
const $form = createElement(` const $form = createElement(`
<div class="component_container component_page_adminlogin"> <div class="component_container component_page_adminlogin">
<style>${css}</style> <style>${await CSS(import.meta.url, "ctrl_login.css")}</style>
<form> <form>
<div class="input_group"> <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>
@ -39,7 +40,16 @@ export default async function(render) {
applyMutation(qs($form, "component-icon"), "setAttribute"), applyMutation(qs($form, "component-icon"), "setAttribute"),
// STEP2: attempt to login // STEP2: attempt to login
rxjs.map(() => ({ password: qs($form, "[name=\"password\"]").value })), 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 // 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 // level one layer above as the login ctrl has no idea what to show after login
rxjs.filter((ok) => !ok), rxjs.filter((ok) => !ok),
@ -50,7 +60,7 @@ export default async function(render) {
)); ));
// feature: autofocus // feature: autofocus
effect(rxjs.of([]).pipe( effect(rxjs.of(null).pipe(
applyMutation(qs($form, "input"), "focus") applyMutation(qs($form, "input"), "focus")
)); ));

View file

@ -4,11 +4,13 @@ import { qs, qsa } from "../../lib/dom.js";
import { createForm, mutateForm } from "../../lib/form.js"; import { createForm, mutateForm } from "../../lib/form.js";
import { formTmpl } from "../../components/form.js"; import { formTmpl } from "../../components/form.js";
import { generateSkeleton } from "../../components/skeleton.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 transition from "./animate.js";
import { renderLeaf } from "./helper_form.js";
import AdminHOC from "./decorator.js"; import AdminHOC from "./decorator.js";
import { get as getConfig, save as saveConfig } from "./model_config.js";
export default AdminHOC(function(render) { export default AdminHOC(function(render) {
const $container = createElement(` const $container = createElement(`
@ -21,12 +23,9 @@ export default AdminHOC(function(render) {
`); `);
render(transition($container)); render(transition($container));
const config$ = getConfig().pipe( const config$ = getAdminConfig().pipe(
rxjs.map((res) => { rxjs.first(),
delete res.constant; reshapeConfigBeforeDisplay,
delete res.middleware;
return res;
}),
); );
const tmpl = formTmpl({ const tmpl = formTmpl({
@ -43,29 +42,46 @@ export default AdminHOC(function(render) {
}); });
// feature: setup the form // feature: setup the form
const setup$ = config$.pipe( const init$ = config$.pipe(
rxjs.mergeMap((formSpec) => createForm(formSpec, tmpl)), rxjs.mergeMap((formSpec) => createForm(formSpec, tmpl)),
rxjs.map(($form) => [$form]), rxjs.map(($form) => [$form]),
applyMutation(qs($container, "[data-bind=\"form\"]"), "replaceChildren"), applyMutation(qs($container, `[data-bind="form"]`), "replaceChildren"),
rxjs.share(), rxjs.share(),
); );
effect(setup$); effect(init$);
// feature: handle form change // feature: handle form change
effect(setup$.pipe( effect(init$.pipe(
rxjs.mergeMap(() => qsa($container, "[data-bind=\"form\"] [name]")), useForm$(() => qsa($container, `[data-bind="form"] [name]`)),
rxjs.mergeMap(($el) => rxjs.fromEvent($el, "input")), rxjs.combineLatestWith(config$.pipe(rxjs.first())),
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$),
rxjs.map(([formState, formSpec]) => mutateForm(formSpec, formState)), rxjs.map(([formState, formSpec]) => mutateForm(formSpec, formState)),
reshapeConfigBeforeSave,
saveConfig(), 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;
}),
);

View file

@ -3,37 +3,39 @@ import rxjs, { effect, stateMutation, applyMutation, preventDefault } from "../.
import { qs } from "../../lib/dom.js"; import { qs } from "../../lib/dom.js";
import { ApplicationError } from "../../lib/error.js"; import { ApplicationError } from "../../lib/error.js";
import { transition, animate, zoomIn, slideXOut, slideXIn } from "../../lib/animate.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 { 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 ctrlError from "../ctrl_error.js";
import WithShell from "./decorator_sidemenu.js"; import WithShell from "./decorator_sidemenu.js";
import { cssHideMenu } from "./animate.js"; import { cssHideMenu } from "./animate.js";
import { formObjToJSON$ } from "./helper_form.js";
import { getDeps } from "./model_setup.js";
import "../../components/icon.js"; import "../../components/icon.js";
const stepper$ = new rxjs.BehaviorSubject(1); const stepper$ = new rxjs.BehaviorSubject(1);
export default function(render) { export default async function(render) {
const $page = createElement(` const $page = createElement(`
<div class="component_setup"> <div class="component_setup">
<div data-bind="multistep-form"></div> <div data-bind="multistep-form"></div>
<style>${css}</style> <style>${await CSS(import.meta.url, "ctrl_setup.css")}</style>
</div> </div>
`); `);
render($page); render($page);
effect(stepper$.pipe( effect(stepper$.pipe(
rxjs.map((step) => { rxjs.map((step) => {
switch (step) { if (step === 1) return WithShell(componentStep1);
case 1: return WithShell(componentStep1); else if (step === 2) return WithShell(componentStep2);
case 2: return WithShell(componentStep2); throw new ApplicationError("INTERNAL_ERROR", "Assumption failed");
default: throw new ApplicationError("INTERNAL_ERROR", "Assumption failed");
}
}), }),
rxjs.tap((ctrl) => ctrl(createRender(qs($page, "[data-bind=\"multistep-form\"]")))), rxjs.tap((ctrl) => ctrl(createRender(qs($page, `[data-bind="multistep-form"]`)))),
rxjs.catchError((err) => ctrlError(err)(render)) rxjs.catchError((err) => ctrlError(err)(render)),
)); ));
}; };
@ -45,14 +47,14 @@ function componentStep1(render) {
<p>Create your instance admin password: </p> <p>Create your instance admin password: </p>
<form> <form>
<div class="input_group"> <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"> <button class="transparent">
<component-icon name="arrow_right"></component-icon> <component-icon name="arrow_right"></component-icon>
</button> </button>
</div> </div>
</form> </form>
</div> </div>
<style></style> <style>${cssHideMenu}</style>
</div> </div>
`); `);
render(transition($page, { render(transition($page, {
@ -66,23 +68,28 @@ function componentStep1(render) {
preventDefault(), preventDefault(),
rxjs.mapTo(["name", "loading"]), applyMutation(qs($page, "component-icon"), "setAttribute"), rxjs.mapTo(["name", "loading"]), applyMutation(qs($page, "component-icon"), "setAttribute"),
rxjs.map(() => qs($page, "input").value), 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.tap(() => animate($page, { time: 200, keyframes: slideXOut(-30) })),
rxjs.delay(200), rxjs.delay(200),
rxjs.tap(() => stepper$.next(2)) 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) { function componentStep2(render) {
const deps = []; const deps = [];
const $page = createElement(` const $page = createElement(`
@ -91,58 +98,76 @@ function componentStep2(render) {
<component-icon name="arrow_left" data-bind="previous"></component-icon> <component-icon name="arrow_left" data-bind="previous"></component-icon>
Summary Summary
</h4> </h4>
${deps.map((t) => t.label).join("")} <div data-bind="dependencies"></div>
<style></style> <style>${cssHideMenu}</style>
</div>`); </div>
`);
render($page); 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 // 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)) rxjs.tap(() => stepper$.next(1))
)); ));
// feature: reveal animation // feature: reveal animation
effect(rxjs.of(cssHideMenu).pipe( effect(rxjs.of(null).pipe(
stateMutation(qs($page, "style"), "textContent"),
rxjs.tap(() => animate(qs($page, "h4"), { time: 200, keyframes: slideXIn(30) })), rxjs.tap(() => animate(qs($page, "h4"), { time: 200, keyframes: slideXIn(30) })),
rxjs.delay(200), rxjs.delay(200),
rxjs.mapTo([]), applyMutation(qs($page, "style"), "remove") rxjs.mapTo([]), applyMutation(qs($page, "style"), "remove")
)); ));
// feature: telemetry popup // feature: telemetry popup
onDestroy(() => { const $modal = createElement(`
const $node = createElement(`
<div> <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;"> <form style="font-size: 0.9em; margin-top: 10px;">
<label> <label>
<div class="component_checkbox"> <div class="component_checkbox">
<input type="checkbox"> <input type="checkbox">
<span class="indicator"></span> <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> </label>
</form> </form>
</div> </div>
`); `);
return new Promise((done) => { effect(getAdminConfig().pipe(
modal.alert($node, { reshapeConfigBeforeSave,
onQuit: done 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");

View file

@ -1,20 +1,22 @@
import { createElement, onDestroy } from "../../lib/skeleton/index.js";
import rxjs, { effect } from "../../lib/rx.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 ctrlLogin from "./ctrl_login.js";
import ctrlError from "../ctrl_error.js";
import { isAdmin$ } from "./model_admin_session.js"; import { isAdmin$ } from "./model_admin_session.js";
export default function AdminOnly(ctrlWrapped) { export default function AdminOnly(ctrlWrapped) {
return (render) => { return (render) => {
const loader$ = rxjs.timer(1000).subscribe(() => render(createElement("<div>loading</div>")));
onDestroy(() => loader$.unsubscribe());
effect(isAdmin$().pipe( effect(isAdmin$().pipe(
rxjs.map((isAdmin) => isAdmin ? ctrlWrapped : ctrlLogin), rxjs.map((isAdmin) => isAdmin ? ctrlWrapped : ctrlLogin),
rxjs.catchError((err) => rxjs.of(ctrlError(err))), rxjs.catchError((err) => {
rxjs.tap(() => loader$.unsubscribe()), if (err instanceof AjaxError && err.code() === "INTERNAL_SERVER_ERROR") {
rxjs.tap((ctrl) => ctrl(render)) ctrlError(err)(render);
return rxjs.EMPTY;
}
return rxjs.of(ctrlError(err));
}),
rxjs.tap((ctrl) => ctrl(render)),
)); ));
}; };
} }

View file

@ -6,15 +6,15 @@ import { CSS } from "../../helpers/loader.js";
import { get as getRelease } from "./model_release.js"; import { get as getRelease } from "./model_release.js";
import { isSaving } from "./model_config.js"; import { isSaving } from "./model_config.js";
import { isLoading } from "./model_audit.js";
import "../../components/icon.js"; import "../../components/icon.js";
export default function(ctrl) { export default function(ctrl) {
return async function(render) { return async function(render) {
const css = await CSS(import.meta.url, "decorator_sidemenu.css", "index.css");
const $page = createElement(` const $page = createElement(`
<div class="component_page_admin"> <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"> <div class="component_menu_sidebar no-select">
<a class="header" href="/"> <a class="header" href="/">
<svg class="arrow_left" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> <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 // feature: logo serving as loading indicator
effect(isSaving().pipe( effect(rxjs.combineLatest([
rxjs.startWith(false), isSaving().pipe(rxjs.startWith(false)),
isLoading().pipe(rxjs.startWith(false)),
]).pipe(
rxjs.map(([a, b]) => a || b),
rxjs.map((isLoading) => isLoading rxjs.map((isLoading) => isLoading
? "<component-icon name=\"loading\"></component-icon>" ? "<component-icon name=\"loading\"></component-icon>"
: `<svg class="logo" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"> : `<svg class="logo" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">

View file

@ -1,4 +1,5 @@
import { createElement } from "../../lib/skeleton/index.js"; import { createElement } from "../../lib/skeleton/index.js";
import rxjs from "../../lib/rx.js";
export function renderLeaf({ format, type, label, description }) { export function renderLeaf({ format, type, label, description }) {
return createElement(` return createElement(`
@ -12,9 +13,47 @@ export function renderLeaf({ format, type, label, description }) {
<div class="flex"> <div class="flex">
<span class="nothing"></span> <span class="nothing"></span>
<div style="width:100%;"> <div style="width:100%;">
<div class="description">${description || ""}</div> <div class="description">${(description || "").replaceAll("\n", "<br>")}</div>
</div> </div>
</div> </div>
</label> </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);
}

View file

@ -13,6 +13,9 @@
box-sizing: border-box; box-sizing: border-box;
max-height: 100vh; max-height: 100vh;
} }
.component_page_admin .page_container.scroll-y > div {
max-width: 1300px;
}
@media screen and (max-width: 1000px) { @media screen and (max-width: 1000px) {
.component_page_admin .page_container { .component_page_admin .page_container {
padding-left: 30px; padding-left: 30px;
@ -171,53 +174,7 @@
border-bottom-right-radius: 3px; border-bottom-right-radius: 3px;
padding-right: 5px; 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 input::placeholder,
.component_page_admin .formbuilder textarea::placeholder { .component_page_admin .formbuilder textarea::placeholder {
opacity: 0.6; 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;
}

View file

@ -17,17 +17,14 @@ export function isAdmin$() {
return adminSession$; return adminSession$;
} }
export function authenticate$() { export function authenticate$(body) {
return rxjs.pipe( return ajax({
rxjs.mergeMap((body) => ajax({
url: "/admin/api/session", url: "/admin/api/session",
method: "POST", method: "POST",
body, body,
responseType: "json" responseType: "json"
}).pipe( }).pipe(
rxjs.mapTo(true), rxjs.mapTo(true),
rxjs.catchError(() => rxjs.of(false)), rxjs.tap((ok) => ok && sessionSubject$.next(ok)),
rxjs.tap((ok) => ok && sessionSubject$.next(ok))
))
); );
} }

View file

@ -1,19 +1,21 @@
import rxjs from "../../lib/rx.js"; import rxjs from "../../lib/rx.js";
import ajax from "../../lib/ajax.js"; import ajax from "../../lib/ajax.js";
class AuditManager { const isLoading$ = new rxjs.BehaviorSubject(false);
get(searchParams = {}) {
const p = new URLSearchParams(); export function get(searchParams = new URLSearchParams()) {
Object.keys(searchParams).forEach((key) => {
p.set(key, searchParams[key]);
});
return ajax({ return ajax({
url: "/admin/api/audit?" + p.toString(), url: "/admin/api/audit?" + searchParams.toString(),
responseType: "json" responseType: "json"
}).pipe( }).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();
}

View 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$;
}

View 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$),
);
}

View file

@ -3,26 +3,34 @@ import ajax from "../../lib/ajax.js";
const isSaving$ = new rxjs.BehaviorSubject(false); 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() { export function isSaving() {
return isSaving$.asObservable(); return isSaving$.asObservable();
} }
export function get() { export function get() {
return ajax({ return config$;
url: "/admin/api/config",
withCredentials: true,
method: "GET",
responseType: "json"
}).pipe(
rxjs.map((res) => res.responseJSON.result)
);
} }
export function save() { export function save() {
return rxjs.pipe( return rxjs.pipe(
rxjs.tap(() => isSaving$.next(true)), rxjs.tap(() => isSaving$.next(true)),
rxjs.debounceTime(1000), rxjs.debounceTime(2000),
rxjs.delay(1000), rxjs.mergeMap((formData) => ajax({
rxjs.tap(() => isSaving$.next(false)) url: "/admin/api/config",
method: "POST",
responseType: "json",
body: formData,
}).pipe(rxjs.tap(() => isSaving$.next(false)))),
); );
} }

View file

@ -1,16 +1,19 @@
import rxjs from "../../lib/rx.js"; import rxjs from "../../lib/rx.js";
import ajax from "../../lib/ajax.js"; import ajax from "../../lib/ajax.js";
class LogManager { const log$ = ajax({
get(maxSize = 1000) { url: url(1024*100), // fetch the last 100kb by default
return ajax({ responseType: "text",
url: `/admin/api/logs?maxSize=${maxSize}`,
responseType: "text"
}).pipe( }).pipe(
rxjs.map(({ response }) => response) rxjs.map(({ response }) => response),
// rxjs.repeat({ delay: 10000 }),
); );
}
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 }),
);
}

View 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",
},
])),
);
}

View file

@ -98,6 +98,9 @@ export default async function(render) {
} }
return json; return json;
}), }),
// rxjs.map((formData) => JSON.parse(JSON.stringify(a, (key, value) => {
// if (value !== null) return value;
// })),
rxjs.mergeMap((creds) => createSession(creds)), rxjs.mergeMap((creds) => createSession(creds)),
rxjs.tap(() => navigate("/")), rxjs.tap(() => navigate("/")),
// TODO: error with notification // TODO: error with notification

View file

@ -9,7 +9,7 @@ import config$ from "./connectpage/model_config.js";
import $fork from "./connectpage/component_forkme.js"; import $fork from "./connectpage/component_forkme.js";
import $poweredby from "./connectpage/component_poweredby.js"; import $poweredby from "./connectpage/component_poweredby.js";
export default function(render) { export default async function(render) {
const $page = createElement(` const $page = createElement(`
<div class="component_page_connect"> <div class="component_page_connect">
<div data-bind="component_forkme"></div> <div data-bind="component_forkme"></div>
@ -17,7 +17,7 @@ export default function(render) {
<div data-bind="component_form"></div> <div data-bind="component_form"></div>
</div> </div>
<div data-bind="component_poweredby"></div> <div data-bind="component_poweredby"></div>
<style>${css}</style> <style>${await CSS(import.meta.url, "ctrl_connectpage.css")}</style>
</div> </div>
`); `);
render($page); render($page);
@ -56,5 +56,3 @@ export default function(render) {
applyMutation(qs($page, "[data-bind=\"centerthis\"]"), "style", "setProperty") applyMutation(qs($page, "[data-bind=\"centerthis\"]"), "style", "setProperty")
)); ));
} }
const css = await CSS(import.meta.url, "ctrl_connectpage.css");

View file

@ -10,13 +10,12 @@ import "../components/icon.js";
export default function(err) { export default function(err) {
return async function(render) { return async function(render) {
const css = await CSS(import.meta.url, "ctrl_error.css");
const [msg, trace] = processError(err); const [msg, trace] = processError(err);
const $page = createElement(` const $page = createElement(`
<div> <div>
<style>${css}</style> <style>${await CSS(import.meta.url, "ctrl_error.css")}</style>
<a href="/" class="backnav"> <a href="/" class="backnav">
<component-icon data-name="arrow_left"></component-icon> <component-icon name="arrow_left"></component-icon>
home home
</a> </a>
<div class="component_container"> <div class="component_container">
@ -35,13 +34,13 @@ export default function(err) {
render($page); render($page);
// feature: show error details // 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"]), rxjs.mapTo(["hidden"]),
applyMutation(qs($page, "pre"), "classList", "toggle") applyMutation(qs($page, "pre"), "classList", "toggle")
)); ));
// feature: refresh button // 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()) rxjs.tap(() => location.reload())
)); ));
@ -52,11 +51,11 @@ export default function(err) {
function processError(err) { function processError(err) {
let msg, trace; let msg, trace;
if (err instanceof AjaxError) { if (err instanceof AjaxError) {
msg = t(err.code()); msg = t(err.message);
trace = ` trace = `
type: ${err.type()} type: ${err.type()}
message: ${err.message}
code: ${err.code()} code: ${err.code()}
message: ${err.message}
trace: ${err.stack}`; trace: ${err.stack}`;
} else if (err instanceof ApplicationError) { } else if (err instanceof ApplicationError) {
msg = t(err.message); msg = t(err.message);

View file

@ -24,6 +24,9 @@ export default function(render) {
if (is_authenticated !== true) return navigate("/login"); if (is_authenticated !== true) return navigate("/login");
return navigate(`/files${home}`); return navigate(`/files${home}`);
}), }),
rxjs.catchError(() => navigate("/login")) rxjs.catchError(() => {
navigate("/login");
return rxjs.EMPTY;
}),
)); ));
}; };

View file

@ -10,6 +10,6 @@ export default function(render) {
effect(deleteSession().pipe( effect(deleteSession().pipe(
rxjs.tap(() => navigate("/")), rxjs.tap(() => navigate("/")),
rxjs.catchError(ctrlError(render)) rxjs.catchError(ctrlError(render)),
)); ));
} }

129
public/worker/sw_cache.js Normal file
View 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() {}
}

View file

@ -4,15 +4,17 @@ import (
"embed" "embed"
"errors" "errors"
"fmt" "fmt"
. "github.com/mickael-kerjean/filestash/server/common"
"io" "io"
"io/fs" "io/fs"
"net/http" "net/http"
URL "net/url"
"os" "os"
"path/filepath"
"regexp" "regexp"
"strings" "strings"
"text/template" "text/template"
. "github.com/mickael-kerjean/filestash"
. "github.com/mickael-kerjean/filestash/server/common"
) )
var ( var (
@ -28,25 +30,19 @@ func init() {
WWWDir = os.DirFS(GetAbsolutePath("../")) 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) { return func(ctx *App, res http.ResponseWriter, req *http.Request) {
var chroot string = GetAbsolutePath(_path) var chroot string = GetAbsolutePath(_path)
if srcPath := JoinPath(chroot, req.URL.Path); strings.HasPrefix(srcPath, chroot) == false { if srcPath := JoinPath(chroot, req.URL.Path); strings.HasPrefix(srcPath, chroot) == false {
http.NotFound(res, req) http.NotFound(res, req)
return 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) { func LegacyIndexHandler(ctx *App, res http.ResponseWriter, req *http.Request) {
urlObj, err := URL.Parse(req.URL.String()) url := req.URL.Path
if err != nil {
NotFoundHandler(ctx, res, req)
return
}
url := urlObj.Path
if url != URL_SETUP && Config.Get("auth.admin").String() == "" { if url != URL_SETUP && Config.Get("auth.admin").String() == "" {
http.Redirect(res, req, URL_SETUP, http.StatusTemporaryRedirect) http.Redirect(res, req, URL_SETUP, http.StatusTemporaryRedirect)
return return
@ -72,7 +68,74 @@ func IndexHandler(ctx *App, res http.ResponseWriter, req *http.Request) {
`))) `)))
return 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) { 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()) 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 { staticConfig := []struct {
ContentType string ContentType string
FileExt 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() head := res.Header()
acceptEncoding := req.Header.Get("Accept-Encoding") acceptEncoding := req.Header.Get("Accept-Encoding")
for _, cfg := range staticConfig { for _, cfg := range staticConfig {
@ -226,10 +282,7 @@ func ServeFile(res http.ResponseWriter, req *http.Request, filePath string) {
continue continue
} }
curPath := filePath + cfg.FileExt curPath := filePath + cfg.FileExt
file, err := WWWEmbed.Open("static/www" + curPath) file, err := fs.Open(curPath)
if env := os.Getenv("NODE_ENV"); env == "development" {
file, err = WWWDir.Open("server/ctrl/static/www" + curPath)
}
if err != nil { if err != nil {
continue continue
} else if stat, err := file.Stat(); err == nil { } 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("Etag", etag)
} }
head.Set("Content-Type", GetMimeType(filepath.Ext(filePath)))
if cfg.ContentType != "" { if cfg.ContentType != "" {
head.Set("Content-Encoding", cfg.ContentType) head.Set("Content-Encoding", cfg.ContentType)
} }
res.WriteHeader(statusCode) res.WriteHeader(http.StatusOK)
io.Copy(res, file) io.Copy(res, file)
file.Close() file.Close()
return return

View file

@ -63,7 +63,7 @@ func IndexHeaders(fn func(*App, http.ResponseWriter, *http.Request)) func(ctx *A
} else { } else {
cspHeader += fmt.Sprintf("frame-ancestors %s;", ori) cspHeader += fmt.Sprintf("frame-ancestors %s;", ori)
} }
header.Set("Content-Security-Policy", cspHeader) // header.Set("Content-Security-Policy", cspHeader)
fn(ctx, res, req) fn(ctx, res, req)
} }
} }

View file

@ -25,7 +25,6 @@ func (this Admin) Setup() Form {
Type: "select", Type: "select",
Value: "direct", Value: "direct",
Opts: []string{"direct", "password_only", "username_and_password"}, Opts: []string{"direct", "password_only", "username_and_password"},
Id: "strategy",
Description: `This plugin has 3 base strategies: 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. 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 }} 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 }}

View 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);
}

View 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
}

View 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);

View 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;
}

View 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
}

View 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);

View 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
}

View 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)

View file

@ -78,7 +78,7 @@ func Build(a App) *mux.Router {
// Webdav server / Shared Link // Webdav server / Shared Link
middlewares = []Middleware{IndexHeaders, SecureHeaders} 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} middlewares = []Middleware{WebdavBlacklist, SessionStart}
r.PathPrefix("/s/{share}").Handler(NewMiddlewareChain(WebdavHandler, middlewares, a)) r.PathPrefix("/s/{share}").Handler(NewMiddlewareChain(WebdavHandler, middlewares, a))
middlewares = []Middleware{ApiHeaders, SecureHeaders, RedirectSharedLoginIfNeeded, SessionStart, LoggedInOnly} 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/config", NewMiddlewareChain(PublicConfigHandler, middlewares, a)).Methods("GET")
r.HandleFunc("/api/backend", NewMiddlewareChain(AdminBackend, middlewares, a)).Methods("GET") r.HandleFunc("/api/backend", NewMiddlewareChain(AdminBackend, middlewares, a)).Methods("GET")
middlewares = []Middleware{StaticHeaders, SecureHeaders} middlewares = []Middleware{StaticHeaders, SecureHeaders}
r.PathPrefix("/assets").Handler(http.HandlerFunc(NewMiddlewareChain(StaticHandler("/"), middlewares, a))).Methods("GET") r.PathPrefix("/assets").Handler(http.HandlerFunc(NewMiddlewareChain(LegacyStaticHandler("/"), middlewares, a))).Methods("GET")
r.HandleFunc("/favicon.ico", NewMiddlewareChain(StaticHandler("/assets/logo/"), middlewares, a)).Methods("GET") r.HandleFunc("/favicon.ico", NewMiddlewareChain(LegacyStaticHandler("/assets/logo/"), middlewares, a)).Methods("GET")
r.HandleFunc("/sw_cache.js", NewMiddlewareChain(StaticHandler("/assets/worker/"), middlewares, a)).Methods("GET") r.HandleFunc("/sw_cache.js", NewMiddlewareChain(LegacyStaticHandler("/assets/worker/"), middlewares, a)).Methods("GET")
// Other endpoints // Other endpoints
middlewares = []Middleware{ApiHeaders} middlewares = []Middleware{ApiHeaders}
@ -110,8 +110,10 @@ func Build(a App) *mux.Router {
} }
initPluginsRoutes(r, &a) initPluginsRoutes(r, &a)
r.PathPrefix("/admin").Handler(http.HandlerFunc(NewMiddlewareChain(IndexHandler, middlewares, a))).Methods("GET") middlewares = []Middleware{SecureHeaders}
r.PathPrefix("/").Handler(http.HandlerFunc(NewMiddlewareChain(IndexHandler, middlewares, a))).Methods("GET", "POST") 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 return r
} }