mirror of
https://codeberg.org/scip/ephemerup.git
synced 2025-12-16 20:20:58 +01:00
reorg dir structure, add docker image build
This commit is contained in:
107
api/auth.go
Normal file
107
api/auth.go
Normal file
@@ -0,0 +1,107 @@
|
||||
/*
|
||||
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 (
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"errors"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/gofiber/keyauth/v2"
|
||||
"github.com/tlinden/up/upd/cfg"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
// these vars can be savely global, since they don't change ever
|
||||
var (
|
||||
errMissing = &fiber.Error{
|
||||
Code: 403000,
|
||||
Message: "Missing API key",
|
||||
}
|
||||
|
||||
errInvalid = &fiber.Error{
|
||||
Code: 403001,
|
||||
Message: "Invalid API key",
|
||||
}
|
||||
|
||||
Authurls []*regexp.Regexp
|
||||
Apikeys []cfg.Apicontext
|
||||
)
|
||||
|
||||
// fill from server: accepted keys
|
||||
func AuthSetApikeys(keys []cfg.Apicontext) {
|
||||
Apikeys = keys
|
||||
}
|
||||
|
||||
// fill from server: endpoints we need to authenticate
|
||||
func AuthSetEndpoints(prefix string, version string, endpoints []string) {
|
||||
for _, endpoint := range endpoints {
|
||||
Authurls = append(Authurls, regexp.MustCompile("^"+prefix+version+endpoint))
|
||||
}
|
||||
}
|
||||
|
||||
// make sure we always return JSON encoded errors
|
||||
func AuthErrHandler(ctx *fiber.Ctx, err error) error {
|
||||
ctx.Status(fiber.StatusForbidden)
|
||||
|
||||
if err == errMissing {
|
||||
return ctx.JSON(errMissing)
|
||||
}
|
||||
|
||||
return ctx.JSON(errInvalid)
|
||||
}
|
||||
|
||||
// validator hook, called by fiber via server keyauth.New()
|
||||
func AuthValidateAPIKey(c *fiber.Ctx, key string) (bool, error) {
|
||||
// create a new session, it will be thrown away if something fails
|
||||
sess, err := Sessionstore.Get(c)
|
||||
if err != nil {
|
||||
return false, errors.New("Unable to initialize session store from context!")
|
||||
}
|
||||
|
||||
// if Apikeys is empty, the server works unauthenticated
|
||||
// FIXME: maybe always reject?
|
||||
if len(Apikeys) == 0 {
|
||||
sess.Set("apicontext", "default")
|
||||
|
||||
if err := sess.Save(); err != nil {
|
||||
return false, errors.New("Unable to save session store!")
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// actual key comparision
|
||||
for _, apicontext := range Apikeys {
|
||||
hashedAPIKey := sha256.Sum256([]byte(apicontext.Key))
|
||||
hashedKey := sha256.Sum256([]byte(key))
|
||||
|
||||
if subtle.ConstantTimeCompare(hashedAPIKey[:], hashedKey[:]) == 1 {
|
||||
// apikey matches, register apicontext for later use by the handlers
|
||||
sess.Set("apicontext", apicontext.Context)
|
||||
|
||||
if err := sess.Save(); err != nil {
|
||||
return false, errors.New("Unable to save session store!")
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
return false, keyauth.ErrMissingOrMalformedAPIKey
|
||||
}
|
||||
85
api/cleaner.go
Normal file
85
api/cleaner.go
Normal file
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
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 (
|
||||
"fmt"
|
||||
//"github.com/alecthomas/repr"
|
||||
"encoding/json"
|
||||
"github.com/tlinden/up/upd/cfg"
|
||||
bolt "go.etcd.io/bbolt"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
func DeleteExpiredUploads(conf *cfg.Config, db *Db) error {
|
||||
err := db.bolt.Update(func(tx *bolt.Tx) error {
|
||||
bucket := tx.Bucket([]byte(Bucket))
|
||||
|
||||
if bucket == nil {
|
||||
return nil // nothin to delete so far
|
||||
}
|
||||
|
||||
err := bucket.ForEach(func(id, j []byte) error {
|
||||
upload := &Upload{}
|
||||
if err := json.Unmarshal(j, &upload); err != nil {
|
||||
return fmt.Errorf("unable to unmarshal json: %s", err)
|
||||
}
|
||||
|
||||
if IsExpired(conf, upload.Uploaded.Time, upload.Expire) {
|
||||
if err := bucket.Delete([]byte(id)); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
cleanup(filepath.Join(conf.StorageDir, upload.Id))
|
||||
|
||||
Log("Cleaned up upload " + upload.Id)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
return err
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
Log("DB error: %s", err.Error())
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func BackgroundCleaner(conf *cfg.Config, db *Db) chan bool {
|
||||
ticker := time.NewTicker(conf.CleanInterval)
|
||||
fmt.Println(conf.CleanInterval)
|
||||
done := make(chan bool)
|
||||
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
DeleteExpiredUploads(conf, db)
|
||||
case <-done:
|
||||
ticker.Stop()
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return done
|
||||
}
|
||||
180
api/common.go
Normal file
180
api/common.go
Normal file
@@ -0,0 +1,180 @@
|
||||
/*
|
||||
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 (
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/tlinden/up/upd/cfg"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"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"`
|
||||
}
|
||||
|
||||
// vaious helbers
|
||||
func Log(format string, values ...any) {
|
||||
fmt.Printf("[DEBUG] "+format+"\n", values...)
|
||||
}
|
||||
|
||||
func Ts() string {
|
||||
t := time.Now()
|
||||
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
|
||||
contexts defined, we'll use 'default' (set in
|
||||
auth.validateAPIKey()).
|
||||
|
||||
If there's no apicontext in the session, assume unauth user, return ""
|
||||
*/
|
||||
func GetApicontext(c *fiber.Ctx) (string, error) {
|
||||
sess, err := Sessionstore.Get(c)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("Unable to initialize session store from context: " + err.Error())
|
||||
}
|
||||
|
||||
apicontext := sess.Get("apicontext")
|
||||
if apicontext != nil {
|
||||
return apicontext.(string), nil
|
||||
}
|
||||
|
||||
return "", nil
|
||||
}
|
||||
78
api/common_test.go
Normal file
78
api/common_test.go
Normal file
@@ -0,0 +1,78 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
194
api/db.go
Normal file
194
api/db.go
Normal file
@@ -0,0 +1,194 @@
|
||||
/*
|
||||
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 (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/tlinden/up/upd/cfg"
|
||||
//"github.com/alecthomas/repr"
|
||||
bolt "go.etcd.io/bbolt"
|
||||
)
|
||||
|
||||
const Bucket string = "uploads"
|
||||
|
||||
// wrapper for bolt db
|
||||
type Db struct {
|
||||
bolt *bolt.DB
|
||||
cfg *cfg.Config
|
||||
}
|
||||
|
||||
func NewDb(c *cfg.Config) (*Db, error) {
|
||||
b, err := bolt.Open(c.DbFile, 0600, nil)
|
||||
db := Db{bolt: b, cfg: c}
|
||||
return &db, err
|
||||
}
|
||||
|
||||
func (db *Db) Close() {
|
||||
db.bolt.Close()
|
||||
}
|
||||
|
||||
func (db *Db) Insert(id string, entry *Upload) error {
|
||||
err := db.bolt.Update(func(tx *bolt.Tx) error {
|
||||
bucket, err := tx.CreateBucketIfNotExists([]byte(Bucket))
|
||||
if err != nil {
|
||||
return fmt.Errorf("create bucket: %s", err)
|
||||
}
|
||||
|
||||
jsonentry, err := json.Marshal(entry)
|
||||
if err != nil {
|
||||
return fmt.Errorf("json marshalling failure: %s", err)
|
||||
}
|
||||
|
||||
err = bucket.Put([]byte(id), []byte(jsonentry))
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert data: %s", err)
|
||||
}
|
||||
|
||||
// results in:
|
||||
// bbolt get /tmp/uploads.db uploads fb242922-86cb-43a8-92bc-b027c35f0586
|
||||
// {"id":"fb242922-86cb-43a8-92bc-b027c35f0586","expire":"1d","file":"2023-02-17-13-09-data.zip"}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
Log("DB error: %s", err.Error())
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (db *Db) Delete(apicontext string, id string) error {
|
||||
err := db.bolt.Update(func(tx *bolt.Tx) error {
|
||||
bucket := tx.Bucket([]byte(Bucket))
|
||||
|
||||
if bucket == nil {
|
||||
return fmt.Errorf("id %s not found", id)
|
||||
}
|
||||
|
||||
j := bucket.Get([]byte(id))
|
||||
|
||||
if len(j) == 0 {
|
||||
return fmt.Errorf("id %s not found", id)
|
||||
}
|
||||
|
||||
upload := &Upload{}
|
||||
if err := json.Unmarshal(j, &upload); err != nil {
|
||||
return fmt.Errorf("unable to unmarshal json: %s", err)
|
||||
}
|
||||
|
||||
if (apicontext != "" && (db.cfg.Super == apicontext || upload.Context == apicontext)) || apicontext == "" {
|
||||
return bucket.Delete([]byte(id))
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
Log("DB error: %s", err.Error())
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (db *Db) List(apicontext string, filter string) (*Uploads, error) {
|
||||
uploads := &Uploads{}
|
||||
|
||||
err := db.bolt.View(func(tx *bolt.Tx) error {
|
||||
bucket := tx.Bucket([]byte(Bucket))
|
||||
if bucket == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
err := bucket.ForEach(func(id, j []byte) error {
|
||||
upload := &Upload{}
|
||||
if err := json.Unmarshal(j, &upload); err != nil {
|
||||
return fmt.Errorf("unable to unmarshal json: %s", err)
|
||||
}
|
||||
|
||||
fmt.Printf("apicontext: %s, filter: %s\n", apicontext, filter)
|
||||
if apicontext != "" && db.cfg.Super != apicontext {
|
||||
// only return the uploads for this context
|
||||
if apicontext == upload.Context {
|
||||
// unless a filter needed OR no filter specified
|
||||
if (filter != "" && upload.Context == filter) || filter == "" {
|
||||
uploads.Entries = append(uploads.Entries, upload)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// return all, because we operate a public service or current==super
|
||||
if (filter != "" && upload.Context == filter) || filter == "" {
|
||||
uploads.Entries = append(uploads.Entries, upload)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
return err // might be nil as well
|
||||
})
|
||||
|
||||
return uploads, err
|
||||
}
|
||||
|
||||
// we only return one obj here, but could return more later
|
||||
func (db *Db) Get(apicontext string, id string) (*Uploads, error) {
|
||||
uploads := &Uploads{}
|
||||
|
||||
err := db.bolt.View(func(tx *bolt.Tx) error {
|
||||
bucket := tx.Bucket([]byte(Bucket))
|
||||
if bucket == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
j := bucket.Get([]byte(id))
|
||||
if j == nil {
|
||||
return fmt.Errorf("No upload object found with id %s", id)
|
||||
}
|
||||
|
||||
upload := &Upload{}
|
||||
if err := json.Unmarshal(j, &upload); err != nil {
|
||||
return fmt.Errorf("unable to unmarshal json: %s", err)
|
||||
}
|
||||
|
||||
if (apicontext != "" && (db.cfg.Super == apicontext || upload.Context == apicontext)) || apicontext == "" {
|
||||
// allowed if no context (public or download)
|
||||
// or if context matches or if context==super
|
||||
uploads.Entries = append(uploads.Entries, upload)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
return uploads, err
|
||||
}
|
||||
|
||||
// a wrapper around Lookup() which extracts the 1st upload, if any
|
||||
func (db *Db) Lookup(apicontext string, id string) (*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)
|
||||
}
|
||||
|
||||
if len(uploads.Entries) == 0 {
|
||||
return &Upload{}, fmt.Errorf("No upload object found with id %s", id)
|
||||
}
|
||||
|
||||
return uploads.Entries[0], nil
|
||||
}
|
||||
178
api/fileio.go
Normal file
178
api/fileio.go
Normal file
@@ -0,0 +1,178 @@
|
||||
/*
|
||||
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 (
|
||||
"archive/zip"
|
||||
"errors"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/tlinden/up/upd/cfg"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// cleanup an upload directory, either because we got an error in the
|
||||
// middle of an upload or something else went wrong.
|
||||
func cleanup(dir string) {
|
||||
err := os.RemoveAll(dir)
|
||||
if err != nil {
|
||||
Log("Failed to remove dir %s: %s", dir, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// Extract form file[s] and store them on disk, returns a list of files
|
||||
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)
|
||||
path := filepath.Join(cfg.StorageDir, id, filename)
|
||||
members = append(members, filename)
|
||||
Log("Received: %s => %s/%s", file.Filename, id, filename)
|
||||
|
||||
if err := c.SaveFile(file, path); err != nil {
|
||||
cleanup(filepath.Join(cfg.StorageDir, id))
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return members, nil
|
||||
}
|
||||
|
||||
// generate return url. in case of multiple files, zip and remove them
|
||||
func ProcessFormFiles(cfg *cfg.Config, members []string, id string) (string, string, error) {
|
||||
returnUrl := ""
|
||||
Filename := ""
|
||||
|
||||
if len(members) == 1 {
|
||||
returnUrl = strings.Join([]string{cfg.Url, "download", id, members[0]}, "/")
|
||||
Filename = members[0]
|
||||
} else {
|
||||
zipfile := Ts() + "data.zip"
|
||||
tmpzip := filepath.Join(cfg.StorageDir, zipfile)
|
||||
finalzip := filepath.Join(cfg.StorageDir, id, zipfile)
|
||||
iddir := filepath.Join(cfg.StorageDir, id)
|
||||
|
||||
if err := ZipDir(iddir, tmpzip); err != nil {
|
||||
cleanup(iddir)
|
||||
Log("zip error")
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
if err := os.Rename(tmpzip, finalzip); err != nil {
|
||||
cleanup(iddir)
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
returnUrl = strings.Join([]string{cfg.Url + cfg.ApiPrefix + ApiVersion, "file", id, zipfile}, "/")
|
||||
Filename = zipfile
|
||||
|
||||
// clean up after us
|
||||
go func() {
|
||||
for _, file := range members {
|
||||
if err := os.Remove(filepath.Join(cfg.StorageDir, id, file)); err != nil {
|
||||
Log("ERROR: unable to delete %s: %s", file, err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
return returnUrl, Filename, nil
|
||||
}
|
||||
|
||||
// Create a zip archive from a directory
|
||||
// FIXME: -e option, if any, goes here
|
||||
func ZipDir(directory, zipfilename string) error {
|
||||
f, err := os.Create(zipfilename)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
writer := zip.NewWriter(f)
|
||||
defer writer.Close()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
|
||||
// don't chdir the server itself
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
|
||||
os.Chdir(directory)
|
||||
newDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
}
|
||||
if newDir != directory {
|
||||
err = errors.New("Failed to changedir!")
|
||||
return
|
||||
}
|
||||
|
||||
err = filepath.Walk(".", func(path string, info os.FileInfo, err error) error {
|
||||
// 2. Go through all the files of the directory
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 3. Create a local file header
|
||||
header, err := zip.FileInfoHeader(info)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// set compression
|
||||
header.Method = zip.Deflate
|
||||
|
||||
// 4. Set relative path of a file as the header name
|
||||
header.Name = path
|
||||
//Log("a: <%s>, b: <%s>, rel: <%s>", filepath.Dir(directory), path, header.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.IsDir() {
|
||||
header.Name += "/"
|
||||
}
|
||||
|
||||
// 5. Create writer for the file header and save content of the file
|
||||
headerWriter, err := writer.CreateHeader(header)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
_, err = io.Copy(headerWriter, f)
|
||||
return err
|
||||
})
|
||||
}()
|
||||
|
||||
wg.Wait()
|
||||
|
||||
return err
|
||||
}
|
||||
273
api/handlers.go
Normal file
273
api/handlers.go
Normal file
@@ -0,0 +1,273 @@
|
||||
/*
|
||||
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 (
|
||||
//"github.com/alecthomas/repr"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/google/uuid"
|
||||
"github.com/tlinden/up/upd/cfg"
|
||||
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type SetContext struct {
|
||||
Apicontext string `json:"apicontext" form:"apicontext"`
|
||||
}
|
||||
|
||||
func FilePut(c *fiber.Ctx, cfg *cfg.Config, db *Db) error {
|
||||
// supports upload of multiple files with:
|
||||
//
|
||||
// curl -X POST localhost:8080/putfile \
|
||||
// -F "upload[]=@/home/scip/2023-02-06_10-51.png" \
|
||||
// -F "upload[]=@/home/scip/pgstat.png" \
|
||||
// -H "Content-Type: multipart/form-data"
|
||||
//
|
||||
// If multiple files are uploaded, they are zipped, otherwise
|
||||
// the file is being stored as is.
|
||||
//
|
||||
// Returns the name of the uploaded file.
|
||||
|
||||
id := uuid.NewString()
|
||||
|
||||
var returnUrl string
|
||||
var formdata Meta
|
||||
|
||||
os.MkdirAll(filepath.Join(cfg.StorageDir, id), os.ModePerm)
|
||||
|
||||
// fetch auxiliary form data
|
||||
form, err := c.MultipartForm()
|
||||
if err != nil {
|
||||
return JsonStatus(c, fiber.StatusForbidden,
|
||||
"mime/multipart error "+err.Error())
|
||||
}
|
||||
|
||||
// init upload obj
|
||||
entry := &Upload{Id: id, Uploaded: Timestamp{Time: time.Now()}}
|
||||
|
||||
// retrieve the API Context name from the session
|
||||
apicontext, err := GetApicontext(c)
|
||||
if err != nil {
|
||||
return JsonStatus(c, fiber.StatusInternalServerError,
|
||||
"Unable to initialize session store from context: "+err.Error())
|
||||
}
|
||||
entry.Context = apicontext
|
||||
|
||||
// retrieve files, if any
|
||||
files := form.File["upload[]"]
|
||||
members, err := SaveFormFiles(c, cfg, files, id)
|
||||
if err != nil {
|
||||
return JsonStatus(c, fiber.StatusInternalServerError,
|
||||
"Could not store uploaded file[s]: "+err.Error())
|
||||
}
|
||||
entry.Members = members
|
||||
|
||||
// extract auxilliary form data (expire field et al)
|
||||
if err := c.BodyParser(&formdata); err != nil {
|
||||
return JsonStatus(c, fiber.StatusInternalServerError,
|
||||
"bodyparser error : "+err.Error())
|
||||
}
|
||||
|
||||
// post process expire
|
||||
if len(formdata.Expire) == 0 {
|
||||
entry.Expire = "asap"
|
||||
} else {
|
||||
ex, err := Untaint(formdata.Expire, cfg.RegDuration) // duration or asap allowed
|
||||
if err != nil {
|
||||
return JsonStatus(c, fiber.StatusForbidden,
|
||||
"Invalid data: "+err.Error())
|
||||
}
|
||||
entry.Expire = ex
|
||||
}
|
||||
|
||||
// get url [and zip if there are multiple files]
|
||||
returnUrl, Newfilename, err := ProcessFormFiles(cfg, entry.Members, id)
|
||||
if err != nil {
|
||||
return JsonStatus(c, fiber.StatusInternalServerError,
|
||||
"Could not process uploaded file[s]: "+err.Error())
|
||||
}
|
||||
entry.File = Newfilename
|
||||
|
||||
Log("Now serving %s from %s/%s", returnUrl, cfg.StorageDir, id)
|
||||
Log("Expire set to: %s", entry.Expire)
|
||||
Log("Uploaded with API-Context %s", entry.Context)
|
||||
|
||||
// we do this in the background to not thwart the server
|
||||
go db.Insert(id, entry)
|
||||
|
||||
// everything went well so far
|
||||
res := &Uploads{Entries: []*Upload{entry}}
|
||||
res.Success = true
|
||||
res.Message = "Download url: " + returnUrl
|
||||
res.Code = fiber.StatusOK
|
||||
return c.Status(fiber.StatusOK).JSON(res)
|
||||
}
|
||||
|
||||
func FileGet(c *fiber.Ctx, cfg *cfg.Config, db *Db, shallExpire ...bool) error {
|
||||
// deliver a file and delete it if expire is set to asap
|
||||
|
||||
// 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)
|
||||
if err != nil {
|
||||
return fiber.NewError(403, "Invalid id provided!")
|
||||
}
|
||||
|
||||
// retrieve the API Context name from the session
|
||||
apicontext, err := GetApicontext(c)
|
||||
if err != nil {
|
||||
return JsonStatus(c, fiber.StatusInternalServerError,
|
||||
"Unable to initialize session store from context: "+err.Error())
|
||||
}
|
||||
|
||||
upload, err := db.Lookup(apicontext, id)
|
||||
if err != nil {
|
||||
// non existent db entry with that id, or other db error, see logs
|
||||
return fiber.NewError(404, "No download with that id could be found!")
|
||||
}
|
||||
|
||||
file := upload.File
|
||||
filename := filepath.Join(cfg.StorageDir, id, file)
|
||||
|
||||
if _, err := os.Stat(filename); err != nil {
|
||||
// db entry is there, but file isn't (anymore?)
|
||||
go db.Delete(apicontext, id)
|
||||
return fiber.NewError(404, "No download with that id could be found!")
|
||||
}
|
||||
|
||||
// finally put the file to the client
|
||||
err = c.Download(filename, file)
|
||||
|
||||
if len(shallExpire) > 0 {
|
||||
if shallExpire[0] == true {
|
||||
go func() {
|
||||
// check if we need to delete the file now and do it in the background
|
||||
if upload.Expire == "asap" {
|
||||
cleanup(filepath.Join(cfg.StorageDir, id))
|
||||
db.Delete(apicontext, id)
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// 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)
|
||||
if err != nil {
|
||||
return JsonStatus(c, fiber.StatusForbidden,
|
||||
"Invalid id provided!")
|
||||
}
|
||||
|
||||
if len(id) == 0 {
|
||||
return JsonStatus(c, fiber.StatusForbidden,
|
||||
"No id specified!")
|
||||
}
|
||||
|
||||
// retrieve the API Context name from the session
|
||||
apicontext, err := GetApicontext(c)
|
||||
if err != nil {
|
||||
return JsonStatus(c, fiber.StatusInternalServerError,
|
||||
"Unable to initialize session store from context: "+err.Error())
|
||||
}
|
||||
|
||||
err = db.Delete(apicontext, id)
|
||||
if err != nil {
|
||||
// non existent db entry with that id, or other db error, see logs
|
||||
return JsonStatus(c, fiber.StatusForbidden,
|
||||
"No upload with that id could be found!")
|
||||
}
|
||||
|
||||
cleanup(filepath.Join(cfg.StorageDir, id))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// returns the whole list + error code, no post processing by server
|
||||
func List(c *fiber.Ctx, cfg *cfg.Config, db *Db) error {
|
||||
// fetch filter from body(json expected)
|
||||
setcontext := new(SetContext)
|
||||
if err := c.BodyParser(setcontext); err != nil {
|
||||
return JsonStatus(c, fiber.StatusForbidden,
|
||||
"Unable to parse body: "+err.Error())
|
||||
}
|
||||
|
||||
filter, err := Untaint(setcontext.Apicontext, cfg.RegKey)
|
||||
if err != nil {
|
||||
return JsonStatus(c, fiber.StatusForbidden,
|
||||
"Invalid api context filter provided!")
|
||||
}
|
||||
|
||||
// retrieve the API Context name from the session
|
||||
apicontext, err := GetApicontext(c)
|
||||
if err != nil {
|
||||
return JsonStatus(c, fiber.StatusInternalServerError,
|
||||
"Unable to initialize session store from context: "+err.Error())
|
||||
}
|
||||
|
||||
// get list
|
||||
uploads, err := db.List(apicontext, filter)
|
||||
if err != nil {
|
||||
return JsonStatus(c, fiber.StatusForbidden,
|
||||
"Unable to list uploads: "+err.Error())
|
||||
}
|
||||
|
||||
// if we reached this point we can signal success
|
||||
uploads.Success = true
|
||||
uploads.Code = fiber.StatusOK
|
||||
|
||||
return c.Status(fiber.StatusOK).JSON(uploads)
|
||||
}
|
||||
|
||||
// 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)
|
||||
if err != nil {
|
||||
return JsonStatus(c, fiber.StatusForbidden,
|
||||
"Invalid id provided!")
|
||||
}
|
||||
|
||||
// retrieve the API Context name from the session
|
||||
apicontext, err := GetApicontext(c)
|
||||
if err != nil {
|
||||
return JsonStatus(c, fiber.StatusInternalServerError,
|
||||
"Unable to initialize session store from context: "+err.Error())
|
||||
}
|
||||
|
||||
uploads, err := db.Get(apicontext, id)
|
||||
if err != nil {
|
||||
return JsonStatus(c, fiber.StatusForbidden,
|
||||
"No upload with that id could be found!")
|
||||
}
|
||||
|
||||
for _, upload := range uploads.Entries {
|
||||
upload.Url = strings.Join([]string{cfg.Url, "download", id, upload.File}, "/")
|
||||
}
|
||||
|
||||
// if we reached this point we can signal success
|
||||
uploads.Success = true
|
||||
uploads.Code = fiber.StatusOK
|
||||
|
||||
return c.Status(fiber.StatusOK).JSON(uploads)
|
||||
}
|
||||
196
api/server.go
Normal file
196
api/server.go
Normal file
@@ -0,0 +1,196 @@
|
||||
/*
|
||||
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 (
|
||||
"errors"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/gofiber/fiber/v2/middleware/compress"
|
||||
"github.com/gofiber/fiber/v2/middleware/cors"
|
||||
"github.com/gofiber/fiber/v2/middleware/logger"
|
||||
"github.com/gofiber/fiber/v2/middleware/requestid"
|
||||
"github.com/gofiber/fiber/v2/middleware/session"
|
||||
"github.com/gofiber/keyauth/v2"
|
||||
"github.com/tlinden/up/upd/cfg"
|
||||
)
|
||||
|
||||
// sessions are context specific and can be global savely
|
||||
var Sessionstore *session.Store
|
||||
|
||||
const shallExpire = true
|
||||
|
||||
func Runserver(conf *cfg.Config, args []string) error {
|
||||
// required for authenticated routes, used to store the api context
|
||||
Sessionstore = session.New()
|
||||
|
||||
// bbolt db setup
|
||||
db, err := NewDb(conf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// setup authenticated endpoints
|
||||
auth := SetupAuthStore(conf)
|
||||
|
||||
// setup api server
|
||||
router := SetupServer(conf)
|
||||
|
||||
// authenticated routes
|
||||
api := router.Group(conf.ApiPrefix + ApiVersion)
|
||||
{
|
||||
// upload
|
||||
api.Post("/file/", auth, func(c *fiber.Ctx) error {
|
||||
return FilePut(c, conf, db)
|
||||
})
|
||||
|
||||
// download w/o expire
|
||||
api.Get("/file/:id/:file", auth, func(c *fiber.Ctx) error {
|
||||
return FileGet(c, conf, db)
|
||||
})
|
||||
api.Get("/file/:id/", auth, func(c *fiber.Ctx) error {
|
||||
return FileGet(c, conf, db)
|
||||
})
|
||||
|
||||
// remove
|
||||
api.Delete("/file/:id/", auth, func(c *fiber.Ctx) error {
|
||||
err := DeleteUpload(c, conf, db)
|
||||
return SendResponse(c, "", err)
|
||||
})
|
||||
|
||||
// listing
|
||||
api.Get("/list/", auth, func(c *fiber.Ctx) error {
|
||||
return List(c, conf, db)
|
||||
})
|
||||
|
||||
// info
|
||||
api.Get("/upload/:id/", auth, func(c *fiber.Ctx) error {
|
||||
return Describe(c, conf, db)
|
||||
})
|
||||
}
|
||||
|
||||
// public routes
|
||||
{
|
||||
router.Get("/", func(c *fiber.Ctx) error {
|
||||
return c.Send([]byte("welcome to upload api, use /api enpoint!"))
|
||||
})
|
||||
|
||||
router.Get("/download/:id/:file", func(c *fiber.Ctx) error {
|
||||
return FileGet(c, conf, db, shallExpire)
|
||||
})
|
||||
|
||||
router.Get("/download/:id/", func(c *fiber.Ctx) error {
|
||||
return FileGet(c, conf, db, shallExpire)
|
||||
})
|
||||
}
|
||||
|
||||
// setup cleaner
|
||||
quitcleaner := BackgroundCleaner(conf, db)
|
||||
|
||||
router.Hooks().OnShutdown(func() error {
|
||||
Log("Shutting down cleaner")
|
||||
close(quitcleaner)
|
||||
return nil
|
||||
})
|
||||
|
||||
return router.Listen(conf.Listen)
|
||||
}
|
||||
|
||||
func SetupAuthStore(conf *cfg.Config) func(*fiber.Ctx) error {
|
||||
AuthSetEndpoints(conf.ApiPrefix, ApiVersion, []string{"/file"})
|
||||
AuthSetApikeys(conf.Apicontext)
|
||||
|
||||
return keyauth.New(keyauth.Config{
|
||||
Validator: AuthValidateAPIKey,
|
||||
ErrorHandler: AuthErrHandler,
|
||||
})
|
||||
}
|
||||
|
||||
func SetupServer(conf *cfg.Config) *fiber.App {
|
||||
router := fiber.New(fiber.Config{
|
||||
CaseSensitive: true,
|
||||
StrictRouting: true,
|
||||
Immutable: true,
|
||||
Prefork: conf.Prefork,
|
||||
ServerHeader: "upd",
|
||||
AppName: conf.AppName,
|
||||
BodyLimit: conf.BodyLimit,
|
||||
Network: conf.Network,
|
||||
})
|
||||
|
||||
router.Use(requestid.New())
|
||||
|
||||
router.Use(logger.New(logger.Config{
|
||||
Format: "${pid} ${locals:requestid} ${status} - ${method} ${path}\n",
|
||||
}))
|
||||
|
||||
router.Use(cors.New(cors.Config{
|
||||
AllowMethods: "GET,PUT,POST,DELETE",
|
||||
ExposeHeaders: "Content-Type,Authorization,Accept",
|
||||
}))
|
||||
|
||||
router.Use(compress.New(compress.Config{
|
||||
Level: compress.LevelBestSpeed,
|
||||
}))
|
||||
|
||||
return router
|
||||
}
|
||||
|
||||
/*
|
||||
Wrapper to respond with proper json status, message and code,
|
||||
shall be prepared and called by the handlers directly.
|
||||
*/
|
||||
func JsonStatus(c *fiber.Ctx, code int, msg string) error {
|
||||
success := true
|
||||
|
||||
if code != fiber.StatusOK {
|
||||
success = false
|
||||
}
|
||||
|
||||
return c.Status(code).JSON(Result{
|
||||
Code: code,
|
||||
Message: msg,
|
||||
Success: success,
|
||||
})
|
||||
}
|
||||
|
||||
/*
|
||||
Used for non json-aware handlers, called by server
|
||||
*/
|
||||
func SendResponse(c *fiber.Ctx, msg string, err error) error {
|
||||
if err != nil {
|
||||
code := fiber.StatusInternalServerError
|
||||
|
||||
var e *fiber.Error
|
||||
if errors.As(err, &e) {
|
||||
code = e.Code
|
||||
}
|
||||
|
||||
return c.Status(code).JSON(Result{
|
||||
Code: code,
|
||||
Message: err.Error(),
|
||||
Success: false,
|
||||
})
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusOK).JSON(Result{
|
||||
Code: fiber.StatusOK,
|
||||
Message: msg,
|
||||
Success: true,
|
||||
})
|
||||
}
|
||||
63
api/timestamp.go
Normal file
63
api/timestamp.go
Normal file
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user