diff --git a/Dockerfile b/Dockerfile index baf7e11..5b90ceb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 " @@ -20,14 +20,14 @@ LABEL maintainer="Uploads Author " 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"] diff --git a/Makefile b/Makefile index a05d2b9..a1f589d 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/README.md b/README.md index 62e4bb8..66f2fe8 100644 --- a/README.md +++ b/README.md @@ -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.. diff --git a/api/auth.go b/api/auth.go index 0f57145..6602be2 100644 --- a/api/auth.go +++ b/api/auth.go @@ -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 diff --git a/api/cleaner.go b/api/cleaner.go index f69b779..c769a1c 100644 --- a/api/cleaner.go +++ b/api/cleaner.go @@ -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 } diff --git a/api/db.go b/api/db.go index 24613a1..c3f43d7 100644 --- a/api/db.go +++ b/api/db.go @@ -18,15 +18,14 @@ along with this program. If not, see . 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 } diff --git a/api/db_test.go b/api/db_test.go new file mode 100644 index 0000000..571a08e --- /dev/null +++ b/api/db_test.go @@ -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 . +*/ + +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") + } + } + }) + } +} diff --git a/api/fileio.go b/api/fileio.go index 9ad11aa..8301e33 100644 --- a/api/fileio.go +++ b/api/fileio.go @@ -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 diff --git a/api/form_handlers.go b/api/form_handlers.go new file mode 100644 index 0000000..d55f244 --- /dev/null +++ b/api/form_handlers.go @@ -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 . +*/ + +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()) +} diff --git a/api/mail.go b/api/mail.go new file mode 100644 index 0000000..894eadb --- /dev/null +++ b/api/mail.go @@ -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 . +*/ + +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 +} diff --git a/api/server.go b/api/server.go index 54a0a22..4ec61d3 100644 --- a/api/server.go +++ b/api/server.go @@ -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, diff --git a/api/handlers.go b/api/upload_handlers.go similarity index 76% rename from api/handlers.go rename to api/upload_handlers.go index f9ad2c0..46f13d8 100644 --- a/api/handlers.go +++ b/api/upload_handlers.go @@ -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) } diff --git a/api/utils.go b/api/utils.go index 57d962e..f662c9b 100644 --- a/api/utils.go +++ b/api/utils.go @@ -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 diff --git a/cfg/config.go b/cfg/config.go index 5f582c1..dfbf732 100644 --- a/cfg/config.go +++ b/cfg/config.go @@ -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 diff --git a/cmd/formtemplate.go b/cmd/formtemplate.go new file mode 100644 index 0000000..38c8bff --- /dev/null +++ b/cmd/formtemplate.go @@ -0,0 +1,102 @@ +package cmd + +const formtemplate = ` + + + + + + + + + + File upload form + + + + + +
+

Upload form {{ .Id }}

+ +
+ + +
+
+
+

+ Use this form to upload one or more files. The creator of the form will automatically get notified. +

