package wikijs
import (
"bytes"
"context"
"encoding/json"
"errors"
"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
endpoint string
apiKey string
userAgent string
http *http.Client
logger *slog.Logger
uploadLimitOnce sync.Once
uploadLimitBytes int
}
// Option customizes a [Client].
type Option func(*Client)
// WithHTTPClient sets the HTTP client used for GraphQL requests.
func WithHTTPClient(hc *http.Client) Option {
return func(c *Client) {
if hc != nil {
c.http = hc
}
}
}
// WithLogger enables debug logging of GraphQL operations.
func WithLogger(logger *slog.Logger) Option {
return func(c *Client) {
if logger != nil {
c.logger = logger
}
}
}
// WithUserAgent overrides the User-Agent sent to WikiJS.
func WithUserAgent(userAgent string) Option {
return func(c *Client) {
if userAgent != "" {
c.userAgent = userAgent
}
}
}
// New creates a client for a WikiJS base URL. The URL may end with /graphql;
// it is normalized so that the GraphQL endpoint is always addressed once.
func New(rawBaseURL, apiKey string, opts ...Option) (*Client, error) {
base := strings.TrimRight(strings.TrimSpace(rawBaseURL), "/")
u, err := url.Parse(base)
if err != nil {
return nil, fmt.Errorf("parse WikiJS URL: %w", err)
}
if (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return nil, fmt.Errorf("WikiJS URL must be an absolute http(s) URL, got %q", rawBaseURL)
}
if u.RawQuery != "" || u.Fragment != "" {
return nil, fmt.Errorf("WikiJS URL must not contain a query or fragment, got %q", rawBaseURL)
}
endpoint := base
if strings.HasSuffix(base, "/graphql") {
base = strings.TrimSuffix(base, "/graphql")
} else {
endpoint += "/graphql"
}
c := &Client{
baseURL: base,
endpoint: endpoint,
apiKey: apiKey,
userAgent: "wikijs-mcp",
http: http.DefaultClient,
logger: slog.New(slog.DiscardHandler),
}
for _, opt := range opts {
opt(c)
}
return c, nil
}
// SearchPages runs a WikiJS full-text search. path and locale are optional.
func (c *Client) SearchPages(ctx context.Context, query, path, locale string) (SearchResults, error) {
vars := map[string]any{"query": query}
putString(vars, "path", path)
putString(vars, "locale", locale)
var data struct {
Pages *struct {
Search *struct {
Results []*SearchHit `json:"results"`
Suggestions []string `json:"suggestions"`
TotalHits int `json:"totalHits"`
} `json:"search"`
} `json:"pages"`
}
if err := c.do(ctx, "SearchPages", searchPagesQuery, vars, &data); err != nil {
return SearchResults{}, err
}
out := SearchResults{Results: []SearchHit{}, Suggestions: []string{}}
if data.Pages == nil || data.Pages.Search == nil {
return out, nil
}
for _, hit := range data.Pages.Search.Results {
if hit == nil {
continue
}
hit.URL = c.pageURL(hit.Path, hit.Locale)
out.Results = append(out.Results, *hit)
}
out.Suggestions = append(out.Suggestions, data.Pages.Search.Suggestions...)
out.TotalHits = data.Pages.Search.TotalHits
return out, nil
}
// PageByID returns a page by its numeric ID. It returns a nil page without an
// error when WikiJS does not know the ID.
func (c *Client) PageByID(ctx context.Context, id int) (*Page, error) {
var data struct {
Pages *struct {
Single *Page `json:"single"`
} `json:"pages"`
}
if err := c.do(ctx, "GetPageById", pageByIDQuery, map[string]any{"id": id}, &data); err != nil {
return nil, err
}
if data.Pages == nil || data.Pages.Single == nil {
return nil, nil
}
return c.decorate(data.Pages.Single), nil
}
// PageByPath returns a page by its path and locale. It returns a nil page
// without an error when the page does not exist.
func (c *Client) PageByPath(ctx context.Context, path, locale string) (*Page, error) {
vars := map[string]any{"path": path, "locale": locale}
var data struct {
Pages *struct {
Single *Page `json:"singleByPath"`
} `json:"pages"`
}
if err := c.do(ctx, "GetPageByPath", pageByPathQuery, vars, &data); err != nil {
return nil, err
}
if data.Pages == nil || data.Pages.Single == nil {
return nil, nil
}
return c.decorate(data.Pages.Single), nil
}
// ListPages returns pages, optionally filtered by limit, locale, and tags.
func (c *Client) ListPages(ctx context.Context, opts ListOptions) ([]PageListItem, error) {
vars := map[string]any{}
if opts.Limit != nil {
vars["limit"] = *opts.Limit
}
putString(vars, "locale", opts.Locale)
if len(opts.Tags) > 0 {
vars["tags"] = opts.Tags
}
var data struct {
Pages *struct {
List []*PageListItem `json:"list"`
} `json:"pages"`
}
if err := c.do(ctx, "GetAllPages", listPagesQuery, vars, &data); err != nil {
return nil, err
}
out := []PageListItem{}
if data.Pages == nil {
return out, nil
}
for _, page := range data.Pages.List {
if page == nil {
continue
}
page.URL = c.pageURL(page.Path, page.Locale)
if page.Tags == nil {
page.Tags = []string{}
}
out = append(out, *page)
}
return out, nil
}
// CreatePage creates a page.
func (c *Client) CreatePage(ctx context.Context, in CreatePageInput) (MutationResult, error) {
vars := map[string]any{
"title": in.Title,
"content": in.Content,
"description": in.Description,
"editor": in.Editor,
"isPublished": in.IsPublished,
"isPrivate": in.IsPrivate,
"locale": in.Locale,
"tags": nonNilStrings(in.Tags),
"path": in.Path,
}
var data struct {
Pages *struct {
Create *struct {
ResponseResult *responseResult `json:"responseResult"`
} `json:"create"`
} `json:"pages"`
}
if err := c.do(ctx, "CreatePage", createPageMutation, vars, &data); err != nil {
return MutationResult{}, err
}
if data.Pages == nil || data.Pages.Create == nil {
return MutationResult{}, errors.New("wikijs CreatePage: missing create result")
}
return mutationResult(data.Pages.Create.ResponseResult)
}
// UpdatePage updates a page.
func (c *Client) UpdatePage(ctx context.Context, in UpdatePageInput) (MutationResult, error) {
vars := map[string]any{
"id": in.ID,
"title": in.Title,
"content": in.Content,
"description": in.Description,
"editor": in.Editor,
"isPublished": in.IsPublished,
"isPrivate": in.IsPrivate,
"locale": in.Locale,
"tags": nonNilStrings(in.Tags),
"path": in.Path,
}
var data struct {
Pages *struct {
Update *struct {
ResponseResult *responseResult `json:"responseResult"`
} `json:"update"`
} `json:"pages"`
}
if err := c.do(ctx, "UpdatePage", updatePageMutation, vars, &data); err != nil {
return MutationResult{}, err
}
if data.Pages == nil || data.Pages.Update == nil {
return MutationResult{}, errors.New("wikijs UpdatePage: missing update result")
}
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"`
Slug string `json:"slug"`
Message string `json:"message"`
}
func mutationResult(rr *responseResult) (MutationResult, error) {
if rr == nil {
return MutationResult{}, errors.New("wikijs mutation: missing responseResult")
}
return MutationResult{
Succeeded: rr.Succeeded,
Message: rr.Message,
ErrorCode: rr.ErrorCode,
Slug: rr.Slug,
}, nil
}
type graphqlRequest struct {
Query string `json:"query"`
Variables map[string]any `json:"variables,omitempty"`
}
type graphqlError struct {
Message string `json:"message"`
}
type graphqlResponse struct {
Data json.RawMessage `json:"data"`
Errors []graphqlError `json:"errors"`
}
// APIError describes a failed WikiJS request.
type APIError struct {
Operation string
Status int
Messages []string
Body string
Err error
}
func (e *APIError) Error() string {
var b strings.Builder
b.WriteString("wikijs")
if e.Operation != "" {
b.WriteByte(' ')
b.WriteString(e.Operation)
}
b.WriteString(": ")
switch {
case len(e.Messages) > 0:
b.WriteString(strings.Join(e.Messages, "; "))
case e.Body != "":
b.WriteString(e.Body)
case e.Err != nil:
b.WriteString(e.Err.Error())
default:
b.WriteString("request failed")
}
if e.Status != 0 {
fmt.Fprintf(&b, " (HTTP %d)", e.Status)
}
return b.String()
}
func (e *APIError) Unwrap() error { return e.Err }
func (c *Client) do(ctx context.Context, operation, query string, vars map[string]any, out any) error {
payload, err := json.Marshal(graphqlRequest{Query: query, Variables: vars})
if err != nil {
return fmt.Errorf("wikijs %s: encode request: %w", operation, err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint, bytes.NewReader(payload))
if err != nil {
return fmt.Errorf("wikijs %s: build request: %w", operation, err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", "Bearer "+c.apiKey)
req.Header.Set("User-Agent", c.userAgent)
start := time.Now()
resp, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("wikijs %s: %w", operation, err)
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes+1))
if err != nil {
return fmt.Errorf("wikijs %s: read response: %w", operation, err)
}
if len(body) > maxResponseBytes {
return fmt.Errorf("wikijs %s: response exceeds %d bytes", operation, maxResponseBytes)
}
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return &APIError{
Operation: operation,
Status: resp.StatusCode,
Body: truncate(strings.TrimSpace(string(body)), 512),
}
}
var envelope graphqlResponse
if err := json.Unmarshal(body, &envelope); err != nil {
return &APIError{Operation: operation, Err: fmt.Errorf("decode response: %w", err)}
}
if len(envelope.Errors) > 0 {
messages := make([]string, 0, len(envelope.Errors))
for _, e := range envelope.Errors {
messages = append(messages, e.Message)
}
return &APIError{Operation: operation, Messages: messages}
}
if len(envelope.Data) == 0 || string(envelope.Data) == "null" {
return &APIError{Operation: operation, Messages: []string{"response contains no data"}}
}
if err := json.Unmarshal(envelope.Data, out); err != nil {
return fmt.Errorf("wikijs %s: decode data: %w", operation, err)
}
c.logger.Debug("wikijs request",
"operation", operation,
"duration", time.Since(start),
"status", resp.StatusCode,
)
return nil
}
func (c *Client) decorate(page *Page) *Page {
page.URL = c.pageURL(page.Path, page.Locale)
if page.Tags == nil {
page.Tags = []Tag{}
}
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" {
segments = append(segments, locale)
}
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) {
if value != "" {
vars[key] = value
}
}
func nonNilStrings(values []string) []string {
if values == nil {
return []string{}
}
return values
}
func truncate(s string, max int) string {
if len(s) <= max {
return s
}
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
// //; root assets live directly under .
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
}