test(12.2-05): cover every Go behaviour of phase 12.2
- lagoon: Date and TimeOfDay through JSON, text and real DATE/TIME columns; Fill text fallback without changing earlier conversions; required on zero dates; deferred_bindings shape, store isolation and envelope; PurgeDeferred cut-off, after-commit blobs, SKIP LOCKED, skipped types and the deferred:purge command - attach: Store limits, extensions, MIME patterns, default lists, key shape and blob cleanup; IsAllowedImage formats, polyglots, ceiling - conga and pact: framework purge schedule entry and forged jobs; the six relation child hook interfaces - cabana: fileupload and datepicker compile, upload, remove, caption, reorder and bounds; deferred commit order, rollback, applied-only and concurrent saves; relation contracts, forms, CRUD, deferral, schema
This commit is contained in:
334
modules/cabana/fileupload_test.go
Normal file
334
modules/cabana/fileupload_test.go
Normal file
@@ -0,0 +1,334 @@
|
||||
package cabana_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
|
||||
)
|
||||
|
||||
// TestFileuploadCompile: every fileupload compile error stops boot naming
|
||||
// the field's file (D-08, D-09).
|
||||
func TestFileuploadCompile(t *testing.T) {
|
||||
photo := func(body string) string {
|
||||
return " photos:\n type: fileupload\n" + body
|
||||
}
|
||||
for name, tc := range map[string]struct {
|
||||
field string
|
||||
want string
|
||||
}{
|
||||
"unknown key": {photo(" mode: image\n resize: true\n"), "unknown field resize"},
|
||||
"datepicker key": {photo(" firstDay: 1\n"), "firstDay is only valid on type: datepicker"},
|
||||
"unknown mode": {photo(" mode: video\n"), `mode "video" must be image or file on type: fileupload`},
|
||||
"image-mode fileType": {photo(" mode: image\n fileTypes: [png, pdf]\n"), "fileTypes: pdf is not an image type"},
|
||||
"bad fileType": {photo(" fileTypes: [p-f]\n"), `fileTypes: "p-f" is not a file extension`},
|
||||
"bad mimeType": {photo(" mimeTypes: ['no such']\n"), `mimeTypes: "no such" is not a MIME type or extension`},
|
||||
"maxFilesize zero": {photo(" maxFilesize: 0\n"), "must be a positive number of megabytes"},
|
||||
"maxFilesize text": {photo(" maxFilesize: big\n"), "must be a positive number of megabytes"},
|
||||
"maxFiles zero": {photo(" maxFiles: 0\n"), "maxFiles \"0\" must be a positive integer"},
|
||||
"imageWidth zero": {photo(" imageWidth: 0\n"), "imageWidth"},
|
||||
"imageHeight huge": {photo(" imageHeight: 99999\n"), "imageHeight"},
|
||||
"thumbOptions key": {photo(" thumbOptions:\n size: 1\n"), "thumbOptions: unknown field size (only mode is supported)"},
|
||||
"thumbOptions mode": {photo(" thumbOptions:\n mode: stretch\n"), `thumbOptions: mode "stretch" must be auto, exact, crop or fit`},
|
||||
"thumbOptions list": {photo(" thumbOptions: [crop]\n"), "thumbOptions: must be a mapping"},
|
||||
"useCaption not bool": {photo(" useCaption: maybe\n"), "useCaption:"},
|
||||
"no relation": {" brochure:\n type: fileupload\n", "field brochure: is not an attachment relation the model declares in AttachRelations"},
|
||||
"maxFiles on attachOne": {" manual:\n type: fileupload\n maxFiles: 2\n", "field manual: maxFiles is only valid on an attachMany relation"},
|
||||
"key on another type": {" name2:\n type: text\n maxFiles: 2\n", "maxFiles is only valid on type: fileupload"},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
err := bootGadgetFields(t, dfGadgetFields(tc.field))
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) || !strings.Contains(err.Error(), "models/gadget/fields.yaml") {
|
||||
t.Fatalf("err = %v, want %q naming the file", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// A fileupload on a model without attach.Owner (the members manage
|
||||
// form) stops boot.
|
||||
member := "fields:\n email:\n type: text\n avatar:\n type: fileupload\n"
|
||||
err := activateDeferred(t, dfPlugin{fsys: dfOverlay(t, map[string]string{"models/member/fields.yaml": member})}, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "type fileupload needs a model implementing attach.Owner") {
|
||||
t.Fatalf("model without attach.Owner: %v", err)
|
||||
}
|
||||
// maxFilesize above http.body_limits.upload_bytes stops boot.
|
||||
err = bootGadgetFields(t, dfGadgetFields(photo(" maxFilesize: 2\n")))
|
||||
if err != nil {
|
||||
t.Fatalf("2 MB without an upload cap: %v", err)
|
||||
}
|
||||
err = activateDeferred(t, dfPlugin{fsys: dfOverlay(t, map[string]string{"models/gadget/fields.yaml": dfGadgetFields(photo(" maxFilesize: 2\n"))})},
|
||||
map[string]any{"http.body_limits.upload_bytes": 1048576})
|
||||
if err == nil || !strings.Contains(err.Error(), "field photos: maxFilesize exceeds http.body_limits.upload_bytes") {
|
||||
t.Fatalf("maxFilesize over upload_bytes: %v", err)
|
||||
}
|
||||
if err := bootGadgetFields(t, dfGadgetFields(photo(" mode: image\n fileTypes: jpg|png\n mimeTypes: image/png, png\n maxFiles: 2\n imageWidth: 120\n thumbOptions:\n mode: fit\n useCaption: true\n prompt: Drop\n"))); err != nil {
|
||||
t.Fatalf("every key: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// photosPath and manualPath build a gadget's photos file route.
|
||||
func photosPath(gadget uint, rest string) string { return dfPath(gadget, "/files/photos"+rest) }
|
||||
|
||||
// fileItems decodes a file list.
|
||||
func fileItems(t *testing.T, c dfClient, gadget uint, field, key string) []cabana.FileItem {
|
||||
t.Helper()
|
||||
var h map[string]string
|
||||
if key != "" {
|
||||
h = sk(key)
|
||||
}
|
||||
return fileList(t, c.do(t, http.MethodGet, dfPath(gadget, "/files/"+field), nil, h))
|
||||
}
|
||||
|
||||
func itemIDs(items []cabana.FileItem) []uint {
|
||||
out := make([]uint, 0, len(items))
|
||||
for _, it := range items {
|
||||
out = append(out, it.ID)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (e *dfEnv) fileRow(t *testing.T, id uint) (attach.File, bool) {
|
||||
t.Helper()
|
||||
var f attach.File
|
||||
err := e.db.Where("id = ?", id).Limit(1).Find(&f).Error
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return f, f.ID != 0
|
||||
}
|
||||
|
||||
func (e *dfEnv) blob(t *testing.T, f attach.File) bool {
|
||||
t.Helper()
|
||||
ok, err := e.bucket.Exists(context.Background(), attach.BlobKey(f.DiskName))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return ok
|
||||
}
|
||||
|
||||
// TestFileuploadUpload: an upload on id 0 is 201 and pending; the request
|
||||
// cap answers 413 and the field limits answer 422 on the field in the
|
||||
// request locale; maxFiles counts the visible files at upload; the list
|
||||
// orders by sort_order and flags pending uploads (D-03, D-08, D-09).
|
||||
func TestFileuploadUpload(t *testing.T) {
|
||||
env := newDeferredEnv(t)
|
||||
key := newSessionKey(t)
|
||||
png := conformPNG(t)
|
||||
|
||||
up := env.a.upload(t, photosPath(0, ""), "a.png", png, sk(key))
|
||||
want(t, "upload", up, http.StatusCreated)
|
||||
var item cabana.Envelope[cabana.FileItem]
|
||||
if err := json.Unmarshal(up.Body.Bytes(), &item); err != nil || !item.Data.Pending || item.Data.FileName != "a.png" || item.Data.URL == "" || item.Data.ContentType != "image/png" {
|
||||
t.Fatalf("upload item = %s (%v)", up.Body.String(), err)
|
||||
}
|
||||
|
||||
before := env.state(t)
|
||||
// photos: maxFilesize 0.5 MB, cap 512 KiB + 64 KiB of multipart framing.
|
||||
big := append(append([]byte{}, png...), bytes.Repeat([]byte{0}, 700<<10)...)
|
||||
want(t, "body past the cap", env.a.upload(t, photosPath(0, ""), "big.png", big, sk(key)), http.StatusRequestEntityTooLarge)
|
||||
over := append(append([]byte{}, png...), bytes.Repeat([]byte{0}, 530<<10)...)
|
||||
rec := env.a.upload(t, photosPath(0, ""), "over.png", over, sk(key))
|
||||
want(t, "file over maxFilesize", rec, http.StatusUnprocessableEntity)
|
||||
if d := errorDetails(t, rec)["photos"]; len(d) != 1 || d[0] != "The photos may not be greater than 512 kilobytes." {
|
||||
t.Fatalf("size message %v", d)
|
||||
}
|
||||
rec = env.a.upload(t, photosPath(0, ""), "x.svg", []byte("<svg/>"), sk(key))
|
||||
want(t, "wrong type", rec, http.StatusUnprocessableEntity)
|
||||
if d := errorDetails(t, rec)["photos"]; len(d) != 1 || d[0] != "The photos must be a file of type: jpg, jpeg, png, gif, webp." {
|
||||
t.Fatalf("type message %v", d)
|
||||
}
|
||||
rec = env.a.upload(t, photosPath(0, ""), "fake.png", []byte("GIF89a<script>alert(1)</script>"), map[string]string{cabana.SessionKeyHeader: key, "Accept-Language": "pl"})
|
||||
want(t, "polyglot", rec, http.StatusUnprocessableEntity)
|
||||
if d := errorDetails(t, rec)["photos"]; len(d) != 1 || d[0] != "photos musi być obrazkiem." {
|
||||
t.Fatalf("localized image message %v", d)
|
||||
}
|
||||
want(t, "no key", env.a.upload(t, photosPath(0, ""), "a.png", png, nil), http.StatusUnprocessableEntity)
|
||||
want(t, "unknown field", env.a.upload(t, dfPath(0, "/files/nothere"), "a.png", png, sk(key)), http.StatusNotFound)
|
||||
env.unchanged(t, "refused uploads", before)
|
||||
|
||||
for i := 2; i <= 3; i++ {
|
||||
want(t, fmt.Sprintf("upload %d", i), env.a.upload(t, photosPath(0, ""), fmt.Sprintf("%d.png", i), png, sk(key)), http.StatusCreated)
|
||||
}
|
||||
rec = env.a.upload(t, photosPath(0, ""), "4.png", png, sk(key))
|
||||
want(t, "fourth upload over maxFiles", rec, http.StatusUnprocessableEntity)
|
||||
if d := errorDetails(t, rec)["photos"]; len(d) != 1 || d[0] != "The photos may not have more than 3 items." {
|
||||
t.Fatalf("maxFiles message %v", d)
|
||||
}
|
||||
items := fileItems(t, env.a, 0, "photos", key)
|
||||
if len(items) != 3 || !slices.IsSortedFunc(items, func(a, b cabana.FileItem) int { return a.SortOrder - b.SortOrder }) {
|
||||
t.Fatalf("pending list = %+v", items)
|
||||
}
|
||||
for _, it := range items {
|
||||
if !it.Pending || it.URL == "" || it.ThumbURL == "" {
|
||||
t.Fatalf("pending public item %+v", it)
|
||||
}
|
||||
}
|
||||
|
||||
// After the save the same files are attached and no longer pending.
|
||||
saved := env.a.do(t, http.MethodPost, "/acme/deferred/gadgets", map[string]any{"name": "g-" + env.stamp}, sk(key))
|
||||
want(t, "save", saved, http.StatusCreated)
|
||||
g := dataID(t, saved.Body.Bytes())
|
||||
attached := fileItems(t, env.a, g, "photos", "")
|
||||
if fmt.Sprint(itemIDs(attached)) != fmt.Sprint(itemIDs(items)) {
|
||||
t.Fatalf("attached %v, pending was %v", itemIDs(attached), itemIDs(items))
|
||||
}
|
||||
for _, it := range attached {
|
||||
if it.Pending {
|
||||
t.Fatalf("attached item still pending %+v", it)
|
||||
}
|
||||
}
|
||||
// A new session sees the attached files plus its own pending upload.
|
||||
k2 := newSessionKey(t)
|
||||
want(t, "upload on a full field", env.a.upload(t, photosPath(g, ""), "5.png", png, sk(k2)), http.StatusUnprocessableEntity)
|
||||
first := attached[0].ID
|
||||
want(t, "remove one", env.a.do(t, http.MethodDelete, photosPath(g, fmt.Sprintf("/%d", first)), nil, sk(k2)), http.StatusOK)
|
||||
up = env.a.upload(t, photosPath(g, ""), "5.png", png, sk(k2))
|
||||
want(t, "upload after a deferred removal", up, http.StatusCreated)
|
||||
mixed := fileItems(t, env.a, g, "photos", k2)
|
||||
if len(mixed) != 3 || mixed[2].ID != dataID(t, up.Body.Bytes()) || !mixed[2].Pending || mixed[0].Pending || slices.Contains(itemIDs(mixed), first) {
|
||||
t.Fatalf("session list = %+v", mixed)
|
||||
}
|
||||
if got := fileItems(t, env.a, g, "photos", ""); len(got) != 3 || !slices.Contains(itemIDs(got), first) {
|
||||
t.Fatalf("list without the session = %v", itemIDs(got))
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileuploadRemove: removing a pending upload deletes its row and blob
|
||||
// at once; removing an attached file is deferred to the save, which then
|
||||
// deletes row and blob after commit.
|
||||
func TestFileuploadRemove(t *testing.T) {
|
||||
env := newDeferredEnv(t)
|
||||
g := env.gadget(t, "g-"+env.stamp, false)
|
||||
attached := env.storeFile(t, dfGadgetMorph, g, "photos", "a.png", conformPNG(t), true)
|
||||
key := newSessionKey(t)
|
||||
up := env.a.upload(t, photosPath(g, ""), "p.png", conformPNG(t), sk(key))
|
||||
want(t, "upload", up, http.StatusCreated)
|
||||
pending, _ := env.fileRow(t, dataID(t, up.Body.Bytes()))
|
||||
|
||||
want(t, "remove pending", env.a.do(t, http.MethodDelete, photosPath(g, fmt.Sprintf("/%d", pending.ID)), nil, sk(key)), http.StatusOK)
|
||||
if _, ok := env.fileRow(t, pending.ID); ok || env.blob(t, pending) {
|
||||
t.Fatal("removed pending upload kept its row or blob")
|
||||
}
|
||||
want(t, "remove attached", env.a.do(t, http.MethodDelete, photosPath(g, fmt.Sprintf("/%d", attached.ID)), nil, sk(key)), http.StatusOK)
|
||||
want(t, "remove attached again", env.a.do(t, http.MethodDelete, photosPath(g, fmt.Sprintf("/%d", attached.ID)), nil, sk(key)), http.StatusOK)
|
||||
if _, ok := env.fileRow(t, attached.ID); !ok || !env.blob(t, attached) {
|
||||
t.Fatal("a deferred removal deleted the file before the save")
|
||||
}
|
||||
if got := fileItems(t, env.a, g, "photos", key); len(got) != 0 {
|
||||
t.Fatalf("session still lists %v", itemIDs(got))
|
||||
}
|
||||
want(t, "save", env.a.do(t, http.MethodPut, dfPath(g, ""), map[string]any{"name": "g-" + env.stamp}, sk(key)), http.StatusOK)
|
||||
if _, ok := env.fileRow(t, attached.ID); ok || env.blob(t, attached) {
|
||||
t.Fatal("the save kept the removed file or its blob")
|
||||
}
|
||||
if n := len(env.state(t).Bindings); n != 0 {
|
||||
t.Fatalf("bindings left %d", n)
|
||||
}
|
||||
want(t, "remove without a key", env.a.do(t, http.MethodDelete, photosPath(g, "/1"), nil, nil), http.StatusUnprocessableEntity)
|
||||
}
|
||||
|
||||
// TestFileuploadCaption: a field without useCaption refuses caption edits
|
||||
// (403); with it the title and description are saved at once, not
|
||||
// deferred, and are limited in length.
|
||||
func TestFileuploadCaption(t *testing.T) {
|
||||
env := newDeferredEnv(t)
|
||||
g := env.gadget(t, "g-"+env.stamp, false)
|
||||
photo := env.storeFile(t, dfGadgetMorph, g, "photos", "a.png", conformPNG(t), true)
|
||||
manual := env.storeFile(t, dfGadgetMorph, g, "manual", "m.txt", []byte("manual"), false)
|
||||
key := newSessionKey(t)
|
||||
want(t, "caption without useCaption", env.a.do(t, http.MethodPut, photosPath(g, fmt.Sprintf("/%d", photo.ID)), map[string]any{"title": "x"}, sk(key)), http.StatusForbidden)
|
||||
rec := env.a.do(t, http.MethodPut, manualPath(g, manual.ID, ""), map[string]any{"title": "Manual", "description": "The full manual"}, sk(key))
|
||||
want(t, "caption", rec, http.StatusOK)
|
||||
f, _ := env.fileRow(t, manual.ID)
|
||||
if strOrNil(f.Title) != "Manual" || strOrNil(f.Description) != "The full manual" {
|
||||
t.Fatalf("caption not saved at once: %+v", f)
|
||||
}
|
||||
want(t, "caption with an unknown key", env.a.do(t, http.MethodPut, manualPath(g, manual.ID, ""), map[string]any{"title": "x", "sort_order": 1}, sk(key)), http.StatusUnprocessableEntity)
|
||||
}
|
||||
|
||||
// TestFileuploadReorder: the id set must equal the visible files exactly
|
||||
// (422 otherwise), the new order is written to sort_order at once, and an
|
||||
// attachOne field has no reorder.
|
||||
func TestFileuploadReorder(t *testing.T) {
|
||||
env := newDeferredEnv(t)
|
||||
g := env.gadget(t, "g-"+env.stamp, false)
|
||||
a := env.storeFile(t, dfGadgetMorph, g, "photos", "a.png", conformPNG(t), true)
|
||||
b := env.storeFile(t, dfGadgetMorph, g, "photos", "b.png", conformPNG(t), true)
|
||||
c := env.storeFile(t, dfGadgetMorph, g, "photos", "c.png", conformPNG(t), true)
|
||||
key := newSessionKey(t)
|
||||
for name, ids := range map[string][]uint{
|
||||
"missing one": {a.ID, b.ID},
|
||||
"extra id": {a.ID, b.ID, c.ID, c.ID + 100},
|
||||
"duplicate id": {a.ID, a.ID, b.ID},
|
||||
"empty": {},
|
||||
} {
|
||||
before := env.state(t)
|
||||
want(t, "reorder "+name, env.a.do(t, http.MethodPost, photosPath(g, "/reorder"), map[string]any{"ids": ids}, sk(key)), http.StatusUnprocessableEntity)
|
||||
env.unchanged(t, "reorder "+name, before)
|
||||
}
|
||||
rec := env.a.do(t, http.MethodPost, photosPath(g, "/reorder"), map[string]any{"ids": []uint{c.ID, a.ID, b.ID}}, sk(key))
|
||||
want(t, "reorder", rec, http.StatusOK)
|
||||
if got := itemIDs(fileItems(t, env.a, g, "photos", "")); fmt.Sprint(got) != fmt.Sprint([]uint{c.ID, a.ID, b.ID}) {
|
||||
t.Fatalf("order = %v", got)
|
||||
}
|
||||
// The permutation reuses the existing sort_order values.
|
||||
var orders []int
|
||||
if err := env.db.Model(&attach.File{}).Where("id IN ?", []uint{a.ID, b.ID, c.ID}).Order("sort_order").Pluck("sort_order", &orders).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fmt.Sprint(orders) != fmt.Sprint([]int{a.SortOrder, b.SortOrder, c.SortOrder}) {
|
||||
t.Fatalf("sort orders %v, want the permutation of %d %d %d", orders, a.SortOrder, b.SortOrder, c.SortOrder)
|
||||
}
|
||||
m := env.storeFile(t, dfGadgetMorph, g, "manual", "m.txt", []byte("m"), false)
|
||||
rec = env.a.do(t, http.MethodPost, dfPath(g, "/files/manual/reorder"), map[string]any{"ids": []uint{m.ID}}, sk(key))
|
||||
if rec.Code/100 == 2 {
|
||||
t.Fatalf("attachOne reorder status=%d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileuploadAttachOneReplace: two uploads into an attachOne field in
|
||||
// one session leave the last one attached at the save; the replaced file
|
||||
// and the file attached before lose their rows and blobs after commit.
|
||||
func TestFileuploadAttachOneReplace(t *testing.T) {
|
||||
env := newDeferredEnv(t)
|
||||
g := env.gadget(t, "g-"+env.stamp, false)
|
||||
old := env.storeFile(t, dfGadgetMorph, g, "manual", "old.txt", []byte("old"), false)
|
||||
key := newSessionKey(t)
|
||||
upA := env.a.upload(t, dfPath(g, "/files/manual"), "a.txt", []byte("a"), sk(key))
|
||||
upB := env.a.upload(t, dfPath(g, "/files/manual"), "b.txt", []byte("b"), sk(key))
|
||||
want(t, "upload a", upA, http.StatusCreated)
|
||||
want(t, "upload b", upB, http.StatusCreated)
|
||||
a, _ := env.fileRow(t, dataID(t, upA.Body.Bytes()))
|
||||
want(t, "save", env.a.do(t, http.MethodPut, dfPath(g, ""), map[string]any{"name": "g-" + env.stamp}, sk(key)), http.StatusOK)
|
||||
got := fileItems(t, env.a, g, "manual", "")
|
||||
if len(got) != 1 || got[0].ID != dataID(t, upB.Body.Bytes()) || got[0].URL != "" {
|
||||
t.Fatalf("attachOne after save = %+v", got)
|
||||
}
|
||||
for _, f := range []attach.File{old, a} {
|
||||
if _, ok := env.fileRow(t, f.ID); ok || env.blob(t, f) {
|
||||
t.Fatalf("replaced file %s kept its row or blob", f.FileName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileuploadMIME: a mimeTypes list refuses an allowed extension whose
|
||||
// sniffed type is not listed (422 on the field).
|
||||
func TestFileuploadMIME(t *testing.T) {
|
||||
fields := strings.Replace(dfFile(t, "models/gadget/fields.yaml"), " fileTypes: [pdf, png, svg, txt, html]\n", " fileTypes: [pdf, png, svg, txt, html]\n mimeTypes: [application/pdf, text/plain]\n", 1)
|
||||
env := newDeferredEnvWith(t, dfPlugin{fsys: dfOverlay(t, map[string]string{"models/gadget/fields.yaml": fields})})
|
||||
g := env.gadget(t, "g-"+env.stamp, false)
|
||||
key := newSessionKey(t)
|
||||
rec := env.a.upload(t, dfPath(g, "/files/manual"), "a.png", conformPNG(t), sk(key))
|
||||
want(t, "png under a pdf/text list", rec, http.StatusUnprocessableEntity)
|
||||
if d := errorDetails(t, rec)["manual"]; len(d) != 1 || d[0] != "The manual must be a file of type: application/pdf, text/plain." {
|
||||
t.Fatalf("mime message %v", d)
|
||||
}
|
||||
want(t, "text", env.a.upload(t, dfPath(g, "/files/manual"), "a.txt", []byte("hello"), sk(key)), http.StatusCreated)
|
||||
}
|
||||
Reference in New Issue
Block a user