From c5e5f1f877aa343eeeead840e099c3f8c15f446e Mon Sep 17 00:00:00 2001 From: Jaggob <37583151+Jaggob@users.noreply.github.com> Date: Sat, 26 Sep 2026 11:42:44 +0200 Subject: [PATCH 1/6] Let the embed-create page wait for its create, and tell the host when the outcome is not known The create is a write, but it went with the default ten-second timeout: a slow create reported 'network' with "Request timed out." while the server went on and made the pad and the file, and a second try met the name it had taken. It now has no client-side time limit, like the embed page's initialise and recovery; a slow create is not a failed one. 'network' now means no answer from this app, which is the one case where the outcome is not known: fetch failed, also while the body streams in, or a proxy answered in its place, with its 502, 503 or 504 in status. A gateway's answer counted as 'server' before, which says nothing was created - true only of this app's own answers, since the server rolls a failed create back. After 'network' the page and the host's message say, in a translated sentence, that the pad may have been created anyway and to look in the folder before trying again. architecture.md says a host that stops waiting on its own must assume the same. --- docs/api-reference.md | 3 +- docs/architecture.md | 3 +- js/etherpad_nextcloud-embed-create-main.mjs | 2 +- ...herpad_nextcloud-embed-create-main.mjs.map | 2 +- l10n/de.js | 1 + l10n/de.json | 1 + l10n/es.js | 1 + l10n/es.json | 1 + l10n/fr.js | 1 + l10n/fr.json | 1 + l10n/it.js | 1 + l10n/it.json | 1 + lib/Controller/EmbedController.php | 1 + src/embed-create-main.js | 26 +++++--- templates/embed-create.php | 3 +- tests/js/embed-create-main.test.js | 65 ++++++++++++++++++- 16 files changed, 97 insertions(+), 16 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index 97bf879a..c89fa58f 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -490,8 +490,9 @@ solely by the separate external-pad policy, not by these two settings. - `epnc:host-sync-now` - `src/embed-create-main.js` - powers the minimal `/embed/create-by-parent/{parentFolderId}` page. - - uses same-origin `POST /api/v1/pads/create-by-parent`. + - uses same-origin `POST /api/v1/pads/create-by-parent`, without a time limit, as it writes. - redirects to returned `embed_url` after successful pad creation. + - tells the host `epnc:create-succeeded` or `epnc:create-failed`; after `reason: 'network'` the outcome is not known (`docs/architecture.md`, the create flow). ## URL Control in Files App diff --git a/docs/architecture.md b/docs/architecture.md index b730965a..394a6caa 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -169,7 +169,8 @@ Primary flow (minimal blank create launcher page): 5. `PadCreateController::createByParent` performs server-side validation of `name`, `accessMode`, and the writable target folder before creating the `.pad` file and binding. 6. Before redirecting, `src/embed-create-main.js` posts the host page one of two structured events so the surrounding UI can react without scraping the iframe DOM: - `epnc:create-succeeded` — payload `{embed_url, file_id, pad_id, access_mode}`. Fires once on the success path, immediately before the iframe self-redirects to the embed-open URL. - - `epnc:create-failed` — payload `{reason, status, message}`. Fires on any error. `reason` is one of `'invalid'` (client-side validation), `'conflict'` (HTTP 409 — e.g. duplicate filename), `'server'` (any other 4xx/5xx), or `'network'` (fetch itself failed). + - `epnc:create-failed` — payload `{reason, status, message}`. Fires on any error. `reason` is one of `'invalid'` (client-side validation), `'conflict'` (HTTP 409 — e.g. duplicate filename), `'server'` (any other 4xx/5xx this app answered; nothing was created, the server rolls a failed create back), or `'network'` (no answer from this app: fetch failed, or a proxy answered in its place, with its `502`/`503`/`504` in `status`). After `'network'` the pad may have been created anyway, and the message says to look in the folder before trying again. + The create has no client-side time limit, as it writes: a slow create is not a failed one, and the page reports nothing until the server or a proxy answers. A host that stops waiting on its own must assume the pad may still be created. The inline error rendering inside the iframe is unchanged — `postMessage` is purely additive for hosts that want to act on the outcome. Target-origin is `*` because the page doesn't know the host's origin up-front; the `frame-ancestors` allowlist already constrains who can be the parent. 7. On success the launcher redirects itself to the returned `embed_url`, after which the normal embed-open flow takes over. diff --git a/js/etherpad_nextcloud-embed-create-main.mjs b/js/etherpad_nextcloud-embed-create-main.mjs index 8530a0f8..e2d47bf4 100644 --- a/js/etherpad_nextcloud-embed-create-main.mjs +++ b/js/etherpad_nextcloud-embed-create-main.mjs @@ -1,2 +1,2 @@ -import{i as U,f as A,o as P,D as R}from"./fetch-helpers-Dqr3YYFE.chunk.mjs";(function(){const r=document.getElementById("etherpad-nextcloud-embed-create");if(!(r instanceof HTMLElement))return;const d=Number(r.getAttribute("data-parent-folder-id")||""),c=String(r.getAttribute("data-create-by-parent-url")||"").trim(),b=String(r.getAttribute("data-request-token")||"").trim(),w=String(r.getAttribute("data-l10n-missing-name")||"Pad name is required."),h=String(r.getAttribute("data-l10n-invalid-access-mode")||"Invalid access mode."),y=String(r.getAttribute("data-l10n-incomplete-config")||"Embed configuration is incomplete."),l=r.querySelector("[data-epnc-embed-create-loading]"),m=r.querySelector("[data-epnc-embed-create-error]"),u=r.querySelector("[data-epnc-embed-create-error-message]"),g=()=>P(b),f=(e,n)=>{if(window.parent!==window)try{window.parent.postMessage(Object.assign({type:e},n||{}),"*")}catch{}},S=e=>{l instanceof HTMLElement&&(l.hidden=!0),u instanceof HTMLElement&&(u.textContent=String(e||"Unknown error.")),m instanceof HTMLElement&&(m.hidden=!1)},a=(e,n,o)=>{const t=String(n||"Unknown error.");S(t),f("epnc:create-failed",{reason:e,status:typeof o=="number"?o:null,message:t})},_=()=>{const e=new URL(window.location.href).searchParams;return{name:String(e.get("name")||"").trim(),accessMode:String(e.get("accessMode")||R).trim()}},v=e=>{const n=new URL(String(e||"").trim(),window.location.origin);if(n.origin!==window.location.origin)throw new Error("Invalid embed URL origin.");return n.pathname+n.search+n.hash},L=e=>e===409?"conflict":"server";(async()=>{if(!Number.isFinite(d)||d<=0||c===""){a("invalid",y);return}if(g()===""){a("invalid","CSRF request token is missing.");return}const{name:e,accessMode:n}=_();if(e===""){a("invalid",w);return}if(!U(n)){a("invalid",h);return}const o=new URLSearchParams;o.set("parentFolderId",String(d)),o.set("name",e),o.set("accessMode",n);let t;try{t=await A(c,{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded;charset=UTF-8",requesttoken:g()},body:o.toString()})}catch(i){const s=i&&typeof i.status=="number"?i.status:null,E=i instanceof Error?i.message:"Pad creation failed.",M=s===null?"network":L(s);a(M,E,s);return}if(!t||typeof t.embed_url!="string"||t.embed_url.trim()===""){a("server","Pad creation API did not return a valid embed URL.");return}let p;try{p=v(t.embed_url)}catch(i){const s=i instanceof Error?i.message:"Invalid embed URL.";a("server",s);return}f("epnc:create-succeeded",{embed_url:t.embed_url,file_id:typeof t.file_id=="number"?t.file_id:null,pad_id:typeof t.pad_id=="string"?t.pad_id:"",access_mode:typeof t.access_mode=="string"?t.access_mode:""}),window.location.replace(p)})()})(); +import{i as A,f as E,r as T,o as q,D as P}from"./fetch-helpers-Dqr3YYFE.chunk.mjs";(function(){const n=document.getElementById("etherpad-nextcloud-embed-create");if(!(n instanceof HTMLElement))return;const s=Number(n.getAttribute("data-parent-folder-id")||""),c=String(n.getAttribute("data-create-by-parent-url")||"").trim(),b=String(n.getAttribute("data-request-token")||"").trim(),w=String(n.getAttribute("data-l10n-missing-name")||"Pad name is required."),y=String(n.getAttribute("data-l10n-invalid-access-mode")||"Invalid access mode."),h=String(n.getAttribute("data-l10n-incomplete-config")||"Embed configuration is incomplete."),S=String(n.getAttribute("data-l10n-unanswered")||"Nextcloud did not answer. The pad may have been created anyway; look in the folder before you try again."),l=n.querySelector("[data-epnc-embed-create-loading]"),m=n.querySelector("[data-epnc-embed-create-error]"),u=n.querySelector("[data-epnc-embed-create-error-message]"),g=()=>q(b),f=(e,r)=>{if(window.parent!==window)try{window.parent.postMessage(Object.assign({type:e},r||{}),"*")}catch{}},_=e=>{l instanceof HTMLElement&&(l.hidden=!0),u instanceof HTMLElement&&(u.textContent=String(e||"Unknown error.")),m instanceof HTMLElement&&(m.hidden=!1)},i=(e,r,o)=>{const t=String(r||"Unknown error.");_(t),f("epnc:create-failed",{reason:e,status:typeof o=="number"?o:null,message:t})},v=()=>{const e=new URL(window.location.href).searchParams;return{name:String(e.get("name")||"").trim(),accessMode:String(e.get("accessMode")||P).trim()}},L=e=>{const r=new URL(String(e||"").trim(),window.location.origin);if(r.origin!==window.location.origin)throw new Error("Invalid embed URL origin.");return r.pathname+r.search+r.hash},M=e=>e===409?"conflict":"server";(async()=>{if(!Number.isFinite(s)||s<=0||c===""){i("invalid",h);return}if(g()===""){i("invalid","CSRF request token is missing.");return}const{name:e,accessMode:r}=v();if(e===""){i("invalid",w);return}if(!A(r)){i("invalid",y);return}const o=new URLSearchParams;o.set("parentFolderId",String(s)),o.set("name",e),o.set("accessMode",r);let t;try{t=await E(c,{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded;charset=UTF-8",requesttoken:g()},body:o.toString()},{timeoutMs:null})}catch(a){const d=a&&typeof a.status=="number"?a.status:null,U=d===null||a&&a.unanswered===!0?"network":M(d);i(U,T(a,S,"Pad creation failed."),d);return}if(!t||typeof t.embed_url!="string"||t.embed_url.trim()===""){i("server","Pad creation API did not return a valid embed URL.");return}let p;try{p=L(t.embed_url)}catch(a){const d=a instanceof Error?a.message:"Invalid embed URL.";i("server",d);return}f("epnc:create-succeeded",{embed_url:t.embed_url,file_id:typeof t.file_id=="number"?t.file_id:null,pad_id:typeof t.pad_id=="string"?t.pad_id:"",access_mode:typeof t.access_mode=="string"?t.access_mode:""}),window.location.replace(p)})()})(); //# sourceMappingURL=etherpad_nextcloud-embed-create-main.mjs.map diff --git a/js/etherpad_nextcloud-embed-create-main.mjs.map b/js/etherpad_nextcloud-embed-create-main.mjs.map index 5b8deafd..15b2dfc0 100644 --- a/js/etherpad_nextcloud-embed-create-main.mjs.map +++ b/js/etherpad_nextcloud-embed-create-main.mjs.map @@ -1 +1 @@ -{"version":3,"file":"etherpad_nextcloud-embed-create-main.mjs","sources":["../src/embed-create-main.js"],"sourcesContent":["/**\n * SPDX-License-Identifier: AGPL-3.0-or-later\n * Copyright (c) 2026 Jacob Bühler\n */\nimport { DEFAULT_PAD_ACCESS_MODE, isPadAccessMode } from './lib/constants.js'\nimport { ocRequestToken } from './lib/oc-compat.js'\nimport { fetchJsonWithTimeout as fetchJson } from './lib/fetch-helpers.js'\n\n(function () {\n\tconst root = document.getElementById('etherpad-nextcloud-embed-create')\n\tif (!(root instanceof HTMLElement)) {\n\t\treturn\n\t}\n\n\tconst parentFolderId = Number(root.getAttribute('data-parent-folder-id') || '')\n\tconst createByParentUrl = String(root.getAttribute('data-create-by-parent-url') || '').trim()\n\tconst templateRequestToken = String(root.getAttribute('data-request-token') || '').trim()\n\tconst missingNameMessage = String(root.getAttribute('data-l10n-missing-name') || 'Pad name is required.')\n\tconst invalidAccessModeMessage = String(root.getAttribute('data-l10n-invalid-access-mode') || 'Invalid access mode.')\n\tconst incompleteConfigMessage = String(root.getAttribute('data-l10n-incomplete-config') || 'Embed configuration is incomplete.')\n\tconst loadingNode = root.querySelector('[data-epnc-embed-create-loading]')\n\tconst errorNode = root.querySelector('[data-epnc-embed-create-error]')\n\tconst errorMessageNode = root.querySelector('[data-epnc-embed-create-error-message]')\n\n\tconst requestToken = () => ocRequestToken(templateRequestToken)\n\n\t/**\n\t * Post an `epnc:*` event to the host page that's embedding this iframe.\n\t *\n\t * Target-origin is `*` rather than a specific origin because the create\n\t * page doesn't know the host's origin up-front (the host hasn't talked to\n\t * us yet). The actual access control happens at iframe-load time via the\n\t * route's CSP `frame-ancestors` header, which only lists the admin-\n\t * configured `trusted_embed_origins`. Anyone receiving these messages is\n\t * by construction already in that allowlist.\n\t *\n\t * No-ops if we're not actually embedded (window.parent === window).\n\t */\n\tconst postHostMessage = (type, payload) => {\n\t\tif (window.parent === window) {\n\t\t\treturn\n\t\t}\n\t\ttry {\n\t\t\twindow.parent.postMessage(Object.assign({ type }, payload || {}), '*')\n\t\t} catch (e) {\n\t\t\t// Posting can throw on certain cross-origin / cross-process boundaries;\n\t\t\t// inline error rendering is the user-visible fallback, so the message\n\t\t\t// is purely advisory for the host.\n\t\t}\n\t}\n\n\tconst showError = (message) => {\n\t\tif (loadingNode instanceof HTMLElement) {\n\t\t\tloadingNode.hidden = true\n\t\t}\n\t\tif (errorMessageNode instanceof HTMLElement) {\n\t\t\terrorMessageNode.textContent = String(message || 'Unknown error.')\n\t\t}\n\t\tif (errorNode instanceof HTMLElement) {\n\t\t\terrorNode.hidden = false\n\t\t}\n\t}\n\n\t/**\n\t * Emit a structured `epnc:create-failed` event AND render the inline error.\n\t * The message is normalised once so the host's payload and the user-facing\n\t * inline message never drift (an empty/undefined `message` would otherwise\n\t * land in the iframe as \"Unknown error.\" but in the postMessage payload as\n\t * the empty string).\n\t *\n\t * `reason` is a coarse bucket so hosts can branch without parsing the\n\t * HTTP status:\n\t * - 'invalid' — client-side validation failed (missing name, etc.)\n\t * - 'conflict' — backend returned 409 (e.g. duplicate filename)\n\t * - 'server' — any other 4xx / 5xx\n\t * - 'network' — fetch itself failed (offline, CORS, timeout)\n\t */\n\tconst failCreate = (reason, message, status) => {\n\t\tconst normalizedMessage = String(message || 'Unknown error.')\n\t\tshowError(normalizedMessage)\n\t\tpostHostMessage('epnc:create-failed', {\n\t\t\treason,\n\t\t\tstatus: typeof status === 'number' ? status : null,\n\t\t\tmessage: normalizedMessage,\n\t\t})\n\t}\n\n\tconst readLauncherParams = () => {\n\t\tconst params = new URL(window.location.href).searchParams\n\t\treturn {\n\t\t\tname: String(params.get('name') || '').trim(),\n\t\t\taccessMode: String(params.get('accessMode') || DEFAULT_PAD_ACCESS_MODE).trim(),\n\t\t}\n\t}\n\n\tconst normalizeEmbedRedirectUrl = (value) => {\n\t\tconst url = new URL(String(value || '').trim(), window.location.origin)\n\t\tif (url.origin !== window.location.origin) {\n\t\t\tthrow new Error('Invalid embed URL origin.')\n\t\t}\n\t\treturn url.pathname + url.search + url.hash\n\t}\n\n\tconst classifyHttpStatus = (status) => {\n\t\tif (status === 409) {\n\t\t\treturn 'conflict'\n\t\t}\n\t\treturn 'server'\n\t}\n\n\tconst run = async () => {\n\t\tif (!Number.isFinite(parentFolderId) || parentFolderId <= 0 || createByParentUrl === '') {\n\t\t\tfailCreate('invalid', incompleteConfigMessage)\n\t\t\treturn\n\t\t}\n\t\tif (requestToken() === '') {\n\t\t\tfailCreate('invalid', 'CSRF request token is missing.')\n\t\t\treturn\n\t\t}\n\n\t\tconst { name, accessMode } = readLauncherParams()\n\t\tif (name === '') {\n\t\t\tfailCreate('invalid', missingNameMessage)\n\t\t\treturn\n\t\t}\n\t\tif (!isPadAccessMode(accessMode)) {\n\t\t\tfailCreate('invalid', invalidAccessModeMessage)\n\t\t\treturn\n\t\t}\n\n\t\tconst body = new URLSearchParams()\n\t\tbody.set('parentFolderId', String(parentFolderId))\n\t\tbody.set('name', name)\n\t\tbody.set('accessMode', accessMode)\n\n\t\t// Step 1: server-side create. Failures here are either network\n\t\t// (fetch threw — no HTTP status reached us) or server (we got a\n\t\t// status code back, including the 409 on duplicate filename).\n\t\tlet data\n\t\ttry {\n\t\t\tdata = await fetchJson(createByParentUrl, {\n\t\t\t\tmethod: 'POST',\n\t\t\t\theaders: {\n\t\t\t\t\t'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',\n\t\t\t\t\trequesttoken: requestToken(),\n\t\t\t\t},\n\t\t\t\tbody: body.toString(),\n\t\t\t})\n\t\t} catch (error) {\n\t\t\tconst status = (error && typeof error.status === 'number') ? error.status : null\n\t\t\tconst message = error instanceof Error ? error.message : 'Pad creation failed.'\n\t\t\tconst reason = status === null ? 'network' : classifyHttpStatus(status)\n\t\t\tfailCreate(reason, message, status)\n\t\t\treturn\n\t\t}\n\n\t\t// Step 2: validate the server's response shape *and* the redirect\n\t\t// target before emitting success. A malformed or cross-origin\n\t\t// embed_url is a server-side bug, not a network failure — and we\n\t\t// must not announce success only to then announce failure to the\n\t\t// same host listener, which would leave them with contradictory\n\t\t// signals.\n\t\tif (!data || typeof data.embed_url !== 'string' || data.embed_url.trim() === '') {\n\t\t\tfailCreate('server', 'Pad creation API did not return a valid embed URL.')\n\t\t\treturn\n\t\t}\n\t\tlet redirectTarget\n\t\ttry {\n\t\t\tredirectTarget = normalizeEmbedRedirectUrl(data.embed_url)\n\t\t} catch (error) {\n\t\t\tconst message = error instanceof Error ? error.message : 'Invalid embed URL.'\n\t\t\tfailCreate('server', message)\n\t\t\treturn\n\t\t}\n\n\t\t// Step 3: announce success once everything is definitively OK, then\n\t\t// navigate. Notify *before* the redirect: once we replace the iframe\n\t\t// location the host loses its handle on this script.\n\t\tpostHostMessage('epnc:create-succeeded', {\n\t\t\tembed_url: data.embed_url,\n\t\t\tfile_id: typeof data.file_id === 'number' ? data.file_id : null,\n\t\t\tpad_id: typeof data.pad_id === 'string' ? data.pad_id : '',\n\t\t\taccess_mode: typeof data.access_mode === 'string' ? data.access_mode : '',\n\t\t})\n\t\twindow.location.replace(redirectTarget)\n\t}\n\n\tvoid run()\n})()\n"],"names":["root","parentFolderId","createByParentUrl","templateRequestToken","missingNameMessage","invalidAccessModeMessage","incompleteConfigMessage","loadingNode","errorNode","errorMessageNode","requestToken","ocRequestToken","postHostMessage","type","payload","showError","message","failCreate","reason","status","normalizedMessage","readLauncherParams","params","DEFAULT_PAD_ACCESS_MODE","normalizeEmbedRedirectUrl","value","url","classifyHttpStatus","name","accessMode","isPadAccessMode","body","data","fetchJson","error","redirectTarget"],"mappings":"6EAQC,UAAY,CACZ,MAAMA,EAAO,SAAS,eAAe,iCAAiC,EACtE,GAAI,EAAEA,aAAgB,aACrB,OAGD,MAAMC,EAAiB,OAAOD,EAAK,aAAa,uBAAuB,GAAK,EAAE,EACxEE,EAAoB,OAAOF,EAAK,aAAa,2BAA2B,GAAK,EAAE,EAAE,KAAI,EACrFG,EAAuB,OAAOH,EAAK,aAAa,oBAAoB,GAAK,EAAE,EAAE,KAAI,EACjFI,EAAqB,OAAOJ,EAAK,aAAa,wBAAwB,GAAK,uBAAuB,EAClGK,EAA2B,OAAOL,EAAK,aAAa,+BAA+B,GAAK,sBAAsB,EAC9GM,EAA0B,OAAON,EAAK,aAAa,6BAA6B,GAAK,oCAAoC,EACzHO,EAAcP,EAAK,cAAc,kCAAkC,EACnEQ,EAAYR,EAAK,cAAc,gCAAgC,EAC/DS,EAAmBT,EAAK,cAAc,wCAAwC,EAE9EU,EAAe,IAAMC,EAAeR,CAAoB,EAcxDS,EAAkB,CAACC,EAAMC,IAAY,CAC1C,GAAI,OAAO,SAAW,OAGtB,GAAI,CACH,OAAO,OAAO,YAAY,OAAO,OAAO,CAAE,KAAAD,GAAQC,GAAW,CAAA,CAAE,EAAG,GAAG,CACtE,MAAY,CAIZ,CACD,EAEMC,EAAaC,GAAY,CAC1BT,aAAuB,cAC1BA,EAAY,OAAS,IAElBE,aAA4B,cAC/BA,EAAiB,YAAc,OAAOO,GAAW,gBAAgB,GAE9DR,aAAqB,cACxBA,EAAU,OAAS,GAErB,EAgBMS,EAAa,CAACC,EAAQF,EAASG,IAAW,CAC/C,MAAMC,EAAoB,OAAOJ,GAAW,gBAAgB,EAC5DD,EAAUK,CAAiB,EAC3BR,EAAgB,qBAAsB,CACrC,OAAAM,EACA,OAAQ,OAAOC,GAAW,SAAWA,EAAS,KAC9C,QAASC,CACZ,CAAG,CACF,EAEMC,EAAqB,IAAM,CAChC,MAAMC,EAAS,IAAI,IAAI,OAAO,SAAS,IAAI,EAAE,aAC7C,MAAO,CACN,KAAM,OAAOA,EAAO,IAAI,MAAM,GAAK,EAAE,EAAE,KAAI,EAC3C,WAAY,OAAOA,EAAO,IAAI,YAAY,GAAKC,CAAuB,EAAE,KAAI,CAC/E,CACC,EAEMC,EAA6BC,GAAU,CAC5C,MAAMC,EAAM,IAAI,IAAI,OAAOD,GAAS,EAAE,EAAE,KAAI,EAAI,OAAO,SAAS,MAAM,EACtE,GAAIC,EAAI,SAAW,OAAO,SAAS,OAClC,MAAM,IAAI,MAAM,2BAA2B,EAE5C,OAAOA,EAAI,SAAWA,EAAI,OAASA,EAAI,IACxC,EAEMC,EAAsBR,GACvBA,IAAW,IACP,WAED,UAGI,SAAY,CACvB,GAAI,CAAC,OAAO,SAASlB,CAAc,GAAKA,GAAkB,GAAKC,IAAsB,GAAI,CACxFe,EAAW,UAAWX,CAAuB,EAC7C,MACD,CACA,GAAII,EAAY,IAAO,GAAI,CAC1BO,EAAW,UAAW,gCAAgC,EACtD,MACD,CAEA,KAAM,CAAE,KAAAW,EAAM,WAAAC,CAAU,EAAKR,EAAkB,EAC/C,GAAIO,IAAS,GAAI,CAChBX,EAAW,UAAWb,CAAkB,EACxC,MACD,CACA,GAAI,CAAC0B,EAAgBD,CAAU,EAAG,CACjCZ,EAAW,UAAWZ,CAAwB,EAC9C,MACD,CAEA,MAAM0B,EAAO,IAAI,gBACjBA,EAAK,IAAI,iBAAkB,OAAO9B,CAAc,CAAC,EACjD8B,EAAK,IAAI,OAAQH,CAAI,EACrBG,EAAK,IAAI,aAAcF,CAAU,EAKjC,IAAIG,EACJ,GAAI,CACHA,EAAO,MAAMC,EAAU/B,EAAmB,CACzC,OAAQ,OACR,QAAS,CACR,eAAgB,kDAChB,aAAcQ,EAAY,CAC/B,EACI,KAAMqB,EAAK,SAAQ,CACvB,CAAI,CACF,OAASG,EAAO,CACf,MAAMf,EAAUe,GAAS,OAAOA,EAAM,QAAW,SAAYA,EAAM,OAAS,KACtElB,EAAUkB,aAAiB,MAAQA,EAAM,QAAU,uBACnDhB,EAASC,IAAW,KAAO,UAAYQ,EAAmBR,CAAM,EACtEF,EAAWC,EAAQF,EAASG,CAAM,EAClC,MACD,CAQA,GAAI,CAACa,GAAQ,OAAOA,EAAK,WAAc,UAAYA,EAAK,UAAU,KAAI,IAAO,GAAI,CAChFf,EAAW,SAAU,oDAAoD,EACzE,MACD,CACA,IAAIkB,EACJ,GAAI,CACHA,EAAiBX,EAA0BQ,EAAK,SAAS,CAC1D,OAASE,EAAO,CACf,MAAMlB,EAAUkB,aAAiB,MAAQA,EAAM,QAAU,qBACzDjB,EAAW,SAAUD,CAAO,EAC5B,MACD,CAKAJ,EAAgB,wBAAyB,CACxC,UAAWoB,EAAK,UAChB,QAAS,OAAOA,EAAK,SAAY,SAAWA,EAAK,QAAU,KAC3D,OAAQ,OAAOA,EAAK,QAAW,SAAWA,EAAK,OAAS,GACxD,YAAa,OAAOA,EAAK,aAAgB,SAAWA,EAAK,YAAc,EAC1E,CAAG,EACD,OAAO,SAAS,QAAQG,CAAc,CACvC,GAEQ,CACT,GAAC"} \ No newline at end of file +{"version":3,"file":"etherpad_nextcloud-embed-create-main.mjs","sources":["../src/embed-create-main.js"],"sourcesContent":["/**\n * SPDX-License-Identifier: AGPL-3.0-or-later\n * Copyright (c) 2026 Jacob Bühler\n */\nimport { DEFAULT_PAD_ACCESS_MODE, isPadAccessMode } from './lib/constants.js'\nimport { ocRequestToken } from './lib/oc-compat.js'\nimport { fetchJsonWithTimeout as fetchJson, requestErrorMessage } from './lib/fetch-helpers.js'\n\n(function () {\n\tconst root = document.getElementById('etherpad-nextcloud-embed-create')\n\tif (!(root instanceof HTMLElement)) {\n\t\treturn\n\t}\n\n\tconst parentFolderId = Number(root.getAttribute('data-parent-folder-id') || '')\n\tconst createByParentUrl = String(root.getAttribute('data-create-by-parent-url') || '').trim()\n\tconst templateRequestToken = String(root.getAttribute('data-request-token') || '').trim()\n\tconst missingNameMessage = String(root.getAttribute('data-l10n-missing-name') || 'Pad name is required.')\n\tconst invalidAccessModeMessage = String(root.getAttribute('data-l10n-invalid-access-mode') || 'Invalid access mode.')\n\tconst incompleteConfigMessage = String(root.getAttribute('data-l10n-incomplete-config') || 'Embed configuration is incomplete.')\n\tconst unansweredMessage = String(root.getAttribute('data-l10n-unanswered') || 'Nextcloud did not answer. The pad may have been created anyway; look in the folder before you try again.')\n\tconst loadingNode = root.querySelector('[data-epnc-embed-create-loading]')\n\tconst errorNode = root.querySelector('[data-epnc-embed-create-error]')\n\tconst errorMessageNode = root.querySelector('[data-epnc-embed-create-error-message]')\n\n\tconst requestToken = () => ocRequestToken(templateRequestToken)\n\n\t/**\n\t * Post an `epnc:*` event to the host page that's embedding this iframe.\n\t *\n\t * Target-origin is `*` rather than a specific origin because the create\n\t * page doesn't know the host's origin up-front (the host hasn't talked to\n\t * us yet). The actual access control happens at iframe-load time via the\n\t * route's CSP `frame-ancestors` header, which only lists the admin-\n\t * configured `trusted_embed_origins`. Anyone receiving these messages is\n\t * by construction already in that allowlist.\n\t *\n\t * No-ops if we're not actually embedded (window.parent === window).\n\t */\n\tconst postHostMessage = (type, payload) => {\n\t\tif (window.parent === window) {\n\t\t\treturn\n\t\t}\n\t\ttry {\n\t\t\twindow.parent.postMessage(Object.assign({ type }, payload || {}), '*')\n\t\t} catch (e) {\n\t\t\t// Posting can throw on certain cross-origin / cross-process boundaries;\n\t\t\t// inline error rendering is the user-visible fallback, so the message\n\t\t\t// is purely advisory for the host.\n\t\t}\n\t}\n\n\tconst showError = (message) => {\n\t\tif (loadingNode instanceof HTMLElement) {\n\t\t\tloadingNode.hidden = true\n\t\t}\n\t\tif (errorMessageNode instanceof HTMLElement) {\n\t\t\terrorMessageNode.textContent = String(message || 'Unknown error.')\n\t\t}\n\t\tif (errorNode instanceof HTMLElement) {\n\t\t\terrorNode.hidden = false\n\t\t}\n\t}\n\n\t/**\n\t * Emit a structured `epnc:create-failed` event AND render the inline error.\n\t * The message is normalised once so the host's payload and the user-facing\n\t * inline message never drift (an empty/undefined `message` would otherwise\n\t * land in the iframe as \"Unknown error.\" but in the postMessage payload as\n\t * the empty string).\n\t *\n\t * `reason` is a coarse bucket so hosts can branch without parsing the\n\t * HTTP status:\n\t * - 'invalid' — client-side validation failed (missing name, etc.)\n\t * - 'conflict' — backend returned 409 (e.g. duplicate filename)\n\t * - 'server' — any other 4xx / 5xx this app answered; nothing was\n\t * created, the server rolls a failed create back\n\t * - 'network' — no answer from this app: fetch failed, or a proxy\n\t * answered in its place (`status` is then its 502, 503 or 504).\n\t * The pad may have been created anyway.\n\t */\n\tconst failCreate = (reason, message, status) => {\n\t\tconst normalizedMessage = String(message || 'Unknown error.')\n\t\tshowError(normalizedMessage)\n\t\tpostHostMessage('epnc:create-failed', {\n\t\t\treason,\n\t\t\tstatus: typeof status === 'number' ? status : null,\n\t\t\tmessage: normalizedMessage,\n\t\t})\n\t}\n\n\tconst readLauncherParams = () => {\n\t\tconst params = new URL(window.location.href).searchParams\n\t\treturn {\n\t\t\tname: String(params.get('name') || '').trim(),\n\t\t\taccessMode: String(params.get('accessMode') || DEFAULT_PAD_ACCESS_MODE).trim(),\n\t\t}\n\t}\n\n\tconst normalizeEmbedRedirectUrl = (value) => {\n\t\tconst url = new URL(String(value || '').trim(), window.location.origin)\n\t\tif (url.origin !== window.location.origin) {\n\t\t\tthrow new Error('Invalid embed URL origin.')\n\t\t}\n\t\treturn url.pathname + url.search + url.hash\n\t}\n\n\tconst classifyHttpStatus = (status) => {\n\t\tif (status === 409) {\n\t\t\treturn 'conflict'\n\t\t}\n\t\treturn 'server'\n\t}\n\n\tconst run = async () => {\n\t\tif (!Number.isFinite(parentFolderId) || parentFolderId <= 0 || createByParentUrl === '') {\n\t\t\tfailCreate('invalid', incompleteConfigMessage)\n\t\t\treturn\n\t\t}\n\t\tif (requestToken() === '') {\n\t\t\tfailCreate('invalid', 'CSRF request token is missing.')\n\t\t\treturn\n\t\t}\n\n\t\tconst { name, accessMode } = readLauncherParams()\n\t\tif (name === '') {\n\t\t\tfailCreate('invalid', missingNameMessage)\n\t\t\treturn\n\t\t}\n\t\tif (!isPadAccessMode(accessMode)) {\n\t\t\tfailCreate('invalid', invalidAccessModeMessage)\n\t\t\treturn\n\t\t}\n\n\t\tconst body = new URLSearchParams()\n\t\tbody.set('parentFolderId', String(parentFolderId))\n\t\tbody.set('name', name)\n\t\tbody.set('accessMode', accessMode)\n\n\t\t// Step 1: server-side create. A write, so it has no time limit (see\n\t\t// fetchJsonWithTimeout()): cut short, it would go on creating with\n\t\t// nobody told, and a second try would meet the file it made. A slow\n\t\t// create is not a failed one.\n\t\tlet data\n\t\ttry {\n\t\t\tdata = await fetchJson(createByParentUrl, {\n\t\t\t\tmethod: 'POST',\n\t\t\t\theaders: {\n\t\t\t\t\t'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',\n\t\t\t\t\trequesttoken: requestToken(),\n\t\t\t\t},\n\t\t\t\tbody: body.toString(),\n\t\t\t}, { timeoutMs: null })\n\t\t} catch (error) {\n\t\t\tconst status = (error && typeof error.status === 'number') ? error.status : null\n\t\t\t// This app answered, including the 409 on a duplicate name, or\n\t\t\t// nothing came back from it and the outcome is not known.\n\t\t\tconst reason = status === null || (error && error.unanswered === true) ? 'network' : classifyHttpStatus(status)\n\t\t\tfailCreate(reason, requestErrorMessage(error, unansweredMessage, 'Pad creation failed.'), status)\n\t\t\treturn\n\t\t}\n\n\t\t// Step 2: validate the server's response shape *and* the redirect\n\t\t// target before emitting success. A malformed or cross-origin\n\t\t// embed_url is a server-side bug, not a network failure — and we\n\t\t// must not announce success only to then announce failure to the\n\t\t// same host listener, which would leave them with contradictory\n\t\t// signals.\n\t\tif (!data || typeof data.embed_url !== 'string' || data.embed_url.trim() === '') {\n\t\t\tfailCreate('server', 'Pad creation API did not return a valid embed URL.')\n\t\t\treturn\n\t\t}\n\t\tlet redirectTarget\n\t\ttry {\n\t\t\tredirectTarget = normalizeEmbedRedirectUrl(data.embed_url)\n\t\t} catch (error) {\n\t\t\tconst message = error instanceof Error ? error.message : 'Invalid embed URL.'\n\t\t\tfailCreate('server', message)\n\t\t\treturn\n\t\t}\n\n\t\t// Step 3: announce success once everything is definitively OK, then\n\t\t// navigate. Notify *before* the redirect: once we replace the iframe\n\t\t// location the host loses its handle on this script.\n\t\tpostHostMessage('epnc:create-succeeded', {\n\t\t\tembed_url: data.embed_url,\n\t\t\tfile_id: typeof data.file_id === 'number' ? data.file_id : null,\n\t\t\tpad_id: typeof data.pad_id === 'string' ? data.pad_id : '',\n\t\t\taccess_mode: typeof data.access_mode === 'string' ? data.access_mode : '',\n\t\t})\n\t\twindow.location.replace(redirectTarget)\n\t}\n\n\tvoid run()\n})()\n"],"names":["root","parentFolderId","createByParentUrl","templateRequestToken","missingNameMessage","invalidAccessModeMessage","incompleteConfigMessage","unansweredMessage","loadingNode","errorNode","errorMessageNode","requestToken","ocRequestToken","postHostMessage","type","payload","showError","message","failCreate","reason","status","normalizedMessage","readLauncherParams","params","DEFAULT_PAD_ACCESS_MODE","normalizeEmbedRedirectUrl","value","url","classifyHttpStatus","name","accessMode","isPadAccessMode","body","data","fetchJson","error","requestErrorMessage","redirectTarget"],"mappings":"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"} \ No newline at end of file diff --git a/l10n/de.js b/l10n/de.js index 1ff4feb1..22bafa7b 100644 --- a/l10n/de.js +++ b/l10n/de.js @@ -118,6 +118,7 @@ OC.L10N.register( "New pad": "Neues Pad", "Nextcloud and Etherpad share no parent domain, so the browser cannot send the session cookie to Etherpad and protected pads will not open. If a proxy exposes Etherpad under the Nextcloud domain, use that address as the base URL. Otherwise, place both services under a shared parent domain or switch protected pads off.": "Nextcloud und Etherpad haben keine gemeinsame übergeordnete Domain. Der Browser kann das Sitzungscookie deshalb nicht an Etherpad senden, und geschützte Pads lassen sich nicht öffnen. Wenn ein Proxy Etherpad unter der Nextcloud-Domain bereitstellt, trage diese Adresse als Basis-URL ein. Andernfalls betreibe beide Dienste unter einer gemeinsamen übergeordneten Domain oder schalte geschützte Pads ab.", "Nextcloud did not answer. Check your connection and try again.": "Nextcloud hat nicht geantwortet. Prüfe deine Verbindung und versuche es erneut.", + "Nextcloud did not answer. The pad may have been created anyway; look in the folder before you try again.": "Nextcloud hat nicht geantwortet. Das Pad wurde vielleicht trotzdem angelegt; sieh im Ordner nach, bevor du es erneut versuchst.", "Nextcloud runs on {nextcloud_host}, Etherpad on {etherpad_host}.": "Nextcloud läuft auf {nextcloud_host}, Etherpad auf {etherpad_host}.", "No .pad file selected. Open a .pad file from this shared folder.": "Keine .pad-Datei ausgewählt. Öffne eine .pad-Datei aus diesem freigegebenen Ordner.", "No shared templates yet.": "Noch keine geteilten Vorlagen.", diff --git a/l10n/de.json b/l10n/de.json index f52dfe49..c26f0209 100644 --- a/l10n/de.json +++ b/l10n/de.json @@ -117,6 +117,7 @@ "New pad": "Neues Pad", "Nextcloud and Etherpad share no parent domain, so the browser cannot send the session cookie to Etherpad and protected pads will not open. If a proxy exposes Etherpad under the Nextcloud domain, use that address as the base URL. Otherwise, place both services under a shared parent domain or switch protected pads off.": "Nextcloud und Etherpad haben keine gemeinsame übergeordnete Domain. Der Browser kann das Sitzungscookie deshalb nicht an Etherpad senden, und geschützte Pads lassen sich nicht öffnen. Wenn ein Proxy Etherpad unter der Nextcloud-Domain bereitstellt, trage diese Adresse als Basis-URL ein. Andernfalls betreibe beide Dienste unter einer gemeinsamen übergeordneten Domain oder schalte geschützte Pads ab.", "Nextcloud did not answer. Check your connection and try again.": "Nextcloud hat nicht geantwortet. Prüfe deine Verbindung und versuche es erneut.", + "Nextcloud did not answer. The pad may have been created anyway; look in the folder before you try again.": "Nextcloud hat nicht geantwortet. Das Pad wurde vielleicht trotzdem angelegt; sieh im Ordner nach, bevor du es erneut versuchst.", "Nextcloud runs on {nextcloud_host}, Etherpad on {etherpad_host}.": "Nextcloud läuft auf {nextcloud_host}, Etherpad auf {etherpad_host}.", "No .pad file selected. Open a .pad file from this shared folder.": "Keine .pad-Datei ausgewählt. Öffne eine .pad-Datei aus diesem freigegebenen Ordner.", "No shared templates yet.": "Noch keine geteilten Vorlagen.", diff --git a/l10n/es.js b/l10n/es.js index 2759bf81..fcc10b03 100644 --- a/l10n/es.js +++ b/l10n/es.js @@ -118,6 +118,7 @@ OC.L10N.register( "New pad": "Nuevo pad", "Nextcloud and Etherpad share no parent domain, so the browser cannot send the session cookie to Etherpad and protected pads will not open. If a proxy exposes Etherpad under the Nextcloud domain, use that address as the base URL. Otherwise, place both services under a shared parent domain or switch protected pads off.": "Nextcloud y Etherpad no comparten ningún dominio padre común, así que el navegador no puede enviar la cookie de sesión a Etherpad y los pads protegidos no se abrirán. Si un proxy expone Etherpad bajo el dominio de Nextcloud, usa esa dirección como URL base. En caso contrario, sitúa ambos servicios bajo un dominio padre común o desactiva los pads protegidos.", "Nextcloud did not answer. Check your connection and try again.": "Nextcloud no ha respondido. Comprueba tu conexión y vuelve a intentarlo.", + "Nextcloud did not answer. The pad may have been created anyway; look in the folder before you try again.": "Nextcloud no ha respondido. Puede que el pad se haya creado de todos modos; mira en la carpeta antes de volver a intentarlo.", "Nextcloud runs on {nextcloud_host}, Etherpad on {etherpad_host}.": "Nextcloud se ejecuta en {nextcloud_host} y Etherpad en {etherpad_host}.", "No .pad file selected. Open a .pad file from this shared folder.": "No se ha seleccionado ningún archivo .pad. Abre un archivo .pad de esta carpeta compartida.", "No shared templates yet.": "Todavía no hay plantillas compartidas.", diff --git a/l10n/es.json b/l10n/es.json index d65f8604..8342858b 100644 --- a/l10n/es.json +++ b/l10n/es.json @@ -117,6 +117,7 @@ "New pad": "Nuevo pad", "Nextcloud and Etherpad share no parent domain, so the browser cannot send the session cookie to Etherpad and protected pads will not open. If a proxy exposes Etherpad under the Nextcloud domain, use that address as the base URL. Otherwise, place both services under a shared parent domain or switch protected pads off.": "Nextcloud y Etherpad no comparten ningún dominio padre común, así que el navegador no puede enviar la cookie de sesión a Etherpad y los pads protegidos no se abrirán. Si un proxy expone Etherpad bajo el dominio de Nextcloud, usa esa dirección como URL base. En caso contrario, sitúa ambos servicios bajo un dominio padre común o desactiva los pads protegidos.", "Nextcloud did not answer. Check your connection and try again.": "Nextcloud no ha respondido. Comprueba tu conexión y vuelve a intentarlo.", + "Nextcloud did not answer. The pad may have been created anyway; look in the folder before you try again.": "Nextcloud no ha respondido. Puede que el pad se haya creado de todos modos; mira en la carpeta antes de volver a intentarlo.", "Nextcloud runs on {nextcloud_host}, Etherpad on {etherpad_host}.": "Nextcloud se ejecuta en {nextcloud_host} y Etherpad en {etherpad_host}.", "No .pad file selected. Open a .pad file from this shared folder.": "No se ha seleccionado ningún archivo .pad. Abre un archivo .pad de esta carpeta compartida.", "No shared templates yet.": "Todavía no hay plantillas compartidas.", diff --git a/l10n/fr.js b/l10n/fr.js index f7ff47fe..d8a77695 100644 --- a/l10n/fr.js +++ b/l10n/fr.js @@ -118,6 +118,7 @@ OC.L10N.register( "New pad": "Nouveau pad", "Nextcloud and Etherpad share no parent domain, so the browser cannot send the session cookie to Etherpad and protected pads will not open. If a proxy exposes Etherpad under the Nextcloud domain, use that address as the base URL. Otherwise, place both services under a shared parent domain or switch protected pads off.": "Nextcloud et Etherpad ne partagent aucun domaine parent : le navigateur ne peut donc pas envoyer le cookie de session à Etherpad, et les pads protégés ne s'ouvriront pas. Si un proxy expose Etherpad sous le domaine Nextcloud, utilisez cette adresse comme URL de base. Sinon, placez les deux services sous un domaine parent commun ou désactivez les pads protégés.", "Nextcloud did not answer. Check your connection and try again.": "Nextcloud n'a pas répondu. Vérifiez votre connexion et réessayez.", + "Nextcloud did not answer. The pad may have been created anyway; look in the folder before you try again.": "Nextcloud n'a pas répondu. Le pad a peut-être été créé malgré tout ; vérifiez le dossier avant de réessayer.", "Nextcloud runs on {nextcloud_host}, Etherpad on {etherpad_host}.": "Nextcloud tourne sur {nextcloud_host}, Etherpad sur {etherpad_host}.", "No .pad file selected. Open a .pad file from this shared folder.": "Aucun fichier .pad sélectionné. Ouvrez un fichier .pad de ce dossier partagé.", "No shared templates yet.": "Aucun modèle partagé pour le moment.", diff --git a/l10n/fr.json b/l10n/fr.json index 65c1ba0c..f3ba7fd0 100644 --- a/l10n/fr.json +++ b/l10n/fr.json @@ -117,6 +117,7 @@ "New pad": "Nouveau pad", "Nextcloud and Etherpad share no parent domain, so the browser cannot send the session cookie to Etherpad and protected pads will not open. If a proxy exposes Etherpad under the Nextcloud domain, use that address as the base URL. Otherwise, place both services under a shared parent domain or switch protected pads off.": "Nextcloud et Etherpad ne partagent aucun domaine parent : le navigateur ne peut donc pas envoyer le cookie de session à Etherpad, et les pads protégés ne s'ouvriront pas. Si un proxy expose Etherpad sous le domaine Nextcloud, utilisez cette adresse comme URL de base. Sinon, placez les deux services sous un domaine parent commun ou désactivez les pads protégés.", "Nextcloud did not answer. Check your connection and try again.": "Nextcloud n'a pas répondu. Vérifiez votre connexion et réessayez.", + "Nextcloud did not answer. The pad may have been created anyway; look in the folder before you try again.": "Nextcloud n'a pas répondu. Le pad a peut-être été créé malgré tout ; vérifiez le dossier avant de réessayer.", "Nextcloud runs on {nextcloud_host}, Etherpad on {etherpad_host}.": "Nextcloud tourne sur {nextcloud_host}, Etherpad sur {etherpad_host}.", "No .pad file selected. Open a .pad file from this shared folder.": "Aucun fichier .pad sélectionné. Ouvrez un fichier .pad de ce dossier partagé.", "No shared templates yet.": "Aucun modèle partagé pour le moment.", diff --git a/l10n/it.js b/l10n/it.js index d49f76a9..2809f647 100644 --- a/l10n/it.js +++ b/l10n/it.js @@ -118,6 +118,7 @@ OC.L10N.register( "New pad": "Nuovo pad", "Nextcloud and Etherpad share no parent domain, so the browser cannot send the session cookie to Etherpad and protected pads will not open. If a proxy exposes Etherpad under the Nextcloud domain, use that address as the base URL. Otherwise, place both services under a shared parent domain or switch protected pads off.": "Nextcloud ed Etherpad non condividono alcun parent domain, quindi il browser non può inviare il cookie di sessione a Etherpad e i pad protetti non si apriranno. Se un proxy espone Etherpad sotto il dominio di Nextcloud, usa quell'indirizzo come URL base. Altrimenti colloca entrambi i servizi sotto un parent domain comune, oppure disattiva i pad protetti.", "Nextcloud did not answer. Check your connection and try again.": "Nextcloud non ha risposto. Controlla la connessione e riprova.", + "Nextcloud did not answer. The pad may have been created anyway; look in the folder before you try again.": "Nextcloud non ha risposto. Il pad potrebbe essere stato creato comunque; controlla la cartella prima di riprovare.", "Nextcloud runs on {nextcloud_host}, Etherpad on {etherpad_host}.": "Nextcloud è in esecuzione su {nextcloud_host}, Etherpad su {etherpad_host}.", "No .pad file selected. Open a .pad file from this shared folder.": "Nessun file .pad selezionato. Apri un file .pad da questa cartella condivisa.", "No shared templates yet.": "Non ci sono ancora modelli condivisi.", diff --git a/l10n/it.json b/l10n/it.json index f8218851..60902eb3 100644 --- a/l10n/it.json +++ b/l10n/it.json @@ -117,6 +117,7 @@ "New pad": "Nuovo pad", "Nextcloud and Etherpad share no parent domain, so the browser cannot send the session cookie to Etherpad and protected pads will not open. If a proxy exposes Etherpad under the Nextcloud domain, use that address as the base URL. Otherwise, place both services under a shared parent domain or switch protected pads off.": "Nextcloud ed Etherpad non condividono alcun parent domain, quindi il browser non può inviare il cookie di sessione a Etherpad e i pad protetti non si apriranno. Se un proxy espone Etherpad sotto il dominio di Nextcloud, usa quell'indirizzo come URL base. Altrimenti colloca entrambi i servizi sotto un parent domain comune, oppure disattiva i pad protetti.", "Nextcloud did not answer. Check your connection and try again.": "Nextcloud non ha risposto. Controlla la connessione e riprova.", + "Nextcloud did not answer. The pad may have been created anyway; look in the folder before you try again.": "Nextcloud non ha risposto. Il pad potrebbe essere stato creato comunque; controlla la cartella prima di riprovare.", "Nextcloud runs on {nextcloud_host}, Etherpad on {etherpad_host}.": "Nextcloud è in esecuzione su {nextcloud_host}, Etherpad su {etherpad_host}.", "No .pad file selected. Open a .pad file from this shared folder.": "Nessun file .pad selezionato. Apri un file .pad da questa cartella condivisa.", "No shared templates yet.": "Non ci sono ancora modelli condivisi.", diff --git a/lib/Controller/EmbedController.php b/lib/Controller/EmbedController.php index 0ae5c05f..94ebcf27 100644 --- a/lib/Controller/EmbedController.php +++ b/lib/Controller/EmbedController.php @@ -114,6 +114,7 @@ function () use ($parentFolderId): array { 'missing_name' => $this->l10n->t('Pad name is required.'), 'invalid_access_mode' => $this->l10n->t('Invalid access mode.'), 'incomplete_config' => $this->l10n->t('Embed configuration is incomplete.'), + 'unanswered' => $this->l10n->t('Nextcloud did not answer. The pad may have been created anyway; look in the folder before you try again.'), ], ]), errorTitle: $this->l10n->t('Could not create pad'), diff --git a/src/embed-create-main.js b/src/embed-create-main.js index 9316d79c..70f0e85a 100644 --- a/src/embed-create-main.js +++ b/src/embed-create-main.js @@ -4,7 +4,7 @@ */ import { DEFAULT_PAD_ACCESS_MODE, isPadAccessMode } from './lib/constants.js' import { ocRequestToken } from './lib/oc-compat.js' -import { fetchJsonWithTimeout as fetchJson } from './lib/fetch-helpers.js' +import { fetchJsonWithTimeout as fetchJson, requestErrorMessage } from './lib/fetch-helpers.js' (function () { const root = document.getElementById('etherpad-nextcloud-embed-create') @@ -18,6 +18,7 @@ import { fetchJsonWithTimeout as fetchJson } from './lib/fetch-helpers.js' const missingNameMessage = String(root.getAttribute('data-l10n-missing-name') || 'Pad name is required.') const invalidAccessModeMessage = String(root.getAttribute('data-l10n-invalid-access-mode') || 'Invalid access mode.') const incompleteConfigMessage = String(root.getAttribute('data-l10n-incomplete-config') || 'Embed configuration is incomplete.') + const unansweredMessage = String(root.getAttribute('data-l10n-unanswered') || 'Nextcloud did not answer. The pad may have been created anyway; look in the folder before you try again.') const loadingNode = root.querySelector('[data-epnc-embed-create-loading]') const errorNode = root.querySelector('[data-epnc-embed-create-error]') const errorMessageNode = root.querySelector('[data-epnc-embed-create-error-message]') @@ -72,8 +73,11 @@ import { fetchJsonWithTimeout as fetchJson } from './lib/fetch-helpers.js' * HTTP status: * - 'invalid' — client-side validation failed (missing name, etc.) * - 'conflict' — backend returned 409 (e.g. duplicate filename) - * - 'server' — any other 4xx / 5xx - * - 'network' — fetch itself failed (offline, CORS, timeout) + * - 'server' — any other 4xx / 5xx this app answered; nothing was + * created, the server rolls a failed create back + * - 'network' — no answer from this app: fetch failed, or a proxy + * answered in its place (`status` is then its 502, 503 or 504). + * The pad may have been created anyway. */ const failCreate = (reason, message, status) => { const normalizedMessage = String(message || 'Unknown error.') @@ -133,9 +137,10 @@ import { fetchJsonWithTimeout as fetchJson } from './lib/fetch-helpers.js' body.set('name', name) body.set('accessMode', accessMode) - // Step 1: server-side create. Failures here are either network - // (fetch threw — no HTTP status reached us) or server (we got a - // status code back, including the 409 on duplicate filename). + // Step 1: server-side create. A write, so it has no time limit (see + // fetchJsonWithTimeout()): cut short, it would go on creating with + // nobody told, and a second try would meet the file it made. A slow + // create is not a failed one. let data try { data = await fetchJson(createByParentUrl, { @@ -145,12 +150,13 @@ import { fetchJsonWithTimeout as fetchJson } from './lib/fetch-helpers.js' requesttoken: requestToken(), }, body: body.toString(), - }) + }, { timeoutMs: null }) } catch (error) { const status = (error && typeof error.status === 'number') ? error.status : null - const message = error instanceof Error ? error.message : 'Pad creation failed.' - const reason = status === null ? 'network' : classifyHttpStatus(status) - failCreate(reason, message, status) + // This app answered, including the 409 on a duplicate name, or + // nothing came back from it and the outcome is not known. + const reason = status === null || (error && error.unanswered === true) ? 'network' : classifyHttpStatus(status) + failCreate(reason, requestErrorMessage(error, unansweredMessage, 'Pad creation failed.'), status) return } diff --git a/templates/embed-create.php b/templates/embed-create.php index a38bfd58..67079cb8 100644 --- a/templates/embed-create.php +++ b/templates/embed-create.php @@ -17,7 +17,8 @@ class="epnc-embed" data-l10n-error-title="" data-l10n-missing-name="" data-l10n-invalid-access-mode="" - data-l10n-incomplete-config=""> + data-l10n-incomplete-config="" + data-l10n-unanswered="">
diff --git a/tests/js/embed-create-main.test.js b/tests/js/embed-create-main.test.js index dea82d76..1128eade 100644 --- a/tests/js/embed-create-main.test.js +++ b/tests/js/embed-create-main.test.js @@ -16,7 +16,8 @@ const setupEmbedCreateDom = () => { data-request-token="csrf" data-l10n-missing-name="Pad name is required." data-l10n-invalid-access-mode="Invalid access mode." - data-l10n-incomplete-config="Embed configuration is incomplete."> + data-l10n-incomplete-config="Embed configuration is incomplete." + data-l10n-unanswered="No answer; look in the folder first.">
loading