1
0

114 Commits

Author SHA1 Message Date
2f19f28213 Merge remote-tracking branch 'origin/master' 2026-01-18 00:05:54 +01:00
6e1eceba2f #26 Reenabled buildkit 2026-01-18 00:05:49 +01:00
618479634f [maven-release-plugin] prepare for next development iteration 2026-01-18 00:04:27 +01:00
0ec5d71bcb [maven-release-plugin] prepare release v29 2026-01-18 00:04:26 +01:00
b3b15cedc2 Merge remote-tracking branch 'origin/master' 2026-01-18 00:04:02 +01:00
79c59af0c6 #26 Disable buildkit 2026-01-18 00:03:48 +01:00
3fe79f6c05 [maven-release-plugin] prepare for next development iteration 2026-01-17 23:57:12 +01:00
09b776c920 [maven-release-plugin] prepare release v28 2026-01-17 23:57:11 +01:00
bfee9ac082 #26 Fix docker build again 2026-01-17 23:56:53 +01:00
e6e312ca09 [maven-release-plugin] prepare for next development iteration 2026-01-17 23:54:26 +01:00
30843a3064 [maven-release-plugin] prepare release v27 2026-01-17 23:54:25 +01:00
f5e9e26dbf Merge remote-tracking branch 'origin/master' 2026-01-17 23:54:06 +01:00
4a1e0b9606 #26 Fix docker build 2026-01-17 23:53:57 +01:00
68cc0df881 [maven-release-plugin] prepare for next development iteration 2026-01-17 23:40:39 +01:00
b1326d7c37 [maven-release-plugin] prepare release v26 2026-01-17 23:40:38 +01:00
9b94766494 #26 Fix comment 2026-01-17 23:40:14 +01:00
27db87792d Merge remote-tracking branch 'origin/master' 2026-01-17 23:39:19 +01:00
0af399275d #26 Enable docker build 2026-01-17 23:38:52 +01:00
7b707e5bb5 [maven-release-plugin] prepare for next development iteration 2026-01-17 23:27:51 +01:00
634ae4365f [maven-release-plugin] prepare release v25 2026-01-17 23:27:49 +01:00
49008be24a #26 Fix repo param 2026-01-17 23:27:26 +01:00
eca68b7bbf [maven-release-plugin] prepare for next development iteration 2026-01-17 23:10:42 +01:00
d0b5b92fc7 [maven-release-plugin] prepare release v24 2026-01-17 23:10:40 +01:00
4386dc4449 Fix repo param name 2026-01-17 23:10:20 +01:00
32c7719942 Fix repo param name 2026-01-17 23:08:38 +01:00
950ab8c568 [maven-release-plugin] prepare for next development iteration 2026-01-17 23:06:45 +01:00
73043da77c [maven-release-plugin] prepare release v23 2026-01-17 23:06:43 +01:00
30d6252992 Bump to 23-SNAPSHOT and fix repo URL 2026-01-17 23:05:25 +01:00
66ccf4b263 [maven-release-plugin] prepare release v22 2026-01-17 22:42:17 +01:00
5cc6bd6305 #26 Try usr/pwd again 2026-01-17 22:40:54 +01:00
c42313e022 #26 Try dev connection with credentials 2026-01-17 22:32:35 +01:00
ac439261f0 #26 Update scm connection 2026-01-17 22:29:48 +01:00
be19d2881e #26 Try usr/pwd 2026-01-17 22:24:15 +01:00
71bc53488d #26 Readd origin 2026-01-17 20:53:41 +01:00
89db72a316 #26 Use developerConnection for release plugin 2026-01-17 20:43:51 +01:00
06e72a794f #26 Add GPG signing 2026-01-17 20:34:48 +01:00
ea45ef73f4 #26 Do semi dry run 2026-01-17 20:10:37 +01:00
ef9c56f6c9 #26 Add more debug 2026-01-17 13:08:56 +01:00
718f3e4a51 #26 Add debug 2026-01-17 12:01:08 +01:00
9cb1bab118 #26 Try to some more 2026-01-17 11:59:35 +01:00
90dbeecdca #26 Try to fix bad sub 2026-01-17 11:56:13 +01:00
b6545e2f62 #26 Add missing env var 2026-01-17 11:49:04 +01:00
c1ca25ea24 #26 Use pkgx instead of pixi 2026-01-17 11:46:30 +01:00
046124464e #26 Dockerize 2026-01-17 11:12:53 +01:00
2eab631796 [maven-release-plugin] prepare for next development iteration 2023-02-21 18:33:05 +01:00
ed23426690 [maven-release-plugin] prepare release v21 2023-02-21 18:33:02 +01:00
b6e6a94a1e #24 todo app 2023-02-21 18:31:01 +01:00
455cb63652 [maven-release-plugin] prepare for next development iteration 2023-01-23 23:14:16 +01:00
4573df557f [maven-release-plugin] prepare release v20 2023-01-23 23:14:13 +01:00
1cac696dde #2 Folder sharing fixup 2023-01-23 23:13:22 +01:00
7be52466d2 [maven-release-plugin] prepare for next development iteration 2023-01-23 21:03:32 +01:00
4a8bfe68c5 [maven-release-plugin] prepare release v19 2023-01-23 21:03:29 +01:00
b3ea85bdae #2 Folder sharing 2023-01-23 21:02:09 +01:00
7ba25aa455 Fix typos 2022-11-01 19:45:32 +01:00
69cb3204b9 Add Apache httpd config example 2022-11-01 19:44:21 +01:00
b8251ac4e7 Merge remote-tracking branch 'origin/master' 2022-11-01 18:22:44 +01:00
9b30e06949 #22 Fix bug with password protected shares 2022-11-01 18:19:16 +01:00
1a50a196d4 [maven-release-plugin] prepare for next development iteration 2022-10-20 21:15:54 +02:00
20ed65910a [maven-release-plugin] prepare release v18 2022-10-20 21:15:51 +02:00
1f5b2c0a5d Update changelog 2022-10-20 21:14:55 +02:00
4188a86995 #22 Password protected shares 2022-10-20 21:11:05 +02:00
2ab4497bd1 Basic implementation for notes app 2022-08-25 17:14:37 +02:00
a0239ecda6 Basic implementation of files API that allows other apps filesystem access 2022-08-13 18:40:13 +02:00
bd6f1e43d1 [maven-release-plugin] prepare for next development iteration 2022-08-13 15:10:30 +02:00
1fcf2c3fbc [maven-release-plugin] prepare release v17 2022-08-13 15:10:27 +02:00
4134160b97 #11 Metrics widget 2022-08-13 15:09:05 +02:00
16fe2c4a93 Merge remote-tracking branch 'origin/master' 2022-08-13 12:42:46 +02:00
7a5cd8fe48 Add disk size to disk usage widget 2022-08-13 12:42:25 +02:00
76faace53d [maven-release-plugin] prepare for next development iteration 2022-08-12 22:28:01 +02:00
a1644c89da [maven-release-plugin] prepare release v16 2022-08-12 22:27:58 +02:00
f4d9f4bab7 #12 App widgets 2022-08-12 22:26:17 +02:00
e47af45211 #6 Directory download as ZIP 2022-08-11 22:53:01 +02:00
530b2f6198 #1 #5 One time and time limited shares 2022-08-11 21:23:17 +02:00
c081d69d29 #7 Add a favicon 2022-08-11 17:29:56 +02:00
607a4c1c3f #20 Improve location checking 2022-08-10 02:10:42 +02:00
c3a60381c8 Specify server port so the app is not exposed
FUCK YOU, better luck next time fucker. Write better shellcode you cunt
2022-06-28 22:32:52 +02:00
5e4d9a05c9 [maven-release-plugin] prepare for next development iteration 2022-05-11 22:17:36 +02:00
5921ba0bc7 [maven-release-plugin] prepare release v15 2022-05-11 22:17:33 +02:00
dbc9e74979 Increase version after failed release 2022-05-11 22:16:43 +02:00
739b8040a5 [maven-release-plugin] prepare release v14 2022-05-11 22:09:56 +02:00
ce27dd6b16 Reset to SNAPSHOT after failed release 2022-05-11 22:08:51 +02:00
b7efe6321c [maven-release-plugin] prepare release v14 2022-05-11 22:01:16 +02:00
2aab11b518 #19 Release & deployment v2 2022-05-11 22:00:25 +02:00
42b46d87c3 Merge remote-tracking branch 'origin/master' 2022-05-10 22:46:08 +02:00
78d1f59929 #19 Release & deployment v2 2022-05-10 22:45:49 +02:00
62706fb214 [maven-release-plugin] prepare for next development iteration 2022-05-09 22:40:02 +02:00
a67d524074 [maven-release-plugin] prepare release v13 2022-05-09 22:39:59 +02:00
7a06f56f94 Merge remote-tracking branch 'origin/master' 2022-05-09 22:39:14 +02:00
719227a57b #18 Add profiles 2022-05-09 22:39:01 +02:00
941ce785f2 [maven-release-plugin] prepare for next development iteration 2022-05-09 22:36:52 +02:00
283d4bc964 [maven-release-plugin] prepare release v12 2022-05-09 22:36:49 +02:00
48a0fd48ba Merge remote-tracking branch 'origin/master' 2022-05-09 22:36:19 +02:00
66570e71c8 #18 Add profiles 2022-05-09 22:36:05 +02:00
ca881dbf86 [maven-release-plugin] prepare for next development iteration 2022-05-09 22:29:18 +02:00
6fa96d110b [maven-release-plugin] prepare release v11 2022-05-09 22:29:15 +02:00
98b1ecfe7a Merge remote-tracking branch 'origin/master' 2022-05-09 22:28:00 +02:00
6b5c260bd2 #18 Add profiles 2022-05-09 22:27:42 +02:00
ea67c31867 [maven-release-plugin] prepare for next development iteration 2022-05-09 22:23:41 +02:00
82e048d56e [maven-release-plugin] prepare release v10 2022-05-09 22:23:38 +02:00
1b9ce54933 #18 Add profiles 2022-05-09 22:18:20 +02:00
f89ebaa44e [maven-release-plugin] prepare for next development iteration 2022-05-08 22:38:31 +02:00
21a61b8a4d [maven-release-plugin] prepare release v9 2022-05-08 22:38:28 +02:00
8369822717 Merge remote-tracking branch 'origin/master' 2022-05-08 22:30:52 +02:00
54d3636275 Update changelog.txt 2022-05-08 22:30:40 +02:00
7ec613f0f4 #9 Add systemd user service
Fix service file, because the hardening does not make that much sense as it is run as --user anyway
2022-05-08 22:27:06 +02:00
8a20c156d5 [maven-release-plugin] prepare for next development iteration 2022-05-08 21:39:10 +02:00
09c0111f91 [maven-release-plugin] prepare release v8 2022-05-08 21:39:07 +02:00
2435670c3f Merge remote-tracking branch 'origin/master' 2022-05-08 21:38:35 +02:00
501498e0ce #8 Add Jenkins jobs
Fix deployment script over 9000
2022-05-08 21:38:19 +02:00
45ebd73940 [maven-release-plugin] prepare for next development iteration 2022-05-08 21:30:49 +02:00
6e26a5c9c5 [maven-release-plugin] prepare release v7 2022-05-08 21:30:46 +02:00
fdef4ee9ae Merge remote-tracking branch 'origin/master' 2022-05-08 21:30:06 +02:00
2f662f2646 #8 Add Jenkins jobs
Fix deployment script even more
2022-05-08 21:29:53 +02:00
fd3ad23d67 [maven-release-plugin] prepare for next development iteration 2022-05-08 21:25:25 +02:00
92 changed files with 3037 additions and 400 deletions

21
.gitignore vendored Normal file
View File

