Only Office for personal use

Vibe coding is supposedly going to replace actual programmers. I can’t wait for it to go away. Robots have a terrible way of doing things, and they are not very good at writing code. That, however, has not kept me from regularly dabbling with ChatGPT and code generation.

Programming is my hobby - not my livelihood. So when it comes to solving an personal issue with code, ChatGPT appeals to me. I am too busy in life to spend hours catching myself up from recent advancements in technology in order to find the right framework, read the docs, and use that knowledge to write good, secure code.

I am still not convinced that ChatGPT is smart enough to replace anything of value. In my haste of finding an alternative to Google Docs, I asked ChatGPT to help me code a simple middleware to load OnlyOffice behind authentication.1 OnlyOffice required a JSON Web Token, but their docs were very vague - a vice of all companies that want free fixes from Open Source Software enthusiasts, yet also want money.

What was supposed to be a simple task ended up to be a conversaion where ChatGPT said, "Ah, OnlyOffice does x,y,z, but I wrote a,b,c instead. Here, let me fix that for you." When that did not work, ChatGPT would say, "Ah, let's try this instead." To save others from having to spend hours repeating the conversation I had with a robot to generate boilerplate code, let me provide you with the end result.

To host and OnlyOffice locally, you need middleware that signs a JSON Web Token, and passes it to OnlyOffice's Document Server for identification. This Document server stores your text and sends it to Vladimir Putin and the Kremlin your storage server after you close your browser window. If anyone wants to send their important documents to dubious destinations to self host OnlyOffice, you may find help with this generated code:

Middleware Server

require("dotenv").config();
const express = require("express");
const axios = require("axios");
const jwt = require("jsonwebtoken");
const qs = require("querystring");
const fs = require("fs");
const path = require("path");
const session = require("express-session");
const officegen = require("officegen");

const app = express();
const port = process.env.PORT || 3000;

const {
  DEX_ISSUER,
  CLIENT_ID,
  CLIENT_SECRET,
  JWT_SECRET,
  DOCUMENTSERVER_URL,
  MIDDLEWARE_SERVER,
  NODE_ENV
} = process.env;

if (!DEX_ISSUER || !CLIENT_ID || !CLIENT_SECRET || !JWT_SECRET || !DOCUMENTSERVER_URL || !MIDDLEWARE_SERVER) {
  console.error("Missing required env vars. Please set DEX_ISSUER, CLIENT_ID, CLIENT_SECRET, JWT_SECRET, DOCUMENTSERVER_URL, MIDDLEWARE_SERVER");
  process.exit(1);
}

if (NODE_ENV === "production") {
  app.set("trust proxy", 1);
}

// Body parsers
app.use(express.json());
app.use(express.urlencoded({ extended: true }));

// Session
app.use(session({
  secret: JWT_SECRET,
  resave: false,
  saveUninitialized: false,
  cookie: {
    secure: NODE_ENV === "production",
    httpOnly: true,
    sameSite: "lax"
  }
}));

// Files directory
const FILES_DIR = path.join(__dirname, "files");
if (!fs.existsSync(FILES_DIR)) fs.mkdirSync(FILES_DIR, { recursive: true });

// Utility: create empty docx
function createEmptyDocx(filePath) {
  return new Promise((resolve, reject) => {
    const docx = officegen("docx");
    const out = fs.createWriteStream(filePath);

    out.on("error", err => reject(err));
    docx.on("error", err => reject(err));
    out.on("close", () => resolve(filePath));

    docx.generate(out);
  });
}

// Middleware: login required
function requireLogin(req, res, next) {
  if (!req.session.user) {
    req.session.redirectAfterLogin = req.originalUrl;
    return res.redirect("/login");
  }
  next();
}

/* ---------------- Routes ---------------- */

// Root

