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. 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);
}
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(session({
secret: JWT_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
secure: NODE_ENV === "production",
httpOnly: true,
sameSite: "lax"
}
}));
const FILES_DIR = path.join(__dirname, "files");
if (!fs.existsSync(FILES_DIR)) fs.mkdirSync(FILES_DIR, { recursive: true });
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);
});
}
function requireLogin(req, res, next) {
if (!req.session.user) {
req.session.redirectAfterLogin = req.originalUrl;
return res.redirect("/login");
}
next();
}
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>
`);
});
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>");
}
});
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("/");
});
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);
});
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");
}
});
app.get("/logout", (req, res) => {
req.session.destroy(err => {
const logoutUrl = DEX_ISSUER ? (DEX_ISSUER.endsWith("/") ? `${DEX_ISSUER}logout` : `${DEX_ISSUER}/logout`) : "/";
res.redirect(`/`);
});
});
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");
}
});
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");
}
}));
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>
`);
});
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 });
});
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");
});
function escapeHtml(str) {
return String(str).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/'/g,"'");
}
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. 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. 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.