Initial commit: docker compose config
Release Docker multi arch / docker (push) Has been cancelled
Test Install Script / Test Script Syntax (push) Has been cancelled
Test Install Script / Test on almalinux-10 (default) (push) Has been cancelled
Test Install Script / Test on almalinux-10 (root) (push) Has been cancelled
Test Install Script / Test on almalinux-8 (default) (push) Has been cancelled
Test Install Script / Test on almalinux-8 (root) (push) Has been cancelled
Test Install Script / Test on almalinux-9 (default) (push) Has been cancelled
Test Install Script / Test on almalinux-9 (root) (push) Has been cancelled
Test Install Script / Test on amazonlinux-2 (default) (push) Has been cancelled
Test Install Script / Test on amazonlinux-2 (root) (push) Has been cancelled
Test Install Script / Test on debian-11 (default) (push) Has been cancelled
Test Install Script / Test on debian-11 (root) (push) Has been cancelled
Test Install Script / Test on debian-12 (default) (push) Has been cancelled
Test Install Script / Test on debian-12 (root) (push) Has been cancelled
Test Install Script / Test on debian-13 (default) (push) Has been cancelled
Test Install Script / Test on debian-13 (root) (push) Has been cancelled
Test Install Script / Test on fedora-latest (default) (push) Has been cancelled
Test Install Script / Test on fedora-latest (root) (push) Has been cancelled
Test Install Script / Test on rocky-10 (default) (push) Has been cancelled
Test Install Script / Test on rocky-10 (root) (push) Has been cancelled
Test Install Script / Test on rocky-8 (default) (push) Has been cancelled
Test Install Script / Test on rocky-8 (root) (push) Has been cancelled
Test Install Script / Test on rocky-9 (default) (push) Has been cancelled
Test Install Script / Test on rocky-9 (root) (push) Has been cancelled
Test Install Script / Test on ubuntu-22.04 (default) (push) Has been cancelled
Test Install Script / Test on ubuntu-22.04 (root) (push) Has been cancelled
Test Install Script / Test on ubuntu-24.04 (default) (push) Has been cancelled
Test Install Script / Test on ubuntu-24.04 (root) (push) Has been cancelled
@@ -0,0 +1,63 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"server/torr"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
// Action: get
|
||||
type cacheReqJS struct {
|
||||
requestI
|
||||
Hash string `json:"hash,omitempty"`
|
||||
}
|
||||
|
||||
// cache godoc
|
||||
//
|
||||
// @Summary Return cache stats
|
||||
// @Description Return cache stats.
|
||||
//
|
||||
// @Tags API
|
||||
//
|
||||
// @Param request body cacheReqJS true "Cache stats request"
|
||||
//
|
||||
// @Produce json
|
||||
// @Success 200 {object} state.CacheState "Cache stats"
|
||||
// @Router /cache [post]
|
||||
func cache(c *gin.Context) {
|
||||
var req cacheReqJS
|
||||
err := c.ShouldBindJSON(&req)
|
||||
if err != nil {
|
||||
c.AbortWithError(http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusBadRequest)
|
||||
switch req.Action {
|
||||
case "get":
|
||||
{
|
||||
getCache(req, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func getCache(req cacheReqJS, c *gin.Context) {
|
||||
if req.Hash == "" {
|
||||
c.AbortWithError(http.StatusBadRequest, errors.New("hash is empty"))
|
||||
return
|
||||
}
|
||||
tor := torr.GetTorrent(req.Hash)
|
||||
|
||||
if tor != nil {
|
||||
st := tor.CacheState()
|
||||
if st == nil {
|
||||
c.JSON(200, struct{}{})
|
||||
} else {
|
||||
c.JSON(200, st)
|
||||
}
|
||||
} else {
|
||||
c.Status(http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type fileReader struct {
|
||||
pos int64
|
||||
size int64
|
||||
io.ReadSeeker
|
||||
}
|
||||
|
||||
func newFR(size int64) *fileReader {
|
||||
return &fileReader{
|
||||
pos: 0,
|
||||
size: size,
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fileReader) Read(p []byte) (n int, err error) {
|
||||
f.pos = f.pos + int64(len(p))
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func (f *fileReader) Seek(offset int64, whence int) (int64, error) {
|
||||
switch whence {
|
||||
case 0:
|
||||
f.pos = offset
|
||||
case 1:
|
||||
f.pos += offset
|
||||
case 2:
|
||||
f.pos = f.size + offset
|
||||
}
|
||||
return f.pos, nil
|
||||
}
|
||||
|
||||
// download godoc
|
||||
//
|
||||
// @Summary Generates test file of given size
|
||||
// @Description Download the test file of given size (for speed testing purpose).
|
||||
//
|
||||
// @Tags API
|
||||
//
|
||||
// @Param size path string true "Test file size (in MB)"
|
||||
//
|
||||
// @Produce application/octet-stream
|
||||
// @Success 200 {file} file
|
||||
// @Router /download/{size} [get]
|
||||
func download(c *gin.Context) {
|
||||
szStr := c.Param("size")
|
||||
sz, err := strconv.Atoi(szStr)
|
||||
if err != nil {
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
http.ServeContent(c.Writer, c.Request, fmt.Sprintln(szStr)+"mb.bin", time.Now(), newFR(int64(sz*1024*1024)))
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"server/ffprobe"
|
||||
sets "server/settings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ffp godoc
|
||||
//
|
||||
// @Summary Gather informations using ffprobe
|
||||
// @Description Gather informations using ffprobe.
|
||||
//
|
||||
// @Tags API
|
||||
//
|
||||
// @Param hash path string true "Torrent hash"
|
||||
// @Param id path string true "File index in torrent"
|
||||
//
|
||||
// @Produce json
|
||||
// @Success 200 "Data returned from ffprobe"
|
||||
// @Router /ffp/{hash}/{id} [get]
|
||||
func ffp(c *gin.Context) {
|
||||
hash := c.Param("hash")
|
||||
indexStr := c.Param("id")
|
||||
|
||||
if hash == "" || indexStr == "" {
|
||||
c.AbortWithError(http.StatusNotFound, errors.New("link should not be empty"))
|
||||
return
|
||||
}
|
||||
|
||||
link := "http://127.0.0.1:" + sets.Port + "/play/" + hash + "/" + indexStr
|
||||
if sets.Ssl {
|
||||
link = "https://127.0.0.1:" + sets.SslPort + "/play/" + hash + "/" + indexStr
|
||||
}
|
||||
|
||||
data, err := ffprobe.ProbeUrl(link)
|
||||
if err != nil {
|
||||
c.AbortWithError(http.StatusBadRequest, fmt.Errorf("error getting data: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, data)
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/anacrolix/missinggo/v2/httptoo"
|
||||
|
||||
sets "server/settings"
|
||||
"server/torr"
|
||||
"server/torr/state"
|
||||
"server/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
// allPlayList godoc
|
||||
//
|
||||
// @Summary Get a M3U playlist with all torrents
|
||||
// @Description Retrieve all torrents and generates a bundled M3U playlist.
|
||||
//
|
||||
// @Tags API
|
||||
//
|
||||
// @Produce audio/x-mpegurl
|
||||
// @Success 200 {file} file
|
||||
// @Router /playlistall/all.m3u [get]
|
||||
func allPlayList(c *gin.Context) {
|
||||
torrs := torr.ListTorrent()
|
||||
|
||||
host := utils.GetScheme(c) + "://" + utils.GetHost(c)
|
||||
list := "#EXTM3U\n"
|
||||
hash := ""
|
||||
// fn=file.m3u fix forkplayer bug with end .m3u in link
|
||||
for _, tr := range torrs {
|
||||
list += "#EXTINF:0"
|
||||
if tr.Poster != "" {
|
||||
list += " tvg-logo=\"" + tr.Poster + "\""
|
||||
}
|
||||
list += " type=\"playlist\"," + tr.Title + "\n"
|
||||
list += host + "/stream/" + url.PathEscape(tr.Title) + ".m3u?link=" + tr.TorrentSpec.InfoHash.HexString() + "&m3u&fn=file.m3u\n"
|
||||
hash += tr.Hash().HexString()
|
||||
}
|
||||
|
||||
sendM3U(c, "all.m3u", hash, list)
|
||||
}
|
||||
|
||||
// playList godoc
|
||||
//
|
||||
// @Summary Get HTTP link of torrent in M3U list
|
||||
// @Description Get HTTP link of torrent in M3U list.
|
||||
//
|
||||
// @Tags API
|
||||
//
|
||||
// @Param hash query string true "Torrent hash"
|
||||
// @Param fromlast query bool false "From last play file"
|
||||
//
|
||||
// @Produce audio/x-mpegurl
|
||||
// @Success 200 {file} file
|
||||
// @Router /playlist [get]
|
||||
func playList(c *gin.Context) {
|
||||
hash, _ := c.GetQuery("hash")
|
||||
_, fromlast := c.GetQuery("fromlast")
|
||||
if hash == "" {
|
||||
c.AbortWithError(http.StatusBadRequest, errors.New("hash is empty"))
|
||||
return
|
||||
}
|
||||
|
||||
tor := torr.GetTorrent(hash)
|
||||
if tor == nil {
|
||||
c.AbortWithStatus(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
if tor.Stat == state.TorrentInDB {
|
||||
tor = torr.LoadTorrent(tor)
|
||||
if tor == nil {
|
||||
c.AbortWithError(http.StatusInternalServerError, errors.New("error get torrent info"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
host := utils.GetScheme(c) + "://" + utils.GetHost(c)
|
||||
list := getM3uList(tor.Status(), host, fromlast)
|
||||
list = "#EXTM3U\n" + list
|
||||
name := strings.ReplaceAll(c.Param("fname"), `/`, "") // strip starting / from param
|
||||
if name == "" {
|
||||
name = tor.Name() + ".m3u"
|
||||
} else if !strings.HasSuffix(strings.ToLower(name), ".m3u") && !strings.HasSuffix(strings.ToLower(name), ".m3u8") {
|
||||
name += ".m3u"
|
||||
}
|
||||
|
||||
sendM3U(c, name, tor.Hash().HexString(), list)
|
||||
}
|
||||
|
||||
func sendM3U(c *gin.Context, name, hash string, m3u string) {
|
||||
c.Header("Content-Type", "audio/x-mpegurl")
|
||||
c.Header("Connection", "close")
|
||||
if hash != "" {
|
||||
etag := hex.EncodeToString([]byte(fmt.Sprintf("%s/%s", hash, name)))
|
||||
c.Header("ETag", httptoo.EncodeQuotedString(etag))
|
||||
}
|
||||
if name == "" {
|
||||
name = "playlist.m3u"
|
||||
}
|
||||
c.Header("Content-Disposition", `attachment; filename="`+name+`"`)
|
||||
http.ServeContent(c.Writer, c.Request, name, time.Now(), bytes.NewReader([]byte(m3u)))
|
||||
}
|
||||
|
||||
func getM3uList(tor *state.TorrentStatus, host string, fromLast bool) string {
|
||||
m3u := ""
|
||||
from := 0
|
||||
if fromLast {
|
||||
pos := searchLastPlayed(tor)
|
||||
if pos != -1 {
|
||||
from = pos
|
||||
}
|
||||
}
|
||||
for i, f := range tor.FileStats {
|
||||
if i >= from {
|
||||
if utils.GetMimeType(f.Path) != "*/*" {
|
||||
fn := filepath.Base(f.Path)
|
||||
if fn == "" {
|
||||
fn = f.Path
|
||||
}
|
||||
m3u += "#EXTINF:0," + fn + "\n"
|
||||
fileNamesakes := findFileNamesakes(tor.FileStats, f) // find external media with same name (audio/subtiles tracks)
|
||||
if fileNamesakes != nil {
|
||||
m3u += "#EXTVLCOPT:input-slave=" // include VLC option for external media
|
||||
for _, namesake := range fileNamesakes { // include play-links to external media, with # splitter
|
||||
sname := filepath.Base(namesake.Path)
|
||||
m3u += host + "/stream/" + url.PathEscape(sname) + "?link=" + tor.Hash + "&index=" + fmt.Sprint(namesake.Id) + "&play#"
|
||||
}
|
||||
m3u += "\n"
|
||||
}
|
||||
name := filepath.Base(f.Path)
|
||||
m3u += host + "/stream/" + url.PathEscape(name) + "?link=" + tor.Hash + "&index=" + fmt.Sprint(f.Id) + "&play\n"
|
||||
}
|
||||
}
|
||||
}
|
||||
return m3u
|
||||
}
|
||||
|
||||
func findFileNamesakes(files []*state.TorrentFileStat, file *state.TorrentFileStat) []*state.TorrentFileStat {
|
||||
// find files with the same name in torrent
|
||||
name := filepath.Base(strings.TrimSuffix(file.Path, filepath.Ext(file.Path)))
|
||||
var namesakes []*state.TorrentFileStat
|
||||
for _, f := range files {
|
||||
if strings.Contains(f.Path, name) { // external tracks always include name of videofile
|
||||
if f != file { // exclude itself
|
||||
namesakes = append(namesakes, f)
|
||||
}
|
||||
}
|
||||
}
|
||||
return namesakes
|
||||
}
|
||||
|
||||
func searchLastPlayed(tor *state.TorrentStatus) int {
|
||||
viewed := sets.ListViewed(tor.Hash)
|
||||
if len(viewed) == 0 {
|
||||
return -1
|
||||
}
|
||||
sort.Slice(viewed, func(i, j int) bool {
|
||||
return viewed[i].FileIndex > viewed[j].FileIndex
|
||||
})
|
||||
|
||||
lastViewedIndex := viewed[0].FileIndex
|
||||
|
||||
for i, stat := range tor.FileStats {
|
||||
if stat.Id == lastViewedIndex {
|
||||
if i >= len(tor.FileStats) {
|
||||
return -1
|
||||
}
|
||||
return i
|
||||
}
|
||||
}
|
||||
|
||||
return -1
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"server/torr"
|
||||
"server/torr/state"
|
||||
"server/web/api/utils"
|
||||
)
|
||||
|
||||
// play godoc
|
||||
//
|
||||
// @Summary Play given torrent by infohash
|
||||
// @Description Play given torrent referenced by infohash and file id.
|
||||
//
|
||||
// @Tags API
|
||||
//
|
||||
// @Param hash path string true "Torrent infohash"
|
||||
// @Param id path string true "File index in torrent"
|
||||
//
|
||||
// @Produce application/octet-stream
|
||||
// @Success 200 "Torrent data"
|
||||
// @Router /play/{hash}/{id} [get]
|
||||
func play(c *gin.Context) {
|
||||
hash := c.Param("hash")
|
||||
indexStr := c.Param("id")
|
||||
notAuth := c.GetBool("auth_required") && c.GetString(gin.AuthUserKey) == ""
|
||||
|
||||
if hash == "" || indexStr == "" {
|
||||
c.AbortWithError(http.StatusNotFound, errors.New("no infohash or file index in link"))
|
||||
return
|
||||
}
|
||||
|
||||
spec, err := utils.ParseLink(hash)
|
||||
if err != nil {
|
||||
c.AbortWithError(http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
tor := torr.GetTorrent(spec.InfoHash.HexString())
|
||||
if tor == nil && notAuth {
|
||||
c.Header("WWW-Authenticate", "Basic realm=Authorization Required")
|
||||
c.AbortWithStatus(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if tor == nil {
|
||||
c.AbortWithError(http.StatusInternalServerError, errors.New("error get torrent"))
|
||||
return
|
||||
}
|
||||
|
||||
if tor.Stat == state.TorrentInDB {
|
||||
tor, err = torr.AddTorrent(spec, tor.Title, tor.Poster, tor.Data, tor.Category)
|
||||
if err != nil {
|
||||
c.AbortWithError(http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if !tor.GotInfo() {
|
||||
c.AbortWithError(http.StatusInternalServerError, errors.New("torrent connection timeout"))
|
||||
return
|
||||
}
|
||||
|
||||
// find file
|
||||
index := -1
|
||||
if len(tor.Files()) == 1 {
|
||||
index = 1
|
||||
} else {
|
||||
ind, err := strconv.Atoi(indexStr)
|
||||
if err == nil {
|
||||
index = ind
|
||||
}
|
||||
}
|
||||
if index == -1 { // if file index not set and play file exec
|
||||
c.AbortWithError(http.StatusBadRequest, errors.New("file \"index\" is wrong"))
|
||||
return
|
||||
}
|
||||
|
||||
tor.Stream(index, c.Request, c.Writer)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
config "server/settings"
|
||||
"server/web/auth"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type requestI struct {
|
||||
Action string `json:"action,omitempty"`
|
||||
}
|
||||
|
||||
func SetupRoute(route gin.IRouter) {
|
||||
authorized := route.Group("/", auth.CheckAuth())
|
||||
|
||||
authorized.GET("/shutdown", shutdown)
|
||||
authorized.GET("/shutdown/*reason", shutdown)
|
||||
|
||||
authorized.POST("/settings", settings)
|
||||
authorized.POST("/torznab/test", torznabTest)
|
||||
|
||||
authorized.POST("/torrents", torrents)
|
||||
|
||||
authorized.POST("/torrent/upload", torrentUpload)
|
||||
|
||||
authorized.POST("/cache", cache)
|
||||
|
||||
route.HEAD("/stream", stream)
|
||||
route.GET("/stream", stream)
|
||||
|
||||
route.HEAD("/stream/*fname", stream)
|
||||
route.GET("/stream/*fname", stream)
|
||||
|
||||
route.HEAD("/play/:hash/:id", play)
|
||||
route.GET("/play/:hash/:id", play)
|
||||
|
||||
authorized.POST("/viewed", viewed)
|
||||
|
||||
authorized.GET("/playlistall/all.m3u", allPlayList)
|
||||
|
||||
route.GET("/playlist", playList)
|
||||
route.GET("/playlist/*fname", playList)
|
||||
|
||||
authorized.GET("/download/:size", download)
|
||||
|
||||
if config.SearchWA {
|
||||
route.GET("/search/*query", rutorSearch)
|
||||
} else {
|
||||
authorized.GET("/search/*query", rutorSearch)
|
||||
}
|
||||
|
||||
if config.SearchWA {
|
||||
route.GET("/torznab/search/*query", torznabSearch)
|
||||
} else {
|
||||
authorized.GET("/torznab/search/*query", torznabSearch)
|
||||
}
|
||||
|
||||
// Add storage settings endpoints
|
||||
authorized.GET("/storage/settings", GetStorageSettings)
|
||||
authorized.POST("/storage/settings", UpdateStorageSettings)
|
||||
|
||||
// Add TMDB settings endpoint
|
||||
authorized.GET("/tmdb/settings", tmdbSettings)
|
||||
|
||||
authorized.GET("/ffp/:hash/:id", ffp)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"server/rutor"
|
||||
"server/rutor/models"
|
||||
sets "server/settings"
|
||||
)
|
||||
|
||||
// rutorSearch godoc
|
||||
//
|
||||
// @Summary Makes a rutor search
|
||||
// @Description Makes a rutor search.
|
||||
//
|
||||
// @Tags API
|
||||
//
|
||||
// @Param query query string true "Rutor query"
|
||||
//
|
||||
// @Produce json
|
||||
// @Success 200 {array} models.TorrentDetails "Rutor torrent search result(s)"
|
||||
// @Router /search [get]
|
||||
func rutorSearch(c *gin.Context) {
|
||||
if !sets.BTsets.EnableRutorSearch {
|
||||
c.JSON(http.StatusBadRequest, []string{})
|
||||
return
|
||||
}
|
||||
query := c.Query("query")
|
||||
query, _ = url.QueryUnescape(query)
|
||||
list := rutor.Search(query)
|
||||
if list == nil {
|
||||
list = []*models.TorrentDetails{}
|
||||
}
|
||||
c.JSON(200, list)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"server/rutor"
|
||||
|
||||
"server/dlna"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/pkg/errors"
|
||||
|
||||
sets "server/settings"
|
||||
"server/torr"
|
||||
)
|
||||
|
||||
// Action: get, set, def
|
||||
type setsReqJS struct {
|
||||
requestI
|
||||
Sets *sets.BTSets `json:"sets,omitempty"`
|
||||
}
|
||||
|
||||
// settings godoc
|
||||
//
|
||||
// @Summary Get / Set server settings
|
||||
// @Description Allow to get or set server settings.
|
||||
//
|
||||
// @Tags API
|
||||
//
|
||||
// @Param request body setsReqJS true "Settings request. Available params for action: get, set, def"
|
||||
//
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} sets.BTSets "Settings JSON or nothing. Depends on what action has been asked."
|
||||
// @Router /settings [post]
|
||||
func settings(c *gin.Context) {
|
||||
var req setsReqJS
|
||||
err := c.ShouldBindJSON(&req)
|
||||
if err != nil {
|
||||
c.AbortWithError(http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Action == "get" {
|
||||
c.JSON(200, sets.BTsets)
|
||||
return
|
||||
} else if req.Action == "set" {
|
||||
torr.SetSettings(req.Sets)
|
||||
dlna.Stop()
|
||||
if req.Sets.EnableDLNA {
|
||||
dlna.Start()
|
||||
}
|
||||
rutor.Stop()
|
||||
rutor.Start()
|
||||
c.Status(200)
|
||||
return
|
||||
} else if req.Action == "def" {
|
||||
torr.SetDefSettings()
|
||||
dlna.Stop()
|
||||
rutor.Stop()
|
||||
c.Status(200)
|
||||
return
|
||||
}
|
||||
c.AbortWithError(http.StatusBadRequest, errors.New("action is empty"))
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
sets "server/settings"
|
||||
"server/torr"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// shutdown godoc
|
||||
// @Summary Shuts down server
|
||||
// @Description Gracefully shuts down server after 1 second.
|
||||
//
|
||||
// @Tags API
|
||||
//
|
||||
// @Success 200
|
||||
// @Router /shutdown [get]
|
||||
func shutdown(c *gin.Context) {
|
||||
reasonStr := strings.ReplaceAll(c.Param("reason"), `/`, "")
|
||||
if sets.ReadOnly && reasonStr == "" {
|
||||
c.Status(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
c.Status(200)
|
||||
go func() {
|
||||
time.Sleep(1000)
|
||||
torr.Shutdown()
|
||||
}()
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
sets "server/settings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GetStorageSettings godoc
|
||||
// @Summary Get storage configuration settings
|
||||
// @Description Retrieves the current storage preferences for settings and viewed history
|
||||
// @Tags API
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security ApiKeyAuth
|
||||
// @Success 200 {object} map[string]interface{} "Storage preferences"
|
||||
// @Failure 401 {object} map[string]string "Unauthorized"
|
||||
// @Failure 500 {object} map[string]string "Internal server error"
|
||||
// @Router /storage/settings [get]
|
||||
func GetStorageSettings(c *gin.Context) {
|
||||
prefs := sets.GetStoragePreferences()
|
||||
c.JSON(http.StatusOK, prefs)
|
||||
}
|
||||
|
||||
// UpdateStorageSettings godoc
|
||||
// @Summary Update storage configuration settings
|
||||
// @Description Updates the storage preferences for settings and viewed history. Requires application restart for changes to take effect.
|
||||
// @Tags API
|
||||
// @Accept json,x-www-form-urlencoded
|
||||
// @Produce json
|
||||
// @Security ApiKeyAuth
|
||||
// @Param request body map[string]interface{} true "Storage preferences to update"
|
||||
// @Param settings formData string false "Settings storage type" Enums(json,bbolt)
|
||||
// @Param viewed formData string false "Viewed history storage type" Enums(json,bbolt)
|
||||
// @Success 200 {object} map[string]string "Update successful"
|
||||
// @Failure 400 {object} map[string]string "Invalid input data"
|
||||
// @Failure 401 {object} map[string]string "Unauthorized"
|
||||
// @Failure 403 {object} map[string]string "Read-only mode"
|
||||
// @Failure 500 {object} map[string]string "Internal server error"
|
||||
// @Router /storage/settings [post]
|
||||
func UpdateStorageSettings(c *gin.Context) {
|
||||
if sets.ReadOnly {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Read-only mode"})
|
||||
return
|
||||
}
|
||||
|
||||
var prefs map[string]interface{}
|
||||
|
||||
// Check Content-Type to handle both JSON and form data
|
||||
contentType := c.GetHeader("Content-Type")
|
||||
|
||||
if contentType == "application/x-www-form-urlencoded" {
|
||||
// Handle form data
|
||||
settings := c.PostForm("settings")
|
||||
viewed := c.PostForm("viewed")
|
||||
|
||||
prefs = make(map[string]interface{})
|
||||
if settings != "" {
|
||||
prefs["settings"] = settings
|
||||
}
|
||||
if viewed != "" {
|
||||
prefs["viewed"] = viewed
|
||||
}
|
||||
} else {
|
||||
// Handle JSON (default)
|
||||
if err := c.ShouldBindJSON(&prefs); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Validate preferences - only validate if provided
|
||||
if settingsPref, ok := prefs["settings"].(string); ok && settingsPref != "" {
|
||||
if settingsPref != "json" && settingsPref != "bbolt" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid settings storage value"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if viewedPref, ok := prefs["viewed"].(string); ok && viewedPref != "" {
|
||||
if viewedPref != "json" && viewedPref != "bbolt" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid viewed storage value"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we have at least one value to update
|
||||
if len(prefs) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "No preferences provided"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := sets.SetStoragePreferences(prefs); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"server/log"
|
||||
"server/torrshash"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"server/torr"
|
||||
"server/torr/state"
|
||||
utils2 "server/utils"
|
||||
"server/web/api/utils"
|
||||
|
||||
"github.com/anacrolix/torrent"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
// get stat
|
||||
// http://127.0.0.1:8090/stream/fname?link=...&stat
|
||||
// get m3u
|
||||
// http://127.0.0.1:8090/stream/fname?link=...&index=1&m3u
|
||||
// http://127.0.0.1:8090/stream/fname?link=...&index=1&m3u&fromlast
|
||||
// stream torrent
|
||||
// http://127.0.0.1:8090/stream/fname?link=...&index=1&play
|
||||
// http://127.0.0.1:8090/stream/fname?link=...&index=1&play&preload
|
||||
// http://127.0.0.1:8090/stream/fname?link=...&index=1&play&save
|
||||
// http://127.0.0.1:8090/stream/fname?link=...&index=1&play&save&title=...&poster=...
|
||||
// only save
|
||||
// http://127.0.0.1:8090/stream/fname?link=...&save&title=...&poster=...
|
||||
|
||||
// stream godoc
|
||||
//
|
||||
// @Summary Multi usage endpoint
|
||||
// @Description Multi usage endpoint.
|
||||
//
|
||||
// @Tags API
|
||||
//
|
||||
// @Param link query string true "Magnet/hash/link to torrent"
|
||||
// @Param index query string false "File index in torrent"
|
||||
// @Param preload query string false "Should preload torrent"
|
||||
// @Param stat query string false "Get statistics from torrent"
|
||||
// @Param save query string false "Should save torrent"
|
||||
// @Param m3u query string false "Get torrent as M3U playlist"
|
||||
// @Param fromlast query string false "Get M3U from last played file"
|
||||
// @Param play query string false "Start stream torrent"
|
||||
// @Param title query string false "Set title of torrent"
|
||||
// @Param poster query string false "Set poster link of torrent"
|
||||
// @Param category query string false "Set category of torrent, used in web: movie, tv, music, other"
|
||||
//
|
||||
// @Produce application/octet-stream
|
||||
// @Success 200 "Data returned according to query"
|
||||
// @Router /stream [get]
|
||||
func stream(c *gin.Context) {
|
||||
link := c.Query("link")
|
||||
indexStr := c.Query("index")
|
||||
_, preload := c.GetQuery("preload")
|
||||
_, stat := c.GetQuery("stat")
|
||||
_, save := c.GetQuery("save")
|
||||
_, m3u := c.GetQuery("m3u")
|
||||
_, fromlast := c.GetQuery("fromlast")
|
||||
_, play := c.GetQuery("play")
|
||||
title := c.Query("title")
|
||||
poster := c.Query("poster")
|
||||
category := c.Query("category")
|
||||
|
||||
data := ""
|
||||
|
||||
notAuth := c.GetBool("auth_required") && c.GetString(gin.AuthUserKey) == ""
|
||||
|
||||
if notAuth {
|
||||
err := utils.TestLink(link, !notAuth)
|
||||
if err != nil {
|
||||
log.TLogln("Wrong link:", err)
|
||||
c.AbortWithError(http.StatusBadRequest, errors.New("wrong link"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if notAuth && (play || m3u) {
|
||||
streamNoAuth(c)
|
||||
return
|
||||
}
|
||||
if notAuth {
|
||||
c.Header("WWW-Authenticate", "Basic realm=Authorization Required")
|
||||
c.AbortWithStatus(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if link == "" {
|
||||
c.AbortWithError(http.StatusBadRequest, errors.New("link should not be empty"))
|
||||
return
|
||||
}
|
||||
|
||||
link, _ = url.QueryUnescape(link)
|
||||
title, _ = url.QueryUnescape(title)
|
||||
poster, _ = url.QueryUnescape(poster)
|
||||
category, _ = url.QueryUnescape(category)
|
||||
|
||||
var spec *torrent.TorrentSpec
|
||||
var torrsHash *torrshash.TorrsHash
|
||||
var err error
|
||||
|
||||
if strings.HasPrefix(link, "torrs://") || (len(link) > 45 && torrshash.IsBase62(link)) {
|
||||
spec, torrsHash, err = utils.ParseTorrsHash(link)
|
||||
if err != nil {
|
||||
log.TLogln("error parse torrshash:", err)
|
||||
c.AbortWithError(http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
if title == "" {
|
||||
title = torrsHash.Title()
|
||||
}
|
||||
if poster == "" {
|
||||
poster = torrsHash.Poster()
|
||||
}
|
||||
if category == "" {
|
||||
category = torrsHash.Category()
|
||||
}
|
||||
} else {
|
||||
spec, err = utils.ParseLink(link)
|
||||
if err != nil {
|
||||
c.AbortWithError(http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
tor := torr.GetTorrent(spec.InfoHash.HexString())
|
||||
if tor != nil {
|
||||
title = tor.Title
|
||||
poster = tor.Poster
|
||||
data = tor.Data
|
||||
category = tor.Category
|
||||
}
|
||||
if tor == nil || tor.Stat == state.TorrentInDB {
|
||||
tor, err = torr.AddTorrent(spec, title, poster, data, category)
|
||||
if err != nil {
|
||||
c.AbortWithError(http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if !tor.GotInfo() {
|
||||
c.AbortWithError(http.StatusInternalServerError, errors.New("torrent connection timeout"))
|
||||
return
|
||||
}
|
||||
|
||||
if tor.Title == "" {
|
||||
tor.Title = tor.Name()
|
||||
}
|
||||
|
||||
// save to db
|
||||
if save {
|
||||
torr.SaveTorrentToDB(tor)
|
||||
c.Status(200) // only set status, not return
|
||||
}
|
||||
|
||||
// find file
|
||||
index := -1
|
||||
if len(tor.Files()) == 1 {
|
||||
index = 1
|
||||
} else {
|
||||
ind, err := strconv.Atoi(indexStr)
|
||||
if err == nil {
|
||||
index = ind
|
||||
}
|
||||
}
|
||||
if index == -1 && play { // if file index not set and play file exec
|
||||
c.AbortWithError(http.StatusBadRequest, errors.New("\"index\" is empty or wrong"))
|
||||
return
|
||||
}
|
||||
// preload torrent
|
||||
if preload {
|
||||
torr.Preload(tor, index)
|
||||
}
|
||||
// return stat if query
|
||||
if stat {
|
||||
c.JSON(200, tor.Status())
|
||||
return
|
||||
} else
|
||||
// return m3u if query
|
||||
if m3u {
|
||||
name := strings.ReplaceAll(c.Param("fname"), `/`, "") // strip starting / from param
|
||||
if name == "" {
|
||||
name = tor.Name() + ".m3u"
|
||||
} else if !strings.HasSuffix(strings.ToLower(name), ".m3u") && !strings.HasSuffix(strings.ToLower(name), ".m3u8") {
|
||||
name += ".m3u"
|
||||
}
|
||||
m3ulist := "#EXTM3U\n" + getM3uList(tor.Status(), utils2.GetScheme(c)+"://"+utils2.GetHost(c), fromlast)
|
||||
sendM3U(c, name, tor.Hash().HexString(), m3ulist)
|
||||
return
|
||||
} else
|
||||
// return play if query
|
||||
if play {
|
||||
tor.Stream(index, c.Request, c.Writer)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func streamNoAuth(c *gin.Context) {
|
||||
link := c.Query("link")
|
||||
indexStr := c.Query("index")
|
||||
_, preload := c.GetQuery("preload")
|
||||
_, m3u := c.GetQuery("m3u")
|
||||
_, fromlast := c.GetQuery("fromlast")
|
||||
_, play := c.GetQuery("play")
|
||||
title := c.Query("title")
|
||||
poster := c.Query("poster")
|
||||
category := c.Query("category")
|
||||
|
||||
if link == "" {
|
||||
c.AbortWithError(http.StatusBadRequest, errors.New("link should not be empty"))
|
||||
return
|
||||
}
|
||||
|
||||
link, _ = url.QueryUnescape(link)
|
||||
title, _ = url.QueryUnescape(title)
|
||||
poster, _ = url.QueryUnescape(poster)
|
||||
category, _ = url.QueryUnescape(category)
|
||||
|
||||
var spec *torrent.TorrentSpec
|
||||
var torrsHash *torrshash.TorrsHash
|
||||
var err error
|
||||
|
||||
if strings.HasPrefix(link, "torrs://") || (len(link) > 45 && torrshash.IsBase62(link)) {
|
||||
spec, torrsHash, err = utils.ParseTorrsHash(link)
|
||||
if err != nil {
|
||||
log.TLogln("error parse torrshash:", err)
|
||||
c.AbortWithError(http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
if title == "" {
|
||||
title = torrsHash.Title()
|
||||
}
|
||||
if poster == "" {
|
||||
poster = torrsHash.Poster()
|
||||
}
|
||||
if category == "" {
|
||||
category = torrsHash.Category()
|
||||
}
|
||||
} else {
|
||||
spec, err = utils.ParseLink(link)
|
||||
if err != nil {
|
||||
c.AbortWithError(http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
tor := torr.GetTorrent(spec.InfoHash.HexString())
|
||||
if tor == nil {
|
||||
c.Header("WWW-Authenticate", "Basic realm=Authorization Required")
|
||||
c.AbortWithStatus(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if title == "" {
|
||||
title = tor.Title
|
||||
}
|
||||
|
||||
if poster == "" {
|
||||
poster = tor.Poster
|
||||
}
|
||||
|
||||
if category == "" {
|
||||
category = tor.Category
|
||||
}
|
||||
|
||||
data := tor.Data
|
||||
|
||||
if tor.Stat == state.TorrentInDB {
|
||||
tor, err = torr.AddTorrent(spec, title, poster, data, category)
|
||||
if err != nil {
|
||||
c.AbortWithError(http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if !tor.GotInfo() {
|
||||
c.AbortWithError(http.StatusInternalServerError, errors.New("torrent connection timeout"))
|
||||
return
|
||||
}
|
||||
|
||||
// find file
|
||||
index := -1
|
||||
if len(tor.Files()) == 1 {
|
||||
index = 1
|
||||
} else {
|
||||
ind, err := strconv.Atoi(indexStr)
|
||||
if err == nil {
|
||||
index = ind
|
||||
}
|
||||
}
|
||||
if index == -1 && play { // if file index not set and play file exec
|
||||
c.AbortWithError(http.StatusBadRequest, errors.New("\"index\" is empty or wrong"))
|
||||
return
|
||||
}
|
||||
// preload torrent
|
||||
if preload {
|
||||
torr.Preload(tor, index)
|
||||
}
|
||||
// return m3u if query
|
||||
if m3u {
|
||||
name := strings.ReplaceAll(c.Param("fname"), `/`, "") // strip starting / from param
|
||||
if name == "" {
|
||||
name = tor.Name() + ".m3u"
|
||||
} else if !strings.HasSuffix(strings.ToLower(name), ".m3u") && !strings.HasSuffix(strings.ToLower(name), ".m3u8") {
|
||||
name += ".m3u"
|
||||
}
|
||||
m3ulist := "#EXTM3U\n" + getM3uList(tor.Status(), utils2.GetScheme(c)+"://"+utils2.GetHost(c), fromlast)
|
||||
sendM3U(c, name, tor.Hash().HexString(), m3ulist)
|
||||
return
|
||||
} else
|
||||
// return play if query
|
||||
if play {
|
||||
tor.Stream(index, c.Request, c.Writer)
|
||||
return
|
||||
}
|
||||
c.Header("WWW-Authenticate", "Basic realm=Authorization Required")
|
||||
c.AbortWithStatus(http.StatusUnauthorized)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
sets "server/settings"
|
||||
)
|
||||
|
||||
// tmdbSettings godoc
|
||||
//
|
||||
// @Summary Get TMDB settings
|
||||
// @Description Get TMDB API configuration
|
||||
//
|
||||
// @Tags API
|
||||
//
|
||||
// @Produce json
|
||||
// @Success 200 {object} sets.TMDBConfig "TMDB settings"
|
||||
// @Router /tmdb/settings [get]
|
||||
func tmdbSettings(c *gin.Context) {
|
||||
if sets.BTsets == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Settings not initialized"})
|
||||
return
|
||||
}
|
||||
c.JSON(200, sets.BTsets.TMDBSettings)
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"server/torrshash"
|
||||
"strings"
|
||||
|
||||
"server/dlna"
|
||||
"server/log"
|
||||
set "server/settings"
|
||||
"server/torr"
|
||||
"server/torr/state"
|
||||
"server/web/api/utils"
|
||||
|
||||
"github.com/anacrolix/torrent"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
// Action: add, get, set, rem, list, drop
|
||||
type torrReqJS struct {
|
||||
requestI
|
||||
Link string `json:"link,omitempty"`
|
||||
Hash string `json:"hash,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Category string `json:"category,omitempty"`
|
||||
Poster string `json:"poster,omitempty"`
|
||||
Data string `json:"data,omitempty"`
|
||||
SaveToDB bool `json:"save_to_db,omitempty"`
|
||||
}
|
||||
|
||||
// torrents godoc
|
||||
//
|
||||
// @Summary Handle torrents informations
|
||||
// @Description Allow to list, add, remove, get, set, drop, wipe torrents on server. The action depends of what has been asked.
|
||||
//
|
||||
// @Tags API
|
||||
//
|
||||
// @Param request body torrReqJS true "Torrent request. Available params for action: add, get, set, rem, list, drop, wipe. link required for add, hash required for get, set, rem, drop."
|
||||
//
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200
|
||||
// @Router /torrents [post]
|
||||
func torrents(c *gin.Context) {
|
||||
var req torrReqJS
|
||||
err := c.ShouldBindJSON(&req)
|
||||
if err != nil {
|
||||
c.AbortWithError(http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusBadRequest)
|
||||
switch req.Action {
|
||||
case "add":
|
||||
{
|
||||
addTorrent(req, c)
|
||||
}
|
||||
case "get":
|
||||
{
|
||||
getTorrent(req, c)
|
||||
}
|
||||
case "set":
|
||||
{
|
||||
setTorrent(req, c)
|
||||
}
|
||||
case "rem":
|
||||
{
|
||||
remTorrent(req, c)
|
||||
}
|
||||
case "list":
|
||||
{
|
||||
listTorrents(c)
|
||||
}
|
||||
case "drop":
|
||||
{
|
||||
dropTorrent(req, c)
|
||||
}
|
||||
case "wipe":
|
||||
{
|
||||
wipeTorrents(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func addTorrent(req torrReqJS, c *gin.Context) {
|
||||
if req.Link == "" {
|
||||
c.AbortWithError(http.StatusBadRequest, errors.New("link is empty"))
|
||||
return
|
||||
}
|
||||
|
||||
log.TLogln("add torrent", req.Link)
|
||||
req.Link = strings.ReplaceAll(req.Link, "&", "&")
|
||||
|
||||
var torrSpec *torrent.TorrentSpec
|
||||
var torrsHash *torrshash.TorrsHash
|
||||
var err error
|
||||
|
||||
if strings.HasPrefix(req.Link, "torrs://") {
|
||||
torrSpec, torrsHash, err = utils.ParseTorrsHash(req.Link)
|
||||
if err != nil {
|
||||
log.TLogln("error parse torrshash:", err)
|
||||
c.AbortWithError(http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
if req.Title == "" {
|
||||
req.Title = torrsHash.Title()
|
||||
}
|
||||
if req.Poster == "" {
|
||||
req.Poster = torrsHash.Poster()
|
||||
}
|
||||
if req.Category == "" {
|
||||
req.Category = torrsHash.Category()
|
||||
}
|
||||
} else {
|
||||
torrSpec, err = utils.ParseLink(req.Link)
|
||||
if err != nil {
|
||||
log.TLogln("error parse link:", err)
|
||||
c.AbortWithError(http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
tor, err := torr.AddTorrent(torrSpec, req.Title, req.Poster, req.Data, req.Category)
|
||||
if err != nil {
|
||||
log.TLogln("error add torrent:", err)
|
||||
c.AbortWithError(http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
go func() {
|
||||
if !tor.GotInfo() {
|
||||
log.TLogln("error add torrent:", "timeout connection get torrent info")
|
||||
return
|
||||
}
|
||||
|
||||
if tor.Title == "" {
|
||||
tor.Title = torrSpec.DisplayName // prefer dn over name
|
||||
tor.Title = strings.ReplaceAll(tor.Title, "rutor.info", "")
|
||||
tor.Title = strings.ReplaceAll(tor.Title, "_", " ")
|
||||
tor.Title = strings.Trim(tor.Title, " ")
|
||||
if tor.Title == "" {
|
||||
tor.Title = tor.Name()
|
||||
}
|
||||
}
|
||||
|
||||
if req.SaveToDB {
|
||||
torr.SaveTorrentToDB(tor)
|
||||
}
|
||||
}()
|
||||
|
||||
if set.BTsets.EnableDLNA {
|
||||
dlna.Stop()
|
||||
dlna.Start()
|
||||
}
|
||||
c.JSON(200, tor.Status())
|
||||
}
|
||||
|
||||
func getTorrent(req torrReqJS, c *gin.Context) {
|
||||
if req.Hash == "" {
|
||||
c.AbortWithError(http.StatusBadRequest, errors.New("hash is empty"))
|
||||
return
|
||||
}
|
||||
tor := torr.GetTorrent(req.Hash)
|
||||
|
||||
if tor != nil {
|
||||
st := tor.Status()
|
||||
c.JSON(200, st)
|
||||
} else {
|
||||
c.Status(http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
func setTorrent(req torrReqJS, c *gin.Context) {
|
||||
if req.Hash == "" {
|
||||
c.AbortWithError(http.StatusBadRequest, errors.New("hash is empty"))
|
||||
return
|
||||
}
|
||||
torr.SetTorrent(req.Hash, req.Title, req.Poster, req.Category, req.Data)
|
||||
c.Status(200)
|
||||
}
|
||||
|
||||
func remTorrent(req torrReqJS, c *gin.Context) {
|
||||
if req.Hash == "" {
|
||||
c.AbortWithError(http.StatusBadRequest, errors.New("hash is empty"))
|
||||
return
|
||||
}
|
||||
torr.RemTorrent(req.Hash)
|
||||
// TODO: remove
|
||||
if set.BTsets.EnableDLNA {
|
||||
dlna.Stop()
|
||||
dlna.Start()
|
||||
}
|
||||
c.Status(200)
|
||||
}
|
||||
|
||||
func listTorrents(c *gin.Context) {
|
||||
list := torr.ListTorrent()
|
||||
if len(list) == 0 {
|
||||
c.JSON(200, []*state.TorrentStatus{})
|
||||
return
|
||||
}
|
||||
var stats []*state.TorrentStatus
|
||||
for _, tr := range list {
|
||||
stats = append(stats, tr.Status())
|
||||
}
|
||||
c.JSON(200, stats)
|
||||
}
|
||||
|
||||
func dropTorrent(req torrReqJS, c *gin.Context) {
|
||||
if req.Hash == "" {
|
||||
c.AbortWithError(http.StatusBadRequest, errors.New("hash is empty"))
|
||||
return
|
||||
}
|
||||
torr.DropTorrent(req.Hash)
|
||||
c.Status(200)
|
||||
}
|
||||
|
||||
func wipeTorrents(c *gin.Context) {
|
||||
torrents := torr.ListTorrent()
|
||||
for _, t := range torrents {
|
||||
torr.RemTorrent(t.TorrentSpec.InfoHash.HexString())
|
||||
}
|
||||
// TODO: remove (copied todo from remTorrent())
|
||||
if set.BTsets.EnableDLNA {
|
||||
dlna.Stop()
|
||||
dlna.Start()
|
||||
}
|
||||
c.Status(200)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"server/rutor/models"
|
||||
sets "server/settings"
|
||||
"server/torznab"
|
||||
)
|
||||
|
||||
// torznabSearch godoc
|
||||
//
|
||||
// @Summary Makes a torznab search
|
||||
// @Description Makes a torznab search.
|
||||
//
|
||||
// @Tags API
|
||||
//
|
||||
// @Param query query string true "Torznab query"
|
||||
//
|
||||
// @Produce json
|
||||
// @Success 200 {array} models.TorrentDetails "Torznab torrent search result(s)"
|
||||
// @Router /torznab/search [get]
|
||||
func torznabSearch(c *gin.Context) {
|
||||
if !sets.BTsets.EnableTorznabSearch {
|
||||
c.JSON(http.StatusBadRequest, []string{})
|
||||
return
|
||||
}
|
||||
query := c.Query("query")
|
||||
indexStr := c.DefaultQuery("index", "-1")
|
||||
index := -1
|
||||
if i, err := strconv.Atoi(indexStr); err == nil {
|
||||
index = i
|
||||
}
|
||||
|
||||
query, _ = url.QueryUnescape(query)
|
||||
list := torznab.Search(query, index)
|
||||
if list == nil {
|
||||
list = []*models.TorrentDetails{}
|
||||
}
|
||||
c.JSON(200, list)
|
||||
}
|
||||
|
||||
type torznabTestReq struct {
|
||||
Host string `json:"host"`
|
||||
Key string `json:"key"`
|
||||
}
|
||||
|
||||
func torznabTest(c *gin.Context) {
|
||||
var req torznabTestReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.AbortWithError(http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := torznab.Test(req.Host, req.Key); err != nil {
|
||||
c.JSON(200, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"success": true})
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
|
||||
"server/log"
|
||||
set "server/settings"
|
||||
"server/torr"
|
||||
"server/torr/state"
|
||||
"server/web/api/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// torrentUpload godoc
|
||||
//
|
||||
// @Summary Add .torrent files
|
||||
// @Description Supports multiple files. Returns array of statuses.
|
||||
//
|
||||
// @Tags API
|
||||
//
|
||||
// @Param file formData file true "Torrent file(s) to insert"
|
||||
// @Param save formData string false "Save to DB"
|
||||
// @Param title formData string false "Torrent title (single file only)"
|
||||
// @Param category formData string false "Torrent category"
|
||||
// @Param poster formData string false "Torrent poster (single file only)"
|
||||
// @Param data formData string false "Torrent data"
|
||||
//
|
||||
// @Accept multipart/form-data
|
||||
//
|
||||
// @Produce json
|
||||
// @Success 200 {array} state.TorrentStatus "Torrent statuses"
|
||||
// @Router /torrent/upload [post]
|
||||
func torrentUpload(c *gin.Context) {
|
||||
form, err := c.MultipartForm()
|
||||
if err != nil {
|
||||
c.AbortWithError(http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
defer form.RemoveAll()
|
||||
|
||||
save := len(form.Value["save"]) > 0
|
||||
title := ""
|
||||
if len(form.Value["title"]) > 0 {
|
||||
title = form.Value["title"][0]
|
||||
}
|
||||
category := ""
|
||||
if len(form.Value["category"]) > 0 {
|
||||
category = form.Value["category"][0]
|
||||
}
|
||||
poster := ""
|
||||
if len(form.Value["poster"]) > 0 {
|
||||
poster = form.Value["poster"][0]
|
||||
}
|
||||
data := ""
|
||||
if len(form.Value["data"]) > 0 {
|
||||
data = form.Value["data"][0]
|
||||
}
|
||||
|
||||
var files []*multipart.FileHeader
|
||||
for _, fh := range form.File {
|
||||
files = append(files, fh...)
|
||||
}
|
||||
|
||||
var stats []*state.TorrentStatus
|
||||
for _, fh := range files {
|
||||
log.TLogln("add .torrent", fh.Filename)
|
||||
|
||||
torrFile, err := fh.Open()
|
||||
if err != nil {
|
||||
log.TLogln("error upload torrent:", err)
|
||||
continue
|
||||
}
|
||||
|
||||
spec, err := utils.ParseFile(torrFile)
|
||||
torrFile.Close()
|
||||
if err != nil {
|
||||
log.TLogln("error upload torrent:", err)
|
||||
continue
|
||||
}
|
||||
|
||||
tor, err := torr.AddTorrent(spec, title, poster, data, category)
|
||||
if err != nil {
|
||||
log.TLogln("error upload torrent:", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if tor.Data != "" && set.BTsets.EnableDebug {
|
||||
log.TLogln("torrent data:", tor.Data)
|
||||
}
|
||||
if tor.Category != "" && set.BTsets.EnableDebug {
|
||||
log.TLogln("torrent category:", tor.Category)
|
||||
}
|
||||
|
||||
go func(t *torr.Torrent) {
|
||||
if !t.GotInfo() {
|
||||
log.TLogln("error add torrent:", "torrent connection timeout")
|
||||
return
|
||||
}
|
||||
|
||||
if t.Title == "" {
|
||||
t.Title = t.Name()
|
||||
}
|
||||
|
||||
if save {
|
||||
torr.SaveTorrentToDB(t)
|
||||
}
|
||||
}(tor)
|
||||
|
||||
stats = append(stats, tor.Status())
|
||||
}
|
||||
c.JSON(200, stats)
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"runtime"
|
||||
"server/torrshash"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/anacrolix/torrent"
|
||||
"github.com/anacrolix/torrent/metainfo"
|
||||
)
|
||||
|
||||
func ParseFromBytes(data []byte) (*torrent.TorrentSpec, error) {
|
||||
minfo, err := metainfo.Load(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
info, err := minfo.UnmarshalInfo()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mag := minfo.Magnet(nil, &info)
|
||||
return &torrent.TorrentSpec{
|
||||
InfoBytes: minfo.InfoBytes,
|
||||
Trackers: [][]string{mag.Trackers},
|
||||
DisplayName: info.Name,
|
||||
InfoHash: minfo.HashInfoBytes(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func ParseFile(file multipart.File) (*torrent.TorrentSpec, error) {
|
||||
minfo, err := metainfo.Load(file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
info, err := minfo.UnmarshalInfo()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// mag := minfo.Magnet(info.Name, minfo.HashInfoBytes())
|
||||
mag := minfo.Magnet(nil, &info)
|
||||
return &torrent.TorrentSpec{
|
||||
InfoBytes: minfo.InfoBytes,
|
||||
Trackers: [][]string{mag.Trackers},
|
||||
DisplayName: info.Name,
|
||||
InfoHash: minfo.HashInfoBytes(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func ParseLink(link string) (*torrent.TorrentSpec, error) {
|
||||
urlLink, err := url.Parse(link)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
switch strings.ToLower(urlLink.Scheme) {
|
||||
case "magnet":
|
||||
return fromMagnet(urlLink.String())
|
||||
case "http", "https":
|
||||
return fromHttp(urlLink.String())
|
||||
case "":
|
||||
return fromMagnet("magnet:?xt=urn:btih:" + urlLink.Path)
|
||||
case "file":
|
||||
return fromFile(urlLink.Path)
|
||||
default:
|
||||
err = fmt.Errorf("unknown scheme:", urlLink, urlLink.Scheme)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
func fromMagnet(link string) (*torrent.TorrentSpec, error) {
|
||||
mag, err := metainfo.ParseMagnetUri(link)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var trackers [][]string
|
||||
if len(mag.Trackers) > 0 {
|
||||
trackers = [][]string{mag.Trackers}
|
||||
}
|
||||
|
||||
return &torrent.TorrentSpec{
|
||||
InfoBytes: nil,
|
||||
Trackers: trackers,
|
||||
DisplayName: mag.DisplayName,
|
||||
InfoHash: mag.InfoHash,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func ParseTorrsHash(token string) (*torrent.TorrentSpec, *torrshash.TorrsHash, error) {
|
||||
if strings.HasPrefix(token, "torrs://") {
|
||||
token = strings.TrimPrefix(token, "torrs://")
|
||||
}
|
||||
th, err := torrshash.Unpack(token)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
var trackers [][]string
|
||||
if len(th.Trackers()) > 0 {
|
||||
trackers = [][]string{th.Trackers()}
|
||||
}
|
||||
|
||||
return &torrent.TorrentSpec{
|
||||
InfoBytes: nil,
|
||||
Trackers: trackers,
|
||||
DisplayName: th.Title(),
|
||||
InfoHash: metainfo.NewHashFromHex(th.Hash),
|
||||
}, th, nil
|
||||
}
|
||||
|
||||
func fromHttp(link string) (*torrent.TorrentSpec, error) {
|
||||
req, err := http.NewRequest("GET", link, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client := new(http.Client)
|
||||
client.Timeout = time.Duration(time.Second * 60)
|
||||
req.Header.Set("User-Agent", "DWL/1.1.1 (Torrent)")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if er, ok := err.(*url.Error); ok {
|
||||
if strings.HasPrefix(er.URL, "magnet:") {
|
||||
return fromMagnet(er.URL)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, errors.New(resp.Status)
|
||||
}
|
||||
|
||||
minfo, err := metainfo.Load(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
info, err := minfo.UnmarshalInfo()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// mag := minfo.Magnet(info.Name, minfo.HashInfoBytes())
|
||||
mag := minfo.Magnet(nil, &info)
|
||||
|
||||
return &torrent.TorrentSpec{
|
||||
InfoBytes: minfo.InfoBytes,
|
||||
Trackers: [][]string{mag.Trackers},
|
||||
DisplayName: info.Name,
|
||||
InfoHash: minfo.HashInfoBytes(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func fromFile(path string) (*torrent.TorrentSpec, error) {
|
||||
if runtime.GOOS == "windows" && strings.HasPrefix(path, "/") {
|
||||
path = strings.TrimPrefix(path, "/")
|
||||
}
|
||||
minfo, err := metainfo.LoadFromFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
info, err := minfo.UnmarshalInfo()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// mag := minfo.Magnet(info.Name, minfo.HashInfoBytes())
|
||||
mag := minfo.Magnet(nil, &info)
|
||||
return &torrent.TorrentSpec{
|
||||
InfoBytes: minfo.InfoBytes,
|
||||
Trackers: [][]string{mag.Trackers},
|
||||
DisplayName: info.Name,
|
||||
InfoHash: minfo.HashInfoBytes(),
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
func TestLink(link string, auth bool) error {
|
||||
link, err := url.QueryUnescape(link)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ur, err := url.Parse(link)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if ur.Scheme == "magnet" || ur.Scheme == "torrs" || ur.Scheme == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if !auth {
|
||||
return fmt.Errorf("auth required")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
sets "server/settings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
/*
|
||||
file index starts from 1
|
||||
*/
|
||||
|
||||
// Action: set, rem, list
|
||||
type viewedReqJS struct {
|
||||
requestI
|
||||
*sets.Viewed
|
||||
}
|
||||
|
||||
// viewed godoc
|
||||
//
|
||||
// @Summary Set / List / Remove viewed torrents
|
||||
// @Description Allow to set, list or remove viewed torrents from server.
|
||||
//
|
||||
// @Tags API
|
||||
//
|
||||
// @Param request body viewedReqJS true "Viewed torrent request. Available params for action: set, rem, list"
|
||||
//
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {array} sets.Viewed
|
||||
// @Router /viewed [post]
|
||||
func viewed(c *gin.Context) {
|
||||
var req viewedReqJS
|
||||
err := c.ShouldBindJSON(&req)
|
||||
if err != nil {
|
||||
c.AbortWithError(http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
switch req.Action {
|
||||
case "set":
|
||||
{
|
||||
setViewed(req, c)
|
||||
}
|
||||
case "rem":
|
||||
{
|
||||
remViewed(req, c)
|
||||
}
|
||||
case "list":
|
||||
{
|
||||
listViewed(req, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func setViewed(req viewedReqJS, c *gin.Context) {
|
||||
sets.SetViewed(req.Viewed)
|
||||
c.Status(200)
|
||||
}
|
||||
|
||||
func remViewed(req viewedReqJS, c *gin.Context) {
|
||||
sets.RemViewed(req.Viewed)
|
||||
c.Status(200)
|
||||
}
|
||||
|
||||
func listViewed(req viewedReqJS, c *gin.Context) {
|
||||
list := sets.ListViewed(req.Hash)
|
||||
c.JSON(200, list)
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"unsafe"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"server/log"
|
||||
"server/settings"
|
||||
)
|
||||
|
||||
func SetupAuth(engine *gin.Engine) {
|
||||
if !settings.HttpAuth {
|
||||
return
|
||||
}
|
||||
accs := getAccounts()
|
||||
if accs == nil {
|
||||
return
|
||||
}
|
||||
engine.Use(BasicAuth(accs))
|
||||
}
|
||||
|
||||
func getAccounts() gin.Accounts {
|
||||
buf, err := os.ReadFile(filepath.Join(settings.Path, "accs.db"))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var accs gin.Accounts
|
||||
err = json.Unmarshal(buf, &accs)
|
||||
if err != nil {
|
||||
log.TLogln("Error parse accs.db", err)
|
||||
}
|
||||
return accs
|
||||
}
|
||||
|
||||
type authPair struct {
|
||||
value string
|
||||
user string
|
||||
}
|
||||
type authPairs []authPair
|
||||
|
||||
func (a authPairs) searchCredential(authValue string) (string, bool) {
|
||||
if authValue == "" {
|
||||
return "", false
|
||||
}
|
||||
for _, pair := range a {
|
||||
if pair.value == authValue {
|
||||
return pair.user, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func BasicAuth(accounts gin.Accounts) gin.HandlerFunc {
|
||||
pairs := processAccounts(accounts)
|
||||
return func(c *gin.Context) {
|
||||
c.Set("auth_required", true)
|
||||
|
||||
user, found := pairs.searchCredential(c.Request.Header.Get("Authorization"))
|
||||
if found {
|
||||
c.Set(gin.AuthUserKey, user)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func CheckAuth() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if !settings.HttpAuth {
|
||||
return
|
||||
}
|
||||
|
||||
if _, ok := c.Get(gin.AuthUserKey); ok {
|
||||
return
|
||||
}
|
||||
|
||||
c.Header("WWW-Authenticate", "Basic realm=Authorization Required")
|
||||
c.AbortWithStatus(http.StatusUnauthorized)
|
||||
}
|
||||
}
|
||||
|
||||
func processAccounts(accounts gin.Accounts) authPairs {
|
||||
pairs := make(authPairs, 0, len(accounts))
|
||||
for user, password := range accounts {
|
||||
value := authorizationHeader(user, password)
|
||||
pairs = append(pairs, authPair{
|
||||
value: value,
|
||||
user: user,
|
||||
})
|
||||
}
|
||||
return pairs
|
||||
}
|
||||
|
||||
func authorizationHeader(user, password string) string {
|
||||
base := user + ":" + password
|
||||
return "Basic " + base64.StdEncoding.EncodeToString(StringToBytes(base))
|
||||
}
|
||||
|
||||
func StringToBytes(s string) (b []byte) {
|
||||
return unsafe.Slice(unsafe.StringData(s), len(s))
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package blocker
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"server/log"
|
||||
"server/settings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func Blocker() gin.HandlerFunc {
|
||||
emptyFN := func(c *gin.Context) {
|
||||
c.Next()
|
||||
}
|
||||
|
||||
name := filepath.Join(settings.Path, "bip.txt")
|
||||
buf, _ := os.ReadFile(name)
|
||||
blackIpList := scanBuf(buf)
|
||||
|
||||
name = filepath.Join(settings.Path, "wip.txt")
|
||||
buf, _ = os.ReadFile(name)
|
||||
whiteIpList := scanBuf(buf)
|
||||
|
||||
if blackIpList.NumRanges() == 0 && whiteIpList.NumRanges() == 0 {
|
||||
return emptyFN
|
||||
}
|
||||
|
||||
return func(c *gin.Context) {
|
||||
arr := strings.Split(c.Request.RemoteAddr, ":")
|
||||
if len(arr) > 0 {
|
||||
ip := net.ParseIP(arr[0])
|
||||
minifyIP(&ip)
|
||||
if whiteIpList.NumRanges() > 0 {
|
||||
if _, ok := whiteIpList.Lookup(ip); !ok {
|
||||
log.WebLogln("Block ip, not in white list", ip.String())
|
||||
c.String(http.StatusTeapot, "Banned")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
}
|
||||
if blackIpList.NumRanges() > 0 {
|
||||
if r, ok := blackIpList.Lookup(ip); ok {
|
||||
log.WebLogln("Block ip, in black list:", ip.String(), "in range", r.Description, ":", r.First, "-", r.Last)
|
||||
c.String(http.StatusTeapot, "Banned")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func scanBuf(buf []byte) Ranger {
|
||||
if len(buf) == 0 {
|
||||
return New(nil)
|
||||
}
|
||||
var ranges []Range
|
||||
scanner := bufio.NewScanner(strings.NewReader(string(buf)))
|
||||
for scanner.Scan() {
|
||||
r, ok, err := parseLine(scanner.Bytes())
|
||||
if err != nil {
|
||||
log.TLogln("Error scan ip list:", err)
|
||||
return New(nil)
|
||||
}
|
||||
if ok {
|
||||
ranges = append(ranges, r)
|
||||
}
|
||||
}
|
||||
err := scanner.Err()
|
||||
if err != nil {
|
||||
log.TLogln("Error scan ip list:", err)
|
||||
}
|
||||
if len(ranges) > 0 {
|
||||
return New(ranges)
|
||||
}
|
||||
return New(nil)
|
||||
}
|
||||
|
||||
func parseLine(l []byte) (r Range, ok bool, err error) {
|
||||
l = bytes.TrimSpace(l)
|
||||
if len(l) == 0 || bytes.HasPrefix(l, []byte("#")) {
|
||||
return
|
||||
}
|
||||
colon := bytes.LastIndexAny(l, ":")
|
||||
hyphen := bytes.IndexByte(l[colon+1:], '-')
|
||||
hyphen += colon + 1
|
||||
if colon >= 0 {
|
||||
r.Description = string(l[:colon])
|
||||
}
|
||||
if hyphen-(colon+1) >= 0 {
|
||||
r.First = net.ParseIP(string(l[colon+1 : hyphen]))
|
||||
minifyIP(&r.First)
|
||||
r.Last = net.ParseIP(string(l[hyphen+1:]))
|
||||
minifyIP(&r.Last)
|
||||
} else {
|
||||
r.First = net.ParseIP(string(l[colon+1:]))
|
||||
minifyIP(&r.First)
|
||||
r.Last = r.First
|
||||
}
|
||||
if r.First == nil || r.Last == nil || len(r.First) != len(r.Last) {
|
||||
err = errors.New("bad IP range")
|
||||
return
|
||||
}
|
||||
ok = true
|
||||
return
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package blocker
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net"
|
||||
)
|
||||
|
||||
type Ranger interface {
|
||||
Lookup(net.IP) (r Range, ok bool)
|
||||
NumRanges() int
|
||||
}
|
||||
|
||||
type IPList struct {
|
||||
ranges []Range
|
||||
}
|
||||
|
||||
type Range struct {
|
||||
First, Last net.IP
|
||||
Description string
|
||||
}
|
||||
|
||||
func (r Range) String() string {
|
||||
return fmt.Sprintf("%s-%s: %s", r.First, r.Last, r.Description)
|
||||
}
|
||||
|
||||
// Create a new IP list. The given ranges must already sorted by the lower
|
||||
// bound IP in each range. Behaviour is undefined for lists of overlapping
|
||||
// ranges.
|
||||
func New(initSorted []Range) *IPList {
|
||||
return &IPList{
|
||||
ranges: initSorted,
|
||||
}
|
||||
}
|
||||
|
||||
func (ipl *IPList) NumRanges() int {
|
||||
if ipl == nil {
|
||||
return 0
|
||||
}
|
||||
return len(ipl.ranges)
|
||||
}
|
||||
|
||||
// Return the range the given IP is in. ok if false if no range is found.
|
||||
func (ipl *IPList) Lookup(ip net.IP) (r Range, ok bool) {
|
||||
if ipl == nil {
|
||||
return
|
||||
}
|
||||
v4 := ip.To4()
|
||||
if v4 != nil {
|
||||
r, ok = ipl.lookup(v4)
|
||||
if ok {
|
||||
return
|
||||
}
|
||||
}
|
||||
v6 := ip.To16()
|
||||
if v6 != nil {
|
||||
return ipl.lookup(v6)
|
||||
}
|
||||
if v4 == nil && v6 == nil {
|
||||
r = Range{
|
||||
Description: "bad IP",
|
||||
}
|
||||
ok = true
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Return the range the given IP is in. Returns nil if no range is found.
|
||||
func (ipl *IPList) lookup(ip net.IP) (Range, bool) {
|
||||
var rng Range
|
||||
ok := false
|
||||
for _, r := range ipl.ranges {
|
||||
ok = bytes.Compare(r.First, ip) <= 0 && bytes.Compare(ip, r.Last) <= 0
|
||||
if ok {
|
||||
rng = r
|
||||
break
|
||||
}
|
||||
}
|
||||
return rng, ok
|
||||
}
|
||||
|
||||
func minifyIP(ip *net.IP) {
|
||||
v4 := ip.To4()
|
||||
if v4 != nil {
|
||||
*ip = append(make([]byte, 0, 4), v4...)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"server/log"
|
||||
"server/settings"
|
||||
)
|
||||
|
||||
func runHTTPRedirectToHTTPS(addr string) error {
|
||||
h := func(w http.ResponseWriter, r *http.Request) {
|
||||
target := buildHTTPSRedirectTarget(r)
|
||||
http.Redirect(w, r, target, http.StatusTemporaryRedirect)
|
||||
}
|
||||
log.TLogln("Start http server (redirect to https) at", addr)
|
||||
return http.ListenAndServe(addr, http.HandlerFunc(h))
|
||||
}
|
||||
|
||||
func buildHTTPSRedirectTarget(r *http.Request) string {
|
||||
host := r.Host
|
||||
hostName, _, err := net.SplitHostPort(host)
|
||||
if err != nil {
|
||||
hostName = host
|
||||
}
|
||||
sslPort := settings.SslPort
|
||||
if sslPort == "" {
|
||||
sslPort = "8091"
|
||||
}
|
||||
var httpsHost string
|
||||
if sslPort == "443" {
|
||||
httpsHost = hostName
|
||||
} else {
|
||||
httpsHost = net.JoinHostPort(hostName, sslPort)
|
||||
}
|
||||
path := r.URL.EscapedPath()
|
||||
if path == "" {
|
||||
path = "/"
|
||||
}
|
||||
u := &url.URL{
|
||||
Scheme: "https",
|
||||
Host: httpsHost,
|
||||
Path: path,
|
||||
RawQuery: r.URL.RawQuery,
|
||||
}
|
||||
return u.String()
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package msx
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"server/settings"
|
||||
"server/torr"
|
||||
"server/utils"
|
||||
"server/version"
|
||||
"server/web/auth"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const base, files = "tsmsx.yourok.ru", "media"
|
||||
|
||||
var param = "menu:request:interaction:{SERVER}@{PREFIX}" + base + "/start.html"
|
||||
|
||||
func trn(h string) (st, sc string) {
|
||||
if h := torr.GetTorrent(h); h != nil {
|
||||
if h := h.Status(); h != nil && h.Stat < 5 {
|
||||
switch h.Stat {
|
||||
case 4:
|
||||
sc = "msx-red"
|
||||
case 3:
|
||||
sc = "msx-green"
|
||||
default:
|
||||
sc = "msx-yellow"
|
||||
}
|
||||
st = "{ico:north} " + strconv.Itoa(h.ActivePeers) + " / " + strconv.Itoa(h.TotalPeers) + " {ico:south} " + strconv.Itoa(h.ConnectedSeeders)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func rsp(c *gin.Context, r *http.Response, e error) {
|
||||
if e != nil {
|
||||
c.AbortWithError(http.StatusInternalServerError, e)
|
||||
} else {
|
||||
defer r.Body.Close()
|
||||
c.DataFromReader(r.StatusCode, r.ContentLength, r.Header.Get("Content-Type"), r.Body, nil)
|
||||
}
|
||||
}
|
||||
|
||||
func SetupRoute(r gin.IRouter) {
|
||||
authorized := r.Group("/", auth.CheckAuth())
|
||||
// MSX:
|
||||
authorized.GET("/msx/", func(c *gin.Context) {
|
||||
r, e := http.Get("http://" + base)
|
||||
rsp(c, r, e)
|
||||
})
|
||||
authorized.GET("/msx/start.json", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, map[string]any{
|
||||
"name": "TorrServer",
|
||||
"version": version.Version,
|
||||
"parameter": param,
|
||||
"launcher": map[string]any{
|
||||
"type": "start",
|
||||
"image": utils.GetScheme(c) + "://" + c.Request.Host + "/logo.png",
|
||||
},
|
||||
})
|
||||
})
|
||||
authorized.POST("/msx/start.json", func(c *gin.Context) {
|
||||
if e := c.BindJSON(¶m); e != nil {
|
||||
c.AbortWithError(http.StatusBadRequest, e)
|
||||
}
|
||||
})
|
||||
authorized.GET("/msx/trn", func(c *gin.Context) {
|
||||
r := false
|
||||
if h := c.Query("hash"); h != "" {
|
||||
for _, t := range settings.ListTorrent() {
|
||||
if r = (t != nil && t.InfoHash.HexString() == h); r {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, r)
|
||||
})
|
||||
authorized.POST("/msx/trn", func(c *gin.Context) {
|
||||
var r struct {
|
||||
R struct {
|
||||
S int `json:"status"`
|
||||
T string `json:"text"`
|
||||
M string `json:"message,omitempty"`
|
||||
D map[string]any `json:"data,omitempty"`
|
||||
} `json:"response"`
|
||||
}
|
||||
if j := struct{ Data string }{Data: c.Query("hash")}; j.Data != "" {
|
||||
st, sc := trn(j.Data)
|
||||
if sc != "" {
|
||||
sc = "{col:" + sc + "}"
|
||||
}
|
||||
r.R.S, r.R.D = http.StatusOK, map[string]any{"action": "player:label:position:{VALUE}{tb}{tb}" + sc + st}
|
||||
} else if e := c.BindJSON(&j); e != nil {
|
||||
r.R.S, r.R.M = http.StatusBadRequest, e.Error()
|
||||
} else if j.Data == "" {
|
||||
r.R.S, r.R.M = http.StatusBadRequest, "data is not set"
|
||||
} else {
|
||||
st, sc := trn(j.Data[strings.LastIndexByte(j.Data, ':')+1:])
|
||||
r.R.D = map[string]any{"stamp": st, "stampColor": sc}
|
||||
if sc != "" {
|
||||
r.R.D["live"] = map[string]any{
|
||||
"type": "airtime", "duration": 3000, "over": map[string]any{
|
||||
"action": "execute:" + utils.GetScheme(c) + "://" + c.Request.Host + c.Request.URL.Path, "data": j.Data,
|
||||
},
|
||||
}
|
||||
}
|
||||
r.R.S, r.R.D = http.StatusOK, map[string]any{"action": j.Data, "data": r.R.D}
|
||||
}
|
||||
r.R.T = http.StatusText(r.R.S)
|
||||
c.JSON(http.StatusOK, &r)
|
||||
})
|
||||
authorized.Any("/msx/proxy", func(c *gin.Context) {
|
||||
if u := c.Query("url"); u == "" {
|
||||
c.AbortWithStatus(http.StatusBadRequest)
|
||||
} else if q, e := http.NewRequest(c.Request.Method, u, c.Request.Body); e != nil {
|
||||
c.AbortWithError(http.StatusInternalServerError, e)
|
||||
} else {
|
||||
for _, v := range c.QueryArray("header") {
|
||||
if v := strings.SplitN(v, ":", 2); len(v) == 2 {
|
||||
q.Header.Add(v[0], v[1])
|
||||
}
|
||||
}
|
||||
r, e := http.DefaultClient.Do(q)
|
||||
rsp(c, r, e)
|
||||
}
|
||||
})
|
||||
authorized.GET("/msx/imdb/:id", func(c *gin.Context) {
|
||||
i, j := strings.TrimPrefix(c.Param("id"), "/"), false
|
||||
if j = strings.HasSuffix(i, ".json"); !j {
|
||||
i += ".json"
|
||||
}
|
||||
if r, e := http.Get("https://v2.sg.media-imdb.com/suggestion/h/" + i); e != nil || r.StatusCode != http.StatusOK || j {
|
||||
rsp(c, r, e)
|
||||
} else {
|
||||
var j struct {
|
||||
D []struct{ I struct{ ImageUrl string } }
|
||||
}
|
||||
if e = json.NewDecoder(r.Body).Decode(&j); e != nil {
|
||||
c.AbortWithError(http.StatusInternalServerError, e)
|
||||
} else if len(j.D) == 0 || j.D[0].I.ImageUrl == "" {
|
||||
c.Status(http.StatusNotFound)
|
||||
} else {
|
||||
c.Redirect(http.StatusMovedPermanently, j.D[0].I.ImageUrl)
|
||||
}
|
||||
}
|
||||
})
|
||||
// Files:
|
||||
authorized.StaticFS("/files/", gin.Dir(filepath.Join(settings.Path, files), true))
|
||||
authorized.GET("/files", func(c *gin.Context) {
|
||||
if l, e := os.Readlink(filepath.Join(settings.Path, files)); e == nil || os.IsNotExist(e) {
|
||||
c.JSON(http.StatusOK, l)
|
||||
} else {
|
||||
c.JSON(http.StatusInternalServerError, e.Error)
|
||||
}
|
||||
})
|
||||
authorized.POST("/files", func(c *gin.Context) {
|
||||
var l string
|
||||
if e := c.BindJSON(&l); e != nil {
|
||||
c.AbortWithError(http.StatusBadRequest, e)
|
||||
} else if e = os.Remove(filepath.Join(settings.Path, files)); e != nil && !os.IsNotExist(e) {
|
||||
c.AbortWithError(http.StatusInternalServerError, e)
|
||||
} else if l != "" {
|
||||
if f, e := os.Stat(l); e != nil {
|
||||
c.AbortWithError(http.StatusBadRequest, e)
|
||||
} else if !f.IsDir() {
|
||||
c.AbortWithError(http.StatusBadRequest, errors.New(l+" is not a directory"))
|
||||
} else if e = os.Symlink(l, filepath.Join(settings.Path, files)); e != nil {
|
||||
c.AbortWithError(http.StatusInternalServerError, e)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package pages
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"server/proxy"
|
||||
|
||||
"github.com/anacrolix/torrent/metainfo"
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"server/settings"
|
||||
"server/torr"
|
||||
"server/web/auth"
|
||||
"server/web/pages/template"
|
||||
|
||||
"golang.org/x/exp/slices"
|
||||
)
|
||||
|
||||
func SetupRoute(route gin.IRouter) {
|
||||
authorized := route.Group("/", auth.CheckAuth())
|
||||
|
||||
webPagesAuth := route.Group("/", func() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if slices.Contains([]string{"/site.webmanifest"}, c.FullPath()) {
|
||||
return
|
||||
}
|
||||
auth.CheckAuth()(c)
|
||||
}
|
||||
}())
|
||||
|
||||
template.RouteWebPages(webPagesAuth)
|
||||
authorized.GET("/stat", statPage)
|
||||
authorized.GET("/magnets", getTorrents)
|
||||
authorized.Any("/proxy/*url", proxyUrl)
|
||||
}
|
||||
|
||||
// stat godoc
|
||||
//
|
||||
// @Summary TorrServer Statistics
|
||||
// @Description Show server and torrents statistics.
|
||||
//
|
||||
// @Tags Pages
|
||||
//
|
||||
// @Produce text/plain
|
||||
// @Success 200 "TorrServer statistics"
|
||||
// @Router /stat [get]
|
||||
func statPage(c *gin.Context) {
|
||||
torr.WriteStatus(c.Writer)
|
||||
c.Status(200)
|
||||
}
|
||||
|
||||
// getTorrents godoc
|
||||
//
|
||||
// @Summary Get HTML of magnet links
|
||||
// @Description Get HTML of magnet links.
|
||||
//
|
||||
// @Tags Pages
|
||||
//
|
||||
// @Produce text/html
|
||||
// @Success 200 "HTML with Magnet links"
|
||||
// @Router /magnets [get]
|
||||
func getTorrents(c *gin.Context) {
|
||||
list := settings.ListTorrent()
|
||||
http := "<div>"
|
||||
for _, db := range list {
|
||||
ts := db.TorrentSpec
|
||||
mi := metainfo.MetaInfo{
|
||||
AnnounceList: ts.Trackers,
|
||||
}
|
||||
// mag := mi.Magnet(ts.DisplayName, ts.InfoHash)
|
||||
mag := mi.Magnet(&ts.InfoHash, &metainfo.Info{Name: ts.DisplayName})
|
||||
http += "<p><a href='" + mag.String() + "'>magnet:?xt=urn:btih:" + mag.InfoHash.HexString() + "</a></p>"
|
||||
}
|
||||
http += "</div>"
|
||||
c.Data(200, "text/html; charset=utf-8", []byte(http))
|
||||
}
|
||||
|
||||
func proxyUrl(c *gin.Context) {
|
||||
if proxy.P2Proxy != nil {
|
||||
proxy.P2Proxy.GinHandler(c)
|
||||
return
|
||||
}
|
||||
c.AbortWithStatus(http.StatusNotFound)
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package template
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
)
|
||||
|
||||
//go:embed pages/apple-splash-1125-2436.jpg
|
||||
var Applesplash11252436jpg []byte
|
||||
|
||||
//go:embed pages/apple-splash-1136-640.jpg
|
||||
var Applesplash1136640jpg []byte
|
||||
|
||||
//go:embed pages/apple-splash-1170-2532.jpg
|
||||
var Applesplash11702532jpg []byte
|
||||
|
||||
//go:embed pages/apple-splash-1242-2208.jpg
|
||||
var Applesplash12422208jpg []byte
|
||||
|
||||
//go:embed pages/apple-splash-1242-2688.jpg
|
||||
var Applesplash12422688jpg []byte
|
||||
|
||||
//go:embed pages/apple-splash-1284-2778.jpg
|
||||
var Applesplash12842778jpg []byte
|
||||
|
||||
//go:embed pages/apple-splash-1334-750.jpg
|
||||
var Applesplash1334750jpg []byte
|
||||
|
||||
//go:embed pages/apple-splash-1536-2048.jpg
|
||||
var Applesplash15362048jpg []byte
|
||||
|
||||
//go:embed pages/apple-splash-1620-2160.jpg
|
||||
var Applesplash16202160jpg []byte
|
||||
|
||||
//go:embed pages/apple-splash-1668-2224.jpg
|
||||
var Applesplash16682224jpg []byte
|
||||
|
||||
//go:embed pages/apple-splash-1668-2388.jpg
|
||||
var Applesplash16682388jpg []byte
|
||||
|
||||
//go:embed pages/apple-splash-1792-828.jpg
|
||||
var Applesplash1792828jpg []byte
|
||||
|
||||
//go:embed pages/apple-splash-2048-1536.jpg
|
||||
var Applesplash20481536jpg []byte
|
||||
|
||||
//go:embed pages/apple-splash-2048-2732.jpg
|
||||
var Applesplash20482732jpg []byte
|
||||
|
||||
//go:embed pages/apple-splash-2160-1620.jpg
|
||||
var Applesplash21601620jpg []byte
|
||||
|
||||
//go:embed pages/apple-splash-2208-1242.jpg
|
||||
var Applesplash22081242jpg []byte
|
||||
|
||||
//go:embed pages/apple-splash-2224-1668.jpg
|
||||
var Applesplash22241668jpg []byte
|
||||
|
||||
//go:embed pages/apple-splash-2388-1668.jpg
|
||||
var Applesplash23881668jpg []byte
|
||||
|
||||
//go:embed pages/apple-splash-2436-1125.jpg
|
||||
var Applesplash24361125jpg []byte
|
||||
|
||||
//go:embed pages/apple-splash-2532-1170.jpg
|
||||
var Applesplash25321170jpg []byte
|
||||
|
||||
//go:embed pages/apple-splash-2688-1242.jpg
|
||||
var Applesplash26881242jpg []byte
|
||||
|
||||
//go:embed pages/apple-splash-2732-2048.jpg
|
||||
var Applesplash27322048jpg []byte
|
||||
|
||||
//go:embed pages/apple-splash-2778-1284.jpg
|
||||
var Applesplash27781284jpg []byte
|
||||
|
||||
//go:embed pages/apple-splash-640-1136.jpg
|
||||
var Applesplash6401136jpg []byte
|
||||
|
||||
//go:embed pages/apple-splash-750-1334.jpg
|
||||
var Applesplash7501334jpg []byte
|
||||
|
||||
//go:embed pages/apple-splash-828-1792.jpg
|
||||
var Applesplash8281792jpg []byte
|
||||
|
||||
//go:embed pages/asset-manifest.json
|
||||
var Assetmanifestjson []byte
|
||||
|
||||
//go:embed pages/browserconfig.xml
|
||||
var Browserconfigxml []byte
|
||||
|
||||
//go:embed pages/dlnaicon-120.png
|
||||
var Dlnaicon120png []byte
|
||||
|
||||
//go:embed pages/dlnaicon-48.png
|
||||
var Dlnaicon48png []byte
|
||||
|
||||
//go:embed pages/favicon-16x16.png
|
||||
var Favicon16x16png []byte
|
||||
|
||||
//go:embed pages/favicon-32x32.png
|
||||
var Favicon32x32png []byte
|
||||
|
||||
//go:embed pages/favicon.ico
|
||||
var Faviconico []byte
|
||||
|
||||
//go:embed pages/icon.png
|
||||
var Iconpng []byte
|
||||
|
||||
//go:embed pages/index.html
|
||||
var Indexhtml []byte
|
||||
|
||||
//go:embed pages/logo.png
|
||||
var Logopng []byte
|
||||
|
||||
//go:embed pages/lordicon/jkzgajyr.json
|
||||
var Lordiconjkzgajyrjson []byte
|
||||
|
||||
//go:embed pages/lordicon/lord-icon-2.0.2.js
|
||||
var Lordiconlordicon202js []byte
|
||||
|
||||
//go:embed pages/lordicon/wrprwmwt.json
|
||||
var Lordiconwrprwmwtjson []byte
|
||||
|
||||
//go:embed pages/mstile-150x150.png
|
||||
var Mstile150x150png []byte
|
||||
|
||||
//go:embed pages/site.webmanifest
|
||||
var Sitewebmanifest []byte
|
||||
|
||||
//go:embed pages/static/js/2.17225dbd.chunk.js
|
||||
var Staticjs217225dbdchunkjs []byte
|
||||
|
||||
//go:embed pages/static/js/2.17225dbd.chunk.js.LICENSE.txt
|
||||
var Staticjs217225dbdchunkjsLICENSEtxt []byte
|
||||
|
||||
//go:embed pages/static/js/2.17225dbd.chunk.js.map
|
||||
var Staticjs217225dbdchunkjsmap []byte
|
||||
|
||||
//go:embed pages/static/js/main.c7b9a3c5.chunk.js
|
||||
var Staticjsmainc7b9a3c5chunkjs []byte
|
||||
|
||||
//go:embed pages/static/js/main.c7b9a3c5.chunk.js.map
|
||||
var Staticjsmainc7b9a3c5chunkjsmap []byte
|
||||
|
||||
//go:embed pages/static/js/runtime-main.5ed86a79.js
|
||||
var Staticjsruntimemain5ed86a79js []byte
|
||||
|
||||
//go:embed pages/static/js/runtime-main.5ed86a79.js.map
|
||||
var Staticjsruntimemain5ed86a79jsmap []byte
|
||||
|
After Width: | Height: | Size: 386 KiB |
|
After Width: | Height: | Size: 195 KiB |
|
After Width: | Height: | Size: 411 KiB |
|
After Width: | Height: | Size: 403 KiB |
|
After Width: | Height: | Size: 455 KiB |
|
After Width: | Height: | Size: 480 KiB |
|
After Width: | Height: | Size: 252 KiB |
|
After Width: | Height: | Size: 476 KiB |
|
After Width: | Height: | Size: 516 KiB |
|
After Width: | Height: | Size: 547 KiB |
|
After Width: | Height: | Size: 572 KiB |
|
After Width: | Height: | Size: 368 KiB |
|
After Width: | Height: | Size: 535 KiB |
|
After Width: | Height: | Size: 734 KiB |
|
After Width: | Height: | Size: 595 KiB |
|
After Width: | Height: | Size: 537 KiB |
|
After Width: | Height: | Size: 624 KiB |
|
After Width: | Height: | Size: 675 KiB |
|
After Width: | Height: | Size: 596 KiB |
|
After Width: | Height: | Size: 638 KiB |
|
After Width: | Height: | Size: 691 KiB |
|
After Width: | Height: | Size: 848 KiB |
|
After Width: | Height: | Size: 730 KiB |
|
After Width: | Height: | Size: 137 KiB |
|
After Width: | Height: | Size: 178 KiB |
|
After Width: | Height: | Size: 236 KiB |
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"files": {
|
||||
"main.js": "./static/js/main.c7b9a3c5.chunk.js",
|
||||
"main.js.map": "./static/js/main.c7b9a3c5.chunk.js.map",
|
||||
"runtime-main.js": "./static/js/runtime-main.5ed86a79.js",
|
||||
"runtime-main.js.map": "./static/js/runtime-main.5ed86a79.js.map",
|
||||
"static/js/2.17225dbd.chunk.js": "./static/js/2.17225dbd.chunk.js",
|
||||
"static/js/2.17225dbd.chunk.js.map": "./static/js/2.17225dbd.chunk.js.map",
|
||||
"index.html": "./index.html",
|
||||
"static/js/2.17225dbd.chunk.js.LICENSE.txt": "./static/js/2.17225dbd.chunk.js.LICENSE.txt"
|
||||
},
|
||||
"entrypoints": [
|
||||
"static/js/runtime-main.5ed86a79.js",
|
||||
"static/js/2.17225dbd.chunk.js",
|
||||
"static/js/main.c7b9a3c5.chunk.js"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<browserconfig>
|
||||
<msapplication>
|
||||
<tile>
|
||||
<square150x150logo src="/mstile-150x150.png"/>
|
||||
<TileColor>#00a572</TileColor>
|
||||
<TileImage src="/mstile-150x150.png" />
|
||||
</tile>
|
||||
</msapplication>
|
||||
</browserconfig>
|
||||
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 824 B |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 213 KiB |
|
After Width: | Height: | Size: 20 KiB |
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "TorrServer",
|
||||
"short_name": "TorrServer",
|
||||
"icons": [
|
||||
{
|
||||
"src": "icon.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "logo.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
}
|
||||
],
|
||||
"theme_color": "#ffffff",
|
||||
"background_color": "#ffffff",
|
||||
"display": "standalone"
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
object-assign
|
||||
(c) Sindre Sorhus
|
||||
@license MIT
|
||||
*/
|
||||
|
||||
/*!
|
||||
* The buffer module from node.js, for the browser.
|
||||
*
|
||||
* @author Feross Aboukhadijeh <http://feross.org>
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
/*! blob-to-buffer. MIT License. Feross Aboukhadijeh <https://feross.org/opensource> */
|
||||
|
||||
/*! https://mths.be/punycode v1.4.1 by @mathias */
|
||||
|
||||
/*! ieee754. BSD-3-Clause License. Feross Aboukhadijeh <https://feross.org/opensource> */
|
||||
|
||||
/*! magnet-uri. MIT License. WebTorrent LLC <https://webtorrent.io/opensource> */
|
||||
|
||||
/*! parse-torrent. MIT License. WebTorrent LLC <https://webtorrent.io/opensource> */
|
||||
|
||||
/*! queue-microtask. MIT License. Feross Aboukhadijeh <https://feross.org/opensource> */
|
||||
|
||||
/*! safe-buffer. MIT License. Feross Aboukhadijeh <https://feross.org/opensource> */
|
||||
|
||||
/*! simple-concat. MIT License. Feross Aboukhadijeh <https://feross.org/opensource> */
|
||||
|
||||
/*! simple-get. MIT License. Feross Aboukhadijeh <https://feross.org/opensource> */
|
||||
|
||||
/**
|
||||
* A better abstraction over CSS.
|
||||
*
|
||||
* @copyright Oleg Isonen (Slobodskoi) / Isonen 2014-present
|
||||
* @website https://github.com/cssinjs/jss
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
/** @license React v0.20.2
|
||||
* scheduler.production.min.js
|
||||
*
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
/** @license React v16.13.1
|
||||
* react-is.production.min.js
|
||||
*
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
/** @license React v17.0.2
|
||||
* react-dom.production.min.js
|
||||
*
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
/** @license React v17.0.2
|
||||
* react-is.production.min.js
|
||||
*
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
/** @license React v17.0.2
|
||||
* react-jsx-runtime.production.min.js
|
||||
*
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
/** @license React v17.0.2
|
||||
* react.production.min.js
|
||||
*
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
/**!
|
||||
* @fileOverview Kickass library to create and place poppers near their reference elements.
|
||||
* @version 1.16.1-lts
|
||||
* @license
|
||||
* Copyright (c) 2016 Federico Zivolo and contributors
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
@@ -0,0 +1,2 @@
|
||||
!function(e){function r(r){for(var n,l,f=r[0],i=r[1],a=r[2],c=0,s=[];c<f.length;c++)l=f[c],Object.prototype.hasOwnProperty.call(o,l)&&o[l]&&s.push(o[l][0]),o[l]=0;for(n in i)Object.prototype.hasOwnProperty.call(i,n)&&(e[n]=i[n]);for(p&&p(r);s.length;)s.shift()();return u.push.apply(u,a||[]),t()}function t(){for(var e,r=0;r<u.length;r++){for(var t=u[r],n=!0,f=1;f<t.length;f++){var i=t[f];0!==o[i]&&(n=!1)}n&&(u.splice(r--,1),e=l(l.s=t[0]))}return e}var n={},o={1:0},u=[];function l(r){if(n[r])return n[r].exports;var t=n[r]={i:r,l:!1,exports:{}};return e[r].call(t.exports,t,t.exports,l),t.l=!0,t.exports}l.m=e,l.c=n,l.d=function(e,r,t){l.o(e,r)||Object.defineProperty(e,r,{enumerable:!0,get:t})},l.r=function(e){"undefined"!==typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},l.t=function(e,r){if(1&r&&(e=l(e)),8&r)return e;if(4&r&&"object"===typeof e&&e&&e.__esModule)return e;var t=Object.create(null);if(l.r(t),Object.defineProperty(t,"default",{enumerable:!0,value:e}),2&r&&"string"!=typeof e)for(var n in e)l.d(t,n,function(r){return e[r]}.bind(null,n));return t},l.n=function(e){var r=e&&e.__esModule?function(){return e.default}:function(){return e};return l.d(r,"a",r),r},l.o=function(e,r){return Object.prototype.hasOwnProperty.call(e,r)},l.p="./";var f=this.webpackJsonptorrserver_web=this.webpackJsonptorrserver_web||[],i=f.push.bind(f);f.push=r,f=f.slice();for(var a=0;a<f.length;a++)r(f[a]);var p=i;t()}([]);
|
||||
//# sourceMappingURL=runtime-main.5ed86a79.js.map
|
||||
@@ -0,0 +1,352 @@
|
||||
package template
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"fmt"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func RouteWebPages(route gin.IRouter) {
|
||||
route.GET("/", func(c *gin.Context) {
|
||||
etag := fmt.Sprintf("%x", md5.Sum(Indexhtml))
|
||||
c.Header("Cache-Control", "public, max-age=31536000")
|
||||
c.Header("ETag", etag)
|
||||
c.Data(200, "text/html; charset=utf-8", Indexhtml)
|
||||
})
|
||||
|
||||
route.GET("/apple-splash-1125-2436.jpg", func(c *gin.Context) {
|
||||
etag := fmt.Sprintf("%x", md5.Sum(Applesplash11252436jpg))
|
||||
c.Header("Cache-Control", "public, max-age=31536000")
|
||||
c.Header("ETag", etag)
|
||||
c.Data(200, "image/jpeg", Applesplash11252436jpg)
|
||||
})
|
||||
|
||||
route.GET("/apple-splash-1136-640.jpg", func(c *gin.Context) {
|
||||
etag := fmt.Sprintf("%x", md5.Sum(Applesplash1136640jpg))
|
||||
c.Header("Cache-Control", "public, max-age=31536000")
|
||||
c.Header("ETag", etag)
|
||||
c.Data(200, "image/jpeg", Applesplash1136640jpg)
|
||||
})
|
||||
|
||||
route.GET("/apple-splash-1170-2532.jpg", func(c *gin.Context) {
|
||||
etag := fmt.Sprintf("%x", md5.Sum(Applesplash11702532jpg))
|
||||
c.Header("Cache-Control", "public, max-age=31536000")
|
||||
c.Header("ETag", etag)
|
||||
c.Data(200, "image/jpeg", Applesplash11702532jpg)
|
||||
})
|
||||
|
||||
route.GET("/apple-splash-1242-2208.jpg", func(c *gin.Context) {
|
||||
etag := fmt.Sprintf("%x", md5.Sum(Applesplash12422208jpg))
|
||||
c.Header("Cache-Control", "public, max-age=31536000")
|
||||
c.Header("ETag", etag)
|
||||
c.Data(200, "image/jpeg", Applesplash12422208jpg)
|
||||
})
|
||||
|
||||
route.GET("/apple-splash-1242-2688.jpg", func(c *gin.Context) {
|
||||
etag := fmt.Sprintf("%x", md5.Sum(Applesplash12422688jpg))
|
||||
c.Header("Cache-Control", "public, max-age=31536000")
|
||||
c.Header("ETag", etag)
|
||||
c.Data(200, "image/jpeg", Applesplash12422688jpg)
|
||||
})
|
||||
|
||||
route.GET("/apple-splash-1284-2778.jpg", func(c *gin.Context) {
|
||||
etag := fmt.Sprintf("%x", md5.Sum(Applesplash12842778jpg))
|
||||
c.Header("Cache-Control", "public, max-age=31536000")
|
||||
c.Header("ETag", etag)
|
||||
c.Data(200, "image/jpeg", Applesplash12842778jpg)
|
||||
})
|
||||
|
||||
route.GET("/apple-splash-1334-750.jpg", func(c *gin.Context) {
|
||||
etag := fmt.Sprintf("%x", md5.Sum(Applesplash1334750jpg))
|
||||
c.Header("Cache-Control", "public, max-age=31536000")
|
||||
c.Header("ETag", etag)
|
||||
c.Data(200, "image/jpeg", Applesplash1334750jpg)
|
||||
})
|
||||
|
||||
route.GET("/apple-splash-1536-2048.jpg", func(c *gin.Context) {
|
||||
etag := fmt.Sprintf("%x", md5.Sum(Applesplash15362048jpg))
|
||||
c.Header("Cache-Control", "public, max-age=31536000")
|
||||
c.Header("ETag", etag)
|
||||
c.Data(200, "image/jpeg", Applesplash15362048jpg)
|
||||
})
|
||||
|
||||
route.GET("/apple-splash-1620-2160.jpg", func(c *gin.Context) {
|
||||
etag := fmt.Sprintf("%x", md5.Sum(Applesplash16202160jpg))
|
||||
c.Header("Cache-Control", "public, max-age=31536000")
|
||||
c.Header("ETag", etag)
|
||||
c.Data(200, "image/jpeg", Applesplash16202160jpg)
|
||||
})
|
||||
|
||||
route.GET("/apple-splash-1668-2224.jpg", func(c *gin.Context) {
|
||||
etag := fmt.Sprintf("%x", md5.Sum(Applesplash16682224jpg))
|
||||
c.Header("Cache-Control", "public, max-age=31536000")
|
||||
c.Header("ETag", etag)
|
||||
c.Data(200, "image/jpeg", Applesplash16682224jpg)
|
||||
})
|
||||
|
||||
route.GET("/apple-splash-1668-2388.jpg", func(c *gin.Context) {
|
||||
etag := fmt.Sprintf("%x", md5.Sum(Applesplash16682388jpg))
|
||||
c.Header("Cache-Control", "public, max-age=31536000")
|
||||
c.Header("ETag", etag)
|
||||
c.Data(200, "image/jpeg", Applesplash16682388jpg)
|
||||
})
|
||||
|
||||
route.GET("/apple-splash-1792-828.jpg", func(c *gin.Context) {
|
||||
etag := fmt.Sprintf("%x", md5.Sum(Applesplash1792828jpg))
|
||||
c.Header("Cache-Control", "public, max-age=31536000")
|
||||
c.Header("ETag", etag)
|
||||
c.Data(200, "image/jpeg", Applesplash1792828jpg)
|
||||
})
|
||||
|
||||
route.GET("/apple-splash-2048-1536.jpg", func(c *gin.Context) {
|
||||
etag := fmt.Sprintf("%x", md5.Sum(Applesplash20481536jpg))
|
||||
c.Header("Cache-Control", "public, max-age=31536000")
|
||||
c.Header("ETag", etag)
|
||||
c.Data(200, "image/jpeg", Applesplash20481536jpg)
|
||||
})
|
||||
|
||||
route.GET("/apple-splash-2048-2732.jpg", func(c *gin.Context) {
|
||||
etag := fmt.Sprintf("%x", md5.Sum(Applesplash20482732jpg))
|
||||
c.Header("Cache-Control", "public, max-age=31536000")
|
||||
c.Header("ETag", etag)
|
||||
c.Data(200, "image/jpeg", Applesplash20482732jpg)
|
||||
})
|
||||
|
||||
route.GET("/apple-splash-2160-1620.jpg", func(c *gin.Context) {
|
||||
etag := fmt.Sprintf("%x", md5.Sum(Applesplash21601620jpg))
|
||||
c.Header("Cache-Control", "public, max-age=31536000")
|
||||
c.Header("ETag", etag)
|
||||
c.Data(200, "image/jpeg", Applesplash21601620jpg)
|
||||
})
|
||||
|
||||
route.GET("/apple-splash-2208-1242.jpg", func(c *gin.Context) {
|
||||
etag := fmt.Sprintf("%x", md5.Sum(Applesplash22081242jpg))
|
||||
c.Header("Cache-Control", "public, max-age=31536000")
|
||||
c.Header("ETag", etag)
|
||||
c.Data(200, "image/jpeg", Applesplash22081242jpg)
|
||||
})
|
||||
|
||||
route.GET("/apple-splash-2224-1668.jpg", func(c *gin.Context) {
|
||||
etag := fmt.Sprintf("%x", md5.Sum(Applesplash22241668jpg))
|
||||
c.Header("Cache-Control", "public, max-age=31536000")
|
||||
c.Header("ETag", etag)
|
||||
c.Data(200, "image/jpeg", Applesplash22241668jpg)
|
||||
})
|
||||
|
||||
route.GET("/apple-splash-2388-1668.jpg", func(c *gin.Context) {
|
||||
etag := fmt.Sprintf("%x", md5.Sum(Applesplash23881668jpg))
|
||||
c.Header("Cache-Control", "public, max-age=31536000")
|
||||
c.Header("ETag", etag)
|
||||
c.Data(200, "image/jpeg", Applesplash23881668jpg)
|
||||
})
|
||||
|
||||
route.GET("/apple-splash-2436-1125.jpg", func(c *gin.Context) {
|
||||
etag := fmt.Sprintf("%x", md5.Sum(Applesplash24361125jpg))
|
||||
c.Header("Cache-Control", "public, max-age=31536000")
|
||||
c.Header("ETag", etag)
|
||||
c.Data(200, "image/jpeg", Applesplash24361125jpg)
|
||||
})
|
||||
|
||||
route.GET("/apple-splash-2532-1170.jpg", func(c *gin.Context) {
|
||||
etag := fmt.Sprintf("%x", md5.Sum(Applesplash25321170jpg))
|
||||
c.Header("Cache-Control", "public, max-age=31536000")
|
||||
c.Header("ETag", etag)
|
||||
c.Data(200, "image/jpeg", Applesplash25321170jpg)
|
||||
})
|
||||
|
||||
route.GET("/apple-splash-2688-1242.jpg", func(c *gin.Context) {
|
||||
etag := fmt.Sprintf("%x", md5.Sum(Applesplash26881242jpg))
|
||||
c.Header("Cache-Control", "public, max-age=31536000")
|
||||
c.Header("ETag", etag)
|
||||
c.Data(200, "image/jpeg", Applesplash26881242jpg)
|
||||
})
|
||||
|
||||
route.GET("/apple-splash-2732-2048.jpg", func(c *gin.Context) {
|
||||
etag := fmt.Sprintf("%x", md5.Sum(Applesplash27322048jpg))
|
||||
c.Header("Cache-Control", "public, max-age=31536000")
|
||||
c.Header("ETag", etag)
|
||||
c.Data(200, "image/jpeg", Applesplash27322048jpg)
|
||||
})
|
||||
|
||||
route.GET("/apple-splash-2778-1284.jpg", func(c *gin.Context) {
|
||||
etag := fmt.Sprintf("%x", md5.Sum(Applesplash27781284jpg))
|
||||
c.Header("Cache-Control", "public, max-age=31536000")
|
||||
c.Header("ETag", etag)
|
||||
c.Data(200, "image/jpeg", Applesplash27781284jpg)
|
||||
})
|
||||
|
||||
route.GET("/apple-splash-640-1136.jpg", func(c *gin.Context) {
|
||||
etag := fmt.Sprintf("%x", md5.Sum(Applesplash6401136jpg))
|
||||
c.Header("Cache-Control", "public, max-age=31536000")
|
||||
c.Header("ETag", etag)
|
||||
c.Data(200, "image/jpeg", Applesplash6401136jpg)
|
||||
})
|
||||
|
||||
route.GET("/apple-splash-750-1334.jpg", func(c *gin.Context) {
|
||||
etag := fmt.Sprintf("%x", md5.Sum(Applesplash7501334jpg))
|
||||
c.Header("Cache-Control", "public, max-age=31536000")
|
||||
c.Header("ETag", etag)
|
||||
c.Data(200, "image/jpeg", Applesplash7501334jpg)
|
||||
})
|
||||
|
||||
route.GET("/apple-splash-828-1792.jpg", func(c *gin.Context) {
|
||||
etag := fmt.Sprintf("%x", md5.Sum(Applesplash8281792jpg))
|
||||
c.Header("Cache-Control", "public, max-age=31536000")
|
||||
c.Header("ETag", etag)
|
||||
c.Data(200, "image/jpeg", Applesplash8281792jpg)
|
||||
})
|
||||
|
||||
route.GET("/asset-manifest.json", func(c *gin.Context) {
|
||||
etag := fmt.Sprintf("%x", md5.Sum(Assetmanifestjson))
|
||||
c.Header("Cache-Control", "public, max-age=31536000")
|
||||
c.Header("ETag", etag)
|
||||
c.Data(200, "application/json", Assetmanifestjson)
|
||||
})
|
||||
|
||||
route.GET("/browserconfig.xml", func(c *gin.Context) {
|
||||
etag := fmt.Sprintf("%x", md5.Sum(Browserconfigxml))
|
||||
c.Header("Cache-Control", "public, max-age=31536000")
|
||||
c.Header("ETag", etag)
|
||||
c.Data(200, "application/xml; charset=utf-8", Browserconfigxml)
|
||||
})
|
||||
|
||||
route.GET("/dlnaicon-120.png", func(c *gin.Context) {
|
||||
etag := fmt.Sprintf("%x", md5.Sum(Dlnaicon120png))
|
||||
c.Header("Cache-Control", "public, max-age=31536000")
|
||||
c.Header("ETag", etag)
|
||||
c.Data(200, "image/png", Dlnaicon120png)
|
||||
})
|
||||
|
||||
route.GET("/dlnaicon-48.png", func(c *gin.Context) {
|
||||
etag := fmt.Sprintf("%x", md5.Sum(Dlnaicon48png))
|
||||
c.Header("Cache-Control", "public, max-age=31536000")
|
||||
c.Header("ETag", etag)
|
||||
c.Data(200, "image/png", Dlnaicon48png)
|
||||
})
|
||||
|
||||
route.GET("/favicon-16x16.png", func(c *gin.Context) {
|
||||
etag := fmt.Sprintf("%x", md5.Sum(Favicon16x16png))
|
||||
c.Header("Cache-Control", "public, max-age=31536000")
|
||||
c.Header("ETag", etag)
|
||||
c.Data(200, "image/png", Favicon16x16png)
|
||||
})
|
||||
|
||||
route.GET("/favicon-32x32.png", func(c *gin.Context) {
|
||||
etag := fmt.Sprintf("%x", md5.Sum(Favicon32x32png))
|
||||
c.Header("Cache-Control", "public, max-age=31536000")
|
||||
c.Header("ETag", etag)
|
||||
c.Data(200, "image/png", Favicon32x32png)
|
||||
})
|
||||
|
||||
route.GET("/favicon.ico", func(c *gin.Context) {
|
||||
etag := fmt.Sprintf("%x", md5.Sum(Faviconico))
|
||||
c.Header("Cache-Control", "public, max-age=31536000")
|
||||
c.Header("ETag", etag)
|
||||
c.Data(200, "image/vnd.microsoft.icon", Faviconico)
|
||||
})
|
||||
|
||||
route.GET("/icon.png", func(c *gin.Context) {
|
||||
etag := fmt.Sprintf("%x", md5.Sum(Iconpng))
|
||||
c.Header("Cache-Control", "public, max-age=31536000")
|
||||
c.Header("ETag", etag)
|
||||
c.Data(200, "image/png", Iconpng)
|
||||
})
|
||||
|
||||
route.GET("/index.html", func(c *gin.Context) {
|
||||
etag := fmt.Sprintf("%x", md5.Sum(Indexhtml))
|
||||
c.Header("Cache-Control", "public, max-age=31536000")
|
||||
c.Header("ETag", etag)
|
||||
c.Data(200, "text/html; charset=utf-8", Indexhtml)
|
||||
})
|
||||
|
||||
route.GET("/logo.png", func(c *gin.Context) {
|
||||
etag := fmt.Sprintf("%x", md5.Sum(Logopng))
|
||||
c.Header("Cache-Control", "public, max-age=31536000")
|
||||
c.Header("ETag", etag)
|
||||
c.Data(200, "image/png", Logopng)
|
||||
})
|
||||
|
||||
route.GET("/lordicon/jkzgajyr.json", func(c *gin.Context) {
|
||||
etag := fmt.Sprintf("%x", md5.Sum(Lordiconjkzgajyrjson))
|
||||
c.Header("Cache-Control", "public, max-age=31536000")
|
||||
c.Header("ETag", etag)
|
||||
c.Data(200, "application/json", Lordiconjkzgajyrjson)
|
||||
})
|
||||
|
||||
route.GET("/lordicon/lord-icon-2.0.2.js", func(c *gin.Context) {
|
||||
etag := fmt.Sprintf("%x", md5.Sum(Lordiconlordicon202js))
|
||||
c.Header("Cache-Control", "public, max-age=31536000")
|
||||
c.Header("ETag", etag)
|
||||
c.Data(200, "application/javascript; charset=utf-8", Lordiconlordicon202js)
|
||||
})
|
||||
|
||||
route.GET("/lordicon/wrprwmwt.json", func(c *gin.Context) {
|
||||
etag := fmt.Sprintf("%x", md5.Sum(Lordiconwrprwmwtjson))
|
||||
c.Header("Cache-Control", "public, max-age=31536000")
|
||||
c.Header("ETag", etag)
|
||||
c.Data(200, "application/json", Lordiconwrprwmwtjson)
|
||||
})
|
||||
|
||||
route.GET("/mstile-150x150.png", func(c *gin.Context) {
|
||||
etag := fmt.Sprintf("%x", md5.Sum(Mstile150x150png))
|
||||
c.Header("Cache-Control", "public, max-age=31536000")
|
||||
c.Header("ETag", etag)
|
||||
c.Data(200, "image/png", Mstile150x150png)
|
||||
})
|
||||
|
||||
route.GET("/site.webmanifest", func(c *gin.Context) {
|
||||
etag := fmt.Sprintf("%x", md5.Sum(Sitewebmanifest))
|
||||
c.Header("Cache-Control", "public, max-age=31536000")
|
||||
c.Header("ETag", etag)
|
||||
c.Data(200, "application/manifest+json", Sitewebmanifest)
|
||||
})
|
||||
|
||||
route.GET("/static/js/2.17225dbd.chunk.js", func(c *gin.Context) {
|
||||
etag := fmt.Sprintf("%x", md5.Sum(Staticjs217225dbdchunkjs))
|
||||
c.Header("Cache-Control", "public, max-age=31536000")
|
||||
c.Header("ETag", etag)
|
||||
c.Data(200, "application/javascript; charset=utf-8", Staticjs217225dbdchunkjs)
|
||||
})
|
||||
|
||||
route.GET("/static/js/2.17225dbd.chunk.js.LICENSE.txt", func(c *gin.Context) {
|
||||
etag := fmt.Sprintf("%x", md5.Sum(Staticjs217225dbdchunkjsLICENSEtxt))
|
||||
c.Header("Cache-Control", "public, max-age=31536000")
|
||||
c.Header("ETag", etag)
|
||||
c.Data(200, "text/plain; charset=utf-8", Staticjs217225dbdchunkjsLICENSEtxt)
|
||||
})
|
||||
|
||||
route.GET("/static/js/2.17225dbd.chunk.js.map", func(c *gin.Context) {
|
||||
etag := fmt.Sprintf("%x", md5.Sum(Staticjs217225dbdchunkjsmap))
|
||||
c.Header("Cache-Control", "public, max-age=31536000")
|
||||
c.Header("ETag", etag)
|
||||
c.Data(200, "application/json", Staticjs217225dbdchunkjsmap)
|
||||
})
|
||||
|
||||
route.GET("/static/js/main.c7b9a3c5.chunk.js", func(c *gin.Context) {
|
||||
etag := fmt.Sprintf("%x", md5.Sum(Staticjsmainc7b9a3c5chunkjs))
|
||||
c.Header("Cache-Control", "public, max-age=31536000")
|
||||
c.Header("ETag", etag)
|
||||
c.Data(200, "application/javascript; charset=utf-8", Staticjsmainc7b9a3c5chunkjs)
|
||||
})
|
||||
|
||||
route.GET("/static/js/main.c7b9a3c5.chunk.js.map", func(c *gin.Context) {
|
||||
etag := fmt.Sprintf("%x", md5.Sum(Staticjsmainc7b9a3c5chunkjsmap))
|
||||
c.Header("Cache-Control", "public, max-age=31536000")
|
||||
c.Header("ETag", etag)
|
||||
c.Data(200, "application/json", Staticjsmainc7b9a3c5chunkjsmap)
|
||||
})
|
||||
|
||||
route.GET("/static/js/runtime-main.5ed86a79.js", func(c *gin.Context) {
|
||||
etag := fmt.Sprintf("%x", md5.Sum(Staticjsruntimemain5ed86a79js))
|
||||
c.Header("Cache-Control", "public, max-age=31536000")
|
||||
c.Header("ETag", etag)
|
||||
c.Data(200, "application/javascript; charset=utf-8", Staticjsruntimemain5ed86a79js)
|
||||
})
|
||||
|
||||
route.GET("/static/js/runtime-main.5ed86a79.js.map", func(c *gin.Context) {
|
||||
etag := fmt.Sprintf("%x", md5.Sum(Staticjsruntimemain5ed86a79jsmap))
|
||||
c.Header("Cache-Control", "public, max-age=31536000")
|
||||
c.Header("ETag", etag)
|
||||
c.Data(200, "application/json", Staticjsruntimemain5ed86a79jsmap)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net"
|
||||
"os"
|
||||
"server/proxy"
|
||||
"sort"
|
||||
|
||||
"server/torrfs/fuse"
|
||||
"server/torrfs/webdav"
|
||||
|
||||
"server/rutor"
|
||||
|
||||
"github.com/gin-contrib/cors"
|
||||
"github.com/gin-contrib/location/v2"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/wlynxg/anet"
|
||||
|
||||
"server/dlna"
|
||||
"server/settings"
|
||||
"server/web/msx"
|
||||
|
||||
"server/log"
|
||||
"server/torr"
|
||||
"server/version"
|
||||
"server/web/api"
|
||||
"server/web/auth"
|
||||
"server/web/blocker"
|
||||
"server/web/pages"
|
||||
"server/web/sslcerts"
|
||||
|
||||
swaggerFiles "github.com/swaggo/files" // swagger embed files
|
||||
ginSwagger "github.com/swaggo/gin-swagger" // gin-swagger middleware
|
||||
)
|
||||
|
||||
var (
|
||||
BTS = torr.NewBTS()
|
||||
waitChan = make(chan error)
|
||||
)
|
||||
|
||||
// @title Swagger Torrserver API
|
||||
// @version {version.Version}
|
||||
// @description Torrent streaming server.
|
||||
|
||||
// @license.name GPL 3.0
|
||||
|
||||
// @BasePath /
|
||||
|
||||
// @securityDefinitions.basic BasicAuth
|
||||
|
||||
// @externalDocs.description OpenAPI
|
||||
// @externalDocs.url https://swagger.io/resources/open-api/
|
||||
func Start() {
|
||||
log.TLogln("Start TorrServer " + version.Version + " torrent " + version.GetTorrentVersion())
|
||||
ips := GetLocalIps()
|
||||
if len(ips) > 0 {
|
||||
log.TLogln("Local IPs:", ips)
|
||||
}
|
||||
err := BTS.Connect()
|
||||
if err != nil {
|
||||
log.TLogln("BTS.Connect() error!", err) // waitChan <- err
|
||||
os.Exit(1) // return
|
||||
}
|
||||
rutor.Start()
|
||||
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
|
||||
// corsCfg := cors.DefaultConfig()
|
||||
// corsCfg.AllowAllOrigins = true
|
||||
// corsCfg.AllowHeaders = []string{"*"}
|
||||
// corsCfg.AllowMethods = []string{"*"}
|
||||
corsCfg := cors.DefaultConfig()
|
||||
corsCfg.AllowAllOrigins = true
|
||||
corsCfg.AllowPrivateNetwork = true
|
||||
corsCfg.AllowHeaders = []string{"Origin", "Content-Length", "Content-Type", "X-Requested-With", "Accept", "Authorization"}
|
||||
|
||||
route := gin.New()
|
||||
route.Use(log.WebLogger(), blocker.Blocker(), gin.Recovery(), cors.New(corsCfg), location.Default())
|
||||
auth.SetupAuth(route)
|
||||
|
||||
route.GET("/echo", echo)
|
||||
|
||||
api.SetupRoute(route)
|
||||
msx.SetupRoute(route)
|
||||
pages.SetupRoute(route)
|
||||
if settings.Args.WebDAV {
|
||||
webdav.MountWebDAV(route)
|
||||
}
|
||||
|
||||
if settings.BTsets.EnableDLNA {
|
||||
dlna.Start()
|
||||
}
|
||||
|
||||
// Auto-mount FUSE filesystem if enabled
|
||||
fuse.FuseAutoMount()
|
||||
|
||||
route.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
|
||||
|
||||
// check if https enabled
|
||||
if settings.Ssl {
|
||||
// if no cert and key files set in db/settings, generate new self-signed cert and key files
|
||||
if settings.BTsets.SslCert == "" || settings.BTsets.SslKey == "" {
|
||||
settings.BTsets.SslCert, settings.BTsets.SslKey = sslcerts.MakeCertKeyFiles(ips)
|
||||
log.TLogln("Saving path to ssl cert and key in db", settings.BTsets.SslCert, settings.BTsets.SslKey)
|
||||
settings.SetBTSets(settings.BTsets)
|
||||
}
|
||||
// verify if cert and key files are valid
|
||||
err = sslcerts.VerifyCertKeyFiles(settings.BTsets.SslCert, settings.BTsets.SslKey, settings.SslPort)
|
||||
// if not valid, generate new self-signed cert and key files
|
||||
if err != nil {
|
||||
log.TLogln("Error checking certificate and private key files:", err)
|
||||
settings.BTsets.SslCert, settings.BTsets.SslKey = sslcerts.MakeCertKeyFiles(ips)
|
||||
log.TLogln("Saving path to ssl cert and key in db", settings.BTsets.SslCert, settings.BTsets.SslKey)
|
||||
settings.SetBTSets(settings.BTsets)
|
||||
}
|
||||
go func() {
|
||||
log.TLogln("Start https server at", settings.IP+":"+settings.SslPort)
|
||||
waitChan <- route.RunTLS(settings.IP+":"+settings.SslPort, settings.BTsets.SslCert, settings.BTsets.SslKey)
|
||||
}()
|
||||
}
|
||||
|
||||
go func() {
|
||||
addr := settings.IP + ":" + settings.Port
|
||||
if settings.Args != nil && settings.Args.ForceHTTPS && settings.Ssl {
|
||||
waitChan <- runHTTPRedirectToHTTPS(addr)
|
||||
return
|
||||
}
|
||||
log.TLogln("Start http server at", addr)
|
||||
waitChan <- route.Run(addr)
|
||||
}()
|
||||
}
|
||||
|
||||
func Wait() error {
|
||||
return <-waitChan
|
||||
}
|
||||
|
||||
func Stop() {
|
||||
dlna.Stop()
|
||||
// Unmount FUSE filesystem if mounted
|
||||
fuse.FuseCleanup()
|
||||
BTS.Disconnect()
|
||||
proxy.Stop()
|
||||
waitChan <- nil
|
||||
}
|
||||
|
||||
// echo godoc
|
||||
//
|
||||
// @Summary Tests server status
|
||||
// @Description Tests whether server is alive or not
|
||||
//
|
||||
// @Tags API
|
||||
//
|
||||
// @Produce plain
|
||||
// @Success 200 {string} string "Server version"
|
||||
// @Router /echo [get]
|
||||
func echo(c *gin.Context) {
|
||||
c.String(200, "%v", version.Version)
|
||||
}
|
||||
|
||||
func GetLocalIps() []string {
|
||||
ifaces, err := anet.Interfaces()
|
||||
if err != nil {
|
||||
log.TLogln("Error get local IPs")
|
||||
return nil
|
||||
}
|
||||
var list []string
|
||||
for _, i := range ifaces {
|
||||
addrs, _ := anet.InterfaceAddrsByInterface(&i)
|
||||
if i.Flags&net.FlagUp == net.FlagUp {
|
||||
for _, addr := range addrs {
|
||||
var ip net.IP
|
||||
switch v := addr.(type) {
|
||||
case *net.IPNet:
|
||||
ip = v.IP
|
||||
case *net.IPAddr:
|
||||
ip = v.IP
|
||||
}
|
||||
if !ip.IsLoopback() && !ip.IsLinkLocalUnicast() && !ip.IsLinkLocalMulticast() {
|
||||
list = append(list, ip.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Strings(list)
|
||||
return list
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package sslcerts
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"math/big"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"server/log"
|
||||
"server/settings"
|
||||
)
|
||||
|
||||
func generateSelfSignedCert(ips []string) ([]byte, []byte, error) {
|
||||
priv, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
notBefore := time.Now()
|
||||
notAfter := notBefore.Add(365 * 24 * time.Hour) // Valid for 1 year
|
||||
|
||||
serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
netIps := make([]net.IP, 0)
|
||||
if len(ips) != 0 {
|
||||
for _, ip := range ips {
|
||||
netIps = append(netIps, net.ParseIP(ip))
|
||||
}
|
||||
}
|
||||
|
||||
template := x509.Certificate{
|
||||
SerialNumber: serialNumber,
|
||||
Subject: pkix.Name{
|
||||
Organization: []string{"TorrServer"},
|
||||
},
|
||||
NotBefore: notBefore,
|
||||
NotAfter: notAfter,
|
||||
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
BasicConstraintsValid: true,
|
||||
DNSNames: []string{"localhost"},
|
||||
IPAddresses: netIps,
|
||||
}
|
||||
|
||||
certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
|
||||
|
||||
privBytes, err := x509.MarshalECPrivateKey(priv)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
privPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: privBytes})
|
||||
|
||||
return certPEM, privPEM, nil
|
||||
}
|
||||
|
||||
func MakeCertKeyFiles(ips []string) (string, string) {
|
||||
certPEM, privPEM, err := generateSelfSignedCert(ips)
|
||||
if err != nil {
|
||||
log.TLogln("Error generating certificate:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
certFile, err := os.Create(filepath.Join(settings.Path, "server.pem"))
|
||||
if err != nil {
|
||||
log.TLogln("Error creating certificate file:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer certFile.Close()
|
||||
|
||||
privFile, err := os.Create(filepath.Join(settings.Path, "server.key"))
|
||||
if err != nil {
|
||||
log.TLogln("Error creating private key file:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer privFile.Close()
|
||||
|
||||
_, err = certFile.Write(certPEM)
|
||||
if err != nil {
|
||||
log.TLogln("Error writing certificate file:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
_, err = privFile.Write(privPEM)
|
||||
if err != nil {
|
||||
log.TLogln("Error writing private key file:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
log.TLogln("Self-signed certificate and private key generated successfully.")
|
||||
|
||||
return getAbsPath(certFile.Name()), getAbsPath(privFile.Name())
|
||||
}
|
||||
|
||||
func getAbsPath(fileName string) string {
|
||||
filePath, err := filepath.Abs(fileName)
|
||||
if err != nil {
|
||||
log.TLogln("Error getting absolute path:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return filePath
|
||||
}
|
||||
|
||||
func VerifyCertKeyFiles(certFile, keyFile, port string) error {
|
||||
// Load the certificate and key
|
||||
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Check if the certificate chain is expired
|
||||
for _, cert := range cert.Certificate {
|
||||
x509Cert, err := x509.ParseCertificate(cert)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if x509Cert.NotAfter.Before(time.Now()) {
|
||||
return errors.New("certificate has expired")
|
||||
}
|
||||
}
|
||||
// Create a TLS configuration
|
||||
config := tls.Config{
|
||||
Certificates: []tls.Certificate{cert},
|
||||
}
|
||||
// Create a listener to check the certificate and key
|
||||
ln, err := tls.Listen("tcp", ":"+port, &config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer ln.Close()
|
||||
log.TLogln("Certificate and key are valid.")
|
||||
return nil
|
||||
}
|
||||