Free TinyMCE File Manager

The contract is simple. The backend jails everything to one directory on disk (in xCMS that is images/ from config; in the package it is uploads/). The frontend calls four actions: list, mkdir, upload, delete. After a click the modal returns { url, name, path }.

The package does not pull in jQuery, Bootstrap, or Font Awesome. TinyMCE stays where it already lives — local files or a CDN. The plugin attaches through external_plugins.

DEMO      |      DOWNLOAD

How looks file menager
How looks file menager

 

2. How the idea started (xCMS 4)

This did not start as a standalone product. It appeared while writing a new CMS version — xCMS 4, when the admin panel got a new theme, a new form engine, and TinyMCE in dark mode. The tree still held the old manager: Responsive Filemanager (trippo), around version 9.14.

RFM lived its own life. It had its own directory, its own config.php, its own upload scripts, and a dialog opened in an iframe. TinyMCE loaded the old responsivefilemanager plugin, and the handshake went over postMessage. In xCMS 4 that stack started to get in the way:

  • the iframe did not match the dark panel and Bootstrap 5,
  • session, CSRF token and CMS login were duplicated in RFM’s config,
  • the file root was already images/ from the config class — why a second path guard,
  • the same image pick was needed outside the editor: cover field, galleries, the “File manager” page.

In xCMS 4 the manager grew into the CMS. The endpoint became admin/media.php (JSON, login and token required). Logic moved to admin/libraries/class_media.php, rooted in images/. The UI is xcms-filemanager.js and a modal on window.XcmsFilemanager. TinyMCE loads the xcmsfilemanager plugin from init_tinymc.html, and init_media.html injects the API URL plus token as window.xcmsMedia.

Old RFM stayed in the tree, but current tinymce.init does not load it. We replaced iframe and postMessage with our own modal and JSON API. When the same set proved useful outside xCMS — as a drop-in for someone else’s TinyMCE form — the class and plugin left the CMS as a package. Someone downloads a zip and has it.

In xCMS 4 the code lives here:

Layer Path in xCMS 4
JSON endpoint admin/media.php
Class admin/libraries/class_media.php
UI JS / CSS admin/theme/assets/js/xcms-filemanager.js, …/css/xcms-filemanager.css
Gallery page admin/components/galleries/ (task=filemanager)
TinyMCE plugin admin/theme/plugins/tinymce/plugins/xcmsfilemanager/plugin.min.js
Editor init admin/theme/interface/init_tinymc.html + init_media.html
Script loading admin/interfaces/includes_form.php, template {{ init_tinymc }} from start.php

Old, unhooked RFM: admin/interfaces/filemanager/ and a copy of the responsivefilemanager plugin.

 

3. Advantages over the old stack

  1. One UI, three places. The TinyMCE toolbar, the image dialog (folder icon) and a form field all call the same openModal(). In RFM the editor lived in an iframe, and a cover field needed a separate path.
  2. JSON instead of an iframe. No postMessage, no foreign window, no z-index fight with TinyMCE dialogs. The modal sits in the page document, with its own high z-index.
  3. The CMS root, not a second config. The class gets a directory and a public URL. In xCMS that is config->dir_image. In the package it is uploads/. Jail via realpath.
  4. Images only, checked twice. Extension allowlist plus getimagesize() for MIME. PHP is disabled in the upload folder.
  5. No jQuery or Bootstrap in the package. The xCMS 4 original sat on jQuery and a BS5 modal. The package has its own overlay and SVG icons, so it drops into any admin panel.
  6. Session token. Every POST carries a session token. That is CSRF, not “security by hiding the URL”. You wire login with an auth callback.
  7. The same contract outside TinyMCE. data-filemanager-field writes a path into an input. data-xcms-filemanager mounts the grid on a gallery page.

This is not Dropbox and not a CDN. It does not resize images, it does not cut server-side thumbnails, it does not handle PDFs or video. On purpose: a CMS panel needs an image pick for an article, not a DAM.

 