@@ -0,0 +1,21 @@
# Compiled class file
*.class
# Log file
*.log
# Package Files #
*.jar
*.war
*.nar
*.ear
*.zip
*.tar.gz
*.rar
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
hs_err_pid*
replay_pid*
*/target/*
.idea/*

75
README.md Normal file
View File

@@ -0,0 +1,75 @@
# NoBullShit-cloud
A personal cloud without bullshit
## Prerequisites
nbscloud requires `java>=18`
## Install
### systemd service
### `deploy.sh`
## Config
To adjust the default configuration create the file `~/.config/nbscloud/application.properties`. It allows overwriting
properties from every configuration file of nbscloud. To get a full list of existing properties look at the following
files:
|File|Description|
|----|-----------|
|[application.properties](./web-container/src/main/resources/config/application.properties)|Main config file providing general app properties|
|[shared-application.properties](./web-container-config/src/main/resources/config/shared-application.properties)|Properties shared by all apps|
|[files-application.properties](./files/src/main/resources/config/files-application.properties)|Config file for the files app|
|[dashboard-application.properties](./dashboard/src/main/resources/config/dashboard-application.properties)|Config file for the dashboard app|
## Apache httpd config
It is advised to not expose NoBullShit-cloud directly - instead a proxy server like Apache httpd should be used to shield access.
The following config example can be used a blueprint:
```
# CSS, favicon and fonts need to be accesible without auth
# for e.g. the password protected share feature
# If the password protected share feature is not used
# the locations can be omitted to prevent an information leak
# In fact, they can also be ommitted if the password protected
# share feature _is_ used, but then the password entry page
# will not be styled. Pick your poison.
<Location /nbscloud/css>
ProxyPass http://localhost:PORT/nbscloud
ProxyPassReverse /nbscloud
allow from all
satisfy any
</Location>
<Location /nbscloud/favicon.ico>
ProxyPass http://localhost:PORT/nbscloud
ProxyPassReverse /nbscloud
allow from all
satisfy any
</Location>
<Location /nbscloud/font>
ProxyPass http://localhost:PORT/nbscloud
ProxyPassReverse /nbscloud
allow from all
satisfy any
</Location>
# If shares should not be accessible to unknown clients
# this (and the Location directives above) can be omitted
<Location /nbscloud/files/shares>
ProxyPass http://localhost:PORT/nbscloud
ProxyPassReverse /nbscloud
allow from all
satisfy any
</Location>
<Location /nbscloud>
ProxyPass http://localhost:PORT/nbscloud
ProxyPassReverse /nbscloud
<RequireAll>
Require all granted
AuthName "YOUR AUTH"
AuthType Basic
AuthUserFile /var/www/html/.htpasswd
Require valid-user
</RequireAll>
</Location>
```

View File

@@ -5,7 +5,7 @@
<parent>
<artifactId>nbs-cloud-aggregator</artifactId>
<groupId>de.77zzcx7.nbs-cloud</groupId>
<version>6</version>
<version>30-SNAPSHOT</version>
</parent>
<groupId>de.77zzcx7.nbs-cloud</groupId>

9
build/Dockerfile Normal file
View File

@@ -0,0 +1,9 @@
FROM eclipse-temurin:25-jre-alpine
ARG JAR_FILE
COPY ${JAR_FILE} app.jar
EXPOSE 8082
ENTRYPOINT ["java", "-jar", "/app.jar"]

86
build/Jenkinsfile vendored Normal file
View File

@@ -0,0 +1,86 @@
pipeline {
agent { label 'docker' }
parameters {
booleanParam(name: 'DRY_RUN', defaultValue: true, description: 'If checked, no code will be pushed to Gitea, Reposilite, or Docker.')
}
environment {
REPO_URL = credentials('reposilite-url')
DOCKER_REGISTRY = credentials('docker-registry-url')
GIT_URL_CLEAN = sh(script: "echo ${GIT_URL} | sed 's|https://||'", returnStdout: true).trim()
IS_DRY_RUN = "${params.DRY_RUN}"
}
stages {
stage('Version & Tag') {
steps {
withCredentials([usernamePassword(credentialsId: 'Jenkins_Gitea',
usernameVariable: 'GITEA_CREDS_USR',
passwordVariable: 'GITEA_CREDS_PSW'),
file(credentialsId: 'jenkins-gpg-key', variable: 'GPG_KEY_FILE')]) {
sh '''
gpg --batch --import "${GPG_KEY_FILE}"
KEY_ID=$(gpg --list-keys --with-colons | awk -F: '/^pub:/ { print $5; exit }')
git config user.email "jenkins@77zzcx7.de"
git config user.name "Jenkins"
git config user.signingkey "$KEY_ID"
git config commit.gpgsign true
git config tag.gpgSign true
# We need to pass the repo url in -Darguments again because of insane maven lifecycle forking
pkgx mvn release:prepare -B \
-s build/settings.xml \
-Dpassword="${GITEA_CREDS_PSW}" \
-Dusername="${GITEA_CREDS_USR}" \
-DdryRun=${IS_DRY_RUN} \
-Drepository.url=${REPO_URL} \
-DtagNameFormat="v@{project.version}" \
-Darguments="-Drepository.url=${REPO_URL} -Dtag=v\\${project.version} -DskipTests"
'''
}
}
}
stage('Deploy Release') {
steps {
withCredentials([usernamePassword(credentialsId: 'reposilite-jenkins-cred',
usernameVariable: 'REPO_USER',
passwordVariable: 'REPO_TOKEN')]) {
sh '''
pkgx mvn release:perform -B \
-s build/settings.xml \
-DdryRun=${DRY_RUN} \
-Drepository.url=${REPO_URL} \
-Darguments="-Drepository.url=${REPO_URL} -DskipTests"
'''
}
}
}
stage('Docker Build & Push') {
steps {
script {
def jarPath = sh(script: "ls target/checkout/web-container/target/*.jar | head -n 1", returnStdout: true).trim()
def releaseVer = sh(script: "pkgx mvn help:evaluate -Dexpression=project.version -q -DforceStdout -f target/checkout/web-container/pom.xml", returnStdout: true).trim()
def registry = env.DOCKER_REGISTRY.toLowerCase()
def registryHost = registry.replace("https://", "").replace("http://", "").replaceAll("/\$", "").toLowerCase()
def imageTag = "${registryHost}/nbscloud:${releaseVer}".toLowerCase()
docker.withRegistry(registry, '') {
def customImage = docker.build(imageTag, "-f build/Dockerfile --build-arg JAR_FILE=${jarPath} .")
if (params.DRY_RUN) {
echo "DRY_RUN - do not push image to registry"
}
else {
customImage.push("${releaseVer}")
customImage.push("latest")
}
}
}
}
}
}
}

20
build/settings.xml Normal file
View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<settings xmlns="http://maven.apache.org/SETTINGS/1.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.0.0 http://maven.apache.org/xsd/settings-1.0.0.xsd">
<servers>
<server>
<id>77zzcx7-releases</id>
<username>${env.REPO_USER}</username>
<password>${env.REPO_TOKEN}</password>
</server>
</servers>
<mirrors>
<mirror>
<id>central</id>
<mirrorOf>*</mirrorOf>
<name>77zzcx7-central</name>
<url>${env.REPO_URL}/releases</url>
</mirror>
</mirrors>
</settings>

View File

@@ -5,7 +5,7 @@
<parent>
<artifactId>nbs-cloud-aggregator</artifactId>
<groupId>de.77zzcx7.nbs-cloud</groupId>
<version>6</version>
<version>30-SNAPSHOT</version>
</parent>
<groupId>de.77zzcx7.nbs-cloud</groupId>
@@ -31,6 +31,10 @@
<artifactId>spring-boot-starter-web</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>de.77zzcx7.nbs-cloud</groupId>
<artifactId>files-api</artifactId>
</dependency>
</dependencies>
</project>

View File

@@ -1,19 +1,26 @@
package de.nbscloud.dashboard;
import de.nbscloud.dashboard.widget.MachineMetricsWidget;
import de.nbscloud.webcontainer.registry.App;
import de.nbscloud.webcontainer.registry.AppRegistry;
import de.nbscloud.webcontainer.registry.Widget;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.util.Collection;
import java.util.List;
@Component
public class DashboardApp implements App, InitializingBean {
public static final String ID = "dashboard";
@Autowired
private AppRegistry appRegistry;
@Override
public String getId() {
return "dashboard";
return ID;
}
@Override
@@ -31,6 +38,11 @@ public class DashboardApp implements App, InitializingBean {
return 0;
}
@Override
public Collection<Widget> getWidgets() {
return List.of(new MachineMetricsWidget());
}
@Override
public void afterPropertiesSet() throws Exception {
this.appRegistry.registerApp(this);

View File

@@ -0,0 +1,49 @@
package de.nbscloud.dashboard.config;
import de.nbscloud.dashboard.widget.metrics.updates.scraper.UpdateScrapers;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.PropertySource;
import java.util.List;
@Configuration
@ConfigurationProperties(prefix = "nbs-cloud.dashboard")
@PropertySource("classpath:/config/dashboard-application.properties")
public class DashboardConfig {
@Configuration
public static class Metrics {
@Configuration
public static class Updates {
private List<UpdateScrapers> scrapers;
public List<UpdateScrapers> getScrapers() {
return scrapers;
}
public void setScrapers(List<UpdateScrapers> scrapers) {
this.scrapers = scrapers;
}
}
private Updates updates;
public Updates getUpdates() {
return updates;
}
public void setUpdates(Updates updates) {
this.updates = updates;
}
}
private Metrics metrics;
public Metrics getMetrics() {
return metrics;
}
public void setMetrics(Metrics metrics) {
this.metrics = metrics;
}
}

View File

@@ -8,6 +8,7 @@ import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import javax.servlet.http.HttpServletRequest;
import java.util.stream.Collectors;
@Controller
public class DashboardController {
@@ -20,6 +21,7 @@ public class DashboardController {
@GetMapping("/dashboard")
public String start(Model model, HttpServletRequest httpServletRequest, String sortOrder) {
model.addAttribute("apps", this.appRegistry.getAll());
model.addAttribute("widgets", this.appRegistry.getAll().stream().flatMap(a -> a.getWidgets().stream()).collect(Collectors.toList()));
this.webContainerSharedConfig.addDefaults(model);
return "dashboard/index";

View File

@@ -0,0 +1,35 @@
package de.nbscloud.dashboard.controller;
import de.nbscloud.dashboard.config.DashboardConfig;
import de.nbscloud.dashboard.widget.service.MetricService;
import de.nbscloud.webcontainer.shared.config.WebContainerSharedConfig;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
@Controller
public class DashboardWidgetController {
private static final Logger logger = LoggerFactory.getLogger(DashboardWidgetController.class);
@Autowired
private WebContainerSharedConfig webContainerSharedConfig;
@Autowired
private MetricService metricService;
@Autowired
private DashboardConfig dashboardConfig;
@GetMapping("dashboard/widgets/machineMetrics")
public String getDiskUsage(HttpServletRequest request, HttpServletResponse response, Model model) {
model.addAttribute("interfaces", metricService.getInterfaces());
model.addAttribute("updates", metricService.getUpdates());
return "dashboard/widgets/machineMetrics :: dashboard-machine-metrics";
}
}

View File

@@ -0,0 +1,11 @@
package de.nbscloud.dashboard.widget;
import de.nbscloud.dashboard.DashboardApp;
import de.nbscloud.webcontainer.registry.Widget;
public class MachineMetricsWidget implements Widget {
@Override
public String getPath() {
return DashboardApp.ID + "/widgets/machineMetrics";
}
}

View File

@@ -0,0 +1,9 @@
package de.nbscloud.dashboard.widget.metrics.updates.scraper;
import de.nbscloud.dashboard.widget.service.MetricService;
import java.util.Map;
public interface UpdateScraper {
public MetricService.UpdateContainer scrape();
}

View File

@@ -0,0 +1,25 @@
package de.nbscloud.dashboard.widget.metrics.updates.scraper;
import de.nbscloud.dashboard.widget.metrics.updates.scraper.impl.ArchAuracleScraperImpl;
import de.nbscloud.dashboard.widget.metrics.updates.scraper.impl.ArchCheckupdatesScraperImpl;
import java.lang.reflect.InvocationTargetException;
public enum UpdateScrapers {
ARCH_CHECKUPDATES(ArchCheckupdatesScraperImpl.class),
ARCH_AURACLE(ArchAuracleScraperImpl.class);
private Class<? extends UpdateScraper> scraperClass;
UpdateScrapers(Class<? extends UpdateScraper> scraperClass) {
this.scraperClass = scraperClass;
}
public UpdateScraper getUpdateScraper() {
try {
return this.scraperClass.getDeclaredConstructor().newInstance();
} catch (ReflectiveOperationException e) {
throw new RuntimeException(e);
}
}
}

View File

@@ -0,0 +1,36 @@
package de.nbscloud.dashboard.widget.metrics.updates.scraper.impl;
import de.nbscloud.dashboard.widget.metrics.updates.scraper.UpdateScraper;
import de.nbscloud.dashboard.widget.service.MetricService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
public class ArchAuracleScraperImpl implements UpdateScraper {
private static final Logger logger = LoggerFactory.getLogger(ArchAuracleScraperImpl.class);
private static final String KEY = "AUR";
@Override
public MetricService.UpdateContainer scrape() {
try {
final Process process = Runtime.getRuntime()
.exec(new String[]{"sh", "-c", "auracle outdated | wc -l"});
final String updateCount = new BufferedReader(new InputStreamReader(process.getInputStream())).readLine();
process.destroy();
if(updateCount != null && !updateCount.isBlank()) {
return new MetricService.UpdateContainer(KEY, updateCount);
}
} catch (IOException e) {
logger.error("Could not get AUR update count", e);
return new MetricService.UpdateContainer(KEY, "error");
}
return new MetricService.UpdateContainer(KEY, "0");
}
}

View File

@@ -0,0 +1,36 @@
package de.nbscloud.dashboard.widget.metrics.updates.scraper.impl;
import de.nbscloud.dashboard.widget.metrics.updates.scraper.UpdateScraper;
import de.nbscloud.dashboard.widget.service.MetricService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
public class ArchCheckupdatesScraperImpl implements UpdateScraper {
private static final Logger logger = LoggerFactory.getLogger(ArchCheckupdatesScraperImpl.class);
private static final String KEY = "pacman";
@Override
public MetricService.UpdateContainer scrape() {
try {
final Process process = Runtime.getRuntime()
.exec(new String[]{"sh", "-c", "checkupdates | wc -l"});
final String updateCount = new BufferedReader(new InputStreamReader(process.getInputStream())).readLine();
process.destroy();
if(updateCount != null && !updateCount.isBlank()) {
return new MetricService.UpdateContainer(KEY, updateCount);
}
} catch (IOException e) {
logger.error("Could not get pacman update count", e);
return new MetricService.UpdateContainer(KEY, "error");
}
return new MetricService.UpdateContainer(KEY, "0");
}
}

View File

@@ -0,0 +1,78 @@
package de.nbscloud.dashboard.widget.service;
import de.nbscloud.dashboard.config.DashboardConfig;
import de.nbscloud.dashboard.widget.metrics.updates.scraper.UpdateScraper;
import de.nbscloud.dashboard.widget.metrics.updates.scraper.UpdateScrapers;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.net.InterfaceAddress;
import java.net.NetworkInterface;
import java.net.SocketException;
import java.util.*;
import java.util.stream.Collectors;
@Service
public class MetricService {
private static final Logger logger = LoggerFactory.getLogger(MetricService.class);
public static record InterfaceContainer(String name, String ip) {
}
public static record UpdateContainer(String key, String count) {
}
@Autowired
private DashboardConfig dashboardConfig;
public List<InterfaceContainer> getInterfaces() {
try {
final Iterator<NetworkInterface> networkInterfaceIterator = NetworkInterface.getNetworkInterfaces()
.asIterator();
final List<InterfaceContainer> retList = new ArrayList<>();
while (networkInterfaceIterator.hasNext()) {
final NetworkInterface networkInterface = networkInterfaceIterator.next();
if (networkInterface.isLoopback() || !networkInterface.isUp()) {
continue;
}
for (InterfaceAddress interfaceAddress : networkInterface.getInterfaceAddresses()) {
retList.add(new InterfaceContainer(networkInterface.getDisplayName(), removeInterfaceName(removeSlash(interfaceAddress.getAddress()
.toString()))));
}
}
retList.sort(Comparator.comparing(InterfaceContainer::name));
return retList;
} catch (SocketException e) {
throw new RuntimeException(e);
}
}
private static final String removeSlash(String address) {
return address.startsWith("/") ? address.substring(1) : address;
}
private static final String removeInterfaceName(String address) {
// IPv6 addresses have the interface name in them like
// abc:...:xyz%myInterface
return address.contains("%") ? address.substring(0, address.indexOf("%")) : address;
}
public List<UpdateContainer> getUpdates() {
return this.dashboardConfig.getMetrics()
.getUpdates()
.getScrapers()
.stream()
.map(UpdateScrapers::getUpdateScraper)
.map(UpdateScraper::scrape)
.sorted(Comparator.comparing(UpdateContainer::key))
.collect(Collectors.toList());
}
}

View File

@@ -0,0 +1,7 @@
spring.profiles.active=@activeProfiles@
info.app.name=NoBullShit Cloud - Dashboard app
info.app.description=A simple dashboard app with widget support
nbs-cloud.dashboard.metrics.updates.scrapers[0]=ARCH_CHECKUPDATES
nbs-cloud.dashboard.metrics.updates.scrapers[1]=ARCH_AURACLE

View File

@@ -0,0 +1,7 @@
nbscloud.dashboard.greeting=Welcome to nbscloud
nbscloud.dashboard.index.title=nbscloud\: dashboard
nbscloud.dashboard.dashboard-machine-metrics-widget.heading=dashboard machine metrics
nbscloud.dashboard.dashboard-machine-metrics-widget-table.interfaces=Interfaces\:
nbscloud.dashboard.dashboard-machine-metrics-widget-table.updates=Updates\:

View File

@@ -0,0 +1,7 @@
nbscloud.dashboard.greeting=Willkommen bei nbscloud
nbscloud.dashboard.index.title=nbscloud\: \u00DCbersicht
nbscloud.dashboard.dashboard-machine-metrics-widget.heading=dashboard Metriken
nbscloud.dashboard.dashboard-machine-metrics-widget-table.interfaces=Schnittstellen\:
nbscloud.dashboard.dashboard-machine-metrics-widget-table.updates=Updates\:

View File

@@ -1,3 +1,36 @@
#content-container {
padding-top: 2em;
#widgets-container {
padding: 2em;
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: space-between;
align-items: flex-start;
align-content: space-between;
row-gap: 2em;
column-gap: 2em;
}
.widget {
flex-grow: 1;
background-color:var(--background-color-highlight);
padding: 1em;
align-self: stretch;
}
.widget-heading {
}
.greeting {
display: block;
font-size: 1.5em;
margin-top: 0.83em;
margin-bottom: 0.83em;
margin-left: 0;
margin-right: 0;
font-weight: bold;
}
.widget > table > tbody > tr > td {
padding-inline-end: 1em;
}

View File

@@ -14,7 +14,14 @@
<div id="main-container">
<div th:replace="includes/header :: header"/>
<div id="content-container">
Welcome to nbscloud
<div id="greetings-container">
<p class="greeting" th:text="#{nbscloud.dashboard.greeting}" />
</div>
<div id="widgets-container">
<th:block th:each="widget : ${widgets}">
<th:block th:utext="${#servletContext.getRequestDispatcher('/' + widget.path).include(#request,#response)}"/>
</th:block>
</div>
</div>
<div th:replace="includes/footer :: footer"/>
</div>

View File

@@ -0,0 +1,19 @@
<div id="dashboard-machine-metrics-widget" class="widget" th:fragment="dashboard-machine-metrics">
<p class="widget-heading" th:text="#{nbscloud.dashboard.dashboard-machine-metrics-widget.heading}"/>
<table id="dashboard-machine-metrics-widget-table">
<tr th:each="interface, i : ${interfaces}">
<td th:if="${i.count == 1}"
th:text="#{nbscloud.dashboard.dashboard-machine-metrics-widget-table.interfaces}" />
<td th:if="${i.count > 1}"></td>
<td th:text="${interface.name}" />
<td th:text="${interface.ip}" />
</tr>
<tr th:each="update, i : ${updates}">
<td th:if="${i.count == 1}"
th:text="#{nbscloud.dashboard.dashboard-machine-metrics-widget-table.updates}" />
<td th:if="${i.count > 1}"></td>
<td th:text="${update.key}" />
<td th:text="${update.count}" />
</tr>
</table>
</div>

27
files-api/pom.xml Normal file
View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>de.77zzcx7.nbs-cloud</groupId>
<artifactId>nbs-cloud-aggregator</artifactId>
<version>30-SNAPSHOT</version>
</parent>
<groupId>de.77zzcx7.nbs-cloud</groupId>
<artifactId>files-api</artifactId>
<packaging>jar</packaging>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>de.77zzcx7.nbs-cloud</groupId>
<artifactId>web-container-registry</artifactId>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,92 @@
package de.nbscloud.files.api;
import de.nbscloud.webcontainer.registry.App;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
public interface FilesService {
record ContentContainer(boolean directory, Path path, String name, long size,
LocalDateTime lastModified) {
}
class ContentTree {
private String name;
private boolean directory;
private String path;
private List<ContentTree> subTree = new ArrayList<>();
private ContentTree parent;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public boolean isDirectory() {
return directory;
}
public void setDirectory(boolean directory) {
this.directory = directory;
}
public String getPath() {
return path;
}
public void setPath(String path) {
this.path = path;
}
public List<ContentTree> getSubTree() {
return subTree;
}
public void setSubTree(List<ContentTree> subTree) {
this.subTree = subTree;
}
public boolean getHasSubTree() {
return !this.subTree.isEmpty();
}
public ContentTree getParent() {
return parent;
}
public void setParent(ContentTree parent) {
this.parent = parent;
}
}
void createAppDirectoryIfNotExists(App app);
void createDirectory(App app, Path path);
void createFile(App app, Path path, byte[] content);
void overwriteFile(App app, Path path, byte[] content);
void delete(App app, Path path);
byte[] get(App app, Path path);
/**
* Paths in return list are always relative to the appDir. Non-recursive list.
*
* @param app to list the files for
* @param path in case of an empty optional the appDir is used as start dir. If not empty, path has to be relative
* to the appDir
*/
List<ContentContainer> list(App app, Optional<Path> path);
ContentTree getTree(App app, Optional<Path> path);
List<String> collectDirs(App app, Optional<Path> path);
}

View File

