diff --git a/codegen/customizations.yml b/codegen/customizations.yml index a5c7991..78fe5dc 100644 --- a/codegen/customizations.yml +++ b/codegen/customizations.yml @@ -1,6 +1,8 @@ --- customizations: client: + imports: + - "io" excludeResources: - "DescribedFeature" - "Dpi*" @@ -334,6 +336,70 @@ customizations: returns: - "bool" - "error" + - name: "UploadPortalFile" + resourceName: "PortalFile" + comment: "UploadPortalFile uploads a Hotspot Portal file to the controller." + params: + - name: "ctx" + type: "context.Context" + - name: "site" + type: "string" + - name: "filepath" + type: "string" + returns: + - "*PortalFile" + - "error" + - name: "UploadPortalFileFromReader" + resourceName: "PortalFile" + comment: "UploadPortalFileFromReader uploads a Hotspot Portal file using io.Reader to the controller." + params: + - name: "ctx" + type: "context.Context" + - name: "site" + type: "string" + - name: "reader" + type: "io.Reader" + - name: "filename" + type: "string" + returns: + - "*PortalFile" + - "error" + - name: "DeletePortalFile" + resourceName: "PortalFile" + comment: "DeletePortalFile deletes a Hotspot Portal file from the controller." + params: + - name: "ctx" + type: "context.Context" + - name: "site" + type: "string" + - name: "id" + type: "string" + returns: + - "error" + - name: "ListPortalFiles" + resourceName: "PortalFile" + comment: "ListPortalFiles lists all Hotspot Portal files on the controller." + params: + - name: "ctx" + type: "context.Context" + - name: "site" + type: "string" + returns: + - "[]PortalFile" + - "error" + - name: "GetPortalFile" + resourceName: "PortalFile" + comment: "GetPortalFile returns a specific Hotspot Portal file by it's ID." + params: + - name: "ctx" + type: "context.Context" + - name: "site" + type: "string" + - name: "id" + type: "string" + returns: + - "*PortalFile" + - "error" resources: Account: fields: diff --git a/go.mod b/go.mod index a4e49ae..aee9eb9 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/filipowm/go-unifi go 1.23.5 require ( + github.com/gabriel-vasile/mimetype v1.4.8 github.com/go-playground/locales v0.14.1 github.com/go-playground/universal-translator v0.18.1 github.com/go-playground/validator/v10 v10.25.0 @@ -179,7 +180,6 @@ require ( github.com/firefart/nonamedreturns v1.0.5 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/fzipp/gocyclo v0.6.0 // indirect - github.com/gabriel-vasile/mimetype v1.4.8 // indirect github.com/ghostiam/protogetter v0.3.9 // indirect github.com/github/smimesign v0.2.0 // indirect github.com/go-critic/go-critic v0.12.0 // indirect diff --git a/unifi/api_paths.go b/unifi/api_paths.go index 0bb13d7..d0e4b5d 100644 --- a/unifi/api_paths.go +++ b/unifi/api_paths.go @@ -20,6 +20,9 @@ const ( statusPath = "/status" statusPathNew = "/proxy/network/status" + uploadPath = "/upload" + uploadPathNew = "/proxy/network/upload" + logoutPath = "/api/logout" defaultUserAgent = "go-unifi/0.0.1" @@ -38,6 +41,7 @@ type APIPaths struct { LoginPath string StatusPath string LogoutPath string + UploadPath string } var ( @@ -47,6 +51,7 @@ var ( LoginPath: loginPath, StatusPath: statusPath, LogoutPath: logoutPath, + UploadPath: uploadPath, } NewStyleAPI = APIPaths{ ApiPath: apiPathNew, @@ -54,6 +59,7 @@ var ( LoginPath: loginPathNew, StatusPath: statusPathNew, LogoutPath: logoutPath, + UploadPath: uploadPathNew, } ) diff --git a/unifi/client.generated.go b/unifi/client.generated.go index 255d3f7..afd23c2 100644 --- a/unifi/client.generated.go +++ b/unifi/client.generated.go @@ -5,6 +5,7 @@ package unifi import ( "context" + "io" ) type Client interface { @@ -495,6 +496,21 @@ type Client interface { // ==== end of client methods for PortProfile resource ==== + // DeletePortalFile deletes a Hotspot Portal file from the controller. + DeletePortalFile(ctx context.Context, site string, id string) error + + // GetPortalFile returns a specific Hotspot Portal file by it's ID. + GetPortalFile(ctx context.Context, site string, id string) (*PortalFile, error) + + // ListPortalFiles lists all Hotspot Portal files on the controller. + ListPortalFiles(ctx context.Context, site string) ([]PortalFile, error) + + // UploadPortalFile uploads a Hotspot Portal file to the controller. + UploadPortalFile(ctx context.Context, site string, filepath string) (*PortalFile, error) + + // UploadPortalFileFromReader uploads a Hotspot Portal file using io.Reader to the controller. + UploadPortalFileFromReader(ctx context.Context, site string, reader io.Reader, filename string) (*PortalFile, error) + // ==== client methods for RADIUSProfile resource ==== // CreateRADIUSProfile creates a resource diff --git a/unifi/portalfile.go b/unifi/portalfile.go new file mode 100644 index 0000000..fe22b79 --- /dev/null +++ b/unifi/portalfile.go @@ -0,0 +1,117 @@ +package unifi + +import ( + "context" + "encoding/json" + "fmt" + "io" +) + +// just to fix compile issues with the import. +var ( + _ context.Context + _ fmt.Formatter + _ json.Marshaler +) + +type PortalFile struct { + ID string `json:"_id,omitempty"` + SiteID string `json:"site_id,omitempty"` + + ContentType string `json:"content_type,omitempty"` + LastModified int `json:"last_modified,omitempty"` + Filename string `json:"filename,omitempty"` + FileSize int `json:"filesize,omitempty"` + MD5 string `json:"md5,omitempty"` + URL string `json:"url,omitempty"` +} + +func (dst *PortalFile) UnmarshalJSON(b []byte) error { + type Alias PortalFile + aux := &struct { + *Alias + }{ + Alias: (*Alias)(dst), + } + + err := json.Unmarshal(b, &aux) + if err != nil { + return fmt.Errorf("unable to unmarshal alias: %w", err) + } + + return nil +} + +func (c *client) UploadPortalFile(ctx context.Context, site string, filepath string) (*PortalFile, error) { + var respBody struct { + Meta Meta `json:"meta"` + Data []PortalFile `json:"data"` + } + + err := c.UploadFile(ctx, fmt.Sprintf("%s/s/%s/portalfile", c.apiPaths.UploadPath, site), filepath, "file", &respBody) + if err != nil { + return nil, err + } + + if len(respBody.Data) == 0 { + return nil, ErrNotFound + } + return &respBody.Data[0], nil +} + +func (c *client) UploadPortalFileFromReader(ctx context.Context, site string, reader io.Reader, filename string) (*PortalFile, error) { + var respBody struct { + Meta Meta `json:"meta"` + Data []PortalFile `json:"data"` + } + + err := c.UploadFileFromReader(ctx, fmt.Sprintf("%s/s/%s/portalfile", c.apiPaths.UploadPath, site), reader, filename, "file", &respBody) + if err != nil { + return nil, err + } + + if len(respBody.Data) == 0 { + return nil, ErrNotFound + } + return &respBody.Data[0], nil +} + +func (c *client) DeletePortalFile(ctx context.Context, site, id string) error { + err := c.Delete(ctx, fmt.Sprintf("s/%s/rest/portalfile/%s", site, id), struct{}{}, nil) + if err != nil { + return err + } + return nil +} + +func (c *client) ListPortalFiles(ctx context.Context, site string) ([]PortalFile, error) { + var respBody struct { + Meta Meta `json:"meta"` + Data []PortalFile `json:"data"` + } + + err := c.Get(ctx, fmt.Sprintf("s/%s/rest/portalfile", site), nil, &respBody) + if err != nil { + return nil, err + } + + return respBody.Data, nil +} + +func (c *client) GetPortalFile(ctx context.Context, site, id string) (*PortalFile, error) { + var respBody struct { + Meta Meta `json:"meta"` + Data []PortalFile `json:"data"` + } + + err := c.Get(ctx, fmt.Sprintf("s/%s/rest/portalfile/%s", site, id), nil, &respBody) + if err != nil { + return nil, err + } + + if len(respBody.Data) != 1 { + return nil, ErrNotFound + } + + return &respBody.Data[0], nil +} diff --git a/unifi/requests.go b/unifi/requests.go index 7e44395..39843b5 100644 --- a/unifi/requests.go +++ b/unifi/requests.go @@ -6,10 +6,16 @@ import ( "encoding/json" "fmt" "io" + "mime/multipart" "net/http" + "net/textproto" "net/url" + "os" "path" + "path/filepath" "strings" + + "github.com/gabriel-vasile/mimetype" ) // marshalRequest marshals the request body to an io.Reader. Returns nil if reqBody is nil. @@ -60,35 +66,27 @@ func (c *client) newRequestContext() (context.Context, context.CancelFunc) { return ctx, func() {} } -// Do performs an HTTP request using the given method, apiPath, request body, and decodes the response into respBody. -// It validates the request body, applies interceptors, and decodes the HTTP response into respBody if provided. -// It returns an error if the request or response handling fails. -func (c *client) Do(ctx context.Context, method, apiPath string, reqBody interface{}, respBody interface{}) error { - c.Tracef("Performing request: %s %s", method, apiPath) - if err := c.validateRequestBody(reqBody); err != nil { - return err - } - reqReader, err := marshalRequest(reqBody) - if err != nil { - return fmt.Errorf("unable to marshal request: %w", err) - } - +// executeRequest executes an HTTP request with the given context, method, URL, body, and headers. +// It applies interceptors, handles errors, and decodes the response body if provided. +// Returns an error if the request or response handling fails. +func (c *client) executeRequest(ctx context.Context, method, apiPath string, body io.Reader, headers http.Header, respBody interface{}) error { url, err := c.buildRequestURL(apiPath) if err != nil { return fmt.Errorf("unable to create request URL: %w", err) } c.Debugf("Executing request: %s %s", method, url.String()) - req, err := http.NewRequestWithContext(ctx, method, url.String(), reqReader) + req, err := http.NewRequestWithContext(ctx, method, url.String(), body) if err != nil { return fmt.Errorf("unable to create request: %s %s %w", method, apiPath, err) } if c.useLocking { c.lock.Lock() - c.Trace("Acquired lock fo request") + c.Trace("Acquired lock for request") defer c.lock.Unlock() } + c.Trace("Executing request interceptors") for _, interceptor := range c.interceptors { if err := interceptor.InterceptRequest(req); err != nil { @@ -96,6 +94,17 @@ func (c *client) Do(ctx context.Context, method, apiPath string, reqBody interfa } } + // Set headers if provided overriding any coming from interceptors + for key, values := range headers { + // delete headers if already exist to be able to override them + if req.Header.Get(key) != "" { + req.Header.Del(key) + } + for _, value := range values { + req.Header.Add(key, value) + } + } + resp, err := c.http.Do(req) if err != nil { return fmt.Errorf("unable to perform request: %s %s %w", method, apiPath, err) @@ -114,6 +123,7 @@ func (c *client) Do(ctx context.Context, method, apiPath string, reqBody interfa return err } + // If no response body is expected, return if respBody == nil || resp.ContentLength == 0 { c.Trace("No response body to decode") return nil @@ -127,6 +137,105 @@ func (c *client) Do(ctx context.Context, method, apiPath string, reqBody interfa return nil } +// UploadFile uploads a file to the UniFi controller. +// It takes a context, API path, file path, field name, and additional form fields. +// The file is uploaded as multipart/form-data. +// It returns the response body and an error if the operation fails. +func (c *client) UploadFile(ctx context.Context, apiPath, filePath, fieldName string, respBody interface{}) error { + c.Tracef("Uploading file: %s to %s", filePath, apiPath) + + file, err := os.Open(filePath) + if err != nil { + return fmt.Errorf("unable to open file for upload: %w", err) + } + defer file.Close() + return c.UploadFileFromReader(ctx, apiPath, file, filepath.Base(filePath), fieldName, respBody) +} + +var quoteEscaper = strings.NewReplacer("\\", "\\\\", `"`, "\\\"") + +func escapeQuotes(s string) string { + return quoteEscaper.Replace(s) +} + +// NOTE! This is a copy of the function from the mime/multipart package, but allows to set custom mimetype. +func createFormFile(w *multipart.Writer, mimeType, fieldname, filename string) (io.Writer, error) { + h := make(textproto.MIMEHeader) + h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`, escapeQuotes(fieldname), escapeQuotes(filename))) + h.Set("Content-Type", mimeType) + return w.CreatePart(h) +} + +// UploadFileFromReader uploads a file to the UniFi controller from an io.Reader. +// It takes a context, API path, reader, filename, field name, and additional form fields. +// The file is uploaded as multipart/form-data. +func (c *client) UploadFileFromReader(ctx context.Context, apiPath string, reader io.Reader, filename, fieldName string, respBody interface{}) error { + c.Tracef("Uploading file: %s to %s", filename, apiPath) + + // Read the entire content into a buffer first to avoid deadlock. I tied using TeeReader and Pipe but ended up in deadlock. + var buf bytes.Buffer + if _, err := io.Copy(&buf, reader); err != nil { + return fmt.Errorf("unable to read file content into buffer: %w", err) + } + contentReader := bytes.NewReader(buf.Bytes()) + + if fieldName == "" { + fieldName = "file" + } + + // Detect MIME type from the first reader + mt, err := mimetype.DetectReader(contentReader) + if err != nil { + return fmt.Errorf("unable to detect file mimetype: %w", err) + } + + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + part, err := createFormFile(writer, mt.String(), fieldName, filename) + if err != nil { + return fmt.Errorf("unable to create form file: %w", err) + } + // reinit reader + contentReader = bytes.NewReader(buf.Bytes()) + // Copy the file content to the form field from the second reader + if _, err = io.Copy(part, contentReader); err != nil { + return fmt.Errorf("unable to copy file content: %w", err) + } + + if err := writer.Close(); err != nil { + return fmt.Errorf("unable to close multipart writer: %w", err) + } + + headers := http.Header{} + headers.Set("Content-Type", writer.FormDataContentType()) + headers.Set("X-Requested-With", "XMLHttpRequest") // TODO if not provided, the response will be 404. UniFi bug? + + return c.executeRequest(ctx, http.MethodPost, apiPath, body, headers, respBody) +} + +// Do performs an HTTP request using the given method, apiPath, request body, and decodes the response into respBody. +// It validates the request body, applies interceptors, and decodes the HTTP response into respBody if provided. +// It returns an error if the request or response handling fails. +func (c *client) Do(ctx context.Context, method, apiPath string, reqBody interface{}, respBody interface{}) error { + c.Tracef("Performing request: %s %s", method, apiPath) + + if err := c.validateRequestBody(reqBody); err != nil { + return err + } + + body, err := marshalRequest(reqBody) + if err != nil { + return fmt.Errorf("unable to marshal request: %w", err) + } + + headers := http.Header{} + if reqBody != nil { + headers.Set("Content-Type", "application/json") + } + + return c.executeRequest(ctx, method, apiPath, body, headers, respBody) +} + // Get sends an HTTP GET request to the specified API path with the provided request body, // and decodes the HTTP response into respBody. // It is a convenience wrapper around Do.