4. The chain: from TinyMCE to disk

TinyMCE
  → plugin xcmsfilemanager  (button or file_picker_callback)
    → XcmsFilemanager.openModal({ onSelect })
      → POST api.php  (action=list|mkdir|upload|delete + token + path)
        → XcmsFileManager::handle()
          → uploads/  (in xCMS: images/)
            → onSelect({ url, name, path })
              → insertContent(<img>)  or TinyMCE callback  or input.value
 

In xCMS 4 the same chain ends at media.php and images/. The package only changes file names, not the idea.

How looks TinyMC
How looks TinyMC

 

5. File layout

In the drop-in zip (no demo sandbox):

File Role
src/FileManager.php Engine: list / mkdir / upload / delete, jail, MIME
api.php Thin JSON endpoint
config.php Root, URL, allowlist, max size
assets/js/filemanager.js Grid, modal, window.XcmsFilemanager
assets/css/filemanager.css Standalone tile and dialog look
tinymce/plugins/xcmsfilemanager/plugin.min.js Toolbar button + picker hook
example.php Editor + field + grid on one page

JS config must exist before the grid hits the API:

<script>
window.xcmsMedia = {
  url: 'api.php',
  token: '…',
  i18n: { title: 'File manager', upload: 'Upload' /* … */ }
};
</script>

The class can emit that itself: XcmsFileManager::configScript($apiUrl, $token).

 

6. JSON endpoint — actions

Everything is POST (FormData), with token, action, and optionally path, name, files[].

Success:

{ "ok": true, "data": { … } }

Error (HTTP 400 or 403):

{ "ok": false, "error": "Access denied" }

6.1. list

Returns folder contents. path is relative to root (empty string = the root). Folders first, then files, alphabetically. Hidden names (leading .) and protected directories are skipped.

{
  "ok": true,
  "data": {
    "path": "news",
    "parent": "",
    "crumbs": [
      { "name": "Images", "path": "" },
      { "name": "news", "path": "news" }
    ],
    "folders": [{ "name": "2026", "path": "news/2026" }],
    "items": [
      { "type": "dir",  "name": "2026", "path": "news/2026" },
      { "type": "file", "name": "cover.jpg", "path": "news/cover.jpg",
        "url": "/uploads/news/cover.jpg", "size": 48211 }
    ],
    "can_create": true
  }
}
 

6.2. mkdir

POST: path (parent) + name. The name goes through a slug (Polish characters → ASCII, the rest to [a-z0-9_-]). Returns the parent listing.

6.3. upload

POST: path + one or more files[]. Checks: is_uploaded_file, size, extension, MIME from getimagesize. Name clash → foto-1.jpg. The response is the listing plus an uploaded array.

6.4. delete

POST: path. A file — only if it is an allowed image. A folder — only if empty. Root and protected paths are left alone. Returns the parent listing.

Thin endpoint in the zip:

<?php
require_once __DIR__ . '/src/FileManager.php';
$config = require __DIR__ . '/config.php';
session_start();

$fm = new XcmsFileManager([
    'root'  => $config['root'],
    'url'   => $config['url'] ?? (XcmsFileManager::publicUrl('uploads') . '/'),
    'token' => XcmsFileManager::token(),
]);
$fm->handle();
 

 

7. PHP class XcmsFileManager

One class, no namespace, PHP 7.4+. The constructor takes an options array. The public API is handle() plus a few static helpers. Everything else is private — that is the disk engine.

7.1. Constructor options

Key Meaning
root Directory on disk (required)
url Public URL prefix returned with files
protected Reserved first path segments, e.g. ['galleries']
allowed Extensions: jpg, jpeg, png, gif, webp
mimes Extension → allowed MIME map
max_size Bytes, default 12 MB
token Expected CSRF; empty = do not check the token
auth Callable; false → HTTP 403
root_label Breadcrumb label (Images)
writable When false, list only
max_files / max_total File count and byte-sum limits (0 = off)
rate_uploads / rate_window Per-IP upload cap; used on the public demo