+
+
+ +
+ +
+
+ +
+ +
+ +
+
+
+ + +
+
+
+ + + + + + + +` diff --git a/cmd/root.go b/cmd/root.go index 2c99b0b..8834071 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -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)=":" + EPHEMERUPD_CONTEXT_$(NAME)=":" 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]}) diff --git a/common/go.mod b/common/go.mod index 06d829b..e70a9b7 100644 --- a/common/go.mod +++ b/common/go.mod @@ -1,3 +1,3 @@ -module github.com/tlinden/cenophane/common +module github.com/tlinden/ephemerup/common go 1.18 diff --git a/common/types.go b/common/types.go index bfa9cfa..57ea156 100644 --- a/common/types.go +++ b/common/types.go @@ -17,6 +17,11 @@ along with this program. If not, see . 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 + } +} diff --git a/docker-compose.yml b/docker-compose.yml index f2933be..a2f52a9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,6 @@ version: "3.9" services: - cenophane: + ephemerup: build: . ports: - "8080:8080" diff --git a/cenod.hcl b/ephemerup.hcl similarity index 80% rename from cenod.hcl rename to ephemerup.hcl index 1c4e7e9..274f3f0 100644 --- a/cenod.hcl +++ b/ephemerup.hcl @@ -17,3 +17,10 @@ apicontext = [ # this is the root context with all permissions super = "root" + +mail = { + server = "localhost" + port = "25" + from = "root@localhost" + password = "" +} diff --git a/go.mod b/go.mod index 8899487..da68222 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index cd34ef9..c9cae71 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/main.go b/main.go index 720e918..6957212 100644 --- a/main.go +++ b/main.go @@ -18,7 +18,7 @@ along with this program. If not, see . package main import ( - "github.com/tlinden/cenophane/cmd" + "github.com/tlinden/ephemerup/cmd" "log" ) diff --git a/templates/formtemplate.html b/templates/formtemplate.html new file mode 100644 index 0000000..b4ac878 --- /dev/null +++ b/templates/formtemplate.html @@ -0,0 +1,98 @@ + + + + + + + + + + File upload form + + + + + +
+

Upload form {{ .Id }}

+ +
+ + +
+
+
+

+ Use this form to upload one or more files. The creator of the form will automatically get notified. +

+
+
+ +
+ +
+
+ +
+ +
+ +
+
+
+ + +
+
+
+ + + + + + + diff --git a/upctl/Makefile b/upctl/Makefile index dbadb30..636da5d 100644 --- a/upctl/Makefile +++ b/upctl/Makefile @@ -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) diff --git a/upctl/cfg/config.go b/upctl/cfg/config.go index 85cf230..c283ba4 100644 --- a/upctl/cfg/config.go +++ b/upctl/cfg/config.go @@ -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 { diff --git a/upctl/cmd/delete.go b/upctl/cmd/delete.go deleted file mode 100644 index 74d079d..0000000 --- a/upctl/cmd/delete.go +++ /dev/null @@ -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 . -*/ -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] ", - 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 -} diff --git a/upctl/cmd/describe.go b/upctl/cmd/describe.go deleted file mode 100644 index f38a1a7..0000000 --- a/upctl/cmd/describe.go +++ /dev/null @@ -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 . -*/ -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 -} diff --git a/upctl/cmd/download.go b/upctl/cmd/download.go deleted file mode 100644 index ed12624..0000000 --- a/upctl/cmd/download.go +++ /dev/null @@ -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 . -*/ -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 -} diff --git a/upctl/cmd/formcommands.go b/upctl/cmd/formcommands.go new file mode 100644 index 0000000..b95d192 --- /dev/null +++ b/upctl/cmd/formcommands.go @@ -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 . +*/ +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 +} diff --git a/upctl/cmd/list.go b/upctl/cmd/list.go deleted file mode 100644 index 4d9ac65..0000000 --- a/upctl/cmd/list.go +++ /dev/null @@ -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 . -*/ -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 -} diff --git a/upctl/cmd/maincommands.go b/upctl/cmd/maincommands.go new file mode 100644 index 0000000..180bc01 --- /dev/null +++ b/upctl/cmd/maincommands.go @@ -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 . +*/ +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] ", + 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 +} diff --git a/upctl/cmd/root.go b/upctl/cmd/root.go index d1f953d..b2e5b46 100644 --- a/upctl/cmd/root.go +++ b/upctl/cmd/root.go @@ -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 { diff --git a/upctl/cmd/upload.go b/upctl/cmd/upload.go deleted file mode 100644 index a271692..0000000 --- a/upctl/cmd/upload.go +++ /dev/null @@ -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 . -*/ -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 -} diff --git a/upctl/go.mod b/upctl/go.mod index 6b4ff8d..9b3b246 100644 --- a/upctl/go.mod +++ b/upctl/go.mod @@ -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 diff --git a/upctl/go.sum b/upctl/go.sum index d75a6e1..cd00eca 100644 --- a/upctl/go.sum +++ b/upctl/go.sum @@ -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= diff --git a/upctl/lib/client.go b/upctl/lib/client.go index b8befac..2850b53 100644 --- a/upctl/lib/client.go +++ b/upctl/lib/client.go @@ -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 } diff --git a/upctl/lib/client_test.go b/upctl/lib/client_test.go index 578f38e..b2fc1fb 100644 --- a/upctl/lib/client_test.go +++ b/upctl/lib/client_test.go @@ -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) + } + } + } } diff --git a/upctl/lib/output.go b/upctl/lib/output.go index 2d70b32..63c8861 100644 --- a/upctl/lib/output.go +++ b/upctl/lib/output.go @@ -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 } diff --git a/upctl/main.go b/upctl/main.go index c11a498..a7aa748 100644 --- a/upctl/main.go +++ b/upctl/main.go @@ -18,7 +18,7 @@ along with this program. If not, see . package main import ( - "github.com/tlinden/cenophane/upctl/cmd" + "github.com/tlinden/ephemerup/upctl/cmd" ) func main() { diff --git a/upctl/upctl.hcl b/upctl/upctl.hcl index 5b521d5..fc989e6 100644 --- a/upctl/upctl.hcl +++ b/upctl/upctl.hcl @@ -1,2 +1,2 @@ -endpoint = "http://localhost:8080/api/v1" +endpoint = "http://localhost:8080/v1" apikey = "970b391f22f515d96b3e9b86a2c62c627968828e47b356994d2e583188b4190a"