feat: add support for uploading Hotspot Captive Portal files (like background image, logo) (#42)

* feat: add support for uploading Hotspot Captive Portal files (like background image, logo)

* feat: add UploadPortalFileFromReader
This commit is contained in:
Mateusz Filipowicz
2025-03-03 02:03:10 +01:00
committed by GitHub
parent bdc73a9811
commit 278a72fbb9
6 changed files with 330 additions and 16 deletions

View File

@@ -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:

2
go.mod
View File

@@ -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

View File

@@ -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,
}
)

View File

@@ -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

117
unifi/portalfile.go Normal file
View File

@@ -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
}

View File

@@ -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.