From 1e62341f94409b0e360071e51128aad8ed6e840e Mon Sep 17 00:00:00 2001 From: Brett Smith Date: Sat, 21 Sep 2024 01:29:18 +0100 Subject: [PATCH] WIP --- .../java/com/sshtools/jenny/api/Lang.java | 5 + auth/linid/pom.xml | 80 +++ .../sshtools/jenny/auth/linux/LinidAuth.java | 100 +++ auth/linid/src/main/java/module-info.java | 27 + auth/linid/src/main/resources/layers.ini | 7 + config/pom.xml | 2 +- .../com/sshtools/jenny/config/Config.java | 73 ++- .../com/sshtools/jenny/product/Product.java | 13 +- plugins/alert-centre/pom.xml | 58 ++ .../jenny/alertcentre/AlertCentre.java | 471 ++++++++++++++ .../jenny/alertcentre/AlertCentreContext.java | 21 + .../jenny/alertcentre/AlertCentreToolkit.java | 17 + .../sshtools/jenny/alertcentre/Dismissal.java | 149 +++++ .../sshtools/jenny/alertcentre/Monitor.java | 16 + .../jenny/alertcentre/Notification.java | 592 ++++++++++++++++++ .../alertcentre/NotificationInstance.java | 19 + .../jenny/alertcentre/NotificationType.java | 42 ++ .../src/main/java/module-info.java | 11 + .../jenny/alertcentre/AlertCentre.properties | 5 + .../sshtools/jenny/alertcentre/alertcentre.js | 0 .../src/main/resources/layers.ini | 11 + .../java/com/sshtools/jenny/i18n/I18N.java | 8 +- plugins/pages/pom.xml | 54 ++ .../com/sshtools/jenny/pages/APIEndpoint.java | 5 + .../java/com/sshtools/jenny/pages/Page.java | 76 +++ .../java/com/sshtools/jenny/pages/Pages.java | 29 + plugins/pages/src/main/java/module-info.java | 27 + .../com/sshtools/jenny/pages/pages.js | 0 plugins/pages/src/main/resources/layers.ini | 9 + plugins/pom.xml | 2 + pom.xml | 1 + .../com/sshtools/jenny/web/ResourceRef.java | 21 +- .../com/sshtools/jenny/web/Responses.java | 77 +++ .../main/java/com/sshtools/jenny/web/Web.java | 98 +-- .../com/sshtools/jenny/web/WebModule.java | 46 +- .../java/com/sshtools/jenny/web/WebState.java | 13 +- .../com/sshtools/jenny/web/Web.schema.ini | 125 ++++ 37 files changed, 2253 insertions(+), 57 deletions(-) create mode 100644 auth/linid/pom.xml create mode 100644 auth/linid/src/main/java/com/sshtools/jenny/auth/linux/LinidAuth.java create mode 100644 auth/linid/src/main/java/module-info.java create mode 100644 auth/linid/src/main/resources/layers.ini create mode 100644 plugins/alert-centre/pom.xml create mode 100644 plugins/alert-centre/src/main/java/com/sshtools/jenny/alertcentre/AlertCentre.java create mode 100644 plugins/alert-centre/src/main/java/com/sshtools/jenny/alertcentre/AlertCentreContext.java create mode 100644 plugins/alert-centre/src/main/java/com/sshtools/jenny/alertcentre/AlertCentreToolkit.java create mode 100644 plugins/alert-centre/src/main/java/com/sshtools/jenny/alertcentre/Dismissal.java create mode 100644 plugins/alert-centre/src/main/java/com/sshtools/jenny/alertcentre/Monitor.java create mode 100644 plugins/alert-centre/src/main/java/com/sshtools/jenny/alertcentre/Notification.java create mode 100644 plugins/alert-centre/src/main/java/com/sshtools/jenny/alertcentre/NotificationInstance.java create mode 100644 plugins/alert-centre/src/main/java/com/sshtools/jenny/alertcentre/NotificationType.java create mode 100644 plugins/alert-centre/src/main/java/module-info.java create mode 100644 plugins/alert-centre/src/main/resources/com/sshtools/jenny/alertcentre/AlertCentre.properties create mode 100644 plugins/alert-centre/src/main/resources/com/sshtools/jenny/alertcentre/alertcentre.js create mode 100644 plugins/alert-centre/src/main/resources/layers.ini create mode 100644 plugins/pages/pom.xml create mode 100644 plugins/pages/src/main/java/com/sshtools/jenny/pages/APIEndpoint.java create mode 100644 plugins/pages/src/main/java/com/sshtools/jenny/pages/Page.java create mode 100644 plugins/pages/src/main/java/com/sshtools/jenny/pages/Pages.java create mode 100644 plugins/pages/src/main/java/module-info.java create mode 100644 plugins/pages/src/main/resources/com/sshtools/jenny/pages/pages.js create mode 100644 plugins/pages/src/main/resources/layers.ini create mode 100644 web/src/main/java/com/sshtools/jenny/web/Responses.java create mode 100644 web/src/main/resources/com/sshtools/jenny/web/Web.schema.ini diff --git a/api/src/main/java/com/sshtools/jenny/api/Lang.java b/api/src/main/java/com/sshtools/jenny/api/Lang.java index f8191f1..add8da9 100644 --- a/api/src/main/java/com/sshtools/jenny/api/Lang.java +++ b/api/src/main/java/com/sshtools/jenny/api/Lang.java @@ -19,11 +19,16 @@ import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; import java.util.function.Function; import java.util.function.Predicate; public class Lang { + + public static Optional emptyable(String val) { + return val == null || val.length() == 0 ? Optional.empty() : Optional.of(val); + } public static List splitStringList(String str, String delimRegex) { var arr = str.split(delimRegex); diff --git a/auth/linid/pom.xml b/auth/linid/pom.xml new file mode 100644 index 0000000..3b35011 --- /dev/null +++ b/auth/linid/pom.xml @@ -0,0 +1,80 @@ + + + 4.0.0 + + com.sshtools + jenny-auth + 0.0.1-SNAPSHOT + ../ + + + + 22 + 22 + + Jenny - Alternative (Java 22) Linux Authentication + jenny-auth-linid + + + + + ${project.groupId} + jenny-auth-api + ${project.version} + provided + + + + + com.sshtools + bootlace-api + provided + + + ${project.groupId} + jenny-api + ${project.version} + provided + + + + + uk.co.bithatch + linid + 0.0.1-SNAPSHOT + + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + 22 + + + + + + diff --git a/auth/linid/src/main/java/com/sshtools/jenny/auth/linux/LinidAuth.java b/auth/linid/src/main/java/com/sshtools/jenny/auth/linux/LinidAuth.java new file mode 100644 index 0000000..116fa23 --- /dev/null +++ b/auth/linid/src/main/java/com/sshtools/jenny/auth/linux/LinidAuth.java @@ -0,0 +1,100 @@ +/** + * Copyright © 2023 JAdaptive Limited (support@jadaptive.com) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.sshtools.jenny.auth.linux; + +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Optional; +import java.util.Set; + +import com.sshtools.bootlace.api.Plugin; +import com.sshtools.bootlace.api.PluginContext; +import com.sshtools.jenny.api.Api; +import com.sshtools.jenny.auth.api.Auth.AuthResult; +import com.sshtools.jenny.auth.api.Auth.AuthState; +import com.sshtools.jenny.auth.api.Auth.PasswordAuthProvider; +import com.sshtools.jenny.auth.api.ExtendedUserPrincipal; + +import uk.co.bitatch.linid.Linid; +import uk.co.bitatch.linid.LinuxUserId; + +public class LinidAuth implements Plugin { + + public final static class Provider implements PasswordAuthProvider { + @Override + public AuthResult logon(String username, char[] password) { + return Linid.get().authenticate(username, password).map(id -> { + var linId = (LinuxUserId)id; + return new AuthResult(AuthState.COMPLETE, new ExtendedUserPrincipal.LinuxUser() { + + @Override + public String getName() { + return id.getName(); + } + + @Override + public int uid() { + return linId.uid(); + } + + @Override + public Optional shell() { + return Optional.of(linId.shell()); + } + + @Override + public Set groups() { + return Set.of(linId.collectionNames()); + } + + @Override + public int gid() { + return linId.gid(); + } + + @Override + public Optional gecos() { + return Optional.of(String.join(",", linId.gecos())); + } + + @Override + public Optional dir() { + return Optional.of(linId.home()).map(Paths::get); + } + }); + }).orElseGet(() -> new AuthResult(AuthState.DENY)); + } + + @Override + public int weight() { + return 0; + } + } + + private Api api; + + @Override + public void afterOpen(PluginContext context) { + api = context.plugin(Api.class); + + var provider = new Provider(); + + context.autoClose(api.extensions(). + group(). + point(PasswordAuthProvider.class, (a) -> provider)); + } + +} diff --git a/auth/linid/src/main/java/module-info.java b/auth/linid/src/main/java/module-info.java new file mode 100644 index 0000000..bc53e7e --- /dev/null +++ b/auth/linid/src/main/java/module-info.java @@ -0,0 +1,27 @@ +/** + * Copyright © 2023 JAdaptive Limited (support@jadaptive.com) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import com.sshtools.bootlace.api.Plugin; +import com.sshtools.jenny.auth.linux.LinidAuth; + +module com.sshtools.jenny.auth.linid { + exports com.sshtools.jenny.auth.linux; + requires transitive com.sshtools.jenny.auth.api; + requires com.sshtools.bootlace.api; + requires com.sshtools.jenny.api; + requires uk.co.bithatch.linid; + + provides Plugin with LinidAuth; +} \ No newline at end of file diff --git a/auth/linid/src/main/resources/layers.ini b/auth/linid/src/main/resources/layers.ini new file mode 100644 index 0000000..5c02173 --- /dev/null +++ b/auth/linid/src/main/resources/layers.ini @@ -0,0 +1,7 @@ +[component] + id = com.sshtools.jenny.auth.api.linid + name = Linux Authentication (Alternative version) + +[artifacts] + uk.co.bithatch:linid:0.0.1-SNAPSHOT + diff --git a/config/pom.xml b/config/pom.xml index 1fdf790..eebe33a 100644 --- a/config/pom.xml +++ b/config/pom.xml @@ -52,7 +52,7 @@ com.sshtools jini-config - 0.3.3-SNAPSHOT + 0.3.3 diff --git a/config/src/main/java/com/sshtools/jenny/config/Config.java b/config/src/main/java/com/sshtools/jenny/config/Config.java index 6d400bd..398dc67 100644 --- a/config/src/main/java/com/sshtools/jenny/config/Config.java +++ b/config/src/main/java/com/sshtools/jenny/config/Config.java @@ -19,12 +19,14 @@ import java.io.Closeable; import java.io.IOException; +import java.io.InputStreamReader; import java.io.StringReader; import java.io.UncheckedIOException; import java.net.URL; import java.net.URLClassLoader; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.Paths; import java.text.MessageFormat; import java.text.ParseException; import java.util.Locale; @@ -44,8 +46,12 @@ import com.sshtools.jenny.product.Product; import com.sshtools.jini.INI; import com.sshtools.jini.INIReader; +import com.sshtools.jini.INIReader.MultiValueMode; import com.sshtools.jini.INIWriter; import com.sshtools.jini.config.INISet; +import com.sshtools.jini.config.INISet.Builder; +import com.sshtools.jini.config.Monitor; +import com.sshtools.jini.schema.INISchema; public class Config implements Plugin { @@ -53,6 +59,7 @@ public class Config implements Plugin { private final static Log LOG = Logs.of(ApiLog.CONFIG); + @Deprecated public interface Handle extends Closeable { INI ini(); @@ -63,6 +70,7 @@ public interface Handle extends Closeable { void close(); } + @Deprecated private record Key(Plugin plugin, Scope scope) { } @@ -73,6 +81,8 @@ private record Key(Plugin plugin, Scope scope) { private URLClassLoader bundleLoader; private final ResourceBundle emptyBundle; private ConfigResolver configResolver; + private boolean developerMode = "true".equals(System.getProperty("jenny.config.developer", String.valueOf(Files.exists(Paths.get("pom.xml"))))); + private Monitor monitor; public Config() { try { @@ -87,6 +97,12 @@ public Config() { @Override public void afterOpen(PluginContext context) throws Exception { bundleLoader = new URLClassLoader(new URL[] { configResolver.resolveDir(CONFIG_APP_ID, Scope.VENDOR).toUri().toURL() }); + monitor = new Monitor(); + } + + @Override + public void beforeClose(PluginContext context) throws Exception { + monitor.close(); } public ResourceBundle bundle(Plugin plugin, Locale locale) { @@ -101,11 +117,29 @@ public ResourceBundle bundle(Plugin plugin, Locale locale) { } public INISet.Builder defaultConfig() { - return new INISet.Builder(product.info().app()); + return createBuilder(product.info().app()); } public INISet.Builder configBuilder(String name) { - return new INISet.Builder(name).withApp(product.info().app()); + return createBuilder(name). + withApp(product.info().app()); + } + + public INISet.Builder configBuilder(String name, Class schemaBase, String schemaResource) { + try(var rdr = new InputStreamReader(schemaBase.getResourceAsStream(schemaResource), "UTF-8")) { + return createBuilder(name). + withSchema(new INISchema.Builder().fromDocument(reader().build().read(rdr)).build()). + withApp(product.info().app()); + } + catch(IOException ioe) { + throw new UncheckedIOException(ioe); + } catch (ParseException e) { + throw new IllegalStateException(e); + } + } + + public Monitor monitor() { + return monitor; } @Deprecated @@ -154,6 +188,7 @@ public void close() { } } + @Deprecated private INI loadIni(Path path) { if (Files.exists(path)) { try { @@ -167,4 +202,38 @@ private INI loadIni(Path path) { return INI.create(); } } + + private Builder createBuilder(String name) { + var bldr = new INISet.Builder(name). + withMonitor(monitor). + withScopes(INISet.Scope.GLOBAL). + withWriteScope(INISet.Scope.GLOBAL); + + readerAndWriter(bldr); + + if(developerMode) { + bldr.withPath(INISet.Scope.GLOBAL, Paths.get("conf")); + } + + return bldr; + } + + private void readerAndWriter(Builder bldr) { + bldr. + withWriterFactory(() -> + new INIWriter.Builder(). + withMultiValueMode(MultiValueMode.REPEATED_KEY). + withSectionPathSeparator('/') + ). + withReaderFactory(() -> + reader() + ); + + } + + private INIReader.Builder reader() { + return new INIReader.Builder(). + withMultiValueMode(MultiValueMode.REPEATED_KEY). + withSectionPathSeparator('/'); + } } diff --git a/frameworks/product/src/main/java/com/sshtools/jenny/product/Product.java b/frameworks/product/src/main/java/com/sshtools/jenny/product/Product.java index a202500..827e795 100644 --- a/frameworks/product/src/main/java/com/sshtools/jenny/product/Product.java +++ b/frameworks/product/src/main/java/com/sshtools/jenny/product/Product.java @@ -24,14 +24,15 @@ public final class Product implements Plugin { private Info info; - public record Info(String app, String vendor) { + public record Info(String app, String vendor, String version) { } public final static class Builder { private Optional app = Optional.empty(); private Optional> appClass = Optional.empty(); - private Optional vendor = Optional.empty(); + private Optional vendor = Optional.empty(); + private Optional version = Optional.empty(); public Builder withApp(Class appClass) { this.appClass = Optional.of(appClass); @@ -48,10 +49,16 @@ public Builder withVendor(String vendor) { return this; } + public Builder withVersion(String version) { + this.version = Optional.of(version); + return this; + } + public Info build() { return new Info( app.or(() -> appClass.map(Class::getName)).orElseThrow(() -> new IllegalStateException("'App' must be set, either a class or short ID.")), - vendor.orElse("Unknown") + vendor.orElse("Unknown"), + version.orElse("Unknown") ); } } diff --git a/plugins/alert-centre/pom.xml b/plugins/alert-centre/pom.xml new file mode 100644 index 0000000..7eb6808 --- /dev/null +++ b/plugins/alert-centre/pom.xml @@ -0,0 +1,58 @@ + + + 4.0.0 + + com.sshtools + jenny-plugins + 0.0.1-SNAPSHOT + ../ + + Jenny - Alert Centre + jenny-alert-centre + + + + + ${project.groupId} + jenny-web + ${project.version} + provided + + + ${project.groupId} + jenny-api + ${project.version} + provided + + + com.sshtools + bootlace-api + provided + + + ${project.groupId} + jenny-i18n + ${project.version} + provided + + + + diff --git a/plugins/alert-centre/src/main/java/com/sshtools/jenny/alertcentre/AlertCentre.java b/plugins/alert-centre/src/main/java/com/sshtools/jenny/alertcentre/AlertCentre.java new file mode 100644 index 0000000..bef6578 --- /dev/null +++ b/plugins/alert-centre/src/main/java/com/sshtools/jenny/alertcentre/AlertCentre.java @@ -0,0 +1,471 @@ + +package com.sshtools.jenny.alertcentre; + +import static com.sshtools.bootlace.api.PluginContext.$; + +import java.security.Principal; +import java.text.MessageFormat; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; +import java.util.stream.StreamSupport; + +import com.sshtools.bootlace.api.Http; +import com.sshtools.bootlace.api.Logs; +import com.sshtools.bootlace.api.Logs.Category; +import com.sshtools.bootlace.api.Logs.Log; +import com.sshtools.bootlace.api.Plugin; +import com.sshtools.bootlace.api.PluginContext; +import com.sshtools.bootlace.api.UncheckedCloseable; +import com.sshtools.jenny.alertcentre.Monitor.Scope; +import com.sshtools.jenny.alertcentre.Notification.Builder.NotificationAction; +import com.sshtools.jenny.alertcentre.Notification.Dismission; +import com.sshtools.jenny.api.Resources; +import com.sshtools.jenny.config.Config; +import com.sshtools.jenny.product.Product; +import com.sshtools.jenny.web.Responses; +import com.sshtools.jenny.web.Responses.RedirectResponse; +import com.sshtools.jenny.web.Web; +import com.sshtools.jenny.web.WebState; +import com.sshtools.jini.INI.Section; +import com.sshtools.tinytemplate.Templates.TemplateModel; +import com.sshtools.uhttpd.UHTTPD.Transaction; + +public class AlertCentre implements Plugin { + + private final static class Defaults { + private final static AlertCentreContext DEFAULT = new AlertCentreContext() { + @Override + public boolean isAdministrator(Principal principal) { + return false; + } + + @Override + public boolean isSystem(Principal principal) { + return false; + } + + @Override + public void onLogin(Consumer principal) { + } + + @Override + public UncheckedCloseable systemContext() { + return new UncheckedCloseable() { + @Override + public void close() { + } + + }; + } + + @Override + public UncheckedCloseable administratorContext() { + return systemContext(); + } + + @Override + public UncheckedCloseable userContext() { + return systemContext(); + } + }; + } + + public static AlertCentreContext defaultContext() { + return Defaults.DEFAULT; + } + + final static Instant SERVER_STARTED = Instant.now(); + + private final Web web = $().plugin(Web.class); + private final Config config = $().plugin(Config.class); + private final Product product = $().plugin(Product.class); + private final List monitors = new ArrayList<>(); + private AlertCentreContext context = new AlertCentreContext() { + + @Override + public UncheckedCloseable userContext() { + throw throwUE(); + } + + @Override + public UncheckedCloseable systemContext() { + throw throwUE(); + } + + @Override + public UncheckedCloseable administratorContext() { + throw throwUE(); + } + + private UnsupportedOperationException throwUE() { + return new UnsupportedOperationException("Notification centre requires AlertCentre.context() is used to provide some integration points."); + } + + @Override + public void onLogin(Consumer principal) { + } + + @Override + public boolean isAdministrator(Principal principal) { + throw throwUE(); + } + + @Override + public boolean isSystem(Principal principal) { + throw throwUE(); + } + }; + + private AlertCentreToolkit toolkit = new AlertCentreToolkit() { + + @Override + public String titleBgStyle(NotificationType type) { + throw throwUE(); + } + + @Override + public String textStyle(NotificationType type) { + throw throwUE(); + } + + @Override + public String icon(NotificationType type) { + throw throwUE(); + } + + @Override + public String bgStyle(NotificationType type) { + throw throwUE(); + } + + @Override + public TemplateModel template(Transaction tx) { + throw throwUE(); + } + + private UnsupportedOperationException throwUE() { + return new UnsupportedOperationException("Notification centre requires AlertCentre.toolkit() is used to provide some UI integration points."); + } + }; + + private Section dismissalDatabase; + + private final static Log LOG = Logs.of(Category.ofName(AlertCentre.class)); + + public AlertCentre() { + } + + public UncheckedCloseable toolkit(AlertCentreToolkit toolkit) { + if(toolkit == null) + throw new NullPointerException(); + if(this.toolkit == null) + throw new IllegalStateException("Only one alert centre toolkit is allowed."); + + this.toolkit = toolkit; + + return UncheckedCloseable.onClose(() -> AlertCentre.this.toolkit = null); + } + + public UncheckedCloseable context(AlertCentreContext context) { + if(context == null) + throw new NullPointerException(); + if(this.context == null) + throw new IllegalStateException("Only one alert centre context is allowed."); + + this.context = context; + + context.onLogin(user -> { + dismissalDatabase.sections().values().stream().map(s -> s[0]).filter(s -> + s.getEnum(Dismission.class, "dismission", Dismission.RESTART).equals(Dismission.LOGIN) && + s.get("user", "").equals(user.getName()) + ).forEach(s -> s.remove()); + }); + + return UncheckedCloseable.onClose(() -> AlertCentre.this.context = null); + } + + public UncheckedCloseable monitor(Monitor monitor) { + monitors.add(monitor); + return UncheckedCloseable.onClose(() -> monitors.remove(monitor)); + } + + public void remove(Monitor monitor) { + monitors.remove(monitor); + } + + @Override + public void open(PluginContext context) { + + var cfgBldr = config.configBuilder("alerts"); + var alertConfig = cfgBldr.build(); + + context.autoClose( + alertConfig, + + web.router().route(). + get("/alert-action/(.*)/(.*)", this::apiAlertAction). + build() + ); + + dismissalDatabase = alertConfig.document().obtainSection("dismissals"); + + if(Boolean.getBoolean("jadaptive.alerts.reset")) { + LOG.info("Resetting dismissals."); + dismissalDatabase.clear(); + LOG.info("Reset dismissals."); + } + else { + + LOG.info("Clearing up stale dismissals."); + + var counter = new AtomicInteger(); + Consumer
deleteAndCount = dis -> { + dis.remove(); + counter.incrementAndGet(); + }; + + /* Delete all dismissals that have dismission of UPGRADE and a version + * field that does not match the current version + */ + dismissalDatabase.sections().values().stream().map(s -> s[0]).filter(s -> + s.getEnum(Dismission.class, "dismission", Dismission.RESTART).equals(Dismission.UPGRADE) && + !s.get("version", "").equals(product.info().version()) + ).forEach(deleteAndCount); + + /* + * Delete all dismissals that have dismission of RESTART or LOGIN + */ + dismissalDatabase.sections().values().stream().map(s -> s[0]).filter(s -> { + var e = s.getEnum(Dismission.class, "dismission", Dismission.RESTART); + return e.equals(Dismission.RESTART) || e.equals(Dismission.LOGIN); + } + ).forEach(deleteAndCount); + + /* + * Delete all dismissals that have expired + */ + dismissalDatabase.sections().values().stream().map(s -> s[0]).filter(s -> { + var e = s.getEnum(Dismission.class, "dismission", Dismission.RESTART); + return e.equals(Dismission.RESTART) || e.equals(Dismission.LOGIN); + } + ).forEach(deleteAndCount); + + + dismissalDatabase.sections().values().stream().map(s -> s[0]). + filter(s -> s.contains("expire")). + forEach(s -> { + if(Instant.parse(s.get("expire")).isBefore(SERVER_STARTED)) { + deleteAndCount.accept(s); + } + }); + + LOG.info("Cleared up {} stale dismissals.", counter.get()); + } + } + + public TemplateModel fragAlertIcons(Transaction tx) { + var templ = toolkit.template(tx); + var state = WebState.get(); + var allNotifications = alerts(state.user().orElseThrow(() -> new IllegalStateException("Not authenticated.")), null); + + templ.list("types", (c) -> { + + var iconTemplates = new ArrayList(); + for(var type : NotificationType.values()) { + var notifications = allNotifications.stream().filter(alt -> alt.alert().type() == type).map(NotificationInstance::alert).toList(); + if(notifications.isEmpty()) + continue; + + iconTemplates.add(TemplateModel.ofContent(c). + list("alerts", cc -> + notifications.stream().map(notification -> { + var allActions = new ArrayList(notification.actions()); + if(notification.dismission() != Dismission.NEVER) { + allActions.add(createDismissAction(state, notification)); + } + + return TemplateModel.ofContent(cc). + variable("type", type.name()). + variable("any-icon", () -> notification.icon().orElseGet(() -> toolkit.icon( type))). + variable("icon", () -> notification.icon().orElse("")). + variable("icon-variant", () -> notification.iconVariant().orElse("")). + variable("title", () -> notification.resolveTitle(state.locale())). + variable("content", () -> notification.resolveContent(state.locale())). + variable("type-bg-style", toolkit.bgStyle( type)). + variable("type-text-style", toolkit.textStyle(type)). + variable("type-icon", toolkit.icon( type)). + list("actions", (ac) -> allActions.stream().map(action -> + TemplateModel.ofContent(ac). + variable("classes", String.join(" ", action.getClasses())). + variable("icon", () -> action.getIcon().orElse("")). + variable("uri", () -> "/alert-action/" + Http.urlEncode(notification.key()) + "/" + Http.urlEncode(action.getResourceKey())). + variable("icon-variant", () -> action.getIconVariant().orElse("")). + variable("text", () -> action.resolveText(notification, state.locale())) + ).toList()); + } + ).toList() + ). + variable("type", type.name()). + variable("bg-style", toolkit.bgStyle( type)). + variable("text-style", toolkit.textStyle(type)). + variable("icon", toolkit.icon( type))); + } + + return iconTemplates; + }); + + return templ; + } + + private NotificationAction createDismissAction(WebState state, Notification notification) { + return new NotificationAction("dismiss", () -> { + dismiss(WebState.get().user().orElseThrow(() -> new IllegalStateException("Not authenticated.")), notification.key()); + }).text(Resources.of(getClass(), state.locale()).getString("dismission." + notification.dismission().name())); + } + + private void apiAlertAction(Transaction tx) throws Exception { + try { + action(WebState.get().user().orElseThrow(() -> new IllegalStateException("Not authenticated.")), tx.match(0), tx.match(1)); + tx.response(Responses.success()); + } + catch(RedirectResponse redirect) { + tx.response(redirect); + } + catch(Exception e) { + LOG.error("Action failed.", e); + tx.response(Responses.error(e)); + } + } + + public void action(Principal user, String key, String action) throws Exception { + var alert = alerts(user, null).stream(). + filter(alt -> alt.alert().key().equals(key)). + findFirst(). + orElseThrow(() -> new IllegalArgumentException(MessageFormat.format("No alert with key of {0}", key))); + alert.alert().action(action).orElseGet(() -> dismissAction(alert.alert(), action)).getListener().action(); + } + + public List alerts(Principal user, NotificationType type) { + var admin = context.isAdministrator(user); + var sysTenant = context.isSystem(user); + + var dismissalsThreadLocal = new ThreadLocal>(); + + var cache = new HashMap>(); + + return monitors.stream(). + filter(monitor -> { + var scope = monitor.scope(); + if(scope == Scope.USER) { + return true; + } + else if(scope == Scope.ADMINISTRATOR && admin) { + return admin; + } + else if(scope == Scope.SYSTEM && sysTenant && admin) { + return admin; + } + return false; + }). + map(monitor -> { + if(cache.containsKey(monitor)) { + return cache.get(monitor); + } + else { + var res = alertInstanceForMonitor(user, monitor); + cache.put(monitor, res); + return res; + } + }). + filter(Optional::isPresent). + map(Optional::get). + filter(alt -> { + var dismissals = dismissalsThreadLocal.get(); + if(dismissals == null) { + /* Lazily load the dismissals for this user */ + dismissals = StreamSupport.stream(listDismissalsForUser(user).spliterator(), false).filter(dis -> !dis.expired()).toList(); + dismissalsThreadLocal.set(dismissals); + } + + if(dismissals.stream().filter(dis -> dis.alert().equals(alt.alert().key())).findFirst().isPresent()) { + return false; + } + + return type == null || alt.alert().type() == type; + }). + toList(); + } + + + public Iterable listDismissalsForUser(Principal user) { + return dismissalDatabase.sections().values().stream().map(s -> s[0]).filter(s -> s.get("user", "").equals(user.getName())).map(sec -> new Dismissal.Builder().fromData(sec).build()).toList(); + } + + public Dismissal dismiss(Principal user, NotificationInstance alert) { + var bldr = new Dismissal.Builder(); + bldr.withUuid(alert.toUUID()); + switch(alert.alert().dismission()) { + case UPGRADE: + bldr.withVersion(product.info().version()); + break; + case RESTART: + bldr.withServerStart(SERVER_STARTED); + break; + case DURATION: + bldr.withExpire(Instant.now().plus(alert.alert().dimissDuration().orElseGet(() -> Duration.ofDays(1)))); + break; + default: + break; + } + bldr.withAlert(alert.alert().key()); + bldr.withUser(user); + bldr.withDismission(alert.alert().dismission()); + + var dismissal = bldr.build(); + dismissal.store(dismissalDatabase.obtainSection(dismissal.uuid().toString())); + + return dismissal; + } + + private void dismiss(Principal user, String alert) { + var alertObj = alerts(user, null).stream(). + filter(alt -> alt.alert().key().equals(alert)). + findFirst(). + orElseThrow(() -> new IllegalArgumentException(MessageFormat.format("No alert with key of {0}", alert))); + + dismiss(user, alertObj); + } + + private NotificationAction dismissAction(Notification notification, String action) { + if(action.equals("dismiss")) { + return createDismissAction(WebState.get(), notification); + } + else { + throw new IllegalArgumentException(MessageFormat.format("No action with key of {0}", action)); + } + } + + + private Optional alertInstanceForMonitor(Principal user, Monitor monitor) { + switch(monitor.scope()) { + case SYSTEM: + try(var ctx = context.systemContext()) { + return monitor.query().map(alt -> new NotificationInstance(alt, user, monitor.scope())); + } + case ADMINISTRATOR: + try(var uctx = context.administratorContext()) { + return monitor.query().map(alt -> new NotificationInstance(alt, user, monitor.scope())); + } + default: + try(var uctx = context.userContext()) { + return monitor.query().map(alt -> new NotificationInstance(alt, user, monitor.scope())); + } + } + } +} diff --git a/plugins/alert-centre/src/main/java/com/sshtools/jenny/alertcentre/AlertCentreContext.java b/plugins/alert-centre/src/main/java/com/sshtools/jenny/alertcentre/AlertCentreContext.java new file mode 100644 index 0000000..9aecbb6 --- /dev/null +++ b/plugins/alert-centre/src/main/java/com/sshtools/jenny/alertcentre/AlertCentreContext.java @@ -0,0 +1,21 @@ +package com.sshtools.jenny.alertcentre; + +import java.security.Principal; +import java.util.function.Consumer; + +import com.sshtools.bootlace.api.UncheckedCloseable; + +public interface AlertCentreContext { + + boolean isAdministrator(Principal principal); + + boolean isSystem(Principal principal); + + void onLogin(Consumer principal); + + UncheckedCloseable systemContext(); + + UncheckedCloseable administratorContext(); + + UncheckedCloseable userContext(); +} diff --git a/plugins/alert-centre/src/main/java/com/sshtools/jenny/alertcentre/AlertCentreToolkit.java b/plugins/alert-centre/src/main/java/com/sshtools/jenny/alertcentre/AlertCentreToolkit.java new file mode 100644 index 0000000..aaf6402 --- /dev/null +++ b/plugins/alert-centre/src/main/java/com/sshtools/jenny/alertcentre/AlertCentreToolkit.java @@ -0,0 +1,17 @@ +package com.sshtools.jenny.alertcentre; + +import com.sshtools.tinytemplate.Templates.TemplateModel; +import com.sshtools.uhttpd.UHTTPD.Transaction; + +public interface AlertCentreToolkit { + + String icon(NotificationType type); + + String textStyle(NotificationType type); + + String bgStyle(NotificationType type); + + String titleBgStyle(NotificationType type); + + TemplateModel template(Transaction tx); +} diff --git a/plugins/alert-centre/src/main/java/com/sshtools/jenny/alertcentre/Dismissal.java b/plugins/alert-centre/src/main/java/com/sshtools/jenny/alertcentre/Dismissal.java new file mode 100644 index 0000000..b75e716 --- /dev/null +++ b/plugins/alert-centre/src/main/java/com/sshtools/jenny/alertcentre/Dismissal.java @@ -0,0 +1,149 @@ +package com.sshtools.jenny.alertcentre; + + +import java.security.Principal; +import java.time.Instant; +import java.util.Optional; +import java.util.UUID; + +import com.sshtools.jenny.alertcentre.Notification.Dismission; +import com.sshtools.jini.Data; +import com.sshtools.jini.INI.Section; + +public final class Dismissal { + + public final static class Builder { + private Optional uuid = Optional.empty(); + private Optional user = Optional.empty(); + private Dismission dismission = Dismission.RESTART; + private Optional version = Optional.empty(); + private String alert; + private Optional expire = Optional.empty(); + private Optional serverStart = Optional.empty(); + + public Builder fromData(Section data) { + withUuid(data.key()); + user = data.getOr("user"); + dismission = data.getEnum(Dismission.class, "dismission", Dismission.RESTART); + version = data.getOr("version"); + alert = data.get("alert"); + expire = data.getOr("expire").map(e -> Instant.parse(e)); + serverStart = data.getOr("server-start").map(e -> Instant.parse(e)); + return this; + } + + public Builder withUuid(String uuid) { + return withUuid(UUID.fromString(uuid)); + } + + public Builder withUuid(UUID uuid) { + this.uuid = Optional.of(uuid); + return this; + } + + public Builder withUser(Principal user) { + return withUser(user.getName()); + } + + public Builder withUser(String user) { + this.user = Optional.of(user); + return this; + } + + public Builder withDismission(Dismission dismission) { + this.dismission = dismission; + return this; + } + + public Builder withVersion(String version) { + this.version = Optional.of(version); + return this; + } + + public Builder withAlert(String alert) { + this.alert = alert; + return this; + } + + public Builder withExpire(Instant expire) { + this.expire = Optional.of(expire); + return this; + } + + public Builder withServerStart(Instant serverStart) { + this.serverStart = Optional.of(serverStart); + return this; + } + + public Dismissal build() { + return new Dismissal(this); + } + } + + + private final UUID uuid; + private final Optional user; + private final Dismission dismission; + private final Optional version; + private final String alert; + private Optional expire = Optional.empty(); + private Optional serverStart = Optional.empty(); + + private Dismissal(Builder bldr) { + this.uuid = bldr.uuid.orElseGet(UUID::randomUUID); + this.user = bldr.user; + this.dismission = bldr.dismission; + this.version = bldr.version; + this.alert = Optional.ofNullable(bldr.alert).orElseThrow(() -> new IllegalStateException("Notification key required.")); + this.expire = bldr.expire; + this.serverStart = bldr.serverStart; + } + + public UUID uuid() { + return uuid; + } + + public Optional serverStart() { + return serverStart; + } + + public String alert() { + return alert; + } + + public Optional user() { + return user; + } + + public Dismission dismission() { + return dismission; + } + + public Optional version() { + return version; + } + + public Optional expire() { + return expire; + } + + public boolean expired() { + return expire.map(e -> System.currentTimeMillis() >= e.toEpochMilli()).orElse(false); + } + + @Override + public String toString() { + return "Dismissal [user=" + user + ", dismission=" + dismission + ", version=" + version + ", alert=" + alert + + ", expire=" + expire + ", serverStart=" + serverStart + "]"; + } + + public void store(Data data) { + user.ifPresentOrElse(u -> data.put("user", u), () -> data.remove("user")); + data.putEnum("dismission", dismission); + version.ifPresentOrElse(u -> data.put("version", u), () -> data.remove("version")); + data.put("alert", alert); + expire.ifPresentOrElse(u -> data.put("expire", u.toString()), () -> data.remove("expire")); + serverStart.ifPresentOrElse(u -> data.put("server-start", u.toString()), () -> data.remove("server-start")); + } + +} diff --git a/plugins/alert-centre/src/main/java/com/sshtools/jenny/alertcentre/Monitor.java b/plugins/alert-centre/src/main/java/com/sshtools/jenny/alertcentre/Monitor.java new file mode 100644 index 0000000..02839ae --- /dev/null +++ b/plugins/alert-centre/src/main/java/com/sshtools/jenny/alertcentre/Monitor.java @@ -0,0 +1,16 @@ +package com.sshtools.jenny.alertcentre; + +import java.util.Optional; + +public interface Monitor { + + public enum Scope { + USER, ADMINISTRATOR, SYSTEM + } + + default Scope scope() { + return Scope.ADMINISTRATOR; + } + + Optional query(); +} diff --git a/plugins/alert-centre/src/main/java/com/sshtools/jenny/alertcentre/Notification.java b/plugins/alert-centre/src/main/java/com/sshtools/jenny/alertcentre/Notification.java new file mode 100644 index 0000000..1d2a87f --- /dev/null +++ b/plugins/alert-centre/src/main/java/com/sshtools/jenny/alertcentre/Notification.java @@ -0,0 +1,592 @@ +package com.sshtools.jenny.alertcentre; + +import java.text.MessageFormat; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.ResourceBundle; +import java.util.function.Supplier; + +import com.sshtools.jenny.alertcentre.Notification.Builder.NotificationAction; +import com.sshtools.jenny.api.Resources; +import com.sshtools.tinytemplate.Templates.TemplateModel; + + +public final class Notification { + + /** + * How long the alert can be dismissed for + */ + public enum Dismission { + /** + * Cannot be dismissed + */ + NEVER, + /** + * Can be dismissed forever (by this user) + */ + FOREVER, + /** + * Can be dismissed till the next upgrade + */ + UPGRADE, + /** + * Can be dismissed till the next login (by this user) + */ + LOGIN, + /** + * Can be dismissed till the next server restart + */ + RESTART, + /** + * Can be dismissed until later (some amount of time, by this user) + */ + DURATION + } + + /** + * Interface defining callback for the when an action is performed on an alert + * i.e. the notification popup is clicked. + */ + @FunctionalInterface + public interface AlertActionListener { + /** + * Called when action is invoked. + */ + void action() throws Exception; + } + + /** + * Builds a new alert. + */ + public final static class Builder { + + /** + * Represents an action that may be invoked from an alert. The + * + */ + public static class NotificationAction { + private final String resourceKey; + private Optional icon = Optional.empty(); + private Optional text = Optional.empty(); + private Optional iconVariant = Optional.empty(); + private Optional> bundle = Optional.empty(); + private Optional>> bundleClass = Optional.empty(); + private AlertActionListener listener; + private List args = new ArrayList<>(); + private List classes = new ArrayList<>(); + + NotificationAction(String resourceKey, AlertActionListener listener, String... classes) { + super(); + this.resourceKey = resourceKey; + this.listener = listener; + this.classes.addAll(Arrays.asList(classes)); + } + + /** + * Text + * + * @param text text + * @return this for chaining + */ + public NotificationAction text(String text) { + this.text = Optional.of(text); + return this; + } + + /** + * Bundle, if different from the alert the action is in + * + * @param bundle bundle + * @return this for chaining + */ + public NotificationAction bundle(ResourceBundle bundle) { + return bundle(() -> bundle); + } + + /** + * Bundle, if different from the alert the action is in + * + * @param bundle bundle + * @return this for chaining + */ + public NotificationAction bundle(Supplier bundle) { + this.bundle = Optional.of(bundle); + return this; + } + + /** + * Bundle, if different from the alert the action is in + * + * @param bundle bundle + * @return this for chaining + */ + public NotificationAction bundleClass(Class bundleClass) { + return bundleClass(() -> bundleClass); + } + + /** + * Bundle, if different from the alert the action is in + * + * @param bundleClass bundle + * @return this for chaining + */ + public NotificationAction bundleClass(Supplier> bundleClass) { + this.bundleClass = Optional.of(bundleClass); + return this; + } + + /** + * CSS classes for the action button + * + * @param classes classes + * @return this for chaining + */ + public NotificationAction classes(String... arguments) { + return classes(Arrays.asList(arguments)); + } + + /** + * CSS classes for the action button + * + * @param classes classes + * @return this for chaining + */ + public NotificationAction classes(Collection classes) { + this.classes.clear(); + this.classes.addAll(classes); + return this; + } + + /** + * Arguments for i18n resource (when in use) + * + * @param titleArgs arguments + * @return this for chaining + */ + public NotificationAction arguments(Object... arguments) { + return argumentsList(Arrays.asList(arguments)); + } + + /** + * Arguments for i18n resource (when in use) + * + * @param titleArgs arguments + * @return this for chaining + */ + public NotificationAction argumentsList(Collection arguments) { + this.args.clear(); + this.args.addAll(arguments); + return this; + } + + /** + * Get the icon for this action. + * + * @return icon + */ + Optional getIcon() { + return icon; + } + + /** + * Set the icon for this action. + * + * @param icon icon + * @return this for chaining + */ + public NotificationAction icon(String icon) { + this.icon = Optional.of(icon); + return this; + } + + /** + * Set the icon variant for this action. + * + * @param iconVariant icon + * @return this for chaining + */ + public NotificationAction iconVariant(String iconVariant) { + this.iconVariant = Optional.of(iconVariant); + return this; + } + + /** + * Set the listener for this action. + * + * @param listener listener + * @return this for chaining + */ + public NotificationAction listener(AlertActionListener listener) { + this.listener = listener; + return this; + } + + String getResourceKey() { + return resourceKey; + } + + AlertActionListener getListener() { + return listener; + } + + List getClasses() { + return classes; + } + + Optional getIconVariant() { + return iconVariant; + } + + + String resolveText(Notification parent, Locale locale ) { + return text.orElseGet(() -> MessageFormat.format(resolveBundle(parent, locale). + getString(resourceKey + ".text"), args.toArray(new Object[0]))); + } + + + private ResourceBundle resolveBundle(Notification parent, Locale locale) { + return bundle.map(b -> b.get()).orElseGet(() -> + bundleClass.map(bc -> Resources.of(bc.get(), locale)).orElseGet(() -> parent.resolveBundle(locale))); + } + } + + private NotificationType type = NotificationType.INFO; + private Dismission dismission = Dismission.DURATION; + private List titleArgs = new ArrayList<>(); + private List contentArgs = new ArrayList<>(); + private Optional>> bundleClass = Optional.empty(); + private Optional> bundle = Optional.empty(); + private Optional key = Optional.empty(); + private Optional title = Optional.empty(); + private Optional content = Optional.empty(); + private Optional icon = Optional.empty(); + private Optional iconVariant = Optional.empty(); + private final List actions = new ArrayList<>(); + private Optional dimissDuration = Optional.empty(); + private Optional accessory = Optional.empty(); + + /** + * An accessory is any JSoup {@link Element} that will be added + * between the content and the action of the alert. + * + * @param accessory accessory + * @return this for chaining + */ + public Builder accessory(TemplateModel accessory) { + this.accessory = Optional.of(accessory); + return this; + } + + /** + * Arguments for i18n title resource (when in use) + * + * @param titleArgs title arguments + */ + public Builder titleArguments(Object... arguments) { + return titleArgumentsList(Arrays.asList(arguments)); + } + + /** + * Arguments for i18n title resource (when in use) + * + * @param titleArgs title arguments + */ + public Builder titleArgumentsList(Collection arguments) { + this.titleArgs.clear(); + this.titleArgs.addAll(arguments); + return this; + } + + /** + * Arguments for i18n content resource (when in use) + * + * @param contentArgs content arguments + */ + public Builder contentArguments(Object... arguments) { + return contentArgumentsList(Arrays.asList(arguments)); + } + + /** + * Arguments for i18n content resource (when in use) + * + * @param contentArgs content arguments + */ + public Builder contentArgumentsList(Collection arguments) { + this.contentArgs.clear(); + this.contentArgs.addAll(arguments); + return this; + } + + /** + * Set the dimission policy for this message. + * + * @param dimission dimission policy + * @return this for chaining + */ + public Builder dismission(Dismission dismission) { + this.dismission = dismission; + return this; + } + + /** + * Set how long to dismiss for, only applicable when {@link Dismission} is {@link Dismission#DURATION}. + * + * @param dimissDuration dismiss duration + * @return this for chaining + */ + public Builder dimissDuration(Duration dimissDuration) { + this.dimissDuration = Optional.of(dimissDuration); + return this; + } + + /** + * Set the type of alert. + * + * @param type type + * @return this + */ + public Builder type(NotificationType type) { + this.type = type; + return this; + } + + /** + * Set the icon for this alert. + * + * @param icon icon name + * @return this for chaining + */ + public Builder icon(String icon) { + this.icon = Optional.of(icon); + return this; + } + + /** + * Set the icon variant for this action. + * + * @param iconVariant icon + * @return this for chaining + */ + public Builder iconVariant(String iconVariant) { + this.iconVariant = Optional.of(iconVariant); + return this; + } + + /** + * Set this key for this alert. + * + * @param key key + * @return this for chaining + */ + public Builder key(String resourceKey) { + this.key = Optional.of(resourceKey); + return this; + } + + /** + * The resource bundle for internationalised text. + * + * @param clazz bundle + * @return bundle + */ + public Builder bundleClass(Class clazz) { + return bundleClass(() -> clazz); + } + + + /** + * The resource bundle for internationalised text. + * + * @param clazz bundle + * @return bundle + */ + public Builder bundle(ResourceBundle bundle) { + return bundle(() -> bundle); + } + + /** + * The resource bundle for internationalised text. + * + * @param clazz bundle + * @return bundle + */ + public Builder bundleClass(Supplier> clazz) { + this.bundleClass = Optional.of(clazz); + return this; + } + + + /** + * The resource bundle for internationalised text. + * + * @param clazz bundle + * @return bundle + */ + public Builder bundle(Supplier bundle) { + this.bundle = Optional.of(bundle); + return this; + } + + /** + * Set this title for this alert. + * + * @param title title + * @return this for chaining + */ + public Builder title(String title) { + this.title = Optional.of(title); + return this; + } + + /** + * Set this content for this toast. + *alert + * @param content content + * @return this for chaining + */ + public Builder content(String content) { + this.content = Optional.of(content); + return this; + } + + /** + * Convenience method to add a new named action with a listener. + * + * @param key resource key + * @param listener listener + * @param classes button CSS classes + * @return this for chaining + */ + public Builder action(String resourceKey, AlertActionListener listener, String... classes) { + actions.add(new NotificationAction(resourceKey, listener, classes)); + return this; + } + + /** + * Convenience method to add a new named action with a listener. + * + * @param key resource key + * @param icon icon + * @param listener listener + * @param classes button CSS classes + * @return this for chaining + */ + public Builder action(String resourceKey, String icon, AlertActionListener listener, String... classes) { + actions.add(new NotificationAction(resourceKey, listener, classes).icon(icon)); + return this; + } + + /** + * Trigger a new notification message based on the configuration in this + * builder. + */ + public Notification build() { + return new Notification(this); + } + } + + private final NotificationType type; + private final Optional title; + private final Optional content; + private final Optional icon; + private final List actions; + private final Optional dimissDuration; + private final String key; + private final Optional> bundle; + private final Optional>> bundleClass; + private final Dismission dismission; + private final List titleArgs; + private final List contentArgs; + private final Optional accessory; + private final Optional iconVariant; + + private Notification(Builder builder) { + accessory = builder.accessory; + bundle = builder.bundle; + bundleClass = builder.bundleClass; + key = builder.key.orElseThrow(() -> new IllegalStateException("All alerts requires a key()")); + type = builder.type; + title = builder.title; + content = builder.content; + icon = builder.icon; + actions = Collections.unmodifiableList(new ArrayList<>(builder.actions)); + titleArgs = Collections.unmodifiableList(new ArrayList<>(builder.titleArgs)); + contentArgs = Collections.unmodifiableList(new ArrayList<>(builder.contentArgs)); + dimissDuration = builder.dimissDuration; + dismission = builder.dismission; + iconVariant = builder.iconVariant; + } + + public List titleArgs() { + return titleArgs; + } + + public List contentArgs() { + return contentArgs; + } + + public NotificationType type() { + return type; + } + + public Optional accessory() { + return accessory; + } + + public String key() { + return key; + } + + public Optional title() { + return title; + } + + public Optional content() { + return content; + } + + public Optional icon() { + return icon; + } + + public Optional iconVariant() { + return iconVariant; + } + + public List actions() { + return actions; + } + + public Optional dimissDuration() { + return dimissDuration; + } + + public Dismission dismission() { + return dismission; + } + + public Optional action(String action) { + return actions.stream().filter(a -> a.getResourceKey().equals(action)).findFirst(); + } + + String resolveTitle(Locale locale ) { + return title.orElseGet(() -> MessageFormat.format(resolveBundle(locale). + getString(key + ".title"), titleArgs.toArray(new Object[0]))); + } + + String resolveContent(Locale locale ) { + return content.orElseGet(() -> MessageFormat.format(resolveBundle(locale). + getString(key + ".content"), contentArgs.toArray(new Object[0]))); + } + + private ResourceBundle resolveBundle(Locale locale) { + return bundle.map(b -> b.get()).orElseGet(() -> + Resources.of(bundleClass.map(bc -> bc.get()).orElseThrow(() -> new IllegalStateException("No bundle could be resolved.")), locale)); + } +} diff --git a/plugins/alert-centre/src/main/java/com/sshtools/jenny/alertcentre/NotificationInstance.java b/plugins/alert-centre/src/main/java/com/sshtools/jenny/alertcentre/NotificationInstance.java new file mode 100644 index 0000000..6e360c7 --- /dev/null +++ b/plugins/alert-centre/src/main/java/com/sshtools/jenny/alertcentre/NotificationInstance.java @@ -0,0 +1,19 @@ +package com.sshtools.jenny.alertcentre; + +import java.io.UnsupportedEncodingException; +import java.security.Principal; +import java.util.UUID; + +import com.sshtools.jenny.alertcentre.Monitor.Scope; + + +public record NotificationInstance(Notification alert, Principal user, Scope scope) { + + public String toUUID() { + try { + return UUID.nameUUIDFromBytes((alert().key() + "-" + user().getName()).getBytes("UTF-8")).toString(); + } catch (UnsupportedEncodingException e) { + throw new IllegalArgumentException(e); + } + } +} diff --git a/plugins/alert-centre/src/main/java/com/sshtools/jenny/alertcentre/NotificationType.java b/plugins/alert-centre/src/main/java/com/sshtools/jenny/alertcentre/NotificationType.java new file mode 100644 index 0000000..ee8bcb9 --- /dev/null +++ b/plugins/alert-centre/src/main/java/com/sshtools/jenny/alertcentre/NotificationType.java @@ -0,0 +1,42 @@ +/** + * Copyright © 2018 SSHTOOLS Limited (support@sshtools.com) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.sshtools.jenny.alertcentre; + +/** + * Constants for the type of notification message. + */ +public enum NotificationType { + /** + * Error + */ + DANGER, + /** + * Warning + */ + WARNING, + /** + * Information + */ + INFO, + /** + * Download + */ + DOWNLOAD; + + public boolean counter() { + return this != DOWNLOAD; + } +} diff --git a/plugins/alert-centre/src/main/java/module-info.java b/plugins/alert-centre/src/main/java/module-info.java new file mode 100644 index 0000000..a01dd63 --- /dev/null +++ b/plugins/alert-centre/src/main/java/module-info.java @@ -0,0 +1,11 @@ +import com.sshtools.bootlace.api.Plugin; +import com.sshtools.jenny.alertcentre.AlertCentre; + +module com.sshtools.jenny.alertscentre { + exports com.sshtools.jenny.alertcentre ; + opens com.sshtools.jenny.alertcentre; + requires java.json; + requires transitive com.sshtools.jenny.api; + requires transitive com.sshtools.jenny.i18n; + provides Plugin with AlertCentre; +} \ No newline at end of file diff --git a/plugins/alert-centre/src/main/resources/com/sshtools/jenny/alertcentre/AlertCentre.properties b/plugins/alert-centre/src/main/resources/com/sshtools/jenny/alertcentre/AlertCentre.properties new file mode 100644 index 0000000..9addd28 --- /dev/null +++ b/plugins/alert-centre/src/main/resources/com/sshtools/jenny/alertcentre/AlertCentre.properties @@ -0,0 +1,5 @@ +dismission.FOREVER=Dismiss this alert permanently +dismission.DURATION=Remind me about this later +dismission.UPGRADE=Remind me about this at next upgrade +dismission.LOGIN=Remind me about this at next login +dismission.RESTART=Remind me about this at next restart diff --git a/plugins/alert-centre/src/main/resources/com/sshtools/jenny/alertcentre/alertcentre.js b/plugins/alert-centre/src/main/resources/com/sshtools/jenny/alertcentre/alertcentre.js new file mode 100644 index 0000000..e69de29 diff --git a/plugins/alert-centre/src/main/resources/layers.ini b/plugins/alert-centre/src/main/resources/layers.ini new file mode 100644 index 0000000..b07335e --- /dev/null +++ b/plugins/alert-centre/src/main/resources/layers.ini @@ -0,0 +1,11 @@ +[component] + id = com.sshtools:jenny-alerts + name = Alerts + +[meta] + icon = plugin + description = Provides framework for information alerts, that highlight some \ + currently existing state. + +[artifacts] + com.sshtools:jenny-io:0.0.1-SNAPSHOT diff --git a/plugins/i18n/src/main/java/com/sshtools/jenny/i18n/I18N.java b/plugins/i18n/src/main/java/com/sshtools/jenny/i18n/I18N.java index 0c1c96d..f6e01b4 100644 --- a/plugins/i18n/src/main/java/com/sshtools/jenny/i18n/I18N.java +++ b/plugins/i18n/src/main/java/com/sshtools/jenny/i18n/I18N.java @@ -65,11 +65,11 @@ public static Handler script(Class bundle) { }); var buf = new StringBuilder(); - buf.append("if(typeof i18n === 'undefined') { alert('I18N Javascript support not loaded.'); } else { i18n.bundles['"); + buf.append("if(typeof i18n === \"undefined\") { alert(\"I18N Javascript support not loaded.\"); } else { i18n.bundles[\""); buf.append(bundle.getSimpleName()); - buf.append("'] = JSON.parse('"); - buf.append(arrBldr.build().toString().replace("\\\"", RNDTKN).replace("\"", "\\\"")); - buf.append("'); }"); + buf.append("\"] = JSON.parse(\""); + buf.append(arrBldr.build().toString().replace("\\", "\\\\").replace("\\\"", RNDTKN).replace("\"", "\\\"")); + buf.append("\"); }"); tx.response("text/javascript", buf); }; diff --git a/plugins/pages/pom.xml b/plugins/pages/pom.xml new file mode 100644 index 0000000..7c5837d --- /dev/null +++ b/plugins/pages/pom.xml @@ -0,0 +1,54 @@ + + + 4.0.0 + + com.sshtools + jenny-plugins + 0.0.1-SNAPSHOT + ../ + + Jenny - Pages + jenny-pages + + + + + ${project.groupId} + jenny-web + ${project.version} + provided + + + ${project.groupId} + jenny-api + ${project.version} + provided + + + com.sshtools + bootlace-api + provided + + + + + + diff --git a/plugins/pages/src/main/java/com/sshtools/jenny/pages/APIEndpoint.java b/plugins/pages/src/main/java/com/sshtools/jenny/pages/APIEndpoint.java new file mode 100644 index 0000000..0579887 --- /dev/null +++ b/plugins/pages/src/main/java/com/sshtools/jenny/pages/APIEndpoint.java @@ -0,0 +1,5 @@ +package com.sshtools.jenny.pages; + +public interface APIEndpoint { + +} diff --git a/plugins/pages/src/main/java/com/sshtools/jenny/pages/Page.java b/plugins/pages/src/main/java/com/sshtools/jenny/pages/Page.java new file mode 100644 index 0000000..5d647ad --- /dev/null +++ b/plugins/pages/src/main/java/com/sshtools/jenny/pages/Page.java @@ -0,0 +1,76 @@ +package com.sshtools.jenny.pages; + +import java.io.Closeable; +import java.io.IOException; +import java.util.Optional; +import java.util.function.Consumer; +import java.util.function.Supplier; + +import com.sshtools.tinytemplate.Templates.TemplateModel; +import com.sshtools.uhttpd.UHTTPD.Transaction; + +public final class Page implements Closeable { + + public final static class Builder { + private Optional contentType = Optional.empty(); + private Optional uri = Optional.empty(); + private Optional onClose = Optional.empty(); + private Optional> onHandle = Optional.empty(); + private Optional> onGet = Optional.empty(); + private Optional> onPost = Optional.empty(); + private Optional> content = Optional.empty(); + private Optional> template = Optional.empty(); + private Optional> templateBase = Optional.empty(); + private Optional templateResource = Optional.empty(); + + public Builder withUri(String uri) { + this.uri = Optional.of(uri); + return this; + } + + public Builder withContent(String content) { + return withContent(() -> content); + } + + public Builder withContent(Supplier content) { + this.content = Optional.of(content); + return this; + } + + public Builder withContentType(String contentType) { + this.contentType = Optional.of(contentType); + return this; + } + + public Builder withTemplate(Supplier content) { + this.content = Optional.of(content); + return this; + } + + public Builder onClose(Runnable onClose) { + this.onClose = Optional.of(onClose); + return this; + } + } + + private final Optional contentType; + private final Optional> content; + private final Optional onClose; + private final String uri; + + private Page(Builder bldr) { + this.contentType = bldr.contentType; + this.content = bldr.content; + this.onClose = bldr.onClose; + this.uri = bldr.uri.orElseThrow(() -> new IllegalArgumentException("All pages require a URI.")); + } + + public void render(Transaction tx) { + tx.response(contentType.orElse("text/plain"), content.map(Supplier::get).orElse("")); + } + + @Override + public void close() throws IOException { + onClose.ifPresent(Runnable::run); + } +} diff --git a/plugins/pages/src/main/java/com/sshtools/jenny/pages/Pages.java b/plugins/pages/src/main/java/com/sshtools/jenny/pages/Pages.java new file mode 100644 index 0000000..3d836e8 --- /dev/null +++ b/plugins/pages/src/main/java/com/sshtools/jenny/pages/Pages.java @@ -0,0 +1,29 @@ +/** + * Copyright © 2023 JAdaptive Limited (support@jadaptive.com) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.sshtools.jenny.pages; + +import com.sshtools.bootlace.api.Logs; +import com.sshtools.bootlace.api.Logs.Category; +import com.sshtools.bootlace.api.Logs.Log; +import com.sshtools.bootlace.api.Plugin; +import com.sshtools.jenny.web.WebModule; + +public class Pages implements Plugin { + final static Log LOG = Logs.of(Category.ofName(Pages.class)); + + public final static WebModule MODULE_PAGES = WebModule.of("/pages/pages.js", Pages.class, "pages.js"); + +} diff --git a/plugins/pages/src/main/java/module-info.java b/plugins/pages/src/main/java/module-info.java new file mode 100644 index 0000000..f0abf4c --- /dev/null +++ b/plugins/pages/src/main/java/module-info.java @@ -0,0 +1,27 @@ +/** + * Copyright © 2023 JAdaptive Limited (support@jadaptive.com) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import com.sshtools.bootlace.api.Plugin; +import com.sshtools.jenny.pages.Pages; + +module com.sshtools.jenny.pages { + exports com.sshtools.jenny.pages; + opens com.sshtools.jenny.pages; + + requires transitive java.json; + requires transitive com.sshtools.jenny.web; + + provides Plugin with Pages; +} diff --git a/plugins/pages/src/main/resources/com/sshtools/jenny/pages/pages.js b/plugins/pages/src/main/resources/com/sshtools/jenny/pages/pages.js new file mode 100644 index 0000000..e69de29 diff --git a/plugins/pages/src/main/resources/layers.ini b/plugins/pages/src/main/resources/layers.ini new file mode 100644 index 0000000..aa8a51e --- /dev/null +++ b/plugins/pages/src/main/resources/layers.ini @@ -0,0 +1,9 @@ +[component] + id = com.sshtools:jenny-io + name = I/O + +[meta] + icon = plugin + description = Provides a websocket based communication channel between the server and any client \ + browsers. + diff --git a/plugins/pom.xml b/plugins/pom.xml index 38b9125..74f24d7 100644 --- a/plugins/pom.xml +++ b/plugins/pom.xml @@ -37,6 +37,8 @@ jobs toast files + alert-centre + pages diff --git a/pom.xml b/pom.xml index 60e51f6..bbb792b 100644 --- a/pom.xml +++ b/pom.xml @@ -27,6 +27,7 @@ boot api + config frameworks web auth diff --git a/web/src/main/java/com/sshtools/jenny/web/ResourceRef.java b/web/src/main/java/com/sshtools/jenny/web/ResourceRef.java index 7a21771..f88852b 100644 --- a/web/src/main/java/com/sshtools/jenny/web/ResourceRef.java +++ b/web/src/main/java/com/sshtools/jenny/web/ResourceRef.java @@ -20,8 +20,12 @@ import com.sshtools.uhttpd.UHTTPD.Handler; public record ResourceRef(Class base, ClassLoader loader, String path) { + + public ResourceRef(Class base) { + this(base, null); + } - ResourceRef(Class base, String path) { + public ResourceRef(Class base, String path) { this(base, base.getClassLoader(), path); } @@ -42,8 +46,21 @@ public Handler handler() { return classpathResource(base(), path()); } } - + public ResourceRef translate(String uri) { return new ResourceRef(base, loader, uri + "/" + path); } + + public String fullpath() { + var p = new StringBuilder(); + if(base != null) { + p.append(base.getPackage().getName().replace('.', '/')); + } + if(path != null) { + if(p.length() > 0) + p.append('/'); + p.append(path); + } + return p.toString(); + } } diff --git a/web/src/main/java/com/sshtools/jenny/web/Responses.java b/web/src/main/java/com/sshtools/jenny/web/Responses.java new file mode 100644 index 0000000..77865e2 --- /dev/null +++ b/web/src/main/java/com/sshtools/jenny/web/Responses.java @@ -0,0 +1,77 @@ +package com.sshtools.jenny.web; + +import java.text.MessageFormat; + +import javax.json.Json; +import javax.json.JsonObject; +import javax.json.JsonObjectBuilder; +import javax.json.JsonValue; + +import com.sshtools.bootlace.api.Logs; +import com.sshtools.bootlace.api.Logs.Log; +import com.sshtools.jenny.api.Resources; + + +public class Responses { + + @SuppressWarnings("serial") + public final static class RedirectResponse extends RuntimeException { + public RedirectResponse(String location) { + super(location); + } + + public String location() { + return getMessage(); + } + + public String toString() { + return Responses.redirect(location()).toString(); + } + } + + private final static Log LOG = Logs.of(WebLog.WEB); + + public static JsonObject success() { + return buildSuccess(). + build(); + } + + public static JsonObjectBuilder buildSuccess() { + return Json.createObjectBuilder(). + add("success", true); + } + + public static JsonObject success(JsonValue payload) { + return buildSuccess(). + add("payload", payload). + build(); + } + + public static JsonObject redirect(String location) { + return Json.createObjectBuilder(). + add("redirect", true). + add("location", location). + build(); + } + + public static JsonObject error(Exception exception) { + LOG.error("API failure.", exception); + return error(exception.getClass().getName(), exception.getMessage() == null ? "No additional detail supplied." : exception.getMessage()); + + } + + public static JsonObject error(Class bundle, String key, Object... args) { + var rsrcs = Resources.of(bundle, WebState.get().locale()); + var message = args.length == 0 ? rsrcs.getString(key) : MessageFormat.format(rsrcs.getString(key), args); + LOG.error("API failure. {}", message); + return error(Exception.class.getName(), message); + } + + public static JsonObject error(String type, String message) { + return Json.createObjectBuilder(). + add("success", false). + add("type", type). + add("message", message). + build(); + } +} diff --git a/web/src/main/java/com/sshtools/jenny/web/Web.java b/web/src/main/java/com/sshtools/jenny/web/Web.java index 30d1a87..4b9d4ad 100644 --- a/web/src/main/java/com/sshtools/jenny/web/Web.java +++ b/web/src/main/java/com/sshtools/jenny/web/Web.java @@ -48,12 +48,13 @@ import com.sshtools.bootlace.api.PluginContext; import com.sshtools.jenny.api.XPoints; import com.sshtools.jenny.config.Config; -import com.sshtools.jenny.config.Config.Handle; import com.sshtools.jenny.web.Router.RouterBuilder; import com.sshtools.jenny.web.WebModule.Placement; import com.sshtools.jenny.web.WebModule.Type; import com.sshtools.jenny.web.WebModule.WebModuleResource; import com.sshtools.jenny.web.WebModule.WebModulesRef; +import com.sshtools.jini.INI; +import com.sshtools.jini.config.INISet; import com.sshtools.tinytemplate.Templates.Logger; import com.sshtools.tinytemplate.Templates.TemplateModel; import com.sshtools.tinytemplate.Templates.TemplateProcessor; @@ -67,6 +68,17 @@ public final class Web implements Plugin { + public static final String KEYSTORE_TYPE = "keystore-type"; + public static final String KEYSTORE_PASSWORD = "keystore-password"; + public static final String KEYSTORE_FILE = "keystore-file"; + public static final String KEY_PASSWORD = "key-password"; + public static final String ADDRESS = "address"; + public static final String PORT = "port"; + public static final String HTTPS = "https"; + public static final String NCSA = "ncsa"; + public static final String TUNING = "tuning"; + public static final String HTTP = "http"; + final static Log LOG = Logs.of(WebLog.WEB); public static final String TX_BASE_HREF = "base.href"; @@ -76,6 +88,8 @@ public static void setBase(Transaction tx) { tx.attr(Web.TX_BASE_HREF, ( tx.secure() ? "https://" : "http://" ) + tx.host() + tx.contextPath()); } + private final Config config = PluginContext.$().plugin(Config.class); + private final TemplateProcessor tp; private RootContext httpd; private final XPoints extensions; @@ -83,6 +97,7 @@ public static void setBase(Transaction tx) { private final ScheduledExecutorService queue; private final Map modules = new ConcurrentHashMap<>(); private final List globalModules = new ArrayList(); + private final INISet configSet; private com.sshtools.bootlace.api.RootContext rootContext; @@ -125,6 +140,14 @@ public void warning(String message, Object... args) { /* Routes */ router = new RouterBuilder(). build(); + + if(Boolean.getBoolean("jenny.newConfig")) { + configSet = config.configBuilder("web", Web.class, "Web.schema.ini"). + build(); + } + else { + configSet = null; + } } public Closeable global(WebModule... modules) { @@ -144,7 +167,6 @@ public void close() throws IOException { public void afterOpen(PluginContext context) { this.rootContext = context.root(); - /* Main server loop */ try { var sessions = UHTTPD.sessionCookies().build(); @@ -172,8 +194,6 @@ public void afterOpen(PluginContext context) { }). /* Default resource */ - get("/npm2mvn/(.*)", this::npmResource). - withClasspathResources("/npm2mvn/(.*)", getClass().getClassLoader(), "npm2mvn/"). withClasspathResources("/(.*)", getClass().getClassLoader(), "web/"); configureServer(bldr); @@ -185,7 +205,9 @@ public void afterOpen(PluginContext context) { httpd.start(); var webConfig = getWebConfig(); - webConfig.ini().getOr("port-info").ifPresent(pi -> { + var stateSection = webConfig.obtainSection("state"); + + stateSection.getOr("port-info").ifPresent(pi -> { try(var out = new PrintWriter(Files.newBufferedWriter(Paths.get(pi)), true)) { httpd.httpPort().ifPresent(p -> out.println("http.port=" + p)); httpd.httpsPort().ifPresent(p -> out.println("https.port=" + p)); @@ -206,11 +228,18 @@ public void afterOpen(PluginContext context) { @Override public void close() { httpd.close(); + if(configSet != null) { + configSet.close(); + } } public XPoints extensions() { return extensions; } + + public INI configuration() { + return configSet.document(); + } public ScheduledExecutorService globalUiQueue() { return queue; @@ -301,25 +330,29 @@ private void addModules(ArrayList l, HashSet m, WebModule... modu private void configureServer(RootContextBuilder bldr) { - var webConfig = getWebConfig(); + var ini = getWebConfig(); - var http = webConfig.ini().sectionOr("http"); + var http = ini.sectionOr(HTTP); http.ifPresent(cfg -> { - bldr.withHttp(cfg.getInt("port", 8080)); - bldr.withHttpAddress(cfg.get("address", "::")); + var httpPort = cfg.getInt(PORT, 8080); + if(httpPort > 0) + bldr.withHttp(httpPort); + bldr.withHttpAddress(cfg.get(ADDRESS, "::")); }); - var https = webConfig.ini().sectionOr("https"); + var https = ini.sectionOr(HTTPS); https.ifPresent(cfg -> { - bldr.withHttps(cfg.getInt("port", 8443)); - bldr.withHttpsAddress(cfg.get("address", "::")); - cfg.getOr("key-password").ifPresent(kp -> bldr.withKeyPassword(kp.toCharArray())); - cfg.getOr("keystore-file").ifPresent(ks -> bldr.withKeyStoreFile(Paths.get(ks))); - cfg.getOr("keystore-password").ifPresent(kp -> bldr.withKeyPassword(kp.toCharArray())); - cfg.getOr("keystore-type").ifPresent(kp -> bldr.withKeyStoreType(kp)); + var httpsPort = cfg.getInt(PORT, 8443); + if(httpsPort > 0) + bldr.withHttps(httpsPort); + bldr.withHttpsAddress(cfg.get(ADDRESS, "::")); + cfg.getOr(KEY_PASSWORD).ifPresent(kp -> bldr.withKeyPassword(kp.toCharArray())); + cfg.getOr(KEYSTORE_FILE).ifPresent(ks -> bldr.withKeyStoreFile(Paths.get(ks))); + cfg.getOr(KEYSTORE_PASSWORD).ifPresent(kp -> bldr.withKeyPassword(kp.toCharArray())); + cfg.getOr(KEYSTORE_TYPE).ifPresent(kp -> bldr.withKeyStoreType(kp)); }); - webConfig.ini().sectionOr("tuning").ifPresent(cfg -> { + ini.sectionOr(TUNING).ifPresent(cfg -> { if(!cfg.getBoolean("compression", true)) { bldr.withoutCompression(); } @@ -328,7 +361,7 @@ private void configureServer(RootContextBuilder bldr) { /* TODO Arggh... The "view source" in firefox bug when compression is on really needs fixing */ bldr.withoutCompression(); - webConfig.ini().sectionOr("ncsa").ifPresent(cfg -> { + ini.sectionOr(NCSA).ifPresent(cfg -> { bldr.withLogger(new NCSALoggerBuilder(). withAppend(cfg.getBoolean("append", true)). withDirectory(Paths.get(cfg.get("directory", System.getProperty("user.dir") + File.separator + "logs"))). @@ -340,10 +373,15 @@ private void configureServer(RootContextBuilder bldr) { }); } - private Handle getWebConfig() { - var config = PluginContext.$().plugin(Config.class); - var webConfig = config.config(this, Scope.SYSTEM); - return webConfig; + private INI getWebConfig() { + if(configSet == null) { + var config = PluginContext.$().plugin(Config.class); + var webConfig = config.config(this, Scope.SYSTEM); + return webConfig.ini(); + } + else { + return configSet.document(); + } } private List cssModules(Transaction tx, String content, Placement placement) { @@ -432,22 +470,6 @@ private List scriptModules(Transaction tx, String content, Placem } ).toList(); } - - /** - * To be replaced by {@link NpmWebModule}. - * - * @param tx - */ - @Deprecated - private void npmResource(Transaction tx) { - rootContext.globalResource(tx.match(0)).ifPresent(res -> { - try { - UHTTPD.urlResource(res).get(tx); - } catch (Exception e) { - LOG.error("Failed to serve static npm resource.", e); - } - }); - } private Collection sortModules(Collection types, Placement placement) { /* The complete list */ diff --git a/web/src/main/java/com/sshtools/jenny/web/WebModule.java b/web/src/main/java/com/sshtools/jenny/web/WebModule.java index 33ab7a0..c96ce1c 100644 --- a/web/src/main/java/com/sshtools/jenny/web/WebModule.java +++ b/web/src/main/java/com/sshtools/jenny/web/WebModule.java @@ -50,7 +50,7 @@ public enum Mount { } public enum Type { - IMPORT_MAP, CSS, JS, MODULE, IMPORTED + IMPORT_MAP, CSS, JS, MODULE, IMPORTED, ANCILIARY } public enum Placement { @@ -320,6 +320,7 @@ public final static class Builder { private List resources = new ArrayList<>(); private Optional mount = Optional.empty(); private Optional loader = Optional.empty(); + private Optional prefix = Optional.empty(); public Builder withName(String name) { this.name = Optional.of(name); @@ -384,6 +385,15 @@ public Builder withLoader(ClassLoader loader) { return this; } + public Builder withPrefix(String prefix) { + return withPrefix(new ResourceRef(prefix)); + } + + public Builder withPrefix(ResourceRef prefix) { + this.prefix = Optional.of(prefix); + return this; + } + public Builder asFile() { return as(Mount.FILE); } @@ -396,6 +406,18 @@ public Builder asDirectory() { return as(Mount.DIRECTORY); } + public Builder asDirectory(Class base) { + return asDirectory(new ResourceRef(base)); + } + + public Builder asDirectory(Class base, String prefix) { + return asDirectory(new ResourceRef(base, prefix)); + } + + public Builder asDirectory(ResourceRef prefix) { + return as(Mount.DIRECTORY).withPrefix(prefix).withLoader(prefix.loader()); + } + public Builder as(Mount mount) { this.mount = Optional.of(mount); return this; @@ -424,10 +446,16 @@ private static String escapeLiteral(String path) { private final Optional handler; private final Mount mount; private final String uri; + private final Optional prefix; private Optional loader; private WebModule(Builder builder) { - this.resources = Collections.unmodifiableList(new ArrayList<>(builder.resources)); + this.prefix = builder.prefix; + this.loader = builder.loader; + this.resources = Collections.unmodifiableList(new ArrayList<>(builder.resources).stream().peek(f-> { + if( f.ref.loader() != null && loader.isPresent()) + throw new IllegalArgumentException("Loader is set on the " +WebModuleResource.class.getName() + " , so should not be set on any " + ResourceRef.class.getName() + "."); + }).toList()); this.resources.forEach(r -> r.module = this); this.mount = builder.mount.orElseGet(() -> builder.uri == null ? Mount.CONTENT : builder.uri.endsWith("/") ? Mount.DIRECTORY : Mount.FILE); @@ -465,9 +493,9 @@ else if(mount != Mount.URL) { this.uri = uri; this.name = builder.name.orElse(this.pattern); this.requires = Collections.unmodifiableSet(new LinkedHashSet<>(builder.requires)); - this.loader = builder.loader; if(mount == Mount.DIRECTORY) { + /* TODO is this really the right condition? prefix.isPresent() maybe better */ if(this.loader.isPresent()) { /** * Web module groups any arbitrary resource on the classpath @@ -479,7 +507,13 @@ else if(mount != Mount.URL) { @Override public void get(Transaction req) throws Exception { var rel = req.match(0); - var fullPath = WebModule.this.uri.substring(1) + "/" + rel; + String fullPath; + if(prefix.isPresent()) { + fullPath = prefix.get().fullpath() + "/" + rel; + } + else { + fullPath = WebModule.this.uri.substring(1) + "/" + rel; + } UHTTPD.classpathResource(loader, fullPath).get(req); } }); @@ -549,6 +583,10 @@ private String uri(WebModuleResource webModuleResource) { } } } + + public Optional prefix() { + return prefix; + } public Set requires() { return requires; diff --git a/web/src/main/java/com/sshtools/jenny/web/WebState.java b/web/src/main/java/com/sshtools/jenny/web/WebState.java index 625dc8c..fb828d4 100644 --- a/web/src/main/java/com/sshtools/jenny/web/WebState.java +++ b/web/src/main/java/com/sshtools/jenny/web/WebState.java @@ -15,11 +15,12 @@ */ package com.sshtools.jenny.web; -import java.nio.file.attribute.UserPrincipal; +import java.security.Principal; import java.util.Locale; import java.util.Map; import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Supplier; import com.sshtools.uhttpd.UHTTPD.Session; @@ -54,7 +55,7 @@ public static WebState get(Session session) { this.session = session; } - public Optional user() { + public Optional user() { return Optional.ofNullable(get(USER)); } @@ -76,6 +77,12 @@ public V get(String key, V defaultValue) { return env.containsKey(key) ? (V)env.get(key) : defaultValue; } + + @SuppressWarnings("unchecked") + public V get(String key, Supplier defaultValue) { + return env.containsKey(key) ? (V)env.get(key) : defaultValue.get(); + } + @SuppressWarnings("unchecked") public V set(String key, V val) { return (V)env.put(key, val); @@ -90,7 +97,7 @@ public WebState locale(Locale locale) { return this; } - public void authenticate(UserPrincipal user) { + public void authenticate(Principal user) { user().ifPresentOrElse(u -> { throw new IllegalStateException("Already authenticated."); }, () -> set(USER, user)); diff --git a/web/src/main/resources/com/sshtools/jenny/web/Web.schema.ini b/web/src/main/resources/com/sshtools/jenny/web/Web.schema.ini new file mode 100644 index 0000000..2b6d010 --- /dev/null +++ b/web/src/main/resources/com/sshtools/jenny/web/Web.schema.ini @@ -0,0 +1,125 @@ +[http] + name = HHTTP + description = Options for the insecure plain HTTP protocol + + [http/port] + name = Port + description = The port number on which the server will listen for HTTP requests. When zero, \ + a random port will be chosen. + type = NUMBER + default-value = 8080 + + [http/address] + name = Address + description = The IPv4 or IPv6 address of the interface the server will listen for HTTP \ + requests. A blank address will listen on all on IPv4 and IPV6 interfaces. + type = TEXT + descriminator = IP + default-value = :: + +[https] + name = HTTPS + description = Options for the insecure plain HTTPS protocol + + [https/port] + name = Port + description = The port number on which the server will listen for HTTPS requests. When zero, \ + a random port will be chosen. + type = NUMBER + default-value = 8443 + + [https/address] + name = Address + description = The IPv4 or IPv6 address of the interface the server will listen for HTTPS \ + requests. A blank address will listen on all on IPv4 and IPV6 interfaces. + type = TEXT + descriminator = IP + default-value = :: + + [https/key-password] + name = Key Password + description = The password for the key in the keystorre, if any. + type = TEXT + descriminator = SECRET + + [https/keystore-file] + name = Keystore File + description = The path to the keystore file. + type = TEXT + descriminator = PATH + + [https/keystore-password] + name = Keystore Password + description = The password for the keystore itself. + type = TEXT + descriminator = SECRET + + [https/keystore-type] + name = Keystore Type + description = The type of keystore. + type = ENUM + default-value = JKS + value = JKS + value = PKCS12 + +[tuning] + name = Tuning + description = Option options for tuning the server + + [tuning/compression] + name = Disable Compression + description = Prevent Gzip compression from being used. + type = BOOLEAN + default-value = true + +[ncsa] + name = NCSA Request Log + description = Options for changing the behaviour of request logging. + + [ncsa/append] + name = Append + description = When enabled, on start-up the previous log will not be replaced, instead it \ + will be appened to. + type = BOOLEAN + default-value = true + + [ncsa/directory] + name = Directory + descriptionn = The directory path to place the logs. This will be created if it does not exist. + type = TEXT + descriminator = PATH + default-value = logs + + [ncsa/extended] + name = Extended + description = Whether to add extended logging attributes. + type = BOOLEAN + default-value = true + + [ncsa/server-name] + name = Log Server Name + description = Whether to log the server name from requests. + type = BOOLEAN + default-value = false + + [ncsa/pattern] + name = Filename Pattern + description = The pattern to use for naming log files. Use the sequence `%d` to include the \ + current date in the format described below. + type = TEXT + default-value = access_log_%d.log + + [ncsa/date-format] + name = Date Format + description = The date formatter string. Use `dd` for the date, `MM` for the month or \ + `yyyy` for the year. See https://docs.oracle.com/javase/8/docs/api/java/text/SimpleDateFormat.html for other patterns. + type = TEXT + default-value = ddMM + +[state] + name = State + + [state/port-info] + name = Port Info + description = A file to write port information to (particularly useful when randomly assigned ports are used) + type = TEXT \ No newline at end of file