Merge branch 'development'

This commit is contained in:
2023-03-29 13:46:40 +02:00
41 changed files with 1973 additions and 545 deletions

View File

@@ -12,7 +12,7 @@ WORKDIR /work
COPY go.mod .
COPY . .
RUN go mod download
RUN make && strip cenod
RUN make && strip ephemerupd
FROM alpine:3.17
LABEL maintainer="Uploads Author <info@daemon.de>"
@@ -20,14 +20,14 @@ LABEL maintainer="Uploads Author <info@daemon.de>"
RUN install -o 1001 -g 1001 -d /data
WORKDIR /app
COPY --from=builder /work/cenod /app/cenod
COPY --from=builder /work/ephemerupd /app/ephemerupd
ENV CENOD_LISTEN=:8080
ENV CENOD_STORAGEDIR=/data
ENV CENOD_DBFILE=/data/bbolt.db
ENV CENOD_DEBUG=1
ENV EPHEMERUPD_LISTEN=:8080
ENV EPHEMERUPD_STORAGEDIR=/data
ENV EPHEMERUPD_DBFILE=/data/bbolt.db
ENV EPHEMERUPD_DEBUG=1
USER 1001:1001
EXPOSE 8080
VOLUME /data
CMD ["/app/cenod"]
CMD ["/app/ephemerupd"]

View File

@@ -27,15 +27,15 @@ COMMIT = $(shell git rev-parse --short=8 HEAD)
BUILD = $(shell date +%Y.%m.%d.%H%M%S)
VERSION := $(if $(filter $(BRANCH), development),$(version)-$(BRANCH)-$(COMMIT)-$(BUILD),$(version))
HAVE_POD := $(shell pod2text -h 2>/dev/null)
DAEMON := cenod
DAEMON := ephemerupd
all: buildlocal buildlocalctl
all: cmd/formtemplate.go buildlocal buildlocalctl
buildlocalctl:
make -C upctl
buildlocal:
go build -ldflags "-X 'github.com/tlinden/cenophane/cfg.VERSION=$(VERSION)'" -o $(DAEMON)
go build -ldflags "-X 'github.com/tlinden/ephemerup/cfg.VERSION=$(VERSION)'" -o $(DAEMON)
buildimage: clean
docker-compose --verbose build
@@ -60,15 +60,15 @@ test:
singletest:
@echo "Call like this: ''make singletest TEST=TestX1 MOD=lib"
go test -run $(TEST) github.com/tlinden/cenophane/$(MOD)
go test -run $(TEST) github.com/tlinden/ephemerup/$(MOD)
cover-report:
go test ./... -cover -coverprofile=coverage.out
go tool cover -html=coverage.out
show-versions: buildlocal
@echo "### cenod version:"
@./cenod --version
@echo "### ephemerupd version:"
@./ephemerupd --version
@echo
@echo "### go module versions:"
@@ -80,3 +80,10 @@ show-versions: buildlocal
goupdate:
go get -t -u=patch ./...
cmd/%.go: templates/%.html
echo "package cmd" > cmd/$*.go
echo >> cmd/$*.go
echo "const formtemplate = \`" >> cmd/$*.go
cat templates/$*.html >> cmd/$*.go
echo "\`" >> cmd/$*.go

115
README.md
View File

@@ -1,9 +1,9 @@
# Cenophane
# ephemerup
Simple standalone file upload server with expiration and commandline client.
## Introduction
**Cenophane** is a simple standalone file server where every uploaded
**ephemerup** is a simple standalone file server where every uploaded
file expires sooner or later. The server provides a RESTful API and
can be used easily with the commandline client `upctl`.
@@ -13,7 +13,7 @@ important enough to keep them around. Think of this szenario: you're
working for the network departement and there's a problem with your
routing. Tech support asks you to create a network trace and send it
to them. But you can't because the trace file is too large and
sensitive to be sent by email. This is where **Cenophane** comes to
sensitive to be sent by email. This is where **ephemerup** comes to
the rescue.
You upload the file, send the download url to the other party and -
@@ -21,11 +21,11 @@ assuming you've utilized the defaults - when they download it, it is
being deleted immediately from the server. But you can also set an
expire time, say 5 days or something like that.
The download urls generated by **Cenophane** consist of a unique
The download urls generated by **ephemerup** consist of a unique
onetime hash, so they are somewhat confident. However, if you're
uploading really sensitive data, you better encrypt it.
**Cenophane** also supports something we call an API Context. There
**ephemerup** also supports something we call an API Context. There
can be many such API contexts. Each of these has an associated token,
which has to be used by legitimate clients to authenticate and
authorize. A user can only manage uploads within that context. Think
@@ -60,17 +60,17 @@ releases available yet. You'll need a go build environment. Just run
There's a `Dockerfile` available for the server so you can build and run it using docker:
```
make buildimage
docker-compose run cenophane
docker-compose run ephemerup
```
Then use the client to test it.
## Server Usage
```
cenod -h
ephemerupd -h
--apikeys strings Api key[s] to allow access
-a, --apiprefix string API endpoint path (default "/api")
-n, --appname string App name to say hi as (default "cenod v0.0.1")
-n, --appname string App name to say hi as (default "ephemerupd v0.0.1")
-b, --bodylimit int Max allowed upload size in bytes (default 10250000000)
-c, --config string custom config file
-D, --dbfile string Bold database file to use (default "/tmp/uploads.db")
@@ -86,23 +86,23 @@ cenod -h
-v, --version Print program version
```
All flags can be set using environment variables, prefix the flag with `CENOD_` and uppercase it, eg:
All flags can be set using environment variables, prefix the flag with `EPHEMERUPD_` and uppercase it, eg:
```
CENOD_LISTEN=:8080
EPHEMERUPD_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"
EPHEMERUPD_CONTEXT_SUPPORT="support:tymag-fycyh-gymof-dysuf-doseb-puxyx"
EPHEMERUPD_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`
- `/etc/ephemerupd.hcl`
- `/usr/local/etc/ephemerupd.hcl`
- `~/.config/ephemerupd/ephemerupd.hcl`
- `~/.ephemerupd`
- `$(pwd)/ephemerupd.hcl`
Or using the flag `-c`. Sample config file:
```
@@ -131,7 +131,7 @@ super = "root"
The server serves the API under the following endpoint:
`http://SERVERNAME[:PORT]/api/v1` where SERVERNAME[:PORT] is the
argument to the `-l` commandline argument or the config option
`listen` or the environment variable `CENOD_LISTEN`.
`listen` or the environment variable `EPHEMERUPD_LISTEN`.
By default the server listens on any interface ip4 and ipv6 on TCP
port 8080. You can specify a server name or an ipaddress and a
@@ -141,6 +141,83 @@ the `-4` respective the `-6` commandline flags.
It does not support TLS at the moment. Use a nginx reverse proxy in
front of it.
### Server REST API
Every endpoint returns a JSON object. Each returned object contains the data requested plus:
- success: true or false
- code: HTTP Response Code
- message: error message, if success==false
#### Endpoints
| HTTP Method | Endpoint | Parameters | Input | Returns | Description |
|-------------|-----------------------|---------------------|----------------------------|---------------------------------------|-----------------------------------------------|
| GET | /v1/uploads | apicontext,q,expire | | List of upload objects | list upload objects |
| POST | /v1/uploads | | multipart-formdata file[s] | List of 1 upload object if successful | upload a file and create a new upload object |
| GET | /v1/uploads/{id} | | | List of 1 upload object if successful | list one specific upload object matching {id} |
| DELETE | /v1/uploads/{id} | | | Noting | delete an upload object identified by {id} |
| PUT | /v1/uploads/{id} | | JSON upload object | List of 1 upload object if successful | modify an upload object identified by {id} |
| GET | /v1/uploads/{id}/file | | | File download | Download the file associated with the object |
| GET | /v1/forms | apicontext,q,expire | | List of form objects | list form objects |
| POST | /v1/forms | | JSON form object | List of 1 form object if successful | create a new form object |
| GET | /v1/forms/{id} | | | List of 1 form object if successful | list one specific form object matching {id} |
| DELETE | /v1/forms/{id} | | | Noting | delete an form object identified by {id} |
| PUT | /v1/forms/{id} | | JSON form object | List of 1 form object if successful | modify an form object identified by {id} |
#### Consumer URLs
The following endpoints are no API urls, but accessed directly by consumers using their browser or `wget` etc:
| URL | Description |
|-------------------------|---------------------------------------------------------|
| / | Display a short welcome message, can be customized |
| /download/{id}[/{file}] | Download link returned after an upload has been created |
| /form/{id} | Upload form for consumer |
#### API Objects
Response:
| Field | Data Type | Description |
|---------|-----------|---------------------------------------|
| success | bool | if true the request was successful |
| code | int | HTTP response code |
| message | string | error message, if any |
| uploads | array | list of upload objects (may be empty) |
| forms | array | list of form objects (may be empty) |
Upload:
| Field | Data Type | Description |
|----------|------------------|---------------------------------------------------------------------------------------------------------------------------------------------|
| id | string | unique identifier for the object |
| expire | string | when the upload has to expire, either "asap" or a Duration using numbers and the letters d,h,m,s (days,hours,minutes,seconds), e.g. 2d4h30m |
| file | string | filename after uploading, this is what a consumer gets when downloading it |
| members | array of strings | list of the original filenames |
| created | timestamp | time of object creation |
| context | string | the API context the upload has been created under |
| url | string | the download URL |
Form:
| Field | Data Type | Description |
|-------------|-----------|-------------------------------------------------------------------------------------------------------------------------------------------|
| id | string | unique identifier for the object |
| expire | string | when the form has to expire, either "asap" or a Duration using numbers and the letters d,h,m,s (days,hours,minutes,seconds), e.g. 2d4h30m |
| description | string | arbitrary description, shown on the form page |
| context | string | the API context the form has been created under and the uploaded files will be created on |
| notify | string | email address of the form creator, who gets an email once the consumer has uploaded files using the form |
| created | timestamp | time of object creation |
| url | string | the form URL |
Note: if the expire field for a form is not set or set to "asap" only
1 upload object can be created from it. However, if a duration has
been specified, the form can be used multiple times and thus creates
multiple upload objects.
## Client Usage
```
@@ -181,7 +258,7 @@ endpoint = "http://localhost:8080/api/v1"
apikey = "970b391f22f515d96b3e9b86a2c62c627968828e47b356994d2e583188b4190a"
```
The `endpoint` is the **Cenophane** server running somewhere and the
The `endpoint` is the **ephemerup** server running somewhere and the
`apikey` is the token you got from the server operator..

View File

@@ -23,8 +23,8 @@ import (
"errors"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/keyauth/v2"
"github.com/tlinden/cenophane/cfg"
"regexp"
"github.com/tlinden/ephemerup/cfg"
"github.com/tlinden/ephemerup/common"
)
// these vars can be savely global, since they don't change ever
@@ -39,8 +39,7 @@ var (
Message: "Invalid API key",
}
Authurls []*regexp.Regexp
Apikeys []cfg.Apicontext
Apikeys []cfg.Apicontext
)
// fill from server: accepted keys
@@ -48,13 +47,6 @@ 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)
@@ -66,6 +58,33 @@ func AuthErrHandler(ctx *fiber.Ctx, err error) error {
return ctx.JSON(errInvalid)
}
// validator hook, validates incoming api key against form id, which
// also acts as onetime api key
func AuthValidateOnetimeKey(c *fiber.Ctx, key string, db *Db) (bool, error) {
resp, err := db.Get("", key, common.TypeForm)
if err != nil {
return false, errors.New("Onetime key doesn't match any form id!")
}
if len(resp.Forms) != 1 {
return false, errors.New("db.Get(form) returned no results and no errors!")
}
sess, err := Sessionstore.Get(c)
// store the result into the session, the 'formid' key tells the
// upload handler that the apicontext it sees is in fact a form id
// and has to be deleted if set to asap.
sess.Set("apicontext", resp.Forms[0].Context)
sess.Set("formid", key)
if err := sess.Save(); err != nil {
return false, errors.New("Unable to save session store!")
}
return true, nil
}
// 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

