client.go 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832
  1. package wikijs
  2. import (
  3. "bytes"
  4. "context"
  5. "encoding/json"
  6. "errors"
  7. "fmt"
  8. "io"
  9. "log/slog"
  10. "mime"
  11. "mime/multipart"
  12. "net/http"
  13. "net/textproto"
  14. "net/url"
  15. "path/filepath"
  16. "regexp"
  17. "slices"
  18. "strings"
  19. "sync"
  20. "time"
  21. "unicode/utf8"
  22. )
  23. // maxResponseBytes bounds the response body read from WikiJS.
  24. const maxResponseBytes = 32 << 20
  25. // filenameRuns matches the character runs WikiJS collapses to a single
  26. // underscore when sanitizing uploaded filenames.
  27. var filenameRuns = regexp.MustCompile(`[\s,;#]+`)
  28. // assetFolderSegmentPattern matches a single asset folder slug segment as
  29. // produced by WikiJS (lowercase slugs; validation is deliberately tolerant
  30. // of uppercase input because folder matching is case-insensitive).
  31. var assetFolderSegmentPattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]*$`)
  32. // DefaultMaxUploadBytes is the upload limit WikiJS enforces out of the box
  33. // (5 MB, server/app/data.yml). It is the fallback when the site config is
  34. // not readable with the configured API key.
  35. const DefaultMaxUploadBytes = 5 << 20
  36. // siteConfigQuery fetches the upload limit from the WikiJS site config.
  37. // The field requires the manage:system permission; callers must treat a
  38. // failure as "limit unknown" and fall back to DefaultMaxUploadBytes.
  39. const siteConfigQuery = `query SiteConfig {
  40. site {
  41. config {
  42. uploadMaxFileSize
  43. }
  44. }
  45. }`
  46. // Client talks to the WikiJS GraphQL API.
  47. type Client struct {
  48. baseURL string
  49. endpoint string
  50. apiKey string
  51. userAgent string
  52. http *http.Client
  53. logger *slog.Logger
  54. uploadLimitOnce sync.Once
  55. uploadLimitBytes int
  56. }
  57. // Option customizes a [Client].
  58. type Option func(*Client)
  59. // WithHTTPClient sets the HTTP client used for GraphQL requests.
  60. func WithHTTPClient(hc *http.Client) Option {
  61. return func(c *Client) {
  62. if hc != nil {
  63. c.http = hc
  64. }
  65. }
  66. }
  67. // WithLogger enables debug logging of GraphQL operations.
  68. func WithLogger(logger *slog.Logger) Option {
  69. return func(c *Client) {
  70. if logger != nil {
  71. c.logger = logger
  72. }
  73. }
  74. }
  75. // WithUserAgent overrides the User-Agent sent to WikiJS.
  76. func WithUserAgent(userAgent string) Option {
  77. return func(c *Client) {
  78. if userAgent != "" {
  79. c.userAgent = userAgent
  80. }
  81. }
  82. }
  83. // New creates a client for a WikiJS base URL. The URL may end with /graphql;
  84. // it is normalized so that the GraphQL endpoint is always addressed once.
  85. func New(rawBaseURL, apiKey string, opts ...Option) (*Client, error) {
  86. base := strings.TrimRight(strings.TrimSpace(rawBaseURL), "/")
  87. u, err := url.Parse(base)
  88. if err != nil {
  89. return nil, fmt.Errorf("parse WikiJS URL: %w", err)
  90. }
  91. if (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
  92. return nil, fmt.Errorf("WikiJS URL must be an absolute http(s) URL, got %q", rawBaseURL)
  93. }
  94. if u.RawQuery != "" || u.Fragment != "" {
  95. return nil, fmt.Errorf("WikiJS URL must not contain a query or fragment, got %q", rawBaseURL)
  96. }
  97. endpoint := base
  98. if strings.HasSuffix(base, "/graphql") {
  99. base = strings.TrimSuffix(base, "/graphql")
  100. } else {
  101. endpoint += "/graphql"
  102. }
  103. c := &Client{
  104. baseURL: base,
  105. endpoint: endpoint,
  106. apiKey: apiKey,
  107. userAgent: "wikijs-mcp",
  108. http: http.DefaultClient,
  109. logger: slog.New(slog.DiscardHandler),
  110. }
  111. for _, opt := range opts {
  112. opt(c)
  113. }
  114. return c, nil
  115. }
  116. // SearchPages runs a WikiJS full-text search. path and locale are optional.
  117. func (c *Client) SearchPages(ctx context.Context, query, path, locale string) (SearchResults, error) {
  118. vars := map[string]any{"query": query}
  119. putString(vars, "path", path)
  120. putString(vars, "locale", locale)
  121. var data struct {
  122. Pages *struct {
  123. Search *struct {
  124. Results []*SearchHit `json:"results"`
  125. Suggestions []string `json:"suggestions"`
  126. TotalHits int `json:"totalHits"`
  127. } `json:"search"`
  128. } `json:"pages"`
  129. }
  130. if err := c.do(ctx, "SearchPages", searchPagesQuery, vars, &data); err != nil {
  131. return SearchResults{}, err
  132. }
  133. out := SearchResults{Results: []SearchHit{}, Suggestions: []string{}}
  134. if data.Pages == nil || data.Pages.Search == nil {
  135. return out, nil
  136. }
  137. for _, hit := range data.Pages.Search.Results {
  138. if hit == nil {
  139. continue
  140. }
  141. hit.URL = c.pageURL(hit.Path, hit.Locale)
  142. out.Results = append(out.Results, *hit)
  143. }
  144. out.Suggestions = append(out.Suggestions, data.Pages.Search.Suggestions...)
  145. out.TotalHits = data.Pages.Search.TotalHits
  146. return out, nil
  147. }
  148. // PageByID returns a page by its numeric ID. It returns a nil page without an
  149. // error when WikiJS does not know the ID.
  150. func (c *Client) PageByID(ctx context.Context, id int) (*Page, error) {
  151. var data struct {
  152. Pages *struct {
  153. Single *Page `json:"single"`
  154. } `json:"pages"`
  155. }
  156. if err := c.do(ctx, "GetPageById", pageByIDQuery, map[string]any{"id": id}, &data); err != nil {
  157. return nil, err
  158. }
  159. if data.Pages == nil || data.Pages.Single == nil {
  160. return nil, nil
  161. }
  162. return c.decorate(data.Pages.Single), nil
  163. }
  164. // PageByPath returns a page by its path and locale. It returns a nil page
  165. // without an error when the page does not exist.
  166. func (c *Client) PageByPath(ctx context.Context, path, locale string) (*Page, error) {
  167. vars := map[string]any{"path": path, "locale": locale}
  168. var data struct {
  169. Pages *struct {
  170. Single *Page `json:"singleByPath"`
  171. } `json:"pages"`
  172. }
  173. if err := c.do(ctx, "GetPageByPath", pageByPathQuery, vars, &data); err != nil {
  174. return nil, err
  175. }
  176. if data.Pages == nil || data.Pages.Single == nil {
  177. return nil, nil
  178. }
  179. return c.decorate(data.Pages.Single), nil
  180. }
  181. // ListPages returns pages, optionally filtered by limit, locale, and tags.
  182. func (c *Client) ListPages(ctx context.Context, opts ListOptions) ([]PageListItem, error) {
  183. vars := map[string]any{}
  184. if opts.Limit != nil {
  185. vars["limit"] = *opts.Limit
  186. }
  187. putString(vars, "locale", opts.Locale)
  188. if len(opts.Tags) > 0 {
  189. vars["tags"] = opts.Tags
  190. }
  191. var data struct {
  192. Pages *struct {
  193. List []*PageListItem `json:"list"`
  194. } `json:"pages"`
  195. }
  196. if err := c.do(ctx, "GetAllPages", listPagesQuery, vars, &data); err != nil {
  197. return nil, err
  198. }
  199. out := []PageListItem{}
  200. if data.Pages == nil {
  201. return out, nil
  202. }
  203. for _, page := range data.Pages.List {
  204. if page == nil {
  205. continue
  206. }
  207. page.URL = c.pageURL(page.Path, page.Locale)
  208. if page.Tags == nil {
  209. page.Tags = []string{}
  210. }
  211. out = append(out, *page)
  212. }
  213. return out, nil
  214. }
  215. // CreatePage creates a page.
  216. func (c *Client) CreatePage(ctx context.Context, in CreatePageInput) (MutationResult, error) {
  217. vars := map[string]any{
  218. "title": in.Title,
  219. "content": in.Content,
  220. "description": in.Description,
  221. "editor": in.Editor,
  222. "isPublished": in.IsPublished,
  223. "isPrivate": in.IsPrivate,
  224. "locale": in.Locale,
  225. "tags": nonNilStrings(in.Tags),
  226. "path": in.Path,
  227. }
  228. var data struct {
  229. Pages *struct {
  230. Create *struct {
  231. ResponseResult *responseResult `json:"responseResult"`
  232. } `json:"create"`
  233. } `json:"pages"`
  234. }
  235. if err := c.do(ctx, "CreatePage", createPageMutation, vars, &data); err != nil {
  236. return MutationResult{}, err
  237. }
  238. if data.Pages == nil || data.Pages.Create == nil {
  239. return MutationResult{}, errors.New("wikijs CreatePage: missing create result")
  240. }
  241. return mutationResult(data.Pages.Create.ResponseResult)
  242. }
  243. // UpdatePage updates a page.
  244. func (c *Client) UpdatePage(ctx context.Context, in UpdatePageInput) (MutationResult, error) {
  245. vars := map[string]any{
  246. "id": in.ID,
  247. "title": in.Title,
  248. "content": in.Content,
  249. "description": in.Description,
  250. "editor": in.Editor,
  251. "isPublished": in.IsPublished,
  252. "isPrivate": in.IsPrivate,
  253. "locale": in.Locale,
  254. "tags": nonNilStrings(in.Tags),
  255. "path": in.Path,
  256. }
  257. var data struct {
  258. Pages *struct {
  259. Update *struct {
  260. ResponseResult *responseResult `json:"responseResult"`
  261. } `json:"update"`
  262. } `json:"pages"`
  263. }
  264. if err := c.do(ctx, "UpdatePage", updatePageMutation, vars, &data); err != nil {
  265. return MutationResult{}, err
  266. }
  267. if data.Pages == nil || data.Pages.Update == nil {
  268. return MutationResult{}, errors.New("wikijs UpdatePage: missing update result")
  269. }
  270. return mutationResult(data.Pages.Update.ResponseResult)
  271. }
  272. // ListAssetFolders returns the child folders of an asset folder. An empty
  273. // parentPath addresses the root folder. Folder paths are slash-separated slug
  274. // chains, for example "images/vacation".
  275. func (c *Client) ListAssetFolders(ctx context.Context, parentPath string) ([]AssetFolder, error) {
  276. parentID, chain, err := c.resolveAssetFolder(ctx, parentPath)
  277. if err != nil {
  278. return nil, err
  279. }
  280. folders, err := c.assetFolders(ctx, parentID)
  281. if err != nil {
  282. return nil, err
  283. }
  284. for i := range folders {
  285. folders[i].Path = strings.Join(append(slices.Clone(chain), folders[i].Slug), "/")
  286. }
  287. return folders, nil
  288. }
  289. // ListAssets returns the assets of an asset folder. An empty folderPath
  290. // addresses the root folder.
  291. func (c *Client) ListAssets(ctx context.Context, folderPath string) ([]Asset, error) {
  292. folderID, chain, err := c.resolveAssetFolder(ctx, folderPath)
  293. if err != nil {
  294. return nil, err
  295. }
  296. var data struct {
  297. Assets *struct {
  298. List []Asset `json:"list"`
  299. } `json:"assets"`
  300. }
  301. vars := map[string]any{"folderId": folderID, "kind": "ALL"}
  302. if err := c.do(ctx, "ListAssets", listAssetsQuery, vars, &data); err != nil {
  303. return nil, err
  304. }
  305. out := []Asset{}
  306. if data.Assets == nil {
  307. return out, nil
  308. }
  309. for i := range data.Assets.List {
  310. asset := &data.Assets.List[i]
  311. if asset.Folder == nil {
  312. asset.Folder = &AssetFolder{ID: folderID, Path: strings.Join(chain, "/")}
  313. } else {
  314. asset.Folder.Path = strings.Join(chain, "/")
  315. }
  316. asset.URL = c.assetURL(chain, asset.FileName)
  317. out = append(out, *asset)
  318. }
  319. return out, nil
  320. }
  321. // CreateAssetFolder creates a new asset folder inside parentPath (empty means
  322. // the root folder). slug must already be a valid WikiJS slug.
  323. func (c *Client) CreateAssetFolder(ctx context.Context, parentPath, slug, name string) (MutationResult, error) {
  324. parentID, _, err := c.resolveAssetFolder(ctx, parentPath)
  325. if err != nil {
  326. return MutationResult{}, err
  327. }
  328. var data struct {
  329. Assets *struct {
  330. CreateFolder *struct {
  331. ResponseResult *responseResult `json:"responseResult"`
  332. } `json:"createFolder"`
  333. } `json:"assets"`
  334. }
  335. vars := map[string]any{"parentFolderId": parentID, "slug": slug, "name": name}
  336. if err := c.do(ctx, "CreateAssetFolder", createAssetFolderMutation, vars, &data); err != nil {
  337. return MutationResult{}, err
  338. }
  339. if data.Assets == nil || data.Assets.CreateFolder == nil {
  340. return MutationResult{}, errors.New("wikijs CreateAssetFolder: missing createFolder result")
  341. }
  342. return mutationResult(data.Assets.CreateFolder.ResponseResult)
  343. }
  344. // uploadLimit returns the WikiJS upload limit in bytes, reading it once
  345. // from the site config (requires manage:system) and falling back to
  346. // DefaultMaxUploadBytes when the config is not readable. The value is
  347. // cached for the lifetime of the client; a failed lookup is not retried.
  348. func (c *Client) uploadLimit(ctx context.Context) int {
  349. c.uploadLimitOnce.Do(func() {
  350. var data struct {
  351. Site *struct {
  352. Config *struct {
  353. UploadMaxFileSize *int `json:"uploadMaxFileSize"`
  354. } `json:"config"`
  355. } `json:"site"`
  356. }
  357. if err := c.do(ctx, "SiteConfig", siteConfigQuery, nil, &data); err != nil {
  358. c.logger.Debug("site config not readable; using default upload limit",
  359. "limit", DefaultMaxUploadBytes, "error", err)
  360. c.uploadLimitBytes = DefaultMaxUploadBytes
  361. return
  362. }
  363. limit := 0
  364. if data.Site != nil && data.Site.Config != nil && data.Site.Config.UploadMaxFileSize != nil {
  365. limit = *data.Site.Config.UploadMaxFileSize
  366. }
  367. if limit <= 0 {
  368. limit = DefaultMaxUploadBytes
  369. }
  370. c.uploadLimitBytes = limit
  371. })
  372. return c.uploadLimitBytes
  373. }
  374. // UploadAsset uploads a single file to an asset folder (empty folderPath
  375. // means the root folder) using the /u upload endpoint. The filename appears
  376. // in the returned URL and in markdown references. mimeType is optional and
  377. // guessed from the file extension when empty.
  378. //
  379. // Uploads larger than the WikiJS upload limit are rejected before they are
  380. // sent. The limit is read once from the site config (requires manage:system)
  381. // and falls back to DefaultMaxUploadBytes; it can be overridden per tool
  382. // call only by raising the limit in WikiJS itself.
  383. func (c *Client) UploadAsset(ctx context.Context, folderPath, filename string, data []byte, mimeType string) (MutationResult, UploadedAsset, error) {
  384. clean := sanitizeAssetFilename(filename)
  385. if clean == "" || clean == "." {
  386. return MutationResult{}, UploadedAsset{}, fmt.Errorf("wikijs UploadAsset: invalid filename %q", filename)
  387. }
  388. if len(data) > c.uploadLimit(ctx) {
  389. return MutationResult{}, UploadedAsset{}, fmt.Errorf(
  390. "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)",
  391. len(data), c.uploadLimitBytes)
  392. }
  393. folderID, chain, err := c.resolveAssetFolder(ctx, folderPath)
  394. if err != nil {
  395. return MutationResult{}, UploadedAsset{}, err
  396. }
  397. if mimeType == "" {
  398. mimeType = "application/octet-stream"
  399. if guessed := mime.TypeByExtension(filepath.Ext(clean)); guessed != "" {
  400. mimeType = guessed
  401. }
  402. }
  403. metadata, err := json.Marshal(map[string]any{"folderId": folderID})
  404. if err != nil {
  405. return MutationResult{}, UploadedAsset{}, fmt.Errorf("wikijs UploadAsset: encode metadata: %w", err)
  406. }
  407. var body bytes.Buffer
  408. form := multipart.NewWriter(&body)
  409. metaPart, err := form.CreatePart(textproto.MIMEHeader{
  410. "Content-Disposition": {`form-data; name="mediaUpload"`},
  411. })
  412. if err != nil {
  413. return MutationResult{}, UploadedAsset{}, fmt.Errorf("wikijs UploadAsset: build form: %w", err)
  414. }
  415. if _, err := metaPart.Write(metadata); err != nil {
  416. return MutationResult{}, UploadedAsset{}, fmt.Errorf("wikijs UploadAsset: build form: %w", err)
  417. }
  418. filePart, err := form.CreatePart(textproto.MIMEHeader{
  419. "Content-Disposition": {fmt.Sprintf(`form-data; name="mediaUpload"; filename=%q`, clean)},
  420. "Content-Type": {mimeType},
  421. })
  422. if err != nil {
  423. return MutationResult{}, UploadedAsset{}, fmt.Errorf("wikijs UploadAsset: build form: %w", err)
  424. }
  425. if _, err := filePart.Write(data); err != nil {
  426. return MutationResult{}, UploadedAsset{}, fmt.Errorf("wikijs UploadAsset: build form: %w", err)
  427. }
  428. if err := form.Close(); err != nil {
  429. return MutationResult{}, UploadedAsset{}, fmt.Errorf("wikijs UploadAsset: build form: %w", err)
  430. }
  431. req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/u", &body)
  432. if err != nil {
  433. return MutationResult{}, UploadedAsset{}, fmt.Errorf("wikijs UploadAsset: build request: %w", err)
  434. }
  435. req.Header.Set("Content-Type", form.FormDataContentType())
  436. req.Header.Set("Authorization", "Bearer "+c.apiKey)
  437. req.Header.Set("User-Agent", c.userAgent)
  438. resp, err := c.http.Do(req)
  439. if err != nil {
  440. return MutationResult{}, UploadedAsset{}, fmt.Errorf("wikijs UploadAsset: %w", err)
  441. }
  442. defer resp.Body.Close()
  443. respBody, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes+1))
  444. if err != nil {
  445. return MutationResult{}, UploadedAsset{}, fmt.Errorf("wikijs UploadAsset: read response: %w", err)
  446. }
  447. if len(respBody) > maxResponseBytes {
  448. return MutationResult{}, UploadedAsset{}, &APIError{
  449. Operation: "UploadAsset",
  450. Status: resp.StatusCode,
  451. Err: fmt.Errorf("response exceeds %d bytes", maxResponseBytes),
  452. }
  453. }
  454. if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices || strings.TrimSpace(string(respBody)) != "ok" {
  455. var rr struct {
  456. Succeeded bool `json:"succeeded"`
  457. Message string `json:"message"`
  458. }
  459. if err := json.Unmarshal(respBody, &rr); err == nil && rr.Message != "" {
  460. return MutationResult{Succeeded: false, Message: rr.Message}, UploadedAsset{}, nil
  461. }
  462. return MutationResult{}, UploadedAsset{}, &APIError{
  463. Operation: "UploadAsset",
  464. Status: resp.StatusCode,
  465. Body: truncate(strings.TrimSpace(string(respBody)), 512),
  466. }
  467. }
  468. return MutationResult{Succeeded: true}, UploadedAsset{
  469. Filename: clean,
  470. URL: c.assetURL(chain, clean),
  471. }, nil
  472. }
  473. type responseResult struct {
  474. Succeeded bool `json:"succeeded"`
  475. ErrorCode int `json:"errorCode"`
  476. Slug string `json:"slug"`
  477. Message string `json:"message"`
  478. }
  479. func mutationResult(rr *responseResult) (MutationResult, error) {
  480. if rr == nil {
  481. return MutationResult{}, errors.New("wikijs mutation: missing responseResult")
  482. }
  483. return MutationResult{
  484. Succeeded: rr.Succeeded,
  485. Message: rr.Message,
  486. ErrorCode: rr.ErrorCode,
  487. Slug: rr.Slug,
  488. }, nil
  489. }
  490. type graphqlRequest struct {
  491. Query string `json:"query"`
  492. Variables map[string]any `json:"variables,omitempty"`
  493. }
  494. type graphqlError struct {
  495. Message string `json:"message"`
  496. }
  497. type graphqlResponse struct {
  498. Data json.RawMessage `json:"data"`
  499. Errors []graphqlError `json:"errors"`
  500. }
  501. // APIError describes a failed WikiJS request.
  502. type APIError struct {
  503. Operation string
  504. Status int
  505. Messages []string
  506. Body string
  507. Err error
  508. }
  509. func (e *APIError) Error() string {
  510. var b strings.Builder
  511. b.WriteString("wikijs")
  512. if e.Operation != "" {
  513. b.WriteByte(' ')
  514. b.WriteString(e.Operation)
  515. }
  516. b.WriteString(": ")
  517. switch {
  518. case len(e.Messages) > 0:
  519. b.WriteString(strings.Join(e.Messages, "; "))
  520. case e.Body != "":
  521. b.WriteString(e.Body)
  522. case e.Err != nil:
  523. b.WriteString(e.Err.Error())
  524. default:
  525. b.WriteString("request failed")
  526. }
  527. if e.Status != 0 {
  528. fmt.Fprintf(&b, " (HTTP %d)", e.Status)
  529. }
  530. return b.String()
  531. }
  532. func (e *APIError) Unwrap() error { return e.Err }
  533. func (c *Client) do(ctx context.Context, operation, query string, vars map[string]any, out any) error {
  534. payload, err := json.Marshal(graphqlRequest{Query: query, Variables: vars})
  535. if err != nil {
  536. return fmt.Errorf("wikijs %s: encode request: %w", operation, err)
  537. }
  538. req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint, bytes.NewReader(payload))
  539. if err != nil {
  540. return fmt.Errorf("wikijs %s: build request: %w", operation, err)
  541. }
  542. req.Header.Set("Content-Type", "application/json")
  543. req.Header.Set("Accept", "application/json")
  544. req.Header.Set("Authorization", "Bearer "+c.apiKey)
  545. req.Header.Set("User-Agent", c.userAgent)
  546. start := time.Now()
  547. resp, err := c.http.Do(req)
  548. if err != nil {
  549. return fmt.Errorf("wikijs %s: %w", operation, err)
  550. }
  551. defer resp.Body.Close()
  552. body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes+1))
  553. if err != nil {
  554. return fmt.Errorf("wikijs %s: read response: %w", operation, err)
  555. }
  556. if len(body) > maxResponseBytes {
  557. return fmt.Errorf("wikijs %s: response exceeds %d bytes", operation, maxResponseBytes)
  558. }
  559. if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
  560. return &APIError{
  561. Operation: operation,
  562. Status: resp.StatusCode,
  563. Body: truncate(strings.TrimSpace(string(body)), 512),
  564. }
  565. }
  566. var envelope graphqlResponse
  567. if err := json.Unmarshal(body, &envelope); err != nil {
  568. return &APIError{Operation: operation, Err: fmt.Errorf("decode response: %w", err)}
  569. }
  570. if len(envelope.Errors) > 0 {
  571. messages := make([]string, 0, len(envelope.Errors))
  572. for _, e := range envelope.Errors {
  573. messages = append(messages, e.Message)
  574. }
  575. return &APIError{Operation: operation, Messages: messages}
  576. }
  577. if len(envelope.Data) == 0 || string(envelope.Data) == "null" {
  578. return &APIError{Operation: operation, Messages: []string{"response contains no data"}}
  579. }
  580. if err := json.Unmarshal(envelope.Data, out); err != nil {
  581. return fmt.Errorf("wikijs %s: decode data: %w", operation, err)
  582. }
  583. c.logger.Debug("wikijs request",
  584. "operation", operation,
  585. "duration", time.Since(start),
  586. "status", resp.StatusCode,
  587. )
  588. return nil
  589. }
  590. func (c *Client) decorate(page *Page) *Page {
  591. page.URL = c.pageURL(page.Path, page.Locale)
  592. if page.Tags == nil {
  593. page.Tags = []Tag{}
  594. }
  595. return page
  596. }
  597. // pageURL builds the absolute URL of a page. Each path segment is escaped
  598. // separately so that separators stay intact and unicode filenames produce
  599. // valid links. The locale segment is only included for non-English pages.
  600. func (c *Client) pageURL(path, locale string) string {
  601. p := strings.TrimLeft(path, "/")
  602. segments := []string{}
  603. if locale != "" && locale != "en" {
  604. segments = append(segments, locale)
  605. }
  606. segments = append(segments, strings.Split(p, "/")...)
  607. return c.baseURL + "/" + escapePathSegments(segments)
  608. }
  609. // escapePathSegments escapes every segment with url.PathEscape and joins
  610. // them with "/", keeping the path structure intact.
  611. func escapePathSegments(segments []string) string {
  612. escaped := make([]string, len(segments))
  613. for i, seg := range segments {
  614. escaped[i] = url.PathEscape(seg)
  615. }
  616. return strings.Join(escaped, "/")
  617. }
  618. func putString(vars map[string]any, key, value string) {
  619. if value != "" {
  620. vars[key] = value
  621. }
  622. }
  623. func nonNilStrings(values []string) []string {
  624. if values == nil {
  625. return []string{}
  626. }
  627. return values
  628. }
  629. func truncate(s string, max int) string {
  630. if len(s) <= max {
  631. return s
  632. }
  633. return truncateRunes(s, max-3) + "..."
  634. }
  635. // assetFolders returns the direct child folders of the folder with the given
  636. // ID (0 is the root folder).
  637. func (c *Client) assetFolders(ctx context.Context, parentFolderID int) ([]AssetFolder, error) {
  638. var data struct {
  639. Assets *struct {
  640. Folders []AssetFolder `json:"folders"`
  641. } `json:"assets"`
  642. }
  643. if err := c.do(ctx, "ListAssetFolders", listAssetFoldersQuery, map[string]any{"parentFolderId": parentFolderID}, &data); err != nil {
  644. return nil, err
  645. }
  646. out := []AssetFolder{}
  647. if data.Assets == nil {
  648. return out, nil
  649. }
  650. return append(out, data.Assets.Folders...), nil
  651. }
  652. // resolveAssetFolder walks the asset folder tree from the root and returns the
  653. // ID and slug chain of the folder at folderPath. An empty path yields the root
  654. // folder (ID 0) with an empty chain.
  655. func (c *Client) resolveAssetFolder(ctx context.Context, folderPath string) (int, []string, error) {
  656. segments, err := assetFolderPathSegments(folderPath)
  657. if err != nil {
  658. return 0, nil, err
  659. }
  660. id := 0
  661. chain := []string{}
  662. for _, slug := range segments {
  663. children, err := c.assetFolders(ctx, id)
  664. if err != nil {
  665. return 0, nil, err
  666. }
  667. found := false
  668. for _, folder := range children {
  669. if strings.EqualFold(folder.Slug, slug) {
  670. id = folder.ID
  671. chain = append(chain, folder.Slug)
  672. found = true
  673. break
  674. }
  675. }
  676. if !found {
  677. return 0, nil, fmt.Errorf("asset folder %q not found", strings.Join(chain, "/")+"/"+slug)
  678. }
  679. }
  680. return id, chain, nil
  681. }
  682. // AssetFolderPathSegments splits a slash-separated asset folder path into
  683. // slug segments. An empty (or whitespace-only) path yields no segments and
  684. // addresses the root folder. It rejects empty segments and segments that are
  685. // not valid WikiJS slugs; resolvers treat matching case-insensitively.
  686. func AssetFolderPathSegments(folderPath string) ([]string, error) {
  687. segments, err := assetFolderPathSegments(folderPath)
  688. if err != nil {
  689. return nil, err
  690. }
  691. for _, segment := range segments {
  692. if !assetFolderSegmentPattern.MatchString(segment) {
  693. return nil, fmt.Errorf("invalid asset folder segment %q: use slugs like images or images-screens", segment)
  694. }
  695. }
  696. return segments, nil
  697. }
  698. func assetFolderPathSegments(folderPath string) ([]string, error) {
  699. if strings.TrimSpace(folderPath) == "" {
  700. return nil, nil
  701. }
  702. var segments []string
  703. for _, segment := range strings.Split(strings.Trim(folderPath, "/"), "/") {
  704. segment = strings.TrimSpace(segment)
  705. if segment == "" {
  706. return nil, fmt.Errorf("asset folder path %q must not contain an empty segment", folderPath)
  707. }
  708. segments = append(segments, segment)
  709. }
  710. return segments, nil
  711. }
  712. // assetURL builds the direct download URL of an asset. WikiJS serves assets at
  713. // <base>/<folder chain>/<filename>; root assets live directly under <base>.
  714. func (c *Client) assetURL(chain []string, filename string) string {
  715. parts := append(slices.Clone(chain), filename)
  716. return c.baseURL + "/" + strings.Join(parts, "/")
  717. }
  718. // sanitizeAssetFilename mirrors WikiJS's upload normalization, which uses
  719. // the sanitize-filename package: lowercase, runs of whitespace/commas/
  720. // semicolons/hashes collapsed to a single underscore, illegal and control
  721. // characters dropped, Windows reserved names removed, trailing spaces and
  722. // dots removed, and a maximum length of 255 bytes (truncated without
  723. // splitting UTF-8 sequences).
  724. func sanitizeAssetFilename(name string) string {
  725. const maxFilenameBytes = 255
  726. name = filenameRuns.ReplaceAllString(strings.ToLower(strings.TrimSpace(name)), "_")
  727. var b strings.Builder
  728. for _, r := range name {
  729. if r < 0x20 || r == 0x7f || (r >= 0x80 && r <= 0x9f) || strings.ContainsRune(`/?%*<>|":\`, r) {
  730. continue
  731. }
  732. b.WriteRune(r)
  733. }
  734. name = strings.Trim(b.String(), ". ")
  735. // Windows reserved device names are removed entirely (case-insensitive,
  736. // with or without extension), as in sanitize-filename.
  737. base := name
  738. if i := strings.IndexByte(base, '.'); i >= 0 {
  739. base = base[:i]
  740. }
  741. switch strings.ToLower(base) {
  742. case "con", "prn", "aux", "nul",
  743. "com1", "com2", "com3", "com4", "com5", "com6", "com7", "com8", "com9",
  744. "lpt1", "lpt2", "lpt3", "lpt4", "lpt5", "lpt6", "lpt7", "lpt8", "lpt9":
  745. return ""
  746. }
  747. return truncateRunes(name, maxFilenameBytes)
  748. }
  749. // truncateRunes cuts s to at most max bytes without splitting a multi-byte
  750. // UTF-8 sequence, mirroring sanitize-filename's truncate-utf8-bytes helper.
  751. func truncateRunes(s string, max int) string {
  752. for len(s) > max {
  753. _, size := utf8.DecodeLastRuneInString(s)
  754. s = s[:len(s)-size]
  755. }
  756. return s
  757. }