跳到主要内容

Automatically sanitize file names on upload

This example shows how to enforce consistent and safe file naming rules in ONLYOFFICE DocSpace by automatically renaming files right after upload.

When DocSpace sends a webhook to your backend, the handler:

  • reads event.trigger and payload,
  • accepts only upload triggers (file.uploaded, file.created),
  • extracts payload.id (fileId) and payload.title,
  • generates a normalized title,
  • renames the file via the DocSpace API.

Before you start

  1. Replace https://yourportal.onlyoffice.com and YOUR_API_KEY with your actual DocSpace portal URL and API key. Ensure you have the necessary data and permissions to perform these operations.
  2. Before you can make requests to the API, you need to authenticate. Check out the Personal access tokens page to learn how to obtain and use access tokens.
  3. Expose your webhook endpoint over HTTPS in production (terminate TLS in your app or behind a reverse proxy/load balancer, and reject non-HTTPS requests). For local testing, use a secure tunnel that provides an HTTPS URL.

Webhook configuration

This example relies on DocSpace webhooks. To register and manage webhooks on your portal, see Webhooks and the Help Center instructions: https://helpcenter.onlyoffice.com/administration/docspace-webhooks.aspx.

  • Register a webhook and enable the triggers used in this example.
  • Set the payload URL to your backend endpoint.
  • Generate/set a secret key and store it on the backend as WEBHOOK_SECRET. DocSpace sends the signature in the x-docspace-signature-256 header so you can validate the request against the raw body.

Timeouts and retries

DocSpace retries failed webhook deliveries. According to the webhook docs:

  • Up to 5 attempts are made, with exponential backoff (2^attempt seconds).
  • Any successful status (any 2xx) stops retries.
  • If your endpoint returns 410 Gone, the webhook is removed from the portal.

Keep the handler fast: validate the request, enqueue background work if needed, and return quickly. Make processing idempotent because the same event may be delivered more than once.

Full example
import express from "express";
import crypto from "crypto";

// Config
const API_HOST = process.env.DOCSPACE_API_HOST; // Set DOCSPACE_API_HOST in env (recommended). For quick tests you can temporarily paste your portal URL here.
const API_KEY = process.env.DOCSPACE_API_KEY; // Set DOCSPACE_API_KEY in env (recommended). For quick tests you can temporarily paste token here.
const WEBHOOK_SECRET = process.env.DOCSPACE_WEBHOOK_SECRET; // optional. If empty, the signature check is skipped (dev only).

const ALLOWED_TRIGGERS = new Set<string>(["file.uploaded", "file.created"]);

const HEADERS: Record<string, string> = {
Accept: "application/json",
Authorization: `Bearer ${API_KEY}`,
"Content-Type": "application/json",
};

// Step 1: Webhook validation (optional)
function verifySignature(rawBody: Buffer, signatureHeader: string | undefined) {
if (!WEBHOOK_SECRET) return true; // signature is optional

if (!signatureHeader || !signatureHeader.startsWith("sha256=")) return false;

const received = signatureHeader.split("=", 2)[1]?.trim()?.toLowerCase();
if (!received) return false;

const expected = crypto
.createHmac("sha256", WEBHOOK_SECRET)
.update(rawBody)
.digest("hex")
.toLowerCase();

if (received.length !== expected.length) return false;
return crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected));
}

// DocSpace request helper
async function docspaceRequest(path: string, method: string = "GET", body: any = null) {
const url = `${API_HOST}${path}`;

try {
const res = await fetch(url, {
method,
headers: HEADERS,
body: body ? JSON.stringify(body) : undefined,
});

if (!res.ok) {
const text = await res.text();
console.error(`[ERROR] ${method} ${url} -> ${res.status} ${text}`);
return null;
}

return res.json();
} catch (err: any) {
console.error(`[ERROR] Request error: ${err?.message || err}`);
return null;
}
}

// Step 2: Read trigger + payload (your real webhook shape)
function extractTriggerAndPayload(body: any) {
const trigger = String(body?.event?.trigger || "").trim();
const payload = body?.payload && typeof body.payload === "object" ? body.payload : null;
return { trigger, payload };
}

function extractFileId(payload: any) {
const raw = payload?.id ?? null;
const n = Number(raw);
return Number.isFinite(n) ? n : null;
}

function extractTitle(payload: any) {
const title = payload?.title ?? null;
return title ? String(title) : "";
}

// Optional: load title from API if webhook payload has no title
async function getFileTitle(fileId: number) {
const data = await docspaceRequest(`/api/2.0/files/file/${fileId}`, "GET");
const node = data && typeof data === "object" ? (data.response ?? null) : null;
const title = node && typeof node === "object" ? (node as any).title : null;
return title ? String(title) : null;
}

// Step 3: Build a safe title
function sanitizeFileName(originalName: string, maxLength: number = 100) {
const name = String(originalName || "").trim();

const lastDot = name.lastIndexOf(".");
const hasExt = lastDot > 0 && lastDot < name.length - 1;

const baseRaw = hasExt ? name.slice(0, lastDot) : name;
const extRaw = hasExt ? name.slice(lastDot) : "";

let base = baseRaw.replace(/ /g, "_");
base = base.replace(/[^A-Za-z0-9_\.-]+/g, "_");
base = base.replace(/_+/g, "_");
base = base.replace(/^[_\.-]+|[_\.-]+$/g, "");

if (!base) base = "file";

if ((base + extRaw).length > maxLength) {
const allowedBaseLen = Math.max(maxLength - extRaw.length, 1);
base = base.slice(0, allowedBaseLen);
}

return base + extRaw;
}

