Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 16 additions & 6 deletions client/packages/admin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -845,6 +845,10 @@ const isNodeReadable = (v: any): v is Readable =>
const isWebReadable = (v: any): v is ReadableStream =>
v && typeof v.getReader === 'function';

function isHeaderSafe(value: string): boolean {
return /^[\x20-\x7e\xa0-\xff]*$/.test(value);
}

/**
* Functions to manage file storage.
*/
Expand Down Expand Up @@ -872,10 +876,14 @@ class Storage {
): Promise<UploadFileResponse> => {
const headers = {
...authorizedHeaders(this.config, this.impersonationOpts),
path,
};
if (isHeaderSafe(path)) {
headers['path'] = path;
}
if (metadata.contentDisposition) {
headers['content-disposition'] = metadata.contentDisposition;
if (isHeaderSafe(metadata.contentDisposition)) {
headers['content-disposition'] = metadata.contentDisposition;
}
}

// headers.content-type will become "undefined" (string)
Expand Down Expand Up @@ -906,10 +914,12 @@ class Storage {
...(duplex && { duplex }),
};

return jsonFetch(
`${this.config.apiURI}/admin/storage/upload?app_id=${this.config.appId}`,
options,
);
let url = `${this.config.apiURI}/admin/storage/upload?app_id=${encodeURIComponent(this.config.appId)}&path=${encodeURIComponent(path)}`;
if (metadata.contentDisposition) {
url += `&content-disposition=${encodeURIComponent(metadata.contentDisposition)}`;
}

return jsonFetch(url, options);
};

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1490,22 +1490,31 @@ export async function jsonFetch(
: Promise.reject({ status: res.status, body: json });
}

function isHeaderSafe(value: string): boolean {
return /^[\x20-\x7e\xa0-\xff]*$/.test(value);
}

