跪拜 Guibai
← Back to the summary

Embedding OnlyOffice in Spring Boot for In-Browser Word and Excel Editing

Spring Boot Integrates OnlyOffice: Get Word/Excel Online Editing Done in 5 Minutes

Follow my public account: 【编程朝花夕拾】 for first-hand content.

01 Introduction

At work, we often encounter scenarios where multiple people edit a file simultaneously, especially in WeCom. But how do you handle it when a project requires multi-user file editing?

That's the project we're sharing today: OnlyOffice, which can help us implement online editing for Office files. This section uses SpringBoot 4.x integration as a case study.

02 Overview

OnlyOffice Docs (Document Server) is an open-source office suite that includes all the tools needed for processing documents, spreadsheets, presentations, PDFs, and PDF forms. The suite supports all major office file formats (DOCX, ODT, XLSX, ODS, CSV, PPTX, ODP, etc.) and supports real-time collaborative editing.

OnlyOffice (Document Service) is an independent web service responsible for:

Both Community and Enterprise Edition Docker services are available on GitHub. We'll use the Community Edition as an example below.

GitHub address: https://github.com/ONLYOFFICE/Docker-DocumentServer

Official website: https://www.onlyoffice.com/zh/office-suite

03 Service Setup

OnlyOffice service setup can be done directly using Docker deployment.

sudo docker run -i -t -d -p 9090:80 \
    -v /app/onlyoffice/DocumentServer/logs:/var/log/onlyoffice  \
    -v /app/onlyoffice/DocumentServer/data:/var/www/onlyoffice/Data  \
    -v /app/onlyoffice/DocumentServer/lib:/var/lib/onlyoffice \
    onlyoffice/documentserver

Note that JWT authentication is enabled by default here.

You can find the JWT secret via a command. The homepage provides many command hints.

04 SpringBoot Integration

4.1 Maven Dependencies

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>

<!-- Nimbus JOSE+JWT (OnlyOffice officially recommended JWT library) -->
<dependency>
    <groupId>com.nimbusds</groupId>
    <artifactId>nimbus-jose-jwt</artifactId>
    <version>10.2</version>
</dependency>

If OnlyOffice does not enable JWT authentication, nimbus-jose-jwt is not needed.

4.2 Core Constants

/**
 * OnlyOffice constant configuration
 */
public final class OnlyOfficeProperties {

    private OnlyOfficeProperties() {}

    /** OnlyOffice Document Service URL */
    public static final String DOC_SERVICE_URL = "http://10.100.xx.xx:9090";

    /** This project's externally accessible address (must be an IP reachable by OnlyOffice) */
    public static final String SERVER_URL = "http://10.50.xx.xx:8080";

    /** File storage directory */
    public static final String STORAGE_PATH = "./storage";

    /** JWT secret (empty means no signing) */
    public static final String JWT_SECRET = "InYyL*******xTDDugbfyQI";
}

4.3 Core Code

Upload and List Display

@GetMapping("/files")
public ResponseEntity<List<String>> listFiles() throws IOException {
    try (Stream<Path> stream = Files.list(storageDir)) {
        return ResponseEntity.ok(stream
                .filter(Files::isRegularFile)
                .map(p -> p.getFileName().toString())
                .toList());
    }
}

@PostMapping("/files")
public ResponseEntity<String> upload(@RequestParam("file") MultipartFile file) throws IOException {
    Files.copy(file.getInputStream(),
            storageDir.resolve(file.getOriginalFilename()),
            StandardCopyOption.REPLACE_EXISTING);
    return ResponseEntity.ok("uploaded: " + file.getOriginalFilename());
}

OnlyOffice loads documents via download token

@GetMapping("/files/download/{token}")
public ResponseEntity<Resource> downloadByToken(@PathVariable String token) throws IOException {
    String name = downloadTokens.getOrDefault(token, token);
    Path file = storageDir.resolve(name).normalize();
    Resource resource = new UrlResource(file.toUri());
    if (!resource.exists() || !resource.isReadable())
        return ResponseEntity.notFound().build();
    return ResponseEntity.ok()
            .contentType(MediaType.parseMediaType(contentType(name)))
            .body(resource);
}

Edit

@GetMapping("/settings")
public ResponseEntity<Map<String, String>> settings() {
    return ResponseEntity.ok(Map.of("docServiceUrl", OnlyOfficeProperties.DOC_SERVICE_URL));
}

