├── .dockerignore ├── .github ├── dependabot.yml └── workflows │ └── docker.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── docker-bake.hcl ├── docs ├── API.md ├── build.md ├── config.md ├── database.md ├── docker.md ├── screenshots.md └── usage.md ├── exatorrent.go ├── go.mod ├── go.sum └── internal ├── core ├── auth.go ├── cache.go ├── connection.go ├── engine.go ├── get.go ├── getspec.go ├── init.go ├── routine.go ├── serve.go ├── socket.go ├── statsapi.go ├── storage.go ├── vars.go └── version.go ├── db ├── db.go ├── sqlite3filestatedb.go ├── sqlite3lockstatedb.go ├── sqlite3pc.go ├── sqlite3torrentdb.go ├── sqlite3torrentuserdb.go ├── sqlite3trackerdb.go └── sqlite3userdb.go └── web ├── .gitignore ├── .npmrc ├── .prettierignore ├── .prettierrc ├── index.html ├── package-lock.json ├── package.json ├── src ├── App.svelte ├── app.css ├── main.ts ├── partials │ ├── About.svelte │ ├── Disconnect.svelte │ ├── File.svelte │ ├── Index.svelte │ ├── Notifications.svelte │ ├── ProgStat.svelte │ ├── Settings.svelte │ ├── Signin.svelte │ ├── Stats.svelte │ ├── Top.svelte │ ├── Torrent.svelte │ ├── TorrentCard.svelte │ ├── Torrents.svelte │ ├── User.svelte │ ├── Useredit.svelte │ ├── Users.svelte │ └── core.ts └── vite-env.d.ts ├── svelte.config.js ├── tsconfig.json ├── tsconfig.node.json ├── vite.config.ts ├── web.go └── web_noui.go /.dockerignore: -------------------------------------------------------------------------------- 1 | .github 2 | docs/ 3 | build/ 4 | internal/web/build/ 5 | internal/web/node_modules/ 6 | Dockerfile 7 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "gomod" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | 8 | - package-ecosystem: "npm" 9 | directory: "/internal/web" 10 | schedule: 11 | interval: "weekly" 12 | 13 | - package-ecosystem: "github-actions" 14 | directory: "/" 15 | schedule: 16 | interval: "weekly" 17 | -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | name: Create and publish Container images 2 | 3 | on: 4 | pull_request: 5 | types: 6 | - opened 7 | - reopened 8 | - synchronize 9 | push: 10 | branches: 11 | - "main" 12 | 13 | jobs: 14 | build-container-images: 15 | runs-on: ubuntu-latest 16 | permissions: 17 | contents: read 18 | packages: write 19 | 20 | steps: 21 | - name: Checkout repository 22 | uses: actions/checkout@v4 23 | 24 | - name: Docker meta and tags 25 | id: meta 26 | uses: docker/metadata-action@v5 27 | with: 28 | # list of Docker images to use as base name for tags 29 | images: | 30 | ghcr.io/${{ github.repository_owner }}/exatorrent 31 | # generate Docker tags based on the following events/attributes 32 | tags: | 33 | type=raw,value=amd64 34 | type=raw,value=arm64 35 | type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }} 36 | type=schedule 37 | type=ref,event=branch 38 | type=ref,event=pr 39 | type=semver,pattern={{version}} 40 | type=semver,pattern={{major}}.{{minor}} 41 | type=semver,pattern={{major}} 42 | type=sha 43 | 44 | - name: Setup Docker Buildx 45 | uses: docker/setup-buildx-action@v3 46 | 47 | - name: Login to GHCR 48 | uses: docker/login-action@v3 49 | with: 50 | registry: ghcr.io 51 | username: ${{ github.repository_owner }} 52 | password: ${{ secrets.GITHUB_TOKEN }} 53 | 54 | - name: Create artifact directory 55 | run: mkdir -p artifact 56 | 57 | - name: Build and push 58 | uses: docker/bake-action@v6 59 | with: 60 | files: | 61 | ./docker-bake.hcl 62 | ${{ steps.meta.outputs.bake-file }} 63 | source: . 64 | push: true 65 | set: | 66 | *.cache-from=type=gha 67 | *.cache-to=type=gha,mode=max 68 | 69 | build-binaries-darwin: 70 | runs-on: macos-13 71 | permissions: 72 | contents: write 73 | steps: 74 | - name: Checkout repository 75 | uses: actions/checkout@v4 76 | 77 | - name: Set up NodeJS 78 | uses: actions/setup-node@v4 79 | with: 80 | node-version: 18 81 | cache: npm 82 | cache-dependency-path: internal/web/package-lock.json 83 | 84 | - name: Install node dependencies 85 | run: npm ci 86 | working-directory: internal/web 87 | - name: Build frontend 88 | run: make web 89 | 90 | - name: Set up Go 91 | uses: actions/setup-go@v5 92 | with: 93 | go-version: 1.23 94 | cache: true 95 | 96 | - name: Build amd64 97 | run: | 98 | make app-no-sl 99 | mv build/exatorrent build/exatorrent-darwin-amd64 100 | env: 101 | GOOS: darwin 102 | GOARCH: amd64 103 | 104 | - name: Build arm64 105 | run: | 106 | make app-no-sl 107 | mv build/exatorrent build/exatorrent-darwin-arm64 108 | env: 109 | GOOS: darwin 110 | GOARCH: arm64 111 | 112 | - name: Upload artifacts 113 | uses: actions/upload-artifact@v4 114 | with: 115 | name: binaries-darwin 116 | path: build/* 117 | if-no-files-found: error 118 | 119 | build-binaries-linux: 120 | runs-on: ubuntu-22.04 121 | permissions: 122 | contents: write 123 | steps: 124 | - name: Checkout repository 125 | uses: actions/checkout@v4 126 | 127 | - name: Set up Docker Buildx 128 | uses: docker/setup-buildx-action@v3 129 | 130 | - name: Create artifacts 131 | uses: docker/bake-action@v6 132 | with: 133 | files: | 134 | ./docker-bake.hcl 135 | targets: artifact-linux 136 | source: . 137 | provenance: false 138 | 139 | - name: Flatten artifact 140 | uses: docker/bake-action@v6 141 | with: 142 | files: | 143 | ./docker-bake.hcl 144 | targets: release 145 | source: . 146 | provenance: false 147 | 148 | - name: Upload artifacts 149 | uses: actions/upload-artifact@v4 150 | with: 151 | name: binaries-linux 152 | path: release/* 153 | if-no-files-found: error 154 | 155 | gh-release: 156 | needs: 157 | - build-binaries-linux 158 | - build-binaries-darwin 159 | runs-on: ubuntu-latest 160 | if: startsWith(github.ref, 'refs/tags/v') 161 | permissions: 162 | contents: write 163 | steps: 164 | - uses: actions/download-artifact@v3 165 | id: binaries 166 | 167 | - name: Display structure of downloaded files 168 | run: ls -R ${{steps.binaries.outputs.download-path}} 169 | 170 | - name: GitHub Release 171 | uses: softprops/action-gh-release@v2 172 | with: 173 | generate_release_notes: true 174 | fail_on_unmatched_files: true 175 | files: | 176 | ${{steps.binaries.outputs.download-path}}/binaries-linux/* 177 | ${{steps.binaries.outputs.download-path}}/binaries-darwin/* 178 | env: 179 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 180 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build/ 2 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1 2 | 3 | FROM docker.io/alpine:3.18 AS base 4 | 5 | # Build the web ui from source 6 | FROM --platform=$BUILDPLATFORM docker.io/node:18 AS build-node 7 | WORKDIR /exa 8 | ADD internal/web /exa/internal/web 9 | ADD Makefile /exa/ 10 | RUN make web 11 | 12 | # Build the application from source 13 | FROM docker.io/golang:1.24-bookworm AS build-go 14 | 15 | ARG TARGETOS TARGETARCH 16 | 17 | WORKDIR /exa 18 | 19 | COPY go.mod go.sum ./ 20 | RUN go mod download 21 | 22 | COPY . ./ 23 | COPY --link --from=build-node /exa/internal/web/build /exa/internal/web/build 24 | RUN GOOS=${TARGETOS} GOARCH=${TARGETARCH} make app 25 | 26 | # Artifact Target 27 | FROM scratch AS artifact 28 | 29 | ARG TARGETOS TARGETARCH TARGETVARIANT 30 | 31 | COPY --link --from=build-go /exa/build/exatorrent /exatorrent-${TARGETOS}-${TARGETARCH}${TARGETVARIANT} 32 | 33 | # Failover if contexts=artifacts= is not set 34 | FROM scratch AS artifacts 35 | # Releaser flat all artifacts 36 | FROM base AS releaser 37 | WORKDIR /out 38 | RUN --mount=from=artifacts,source=.,target=/artifacts </dev/null || cp /artifacts/* /out/ 41 | EOT 42 | FROM scratch AS release 43 | COPY --link --from=releaser /out / 44 | 45 | # Final stage 46 | # Deploy the application binary into a lean image 47 | FROM base 48 | 49 | LABEL maintainer="varbhat" 50 | LABEL org.label-schema.schema-version="1.0" 51 | LABEL org.label-schema.name="varbhat/exatorrent" 52 | LABEL org.label-schema.description="self-hostable torrent client" 53 | LABEL org.label-schema.url="https://github.com/varbhat/exatorrent" 54 | LABEL org.label-schema.vcs-url="https://github.com/varbhat/exatorrent" 55 | 56 | COPY --link --from=build-go --chown=1000:1000 /exa/build/exatorrent /exatorrent 57 | 58 | RUN apk add -U --upgrade --no-cache \ 59 | ca-certificates 60 | 61 | USER 1000:1000 62 | 63 | WORKDIR /exa 64 | 65 | RUN mkdir -p exadir 66 | 67 | EXPOSE 5000 42069 68 | 69 | VOLUME /exa/exadir 70 | 71 | ENTRYPOINT ["/exatorrent"] 72 | 73 | CMD ["-dir", "exadir"] 74 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | SHELL = /usr/bin/env sh 2 | APP_NAME = exatorrent 3 | PACKAGES ?= ./... 4 | MAIN_SOURCE = exatorrent.go 5 | .DEFAULT_GOAL := help 6 | 7 | ##help: Display list of commands 8 | .PHONY: help 9 | help: Makefile 10 | @printf "Options:\n" 11 | @sed -n 's|^##||p' $< 12 | 13 | ##web: Build the Web Client 14 | .PHONY: web 15 | web: 16 | cd internal/web && npm install && npm run build 17 | 18 | ##web: Build the Web Client for CI 19 | .PHONY: web-ci 20 | web-ci: 21 | cd internal/web && npm ci && npm run build 22 | 23 | ##app: Build the Application 24 | .PHONY: app 25 | app: 26 | env CGO_ENABLED=1 go build -trimpath -buildmode=pie -ldflags '-extldflags "-static -s -w"' -o build/$(APP_NAME) $(MAIN_SOURCE) 27 | 28 | ##app-no-ui: Build the Application without UI 29 | .PHONY: app-no-ui 30 | app-no-ui: 31 | env CGO_ENABLED=1 go build -tags noui -trimpath -buildmode=pie -ldflags '-extldflags "-static -s -w"' -o build/$(APP_NAME) $(MAIN_SOURCE) 32 | 33 | ##app-no-buildflags: Build the Application without any buildflags 34 | .PHONY: app-no-buildflags 35 | app-no-buildflags: 36 | env CGO_ENABLED=1 go build -o build/$(APP_NAME) $(MAIN_SOURCE) 37 | 38 | ##app-no-sl: Build the Application without -static build flag 39 | .PHONY: app-no-sl 40 | app-no-sl: 41 | env CGO_ENABLED=1 go build -trimpath -buildmode=pie -ldflags '-extldflags "-s -w"' -o build/$(APP_NAME) $(MAIN_SOURCE) 42 | 43 | ##checksum: Generate sha256 checksums for the builds 44 | .PHONY: checksum 45 | checksum: 46 | cd build && sha256sum -b * > checksums_sha256.txt 47 | 48 | ##run: Runs the build 49 | .PHONY: run 50 | run: 51 | cd build && ./exatorrent* 52 | 53 | ##docker: Build the Docker image 54 | .PHONY: docker 55 | docker: 56 | docker build -t "exatorrent" . 57 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # *exatorrent* 2 | ## 🧲 self-hostable torrent client 3 | 4 | ![GitHub Repo stars](https://img.shields.io/github/stars/varbhat/exatorrent) 5 | ![Latest Release](https://img.shields.io/github/release/varbhat/exatorrent) 6 | ![GitHub go.mod Go version](https://img.shields.io/github/go-mod/go-version/varbhat/exatorrent) 7 | ![GitHub License](https://img.shields.io/github/license/varbhat/exatorrent?logoColor=violet) 8 | 9 | ![Linux](https://img.shields.io/badge/Linux-%23.svg?logo=linux&color=FCC624&logoColor=black) 10 | ![macOS](https://img.shields.io/badge/macOS-%23.svg?logo=apple&color=000000&logoColor=white) 11 | ![Windows](https://img.shields.io/badge/Windows-%23.svg?logo=windows&color=0078D6&logoColor=white) 12 | ![Docker](https://img.shields.io/badge/docker-%23.svg?logo=docker&color=1D63ED&logoColor=white) 13 | ![Podman](https://img.shields.io/badge/podman-%23.svg?logo=podman&color=734392&logoColor=white) 14 | 15 |

ScreenshotsReleasesFeatures Installation UsageDocker Build License