// Dashboard
app.get("/", requireLogin, (req, res) => {
  const user = req.session.user;
  const files = fs.readdirSync(FILES_DIR).filter(f => f.endsWith(".docx"));

  const fileLinks = files.length ? files.map(f => `<li><a href="/edit/${encodeURIComponent(f)}">${f}</a></li>`).join("") : "<li>No documents yet</li>";


  res.send(`
    <!DOCTYPE html>
    <html>
    <head><title>Dashboard</title>
      <style>
    body {
      font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
      margin: 0;
      padding: 20px;
      background: #f7f8fa;
      color: #333;
    }
    h1, h2 {
      color: #222;
    }
    ul {
      list-style-type: none;
      padding-left: 0;
    }
    li {
      margin-bottom: 8px;
    }
    a {
      text-decoration: none;
      color: #0078d4;
    }
    a:hover {
      text-decoration: underline;
    }
    form {
      margin-bottom: 20px;
      background: #fff;
      padding: 15px;
      border-radius: 8px;
      box-shadow: 0 2px 6px rgba(0,0,0,0.1);
    }
    input[type="text"] {
      padding: 6px 10px;
      width: 250px;
      border-radius: 4px;
      border: 1px solid #ccc;
    }
    button {
      padding: 6px 12px;
      background-color: #0078d4;
      color: #fff;
      border: none;
      border-radius: 4px;
      cursor: pointer;
    }
    button:hover {
      background-color: #005a9e;
    }
    .topbar {
      margin-bottom: 20px;
      font-size: 0.9rem;
    }
    .topbar a {
      margin-left: 10px;
    }
  </style>
    </head>
    <body>
      <h1>Welcome, ${user.name}</h1>

      <h2>Your Documents</h2>
      <ul>${fileLinks}</ul>

      <h2>Create New Document</h2>
      <form action="/create" method="post">
        <input type="text" name="filename" placeholder="Document name" required>
        <button type="submit">Create</button>
      </form>

      <h2>Rename Document</h2>
      <form action="/rename" method="post">
        <select name="oldName">
          ${files.map(f => `<option value="${f}">${f}</option>`).join("")}
        </select>
        <input type="text" name="newName" placeholder="New name" required>
        <button type="submit">Rename</button>
      </form>

      <p><a href="/logout">Logout</a></p>
    </body>
    </html>
  `);
});

