diff --git a/.gitignore b/.gitignore index ca67cc2..e6d18f3 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,6 @@ target .idea bin .gradle -build \ No newline at end of file +build +/modules +/plugins \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index c1fb7ec..300b4f8 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -2,11 +2,12 @@ plugins { java application id("com.github.johnrengelman.shadow") version "8.1.1" + id("maven-publish") } group = "igs-landstuhl" -version = "v1.0.1-PATCH-2" +version = "v1.1.0-SNAPSHOT-0" application { mainClass.set("de.igslandstuhl.database.Application") @@ -22,6 +23,7 @@ dependencies { implementation("commons-codec:commons-codec:1.19.0") implementation("com.googlecode.owasp-java-html-sanitizer:owasp-java-html-sanitizer:20260101.1") implementation("org.jline:jline:3.30.6") // for better console input handling + implementation("org.yaml:snakeyaml:2.2") // plugin imports testImplementation("org.junit.jupiter:junit-jupiter:5.13.4") // using JUnit 5 (latest) testRuntimeOnly("org.junit.platform:junit-platform-launcher") @@ -50,3 +52,27 @@ java { languageVersion.set(JavaLanguageVersion.of(17)) // or another version you prefer } } + +publishing { + publications { + create("mavenJava") { + from(components["java"]) + + groupId = "igs-landstuhl" + artifactId = "student-database" + version = project.version.toString() + } + } + + repositories { + maven { + name = "GitHubPackages" + url = uri("https://maven.pkg.github.com/Learn-Monitor/student-database/") + + credentials { + username = System.getenv("GITHUB_ACTOR") + password = System.getenv("GITHUB_TOKEN") + } + } + } +} \ No newline at end of file diff --git a/src/main/java/de/igslandstuhl/database/Application.java b/src/main/java/de/igslandstuhl/database/Application.java index 58a25c8..b91688f 100644 --- a/src/main/java/de/igslandstuhl/database/Application.java +++ b/src/main/java/de/igslandstuhl/database/Application.java @@ -4,11 +4,14 @@ import java.util.ArrayList; import java.util.List; +import org.jline.reader.UserInterruptException; + import de.igslandstuhl.database.api.SerializationException; import de.igslandstuhl.database.api.Subject; import de.igslandstuhl.database.api.Topic; -import de.igslandstuhl.database.api.modules.WebModule; +import de.igslandstuhl.database.client.HTMLTemplate; import de.igslandstuhl.database.holidays.Holiday; +import de.igslandstuhl.database.plugins.PluginLoader; import de.igslandstuhl.database.server.Server; import de.igslandstuhl.database.server.commands.Command; import de.igslandstuhl.database.server.webserver.WebPath; @@ -105,19 +108,28 @@ public static void main(String[] args) throws Exception { Holiday.setupCurrentSchoolYear(); PostRequestHandler.registerHandlers(); - WebModule.registerModules(); + PluginLoader.getInstance().registerPlugins(); WebPath.registerPaths(); + HTMLTemplate.registerAll(); GetRequestHandler.getInstance().registerHandlers(); if (getInstance().runsWebServer()) { Server.getInstance().getWebServer().start(); } - while (true) { - if (!getInstance().suppressCmd()) { - CommandLineUtils.waitForCommandAndExec(); + PluginLoader.getInstance().enablePlugins(); + + Runtime.getRuntime().addShutdownHook(new Thread(() -> PluginLoader.getInstance().unloadPlugins(),"Plugin cleanup thread")); + + try { + while (true) { + if (!getInstance().suppressCmd()) { + CommandLineUtils.waitForCommandAndExec(); + } } - } + } catch (UserInterruptException e) { + System.exit(0); + } // Program exit using Ctrl+C } } diff --git a/src/main/java/de/igslandstuhl/database/Registry.java b/src/main/java/de/igslandstuhl/database/Registry.java index de0e8f9..6e756e2 100644 --- a/src/main/java/de/igslandstuhl/database/Registry.java +++ b/src/main/java/de/igslandstuhl/database/Registry.java @@ -2,24 +2,34 @@ import java.io.Closeable; import java.util.HashMap; +import java.util.HashSet; import java.util.Map; +import java.util.Set; import java.util.stream.Stream; -import de.igslandstuhl.database.api.modules.WebModule; +import de.igslandstuhl.database.client.HTMLTemplate; +import de.igslandstuhl.database.client.navigation.NavigationElement; +import de.igslandstuhl.database.client.navigation.NavigationType; +import de.igslandstuhl.database.plugins.Plugin; import de.igslandstuhl.database.server.commands.Command; import de.igslandstuhl.database.server.commands.CommandDescription; import de.igslandstuhl.database.server.webserver.WebPath; import de.igslandstuhl.database.server.webserver.handlers.HttpHandler; import de.igslandstuhl.database.server.webserver.requests.APIPostRequest; import de.igslandstuhl.database.server.webserver.requests.GetRequest; +import de.igslandstuhl.database.utils.RegistryEnum; public class Registry implements Closeable { private static final Registry COMMAND_REGISTRY = new Registry<>(); private static final Registry COMMAND_DESCRIPTION_REGISTRY = new Registry<>(); private static final Registry> POST_HANDLER_REGISTRY = new Registry<>(); private static final Registry> GET_HANDLER_REGISTRY = new Registry<>(); - private static final Registry MODULE_REGISTRY = new Registry<>(); + private static final Registry PLUGIN_REGISTRY = new Registry<>(); private static final Registry WEB_PATH_REGISTRY = new Registry<>(); + + private static final EnumRegistry NAVIGATION_REGISTRY = new EnumRegistry<>(NavigationType.class); + private static final Registry TEMPLATE_REGISTRY = new Registry<>(); + public static Registry commandRegistry() { return COMMAND_REGISTRY; } @@ -29,8 +39,8 @@ public static Registry> postRequestHandlerRe public static Registry> getRequestHandlerRegistry() { return GET_HANDLER_REGISTRY; } - public static Registry moduleRegistry() { - return MODULE_REGISTRY; + public static Registry pluginRegistry() { + return PLUGIN_REGISTRY; } public static Registry commandDescriptionRegistry() { return COMMAND_DESCRIPTION_REGISTRY; @@ -38,10 +48,21 @@ public static Registry commandDescriptionRegistry() public static Registry webPathRegistry() { return WEB_PATH_REGISTRY; } + public static EnumRegistry navigationRegistry() { + return NAVIGATION_REGISTRY; + } + public static Registry templateRegistry() { + return TEMPLATE_REGISTRY; + } private final Map objects = new HashMap<>(); + private boolean locked = false; + private void checkLocked() { + if (locked) throw new RegistryLockedException(); + } public synchronized void register(K key, V value) { + checkLocked(); objects.put(key, value); } public synchronized Stream stream() { @@ -53,6 +74,37 @@ public synchronized Stream keyStream() { public synchronized V get(K key) { return objects.get(key); } + public synchronized void unregister(K key) { + objects.remove(key); + } + public synchronized void lock() { + locked = true; + } + + public static class EnumRegistry, V> { + private final Map> objects = new HashMap<>(); + + private EnumRegistry(Class clazz) { + try { + Registry enumRegistry = RegistryEnum.init(clazz); + enumRegistry.stream().forEach((k) -> objects.put(k, new HashSet<>())); + } catch (Exception e) { + throw new RuntimeException("Could not initialize registry enum " + clazz.getName(), e); + } + } + public synchronized void register(K key, V value) { + objects.get(key).add(value); + } + public synchronized Stream stream(K key) { + return objects.get(key).stream(); + } + public synchronized Stream keyStream() { + return objects.keySet().stream(); + } + public synchronized void unregister(K key) { + objects.get(key).clear(); + } + } @Override public void close() { diff --git a/src/main/java/de/igslandstuhl/database/RegistryLockedException.java b/src/main/java/de/igslandstuhl/database/RegistryLockedException.java new file mode 100644 index 0000000..ddbccb8 --- /dev/null +++ b/src/main/java/de/igslandstuhl/database/RegistryLockedException.java @@ -0,0 +1,20 @@ +package de.igslandstuhl.database; + +public class RegistryLockedException extends RuntimeException { + public RegistryLockedException() { + super("Registry already locked"); + } + public RegistryLockedException(String message) { + super(message); + } + public RegistryLockedException(Throwable cause) { + super(cause); + } + public RegistryLockedException(String message, Throwable cause) { + super(message, cause); + } + public RegistryLockedException(String message, Throwable cause, boolean enableSuppression, + boolean writableStackTrace) { + super(message, cause, enableSuppression, writableStackTrace); + } +} diff --git a/src/main/java/de/igslandstuhl/database/api/modules/WebModule.java b/src/main/java/de/igslandstuhl/database/api/modules/WebModule.java deleted file mode 100644 index acf5492..0000000 --- a/src/main/java/de/igslandstuhl/database/api/modules/WebModule.java +++ /dev/null @@ -1,137 +0,0 @@ -package de.igslandstuhl.database.api.modules; - -import java.util.LinkedList; -import java.util.List; - -import de.igslandstuhl.database.Registry; - -public abstract class WebModule { - private String id; - private String name; - private String description; - - private boolean enabled; - - private List> settings = new LinkedList<>(); - - public WebModule(String id, String name, String description) { - this.id = id; - this.name = name; - this.description = description; - this.enabled = true; - } - - public String getId() { - return id; - } - public String getName() { - return name; - } - public String getDescription() { - return description; - } - public boolean isEnabled() { - return enabled; - } - - public String toJSON() { - StringBuilder sb = new StringBuilder(); - sb.append("{"); - sb.append("\"id\":\"").append(id).append("\","); - sb.append("\"name\":\"").append(name).append("\","); - sb.append("\"description\":\"").append(description).append("\","); - sb.append("\"enabled\":").append(enabled).append(","); - sb.append("\"settings\":{"); - for (int i = 0; i < settings.size(); i++) { - sb.append("\"").append(settings.get(i).getKey()).append("\":").append(settings.get(i).toJSON()); - if (i < settings.size() - 1) { - sb.append(","); - } - } - sb.append("}"); - sb.append("}"); - return sb.toString(); - } - - public List> getSettings() { - return settings; - } - public ModuleSetting getSettingByKey(String key) { - for (ModuleSetting setting : settings) { - if (setting.getKey().equals(key)) { - return setting; - } - } - return null; - } - public BoolSetting getBoolSetting(String key) { - ModuleSetting setting = getSettingByKey(key); - if (setting instanceof BoolSetting) { - return (BoolSetting) setting; - } - return null; - } - - protected abstract void onEnable(); - protected abstract void onDisable(); - protected abstract void onLoad(); - - public void enable() { - if (enabled) return; - onEnable(); - enabled = true; - } - public void disable() { - if (!enabled) return; - onDisable(); - enabled = false; - } - public void toggle() { - if (enabled) { - disable(); - } else { - enable(); - } - } - public void load() { - onLoad(); - } - - public void toggleSetting(String key) { - getBoolSetting(key).toggle(); - } - - private static class DummyModule extends WebModule { - public DummyModule(String id, String name, String description, List> settings) { - super(id, name, description); - this.getSettings().addAll(settings); - } - - @Override - protected void onEnable() { - // Dummy enable logic - } - - @Override - protected void onDisable() { - // Dummy disable logic - } - - @Override - protected void onLoad() { - // Dummy load logic - } - } - - private static void registerModule(WebModule module) { - Registry.moduleRegistry().register(module.getId(), module); - } - public static void registerModules() { - registerModule(new DummyModule("result_view", "Student Results View", "The view displaying the student's current progress and prognoses for the final result", List.of( - new BoolSetting("show_prognosis", "Show Prognosis", "Whether to display the prognosis for the final result", true), - new BoolSetting("show_current_progress", "Show Current", "Whether to display the current progress to the subject (in percent)", true), - new BoolSetting("show_current_grade", "Show Currently Achieved Grade", "Whether to display the grade the student would achieve when they decide to immediately stop working", false) - ))); - } - -} diff --git a/src/main/java/de/igslandstuhl/database/client/HTMLFileTemplate.java b/src/main/java/de/igslandstuhl/database/client/HTMLFileTemplate.java new file mode 100644 index 0000000..1b53f8e --- /dev/null +++ b/src/main/java/de/igslandstuhl/database/client/HTMLFileTemplate.java @@ -0,0 +1,47 @@ +package de.igslandstuhl.database.client; + +import java.io.FileNotFoundException; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import de.igslandstuhl.database.server.Server; +import de.igslandstuhl.database.server.resources.ResourceLocation; + +public class HTMLFileTemplate implements HTMLTemplate { + private static String sanitizeTemplateName(String name) { + Path base = Paths.get("templates/html"); + Path resolved = base.resolve(name + ".html").normalize(); + + if (!resolved.startsWith(base)) { + throw new IllegalArgumentException("Invalid template name"); + } + + return resolved.getFileName().toString(); + } + private final String templateString; + public HTMLFileTemplate(ResourceLocation file) throws FileNotFoundException { + templateString = Server.getInstance().getResourceManager().readResourceCompletely(file); + } + public HTMLFileTemplate(String file) throws FileNotFoundException { + this(new ResourceLocation("templates", "html", sanitizeTemplateName(file))); + } + @Override + public String fill(Map args) { + if (templateString == null || templateString.isEmpty()) return ""; + Pattern p = Pattern.compile("%\\{([^}]+)\\}"); + Matcher m = p.matcher(templateString); + StringBuffer sb = new StringBuffer(); + while (m.find()) { + String key = m.group(1); + String val = args.getOrDefault(key, ""); + // escape backslashes and dollars for regex replacement + val = val.replace("\\", "\\\\").replace("$", "\\$"); + m.appendReplacement(sb, val); + } + m.appendTail(sb); + return sb.toString(); + } +} diff --git a/src/main/java/de/igslandstuhl/database/client/HTMLTemplate.java b/src/main/java/de/igslandstuhl/database/client/HTMLTemplate.java new file mode 100644 index 0000000..79a3d20 --- /dev/null +++ b/src/main/java/de/igslandstuhl/database/client/HTMLTemplate.java @@ -0,0 +1,44 @@ +package de.igslandstuhl.database.client; + +import java.io.FileNotFoundException; +import java.util.Map; + +import de.igslandstuhl.database.Registry; +import de.igslandstuhl.database.client.navigation.HTMLNavigationTemplate; +import de.igslandstuhl.database.client.navigation.NavigationAppearance; +import de.igslandstuhl.database.client.navigation.NavigationElement; +import de.igslandstuhl.database.client.navigation.NavigationType; +import de.igslandstuhl.database.server.Server; +import de.igslandstuhl.database.server.resources.ResourceLocation; + +public interface HTMLTemplate { + public static final ResourceLocation meta = new ResourceLocation("meta", "templates", "templates.json"); + public String fill(Map args); + private static void register(HTMLTemplate template, String key) { + Registry.templateRegistry().register(key, template); + } + public static void registerAll() { + NavigationElement.registerAll(); + Map json = Server.getInstance().getResourceManager().readJsonResourceMerged(meta); + json.keySet().forEach((key) -> { + @SuppressWarnings("unchecked") + Map template = (Map) json.get(key); + + String type = (String) template.get("type"); + switch (type) { + case "HTMLFileTemplate": + try { + register(new HTMLFileTemplate((String) template.get("path")), key); + } catch (FileNotFoundException e) { + System.err.println("Failed to load html template " + key); + e.printStackTrace(); + } + break; + case "HTMLNavigationTemplate": + register(new HTMLNavigationTemplate(NavigationAppearance.valueOf((String) template.get("appearance")), NavigationType.valueOf((String) template.get("navigation_type"))), key); + default: + break; + } + }); + } +} diff --git a/src/main/java/de/igslandstuhl/database/server/webserver/responses/TemplatingPreprocessor.java b/src/main/java/de/igslandstuhl/database/client/TemplatingPreprocessor.java similarity index 65% rename from src/main/java/de/igslandstuhl/database/server/webserver/responses/TemplatingPreprocessor.java rename to src/main/java/de/igslandstuhl/database/client/TemplatingPreprocessor.java index 2abe8f8..6ef5fe1 100644 --- a/src/main/java/de/igslandstuhl/database/server/webserver/responses/TemplatingPreprocessor.java +++ b/src/main/java/de/igslandstuhl/database/client/TemplatingPreprocessor.java @@ -1,16 +1,11 @@ -package de.igslandstuhl.database.server.webserver.responses; +package de.igslandstuhl.database.client; import java.io.FileNotFoundException; import java.io.IOException; -import java.nio.file.Path; -import java.nio.file.Paths; import java.util.HashMap; import java.util.Map; -import java.util.regex.Matcher; -import java.util.regex.Pattern; -import de.igslandstuhl.database.server.Server; -import de.igslandstuhl.database.server.resources.ResourceLocation; +import de.igslandstuhl.database.Registry; public class TemplatingPreprocessor { private static final TemplatingPreprocessor instance = new TemplatingPreprocessor(); @@ -20,20 +15,8 @@ public static TemplatingPreprocessor getInstance() { private TemplatingPreprocessor() {} - private static String sanitizeTemplateName(String name) { - Path base = Paths.get("templates/html"); - Path resolved = base.resolve(name + ".html").normalize(); - - if (!resolved.startsWith(base)) { - throw new IllegalArgumentException("Invalid template name"); - } - - return resolved.getFileName().toString(); - } - - private String getTemplate(String name) throws FileNotFoundException { - ResourceLocation templateLocation = new ResourceLocation("templates", "html", sanitizeTemplateName(name)); - return Server.getInstance().getResourceManager().readResourceCompletely(templateLocation); + private HTMLTemplate getTemplate(String name) throws FileNotFoundException { + return Registry.templateRegistry().get(name); } public String executeTemplating(String content) throws IOException { @@ -92,8 +75,8 @@ public String executeTemplating(String content) throws IOException { String expandedRest = executeTemplating(rest); args.put(followsKey, expandedRemapNull(expandedRest)); // build template and finish (rest is consumed by this template) - String template = getTemplate(templateName); - String filled = fillTemplate(template, args); + HTMLTemplate template = getTemplate(templateName); + String filled = template.fill(args); // expand any templates produced by the filled template String finalFilled = executeTemplating(filled); out.append(finalFilled); @@ -102,8 +85,8 @@ public String executeTemplating(String content) throws IOException { break; } else { // normal case: build template now, then continue after marker - String template = getTemplate(templateName); - String filled = fillTemplate(template, args); + HTMLTemplate template = getTemplate(templateName); + String filled = template.fill(args); // expand templates that might be present inside the filled template String finalFilled = executeTemplating(filled); out.append(finalFilled); @@ -114,23 +97,6 @@ public String executeTemplating(String content) throws IOException { return out.toString(); } - // replace %{key} with args.getOrDefault(key, "") - private static String fillTemplate(String template, Map args) { - if (template == null || template.isEmpty()) return ""; - Pattern p = Pattern.compile("%\\{([^}]+)\\}"); - Matcher m = p.matcher(template); - StringBuffer sb = new StringBuffer(); - while (m.find()) { - String key = m.group(1); - String val = args.getOrDefault(key, ""); - // escape backslashes and dollars for regex replacement - val = val.replace("\\", "\\\\").replace("$", "\\$"); - m.appendReplacement(sb, val); - } - m.appendTail(sb); - return sb.toString(); - } - // helper in case expanded rest is null private static String expandedRemapNull(String s) { return s == null ? "" : s; diff --git a/src/main/java/de/igslandstuhl/database/client/navigation/HTMLNavigationTemplate.java b/src/main/java/de/igslandstuhl/database/client/navigation/HTMLNavigationTemplate.java new file mode 100644 index 0000000..ccb8956 --- /dev/null +++ b/src/main/java/de/igslandstuhl/database/client/navigation/HTMLNavigationTemplate.java @@ -0,0 +1,14 @@ +package de.igslandstuhl.database.client.navigation; + +import java.util.Map; + +import de.igslandstuhl.database.Registry; +import de.igslandstuhl.database.client.HTMLTemplate; + +public record HTMLNavigationTemplate(NavigationAppearance appearance, NavigationType type) implements HTMLTemplate { + @Override + public String fill(Map args) { + // args are not being used by this template + return appearance().translateToHTML(Registry.navigationRegistry().stream(type()).toList()); + } +} diff --git a/src/main/java/de/igslandstuhl/database/client/navigation/NavigationAppearance.java b/src/main/java/de/igslandstuhl/database/client/navigation/NavigationAppearance.java new file mode 100644 index 0000000..9f51181 --- /dev/null +++ b/src/main/java/de/igslandstuhl/database/client/navigation/NavigationAppearance.java @@ -0,0 +1,37 @@ +package de.igslandstuhl.database.client.navigation; + +import java.util.List; +import java.util.function.Function; + +public enum NavigationAppearance { + LIST_APPEARANCE ((l) -> { + StringBuilder builder = new StringBuilder(""); + return builder.toString(); + }), + BUTTON_APPEARANCE ((l) -> { + StringBuilder builder = new StringBuilder(); + l.forEach((e) -> { + builder.append("") + .append(""); + }); + return builder.toString(); + }); + private final Function,String> translator; + private NavigationAppearance(Function,String> translator) { + this.translator = translator; + } + public String translateToHTML(List navigationElements) { + return translator.apply(navigationElements); + } +} diff --git a/src/main/java/de/igslandstuhl/database/client/navigation/NavigationElement.java b/src/main/java/de/igslandstuhl/database/client/navigation/NavigationElement.java new file mode 100644 index 0000000..3090a5d --- /dev/null +++ b/src/main/java/de/igslandstuhl/database/client/navigation/NavigationElement.java @@ -0,0 +1,21 @@ +package de.igslandstuhl.database.client.navigation; + +import java.util.List; +import java.util.Map; + +import com.google.gson.reflect.TypeToken; + +import de.igslandstuhl.database.Registry; +import de.igslandstuhl.database.server.Server; +import de.igslandstuhl.database.server.resources.ResourceLocation; + +public record NavigationElement(String path, String label) { + private static final ResourceLocation meta = new ResourceLocation("meta", "navigation", "navigation_elements.json"); + + public static void registerAll() { + List> elements = Server.getInstance().getResourceManager().readJsonListMerged(meta, new TypeToken>>() {}); + elements.forEach((e) -> { + Registry.navigationRegistry().register(NavigationType.valueOf(e.get("type")), new NavigationElement(e.get("path"), e.get("label"))); + }); + } +} diff --git a/src/main/java/de/igslandstuhl/database/client/navigation/NavigationType.java b/src/main/java/de/igslandstuhl/database/client/navigation/NavigationType.java new file mode 100644 index 0000000..6d20e3a --- /dev/null +++ b/src/main/java/de/igslandstuhl/database/client/navigation/NavigationType.java @@ -0,0 +1,44 @@ +package de.igslandstuhl.database.client.navigation; + +import java.lang.reflect.InvocationTargetException; +import java.util.List; + +import de.igslandstuhl.database.Registry; +import de.igslandstuhl.database.server.resources.ResourceLocation; +import de.igslandstuhl.database.utils.RegistryEnum; + +public class NavigationType extends RegistryEnum { + protected NavigationType(Registry registry, String key) { + super(registry, key); + } + + private static final ResourceLocation meta = new ResourceLocation("meta", "navigation", "navigation_types.json"); + @Override + protected NavigationType[] values(Registry registry) { + List navigationTypes = registry.stream().toList(); + NavigationType[] arr = new NavigationType[navigationTypes.size()]; + return navigationTypes.toArray(arr); + } + + @Override + protected void initValues() { + initUsingJSONMeta(meta); + } + + @Override + protected NavigationType initValue(Registry registry,String key) { + return new NavigationType(registry, key); + } + + public static NavigationType valueOf(String string) { + return RegistryEnum.valueOf(string, NavigationType.class); + } + public static void init() { + try { + RegistryEnum.init(NavigationType.class); + } catch (InstantiationException | IllegalAccessException | IllegalArgumentException | InvocationTargetException + | NoSuchMethodException | SecurityException e) { + throw new ExceptionInInitializerError(e); + } + } +} diff --git a/src/main/java/de/igslandstuhl/database/plugins/Plugin.java b/src/main/java/de/igslandstuhl/database/plugins/Plugin.java new file mode 100644 index 0000000..08c8317 --- /dev/null +++ b/src/main/java/de/igslandstuhl/database/plugins/Plugin.java @@ -0,0 +1,119 @@ +package de.igslandstuhl.database.plugins; + +import java.util.List; + +import de.igslandstuhl.database.plugins.config.PluginConfig; +import de.igslandstuhl.database.plugins.config.PluginSetting; + +public abstract class Plugin { + private String id; + private String name; + private String description; + + private boolean enabled; + private boolean initialized = false; + + public Plugin() { + this.enabled = false; + } + + void init(String id, String name, String description) { + if (initialized) throw new IllegalStateException("Plugin already initialized"); + this.id = id; + this.name = name; + this.description = description; + this.initialized = true; + } + void init(PluginDescription description) { + init(description.id(), description.name(), description.description()); + } + + public String getId() { + return id; + } + public String getName() { + return name; + } + public String getDescription() { + return description; + } + public boolean isEnabled() { + return enabled; + } + + public String toJSON() { + StringBuilder sb = new StringBuilder(); + sb.append("{"); + sb.append("\"id\":\"").append(id).append("\","); + sb.append("\"name\":\"").append(name).append("\","); + sb.append("\"description\":\"").append(description).append("\","); + sb.append("\"enabled\":").append(enabled).append(","); + sb.append("\"config\":").append(getConfig().toJSON()); + sb.append("}"); + return sb.toString(); + } + + /** + * Returns the config of this plugin. + * This should never be null, as it will break the plugin lifecycle otherwise. + * @return the config + */ + public abstract PluginConfig getConfig(); + + protected abstract void onEnable(); + protected abstract void onDisable(); + protected abstract void onLoad(); + + public void enable() { + if (enabled) return; + onEnable(); + enabled = true; + } + public void disable() { + if (!enabled) return; + onDisable(); + enabled = false; + } + public void toggle() { + if (enabled) { + disable(); + } else { + enable(); + } + getConfig().save(); + } + void load() { + onLoad(); + } + + static class DummyModule extends Plugin { + private final PluginConfig config; + public DummyModule(String id, String name, String description, List> settings) { + init(id, name, description); + config = new PluginConfig(this, settings) { + + }; + } + + @Override + public PluginConfig getConfig() { + return config; + } + + @Override + protected void onEnable() { + // Dummy enable logic + } + + @Override + protected void onDisable() { + // Dummy disable logic + } + + @Override + protected void onLoad() { + // Dummy load logic + } + } + +} diff --git a/src/main/java/de/igslandstuhl/database/plugins/PluginDescription.java b/src/main/java/de/igslandstuhl/database/plugins/PluginDescription.java new file mode 100644 index 0000000..1a2d7eb --- /dev/null +++ b/src/main/java/de/igslandstuhl/database/plugins/PluginDescription.java @@ -0,0 +1,7 @@ +package de.igslandstuhl.database.plugins; + +import java.util.List; + +public record PluginDescription(String id, String name, String description, String main, List depends) { + +} diff --git a/src/main/java/de/igslandstuhl/database/plugins/PluginLoader.java b/src/main/java/de/igslandstuhl/database/plugins/PluginLoader.java new file mode 100644 index 0000000..a87e3e0 --- /dev/null +++ b/src/main/java/de/igslandstuhl/database/plugins/PluginLoader.java @@ -0,0 +1,185 @@ +package de.igslandstuhl.database.plugins; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.net.MalformedURLException; +import java.net.URL; +import java.net.URLClassLoader; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.yaml.snakeyaml.Yaml; + +import de.igslandstuhl.database.Registry; +import de.igslandstuhl.database.plugins.config.BoolSetting; + +public class PluginLoader { + private final List pluginInfos = new ArrayList<>(); + public List getPluginInfos() { + return pluginInfos; + } + private static final PluginLoader INSTANCE = new PluginLoader(); + public static PluginLoader getInstance() { + return INSTANCE; + } + private PluginLoader() {} + + private Map loadYaml(URLClassLoader classLoader) { + try (InputStream is = classLoader.getResourceAsStream("plugin.yml")) { + if (is == null) return null; + + Yaml yaml = new Yaml(); + return yaml.load(is); + } catch (Exception e) { + e.printStackTrace(); + return null; + } + } + public PreLoadedPlugin loadPluginFromJar(File jarFile) { + URLClassLoader classLoader; + try { + classLoader = new URLClassLoader( + new URL[]{jarFile.toURI().toURL()}, + getClass().getClassLoader() + ); + } catch (MalformedURLException e) { + e.printStackTrace(); + return null; + } + try { + + Map yaml = loadYaml(classLoader); + if (yaml == null) { + System.err.println("No plugin.yml found in " + jarFile.getName()); + classLoader.close(); + return null; + } + + String mainClassName = (String) yaml.get("main"); + String id = (String) yaml.get("id"); + if (id == null || mainClassName == null) { + throw new IllegalStateException("Invalid plugin.yml in " + jarFile.getName() + ": you must define id and main"); + } + String name = (String) yaml.getOrDefault("name", id); + String description = (String) yaml.getOrDefault("description", ""); + + Object dependsObj = yaml.get("depends"); + List depends = new ArrayList<>(); + + if (dependsObj instanceof List) { + for (Object o : (List) dependsObj) { + if (o instanceof String s) { + depends.add(s); + } + } + } + + Class clazz = classLoader.loadClass(mainClassName); + + if (!Plugin.class.isAssignableFrom(clazz)) { + throw new IllegalStateException("Main class does not extend Plugin"); + } + + return new PreLoadedPlugin(new PluginDescription(id, name, description, mainClassName, depends), clazz, classLoader); + + } catch (Exception e) { + e.printStackTrace(); + try { + classLoader.close(); + } catch (IOException e1) { + System.out.println("FAILED to close class loader"); + e1.printStackTrace(); + } + return null; + } + } + public void load(PreLoadedPlugin preload) { + Plugin plugin; + try { + plugin = (Plugin) preload.clazz().getDeclaredConstructor().newInstance(); + plugin.init(preload.description()); + registerPlugin(plugin); + plugin.load(); + if (plugin.getConfig() == null) { + throw new NullPointerException("Plugin must have a config"); + } + } catch (Exception e) { + System.err.println("Failed to load plugin: " + preload.description().id()); + pluginInfos.remove(preload); + if (Registry.pluginRegistry().get(preload.description().id()) != null) Registry.pluginRegistry().unregister(preload.description().id()); + e.printStackTrace(); + } + } + public void loadAllPlugins(File folder) { + File[] jars = folder.listFiles((dir, name) -> name.endsWith(".jar")); + if (jars == null) return; + + List plugins = new ArrayList<>(); + + for (File jar : jars) { + PreLoadedPlugin m = loadPluginFromJar(jar); + if (m != null) { + plugins.add(m); + } + } + + // Check for duplicate ids + Set ids = new HashSet<>(); + for (PreLoadedPlugin p : plugins) { + if (!ids.add(p.description().id())) { + throw new IllegalStateException("Duplicate module id: " + p.description().id()); + } + } + + PluginSort.sortPlugins(plugins).forEach((p) -> pluginInfos.add(p)); + pluginInfos.forEach(this::load); + } + public void enablePlugins() { + pluginInfos.forEach((p) -> { + Plugin plugin = Registry.pluginRegistry().get(p.description().id()); + if (plugin.getConfig().isEnabledOnStart()) { + plugin.enable(); + } + }); + } + public void unloadPlugins() { + Collections.reverse(pluginInfos); + pluginInfos.forEach((p) -> { + Plugin plugin = Registry.pluginRegistry().get(p.description().id()); + plugin.getConfig().save(); + if (plugin != null && plugin.isEnabled()) { + plugin.disable(); + } + Registry.pluginRegistry().unregister(plugin.getId()); + + try { + p.classLoader().close(); + } catch (IOException e) { + throw new RuntimeException("Problem while unloading", e); + } + }); + pluginInfos.clear(); + } + + + + private void registerPlugin(Plugin plugin) { + if (Registry.pluginRegistry().keyStream().anyMatch(plugin.getId()::equals)) { + throw new IllegalStateException("Duplicate module id: " + plugin.getId()); + } + Registry.pluginRegistry().register(plugin.getId(), plugin); + } + public void registerPlugins() { + registerPlugin(new Plugin.DummyModule("result_view", "Student Results View", "The view displaying the student's current progress and prognoses for the final result", List.of( + new BoolSetting("show_prognosis", "Show Prognosis", "Whether to display the prognosis for the final result", true), + new BoolSetting("show_current_progress", "Show Current", "Whether to display the current progress to the subject (in percent)", true), + new BoolSetting("show_current_grade", "Show Currently Achieved Grade", "Whether to display the grade the student would achieve when they decide to immediately stop working", false) + ))); + loadAllPlugins(new File("plugins")); + } +} diff --git a/src/main/java/de/igslandstuhl/database/plugins/PluginResourceProvider.java b/src/main/java/de/igslandstuhl/database/plugins/PluginResourceProvider.java new file mode 100644 index 0000000..7b6c2b6 --- /dev/null +++ b/src/main/java/de/igslandstuhl/database/plugins/PluginResourceProvider.java @@ -0,0 +1,86 @@ +package de.igslandstuhl.database.plugins; + +import java.io.File; +import java.io.InputStream; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Enumeration; +import java.util.List; +import java.util.regex.Pattern; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; + +import de.igslandstuhl.database.server.resources.CoreResourceProvider; +import de.igslandstuhl.database.server.resources.ResourceLocation; +import de.igslandstuhl.database.server.resources.ResourceProvider; + +public class PluginResourceProvider implements ResourceProvider { + + @Override + public InputStream open(ResourceLocation location) { + String path = location.context() + "/" + location.namespace() + "/" + location.resource(); + + for (PreLoadedPlugin module : PluginLoader.getInstance().getPluginInfos()) { + ClassLoader cl = module.classLoader(); + InputStream stream = cl.getResourceAsStream(path); + + if (stream != null) { + return stream; + } + } + + return null; + } + + @Override + public List openAll(ResourceLocation location) { + String path = location.context() + "/" + location.namespace() + "/" + location.resource(); + List inputStreams = new ArrayList<>(); + + for (PreLoadedPlugin module : PluginLoader.getInstance().getPluginInfos()) { + ClassLoader cl = module.classLoader(); + InputStream stream = cl.getResourceAsStream(path); + + if (stream != null) { + inputStreams.add(stream); + } + } + + return inputStreams; + } + + @Override + public Collection list(Pattern pattern) { + List result = new ArrayList<>(); + // Virtual root – no real filesystem access needed + final Path virtualRoot = Paths.get("").toAbsolutePath().normalize(); + + for (PreLoadedPlugin module : PluginLoader.getInstance().getPluginInfos()) { + try (ZipFile zip = new ZipFile(new File(module.classLoader().getURLs()[0].toURI()))) { + + Enumeration entries = zip.entries(); + + while (entries.hasMoreElements()) { + ZipEntry entry = entries.nextElement(); + + if (entry.isDirectory()) continue; + + String name = entry.getName(); + + if (!CoreResourceProvider.isSafeZipEntryName(name, virtualRoot)) + if (pattern.matcher(name).matches()) { + ResourceLocation loc = ResourceLocation.fromPath(name); + if (loc != null) result.add(loc); + } + } + + } catch (Exception e) { + e.printStackTrace(); + } + } + + return result; + } +} diff --git a/src/main/java/de/igslandstuhl/database/plugins/PluginSort.java b/src/main/java/de/igslandstuhl/database/plugins/PluginSort.java new file mode 100644 index 0000000..05f59d4 --- /dev/null +++ b/src/main/java/de/igslandstuhl/database/plugins/PluginSort.java @@ -0,0 +1,57 @@ +package de.igslandstuhl.database.plugins; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +public class PluginSort { + private PluginSort() {} + private static void visit( + PreLoadedPlugin plugin, + Map map, + List sorted, + Set visited, + Set visiting + ) { + String id = plugin.description().id(); + + if (visited.contains(id)) return; + + if (visiting.contains(id)) { + throw new IllegalStateException("Circular dependency detected: " + id); + } + + visiting.add(id); + + for (String dep : plugin.description().depends()) { + PreLoadedPlugin dependency = map.get(dep); + if (dependency == null) { + throw new IllegalStateException("Missing dependency: " + dep + " for " + id); + } + visit(dependency, map, sorted, visited, visiting); + } + + visiting.remove(id); + visited.add(id); + sorted.add(plugin); + } + public static List sortPlugins(List plugins) { + Map map = new HashMap<>(); + for (PreLoadedPlugin m : plugins) { + map.put(m.description().id(), m); + } + + List sorted = new ArrayList<>(); + Set visited = new HashSet<>(); + Set visiting = new HashSet<>(); + + for (PreLoadedPlugin m : plugins) { + visit(m, map, sorted, visited, visiting); + } + + return sorted; + } +} diff --git a/src/main/java/de/igslandstuhl/database/plugins/PreLoadedPlugin.java b/src/main/java/de/igslandstuhl/database/plugins/PreLoadedPlugin.java new file mode 100644 index 0000000..0f794a0 --- /dev/null +++ b/src/main/java/de/igslandstuhl/database/plugins/PreLoadedPlugin.java @@ -0,0 +1,11 @@ +package de.igslandstuhl.database.plugins; + +import java.net.URLClassLoader; + +record PreLoadedPlugin ( + PluginDescription description, + Class clazz, + URLClassLoader classLoader +) { + +} \ No newline at end of file diff --git a/src/main/java/de/igslandstuhl/database/api/modules/BoolSetting.java b/src/main/java/de/igslandstuhl/database/plugins/config/BoolSetting.java similarity index 87% rename from src/main/java/de/igslandstuhl/database/api/modules/BoolSetting.java rename to src/main/java/de/igslandstuhl/database/plugins/config/BoolSetting.java index 556ec3e..1e368ff 100644 --- a/src/main/java/de/igslandstuhl/database/api/modules/BoolSetting.java +++ b/src/main/java/de/igslandstuhl/database/plugins/config/BoolSetting.java @@ -1,6 +1,6 @@ -package de.igslandstuhl.database.api.modules; +package de.igslandstuhl.database.plugins.config; -public class BoolSetting extends ModuleSetting { +public class BoolSetting extends PluginSetting { public BoolSetting(String key, String name, String description, boolean defaultValue) { super(key, name, description, defaultValue); } diff --git a/src/main/java/de/igslandstuhl/database/plugins/config/PluginConfig.java b/src/main/java/de/igslandstuhl/database/plugins/config/PluginConfig.java new file mode 100644 index 0000000..61c88c3 --- /dev/null +++ b/src/main/java/de/igslandstuhl/database/plugins/config/PluginConfig.java @@ -0,0 +1,134 @@ +package de.igslandstuhl.database.plugins.config; + +import java.io.File; +import java.io.FileReader; +import java.io.FileWriter; +import java.io.IOException; +import java.util.Arrays; +import java.util.List; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonObject; + +import de.igslandstuhl.database.plugins.Plugin; + +public abstract class PluginConfig { + private final T plugin; + private final BoolSetting[] boolSettings; + + private final File configFile; + private boolean enabledOnStart; + + public PluginConfig(T plugin, PluginSetting... moduleSettings) { + this.plugin = plugin; + List boolSettings = Arrays.stream(moduleSettings).filter((s) -> s instanceof BoolSetting).map((s) -> (BoolSetting) s).toList(); + this.boolSettings = boolSettings.toArray(new BoolSetting[boolSettings.size()]); + this.configFile = new File("plugins/config", plugin.getId() + ".json"); + load(); + } + + public PluginConfig(T plugin, List> moduleSettings) { + this.plugin = plugin; + List boolSettings = moduleSettings.stream().filter((s) -> s instanceof BoolSetting).map((s) -> (BoolSetting) s).toList(); + this.boolSettings = boolSettings.toArray(new BoolSetting[boolSettings.size()]); + this.configFile = new File("plugins/config", plugin.getId() + ".json"); + load(); + } + + public T getPlugin() { + return plugin; + } + public boolean isEnabledOnStart() { + return enabledOnStart; + } + + private BoolSetting findBoolSetting(String key) { + return Arrays.stream(boolSettings) + .filter((s) -> s.getKey().equals(key)) + .findAny().orElseThrow(); + } + + public boolean getBool(String key) { + return findBoolSetting(key).getValue(); + } + + public void setBool(String key, boolean value) { + findBoolSetting(key).setValue(value); + save(); + } + public void enableSetting(String key) { + findBoolSetting(key).enable(); + save(); + } + public void disableSetting(String key) { + findBoolSetting(key).disable(); + save(); + } + public void toggleSetting(String key) { + findBoolSetting(key).toggle(); + save(); + } + + private JsonObject valuesJSON() { + JsonObject values = new JsonObject(); + for (BoolSetting s : boolSettings) { + values.addProperty(s.getKey(), s.getValue()); + } + return values; + } + public String toJSON() { + StringBuilder builder = new StringBuilder("{"); + builder.append("\"settings\": {") + .append("\"bools\": ["); + for (int i = 0; i < boolSettings.length; i++) { + builder.append(boolSettings[i].toJSON()); + if (i < boolSettings.length - 1) { + builder.append(", "); + } + } + builder + .append("]}, ") + .append("\"values\": ") + .append((new Gson()).toJson(valuesJSON())) + .append("}"); + return builder.toString(); + } + + // Persistence + public void save() { + if (configFile.getParentFile() != null && !configFile.getParentFile().exists()) { + configFile.getParentFile().mkdirs(); + } + try (FileWriter writer = new FileWriter(configFile)) { + JsonObject root = new JsonObject(); + root.add("values", valuesJSON()); + root.addProperty("enabled", plugin.isEnabled()); + + Gson gson = new GsonBuilder().setPrettyPrinting().create(); + gson.toJson(root, writer); + } catch (IOException e) { + e.printStackTrace(); + } + } + public void load() { + if (!configFile.exists()) return; + try (FileReader reader = new FileReader(configFile)) { + Gson gson = new Gson(); + JsonObject root = gson.fromJson(reader, JsonObject.class); + + JsonObject values = root.getAsJsonObject("values"); + if (values != null) { + for (BoolSetting s : boolSettings) { + if (values.has(s.getKey())) { + s.setValue(values.get(s.getKey()).getAsBoolean()); + } + } + } + enabledOnStart = root.get("enabled").getAsBoolean(); + + } catch (IOException e) { + e.printStackTrace(); + } + } +} \ No newline at end of file diff --git a/src/main/java/de/igslandstuhl/database/api/modules/ModuleSetting.java b/src/main/java/de/igslandstuhl/database/plugins/config/PluginSetting.java similarity index 86% rename from src/main/java/de/igslandstuhl/database/api/modules/ModuleSetting.java rename to src/main/java/de/igslandstuhl/database/plugins/config/PluginSetting.java index 59df0d9..0d632fa 100644 --- a/src/main/java/de/igslandstuhl/database/api/modules/ModuleSetting.java +++ b/src/main/java/de/igslandstuhl/database/plugins/config/PluginSetting.java @@ -1,13 +1,13 @@ -package de.igslandstuhl.database.api.modules; +package de.igslandstuhl.database.plugins.config; -public class ModuleSetting { +public class PluginSetting { private final String key; private final String name; private final String description; private final T defaultValue; private T value; - public ModuleSetting(String key, String name, String description, T defaultValue) { + PluginSetting(String key, String name, String description, T defaultValue) { this.key = key; this.name = name; this.description = description; diff --git a/src/main/java/de/igslandstuhl/database/server/resources/CoreResourceProvider.java b/src/main/java/de/igslandstuhl/database/server/resources/CoreResourceProvider.java new file mode 100644 index 0000000..1c67d53 --- /dev/null +++ b/src/main/java/de/igslandstuhl/database/server/resources/CoreResourceProvider.java @@ -0,0 +1,127 @@ +package de.igslandstuhl.database.server.resources; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.NoSuchFileException; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Enumeration; +import java.util.List; +import java.util.regex.Pattern; +import java.util.zip.ZipEntry; +import java.util.zip.ZipException; +import java.util.zip.ZipFile; + +public class CoreResourceProvider implements ResourceProvider { + /** + * Checks if a zip entry name is safe (to prevent zip slipping). + */ + public static boolean isSafeZipEntryName(String entryName, Path rootDir) { + // Resolve entry against a fixed root and normalize + Path resolvedPath = rootDir.resolve(entryName).normalize(); + + // Entry is safe if it stays within the root directory + return resolvedPath.startsWith(rootDir); + } + @Override + public InputStream open(ResourceLocation location) { + String path = "/" + location.context() + "/" + location.namespace() + "/" + location.resource(); + return getClass().getResourceAsStream(path); + } + + @Override + public Collection list(Pattern pattern) { + List result = new ArrayList<>(); + + String classPath = System.getProperty("java.class.path", "."); + String[] elements = classPath.split(System.getProperty("path.separator")); + + for (String element : elements) { + Path path = Path.of(element); + + if (Files.isDirectory(path)) { + result.addAll(getResourcesFromDirectory(path, pattern, path)); + } else { + result.addAll(getResourcesFromJarFile(path, pattern)); + } + } + + return result; + } + + /** + * Get all resources from a jar file or a directory that match the given pattern. + * + * @param jarFilePath the jar file or directory to search in + * @param pattern the pattern to match + * @return the resources in the order they are found + */ + private Collection getResourcesFromJarFile(final Path jarFilePath, final Pattern pattern) { + final ArrayList retval = new ArrayList<>(); + // Virtual root – no real filesystem access needed + final Path virtualRoot = Paths.get("").toAbsolutePath().normalize(); + ZipFile zf; + try { + zf = new ZipFile(jarFilePath.toFile()); + } catch (final ZipException e) { + throw new Error(e); + } catch (final NoSuchFileException e) { + return Collections.emptySet(); + } catch (final IOException e) { + throw new Error(e); + } + final Enumeration e = zf.entries(); + while (e.hasMoreElements()) { + final ZipEntry ze = e.nextElement(); + final String fileName = ze.getName(); + if (!isSafeZipEntryName(fileName, virtualRoot)) { + // Optionally log or throw, here we skip unsafe entries + continue; + } + final boolean accept = pattern.matcher(fileName).matches(); + if (accept) { + ResourceLocation location = ResourceLocation.fromPath(fileName); + if (location != null) retval.add(location); + } + } + try { + zf.close(); + } catch (final IOException e1) { + throw new Error(e1); + } + return retval; + } + + /** + * Get all resources from a directory that match the given pattern. + * + * @param directory the directory to search in + * @param pattern the pattern to match + * @return the resources in the order they are found + */ + private Collection getResourcesFromDirectory(final Path directory, final Pattern pattern, final Path toplevelPath) { + final ArrayList retval = new ArrayList<>(); + try { + Files.list(directory).forEach((path) -> { + if (Files.isDirectory(path)) { + retval.addAll(getResourcesFromDirectory(path, pattern, toplevelPath)); + } else { + final Path relativePath = toplevelPath.relativize(path); + final boolean accept = pattern.matcher(relativePath.toString()).matches(); + if (accept) { + ResourceLocation location = ResourceLocation.fromPath(relativePath); + if (location != null) retval.add(location); + } + } + }); + } catch (IOException e) { + e.printStackTrace(); + return retval; + } + return retval; + } +} diff --git a/src/main/java/de/igslandstuhl/database/server/resources/FileResourceProvider.java b/src/main/java/de/igslandstuhl/database/server/resources/FileResourceProvider.java new file mode 100644 index 0000000..7c2f9c5 --- /dev/null +++ b/src/main/java/de/igslandstuhl/database/server/resources/FileResourceProvider.java @@ -0,0 +1,66 @@ +package de.igslandstuhl.database.server.resources; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.regex.Pattern; + +public class FileResourceProvider implements ResourceProvider { + + private final Path root; + + public FileResourceProvider(Path root) { + this.root = root; + } + + @Override + public InputStream open(ResourceLocation location) { + try { + Path file = root + .resolve(location.context()) + .resolve(location.namespace()) + .resolve(location.resource()) + .normalize(); + + // 🔐 Security: Path Traversal verhindern + if (!file.startsWith(root)) { + return null; + } + + if (!Files.exists(file) || Files.isDirectory(file)) { + return null; + } + + return Files.newInputStream(file); + } catch (IOException e) { + return null; + } + } + + @Override + public Collection list(Pattern pattern) { + List result = new ArrayList<>(); + + try { + Files.walk(root).forEach(path -> { + if (Files.isRegularFile(path)) { + Path relative = root.relativize(path); + String normalized = relative.toString().replace("\\", "/"); + + if (pattern.matcher(normalized).matches()) { + ResourceLocation loc = ResourceLocation.fromPath(normalized); + if (loc != null) result.add(loc); + } + } + }); + } catch (IOException e) { + e.printStackTrace(); + } + + return result; + } +} \ No newline at end of file diff --git a/src/main/java/de/igslandstuhl/database/server/resources/MergeHelper.java b/src/main/java/de/igslandstuhl/database/server/resources/MergeHelper.java new file mode 100644 index 0000000..ced9017 --- /dev/null +++ b/src/main/java/de/igslandstuhl/database/server/resources/MergeHelper.java @@ -0,0 +1,82 @@ +package de.igslandstuhl.database.server.resources; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; + +public class MergeHelper { + public static Map readJsonObjectMerged(ResourceManager manager, ResourceLocation location) { + Gson gson = new Gson(); + + return manager.mergeResources(location, + // merger + (a, b) -> { + a.putAll(b); + return a; + }, + //start supplier + () -> (Map)new LinkedHashMap(), + // parser + (is) -> { + try (BufferedReader reader = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8))) { + return gson.fromJson(reader, new TypeToken>(){}.getType()); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + ); + } + public static List readJsonListMerged(ResourceManager manager, ResourceLocation location, TypeToken> listType) { + Gson gson = new Gson(); + + return manager.mergeResources( + location, + + // merger + (a, b) -> { + a.addAll(b); + return a; + }, + + // supplier + ArrayList::new, + + // parser + (is) -> { + try (BufferedReader reader = new BufferedReader(new InputStreamReader(is))) { + return gson.fromJson(reader, listType.getType()); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + ); + } + public static String readMergedCode(ResourceManager manager, ResourceLocation location) { + return manager.mergeResources( + location, + + // merger + (a, b) -> b + "\n" + a, // concat backwards + + // supplier + () -> "", + + // parser + (is) -> { + try (BufferedReader reader = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8))) { + return reader.lines().reduce("", (acc, line) -> acc + line + "\n"); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + ); + } +} diff --git a/src/main/java/de/igslandstuhl/database/server/resources/ResourceLocation.java b/src/main/java/de/igslandstuhl/database/server/resources/ResourceLocation.java index 6c93f1b..b1f7ca8 100644 --- a/src/main/java/de/igslandstuhl/database/server/resources/ResourceLocation.java +++ b/src/main/java/de/igslandstuhl/database/server/resources/ResourceLocation.java @@ -51,7 +51,19 @@ public static ResourceLocation fromPath(String path) { return fromPath(Path.of(path)); } public static ResourceLocation fromRelativePath(String relativePath) { - String[] parts = relativePath.split(Matcher.quoteReplacement(File.separator)); + if (relativePath == null || relativePath.isEmpty()) { + return null; + } + // Normalize the path to eliminate any "." or ".." segments + Path normalized = Path.of(relativePath).normalize(); + String normalizedStr = normalized.toString(); + // Reject paths that still contain traversal segments after normalization + if (normalizedStr.contains(".." + File.separator) || + normalizedStr.contains(File.separator + "..") || + normalizedStr.equals("..")) { + return null; + } + String[] parts = normalizedStr.split(Matcher.quoteReplacement(File.separator)); if (parts.length != 3) { return null; } diff --git a/src/main/java/de/igslandstuhl/database/server/resources/ResourceManager.java b/src/main/java/de/igslandstuhl/database/server/resources/ResourceManager.java index 7b2501e..567495c 100644 --- a/src/main/java/de/igslandstuhl/database/server/resources/ResourceManager.java +++ b/src/main/java/de/igslandstuhl/database/server/resources/ResourceManager.java @@ -6,41 +6,39 @@ import java.io.InputStream; import java.io.InputStreamReader; import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.NoSuchFileException; import java.nio.file.Path; -import java.nio.file.Paths; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collection; -import java.util.Collections; -import java.util.Enumeration; import java.util.Iterator; import java.util.List; import java.util.Map; +import java.util.function.BiFunction; +import java.util.function.Function; +import java.util.function.Supplier; import java.util.regex.Pattern; import java.util.stream.Stream; -import java.util.zip.ZipEntry; -import java.util.zip.ZipException; -import java.util.zip.ZipFile; import com.google.gson.Gson; import com.google.gson.reflect.TypeToken; +import de.igslandstuhl.database.plugins.PluginResourceProvider; import de.igslandstuhl.database.server.Server; /** * Manages Resources in the application */ public class ResourceManager { - /** - * Checks if a zip entry name is safe (to prevent zip slipping). - */ - private boolean isSafeZipEntryName(String entryName, Path rootDir) { - // Resolve entry against a fixed root and normalize - Path resolvedPath = rootDir.resolve(entryName).normalize(); - - // Entry is safe if it stays within the root directory - return resolvedPath.startsWith(rootDir); + private final List providers; + public ResourceManager(ResourceProvider... providers) { + this.providers = Arrays.asList(providers); + } + public ResourceManager() { + this( + new FileResourceProvider(Path.of("resources")), // highest priority + new PluginResourceProvider(), + new CoreResourceProvider() + ); } /** @@ -51,104 +49,13 @@ private boolean isSafeZipEntryName(String entryName, Path rootDir) { * @return the resources in the order they are found */ public Collection getResources(final Pattern pattern) { - final ArrayList retval = new ArrayList<>(); - final String classPath = System.getProperty("java.class.path", "."); - final String[] classPathElements = classPath.split(System.getProperty("path.separator")); - for (final String element : classPathElements) { - retval.addAll(getResources(element, pattern)); - } - return retval; - } + List result = new ArrayList<>(); - /** - * for a single element of java.class.path get a Collection of resources - * Pattern pattern = Pattern.compile(".*"); gets all resources - * - * @param element the class path element to search in - * @param pattern the pattern to match - * @return the resources in the order they are found - */ - private Collection getResources(final String element, final Pattern pattern) { - final ArrayList retval = new ArrayList<>(); - final Path path = Path.of(element); - if (Files.isDirectory(path)) { - retval.addAll(getResourcesFromDirectory(path, pattern, path)); - } else { - retval.addAll(getResourcesFromJarFile(path, pattern)); + for (ResourceProvider provider : providers) { + result.addAll(provider.list(pattern)); } - return retval; - } - /** - * Get all resources from a jar file or a directory that match the given pattern. - * - * @param jarFilePath the jar file or directory to search in - * @param pattern the pattern to match - * @return the resources in the order they are found - */ - private Collection getResourcesFromJarFile(final Path jarFilePath, final Pattern pattern) { - final ArrayList retval = new ArrayList<>(); - // Virtual root – no real filesystem access needed - final Path virtualRoot = Paths.get("").toAbsolutePath().normalize(); - ZipFile zf; - try { - zf = new ZipFile(jarFilePath.toFile()); - } catch (final ZipException e) { - throw new Error(e); - } catch (final NoSuchFileException e) { - return Collections.emptySet(); - } catch (final IOException e) { - throw new Error(e); - } - final Enumeration e = zf.entries(); - while (e.hasMoreElements()) { - final ZipEntry ze = e.nextElement(); - final String fileName = ze.getName(); - if (!isSafeZipEntryName(fileName, virtualRoot)) { - // Optionally log or throw, here we skip unsafe entries - continue; - } - final boolean accept = pattern.matcher(fileName).matches(); - if (accept) { - ResourceLocation location = ResourceLocation.fromPath(fileName); - if (location != null) retval.add(location); - } - } - try { - zf.close(); - } catch (final IOException e1) { - throw new Error(e1); - } - return retval; - } - - /** - * Get all resources from a directory that match the given pattern. - * - * @param directory the directory to search in - * @param pattern the pattern to match - * @return the resources in the order they are found - */ - private Collection getResourcesFromDirectory(final Path directory, final Pattern pattern, final Path toplevelPath) { - final ArrayList retval = new ArrayList<>(); - try { - Files.list(directory).forEach((path) -> { - if (Files.isDirectory(path)) { - retval.addAll(getResourcesFromDirectory(path, pattern, toplevelPath)); - } else { - final Path relativePath = toplevelPath.relativize(path); - final boolean accept = pattern.matcher(relativePath.toString()).matches(); - if (accept) { - ResourceLocation location = ResourceLocation.fromPath(relativePath); - if (location != null) retval.add(location); - } - } - }); - } catch (IOException e) { - e.printStackTrace(); - return retval; - } - return retval; + return result; } /** @@ -162,7 +69,7 @@ public BufferedReader[] openResourcesAsReader(Pattern pattern) { List readers = new ArrayList<>(); for (ResourceLocation resource : getResources(pattern)) { try { - readers.add(new BufferedReader(new InputStreamReader(openResourceAsStream(resource)))); + readers.add(new BufferedReader(new InputStreamReader(openResourceAsStream(resource), StandardCharsets.UTF_8))); } catch (IOException e) { throw new IllegalStateException(e); } @@ -179,12 +86,28 @@ public BufferedReader[] openResourcesAsReader(Pattern pattern) { * @throws FileNotFoundException if the resource is not found */ public InputStream openResourceAsStream(ResourceLocation location) throws FileNotFoundException { - String url = "/" + location.context() + "/" + location.namespace() + "/" + location.resource(); - InputStream stream = ResourceManager.class.getResourceAsStream(url); - if (stream == null) { - throw new FileNotFoundException(url + " not found in classpath or resources."); + for (ResourceProvider provider : providers) { + InputStream stream = provider.open(location); + if (stream != null) { + return stream; + } } - return stream; + throw new FileNotFoundException(location.toString()); + } + + /** + * Opens a resource as input stream in all sources that are found + * @param location the ResourceLocation object representing the resource + * @return an list of InputStreams for the resource + */ + public List openAll(ResourceLocation location) { + List streams = new ArrayList<>(); + + for (ResourceProvider provider : providers) { + streams.addAll(provider.openAll(location)); + } + + return streams; } /** @@ -267,4 +190,31 @@ public Map readJsonResourceAsMap(ResourceLocation location) throws IOE return json; } } + public Map readJsonResourceMerged(ResourceLocation location) { + return MergeHelper.readJsonObjectMerged(this, location); + } + public List readJsonListMerged(ResourceLocation location, TypeToken> token) { + return MergeHelper.readJsonListMerged(this, location, token); + } + public String readCodeMerged(ResourceLocation location) { + return MergeHelper.readMergedCode(this, location); + } + + public T mergeResources(ResourceLocation location, BiFunction merger, Supplier start, Function parser) { + T result = start.get(); + + for (InputStream is : openAll(location)) { + try (InputStream stream = is) { + T part = parser.apply(stream); + + if (part != null) { + result = merger.apply(result, part); + } + } catch (IOException e) { + throw new RuntimeException("Failed to merge resource: " + location, e); + } + } + + return result; + } } \ No newline at end of file diff --git a/src/main/java/de/igslandstuhl/database/server/resources/ResourceProvider.java b/src/main/java/de/igslandstuhl/database/server/resources/ResourceProvider.java new file mode 100644 index 0000000..2983d0b --- /dev/null +++ b/src/main/java/de/igslandstuhl/database/server/resources/ResourceProvider.java @@ -0,0 +1,18 @@ +package de.igslandstuhl.database.server.resources; + +import java.io.InputStream; +import java.util.Collection; +import java.util.List; +import java.util.regex.Pattern; + +public interface ResourceProvider { + InputStream open(ResourceLocation location); + + default List openAll(ResourceLocation location) { + InputStream stream = open(location); + if (stream == null) return List.of(); + else return List.of(stream); + } + + Collection list(Pattern pattern); +} diff --git a/src/main/java/de/igslandstuhl/database/server/webserver/AccessManager.java b/src/main/java/de/igslandstuhl/database/server/webserver/AccessManager.java index cd92e5a..f287017 100644 --- a/src/main/java/de/igslandstuhl/database/server/webserver/AccessManager.java +++ b/src/main/java/de/igslandstuhl/database/server/webserver/AccessManager.java @@ -5,6 +5,7 @@ import java.util.List; import java.util.Map; +import de.igslandstuhl.database.Registry; import de.igslandstuhl.database.api.User; import de.igslandstuhl.database.server.Server; import de.igslandstuhl.database.server.resources.ResourceLocation; @@ -107,10 +108,21 @@ private AccessManager() { * @param user the username of the user, or null if not authenticated * @param resource the ResourceLocation representing the resource to check access for * @return true if the user has access to the resource, false otherwise + * @deprecated Use hasAccess(String user, String path) */ + @Deprecated public boolean hasAccess(String user, ResourceLocation resource) { return hasAccess(User.getUser(user), resource); } + /** + * Checks if a user has access to a specific resource. + * + * @param user the user, or null if not authenticated + * @param resource the ResourceLocation representing the resource to check access for + * @return true if the user has access to the resource, false otherwise + * @deprecated Use hasAccess(User user, String path) + */ + @Deprecated public boolean hasAccess(User user, ResourceLocation resource) { if (Arrays.asList(PUBLIC_SPACES).contains(resource.namespace()) || Arrays.asList(PUBLIC_LOCATIONS).contains(resource.resource())) { return true; @@ -128,4 +140,48 @@ public boolean hasAccess(User user, ResourceLocation resource) { return false; } } + /** + * Checks if a user has access to a specific access level + * @param user the user, can be null to indicate no user logged in + * @param accessLevel the access level + * @return true, if the user has access, otherwise false + */ + public boolean hasAccess(User user, AccessLevel accessLevel) { + if (accessLevel == AccessLevel.PUBLIC) { + return true; + } else if (user == null || user == User.ANONYMOUS) { + return false; + } else if (accessLevel == AccessLevel.NONE) { + return false; + } else if (accessLevel == AccessLevel.USER) { + return true; + } else if (accessLevel == AccessLevel.STUDENT) { + return user.isStudent(); + } else if (user.isStudent()) { + return false; + } else if (accessLevel == AccessLevel.TEACHER) { + return true; + } else { + return user.isAdmin(); // Must be AccessLevel.ADMIN + } + } + /** + * Checks if a user has access to a specific web path + * @param user the username of the user, can be null to indicate no user logged in + * @param path the web path + * @return true, if the user has access, otherwise false + */ + public boolean hasAccess(String user, String path) { + return hasAccess(User.getUser(user), path); + } + /** + * Checks if a user has access to a specific web path + * @param user user, can be null to indicate no user logged in + * @param path the web path + * @return true, if the user has access, otherwise false + */ + public boolean hasAccess(User user, String path) { + AccessLevel accessLevel = Registry.webPathRegistry().get(path).accessLevel(); + return hasAccess(user, accessLevel); + } } diff --git a/src/main/java/de/igslandstuhl/database/server/webserver/WebPath.java b/src/main/java/de/igslandstuhl/database/server/webserver/WebPath.java index 7e13e61..3c11767 100644 --- a/src/main/java/de/igslandstuhl/database/server/webserver/WebPath.java +++ b/src/main/java/de/igslandstuhl/database/server/webserver/WebPath.java @@ -16,7 +16,7 @@ public static void registerPath(String path, RequestType type, String handlerTyp public static void registerPaths() throws IOException { if (Registry.webPathRegistry().stream().count() > 0) return; // already registered ResourceLocation metaLocation = new ResourceLocation("meta", "paths", "get_paths.json"); - Map pathData = Server.getInstance().getResourceManager().readJsonResourceAsMap(metaLocation); + Map pathData = Server.getInstance().getResourceManager().readJsonResourceMerged(metaLocation); pathData.keySet().forEach((path) -> { @SuppressWarnings("unchecked") Map pathInfo = (Map) pathData.get(path); diff --git a/src/main/java/de/igslandstuhl/database/server/webserver/handlers/GetRequestHandler.java b/src/main/java/de/igslandstuhl/database/server/webserver/handlers/GetRequestHandler.java index f85f759..fc35cab 100644 --- a/src/main/java/de/igslandstuhl/database/server/webserver/handlers/GetRequestHandler.java +++ b/src/main/java/de/igslandstuhl/database/server/webserver/handlers/GetRequestHandler.java @@ -3,8 +3,10 @@ import java.util.List; import de.igslandstuhl.database.Registry; +import de.igslandstuhl.database.api.User; import de.igslandstuhl.database.server.Server; import de.igslandstuhl.database.server.webserver.WebPath; +import de.igslandstuhl.database.server.webserver.handlers.get.PluginRequestHandler; import de.igslandstuhl.database.server.webserver.requests.GetRequest; import de.igslandstuhl.database.server.webserver.requests.RequestType; import de.igslandstuhl.database.server.webserver.responses.GetResponse; @@ -33,21 +35,30 @@ public final HttpResponse handleRequest(GetRequest request) { String path = request.getPath(); HttpHandler handler = Registry.getRequestHandlerRegistry().get(path); + if (handler == null) return GetResponse.notFound(request); return handler.handleHttpRequest(request); } + + private static User getUser(GetRequest request) { + return Server.getInstance().getWebServer().getSessionManager().getSessionUser(request); + } public static GetResponse handleFileRequest(GetRequest request) { - String user = Server.getInstance().getWebServer().getSessionManager().getSessionUser(request).getUsername(); + String user = getUser(request).getUsername(); return GetResponse.getResource(request, request.toResourceLocation(user), user, false); } public static GetResponse handleTemplatingFileRequest(GetRequest request) { - String user = Server.getInstance().getWebServer().getSessionManager().getSessionUser(request).getUsername(); + String user = getUser(request).getUsername(); return GetResponse.getResource(request, request.toResourceLocation(user), user, true); } public static GetResponse handleSQLRequest(GetRequest request) { - String user = Server.getInstance().getWebServer().getSessionManager().getSessionUser(request).getUsername(); + String user = getUser(request).getUsername(); return GetResponse.getResource(request, request.toResourceLocation(user), user, false); } + public static GetResponse handlePluginRequest(GetRequest request) { + User user = getUser(request); + return PluginRequestHandler.handleRequest(user, request); + } public final void registerHandlers() { if (Registry.getRequestHandlerRegistry().stream().count() > 0) return; // already registered @@ -58,6 +69,7 @@ public final void registerHandlers() { case "FileRequestHandler" -> GetRequestHandler::handleFileRequest; case "TemplatingFileRequestHandler" -> GetRequestHandler::handleTemplatingFileRequest; case "SQLRequestHandler" -> GetRequestHandler::handleSQLRequest; + case "PluginRequestHandler" -> GetRequestHandler::handlePluginRequest; default -> throw new IllegalArgumentException("Unknown handler type: " + webPath.handlerType()); }; HttpHandler.registerGetRequestHandler(path, webPath.accessLevel(), handlerFunction); diff --git a/src/main/java/de/igslandstuhl/database/server/webserver/handlers/PostRequestHandler.java b/src/main/java/de/igslandstuhl/database/server/webserver/handlers/PostRequestHandler.java index 47d5d65..6b8d4e3 100644 --- a/src/main/java/de/igslandstuhl/database/server/webserver/handlers/PostRequestHandler.java +++ b/src/main/java/de/igslandstuhl/database/server/webserver/handlers/PostRequestHandler.java @@ -110,13 +110,13 @@ private static PostResponse handleStudentGetData(APIPostRequest request) { String path = request.getPath().replace("student-", "my"); Student student = request.getCurrentStudent(); String email = student.getEmail(); // Email is the username for the student - return PostResponse.getResource(WebResourceHandler.locationFromPath(path, student), email, request); + return PostResponse.getResource(WebResourceHandler.locationFromPath(path, student), email, request, path); } private static PostResponse handleTeacherGetData(APIPostRequest request) { String path = request.getPath().replace("teacher-", "my"); Teacher teacher = request.getCurrentTeacher(); String email = teacher.getEmail(); // Email is the username for the teacher - return PostResponse.getResource(WebResourceHandler.locationFromPath(path, User.getUser(email)), email, request); + return PostResponse.getResource(WebResourceHandler.locationFromPath(path, User.getUser(email)), email, request, path); } private static PostResponse handleTaskChange(APIPostRequest request, int newStatus) throws IOException, SQLException { Student student = request.getCurrentStudent(); @@ -408,17 +408,17 @@ public static void registerHandlers() { handleObjectAction(rq, new TypeToken() {}, PostResponse.ok("Successfully changed graduation level", ContentType.TEXT_PLAIN, rq), (student) -> student.changeGraduationLevel(rq.getInt("graduationLevel"))) ); - HttpHandler.registerPostRequestHandler("/get-module", AccessLevel.USER, (rq) -> { - return PostResponse.ok(Registry.moduleRegistry().get(rq.getString("key")).toJSON(), ContentType.JSON, rq); + HttpHandler.registerPostRequestHandler("/get-plugin", AccessLevel.USER, (rq) -> { + return PostResponse.ok(Registry.pluginRegistry().get(rq.getString("key")).toJSON(), ContentType.JSON, rq); }); - HttpHandler.registerPostRequestHandler("/toggle-module", AccessLevel.ADMIN, (rq) -> { - Registry.moduleRegistry().get(rq.getString("key")).toggle(); - return PostResponse.ok("Module toggled", ContentType.TEXT_PLAIN, rq); + HttpHandler.registerPostRequestHandler("/toggle-plugin", AccessLevel.ADMIN, (rq) -> { + Registry.pluginRegistry().get(rq.getString("key")).toggle(); + return PostResponse.ok("Plugin toggled", ContentType.TEXT_PLAIN, rq); }); - HttpHandler.registerPostRequestHandler("/toggle-module-setting", AccessLevel.ADMIN, (rq) -> { + HttpHandler.registerPostRequestHandler("/toggle-plugin-setting", AccessLevel.ADMIN, (rq) -> { String[] key = rq.getString("key").split(":"); - Registry.moduleRegistry().get(key[0]).toggleSetting(key[1]); - return PostResponse.ok("Module setting toggled", ContentType.TEXT_PLAIN, rq); + Registry.pluginRegistry().get(key[0]).getConfig().toggleSetting(key[1]); + return PostResponse.ok("Plugin setting toggled", ContentType.TEXT_PLAIN, rq); }); HttpHandler.registerPostRequestHandler("/student-results-csv", AccessLevel.TEACHER, (rq) -> { diff --git a/src/main/java/de/igslandstuhl/database/server/webserver/handlers/get/PluginRequestHandler.java b/src/main/java/de/igslandstuhl/database/server/webserver/handlers/get/PluginRequestHandler.java new file mode 100644 index 0000000..c56842c --- /dev/null +++ b/src/main/java/de/igslandstuhl/database/server/webserver/handlers/get/PluginRequestHandler.java @@ -0,0 +1,27 @@ +package de.igslandstuhl.database.server.webserver.handlers.get; + +import de.igslandstuhl.database.Registry; +import de.igslandstuhl.database.api.User; +import de.igslandstuhl.database.server.resources.ResourceLocation; +import de.igslandstuhl.database.server.webserver.requests.GetRequest; +import de.igslandstuhl.database.server.webserver.responses.GetResponse; + +public class PluginRequestHandler { + public static GetResponse handleRequest(User user, GetRequest request) { + if (user == null || !user.isAdmin()) return GetResponse.unauthorized(request); + if (!request.getPath().equals("/plugin-list")) return GetResponse.notFound(request); + + return GetResponse.getResource(request, new ResourceLocation("virtual", "plugin", "list"), user.getUsername(), false); + } + public static String getPluginResource(String resource) { + if (resource.equals("list")) { + return "[" + + Registry.pluginRegistry().keyStream() + .reduce("", (s1, s2) -> s1 + ", \"" + s2 + "\"") + .substring(2) + + "]"; + } else { + throw new NullPointerException("Plugin Resource not found: " + resource); + } + } +} diff --git a/src/main/java/de/igslandstuhl/database/server/webserver/responses/GetResponse.java b/src/main/java/de/igslandstuhl/database/server/webserver/responses/GetResponse.java index 1262092..327a575 100644 --- a/src/main/java/de/igslandstuhl/database/server/webserver/responses/GetResponse.java +++ b/src/main/java/de/igslandstuhl/database/server/webserver/responses/GetResponse.java @@ -4,12 +4,14 @@ import java.io.InputStream; import java.io.PrintStream; +import de.igslandstuhl.database.client.TemplatingPreprocessor; import de.igslandstuhl.database.server.Server; import de.igslandstuhl.database.server.resources.ResourceLocation; import de.igslandstuhl.database.server.webserver.AccessManager; import de.igslandstuhl.database.server.webserver.ContentType; import de.igslandstuhl.database.server.webserver.NoWebResourceException; import de.igslandstuhl.database.server.webserver.Status; +import de.igslandstuhl.database.server.webserver.handlers.get.PluginRequestHandler; import de.igslandstuhl.database.server.webserver.requests.HttpRequest; /** @@ -138,8 +140,11 @@ public GetResponse(HttpRequest request, Status status, ResourceLocation resource * @return the GetResponse object */ public static GetResponse getResource(HttpRequest request, ResourceLocation resourceLocation, String user, boolean isTemplating) { + return getResource(request, resourceLocation, user, isTemplating, request.getPath()); + } + public static GetResponse getResource(HttpRequest request, ResourceLocation resourceLocation, String user, boolean isTemplating, String path) { try { - if (AccessManager.getInstance().hasAccess(user, resourceLocation)) { + if (AccessManager.getInstance().hasAccess(user, path)) { return new GetResponse(request, Status.OK, resourceLocation, ContentType.ofResourceLocation(resourceLocation), user, isTemplating); } else { return unauthorized(request); @@ -172,8 +177,12 @@ public void respond(PrintStream out) { if (!resourceLocation.isVirtual()) { resource = Server.getInstance().getResourceManager().readResourceCompletely(resourceLocation); } else { - resource = Server.getInstance().getResourceManager().readVirtualResource(user, resourceLocation); - if (resource == null) throw new NullPointerException(); + if (resourceLocation.namespace().equals("plugin")) { + resource = PluginRequestHandler.getPluginResource(resourceLocation.resource()); + } else { + resource = Server.getInstance().getResourceManager().readVirtualResource(user, resourceLocation); + } + if (resource == null) throw new NullPointerException(); } } if (isTemplating) { diff --git a/src/main/java/de/igslandstuhl/database/server/webserver/responses/PostResponse.java b/src/main/java/de/igslandstuhl/database/server/webserver/responses/PostResponse.java index b572963..344de23 100644 --- a/src/main/java/de/igslandstuhl/database/server/webserver/responses/PostResponse.java +++ b/src/main/java/de/igslandstuhl/database/server/webserver/responses/PostResponse.java @@ -153,10 +153,10 @@ public static PostResponse ok(String body, ContentType contentType, PostRequest * @param user the user who made the request * @return the PostResponse object */ - public static PostResponse getResource(ResourceLocation resourceLocation, String user, PostRequest request) { + public static PostResponse getResource(ResourceLocation resourceLocation, String user, PostRequest request, String path) { try { - if (AccessManager.getInstance().hasAccess(user, resourceLocation)) { - return new PostResponse(Status.OK, GetResponse.getResource(request, resourceLocation, user, false).getResponseBody(), ContentType.ofResourceLocation(resourceLocation), request); + if (AccessManager.getInstance().hasAccess(user, path)) { + return new PostResponse(Status.OK, GetResponse.getResource(request, resourceLocation, user, false, path).getResponseBody(), ContentType.ofResourceLocation(resourceLocation), request); } else { return unauthorized("You have to be logged in to access this resource.", request); } diff --git a/src/main/java/de/igslandstuhl/database/utils/RegistryEnum.java b/src/main/java/de/igslandstuhl/database/utils/RegistryEnum.java new file mode 100644 index 0000000..b9a6cce --- /dev/null +++ b/src/main/java/de/igslandstuhl/database/utils/RegistryEnum.java @@ -0,0 +1,65 @@ +package de.igslandstuhl.database.utils; + +import java.lang.reflect.InvocationTargetException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import com.google.gson.reflect.TypeToken; + +import de.igslandstuhl.database.Registry; +import de.igslandstuhl.database.plugins.PluginResourceProvider; +import de.igslandstuhl.database.server.resources.CoreResourceProvider; +import de.igslandstuhl.database.server.resources.ResourceLocation; +import de.igslandstuhl.database.server.resources.ResourceManager; + +public abstract class RegistryEnum> { + private static final Map,Registry> map = new HashMap<>(); + private static final ResourceManager manager = new ResourceManager(new PluginResourceProvider(), new CoreResourceProvider()); + @SuppressWarnings("unchecked") + public static > Registry getRegistry(Class clazz) { + return (Registry) map.get(clazz); + } + private final Registry registry; + private final String key; + + protected RegistryEnum(Registry registry,String key) { + this.registry = registry; + this.key = key; + } + + protected abstract T[] values(Registry registry); + + public T[] values() { + return values(registry); + } + + protected abstract void initValues(); + + protected abstract T initValue(Registry registry, String key); + + protected void initUsingJSONMeta(ResourceLocation meta) { + List constants = manager.readJsonListMerged(meta, new TypeToken>() {}); + constants.forEach((c) -> registry.register(c, initValue(registry, c))); + } + + public static > Registry init(Class clazz) throws InstantiationException, IllegalAccessException, IllegalArgumentException, InvocationTargetException, NoSuchMethodException, SecurityException { + Registry registry = new Registry<>(); + var constructor = clazz.getDeclaredConstructor(Registry.class, String.class); + constructor.setAccessible(true); + T dummy = constructor.newInstance(registry, "DUMMY"); + dummy.initValues(); + if (registry.stream().anyMatch((t) -> t == dummy)) registry.unregister(registry.keyStream().filter((k) -> registry.get(k) == dummy).findAny().get()); + registry.lock(); + map.put(clazz, registry); + return registry; + } + public static > T valueOf(String s, Class clazz) { + return getRegistry(clazz).get(s); + } + + @Override + public String toString() { + return key; + } +} diff --git a/src/main/resources/html/admin/class.html b/src/main/resources/html/admin/class.html index af1c639..cec7d6a 100644 --- a/src/main/resources/html/admin/class.html +++ b/src/main/resources/html/admin/class.html @@ -52,7 +52,6 @@

Schüler in der Klasse

\ No newline at end of file diff --git a/src/main/resources/html/admin/dashboard.html b/src/main/resources/html/admin/dashboard.html index 48253fc..0d5bb68 100644 --- a/src/main/resources/html/admin/dashboard.html +++ b/src/main/resources/html/admin/dashboard.html @@ -6,15 +6,7 @@

Admin Dashboard

Willkommen im Admin-Dashboard!

Admin Optionen

- + %[admin_dashboard_nav]
%[file_import;header=Datenimport aus Lehrerplanungstool;note=Hinweis: Stellen Sie sicher, dass das jeweilige Fach bereits existiert, und der jeweiligen Klassenstufe zugeordnet ist.;uploadUrl=/lpt-file] \ No newline at end of file diff --git a/src/main/resources/html/admin/editor.html b/src/main/resources/html/admin/editor.html index e2f2dbc..257e92a 100644 --- a/src/main/resources/html/admin/editor.html +++ b/src/main/resources/html/admin/editor.html @@ -16,3 +16,6 @@

HTML Editor

+ \ No newline at end of file diff --git a/src/main/resources/html/admin/manage_classes.html b/src/main/resources/html/admin/manage_classes.html index 83df6c8..fbff4d3 100644 --- a/src/main/resources/html/admin/manage_classes.html +++ b/src/main/resources/html/admin/manage_classes.html @@ -32,7 +32,7 @@

Klassenübersicht

+ + \ No newline at end of file diff --git a/src/main/resources/html/admin/student-results.html b/src/main/resources/html/admin/student-results.html index d295208..1001f46 100644 --- a/src/main/resources/html/admin/student-results.html +++ b/src/main/resources/html/admin/student-results.html @@ -1,22 +1,18 @@ -%[student_results;nav= - - - - -
- - -
- -] \ No newline at end of file + postDataAndDownload('/student-results-csv', JSON.stringify({ studentId }), 'schueler_ergebnisse_' + studentId + '.csv'); + }); + document.addEventListener('DOMContentLoaded', async () => { + document.getElementById('studentId').value = studentId; + }) + \ No newline at end of file diff --git a/src/main/resources/html/admin/student.html b/src/main/resources/html/admin/student.html index 2ea49f3..ce7590b 100644 --- a/src/main/resources/html/admin/student.html +++ b/src/main/resources/html/admin/student.html @@ -1,6 +1,2 @@ -%[student_dashboard;script=build_student.js;nav= - - - - -] \ No newline at end of file +%[student_dashboard;script=build_student.js;nav=!FOLLOWS] +%[admin_student_nav] \ No newline at end of file diff --git a/src/main/resources/html/admin/subject.html b/src/main/resources/html/admin/subject.html index faea4b5..6db89fe 100644 --- a/src/main/resources/html/admin/subject.html +++ b/src/main/resources/html/admin/subject.html @@ -50,7 +50,6 @@