@GetMapping("/config")
public ResponseEntity<Map<String, Object>> config(
        @RequestParam("name") String name,
        @RequestParam(value = "user", defaultValue = "user") String user) throws Exception {

    String token = UUID.randomUUID().toString().replace("-", "");
    downloadTokens.put(token, name);

    Map<String, Object> document = new LinkedHashMap<>();
    document.put("fileType", fileType(name));
    document.put("key", token);
    document.put("title", name);
    document.put("url", OnlyOfficeProperties.SERVER_URL + "/api/files/download/" + token);

    Map<String, Object> editorConfig = new LinkedHashMap<>();
    editorConfig.put("callbackUrl", OnlyOfficeProperties.SERVER_URL + "/api/callback");
    editorConfig.put("lang", "zh-CN");
    editorConfig.put("mode", "edit");
    editorConfig.put("user", Map.of("id", user, "name", user));

    Map<String, Object> cfg = new LinkedHashMap<>();
    cfg.put("document", document);
    cfg.put("documentType", docType(name));
    cfg.put("editorConfig", editorConfig);
    cfg.put("height", "100%");
    cfg.put("width", "100%");

    if (isJwtEnabled()) {
        cfg.put("token", sign(objectMapper.writeValueAsString(cfg)));
    }
    return ResponseEntity.ok(cfg);
}

Callback

@PostMapping("/callback")
public ResponseEntity<Map<String, Integer>> callback(@RequestBody Map<String, Object> body) {
    try {
        handleCallback(body);
    } catch (Exception e) {
        e.printStackTrace();
    }
    return ResponseEntity.ok(Map.of("error", 0));
}

@SuppressWarnings("unchecked")
private void handleCallback(Map<String, Object> body) throws IOException, InterruptedException {
    int status = ((Number) body.get("status")).intValue();
    if (status != 2 && status != 6) return;

    String downloadUrl = (String) body.get("url");
    if (downloadUrl == null || downloadUrl.isEmpty()) return;

    String filename = downloadTokens.getOrDefault(
            (String) body.get("key"), (String) body.get("key"));

    HttpRequest request = HttpRequest.newBuilder()
            .uri(URI.create(downloadUrl)).GET().build();
    HttpResponse<Path> response = httpClient.send(request,
            HttpResponse.BodyHandlers.ofFile(storageDir.resolve(filename + ".tmp")));

    if (response.statusCode() == 200) {
        Files.move(storageDir.resolve(filename + ".tmp"),
                storageDir.resolve(filename), StandardCopyOption.REPLACE_EXISTING);
    }
}

Other Helper Methods

private String contentType(String name) {
    return switch (ext(name)) {
        case "docx" -> "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
        case "xlsx" -> "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
        case "pptx" -> "application/vnd.openxmlformats-officedocument.presentationml.presentation";
        case "pdf" -> "application/pdf";
        default -> "application/octet-stream";
    };
}

private String fileType(String name) {
    return switch (ext(name)) {
        case "xlsx", "xls" -> "xlsx";
        case "pptx", "ppt" -> "pptx";
        case "pdf" -> "pdf";
        case "txt" -> "txt";
        case "csv" -> "csv";
        default -> "docx";
    };
}

private String docType(String name) {
    return switch (ext(name)) {
        case "xlsx", "xls", "csv" -> "cell";
        case "pptx", "ppt" -> "slide";
        default -> "word";
    };
}

private String ext(String name) {
    int dot = name.lastIndexOf('.');
    return dot > 0 ? name.substring(dot + 1).toLowerCase() : "docx";
}

private boolean isJwtEnabled() {
    return OnlyOfficeProperties.JWT_SECRET != null && !OnlyOfficeProperties.JWT_SECRET.isBlank();
}

private String sign(String configJson) throws Exception {
    JWTClaimsSet claims = JWTClaimsSet.parse(configJson);
    SignedJWT signed = new SignedJWT(
            new JWSHeader.Builder(JWSAlgorithm.HS256).type(JOSEObjectType.JWT).build(), claims);
    signed.sign(new MACSigner(OnlyOfficeProperties.JWT_SECRET.getBytes(StandardCharsets.UTF_8)));
    return signed.serialize();
}

4.4 Pages