// Rename via API
async function renameFile(fileId: number, newTitle: string) {
const payload = { title: newTitle };
const data = await docspaceRequest(`/api/2.0/files/file/${fileId}`, "PUT", payload);
return Boolean(data);
}

// Main flow
async function handleWebhook(body: any) {
const { trigger, payload } = extractTriggerAndPayload(body);

if (!payload) {
console.log("[INFO] No payload object. Skipping.");
return;
}

if (!ALLOWED_TRIGGERS.has(trigger)) {
console.log(`[INFO] Trigger '${trigger}' is not handled. Skipping.`);
return;
}

const fileId = extractFileId(payload);
if (fileId == null) {
console.log("[INFO] No numeric payload.id (fileId). Skipping.");
return;
}

let title = extractTitle(payload);
if (!title) {
const apiTitle = await getFileTitle(fileId);
title = apiTitle || "";
}

if (!title) {
console.warn(`[WARN] Cannot determine title for fileId=${fileId}. Skipping.`);
return;
}

const safeTitle = sanitizeFileName(title, 100);

if (safeTitle === title) {
console.log(`[INFO] Title already OK. fileId=${fileId}, title="${title}"`);
return;
}

const ok = await renameFile(fileId, safeTitle);
if (!ok) {
console.error(`[ERROR] Failed to rename fileId=${fileId}.`);
return;
}

console.log(`[INFO] Renamed fileId=${fileId}: "${title}" -> "${safeTitle}"`);
}

// Express webhook receiver
const app = express();

// keep raw body for optional signature validation
app.use(
express.json({
limit: "2mb",
verify: (req: any, _res, buf) => {
req.rawBody = buf;
},
})
);

app.head("/docspace/sanitize-upload", (_req, res) => res.status(200).send(""));
app.get("/docspace/sanitize-upload", (_req, res) => res.status(200).json({ status: "ok" }));

app.post("/docspace/sanitize-upload", async (req: any, res) => {
const rawBody: Buffer = req.rawBody || Buffer.from(JSON.stringify(req.body || {}));
const signature = req.headers["x-docspace-signature-256"] as string | undefined;

if (!verifySignature(rawBody, signature)) {
console.warn("[WARN] Invalid webhook signature.");
return res.status(401).send("Invalid signature");
}

try {
await handleWebhook(req.body);
} catch (err: any) {
console.error("[ERROR]", err?.message || err);
return res.status(500).send("Internal error");
}

return res.status(200).json({ status: "ok" });
});

app.listen(3000, () => {
console.log("Webhook listener: http://localhost:3000/docspace/sanitize-upload");
});

Step 1: Read the webhook event and file data

The handler receives a DocSpace webhook with:

  • event.trigger — event name (for example file.uploaded)
  • payload — file object with id and usually title

The script processes only file.uploaded and file.created, and extracts:

  • fileId from payload.id
  • title from payload.title

If the title is missing, the script loads it via GET /api/2.0/files/file/:fileId

function verifySignature(rawBody: Buffer, signatureHeader: string | undefined) {
if (!WEBHOOK_SECRET) return true; // signature is optional

if (!signatureHeader || !signatureHeader.startsWith("sha256=")) return false;

const received = signatureHeader.split("=", 2)[1]?.trim()?.toLowerCase();
if (!received) return false;

const expected = crypto
.createHmac("sha256", WEBHOOK_SECRET)
.update(rawBody)
.digest("hex")
.toLowerCase();

if (received.length !== expected.length) return false;
return crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected));
}

Step 2: Generate a safe file name

The script normalizes the title:

  • replaces spaces with _
  • replaces unsupported characters with _
  • collapses repeated _
  • trims separators
  • preserves the extension
  • limits total length
function extractTriggerAndPayload(body: any) {
const trigger = String(body?.event?.trigger || "").trim();
const payload = body?.payload && typeof body.payload === "object" ? body.payload : null;
return { trigger, payload };
}

Step 3: Rename the file in DocSpace

If the sanitized name differs from the original title, the script renames the file using PUT /api/2.0/files/file/:fileId with body { "title": "<newTitle>" }

function sanitizeFileName(originalName: string, maxLength: number = 100) {
const name = String(originalName || "").trim();

const lastDot = name.lastIndexOf(".");
const hasExt = lastDot > 0 && lastDot < name.length - 1;

const baseRaw = hasExt ? name.slice(0, lastDot) : name;
const extRaw = hasExt ? name.slice(lastDot) : "";

let base = baseRaw.replace(/ /g, "_");
base = base.replace(/[^A-Za-z0-9_\.-]+/g, "_");
base = base.replace(/_+/g, "_");
base = base.replace(/^[_\.-]+|[_\.-]+$/g, "");

if (!base) base = "file";

if ((base + extRaw).length > maxLength) {
const allowedBaseLen = Math.max(maxLength - extRaw.length, 1);
base = base.slice(0, allowedBaseLen);
}

return base + extRaw;
}