An upload that arrives and cannot be read back
The note here said the media endpoint sends no `Access-Control-Allow-Origin`. It does send one -- for `https://irc.freeq.at`, and for no other origin asked. It is an allowlist, not an absence, and neither this build nor the deployed one is on it. Which changes what happens rather than only why. With no `Authorization` header the POST is a simple request, so nothing is preflighted and nothing is stopped: the picture uploads, and the answer naming it is withheld. A URL nobody can read is a picture nobody can see. `credentials: "include"` goes, having been the one part of this we chose. A request that sends no credentials can be allowed by a plain `*`; one that sends them obliges the server to name this origin specifically. The smaller ask is the one more likely to be granted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
4ef6dc8 parent: 8c5b241 modified
flutter/web/frq_host.js +21 -5 | @@ -90,10 +90,26 @@ | ||
| 90 | 90 | // freeq's media endpoint as the multipart form it wants. The URL that |
| 91 | 91 | // comes back goes in the line — that is how a picture travels on IRC. |
| 92 | 92 | // |
| 93 | - // The endpoint sends no `Access-Control-Allow-Origin`, so this is blocked | |
| 94 | - // by the browser from any origin but freeq's own. It is written the way it | |
| 95 | - // will work rather than left out: the same POST from the desktop build has | |
| 96 | - // no such limit, and one header on the server end is all this waits for. | |
| 93 | + // From a browser this is blocked, and the shape of the block is worth | |
| 94 | + // writing down because it is not the one it looks like. freeq does send | |
| 95 | + // CORS headers -- `vary: origin`, an allow-methods and an allow-headers -- | |
| 96 | + // and answers with `access-control-allow-origin: https://irc.freeq.at` for | |
| 97 | + // exactly one origin: its own. It is an allowlist, and we are not on it, | |
| 98 | + // for this build or the deployed one. Being added is somebody else's | |
| 99 | + // decision, the same one as the broker's `return_to` list. | |
| 100 | + // | |
| 101 | + // Sending no `Authorization` header is what makes it a *simple* request, | |
| 102 | + // which means no preflight -- so the POST is not stopped, only the answer | |
| 103 | + // is. The picture does upload; the URL naming it is withheld, and a URL | |
| 104 | + // nobody can read is a picture nobody can see. The endpoint wants no auth | |
| 105 | + // for a public upload (it says "No file provided", not "Unauthorized"), | |
| 106 | + // and no credentials are sent, which is deliberate: a request without | |
| 107 | + // them can be allowed by a plain `*`, where `credentials: "include"` | |
| 108 | + // would oblige the server to name this origin specifically. The smaller | |
| 109 | + // ask is the one more likely to be granted. | |
| 110 | + // | |
| 111 | + // Written the way it will work rather than left out -- the same POST from | |
| 112 | + // the desktop build has no such limit. | |
| 97 | 113 | function pickAndUpload(want) { |
| 98 | 114 | var input = document.createElement("input"); |
| 99 | 115 | input.type = "file"; |
| @@ -114,7 +130,7 @@ | ||
| 114 | 130 | if (want.channel) form.append("channel", want.channel); |
| 115 | 131 | form.append("file", file, file.name || "picture.png"); |
| 116 | 132 | fetch("https://" + want.host + "/api/v1/upload", |
| 117 | - {method: "POST", body: form, credentials: "include"}) | |
| 133 | + {method: "POST", body: form}) | |
| 118 | 134 | .then(function (r) { return r.text().then(function (t) { |
| 119 | 135 | return {ok: r.ok, status: r.status, body: t}; }); }) |
| 120 | 136 | .then(function (r) { |
| @@ -90,10 +90,26 @@ | |||
| 90 | // freeq's media endpoint as the multipart form it wants. The URL that | 90 | // freeq's media endpoint as the multipart form it wants. The URL that |
| 91 | // comes back goes in the line — that is how a picture travels on IRC. | 91 | // comes back goes in the line — that is how a picture travels on IRC. |
| 92 | // | 92 | // |
| 93 | - // The endpoint sends no `Access-Control-Allow-Origin`, so this is blocked | 93 | + // From a browser this is blocked, and the shape of the block is worth |
| 94 | - // by the browser from any origin but freeq's own. It is written the way it | 94 | + // writing down because it is not the one it looks like. freeq does send |
| 95 | - // will work rather than left out: the same POST from the desktop build has | 95 | + // CORS headers -- `vary: origin`, an allow-methods and an allow-headers -- |
| 96 | - // no such limit, and one header on the server end is all this waits for. | 96 | + // and answers with `access-control-allow-origin: https://irc.freeq.at` for |
| 97 | + // exactly one origin: its own. It is an allowlist, and we are not on it, | ||
| 98 | + // for this build or the deployed one. Being added is somebody else's | ||
| 99 | + // decision, the same one as the broker's `return_to` list. | ||
| 100 | + // | ||
| 101 | + // Sending no `Authorization` header is what makes it a *simple* request, | ||
| 102 | + // which means no preflight -- so the POST is not stopped, only the answer | ||
| 103 | + // is. The picture does upload; the URL naming it is withheld, and a URL | ||
| 104 | + // nobody can read is a picture nobody can see. The endpoint wants no auth | ||
| 105 | + // for a public upload (it says "No file provided", not "Unauthorized"), | ||
| 106 | + // and no credentials are sent, which is deliberate: a request without | ||
| 107 | + // them can be allowed by a plain `*`, where `credentials: "include"` | ||
| 108 | + // would oblige the server to name this origin specifically. The smaller | ||
| 109 | + // ask is the one more likely to be granted. | ||
| 110 | + // | ||
| 111 | + // Written the way it will work rather than left out -- the same POST from | ||
| 112 | + // the desktop build has no such limit. | ||
| 97 | function pickAndUpload(want) { | 113 | function pickAndUpload(want) { |
| 98 | var input = document.createElement("input"); | 114 | var input = document.createElement("input"); |
| 99 | input.type = "file"; | 115 | input.type = "file"; |
| @@ -114,7 +130,7 @@ | |||
| 114 | if (want.channel) form.append("channel", want.channel); | 130 | if (want.channel) form.append("channel", want.channel); |
| 115 | form.append("file", file, file.name || "picture.png"); | 131 | form.append("file", file, file.name || "picture.png"); |
| 116 | fetch("https://" + want.host + "/api/v1/upload", | 132 | fetch("https://" + want.host + "/api/v1/upload", |
| 117 | - {method: "POST", body: form, credentials: "include"}) | 133 | + {method: "POST", body: form}) |
| 118 | .then(function (r) { return r.text().then(function (t) { | 134 | .then(function (r) { return r.text().then(function (t) { |
| 119 | return {ok: r.ok, status: r.status, body: t}; }); }) | 135 | return {ok: r.ok, status: r.status, body: t}; }); }) |
| 120 | .then(function (r) { | 136 | .then(function (r) { |