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
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,33 @@ All notable changes to the InsForge Dart/Flutter SDK are documented here. This
project follows [Semantic Versioning](https://semver.org) and
[Keep a Changelog](https://keepachangelog.com).

## 0.2.0

Syncs with InsForge JS SDK v1.5.0 (storage: standard PUT create-or-replace
semantics). Requires an InsForge backend that includes the standard-PUT
storage change (InsForge/InsForge#1760); do not run against a pre-change
backend.

### Changed

- **Storage `upload(path, bytes)`** — uploading to a key that already exists
now replaces the object in place (standard PUT semantics). Previously the
server silently auto-renamed the key (`photo.png` → `photo (1).png`). The
method signature is unchanged; the friendly auto-rename UX now lives in the
InsForge dashboard rather than the API.
- **Storage `uploadAutoKey(filename, bytes)`** — now generates a unique,
collision-free key client-side (sanitized base + timestamp + random suffix)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Repeated uploadAutoKey calls are not guaranteed collision-free: a random/key collision will make the replacing PUT overwrite the existing object. The changelog should describe this as best-effort/low-collision unless key existence checking and retry are added.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At CHANGELOG.md, line 22:

<comment>Repeated `uploadAutoKey` calls are not guaranteed collision-free: a random/key collision will make the replacing PUT overwrite the existing object. The changelog should describe this as best-effort/low-collision unless key existence checking and retry are added.</comment>

<file context>
@@ -4,6 +4,33 @@ All notable changes to the InsForge Dart/Flutter SDK are documented here. This
+  method signature is unchanged; the friendly auto-rename UX now lives in the
+  InsForge dashboard rather than the API.
+- **Storage `uploadAutoKey(filename, bytes)`** — now generates a unique,
+  collision-free key client-side (sanitized base + timestamp + random suffix)
+  and uploads through the standard `upload` path, so repeated uploads of the
+  same file never overwrite each other. The backend no longer mints keys
</file context>

and uploads through the standard `upload` path, so repeated uploads of the
same file never overwrite each other. The backend no longer mints keys
server-side.

### Deprecated

- The `upsert` flag on `upload`, `uploadAutoKey`, and `FileOptions` — uploads
always replace an existing object now, so the flag is a no-op (the
`x-upsert` header is no longer sent) and will be removed in a future
release.

## 0.1.0

Initial release.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ final total = await client.database.from('posts').count();
```dart
final stored = await client.storage
.from('avatars')
.upload('users/me.png', bytes, upsert: true);
.upload('users/me.png', bytes); // replaces any existing object at this key
final url = client.storage.from('avatars').getPublicUrl(stored.key);
final files = await client.storage.from('avatars').list(prefix: 'users/');
final data = await client.storage.from('avatars').download('users/me.png');
Expand Down
2 changes: 1 addition & 1 deletion integration_tests/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ environment:

dependencies:
dio: ^5.7.0
insforge: ^0.1.0
insforge: ^0.2.0

dev_dependencies:
lints: ^4.0.0
Expand Down
43 changes: 24 additions & 19 deletions integration_tests/test/storage_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@ void main() {
probeKey,
Uint8List.fromList(utf8.encode('probe')),
contentType: 'text/plain',
upsert: true,
);
// Best-effort cleanup.
try {
Expand Down Expand Up @@ -79,45 +78,51 @@ void main() {
path,
bytes,
contentType: 'text/plain',
upsert: true,
);
expect(stored.key, isNotEmpty);

// Re-uploading to the same key replaces the object in place
// (standard PUT semantics — no server-side auto-rename).
final replacement = 'Replaced – ${DateTime.now().toIso8601String()}';
final replaced = await storage.from(_bucket).upload(
path,
Uint8List.fromList(utf8.encode(replacement)),
contentType: 'text/plain',
);
expect(replaced.key, stored.key);

// List (with prefix).
final listed =
await storage.from(_bucket).list(prefix: 'sdk-test/', limit: 50);
expect(listed, isA<List<StoredFile>>());

// Download and verify bytes.
// Download and verify bytes — the replacement content wins.
final downloaded = await storage.from(_bucket).download(path);
expect(utf8.decode(downloaded), content);
expect(utf8.decode(downloaded), replacement);

// Delete.
await storage.from(_bucket).delete(path);
});

test('uploadAutoKey returns a server-generated key', () async {
test('uploadAutoKey uploads under a unique client-generated key',
() async {
if (!bucketAvailable) return;

final content =
'Auto upload – ${DateTime.now().microsecondsSinceEpoch}';
final bytes = Uint8List.fromList(utf8.encode(content));

final stored = await storage.from(_bucket).uploadAutoKey(
'auto.txt',
bytes,
contentType: 'text/plain',
);
// Client-generated key: sanitized base + timestamp + random suffix.
expect(stored.key, matches(RegExp(r'^auto-\d+-[a-z0-9]{6}\.txt$')));
// Best-effort cleanup.
try {
final stored = await storage.from(_bucket).uploadAutoKey(
'auto.txt',
bytes,
contentType: 'text/plain',
);
expect(stored.key, isNotEmpty);
// Best-effort cleanup.
try {
await storage.from(_bucket).delete(stored.key);
} catch (_) {}
} on InsforgeHttpException catch (e) {
// Some backends may not support auto-key generation.
expect(e.statusCode, greaterThanOrEqualTo(400));
}
await storage.from(_bucket).delete(stored.key);
} catch (_) {}
});

test('download of a non-existent object throws', () async {
Expand Down
15 changes: 15 additions & 0 deletions packages/insforge/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,18 @@
## 0.2.0

Syncs with InsForge JS SDK v1.5.0 (storage: standard PUT create-or-replace
semantics). Requires an InsForge backend that includes the standard-PUT
storage change (InsForge/InsForge#1760).

- **Changed:** storage `upload(path, bytes)` — uploading to an existing key
now replaces the object in place instead of the server auto-renaming the
key. Signature unchanged.
- **Changed:** storage `uploadAutoKey(filename, bytes)` — now generates a
unique, collision-free key client-side (sanitized base + timestamp +
random suffix) and uploads through the standard `upload` path.
- **Deprecated:** the no-op `upsert` flag on `upload`, `uploadAutoKey`, and
`FileOptions` (the `x-upsert` header is no longer sent).

## 0.1.0

Initial release.
2 changes: 1 addition & 1 deletion packages/insforge/lib/src/core/version.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
// both packages in lockstep) with: dart run tool/set_version.dart <version>

/// The current InsForge Dart SDK version (mirrors `pubspec.yaml`).
const String insforgeSdkVersion = '0.1.0';
const String insforgeSdkVersion = '0.2.0';

/// `User-Agent` sent on every request from this SDK: `InsForge-Dart/<version>`.
const String insforgeUserAgent = 'InsForge-Dart/$insforgeSdkVersion';
15 changes: 13 additions & 2 deletions packages/insforge/lib/src/storage/models.dart
Original file line number Diff line number Diff line change
Expand Up @@ -195,16 +195,27 @@ class DownloadStrategy {
}

/// Options for an upload: an explicit [contentType] (otherwise inferred from
/// the filename extension), whether to [upsert] over an existing object, and
/// optional [metadata].
/// the filename extension) and optional [metadata].
class FileOptions {
const FileOptions({
this.contentType,
@Deprecated(
'Uploads follow standard PUT semantics and always replace an existing '
'object; this flag is a no-op and will be removed in a future release.',
)
this.upsert = false,
this.metadata,
});

final String? contentType;

/// No-op: uploads follow standard PUT semantics and always replace an
/// existing object.
@Deprecated(
'Uploads follow standard PUT semantics and always replace an existing '
'object; this flag is a no-op and will be removed in a future release.',
)
final bool upsert;

final Map<String, dynamic>? metadata;
}
70 changes: 57 additions & 13 deletions packages/insforge/lib/src/storage/storage_file_api.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
// packages/insforge_storage/lib/src/storage_file_api.dart
import 'dart:math';
import 'dart:typed_data';

import 'package:dio/dio.dart';
Expand All @@ -23,6 +24,35 @@ class StorageFileApi {
return i >= 0 ? path.substring(i + 1) : path;
}

static const String _keyAlphabet = '0123456789abcdefghijklmnopqrstuvwxyz';
static final Random _keyRandom = Random();

/// Generates a unique object key from [filename]:
/// `<sanitized-base>-<timestamp>-<random><ext>`.
///
/// Auto-key generation is a client-side convenience — the storage API has
/// no server-side key minting — so [uploadAutoKey] produces the key here
/// and then uploads through the standard [upload] path.
static String _generateObjectKey(String filename) {
final dotIndex = filename.lastIndexOf('.');
final hasExt = dotIndex > 0;
final ext = hasExt ? filename.substring(dotIndex) : '';
final base = hasExt ? filename.substring(0, dotIndex) : filename;
var sanitizedBase = base.replaceAll(RegExp('[^a-zA-Z0-9_-]'), '-');
if (sanitizedBase.length > 32) {
sanitizedBase = sanitizedBase.substring(0, 32);
}
if (sanitizedBase.isEmpty) {
sanitizedBase = 'file';
}
final timestamp = DateTime.now().millisecondsSinceEpoch;
final random = List<String>.generate(
6,
(_) => _keyAlphabet[_keyRandom.nextInt(_keyAlphabet.length)],
).join();
return '$sanitizedBase-$timestamp-$random$ext';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Filenames containing URL-significant characters can upload under a truncated or different key because the extension is inserted into the raw object URL without sanitization. Preserving normal extensions while replacing characters outside the URL-safe key alphabet would keep the generated key addressable.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/insforge/lib/src/storage/storage_file_api.dart, line 53:

<comment>Filenames containing URL-significant characters can upload under a truncated or different key because the extension is inserted into the raw object URL without sanitization. Preserving normal extensions while replacing characters outside the URL-safe key alphabet would keep the generated key addressable.</comment>

<file context>
@@ -23,6 +24,35 @@ class StorageFileApi {
+      6,
+      (_) => _keyAlphabet[_keyRandom.nextInt(_keyAlphabet.length)],
+    ).join();
+    return '$sanitizedBase-$timestamp-$random$ext';
+  }
+
</file context>
Suggested change
return '$sanitizedBase-$timestamp-$random$ext';
return '$sanitizedBase-$timestamp-$random${ext.replaceAll(RegExp('[^a-zA-Z0-9._-]'), '-')}';

}

/// Builds a single-part `FormData` whose `file` field carries [bytes] with
/// the given [filename] and [contentType].
static FormData _fileFormData(
Expand All @@ -47,12 +77,17 @@ class StorageFileApi {

/// Uploads [bytes] to [path] (a specific key) via a multipart PUT.
///
/// [contentType] is inferred from the extension when omitted. Set [upsert]
/// to overwrite an existing object (`x-upsert: true`).
/// Standard PUT semantics: uploading to a key that already exists replaces
/// the object in place. [contentType] is inferred from the extension when
/// omitted.
Future<StoredFile> upload(
String path,
Uint8List bytes, {
String? contentType,
@Deprecated(
'Uploads follow standard PUT semantics and always replace an existing '
'object; this flag is a no-op and will be removed in a future release.',
)
bool upsert = false,
Map<String, dynamic>? metadata,
}) async {
Expand All @@ -62,30 +97,36 @@ class StorageFileApi {
'PUT',
'$_bucketPath/objects/$path',
data: form,
headers: <String, String>{if (upsert) 'x-upsert': 'true'},
);
return StoredFile.fromJson(Map<String, dynamic>.from(response.data as Map));
}

/// Uploads [bytes] with a server-generated key via a multipart POST.
/// Uploads [bytes] under an automatically generated, collision-free key.
///
/// The key is derived client-side from [filename] (sanitized base +
/// timestamp + random suffix) — the storage API has no server-side key
/// minting — and uploaded through the standard [upload] path, so repeated
/// uploads of the same file never overwrite each other.
///
/// [filename] is used for content-type inference and key generation.
Future<StoredFile> uploadAutoKey(
String filename,
Uint8List bytes, {
String? contentType,
@Deprecated(
'Auto-generated keys are unique, so there is never an existing object '
'to replace; this flag is a no-op and will be removed in a future '
'release.',
)
bool upsert = false,
Map<String, dynamic>? metadata,
}) async {
final resolved = contentType ?? contentTypeForFilename(filename);
final form = _fileFormData(bytes, _lastSegment(filename), resolved);
final response = await _http.request<dynamic>(
'POST',
'$_bucketPath/objects',
data: form,
headers: <String, String>{if (upsert) 'x-upsert': 'true'},
}) {
return upload(
_generateObjectKey(filename),
bytes,
contentType: contentType ?? contentTypeForFilename(filename),
metadata: metadata,
);
return StoredFile.fromJson(Map<String, dynamic>.from(response.data as Map));
}

// ----- download / list / delete / public url -----
Expand Down Expand Up @@ -191,6 +232,9 @@ class StorageFileApi {
}

/// Confirms a presigned upload of [objectKey], returning the [StoredFile].
///
/// Confirm create-or-replaces the metadata row, matching the standard PUT
/// semantics of [upload].
Future<StoredFile> confirmUpload(
String objectKey, {
required int size,
Expand Down
2 changes: 1 addition & 1 deletion packages/insforge/pubspec.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# packages/insforge/pubspec.yaml
name: insforge
description: Pure-Dart SDK for InsForge — auth, database, storage, functions, and AI.
version: 0.1.0
version: 0.2.0
repository: https://github.com/InsForge/insforge-flutter
homepage: https://insforge.dev
resolution: workspace
Expand Down
Loading