From 96c6f0c2dc55356ac7d520ea87fd50bda1d3de10 Mon Sep 17 00:00:00 2001 From: Thomas von Dein Date: Sun, 19 Mar 2023 12:33:15 +0100 Subject: [PATCH] put shared code into own mod (common), + apicontext env vars --- README.md | 86 ++++++++++++++---- api/cleaner.go | 3 +- api/db.go | 23 ++--- api/fileio.go | 3 +- api/handlers.go | 15 ++-- api/server.go | 11 +-- api/timestamp.go | 63 ------------- api/{common.go => utils.go} | 138 ++++++----------------------- cfg/config.go | 5 +- cmd/root.go | 55 +++++++++++- {api => common}/common_test.go | 2 +- common/go.mod | 3 + {upctl/lib => common}/timestamp.go | 6 +- common/types.go | 43 +++++++++ common/utils.go | 46 ++++++++++ go.mod | 3 + upctl/go.mod | 8 +- upctl/go.sum | 5 -- upctl/lib/client.go | 43 +-------- upctl/lib/output.go | 13 +-- 20 files changed, 295 insertions(+), 279 deletions(-) delete mode 100644 api/timestamp.go rename api/{common.go => utils.go} (51%) rename {api => common}/common_test.go (99%) create mode 100644 common/go.mod rename {upctl/lib => common}/timestamp.go (96%) create mode 100644 common/types.go create mode 100644 common/utils.go diff --git a/README.md b/README.md index 9fede18..bac7192 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/api/cleaner.go b/api/cleaner.go index 1efb101..f69b779 100644 --- a/api/cleaner.go +++ b/api/cleaner.go @@ -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) } diff --git a/api/db.go b/api/db.go index 5579df6..24613a1 100644 --- a/api/db.go +++ b/api/db.go @@ -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 diff --git a/api/fileio.go b/api/fileio.go index 8b21b53..9ad11aa 100644 --- a/api/fileio.go +++ b/api/fileio.go @@ -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) diff --git a/api/handlers.go b/api/handlers.go index 5a7841d..f9ad2c0 100644 --- a/api/handlers.go +++ b/api/handlers.go @@ -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!") diff --git a/api/server.go b/api/server.go index 7c61738..54a0a22 100644 --- a/api/server.go +++ b/api/server.go @@ -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, diff --git a/api/timestamp.go b/api/timestamp.go deleted file mode 100644 index fdec611..0000000 --- a/api/timestamp.go +++ /dev/null @@ -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 . -*/ - -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 -} diff --git a/api/common.go b/api/utils.go similarity index 51% rename from api/common.go rename to api/utils.go index 7206961..57d962e 100644 --- a/api/common.go +++ b/api/utils.go @@ -18,48 +18,20 @@ along with this program. If not, see . 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 +} diff --git a/cfg/config.go b/cfg/config.go index cd03c18..5f582c1 100644 --- a/cfg/config.go +++ b/cfg/config.go @@ -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 diff --git a/cmd/root.go b/cmd/root.go index 71f266f..2c99b0b 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -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)=":" + +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 +} diff --git a/api/common_test.go b/common/common_test.go similarity index 99% rename from api/common_test.go rename to common/common_test.go index 8a515ba..0f22a4d 100644 --- a/api/common_test.go +++ b/common/common_test.go @@ -1,4 +1,4 @@ -package api +package common import ( "fmt" diff --git a/common/go.mod b/common/go.mod new file mode 100644 index 0000000..06d829b --- /dev/null +++ b/common/go.mod @@ -0,0 +1,3 @@ +module github.com/tlinden/cenophane/common + +go 1.18 diff --git a/upctl/lib/timestamp.go b/common/timestamp.go similarity index 96% rename from upctl/lib/timestamp.go rename to common/timestamp.go index 3350bd9..4c9531f 100644 --- a/upctl/lib/timestamp.go +++ b/common/timestamp.go @@ -15,9 +15,7 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . */ -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 diff --git a/common/types.go b/common/types.go new file mode 100644 index 0000000..bfa9cfa --- /dev/null +++ b/common/types.go @@ -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 . +*/ + +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 +} diff --git a/common/utils.go b/common/utils.go new file mode 100644 index 0000000..406bbd9 --- /dev/null +++ b/common/utils.go @@ -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 . +*/ + +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 +} diff --git a/go.mod b/go.mod index 9f911a6..8899487 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/upctl/go.mod b/upctl/go.mod index 7c76a88..154e9f3 100644 --- a/upctl/go.mod +++ b/upctl/go.mod @@ -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 diff --git a/upctl/go.sum b/upctl/go.sum index f0fc9cd..d07f735 100644 --- a/upctl/go.sum +++ b/upctl/go.sum @@ -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= diff --git a/upctl/lib/client.go b/upctl/lib/client.go index 93562c7..004addc 100644 --- a/upctl/lib/client.go +++ b/upctl/lib/client.go @@ -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 -} diff --git a/upctl/lib/output.go b/upctl/lib/output.go index 58c6e97..2d70b32 100644 --- a/upctl/lib/output.go +++ b/upctl/lib/output.go @@ -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())