diff --git a/go.mod b/go.mod index 32c772ca..0fe91ce1 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( github.com/jackc/pgx/v5 v5.5.2 github.com/labstack/echo-contrib v0.15.0 github.com/labstack/echo/v4 v4.11.4 + github.com/labstack/gommon v0.4.2 github.com/lib/pq v1.10.9 github.com/rosedblabs/rosedb/v2 v2.3.4 github.com/stretchr/testify v1.8.4 @@ -53,7 +54,6 @@ require ( github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 // indirect github.com/jackc/puddle/v2 v2.2.1 // indirect - github.com/labstack/gommon v0.4.2 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect diff --git a/handler/app/web.go b/handler/app/web.go index 9f2e0d70..6ead6fb9 100644 --- a/handler/app/web.go +++ b/handler/app/web.go @@ -33,14 +33,15 @@ func EditDownloadGET(filename, filesize, demozoo any) template.HTML { return "" } } + var zooID int64 if val, ok := demozoo.(null.Int64); ok { if !val.Valid || val.Int64 == 0 { return "" } + zooID = val.Int64 } - // zoo := demozoo.(null.Int64).Int64 - // fmt.Sprintf("%d", zoo) - return template.HTML(`GET a remote download`) + s := fmt.Sprintf("GET a remote download", zooID) + return template.HTML(s) } // Web is the configuration and status of the web app. diff --git a/handler/router.go b/handler/router.go index 00c94576..b14be504 100644 --- a/handler/router.go +++ b/handler/router.go @@ -7,14 +7,20 @@ import ( "embed" "fmt" "net/http" + "os" + "path/filepath" + "strconv" "strings" "github.com/Defacto2/releaser" "github.com/Defacto2/server/handler/app" "github.com/Defacto2/server/internal/config" + "github.com/Defacto2/server/internal/helper" + "github.com/Defacto2/server/internal/zoo" "github.com/gorilla/sessions" "github.com/labstack/echo-contrib/session" "github.com/labstack/echo/v4" + "github.com/labstack/gommon/log" "go.uber.org/zap" ) @@ -271,9 +277,47 @@ func (c Configuration) Routes(z *zap.SugaredLogger, e *echo.Echo, public embed.F // Editor pages to update the database records. editor := e.Group("/editor") editor.Use(c.ReadOnlyLock, c.SessionLock) + editor.GET("/new-for-approval", func(x echo.Context) error { return app.FilesWaiting(z, x, "1") }) + editor.GET("/new-for-approval/:id", func(x echo.Context) error { + // todo, placeholder + sid := x.Param("id") + id, err := strconv.Atoi(sid) + if err != nil { + return app.StatusErr(z, x, http.StatusNotFound, sid) + } + dz := zoo.Demozoo{} + if err = dz.Get(id); err != nil { + return app.StatusErr(z, x, http.StatusNotFound, sid) + } + var file string + for _, link := range dz.DownloadLinks { + if link.URL != "" { + file, err = helper.DownloadFile(link.URL) + if err != nil { + log.Debug(err) + continue + } + if file != "" { + base := filepath.Base(link.URL) + home, _ := os.UserHomeDir() + dst := filepath.Join(home, base) + if err = helper.RenameFile(file, dst); err != nil { + return app.StatusErr(z, x, http.StatusInternalServerError, sid) + } + return x.String(http.StatusOK, + fmt.Sprintf("File downloaded to %s", dst)) + } + } + } + if file == "" { + return app.StatusErr(z, x, http.StatusNotFound, sid) + } + return x.JSON(http.StatusOK, dz) + }) + online := editor.Group("/online") online.POST("/true", func(x echo.Context) error { return app.RecordToggle(z, x, true) diff --git a/internal/helper/get.go b/internal/helper/get.go new file mode 100644 index 00000000..ff0b1187 --- /dev/null +++ b/internal/helper/get.go @@ -0,0 +1,106 @@ +package helper + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "os" + "time" +) + +const ( + // Timeout is the HTTP client timeout. + Timeout = 5 * time.Second + // User-Agent to send with the HTTP request. + UserAgent = "Defacto2 2024 app under construction (thanks!)" +) + +// DownloadFile downloads a file from a remote URL and saves it to the default temp directory. +// It returns the path to the downloaded file. +func DownloadFile(url string) (string, error) { + // Get the remote file + client := http.Client{ + Timeout: Timeout, + } + ctx := context.Background() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return "", err + } + req.Header.Set("User-Agent", UserAgent) + res, err := client.Do(req) + if err != nil { + return "", err + } + defer res.Body.Close() + + // Create the file in the default temp directory + tmpFile, err := os.CreateTemp("", "downloadfile-*") + if err != nil { + return "", err + } + defer tmpFile.Close() + + // Write the body to file + if _, err := io.Copy(tmpFile, res.Body); err != nil { + defer os.Remove(tmpFile.Name()) + return "", err + } + return tmpFile.Name(), nil +} + +// RenameFile renames a file from oldpath to newpath. +// It returns an error if the oldpath does not exist or is a directory, +// newpath already exists, or the rename fails. +func RenameFile(oldpath, newpath string) error { + st, err := os.Stat(oldpath) + if err != nil { + return err + } + if st.IsDir() { + return fmt.Errorf("oldpath %w: %s", ErrFilePath, oldpath) + } + if _, err = os.Stat(newpath); err == nil { + return fmt.Errorf("newpath %w: %s", ErrExistPath, newpath) + } + if err := os.Rename(oldpath, newpath); err != nil { + var linkErr *os.LinkError + if errors.As(err, &linkErr) && linkErr.Err.Error() == "invalid cross-device link" { + return RenameCrossDevice(oldpath, newpath) + } + return err + } + return nil +} + +// RenameCrossDevice is a workaround for renaming files across different devices. +// A cross device can also be a different file system such as a Docker volume. +func RenameCrossDevice(oldpath, newpath string) error { + src, err := os.Open(oldpath) + if err != nil { + return err + } + defer src.Close() + dst, err := os.Create(newpath) + if err != nil { + return err + } + defer dst.Close() + + if _, err = io.Copy(dst, src); err != nil { + return err + } + fi, err := os.Stat(oldpath) + if err != nil { + defer os.Remove(newpath) + return err + } + if err = os.Chmod(newpath, fi.Mode()); err != nil { + defer os.Remove(newpath) + return err + } + defer os.Remove(oldpath) + return nil +} diff --git a/internal/helper/helper.go b/internal/helper/helper.go index 13351bdf..75db676d 100644 --- a/internal/helper/helper.go +++ b/internal/helper/helper.go @@ -5,6 +5,7 @@ import ( "crypto/sha512" "embed" "encoding/base64" + "errors" "fmt" "net" "os" @@ -19,6 +20,11 @@ const ( byteUnits = "kMGTPE" ) +var ( + ErrFilePath = errors.New("file path is a directory") + ErrExistPath = errors.New("path ready exists and will not overwrite") +) + // GetLocalIPs returns a list of local IP addresses. // credit: https://gosamples.dev/local-ip-address/ func GetLocalIPs() ([]net.IP, error) { diff --git a/internal/pouet/pouet.go b/internal/pouet/pouet.go index e0ad184d..ea22f5b2 100644 --- a/internal/pouet/pouet.go +++ b/internal/pouet/pouet.go @@ -12,6 +12,8 @@ import ( "strconv" "strings" "time" + + "github.com/Defacto2/server/internal/helper" ) const ( @@ -21,8 +23,6 @@ const ( Timeout = 5 * time.Second // StarRounder is the rounding value for the stars rating. StarRounder = 0.5 - // User-Agent to send with the HTTP request. - UserAgent = "Defacto2 2024 app, under construction (thanks!)" // firstID is the first production ID on Pouet. firstID = 1 ) @@ -191,7 +191,7 @@ func (r *Response) Get(id int) error { if err != nil { return err } - req.Header.Set("User-Agent", UserAgent) + req.Header.Set("User-Agent", helper.UserAgent) res, err := client.Do(req) if err != nil { return err diff --git a/internal/zoo/zoo.go b/internal/zoo/zoo.go index 9f16ca30..3e83f5ed 100644 --- a/internal/zoo/zoo.go +++ b/internal/zoo/zoo.go @@ -11,6 +11,8 @@ import ( "net/http" "strconv" "time" + + "github.com/Defacto2/server/internal/helper" ) const ( @@ -43,6 +45,11 @@ type Demozoo struct { Name string `json:"name"` ID int `json:"id"` } `json:"types"` + // Download links to the remotely hosted files. + DownloadLinks []struct { + LinkClass string `json:"link_class"` + URL string `json:"url"` + } `json:"download_links"` // ID is the production ID. ID int `json:"id"` } @@ -67,8 +74,7 @@ func (d *Demozoo) Get(id int) error { if err != nil { return err } - req.Header.Set("User-Agent", - "Defacto2 2023 app under construction (thanks!)") + req.Header.Set("User-Agent", helper.UserAgent) res, err := client.Do(req) if err != nil { return err