File Management Page index.html

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>OnlyOffice Online Editing</title>
    <style>
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body { font-family: -apple-system, sans-serif; background: #f5f5f5; padding: 24px; }
        h1 { font-size: 20px; margin-bottom: 20px; color: #333; }
        .upload-box { display: flex; gap: 10px; margin-bottom: 20px; }
        input[type="file"] { padding: 8px; border: 2px dashed #ccc; border-radius: 6px; flex: 1; }
        button { padding: 8px 20px; border: none; border-radius: 6px; cursor: pointer; font-size: 14px; }
        .btn-upload { background: #667eea; color: #fff; }
        .btn-edit { background: #52c41a; color: #fff; padding: 4px 14px; font-size: 13px; }
        .btn-del { background: #ff4d4f; color: #fff; padding: 4px 14px; font-size: 13px; }
        .file-list { background: #fff; border-radius: 8px; padding: 16px; }
        .file-item { display: flex; align-items: center; padding: 10px 0; border-bottom: 1px solid #f0f0f0; }
        .file-item:last-child { border-bottom: none; }
        .file-name { flex: 1; font-size: 14px; }
        .file-actions { display: flex; gap: 6px; }
        .empty { text-align: center; color: #999; padding: 40px; font-size: 14px; }
    </style>
</head>
<body>
<h1>OnlyOffice Online Editing</h1>
<div class="upload-box">
    <input type="file" id="fileInput" accept=".docx,.xlsx,.pptx,.pdf,.txt,.csv">
    <button class="btn-upload" onclick="upload()">Upload</button>
</div>
<div class="file-list" id="fileList"></div>

<script>
    const API = '/api/files';

    async function loadFiles() {
        const res = await fetch(API).then(r => r.json());
        const box = document.getElementById('fileList');
        if (!res.length) { box.innerHTML = '<div class="empty">No documents yet, please upload first</div>'; return; }
        box.innerHTML = res.map(f => `
            <div class="file-item">
                <span class="file-name">${f}</span>
                <div class="file-actions">
                    <button class="btn-edit" onclick="openEditor('${f}')">Edit</button>
                    <button class="btn-del" onclick="deleteFile('${f}')">Delete</button>
                </div>
            </div>`).join('');
    }

    function openEditor(name) {
        window.open('/editor.html?name=' + encodeURIComponent(name), '_blank');
    }

    async function upload() {
        const file = document.getElementById('fileInput').files[0];
        if (!file) return;
        const fd = new FormData(); fd.append('file', file);
        await fetch(API, { method: 'POST', body: fd });
        document.getElementById('fileInput').value = '';
        loadFiles();
    }

    async function deleteFile(name) {
        if (!confirm('Are you sure you want to delete ' + name + ' ?')) return;
        await fetch(API + '/' + encodeURIComponent(name), { method: 'DELETE' });
        loadFiles();
    }

    loadFiles();
</script>
</body>
</html>

Editor Page

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document Editing - OnlyOffice</title>
    <style>
        * { margin: 0; padding: 0; box-sizing: border-box; }
        html, body { height: 100%; overflow: hidden; }
        #editor { width: 100%; height: 100%; }
        #error { display: flex; align-items: center; justify-content: center; height: 100%; font-size: 16px; color: #ff4d4f; font-family: -apple-system, sans-serif; }
    </style>
</head>
<body>
<div id="editor"></div>

<script>
    const name = new URLSearchParams(location.search).get("name");
    if (!name) {
        document.getElementById('editor').outerHTML = '<div id="error">Missing document name parameter</div>';
        throw new Error('missing name');
    }
    document.title = name;

    (async function () {
        const { docServiceUrl } = await fetch('/api/settings').then(r => r.json());

        const script = document.createElement('script');
        script.src = docServiceUrl + '/web-apps/apps/api/documents/api.js';
        script.onload = async () => {
            const config = await fetch('/api/config?name=' + encodeURIComponent(name)).then(r => r.json());
            new DocsAPI.DocEditor("editor", config);
        };
        document.head.appendChild(script);
    })();
</script>
</body>
</html>

05 Testing

Enter the homepage, the file management page. We need to upload files.

We upload Excel and Word documents separately. After uploading, the files are displayed in the list below. Clicking edit allows you to start editing.

After editing is complete, opening the file again retains the previously added content.