diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 1c66102..5d42bc0 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -6,6 +6,9 @@ on: - master tags: - 'v*' + pull_request: + branches: + - master workflow_dispatch: env: @@ -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 @@ -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 }} diff --git a/Dockerfile b/Dockerfile index cd2c1f0..b0db734 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/README.md b/README.md index f9a1ce2..be171c4 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/conf/local/application.properties b/conf/local/application.properties index 439c8ae..37d9d77 100644 --- a/conf/local/application.properties +++ b/conf/local/application.properties @@ -1 +1 @@ -davos.version=2.2.2 +davos.version=2.3.0 diff --git a/conf/release/application.properties b/conf/release/application.properties index 6442006..ae29bf5 100644 --- a/conf/release/application.properties +++ b/conf/release/application.properties @@ -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 diff --git a/src/main/java/io/linuxserver/davos/delegation/services/HostService.java b/src/main/java/io/linuxserver/davos/delegation/services/HostService.java index 9407003..f291a13 100644 --- a/src/main/java/io/linuxserver/davos/delegation/services/HostService.java +++ b/src/main/java/io/linuxserver/davos/delegation/services/HostService.java @@ -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 fetchAllHosts(); - + Host fetchHost(Long id); - + Host saveHost(Host host); - + void deleteHost(Long id); - + List fetchSchedulesUsingHost(Long id); - + void testConnection(Host host); + + DirectoryListing browseDirectory(Long hostId, String path); } diff --git a/src/main/java/io/linuxserver/davos/delegation/services/HostServiceImpl.java b/src/main/java/io/linuxserver/davos/delegation/services/HostServiceImpl.java index 6e6fdb0..5f0f0cd 100644 --- a/src/main/java/io/linuxserver/davos/delegation/services/HostServiceImpl.java +++ b/src/main/java/io/linuxserver/davos/delegation/services/HostServiceImpl.java @@ -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 { @@ -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 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); } } diff --git a/src/main/java/io/linuxserver/davos/delegation/services/LocalFileService.java b/src/main/java/io/linuxserver/davos/delegation/services/LocalFileService.java new file mode 100644 index 0000000..028b2b3 --- /dev/null +++ b/src/main/java/io/linuxserver/davos/delegation/services/LocalFileService.java @@ -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 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; + } +} diff --git a/src/main/java/io/linuxserver/davos/web/controller/APIController.java b/src/main/java/io/linuxserver/davos/web/controller/APIController.java index 0bb56b8..9891d93 100644 --- a/src/main/java/io/linuxserver/davos/web/controller/APIController.java +++ b/src/main/java/io/linuxserver/davos/web/controller/APIController.java @@ -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; @@ -41,6 +42,9 @@ public class APIController { @Resource private SettingsService settingsService; + @Resource + private LocalFileService localFileService; + @RequestMapping(value = "/schedule", method = RequestMethod.POST) public ResponseEntity createSchedule(@RequestBody Schedule schedule) { @@ -203,6 +207,36 @@ public ResponseEntity testConnection(@RequestBody Host host) { return ResponseEntity.status(status).body(response); } + @RequestMapping(value = "/host/{id}/directories", method = RequestMethod.GET) + public ResponseEntity 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 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 setLogLevel(@RequestParam("level") LogLevelSelector level) { diff --git a/src/main/java/io/linuxserver/davos/web/controller/response/DirectoryListing.java b/src/main/java/io/linuxserver/davos/web/controller/response/DirectoryListing.java new file mode 100644 index 0000000..0a3aca5 --- /dev/null +++ b/src/main/java/io/linuxserver/davos/web/controller/response/DirectoryListing.java @@ -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 directories; + + public DirectoryListing(String path, String parent, List 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; + } + } +} diff --git a/src/main/resources/static/js/davos.js b/src/main/resources/static/js/davos.js index 3e4dbfa..dace9e6 100644 --- a/src/main/resources/static/js/davos.js +++ b/src/main/resources/static/js/davos.js @@ -521,8 +521,132 @@ var interval = (function($) { }(jQuery)); +var browser = (function($, settings) { + + 'use strict'; + + var initialise, open, load, render, state; + + state = { + mode: null, // 'remote' or 'local' + targetInput: null, // jQuery input to populate on select + currentPath: null + }; + + initialise = function() { + + $('#browseHostDirectory').on('click', function() { + + var hostId = $('#host option:checked').attr('value'); + + if (!hostId) { + settings.notify('danger', 'Please select a host first.', 'glyphicon-warning-sign'); + return; + } + + open('remote', $('#hostDirectory'), 'Browse host directory'); + }); + + $('#browseLocalDirectory').on('click', function() { + open('local', $('#localDirectory'), 'Browse local directory'); + }); + + $('#directoryBrowserList').on('click', '.directory-entry', function(e) { + e.preventDefault(); + load($(this).attr('data-path')); + }); + + $('#directoryBrowserSelect').on('click', function() { + + if (null !== state.currentPath) { + state.targetInput.val(state.currentPath); + state.targetInput.parents('.form-group').removeClass('has-error'); + } + + $('#directoryBrowserModal').modal('hide'); + }); + }; + + open = function(mode, targetInput, title) { + + state.mode = mode; + state.targetInput = targetInput; + state.currentPath = null; + + $('#directoryBrowserTitle').text(title); + $('#directoryBrowserPath').text(''); + $('#directoryBrowserList').empty(); + $('#directoryBrowserEmpty').addClass('hide'); + + $('#directoryBrowserModal').modal('show'); + + load($.trim(targetInput.val())); + }; + + load = function(path) { + + var url; + + if ('remote' === state.mode) { + var hostId = $('#host option:checked').attr('value'); + url = '/api/v2/host/' + hostId + '/directories'; + } else { + url = '/api/v2/browse/local'; + } + + if (path && path.length > 0) { + url += '?path=' + encodeURIComponent(path); + } + + $.ajax({ + method: 'GET', + url: url, + dataType: 'json' + }).done(function(msg) { + render(msg.body); + }).fail(function(msg) { + var reason = (msg.responseJSON && msg.responseJSON.body) ? msg.responseJSON.body : 'Unable to list directory'; + settings.notify('danger', 'There was an error: ' + reason, 'glyphicon-warning-sign'); + }); + }; + + render = function(listing) { + + state.currentPath = listing.path; + $('#directoryBrowserPath').text(listing.path); + + var $list = $('#directoryBrowserList').empty(); + + var entry = function(path, iconClass, label) { + + return $('', { + 'href': '#', + 'class': 'list-group-item directory-entry' + }).attr('data-path', path) + .append($('', { 'class': 'glyphicon ' + iconClass })) + .append(document.createTextNode(' ' + label)); + }; + + if (null !== listing.parent) { + $list.append(entry(listing.parent, 'glyphicon-arrow-up', '..')); + } + + $.each(listing.directories, function(index, dir) { + $list.append(entry(dir.path, 'glyphicon-folder-close', dir.name)); + }); + + $('#directoryBrowserEmpty').toggleClass('hide', listing.directories.length > 0 || null !== listing.parent); + }; + + return { + init: initialise + }; + +}(jQuery, settings)); + jQuery(document).ready(host.init); jQuery(document).ready(schedule.init); jQuery(document).ready(fragments.init); jQuery(document).ready(settings.init); jQuery(document).ready(interval.init); +jQuery(document).ready(browser.init); diff --git a/src/main/resources/templates/v2/edit-schedule.html b/src/main/resources/templates/v2/edit-schedule.html index 5499788..bbcc5b9 100644 --- a/src/main/resources/templates/v2/edit-schedule.html +++ b/src/main/resources/templates/v2/edit-schedule.html @@ -84,14 +84,24 @@

General

- +
+ + + + +
- +
+ + + + +
@@ -433,6 +443,26 @@ + + diff --git a/version.txt b/version.txt index 5859406..276cbf9 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -2.2.3 +2.3.0