7.2. Public methods

handle(): bool

The only HTTP entry point. Sets JSON headers, checks auth and the token, sanitizes action to [a-z_], dispatches the four actions. Exceptions become { ok: false, error }.

Static. Starts a session if needed and returns a 32-character hex. The same token goes into HTML (window.xcmsMedia.token) and comes back on every POST.

public function handle(): bool
{
    header('Content-Type: application/json; charset=utf-8');

    if (!$this->isAuthorized()) {
        return $this->fail($this->messages['forbidden'], 403);
    }

    $action = isset($_REQUEST['action'])
        ? preg_replace('/[^a-z_]/', '', (string) $_REQUEST['action'])
        : 'list';

    if ($action === 'list') {
        return $this->ok($this->listing($this->requestPath()));
    }
    if ($action === 'mkdir') {
        return $this->ok($this->makeDir($this->requestPath(), $_POST['name'] ?? ''));
    }
    if ($action === 'upload') {
        return $this->ok($this->upload($this->requestPath()));
    }
    if ($action === 'delete') {
        return $this->ok($this->deletePath($this->requestPath()));
    }
    return $this->fail($this->messages['unknown_action']);
}
token(?string $sessionKey = 'xcms_fm_token'): string

Builds a public path from dirname($_SERVER['SCRIPT_NAME']). The package then works in a subdirectory without a hardcoded URL.

configScript(string $apiUrl, string $token, array $i18n = []): string

Returns a <script>window.xcmsMedia = …</script> tag. The i18n argument overrides the default UI strings.

defaultI18n(): array

UI dictionary: modal title, “New folder”, “Upload”, empty folder, delete confirm, and so on.

clientIp(): string / ipAllowed(array $allow, ?string $ip = null): bool

Reads REMOTE_ADDR and an optional allowlist. An empty list lets everyone in. It does not trust X-Forwarded-For (easy to spoof).

boot(array $config): array

Helper for the public demo. When sandbox => true, each visitor gets uploads/sandbox/{id}/ and old sandboxes are deleted after a TTL. The drop-in zip api.php does not call this.

sandboxId(): string / gcSandbox(…): void

Session id (16 hex) and cleanup of sandbox directories older than the TTL; if a global cap is exceeded, oldest first.

7.3. Private engine (the part that actually does the work)

listing($path)

scandir, skips dots and protected, builds items / crumbs / folders. Sort: directories, then files, strcasecmp.

makeDir($path, $name) / upload($path) / deletePath($path)

Create, upload, delete — described with the JSON actions. After a successful upload the listing gets an uploaded array.

normalize($path)

The path-safety key. Backslashes to slashes, collapse //, trim /, reject .. and protected segments. Bad input falls back to root (empty string), not a 500.

private function normalize(string $path): string
{
    $path = str_replace('\\', '/', $path);
    $path = trim((string) preg_replace('#/+#', '/', $path), '/');
    if ($path === '.' || $path === '..' || strpos($path, '..') !== false) {
        return '';
    }
    if ($this->isProtected($path)) {
        return '';
    }
    return $path;
}
 
absolute($path, $mustExist = false)

Builds a candidate, runs realpath, checks that the result still starts with root. That is the jail: a symlink or ../ cannot leave the images directory.

$real = realpath($candidate);
if ($real !== $root && strpos($real, $root . DIRECTORY_SEPARATOR) !== 0) {
    return false;
}
validMime($tmp, $ext)

Does not trust $_FILES['type']. Uses getimagesize() and compares MIME to the map for that extension. A .jpg with PHP insides is rejected.

slug / safeFolder / safeFile / uniqueName

Transliteration of Polish characters, then iconv ASCII, then [a-z0-9_-]. uniqueName appends -1, -2, … instead of overwriting.

ok($data) / fail($message, $code = 400)

