first commit
This commit is contained in:
commit
ca4428cf06
8
.dockerignore
Normal file
8
.dockerignore
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
target/
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.iml
|
||||||
|
*.log
|
||||||
|
node_modules/
|
||||||
|
.git/
|
||||||
|
claude-projects/
|
||||||
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
target/
|
||||||
|
*.jar
|
||||||
|
*.log
|
||||||
|
*.class
|
||||||
|
**.properties.local
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.iml
|
||||||
|
.DS_Store
|
||||||
|
.claude
|
||||||
14
Dockerfile
Normal file
14
Dockerfile
Normal file
@ -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"]
|
||||||
46
ddl/schema.sql
Normal file
46
ddl/schema.sql
Normal file
@ -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');
|
||||||
39
dependency-reduced-pom.xml
Normal file
39
dependency-reduced-pom.xml
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
<groupId>cz.kamma</groupId>
|
||||||
|
<artifactId>file-share</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<artifactId>maven-jar-plugin</artifactId>
|
||||||
|
<version>3.3.0</version>
|
||||||
|
<configuration>
|
||||||
|
<archive>
|
||||||
|
<manifest>
|
||||||
|
<mainClass>cz.kamma.fileshare.FileShareServer</mainClass>
|
||||||
|
</manifest>
|
||||||
|
</archive>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
|
<plugin>
|
||||||
|
<artifactId>maven-shade-plugin</artifactId>
|
||||||
|
<version>3.6.0</version>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<phase>package</phase>
|
||||||
|
<goals>
|
||||||
|
<goal>shade</goal>
|
||||||
|
</goals>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
<properties>
|
||||||
|
<maven.compiler.target>11</maven.compiler.target>
|
||||||
|
<maven.compiler.source>11</maven.compiler.source>
|
||||||
|
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||||
|
</properties>
|
||||||
|
</project>
|
||||||
65
pom.xml
Normal file
65
pom.xml
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<groupId>cz.kamma</groupId>
|
||||||
|
<artifactId>file-share</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
<packaging>jar</packaging>
|
||||||
|
|
||||||
|
<properties>
|
||||||
|
<maven.compiler.source>11</maven.compiler.source>
|
||||||
|
<maven.compiler.target>11</maven.compiler.target>
|
||||||
|
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||||
|
</properties>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.mariadb.jdbc</groupId>
|
||||||
|
<artifactId>mariadb-java-client</artifactId>
|
||||||
|
<version>3.5.1</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.zaxxer</groupId>
|
||||||
|
<artifactId>HikariCP</artifactId>
|
||||||
|
<version>5.1.0</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.mindrot</groupId>
|
||||||
|
<artifactId>jbcrypt</artifactId>
|
||||||
|
<version>0.4</version>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-jar-plugin</artifactId>
|
||||||
|
<version>3.3.0</version>
|
||||||
|
<configuration>
|
||||||
|
<archive>
|
||||||
|
<manifest>
|
||||||
|
<mainClass>cz.kamma.fileshare.FileShareServer</mainClass>
|
||||||
|
</manifest>
|
||||||
|
</archive>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-shade-plugin</artifactId>
|
||||||
|
<version>3.6.0</version>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<phase>package</phase>
|
||||||
|
<goals>
|
||||||
|
<goal>shade</goal>
|
||||||
|
</goals>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
</project>
|
||||||
39
src/main/java/cz/kamma/fileshare/Config.java
Normal file
39
src/main/java/cz/kamma/fileshare/Config.java
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/main/java/cz/kamma/fileshare/ConfigHolder.java
Normal file
12
src/main/java/cz/kamma/fileshare/ConfigHolder.java
Normal file
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
50
src/main/java/cz/kamma/fileshare/DbUtil.java
Normal file
50
src/main/java/cz/kamma/fileshare/DbUtil.java
Normal file
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
81
src/main/java/cz/kamma/fileshare/Exchange.java
Normal file
81
src/main/java/cz/kamma/fileshare/Exchange.java
Normal file
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
84
src/main/java/cz/kamma/fileshare/FileShareServer.java
Normal file
84
src/main/java/cz/kamma/fileshare/FileShareServer.java
Normal file
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
34
src/main/java/cz/kamma/fileshare/JsonBuilder.java
Normal file
34
src/main/java/cz/kamma/fileshare/JsonBuilder.java
Normal file
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
27
src/main/java/cz/kamma/fileshare/JsonParse.java
Normal file
27
src/main/java/cz/kamma/fileshare/JsonParse.java
Normal file
@ -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("\\\\", "\\");
|
||||||
|
}
|
||||||
|
}
|
||||||
75
src/main/java/cz/kamma/fileshare/ResourceUtil.java
Normal file
75
src/main/java/cz/kamma/fileshare/ResourceUtil.java
Normal file
@ -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<String, byte[]> cache = new HashMap<>();
|
||||||
|
private final Map<String, String> 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
76
src/main/java/cz/kamma/fileshare/Router.java
Normal file
76
src/main/java/cz/kamma/fileshare/Router.java
Normal file
@ -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<Route> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
17
src/main/java/cz/kamma/fileshare/Util.java
Normal file
17
src/main/java/cz/kamma/fileshare/Util.java
Normal file
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
49
src/main/java/cz/kamma/fileshare/handlers/AdminListOtp.java
Normal file
49
src/main/java/cz/kamma/fileshare/handlers/AdminListOtp.java
Normal file
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
22
src/main/java/cz/kamma/fileshare/handlers/BaseHandler.java
Normal file
22
src/main/java/cz/kamma/fileshare/handlers/BaseHandler.java
Normal file
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
74
src/main/java/cz/kamma/fileshare/handlers/CreateAccount.java
Normal file
74
src/main/java/cz/kamma/fileshare/handlers/CreateAccount.java
Normal file
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
52
src/main/java/cz/kamma/fileshare/handlers/DownloadFile.java
Normal file
52
src/main/java/cz/kamma/fileshare/handlers/DownloadFile.java
Normal file
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
111
src/main/java/cz/kamma/fileshare/handlers/GenerateOtp.java
Normal file
111
src/main/java/cz/kamma/fileshare/handlers/GenerateOtp.java
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
40
src/main/java/cz/kamma/fileshare/handlers/ListAccounts.java
Normal file
40
src/main/java/cz/kamma/fileshare/handlers/ListAccounts.java
Normal file
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
43
src/main/java/cz/kamma/fileshare/handlers/ListFiles.java
Normal file
43
src/main/java/cz/kamma/fileshare/handlers/ListFiles.java
Normal file
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
68
src/main/java/cz/kamma/fileshare/handlers/LoginHandler.java
Normal file
68
src/main/java/cz/kamma/fileshare/handlers/LoginHandler.java
Normal file
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
27
src/main/java/cz/kamma/fileshare/handlers/RemoveAccount.java
Normal file
27
src/main/java/cz/kamma/fileshare/handlers/RemoveAccount.java
Normal file
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
39
src/main/java/cz/kamma/fileshare/handlers/RemoveFile.java
Normal file
39
src/main/java/cz/kamma/fileshare/handlers/RemoveFile.java
Normal file
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/main/java/cz/kamma/fileshare/handlers/RouteHandler.java
Normal file
12
src/main/java/cz/kamma/fileshare/handlers/RouteHandler.java
Normal file
@ -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;
|
||||||
|
}
|
||||||
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
83
src/main/java/cz/kamma/fileshare/handlers/UpdateFile.java
Normal file
83
src/main/java/cz/kamma/fileshare/handlers/UpdateFile.java
Normal file
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
86
src/main/java/cz/kamma/fileshare/handlers/UploadFile.java
Normal file
86
src/main/java/cz/kamma/fileshare/handlers/UploadFile.java
Normal file
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
21
src/main/java/cz/kamma/fileshare/util/AuthContext.java
Normal file
21
src/main/java/cz/kamma/fileshare/util/AuthContext.java
Normal file
@ -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); }
|
||||||
|
}
|
||||||
78
src/main/java/cz/kamma/fileshare/util/FileUtil.java
Normal file
78
src/main/java/cz/kamma/fileshare/util/FileUtil.java
Normal file
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
18
src/main/java/cz/kamma/fileshare/util/KeyUtil.java
Normal file
18
src/main/java/cz/kamma/fileshare/util/KeyUtil.java
Normal file
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
6
src/main/resources/config.properties
Normal file
6
src/main/resources/config.properties
Normal file
@ -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
|
||||||
999
src/main/resources/static/index.html
Normal file
999
src/main/resources/static/index.html
Normal file
@ -0,0 +1,999 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect x='4' y='6' width='20' height='22' rx='2' fill='none' stroke='%233fb950' stroke-width='2'/%3E%3Cpath d='M8 6V4a2 2 0 012-2h8a2 2 0 012 2v4' fill='none' stroke='%233fb950' stroke-width='2'/%3E%3Cpath d='M12 14l4 4 4-4' fill='none' stroke='%233fb950' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3Cline x1='16' y1='18' x2='16' y2='22' stroke='%233fb950' stroke-width='2' stroke-linecap='round'/%3E%3C/svg%3E">
|
||||||
|
<title>File Share</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0d1117;
|
||||||
|
--bg-card: #161b22;
|
||||||
|
--bg-input: #0d1117;
|
||||||
|
--fg: #3fb950;
|
||||||
|
--fg-muted: #2ea043;
|
||||||
|
--border: #30363d;
|
||||||
|
--btn-bg: #21262d;
|
||||||
|
--btn-hover: #30363d;
|
||||||
|
--btn-border: #30363d;
|
||||||
|
--link: #58a6ff;
|
||||||
|
--primary-bg: #238636;
|
||||||
|
--primary-hover: #2ea043;
|
||||||
|
--danger-fg: #f85149;
|
||||||
|
--danger-bg: #21262d;
|
||||||
|
--danger-hover: #30363d;
|
||||||
|
--danger-border: #f85149;
|
||||||
|
--table-bg: #161b22;
|
||||||
|
--table-header-bg: #161b22;
|
||||||
|
--table-border: #30363d;
|
||||||
|
--drop-border: #30363d;
|
||||||
|
--drop-hover-bg: #0d2240;
|
||||||
|
--drop-hover-border: #58a6ff;
|
||||||
|
--overlay-bg: rgba(0,0,0,0.6);
|
||||||
|
--viewer-bg: #161b22;
|
||||||
|
--toast-bg: #e6edf3;
|
||||||
|
--toast-fg: #0d1117;
|
||||||
|
--editor-border: #30363d;
|
||||||
|
--auth-link: #58a6ff;
|
||||||
|
}
|
||||||
|
.light {
|
||||||
|
--bg: #f5f5f5;
|
||||||
|
--bg-card: #fff;
|
||||||
|
--bg-input: #fff;
|
||||||
|
--fg: #222;
|
||||||
|
--fg-muted: #57606a;
|
||||||
|
--border: #ddd;
|
||||||
|
--btn-bg: #f6f8fa;
|
||||||
|
--btn-hover: #e8eaed;
|
||||||
|
--btn-border: #d0d7de;
|
||||||
|
--link: #0969da;
|
||||||
|
--primary-bg: #2da44e;
|
||||||
|
--primary-hover: #2c974b;
|
||||||
|
--danger-fg: #cf222e;
|
||||||
|
--danger-bg: #fff;
|
||||||
|
--danger-hover: #ffeef0;
|
||||||
|
--danger-border: #cf222e;
|
||||||
|
--table-bg: #fff;
|
||||||
|
--table-header-bg: #f6f8fa;
|
||||||
|
--table-border: #d0d7de;
|
||||||
|
--drop-border: #d0d7de;
|
||||||
|
--drop-hover-bg: #ddf4ff;
|
||||||
|
--drop-hover-border: #0969da;
|
||||||
|
--overlay-bg: rgba(0,0,0,0.4);
|
||||||
|
--viewer-bg: #fff;
|
||||||
|
--toast-bg: #24292f;
|
||||||
|
--toast-fg: #fff;
|
||||||
|
--editor-border: #d0d7de;
|
||||||
|
--auth-link: #0969da;
|
||||||
|
}
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
background: var(--bg); color: var(--fg); line-height: 1.5; }
|
||||||
|
a { color: var(--link); text-decoration: none; }
|
||||||
|
a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
/* layout */
|
||||||
|
.container { max-width: 960px; margin: 0 auto; padding: 2rem 1rem; }
|
||||||
|
header { display: flex; justify-content: space-between; align-items: center;
|
||||||
|
padding: 1rem 0; border-bottom: 1px solid var(--border); margin-bottom: 1.5rem; }
|
||||||
|
header h1 { font-size: 1.4rem; }
|
||||||
|
footer { text-align: center; padding: 2rem 0; color: var(--fg-muted); font-size: 0.8rem; }
|
||||||
|
.hidden { display: none !important; }
|
||||||
|
|
||||||
|
/* buttons */
|
||||||
|
button { cursor: pointer; border: 1px solid var(--btn-border); border-radius: 6px;
|
||||||
|
padding: 6px 14px; background: var(--btn-bg); color: var(--fg); font-size: 0.85rem; }
|
||||||
|
button:hover { background: var(--btn-hover); }
|
||||||
|
button.primary { background: var(--primary-bg); color: #fff; border-color: var(--primary-bg); }
|
||||||
|
button.primary:hover { background: var(--primary-hover); }
|
||||||
|
button.danger { color: var(--danger-fg); border-color: var(--danger-border); background: var(--danger-bg); }
|
||||||
|
button.danger:hover { background: var(--danger-hover); }
|
||||||
|
button.small { padding: 3px 8px; font-size: 0.78rem; }
|
||||||
|
|
||||||
|
/* auth */
|
||||||
|
#auth { max-width: 400px; margin: 6rem auto; }
|
||||||
|
#auth h2 { margin-bottom: 1rem; }
|
||||||
|
#auth input { width: 100%; padding: 8px 12px; border: 1px solid var(--btn-border);
|
||||||
|
border-radius: 6px; font-size: 0.95rem; margin-bottom: 0.75rem;
|
||||||
|
background: var(--bg-input); color: var(--fg); }
|
||||||
|
#auth .toggle { margin-top: 0.5rem; font-size: 0.85rem; color: var(--fg-muted); }
|
||||||
|
#auth .error { color: var(--danger-fg); font-size: 0.85rem; margin-bottom: 0.5rem; }
|
||||||
|
|
||||||
|
/* upload zone */
|
||||||
|
#drop-zone { border: 2px dashed var(--drop-border); border-radius: 8px; padding: 2rem;
|
||||||
|
text-align: center; color: var(--fg-muted); cursor: pointer; margin-bottom: 1.5rem;
|
||||||
|
transition: border-color 0.2s, background 0.2s; }
|
||||||
|
#drop-zone.over { border-color: var(--drop-hover-border); background: var(--drop-hover-bg); }
|
||||||
|
#drop-zone input { display: none; }
|
||||||
|
|
||||||
|
/* file list */
|
||||||
|
table { width: 100%; border-collapse: collapse; background: var(--table-bg);
|
||||||
|
border: 1px solid var(--table-border); border-radius: 6px; overflow: hidden; }
|
||||||
|
th, td { text-align: left; padding: 8px 12px; border-bottom: 1px solid var(--table-border);
|
||||||
|
font-size: 0.85rem; }
|
||||||
|
th { background: var(--table-header-bg); font-weight: 600; }
|
||||||
|
td.actions { white-space: nowrap; }
|
||||||
|
.empty { text-align: center; padding: 2rem; color: var(--fg-muted); }
|
||||||
|
|
||||||
|
/* file viewer modal */
|
||||||
|
#viewer-overlay { position: fixed; inset: 0; background: var(--overlay-bg);
|
||||||
|
display: flex; align-items: flex-start; justify-content: center;
|
||||||
|
z-index: 100; overflow-y: auto; }
|
||||||
|
#viewer-box { background: var(--viewer-bg); width: 90%; max-width: 860px; margin: 2rem 0;
|
||||||
|
border-radius: 8px; box-shadow: 0 2px 12px rgba(0,0,0,0.3);
|
||||||
|
display: flex; flex-direction: column; max-height: 90vh; }
|
||||||
|
#viewer-header { display: flex; justify-content: space-between; align-items: center;
|
||||||
|
padding: 12px 16px; border-bottom: 1px solid var(--table-border); }
|
||||||
|
#viewer-header h3 { font-size: 1rem; max-width: 60%; overflow: hidden;
|
||||||
|
text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
#viewer-body { padding: 16px; overflow: auto; flex: 1; }
|
||||||
|
#viewer-body pre { white-space: pre-wrap; word-break: break-word; font-size: 0.85rem;
|
||||||
|
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; }
|
||||||
|
#viewer-editor { width: 100%; min-height: 300px; flex: 1; resize: vertical;
|
||||||
|
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
|
||||||
|
font-size: 0.85rem; padding: 8px; border: 1px solid var(--editor-border);
|
||||||
|
border-radius: 6px; display: block; background: var(--bg-input); color: var(--fg); }
|
||||||
|
|
||||||
|
/* toast */
|
||||||
|
#toast { position: fixed; bottom: 1.5rem; right: 1.5rem; background: var(--toast-bg);
|
||||||
|
color: var(--toast-fg); padding: 10px 18px; border-radius: 6px; font-size: 0.85rem;
|
||||||
|
z-index: 200; opacity: 0; transition: opacity 0.3s; pointer-events: none; }
|
||||||
|
#toast.show { opacity: 1; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- Auth screen -->
|
||||||
|
<div id="auth" class="container">
|
||||||
|
<h2 id="auth-title">Login</h2>
|
||||||
|
<div id="auth-error" class="error hidden"></div>
|
||||||
|
<div id="login-form">
|
||||||
|
<input id="login-username" type="text" placeholder="Username" autocomplete="username">
|
||||||
|
<input id="login-password" type="password" placeholder="Password" autocomplete="current-password">
|
||||||
|
<button class="primary" id="btn-login">Login</button>
|
||||||
|
</div>
|
||||||
|
<footer>Version 1.0.0</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main app -->
|
||||||
|
<div id="app" class="container hidden">
|
||||||
|
<header>
|
||||||
|
<h1>File Share <span id="user-label" style="font-weight:400;font-size:0.9rem;color:var(--fg-muted)"></span></h1>
|
||||||
|
<div style="display:flex;gap:8px;">
|
||||||
|
<button id="btn-theme" class="small">☀️</button>
|
||||||
|
<button id="btn-admin" class="small hidden">Admin</button>
|
||||||
|
<button id="btn-settings" class="small">Settings</button>
|
||||||
|
<button id="btn-logout">Logout</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div id="drop-zone">
|
||||||
|
Drag files here or click to select
|
||||||
|
<input type="file" id="file-input" multiple>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-bottom:0.75rem;">
|
||||||
|
<button id="btn-refresh" class="small">Refresh list</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="file-list"></div>
|
||||||
|
|
||||||
|
<footer>Version 1.0.0</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- File viewer -->
|
||||||
|
<div id="viewer-overlay" class="hidden">
|
||||||
|
<div id="viewer-box">
|
||||||
|
<div id="viewer-header">
|
||||||
|
<h3 id="viewer-filename"></h3>
|
||||||
|
<div>
|
||||||
|
<button id="btn-viewer-edit" class="small hidden">Edit</button>
|
||||||
|
<button id="btn-viewer-save" class="small primary hidden">Save</button>
|
||||||
|
<button id="btn-viewer-cancel" class="small hidden">Cancel</button>
|
||||||
|
<button id="btn-viewer-download" class="small">Download</button>
|
||||||
|
<button id="btn-viewer-close" class="small danger">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="viewer-body">
|
||||||
|
<pre id="viewer-content"></pre>
|
||||||
|
<textarea id="viewer-editor" class="hidden" spellcheck="false"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Settings modal -->
|
||||||
|
<div id="settings-overlay" class="hidden" style="position:fixed;inset:0;background:var(--overlay-bg);display:flex;align-items:flex-start;justify-content:center;z-index:100;overflow-y:auto;">
|
||||||
|
<div id="settings-box" style="background:var(--viewer-bg);width:90%;max-width:500px;margin:2rem 0;border-radius:8px;box-shadow:0 2px 12px rgba(0,0,0,0.3);">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;padding:12px 16px;border-bottom:1px solid var(--table-border);">
|
||||||
|
<h3 style="font-size:1rem;">Account Settings</h3>
|
||||||
|
<button id="btn-settings-close" class="small danger">Close</button>
|
||||||
|
</div>
|
||||||
|
<div style="padding:16px;">
|
||||||
|
<div style="margin-bottom:1rem;">
|
||||||
|
<strong style="font-size:0.85rem;color:var(--fg-muted);">Username</strong><br>
|
||||||
|
<span id="settings-username" style="font-size:0.95rem;">--</span>
|
||||||
|
</div>
|
||||||
|
<div style="margin-bottom:1rem;">
|
||||||
|
<strong style="font-size:0.85rem;color:var(--fg-muted);">API Key</strong><br>
|
||||||
|
<div style="display:flex;gap:6px;margin-top:4px;">
|
||||||
|
<input id="settings-apikey" type="text" readonly
|
||||||
|
style="flex:1;padding:6px 8px;border:1px solid var(--btn-border);border-radius:6px;font-size:0.8rem;font-family:monospace;background:var(--bg-input);color:var(--fg);">
|
||||||
|
<button id="btn-copy-apikey" class="small">Copy</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-bottom:1rem;">
|
||||||
|
<button id="btn-regen-apikey" class="small">Regenerate API Key</button>
|
||||||
|
<span id="regen-msg" style="font-size:0.8rem;color:var(--fg-muted);margin-left:8px;"></span>
|
||||||
|
</div>
|
||||||
|
<div style="margin-bottom:1rem;">
|
||||||
|
<strong style="font-size:0.85rem;color:var(--fg-muted);">Change Password</strong><br>
|
||||||
|
<div style="display:flex;gap:6px;margin-top:4px;">
|
||||||
|
<input id="settings-new-password" type="password" placeholder="New password (8–72 characters)"
|
||||||
|
style="flex:1;padding:6px 8px;border:1px solid var(--btn-border);border-radius:6px;font-size:0.85rem;background:var(--bg-input);color:var(--fg);">
|
||||||
|
<button id="btn-change-password" class="small primary">Save</button>
|
||||||
|
</div>
|
||||||
|
<span id="password-msg" style="font-size:0.8rem;color:var(--fg-muted);margin-left:4px;"></span>
|
||||||
|
</div>
|
||||||
|
<hr style="border:none;border-top:1px solid var(--table-border);margin:1rem 0;">
|
||||||
|
<div>
|
||||||
|
<button id="btn-delete-account" class="danger">Delete Account</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Admin modal -->
|
||||||
|
<div id="admin-overlay" class="hidden" style="position:fixed;inset:0;background:var(--overlay-bg);display:flex;align-items:flex-start;justify-content:center;z-index:100;overflow-y:auto;">
|
||||||
|
<div id="admin-box" style="background:var(--viewer-bg);width:90%;max-width:900px;margin:2rem 0;border-radius:8px;box-shadow:0 2px 12px rgba(0,0,0,0.3);">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;padding:12px 16px;border-bottom:1px solid var(--table-border);">
|
||||||
|
<h3 style="font-size:1rem;">Admin</h3>
|
||||||
|
<button id="btn-admin-close" class="small danger">Close</button>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;border-bottom:1px solid var(--table-border);">
|
||||||
|
<button class="small" id="admin-tab-accounts" style="border:none;border-radius:0;border-bottom:2px solid transparent;padding:8px 16px;">Accounts</button>
|
||||||
|
<button class="small" id="admin-tab-files" style="border:none;border-radius:0;border-bottom:2px solid transparent;padding:8px 16px;">Files</button>
|
||||||
|
<button class="small" id="admin-tab-otp" style="border:none;border-radius:0;border-bottom:2px solid transparent;padding:8px 16px;">OTP Codes</button>
|
||||||
|
</div>
|
||||||
|
<div style="padding:16px;">
|
||||||
|
<!-- Accounts tab -->
|
||||||
|
<div id="admin-panel-accounts">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.5rem;">
|
||||||
|
<strong style="font-size:0.85rem;color:var(--fg-muted);">Create Account</strong>
|
||||||
|
<button id="btn-admin-accounts-del" class="small danger hidden">Delete Selected</button>
|
||||||
|
</div>
|
||||||
|
<div style="margin-bottom:1rem;">
|
||||||
|
<div style="display:flex;gap:6px;margin-top:4px;flex-wrap:wrap;">
|
||||||
|
<input id="admin-new-username" type="text" placeholder="Username"
|
||||||
|
style="flex:1;min-width:120px;padding:6px 8px;border:1px solid var(--btn-border);border-radius:6px;font-size:0.85rem;background:var(--bg-input);color:var(--fg);">
|
||||||
|
<input id="admin-new-password" type="password" placeholder="Password (8–72)"
|
||||||
|
style="flex:1;min-width:120px;padding:6px 8px;border:1px solid var(--btn-border);border-radius:6px;font-size:0.85rem;background:var(--bg-input);color:var(--fg);">
|
||||||
|
<select id="admin-new-role" style="padding:6px 8px;border:1px solid var(--btn-border);border-radius:6px;font-size:0.85rem;background:var(--bg-input);color:var(--fg);">
|
||||||
|
<option value="user">user</option>
|
||||||
|
<option value="admin">admin</option>
|
||||||
|
</select>
|
||||||
|
<button id="btn-admin-create" class="small primary">Create</button>
|
||||||
|
</div>
|
||||||
|
<span id="admin-create-msg" style="font-size:0.8rem;color:var(--fg-muted);margin-left:4px;"></span>
|
||||||
|
</div>
|
||||||
|
<table><thead><tr><th style="width:30px;">☐</th><th>ID</th><th>Name</th><th>Role</th><th>Created</th><th>Action</th></tr></thead>
|
||||||
|
<tbody id="admin-accounts-body"></tbody></table>
|
||||||
|
</div>
|
||||||
|
<!-- Files tab -->
|
||||||
|
<div id="admin-panel-files" class="hidden">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.5rem;">
|
||||||
|
<button id="btn-admin-files-refresh" class="small">Refresh</button>
|
||||||
|
<button id="btn-admin-files-del" class="small danger hidden">Delete Selected</button>
|
||||||
|
</div>
|
||||||
|
<table><thead><tr><th style="width:30px;">☐</th><th>ID</th><th>File</th><th>User</th><th>Size</th><th>Uploaded</th><th>Action</th></tr></thead>
|
||||||
|
<tbody id="admin-files-body"></tbody></table>
|
||||||
|
</div>
|
||||||
|
<!-- OTP tab -->
|
||||||
|
<div id="admin-panel-otp" class="hidden">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.5rem;">
|
||||||
|
<button id="btn-admin-otp-refresh" class="small">Refresh</button>
|
||||||
|
<button id="btn-admin-otp-del" class="small danger hidden">Delete Selected</button>
|
||||||
|
</div>
|
||||||
|
<table><thead><tr><th style="width:30px;">☐</th><th>ID</th><th>Code</th><th>File</th><th>User</th><th>Used</th><th>Expires</th><th>Action</th></tr></thead>
|
||||||
|
<tbody id="admin-otp-body"></tbody></table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Toast -->
|
||||||
|
<div id="toast"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
// --- DOM ---
|
||||||
|
var $ = function (id) { return document.getElementById(id); };
|
||||||
|
|
||||||
|
// --- theme ---
|
||||||
|
var theme = localStorage.getItem("fu_theme") || "dark";
|
||||||
|
if (theme === "light") document.body.classList.add("light");
|
||||||
|
|
||||||
|
function updateThemeBtn() {
|
||||||
|
var btn = $("btn-theme");
|
||||||
|
if (btn) btn.textContent = document.body.classList.contains("light") ? "🌙" : "☀️";
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- state ---
|
||||||
|
var apiKey = localStorage.getItem("fu_api_key") || "";
|
||||||
|
var username = localStorage.getItem("fu_username") || "";
|
||||||
|
var userRole = localStorage.getItem("fu_role") || "user";
|
||||||
|
var currentViewFileId = null;
|
||||||
|
var currentViewFilename = null;
|
||||||
|
var currentViewContent = "";
|
||||||
|
var isEditing = false;
|
||||||
|
var authDiv = $("auth");
|
||||||
|
var appDiv = $("app");
|
||||||
|
var loginForm = $("login-form");
|
||||||
|
var authError = $("auth-error");
|
||||||
|
var loginUsername = $("login-username");
|
||||||
|
var loginPassword = $("login-password");
|
||||||
|
var fileListDiv = $("file-list");
|
||||||
|
var dropZone = $("drop-zone");
|
||||||
|
var fileInput = $("file-input");
|
||||||
|
var overlay = $("viewer-overlay");
|
||||||
|
var viewerContent = $("viewer-content");
|
||||||
|
var viewerFilename= $("viewer-filename");
|
||||||
|
var viewerEditor = $("viewer-editor");
|
||||||
|
var toastEl = $("toast");
|
||||||
|
var userLabel = $("user-label");
|
||||||
|
var toastTimer = null;
|
||||||
|
|
||||||
|
// --- toast ---
|
||||||
|
function toast(msg) {
|
||||||
|
toastEl.textContent = msg;
|
||||||
|
toastEl.classList.add("show");
|
||||||
|
clearTimeout(toastTimer);
|
||||||
|
toastTimer = setTimeout(function () { toastEl.classList.remove("show"); }, 2500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- api helper ---
|
||||||
|
function api(path, opts) {
|
||||||
|
opts = opts || {};
|
||||||
|
var h = Object.assign({}, opts.headers || {});
|
||||||
|
if (apiKey) h["X-Api-Key"] = apiKey;
|
||||||
|
return fetch(path, Object.assign({}, opts, { headers: h }))
|
||||||
|
.then(function (res) {
|
||||||
|
if (opts.raw) return res;
|
||||||
|
if (!res.ok) {
|
||||||
|
return res.text().then(function (text) {
|
||||||
|
try {
|
||||||
|
var err = JSON.parse(text);
|
||||||
|
throw err;
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(text || "Request failed");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (res.status === 204) return {};
|
||||||
|
var ct = res.headers.get("content-type") || "";
|
||||||
|
if (ct.indexOf("application/json") >= 0) return res.json();
|
||||||
|
return res.text();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- auth ---
|
||||||
|
function showAuth() {
|
||||||
|
authDiv.classList.remove("hidden");
|
||||||
|
appDiv.classList.add("hidden");
|
||||||
|
authError.classList.add("hidden");
|
||||||
|
loginForm.classList.remove("hidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
function showApp() {
|
||||||
|
authDiv.classList.add("hidden");
|
||||||
|
appDiv.classList.remove("hidden");
|
||||||
|
userLabel.textContent = username ? "(" + username + ")" : "";
|
||||||
|
if (userRole === "admin") {
|
||||||
|
$("btn-admin").classList.remove("hidden");
|
||||||
|
} else {
|
||||||
|
$("btn-admin").classList.add("hidden");
|
||||||
|
}
|
||||||
|
updateThemeBtn();
|
||||||
|
loadFiles();
|
||||||
|
}
|
||||||
|
|
||||||
|
function authFail(msg) {
|
||||||
|
authError.textContent = msg;
|
||||||
|
authError.classList.remove("hidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
$("btn-login").addEventListener("click", function () {
|
||||||
|
var u = loginUsername.value.trim();
|
||||||
|
var p = loginPassword.value;
|
||||||
|
if (!u) { authFail("Enter username."); return; }
|
||||||
|
if (!p) { authFail("Enter password."); return; }
|
||||||
|
api("/auth/login", { method: "POST", headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ username: u, password: p }) })
|
||||||
|
.then(function (data) {
|
||||||
|
apiKey = data.api_key;
|
||||||
|
username = data.username;
|
||||||
|
userRole = data.role || "user";
|
||||||
|
localStorage.setItem("fu_api_key", apiKey);
|
||||||
|
localStorage.setItem("fu_username", username);
|
||||||
|
localStorage.setItem("fu_role", userRole);
|
||||||
|
showApp();
|
||||||
|
}).catch(function (err) {
|
||||||
|
var msg = (err.error) ? err.error : "Invalid username or password.";
|
||||||
|
authFail(msg);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
loginUsername.addEventListener("keydown", function (e) { if (e.key === "Enter") $("btn-login").click(); });
|
||||||
|
loginPassword.addEventListener("keydown", function (e) { if (e.key === "Enter") $("btn-login").click(); });
|
||||||
|
|
||||||
|
$("btn-logout").addEventListener("click", function () {
|
||||||
|
apiKey = "";
|
||||||
|
username = "";
|
||||||
|
userRole = "user";
|
||||||
|
localStorage.removeItem("fu_api_key");
|
||||||
|
localStorage.removeItem("fu_username");
|
||||||
|
localStorage.removeItem("fu_role");
|
||||||
|
showAuth();
|
||||||
|
loginUsername.value = "";
|
||||||
|
loginPassword.value = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- theme toggle ---
|
||||||
|
$("btn-theme").addEventListener("click", function () {
|
||||||
|
document.body.classList.toggle("light");
|
||||||
|
theme = document.body.classList.contains("light") ? "light" : "dark";
|
||||||
|
localStorage.setItem("fu_theme", theme);
|
||||||
|
updateThemeBtn();
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- settings modal ---
|
||||||
|
var settingsOverlay = $("settings-overlay");
|
||||||
|
|
||||||
|
$("btn-settings").addEventListener("click", function () {
|
||||||
|
api("/account").then(function (data) {
|
||||||
|
$("settings-username").textContent = data.username;
|
||||||
|
$("settings-apikey").value = data.api_key;
|
||||||
|
$("regen-msg").textContent = "";
|
||||||
|
settingsOverlay.classList.remove("hidden");
|
||||||
|
}).catch(function () {
|
||||||
|
toast("Error loading account data");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
$("btn-settings-close").addEventListener("click", function () {
|
||||||
|
settingsOverlay.classList.add("hidden");
|
||||||
|
});
|
||||||
|
|
||||||
|
settingsOverlay.addEventListener("click", function (e) {
|
||||||
|
if (e.target === settingsOverlay) settingsOverlay.classList.add("hidden");
|
||||||
|
});
|
||||||
|
|
||||||
|
$("btn-copy-apikey").addEventListener("click", function () {
|
||||||
|
var input = $("settings-apikey");
|
||||||
|
input.select();
|
||||||
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||||
|
navigator.clipboard.writeText(input.value).then(function () {
|
||||||
|
toast("API key copied");
|
||||||
|
}).catch(function () { fallbackCopy(input.value); });
|
||||||
|
} else {
|
||||||
|
fallbackCopy(input.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$("btn-regen-apikey").addEventListener("click", function () {
|
||||||
|
if (!confirm("New API key will invalidate all existing sessions. Continue?")) return;
|
||||||
|
api("/account/api-key", { method: "PUT" })
|
||||||
|
.then(function (data) {
|
||||||
|
apiKey = data.api_key;
|
||||||
|
localStorage.setItem("fu_api_key", apiKey);
|
||||||
|
$("settings-apikey").value = apiKey;
|
||||||
|
$("regen-msg").textContent = "New key generated";
|
||||||
|
toast("API key regenerated");
|
||||||
|
}).catch(function (err) {
|
||||||
|
toast("Error: " + (err.error || err));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
$("btn-change-password").addEventListener("click", function () {
|
||||||
|
var p = $("settings-new-password").value;
|
||||||
|
if (p.length < 8) { $("password-msg").textContent = "Min 8 characters"; return; }
|
||||||
|
if (p.length > 72) { $("password-msg").textContent = "Max 72 characters"; return; }
|
||||||
|
api("/account/password", { method: "PUT", headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ password: p }) })
|
||||||
|
.then(function () {
|
||||||
|
$("settings-new-password").value = "";
|
||||||
|
$("password-msg").textContent = "Password changed";
|
||||||
|
toast("Password updated");
|
||||||
|
}).catch(function (err) {
|
||||||
|
$("password-msg").textContent = "Error: " + (err.error || err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
$("btn-delete-account").addEventListener("click", function () {
|
||||||
|
if (!confirm("Really delete account and all files?")) return;
|
||||||
|
if (!confirm("This action is irreversible. Really?")) return;
|
||||||
|
api("/account", { method: "DELETE" })
|
||||||
|
.then(function () {
|
||||||
|
apiKey = "";
|
||||||
|
username = "";
|
||||||
|
localStorage.removeItem("fu_api_key");
|
||||||
|
localStorage.removeItem("fu_username");
|
||||||
|
showAuth();
|
||||||
|
toast("Account deleted");
|
||||||
|
}).catch(function (err) {
|
||||||
|
toast("Error: " + (err.error || err));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- admin panel ---
|
||||||
|
var adminOverlay = $("admin-overlay");
|
||||||
|
var adminTab = "accounts";
|
||||||
|
|
||||||
|
function switchAdminTab(tab) {
|
||||||
|
adminTab = tab;
|
||||||
|
["accounts", "files", "otp"].forEach(function (t) {
|
||||||
|
var panel = $("admin-panel-" + t);
|
||||||
|
var btn = $("admin-tab-" + t);
|
||||||
|
if (t === tab) {
|
||||||
|
panel.classList.remove("hidden");
|
||||||
|
btn.style.borderBottomColor = "var(--fg)";
|
||||||
|
} else {
|
||||||
|
panel.classList.add("hidden");
|
||||||
|
btn.style.borderBottomColor = "transparent";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$("btn-admin").addEventListener("click", function () {
|
||||||
|
switchAdminTab("accounts");
|
||||||
|
loadAdminAccounts();
|
||||||
|
adminOverlay.classList.remove("hidden");
|
||||||
|
});
|
||||||
|
|
||||||
|
$("admin-tab-accounts").addEventListener("click", function () { switchAdminTab("accounts"); loadAdminAccounts(); });
|
||||||
|
$("admin-tab-files").addEventListener("click", function () { switchAdminTab("files"); loadAdminFiles(); });
|
||||||
|
$("admin-tab-otp").addEventListener("click", function () { switchAdminTab("otp"); loadAdminOtp(); });
|
||||||
|
|
||||||
|
$("btn-admin-close").addEventListener("click", function () { adminOverlay.classList.add("hidden"); });
|
||||||
|
$("btn-admin-files-refresh").addEventListener("click", loadAdminFiles);
|
||||||
|
$("btn-admin-otp-refresh").addEventListener("click", loadAdminOtp);
|
||||||
|
$("btn-admin-accounts-del").addEventListener("click", function () { bulkDelete("accounts"); });
|
||||||
|
$("btn-admin-files-del").addEventListener("click", function () { bulkDelete("files"); });
|
||||||
|
$("btn-admin-otp-del").addEventListener("click", function () { bulkDelete("otp"); });
|
||||||
|
|
||||||
|
adminOverlay.addEventListener("click", function (e) {
|
||||||
|
if (e.target === adminOverlay) adminOverlay.classList.add("hidden");
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- bulk delete ---
|
||||||
|
function bulkDelete(type) {
|
||||||
|
var tbody = $("admin-" + type + "-body");
|
||||||
|
var checked = [];
|
||||||
|
tbody.querySelectorAll("input[type=checkbox]:checked").forEach(function (cb) {
|
||||||
|
checked.push(cb.value);
|
||||||
|
});
|
||||||
|
if (!checked.length) { toast("No items selected"); return; }
|
||||||
|
var label = type === "accounts" ? "accounts" : type === "files" ? "files" : "OTP codes";
|
||||||
|
if (!confirm("Really delete " + checked.length + " " + label + "?")) return;
|
||||||
|
|
||||||
|
var base = "/admin/" + type + "/";
|
||||||
|
var promises = checked.map(function (id) {
|
||||||
|
return api(base + id, { method: "DELETE" });
|
||||||
|
});
|
||||||
|
Promise.all(promises).then(function () {
|
||||||
|
toast("Deleted: " + checked.length);
|
||||||
|
if (type === "accounts") loadAdminAccounts();
|
||||||
|
else if (type === "files") loadAdminFiles();
|
||||||
|
else loadAdminOtp();
|
||||||
|
}).catch(function (err) {
|
||||||
|
toast("Error: " + (err.error || err));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateBulkDeleteBtn(type) {
|
||||||
|
var tbody = $("admin-" + type + "-body");
|
||||||
|
var checked = tbody.querySelectorAll("input[type=checkbox]:checked").length;
|
||||||
|
var btn = $("btn-admin-" + type + "-del");
|
||||||
|
if (checked > 0) btn.classList.remove("hidden");
|
||||||
|
else btn.classList.add("hidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- accounts ---
|
||||||
|
function loadAdminAccounts() {
|
||||||
|
api("/admin/accounts").then(function (accounts) {
|
||||||
|
var tbody = $("admin-accounts-body");
|
||||||
|
if (!accounts.length) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="6" class="empty">No accounts</td></tr>';
|
||||||
|
$("btn-admin-accounts-del").classList.add("hidden");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var html = "";
|
||||||
|
accounts.forEach(function (a) {
|
||||||
|
html += "<tr>" +
|
||||||
|
"<td><input type='checkbox' value='" + a.id + "'></td>" +
|
||||||
|
"<td>" + a.id + "</td>" +
|
||||||
|
"<td>" + escHtml(a.username) + "</td>" +
|
||||||
|
"<td>" + escHtml(a.role) + "</td>" +
|
||||||
|
"<td>" + escHtml(a.created_at) + "</td>" +
|
||||||
|
"<td class='actions'>" +
|
||||||
|
" <button class='small' data-admin-pw='" + a.id + "'>Password</button> " +
|
||||||
|
" <button class='small danger' data-admin-del='" + a.id + "'>Delete</button>" +
|
||||||
|
"</td></tr>";
|
||||||
|
});
|
||||||
|
tbody.innerHTML = html;
|
||||||
|
tbody.querySelectorAll("input[type=checkbox]").forEach(function (cb) {
|
||||||
|
cb.addEventListener("change", function () { updateBulkDeleteBtn("accounts"); });
|
||||||
|
});
|
||||||
|
tbody.querySelectorAll("[data-admin-pw]").forEach(function (el) {
|
||||||
|
el.addEventListener("click", function () { resetAccountPassword(el.dataset.adminPw); });
|
||||||
|
});
|
||||||
|
tbody.querySelectorAll("[data-admin-del]").forEach(function (el) {
|
||||||
|
el.addEventListener("click", function () { deleteAccount(el.dataset.adminDel); });
|
||||||
|
});
|
||||||
|
}).catch(function () { toast("Error loading accounts"); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetAccountPassword(id) {
|
||||||
|
var pw = prompt("New password for account #" + id + " (min 8 characters):");
|
||||||
|
if (!pw || pw.length < 8) {
|
||||||
|
if (pw !== null) toast("Password must be at least 8 characters");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
api("/admin/accounts/" + id + "/password", {
|
||||||
|
method: "PUT", headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ password: pw })
|
||||||
|
}).then(function () { toast("Password changed"); })
|
||||||
|
.catch(function (err) { toast("Error: " + (err.error || err)); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteAccount(id) {
|
||||||
|
if (!confirm("Really delete account #" + id + " and all its files?")) return;
|
||||||
|
api("/admin/accounts/" + id, { method: "DELETE" })
|
||||||
|
.then(function () { toast("Account deleted"); loadAdminAccounts(); })
|
||||||
|
.catch(function (err) { toast("Error: " + (err.error || err)); });
|
||||||
|
}
|
||||||
|
|
||||||
|
$("btn-admin-create").addEventListener("click", function () {
|
||||||
|
var u = $("admin-new-username").value.trim();
|
||||||
|
var p = $("admin-new-password").value;
|
||||||
|
var r = $("admin-new-role").value;
|
||||||
|
$("admin-create-msg").textContent = "";
|
||||||
|
if (u.length < 3) { $("admin-create-msg").textContent = "Name min 3 characters"; return; }
|
||||||
|
if (p.length < 8) { $("admin-create-msg").textContent = "Password min 8 characters"; return; }
|
||||||
|
api("/account", { method: "POST", headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ username: u, password: p, role: r }) })
|
||||||
|
.then(function () {
|
||||||
|
$("admin-new-username").value = "";
|
||||||
|
$("admin-new-password").value = "";
|
||||||
|
$("admin-new-role").value = "user";
|
||||||
|
$("admin-create-msg").textContent = "Account created";
|
||||||
|
toast("Account created");
|
||||||
|
loadAdminAccounts();
|
||||||
|
}).catch(function (err) {
|
||||||
|
$("admin-create-msg").textContent = "Error: " + (err.error || err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- admin files ---
|
||||||
|
function loadAdminFiles() {
|
||||||
|
api("/admin/files").then(function (files) {
|
||||||
|
var tbody = $("admin-files-body");
|
||||||
|
if (!files.length) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="7" class="empty">No files</td></tr>';
|
||||||
|
$("btn-admin-files-del").classList.add("hidden");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var html = "";
|
||||||
|
files.forEach(function (f) {
|
||||||
|
html += "<tr>" +
|
||||||
|
"<td><input type='checkbox' value='" + f.id + "'></td>" +
|
||||||
|
"<td>" + f.id + "</td>" +
|
||||||
|
"<td>" + escHtml(f.filename) + "</td>" +
|
||||||
|
"<td>" + escHtml(f.username) + "</td>" +
|
||||||
|
"<td>" + formatSize(f.size) + "</td>" +
|
||||||
|
"<td>" + escHtml(f.uploaded_at) + "</td>" +
|
||||||
|
"<td class='actions'>" +
|
||||||
|
" <button class='small danger' data-admin-file-del='" + f.id + "'>Delete</button>" +
|
||||||
|
"</td></tr>";
|
||||||
|
});
|
||||||
|
tbody.innerHTML = html;
|
||||||
|
tbody.querySelectorAll("input[type=checkbox]").forEach(function (cb) {
|
||||||
|
cb.addEventListener("change", function () { updateBulkDeleteBtn("files"); });
|
||||||
|
});
|
||||||
|
tbody.querySelectorAll("[data-admin-file-del]").forEach(function (el) {
|
||||||
|
el.addEventListener("click", function () { adminDeleteFile(el.dataset.adminFileDel); });
|
||||||
|
});
|
||||||
|
}).catch(function () { toast("Error loading files"); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function adminDeleteFile(id) {
|
||||||
|
if (!confirm("Really delete file #" + id + "?")) return;
|
||||||
|
api("/admin/files/" + id, { method: "DELETE" })
|
||||||
|
.then(function () { toast("File deleted"); loadAdminFiles(); })
|
||||||
|
.catch(function (err) { toast("Error: " + (err.error || err)); });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- admin otp ---
|
||||||
|
function loadAdminOtp() {
|
||||||
|
api("/admin/otp").then(function (otps) {
|
||||||
|
var tbody = $("admin-otp-body");
|
||||||
|
if (!otps.length) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="8" class="empty">No OTP codes</td></tr>';
|
||||||
|
$("btn-admin-otp-del").classList.add("hidden");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var html = "";
|
||||||
|
otps.forEach(function (o) {
|
||||||
|
html += "<tr>" +
|
||||||
|
"<td><input type='checkbox' value='" + o.id + "'></td>" +
|
||||||
|
"<td>" + o.id + "</td>" +
|
||||||
|
"<td style='font-family:monospace;letter-spacing:1px;'>" + escHtml(o.code) + "</td>" +
|
||||||
|
"<td>" + escHtml(o.filename) + "</td>" +
|
||||||
|
"<td>" + escHtml(o.username) + "</td>" +
|
||||||
|
"<td>" + (o.used ? "yes" : "no") + "</td>" +
|
||||||
|
"<td>" + escHtml(o.expires_at) + "</td>" +
|
||||||
|
"<td class='actions'>" +
|
||||||
|
" <button class='small danger' data-admin-otp-del='" + o.id + "'>Delete</button>" +
|
||||||
|
"</td></tr>";
|
||||||
|
});
|
||||||
|
tbody.innerHTML = html;
|
||||||
|
tbody.querySelectorAll("input[type=checkbox]").forEach(function (cb) {
|
||||||
|
cb.addEventListener("change", function () { updateBulkDeleteBtn("otp"); });
|
||||||
|
});
|
||||||
|
tbody.querySelectorAll("[data-admin-otp-del]").forEach(function (el) {
|
||||||
|
el.addEventListener("click", function () { adminDeleteOtp(el.dataset.adminOtpDel); });
|
||||||
|
});
|
||||||
|
}).catch(function () { toast("Error loading OTP codes"); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function adminDeleteOtp(id) {
|
||||||
|
if (!confirm("Really delete OTP code #" + id + "?")) return;
|
||||||
|
api("/admin/otp/" + id, { method: "DELETE" })
|
||||||
|
.then(function () { toast("OTP code deleted"); loadAdminOtp(); })
|
||||||
|
.catch(function (err) { toast("Error: " + (err.error || err)); });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- files ---
|
||||||
|
function loadFiles() {
|
||||||
|
api("/files").then(function (files) {
|
||||||
|
if (!files.length) {
|
||||||
|
fileListDiv.innerHTML = '<div class="empty">No files. Upload your first!</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var html = "<table><thead><tr><th>File</th><th>Type</th><th>Size</th><th>Uploaded</th><th>Action</th></tr></thead><tbody>";
|
||||||
|
files.forEach(function (f) {
|
||||||
|
var size = formatSize(f.size);
|
||||||
|
var name = escHtml(f.filename);
|
||||||
|
html += "<tr>" +
|
||||||
|
"<td><a href='#' data-view='" + f.id + "'>" + name + "</a></td>" +
|
||||||
|
"<td>" + escHtml(f.mime_type) + "</td>" +
|
||||||
|
"<td>" + size + "</td>" +
|
||||||
|
"<td>" + escHtml(f.uploaded_at) + "</td>" +
|
||||||
|
"<td class='actions'>" +
|
||||||
|
" <a href='#' data-dl='" + f.id + "'>Download</a> " +
|
||||||
|
" <button class='small' data-otp='" + f.id + "'>OTP Link</button> " +
|
||||||
|
" <button class='small danger' data-del='" + f.id + "'>Delete</button>" +
|
||||||
|
"</td></tr>";
|
||||||
|
});
|
||||||
|
html += "</tbody></table>";
|
||||||
|
fileListDiv.innerHTML = html;
|
||||||
|
|
||||||
|
fileListDiv.querySelectorAll("[data-view]").forEach(function (el) {
|
||||||
|
el.addEventListener("click", function (e) { e.preventDefault(); viewFile(el.dataset.view); });
|
||||||
|
});
|
||||||
|
fileListDiv.querySelectorAll("[data-dl]").forEach(function (el) {
|
||||||
|
el.addEventListener("click", function (e) { e.preventDefault(); downloadFile(el.dataset.dl); });
|
||||||
|
});
|
||||||
|
fileListDiv.querySelectorAll("[data-otp]").forEach(function (el) {
|
||||||
|
el.addEventListener("click", function () { generateOtpLink(el.dataset.otp); });
|
||||||
|
});
|
||||||
|
fileListDiv.querySelectorAll("[data-del]").forEach(function (el) {
|
||||||
|
el.addEventListener("click", function () { deleteFile(el.dataset.del); });
|
||||||
|
});
|
||||||
|
}).catch(function () {
|
||||||
|
toast("Error loading files");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$("btn-refresh").addEventListener("click", loadFiles);
|
||||||
|
|
||||||
|
// --- upload ---
|
||||||
|
dropZone.addEventListener("click", function () { fileInput.click(); });
|
||||||
|
dropZone.addEventListener("dragover", function (e) { e.preventDefault(); dropZone.classList.add("over"); });
|
||||||
|
dropZone.addEventListener("dragleave", function () { dropZone.classList.remove("over"); });
|
||||||
|
dropZone.addEventListener("drop", function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
dropZone.classList.remove("over");
|
||||||
|
uploadFiles(e.dataTransfer.files);
|
||||||
|
});
|
||||||
|
fileInput.addEventListener("change", function () {
|
||||||
|
uploadFiles(fileInput.files);
|
||||||
|
fileInput.value = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
function uploadFiles(fileList) {
|
||||||
|
Array.from(fileList).forEach(function (file) {
|
||||||
|
var reader = new FileReader();
|
||||||
|
reader.onload = function () {
|
||||||
|
api("/file", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "X-Filename": file.name, "Content-Type": file.type || "application/octet-stream" },
|
||||||
|
body: reader.result
|
||||||
|
}).then(function () {
|
||||||
|
toast("Uploaded: " + file.name);
|
||||||
|
loadFiles();
|
||||||
|
}).catch(function (err) {
|
||||||
|
toast("Upload error " + file.name + ": " + (err.error || err));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
reader.readAsArrayBuffer(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- view file ---
|
||||||
|
function viewFile(id) {
|
||||||
|
isEditing = false;
|
||||||
|
$("btn-viewer-edit").classList.add("hidden");
|
||||||
|
$("btn-viewer-save").classList.add("hidden");
|
||||||
|
$("btn-viewer-cancel").classList.add("hidden");
|
||||||
|
api("/file/" + id, { raw: true })
|
||||||
|
.then(function (res) {
|
||||||
|
var disp = res.headers.get("content-disposition") || "";
|
||||||
|
var fname = "";
|
||||||
|
var m = disp.match(/filename="?([^";]+)"?/);
|
||||||
|
if (m) fname = m[1];
|
||||||
|
currentViewFileId = id;
|
||||||
|
currentViewFilename = fname;
|
||||||
|
viewerFilename.textContent = fname;
|
||||||
|
return res.text().then(function (text) {
|
||||||
|
currentViewContent = text;
|
||||||
|
viewerContent.textContent = text;
|
||||||
|
viewerContent.classList.remove("hidden");
|
||||||
|
viewerEditor.classList.add("hidden");
|
||||||
|
$("btn-viewer-edit").classList.remove("hidden");
|
||||||
|
overlay.classList.remove("hidden");
|
||||||
|
}).catch(function () {
|
||||||
|
currentViewContent = "";
|
||||||
|
viewerContent.textContent = "[Binary file – use the Download button]";
|
||||||
|
viewerContent.classList.remove("hidden");
|
||||||
|
viewerEditor.classList.add("hidden");
|
||||||
|
overlay.classList.remove("hidden");
|
||||||
|
});
|
||||||
|
}).catch(function () { toast("File not found"); });
|
||||||
|
}
|
||||||
|
|
||||||
|
$("btn-viewer-edit").addEventListener("click", function () {
|
||||||
|
isEditing = true;
|
||||||
|
viewerContent.classList.add("hidden");
|
||||||
|
viewerEditor.classList.remove("hidden");
|
||||||
|
viewerEditor.value = currentViewContent;
|
||||||
|
$("btn-viewer-edit").classList.add("hidden");
|
||||||
|
$("btn-viewer-save").classList.remove("hidden");
|
||||||
|
$("btn-viewer-cancel").classList.remove("hidden");
|
||||||
|
viewerEditor.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
$("btn-viewer-save").addEventListener("click", function () {
|
||||||
|
var content = viewerEditor.value;
|
||||||
|
api("/file/" + currentViewFileId, { method: "PUT", body: content })
|
||||||
|
.then(function () {
|
||||||
|
currentViewContent = content;
|
||||||
|
isEditing = false;
|
||||||
|
viewerContent.textContent = content;
|
||||||
|
viewerContent.classList.remove("hidden");
|
||||||
|
viewerEditor.classList.add("hidden");
|
||||||
|
$("btn-viewer-edit").classList.remove("hidden");
|
||||||
|
$("btn-viewer-save").classList.add("hidden");
|
||||||
|
$("btn-viewer-cancel").classList.add("hidden");
|
||||||
|
toast("File saved");
|
||||||
|
}).catch(function (err) {
|
||||||
|
toast("Save error: " + (err.error || err));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
$("btn-viewer-cancel").addEventListener("click", function () {
|
||||||
|
isEditing = false;
|
||||||
|
viewerContent.classList.remove("hidden");
|
||||||
|
viewerEditor.classList.add("hidden");
|
||||||
|
$("btn-viewer-edit").classList.remove("hidden");
|
||||||
|
$("btn-viewer-save").classList.add("hidden");
|
||||||
|
$("btn-viewer-cancel").classList.add("hidden");
|
||||||
|
});
|
||||||
|
|
||||||
|
$("btn-viewer-close").addEventListener("click", function () { overlay.classList.add("hidden"); });
|
||||||
|
overlay.addEventListener("click", function (e) {
|
||||||
|
if (e.target === overlay) overlay.classList.add("hidden");
|
||||||
|
});
|
||||||
|
$("btn-viewer-download").addEventListener("click", function () {
|
||||||
|
if (currentViewFileId) downloadFile(currentViewFileId);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- download ---
|
||||||
|
function downloadFile(id) {
|
||||||
|
api("/file/" + id, { raw: true }).then(function (res) {
|
||||||
|
var disp = res.headers.get("content-disposition") || "";
|
||||||
|
var fname = "download";
|
||||||
|
var m = disp.match(/filename="?([^";]+)"?/);
|
||||||
|
if (m) fname = m[1];
|
||||||
|
return res.blob().then(function (blob) {
|
||||||
|
var a = document.createElement("a");
|
||||||
|
a.href = URL.createObjectURL(blob);
|
||||||
|
a.download = fname;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
URL.revokeObjectURL(a.href);
|
||||||
|
});
|
||||||
|
}).catch(function () { toast("Download error"); });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- delete ---
|
||||||
|
function deleteFile(id) {
|
||||||
|
if (!confirm("Really delete file #" + id + "?")) return;
|
||||||
|
api("/file/" + id, { method: "DELETE" }).then(function () {
|
||||||
|
toast("File deleted");
|
||||||
|
loadFiles();
|
||||||
|
}).catch(function (err) {
|
||||||
|
toast("Delete error: " + (err.error || err));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- copy link to clipboard ---
|
||||||
|
function generateOtpLink(fileId) {
|
||||||
|
api("/otp/" + fileId, { method: "POST" })
|
||||||
|
.then(function (data) {
|
||||||
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||||
|
navigator.clipboard.writeText(data.otp_link).then(function () {
|
||||||
|
toast("OTP link copied – valid for 24h, one-time use");
|
||||||
|
}).catch(function () {
|
||||||
|
fallbackCopy(data.otp_link);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
fallbackCopy(data.otp_link);
|
||||||
|
}
|
||||||
|
}).catch(function (err) {
|
||||||
|
toast("Error generating OTP: " + (err.error || err));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function fallbackCopy(text) {
|
||||||
|
var ta = document.createElement("textarea");
|
||||||
|
ta.value = text;
|
||||||
|
ta.style.position = "fixed";
|
||||||
|
ta.style.opacity = "0";
|
||||||
|
document.body.appendChild(ta);
|
||||||
|
ta.select();
|
||||||
|
try {
|
||||||
|
document.execCommand("copy");
|
||||||
|
toast("Link copied to clipboard");
|
||||||
|
} catch (e) {
|
||||||
|
toast("Copy failed – copy manually: " + text);
|
||||||
|
}
|
||||||
|
ta.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- helpers ---
|
||||||
|
function formatSize(b) {
|
||||||
|
if (b < 1024) return b + " B";
|
||||||
|
if (b < 1048576) return (b / 1024).toFixed(1) + " KB";
|
||||||
|
if (b < 1073741824) return (b / 1048576).toFixed(1) + " MB";
|
||||||
|
return (b / 1073741824).toFixed(1) + " GB";
|
||||||
|
}
|
||||||
|
|
||||||
|
function escHtml(s) {
|
||||||
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- init ---
|
||||||
|
if (apiKey) {
|
||||||
|
api("/files").then(function () { showApp(); })
|
||||||
|
.catch(function () { localStorage.removeItem("fu_api_key"); showAuth(); });
|
||||||
|
} else {
|
||||||
|
showAuth();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Loading…
x
Reference in New Issue
Block a user