// Create new document
app.post("/create", requireLogin, async (req, res) => {
  let { filename } = req.body;
  filename = filename.replace(/[\/\\?%*:|"<>]/g, '').replace(/[ ]/g, '-') + ".docx";
  const filePath = path.join(FILES_DIR, filename);

  if (fs.existsSync(filePath)) return res.send("File exists. <a href='/'>Back</a>");

  try {
    await createEmptyDocx(filePath);
    res.redirect(`/edit/${encodeURIComponent(filename)}`);
  } catch (err) {
    console.error(err);
    res.send("Error creating file. <a href='/'>Back</a>");
  }
});

// Rename document
app.post("/rename", requireLogin, (req, res) => {
  let { oldName, newName } = req.body;
  newName = newName.replace(/[\/\\?%*:|"<>]/g, '').replace(/[ ]/g, '-') + ".docx";

  const oldPath = path.join(FILES_DIR, oldName);
  const newPath = path.join(FILES_DIR, newName);

  if (!fs.existsSync(oldPath)) return res.send("Original file not found. <a href='/'>Back</a>");
  if (fs.existsSync(newPath)) return res.send("Target filename exists. <a href='/'>Back</a>");

  fs.renameSync(oldPath, newPath);
  res.redirect("/");
});

// Dex login
app.get("/login", (req, res) => {
  const redirect_uri = `${MIDDLEWARE_SERVER}/callback`;
  const authUrl = `${DEX_ISSUER}/auth?client_id=${encodeURIComponent(CLIENT_ID)}&redirect_uri=${encodeURIComponent(redirect_uri)}&response_type=code&scope=openid%20email%20profile`;
  res.redirect(authUrl);
});

// Dex callback
app.get("/callback", async (req, res) => {
  try {
    const code = req.query.code;
    if (!code) return res.status(400).send("Missing code");

    const tokenResp = await axios.post(
      `${DEX_ISSUER}/token`,
      qs.stringify({
        grant_type: "authorization_code",
        code,
        redirect_uri: `${MIDDLEWARE_SERVER}/callback`,
        client_id: CLIENT_ID,
        client_secret: CLIENT_SECRET
      }),
      { headers: { "Content-Type": "application/x-www-form-urlencoded" } }
    );

    const idToken = tokenResp.data.id_token;
    if (!idToken) return res.status(500).send("No id_token received");

    const userInfo = jwt.decode(idToken);
    if (!userInfo || !userInfo.sub) return res.status(500).send("Invalid id_token");

    req.session.user = {
      id: userInfo.sub,
      email: userInfo.email,
      name: userInfo.name || userInfo.preferred_username || userInfo.email || "Unnamed User"
    };

    if (req.session.redirectAfterLogin) {
      const redirectUrl = req.session.redirectAfterLogin;
      delete req.session.redirectAfterLogin;
      return res.redirect(redirectUrl);
    }

    res.redirect("/");
  } catch (err) {
    console.error("Callback error:", err.response?.data || err.message);
    res.status(500).send("Authentication failed");
  }
});

// Logout
app.get("/logout", (req, res) => {
  req.session.destroy(err => {
    const logoutUrl = DEX_ISSUER ? (DEX_ISSUER.endsWith("/") ? `${DEX_ISSUER}logout` : `${DEX_ISSUER}/logout`) : "/";
    res.redirect(`/`);
  });
});

// New document without login
app.get("/new", async (req, res) => {
  try {
    const fileId = `new-${Date.now()}.docx`;
    const newFilePath = path.join(FILES_DIR, fileId);
    await createEmptyDocx(newFilePath);
    res.redirect(`/edit/${encodeURIComponent(fileId)}`);
  } catch (err) {
    console.error("Error creating new file:", err);
    res.status(500).send("Failed to create new document");
  }
});

// Serve files
app.use("/files", express.static(FILES_DIR, {
  setHeaders: (res) => {
    res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
    res.setHeader("Pragma", "no-cache");
    res.setHeader("Expires", "0");
  }
}));

// Edit OnlyOffice document
app.get("/edit/:filename", requireLogin, (req, res) => {
  const fileId = req.params.filename;
  const filePath = path.join(FILES_DIR, fileId);
  if (!fs.existsSync(filePath)) return res.status(404).send("File not found");

  const documentUrl = `${MIDDLEWARE_SERVER}/files/${encodeURIComponent(fileId)}?v=${Date.now()}`;
  const documentKey = `${fileId}-${Date.now()}`;

  const user = req.session.user;
  const dsPayload = {
    document: {
      fileType: "docx",
      key: documentKey,
      title: fileId,
      url: documentUrl
    },
    editorConfig: {
      mode: "edit",
      user: { id: user.id, name: user.name },
      callbackUrl: "${MIDDLEWARE_SERVER}/onlyoffice-callback"
    },
    iat: Math.floor(Date.now() / 1000),
    exp: Math.floor(Date.now() / 1000) + 3600
  };

  const dsToken = jwt.sign(dsPayload, JWT_SECRET, { algorithm: "HS256" });

  res.send(`
    <!DOCTYPE html>
    <html>
    <head>
      <meta charset="utf-8">
      <title>Editing ${fileId}</title>
      <style>
  html, body { height: 100%; margin: 0; background: #f7f8fa; font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif; }
  #placeholder { width: 100%; height: 100%; }
  .topbar {
    position: absolute;
    top: 8px;
    right: 12px;
    z-index: 10;
    font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
    background: rgba(255,255,255,0.9);
    padding: 6px 10px;
    border-radius: 6px;
    box-shadow: 0 2px 6px rgba(0,0,0,0.1);
  }
  .topbar a {
    margin-left: 8px;
    color: #0078d4;
    text-decoration: none;
  }
  .topbar a:hover {
    text-decoration: underline;
  }
</style>
    </head>
    <body>
      <div id="placeholder"></div>
      <div class="topbar">Logged in as ${escapeHtml(user.name)} | <a href="/logout">Logout</a></div>
      <script src="${DOCUMENTSERVER_URL}/web-apps/apps/api/documents/api.js"></script>
      <script>
        const config = {
          document: ${JSON.stringify(dsPayload.document)},
          documentType: "word",
          editorConfig: ${JSON.stringify(dsPayload.editorConfig)},
          token: ${JSON.stringify(dsToken)}
        };
        const docEditor = new DocsAPI.DocEditor("placeholder", config);
      </script>
    </body>
    </html>
  `);
});

// ONLYOFFICE callback
app.post("/onlyoffice-callback", async (req, res) => {
  const data = req.body;
  console.log("ONLYOFFICE callback:", data);

  if ([2,6,7].includes(data.status) && data.url) {
    try {
      const originalFilename = data.key.split(".docx")[0] + ".docx";
      const filepath = path.join(FILES_DIR, originalFilename);

      const response = await axios.get(data.url, { responseType: "arraybuffer" });
      fs.writeFileSync(filepath, response.data);

      console.log(`Saved document: ${filepath}`);
    } catch (err) {
      console.error("Error saving document:", err.response?.data || err.message);
    }
  }

  res.json({ error: 0 });
});

// Serve individual files
app.get("/files/:filename", (req, res) => {
  const filename = req.params.filename;
  const filepath = path.join(FILES_DIR, filename);

  if (fs.existsSync(filepath)) res.sendFile(filepath);
  else res.status(404).send("File not found");
});

// XSS helper
function escapeHtml(str) {
  return String(str).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&#039;");
}

app.listen(port, () => console.log(`OnlyOffice middleware listening on port ${port}`));

Package.json

{
  "name": "onlyoffice-middleware",
  "version": "1.0.0",
  "main": "index.js",
  "dependencies": {
    "express": "^4.18.2",
    "jsonwebtoken": "^9.0.0",
    "axios": "^1.6.0",
    "dotenv": "^16.0.0",
    "express-session": "^1.17.3",
    "officegen": "^0.6.4"
  }
}

Dockerfile

FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install --production
COPY . .
EXPOSE 3000
CMD ["node", "index.js"]

Environment Variables

The needed Environment variables are:

  • DEX_ISSUER: The domain name to your OAuth2 server
  • CLIENT_ID: OAuth2 client ID
  • CLIENT_SECRET: OAuth2 client secret
  • JWT_SECRET: What you set in OnlyOffice Document Server as the JWT Secret
  • DOCUMENTSERVER_URL: The DocumentServer doman name
  • MIDDLEWARE_SERVER: the domain name serving this application

Kubernetes deployment example

I host this application in my kubernetes cluster.2 with the following deployment manifest:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: onlyoffice-middleware
  namespace: onlyoffice
spec:
  replicas: 1
  selector:
    matchLabels:
      app: onlyoffice-middleware
  template:
    metadata:
      labels:
        app: onlyoffice-middleware
    spec:
      imagePullSecrets:
      - name: regsecret
      containers:
        - name: middleware
          image: d.domain.tld/onlyoffice-middleware:latest
          imagePullPolicy: Always
          ports:
            - containerPort: 3000
          resources:
            requests:
              cpu: "100m"
              memory: "128Mi"
            limits:
              cpu: "500m"
              memory: "512Mi"
          env:
            - name: DEX_ISSUER
              value: "https://dex.domain.tld"
            - name: CLIENT_ID
              value: "onlyoffice-middleware-domain.tld"
            - name: DOCUMENTSERVER_URL
              value: "https://office.domain.tld"
            - name: CLIENT_SECRET
              valueFrom:
                secretKeyRef:
                  name: onlyoffice-secrets
                  key: MIDDLEWARE_SECRET
            - name: JWT_SECRET
              valueFrom:
                secretKeyRef:
                  name: onlyoffice-secrets
                  key: JWT_SECRET
          volumeMounts:
            - name: files-storage
              mountPath: /app/files
      volumes:
        - name: files-storage
          persistentVolumeClaim:
            claimName: middleware-files-pvc

Conclusion

The middleware has a simple gui for creating new documents and editing existing ones. There is a delay when you save in the web app and see the changes in the actual file. For my use case, these documents will be short lived. According to one hackernews post, OnlyOffice has Russian ties. While I like free software, I do not support Russia's agression in Ukraine.3 The free world has imposed sanctions on Russia, and I participate in those by not using Russian-based software. So, if you know of a good lightweight editor that is not markdown based, let me know.

  1. I have Dex which is connected to my local LDAP server.

  2. At least until I find a better lightweight word processor that has easy footnote support.

  3. I have even gone as far as removing nginx from my kubernetes cluster.