Themen in diesem Fach

\ No newline at end of file diff --git a/src/main/resources/html/admin/teacher.html b/src/main/resources/html/admin/teacher.html index d36a908..e6c43f5 100644 --- a/src/main/resources/html/admin/teacher.html +++ b/src/main/resources/html/admin/teacher.html @@ -24,8 +24,7 @@

Lehrer bearbeiten

%[room_info] \ No newline at end of file diff --git a/src/main/resources/html/teacher/dashboard.html b/src/main/resources/html/teacher/dashboard.html index abcea08..8856c14 100644 --- a/src/main/resources/html/teacher/dashboard.html +++ b/src/main/resources/html/teacher/dashboard.html @@ -6,5 +6,6 @@

Willkommen, Lehrer

%[class_info] %[subject_info] %[room_info] + \ No newline at end of file diff --git a/src/main/resources/html/teacher/student-results.html b/src/main/resources/html/teacher/student-results.html index 3ffafe5..1090f9f 100644 --- a/src/main/resources/html/teacher/student-results.html +++ b/src/main/resources/html/teacher/student-results.html @@ -1,8 +1,6 @@ -%[student_results;nav= - - -
- - -
-] \ No newline at end of file +%[student_results;nav=!FOLLOWS] +%[teacher_student_other_nav] +
+ + +
\ No newline at end of file diff --git a/src/main/resources/html/teacher/student.html b/src/main/resources/html/teacher/student.html index 7b597a9..f747271 100644 --- a/src/main/resources/html/teacher/student.html +++ b/src/main/resources/html/teacher/student.html @@ -1,4 +1,2 @@ -%[student_dashboard;script=build_student.js;nav= - - -] \ No newline at end of file +%[student_dashboard;script=build_student.js;nav=!FOLLOWS] +%[teacher_student_nav] \ No newline at end of file diff --git a/src/main/resources/html/user/dashboard.html b/src/main/resources/html/user/dashboard.html index 8b20dad..1111937 100644 --- a/src/main/resources/html/user/dashboard.html +++ b/src/main/resources/html/user/dashboard.html @@ -1,4 +1,4 @@ %[student_dashboard;script=build_dashboard.js;nav= - - -] \ No newline at end of file + !FOLLOWS +] +%[student_dashboard_nav] \ No newline at end of file diff --git a/src/main/resources/html/user/partner_search.html b/src/main/resources/html/user/partner_search.html index 0c36837..56d316f 100644 --- a/src/main/resources/html/user/partner_search.html +++ b/src/main/resources/html/user/partner_search.html @@ -16,7 +16,7 @@

