diff --git a/backend/build.gradle.kts b/backend/build.gradle.kts index 433a029..8205734 100644 --- a/backend/build.gradle.kts +++ b/backend/build.gradle.kts @@ -13,6 +13,7 @@ application{ dependencies{ implementation(project(":core")) + implementation(project(":legacy")) implementation(project(":translations")) implementation(project(":user")) implementation(project(":web")) diff --git a/backend/src/main/java/de/srsoftware/umbrella/backend/Application.java b/backend/src/main/java/de/srsoftware/umbrella/backend/Application.java index c53cfd7..9823d0c 100644 --- a/backend/src/main/java/de/srsoftware/umbrella/backend/Application.java +++ b/backend/src/main/java/de/srsoftware/umbrella/backend/Application.java @@ -10,6 +10,8 @@ import com.sun.net.httpserver.HttpServer; import de.srsoftware.configuration.JsonConfig; import de.srsoftware.tools.ColorLogger; import de.srsoftware.umbrella.core.ConnectionProvider; +import de.srsoftware.umbrella.core.UmbrellaException; +import de.srsoftware.umbrella.legacy.LegacyApi; import de.srsoftware.umbrella.translations.Translations; import de.srsoftware.umbrella.user.UserModule; import de.srsoftware.umbrella.user.sqlite.SqliteDB; @@ -36,22 +38,24 @@ public class Application { } } - public static void main(String[] args) throws IOException { + public static void main(String[] args) throws IOException, UmbrellaException { LOG.log(INFO, "Starting Umbrella:"); - var jsonConfig = new JsonConfig(UMBRELLA); - configureLogging(jsonConfig); - var port = jsonConfig.get("umbrella.http.port", 8080); - var threads = jsonConfig.get("umbrella.threads", 16); - var userDB = jsonConfig.get("umbrella.database.user", jsonConfig.file().getParent()+"/umbrella.db"); - var loginDB = jsonConfig.get("umbrella.database.login_services",jsonConfig.file().getParent()+"/umbrella.db"); + var config = new JsonConfig(UMBRELLA); + configureLogging(config); + var port = config.get("umbrella.http.port", 8080); + var threads = config.get("umbrella.threads", 16); + var userDB = config.get("umbrella.database.user", config.file().getParent()+"/umbrella.db"); + var loginDB = config.get("umbrella.database.login_services",config.file().getParent()+"/umbrella.db"); var connectionProvider = new ConnectionProvider(); var userDb = new SqliteDB(connectionProvider.get(userDB)); var loginServicedb = new SqliteDB(connectionProvider.get(loginDB)); var server = HttpServer.create(new InetSocketAddress(port), 0); server.setExecutor(Executors.newFixedThreadPool(threads)); - new WebHandler().bindPath("/").on(server); - new UserModule(userDb,loginServicedb).bindPath("/api/user").on(server); + + new LegacyApi(userDb,config).bindPath("/legacy").on(server); new Translations().bindPath("/api/translations").on(server); + new UserModule(userDb,loginServicedb).bindPath("/api/user").on(server); + new WebHandler().bindPath("/").on(server); server.start(); LOG.log(INFO,"Started web server at {0}",port); } diff --git a/core/build.gradle.kts b/core/build.gradle.kts index c3dcd1f..1adf021 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -11,10 +11,11 @@ repositories { dependencies { implementation("de.srsoftware:tools.mime:1.1.2") + implementation("de.srsoftware:tools.optionals:1.0.0") implementation("org.json:json:20240303") + implementation("org.xerial:sqlite-jdbc:3.49.0.0") testImplementation(platform("org.junit:junit-bom:5.10.0")) testImplementation("org.junit.jupiter:junit-jupiter") - implementation("org.xerial:sqlite-jdbc:3.49.0.0") } tasks.test { diff --git a/core/src/main/java/de/srsoftware/umbrella/core/BaseHandler.java b/core/src/main/java/de/srsoftware/umbrella/core/BaseHandler.java new file mode 100644 index 0000000..66d9804 --- /dev/null +++ b/core/src/main/java/de/srsoftware/umbrella/core/BaseHandler.java @@ -0,0 +1,65 @@ +package de.srsoftware.umbrella.core; + +import com.sun.net.httpserver.HttpExchange; +import de.srsoftware.tools.Path; +import de.srsoftware.tools.PathHandler; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.List; + +import static de.srsoftware.tools.MimeType.MIME_JSON; +import static de.srsoftware.tools.Optionals.nullable; +import static java.lang.System.Logger.Level.DEBUG; +import static java.lang.System.Logger.Level.WARNING; +import static java.net.HttpURLConnection.HTTP_NOT_FOUND; + +public abstract class BaseHandler extends PathHandler { + + private static HttpExchange addCors(HttpExchange ex){ + var headers = ex.getRequestHeaders(); + var origin = nullable(headers.get("Origin")).orElse(List.of()).stream().filter(url -> url.contains("://localhost")||url.contains("://127.0.0.1")).findAny(); + if (origin.isPresent()) { + var url = origin.get(); + headers = ex.getResponseHeaders(); + headers.add("Allow-Origin", url); + headers.add("Access-Control-Allow-Origin", url); + headers.add("Access-Control-Allow-Headers", "Content-Type"); + headers.add("Access-Control-Allow-Credentials", "true"); + headers.add("Access-Control-Allow-Methods","GET, POST, PATCH"); + } + return ex; + } + + public record Document(String mime, byte[] bytes){} + + public boolean load(Path path, HttpExchange ex) throws IOException { + try { + var doc = load(path.toString()); + ex.getResponseHeaders().add(CONTENT_TYPE, doc.mime); + return sendContent(ex,doc.bytes); + } catch (UmbrellaException e) { + throw new RuntimeException(e); + } + } + + public Document load(String resourcePath) throws IOException, UmbrellaException { + var url = getClass().getClassLoader().getResource(resourcePath); + if (url == null) throw new UmbrellaException(HTTP_NOT_FOUND,"{0} not found!",resourcePath); + LOG.log(DEBUG,"Trying to load {0}",url); + var bos = new ByteArrayOutputStream(); + var conn = url.openConnection(); + var mime = conn.getContentType(); + try (var stream = conn.getInputStream()){ + stream.transferTo(bos); + return new Document(mime,bos.toByteArray()); + } catch (Exception e) { + LOG.log(WARNING,"Failed to load {0}",url); + throw new UmbrellaException(HTTP_NOT_FOUND,"Failed to load {0}",url); + } + } + + public boolean send(HttpExchange ex, UmbrellaException e) throws IOException { + return sendContent(ex,e.statusCode(),e.getMessage()); + } +} diff --git a/user/src/main/java/de/srsoftware/umbrella/user/model/Token.java b/core/src/main/java/de/srsoftware/umbrella/core/Token.java similarity index 91% rename from user/src/main/java/de/srsoftware/umbrella/user/model/Token.java rename to core/src/main/java/de/srsoftware/umbrella/core/Token.java index 760b7fb..8dde201 100644 --- a/user/src/main/java/de/srsoftware/umbrella/user/model/Token.java +++ b/core/src/main/java/de/srsoftware/umbrella/core/Token.java @@ -1,10 +1,10 @@ /* © SRSoftware 2025 */ -package de.srsoftware.umbrella.user.model; +package de.srsoftware.umbrella.core; import static de.srsoftware.umbrella.core.Constants.TOKEN; import de.srsoftware.tools.SessionToken; -import de.srsoftware.umbrella.core.AddableMap; + import java.util.UUID; public class Token implements CharSequence{ diff --git a/legacy/Readme.md b/legacy/Readme.md new file mode 100644 index 0000000..20516e4 --- /dev/null +++ b/legacy/Readme.md @@ -0,0 +1,5 @@ +# Legacy + +This module aims to provide an API for legacy modules to communicate with. + +As soon as all "old" PHP modules are replaced, this should no longer be required. \ No newline at end of file diff --git a/legacy/src/main/java/de/srsoftware/umbrella/legacy/LegacyApi.java b/legacy/src/main/java/de/srsoftware/umbrella/legacy/LegacyApi.java new file mode 100644 index 0000000..2298323 --- /dev/null +++ b/legacy/src/main/java/de/srsoftware/umbrella/legacy/LegacyApi.java @@ -0,0 +1,126 @@ +package de.srsoftware.umbrella.legacy; + +import com.sun.net.httpserver.HttpExchange; +import de.srsoftware.configuration.Configuration; +import de.srsoftware.tools.Path; +import de.srsoftware.tools.SessionToken; +import de.srsoftware.umbrella.core.BaseHandler; +import de.srsoftware.umbrella.core.Token; +import de.srsoftware.umbrella.core.UmbrellaException; +import de.srsoftware.umbrella.user.sqlite.SqliteDB; + +import static de.srsoftware.tools.MimeType.MIME_FORM_URL; +import static de.srsoftware.umbrella.core.ResponseCode.HTTP_SERVER_ERROR; +import static de.srsoftware.umbrella.core.Constants.*; +import static de.srsoftware.umbrella.core.Constants.KEY; +import static de.srsoftware.umbrella.core.Paths.LOGOUT; +import static de.srsoftware.umbrella.core.Paths.SEARCH; +import static de.srsoftware.umbrella.core.Util.request; +import static java.net.HttpURLConnection.*; +import static java.time.temporal.ChronoUnit.DAYS; + +import java.io.IOException; +import java.time.Instant; +import java.util.Map; +import java.util.Optional; + +public class LegacyApi extends BaseHandler { + + private final SqliteDB users; + private final Configuration config; + + public LegacyApi(SqliteDB userDb, Configuration config) { + users = userDb; + this.config = config.subset("umbrella.modules").orElseThrow(() -> new RuntimeException("Missing configuration: umbrella.modules")); + } + + @Override + public boolean doGet(Path path, HttpExchange ex) throws IOException { + var head = path.pop(); + return switch (head){ + case null -> sendRedirect(ex, url(ex).replaceAll("/api/.*","")); + case "common_templates" -> { + allowOrigin(ex, "*"); // add CORS header + yield load(path,ex); + } + case LOGIN -> getLegacyLogin(ex); + case LOGOUT-> logout(ex); + case SEARCH -> { + try { + yield legacySearch(ex); + } catch (UmbrellaException e){ + yield send(ex,e); + } + } + default -> super.doGet(path,ex); + }; + } + + private boolean legacySearch(HttpExchange ex) throws IOException, UmbrellaException { + var optToken = SessionToken.from(ex).map(Token::of); + if (optToken.isEmpty()) return sendEmptyResponse(HTTP_UNAUTHORIZED,ex); + var token = optToken.get(); + var params = queryParam(ex); + var key = params.get(KEY) instanceof String s ? s : null; + if (key == null) throw new UmbrellaException(HTTP_BAD_REQUEST,"Missing search key"); + var fulltext = key.contains("+") || "on".equals(params.get("fulltext")); + StringBuilder searchResult = new StringBuilder(); + if (fulltext){ + + for (var module : config.keys()){ + var baseUrl = config.get(module + ".baseUrl"); + if (baseUrl.isEmpty()) continue; + + var res = request(baseUrl.get()+"/search",token.asMap().plus(KEY,key),MIME_FORM_URL,token.asBearer()); + if (!(res instanceof String content) || content.isBlank()) continue; + searchResult.append("
"); + searchResult.append(content).append("
\n"); + } + } else { + var bookmark = config.get("bookmark.baseUrl"); + if (bookmark.isEmpty()) throw new UmbrellaException(500,"Tag search not available: Bookmark module not configured!"); + var res = request(bookmark.get()+"/search",token.asMap().plus(KEY,key),MIME_FORM_URL,null); + if (!(res instanceof String content)) throw new UmbrellaException(500,"Search did not return html content!"); + searchResult.append(content); + } + return sendContent(ex,searchResult.toString()); + }; + + private boolean logout(HttpExchange ex) throws IOException { + var returnTo = queryParam(ex).get("returnTo"); + var optToken = SessionToken.from(ex).map(Token::of); + if (optToken.isPresent()) try{ + var token = optToken.get(); + users.dropSession(token); + var expiredToken = new SessionToken(token.toString(),"/", Instant.now().minus(1, DAYS),true); + expiredToken.addTo(ex); + if (returnTo instanceof String location) return sendRedirect(ex,location); + return sendContent(ex, Map.of(REDIRECT,"home")); + } catch (UmbrellaException e) { + return send(ex,e); + } + if (returnTo instanceof String location) return sendRedirect(ex,location); + return sendContent(ex,Map.of(REDIRECT,"home")); + } + + private boolean getLegacyLogin(HttpExchange ex) throws IOException { + var returnTo = queryParam(ex).get("returnTo"); + if (returnTo instanceof String url) { + var token = SessionToken.from(ex).map(SessionToken::sessionId) + .or(() -> getBearer(ex)) + .map(Token::of); + if (token.isEmpty()) return sendEmptyResponse(HTTP_UNAUTHORIZED,ex); + return sendRedirect(ex, url + (url.contains("?") ? "&" : "?") + "token=" + token.get()); + } + + var location = url(ex); + location = location.replaceAll("/api/.*","/login"); + if (returnTo != null) location += "?returnTo="+returnTo; + return sendRedirect(ex,location); + } + + @Override + public boolean doPost(Path path, HttpExchange ex) throws IOException { + return super.doPost(path, ex); + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 1bfddd9..ea1ba03 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -5,4 +5,5 @@ include("translations") include("user") include("web") -include("core") \ No newline at end of file +include("core") +include("legacy") \ No newline at end of file diff --git a/user/src/main/java/de/srsoftware/umbrella/user/UserModule.java b/user/src/main/java/de/srsoftware/umbrella/user/UserModule.java index 77cb116..462dde6 100644 --- a/user/src/main/java/de/srsoftware/umbrella/user/UserModule.java +++ b/user/src/main/java/de/srsoftware/umbrella/user/UserModule.java @@ -20,12 +20,13 @@ import static java.net.HttpURLConnection.*; import static java.nio.charset.StandardCharsets.UTF_8; import static java.text.MessageFormat.format; import static java.time.temporal.ChronoUnit.DAYS; -import static javax.security.auth.callback.ConfirmationCallback.OK; import com.sun.net.httpserver.HttpExchange; import de.srsoftware.tools.Path; import de.srsoftware.tools.PathHandler; import de.srsoftware.tools.SessionToken; +import de.srsoftware.umbrella.core.BaseHandler; +import de.srsoftware.umbrella.core.Token; import de.srsoftware.umbrella.core.UmbrellaException; import de.srsoftware.umbrella.user.api.LoginServiceDb; import de.srsoftware.umbrella.user.api.UserDb; @@ -45,7 +46,7 @@ import org.jose4j.keys.resolvers.HttpsJwksVerificationKeyResolver; import org.json.JSONObject; -public class UserModule extends PathHandler { +public class UserModule extends BaseHandler { private record State(LoginService loginService, JSONObject config){ public static State of(LoginService loginService, JSONObject config) { @@ -102,7 +103,7 @@ public class UserModule extends PathHandler { logins.delete(serviceName); return sendEmptyResponse(HTTP_OK,ex); } catch (UmbrellaException e) { - return sendContent(ex,e.statusCode(),e.getMessage()); + return send(ex,e); } } @@ -171,7 +172,7 @@ public class UserModule extends PathHandler { } return sendContent(ex,users.load(userId)); } catch (UmbrellaException e) { - return sendContent(ex,e.statusCode(),e.getMessage()); + return send(ex,e); } catch (NumberFormatException ignored) {} return super.doGet(path, ex); @@ -193,7 +194,7 @@ public class UserModule extends PathHandler { Session session = users.load(Token.of(sessionToken.get())); requestingUser = users.load(session); } catch (UmbrellaException e) { - return sendContent(ex,e.statusCode(),e.getMessage()); + return send(ex,e); } var head = path.pop(); @@ -211,7 +212,7 @@ public class UserModule extends PathHandler { try { editedUser = (DbUser) users.load(userId); } catch (UmbrellaException e) { - return sendContent(ex,e.statusCode(),e.getMessage()); + return send(ex,e); } if (requestingUser.id() != userId && (!(requestingUser instanceof DbUser dbUser) || !dbUser.permissions().contains(UPDATE_USERS))){ @@ -230,7 +231,7 @@ public class UserModule extends PathHandler { try { return update(ex, editedUser,json); } catch (UmbrellaException e) { - return sendContent(ex,e.statusCode(),e.getMessage()); + return send(ex,e); } } @@ -320,7 +321,7 @@ public class UserModule extends PathHandler { var connections = logins.listAssignments(user.id()).stream().map(ForeignLogin::toMap); return sendContent(ex,connections); } catch (UmbrellaException e) { - return sendContent(ex,e.statusCode(),e.getMessage()); + return send(ex,e); } } @@ -342,7 +343,7 @@ public class UserModule extends PathHandler { try { return sendContent(ex,logins.loadLoginService(serviceId).toMap()); } catch (UmbrellaException e) { - return sendContent(ex,e.statusCode(),e.getMessage()); + return send(ex,e); } catch (IOException e) { return sendContent(ex,HTTP_SERVER_ERROR,e.getMessage()); } @@ -391,7 +392,7 @@ public class UserModule extends PathHandler { var services = logins.listLoginServices().stream().map(LoginService::name); return sendContent(ex,services); } catch (UmbrellaException e) { - return sendContent(ex,e.statusCode(),e.getMessage()); + return send(ex,e); } catch (IOException e) { return sendContent(ex,HTTP_SERVER_ERROR,e.getMessage()); } @@ -403,7 +404,7 @@ public class UserModule extends PathHandler { var services = logins.listLoginServices().stream().map(LoginService::toMap); return sendContent(ex,services); } catch (UmbrellaException e) { - return sendContent(ex,e.statusCode(),e.getMessage()); + return send(ex,e); } catch (IOException e) { return sendContent(ex,HTTP_SERVER_ERROR,e.getMessage()); } @@ -420,7 +421,7 @@ public class UserModule extends PathHandler { var list = users.list(0, null).stream().map(UmbrellaUser::toMap).toList(); return sendContent(ex,list); } catch (UmbrellaException e) { - return sendContent(ex,e.statusCode(),e.getMessage()); + return send(ex,e); } } @@ -435,7 +436,7 @@ public class UserModule extends PathHandler { users.getSession(targetUser).cookie().addTo(ex); return sendContent(ex,targetUser.toMap()); } catch (UmbrellaException e) { - return sendContent(ex,e.statusCode(),e.getMessage()); + return send(ex,e); } } @@ -480,7 +481,7 @@ public class UserModule extends PathHandler { var updated = users.save(new DbUser(user.id(), user.name(), user.email(), pass, user.theme(), user.language(), user.permissions(), null)); return sendContent(ex, updated); } catch (UmbrellaException e) { - return sendContent(ex,e.statusCode(),e.getMessage()); + return send(ex,e); } } @@ -495,7 +496,7 @@ public class UserModule extends PathHandler { var service = logins.save(new LoginService(name,url,clientId,secret, DEFAULT_FIELD)); return sendContent(ex,service.toMap()); } catch (UmbrellaException e) { - return sendContent(ex,e.statusCode(),e.getMessage()); + return send(ex,e); } } @@ -509,8 +510,8 @@ public class UserModule extends PathHandler { var user = users.load(username, hashedPass); users.getSession(user).cookie().addTo(ex); return sendContent(ex,user); - } catch (UmbrellaException ue){ - return sendContent(ex,ue.statusCode(),ue.getMessage()); + } catch (UmbrellaException e){ + return send(ex,e); } } @@ -526,9 +527,7 @@ public class UserModule extends PathHandler { return score; } - private boolean send(HttpExchange ex, UmbrellaException e) throws IOException { - return sendContent(ex,e.statusCode(),e.getMessage()); - } + private boolean update(HttpExchange ex, DbUser user, JSONObject json) throws UmbrellaException, IOException { var id = user.id(); diff --git a/user/src/main/java/de/srsoftware/umbrella/user/api/UserDb.java b/user/src/main/java/de/srsoftware/umbrella/user/api/UserDb.java index 27be185..0db8b86 100644 --- a/user/src/main/java/de/srsoftware/umbrella/user/api/UserDb.java +++ b/user/src/main/java/de/srsoftware/umbrella/user/api/UserDb.java @@ -5,7 +5,7 @@ import de.srsoftware.umbrella.core.UmbrellaException; import de.srsoftware.umbrella.user.model.DbUser; import de.srsoftware.umbrella.user.model.Password; import de.srsoftware.umbrella.user.model.Session; -import de.srsoftware.umbrella.user.model.Token; +import de.srsoftware.umbrella.core.Token; import de.srsoftware.umbrella.user.model.UmbrellaUser; import java.util.List; diff --git a/user/src/main/java/de/srsoftware/umbrella/user/model/Session.java b/user/src/main/java/de/srsoftware/umbrella/user/model/Session.java index 662a130..49c322a 100644 --- a/user/src/main/java/de/srsoftware/umbrella/user/model/Session.java +++ b/user/src/main/java/de/srsoftware/umbrella/user/model/Session.java @@ -2,6 +2,8 @@ package de.srsoftware.umbrella.user.model; import de.srsoftware.tools.SessionToken; +import de.srsoftware.umbrella.core.Token; + import java.time.Instant; /* © SRSoftware 2025 */ diff --git a/user/src/main/java/de/srsoftware/umbrella/user/sqlite/SqliteDB.java b/user/src/main/java/de/srsoftware/umbrella/user/sqlite/SqliteDB.java index 86f2dd3..d640715 100644 --- a/user/src/main/java/de/srsoftware/umbrella/user/sqlite/SqliteDB.java +++ b/user/src/main/java/de/srsoftware/umbrella/user/sqlite/SqliteDB.java @@ -16,7 +16,7 @@ import de.srsoftware.umbrella.user.api.LoginServiceDb; import de.srsoftware.umbrella.user.api.UserDb; import de.srsoftware.umbrella.user.model.*; import de.srsoftware.umbrella.user.model.Session; -import de.srsoftware.umbrella.user.model.Token; +import de.srsoftware.umbrella.core.Token; import de.srsoftware.umbrella.user.model.UmbrellaUser; import java.sql.Connection; import java.sql.ResultSet;