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
cover.*
www
*.test.js
__snapshots__
.gitignore
filestash-enterprise

View File

@ -10,7 +10,7 @@ build_frontend:
NODE_ENV=production npm run build
build_backend:
CGO_ENABLED=0 go build -ldflags="-extldflags=-static" -mod=vendor --tags "fts5" -o dist/filestash main.go
CGO_ENABLED=0 go build -ldflags="-extldflags=-static" -mod=vendor --tags "fts5" -o dist/filestash cmd/main.go
clean_frontend:
rm -rf server/ctrl/static/www/

View File

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

View File

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

24
embed.go Normal file
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;
padding: 0 15px;
}
.formbuilder img {
max-height: 110px;
border: 8px solid rgba(0, 0, 0, 0);
}
.formbuilder .fileupload-image img {
height: 150px;
width: 100%;

View File

@ -1,4 +1,5 @@
.component_skeleton {
width: 100%;
height: 30px;
background: linear-gradient(110deg, rgba(0,0,0,0.02) 8%, rgba(0,0,0,0.04) 18%, rgba(0,0,0,0.02) 33%);
border-radius: 5px;

View File

@ -10,40 +10,41 @@
@import url("./designsystem_darkmode.css");
@import url("./designsystem_skeleton.css");
@import url("./designsystem_utils.css");
@import url("./designsystem_alert.css");
/* latin-ext */
@font-face {
font-family: 'Source Code Pro';
font-family: "Source Code Pro";
font-style: normal;
font-weight: 400;
src: local('Source Code Pro'), local('SourceCodePro-Regular'), url(/assets/fonts/SourceCodePro-Regular-400-latin-ext.woff2) format('woff2');
src: local("Source Code Pro"), local("SourceCodePro-Regular"), url(/assets/fonts/SourceCodePro-Regular-400-latin-ext.woff2) format("woff2");
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Source Code Pro';
font-family: "Source Code Pro";
font-style: normal;
font-weight: 400;
src: local('Source Code Pro'), local('SourceCodePro-Regular'), url(/assets/fonts/SourceCodePro-Regular-400-latin.woff2) format('woff2');
src: local("Source Code Pro"), local("SourceCodePro-Regular"), url(/assets/fonts/SourceCodePro-Regular-400-latin.woff2) format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* latin-ext */
@font-face {
font-family: 'Source Code Pro';
font-family: "Source Code Pro";
font-style: normal;
font-weight: 600;
src: local('Source Code Pro Semibold'), local('SourceCodePro-Semibold'), url(/assets/fonts/SourceCodePro-Semibold-600-latin-ext.woff2) format('woff2');
src: local("Source Code Pro Semibold"), local("SourceCodePro-Semibold"), url(/assets/fonts/SourceCodePro-Semibold-600-latin-ext.woff2) format("woff2");
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Source Code Pro';
font-family: "Source Code Pro";
font-style: normal;
font-weight: 600;
src: local('Source Code Pro Semibold'), local('SourceCodePro-Semibold'), url(/assets/fonts/SourceCodePro-Semibold-600-latin.woff2) format('woff2');
src: local("Source Code Pro Semibold"), local("SourceCodePro-Semibold"), url(/assets/fonts/SourceCodePro-Semibold-600-latin.woff2) format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

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

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;
let attr = `name="${path.join(".")}" `;
if (id) attr += `id="${id}" `;
if (placeholder) attr += `placeholder="${safe(placeholder, "\"")}" `;
if (!autocomplete) attr += "autocomplete=\"off\" autocorrect=\"off\" autocapitalize=\"off\" spellcheck=\"off\" ";
if (id) attr += `id="${safe(id)}" `;
if (placeholder) attr += `placeholder="${safe(placeholder)}" `;
if (!autocomplete || props.autocomplete === false) attr += "autocomplete=\"off\" autocorrect=\"off\" autocapitalize=\"off\" spellcheck=\"off\" ";
if (required) attr += "required ";
if (readonly) attr += "readonly ";
switch (type) {
case "text": // TODO
const dataListId = gid("list_");
const $input = createElement(`
<input ${safe(attr)}
case "text":
if (!datalist) return createElement(`
<input ${attr}
type="text"
value="${safe(value, "\"") || ""}"
value="${safe(value)}"
class="component_input"
/>
`);
if (!datalist) return $input;
const $wrapper = window.document.createElement("span");
const $datalist = window.document.createElement("datalist");
$wrapper.appendChild($input);
const dataListId = gid("list_");
const $input = createElement(`
<input ${attr}
list="${dataListId}"
datalist="${datalist.join(",")}"
type="text"
value="${safe(value)}"
class="component_input"
/>
`);
const $wrapper = document.createElement("span");
const $datalist = document.createElement("datalist");
$datalist.setAttribute("id", dataListId);
$wrapper.appendChild($input);
$wrapper.appendChild($datalist);
(props.multi ? multicomplete(value, datalist) : (datalist || [])).forEach((value) => {
$datalist.appendChild(createElement(`<option value="${value}"/>`))
});
if (!props.multi) return $wrapper;
$input.refresh = () => {
const _datalist = $input.getAttribute("datalist").split(",");
multicomplete($input.value, _datalist).forEach((value) => {
$datalist.appendChild(createElement(`<option value="${value}"/>`));
});
};
$input.oninput = (e) => {
for (const $option of $datalist.children) {
$option.remove();
}
$input.refresh();
};
return $wrapper;
case "enable":
return createElement(`
<div class="component_checkbox">
<input
${attr}
type="checkbox"
${(value || props.default) ? "checked" : ""}
${(value === null ? props.default : value) ? "checked" : ""}
/>
<span className="indicator"></span>
</div>
@ -92,19 +118,18 @@ function $renderInput(options = {}) {
case "number":
return createElement(`
<input
${safe(attr)}
${attr}
type="number"
value="${safe(value, "\"") || ""}"
value="${safe(value)}"
class="component_input"
/>
`);
case "password":
// TODO: click eye
const $node = createElement(`
<div class="formbuilder_password">
<input
${safe(attr)}
value="${safe(value, "\"") || ""}"
${attr}
value="${safe(value)}"
type="password"
class="component_input"
/>
@ -124,21 +149,21 @@ function $renderInput(options = {}) {
case "long_password":
// TODO
case "long_text":
return createElement(`
<textarea ${safe(attr)} class="component_textarea" rows="8">
</textarea>
const $textarea = createElement(`
<textarea ${attr} class="component_textarea" rows="8" placeholder="${safe(props.default)}"></textarea>
`);
if (value) $textarea.value = value;
return $textarea;
case "bcrypt":
return createElement(`
<input
type="password"
${safe(attr)}
value="${safe(value, "\"") || ""}"
${attr}
value="${safe(value)}"
readonly
class="component_input"
/>
`);
// TODO
case "hidden":
return createElement(`
<input
@ -151,24 +176,40 @@ function $renderInput(options = {}) {
return createElement(`
<div class="component_checkbox">
<input
${safe(attr)}
${attr}
type="checkbox"
${(value || props.default) ? "checked" : ""}
${(value === null ? props.default : value) ? "checked" : ""}
/>
<span class="indicator"></span>
</div>
`);
case "select":
const renderOption = (name) => `<option name="${safe(name)}">${safe(name)}</option>`;
const renderOption = (name) => {
const optName = safe(name);
const formVal = safe(value || props.default);
return `
<option
name="${optName}"
${(optName === formVal) && "selected"}
>
${optName}
</option>
`;
}
return createElement(`
<select class="component_select" ${safe(attr)}>
<select
${attr}
value="${safe(value || props.default)}"
class="component_select"
>
${(options || []).map(renderOption)}
</select>
`);
case "date":
return createElement(`
<input
${safe(attr)}
${attr}
value="${safe(value || props.default)}"
type="date"
class="component_input"
/>
@ -176,7 +217,8 @@ function $renderInput(options = {}) {
case "datetime":
return createElement(`
<input
${safe(attr)}
${attr}
value="${safe(value || props.default)}"
type="datetime-local"
class="component_input"
/>
@ -191,7 +233,7 @@ function $renderInput(options = {}) {
value="unknown element type ${type}"
type="text"
class="component_input"
path="${safe(path.join("."))}"
name="${safe(path.join("."))}"
readonly
/>
`);
@ -213,3 +255,10 @@ export function format(name) {
})
.join(" ");
};
export function multicomplete(input, datalist) {
input = input.trim().replace(/,$/g, "");
const current = input.split(",").map((val) => val.trim()).filter((t) => !!t);
const diff = datalist.filter((x) => current.indexOf(x) === -1);
return diff.map((candidate) => input.length === 0 ? candidate : `${input}, ${candidate}`);
}

View File

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

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 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 { animate } from "../lib/animate.js";
import { qs } from "../lib/dom.js";
import { qs, qsa } from "../lib/dom.js";
import { CSS } from "../helpers/loader.js";
let _observables = [];
const effect = (obs) => _observables.push(obs.subscribe());
const free = () => {
for (let i = 0; i < _observables.length; i++) {
_observables[i].unsubscribe();
export default class Modal {
static open($node, opts = {}) {
find().trigger($node, opts);
}
}
_observables = [];
};
export default class Modal extends HTMLElement {
async trigger($node, opts = {}) {
const { onQuit } = opts;
const $modal = createElement(`
const createModal = async () => createElement(`
<div class="component_modal" id="modal-box">
<style>${await CSS(import.meta.url, "modal.css")}</style>
<div>
@ -26,28 +20,44 @@ export default class Modal extends HTMLElement {
<div class="modal-message" data-bind="body"><!-- MODAL BODY --></div>
</div>
<div class="buttons">
<button type="submit" class="emphasis">OK</button>
<button type="button"></button>
<button type="submit" class="emphasis"></button>
</div>
</div>
</div>
</div>`);
this.replaceChildren($modal);
</div>
`);
// feature: setup the modal body
effect(rxjs.of([$node]).pipe(
applyMutation(qs($modal, "[data-bind=\"body\"]"), "appendChild")
class ModalComponent extends window.HTMLElement {
async trigger($node, opts = {}) {
const $modal = await createModal();
const close$ = new rxjs.Subject();
const { onQuit = nop, withButtonsLeft = null, withButtonsRight = null } = opts;
// feature: build the dom
qs($modal, `[data-bind="body"]`).replaceChildren($node);
this.replaceChildren($modal);
qsa($modal, `.component_popup > div.buttons > button`).forEach(($button, i) => {
let currentLabel = null;
if (i === 0) currentLabel = withButtonsLeft;
else if (i === 1) currentLabel = withButtonsRight;
if (currentLabel === null) return $button.remove();
$button.textContent = currentLabel;
$button.onclick = () => close$.next(currentLabel);
});
effect(rxjs.fromEvent($modal, "click").pipe(
rxjs.filter((e) => e.target.getAttribute("id") === "modal-box"),
rxjs.tap(() => close$.next()),
));
effect(rxjs.fromEvent(window, "keydown").pipe(
rxjs.filter((e) => e.keyCode === 27),
rxjs.tap(() => close$.next()),
));
// feature: closing the modal
effect(rxjs.merge(
rxjs.fromEvent($modal, "click").pipe(
rxjs.filter((e) => e.target.getAttribute("id") === "modal-box")
),
rxjs.fromEvent(window, "keydown").pipe(
rxjs.filter((e) => e.keyCode === 27)
)
).pipe(
rxjs.tap(() => typeof onQuit === "function" && onQuit()),
effect(close$.pipe(
rxjs.tap((label) => onQuit(label)),
rxjs.tap(() => animate(qs($modal, "div > div"), {
time: 200,
keyframes: [
@ -91,7 +101,7 @@ export default class Modal extends HTMLElement {
rxjs.map(() => {
let size = 300;
const $box = document.querySelector("#modal-box > div");
if ($box instanceof HTMLElement) size = $box.offsetHeight;
if ($box instanceof window.HTMLElement) size = $box.offsetHeight;
size = Math.round((document.body.offsetHeight - size) / 2);
if (size < 0) return 0;
@ -104,4 +114,19 @@ export default class Modal extends HTMLElement {
}
}
customElements.define("component-modal", Modal);
customElements.define("component-modal", ModalComponent);
let _observables = [];
const effect = (obs) => _observables.push(obs.subscribe());
const free = () => {
for (let i = 0; i < _observables.length; i++) {
_observables[i].unsubscribe();
}
_observables = [];
};
function find() {
const $dom = document.body.querySelector("component-modal");
if (!($dom instanceof ModalComponent)) throw new ApplicationError("INTERNAL_ERROR", "assumption failed: wrong type modal component");
return $dom;
}

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

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>
<script type="module">
import main from "/lib/skeleton/index.js";
const routes = {
"/login": "/pages/ctrl_connectpage.js",
"/logout": "/pages/ctrl_logout.js",
"/": "/pages/ctrl_homepage.js",
"/files/.*": "/pages/ctrl_filespage.js",
"/view/.*": "/pages/ctrl_viewerpage.js",
// /tags/.* -> "pages/ctrl_tags.js",
// /s/.* -> "/pages/ctrl_share.js",
"/admin/backend": "/pages/adminpage/ctrl_backend.js",
"/admin/settings": "/pages/adminpage/ctrl_settings.js",
"/admin/logs": "/pages/adminpage/ctrl_logger.js",
"/admin/about": "/pages/adminpage/ctrl_about.js",
"/admin/setup": "/pages/adminpage/ctrl_setup.js",
"/admin/": "/pages/ctrl_adminpage.js",
"": "/pages/ctrl_notfound.js",
};
import routes from "/boot/router_frontoffice.js";
main(document.getElementById("app"), routes, {
spinner: `<component-loader></component-loader>`,
beforeStart: import("/pages/ctrl_boot.js"),
beforeStart: import("/boot/ctrl_boot_frontoffice.js"),
});
</script>

View File

@ -14,15 +14,7 @@
</div>
<script type="module">
import main from "/lib/skeleton/index.js";
const routes = {
"/admin/backend": "/pages/adminpage/ctrl_backend.js",
"/admin/settings": "/pages/adminpage/ctrl_settings.js",
"/admin/logs": "/pages/adminpage/ctrl_log.js",
"/admin/about": "/pages/adminpage/ctrl_about.js",
"/admin/setup": "/pages/adminpage/ctrl_setup.js",
"/admin/": "/pages/ctrl_adminpage.js",
"": "/pages/ctrl_notfound.js",
};
import routes from "/boot/router_backoffice.js";
main(document.getElementById("app"), routes, {
spinner: `<component-loader></component-loader>`,
beforeStart: import("/boot/ctrl_boot_backoffice.js"),
@ -31,6 +23,8 @@
<script type="module" src="/components/modal.js"></script>
<component-modal></component-modal>
<script type="module" src="/components/notification.js"></script>
<component-notification></component-notification>
<noscript>
<div>

View File

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

View File

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

View File

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

View File

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

View File

@ -8,19 +8,26 @@ export default rxjs;
export const ajax = ajaxModule.ajax;
export function effect(obs) {
const tmp = obs.subscribe(() => {}, (err) => console.error("effect", err));
const tmp = obs.subscribe(() => {}, (err) => { throw err; });
onDestroy(() => tmp.unsubscribe());
}
export function applyMutation($node, ...keys) {
if (!$node) throw new Error("undefined node");
const getFn = (obj, arg0, ...args) => {
if (arg0 === undefined) return obj;
const next = obj[arg0];
return getFn(next.bind ? next.bind(obj) : next, ...args);
};
export function applyMutation($node, ...keys) {
if (!$node) throw new Error("undefined node");
const execute = getFn($node, ...keys);
return rxjs.tap((val) => execute(...val));
return rxjs.tap((val) => Array.isArray(val) ? execute(...val) : execute(val));
}
export function applyMutations($node, ...keys) {
if (!$node) throw new Error("undefined node");
const execute = getFn($node, ...keys);
return rxjs.tap((vals) => vals.forEach((val) => {
execute(val);
}));
}
export function stateMutation($node, attr) {
@ -31,3 +38,10 @@ export function stateMutation($node, attr) {
export function preventDefault() {
return rxjs.tap((e) => e.preventDefault());
}
export function onClick($node) {
if (!$node) return rxjs.EMPTY;
return rxjs.fromEvent($node, "click").pipe(
rxjs.map(() => $node),
);
}

View File

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

View File

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

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

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";
export function createSession(authenticationRequest) {
// TODO: how to handle null values?
// rxjs.tap((a) => console.log(JSON.stringify(a, (key, value) => {
// if (value !== null) return value;
// }, 4))),
return ajax({
method: "POST",
url: "/api/session",

View File

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

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

View File

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

View File

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

View File

@ -1,47 +1,293 @@
import { createElement } from "../../lib/skeleton/index.js";
import rxjs, { effect, applyMutation, applyMutations, onClick } from "../../lib/rx.js";
import ajax from "../../lib/ajax.js";
import { createForm, mutateForm } from "../../lib/form.js";
import { qs, qsa } from "../../lib/dom.js";
import { formTmpl } from "../../components/form.js";
import { generateSkeleton } from "../../components/skeleton.js";
import { get as getConfig } from "../../model/config.js";
export default function(render) {
import {
initMiddleware, initStorage,
getMiddlewareAvailable, getMiddlewareEnabled, toggleMiddleware,
getBackendAvailable, getBackendEnabled,
} from "./ctrl_backend_state.js";
import { formObjToJSON$, renderLeaf } from "./helper_form.js";
import { get as getAdminConfig, save as saveConfig } from "./model_config.js";
import "./component_box-item.js";
export default async function(render) {
const $page = createElement(`
<div>
<h2 class="hidden">Authentication Middleware</h2>
<div class="box-container">
<div class="box-item pointer no-select">
<div>admin <span class="no-select">
<span class="icon">+</span>
</span>
</div>
</div>
<div class="box-item pointer no-select">
<div>htpasswd <span class="no-select">
<span class="icon">+</span>
</span>
</div>
</div>
<div class="box-item pointer no-select">
<div>ldap <span class="no-select">
<span class="icon">+</span>
</span>
</div>
</div>
<div class="box-item pointer no-select active">
<div>openid <span class="no-select">
<span class="icon">
<img class="component_icon" draggable="false" src="/assets/icons/delete.svg" alt="delete">
</span>
</span>
</div>
</div>
<div class="box-item pointer no-select">
<div>passthrough <span class="no-select">
<span class="icon">+</span>
</span>
</div>
</div>
<div class="box-item pointer no-select">
<div>saml <span class="no-select">
<span class="icon">+</span>
</span>
${generateSkeleton(5)}
</div>
<div style="min-height: 300px">
<form data-bind="idp"></form>
<form data-bind="attribute-mapping"></div>
</div>
</div>
`);
render($page);
await initMiddleware();
await initStorage();
// feature: setup the buttons
const init$ = getMiddlewareAvailable().pipe(
rxjs.first(),
rxjs.map((specs) => Object.keys(specs).map((label) => createElement(`
<div is="box-item" data-label="${label}"></div>
`))),
rxjs.tap(() => {
qs($page, "h2").classList.remove("hidden");
qs($page, `.box-container`).innerHTML = "";
}),
applyMutations(qs($page, ".box-container"), "appendChild"),
rxjs.share(),
);
effect(init$);
// feature: state of buttons
effect(init$.pipe(
rxjs.concatMap(() => getMiddlewareEnabled()),
rxjs.filter((backend) => !!backend),
rxjs.tap((backend) => qsa($page, `[is="box-item"]`).forEach(($button) => {
$button.getAttribute("data-label") === backend ?
$button.classList.add("active") :
$button.classList.remove("active");
})),
));
// feature: click to select a middleware
effect(init$.pipe(
rxjs.mergeMap(($nodes) => $nodes),
rxjs.mergeMap(($node) => onClick($node)),
rxjs.map(($node) => toggleMiddleware($node.getAttribute("data-label"))),
saveMiddleware,
));
// feature: setup forms - we insert everything in the DOM so we don't lose
// transient state when clicking around
const setupIDPForm$ = getMiddlewareAvailable().pipe(
rxjs.combineLatestWith(getAdminConfig().pipe(
rxjs.first(),
rxjs.map((cfg) => ({
type: cfg?.middleware?.identity_provider?.type?.value,
params: JSON.parse(cfg?.middleware?.identity_provider?.params?.value),
})),
)),
rxjs.concatMap(async ([availableSpecs, idpState = {}]) => {
const { type, params } = idpState;
const idps = []
for (let key in availableSpecs) {
let idpSpec = availableSpecs[key];
delete idpSpec.type;
if (key === type) idpSpec = mutateForm(idpSpec, params);
const $idp = await createForm({ [key]: idpSpec }, formTmpl({
renderLeaf,
autocomplete: false,
}));
$idp.classList.add("hidden");
$idp.setAttribute("id", key);
idps.push($idp);
}
return idps;
}),
applyMutations(qs($page, `[data-bind="idp"]`), "appendChild"),
rxjs.share(),
);
effect(setupIDPForm$);
// feature: handle visibility of the identity_provider form to match the selected midleware
effect(setupIDPForm$.pipe(
rxjs.concatMap(() => getMiddlewareEnabled()),
rxjs.tap((currentMiddleware) => {
qsa($page, `[data-bind="idp"] .formbuilder`).forEach(($node) => {
$node.getAttribute("id") === currentMiddleware ?
$node.classList.remove("hidden") :
$node.classList.add("hidden");
});
const $attrMap = qs($page, `[data-bind="attribute-mapping"]`);
currentMiddleware ?
$attrMap.classList.remove("hidden") :
$attrMap.classList.add("hidden");
qsa($page, ".box-item").forEach(($button) => {
const $icon = qs($button, ".icon");
$icon.style.transition = "transform 0.2s ease";
if (qs($button, "strong").textContent === currentMiddleware) {
$button.classList.add("active");
$icon.style.transform = "rotate(45deg)";
} else {
$button.classList.remove("active");
$icon.style.transform = "";
}
})
}),
));
// feature: setup the attribute mapping form
const setupAMForm$ = init$.pipe(
rxjs.mapTo({
"attribute_mapping": {
"related_backend": {
"type": "text",
"datalist": [],
"multi": true,
"autocomplete": false,
"value": "",
},
// dynamic form here is generated reactively from the value of the "related_backend" field
}
}),
// related_backend value
rxjs.mergeMap((spec) => getAdminConfig().pipe(
rxjs.first(),
rxjs.map((cfg) => {
spec.attribute_mapping.related_backend.value = cfg?.middleware?.attribute_mapping?.related_backend?.value;
return spec;
}),
)),
rxjs.concatMap(async (specs) => await createForm(specs, formTmpl({}))),
applyMutation(qs($page, `[data-bind="attribute-mapping"]`), "replaceChildren"),
rxjs.share(),
);
effect(setupAMForm$);
// feature: setup autocompletion of related backend field
effect(setupAMForm$.pipe(
rxjs.switchMap(() => getBackendEnabled()),
rxjs.map((backends) => backends.map(({ label }) => label)),
rxjs.tap((datalist) => {
const $input = $page.querySelector(`[name="attribute_mapping.related_backend"]`);
$input.setAttribute("datalist", datalist.join(","));
$input.refresh();
}),
));
// feature: related backend values triggers creation/deletion of related backends
effect(setupAMForm$.pipe(
rxjs.switchMap(() => rxjs.merge(
getBackendEnabled().pipe(rxjs.map(() => qs($page, `[name="attribute_mapping.related_backend"]`).value)),
rxjs.fromEvent(qs($page, `[name="attribute_mapping.related_backend"]`), "input").pipe(
rxjs.map((e) => e.target.value),
),
)),
rxjs.map((value) => value.split(",").map((val) => val.trim()).filter((t) => !!t)),
rxjs.mergeMap((inputBackends) => getBackendEnabled().pipe(
rxjs.first(),
rxjs.map((enabledBackends) => inputBackends
.map((label) => enabledBackends.find((b) => b.label === label))
.filter((label) => !!label)),
)),
rxjs.mergeMap((backends) => getBackendAvailable().pipe(rxjs.first(), rxjs.map((specs) => {
// we don't want to show the "normal" form but a flat version of it
// so we're getting rid of anything that could make some magic happen like toggle and
// ids which enable those interactions
for (let key in specs) {
for (let input in specs[key]) {
if (specs[key][input]["type"] === "enable") {
delete specs[key][input];
} else if ("id" in specs[key][input]) {
delete specs[key][input]["id"];
}
}
}
return [backends, specs];
}))),
rxjs.map(([backends, formSpec]) => {
let spec = {};
backends.forEach(({ label, type }) => {
if (formSpec[type]) spec[label] = JSON.parse(JSON.stringify(formSpec[type]));
});
return spec
}),
rxjs.mergeMap((spec) => getAdminConfig().pipe(
rxjs.first(),
rxjs.map((cfg) => JSON.parse(cfg?.middleware?.attribute_mapping?.params?.value)),
rxjs.map((cfg) => {
// transform the form state from legacy format (= an object struct which was replicating the spec object)
// to the new format which leverage the dom (= or the input name attribute to be precise) to store the entire schema
let state = {};
for (let key1 in cfg) {
for (let key2 in cfg[key1]) {
state[`${key1}.${key2}`] = cfg[key1][key2];
}
}
return [spec, state];
}),
)),
rxjs.map(([formSpec, formState]) => mutateForm(formSpec, formState)),
rxjs.mergeMap(async (formSpec) => await createForm(formSpec, formTmpl({
renderLeaf: () => createElement(`<label></label>`),
}))),
rxjs.tap(($node) => {
let $relatedBackendField;
$page.querySelectorAll(`[data-bind="attribute-mapping"] fieldset`).forEach(($el, i) => {
if (i === 0) $relatedBackendField = $el;
else $el.remove();
});
$relatedBackendField?.appendChild($node);
}),
));
// feature: form input change handler
effect(setupAMForm$.pipe(
rxjs.switchMap(() => rxjs.fromEvent($page, "input")),
rxjs.mergeMap(() => getMiddlewareEnabled().pipe(rxjs.first())),
saveMiddleware,
));
}
const saveMiddleware = rxjs.pipe(
rxjs.map((authType) => {
const middleware = {
identity_provider: {},
attribute_mapping: {},
};
if (!authType) return middleware;
let formValues = [...new FormData(document.querySelector(`[data-bind="idp"]`))];
middleware.identity_provider = {
type: authType,
params: JSON.stringify(
formValues
.filter(([key, value]) => key.startsWith(`${authType}.`)) // remove elements that aren't in scope
.map(([key, value]) => [key.replace(new RegExp(`^${authType}\.`), ""), value]) // format the relevant keys
.reduce((acc, [key, value]) => { // transform onto something ready to be saved
if (key === "type") return acc;
return {
...acc,
[key]: value,
};
}, {}),
),
};
formValues = [...new FormData(document.querySelector(`[data-bind="attribute-mapping"]`))];
middleware.attribute_mapping = {
related_backend: formValues.shift()[1],
params: JSON.stringify(formValues.reduce((acc, [key, value]) => {
const k = key.split(".");
if (k.length !== 2) return acc;
if (!acc[k[0]]) acc[k[0]] = {};
if (value !== "") acc[k[0]][k[1]] = value;
return acc;
}, {})),
};
return middleware;
}),
rxjs.mergeMap((middleware) => getAdminConfig().pipe(
rxjs.first(),
formObjToJSON$(),
rxjs.map((config) => [middleware, config]),
)),
rxjs.map(([middleware, config]) => ({...config, middleware})),
rxjs.mergeMap((newConfig) => getConfig().pipe(
rxjs.first(),
rxjs.map(({ connections }) => ({ ...newConfig, connections })),
)),
saveConfig(),
);

View File

@ -1,30 +1,137 @@
import { createElement } from "../../lib/skeleton/index.js";
import rxjs, { effect, applyMutation } from "../../lib/rx.js";
import { qs } from "../../lib/dom.js";
import backend$ from "../connectpage/model_backend.js";
import rxjs, { effect, applyMutations, onClick } from "../../lib/rx.js";
import { createForm } from "../../lib/form.js";
import { qs, qsa } from "../../lib/dom.js";
import { formTmpl } from "../../components/form.js";
import { generateSkeleton } from "../../components/skeleton.js";
export default function(render) {
import { initStorage, getBackendAvailable, getBackendEnabled, addBackendEnabled, removeBackendEnabled } from "./ctrl_backend_state.js";
import { formObjToJSON$ } from "./helper_form.js";
import { get as getAdminConfig, save as saveConfig } from "./model_config.js";
import "./component_box-item.js";
export default async function(render) {
const $page = createElement(`
<div class="component_storagebackend">
<h2>Storage Backend</h2>
<div class="box-container" data-bind="backend-available"></div>
<div class="box-container" data-bind="backend-available">
${generateSkeleton(10)}
</div>
<form data-bind="backend-enabled"></form>
</div>
`);
render($page);
await initStorage();
effect(backend$.pipe(
rxjs.mergeMap((specs) => Object.keys(specs)),
rxjs.map((label) => [createElement(`
<div class="box-item pointer no-select">
<div>
${label}
<span class="no-select">
<span class="icon">+</span>
</span>
// feature: setup the buttons
const init$ = getBackendAvailable().pipe(
rxjs.tap(() => qs($page, `[data-bind="backend-available"]`).innerHTML = ""),
rxjs.mergeMap((specs) => Promise.all(Object.keys(specs).map((label) => createElement(`
<div is="box-item" data-label="${label}"></div>
`)))),
applyMutations(qs($page, `[data-bind="backend-available"]`), "appendChild"),
rxjs.share(),
);
effect(init$);
// feature: state of buttons
effect(init$.pipe(
rxjs.mergeMap(() => getBackendEnabled()),
rxjs.map((enabled) => {
const enabledSet = new Set();
enabled.forEach(({ type }) => {
enabledSet.add(type);
});
return enabledSet;
}),
rxjs.tap((backends) => qsa($page, `[is="box-item"]`).forEach(($button) => {
backends.has($button.getAttribute("data-label")) ?
$button.classList.add("active") :
$button.classList.remove("active");
})),
));
// feature: click to select a backend
effect(init$.pipe(
rxjs.mergeMap(($nodes) => $nodes),
rxjs.mergeMap(($node) => onClick($node)),
rxjs.map(($node) => addBackendEnabled($node.getAttribute("data-label"))),
saveConnections,
));
// feature: setup form
const setupForm$ = getBackendEnabled().pipe(
// initialise the forms
rxjs.mergeMap((enabled) => Promise.all(enabled.map(({ type, label }) => createForm({ [type]: {
"": { type: "text", placeholder: "Label", value: label },
}}, formTmpl({
renderLeaf: () => createElement(`<label></label>`),
renderNode: ({ label, format }) => {
const $fieldset = createElement(`
<fieldset>
<legend class="no-select">
${format(label)}
</legend>
<div data-bind="children"></div>
</fieldset>
`);
const $remove = createElement(`
<div class="icons no-select">
<img class="component_icon" draggable="false" src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA1MS45NzYgNTEuOTc2Ij4KICA8cGF0aCBzdHlsZT0iZmlsbDojMDAwMDAwO2ZpbGwtb3BhY2l0eTowLjUzMzMzMjg1O3N0cm9rZS13aWR0aDoxLjQ1NjgxMTE5IiBkPSJtIDQxLjAwNTMxLDQwLjg0NDA2MiBjIC0xLjEzNzc2OCwxLjEzNzc2NSAtMi45ODIwODgsMS4xMzc3NjUgLTQuMTE5ODYxLDAgTCAyNi4wNjg2MjgsMzAuMDI3MjM0IDE0LjczNzU1MSw0MS4zNTgzMSBjIC0xLjEzNzc3MSwxLjEzNzc3MSAtMi45ODIwOTMsMS4xMzc3NzEgLTQuMTE5ODYxLDAgLTEuMTM3NzcyMiwtMS4xMzc3NjggLTEuMTM3NzcyMiwtMi45ODIwODggMCwtNC4xMTk4NjEgTCAyMS45NDg3NjYsMjUuOTA3MzcyIDExLjEzMTkzOCwxNS4wOTA1NTEgYyAtMS4xMzc3NjQ3LC0xLjEzNzc3MSAtMS4xMzc3NjQ3LC0yLjk4MzU1MyAwLC00LjExOTg2MSAxLjEzNzc3NCwtMS4xMzc3NzIxIDIuOTgyMDk4LC0xLjEzNzc3MjEgNC4xMTk4NjUsMCBMIDI2LjA2ODYyOCwyMS43ODc1MTIgMzYuMzY5NzM5LDExLjQ4NjM5OSBjIDEuMTM3NzY4LC0xLjEzNzc2OCAyLjk4MjA5MywtMS4xMzc3NjggNC4xMTk4NjIsMCAxLjEzNzc2NywxLjEzNzc2OSAxLjEzNzc2NywyLjk4MjA5NCAwLDQuMTE5ODYyIEwgMzAuMTg4NDg5LDI1LjkwNzM3MiA0MS4wMDUzMSwzNi43MjQxOTcgYyAxLjEzNzc3MSwxLjEzNzc2NyAxLjEzNzc3MSwyLjk4MjA5MSAwLDQuMTE5ODY1IHoiIC8+Cjwvc3ZnPgo=" alt="close">
</div>
`);
$fieldset.appendChild($remove);
return $fieldset;
},
}))))),
rxjs.map((nodeList) => {
if (nodeList.length === 0) return [createElement(`
<div class="alert">
You need to select at least 1 storage backend
</div>
`)]),
applyMutation(qs($page, "[data-bind=\"backend-available\"]"), "appendChild")
`)];
return nodeList;
}),
rxjs.tap(() => qs($page, `[data-bind="backend-enabled"]`).innerHTML = ""),
applyMutations(qs($page, `[data-bind="backend-enabled"]`), "appendChild"),
rxjs.share(),
);
effect(setupForm$);
// feature: remove an existing backend
effect(setupForm$.pipe(
rxjs.mergeMap(($nodes) => $nodes),
rxjs.mergeMap(($node) => onClick($node.querySelector(".icons"))),
rxjs.map(($node) => qs($node.parentElement, "input").value),
rxjs.map((label) => removeBackendEnabled(label)),
saveConnections,
));
// feature: form input change handler
effect(setupForm$.pipe(
rxjs.mergeMap((forms) => forms),
rxjs.mergeMap(($el) => rxjs.fromEvent($el, "input")),
rxjs.map(() => new FormData(qs($page, `[data-bind="backend-enabled"]`))),
rxjs.map((formData) => {
const connections = [];
for (const [type, label] of formData.entries()) {
connections.push({ type, label });
}
return connections;
}),
saveConnections,
));
}
const saveConnections = rxjs.pipe(
rxjs.mergeMap((connections) => getAdminConfig().pipe(
rxjs.first(),
formObjToJSON$(),
rxjs.map((config) => ({
...config,
connections,
})),
)),
saveConfig(),
);

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 rxjs, { effect, stateMutation, applyMutation } from "../../lib/rx.js";
import { qs } from "../../lib/dom.js";
import componentLogForm from "./ctrl_log_form.js";
import componentLogViewer from "./ctrl_log_viewer.js";
@ -21,8 +19,8 @@ function Page(render) {
`);
render(transition($page));
componentLogForm(createRender($page.querySelector(".component_logger")));
componentLogViewer(createRender($page.querySelector(".component_logviewer")));
componentLogForm(createRender($page.querySelector(".component_logger")));
componentAuditor(createRender($page.querySelector(".component_reporter")));
}

View File

@ -1,32 +1,66 @@
import { createElement } from "../../lib/skeleton/index.js";
import rxjs, { effect, stateMutation, applyMutation } from "../../lib/rx.js";
import { qs } from "../../lib/dom.js";
import { qs, qsa } from "../../lib/dom.js";
import { createForm } from "../../lib/form.js";
import { formTmpl } from "../../components/form.js";
import { generateSkeleton } from "../../components/skeleton.js";
import Audit from "./model_audit.js";
import { useForm$ } from "./helper_form.js";
import { get as getAudit, setLoader } from "./model_audit.js";
export default function(render) {
const $page = createElement(`
<div>
<form></form>
<form>
${generateSkeleton(10)}
</form>
<div data-bind="auditor"></div>
</div>
`);
render($page);
const audit$ = getAudit().pipe(rxjs.share());
// setup the form
effect(Audit.get().pipe(
// create the form on the dom
const setup$ = audit$.pipe(
rxjs.first(),
rxjs.map(({ form }) => form),
rxjs.map((formSpec) => createForm(formSpec, formTmpl())),
rxjs.mergeMap((promise) => rxjs.from(promise)),
rxjs.mergeMap((formSpec) => createForm(formSpec, formTmpl())),
rxjs.map(($form) => [$form]),
applyMutation(qs($page, "form"), "appendChild")
applyMutation(qs($page, "form"), "replaceChildren"),
);
effect(setup$);
// setup the form handler
effect(setup$.pipe(
rxjs.first(),
rxjs.tap(() => updateLoop($page, audit$)),
))
}
function updateLoop($page, audit$) {
// feature1: query result
effect(audit$.pipe(
rxjs.map(({ render }) => render),
stateMutation(qs($page, "[data-bind=\"auditor\"]"), "innerHTML"),
rxjs.tap(() => setLoader(false)),
));
// setup the result
effect(Audit.get().pipe(
rxjs.map(({ render }) => render),
stateMutation(qs($page, "[data-bind=\"auditor\"]"), "innerHTML")
// feature2: update to the query form
effect(rxjs.of(null).pipe(
useForm$(() => qsa($page, `form [name]`)),
rxjs.tap(() => setLoader(true)),
rxjs.debounceTime(1000),
rxjs.first(),
rxjs.map(() => qs($page, "form")),
rxjs.map(($form) => {
const formData = new FormData($form);
const p = new URLSearchParams();
for (const [key, value] of formData.entries()) {
if (!value) continue;
p.set(key.replace(new RegExp("^search\."), ""), value);
}
return p;
}),
rxjs.tap((p) => updateLoop($page, getAudit(p).pipe(rxjs.share()))),
));
}

View File

@ -1,11 +1,15 @@
import { createElement } from "../../lib/skeleton/index.js";
import rxjs, { effect, applyMutation } from "../../lib/rx.js";
import { qsa } from "../../lib/dom.js";
import { createForm, mutateForm } from "../../lib/form.js";
import t from "../../lib/locales.js";
import { generateSkeleton } from "../../components/skeleton.js";
import { createForm } from "../../lib/form.js";
import notification from "../../components/notification.js";
import { formTmpl } from "../../components/form.js";
import { get as getConfig } from "../../model/config.js";
import { get as getConfig } from "./model_config.js";
import { renderLeaf } from "./helper_form.js";
import { get as getAdminConfig, save as saveConfig } from "./model_config.js";
import { renderLeaf, useForm$, formObjToJSON$ } from "./helper_form.js";
export default function(render) {
const $form = createElement(`
@ -17,13 +21,37 @@ export default function(render) {
render($form);
// feature1: render the form
effect(getConfig().pipe(
const setup$ = getAdminConfig().pipe(
rxjs.map(({ log }) => ({ params: log })),
rxjs.map((formSpec) => createForm(formSpec, formTmpl({ renderLeaf }))),
rxjs.mergeMap((promise) => rxjs.from(promise)),
rxjs.map(($form) => [$form]),
applyMutation($form, "replaceChildren")
));
applyMutation($form, "replaceChildren"),
rxjs.share(),
);
effect(setup$);
// TODO feature2: response to form change
// feature2: form change
effect(setup$.pipe(
useForm$(() => qsa($form, `[name]`)),
rxjs.combineLatestWith(getAdminConfig().pipe(rxjs.first())),
rxjs.map(([formState, formSpec]) => {
const fstate = Object.fromEntries(Object.entries(formState).map(([key, value]) => ([
key.replace(new RegExp("^params\."), "log."),
value,
])));
return mutateForm(formSpec, fstate);
}),
formObjToJSON$(),
rxjs.combineLatestWith(getConfig().pipe(rxjs.first())),
rxjs.map(([adminConfig, publicConfig]) => {
adminConfig["connections"] = publicConfig["connections"];
return adminConfig;
}),
saveConfig(),
rxjs.catchError((err) => {
notification.error(err && err.message || t("Oops"));
return rxjs.EMPTY;
}),
));
}

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 rxjs, { effect, stateMutation } from "../../lib/rx.js";
import { qs } from "../../lib/dom.js";
import { CSS } from "../../helpers/loader.js";
import Log from "./model_log.js";
import { get as getLogs, url as getLogUrl } from "./model_log.js";
export default function(render) {
const $page = createElement(`<pre style="height:350px; max-height: 350px">…</pre>`);
export default async function(render) {
const $page = createElement(`
<div>
<style>${await CSS(import.meta.url, "ctrl_log_viewer.css")}</style>
<pre style="height:350px; max-height: 350px">…</pre>
<a href="${getLogUrl()}" download="${logname()}">
<button class="component_button primary">Download</button>
</a>
<br/><br/>
</div>
`);
const $log = qs($page, "pre");
render($page);
effect(Log.get().pipe(
stateMutation($page, "textContent")
effect(getLogs().pipe(
rxjs.map((logData) => logData + "\n\n\n\n\n"),
stateMutation($log, "textContent"),
rxjs.tap(() => {
if ($log?.scrollTop !== 0) return;
$log.scrollTop = $log.scrollHeight;
}),
rxjs.catchError(() => rxjs.EMPTY),
));
}
function logname() {
const t = new Date().toISOString().substring(0, 10).replace(/-/g, "");
return `access_${t}.log`;
};

View File

@ -2,17 +2,18 @@ import { createElement } from "../../lib/skeleton/index.js";
import rxjs, { effect, stateMutation, applyMutation, preventDefault } from "../../lib/rx.js";
import { qs } from "../../lib/dom.js";
import { transition, zoomIn } from "../../lib/animate.js";
import { AjaxError } from "../../lib/error.js";
import ctrlError from "../ctrl_error.js";
import { CSS } from "../../helpers/loader.js";
import notification from "../../components/notification.js";
import "../../components/icon.js";
import { authenticate$ } from "./model_admin_session.js";
import "../../components/icon.js";
export default async function(render) {
const css = await CSS(import.meta.url, "ctrl_login.css");
const $form = createElement(`
<div class="component_container component_page_adminlogin">
<style>${css}</style>
<style>${await CSS(import.meta.url, "ctrl_login.css")}</style>
<form>
<div class="input_group">
<input type="password" name="password" placeholder="Password" class="component_input" autocomplete>
@ -39,7 +40,16 @@ export default async function(render) {
applyMutation(qs($form, "component-icon"), "setAttribute"),
// STEP2: attempt to login
rxjs.map(() => ({ password: qs($form, "[name=\"password\"]").value })),
authenticate$(),
rxjs.switchMap((creds) => authenticate$(creds).pipe(
rxjs.catchError((err) => {
if (err instanceof AjaxError && err.code() === "INTERNAL_SERVER_ERROR") {
ctrlError(err)(render);
return rxjs.EMPTY;
}
notification.error(err && err.message);
return rxjs.of(false);
}),
)),
// STEP3: update the UI when authentication fails, happy path is handle at the middleware
// level one layer above as the login ctrl has no idea what to show after login
rxjs.filter((ok) => !ok),
@ -50,7 +60,7 @@ export default async function(render) {
));
// feature: autofocus
effect(rxjs.of([]).pipe(
effect(rxjs.of(null).pipe(
applyMutation(qs($form, "input"), "focus")
));

View File

@ -4,11 +4,13 @@ import { qs, qsa } from "../../lib/dom.js";
import { createForm, mutateForm } from "../../lib/form.js";
import { formTmpl } from "../../components/form.js";
import { generateSkeleton } from "../../components/skeleton.js";
import notification from "../../components/notification.js";
import { get as getConfig } from "../../model/config.js";
import { get as getAdminConfig, save as saveConfig } from "./model_config.js";
import { renderLeaf, useForm$, formObjToJSON$ } from "./helper_form.js";
import transition from "./animate.js";
import { renderLeaf } from "./helper_form.js";
import AdminHOC from "./decorator.js";
import { get as getConfig, save as saveConfig } from "./model_config.js";
export default AdminHOC(function(render) {
const $container = createElement(`
@ -21,12 +23,9 @@ export default AdminHOC(function(render) {
`);
render(transition($container));
const config$ = getConfig().pipe(
rxjs.map((res) => {
delete res.constant;
delete res.middleware;
return res;
}),
const config$ = getAdminConfig().pipe(
rxjs.first(),
reshapeConfigBeforeDisplay,
);
const tmpl = formTmpl({
@ -43,29 +42,46 @@ export default AdminHOC(function(render) {
});
// feature: setup the form
const setup$ = config$.pipe(
const init$ = config$.pipe(
rxjs.mergeMap((formSpec) => createForm(formSpec, tmpl)),
rxjs.map(($form) => [$form]),
applyMutation(qs($container, "[data-bind=\"form\"]"), "replaceChildren"),
applyMutation(qs($container, `[data-bind="form"]`), "replaceChildren"),
rxjs.share(),
);
effect(setup$);
effect(init$);
// feature: handle form change
effect(setup$.pipe(
rxjs.mergeMap(() => qsa($container, "[data-bind=\"form\"] [name]")),
rxjs.mergeMap(($el) => rxjs.fromEvent($el, "input")),
rxjs.map((e) => ({
name: e.target.getAttribute("name"),
value: e.target.value
})),
rxjs.scan((store, keyValue) => {
store[keyValue.name] = keyValue.value;
return store;
}, {})
).pipe(
rxjs.withLatestFrom(config$),
effect(init$.pipe(
useForm$(() => qsa($container, `[data-bind="form"] [name]`)),
rxjs.combineLatestWith(config$.pipe(rxjs.first())),
rxjs.map(([formState, formSpec]) => mutateForm(formSpec, formState)),
reshapeConfigBeforeSave,
saveConfig(),
));
});
// the config contains stuff wich we don't want to show in this page such as:
// - the middleware info which is set in the backend page
// - the connections info which is set in the backend page
// - the constant info which is for the setup page
const reshapeConfigBeforeDisplay = rxjs.map((cfg) => {
const { constant, middleware, connections, ...other } = cfg;
return other;
});
// before saving things back to the server, we want to hydrate the config and insert back:
// - the middleware info
// - the connections info
const reshapeConfigBeforeSave = rxjs.pipe(
rxjs.combineLatestWith(getAdminConfig().pipe(rxjs.first())),
rxjs.map(([configWithMissingKeys, config]) => {
configWithMissingKeys["middleware"] = config["middleware"];
return configWithMissingKeys;
}),
formObjToJSON$(),
rxjs.combineLatestWith(getConfig().pipe(rxjs.first())),
rxjs.map(([adminConfig, publicConfig]) => {
adminConfig["connections"] = publicConfig["connections"];
return adminConfig;
}),
);

View File

@ -3,37 +3,39 @@ import rxjs, { effect, stateMutation, applyMutation, preventDefault } from "../.
import { qs } from "../../lib/dom.js";
import { ApplicationError } from "../../lib/error.js";
import { transition, animate, zoomIn, slideXOut, slideXIn } from "../../lib/animate.js";
import bcrypt from "../../lib/vendor/bcrypt.js";
import { CSS } from "../../helpers/loader.js";
import modal from "../../helpers/modal.js";
import modal from "../../components/modal.js";
import { get as getConfig } from "../../model/config.js";
import { get as getAdminConfig, save as saveConfig } from "./model_config.js";
import ctrlError from "../ctrl_error.js";
import WithShell from "./decorator_sidemenu.js";
import { cssHideMenu } from "./animate.js";
import { formObjToJSON$ } from "./helper_form.js";
import { getDeps } from "./model_setup.js";
import "../../components/icon.js";
const stepper$ = new rxjs.BehaviorSubject(1);
export default function(render) {
export default async function(render) {
const $page = createElement(`
<div class="component_setup">
<div data-bind="multistep-form"></div>
<style>${css}</style>
<style>${await CSS(import.meta.url, "ctrl_setup.css")}</style>
</div>
`);
render($page);
effect(stepper$.pipe(
rxjs.map((step) => {
switch (step) {
case 1: return WithShell(componentStep1);
case 2: return WithShell(componentStep2);
default: throw new ApplicationError("INTERNAL_ERROR", "Assumption failed");
}
if (step === 1) return WithShell(componentStep1);
else if (step === 2) return WithShell(componentStep2);
throw new ApplicationError("INTERNAL_ERROR", "Assumption failed");
}),
rxjs.tap((ctrl) => ctrl(createRender(qs($page, "[data-bind=\"multistep-form\"]")))),
rxjs.catchError((err) => ctrlError(err)(render))
rxjs.tap((ctrl) => ctrl(createRender(qs($page, `[data-bind="multistep-form"]`)))),
rxjs.catchError((err) => ctrlError(err)(render)),
));
};
@ -45,14 +47,14 @@ function componentStep1(render) {
<p>Create your instance admin password: </p>
<form>
<div class="input_group">
<input type="password" name="password" placeholder="Password" class="component_input" autocomplete>
<input type="password" name="password" placeholder="Password" class="component_input" autocomplete autofocus>
<button class="transparent">
<component-icon name="arrow_right"></component-icon>
</button>
</div>
</form>
</div>
<style></style>
<style>${cssHideMenu}</style>
</div>
`);
render(transition($page, {
@ -66,23 +68,28 @@ function componentStep1(render) {
preventDefault(),
rxjs.mapTo(["name", "loading"]), applyMutation(qs($page, "component-icon"), "setAttribute"),
rxjs.map(() => qs($page, "input").value),
rxjs.delay(1000),
rxjs.combineLatestWith(getAdminConfig().pipe(rxjs.first())),
rxjs.map(([pwd, config]) => {
config["auth"]["admin"]["value"] = bcrypt.hashSync(pwd);
return config;
}),
reshapeConfigBeforeSave,
saveConfig(),
rxjs.tap(() => animate($page, { time: 200, keyframes: slideXOut(-30) })),
rxjs.delay(200),
rxjs.tap(() => stepper$.next(2))
));
// feature: hide side menu to remove distractions
effect(rxjs.of(cssHideMenu).pipe(
stateMutation(qs($page, "style"), "textContent")
));
// feature: autofocus
effect(rxjs.of([]).pipe(
applyMutation(qs($page, "input"), "focus")
));
}
const reshapeConfigBeforeSave = rxjs.pipe(
formObjToJSON$(),
rxjs.combineLatestWith(getConfig().pipe(rxjs.first())),
rxjs.map(([config, publicConfig]) => {
config["connections"] = publicConfig["connections"];
return config;
}),
)
function componentStep2(render) {
const deps = [];
const $page = createElement(`
@ -91,58 +98,76 @@ function componentStep2(render) {
<component-icon name="arrow_left" data-bind="previous"></component-icon>
Summary
</h4>
${deps.map((t) => t.label).join("")}
<style></style>
</div>`);
<div data-bind="dependencies"></div>
<style>${cssHideMenu}</style>
</div>
`);
render($page);
// feature: show state of dependencies
effect(getDeps().pipe(
rxjs.mergeMap((deps) => deps),
rxjs.map(({ name_success, name_failure, pass, severe, message }) => ({
className: (severe ? "severe" : "") + " " +(pass ? "yes" : "no"),
label: pass ? name_success : name_failure,
extraLabel: pass ? "" : ": " + message,
})),
rxjs.map(({ label, className, extraLabel }) => createElement(`
<div class="component_dependency_installed ${className}">
<span>${label}</span>${extraLabel}
</div>
`)),
applyMutation(qs($page, `[data-bind="dependencies"]`), "appendChild"),
))
// feature: navigate previous step
effect(rxjs.fromEvent(qs($page, "[data-bind=\"previous\"]"), "click").pipe(
effect(rxjs.fromEvent(qs($page, `[data-bind="previous"]`), "click").pipe(
rxjs.tap(() => stepper$.next(1))
));
// feature: reveal animation
effect(rxjs.of(cssHideMenu).pipe(
stateMutation(qs($page, "style"), "textContent"),
effect(rxjs.of(null).pipe(
rxjs.tap(() => animate(qs($page, "h4"), { time: 200, keyframes: slideXIn(30) })),
rxjs.delay(200),
rxjs.mapTo([]), applyMutation(qs($page, "style"), "remove")
));
// feature: telemetry popup
onDestroy(() => {
const $node = createElement(`
const $modal = createElement(`
<div>
<p style="text-align: justify;">Help making this software better by sending crash reports and anonymous usage statistics</p>
<p style="text-align: justify;">
Help making this software better by sending crash reports and anonymous usage statistics
</p>
<form style="font-size: 0.9em; margin-top: 10px;">
<label>
<div class="component_checkbox">
<input type="checkbox">
<span class="indicator"></span>
</div>I accept but the data is not to be share with any third party
</div>
I accept but the data is not to be share with any third party
</label>
</form>
</div>
`);
return new Promise((done) => {
modal.alert($node, {
onQuit: done
});
});
effect(getAdminConfig().pipe(
reshapeConfigBeforeSave,
rxjs.delay(300),
rxjs.filter((config) => config["log"]["telemetry"] !== true),
rxjs.mergeMap((config) => new Promise((next) => {
modal.open($modal, {
withButtonsRight: "OK",
onQuit: () => next(config),
});
qs($modal, `[type="checkbox"]`).oninput = (e) => {
if (!e.target.checked) return;
qs(document, "component-modal > div").click();
}
})),
rxjs.filter(() => qs($modal, `[type="checkbox"]`).checked),
rxjs.map((config) => {
config["log"]["telemetry"] = true;
return config;
}),
saveConfig(),
));
}
// const animateOut = ($el) => {
// return rxjs.pipe(
// rxjs.tap(() => animate($el, {
// time: 300,
// keyframes: [
// { transform: "translateX(0px)", opacity: "1" },
// { transform: "translateX(-30px)", opacity: "0" }
// ]
// })),
// rxjs.delay(200)
// );
// };
const css = await CSS(import.meta.url, "ctrl_setup.css");

View File

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

View File

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

View File

@ -1,4 +1,5 @@
import { createElement } from "../../lib/skeleton/index.js";
import rxjs from "../../lib/rx.js";
export function renderLeaf({ format, type, label, description }) {
return createElement(`
@ -12,9 +13,47 @@ export function renderLeaf({ format, type, label, description }) {
<div class="flex">
<span class="nothing"></span>
<div style="width:100%;">
<div class="description">${description || ""}</div>
<div class="description">${(description || "").replaceAll("\n", "<br>")}</div>
</div>
</div>
</label>
`);
}
export function useForm$($inputNodeList) {
return rxjs.pipe(
rxjs.mergeMap(() => $inputNodeList()),
rxjs.mergeMap(($el) => rxjs.fromEvent($el, "input")),
rxjs.map((e) => ({
name: e.target.getAttribute("name"),
value: function() {
switch(e.target.getAttribute("type")) {
case "checkbox":
return e.target.checked;
default:
return e.target.value;
}
}(),
})),
rxjs.scan((store, keyValue) => {
store[keyValue.name] = keyValue.value;
return store;
}, {})
);
}
export function formObjToJSON$() {
const formObjToJSON = (o, level = 0) => {
const obj = Object.assign({}, o);
Object.keys(obj).map((key) => {
const t = obj[key];
if ("label" in t && "type" in t && "default" in t && "value" in t) {
obj[key] = obj[key].value;
} else {
obj[key] = formObjToJSON(obj[key], level + 1);
}
});
return obj;
}
return rxjs.map(formObjToJSON);
}

View File

@ -13,6 +13,9 @@
box-sizing: border-box;
max-height: 100vh;
}
.component_page_admin .page_container.scroll-y > div {
max-width: 1300px;
}
@media screen and (max-width: 1000px) {
.component_page_admin .page_container {
padding-left: 30px;
@ -171,53 +174,7 @@
border-bottom-right-radius: 3px;
padding-right: 5px;
}
.component_page_admin form .description {
margin-top: -2px;
margin-bottom: 10px;
opacity: 0.6;
font-size: 0.9em;
line-height: 1em;
text-align: justify;
}
.component_page_admin .formbuilder input::placeholder,
.component_page_admin .formbuilder textarea::placeholder {
opacity: 0.6;
}
.component_page_admin .formbuilder label.input_type_hidden {
display: none;
}
.component_page_admin form fieldset legend {
text-transform: uppercase;
font-weight: 200;
font-size: 1em;
padding: 0 15px;
}
.component_page_admin form img {
max-height: 110px;
border: 8px solid rgba(0, 0, 0, 0);
}
.component_page_admin form .fileupload-image img {
height: 150px;
width: 100%;
object-fit: contain;
background: var(--bg-color);
border-radius: 2px;
box-sizing: border-box;
}
.component_page_admin form .fileupload-image object {
background: var(--bg-color);
height: 300px;
width: 100%;
}
.component_page_admin form .formbuilder_password {
display: flex;
}
.component_page_admin form .formbuilder_password img.component_icon {
border: 2px solid rgba(0,0,0,.05);
height: 18px;
border-left: none;
background: rgba(0,0,0,.05);
border-top-right-radius: 3px;
border-bottom-right-radius: 3px;
padding-right: 5px;
}

View File

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

View File

@ -1,19 +1,21 @@
import rxjs from "../../lib/rx.js";
import ajax from "../../lib/ajax.js";
class AuditManager {
get(searchParams = {}) {
const p = new URLSearchParams();
Object.keys(searchParams).forEach((key) => {
p.set(key, searchParams[key]);
});
const isLoading$ = new rxjs.BehaviorSubject(false);
export function get(searchParams = new URLSearchParams()) {
return ajax({
url: "/admin/api/audit?" + p.toString(),
url: "/admin/api/audit?" + searchParams.toString(),
responseType: "json"
}).pipe(
rxjs.map((res) => res.responseJSON.result)
rxjs.map(({ responseJSON }) => responseJSON.result)
);
}
export function setLoader(value) {
return isLoading$.next(!!value);
}
export default new AuditManager();
export function isLoading() {
return isLoading$.asObservable();
}

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 config$ = isSaving$.pipe(
rxjs.filter((loading) => !loading),
rxjs.switchMapTo(ajax({
url: "/admin/api/config",
method: "GET",
responseType: "json"
})),
rxjs.map((res) => res.responseJSON.result),
rxjs.shareReplay(1),
)
export function isSaving() {
return isSaving$.asObservable();
}
export function get() {
return ajax({
url: "/admin/api/config",
withCredentials: true,
method: "GET",
responseType: "json"
}).pipe(
rxjs.map((res) => res.responseJSON.result)
);
return config$;
}
export function save() {
return rxjs.pipe(
rxjs.tap(() => isSaving$.next(true)),
rxjs.debounceTime(1000),
rxjs.delay(1000),
rxjs.tap(() => isSaving$.next(false))
rxjs.debounceTime(2000),
rxjs.mergeMap((formData) => ajax({
url: "/admin/api/config",
method: "POST",
responseType: "json",
body: formData,
}).pipe(rxjs.tap(() => isSaving$.next(false)))),
);
}

View File

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

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;
}),
// rxjs.map((formData) => JSON.parse(JSON.stringify(a, (key, value) => {
// if (value !== null) return value;
// })),
rxjs.mergeMap((creds) => createSession(creds)),
rxjs.tap(() => navigate("/")),
// TODO: error with notification

View File

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

View File

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

View File

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

View File

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

129
public/worker/sw_cache.js Normal file
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"
"errors"
"fmt"
. "github.com/mickael-kerjean/filestash/server/common"
"io"
"io/fs"
"net/http"
URL "net/url"
"os"
"path/filepath"
"regexp"
"strings"
"text/template"
. "github.com/mickael-kerjean/filestash"
. "github.com/mickael-kerjean/filestash/server/common"
)
var (
@ -28,25 +30,19 @@ func init() {
WWWDir = os.DirFS(GetAbsolutePath("../"))
}
func StaticHandler(_path string) func(*App, http.ResponseWriter, *http.Request) {
func LegacyStaticHandler(_path string) func(*App, http.ResponseWriter, *http.Request) { // TODO: migrate away
return func(ctx *App, res http.ResponseWriter, req *http.Request) {
var chroot string = GetAbsolutePath(_path)
if srcPath := JoinPath(chroot, req.URL.Path); strings.HasPrefix(srcPath, chroot) == false {
http.NotFound(res, req)
return
}
ServeFile(res, req, JoinPath(_path, req.URL.Path))
LegacyServeFile(res, req, JoinPath(_path, req.URL.Path))
}
}
func IndexHandler(ctx *App, res http.ResponseWriter, req *http.Request) {
urlObj, err := URL.Parse(req.URL.String())
if err != nil {
NotFoundHandler(ctx, res, req)
return
}
url := urlObj.Path
func LegacyIndexHandler(ctx *App, res http.ResponseWriter, req *http.Request) {
url := req.URL.Path
if url != URL_SETUP && Config.Get("auth.admin").String() == "" {
http.Redirect(res, req, URL_SETUP, http.StatusTemporaryRedirect)
return
@ -72,7 +68,74 @@ func IndexHandler(ctx *App, res http.ResponseWriter, req *http.Request) {
`)))
return
}
ServeFile(res, req, "/index.html")
LegacyServeFile(res, req, "/index.html")
}
func LegacyServeFile(res http.ResponseWriter, req *http.Request, filePath string) { // TODO: migrate away
staticConfig := []struct {
ContentType string
FileExt string
}{
{"br", ".br"},
{"gzip", ".gz"},
{"", ""},
}
statusCode := 200
if req.URL.Path == "/" {
if errName := req.URL.Query().Get("error"); errName != "" {
statusCode = HTTPError(errors.New(errName)).Status()
}
}
head := res.Header()
acceptEncoding := req.Header.Get("Accept-Encoding")
for _, cfg := range staticConfig {
if strings.Contains(acceptEncoding, cfg.ContentType) == false {
continue
}
curPath := filePath + cfg.FileExt
file, err := WWWEmbed.Open("static/www" + curPath)
if env := os.Getenv("DEBUG"); env == "true" {
//file, err = WWWDir.Open("server/ctrl/static/www" + curPath)
file, err = WWWDir.Open("public" + curPath)
}
if err != nil {
continue
} else if stat, err := file.Stat(); err == nil {
etag := QuickHash(fmt.Sprintf(
"%s %d %d %s",
curPath, stat.Size(), stat.Mode(), stat.ModTime()), 10,
)
if etag == req.Header.Get("If-None-Match") {
res.WriteHeader(http.StatusNotModified)
return
}
head.Set("Etag", etag)
}
if cfg.ContentType != "" {
head.Set("Content-Encoding", cfg.ContentType)
}
res.WriteHeader(statusCode)
io.Copy(res, file)
file.Close()
return
}
http.NotFound(res, req)
}
func ServeBackofficeHandler(ctx *App, res http.ResponseWriter, req *http.Request) {
url := req.URL.Path
if url != URL_SETUP && Config.Get("auth.admin").String() == "" {
http.Redirect(res, req, URL_SETUP, http.StatusTemporaryRedirect)
return
}
if filepath.Ext(filepath.Base(url)) == "" {
ServeFile(res, req, WWWPublic, "index.backoffice.html")
return
}
ServeFile(res, req, WWWPublic, strings.TrimPrefix(req.URL.Path, "/admin/"))
}
func NotFoundHandler(ctx *App, res http.ResponseWriter, req *http.Request) {
@ -202,7 +265,7 @@ func CustomCssHandler(ctx *App, res http.ResponseWriter, req *http.Request) {
io.WriteString(res, Config.Get("general.custom_css").String())
}
func ServeFile(res http.ResponseWriter, req *http.Request, filePath string) {
func ServeFile(res http.ResponseWriter, req *http.Request, fs http.FileSystem, filePath string) {
staticConfig := []struct {
ContentType string
FileExt string
@ -212,13 +275,6 @@ func ServeFile(res http.ResponseWriter, req *http.Request, filePath string) {
{"", ""},
}
statusCode := 200
if req.URL.Path == "/" {
if errName := req.URL.Query().Get("error"); errName != "" {
statusCode = HTTPError(errors.New(errName)).Status()
}
}
head := res.Header()
acceptEncoding := req.Header.Get("Accept-Encoding")
for _, cfg := range staticConfig {
@ -226,10 +282,7 @@ func ServeFile(res http.ResponseWriter, req *http.Request, filePath string) {
continue
}
curPath := filePath + cfg.FileExt
file, err := WWWEmbed.Open("static/www" + curPath)
if env := os.Getenv("NODE_ENV"); env == "development" {
file, err = WWWDir.Open("server/ctrl/static/www" + curPath)
}
file, err := fs.Open(curPath)
if err != nil {
continue
} else if stat, err := file.Stat(); err == nil {
@ -243,10 +296,11 @@ func ServeFile(res http.ResponseWriter, req *http.Request, filePath string) {
}
head.Set("Etag", etag)
}
head.Set("Content-Type", GetMimeType(filepath.Ext(filePath)))
if cfg.ContentType != "" {
head.Set("Content-Encoding", cfg.ContentType)
}
res.WriteHeader(statusCode)
res.WriteHeader(http.StatusOK)
io.Copy(res, file)
file.Close()
return

View File

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

View File

@ -25,7 +25,6 @@ func (this Admin) Setup() Form {
Type: "select",
Value: "direct",
Opts: []string{"direct", "password_only", "username_and_password"},
Id: "strategy",
Description: `This plugin has 3 base strategies:
1. The 'direct' strategy will redirect the user to your storage without asking for anything and use whatever is configured in the attribute mapping section.
2. The 'password_only' strategy will redirect the user to a page asking for a password which you can map to a field in the attribute mapping section like this: {{ .password }}

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