> You probably have a whole [media stack](https://github.com/relvacode/mediastack) for managing your _legitimate_ media files remotely.
>
> Sometimes though, downloads can get stuck or you want to add something manually to your Torrent client. Deluge's WebUI whilst powerful is pretty much useless on mobile devices.
>
> Introducing __Storm__
>
> A slick remote interface for Deluge that fully supports mobile devices (including as a home-screen app)
#### Usage
```
docker run --name storm \
--network deluge \
-p 8221:8221 \
-e DELUGE_RPC_HOSTNAME=deluge \
-e DELUGE_RPC_USERNAME=username \
-e DELUGE_RPC_PASSWORD=password \
ghcr.io/relvacode/storm
```
The recommended way to run Storm is with a Docker image.
You'll need a Deluge container running with a valid [auth configuration](https://dev.deluge-torrent.org/wiki/UserGuide/Authentication).
Storm needs a way to contact the Deluge RPC daemon so it's best that you create a [Docker network](https://docs.docker.com/engine/tutorials/networkingcontainers/) and attach the Storm container to that network.
Once that's setup you'll need to configure Deluge to allow remote RPC connections:
Open up `core.conf` in your Deluge configuration folder and set
```
"allow_remote": true
```
Then you can use the following environment variables to configure Storm
| Environment | Description |
| ----------- | ----------- |
| `DELUGE_RPC_HOSTNAME` | The Deluge RPC hostname |
| `DELUGE_RPC_PORT` | The Deluge RPC port |
| `DELUGE_RPC_USERNAME` | The username from Deluge auth |
| `DELUGE_RPC_PASSWORD` | The password from Deluge auth |
| `DELUGE_RPC_VERSION` | `v1` or `v2` depending on your Deluge version |
| `STORM_API_KEY` | Enable authentication for the Storm API |
| `STORM_BASE_PATH` | Set the base URL path. Defaults to `/` |
##### Security
By default, Storm does not authenticate requests made to the API. When serving Storm over the public internet you should ensure access to your Deluge daemon is properly secured.
Storm comes with a simple built-in authentication mechanism which can be enabled with the environment variable `STORM_API_KEY` or the command-line option `--api-key`.
Set this to a reasonably secure password. Any requests made to Storm must now provide the API key in the request.
You should also seriously consider the use of HTTPS over the internet, with services like LetsEncrypt it's relatively easy to get a valid SSL certificate for free.
##### Deluge Version
Deluge has a different interface between versions 1 and 2. You must set `DELUGE_RPC_VERSION` to either `v1` or `v2` based on the version you have installed. Storm defaults to `v1`.
Note that in version 2, different RPC users are not able to see torrents created by another user [(#38)](https://github.com/relvacode/storm/issues/38). If you're using multiple Deluge clients (such as the vanilla Web UI, or Sonarr, etc) you should make sure they're all using the same Deluge RPC account to connect to Deluge.
#### Development
The application is split into two parts, the frontend Angular code and the backend Go API adapter.
The backend API is presented as an HTTP REST api which talks directly to the Deluge RPC daemon.
It also has the frontend code embedded directly into the binary so the application can be distributed as a single binary.
To start a development environment you must first install the node modules
```
cd frontend
npm install
```
Then start the Angular build server in watch mode. This will output the frontend distributable into `frontend/dist` and watch for changes.
```
cd frontend
npm run build -- --watch --configuration=development
```
In a separate terminal you can now start the main Storm binary in development mode.
In development mode, instead of serving the frontend from the binary embedded source it will instead serve directly from the filesystem.
```
go run github.com/relvacode/storm/cmd/storm --listen=127.0.0.1:8221 --dev-mode [OPTIONS...]
```
================================================
FILE: api.go
================================================
package storm
import (
"crypto/subtle"
"encoding/base64"
"fmt"
deluge "github.com/gdm85/go-libdeluge"
"github.com/gorilla/mux"
"github.com/spf13/afero"
"go.uber.org/zap"
"html/template"
"io"
"io/fs"
"net/http"
"os"
"strings"
"time"
)
const (
ApiAuthCookieName = "storm-api-key"
)
func appendSuffix(s, suffix string) string {
if strings.HasSuffix(s, suffix) {
return s
}
return fmt.Sprint(s, suffix)
}
func New(log *zap.Logger, pool *ConnectionPool, pathPrefix string, apiKey string, development bool) *Api {
api := &Api{
pool: pool,
pathPrefix: strings.TrimSuffix(pathPrefix, "/"),
apiKey: apiKey,
log: log,
router: mux.NewRouter(),
}
api.router.NotFoundHandler = api.httpNotFound()
api.bind(development)
return api
}
type Api struct {
pool *ConnectionPool
pathPrefix string
apiKey string
log *zap.Logger
router *mux.Router
}
func (api *Api) DelugeHandler(f DelugeMethod) http.HandlerFunc {
return func(rw http.ResponseWriter, r *http.Request) {
_ = Handle(rw, r, func(r *http.Request) (interface{}, error) {
conn, err := api.pool.Get(r.Context())
if err != nil {
return nil, err
}
ret, err := f(conn, r)
api.pool.Put(conn)
switch t := err.(type) {
case deluge.RPCError:
err = RPCError{
ExceptionType: t.ExceptionType,
ExceptionMessage: t.ExceptionMessage,
}
}
return ret, err
})
}
}
func (api *Api) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
api.router.ServeHTTP(rw, r)
}
// httpNotFound implements http.Handler which returns a not found error message.
func (api *Api) httpNotFound() http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
_ = Handle(rw, r, func(r *http.Request) (interface{}, error) {
return nil, &Error{
Message: "Not found",
Code: http.StatusNotFound,
}
})
})
}
func (api *Api) templateContext() interface{} {
path := appendSuffix(api.pathPrefix, "/")
return map[string]string{
"BasePath": path,
"BaseAPIPath": fmt.Sprint(path, "api/"),
}
}
// renderTemplate takes the contents of a go html/template found at source `name`
// and renders it back to the file system using templateContext().
func (api *Api) renderTemplate(fs afero.Fs, name string) {
log := api.log.With(zap.String("template", name))
f, err := fs.Open(name)
if err != nil {
log.Error("failed to open template for rendering", zap.Error(err))
return
}
templateSource, err := io.ReadAll(f)
if err != nil {
log.Error("failed to read template", zap.Error(err))
return
}
_ = f.Close()
t, err := template.New(name).Parse(string(templateSource))
if err != nil {
log.Error("failed to parse template", zap.Error(err))
return
}
w, err := fs.OpenFile(name, os.O_WRONLY|os.O_TRUNC, os.FileMode(0600))
if err != nil {
log.Error("failed to open template file for writing", zap.Error(err))
return
}
err = t.Execute(w, api.templateContext())
if err != nil {
log.Error("failed to render template", zap.Error(err))
return
}
err = w.Close()
if err != nil {
log.Error("failed to close rendered template", zap.Error(err))
return
}
}
func (api *Api) bindStatic(router *mux.Router, development bool) {
var static, _ = fs.Sub(Static, "frontend/dist")
if development {
static = os.DirFS("./frontend/dist")
}
var (
mem = afero.NewMemMapFs()
ufs = afero.NewCopyOnWriteFs(&afero.FromIOFS{
FS: static,
}, mem)
)
api.renderTemplate(ufs, "index.html")
fileServer := http.FileServer(afero.NewHttpFs(ufs).Dir(""))
if api.pathPrefix != "" {
fileServer = http.StripPrefix(api.pathPrefix, fileServer)
}
router.Methods(http.MethodGet).Handler(fileServer)
}
// keyFromRequest attempts to locate the API key from the incoming request.
// It looks for the key using these methods in the following order:
// - The password component of a Basic auth header
// - The cookie value ApiAuthCookieName as a base64 encoded value
func (api *Api) keyFromRequest(r *http.Request) (string, bool) {
_, password, ok := r.BasicAuth()
if ok {
return password, false
}
fromCookie, err := r.Cookie(ApiAuthCookieName)
if err != nil {
return "", false
}
fromCookieDecoded, _ := base64.StdEncoding.DecodeString(fromCookie.Value)
return string(fromCookieDecoded), true
}
// logForRequest takes a WrappedResponse and an incoming HTTP request and logs it
func (api *Api) logForRequest(rw *WrappedResponse, r *http.Request) {
logger := api.log.With(
zap.String("Method", r.Method),
zap.String("URL", r.URL.String()),
zap.String("RemoteAddr", r.RemoteAddr),
zap.Time("Time", rw.Started()),
zap.Int("StatusCode", rw.code),
zap.Int("ResponseSize", rw.Len()),
zap.Duration("Duration", rw.Duration()),
)
var logLevelFunc = logger.Info
if rw.error != nil {
// Do not log notification errors
httpError, ok := rw.error.(HTTPError)
if !ok || httpError.StatusCode() >= 400 {
logLevelFunc = logger.With(zap.Error(rw.error)).Error
}
}
logLevelFunc(http.StatusText(rw.code))
}
func (api *Api) httpMiddlewareLog(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
wr := WrapResponse(rw)
next.ServeHTTP(wr, r)
api.logForRequest(wr, r)
})
}
func (api *Api) httpMiddlewareAuthenticate(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
apiKey, fromCookie := api.keyFromRequest(r)
if apiKey == "" {
SendError(rw, &Error{
Code: http.StatusUnauthorized,
Message: "No authentication provided in request",
})
return
}
ok := subtle.ConstantTimeCompare([]byte(api.apiKey), []byte(apiKey)) == 1
if !ok {
SendError(rw, &Error{
Code: http.StatusUnauthorized,
Message: "Incorrect API key",
})
return
}
// If the request didn't originate from a cookie, then set the cookie
if !fromCookie {
http.SetCookie(rw, &http.Cookie{
Name: ApiAuthCookieName,
Value: base64.StdEncoding.EncodeToString([]byte(apiKey)),
Path: fmt.Sprintf("%s/api", api.pathPrefix),
Expires: time.Now().Add(time.Hour * 24 * 365),
SameSite: http.SameSiteStrictMode,
HttpOnly: true,
})
}
next.ServeHTTP(rw, r)
})
}
func (api *Api) bind(development bool) {
primaryRouter := api.router
if api.pathPrefix != "" {
primaryRouter = api.router.PathPrefix(api.pathPrefix).Subrouter()
}
router := primaryRouter.PathPrefix("/api").Subrouter()
router.NotFoundHandler = api.httpNotFound()
// CORS
router.Methods(http.MethodOptions).HandlerFunc(func(rw http.ResponseWriter, request *http.Request) {
rw.WriteHeader(http.StatusOK)
})
apiRouter := router.NewRoute().Subrouter()
apiRouter.Use(api.httpMiddlewareLog)
// Enable API level authentication
if api.apiKey != "" {
apiRouter.Use(api.httpMiddlewareAuthenticate)
}
apiRouter.
Methods(http.MethodGet).
Path("/ping").
HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
rw.WriteHeader(http.StatusNoContent)
})
apiRouter.
Methods(http.MethodGet).
Path("/session").
HandlerFunc(api.DelugeHandler(httpGetSessionStatus))
apiRouter.
Methods(http.MethodGet).
Path("/disk/free").
HandlerFunc(api.DelugeHandler(httpGetFreeSpace))
apiRouter.
Methods(http.MethodGet).
Path("/view").
HandlerFunc(api.DelugeHandler(httpViewUpdate))
apiRouter.
Methods(http.MethodGet).
Path("/plugins").
HandlerFunc(api.DelugeHandler(httpGetPlugins))
apiRouter.
Methods(http.MethodPost).
Path("/plugins/{id}").
HandlerFunc(api.DelugeHandler(httpEnablePlugin))
apiRouter.
Methods(http.MethodDelete).
Path("/plugins/{id}").
HandlerFunc(api.DelugeHandler(httpDisablePlugin))
apiRouter.
Methods(http.MethodGet).
Path("/torrents").
HandlerFunc(api.DelugeHandler(httpTorrentsStatus))
apiRouter.
Methods(http.MethodPost).
Path("/torrents").
HandlerFunc(api.DelugeHandler(httpAddTorrent))
apiRouter.
Methods(http.MethodDelete).
Path("/torrents").
HandlerFunc(api.DelugeHandler(httpDeleteTorrents))
apiRouter.
Methods(http.MethodPost).
Path("/torrents/pause").
HandlerFunc(api.DelugeHandler(httpPauseTorrents))
apiRouter.
Methods(http.MethodPost).
Path("/torrents/resume").
HandlerFunc(api.DelugeHandler(httpResumeTorrents))
apiRouter.
Methods(http.MethodGet).
Path("/torrent/{id}").
HandlerFunc(api.DelugeHandler(TorrentHandler(httpTorrentStatus)))
apiRouter.
Methods(http.MethodDelete).
Path("/torrent/{id}").
HandlerFunc(api.DelugeHandler(TorrentHandler(httpDeleteTorrent)))
apiRouter.
Methods(http.MethodPut).
Path("/torrent/{id}").
HandlerFunc(api.DelugeHandler(TorrentHandler(httpSetTorrentOptions)))
apiRouter.
Methods(http.MethodPost).
Path("/torrent/{id}/pause").
HandlerFunc(api.DelugeHandler(TorrentHandler(httpPauseTorrent)))
apiRouter.
Methods(http.MethodPost).
Path("/torrent/{id}/resume").
HandlerFunc(api.DelugeHandler(TorrentHandler(httpResumeTorrent)))
apiRouter.
Methods(http.MethodGet).
Path("/labels").
HandlerFunc(api.DelugeHandler(httpLabels))
apiRouter.
Methods(http.MethodPost).
Path("/labels/{id}").
HandlerFunc(api.DelugeHandler(httpCreateLabel))
apiRouter.
Methods(http.MethodDelete).
Path("/labels/{id}").
HandlerFunc(api.DelugeHandler(httpDeleteLabel))
apiRouter.
Methods(http.MethodGet).
Path("/torrents/labels").
HandlerFunc(api.DelugeHandler(httpTorrentsLabels))
apiRouter.
Methods(http.MethodPost).
Path("/torrent/{id}/label").
HandlerFunc(api.DelugeHandler(TorrentHandler(httpSetTorrentLabel)))
// Static files
api.bindStatic(primaryRouter, development)
}
================================================
FILE: cmd/storm/server.go
================================================
package main
import (
"context"
"fmt"
deluge "github.com/gdm85/go-libdeluge"
"github.com/jessevdk/go-flags"
storm "github.com/relvacode/storm"
"go.uber.org/zap"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
)
func signalContext(ctx context.Context) context.Context {
sig := make(chan os.Signal, 1)
signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
xCtx, cancel := context.WithCancel(ctx)
go func() {
select {
case <-ctx.Done():
case <-sig:
cancel()
}
signal.Stop(sig)
}()
return xCtx
}
type Duration struct {
Duration time.Duration
}
func (dur *Duration) UnmarshalFlag(value string) (err error) {
dur.Duration, err = time.ParseDuration(value)
return
}
type Path string
func (p *Path) UnmarshalFlag(value string) error {
if strings.HasSuffix(value, "/") {
value = strings.TrimSuffix(value, "/")
}
if !strings.HasPrefix(value, "/") {
value = fmt.Sprint("/", value)
}
*p = Path(value)
return nil
}
type ServerOptions struct {
Listen string `short:"l" long:"listen" default:":8221" env:"LISTEN_ADDR" description:"The address for the HTTP server"`
LogStyle string `long:"log-style" choice:"production" choice:"console" default:"console" env:"LOGGING_STYLE" description:"The style of log messages"`
BasePath *Path `long:"base-path" required:"true" default:"/" env:"STORM_BASE_PATH" description:"Respond to requests from this base URL path"`
ApiKey string `long:"api-key" env:"STORM_API_KEY" description:"Set the password required to access the API (enables authentication)"`
DevelopmentMode bool `long:"dev-mode" env:"DEV_MODE" description:"Run in development mode"`
}
func (options *ServerOptions) Logger() (*zap.Logger, error) {
var cfg zap.Config
switch options.LogStyle {
case "production":
cfg = zap.NewProductionConfig()
case "console":
cfg = zap.NewDevelopmentConfig()
default:
panic("Invalid LogStyle choice")
}
cfg.DisableStacktrace = true
return cfg.Build()
}
func (options *ServerOptions) RunHandler(ctx context.Context, log *zap.Logger, handler http.Handler) error {
var (
errors = make(chan error, 1)
server = &http.Server{
Addr: options.Listen,
Handler: handler,
}
)
go func() {
errors <- server.ListenAndServe()
}()
log.Info(fmt.Sprintf("Ready to serve HTTP connections on %s%s", options.Listen, *options.BasePath))
defer close(errors)
select {
case <-ctx.Done():
log.Error("Interrupt signal received. Gracefully shutting down...")
timeout, cancel := context.WithTimeout(context.Background(), time.Minute)
_ = server.Shutdown(timeout)
cancel()
case err := <-errors:
return err
}
return <-errors
}
type DelugeOptions struct {
Version string `long:"deluge-version" choice:"v1" choice:"v2" default:"v1" env:"DELUGE_RPC_VERSION" description:"The Deluge RPC version"`
Hostname string `short:"H" long:"hostname" required:"true" env:"DELUGE_RPC_HOSTNAME" description:"The Deluge RPC hostname"`
Port uint `short:"P" long:"port" default:"58846" env:"DELUGE_RPC_PORT" description:"The Deluge RPC port"`
Username string `short:"u" long:"username" env:"DELUGE_RPC_USERNAME" description:"The Deluge RPC username"`
Password string `short:"p" long:"password" env:"DELUGE_RPC_PASSWORD" description:"The Deluge RPC password"`
MaxConnections int `long:"max-connections" env:"POOL_MAX_CONNECTIONS" required:"true" default:"5" description:"Maximum concurrent Deluge RPC connections"`
IdleTime *Duration `long:"idle-time" env:"POOL_IDLE_TIME" required:"true" default:"30s" description:"Close idle Deluge RPC connections after this duration"`
}
func (options *DelugeOptions) Client() storm.DelugeProvider {
var settings = deluge.Settings{
Hostname: options.Hostname,
Port: options.Port,
Login: options.Username,
Password: options.Password,
ReadWriteTimeout: time.Minute * 5,
}
return func() deluge.DelugeClient {
switch options.Version {
case "v1":
return deluge.NewV1(settings)
case "v2":
return deluge.NewV2(settings)
default:
panic("Invalid Version choice")
}
}
}
func (options *DelugeOptions) Pool(log *zap.Logger) *storm.ConnectionPool {
return storm.NewConnectionPool(log, options.MaxConnections, options.IdleTime.Duration, options.Client())
}
type Options struct {
ServerOptions
DelugeOptions
}
func Main() error {
var options Options
var parser = flags.NewParser(&options, flags.HelpFlag)
_, err := parser.Parse()
if err != nil {
return err
}
log, err := (&options.ServerOptions).Logger()
if err != nil {
return err
}
defer log.Sync()
ctx := signalContext(context.Background())
pool := (&options.DelugeOptions).Pool(log.Named("pool"))
defer pool.Close()
if options.DevelopmentMode {
log.Info("Running in development mode")
}
var (
apiLog = log.Named("api")
api = storm.New(apiLog, pool, (string)(*options.BasePath), options.ServerOptions.ApiKey, options.DevelopmentMode)
)
return (&options.ServerOptions).RunHandler(ctx, apiLog, api)
}
func main() {
err := Main()
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
================================================
FILE: docker-compose/auth
================================================
localclient:deluge:10
================================================
FILE: docker-compose/core.conf
================================================
{
"file": 1,
"format": 1
}{
"add_paused": false,
"allow_remote": true,
"auto_manage_prefer_seeds": false,
"auto_managed": true,
"cache_expiry": 60,
"cache_size": 512,
"copy_torrent_file": false,
"daemon_port": 58846,
"del_copy_torrent_file": false,
"dht": true,
"dont_count_slow_torrents": false,
"download_location": "/root/Downloads",
"download_location_paths_list": [],
"enabled_plugins": [
"Label"
],
"enc_in_policy": 1,
"enc_level": 2,
"enc_out_policy": 1,
"geoip_db_location": "/usr/share/GeoIP/GeoIP.dat",
"ignore_limits_on_local_network": true,
"info_sent": 0.0,
"listen_interface": "",
"listen_ports": [
6881,
6891
],
"listen_random_port": 53703,
"listen_reuse_port": true,
"listen_use_sys_port": false,
"lsd": true,
"max_active_downloading": 3,
"max_active_limit": 8,
"max_active_seeding": 5,
"max_connections_global": 200,
"max_connections_per_second": 20,
"max_connections_per_torrent": -1,
"max_download_speed": -1.0,
"max_download_speed_per_torrent": -1,
"max_half_open_connections": 50,
"max_upload_slots_global": 4,
"max_upload_slots_per_torrent": -1,
"max_upload_speed": -1.0,
"max_upload_speed_per_torrent": -1,
"move_completed": false,
"move_completed_path": "/root/Downloads",
"move_completed_paths_list": [],
"natpmp": true,
"new_release_check": false,
"outgoing_interface": "",
"outgoing_ports": [
0,
0
],
"path_chooser_accelerator_string": "Tab",
"path_chooser_auto_complete_enabled": true,
"path_chooser_max_popup_rows": 20,
"path_chooser_show_chooser_button_on_localhost": true,
"path_chooser_show_hidden_files": false,
"peer_tos": "0x00",
"plugins_location": "/config/plugins",
"pre_allocate_storage": false,
"prioritize_first_last_pieces": false,
"proxy": {
"anonymous_mode": false,
"force_proxy": false,
"hostname": "",
"password": "",
"port": 8080,
"proxy_hostnames": true,
"proxy_peer_connections": true,
"proxy_tracker_connections": true,
"type": 0,
"username": ""
},
"queue_new_to_top": false,
"random_outgoing_ports": true,
"random_port": true,
"rate_limit_ip_overhead": true,
"remove_seed_at_ratio": false,
"seed_time_limit": 180,
"seed_time_ratio_limit": 7.0,
"send_info": false,
"sequential_download": false,
"share_ratio_limit": 2.0,
"shared": false,
"stop_seed_at_ratio": false,
"stop_seed_ratio": 2.0,
"super_seeding": false,
"torrentfiles_location": "/root/Downloads",
"upnp": true,
"utpex": true
}
================================================
FILE: docker-compose/docker-compose.yml
================================================
version: "2.1"
services:
deluge:
image: ghcr.io/linuxserver/deluge
volumes:
- deluge-config:/config
- ./core.conf:/config/core.conf
- ./auth:/config/auth:ro
storm:
image: ghcr.io/relvacode/storm
environment:
DELUGE_RPC_VERSION: v2
DELUGE_RPC_HOSTNAME: deluge
DELUGE_RPC_USERNAME: localclient
DELUGE_RPC_PASSWORD: deluge
ports:
- "8221:8221"
volumes:
deluge-config:
================================================
FILE: error.go
================================================
package storm
import (
"fmt"
"net/http"
)
// HTTPError is an error that extends the built-in Go error with status code hinting.
type HTTPError interface {
error
StatusCode() int
}
type RPCError struct {
ExceptionType string
ExceptionMessage string
}
func (RPCError) StatusCode() int {
return http.StatusInternalServerError
}
func (e RPCError) Error() string {
return fmt.Sprintf("%s: %s", e.ExceptionType, e.ExceptionMessage)
}
var _ HTTPError = (*Error)(nil)
// Hint wraps an input error to hint the HTTP status code.
// If err already implements HTTPError then that error is used directly
func Hint(code int, err error) error {
if httpError, ok := err.(HTTPError); ok {
return httpError
}
return &Error{
Message: err.Error(),
Code: code,
}
}
type Error struct {
Message string
Code int
}
func (e *Error) Error() string {
return e.Message
}
func (e *Error) StatusCode() int {
return e.Code
}
type errorResponse struct {
Error string
}
// SendError sends an error back to the client.
// If err implements HTTPError then that status code is used. Otherwise HTTP InternalServerError is sent.
// It delivers the error message as the errorResponse payload.
func SendError(rw http.ResponseWriter, err error) {
var (
code = http.StatusInternalServerError
response = errorResponse{
Error: err.Error(),
}
)
if httpError, ok := err.(HTTPError); ok {
code = httpError.StatusCode()
}
if wr, ok := rw.(*WrappedResponse); ok {
wr.error = err
}
Send(rw, code, &response)
}
================================================
FILE: frontend/.browserslistrc
================================================
# This file is used by the build system to adjust CSS and JS output to support the specified browsers below.
# For additional information regarding the format and rule options, please see:
# https://github.com/browserslist/browserslist#queries
# For the full list of supported browsers by the Angular framework, please see:
# https://angular.io/guide/browser-support
# You can see what browsers were selected by your queries by running:
# npx browserslist
last 1 Chrome version
last 1 Firefox version
last 2 Edge major versions
last 2 Safari major versions
last 2 iOS major versions
Firefox ESR
not IE 9-10 # Angular support for IE 9-10 has been deprecated and will be removed as of Angular v11. To opt-in, remove the 'not' prefix on this line.
not IE 11 # Angular supports IE 11 only as an opt-in. To opt-in, remove the 'not' prefix on this line.
================================================
FILE: frontend/.editorconfig
================================================
# Editor configuration, see https://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.ts]
quote_type = single
[*.md]
max_line_length = off
trim_trailing_whitespace = false
================================================
FILE: frontend/angular.json
================================================
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"storm": {
"projectType": "application",
"schematics": {
"@schematics/angular:component": {
"style": "scss"
}
},
"root": "",
"sourceRoot": "src",
"prefix": "t",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:browser",
"options": {
"outputPath": "dist",
"index": "src/index.html",
"main": "src/main.ts",
"polyfills": "src/polyfills.ts",
"tsConfig": "tsconfig.app.json",
"aot": true,
"assets": [
"src/favicon.ico",
"src/assets"
],
"styles": [
"node_modules/primeng/resources/primeng.min.css",
"node_modules/primeng/resources/themes/bootstrap4-dark-blue/theme.css",
"node_modules/primeflex/primeflex.css",
"node_modules/@fortawesome/fontawesome-free/css/regular.css",
"node_modules/@fortawesome/fontawesome-free/css/solid.css",
"node_modules/@fortawesome/fontawesome-free/css/fontawesome.css",
"src/icons.scss",
"src/styles.scss"
],
"scripts": []
},
"configurations": {
"production": {
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.prod.ts"
}
],
"optimization": true,
"outputHashing": "all",
"sourceMap": false,
"namedChunks": false,
"extractLicenses": true,
"vendorChunk": false,
"buildOptimizer": true,
"budgets": [
{
"type": "initial",
"maximumWarning": "2mb",
"maximumError": "5mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "6kb",
"maximumError": "10kb"
}
]
},
"development": {
"sourceMap": true,
"buildOptimizer": false,
"optimization": false
}
}
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"options": {
"browserTarget": "storm:build"
},
"configurations": {
"production": {
"browserTarget": "storm:build:production"
}
}
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n",
"options": {
"browserTarget": "storm:build"
}
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"main": "src/test.ts",
"polyfills": "src/polyfills.ts",
"tsConfig": "tsconfig.spec.json",
"karmaConfig": "karma.conf.js",
"assets": [
"src/favicon.ico",
"src/assets"
],
"styles": [
"src/styles.scss"
],
"scripts": []
}
},
"lint": {
"builder": "@angular-devkit/build-angular:tslint",
"options": {
"tsConfig": [
"tsconfig.app.json",
"tsconfig.spec.json",
"e2e/tsconfig.json"
],
"exclude": [
"**/node_modules/**"
]
}
},
"e2e": {
"builder": "@angular-devkit/build-angular:protractor",
"options": {
"protractorConfig": "e2e/protractor.conf.js",
"devServerTarget": "storm:serve"
},
"configurations": {
"production": {
"devServerTarget": "storm:serve:production"
}
}
}
}
}
},
"defaultProject": "storm"
}
================================================
FILE: frontend/e2e/protractor.conf.js
================================================
// @ts-check
// Protractor configuration file, see link for more information
// https://github.com/angular/protractor/blob/master/lib/config.ts
const { SpecReporter, StacktraceOption } = require('jasmine-spec-reporter');
/**
* @type { import("protractor").Config }
*/
exports.config = {
allScriptsTimeout: 11000,
specs: [
'./src/**/*.e2e-spec.ts'
],
capabilities: {
browserName: 'chrome'
},
directConnect: true,
baseUrl: 'http://localhost:4200/',
framework: 'jasmine',
jasmineNodeOpts: {
showColors: true,
defaultTimeoutInterval: 30000,
print: function() {}
},
onPrepare() {
require('ts-node').register({
project: require('path').join(__dirname, './tsconfig.json')
});
jasmine.getEnv().addReporter(new SpecReporter({
spec: {
displayStacktrace: StacktraceOption.PRETTY
}
}));
}
};
================================================
FILE: frontend/e2e/src/app.e2e-spec.ts
================================================
import { AppPage } from './app.po';
import { browser, logging } from 'protractor';
describe('workspace-project App', () => {
let page: AppPage;
beforeEach(() => {
page = new AppPage();
});
it('should display welcome message', () => {
page.navigateTo();
expect(page.getTitleText()).toEqual('storm app is running!');
});
afterEach(async () => {
// Assert that there are no errors emitted from the browser
const logs = await browser.manage().logs().get(logging.Type.BROWSER);
expect(logs).not.toContain(jasmine.objectContaining({
level: logging.Level.SEVERE,
} as logging.Entry));
});
});
================================================
FILE: frontend/e2e/src/app.po.ts
================================================
import { browser, by, element } from 'protractor';
export class AppPage {
navigateTo(): Promise {
return browser.get(browser.baseUrl) as Promise;
}
getTitleText(): Promise {
return element(by.css('app-root .content span')).getText() as Promise;
}
}
================================================
FILE: frontend/e2e/tsconfig.json
================================================
/* To learn more about this file see: https://angular.io/config/tsconfig. */
{
"extends": "../tsconfig.json",
"compilerOptions": {
"outDir": "../out-tsc/e2e",
"module": "commonjs",
"target": "es2018",
"types": [
"jasmine",
"jasminewd2",
"node"
]
}
}
================================================
FILE: frontend/karma.conf.js
================================================
// Karma configuration file, see link for more information
// https://karma-runner.github.io/1.0/config/configuration-file.html
module.exports = function (config) {
config.set({
basePath: '',
frameworks: ['jasmine', '@angular-devkit/build-angular'],
plugins: [
require('karma-jasmine'),
require('karma-chrome-launcher'),
require('karma-jasmine-html-reporter'),
require('karma-coverage-istanbul-reporter'),
require('@angular-devkit/build-angular/plugins/karma')
],
client: {
clearContext: false // leave Jasmine Spec Runner output visible in browser
},
coverageIstanbulReporter: {
dir: require('path').join(__dirname, './coverage/storm'),
reports: ['html', 'lcovonly', 'text-summary'],
fixWebpackSourcePaths: true
},
reporters: ['progress', 'kjhtml'],
port: 9876,
colors: true,
logLevel: config.LOG_INFO,
autoWatch: true,
browsers: ['Chrome'],
singleRun: false,
restartOnFileChange: true
});
};
================================================
FILE: frontend/package.json
================================================
{
"name": "storm",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"build:prod": "ng build --configuration=production",
"test": "ng test",
"lint": "ng lint",
"e2e": "ng e2e"
},
"private": true,
"dependencies": {
"@angular/animations": "^13.1.1",
"@angular/cdk": "^13.1.1",
"@angular/common": "~13.1.1",
"@angular/compiler": "~13.1.1",
"@angular/core": "~13.1.1",
"@angular/forms": "~13.1.1",
"@angular/platform-browser": "~13.1.1",
"@angular/platform-browser-dynamic": "~13.1.1",
"@angular/router": "~13.1.1",
"@fortawesome/fontawesome-free": "^5.15.1",
"ngx-filesize": "^2.0.16",
"ngx-moment": "^5.0.0",
"primeflex": "^2.0.0",
"primeng": "^13.0.3",
"rxjs": "~6.6.0",
"tslib": "^2.0.0",
"zone.js": "~0.11.4"
},
"devDependencies": {
"@angular-devkit/build-angular": "^13.1.2",
"@angular/cli": "~13.1.2",
"@angular/compiler-cli": "~13.1.1",
"@types/jasmine": "~3.5.0",
"@types/jasminewd2": "~2.0.3",
"@types/node": "^12.11.1",
"codelyzer": "^6.0.0",
"jasmine-core": "~3.8.0",
"jasmine-spec-reporter": "~5.0.0",
"karma": "~6.3.9",
"karma-chrome-launcher": "~3.1.0",
"karma-coverage-istanbul-reporter": "~3.0.2",
"karma-jasmine": "~4.0.0",
"karma-jasmine-html-reporter": "^1.5.0",
"protractor": "~7.0.0",
"ts-node": "~8.3.0",
"tslint": "~6.1.0",
"typescript": "~4.5.4"
}
}
================================================
FILE: frontend/src/app/api.service.spec.ts
================================================
import { TestBed } from '@angular/core/testing';
import { ApiService } from './api.service';
describe('ApiService', () => {
let service: ApiService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(ApiService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});
================================================
FILE: frontend/src/app/api.service.ts
================================================
import {Inject, Injectable} from '@angular/core';
import {defer, EMPTY, Observable, ObservableInput, throwError} from 'rxjs';
import {
HttpClient,
HttpErrorResponse,
HttpEvent,
HttpHandler, HttpHeaders,
HttpInterceptor,
HttpParams,
HttpRequest
} from '@angular/common/http';
import {catchError, retryWhen, switchMap, takeWhile} from 'rxjs/operators';
import {Message} from 'primeng/api';
import {Environment, ENVIRONMENT} from './environment';
import {DialogService} from 'primeng/dynamicdialog';
import {ApiKeyDialogComponent} from './components/api-key-dialog/api-key-dialog.component';
/**
* Raised when the API returns an error
*/
export class ApiException {
constructor(public status: number, public error: string) {
}
/**
* Get a PrimeNG error block for this exception
*/
public get message(): Message {
return {
severity: 'error',
summary: 'Error',
detail: this.error,
};
}
}
export type State =
'Active'
| 'Allocating'
| 'Checking'
| 'Downloading'
| 'Seeding'
| 'Paused'
| 'Error'
| 'Queued'
| 'Moving';
export interface Torrent {
ActiveTime: number;
CompletedTime: number;
TimeAdded: number; // most times an integer
DistributedCopies: number;
ETA: number; // most times an integer
Progress: number; // max is 100
Ratio: number;
IsFinished: boolean;
IsSeed: boolean;
Private: boolean;
DownloadLocation: string;
DownloadPayloadRate: number;
Name: string;
NextAnnounce: number;
NumPeers: number;
NumPieces: number;
NumSeeds: number;
PieceLength: number;
SeedingTime: number;
State: State;
TotalDone: number;
TotalPeers: number;
TotalSeeds: number;
TotalSize: number;
TrackerHost: string;
TrackerStatus: string;
UploadPayloadRate: number;
// Files: []File
// Peers: []Peer
FilePriorities: number[];
FileProgress: number[];
}
export interface Label {
Label: string;
}
export interface Hash {
Hash: string;
}
export interface ViewTorrent extends Torrent, Hash, Label {
}
export interface Torrents {
[id: string]: Torrent;
}
export interface TorrentOptions {
MaxConnections?: number;
MaxUploadSlots?: number;
MaxUploadSpeed?: number;
MaxDownloadSpeed?: number;
PrioritizeFirstLastPieces?: boolean;
PreAllocateStorage?: boolean; // compact_allocation for v1
DownloadLocation?: string;
AutoManaged?: boolean;
StopAtRatio?: boolean;
StopRatio?: number;
RemoveAtRatio?: number;
MoveCompleted?: boolean;
MoveCompletedPath?: string;
AddPaused?: boolean;
}
export interface AddTorrent {
Type: 'url' | 'magnet' | 'file';
URI: string;
Data?: string;
}
export interface AddTorrentRequest extends AddTorrent {
Options?: TorrentOptions;
}
export interface AddTorrentResponse {
ID: string;
}
export interface TorrentLabels {
[id: string]: string;
}
export interface SetTorrentLabelRequest {
Label: string;
}
export interface SessionStatus {
HasIncomingConnections: boolean;
UploadRate: number;
DownloadRate: number;
PayloadUploadRate: number;
PayloadDownloadRate: number;
TotalDownload: number;
TotalUpload: number;
NumPeers: number;
DhtNodes: number;
}
export interface DiskSpace {
FreeBytes: number;
}
export interface ViewUpdate {
Torrents: ViewTorrent[];
Session: SessionStatus;
DiskFree: number;
ETag: string;
}
export class ApiInterceptor implements HttpInterceptor {
constructor() {
}
private catchError(err: HttpErrorResponse, caught: Observable>): ObservableInput {
return throwError(new ApiException(err.status, err.error.Error));
}
public intercept(req: HttpRequest, next: HttpHandler): Observable> {
return next.handle(req).pipe(
catchError(this.catchError)
);
}
}
@Injectable()
export class AuthInterceptor implements HttpInterceptor {
ask$: Observable;
constructor(dialogService: DialogService) {
this.ask$ = defer(() => {
const ref = dialogService.open(ApiKeyDialogComponent, {
header: 'Authorization Required',
showHeader: true,
closeOnEscape: false,
closable: false,
});
return ref.onClose;
});
}
public intercept(req: HttpRequest, next: HttpHandler): Observable> {
return next.handle(req).pipe(
// Catch 401 errors and ask for the API key.
// Redo the request with the provided API key in basic auth headers
catchError((err: ApiException) => {
if (err.status !== 401) {
return throwError(err);
}
return this.ask$.pipe(
switchMap(key => {
const withAuthHeaderReq = req.clone({
headers: req.headers.set('Authorization', 'Basic ' + btoa(':' + key))
});
return next.handle(withAuthHeaderReq);
})
);
}),
// Keep retrying 401 errors
retryWhen(errors => errors.pipe(
takeWhile((err: ApiException) => err.status === 401)
))
);
}
}
@Injectable({
providedIn: 'root',
})
export class ApiService {
constructor(private http: HttpClient, @Inject(ENVIRONMENT) private environment: Environment) {
}
private url(endpoint: string): string {
return `${this.environment.baseApiPath}${endpoint}`;
}
/**
* Calls the ping endpoint
*/
public ping(): Observable {
return this.http.get(this.url('ping'));
}
public sessionStatus(): Observable {
return this.http.get(this.url('session'));
}
public freeDiskSpace(path: string = ''): Observable {
return this.http.get(this.url('disk/free'), {
params: {
path
}
});
}
public viewUpdate(etag?: string, state?: State): Observable {
let params = new HttpParams();
if (!!state) {
params = params.set('state', state);
}
let headers = new HttpHeaders();
if (!!etag) {
headers = headers.set('ETag', etag);
}
return this.http.get(this.url('view'), {
params,
headers,
}).pipe(
catchError((err: ApiException) => {
if (err.status === 304) {
return EMPTY;
}
return throwError(err);
})
);
}
/**
* Get a list of all the currently enabled plugins
*/
public plugins(): Observable {
return this.http.get(this.url('plugins'));
}
/**
* Enable a plugin
* @param name
* The plugin name to enable
*/
public enablePlugin(name: string): Observable {
return this.http.post(this.url(`plugins/${name}`), null);
}
/**
* Disable a plugin
* @param name
* The plugin name to disable
*/
public disablePlugin(name: string): Observable {
return this.http.delete(this.url(`plugins/${name}`));
}
/**
* Pauses one or more torrens
* @param torrents
* List of torrent IDs
*/
public pause(...torrents: string[]): Observable {
const params = new HttpParams({
fromObject: {
id: torrents,
}
});
return this.http.post(this.url('torrents/pause'), null, {
params
});
}
/**
* Resumes one or more torrens
* @param torrents
* List of torrent IDs
*/
public resume(...torrents: string[]): Observable {
const params = new HttpParams({
fromObject: {
id: torrents,
}
});
return this.http.post(this.url('torrents/resume'), null, {
params
});
}
/**
* Get a specific torrent by ID
* @param id
* The torrent ID
*/
public torrent(id: string): Observable {
return this.http.get(this.url(`torrent/${id}`));
}
public torrents(state?: State, ...torrents: string[]): Observable {
let params = new HttpParams();
if (!!state) {
params = params.set('state', state);
}
if (!!torrents && torrents.length) {
for (const id of torrents) {
params = params.append('id', id);
}
}
return this.http.get(this.url('torrents'), {
params,
});
}
public removeTorrent(withData: boolean, id: string): Observable {
return this.http.delete(this.url(`torrent/${id}`), {
params: new HttpParams({
fromObject: {
files: withData ? 'true' : 'false'
}
})
});
}
/**
* Add a new torrent
* @param req
* Add torrent request
*/
public add(req: AddTorrentRequest): Observable {
return this.http.post(this.url('torrents'), req);
}
/**
* Gets available labels
*/
public labels(): Observable {
return this.http.get(this.url('labels'));
}
/**
* Create a new label
* @param name
* The label name
*/
public createLabel(name: string): Observable {
return this.http.post(this.url(`labels/${name}`), null);
}
/**
* Delete an existing label
* @param name
* The label name
*/
public deleteLabel(name: string): Observable {
return this.http.delete(this.url(`labels/${name}`));
}
/**
* Gets labels associated with torrents matching filter
* @param state
* The torrent state
* @param torrents
* An optional set of torrent IDs
*/
public torrentsLabels(state?: State, ...torrents: string[]): Observable {
return this.http.get(this.url('torrents/labels'));
}
/**
* Updates the label of a torrent
* @param id
* The torrent ID
* @param req
* Request data
*/
public setTorrentLabel(id: string, req: SetTorrentLabelRequest): Observable {
return this.http.post(this.url(`torrent/${id}/label`), req);
}
}
================================================
FILE: frontend/src/app/app.component.html
================================================