- added unit tests
    - put all subcmds into one file
    - use io.Writer for output, better for testing
    - added upload form support
    - added api docs
    - generalized db engine
    - added mail notify support for forms
    - enhanced server/SetupAuthStore() to also look up form ids
    - added form template (put into .go file by Makefile
    - renamed project
This commit is contained in:
2023-03-21 19:41:24 +01:00
parent b8816f910a
commit 05fa5cd41b
41 changed files with 1973 additions and 545 deletions

View File

@@ -33,7 +33,7 @@ all: buildlocal
buildlocal:
go build -ldflags "-X 'github.com/tlinden/cenophane/upctl/cfg.VERSION=$(VERSION)'"
go build -ldflags "-X 'github.com/tlinden/ephemerup/upctl/cfg.VERSION=$(VERSION)'"
release:
./mkrel.sh $(tool) $(version)

View File

@@ -43,6 +43,10 @@ type Config struct {
// required to intercept requests using httpmock in tests
Mock bool
// required for forms
Description string
Notify string
}
func Getversion() string {

View File

@@ -1,47 +0,0 @@
/*
Copyright © 2023 Thomas von Dein
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package cmd
import (
"errors"
"github.com/spf13/cobra"
"github.com/tlinden/cenophane/upctl/cfg"
"github.com/tlinden/cenophane/upctl/lib"
)
func DeleteCommand(conf *cfg.Config) *cobra.Command {
var deleteCmd = &cobra.Command{
Use: "delete [options] <id>",
Short: "Delete an upload",
Long: `Delete an upload identified by its id`,
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
return errors.New("No id specified to delete!")
}
// errors at this stage do not cause the usage to be shown
cmd.SilenceUsage = true
return lib.Delete(conf, args)
},
}
deleteCmd.Aliases = append(deleteCmd.Aliases, "rm")
deleteCmd.Aliases = append(deleteCmd.Aliases, "d")
return deleteCmd
}

View File

@@ -1,48 +0,0 @@
/*
Copyright © 2023 Thomas von Dein
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package cmd
import (
"errors"
"github.com/spf13/cobra"
"github.com/tlinden/cenophane/upctl/cfg"
"github.com/tlinden/cenophane/upctl/lib"
)
func DescribeCommand(conf *cfg.Config) *cobra.Command {
var listCmd = &cobra.Command{
Use: "describe [options] upload-id",
Long: "Show detailed informations about an upload object.",
Short: `Describe an upload.`,
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
return errors.New("No id specified to delete!")
}
// errors at this stage do not cause the usage to be shown
cmd.SilenceUsage = true
return lib.Describe(conf, args)
},
}
listCmd.Aliases = append(listCmd.Aliases, "des")
listCmd.Aliases = append(listCmd.Aliases, "info")
listCmd.Aliases = append(listCmd.Aliases, "i")
return listCmd
}

View File

@@ -1,49 +0,0 @@
/*
Copyright © 2023 Thomas von Dein
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package cmd
import (
"errors"
"github.com/spf13/cobra"
"github.com/tlinden/cenophane/upctl/cfg"
"github.com/tlinden/cenophane/upctl/lib"
)
func DownloadCommand(conf *cfg.Config) *cobra.Command {
var listCmd = &cobra.Command{
Use: "download [options] upload-id",
Long: "Download the file associated with an upload object.",
Short: `Download a file.`,
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
return errors.New("No id specified to delete!")
}
// errors at this stage do not cause the usage to be shown
cmd.SilenceUsage = true
return lib.Download(conf, args)
},
}
listCmd.Aliases = append(listCmd.Aliases, "down")
listCmd.Aliases = append(listCmd.Aliases, "get")
listCmd.Aliases = append(listCmd.Aliases, "g")
listCmd.Aliases = append(listCmd.Aliases, "fetch")
return listCmd
}

73
upctl/cmd/formcommands.go Normal file
View File

@@ -0,0 +1,73 @@
/*
Copyright © 2023 Thomas von Dein
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package cmd
import (
//"errors"
"github.com/spf13/cobra"
"github.com/tlinden/ephemerup/upctl/cfg"
"github.com/tlinden/ephemerup/upctl/lib"
"os"
)
func FormCommand(conf *cfg.Config) *cobra.Command {
var formCmd = &cobra.Command{
Use: "form {create|delete|modify|list}",
Short: "Form commands",
Long: `Manage upload forms.`,
RunE: func(cmd *cobra.Command, args []string) error {
// errors at this stage do not cause the usage to be shown
//cmd.SilenceUsage = true
if len(args) == 0 {
cmd.Help()
os.Exit(0)
}
return nil
},
}
formCmd.Aliases = append(formCmd.Aliases, "frm")
formCmd.Aliases = append(formCmd.Aliases, "f")
formCmd.AddCommand(FormCreateCommand(conf))
return formCmd
}
func FormCreateCommand(conf *cfg.Config) *cobra.Command {
var formCreateCmd = &cobra.Command{
Use: "create [options]",
Short: "Create a new form",
Long: `Create a new form for consumers so they can upload something.`,
RunE: func(cmd *cobra.Command, args []string) error {
// errors at this stage do not cause the usage to be shown
cmd.SilenceUsage = true
return lib.CreateForm(os.Stdout, conf)
},
}
// options
formCreateCmd.PersistentFlags().StringVarP(&conf.Expire, "expire", "e", "", "Expire setting: asap or duration (accepted shortcuts: dmh)")
formCreateCmd.PersistentFlags().StringVarP(&conf.Description, "description", "D", "", "Description of the form")
formCreateCmd.PersistentFlags().StringVarP(&conf.Notify, "notify", "n", "", "Email address to get notified when consumer has uploaded files")
formCreateCmd.Aliases = append(formCreateCmd.Aliases, "add")
formCreateCmd.Aliases = append(formCreateCmd.Aliases, "+")
return formCreateCmd
}

View File

@@ -1,45 +0,0 @@
/*
Copyright © 2023 Thomas von Dein
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package cmd
import (
"github.com/spf13/cobra"
"github.com/tlinden/cenophane/upctl/cfg"
"github.com/tlinden/cenophane/upctl/lib"
)
func ListCommand(conf *cfg.Config) *cobra.Command {
var listCmd = &cobra.Command{
Use: "list [options] [file ..]",
Short: "List uploads",
Long: `List uploads.`,
RunE: func(cmd *cobra.Command, args []string) error {
// errors at this stage do not cause the usage to be shown
cmd.SilenceUsage = true
return lib.List(conf, args)
},
}
// options
listCmd.PersistentFlags().StringVarP(&conf.Apicontext, "apicontext", "", "", "Filter by given API context")
listCmd.Aliases = append(listCmd.Aliases, "ls")
listCmd.Aliases = append(listCmd.Aliases, "l")
return listCmd
}

145
upctl/cmd/maincommands.go Normal file
View File

@@ -0,0 +1,145 @@
/*
Copyright © 2023 Thomas von Dein
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package cmd
import (
"errors"
"github.com/spf13/cobra"
"github.com/tlinden/ephemerup/upctl/cfg"
"github.com/tlinden/ephemerup/upctl/lib"
"os"
)
func UploadCommand(conf *cfg.Config) *cobra.Command {
var uploadCmd = &cobra.Command{
Use: "upload [options] [file ..]",
Short: "Upload files",
Long: `Upload files to an upload api.`,
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
return errors.New("No files specified to upload!")
}
// errors at this stage do not cause the usage to be shown
cmd.SilenceUsage = true
return lib.UploadFiles(os.Stdout, conf, args)
},
}
// options
uploadCmd.PersistentFlags().StringVarP(&conf.Expire, "expire", "e", "", "Expire setting: asap or duration (accepted shortcuts: dmh)")
uploadCmd.Aliases = append(uploadCmd.Aliases, "up")
uploadCmd.Aliases = append(uploadCmd.Aliases, "u")
return uploadCmd
}
func ListCommand(conf *cfg.Config) *cobra.Command {
var listCmd = &cobra.Command{
Use: "list [options] [file ..]",
Short: "List uploads",
Long: `List uploads.`,
RunE: func(cmd *cobra.Command, args []string) error {
// errors at this stage do not cause the usage to be shown
cmd.SilenceUsage = true
return lib.List(os.Stdout, conf, args)
},
}
// options
listCmd.PersistentFlags().StringVarP(&conf.Apicontext, "apicontext", "", "", "Filter by given API context")
listCmd.Aliases = append(listCmd.Aliases, "ls")
listCmd.Aliases = append(listCmd.Aliases, "l")
return listCmd
}
func DeleteCommand(conf *cfg.Config) *cobra.Command {
var deleteCmd = &cobra.Command{
Use: "delete [options] <id>",
Short: "Delete an upload",
Long: `Delete an upload identified by its id`,
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
return errors.New("No id specified to delete!")
}
// errors at this stage do not cause the usage to be shown
cmd.SilenceUsage = true
return lib.Delete(os.Stdout, conf, args)
},
}
deleteCmd.Aliases = append(deleteCmd.Aliases, "rm")
deleteCmd.Aliases = append(deleteCmd.Aliases, "d")
return deleteCmd
}
func DescribeCommand(conf *cfg.Config) *cobra.Command {
var listCmd = &cobra.Command{
Use: "describe [options] upload-id",
Long: "Show detailed informations about an upload object.",
Short: `Describe an upload.`,
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
return errors.New("No id specified to delete!")
}
// errors at this stage do not cause the usage to be shown
cmd.SilenceUsage = true
return lib.Describe(os.Stdout, conf, args)
},
}
listCmd.Aliases = append(listCmd.Aliases, "des")
listCmd.Aliases = append(listCmd.Aliases, "info")
listCmd.Aliases = append(listCmd.Aliases, "i")
return listCmd
}
func DownloadCommand(conf *cfg.Config) *cobra.Command {
var listCmd = &cobra.Command{
Use: "download [options] upload-id",
Long: "Download the file associated with an upload object.",
Short: `Download a file.`,
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
return errors.New("No id specified to delete!")
}
// errors at this stage do not cause the usage to be shown
cmd.SilenceUsage = true
return lib.Download(os.Stdout, conf, args)
},
}
listCmd.Aliases = append(listCmd.Aliases, "down")
listCmd.Aliases = append(listCmd.Aliases, "get")
listCmd.Aliases = append(listCmd.Aliases, "g")
listCmd.Aliases = append(listCmd.Aliases, "fetch")
return listCmd
}

View File

@@ -22,7 +22,7 @@ import (
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"github.com/spf13/viper"
"github.com/tlinden/cenophane/upctl/cfg"
"github.com/tlinden/ephemerup/upctl/cfg"
"os"
"strings"
)
@@ -92,6 +92,7 @@ func Execute() {
rootCmd.AddCommand(DeleteCommand(&conf))
rootCmd.AddCommand(DescribeCommand(&conf))
rootCmd.AddCommand(DownloadCommand(&conf))
rootCmd.AddCommand(FormCommand(&conf))
err := rootCmd.Execute()
if err != nil {

View File

@@ -1,50 +0,0 @@
/*
Copyright © 2023 Thomas von Dein
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package cmd
import (
"errors"
"github.com/spf13/cobra"
"github.com/tlinden/cenophane/upctl/cfg"
"github.com/tlinden/cenophane/upctl/lib"
)
func UploadCommand(conf *cfg.Config) *cobra.Command {
var uploadCmd = &cobra.Command{
Use: "upload [options] [file ..]",
Short: "Upload files",
Long: `Upload files to an upload api.`,
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
return errors.New("No files specified to upload!")
}
// errors at this stage do not cause the usage to be shown
cmd.SilenceUsage = true
return lib.UploadFiles(conf, args)
},
}
// options
uploadCmd.PersistentFlags().StringVarP(&conf.Expire, "expire", "e", "", "Expire setting: asap or duration (accepted shortcuts: dmh)")
uploadCmd.Aliases = append(uploadCmd.Aliases, "up")
uploadCmd.Aliases = append(uploadCmd.Aliases, "u")
return uploadCmd
}

View File

@@ -1,4 +1,4 @@
module github.com/tlinden/cenophane/upctl
module github.com/tlinden/ephemerup/upctl
go 1.18
@@ -10,10 +10,11 @@ require (
github.com/spf13/cobra v1.6.1
github.com/spf13/pflag v1.0.5
github.com/spf13/viper v1.15.0
github.com/tlinden/cenophane/common v0.0.0-00010101000000-000000000000
github.com/tlinden/ephemerup/common v0.0.0-00010101000000-000000000000
)
require (
github.com/alecthomas/repr v0.2.0 // indirect
github.com/fsnotify/fsnotify v1.6.0 // indirect
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0 // indirect
github.com/golang/mock v1.6.0 // indirect
@@ -50,4 +51,4 @@ require (
gopkg.in/yaml.v3 v3.0.1 // indirect
)
replace github.com/tlinden/cenophane/common => ../common
replace github.com/tlinden/ephemerup/common => ../common

View File

@@ -38,6 +38,8 @@ cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3f
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/alecthomas/repr v0.2.0 h1:HAzS41CIzNW5syS8Mf9UwXhNH1J9aix/BvDRf1Ml2Yk=
github.com/alecthomas/repr v0.2.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=

View File

@@ -25,8 +25,9 @@ import (
"github.com/imroc/req/v3"
"github.com/jarcoal/httpmock"
"github.com/schollz/progressbar/v3"
"github.com/tlinden/cenophane/common"
"github.com/tlinden/cenophane/upctl/cfg"
"github.com/tlinden/ephemerup/common"
"github.com/tlinden/ephemerup/upctl/cfg"
"io"
"mime"
"os"
"path/filepath"
@@ -49,8 +50,11 @@ type ListParams struct {
Apicontext string `json:"apicontext"`
}
const Maxwidth = 10
const Maxwidth = 12
/*
Create a new request object for outgoing queries
*/
func Setup(c *cfg.Config, path string) *Request {
client := req.C()
if c.Debug {
@@ -86,9 +90,12 @@ func Setup(c *cfg.Config, path string) *Request {
}
return &Request{Url: c.Endpoint + path, R: R}
}
/*
Iterate over args, considering the elements are filenames, and add
them to the request.
*/
func GatherFiles(rq *Request, args []string) error {
for _, file := range args {
info, err := os.Stat(file)
@@ -120,9 +127,48 @@ func GatherFiles(rq *Request, args []string) error {
return nil
}
func UploadFiles(c *cfg.Config, args []string) error {
/*
Check HTTP Response Code and validate JSON status output, if
any. Turns'em into a regular error
*/
func HandleResponse(c *cfg.Config, resp *req.Response) error {
// we expect a json response, extract the error, if any
r := Response{}
if c.Debug {
trace := resp.Request.TraceInfo()
fmt.Println(trace.Blame())
fmt.Println("----------")
fmt.Println(trace)
}
if err := json.Unmarshal([]byte(resp.String()), &r); err != nil {
// text output!
r.Message = resp.String()
}
if !resp.IsSuccessState() {
return fmt.Errorf("bad response: %s (%s)", resp.Status, r.Message)
}
if !r.Success {
if len(r.Message) == 0 {
if resp.Err != nil {
return resp.Err
} else {
return errors.New("Unknown error")
}
} else {
return errors.New(r.Message)
}
}
return nil
}
func UploadFiles(w io.Writer, c *cfg.Config, args []string) error {
// setup url, req.Request, timeout handling etc
rq := Setup(c, "/file/")
rq := Setup(c, "/uploads")
// collect files to upload from @argv
if err := GatherFiles(rq, args); err != nil {
@@ -150,47 +196,15 @@ func UploadFiles(c *cfg.Config, args []string) error {
return err
}
return RespondExtended(resp)
if err := HandleResponse(c, resp); err != nil {
return err
}
return RespondExtended(w, resp)
}
func HandleResponse(c *cfg.Config, resp *req.Response) error {
// we expect a json response, extract the error, if any
r := Response{}
if err := json.Unmarshal([]byte(resp.String()), &r); err != nil {
// text output!
r.Message = resp.String()
}
if c.Debug {
trace := resp.Request.TraceInfo()
fmt.Println(trace.Blame())
fmt.Println("----------")
fmt.Println(trace)
}
if !r.Success {
if len(r.Message) == 0 {
if resp.Err != nil {
return resp.Err
} else {
return errors.New("Unknown error")
}
} else {
return errors.New(r.Message)
}
}
// all right
if r.Message != "" {
fmt.Println(r.Message)
}
return nil
}
func List(c *cfg.Config, args []string) error {
rq := Setup(c, "/list/")
func List(w io.Writer, c *cfg.Config, args []string) error {
rq := Setup(c, "/uploads")
params := &ListParams{Apicontext: c.Apicontext}
resp, err := rq.R.
@@ -201,12 +215,16 @@ func List(c *cfg.Config, args []string) error {
return err
}
return RespondTable(resp)
if err := HandleResponse(c, resp); err != nil {
return err
}
return UploadsRespondTable(w, resp)
}
func Delete(c *cfg.Config, args []string) error {
func Delete(w io.Writer, c *cfg.Config, args []string) error {
for _, id := range args {
rq := Setup(c, "/file/"+id+"/")
rq := Setup(c, "/uploads/"+id+"/")
resp, err := rq.R.Delete(rq.Url)
@@ -218,47 +236,67 @@ func Delete(c *cfg.Config, args []string) error {
return err
}
fmt.Printf("Upload %s successfully deleted.\n", id)
fmt.Fprintf(w, "Upload %s successfully deleted.\n", id)
}
return nil
}
func Describe(c *cfg.Config, args []string) error {
func Describe(w io.Writer, c *cfg.Config, args []string) error {
if len(args) == 0 {
return errors.New("No id provided!")
}
id := args[0] // we describe only 1 object
rq := Setup(c, "/upload/"+id+"/")
rq := Setup(c, "/uploads/"+id)
resp, err := rq.R.Get(rq.Url)
if err != nil {
return err
}
return RespondExtended(resp)
}
func Download(c *cfg.Config, args []string) error {
id := args[0]
// progres bar
bar := progressbar.Default(100)
callback := func(info req.DownloadInfo) {
if info.Response.Response != nil {
bar.Add(1)
}
if err := HandleResponse(c, resp); err != nil {
return err
}
return RespondExtended(w, resp)
}
func Download(w io.Writer, c *cfg.Config, args []string) error {
if len(args) == 0 {
return errors.New("No id provided!")
}
id := args[0]
rq := Setup(c, "/uploads/"+id+"/file")
if !c.Silent {
// progres bar
bar := progressbar.Default(100)
callback := func(info req.DownloadInfo) {
if info.Response.Response != nil {
bar.Add(1)
}
}
rq.R.SetDownloadCallback(callback)
}
rq := Setup(c, "/file/"+id+"/")
resp, err := rq.R.
SetOutputFile(id).
SetDownloadCallback(callback).
Get(rq.Url)
if err != nil {
return err
}
if !resp.IsSuccessState() {
return fmt.Errorf("bad response: %s", resp.Status)
}
_, params, err := mime.ParseMediaType(resp.Header.Get("Content-Disposition"))
if err != nil {
os.Remove(id)
@@ -278,7 +316,34 @@ func Download(c *cfg.Config, args []string) error {
return fmt.Errorf("\nUnable to rename file: " + err.Error())
}
fmt.Printf("%s successfully downloaded to file %s.", id, cleanfilename)
fmt.Fprintf(w, "%s successfully downloaded to file %s.", id, cleanfilename)
return nil
}
/**** Forms stuff ****/
func CreateForm(w io.Writer, c *cfg.Config) error {
// setup url, req.Request, timeout handling etc
rq := Setup(c, "/forms")
// actual post w/ settings
resp, err := rq.R.
SetFormData(map[string]string{
"expire": c.Expire,
"description": c.Description,
"notify": c.Notify,
}).
Post(rq.Url)
if err != nil {
return err
}
if err := HandleResponse(c, resp); err != nil {
return err
}
return RespondExtended(w, resp)
return nil
}

View File

@@ -19,37 +19,85 @@ package lib
import (
//"github.com/alecthomas/repr"
"bytes"
"fmt"
"github.com/jarcoal/httpmock"
"github.com/tlinden/cenophane/upctl/cfg"
"github.com/tlinden/ephemerup/upctl/cfg"
"io/ioutil"
"net/http"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"testing"
)
const endpoint string = "http://localhost:8080/api/v1"
const endpoint string = "http://localhost:8080/v1"
type Unit struct {
name string
apikey string // set to something else than "token" to fail auth
wantfail bool // true: expect to fail
files []string // path relative to ./t/
sendcode int // for httpmock
sendjson string // struct to respond with
route string // dito
method string // method to use
expect string // regex used to parse the output
sendcode int // for httpmock
sendjson string // struct to respond with
sendfile string // bare file content to be sent
route string // dito
method string // method to use
}
// simulate our cenophane server
// simulate our ephemerup server
func Intercept(tt Unit) {
httpmock.RegisterResponder(tt.method, endpoint+tt.route,
func(request *http.Request) (*http.Response, error) {
respbody := fmt.Sprintf(tt.sendjson)
resp := httpmock.NewStringResponse(tt.sendcode, respbody)
resp.Header.Set("Content-Type", "application/json; charset=utf-8")
var resp *http.Response
if tt.sendfile != "" {
// simulate a file download
content, err := ioutil.ReadFile(tt.sendfile)
if err != nil {
panic(err) // should not happen
}
stat, err := os.Stat(tt.sendfile)
if err != nil {
panic(err) // should not happen as well
}
resp = httpmock.NewStringResponse(tt.sendcode, string(content))
resp.Header.Set("Content-Type", "text/markdown; charset=utf-8")
resp.Header.Set("Content-Length", strconv.Itoa(int(stat.Size())))
resp.Header.Set("Content-Disposition", "attachment; filename='t1'")
} else {
// simulate JSON response
resp = httpmock.NewStringResponse(tt.sendcode, tt.sendjson)
resp.Header.Set("Content-Type", "application/json; charset=utf-8")
}
return resp, nil
})
}
// execute the actual test
func Check(t *testing.T, tt Unit, w *bytes.Buffer, err error) {
testname := fmt.Sprintf("%s-%t", tt.name, tt.wantfail)
if err != nil && !tt.wantfail {
t.Errorf("%s failed! wantfail: %t, error: %s", testname, tt.wantfail, err.Error())
}
if tt.expect != "" {
got := strings.TrimSpace(w.String())
r := regexp.MustCompile(tt.expect)
if !r.MatchString(got) {
t.Errorf("%s failed! error: output does not match!\nexpect: %s\ngot:\n%s", testname, tt.expect, got)
}
}
}
func TestUploadFiles(t *testing.T) {
conf := &cfg.Config{
Mock: true,
@@ -63,42 +111,67 @@ func TestUploadFiles(t *testing.T) {
name: "upload-file",
apikey: "token",
wantfail: false,
route: "/file/",
route: "/uploads",
sendcode: 200,
sendjson: `{"success": true}`,
files: []string{"../t/t1"}, // pwd is lib/ !
method: "POST",
},
{
name: "upload-nonexistent-file",
name: "upload-dir",
apikey: "token",
wantfail: false,
route: "/uploads",
sendcode: 200,
sendjson: `{"success": true}`,
files: []string{"../t"}, // pwd is lib/ !
method: "POST",
},
{
name: "upload-catch-nonexistent-file",
apikey: "token",
wantfail: true,
route: "/file/",
route: "/uploads",
sendcode: 200,
sendjson: `{"success": false}`,
files: []string{"../t/none"},
method: "POST",
},
{
name: "upload-unauth",
name: "upload-catch-no-access",
apikey: "token",
wantfail: true,
route: "/file/",
route: "/uploads",
sendcode: 403,
sendjson: `{"success": false}`,
files: []string{"../t/t1"},
method: "POST",
},
{
name: "upload-check-output",
apikey: "token",
wantfail: false,
route: "/uploads",
sendcode: 200,
sendjson: `{"uploads":[
{
"id":"cc2c965a","expire":"asap","file":"t1","members":["t1"],
"uploaded":1679396814.890502,"context":"foo","url":""
}
],
"success":true,
"message":"Download url: http://localhost:8080/download/cc2c965a/t1",
"code":200}`,
files: []string{"../t/t1"}, // pwd is lib/ !
method: "POST",
expect: "Expire: On first access",
},
}
for _, tt := range tests {
testname := fmt.Sprintf("UploadFiles-%s-%t", tt.name, tt.wantfail)
Intercept(tt)
err := UploadFiles(conf, tt.files)
if err != nil && !tt.wantfail {
t.Errorf("%s failed! wantfail: %t, error: %s", testname, tt.wantfail, err.Error())
}
for _, unit := range tests {
var w bytes.Buffer
Intercept(unit)
Check(t, unit, &w, UploadFiles(&w, conf, unit.files))
}
}
@@ -110,28 +183,227 @@ func TestList(t *testing.T) {
Silent: true,
}
listing := `{"uploads":[{"id":"c8dh","expire":"asap","file":"t1","members":["t1"],"uploaded":1679318969.6434112,"context":"foo","url":""}],"success":true,"message":"","code":200}`
listing := `{"uploads":[
{
"id":"cc2c965a","expire":"asap","file":"t1","members":["t1"],
"uploaded":1679396814.890502,"context":"foo","url":""
}
],
"success":true,
"message":"",
"code":200}`
listingnoaccess := `{"success":false,"message":"invalid context","code":503}`
tests := []Unit{
{
name: "list",
apikey: "token",
wantfail: false,
route: "/list/",
route: "/uploads",
sendcode: 200,
sendjson: listing,
files: []string{},
method: "GET",
expect: `cc2c965a\s*asap\s*foo\s*2023-03-21 12:06:54`, // expect tabular output
},
{
name: "list-catch-empty-json",
apikey: "token",
wantfail: true,
route: "/uploads",
sendcode: 404,
sendjson: "",
files: []string{},
method: "GET",
},
{
name: "list-catch-no-access",
apikey: "token",
wantfail: true,
route: "/uploads",
sendcode: 503,
sendjson: listingnoaccess,
files: []string{},
method: "GET",
},
}
for _, tt := range tests {
testname := fmt.Sprintf("List-%s-%t", tt.name, tt.wantfail)
Intercept(tt)
err := List(conf, []string{})
for _, unit := range tests {
var w bytes.Buffer
Intercept(unit)
Check(t, unit, &w, List(&w, conf, []string{}))
}
}
if err != nil && !tt.wantfail {
t.Errorf("%s failed! wantfail: %t, error: %s", testname, tt.wantfail, err.Error())
}
func TestDescribe(t *testing.T) {
conf := &cfg.Config{
Mock: true,
Apikey: "token",
Endpoint: endpoint,
Silent: true,
}
listing := `{"uploads":[
{
"id":"cc2c965a","expire":"asap","file":"t1","members":["t1"],
"uploaded":1679396814.890502,"context":"foo","url":""
}
],
"success":true,
"message":"",
"code":200}`
listingnoaccess := `{"success":false,"message":"invalid context","code":503}`
tests := []Unit{
{
name: "describe",
apikey: "token",
wantfail: false,
route: "/uploads/",
sendcode: 200,
sendjson: listing,
files: []string{"cc2c965a"},
method: "GET",
expect: `Created: 2023-03-21 12:06:54.890501888`,
},
{
name: "describe-catch-empty-json",
apikey: "token",
wantfail: true,
route: "/uploads/",
sendcode: 200,
sendjson: "",
files: []string{"cc2c965a"},
method: "GET",
},
{
name: "describe-catch-no-access",
apikey: "token",
wantfail: true,
route: "/uploads/",
sendcode: 503,
sendjson: listingnoaccess,
files: []string{"cc2c965a"},
method: "GET",
},
}
for _, unit := range tests {
var w bytes.Buffer
unit.route += unit.files[0]
Intercept(unit)
Check(t, unit, &w, Describe(&w, conf, unit.files))
}
}
func TestDelete(t *testing.T) {
conf := &cfg.Config{
Mock: true,
Apikey: "token",
Endpoint: endpoint,
Silent: true,
}
listingnoaccess := `{"success":false,"message":"invalid context","code":503}`
tests := []Unit{
{
name: "delete",
apikey: "token",
wantfail: false,
route: "/uploads/",
sendcode: 200,
sendjson: `{"success":true,"message":"","code":200}`,
files: []string{"cc2c965a"},
method: "DELETE",
expect: `Upload cc2c965a successfully deleted`,
},
{
name: "delete-catch-empty-json",
apikey: "token",
wantfail: true,
route: "/uploads/",
sendcode: 200,
sendjson: "",
files: []string{"cc2c965a"},
method: "DELETE",
},
{
name: "delete-catch-no-access",
apikey: "token",
wantfail: true,
route: "/uploads/",
sendcode: 503,
sendjson: listingnoaccess,
files: []string{"cc2c965a"},
method: "DELETE",
},
}
for _, unit := range tests {
var w bytes.Buffer
unit.route += unit.files[0] + "/"
Intercept(unit)
Check(t, unit, &w, Delete(&w, conf, unit.files))
}
}
func TestDownload(t *testing.T) {
conf := &cfg.Config{
Mock: true,
Apikey: "token",
Endpoint: endpoint,
Silent: true,
}
listingnoaccess := `{"success":false,"message":"invalid context","code":503}`
tests := []Unit{
{
name: "download",
apikey: "token",
wantfail: false,
route: "/uploads/",
sendcode: 200,
sendfile: "../t/t1",
files: []string{"cc2c965a"},
method: "GET",
expect: `cc2c965a successfully downloaded to file t1`,
},
{
name: "download-catch-empty-response",
apikey: "token",
wantfail: true,
route: "/uploads/",
sendcode: 200,
files: []string{"cc2c965a"},
method: "GET",
},
{
name: "download-catch-no-access",
apikey: "token",
wantfail: true,
route: "/uploads/",
sendcode: 503,
sendjson: listingnoaccess,
files: []string{"cc2c965a"},
method: "GET",
},
}
for _, unit := range tests {
var w bytes.Buffer
unit.route += unit.files[0] + "/file"
Intercept(unit)
Check(t, unit, &w, Download(&w, conf, unit.files))
if unit.sendfile != "" {
file := filepath.Base(unit.sendfile)
if _, err := os.Stat(file); err == nil {
os.Remove(file)
}
}
}
}

View File

@@ -23,8 +23,9 @@ import (
"fmt"
"github.com/imroc/req/v3"
"github.com/olekukonko/tablewriter"
"github.com/tlinden/cenophane/common"
"os"
"github.com/tlinden/ephemerup/common"
"io"
"strings"
"time"
)
@@ -34,15 +35,17 @@ func prepareExpire(expire string, start common.Timestamp) string {
case "asap":
return "On first access"
default:
return time.Unix(start.Unix()+int64(common.Duration2int(expire)), 0).Format("2006-01-02 15:04:05")
return time.Unix(start.Unix()+int64(common.Duration2int(expire)), 0).
Format("2006-01-02 15:04:05")
}
return ""
}
// generic table writer
func WriteTable(headers []string, data [][]string) {
table := tablewriter.NewWriter(os.Stdout)
func WriteTable(w io.Writer, headers []string, data [][]string) {
tableString := &strings.Builder{}
table := tablewriter.NewWriter(tableString)
table.SetHeader(headers)
table.AppendBulk(data)
@@ -60,76 +63,95 @@ func WriteTable(headers []string, data [][]string) {
table.SetNoWhiteSpace(true)
table.Render()
fmt.Fprintln(w, tableString.String())
}
// output like psql \x
func WriteExtended(uploads *common.Uploads) {
/* Print output like psql \x
Prints all Uploads and Forms which exist in common.Response,
however, we expect only one kind of them to be actually filled, so
the function can be used for forms and uploads.
*/
func WriteExtended(w io.Writer, response *common.Response) {
format := fmt.Sprintf("%%%ds: %%s\n", Maxwidth)
// we shall only have 1 element, however, if we ever support more, here we go
for _, entry := range uploads.Entries {
expire := prepareExpire(entry.Expire, entry.Uploaded)
fmt.Printf(format, "Id", entry.Id)
fmt.Printf(format, "Expire", expire)
fmt.Printf(format, "Context", entry.Context)
fmt.Printf(format, "Uploaded", entry.Uploaded)
fmt.Printf(format, "Filename", entry.File)
fmt.Printf(format, "Url", entry.Url)
fmt.Println()
for _, entry := range response.Uploads {
expire := prepareExpire(entry.Expire, entry.Created)
fmt.Fprintf(w, format, "Id", entry.Id)
fmt.Fprintf(w, format, "Expire", expire)
fmt.Fprintf(w, format, "Context", entry.Context)
fmt.Fprintf(w, format, "Created", entry.Created)
fmt.Fprintf(w, format, "Filename", entry.File)
fmt.Fprintf(w, format, "Url", entry.Url)
fmt.Fprintln(w)
}
for _, entry := range response.Forms {
expire := prepareExpire(entry.Expire, entry.Created)
fmt.Fprintf(w, format, "Id", entry.Id)
fmt.Fprintf(w, format, "Expire", expire)
fmt.Fprintf(w, format, "Context", entry.Context)
fmt.Fprintf(w, format, "Created", entry.Created)
fmt.Fprintf(w, format, "Description", entry.Description)
fmt.Fprintf(w, format, "Notify", entry.Notify)
fmt.Fprintf(w, format, "Url", entry.Url)
fmt.Fprintln(w)
}
}
// extract an common.Uploads{} struct from json response
func GetUploadsFromResponse(resp *req.Response) (*common.Uploads, error) {
uploads := common.Uploads{}
func GetResponse(resp *req.Response) (*common.Response, error) {
response := common.Response{}
if err := json.Unmarshal([]byte(resp.String()), &uploads); err != nil {
if err := json.Unmarshal([]byte(resp.String()), &response); err != nil {
return nil, errors.New("Could not unmarshall JSON response: " + err.Error())
}
if !uploads.Success {
return nil, errors.New(uploads.Message)
if !response.Success {
return nil, errors.New(response.Message)
}
return &uploads, nil
return &response, nil
}
// turn the Uploads{} struct into a table and print it
func RespondTable(resp *req.Response) error {
uploads, err := GetUploadsFromResponse(resp)
func UploadsRespondTable(w io.Writer, resp *req.Response) error {
response, err := GetResponse(resp)
if err != nil {
return err
}
if uploads.Message != "" {
fmt.Println(uploads.Message)
if response.Message != "" {
fmt.Fprintln(w, response.Message)
}
// tablewriter
data := [][]string{}
for _, entry := range uploads.Entries {
for _, entry := range response.Uploads {
data = append(data, []string{
entry.Id, entry.Expire, entry.Context, entry.Uploaded.Format("2006-01-02 15:04:05"),
entry.Id, entry.Expire, entry.Context, entry.Created.Format("2006-01-02 15:04:05"),
})
}
WriteTable([]string{"ID", "EXPIRE", "CONTEXT", "UPLOADED"}, data)
WriteTable(w, []string{"ID", "EXPIRE", "CONTEXT", "CREATED"}, data)
return nil
}
// turn the Uploads{} struct into xtnd output and print it
func RespondExtended(resp *req.Response) error {
uploads, err := GetUploadsFromResponse(resp)
func RespondExtended(w io.Writer, resp *req.Response) error {
response, err := GetResponse(resp)
if err != nil {
return err
}
if uploads.Message != "" {
fmt.Println(uploads.Message)
if response.Message != "" {
fmt.Fprintln(w, response.Message)
}
WriteExtended(uploads)
WriteExtended(w, response)
return nil
}

View File

@@ -18,7 +18,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
package main
import (
"github.com/tlinden/cenophane/upctl/cmd"
"github.com/tlinden/ephemerup/upctl/cmd"
)
func main() {

View File

@@ -1,2 +1,2 @@
endpoint = "http://localhost:8080/api/v1"
endpoint = "http://localhost:8080/v1"
apikey = "970b391f22f515d96b3e9b86a2c62c627968828e47b356994d2e583188b4190a"