async function upload(
token: string,
appId: string,
file: File,
customFilename: string,
apiUri: string,
): Promise<boolean> {
const headers = {
const path = customFilename || file.name;
const headers: Record<string, string> = {
'app-id': appId,
app_id: appId,
path: customFilename || file.name,
authorization: `Bearer ${token}`,
'content-type': file.type,
};
if (isHeaderSafe(path)) {
headers['path'] = path;
}

const url = `${apiUri}/dash/apps/${appId}/storage/upload?path=${encodeURIComponent(path)}`;

const data = await jsonFetch(`${apiUri}/dash/apps/${appId}/storage/upload`, {
const data = await jsonFetch(url, {
method: 'PUT',
headers,
body: file,
Expand Down
17 changes: 14 additions & 3 deletions client/packages/core/src/StorageAPI.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { jsonFetch } from './utils/fetch.js';

function isHeaderSafe(value: string): boolean {
return /^[\x20-\x7e\xa0-\xff]*$/.test(value);
}

export type UploadFileResponse = {
data: {
id: string;
Expand Down Expand Up @@ -32,15 +36,22 @@ export async function uploadFile({
const headers = {
'app-id': appId,
app_id: appId,
path,
authorization: `Bearer ${refreshToken}`,
'content-type': contentType || file.type,
};
if (contentDisposition) {
if (isHeaderSafe(path)) {
headers['path'] = path;
}
if (contentDisposition && isHeaderSafe(contentDisposition)) {
headers['content-disposition'] = contentDisposition;
}

const data = await jsonFetch(`${apiURI}/storage/upload`, {
let url = `${apiURI}/storage/upload?app_id=${encodeURIComponent(appId)}&path=${encodeURIComponent(path)}`;
if (contentDisposition) {
url += `&content-disposition=${encodeURIComponent(contentDisposition)}`;
}

const data = await jsonFetch(url, {
method: 'PUT',
headers,
body: file,
Expand Down
4 changes: 1 addition & 3 deletions client/www/app/docs/http-api/page.md
Original file line number Diff line number Diff line change
Expand Up @@ -323,10 +323,8 @@ You can also manage your app's [storage](/docs/storage) with the HTTP API.
Upload a file with `PUT /admin/storage/upload`:

```shell
curl -X PUT "https://api.instantdb.com/admin/storage/upload" \
curl -X PUT "https://api.instantdb.com/admin/storage/upload?app_id=$APP_ID&path=snippets/demo.txt" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "App-Id: $APP_ID" \
-H "Path: snippets/demo.txt" \
-H "Content-Type: text/plain" \
--data-binary "@demo.txt"
```
Expand Down
9 changes: 5 additions & 4 deletions server/src/instant/admin/routes.clj
Original file line number Diff line number Diff line change
Expand Up @@ -627,11 +627,12 @@

(defn upload-put [req]
(let [{:keys [app-id] :as perms} (get-perms! req :storage/write)
params (:headers req)
path (ex/get-param! params ["path"] string-util/coerce-non-blank-str)
params (merge (w/keywordize-keys (:headers req))
(:params req))
path (ex/get-param! params [:path] string-util/coerce-non-blank-str)
file (ex/get-param! req [:body] identity)
content-type (storage-coordinator/coerce-content-type (get params "content-type"))
content-disposition (ex/get-optional-param! params ["content-disposition"] string-util/coerce-non-blank-str)
content-type (storage-coordinator/coerce-content-type (:content-type params))
content-disposition (ex/get-optional-param! params [:content-disposition] string-util/coerce-non-blank-str)
data (storage-coordinator/upload-file! {:app-id app-id
:path path
:content-type content-type
Expand Down
5 changes: 3 additions & 2 deletions server/src/instant/dash/routes.clj
Original file line number Diff line number Diff line change
Expand Up @@ -1764,8 +1764,9 @@
(let [{{app-id :id} :app} (req->app-accepting-superadmin-or-ref-token! :collaborator
:apps/read
req)
params (:headers req)
path (ex/get-param! params ["path"] string-util/coerce-non-blank-str)
params (merge (w/keywordize-keys (:headers req))
(:params req))
path (ex/get-param! params [:path] string-util/coerce-non-blank-str)
file (ex/get-param! req [:body] identity)
content-type (storage-coordinator/coerce-content-type (:content-type req))
data (storage-coordinator/upload-file!
Expand Down
3 changes: 2 additions & 1 deletion server/src/instant/storage/routes.clj
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@
:content-disposition (ex/get-optional-param! params [:content-disposition] string-util/coerce-non-blank-str)}))

(defn upload-put [req]
(let [params (w/keywordize-keys (:headers req))
(let [params (merge (w/keywordize-keys (:headers req))
(:params req))
ctx (req->app-file! req params)
file (ex/get-param! req [:body] identity)
data (storage-coordinator/upload-file! ctx file)]
Expand Down
11 changes: 11 additions & 0 deletions server/test/instant/admin/routes_test.clj
Original file line number Diff line number Diff line change
Expand Up @@ -1088,6 +1088,17 @@
(is (= 200 (:status ret)))
(is (some? (-> ret :body :data :id)))))

(testing "admin can upload a file with a non-ASCII filename via query params"
(let [ret (upload-put
{:body (make-file-content)
:params {:path "café à noite.txt"}
:headers {"app-id" app-id
"authorization" (str "Bearer " admin-token)
"content-type" "text/plain"}
:content-length 5})]
(is (= 200 (:status ret)))
(is (some? (-> ret :body :data :id)))))

(testing "user with email can upload"
(let [ret (upload-put
{:body (make-file-content)
Expand Down
44 changes: 44 additions & 0 deletions server/test/instant/storage/routes_test.clj
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
(ns instant.storage.routes-test
(:require [clojure.test :as test :refer [deftest is testing]]
[instant.storage.routes :as storage-routes]
[instant.storage.coordinator :as storage-coordinator]))

(deftest upload-put-reads-path-from-query-params
(let [captured-ctx (atom nil)
app-id (random-uuid)]
(with-redefs [storage-coordinator/upload-file!
(fn [ctx _file]
(reset! captured-ctx ctx)
{:id "fake-file-id"})]
(testing "path comes from query params when present"
(let [ret (storage-routes/upload-put
{:body "file-contents"
:params {:app_id (str app-id)
:path "café à noite.txt"}
:headers {"app-id" (str app-id)
"content-type" "text/plain"}
:content-length 5})]
(is (= 200 (:status ret)))
(is (= "café à noite.txt" (:path @captured-ctx)))))

(testing "path still works from the legacy header for ASCII filenames"
(let [ret (storage-routes/upload-put
{:body "file-contents"
:params {}
:headers {"app-id" (str app-id)
"path" "legacy-file.txt"
"content-type" "text/plain"}
:content-length 5})]
(is (= 200 (:status ret)))
(is (= "legacy-file.txt" (:path @captured-ctx)))))

(testing "query param takes priority over the header when both are present"
(let [ret (storage-routes/upload-put
{:body "file-contents"
:params {:path "café.txt"}
:headers {"app-id" (str app-id)
"path" "stale-ascii-name.txt"
"content-type" "text/plain"}
:content-length 5})]
(is (= 200 (:status ret)))
(is (= "café.txt" (:path @captured-ctx))))))))