16 |
17 | 18 | * exatorrent is Elegant [BitTorrent](https://www.bittorrent.org/) Client written in [Go](https://go.dev/). 19 | * It is Simple, easy to use, yet feature rich. 20 | * It can be run locally or be hosted in Remote Server with good resources. 21 | * It is Single Completely Statically Linked Binary with Zero External Dependencies. 22 | * It is lightweight and light on resources. 23 | * It comes with Beautiful Responsive Web Client written in Svelte and Typescript. 24 | * Thanks to documented [WebSocket](https://datatracker.ietf.org/doc/html/rfc6455) [API](docs/API.md) of exatorrent, custom client can be created. 25 | * It supports Single User Mode and Multi User Mode. 26 | * Torrented Files are stored in local disk can be downloaded and streamed via HTTP/Browser/Media Players. 27 | 28 |
29 |

30 | exatorrent web client 31 |

More Screenshots →

32 |

33 |
34 | 35 | ## Usage 36 | Exatorrent can be operated using either of the following methods: 37 | * **Releases:** You can download precompiled binary for your Operating System from [Releases](https://github.com/varbhat/exatorrent/releases/latest). Mark it as executable and run it. Refer [Usage](docs/usage.md). 38 | ```bash 39 | wget https://github.com/varbhat/exatorrent/releases/latest/download/exatorrent-linux-amd64 40 | chmod u+x ./exatorrent-linux-amd64 41 | ./exatorrent-linux-amd64 42 | ``` 43 | * **Docker:** Docker images of exatorrent are also provided which lets exatorrent to be run in a Docker container. See [Docker Docs](docs/docker.md). 44 | ```bash 45 | docker pull ghcr.io/varbhat/exatorrent:latest 46 | docker run -p 5000:5000 -p 42069:42069 -v /path/to/directory:/exa/exadir ghcr.io/varbhat/exatorrent:latest 47 | ``` 48 | * **Manual Build:** exatorrent is open source and can be built from sources. See [Build Docs](docs/build.md). 49 | ```bash 50 | make web && make app 51 | ``` 52 | 53 | #### Notes: 54 | * Note that **Username** and **Password** of Default User created on first run are `adminuser` and `adminpassword` respectively. 55 | * You can change Password later but Username of Account can't be changed after creation. Refer [Usage](docs/usage.md#-admin). 56 | * [Github Actions](https://github.com/features/actions) is used to build and publish [Releases](https://github.com/varbhat/exatorrent/releases/latest) and [Docker Images](https://ghcr.io/varbhat/exatorrent) of exatorrent. 57 | 58 | 59 | ## Features 60 | * Single Executable File with No Dependencies 61 | * Small in Size 62 | * Cross Platform 63 | * Download (or Build ) Single Executable Binary and run. That's it 64 | * Open and Stream Torrents in your Browser 65 | * Add Torrents by Magnet or by Infohash or Torrent File 66 | * Individual File Control (Start, Stop or Delete ) 67 | * Stop, Remove or Delete Torrent 68 | * Persistent between Sessions 69 | * Stop Torrent once SeedRatio is reached (Optional) 70 | * Perform Actions on Torrent [Completion](docs/config.md#actions-on-torrent-completion) (Optional) 71 | * Powered by [anacrolix/torrent](https://github.com/anacrolix/torrent) 72 | * Download/Upload [Rate limiter](docs/usage.md#rate-limiter) (Optional) 73 | * Apply [Blocklist](docs/usage.md#blocklist) (Optional) 74 | * [Configurable](docs/config.md) via Config File but works fine with Zero Configuration 75 | * Share Files by Unlocking Torrent or Lock Torrent (protect by Auth) to prevent External Access 76 | * Retrieve or Stream Files via HTTP 77 | * Multi-Users with Authentication 78 | * Auto Add Trackers to Torrent from TrackerList URL 79 | * Auto Fetch Torrent Metainfo from Online/Local Metainfo Cache 80 | * Download Directory as Zip or as Tarball 81 | * Stream directly on Browser or [VLC](https://www.videolan.org/vlc/) or [mpv](https://mpv.io/) or other Media Players 82 | * [Documented API](docs/API.md) 83 | 84 | ## License 85 | [GPL-v3](LICENSE) 86 | -------------------------------------------------------------------------------- /docker-bake.hcl: -------------------------------------------------------------------------------- 1 | target "docker-metadata-action" { 2 | tags = ["exatorrent:local"] 3 | } 4 | 5 | target "default" { 6 | inherits = ["docker-metadata-action"] 7 | platforms = [ 8 | "linux/amd64", 9 | "linux/arm64", 10 | "linux/arm/v7", 11 | ] 12 | } 13 | 14 | target "artifact" { 15 | inherits = ["docker-metadata-action"] 16 | target = "artifact" 17 | output = ["type=local,dest=./artifact"] 18 | } 19 | 20 | target "artifact-linux" { 21 | inherits = ["artifact"] 22 | platforms = [ 23 | "linux/amd64", 24 | "linux/arm64", 25 | "linux/arm/v7", 26 | "linux/ppc64le", 27 | ] 28 | } 29 | 30 | target "release" { 31 | target = "release" 32 | output = ["type=local,dest=./release"] 33 | contexts = { 34 | artifacts = "./artifact" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /docs/API.md: -------------------------------------------------------------------------------- 1 | exatorrent comes with beautiful performant Web Client written in svelte+Typescript but also comes with documented API so that custom clients(TUI, android app ,desktop app,etc.) can be created for it. 2 | 3 | Here lies the documented API reference. 4 | 5 | ## Authentication 6 | 7 | exatorrent has authentication and if the connection is not authenticated, it cannot make API request. 8 | 9 | exatorrent has user system. user is structure as defined below. 10 | 11 | ```go 12 | type User struct { 13 | Username string 14 | Password string 15 | Token string 16 | UserType int // 0 for User,1 for Admin,-1 for Disabled 17 | CreatedAt time.Time 18 | } 19 | ``` 20 | 21 | Length of Username and Password fields of User must be more than 5. CreatedAt field stores the creation time of User. UserType field denotes whether the User is Admin / User / Disabled. 22 | if UserType field is -1 , User is Disabled and cannot connect to server. if UserType field is 0 , then the User is Normal User and can connect to server and perform normal operations. if UserType field is 1 ,then the User is Admin User and can connect to server. Admin User can also perform Administration Operations in addition to normal Operations. Token field contains unique random UUID token issued to User which can be used by User to Authenticate. 23 | 24 | exatorrent looks whether `session_token` cookie is present in request and if the cookie is present, it validates value of `session_token` as `Token` , and if `Token` is valid, User will be authenticated. 25 | 26 | if `session_token` cookie is not present , exatorrent looks whether Query named "token" is present in request's URL and if query named "token" is present , it validates value of `token` query as `Token` , and if `Token` is valid , User will be authenticated. 27 | 28 | If both `session_token` cookie and `token` query string are not present OR, value presented in cookie or query is invalid, `basic_auth` will be issued, and if Basic Authentication's values filled by connection are valid, then the User will be authenticated. 29 | 30 | After User is authenticated, `session_token` cookie with `Token` value will be set by exatorrent. 31 | 32 | Briefly , Connection can be authenticated by Cookie with token ,or URL Query with token , or by Basic Auth(Username and Password). 33 | 34 | 35 | exatorrent's WebSocket API endpoint `/api/socket` is protected by Authentication and only Authenticated User(Normal User or Admin User) can connect to WebSocket API. Note that Disabled User cannot connect to WebSocket API. Also, Only ONE WebSocket connection is allowed per User(existing WebSocket connection of User gets disconnected on new WebSocket connection of User) 36 | 37 | Endpoint `/api/auth` is also provided where you can `POST` json request 38 | 39 | ```json 40 | { 41 | "data1": "yourusernamehere", 42 | "data2": "yourpasswordhere" 43 | } 44 | ``` 45 | 46 | to get `Token` and `UserType` of the User. Response will be of the form , 47 | 48 | ```json 49 | { 50 | "usertype": "user", 51 | "session": "uniqueuuidsession" 52 | } 53 | ``` 54 | 55 | # WebSocket API 56 | 57 | exatorrent has WebSocket API which provides API to control exatorrent. Note that there is seperate HTTP API Endpoint to retrieve / stream Torrent Files for which websocket will not be used and is documented later. 58 | 59 | Only Authenticated User who is not `disabled` can connect to WebSocket API. Only ONE WebSocket connection is allowed per User(existing WebSocket connection of User gets disconnected on new WebSocket connection of User). 60 | 61 | Communication in exatorrent WebSocket is done through JSON. exatorrent WebSocket Only accepts JSON requests of the form, 62 | 63 | ```json 64 | { 65 | "command": "commandstring", 66 | "data1": "data1string", 67 | "data2": "data2string", 68 | "data3": "data3string", 69 | "aop": 0 70 | } 71 | ``` 72 | 73 | if json request is not of the form mentioned above , json 74 | 75 | ```json 76 | { 77 | "type": "resp", 78 | "state": "error", 79 | "msg":"invalid command" 80 | } 81 | ``` 82 | 83 | will be sent back as response. 84 | 85 | 86 | command field of json request represents command that needs to be done. `data1` , `data2` and `data3` fields of json request represents data. `aop` field specifies whether json request is admin request or not. if `aop` field is 1 and user is admin, then exatorrent considers it as admin command (note that if users who are not admins send this,they are not valuated). better omit `aop` field if request is not admin request. 87 | 88 | 89 | ## Stream Requests 90 | 91 | Some Requests namely `getalltorrents` , `gettorrents` , `gettorrentinfo` are stream requests. They continuously send data regularly with 5 seconds gap in between. This is useful , say for showing progress of Torrent. There can be only 1 Stream at a time , so if you request new stream , old stream stops. you can also stop stream by sending request `{"command":"stopstream"}`. This stops stream if any do exist. 92 | 93 | ## Reference 94 | Please Read [wshandler](https://github.com/varbhat/exatorrent/blob/main/internal/core/socket.go#L108) to get more details about API 95 | -------------------------------------------------------------------------------- /docs/build.md: -------------------------------------------------------------------------------- 1 | # Build Docs 2 | This Documents how to build `exatorrent` from sources. 3 | 4 | `exatorrent` is written in [Go](https://golang.org). As such you need `go` to be installed in order to compile. 5 | 6 | `exatorrent` is dependent on [sqlite3](https://www.sqlite.org) ( used as database ) and [libutp](https://github.com/anacrolix/go-libutp) ( used for uTP connections ). As such , you also require C and C++ compilers to be installed in order to compile. [gcc](https://gcc.gnu.org/) is preferred but [clang](https://clang.llvm.org/) also compiles well. There is no need to install any `devel` packages or `sqlite` or headers in system to compile `exatorrent` , as Source comes bundeled with drivers [`crawshaw/sqlite`](https://github.com/crawshaw/sqlite) and [`libutp`](https://github.com/anacrolix/go-libutp) and C compiler is used to compile cgo code. 7 | 8 | 9 | `exatorrent` comes with beautiful , small and performant Web Client. It is written in [Svelte](https://svelte.dev/) + [TypeScript](https://www.typescriptlang.org/). It gets bundled with amazing [esbuild](https://esbuild.github.io/) and the built web client is then embedded within the binary using Go's [embed](https://pkg.go.dev/embed). [Node.js](https://nodejs.org/) is thus required to build Web Client. Note that Node.js is required only to build Web Client written in Svelte and not required thereafter (i.e exatorrent is not dependent on Node.js and Node.js in only required to build Web Client ). 10 | 11 | 12 | For sake of Convenience although not necessary to build `exatorrent` , Build commands of `exatorrent` are written in [Makefile](../Makefile). Install [`make`](https://www.gnu.org/software/make/) to execute make commands. Note that you can also manually type build commands instead of using `make`. 13 | 14 | 15 | ## Requirements 16 | 17 | * [go](https://golang.org) 18 | * [gcc](https://gcc.gnu.org/) 19 | * [make](https://www.gnu.org/software/make/) ( to execute make commands ) 20 | 21 | Requirements to build Web Client : 22 | * [Node.js](https://nodejs.org/) (`node` and `npm` must be available ) 23 | 24 | ## Build 25 | Since Web Client will be embedded within final binary , Web Client needs to be built first. 26 | 27 | Web Client can be built by : 28 | 29 | ```bash 30 | make web 31 | ``` 32 | 33 | After building web client , `exatorrent` can be built by : 34 | ```bash 35 | make app 36 | ``` 37 | 38 | You can see built `exatorrent` in `build` directory. 39 | 40 | If you don't have `make` installed , you can execute these commands manually to build exatorrent : 41 | ```bash 42 | cd internal/web && npm install && npm run build 43 | cd../.. 44 | env CGO_ENABLED=1 go build -trimpath -buildmode=pie -ldflags '-extldflags "-static -s -w"' -o build/exatorrent exatorrent.go 45 | ``` 46 | ## Notes 47 | * See [Building Docker/Podman Images](./docker.md#building-podman--docker-container-image) if you want to build `exatorrent` Docker / Podman Images. 48 | * If you don't want to build Web Client or want to skip building Web Client , you can do it by creating empty / dummy `index.html` file at `internal/web/build` directory ( Create `build` folder if it didn't exist ). Note that Web Client will not be available then. 49 | -------------------------------------------------------------------------------- /docs/config.md: -------------------------------------------------------------------------------- 1 | `exatorrent` is fully configurable. This page details configurations that are possible in `exatorrent` 2 | 3 | 4 | ## Options that are configurable during Runtime 5 | 6 | There are options that can be configured during runtime and without restarting `exatorrent`. It can also be configured through Web Client (or API). See [EngConfig](https://github.com/varbhat/exatorrent/blob/main/internal/core/vars.go#L352) for more details about the engconfig. 7 | 8 | When exatorrent gets started, it checks whether engconfig.json file is present in config directory (which is subdirectory of main directory of exatorrent) and if json is valid configuration, it gets applied. also, when you change the configuration during runtime, the json file gets updated. You can generate sample engconfig(so that you can modify it to set value you want it to have) by passing flag `-engc` while starting the program. 9 | 10 | ## Options that are not configurable during runtime 11 | 12 | Many of configuration of torrent engine `anacrolix/torrent` are only applied at start of engine and cannot be configurable during runtime. See [TorConfig](https://github.com/varbhat/exatorrent/blob/main/internal/core/vars.go#L42) for more details. 13 | 14 | Note that most of torcconfig maps to [ClientConfig](https://github.com/anacrolix/torrent/blob/master/config.go#L23) 15 | 16 | You can generate sample torcconfig(so that you can modify it to set value you want it to have) by passing flag `-torc` while starting the program. Note that if you don't want to configure , set it's value as `null`. 17 | 18 | ## Actions on Torrent Completion 19 | 20 | `exatorrent` can listen to completion of torrent and call Hook on Completion. Hook is just a HTTP POST Request containing Infohash, Name, Completed Time of Completed Torrent sent to configured URL. 21 | `listencompletion` of engconfig.json specifies whether the torrent must be listened for completion. 22 | `hookposturl` of engconfig.json specifies URL where the Hook HTTP request must be posted. 23 | `notifyoncomplete` of engconfig.json specifies whether the connected user( and owner of torrent) must be notified of completion via API/Web-Interface. 24 | -------------------------------------------------------------------------------- /docs/database.md: -------------------------------------------------------------------------------- 1 | To make things persistent between sessions, `exatorrent` uses database. But because `exatorrent` uses awesome `sqlite3` which comes embedded within `exatorrent`, you barely notice it. 2 | 3 | 4 | Note that once you start using one Database, you must stick to it. You cannot jump between Database Implementations and if you do, you loose data. 5 | 6 | ### Data Stored in Database 7 | 8 | 1. Users and their data 9 | 2. Trackers 10 | 3. State of Torrent 11 | 4. Piece Completion State of Torrent 12 | 5. State of Files of Torrent 13 | 6. Lock State of Torrent 14 | -------------------------------------------------------------------------------- /docs/docker.md: -------------------------------------------------------------------------------- 1 | `exatorrent` can be run inside Docker Container 2 | 3 | ## Docker Image 4 | Docker Images of `exatorrent` are officially available for `amd64`,`arm64` and `arm32` architectures. They are built on release of new version by Github Actions and are Hosted and available at [Github Container Registry](https://ghcr.io/varbhat/exatorrent). Alpine is used as Base Image of `exatorrent` image. Docker images of `exatorrent` can be pulled by 5 | 6 | ```bash 7 | docker pull ghcr.io/varbhat/exatorrent:latest 8 | ``` 9 | 10 | This pulls latest version of `exatorrent` Docker Image. You can use [diun](https://github.com/crazy-max/diun) to keep exatorrent updated to latest release as when it rolls out. 11 | 12 | ## Docker Container Image 13 | Docker Image of `exatorrent` can also be built in your machine if you intend to build. Following commands will build `exatorrent` Docker Image. 14 | 15 | ```bash 16 | git clone https://github.com/varbhat/exatorrent 17 | cd exatorrent 18 | docker build -t "exatorrent". 19 | ``` 20 | 21 | ## Usage 22 | Docker Image of `exatorrent` can be run by following command 23 | 24 | ```bash 25 | docker run -p 5000:5000 -p 42069:42069 -v /path/to/directory:/exa/exadir ghcr.io/varbhat/exatorrent:latest 26 | # docker run -p 5000:5000 -p 42069:42069 -v /path/to/directory:/exa/exadir exatorrent 27 | ``` 28 | 5000 port is default port where Web Client and API are served. `42069` is default port for Torrent Client where Torrent Transfers occur. So, they need to be exposed. Also Refer [Usage](usage.md). 29 | 30 | You can use `--user` flag of docker to run `exatorrent` as other user. 31 | 32 | 33 | # Deploy Docs 34 | 35 | ## Reverse Proxy 36 | We recommend running `exatorrent` behind reverse proxy . Below are example configurations of Nginx , Haproxy and Caddy made to reverse proxy `exatorrent` . Please don't put Basic Auth or any kind of custom auth / request modification system as Authentication is handled by `exatorrent` itself . `/api/socket` is WebSocket endpoint and Reverse Proxying server must not hinder it . 37 | 38 | ### Nginx 39 | 40 | ```Nginx 41 | server { 42 | listen 443 ssl http2; 43 | listen [::]:443 ssl http2; 44 | ssl_certificate /path/to/tls/tls.crt; 45 | ssl_certificate_key /path/to/tls/tls.key; 46 | 47 | server_name the.domain.tld; 48 | 49 | location / { 50 | proxy_pass http://localhost:5000; 51 | # proxy_pass http://unix:/path/to/exatorrent/unixsocket; 52 | } 53 | 54 | location /api/socket { 55 | proxy_pass http://localhost:5000/api/socket; 56 | # proxy_pass http://unix:/path/to/exatorrent/unixsocket:/api/socket; 57 | proxy_http_version 1.1; 58 | proxy_set_header Upgrade $http_upgrade; 59 | proxy_set_header Connection "Upgrade"; 60 | proxy_set_header Host $host; 61 | } 62 | 63 | } 64 | ``` 65 | 66 | ### Haproxy 67 | 68 | ```HAProxy 69 | frontend proxy 70 | #bind *:80 71 | bind *:443 ssl crt /path/to/tls/cert.pem 72 | default_backend exatorrent 73 | 74 | backend exatorrent 75 | server exatorrent localhost:5000 76 | ``` 77 | 78 | ### Caddy 79 | 80 | ``` 81 | https://the.domain.tld { 82 | reverse_proxy * localhost:5000 83 | } 84 | ``` 85 | -------------------------------------------------------------------------------- /docs/screenshots.md: -------------------------------------------------------------------------------- 1 |

2 | exatorrent images 3 |

4 |

5 | exatorrent images 6 |

7 |

8 | exatorrent images 9 |

10 |

11 | exatorrent images 12 |

13 |

14 | exatorrent images 15 |

16 |

17 | exatorrent images 18 |

19 |

20 | exatorrent images 21 |

22 |

23 | exatorrent images 24 |

25 |

26 | exatorrent images 27 |

28 |

29 | exatorrent images 30 |

31 |

32 | exatorrent images 33 |

34 |

35 | exatorrent images 36 |

37 |

38 | exatorrent images 39 |

40 |

41 | exatorrent images 42 |

43 |

44 | exatorrent images 45 |

46 |

47 | exatorrent images 48 |

49 |

50 | exatorrent images 51 |

52 |

53 | exatorrent images 54 |

55 | -------------------------------------------------------------------------------- /docs/usage.md: -------------------------------------------------------------------------------- 1 | ## Usage 2 | ```bash 3 | Usage of exatorrent: 4 | -addr Listen Address (Default: ":5000") 5 | -admin Default admin username (Default Username: "adminuser" and Default Password: "adminpassword") 6 | -cert Path to TLS Certificate (Required for HTTPS) 7 | -dir exatorrent Directory (Default: "exadir") 8 | -engc Generate Custom Engine Configuration 9 | -key Path to TLS Key (Required for HTTPS) 10 | -passw Set Default admin password from "EXAPASSWORD" environment variable 11 | -torc Generate Custom Torrent Client Configuration 12 | -unix Unix Socket Path 13 | -help Print this Help 14 | ``` 15 | 16 | ### `-addr` 17 | Listen Address of `exatorrent` . It specifies the TCP address for the `exatorrent` to listen on . It's of form `host:port` . The host must be a literal IP address, or a host name that can be resolved to IP addresses. The port must be a literal port number or a service name. If the host is a literal IPv6 address it must be enclosed in square brackets, as in `[2001:db8::1]:80` or `[fe80::1%zone]:80`. 18 | 19 | Default Listen Address is `:5000` . Open http://localhost:5000 or http://0.0.0.0:5000 or http://127.0.0.1:5000 to use `exatorrent` Web Client if exatorrent is listening on Default Address . 20 | 21 | Valid Listen Addresses include `localhost:9999` , `0.0.0.0:3456` , `127.0.0.1:7777` , `[0:0:0:0:0:0:0:1]:5000` , `x.x.x.x:port` , `[x:x:x:x:x:x:x:x]:port` . 22 | 23 | ### `-admin` 24 | Usernames of Users in `exatorrent` can't be changed after User is created . It must be choosen while creating User and can't be changed later . Note that password can be changed anytime later . 25 | 26 | On the first use of `exatorrent` , `exatorrent` creates Admin user with username `adminuser` and password `adminpassword` . Since this username `adminuser` can't be changed later on , you can customize it before first run itself by passing your desired username to `-admin` flag. Also see `-passw` flag below . 27 | 28 | ```bash 29 | exatorrent -admin "mycustomadminusername" 30 | ``` 31 | 32 | You are advised to use custom username for default admin using this flag . You can always change password of default admin or other users later . 33 | 34 | ### `-cert` 35 | If HTTPS needs to be served , Path to TLS Certificate must be specified in this flag . Note that `-cert` flag is also necessary along with this flag to serve HTTPS . 36 | 37 | ### `-key` 38 | If HTTPS needs to be served , Path to TLS Key must be specified in this flag . Note that `-cert` flag is also necessary along with this flag to serve HTTPS . 39 | 40 | ### `-passw` 41 | On the first use of `exatorrent` , `exatorrent` creates Admin user with username `adminuser` and password `adminpassword` . You can make exatorrent to use custom password rather than default password (`adminpassword`) by `-passw` flag. `-passw` flag sets value of `EXAPASSWORD` environment variable as Password of created Admin User . Also see `-admin` flag above . 42 | 43 | ```bash 44 | EXAPASSWORD="MyCustomPassword" exatorrent -passw 45 | ``` 46 | 47 | ### `-unix` 48 | This flag specifies file path where Unix Socket must be served . This flag is alternative to `-addr` flag and works only on Operating Systems that support [Unix Sockets](https://en.wikipedia.org/wiki/Unix_domain_socket) . 49 | 50 | ### `-dir` 51 | `exatorrent` always operates in specific directory and never leaves beyond that directory . It stores File downloaded through Torrents , Torrent Metadata Files , Sqlite3 Database files if any , Configuration files in this directory. 52 | 53 | `-dir` flag specifies the directory where `exatorrent` must store the data . 54 | 55 | ### `-engc` 56 | Writes Sample Runtime-Configurable Engine connection URL to `/config/engconfig.json` . All Runtime-Configurable Settings are configured in this file . You can change it anytime in Client . 57 | 58 | ### `-torc` 59 | Writes Sample Torrent Client connection URL to `/config/clientconfig.json` . These settings cannot be configured during runtime and must only be configured before starting `exatorrent` . 60 | 61 | 62 | ## Blocklist 63 | Blocklist of PeerGuardian Text Lists (P2P) Format can be placed at `/config/blocklist` to apply blocking based upon blocklist . You are also required to set `IPBlocklist` value of `clentconfig.json` to `true` . Note that Blocklist can't be changed during runtime . 64 | 65 | ## Rate Limiter 66 | `UploadLimiterLimit` , `UploadLimiterBurst` , `DownloadLimiterLimit` , `DownloadLimiterBurst` values of `clientconfig.json` can be used to Rate Limit `exatorrent` . 67 | -------------------------------------------------------------------------------- /exatorrent.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net" 5 | "net/http" 6 | "os" 7 | "os/signal" 8 | "syscall" 9 | 10 | "github.com/varbhat/exatorrent/internal/core" 11 | "github.com/varbhat/exatorrent/internal/web" 12 | ) 13 | 14 | func main() { 15 | core.Initialize() 16 | 17 | http.HandleFunc("/api/socket", core.SocketAPI) 18 | http.HandleFunc("/api/auth", core.AuthCheck) 19 | http.HandleFunc("/api/stream/", core.StreamFile) 20 | http.HandleFunc("/api/torrent/", core.TorrentServe) 21 | http.Handle("/", web.FrontEndHandler) 22 | 23 | if core.Flagconfig.UnixSocket != "" { 24 | // Run under specified Unix Socket Path 25 | core.Info.Println("Starting server at Path (Unix Socket)", core.Flagconfig.UnixSocket) 26 | usock, err := net.Listen("unix", core.Flagconfig.UnixSocket) 27 | if err != nil { 28 | core.Err.Fatalln("Failed listening", err) 29 | } 30 | go func() { 31 | if core.Flagconfig.TLSCertPath != "" && core.Flagconfig.TLSKeyPath != "" { 32 | core.Info.Println("Serving the HTTPS with TLS Cert ", core.Flagconfig.TLSCertPath, " and TLS Key", core.Flagconfig.TLSKeyPath) 33 | core.Err.Fatal(http.ServeTLS(usock, nil, core.Flagconfig.TLSCertPath, core.Flagconfig.TLSKeyPath)) 34 | } else { 35 | core.Err.Fatal(http.Serve(usock, nil)) 36 | } 37 | }() 38 | c := make(chan os.Signal, 1) 39 | signal.Notify(c, os.Interrupt, syscall.SIGHUP, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT) 40 | <-c 41 | _ = usock.Close() 42 | } else { 43 | // Run under specified Port 44 | core.Info.Println("Starting server on", core.Flagconfig.ListenAddress) 45 | if core.Flagconfig.TLSCertPath != "" && core.Flagconfig.TLSKeyPath != "" { 46 | core.Info.Println("Serving the HTTPS with TLS Cert ", core.Flagconfig.TLSCertPath, " and TLS Key", core.Flagconfig.TLSKeyPath) 47 | core.Err.Fatal(http.ListenAndServeTLS(core.Flagconfig.ListenAddress, core.Flagconfig.TLSCertPath, core.Flagconfig.TLSKeyPath, nil)) 48 | } else { 49 | core.Err.Fatal(http.ListenAndServe(core.Flagconfig.ListenAddress, nil)) 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/varbhat/exatorrent 2 | 3 | go 1.24.0 4 | 5 | require ( 6 | github.com/anacrolix/chansync v0.6.0 7 | github.com/anacrolix/go-libutp v1.3.2 8 | github.com/anacrolix/log v0.16.0 9 | github.com/anacrolix/torrent v1.58.1 10 | github.com/go-llsqlite/crawshaw v0.5.5 11 | github.com/google/uuid v1.6.0 12 | github.com/gorilla/websocket v1.5.3 13 | github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 14 | github.com/shirou/gopsutil/v3 v3.24.5 15 | golang.org/x/crypto v0.38.0 16 | golang.org/x/time v0.11.0 17 | ) 18 | 19 | require ( 20 | github.com/RoaringBitmap/roaring v1.9.4 // indirect 21 | github.com/ajwerner/btree v0.0.0-20211221152037-f427b3e689c0 // indirect 22 | github.com/alecthomas/atomic v0.1.0-alpha2 // indirect 23 | github.com/anacrolix/dht/v2 v2.22.1 // indirect 24 | github.com/anacrolix/envpprof v1.4.0 // indirect 25 | github.com/anacrolix/generics v0.0.3-0.20240902042256-7fb2702ef0ca // indirect 26 | github.com/anacrolix/missinggo v1.3.0 // indirect 27 | github.com/anacrolix/missinggo/perf v1.0.0 // indirect 28 | github.com/anacrolix/missinggo/v2 v2.8.0 // indirect 29 | github.com/anacrolix/mmsg v1.1.1 // indirect 30 | github.com/anacrolix/multiless v0.4.0 // indirect 31 | github.com/anacrolix/stm v0.5.0 // indirect 32 | github.com/anacrolix/sync v0.5.3 // indirect 33 | github.com/anacrolix/upnp v0.1.4 // indirect 34 | github.com/anacrolix/utp v0.2.0 // indirect 35 | github.com/bahlo/generic-list-go v0.2.0 // indirect 36 | github.com/benbjohnson/immutable v0.4.3 // indirect 37 | github.com/bits-and-blooms/bitset v1.22.0 // indirect 38 | github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8 // indirect 39 | github.com/cespare/xxhash v1.1.0 // indirect 40 | github.com/davecgh/go-spew v1.1.1 // indirect 41 | github.com/dustin/go-humanize v1.0.1 // indirect 42 | github.com/edsrzf/mmap-go v1.2.0 // indirect 43 | github.com/go-llsqlite/adapter v0.2.0 // indirect 44 | github.com/go-logr/logr v1.4.2 // indirect 45 | github.com/go-logr/stdr v1.2.2 // indirect 46 | github.com/go-ole/go-ole v1.3.0 // indirect 47 | github.com/google/btree v1.1.3 // indirect 48 | github.com/huandu/xstrings v1.5.0 // indirect 49 | github.com/klauspost/cpuid/v2 v2.2.10 // indirect 50 | github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 // indirect 51 | github.com/mattn/go-isatty v0.0.20 // indirect 52 | github.com/minio/sha256-simd v1.0.1 // indirect 53 | github.com/mr-tron/base58 v1.2.0 // indirect 54 | github.com/mschoch/smat v0.2.0 // indirect 55 | github.com/multiformats/go-multihash v0.2.3 // indirect 56 | github.com/multiformats/go-varint v0.0.7 // indirect 57 | github.com/ncruces/go-strftime v0.1.9 // indirect 58 | github.com/pion/datachannel v1.5.10 // indirect 59 | github.com/pion/dtls/v3 v3.0.6 // indirect 60 | github.com/pion/ice/v4 v4.0.10 // indirect 61 | github.com/pion/interceptor v0.1.37 // indirect 62 | github.com/pion/logging v0.2.3 // indirect 63 | github.com/pion/mdns/v2 v2.0.7 // indirect 64 | github.com/pion/randutil v0.1.0 // indirect 65 | github.com/pion/rtcp v1.2.15 // indirect 66 | github.com/pion/rtp v1.8.15 // indirect 67 | github.com/pion/sctp v1.8.39 // indirect 68 | github.com/pion/sdp/v3 v3.0.11 // indirect 69 | github.com/pion/srtp/v3 v3.0.4 // indirect 70 | github.com/pion/stun/v3 v3.0.0 // indirect 71 | github.com/pion/transport/v3 v3.0.7 // indirect 72 | github.com/pion/turn/v4 v4.0.1 // indirect 73 | github.com/pion/webrtc/v4 v4.1.0 // indirect 74 | github.com/pkg/errors v0.9.1 // indirect 75 | github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect 76 | github.com/protolambda/ctxlock v0.1.0 // indirect 77 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect 78 | github.com/rs/dnscache v0.0.0-20230804202142-fc85eb664529 // indirect 79 | github.com/shoenig/go-m1cpu v0.1.6 // indirect 80 | github.com/spaolacci/murmur3 v1.1.0 // indirect 81 | github.com/tidwall/btree v1.7.0 // indirect 82 | github.com/tklauser/go-sysconf v0.3.15 // indirect 83 | github.com/tklauser/numcpus v0.10.0 // indirect 84 | github.com/wlynxg/anet v0.0.5 // indirect 85 | github.com/yusufpapurcu/wmi v1.2.4 // indirect 86 | go.etcd.io/bbolt v1.4.0 // indirect 87 | go.opentelemetry.io/auto/sdk v1.1.0 // indirect 88 | go.opentelemetry.io/otel v1.35.0 // indirect 89 | go.opentelemetry.io/otel/metric v1.35.0 // indirect 90 | go.opentelemetry.io/otel/trace v1.35.0 // indirect 91 | golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect 92 | golang.org/x/net v0.39.0 // indirect 93 | golang.org/x/sync v0.13.0 // indirect 94 | golang.org/x/sys v0.33.0 // indirect 95 | lukechampine.com/blake3 v1.4.0 // indirect 96 | modernc.org/libc v1.65.0 // indirect 97 | modernc.org/mathutil v1.7.1 // indirect 98 | modernc.org/memory v1.10.0 // indirect 99 | modernc.org/sqlite v1.37.0 // indirect 100 | zombiezen.com/go/sqlite v1.4.0 // indirect 101 | ) 102 | -------------------------------------------------------------------------------- /internal/core/auth.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "time" 9 | 10 | "github.com/google/uuid" 11 | ) 12 | 13 | func AuthCheck(w http.ResponseWriter, r *http.Request) { 14 | var username string 15 | var usertype int 16 | var token string 17 | var err error 18 | if r.Method == http.MethodPost { 19 | c, err := r.Cookie("session_token") 20 | if err != nil { 21 | if val := r.URL.Query().Get("token"); val != "" { 22 | username, usertype, err = Engine.UDb.ValidateToken(val) 23 | if err != nil { 24 | username, usertype, token, err = rauthHelper(w, r) 25 | if err != nil { 26 | Warn.Printf("not authorized (%s)\n", r.RemoteAddr) 27 | http.Error(w, "invalid credentials", http.StatusBadRequest) 28 | return 29 | } 30 | } else { 31 | token = val 32 | } 33 | } else { 34 | username, usertype, token, err = rauthHelper(w, r) 35 | if err != nil { 36 | Warn.Printf("not authorized (%s)\n", r.RemoteAddr) 37 | http.Error(w, "invalid credentials", http.StatusBadRequest) 38 | return 39 | } 40 | } 41 | } else { 42 | username, usertype, err = Engine.UDb.ValidateToken(c.Value) 43 | if err != nil { 44 | username, usertype, token, err = rauthHelper(w, r) 45 | if err != nil { 46 | Warn.Printf("not authorized (%s)\n", r.RemoteAddr) 47 | http.Error(w, "invalid credentials", http.StatusBadRequest) 48 | return 49 | } 50 | } else { 51 | token = c.Value 52 | } 53 | } 54 | } else { 55 | username, usertype, token, err = authHelper(w, r) 56 | if err != nil { 57 | Warn.Printf("%s (%s)\n", err, r.RemoteAddr) 58 | return 59 | } 60 | } 61 | var cmsg []byte 62 | if usertype == 0 { 63 | cmsg, err = json.Marshal(&ConnectionMsg{Type: "user", Session: token}) 64 | } else if usertype == 1 { 65 | cmsg, err = json.Marshal(&ConnectionMsg{Type: "admin", Session: token}) 66 | } else if usertype == -1 { 67 | cmsg, err = json.Marshal(&ConnectionMsg{Type: "disabled", Session: token}) 68 | } 69 | if err != nil { 70 | return 71 | } 72 | _, err = w.Write(cmsg) 73 | if err != nil { 74 | return 75 | } 76 | Info.Printf("User %s (%s) Authenticated", username, r.RemoteAddr) 77 | 78 | } 79 | 80 | func authHelper(w http.ResponseWriter, r *http.Request) (username string, usertype int, token string, err error) { 81 | c, err := r.Cookie("session_token") 82 | if err != nil { 83 | if val := r.URL.Query().Get("token"); val != "" { 84 | username, usertype, err = Engine.UDb.ValidateToken(val) 85 | if err != nil { 86 | return bauthHelper(w, r) 87 | } 88 | token = val 89 | } else { 90 | return bauthHelper(w, r) 91 | } 92 | } else { 93 | username, usertype, err = Engine.UDb.ValidateToken(c.Value) 94 | if err != nil { 95 | return bauthHelper(w, r) 96 | } 97 | token = c.Value 98 | } 99 | return username, usertype, token, nil 100 | } 101 | 102 | func bauthHelper(w http.ResponseWriter, r *http.Request) (username string, usertype int, token string, err error) { 103 | defer func() { 104 | if r := recover(); r != nil { // uuid may panic 105 | username = "" 106 | usertype = -1 107 | token = "" 108 | err = fmt.Errorf("uuid error") 109 | return 110 | } 111 | }() 112 | var password string 113 | var ok bool 114 | w.Header().Set("WWW-Authenticate", `Basic realm="Protected"`) 115 | username, password, ok = r.BasicAuth() 116 | if !ok { 117 | http.Error(w, "Not authorized", http.StatusUnauthorized) 118 | return "", -1, "", fmt.Errorf("not authorized") 119 | } 120 | 121 | if len(username) < 5 || len(password) < 5 { 122 | http.Error(w, "Not authorized", http.StatusUnauthorized) 123 | return "", -1, "", fmt.Errorf("not authorized") 124 | } 125 | 126 | usertype, ok = Engine.UDb.Validate(username, password) 127 | if !ok { 128 | http.Error(w, "Not authorized", http.StatusUnauthorized) 129 | return "", -1, "", fmt.Errorf("not authorized") 130 | } 131 | token = uuid.New().String() 132 | err = Engine.UDb.SetToken(username, token) 133 | if err != nil { 134 | http.Error(w, "Not authorized", http.StatusUnauthorized) 135 | return "", -1, "", fmt.Errorf("not authorized") 136 | } 137 | http.SetCookie(w, &http.Cookie{ 138 | Name: "session_token", 139 | Path: "/", 140 | Value: token, 141 | Expires: time.Now().Add(48 * time.Hour), 142 | SameSite: http.SameSiteStrictMode, 143 | }) 144 | return username, usertype, token, nil 145 | } 146 | 147 | func rauthHelper(w http.ResponseWriter, r *http.Request) (username string, usertype int, token string, err error) { 148 | defer func() { 149 | if r := recover(); r != nil { // uuid may panic 150 | username = "" 151 | usertype = -1 152 | token = "" 153 | err = fmt.Errorf("uuid error") 154 | return 155 | } 156 | }() 157 | var ok bool 158 | var cred ConReq 159 | reqreader := io.LimitReader(r.Body, 1048576) // 1MB limiter 160 | err = json.NewDecoder(reqreader).Decode(&cred) 161 | if err != nil { 162 | return "", -1, "", err 163 | } 164 | if len(cred.Data1) < 5 || len(cred.Data2) < 5 { 165 | return "", -1, "", fmt.Errorf("invalid credentials") 166 | } 167 | usertype, ok = Engine.UDb.Validate(cred.Data1, cred.Data2) 168 | if !ok { 169 | return "", -1, "", fmt.Errorf("invalid credentials") 170 | } 171 | token = uuid.New().String() 172 | err = Engine.UDb.SetToken(cred.Data1, token) 173 | if err != nil { 174 | return "", -1, "", fmt.Errorf("not authorized") 175 | } 176 | http.SetCookie(w, &http.Cookie{ 177 | Name: "session_token", 178 | Path: "/", 179 | Value: token, 180 | Expires: time.Now().Add(48 * time.Hour), 181 | SameSite: http.SameSiteStrictMode, 182 | }) 183 | username = cred.Data1 184 | return username, usertype, token, nil 185 | } 186 | -------------------------------------------------------------------------------- /internal/core/cache.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | 8 | "github.com/anacrolix/torrent" 9 | "github.com/anacrolix/torrent/metainfo" 10 | ) 11 | 12 | func AddMetaCache(ih metainfo.Hash, mi metainfo.MetaInfo) { 13 | // create .torrent file 14 | if w, err := os.Stat(Dirconfig.CacheDir); err == nil && w.IsDir() { 15 | cacheFilePath := filepath.Join(Dirconfig.CacheDir, fmt.Sprintf("%s.torrent", ih.HexString())) 16 | // only create the cache file if not exists 17 | if _, err := os.Stat(cacheFilePath); os.IsNotExist(err) { 18 | cf, err := os.Create(cacheFilePath) 19 | if err == nil { 20 | werr := mi.Write(cf) 21 | if werr != nil { 22 | Warn.Println("failed to create torrent file ", err) 23 | } 24 | Info.Println("created torrent cache file", ih.HexString()) 25 | } else { 26 | Warn.Println("failed to create torrent file ", err) 27 | } 28 | _ = cf.Close() 29 | } 30 | } 31 | 32 | } 33 | 34 | func RemMetaCache(ih metainfo.Hash) { 35 | _ = os.Remove(filepath.Join(Dirconfig.CacheDir, fmt.Sprintf("%s.torrent", ih.HexString()))) 36 | } 37 | 38 | func GetMetaCache(ih metainfo.Hash) (spec *torrent.TorrentSpec, reterr error) { 39 | return SpecfromPath(filepath.Join(Dirconfig.CacheDir, fmt.Sprintf("%s.torrent", ih.HexString()))) 40 | } 41 | 42 | func EmptyMetaCache() { 43 | _ = os.Remove(Dirconfig.CacheDir) 44 | checkDir(Dirconfig.CacheDir) 45 | } 46 | -------------------------------------------------------------------------------- /internal/core/connection.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "net/http" 7 | "sync" 8 | "time" 9 | 10 | "github.com/anacrolix/torrent/metainfo" 11 | 12 | "github.com/gorilla/websocket" 13 | ) 14 | 15 | // Hub 16 | type Hub struct { 17 | sync.RWMutex 18 | Conns map[string]*UserConn 19 | } 20 | 21 | func (h *Hub) Add(Uc *UserConn) { 22 | conn, ok := h.Conns[Uc.Username] 23 | if ok && conn != nil { 24 | conn.Close() 25 | } 26 | h.Lock() 27 | h.Conns[Uc.Username] = Uc 28 | h.Unlock() 29 | 30 | Info.Printf("User %s (%s) Connected\n", Uc.Username, Uc.Conn.RemoteAddr().String()) 31 | } 32 | 33 | func (h *Hub) SendMsg(User string, Type string, State string, Resp string) { 34 | if User != "" { 35 | conn, ok := h.Conns[User] 36 | if ok && conn != nil { 37 | _ = conn.SendMsg(Type, State, Resp) 38 | } 39 | } 40 | } 41 | 42 | func (h *Hub) SendMsgU(User string, Type string, Infohash string, State string, Resp string) { 43 | if User != "" { 44 | conn, ok := h.Conns[User] 45 | if ok && conn != nil { 46 | _ = conn.SendMsgU(Type, State, Infohash, Resp) 47 | } 48 | } 49 | } 50 | 51 | func (h *Hub) Remove(Uc *UserConn) { 52 | if Uc == nil { 53 | return 54 | } 55 | h.Lock() 56 | defer h.Unlock() 57 | conn, ok := h.Conns[Uc.Username] 58 | if ok && conn != nil { 59 | if conn.Time == Uc.Time { 60 | delete(h.Conns, Uc.Username) 61 | Info.Printf("User %s (%s) Disconnected\n", Uc.Username, Uc.Conn.RemoteAddr().String()) 62 | } 63 | } 64 | } 65 | 66 | func (h *Hub) RemoveUser(Username string) { 67 | conn, ok := h.Conns[Username] 68 | if ok && conn != nil { 69 | conn.Close() 70 | } 71 | } 72 | 73 | func (h *Hub) ListUsers() (ret []byte) { 74 | var userconnmsg []*UserConnMsg 75 | h.Lock() 76 | defer h.Unlock() 77 | for name, user := range h.Conns { 78 | var usermsg UserConnMsg 79 | if user != nil { 80 | usermsg.Username = name 81 | usermsg.IsAdmin = user.IsAdmin 82 | usermsg.Time = user.Time 83 | } 84 | userconnmsg = append(userconnmsg, &usermsg) 85 | } 86 | ret, _ = json.Marshal(DataMsg{Type: "userconn", Data: userconnmsg}) 87 | return 88 | } 89 | 90 | var MainHub Hub = Hub{ 91 | RWMutex: sync.RWMutex{}, 92 | Conns: make(map[string]*UserConn), 93 | } 94 | 95 | // UserConn 96 | type UserConn struct { 97 | Sendmu sync.Mutex 98 | Username string 99 | IsAdmin bool 100 | Time time.Time 101 | Conn *websocket.Conn 102 | Stream sync.Mutex 103 | Streamers MutInt 104 | } 105 | 106 | func NewUserConn(Username string, Conn *websocket.Conn, IsAdmin bool) (uc *UserConn) { 107 | uc = &UserConn{ 108 | Username: Username, 109 | Conn: Conn, 110 | IsAdmin: IsAdmin, 111 | Time: time.Now(), 112 | } 113 | MainHub.Add(uc) 114 | return 115 | } 116 | 117 | func (uc *UserConn) SendMsg(Type string, State string, Msg string) (err error) { 118 | resp, _ := json.Marshal(Resp{Type: Type, State: State, Msg: Msg}) 119 | err = uc.Send(resp) 120 | return 121 | } 122 | 123 | func (uc *UserConn) SendMsgU(Type string, State string, Infohash string, Msg string) (err error) { 124 | resp, _ := json.Marshal(Resp{Type: Type, State: State, Infohash: Infohash, Msg: Msg}) 125 | err = uc.Send(resp) 126 | return 127 | } 128 | 129 | func (uc *UserConn) Send(v []byte) (err error) { 130 | uc.Sendmu.Lock() 131 | _ = uc.Conn.SetWriteDeadline(time.Now().Add(writeWait)) 132 | err = uc.Conn.WriteMessage(websocket.TextMessage, v) 133 | uc.Sendmu.Unlock() 134 | if err != nil { 135 | Err.Println(err) 136 | uc.Close() 137 | return 138 | } 139 | return 140 | } 141 | 142 | func (uc *UserConn) StopStream() { 143 | uc.Streamers.Inc() 144 | uc.Stream.Lock() 145 | Info.Println("Stopped Stream for ", uc.Username) 146 | uc.Stream.Unlock() 147 | uc.Streamers.Dec() 148 | } 149 | 150 | func (uc *UserConn) Close() { 151 | uc.Sendmu.Lock() 152 | _ = uc.Conn.Close() 153 | uc.Sendmu.Unlock() 154 | MainHub.Remove(uc) 155 | } 156 | 157 | func sendPostReq(h metainfo.Hash, url string, name string) { 158 | Info.Println("Torrent ", h, " has completed. Sending POST request to ", url) 159 | postrequest := struct { 160 | Metainfo metainfo.Hash `json:"metainfo"` 161 | Name string `json:"name"` 162 | State string `json:"state"` 163 | Time time.Time `json:"time"` 164 | }{ 165 | Metainfo: h, 166 | Name: name, 167 | Time: time.Now(), 168 | State: "torrent-completed-exatorrent", 169 | } 170 | 171 | jsonData, err := json.Marshal(postrequest) 172 | 173 | if err != nil { 174 | Warn.Println(err) 175 | return 176 | } 177 | 178 | resp, err := http.Post(url, "application/json", bytes.NewBuffer(jsonData)) 179 | 180 | if err != nil { 181 | Warn.Println("POST Request failed to Send. Hook failed") 182 | Warn.Println(err) 183 | return 184 | } 185 | 186 | if resp != nil { 187 | resp.Body.Close() 188 | } 189 | 190 | Info.Println("POST Request Sent. Hook Succeeded") 191 | } 192 | -------------------------------------------------------------------------------- /internal/core/get.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "os" 8 | "path/filepath" 9 | "strings" 10 | "time" 11 | 12 | "github.com/anacrolix/torrent" 13 | "github.com/anacrolix/torrent/metainfo" 14 | ) 15 | 16 | type Torrent1 struct { 17 | Infohash string `json:"infohash"` 18 | Name string `json:"name,omitempty"` 19 | BytesCompleted int64 `json:"bytescompleted,omitempty"` 20 | BytesMissing int64 `json:"bytesmissing,omitempty"` 21 | BytesWritten int64 `json:"byteswritten,omitempty"` 22 | Length int64 `json:"length,omitempty"` 23 | State string `json:"state"` 24 | Seeding bool `json:"seeding,omitempty"` 25 | Private bool `json:"private,omitempty"` 26 | CreationDate int64 `json:"creationdate,omitempty"` 27 | AddedDate int64 `json:"addeddate,omitempty"` 28 | StartedDate int64 `json:"starteddate,omitempty"` 29 | TotalPeers int `json:"totalpeers,omitempty"` 30 | ActivePeers int `json:"activepeers,omitempty"` 31 | ConnectedSeeders int `json:"connectedseeders,omitempty"` 32 | AnnounceList []string `json:"announcelist,omitempty"` 33 | } 34 | 35 | type Torrent2 struct { 36 | Torrent1 37 | StartedAt time.Time `json:"startedat"` 38 | AddedAt time.Time `json:"addedat"` 39 | } 40 | 41 | type FileInfo struct { 42 | BytesCompleted int64 `json:"bytescompleted"` 43 | DisplayPath string `json:"displaypath"` 44 | Length int64 `json:"length"` 45 | Offset int64 `json:"offset"` 46 | Path string `json:"path"` 47 | Priority byte `json:"priority"` 48 | } 49 | 50 | type FsFileInfo struct { 51 | Name string `json:"name"` 52 | Path string `json:"path"` 53 | Size int64 `json:"size"` 54 | IsDir bool `json:"isdir"` 55 | } 56 | 57 | type PeerInfo struct { 58 | RemoteAddr string `json:"remoteaddr"` 59 | PeerClientName string `json:"peerclientname"` 60 | DownloadRate float64 `json:"downloadrate"` 61 | PeerPreferEncryption bool `json:"peerpreferencryption"` 62 | } 63 | 64 | func GetTorrent(ih metainfo.Hash) (ret Torrent1) { 65 | ret.Infohash = ih.HexString() 66 | t, ok := Engine.Torc.Torrent(ih) 67 | if !ok { 68 | ret.State = "removed" 69 | return 70 | } 71 | ret.Name = t.Name() 72 | if t == nil || t.Info() == nil { 73 | ret.State = "loading" 74 | return 75 | } 76 | if Engine.TorDb.HasStarted(ih.HexString()) { 77 | ret.State = "active" 78 | } else { 79 | ret.State = "inactive" 80 | } 81 | tdb, _ := Engine.TorDb.GetTorrent(t.InfoHash()) 82 | if tdb != nil { 83 | ret.AddedDate = tdb.AddedAt.Unix() 84 | ret.StartedDate = tdb.StartedAt.Unix() 85 | } 86 | 87 | stats := t.Stats() 88 | info := t.Metainfo() 89 | 90 | if t.Info().Private != nil { 91 | ret.Private = *t.Info().Private 92 | } 93 | 94 | ret.Length = t.Length() 95 | ret.BytesCompleted = t.BytesCompleted() 96 | ret.BytesMissing = t.BytesMissing() 97 | ret.BytesWritten = stats.BytesWrittenData.Int64() 98 | ret.Seeding = t.Seeding() 99 | ret.CreationDate = info.CreationDate 100 | ret.TotalPeers = stats.TotalPeers 101 | ret.ActivePeers = stats.ActivePeers 102 | ret.ConnectedSeeders = stats.ConnectedSeeders 103 | ret.AnnounceList = info.AnnounceList.DistinctValues() 104 | 105 | return 106 | } 107 | 108 | func GetTorrents(lt []metainfo.Hash) (ret []byte) { 109 | var tinits []*Torrent1 110 | for _, ih := range lt { 111 | tinit := GetTorrent(ih) 112 | tinits = append(tinits, &tinit) 113 | } 114 | ret, _ = json.Marshal(DataMsg{Type: "torrentstream", Data: tinits}) 115 | return 116 | } 117 | 118 | func GetTorrentInfo(ih metainfo.Hash) (ret []byte) { 119 | tinit := GetTorrent(ih) 120 | ret, _ = json.Marshal(DataMsg{Type: "torrentinfo", Data: tinit}) 121 | return 122 | } 123 | 124 | func GetTorrentInfoStat(ih metainfo.Hash) (ret []byte) { 125 | trnt, err := Engine.TorDb.GetTorrent(ih) 126 | if err == nil { 127 | ret, _ = json.Marshal(DataMsg{Type: "torrentinfostat", Data: trnt}) 128 | } 129 | return 130 | } 131 | 132 | func GetTorrentStats(ih metainfo.Hash) (ret []byte) { 133 | defer func() { 134 | if r := recover(); r != nil { 135 | Warn.Println("There was Panic in GetTorrentStats") 136 | } 137 | }() 138 | t, ok := Engine.Torc.Torrent(ih) 139 | if !ok { 140 | ret, _ = json.Marshal(DataMsg{Type: "torrentstats", Data: nil}) 141 | return 142 | } 143 | if t == nil || t.Info() == nil { 144 | ret, _ = json.Marshal(DataMsg{Type: "torrentstats", Data: nil}) 145 | return 146 | } 147 | ts := t.Stats() 148 | ret, _ = json.Marshal(DataMsg{Data: &ts, Infohash: ih.HexString(), Type: "torrentstats"}) 149 | return 150 | } 151 | 152 | func GetFsDirInfo(ih metainfo.Hash, fp string) (ret []byte) { 153 | fp = filepath.ToSlash(fp) 154 | ret, _ = json.Marshal(DataMsg{Type: "fsdirinfo", Data: nil}) 155 | ihs := ih.HexString() 156 | if ihs == "" { 157 | Warn.Println("empty infohash") 158 | return 159 | } 160 | dirpath := filepath.Join(Dirconfig.TrntDir, ihs, fp) 161 | 162 | if !strings.HasPrefix(dirpath, filepath.Join(Dirconfig.TrntDir, ihs)) { 163 | return 164 | } 165 | rl, err := os.ReadDir(filepath.FromSlash(dirpath)) 166 | if err != nil { 167 | Warn.Println(err.Error()) 168 | return 169 | } 170 | 171 | var retfiles []FsFileInfo 172 | for _, eachdirentry := range rl { 173 | var retfile FsFileInfo 174 | inf, err := eachdirentry.Info() 175 | if err != nil { 176 | continue 177 | } 178 | retfile.Name = inf.Name() 179 | retfile.IsDir = inf.IsDir() 180 | retfile.Size = inf.Size() 181 | retfile.Path = filepath.ToSlash(filepath.Join(fp, retfile.Name)) 182 | retfiles = append(retfiles, retfile) 183 | } 184 | ret, _ = json.Marshal(DataMsg{Infohash: ih.HexString(), Type: "fsdirinfo", Data: retfiles}) 185 | return 186 | } 187 | 188 | func GetFsFileInfo(ih metainfo.Hash, fp string) (ret []byte) { 189 | fp = filepath.ToSlash(fp) 190 | ret, _ = json.Marshal(DataMsg{Type: "fsfileinfo", Data: nil}) 191 | ihs := ih.HexString() 192 | if ihs == "" { 193 | Warn.Println("empty infohash") 194 | return 195 | } 196 | dirpath := filepath.Join(Dirconfig.TrntDir, ihs, fp) 197 | 198 | if !strings.HasPrefix(dirpath, filepath.Join(Dirconfig.TrntDir, ihs)) { 199 | return 200 | } 201 | var retfile FsFileInfo 202 | inf, err := os.Stat(filepath.FromSlash(dirpath)) 203 | if err != nil { 204 | Warn.Println("GetFsFileInfo Error", err) 205 | return 206 | } 207 | retfile.Name = inf.Name() 208 | retfile.IsDir = inf.IsDir() 209 | retfile.Size = inf.Size() 210 | retfile.Path = fp 211 | ret, _ = json.Marshal(DataMsg{Infohash: ih.HexString(), Type: "fsfileinfo", Data: retfile}) 212 | return 213 | } 214 | 215 | func GetTorrentFiles(ih metainfo.Hash) (ret []byte) { 216 | ret, _ = json.Marshal(DataMsg{Type: "torrentfiles", Data: nil}) 217 | t, ok := Engine.Torc.Torrent(ih) 218 | if !ok { 219 | return 220 | } 221 | if t == nil || t.Info() == nil { 222 | return 223 | } 224 | 225 | var retfiles []FileInfo 226 | for _, file := range t.Files() { 227 | if file == nil { 228 | continue 229 | } 230 | var retfile FileInfo 231 | retfile.BytesCompleted = file.BytesCompleted() 232 | retfile.DisplayPath = file.DisplayPath() 233 | retfile.Length = file.Length() 234 | retfile.Offset = file.Offset() 235 | retfile.Path = file.Path() 236 | retfile.Priority = byte(file.Priority()) 237 | 238 | retfiles = append(retfiles, retfile) 239 | } 240 | 241 | ret, _ = json.Marshal(DataMsg{Infohash: ih.HexString(), Type: "torrentfiles", Data: retfiles}) 242 | return 243 | } 244 | 245 | func GetTorrentFileInfo(ih metainfo.Hash, fp string) (ret []byte) { 246 | ret, _ = json.Marshal(DataMsg{Type: "torrentfileinfo", Data: nil}) 247 | t, ok := Engine.Torc.Torrent(ih) 248 | if !ok { 249 | return 250 | } 251 | if t == nil || t.Info() == nil { 252 | return 253 | } 254 | 255 | // Get File from Given Torrent 256 | var file *torrent.File 257 | for _, f := range t.Files() { 258 | if f.Path() == fp { 259 | file = f 260 | break 261 | } 262 | } 263 | 264 | if file == nil { 265 | return 266 | } 267 | var retfile FileInfo 268 | retfile.BytesCompleted = file.BytesCompleted() 269 | retfile.DisplayPath = file.DisplayPath() 270 | retfile.Length = file.Length() 271 | retfile.Offset = file.Offset() 272 | retfile.Path = file.Path() 273 | retfile.Priority = byte(file.Priority()) 274 | 275 | ret, _ = json.Marshal(DataMsg{Infohash: ih.HexString(), Type: "torrentfileinfo", Data: retfile}) 276 | return 277 | } 278 | 279 | func GetTorrentPieceStateRuns(ih metainfo.Hash) (ret []byte) { 280 | ret, _ = json.Marshal(DataMsg{Type: "torrentpiecestateruns", Data: nil}) 281 | t, ok := Engine.Torc.Torrent(ih) 282 | if !ok { 283 | return 284 | } 285 | if t == nil || t.Info() == nil { 286 | return 287 | } 288 | 289 | ret, _ = json.Marshal(DataMsg{Infohash: ih.HexString(), Type: "torrentpiecestateruns", Data: t.PieceStateRuns()}) 290 | return 291 | } 292 | 293 | func GetTorrentKnownSwarm(ih metainfo.Hash) (ret []byte) { 294 | ret, _ = json.Marshal(DataMsg{Type: "torrentknownswarm", Data: nil}) 295 | t, ok := Engine.Torc.Torrent(ih) 296 | if !ok { 297 | return 298 | } 299 | if t == nil || t.Info() == nil { 300 | return 301 | } 302 | 303 | ret, _ = json.Marshal(DataMsg{Infohash: ih.HexString(), Type: "torrentknownswarm", Data: t.KnownSwarm()}) 304 | return 305 | } 306 | 307 | func GetTorrentNumpieces(ih metainfo.Hash) (ret []byte) { 308 | ret, _ = json.Marshal(DataMsg{Type: "torrentnumpieces", Data: nil}) 309 | t, ok := Engine.Torc.Torrent(ih) 310 | if !ok { 311 | return 312 | } 313 | if t == nil || t.Info() == nil { 314 | return 315 | } 316 | 317 | ret, _ = json.Marshal(DataMsg{Infohash: ih.HexString(), Type: "torrentnumpieces", Data: t.NumPieces()}) 318 | return 319 | } 320 | 321 | func GetTorrentMetainfo(ih metainfo.Hash) (ret []byte) { 322 | ret, _ = json.Marshal(DataMsg{Type: "torrentmetainfo", Data: nil}) 323 | t, ok := Engine.Torc.Torrent(ih) 324 | if !ok { 325 | return 326 | } 327 | if t == nil || t.Info() == nil { 328 | return 329 | } 330 | mi := t.Metainfo() 331 | mi.CreatedBy = "exatorrent" 332 | var tmi bytes.Buffer 333 | err := mi.Write(&tmi) 334 | if err != nil { 335 | return 336 | } 337 | ret, _ = json.Marshal(DataMsg{Infohash: ih.HexString(), Type: "torrentmetainfo", Data: tmi.Bytes()}) 338 | return 339 | } 340 | 341 | func GetTorrentPeerConns(ih metainfo.Hash) (ret []byte) { 342 | ret, _ = json.Marshal(DataMsg{Type: "torrentpeerconns", Data: nil}) 343 | t, ok := Engine.Torc.Torrent(ih) 344 | if !ok { 345 | return 346 | } 347 | if t == nil || t.Info() == nil { 348 | return 349 | } 350 | 351 | var retpeers []PeerInfo 352 | for _, peerconn := range t.PeerConns() { 353 | var peerinfo PeerInfo 354 | peerinfo.RemoteAddr = peerconn.Peer.RemoteAddr.String() 355 | peerinfo.PeerClientName = fmt.Sprint(peerconn.PeerClientName.Load()) 356 | peerinfo.DownloadRate = peerconn.DownloadRate() 357 | peerinfo.PeerPreferEncryption = peerconn.PeerPrefersEncryption 358 | retpeers = append(retpeers, peerinfo) 359 | } 360 | 361 | ret, _ = json.Marshal(DataMsg{Infohash: ih.HexString(), Type: "torrentpeerconns", Data: retpeers}) 362 | return 363 | } 364 | -------------------------------------------------------------------------------- /internal/core/getspec.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "bytes" 5 | "encoding/base64" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "os" 10 | "strings" 11 | 12 | "github.com/anacrolix/torrent" 13 | "github.com/anacrolix/torrent/metainfo" 14 | ) 15 | 16 | // SpecfromURL Returns Torrent Spec from HTTP URL 17 | func SpecfromURL(torrentURL string) (spec *torrent.TorrentSpec, reterr error) { 18 | // TorrentSpecFromMetaInfo may panic if the info is malformed 19 | defer func() { 20 | if r := recover(); r != nil { 21 | reterr = fmt.Errorf("SpecfromURL: error loading spec from URL") 22 | } 23 | reterr = nil 24 | }() 25 | 26 | Info.Println("Adding Torrent from Torrent URL ", torrentURL) 27 | 28 | torrentURL = strings.TrimSpace(torrentURL) 29 | resp, reterr := http.Get(torrentURL) 30 | if reterr != nil { 31 | return 32 | } 33 | 34 | // Limit Response 35 | lr := io.LimitReader(resp.Body, 20971520) // 20MB 36 | info, reterr := metainfo.Load(lr) 37 | if reterr != nil { 38 | _ = resp.Body.Close() 39 | return 40 | } 41 | spec = torrent.TorrentSpecFromMetaInfo(info) 42 | _ = resp.Body.Close() 43 | return 44 | } 45 | 46 | // SpecfromPath Returns Torrent Spec from File Path 47 | func SpecfromPath(path string) (spec *torrent.TorrentSpec, reterr error) { 48 | // TorrentSpecFromMetaInfo may panic if the info is malformed 49 | defer func() { 50 | if r := recover(); r != nil { 51 | reterr = fmt.Errorf("SpecfromPath: error loading new torrent from file %s: %v+", path, r) 52 | } 53 | }() 54 | 55 | fi, err := os.Stat(path) 56 | 57 | if os.IsNotExist(err) { 58 | return nil, fmt.Errorf("file doesn't exist") 59 | } 60 | 61 | if fi.IsDir() { 62 | Err.Println("Directory Present instead of file") 63 | return nil, fmt.Errorf("directory present") 64 | } 65 | 66 | Info.Println("Getting Torrent Metainfo from File Path", path) 67 | 68 | info, reterr := metainfo.LoadFromFile(path) 69 | if reterr != nil { 70 | return 71 | } 72 | spec = torrent.TorrentSpecFromMetaInfo(info) 73 | return 74 | } 75 | 76 | // SpecfromBytes Returns Torrent Spec from Bytes 77 | func SpecfromBytes(trnt []byte) (spec *torrent.TorrentSpec, reterr error) { 78 | // TorrentSpecFromMetaInfo may panic if the info is malformed 79 | defer func() { 80 | if r := recover(); r != nil { 81 | reterr = fmt.Errorf("SpecfromBytes: error loading new torrent from bytes") 82 | } 83 | }() 84 | Info.Println("Getting Torrent Metainfo from Torrent Bytes") 85 | info, reterr := metainfo.Load(bytes.NewReader(trnt)) 86 | if reterr != nil { 87 | return nil, reterr 88 | } 89 | spec = torrent.TorrentSpecFromMetaInfo(info) 90 | return 91 | } 92 | 93 | // SpecfromB64String Returns Torrent Spec from Base64 Encoded Torrent File 94 | func SpecfromB64String(trnt string) (spec *torrent.TorrentSpec, reterr error) { 95 | t, err := base64.StdEncoding.DecodeString(trnt) 96 | if err != nil { 97 | return nil, err 98 | } 99 | return SpecfromBytes(t) 100 | } 101 | 102 | // MetafromHex returns metainfo.Hash from given infohash string 103 | func MetafromHex(infohash string) (h metainfo.Hash, err error) { 104 | defer func() { 105 | if r := recover(); r != nil { 106 | err = fmt.Errorf("error parsing string to InfoHash") 107 | } 108 | }() 109 | 110 | h = metainfo.NewHashFromHex(infohash) 111 | 112 | return h, nil 113 | } 114 | 115 | // RemTrackersSpec removes trackers from torrent.Spec 116 | func RemTrackersSpec(spec *torrent.TorrentSpec) { 117 | if spec == nil { 118 | return 119 | } 120 | spec.Trackers = [][]string{} 121 | } 122 | -------------------------------------------------------------------------------- /internal/core/init.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "flag" 7 | "fmt" 8 | "io/fs" 9 | "os" 10 | "os/signal" 11 | "path/filepath" 12 | "syscall" 13 | "time" 14 | 15 | "github.com/anacrolix/chansync" 16 | utp "github.com/anacrolix/go-libutp" 17 | 18 | "github.com/anacrolix/torrent" 19 | "github.com/anacrolix/torrent/metainfo" 20 | "github.com/anacrolix/torrent/storage" 21 | 22 | anaclog "github.com/anacrolix/log" 23 | ) 24 | 25 | func checkDir(dir string) { 26 | fi, err := os.Stat(dir) 27 | 28 | if err != nil { 29 | if errors.Is(err, fs.ErrNotExist) { 30 | er := os.MkdirAll(dir, 0755) 31 | if er != nil { 32 | Err.Fatalln("Error Creating Directory") 33 | } 34 | return 35 | } else { 36 | Err.Fatalf("Error Stat Directory %s ( %s ) \n", dir, err.Error()) 37 | return 38 | } 39 | } 40 | 41 | if fi != nil { 42 | if !fi.IsDir() { 43 | Err.Fatalln("Non-Directory File Present") 44 | return 45 | } 46 | } else { 47 | Err.Fatalln("Error Stat Directory ", dir) 48 | 49 | } 50 | } 51 | 52 | func Initialize() { 53 | var cfilename string 54 | var torcc bool 55 | var engc bool 56 | var err error 57 | var auser string 58 | var pw bool 59 | 60 | flag.Usage = func() { 61 | _, _ = fmt.Fprintf(flag.CommandLine.Output(), "exatorrent is bittorrent client\n\n") 62 | 63 | _, _ = fmt.Fprintf(flag.CommandLine.Output(), "Usage of %s:\n", os.Args[0]) 64 | 65 | flag.VisitAll(func(f *flag.Flag) { 66 | _, _ = fmt.Fprintf(flag.CommandLine.Output(), " -%-5v %v\n", f.Name, f.Usage) 67 | }) 68 | _, _ = fmt.Fprintf(flag.CommandLine.Output(), " -%-5v %v\n", "help", " Print this Help") 69 | _, _ = fmt.Fprintf(flag.CommandLine.Output(), "\nVersion: %s", Version) 70 | _, _ = fmt.Fprintf(flag.CommandLine.Output(), "\nLicense: GPLv3") 71 | _, _ = fmt.Fprintf(flag.CommandLine.Output(), "\nSource : https://github.com/varbhat/exatorrent\n") 72 | } 73 | 74 | flag.StringVar(&Flagconfig.ListenAddress, "addr", ":5000", ` Listen Address (Default: ":5000")`) 75 | flag.StringVar(&Flagconfig.UnixSocket, "unix", "", ` Unix Socket Path`) 76 | flag.StringVar(&Flagconfig.TLSKeyPath, "key", "", " Path to TLS Key (Required for HTTPS)") 77 | flag.StringVar(&Flagconfig.TLSCertPath, "cert", "", " Path to TLS Certificate (Required for HTTPS)") 78 | flag.StringVar(&Dirconfig.DirPath, "dir", "exadir", ` exatorrent Directory (Default: "exadir")`) 79 | flag.StringVar(&auser, "admin", "adminuser", ` Default admin username (Default Username: "adminuser" and Default Password: "adminpassword")`) 80 | flag.BoolVar(&pw, "passw", false, ` Set Default admin password from "EXAPASSWORD" environment variable`) 81 | flag.BoolVar(&engc, "engc", false, " Generate Custom Engine Configuration") 82 | flag.BoolVar(&torcc, "torc", false, " Generate Custom Torrent Client Configuration") 83 | flag.Parse() 84 | 85 | if len(flag.Args()) != 0 { 86 | _, _ = fmt.Fprintf(flag.CommandLine.Output(), "Invalid Flags Provided: %s\n\n", flag.Args()) 87 | flag.Usage() 88 | os.Exit(1) 89 | } 90 | 91 | // Display All Flag Configurations Provided to exatorrent 92 | if Flagconfig.UnixSocket != "" { 93 | _, _ = fmt.Fprintf(flag.CommandLine.Output(), "\nUnix Socket Path => %s", Flagconfig.UnixSocket) 94 | } else { 95 | _, _ = fmt.Fprintf(flag.CommandLine.Output(), "\nAddress => %s", Flagconfig.ListenAddress) 96 | } 97 | if Flagconfig.TLSKeyPath != "" { 98 | _, _ = fmt.Fprintf(flag.CommandLine.Output(), "\nTLS Key Path => %s", Flagconfig.TLSKeyPath) 99 | } 100 | if Flagconfig.TLSCertPath != "" { 101 | _, _ = fmt.Fprintf(flag.CommandLine.Output(), "\nTLS Certificate Path => %s", Flagconfig.TLSCertPath) 102 | } 103 | _, _ = fmt.Fprintf(flag.CommandLine.Output(), "\nDirectory => %s\n\n", Dirconfig.DirPath) 104 | 105 | // Create Required SubDirectories if not present 106 | checkDir(Dirconfig.DirPath) 107 | Dirconfig.ConfigDir = filepath.Join(Dirconfig.DirPath, "config") 108 | checkDir(Dirconfig.ConfigDir) 109 | Dirconfig.CacheDir = filepath.Join(Dirconfig.DirPath, "cache") 110 | checkDir(Dirconfig.CacheDir) 111 | Dirconfig.DataDir = filepath.Join(Dirconfig.DirPath, "data") 112 | checkDir(Dirconfig.DataDir) 113 | Dirconfig.TrntDir = filepath.Join(Dirconfig.DirPath, "torrents") 114 | checkDir(Dirconfig.TrntDir) 115 | 116 | // Load Torrent Client Configuration 117 | cfilename = filepath.Join(Dirconfig.ConfigDir, "clientconfig.json") 118 | _, cfileerr := os.Stat(cfilename) 119 | if cfileerr == nil { 120 | var e error 121 | cf, e := os.Open(cfilename) 122 | if e != nil { 123 | Err.Fatalln("Error Opening ", cfilename) 124 | } 125 | if cf != nil { 126 | e = json.NewDecoder(cf).Decode(&Engine.Tconfig) 127 | if e != nil { 128 | Err.Fatalln("Error Decoding ", cfilename) 129 | } 130 | Info.Println("Torrent Client Configuration is now loaded from ", cfilename) 131 | torcc = true 132 | _ = cf.Close() 133 | } 134 | } else if os.IsNotExist(cfileerr) && torcc { 135 | jfile, _ := json.MarshalIndent(Engine.Tconfig, "", "\t") 136 | _ = os.WriteFile(cfilename, jfile, 0644) 137 | _, _ = fmt.Fprintf(flag.CommandLine.Output(), "\nSample Torrent Client Configuration has been written at %s\n", cfilename) 138 | _, _ = fmt.Fprintf(flag.CommandLine.Output(), "Please Customize Torrent Client Configuration File %s if required , and restart\n", cfilename) 139 | os.Exit(0) 140 | } 141 | 142 | // Load Custom Engine Configuration 143 | Engine.Econfig = EngConfig{GlobalSeedRatio: 0, OnlineCacheURL: "", SRRefresh: 150, TrackerRefresh: 60, TrackerListURLs: []string{"https://ngosang.github.io/trackerslist/trackers_best.txt"}} 144 | // You can also add these "https://newtrackon.com/api/stable" , "https://cdn.jsdelivr.net/gh/XIU2/TrackersListCollection@master/best.txt" 145 | cfilename = filepath.Join(Dirconfig.ConfigDir, "engconfig.json") 146 | _, cfileerr = os.Stat(cfilename) 147 | if cfileerr == nil { 148 | var e error 149 | cf, e := os.Open(cfilename) 150 | if e != nil { 151 | Err.Fatalln("Error Opening ", cfilename) 152 | } else { 153 | if cf != nil { 154 | e = json.NewDecoder(cf).Decode(&Engine.Econfig) 155 | if e != nil { 156 | Err.Fatalln("Error Decoding ", cfilename) 157 | } 158 | Info.Printf("Engine Configuration %+v is now loaded\n", Engine.Econfig) 159 | engc = true 160 | _ = cf.Close() 161 | } 162 | } 163 | } else if os.IsNotExist(cfileerr) && engc { 164 | _ = Engine.Econfig.WriteConfig() 165 | _, _ = fmt.Fprintf(flag.CommandLine.Output(), "\nSample Engine Configuration has been written at %s\n", cfilename) 166 | _, _ = fmt.Fprintf(flag.CommandLine.Output(), "Please Customize Engine Configuration File %s if required , and restart\n", cfilename) 167 | os.Exit(0) 168 | } 169 | 170 | tc := Engine.Tconfig.ToTorrentConfig() 171 | 172 | // Set Different Logger for UTP 173 | utp.Logger = anaclog.Logger{} // Info Logger 174 | utp.Logger.Handlers = []anaclog.Handler{anaclog.StreamHandler{ 175 | W: os.Stderr, 176 | Fmt: func(msg anaclog.Record) []byte { 177 | var pc [1]uintptr 178 | msg.Callers(1, pc[:]) 179 | return []byte(fmt.Sprintf("[UTp ] %s %s\n", time.Now().Format("2006/01/02 03:04:05"), msg.Text())) 180 | }, 181 | }} 182 | 183 | sqliteSetup(tc) 184 | 185 | if !CheckUserExists(auser) { 186 | var password = "adminpassword" 187 | if pw { 188 | password = os.Getenv("EXAPASSWORD") 189 | } 190 | 191 | Info.Printf("Adding Admin user with username %s and password %s\n", auser, password) 192 | er := Engine.UDb.Add(auser, password, 1) 193 | if er != nil { 194 | Err.Fatalln("Unable to add admin user to adminless exatorrent instance :", er) 195 | } 196 | // keep for backward compatibility 197 | _, er = os.Create(filepath.Join(Dirconfig.DataDir, ".adminadded")) 198 | if er != nil { 199 | Err.Fatalln(er) 200 | } 201 | } 202 | 203 | stor := storage.NewFileOpts(storage.NewFileClientOpts{ClientBaseDir: Dirconfig.TrntDir, FilePathMaker: nil, TorrentDirMaker: func(baseDir string, info *metainfo.Info, infoHash metainfo.Hash) string { 204 | return filepath.Join(baseDir, infoHash.HexString()) 205 | }, PieceCompletion: Engine.PcDb}) 206 | 207 | tc.DefaultStorage = stor 208 | 209 | Engine.Torc, err = torrent.NewClient(tc) 210 | if err != nil { 211 | Err.Fatalln("Unable to Create Torrent Client ", err) 212 | } else { 213 | Info.Println("Torrent Client Created") 214 | } 215 | 216 | Engine.onCloseMap = make(map[metainfo.Hash]*chansync.Flag) 217 | 218 | go func() { 219 | defer func() { 220 | if err := recover(); err != nil { 221 | Warn.Println(err) 222 | } 223 | }() 224 | var stoperr error 225 | stopsignal := make(chan os.Signal, 5) 226 | signal.Notify(stopsignal, os.Interrupt, syscall.SIGHUP, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT) 227 | sig := <-stopsignal 228 | fmt.Fprintf(os.Stderr, "\n") 229 | Warn.Println("Caught Signal:", sig) 230 | Warn.Println("Closing exatorrent") 231 | Engine.TorDb.Close() 232 | Engine.TrackerDB.Close() 233 | Engine.TUDb.Close() 234 | stoperr = Engine.PcDb.Close() // Close PcDb at the end 235 | if stoperr != nil { 236 | Warn.Println("Error Closing PieceCompletion DB ", stoperr) 237 | } 238 | Engine.Torc.Close() // Close the Torrent Client 239 | stoperr = stor.Close() // Close the storage.ClientImplCloser 240 | if stoperr != nil { 241 | Warn.Println("Error Closing Default Storage ", stoperr) 242 | } 243 | os.Exit(1) 244 | }() 245 | 246 | //Recover Torrents from Database 247 | torlist, err := Engine.TorDb.GetTorrents() 248 | if err != nil { 249 | Err.Fatalln("Error Recovering Torrents") 250 | } 251 | for _, eachtrnt := range torlist { 252 | go func(started bool, infohash metainfo.Hash) { 253 | AddfromSpec("", &torrent.TorrentSpec{InfoHash: infohash}, true, true) 254 | if started { 255 | StartTorrent("", infohash, true) 256 | } 257 | flist := Engine.FsDb.Get(infohash) 258 | if started { 259 | for _, f := range flist { 260 | StopFile("", infohash, f) 261 | } 262 | } else { 263 | for _, f := range flist { 264 | StartFile("", infohash, f) 265 | } 266 | } 267 | }(eachtrnt.Started, eachtrnt.Infohash) 268 | } 269 | 270 | go UpdateTrackers() 271 | go TorrentRoutine() 272 | 273 | } 274 | -------------------------------------------------------------------------------- /internal/core/routine.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "bufio" 5 | "net/http" 6 | "strings" 7 | "time" 8 | ) 9 | 10 | // UpdateTrackers Updates the Trackers from TrackerURL 11 | func UpdateTrackers() { 12 | defer func() { 13 | if err := recover(); err != nil { 14 | Warn.Println(err) 15 | } 16 | }() 17 | for { 18 | updatetrackers() 19 | // Pause UpdateTrackers() every Engine.Econfig.TrackerRefresh Minutes 20 | time.Sleep(time.Minute * time.Duration(Engine.Econfig.GetTR())) 21 | } 22 | 23 | } 24 | 25 | // TorrentRoutine Stops Torrent on Reaching Global SeedRatio 26 | func TorrentRoutine() { 27 | defer func() { 28 | if err := recover(); err != nil { 29 | Warn.Println(err) 30 | } 31 | }() 32 | for range time.Tick(time.Minute * time.Duration(Engine.Econfig.GetSRR())) { 33 | stoponseedratio(Engine.Econfig.GetGSR()) 34 | } 35 | } 36 | 37 | func updatetrackers() { 38 | for _, url := range Engine.Econfig.GetTLU() { 39 | resp, err := http.Get(url) 40 | if err != nil { 41 | Warn.Println("TrackerList URL: ", url, " is Invalid") 42 | continue 43 | } 44 | 45 | // Add Trackers to txtlines string slice 46 | scanner := bufio.NewScanner(resp.Body) 47 | scanner.Split(bufio.ScanLines) 48 | 49 | var trackerpertrurl int 50 | for scanner.Scan() { 51 | line := strings.TrimSpace(scanner.Text()) 52 | if line == "" { 53 | continue 54 | } 55 | Engine.TrackerDB.Add(line) 56 | trackerpertrurl++ 57 | } 58 | 59 | _ = resp.Body.Close() 60 | Info.Println("Loaded ", trackerpertrurl, " trackers from ", url) 61 | } 62 | 63 | Info.Println("Loaded ", Engine.TrackerDB.Count(), " trackers in total , eliminating duplicates") 64 | 65 | // Add Trackers to Every Torrents 66 | trckrs := [][]string{Engine.TrackerDB.Get()} 67 | en := Engine.Torc 68 | if en != nil { 69 | for _, trnt := range en.Torrents() { 70 | if trnt != nil { 71 | trnt.AddTrackers(trckrs) 72 | } 73 | } 74 | Info.Println("Added Loaded Trackers to Torrents") 75 | } 76 | } 77 | 78 | func stoponseedratio(sr float64) { 79 | if sr != 0 { 80 | if Engine.Torc != nil { 81 | trnts := Engine.Torc.Torrents() 82 | 83 | for _, trnt := range trnts { 84 | if trnt == nil { 85 | continue 86 | } 87 | if trnt.Info() == nil { 88 | continue 89 | } 90 | 91 | stats := trnt.Stats() 92 | 93 | seedratio := float64(stats.BytesWrittenData.Int64()) / float64(stats.BytesReadData.Int64()) 94 | 95 | // stops task on reaching ratio 96 | if seedratio >= sr { 97 | Warn.Printf("Torrent %s Stopped on Reaching Global SeedRatio", trnt.InfoHash()) 98 | go StopTorrent("", trnt.InfoHash()) 99 | } 100 | } 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /internal/core/serve.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "archive/tar" 5 | "archive/zip" 6 | "fmt" 7 | "io" 8 | "io/fs" 9 | "net/http" 10 | "os" 11 | "path" 12 | "path/filepath" 13 | "strings" 14 | "time" 15 | 16 | "github.com/anacrolix/torrent" 17 | ) 18 | 19 | func TorrentServe(w http.ResponseWriter, r *http.Request) { 20 | parts := strings.Split(r.URL.Path, "/") 21 | if !(len(parts) > 4) { 22 | http.Error(w, "404 Not Found", http.StatusNotFound) 23 | return 24 | } 25 | if Engine.LsDb.IsLocked(parts[3]) { 26 | u, ut, _, err := authHelper(w, r) 27 | if err != nil { 28 | Err.Println(err) 29 | http.Error(w, "404 Not Found", http.StatusNotFound) 30 | return 31 | } 32 | if ut != 1 { 33 | if ut == -1 { 34 | http.Error(w, "404 Not Found", http.StatusNotFound) 35 | return 36 | } 37 | if !Engine.TUDb.HasUser(u, parts[3]) { 38 | http.Error(w, "404 Not Found", http.StatusNotFound) 39 | return 40 | } 41 | } 42 | } 43 | if val := r.URL.Query().Get("dl"); val != "" { 44 | file := filepath.Join(Dirconfig.TrntDir, filepath.Join(parts[3:]...)) 45 | if !strings.HasPrefix(file, filepath.Join(Dirconfig.TrntDir, parts[3])) { 46 | http.Error(w, "404 Not Found", http.StatusNotFound) 47 | return 48 | } 49 | info, err := os.Stat(file) 50 | if err != nil { 51 | http.Error(w, "404 Not Found", http.StatusNotFound) 52 | return 53 | } else if info.IsDir() { 54 | if val == "tar" { 55 | TarDir(file, w, path.Base(r.URL.Path)) 56 | return 57 | } else if val == "zip" { 58 | ZipDir(file, w, path.Base(r.URL.Path)) 59 | return 60 | } 61 | } 62 | } 63 | http.StripPrefix("/api/torrent/", http.FileServer(http.Dir(Dirconfig.TrntDir))).ServeHTTP(w, r) 64 | } 65 | 66 | func StreamFile(w http.ResponseWriter, r *http.Request) { 67 | parts := strings.Split(r.URL.Path, "/") 68 | if !(len(parts) > 4) { 69 | http.Error(w, "404 Not Found", http.StatusNotFound) 70 | return 71 | } 72 | if Engine.LsDb.IsLocked(parts[3]) { 73 | u, ut, _, err := authHelper(w, r) 74 | if err != nil { 75 | Err.Println(err) 76 | http.Error(w, "404 Not Found", http.StatusNotFound) 77 | return 78 | } 79 | if ut != 1 { 80 | if ut == -1 { 81 | http.Error(w, "404 Not Found", http.StatusNotFound) 82 | return 83 | } 84 | if !Engine.TUDb.HasUser(u, parts[3]) { 85 | http.Error(w, "404 Not Found", http.StatusNotFound) 86 | return 87 | } 88 | } 89 | } 90 | ih, err := MetafromHex(parts[3]) 91 | if err != nil { 92 | Warn.Println(err) 93 | return 94 | } 95 | t, ok := Engine.Torc.Torrent(ih) 96 | if !ok { 97 | Warn.Println("Error fetching torrent of infohash ", ih, " from the client") 98 | return 99 | } 100 | 101 | if t.Info() == nil { 102 | Warn.Println("Metainfo not yet loaded") 103 | http.Error(w, "Metainfo not yet loaded", http.StatusNotFound) 104 | return 105 | } 106 | 107 | // Get File from Given Torrent 108 | reqpath := strings.Join(parts[4:], "/") 109 | var f *torrent.File 110 | for _, file := range t.Files() { 111 | if file.Path() == reqpath { 112 | f = file 113 | break 114 | } 115 | } 116 | if f == nil { 117 | http.Error(w, "404 Not Found", http.StatusNotFound) 118 | return 119 | } 120 | filereader := f.NewReader() 121 | defer filereader.Close() 122 | filereader.SetReadahead(48 << 20) 123 | http.ServeContent(w, r, reqpath, time.Time{}, filereader) 124 | } 125 | 126 | func TarDir(dirpath string, w http.ResponseWriter, name string) { 127 | w.Header().Set("Content-Type", "application/x-tar") 128 | w.Header().Set("Content-disposition", `attachment; filename="`+name+`.tar"`) 129 | w.WriteHeader(http.StatusOK) 130 | tw := tar.NewWriter(w) 131 | defer tw.Close() 132 | 133 | _ = filepath.WalkDir(dirpath, func(p string, de fs.DirEntry, err error) error { 134 | if err != nil { 135 | return err 136 | } 137 | 138 | info, ierr := de.Info() 139 | if ierr != nil { 140 | return ierr 141 | } 142 | 143 | if !info.Mode().IsRegular() { 144 | return nil 145 | } 146 | 147 | rel, err := filepath.Rel(dirpath, p) 148 | if err != nil { 149 | return err 150 | } 151 | f, err := os.Open(p) 152 | if err != nil { 153 | return err 154 | } 155 | defer f.Close() 156 | 157 | h, err := tar.FileInfoHeader(info, "") 158 | if err != nil { 159 | return err 160 | } 161 | h.Name = rel 162 | if err := tw.WriteHeader(h); err != nil { 163 | return err 164 | } 165 | n, err := io.Copy(tw, f) 166 | if info.Size() != n { 167 | return fmt.Errorf("mismatch of size with %s", rel) 168 | } 169 | return err 170 | }) 171 | } 172 | 173 | func ZipDir(dirpath string, w http.ResponseWriter, name string) { 174 | w.Header().Set("Content-Type", "application/zip") 175 | w.Header().Set("Content-disposition", `attachment; filename="`+name+`.zip"`) 176 | w.WriteHeader(http.StatusOK) 177 | zw := zip.NewWriter(w) 178 | defer zw.Close() 179 | 180 | _ = filepath.WalkDir(dirpath, func(p string, de fs.DirEntry, err error) error { 181 | if err != nil { 182 | return err 183 | } 184 | 185 | info, ierr := de.Info() 186 | if ierr != nil { 187 | return ierr 188 | } 189 | 190 | if !info.Mode().IsRegular() { 191 | return nil 192 | } 193 | 194 | rel, err := filepath.Rel(dirpath, p) 195 | if err != nil { 196 | return err 197 | } 198 | f, err := os.Open(p) 199 | if err != nil { 200 | return err 201 | } 202 | defer f.Close() 203 | 204 | h, err := zip.FileInfoHeader(info) 205 | if err != nil { 206 | return err 207 | } 208 | 209 | h.Name = rel 210 | //h.Method = zip.Deflate 211 | 212 | zf, err := zw.CreateHeader(h) 213 | if err != nil { 214 | return err 215 | } 216 | 217 | n, err := io.Copy(zf, f) 218 | if info.Size() != n { 219 | return fmt.Errorf("mismatch of size with %s", rel) 220 | } 221 | 222 | return err 223 | 224 | }) 225 | 226 | } 227 | -------------------------------------------------------------------------------- /internal/core/statsapi.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "os" 5 | "runtime" 6 | "time" 7 | 8 | "github.com/pbnjay/memory" 9 | 10 | "github.com/shirou/gopsutil/v3/cpu" 11 | "github.com/shirou/gopsutil/v3/disk" 12 | "github.com/shirou/gopsutil/v3/host" 13 | "github.com/shirou/gopsutil/v3/mem" 14 | ) 15 | 16 | type machInfo struct { 17 | Arch string `json:"arch"` 18 | NumberCPUs int `json:"numbercpu"` 19 | CPUModel string `json:"cpumodel"` 20 | HostName string `json:"hostname"` 21 | Platform string `json:"platform"` 22 | OS string `json:"os"` 23 | TotalMem uint64 `json:"totalmem"` 24 | GoVersion string `json:"goversion"` 25 | StartedAt time.Time `json:"startedat"` 26 | } 27 | 28 | type machStats struct { 29 | CPU float64 `json:"cpucycles"` 30 | DiskFree uint64 `json:"diskfree"` 31 | DiskUsedPercent float64 `json:"diskpercent"` 32 | MemUsedPercent float64 `json:"mempercent"` 33 | GoMemory int64 `json:"gomem"` 34 | GoMemorySys int64 `json:"gomemsys"` 35 | GoRoutines int `json:"goroutines"` 36 | } 37 | 38 | type NetworkStats struct { 39 | ActiveHalfOpenAttempts int `json:"activehalfopenattempts"` 40 | BytesWritten int64 `json:"byteswritten"` 41 | BytesWrittenData int64 `json:"byteswrittendata"` 42 | BytesRead int64 `json:"bytesread"` 43 | BytesReadData int64 `json:"bytesreaddata"` 44 | BytesReadUsefulData int64 `json:"bytesreadusefuldata"` 45 | BytesReadUsefulIntendedData int64 `json:"bytesreadusefulintendeddata"` 46 | ChunksWritten int64 `json:"chunkswritten"` 47 | ChunksRead int64 `json:"chunksread"` 48 | ChunksReadUseful int64 `json:"chunksreaduseful"` 49 | ChunksReadWasted int64 `json:"chunksreadwasted"` 50 | MetadataChunksRead int64 `json:"metadatachunksread"` 51 | } 52 | 53 | var MachInfo machInfo = loadMachInfo() 54 | var MachStats machStats 55 | 56 | func loadMachInfo() (retmachinfo machInfo) { 57 | hostInfo, err := host.Info() 58 | if err == nil && hostInfo != nil { 59 | retmachinfo.OS = hostInfo.Platform + " " + hostInfo.PlatformVersion 60 | retmachinfo.Platform = hostInfo.OS 61 | } 62 | 63 | cpuInfo, err := cpu.Info() 64 | if err == nil && len(cpuInfo) > 0 { 65 | retmachinfo.CPUModel = cpuInfo[0].ModelName 66 | } 67 | 68 | retmachinfo.HostName, _ = os.Hostname() 69 | retmachinfo.GoVersion = runtime.Version() 70 | retmachinfo.TotalMem = memory.TotalMemory() 71 | retmachinfo.Arch = runtime.GOARCH 72 | retmachinfo.NumberCPUs = runtime.NumCPU() 73 | retmachinfo.StartedAt = time.Now() 74 | return retmachinfo 75 | } 76 | 77 | func (s *machStats) LoadStats(diskDir string) { 78 | //count cpu cycles between last count 79 | if cpuprct, err := cpu.Percent(0, false); err == nil { 80 | if len(cpuprct) > 0 { 81 | s.CPU = cpuprct[0] 82 | } 83 | } 84 | //count disk usage 85 | if stat, err := disk.Usage(diskDir); err == nil { 86 | s.DiskUsedPercent = stat.UsedPercent 87 | s.DiskFree = stat.Free 88 | } 89 | //count memory usage 90 | if stat, err := mem.VirtualMemory(); err == nil { 91 | s.MemUsedPercent = stat.UsedPercent 92 | } 93 | //count total bytes allocated by the go runtime 94 | memStats := runtime.MemStats{} 95 | runtime.ReadMemStats(&memStats) 96 | s.GoMemory = int64(memStats.Alloc) 97 | s.GoMemorySys = int64(memStats.Sys) 98 | //count current number of goroutines 99 | s.GoRoutines = runtime.NumGoroutine() 100 | } 101 | 102 | func GetNetworkStats() (retnstats NetworkStats) { 103 | s := Engine.Torc.Stats() 104 | retnstats.ActiveHalfOpenAttempts = s.ActiveHalfOpenAttempts 105 | retnstats.BytesWritten = s.BytesWritten.Int64() 106 | retnstats.BytesWrittenData = s.BytesWrittenData.Int64() 107 | retnstats.BytesRead = s.BytesRead.Int64() 108 | retnstats.BytesReadData = s.BytesReadData.Int64() 109 | retnstats.BytesReadUsefulData = s.BytesReadUsefulData.Int64() 110 | retnstats.BytesReadUsefulIntendedData = s.BytesReadUsefulIntendedData.Int64() 111 | retnstats.ChunksWritten = s.ChunksWritten.Int64() 112 | retnstats.ChunksRead = s.ChunksRead.Int64() 113 | retnstats.ChunksReadUseful = s.ChunksReadUseful.Int64() 114 | retnstats.ChunksReadWasted = s.ChunksReadWasted.Int64() 115 | retnstats.MetadataChunksRead = s.MetadataChunksRead.Int64() 116 | return 117 | } 118 | -------------------------------------------------------------------------------- /internal/core/storage.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "path/filepath" 5 | 6 | "github.com/varbhat/exatorrent/internal/db" 7 | 8 | "github.com/anacrolix/torrent" 9 | ) 10 | 11 | func sqliteSetup(tc *torrent.ClientConfig) { 12 | var err error 13 | 14 | Engine.TorDb = &db.Sqlite3Db{} 15 | Engine.TorDb.Open(filepath.Join(Dirconfig.DataDir, "torc.db")) 16 | 17 | Engine.TrackerDB = &db.SqliteTdb{} 18 | Engine.TrackerDB.Open(filepath.Join(Dirconfig.DataDir, "trackers.db")) 19 | 20 | Engine.FsDb = &db.SqliteFSDb{} 21 | Engine.FsDb.Open(filepath.Join(Dirconfig.DataDir, "filestate.db")) 22 | 23 | Engine.LsDb = &db.SqliteLSDb{} 24 | Engine.LsDb.Open(filepath.Join(Dirconfig.DataDir, "lockstate.db")) 25 | 26 | Engine.UDb = &db.Sqlite3UserDb{} 27 | Engine.UDb.Open(filepath.Join(Dirconfig.DataDir, "user.db")) 28 | 29 | Engine.TUDb = &db.SqliteTorrentUserDb{} 30 | Engine.TUDb.Open(filepath.Join(Dirconfig.DataDir, "torrentuser.db")) 31 | 32 | Engine.PcDb, err = db.NewSqlitePieceCompletion(Dirconfig.DataDir) 33 | 34 | if err != nil { 35 | Err.Fatalln("Unable to create sqlite3 database for PieceCompletion") 36 | } 37 | 38 | } 39 | -------------------------------------------------------------------------------- /internal/core/version.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | const Version string = "v1.3.0" 4 | -------------------------------------------------------------------------------- /internal/db/db.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | "time" 8 | 9 | "github.com/anacrolix/torrent/metainfo" 10 | "github.com/anacrolix/torrent/storage" 11 | ) 12 | 13 | var DbL = log.New(os.Stderr, "[DB] ", log.LstdFlags) // Database Logger 14 | 15 | // MetafromHex returns metainfo.Hash from given infohash string 16 | func MetafromHex(infohash string) (h metainfo.Hash, err error) { 17 | defer func() { 18 | if r := recover(); r != nil { 19 | err = fmt.Errorf("error parsing string to InfoHash") 20 | } 21 | }() 22 | 23 | h = metainfo.NewHashFromHex(infohash) 24 | 25 | return h, nil 26 | } 27 | 28 | // Interfaces 29 | 30 | type TorrentDb interface { 31 | Open(string) 32 | Close() 33 | Exists(metainfo.Hash) bool 34 | Add(metainfo.Hash) error 35 | Delete(metainfo.Hash) error 36 | Start(metainfo.Hash) error 37 | SetStarted(metainfo.Hash, bool) error 38 | HasStarted(string) bool 39 | GetTorrent(metainfo.Hash) (*Torrent, error) 40 | GetTorrents() ([]*Torrent, error) 41 | } 42 | 43 | type TrackerDb interface { 44 | Open(string) 45 | Close() 46 | Add(string) 47 | Delete(string) 48 | DeleteN(int) 49 | DeleteAll() 50 | Count() int64 51 | Get() []string 52 | } 53 | 54 | type FileStateDb interface { 55 | Open(string) 56 | Close() 57 | Add(string, metainfo.Hash) error 58 | Get(metainfo.Hash) []string 59 | Deletefile(string, metainfo.Hash) error 60 | Delete(metainfo.Hash) error 61 | } 62 | 63 | type LockStateDb interface { 64 | Open(string) 65 | Close() 66 | Lock(metainfo.Hash) error 67 | Unlock(metainfo.Hash) error 68 | IsLocked(string) bool 69 | } 70 | 71 | type UserDb interface { 72 | Open(string) 73 | Close() 74 | Add(string, string, int) error // Username , Password , Usertype 75 | ChangeType(string, string) error 76 | Delete(string) error 77 | UpdatePw(string, string) error 78 | GetUsers() []*User 79 | Validate(string, string) (int, bool) 80 | ValidateToken(string) (string, int, error) 81 | SetToken(string, string) error 82 | CheckUserExists(string) bool 83 | } 84 | 85 | type TorrentUserDb interface { 86 | Open(string) 87 | Close() 88 | Add(string, metainfo.Hash) error 89 | Remove(string, metainfo.Hash) error 90 | RemoveAll(string) error 91 | RemoveAllMi(metainfo.Hash) error 92 | HasUser(string, string) bool 93 | ListTorrents(string) []metainfo.Hash 94 | ListUsers(metainfo.Hash) []string 95 | } 96 | 97 | type PcDb interface { 98 | storage.PieceCompletion 99 | Delete(metainfo.Hash) 100 | } 101 | 102 | // Struct 103 | 104 | type Torrent struct { 105 | Infohash metainfo.Hash 106 | Started bool 107 | AddedAt time.Time 108 | StartedAt time.Time 109 | } 110 | 111 | type User struct { 112 | Username string 113 | Password string `json:"-"` 114 | Token string 115 | UserType int // 0 for User,1 for Admin,-1 for Disabled 116 | CreatedAt time.Time 117 | } 118 | -------------------------------------------------------------------------------- /internal/db/sqlite3filestatedb.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "sync" 5 | 6 | "github.com/anacrolix/torrent/metainfo" 7 | sqlite "github.com/go-llsqlite/crawshaw" 8 | "github.com/go-llsqlite/crawshaw/sqlitex" 9 | ) 10 | 11 | type SqliteFSDb struct { 12 | Db *sqlite.Conn 13 | mu sync.Mutex 14 | } 15 | 16 | func (db *SqliteFSDb) Open(fp string) { 17 | var err error 18 | 19 | db.Db, err = sqlite.OpenConn(fp, 0) 20 | if err != nil { 21 | DbL.Fatalln(err) 22 | } 23 | 24 | err = sqlitex.ExecScript(db.Db, `create table if not exists filestatedb (filepath text,infohash text, unique(filepath, infohash));`) 25 | 26 | if err != nil { 27 | DbL.Fatalln(err) 28 | } 29 | } 30 | 31 | func (db *SqliteFSDb) Close() { 32 | db.mu.Lock() 33 | defer db.mu.Unlock() 34 | err := db.Db.Close() 35 | if err != nil { 36 | DbL.Fatalln(err) 37 | } 38 | } 39 | 40 | func (db *SqliteFSDb) Add(fp string, ih metainfo.Hash) (err error) { 41 | db.mu.Lock() 42 | defer db.mu.Unlock() 43 | err = sqlitex.Exec(db.Db, `insert into filestatedb (filepath,infohash) values (?,?) on conflict (filepath,infohash) do nothing;`, nil, fp, ih.HexString()) 44 | return 45 | } 46 | 47 | func (db *SqliteFSDb) Get(ih metainfo.Hash) (ret []string) { 48 | ret = make([]string, 0) 49 | db.mu.Lock() 50 | defer db.mu.Unlock() 51 | _ = sqlitex.Exec( 52 | db.Db, `select filepath from filestatedb where infohash=?;`, 53 | func(stmt *sqlite.Stmt) error { 54 | ret = append(ret, stmt.GetText("filepath")) 55 | return nil 56 | }, ih.HexString()) 57 | 58 | return 59 | } 60 | 61 | func (db *SqliteFSDb) Delete(ih metainfo.Hash) (err error) { 62 | db.mu.Lock() 63 | defer db.mu.Unlock() 64 | err = sqlitex.Exec(db.Db, `delete from filestatedb where infohash=?;`, nil, ih.HexString()) 65 | return 66 | } 67 | 68 | func (db *SqliteFSDb) Deletefile(fp string, ih metainfo.Hash) (err error) { 69 | db.mu.Lock() 70 | defer db.mu.Unlock() 71 | err = sqlitex.Exec(db.Db, `delete from filestatedb where filepath=? and infohash=?;`, nil, fp, ih.HexString()) 72 | return err 73 | } 74 | -------------------------------------------------------------------------------- /internal/db/sqlite3lockstatedb.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "sync" 5 | 6 | "github.com/anacrolix/torrent/metainfo" 7 | sqlite "github.com/go-llsqlite/crawshaw" 8 | "github.com/go-llsqlite/crawshaw/sqlitex" 9 | ) 10 | 11 | type SqliteLSDb struct { 12 | Db *sqlite.Conn 13 | mu sync.Mutex 14 | } 15 | 16 | func (db *SqliteLSDb) Open(fp string) { 17 | var err error 18 | 19 | db.Db, err = sqlite.OpenConn(fp, 0) 20 | if err != nil { 21 | DbL.Fatalln(err) 22 | } 23 | 24 | err = sqlitex.ExecScript(db.Db, `create table if not exists lockstatedb (infohash text primary key);`) 25 | 26 | if err != nil { 27 | DbL.Fatalln(err) 28 | } 29 | } 30 | 31 | func (db *SqliteLSDb) Close() { 32 | db.mu.Lock() 33 | defer db.mu.Unlock() 34 | err := db.Db.Close() 35 | if err != nil { 36 | DbL.Fatalln(err) 37 | } 38 | } 39 | 40 | func (db *SqliteLSDb) Lock(m metainfo.Hash) (err error) { 41 | db.mu.Lock() 42 | defer db.mu.Unlock() 43 | err = sqlitex.Exec(db.Db, `insert into lockstatedb (infohash) values (?) on conflict (infohash) do nothing;`, nil, m.HexString()) 44 | return 45 | } 46 | 47 | func (db *SqliteLSDb) Unlock(m metainfo.Hash) (err error) { 48 | db.mu.Lock() 49 | defer db.mu.Unlock() 50 | err = sqlitex.Exec(db.Db, `delete from lockstatedb where infohash=?;`, nil, m.HexString()) 51 | return 52 | } 53 | 54 | func (db *SqliteLSDb) IsLocked(m string) (b bool) { 55 | db.mu.Lock() 56 | defer db.mu.Unlock() 57 | if sqlitex.Exec( 58 | db.Db, `select 1 from lockstatedb where infohash=?;`, 59 | func(stmt *sqlite.Stmt) error { 60 | b = true 61 | return nil 62 | }, m) != nil { 63 | return false 64 | } 65 | return 66 | } 67 | -------------------------------------------------------------------------------- /internal/db/sqlite3pc.go: -------------------------------------------------------------------------------- 1 | //go:build cgo && !nosqlite 2 | // +build cgo,!nosqlite 3 | 4 | package db 5 | 6 | import ( 7 | "path/filepath" 8 | "sync" 9 | 10 | sqlite "github.com/go-llsqlite/crawshaw" 11 | "github.com/go-llsqlite/crawshaw/sqlitex" 12 | 13 | "github.com/anacrolix/torrent/metainfo" 14 | "github.com/anacrolix/torrent/storage" 15 | ) 16 | 17 | type sqlitePieceCompletion struct { 18 | mu sync.Mutex 19 | db *sqlite.Conn 20 | } 21 | 22 | func NewSqlitePieceCompletion(dir string) (ret *sqlitePieceCompletion, err error) { 23 | p := filepath.Join(dir, "pcomp.db") 24 | db, err := sqlite.OpenConn(p, 0) 25 | if err != nil { 26 | return 27 | } 28 | err = sqlitex.ExecScript(db, `create table if not exists pcomp (infohash text, pindex integer, complete boolean, unique(infohash, pindex));`) 29 | if err != nil { 30 | _ = db.Close() 31 | return 32 | } 33 | ret = &sqlitePieceCompletion{db: db} 34 | return 35 | } 36 | 37 | func (me *sqlitePieceCompletion) Get(pk metainfo.PieceKey) (c storage.Completion, err error) { 38 | me.mu.Lock() 39 | defer me.mu.Unlock() 40 | err = sqlitex.Exec( 41 | me.db, `select complete from pcomp where infohash=? and pindex=?;`, 42 | func(stmt *sqlite.Stmt) error { 43 | c.Complete = stmt.ColumnInt(0) != 0 44 | c.Ok = true 45 | return nil 46 | }, 47 | pk.InfoHash.HexString(), pk.Index) 48 | return 49 | } 50 | 51 | func (me *sqlitePieceCompletion) Set(pk metainfo.PieceKey, b bool) error { 52 | me.mu.Lock() 53 | defer me.mu.Unlock() 54 | return sqlitex.Exec( 55 | me.db, 56 | `insert into pcomp (infohash,pindex,complete) values (?,?,?) on conflict (infohash,pindex) do update set complete=?;`, 57 | nil, 58 | pk.InfoHash.HexString(), pk.Index, b, b) 59 | } 60 | 61 | func (me *sqlitePieceCompletion) Delete(m metainfo.Hash) { 62 | me.mu.Lock() 63 | defer me.mu.Unlock() 64 | if me.db != nil { 65 | _ = sqlitex.Exec( 66 | me.db, 67 | `delete from pcomp where infohash=?;`, 68 | nil, 69 | m.HexString()) 70 | } 71 | } 72 | 73 | func (me *sqlitePieceCompletion) Close() (err error) { 74 | me.mu.Lock() 75 | defer me.mu.Unlock() 76 | if me.db != nil { 77 | err = me.db.Close() 78 | me.db = nil 79 | } 80 | return 81 | } 82 | -------------------------------------------------------------------------------- /internal/db/sqlite3torrentdb.go: -------------------------------------------------------------------------------- 1 | //go:build cgo 2 | // +build cgo 3 | 4 | package db 5 | 6 | import ( 7 | "fmt" 8 | "sync" 9 | "time" 10 | 11 | sqlite "github.com/go-llsqlite/crawshaw" 12 | "github.com/go-llsqlite/crawshaw/sqlitex" 13 | 14 | "github.com/anacrolix/torrent/metainfo" 15 | ) 16 | 17 | type Sqlite3Db struct { 18 | Db *sqlite.Conn 19 | mu sync.Mutex 20 | } 21 | 22 | func (db *Sqlite3Db) Open(fp string) { 23 | var err error 24 | 25 | db.Db, err = sqlite.OpenConn(fp, 0) 26 | if err != nil { 27 | DbL.Fatalln(err) 28 | } 29 | 30 | err = sqlitex.ExecScript(db.Db, `create table if not exists torrent (infohash text primary key,started boolean,addedat text,startedat text);`) 31 | 32 | if err != nil { 33 | DbL.Fatalln(err) 34 | } 35 | } 36 | 37 | func (db *Sqlite3Db) Close() { 38 | db.mu.Lock() 39 | defer db.mu.Unlock() 40 | err := db.Db.Close() 41 | if err != nil { 42 | DbL.Fatalln(err) 43 | } 44 | } 45 | 46 | func (db *Sqlite3Db) Exists(ih metainfo.Hash) (ret bool) { 47 | db.mu.Lock() 48 | defer db.mu.Unlock() 49 | 50 | serr := sqlitex.Exec( 51 | db.Db, `select 1 from torrent where infohash=?;`, 52 | func(stmt *sqlite.Stmt) error { 53 | ret = stmt.ColumnInt(0) == 1 54 | return nil 55 | }, ih.HexString()) 56 | if serr != nil { 57 | return false 58 | } 59 | return 60 | } 61 | 62 | func (db *Sqlite3Db) IsLocked(ih string) (ret bool) { 63 | db.mu.Lock() 64 | defer db.mu.Unlock() 65 | _ = sqlitex.Exec( 66 | db.Db, `select locked from torrent where infohash=?;`, 67 | func(stmt *sqlite.Stmt) error { 68 | ret = stmt.ColumnInt(0) != 0 69 | return nil 70 | }, ih) 71 | 72 | return 73 | } 74 | 75 | func (db *Sqlite3Db) HasStarted(ih string) (ret bool) { 76 | db.mu.Lock() 77 | defer db.mu.Unlock() 78 | _ = sqlitex.Exec( 79 | db.Db, `select started from torrent where infohash=?;`, 80 | func(stmt *sqlite.Stmt) error { 81 | ret = stmt.ColumnInt(0) != 0 82 | return nil 83 | }, ih) 84 | 85 | return 86 | } 87 | 88 | func (db *Sqlite3Db) SetLocked(ih string, b bool) (err error) { 89 | db.mu.Lock() 90 | defer db.mu.Unlock() 91 | err = sqlitex.Exec(db.Db, `update torrent set locked=? where infohash=?;`, nil, b, ih) 92 | return 93 | } 94 | 95 | func (db *Sqlite3Db) Add(ih metainfo.Hash) (err error) { 96 | db.mu.Lock() 97 | defer db.mu.Unlock() 98 | tn := time.Now().Format(time.RFC3339) 99 | err = sqlitex.Exec(db.Db, `insert into torrent (infohash,started,addedat,startedat) values (?,?,?,?) on conflict (infohash) do update set startedat=?;`, nil, ih.HexString(), 0, tn, tn, tn) 100 | return 101 | } 102 | 103 | func (db *Sqlite3Db) Delete(ih metainfo.Hash) (err error) { 104 | db.mu.Lock() 105 | defer db.mu.Unlock() 106 | err = sqlitex.Exec(db.Db, `delete from torrent where infohash=?;`, nil, ih.HexString()) 107 | return 108 | } 109 | 110 | func (db *Sqlite3Db) Start(ih metainfo.Hash) (err error) { 111 | db.mu.Lock() 112 | defer db.mu.Unlock() 113 | tn := time.Now().Format(time.RFC3339) 114 | err = sqlitex.Exec(db.Db, `update torrent set started=?,startedat=? where infohash=?;`, nil, 1, tn, ih.HexString()) 115 | return 116 | } 117 | 118 | func (db *Sqlite3Db) SetStarted(ih metainfo.Hash, inp bool) (err error) { 119 | db.mu.Lock() 120 | defer db.mu.Unlock() 121 | err = sqlitex.Exec(db.Db, `update torrent set started=? where infohash=?;`, nil, inp, ih.HexString()) 122 | return 123 | } 124 | 125 | func (db *Sqlite3Db) GetTorrent(ih metainfo.Hash) (*Torrent, error) { 126 | var trnt Torrent 127 | var exists bool 128 | var serr error 129 | var terr error 130 | 131 | db.mu.Lock() 132 | defer db.mu.Unlock() 133 | serr = sqlitex.Exec( 134 | db.Db, `select * from torrent where infohash=?;`, 135 | func(stmt *sqlite.Stmt) error { 136 | exists = true 137 | trnt.Infohash = ih 138 | trnt.Started = stmt.ColumnInt(1) != 0 139 | trnt.AddedAt, terr = time.Parse(time.RFC3339, stmt.GetText("addedat")) 140 | if terr != nil { 141 | DbL.Println(terr) 142 | return terr 143 | } 144 | trnt.StartedAt, terr = time.Parse(time.RFC3339, stmt.GetText("startedat")) 145 | if terr != nil { 146 | DbL.Println(terr) 147 | return terr 148 | } 149 | return nil 150 | }, ih.HexString()) 151 | 152 | if serr != nil { 153 | return nil, serr 154 | } 155 | if !exists { 156 | return nil, fmt.Errorf("Torrent doesn't exist") 157 | } 158 | return &trnt, nil 159 | } 160 | 161 | func (db *Sqlite3Db) GetTorrents() (Trnts []*Torrent, err error) { 162 | Trnts = make([]*Torrent, 0) 163 | 164 | var serr error 165 | var terr error 166 | db.mu.Lock() 167 | defer db.mu.Unlock() 168 | serr = sqlitex.Exec( 169 | db.Db, `select * from torrent;`, 170 | func(stmt *sqlite.Stmt) error { 171 | var trnt Torrent 172 | trnt.Infohash, terr = MetafromHex(stmt.GetText("infohash")) 173 | if terr != nil { 174 | DbL.Println(terr) 175 | return terr 176 | } 177 | trnt.Started = stmt.ColumnInt(1) != 0 178 | trnt.AddedAt, terr = time.Parse(time.RFC3339, stmt.GetText("addedat")) 179 | if terr != nil { 180 | DbL.Println(terr) 181 | return terr 182 | } 183 | trnt.StartedAt, terr = time.Parse(time.RFC3339, stmt.GetText("startedat")) 184 | if terr != nil { 185 | DbL.Println(terr) 186 | return terr 187 | } 188 | Trnts = append(Trnts, &trnt) 189 | return nil 190 | }) 191 | if serr != nil { 192 | return Trnts, serr 193 | } 194 | 195 | return Trnts, nil 196 | } 197 | -------------------------------------------------------------------------------- /internal/db/sqlite3torrentuserdb.go: -------------------------------------------------------------------------------- 1 | //go:build cgo 2 | // +build cgo 3 | 4 | package db 5 | 6 | import ( 7 | "sync" 8 | 9 | sqlite "github.com/go-llsqlite/crawshaw" 10 | "github.com/go-llsqlite/crawshaw/sqlitex" 11 | 12 | "github.com/anacrolix/torrent/metainfo" 13 | ) 14 | 15 | type SqliteTorrentUserDb struct { 16 | Db *sqlite.Conn 17 | mu sync.Mutex 18 | } 19 | 20 | func (db *SqliteTorrentUserDb) Open(fp string) { 21 | var err error 22 | 23 | db.Db, err = sqlite.OpenConn(fp, 0) 24 | if err != nil { 25 | DbL.Fatalln(err) 26 | } 27 | 28 | err = sqlitex.ExecScript(db.Db, `create table if not exists torrentuserdb (username text,infohash text, unique(username,infohash));`) 29 | 30 | if err != nil { 31 | DbL.Fatalln(err) 32 | } 33 | } 34 | 35 | func (db *SqliteTorrentUserDb) Close() { 36 | db.mu.Lock() 37 | defer db.mu.Unlock() 38 | err := db.Db.Close() 39 | if err != nil { 40 | DbL.Fatalln(err) 41 | } 42 | } 43 | 44 | func (db *SqliteTorrentUserDb) Add(username string, ih metainfo.Hash) (err error) { 45 | db.mu.Lock() 46 | defer db.mu.Unlock() 47 | err = sqlitex.Exec(db.Db, `insert into torrentuserdb (username,infohash) values (?,?) on conflict (username,infohash) do nothing;`, nil, username, ih.HexString()) 48 | return 49 | } 50 | 51 | func (db *SqliteTorrentUserDb) Remove(username string, ih metainfo.Hash) (err error) { 52 | db.mu.Lock() 53 | defer db.mu.Unlock() 54 | err = sqlitex.Exec(db.Db, `delete from torrentuserdb where username=? and infohash=?;`, nil, username, ih.HexString()) 55 | return 56 | } 57 | 58 | func (db *SqliteTorrentUserDb) RemoveAll(username string) (err error) { 59 | db.mu.Lock() 60 | defer db.mu.Unlock() 61 | err = sqlitex.Exec(db.Db, `delete from torrentuserdb where username=?;`, nil, username) 62 | return 63 | } 64 | 65 | func (db *SqliteTorrentUserDb) RemoveAllMi(mi metainfo.Hash) (err error) { 66 | db.mu.Lock() 67 | defer db.mu.Unlock() 68 | err = sqlitex.Exec(db.Db, `delete from torrentuserdb where infohash=?;`, nil, mi.HexString()) 69 | return 70 | } 71 | 72 | func (db *SqliteTorrentUserDb) HasUser(username string, ih string) (ret bool) { 73 | db.mu.Lock() 74 | defer db.mu.Unlock() 75 | 76 | serr := sqlitex.Exec( 77 | db.Db, `select 1 from torrentuserdb where username=? and infohash=?;`, 78 | func(stmt *sqlite.Stmt) error { 79 | ret = stmt.ColumnInt(0) == 1 80 | return nil 81 | }, username, ih) 82 | if serr != nil { 83 | return false 84 | } 85 | return 86 | } 87 | 88 | func (db *SqliteTorrentUserDb) ListTorrents(username string) (ret []metainfo.Hash) { 89 | ret = make([]metainfo.Hash, 0) 90 | db.mu.Lock() 91 | defer db.mu.Unlock() 92 | 93 | _ = sqlitex.Exec( 94 | db.Db, `select infohash from torrentuserdb where username=?;`, 95 | func(stmt *sqlite.Stmt) error { 96 | tm, terr := MetafromHex(stmt.GetText("infohash")) 97 | if terr != nil { 98 | DbL.Println(terr) 99 | return terr 100 | } 101 | ret = append(ret, tm) 102 | return nil 103 | }, username) 104 | return 105 | } 106 | 107 | func (db *SqliteTorrentUserDb) ListUsers(mi metainfo.Hash) (ret []string) { 108 | ret = make([]string, 0) 109 | db.mu.Lock() 110 | defer db.mu.Unlock() 111 | 112 | _ = sqlitex.Exec( 113 | db.Db, `select username from torrentuserdb where infohash=?;`, 114 | func(stmt *sqlite.Stmt) error { 115 | username := stmt.GetText("username") 116 | ret = append(ret, username) 117 | return nil 118 | }, mi.HexString()) 119 | return 120 | } 121 | -------------------------------------------------------------------------------- /internal/db/sqlite3trackerdb.go: -------------------------------------------------------------------------------- 1 | //go:build cgo 2 | // +build cgo 3 | 4 | package db 5 | 6 | import ( 7 | "sync" 8 | 9 | sqlite "github.com/go-llsqlite/crawshaw" 10 | "github.com/go-llsqlite/crawshaw/sqlitex" 11 | ) 12 | 13 | type SqliteTdb struct { 14 | Db *sqlite.Conn 15 | mu sync.Mutex 16 | } 17 | 18 | func (db *SqliteTdb) Open(fp string) { 19 | var err error 20 | 21 | db.Db, err = sqlite.OpenConn(fp, 0) 22 | if err != nil { 23 | DbL.Fatalln(err) 24 | } 25 | 26 | err = sqlitex.ExecScript(db.Db, `create table if not exists trackerdb (url text primary key);`) 27 | 28 | if err != nil { 29 | DbL.Fatalln(err) 30 | } 31 | } 32 | 33 | func (db *SqliteTdb) Close() { 34 | db.mu.Lock() 35 | defer db.mu.Unlock() 36 | err := db.Db.Close() 37 | if err != nil { 38 | DbL.Fatalln(err) 39 | } 40 | } 41 | 42 | func (db *SqliteTdb) Add(url string) { 43 | db.mu.Lock() 44 | defer db.mu.Unlock() 45 | _ = sqlitex.Exec(db.Db, `insert into trackerdb (url) values (?);`, nil, url) 46 | } 47 | 48 | func (db *SqliteTdb) Delete(url string) { 49 | db.mu.Lock() 50 | defer db.mu.Unlock() 51 | _ = sqlitex.Exec(db.Db, `delete from trackerdb where url=?;`, nil, url) 52 | } 53 | 54 | func (db *SqliteTdb) DeleteN(count int) { 55 | db.mu.Lock() 56 | defer db.mu.Unlock() 57 | _ = sqlitex.Exec(db.Db, `delete from trackerdb where url in (select url from trackerdb limit ?);`, nil, count) 58 | } 59 | 60 | func (db *SqliteTdb) DeleteAll() { 61 | db.mu.Lock() 62 | defer db.mu.Unlock() 63 | _ = sqlitex.Exec(db.Db, `delete from trackerdb;`, nil) 64 | } 65 | 66 | func (db *SqliteTdb) Count() (ret int64) { 67 | db.mu.Lock() 68 | defer db.mu.Unlock() 69 | _ = sqlitex.Exec( 70 | db.Db, `select count(*) from trackerdb;`, 71 | func(stmt *sqlite.Stmt) error { 72 | ret = stmt.ColumnInt64(0) 73 | return nil 74 | }) 75 | return 76 | } 77 | 78 | func (db *SqliteTdb) Get() (ret []string) { 79 | ret = make([]string, 0) 80 | db.mu.Lock() 81 | defer db.mu.Unlock() 82 | _ = sqlitex.Exec( 83 | db.Db, `select url from trackerdb;`, 84 | func(stmt *sqlite.Stmt) error { 85 | ret = append(ret, stmt.GetText("url")) 86 | return nil 87 | }) 88 | 89 | return 90 | } 91 | -------------------------------------------------------------------------------- /internal/db/sqlite3userdb.go: -------------------------------------------------------------------------------- 1 | //go:build cgo 2 | // +build cgo 3 | 4 | package db 5 | 6 | import ( 7 | "fmt" 8 | "sync" 9 | "time" 10 | 11 | "github.com/google/uuid" 12 | "golang.org/x/crypto/bcrypt" 13 | 14 | sqlite "github.com/go-llsqlite/crawshaw" 15 | "github.com/go-llsqlite/crawshaw/sqlitex" 16 | ) 17 | 18 | type Sqlite3UserDb struct { 19 | Db *sqlite.Conn 20 | mu sync.Mutex 21 | } 22 | 23 | func (db *Sqlite3UserDb) Open(fp string) { 24 | var err error 25 | db.Db, err = sqlite.OpenConn(fp, 0) 26 | if err != nil { 27 | DbL.Fatalln(err) 28 | } 29 | 30 | err = sqlitex.ExecScript(db.Db, `create table if not exists userdb (username text unique,password text,token text unique,usertype integer,createdat text);`) 31 | 32 | if err != nil { 33 | DbL.Fatalln(err) 34 | } 35 | } 36 | 37 | func (db *Sqlite3UserDb) Close() { 38 | db.mu.Lock() 39 | defer db.mu.Unlock() 40 | err := db.Db.Close() 41 | if err != nil { 42 | DbL.Fatalln(err) 43 | } 44 | } 45 | 46 | func (db *Sqlite3UserDb) Add(Username string, Password string, UserType int) (err error) { 47 | defer func() { 48 | if r := recover(); r != nil { 49 | err = fmt.Errorf("uuid error") // uuid may panic 50 | } 51 | }() 52 | if len(Username) <= 5 || len(Password) <= 5 { 53 | return fmt.Errorf("username or password size too small") 54 | } 55 | bytes, err := bcrypt.GenerateFromPassword([]byte(Password), 10) 56 | if err != nil { 57 | return 58 | } 59 | db.mu.Lock() 60 | defer db.mu.Unlock() 61 | err = sqlitex.Exec(db.Db, `insert into userdb (username,password,token,usertype,createdat) values (?,?,?,?,?);`, nil, Username, string(bytes), uuid.New().String(), UserType, time.Now().Format(time.RFC3339)) 62 | return 63 | } 64 | 65 | func (db *Sqlite3UserDb) Delete(Username string) (err error) { 66 | db.mu.Lock() 67 | defer db.mu.Unlock() 68 | err = sqlitex.Exec(db.Db, `delete from userdb where username=?;`, nil, Username) 69 | return 70 | } 71 | 72 | func (db *Sqlite3UserDb) UpdatePw(Username string, Password string) (err error) { 73 | if len(Password) < 5 { 74 | return fmt.Errorf("username or password size too small") 75 | } 76 | bytes, err := bcrypt.GenerateFromPassword([]byte(Password), 10) 77 | if err != nil { 78 | return 79 | } 80 | db.mu.Lock() 81 | defer db.mu.Unlock() 82 | err = sqlitex.Exec(db.Db, `update userdb set password=? where username=?;`, nil, string(bytes), Username) 83 | return 84 | } 85 | 86 | func (db *Sqlite3UserDb) ChangeType(Username string, Type string) (err error) { 87 | if len(Username) == 0 { 88 | return fmt.Errorf("empty username") 89 | } 90 | var ut int 91 | if Type == "admin" { 92 | ut = 1 93 | } else if Type == "user" { 94 | ut = 0 95 | } else if Type == "disabled" { 96 | ut = -1 97 | } else { 98 | return fmt.Errorf("unknown type") 99 | } 100 | db.mu.Lock() 101 | defer db.mu.Unlock() 102 | err = sqlitex.Exec(db.Db, `update userdb set usertype=? where username=?;`, nil, ut, Username) 103 | return 104 | } 105 | 106 | func (db *Sqlite3UserDb) GetUsers() (ret []*User) { 107 | ret = make([]*User, 0) 108 | var terr error 109 | 110 | db.mu.Lock() 111 | defer db.mu.Unlock() 112 | _ = sqlitex.Exec( 113 | db.Db, `select * from userdb;`, 114 | func(stmt *sqlite.Stmt) error { 115 | var user User 116 | user.Username = stmt.GetText("username") 117 | user.Password = stmt.GetText("password") 118 | user.Token = stmt.GetText("token") 119 | user.UserType = stmt.ColumnInt(3) 120 | user.CreatedAt, terr = time.Parse(time.RFC3339, stmt.GetText("createdat")) 121 | if terr != nil { 122 | DbL.Println(terr) 123 | return terr 124 | } 125 | ret = append(ret, &user) 126 | return nil 127 | }) 128 | return 129 | } 130 | 131 | func (db *Sqlite3UserDb) Validate(Username string, Password string) (ut int, ret bool) { 132 | var pw string 133 | var exists bool 134 | var serr error 135 | 136 | db.mu.Lock() 137 | defer db.mu.Unlock() 138 | serr = sqlitex.Exec( 139 | db.Db, `select usertype,password from userdb where username=?;`, 140 | func(stmt *sqlite.Stmt) error { 141 | exists = true 142 | ut = stmt.ColumnInt(0) 143 | pw = stmt.GetText("password") 144 | return nil 145 | }, Username) 146 | 147 | if serr != nil { 148 | return -1, false 149 | } 150 | if !exists { 151 | return -1, false 152 | } 153 | 154 | serr = bcrypt.CompareHashAndPassword([]byte(pw), []byte(Password)) 155 | return ut, serr == nil 156 | } 157 | 158 | func (db *Sqlite3UserDb) ValidateToken(Token string) (user string, ut int, err error) { 159 | if Token == "" { 160 | return "", -1, fmt.Errorf("token is empty") 161 | } 162 | var exists bool 163 | db.mu.Lock() 164 | defer db.mu.Unlock() 165 | err = sqlitex.Exec( 166 | db.Db, `select usertype,username from userdb where token=?;`, 167 | func(stmt *sqlite.Stmt) error { 168 | exists = true 169 | ut = stmt.ColumnInt(0) 170 | user = stmt.GetText("username") 171 | return nil 172 | }, Token) 173 | 174 | if err != nil { 175 | return "", -1, err 176 | } 177 | if !exists { 178 | return "", -1, fmt.Errorf("token doesn't exist") 179 | } 180 | if user == "" { 181 | return "", -1, fmt.Errorf("user doesn't exist") 182 | } 183 | return 184 | } 185 | 186 | func (db *Sqlite3UserDb) SetToken(Username string, Token string) (err error) { 187 | db.mu.Lock() 188 | defer db.mu.Unlock() 189 | err = sqlitex.Exec(db.Db, `update userdb set token=? where username=?;`, nil, Token, Username) 190 | return 191 | } 192 | 193 | func (db *Sqlite3UserDb) CheckUserExists(username string) bool { 194 | var exists bool 195 | var err = sqlitex.Exec( 196 | db.Db, 197 | `select exists(select * from userdb where username = ?);`, 198 | func(stmt *sqlite.Stmt) error { 199 | exists = stmt.ColumnInt(0) != 0 200 | return nil 201 | }, username) 202 | if err != nil { 203 | DbL.Printf("fail to check username exists: %s, err: %v", username, err) 204 | return false 205 | } 206 | return exists 207 | } 208 | -------------------------------------------------------------------------------- /internal/web/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | -------------------------------------------------------------------------------- /internal/web/.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | -------------------------------------------------------------------------------- /internal/web/.prettierignore: -------------------------------------------------------------------------------- 1 | static/** 2 | build/** 3 | node_modules/** 4 | -------------------------------------------------------------------------------- /internal/web/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "none", 4 | "printWidth": 300, 5 | "bracketSameLine": true 6 | } 7 | -------------------------------------------------------------------------------- /internal/web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /internal/web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "exatorrent", 3 | "private": true, 4 | "homepage": "https://github.com/varbhat/exatorrent#readme", 5 | "bugs": { 6 | "url": "https://github.com/varbhat/exatorrent/issues" 7 | }, 8 | "repository": { 9 | "type": "git", 10 | "url": "git+https://github.com/varbhat/exatorrent.git" 11 | }, 12 | "license": "GPL-3.0-or-later", 13 | "author": "varbhat", 14 | "type": "module", 15 | "scripts": { 16 | "build": "vite build", 17 | "check": "svelte-check --tsconfig ./tsconfig.json && tsc -p tsconfig.node.json", 18 | "dev": "vite", 19 | "format": "prettier --write --plugin-search-dir=. .", 20 | "preview": "vite preview" 21 | }, 22 | "dependencies": { 23 | "slocation": "^1.5.0", 24 | "svelte-sonner": "^0.3.28" 25 | }, 26 | "devDependencies": { 27 | "@sveltejs/vite-plugin-svelte": "^5.0.3", 28 | "@tailwindcss/vite": "^4.1.6", 29 | "@tsconfig/svelte": "^5.0.4", 30 | "prettier": "^3.5.3", 31 | "prettier-plugin-svelte": "^3.3.3", 32 | "svelte": "^5.28.2", 33 | "svelte-check": "^4.1.7", 34 | "tailwindcss": "^4.1.6", 35 | "tslib": "^2.7.0", 36 | "typescript": "^5.8.3", 37 | "vite": "^6.3.5" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /internal/web/src/App.svelte: -------------------------------------------------------------------------------- 1 | 20 | 21 | 22 | exatorrent 23 | 24 | 25 | 26 | {#if $isDisConnected === false && $slocation.pathname !== '/signin' && $slocation.pathname !== '/file'} 27 | 28 | {/if} 29 | 30 | {#if $isDisConnected === true && $slocation.pathname !== '/signin' && $slocation.pathname !== '/file'} 31 | 32 | {:else if $slocation.pathname === '/'} 33 | 34 | {:else if $slocation.pathname === '/signin'} 35 | 36 | {:else if $slocation.pathname === '/notifications'} 37 | 38 | {:else if $slocation.pathname === '/torrents'} 39 | 40 | {:else if $slocation.pathname === '/settings'} 41 | 42 | {:else if $slocation.pathname === '/file'} 43 | 44 | {:else if $slocation.pathname === '/stats' && $isAdmin === true} 45 | 46 | {:else if $slocation.pathname === '/users' && $isAdmin === true} 47 | 48 | {:else if $slocation.pathname.startsWith('/user/') && $isAdmin === true} 49 | 50 | {:else if $slocation.pathname.startsWith('/torrent/')} 51 | 52 | {:else if $slocation.pathname === '/about'} 53 | 54 | {:else} 55 |
56 |

Not Found

57 |
58 | {/if} 59 | 60 | 61 | -------------------------------------------------------------------------------- /internal/web/src/app.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | 3 | body { 4 | @apply bg-neutral-900; 5 | } 6 | 7 | .noHL { 8 | -webkit-tap-highlight-color: transparent; 9 | } 10 | 11 | -------------------------------------------------------------------------------- /internal/web/src/main.ts: -------------------------------------------------------------------------------- 1 | import './app.css'; 2 | import App from './App.svelte'; 3 | import { mount } from 'svelte'; 4 | 5 | const app = mount(App, { target: document.getElementById('app')! }); 6 | 7 | export default app; 8 | -------------------------------------------------------------------------------- /internal/web/src/partials/About.svelte: -------------------------------------------------------------------------------- 1 | 13 | 14 |
15 |

exatorrent is torrent client

16 |

License: GPLv3

17 |

Version: {$versionstr}

18 | 23 |
24 | -------------------------------------------------------------------------------- /internal/web/src/partials/Disconnect.svelte: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 | exatorrent - Disconnected 12 | 13 | 14 |
15 |
16 | 17 | 22 | 23 |
24 |

Disconnected

25 | 26 |
27 |
{ 31 | Connect(); 32 | }}> 33 | 34 | 35 | 36 | Reconnect 37 |
38 | 39 |
{ 43 | SignOut(); 44 | }}> 45 | 46 | 47 | 48 | Sign Out 49 |
50 |
51 |
52 | -------------------------------------------------------------------------------- /internal/web/src/partials/File.svelte: -------------------------------------------------------------------------------- 1 | 27 | 28 | 29 | {$fsfileinfo?.name} 30 | 31 | 32 |
33 |
34 | 43 | 44 | 51 | 52 |
53 |
54 | 55 |
56 | 57 | {#if ft === 'video'} 58 |
63 | 64 |

{$fsfileinfo?.name}

65 |

({fileSize($fsfileinfo?.size)})

66 | 67 |
68 |
Use Stream API
69 |
70 | 75 |
76 |
77 | 78 | {#if ft === 'video' || ft === 'audio'} 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | {/if} 87 | 88 | 89 | 90 | 91 | 92 | 93 |
94 | -------------------------------------------------------------------------------- /internal/web/src/partials/Index.svelte: -------------------------------------------------------------------------------- 1 | 71 | 72 |
73 |
74 |
75 |

76 | {#if ismetainfo}Enter Magnet or Infohash{:else}Select Torrent File{/if} 77 |

78 |
79 | 80 |
81 |
82 | {#if ismetainfo} 83 | 91 | {:else} 92 | 101 | {/if} 102 | 111 |
112 | 113 | 114 |
115 |
116 |
117 | 118 |
119 |
120 |
{ 123 | slocation.goto('/torrents'); 124 | }}> 125 | 126 | 127 | 128 | Torrents 129 |
130 |
{ 133 | slocation.goto('/settings'); 134 | }}> 135 | 136 | 141 | 142 | 143 | Settings 144 |
145 |
146 | {#if $isAdmin} 147 |
148 |
{ 151 | slocation.goto('/users'); 152 | }}> 153 | 154 | 155 | 156 | Users 157 |
158 |
{ 161 | slocation.goto('/stats'); 162 | }}> 163 | 164 | 165 | 166 | Stats 167 |
168 |
169 | {/if} 170 |
171 | -------------------------------------------------------------------------------- /internal/web/src/partials/Notifications.svelte: -------------------------------------------------------------------------------- 1 | 5 | 6 |
7 | {#if $resplist?.has === true} 8 | 16 | {#each $resplist?.data as resp} 17 |
{ 20 | if (typeof resp?.infohash === 'string' && resp?.infohash.length > 0) { 21 | slocation.goto(`/torrent/${resp?.infohash}`); 22 | } 23 | }}> 24 |
25 | {resp?.message} 26 |
27 |
28 |
29 | {resp?.type} 30 | {resp?.state} 31 | {resp?.infohash ? `( ${resp?.infohash} )` : ''} 32 |
33 |
34 |
35 | {/each} 36 | {:else} 37 |

No Notifications

38 | {/if} 39 |
40 | -------------------------------------------------------------------------------- /internal/web/src/partials/ProgStat.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 |
14 |
15 |
16 |
17 |
18 |
19 | {fileSize(bytescompleted)} / {fileSize(length)} (Off. {offset}) 20 |
21 |
22 | {progress?.toLocaleString('en-US', { 23 | maximumFractionDigits: 2, 24 | minimumFractionDigits: 2 25 | })} % 26 |
27 |
28 |
29 | -------------------------------------------------------------------------------- /internal/web/src/partials/Settings.svelte: -------------------------------------------------------------------------------- 1 | 95 | 96 |
97 |
99 |
100 |
101 |

102 | User Settings 103 | {#if localStorage.getItem('exausertype') === 'admin'} admin{/if} 105 |

106 |
107 |
108 | 109 |
110 |
111 | {#if editmode === false} 112 | 115 | 127 | {:else if editmode === true} 128 | 131 | 142 | 148 | {/if} 149 |
150 |
152 |
154 | Don't Start Torrents on Add 155 |
156 |
157 | 162 |
163 |
164 | {#if $isAdmin === true} 165 |
167 |
169 | Admin Mode 170 |
171 |
172 | 178 |
179 |
180 | {/if} 181 |
182 |
183 |
184 | 185 |
186 |
188 |
189 |
190 |

Disk Usage

191 |
192 | 193 | {#if diskstatsOpen === true} 194 | 207 | {/if} 208 | 209 | 221 |
222 | 223 |
224 | {#if diskstatsOpen === true} 225 |
226 | Total: {fileSize($diskstats?.total)} 227 |
228 |
229 | Free: {fileSize($diskstats?.free)} 230 |
231 |
232 | Used: {fileSize($diskstats?.used)} ({$diskstats?.usedPercent} %) 233 |
234 | {/if} 235 |
236 |
237 |
238 | 239 | {#if $isAdmin === true} 240 |
241 |
243 |
244 |
245 |

Misc Settings

246 |
247 | 248 | {#if miscOpen === true} 249 | 264 | {/if} 265 | 266 | 280 |
281 | 282 |
283 | {#if miscOpen === true} 284 |
285 | Total Number of Trackers in TrackerDB: {$nooftrackersintrackerdb} 286 |
287 | 297 | 306 | 315 |
316 | 319 | 331 |
332 |
333 | 336 | 348 |
349 | {/if} 350 |
351 |
352 |
353 | 354 |
355 |
357 |
358 |
359 |

Engine Settings

360 |
361 | 362 | {#if engsettingsOpen === true} 363 | 379 | 394 | {/if} 395 | 396 | 410 |
411 | 412 |
413 | {#if engsettingsOpen === true && $engconfig != null} 414 |