View File

@@ -21,8 +21,8 @@ import (
"fmt"
//"github.com/alecthomas/repr"
"encoding/json"
"github.com/tlinden/cenophane/cfg"
"github.com/tlinden/cenophane/common"
"github.com/tlinden/ephemerup/cfg"
"github.com/tlinden/ephemerup/common"
bolt "go.etcd.io/bbolt"
"path/filepath"
"time"
@@ -42,7 +42,7 @@ func DeleteExpiredUploads(conf *cfg.Config, db *Db) error {
return fmt.Errorf("unable to unmarshal json: %s", err)
}
if IsExpired(conf, upload.Uploaded.Time, upload.Expire) {
if IsExpired(conf, upload.Created.Time, upload.Expire) {
if err := bucket.Delete([]byte(id)); err != nil {
return nil
}

View File

@@ -18,15 +18,14 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
package api
import (
"encoding/json"
"fmt"
"github.com/tlinden/cenophane/cfg"
"github.com/tlinden/cenophane/common"
"github.com/tlinden/ephemerup/cfg"
"github.com/tlinden/ephemerup/common"
//"github.com/alecthomas/repr"
bolt "go.etcd.io/bbolt"
)
const Bucket string = "uploads"
const Bucket string = "data"
// wrapper for bolt db
type Db struct {
@@ -44,14 +43,14 @@ func (db *Db) Close() {
db.bolt.Close()
}
func (db *Db) Insert(id string, entry *common.Upload) error {
func (db *Db) Insert(id string, entry common.Dbentry) 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)
jsonentry, err := entry.Marshal()
if err != nil {
return fmt.Errorf("json marshalling failure: %s", err)
}
@@ -61,9 +60,6 @@ func (db *Db) Insert(id string, entry *common.Upload) error {
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 {
@@ -87,12 +83,12 @@ func (db *Db) Delete(apicontext string, id string) error {
return fmt.Errorf("id %s not found", id)
}
upload := &common.Upload{}
if err := json.Unmarshal(j, &upload); err != nil {
entryContext, err := common.GetContext(j)
if err != nil {
return fmt.Errorf("unable to unmarshal json: %s", err)
}
if (apicontext != "" && (db.cfg.Super == apicontext || upload.Context == apicontext)) || apicontext == "" {
if (apicontext != "" && (db.cfg.Super == apicontext || entryContext == apicontext)) || apicontext == "" {
return bucket.Delete([]byte(id))
}
@@ -106,8 +102,8 @@ func (db *Db) Delete(apicontext string, id string) error {
return err
}
func (db *Db) List(apicontext string, filter string) (*common.Uploads, error) {
uploads := &common.Uploads{}
func (db *Db) List(apicontext string, filter string, t int) (*common.Response, error) {
response := &common.Response{}
err := db.bolt.View(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(Bucket))
@@ -116,24 +112,31 @@ func (db *Db) List(apicontext string, filter string) (*common.Uploads, error) {
}
err := bucket.ForEach(func(id, j []byte) error {
upload := &common.Upload{}
if err := json.Unmarshal(j, &upload); err != nil {
entry, err := common.Unmarshal(j, t)
if err != nil {
return fmt.Errorf("unable to unmarshal json: %s", err)
}
fmt.Printf("apicontext: %s, filter: %s\n", apicontext, filter)
var entryContext string
if t == common.TypeUpload {
entryContext = entry.(*common.Upload).Context
} else {
entryContext = entry.(*common.Form).Context
}
//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 {
if apicontext == entryContext {
// unless a filter needed OR no filter specified
if (filter != "" && upload.Context == filter) || filter == "" {
uploads.Entries = append(uploads.Entries, upload)
if (filter != "" && entryContext == filter) || filter == "" {
response.Append(entry)
}
}
} else {
// return all, because we operate a public service or current==super
if (filter != "" && upload.Context == filter) || filter == "" {
uploads.Entries = append(uploads.Entries, upload)
if (filter != "" && entryContext == filter) || filter == "" {
response.Append(entry)
}
}
@@ -143,12 +146,13 @@ func (db *Db) List(apicontext string, filter string) (*common.Uploads, error) {
return err // might be nil as well
})
return uploads, err
return response, err
}
// we only return one obj here, but could return more later
func (db *Db) Get(apicontext string, id string) (*common.Uploads, error) {
uploads := &common.Uploads{}
// FIXME: turn the id into a filter and call (Uploads|Forms)List(), same code!
func (db *Db) Get(apicontext string, id string, t int) (*common.Response, error) {
response := &common.Response{}
err := db.bolt.View(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(Bucket))
@@ -161,35 +165,42 @@ func (db *Db) Get(apicontext string, id string) (*common.Uploads, error) {
return fmt.Errorf("No upload object found with id %s", id)
}
upload := &common.Upload{}
if err := json.Unmarshal(j, &upload); err != nil {
entry, err := common.Unmarshal(j, t)
if err != nil {
return fmt.Errorf("unable to unmarshal json: %s", err)
}
if (apicontext != "" && (db.cfg.Super == apicontext || upload.Context == apicontext)) || apicontext == "" {
var entryContext string
if t == common.TypeUpload {
entryContext = entry.(*common.Upload).Context
} else {
entryContext = entry.(*common.Form).Context
}
if (apicontext != "" && (db.cfg.Super == apicontext || entryContext == apicontext)) || apicontext == "" {
// allowed if no context (public or download)
// or if context matches or if context==super
uploads.Entries = append(uploads.Entries, upload)
response.Append(entry)
}
return nil
})
return uploads, err
return response, err
}
// a wrapper around Lookup() which extracts the 1st upload, if any
func (db *Db) Lookup(apicontext string, id string) (*common.Upload, error) {
uploads, err := db.Get(apicontext, id)
func (db *Db) Lookup(apicontext string, id string, t int) (*common.Response, error) {
response, err := db.Get(apicontext, id, t)
if err != nil {
// non existent db entry with that id, or other db error, see logs
return &common.Upload{}, fmt.Errorf("No upload object found with id %s", id)
return &common.Response{}, fmt.Errorf("No upload object found with id %s", id)
}
if len(uploads.Entries) == 0 {
return &common.Upload{}, fmt.Errorf("No upload object found with id %s", id)
if len(response.Uploads) == 0 {
return &common.Response{}, fmt.Errorf("No upload object found with id %s", id)
}
return uploads.Entries[0], nil
return response, nil
}

231
api/db_test.go Normal file
View File

@@ -0,0 +1,231 @@
/*
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/maxatome/go-testdeep/td"
"github.com/tlinden/ephemerup/cfg"
"github.com/tlinden/ephemerup/common"
"os"
"testing"
"time"
)
func finalize(db *Db) {
if db.bolt != nil {
db.Close()
}
if _, err := os.Stat(db.cfg.DbFile); err == nil {
os.Remove(db.cfg.DbFile)
}
}
func TestNew(t *testing.T) {
var tests = []struct {
name string
dbfile string
wantfail bool
}{
{"opennew", "test.db", false},
{"openfail", "/hopefully/not/existing/directory/test.db", true},
}
for _, tt := range tests {
c := &cfg.Config{DbFile: tt.dbfile}
t.Run(tt.name, func(t *testing.T) {
db, err := NewDb(c)
defer finalize(db)
if err != nil && !tt.wantfail {
t.Errorf("expected: &Db{}, got err: " + err.Error())
}
if err == nil && tt.wantfail {
t.Errorf("expected: fail, got &Db{}")
}
})
}
}
const timeformat string = "2006-01-02T15:04:05.000Z"
var dbtests = []struct {
name string
dbfile string
wantfail bool
id string
context string
ts string
filter string
upload common.Upload
form common.Form
}{
{
"upload", "test.db", false, "1", "foo",
"2023-03-10T11:45:00.000Z", "",
common.Upload{
Id: "1", Expire: "asap", File: "none", Context: "foo",
Created: common.Timestamp{}},
common.Form{},
},
{
"form", "test.db", false, "2", "foo",
"2023-03-10T11:45:00.000Z", "",
common.Upload{},
common.Form{
Id: "1", Expire: "asap", Description: "none", Context: "foo",
Created: common.Timestamp{}},
},
}
/*
We need to test the whole Db operation in one run, because it
doesn't work well if using a global Db.
*/
func TestDboperation(t *testing.T) {
for _, tt := range dbtests {
c := &cfg.Config{DbFile: tt.dbfile}
t.Run(tt.name, func(t *testing.T) {
// create new bbolt db
db, err := NewDb(c)
defer finalize(db)
if err != nil {
t.Errorf("Could not open new DB: " + err.Error())
}
if tt.upload.Id != "" {
// set ts
ts, err := time.Parse(timeformat, tt.ts)
tt.upload.Created = common.Timestamp{Time: ts}
// create new upload db object
err = db.Insert(tt.id, tt.upload)
if err != nil {
t.Errorf("Could not insert new upload object: " + err.Error())
}
// fetch it
response, err := db.Get(tt.context, tt.id, common.TypeUpload)
if err != nil {
t.Errorf("Could not fetch upload object: " + err.Error())
}
// is it there?
if len(response.Uploads) != 1 {
t.Errorf("db.Get() did not return an upload obj")
}
// compare times
if !tt.upload.Created.Time.Equal(response.Uploads[0].Created.Time) {
t.Errorf("Timestamps don't match!\ngot: %s\nexp: %s\n",
response.Uploads[0].Created, tt.upload.Created)
}
// equal them artificially, because otherwise td will
// fail because of time.Time.wall+ext, or TZ is missing
response.Uploads[0].Created = tt.upload.Created
// compare
td.Cmp(t, response.Uploads[0], &tt.upload, tt.name)
// fetch list
response, err = db.List(tt.context, tt.filter, common.TypeUpload)
if err != nil {
t.Errorf("Could not fetch uploads list: " + err.Error())
}
// is it there?
if len(response.Uploads) != 1 {
t.Errorf("db.List() did not return upload obj[s]")
}
// delete
err = db.Delete(tt.context, tt.id)
if err != nil {
t.Errorf("Could not delete upload obj: " + err.Error())
}
// fetch again, shall return empty
response, err = db.Get(tt.context, tt.id, common.TypeUpload)
if err == nil {
t.Errorf("Could fetch upload object again although we deleted it")
}
}
if tt.form.Id != "" {
// set ts
ts, err := time.Parse(timeformat, tt.ts)
tt.form.Created = common.Timestamp{Time: ts}
// create new form db object
err = db.Insert(tt.id, tt.form)
if err != nil {
t.Errorf("Could not insert new form object: " + err.Error())
}
// fetch it
response, err := db.Get(tt.context, tt.id, common.TypeForm)
if err != nil {
t.Errorf("Could not fetch form object: " + err.Error())
}
// is it there?
if len(response.Forms) != 1 {
t.Errorf("db.Get() did not return an form obj")
}
// compare times
if !tt.form.Created.Time.Equal(response.Forms[0].Created.Time) {
t.Errorf("Timestamps don't match!\ngot: %s\nexp: %s\n",
response.Forms[0].Created, tt.form.Created)
}
// equal them artificially, because otherwise td will
// fail because of time.Time.wall+ext, or TZ is missing
response.Forms[0].Created = tt.form.Created
// compare
td.Cmp(t, response.Forms[0], &tt.form, tt.name)
// fetch list
response, err = db.List(tt.context, tt.filter, common.TypeForm)
if err != nil {
t.Errorf("Could not fetch forms list: " + err.Error())
}
// is it there?
if len(response.Forms) != 1 {
t.Errorf("db.FormsList() did not return form obj[s]")
}
// delete
err = db.Delete(tt.context, tt.id)
if err != nil {
t.Errorf("Could not delete form obj: " + err.Error())
}
// fetch again, shall return empty
response, err = db.Get(tt.context, tt.id, common.TypeForm)
if err == nil {
t.Errorf("Could fetch form object again although we deleted it")
}
}
})
}
}

View File

@@ -21,8 +21,8 @@ import (
"archive/zip"
"errors"
"github.com/gofiber/fiber/v2"
"github.com/tlinden/cenophane/cfg"
"github.com/tlinden/cenophane/common"
"github.com/tlinden/ephemerup/cfg"
"github.com/tlinden/ephemerup/common"
"io"
"mime/multipart"
"os"
@@ -83,7 +83,7 @@ func ProcessFormFiles(cfg *cfg.Config, members []string, id string) (string, str
return "", "", err
}
returnUrl = strings.Join([]string{cfg.Url + cfg.ApiPrefix + ApiVersion, "file", id, zipfile}, "/")
returnUrl = strings.Join([]string{cfg.Url, "download", id, zipfile}, "/")
Filename = zipfile
// clean up after us

230
api/form_handlers.go Normal file
View File

@@ -0,0 +1,230 @@
/*
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/ephemerup/cfg"
"github.com/tlinden/ephemerup/common"
"bytes"
"html/template"
"strings"
"time"
)
func FormCreate(c *fiber.Ctx, cfg *cfg.Config, db *Db) error {
id := uuid.NewString()
var formdata common.Form
// init form obj
entry := &common.Form{Id: id, Created: common.Timestamp{Time: time.Now()}}
// retrieve the API Context name from the session
apicontext, err := SessionGetApicontext(c)
if err != nil {
return JsonStatus(c, fiber.StatusInternalServerError,
"Unable to initialize session store from context: "+err.Error())
}
entry.Context = apicontext
// 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 := common.Untaint(formdata.Expire, cfg.RegDuration) // duration or asap allowed
if err != nil {
return JsonStatus(c, fiber.StatusForbidden,
"Invalid expire data: "+err.Error())
}
entry.Expire = ex
}
if len(formdata.Notify) != 0 {
nt, err := common.Untaint(formdata.Notify, cfg.RegEmail)
if err != nil {
return JsonStatus(c, fiber.StatusForbidden,
"Invalid email address: "+err.Error())
}
entry.Notify = nt
}
// get url [and zip if there are multiple files]
returnUrl := strings.Join([]string{cfg.Url, "form", id}, "/")
entry.Url = returnUrl
Log("Now serving %s", returnUrl)
Log("Expire set to: %s", entry.Expire)
Log("Form created 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 := &common.Response{Forms: []*common.Form{entry}}
res.Success = true
res.Code = fiber.StatusOK
return c.Status(fiber.StatusOK).JSON(res)
}
// delete form
func FormDelete(c *fiber.Ctx, cfg *cfg.Config, db *Db) error {
id, err := common.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 := SessionGetApicontext(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 form with that id could be found!")
}
return nil
}
// returns the whole list + error code, no post processing by server
func FormsList(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 := common.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 := SessionGetApicontext(c)
if err != nil {
return JsonStatus(c, fiber.StatusInternalServerError,
"Unable to initialize session store from context: "+err.Error())
}
// get list
response, err := db.List(apicontext, filter, common.TypeForm)
if err != nil {
return JsonStatus(c, fiber.StatusForbidden,
"Unable to list forms: "+err.Error())
}
// if we reached this point we can signal success
response.Success = true
response.Code = fiber.StatusOK
return c.Status(fiber.StatusOK).JSON(response)
}
// returns just one form obj + error code
func FormDescribe(c *fiber.Ctx, cfg *cfg.Config, db *Db) error {
id, err := common.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 := SessionGetApicontext(c)
if err != nil {
return JsonStatus(c, fiber.StatusInternalServerError,
"Unable to initialize session store from context: "+err.Error())
}
response, err := db.Get(apicontext, id, common.TypeForm)
if err != nil {
return JsonStatus(c, fiber.StatusForbidden,
"No form with that id could be found!")
}
for _, form := range response.Forms {
form.Url = strings.Join([]string{cfg.Url, "form", id}, "/")
}
// if we reached this point we can signal success
response.Success = true
response.Code = fiber.StatusOK
return c.Status(fiber.StatusOK).JSON(response)
}
/*
Render the upload html form. Template given by --formpage, stored
as text in cfg.Formpage. It will be rendered using golang's
template engine, data to be filled in is the form matching the
given id.
*/
func FormPage(c *fiber.Ctx, cfg *cfg.Config, db *Db, shallexpire bool) error {
id, err := common.Untaint(c.Params("id"), cfg.RegKey)
if err != nil {
return c.Status(fiber.StatusForbidden).SendString("Invalid id provided!")
}
apicontext, err := SessionGetApicontext(c)
if err != nil {
return c.Status(fiber.StatusInternalServerError).SendString("Unable to initialize session store from context:" + err.Error())
}
response, err := db.Get(apicontext, id, common.TypeForm)
if err != nil || len(response.Forms) == 0 {
return c.Status(fiber.StatusForbidden).SendString("No form with that id could be found!")
}
t := template.New("form")
if t, err = t.Parse(cfg.Formpage); err != nil {
return c.Status(fiber.StatusInternalServerError).SendString("Unable to load form template: " + err.Error())
}
// prepare upload url
uploadurl := strings.Join([]string{cfg.ApiPrefix + ApiVersion, "uploads"}, "/")
response.Forms[0].Url = uploadurl
var out bytes.Buffer
if err := t.Execute(&out, response.Forms[0]); err != nil {
return c.Status(fiber.StatusInternalServerError).SendString("Unable to render form template: " + err.Error())
}
c.Set("Content-type", "text/html; charset=utf-8")
return c.Status(fiber.StatusOK).SendString(out.String())
}

54
api/mail.go Normal file
View File

@@ -0,0 +1,54 @@
/*
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/tlinden/ephemerup/cfg"
"net/smtp"
)
var mailtpl string = `To: %s\r
From: %s\r
Subject: %s\r
\r
%s\r
`
/*
Send an email via an external mail gateway. SMTP Auth is
required. Errors may occur with a time delay, like server timeouts
etc. So only call it detached via go routine.
*/
func Sendmail(c *cfg.Config, recipient string, body string, subject string) error {
// Message.
message := []byte(fmt.Sprintf(mailtpl, recipient, c.Mail.From, subject, body))
// Authentication.
auth := smtp.PlainAuth("", c.Mail.From, c.Mail.Password, c.Mail.Server)
// Sending email.
Log("Trying to send mail to %s via %s:%s with subject %s",
recipient, c.Mail.Server, c.Mail.Port, subject)
err := smtp.SendMail(c.Mail.Server+":"+c.Mail.Port, auth, c.Mail.From, []string{recipient}, []byte(message))
if err != nil {
return err
}
return nil
}

View File

@@ -26,8 +26,8 @@ import (
"github.com/gofiber/fiber/v2/middleware/requestid"
"github.com/gofiber/fiber/v2/middleware/session"
"github.com/gofiber/keyauth/v2"
"github.com/tlinden/cenophane/cfg"
"github.com/tlinden/cenophane/common"
"github.com/tlinden/ephemerup/cfg"
"github.com/tlinden/ephemerup/common"
)
// sessions are context specific and can be global savely
@@ -47,7 +47,7 @@ func Runserver(conf *cfg.Config, args []string) error {
defer db.Close()
// setup authenticated endpoints
auth := SetupAuthStore(conf)
auth := SetupAuthStore(conf, db)
// setup api server
router := SetupServer(conf)
@@ -56,32 +56,50 @@ func Runserver(conf *cfg.Config, args []string) error {
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)
api.Post("/uploads", auth, func(c *fiber.Ctx) error {
return UploadPost(c, conf, db)
})
// remove
api.Delete("/file/:id/", auth, func(c *fiber.Ctx) error {
err := DeleteUpload(c, conf, db)
api.Delete("/uploads/:id", auth, func(c *fiber.Ctx) error {
err := UploadDelete(c, conf, db)
return SendResponse(c, "", err)
})
// listing
api.Get("/list/", auth, func(c *fiber.Ctx) error {
return List(c, conf, db)
api.Get("/uploads", auth, func(c *fiber.Ctx) error {
return UploadsList(c, conf, db)
})
// info
api.Get("/upload/:id/", auth, func(c *fiber.Ctx) error {
return Describe(c, conf, db)
// info/describe
api.Get("/uploads/:id", auth, func(c *fiber.Ctx) error {
return UploadDescribe(c, conf, db)
})
// download w/o expire
api.Get("/uploads/:id/file", auth, func(c *fiber.Ctx) error {
return UploadFetch(c, conf, db)
})
// same for forms ************
api.Post("/forms", auth, func(c *fiber.Ctx) error {
return FormCreate(c, conf, db)
})
// remove
api.Delete("/forms/:id", auth, func(c *fiber.Ctx) error {
err := FormDelete(c, conf, db)
return SendResponse(c, "", err)
})
// listing
api.Get("/forms", auth, func(c *fiber.Ctx) error {
return FormsList(c, conf, db)
})
// info/describe
api.Get("/forms/:id", auth, func(c *fiber.Ctx) error {
return FormDescribe(c, conf, db)
})
}
@@ -92,12 +110,17 @@ func Runserver(conf *cfg.Config, args []string) error {
})
router.Get("/download/:id/:file", func(c *fiber.Ctx) error {
return FileGet(c, conf, db, shallExpire)
return UploadFetch(c, conf, db, shallExpire)
})
router.Get("/download/:id/", func(c *fiber.Ctx) error {
return FileGet(c, conf, db, shallExpire)
router.Get("/download/:id", func(c *fiber.Ctx) error {
return UploadFetch(c, conf, db, shallExpire)
})
router.Get("/form/:id", func(c *fiber.Ctx) error {
return FormPage(c, conf, db, shallExpire)
})
}
// setup cleaner
@@ -112,12 +135,23 @@ func Runserver(conf *cfg.Config, args []string) error {
return router.Listen(conf.Listen)
}
func SetupAuthStore(conf *cfg.Config) func(*fiber.Ctx) error {
AuthSetEndpoints(conf.ApiPrefix, ApiVersion, []string{"/file"})
func SetupAuthStore(conf *cfg.Config, db *Db) func(*fiber.Ctx) error {
AuthSetApikeys(conf.Apicontexts)
return keyauth.New(keyauth.Config{
Validator: AuthValidateAPIKey,
Validator: func(c *fiber.Ctx, key string) (bool, error) {
// we use a wrapper closure to be able to forward the db object
formuser, err := AuthValidateOnetimeKey(c, key, db)
// incoming apicontext matches a form id, accept it
if err == nil {
Log("Incoming API Context equals formuser: %t, id: %s", formuser, key)
return formuser, err
}
// nope, we need to check against regular configured apicontexts
return AuthValidateAPIKey(c, key)
},
ErrorHandler: AuthErrHandler,
})
}
@@ -128,7 +162,7 @@ func SetupServer(conf *cfg.Config) *fiber.App {
StrictRouting: true,
Immutable: true,
Prefork: conf.Prefork,
ServerHeader: "Cenophane Server",
ServerHeader: "ephemerup Server",
AppName: conf.AppName,
BodyLimit: conf.BodyLimit,
Network: conf.Network,

View File

@@ -21,9 +21,10 @@ import (
//"github.com/alecthomas/repr"
"github.com/gofiber/fiber/v2"
"github.com/google/uuid"
"github.com/tlinden/cenophane/cfg"
"github.com/tlinden/cenophane/common"
"github.com/tlinden/ephemerup/cfg"
"github.com/tlinden/ephemerup/common"
"fmt"
"os"
"path/filepath"
"strings"
@@ -34,7 +35,7 @@ type SetContext struct {
Apicontext string `json:"apicontext" form:"apicontext"`
}
func FilePut(c *fiber.Ctx, cfg *cfg.Config, db *Db) error {
func UploadPost(c *fiber.Ctx, cfg *cfg.Config, db *Db) error {
// supports upload of multiple files with:
//
// curl -X POST localhost:8080/putfile \
@@ -62,10 +63,10 @@ func FilePut(c *fiber.Ctx, cfg *cfg.Config, db *Db) error {
}
// init upload obj
entry := &common.Upload{Id: id, Uploaded: common.Timestamp{Time: time.Now()}}
entry := &common.Upload{Id: id, Created: common.Timestamp{Time: time.Now()}}
// retrieve the API Context name from the session
apicontext, err := GetApicontext(c)
apicontext, err := SessionGetApicontext(c)
if err != nil {
return JsonStatus(c, fiber.StatusInternalServerError,
"Unable to initialize session store from context: "+err.Error())
@@ -106,6 +107,7 @@ func FilePut(c *fiber.Ctx, cfg *cfg.Config, db *Db) error {
"Could not process uploaded file[s]: "+err.Error())
}
entry.File = Newfilename
entry.Url = returnUrl
Log("Now serving %s from %s/%s", returnUrl, cfg.StorageDir, id)
Log("Expire set to: %s", entry.Expire)
@@ -115,14 +117,41 @@ func FilePut(c *fiber.Ctx, cfg *cfg.Config, db *Db) error {
go db.Insert(id, entry)
// everything went well so far
res := &common.Uploads{Entries: []*common.Upload{entry}}
res := &common.Response{Uploads: []*common.Upload{entry}}
res.Success = true
res.Message = "Download url: " + returnUrl
res.Code = fiber.StatusOK
// ok, check if we need to remove a form, if so we do it in the
// background. delete error doesn't lead to upload failure, we
// only log it. same applies to mail notification.
formid, _ := SessionGetFormId(c)
if formid != "" {
go func() {
r, err := db.Get(apicontext, formid, common.TypeForm)
if err == nil {
if len(r.Forms) == 1 {
if r.Forms[0].Expire == "asap" {
db.Delete(apicontext, formid)
}
// email notification to form creator
if r.Forms[0].Notify != "" {
body := fmt.Sprintf("Upload is available under: %s", returnUrl)
subject := fmt.Sprintf("Upload form %s has been used", formid)
err := Sendmail(cfg, r.Forms[0].Notify, body, subject)
if err != nil {
Log("Failed to send mail: %s", err.Error())
}
}
}
}
}()
}
return c.Status(fiber.StatusOK).JSON(res)
}
func FileGet(c *fiber.Ctx, cfg *cfg.Config, db *Db, shallExpire ...bool) error {
func UploadFetch(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
@@ -133,18 +162,23 @@ func FileGet(c *fiber.Ctx, cfg *cfg.Config, db *Db, shallExpire ...bool) error {
}
// retrieve the API Context name from the session
apicontext, err := GetApicontext(c)
apicontext, err := SessionGetApicontext(c)
if err != nil {
return JsonStatus(c, fiber.StatusInternalServerError,
"Unable to initialize session store from context: "+err.Error())
}
upload, err := db.Lookup(apicontext, id)
response, err := db.Lookup(apicontext, id, common.TypeUpload)
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!")
}
var upload *common.Upload
if len(response.Uploads) > 0 {
upload = response.Uploads[0]
}
file := upload.File
filename := filepath.Join(cfg.StorageDir, id, file)
@@ -173,7 +207,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 {
func UploadDelete(c *fiber.Ctx, cfg *cfg.Config, db *Db) error {
id, err := common.Untaint(c.Params("id"), cfg.RegKey)
if err != nil {
@@ -187,7 +221,7 @@ func DeleteUpload(c *fiber.Ctx, cfg *cfg.Config, db *Db) error {
}
// retrieve the API Context name from the session
apicontext, err := GetApicontext(c)
apicontext, err := SessionGetApicontext(c)
if err != nil {
return JsonStatus(c, fiber.StatusInternalServerError,
"Unable to initialize session store from context: "+err.Error())
@@ -206,7 +240,7 @@ func DeleteUpload(c *fiber.Ctx, cfg *cfg.Config, db *Db) error {
}
// returns the whole list + error code, no post processing by server
func List(c *fiber.Ctx, cfg *cfg.Config, db *Db) error {
func UploadsList(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 {
@@ -221,14 +255,14 @@ func List(c *fiber.Ctx, cfg *cfg.Config, db *Db) error {
}
// retrieve the API Context name from the session
apicontext, err := GetApicontext(c)
apicontext, err := SessionGetApicontext(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)
uploads, err := db.List(apicontext, filter, common.TypeUpload)
if err != nil {
return JsonStatus(c, fiber.StatusForbidden,
"Unable to list uploads: "+err.Error())
@@ -242,7 +276,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 {
func UploadDescribe(c *fiber.Ctx, cfg *cfg.Config, db *Db) error {
id, err := common.Untaint(c.Params("id"), cfg.RegKey)
if err != nil {
return JsonStatus(c, fiber.StatusForbidden,
@@ -250,25 +284,25 @@ func Describe(c *fiber.Ctx, cfg *cfg.Config, db *Db) error {
}
// retrieve the API Context name from the session
apicontext, err := GetApicontext(c)
apicontext, err := SessionGetApicontext(c)
if err != nil {
return JsonStatus(c, fiber.StatusInternalServerError,
"Unable to initialize session store from context: "+err.Error())
}
uploads, err := db.Get(apicontext, id)
response, err := db.Get(apicontext, id, common.TypeUpload)
if err != nil {
return JsonStatus(c, fiber.StatusForbidden,
"No upload with that id could be found!")
}
for _, upload := range uploads.Entries {
for _, upload := range response.Uploads {
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
response.Success = true
response.Code = fiber.StatusOK
return c.Status(fiber.StatusOK).JSON(uploads)
return c.Status(fiber.StatusOK).JSON(response)
}

View File

@@ -20,8 +20,8 @@ package api
import (
"fmt"
"github.com/gofiber/fiber/v2"
"github.com/tlinden/cenophane/cfg"
"github.com/tlinden/cenophane/common"
"github.com/tlinden/ephemerup/cfg"
"github.com/tlinden/ephemerup/common"
"time"
)
@@ -55,7 +55,7 @@ func Ts() string {
If there's no apicontext in the session, assume unauth user, return ""
*/
func GetApicontext(c *fiber.Ctx) (string, error) {
func SessionGetApicontext(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())
@@ -69,6 +69,25 @@ func GetApicontext(c *fiber.Ctx) (string, error) {
return "", nil
}
/*
Retrieve the formid (aka onetime api key) from the session. It is
configured if an upload request has been successfully authenticated
using a onetime key.
*/
func SessionGetFormId(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())
}
formid := sess.Get("formid")
if formid != nil {
return formid.(string), nil
}
return "", nil
}
/*
Calculate if time is up based on start time.Time and
duration. Returns true if time is expired. Start time comes from

View File

@@ -32,8 +32,16 @@ type Apicontext struct {
Key string `koanf:"key"`
}
type Mailsettings struct {
Server string `koanf:"server"`
Port string `koanf:"port"`
From string `koanf:"from"`
Password string `koanf:"password"`
}
// holds the whole configs, filled by commandline flags, env and config file
type Config struct {
// Flags+config file settings
ApiPrefix string `koanf:"apiprefix"` // path prefix
Debug bool `koanf:"debug"`
Listen string `koanf:"listen"` // [host]:port
@@ -42,6 +50,7 @@ type Config struct {
DbFile string `koanf:"dbfile"`
Super string `koanf:"super"` // the apicontext which has all permissions
Frontpage string `koanf:"frontpage"` // a html file
Formpage string `koanf:"formpage"` // a html file
// fiber settings, see:
// https://docs.gofiber.io/api/fiber/#config
@@ -55,10 +64,14 @@ type Config struct {
// only settable via config
Apicontexts []Apicontext `koanf:"apicontext"`
// smtp settings
Mail Mailsettings `koanf:mail`
// Internals only
RegNormalizedFilename *regexp.Regexp
RegDuration *regexp.Regexp
RegKey *regexp.Regexp
RegEmail *regexp.Regexp
CleanInterval time.Duration
DefaultExpire int
}
@@ -70,7 +83,7 @@ func Getversion() string {
// main branch, and cfg.Version-$branch-$lastcommit-$date on
// development branch
return fmt.Sprintf("This is cenophane server version %s", VERSION)
return fmt.Sprintf("This is ephemerup server version %s", VERSION)
}
func (c *Config) GetVersion() string {
@@ -105,6 +118,8 @@ func (c *Config) ApplyDefaults() {
c.RegNormalizedFilename = regexp.MustCompile(`[^\w\d\-_\.]`)
c.RegDuration = regexp.MustCompile(`[^dhms0-9]`)
c.RegKey = regexp.MustCompile(`[^a-zA-Z0-9\-]`)
c.RegEmail = regexp.MustCompile(`^[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,4}$`)
c.RegEmail = regexp.MustCompile(`[^a-z0-9._%+\-@0-9]`)
c.CleanInterval = 10 * time.Second
c.DefaultExpire = 30 * 86400 // 1 month

102
cmd/formtemplate.go Normal file
View File

@@ -0,0 +1,102 @@
package cmd
const formtemplate = `
<!DOCTYPE html>
<!-- -*-web-*- -->
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="description" content="upload form" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>File upload form</title>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha2/dist/css/bootstrap.min.css"
rel="stylesheet" integrity="sha384-aFq/bzH65dt+w6FI2ooMVUpc+21e0SRygnTpmBvdBgSdnuTN7QbdgL+OapgHtvPp" crossorigin="anonymous">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.5.0/font/bootstrap-icons.css">
</head>
<body>
<div class="container">
<h4>Upload form {{ .Id }}</h4>
<!-- Response -->
<div class="statusMsg"></div>
<!-- File upload form -->
<div class="col-lg-12">
<form id="UploadForm" enctype="multipart/form-data" action="/v1/uploads" method="POST">
<div class="mb-3 row">
<p>
Use this form to upload one or more files. The creator of the form will automatically get notified.
</p>
</div>
<div class="mb-3 row">
<label for="file" class="col-sm-2 col-form-label">Select</label>
<div class="col-sm-10">
<input type="file" class="form-control" id="file" name="uploads[]" multiple
/>
</div>
</div>
<div class="mb-3 row">
<label for="display" class="col-sm-2 col-form-label">Selected Files</label>
<div class="col-sm-10">
<!-- <input type="textara" class="form-control" id="upload-file-info" readonly>-->
<div id="upload-file-info"></div>
</div>
</div>
<input type="submit" name="submit" class="btn btn-success submitBtn" value="Upload"/>
</form>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha2/dist/js/bootstrap.bundle.min.js"
integrity="sha384-qKXV1j0HvMUeCBQ+QVp7JcfGl760yU08IQ+GpUo5hlbpg51QRiuqHAJz8+BrxE/N" crossorigin="anonymous"></script>
<script>
$(document).ready(function(){
// Submit form data via Ajax
$("#UploadForm").on('submit', function(e){
e.preventDefault();
$.ajax({
type: 'POST',
url: '/v1/uploads',
data: new FormData(this),
dataType: 'json',
contentType: false,
cache: false,
processData:false,
beforeSend: function(xhr){
$('.submitBtn').attr("disabled","disabled");
$('#UploadForm').css("opacity",".5");
xhr.setRequestHeader('Authorization', 'Bearer {{.Id}}');
},
success: function(response){
$('.statusMsg').html('');
if(response.success){
$('#UploadForm')[0].reset();
$('.statusMsg').html('<p class="alert alert-success">Your upload is available at <code>'
+response.uploads[0].url+'</code> for download</p>');
$('#UploadForm').hide();
}else{
$('.statusMsg').html('<p class="alert alert-danger">'+response.message+'</p>');
}
$('#UploadForm').css("opacity","");
$(".submitBtn").removeAttr("disabled");
}
});
});
$("#file").on('change', function() {
$("#upload-file-info").empty();
for (var i = 0; i < $(this).get(0).files.length; ++i) {
$("#upload-file-info").append('<i class="bi-check-lg"></i> ' + $(this).get(0).files[i].name + '<br>');
}
});
});
</script>
</body>
</html>
`

View File

@@ -29,8 +29,8 @@ import (
flag "github.com/spf13/pflag"
"github.com/alecthomas/repr"
"github.com/tlinden/cenophane/api"
"github.com/tlinden/cenophane/cfg"
"github.com/tlinden/ephemerup/api"
"github.com/tlinden/ephemerup/cfg"
"io/ioutil"
"os"
@@ -59,19 +59,20 @@ func Execute() error {
f.BoolVarP(&conf.Debug, "debug", "d", false, "Enable debugging")
f.StringVarP(&conf.Listen, "listen", "l", ":8080", "listen to custom ip:port (use [ip]:port for ipv6)")
f.StringVarP(&conf.StorageDir, "storagedir", "s", "/tmp", "storage directory for uploaded files")
f.StringVarP(&conf.ApiPrefix, "apiprefix", "a", "/api", "API endpoint path")
f.StringVarP(&conf.ApiPrefix, "apiprefix", "a", "", "API endpoint path")
f.StringVarP(&conf.Url, "url", "u", "", "HTTP endpoint w/o path")
f.StringVarP(&conf.DbFile, "dbfile", "D", "/tmp/uploads.db", "Bold database file to use")
f.StringVarP(&conf.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")
f.StringVarP(&conf.Formpage, "formpage", "", "", "Content or filename to be displayed for forms (must be a go template)")
// server settings
f.BoolVarP(&conf.V4only, "ipv4", "4", false, "Only listen on ipv4")
f.BoolVarP(&conf.V6only, "ipv6", "6", false, "Only listen on ipv6")
f.BoolVarP(&conf.Prefork, "prefork", "p", false, "Prefork server threads")
f.StringVarP(&conf.AppName, "appname", "n", "cenod "+conf.GetVersion(), "App name to say hi as")
f.StringVarP(&conf.AppName, "appname", "n", "ephemerupd "+conf.GetVersion(), "App name to say hi as")
f.IntVarP(&conf.BodyLimit, "bodylimit", "b", 10250000000, "Max allowed upload size in bytes")
f.Parse(os.Args[1:])
@@ -91,10 +92,10 @@ func Execute() error {
configfiles = []string{configfile}
} else {
configfiles = []string{
"/etc/cenod.hcl", "/usr/local/etc/cenod.hcl", // unix variants
filepath.Join(os.Getenv("HOME"), ".config", "cenod", "cenod.hcl"),
filepath.Join(os.Getenv("HOME"), ".cenod"),
"cenod.hcl",
"/etc/ephemerupd.hcl", "/usr/local/etc/ephemerupd.hcl", // unix variants
filepath.Join(os.Getenv("HOME"), ".config", "ephemerupd", "ephemerupd.hcl"),
filepath.Join(os.Getenv("HOME"), ".ephemerupd"),
"ephemerupd.hcl",
}
}
@@ -108,9 +109,9 @@ func Execute() error {
}
// env overrides config file
k.Load(env.Provider("CENOD_", ".", func(s string) string {
k.Load(env.Provider("EPHEMERUPD_", ".", func(s string) string {
return strings.Replace(strings.ToLower(
strings.TrimPrefix(s, "CENOD_")), "_", ".", -1)
strings.TrimPrefix(s, "EPHEMERUPD_")), "_", ".", -1)
}), nil)
// command line overrides env
@@ -142,6 +143,23 @@ func Execute() error {
}
}
// Formpage?
if conf.Formpage != "" {
if _, err := os.Stat(conf.Formpage); err == nil {
// it's a filename, try to use it
content, err := ioutil.ReadFile(conf.Formpage)
if err != nil {
return errors.New("error loading config: " + err.Error())
}
// replace the filename
conf.Formpage = string(content)
}
} else {
// use builtin default
conf.Formpage = formtemplate
}
switch {
case ShowVersion:
fmt.Println(cfg.Getversion())
@@ -157,11 +175,11 @@ func Execute() error {
Multiple env vars are supported in this format:
CENOD_CONTEXT_$(NAME)="<context>:<key>"
EPHEMERUPD_CONTEXT_$(NAME)="<context>:<key>"
eg:
CENOD_CONTEXT_SUPPORT="support:tymag-fycyh-gymof-dysuf-doseb-puxyx"
EPHEMERUPD_CONTEXT_SUPPORT="support:tymag-fycyh-gymof-dysuf-doseb-puxyx"
^^^^^^^- doesn't matter.
Modifies cfg.Config directly
@@ -171,7 +189,7 @@ func GetApicontextsFromEnv(conf *cfg.Config) {
for _, envvar := range os.Environ() {
pair := strings.SplitN(envvar, "=", 2)
if strings.HasPrefix(pair[0], "CENOD_CONTEXT_") {
if strings.HasPrefix(pair[0], "EPHEMERUPD_CONTEXT_") {
c := strings.SplitN(pair[1], ":", 2)
if len(c) == 2 {
contexts = append(contexts, cfg.Apicontext{Context: c[0], Key: c[1]})

View File

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

View File

@@ -17,6 +17,11 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
package common
import (
"encoding/json"
"fmt"
)
// used to return to the api client
type Result struct {
Success bool `json:"success"`
@@ -24,20 +29,137 @@ type Result struct {
Code int `json:"code"`
}
// upload or form structs
type Dbentry interface {
Getcontext(j []byte) (string, error)
Marshal() ([]byte, error)
}
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"`
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
Created 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"`
type Response struct {
Uploads []*Upload `json:"uploads"`
Forms []*Form `json:"forms"`
// integrate the Result struct so we can signal success
Result
}
type Form struct {
// Note the dual use of the Id: it will be used as onetime api key
// from generated upload forms and stored in the session store so
// that the upload handler is able to check if the form object has
// to be deleted immediately (if its expire field has been set to
// asap)
Id string `json:"id"`
Expire string `json:"expire"`
Description string `json:"description"`
Created Timestamp `json:"uploaded"`
Context string `json:"context"`
Url string `json:"url"`
Notify string `json:"notify"`
}
const (
TypeUpload = iota
TypeForm
)
/*
implement Dbentry interface
*/
func (upload Upload) Getcontext(j []byte) (string, error) {
if err := json.Unmarshal(j, &upload); err != nil {
return "", fmt.Errorf("unable to unmarshal json: %s", err)
}
return upload.Context, nil
}
func (form Form) Getcontext(j []byte) (string, error) {
if err := json.Unmarshal(j, &form); err != nil {
return "", fmt.Errorf("unable to unmarshal json: %s", err)
}
return form.Context, nil
}
func (upload Upload) Marshal() ([]byte, error) {
jsonentry, err := json.Marshal(upload)
if err != nil {
return nil, fmt.Errorf("json marshalling failure: %s", err)
}
return jsonentry, nil
}
func (form Form) Marshal() ([]byte, error) {
jsonentry, err := json.Marshal(form)
if err != nil {
return nil, fmt.Errorf("json marshalling failure: %s", err)
}
return jsonentry, nil
}
/*
Response methods
*/
func (r *Response) Append(entry Dbentry) {
switch entry.(type) {
case *Upload:
r.Uploads = append(r.Uploads, entry.(*Upload))
case Upload:
r.Uploads = append(r.Uploads, entry.(*Upload))
case Form:
r.Forms = append(r.Forms, entry.(*Form))
case *Form:
r.Forms = append(r.Forms, entry.(*Form))
default:
panic("unknown type!")
}
}
/*
Extract context, whatever kind entry is, but we don't know in
advance, only after unmarshalling. So try Upload first, if that
fails, try Form.
*/
func GetContext(j []byte) (string, error) {
upload := &Upload{}
entryContext, err := upload.Getcontext(j)
if err != nil {
form := &Form{}
entryContext, err = form.Getcontext(j)
if err != nil {
return "", fmt.Errorf("unable to unmarshal json: %s", err)
}
}
return entryContext, nil
}
func Unmarshal(j []byte, t int) (Dbentry, error) {
if t == TypeUpload {
upload := &Upload{}
if err := json.Unmarshal(j, &upload); err != nil {
return upload, fmt.Errorf("unable to unmarshal json: %s", err)
}
return upload, nil
} else {
form := &Form{}
if err := json.Unmarshal(j, &form); err != nil {
return form, fmt.Errorf("unable to unmarshal json: %s", err)
}
return form, nil
}
}

View File

@@ -1,6 +1,6 @@
version: "3.9"
services:
cenophane:
ephemerup:
build: .
ports:
- "8080:8080"

View File

@@ -17,3 +17,10 @@ apicontext = [
# this is the root context with all permissions
super = "root"
mail = {
server = "localhost"
port = "25"
from = "root@localhost"
password = ""
}

8
go.mod
View File

@@ -1,4 +1,4 @@
module github.com/tlinden/cenophane
module github.com/tlinden/ephemerup
go 1.18
@@ -13,12 +13,13 @@ 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
github.com/tlinden/ephemerup/common v0.0.0-00010101000000-000000000000
go.etcd.io/bbolt v1.3.7
)
require (
github.com/andybalholm/brotli v1.0.4 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/fsnotify/fsnotify v1.6.0 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/klauspost/compress v1.15.9 // indirect
@@ -26,6 +27,7 @@ require (
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.17 // indirect
github.com/mattn/go-runewidth v0.0.14 // indirect
github.com/maxatome/go-testdeep v1.13.0 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
@@ -40,4 +42,4 @@ require (
golang.org/x/sys v0.4.0 // indirect
)
replace github.com/tlinden/cenophane/common => ./common
replace github.com/tlinden/ephemerup/common => ./common

2
go.sum
View File

@@ -35,6 +35,8 @@ github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPn
github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU=
github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/maxatome/go-testdeep v1.13.0 h1:EBmRelH7MhMfPvA+0kXAeOeJUXn3mzul5NmvjLDcQZI=
github.com/maxatome/go-testdeep v1.13.0/go.mod h1:lPZc/HAcJMP92l7yI6TRz1aZN5URwUBUAfUNvrclaNM=
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=

View File

@@ -18,7 +18,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
package main
import (
"github.com/tlinden/cenophane/cmd"
"github.com/tlinden/ephemerup/cmd"
"log"
)

View File

@@ -0,0 +1,98 @@
<!DOCTYPE html>
<!-- -*-web-*- -->
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="description" content="upload form" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>File upload form</title>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha2/dist/css/bootstrap.min.css"
rel="stylesheet" integrity="sha384-aFq/bzH65dt+w6FI2ooMVUpc+21e0SRygnTpmBvdBgSdnuTN7QbdgL+OapgHtvPp" crossorigin="anonymous">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.5.0/font/bootstrap-icons.css">
</head>
<body>
<div class="container">
<h4>Upload form {{ .Id }}</h4>
<!-- Response -->
<div class="statusMsg"></div>
<!-- File upload form -->
<div class="col-lg-12">
<form id="UploadForm" enctype="multipart/form-data" action="/v1/uploads" method="POST">
<div class="mb-3 row">
<p>
Use this form to upload one or more files. The creator of the form will automatically get notified.
</p>
</div>
<div class="mb-3 row">
<label for="file" class="col-sm-2 col-form-label">Select</label>
<div class="col-sm-10">
<input type="file" class="form-control" id="file" name="uploads[]" multiple
/>
</div>
</div>
<div class="mb-3 row">
<label for="display" class="col-sm-2 col-form-label">Selected Files</label>
<div class="col-sm-10">
<!-- <input type="textara" class="form-control" id="upload-file-info" readonly>-->
<div id="upload-file-info"></div>
</div>
</div>
<input type="submit" name="submit" class="btn btn-success submitBtn" value="Upload"/>
</form>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha2/dist/js/bootstrap.bundle.min.js"
integrity="sha384-qKXV1j0HvMUeCBQ+QVp7JcfGl760yU08IQ+GpUo5hlbpg51QRiuqHAJz8+BrxE/N" crossorigin="anonymous"></script>
<script>
$(document).ready(function(){
// Submit form data via Ajax
$("#UploadForm").on('submit', function(e){
e.preventDefault();
$.ajax({
type: 'POST',
url: '/v1/uploads',
data: new FormData(this),
dataType: 'json',
contentType: false,
cache: false,
processData:false,
beforeSend: function(xhr){
$('.submitBtn').attr("disabled","disabled");
$('#UploadForm').css("opacity",".5");
xhr.setRequestHeader('Authorization', 'Bearer {{.Id}}');
},
success: function(response){
$('.statusMsg').html('');
if(response.success){
$('#UploadForm')[0].reset();
$('.statusMsg').html('<p class="alert alert-success">Your upload is available at <code>'
+response.uploads[0].url+'</code> for download</p>');
$('#UploadForm').hide();
}else{
$('.statusMsg').html('<p class="alert alert-danger">'+response.message+'</p>');
}
$('#UploadForm').css("opacity","");
$(".submitBtn").removeAttr("disabled");
}
});
});
$("#file").on('change', function() {
$("#upload-file-info").empty();
for (var i = 0; i < $(this).get(0).files.length; ++i) {
$("#upload-file-info").append('<i class="bi-check-lg"></i> ' + $(this).get(0).files[i].name + '<br>');
}
});
});
</script>
</body>
</html>

View File

@@ -33,7 +33,7 @@ all: buildlocal
buildlocal:
go build -ldflags "-X 'github.com/tlinden/cenophane/upctl/cfg.VERSION=$(VERSION)'"
go build -ldflags "-X 'github.com/tlinden/ephemerup/upctl/cfg.VERSION=$(VERSION)'"
release:
./mkrel.sh $(tool) $(version)

View File

@@ -43,6 +43,10 @@ type Config struct {
// required to intercept requests using httpmock in tests
Mock bool
// required for forms
Description string
Notify string
}
func Getversion() string {

View File

@@ -1,47 +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 cmd
import (
"errors"
"github.com/spf13/cobra"
"github.com/tlinden/cenophane/upctl/cfg"
"github.com/tlinden/cenophane/upctl/lib"
)
func DeleteCommand(conf *cfg.Config) *cobra.Command {
var deleteCmd = &cobra.Command{
Use: "delete [options] <id>",
Short: "Delete an upload",
Long: `Delete an upload identified by its id`,
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
return errors.New("No id specified to delete!")
}
// errors at this stage do not cause the usage to be shown
cmd.SilenceUsage = true
return lib.Delete(conf, args)
},
}
deleteCmd.Aliases = append(deleteCmd.Aliases, "rm")
deleteCmd.Aliases = append(deleteCmd.Aliases, "d")
return deleteCmd
}

View File

@@ -1,48 +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 cmd
import (
"errors"
"github.com/spf13/cobra"
"github.com/tlinden/cenophane/upctl/cfg"
"github.com/tlinden/cenophane/upctl/lib"
)
func DescribeCommand(conf *cfg.Config) *cobra.Command {
var listCmd = &cobra.Command{
Use: "describe [options] upload-id",
Long: "Show detailed informations about an upload object.",
Short: `Describe an upload.`,
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
return errors.New("No id specified to delete!")
}
// errors at this stage do not cause the usage to be shown
cmd.SilenceUsage = true
return lib.Describe(conf, args)
},
}
listCmd.Aliases = append(listCmd.Aliases, "des")
listCmd.Aliases = append(listCmd.Aliases, "info")
listCmd.Aliases = append(listCmd.Aliases, "i")
return listCmd
}

View File

@@ -1,49 +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 cmd
import (
"errors"
"github.com/spf13/cobra"
"github.com/tlinden/cenophane/upctl/cfg"
"github.com/tlinden/cenophane/upctl/lib"
)
func DownloadCommand(conf *cfg.Config) *cobra.Command {
var listCmd = &cobra.Command{
Use: "download [options] upload-id",
Long: "Download the file associated with an upload object.",
Short: `Download a file.`,
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
return errors.New("No id specified to delete!")
}
// errors at this stage do not cause the usage to be shown
cmd.SilenceUsage = true
return lib.Download(conf, args)
},
}
listCmd.Aliases = append(listCmd.Aliases, "down")
listCmd.Aliases = append(listCmd.Aliases, "get")
listCmd.Aliases = append(listCmd.Aliases, "g")
listCmd.Aliases = append(listCmd.Aliases, "fetch")
return listCmd
}

73
upctl/cmd/formcommands.go Normal file
View File

@@ -0,0 +1,73 @@
/*
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 cmd
import (
//"errors"
"github.com/spf13/cobra"
"github.com/tlinden/ephemerup/upctl/cfg"
"github.com/tlinden/ephemerup/upctl/lib"
"os"
)
func FormCommand(conf *cfg.Config) *cobra.Command {
var formCmd = &cobra.Command{
Use: "form {create|delete|modify|list}",
Short: "Form commands",
Long: `Manage upload forms.`,
RunE: func(cmd *cobra.Command, args []string) error {
// errors at this stage do not cause the usage to be shown
//cmd.SilenceUsage = true
if len(args) == 0 {
cmd.Help()
os.Exit(0)
}
return nil
},
}
formCmd.Aliases = append(formCmd.Aliases, "frm")
formCmd.Aliases = append(formCmd.Aliases, "f")
formCmd.AddCommand(FormCreateCommand(conf))
return formCmd
}
func FormCreateCommand(conf *cfg.Config) *cobra.Command {
var formCreateCmd = &cobra.Command{
Use: "create [options]",
Short: "Create a new form",
Long: `Create a new form for consumers so they can upload something.`,
RunE: func(cmd *cobra.Command, args []string) error {
// errors at this stage do not cause the usage to be shown
cmd.SilenceUsage = true
return lib.CreateForm(os.Stdout, conf)
},
}
// options
formCreateCmd.PersistentFlags().StringVarP(&conf.Expire, "expire", "e", "", "Expire setting: asap or duration (accepted shortcuts: dmh)")
formCreateCmd.PersistentFlags().StringVarP(&conf.Description, "description", "D", "", "Description of the form")
formCreateCmd.PersistentFlags().StringVarP(&conf.Notify, "notify", "n", "", "Email address to get notified when consumer has uploaded files")
formCreateCmd.Aliases = append(formCreateCmd.Aliases, "add")
formCreateCmd.Aliases = append(formCreateCmd.Aliases, "+")
return formCreateCmd
}

View File

@@ -1,45 +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 cmd
import (
"github.com/spf13/cobra"
"github.com/tlinden/cenophane/upctl/cfg"
"github.com/tlinden/cenophane/upctl/lib"
)
func ListCommand(conf *cfg.Config) *cobra.Command {
var listCmd = &cobra.Command{
Use: "list [options] [file ..]",
Short: "List uploads",
Long: `List uploads.`,
RunE: func(cmd *cobra.Command, args []string) error {
// errors at this stage do not cause the usage to be shown
cmd.SilenceUsage = true
return lib.List(conf, args)
},
}
// options
listCmd.PersistentFlags().StringVarP(&conf.Apicontext, "apicontext", "", "", "Filter by given API context")
listCmd.Aliases = append(listCmd.Aliases, "ls")
listCmd.Aliases = append(listCmd.Aliases, "l")
return listCmd
}

145
upctl/cmd/maincommands.go Normal file
View File

@@ -0,0 +1,145 @@
/*
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 cmd
import (
"errors"
"github.com/spf13/cobra"
"github.com/tlinden/ephemerup/upctl/cfg"
"github.com/tlinden/ephemerup/upctl/lib"
"os"
)
func UploadCommand(conf *cfg.Config) *cobra.Command {
var uploadCmd = &cobra.Command{
Use: "upload [options] [file ..]",
Short: "Upload files",
Long: `Upload files to an upload api.`,
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
return errors.New("No files specified to upload!")
}
// errors at this stage do not cause the usage to be shown
cmd.SilenceUsage = true
return lib.UploadFiles(os.Stdout, conf, args)
},
}
// options
uploadCmd.PersistentFlags().StringVarP(&conf.Expire, "expire", "e", "", "Expire setting: asap or duration (accepted shortcuts: dmh)")
uploadCmd.Aliases = append(uploadCmd.Aliases, "up")
uploadCmd.Aliases = append(uploadCmd.Aliases, "u")
return uploadCmd
}
func ListCommand(conf *cfg.Config) *cobra.Command {
var listCmd = &cobra.Command{
Use: "list [options] [file ..]",
Short: "List uploads",
Long: `List uploads.`,
RunE: func(cmd *cobra.Command, args []string) error {
// errors at this stage do not cause the usage to be shown
cmd.SilenceUsage = true
return lib.List(os.Stdout, conf, args)
},
}
// options
listCmd.PersistentFlags().StringVarP(&conf.Apicontext, "apicontext", "", "", "Filter by given API context")
listCmd.Aliases = append(listCmd.Aliases, "ls")
listCmd.Aliases = append(listCmd.Aliases, "l")
return listCmd
}
func DeleteCommand(conf *cfg.Config) *cobra.Command {
var deleteCmd = &cobra.Command{
Use: "delete [options] <id>",
Short: "Delete an upload",
Long: `Delete an upload identified by its id`,
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
return errors.New("No id specified to delete!")
}
// errors at this stage do not cause the usage to be shown
cmd.SilenceUsage = true
return lib.Delete(os.Stdout, conf, args)
},
}
deleteCmd.Aliases = append(deleteCmd.Aliases, "rm")
deleteCmd.Aliases = append(deleteCmd.Aliases, "d")
return deleteCmd
}
func DescribeCommand(conf *cfg.Config) *cobra.Command {
var listCmd = &cobra.Command{
Use: "describe [options] upload-id",
Long: "Show detailed informations about an upload object.",
Short: `Describe an upload.`,
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
return errors.New("No id specified to delete!")
}
// errors at this stage do not cause the usage to be shown
cmd.SilenceUsage = true
return lib.Describe(os.Stdout, conf, args)
},
}
listCmd.Aliases = append(listCmd.Aliases, "des")
listCmd.Aliases = append(listCmd.Aliases, "info")
listCmd.Aliases = append(listCmd.Aliases, "i")
return listCmd
}
func DownloadCommand(conf *cfg.Config) *cobra.Command {
var listCmd = &cobra.Command{
Use: "download [options] upload-id",
Long: "Download the file associated with an upload object.",
Short: `Download a file.`,
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
return errors.New("No id specified to delete!")
}
// errors at this stage do not cause the usage to be shown
cmd.SilenceUsage = true
return lib.Download(os.Stdout, conf, args)
},
}
listCmd.Aliases = append(listCmd.Aliases, "down")
listCmd.Aliases = append(listCmd.Aliases, "get")
listCmd.Aliases = append(listCmd.Aliases, "g")
listCmd.Aliases = append(listCmd.Aliases, "fetch")
return listCmd
}

View File

@@ -22,7 +22,7 @@ import (
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"github.com/spf13/viper"
"github.com/tlinden/cenophane/upctl/cfg"
"github.com/tlinden/ephemerup/upctl/cfg"
"os"
"strings"
)
@@ -92,6 +92,7 @@ func Execute() {
rootCmd.AddCommand(DeleteCommand(&conf))
rootCmd.AddCommand(DescribeCommand(&conf))
rootCmd.AddCommand(DownloadCommand(&conf))
rootCmd.AddCommand(FormCommand(&conf))
err := rootCmd.Execute()
if err != nil {

View File

@@ -1,50 +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 cmd
import (
"errors"
"github.com/spf13/cobra"
"github.com/tlinden/cenophane/upctl/cfg"
"github.com/tlinden/cenophane/upctl/lib"
)
func UploadCommand(conf *cfg.Config) *cobra.Command {
var uploadCmd = &cobra.Command{
Use: "upload [options] [file ..]",
Short: "Upload files",
Long: `Upload files to an upload api.`,
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
return errors.New("No files specified to upload!")
}
// errors at this stage do not cause the usage to be shown
cmd.SilenceUsage = true
return lib.UploadFiles(conf, args)
},
}
// options
uploadCmd.PersistentFlags().StringVarP(&conf.Expire, "expire", "e", "", "Expire setting: asap or duration (accepted shortcuts: dmh)")
uploadCmd.Aliases = append(uploadCmd.Aliases, "up")
uploadCmd.Aliases = append(uploadCmd.Aliases, "u")
return uploadCmd
}

View File

@@ -1,4 +1,4 @@
module github.com/tlinden/cenophane/upctl
module github.com/tlinden/ephemerup/upctl
go 1.18
@@ -10,10 +10,11 @@ require (
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
github.com/tlinden/ephemerup/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
@@ -50,4 +51,4 @@ require (
gopkg.in/yaml.v3 v3.0.1 // indirect
)
replace github.com/tlinden/cenophane/common => ../common
replace github.com/tlinden/ephemerup/common => ../common

View File

@@ -38,6 +38,8 @@ 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=

View File

@@ -25,8 +25,9 @@ import (
"github.com/imroc/req/v3"
"github.com/jarcoal/httpmock"
"github.com/schollz/progressbar/v3"
"github.com/tlinden/cenophane/common"
"github.com/tlinden/cenophane/upctl/cfg"
"github.com/tlinden/ephemerup/common"
"github.com/tlinden/ephemerup/upctl/cfg"
"io"
"mime"
"os"
"path/filepath"
@@ -49,8 +50,11 @@ type ListParams struct {
Apicontext string `json:"apicontext"`
}
const Maxwidth = 10
const Maxwidth = 12
/*
Create a new request object for outgoing queries
*/
func Setup(c *cfg.Config, path string) *Request {
client := req.C()
if c.Debug {
@@ -86,9 +90,12 @@ func Setup(c *cfg.Config, path string) *Request {
}
return &Request{Url: c.Endpoint + path, R: R}
}
/*
Iterate over args, considering the elements are filenames, and add
them to the request.
*/
func GatherFiles(rq *Request, args []string) error {
for _, file := range args {
info, err := os.Stat(file)
@@ -120,9 +127,48 @@ func GatherFiles(rq *Request, args []string) error {
return nil
}
func UploadFiles(c *cfg.Config, args []string) error {
/*
Check HTTP Response Code and validate JSON status output, if
any. Turns'em into a regular error
*/
func HandleResponse(c *cfg.Config, resp *req.Response) error {
// we expect a json response, extract the error, if any
r := Response{}
if c.Debug {
trace := resp.Request.TraceInfo()
fmt.Println(trace.Blame())
fmt.Println("----------")
fmt.Println(trace)
}
if err := json.Unmarshal([]byte(resp.String()), &r); err != nil {
// text output!
r.Message = resp.String()
}
if !resp.IsSuccessState() {
return fmt.Errorf("bad response: %s (%s)", resp.Status, r.Message)
}
if !r.Success {
if len(r.Message) == 0 {
if resp.Err != nil {
return resp.Err
} else {
return errors.New("Unknown error")
}
} else {
return errors.New(r.Message)
}
}
return nil
}
func UploadFiles(w io.Writer, c *cfg.Config, args []string) error {
// setup url, req.Request, timeout handling etc
rq := Setup(c, "/file/")
rq := Setup(c, "/uploads")
// collect files to upload from @argv
if err := GatherFiles(rq, args); err != nil {
@@ -150,47 +196,15 @@ func UploadFiles(c *cfg.Config, args []string) error {
return err
}
return RespondExtended(resp)
if err := HandleResponse(c, resp); err != nil {
return err
}
return RespondExtended(w, resp)
}
func HandleResponse(c *cfg.Config, resp *req.Response) error {
// we expect a json response, extract the error, if any
r := Response{}
if err := json.Unmarshal([]byte(resp.String()), &r); err != nil {
// text output!
r.Message = resp.String()
}
if c.Debug {
trace := resp.Request.TraceInfo()
fmt.Println(trace.Blame())
fmt.Println("----------")
fmt.Println(trace)
}
if !r.Success {
if len(r.Message) == 0 {
if resp.Err != nil {
return resp.Err
} else {
return errors.New("Unknown error")
}
} else {
return errors.New(r.Message)
}
}
// all right
if r.Message != "" {
fmt.Println(r.Message)
}
return nil
}
func List(c *cfg.Config, args []string) error {
rq := Setup(c, "/list/")
func List(w io.Writer, c *cfg.Config, args []string) error {
rq := Setup(c, "/uploads")
params := &ListParams{Apicontext: c.Apicontext}
resp, err := rq.R.
@@ -201,12 +215,16 @@ func List(c *cfg.Config, args []string) error {
return err
}
return RespondTable(resp)
if err := HandleResponse(c, resp); err != nil {
return err
}
return UploadsRespondTable(w, resp)
}
func Delete(c *cfg.Config, args []string) error {
func Delete(w io.Writer, c *cfg.Config, args []string) error {
for _, id := range args {
rq := Setup(c, "/file/"+id+"/")
rq := Setup(c, "/uploads/"+id+"/")
resp, err := rq.R.Delete(rq.Url)
@@ -218,47 +236,67 @@ func Delete(c *cfg.Config, args []string) error {
return err
}
fmt.Printf("Upload %s successfully deleted.\n", id)
fmt.Fprintf(w, "Upload %s successfully deleted.\n", id)
}
return nil
}
func Describe(c *cfg.Config, args []string) error {
func Describe(w io.Writer, c *cfg.Config, args []string) error {
if len(args) == 0 {
return errors.New("No id provided!")
}
id := args[0] // we describe only 1 object
rq := Setup(c, "/upload/"+id+"/")
rq := Setup(c, "/uploads/"+id)
resp, err := rq.R.Get(rq.Url)
if err != nil {
return err
}
return RespondExtended(resp)
}
func Download(c *cfg.Config, args []string) error {
id := args[0]
// progres bar
bar := progressbar.Default(100)
callback := func(info req.DownloadInfo) {
if info.Response.Response != nil {
bar.Add(1)
}
if err := HandleResponse(c, resp); err != nil {
return err
}
return RespondExtended(w, resp)
}
func Download(w io.Writer, c *cfg.Config, args []string) error {
if len(args) == 0 {
return errors.New("No id provided!")
}
id := args[0]
rq := Setup(c, "/uploads/"+id+"/file")
if !c.Silent {
// progres bar
bar := progressbar.Default(100)
callback := func(info req.DownloadInfo) {
if info.Response.Response != nil {
bar.Add(1)
}
}
rq.R.SetDownloadCallback(callback)
}
rq := Setup(c, "/file/"+id+"/")
resp, err := rq.R.
SetOutputFile(id).
SetDownloadCallback(callback).
Get(rq.Url)
if err != nil {
return err
}
if !resp.IsSuccessState() {
return fmt.Errorf("bad response: %s", resp.Status)
}
_, params, err := mime.ParseMediaType(resp.Header.Get("Content-Disposition"))
if err != nil {
os.Remove(id)
@@ -278,7 +316,34 @@ func Download(c *cfg.Config, args []string) error {
return fmt.Errorf("\nUnable to rename file: " + err.Error())
}
fmt.Printf("%s successfully downloaded to file %s.", id, cleanfilename)
fmt.Fprintf(w, "%s successfully downloaded to file %s.", id, cleanfilename)
return nil
}
/**** Forms stuff ****/
func CreateForm(w io.Writer, c *cfg.Config) error {
// setup url, req.Request, timeout handling etc
rq := Setup(c, "/forms")
// actual post w/ settings
resp, err := rq.R.
SetFormData(map[string]string{
"expire": c.Expire,
"description": c.Description,
"notify": c.Notify,
}).
Post(rq.Url)
if err != nil {
return err
}
if err := HandleResponse(c, resp); err != nil {
return err
}
return RespondExtended(w, resp)
return nil
}

View File

@@ -19,37 +19,85 @@ package lib
import (
//"github.com/alecthomas/repr"
"bytes"
"fmt"
"github.com/jarcoal/httpmock"
"github.com/tlinden/cenophane/upctl/cfg"
"github.com/tlinden/ephemerup/upctl/cfg"
"io/ioutil"
"net/http"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"testing"
)
const endpoint string = "http://localhost:8080/api/v1"
const endpoint string = "http://localhost:8080/v1"
type Unit struct {
name string
apikey string // set to something else than "token" to fail auth
wantfail bool // true: expect to fail
files []string // path relative to ./t/
sendcode int // for httpmock
sendjson string // struct to respond with
route string // dito
method string // method to use
expect string // regex used to parse the output
sendcode int // for httpmock
sendjson string // struct to respond with
sendfile string // bare file content to be sent
route string // dito
method string // method to use
}
// simulate our cenophane server
// simulate our ephemerup server
func Intercept(tt Unit) {
httpmock.RegisterResponder(tt.method, endpoint+tt.route,
func(request *http.Request) (*http.Response, error) {
respbody := fmt.Sprintf(tt.sendjson)
resp := httpmock.NewStringResponse(tt.sendcode, respbody)
resp.Header.Set("Content-Type", "application/json; charset=utf-8")
var resp *http.Response
if tt.sendfile != "" {
// simulate a file download
content, err := ioutil.ReadFile(tt.sendfile)
if err != nil {
panic(err) // should not happen
}
stat, err := os.Stat(tt.sendfile)
if err != nil {
panic(err) // should not happen as well
}
resp = httpmock.NewStringResponse(tt.sendcode, string(content))
resp.Header.Set("Content-Type", "text/markdown; charset=utf-8")
resp.Header.Set("Content-Length", strconv.Itoa(int(stat.Size())))
resp.Header.Set("Content-Disposition", "attachment; filename='t1'")
} else {
// simulate JSON response
resp = httpmock.NewStringResponse(tt.sendcode, tt.sendjson)
resp.Header.Set("Content-Type", "application/json; charset=utf-8")
}
return resp, nil
})
}
// execute the actual test
func Check(t *testing.T, tt Unit, w *bytes.Buffer, err error) {
testname := fmt.Sprintf("%s-%t", tt.name, tt.wantfail)
if err != nil && !tt.wantfail {
t.Errorf("%s failed! wantfail: %t, error: %s", testname, tt.wantfail, err.Error())
}
if tt.expect != "" {
got := strings.TrimSpace(w.String())
r := regexp.MustCompile(tt.expect)
if !r.MatchString(got) {
t.Errorf("%s failed! error: output does not match!\nexpect: %s\ngot:\n%s", testname, tt.expect, got)
}
}
}
func TestUploadFiles(t *testing.T) {
conf := &cfg.Config{
Mock: true,
@@ -63,42 +111,67 @@ func TestUploadFiles(t *testing.T) {
name: "upload-file",
apikey: "token",
wantfail: false,
route: "/file/",
route: "/uploads",
sendcode: 200,
sendjson: `{"success": true}`,
files: []string{"../t/t1"}, // pwd is lib/ !
method: "POST",
},
{
name: "upload-nonexistent-file",
name: "upload-dir",
apikey: "token",
wantfail: false,
route: "/uploads",
sendcode: 200,
sendjson: `{"success": true}`,
files: []string{"../t"}, // pwd is lib/ !
method: "POST",
},
{
name: "upload-catch-nonexistent-file",
apikey: "token",
wantfail: true,
route: "/file/",
route: "/uploads",
sendcode: 200,
sendjson: `{"success": false}`,
files: []string{"../t/none"},
method: "POST",
},
{
name: "upload-unauth",
name: "upload-catch-no-access",
apikey: "token",
wantfail: true,
route: "/file/",
route: "/uploads",
sendcode: 403,
sendjson: `{"success": false}`,
files: []string{"../t/t1"},
method: "POST",
},
{
name: "upload-check-output",
apikey: "token",
wantfail: false,
route: "/uploads",
sendcode: 200,
sendjson: `{"uploads":[
{
"id":"cc2c965a","expire":"asap","file":"t1","members":["t1"],
"uploaded":1679396814.890502,"context":"foo","url":""
}
],
"success":true,
"message":"Download url: http://localhost:8080/download/cc2c965a/t1",
"code":200}`,
files: []string{"../t/t1"}, // pwd is lib/ !
method: "POST",
expect: "Expire: On first access",
},
}
for _, tt := range tests {
testname := fmt.Sprintf("UploadFiles-%s-%t", tt.name, tt.wantfail)
Intercept(tt)
err := UploadFiles(conf, tt.files)
if err != nil && !tt.wantfail {
t.Errorf("%s failed! wantfail: %t, error: %s", testname, tt.wantfail, err.Error())
}
for _, unit := range tests {
var w bytes.Buffer
Intercept(unit)
Check(t, unit, &w, UploadFiles(&w, conf, unit.files))
}
}
@@ -110,28 +183,227 @@ func TestList(t *testing.T) {
Silent: true,
}
listing := `{"uploads":[{"id":"c8dh","expire":"asap","file":"t1","members":["t1"],"uploaded":1679318969.6434112,"context":"foo","url":""}],"success":true,"message":"","code":200}`
listing := `{"uploads":[
{
"id":"cc2c965a","expire":"asap","file":"t1","members":["t1"],
"uploaded":1679396814.890502,"context":"foo","url":""
}
],
"success":true,
"message":"",
"code":200}`
listingnoaccess := `{"success":false,"message":"invalid context","code":503}`
tests := []Unit{
{
name: "list",
apikey: "token",
wantfail: false,
route: "/list/",
route: "/uploads",
sendcode: 200,
sendjson: listing,
files: []string{},
method: "GET",
expect: `cc2c965a\s*asap\s*foo\s*2023-03-21 12:06:54`, // expect tabular output
},
{
name: "list-catch-empty-json",
apikey: "token",
wantfail: true,
route: "/uploads",
sendcode: 404,
sendjson: "",
files: []string{},
method: "GET",
},
{
name: "list-catch-no-access",
apikey: "token",
wantfail: true,
route: "/uploads",
sendcode: 503,
sendjson: listingnoaccess,
files: []string{},
method: "GET",
},
}
for _, tt := range tests {
testname := fmt.Sprintf("List-%s-%t", tt.name, tt.wantfail)
Intercept(tt)
err := List(conf, []string{})
for _, unit := range tests {
var w bytes.Buffer
Intercept(unit)
Check(t, unit, &w, List(&w, conf, []string{}))
}
}
if err != nil && !tt.wantfail {
t.Errorf("%s failed! wantfail: %t, error: %s", testname, tt.wantfail, err.Error())
}
func TestDescribe(t *testing.T) {
conf := &cfg.Config{
Mock: true,
Apikey: "token",
Endpoint: endpoint,
Silent: true,
}
listing := `{"uploads":[
{
"id":"cc2c965a","expire":"asap","file":"t1","members":["t1"],
"uploaded":1679396814.890502,"context":"foo","url":""
}
],
"success":true,
"message":"",
"code":200}`
listingnoaccess := `{"success":false,"message":"invalid context","code":503}`
tests := []Unit{
{
name: "describe",
apikey: "token",
wantfail: false,
route: "/uploads/",
sendcode: 200,
sendjson: listing,
files: []string{"cc2c965a"},
method: "GET",
expect: `Created: 2023-03-21 12:06:54.890501888`,
},
{
name: "describe-catch-empty-json",
apikey: "token",
wantfail: true,
route: "/uploads/",
sendcode: 200,
sendjson: "",
files: []string{"cc2c965a"},
method: "GET",
},
{
name: "describe-catch-no-access",
apikey: "token",
wantfail: true,
route: "/uploads/",
sendcode: 503,
sendjson: listingnoaccess,
files: []string{"cc2c965a"},
method: "GET",
},
}
for _, unit := range tests {
var w bytes.Buffer
unit.route += unit.files[0]
Intercept(unit)
Check(t, unit, &w, Describe(&w, conf, unit.files))
}
}
func TestDelete(t *testing.T) {
conf := &cfg.Config{
Mock: true,
Apikey: "token",
Endpoint: endpoint,
Silent: true,
}
listingnoaccess := `{"success":false,"message":"invalid context","code":503}`
tests := []Unit{
{
name: "delete",
apikey: "token",
wantfail: false,
route: "/uploads/",
sendcode: 200,
sendjson: `{"success":true,"message":"","code":200}`,
files: []string{"cc2c965a"},
method: "DELETE",
expect: `Upload cc2c965a successfully deleted`,
},
{
name: "delete-catch-empty-json",
apikey: "token",
wantfail: true,
route: "/uploads/",
sendcode: 200,
sendjson: "",
files: []string{"cc2c965a"},
method: "DELETE",
},
{
name: "delete-catch-no-access",
apikey: "token",
wantfail: true,
route: "/uploads/",
sendcode: 503,
sendjson: listingnoaccess,
files: []string{"cc2c965a"},
method: "DELETE",
},
}
for _, unit := range tests {
var w bytes.Buffer
unit.route += unit.files[0] + "/"
Intercept(unit)
Check(t, unit, &w, Delete(&w, conf, unit.files))
}
}
func TestDownload(t *testing.T) {
conf := &cfg.Config{
Mock: true,
Apikey: "token",
Endpoint: endpoint,
Silent: true,
}
listingnoaccess := `{"success":false,"message":"invalid context","code":503}`
tests := []Unit{
{
name: "download",
apikey: "token",
wantfail: false,
route: "/uploads/",
sendcode: 200,
sendfile: "../t/t1",
files: []string{"cc2c965a"},
method: "GET",
expect: `cc2c965a successfully downloaded to file t1`,
},
{
name: "download-catch-empty-response",
apikey: "token",
wantfail: true,
route: "/uploads/",
sendcode: 200,
files: []string{"cc2c965a"},
method: "GET",
},
{
name: "download-catch-no-access",
apikey: "token",
wantfail: true,
route: "/uploads/",
sendcode: 503,
sendjson: listingnoaccess,
files: []string{"cc2c965a"},
method: "GET",
},
}
for _, unit := range tests {
var w bytes.Buffer
unit.route += unit.files[0] + "/file"
Intercept(unit)
Check(t, unit, &w, Download(&w, conf, unit.files))
if unit.sendfile != "" {
file := filepath.Base(unit.sendfile)
if _, err := os.Stat(file); err == nil {
os.Remove(file)
}
}
}
}

View File

@@ -23,8 +23,9 @@ import (
"fmt"
"github.com/imroc/req/v3"
"github.com/olekukonko/tablewriter"
"github.com/tlinden/cenophane/common"
"os"
"github.com/tlinden/ephemerup/common"
"io"
"strings"
"time"
)
@@ -34,15 +35,17 @@ func prepareExpire(expire string, start common.Timestamp) string {
case "asap":
return "On first access"
default:
return time.Unix(start.Unix()+int64(common.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 ""
}
// generic table writer
func WriteTable(headers []string, data [][]string) {
table := tablewriter.NewWriter(os.Stdout)
func WriteTable(w io.Writer, headers []string, data [][]string) {
tableString := &strings.Builder{}
table := tablewriter.NewWriter(tableString)
table.SetHeader(headers)
table.AppendBulk(data)
@@ -60,76 +63,95 @@ func WriteTable(headers []string, data [][]string) {
table.SetNoWhiteSpace(true)
table.Render()
fmt.Fprintln(w, tableString.String())
}
// output like psql \x
func WriteExtended(uploads *common.Uploads) {
/* Print output like psql \x
Prints all Uploads and Forms which exist in common.Response,
however, we expect only one kind of them to be actually filled, so
the function can be used for forms and uploads.
*/
func WriteExtended(w io.Writer, response *common.Response) {
format := fmt.Sprintf("%%%ds: %%s\n", Maxwidth)
// we shall only have 1 element, however, if we ever support more, here we go
for _, entry := range uploads.Entries {
expire := prepareExpire(entry.Expire, entry.Uploaded)
fmt.Printf(format, "Id", entry.Id)
fmt.Printf(format, "Expire", expire)
fmt.Printf(format, "Context", entry.Context)
fmt.Printf(format, "Uploaded", entry.Uploaded)
fmt.Printf(format, "Filename", entry.File)
fmt.Printf(format, "Url", entry.Url)
fmt.Println()
for _, entry := range response.Uploads {
expire := prepareExpire(entry.Expire, entry.Created)
fmt.Fprintf(w, format, "Id", entry.Id)
fmt.Fprintf(w, format, "Expire", expire)
fmt.Fprintf(w, format, "Context", entry.Context)
fmt.Fprintf(w, format, "Created", entry.Created)
fmt.Fprintf(w, format, "Filename", entry.File)
fmt.Fprintf(w, format, "Url", entry.Url)
fmt.Fprintln(w)
}
for _, entry := range response.Forms {
expire := prepareExpire(entry.Expire, entry.Created)
fmt.Fprintf(w, format, "Id", entry.Id)
fmt.Fprintf(w, format, "Expire", expire)
fmt.Fprintf(w, format, "Context", entry.Context)
fmt.Fprintf(w, format, "Created", entry.Created)
fmt.Fprintf(w, format, "Description", entry.Description)
fmt.Fprintf(w, format, "Notify", entry.Notify)
fmt.Fprintf(w, format, "Url", entry.Url)
fmt.Fprintln(w)
}
}
// extract an common.Uploads{} struct from json response
func GetUploadsFromResponse(resp *req.Response) (*common.Uploads, error) {
uploads := common.Uploads{}
func GetResponse(resp *req.Response) (*common.Response, error) {
response := common.Response{}
if err := json.Unmarshal([]byte(resp.String()), &uploads); err != nil {
if err := json.Unmarshal([]byte(resp.String()), &response); err != nil {
return nil, errors.New("Could not unmarshall JSON response: " + err.Error())
}
if !uploads.Success {
return nil, errors.New(uploads.Message)
if !response.Success {
return nil, errors.New(response.Message)
}
return &uploads, nil
return &response, nil
}
// turn the Uploads{} struct into a table and print it
func RespondTable(resp *req.Response) error {
uploads, err := GetUploadsFromResponse(resp)
func UploadsRespondTable(w io.Writer, resp *req.Response) error {
response, err := GetResponse(resp)
if err != nil {
return err
}
if uploads.Message != "" {
fmt.Println(uploads.Message)
if response.Message != "" {
fmt.Fprintln(w, response.Message)
}
// tablewriter
data := [][]string{}
for _, entry := range uploads.Entries {
for _, entry := range response.Uploads {
data = append(data, []string{
entry.Id, entry.Expire, entry.Context, entry.Uploaded.Format("2006-01-02 15:04:05"),
entry.Id, entry.Expire, entry.Context, entry.Created.Format("2006-01-02 15:04:05"),
})
}
WriteTable([]string{"ID", "EXPIRE", "CONTEXT", "UPLOADED"}, data)
WriteTable(w, []string{"ID", "EXPIRE", "CONTEXT", "CREATED"}, data)
return nil
}
// turn the Uploads{} struct into xtnd output and print it
func RespondExtended(resp *req.Response) error {
uploads, err := GetUploadsFromResponse(resp)
func RespondExtended(w io.Writer, resp *req.Response) error {
response, err := GetResponse(resp)
if err != nil {
return err
}
if uploads.Message != "" {
fmt.Println(uploads.Message)
if response.Message != "" {
fmt.Fprintln(w, response.Message)
}
WriteExtended(uploads)
WriteExtended(w, response)
return nil
}

View File

@@ -18,7 +18,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
package main
import (
"github.com/tlinden/cenophane/upctl/cmd"
"github.com/tlinden/ephemerup/upctl/cmd"
)
func main() {

View File

@@ -1,2 +1,2 @@
endpoint = "http://localhost:8080/api/v1"
endpoint = "http://localhost:8080/v1"
apikey = "970b391f22f515d96b3e9b86a2c62c627968828e47b356994d2e583188b4190a"