1
0

47 Commits

Author SHA1 Message Date
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
48 changed files with 977 additions and 140 deletions

View File

@@ -16,4 +16,60 @@ files:
|----|-----------|
|[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|
|[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>18</version>
<version>27-SNAPSHOT</version>
</parent>
<groupId>de.77zzcx7.nbs-cloud</groupId>

13
build/Dockerfile Normal file
View File

@@ -0,0 +1,13 @@
FROM eclipse-temurin:25-jre-alpine
# Create a non-root user for security
RUN addgroup -S spring && adduser -S spring -G spring
USER spring:spring
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 = registryUrl.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>18</version>
<version>27-SNAPSHOT</version>
</parent>
<groupId>de.77zzcx7.nbs-cloud</groupId>

View File

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

View File

@@ -65,7 +65,7 @@ public interface FilesService {
}
}
void createAppDirectory(App app);
void createAppDirectoryIfNotExists(App app);
void createDirectory(App app, Path path);
@@ -81,7 +81,7 @@ public interface FilesService {
* 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 {@link Optional#EMPTY} the appDir is used as start dir. If not empty, path has to be relative
* @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);

View File

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

View File

@@ -6,10 +6,12 @@ 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);

View File

@@ -88,6 +88,7 @@ public class FileSystemService {
private MimeTypeDetector mimeTypeDetector = new MimeTypeDetector();
public Path createDirectory(String name) {
try {
return Files.createDirectories(this.locationTracker.resolve(name));
} catch (IOException e) {
@@ -96,6 +97,8 @@ public class FileSystemService {
}
void createDirectory(Path path) {
this.locationTracker.ensureValidPath(path);
try {
Files.createDirectories(path);
} catch (IOException e) {
@@ -104,14 +107,12 @@ public class FileSystemService {
}
public void createFile(String name, byte[] content) {
try {
Files.write(this.locationTracker.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) {
@@ -120,6 +121,8 @@ public class FileSystemService {
}
public void overwriteFile(Path path, byte[] content) {
this.locationTracker.ensureValidPath(path);
try {
Files.write(path, content, StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING);
} catch (IOException e) {
@@ -128,6 +131,9 @@ public class FileSystemService {
}
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) {
@@ -136,15 +142,12 @@ public class FileSystemService {
}
public boolean delete(String name) {
try {
// TODO does only delete dirs if they are empty - but maybe we want that?
return Files.deleteIfExists(this.locationTracker.resolve(name));
} catch (IOException e) {
throw new FileSystemServiceException("Could not delete file", e);
}
return delete(this.locationTracker.resolve(name));
}
public boolean delete(Path path) {
this.locationTracker.ensureValidPath(path);
try {
// TODO does only delete dirs if they are empty - but maybe we want that?
return Files.deleteIfExists(path);
@@ -154,6 +157,8 @@ public class FileSystemService {
}
public byte[] get(Path path) {
this.locationTracker.ensureValidPath(path);
try {
return Files.readAllBytes(path);
} catch (IOException e) {
@@ -170,6 +175,8 @@ public class FileSystemService {
}
public void stream(Path targetPath, ZipOutputStream outputStream) {
this.locationTracker.ensureValidPath(targetPath);
try {
Files.walkFileTree(targetPath, new SimpleFileVisitor<Path>() {
@Override
@@ -198,14 +205,12 @@ public class FileSystemService {
}
public InputStream stream(String name) {
try {
return Files.newInputStream(this.locationTracker.resolve(name));
} catch (IOException e) {
throw new FileSystemServiceException("Could not stream file", e);
}
return stream(this.locationTracker.resolve(name));
}
private InputStream stream(Path name) {
public InputStream stream(Path name) {
this.locationTracker.ensureValidPath(name);
try {
return Files.newInputStream(name);
} catch (IOException e) {
@@ -214,18 +219,16 @@ public class FileSystemService {
}
public long getSize(String name) {
try {
return Files.size(this.locationTracker.resolve(name));
} catch (IOException e) {
throw new FileSystemServiceException("Could not get file", e);
}
return getSize(this.locationTracker.resolve(name));
}
public long getSize(Path name) {
this.locationTracker.ensureValidPath(name);
try {
return Files.size(name);
} catch (IOException e) {
throw new FileSystemServiceException("Could not get file", e);
throw new FileSystemServiceException("Could not get file size", e);
}
}
@@ -258,10 +261,16 @@ public class FileSystemService {
}
public String getMimeType(String name) {
try {
final String detectedMimeType = this.mimeTypeDetector.detectMimeType(this.locationTracker.resolve(name));
return getMimeType(this.locationTracker.resolve(name));
}
logger.debug("Detected mime type {} for file {}", detectedMimeType, 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) {
@@ -295,6 +304,8 @@ public class FileSystemService {
}
List<ContentContainer> list(Path startPath, SortOrder sortOrder, Function<Path, Path> relativizer) {
this.locationTracker.ensureValidPath(startPath);
try {
List<ContentContainer> contentList = Files.list(startPath)
.filter(path -> {
@@ -330,6 +341,8 @@ public class FileSystemService {
}
ContentTree getTree(Path startPath, SortOrder sortOrder, Function<Path, Path> relativizer) {
this.locationTracker.ensureValidPath(startPath);
try {
if (!Files.isDirectory(startPath)) {
return null;
@@ -390,6 +403,12 @@ public class FileSystemService {
}
}
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));
}
@@ -399,6 +418,9 @@ public class FileSystemService {
}
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 boolean sourceIsDir = Files.isDirectory(sourcePath);

View File

@@ -5,12 +5,14 @@ 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;
@@ -26,7 +28,7 @@ public class FilesServiceImpl implements FilesService {
}
@Override
public void createAppDirectory(App app) {
public void createAppDirectoryIfNotExists(App app) {
try {
this.fileSystemService.createDirectory(resolve(app, Path.of("")));
}

View File

@@ -5,12 +5,16 @@ 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
@SessionScope
public class LocationTracker implements InitializingBean {
private static final Logger logger = LoggerFactory.getLogger(LocationTracker.class);
@@ -31,11 +35,11 @@ public class LocationTracker implements InitializingBean {
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());
try {
afterPropertiesSet();
@@ -44,6 +48,14 @@ public class LocationTracker implements InitializingBean {
}
}
public void resetCurrentLocation() {
this.currentLocation = this.baseDirPath.resolve("");
}
private long id() {
return System.identityHashCode(this);
}
Path getCurrentLocation() {
return this.currentLocation;
}
@@ -79,7 +91,19 @@ public class LocationTracker implements InitializingBean {
public void setCurrentLocation(String 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;
}
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) {
@@ -106,6 +130,25 @@ public class LocationTracker implements InitializingBean {
}
}
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);

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

@@ -4,6 +4,7 @@ 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;
@@ -14,6 +15,7 @@ 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;
@@ -22,7 +24,6 @@ import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.InputStreamResource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
@@ -37,15 +38,12 @@ 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.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@@ -70,6 +68,8 @@ public class FilesController implements InitializingBean {
private ObjectMapper mapper;
@Autowired
private MessageHelper messageHelper;
@Autowired
private SessionInfo sessionInfo;
@GetMapping("/files/browse/**")
public String start(Model model, HttpServletRequest httpServletRequest, String sortOrder) {
@@ -82,6 +82,8 @@ public class FilesController implements InitializingBean {
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";
@@ -119,6 +121,8 @@ public class FilesController implements InitializingBean {
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";
@@ -146,6 +150,11 @@ public class FilesController implements InitializingBean {
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 downloadFile(HttpServletResponse response, String filename) {
final Path targetPath = this.locationTracker.resolve(filename);
@@ -223,6 +232,8 @@ public class FilesController implements InitializingBean {
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";
@@ -237,11 +248,6 @@ public class FilesController implements InitializingBean {
final Path filePath = this.locationTracker.getRelativeToBaseDir(this.locationTracker.resolve(filename));
final String shareUuid = UUID.randomUUID().toString();
final Share share = new Share();
// The format is always "yyyy-MM-dd", see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/date
final LocalDate expiryDate = Optional.ofNullable(expiryDateString)
.map(ed -> ed.isBlank() ? null : ed)
.map(ed -> LocalDate.parse(ed, DateTimeFormatter.ofPattern("yyyy-MM-dd")))
.orElse(null);
if(StringUtils.isEmpty(password)) {
password = null; // if no real password has been provided assume no password
@@ -249,7 +255,7 @@ public class FilesController implements InitializingBean {
share.setUuid(shareUuid);
share.setPath(filePath.toString());
share.setExpiryDate(expiryDate);
share.setExpiryDate(ControllerUtils.parseDate(expiryDateString));
share.setOneTime(oneTime);
share.setPassword(password);
@@ -268,22 +274,56 @@ public class FilesController implements InitializingBean {
return "redirect:/files/browse/" + this.locationTracker.getRelativeLocation();
}
@GetMapping("/files/shares/files/browse/**")
public String sharesStart(Model model, HttpServletRequest httpServletRequest, String sortOrder) {
if(!this.sessionInfo.isRestrictedSession()) {
throw new IllegalStateException();
}
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 ResponseEntity shares(String shareUuid) {
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) {
// If there is a password check nothing else, so there is no information leak if the remote doesn't know the password
final HttpHeaders headers = new HttpHeaders();
model.addAttribute("form", new PasswordForm(shareUuid, null));
this.webContainerSharedConfig.addDefaults(model);
headers.add("Location", "shares/passwordCheck?shareUuid=" + shareUuid);
return new ResponseEntity<String>(headers, HttpStatus.FOUND);
return "files/checkPassword";
}
else {
return doShares(shareUuid);
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);
@@ -292,33 +332,43 @@ public class FilesController implements InitializingBean {
}
}
@GetMapping("files/shares/passwordCheck")
public String passwordCheck(Model model, String shareUuid) {
this.messageHelper.addAndClearAll(model);
model.addAttribute("form", new PasswordForm(shareUuid, null));
this.webContainerSharedConfig.addDefaults(model);
return "files/checkPassword";
}
@PostMapping("/files/shares/checkPassword")
public ResponseEntity checkPassword(@RequestParam(value = "shareUuid", required = true) String shareUuid,
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)) {
return doShares(shareUuid);
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);
final HttpHeaders headers = new HttpHeaders();
model.addAttribute("form", new PasswordForm(shareUuid, null));
this.webContainerSharedConfig.addDefaults(model);
headers.add("Location", "passwordCheck?shareUuid=" + shareUuid);
return new ResponseEntity<String>(headers, HttpStatus.FOUND);
return "files/checkPassword";
}
} catch (RuntimeException | JsonProcessingException e) {
logger.error("Could not get shared file", e);
@@ -333,20 +383,16 @@ public class FilesController implements InitializingBean {
final Share share = mapper.readValue(shareJson, Share.class);
final Path sharedFilePath = this.locationTracker.resolveToBaseDir(share.getPath());
final String filename = sharedFilePath.getFileName().toString();
final Optional<LocalDate> optExpiryDate = Optional.ofNullable(share.getExpiryDate());
final boolean expired = optExpiryDate.map(expiryDate -> LocalDate.now().isAfter(expiryDate)).orElse(false);
if (expired) {
fileSystemService.delete(locationTracker.resolveShare(shareUuid));
return ResponseEntity.status(410).body("Share expired!");
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(filename))
.header(HttpHeaders.CONTENT_LENGTH, String.valueOf(this.fileSystemService.getSize(filename)))
.body(new InputStreamResource(new ObservableInputStream(this.fileSystemService.stream(filename), new ObservableInputStream.Observer() {
.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()) {
@@ -361,6 +407,15 @@ public class FilesController implements InitializingBean {
}
}
@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 {
@@ -374,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 {
@@ -381,6 +449,8 @@ 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";
@@ -415,7 +485,12 @@ 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();
}
}
}
@@ -437,23 +512,23 @@ public class FilesController implements InitializingBean {
@Override
public void afterPropertiesSet() throws Exception {
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

@@ -49,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)}"
@@ -67,25 +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 id="files-content-table-show-actions-detail-container-download">
<a th:href="@{/files/download(filename=${fileEntry.name})}"
<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,6 +1,6 @@
<div id="menu-container" th:fragment="menu">
<div class="menu-spacer"></div>
<div id="upload-container" class="menu-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()"
@@ -9,8 +9,8 @@
onclick="document.getElementById('upload-container-file-input').click();">&#xe9fc;</span>
</form>
</div>
<div class="menu-spacer"></div>
<div id="create-dir-container" class="menu-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 menu-icon">&#xe2cc;</span>
@@ -28,7 +28,7 @@
</div>
<div class="menu-spacer"></div>
<div>
<a id="gallery-link" th:href="@{/files/gallery}">
<a id="gallery-link" th:href="@{__${prefix}__/files/gallery}">
<span class="icon menu-icon">&#xe3b6;</span>
</a>
</div>

View File

@@ -21,7 +21,7 @@
<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="text" id="password" th:field="*{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"/>

View File

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

View File

@@ -20,7 +20,7 @@ import java.nio.file.Path;
import java.util.Optional;
@Controller
public class NotesController implements InitializingBean {
public class NotesController {
private static final Logger logger = LoggerFactory.getLogger(NotesController.class);
@Autowired
@@ -42,6 +42,8 @@ public class NotesController implements InitializingBean {
@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()) {
@@ -51,7 +53,7 @@ public class NotesController implements InitializingBean {
}
this.messageHelper.addAndClearAll(model);
model.addAttribute("currentNote", notePath);
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());
@@ -121,9 +123,4 @@ public class NotesController implements InitializingBean {
return "redirect:?notePath=" + currentPath;
}
@Override
public void afterPropertiesSet() throws Exception {
this.filesService.createAppDirectory(this.app);
}
}

View File

@@ -4,4 +4,6 @@ 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.createNote.success=Note created
nbscloud.notes.index.title=nbscloud - notes\:\u0020

View File

@@ -4,4 +4,6 @@ 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.createNote.success=Notiz erstellt
nbscloud.notes.index.title=nbscloud - Notizen\:\u0020

View File

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

16
pom.xml
View File

@@ -10,7 +10,7 @@
<groupId>de.77zzcx7.nbs-cloud</groupId>
<artifactId>nbs-cloud-aggregator</artifactId>
<version>18</version>
<version>27-SNAPSHOT</version>
<packaging>pom</packaging>
<description>The umbrella for all No BullShit cloud projects</description>
<name>nbs-cloud-aggregator</name>
@@ -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>v18</tag>
</scm>
<tag>v22</tag>
</scm>
<dependencyManagement>
<dependencies>

View File

@@ -5,7 +5,7 @@
<parent>
<artifactId>nbs-cloud-aggregator</artifactId>
<groupId>de.77zzcx7.nbs-cloud</groupId>
<version>18</version>
<version>27-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

@@ -5,7 +5,7 @@
<parent>
<artifactId>nbs-cloud-aggregator</artifactId>
<groupId>de.77zzcx7.nbs-cloud</groupId>
<version>18</version>
<version>27-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>18</version>
<version>27-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

@@ -2,11 +2,13 @@ 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

View File

@@ -5,7 +5,7 @@
<parent>
<groupId>de.77zzcx7.nbs-cloud</groupId>
<artifactId>nbs-cloud-aggregator</artifactId>
<version>18</version>
<version>27-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,i18n/dashboard_messages,i18n/notes_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

View File

@@ -1,5 +1,10 @@
v19:
- #22 Fix a bug with password protected shares
- Add Apache httpd config example
- #2 Add folder sharing
v18:
- Password protected shares
- #22 Password protected shares
- Basic Note app implementation
- Files app now offers API for other apps to store files

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>