@@ -5,7 +5,7 @@
<parent>
<groupId>de.77zzcx7.nbs-cloud</groupId>
<artifactId>nbs-cloud-aggregator</artifactId>
<version>6</version>
<version>30-SNAPSHOT</version>
</parent>
<groupId>de.77zzcx7.nbs-cloud</groupId>
@@ -21,6 +21,10 @@
<groupId>de.77zzcx7.nbs-cloud</groupId>
<artifactId>web-container-config</artifactId>
</dependency>
<dependency>
<groupId>de.77zzcx7.nbs-cloud</groupId>
<artifactId>files-api</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
@@ -44,6 +48,11 @@
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
</dependency>
<!-- <dependency>-->
<!-- <groupId>org.flywaydb</groupId>-->
<!-- <artifactId>flyway-core</artifactId>-->
@@ -68,6 +77,11 @@
<!-- <artifactId>spring-security-test</artifactId>-->
<!-- <scope>test</scope>-->
<!-- </dependency>-->
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,56 @@
package de.nbscloud.files;
import de.nbscloud.files.config.FilesConfig;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.context.annotation.SessionScope;
import java.nio.file.Path;
@Component
@SessionScope
public class AppLocationTracker implements InitializingBean {
private static final Logger logger = LoggerFactory.getLogger(AppLocationTracker.class);
@Autowired
private FilesConfig filesConfig;
@Autowired
private LocationTracker locationTracker;
private Path baseDirPath;
public Path resolve(String appId, Path path) {
validate(Path.of(appId));
validate(path);
final Path appPath = this.baseDirPath.resolve(appId);
return appPath.resolve(path);
}
private void validate(Path path) {
if(path == null) {
throw new IllegalStateException("Null");
}
if(path.toString().contains("..")) {
throw new IllegalStateException("Relative path");
}
if (path.isAbsolute()) {
throw new IllegalStateException("Absolute path: " + path);
}
if(!this.baseDirPath.resolve(path).normalize().startsWith(this.baseDirPath)) {
throw new IllegalStateException("Illegal path: " + path);
}
}
@Override
public void afterPropertiesSet() throws Exception {
this.baseDirPath = this.locationTracker.getAppDir();
}
}

View File

