put shared code into own mod (common), + apicontext env vars

This commit is contained in:
2023-03-19 12:33:15 +01:00
parent a786fd56f4
commit 96c6f0c2dc
20 changed files with 295 additions and 279 deletions

View File

@@ -1,6 +1,22 @@
# Cenophane # Cenophane
Simple standalone file upload server with expiration Simple standalone file upload server with expiration
## Features
- RESTful API
- Authentication and Authorization through bearer api token
- multiple tenants supported (tenant == api context)
- Each upload gets its own unique id
- download uri is public, no api required, it is intended for end users
- uploads may consist of one or multiple files
- zipped automatically
- uploads expire, either as soon as it gets downloaded or when a timer runs out
- the command line client uses the api
- configuration using HCL language
- docker container build available
- the server supports config by config file, environment variables or flags
- restrictive defaults
## Server Usage ## Server Usage
``` ```
@@ -12,6 +28,7 @@ cenod -h
-c, --config string custom config file -c, --config string custom config file
-D, --dbfile string Bold database file to use (default "/tmp/uploads.db") -D, --dbfile string Bold database file to use (default "/tmp/uploads.db")
-d, --debug Enable debugging -d, --debug Enable debugging
--frontpage string Content or filename to be displayed on / in case someone visits (default "welcome to upload api, use /api enpoint!")
-4, --ipv4 Only listen on ipv4 -4, --ipv4 Only listen on ipv4
-6, --ipv6 Only listen on ipv6 -6, --ipv6 Only listen on ipv6
-l, --listen string listen to custom ip:port (use [ip]:port for ipv6) (default ":8080") -l, --listen string listen to custom ip:port (use [ip]:port for ipv6) (default ":8080")
@@ -22,6 +39,46 @@ cenod -h
-v, --version Print program version -v, --version Print program version
``` ```
All flags can be set using environment variables, prefix the flag with `CENOD_` and uppercase it, eg:
```
CENOD_LISTEN=:8080
```
In addition it is possible to set api contexts using env vars (otherwise only possible using the config file):
```
CENOD_CONTEXT_SUPPORT="support:tymag-fycyh-gymof-dysuf-doseb-puxyx"
CENOD_CONTEXT_FOOBAR="foobar:U3VuIE1hciAxOSAxMjoyNTo1NyBQTSBDRVQgMjAyMwo"
```
Configuration can also be done using a config file (searched in the following locations):
- `/etc/cenod.hcl`
- `/usr/local/etc/cenod.hcl`
- `~/.config/cenod/cenod.hcl`
- `~/.cenod`
- `$(pwd)/cenod.hcl`
Or using the flag `-c`. Sample config file:
```
listen = ":8080"
bodylimit = 10000
apicontext = [
{
context = "root"
key = "0fddbff5d8010f81cd28a7d77f3e38981b13d6164c2fd6e1c3f60a4287630c37",
},
{
context = "foo",
key = "970b391f22f515d96b3e9b86a2c62c627968828e47b356994d2e583188b4190a"
}
]
#url = "https://sokrates.daemon.de"
# this is the root context with all permissions
super = "root"
```
## Client Usage ## Client Usage
``` ```
@@ -52,33 +109,28 @@ Flags:
Use "upctl [command] --help" for more information about a command. Use "upctl [command] --help" for more information about a command.
``` ```
## Features The client must be configured using a config file. The following locations are searched for it:
- `$(pwd)/upctl.hcl`
- `~/.config/upctl/upctl.hcl`
Sample config file for a client:
```
endpoint = "http://localhost:8080/api/v1"
apikey = "970b391f22f515d96b3e9b86a2c62c627968828e47b356994d2e583188b4190a"
```
- RESTful API
- Authentication and Authorization through bearer api token
- multiple tenants supported (tenant == api context)
- Each upload gets its own unique id
- download uri is public, no api required, it is intended for end users
- uploads may consist of one or multiple files
- zipped automatically
- uploads expire, either as soon as it gets downloaded or when a timer runs out
- the command line client uses the api
- configuration using HCL language
- docker container build available
- the server supports config by config file, environment variables or flags
- restrictive defaults
## TODO ## TODO
- also serve a html upload page - also serve a html upload page
- add metrics (as in https://github.com/ansrivas/fiberprometheus) - add metrics (as in https://github.com/ansrivas/fiberprometheus)
- add authorization checks for delete and list based on apicontext
- do not manually generate output urls, use fiber.GetRoute() - do not manually generate output urls, use fiber.GetRoute()
- import code from upd into upctl to avoid duplicates, like the time stuff we've now
- upd: https://docs.gofiber.io/guide/error-handling/ to always use json output - upd: https://docs.gofiber.io/guide/error-handling/ to always use json output
- upctl: get rid of HandleResponse(), used only once anyway - upctl: get rid of HandleResponse(), used only once anyway
- add form so that public users can upload - add form so that public users can upload
- add support for custom front page
## BUGS ## BUGS

View File

@@ -22,6 +22,7 @@ import (
//"github.com/alecthomas/repr" //"github.com/alecthomas/repr"
"encoding/json" "encoding/json"
"github.com/tlinden/cenophane/cfg" "github.com/tlinden/cenophane/cfg"
"github.com/tlinden/cenophane/common"
bolt "go.etcd.io/bbolt" bolt "go.etcd.io/bbolt"
"path/filepath" "path/filepath"
"time" "time"
@@ -36,7 +37,7 @@ func DeleteExpiredUploads(conf *cfg.Config, db *Db) error {
} }
err := bucket.ForEach(func(id, j []byte) error { err := bucket.ForEach(func(id, j []byte) error {
upload := &Upload{} upload := &common.Upload{}
if err := json.Unmarshal(j, &upload); err != nil { if err := json.Unmarshal(j, &upload); err != nil {
return fmt.Errorf("unable to unmarshal json: %s", err) return fmt.Errorf("unable to unmarshal json: %s", err)
} }

View File

@@ -21,6 +21,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"github.com/tlinden/cenophane/cfg" "github.com/tlinden/cenophane/cfg"
"github.com/tlinden/cenophane/common"
//"github.com/alecthomas/repr" //"github.com/alecthomas/repr"
bolt "go.etcd.io/bbolt" bolt "go.etcd.io/bbolt"
) )
@@ -43,7 +44,7 @@ func (db *Db) Close() {
db.bolt.Close() db.bolt.Close()
} }
func (db *Db) Insert(id string, entry *Upload) error { func (db *Db) Insert(id string, entry *common.Upload) error {
err := db.bolt.Update(func(tx *bolt.Tx) error { err := db.bolt.Update(func(tx *bolt.Tx) error {
bucket, err := tx.CreateBucketIfNotExists([]byte(Bucket)) bucket, err := tx.CreateBucketIfNotExists([]byte(Bucket))
if err != nil { if err != nil {
@@ -86,7 +87,7 @@ func (db *Db) Delete(apicontext string, id string) error {
return fmt.Errorf("id %s not found", id) return fmt.Errorf("id %s not found", id)
} }
upload := &Upload{} upload := &common.Upload{}
if err := json.Unmarshal(j, &upload); err != nil { if err := json.Unmarshal(j, &upload); err != nil {
return fmt.Errorf("unable to unmarshal json: %s", err) return fmt.Errorf("unable to unmarshal json: %s", err)
} }
@@ -105,8 +106,8 @@ func (db *Db) Delete(apicontext string, id string) error {
return err return err
} }
func (db *Db) List(apicontext string, filter string) (*Uploads, error) { func (db *Db) List(apicontext string, filter string) (*common.Uploads, error) {
uploads := &Uploads{} uploads := &common.Uploads{}
err := db.bolt.View(func(tx *bolt.Tx) error { err := db.bolt.View(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(Bucket)) bucket := tx.Bucket([]byte(Bucket))
@@ -115,7 +116,7 @@ func (db *Db) List(apicontext string, filter string) (*Uploads, error) {
} }
err := bucket.ForEach(func(id, j []byte) error { err := bucket.ForEach(func(id, j []byte) error {
upload := &Upload{} upload := &common.Upload{}
if err := json.Unmarshal(j, &upload); err != nil { if err := json.Unmarshal(j, &upload); err != nil {
return fmt.Errorf("unable to unmarshal json: %s", err) return fmt.Errorf("unable to unmarshal json: %s", err)
} }
@@ -146,8 +147,8 @@ func (db *Db) List(apicontext string, filter string) (*Uploads, error) {
} }
// we only return one obj here, but could return more later // we only return one obj here, but could return more later
func (db *Db) Get(apicontext string, id string) (*Uploads, error) { func (db *Db) Get(apicontext string, id string) (*common.Uploads, error) {
uploads := &Uploads{} uploads := &common.Uploads{}
err := db.bolt.View(func(tx *bolt.Tx) error { err := db.bolt.View(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(Bucket)) bucket := tx.Bucket([]byte(Bucket))
@@ -160,7 +161,7 @@ func (db *Db) Get(apicontext string, id string) (*Uploads, error) {
return fmt.Errorf("No upload object found with id %s", id) return fmt.Errorf("No upload object found with id %s", id)
} }
upload := &Upload{} upload := &common.Upload{}
if err := json.Unmarshal(j, &upload); err != nil { if err := json.Unmarshal(j, &upload); err != nil {
return fmt.Errorf("unable to unmarshal json: %s", err) return fmt.Errorf("unable to unmarshal json: %s", err)
} }
@@ -178,16 +179,16 @@ func (db *Db) Get(apicontext string, id string) (*Uploads, error) {
} }
// a wrapper around Lookup() which extracts the 1st upload, if any // a wrapper around Lookup() which extracts the 1st upload, if any
func (db *Db) Lookup(apicontext string, id string) (*Upload, error) { func (db *Db) Lookup(apicontext string, id string) (*common.Upload, error) {
uploads, err := db.Get(apicontext, id) uploads, err := db.Get(apicontext, id)
if err != nil { if err != nil {
// non existent db entry with that id, or other db error, see logs // non existent db entry with that id, or other db error, see logs
return &Upload{}, fmt.Errorf("No upload object found with id %s", id) return &common.Upload{}, fmt.Errorf("No upload object found with id %s", id)
} }
if len(uploads.Entries) == 0 { if len(uploads.Entries) == 0 {
return &Upload{}, fmt.Errorf("No upload object found with id %s", id) return &common.Upload{}, fmt.Errorf("No upload object found with id %s", id)
} }
return uploads.Entries[0], nil return uploads.Entries[0], nil

View File

@@ -22,6 +22,7 @@ import (
"errors" "errors"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"github.com/tlinden/cenophane/cfg" "github.com/tlinden/cenophane/cfg"
"github.com/tlinden/cenophane/common"
"io" "io"
"mime/multipart" "mime/multipart"
"os" "os"
@@ -43,7 +44,7 @@ func cleanup(dir string) {
func SaveFormFiles(c *fiber.Ctx, cfg *cfg.Config, files []*multipart.FileHeader, id string) ([]string, error) { func SaveFormFiles(c *fiber.Ctx, cfg *cfg.Config, files []*multipart.FileHeader, id string) ([]string, error) {
members := []string{} members := []string{}
for _, file := range files { for _, file := range files {
filename, _ := Untaint(filepath.Base(file.Filename), cfg.RegNormalizedFilename) filename, _ := common.Untaint(filepath.Base(file.Filename), cfg.RegNormalizedFilename)
path := filepath.Join(cfg.StorageDir, id, filename) path := filepath.Join(cfg.StorageDir, id, filename)
members = append(members, filename) members = append(members, filename)
Log("Received: %s => %s/%s", file.Filename, id, filename) Log("Received: %s => %s/%s", file.Filename, id, filename)

View File

@@ -22,6 +22,7 @@ import (
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/tlinden/cenophane/cfg" "github.com/tlinden/cenophane/cfg"
"github.com/tlinden/cenophane/common"
"os" "os"
"path/filepath" "path/filepath"
@@ -61,7 +62,7 @@ func FilePut(c *fiber.Ctx, cfg *cfg.Config, db *Db) error {
} }
// init upload obj // init upload obj
entry := &Upload{Id: id, Uploaded: Timestamp{Time: time.Now()}} entry := &common.Upload{Id: id, Uploaded: common.Timestamp{Time: time.Now()}}
// retrieve the API Context name from the session // retrieve the API Context name from the session
apicontext, err := GetApicontext(c) apicontext, err := GetApicontext(c)
@@ -90,7 +91,7 @@ func FilePut(c *fiber.Ctx, cfg *cfg.Config, db *Db) error {
if len(formdata.Expire) == 0 { if len(formdata.Expire) == 0 {
entry.Expire = "asap" entry.Expire = "asap"
} else { } else {
ex, err := Untaint(formdata.Expire, cfg.RegDuration) // duration or asap allowed ex, err := common.Untaint(formdata.Expire, cfg.RegDuration) // duration or asap allowed
if err != nil { if err != nil {
return JsonStatus(c, fiber.StatusForbidden, return JsonStatus(c, fiber.StatusForbidden,
"Invalid data: "+err.Error()) "Invalid data: "+err.Error())
@@ -114,7 +115,7 @@ func FilePut(c *fiber.Ctx, cfg *cfg.Config, db *Db) error {
go db.Insert(id, entry) go db.Insert(id, entry)
// everything went well so far // everything went well so far
res := &Uploads{Entries: []*Upload{entry}} res := &common.Uploads{Entries: []*common.Upload{entry}}
res.Success = true res.Success = true
res.Message = "Download url: " + returnUrl res.Message = "Download url: " + returnUrl
res.Code = fiber.StatusOK res.Code = fiber.StatusOK
@@ -126,7 +127,7 @@ func FileGet(c *fiber.Ctx, cfg *cfg.Config, db *Db, shallExpire ...bool) error {
// we ignore c.Params("file"), cause it may be malign. Also we've // we ignore c.Params("file"), cause it may be malign. Also we've
// got it in the db anyway // got it in the db anyway
id, err := Untaint(c.Params("id"), cfg.RegKey) id, err := common.Untaint(c.Params("id"), cfg.RegKey)
if err != nil { if err != nil {
return fiber.NewError(403, "Invalid id provided!") return fiber.NewError(403, "Invalid id provided!")
} }
@@ -174,7 +175,7 @@ func FileGet(c *fiber.Ctx, cfg *cfg.Config, db *Db, shallExpire ...bool) error {
// delete file, id dir and db entry // delete file, id dir and db entry
func DeleteUpload(c *fiber.Ctx, cfg *cfg.Config, db *Db) error { func DeleteUpload(c *fiber.Ctx, cfg *cfg.Config, db *Db) error {
id, err := Untaint(c.Params("id"), cfg.RegKey) id, err := common.Untaint(c.Params("id"), cfg.RegKey)
if err != nil { if err != nil {
return JsonStatus(c, fiber.StatusForbidden, return JsonStatus(c, fiber.StatusForbidden,
"Invalid id provided!") "Invalid id provided!")
@@ -213,7 +214,7 @@ func List(c *fiber.Ctx, cfg *cfg.Config, db *Db) error {
"Unable to parse body: "+err.Error()) "Unable to parse body: "+err.Error())
} }
filter, err := Untaint(setcontext.Apicontext, cfg.RegKey) filter, err := common.Untaint(setcontext.Apicontext, cfg.RegKey)
if err != nil { if err != nil {
return JsonStatus(c, fiber.StatusForbidden, return JsonStatus(c, fiber.StatusForbidden,
"Invalid api context filter provided!") "Invalid api context filter provided!")
@@ -242,7 +243,7 @@ func List(c *fiber.Ctx, cfg *cfg.Config, db *Db) error {
// returns just one upload obj + error code, no post processing by server // returns just one upload obj + error code, no post processing by server
func Describe(c *fiber.Ctx, cfg *cfg.Config, db *Db) error { func Describe(c *fiber.Ctx, cfg *cfg.Config, db *Db) error {
id, err := Untaint(c.Params("id"), cfg.RegKey) id, err := common.Untaint(c.Params("id"), cfg.RegKey)
if err != nil { if err != nil {
return JsonStatus(c, fiber.StatusForbidden, return JsonStatus(c, fiber.StatusForbidden,
"Invalid id provided!") "Invalid id provided!")

View File

@@ -27,6 +27,7 @@ import (
"github.com/gofiber/fiber/v2/middleware/session" "github.com/gofiber/fiber/v2/middleware/session"
"github.com/gofiber/keyauth/v2" "github.com/gofiber/keyauth/v2"
"github.com/tlinden/cenophane/cfg" "github.com/tlinden/cenophane/cfg"
"github.com/tlinden/cenophane/common"
) )
// sessions are context specific and can be global savely // sessions are context specific and can be global savely
@@ -87,7 +88,7 @@ func Runserver(conf *cfg.Config, args []string) error {
// public routes // public routes
{ {
router.Get("/", func(c *fiber.Ctx) error { router.Get("/", func(c *fiber.Ctx) error {
return c.Send([]byte("welcome to upload api, use /api enpoint!")) return c.Send([]byte(conf.Frontpage))
}) })
router.Get("/download/:id/:file", func(c *fiber.Ctx) error { router.Get("/download/:id/:file", func(c *fiber.Ctx) error {
@@ -113,7 +114,7 @@ func Runserver(conf *cfg.Config, args []string) error {
func SetupAuthStore(conf *cfg.Config) func(*fiber.Ctx) error { func SetupAuthStore(conf *cfg.Config) func(*fiber.Ctx) error {
AuthSetEndpoints(conf.ApiPrefix, ApiVersion, []string{"/file"}) AuthSetEndpoints(conf.ApiPrefix, ApiVersion, []string{"/file"})
AuthSetApikeys(conf.Apicontext) AuthSetApikeys(conf.Apicontexts)
return keyauth.New(keyauth.Config{ return keyauth.New(keyauth.Config{
Validator: AuthValidateAPIKey, Validator: AuthValidateAPIKey,
@@ -162,7 +163,7 @@ func JsonStatus(c *fiber.Ctx, code int, msg string) error {
success = false success = false
} }
return c.Status(code).JSON(Result{ return c.Status(code).JSON(common.Result{
Code: code, Code: code,
Message: msg, Message: msg,
Success: success, Success: success,
@@ -181,14 +182,14 @@ func SendResponse(c *fiber.Ctx, msg string, err error) error {
code = e.Code code = e.Code
} }
return c.Status(code).JSON(Result{ return c.Status(code).JSON(common.Result{
Code: code, Code: code,
Message: err.Error(), Message: err.Error(),
Success: false, Success: false,
}) })
} }
return c.Status(fiber.StatusOK).JSON(Result{ return c.Status(fiber.StatusOK).JSON(common.Result{
Code: fiber.StatusOK, Code: fiber.StatusOK,
Message: msg, Message: msg,
Success: true, Success: true,

View File

@@ -1,63 +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 api
import (
"strconv"
"time"
)
// https://gist.github.com/rhcarvalho/9338c3ff8850897c68bc74797c5dc25b
// Timestamp is like time.Time, but knows how to unmarshal from JSON
// Unix timestamp numbers or RFC3339 strings, and marshal back into
// the same JSON representation.
type Timestamp struct {
time.Time
rfc3339 bool
}
func (t Timestamp) MarshalJSON() ([]byte, error) {
if t.rfc3339 {
return t.Time.MarshalJSON()
}
return t.formatUnix()
}
func (t *Timestamp) UnmarshalJSON(data []byte) error {
err := t.Time.UnmarshalJSON(data)
if err != nil {
return t.parseUnix(data)
}
t.rfc3339 = true
return nil
}
func (t Timestamp) formatUnix() ([]byte, error) {
sec := float64(t.Time.UnixNano()) * float64(time.Nanosecond) / float64(time.Second)
return strconv.AppendFloat(nil, sec, 'f', -1, 64), nil
}
func (t *Timestamp) parseUnix(data []byte) error {
f, err := strconv.ParseFloat(string(data), 64)
if err != nil {
return err
}
t.Time = time.Unix(0, int64(f*float64(time.Second/time.Nanosecond)))
return nil
}

View File

@@ -18,48 +18,20 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
package api package api
import ( import (
"errors"
"fmt" "fmt"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"github.com/tlinden/cenophane/cfg" "github.com/tlinden/cenophane/cfg"
"regexp" "github.com/tlinden/cenophane/common"
"strconv"
"time" "time"
) )
const ApiVersion string = "/v1" const ApiVersion string = "/v1"
// used to return to the api client
type Result struct {
Success bool
Message string
Code int
}
// Binding from JSON, data coming from user, not tainted // Binding from JSON, data coming from user, not tainted
type Meta struct { type Meta struct {
Expire string `json:"expire" form:"expire"` Expire string `json:"expire" form:"expire"`
} }
// stores 1 upload object, gets into db
type Upload struct {
Id string `json:"id"`
Expire string `json:"expire"`
File string `json:"file"` // final filename (visible to the downloader)
Members []string `json:"members"` // contains multiple files, so File is an archive
Uploaded Timestamp `json:"uploaded"`
Context string `json:"context"`
Url string `json:"url"`
}
// this one is also used for marshalling to the client
type Uploads struct {
Entries []*Upload `json:"uploads"`
// integrate the Result struct so we can signal success
Result
}
// incoming id // incoming id
type Id struct { type Id struct {
Id string `json:"name" xml:"name" form:"name"` Id string `json:"name" xml:"name" form:"name"`
@@ -75,88 +47,6 @@ func Ts() string {
return t.Format("2006-01-02-15-04-") return t.Format("2006-01-02-15-04-")
} }
/*
We could use time.ParseDuration(), but this doesn't support days.
We could also use github.com/xhit/go-str2duration/v2, which does
the job, but it's just another dependency, just for this little
gem. And we don't need a time.Time value.
Convert a duration into seconds (int).
Valid time units are "s", "m", "h" and "d".
*/
func duration2int(duration string) int {
re := regexp.MustCompile(`(\d+)([dhms])`)
seconds := 0
for _, match := range re.FindAllStringSubmatch(duration, -1) {
if len(match) == 3 {
v, _ := strconv.Atoi(match[1])
switch match[2][0] {
case 'd':
seconds += v * 86400
case 'h':
seconds += v * 3600
case 'm':
seconds += v * 60
case 's':
seconds += v
}
}
}
return seconds
}
/*
Calculate if time is up based on start time.Time and
duration. Returns true if time is expired. Start time comes from
the database.
aka:
if(now - start) >= duration { time is up}
*/
func IsExpired(conf *cfg.Config, start time.Time, duration string) bool {
var expiretime int // seconds
now := time.Now()
if duration == "asap" {
expiretime = conf.DefaultExpire
} else {
expiretime = duration2int(duration)
}
if now.Unix()-start.Unix() >= int64(expiretime) {
return true
}
return false
}
/*
Untaint user input, that is: remove all non supported chars.
wanted is a regexp matching chars we shall leave. Everything else
will be removed. Eg:
untainted := Untaint(input, `[^a-zA-Z0-9\-]`)
Returns a new string and an error if the input string has been
modified. It's the callers choice to decide what to do about
it. You may ignore the error and use the untainted string or bail
out.
*/
func Untaint(input string, wanted *regexp.Regexp) (string, error) {
untainted := wanted.ReplaceAllString(input, "")
if len(untainted) != len(input) {
return untainted, errors.New("Invalid input string!")
}
return untainted, nil
}
/* /*
Retrieve the API Context name from the session, assuming is has Retrieve the API Context name from the session, assuming is has
been successfully authenticated. However, if there are no api been successfully authenticated. However, if there are no api
@@ -178,3 +68,29 @@ func GetApicontext(c *fiber.Ctx) (string, error) {
return "", nil return "", nil
} }
/*
Calculate if time is up based on start time.Time and
duration. Returns true if time is expired. Start time comes from
the database.
aka:
if(now - start) >= duration { time is up}
*/
func IsExpired(conf *cfg.Config, start time.Time, duration string) bool {
var expiretime int // seconds
now := time.Now()
if duration == "asap" {
expiretime = conf.DefaultExpire
} else {
expiretime = common.Duration2int(duration)
}
if now.Unix()-start.Unix() >= int64(expiretime) {
return true
}
return false
}

View File

@@ -40,7 +40,8 @@ type Config struct {
StorageDir string `koanf:"storagedir"` // db and uploads go there StorageDir string `koanf:"storagedir"` // db and uploads go there
Url string `koanf:"url"` // public visible url, might be different from Listen Url string `koanf:"url"` // public visible url, might be different from Listen
DbFile string `koanf:"dbfile"` DbFile string `koanf:"dbfile"`
Super string `koanf:"super"` // the apicontext which has all permissions Super string `koanf:"super"` // the apicontext which has all permissions
Frontpage string `koanf:"frontpage"` // a html file
// fiber settings, see: // fiber settings, see:
// https://docs.gofiber.io/api/fiber/#config // https://docs.gofiber.io/api/fiber/#config
@@ -52,7 +53,7 @@ type Config struct {
Network string Network string
// only settable via config // only settable via config
Apicontext []Apicontext `koanf:"apicontext"` Apicontexts []Apicontext `koanf:"apicontext"`
// Internals only // Internals only
RegNormalizedFilename *regexp.Regexp RegNormalizedFilename *regexp.Regexp

View File

@@ -32,6 +32,7 @@ import (
"github.com/tlinden/cenophane/api" "github.com/tlinden/cenophane/api"
"github.com/tlinden/cenophane/cfg" "github.com/tlinden/cenophane/cfg"
"io/ioutil"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -62,6 +63,8 @@ func Execute() error {
f.StringVarP(&conf.Url, "url", "u", "", "HTTP endpoint w/o path") f.StringVarP(&conf.Url, "url", "u", "", "HTTP endpoint w/o path")
f.StringVarP(&conf.DbFile, "dbfile", "D", "/tmp/uploads.db", "Bold database file to use") f.StringVarP(&conf.DbFile, "dbfile", "D", "/tmp/uploads.db", "Bold database file to use")
f.StringVarP(&conf.Super, "super", "", "", "The API Context which has permissions on all contexts") f.StringVarP(&conf.Super, "super", "", "", "The API Context which has permissions on all contexts")
f.StringVarP(&conf.Frontpage, "frontpage", "", "welcome to upload api, use /api enpoint!",
"Content or filename to be displayed on / in case someone visits")
// server settings // server settings
f.BoolVarP(&conf.V4only, "ipv4", "4", false, "Only listen on ipv4") f.BoolVarP(&conf.V4only, "ipv4", "4", false, "Only listen on ipv4")
@@ -70,7 +73,6 @@ func Execute() error {
f.BoolVarP(&conf.Prefork, "prefork", "p", false, "Prefork server threads") f.BoolVarP(&conf.Prefork, "prefork", "p", false, "Prefork server threads")
f.StringVarP(&conf.AppName, "appname", "n", "cenod "+conf.GetVersion(), "App name to say hi as") f.StringVarP(&conf.AppName, "appname", "n", "cenod "+conf.GetVersion(), "App name to say hi as")
f.IntVarP(&conf.BodyLimit, "bodylimit", "b", 10250000000, "Max allowed upload size in bytes") f.IntVarP(&conf.BodyLimit, "bodylimit", "b", 10250000000, "Max allowed upload size in bytes")
f.StringSliceP("apikeys", "", []string{}, "Api key[s] to allow access")
f.Parse(os.Args[1:]) f.Parse(os.Args[1:])
@@ -119,10 +121,27 @@ func Execute() error {
// fetch values // fetch values
k.Unmarshal("", &conf) k.Unmarshal("", &conf)
// there may exist some api context variables
GetApicontextsFromEnv(&conf)
if conf.Debug { if conf.Debug {
repr.Print(conf) repr.Print(conf)
} }
// Frontpage?
if conf.Frontpage != "" {
if _, err := os.Stat(conf.Frontpage); err == nil {
// it's a filename, try to use it
content, err := ioutil.ReadFile(conf.Frontpage)
if err != nil {
return errors.New("error loading config: " + err.Error())
}
// replace the filename
conf.Frontpage = string(content)
}
}
switch { switch {
case ShowVersion: case ShowVersion:
fmt.Println(cfg.Getversion()) fmt.Println(cfg.Getversion())
@@ -132,3 +151,37 @@ func Execute() error {
return api.Runserver(&conf, flag.Args()) return api.Runserver(&conf, flag.Args())
} }
} }
/*
Get a list of Api Contexts from ENV. Useful for use with k8s secrets.
Multiple env vars are supported in this format:
CENOD_CONTEXT_$(NAME)="<context>:<key>"
eg:
CENOD_CONTEXT_SUPPORT="support:tymag-fycyh-gymof-dysuf-doseb-puxyx"
^^^^^^^- doesn't matter.
Modifies cfg.Config directly
*/
func GetApicontextsFromEnv(conf *cfg.Config) {
contexts := []cfg.Apicontext{}
for _, envvar := range os.Environ() {
pair := strings.SplitN(envvar, "=", 2)
if strings.HasPrefix(pair[0], "CENOD_CONTEXT_") {
c := strings.SplitN(pair[1], ":", 2)
if len(c) == 2 {
contexts = append(contexts, cfg.Apicontext{Context: c[0], Key: c[1]})
}
}
}
for _, ap := range conf.Apicontexts {
contexts = append(contexts, ap)
}
conf.Apicontexts = contexts
}

View File

@@ -1,4 +1,4 @@
package api package common
import ( import (
"fmt" "fmt"

3
common/go.mod Normal file
View File

@@ -0,0 +1,3 @@
module github.com/tlinden/cenophane/common
go 1.18

View File

@@ -15,9 +15,7 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>. along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
package lib package common
// FIXME: import from upd!!!!
import ( import (
"regexp" "regexp"
@@ -75,7 +73,7 @@ func (t *Timestamp) parseUnix(data []byte) error {
Convert a duration into seconds (int). Convert a duration into seconds (int).
Valid time units are "s", "m", "h" and "d". Valid time units are "s", "m", "h" and "d".
*/ */
func duration2int(duration string) int { func Duration2int(duration string) int {
re := regexp.MustCompile(`(\d+)([dhms])`) re := regexp.MustCompile(`(\d+)([dhms])`)
seconds := 0 seconds := 0

43
common/types.go Normal file
View File

@@ -0,0 +1,43 @@
/*
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 common
// used to return to the api client
type Result struct {
Success bool `json:"success"`
Message string `json:"message"`
Code int `json:"code"`
}
type Upload struct {
Id string `json:"id"`
Expire string `json:"expire"`
File string `json:"file"` // final filename (visible to the downloader)
Members []string `json:"members"` // contains multiple files, so File is an archive
Uploaded Timestamp `json:"uploaded"`
Context string `json:"context"`
Url string `json:"url"`
}
// this one is also used for marshalling to the client
type Uploads struct {
Entries []*Upload `json:"uploads"`
// integrate the Result struct so we can signal success
Result
}

46
common/utils.go Normal file
View File

@@ -0,0 +1,46 @@
/*
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 common
import (
"errors"
"regexp"
)
/*
Untaint user input, that is: remove all non supported chars.
wanted is a regexp matching chars we shall leave. Everything else
will be removed. Eg:
untainted := Untaint(input, `[^a-zA-Z0-9\-]`)
Returns a new string and an error if the input string has been
modified. It's the callers choice to decide what to do about
it. You may ignore the error and use the untainted string or bail
out.
*/
func Untaint(input string, wanted *regexp.Regexp) (string, error) {
untainted := wanted.ReplaceAllString(input, "")
if len(untainted) != len(input) {
return untainted, errors.New("Invalid input string!")
}
return untainted, nil
}

3
go.mod
View File

@@ -13,6 +13,7 @@ require (
github.com/knadh/koanf/providers/posflag v0.1.0 github.com/knadh/koanf/providers/posflag v0.1.0
github.com/knadh/koanf/v2 v2.0.0 github.com/knadh/koanf/v2 v2.0.0
github.com/spf13/pflag v1.0.5 github.com/spf13/pflag v1.0.5
github.com/tlinden/cenophane/common v0.0.0-00010101000000-000000000000
go.etcd.io/bbolt v1.3.7 go.etcd.io/bbolt v1.3.7
) )
@@ -38,3 +39,5 @@ require (
github.com/valyala/tcplisten v1.0.0 // indirect github.com/valyala/tcplisten v1.0.0 // indirect
golang.org/x/sys v0.4.0 // indirect golang.org/x/sys v0.4.0 // indirect
) )
replace github.com/tlinden/cenophane/common => ./common

View File

@@ -4,13 +4,15 @@ go 1.18
require ( require (
github.com/imroc/req/v3 v3.32.0 github.com/imroc/req/v3 v3.32.0
github.com/olekukonko/tablewriter v0.0.5
github.com/schollz/progressbar/v3 v3.13.1
github.com/spf13/cobra v1.6.1 github.com/spf13/cobra v1.6.1
github.com/spf13/pflag v1.0.5 github.com/spf13/pflag v1.0.5
github.com/spf13/viper v1.15.0 github.com/spf13/viper v1.15.0
github.com/tlinden/cenophane/common v0.0.0-00010101000000-000000000000
) )
require ( require (
github.com/alecthomas/repr v0.2.0 // indirect
github.com/fsnotify/fsnotify v1.6.0 // indirect github.com/fsnotify/fsnotify v1.6.0 // indirect
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0 // indirect github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0 // indirect
github.com/golang/mock v1.6.0 // indirect github.com/golang/mock v1.6.0 // indirect
@@ -23,7 +25,6 @@ require (
github.com/mattn/go-runewidth v0.0.14 // indirect github.com/mattn/go-runewidth v0.0.14 // indirect
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/olekukonko/tablewriter v0.0.5 // indirect
github.com/onsi/ginkgo/v2 v2.2.0 // indirect github.com/onsi/ginkgo/v2 v2.2.0 // indirect
github.com/pelletier/go-toml/v2 v2.0.6 // indirect github.com/pelletier/go-toml/v2 v2.0.6 // indirect
github.com/quic-go/qpack v0.4.0 // indirect github.com/quic-go/qpack v0.4.0 // indirect
@@ -32,7 +33,6 @@ require (
github.com/quic-go/qtls-go1-20 v0.1.0 // indirect github.com/quic-go/qtls-go1-20 v0.1.0 // indirect
github.com/quic-go/quic-go v0.32.0 // indirect github.com/quic-go/quic-go v0.32.0 // indirect
github.com/rivo/uniseg v0.2.0 // indirect github.com/rivo/uniseg v0.2.0 // indirect
github.com/schollz/progressbar/v3 v3.13.1 // indirect
github.com/spf13/afero v1.9.3 // indirect github.com/spf13/afero v1.9.3 // indirect
github.com/spf13/cast v1.5.0 // indirect github.com/spf13/cast v1.5.0 // indirect
github.com/spf13/jwalterweatherman v1.1.0 // indirect github.com/spf13/jwalterweatherman v1.1.0 // indirect
@@ -48,3 +48,5 @@ require (
gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )
replace github.com/tlinden/cenophane/common => ../common

View File

@@ -38,8 +38,6 @@ 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= 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/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/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/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/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
@@ -154,7 +152,6 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0=
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU= github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU=
github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
@@ -367,8 +364,6 @@ golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.3.0 h1:w8ZOecv6NaNa/zC8944JTU3vz4u6Lagfk4RPQxv92NQ=
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ= golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=

View File

@@ -24,6 +24,7 @@ import (
//"github.com/alecthomas/repr" //"github.com/alecthomas/repr"
"github.com/imroc/req/v3" "github.com/imroc/req/v3"
"github.com/schollz/progressbar/v3" "github.com/schollz/progressbar/v3"
"github.com/tlinden/cenophane/common"
"github.com/tlinden/up/upctl/cfg" "github.com/tlinden/up/upctl/cfg"
"mime" "mime"
"os" "os"
@@ -47,23 +48,6 @@ type ListParams struct {
Apicontext string `json:"apicontext"` Apicontext string `json:"apicontext"`
} }
type Upload struct {
Id string `json:"id"`
Expire string `json:"expire"`
File string `json:"file"` // final filename (visible to the downloader)
Members []string `json:"members"` // contains multiple files, so File is an archive
Uploaded Timestamp `json:"uploaded"`
Context string `json:"context"`
Url string `json:"url"`
}
type Uploads struct {
Entries []*Upload `json:"uploads"`
Success bool `json:"success"`
Message string `json:"message"`
Code int `json:"code"`
}
const Maxwidth = 10 const Maxwidth = 10
func Setup(c *cfg.Config, path string) *Request { func Setup(c *cfg.Config, path string) *Request {
@@ -279,7 +263,7 @@ func Download(c *cfg.Config, args []string) error {
return fmt.Errorf("No filename provided!") return fmt.Errorf("No filename provided!")
} }
cleanfilename, _ := Untaint(filename, regexp.MustCompile(`[^a-zA-Z0-9\-\._]`)) cleanfilename, _ := common.Untaint(filename, regexp.MustCompile(`[^a-zA-Z0-9\-\._]`))
if err := os.Rename(id, cleanfilename); err != nil { if err := os.Rename(id, cleanfilename); err != nil {
os.Remove(id) os.Remove(id)
@@ -290,26 +274,3 @@ func Download(c *cfg.Config, args []string) error {
return nil return nil
} }
/*
Untaint user input, that is: remove all non supported chars.
wanted is a regexp matching chars we shall leave. Everything else
will be removed. Eg:
untainted := Untaint(input, `[^a-zA-Z0-9\-]`)
Returns a new string and an error if the input string has been
modified. It's the callers choice to decide what to do about
it. You may ignore the error and use the untainted string or bail
out.
*/
func Untaint(input string, wanted *regexp.Regexp) (string, error) {
untainted := wanted.ReplaceAllString(input, "")
if len(untainted) != len(input) {
return untainted, errors.New("Invalid input string!")
}
return untainted, nil
}

View File

@@ -23,17 +23,18 @@ import (
"fmt" "fmt"
"github.com/imroc/req/v3" "github.com/imroc/req/v3"
"github.com/olekukonko/tablewriter" "github.com/olekukonko/tablewriter"
"github.com/tlinden/cenophane/common"
"os" "os"
"time" "time"
) )
// make a human readable version of the expire setting // make a human readable version of the expire setting
func prepareExpire(expire string, start Timestamp) string { func prepareExpire(expire string, start common.Timestamp) string {
switch expire { switch expire {
case "asap": case "asap":
return "On first access" return "On first access"
default: default:
return time.Unix(start.Unix()+int64(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 "" return ""
@@ -62,7 +63,7 @@ func WriteTable(headers []string, data [][]string) {
} }
// output like psql \x // output like psql \x
func WriteExtended(uploads *Uploads) { func WriteExtended(uploads *common.Uploads) {
format := fmt.Sprintf("%%%ds: %%s\n", Maxwidth) format := fmt.Sprintf("%%%ds: %%s\n", Maxwidth)
// we shall only have 1 element, however, if we ever support more, here we go // we shall only have 1 element, however, if we ever support more, here we go
@@ -78,9 +79,9 @@ func WriteExtended(uploads *Uploads) {
} }
} }
// extract an Uploads{} struct from json response // extract an common.Uploads{} struct from json response
func GetUploadsFromResponse(resp *req.Response) (*Uploads, error) { func GetUploadsFromResponse(resp *req.Response) (*common.Uploads, error) {
uploads := Uploads{} uploads := common.Uploads{}
if err := json.Unmarshal([]byte(resp.String()), &uploads); err != nil { if err := json.Unmarshal([]byte(resp.String()), &uploads); err != nil {
return nil, errors.New("Could not unmarshall JSON response: " + err.Error()) return nil, errors.New("Could not unmarshall JSON response: " + err.Error())