commit ca4428cf06f6a65b2f72d7e5883e83d91763962f Author: Radek Davidek Date: Fri May 22 17:42:38 2026 +0200 first commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9f82cd8 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +target/ +.idea/ +.vscode/ +*.iml +*.log +node_modules/ +.git/ +claude-projects/ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c07c34e --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +target/ +*.jar +*.log +*.class +**.properties.local +.idea/ +.vscode/ +*.iml +.DS_Store +.claude \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5147ad2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +# Build stage +FROM maven:3.9-eclipse-temurin-11 AS build +WORKDIR /app +COPY pom.xml . +RUN mvn dependency:go-offline +COPY src ./src +RUN mvn package -DskipTests + +# Runtime stage +FROM eclipse-temurin:11-jre-alpine +WORKDIR /app +COPY --from=build /app/target/file-share-1.0.0.jar app.jar +EXPOSE 8080 +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/ddl/schema.sql b/ddl/schema.sql new file mode 100644 index 0000000..b457328 --- /dev/null +++ b/ddl/schema.sql @@ -0,0 +1,46 @@ +-- File Share Database Schema +-- MariaDB 10.3+ + +CREATE DATABASE IF NOT EXISTS file_share + CHARACTER SET utf8mb4 + COLLATE utf8mb4_unicode_ci; + +USE file_share; + +CREATE TABLE accounts ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + username VARCHAR(128) NOT NULL UNIQUE, + api_key VARCHAR(128) NOT NULL UNIQUE, + password_hash VARCHAR(255) NOT NULL, + role VARCHAR(16) NOT NULL DEFAULT 'user', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE files ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + account_id BIGINT UNSIGNED NOT NULL, + filename VARCHAR(512) NOT NULL, + mime_type VARCHAR(256) NOT NULL, + size BIGINT UNSIGNED NOT NULL, + sha256 CHAR(64) NOT NULL, + data LONGBLOB NOT NULL, + uploaded_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (account_id) REFERENCES accounts (id) ON DELETE CASCADE, + INDEX idx_account (account_id) +); + +CREATE TABLE download_otp ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + file_id BIGINT UNSIGNED NOT NULL, + code CHAR(5) NOT NULL, + used TINYINT(1) NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at TIMESTAMP NOT NULL, + INDEX idx_code (code), + INDEX idx_expires (expires_at), + FOREIGN KEY (file_id) REFERENCES files (id) ON DELETE CASCADE +); + +-- Seed data +INSERT INTO accounts (username, api_key, password_hash, role) +VALUES ('kamma', 'kamma-api-key-initial-2026', '$2a$10$fUslPcoWmwNyFLvY0pM5GONpdVa2XTALvJPybuIP/MEKccdndjQIq', 'admin'); diff --git a/dependency-reduced-pom.xml b/dependency-reduced-pom.xml new file mode 100644 index 0000000..2f7e64d --- /dev/null +++ b/dependency-reduced-pom.xml @@ -0,0 +1,39 @@ + + + 4.0.0 + cz.kamma + file-share + 1.0.0 + + + + maven-jar-plugin + 3.3.0 + + + + cz.kamma.fileshare.FileShareServer + + + + + + maven-shade-plugin + 3.6.0 + + + package + + shade + + + + + + + + 11 + 11 + UTF-8 + + diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..0ba07fe --- /dev/null +++ b/pom.xml @@ -0,0 +1,65 @@ + + + 4.0.0 + + cz.kamma + file-share + 1.0.0 + jar + + + 11 + 11 + UTF-8 + + + + + org.mariadb.jdbc + mariadb-java-client + 3.5.1 + + + com.zaxxer + HikariCP + 5.1.0 + + + org.mindrot + jbcrypt + 0.4 + + + + + + + org.apache.maven.plugins + maven-jar-plugin + 3.3.0 + + + + cz.kamma.fileshare.FileShareServer + + + + + + org.apache.maven.plugins + maven-shade-plugin + 3.6.0 + + + package + + shade + + + + + + + diff --git a/src/main/java/cz/kamma/fileshare/Config.java b/src/main/java/cz/kamma/fileshare/Config.java new file mode 100644 index 0000000..d22ffec --- /dev/null +++ b/src/main/java/cz/kamma/fileshare/Config.java @@ -0,0 +1,39 @@ +package cz.kamma.fileshare; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Properties; + +public final class Config { + + private final Properties props; + + public Config() throws IOException { + props = new Properties(); + try (InputStream is = Config.class.getClassLoader() + .getResourceAsStream("config.properties")) { + if (is != null) { + props.load(is); + } + } + } + + public String dbHost() { return envOrProps("DB_HOST", props.getProperty("db.host"), "localhost"); } + public int dbPort() { return envOrInt("DB_PORT", props.getProperty("db.port"), 3306); } + public String dbName() { return envOrProps("DB_NAME", props.getProperty("db.name"), "file_share"); } + public String dbUser() { return envOrProps("DB_USER", props.getProperty("db.user"), ""); } + public String dbPassword(){ return envOrProps("DB_PASSWORD", props.getProperty("db.password"), ""); } + public int serverPort(){ return envOrInt("SERVER_PORT", props.getProperty("server.port"), 8080); } + + private String envOrProps(String env, String fileVal, String fallback) { + String v = System.getenv(env); + return v != null && !v.isEmpty() ? v : (fileVal != null ? fileVal : fallback); + } + + private int envOrInt(String env, String fileVal, int fallback) { + String v = System.getenv(env); + if (v != null && !v.isEmpty()) return Integer.parseInt(v); + if (fileVal != null) return Integer.parseInt(fileVal); + return fallback; + } +} diff --git a/src/main/java/cz/kamma/fileshare/ConfigHolder.java b/src/main/java/cz/kamma/fileshare/ConfigHolder.java new file mode 100644 index 0000000..91e1588 --- /dev/null +++ b/src/main/java/cz/kamma/fileshare/ConfigHolder.java @@ -0,0 +1,12 @@ +package cz.kamma.fileshare; + +public final class ConfigHolder { + public static final Config CFG; + static { + try { + CFG = new Config(); + } catch (Exception e) { + throw new ExceptionInInitializerError(e); + } + } +} diff --git a/src/main/java/cz/kamma/fileshare/DbUtil.java b/src/main/java/cz/kamma/fileshare/DbUtil.java new file mode 100644 index 0000000..df0ee69 --- /dev/null +++ b/src/main/java/cz/kamma/fileshare/DbUtil.java @@ -0,0 +1,50 @@ +package cz.kamma.fileshare; + +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; + +import java.sql.Connection; +import java.sql.SQLException; + +public final class DbUtil { + private static HikariDataSource dataSource; + + private DbUtil() {} + + public static synchronized void init(Config cfg) { + if (dataSource != null) return; + + HikariConfig config = new HikariConfig(); + String url = String.format( + "jdbc:mariadb://%s:%d/%s?serverTimezone=UTC&useSSL=false", + cfg.dbHost(), cfg.dbPort(), cfg.dbName()); + + config.setJdbcUrl(url); + config.setUsername(cfg.dbUser()); + config.setPassword(cfg.dbPassword()); + + // Recommended HikariCP settings for MariaDB/MySQL + config.addDataSourceProperty("cachePrepStmts", "true"); + config.addDataSourceProperty("prepStmtCacheSize", "250"); + config.addDataSourceProperty("prepStmtCacheSqlLimit", "2048"); + config.setMaximumPoolSize(10); + config.setMinimumIdle(2); + config.setIdleTimeout(300000); + config.setConnectionTimeout(20000); + + dataSource = new HikariDataSource(config); + } + + public static Connection connect() throws SQLException { + if (dataSource == null) { + throw new SQLException("DbUtil not initialized. Call init(Config) first."); + } + return dataSource.getConnection(); + } + + public static void shutdown() { + if (dataSource != null) { + dataSource.close(); + } + } +} diff --git a/src/main/java/cz/kamma/fileshare/Exchange.java b/src/main/java/cz/kamma/fileshare/Exchange.java new file mode 100644 index 0000000..3620fa3 --- /dev/null +++ b/src/main/java/cz/kamma/fileshare/Exchange.java @@ -0,0 +1,81 @@ +package cz.kamma.fileshare; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; + +public final class Exchange { + private final com.sun.net.httpserver.HttpExchange exchange; + private byte[] _cachedBody = null; + + public Exchange(com.sun.net.httpserver.HttpExchange exchange) { + this.exchange = exchange; + } + + public String header(String name) { + return exchange.getRequestHeaders().getFirst(name); + } + + public String pathParam(String path, String key) { + String seg = "/" + key + "/"; + int idx = path.indexOf(seg); + if (idx >= 0) { + int end = path.indexOf('/', idx + key.length() + 2); + return end >= 0 ? path.substring(idx + key.length() + 2, end) + : path.substring(idx + key.length() + 2); + } + return null; + } + + public InputStream body() { return exchange.getRequestBody(); } + + public byte[] bodyAsBytes() throws IOException { + if (_cachedBody != null) return _cachedBody; + try (InputStream is = body()) { + _cachedBody = is.readAllBytes(); + } + return _cachedBody; + } + + public String bodyAsString() throws IOException { + return new String(bodyAsBytes(), StandardCharsets.UTF_8); + } + + public com.sun.net.httpserver.HttpExchange exchange() { return exchange; } + + public void write(int code, String contentType, String body) throws IOException { + byte[] raw = body.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", contentType + "; charset=utf-8"); + exchange.sendResponseHeaders(code, raw.length); + exchange.getResponseBody().write(raw); + exchange.getResponseBody().flush(); + } + + public void write(int code, String contentType, byte[] body) throws IOException { + exchange.getResponseHeaders().set("Content-Type", contentType); + exchange.sendResponseHeaders(code, body.length); + exchange.getResponseBody().write(body); + exchange.getResponseBody().flush(); + } + + public void writeDownload(int code, String contentType, String filename, InputStream data, long size) + throws IOException { + exchange.getResponseHeaders().set("Content-Type", contentType); + exchange.getResponseHeaders().set("Content-Disposition", + "attachment; filename=\"" + filename + "\""); + exchange.sendResponseHeaders(code, size); + data.transferTo(exchange.getResponseBody()); + exchange.getResponseBody().flush(); + } + + public void writeInline(int code, String contentType, String filename, InputStream data, long size) + throws IOException { + exchange.getResponseHeaders().set("Content-Type", contentType + "; charset=utf-8"); + exchange.getResponseHeaders().set("Content-Disposition", + "inline; filename=\"" + filename + "\""); + exchange.getResponseHeaders().set("Content-Security-Policy", "default-src 'none'"); + exchange.sendResponseHeaders(code, size); + data.transferTo(exchange.getResponseBody()); + exchange.getResponseBody().flush(); + } +} diff --git a/src/main/java/cz/kamma/fileshare/FileShareServer.java b/src/main/java/cz/kamma/fileshare/FileShareServer.java new file mode 100644 index 0000000..c47290b --- /dev/null +++ b/src/main/java/cz/kamma/fileshare/FileShareServer.java @@ -0,0 +1,84 @@ +package cz.kamma.fileshare; + +import cz.kamma.fileshare.handlers.*; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpServer; + +import java.net.InetSocketAddress; + +public class FileShareServer { + + public static void main(String[] args) throws Exception { + Config cfg = ConfigHolder.CFG; + DbUtil.init(cfg); + ResourceUtil resources = new ResourceUtil(); + Router router = new Router(resources); + + router.addRoute("POST", "/auth/login", new LoginHandler()); + + // Admin routes + router.addRoute("GET", "/admin/accounts", new ListAccounts()); + router.addRoute("DELETE", "/admin/accounts/", new AdminDeleteAccount()); + router.addRoute("PUT", "/admin/accounts/", new AdminAccountsPut()); + router.addRoute("GET", "/admin/files", new AdminListFiles()); + router.addRoute("DELETE", "/admin/files/", new AdminDeleteFile()); + router.addRoute("GET", "/admin/otp", new AdminListOtp()); + router.addRoute("DELETE", "/admin/otp/", new AdminDeleteOtp()); + + // Account routes (authenticated) + router.addRoute("POST", "/account", new CreateAccount()); + router.addRoute("GET", "/account", new MyAccountHandler()); + router.addRoute("PUT", "/account/api-key", new UpdateApiKeyHandler()); + router.addRoute("PUT", "/account/password", new ResetPasswordHandler()); + router.addRoute("DELETE", "/account", new RemoveAccount()); + + // File routes + router.addRoute("POST", "/file", new UploadFile()); + router.addRoute("PUT", "/file/", new UpdateFile()); + router.addRoute("DELETE", "/file/", new RemoveFile()); + router.addRoute("POST", "/otp/", new GenerateOtp()); + router.addRoute("GET", "/d/", new PublicDownload()); + router.addRoute("GET", "/files", new ListFiles()); + router.addRoute("GET", "/file/", new DownloadFile()); + + HttpServer server = HttpServer.create( + new InetSocketAddress(cfg.serverPort()), 0); + server.createContext("/", new RequestHandler(router)); + server.setExecutor(java.util.concurrent.Executors.newFixedThreadPool(32)); + server.start(); + + System.out.println("File Share running on port " + cfg.serverPort()); + } + + private static final class RequestHandler implements HttpHandler { + private final Router router; + + RequestHandler(Router router) { this.router = router; } + + @Override + public void handle(com.sun.net.httpserver.HttpExchange exchange) { + try { + String method = exchange.getRequestMethod(); + String path = exchange.getRequestURI().getRawPath(); + + int q = path.indexOf('?'); + if (q >= 0) path = path.substring(0, q); + + router.dispatch(method, path, new Exchange(exchange)); + } catch (Exception e) { + try { + e.printStackTrace(); + byte[] err = "{\"error\":\"Internal server error\"}" + .getBytes(java.nio.charset.StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", + "application/json; charset=utf-8"); + exchange.sendResponseHeaders(500, err.length); + exchange.getResponseBody().write(err); + exchange.getResponseBody().flush(); + } catch (Exception ignore) {} + } finally { + exchange.close(); + } + } + } +} diff --git a/src/main/java/cz/kamma/fileshare/JsonBuilder.java b/src/main/java/cz/kamma/fileshare/JsonBuilder.java new file mode 100644 index 0000000..e3ceacb --- /dev/null +++ b/src/main/java/cz/kamma/fileshare/JsonBuilder.java @@ -0,0 +1,34 @@ +package cz.kamma.fileshare; + +/** + * Chainable helper for building JSON object strings. + */ +public final class JsonBuilder { + private final StringBuilder sb = new StringBuilder(); + private boolean first = true; + + public JsonBuilder() { sb.append('{'); } + + public JsonBuilder string(String key, String value) { + if (!first) sb.append(','); + sb.append('"').append(key).append("\":\"").append(Util.escapeJson(value)).append('"'); + first = false; + return this; + } + + public JsonBuilder number(String key, long value) { + if (!first) sb.append(','); + sb.append('"').append(key).append("\":").append(value); + first = false; + return this; + } + + public JsonBuilder number(String key, int value) { + return number(key, (long) value); + } + + public String toString() { + sb.append('}'); + return sb.toString(); + } +} diff --git a/src/main/java/cz/kamma/fileshare/JsonParse.java b/src/main/java/cz/kamma/fileshare/JsonParse.java new file mode 100644 index 0000000..226b595 --- /dev/null +++ b/src/main/java/cz/kamma/fileshare/JsonParse.java @@ -0,0 +1,27 @@ +package cz.kamma.fileshare; + +/** + * Minimal JSON parser for flat string-valued objects. + */ +public final class JsonParse { + private final String json; + + public JsonParse(String json) { + this.json = json; + } + + public String getString(String key) { + String search = "\"" + key + "\""; + int keyIdx = json.indexOf(search); + if (keyIdx < 0) return null; + int colonIdx = json.indexOf(':', keyIdx + search.length()); + if (colonIdx < 0) return null; + int quoteStart = json.indexOf('"', colonIdx + 1); + if (quoteStart < 0) return null; + int quoteEnd = json.indexOf('"', quoteStart + 1); + if (quoteEnd < 0) return null; + return json.substring(quoteStart + 1, quoteEnd) + .replace("\\\"", "\"") + .replace("\\\\", "\\"); + } +} diff --git a/src/main/java/cz/kamma/fileshare/ResourceUtil.java b/src/main/java/cz/kamma/fileshare/ResourceUtil.java new file mode 100644 index 0000000..f21a74d --- /dev/null +++ b/src/main/java/cz/kamma/fileshare/ResourceUtil.java @@ -0,0 +1,75 @@ +package cz.kamma.fileshare; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.util.HashMap; +import java.util.Map; + +/** + * Reads static assets from the classpath and caches them in memory. + */ +public final class ResourceUtil { + + private final Map cache = new HashMap<>(); + private final Map mime = new HashMap<>(); + + public ResourceUtil() throws IOException { + mime.put("html", "text/html"); + mime.put("css", "text/css"); + mime.put("js", "application/javascript"); + mime.put("png", "image/png"); + mime.put("jpg", "image/jpeg"); + mime.put("gif", "image/gif"); + mime.put("svg", "image/svg+xml"); + mime.put("ico", "image/x-icon"); + mime.put("woff", "font/woff"); + mime.put("woff2","font/woff2"); + mime.put("ttf", "font/ttf"); + mime.put("eot", "application/vnd.ms-fontobject"); + mime.put("json", "application/json"); + mime.put("xml", "application/xml"); + mime.put("txt", "text/plain"); + + URL dirUrl = getClass().getClassLoader().getResource("static"); + if (dirUrl != null && dirUrl.getProtocol().equals("file")) { + scanDir(new java.io.File(dirUrl.getFile()), "static"); + } else { + load("static/index.html"); + } + } + + public byte[] get(String path) { return cache.get(path); } + + public String mimeType(String path) { + int dot = path.lastIndexOf('.'); + if (dot >= 0) { + String m = mime.get(path.substring(dot + 1).toLowerCase()); + if (m != null) return m; + } + return "application/octet-stream"; + } + + private void load(String key) throws IOException { + URL url = getClass().getClassLoader().getResource(key); + if (url == null) return; + try (InputStream is = url.openStream()) { + cache.put(key, is.readAllBytes()); + } + } + + private void scanDir(java.io.File dir, String relPrefix) throws IOException { + java.io.File[] entries = dir.listFiles(); + if (entries == null) return; + for (java.io.File f : entries) { + String rel = relPrefix + "/" + f.getName(); + if (f.isDirectory()) { + scanDir(f, rel); + } else { + try (InputStream is = new java.io.FileInputStream(f)) { + cache.put(rel, is.readAllBytes()); + } + } + } + } +} diff --git a/src/main/java/cz/kamma/fileshare/Router.java b/src/main/java/cz/kamma/fileshare/Router.java new file mode 100644 index 0000000..815224b --- /dev/null +++ b/src/main/java/cz/kamma/fileshare/Router.java @@ -0,0 +1,76 @@ +package cz.kamma.fileshare; + +import cz.kamma.fileshare.handlers.RouteHandler; + +import java.util.ArrayList; +import java.util.List; + +/** + * Routes HTTP requests to handlers by method + path match. + */ +public final class Router { + + static class Route { + final String method; + final String pathPrefix; + final RouteHandler handler; + + Route(String method, String pathPrefix, RouteHandler handler) { + this.method = method; + this.pathPrefix = pathPrefix; + this.handler = handler; + } + } + + private final List routes = new ArrayList<>(); + private final ResourceUtil resources; + + Router(ResourceUtil resources) { + this.resources = resources; + } + + void addRoute(String method, String pathPrefix, RouteHandler handler) { + routes.add(new Route(method, pathPrefix, handler)); + } + + void dispatch(String method, String path, Exchange ex) throws Exception { + // static files + if ("GET".equals(method)) { + if ("/favicon.ico".equals(path)) { + ex.exchange().sendResponseHeaders(204, -1); + return; + } + if ("/".equals(path) || "".equals(path)) { + serveStatic(ex, "static/index.html"); + return; + } + if (path.startsWith("/static/")) { + serveStatic(ex, path.substring(1)); + return; + } + } + + for (Route route : routes) { + if (!route.method.equals(method)) continue; + + // Exact match or prefix match only if prefix ends with '/' + if (path.equals(route.pathPrefix) || + (route.pathPrefix.endsWith("/") && path.startsWith(route.pathPrefix))) { + route.handler.handle(ex, path); + return; + } + } + + ex.write(404, "application/json", "{\"error\":\"not found\"}"); + } + + private void serveStatic(Exchange ex, String resourcePath) throws Exception { + byte[] data = resources.get(resourcePath); + if (data == null) { + ex.write(404, "application/json", "{\"error\":\"not found\"}"); + return; + } + String mime = resources.mimeType(resourcePath); + ex.write(200, mime, data); + } +} diff --git a/src/main/java/cz/kamma/fileshare/Util.java b/src/main/java/cz/kamma/fileshare/Util.java new file mode 100644 index 0000000..577b547 --- /dev/null +++ b/src/main/java/cz/kamma/fileshare/Util.java @@ -0,0 +1,17 @@ +package cz.kamma.fileshare; + +/** + * String escaping utilities shared across handlers. + */ +public final class Util { + + private Util() {} + + public static String escapeJson(String s) { + return s.replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t"); + } +} diff --git a/src/main/java/cz/kamma/fileshare/handlers/AdminAccountsPut.java b/src/main/java/cz/kamma/fileshare/handlers/AdminAccountsPut.java new file mode 100644 index 0000000..0efcaaa --- /dev/null +++ b/src/main/java/cz/kamma/fileshare/handlers/AdminAccountsPut.java @@ -0,0 +1,20 @@ +package cz.kamma.fileshare.handlers; + +import cz.kamma.fileshare.Exchange; +import cz.kamma.fileshare.util.AuthContext; + +/** PUT /admin/accounts/... — Dispatch to password reset (admin only) */ +public class AdminAccountsPut extends AuthenticatedHandler { + + @Override + protected void handleAuthenticated(Exchange ex, String path, AuthContext auth) throws Exception { + if (!auth.isAdmin()) { requireAdmin(ex); return; } + + // /admin/accounts/:id/password + if (path.endsWith("/password")) { + new AdminResetPassword().handleAuthenticated(ex, path, auth); + return; + } + writeError(ex, 404, "not found"); + } +} diff --git a/src/main/java/cz/kamma/fileshare/handlers/AdminDeleteAccount.java b/src/main/java/cz/kamma/fileshare/handlers/AdminDeleteAccount.java new file mode 100644 index 0000000..89ce4d9 --- /dev/null +++ b/src/main/java/cz/kamma/fileshare/handlers/AdminDeleteAccount.java @@ -0,0 +1,41 @@ +package cz.kamma.fileshare.handlers; + +import cz.kamma.fileshare.DbUtil; +import cz.kamma.fileshare.Exchange; +import cz.kamma.fileshare.util.AuthContext; + +import java.sql.Connection; +import java.sql.PreparedStatement; + +/** DELETE /admin/accounts/:id — Delete account by id (admin only) */ +public class AdminDeleteAccount extends AuthenticatedHandler { + + @Override + protected void handleAuthenticated(Exchange ex, String path, AuthContext auth) throws Exception { + if (!auth.isAdmin()) { requireAdmin(ex); return; } + + String idStr = ex.pathParam(path, "accounts"); + if (idStr == null) { + writeError(ex, 400, "missing account id"); + return; + } + long id; + try { id = Long.parseLong(idStr); } catch (NumberFormatException e) { + writeError(ex, 400, "invalid account id"); + return; + } + + String sql = "DELETE FROM accounts WHERE id = ?"; + try (Connection conn = DbUtil.connect()) { + try (PreparedStatement ps = conn.prepareStatement(sql)) { + ps.setLong(1, id); + int rows = ps.executeUpdate(); + if (rows > 0) { + writeEmpty(ex, 204); + } else { + writeError(ex, 404, "account not found"); + } + } + } + } +} diff --git a/src/main/java/cz/kamma/fileshare/handlers/AdminDeleteFile.java b/src/main/java/cz/kamma/fileshare/handlers/AdminDeleteFile.java new file mode 100644 index 0000000..2ca5f79 --- /dev/null +++ b/src/main/java/cz/kamma/fileshare/handlers/AdminDeleteFile.java @@ -0,0 +1,41 @@ +package cz.kamma.fileshare.handlers; + +import cz.kamma.fileshare.DbUtil; +import cz.kamma.fileshare.Exchange; +import cz.kamma.fileshare.util.AuthContext; + +import java.sql.Connection; +import java.sql.PreparedStatement; + +/** DELETE /admin/files/:id — Delete any file (admin only) */ +public class AdminDeleteFile extends AuthenticatedHandler { + + @Override + protected void handleAuthenticated(Exchange ex, String path, AuthContext auth) throws Exception { + if (!auth.isAdmin()) { requireAdmin(ex); return; } + + String fileId = ex.pathParam(path, "files"); + if (fileId == null) { + writeError(ex, 400, "missing file id"); + return; + } + long id; + try { id = Long.parseLong(fileId); } catch (NumberFormatException e) { + writeError(ex, 400, "invalid file id"); + return; + } + + String sql = "DELETE FROM files WHERE id = ?"; + try (Connection conn = DbUtil.connect()) { + try (PreparedStatement ps = conn.prepareStatement(sql)) { + ps.setLong(1, id); + int rows = ps.executeUpdate(); + if (rows > 0) { + writeEmpty(ex, 204); + } else { + writeError(ex, 404, "file not found"); + } + } + } + } +} diff --git a/src/main/java/cz/kamma/fileshare/handlers/AdminDeleteOtp.java b/src/main/java/cz/kamma/fileshare/handlers/AdminDeleteOtp.java new file mode 100644 index 0000000..0001201 --- /dev/null +++ b/src/main/java/cz/kamma/fileshare/handlers/AdminDeleteOtp.java @@ -0,0 +1,41 @@ +package cz.kamma.fileshare.handlers; + +import cz.kamma.fileshare.DbUtil; +import cz.kamma.fileshare.Exchange; +import cz.kamma.fileshare.util.AuthContext; + +import java.sql.Connection; +import java.sql.PreparedStatement; + +/** DELETE /admin/otp/:id — Delete an OTP code (admin only) */ +public class AdminDeleteOtp extends AuthenticatedHandler { + + @Override + protected void handleAuthenticated(Exchange ex, String path, AuthContext auth) throws Exception { + if (!auth.isAdmin()) { requireAdmin(ex); return; } + + String idStr = ex.pathParam(path, "otp"); + if (idStr == null) { + writeError(ex, 400, "missing otp id"); + return; + } + long id; + try { id = Long.parseLong(idStr); } catch (NumberFormatException e) { + writeError(ex, 400, "invalid otp id"); + return; + } + + String sql = "DELETE FROM download_otp WHERE id = ?"; + try (Connection conn = DbUtil.connect()) { + try (PreparedStatement ps = conn.prepareStatement(sql)) { + ps.setLong(1, id); + int rows = ps.executeUpdate(); + if (rows > 0) { + writeEmpty(ex, 204); + } else { + writeError(ex, 404, "OTP not found"); + } + } + } + } +} diff --git a/src/main/java/cz/kamma/fileshare/handlers/AdminListFiles.java b/src/main/java/cz/kamma/fileshare/handlers/AdminListFiles.java new file mode 100644 index 0000000..4ac6e44 --- /dev/null +++ b/src/main/java/cz/kamma/fileshare/handlers/AdminListFiles.java @@ -0,0 +1,49 @@ +package cz.kamma.fileshare.handlers; + +import cz.kamma.fileshare.DbUtil; +import cz.kamma.fileshare.Exchange; +import cz.kamma.fileshare.Util; +import cz.kamma.fileshare.util.AuthContext; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; + +/** GET /admin/files — List all files across all accounts (admin only) */ +public class AdminListFiles extends AuthenticatedHandler { + + @Override + protected void handleAuthenticated(Exchange ex, String path, AuthContext auth) throws Exception { + if (!auth.isAdmin()) { requireAdmin(ex); return; } + + String sql = "SELECT f.id, f.filename, f.mime_type, f.size, f.sha256, " + + "DATE_FORMAT(f.uploaded_at, '%Y-%m-%dT%H:%i:%s') AS uploaded_at, " + + "a.id AS account_id, a.username " + + "FROM files f JOIN accounts a ON f.account_id = a.id " + + "ORDER BY f.uploaded_at DESC"; + StringBuilder body = new StringBuilder("["); + boolean first = true; + try (Connection conn = DbUtil.connect()) { + try (PreparedStatement ps = conn.prepareStatement(sql)) { + try (ResultSet rs = ps.executeQuery()) { + while (rs.next()) { + if (!first) body.append(','); + first = false; + body.append("{") + .append("\"id\":").append(rs.getLong("id")).append(',') + .append("\"filename\":\"").append(Util.escapeJson(rs.getString("filename"))).append("\",") + .append("\"mime_type\":\"").append(Util.escapeJson(rs.getString("mime_type"))).append("\",") + .append("\"size\":").append(rs.getLong("size")).append(',') + .append("\"sha256\":\"").append(Util.escapeJson(rs.getString("sha256"))).append("\",") + .append("\"uploaded_at\":\"").append(Util.escapeJson(rs.getString("uploaded_at"))).append("\",") + .append("\"account_id\":").append(rs.getLong("account_id")).append(',') + .append("\"username\":\"").append(Util.escapeJson(rs.getString("username"))).append('"') + .append('}'); + } + } + } + } + body.append(']'); + ex.write(200, "application/json", body.toString()); + } +} diff --git a/src/main/java/cz/kamma/fileshare/handlers/AdminListOtp.java b/src/main/java/cz/kamma/fileshare/handlers/AdminListOtp.java new file mode 100644 index 0000000..1fdaca4 --- /dev/null +++ b/src/main/java/cz/kamma/fileshare/handlers/AdminListOtp.java @@ -0,0 +1,49 @@ +package cz.kamma.fileshare.handlers; + +import cz.kamma.fileshare.DbUtil; +import cz.kamma.fileshare.Exchange; +import cz.kamma.fileshare.Util; +import cz.kamma.fileshare.util.AuthContext; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; + +/** GET /admin/otp — List all OTP codes (admin only) */ +public class AdminListOtp extends AuthenticatedHandler { + + @Override + protected void handleAuthenticated(Exchange ex, String path, AuthContext auth) throws Exception { + if (!auth.isAdmin()) { requireAdmin(ex); return; } + + String sql = "SELECT o.id, o.file_id, o.code, o.used, o.created_at, o.expires_at, " + + "f.filename, a.username " + + "FROM download_otp o JOIN files f ON o.file_id = f.id " + + "JOIN accounts a ON f.account_id = a.id " + + "ORDER BY o.created_at DESC"; + StringBuilder body = new StringBuilder("["); + boolean first = true; + try (Connection conn = DbUtil.connect()) { + try (PreparedStatement ps = conn.prepareStatement(sql)) { + try (ResultSet rs = ps.executeQuery()) { + while (rs.next()) { + if (!first) body.append(','); + first = false; + body.append("{") + .append("\"id\":").append(rs.getLong("id")).append(',') + .append("\"file_id\":").append(rs.getLong("file_id")).append(',') + .append("\"code\":\"").append(Util.escapeJson(rs.getString("code"))).append("\",") + .append("\"used\":").append(rs.getBoolean("used")).append(',') + .append("\"filename\":\"").append(Util.escapeJson(rs.getString("filename"))).append("\",") + .append("\"username\":\"").append(Util.escapeJson(rs.getString("username"))).append("\",") + .append("\"created_at\":\"").append(Util.escapeJson(rs.getTimestamp("created_at").toString())).append("\",") + .append("\"expires_at\":\"").append(Util.escapeJson(rs.getTimestamp("expires_at").toString())).append('"') + .append('}'); + } + } + } + } + body.append(']'); + ex.write(200, "application/json", body.toString()); + } +} diff --git a/src/main/java/cz/kamma/fileshare/handlers/AdminResetPassword.java b/src/main/java/cz/kamma/fileshare/handlers/AdminResetPassword.java new file mode 100644 index 0000000..84fafab --- /dev/null +++ b/src/main/java/cz/kamma/fileshare/handlers/AdminResetPassword.java @@ -0,0 +1,55 @@ +package cz.kamma.fileshare.handlers; + +import cz.kamma.fileshare.DbUtil; +import cz.kamma.fileshare.Exchange; +import cz.kamma.fileshare.JsonBuilder; +import cz.kamma.fileshare.JsonParse; +import cz.kamma.fileshare.util.AuthContext; +import org.mindrot.jbcrypt.BCrypt; + +import java.sql.Connection; +import java.sql.PreparedStatement; + +/** PUT /admin/accounts/:id/password — Reset account password (admin only) */ +public class AdminResetPassword extends AuthenticatedHandler { + + @Override + protected void handleAuthenticated(Exchange ex, String path, AuthContext auth) throws Exception { + if (!auth.isAdmin()) { requireAdmin(ex); return; } + + String idStr = ex.pathParam(path, "accounts"); + if (idStr == null) { + writeError(ex, 400, "missing account id"); + return; + } + long id; + try { id = Long.parseLong(idStr); } catch (NumberFormatException e) { + writeError(ex, 400, "invalid account id"); + return; + } + + String raw = ex.bodyAsString(); + JsonParse body = new JsonParse(raw); + String password = body.getString("password"); + if (password == null || password.length() < 8 || password.length() > 72) { + writeError(ex, 400, "password must be 8-72 characters"); + return; + } + + String passwordHash = BCrypt.hashpw(password, BCrypt.gensalt()); + String sql = "UPDATE accounts SET password_hash = ? WHERE id = ?"; + try (Connection conn = DbUtil.connect()) { + try (PreparedStatement ps = conn.prepareStatement(sql)) { + ps.setString(1, passwordHash); + ps.setLong(2, id); + int rows = ps.executeUpdate(); + if (rows == 0) { + writeError(ex, 404, "account not found"); + return; + } + } + } + + writeJson(ex, 200, new JsonBuilder().string("message", "password updated")); + } +} diff --git a/src/main/java/cz/kamma/fileshare/handlers/AuthenticatedHandler.java b/src/main/java/cz/kamma/fileshare/handlers/AuthenticatedHandler.java new file mode 100644 index 0000000..c35682c --- /dev/null +++ b/src/main/java/cz/kamma/fileshare/handlers/AuthenticatedHandler.java @@ -0,0 +1,59 @@ +package cz.kamma.fileshare.handlers; + +import cz.kamma.fileshare.DbUtil; +import cz.kamma.fileshare.Exchange; +import cz.kamma.fileshare.util.AuthContext; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; + +/** + * Template method pattern for authenticated routes. + * Handles API key extraction and validation, then delegates to {@link #handleAuthenticated}. + */ +public abstract class AuthenticatedHandler extends BaseHandler { + + public void handle(Exchange ex, String path) throws Exception { + String apiKey = ex.header("X-Api-Key"); + if (apiKey == null) { + writeError(ex, 401, "X-Api-Key header required"); + return; + } + Resolved resolved = resolveApiKey(apiKey); + if (resolved == null) { + writeError(ex, 403, "invalid api key"); + return; + } + handleAuthenticated(ex, path, new AuthContext(resolved.accountId, apiKey, resolved.role)); + } + + protected abstract void handleAuthenticated(Exchange ex, String path, AuthContext auth) throws Exception; + + protected boolean requireAdmin(Exchange ex) throws Exception { + writeError(ex, 403, "admin access required"); + return false; + } + + static Resolved resolveApiKey(String apiKey) throws SQLException { + String sql = "SELECT id, role FROM accounts WHERE api_key = ?"; + try (Connection conn = DbUtil.connect()) { + try (PreparedStatement ps = conn.prepareStatement(sql)) { + ps.setString(1, apiKey); + try (ResultSet rs = ps.executeQuery()) { + if (rs.next()) { + return new Resolved(String.valueOf(rs.getLong("id")), rs.getString("role")); + } + } + } + } + return null; + } + + static final class Resolved { + final String accountId; + final String role; + Resolved(String accountId, String role) { this.accountId = accountId; this.role = role; } + } +} diff --git a/src/main/java/cz/kamma/fileshare/handlers/BaseHandler.java b/src/main/java/cz/kamma/fileshare/handlers/BaseHandler.java new file mode 100644 index 0000000..76c6871 --- /dev/null +++ b/src/main/java/cz/kamma/fileshare/handlers/BaseHandler.java @@ -0,0 +1,22 @@ +package cz.kamma.fileshare.handlers; + +import cz.kamma.fileshare.Exchange; +import cz.kamma.fileshare.JsonBuilder; + +/** + * Provides common response helpers for all handlers. + */ +public abstract class BaseHandler implements RouteHandler { + + protected void writeError(Exchange ex, int code, String message) throws Exception { + ex.write(code, "application/json", new JsonBuilder().string("error", message).toString()); + } + + protected void writeEmpty(Exchange ex, int code) throws Exception { + ex.write(code, "application/json", "{}"); + } + + protected void writeJson(Exchange ex, int code, JsonBuilder json) throws Exception { + ex.write(code, "application/json", json.toString()); + } +} diff --git a/src/main/java/cz/kamma/fileshare/handlers/CreateAccount.java b/src/main/java/cz/kamma/fileshare/handlers/CreateAccount.java new file mode 100644 index 0000000..1a42b60 --- /dev/null +++ b/src/main/java/cz/kamma/fileshare/handlers/CreateAccount.java @@ -0,0 +1,74 @@ +package cz.kamma.fileshare.handlers; + +import cz.kamma.fileshare.DbUtil; +import cz.kamma.fileshare.Exchange; +import cz.kamma.fileshare.JsonBuilder; +import cz.kamma.fileshare.JsonParse; +import cz.kamma.fileshare.util.AuthContext; +import cz.kamma.fileshare.util.KeyUtil; +import org.mindrot.jbcrypt.BCrypt; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; + +/** POST /account — Create a new account (admin only) */ +public class CreateAccount extends AuthenticatedHandler { + + @Override + protected void handleAuthenticated(Exchange ex, String path, AuthContext auth) throws Exception { + if (!auth.isAdmin()) { + requireAdmin(ex); + return; + } + + String raw = ex.bodyAsString(); + JsonParse body = new JsonParse(raw); + String username = body.getString("username"); + String password = body.getString("password"); + String role = body.getString("role"); + + if (username == null || password == null) { + writeError(ex, 400, "username and password required"); + return; + } + username = username.trim(); + if (username.length() < 3 || username.length() > 128) { + writeError(ex, 400, "username must be 3-128 characters"); + return; + } + if (password.length() < 8 || password.length() > 72) { + writeError(ex, 400, "password must be 8-72 characters"); + return; + } + if (role == null || role.isEmpty()) role = "user"; + if (!role.equals("user") && !role.equals("admin")) { + writeError(ex, 400, "role must be user or admin"); + return; + } + + String passwordHash = BCrypt.hashpw(password, BCrypt.gensalt()); + String apiKey = KeyUtil.generate(); + + String sql = "INSERT INTO accounts (username, api_key, password_hash, role) VALUES (?, ?, ?, ?)"; + try (Connection conn = DbUtil.connect()) { + try (PreparedStatement ps = conn.prepareStatement(sql, + PreparedStatement.RETURN_GENERATED_KEYS)) { + ps.setString(1, username); + ps.setString(2, apiKey); + ps.setString(3, passwordHash); + ps.setString(4, role); + ps.executeUpdate(); + try (ResultSet rs = ps.getGeneratedKeys()) { + if (rs.next()) { + writeJson(ex, 201, new JsonBuilder() + .number("id", rs.getLong(1)) + .string("username", username) + .string("api_key", apiKey) + .string("role", role)); + } + } + } + } + } +} diff --git a/src/main/java/cz/kamma/fileshare/handlers/DownloadFile.java b/src/main/java/cz/kamma/fileshare/handlers/DownloadFile.java new file mode 100644 index 0000000..9b40406 --- /dev/null +++ b/src/main/java/cz/kamma/fileshare/handlers/DownloadFile.java @@ -0,0 +1,52 @@ +package cz.kamma.fileshare.handlers; + +import cz.kamma.fileshare.DbUtil; +import cz.kamma.fileshare.Exchange; +import cz.kamma.fileshare.util.AuthContext; +import cz.kamma.fileshare.util.FileUtil; + +import java.io.ByteArrayInputStream; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; + +/** GET /file/:id — Download or view file (authenticated) */ +public class DownloadFile extends AuthenticatedHandler { + + protected void handleAuthenticated(Exchange ex, String path, AuthContext auth) throws Exception { + String fileId = ex.pathParam(path, "file"); + if (fileId == null) { + writeError(ex, 400, "missing file id"); + return; + } + + String sql = "SELECT id, filename, mime_type, size, data FROM files WHERE id = ? AND account_id = ?"; + try (Connection conn = DbUtil.connect()) { + try (PreparedStatement ps = conn.prepareStatement(sql)) { + try { + ps.setLong(1, Long.parseLong(fileId)); + } catch (NumberFormatException e) { + writeError(ex, 400, "invalid file id format"); + return; + } + ps.setLong(2, Long.parseLong(auth.accountId())); + try (ResultSet rs = ps.executeQuery()) { + if (!rs.next()) { + writeError(ex, 404, "file not found"); + return; + } + String filename = rs.getString("filename"); + String mimeType = rs.getString("mime_type"); + long size = rs.getLong("size"); + try (java.io.InputStream data = rs.getBinaryStream("data")) { + if (FileUtil.isTextFile(filename, mimeType)) { + ex.writeInline(200, mimeType, filename, data, size); + } else { + ex.writeDownload(200, mimeType, filename, data, size); + } + } + } + } + } + } +} diff --git a/src/main/java/cz/kamma/fileshare/handlers/GenerateOtp.java b/src/main/java/cz/kamma/fileshare/handlers/GenerateOtp.java new file mode 100644 index 0000000..c36f7ac --- /dev/null +++ b/src/main/java/cz/kamma/fileshare/handlers/GenerateOtp.java @@ -0,0 +1,111 @@ +package cz.kamma.fileshare.handlers; + +import cz.kamma.fileshare.DbUtil; +import cz.kamma.fileshare.Exchange; +import cz.kamma.fileshare.JsonBuilder; +import cz.kamma.fileshare.util.AuthContext; + +import java.security.SecureRandom; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.time.Instant; +import java.time.format.DateTimeFormatter; + +/** POST /otp/:id — Generate a one-time download code for a file */ +public class GenerateOtp extends AuthenticatedHandler { + + static final String CHARSET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + static final int CODE_LENGTH = 5; + static final long TTL_SECONDS = 86400; // 24 hours + static final SecureRandom RANDOM = new SecureRandom(); + + @Override + protected void handleAuthenticated(Exchange ex, String path, AuthContext auth) throws Exception { + String fileId = ex.pathParam(path, "otp"); + if (fileId == null) { + writeError(ex, 400, "missing file id"); + return; + } + + // Verify file exists and belongs to account + Long fileIdLong; + try { + fileIdLong = Long.parseLong(fileId); + } catch (NumberFormatException e) { + writeError(ex, 400, "invalid file id"); + return; + } + + String verifySql = "SELECT id, filename FROM files WHERE id = ? AND account_id = ? LIMIT 1"; + String filename; + try (Connection conn = DbUtil.connect()) { + try (PreparedStatement ps = conn.prepareStatement(verifySql)) { + ps.setLong(1, fileIdLong); + ps.setLong(2, Long.parseLong(auth.accountId())); + try (var rs = ps.executeQuery()) { + if (!rs.next()) { + writeError(ex, 404, "file not found"); + return; + } + filename = rs.getString("filename"); + } + } + } + + // Generate unique code + String code; + Instant now = Instant.now(); + Instant expires = now.plusSeconds(TTL_SECONDS); + try (Connection conn = DbUtil.connect()) { + do { + code = generateCode(); + } while (isCodeTaken(conn, code)); + + String insertSql = "INSERT INTO download_otp (file_id, code, used, created_at, expires_at) VALUES (?, ?, 0, ?, ?)"; + try (PreparedStatement ps = conn.prepareStatement(insertSql)) { + ps.setLong(1, fileIdLong); + ps.setString(2, code); + ps.setTimestamp(3, java.sql.Timestamp.from(now)); + ps.setTimestamp(4, java.sql.Timestamp.from(expires)); + ps.executeUpdate(); + } + } + + String otpLink = buildOtpLink(ex, filename, code); + String expiresStr = DateTimeFormatter.ISO_INSTANT.format(expires); + + JsonBuilder json = new JsonBuilder() + .string("otp_link", otpLink) + .string("code", code) + .string("expires_at", expiresStr); + + writeJson(ex, 201, json); + } + + static String generateCode() { + StringBuilder sb = new StringBuilder(CODE_LENGTH); + for (int i = 0; i < CODE_LENGTH; i++) { + sb.append(CHARSET.charAt(RANDOM.nextInt(CHARSET.length()))); + } + return sb.toString(); + } + + static boolean isCodeTaken(Connection conn, String code) throws Exception { + try (PreparedStatement ps = conn.prepareStatement( + "SELECT 1 FROM download_otp WHERE code = ? LIMIT 1")) { + ps.setString(1, code); + try (var rs = ps.executeQuery()) { + return rs.next(); + } + } + } + + static String buildOtpLink(Exchange ex, String filename, String code) { + String host = ex.header("Host"); + if (host == null || host.isEmpty()) host = "localhost:8080"; + String scheme = ex.header("X-Forwarded-Proto"); + if (scheme == null) scheme = "http"; + return scheme + "://" + host + "/d/" + + java.net.URLEncoder.encode(filename, java.nio.charset.StandardCharsets.UTF_8) + "/" + code; + } +} diff --git a/src/main/java/cz/kamma/fileshare/handlers/ListAccounts.java b/src/main/java/cz/kamma/fileshare/handlers/ListAccounts.java new file mode 100644 index 0000000..20ee57b --- /dev/null +++ b/src/main/java/cz/kamma/fileshare/handlers/ListAccounts.java @@ -0,0 +1,40 @@ +package cz.kamma.fileshare.handlers; + +import cz.kamma.fileshare.DbUtil; +import cz.kamma.fileshare.Exchange; +import cz.kamma.fileshare.JsonBuilder; +import cz.kamma.fileshare.util.AuthContext; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; + +/** GET /admin/accounts — List all accounts (admin only) */ +public class ListAccounts extends AuthenticatedHandler { + + @Override + protected void handleAuthenticated(Exchange ex, String path, AuthContext auth) throws Exception { + if (!auth.isAdmin()) { requireAdmin(ex); return; } + + String sql = "SELECT id, username, role, created_at FROM accounts ORDER BY id DESC"; + StringBuilder sb = new StringBuilder("["); + boolean first = true; + try (Connection conn = DbUtil.connect()) { + try (PreparedStatement ps = conn.prepareStatement(sql)) { + try (ResultSet rs = ps.executeQuery()) { + while (rs.next()) { + if (!first) sb.append(','); + sb.append(new JsonBuilder() + .number("id", rs.getLong("id")) + .string("username", rs.getString("username")) + .string("role", rs.getString("role")) + .string("created_at", rs.getTimestamp("created_at").toString())); + first = false; + } + } + } + } + sb.append(']'); + ex.write(200, "application/json", sb.toString()); + } +} diff --git a/src/main/java/cz/kamma/fileshare/handlers/ListFiles.java b/src/main/java/cz/kamma/fileshare/handlers/ListFiles.java new file mode 100644 index 0000000..4037c7d --- /dev/null +++ b/src/main/java/cz/kamma/fileshare/handlers/ListFiles.java @@ -0,0 +1,43 @@ +package cz.kamma.fileshare.handlers; + +import cz.kamma.fileshare.DbUtil; +import cz.kamma.fileshare.Exchange; +import cz.kamma.fileshare.Util; +import cz.kamma.fileshare.util.AuthContext; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; + +/** GET /files — List all files (authenticated) */ +public class ListFiles extends AuthenticatedHandler { + + protected void handleAuthenticated(Exchange ex, String path, AuthContext auth) throws Exception { + String sql = "SELECT id, filename, mime_type, size, sha256, " + + "DATE_FORMAT(uploaded_at, '%Y-%m-%dT%H:%i:%s') AS uploaded_at " + + "FROM files WHERE account_id = ? ORDER BY uploaded_at DESC"; + StringBuilder body = new StringBuilder("["); + boolean first = true; + try (Connection conn = DbUtil.connect()) { + try (PreparedStatement ps = conn.prepareStatement(sql)) { + ps.setLong(1, Long.parseLong(auth.accountId())); + try (ResultSet rs = ps.executeQuery()) { + while (rs.next()) { + if (!first) body.append(','); + first = false; + body.append("{") + .append("\"id\":").append(rs.getLong("id")).append(',') + .append("\"filename\":\"").append(Util.escapeJson(rs.getString("filename"))).append("\",") + .append("\"mime_type\":\"").append(Util.escapeJson(rs.getString("mime_type"))).append("\",") + .append("\"size\":").append(rs.getLong("size")).append(',') + .append("\"sha256\":\"").append(Util.escapeJson(rs.getString("sha256"))).append("\",") + .append("\"uploaded_at\":\"").append(Util.escapeJson(rs.getString("uploaded_at"))).append('"') + .append('}'); + } + } + } + } + body.append(']'); + ex.write(200, "application/json", body.toString()); + } +} diff --git a/src/main/java/cz/kamma/fileshare/handlers/LoginHandler.java b/src/main/java/cz/kamma/fileshare/handlers/LoginHandler.java new file mode 100644 index 0000000..f79932b --- /dev/null +++ b/src/main/java/cz/kamma/fileshare/handlers/LoginHandler.java @@ -0,0 +1,68 @@ +package cz.kamma.fileshare.handlers; + +import cz.kamma.fileshare.DbUtil; +import cz.kamma.fileshare.Exchange; +import cz.kamma.fileshare.JsonBuilder; +import cz.kamma.fileshare.JsonParse; +import org.mindrot.jbcrypt.BCrypt; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; + +/** POST /auth/login — Login with username/password */ +public class LoginHandler extends BaseHandler { + + @Override + public void handle(Exchange ex, String path) throws Exception { + String raw = ex.bodyAsString(); + JsonParse body = new JsonParse(raw); + String username = body.getString("username"); + String password = body.getString("password"); + + if (username == null || password == null || + username.trim().isEmpty() || password.isEmpty()) { + writeError(ex, 400, "username and password required"); + return; + } + + String sql = "SELECT api_key, password_hash, role FROM accounts WHERE username = ? LIMIT 1"; + try (Connection conn = DbUtil.connect()) { + try (PreparedStatement ps = conn.prepareStatement(sql)) { + ps.setString(1, username.trim()); + try (ResultSet rs = ps.executeQuery()) { + if (!rs.next()) { + writeError(ex, 401, "invalid credentials"); + return; + } + String apiKey = rs.getString("api_key"); + String passwordHash = rs.getString("password_hash"); + String role = rs.getString("role"); + + if (passwordHash == null || !passwordHash.startsWith("$2a$")) { + writeError(ex, 503, "account password not properly set, contact admin"); + return; + } + + boolean matches; + try { + matches = BCrypt.checkpw(password, passwordHash); + } catch (Exception e) { + writeError(ex, 503, "account password hash is invalid, contact admin"); + return; + } + + if (!matches) { + writeError(ex, 401, "invalid credentials"); + return; + } + + writeJson(ex, 200, new JsonBuilder() + .string("api_key", apiKey) + .string("username", username.trim()) + .string("role", role)); + } + } + } + } +} diff --git a/src/main/java/cz/kamma/fileshare/handlers/MyAccountHandler.java b/src/main/java/cz/kamma/fileshare/handlers/MyAccountHandler.java new file mode 100644 index 0000000..1e6a302 --- /dev/null +++ b/src/main/java/cz/kamma/fileshare/handlers/MyAccountHandler.java @@ -0,0 +1,35 @@ +package cz.kamma.fileshare.handlers; + +import cz.kamma.fileshare.DbUtil; +import cz.kamma.fileshare.Exchange; +import cz.kamma.fileshare.JsonBuilder; +import cz.kamma.fileshare.util.AuthContext; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; + +/** GET /account — Return current account info (authenticated) */ +public class MyAccountHandler extends AuthenticatedHandler { + + @Override + protected void handleAuthenticated(Exchange ex, String path, AuthContext auth) throws Exception { + String sql = "SELECT id, username, api_key, role FROM accounts WHERE id = ? LIMIT 1"; + try (Connection conn = DbUtil.connect()) { + try (PreparedStatement ps = conn.prepareStatement(sql)) { + ps.setLong(1, Long.parseLong(auth.accountId())); + try (ResultSet rs = ps.executeQuery()) { + if (!rs.next()) { + writeError(ex, 404, "account not found"); + return; + } + writeJson(ex, 200, new JsonBuilder() + .number("id", rs.getLong("id")) + .string("username", rs.getString("username")) + .string("api_key", rs.getString("api_key")) + .string("role", rs.getString("role"))); + } + } + } + } +} diff --git a/src/main/java/cz/kamma/fileshare/handlers/PublicDownload.java b/src/main/java/cz/kamma/fileshare/handlers/PublicDownload.java new file mode 100644 index 0000000..16c6e8b --- /dev/null +++ b/src/main/java/cz/kamma/fileshare/handlers/PublicDownload.java @@ -0,0 +1,96 @@ +package cz.kamma.fileshare.handlers; + +import cz.kamma.fileshare.DbUtil; +import cz.kamma.fileshare.Exchange; + +import java.io.ByteArrayInputStream; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; + +/** GET /d/filename/code — Public file download (OTP or legacy API key) */ +public class PublicDownload extends BaseHandler { + + static final String OTP_PATTERN = "[A-Z]{5}"; + + public void handle(Exchange ex, String path) throws Exception { + int lastSlash = path.lastIndexOf('/'); + if (lastSlash <= 3) { + writeError(ex, 400, "invalid path"); + return; + } + String code = path.substring(lastSlash + 1); + String filename = path.substring(3, lastSlash); + + if (code.matches(OTP_PATTERN)) { + handleOtp(ex, filename, code); + } else { + handleApiKey(ex, filename, code); + } + } + + void handleOtp(Exchange ex, String filename, String code) throws Exception { + String sql = "SELECT o.id AS otp_id, o.used, o.expires_at, f.filename, f.mime_type, f.data " + + "FROM download_otp o JOIN files f ON o.file_id = f.id " + + "WHERE o.code = ? AND f.filename = ? LIMIT 1"; + try (Connection conn = DbUtil.connect()) { + try (PreparedStatement ps = conn.prepareStatement(sql)) { + ps.setString(1, code); + ps.setString(2, filename); + try (ResultSet rs = ps.executeQuery()) { + if (!rs.next()) { + writeError(ex, 404, "file not found or invalid code"); + return; + } + boolean used = rs.getBoolean("used"); + java.sql.Timestamp expiresAt = rs.getTimestamp("expires_at"); + if (used || expiresAt.getTime() < System.currentTimeMillis()) { + writeError(ex, 410, "code already used or expired"); + return; + } + String fname = rs.getString("filename"); + String mimeType = rs.getString("mime_type"); + byte[] data = rs.getBytes("data"); + + // Mark as used + long otpId = rs.getLong("otp_id"); + markUsed(conn, otpId); + + ex.writeDownload(200, mimeType, fname, + new ByteArrayInputStream(data), data.length); + } + } + } + } + + void handleApiKey(Exchange ex, String filename, String apiKey) throws Exception { + String sql = "SELECT f.filename, f.mime_type, f.data FROM files f " + + "JOIN accounts a ON f.account_id = a.id " + + "WHERE f.filename = ? AND a.api_key = ? LIMIT 1"; + try (Connection conn = DbUtil.connect()) { + try (PreparedStatement ps = conn.prepareStatement(sql)) { + ps.setString(1, filename); + ps.setString(2, apiKey); + try (ResultSet rs = ps.executeQuery()) { + if (!rs.next()) { + writeError(ex, 404, "file not found"); + return; + } + String fname = rs.getString("filename"); + String mimeType = rs.getString("mime_type"); + byte[] data = rs.getBytes("data"); + ex.writeDownload(200, mimeType, fname, + new ByteArrayInputStream(data), data.length); + } + } + } + } + + static void markUsed(Connection conn, long otpId) throws Exception { + try (PreparedStatement ps = conn.prepareStatement( + "UPDATE download_otp SET used = 1 WHERE id = ?")) { + ps.setLong(1, otpId); + ps.executeUpdate(); + } + } +} diff --git a/src/main/java/cz/kamma/fileshare/handlers/RemoveAccount.java b/src/main/java/cz/kamma/fileshare/handlers/RemoveAccount.java new file mode 100644 index 0000000..d7fd61f --- /dev/null +++ b/src/main/java/cz/kamma/fileshare/handlers/RemoveAccount.java @@ -0,0 +1,27 @@ +package cz.kamma.fileshare.handlers; + +import cz.kamma.fileshare.DbUtil; +import cz.kamma.fileshare.Exchange; +import cz.kamma.fileshare.util.AuthContext; + +import java.sql.Connection; +import java.sql.PreparedStatement; + +/** DELETE /account — Remove account (authenticated) */ +public class RemoveAccount extends AuthenticatedHandler { + + protected void handleAuthenticated(Exchange ex, String path, AuthContext auth) throws Exception { + String sql = "DELETE FROM accounts WHERE api_key = ?"; + try (Connection conn = DbUtil.connect()) { + try (PreparedStatement ps = conn.prepareStatement(sql)) { + ps.setString(1, auth.apiKey()); + int rows = ps.executeUpdate(); + if (rows > 0) { + writeEmpty(ex, 204); + } else { + writeError(ex, 404, "account not found"); + } + } + } + } +} diff --git a/src/main/java/cz/kamma/fileshare/handlers/RemoveFile.java b/src/main/java/cz/kamma/fileshare/handlers/RemoveFile.java new file mode 100644 index 0000000..f81e869 --- /dev/null +++ b/src/main/java/cz/kamma/fileshare/handlers/RemoveFile.java @@ -0,0 +1,39 @@ +package cz.kamma.fileshare.handlers; + +import cz.kamma.fileshare.DbUtil; +import cz.kamma.fileshare.Exchange; +import cz.kamma.fileshare.util.AuthContext; + +import java.sql.Connection; +import java.sql.PreparedStatement; + +/** DELETE /file/:id — Remove a file (authenticated) */ +public class RemoveFile extends AuthenticatedHandler { + + protected void handleAuthenticated(Exchange ex, String path, AuthContext auth) throws Exception { + String fileId = ex.pathParam(path, "file"); + if (fileId == null) { + writeError(ex, 400, "missing file id"); + return; + } + + String sql = "DELETE FROM files WHERE id = ? AND account_id = ?"; + try (Connection conn = DbUtil.connect()) { + try (PreparedStatement ps = conn.prepareStatement(sql)) { + try { + ps.setLong(1, Long.parseLong(fileId)); + } catch (NumberFormatException e) { + writeError(ex, 400, "invalid file id format"); + return; + } + ps.setLong(2, Long.parseLong(auth.accountId())); + int rows = ps.executeUpdate(); + if (rows > 0) { + writeEmpty(ex, 204); + } else { + writeError(ex, 404, "file not found"); + } + } + } + } +} diff --git a/src/main/java/cz/kamma/fileshare/handlers/ResetPasswordHandler.java b/src/main/java/cz/kamma/fileshare/handlers/ResetPasswordHandler.java new file mode 100644 index 0000000..a0af5ef --- /dev/null +++ b/src/main/java/cz/kamma/fileshare/handlers/ResetPasswordHandler.java @@ -0,0 +1,43 @@ +package cz.kamma.fileshare.handlers; + +import cz.kamma.fileshare.DbUtil; +import cz.kamma.fileshare.Exchange; +import cz.kamma.fileshare.JsonBuilder; +import cz.kamma.fileshare.JsonParse; +import org.mindrot.jbcrypt.BCrypt; + +import java.sql.Connection; +import java.sql.PreparedStatement; + +/** PUT /account/password — Reset password (authenticated) */ +public class ResetPasswordHandler extends AuthenticatedHandler { + + @Override + protected void handleAuthenticated(Exchange ex, String path, cz.kamma.fileshare.util.AuthContext auth) throws Exception { + String raw = ex.bodyAsString(); + JsonParse body = new JsonParse(raw); + String password = body.getString("password"); + + if (password == null || password.length() < 8 || password.length() > 72) { + writeError(ex, 400, "password must be 8-72 characters"); + return; + } + + String passwordHash = BCrypt.hashpw(password, BCrypt.gensalt()); + + String sql = "UPDATE accounts SET password_hash = ? WHERE id = ?"; + try (Connection conn = DbUtil.connect()) { + try (PreparedStatement ps = conn.prepareStatement(sql)) { + ps.setString(1, passwordHash); + ps.setLong(2, Long.parseLong(auth.accountId())); + int rows = ps.executeUpdate(); + if (rows == 0) { + writeError(ex, 404, "account not found"); + return; + } + } + } + + writeJson(ex, 200, new JsonBuilder().string("message", "password updated")); + } +} diff --git a/src/main/java/cz/kamma/fileshare/handlers/RouteHandler.java b/src/main/java/cz/kamma/fileshare/handlers/RouteHandler.java new file mode 100644 index 0000000..c987f37 --- /dev/null +++ b/src/main/java/cz/kamma/fileshare/handlers/RouteHandler.java @@ -0,0 +1,12 @@ +package cz.kamma.fileshare.handlers; + +import cz.kamma.fileshare.Exchange; + +/** + * Single interface for all route handlers. + * Handlers that don't need path parameters can ignore the {@code path} argument. + */ +@FunctionalInterface +public interface RouteHandler { + void handle(Exchange ex, String path) throws Exception; +} diff --git a/src/main/java/cz/kamma/fileshare/handlers/UpdateApiKeyHandler.java b/src/main/java/cz/kamma/fileshare/handlers/UpdateApiKeyHandler.java new file mode 100644 index 0000000..c41b973 --- /dev/null +++ b/src/main/java/cz/kamma/fileshare/handlers/UpdateApiKeyHandler.java @@ -0,0 +1,31 @@ +package cz.kamma.fileshare.handlers; + +import cz.kamma.fileshare.DbUtil; +import cz.kamma.fileshare.Exchange; +import cz.kamma.fileshare.JsonBuilder; +import cz.kamma.fileshare.util.AuthContext; +import cz.kamma.fileshare.util.KeyUtil; + +import java.sql.Connection; +import java.sql.PreparedStatement; + +/** PUT /account/api-key — Regenerate API key (authenticated) */ +public class UpdateApiKeyHandler extends AuthenticatedHandler { + + @Override + protected void handleAuthenticated(Exchange ex, String path, AuthContext auth) throws Exception { + String newApiKey = KeyUtil.generate(); + + String sql = "UPDATE accounts SET api_key = ? WHERE id = ?"; + try (Connection conn = DbUtil.connect()) { + try (PreparedStatement ps = conn.prepareStatement(sql)) { + ps.setString(1, newApiKey); + ps.setLong(2, Long.parseLong(auth.accountId())); + ps.executeUpdate(); + } + } + + writeJson(ex, 200, new JsonBuilder() + .string("api_key", newApiKey)); + } +} diff --git a/src/main/java/cz/kamma/fileshare/handlers/UpdateFile.java b/src/main/java/cz/kamma/fileshare/handlers/UpdateFile.java new file mode 100644 index 0000000..00ac507 --- /dev/null +++ b/src/main/java/cz/kamma/fileshare/handlers/UpdateFile.java @@ -0,0 +1,83 @@ +package cz.kamma.fileshare.handlers; + +import cz.kamma.fileshare.DbUtil; +import cz.kamma.fileshare.Exchange; +import cz.kamma.fileshare.JsonBuilder; +import cz.kamma.fileshare.util.AuthContext; +import cz.kamma.fileshare.util.FileUtil; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.sql.Connection; +import java.sql.PreparedStatement; + +/** PUT /file/:id — Update file contents (authenticated) */ +public class UpdateFile extends AuthenticatedHandler { + + protected void handleAuthenticated(Exchange ex, String path, AuthContext auth) throws Exception { + String fileId = ex.pathParam(path, "file"); + if (fileId == null) { + writeError(ex, 400, "missing file id"); + return; + } + + String contentType = ex.header("Content-Type"); + if (contentType == null) contentType = "application/octet-stream"; + + long size; + String sha256; + try (InputStream is = ex.body()) { + java.nio.file.Path tempFile = java.nio.file.Files.createTempFile("update-", ".tmp"); + try { + long bytesRead = 0; + java.security.MessageDigest digest = java.security.MessageDigest.getInstance("SHA-256"); + try (java.io.OutputStream os = java.nio.file.Files.newOutputStream(tempFile)) { + byte[] buffer = new byte[8192]; + int n; + while ((n = is.read(buffer)) != -1) { + os.write(buffer, 0, n); + digest.update(buffer, 0, n); + bytesRead += n; + } + } + size = bytesRead; + if (size == 0) { + java.nio.file.Files.delete(tempFile); + writeError(ex, 400, "empty file body"); + return; + } + byte[] shaBytes = digest.digest(); + StringBuilder hex = new StringBuilder(64); + for (byte b : shaBytes) hex.append(String.format("%02x", b)); + sha256 = hex.toString(); + + String sql = "UPDATE files SET size = ?, sha256 = ?, data = ?, uploaded_at = CURRENT_TIMESTAMP " + + "WHERE id = ? AND account_id = ?"; + try (Connection conn = DbUtil.connect()) { + try (PreparedStatement ps = conn.prepareStatement(sql)) { + ps.setLong(1, size); + ps.setString(2, sha256); + ps.setBinaryStream(3, java.nio.file.Files.newInputStream(tempFile), size); + try { + ps.setLong(4, Long.parseLong(fileId)); + } catch (NumberFormatException e) { + writeError(ex, 400, "invalid file id format"); + return; + } + ps.setLong(5, Long.parseLong(auth.accountId())); + int rows = ps.executeUpdate(); + if (rows > 0) { + writeJson(ex, 200, new JsonBuilder() + .number("id", Long.parseLong(fileId)) + .string("sha256", sha256)); + } else { + writeError(ex, 404, "file not found"); + } + } + } + } finally { + java.nio.file.Files.deleteIfExists(tempFile); + } + } + } +} diff --git a/src/main/java/cz/kamma/fileshare/handlers/UploadFile.java b/src/main/java/cz/kamma/fileshare/handlers/UploadFile.java new file mode 100644 index 0000000..4b7c42c --- /dev/null +++ b/src/main/java/cz/kamma/fileshare/handlers/UploadFile.java @@ -0,0 +1,86 @@ +package cz.kamma.fileshare.handlers; + +import cz.kamma.fileshare.DbUtil; +import cz.kamma.fileshare.Exchange; +import cz.kamma.fileshare.JsonBuilder; +import cz.kamma.fileshare.util.AuthContext; +import cz.kamma.fileshare.util.FileUtil; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; + +/** POST /file — Upload a file (authenticated) */ +public class UploadFile extends AuthenticatedHandler { + + protected void handleAuthenticated(Exchange ex, String path, AuthContext auth) throws Exception { + String filename = ex.header("X-Filename"); + if (filename == null || filename.trim().isEmpty()) { + writeError(ex, 400, "X-Filename header required"); + return; + } + filename = filename.trim(); + + String contentType = ex.header("Content-Type"); + if (contentType == null) contentType = "application/octet-stream"; + + long size; + String sha256; + try (InputStream is = ex.body()) { + // To compute SHA-256 and save to DB, we need the data. + // Since we can't read the stream twice, we must buffer it to a temporary file + // or load it if it's small. For a true OOM fix, we use a temp file. + java.nio.file.Path tempFile = java.nio.file.Files.createTempFile("upload-", ".tmp"); + try { + long bytesRead = 0; + java.security.MessageDigest digest = java.security.MessageDigest.getInstance("SHA-256"); + try (java.io.OutputStream os = java.nio.file.Files.newOutputStream(tempFile)) { + byte[] buffer = new byte[8192]; + int n; + while ((n = is.read(buffer)) != -1) { + os.write(buffer, 0, n); + digest.update(buffer, 0, n); + bytesRead += n; + } + } + size = bytesRead; + if (size == 0) { + java.nio.file.Files.delete(tempFile); + writeError(ex, 400, "empty file body"); + return; + } + byte[] shaBytes = digest.digest(); + StringBuilder hex = new StringBuilder(64); + for (byte b : shaBytes) hex.append(String.format("%02x", b)); + sha256 = hex.toString(); + + String sql = "INSERT INTO files (account_id, filename, mime_type, size, sha256, data) " + + "VALUES (?, ?, ?, ?, ?, ?)"; + try (Connection conn = DbUtil.connect()) { + try (PreparedStatement ps = conn.prepareStatement(sql, + PreparedStatement.RETURN_GENERATED_KEYS)) { + ps.setLong(1, Long.parseLong(auth.accountId())); + ps.setString(2, filename); + ps.setString(3, contentType); + ps.setLong(4, size); + ps.setString(5, sha256); + ps.setBinaryStream(6, java.nio.file.Files.newInputStream(tempFile), size); + ps.executeUpdate(); + try (ResultSet rs = ps.getGeneratedKeys()) { + if (rs.next()) { + writeJson(ex, 201, new JsonBuilder() + .number("id", rs.getLong(1)) + .string("filename", filename) + .string("sha256", sha256)); + } + } + } + } + } finally { + java.nio.file.Files.deleteIfExists(tempFile); + } + } + } +} diff --git a/src/main/java/cz/kamma/fileshare/util/AuthContext.java b/src/main/java/cz/kamma/fileshare/util/AuthContext.java new file mode 100644 index 0000000..9a4f487 --- /dev/null +++ b/src/main/java/cz/kamma/fileshare/util/AuthContext.java @@ -0,0 +1,21 @@ +package cz.kamma.fileshare.util; + +/** + * Holds authentication context resolved from an API key. + */ +public final class AuthContext { + private final String accountId; + private final String apiKey; + private final String role; + + public AuthContext(String accountId, String apiKey, String role) { + this.accountId = accountId; + this.apiKey = apiKey; + this.role = role; + } + + public String accountId() { return accountId; } + public String apiKey() { return apiKey; } + public String role() { return role; } + public boolean isAdmin() { return "admin".equals(role); } +} diff --git a/src/main/java/cz/kamma/fileshare/util/FileUtil.java b/src/main/java/cz/kamma/fileshare/util/FileUtil.java new file mode 100644 index 0000000..5ebe201 --- /dev/null +++ b/src/main/java/cz/kamma/fileshare/util/FileUtil.java @@ -0,0 +1,78 @@ +package cz.kamma.fileshare.util; + +import java.io.InputStream; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +public final class FileUtil { + + private FileUtil() {} + + public static String sha256Hex(InputStream is) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] buffer = new byte[8192]; + int read; + while ((read = is.read(buffer)) != -1) { + digest.update(buffer, 0, read); + } + byte[] shaBytes = digest.digest(); + StringBuilder hex = new StringBuilder(64); + for (byte b : shaBytes) hex.append(String.format("%02x", b)); + return hex.toString(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + public static boolean isTextFile(String filename, String mimeType) { + if (mimeType != null && mimeType.startsWith("text/")) return true; + if (mimeType != null && mimeType.contains("json")) return true; + if (mimeType != null && mimeType.contains("xml")) return true; + if (mimeType != null && mimeType.contains("html")) return true; + if (mimeType != null && mimeType.contains("markdown")) return true; + if (mimeType != null && mimeType.contains("javascript")) return true; + if (mimeType != null && mimeType.contains("css")) return true; + if (mimeType != null && mimeType.contains("csv")) return true; + if (mimeType != null && mimeType.contains("svg")) return true; + if (mimeType != null && mimeType.contains("yaml")) return true; + if (mimeType != null && mimeType.contains("toml")) return true; + if (mimeType != null && mimeType.contains("ini")) return true; + if (mimeType != null && mimeType.contains("conf")) return true; + if (mimeType != null && mimeType.contains("log")) return true; + if (mimeType != null && mimeType.contains("properties")) return true; + if (mimeType != null && mimeType.contains("shell")) return true; + if (mimeType != null && mimeType.contains("x-sh")) return true; + if (mimeType != null && mimeType.contains("x-python")) return true; + if (mimeType != null && mimeType.contains("x-perl")) return true; + if (mimeType != null && mimeType.contains("x-ruby")) return true; + if (mimeType != null && mimeType.contains("x-php")) return true; + if (mimeType != null && mimeType.contains("x-java")) return true; + if (mimeType != null && mimeType.contains("x-c")) return true; + if (mimeType != null && mimeType.contains("x-csrc")) return true; + if (mimeType != null && mimeType.contains("x-c++")) return true; + if (mimeType != null && mimeType.contains("x-go")) return true; + if (mimeType != null && mimeType.contains("x-rust")) return true; + if (mimeType != null && mimeType.contains("x-typescript")) return true; + if (mimeType != null && mimeType.contains("x-sql")) return true; + + String lower = filename.toLowerCase(); + return lower.endsWith(".txt") || lower.endsWith(".md") || + lower.endsWith(".html") || lower.endsWith(".htm") || + lower.endsWith(".json") || lower.endsWith(".xml") || + lower.endsWith(".csv") || lower.endsWith(".yaml") || + lower.endsWith(".yml") || lower.endsWith(".toml") || + lower.endsWith(".ini") || lower.endsWith(".conf") || + lower.endsWith(".log") || lower.endsWith(".sh") || + lower.endsWith(".py") || lower.endsWith(".rb") || + lower.endsWith(".pl") || lower.endsWith(".php") || + lower.endsWith(".js") || lower.endsWith(".ts") || + lower.endsWith(".jsx") || lower.endsWith(".tsx") || + lower.endsWith(".java") || lower.endsWith(".c") || + lower.endsWith(".cpp") || lower.endsWith(".h") || + lower.endsWith(".hpp") || lower.endsWith(".go") || + lower.endsWith(".rs") || lower.endsWith(".sql") || + lower.endsWith(".css") || lower.endsWith(".scss") || + lower.endsWith(".svg"); + } +} diff --git a/src/main/java/cz/kamma/fileshare/util/KeyUtil.java b/src/main/java/cz/kamma/fileshare/util/KeyUtil.java new file mode 100644 index 0000000..e99c81e --- /dev/null +++ b/src/main/java/cz/kamma/fileshare/util/KeyUtil.java @@ -0,0 +1,18 @@ +package cz.kamma.fileshare.util; + +import java.security.SecureRandom; + +public final class KeyUtil { + + private static final SecureRandom RNG = new SecureRandom(); + private static final char[] CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".toCharArray(); + + /** Generate a random 5-letter uppercase key */ + public static String generate() { + char[] buf = new char[5]; + for (int i = 0; i < 5; i++) { + buf[i] = CHARS[RNG.nextInt(CHARS.length)]; + } + return new String(buf); + } +} diff --git a/src/main/resources/config.properties b/src/main/resources/config.properties new file mode 100644 index 0000000..6c3e009 --- /dev/null +++ b/src/main/resources/config.properties @@ -0,0 +1,6 @@ +db.host=10.0.0.147 +db.port=3306 +db.name=file_share +db.user=uploader +db.password=g65v7GFYE-3bq8+956bg +server.port=8080 diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html new file mode 100644 index 0000000..5c796f8 --- /dev/null +++ b/src/main/resources/static/index.html @@ -0,0 +1,999 @@ + + + + + + +File Share + + + + + +
+

Login

+ +
+ + + +
+
Version 1.0.0
+
+ + + + + + + + + + + + + + +
+ + + +