The only JSON serialization. Unicode and slashes are unescaped so image URLs stay readable.

Other helpers

  • requestPath()path from POST, else GET, then normalize
  • uploadedFiles() — flattens $_FILES['files'] (one file or an array)
  • crumbs($path) / childFolders($path) / parentPath($path) — navigation for the UI
  • isProtected / isAllowedFile / isAllowedExt / extension
  • assertQuota / usage — demo limits
  • assertIpRate — JSON file of timestamps per IP hash
  • dirSize / rrmdir — sandbox cleanup (does not follow symlinks)

 

8. JavaScript layer — window.XcmsFilemanager

assets/js/filemanager.js is an IIFE. It reads window.xcmsMedia at request time (script order does not break the call). Public API:

Method What it does
mount(el, options) Puts the grid in an element. onSelect, onUpload, path
openModal(options) Overlay, host; onSelect closes the modal
request(action, data, files) Raw POST; throws Error with the error text

Opening from TinyMCE

window.XcmsFilemanager.openModal({
  onSelect: function (item) {
    callback(item.url, { alt: item.name || '' });
  }
});
 

On DOM ready the script:

  • mounts every [data-xcms-filemanager] (skips data-gallery-fm, left for xCMS),
  • on click of [data-filemanager-field="cover"] opens the modal, writes item.path to #cover and sets src on [data-filemanager-preview="cover"].

The grid: breadcrumbs, a jump-to-subdir select, up, new folder (an inline field in the toolbar, not window.prompt — that died under the TinyMCE modal), upload, drag-and-drop, double-click a folder, click a file = pick, the × is an in-bar confirm, not native confirm.

Request

function request(action, data, files) {
  var cfg = mediaConfig();
  var fd = new FormData();
  fd.append('token', cfg.token || '');
  fd.append('action', action);
  // … data fields, files[] …
  return fetch(cfg.url || 'api.php', {
    method: 'POST',
    body: fd,
    credentials: 'same-origin'
  }).then(function (res) {
    return res.text().then(function (text) {
      var json = JSON.parse(text);
      if (!json || !json.ok) {
        throw new Error((json && json.error) || 'Error');
      }
      return json.data;
    });
  });
}
 

DOM events: xcms-filemanager:select and xcms-filemanager:upload (bubbling, detail = item or array of uploaded files).

 

9. TinyMCE plugin xcmsfilemanager

One file, works with TinyMCE 5/6/7 (the PluginManager build, not ESM). It registers a gallery-icon button and — when it can — sets file_picker_callback. Init should still pass the picker in options, because TinyMCE 6 registers that option in tinymce.init

 
tinymce.PluginManager.add('xcmsfilemanager', function (editor) {
  function pick(done) {
    if (!window.XcmsFilemanager) {
      return;
    }
    window.XcmsFilemanager.openModal({
      onSelect: function (item) {
        done(item.url, { alt: item.name || '', title: item.name || '' });
      }
    });
  }

  editor.ui.registry.addButton('xcmsfilemanager', {
    icon: 'gallery',
    tooltip: 'File manager',
    onAction: function () {
      pick(function (url, meta) {
        editor.insertContent(
          '<img src="' + editor.dom.encode(url) +
          '" alt="' + editor.dom.encode(meta.alt || '') + '" />'
        );
      });
    }
  });
});

Two entries in the editor:

  • toolbar button — inserts <img> immediately,
  • folder icon in the “Insert image” dialog — returns a URL to TinyMCE’s callback (alt / text).

Init in xCMS 4 (short) and in the package is the same idea

tinymce.init({
  selector: '.editor',
  plugins: 'image link lists',
  external_plugins: {
    xcmsfilemanager: '/path/tinymce/plugins/xcmsfilemanager/plugin.min.js'
  },
  toolbar: 'undo redo | link image xcmsfilemanager | bullist numlist',
  file_picker_types: 'image',
  automatic_uploads: false,
  file_picker_callback: function (callback) {
    window.XcmsFilemanager.openModal({
      onSelect: function (item) {
        callback(item.url, { alt: item.name || '' });
      }
    });
  }
});
 

