This commit is contained in:
parent
b8ee1a58f7
commit
b9e97f6fbd
25
.gitea/workflows/build.yaml
Normal file
25
.gitea/workflows/build.yaml
Normal file
@ -0,0 +1,25 @@
|
||||
on:
|
||||
- push
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build the server
|
||||
runs-on: 'docker'
|
||||
container:
|
||||
node:current-alpine
|
||||
steps:
|
||||
- run: apk add git openjdk21-jdk
|
||||
- run: corepack enable
|
||||
- uses: actions/checkout@v3
|
||||
- run: corepack install
|
||||
working-directory: frontend
|
||||
- run: pnpm install --frozen-lockfile
|
||||
working-directory: frontend
|
||||
- run: pnpm run build
|
||||
working-directory: frontend
|
||||
- run: ./gradlew --no-daemon bootJar
|
||||
- run: mv build/libs/fileserver*.jar fileserver.jar
|
||||
- uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: fileserver.jar
|
||||
path: fileserver.jar
|
7
.gitignore
vendored
7
.gitignore
vendored
@ -35,3 +35,10 @@ out/
|
||||
|
||||
### VS Code ###
|
||||
.vscode/
|
||||
|
||||
application.properties
|
||||
data.xml
|
||||
data_old.xml
|
||||
data_new.xml
|
||||
files/
|
||||
logs/
|
||||
|
@ -1,10 +1,14 @@
|
||||
plugins {
|
||||
java
|
||||
idea
|
||||
id("org.springframework.boot") version "3.3.3"
|
||||
id("io.spring.dependency-management") version "1.1.6"
|
||||
id("org.graalvm.buildtools.native") version "0.10.2"
|
||||
id("io.freefair.lombok") version "8.10"
|
||||
}
|
||||
|
||||
idea
|
||||
|
||||
group = "de.mattv"
|
||||
version = "0.1.0"
|
||||
|
||||
@ -18,8 +22,31 @@ repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
idea {
|
||||
module {
|
||||
isDownloadJavadoc = true
|
||||
isDownloadSources = true
|
||||
}
|
||||
}
|
||||
|
||||
val copyIndexHtml by tasks.creating(Copy::class) {
|
||||
from("$projectDir/frontend/dist/index.html")
|
||||
into("${layout.buildDirectory.get()}/resources/main/public")
|
||||
}
|
||||
|
||||
tasks.processResources.get().dependsOn(copyIndexHtml)
|
||||
|
||||
dependencies {
|
||||
implementation("org.springframework.boot:spring-boot-starter-mail")
|
||||
implementation("org.springframework.boot:spring-boot-starter-security")
|
||||
implementation("org.springframework.security:spring-security-core")
|
||||
implementation("org.springframework.boot:spring-boot-starter-web")
|
||||
implementation("org.springframework.boot:spring-boot-starter-actuator")
|
||||
implementation("org.springframework.boot:spring-boot-starter-security")
|
||||
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0")
|
||||
implementation("com.thoughtworks.xstream:xstream:1.4.20")
|
||||
implementation("dev.samstevens.totp:totp:1.7")
|
||||
implementation("com.twelvemonkeys.imageio:imageio-webp:3.11.0")
|
||||
implementation("com.twelvemonkeys.imageio:imageio-jpeg:3.11.0")
|
||||
implementation("net.coobird:thumbnailator:0.4.20")
|
||||
implementation("org.bouncycastle:bcprov-jdk18on:1.78")
|
||||
}
|
||||
|
31
checkstyle.xml
Normal file
31
checkstyle.xml
Normal file
@ -0,0 +1,31 @@
|
||||
<?xml version="1.0"?>
|
||||
<!DOCTYPE module PUBLIC
|
||||
"-//Checkstyle//DTD Checkstyle Configuration 1.3//EN"
|
||||
"https://checkstyle.org/dtds/configuration_1_3.dtd">
|
||||
<module name="Checker">
|
||||
<module name="BeforeExecutionExclusionFileFilter">
|
||||
<property name="fileNamePattern" value="^build/"/>
|
||||
</module>
|
||||
<module name="TreeWalker">
|
||||
<module name="AbbreviationAsWordInName"/>
|
||||
<module name="AbstractClassName"/>
|
||||
<module name="CatchParameterName"/>
|
||||
<module name="ClassTypeParameterName"/>
|
||||
<module name="ConstantName"/>
|
||||
<module name="IllegalIdentifierName"/>
|
||||
<module name="InterfaceTypeParameterName"/>
|
||||
<module name="LambdaParameterName"/>
|
||||
<module name="LocalFinalVariableName"/>
|
||||
<module name="LocalVariableName"/>
|
||||
<module name="MemberName"/>
|
||||
<module name="MethodName"/>
|
||||
<module name="MethodTypeParameterName"/>
|
||||
<module name="PackageName"/>
|
||||
<module name="ParameterName"/>
|
||||
<module name="PatternVariableName"/>
|
||||
<module name="RecordComponentName"/>
|
||||
<module name="RecordTypeParameterName"/>
|
||||
<module name="StaticVariableName"/>
|
||||
<module name="TypeName"/>
|
||||
</module>
|
||||
</module>
|
1
frontend/favicon.svg.base64
Normal file
1
frontend/favicon.svg.base64
Normal file
@ -0,0 +1 @@
|
||||
PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB2aWV3Qm94PSIwIDAgMzIgMzIiPjxwYXRoIGQ9Ik0yOCAyMGgtMnYyaDJ2Nkg0di02aDJ2LTJINGEyLjAwMiAyLjAwMiAwIDAgMC0yIDJ2NmEyLjAwMiAyLjAwMiAwIDAgMCAyIDJoMjRhMi4wMDIgMi4wMDIgMCAwIDAgMi0ydi02YTIuMDAyIDIuMDAyIDAgMCAwLTItMnoiIGZpbGw9ImN1cnJlbnRDb2xvciI+PC9wYXRoPjxjaXJjbGUgY3g9IjciIGN5PSIyNSIgcj0iMSIgZmlsbD0iY3VycmVudENvbG9yIj48L2NpcmNsZT48cGF0aCBkPSJNMjIuNzA3IDcuMjkzbC01LTVBMSAxIDAgMCAwIDE3IDJoLTZhMi4wMDIgMi4wMDIgMCAwIDAtMiAydjE2YTIuMDAyIDIuMDAyIDAgMCAwIDIgMmgxMGEyLjAwMiAyLjAwMiAwIDAgMCAyLTJWOGExIDEgMCAwIDAtLjI5My0uNzA3ek0yMC41ODYgOEgxN1Y0LjQxNHpNMTEgMjBWNGg0djRhMi4wMDIgMi4wMDIgMCAwIDAgMiAyaDR2MTB6IiBmaWxsPSJjdXJyZW50Q29sb3IiPjwvcGF0aD48L3N2Zz4=
|
@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB2aWV3Qm94PSIwIDAgMzIgMzIiPjxwYXRoIGQ9Ik0yOCAyMGgtMnYyaDJ2Nkg0di02aDJ2LTJINGEyLjAwMiAyLjAwMiAwIDAgMC0yIDJ2NmEyLjAwMiAyLjAwMiAwIDAgMCAyIDJoMjRhMi4wMDIgMi4wMDIgMCAwIDAgMi0ydi02YTIuMDAyIDIuMDAyIDAgMCAwLTItMnoiIGZpbGw9ImN1cnJlbnRDb2xvciI+PC9wYXRoPjxjaXJjbGUgY3g9IjciIGN5PSIyNSIgcj0iMSIgZmlsbD0iY3VycmVudENvbG9yIj48L2NpcmNsZT48cGF0aCBkPSJNMjIuNzA3IDcuMjkzbC01LTVBMSAxIDAgMCAwIDE3IDJoLTZhMi4wMDIgMi4wMDIgMCAwIDAtMiAydjE2YTIuMDAyIDIuMDAyIDAgMCAwIDIgMmgxMGEyLjAwMiAyLjAwMiAwIDAgMCAyLTJWOGExIDEgMCAwIDAtLjI5My0uNzA3ek0yMC41ODYgOEgxN1Y0LjQxNHpNMTEgMjBWNGg0djRhMi4wMDIgMi4wMDIgMCAwIDAgMiAyaDR2MTB6IiBmaWxsPSJjdXJyZW50Q29sb3IiPjwvcGF0aD48L3N2Zz4=" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>MFileserver</title>
|
||||
</head>
|
||||
|
3581
frontend/package-lock.json
generated
3581
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -1,13 +1,15 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@9.0.0",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-check --tsconfig ./tsconfig.json"
|
||||
"check": "svelte-check --tsconfig ./tsconfig.json",
|
||||
"fetch-api": "openapi-typescript http://127.0.0.1:2121/openapi.json -o ./src/api/schema.d.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@iconify/json": "^2.2.132",
|
||||
@ -18,6 +20,7 @@
|
||||
"autoprefixer": "^10.4.14",
|
||||
"flowbite": "^1.8.1",
|
||||
"flowbite-svelte": "^0.44.18",
|
||||
"openapi-typescript": "^7.3.3",
|
||||
"postcss": "^8.4.24",
|
||||
"postcss-load-config": "^4.0.1",
|
||||
"svelte": "^4.0.5",
|
||||
@ -34,6 +37,7 @@
|
||||
"dependencies": {
|
||||
"@microsoft/fetch-event-source": "^2.0.1",
|
||||
"filesize": "^10.1.0",
|
||||
"openapi-fetch": "^0.12.0",
|
||||
"qrcode-svg": "^1.1.0",
|
||||
"svelte-spa-router": "^3.3.0",
|
||||
"tailwind-merge": "^1.14.0"
|
||||
|
3018
frontend/pnpm-lock.yaml
Normal file
3018
frontend/pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load Diff
@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 32 32"><path d="M28 20h-2v2h2v6H4v-6h2v-2H4a2.002 2.002 0 0 0-2 2v6a2.002 2.002 0 0 0 2 2h24a2.002 2.002 0 0 0 2-2v-6a2.002 2.002 0 0 0-2-2z" fill="currentColor"></path><circle cx="7" cy="25" r="1" fill="currentColor"></circle><path d="M22.707 7.293l-5-5A1 1 0 0 0 17 2h-6a2.002 2.002 0 0 0-2 2v16a2.002 2.002 0 0 0 2 2h10a2.002 2.002 0 0 0 2-2V8a1 1 0 0 0-.293-.707zM20.586 8H17V4.414zM11 20V4h4v4a2.002 2.002 0 0 0 2 2h4v10z" fill="currentColor"></path></svg>
|
Before Width: | Height: | Size: 557 B |
@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import {error_banner, info_banner, rpc, session, show_working, token, workingWrapperO} from './store';
|
||||
import {error_banner, info_banner, rpc, session, show_working, token, workingWrapper} from './store';
|
||||
import {Banner, Navbar, NavBrand, Spinner} from 'flowbite-svelte';
|
||||
import Router, {replace} from 'svelte-spa-router';
|
||||
import {routes} from './routes';
|
||||
@ -10,13 +10,13 @@
|
||||
const s = session.s;
|
||||
|
||||
async function leaveSudo() {
|
||||
await workingWrapperO(() => rpc.Admin_unsudo($token ?? ''));
|
||||
await workingWrapper(() => rpc.admin.unSudo());
|
||||
await session.update($token);
|
||||
await replace('/admin');
|
||||
}
|
||||
|
||||
function logout() {
|
||||
rpc.Auth_logout($token ?? '');
|
||||
async function logout() {
|
||||
await rpc.logout();
|
||||
token.set(null);
|
||||
}
|
||||
</script>
|
||||
|
@ -1,311 +0,0 @@
|
||||
import { fetchEventSource } from '@microsoft/fetch-event-source';
|
||||
|
||||
|
||||
export interface ResponseE {
|
||||
e: string,
|
||||
o: null
|
||||
}
|
||||
interface ResponseO<T> {
|
||||
e: null,
|
||||
o: T
|
||||
}
|
||||
export type Response<T> = ResponseE | ResponseO<T>;
|
||||
|
||||
export interface LoginResponse {
|
||||
otp_needed: boolean;
|
||||
token: (string|null);
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
name: string;
|
||||
tfa_enabled: boolean;
|
||||
admin: boolean;
|
||||
sudo: boolean;
|
||||
}
|
||||
|
||||
export interface UserInfo {
|
||||
id: number;
|
||||
name: string;
|
||||
tfa: boolean;
|
||||
admin: boolean;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface Node {
|
||||
id: number;
|
||||
name: string;
|
||||
file: boolean;
|
||||
preview: boolean;
|
||||
parent: (number|null);
|
||||
size: (number|null);
|
||||
children: (Node[]|null);
|
||||
}
|
||||
|
||||
export interface CreateNodeInfo {
|
||||
id: number;
|
||||
exists: boolean;
|
||||
file: boolean;
|
||||
}
|
||||
|
||||
export interface ZipInfo {
|
||||
done: boolean;
|
||||
progress: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface PathSegment {
|
||||
name: string;
|
||||
id: (number|null);
|
||||
}
|
||||
|
||||
|
||||
export class MRPCConnector {
|
||||
url: string;
|
||||
|
||||
private __create_msg(service: string, method: string, data: any) {
|
||||
return {service, method, data};
|
||||
}
|
||||
|
||||
public constructor(url: string) {
|
||||
this.url = url;
|
||||
}
|
||||
|
||||
|
||||
public Auth_signup(username: string, password: string): Promise<(string|null)> {
|
||||
const __msg = this.__create_msg('Auth', 'signup', {username,password});
|
||||
return fetch(this.url, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(__msg)
|
||||
}).then((__r) => __r.json());
|
||||
}
|
||||
|
||||
public Auth_login(username: string, password: string, otp: (string|null)): Promise<Response<LoginResponse>> {
|
||||
const __msg = this.__create_msg('Auth', 'login', {username,password,otp});
|
||||
return fetch(this.url, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(__msg)
|
||||
}).then((__r) => __r.json());
|
||||
}
|
||||
|
||||
public Auth_send_recovery_key(username: string): Promise<void> {
|
||||
const __msg = this.__create_msg('Auth', 'send_recovery_key', {username});
|
||||
return fetch(this.url, {method: 'POST', body: JSON.stringify(__msg)}).then(__r => {});
|
||||
}
|
||||
|
||||
public Auth_reset_password(key: string, password: string): Promise<(string|null)> {
|
||||
const __msg = this.__create_msg('Auth', 'reset_password', {key,password});
|
||||
return fetch(this.url, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(__msg)
|
||||
}).then((__r) => __r.json());
|
||||
}
|
||||
|
||||
public Auth_change_password(token: string, old_pw: string, new_pw: string): Promise<(string|null)> {
|
||||
const __msg = this.__create_msg('Auth', 'change_password', {token,old_pw,new_pw});
|
||||
return fetch(this.url, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(__msg)
|
||||
}).then((__r) => __r.json());
|
||||
}
|
||||
|
||||
public Auth_logout(token: string): Promise<void> {
|
||||
const __msg = this.__create_msg('Auth', 'logout', {token});
|
||||
return fetch(this.url, {method: 'POST', body: JSON.stringify(__msg)}).then(__r => {});
|
||||
}
|
||||
|
||||
public Auth_logout_all(token: string): Promise<(string|null)> {
|
||||
const __msg = this.__create_msg('Auth', 'logout_all', {token});
|
||||
return fetch(this.url, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(__msg)
|
||||
}).then((__r) => __r.json());
|
||||
}
|
||||
|
||||
public Auth_tfa_setup_mail(token: string): Promise<(string|null)> {
|
||||
const __msg = this.__create_msg('Auth', 'tfa_setup_mail', {token});
|
||||
return fetch(this.url, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(__msg)
|
||||
}).then((__r) => __r.json());
|
||||
}
|
||||
|
||||
public Auth_tfa_setup_totp(token: string): Promise<Response<string>> {
|
||||
const __msg = this.__create_msg('Auth', 'tfa_setup_totp', {token});
|
||||
return fetch(this.url, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(__msg)
|
||||
}).then((__r) => __r.json());
|
||||
}
|
||||
|
||||
public Auth_tfa_complete(token: string, otp: string): Promise<(string|null)> {
|
||||
const __msg = this.__create_msg('Auth', 'tfa_complete', {token,otp});
|
||||
return fetch(this.url, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(__msg)
|
||||
}).then((__r) => __r.json());
|
||||
}
|
||||
|
||||
public Auth_tfa_disable(token: string): Promise<(string|null)> {
|
||||
const __msg = this.__create_msg('Auth', 'tfa_disable', {token});
|
||||
return fetch(this.url, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(__msg)
|
||||
}).then((__r) => __r.json());
|
||||
}
|
||||
|
||||
public Auth_delete_user(token: string): Promise<(string|null)> {
|
||||
const __msg = this.__create_msg('Auth', 'delete_user', {token});
|
||||
return fetch(this.url, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(__msg)
|
||||
}).then((__r) => __r.json());
|
||||
}
|
||||
|
||||
public Auth_session_info(token: string): Promise<Response<Session>> {
|
||||
const __msg = this.__create_msg('Auth', 'session_info', {token});
|
||||
return fetch(this.url, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(__msg)
|
||||
}).then((__r) => __r.json());
|
||||
}
|
||||
|
||||
public Admin_list_users(token: string): Promise<Response<UserInfo[]>> {
|
||||
const __msg = this.__create_msg('Admin', 'list_users', {token});
|
||||
return fetch(this.url, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(__msg)
|
||||
}).then((__r) => __r.json());
|
||||
}
|
||||
|
||||
public Admin_delete_user(token: string, user: number): Promise<(string|null)> {
|
||||
const __msg = this.__create_msg('Admin', 'delete_user', {token,user});
|
||||
return fetch(this.url, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(__msg)
|
||||
}).then((__r) => __r.json());
|
||||
}
|
||||
|
||||
public Admin_logout(token: string, user: number): Promise<(string|null)> {
|
||||
const __msg = this.__create_msg('Admin', 'logout', {token,user});
|
||||
return fetch(this.url, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(__msg)
|
||||
}).then((__r) => __r.json());
|
||||
}
|
||||
|
||||
public Admin_disable_tfa(token: string, user: number): Promise<(string|null)> {
|
||||
const __msg = this.__create_msg('Admin', 'disable_tfa', {token,user});
|
||||
return fetch(this.url, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(__msg)
|
||||
}).then((__r) => __r.json());
|
||||
}
|
||||
|
||||
public Admin_set_admin(token: string, user: number, admin: boolean): Promise<(string|null)> {
|
||||
const __msg = this.__create_msg('Admin', 'set_admin', {token,user,admin});
|
||||
return fetch(this.url, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(__msg)
|
||||
}).then((__r) => __r.json());
|
||||
}
|
||||
|
||||
public Admin_set_enabled(token: string, user: number, enabled: boolean): Promise<(string|null)> {
|
||||
const __msg = this.__create_msg('Admin', 'set_enabled', {token,user,enabled});
|
||||
return fetch(this.url, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(__msg)
|
||||
}).then((__r) => __r.json());
|
||||
}
|
||||
|
||||
public Admin_sudo(token: string, user: number): Promise<(string|null)> {
|
||||
const __msg = this.__create_msg('Admin', 'sudo', {token,user});
|
||||
return fetch(this.url, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(__msg)
|
||||
}).then((__r) => __r.json());
|
||||
}
|
||||
|
||||
public Admin_unsudo(token: string): Promise<(string|null)> {
|
||||
const __msg = this.__create_msg('Admin', 'unsudo', {token});
|
||||
return fetch(this.url, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(__msg)
|
||||
}).then((__r) => __r.json());
|
||||
}
|
||||
|
||||
public Admin_shutdown(token: string): Promise<(string|null)> {
|
||||
const __msg = this.__create_msg('Admin', 'shutdown', {token});
|
||||
return fetch(this.url, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(__msg)
|
||||
}).then((__r) => __r.json());
|
||||
}
|
||||
|
||||
public FS_get_node(token: string, node: number): Promise<Response<Node>> {
|
||||
const __msg = this.__create_msg('FS', 'get_node', {token,node});
|
||||
return fetch(this.url, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(__msg)
|
||||
}).then((__r) => __r.json());
|
||||
}
|
||||
|
||||
public FS_get_path(token: string, node: number): Promise<Response<PathSegment[]>> {
|
||||
const __msg = this.__create_msg('FS', 'get_path', {token,node});
|
||||
return fetch(this.url, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(__msg)
|
||||
}).then((__r) => __r.json());
|
||||
}
|
||||
|
||||
public FS_get_nodes_size(token: string, nodes: number[]): Promise<Response<number>> {
|
||||
const __msg = this.__create_msg('FS', 'get_nodes_size', {token,nodes});
|
||||
return fetch(this.url, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(__msg)
|
||||
}).then((__r) => __r.json());
|
||||
}
|
||||
|
||||
public FS_create_node(token: string, file: boolean, parent: number, name: string): Promise<Response<CreateNodeInfo>> {
|
||||
const __msg = this.__create_msg('FS', 'create_node', {token,file,parent,name});
|
||||
return fetch(this.url, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(__msg)
|
||||
}).then((__r) => __r.json());
|
||||
}
|
||||
|
||||
public FS_move_nodes(token: string, nodes: number[], parent: number): Promise<(string|null)> {
|
||||
const __msg = this.__create_msg('FS', 'move_nodes', {token,nodes,parent});
|
||||
return fetch(this.url, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(__msg)
|
||||
}).then((__r) => __r.json());
|
||||
}
|
||||
|
||||
public FS_delete_nodes(token: string, nodes: number[], __cbk: (v: string|null) => void) {
|
||||
const __msg = this.__create_msg('FS', 'delete_nodes', {token,nodes});
|
||||
fetchEventSource(this.url, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(__msg),
|
||||
onmessage: __e => __cbk(JSON.parse(__e.data)),
|
||||
onerror: __e => {throw __e;},
|
||||
onclose: () => __cbk(null)
|
||||
});
|
||||
}
|
||||
|
||||
public FS_download_preview(token: string, node: number): Promise<Response<string>> {
|
||||
const __msg = this.__create_msg('FS', 'download_preview', {token,node});
|
||||
return fetch(this.url, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(__msg)
|
||||
}).then((__r) => __r.json());
|
||||
}
|
||||
|
||||
public FS_get_mime(token: string, node: number): Promise<Response<string>> {
|
||||
const __msg = this.__create_msg('FS', 'get_mime', {token,node});
|
||||
return fetch(this.url, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(__msg)
|
||||
}).then((__r) => __r.json());
|
||||
}
|
||||
|
||||
}
|
82
frontend/src/api/index.ts
Normal file
82
frontend/src/api/index.ts
Normal file
@ -0,0 +1,82 @@
|
||||
import type {paths, components} from './schema';
|
||||
import createClient from 'openapi-fetch';
|
||||
import {fetchEventSource} from '@microsoft/fetch-event-source';
|
||||
|
||||
const client = createClient<paths>();
|
||||
client.use({
|
||||
onRequest({ schemaPath, request }) {
|
||||
if (schemaPath.startsWith('/api/public') || rpc.token == '')
|
||||
return;
|
||||
|
||||
request.headers.set('Authorization', `Bearer ${rpc.token}`);
|
||||
return request;
|
||||
}
|
||||
})
|
||||
|
||||
export type Session = components['schemas']['de.mattv.fileserver.Response$Session'];
|
||||
export type Node = components['schemas']['de.mattv.fileserver.Response$Node'];
|
||||
export type PathSegment = components['schemas']['de.mattv.fileserver.Response$PathSegment'];
|
||||
export type CreateNodeInfo = components['schemas']['de.mattv.fileserver.Response$CreateNodeInfo'];
|
||||
export type UserInfo = components['schemas']['de.mattv.fileserver.Response$UserInfo'];
|
||||
|
||||
export const rpc = {
|
||||
token: '',
|
||||
|
||||
signup: (username: string, password: string) =>
|
||||
client.POST('/api/public/auth/signup', { body: { username, password } }).then(v => v.data),
|
||||
login: (username: string, password: string, otp?: string) =>
|
||||
client.POST('/api/public/auth/login', { body: { username, password, otp } }).then(v => v.data),
|
||||
|
||||
send_recovery_key: (username: string) =>
|
||||
client.POST('/api/public/auth/send_recovery_key', { body: username }).then(v => v.data),
|
||||
reset_password: (key: string, password: string) =>
|
||||
client.POST('/api/public/auth/reset_password', { body: { key, password } }).then(v => v.data),
|
||||
|
||||
|
||||
change_password: (oldPassword: string, newPassword: string) =>
|
||||
client.POST('/api/user/auth/change_password', { body: { oldPassword, newPassword } }).then(v => v.data),
|
||||
|
||||
logout: () => client.POST('/api/user/auth/logout').then(v => v.data),
|
||||
logoutAll: () => client.POST('/api/user/auth/logout_all').then(v => v.data),
|
||||
deleteAccount: () => client.POST('/api/user/auth/delete').then(v => v.data),
|
||||
sessionInfo: () => client.POST('/api/user/session').then(v => v.data),
|
||||
|
||||
tfaSetupMail: () => client.POST('/api/user/tfa/setup_mail').then(v => v.data),
|
||||
tfaSetupTotp: () => client.POST('/api/user/tfa/setup_totp').then(v => v.data),
|
||||
tfaComplete: (code: string) => client.POST('/api/user/tfa/complete', { body: code }).then(v => v.data),
|
||||
tfaDisable: () => client.POST('/api/user/tfa/disable').then(v => v.data),
|
||||
|
||||
|
||||
getNode: (node: number) => client.POST('/api/user/fs/node', { body: node }).then(v => v.data),
|
||||
getPath: (node: number) => client.POST('/api/user/fs/path', { body: node }).then(v => v.data),
|
||||
getNodesSize: (nodes: number[]) => client.POST('/api/user/fs/size', { body: nodes }).then(v => v.data),
|
||||
getMime: (node: number) => client.POST('/api/user/fs/mime', { body: node }).then(v => v.data),
|
||||
downloadPreview: (node: number) => client.POST('/api/user/fs/preview', { body: node }).then(v => v.data),
|
||||
|
||||
createNode: (name: string, parent: number, file: boolean) =>
|
||||
client.POST('/api/user/fs/create', { body: { name, parent, file } }).then(v => v.data),
|
||||
|
||||
deleteNodes: (nodes: number[], cbk: (v: string|null) => void) => fetchEventSource('/api/user/fs/delete', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(nodes),
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + rpc.token,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
onmessage: v => cbk(v.data),
|
||||
onerror: e => { throw e; },
|
||||
onclose: () => cbk(null)
|
||||
}),
|
||||
|
||||
admin: {
|
||||
listUsers: () => client.POST('/api/admin/users').then(v => v.data),
|
||||
setEnabled: (id: number, state: boolean) => client.POST('/api/admin/user/set_enabled', { body: { id, state } }).then(v => v.data),
|
||||
setAdmin: (id: number, state: boolean) => client.POST('/api/admin/user/set_admin', { body: { id, state } }).then(v => v.data),
|
||||
sudo: (id: number) => client.POST('/api/admin/user/sudo', { body: id }).then(v => v.data),
|
||||
logout: (id: number) => client.POST('/api/admin/user/logout', { body: id }).then(v => v.data),
|
||||
disableTfa: (id: number) => client.POST('/api/admin/user/disable_tfa', { body: id }).then(v => v.data),
|
||||
deleteUser: (id: number) => client.POST('/api/admin/user/delete', { body: id }).then(v => v.data),
|
||||
unSudo: () => client.POST('/api/admin/un_sudo').then(v => v.data),
|
||||
shutdown: () => client.POST('/api/admin/shutdown').then(v => v.data),
|
||||
}
|
||||
};
|
1197
frontend/src/api/schema.d.ts
vendored
Normal file
1197
frontend/src/api/schema.d.ts
vendored
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import {rpc, show_working, token} from '../store';
|
||||
import {rpc, show_working} from '../store';
|
||||
import {Button, ButtonGroup, Modal} from 'flowbite-svelte';
|
||||
import {afterUpdate, createEventDispatcher} from 'svelte';
|
||||
|
||||
@ -18,7 +18,7 @@
|
||||
show_working.set(true);
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
rpc.FS_delete_nodes($token ?? '', nodes, (v) => {
|
||||
rpc.deleteNodes(nodes, v => {
|
||||
if (v == null)
|
||||
resolve();
|
||||
else {
|
||||
|
@ -23,7 +23,7 @@
|
||||
} from 'flowbite-svelte';
|
||||
import {filesize} from 'filesize';
|
||||
import {Folder, FolderParent, DocumentBlank, CaretLeft, FolderAdd} from '../icons';
|
||||
import {api, download, token, rpc, workingWrapperR, error_banner} from '../store';
|
||||
import {api, download, rpc, workingWrapperR, error_banner} from '../store';
|
||||
import LinkButton from './LinkButton.svelte';
|
||||
import DeleteModal from './DeleteModal.svelte';
|
||||
import A from './A.svelte';
|
||||
@ -57,18 +57,18 @@
|
||||
return error_banner.set('Folder name can\'t be empty');
|
||||
|
||||
show_new_folder = false;
|
||||
const resp = await workingWrapperR<api.CreateNodeInfo>(() => rpc.FS_create_node($token ?? '', false, node.id, new_folder_name));
|
||||
if (resp && resp.file)
|
||||
const resp = await workingWrapperR<api.CreateNodeInfo>(() => rpc.createNode(new_folder_name, node.id, false));
|
||||
if (resp && resp.isFile)
|
||||
return error_banner.set('Folder already exists as file');
|
||||
|
||||
dispatch('reload_node');
|
||||
}
|
||||
|
||||
async function getPreview(node: number) {
|
||||
const resp = await rpc.FS_download_preview($token ?? '', node);
|
||||
if (resp.o == null)
|
||||
const resp = await rpc.downloadPreview(node);
|
||||
if (!resp)
|
||||
return;
|
||||
previews[node] = 'data:image/png;base64,' + resp.o;
|
||||
previews[node] = 'data:image/png;base64,' + resp;
|
||||
previews = previews;
|
||||
}
|
||||
|
||||
@ -93,11 +93,11 @@
|
||||
const selectFolders = () => selected = dirs.map(v => v.id);
|
||||
const selectFiles = () => selected = files.map(v => v.id);
|
||||
const selectNone = () => selected = [];
|
||||
const downloadSelected = () => download($token ?? '', nodes.filter(v => selected.includes(v.id)));
|
||||
const downloadSelected = () => download(nodes.filter(v => selected.includes(v.id)));
|
||||
const deleteSelected = () => del(selected);
|
||||
|
||||
|
||||
const onCtxDownload = () => download($token ?? '', [ctx_node]);
|
||||
const onCtxDownload = () => download([ctx_node]);
|
||||
|
||||
let del: (nodes: number[]) => Promise<void>;
|
||||
const onCtxDelete = () => del([ctx_node.id]);
|
||||
|
@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import {Button, Spinner} from 'flowbite-svelte';
|
||||
import {Download} from '../icons';
|
||||
import {api, download, rpc, token, workingWrapperR} from '../store';
|
||||
import {api, download, rpc, token, workingWrapper} from '../store';
|
||||
import {onDestroy} from 'svelte';
|
||||
|
||||
export let node: api.Node;
|
||||
@ -11,7 +11,7 @@
|
||||
|
||||
let mime: string|null;
|
||||
let image: boolean, video: boolean, audio: boolean, pdf: boolean, can_display: boolean;
|
||||
$: workingWrapperR<string>(() => rpc.FS_get_mime($token ?? '', node.id)).then(v => mime = v);
|
||||
$: workingWrapper(() => rpc.getMime(node.id)).then(v => mime = v || null);
|
||||
$: image = mime?.startsWith('image/') ?? false;
|
||||
$: video = mime?.startsWith('video/') ?? false;
|
||||
$: audio = mime?.startsWith('audio/') ?? false;
|
||||
@ -22,7 +22,7 @@
|
||||
loading = true;
|
||||
if (src.startsWith('blob'))
|
||||
URL.revokeObjectURL(src);
|
||||
const resp = await fetch('/download', {
|
||||
const resp = await fetch('/api/public/download', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: `token=${$token ?? ''}&node=${node.id}`
|
||||
@ -38,7 +38,7 @@
|
||||
onDestroy(() => { if (src.startsWith('blob')) URL.revokeObjectURL(src); });
|
||||
</script>
|
||||
|
||||
<Button class="w-full mb-6" on:click={() => download($token ?? '', [node])}><Download />Download</Button>
|
||||
<Button class="w-full mb-6" on:click={() => download([node])}><Download />Download</Button>
|
||||
{#if can_display && !loading && src === ''}
|
||||
<Button class="w-full" outline on:click={load}>Load</Button>
|
||||
{:else if loading}
|
||||
|
@ -49,9 +49,8 @@
|
||||
if (file.current == file.total)
|
||||
resolve(null);
|
||||
};
|
||||
xhr.open('POST', '/upload', true);
|
||||
xhr.setRequestHeader('X-Node', file.id.toString());
|
||||
xhr.setRequestHeader('X-Token', $token ?? '');
|
||||
xhr.open('POST', `/api/user/upload/${file.id}`, true);
|
||||
xhr.setRequestHeader('Authorization', 'Bearer ' + ($token ?? ''));
|
||||
xhr.send(file.file);
|
||||
});
|
||||
current += file.total - load_progress;
|
||||
|
@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import {api, rpc, session, token, workingWrapperO, workingWrapperR} from '../store';
|
||||
import {api, rpc, session, token, workingWrapper} from '../store';
|
||||
import {Checkbox, Table, TableBody, TableBodyCell, TableBodyRow, TableHead, TableHeadCell} from 'flowbite-svelte';
|
||||
import {Checkmark, Error} from '../icons';
|
||||
import LinkButton from '../components/LinkButton.svelte';
|
||||
@ -8,46 +8,44 @@
|
||||
let users: api.UserInfo[] = [];
|
||||
|
||||
async function fetchUsers() {
|
||||
const resp = await workingWrapperR<api.UserInfo[]>(() => rpc.Admin_list_users($token ?? ''));
|
||||
if (resp != null)
|
||||
users = resp;
|
||||
const resp = await workingWrapper(() => rpc.admin.listUsers());
|
||||
users = resp || [];
|
||||
}
|
||||
|
||||
async function changeEnabled(user: number, target: boolean) {
|
||||
await workingWrapperO(() => rpc.Admin_set_enabled($token ?? '', user, target));
|
||||
await workingWrapper(() => rpc.admin.setEnabled(user, target));
|
||||
await fetchUsers();
|
||||
}
|
||||
|
||||
async function changeAdmin(user: number, target: boolean) {
|
||||
await workingWrapperO(() => rpc.Admin_set_admin($token ?? '', user, target));
|
||||
await workingWrapper(() => rpc.admin.setAdmin(user, target));
|
||||
await fetchUsers();
|
||||
}
|
||||
|
||||
async function sudo(user: number) {
|
||||
if (await workingWrapperO(() => rpc.Admin_sudo($token ?? '', user))) {
|
||||
await session.update($token);
|
||||
await replace('/view/0');
|
||||
}
|
||||
await workingWrapper(() => rpc.admin.sudo(user))
|
||||
await session.update('');
|
||||
await replace('/view/0');
|
||||
}
|
||||
|
||||
async function logout(user: number) {
|
||||
await workingWrapperO(() => rpc.Admin_logout($token ?? '', user));
|
||||
await workingWrapper(() => rpc.admin.logout(user));
|
||||
await fetchUsers();
|
||||
}
|
||||
|
||||
async function removeTfa(user: number) {
|
||||
await workingWrapperO(() => rpc.Admin_disable_tfa($token ?? '', user));
|
||||
await workingWrapper(() => rpc.admin.disableTfa(user));
|
||||
await fetchUsers();
|
||||
}
|
||||
|
||||
async function deleteUser(user: number) {
|
||||
await workingWrapperO(() => rpc.Admin_delete_user($token ?? '', user));
|
||||
await workingWrapper(() => rpc.admin.deleteUser(user));
|
||||
await fetchUsers();
|
||||
}
|
||||
|
||||
async function shutdown() {
|
||||
if (confirm('Do you really want to shutdown the server?')) {
|
||||
await rpc.Admin_shutdown($token ?? '');
|
||||
await rpc.admin.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
@ -66,7 +64,7 @@
|
||||
{#each users as user (user.id)}
|
||||
<TableBodyRow>
|
||||
<TableBodyCell>{user.name}</TableBodyCell>
|
||||
<TableBodyCell>{#if user.tfa}<Checkmark/>{:else}<Error/>{/if}</TableBodyCell>
|
||||
<TableBodyCell>{#if user.tfaEnabled}<Checkmark/>{:else}<Error/>{/if}</TableBodyCell>
|
||||
<TableBodyCell>
|
||||
<Checkbox checked={user.enabled} on:change={changeEnabled.bind(null, user.id, !user.enabled)}></Checkbox>
|
||||
</TableBodyCell>
|
||||
@ -76,7 +74,7 @@
|
||||
<TableBodyCell class="flex">
|
||||
<LinkButton class="flex-auto" on:click={sudo.bind(null, user.id)}>Sudo</LinkButton>
|
||||
<LinkButton class="flex-auto" on:click={logout.bind(null, user.id)}>Logout</LinkButton>
|
||||
{#if user.tfa}<LinkButton class="flex-auto" color="amber" on:click={removeTfa.bind(null, user.id)}>Remove tfa</LinkButton>{/if}
|
||||
{#if user.tfaEnabled}<LinkButton class="flex-auto" color="amber" on:click={removeTfa.bind(null, user.id)}>Remove tfa</LinkButton>{/if}
|
||||
<LinkButton class="flex-auto" color="red" on:click={deleteUser.bind(null, user.id)}>Delete</LinkButton>
|
||||
</TableBodyCell>
|
||||
</TableBodyRow>
|
||||
|
@ -1,21 +1,22 @@
|
||||
<script lang="ts">
|
||||
import {Button, ButtonGroup, Card, Input, InputAddon} from 'flowbite-svelte';
|
||||
import {Email, OTP, Password} from '../icons';
|
||||
import {rpc, token, workingWrapperR, api} from '../store';
|
||||
import {rpc, token, workingWrapperR} from '../store';
|
||||
import {replace} from 'svelte-spa-router';
|
||||
|
||||
let ask_tfa = false;
|
||||
let username = '', password = '', tfa = '';
|
||||
|
||||
async function login() {
|
||||
const resp = await workingWrapperR<api.LoginResponse>(() => rpc.Auth_login(username, password, ask_tfa ? tfa : null));
|
||||
const resp = await workingWrapperR(() => rpc.login(username, password, ask_tfa ? tfa : undefined));
|
||||
if (!resp) return;
|
||||
if (resp.otp_needed) {
|
||||
if (resp.otpNeeded) {
|
||||
ask_tfa = true;
|
||||
return;
|
||||
} else if (resp.token) {
|
||||
token.set(resp.token);
|
||||
await replace('/view/0');
|
||||
}
|
||||
token.set(resp.token);
|
||||
await replace('/view/0');
|
||||
}
|
||||
|
||||
function keyUp(e: KeyboardEvent) {
|
||||
|
@ -1,11 +1,11 @@
|
||||
<script lang="ts">
|
||||
import {error_banner, rpc, session, token, workingWrapperO} from '../store';
|
||||
import {error_banner, rpc, session, token, workingWrapper, workingWrapperO} from '../store';
|
||||
import {Accordion, AccordionItem, Button, ButtonGroup, Input, InputAddon} from 'flowbite-svelte';
|
||||
import {Password} from '../icons';
|
||||
import {info_banner} from '../store.js';
|
||||
|
||||
const s = session.s;
|
||||
const tfa_enabled: boolean = $s?.tfa_enabled ?? false;
|
||||
const tfa_enabled: boolean = $s?.tfaEnabled ?? false;
|
||||
const change_pw_data = {o: '', n: '', n2: ''}
|
||||
|
||||
async function changePw() {
|
||||
@ -15,28 +15,29 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const resp = await workingWrapperO(() => rpc.Auth_change_password($token ?? '', old, password));
|
||||
const resp = await workingWrapperO(() => rpc.change_password(old, password));
|
||||
if (resp) {
|
||||
info_banner.set('Changed password');
|
||||
change_pw_data.o = '';
|
||||
change_pw_data.n = '';
|
||||
change_pw_data.n2 = '';
|
||||
token.set(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function disableTfa() {
|
||||
await workingWrapperO(() => rpc.Auth_tfa_disable($token ?? ''));
|
||||
await workingWrapper(() => rpc.tfaDisable());
|
||||
token.set(null);
|
||||
}
|
||||
|
||||
async function logoutAll() {
|
||||
await workingWrapperO(() => rpc.Auth_logout_all($token ?? ''));
|
||||
await workingWrapper(() => rpc.logoutAll());
|
||||
token.set(null);
|
||||
}
|
||||
|
||||
async function deleteAccount() {
|
||||
if (confirm("Do your really want to delete your account?")) {
|
||||
await workingWrapperO(() => rpc.Auth_delete_user($token ?? ''));
|
||||
await workingWrapper(() => rpc.deleteAccount());
|
||||
token.set(null);
|
||||
}
|
||||
}
|
||||
|
@ -8,7 +8,7 @@
|
||||
let username = '', key = '', password = '', password2 = '';
|
||||
|
||||
async function sendKey() {
|
||||
await workingWrapper(() => rpc.Auth_send_recovery_key(username));
|
||||
await workingWrapper(() => rpc.send_recovery_key(username));
|
||||
info_banner.set('A message has been sent');
|
||||
enter_key = true;
|
||||
}
|
||||
@ -19,7 +19,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
if (await workingWrapperO(() => rpc.Auth_reset_password(key, password)))
|
||||
if (await workingWrapperO(() => rpc.reset_password(key, password)))
|
||||
await replace('/login');
|
||||
}
|
||||
|
||||
|
@ -17,7 +17,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const resp = await workingWrapperO(() => rpc.Auth_signup(username, password));
|
||||
const resp = await workingWrapperO(() => rpc.signup(username, password));
|
||||
|
||||
if (resp) {
|
||||
info_banner.set('Account created, please wait till an administrator approves it');
|
||||
|
@ -3,7 +3,6 @@
|
||||
import {OTP} from '../icons';
|
||||
import {info_banner, rpc, session, token, workingWrapperO, workingWrapperR} from '../store';
|
||||
import QRCode from 'qrcode-svg';
|
||||
import {replace} from 'svelte-spa-router';
|
||||
|
||||
const s = session.s;
|
||||
|
||||
@ -14,13 +13,13 @@
|
||||
|
||||
async function startSetup(mail: boolean) {
|
||||
if (mail) {
|
||||
const resp = await workingWrapperO(() => rpc.Auth_tfa_setup_mail($token ?? ''));
|
||||
const resp = await workingWrapperO(() => rpc.tfaSetupMail());
|
||||
if (resp) {
|
||||
secret = null;
|
||||
step = 2;
|
||||
}
|
||||
} else {
|
||||
const resp = await workingWrapperR<string>(() => rpc.Auth_tfa_setup_totp($token ?? ''));
|
||||
const resp = await workingWrapperR<string>(() => rpc.tfaSetupTotp());
|
||||
if (resp != null) {
|
||||
secret = resp.replaceAll('=', '');
|
||||
secret_qr_code = new QRCode({
|
||||
@ -34,7 +33,7 @@
|
||||
}
|
||||
|
||||
async function completeSetup() {
|
||||
if (await workingWrapperO(() => rpc.Auth_tfa_complete($token ?? '', code))) {
|
||||
if (await workingWrapperO(() => rpc.tfaComplete(code))) {
|
||||
info_banner.set("Successfully set up two factor authentication");
|
||||
token.set(null);
|
||||
}
|
||||
|
@ -2,7 +2,7 @@
|
||||
import {Breadcrumb, Dropzone, Modal, Progressbar} from 'flowbite-svelte';
|
||||
import {writable} from 'svelte/store';
|
||||
import {CloudUpload} from '../icons';
|
||||
import {api, rpc, token, type UploadFile, workingWrapperR} from '../store';
|
||||
import {api, rpc, token, type UploadFile, workingWrapper, workingWrapperR} from '../store';
|
||||
import DirViewer from '../components/DirViewer.svelte';
|
||||
import UploadModal from '../components/UploadModal.svelte';
|
||||
import FileViewer from '../components/FileViewer.svelte';
|
||||
@ -25,10 +25,10 @@
|
||||
|
||||
const data = writable<Data>({node: null, segments: []});
|
||||
async function updateData(id: number) {
|
||||
let node = await workingWrapperR<api.Node>(() => rpc.FS_get_node($token ?? '', id));
|
||||
let node = await workingWrapper(() => rpc.getNode(id));
|
||||
if (!node)
|
||||
return;
|
||||
let segments = await workingWrapperR<api.PathSegment[]>(() => rpc.FS_get_path($token ?? '', id));
|
||||
let segments = await workingWrapper(() => rpc.getPath(id));
|
||||
if (!segments)
|
||||
return;
|
||||
data.set({node: node as Data['node'], segments });
|
||||
@ -50,9 +50,9 @@
|
||||
return [];
|
||||
}
|
||||
}
|
||||
const resp = await workingWrapperR<api.CreateNodeInfo>(() => rpc.FS_create_node($token ?? '', false, parent, entry.name));
|
||||
const resp = await workingWrapperR<api.CreateNodeInfo>(() => rpc.createNode(entry.name, parent, false));
|
||||
if (!resp) return [];
|
||||
if (resp.file) return [];
|
||||
if (resp.isFile) return [];
|
||||
const reader = (entry as FileSystemDirectoryEntry).createReader();
|
||||
const files: UploadFile[] = [];
|
||||
const name = parent_name + entry.name + '/';
|
||||
@ -109,8 +109,8 @@
|
||||
upload_progress_data.current = 0;
|
||||
const upload_files: UploadFile[] = [];
|
||||
for (const file of files) {
|
||||
const resp = await workingWrapperR<api.CreateNodeInfo>(() => rpc.FS_create_node($token ?? '', true, file.id, file.name));
|
||||
if (resp && resp.file)
|
||||
const resp = await workingWrapperR<api.CreateNodeInfo>(() => rpc.createNode(file.name, file.id, true));
|
||||
if (resp && resp.isFile)
|
||||
upload_files.push({ ...file, id: resp.id, overwrite: resp.exists });
|
||||
upload_progress_data.current++;
|
||||
}
|
||||
|
@ -1,8 +1,9 @@
|
||||
import {MRPCConnector, type Session, type Response} from './api';
|
||||
import {type Session, rpc} from './api';
|
||||
import {type Writable, writable} from 'svelte/store';
|
||||
import {filesize} from 'filesize';
|
||||
|
||||
export * as api from './api';
|
||||
export {rpc} from './api';
|
||||
|
||||
export interface UploadFile {
|
||||
id: number,
|
||||
@ -16,8 +17,6 @@ export const show_working = writable<boolean>(false);
|
||||
export const info_banner = writable<string>('');
|
||||
export const error_banner = writable<string>('');
|
||||
|
||||
export const rpc = new MRPCConnector('/mrpc');
|
||||
|
||||
export const token = writable<string|null>(localStorage.getItem('token'));
|
||||
export const session: { s: Writable<Session|null>, update: (token: string|null) => Promise<void> } = {
|
||||
s: writable(null),
|
||||
@ -26,14 +25,15 @@ export const session: { s: Writable<Session|null>, update: (token: string|null)
|
||||
session.s.set(null);
|
||||
return;
|
||||
}
|
||||
const s = await rpc.Auth_session_info(t)
|
||||
if (s.e)
|
||||
const s = await rpc.sessionInfo();
|
||||
if (!s)
|
||||
token.set(null);
|
||||
else
|
||||
session.s.set(s.o);
|
||||
session.s.set(s);
|
||||
}
|
||||
};
|
||||
token.subscribe((t) => session.update(t));
|
||||
token.subscribe(t => rpc.token = t ?? '');
|
||||
token.subscribe(t => session.update(t));
|
||||
|
||||
token.subscribe(v => {
|
||||
if (v == null)
|
||||
@ -44,49 +44,50 @@ token.subscribe(v => {
|
||||
|
||||
export async function workingWrapper<T>(fn: () => Promise<T>): Promise<T|null> {
|
||||
let r = null;
|
||||
info_banner.set('');
|
||||
error_banner.set('');
|
||||
show_working.set(true);
|
||||
try {
|
||||
r = await fn();
|
||||
} catch (e) {
|
||||
error_banner.set(`Error while making request: ${e}`);
|
||||
token.set(null);
|
||||
}
|
||||
show_working.set(false);
|
||||
return r;
|
||||
}
|
||||
|
||||
export async function workingWrapperO(fn: () => Promise<string|null>): Promise<boolean> {
|
||||
export async function workingWrapperO(fn: () => Promise<string|undefined>): Promise<boolean> {
|
||||
const resp = await workingWrapper(fn);
|
||||
if (resp)
|
||||
error_banner.set(resp);
|
||||
if (resp === 'Unauthorized')
|
||||
token.set(null);
|
||||
return resp == null;
|
||||
return resp == undefined;
|
||||
}
|
||||
|
||||
export async function workingWrapperR<T>(fn: () => Promise<Response<T>>): Promise<T|null> {
|
||||
export async function workingWrapperR<T>(fn: () => Promise<{
|
||||
e?: string,
|
||||
o?: T
|
||||
} | undefined>): Promise<T|null> {
|
||||
const resp = await workingWrapper(fn);
|
||||
if (!resp)
|
||||
return null;
|
||||
if (resp.e === 'Unauthorized')
|
||||
token.set(null);
|
||||
else if (resp.e != null)
|
||||
if (resp.e != null)
|
||||
error_banner.set(resp.e);
|
||||
return resp.o;
|
||||
return resp.o as unknown as T;
|
||||
}
|
||||
|
||||
export async function download<T extends {id:number, file:boolean}>(token: string, nodes: T[]) {
|
||||
export async function download<T extends {id:number, file:boolean}>(nodes: T[]) {
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.target = '_blank';
|
||||
form.innerHTML = `<input type="hidden" name="token" value="${token}">`;
|
||||
if (nodes.length == 1 && nodes[0].file) {
|
||||
form.action = '/download';
|
||||
form.innerHTML += `<input type="hidden" name="node" value="${nodes[0].id}}">`;
|
||||
form.innerHTML = `<input type="hidden" name="token" value="${rpc.token}">`;
|
||||
if (nodes.length == 1 && nodes[0] && nodes[0].file) {
|
||||
form.action = '/api/public/download';
|
||||
form.innerHTML += `<input type="hidden" name="node" value="${nodes[0].id}">`;
|
||||
} else {
|
||||
form.action = '/download_multi';
|
||||
form.action = '/api/public/download_multi';
|
||||
form.innerHTML += `<input type="hidden" name="nodes" value="${nodes.map(n => n.id).join('.')}">`;
|
||||
const resp = await workingWrapperR<number>(() => rpc.FS_get_nodes_size(token, nodes.map(v => v.id)));
|
||||
const resp = await workingWrapper(() => rpc.getNodesSize(nodes.map(v => v.id)));
|
||||
if (!resp)
|
||||
return;
|
||||
info_banner.set(`Estimated size: ${filesize(resp, {base: 2, standard: 'jedec'})}`);
|
||||
|
@ -13,7 +13,8 @@
|
||||
*/
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"isolatedModules": true
|
||||
"isolatedModules": true,
|
||||
"noUncheckedIndexedAccess": true
|
||||
},
|
||||
"include": ["src/**/*.d.ts", "src/**/*.ts", "src/**/*.js", "src/**/*.svelte"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
|
@ -4,42 +4,9 @@ import {viteSingleFile} from 'vite-plugin-singlefile';
|
||||
import {createHtmlPlugin} from 'vite-plugin-html';
|
||||
import purgeCss from 'vite-plugin-tailwind-purgecss';
|
||||
import Icons from 'unplugin-icons/vite';
|
||||
import {NormalizedInputOptions, PluginContext} from 'rollup';
|
||||
import * as fs from 'fs';
|
||||
import * as child_process from 'child_process';
|
||||
|
||||
const response_replacement =
|
||||
'interface ResponseE {\n' +
|
||||
' e: string,\n' +
|
||||
' o: null\n' +
|
||||
'}\n' +
|
||||
'interface ResponseO<T> {\n' +
|
||||
' e: null,\n' +
|
||||
' o: T\n' +
|
||||
'}\n' +
|
||||
'export type Response<T> = ResponseE | ResponseO<T>;'
|
||||
|
||||
function checkMrpc(this: PluginContext, _options: NormalizedInputOptions) {
|
||||
const src_ts = fs.statSync('../fileserver.rs').mtimeMs;
|
||||
const update = !fs.existsSync('src/api.ts') || fs.statSync('src/api.ts').mtimeMs <= src_ts;
|
||||
if (!update)
|
||||
return;
|
||||
child_process.spawnSync(
|
||||
'../mrpc',
|
||||
['-n', 'src/api', '-c', 'ts', '../fileserver.rs'],
|
||||
{ stdio: 'inherit' }
|
||||
);
|
||||
let api_content = fs.readFileSync('src/api.ts', 'utf8');
|
||||
api_content = api_content.replace(
|
||||
'interface Response<T> {\n e: (string|null);\n o: (T|null);\n}',
|
||||
response_replacement
|
||||
);
|
||||
fs.writeFileSync('src/api.ts', api_content, 'utf8');
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
{ name: 'mrpc', buildStart: checkMrpc },
|
||||
svelte(),
|
||||
Icons({ compiler: 'svelte' }),
|
||||
purgeCss(),
|
||||
@ -51,11 +18,9 @@ export default defineConfig({
|
||||
},
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 2345,
|
||||
proxy: {
|
||||
'/mrpc': 'http://127.0.0.1:2121',
|
||||
'/download': 'http://127.0.0.1:2121',
|
||||
'/download_multi': 'http://127.0.0.1:2121',
|
||||
'/upload': 'http://127.0.0.1:2121'
|
||||
'/api': 'http://127.0.0.1:2121'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
80
migrate.py
Normal file
80
migrate.py
Normal file
@ -0,0 +1,80 @@
|
||||
import json
|
||||
import pathlib
|
||||
import lxml.etree
|
||||
import lxml.builder
|
||||
from typing import Any
|
||||
|
||||
base = pathlib.Path('data')
|
||||
base.mkdir(exist_ok=True)
|
||||
|
||||
data: dict[str, Any]
|
||||
|
||||
with open('data.json', 'r') as f:
|
||||
data = json.load(f)
|
||||
|
||||
E = lxml.builder.ElementMaker()
|
||||
DATA = E.data
|
||||
USER = E.user
|
||||
NODE = E.node
|
||||
|
||||
|
||||
def parse_node(nodes: dict[int, dict[str, object]], nid: int):
|
||||
n = nodes[nid]
|
||||
|
||||
flags = 0
|
||||
if n['file']: flags = flags | 1
|
||||
if n['preview']: flags = flags | 2
|
||||
|
||||
|
||||
xn = NODE({
|
||||
'id': str(n['id']),
|
||||
'name': str(n['name']),
|
||||
'flags': str(flags)
|
||||
})
|
||||
if not n['file']:
|
||||
for c in n['children']:
|
||||
xn.append(parse_node(nodes, c))
|
||||
else:
|
||||
xn.attrib['size'] = str(n['size'])
|
||||
|
||||
return xn
|
||||
|
||||
|
||||
doc = DATA({
|
||||
'version': '1',
|
||||
'next-user': str(data['next_user_id'])
|
||||
})
|
||||
|
||||
users: list[dict[str, list[dict[str, Any]]]] = data['users']
|
||||
for user in users:
|
||||
new_nodes = {}
|
||||
for node in user['nodes']:
|
||||
new_nodes[node['id']] = node
|
||||
|
||||
flags = 0
|
||||
if user['enabled']:
|
||||
flags = flags | 1
|
||||
if user['admin']:
|
||||
flags = flags | 2
|
||||
|
||||
|
||||
xu = USER({
|
||||
'id': str(user['id']),
|
||||
'name': user['name'],
|
||||
'password': user['password'],
|
||||
'flags': str(flags),
|
||||
'next-node': str(user['next_node_id'])
|
||||
})
|
||||
if user['tfa_enabled']:
|
||||
xu.attrib['tfa'] = user['tfa_secret']
|
||||
|
||||
for c in new_nodes[0]['children']:
|
||||
xu.append(parse_node(new_nodes, c))
|
||||
|
||||
doc.append(xu)
|
||||
|
||||
|
||||
|
||||
|
||||
with open('data.xml', 'wb') as f:
|
||||
f.write(lxml.etree.tostring(doc, pretty_print=True))
|
44
src/main/java/de/mattv/fileserver/ExceptionHandler.java
Normal file
44
src/main/java/de/mattv/fileserver/ExceptionHandler.java
Normal file
@ -0,0 +1,44 @@
|
||||
package de.mattv.fileserver;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.NonNull;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.client.RestClient;
|
||||
import org.springframework.web.servlet.ModelAndView;
|
||||
import org.springframework.web.servlet.mvc.support.DefaultHandlerExceptionResolver;
|
||||
|
||||
import java.io.PrintWriter;
|
||||
import java.io.StringWriter;
|
||||
|
||||
@Component
|
||||
public class ExceptionHandler extends DefaultHandlerExceptionResolver {
|
||||
private record GotifyMessage(String title, String message) {}
|
||||
|
||||
@Value("${gotify.url:}")
|
||||
private String gotifyUrl;
|
||||
|
||||
@Value("${gotify.token:}")
|
||||
private String gotifyToken;
|
||||
|
||||
private RestClient restClient = null;
|
||||
|
||||
@Override
|
||||
protected ModelAndView doResolveException(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, Object handler, @NonNull Exception ex) {
|
||||
if (!gotifyToken.isEmpty() && !gotifyUrl.isEmpty()) {
|
||||
if (restClient == null)
|
||||
restClient = RestClient.builder().baseUrl(gotifyUrl).build();
|
||||
|
||||
StringWriter writer = new StringWriter();
|
||||
PrintWriter printWriter = new PrintWriter(writer);
|
||||
ex.printStackTrace(printWriter);
|
||||
printWriter.flush();
|
||||
restClient.post()
|
||||
.uri("/message?token=" + gotifyToken)
|
||||
.body(new GotifyMessage("Encountered an error", writer.toString()))
|
||||
.retrieve();
|
||||
}
|
||||
return super.doResolveException(request, response, handler, ex);
|
||||
}
|
||||
}
|
@ -1,11 +1,68 @@
|
||||
package de.mattv.fileserver;
|
||||
|
||||
import de.mattv.fileserver.util.NonNull;
|
||||
import jakarta.servlet.RequestDispatcher;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.boot.autoconfigure.http.HttpMessageConverters;
|
||||
import org.springframework.boot.web.servlet.FilterRegistrationBean;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.http.HttpOutputMessage;
|
||||
import org.springframework.http.converter.HttpMessageConverter;
|
||||
import org.springframework.http.converter.HttpMessageNotWritableException;
|
||||
import org.springframework.http.converter.StringHttpMessageConverter;
|
||||
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
|
||||
import jakarta.servlet.Filter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.Type;
|
||||
import java.util.List;
|
||||
|
||||
@SpringBootApplication
|
||||
public class FileServerApplication {
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(FileServerApplication.class, args);
|
||||
}
|
||||
|
||||
public static class MessageConverter extends MappingJackson2HttpMessageConverter {
|
||||
@Override
|
||||
protected void writeInternal(@NonNull @lombok.NonNull Object object, Type type, @NonNull HttpOutputMessage outputMessage)
|
||||
throws IOException, HttpMessageNotWritableException {
|
||||
byte[] data = getObjectMapper().writeValueAsBytes(object);
|
||||
outputMessage.getHeaders().add("Content-Length", String.valueOf(data.length));
|
||||
outputMessage.getBody().write(data);
|
||||
}
|
||||
}
|
||||
|
||||
@Bean
|
||||
public HttpMessageConverters createHttpMessageConverter() {
|
||||
return new HttpMessageConverters(new MessageConverter()) {
|
||||
@Override
|
||||
protected List<HttpMessageConverter<?>> postProcessConverters(List<HttpMessageConverter<?>> converters) {
|
||||
return converters.stream().filter(v -> !v.getClass().equals(StringHttpMessageConverter.class)).toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected List<HttpMessageConverter<?>> postProcessPartConverters(List<HttpMessageConverter<?>> converters) {
|
||||
return converters.stream().filter(v -> !v.getClass().equals(StringHttpMessageConverter.class)).toList();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Bean
|
||||
public FilterRegistrationBean<Filter> nonApiToIndex() {
|
||||
return new FilterRegistrationBean<>((servletRequest, servletResponse, chain) -> {
|
||||
String uri = ((HttpServletRequest) servletRequest).getRequestURI();
|
||||
if (uri.startsWith("/api")) {
|
||||
chain.doFilter(servletRequest, servletResponse);
|
||||
} else if (uri.equals("/")) {
|
||||
RequestDispatcher requestDispatcher = servletRequest.getRequestDispatcher("/index.html");
|
||||
requestDispatcher.forward(servletRequest, servletResponse);
|
||||
} else {
|
||||
((HttpServletResponse) servletResponse).sendRedirect("/");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
70
src/main/java/de/mattv/fileserver/Response.java
Normal file
70
src/main/java/de/mattv/fileserver/Response.java
Normal file
@ -0,0 +1,70 @@
|
||||
package de.mattv.fileserver;
|
||||
|
||||
import de.mattv.fileserver.util.NonNull;
|
||||
import jakarta.annotation.Nullable;
|
||||
|
||||
public class Response<T> {
|
||||
public static <S> Response<S> e(@NonNull String v) { return new Response<>(v, null); }
|
||||
public static <S> Response<S> o(@NonNull S v) { return new Response<>(null, v); }
|
||||
|
||||
public final @Nullable String e;
|
||||
public final @Nullable T o;
|
||||
|
||||
private Response(@Nullable String e, @Nullable T o) {
|
||||
this.e = e;
|
||||
this.o = o;
|
||||
}
|
||||
|
||||
public record Login(
|
||||
@NonNull boolean otpNeeded,
|
||||
@Nullable String token
|
||||
) {}
|
||||
|
||||
public record Session(
|
||||
@NonNull String name,
|
||||
@NonNull boolean tfaEnabled,
|
||||
@NonNull boolean admin,
|
||||
@NonNull boolean sudo
|
||||
) {}
|
||||
|
||||
public record UserInfo(
|
||||
@NonNull long id,
|
||||
@NonNull String name,
|
||||
@NonNull boolean tfaEnabled,
|
||||
@NonNull boolean enabled,
|
||||
@NonNull boolean admin
|
||||
) {}
|
||||
|
||||
public record CreateNodeInfo(
|
||||
@NonNull long id,
|
||||
@NonNull boolean exists,
|
||||
@NonNull boolean isFile
|
||||
) {}
|
||||
|
||||
public record PathSegment(
|
||||
@NonNull String name,
|
||||
@Nullable Long id
|
||||
) {}
|
||||
|
||||
public record Node(
|
||||
@NonNull long id,
|
||||
@NonNull String name,
|
||||
@NonNull boolean file,
|
||||
@NonNull boolean preview,
|
||||
@Nullable Long size,
|
||||
@Nullable Long parent,
|
||||
@Nullable Node[] children
|
||||
) {
|
||||
public static Node from(de.mattv.fileserver.data.Node node, Node[] children) {
|
||||
return new Node(
|
||||
node.id,
|
||||
node.name,
|
||||
node.isFile,
|
||||
node.hasPreview,
|
||||
node.size,
|
||||
node.parent == null ? null : node.parent.id,
|
||||
children
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
28
src/main/java/de/mattv/fileserver/SpringConfiguration.java
Normal file
28
src/main/java/de/mattv/fileserver/SpringConfiguration.java
Normal file
@ -0,0 +1,28 @@
|
||||
package de.mattv.fileserver;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.scheduling.annotation.EnableAsync;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
import org.springframework.web.filter.CommonsRequestLoggingFilter;
|
||||
|
||||
/*
|
||||
trait FS {
|
||||
https://gitea.mattv.de/root/fileserver/src/commit/702c18673c9d23304d00beda1eb40b7ea748281d/src/server/fs.cxx#L147
|
||||
fn move_nodes(token: String, nodes: [u64], parent: u64) -> Option<String>;
|
||||
}
|
||||
*/
|
||||
|
||||
@EnableAsync
|
||||
@EnableScheduling
|
||||
@Configuration
|
||||
public class SpringConfiguration {
|
||||
@Bean
|
||||
public CommonsRequestLoggingFilter loggingFilter() {
|
||||
CommonsRequestLoggingFilter filter = new CommonsRequestLoggingFilter();
|
||||
filter.setIncludeQueryString(true);
|
||||
filter.setIncludePayload(false);
|
||||
filter.setIncludeClientInfo(true);
|
||||
return filter;
|
||||
}
|
||||
}
|
21
src/main/java/de/mattv/fileserver/Utils.java
Normal file
21
src/main/java/de/mattv/fileserver/Utils.java
Normal file
@ -0,0 +1,21 @@
|
||||
package de.mattv.fileserver;
|
||||
|
||||
import de.mattv.fileserver.util.NonNull;
|
||||
import org.slf4j.Logger;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public class Utils {
|
||||
public static void crash(@NonNull Logger logger, String msg) {
|
||||
crash(logger, msg, null);
|
||||
}
|
||||
|
||||
public static void crash(@NonNull Logger logger, String msg, Throwable err) {
|
||||
logger.error(msg, err);
|
||||
System.exit(-1);
|
||||
}
|
||||
|
||||
public static boolean instantExpired(@NonNull Instant instant) {
|
||||
return instant.isBefore(Instant.now());
|
||||
}
|
||||
}
|
118
src/main/java/de/mattv/fileserver/data/Data.java
Normal file
118
src/main/java/de/mattv/fileserver/data/Data.java
Normal file
@ -0,0 +1,118 @@
|
||||
package de.mattv.fileserver.data;
|
||||
|
||||
import com.thoughtworks.xstream.io.HierarchicalStreamDriver;
|
||||
import com.thoughtworks.xstream.io.xml.XppDriver;
|
||||
import de.mattv.fileserver.Utils;
|
||||
import de.mattv.fileserver.data.converter.ConverterUtils;
|
||||
import de.mattv.fileserver.data.converter.DataConverter;
|
||||
import jakarta.annotation.Nullable;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.annotation.PreDestroy;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileWriter;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.locks.ReentrantReadWriteLock;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
public class Data {
|
||||
private static final File DATA_FILE = new File("data.xml");
|
||||
private static final File DATA_FILE_NEW = new File("data_new.xml");
|
||||
private static final File DATA_FILE_OLD = new File("data_old.xml");
|
||||
private static final HierarchicalStreamDriver X_STREAM = new XppDriver();
|
||||
|
||||
private static Thread saveThread = null;
|
||||
private static final AtomicBoolean SAVE_FLAG = new AtomicBoolean(false);
|
||||
private static final AtomicBoolean SHUTDOWN_FLAG = new AtomicBoolean(false);
|
||||
|
||||
public static final File FILES_DIR = new File("files");
|
||||
public static final long CURRENT_VERSION = 1;
|
||||
public static long nextUserId = 0;
|
||||
|
||||
public static final ConcurrentHashMap<Long, User> USERS = new ConcurrentHashMap<>();
|
||||
|
||||
|
||||
public static final ReentrantReadWriteLock USER_LOCK = new ReentrantReadWriteLock();
|
||||
|
||||
private static void saveData() {
|
||||
Instant now = Instant.now();
|
||||
log.info("Saving data");
|
||||
try (FileWriter writer = new FileWriter(DATA_FILE_NEW)) {
|
||||
DataConverter.toXml(X_STREAM.createWriter(writer));
|
||||
} catch (IOException e) {
|
||||
Utils.crash(log, "Failed to serialize data to xml", e);
|
||||
}
|
||||
try {
|
||||
if (DATA_FILE.exists())
|
||||
Files.move(Data.DATA_FILE.toPath(), Data.DATA_FILE_OLD.toPath(), StandardCopyOption.REPLACE_EXISTING);
|
||||
Files.move(Data.DATA_FILE_NEW.toPath(), Data.DATA_FILE.toPath(), StandardCopyOption.REPLACE_EXISTING);
|
||||
} catch (IOException e) {
|
||||
Utils.crash(log, "Failed move data xml", e);
|
||||
}
|
||||
double elapsed = Duration.between(now, Instant.now()).toNanos() / 1000000.0;
|
||||
log.info("Saved data in {} ms", elapsed);
|
||||
}
|
||||
|
||||
public static void save() {
|
||||
SAVE_FLAG.set(true);
|
||||
synchronized (SAVE_FLAG) { SAVE_FLAG.notifyAll(); }
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
public static void onExit() {
|
||||
SHUTDOWN_FLAG.set(true);
|
||||
save();
|
||||
try {
|
||||
saveThread.join();
|
||||
} catch (InterruptedException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
public static void load() {
|
||||
log.info("Loading data from {}", DATA_FILE.getAbsolutePath());
|
||||
try {
|
||||
if (!FILES_DIR.exists()) if (!FILES_DIR.mkdir()) Utils.crash(log, "Failed to create files dir");
|
||||
if(DATA_FILE.exists()) DataConverter.fromXML(X_STREAM.createReader(DATA_FILE));
|
||||
if (ConverterUtils.hasError) throw new RuntimeException();
|
||||
DataValidator.validateData();
|
||||
} catch (RuntimeException e) {
|
||||
Utils.crash(log, "Failed to load data", e);
|
||||
}
|
||||
log.info("Finished loading data");
|
||||
saveThread = new Thread(() -> {
|
||||
while (!SHUTDOWN_FLAG.get()) {
|
||||
try { synchronized (SAVE_FLAG) { SAVE_FLAG.wait(); } } catch (InterruptedException ignored) {}
|
||||
do {
|
||||
SAVE_FLAG.set(false);
|
||||
try {
|
||||
//noinspection BusyWait
|
||||
Thread.sleep(2000);
|
||||
} catch (InterruptedException ignored) {}
|
||||
} while (SAVE_FLAG.get());
|
||||
saveData();
|
||||
}
|
||||
log.info("Data saver stopping");
|
||||
saveData();
|
||||
});
|
||||
saveThread.start();
|
||||
}
|
||||
|
||||
public static @Nullable User findUser(String name) {
|
||||
return Data.USERS.values()
|
||||
.stream()
|
||||
.filter(u -> u.name.equals(name))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
}
|
||||
}
|
90
src/main/java/de/mattv/fileserver/data/DataValidator.java
Normal file
90
src/main/java/de/mattv/fileserver/data/DataValidator.java
Normal file
@ -0,0 +1,90 @@
|
||||
package de.mattv.fileserver.data;
|
||||
|
||||
import de.mattv.fileserver.Utils;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.util.HashSet;
|
||||
|
||||
@Slf4j
|
||||
public class DataValidator {
|
||||
private static long errors = 0;
|
||||
|
||||
public static void validateData() {
|
||||
log.info("Validating data");
|
||||
try {
|
||||
long lastUserId = -1;
|
||||
for (User user : Data.USERS.values()) {
|
||||
validateUser(user);
|
||||
lastUserId = Math.max(lastUserId, user.id);
|
||||
}
|
||||
if (lastUserId >= Data.nextUserId) {
|
||||
log.error("Next user id {} must be larger than the used ids {}", Data.nextUserId, lastUserId);
|
||||
errors++;
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
Utils.crash(log, "Exception while validating data", e);
|
||||
}
|
||||
if (errors > 0)
|
||||
Utils.crash(log, "Encountered " + errors + " errors while validating data");
|
||||
log.info("Data validated");
|
||||
}
|
||||
|
||||
private static void validateUser(User user) throws IOException {
|
||||
log.info("Validating user {}", user.id);
|
||||
|
||||
long lastNodeId = 0;
|
||||
HashSet<Long> orphans = new HashSet<>();
|
||||
user.nodes.keys().asIterator().forEachRemaining(orphans::add);
|
||||
orphans.remove(0L);
|
||||
|
||||
for (Node node : user.nodes.values()) {
|
||||
validateNode(node);
|
||||
lastNodeId = Math.max(lastNodeId, node.id);
|
||||
for (Node child : node.children)
|
||||
orphans.remove(child.id);
|
||||
}
|
||||
|
||||
for (Long orphan : orphans) {
|
||||
log.error("Orphaned node {}", orphan);
|
||||
errors++;
|
||||
}
|
||||
if (lastNodeId >= user.nextNodeId.get()) {
|
||||
log.error("Next node id {} must be larger than the used ids {}", user.nextNodeId.get(), lastNodeId);
|
||||
errors++;
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateNode(Node node) throws IOException {
|
||||
if (node.isFile) {
|
||||
if (!node.children.isEmpty()) {
|
||||
log.error("Node {} is file and has children", node.id);
|
||||
errors++;
|
||||
}
|
||||
if (node.hasPreview && !node.previewFile.exists()) {
|
||||
log.warn("Missing preview file for node {}, regenerating", node.id);
|
||||
node.createThumbnail();
|
||||
}
|
||||
if (!node.file.exists()) {
|
||||
if (node.size == 0) {
|
||||
log.warn("Created empty file for node {}", node.id);
|
||||
if (!node.file.createNewFile()) {
|
||||
log.error("Failed to create file {}", node.file.getAbsolutePath());
|
||||
errors++;
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
log.error("Missing file for node {}", node.id);
|
||||
errors++;
|
||||
return;
|
||||
}
|
||||
}
|
||||
long realSize = Files.size(node.file.toPath());
|
||||
if (realSize != node.size) {
|
||||
log.error("Node {} size mismatch (Node, Disk) {} != {}", node.id, node.size, realSize);
|
||||
errors++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
56
src/main/java/de/mattv/fileserver/data/Node.java
Normal file
56
src/main/java/de/mattv/fileserver/data/Node.java
Normal file
@ -0,0 +1,56 @@
|
||||
package de.mattv.fileserver.data;
|
||||
|
||||
import de.mattv.fileserver.util.NonNull;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import net.coobird.thumbnailator.Thumbnails;
|
||||
import net.coobird.thumbnailator.tasks.UnsupportedFormatException;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
|
||||
@Slf4j
|
||||
public class Node {
|
||||
private static final long MAX_IMAGE_SIZE = 1024*1024*50;
|
||||
private static final int PREVIEW_SIZE = 480;
|
||||
|
||||
public final long id;
|
||||
public final @NonNull String name;
|
||||
public boolean isFile = false;
|
||||
public boolean hasPreview = false;
|
||||
public long size = 0;
|
||||
public Node parent = null;
|
||||
public final ArrayList<Node> children = new ArrayList<>();
|
||||
|
||||
public final File file;
|
||||
public final File previewFile;
|
||||
|
||||
public Node(long id, @NonNull String name, @NonNull User user) {
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
file = new File(user.userDir, String.valueOf(id));
|
||||
previewFile = new File(user.userDir, id + ".png");
|
||||
}
|
||||
|
||||
public void removePreview() {
|
||||
if (previewFile.exists())
|
||||
if (!previewFile.delete())
|
||||
log.warn("Failed to delete preview file {}", previewFile);
|
||||
hasPreview = false;
|
||||
}
|
||||
|
||||
public void createThumbnail() throws IOException {
|
||||
if (size > MAX_IMAGE_SIZE)
|
||||
return;
|
||||
|
||||
try {
|
||||
Thumbnails.of(file)
|
||||
.size(PREVIEW_SIZE, PREVIEW_SIZE)
|
||||
.allowOverwrite(true)
|
||||
.outputFormat("png")
|
||||
.toFile(previewFile);
|
||||
} catch (UnsupportedFormatException ignored) {}
|
||||
|
||||
hasPreview = true;
|
||||
}
|
||||
}
|
66
src/main/java/de/mattv/fileserver/data/Token.java
Normal file
66
src/main/java/de/mattv/fileserver/data/Token.java
Normal file
@ -0,0 +1,66 @@
|
||||
package de.mattv.fileserver.data;
|
||||
|
||||
import de.mattv.fileserver.Utils;
|
||||
import lombok.Getter;
|
||||
import de.mattv.fileserver.util.NonNull;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
public class Token implements Authentication {
|
||||
private static final Duration LIFETIME = Duration.ofMinutes(60);
|
||||
private static Instant nowPlusLifetime() { return Instant.now().plus(LIFETIME); }
|
||||
|
||||
private @NonNull Instant expiresAt = nowPlusLifetime();
|
||||
@Getter private final @NonNull String token;
|
||||
@Getter private @NonNull User user;
|
||||
@Getter private User sudoRealUser = null;
|
||||
|
||||
public void refresh() { expiresAt = nowPlusLifetime(); }
|
||||
public boolean expired() { return Utils.instantExpired(expiresAt); }
|
||||
public boolean inSudo() { return sudoRealUser != null; }
|
||||
public boolean isAdmin() { return (sudoRealUser != null) || user.admin; }
|
||||
public User getRealUser() { return sudoRealUser != null ? sudoRealUser : user; }
|
||||
|
||||
public void unSudo() { if (sudoRealUser != null) user = sudoRealUser; }
|
||||
|
||||
public void sudo(@NonNull User newUser) {
|
||||
if (this.sudoRealUser == null) sudoRealUser = user;
|
||||
user = newUser;
|
||||
}
|
||||
|
||||
public Token(@NonNull String token, @NonNull User user) {
|
||||
this.token = token;
|
||||
this.user = user;
|
||||
}
|
||||
|
||||
|
||||
@Override public Object getDetails() { return null; }
|
||||
@Override public String getName() { return String.valueOf(user.id); }
|
||||
@Override public Object getPrincipal() { return user; }
|
||||
@Override public Object getCredentials() { return this; }
|
||||
@Override public boolean isAuthenticated() { return true; }
|
||||
|
||||
@Override
|
||||
public Collection<? extends GrantedAuthority> getAuthorities() {
|
||||
return isAdmin() ? List.of(new SimpleGrantedAuthority("ROLE_ADMIN")) : List.of();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException {
|
||||
throw new IllegalArgumentException("Cannot set authenticated");
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "Token{" +
|
||||
"user=" + user +
|
||||
", real_user=" + sudoRealUser +
|
||||
'}';
|
||||
}
|
||||
}
|
69
src/main/java/de/mattv/fileserver/data/User.java
Normal file
69
src/main/java/de/mattv/fileserver/data/User.java
Normal file
@ -0,0 +1,69 @@
|
||||
package de.mattv.fileserver.data;
|
||||
|
||||
import de.mattv.fileserver.security.TokenService;
|
||||
import de.mattv.fileserver.util.AutoCloseLock;
|
||||
import de.mattv.fileserver.util.NonNull;
|
||||
import org.springframework.util.FileSystemUtils;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import java.util.concurrent.locks.ReentrantReadWriteLock;
|
||||
|
||||
public class User {
|
||||
public final long id;
|
||||
public final @NonNull String name;
|
||||
public @NonNull String password;
|
||||
|
||||
public boolean enabled = false;
|
||||
public boolean admin = false;
|
||||
|
||||
public String tfaSecret = null;
|
||||
public String tempTfaSecret = null;
|
||||
|
||||
public final AtomicLong nextNodeId = new AtomicLong(1);
|
||||
|
||||
public final Node rootNode = new Node(0, "/", this);
|
||||
public final ConcurrentHashMap<Long, Node> nodes = new ConcurrentHashMap<>();
|
||||
public final ReentrantReadWriteLock nodeLock = new ReentrantReadWriteLock();
|
||||
public final File userDir;
|
||||
|
||||
public User(long id, @NonNull String name, @NonNull String password) {
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
this.password = password;
|
||||
this.nodes.put(0L, rootNode);
|
||||
this.userDir = new File(Data.FILES_DIR, String.valueOf(id));
|
||||
|
||||
if (!userDir.exists()) if (!userDir.mkdir()) throw new RuntimeException("Failed to create user " + id + " directory");
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "User{" +
|
||||
"id=" + id +
|
||||
", name='" + name + '\'' +
|
||||
", admin=" + admin +
|
||||
'}';
|
||||
}
|
||||
|
||||
public void delete() {
|
||||
enabled = false;
|
||||
TokenService.logoutAll(id);
|
||||
nodeLock.writeLock().lock();
|
||||
try (AutoCloseLock ignored = new AutoCloseLock(Data.USER_LOCK.writeLock())) {
|
||||
Data.USERS.remove(id);
|
||||
}
|
||||
for (Node node : nodes.values()) {
|
||||
if (node.isFile) {
|
||||
boolean ignored = node.file.delete();
|
||||
if (node.hasPreview) {
|
||||
boolean ignored2 = node.previewFile.delete();
|
||||
}
|
||||
}
|
||||
node.parent = null;
|
||||
node.children.clear();
|
||||
}
|
||||
FileSystemUtils.deleteRecursively(userDir);
|
||||
}
|
||||
}
|
@ -0,0 +1,41 @@
|
||||
package de.mattv.fileserver.data.converter;
|
||||
|
||||
import com.thoughtworks.xstream.io.HierarchicalStreamReader;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.util.Stack;
|
||||
|
||||
@Slf4j
|
||||
public class ConverterUtils {
|
||||
protected static final Stack<String> CONVERSION_STACK_FRAMES = new Stack<>();
|
||||
public static boolean hasError = false;
|
||||
|
||||
protected static void dumpConversionStackFrames() {
|
||||
log.error("Conversion stack: {}", String.join(" > ", CONVERSION_STACK_FRAMES));
|
||||
hasError = true;
|
||||
}
|
||||
|
||||
protected static String getString(HierarchicalStreamReader reader, String name) {
|
||||
String attribute = reader.getAttribute(name);
|
||||
if (attribute == null) {
|
||||
log.error("Missing required attribute: {}", name);
|
||||
dumpConversionStackFrames();
|
||||
attribute = "";
|
||||
}
|
||||
return attribute;
|
||||
}
|
||||
|
||||
protected static long getLong(HierarchicalStreamReader reader, String name) {
|
||||
String attribute = getString(reader, name);
|
||||
try {
|
||||
return Long.parseLong(attribute);
|
||||
} catch (NumberFormatException e) {
|
||||
log.error("Can't convert attribute to long: {} '{}'", name, attribute);
|
||||
dumpConversionStackFrames();
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
protected static void push(String frame) { CONVERSION_STACK_FRAMES.push(frame); }
|
||||
protected static void pop() { CONVERSION_STACK_FRAMES.pop(); }
|
||||
}
|
@ -0,0 +1,52 @@
|
||||
package de.mattv.fileserver.data.converter;
|
||||
|
||||
import com.thoughtworks.xstream.io.HierarchicalStreamReader;
|
||||
import com.thoughtworks.xstream.io.HierarchicalStreamWriter;
|
||||
import de.mattv.fileserver.data.Data;
|
||||
import de.mattv.fileserver.data.User;
|
||||
import de.mattv.fileserver.util.AutoCloseLock;
|
||||
import de.mattv.fileserver.util.NonNull;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
@Slf4j
|
||||
public class DataConverter {
|
||||
private static final String NODE_NAME = "data";
|
||||
private static final String ATTR_VERSION_NAME = "version";
|
||||
private static final String ATTR_NEXT_USER_ID_NAME = "next-user";
|
||||
|
||||
public static void toXml(@NonNull HierarchicalStreamWriter writer) {
|
||||
try (AutoCloseLock ignored = new AutoCloseLock(Data.USER_LOCK.readLock())) {
|
||||
writer.startNode(NODE_NAME);
|
||||
writer.addAttribute(ATTR_VERSION_NAME, String.valueOf(Data.CURRENT_VERSION));
|
||||
writer.addAttribute(ATTR_NEXT_USER_ID_NAME, String.valueOf(Data.nextUserId));
|
||||
Data.USERS.values().forEach(user -> UserConverter.toXml(user, writer));
|
||||
writer.endNode();
|
||||
}
|
||||
}
|
||||
|
||||
public static void fromXML(@NonNull HierarchicalStreamReader reader) {
|
||||
ConverterUtils.hasError = false;
|
||||
|
||||
if (!reader.getNodeName().equals(NODE_NAME)) {
|
||||
log.error("Trying to parse user from xml type {}", reader.getNodeName());
|
||||
ConverterUtils.dumpConversionStackFrames();
|
||||
}
|
||||
|
||||
ConverterUtils.push("Data");
|
||||
|
||||
long version = ConverterUtils.getLong(reader, ATTR_VERSION_NAME);
|
||||
if (version != Data.CURRENT_VERSION) {
|
||||
log.error("Data version is {}, current version is {}.", version, Data.CURRENT_VERSION);
|
||||
ConverterUtils.hasError = true;
|
||||
return;
|
||||
}
|
||||
Data.nextUserId = ConverterUtils.getLong(reader, ATTR_NEXT_USER_ID_NAME);
|
||||
|
||||
while (reader.hasMoreChildren()) {
|
||||
User child = UserConverter.fromXml(reader);
|
||||
Data.USERS.put(child.id, child);
|
||||
}
|
||||
|
||||
ConverterUtils.pop();
|
||||
}
|
||||
}
|
@ -0,0 +1,77 @@
|
||||
package de.mattv.fileserver.data.converter;
|
||||
|
||||
import com.thoughtworks.xstream.io.HierarchicalStreamReader;
|
||||
import com.thoughtworks.xstream.io.HierarchicalStreamWriter;
|
||||
import de.mattv.fileserver.data.Node;
|
||||
import de.mattv.fileserver.data.User;
|
||||
import de.mattv.fileserver.util.NonNull;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
@Slf4j
|
||||
public class NodeConverter {
|
||||
private static final String NODE_NAME = "node";
|
||||
private static final String ATTR_ID_NAME = "id";
|
||||
private static final String ATTR_NAME_NAME = "name";
|
||||
private static final String ATTR_FLAGS_NAME = "flags";
|
||||
private static final String ATTR_SIZE_NAME = "size";
|
||||
|
||||
private static class Flags {
|
||||
public static final long FILE = 1;
|
||||
public static final long PREVIEW = 2;
|
||||
}
|
||||
|
||||
public static void toXml(@NonNull Node node, @NonNull HierarchicalStreamWriter writer) {
|
||||
writer.startNode(NODE_NAME);
|
||||
long flags = 0;
|
||||
if (node.isFile) flags |= Flags.FILE;
|
||||
if (node.hasPreview) flags |= Flags.PREVIEW;
|
||||
|
||||
writer.addAttribute(ATTR_ID_NAME, String.valueOf(node.id));
|
||||
writer.addAttribute(ATTR_NAME_NAME, node.name);
|
||||
writer.addAttribute(ATTR_FLAGS_NAME, String.valueOf(flags));
|
||||
|
||||
if (node.isFile) writer.addAttribute(ATTR_SIZE_NAME, String.valueOf(node.size));
|
||||
|
||||
node.children.forEach(child -> toXml(child, writer));
|
||||
|
||||
writer.endNode();
|
||||
}
|
||||
|
||||
public static Node fromXml(@NonNull HierarchicalStreamReader reader, @NonNull User user) {
|
||||
reader.moveDown();
|
||||
if (!reader.getNodeName().equals(NODE_NAME)) {
|
||||
log.error("Trying to parse node from xml type {}", reader.getNodeName());
|
||||
ConverterUtils.dumpConversionStackFrames();
|
||||
}
|
||||
|
||||
ConverterUtils.push("Node");
|
||||
|
||||
Node node = new Node(
|
||||
ConverterUtils.getLong(reader, ATTR_ID_NAME),
|
||||
ConverterUtils.getString(reader, ATTR_NAME_NAME),
|
||||
user
|
||||
);
|
||||
|
||||
ConverterUtils.pop();
|
||||
ConverterUtils.push("Node " + node.id);
|
||||
|
||||
long flags = ConverterUtils.getLong(reader, ATTR_FLAGS_NAME);
|
||||
node.isFile = (flags & Flags.FILE) != 0;
|
||||
if (node.isFile) {
|
||||
node.hasPreview = (flags & Flags.PREVIEW) != 0;
|
||||
node.size = ConverterUtils.getLong(reader, ATTR_SIZE_NAME);
|
||||
}
|
||||
|
||||
while (reader.hasMoreChildren()) {
|
||||
Node child = fromXml(reader, user);
|
||||
child.parent = node;
|
||||
node.children.add(child);
|
||||
}
|
||||
|
||||
user.nodes.put(node.id, node);
|
||||
ConverterUtils.pop();
|
||||
reader.moveUp();
|
||||
|
||||
return node;
|
||||
}
|
||||
}
|
@ -0,0 +1,89 @@
|
||||
package de.mattv.fileserver.data.converter;
|
||||
|
||||
import com.thoughtworks.xstream.io.HierarchicalStreamReader;
|
||||
import com.thoughtworks.xstream.io.HierarchicalStreamWriter;
|
||||
import de.mattv.fileserver.data.Node;
|
||||
import de.mattv.fileserver.data.User;
|
||||
import de.mattv.fileserver.util.AutoCloseLock;
|
||||
import de.mattv.fileserver.util.NonNull;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
@Slf4j
|
||||
public class UserConverter {
|
||||
private static final String NODE_NAME = "user";
|
||||
private static final String ATTR_ID_NAME = "id";
|
||||
private static final String ATTR_NAME_NAME = "name";
|
||||
private static final String ATTR_PASSWORD_NAME = "password";
|
||||
private static final String ATTR_FLAGS_NAME = "flags";
|
||||
private static final String ATTR_NEXT_NODE_NAME = "next-node";
|
||||
private static final String ATTR_TFA_NAME = "tfa";
|
||||
|
||||
private static class Flags {
|
||||
private static final long ENABLED = 1;
|
||||
private static final long ADMIN = 2;
|
||||
}
|
||||
|
||||
public static void toXml(@NonNull User user, @NonNull HierarchicalStreamWriter writer) {
|
||||
try (AutoCloseLock ignored = new AutoCloseLock(user.nodeLock.readLock())) {
|
||||
writer.startNode(NODE_NAME);
|
||||
|
||||
long flags = 0;
|
||||
if (user.enabled) flags |= Flags.ENABLED;
|
||||
if (user.admin) flags |= Flags.ADMIN;
|
||||
|
||||
writer.addAttribute(ATTR_ID_NAME, String.valueOf(user.id));
|
||||
writer.addAttribute(ATTR_NAME_NAME, user.name);
|
||||
writer.addAttribute(ATTR_PASSWORD_NAME, user.password);
|
||||
writer.addAttribute(ATTR_FLAGS_NAME, String.valueOf(flags));
|
||||
writer.addAttribute(ATTR_NEXT_NODE_NAME, String.valueOf(user.nextNodeId));
|
||||
|
||||
String tfa = user.tfaSecret;
|
||||
if (tfa != null) writer.addAttribute(ATTR_TFA_NAME, tfa);
|
||||
|
||||
user.nodes.get(0L).children.forEach(child -> NodeConverter.toXml(child, writer));
|
||||
|
||||
writer.endNode();
|
||||
}
|
||||
}
|
||||
|
||||
public static User fromXml(@NonNull HierarchicalStreamReader reader) {
|
||||
reader.moveDown();
|
||||
|
||||
if (!reader.getNodeName().equals(NODE_NAME)) {
|
||||
log.error("Trying to parse user from xml type {}", reader.getNodeName());
|
||||
ConverterUtils.dumpConversionStackFrames();
|
||||
}
|
||||
|
||||
ConverterUtils.push("User");
|
||||
|
||||
User user = new User(
|
||||
ConverterUtils.getLong(reader, ATTR_ID_NAME),
|
||||
ConverterUtils.getString(reader, ATTR_NAME_NAME),
|
||||
ConverterUtils.getString(reader, ATTR_PASSWORD_NAME)
|
||||
);
|
||||
|
||||
ConverterUtils.pop();
|
||||
ConverterUtils.push("User " + user.id);
|
||||
|
||||
long flags = ConverterUtils.getLong(reader, ATTR_FLAGS_NAME);
|
||||
user.enabled = (flags & Flags.ENABLED) != 0;
|
||||
user.admin = (flags & Flags.ADMIN) != 0;
|
||||
user.nextNodeId.set(ConverterUtils.getLong(reader, ATTR_NEXT_NODE_NAME));
|
||||
|
||||
String tfa = reader.getAttribute(ATTR_TFA_NAME);
|
||||
if (tfa != null) {
|
||||
user.tfaSecret = tfa;
|
||||
}
|
||||
|
||||
while (reader.hasMoreChildren()) {
|
||||
Node child = NodeConverter.fromXml(reader, user);
|
||||
child.parent = user.rootNode;
|
||||
user.rootNode.children.add(child);
|
||||
}
|
||||
|
||||
ConverterUtils.pop();
|
||||
reader.moveUp();
|
||||
|
||||
return user;
|
||||
}
|
||||
}
|
49
src/main/java/de/mattv/fileserver/routes/Download.java
Normal file
49
src/main/java/de/mattv/fileserver/routes/Download.java
Normal file
@ -0,0 +1,49 @@
|
||||
package de.mattv.fileserver.routes;
|
||||
|
||||
import de.mattv.fileserver.data.Node;
|
||||
import de.mattv.fileserver.data.Token;
|
||||
import de.mattv.fileserver.data.User;
|
||||
import de.mattv.fileserver.security.TokenService;
|
||||
import de.mattv.fileserver.util.AutoCloseLock;
|
||||
import de.mattv.fileserver.util.PublicRestController;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import de.mattv.fileserver.util.NonNull;
|
||||
import org.springframework.http.ContentDisposition;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.MediaTypeFactory;
|
||||
import org.springframework.util.FileCopyUtils;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
|
||||
@PublicRestController
|
||||
public class Download {
|
||||
private record Body(@NonNull String token, @NonNull long node) {}
|
||||
|
||||
@PostMapping(path = "/download", consumes = {MediaType.APPLICATION_FORM_URLENCODED_VALUE})
|
||||
@Operation(hidden = true)
|
||||
private void download(Body body, HttpServletResponse response) throws IOException {
|
||||
Token token = TokenService.getToken(body.token);
|
||||
if (token == null) {
|
||||
response.sendError(401, "Invalid token");
|
||||
return;
|
||||
}
|
||||
|
||||
User user = token.getUser();
|
||||
try (AutoCloseLock ignored = new AutoCloseLock(user.nodeLock.readLock())) {
|
||||
Node node = user.nodes.get(body.node);
|
||||
if (node == null || !node.isFile) {
|
||||
response.sendError(401, "Invalid node");
|
||||
return;
|
||||
}
|
||||
|
||||
response.setContentType(MediaTypeFactory.getMediaType(node.name).orElse(MediaType.APPLICATION_OCTET_STREAM).toString());
|
||||
response.setContentLengthLong(node.size);
|
||||
response.setHeader("Content-Disposition", ContentDisposition.attachment().filename(node.name).build().toString());
|
||||
FileCopyUtils.copy(Files.newInputStream(node.file.toPath()), response.getOutputStream());
|
||||
response.flushBuffer();
|
||||
}
|
||||
}
|
||||
}
|
87
src/main/java/de/mattv/fileserver/routes/DownloadMulti.java
Normal file
87
src/main/java/de/mattv/fileserver/routes/DownloadMulti.java
Normal file
@ -0,0 +1,87 @@
|
||||
package de.mattv.fileserver.routes;
|
||||
|
||||
import de.mattv.fileserver.data.Node;
|
||||
import de.mattv.fileserver.data.Token;
|
||||
import de.mattv.fileserver.data.User;
|
||||
import de.mattv.fileserver.security.TokenService;
|
||||
import de.mattv.fileserver.util.AutoCloseLock;
|
||||
import de.mattv.fileserver.util.PublicRestController;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import de.mattv.fileserver.util.NonNull;
|
||||
import org.apache.commons.lang3.tuple.Pair;
|
||||
import org.springframework.http.ContentDisposition;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.Queue;
|
||||
import java.util.zip.Deflater;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
|
||||
@PublicRestController
|
||||
public class DownloadMulti {
|
||||
private record Body(@NonNull String token, @NonNull long[] nodes) {}
|
||||
|
||||
private static ZipEntry createEntry(String name, long size) {
|
||||
ZipEntry entry = new ZipEntry(name);
|
||||
entry.setSize(size);
|
||||
return entry;
|
||||
}
|
||||
|
||||
@PostMapping(path = "/download_multi", consumes = {MediaType.APPLICATION_FORM_URLENCODED_VALUE})
|
||||
@Operation(hidden = true)
|
||||
private void downloadMulti(Body body, HttpServletResponse response) throws IOException {
|
||||
Token token = TokenService.getToken(body.token);
|
||||
if (token == null) {
|
||||
response.sendError(401, "Invalid token");
|
||||
return;
|
||||
}
|
||||
|
||||
User user = token.getUser();
|
||||
try (AutoCloseLock ignored = new AutoCloseLock(user.nodeLock.readLock())) {
|
||||
response.setContentType("application/zip");
|
||||
response.setHeader("Content-Disposition", ContentDisposition.attachment().filename("files.zip").build().toString());
|
||||
|
||||
Queue<Pair<Node, String>> todo = new ArrayDeque<>();
|
||||
|
||||
for (long id : body.nodes) {
|
||||
Node node = user.nodes.get(id);
|
||||
if (node != null)
|
||||
todo.add(Pair.of(node, ""));
|
||||
}
|
||||
|
||||
ZipOutputStream zip = new ZipOutputStream(response.getOutputStream());
|
||||
zip.setLevel(Deflater.NO_COMPRESSION);
|
||||
|
||||
while (!todo.isEmpty()) {
|
||||
Pair<Node, String> entry = todo.remove();
|
||||
Node node = entry.getLeft();
|
||||
String path = entry.getRight() + node.name;
|
||||
|
||||
if (node.isFile) {
|
||||
zip.putNextEntry(createEntry(path, node.size));
|
||||
try (InputStream is = Files.newInputStream(node.file.toPath())) {
|
||||
is.transferTo(zip);
|
||||
}
|
||||
zip.closeEntry();
|
||||
} else {
|
||||
path += "/";
|
||||
zip.putNextEntry(createEntry(path, 0));
|
||||
zip.closeEntry();
|
||||
for (Node child : node.children)
|
||||
todo.add(Pair.of(child, path));
|
||||
}
|
||||
}
|
||||
|
||||
zip.finish();
|
||||
zip.flush();
|
||||
zip.close();
|
||||
response.flushBuffer();
|
||||
}
|
||||
}
|
||||
}
|
68
src/main/java/de/mattv/fileserver/routes/Upload.java
Normal file
68
src/main/java/de/mattv/fileserver/routes/Upload.java
Normal file
@ -0,0 +1,68 @@
|
||||
package de.mattv.fileserver.routes;
|
||||
|
||||
import de.mattv.fileserver.Utils;
|
||||
import de.mattv.fileserver.data.Node;
|
||||
import de.mattv.fileserver.data.User;
|
||||
import de.mattv.fileserver.util.AuthUser;
|
||||
import de.mattv.fileserver.util.AutoCloseLock;
|
||||
import de.mattv.fileserver.util.UserRestController;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.annotation.PreDestroy;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.util.FileCopyUtils;
|
||||
import org.springframework.util.FileSystemUtils;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
@Slf4j
|
||||
@UserRestController
|
||||
public class Upload {
|
||||
private static final File TEMP_DIR = new File("temp");
|
||||
private static final AtomicLong NEXT_TEMP_ID = new AtomicLong(0);
|
||||
|
||||
@PostConstruct
|
||||
private void init() {
|
||||
if (TEMP_DIR.exists()) FileSystemUtils.deleteRecursively(TEMP_DIR);
|
||||
if (!TEMP_DIR.mkdir()) Utils.crash(log, "Failed to create temp directory");
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
private void destroy() {
|
||||
FileSystemUtils.deleteRecursively(TEMP_DIR);
|
||||
}
|
||||
|
||||
private File getTempFile() {
|
||||
return new File(TEMP_DIR, String.valueOf(NEXT_TEMP_ID.getAndIncrement()));
|
||||
}
|
||||
|
||||
@PostMapping("/upload/{id}")
|
||||
@Operation(hidden = true)
|
||||
private void upload(@AuthUser User user, @PathVariable long id, HttpServletRequest request, HttpServletResponse response) throws IOException {
|
||||
try (AutoCloseLock ignored = new AutoCloseLock(user.nodeLock.readLock())) {
|
||||
Node node = user.nodes.get(id);
|
||||
if (node == null || !node.isFile) {
|
||||
response.sendError(400, "Invalid node");
|
||||
return;
|
||||
}
|
||||
|
||||
File tempFile = getTempFile();
|
||||
FileCopyUtils.copy(request.getInputStream(), Files.newOutputStream(tempFile.toPath()));
|
||||
|
||||
node.size = 0;
|
||||
node.removePreview();
|
||||
Files.move(tempFile.toPath(), node.file.toPath(), StandardCopyOption.REPLACE_EXISTING);
|
||||
node.size = node.file.length();
|
||||
|
||||
node.createThumbnail();
|
||||
}
|
||||
}
|
||||
}
|
30
src/main/java/de/mattv/fileserver/routes/admin/General.java
Normal file
30
src/main/java/de/mattv/fileserver/routes/admin/General.java
Normal file
@ -0,0 +1,30 @@
|
||||
package de.mattv.fileserver.routes.admin;
|
||||
|
||||
import de.mattv.fileserver.data.Token;
|
||||
import de.mattv.fileserver.util.AdminRestController;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
|
||||
@Slf4j
|
||||
@AdminRestController
|
||||
public class General {
|
||||
private final ApplicationContext ctx;
|
||||
|
||||
public General(ApplicationContext ctx) {
|
||||
this.ctx = ctx;
|
||||
}
|
||||
|
||||
@PostMapping("/un_sudo")
|
||||
private void unSudo(@Parameter(hidden = true) Token token) {
|
||||
token.unSudo();
|
||||
}
|
||||
|
||||
@PostMapping("/shutdown")
|
||||
private void shutdown(@Parameter(hidden = true) Token token) {
|
||||
log.warn("Shutdown started by {}", token);
|
||||
SpringApplication.exit(ctx);
|
||||
}
|
||||
}
|
79
src/main/java/de/mattv/fileserver/routes/admin/User.java
Normal file
79
src/main/java/de/mattv/fileserver/routes/admin/User.java
Normal file
@ -0,0 +1,79 @@
|
||||
package de.mattv.fileserver.routes.admin;
|
||||
|
||||
import de.mattv.fileserver.Response;
|
||||
import de.mattv.fileserver.data.Data;
|
||||
import de.mattv.fileserver.data.Token;
|
||||
import de.mattv.fileserver.security.TokenService;
|
||||
import de.mattv.fileserver.util.AdminRestController;
|
||||
import de.mattv.fileserver.util.AutoCloseLock;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import de.mattv.fileserver.util.NonNull;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@AdminRestController
|
||||
public class User {
|
||||
@PostMapping("/users")
|
||||
private @NonNull List<Response.UserInfo> listUsers() {
|
||||
return Data.USERS.values().stream()
|
||||
.map(user -> new Response.UserInfo(
|
||||
user.id,
|
||||
user.name,
|
||||
user.tfaSecret != null,
|
||||
user.enabled,
|
||||
user.admin
|
||||
))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@PostMapping("/user/logout")
|
||||
private void logout(@RequestBody long id) {
|
||||
TokenService.logoutAll(id);
|
||||
}
|
||||
|
||||
@PostMapping("/user/disable_tfa")
|
||||
private void disableTfa(@RequestBody long id) {
|
||||
try (AutoCloseLock ignored = new AutoCloseLock(Data.USER_LOCK.readLock())) {
|
||||
de.mattv.fileserver.data.User user = Data.USERS.get(id);
|
||||
if (user != null) user.tfaSecret = "";
|
||||
Data.save();
|
||||
}
|
||||
}
|
||||
|
||||
private record BodySetProp(@NonNull long id, @NonNull boolean state) {}
|
||||
|
||||
@PostMapping("/user/set_enabled")
|
||||
private void setEnabled(@RequestBody BodySetProp body) {
|
||||
try (AutoCloseLock ignored = new AutoCloseLock(Data.USER_LOCK.readLock())) {
|
||||
de.mattv.fileserver.data.User user = Data.USERS.get(body.id);
|
||||
if (user != null) user.enabled = body.state;
|
||||
Data.save();
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/user/set_admin")
|
||||
private void setAdmin(@RequestBody BodySetProp body) {
|
||||
try (AutoCloseLock ignored = new AutoCloseLock(Data.USER_LOCK.readLock())) {
|
||||
de.mattv.fileserver.data.User user = Data.USERS.get(body.id);
|
||||
if (user != null) user.admin = body.state;
|
||||
Data.save();
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/user/sudo")
|
||||
private void sudo(@Parameter(hidden = true) Token token, @RequestBody long id) {
|
||||
try (AutoCloseLock ignored = new AutoCloseLock(Data.USER_LOCK.readLock())) {
|
||||
de.mattv.fileserver.data.User user = Data.USERS.get(id);
|
||||
if (user != null) token.sudo(user);
|
||||
}
|
||||
}
|
||||
@PostMapping("/user/delete")
|
||||
private void delete(@RequestBody long id) {
|
||||
try (AutoCloseLock ignored = new AutoCloseLock(Data.USER_LOCK.readLock())) {
|
||||
de.mattv.fileserver.data.User user = Data.USERS.get(id);
|
||||
if (user != null) user.delete();
|
||||
}
|
||||
}
|
||||
}
|
14
src/main/java/de/mattv/fileserver/routes/auth/AuthUtils.java
Normal file
14
src/main/java/de/mattv/fileserver/routes/auth/AuthUtils.java
Normal file
@ -0,0 +1,14 @@
|
||||
package de.mattv.fileserver.routes.auth;
|
||||
|
||||
import dev.samstevens.totp.code.CodeVerifier;
|
||||
import dev.samstevens.totp.code.DefaultCodeGenerator;
|
||||
import dev.samstevens.totp.code.DefaultCodeVerifier;
|
||||
import dev.samstevens.totp.time.SystemTimeProvider;
|
||||
import org.springframework.security.crypto.argon2.Argon2PasswordEncoder;
|
||||
|
||||
public class AuthUtils {
|
||||
private AuthUtils() {}
|
||||
|
||||
protected static final Argon2PasswordEncoder PW_ENCODER = Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8();
|
||||
protected static final CodeVerifier TOTP_VERIFIER = new DefaultCodeVerifier(new DefaultCodeGenerator(), new SystemTimeProvider());
|
||||
}
|
34
src/main/java/de/mattv/fileserver/routes/auth/ChangePW.java
Normal file
34
src/main/java/de/mattv/fileserver/routes/auth/ChangePW.java
Normal file
@ -0,0 +1,34 @@
|
||||
package de.mattv.fileserver.routes.auth;
|
||||
|
||||
import de.mattv.fileserver.data.Data;
|
||||
import de.mattv.fileserver.data.User;
|
||||
import de.mattv.fileserver.security.TokenService;
|
||||
import de.mattv.fileserver.util.AuthUser;
|
||||
import de.mattv.fileserver.util.AutoCloseLock;
|
||||
import de.mattv.fileserver.util.UserRestController;
|
||||
import de.mattv.fileserver.util.NonNull;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
@UserRestController
|
||||
public class ChangePW {
|
||||
private record Body(@NonNull String oldPassword, @NonNull String newPassword) {}
|
||||
|
||||
@PostMapping("/auth/change_password")
|
||||
private @NonNull Optional<String> change(@AuthUser User user, @RequestBody Body body) {
|
||||
if (body.newPassword.length() < 6)
|
||||
return Optional.of("Password must be at least 6 characters");
|
||||
|
||||
try (AutoCloseLock ignored = new AutoCloseLock(Data.USER_LOCK.readLock())) {
|
||||
if (!AuthUtils.PW_ENCODER.matches(body.oldPassword, user.password))
|
||||
return Optional.of("Old password is incorrect");
|
||||
|
||||
user.password = AuthUtils.PW_ENCODER.encode(body.newPassword);
|
||||
Data.save();
|
||||
TokenService.logoutAll(user.id);
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
}
|
12
src/main/java/de/mattv/fileserver/routes/auth/Delete.java
Normal file
12
src/main/java/de/mattv/fileserver/routes/auth/Delete.java
Normal file
@ -0,0 +1,12 @@
|
||||
package de.mattv.fileserver.routes.auth;
|
||||
|
||||
import de.mattv.fileserver.data.User;
|
||||
import de.mattv.fileserver.util.AuthUser;
|
||||
import de.mattv.fileserver.util.UserRestController;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
|
||||
@UserRestController
|
||||
public class Delete {
|
||||
@PostMapping("/auth/delete")
|
||||
private void delete(@AuthUser User user) { user.delete(); }
|
||||
}
|
60
src/main/java/de/mattv/fileserver/routes/auth/Login.java
Normal file
60
src/main/java/de/mattv/fileserver/routes/auth/Login.java
Normal file
@ -0,0 +1,60 @@
|
||||
package de.mattv.fileserver.routes.auth;
|
||||
|
||||
import de.mattv.fileserver.Response;
|
||||
import de.mattv.fileserver.data.Data;
|
||||
import de.mattv.fileserver.data.User;
|
||||
import de.mattv.fileserver.services.TFAMailer;
|
||||
import de.mattv.fileserver.security.TokenService;
|
||||
import de.mattv.fileserver.util.PublicRestController;
|
||||
import de.mattv.fileserver.util.AutoCloseLock;
|
||||
import jakarta.annotation.Nullable;
|
||||
import de.mattv.fileserver.util.NonNull;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
|
||||
@PublicRestController
|
||||
public class Login {
|
||||
private final TFAMailer mailer;
|
||||
|
||||
public Login(TFAMailer mailer) {
|
||||
this.mailer = mailer;
|
||||
}
|
||||
|
||||
private record Body(@NonNull String username, @NonNull String password, @Nullable String otp) {}
|
||||
|
||||
@PostMapping("/auth/login")
|
||||
private @NonNull Response<Response.Login> login(@RequestBody Body body) {
|
||||
try (AutoCloseLock ignored = new AutoCloseLock(Data.USER_LOCK.readLock())) {
|
||||
User user = Data.findUser(body.username);
|
||||
if (user == null)
|
||||
return Response.e("Invalid username or password");
|
||||
|
||||
if (!AuthUtils.PW_ENCODER.matches(body.password, user.password))
|
||||
return Response.e("Invalid username or password");
|
||||
|
||||
if (AuthUtils.PW_ENCODER.upgradeEncoding(user.password)) {
|
||||
user.password = AuthUtils.PW_ENCODER.encode(body.password);
|
||||
Data.save();
|
||||
}
|
||||
|
||||
if (!user.enabled)
|
||||
return Response.e("User is disabled");
|
||||
|
||||
if (user.tfaSecret != null) {
|
||||
if (body.otp == null) {
|
||||
if (user.tfaSecret.isEmpty())
|
||||
mailer.sendMail(user);
|
||||
return Response.o(new Response.Login(true, null));
|
||||
} else {
|
||||
boolean ok = user.tfaSecret.isEmpty()
|
||||
? mailer.checkCode(user.id, body.otp)
|
||||
: AuthUtils.TOTP_VERIFIER.isValidCode(user.tfaSecret, body.otp);
|
||||
if (!ok)
|
||||
return Response.e("Invalid code");
|
||||
}
|
||||
}
|
||||
|
||||
return Response.o(new Response.Login(false, TokenService.createToken(user)));
|
||||
}
|
||||
}
|
||||
}
|
22
src/main/java/de/mattv/fileserver/routes/auth/Logout.java
Normal file
22
src/main/java/de/mattv/fileserver/routes/auth/Logout.java
Normal file
@ -0,0 +1,22 @@
|
||||
package de.mattv.fileserver.routes.auth;
|
||||
|
||||
import de.mattv.fileserver.data.Token;
|
||||
import de.mattv.fileserver.data.User;
|
||||
import de.mattv.fileserver.security.TokenService;
|
||||
import de.mattv.fileserver.util.AuthUser;
|
||||
import de.mattv.fileserver.util.UserRestController;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
|
||||
@UserRestController
|
||||
public class Logout {
|
||||
@PostMapping("/auth/logout")
|
||||
private void logout(@Parameter(hidden = true) Token token) {
|
||||
TokenService.logout(token);
|
||||
}
|
||||
|
||||
@PostMapping("/auth/logout_all")
|
||||
private void logoutAll(@AuthUser User user) {
|
||||
TokenService.logoutAll(user.id);
|
||||
}
|
||||
}
|
49
src/main/java/de/mattv/fileserver/routes/auth/Recovery.java
Normal file
49
src/main/java/de/mattv/fileserver/routes/auth/Recovery.java
Normal file
@ -0,0 +1,49 @@
|
||||
package de.mattv.fileserver.routes.auth;
|
||||
|
||||
import de.mattv.fileserver.data.Data;
|
||||
import de.mattv.fileserver.data.User;
|
||||
import de.mattv.fileserver.services.RecoveryMailer;
|
||||
import de.mattv.fileserver.security.TokenService;
|
||||
import de.mattv.fileserver.util.PublicRestController;
|
||||
import de.mattv.fileserver.util.AutoCloseLock;
|
||||
import de.mattv.fileserver.util.NonNull;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
@PublicRestController
|
||||
public class Recovery {
|
||||
private final RecoveryMailer mailer;
|
||||
|
||||
public Recovery(RecoveryMailer mailer) {
|
||||
this.mailer = mailer;
|
||||
}
|
||||
|
||||
@PostMapping("/auth/send_recovery_key")
|
||||
private void sendKey(@RequestBody String username) {
|
||||
try (AutoCloseLock ignored = new AutoCloseLock(Data.USER_LOCK.readLock())) {
|
||||
User user = Data.findUser(username);
|
||||
if (user != null)
|
||||
mailer.sendMail(user);
|
||||
}
|
||||
}
|
||||
|
||||
private record Body(@NonNull String key, @NonNull String password) {}
|
||||
|
||||
@PostMapping("/auth/reset_password")
|
||||
private @NonNull Optional<String> recover(@RequestBody Body body) {
|
||||
try (AutoCloseLock ignored = new AutoCloseLock(Data.USER_LOCK.readLock())) {
|
||||
User user = mailer.checkCode(body.key);
|
||||
if (user == null)
|
||||
return Optional.of("Invalid key");
|
||||
if (body.password.length() < 6)
|
||||
return Optional.of("Password must be at least 6 characters");
|
||||
|
||||
user.password = AuthUtils.PW_ENCODER.encode(body.password);
|
||||
TokenService.logoutAll(user.id);
|
||||
Data.save();
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,23 @@
|
||||
package de.mattv.fileserver.routes.auth;
|
||||
|
||||
import de.mattv.fileserver.Response;
|
||||
import de.mattv.fileserver.data.Token;
|
||||
import de.mattv.fileserver.data.User;
|
||||
import de.mattv.fileserver.util.AuthUser;
|
||||
import de.mattv.fileserver.util.UserRestController;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import de.mattv.fileserver.util.NonNull;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
|
||||
@UserRestController
|
||||
public class SessionInfo {
|
||||
@PostMapping("/session")
|
||||
private @NonNull Response.Session session(@AuthUser User user, @Parameter(hidden = true) Token token) {
|
||||
return new Response.Session(
|
||||
user.name,
|
||||
user.tfaSecret != null,
|
||||
token.isAdmin(),
|
||||
token.inSudo()
|
||||
);
|
||||
}
|
||||
}
|
34
src/main/java/de/mattv/fileserver/routes/auth/Signup.java
Normal file
34
src/main/java/de/mattv/fileserver/routes/auth/Signup.java
Normal file
@ -0,0 +1,34 @@
|
||||
package de.mattv.fileserver.routes.auth;
|
||||
|
||||
import de.mattv.fileserver.data.Data;
|
||||
import de.mattv.fileserver.data.User;
|
||||
import de.mattv.fileserver.util.PublicRestController;
|
||||
import de.mattv.fileserver.util.AutoCloseLock;
|
||||
import de.mattv.fileserver.util.NonNull;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
@PublicRestController
|
||||
public class Signup {
|
||||
private record Body(@NonNull String username, @NonNull String password) {}
|
||||
|
||||
@PostMapping("/auth/signup")
|
||||
private @NonNull Optional<String> signup(@RequestBody Body body) {
|
||||
try (AutoCloseLock ignored = new AutoCloseLock(Data.USER_LOCK.writeLock())) {
|
||||
if (Data.findUser(body.username) != null)
|
||||
return Optional.of("User already exists");
|
||||
|
||||
if (body.password.length() < 6)
|
||||
return Optional.of("Password must be at least 6 characters");
|
||||
|
||||
String hash = AuthUtils.PW_ENCODER.encode(body.password);
|
||||
long id = Data.nextUserId++;
|
||||
User user = new User(id, body.username, hash);
|
||||
Data.USERS.put(id, user);
|
||||
Data.save();
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
}
|
72
src/main/java/de/mattv/fileserver/routes/auth/TFA.java
Normal file
72
src/main/java/de/mattv/fileserver/routes/auth/TFA.java
Normal file
@ -0,0 +1,72 @@
|
||||
package de.mattv.fileserver.routes.auth;
|
||||
|
||||
|
||||
import de.mattv.fileserver.Response;
|
||||
import de.mattv.fileserver.data.Data;
|
||||
import de.mattv.fileserver.data.User;
|
||||
import de.mattv.fileserver.security.TokenService;
|
||||
import de.mattv.fileserver.services.TFAMailer;
|
||||
import de.mattv.fileserver.util.AuthUser;
|
||||
import de.mattv.fileserver.util.UserRestController;
|
||||
import dev.samstevens.totp.secret.DefaultSecretGenerator;
|
||||
import dev.samstevens.totp.secret.SecretGenerator;
|
||||
import de.mattv.fileserver.util.NonNull;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
@UserRestController
|
||||
public class TFA {
|
||||
private static final SecretGenerator TOTP_SECRET_GENERATOR = new DefaultSecretGenerator();
|
||||
|
||||
private final TFAMailer mailer;
|
||||
|
||||
public TFA(TFAMailer mailer) {
|
||||
this.mailer = mailer;
|
||||
}
|
||||
|
||||
@PostMapping("/tfa/setup_mail")
|
||||
private @NonNull Optional<String> setupTfaMail(@AuthUser User user) {
|
||||
if (user.tfaSecret != null)
|
||||
return Optional.of("Tfa is already enabled");
|
||||
|
||||
user.tempTfaSecret = "";
|
||||
mailer.sendMail(user);
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
@PostMapping("/tfa/setup_totp")
|
||||
private @NonNull Response<String> setupTfaTotp(@AuthUser User user) {
|
||||
if (user.tfaSecret != null)
|
||||
return Response.e("Tfa is already enabled");
|
||||
|
||||
user.tempTfaSecret = TOTP_SECRET_GENERATOR.generate();
|
||||
return Response.o(user.tempTfaSecret);
|
||||
}
|
||||
|
||||
@PostMapping("/tfa/complete")
|
||||
private @NonNull Optional<String> setupComplete(@AuthUser User user, @RequestBody @NonNull String otp) {
|
||||
if (user.tempTfaSecret == null)
|
||||
return Optional.of("You never started tfa setup");
|
||||
|
||||
boolean ok = user.tempTfaSecret.isEmpty()
|
||||
? mailer.checkCode(user.id, otp)
|
||||
: AuthUtils.TOTP_VERIFIER.isValidCode(user.tempTfaSecret, otp);
|
||||
if (!ok)
|
||||
return Optional.of("Invalid code");
|
||||
|
||||
user.tfaSecret = user.tempTfaSecret;
|
||||
user.tempTfaSecret = null;
|
||||
Data.save();
|
||||
TokenService.logoutAll(user.id);
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
@PostMapping("/tfa/disable")
|
||||
private void disableTfa(@AuthUser User user) {
|
||||
user.tfaSecret = null;
|
||||
Data.save();
|
||||
TokenService.logoutAll(user.id);
|
||||
}
|
||||
}
|
53
src/main/java/de/mattv/fileserver/routes/fs/Create.java
Normal file
53
src/main/java/de/mattv/fileserver/routes/fs/Create.java
Normal file
@ -0,0 +1,53 @@
|
||||
package de.mattv.fileserver.routes.fs;
|
||||
|
||||
import de.mattv.fileserver.Response;
|
||||
import de.mattv.fileserver.data.Node;
|
||||
import de.mattv.fileserver.data.User;
|
||||
import de.mattv.fileserver.util.AuthUser;
|
||||
import de.mattv.fileserver.util.AutoCloseLock;
|
||||
import de.mattv.fileserver.util.UserRestController;
|
||||
import de.mattv.fileserver.util.NonNull;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Optional;
|
||||
|
||||
@Slf4j
|
||||
@UserRestController
|
||||
public class Create {
|
||||
private record Body(@NonNull String name, @NonNull long parent, @NonNull boolean file) {}
|
||||
|
||||
@PostMapping("/fs/create")
|
||||
private @NonNull Response<Response.CreateNodeInfo> create(@AuthUser User user, @RequestBody Body body) {
|
||||
try (AutoCloseLock ignored = new AutoCloseLock(user.nodeLock.readLock())) {
|
||||
Node parent = user.nodes.get(body.parent);
|
||||
if (parent == null) return Response.e("Invalid parent node");
|
||||
|
||||
Optional<Node> existing = parent.children.stream().filter(n -> n.name.equals(body.name)).findFirst();
|
||||
if (existing.isPresent()) return Response.o(new Response.CreateNodeInfo(
|
||||
existing.get().id,
|
||||
true,
|
||||
existing.get().isFile
|
||||
));
|
||||
|
||||
long id = user.nextNodeId.getAndIncrement();
|
||||
Node node = new Node(id, body.name, user);
|
||||
if (body.file) {
|
||||
boolean ignored2 = node.file.createNewFile();
|
||||
}
|
||||
node.isFile = body.file;
|
||||
parent.children.add(node);
|
||||
node.parent = parent;
|
||||
user.nodes.put(id, node);
|
||||
return Response.o(new Response.CreateNodeInfo(
|
||||
id,
|
||||
false,
|
||||
body.file
|
||||
));
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
}
|
77
src/main/java/de/mattv/fileserver/routes/fs/FsDelete.java
Normal file
77
src/main/java/de/mattv/fileserver/routes/fs/FsDelete.java
Normal file
@ -0,0 +1,77 @@
|
||||
package de.mattv.fileserver.routes.fs;
|
||||
|
||||
import de.mattv.fileserver.data.Data;
|
||||
import de.mattv.fileserver.data.Node;
|
||||
import de.mattv.fileserver.data.User;
|
||||
import de.mattv.fileserver.util.AuthUser;
|
||||
import de.mattv.fileserver.util.AutoCloseLock;
|
||||
import de.mattv.fileserver.util.UserRestController;
|
||||
import de.mattv.fileserver.util.NonNull;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Arrays;
|
||||
import java.util.Objects;
|
||||
import java.util.Stack;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
@Slf4j
|
||||
@UserRestController
|
||||
public class FsDelete {
|
||||
private record MyEmitter(SseEmitter emitter) {
|
||||
void send(String msg) {
|
||||
try { emitter.send(msg); } catch (IOException | IllegalStateException ignored) {}
|
||||
}
|
||||
}
|
||||
|
||||
private static final ExecutorService DELETE_POOL = Executors.newSingleThreadExecutor();
|
||||
|
||||
@PostMapping("/fs/delete")
|
||||
private @NonNull SseEmitter delete(@AuthUser User user, @RequestBody @NonNull long[] ids) {
|
||||
MyEmitter emitter = new MyEmitter(new SseEmitter());
|
||||
DELETE_POOL.execute(() -> {
|
||||
emitter.send("Waiting for lock...");
|
||||
try (AutoCloseLock ignored = new AutoCloseLock(user.nodeLock.writeLock())) {
|
||||
emitter.send(" Acquired\n");
|
||||
Stack<Node> todo = new Stack<>();
|
||||
todo.addAll(Arrays.stream(ids)
|
||||
.filter(n -> n > 0)
|
||||
.mapToObj(user.nodes::get)
|
||||
.filter(Objects::nonNull)
|
||||
.toList());
|
||||
|
||||
while (!todo.empty()) {
|
||||
Node node = todo.pop();
|
||||
String path = FsUtils.getPath(node);
|
||||
|
||||
if (!node.children.isEmpty()) {
|
||||
todo.push(node);
|
||||
emitter.send("Entering " + path + "\n");
|
||||
todo.addAll(node.children);
|
||||
node.children.clear();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (node.isFile) {
|
||||
emitter.send("Deleting " + path + "...");
|
||||
if (!node.file.delete()) log.warn("Failed to delete file {}", node.file);
|
||||
node.removePreview();
|
||||
emitter.send(" Done\n");
|
||||
} else {
|
||||
emitter.send("Exiting " + path + "\n");
|
||||
}
|
||||
node.parent.children.remove(node);
|
||||
node.parent = null;
|
||||
user.nodes.remove(node.id);
|
||||
}
|
||||
}
|
||||
Data.save();
|
||||
emitter.emitter.complete();
|
||||
});
|
||||
return emitter.emitter;
|
||||
}
|
||||
}
|
38
src/main/java/de/mattv/fileserver/routes/fs/FsUtils.java
Normal file
38
src/main/java/de/mattv/fileserver/routes/fs/FsUtils.java
Normal file
@ -0,0 +1,38 @@
|
||||
package de.mattv.fileserver.routes.fs;
|
||||
|
||||
import de.mattv.fileserver.data.Node;
|
||||
import de.mattv.fileserver.data.User;
|
||||
import de.mattv.fileserver.util.NonNull;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Objects;
|
||||
import java.util.Stack;
|
||||
|
||||
public class FsUtils {
|
||||
protected static long nodesSize(User user, long[] nodes) {
|
||||
long total = 0;
|
||||
Stack<Node> todo = new Stack<>();
|
||||
todo.addAll(Arrays.stream(nodes)
|
||||
.mapToObj(user.nodes::get)
|
||||
.filter(Objects::nonNull)
|
||||
.toList());
|
||||
|
||||
while (!todo.empty()) {
|
||||
Node node = todo.pop();
|
||||
if (node.isFile) total += node.size;
|
||||
else todo.addAll(node.children);
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
protected static @NonNull String getPath(Node node) {
|
||||
StringBuilder path = new StringBuilder();
|
||||
while (node != null) {
|
||||
path.insert(0, node.name);
|
||||
path.insert(0, '/');
|
||||
node = node.parent;
|
||||
}
|
||||
return path.isEmpty() ? "/" : path.toString();
|
||||
}
|
||||
}
|
67
src/main/java/de/mattv/fileserver/routes/fs/Info.java
Normal file
67
src/main/java/de/mattv/fileserver/routes/fs/Info.java
Normal file
@ -0,0 +1,67 @@
|
||||
package de.mattv.fileserver.routes.fs;
|
||||
|
||||
import de.mattv.fileserver.Response;
|
||||
import de.mattv.fileserver.data.Node;
|
||||
import de.mattv.fileserver.data.User;
|
||||
import de.mattv.fileserver.util.AuthUser;
|
||||
import de.mattv.fileserver.util.AutoCloseLock;
|
||||
import de.mattv.fileserver.util.UserRestController;
|
||||
import de.mattv.fileserver.util.NonNull;
|
||||
import org.springframework.http.MediaTypeFactory;
|
||||
import org.springframework.util.MimeType;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@UserRestController
|
||||
public class Info {
|
||||
@PostMapping("/fs/node")
|
||||
private @NonNull Optional<Response.Node> nodeInfo(@AuthUser User user, @RequestBody @NonNull long id) {
|
||||
try (AutoCloseLock ignored = new AutoCloseLock(user.nodeLock.readLock())) {
|
||||
Node node = user.nodes.get(id);
|
||||
if (node == null) return Optional.empty();
|
||||
|
||||
Response.Node me = Response.Node.from(
|
||||
node,
|
||||
node.children.stream()
|
||||
.map(n -> Response.Node.from(n, new Response.Node[0]))
|
||||
.toArray(Response.Node[]::new)
|
||||
);
|
||||
return Optional.of(me);
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/fs/path")
|
||||
private @NonNull Optional<List<Response.PathSegment>> path(@AuthUser User user, @RequestBody @NonNull long id) {
|
||||
try (AutoCloseLock ignored = new AutoCloseLock(user.nodeLock.readLock())) {
|
||||
Node node = user.nodes.get(id);
|
||||
if (node == null) return Optional.empty();
|
||||
|
||||
ArrayList<Response.PathSegment> segments = new ArrayList<>();
|
||||
while (node != null) {
|
||||
segments.add(new Response.PathSegment(node.name, node.isFile ? null : node.id));
|
||||
node = node.parent;
|
||||
}
|
||||
return Optional.of(segments.reversed());
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/fs/size")
|
||||
private long size(@AuthUser User user, @RequestBody @NonNull long[] ids) {
|
||||
try (AutoCloseLock ignored = new AutoCloseLock(user.nodeLock.readLock())) {
|
||||
return FsUtils.nodesSize(user, ids);
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/fs/mime")
|
||||
private @NonNull Optional<String> mime(@AuthUser User user, @RequestBody @NonNull long id) {
|
||||
try (AutoCloseLock ignored = new AutoCloseLock(user.nodeLock.readLock())) {
|
||||
Node node = user.nodes.get(id);
|
||||
if (node == null || !node.isFile) return Optional.empty();
|
||||
return MediaTypeFactory.getMediaType(node.name).map(MimeType::toString);
|
||||
}
|
||||
}
|
||||
}
|
33
src/main/java/de/mattv/fileserver/routes/fs/Preview.java
Normal file
33
src/main/java/de/mattv/fileserver/routes/fs/Preview.java
Normal file
@ -0,0 +1,33 @@
|
||||
package de.mattv.fileserver.routes.fs;
|
||||
|
||||
import de.mattv.fileserver.data.Node;
|
||||
import de.mattv.fileserver.data.User;
|
||||
import de.mattv.fileserver.util.AuthUser;
|
||||
import de.mattv.fileserver.util.AutoCloseLock;
|
||||
import de.mattv.fileserver.util.UserRestController;
|
||||
import de.mattv.fileserver.util.NonNull;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.codec.binary.Base64;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.util.Optional;
|
||||
|
||||
@Slf4j
|
||||
@UserRestController
|
||||
public class Preview {
|
||||
@PostMapping("/fs/preview")
|
||||
private @NonNull Optional<String> preview(@AuthUser User user, @RequestBody long id) {
|
||||
try (AutoCloseLock ignored = new AutoCloseLock(user.nodeLock.readLock())) {
|
||||
Node node = user.nodes.get(id);
|
||||
if (node == null || !node.hasPreview) return Optional.empty();
|
||||
byte[] content = Files.readAllBytes(node.previewFile.toPath());
|
||||
return Optional.of(Base64.encodeBase64String(content));
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to read preview file", e);
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
}
|
49
src/main/java/de/mattv/fileserver/security/Config.java
Normal file
49
src/main/java/de/mattv/fileserver/security/Config.java
Normal file
@ -0,0 +1,49 @@
|
||||
package de.mattv.fileserver.security;
|
||||
|
||||
import io.swagger.v3.oas.annotations.enums.SecuritySchemeType;
|
||||
import io.swagger.v3.oas.annotations.security.SecurityScheme;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.context.NullSecurityContextRepository;
|
||||
|
||||
@EnableWebSecurity
|
||||
@Configuration
|
||||
@SecurityScheme(
|
||||
name = "Token",
|
||||
type = SecuritySchemeType.HTTP,
|
||||
scheme = "bearer"
|
||||
)
|
||||
public class Config {
|
||||
@Bean
|
||||
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
||||
Manager manager = new Manager();
|
||||
Filter filter = new Filter();
|
||||
filter.setAuthenticationManager(manager);
|
||||
filter.setSecurityContextRepository(new NullSecurityContextRepository());
|
||||
|
||||
return http
|
||||
.addFilter(filter)
|
||||
.authenticationManager(manager)
|
||||
.securityMatcher("/api/user/**", "/api/admin/**")
|
||||
.authorizeHttpRequests(auth -> {
|
||||
auth.requestMatchers("/api/admin/**").hasRole("ADMIN");
|
||||
auth.anyRequest().authenticated();
|
||||
}
|
||||
)
|
||||
.cors(AbstractHttpConfigurer::disable)
|
||||
.csrf(AbstractHttpConfigurer::disable)
|
||||
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public UserDetailsService userDetailsService() {
|
||||
return username -> null;
|
||||
}
|
||||
}
|
19
src/main/java/de/mattv/fileserver/security/Filter.java
Normal file
19
src/main/java/de/mattv/fileserver/security/Filter.java
Normal file
@ -0,0 +1,19 @@
|
||||
package de.mattv.fileserver.security;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.springframework.security.web.authentication.preauth.AbstractPreAuthenticatedProcessingFilter;
|
||||
|
||||
public class Filter extends AbstractPreAuthenticatedProcessingFilter {
|
||||
@Override
|
||||
protected Object getPreAuthenticatedPrincipal(HttpServletRequest request) {
|
||||
return "";
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Object getPreAuthenticatedCredentials(HttpServletRequest request) {
|
||||
String auth = request.getHeader("Authorization");
|
||||
if (auth == null || !auth.startsWith("Bearer "))
|
||||
return null;
|
||||
return TokenService.getToken(auth.substring(7));
|
||||
}
|
||||
}
|
14
src/main/java/de/mattv/fileserver/security/Manager.java
Normal file
14
src/main/java/de/mattv/fileserver/security/Manager.java
Normal file
@ -0,0 +1,14 @@
|
||||
package de.mattv.fileserver.security;
|
||||
|
||||
import de.mattv.fileserver.data.Token;
|
||||
import org.springframework.security.authentication.AuthenticationManager;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
|
||||
public class Manager implements AuthenticationManager {
|
||||
@Override
|
||||
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
|
||||
Token token = (Token) authentication.getCredentials();
|
||||
return token == null ? authentication : token;
|
||||
}
|
||||
}
|
48
src/main/java/de/mattv/fileserver/security/TokenService.java
Normal file
48
src/main/java/de/mattv/fileserver/security/TokenService.java
Normal file
@ -0,0 +1,48 @@
|
||||
package de.mattv.fileserver.security;
|
||||
|
||||
import de.mattv.fileserver.data.Token;
|
||||
import de.mattv.fileserver.data.User;
|
||||
import de.mattv.fileserver.util.NonNull;
|
||||
import org.apache.commons.lang3.RandomStringUtils;
|
||||
import org.springframework.lang.Nullable;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
public class TokenService {
|
||||
private static final ConcurrentHashMap<String, Token> TOKENS = new ConcurrentHashMap<>();
|
||||
|
||||
@Scheduled(fixedRate = 2, timeUnit = TimeUnit.HOURS)
|
||||
private static void cleanExpiredTokens() {
|
||||
TOKENS.entrySet().removeIf(entry -> entry.getValue().expired());
|
||||
}
|
||||
|
||||
public static String createToken(@NonNull User user) {
|
||||
String token = RandomStringUtils.random(32, true, true);
|
||||
TOKENS.put(token, new Token(token, user));
|
||||
return token;
|
||||
}
|
||||
|
||||
public static @Nullable Token getToken(@NonNull String token) {
|
||||
Token found = TOKENS.get(token);
|
||||
if (found != null && found.expired()) {
|
||||
TOKENS.remove(token);
|
||||
found = null;
|
||||
} else if (found != null) {
|
||||
found.refresh();
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
public static void logout(@NonNull Token token) {
|
||||
TOKENS.remove(token.getToken());
|
||||
}
|
||||
|
||||
public static void logoutAll(long id) {
|
||||
TOKENS.entrySet().removeIf(entry -> {
|
||||
Token token = entry.getValue();
|
||||
return token.getUser().id == id || token.getRealUser().id == id;
|
||||
});
|
||||
}
|
||||
}
|
@ -0,0 +1,59 @@
|
||||
package de.mattv.fileserver.services;
|
||||
|
||||
import de.mattv.fileserver.Utils;
|
||||
import de.mattv.fileserver.data.Data;
|
||||
import de.mattv.fileserver.data.User;
|
||||
import jakarta.annotation.Nullable;
|
||||
import de.mattv.fileserver.util.NonNull;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.RandomStringUtils;
|
||||
import org.apache.commons.lang3.tuple.Pair;
|
||||
import org.springframework.mail.MailSender;
|
||||
import org.springframework.mail.SimpleMailMessage;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
public class RecoveryMailer {
|
||||
private static final ConcurrentHashMap<String, Pair<Long, Instant>> RECOVERY_KEYS = new ConcurrentHashMap<>();
|
||||
|
||||
private static final Duration LIFETIME = Duration.ofMinutes(5);
|
||||
private static final int CODE_LENGTH = 16;
|
||||
|
||||
private final MailSender mailSender;
|
||||
|
||||
public RecoveryMailer(@SuppressWarnings("SpringJavaInjectionPointsAutowiringInspection") @NonNull MailSender mailSender) {
|
||||
this.mailSender = mailSender;
|
||||
}
|
||||
|
||||
public void sendMail(@NonNull User user) {
|
||||
log.info("Sending recovery mail to: {}", user.name);
|
||||
String code = RandomStringUtils.random(CODE_LENGTH, false, true);
|
||||
RECOVERY_KEYS.put(code, Pair.of(user.id, Instant.now().plus(LIFETIME)));
|
||||
SimpleMailMessage msg = new SimpleMailMessage();
|
||||
msg.setTo(user.name);
|
||||
msg.setSubject("MFileserver - Password recovery");
|
||||
msg.setText("Your recovery key is: " + code + "\nIt is valid for " + LIFETIME.toMinutes() + " minutes.");
|
||||
mailSender.send(msg);
|
||||
}
|
||||
|
||||
public @Nullable User checkCode(@NonNull String code) {
|
||||
if (code.length() != CODE_LENGTH)
|
||||
return null;
|
||||
Pair<Long, Instant> entry = RECOVERY_KEYS.remove(code);
|
||||
if (entry == null || Utils.instantExpired(entry.getRight()))
|
||||
return null;
|
||||
return Data.USERS.get(entry.getLeft());
|
||||
}
|
||||
|
||||
@Scheduled(fixedRate = 2, timeUnit = TimeUnit.HOURS)
|
||||
private static void cleanExpiredKeys() {
|
||||
RECOVERY_KEYS.entrySet().removeIf(entry -> Utils.instantExpired(entry.getValue().getRight()));
|
||||
}
|
||||
}
|
60
src/main/java/de/mattv/fileserver/services/TFAMailer.java
Normal file
60
src/main/java/de/mattv/fileserver/services/TFAMailer.java
Normal file
@ -0,0 +1,60 @@
|
||||
package de.mattv.fileserver.services;
|
||||
|
||||
import de.mattv.fileserver.Utils;
|
||||
import de.mattv.fileserver.data.User;
|
||||
import de.mattv.fileserver.util.NonNull;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.RandomStringUtils;
|
||||
import org.apache.commons.lang3.tuple.Pair;
|
||||
import org.springframework.mail.MailSender;
|
||||
import org.springframework.mail.SimpleMailMessage;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
public class TFAMailer {
|
||||
private static final ConcurrentHashMap<String, Pair<Long, Instant>> MAIL_OTP = new ConcurrentHashMap<>();
|
||||
|
||||
private static final Duration LIFETIME = Duration.ofMinutes(5);
|
||||
private static final int CODE_LENGTH = 10;
|
||||
|
||||
private final MailSender mailSender;
|
||||
|
||||
public TFAMailer(@SuppressWarnings("SpringJavaInjectionPointsAutowiringInspection") @NonNull MailSender mailSender) {
|
||||
this.mailSender = mailSender;
|
||||
}
|
||||
|
||||
public void sendMail(@NonNull User user) {
|
||||
log.info("Sending tfa mail to: {}", user.name);
|
||||
String code = RandomStringUtils.random(CODE_LENGTH, false, true);
|
||||
MAIL_OTP.put(code, Pair.of(user.id, Instant.now().plus(LIFETIME)));
|
||||
SimpleMailMessage msg = new SimpleMailMessage();
|
||||
msg.setTo(user.name);
|
||||
msg.setSubject("MFileserver - TFA code");
|
||||
msg.setText("Your code is: " + code + "\nIt is valid for " + LIFETIME.toMinutes() + " minutes.");
|
||||
mailSender.send(msg);
|
||||
|
||||
}
|
||||
|
||||
public boolean checkCode(long userId, @NonNull String code) {
|
||||
if (code.length() != CODE_LENGTH)
|
||||
return false;
|
||||
Pair<Long, Instant> entry = MAIL_OTP.remove(code);
|
||||
if (entry == null || Utils.instantExpired(entry.getRight()))
|
||||
return false;
|
||||
if (entry.getLeft() != userId)
|
||||
MAIL_OTP.put(code, entry);
|
||||
return entry.getLeft() == userId;
|
||||
}
|
||||
|
||||
@Scheduled(fixedRate = 2, timeUnit = TimeUnit.HOURS)
|
||||
private static void cleanExpiredKeys() {
|
||||
MAIL_OTP.entrySet().removeIf(entry -> Utils.instantExpired(entry.getValue().getRight()));
|
||||
}
|
||||
}
|
@ -0,0 +1,14 @@
|
||||
package de.mattv.fileserver.util;
|
||||
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
@Target(ElementType.TYPE)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@SecuredRestController
|
||||
@RequestMapping("/api/admin")
|
||||
public @interface AdminRestController {}
|
17
src/main/java/de/mattv/fileserver/util/AuthUser.java
Normal file
17
src/main/java/de/mattv/fileserver/util/AuthUser.java
Normal file
@ -0,0 +1,17 @@
|
||||
package de.mattv.fileserver.util;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
|
||||
@Target(ElementType.PARAMETER)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@AuthenticationPrincipal
|
||||
@Parameter(hidden = true)
|
||||
public @interface AuthUser {
|
||||
}
|
17
src/main/java/de/mattv/fileserver/util/AutoCloseLock.java
Normal file
17
src/main/java/de/mattv/fileserver/util/AutoCloseLock.java
Normal file
@ -0,0 +1,17 @@
|
||||
package de.mattv.fileserver.util;
|
||||
|
||||
import java.util.concurrent.locks.Lock;
|
||||
|
||||
public class AutoCloseLock implements AutoCloseable {
|
||||
private final Lock lock;
|
||||
|
||||
public AutoCloseLock(Lock lock) {
|
||||
this.lock = lock;
|
||||
lock.lock();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
15
src/main/java/de/mattv/fileserver/util/NonNull.java
Normal file
15
src/main/java/de/mattv/fileserver/util/NonNull.java
Normal file
@ -0,0 +1,15 @@
|
||||
package de.mattv.fileserver.util;
|
||||
|
||||
import jakarta.annotation.Nonnull;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
@Target({ElementType.METHOD, ElementType.PARAMETER, ElementType.FIELD})
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@lombok.NonNull
|
||||
@Nonnull
|
||||
public @interface NonNull {
|
||||
}
|
@ -0,0 +1,15 @@
|
||||
package de.mattv.fileserver.util;
|
||||
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
@Target(ElementType.TYPE)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@RestController
|
||||
@RequestMapping("/api/public")
|
||||
public @interface PublicRestController {}
|
@ -0,0 +1,15 @@
|
||||
package de.mattv.fileserver.util;
|
||||
|
||||
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
@Target(ElementType.TYPE)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@RestController
|
||||
@SecurityRequirement(name = "Token")
|
||||
public @interface SecuredRestController {}
|
@ -0,0 +1,14 @@
|
||||
package de.mattv.fileserver.util;
|
||||
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
@Target(ElementType.TYPE)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@SecuredRestController
|
||||
@RequestMapping("/api/user")
|
||||
public @interface UserRestController {}
|
6
src/main/resources/application-dev.properties
Normal file
6
src/main/resources/application-dev.properties
Normal file
@ -0,0 +1,6 @@
|
||||
server.port=2121
|
||||
|
||||
springdoc.api-docs.enabled=true
|
||||
springdoc.api-docs.path=/openapi.json
|
||||
springdoc.swagger-ui.enabled=true
|
||||
springdoc.swagger-ui.path=/swagger
|
@ -1 +1,8 @@
|
||||
spring.application.name=fileserver
|
||||
|
||||
server.port=2345
|
||||
server.shutdown=graceful
|
||||
|
||||
springdoc.use-fqn=true
|
||||
springdoc.api-docs.enabled=false
|
||||
springdoc.swagger-ui.enabled=false
|
||||
|
25
src/main/resources/logback-spring.xml
Normal file
25
src/main/resources/logback-spring.xml
Normal file
@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<configuration>
|
||||
<timestamp key="currentTs" datePattern="yyyy-MM-dd_HH-mm-ss"/>
|
||||
|
||||
<appender name="Console" class="ch.qos.logback.core.ConsoleAppender">
|
||||
<layout class="ch.qos.logback.classic.PatternLayout">
|
||||
<Pattern>%d{HH:mm:ss.SSS} [%t] %-5level %logger{36} - %msg%n%throwable</Pattern>
|
||||
</layout>
|
||||
</appender>
|
||||
|
||||
<appender name="RollingFile" class="ch.qos.logback.core.FileAppender">
|
||||
<file>./logs/fileserver-${currentTs}.log</file>
|
||||
<encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
|
||||
<Pattern>%d{HH:mm:ss.SSS} [%t] %-5level %logger{36} - %msg%n%throwable</Pattern>
|
||||
</encoder>
|
||||
</appender>
|
||||
|
||||
<root level="info">
|
||||
<appender-ref ref="RollingFile" />
|
||||
<appender-ref ref="Console" />
|
||||
</root>
|
||||
|
||||
<logger name="org.springframework.web.filter.CommonsRequestLoggingFilter" level="debug" />
|
||||
<logger name="de.mattv.fileserver" level="trace" />
|
||||
</configuration>
|
Loading…
Reference in New Issue
Block a user