@lacrous/omnicloud 0.2.15 → 0.2.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lacrous/omnicloud",
3
- "version": "0.2.15",
3
+ "version": "0.2.17",
4
4
  "description": "SDK and core building blocks for OmniCloud — self-hosted cloud storage powered by your Telegram account.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -48,9 +48,9 @@
48
48
  "prisma": "^6.2.0"
49
49
  },
50
50
  "devDependencies": {
51
- "@omnicloud/core": "0.2.15",
52
- "@omnicloud/shared": "0.2.15",
53
- "@omnicloud/telegram": "0.2.15",
51
+ "@omnicloud/core": "0.2.17",
52
+ "@omnicloud/shared": "0.2.17",
53
+ "@omnicloud/telegram": "0.2.17",
54
54
  "@types/node": "^22.10.0",
55
55
  "esbuild": "0.25.12",
56
56
  "tsup": "^8.3.0",
package/server/index.js CHANGED
@@ -1078,6 +1078,20 @@ function sanitizeFileName(name) {
1078
1078
  return cleaned.slice(0, 255);
1079
1079
  }
1080
1080
 
1081
+ // ../core/src/utils/request-fingerprint.ts
1082
+ import { createHash as createHash4 } from "node:crypto";
1083
+ function uploadRequestFingerprint(input) {
1084
+ const contentHash = input.spooled?.sha256 ?? sha256Hex(input.data ?? Buffer.alloc(0));
1085
+ const canonical = JSON.stringify({
1086
+ name: sanitizeFileName(input.name),
1087
+ folderId: input.folderId ?? null,
1088
+ replaceFileId: input.replaceFileId ?? null,
1089
+ contentHash,
1090
+ size: input.spooled?.size ?? input.data?.byteLength ?? 0
1091
+ });
1092
+ return createHash4("sha256").update(canonical).digest("hex");
1093
+ }
1094
+
1081
1095
  // ../core/src/services/file-service.ts
1082
1096
  var FileService = class {
1083
1097
  constructor(files, folders, engineFor, activity = noopActivityRecorder, operations = null, log = null) {
@@ -1110,7 +1124,13 @@ var FileService = class {
1110
1124
  if (!isValidOperationId(operationId)) {
1111
1125
  throw new ValidationError("Invalid upload operation id");
1112
1126
  }
1127
+ const fingerprint = uploadRequestFingerprint(input);
1113
1128
  let op = await operations.findByOperationId(userId, operationId);
1129
+ if (op?.requestFingerprint && op.requestFingerprint !== fingerprint) {
1130
+ throw new ConflictError(
1131
+ "This upload key was already used for a different file. Use a new key for a new upload."
1132
+ );
1133
+ }
1114
1134
  if (op?.status === "COMPLETED" && op.fileId) {
1115
1135
  return this.get(userId, op.fileId);
1116
1136
  }
@@ -1119,7 +1139,7 @@ var FileService = class {
1119
1139
  }
1120
1140
  if (!op) {
1121
1141
  try {
1122
- op = await operations.create({ userId, operationId });
1142
+ op = await operations.create({ userId, operationId, requestFingerprint: fingerprint });
1123
1143
  } catch (error) {
1124
1144
  if (!(error instanceof OperationAlreadyExistsError)) throw error;
1125
1145
  const outcome = await this.followWinner(userId, input, operationId, operations);
@@ -2214,7 +2234,7 @@ var RecentService = class {
2214
2234
  };
2215
2235
 
2216
2236
  // ../core/src/utils/spool.ts
2217
- import { createHash as createHash4 } from "node:crypto";
2237
+ import { createHash as createHash5 } from "node:crypto";
2218
2238
  import { createWriteStream } from "node:fs";
2219
2239
  import { mkdtemp, rm } from "node:fs/promises";
2220
2240
  import { tmpdir } from "node:os";
@@ -2226,7 +2246,7 @@ async function spoolToFile(source, maxBytes) {
2226
2246
  const dir = await mkdtemp(join(tmpdir(), `${SPOOL_PREFIX}${process.pid}-`));
2227
2247
  const path = join(dir, "payload");
2228
2248
  const discard = () => rm(dir, { recursive: true, force: true });
2229
- const hash = createHash4("sha256");
2249
+ const hash = createHash5("sha256");
2230
2250
  let size = 0;
2231
2251
  const meter = new Transform2({
2232
2252
  transform(chunk, _encoding, callback) {
@@ -3554,6 +3574,7 @@ function mapUploadOperation(row) {
3554
3574
  telegramMessageId: row.telegramMessageId === null ? null : Number(row.telegramMessageId),
3555
3575
  sha256: row.sha256,
3556
3576
  size: row.size === null ? null : Number(row.size),
3577
+ requestFingerprint: row.requestFingerprint,
3557
3578
  fileId: row.fileId,
3558
3579
  error: row.error,
3559
3580
  createdAt: row.createdAt,
@@ -3575,7 +3596,12 @@ function createUploadOperationRepo(prisma) {
3575
3596
  async create(input) {
3576
3597
  try {
3577
3598
  const row = await prisma.uploadOperation.create({
3578
- data: { userId: input.userId, operationId: input.operationId, status: "PENDING" }
3599
+ data: {
3600
+ userId: input.userId,
3601
+ operationId: input.operationId,
3602
+ status: "PENDING",
3603
+ requestFingerprint: input.requestFingerprint ?? null
3604
+ }
3579
3605
  });
3580
3606
  return mapUploadOperation(row);
3581
3607
  } catch (error) {
@@ -4128,9 +4154,9 @@ import { readFileSync } from "node:fs";
4128
4154
  import { join as join2 } from "node:path";
4129
4155
 
4130
4156
  // ../../apps/api/src/csp.ts
4131
- import { createHash as createHash5 } from "node:crypto";
4157
+ import { createHash as createHash6 } from "node:crypto";
4132
4158
  function sha256Source(content) {
4133
- return `'sha256-${createHash5("sha256").update(content, "utf8").digest("base64")}'`;
4159
+ return `'sha256-${createHash6("sha256").update(content, "utf8").digest("base64")}'`;
4134
4160
  }
4135
4161
  function buildContentSecurityPolicy(inlineScripts, inlineStyles) {
4136
4162
  const scriptSources = ["'self'", ...inlineScripts.map(sha256Source)];
@@ -5004,7 +5030,7 @@ function encryptionCheck(configured) {
5004
5030
  }
5005
5031
 
5006
5032
  // ../../apps/api/src/routes/storage.ts
5007
- var VERSION = "0.2.15";
5033
+ var VERSION = "0.2.17";
5008
5034
  function registerStorageRoutes(app2, container2) {
5009
5035
  app2.get("/api/health", async () => {
5010
5036
  const database = await probeDatabase(container2);
@@ -0,0 +1,3 @@
1
+ -- Binds an upload idempotency key to the request that created it. Nullable so
2
+ -- operations created before this migration keep their existing behaviour.
3
+ ALTER TABLE "UploadOperation" ADD COLUMN "requestFingerprint" TEXT;
@@ -24,29 +24,29 @@ model User {
24
24
  createdAt DateTime @default(now())
25
25
  updatedAt DateTime @updatedAt
26
26
 
27
- telegramSession TelegramSession?
28
- storages Storage[]
29
- folders Folder[]
30
- files File[]
31
- fileVersions FileVersion[]
32
- activityEvents ActivityEvent[]
33
- browserSessions BrowserSession[]
27
+ telegramSession TelegramSession?
28
+ storages Storage[]
29
+ folders Folder[]
30
+ files File[]
31
+ fileVersions FileVersion[]
32
+ activityEvents ActivityEvent[]
33
+ browserSessions BrowserSession[]
34
34
  uploadOperations UploadOperation[]
35
35
  }
36
36
 
37
37
  /// A signed-in browser session. Only the SHA-256 of the cookie token is stored,
38
38
  /// so revoking the row ends the session on the next request.
39
39
  model BrowserSession {
40
- id String @id @default(cuid())
41
- userId String
42
- user User @relation(fields: [userId], references: [id], onDelete: Cascade)
43
- tokenHash String @unique
44
- createdAt DateTime @default(now())
45
- expiresAt DateTime
46
- revokedAt DateTime?
47
- lastUsedAt DateTime?
48
- userAgent String?
49
- ip String?
40
+ id String @id @default(cuid())
41
+ userId String
42
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
43
+ tokenHash String @unique
44
+ createdAt DateTime @default(now())
45
+ expiresAt DateTime
46
+ revokedAt DateTime?
47
+ lastUsedAt DateTime?
48
+ userAgent String?
49
+ ip String?
50
50
 
51
51
  @@index([userId, revokedAt])
52
52
  @@index([expiresAt])
@@ -56,18 +56,19 @@ model BrowserSession {
56
56
  /// second Telegram object. `telegramMessageId` is recorded as soon as Telegram
57
57
  /// accepts the object, so recovery can commit the file without uploading again.
58
58
  model UploadOperation {
59
- id String @id @default(cuid())
60
- userId String
61
- user User @relation(fields: [userId], references: [id], onDelete: Cascade)
62
- operationId String
63
- status String
64
- telegramMessageId BigInt?
65
- sha256 String?
66
- size BigInt?
67
- fileId String?
68
- error String?
69
- createdAt DateTime @default(now())
70
- updatedAt DateTime @updatedAt
59
+ id String @id @default(cuid())
60
+ userId String
61
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
62
+ operationId String
63
+ status String
64
+ telegramMessageId BigInt?
65
+ sha256 String?
66
+ size BigInt?
67
+ requestFingerprint String?
68
+ fileId String?
69
+ error String?
70
+ createdAt DateTime @default(now())
71
+ updatedAt DateTime @updatedAt
71
72
 
72
73
  @@unique([userId, operationId])
73
74
  @@index([status, updatedAt])
@@ -102,20 +103,20 @@ model Storage {
102
103
  /// Folders are virtual OmniCloud objects — they have no physical
103
104
  /// representation on Telegram. `deletedAt` is the Trash marker.
104
105
  model Folder {
105
- id String @id @default(cuid())
106
- userId String
107
- user User @relation(fields: [userId], references: [id], onDelete: Cascade)
108
- parentId String?
109
- parent Folder? @relation("FolderTree", fields: [parentId], references: [id], onDelete: Restrict)
110
- children Folder[] @relation("FolderTree")
111
- name String
112
- starred Boolean @default(false)
113
- deletedAt DateTime?
106
+ id String @id @default(cuid())
107
+ userId String
108
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
109
+ parentId String?
110
+ parent Folder? @relation("FolderTree", fields: [parentId], references: [id], onDelete: Restrict)
111
+ children Folder[] @relation("FolderTree")
112
+ name String
113
+ starred Boolean @default(false)
114
+ deletedAt DateTime?
114
115
  /// Groups everything one trash operation moved, for exact restore.
115
116
  trashBatchId String?
116
- createdAt DateTime @default(now())
117
- updatedAt DateTime @updatedAt
118
- files File[]
117
+ createdAt DateTime @default(now())
118
+ updatedAt DateTime @updatedAt
119
+ files File[]
119
120
 
120
121
  @@index([userId, parentId, deletedAt])
121
122
  @@index([userId, deletedAt])
@@ -191,4 +192,4 @@ model ActivityEvent {
191
192
  @@index([userId, createdAt])
192
193
  @@index([userId, resourceId, createdAt])
193
194
  @@index([createdAt])
194
- }
195
+ }