@lacrous/omnicloud 0.2.33 → 0.2.35
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/dist/index.cjs +11 -8
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +23 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
- package/server/index.js +133 -5
- package/server/prisma/migrations/6_api_tokens/migration.sql +19 -0
- package/server/prisma/schema.prisma +16 -0
- package/server/web/assets/index-Cw_OGTa3.js +361 -0
- package/server/web/assets/index-j7HzBSRq.css +1 -0
- package/server/web/index.html +2 -2
- package/server/web/assets/index-Cimv9hDI.js +0 -346
- package/server/web/assets/index-DZrY56Sp.css +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lacrous/omnicloud",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.35",
|
|
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.
|
|
52
|
-
"@omnicloud/shared": "0.2.
|
|
53
|
-
"@omnicloud/telegram": "0.2.
|
|
51
|
+
"@omnicloud/core": "0.2.35",
|
|
52
|
+
"@omnicloud/shared": "0.2.35",
|
|
53
|
+
"@omnicloud/telegram": "0.2.35",
|
|
54
54
|
"@types/node": "^22.10.0",
|
|
55
55
|
"esbuild": "0.25.12",
|
|
56
56
|
"tsup": "^8.3.0",
|
package/server/index.js
CHANGED
|
@@ -770,6 +770,41 @@ function truncate(value, max) {
|
|
|
770
770
|
return value === null ? null : value.slice(0, max);
|
|
771
771
|
}
|
|
772
772
|
|
|
773
|
+
// ../core/src/services/api-token-service.ts
|
|
774
|
+
import { randomBytes as randomBytes2 } from "node:crypto";
|
|
775
|
+
var MAX_NAME_LENGTH = 80;
|
|
776
|
+
var ApiTokenService = class {
|
|
777
|
+
constructor(repo, now = () => /* @__PURE__ */ new Date()) {
|
|
778
|
+
this.repo = repo;
|
|
779
|
+
this.now = now;
|
|
780
|
+
}
|
|
781
|
+
async issue(userId, name) {
|
|
782
|
+
const label = name.trim();
|
|
783
|
+
if (label.length === 0 || label.length > MAX_NAME_LENGTH) {
|
|
784
|
+
throw new ValidationError(`Token name must be 1 to ${MAX_NAME_LENGTH} characters`);
|
|
785
|
+
}
|
|
786
|
+
const token = randomBytes2(32).toString("base64url");
|
|
787
|
+
const record = await this.repo.create({ userId, name: label, tokenHash: hashToken(token) });
|
|
788
|
+
return { token, record };
|
|
789
|
+
}
|
|
790
|
+
list(userId) {
|
|
791
|
+
return this.repo.listForUser(userId);
|
|
792
|
+
}
|
|
793
|
+
/** Resolves a bearer token to its active record, or null if unknown or revoked. */
|
|
794
|
+
async resolve(token) {
|
|
795
|
+
if (!token || token.length < 32 || token.length > 128) return null;
|
|
796
|
+
const record = await this.repo.findByTokenHash(hashToken(token));
|
|
797
|
+
if (!record || record.revokedAt !== null) return null;
|
|
798
|
+
await this.repo.touch(record.id, this.now());
|
|
799
|
+
return record;
|
|
800
|
+
}
|
|
801
|
+
async revoke(userId, id) {
|
|
802
|
+
const record = await this.repo.findByIdForUser(userId, id);
|
|
803
|
+
if (!record) throw new NotFoundError("API token not found");
|
|
804
|
+
if (record.revokedAt === null) await this.repo.revoke(id, this.now());
|
|
805
|
+
}
|
|
806
|
+
};
|
|
807
|
+
|
|
773
808
|
// ../core/src/services/version-retention.ts
|
|
774
809
|
function versionsToPrune(versions, currentVersionId, policy, now) {
|
|
775
810
|
const historical = versions.filter((version) => version.id !== currentVersionId).sort((a, b) => b.versionNumber - a.versionNumber);
|
|
@@ -2372,7 +2407,7 @@ async function sweepStaleSpools() {
|
|
|
2372
2407
|
}
|
|
2373
2408
|
|
|
2374
2409
|
// ../core/src/utils/secret-box.ts
|
|
2375
|
-
import { createCipheriv, createDecipheriv, randomBytes as
|
|
2410
|
+
import { createCipheriv, createDecipheriv, randomBytes as randomBytes3, scryptSync } from "node:crypto";
|
|
2376
2411
|
var ALGORITHM = "aes-256-gcm";
|
|
2377
2412
|
var IV_BYTES = 12;
|
|
2378
2413
|
var KEY_BYTES = 32;
|
|
@@ -2398,7 +2433,7 @@ var SecretBox = class {
|
|
|
2398
2433
|
}
|
|
2399
2434
|
seal(plaintext) {
|
|
2400
2435
|
const key = this.keys.get(this.currentVersion);
|
|
2401
|
-
const iv =
|
|
2436
|
+
const iv = randomBytes3(IV_BYTES);
|
|
2402
2437
|
const cipher = createCipheriv(ALGORITHM, key, iv);
|
|
2403
2438
|
const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
|
2404
2439
|
const tag = cipher.getAuthTag();
|
|
@@ -3817,6 +3852,49 @@ function createUploadOperationRepo(prisma) {
|
|
|
3817
3852
|
}
|
|
3818
3853
|
};
|
|
3819
3854
|
}
|
|
3855
|
+
function createApiTokenRepo(prisma) {
|
|
3856
|
+
return {
|
|
3857
|
+
async create(input) {
|
|
3858
|
+
const row = await prisma.apiToken.create({ data: input });
|
|
3859
|
+
return mapApiToken(row);
|
|
3860
|
+
},
|
|
3861
|
+
async findByTokenHash(tokenHash) {
|
|
3862
|
+
const row = await prisma.apiToken.findUnique({ where: { tokenHash } });
|
|
3863
|
+
return row ? mapApiToken(row) : null;
|
|
3864
|
+
},
|
|
3865
|
+
async findByIdForUser(userId, id) {
|
|
3866
|
+
const row = await prisma.apiToken.findFirst({ where: { id, userId } });
|
|
3867
|
+
return row ? mapApiToken(row) : null;
|
|
3868
|
+
},
|
|
3869
|
+
async listForUser(userId) {
|
|
3870
|
+
const rows = await prisma.apiToken.findMany({
|
|
3871
|
+
where: { userId },
|
|
3872
|
+
orderBy: { createdAt: "desc" }
|
|
3873
|
+
});
|
|
3874
|
+
return rows.map(mapApiToken);
|
|
3875
|
+
},
|
|
3876
|
+
async touch(id, at) {
|
|
3877
|
+
await prisma.apiToken.update({ where: { id }, data: { lastUsedAt: at } });
|
|
3878
|
+
},
|
|
3879
|
+
async revoke(id, at) {
|
|
3880
|
+
await prisma.apiToken.updateMany({
|
|
3881
|
+
where: { id, revokedAt: null },
|
|
3882
|
+
data: { revokedAt: at }
|
|
3883
|
+
});
|
|
3884
|
+
}
|
|
3885
|
+
};
|
|
3886
|
+
}
|
|
3887
|
+
function mapApiToken(row) {
|
|
3888
|
+
return {
|
|
3889
|
+
id: row.id,
|
|
3890
|
+
userId: row.userId,
|
|
3891
|
+
name: row.name,
|
|
3892
|
+
tokenHash: row.tokenHash,
|
|
3893
|
+
createdAt: row.createdAt,
|
|
3894
|
+
revokedAt: row.revokedAt,
|
|
3895
|
+
lastUsedAt: row.lastUsedAt
|
|
3896
|
+
};
|
|
3897
|
+
}
|
|
3820
3898
|
function createBrowserSessionRepo(prisma) {
|
|
3821
3899
|
return {
|
|
3822
3900
|
async create(input) {
|
|
@@ -4258,6 +4336,7 @@ function createPrismaRepos(prisma, box = null) {
|
|
|
4258
4336
|
users: createUserRepo(prisma),
|
|
4259
4337
|
sessions: createSessionRepo(prisma, box),
|
|
4260
4338
|
browserSessions: createBrowserSessionRepo(prisma),
|
|
4339
|
+
apiTokens: createApiTokenRepo(prisma),
|
|
4261
4340
|
uploadOperations: createUploadOperationRepo(prisma),
|
|
4262
4341
|
storages: createStorageRepo(prisma),
|
|
4263
4342
|
folders: createFolderRepo(prisma),
|
|
@@ -4362,6 +4441,7 @@ function buildContainerFromRepos(config2, repos, overrides = {}) {
|
|
|
4362
4441
|
reconciliation: new ReconciliationService(repos, engineFor),
|
|
4363
4442
|
reconciliationFlow: new ReconciliationFlow(repos, engineFor),
|
|
4364
4443
|
authSessions: new AuthSessionService(repos.browserSessions),
|
|
4444
|
+
apiTokens: new ApiTokenService(repos.apiTokens),
|
|
4365
4445
|
engineFor,
|
|
4366
4446
|
shutdown: async () => {
|
|
4367
4447
|
await clientManager.disconnectAll();
|
|
@@ -4416,12 +4496,22 @@ import multipart from "@fastify/multipart";
|
|
|
4416
4496
|
import fastifyStatic from "@fastify/static";
|
|
4417
4497
|
|
|
4418
4498
|
// ../../apps/api/src/auth.ts
|
|
4419
|
-
function registerAuthHook(app2, repos, authSessions) {
|
|
4499
|
+
function registerAuthHook(app2, repos, authSessions, apiTokens) {
|
|
4420
4500
|
app2.addHook("preHandler", async (request) => {
|
|
4421
4501
|
const url = request.routeOptions?.url ?? "";
|
|
4422
4502
|
if (!url.startsWith("/api/") || url.startsWith("/api/auth/") || url.startsWith("/api/health")) {
|
|
4423
4503
|
return;
|
|
4424
4504
|
}
|
|
4505
|
+
const bearer = bearerToken(request.headers.authorization);
|
|
4506
|
+
if (bearer !== void 0) {
|
|
4507
|
+
const token = await apiTokens.resolve(bearer);
|
|
4508
|
+
if (!token) throw new UnauthorizedError();
|
|
4509
|
+
const owner = await repos.users.findById(token.userId);
|
|
4510
|
+
if (!owner) throw new AuthInvalidError("Account no longer exists");
|
|
4511
|
+
request.user = { id: owner.id };
|
|
4512
|
+
request.authSessionId = void 0;
|
|
4513
|
+
return;
|
|
4514
|
+
}
|
|
4425
4515
|
const session = await authSessions.resolve(request.cookies[SESSION_COOKIE]);
|
|
4426
4516
|
if (!session) throw new UnauthorizedError();
|
|
4427
4517
|
const user = await repos.users.findById(session.userId);
|
|
@@ -4430,6 +4520,11 @@ function registerAuthHook(app2, repos, authSessions) {
|
|
|
4430
4520
|
request.authSessionId = session.id;
|
|
4431
4521
|
});
|
|
4432
4522
|
}
|
|
4523
|
+
function bearerToken(header) {
|
|
4524
|
+
if (!header) return void 0;
|
|
4525
|
+
const match = /^Bearer\s+(\S+)$/i.exec(header.trim());
|
|
4526
|
+
return match ? match[1] : void 0;
|
|
4527
|
+
}
|
|
4433
4528
|
async function issueSessionCookie(authSessions, request, reply, userId, cookieSecure) {
|
|
4434
4529
|
const { token } = await authSessions.issue(userId, {
|
|
4435
4530
|
userAgent: request.headers["user-agent"] ?? null,
|
|
@@ -5253,7 +5348,7 @@ function encryptionCheck(configured) {
|
|
|
5253
5348
|
}
|
|
5254
5349
|
|
|
5255
5350
|
// ../../apps/api/src/routes/storage.ts
|
|
5256
|
-
var VERSION = "0.2.
|
|
5351
|
+
var VERSION = "0.2.35";
|
|
5257
5352
|
function registerStorageRoutes(app2, container2) {
|
|
5258
5353
|
app2.get("/api/health", async () => {
|
|
5259
5354
|
const database = await probeDatabase(container2);
|
|
@@ -5353,6 +5448,38 @@ async function probeDatabase(container2) {
|
|
|
5353
5448
|
}
|
|
5354
5449
|
}
|
|
5355
5450
|
|
|
5451
|
+
// ../../apps/api/src/routes/tokens.ts
|
|
5452
|
+
function registerTokenRoutes(app2, container2) {
|
|
5453
|
+
app2.post("/api/tokens", async (request, reply) => {
|
|
5454
|
+
const body = request.body ?? {};
|
|
5455
|
+
if (typeof body.name !== "string") throw new ValidationError("A token name is required");
|
|
5456
|
+
const issued = await container2.apiTokens.issue(request.user.id, body.name);
|
|
5457
|
+
return reply.status(201).send({
|
|
5458
|
+
id: issued.record.id,
|
|
5459
|
+
name: issued.record.name,
|
|
5460
|
+
createdAt: issued.record.createdAt.toISOString(),
|
|
5461
|
+
token: issued.token
|
|
5462
|
+
});
|
|
5463
|
+
});
|
|
5464
|
+
app2.get("/api/tokens", async (request) => {
|
|
5465
|
+
const tokens = await container2.apiTokens.list(request.user.id);
|
|
5466
|
+
return {
|
|
5467
|
+
tokens: tokens.map((t) => ({
|
|
5468
|
+
id: t.id,
|
|
5469
|
+
name: t.name,
|
|
5470
|
+
createdAt: t.createdAt.toISOString(),
|
|
5471
|
+
lastUsedAt: t.lastUsedAt?.toISOString() ?? null,
|
|
5472
|
+
revokedAt: t.revokedAt?.toISOString() ?? null
|
|
5473
|
+
}))
|
|
5474
|
+
};
|
|
5475
|
+
});
|
|
5476
|
+
app2.delete("/api/tokens/:id", async (request, reply) => {
|
|
5477
|
+
const { id } = request.params;
|
|
5478
|
+
await container2.apiTokens.revoke(request.user.id, id);
|
|
5479
|
+
return reply.status(204).send();
|
|
5480
|
+
});
|
|
5481
|
+
}
|
|
5482
|
+
|
|
5356
5483
|
// ../../apps/api/src/app.ts
|
|
5357
5484
|
function sendError(reply, status, code, message, requestId, details) {
|
|
5358
5485
|
void reply.status(status).send({ error: { code, message, requestId, ...details ? { details } : {} } });
|
|
@@ -5382,7 +5509,7 @@ async function createApp(container2) {
|
|
|
5382
5509
|
}
|
|
5383
5510
|
}
|
|
5384
5511
|
});
|
|
5385
|
-
registerAuthHook(app2, container2.repos, container2.authSessions);
|
|
5512
|
+
registerAuthHook(app2, container2.repos, container2.authSessions, container2.apiTokens);
|
|
5386
5513
|
app2.setErrorHandler((error, request, reply) => {
|
|
5387
5514
|
if (error instanceof DomainError) {
|
|
5388
5515
|
sendError(reply, error.status, error.code, error.message, request.id, error.details);
|
|
@@ -5410,6 +5537,7 @@ async function createApp(container2) {
|
|
|
5410
5537
|
registerFolderRoutes(app2, container2);
|
|
5411
5538
|
registerCollectionRoutes(app2, container2);
|
|
5412
5539
|
registerStorageRoutes(app2, container2);
|
|
5540
|
+
registerTokenRoutes(app2, container2);
|
|
5413
5541
|
const webDistDir = container2.config.webDistDir;
|
|
5414
5542
|
if (webDistDir && existsSync(webDistDir)) {
|
|
5415
5543
|
await app2.register(fastifyStatic, { root: resolve(webDistDir), wildcard: false });
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
-- Personal API tokens. Only a SHA-256 hash of each secret is stored; revoked tokens
|
|
2
|
+
-- are kept so their history survives, and are rejected by the auth check.
|
|
3
|
+
CREATE TABLE "ApiToken" (
|
|
4
|
+
"id" TEXT NOT NULL,
|
|
5
|
+
"userId" TEXT NOT NULL,
|
|
6
|
+
"name" TEXT NOT NULL,
|
|
7
|
+
"tokenHash" TEXT NOT NULL,
|
|
8
|
+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
9
|
+
"revokedAt" TIMESTAMP(3),
|
|
10
|
+
"lastUsedAt" TIMESTAMP(3),
|
|
11
|
+
|
|
12
|
+
CONSTRAINT "ApiToken_pkey" PRIMARY KEY ("id")
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
CREATE UNIQUE INDEX "ApiToken_tokenHash_key" ON "ApiToken"("tokenHash");
|
|
16
|
+
|
|
17
|
+
CREATE INDEX "ApiToken_userId_revokedAt_idx" ON "ApiToken"("userId", "revokedAt");
|
|
18
|
+
|
|
19
|
+
ALTER TABLE "ApiToken" ADD CONSTRAINT "ApiToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
@@ -31,6 +31,7 @@ model User {
|
|
|
31
31
|
fileVersions FileVersion[]
|
|
32
32
|
activityEvents ActivityEvent[]
|
|
33
33
|
browserSessions BrowserSession[]
|
|
34
|
+
apiTokens ApiToken[]
|
|
34
35
|
uploadOperations UploadOperation[]
|
|
35
36
|
}
|
|
36
37
|
|
|
@@ -74,6 +75,21 @@ model UploadOperation {
|
|
|
74
75
|
@@index([status, updatedAt])
|
|
75
76
|
}
|
|
76
77
|
|
|
78
|
+
/// Personal API token for scripts and the SDK. Only a SHA-256 hash of the secret
|
|
79
|
+
/// is stored; the secret itself is shown once, when the token is created.
|
|
80
|
+
model ApiToken {
|
|
81
|
+
id String @id @default(cuid())
|
|
82
|
+
userId String
|
|
83
|
+
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
84
|
+
name String
|
|
85
|
+
tokenHash String @unique
|
|
86
|
+
createdAt DateTime @default(now())
|
|
87
|
+
revokedAt DateTime?
|
|
88
|
+
lastUsedAt DateTime?
|
|
89
|
+
|
|
90
|
+
@@index([userId, revokedAt])
|
|
91
|
+
}
|
|
92
|
+
|
|
77
93
|
/// MTProto session string for the user's connected Telegram account.
|
|
78
94
|
/// Never exposed through the API and never logged.
|
|
79
95
|
model TelegramSession {
|