Partnersuche

\ No newline at end of file diff --git a/src/main/resources/html/user/results.html b/src/main/resources/html/user/results.html index d123147..72b7f22 100644 --- a/src/main/resources/html/user/results.html +++ b/src/main/resources/html/user/results.html @@ -1,3 +1,4 @@ %[student_results;nav= - -] \ No newline at end of file + !FOLLOWS +] +%[student_nav] \ No newline at end of file diff --git a/src/main/resources/js/admin/build_modules.js b/src/main/resources/js/admin/build_modules.js deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/resources/js/site/student-database.js b/src/main/resources/js/site/student-database.js index 2087fc6..c3fe14b 100644 --- a/src/main/resources/js/site/student-database.js +++ b/src/main/resources/js/site/student-database.js @@ -92,8 +92,14 @@ async function fetchTopicList(subjectId, grade) { async function fetchTasks(taskIds, studentId) { return await getJsonWithPost('/tasks', { ids: taskIds, studentId }); } -async function fetchModuleSettings(moduleKey) { - return (await getJsonWithPost('/get-module', { key: moduleKey })).settings; +async function fetchPluginKeys() { + return await getJson('/plugin-list'); +} +async function fetchPlugin(pluginKey) { + return await getJsonWithPost('/get-plugin', { key: pluginKey }) +} +async function fetchPluginConfig(pluginKey) { + return (await fetchPlugin(pluginKey)).config; } async function getStudents(classId) { @@ -156,6 +162,12 @@ async function beginTask(studentId, taskId) { async function updateRoom(studentId, room) { return await post('/update-room', { studentId, room }); } +async function togglePlugin(pluginKey) { + return await post('/toggle-plugin', { key: pluginKey }); +} +async function togglePluginSetting(pluginKey, setting) { + return await post('/toggle-plugin-setting', { key: pluginKey + ":" + setting }); +} async function deleteClass(classId) { return await post('/delete-class', { id: classId }); @@ -587,10 +599,10 @@ function createBarChart(subject, subjectName, studentData, settings) { const predicted = studentData.predictedProgress && studentData.predictedProgress[subjectName] ? studentData.predictedProgress[subjectName].predictedProgress : 0; - const grade = settings.show_current_grade.value ? getGrade(progress) : 0; + const grade = settings.show_current_grade ? getGrade(progress) : 0; const predictedGrade = getGrade(predicted); - if (!settings || settings.show_current_progress.value) { + if (!settings || settings.show_current_progress) { const gradeInfo = document.createElement('div'); gradeInfo.className = 'grade-current'; gradeInfo.innerHTML = `Aktueller Fortschritt: ${getGradeLabel(grade)} (${Math.round(progress * 100)}%)`; @@ -801,7 +813,7 @@ function loadStudentDashboard(studentData, subjects, teacherPerms) { // Show stu }); } async function loadStudentResultView(studentData) { - const settings = await fetchModuleSettings('result_view'); + const config = await fetchPluginConfig('result_view'); document.getElementById('student-name').textContent = `${studentData.firstName} ${studentData.lastName}`; @@ -813,7 +825,44 @@ async function loadStudentResultView(studentData) { const charts = document.getElementById('charts'); subjects.forEach(subject => { - charts.appendChild(createBarChart(subject, subject.name, studentData, settings)); + charts.appendChild(createBarChart(subject, subject.name, studentData, config.values)); + }); +} +let plugin_panels = {} +function loadPluginSection(pluginKey) { + return createPanel(pluginKey, document.createElement("div"), async (header, body) => { + const plugin = await fetchPlugin(pluginKey); + header.textContent = plugin.name; + body.innerHTML = ` +

${plugin.description.replace(/\n/g, "

")}

+ + + + + + + + + + +
KeyValue +
ID${plugin.id}
Name${plugin.name}
Enabled${plugin.enabled}
+ `; + const tbody = body.getElementsByTagName("tbody")[0] + const settings = plugin.config.settings; + settings.bools.forEach((b) => { + const tr = document.createElement("tr"); + tr.innerHTML = `${b.name}${b.value}`; + tbody.appendChild(tr); + }); + }) +} +async function loadPluginsView(pluginContainer) { + const plugins = fetchPluginKeys(); + (await plugins).forEach(async key => { + const pluginSection = loadPluginSection(key); + plugin_panels[key] = pluginSection; + pluginContainer.appendChild(pluginSection); }); } const graduationLevels = ["Neustarter", "Starter", "Durchstarter", "Lernprofi"]; diff --git a/src/main/resources/meta/navigation/navigation_elements.json b/src/main/resources/meta/navigation/navigation_elements.json new file mode 100644 index 0000000..14dbbb1 --- /dev/null +++ b/src/main/resources/meta/navigation/navigation_elements.json @@ -0,0 +1,163 @@ +[ + { + "type": "ADMIN_DASHBOARD", + "path": "/manage_classes", + "label": "Klassen verwalten" + }, + { + "type": "ADMIN_DASHBOARD", + "path": "/manage_rooms", + "label": "Räume verwalten" + }, + { + "type": "ADMIN_DASHBOARD", + "path": "/manage_students", + "label": "Schüler verwalten" + }, + { + "type": "ADMIN_DASHBOARD", + "path": "/manage_subjects", + "label": "Fächer verwalten" + }, + { + "type": "ADMIN_DASHBOARD", + "path": "/manage_teachers", + "label": "Lehrer verwalten" + }, + { + "type": "ADMIN_DASHBOARD", + "path": "/plugins", + "label": "Module verwalten" + }, + { + "type": "ADMIN_DASHBOARD", + "path": "/editor", + "label": "HTML Editor" + }, + + { + "type": "ADMIN_OTHER", + "path": "/dashboard", + "label": "Zurück zum Admin-Dashboard" + }, + + { + "type": "ADMIN_STUDENT", + "path": "/student-results", + "label": "Ergebnisse anzeigen" + }, + { + "type": "ADMIN_STUDENT", + "path": "/teacher", + "label": "Zurück zur Lehrer-Ansicht" + }, + { + "type": "ADMIN_STUDENT", + "path": "/class", + "label": "Zurück zur Klassen-Ansicht" + }, + { + "type": "ADMIN_STUDENT", + "path": "/dashboard", + "label": "Zurück zum Admin-Dashboard" + }, + + { + "type": "ADMIN_STUDENT_OTHER", + "path": "/student", + "label": "Zurück zur Schüler-Ansicht" + }, + { + "type": "ADMIN_STUDENT_OTHER", + "path": "/teacher", + "label": "Zurück zur Lehrer-Ansicht" + }, + { + "type": "ADMIN_STUDENT_OTHER", + "path": "/class", + "label": "Zurück zur Klassen-Ansicht" + }, + { + "type": "ADMIN_STUDENT_OTHER", + "path": "/dashboard", + "label": "Zurück zum Admin-Dashboard" + }, + + { + "type": "ADMIN_CLASS", + "path": "/manage_classes", + "label": "Zurück zur Klassenverwaltung" + }, + { + "type": "ADMIN_CLASS", + "path": "/dashboard", + "label": "Zurück zum Admin-Dashboard" + }, + + { + "type": "ADMIN_SUBJECT", + "path": "/manage_subjects", + "label": "Zurück zur Fächerverwaltung" + }, + { + "type": "ADMIN_SUBJECT", + "path": "/dashboard", + "label": "Zurück zum Admin-Dashboard" + }, + + { + "type": "ADMIN_TEACHER", + "path": "/manage_teachers", + "label": "Zurück zur Lehrerverwaltung" + }, + { + "type": "ADMIN_TEACHER", + "path": "/dashboard", + "label": "Zurück zum Admin-Dashboard" + }, + + { + "type": "TEACHER_OTHER", + "path": "/dashboard", + "label": "Zurück zum Lehrer-Dashboard" + }, + + { + "type": "TEACHER_STUDENT", + "path": "/student-results", + "label": "Ergebnisse anzeigen" + }, + { + "type": "TEACHER_STUDENT", + "path": "/dashboard", + "label": "Zurück zum Lehrer-Dashboard" + }, + + { + "type": "TEACHER_STUDENT_OTHER", + "path": "/student", + "label": "Zurück zur Schüler-Ansicht" + }, + { + "type": "TEACHER_STUDENT", + "path": "/dashboard", + "label": "Zurück zum Lehrer-Dashboard" + }, + + { + "type": "STUDENT_DASHBOARD", + "path": "/results", + "label": "Meine Ergebnisse anzeigen" + }, + { + "type": "STUDENT_DASHBOARD", + "path": "/partner_search", + "label": "Partner suchen" + }, + + { + "type": "STUDENT_OTHER", + "path": "/dashboard", + "label": "Zurück zum Dashboard" + } +] \ No newline at end of file diff --git a/src/main/resources/meta/navigation/navigation_types.json b/src/main/resources/meta/navigation/navigation_types.json new file mode 100644 index 0000000..ef33ef7 --- /dev/null +++ b/src/main/resources/meta/navigation/navigation_types.json @@ -0,0 +1,17 @@ +[ + "ADMIN_DASHBOARD", + "ADMIN_OTHER", + "ADMIN_STUDENT", + "ADMIN_STUDENT_OTHER", + "ADMIN_CLASS", + "ADMIN_SUBJECT", + "ADMIN_TEACHER", + + "TEACHER_DASHBOARD", + "TEACHER_OTHER", + "TEACHER_STUDENT", + "TEACHER_STUDENT_OTHER", + + "STUDENT_DASHBOARD", + "STUDENT_OTHER" +] \ No newline at end of file diff --git a/src/main/resources/meta/paths/get_paths.json b/src/main/resources/meta/paths/get_paths.json index fe64bda..ff8c2a5 100644 --- a/src/main/resources/meta/paths/get_paths.json +++ b/src/main/resources/meta/paths/get_paths.json @@ -115,7 +115,7 @@ "access_level": "teacher" }, - "/modules": { + "/plugins": { "type": "GET", "handler_type": "TemplatingFileRequestHandler", "namespaces": ["admin"], @@ -266,14 +266,6 @@ "context": "js", "access_level": "teacher" }, - - "/build_modules.js": { - "type": "GET", - "handler_type": "FileRequestHandler", - "namespaces": ["admin"], - "context": "js", - "access_level": "admin" - }, "/build_teacher.js": { "type": "GET", "handler_type": "FileRequestHandler", @@ -330,5 +322,12 @@ "namespaces": ["icons"], "context": "imgs", "access_level": "public" + }, + "/plugin-list": { + "type": "GET", + "handler_type": "PluginRequestHandler", + "namespaces": ["plugin"], + "context": "virtual", + "access_level": "admin" } } \ No newline at end of file diff --git a/src/main/resources/meta/templates/templates.json b/src/main/resources/meta/templates/templates.json new file mode 100644 index 0000000..b39d226 --- /dev/null +++ b/src/main/resources/meta/templates/templates.json @@ -0,0 +1,100 @@ +{ + "class_info": { + "type": "HTMLFileTemplate", + "path": "class_info" + }, + "file_import": { + "type": "HTMLFileTemplate", + "path": "file_import" + }, + "login": { + "type": "HTMLFileTemplate", + "path": "login" + }, + "room_info": { + "type": "HTMLFileTemplate", + "path": "room_info" + }, + "site": { + "type": "HTMLFileTemplate", + "path": "site" + }, + "student_dashboard": { + "type": "HTMLFileTemplate", + "path": "student_dashboard" + }, + "student_results": { + "type": "HTMLFileTemplate", + "path": "student_results" + }, + "subject_info": { + "type": "HTMLFileTemplate", + "path": "subject_info" + }, + + "admin_dashboard_nav": { + "type": "HTMLNavigationTemplate", + "navigation_type": "ADMIN_DASHBOARD", + "appearance": "LIST_APPEARANCE" + }, + "admin_nav": { + "type": "HTMLNavigationTemplate", + "navigation_type": "ADMIN_OTHER", + "appearance": "BUTTON_APPEARANCE" + }, + "admin_student_nav": { + "type": "HTMLNavigationTemplate", + "navigation_type": "ADMIN_STUDENT", + "appearance": "BUTTON_APPEARANCE" + }, + "admin_student_other_nav": { + "type": "HTMLNavigationTemplate", + "navigation_type": "ADMIN_STUDENT_OTHER", + "appearance": "BUTTON_APPEARANCE" + }, + "admin_class_nav": { + "type": "HTMLNavigationTemplate", + "navigation_type": "ADMIN_CLASS", + "appearance": "BUTTON_APPEARANCE" + }, + "admin_subject_nav": { + "type": "HTMLNavigationTemplate", + "navigation_type": "ADMIN_SUBJECT", + "appearance": "BUTTON_APPEARANCE" + }, + "admin_teacher_nav": { + "type": "HTMLNavigationTemplate", + "navigation_type": "ADMIN_TEACHER", + "appearance": "BUTTON_APPEARANCE" + }, + "teacher_dashboard_nav": { + "type": "HTMLNavigationTemplate", + "navigation_type": "TEACHER_DASHBOARD", + "appearance": "LIST_APPEARANCE" + }, + "teacher_other_nav": { + "type": "HTMLNavigationTemplate", + "navigation_type": "TEACHER_OTHER", + "appearance": "BUTTON_APPEARANCE" + }, + "teacher_student_nav": { + "type": "HTMLNavigationTemplate", + "navigation_type": "TEACHER_STUDENT", + "appearance": "BUTTON_APPEARANCE" + }, + "teacher_student_other_nav": { + "type": "HTMLNavigationTemplate", + "navigation_type": "TEACHER_STUDENT_OTHER", + "appearance": "BUTTON_APPEARANCE" + }, + "student_dashboard_nav": { + "type": "HTMLNavigationTemplate", + "navigation_type": "STUDENT_DASHBOARD", + "appearance": "BUTTON_APPEARANCE" + }, + "student_nav": { + "type": "HTMLNavigationTemplate", + "navigation_type": "STUDENT_OTHER", + "appearance": "BUTTON_APPEARANCE" + } +} \ No newline at end of file