automatic_uploads: false is intentional: we do not push binaries through TinyMCE, only a URL already on disk.

 

10. Three entries into the same UI

10.1. TinyMCE

Described above. In xCMS 4 includes_form.php loads the scripts on add/edit, and start.php injects {{ init_tinymc }}.

10.2. Form field

<input type="text" id="cover" name="cover">
<button type="button" data-filemanager-field="cover">Choose</button>
<img data-filemanager-preview="cover" hidden>
 

The database gets a relative path (news/cover.jpg); the preview gets the full URL. Same split as in xCMS: public URL in HTML content, path relative to images/ in the record field.

10.3. Standalone grid

<div data-xcms-filemanager></div>
 

In xCMS 4 the Galleries → File manager page is exactly that: view filemanager.html with data-xcms-filemanager. Clicking a file does not insert into the editor — it is a disk browser.

 

11. Security

A file manager in an admin panel is always an attack surface. The rules here are narrow and strict:

  • Jail. normalize + realpath. ../ and a symlink outside root = root or an error.
  • Images only. Allowlisted extension and MIME from getimagesize, not from the browser header.
  • Slugged names. No spaces, no raw UTF-8 in the on-disk filename, no overwrites.
  • Session token. CSRF. That is not login — login is auth or the CMS session.
  • No PHP in uploads. uploads/.htaccess turns the engine off and blocks *.php.
  • Careful deletes. A non-empty folder stays. The root cannot be deleted.

In xCMS 4 $sessions->ifLogin() and hasValidToken() still sit in front of the class. In the zip you hook that yourself

$fm = new XcmsFileManager([
    'root'  => '/var/www/html/images',
    'url'   => 'https://example.com/images/',
    'token' => XcmsFileManager::token(),
    'auth'  => static function () {
        return !empty($_SESSION['user_id']);
    },
]);
 

Without auth on a public URL, anyone who hits api.php can upload images. The zip is a drop-in for an admin panel, not a public file host.

 

12. Drop-in install

  1. Upload the zip contents to PHP 7.4+.
  2. Make uploads/ writable (or change root / url in config.php).
  3. Include the CSS, configScript, TinyMCE, filemanager.js, and the plugin.
  4. Add xcmsfilemanager to the toolbar and set file_picker_callback.
  5. Wire auth to your existing login.

Drop-in config.php:

return [
    'root'       => __DIR__ . '/uploads',
    'url'        => null,          // null = auto from the script directory
    'protected'  => [],            // e.g. ['galleries']
    'allowed'    => ['jpg', 'jpeg', 'png', 'gif', 'webp'],
    'max_size'   => 12 * 1024 * 1024,
    'root_label' => 'Images',
];

Requirements: PHP with sessions and getimagesize, a browser with fetch and FormData, TinyMCE 5/6/7 in the classic build.

 

 

13. Public demo vs the zip

The live demo page is in English and is deliberately not an open dump. Each tester gets a private session folder (sandbox), files expire after a few hours, and there are caps on file count, total megabytes, and uploads per IP. That is only a shield for the public sample.

The downloadable zip does not turn those shields on: root is plain uploads/, api.php does not call boot(), there is no TTL and no per-IP cap. There you attach your own login — the same way xCMS 4 puts the admin session in front of the class.

 

14. Summary

The manager exists because xCMS 4 did not need another trippo iframe. It needed an image pick that matched the rest of the panel: the same token, the same images/ directory, the same UI in the editor and outside it. Responsive Filemanager stayed in the tree as a fossil. The production path is a modal, JSON, and the xcmsfilemanager plugin.

The package is that core lifted out of the CMS. The class guards the disk, JS guards the grid, TinyMCE only calls openModal and inserts <img>. Someone downloads the zip, hooks login, and gets the same chain that media.php runs in xCMS 4.