feat(clientapi): 客户端密钥可上传 Agent APK 并单独授权设为当前 (#384)
devices 模块新增 release_upload(列表/上传)与 release_activate(设为当前)两个显式动作, 不挂在 read/write 上;不开放下载。Upload 创建人统一取操作人(客户端取 AuthorizedBy, Admin 仍取 JWT 用户)。网关请求体上限仅对 APK 上传端点放宽到 MaxAPKBytes+1MB,其余仍为 16MB。 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
3495de96a2
commit
19484f0a68
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user"
|
||||
"go-admin/app/goauto/device"
|
||||
"go-admin/common/clientprincipal"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -72,13 +73,21 @@ func (h Handler) Upload(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
result, err := s.Upload(c.Request.Context(), c.PostForm("releaseNotes"), uint64(user.GetUserId(c)), stream)
|
||||
result, err := s.Upload(c.Request.Context(), c.PostForm("releaseNotes"), operatorID(c), stream)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{"code": http.StatusOK, "data": result})
|
||||
}
|
||||
|
||||
// operatorID returns the authorizing user for client API keys and the JWT user otherwise.
|
||||
func operatorID(c *gin.Context) uint64 {
|
||||
if id, ok := clientprincipal.Get(c); ok {
|
||||
return id.AuthorizedBy
|
||||
}
|
||||
return uint64(user.GetUserId(c))
|
||||
}
|
||||
func (h Handler) SetCurrent(c *gin.Context) {
|
||||
var req SetCurrentRequest
|
||||
if err := decode(c, &req); err != nil {
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
package apprelease
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/common/clientprincipal"
|
||||
)
|
||||
|
||||
// buildTestAPK returns a minimal zip whose binary AndroidManifest.xml carries
|
||||
// the manifest versionCode/versionName attributes that parseAPK reads.
|
||||
func buildTestAPK(t *testing.T, versionCode uint32, versionName string) []byte {
|
||||
t.Helper()
|
||||
pool := []string{"manifest", "versionCode", "versionName", versionName}
|
||||
var strs bytes.Buffer
|
||||
offsets := make([]uint32, len(pool))
|
||||
for i, s := range pool {
|
||||
offsets[i] = uint32(strs.Len())
|
||||
strs.WriteByte(byte(len(s)))
|
||||
strs.WriteByte(byte(len(s)))
|
||||
strs.WriteString(s)
|
||||
strs.WriteByte(0)
|
||||
}
|
||||
for strs.Len()%4 != 0 {
|
||||
strs.WriteByte(0)
|
||||
}
|
||||
headerSize := 28
|
||||
poolSize := headerSize + len(pool)*4 + strs.Len()
|
||||
var m bytes.Buffer
|
||||
le := func(v any) { _ = binary.Write(&m, binary.LittleEndian, v) }
|
||||
le(uint16(0x0003))
|
||||
le(uint16(8))
|
||||
le(uint32(0)) // total size, patched below
|
||||
le(uint16(0x0001))
|
||||
le(uint16(headerSize))
|
||||
le(uint32(poolSize))
|
||||
le(uint32(len(pool)))
|
||||
le(uint32(0))
|
||||
le(uint32(0x100))
|
||||
le(uint32(headerSize + len(pool)*4))
|
||||
le(uint32(0))
|
||||
for _, o := range offsets {
|
||||
le(o)
|
||||
}
|
||||
m.Write(strs.Bytes())
|
||||
attrs := 2
|
||||
le(uint16(0x0102))
|
||||
le(uint16(16))
|
||||
le(uint32(36 + attrs*20))
|
||||
le(uint32(1)) // line
|
||||
le(uint32(0xffffffff)) // comment
|
||||
le(uint32(0xffffffff)) // ns
|
||||
le(uint32(0)) // name -> manifest
|
||||
le(uint16(20)) // attributeStart
|
||||
le(uint16(20)) // attributeSize
|
||||
le(uint16(attrs))
|
||||
le(uint16(0))
|
||||
le(uint16(0))
|
||||
le(uint16(0))
|
||||
attr := func(name uint32, raw uint32, typ byte, data uint32) {
|
||||
le(uint32(0xffffffff))
|
||||
le(name)
|
||||
le(raw)
|
||||
le(uint16(8))
|
||||
le(byte(0))
|
||||
le(typ)
|
||||
le(data)
|
||||
}
|
||||
attr(1, 0xffffffff, 0x10, versionCode)
|
||||
attr(2, 3, 0x03, 3)
|
||||
raw := m.Bytes()
|
||||
binary.LittleEndian.PutUint32(raw[4:], uint32(len(raw)))
|
||||
var out bytes.Buffer
|
||||
zw := zip.NewWriter(&out)
|
||||
w, err := zw.Create("AndroidManifest.xml")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _ = w.Write(raw)
|
||||
if err = zw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out.Bytes()
|
||||
}
|
||||
|
||||
func uploadRequest(t *testing.T, filename string, content []byte) *http.Request {
|
||||
t.Helper()
|
||||
var body bytes.Buffer
|
||||
mw := multipart.NewWriter(&body)
|
||||
part, _ := mw.CreateFormFile("file", filename)
|
||||
_, _ = part.Write(content)
|
||||
_ = mw.WriteField("releaseNotes", "test")
|
||||
_ = mw.Close()
|
||||
req := httptest.NewRequest("POST", "/upload", &body)
|
||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
return req
|
||||
}
|
||||
|
||||
func TestUploadCreatorUsesClientAuthorizerOrJWTUser(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := releaseTestDB(t)
|
||||
h := Handler{DB: db, StorageDir: t.TempDir()}
|
||||
router := gin.New()
|
||||
router.POST("/upload", func(c *gin.Context) {
|
||||
if c.GetHeader("X-Test-Client") != "" {
|
||||
clientprincipal.Set(c, clientprincipal.Identity{KeyID: 5, RequestID: "r", AuthorizedBy: 42})
|
||||
} else {
|
||||
c.Set(jwt.JwtPayloadKey, jwt.MapClaims{"identity": float64(7)})
|
||||
}
|
||||
h.Upload(c)
|
||||
})
|
||||
send := func(client bool, filename string, content []byte) *httptest.ResponseRecorder {
|
||||
req := uploadRequest(t, filename, content)
|
||||
if client {
|
||||
req.Header.Set("X-Test-Client", "1")
|
||||
}
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
return rr
|
||||
}
|
||||
if rr := send(true, "agent.apk", buildTestAPK(t, 101, "1.0.1")); rr.Code != 201 {
|
||||
t.Fatalf("client upload %d %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if rr := send(false, "agent.apk", buildTestAPK(t, 102, "1.0.2")); rr.Code != 201 {
|
||||
t.Fatalf("admin upload %d %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var rows []models.AgentAppRelease
|
||||
if err := db.Order("version_code").Find(&rows).Error; err != nil || len(rows) != 2 {
|
||||
t.Fatalf("rows=%d err=%v", len(rows), err)
|
||||
}
|
||||
if rows[0].CreatedBy != 42 || rows[1].CreatedBy != 7 {
|
||||
t.Fatalf("createdBy client=%d admin=%d", rows[0].CreatedBy, rows[1].CreatedBy)
|
||||
}
|
||||
if rr := send(true, "agent.zip", buildTestAPK(t, 103, "1.0.3")); rr.Code != 422 {
|
||||
t.Fatalf("non-apk accepted %d", rr.Code)
|
||||
}
|
||||
rr := send(true, "again.apk", buildTestAPK(t, 101, "1.0.1"))
|
||||
var res struct{ Code string }
|
||||
_ = json.Unmarshal(rr.Body.Bytes(), &res)
|
||||
if rr.Code != 409 || res.Code != "AGENT_RELEASE_CONFLICT" {
|
||||
t.Fatalf("duplicate versionCode %d %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg"
|
||||
"go-admin/app/goauto/apprelease"
|
||||
"go-admin/app/goauto/clientkey"
|
||||
"go-admin/common/clientprincipal"
|
||||
"gorm.io/gorm"
|
||||
@@ -80,7 +81,7 @@ func Gate(db *gorm.DB, e Endpoint) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
clientprincipal.Set(c, clientprincipal.Identity{KeyID: key.ID, RequestID: requestID, AuthorizedBy: key.UpdatedBy})
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 16<<20)
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, bodyLimit(e))
|
||||
original := c.Writer
|
||||
buffer := &responseBuffer{ResponseWriter: original, status: 200}
|
||||
c.Writer = buffer
|
||||
@@ -104,6 +105,15 @@ func Gate(db *gorm.DB, e Endpoint) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// bodyLimit keeps 16MB for every endpoint except the Agent APK upload, which
|
||||
// needs room for the APK itself plus multipart overhead (same bound as Admin).
|
||||
func bodyLimit(e Endpoint) int64 {
|
||||
if e.Method == "POST" && e.Path == "/agent-app-releases" {
|
||||
return apprelease.MaxAPKBytes + (1 << 20)
|
||||
}
|
||||
return 16 << 20
|
||||
}
|
||||
|
||||
type responseBuffer struct {
|
||||
gin.ResponseWriter
|
||||
body bytes.Buffer
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
package clientapi
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"go-admin/app/goauto/apprelease"
|
||||
"go-admin/app/goauto/clientkey"
|
||||
"go-admin/app/goauto/models"
|
||||
)
|
||||
|
||||
// releaseAPK builds a minimal APK whose binary manifest carries the version fields.
|
||||
func releaseAPK(t *testing.T, code uint32, name string) []byte {
|
||||
t.Helper()
|
||||
pool := []string{"manifest", "versionCode", "versionName", name}
|
||||
var strs bytes.Buffer
|
||||
offsets := make([]uint32, len(pool))
|
||||
for i, s := range pool {
|
||||
offsets[i] = uint32(strs.Len())
|
||||
strs.WriteByte(byte(len(s)))
|
||||
strs.WriteByte(byte(len(s)))
|
||||
strs.WriteString(s)
|
||||
strs.WriteByte(0)
|
||||
}
|
||||
for strs.Len()%4 != 0 {
|
||||
strs.WriteByte(0)
|
||||
}
|
||||
var m bytes.Buffer
|
||||
le := func(v any) { _ = binary.Write(&m, binary.LittleEndian, v) }
|
||||
le(uint16(3))
|
||||
le(uint16(8))
|
||||
le(uint32(0))
|
||||
le(uint16(1))
|
||||
le(uint16(28))
|
||||
le(uint32(28 + len(pool)*4 + strs.Len()))
|
||||
le(uint32(len(pool)))
|
||||
le(uint32(0))
|
||||
le(uint32(0x100))
|
||||
le(uint32(28 + len(pool)*4))
|
||||
le(uint32(0))
|
||||
for _, o := range offsets {
|
||||
le(o)
|
||||
}
|
||||
m.Write(strs.Bytes())
|
||||
le(uint16(0x0102))
|
||||
le(uint16(16))
|
||||
le(uint32(36 + 40))
|
||||
le(uint32(1))
|
||||
le(uint32(0xffffffff))
|
||||
le(uint32(0xffffffff))
|
||||
le(uint32(0))
|
||||
le(uint16(20))
|
||||
le(uint16(20))
|
||||
le(uint16(2))
|
||||
le(uint16(0))
|
||||
le(uint16(0))
|
||||
le(uint16(0))
|
||||
attr := func(n, raw uint32, typ byte, data uint32) {
|
||||
le(uint32(0xffffffff))
|
||||
le(n)
|
||||
le(raw)
|
||||
le(uint16(8))
|
||||
le(byte(0))
|
||||
le(typ)
|
||||
le(data)
|
||||
}
|
||||
attr(1, 0xffffffff, 0x10, code)
|
||||
attr(2, 3, 0x03, 3)
|
||||
raw := m.Bytes()
|
||||
binary.LittleEndian.PutUint32(raw[4:], uint32(len(raw)))
|
||||
var out bytes.Buffer
|
||||
zw := zip.NewWriter(&out)
|
||||
w, _ := zw.Create("AndroidManifest.xml")
|
||||
_, _ = w.Write(raw)
|
||||
_ = zw.Close()
|
||||
return out.Bytes()
|
||||
}
|
||||
|
||||
func multipartUpload(content []byte, filename string) (*bytes.Buffer, string) {
|
||||
var body bytes.Buffer
|
||||
mw := multipart.NewWriter(&body)
|
||||
part, _ := mw.CreateFormFile("file", filename)
|
||||
_, _ = part.Write(content)
|
||||
_ = mw.Close()
|
||||
return &body, mw.FormDataContentType()
|
||||
}
|
||||
|
||||
func TestAgentReleaseActionsAreSeparateAndExplicit(t *testing.T) {
|
||||
var devices clientkey.Module
|
||||
for _, m := range Catalog(Inventory()) {
|
||||
if m.Key == "devices" {
|
||||
devices = m
|
||||
}
|
||||
}
|
||||
if strings.Join(devices.Actions, ",") != "release_activate,release_upload" || devices.Writable {
|
||||
t.Fatalf("devices catalog=%+v", devices)
|
||||
}
|
||||
db, s := fixture(t)
|
||||
if err := db.AutoMigrate(&models.AgentAppRelease{}, &models.AgentAppReleaseSetting{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := apprelease.Handler{DB: db, StorageDir: t.TempDir()}
|
||||
router := gin.New()
|
||||
found := 0
|
||||
for _, e := range Inventory() {
|
||||
if !strings.HasPrefix(e.Path, "/agent-app-releases") {
|
||||
continue
|
||||
}
|
||||
found++
|
||||
switch e.Method + e.Path {
|
||||
case "GET/agent-app-releases":
|
||||
e.Handle = h.List
|
||||
case "POST/agent-app-releases":
|
||||
e.Handle = h.Upload
|
||||
case "PUT/agent-app-releases/current":
|
||||
e.Handle = h.SetCurrent
|
||||
default:
|
||||
t.Fatalf("unexpected release route %s", keyFor(e))
|
||||
}
|
||||
router.Handle(e.Method, "/api/client/v1"+e.Path, Gate(db, e), e.Handle)
|
||||
}
|
||||
if found != 3 {
|
||||
t.Fatalf("release routes=%d", found)
|
||||
}
|
||||
newKey := func(grant clientkey.Grant) string {
|
||||
_, token, err := s.Create(context.Background(), "k", []clientkey.Grant{grant}, 42)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return token
|
||||
}
|
||||
do := func(token, method, path, contentType string, body *bytes.Buffer) *httptest.ResponseRecorder {
|
||||
if body == nil {
|
||||
body = &bytes.Buffer{}
|
||||
}
|
||||
req := httptest.NewRequest(method, "https://example.test/api/client/v1"+path, body)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
if contentType != "" {
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
}
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
return rr
|
||||
}
|
||||
upload := func(token string, code uint32) *httptest.ResponseRecorder {
|
||||
body, ct := multipartUpload(releaseAPK(t, code, "9.9.9"), "agent.apk")
|
||||
return do(token, "POST", "/agent-app-releases", ct, body)
|
||||
}
|
||||
setCurrent := func(token string, id uint64) *httptest.ResponseRecorder {
|
||||
raw, _ := json.Marshal(map[string]any{"requestId": uuid.NewString(), "releaseId": id})
|
||||
return do(token, "PUT", "/agent-app-releases/current", "application/json", bytes.NewBuffer(raw))
|
||||
}
|
||||
|
||||
// A key with only devices read must be rejected everywhere (devices has no
|
||||
// write capability, so a write grant cannot even be created).
|
||||
if _, _, err := s.Create(context.Background(), "k", []clientkey.Grant{{Module: "devices", Write: true}}, 42); err == nil {
|
||||
t.Fatal("devices write grant must be invalid")
|
||||
}
|
||||
for name, token := range map[string]string{
|
||||
"devices read": newKey(clientkey.Grant{Module: "devices"}),
|
||||
} {
|
||||
if rr := do(token, "GET", "/agent-app-releases", "", nil); rr.Code != 403 {
|
||||
t.Fatalf("%s list %d", name, rr.Code)
|
||||
}
|
||||
if rr := upload(token, 1); rr.Code != 403 {
|
||||
t.Fatalf("%s upload %d", name, rr.Code)
|
||||
}
|
||||
if rr := setCurrent(token, 1); rr.Code != 403 {
|
||||
t.Fatalf("%s activate %d", name, rr.Code)
|
||||
}
|
||||
}
|
||||
uploader := newKey(clientkey.Grant{Module: "devices", Actions: []string{"release_upload"}})
|
||||
if rr := do(uploader, "GET", "/agent-app-releases", "", nil); rr.Code != 200 {
|
||||
t.Fatalf("uploader list %d %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if rr := upload(uploader, 201); rr.Code != 201 {
|
||||
t.Fatalf("uploader upload %d %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var row models.AgentAppRelease
|
||||
if err := db.First(&row).Error; err != nil || row.CreatedBy != 42 {
|
||||
t.Fatalf("createdBy=%d err=%v", row.CreatedBy, err)
|
||||
}
|
||||
if rr := setCurrent(uploader, row.ID); rr.Code != 403 {
|
||||
t.Fatalf("uploader activated %d", rr.Code)
|
||||
}
|
||||
activator := newKey(clientkey.Grant{Module: "devices", Actions: []string{"release_activate"}})
|
||||
if rr := upload(activator, 202); rr.Code != 403 {
|
||||
t.Fatalf("activator uploaded %d", rr.Code)
|
||||
}
|
||||
if rr := setCurrent(activator, row.ID); rr.Code != 200 {
|
||||
t.Fatalf("activator set current %d %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
for _, e := range Inventory() {
|
||||
if strings.Contains(e.Path, "download") {
|
||||
t.Fatal("download must not be a client route")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentReleaseUploadBodyLimitIsScopedToUploadEndpoint(t *testing.T) {
|
||||
if got := bodyLimit(Endpoint{Method: "POST", Path: "/agent-app-releases"}); got != apprelease.MaxAPKBytes+(1<<20) {
|
||||
t.Fatalf("upload limit %d", got)
|
||||
}
|
||||
for _, e := range Inventory() {
|
||||
if e.Method == "POST" && e.Path == "/agent-app-releases" {
|
||||
continue
|
||||
}
|
||||
if got := bodyLimit(e); got != 16<<20 {
|
||||
t.Fatalf("%s limit %d", keyFor(e), got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentReleaseUploadAcceptsBodyAbove16MB(t *testing.T) {
|
||||
db, s := fixture(t)
|
||||
if err := db.AutoMigrate(&models.AgentAppRelease{}, &models.AgentAppReleaseSetting{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e := Endpoint{Method: "POST", Path: "/agent-app-releases", Module: "devices", Capability: "release_upload", Handle: apprelease.Handler{DB: db, StorageDir: t.TempDir()}.Upload}
|
||||
router := gin.New()
|
||||
router.Handle(e.Method, "/api/client/v1"+e.Path, Gate(db, e), e.Handle)
|
||||
_, token, err := s.Create(context.Background(), "k", []clientkey.Grant{{Module: "devices", Actions: []string{"release_upload"}}}, 42)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 17MB of non-APK data passes the transport limit and fails only at APK parsing.
|
||||
body, ct := multipartUpload(bytes.Repeat([]byte{'x'}, 17<<20), "big.apk")
|
||||
req := httptest.NewRequest(http.MethodPost, "https://example.test/api/client/v1"+e.Path, body)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", ct)
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
if rr.Code != 422 || !strings.Contains(rr.Body.String(), "文件不是有效 APK") {
|
||||
t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var audits []clientkey.Audit
|
||||
if err = db.Where("event = ?", "request").Find(&audits).Error; err != nil || len(audits) != 1 || audits[0].Status != 422 {
|
||||
t.Fatalf("audit=%+v err=%v", audits, err)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ package clientapi
|
||||
import (
|
||||
"go-admin/app/goauto/access"
|
||||
"go-admin/app/goauto/aimatching"
|
||||
"go-admin/app/goauto/apprelease"
|
||||
"go-admin/app/goauto/clientkey"
|
||||
"go-admin/app/goauto/device"
|
||||
"go-admin/app/goauto/product"
|
||||
@@ -39,9 +40,13 @@ func Inventory() []Endpoint {
|
||||
pr := purchaserule.Handler{}
|
||||
t := task.Handler{}
|
||||
buy := purchase.Handler{}
|
||||
release := apprelease.Handler{}
|
||||
return []Endpoint{
|
||||
{"POST", "/ai-matching-settings/resolve", access.ModuleAIMatching, "match", resolveSpecs},
|
||||
{"GET", "/devices", access.ModuleDevices, "read", device.Handler{}.List},
|
||||
{"GET", "/agent-app-releases", access.ModuleDevices, "release_upload", release.List},
|
||||
{"POST", "/agent-app-releases", access.ModuleDevices, "release_upload", release.Upload},
|
||||
{"PUT", "/agent-app-releases/current", access.ModuleDevices, "release_activate", release.SetCurrent},
|
||||
{"GET", "/pdd-products", access.ModulePDDProducts, "read", p.List},
|
||||
{"GET", "/pdd-products/:productId", access.ModulePDDProducts, "read", p.Detail},
|
||||
{"POST", "/pdd-products", access.ModulePDDProducts, "write", p.Create},
|
||||
|
||||
Reference in New Issue
Block a user