@@ -1,7 +1,8 @@
package de.nbscloud.files;
import de.nbscloud.files.api.FilesService.ContentContainer;
import de.nbscloud.files.api.FilesService.ContentTree;
import de.nbscloud.files.config.FilesConfig;
import de.nbscloud.files.controller.FilesController;
import de.nbscloud.files.exception.FileSystemServiceException;
import org.apache.commons.io.FileUtils;
import org.apache.commons.lang3.StringUtils;
@@ -11,10 +12,9 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.util.StreamUtils;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.*;
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;
import java.time.LocalDateTime;
@@ -22,17 +22,17 @@ import java.time.ZoneId;
import java.util.*;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
@Service
public class FileSystemService {
private static final Logger logger = LoggerFactory.getLogger(FileSystemService.class);
public static record ContentContainer(boolean directory, Path path, String name, long size,
LocalDateTime lastModified) {
}
public enum SortOrder {
/**
* First by type (directories first), then by name ignoring case
@@ -88,23 +88,31 @@ public class FileSystemService {
private MimeTypeDetector mimeTypeDetector = new MimeTypeDetector();
public Path createDirectory(String name) {
try {
return Files.createDirectories(this.locationTracker.getCurrentLocation().resolve(name));
return Files.createDirectories(this.locationTracker.resolve(name));
} catch (IOException e) {
throw new FileSystemServiceException("Could not create directory", e);
}
}
void createDirectory(Path path) {
this.locationTracker.ensureValidPath(path);
try {
Files.createDirectories(path);
} catch (IOException e) {
throw new FileSystemServiceException("Could not create directory", e);
}
}
public void createFile(String name, byte[] content) {
try {
Files.write(this.locationTracker.getCurrentLocation()
.resolve(name), content, StandardOpenOption.WRITE, StandardOpenOption.CREATE_NEW);
} catch (IOException e) {
throw new FileSystemServiceException("Could not create file", e);
}
createFile(this.locationTracker.resolve(name), content);
}
public void createFile(Path path, byte[] content) {
this.locationTracker.ensureValidPath(path);
try {
Files.write(path, content, StandardOpenOption.WRITE, StandardOpenOption.CREATE_NEW);
} catch (IOException e) {
@@ -112,16 +120,20 @@ public class FileSystemService {
}
}
public boolean delete(String name) {
public void overwriteFile(Path path, byte[] content) {
this.locationTracker.ensureValidPath(path);
try {
// TODO does only delete dirs if they are empty - but maybe we want that?
return Files.deleteIfExists(this.locationTracker.getCurrentLocation().resolve(name));
Files.write(path, content, StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING);
} catch (IOException e) {
throw new FileSystemServiceException("Could not delete file", e);
throw new FileSystemServiceException("Could not overwrite file", e);
}
}
public Path move(Path originalPath, Path newPath) {
this.locationTracker.ensureValidPath(originalPath);
this.locationTracker.ensureValidPath(newPath);
try {
return Files.move(originalPath, newPath, StandardCopyOption.ATOMIC_MOVE);
} catch (IOException e) {
@@ -129,9 +141,26 @@ public class FileSystemService {
}
}
public byte[] get(String name) {
public boolean delete(String name) {
return delete(this.locationTracker.resolve(name));
}
public boolean delete(Path path) {
this.locationTracker.ensureValidPath(path);
try {
return Files.readAllBytes(this.locationTracker.getCurrentLocation().resolve(name));
// TODO does only delete dirs if they are empty - but maybe we want that?
return Files.deleteIfExists(path);
} catch (IOException e) {
throw new FileSystemServiceException("Could not delete file", e);
}
}
public byte[] get(Path path) {
this.locationTracker.ensureValidPath(path);
try {
return Files.readAllBytes(path);
} catch (IOException e) {
throw new FileSystemServiceException("Could not get file", e);
}
@@ -139,34 +168,109 @@ public class FileSystemService {
public void stream(String name, OutputStream outputStream) {
try {
Files.copy(this.locationTracker.getCurrentLocation().resolve(name), outputStream);
Files.copy(this.locationTracker.resolve(name), outputStream);
} catch (IOException e) {
throw new FileSystemServiceException("Could not get file", e);
throw new FileSystemServiceException("Could not stream file", e);
}
}
public void stream(Path targetPath, ZipOutputStream outputStream) {
this.locationTracker.ensureValidPath(targetPath);
try {
Files.walkFileTree(targetPath, new SimpleFileVisitor<Path>() {
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
if (Files.isDirectory(file)) {
return FileVisitResult.CONTINUE;
}
final ZipEntry e = new ZipEntry(targetPath.relativize(file).toString());
e.setSize(getSize(file));
e.setTime(System.currentTimeMillis());
outputStream.putNextEntry(e);
StreamUtils.copy(stream(file), outputStream);
outputStream.closeEntry();
return FileVisitResult.CONTINUE;
}
});
} catch (IOException e) {
throw new FileSystemServiceException("Could not stream file", e);
}
}
public InputStream stream(String name) {
return stream(this.locationTracker.resolve(name));
}
public InputStream stream(Path name) {
this.locationTracker.ensureValidPath(name);
try {
return Files.newInputStream(this.locationTracker.getCurrentLocation().resolve(name));
return Files.newInputStream(name);
} catch (IOException e) {
throw new FileSystemServiceException("Could not get file", e);
throw new FileSystemServiceException("Could not stream file", e);
}
}
public long getSize(String name) {
return getSize(this.locationTracker.resolve(name));
}
public long getSize(Path name) {
this.locationTracker.ensureValidPath(name);
try {
return Files.size(this.locationTracker.getCurrentLocation().resolve(name));
return Files.size(name);
} catch (IOException e) {
throw new FileSystemServiceException("Could not get file size", e);
}
}
public String getDevice() {
try {
final Process process = Runtime.getRuntime()
.exec(new String[]{"findmnt", "-n", "-o", "SOURCE", "--target", this.locationTracker.getBaseDirPath().toString()});
final String device = new BufferedReader(new InputStreamReader(process.getInputStream())).readLine();
process.destroy();
return device;
} catch (IOException e) {
throw new FileSystemServiceException("Could not get file", e);
}
}
public String getMimeType(String name) {
public long getDiskSize(String device) {
try {
final String detectedMimeType = this.mimeTypeDetector.detectMimeType(this.locationTracker.getCurrentLocation()
.resolve(name));
final Process process = Runtime.getRuntime()
.exec(new String[]{"lsblk", "-b", "-o", "SIZE", "-n", device});
final String size = new BufferedReader(new InputStreamReader(process.getInputStream())).readLine();
logger.debug("Detected mime type {} for file {}", detectedMimeType, name);
process.destroy();
return Long.parseLong(size);
} catch (IOException e) {
throw new FileSystemServiceException("Could not get disk size", e);
}
}
public String getMimeType(String name) {
return getMimeType(this.locationTracker.resolve(name));
}
public String getMimeType(Path path) {
this.locationTracker.ensureValidPath(path);
try {
final String detectedMimeType = this.mimeTypeDetector.detectMimeType(path);
logger.debug("Detected mime type {} for file {}", detectedMimeType, path);
return detectedMimeType;
} catch (GetBytesException e) {
@@ -199,9 +303,11 @@ public class FileSystemService {
}
}
public List<ContentContainer> list(SortOrder sortOrder) {
List<ContentContainer> list(Path startPath, SortOrder sortOrder, Function<Path, Path> relativizer) {
this.locationTracker.ensureValidPath(startPath);
try {
List<ContentContainer> contentList = Files.list(this.locationTracker.getCurrentLocation())
List<ContentContainer> contentList = Files.list(startPath)
.filter(path -> {
boolean ok = Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS) || Files.isDirectory(path);
@@ -216,7 +322,7 @@ public class FileSystemService {
final boolean isDir = Files.isDirectory(path);
return new ContentContainer(isDir,
this.locationTracker.getRelativeToBaseDir(path),
relativizer.apply(path),
path.getFileName().toString(),
isDir ? FileUtils.sizeOfDirectory(path.toFile()) : Files.size(path),
LocalDateTime.ofInstant(Files.getLastModifiedTime(path)
@@ -234,18 +340,97 @@ public class FileSystemService {
}
}
ContentTree getTree(Path startPath, SortOrder sortOrder, Function<Path, Path> relativizer) {
this.locationTracker.ensureValidPath(startPath);
try {
if (!Files.isDirectory(startPath)) {
return null;
}
final ContentTree root = new ContentTree();
root.setName(startPath.getFileName().toString());
root.setPath(relativizer.apply(startPath).toString());
root.setDirectory(true);
final AtomicReference<ContentTree> current = new AtomicReference<>(root);
Files.walkFileTree(startPath, new SimpleFileVisitor<Path>() {
public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException {
if (startPath.equals(dir)) {
return FileVisitResult.CONTINUE;
}
final ContentTree dirTree = new ContentTree();
dirTree.setName(dir.getFileName().toString());
dirTree.setPath(relativizer.apply(dir).toString());
dirTree.setDirectory(true);
dirTree.setParent(current.get());
current.get().getSubTree().add(dirTree);
current.set(dirTree);
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
final ContentTree fileTree = new ContentTree();
fileTree.setName(file.getFileName().toString());
fileTree.setPath(relativizer.apply(file).toString());
fileTree.setDirectory(false);
fileTree.setParent(current.get());
current.get().getSubTree().add(fileTree);
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException {
current.set(current.get().getParent());
return FileVisitResult.CONTINUE;
}
});
return root;
} catch (IOException e) {
throw new FileSystemServiceException("Could not list files", e);
}
}
public boolean isDirectory(Path path) {
this.locationTracker.ensureValidPath(path);
return Files.isDirectory(path);
}
public List<ContentContainer> list(SortOrder sortOrder) {
return list(this.locationTracker.getCurrentLocation(), sortOrder, path -> this.locationTracker.getRelativeToBaseDir(path));
}
public List<String> collectDirs(String sourceFile) {
return collectDirs(this.locationTracker.resolve(sourceFile), this.locationTracker.getBaseDirPath(), path -> this.locationTracker.getRelativeToBaseDir(path), false);
}
List<String> collectDirs(Path sourcePath, Path baseDir, Function<Path, Path> relativizer, boolean includeSource) {
this.locationTracker.ensureValidPath(sourcePath);
this.locationTracker.ensureValidPath(baseDir);
try {
final List<String> resultList = new ArrayList<>();
final Path sourcePath = this.locationTracker.getCurrentLocation().resolve(sourceFile);
final boolean sourceIsDir = Files.isDirectory(sourcePath);
Files.walkFileTree(this.locationTracker.getBaseDirPath(), new SimpleFileVisitor<Path>() {
Files.walkFileTree(baseDir, new SimpleFileVisitor<Path>() {
@Override
public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException {
super.visitFile(dir, attrs);
if (sourceIsDir && sourcePath.equals(dir)) {
if (!includeSource && sourceIsDir && sourcePath.equals(dir)) {
return FileVisitResult.SKIP_SUBTREE;
}
@@ -261,7 +446,7 @@ public class FileSystemService {
return FileVisitResult.SKIP_SUBTREE;
}
final String relPath = locationTracker.getRelativeToBaseDir(dir).toString();
final String relPath = relativizer.apply(dir).toString();
resultList.add("/" + relPath);

View File

@@ -1,19 +1,27 @@
package de.nbscloud.files;
import de.nbscloud.files.widget.DiskUsageWidget;
import de.nbscloud.webcontainer.registry.App;
import de.nbscloud.webcontainer.registry.AppRegistry;
import de.nbscloud.webcontainer.registry.Widget;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
@Component
public class FilesApp implements App, InitializingBean {
public static final String ID = "files";
@Autowired
private AppRegistry appRegistry;
@Override
public String getId() {
return "files";
return ID;
}
@Override
@@ -31,6 +39,11 @@ public class FilesApp implements App, InitializingBean {
return 10;
}
@Override
public Collection<Widget> getWidgets() {
return List.of(new DiskUsageWidget());
}
@Override
public void afterPropertiesSet() throws Exception {
this.appRegistry.registerApp(this);

View File

@@ -0,0 +1,89 @@
package de.nbscloud.files;
import de.nbscloud.files.api.FilesService;
import de.nbscloud.files.exception.FileSystemServiceException;
import de.nbscloud.webcontainer.registry.App;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.web.context.annotation.SessionScope;
import java.nio.file.Path;
import java.util.List;
import java.util.Optional;
@Service
@SessionScope
public class FilesServiceImpl implements FilesService {
@Autowired
private FileSystemService fileSystemService;
@Autowired
private AppLocationTracker locationTracker;
private Path resolve(App app, Path path) {
if(path.startsWith("/")) {
path = path.subpath(0, path.getNameCount());
}
return this.locationTracker.resolve(app.getId(), path);
}
@Override
public void createAppDirectoryIfNotExists(App app) {
try {
this.fileSystemService.createDirectory(resolve(app, Path.of("")));
}
catch(FileSystemServiceException e) {
// Ignore, may happen if the dir already exists
}
}
@Override
public void createDirectory(App app, Path path) {
this.fileSystemService.createDirectory(resolve(app, path));
}
@Override
public void createFile(App app, Path path, byte[] content) {
this.fileSystemService.createFile(resolve(app, path), content);
}
@Override
public void overwriteFile(App app, Path path, byte[] content) {
this.fileSystemService.overwriteFile(resolve(app, path), content);
}
@Override
public void delete(App app, Path path) {
this.fileSystemService.delete(resolve(app, path));
}
@Override
public byte[] get(App app, Path path) {
return this.fileSystemService.get(resolve(app, path));
}
@Override
public List<ContentContainer> list(App app, Optional<Path> path) {
final Path appPath = resolve(app, Path.of(""));
final Path p = path.map(tmpPath -> resolve(app, tmpPath)).orElse(appPath);
return this.fileSystemService.list(p, FileSystemService.SortOrder.NATURAL, callbackPath -> appPath.relativize(callbackPath));
}
@Override
public ContentTree getTree(App app, Optional<Path> path) {
final Path appPath = resolve(app, Path.of(""));
final Path p = path.map(tmpPath -> resolve(app, tmpPath)).orElse(appPath);
return this.fileSystemService.getTree(p, FileSystemService.SortOrder.NATURAL, callbackPath -> appPath.relativize(callbackPath));
}
@Override
public List<String> collectDirs(App app, Optional<Path> path) {
final Path appPath = resolve(app, Path.of(""));
final Path p = path.map(tmpPath -> resolve(app, tmpPath)).orElse(appPath);
return this.fileSystemService.collectDirs(p, appPath, callbackPath -> appPath.relativize(callbackPath), true);
}
}

View File

@@ -1,17 +1,21 @@
package de.nbscloud.files;
import de.nbscloud.files.config.FilesConfig;
import de.nbscloud.files.controller.FilesController;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.config.ConfigurableBeanFactory;
import org.springframework.context.annotation.Scope;
import org.springframework.stereotype.Component;
import org.springframework.web.context.annotation.SessionScope;
import java.nio.file.Path;
import java.nio.file.Paths;
@Component
public class LocationTracker {
@SessionScope
public class LocationTracker implements InitializingBean {
private static final Logger logger = LoggerFactory.getLogger(LocationTracker.class);
@Autowired
@@ -20,32 +24,55 @@ public class LocationTracker {
private Path baseDirPath;
private Path currentLocation;
public void init() {
// For testing
void init_internal(String baseDir) {
this.baseDirPath = Paths.get(baseDir);
this.currentLocation = this.baseDirPath.resolve("");
}
@Override
public void afterPropertiesSet() throws Exception {
this.baseDirPath = Paths.get(this.filesConfig.getBaseDir());
this.currentLocation = this.baseDirPath.resolve("");
logger.info("Initialized location to {}", this.currentLocation);
logger.info("{}: Initialized location to {}", id(), this.currentLocation);
}
public void reset() {
logger.debug("Reset location");
logger.debug("{}: Reset location", id());
init();
try {
afterPropertiesSet();
} catch (Exception e) {
// Cannot happen
}
}
public Path getCurrentLocation() {
public void resetCurrentLocation() {
this.currentLocation = this.baseDirPath.resolve("");
}
private long id() {
return System.identityHashCode(this);
}
Path getCurrentLocation() {
return this.currentLocation;
}
public Path getRelativeLocation() {
return this.baseDirPath.relativize(this.currentLocation);
public String getRelativeLocation() {
return this.baseDirPath.relativize(this.currentLocation).toString();
}
public Path getRelativeToBaseDir(Path other) {
return this.baseDirPath.relativize(other);
}
public Path getTrashBin() {
public Path getAppDir() {
return this.baseDirPath.resolve(this.filesConfig.getAppDir());
}
private Path getTrashBin() {
return this.baseDirPath.resolve(this.filesConfig.getTrashBinName());
}
@@ -62,12 +89,24 @@ public class LocationTracker {
}
public void setCurrentLocation(String navigateTo) {
validate(navigateTo);
validate_internal(navigateTo);
this.currentLocation = this.baseDirPath.resolve(navigateTo);
final Path newLocation = this.baseDirPath.resolve(navigateTo);
logger.debug("{}: Change location from {} to {}", id(), this.currentLocation, newLocation);
this.currentLocation = newLocation;
}
private void validate(String navigateTo) {
public void setBaseDirPath(String path) {
validate_internal(path);
this.baseDirPath = this.baseDirPath.resolve(path);
logger.debug("{}: Change base dir to {}", id(), baseDirPath);
}
private void validate_internal(String navigateTo) {
if(navigateTo == null) {
throw new IllegalStateException("Null");
}
@@ -80,12 +119,61 @@ public class LocationTracker {
throw new IllegalStateException("Absolute path: " + navigateTo);
}
if(!this.baseDirPath.resolve(navigateTo).startsWith(this.baseDirPath)) {
// Regardless of where we navigate to, we must never leave the base directory
// The normalize() call is important, as it resolves (possible) relative paths
if(!this.currentLocation.resolve(navigateTo).normalize().startsWith(this.baseDirPath)) {
throw new IllegalStateException("Illegal path: " + navigateTo);
}
if(!this.baseDirPath.resolve(navigateTo).normalize().startsWith(this.baseDirPath)) {
throw new IllegalStateException("Illegal path: " + navigateTo);
}
}
public Path getBaseDirPath() {
return baseDirPath;
public void ensureValidPath(Path path) {
// The provided systemd unit file restricts access to the nbscloud/ directory
// but better safe than sorry - user could roll their own service after all
if(path == null) {
throw new IllegalStateException("Null");
}
if(path.toString().contains("..")) {
throw new IllegalStateException("Relative path");
}
// Regardless of where we navigate to, we must never leave the base directory
// The normalize() call is important, as it resolves (possible) relative paths
if(!path.normalize().startsWith(this.baseDirPath)) {
throw new IllegalStateException("Illegal path: " + path);
}
}
public Path resolve(String name) {
validate_internal(name);
return this.currentLocation.resolve(name).normalize();
}
public Path resolveToBaseDir(String name) {
validate_internal(name);
return this.baseDirPath.resolve(name).normalize();
}
public Path resolveShare(String shareUuid) {
validate_internal(shareUuid);
return this.baseDirPath.resolve(this.filesConfig.getSharesName()).resolve(shareUuid);
}
public Path resolveTrash(String name) {
validate_internal(name);
return this.baseDirPath.resolve(this.filesConfig.getTrashBinName()).resolve(name);
}
Path getBaseDirPath() {
return this.baseDirPath;
}
}

View File

@@ -0,0 +1,18 @@
package de.nbscloud.files;
import org.springframework.stereotype.Component;
import org.springframework.web.context.annotation.SessionScope;
@Component
@SessionScope
public class SessionInfo {
private boolean restrictedSession = false;
public boolean isRestrictedSession() {
return restrictedSession;
}
public void setRestrictedSession(boolean restrictedSession) {
this.restrictedSession = restrictedSession;
}
}

View File

@@ -0,0 +1,51 @@
package de.nbscloud.files;
import java.time.LocalDate;
public class Share {
private String uuid;
private boolean oneTime;
private LocalDate expiryDate;
private String path;
private String password; // clear text, as protecting it provides no additional security
public String getUuid() {
return uuid;
}
public void setUuid(String uuid) {
this.uuid = uuid;
}
public boolean isOneTime() {
return oneTime;
}
public void setOneTime(boolean oneTime) {
this.oneTime = oneTime;
}
public LocalDate getExpiryDate() {
return expiryDate;
}
public void setExpiryDate(LocalDate expiryDate) {
this.expiryDate = expiryDate;
}
public String getPath() {
return path;
}
public void setPath(String path) {
this.path = path;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
}

View File

@@ -14,6 +14,7 @@ public class FilesConfig {
private String trashBinName;
private int truncateFileNameChars;
private String sharesName;
private String appDir;
public String getBaseDir() {
return baseDir;
@@ -62,4 +63,12 @@ public class FilesConfig {
public void setSharesName(String sharesName) {
this.sharesName = sharesName;
}
public String getAppDir() {
return appDir;
}
public void setAppDir(String appDir) {
this.appDir = appDir;
}
}

View File

@@ -1,19 +1,30 @@
package de.nbscloud.files.controller;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import de.nbscloud.files.FileSystemService;
import de.nbscloud.files.LocationTracker;
import de.nbscloud.files.SessionInfo;
import de.nbscloud.files.Share;
import de.nbscloud.files.api.FilesService.ContentContainer;
import de.nbscloud.files.config.FilesConfig;
import de.nbscloud.files.exception.FileSystemServiceException;
import de.nbscloud.files.form.PasswordForm;
import de.nbscloud.files.form.RenameForm;
import de.nbscloud.files.form.ShareForm;
import de.nbscloud.webcontainer.MessageHelper;
import de.nbscloud.webcontainer.registry.AppRegistry;
import de.nbscloud.webcontainer.shared.config.WebContainerSharedConfig;
import de.nbscloud.webcontainer.shared.util.ControllerUtils;
import org.apache.commons.io.input.ObservableInputStream;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.InputStreamResource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
@@ -27,14 +38,16 @@ import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.FileAlreadyExistsException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import java.util.zip.ZipOutputStream;
@Controller
public class FilesController implements InitializingBean {
@@ -51,12 +64,12 @@ public class FilesController implements InitializingBean {
private FileSystemService fileSystemService;
@Autowired
private AppRegistry appRegistry;
// We have to temporarily store messages as we redirect: in the manipulation methods
// so everything we add to the model will be gone
private final List<String> errors = new ArrayList<>();
private final List<String> shareInfo = new ArrayList<>();
private final List<String> infoMessages = new ArrayList<>();
@Autowired
private ObjectMapper mapper;
@Autowired
private MessageHelper messageHelper;
@Autowired
private SessionInfo sessionInfo;
@GetMapping("/files/browse/**")
public String start(Model model, HttpServletRequest httpServletRequest, String sortOrder) {
@@ -64,13 +77,13 @@ public class FilesController implements InitializingBean {
updateLocation(httpServletRequest);
model.addAttribute("errors", getAndClear(this.errors));
model.addAttribute("infoMessages", getAndClear(this.infoMessages));
model.addAttribute("shareInfo", getAndClear(this.shareInfo));
this.messageHelper.addAndClearAll(model);
model.addAttribute("currentLocation", getCurrentLocationPrefixed());
model.addAttribute("content", getContent(order));
model.addAttribute("sortOrder", order);
model.addAttribute("apps", this.appRegistry.getAll());
model.addAttribute("restricted", this.sessionInfo.isRestrictedSession());
model.addAttribute("prefix", "");
this.webContainerSharedConfig.addDefaults(model);
return "files/filesIndex";
@@ -82,20 +95,20 @@ public class FilesController implements InitializingBean {
try {
// Soft delete
this.fileSystemService.move(
this.locationTracker.getCurrentLocation().resolve(Path.of(filename)),
this.locationTracker.getTrashBin().resolve(filename));
this.locationTracker.resolve(filename),
this.locationTracker.resolveTrash(filename));
this.infoMessages.add("nbscloud.files.delete.success");
this.messageHelper.addInfo("nbscloud.files.delete.success");
} catch (RuntimeException e) {
logger.error("Could not soft delete file", e);
this.errors.add(e.getMessage());
this.messageHelper.addError(e.getMessage());
}
} else {
// Hard delete
this.fileSystemService.delete(filename);
this.infoMessages.add("nbscloud.files.delete.success");
this.messageHelper.addInfo("nbscloud.files.delete.success");
}
return "redirect:/files/browse/" + this.locationTracker.getRelativeLocation();
@@ -107,6 +120,9 @@ public class FilesController implements InitializingBean {
model.addAttribute("form", new RenameForm(filename, getCurrentLocation(), filename));
model.addAttribute("targetDirs", this.fileSystemService.collectDirs(filename));
model.addAttribute("apps", this.appRegistry.getAll());
model.addAttribute("filename", filename);
model.addAttribute("restricted", this.sessionInfo.isRestrictedSession());
model.addAttribute("prefix", "");
this.webContainerSharedConfig.addDefaults(model);
return "files/rename";
@@ -114,40 +130,67 @@ public class FilesController implements InitializingBean {
@PostMapping("/files/doRename")
public String doRename(RenameForm form) {
final Path sourcePath = this.locationTracker.getCurrentLocation().resolve(Path.of(form.getOriginalFilename()));
final Path sourcePath = this.locationTracker.resolve(form.getOriginalFilename());
// We navigate to the target dir, so we display the content of that dir after the move
this.locationTracker.setCurrentLocation(form.getTargetDir().substring(1));
final Path targetPath = this.locationTracker.getCurrentLocation().resolve(Path.of(form.getFilename()));
final Path targetPath = this.locationTracker.resolve(form.getFilename());
try {
this.fileSystemService.move(sourcePath, targetPath);
this.infoMessages.add("nbscloud.files.rename.success");
this.messageHelper.addInfo("nbscloud.files.rename.success");
} catch (RuntimeException e) {
logger.error("Could not rename file", e);
this.errors.add(e.getMessage());
this.messageHelper.addError(e.getMessage());
}
return "redirect:/files/browse/" + this.locationTracker.getRelativeLocation();
}
@GetMapping("/files/shares/files/download")
public ResponseEntity sharesDownloadFile(HttpServletResponse response, String filename) {
return downloadFile(response, filename);
}
@GetMapping("/files/download")
public ResponseEntity<Resource> downloadFile(String filename) {
// TODO download of directories via .zip? Also needs to be streaming
public ResponseEntity downloadFile(HttpServletResponse response, String filename) {
final Path targetPath = this.locationTracker.resolve(filename);
try {
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"")
.header(HttpHeaders.CONTENT_TYPE, this.fileSystemService.getMimeType(filename))
.header(HttpHeaders.CONTENT_LENGTH, String.valueOf(this.fileSystemService.getSize(filename)))
.body(new InputStreamResource(this.fileSystemService.stream(filename)));
} catch (RuntimeException e) {
if (Files.isDirectory(targetPath)) {
downloadDirectory(targetPath, filename, response);
return ResponseEntity.ok(":)");
} else {
return downloadSingleFile(filename);
}
} catch (RuntimeException | IOException e) {
logger.error("Could not get file", e);
return ResponseEntity.internalServerError().body(null);
return ResponseEntity.internalServerError().body(":(");
}
}
private ResponseEntity downloadSingleFile(String filename) {
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=" + filename)
.header(HttpHeaders.CONTENT_TYPE, this.fileSystemService.getMimeType(filename))
.header(HttpHeaders.CONTENT_LENGTH, String.valueOf(this.fileSystemService.getSize(filename)))
.body(new InputStreamResource(this.fileSystemService.stream(filename)));
}
private void downloadDirectory(Path targetPath, String filename, HttpServletResponse response) throws IOException {
response.setContentType(MediaType.APPLICATION_OCTET_STREAM_VALUE);
response.setHeader(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=" + filename + ".zip");
response.setStatus(HttpServletResponse.SC_OK);
try (ZipOutputStream zippedOut = new ZipOutputStream(response.getOutputStream())) {
fileSystemService.stream(targetPath, zippedOut);
zippedOut.finish();
}
}
@@ -157,11 +200,11 @@ public class FilesController implements InitializingBean {
try {
this.fileSystemService.createFile(file.getOriginalFilename(), file.getBytes());
this.infoMessages.add("nbscloud.files.created.file.success");
} catch (IOException e) {
this.messageHelper.addInfo("nbscloud.files.created.file.success");
} catch (IOException | RuntimeException e) {
logger.error("Could not upload file", e);
this.errors.add(e.getMessage());
this.messageHelper.addError(e.getMessage());
}
}
@@ -173,67 +216,212 @@ public class FilesController implements InitializingBean {
try {
this.fileSystemService.createDirectory(dirName);
this.infoMessages.add("nbscloud.files.created.dir.success");
this.messageHelper.addInfo("nbscloud.files.created.dir.success");
} catch (RuntimeException e) {
logger.error("Could not create dir", e);
this.errors.add(e.getMessage());
this.messageHelper.addError(e.getMessage());
}
return "redirect:/files/browse/" + this.locationTracker.getRelativeLocation();
}
@GetMapping("/files/share")
public String share(String filename) {
final Path filePath = this.locationTracker.getRelativeToBaseDir(this.locationTracker.getCurrentLocation()
.resolve(filename));
@GetMapping("files/share")
public String share(Model model, String filename) {
model.addAttribute("currentLocation", getCurrentLocationPrefixed());
model.addAttribute("form", new ShareForm(filename, false, LocalDate.now().plusDays(1), null));
model.addAttribute("apps", this.appRegistry.getAll());
model.addAttribute("filename", filename);
model.addAttribute("restricted", this.sessionInfo.isRestrictedSession());
model.addAttribute("prefix", "");
this.webContainerSharedConfig.addDefaults(model);
return "files/share";
}
@PostMapping("/files/doShare")
public String doShare(@RequestParam("filename") String filename,
@RequestParam(value = "oneTime", defaultValue = "false") boolean oneTime,
@RequestParam(value = "expiryDate", required = false) String expiryDateString,
@RequestParam(value = "password", required = false) String password
) {
final Path filePath = this.locationTracker.getRelativeToBaseDir(this.locationTracker.resolve(filename));
final String shareUuid = UUID.randomUUID().toString();
final Share share = new Share();
if(StringUtils.isEmpty(password)) {
password = null; // if no real password has been provided assume no password
}
share.setUuid(shareUuid);
share.setPath(filePath.toString());
share.setExpiryDate(ControllerUtils.parseDate(expiryDateString));
share.setOneTime(oneTime);
share.setPassword(password);
try {
this.fileSystemService.createFile(this.locationTracker.getBaseDirPath()
.resolve(this.filesConfig.getSharesName())
.resolve(shareUuid), filePath.toString()
.getBytes(StandardCharsets.UTF_8));
final String shareJson = mapper.writeValueAsString(share);
this.shareInfo.add("/files/shares?shareUuid=" + shareUuid);
} catch (RuntimeException e) {
this.fileSystemService.createFile(this.locationTracker.resolveShare(share.getUuid()), shareJson.getBytes(StandardCharsets.UTF_8));
this.messageHelper.addShare("/files/shares?shareUuid=" + share.getUuid());
} catch (RuntimeException | JsonProcessingException e) {
logger.error("Could not share file", e);
this.errors.add(e.getMessage());
this.messageHelper.addError(e.getMessage());
}
return "redirect:/files/browse/" + this.locationTracker.getRelativeLocation();
}
@GetMapping("files/shares")
public ResponseEntity shares(String shareUuid) {
try {
final String sharedFile = new String(this.fileSystemService.get(this.locationTracker.getBaseDirPath()
.resolve(this.filesConfig.getSharesName())
.resolve(shareUuid)
.toString()));
final Path sharedFilePath = this.locationTracker.getBaseDirPath().resolve(sharedFile);
final String filename = sharedFilePath.getFileName().toString();
@GetMapping("/files/shares/files/browse/**")
public String sharesStart(Model model, HttpServletRequest httpServletRequest, String sortOrder) {
if(!this.sessionInfo.isRestrictedSession()) {
throw new IllegalStateException();
}
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"")
.header(HttpHeaders.CONTENT_TYPE, this.fileSystemService.getMimeType(filename))
.header(HttpHeaders.CONTENT_LENGTH, String.valueOf(this.fileSystemService.getSize(filename)))
.body(new InputStreamResource(this.fileSystemService.stream(filename)));
} catch (RuntimeException e) {
final String start = start(model, httpServletRequest, sortOrder);
model.addAttribute("prefix", "/files/shares");
return start;
}
// We need to copy all functions for shares because they should be available without auth and that is
// controlled by the URI via htaccess/htpasswd
@GetMapping("files/shares")
public Object shares(Model model, String shareUuid) {
this.locationTracker.reset();
try {
final String shareJson = new String(this.fileSystemService.get(this.locationTracker.resolveShare(shareUuid)));
final Share share = mapper.readValue(shareJson, Share.class);
final Path sharedFilePath = this.locationTracker.resolveToBaseDir(share.getPath());
final Optional<LocalDate> optExpiryDate = Optional.ofNullable(share.getExpiryDate());
final boolean expired = optExpiryDate.map(expiryDate -> LocalDate.now().isAfter(expiryDate)).orElse(false);
if(share.getPassword() != null) {
model.addAttribute("form", new PasswordForm(shareUuid, null));
this.webContainerSharedConfig.addDefaults(model);
return "files/checkPassword";
}
else {
if (expired) {
fileSystemService.delete(locationTracker.resolveShare(shareUuid));
return ResponseEntity.status(410).body("Share expired!");
}
if(this.fileSystemService.isDirectory(sharedFilePath)) {
this.locationTracker.setBaseDirPath(share.getPath());
this.locationTracker.resetCurrentLocation();
this.sessionInfo.setRestrictedSession(true);
return "redirect:/files/browse/";
}
else {
return doShares(shareUuid);
}
}
} catch (RuntimeException | JsonProcessingException e) {
logger.error("Could not get shared file", e);
return ResponseEntity.internalServerError().body(null);
return ResponseEntity.internalServerError().body(":(");
}
}
@PostMapping("/files/shares/checkPassword")
public Object checkPassword(Model model, @RequestParam(value = "shareUuid", required = true) String shareUuid,
@RequestParam(value = "password", required = true) String password) {
try {
final String shareJson = new String(this.fileSystemService.get(this.locationTracker.resolveShare(shareUuid)));
final Share share = mapper.readValue(shareJson, Share.class);
final Path sharedFilePath = this.locationTracker.resolveToBaseDir(share.getPath());
final Optional<LocalDate> optExpiryDate = Optional.ofNullable(share.getExpiryDate());
final boolean expired = optExpiryDate.map(expiryDate -> LocalDate.now().isAfter(expiryDate)).orElse(false);
if(share.getPassword().equals(password)) {
if (expired) {
fileSystemService.delete(locationTracker.resolveShare(shareUuid));
return ResponseEntity.status(410).body("Share expired!");
}
if(this.fileSystemService.isDirectory(sharedFilePath)) {
this.locationTracker.setBaseDirPath(share.getPath());
this.locationTracker.resetCurrentLocation();
this.sessionInfo.setRestrictedSession(true);
return "redirect:/files/browse/";
}
else {
return doShares(shareUuid);
}
}
else {
this.messageHelper.addResolvableError("nbscloud.files.share.error.passwordWrong");
this.messageHelper.addAndClearAll(model);
model.addAttribute("form", new PasswordForm(shareUuid, null));
this.webContainerSharedConfig.addDefaults(model);
return "files/checkPassword";
}
} catch (RuntimeException | JsonProcessingException e) {
logger.error("Could not get shared file", e);
return ResponseEntity.internalServerError().body(":(");
}
}
private ResponseEntity doShares(String shareUuid) {
try {
final String shareJson = new String(this.fileSystemService.get(this.locationTracker.resolveShare(shareUuid)));
final Share share = mapper.readValue(shareJson, Share.class);
final Path sharedFilePath = this.locationTracker.resolveToBaseDir(share.getPath());
final String filename = sharedFilePath.getFileName().toString();
if(this.fileSystemService.isDirectory(sharedFilePath)) {
throw new IllegalStateException("Attempt to access folder via file share: " + sharedFilePath);
}
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=" + filename)
.header(HttpHeaders.CONTENT_TYPE, this.fileSystemService.getMimeType(sharedFilePath))
.header(HttpHeaders.CONTENT_LENGTH, String.valueOf(this.fileSystemService.getSize(sharedFilePath)))
.body(new InputStreamResource(new ObservableInputStream(this.fileSystemService.stream(sharedFilePath), new ObservableInputStream.Observer() {
@Override
public void closed() throws IOException {
if (share.isOneTime()) {
fileSystemService.delete(locationTracker.resolveShare(shareUuid));
}
}
})));
} catch (RuntimeException | JsonProcessingException e) {
logger.error("Could not get shared file", e);
return ResponseEntity.internalServerError().body(":(");
}
}
@GetMapping("/files/shares/files/preview")
public void sharesPreview(HttpServletResponse response, String filename) {
if(!this.sessionInfo.isRestrictedSession()) {
throw new IllegalStateException();
}
preview(response, filename);
}
@GetMapping("files/preview")
public void preview(HttpServletResponse response, String filename) {
try {
response.setContentType(this.fileSystemService.getMimeType(filename));
response.setHeader(HttpHeaders.CONTENT_LENGTH, String.valueOf(this.fileSystemService.getSize(filename)));
response.setHeader(HttpHeaders.CONTENT_DISPOSITION, "filename=\"" + filename + "\"");
response.setHeader(HttpHeaders.CONTENT_DISPOSITION, "filename=" + filename);
this.fileSystemService.stream(filename, response.getOutputStream());
} catch (FileSystemServiceException | IOException e) {
@@ -241,6 +429,19 @@ public class FilesController implements InitializingBean {
}
}
@GetMapping("/files/shares/files/gallery")
public String sharesGallery(Model model) {
if(!this.sessionInfo.isRestrictedSession()) {
throw new IllegalStateException();
}
final String gallery = gallery(model);
model.addAttribute("prefix", "/files/shares");
return gallery;
}
@GetMapping("files/gallery")
public String gallery(Model model) {
try {
@@ -248,32 +449,26 @@ public class FilesController implements InitializingBean {
model.addAttribute("files", files);
model.addAttribute("apps", this.appRegistry.getAll());
model.addAttribute("restricted", this.sessionInfo.isRestrictedSession());
model.addAttribute("prefix", "");
this.webContainerSharedConfig.addDefaults(model);
return "files/gallery";
} catch (FileSystemServiceException e) {
logger.error("Could not generate gallery", e);
this.errors.add(e.getMessage());
this.messageHelper.addError(e.getMessage());
return "redirect:/files/browse/" + this.locationTracker.getRelativeLocation();
}
}
private List<String> getAndClear(List<String> source) {
final List<String> retList = new ArrayList<>(source);
source.clear();
return retList;
}
private List<FileSystemService.ContentContainer> getContent(FileSystemService.SortOrder order) {
final List<FileSystemService.ContentContainer> contentList = this.fileSystemService.list(order);
private List<ContentContainer> getContent(FileSystemService.SortOrder order) {
final List<ContentContainer> contentList = this.fileSystemService.list(order);
if (!this.locationTracker.isBasePath()) {
contentList.add(0,
new FileSystemService.ContentContainer(true,
new ContentContainer(true,
this.locationTracker.getParent(),
"..",
0L,
@@ -290,47 +485,50 @@ public class FilesController implements InitializingBean {
if (split.length > 1) {
this.locationTracker.setCurrentLocation(UriUtils.decode(split[1].substring(1), StandardCharsets.UTF_8));
} else {
this.locationTracker.reset();
if(!this.sessionInfo.isRestrictedSession()) {
this.locationTracker.reset();
}
else {
this.locationTracker.resetCurrentLocation();
}
}
}
private String getCurrentLocation() {
if (this.locationTracker.getRelativeLocation().toString().isEmpty()) {
if (this.locationTracker.getRelativeLocation().isEmpty()) {
return "/";
}
return this.locationTracker.getRelativeLocation().toString();
return this.locationTracker.getRelativeLocation();
}
private String getCurrentLocationPrefixed() {
if (this.locationTracker.getRelativeLocation().toString().isEmpty()) {
if (this.locationTracker.getRelativeLocation().isEmpty()) {
return "/";
}
return "/" + this.locationTracker.getRelativeLocation().toString();
return "/" + this.locationTracker.getRelativeLocation();
}
@Override
public void afterPropertiesSet() throws Exception {
this.locationTracker.init();
if (this.filesConfig.isUseTrashBin()) {
try {
this.fileSystemService.createDirectory(this.filesConfig.getTrashBinName());
} catch (FileSystemServiceException e) {
if (!FileAlreadyExistsException.class.equals(e.getCause().getClass())) {
throw e;
}
// else: do nothing
}
}
try {
this.fileSystemService.createDirectory(this.filesConfig.getSharesName());
} catch (FileSystemServiceException e) {
if (!FileAlreadyExistsException.class.equals(e.getCause().getClass())) {
throw e;
}
// else: do nothing
}
// if (this.filesConfig.isUseTrashBin()) {
// try {
// this.fileSystemService.createDirectory(this.filesConfig.getTrashBinName());
// } catch (FileSystemServiceException e) {
// if (!FileAlreadyExistsException.class.equals(e.getCause().getClass())) {
// throw e;
// }
// // else: do nothing
// }
// }
// try {
// this.fileSystemService.createDirectory(this.filesConfig.getSharesName());
// } catch (FileSystemServiceException e) {
// if (!FileAlreadyExistsException.class.equals(e.getCause().getClass())) {
// throw e;
// }
// // else: do nothing
// }
}
}

View File

@@ -0,0 +1,47 @@
package de.nbscloud.files.controller;
import de.nbscloud.files.FileSystemService;
import de.nbscloud.files.LocationTracker;
import de.nbscloud.files.config.FilesConfig;
import de.nbscloud.webcontainer.shared.config.WebContainerSharedConfig;
import org.apache.commons.io.FileUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.nio.file.Paths;
@Controller
public class FilesWidgetController {
private static final Logger logger = LoggerFactory.getLogger(FilesWidgetController.class);
@Autowired
private FilesConfig filesConfig;
@Autowired
private LocationTracker locationTracker;
@Autowired
private WebContainerSharedConfig webContainerSharedConfig;
@Autowired
private FileSystemService fileSystemService;
@GetMapping("files/widgets/diskUsage")
public String getDiskUsage(HttpServletRequest request, HttpServletResponse response, Model model) {
final String device = this.fileSystemService.getDevice();
final long diskSize = this.fileSystemService.getDiskSize(device);
final long used = FileUtils.sizeOfDirectory(Paths.get(this.filesConfig.getBaseDir()).toFile());
final long available = diskSize - used;
model.addAttribute("baseDir", this.filesConfig.getBaseDir());
model.addAttribute("device", device);
model.addAttribute("available", available);
model.addAttribute("used", used);
model.addAttribute("diskSize", diskSize);
return "files/widgets/diskUsage :: file-disk-usage";
}
}

View File

@@ -0,0 +1,27 @@
package de.nbscloud.files.form;
public class PasswordForm {
private String shareUuid;
private String password;
public PasswordForm(String shareUuid, String password) {
this.shareUuid = shareUuid;
this.password = password;
}
public String getShareUuid() {
return shareUuid;
}
public void setShareUuid(String shareUuid) {
this.shareUuid = shareUuid;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
}

View File

@@ -0,0 +1,50 @@
package de.nbscloud.files.form;
import java.time.LocalDate;
public class ShareForm {
private String filename;
private boolean oneTime;
private LocalDate expiryDate;
private String password;
public ShareForm(String filename, boolean oneTime, LocalDate expiryDate, String password) {
this.filename = filename;
this.oneTime = oneTime;
this.expiryDate = expiryDate;
this.password = password;
}
public String getFilename() {
return filename;
}
public void setFilename(String filename) {
this.filename = filename;
}
public boolean isOneTime() {
return oneTime;
}
public void setOneTime(boolean oneTime) {
this.oneTime = oneTime;
}
public LocalDate getExpiryDate() {
return expiryDate;
}
public void setExpiryDate(LocalDate expiryDate) {
this.expiryDate = expiryDate;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
}

View File

@@ -0,0 +1,11 @@
package de.nbscloud.files.widget;
import de.nbscloud.files.FilesApp;
import de.nbscloud.webcontainer.registry.Widget;
public class DiskUsageWidget implements Widget {
@Override
public String getPath() {
return FilesApp.ID + "/widgets/diskUsage";
}
}

View File

@@ -5,8 +5,7 @@ info.app.description=A simple web file admin app
# Knob to configure the base dir where files stores and reads files
# Make sure the permissions match
nbs-cloud.files.baseDir=/home/marius
#nbs-cloud.files.baseDir=/opt/nextcloud_data/marius/files
nbs-cloud.files.baseDir=/home/marius/nbstest
# Knob to configure whether hidden files (e.g. starting with '.' on *NIX)
# will be filtered in the file view
@@ -26,4 +25,7 @@ nbs-cloud.files.trashBinName=nbs.internal/nbs.trashbin
nbs-cloud.files.truncateFileNameChars=130
# Knob to configure the name of the shares directory
nbs-cloud.files.sharesName=nbs.internal/nbs.shares
nbs-cloud.files.sharesName=nbs.internal/nbs.shares
# Knob to configure the name of the app directory
nbs-cloud.files.appDir=nbs.internal/apps

View File

@@ -6,19 +6,36 @@ nbscloud.files.files-content-table.table-header.lastmodified=Last modified
nbscloud.files.files-content-table.table-header.actions=Actions
nbscloud.files-content-table.table.actions.delete=Delete
nbscloud.files-content-table.table.actions.rename=Rename/move
nbscloud.files-content-table.table.actions.rename=Rename/move...
nbscloud.files-content-table.table.actions.download=Download
nbscloud.files-content-table.table.actions.share=Share
nbscloud.files-content-table.table.actions.share=Share...
nbscloud.files-content-table.table.actions.preview=Preview
nbscloud.files.rename.title=nbscloud - files\: rename/move \u0020
nbscloud.files.rename.label.filename=Filename\:
nbscloud.files.rename.label.targetDir=Target directory\:
nbscloud.files.rename.submit=Rename/move
nbscloud.files.share.title=nbscloud - files\: share \u0020
nbscloud.files.share.label.filename=Filename\:
nbscloud.files.share.label.onetime=One time\:
nbscloud.files.share.label.expirydate=Expiry date\:
nbscloud.files.share.label.password=Password\:
nbscloud.files.share.submit=Share
nbscloud.files.share-message=Shared file\:\u0020
nbscloud.files.share.error.passwordWrong=Wrong password!
nbscloud.files.share.password.title=Password check
nbscloud.files.share.password.submit=Check password
nbscloud.files.share.password.label.password=Password\:
nbscloud.files.file-disk-usage-widget.heading=files disk usage
nbscloud.files.file-disk-usage-widget-table.basedir=Base dir\:
nbscloud.files.file-disk-usage-widget-table.device=Host device\:
nbscloud.files.file-disk-usage-widget-table.available=Available\:
nbscloud.files.file-disk-usage-widget-table.used=Used\:
nbscloud.files.file-disk-usage-widget-table.disksize=Disk size\:
nbscloud.files.delete.success=File deleted
nbscloud.files.rename.success=File renamed

View File

@@ -6,19 +6,36 @@ nbscloud.files.files-content-table.table-header.lastmodified=Zuletzt ge\u00E4nde
nbscloud.files.files-content-table.table-header.actions=Aktionen
nbscloud.files-content-table.table.actions.delete=L\u00F6schen
nbscloud.files-content-table.table.actions.rename=Umbenennen/verschieben
nbscloud.files-content-table.table.actions.rename=Umbenennen/verschieben...
nbscloud.files-content-table.table.actions.download=Download
nbscloud.files-content-table.table.actions.share=Teilen
nbscloud.files-content-table.table.actions.share=Teilen...
nbscloud.files-content-table.table.actions.preview=Vorschau
nbscloud.files.rename.title=nbscloud - Dateien\: umbenennen/verschieben \u0020
nbscloud.files.rename.label.filename=Dateiname\:
nbscloud.files.rename.label.targetDir=Zielverzeichnis\:
nbscloud.files.rename.submit=Umbenennen/verschieben
nbscloud.files.share.title=nbscloud - Dateien\: teilen \u0020
nbscloud.files.share.label.filename=Dateiname\:
nbscloud.files.share.label.onetime=Einmaliger Abruf\:
nbscloud.files.share.label.expirydate=Ablaufdatum\:
nbscloud.files.share.label.password=Passwort\:
nbscloud.files.share.submit=Teilen
nbscloud.files.share-message=Datei geteilt\:\u0020
nbscloud.files.share.error.passwordWrong=Passwort falsch!
nbscloud.files.share.password.title=Passwort eingeben
nbscloud.files.share.password.submit=Passwort pr\u00FCfen
nbscloud.files.share.password.label.password=Passwort\:
nbscloud.files.file-disk-usage-widget.heading=Dateien Festplattennutzung
nbscloud.files.file-disk-usage-widget-table.basedir=Verzeichnis\:
nbscloud.files.file-disk-usage-widget-table.device=Ger\u00E4t\:
nbscloud.files.file-disk-usage-widget-table.available=Verf\u00FCgbar\:
nbscloud.files.file-disk-usage-widget-table.used=Belegt\:
nbscloud.files.file-disk-usage-widget-table.disksize=Gr\u00F6\u00DFe\:
nbscloud.files.delete.success=Datei gel\u00F6scht
nbscloud.files.rename.success=Datei verschoben/umbenannt

View File

@@ -48,17 +48,21 @@
cursor: pointer;
}
#rename-form {
#rename-form,
#share-form,
#share-password-form {
margin-top: 3em;
margin-left: 3em;
}
#rename-form * {
#rename-form *,
#share-form *,
#share-password-form * {
display: block;
margin-top: 1em;
box-sizing: border-box;
}
#rename-form > select > * {
#rename-form > select > *{
margin-top: unset;
}
@@ -66,109 +70,16 @@
width: 70em;
}
#rename-form > input[type=text] {
#rename-form > input[type=text],
#share-form > input[type=text],
#share-password-form > input[type=text] {
width: 70em;
}
#menu-container {
padding-top: 2em;
margin-left: 3em;
}
.files-menu-icon {
background: none !important;
border: none;
padding: 0 !important;
color: var(--text-color);
cursor: pointer;
font-size: 2em;
}
.files-menu-icon:hover {
color: var(--link-color);
}
#content-container {
padding-top: unset !important;
}
#create-dir-container > details > summary {
list-style-type: none;
}
#create-dir-container > details[open] > summary {
list-style-type: none;
}
#menu-container > div {
display: inline-block !important;
}
.create-dir-container-details-modal-content {
padding: 0.5em;
pointer-events: all;
display: inline;
}
.create-dir-container-details-modal {
background: var(--background-color);
filter: brightness(var(--background-brightness));
border-radius: 0.3em;
left: auto;
top: auto;
right: auto;
bottom: auto;
padding: 0;
pointer-events: none;
position: absolute;
display: flex;
flex-direction: column;
z-index: 100;
}
.menu-spacer {
width: 2em;
}
.messageContainer {
width: 100%;
display: flex;
flex-direction: row;
justify-content: center;
}
.message {
margin-top: 2em;
border: 1px;
border-style: solid;
border-radius: 0.3em;
padding: 1em;
display: inline-block;
}
.shareMessage {
border-color: var(--good-color) !important;
}
.infoMessage {
border-color: var(--good-color) !important;
}
.errorMessage {
border-color: var(--error-color) !important;
}
@media only screen and (max-width: 450px) {
#menu-container {
padding-top: 1em;
margin-left: 1em;
}
.menu-spacer {
width: 1em;
}
}
#gallery-link {
text-decoration: none;
}
#file-disk-usage-widget {
display: block;
}

View File

@@ -0,0 +1,25 @@
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title th:text="#{nbscloud.files.share.password.title}"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link th:if="${darkMode}" rel="stylesheet" th:href="@{/css/darkModeColors.css}"/>
<link th:if="${!darkMode}" rel="stylesheet" th:href="@{/css/lightModeColors.css}"/>
<link rel="stylesheet" th:href="@{/css/main.css}"/>
<link rel="stylesheet" th:href="@{/css/files_main.css}"/>
<link rel="shortcut icon" th:href="@{/favicon.ico}"/>
</head>
<body>
<div id="main-container">
<div th:replace="includes/messages :: messages"/>
<form id="share-password-form" action="#" th:action="@{/files/shares/checkPassword}" th:object="${form}" method="post" enctype="multipart/form-data">
<label for="password" th:text="#{nbscloud.files.share.password.label.password}"/>
<input type="password" id="password" th:field="*{password}" />
<input type="hidden" id="shareUuid" th:field="*{shareUuid}" />
<input type="submit" th:value="#{nbscloud.files.share.password.submit}" />
</form>
<div th:replace="includes/footer :: footer"/>
</div>
</body>
</html>

View File

@@ -13,22 +13,7 @@
<body>
<div id="main-container">
<div th:replace="includes/header :: header"/>
<div class="messageContainer" th:each="shareInfo : ${shareInfo}">
<div th:if="${!shareInfo.isEmpty()}" class="shareMessage message">
<span th:text="#{'nbscloud.files.share-message'}"/>
<a th:href="@{${shareInfo}}" th:text="${shareInfo}"/>
</div>
</div>
<div class="messageContainer" th:each="error : ${errors}">
<div th:if="${!errors.isEmpty()}" class="errorMessage message">
<span th:text="${error}"/>
</div>
</div>
<div class="messageContainer" th:each="infoMessage : ${infoMessages}">
<div th:if="${!infoMessages.isEmpty()}" class="infoMessage message">
<span th:text="#{${infoMessage}}"/>
</div>
</div>
<div th:replace="includes/messages :: messages"/>
<div th:replace="files/includes/menu :: menu"/>
<div id="content-container">
<table id="files-content-table">
@@ -64,9 +49,9 @@
<td th:if="${fileEntry.directory}" class="no-mobile">d</td>
<td th:if="${!fileEntry.directory}" class="no-mobile">-</td>
<td th:if="${fileEntry.directory}">
<a th:if="${fileEntry.name != '..'}" th:href="@{/files/browse/} + ${fileEntry.path}"
<a th:if="${fileEntry.name != '..'}" th:href="@{__${prefix}__/files/browse/__${fileEntry.path}__}"
th:text="${fileEntry.name}"/>
<a th:if="${fileEntry.name == '..'}" th:href="@{/files/browse/} + ${fileEntry.path}"
<a th:if="${fileEntry.name == '..'}" th:href="@{__${prefix}__/files/browse/__${fileEntry.path}__}"
th:text="${fileEntry.name}"/>
</td>
<td th:if="${!fileEntry.directory && @filesFormatter.needsTruncate(fileEntry.name)}"
@@ -82,26 +67,27 @@
<details th:if="${fileEntry.name != '..'}" class="files-table-text-align-right">
<summary th:text="#{nbscloud.show-actions}"/>
<div id="files-content-table-show-actions-detail-container">
<div id="files-content-table-show-actions-detail-container-rename">
<div id="files-content-table-show-actions-detail-container-rename"
th:if="${!restricted}">
<a th:href="@{/files/rename(filename=${fileEntry.name})}"
th:text="#{nbscloud.files-content-table.table.actions.rename}"/>
</div>
<div id="files-content-table-show-actions-detail-container-delete">
<div id="files-content-table-show-actions-detail-container-delete"
th:if="${!restricted}">
<a th:href="@{/files/delete(filename=${fileEntry.name})}"
th:text="#{nbscloud.files-content-table.table.actions.delete}"/>
</div>
<div th:if="${!fileEntry.directory}"
id="files-content-table-show-actions-detail-container-download">
<a th:href="@{/files/download(filename=${fileEntry.name})}"
<div id="files-content-table-show-actions-detail-container-download">
<a th:href="@{__${prefix}__/files/download(filename=${fileEntry.name})}"
th:text="#{nbscloud.files-content-table.table.actions.download}"/>
</div>
<div th:if="${!fileEntry.directory}"
id="files-content-table-show-actions-detail-container-preview">
<a th:href="@{/files/preview(filename=${fileEntry.name})}"
<a th:href="@{__${prefix}__/files/preview(filename=${fileEntry.name})}"
th:text="#{nbscloud.files-content-table.table.actions.preview}"/>
</div>
<div th:if="${!fileEntry.directory}"
id="files-content-table-show-actions-detail-container-share">
<div id="files-content-table-show-actions-detail-container-share"
th:if="${!restricted}">
<a th:href="@{/files/share(filename=${fileEntry.name})}"
th:text="#{nbscloud.files-content-table.table.actions.share}"/>
</div>

View File

@@ -16,7 +16,7 @@
<div id="main-container">
<div th:replace="includes/header :: header"/>
<div id="gallery-container">
<img class="galleryImage" th:each="file : ${files}" th:src="@{/files/preview(filename=${file})}">
<img class="galleryImage" th:each="file : ${files}" th:src="@{__${prefix}__/files/preview(filename=${file})}">
</div>
<div th:replace="includes/footer :: footer"/>
</div>

View File

@@ -1,23 +1,23 @@
<div id="menu-container" th:fragment="menu">
<div class="menu-spacer"></div>
<div id="upload-container">
<div th:if="${!restricted}" class="menu-spacer"></div>
<div th:if="${!restricted}" id="upload-container" class="menu-container">
<form method="POST" action="#" th:action="@{/files/upload}" enctype="multipart/form-data">
<!-- JavaScript required unfortunately -->
<input id="upload-container-file-input" type="file" name="files" onchange="this.form.submit()"
style="display: none;" multiple>
<span id="upload-container-span-input" class="icon files-menu-icon"
<span id="upload-container-span-input" class="icon menu-icon"
onclick="document.getElementById('upload-container-file-input').click();">&#xe9fc;</span>
</form>
</div>
<div class="menu-spacer"></div>
<div id="create-dir-container">
<div th:if="${!restricted}" class="menu-spacer"></div>
<div th:if="${!restricted}" id="create-dir-container" class="menu-container">
<details>
<summary>
<span id="create-dir-container-span-input" class="icon files-menu-icon">&#xe2cc;</span>
<span id="create-dir-container-span-input" class="icon menu-icon">&#xe2cc;</span>
<div class="create-dir-container-details-modal-overlay"></div>
</summary>
<div class="create-dir-container-details-modal">
<div class="create-dir-container-details-modal-content">
<div class="menu-modal">
<div class="menu-modal-content">
<form method="POST" action="#" th:action="@{/files/createDir}" enctype="multipart/form-data">
<input id="create-dir-container-dir-name" type="text" name="dirName" />
<input type="submit" value="Create"/>
@@ -28,8 +28,8 @@
</div>
<div class="menu-spacer"></div>
<div>
<a id="gallery-link" th:href="@{/files/gallery}">
<span class="icon files-menu-icon">&#xe3b6;</span>
<a id="gallery-link" th:href="@{__${prefix}__/files/gallery}">
<span class="icon menu-icon">&#xe3b6;</span>
</a>
</div>
</div>

View File

@@ -0,0 +1,30 @@
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title th:text="#{nbscloud.files.share.title} + ${filename}"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link th:if="${darkMode}" rel="stylesheet" th:href="@{/css/darkModeColors.css}"/>
<link th:if="${!darkMode}" rel="stylesheet" th:href="@{/css/lightModeColors.css}"/>
<link rel="stylesheet" th:href="@{/css/main.css}"/>
<link rel="stylesheet" th:href="@{/css/files_main.css}"/>
<link rel="shortcut icon" th:href="@{/favicon.ico}"/>
</head>
<body>
<div id="main-container">
<div th:replace="includes/header :: header"/>
<form id="share-form" action="#" th:action="@{/files/doShare}" th:object="${form}" method="post" enctype="multipart/form-data">
<label for="filename" th:text="#{nbscloud.files.share.label.filename}"/>
<input type="text" id="filename" th:field="*{filename}" readonly />
<label for="oneTime" th:text="#{nbscloud.files.share.label.onetime}"/>
<input type="checkbox" id="oneTime" th:field="*{oneTime}" />
<label for="expiryDate" th:text="#{nbscloud.files.share.label.expirydate}"/>
<input type="date" id="expiryDate" th:field="*{expiryDate}"/>
<label for="password" th:text="#{nbscloud.files.share.label.password}"/>
<input type="password" id="password" th:field="*{password}"/>
<input type="submit" th:value="#{nbscloud.files.share.submit}" />
</form>
<div th:replace="includes/footer :: footer"/>
</div>
</body>
</html>

View File

@@ -0,0 +1,25 @@
<div id="file-disk-usage-widget" class="widget" th:fragment="file-disk-usage">
<p class="widget-heading" th:text="#{nbscloud.files.file-disk-usage-widget.heading}"/>
<table id="file-disk-usage-widget-table">
<tr>
<td th:text="#{nbscloud.files.file-disk-usage-widget-table.basedir}" />
<td th:text="${baseDir}" />
</tr>
<tr>
<td th:text="#{nbscloud.files.file-disk-usage-widget-table.device}" />
<td th:text="${device}" />
</tr>
<tr>
<td th:text="#{nbscloud.files.file-disk-usage-widget-table.disksize}" />
<td th:text="${@filesFormatter.formatSize(diskSize)}" />
</tr>
<tr>
<td th:text="#{nbscloud.files.file-disk-usage-widget-table.used}" />
<td th:text="${@filesFormatter.formatSize(used)}" />
</tr>
<tr>
<td th:text="#{nbscloud.files.file-disk-usage-widget-table.available}" />
<td th:text="${@filesFormatter.formatSize(available)}" />
</tr>
</table>
</div>

View File

@@ -0,0 +1,75 @@
package de.nbscloud.files;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
public class LocationTrackerTest {
private static final String BASE_DIR = "/tmp";
private LocationTracker locationTracker = new LocationTracker();
@Before
public void init() {
this.locationTracker.init_internal(BASE_DIR);
}
@Test(expected = IllegalStateException.class)
public void test_null() {
this.locationTracker.resolve(null);
}
@Test(expected = IllegalStateException.class)
public void test_relative1() {
this.locationTracker.resolve("../test");
}
@Test(expected = IllegalStateException.class)
public void test_relative2() {
this.locationTracker.resolve("/../test");
}
@Test(expected = IllegalStateException.class)
public void test_relative3() {
this.locationTracker.resolve("\\../test");
}
@Test(expected = IllegalStateException.class)
public void test_relative4() {
this.locationTracker.resolve("\\/../test");
}
@Test(expected = IllegalStateException.class)
public void test_relative5() {
this.locationTracker.resolve("..");
}
@Test
public void test_relative6_ok() {
// While 0x2E translates to the dot character and that might be used to
// traverse the path, the Java Path API does not resolve those and just treats them as
// any other file/dir name
Assert.assertTrue(this.locationTracker.resolve("%2e%2e%2f").toString().equals(BASE_DIR + "/%2e%2e%2f"));
}
@Test(expected = IllegalStateException.class)
public void test_relative7() {
this.locationTracker.resolve("...");
}
@Test
public void test_relative8_ok() {
Assert.assertTrue(this.locationTracker.resolve("././").toString().equals(BASE_DIR));
}
@Test
public void test_relative9_ok() {
// The tilde is treated as regular file/dir name if it is resolved from a path
Assert.assertTrue(this.locationTracker.resolve("~").toString().equals(BASE_DIR + "/~"));
}
@Test(expected = IllegalStateException.class)
public void test_absolut() {
this.locationTracker.resolve("/bin");
}
}

View File

@@ -5,7 +5,7 @@
<parent>
<artifactId>nbs-cloud-aggregator</artifactId>
<groupId>de.77zzcx7.nbs-cloud</groupId>
<version>6</version>
<version>30-SNAPSHOT</version>
</parent>
<groupId>de.77zzcx7.nbs-cloud</groupId>
@@ -21,6 +21,10 @@
<groupId>de.77zzcx7.nbs-cloud</groupId>
<artifactId>web-container-config</artifactId>
</dependency>
<dependency>
<groupId>de.77zzcx7.nbs-cloud</groupId>
<artifactId>files-api</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>

View File

@@ -0,0 +1,126 @@
package de.nbscloud.notes.controller;
import de.nbscloud.files.api.FilesService;
import de.nbscloud.notes.NotesApp;
import de.nbscloud.webcontainer.MessageHelper;
import de.nbscloud.webcontainer.registry.AppRegistry;
import de.nbscloud.webcontainer.shared.config.WebContainerSharedConfig;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.Optional;
@Controller
public class NotesController {
private static final Logger logger = LoggerFactory.getLogger(NotesController.class);
@Autowired
private AppRegistry appRegistry;
@Autowired
private FilesService filesService;
@Autowired
private WebContainerSharedConfig webContainerSharedConfig;
@Autowired
private NotesApp app;
@Autowired
private MessageHelper messageHelper;
public static Optional<Path> notePathToPath(String notePath) {
final Optional<String> optNotePath = Optional.ofNullable(notePath);
return optNotePath.map(p -> Path.of(p));
}
@GetMapping("/notes")
public String start(Model model, @RequestParam(required = false) String notePath) {
this.filesService.createAppDirectoryIfNotExists(this.app);
final Optional<Path> optNotePath = notePathToPath(notePath);
if(optNotePath.isPresent()) {
final byte[] content = this.filesService.get(app, optNotePath.get());
model.addAttribute("content", new String(content));
}
this.messageHelper.addAndClearAll(model);
model.addAttribute("currentNote", optNotePath.orElse(Path.of("/")));
model.addAttribute("tree", this.filesService.getTree(app, Optional.empty()));
model.addAttribute("dirs", this.filesService.collectDirs(app, Optional.empty()));
model.addAttribute("apps", this.appRegistry.getAll());
this.webContainerSharedConfig.addDefaults(model);
return "notes/notesIndex";
}
@PostMapping("/notes/save")
public String save(String textContent, String notePath) {
final Optional<Path> optNotePath = notePathToPath(notePath);
if(optNotePath.isPresent()) {
this.filesService.overwriteFile(app, optNotePath.get(), textContent.getBytes(StandardCharsets.UTF_8));
this.messageHelper.addInfo("nbscloud.notes.save.success");
}
return "redirect:?notePath=" + notePath;
}
@PostMapping("/notes/delete")
public String delete(String notePath) {
final Optional<Path> optNotePath = notePathToPath(notePath);
if(optNotePath.isPresent()) {
this.filesService.delete(app, optNotePath.get());
this.messageHelper.addInfo("nbscloud.notes.delete.success");
}
return "redirect:";
}
@PostMapping("/notes/createDir")
public String createDirectory(String dirName, String parentPath, String currentPath) {
final Optional<Path> optParentPath = notePathToPath(parentPath);
if(optParentPath.isPresent()) {
final Path dirPath = optParentPath.get().resolve(dirName);
this.filesService.createDirectory(app, dirPath);
this.messageHelper.addInfo("nbscloud.notes.createDir.success");
}
if(currentPath == null || currentPath.isBlank()) {
return "redirect:";
}
return "redirect:?notePath=" + currentPath;
}
@PostMapping("/notes/createNote")
public String createNote(String noteName, String parentPath, String currentPath) {
final Optional<Path> optParentPath = notePathToPath(parentPath);
if(optParentPath.isPresent()) {
final Path notePath = optParentPath.get().resolve(noteName);
this.filesService.createFile(app, notePath, new byte[] {});
this.messageHelper.addInfo("nbscloud.notes.createNote.success");
return "redirect:?notePath=" + notePath;
}
return "redirect:?notePath=" + currentPath;
}
}

View File

@@ -0,0 +1,9 @@
nbscloud.notes.action.save=Save
nbscloud.notes.action.delete=Delete
nbscloud.notes.save.success=Saved
nbscloud.notes.delete.success=Deleted
nbscloud.notes.createDir.success=Directory created
nbscloud.notes.createNote.success=Note created
nbscloud.notes.index.title=nbscloud - notes\:\u0020

View File

@@ -0,0 +1,9 @@
nbscloud.notes.action.save=Speichern
nbscloud.notes.action.delete=L\u00F6schen
nbscloud.notes.save.success=Gespeichert
nbscloud.notes.delete.success=Gel\u00F6scht
nbscloud.notes.createDir.success=Verzeichnis erstellt
nbscloud.notes.createNote.success=Notiz erstellt
nbscloud.notes.index.title=nbscloud - Notizen\:\u0020

View File

@@ -0,0 +1,59 @@
#content-container {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
row-gap: 2em;
column-gap: 2em;
}
#nav-tree {
flex-grow: 1;
background-color: var(--background-color-highlight);
overflow-y: scroll;
}
#sub-content {
display: flex;
flex-direction: column;
flex-wrap: nowrap;
row-gap: 2em;
column-gap: 2em;
flex-grow: 1;
flex-basis: 70%;
}
#nav-tree ul {
list-style-type: none;
padding-left: 1.6em;
}
#nav-tree p, a {
margin: 0em;
}
#edit-area {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
}
#edit-area textarea {
flex: 1;
background-color: var(--background-color-highlight);
color: var(--text-color);
resize: none;
}
#actions-sub {
display: flex;
flex-direction: row-reverse;
flex-wrap: nowrap;
row-gap: 2em;
column-gap: 2em;
margin-top: 2em;
}
#dir-container > * {
display: inline;
}

View File

@@ -0,0 +1,12 @@
<div th:fragment="tree-level">
<ul>
<li th:each="tmpSubTree : ${tree}">
<div th:if="${tmpSubTree.directory}" id="dir-container">
<span class="icon">&#xe2c7;</span>
<p th:text="${tmpSubTree.name}" />
</div>
<p th:if="${!tmpSubTree.directory}"><a th:href="@{/notes(notePath=${tmpSubTree.path})}" th:text="${tmpSubTree.name}" /></p>
<th:block th:include="@{notes/fragments/treeLevel} :: tree-level" th:with="tree=${tmpSubTree.subTree}" />
</li>
</ul>
</div>

View File

@@ -0,0 +1,47 @@
<div id="menu-container" th:fragment="menu">
<div class="menu-spacer"></div>
<div id="create-dir-container" class="menu-container">
<details>
<summary>
<span id="create-dir-container-span-input" class="icon menu-icon">&#xe2cc;</span>
</summary>
<div class="menu-modal">
<div class="menu-modal-content">
<form method="POST" action="#" th:action="@{/notes/createDir}" enctype="multipart/form-data">
<input id="create-dir-container-dir-name" type="text" name="dirName" />
<select size="1" id="parentPath" name="parentPath">
<option th:each="dir : ${dirs}"
th:value="${dir}"
th:text="${dir}" />
</select>
<input type="hidden" name="notePath" th:value="${currentNote}" class="display-none" />
<input type="submit" value="Create"/>
</form>
</div>
</div>
</details>
</div>
<div class="menu-spacer"></div>
<div id="create-note-container" class="menu-container">
<details>
<summary>
<span id="create-note-container-span-input" class="icon menu-icon">&#xe89c;</span>
<div class="create-note-container-details-modal-overlay"></div>
</summary>
<div class="menu-modal">
<div class="menu-modal-content">
<form method="POST" action="#" th:action="@{/notes/createNote}" enctype="multipart/form-data">
<input id="create-note-container-dir-name" type="text" name="noteName" />
<select size="1" id="parentPathNote" name="parentPath">
<option th:each="dir : ${dirs}"
th:value="${dir}"
th:text="${dir}" />
</select>
<input type="hidden" name="notePath" th:value="${currentNote}" class="display-none" />
<input type="submit" value="Create"/>
</form>
</div>
</div>
</details>
</div>
</div>

View File

@@ -0,0 +1,40 @@
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title th:text="#{nbscloud.notes.index.title} + ${currentNote}"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link th:if="${darkMode}" rel="stylesheet" th:href="@{/css/darkModeColors.css}"/>
<link th:if="${!darkMode}" rel="stylesheet" th:href="@{/css/lightModeColors.css}"/>
<link rel="stylesheet" th:href="@{/css/main.css}"/>
<link rel="stylesheet" th:href="@{/css/notes_main.css}"/>
<link rel="shortcut icon" th:href="@{/favicon.ico}"/>
</head>
<body>
<div id="main-container">
<div th:replace="includes/header :: header"/>
<div th:replace="includes/messages :: messages"/>
<div th:replace="notes/includes/menu :: menu"/>
<div id="content-container">
<div id="nav-tree">
<th:block th:include="@{notes/fragments/treeLevel} :: tree-level" th:with="tree=${tree}" />
</div>
<div id="sub-content">
<form method="POST" action="#" enctype="multipart/form-data">
<div id="edit-area">
<textarea type="text" th:if="${content != null}" th:text="${content}" rows="25" name="textContent"/>
</div>
<div id="actions">
<div th:if="${content != null}" id="actions-sub">
<input type="submit" th:value="#{nbscloud.notes.action.save}" th:formaction="@{/notes/save}" />
<input type="submit" th:value="#{nbscloud.notes.action.delete}" th:formaction="@{/notes/delete}" />
<input type="hidden" name="notePath" th:value="${currentNote}" class="display-none" />
</div>
</div>
</form>
</div>
</div>
<div th:replace="includes/footer :: footer"/>
</div>
</body>
</html>

View File

@@ -5,7 +5,7 @@
<parent>
<artifactId>nbs-cloud-aggregator</artifactId>
<groupId>de.77zzcx7.nbs-cloud</groupId>
<version>6</version>
<version>30-SNAPSHOT</version>
</parent>
<groupId>de.77zzcx7.nbs-cloud</groupId>

31
pom.xml
View File

@@ -1,7 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
@@ -11,13 +10,14 @@
<groupId>de.77zzcx7.nbs-cloud</groupId>
<artifactId>nbs-cloud-aggregator</artifactId>
<version>6</version>
<version>30-SNAPSHOT</version>
<packaging>pom</packaging>
<description>The umbrella for all No BullShit cloud projects</description>
<name>nbs-cloud-aggregator</name>
<modules>
<module>files</module>
<module>files-api</module>
<module>web-container</module>
<module>web-container-registry</module>
<module>notes</module>
@@ -31,26 +31,26 @@
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<java.version>18</java.version>
<scmDeveloperConnectionProp />
<repository.url>http://reposilite.intern.77zzcx7.de</repository.url>
</properties>
<distributionManagement>
<snapshotRepository>
<id>77zzcx7-snapshots</id>
<url>http://192.168.10.4:8100/snapshots/</url>
<url>${repository.url}/snapshots/</url>
</snapshotRepository>
<repository>
<id>77zzcx7-releases</id>
<url>http://192.168.10.4:8100/releases/</url>
<url>${repository.url}/releases/</url>
</repository>
</distributionManagement>
<scm>
<connection>scm:git:https://77zzcx7.de/gitea/MK13/NoBullShit-cloud.git</connection>
<developerConnection>${scmDeveloperConnectionProp}</developerConnection>
<connection>scm:git:https://gitea.77zzcx7.de/MK13/NoBullShit-cloud.git</connection>
<developerConnection>scm:git:https://gitea.77zzcx7.de/MK13/NoBullShit-cloud.git</developerConnection>
<url>https://77zzcx7.de/gitea/MK13/NoBullShit-cloud</url>
<tag>v6</tag>
</scm>
<tag>v22</tag>
</scm>
<dependencyManagement>
<dependencies>
@@ -64,6 +64,11 @@
<artifactId>files</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>de.77zzcx7.nbs-cloud</groupId>
<artifactId>files-api</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>de.77zzcx7.nbs-cloud</groupId>
<artifactId>web-container-registry</artifactId>
@@ -114,6 +119,13 @@
<artifactId>mime-types</artifactId>
<version>1.0.2</version>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.12</version>
<scope>test</scope>
</dependency>
</dependencies>
</dependencyManagement>
@@ -193,5 +205,4 @@
</plugin>
</plugins>
</build>
</project>

View File

@@ -5,7 +5,7 @@
<parent>
<artifactId>nbs-cloud-aggregator</artifactId>
<groupId>de.77zzcx7.nbs-cloud</groupId>
<version>6</version>
<version>30-SNAPSHOT</version>
</parent>
<groupId>de.77zzcx7.nbs-cloud</groupId>
@@ -31,5 +31,9 @@
<artifactId>spring-boot-starter-web</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>de.77zzcx7.nbs-cloud</groupId>
<artifactId>files-api</artifactId>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,5 @@
package de.nbscloud.todo;
public enum Category {
A, B, C;
}

View File

@@ -1,19 +1,26 @@
package de.nbscloud.todo;
import de.nbscloud.todo.widget.TodoWidget;
import de.nbscloud.webcontainer.registry.App;
import de.nbscloud.webcontainer.registry.AppRegistry;
import de.nbscloud.webcontainer.registry.Widget;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.util.Collection;
import java.util.List;
@Component
public class TodoApp implements App, InitializingBean {
public static final String ID = "todo";
@Autowired
private AppRegistry appRegistry;
@Override
public String getId() {
return "todo";
return ID;
}
@Override
@@ -31,6 +38,11 @@ public class TodoApp implements App, InitializingBean {
return 40;
}
@Override
public Collection<Widget> getWidgets() {
return List.of(new TodoWidget());
}
@Override
public void afterPropertiesSet() throws Exception {
this.appRegistry.registerApp(this);

View File

@@ -0,0 +1,74 @@
package de.nbscloud.todo;
import java.time.LocalDate;
import java.util.UUID;
public class TodoItem {
private String id;
private boolean done;
private String text;
private Category category;
private LocalDate added;
private LocalDate due;
public static final TodoItem create(boolean done, String text, Category category, LocalDate added, LocalDate due) {
final TodoItem item = new TodoItem();
item.setDone(done);
item.setText(text);
item.setCategory(category);
item.setAdded(added);
item.setDue(due);
item.setId(UUID.randomUUID().toString());
return item;
}
public boolean isDone() {
return done;
}
public void setDone(boolean done) {
this.done = done;
}
public String getText() {
return text;
}
public void setText(String text) {
this.text = text;
}
public Category getCategory() {
return category;
}
public void setCategory(Category category) {
this.category = category;
}
public LocalDate getAdded() {
return added;
}
public void setAdded(LocalDate added) {
this.added = added;
}
public LocalDate getDue() {
return due;
}
public void setDue(LocalDate due) {
this.due = due;
}
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
}

View File

@@ -0,0 +1,16 @@
package de.nbscloud.todo;
import java.util.ArrayList;
import java.util.List;
public class TodoList {
private List<TodoItem> todos = new ArrayList<>();
public List<TodoItem> getTodos() {
return todos;
}
public void setTodos(List<TodoItem> todos) {
this.todos = todos;
}
}

View File

@@ -0,0 +1,159 @@
package de.nbscloud.todo.controller;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import de.nbscloud.files.api.FilesService;
import de.nbscloud.todo.Category;
import de.nbscloud.todo.TodoApp;
import de.nbscloud.todo.TodoItem;
import de.nbscloud.todo.TodoList;
import de.nbscloud.webcontainer.MessageHelper;
import de.nbscloud.webcontainer.registry.AppRegistry;
import de.nbscloud.webcontainer.shared.config.WebContainerSharedConfig;
import de.nbscloud.webcontainer.shared.util.ControllerUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import java.nio.charset.StandardCharsets;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
import java.time.LocalDate;
import java.util.*;
import java.util.stream.Collectors;
@Controller
public class TodoController {
private static final Logger logger = LoggerFactory.getLogger(TodoController.class);
public static final Comparator<TodoItem> TODO_ITEM_COMPARATOR = Comparator.comparing(TodoItem::isDone)
.thenComparing(TodoItem::getDue,
Comparator.nullsLast(Comparator.naturalOrder()))
.thenComparing(TodoItem::getAdded);
static final Path FILENAME = Path.of("todo");
@Autowired
private AppRegistry appRegistry;
@Autowired
private FilesService filesService;
@Autowired
private WebContainerSharedConfig webContainerSharedConfig;
@Autowired
private TodoApp app;
@Autowired
private MessageHelper messageHelper;
@Autowired
private ObjectMapper mapper;
@GetMapping("/todo")
public String start(Model model) {
try {
this.filesService.createAppDirectoryIfNotExists(this.app);
final String todoListJson = new String(this.filesService.get(app, FILENAME));
final TodoList todos = mapper.readValue(todoListJson, TodoList.class);
final Map<Category, List<TodoItem>> categoryMap = new TreeMap<>(Comparator.comparing(Category::name));
todos.getTodos().stream().collect(Collectors.groupingBy(TodoItem::getCategory, () -> categoryMap, Collectors.toList()));
if(categoryMap.containsKey(Category.A)) {
Collections.sort(categoryMap.get(Category.A), TODO_ITEM_COMPARATOR);
}
if(categoryMap.containsKey(Category.B)) {
Collections.sort(categoryMap.get(Category.B), TODO_ITEM_COMPARATOR);
}
if(categoryMap.containsKey(Category.C)) {
Collections.sort(categoryMap.get(Category.C), TODO_ITEM_COMPARATOR);
}
model.addAttribute("todosByCategory", categoryMap);
}
catch(JsonProcessingException e) {
logger.error("Error while parsing todo file", e);
this.messageHelper.addError(e.getMessage());
}
catch(RuntimeException re) {
if(re.getCause() instanceof NoSuchFileException) {
logger.debug("Todo file does not exist, create it");
try {
this.filesService.createFile(app, FILENAME, mapper.writeValueAsString(new TodoList()).getBytes(StandardCharsets.UTF_8));
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
else {
logger.error("Error while reading todo file", re);
this.messageHelper.addError(re.getMessage());
}
}
model.addAttribute("categories", Category.values());
this.messageHelper.addAndClearAll(model);
model.addAttribute("apps", this.appRegistry.getAll());
this.webContainerSharedConfig.addDefaults(model);
return "todo/todoIndex";
}
@PostMapping("/todo/addItem")
public String addItem(Model model, String text, Category category, String due) {
try {
final String todoListJson = new String(this.filesService.get(app, FILENAME));
final TodoList todos = mapper.readValue(todoListJson, TodoList.class);
todos.getTodos().add(TodoItem.create(false, text, category, LocalDate.now(), ControllerUtils.parseDate(due)));
this.filesService.overwriteFile(app, FILENAME, mapper.writeValueAsBytes(todos));
this.messageHelper.addInfo("nbscloud.todo.item.add");
} catch (JsonProcessingException e) {
logger.error("Error", e);
this.messageHelper.addError(e.getMessage());
}
model.addAttribute("categories", Category.values());
model.addAttribute("apps", this.appRegistry.getAll());
this.webContainerSharedConfig.addDefaults(model);
return "redirect:";
}
@PostMapping("/todo/toggle")
public String toggle(Model model, String id, boolean done) {
try {
final String todoListJson = new String(this.filesService.get(app, FILENAME));
final TodoList todos = mapper.readValue(todoListJson, TodoList.class);
todos.getTodos().stream().filter(item -> item.getId().equals(id)).findFirst().ifPresent(item -> item.setDone(done));
this.filesService.overwriteFile(app, FILENAME, mapper.writeValueAsBytes(todos));
if(done) {
this.messageHelper.addInfo("nbscloud.todo.item.toggle.done");
}
else {
this.messageHelper.addInfo("nbscloud.todo.item.toggle.inprogress");
}
} catch (JsonProcessingException e) {
logger.error("Error", e);
this.messageHelper.addError(e.getMessage());
}
model.addAttribute("categories", Category.values());
model.addAttribute("apps", this.appRegistry.getAll());
this.webContainerSharedConfig.addDefaults(model);
return "redirect:";
}
}

View File

@@ -0,0 +1,58 @@
package de.nbscloud.todo.controller;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import de.nbscloud.files.api.FilesService;
import de.nbscloud.todo.Category;
import de.nbscloud.todo.TodoApp;
import de.nbscloud.todo.TodoItem;
import de.nbscloud.todo.TodoList;
import de.nbscloud.webcontainer.shared.config.WebContainerSharedConfig;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.*;
import java.util.stream.Collectors;
@Controller
public class TodoWidgetController {
private static final Logger logger = LoggerFactory.getLogger(TodoWidgetController.class);
@Autowired
private WebContainerSharedConfig webContainerSharedConfig;
@Autowired
private FilesService filesService;
@Autowired
private TodoApp app;
@Autowired
private ObjectMapper mapper;
@GetMapping("todo/widgets/todoOverview")
public String getTodoOverview(HttpServletRequest request, HttpServletResponse response, Model model) {
try {
this.filesService.createAppDirectoryIfNotExists(this.app);
final String todoListJson = new String(this.filesService.get(app, TodoController.FILENAME));
final TodoList todos = mapper.readValue(todoListJson, TodoList.class);
final List<TodoItem> topFiveTodos = todos.getTodos().stream().filter(item -> !item.isDone()).sorted(
Comparator.comparing(TodoItem::getDue,
Comparator.nullsLast(Comparator.naturalOrder()))
.thenComparing(TodoItem::getCategory)
.thenComparing(TodoItem::getAdded)).limit(5).collect(Collectors.toList());
model.addAttribute("todos", topFiveTodos);
}
catch(JsonProcessingException e) {
logger.error("Error while parsing todo file", e);
}
return "todo/widgets/todoOverview :: todo-overview";
}
}

View File

@@ -0,0 +1,11 @@
package de.nbscloud.todo.widget;
import de.nbscloud.todo.TodoApp;
import de.nbscloud.webcontainer.registry.Widget;
public class TodoWidget implements Widget {
@Override
public String getPath() {
return TodoApp.ID + "/widgets/todoOverview";
}
}

View File

@@ -0,0 +1,7 @@
nbscloud.todo.index.title=nbscloud - todo
nbscloud.todo.widget.heading=todo overview
nbscloud.todo.item.add=Todo added
nbscloud.todo.item.toggle.done=Done
nbscloud.todo.item.toggle.inprogress=In progress

View File

@@ -0,0 +1,7 @@
nbscloud.todo.index.title=nbscloud - todo
nbscloud.todo.widget.heading=todo \u00FCbersicht
nbscloud.todo.item.add=Todo hinzugef\u00FCgt
nbscloud.todo.item.toggle.done=Fertig
nbscloud.todo.item.toggle.inprogress=Unfertig

View File

@@ -0,0 +1,24 @@
#content-container {
display: flex;
flex-direction: row;
flex-wrap: wrap;
row-gap: 2em;
column-gap: 2em;
}
.todo-category {
flex-grow: 1;
background-color:var(--background-color-highlight);
padding: 1em;
align-self: stretch;
word-break: break-all;
flex-basis: 100%;
}
.todo-category-header {
text-decoration-line: underline;
}
.todo-item > td:nth-child(2) {
padding-inline: 3em;
}

View File

@@ -0,0 +1,24 @@
<div id="menu-container" th:fragment="menu">
<div class="menu-spacer"></div>
<div id="add-item-container" class="menu-container">
<details>
<summary>
<span id="add-item-container-span-input" class="icon menu-icon">&#xf23a;</span>
</summary>
<div class="menu-modal">
<div class="menu-modal-content">
<form method="POST" action="#" th:action="@{/todo/addItem}" enctype="multipart/form-data">
<input id="add-item-container-text" type="text" name="text" />
<select size="1" id="add-item-category" name="category">
<option th:each="category : ${categories}"
th:value="${category}"
th:text="${category}" />
</select>
<input type="date" id="add-item-due" name="due"/>
<input type="submit" value="Add"/>
</form>
</div>
</div>
</details>
</div>
</div>

View File

@@ -0,0 +1,39 @@
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title th:text="#{nbscloud.todo.index.title}"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link th:if="${darkMode}" rel="stylesheet" th:href="@{/css/darkModeColors.css}"/>
<link th:if="${!darkMode}" rel="stylesheet" th:href="@{/css/lightModeColors.css}"/>
<link rel="stylesheet" th:href="@{/css/main.css}"/>
<link rel="stylesheet" th:href="@{/css/todo_main.css}"/>
<link rel="shortcut icon" th:href="@{/favicon.ico}"/>
</head>
<body>
<div id="main-container">
<div th:replace="includes/header :: header"/>
<div th:replace="includes/messages :: messages"/>
<div th:replace="todo/includes/menu :: menu"/>
<div id="content-container">
<div class="todo-category" th:each="entry : ${todosByCategory}">
<p th:text="${#strings.prepend(#strings.append(entry.key, #strings.repeat('&nbsp;', 10)), '&nbsp;')}"
class="todo-category-header"/>
<table id="todo-item-table">
<tr class="todo-item" th:each="todo : ${entry.value}">
<td>
<form method="POST" th:action="@{/todo/toggle}" enctype="multipart/form-data">
<input type="checkbox" th:checked="${todo.done}" name="done" onClick="this.form.submit()" /> <!-- fucking javascript -->
<input type="hidden" name="id" th:value="${todo.id}" class="display-none"/>
</form>
</td>
<td th:text="${todo.text}"/>
<td th:text="${todo.due}"/>
</tr>
</table>
</div>
</div>
<div th:replace="includes/footer :: footer"/>
</div>
</body>
</html>

View File

@@ -0,0 +1,12 @@
<div id="todo-widget" class="widget" th:fragment="todo-overview">
<p class="widget-heading" th:text="#{nbscloud.todo.widget.heading}"/>
<table id="todo-widget-table">
<tbody>
<tr th:each="todo : ${todos}">
<td th:text="${todo.category}"/>
<td th:text="${todo.text}"/>
<td th:text="${todo.due}"/>
</tr>
</tbody>
</table>
</div>

View File

@@ -1,54 +1,16 @@
#!/bin/bash
workspace_loc=$1
echo "$workspace_loc"
version=$1
pwd
echo "Create required directories"
mkdir -p ~/.local/share/java/nbscloud/
mkdir -p ~/.config/nbscloud/
mkdir -p ~/nbscloud
deploy_tmp="deploy_tmp"
target_loc="/opt/nbscloud"
echo "Download nbscloud version $version to ~/.local/share/java/nbscloud/nbscloud.jar"
app_props_loc="$workspace_loc/web-container/target/classes/config/application.properties"
files_prop_loc="$workspace_loc/files/target/classes/config/files-application.properties"
wget -qO ~/.local/share/java/nbscloud/nbscloud.jar http://192.168.10.4:8100/releases/de/77zzcx7/nbs-cloud/web-container/"$version"/web-container-"$version".jar
echo "$app_props_loc"
echo "$files_prop_loc"
user=$(grep "nbscloud.deploy.service.user" "$app_props_loc" | cut -d'=' -f2)
echo "$user"
deploy_path=$(grep "nbscloud.deploy.path" "$app_props_loc" | cut -d'=' -f2)
echo "$deploy_path"
base_dir=$(grep "nbs-cloud.files.baseDir" "$files_prop_loc" | cut -d'=' -f2)
echo "$base_dir"
cd tools
mkdir $deploy_tmp
cp "template-nbscloud.service" $deploy_tmp"/nbscloud.service"
cd $deploy_tmp
sed -i "s|USER_R|$user|g" nbscloud.service
sed -i "s|DEPLOY_PATH_R|$deploy_path|g" nbscloud.service
sed -i "s|BASE_DIR_R|$base_dir|g" nbscloud.service
echo ""
cat nbscloud.service
echo ""
cp nbscloud.service $target_loc"/nbscloud.service"
echo "Copied service file"
war_path=$(find "$workspace_loc/web-container/target/" -name "*.war")
echo "$war_path"
cp "$war_path" "$target_loc"/nbscloud.war
echo "Copied war"
echo "Finished deployment"
echo ""
echo "Manually reload service files via 'systemctl --user daemon-reload'"
echo "Then, enable the service 'systemctl --user enable --now nbscloud.service'"
echo "Or, restart the service 'systemctl --user restart nbscloud.service'"
echo "Reload and restart service"
systemctl --user daemon-reload
systemctl --user restart nbscloud

View File

@@ -1,30 +1,19 @@
[unit]
[Unit]
Description=NoBullShit-cloud - A personal cloud without bullshit
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
DynamicUser=true
# Values substituted from application.properties
User=USER_R
Group=USER_R
ExecStart=java -jar DEPLOY_PATH_R/nbscloud.jar
ReadWritePaths=BASE_DIR_R
ExecStart=java -jar %h/.local/share/java/nbscloud/nbscloud.jar --spring.config.additional-location="file://%h/.config/nbscloud/application.properties"
ReadWritePaths=%h/nbscloud
ReadOnlyPaths=%h/.local/share/java/nbscloud/nbscloud.jar
ReadOnlyPaths=%h/.config/nbscloud/application.properties
# Hardening
CapabilityBoundingSet=
AmbientCapabilities=
NoNewPrivileges=true
ProtectHome=true
ProtectSystem=full
PrivateDevices=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
PrivateTmp=true
LockPersonality=true
[Install]
WantedBy=multi-user.target

View File

@@ -5,7 +5,7 @@
<parent>
<artifactId>nbs-cloud-aggregator</artifactId>
<groupId>de.77zzcx7.nbs-cloud</groupId>
<version>6</version>
<version>30-SNAPSHOT</version>
</parent>
<artifactId>web-container-config</artifactId>

View File

@@ -0,0 +1,16 @@
package de.nbscloud.webcontainer.shared.util;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.Optional;
public class ControllerUtils {
public static LocalDate parseDate(String date) {
// The format is always "yyyy-MM-dd", see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/date
return Optional.ofNullable(date)
.map(ed -> ed.isBlank() ? null : ed)
.map(ed -> LocalDate.parse(ed, DateTimeFormatter.ofPattern("yyyy-MM-dd")))
.orElse(null);
}
}

View File

@@ -5,7 +5,7 @@
<parent>
<artifactId>nbs-cloud-aggregator</artifactId>
<groupId>de.77zzcx7.nbs-cloud</groupId>
<version>6</version>
<version>30-SNAPSHOT</version>
</parent>
<artifactId>web-container-registry</artifactId>
@@ -16,5 +16,10 @@
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<scope>provided</scope>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,51 @@
package de.nbscloud.webcontainer;
import org.springframework.stereotype.Component;
import org.springframework.ui.Model;
import org.springframework.web.context.annotation.SessionScope;
import java.util.ArrayList;
import java.util.List;
@Component
@SessionScope
public class MessageHelper {
// We have to temporarily store messages as we redirect: in some methods
// so everything we add to the model will be gone, that's why we store messages
// temporarily in here
private final List<String> errors = new ArrayList<>(); // not resolved against a bundle
private final List<String> resolvableErrors = new ArrayList<>(); // messages that are resolved against a bundle
private final List<String> shareInfo = new ArrayList<>();
private final List<String> infoMessages = new ArrayList<>();
public void addAndClearAll(Model model) {
model.addAttribute("errors", getAndClear(this.errors));
model.addAttribute("infoMessages", getAndClear(this.infoMessages));
model.addAttribute("shareInfo", getAndClear(this.shareInfo));
model.addAttribute("resolvableErrors", getAndClear(this.resolvableErrors));
}
private static List<String> getAndClear(List<String> source) {
final List<String> retList = new ArrayList<>(source);
source.clear();
return retList;
}
public void addError(String message) {
this.errors.add(message);
}
public void addResolvableError(String message) {
this.resolvableErrors.add(message);
}
public void addShare(String message) {
this.shareInfo.add(message);
}
public void addInfo(String message) {
this.infoMessages.add(message);
}
}

View File

@@ -1,5 +1,8 @@
package de.nbscloud.webcontainer.registry;
import java.util.Collection;
import java.util.Collections;
public interface App {
// New app:
// 1) Create module
@@ -15,4 +18,8 @@ public interface App {
String getStartPath();
int getIndex();
default Collection<Widget> getWidgets() {
return Collections.EMPTY_LIST;
}
}

View File

@@ -0,0 +1,5 @@
package de.nbscloud.webcontainer.registry;
public interface Widget {
String getPath();
}

View File

@@ -5,7 +5,7 @@
<parent>
<groupId>de.77zzcx7.nbs-cloud</groupId>
<artifactId>nbs-cloud-aggregator</artifactId>
<version>6</version>
<version>30-SNAPSHOT</version>
</parent>
<groupId>de.77zzcx7.nbs-cloud</groupId>
@@ -74,6 +74,10 @@
<artifactId>spring-boot-starter-tomcat</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- Misc -->
<dependency>

View File

@@ -2,10 +2,21 @@ package de.nbscloud.webcontainer;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.ALWAYS);
return http.build();
}
}

View File

@@ -9,7 +9,7 @@ info.build.group=@project.groupId@
info.build.artifact=@project.artifactId@
info.build.version=@project.version@
spring.messages.basename=i18n/container_messages,i18n/files_messages
spring.messages.basename=i18n/container_messages,i18n/files_messages,i18n/dashboard_messages,i18n/notes_messages,i18n/todo_messages
spring.servlet.multipart.max-file-size=-1
spring.servlet.multipart.max-request-size=-1
@@ -20,6 +20,4 @@ logging.level.de.nbscloud=DEBUG
server.servlet.context-path=/nbscloud
server.port=9966
nbscloud.deploy.service.user=marius
nbscloud.deploy.path=/opt/nbscloud
server.address=localhost

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -1,2 +1,41 @@
v19:
- #22 Fix a bug with password protected shares
- Add Apache httpd config example
- #2 Add folder sharing
v18:
- #22 Password protected shares
- Basic Note app implementation
- Files app now offers API for other apps to store files
v17:
- Disk usage widget
- Pending updates widget
- Network interfaces widget
v14 -> v15:
- Same as v14, but the release build of that failed
v13 -> v14:
- #19 Release & deployment v2
v12 - v13:
- #18 Add profiles
v11 - v12:
- #18 Add profiles
v10 - v11:
- #18 Add profiles
v9 -> v10:
- #18 Add profiles
v2..v9:
- Test releases
- #8 Add Jenkins jobs
- #9 Add systemd user service
- #15 Make releasing easier
v1:
- Initial

View File

@@ -3,6 +3,7 @@
--error-color: #D30000;
--text-color: #7f7f7f;
--background-color: #1d1f21;
--background-color-highlight: #1d2121;
--link-color: #87ab63;
--hover-color: #1f1f2f;
--border-color: #7f7f7f;

View File

@@ -3,6 +3,7 @@
--error-color: #D30000;
--text-color: #000000;
--background-color: #FFFFFF;
--background-color-highlight: darkgrey;
--link-color: #0000EE;
--hover-color: lightgrey;
--border-color: #ddd;

View File

@@ -92,4 +92,107 @@ tr:hover {
display: inline;
padding-right: 1em;
}
}
#menu-container {
padding-top: 2em;
margin-left: 3em;
}
.menu-icon {
background: none !important;
border: none;
padding: 0 !important;
color: var(--text-color);
cursor: pointer;
font-size: 2em;
}
.menu-container > details > summary {
list-style-type: none;
}
.menu-container > details[open] > summary {
list-style-type: none;
}
.menu-icon:hover {
color: var(--link-color);
}
.menu-spacer {
width: 2em;
}
@media only screen and (max-width: 450px) {
#menu-container {
padding-top: 1em;
margin-left: 1em;
}
.menu-spacer {
width: 1em;
}
}
#menu-container > div {
display: inline-block !important;
}
#content-container {
padding-top: 2em;
}
.display-none {
display: none;
}
.messageContainer {
width: 100%;
display: flex;
flex-direction: row;
justify-content: center;
}
.message {
margin-top: 2em;
border: 1px;
border-style: solid;
border-radius: 0.3em;
padding: 1em;
display: inline-block;
}
.shareMessage {
border-color: var(--good-color) !important;
}
.infoMessage {
border-color: var(--good-color) !important;
}
.errorMessage {
border-color: var(--error-color) !important;
}
.menu-modal-content {
padding: 0.5em;
pointer-events: all;
display: inline;
}
.menu-modal {
background: var(--background-color);
filter: brightness(var(--background-brightness));
border-radius: 0.3em;
left: auto;
top: auto;
right: auto;
bottom: auto;
padding: 0;
pointer-events: none;
position: absolute;
display: flex;
flex-direction: column;
z-index: 100;
}

View File

@@ -1,5 +1,7 @@
<div id="header-container" th:fragment="header">
<div th:each="app : ${apps}" class="menu-entry">
<a th:href="@{/} + ${app.startPath}" th:text="${app.id}"/>
<div th:if="${restricted == null || restricted == false}">
<div th:each="app : ${apps}" class="menu-entry">
<a th:href="@{/} + ${app.startPath}" th:text="${app.id}"/>
</div>
</div>
</div>

View File

@@ -0,0 +1,23 @@
<div id="messages-container" th:fragment="messages">
<div class="messageContainer" th:each="shareInfo : ${shareInfo}">
<div th:if="${!shareInfo.isEmpty()}" class="shareMessage message">
<span th:text="#{'nbscloud.files.share-message'}"/>
<a th:href="@{${shareInfo}}" th:text="${shareInfo}"/>
</div>
</div>
<div class="messageContainer" th:each="error : ${errors}">
<div th:if="${!errors.isEmpty()}" class="errorMessage message">
<span th:text="${error}"/>
</div>
</div>
<div class="messageContainer" th:each="error : ${resolvableErrors}">
<div th:if="${!resolvableErrors.isEmpty()}" class="errorMessage message">
<span th:text="#{${error}}"/>
</div>
</div>
<div class="messageContainer" th:each="infoMessage : ${infoMessages}">
<div th:if="${!infoMessages.isEmpty()}" class="infoMessage message">
<span th:text="#{${infoMessage}}"/>
</div>
</div>
</div>