|
|
@@ -8,15 +8,48 @@ import (
|
|
|
"fmt"
|
|
|
"io"
|
|
|
"log/slog"
|
|
|
+ "mime"
|
|
|
+ "mime/multipart"
|
|
|
"net/http"
|
|
|
+ "net/textproto"
|
|
|
"net/url"
|
|
|
+ "path/filepath"
|
|
|
+ "regexp"
|
|
|
+ "slices"
|
|
|
"strings"
|
|
|
+ "sync"
|
|
|
"time"
|
|
|
+ "unicode/utf8"
|
|
|
)
|
|
|
|
|
|
// maxResponseBytes bounds the response body read from WikiJS.
|
|
|
const maxResponseBytes = 32 << 20
|
|
|
|
|
|
+// filenameRuns matches the character runs WikiJS collapses to a single
|
|
|
+// underscore when sanitizing uploaded filenames.
|
|
|
+var filenameRuns = regexp.MustCompile(`[\s,;#]+`)
|
|
|
+
|
|
|
+// assetFolderSegmentPattern matches a single asset folder slug segment as
|
|
|
+// produced by WikiJS (lowercase slugs; validation is deliberately tolerant
|
|
|
+// of uppercase input because folder matching is case-insensitive).
|
|
|
+var assetFolderSegmentPattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]*$`)
|
|
|
+
|
|
|
+// DefaultMaxUploadBytes is the upload limit WikiJS enforces out of the box
|
|
|
+// (5 MB, server/app/data.yml). It is the fallback when the site config is
|
|
|
+// not readable with the configured API key.
|
|
|
+const DefaultMaxUploadBytes = 5 << 20
|
|
|
+
|
|
|
+// siteConfigQuery fetches the upload limit from the WikiJS site config.
|
|
|
+// The field requires the manage:system permission; callers must treat a
|
|
|
+// failure as "limit unknown" and fall back to DefaultMaxUploadBytes.
|
|
|
+const siteConfigQuery = `query SiteConfig {
|
|
|
+ site {
|
|
|
+ config {
|
|
|
+ uploadMaxFileSize
|
|
|
+ }
|
|
|
+ }
|
|
|
+}`
|
|
|
+
|
|
|
// Client talks to the WikiJS GraphQL API.
|
|
|
type Client struct {
|
|
|
baseURL string
|
|
|
@@ -25,6 +58,9 @@ type Client struct {
|
|
|
userAgent string
|
|
|
http *http.Client
|
|
|
logger *slog.Logger
|
|
|
+
|
|
|
+ uploadLimitOnce sync.Once
|
|
|
+ uploadLimitBytes int
|
|
|
}
|
|
|
|
|
|
// Option customizes a [Client].
|
|
|
@@ -262,6 +298,222 @@ func (c *Client) UpdatePage(ctx context.Context, in UpdatePageInput) (MutationRe
|
|
|
return mutationResult(data.Pages.Update.ResponseResult)
|
|
|
}
|
|
|
|
|
|
+// ListAssetFolders returns the child folders of an asset folder. An empty
|
|
|
+// parentPath addresses the root folder. Folder paths are slash-separated slug
|
|
|
+// chains, for example "images/vacation".
|
|
|
+func (c *Client) ListAssetFolders(ctx context.Context, parentPath string) ([]AssetFolder, error) {
|
|
|
+ parentID, chain, err := c.resolveAssetFolder(ctx, parentPath)
|
|
|
+ if err != nil {
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+
|
|
|
+ folders, err := c.assetFolders(ctx, parentID)
|
|
|
+ if err != nil {
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+ for i := range folders {
|
|
|
+ folders[i].Path = strings.Join(append(slices.Clone(chain), folders[i].Slug), "/")
|
|
|
+ }
|
|
|
+ return folders, nil
|
|
|
+}
|
|
|
+
|
|
|
+// ListAssets returns the assets of an asset folder. An empty folderPath
|
|
|
+// addresses the root folder.
|
|
|
+func (c *Client) ListAssets(ctx context.Context, folderPath string) ([]Asset, error) {
|
|
|
+ folderID, chain, err := c.resolveAssetFolder(ctx, folderPath)
|
|
|
+ if err != nil {
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+
|
|
|
+ var data struct {
|
|
|
+ Assets *struct {
|
|
|
+ List []Asset `json:"list"`
|
|
|
+ } `json:"assets"`
|
|
|
+ }
|
|
|
+ vars := map[string]any{"folderId": folderID, "kind": "ALL"}
|
|
|
+ if err := c.do(ctx, "ListAssets", listAssetsQuery, vars, &data); err != nil {
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+ out := []Asset{}
|
|
|
+ if data.Assets == nil {
|
|
|
+ return out, nil
|
|
|
+ }
|
|
|
+ for i := range data.Assets.List {
|
|
|
+ asset := &data.Assets.List[i]
|
|
|
+ if asset.Folder == nil {
|
|
|
+ asset.Folder = &AssetFolder{ID: folderID, Path: strings.Join(chain, "/")}
|
|
|
+ } else {
|
|
|
+ asset.Folder.Path = strings.Join(chain, "/")
|
|
|
+ }
|
|
|
+ asset.URL = c.assetURL(chain, asset.FileName)
|
|
|
+ out = append(out, *asset)
|
|
|
+ }
|
|
|
+ return out, nil
|
|
|
+}
|
|
|
+
|
|
|
+// CreateAssetFolder creates a new asset folder inside parentPath (empty means
|
|
|
+// the root folder). slug must already be a valid WikiJS slug.
|
|
|
+func (c *Client) CreateAssetFolder(ctx context.Context, parentPath, slug, name string) (MutationResult, error) {
|
|
|
+ parentID, _, err := c.resolveAssetFolder(ctx, parentPath)
|
|
|
+ if err != nil {
|
|
|
+ return MutationResult{}, err
|
|
|
+ }
|
|
|
+
|
|
|
+ var data struct {
|
|
|
+ Assets *struct {
|
|
|
+ CreateFolder *struct {
|
|
|
+ ResponseResult *responseResult `json:"responseResult"`
|
|
|
+ } `json:"createFolder"`
|
|
|
+ } `json:"assets"`
|
|
|
+ }
|
|
|
+ vars := map[string]any{"parentFolderId": parentID, "slug": slug, "name": name}
|
|
|
+ if err := c.do(ctx, "CreateAssetFolder", createAssetFolderMutation, vars, &data); err != nil {
|
|
|
+ return MutationResult{}, err
|
|
|
+ }
|
|
|
+ if data.Assets == nil || data.Assets.CreateFolder == nil {
|
|
|
+ return MutationResult{}, errors.New("wikijs CreateAssetFolder: missing createFolder result")
|
|
|
+ }
|
|
|
+ return mutationResult(data.Assets.CreateFolder.ResponseResult)
|
|
|
+}
|
|
|
+
|
|
|
+// uploadLimit returns the WikiJS upload limit in bytes, reading it once
|
|
|
+// from the site config (requires manage:system) and falling back to
|
|
|
+// DefaultMaxUploadBytes when the config is not readable. The value is
|
|
|
+// cached for the lifetime of the client; a failed lookup is not retried.
|
|
|
+func (c *Client) uploadLimit(ctx context.Context) int {
|
|
|
+ c.uploadLimitOnce.Do(func() {
|
|
|
+ var data struct {
|
|
|
+ Site *struct {
|
|
|
+ Config *struct {
|
|
|
+ UploadMaxFileSize *int `json:"uploadMaxFileSize"`
|
|
|
+ } `json:"config"`
|
|
|
+ } `json:"site"`
|
|
|
+ }
|
|
|
+ if err := c.do(ctx, "SiteConfig", siteConfigQuery, nil, &data); err != nil {
|
|
|
+ c.logger.Debug("site config not readable; using default upload limit",
|
|
|
+ "limit", DefaultMaxUploadBytes, "error", err)
|
|
|
+ c.uploadLimitBytes = DefaultMaxUploadBytes
|
|
|
+ return
|
|
|
+ }
|
|
|
+ limit := 0
|
|
|
+ if data.Site != nil && data.Site.Config != nil && data.Site.Config.UploadMaxFileSize != nil {
|
|
|
+ limit = *data.Site.Config.UploadMaxFileSize
|
|
|
+ }
|
|
|
+ if limit <= 0 {
|
|
|
+ limit = DefaultMaxUploadBytes
|
|
|
+ }
|
|
|
+ c.uploadLimitBytes = limit
|
|
|
+ })
|
|
|
+ return c.uploadLimitBytes
|
|
|
+}
|
|
|
+
|
|
|
+// UploadAsset uploads a single file to an asset folder (empty folderPath
|
|
|
+// means the root folder) using the /u upload endpoint. The filename appears
|
|
|
+// in the returned URL and in markdown references. mimeType is optional and
|
|
|
+// guessed from the file extension when empty.
|
|
|
+//
|
|
|
+// Uploads larger than the WikiJS upload limit are rejected before they are
|
|
|
+// sent. The limit is read once from the site config (requires manage:system)
|
|
|
+// and falls back to DefaultMaxUploadBytes; it can be overridden per tool
|
|
|
+// call only by raising the limit in WikiJS itself.
|
|
|
+func (c *Client) UploadAsset(ctx context.Context, folderPath, filename string, data []byte, mimeType string) (MutationResult, UploadedAsset, error) {
|
|
|
+ clean := sanitizeAssetFilename(filename)
|
|
|
+ if clean == "" || clean == "." {
|
|
|
+ return MutationResult{}, UploadedAsset{}, fmt.Errorf("wikijs UploadAsset: invalid filename %q", filename)
|
|
|
+ }
|
|
|
+ if len(data) > c.uploadLimit(ctx) {
|
|
|
+ return MutationResult{}, UploadedAsset{}, fmt.Errorf(
|
|
|
+ "wikijs UploadAsset: file is %d bytes, exceeding the WikiJS upload limit of %d bytes; reduce the file size or raise the limit in WikiJS (Administration > System Info)",
|
|
|
+ len(data), c.uploadLimitBytes)
|
|
|
+ }
|
|
|
+ folderID, chain, err := c.resolveAssetFolder(ctx, folderPath)
|
|
|
+ if err != nil {
|
|
|
+ return MutationResult{}, UploadedAsset{}, err
|
|
|
+ }
|
|
|
+
|
|
|
+ if mimeType == "" {
|
|
|
+ mimeType = "application/octet-stream"
|
|
|
+ if guessed := mime.TypeByExtension(filepath.Ext(clean)); guessed != "" {
|
|
|
+ mimeType = guessed
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ metadata, err := json.Marshal(map[string]any{"folderId": folderID})
|
|
|
+ if err != nil {
|
|
|
+ return MutationResult{}, UploadedAsset{}, fmt.Errorf("wikijs UploadAsset: encode metadata: %w", err)
|
|
|
+ }
|
|
|
+
|
|
|
+ var body bytes.Buffer
|
|
|
+ form := multipart.NewWriter(&body)
|
|
|
+ metaPart, err := form.CreatePart(textproto.MIMEHeader{
|
|
|
+ "Content-Disposition": {`form-data; name="mediaUpload"`},
|
|
|
+ })
|
|
|
+ if err != nil {
|
|
|
+ return MutationResult{}, UploadedAsset{}, fmt.Errorf("wikijs UploadAsset: build form: %w", err)
|
|
|
+ }
|
|
|
+ if _, err := metaPart.Write(metadata); err != nil {
|
|
|
+ return MutationResult{}, UploadedAsset{}, fmt.Errorf("wikijs UploadAsset: build form: %w", err)
|
|
|
+ }
|
|
|
+ filePart, err := form.CreatePart(textproto.MIMEHeader{
|
|
|
+ "Content-Disposition": {fmt.Sprintf(`form-data; name="mediaUpload"; filename=%q`, clean)},
|
|
|
+ "Content-Type": {mimeType},
|
|
|
+ })
|
|
|
+ if err != nil {
|
|
|
+ return MutationResult{}, UploadedAsset{}, fmt.Errorf("wikijs UploadAsset: build form: %w", err)
|
|
|
+ }
|
|
|
+ if _, err := filePart.Write(data); err != nil {
|
|
|
+ return MutationResult{}, UploadedAsset{}, fmt.Errorf("wikijs UploadAsset: build form: %w", err)
|
|
|
+ }
|
|
|
+ if err := form.Close(); err != nil {
|
|
|
+ return MutationResult{}, UploadedAsset{}, fmt.Errorf("wikijs UploadAsset: build form: %w", err)
|
|
|
+ }
|
|
|
+
|
|
|
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/u", &body)
|
|
|
+ if err != nil {
|
|
|
+ return MutationResult{}, UploadedAsset{}, fmt.Errorf("wikijs UploadAsset: build request: %w", err)
|
|
|
+ }
|
|
|
+ req.Header.Set("Content-Type", form.FormDataContentType())
|
|
|
+ req.Header.Set("Authorization", "Bearer "+c.apiKey)
|
|
|
+ req.Header.Set("User-Agent", c.userAgent)
|
|
|
+
|
|
|
+ resp, err := c.http.Do(req)
|
|
|
+ if err != nil {
|
|
|
+ return MutationResult{}, UploadedAsset{}, fmt.Errorf("wikijs UploadAsset: %w", err)
|
|
|
+ }
|
|
|
+ defer resp.Body.Close()
|
|
|
+
|
|
|
+ respBody, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes+1))
|
|
|
+ if err != nil {
|
|
|
+ return MutationResult{}, UploadedAsset{}, fmt.Errorf("wikijs UploadAsset: read response: %w", err)
|
|
|
+ }
|
|
|
+ if len(respBody) > maxResponseBytes {
|
|
|
+ return MutationResult{}, UploadedAsset{}, &APIError{
|
|
|
+ Operation: "UploadAsset",
|
|
|
+ Status: resp.StatusCode,
|
|
|
+ Err: fmt.Errorf("response exceeds %d bytes", maxResponseBytes),
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices || strings.TrimSpace(string(respBody)) != "ok" {
|
|
|
+ var rr struct {
|
|
|
+ Succeeded bool `json:"succeeded"`
|
|
|
+ Message string `json:"message"`
|
|
|
+ }
|
|
|
+ if err := json.Unmarshal(respBody, &rr); err == nil && rr.Message != "" {
|
|
|
+ return MutationResult{Succeeded: false, Message: rr.Message}, UploadedAsset{}, nil
|
|
|
+ }
|
|
|
+ return MutationResult{}, UploadedAsset{}, &APIError{
|
|
|
+ Operation: "UploadAsset",
|
|
|
+ Status: resp.StatusCode,
|
|
|
+ Body: truncate(strings.TrimSpace(string(respBody)), 512),
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return MutationResult{Succeeded: true}, UploadedAsset{
|
|
|
+ Filename: clean,
|
|
|
+ URL: c.assetURL(chain, clean),
|
|
|
+ }, nil
|
|
|
+}
|
|
|
+
|
|
|
type responseResult struct {
|
|
|
Succeeded bool `json:"succeeded"`
|
|
|
ErrorCode int `json:"errorCode"`
|
|
|
@@ -402,12 +654,27 @@ func (c *Client) decorate(page *Page) *Page {
|
|
|
return page
|
|
|
}
|
|
|
|
|
|
+// pageURL builds the absolute URL of a page. Each path segment is escaped
|
|
|
+// separately so that separators stay intact and unicode filenames produce
|
|
|
+// valid links. The locale segment is only included for non-English pages.
|
|
|
func (c *Client) pageURL(path, locale string) string {
|
|
|
p := strings.TrimLeft(path, "/")
|
|
|
+ segments := []string{}
|
|
|
if locale != "" && locale != "en" {
|
|
|
- return c.baseURL + "/" + locale + "/" + p
|
|
|
+ segments = append(segments, locale)
|
|
|
}
|
|
|
- return c.baseURL + "/" + p
|
|
|
+ segments = append(segments, strings.Split(p, "/")...)
|
|
|
+ return c.baseURL + "/" + escapePathSegments(segments)
|
|
|
+}
|
|
|
+
|
|
|
+// escapePathSegments escapes every segment with url.PathEscape and joins
|
|
|
+// them with "/", keeping the path structure intact.
|
|
|
+func escapePathSegments(segments []string) string {
|
|
|
+ escaped := make([]string, len(segments))
|
|
|
+ for i, seg := range segments {
|
|
|
+ escaped[i] = url.PathEscape(seg)
|
|
|
+ }
|
|
|
+ return strings.Join(escaped, "/")
|
|
|
}
|
|
|
|
|
|
func putString(vars map[string]any, key, value string) {
|
|
|
@@ -427,5 +694,139 @@ func truncate(s string, max int) string {
|
|
|
if len(s) <= max {
|
|
|
return s
|
|
|
}
|
|
|
- return s[:max] + "..."
|
|
|
+ return truncateRunes(s, max-3) + "..."
|
|
|
+}
|
|
|
+
|
|
|
+// assetFolders returns the direct child folders of the folder with the given
|
|
|
+// ID (0 is the root folder).
|
|
|
+func (c *Client) assetFolders(ctx context.Context, parentFolderID int) ([]AssetFolder, error) {
|
|
|
+ var data struct {
|
|
|
+ Assets *struct {
|
|
|
+ Folders []AssetFolder `json:"folders"`
|
|
|
+ } `json:"assets"`
|
|
|
+ }
|
|
|
+ if err := c.do(ctx, "ListAssetFolders", listAssetFoldersQuery, map[string]any{"parentFolderId": parentFolderID}, &data); err != nil {
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+ out := []AssetFolder{}
|
|
|
+ if data.Assets == nil {
|
|
|
+ return out, nil
|
|
|
+ }
|
|
|
+ return append(out, data.Assets.Folders...), nil
|
|
|
+}
|
|
|
+
|
|
|
+// resolveAssetFolder walks the asset folder tree from the root and returns the
|
|
|
+// ID and slug chain of the folder at folderPath. An empty path yields the root
|
|
|
+// folder (ID 0) with an empty chain.
|
|
|
+func (c *Client) resolveAssetFolder(ctx context.Context, folderPath string) (int, []string, error) {
|
|
|
+ segments, err := assetFolderPathSegments(folderPath)
|
|
|
+ if err != nil {
|
|
|
+ return 0, nil, err
|
|
|
+ }
|
|
|
+
|
|
|
+ id := 0
|
|
|
+ chain := []string{}
|
|
|
+ for _, slug := range segments {
|
|
|
+ children, err := c.assetFolders(ctx, id)
|
|
|
+ if err != nil {
|
|
|
+ return 0, nil, err
|
|
|
+ }
|
|
|
+ found := false
|
|
|
+ for _, folder := range children {
|
|
|
+ if strings.EqualFold(folder.Slug, slug) {
|
|
|
+ id = folder.ID
|
|
|
+ chain = append(chain, folder.Slug)
|
|
|
+ found = true
|
|
|
+ break
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if !found {
|
|
|
+ return 0, nil, fmt.Errorf("asset folder %q not found", strings.Join(chain, "/")+"/"+slug)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return id, chain, nil
|
|
|
+}
|
|
|
+
|
|
|
+// AssetFolderPathSegments splits a slash-separated asset folder path into
|
|
|
+// slug segments. An empty (or whitespace-only) path yields no segments and
|
|
|
+// addresses the root folder. It rejects empty segments and segments that are
|
|
|
+// not valid WikiJS slugs; resolvers treat matching case-insensitively.
|
|
|
+func AssetFolderPathSegments(folderPath string) ([]string, error) {
|
|
|
+ segments, err := assetFolderPathSegments(folderPath)
|
|
|
+ if err != nil {
|
|
|
+ return nil, err
|
|
|
+ }
|
|
|
+ for _, segment := range segments {
|
|
|
+ if !assetFolderSegmentPattern.MatchString(segment) {
|
|
|
+ return nil, fmt.Errorf("invalid asset folder segment %q: use slugs like images or images-screens", segment)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return segments, nil
|
|
|
+}
|
|
|
+
|
|
|
+func assetFolderPathSegments(folderPath string) ([]string, error) {
|
|
|
+ if strings.TrimSpace(folderPath) == "" {
|
|
|
+ return nil, nil
|
|
|
+ }
|
|
|
+ var segments []string
|
|
|
+ for _, segment := range strings.Split(strings.Trim(folderPath, "/"), "/") {
|
|
|
+ segment = strings.TrimSpace(segment)
|
|
|
+ if segment == "" {
|
|
|
+ return nil, fmt.Errorf("asset folder path %q must not contain an empty segment", folderPath)
|
|
|
+ }
|
|
|
+ segments = append(segments, segment)
|
|
|
+ }
|
|
|
+ return segments, nil
|
|
|
+}
|
|
|
+
|
|
|
+// assetURL builds the direct download URL of an asset. WikiJS serves assets at
|
|
|
+// <base>/<folder chain>/<filename>; root assets live directly under <base>.
|
|
|
+func (c *Client) assetURL(chain []string, filename string) string {
|
|
|
+ parts := append(slices.Clone(chain), filename)
|
|
|
+ return c.baseURL + "/" + strings.Join(parts, "/")
|
|
|
+}
|
|
|
+
|
|
|
+// sanitizeAssetFilename mirrors WikiJS's upload normalization, which uses
|
|
|
+// the sanitize-filename package: lowercase, runs of whitespace/commas/
|
|
|
+// semicolons/hashes collapsed to a single underscore, illegal and control
|
|
|
+// characters dropped, Windows reserved names removed, trailing spaces and
|
|
|
+// dots removed, and a maximum length of 255 bytes (truncated without
|
|
|
+// splitting UTF-8 sequences).
|
|
|
+func sanitizeAssetFilename(name string) string {
|
|
|
+ const maxFilenameBytes = 255
|
|
|
+ name = filenameRuns.ReplaceAllString(strings.ToLower(strings.TrimSpace(name)), "_")
|
|
|
+
|
|
|
+ var b strings.Builder
|
|
|
+ for _, r := range name {
|
|
|
+ if r < 0x20 || r == 0x7f || (r >= 0x80 && r <= 0x9f) || strings.ContainsRune(`/?%*<>|":\`, r) {
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ b.WriteRune(r)
|
|
|
+ }
|
|
|
+ name = strings.Trim(b.String(), ". ")
|
|
|
+
|
|
|
+ // Windows reserved device names are removed entirely (case-insensitive,
|
|
|
+ // with or without extension), as in sanitize-filename.
|
|
|
+ base := name
|
|
|
+ if i := strings.IndexByte(base, '.'); i >= 0 {
|
|
|
+ base = base[:i]
|
|
|
+ }
|
|
|
+ switch strings.ToLower(base) {
|
|
|
+ case "con", "prn", "aux", "nul",
|
|
|
+ "com1", "com2", "com3", "com4", "com5", "com6", "com7", "com8", "com9",
|
|
|
+ "lpt1", "lpt2", "lpt3", "lpt4", "lpt5", "lpt6", "lpt7", "lpt8", "lpt9":
|
|
|
+ return ""
|
|
|
+ }
|
|
|
+
|
|
|
+ return truncateRunes(name, maxFilenameBytes)
|
|
|
+}
|
|
|
+
|
|
|
+// truncateRunes cuts s to at most max bytes without splitting a multi-byte
|
|
|
+// UTF-8 sequence, mirroring sanitize-filename's truncate-utf8-bytes helper.
|
|
|
+func truncateRunes(s string, max int) string {
|
|
|
+ for len(s) > max {
|
|
|
+ _, size := utf8.DecodeLastRuneInString(s)
|
|
|
+ s = s[:len(s)-size]
|
|
|
+ }
|
|
|
+ return s
|
|
|
}
|