Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat/anti spam #5

Merged
merged 4 commits into from
Jul 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/tag.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ on:
push:
tags:
- 'v[0-9]+.[0-9]+.[0-9]+'
- 'v[0-9]+.[0-9]+.[0-9]+-rc[0-9]+'

env:
CI_JOB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Expand Down
3 changes: 2 additions & 1 deletion docs/spec/components/schemas/FormStatus.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@ allOf:
properties:
status:
type: string
enum: [ accepted, processed ]
enum: [ created, accepted, processed ]
description: |
Created - the empty form was created and now user can't use legacy submit
Accepted - the data was saved by the service for further processing
Processed - the data is processed and stored
created_at:
Expand Down
16 changes: 8 additions & 8 deletions docs/spec/paths/integrations@forms-svc@[email protected]
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,11 @@ post:
- User form
summary: Submit lightweight user answers
description: |
Send user answers and return their current status.
Sending the filled form. Requires created empty form
linked to the name of the file with the photo.
Return form current status.

The image is a link to s3 storage

Only a user with a confirmed passport can send the form ('verified: true' in JWT).
There is a configurable delay before the same user
can submit another form.
The image is a link to s3 storage
operationId: lightweightSubmitForm
security:
- BearerAuth: []
Expand All @@ -34,11 +32,13 @@ post:
$ref: '#/components/responses/invalidParameter'
401:
$ref: '#/components/responses/invalidAuth'
429:
description: "It is necessary to wait some time before sending the next form"
403:
description: "Empty form absent for user, but processed or accepted one exists"
content:
application/vnd.api+json:
schema:
$ref: '#/components/schemas/Errors'
404:
$ref: '#/components/responses/notFound'
500:
$ref: '#/components/responses/internalError'
1 change: 1 addition & 0 deletions docs/spec/paths/integrations@forms-svc@[email protected]
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ post:
in S3 Storage. The name is generated on the server side.
'verified: true' must be specified in the JWT.
The cooldown of this endpoint is the same as in the submit form.
This endpoint creates an empty form that can be submitted on `/form`
operationId: uploadImage
security:
- BearerAuth: []
Expand Down
24 changes: 22 additions & 2 deletions internal/data/forms.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,29 @@ import (
)

const (
CreatedStatus = "created"
AcceptedStatus = "accepted"
ProcessedStatus = "processed"
)

const (
ColStatus = "status"
ColName = "name"
ColSurname = "surname"
ColIDNum = "id_num"
ColBirthday = "birthday"
ColCitizen = "citizen"
ColVisited = "visited"
ColPurpose = "purpose"
ColCountry = "country"
ColCity = "city"
ColAddress = "address"
ColPostal = "postal"
ColPhone = "phone"
ColEmail = "email"
ColImageURL = "image_url"
)

