Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion .github/workflows/docker-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ on:
- master
tags:
- 'v*'
pull_request:
branches:
- master
workflow_dispatch:

env:
Expand All @@ -29,7 +32,11 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

# On pull requests we only build to validate that the project still
# compiles; the image is not published. Publishing happens on push to
# master, on tags, or via manual dispatch.
- name: Log in to GitHub Container Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ghcr.io
Expand All @@ -40,7 +47,7 @@ jobs:
uses: docker/build-push-action@v6
with:
context: .
push: true
push: ${{ github.event_name != 'pull_request' }}
tags: |
${{ env.IMAGE_NAME }}:latest
${{ env.IMAGE_NAME }}:${{ steps.version.outputs.value }}
Expand Down
4 changes: 3 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@ LABEL org.opencontainers.image.title="davos" \
WORKDIR /app

# Persistent data: H2 database and logs live here (see conf/release).
VOLUME ["/config"]
# /download is where you mount the media volume that davos downloads into; it
# is also the root browsable from the schedule "Local Directory" field.
VOLUME ["/config", "/download"]

# Default Spring Boot web port.
EXPOSE 8080
Expand Down
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,28 @@ Finally, schedules can be started or stopped at any point, using the schedules l

![https://raw.githubusercontent.com/linuxserver/davos/master/docs/list.PNG](https://raw.githubusercontent.com/linuxserver/davos/master/docs/list.PNG)

# Docker

A container image is published to the GitHub Container Registry on every push to `master`:

```
docker run -d \
--name davos \
-p 8080:8080 \
-v /path/to/config:/config \
-v /path/to/downloads:/download \
ghcr.io/aerya/davos:latest
```

- `/config` holds the H2 database and logs.
- `/download` is the directory davos downloads into, and the root that the schedule **Local Directory** browser can explore. Point a schedule's local directory anywhere under `/download`.

The web UI is then available on [http://localhost:8080](http://localhost:8080).

# Changelog
- **2.3.0**
- Added a directory browser to the schedule editor. The **Host Directory** field can now browse the remote FTP/SFTP server, and the **Local Directory** field can browse the volume mounted at `/download` in the container, so folders can be picked instead of typed by hand.

- **2.2.3**
- Replaced the unmaintained `com.jcraft:jsch` 0.1.50 SSH library with the maintained `com.github.mwiede:jsch` fork. This fixes the `Algorithm negotiation fail` error when connecting via SFTP to modern SSH servers that no longer offer legacy key-exchange, host-key and cipher algorithms.
- Added a Dockerfile and a GitHub Actions workflow that publishes a container image to the GitHub Container Registry.
Expand Down
2 changes: 1 addition & 1 deletion conf/local/application.properties
Original file line number Diff line number Diff line change
@@ -1 +1 @@
davos.version=2.2.2
davos.version=2.3.0
6 changes: 5 additions & 1 deletion conf/release/application.properties
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,8 @@ spring.datasource.password=sa
spring.datasource.driverClassName=org.h2.Driver
spring.jpa.hibernate.ddl-auto=update

davos.version=2.2.3
# Root directory exposed by the "Browse" button next to a schedule's Local
# Directory field. Mount your download volume here (see Dockerfile).
davos.local.downloadRoot=/download

davos.version=2.3.0
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,21 @@
import java.util.List;

import io.linuxserver.davos.web.Host;
import io.linuxserver.davos.web.controller.response.DirectoryListing;

public interface HostService {

List<Host> fetchAllHosts();

Host fetchHost(Long id);

Host saveHost(Host host);

void deleteHost(Long id);

List<Long> fetchSchedulesUsingHost(Long id);

void testConnection(Host host);

DirectoryListing browseDirectory(Long hostId, String path);
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,15 @@
import io.linuxserver.davos.persistence.dao.HostDAO;
import io.linuxserver.davos.persistence.dao.ScheduleDAO;
import io.linuxserver.davos.persistence.model.HostModel;
import io.linuxserver.davos.transfer.ftp.FTPFile;
import io.linuxserver.davos.transfer.ftp.client.Client;
import io.linuxserver.davos.transfer.ftp.client.ClientFactory;
import io.linuxserver.davos.transfer.ftp.client.UserCredentials;
import io.linuxserver.davos.transfer.ftp.client.UserCredentials.Identity;
import io.linuxserver.davos.transfer.ftp.connection.Connection;
import io.linuxserver.davos.web.Host;
import io.linuxserver.davos.web.controller.response.DirectoryListing;
import io.linuxserver.davos.web.controller.response.DirectoryListing.DirectoryEntry;

@Component
public class HostServiceImpl implements HostService {
Expand Down Expand Up @@ -79,25 +83,81 @@ public void testConnection(Host host) {

LOGGER.info("Attempting to test connection to host", model.address);

Client client = buildClient(model);

LOGGER.debug("Making connection on port {}", model.port);
client.connect();
LOGGER.info("Connection successful.");
client.disconnect();
LOGGER.debug("Disconnected");
}

@Override
public DirectoryListing browseDirectory(Long hostId, String path) {

HostModel model = hostDAO.fetchHost(hostId);

LOGGER.info("Browsing directory {} on host {}", path, model.address);

Client client = buildClient(model);
Connection connection = client.connect();

try {

String currentPath = (null == path || path.trim().isEmpty()) ? connection.currentDirectory() : path.trim();

List<DirectoryEntry> directories = connection.listFiles(currentPath).stream()
.filter(FTPFile::isDirectory)
.filter(file -> !file.getName().equals(".") && !file.getName().equals(".."))
.sorted((a, b) -> a.getName().compareToIgnoreCase(b.getName()))
.map(file -> new DirectoryEntry(file.getName(), joinPath(currentPath, file.getName())))
.collect(Collectors.toList());

return new DirectoryListing(currentPath, parentOf(currentPath), directories);

} finally {
client.disconnect();
LOGGER.debug("Disconnected after browsing");
}
}

private Client buildClient(HostModel model) {

Client client = new ClientFactory().getClient(model.protocol);

LOGGER.debug("Credentials: {} : {}", model.username, model.password);

UserCredentials userCredentials;

if (model.isIdentityFileEnabled())
userCredentials = new UserCredentials(model.username, new Identity(model.identityFile));
else
userCredentials = new UserCredentials(model.username, model.password);

client.setCredentials(userCredentials);
client.setHost(model.address);
client.setPort(model.port);

LOGGER.debug("Making connection on port {}", model.port);
client.connect();
LOGGER.info("Connection successful.");
client.disconnect();
LOGGER.debug("Disconnected");
return client;
}

private String joinPath(String base, String name) {
return base.endsWith("/") ? base + name : base + "/" + name;
}

private String parentOf(String path) {

String normalized = path;
if (normalized.length() > 1 && normalized.endsWith("/"))
normalized = normalized.substring(0, normalized.length() - 1);

int lastSlash = normalized.lastIndexOf('/');

if (lastSlash < 0)
return null;
if (lastSlash == 0)
return normalized.equals("/") ? null : "/";

return normalized.substring(0, lastSlash);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package io.linuxserver.davos.delegation.services;

import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.List;
import java.util.stream.Collectors;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import io.linuxserver.davos.web.controller.response.DirectoryListing;
import io.linuxserver.davos.web.controller.response.DirectoryListing.DirectoryEntry;

/**
* Browses the local file system so the UI can pick a download directory. All
* navigation is constrained to a configurable root (the volume mounted into the
* container, {@code /download} by default) to avoid exposing the rest of the
* file system.
*/
@Component
public class LocalFileService {

private static final Logger LOGGER = LoggerFactory.getLogger(LocalFileService.class);

@Value("${davos.local.downloadRoot:/download}")
private String downloadRoot;

public DirectoryListing browse(String path) {

try {

File root = new File(downloadRoot).getCanonicalFile();

File target = (null == path || path.trim().isEmpty()) ? root : new File(path.trim()).getCanonicalFile();

if (!isWithinRoot(target, root)) {
LOGGER.warn("Requested path {} is outside of the allowed root {}. Falling back to root", path, downloadRoot);
target = root;
}

List<DirectoryEntry> directories = new ArrayList<>();

File[] children = target.listFiles(File::isDirectory);
if (null != children) {
directories = Arrays.stream(children)
.sorted(Comparator.comparing(File::getName, String.CASE_INSENSITIVE_ORDER))
.map(file -> new DirectoryEntry(file.getName(), file.getPath()))
.collect(Collectors.toList());
} else {
LOGGER.warn("Unable to list directory {} (does it exist and is it readable?)", target.getPath());
}

String parent = target.equals(root) ? null : target.getParentFile().getPath();

return new DirectoryListing(target.getPath(), parent, directories);

} catch (IOException e) {
throw new RuntimeException("Unable to browse local directory " + path, e);
}
}

private boolean isWithinRoot(File target, File root) {

File current = target;
while (null != current) {
if (current.equals(root))
return true;
current = current.getParentFile();
}
return false;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import org.springframework.web.bind.annotation.RestController;

import io.linuxserver.davos.delegation.services.HostService;
import io.linuxserver.davos.delegation.services.LocalFileService;
import io.linuxserver.davos.delegation.services.ScheduleService;
import io.linuxserver.davos.delegation.services.SettingsService;
import io.linuxserver.davos.exception.HostInUseException;
Expand All @@ -41,6 +42,9 @@ public class APIController {
@Resource
private SettingsService settingsService;

@Resource
private LocalFileService localFileService;

@RequestMapping(value = "/schedule", method = RequestMethod.POST)
public ResponseEntity<APIResponse> createSchedule(@RequestBody Schedule schedule) {

Expand Down Expand Up @@ -203,6 +207,36 @@ public ResponseEntity<APIResponse> testConnection(@RequestBody Host host) {
return ResponseEntity.status(status).body(response);
}

@RequestMapping(value = "/host/{id}/directories", method = RequestMethod.GET)
public ResponseEntity<APIResponse> browseHostDirectories(@PathVariable("id") Long id,
@RequestParam(value = "path", required = false) String path) {

APIResponse response = APIResponseBuilder.create();
HttpStatus status = HttpStatus.OK;

try {
response.withBody(hostService.browseDirectory(id, path));
} catch (FTPException e) {

LOGGER.error("Failed to browse host directory");
LOGGER.debug("Exception: ", e);

Throwable cause = (null != e.getCause()) ? e.getCause() : e;
response.withBody(cause.getMessage()).withStatus("Failed");
status = HttpStatus.BAD_REQUEST;
}

return ResponseEntity.status(status).body(response);
}

@RequestMapping(value = "/browse/local", method = RequestMethod.GET)
public ResponseEntity<APIResponse> browseLocalDirectories(
@RequestParam(value = "path", required = false) String path) {

return ResponseEntity.status(HttpStatus.OK)
.body(APIResponseBuilder.create().withBody(localFileService.browse(path)));
}

@RequestMapping(value = "/settings/log", method = RequestMethod.POST)
public ResponseEntity<APIResponse> setLogLevel(@RequestParam("level") LogLevelSelector level) {

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package io.linuxserver.davos.web.controller.response;

import java.util.List;

/**
* Represents the contents of a single directory when browsing either a remote
* host or the local file system. Only sub-directories are listed, since davos
* schedules only ever target directories.
*/
public class DirectoryListing {

public String path;
public String parent;
public List<DirectoryEntry> directories;

public DirectoryListing(String path, String parent, List<DirectoryEntry> directories) {
this.path = path;
this.parent = parent;
this.directories = directories;
}

public static class DirectoryEntry {

public String name;
public String path;

public DirectoryEntry(String name, String path) {
this.name = name;
this.path = path;
}
}
}
Loading
Loading