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
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
```
@@ -12,6 +28,7 @@ cenod -h
-c, --config string custom config file
-D, --dbfile string Bold database file to use (default "/tmp/uploads.db")
-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
-6, --ipv6 Only listen on ipv6
-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
```
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
```
@@ -52,33 +109,28 @@ Flags:
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
- also serve a html upload page
- 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()
- 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
- upctl: get rid of HandleResponse(), used only once anyway
- add form so that public users can upload
- add support for custom front page
## BUGS

View File

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

View File

@@ -21,6 +21,7 @@ import (
"encoding/json"
"fmt"
"github.com/tlinden/cenophane/cfg"
"github.com/tlinden/cenophane/common"
//"github.com/alecthomas/repr"
bolt "go.etcd.io/bbolt"
)
@@ -43,7 +44,7 @@ func (db *Db) 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 {
bucket, err := tx.CreateBucketIfNotExists([]byte(Bucket))
if err != nil {
@@ -86,7 +87,7 @@ func (db *Db) Delete(apicontext string, id string) error {
return fmt.Errorf("id %s not found", id)
}
upload := &Upload{}
upload := &common.Upload{}
if err := json.Unmarshal(j, &upload); err != nil {
return fmt.Errorf("unable to unmarshal json: %s", err)
}
@@ -105,8 +106,8 @@ func (db *Db) Delete(apicontext string, id string) error {
return err
}
func (db *Db) List(apicontext string, filter string) (*Uploads, error) {
uploads := &Uploads{}
func (db *Db) List(apicontext string, filter string) (*common.Uploads, error) {
uploads := &common.Uploads{}
err := db.bolt.View(func(tx *bolt.Tx) error {
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 {
upload := &Upload{}
upload := &common.Upload{}
if err := json.Unmarshal(j, &upload); err != nil {
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
func (db *Db) Get(apicontext string, id string) (*Uploads, error) {
uploads := &Uploads{}
func (db *Db) Get(apicontext string, id string) (*common.Uploads, error) {
uploads := &common.Uploads{}
err := db.bolt.View(func(tx *bolt.Tx) error {
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)
}
upload := &Upload{}
upload := &common.Upload{}
if err := json.Unmarshal(j, &upload); err != nil {
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
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)
if err != nil {
// 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 {
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

View File

@@ -22,6 +22,7 @@ import (
"errors"
"github.com/gofiber/fiber/v2"
"github.com/tlinden/cenophane/cfg"
"github.com/tlinden/cenophane/common"
"io"
"mime/multipart"
"os"
@@ -43,7 +44,7 @@ func cleanup(dir string) {
func SaveFormFiles(c *fiber.Ctx, cfg *cfg.Config, files []*multipart.FileHeader, id string) ([]string, error) {
members := []string{}
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)
members = append(members, filename)
Log("Received: %s => %s/%s", file.Filename, id, filename)

View File

@@ -22,6 +22,7 @@ import (
"github.com/gofiber/fiber/v2"
"github.com/google/uuid"
"github.com/tlinden/cenophane/cfg"
"github.com/tlinden/cenophane/common"
"os"
"path/filepath"
@@ -61,7 +62,7 @@ func FilePut(c *fiber.Ctx, cfg *cfg.Config, db *Db) error {
}
// 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
apicontext, err := GetApicontext(c)
@@ -90,7 +91,7 @@ func FilePut(c *fiber.Ctx, cfg *cfg.Config, db *Db) error {
if len(formdata.Expire) == 0 {
entry.Expire = "asap"
} 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 {
return JsonStatus(c, fiber.StatusForbidden,
"Invalid data: "+err.Error())
@@ -114,7 +115,7 @@ func FilePut(c *fiber.Ctx, cfg *cfg.Config, db *Db) error {
go db.Insert(id, entry)
// everything went well so far
res := &Uploads{Entries: []*Upload{entry}}
res := &common.Uploads{Entries: []*common.Upload{entry}}
res.Success = true
res.Message = "Download url: " + returnUrl
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
// 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 {
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
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 {
return JsonStatus(c, fiber.StatusForbidden,
"Invalid id provided!")
@@ -213,7 +214,7 @@ func List(c *fiber.Ctx, cfg *cfg.Config, db *Db) 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 {
return JsonStatus(c, fiber.StatusForbidden,
"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
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 {
return JsonStatus(c, fiber.StatusForbidden,
"Invalid id provided!")

View File

@@ -27,6 +27,7 @@ import (
"github.com/gofiber/fiber/v2/middleware/session"
"github.com/gofiber/keyauth/v2"
"github.com/tlinden/cenophane/cfg"
"github.com/tlinden/cenophane/common"
)
// sessions are context specific and can be global savely
@@ -87,7 +88,7 @@ func Runserver(conf *cfg.Config, args []string) error {
// public routes
{
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 {
@@ -113,7 +114,7 @@ func Runserver(conf *cfg.Config, args []string) error {
func SetupAuthStore(conf *cfg.Config) func(*fiber.Ctx) error {
AuthSetEndpoints(conf.ApiPrefix, ApiVersion, []string{"/file"})
AuthSetApikeys(conf.Apicontext)
AuthSetApikeys(conf.Apicontexts)
return keyauth.New(keyauth.Config{
Validator: AuthValidateAPIKey,
@@ -162,7 +163,7 @@ func JsonStatus(c *fiber.Ctx, code int, msg string) error {
success = false
}
return c.Status(code).JSON(Result{
return c.Status(code).JSON(common.Result{
Code: code,
Message: msg,
Success: success,
@@ -181,14 +182,14 @@ func SendResponse(c *fiber.Ctx, msg string, err error) error {
code = e.Code
}
return c.Status(code).JSON(Result{
return c.Status(code).JSON(common.Result{
Code: code,
Message: err.Error(),
Success: false,
})
}
return c.Status(fiber.StatusOK).JSON(Result{
return c.Status(fiber.StatusOK).JSON(common.Result{
Code: fiber.StatusOK,
Message: msg,
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
import (
"errors"
"fmt"
"github.com/gofiber/fiber/v2"
"github.com/tlinden/cenophane/cfg"
"regexp"
"strconv"
"github.com/tlinden/cenophane/common"
"time"
)
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
type Meta struct {
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
type Id struct {
Id string `json:"name" xml:"name" form:"name"`
@@ -75,88 +47,6 @@ func Ts() string {
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
been successfully authenticated. However, if there are no api
@@ -178,3 +68,29 @@ func GetApicontext(c *fiber.Ctx) (string, error) {
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
Url string `koanf:"url"` // public visible url, might be different from Listen
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:
// https://docs.gofiber.io/api/fiber/#config
@@ -52,7 +53,7 @@ type Config struct {
Network string
// only settable via config
Apicontext []Apicontext `koanf:"apicontext"`
Apicontexts []Apicontext `koanf:"apicontext"`
// Internals only
RegNormalizedFilename *regexp.Regexp

View File

@@ -32,6 +32,7 @@ import (
"github.com/tlinden/cenophane/api"
"github.com/tlinden/cenophane/cfg"
"io/ioutil"
"os"
"path/filepath"
"strings"
@@ -62,6 +63,8 @@ func Execute() error {
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.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
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.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.StringSliceP("apikeys", "", []string{}, "Api key[s] to allow access")
f.Parse(os.Args[1:])
@@ -119,10 +121,27 @@ func Execute() error {
// fetch values
k.Unmarshal("", &conf)
// there may exist some api context variables
GetApicontextsFromEnv(&conf)
if conf.Debug {
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 {
case ShowVersion:
fmt.Println(cfg.Getversion())
@@ -132,3 +151,37 @@ func Execute() error {
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 (
"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/>.
*/
package lib
// FIXME: import from upd!!!!
package common
import (
"regexp"
@@ -75,7 +73,7 @@ func (t *Timestamp) parseUnix(data []byte) error {
Convert a duration into seconds (int).
Valid time units are "s", "m", "h" and "d".
*/
func duration2int(duration string) int {
func Duration2int(duration string) int {
re := regexp.MustCompile(`(\d+)([dhms])`)
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/v2 v2.0.0
github.com/spf13/pflag v1.0.5
github.com/tlinden/cenophane/common v0.0.0-00010101000000-000000000000
go.etcd.io/bbolt v1.3.7
)
@@ -38,3 +39,5 @@ require (
github.com/valyala/tcplisten v1.0.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 (
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/pflag v1.0.5
github.com/spf13/viper v1.15.0
github.com/tlinden/cenophane/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
@@ -23,7 +25,6 @@ require (
github.com/mattn/go-runewidth v0.0.14 // indirect
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // 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/pelletier/go-toml/v2 v2.0.6 // 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/quic-go v0.32.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/cast v1.5.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/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=
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=
@@ -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/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
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.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU=
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-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.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/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
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/imroc/req/v3"
"github.com/schollz/progressbar/v3"
"github.com/tlinden/cenophane/common"
"github.com/tlinden/up/upctl/cfg"
"mime"
"os"
@@ -47,23 +48,6 @@ type ListParams struct {
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
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!")
}
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 {
os.Remove(id)
@@ -290,26 +274,3 @@ func Download(c *cfg.Config, args []string) error {
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"
"github.com/imroc/req/v3"
"github.com/olekukonko/tablewriter"
"github.com/tlinden/cenophane/common"
"os"
"time"
)
// 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 {
case "asap":
return "On first access"
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 ""
@@ -62,7 +63,7 @@ func WriteTable(headers []string, data [][]string) {
}
// output like psql \x
func WriteExtended(uploads *Uploads) {
func WriteExtended(uploads *common.Uploads) {
format := fmt.Sprintf("%%%ds: %%s\n", Maxwidth)
// 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
func GetUploadsFromResponse(resp *req.Response) (*Uploads, error) {
uploads := Uploads{}
// extract an common.Uploads{} struct from json response
func GetUploadsFromResponse(resp *req.Response) (*common.Uploads, error) {
uploads := common.Uploads{}
if err := json.Unmarshal([]byte(resp.String()), &uploads); err != nil {
return nil, errors.New("Could not unmarshall JSON response: " + err.Error())