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

@@ -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

@@ -1,78 +0,0 @@
package api
import (
"fmt"
"testing"
"time"
)
func TestDuration2Seconds(t *testing.T) {
var tests = []struct {
dur string
expect int
}{
{"1d", 60 * 60 * 24},
{"1h", 60 * 60},
{"10m", 60 * 10},
{"2h4m10s", (60 * 120) + (4 * 60) + 10},
{"88u", 0},
{"19t77X what?4s", 4},
}
for _, tt := range tests {
testname := fmt.Sprintf("duration-%s", tt.dur)
t.Run(testname, func(t *testing.T) {
seconds := duration2int(tt.dur)
if seconds != tt.expect {
t.Errorf("got %d, want %d", seconds, tt.expect)
}
})
}
}
func TestIsExpired(t *testing.T) {
var tests = []struct {
expire string
start time.Time
expect bool
}{
{"3s", time.Now(), true},
{"1d", time.Now(), false},
}
for _, tt := range tests {
testname := fmt.Sprintf("isexpired-%s-%s", tt.start, tt.expire)
t.Run(testname, func(t *testing.T) {
time.Sleep(5 * time.Second)
got := IsExpired(tt.start, tt.expire)
if got != tt.expect {
t.Errorf("got %t, want %t", got, tt.expect)
}
})
}
}
func TestUntaint(t *testing.T) {
var tests = []struct {
want string
input string
expect string
wanterr bool
}{
{`[^a-zA-Z0-9\-]`, "ab23-bb43-beef", "ab23-bb43-beef", false},
{`[^a-zA-Z0-9\-]`, "`cat passwd`+ab23-bb43-beef", "catpasswdab23-bb43-beef", true},
}
for _, tt := range tests {
testname := fmt.Sprintf("untaint-%s-%s", tt.want, tt.expect)
t.Run(testname, func(t *testing.T) {
untainted, err := Untaint(tt.input, tt.want)
if untainted != tt.expect {
t.Errorf("got %s, want %s", untainted, tt.expect)
}
if err != nil && !tt.wanterr {
t.Errorf("got error: %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
}