23 KiB
File Manager Collabora / WOPI Flow
Dokumen ini menjelaskan alur frontend untuk membuka editor Collabora lewat backend WOPI.
Prinsip utama:
- Frontend hanya memanggil App API.
- Frontend tidak pernah memanggil endpoint
/wopi/...secara langsung. - Backend yang mengurus token, proof, S3, lock, autosave, dan finalisasi.
Tambahan penting untuk flow helicopter template:
- hasil
create-from-templateakan dibuat sebagai file manager file biasa, bukan template - backend juga akan memastikan file itu masuk ke
helicopter_filessectionfpwb - kalau frontend membuka template yang sama untuk
takeover_idyang sama, backend akan reuse file existing bila sudah ada - saat child
fpwbbaru berhasil dibuat, backend akan kirim SSE agar frontend bisa langsung append item baru ke list - kalau file hasil template punya
takeover_id, endpointGET /api/v1/helicopter-files/get/{helicopter_id}danGET /api/v1/helicopter-files/listakan menyembunyikan item itu dari user yang tidak punya akses takeover tersebut
Endpoint Yang Dipakai Frontend
1) Buat file baru dari template
POST /api/v1/file-manager/files/create-from-template
Request:
{
"data": {
"type": "file_manager_file_create_from_template",
"attributes": {
"template_uuid": "019f0386-0a00-7a4a-a2d8-30afc0e0c3ad",
"helicopter_id": "019e8713-aa19-7fb7-a510-a0332dc69697",
"takeover_id": "019f0386-0a00-7a4a-a2d8-30afc0e0c3af",
"name": "collabora-draft.docx"
}
}
}
Response:
{
"data": {
"file_id": "019f0a12-1111-7abc-8def-222222222222",
"collabora_action": "https://collabora.example.com/.../WOPISrc=...",
"access_token": "eyJhbGciOiJIUzI1NiIs...",
"access_token_ttl": 1800000
}
}
2) Buka editor untuk file existing
GET /api/v1/file-manager/files/{fileId}/edit-session
Response shape sama:
{
"data": {
"file_id": "019f0a12-1111-7abc-8def-222222222222",
"collabora_action": "https://collabora.example.com/.../WOPISrc=...",
"access_token": "eyJhbGciOiJIUzI1NiIs...",
"access_token_ttl": 1800000
}
}
3) Ambil daftar helicopter files
GET /api/v1/helicopter-files/get/{helicopter_id}
Endpoint ini yang dipakai frontend untuk melihat child file yang muncul di helicopter file collection.
Untuk hasil create-from-template, item baru akan muncul di:
data.attributes.files.prepare- section backend =
fpwb - secara UI biasanya ditampilkan sebagai area Flight Preparation and WB
4) Dengarkan event child baru
GET /api/v1/helicopter-files/events?helicopter_id={helicopter_id}
Stream ini dipakai supaya frontend bisa langsung append child baru tanpa reload penuh.
Event name:
helicopter.file.created
SSE dapet dari mana?
SSE ini bukan datang dari Collabora, bukan dari browser local state, dan bukan dari endpoint /wopi/....
Sumbernya adalah backend App API:
- frontend subscribe ke
GET /api/v1/helicopter-files/events?helicopter_id={helicopter_id} - route ini masuk ke handler SSE backend
- backend hanya akan mengirim event ke user yang sedang subscribe
- event difilter lagi dengan
helicopter_idquery param
Kapan event dipublish:
- frontend memanggil
POST /api/v1/file-manager/files/create-from-template - backend membuat atau reuse file manager file
- backend memastikan ada row
helicopter_filesdi sectionfpwb - kalau row
helicopter_filesitu baru dibuat saat request ini, backend publish eventhelicopter.file.created - stream
/api/v1/helicopter-files/eventsmengirim event itu ke frontend yang subscribe
Artinya:
- kalau child
fpwbsudah ada dari sebelumnya, backend tidak akan publish event create lagi - kalau frontend telat subscribe, fallback paling aman tetap re-fetch
GET /api/v1/helicopter-files/get/{helicopter_id} - SSE ini hanya untuk sinkronisasi UI realtime, bukan source of truth utama
Flow Create From Template
Step 1 - Frontend minta editor session
Frontend mengirim template_uuid, helicopter_id, name, dan boleh menambahkan takeover_id.
template_uuid harus mengarah ke helicopter_file.helicopter_file_id dari helicopter yang sama dengan helicopter_id, bukan ke file takeover biasa.
Backend akan menjalankan alur seperti ini:
create-from-template
-> cek user session
-> cek template memang milik helicopter itu
-> jika ada takeover_id:
-> cek apakah kombinasi template + takeover sudah punya file existing
-> jika sudah ada:
-> reuse file existing
-> pastikan metadata template + takeover tersimpan
-> pastikan ada child helicopter_files di section fpwb
-> jika child baru dibuat, publish SSE helicopter.file.created
-> mint WOPI token
-> balikin Collabora action URL
-> jika belum ada:
-> buat draft file baru dari template
-> copy object template ke key final file baru
-> simpan metadata template + takeover
-> pastikan ada child helicopter_files di section fpwb
-> jika child baru dibuat, publish SSE helicopter.file.created
-> mint WOPI token
-> balikin Collabora action URL
-> jika takeover_id tidak ada:
-> buat draft file baru dari template
-> copy object template ke key final file baru
-> simpan metadata template
-> pastikan ada child helicopter_files di section fpwb
-> jika child baru dibuat, publish SSE helicopter.file.created
-> mint WOPI token
-> balikin Collabora action URL
Catatan untuk step 9:
- SSE dipublish oleh backend App API pada request
create-from-template - jadi FE yang sedang membuka halaman helicopter files bisa langsung menerima event tanpa menunggu save dari Collabora
- source of truth event tetap berasal dari proses create child row di backend
Step 2 - Frontend simpan response
Frontend wajib menyimpan:
file_idcollabora_actionaccess_tokenaccess_token_ttldalam milidetik
file_id dipakai sebagai join key untuk session berikutnya.
file_id ini juga bisa dikirim lagi saat frontend create takeover lewat field edited_template_file_ids.
Kalau tujuan frontend adalah menampilkan child di helicopter files:
file_idadalah ID file manager filehelicopter_file_idtidak ada di response create-from-templatehelicopter_file_idakan muncul lewat:GET /api/v1/helicopter-files/get/{helicopter_id}atau SSEhelicopter.file.created
Step 3 - Frontend buka Collabora
Frontend tidak boleh hanya redirect atau window.open(collabora_action) begitu saja.
collabora_action harus dibuka dengan POST form yang ikut mengirim:
access_tokenaccess_token_ttldalam milidetik
Ini penting karena backend WOPI membaca token dari query access_token atau header bearer pada request WOPI yang datang dari Collabora.
Kalau frontend hanya membuka collabora_action tanpa token bootstrap, backend akan mengembalikan error seperti:
401 FILE_MANAGER_WOPI_INVALID_TOKENtoken is malformed: token contains an invalid number of segments
Frontend membuka editor memakai:
collabora_actionsebagaiform actionaccess_tokensebagai hidden inputaccess_token_ttlsebagai hidden input
Rule praktis:
- jangan pernah memanggil
/wopi/...dari browser - jangan pernah expose S3 URL atau credential ke frontend
- gunakan
collabora_actionsebagai base URL editor - jangan buka
collabora_actiontanpa submitaccess_token
File Akan Muncul Di Mana
Setelah create-from-template sukses, hasil akhirnya ada di dua tempat berbeda yang perlu dibedakan:
1) File manager file
Ini adalah object file utama yang dibuka oleh Collabora.
Identifier utamanya:
file_id
Endpoint yang relevan:
GET /api/v1/file-manager/files/{fileId}/edit-session- WOPI internal backend
/wopi/files/{fileId}dan/wopi/files/{fileId}/contents
2) Helicopter file child
Ini adalah row bisnis yang membuat file tadi muncul di halaman helicopter files.
Lokasinya:
GET /api/v1/helicopter-files/get/{helicopter_id}- response path
data.attributes.files.prepare - section row =
fpwb
Field penting item collection:
helicopter_file_idattachment.idattachment.file_name
Catatan:
- child ini bukan parent folder file manager
- child ini adalah row di tabel
helicopter_files - relasinya menunjuk ke file hasil template lewat
source_file_id
Contoh Integrasi Next.js
Bagian ini menunjukkan contoh yang lengkap untuk Next.js App Router.
Asumsi:
- user sudah login ke App API
- frontend memanggil backend App API, bukan endpoint
/wopi/... - backend mengembalikan
file_id,collabora_action,access_token, danaccess_token_ttl
Tipe response
export type CollaboraSessionResponse = {
data: {
file_id: string
collabora_action: string
access_token: string
access_token_ttl: number
}
}
Server action atau API helper
Contoh helper untuk memanggil backend create-from-template.
// app/lib/file-manager.ts
export type CreateFromTemplateInput = {
templateUuid: string
helicopterId: string
takeoverId?: string
name: string
}
export type CollaboraSessionResponse = {
data: {
file_id: string
collabora_action: string
access_token: string
access_token_ttl: number
}
}
export async function createFromTemplate(
input: CreateFromTemplateInput,
accessToken: string,
): Promise<CollaboraSessionResponse> {
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/v1/file-manager/files/create-from-template`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({
data: {
type: "file_manager_file_create_from_template",
attributes: {
template_uuid: input.templateUuid,
helicopter_id: input.helicopterId,
takeover_id: input.takeoverId,
name: input.name,
},
},
}),
cache: "no-store",
},
)
if (!response.ok) {
throw new Error(`Create from template failed with status ${response.status}`)
}
return response.json()
}
Contoh helper untuk membuka file existing:
// app/lib/file-manager.ts
export async function getEditSession(
fileId: string,
accessToken: string,
): Promise<CollaboraSessionResponse> {
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/v1/file-manager/files/${fileId}/edit-session`,
{
method: "GET",
headers: {
Authorization: `Bearer ${accessToken}`,
},
cache: "no-store",
},
)
if (!response.ok) {
throw new Error(`Edit session failed with status ${response.status}`)
}
return response.json()
}
Komponen client untuk bootstrap Collabora
Komponen ini membuat form hidden dan auto-submit ke collabora_action.
// app/components/collabora-launcher.tsx
"use client"
import { useEffect, useRef } from "react"
type Props = {
collaboraAction: string
accessToken: string
accessTokenTTL: number // milliseconds
target?: string
}
export function CollaboraLauncher({
collaboraAction,
accessToken,
accessTokenTTL,
target = "collabora-editor",
}: Props) {
const formRef = useRef<HTMLFormElement | null>(null)
useEffect(() => {
formRef.current?.submit()
}, [])
return (
<>
<form ref={formRef} action={collaboraAction} method="post" target={target}>
<input type="hidden" name="access_token" value={accessToken} />
<input type="hidden" name="access_token_ttl" value={String(accessTokenTTL)} />
</form>
<iframe
name={target}
title="Collabora Editor"
className="h-[calc(100vh-120px)] w-full border-0"
/>
</>
)
}
Halaman Next.js untuk create from template
Contoh berikut memakai App Router dan menampilkan editor setelah session berhasil dibuat.
// app/file-manager/templates/[templateId]/open/page.tsx
import { CollaboraLauncher } from "@/app/components/collabora-launcher"
import { createFromTemplate } from "@/app/lib/file-manager"
import { cookies } from "next/headers"
type PageProps = {
params: Promise<{
templateId: string
}>
searchParams: Promise<{
helicopterId?: string
name?: string
}>
}
export default async function OpenTemplatePage({ params, searchParams }: PageProps) {
const { templateId } = await params
const { helicopterId, name } = await searchParams
if (!helicopterId || !name) {
throw new Error("helicopterId and name are required")
}
const cookieStore = await cookies()
const accessToken = cookieStore.get("access_token")?.value
if (!accessToken) {
throw new Error("Missing app access token")
}
const session = await createFromTemplate(
{
templateUuid: templateId,
helicopterId,
name,
},
accessToken,
)
return (
<CollaboraLauncher
collaboraAction={session.data.collabora_action}
accessToken={session.data.access_token}
accessTokenTTL={session.data.access_token_ttl}
/>
)
}
Halaman Next.js untuk file existing
// app/file-manager/files/[fileId]/edit/page.tsx
import { CollaboraLauncher } from "@/app/components/collabora-launcher"
import { getEditSession } from "@/app/lib/file-manager"
import { cookies } from "next/headers"
type PageProps = {
params: Promise<{
fileId: string
}>
}
export default async function EditFilePage({ params }: PageProps) {
const { fileId } = await params
const cookieStore = await cookies()
const accessToken = cookieStore.get("access_token")?.value
if (!accessToken) {
throw new Error("Missing app access token")
}
const session = await getEditSession(fileId, accessToken)
return (
<CollaboraLauncher
collaboraAction={session.data.collabora_action}
accessToken={session.data.access_token}
accessTokenTTL={session.data.access_token_ttl}
/>
)
}
Kenapa tidak cukup pakai window.open(collabora_action)?
Karena response backend memisahkan:
collabora_actionaccess_tokenaccess_token_ttl
Sedangkan backend WOPI membutuhkan token valid saat Collabora mulai memanggil:
GET /wopi/files/{fileId}GET /wopi/files/{fileId}/contents
Kalau frontend hanya membuka URL editor tanpa mengirim hidden form fields di atas, Collabora akan tetap mencoba request WOPI, tetapi backend akan membaca token kosong atau malformed.
Contoh yang salah
window.open(session.data.collabora_action, "_blank")
atau:
<iframe src={session.data.collabora_action} />
Kedua contoh di atas tidak mengirim access_token bootstrap.
Contoh yang benar
<form action={session.data.collabora_action} method="post" target="collabora-editor">
<input type="hidden" name="access_token" value={session.data.access_token} />
<input
type="hidden"
name="access_token_ttl"
value={String(session.data.access_token_ttl)}
/>
</form>
Step 4 - Autosave berjalan di backend
Setelah editor terbuka:
- Collabora memanggil
GET /wopi/files/{fileId} - Collabora memanggil
GET /wopi/files/{fileId}/contents - saat autosave, Collabora memanggil
POST /wopi/files/{fileId}/contents - saat close / exit save, Collabora mengirim lifecycle headers
Frontend tidak perlu mengurus autosave secara manual.
Flow Tampilan Child Di Helicopter Files
Kalau FE punya halaman detail helicopter files yang memanggil:
GET /api/v1/helicopter-files/get/{helicopter_id}
maka flow lengkapnya begini:
Step 1 - Load data awal
Frontend fetch:
GET /api/v1/helicopter-files/get/{helicopter_id}
Lalu render:
data.attributes.files.beforedata.attributes.files.preparedata.attributes.files.after
Hasil create-from-template akan masuk ke array:
data.attributes.files.prepare
Step 2 - Subscribe SSE
Saat halaman helicopter files terbuka, frontend subscribe:
GET /api/v1/helicopter-files/events?helicopter_id={helicopter_id}
Event yang perlu didengar:
helicopter.file.created
Penjelasan singkat:
- endpoint ini adalah stream SSE dari backend App API
- backend akan keep connection tetap hidup dan mengirim heartbeat
- event akan dikirim ketika backend membuat child
helicopter_filebaru untuk helicopter itu - event ini tidak tergantung pada autosave WOPI
Contoh payload:
{
"helicopter_id": "019dfb9e-db0c-7eaf-b701-8b4928e62d24",
"helicopter_file_id": "019f2000-aaaa-7bbb-8ccc-111111111111",
"file_id": "019f2000-dddd-7eee-8fff-222222222222",
"section": "fpwb",
"position": 3,
"is_mandatory": false,
"attachment_pending": true,
"name": "collabora-draft.docx",
"mime_type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"updated_at": "2026-07-01T08:30:00Z"
}
Step 3 - Append child baru ke state
Kalau frontend menerima event:
- cocokkan
helicopter_id - cek
section === "fpwb" - append ke collection
prepare - hindari duplikasi dengan key
helicopter_file_id
Contoh Next.js client hook:
"use client"
import { useEffect } from "react"
type HelicopterFileCreatedEvent = {
helicopter_id: string
helicopter_file_id: string
file_id: string
section: string
position: number
is_mandatory: boolean
attachment_pending: boolean
name: string
mime_type: string
updated_at: string
}
type PrepareItem = {
helicopter_file_id: string
section: string
position: number
is_mandatory: boolean
attachment_pending: boolean
attachment: {
id: string
file_name: string
download_url: string
}
created_at: string
updated_at: string
}
export function useHelicopterFileCreatedSSE(
helicopterId: string,
onCreated: (event: HelicopterFileCreatedEvent) => void,
) {
useEffect(() => {
const url = new URL(
`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/v1/helicopter-files/events`,
)
url.searchParams.set("helicopter_id", helicopterId)
const source = new EventSource(url.toString(), { withCredentials: true })
const handler = (raw: MessageEvent<string>) => {
const payload = JSON.parse(raw.data) as HelicopterFileCreatedEvent
onCreated(payload)
}
source.addEventListener("helicopter.file.created", handler as EventListener)
return () => {
source.removeEventListener(
"helicopter.file.created",
handler as EventListener,
)
source.close()
}
}, [helicopterId, onCreated])
}
Contoh append ke state:
setPrepareFiles((current) => {
if (current.some((item) => item.helicopter_file_id === event.helicopter_file_id)) {
return current
}
const nextItem = {
helicopter_file_id: event.helicopter_file_id,
section: event.section,
position: event.position,
is_mandatory: event.is_mandatory,
attachment_pending: event.attachment_pending,
attachment: {
id: event.file_id,
file_name: event.name,
download_url: "",
},
created_at: event.updated_at,
updated_at: event.updated_at,
}
return [...current, nextItem].sort((a, b) => a.position - b.position)
})
Catatan:
- saat event baru datang,
attachment_pendingbisa masihtrue - artinya child row sudah ada, tetapi attachment final mungkin belum ter-resolve penuh
- kalau FE butuh shape yang benar-benar final, aman juga untuk re-fetch sekali ke:
GET /api/v1/helicopter-files/get/{helicopter_id}
Step 5 - Refresh token saat TTL hampir habis
Token WOPI bersifat short-lived.
Saat token mendekati habis, frontend harus:
- memanggil ulang
GET /api/v1/file-manager/files/{fileId}/edit-sessionuntuk file yang sudah ada
Lalu replace token yang dipakai untuk membuka editor.
Catatan:
- setelah file pertama sukses dibuat, jangan panggil
create-from-templatelagi hanya untuk refresh token - untuk reopen atau refresh editor, gunakan
edit-session create-from-templatedipakai untuk bootstrap file baru atau reuse bytemplate + takeover
Flow Edit File Existing
Step 1 - Frontend minta edit session
Frontend hanya mengirim fileId.
Backend akan:
- load file node
- cari takeover asal file
- verifikasi user punya akses ke takeover
- mint access token baru
- balikin Collabora action URL
Step 2 - Frontend buka editor
Frontend membuka editor pakai response yang sama seperti flow create-from-template.
Lifecycle Yang Perlu Dipahami Frontend
Saat editor dibuka
Backend sudah membuat record file node.
Artinya:
- tidak ada lagi langkah create file saat save
- Collabora tinggal overwrite file yang sama
fileIdtetap sama selama sesi itu
Saat user mengetik
Collabora akan autosave ke backend melalui WOPI.
Frontend tidak perlu memanggil endpoint save sendiri.
Saat user menutup editor
Jika Collabora mengirim exit-save:
- backend menandai file
final - backend attach relasi takeover bila perlu saat create takeover menerima
edited_template_file_ids - frontend cukup refresh data list / detail
- child
fpwbtetap berada di helicopter files karena row bisnisnya sudah dibuat sejak create-from-template
Jika user menutup tanpa perubahan
File tetap berada di status draft dan bisa dibersihkan oleh job cleanup jika stale.
Permission Model
App API
App API tetap memakai session auth normal.
Frontend harus sudah login sebelum memanggil:
POST /api/v1/file-manager/files/create-from-templateGET /api/v1/file-manager/files/{fileId}/edit-session
WOPI API
WOPI API memakai:
access_token- proof key validation
- permission claim
readatauwrite
Frontend tidak perlu menambahkan permission manual ke WOPI request. Itu sudah ditangani backend saat mint token.
Error Handling Singkat
Semua error App API mengikuti envelope apperrorsx yang sudah dipakai backend:
statuscodeerror_codetitlemessage
Untuk frontend, patokan utamanya adalah code dan error_code, bukan hanya HTTP status.
Mapping singkat yang dipakai di flow ini:
403App API:FORBIDDEN_ACCESS/USER_UNAUTHORIZEDsaat user tidak boleh akses takeover atau file.401/403WOPI:FILE_MANAGER_WOPI_INVALID_TOKEN,FILE_MANAGER_WOPI_INVALID_PROOF, atauFILE_MANAGER_WOPI_PERMISSION_DENIED.409save WOPI:FILE_MANAGER_WOPI_LOCK_CONFLICTatauFILE_MANAGER_WOPI_STALE_TIMESTAMP.422input invalid:VALIDATION_ERRORatauFILE_MANAGER_WOPI_VALIDATION_FAILED.404file/takeover missing:DATA_NOT_FOUND,FILE_MANAGER_FILE_NOT_FOUND, atauFILE_MANAGER_WOPI_FILE_NOT_FOUND.
Catatan:
- WOPI save conflict kadang tetap mengembalikan body protokol khusus seperti
COOLStatusCode: 1010. - Untuk WOPI, frontend tetap tidak memanggil endpoint tersebut langsung; yang perlu di-handle adalah response dari backend saat membuka editor.
Hal Yang Harus Dihindari
- Jangan panggil
/wopi/...langsung dari frontend. - Jangan kirim credential S3 ke browser.
- Jangan hardcode file path di frontend.
- Jangan anggap token WOPI berlaku lama.
- Jangan reuse token file A untuk file B.
- Jangan pakai
create-from-templateberulang-ulang hanya untuk refresh editor file yang sama.