first commit

This commit is contained in:
Radek Davidek 2026-05-22 17:42:38 +02:00
commit ca4428cf06
45 changed files with 3016 additions and 0 deletions

8
.dockerignore Normal file
View File

@ -0,0 +1,8 @@
target/
.idea/
.vscode/
*.iml
*.log
node_modules/
.git/
claude-projects/

10
.gitignore vendored Normal file
View File

@ -0,0 +1,10 @@
target/
*.jar
*.log
*.class
**.properties.local
.idea/
.vscode/
*.iml
.DS_Store
.claude

14
Dockerfile Normal file
View 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
View 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');

View 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
View 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>

View 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;
}
}

View 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);
}
}
}

View 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();
}
}
}

View 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();
}
}

View 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();
}
}
}
}

View 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();
}
}

View 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("\\\\", "\\");
}
}

View 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());
}
}
}
}
}

View 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);
}
}

View 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");
}
}

View File

@ -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");
}
}

View File

@ -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");
}
}
}
}
}

View File

@ -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");
}
}
}
}
}

View File

@ -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");
}
}
}
}
}

View 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/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());
}
}

View 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());
}
}

View File

@ -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"));
}
}

View File

@ -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; }
}
}

View 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());
}
}

View 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));
}
}
}
}
}
}

View 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);
}
}
}
}
}
}
}

View 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;
}
}

View 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());
}
}

View 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());
}
}

View 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));
}
}
}
}
}

View File

@ -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")));
}
}
}
}
}

View File

@ -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();
}
}
}

View 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");
}
}
}
}
}

View 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");
}
}
}
}
}

View File

@ -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"));
}
}

View 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;
}

View File

@ -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));
}
}

View 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);
}
}
}
}

View 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);
}
}
}
}

View 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); }
}

View 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");
}
}

View 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);
}
}

View 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

View 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 (872 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 (872)"
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
// --- init ---
if (apiKey) {
api("/files").then(function () { showApp(); })
.catch(function () { localStorage.removeItem("fu_api_key"); showAuth(); });
} else {
showAuth();
}
})();
</script>
</body>
</html>