diff --git a/controllers/api/export_test.go b/controllers/api/export_test.go new file mode 100644 index 0000000..a71314e --- /dev/null +++ b/controllers/api/export_test.go @@ -0,0 +1,4 @@ +package api + +// DecodeInline exposes decodeInline to the api_test package. +var DecodeInline = decodeInline diff --git a/controllers/api/manage.go b/controllers/api/manage.go index 90ee9d5..94f5975 100644 --- a/controllers/api/manage.go +++ b/controllers/api/manage.go @@ -476,6 +476,12 @@ func (h *Handlers) ManagedMaterial(w http.ResponseWriter, r *http.Request) { // shows it, or 404 when there is none. Under MCP a GET carries the body only // with include_body, and any other method's answer never does. func (h *Handlers) writeMaterialDetail(w http.ResponseWriter, r *http.Request, db *gorm.DB, id uint, status int) { + h.writeMaterialDetailWith(w, r, db, id, status, nil) +} + +// writeMaterialDetailWith is writeMaterialDetail with extra top-level keys +// beside data (the file warning of the inline upload). +func (h *Handlers) writeMaterialDetailWith(w http.ResponseWriter, r *http.Request, db *gorm.DB, id uint, status int, extra map[string]any) { var m models.Material res := preloadMaterial(db).Where("jz_bm_materials.id = ?", id).Limit(1).Find(&m) if res.Error != nil { @@ -499,7 +505,11 @@ func (h *Handlers) writeMaterialDetail(w http.ResponseWriter, r *http.Request, d } out.Files = append(out.Files, fileJSON{ID: f.ID, URL: f.URL(), Name: f.FileName, Size: f.FileSize, Mime: f.ContentType}) } - wire.WriteJSON(w, status, map[string]any{"data": managedMaterialDetailView(out, viaMCP(r), r.Method == http.MethodGet && includeBody(r))}) + answer := map[string]any{"data": managedMaterialDetailView(out, viaMCP(r), r.Method == http.MethodGet && includeBody(r))} + for k, v := range extra { + answer[k] = v + } + wire.WriteJSON(w, status, answer) } // DeleteMaterial answers DELETE /materials/{id}. diff --git a/controllers/api/manage_files.go b/controllers/api/manage_files.go index 076e129..5f718a1 100644 --- a/controllers/api/manage_files.go +++ b/controllers/api/manage_files.go @@ -122,10 +122,6 @@ func (h *Handlers) storeParts(ctx context.Context, db *gorm.DB, bucket *blob.Buc var keys []string errRefused := errors.New("refused") err := lagoon.Transaction(ctx, db, func(_ context.Context, tx *gorm.DB) error { - var have int64 - if err := materialFiles(tx.Model(&attach.File{}), m).Count(&have).Error; err != nil { - return err - } added := 0 for { part, err := mr.NextPart() @@ -139,32 +135,18 @@ func (h *Handlers) storeParts(ctx context.Context, db *gorm.DB, bucket *blob.Buc part.Close() continue } - if int(have)+added >= MaterialFilesMax { - part.Close() - *refusal = "A material holds at most " + strconv.Itoa(MaterialFilesMax) + " files." - return errRefused - } - f, err := attach.Store(ctx, tx, bucket, attach.Upload{FileName: part.FileName(), Body: part, Public: true}, fileLimits) + f, why, err := storeMaterialFile(ctx, tx, bucket, m, part.FileName(), part, fileLimits) part.Close() - switch { - case errors.Is(err, attach.ErrTooLarge): - *refusal = part.FileName() + ": the file is larger than 50 MB." - return errRefused - case errors.Is(err, attach.ErrFileType), errors.Is(err, attach.ErrMIMEType): - *refusal = part.FileName() + ": this file type is not allowed." - return errRefused - case err != nil: - return err + if f != nil { + keys = append(keys, attach.BlobKeys(*f)...) } - keys = append(keys, attach.BlobKeys(*f)...) - err = tx.Model(&attach.File{}).Where("id = ?", f.ID).Updates(map[string]any{ - "attachment_type": m.MorphName(), - "attachment_id": strconv.FormatUint(uint64(m.ID), 10), - "field": models.MaterialFilesField, - }).Error if err != nil { return err } + if why != "" { + *refusal = why + return errRefused + } added++ } if added == 0 { @@ -179,6 +161,48 @@ func (h *Handlers) storeParts(ctx context.Context, db *gorm.DB, bucket *blob.Buc return keys, err } +// storeMaterialFile stores one file through attach.Store as a public file of +// m and binds it to the material, inside tx. It is the only way the +// management API writes a material file: the multipart route, the inline +// (base64) route and the signed upload all call it. The material's +// MaterialFilesMax cap and attach's extension, MIME and size checks (lim) +// apply. A refusal is returned as a text for the answer, with no error; the +// returned file, when not nil, has blobs the caller deletes when tx does +// not commit. +func storeMaterialFile(ctx context.Context, tx *gorm.DB, bucket *blob.Bucket, m models.Material, name string, body io.Reader, lim attach.Limits) (*attach.File, string, error) { + var have int64 + if err := materialFiles(tx.Model(&attach.File{}), m).Count(&have).Error; err != nil { + return nil, "", err + } + if have >= MaterialFilesMax { + return nil, "A material holds at most " + strconv.Itoa(MaterialFilesMax) + " files.", nil + } + f, err := attach.Store(ctx, tx, bucket, attach.Upload{FileName: name, Body: body, Public: true}, lim) + switch { + case errors.Is(err, attach.ErrTooLarge): + return nil, name + ": the file is larger than " + sizeMB(lim.MaxBytes) + ".", nil + case errors.Is(err, attach.ErrFileType), errors.Is(err, attach.ErrMIMEType): + return nil, name + ": this file type is not allowed.", nil + case err != nil: + return nil, "", err + } + err = tx.Model(&attach.File{}).Where("id = ?", f.ID).Updates(map[string]any{ + "attachment_type": m.MorphName(), + "attachment_id": strconv.FormatUint(uint64(m.ID), 10), + "field": models.MaterialFilesField, + }).Error + return f, "", err +} + +// sizeMB names a byte limit in the refusal texts: 50 MB for 50 MiB, 3 MB +// for 3000000 bytes. +func sizeMB(n int64) string { + if n%(1<<20) == 0 { + return strconv.FormatInt(n>>20, 10) + " MB" + } + return strconv.FormatInt(n/1_000_000, 10) + " MB" +} + // DetachMaterialFile answers DELETE /materials/{id}/files/{file}: the file // row goes, then its blob and thumbnails. A file of another material is a // 404. diff --git a/controllers/api/uploads.go b/controllers/api/uploads.go new file mode 100644 index 0000000..1453f8e --- /dev/null +++ b/controllers/api/uploads.go @@ -0,0 +1,142 @@ +package api + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "net/http" + "strconv" + "strings" + + "git.golem15.com/golem15/summercms/modules/lagoon" + "git.golem15.com/golem15/summercms/modules/lagoon/attach" + "gorm.io/gorm" +) + +// Material files over MCP (D-10, D-11, D-18): a small file goes in as base64 +// in one tool call (AttachMaterialInline). Every path stores through +// storeMaterialFile, so attach.Store and the material's file cap apply. + +// MaterialInlineMaxBytes is the largest decoded file of POST +// /materials/{id}/files/inline. pier refuses MCP request bodies over 4 MiB, +// and base64 grows a file by a third, so about 3 MB is what fits (D-18). +const MaterialInlineMaxBytes = 3_000_000 + +// MaterialInlineBodyLimit is the body cap of POST +// /materials/{id}/files/inline: the base64 of MaterialInlineMaxBytes plus +// the JSON around it. +const MaterialInlineBodyLimit = 4_300_000 + +// MaterialFileWarning is the sentence every material file tool and answer +// carries (D-11). +const MaterialFileWarning = "Files attached to a material are public to anyone with the link." + +// inlineFile is the body of POST /materials/{id}/files/inline. +type inlineFile struct { + Name string `json:"name"` + Mime string `json:"mime"` + ContentBase64 string `json:"content_base64"` +} + +// decodeInline reads and checks the body of POST /materials/{id}/files/inline. +// The encoded content is measured before it is decoded, so an oversized one +// costs no decoding. +func decodeInline(raw []byte) (name string, mime string, data []byte, errs map[string][]string) { + var in inlineFile + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&in); err != nil { + return "", "", nil, map[string][]string{"json": {"Send a JSON object with name, content_base64 and optionally mime: " + err.Error()}} + } + errs = map[string][]string{} + name = strings.TrimSpace(in.Name) + if name == "" || len(name) > 255 { + errs["name"] = []string{"The name is required (the file name with its extension) and may not be longer than 255 characters."} + } + mime = strings.ToLower(strings.TrimSpace(in.Mime)) + if mime != "" && (len(mime) > 100 || !strings.Contains(mime, "/") || strings.ContainsAny(mime, " \t;,")) { + errs["mime"] = []string{"The mime must be one MIME type such as application/pdf."} + } + switch { + case in.ContentBase64 == "": + errs["content_base64"] = []string{"The content_base64 is required."} + case len(in.ContentBase64) > base64.StdEncoding.EncodedLen(MaterialInlineMaxBytes)+inlineSlack(in.ContentBase64): + errs["content_base64"] = []string{"The file is larger than " + strconv.Itoa(MaterialInlineMaxBytes) + " bytes: use bm_manage_create_material_upload for larger files."} + default: + b, err := base64.StdEncoding.DecodeString(in.ContentBase64) + switch { + case err != nil: + errs["content_base64"] = []string{"The content_base64 is not valid standard base64."} + case len(b) == 0: + errs["content_base64"] = []string{"The file is empty."} + case len(b) > MaterialInlineMaxBytes: + errs["content_base64"] = []string{"The file is larger than " + strconv.Itoa(MaterialInlineMaxBytes) + " bytes: use bm_manage_create_material_upload for larger files."} + default: + data = b + } + } + if len(errs) > 0 { + return "", "", nil, errs + } + return name, mime, data, nil +} + +// inlineSlack is the number of line breaks in s, which the base64 decoder +// skips: wrapped base64 is accepted at the same decoded size. +func inlineSlack(s string) int { + return strings.Count(s, "\n") + strings.Count(s, "\r") +} + +// AttachMaterialInline answers POST /materials/{id}/files/inline: one file +// sent as JSON {name, mime?, content_base64}, stored as a public file of the +// material through attach.Store, at most MaterialInlineMaxBytes decoded. A +// sent mime must match the stored content type. The answer is the material +// as GET /materials/{id} shows it, with the public-file warning, 201. +func (h *Handlers) AttachMaterialInline(w http.ResponseWriter, r *http.Request) { + raw, ok := body(w, r) + if !ok { + return + } + name, mime, data, errs := decodeInline(raw) + if errs != nil { + writeValidation(w, errs) + return + } + db, ok := h.db(w, r) + if !ok { + return + } + bucket, ok := h.bucket(w) + if !ok { + return + } + m, ok := findMaterial(w, r, db) + if !ok { + return + } + lim := attach.Limits{MaxBytes: MaterialInlineMaxBytes} + if mime != "" { + lim.MIMETypes = []string{mime} + } + var stored *attach.File + var refusal string + err := lagoon.Transaction(r.Context(), db, func(ctx context.Context, tx *gorm.DB) error { + f, why, err := storeMaterialFile(ctx, tx, bucket, m, name, bytes.NewReader(data), lim) + stored, refusal = f, why + return err + }) + if err != nil || refusal != "" { + // The blob is deleted again when the transaction did not commit. + if stored != nil { + _ = attach.DeleteKeys(context.WithoutCancel(r.Context()), bucket, attach.BlobKeys(*stored)) + } + if refusal != "" { + writeValidation(w, map[string][]string{"file": {refusal}}) + return + } + writeOpaque500(w) + return + } + h.writeMaterialDetailWith(w, r, db, m.ID, http.StatusCreated, map[string]any{"warning": MaterialFileWarning}) +} diff --git a/controllers/api/uploads_test.go b/controllers/api/uploads_test.go new file mode 100644 index 0000000..6c24276 --- /dev/null +++ b/controllers/api/uploads_test.go @@ -0,0 +1,90 @@ +package api_test + +import ( + "encoding/base64" + "net/http" + "strings" + "testing" + + "git.golem15.com/golem15/summercms/modules/bouncer" + "git.golem15.com/jakub/sm-bm-plugin/controllers/api" + "gorm.io/gorm" +) + +// TestDecodeInline (D-11, D-18) checks the body of POST +// /materials/{id}/files/inline before any database work. +func TestDecodeInline(t *testing.T) { + pdf := []byte("%PDF-1.4\n%notatki\n") + enc := base64.StdEncoding.EncodeToString(pdf) + + t.Run("valid", func(t *testing.T) { + name, mime, data, errs := api.DecodeInline([]byte(`{"name":" notatki.pdf ","mime":"Application/PDF","content_base64":"` + enc + `"}`)) + if errs != nil { + t.Fatalf("errors %v", errs) + } + if name != "notatki.pdf" || mime != "application/pdf" || string(data) != string(pdf) { + t.Errorf("got %q %q %q", name, mime, data) + } + }) + t.Run("mime optional", func(t *testing.T) { + _, mime, data, errs := api.DecodeInline([]byte(`{"name":"a.pdf","content_base64":"` + enc + `"}`)) + if errs != nil || mime != "" || len(data) != len(pdf) { + t.Errorf("got mime %q, %d bytes, errors %v", mime, len(data), errs) + } + }) + + big := strings.Repeat("A", base64.StdEncoding.EncodedLen(api.MaterialInlineMaxBytes)+4) + cases := []struct { + name, body, field, contains string + }{ + {"missing name", `{"content_base64":"` + enc + `"}`, "name", ""}, + {"blank name", `{"name":" ","content_base64":"` + enc + `"}`, "name", ""}, + {"empty content", `{"name":"a.pdf","content_base64":""}`, "content_base64", ""}, + {"missing content", `{"name":"a.pdf"}`, "content_base64", ""}, + {"invalid base64", `{"name":"a.pdf","content_base64":"%%%not base64"}`, "content_base64", "base64"}, + {"url-safe alphabet", `{"name":"a.pdf","content_base64":"-_-_"}`, "content_base64", "base64"}, + {"unknown key", `{"name":"a.pdf","content_base64":"` + enc + `","path":"/etc/passwd"}`, "json", ""}, + {"not an object", `[1,2]`, "json", ""}, + {"bad mime", `{"name":"a.pdf","mime":"pdf","content_base64":"` + enc + `"}`, "mime", ""}, + {"too large", `{"name":"a.pdf","content_base64":"` + big + `"}`, "content_base64", "bm_manage_create_material_upload"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, _, data, errs := api.DecodeInline([]byte(tc.body)) + if data != nil { + t.Errorf("data returned with an invalid body") + } + msgs := errs[tc.field] + if len(msgs) == 0 { + t.Fatalf("errors %v have no %s entry", errs, tc.field) + } + if tc.contains != "" && !strings.Contains(msgs[0], tc.contains) { + t.Errorf("%s message %q does not name %q", tc.field, msgs[0], tc.contains) + } + }) + } +} + +// TestAttachMaterialInlineNoDatabase: an invalid body is a 422 before the +// database is looked up; a valid one without a database is a 503. +func TestAttachMaterialInlineNoDatabase(t *testing.T) { + user := &bouncer.Principal{ID: 7} + enc := base64.StdEncoding.EncodeToString([]byte("%PDF-1.4\n")) + valid := bodyRequest{noDB().AttachMaterialInline, http.MethodPost, "/", `{"name":"a.pdf","content_base64":"` + enc + `"}`} + if code, body := serveBody(t, valid, user); code != http.StatusServiceUnavailable { + t.Errorf("valid body without a database = %d %v, want 503", code, body) + } + dbLooked := false + h := &api.Handlers{DB: func() *gorm.DB { dbLooked = true; return nil }} + invalid := bodyRequest{h.AttachMaterialInline, http.MethodPost, "/", `{"name":"","content_base64":""}`} + code, body := serveBody(t, invalid, user) + if code != http.StatusUnprocessableEntity { + t.Errorf("invalid body = %d %v, want 422", code, body) + } + if errs, _ := body["errors"].(map[string]any); errs["name"] == nil || errs["content_base64"] == nil { + t.Errorf("errors %v lack name and content_base64", body["errors"]) + } + if dbLooked { + t.Error("the database was looked up for an invalid body") + } +} diff --git a/mcp.go b/mcp.go index f16ee21..4105e9b 100644 --- a/mcp.go +++ b/mcp.go @@ -12,5 +12,6 @@ var _ pact.HasMCP = (*Plugin)(nil) // signed-in user under golem15.user's "user" guard, and their titles and // descriptions are static text, never built from data. func (p *Plugin) MCP() pact.MCP { - return pact.MCP{Tools: append(studentTools(), manageTools()...)} + tools := append(studentTools(), manageTools()...) + return pact.MCP{Tools: append(tools, fileTools()...)} } diff --git a/mcp_files.go b/mcp_files.go new file mode 100644 index 0000000..0bd2ebb --- /dev/null +++ b/mcp_files.go @@ -0,0 +1,42 @@ +package bm + +import ( + "net/http" + + "git.golem15.com/golem15/summercms/modules/pact" + "git.golem15.com/jakub/sm-bm-plugin/controllers/api" +) + +// attachMaterialFileInput is the input of bm_manage_attach_material_file. +type attachMaterialFileInput struct { + ID uint `json:"id" path:"id" jsonschema:"the material id"` + Name string `json:"name" jsonschema:"file name with extension, for example notatki.pdf"` + Mime string `json:"mime,omitempty" jsonschema:"optional MIME type; the stored type must match"` + ContentBase64 string `json:"content_base64" jsonschema:"standard base64, at most 3000000 bytes decoded; use bm_manage_create_material_upload for larger files"` +} + +// detachMaterialFileInput is the input of bm_manage_detach_material_file. +type detachMaterialFileInput struct { + ID uint `json:"id" path:"id" jsonschema:"the material id"` + File uint `json:"file" path:"file" jsonschema:"the file id, as listed in the files of bm_manage_show_material"` +} + +// fileTools are the material file tools over the management API (D-10, +// D-11, D-17): attach a small file as base64 and detach a file. Every +// description carries the public-file warning and says no file contents +// are returned. +func fileTools() []pact.MCPTool { + tool := func(name, title, desc, method, path string, input any) pact.MCPTool { + return pact.MCPTool{Name: name, Title: title, Description: desc, Guard: "user", Method: method, Path: ManagePrefix + path, Input: input} + } + return []pact.MCPTool{ + tool("manage_attach_material_file", "Attach a file to a material", + "Attaches one file sent as standard base64 (at most 3000000 bytes decoded; for larger files use bm_manage_create_material_upload) to a material, after its files; a material holds at most 20. The extension and the content are checked; a sent mime must match the content. Returns the material with its files (id, absolute link, name, size and type) and a warning; 404 when the material is unknown, 422 when the file is refused. "+ + api.MaterialFileWarning+" "+MCPMaterialPrivacy+writeMaterials, + http.MethodPost, "/materials/{id}/files/inline", attachMaterialFileInput{}), + tool("manage_detach_material_file", "Detach a file from a material", + "Deletes one file of a material, its row and its stored blob, so its link stops working; 404 when the material or the file is unknown or the file belongs to another material."+deleteForever+" "+ + api.MaterialFileWarning+" "+MCPMaterialPrivacy+writeMaterials, + http.MethodDelete, "/materials/{id}/files/{file}", detachMaterialFileInput{}), + } +} diff --git a/mcp_test.go b/mcp_test.go index b6f8eb3..86e2f2f 100644 --- a/mcp_test.go +++ b/mcp_test.go @@ -18,6 +18,7 @@ import ( "git.golem15.com/golem15/summercms/modules/pier" "git.golem15.com/golem15/summercms/modules/surf" bm "git.golem15.com/jakub/sm-bm-plugin" + "git.golem15.com/jakub/sm-bm-plugin/controllers/api" "git.golem15.com/jakub/sm-bm-plugin/models" ) @@ -52,6 +53,7 @@ var pinnedTools = []string{ "bm_courses", "bm_dashboard", "bm_deadlines", + "bm_manage_attach_material_file", "bm_manage_create_class_session", "bm_manage_create_course", "bm_manage_create_deadline", @@ -61,6 +63,7 @@ var pinnedTools = []string{ "bm_manage_delete_deadline", "bm_manage_delete_material", "bm_manage_delete_reading", + "bm_manage_detach_material_file", "bm_manage_list_class_sessions", "bm_manage_list_courses", "bm_manage_list_deadlines", @@ -306,6 +309,13 @@ func testManageTools(t *testing.T, tools []pact.MCPTool) { } } fields := bodyFields(tool.Input) + if segment == "materials" && strings.Contains(rest, "/files") { + testFileTool(t, tool, fields) + if tool.Method == http.MethodDelete { + deletes[segment] = true + } + continue + } if segment == "materials" { if !strings.Contains(tool.Description, bm.MCPMaterialPrivacy) { t.Errorf("%s: description lacks the material privacy sentence", tool.Name) @@ -353,8 +363,8 @@ func testManageTools(t *testing.T, tools []pact.MCPTool) { } } } - if manage != 28 { - t.Errorf("%d management tools, want 28", manage) + if manage != manageToolCount { + t.Errorf("%d management tools, want %d", manage, manageToolCount) } want := map[string]bool{"class-sessions": true, "readings": true, "deadlines": true, "materials": true} if !reflect.DeepEqual(deletes, want) { @@ -362,6 +372,57 @@ func testManageTools(t *testing.T, tools []pact.MCPTool) { } } +// manageToolCount is the number of management tools: 28 record and +// material tools plus the material file tools. +const manageToolCount = 30 + +// fileBodyKeys are the body keys of each material file tool, exactly. +var fileBodyKeys = map[string]map[string]bool{ + "manage_attach_material_file": {"name": false, "mime": true, "content_base64": false}, + "manage_detach_material_file": {}, +} + +// testFileTool (D-10, D-11, D-17) checks one material file tool: its body +// keys, the public-file warning and the privacy sentence, and that the +// detach tool is destructive. +func testFileTool(t *testing.T, tool pact.MCPTool, fields map[string]bool) { + t.Helper() + want, ok := fileBodyKeys[tool.Name] + if !ok { + t.Errorf("%s: not a known material file tool", tool.Name) + return + } + if !reflect.DeepEqual(fields, want) { + t.Errorf("%s: body keys (optional) %v, want %v", tool.Name, fields, want) + } + if !strings.Contains(tool.Description, api.MaterialFileWarning) { + t.Errorf("%s: description lacks the public-file warning", tool.Name) + } + if !strings.Contains(tool.Description, bm.MCPMaterialPrivacy) { + t.Errorf("%s: description lacks the material privacy sentence", tool.Name) + } + if !strings.Contains(tool.Description, "Needs scope write and the jz.bm.access_materials permission") { + t.Errorf("%s: description does not name scope write and jz.bm.access_materials", tool.Name) + } + rt := reflect.TypeOf(tool.Input) + idPath := false + for i := 0; i < rt.NumField(); i++ { + if rt.Field(i).Tag.Get("path") == "id" { + idPath = true + } + } + if !idPath { + t.Errorf("%s: no id path field", tool.Name) + } + if tool.Name == "manage_detach_material_file" { + if tool.Method != http.MethodDelete || (tool.Destructive != nil && !*tool.Destructive) { + t.Errorf("%s is not destructive (method %s)", tool.Name, tool.Method) + } + } else if tool.Method == http.MethodDelete || (tool.Destructive != nil && *tool.Destructive) { + t.Errorf("%s is destructive", tool.Name) + } +} + // studentRoutes are the routes mountStudent registers, in order. var studentRoutes = []string{ "GET /dashboard", diff --git a/plugin_test.go b/plugin_test.go index 10bf43c..285fc1b 100644 --- a/plugin_test.go +++ b/plugin_test.go @@ -139,6 +139,7 @@ func TestRoutes(t *testing.T) { "PUT /api/v1/bm/materials/{id}", "DELETE /api/v1/bm/materials/{id}", "POST /api/v1/bm/materials/{id}/files", + "POST /api/v1/bm/materials/{id}/files/inline", "DELETE /api/v1/bm/materials/{id}/files/{file}", "GET /api/v1/bm/courses", "GET /api/v1/bm/courses/{id}", diff --git a/routes.go b/routes.go index fa02e56..59579a5 100644 --- a/routes.go +++ b/routes.go @@ -172,6 +172,8 @@ func (p *Plugin) Routes(r pact.Router) error { g.Post("/materials/{id}/files", h.AttachMaterialFiles, "user.scope:write", "user.permission:"+controllers.PermMaterials, "body.limit:"+strconv.Itoa(api.MaterialFilesBodyLimit)) g.Where("id", "[0-9]+") + g.Post("/materials/{id}/files/inline", h.AttachMaterialInline, "user.scope:write", "user.permission:"+controllers.PermMaterials, "body.limit:"+strconv.Itoa(api.MaterialInlineBodyLimit)) + g.Where("id", "[0-9]+") g.Delete("/materials/{id}/files/{file}", h.DetachMaterialFile, "user.scope:write", "user.permission:"+controllers.PermMaterials) g.Where("id", "[0-9]+") g.Where("file", "[0-9]+")