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