type Form struct {
ID string `db:"id"`
Nullifier string `db:"nullifier"`
Expand Down Expand Up @@ -45,7 +64,8 @@ type FormStatus struct {
type FormsQ interface {
New() FormsQ
Insert(*Form) (*FormStatus, error)
Update(status string) error

Update(map[string]interface{}) error

Select() ([]*Form, error)
Limit(uint64) FormsQ
Expand All @@ -54,5 +74,5 @@ type FormsQ interface {
Last(nullifier string) (*FormStatus, error)

FilterByID(ids ...string) FormsQ
FilterByStatus(status string) FormsQ
FilterByStatus(status ...string) FormsQ
}
21 changes: 15 additions & 6 deletions internal/data/pg/forms.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,15 @@ const (
type formsQ struct {
db *pgdb.DB
selector squirrel.SelectBuilder
last squirrel.SelectBuilder
updater squirrel.UpdateBuilder
}

func NewForms(db *pgdb.DB) data.FormsQ {
return &formsQ{
db: db,
selector: squirrel.Select("*").From(formsTable),
last: squirrel.Select(formsStatusFields).From(formsTable),
updater: squirrel.Update(formsTable),
}
}
Expand All @@ -36,7 +38,7 @@ func (q *formsQ) New() data.FormsQ {
func (q *formsQ) Insert(form *data.Form) (*data.FormStatus, error) {
var res data.FormStatus

stmt := squirrel.Insert(formsTable).SetMap(map[string]interface{}{
values := map[string]interface{}{
"nullifier": form.Nullifier,
"status": form.Status,
"name": form.Name,
Expand All @@ -54,7 +56,13 @@ func (q *formsQ) Insert(form *data.Form) (*data.FormStatus, error) {
"email": form.Email,
"image": form.Image,
"image_url": form.ImageURL,
}).Suffix("RETURNING id, nullifier, status, created_at, updated_at")
}

if form.ID != "" {
values["id"] = form.ID
}

stmt := squirrel.Insert(formsTable).SetMap(values).Suffix("RETURNING id, nullifier, status, created_at, updated_at")

if err := q.db.Get(&res, stmt); err != nil {
return nil, fmt.Errorf("insert form: %w", err)
Expand All @@ -63,8 +71,8 @@ func (q *formsQ) Insert(form *data.Form) (*data.FormStatus, error) {
return &res, nil
}

func (q *formsQ) Update(status string) error {
if err := q.db.Exec(q.updater.Set("status", status)); err != nil {
func (q *formsQ) Update(fields map[string]any) error {
if err := q.db.Exec(q.updater.SetMap(fields)); err != nil {
return fmt.Errorf("update forms: %w", err)
}

Expand Down Expand Up @@ -98,7 +106,7 @@ func (q *formsQ) Get(id string) (*data.FormStatus, error) {
func (q *formsQ) Last(nullifier string) (*data.FormStatus, error) {
var res data.FormStatus

stmt := squirrel.Select(formsStatusFields).From(formsTable).Where(squirrel.Eq{"nullifier": nullifier}).OrderBy("created_at DESC")
stmt := q.last.Where(squirrel.Eq{"nullifier": nullifier}).OrderBy("created_at DESC")
if err := q.db.Get(&res, stmt); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
Expand All @@ -122,12 +130,13 @@ func (q *formsQ) FilterByNullifier(nullifier string) data.FormsQ {
return q.applyCondition(squirrel.Eq{"nullifier": nullifier})
}

func (q *formsQ) FilterByStatus(status string) data.FormsQ {
func (q *formsQ) FilterByStatus(status ...string) data.FormsQ {
return q.applyCondition(squirrel.Eq{"status": status})
}

func (q *formsQ) applyCondition(cond squirrel.Sqlizer) data.FormsQ {
q.selector = q.selector.Where(cond)
q.last = q.selector.Where(cond)
q.updater = q.updater.Where(cond)
return q
}
2 changes: 1 addition & 1 deletion internal/service/handlers/last_status.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ func LastStatus(w http.ResponseWriter, r *http.Request) {

func newFormStatusResponse(formStatus *data.FormStatus) resources.FormStatusResponse {
untilNextForm := time.Now().UTC().Unix() - formStatus.NextFormAt.Unix()
if untilNextForm < 0 {
if untilNextForm < 0 || formStatus.Status == data.CreatedStatus {
untilNextForm = 0
}

Expand Down
7 changes: 6 additions & 1 deletion internal/service/handlers/status_by_id.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"strings"

"github.com/rarimo/geo-auth-svc/pkg/auth"
"github.com/rarimo/geo-forms-svc/internal/data"
"github.com/rarimo/geo-forms-svc/internal/service/requests"
"gitlab.com/distributed_lab/ape"
"gitlab.com/distributed_lab/ape/problems"
Expand Down Expand Up @@ -54,7 +55,11 @@ func StatusByID(w http.ResponseWriter, r *http.Request) {
ape.RenderErr(w, problems.Unauthorized())
return
}
formStatus.NextFormAt = lastStatus.CreatedAt.Add(Forms(r).Cooldown)

formStatus.NextFormAt = lastStatus.CreatedAt
if lastStatus.Status != data.CreatedStatus {
formStatus.NextFormAt = lastStatus.CreatedAt.Add(Forms(r).Cooldown)
}

ape.Render(w, newFormStatusResponse(formStatus))
}
66 changes: 35 additions & 31 deletions internal/service/handlers/submit_form.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import (
"net/http"
"net/url"
"strings"
"time"

validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/rarimo/geo-auth-svc/pkg/auth"
Expand Down Expand Up @@ -36,13 +35,15 @@ func SubmitForm(w http.ResponseWriter, r *http.Request) {
return
}

if lastForm != nil {
next := lastForm.CreatedAt.Add(Forms(r).Cooldown)
if next.After(time.Now().UTC()) {
Log(r).Debugf("Form submitted time: %s; next available time: %s", lastForm.CreatedAt, next)
ape.RenderErr(w, problems.TooManyRequests())
return
}
if lastForm == nil {
ape.RenderErr(w, problems.NotFound())
return
}

if lastForm.Status != data.CreatedStatus {
Log(r).Debugf("User last form must have %s status, got %s", data.CreatedStatus, lastForm.Status)
ape.RenderErr(w, problems.Forbidden())
return
}

imageURL, err := url.Parse(req.Data.Attributes.Image)
Expand All @@ -52,7 +53,7 @@ func SubmitForm(w http.ResponseWriter, r *http.Request) {
return
}

if err = Storage(r).ValidateImage(imageURL); err != nil {
if err = Storage(r).ValidateImage(imageURL, lastForm.ID); err != nil {
if storage.IsBadRequestError(err) {
ape.RenderErr(w, problems.BadRequest(validation.Errors{
"image": err,
Expand All @@ -66,34 +67,37 @@ func SubmitForm(w http.ResponseWriter, r *http.Request) {
}

userData := req.Data.Attributes
form := &data.Form{
Nullifier: nullifier,
Status: data.AcceptedStatus,
Name: userData.Name,
Surname: userData.Surname,
IDNum: userData.IdNum,
Birthday: userData.Birthday,
Citizen: userData.Citizen,
Visited: userData.Visited,
Purpose: userData.Purpose,
Country: userData.Country,
City: userData.City,
Address: userData.Address,
Postal: userData.Postal,
Phone: userData.Phone,
Email: userData.Email,
Image: nil,
ImageURL: sql.NullString{String: userData.Image, Valid: true},
err = FormsQ(r).FilterByID(lastForm.ID).Update(map[string]interface{}{
data.ColStatus: data.AcceptedStatus,
data.ColName: userData.Name,
data.ColSurname: userData.Surname,
data.ColIDNum: userData.IdNum,
data.ColBirthday: userData.Birthday,
data.ColCitizen: userData.Citizen,
data.ColVisited: userData.Visited,
data.ColPurpose: userData.Purpose,
data.ColCountry: userData.Country,
data.ColCity: userData.City,
data.ColAddress: userData.Address,
data.ColPostal: userData.Postal,
data.ColPhone: userData.Phone,
data.ColEmail: userData.Email,
data.ColImageURL: sql.NullString{String: userData.Image, Valid: true},
})
if err != nil {
Log(r).WithError(err).Error("failed to insert form")
ape.RenderErr(w, problems.InternalError())
return
}

formStatus, err := FormsQ(r).Insert(form)
lastForm, err = FormsQ(r).Last(nullifier)
if err != nil {
Log(r).WithError(err).Error("failed to insert form")
Log(r).WithError(err).Errorf("Failed to get last user form for nullifier [%s]", nullifier)
ape.RenderErr(w, problems.InternalError())
return
}

formStatus.NextFormAt = formStatus.CreatedAt.Add(Forms(r).Cooldown)
lastForm.NextFormAt = lastForm.CreatedAt.Add(Forms(r).Cooldown)

ape.Render(w, newFormStatusResponse(formStatus))
ape.Render(w, newFormStatusResponse(lastForm))
}
Loading
Loading