diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 000000000..0701054f5 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,6 @@ +{ + "tabWidth": 2, + "useTabs": false, + "semi": true, + "singleQuote": false +} \ No newline at end of file diff --git a/README.md b/README.md index 9ffe92cdf..f7893c125 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ yarn add discloud.app [How to manage custom domains](#how-to-manage-custom-domains) [How to manage subdomains](#how-to-manage-subdomains) [How to manage shared/team applications](#how-to-manage-sharedteam-applications) +[How to create snapshots](#how-to-create-snapshots) ```js // index.js @@ -329,4 +330,36 @@ await discloud.sharedApps.stop("APP_ID"); // Promise const status = await discloud.sharedApps.status.fetch("APP_ID"); // Promise ``` -> **Note:** In v1.x, use `discloud.teamApps` instead of `discloud.sharedApps`. See the [Migration Guide](./MIGRATION.md) for more details. +### How to create snapshots +```js +const { discloud } = require("discloud.app"); + +// Create a new snapshot +await discloud.snapshots.create("APP_ID"); + +// List snapshots for the app +await discloud.snapshots.fetch("APP_ID"); + +// Fetch all snapshots +await discloud.snapshots.fetch(); +``` + +### How to download the snapshot +```js +const { discloud } = require("discloud.app"); + +// List the snapshots +const snapshots = await discloud.snapshots.fetch("APP_ID"); + +// Get the most recent snapshot (last of the Map) +const snap = [...snapshots.values()].pop(); + +// Download the snapshot +await snap.download("./backups"); + +- If you want a specific one by version (YYYYMMDD-HHMMSS format): +const snapshots = await discloud.snapshots.fetch("APP_ID"); +const snap = snapshots.get("APP_ID-20260420-153000"); // appId-version +``` + +> **Note:** In v1.x, use `discloud.teamApps` instead of `discloud.sharedApps`. See the [Migration Guide](./MIGRATION.md) for more details. \ No newline at end of file diff --git a/packages/api-types/rest/v2/index.ts b/packages/api-types/rest/v2/index.ts index 4ae50e792..ec83afcc5 100644 --- a/packages/api-types/rest/v2/index.ts +++ b/packages/api-types/rest/v2/index.ts @@ -3,6 +3,7 @@ export * from "./base"; export * from "./customdomain"; export * from "./locale"; export * from "./routes"; +export * from "./snapshot" export * from "./subdomain"; export * from "./team"; export * from "./upload"; diff --git a/packages/api-types/rest/v2/snapshot.ts b/packages/api-types/rest/v2/snapshot.ts new file mode 100644 index 000000000..12703e367 --- /dev/null +++ b/packages/api-types/rest/v2/snapshot.ts @@ -0,0 +1,50 @@ +import { type BaseApiApp } from "./app"; +import { type RESTApiBaseResult } from "./base"; + +export type ApiSnapshotApp = BaseApiApp + +export interface ApiSnapshotVersion { + version: string + size: number | string + date: number | string +} + +export interface ApiSnapshotDownload { + url: string + expiresAt: string + version: string + size: number +} + +export interface ApiSnapshotCreated { + version: string + size: string + url: string + allVersions: ApiSnapshotVersion[] +} + +export interface ApiSnapshotListItem extends ApiSnapshotVersion { + appID: string +} + +export interface RESTGetApiSnapshotListResult extends RESTApiBaseResult { + page: number + limit: number + total: number + backups: ApiSnapshotListItem[] +} + +export interface RESTGetApiSnapshotResult extends RESTApiBaseResult { + app: ApiSnapshotApp + versions: ApiSnapshotVersion[] +} + +export interface RESTGetApiSnapshotVersionResult extends RESTApiBaseResult { + app: ApiSnapshotApp + download: ApiSnapshotDownload +} + +export interface RESTPostApiSnapshotResult extends RESTApiBaseResult { + app: ApiSnapshotApp + snapshot: ApiSnapshotCreated +} \ No newline at end of file diff --git a/packages/discloud.app/src/discloudApp/DiscloudApp.ts b/packages/discloud.app/src/discloudApp/DiscloudApp.ts index 8a1cd9e00..9bcebb628 100644 --- a/packages/discloud.app/src/discloudApp/DiscloudApp.ts +++ b/packages/discloud.app/src/discloudApp/DiscloudApp.ts @@ -9,6 +9,7 @@ import SubdomainsManager from "../managers/SubdomainsManager"; import User from "../structures/User"; import { DefaultDiscloudAppOptions } from "../util/constants"; import Deprecation from "../util/deprecation"; +import SnapshotsManager from "../managers/SnapshotsManager"; const appAptDeprecation = new Deprecation("The appApt property is deprecated. Use apps.apts instead."); const appTeamDeprecation = new Deprecation("The appTeam property is deprecated. Use apps.moderators instead."); @@ -20,6 +21,7 @@ export default class DiscloudApp extends EventEmitter { readonly apps = new AppsManager(this); readonly sharedApps = new SharedAppsManager(this); readonly customdomains = new CustomdomainsManager(this); + readonly snapshots = new SnapshotsManager(this) readonly subdomains = new SubdomainsManager(this); readonly user = new User(this); diff --git a/packages/discloud.app/src/managers/BaseSnapshotsManager.ts b/packages/discloud.app/src/managers/BaseSnapshotsManager.ts new file mode 100644 index 000000000..37326b027 --- /dev/null +++ b/packages/discloud.app/src/managers/BaseSnapshotsManager.ts @@ -0,0 +1,63 @@ +import { type ApiSnapshotVersion } from "@discloudapp/api-types/v2"; +import { type Instanciable } from "../@types"; +import type DiscloudApp from "../discloudApp/DiscloudApp"; +import type BaseSnapshot from "../structures/BaseSnapshot"; +import CachedManager from "./CachedManager"; + +export type PartialApiSnapshot = Partial & { appId: string, version: string }; + +export default abstract class BaseSnapshotsManager> extends CachedManager { + constructor(discloudApp: DiscloudApp, holds: T) { + super(discloudApp, holds); + } + + protected _key(appId: string, version: string): string { + return `${appId}-${version}`; + } + + protected _add(data: PartialApiSnapshot): InstanceType { + const key = this._key(data.appId, data.version); + + const existing = this._patch(key, data); + if (existing) return existing; + + const entry = new this.holds(this.discloudApp, data.appId, data) as InstanceType; + + this._cache.set(key, entry); + return entry; + } + + protected _addMany(data: PartialApiSnapshot[]): Map> { + const cache = new Map>(); + + for (const element of data) { + const obj = this._add(element); + cache.set(this._key(element.appId, obj.version), obj); + } + + return cache; + } + + protected _clear(data?: PartialApiSnapshot[]): void { + if (!data?.length) return this._cache.clear(); + + const mapped = new Set(data.map((v) => this._key(v.appId, v.version))); + + for (const key of this._cache.keys()) { + if (!mapped.has(key)) this._delete(key); + } + } + + protected _delete(key: string): boolean { + return this._cache.delete(key); + } + + protected _deleteMany(keys: string[]) { + for (const key of keys) this._delete(key); + } + + protected _patch(key: string, data: Partial): InstanceType | undefined { + // @ts-expect-error ts(2445) + return this._cache.get(key)?._patch(data); + } +} diff --git a/packages/discloud.app/src/managers/SnapshotsManager.ts b/packages/discloud.app/src/managers/SnapshotsManager.ts new file mode 100644 index 000000000..164c27a70 --- /dev/null +++ b/packages/discloud.app/src/managers/SnapshotsManager.ts @@ -0,0 +1,163 @@ +import { + type ApiSnapshotDownload, + type RESTGetApiSnapshotListResult, + type RESTGetApiSnapshotResult, + type RESTGetApiSnapshotVersionResult, + type RESTPostApiSnapshotResult, + Routes, +} from "@discloudapp/api-types/v2"; +import { DiscloudAPIError } from "@discloudapp/rest"; +import { constants } from "http2"; +import type DiscloudApp from "../discloudApp/DiscloudApp"; +import Snapshot from "../structures/Snapshot"; +import { validateNonEmptyString } from "../util/assertions"; +import BaseSnapshotsManager from "./BaseSnapshotsManager"; + +export interface FetchSnapshotsOptions { + page?: number; + limit?: number; + summary?: boolean; +} + +/** + * Manager for versioned snapshots on Discloud + */ +export default class SnapshotsManager extends BaseSnapshotsManager< + typeof Snapshot +> { + constructor(discloudApp: DiscloudApp) { + super(discloudApp, Snapshot); + } + + /** + * Create a new versioned snapshot for an app + * + * @throws `400` + * @throws `403` + * @throws `404` not found + * @throws `409` app busy + * + * @param appID - Your app id + */ + async create(appID: string) { + validateNonEmptyString(appID); + + const data = await this.discloudApp.rest.post( + Routes.snapshot(appID), + ); + + return this._add({ + appId: appID, + version: data.snapshot.version, + size: data.snapshot.size, + }); + } + + /** + * Generate a temporary download url for a specific snapshot version + * + * @throws `400` + * @throws `404` not found + * + * @param appID - Your app id + * @param version - Snapshot version in `YYYYMMDD-HHMMSS` format + */ + async getDownloadUrl( + appID: string, + version: string, + ): Promise { + validateNonEmptyString(appID); + validateNonEmptyString(version); + + const data = + await this.discloudApp.rest.get( + Routes.snapshot(appID, version), + ); + + return data.download; + } + + /** + * Get all versioned snapshots for an app on Discloud + * + * @throws `400` + * @throws `404` not found + * + * @param appID - Your app id + */ + async fetch(appID: string): Promise>; + /** + * Get all versioned snapshots of the authenticated user + * + * @throws `400` + */ + async fetch( + appID?: "all", + options?: FetchSnapshotsOptions, + ): Promise>; + async fetch(appID: string = "all", options: FetchSnapshotsOptions = {}) { + if (appID === "all") return this.#fetchMany(options); + + validateNonEmptyString(appID); + + try { + const data = await this.discloudApp.rest.get( + Routes.snapshot(appID), + ); + + this._clear( + data.versions.map((version) => ({ appId: appID, ...version })), + ); + + return this._addMany( + data.versions.map((version) => ({ appId: appID, ...version })), + ); + } catch (error) { + if (error instanceof DiscloudAPIError) { + switch (error.code) { + case constants.HTTP_STATUS_NOT_FOUND: + this._clear(); + break; + + default: + throw error; + } + } + + throw error; + } + } + + async #fetchMany(options: FetchSnapshotsOptions) { + const query: Record = {}; + + if (options.page !== undefined) query.page = String(options.page); + if (options.limit !== undefined) query.limit = String(options.limit); + if (options.summary !== undefined) query.summary = String(options.summary); + + try { + const data = + await this.discloudApp.rest.get( + Routes.snapshot(), + { query }, + ); + + return this._addMany( + data.backups.map((backup) => ({ appId: backup.appID, ...backup })), + ); + } catch (error) { + if (error instanceof DiscloudAPIError) { + switch (error.code) { + case constants.HTTP_STATUS_NOT_FOUND: + this._clear(); + break; + + default: + throw error; + } + } + + throw error; + } + } +} \ No newline at end of file diff --git a/packages/discloud.app/src/structures/BaseSnapshot.ts b/packages/discloud.app/src/structures/BaseSnapshot.ts new file mode 100644 index 000000000..1945f0829 --- /dev/null +++ b/packages/discloud.app/src/structures/BaseSnapshot.ts @@ -0,0 +1,23 @@ +import { ApiSnapshotVersion } from "@discloudapp/api-types/v2"; +import type DiscloudApp from "../discloudApp/DiscloudApp"; +import Base from "./Base"; + +export default abstract class BaseSnapshot extends Base { + constructor(discloudApp: DiscloudApp, readonly appId: string, data: ApiSnapshotVersion) { + super(discloudApp); + + this.version = data.version; + } + + declare readonly version: string; + declare size: number | string; + declare date: number | string; + + protected _path(data: Partial): this { + if (data.size !== undefined) this.size = data.size; + + if (data.date !== undefined) this.date = data.date; + + return super._patch(data); + } +} \ No newline at end of file diff --git a/packages/discloud.app/src/structures/Snapshot.ts b/packages/discloud.app/src/structures/Snapshot.ts new file mode 100644 index 000000000..291f5fbf2 --- /dev/null +++ b/packages/discloud.app/src/structures/Snapshot.ts @@ -0,0 +1,76 @@ +import { type ApiSnapshotVersion } from "@discloudapp/api-types/v2"; +import { FlexibleBuffer } from "@discloudapp/util"; +import { existsSync } from "fs"; +import { mkdir, open } from "fs/promises"; +import { extname, join } from "path"; +import { cwd } from "process"; +import type DiscloudApp from "../discloudApp/DiscloudApp"; +import { HttpBadStatusError } from "../errors/http"; +import { type DownloadProgressCallback } from "./AppBackup"; +import BaseSnapshot from "./BaseSnapshot"; +import Base from "./Base"; + +export default class Snapshot extends BaseSnapshot { + constructor( + discloudApp: DiscloudApp, + appId: string, + data: ApiSnapshotVersion, + ) { + super(discloudApp, appId, data); + + this._patch(data); + } + + /** + * Download this snapshot version + * + * @param path - Backup path + * @param filename - Backup file name + * @param onProgress - Callback to track download progress + */ + async download( + path: string = cwd(), + filename: string = `${this.appId}-${this.version}`, + onProgress?: DownloadProgressCallback, + ) { + const { url } = await this.discloudApp.snapshots.getDownloadUrl( + this.appId, + this.version, + ); + + if (!existsSync(path)) await mkdir(path, { recursive: true }); + + const parsedUrl = new URL(url); + + const response = await fetch(parsedUrl); + + if (!response.ok) throw HttpBadStatusError.fromResponse(response); + + const contentLength = response.headers.get("content-length"); + const total = contentLength ? parseInt(contentLength) : 0; + + const filePath = join(path, `${filename}${extname(parsedUrl.pathname)}`); + const file = await open(filePath, "w"); + + const buffer = + total > 0 ? FlexibleBuffer.fixed(total) : FlexibleBuffer.flexible(); + + try { + if (!response.body) return this; + + for await (const chunk of response.body.values()) { + await file.write(chunk); + + buffer.push(chunk); + + if (onProgress) { + await onProgress({ downloaded: buffer.length, total }); + } + } + } finally { + await file.close(); + } + + return this; + } +} diff --git a/packages/discloud.app/src/structures/SnapshotApp.ts b/packages/discloud.app/src/structures/SnapshotApp.ts new file mode 100644 index 000000000..2fea22c93 --- /dev/null +++ b/packages/discloud.app/src/structures/SnapshotApp.ts @@ -0,0 +1,13 @@ +import { type ApiSnapshotApp } from "@discloudapp/api-types/v2"; +import type DiscloudApp from "../discloudApp/DiscloudApp"; +import Base from "./Base"; + +export default class SnapshotApp extends Base { + constructor(discloudApp: DiscloudApp, data: ApiSnapshotApp) { + super(discloudApp); + + this.id = data.id; + } + + declare readonly id: string; +} \ No newline at end of file