初回コミット: AltStore PAL向けADPホスティングSaaS

- Fastify API・Node Worker・PostgreSQL・S3互換ストレージ構成
- ADP ZIPのマルチパートアップロードとManifest検証(パストラバーサル・ZIP爆弾等を拒否)
- manifest.json/signatureは再シリアライズせず元バイト列を保持
- Cloudflare風の運用向け管理ダッシュボード(shadcn/Radix・日本語UI・4ルート)
  概要/Sources/アプリ/リリース + ルートランディングページ
- 汎用OIDC SSO・Workspace単位の認可・demo mode
- 匿名配布: /sources/{slug}/source.json, /artifacts/{releaseId}/manifest.json
- Docker Compose対応
This commit is contained in:
amania-jailbreak
2026-08-05 10:09:58 +09:00
commit 93825ebc64
59 changed files with 16065 additions and 0 deletions
+191
View File
@@ -0,0 +1,191 @@
import { createReadStream, createWriteStream } from "node:fs";
import { mkdir, stat, writeFile } from "node:fs/promises";
/* eslint-disable @typescript-eslint/no-explicit-any */
import { dirname, normalize, relative, resolve } from "node:path";
import { pipeline } from "node:stream/promises";
import { Readable } from "node:stream";
import {
CompleteMultipartUploadCommand,
CreateMultipartUploadCommand,
GetObjectCommand,
HeadObjectCommand,
PutObjectCommand,
S3Client,
UploadPartCommand,
} from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import type { AppConfig } from "./config";
export interface StoredObject {
body: Readable;
sizeBytes: number;
contentType: string;
}
export type UploadPlan =
| { mode: "single"; uploadUrl: string }
| { mode: "multipart"; uploadId: string; partSizeBytes: number; parts: Array<{ partNumber: number; uploadUrl: string }> };
export interface StorageAdapter {
createUploadPlan(key: string, contentType: string, sizeBytes: number): Promise<UploadPlan>;
completeMultipartUpload(key: string, uploadId: string | undefined, parts: Array<{ partNumber: number; etag: string }> | undefined): Promise<void>;
writeUploadFromStream(key: string, stream: NodeJS.ReadableStream): Promise<number>;
writeObject(key: string, body: Buffer, contentType: string): Promise<void>;
writeStream(key: string, transform: NodeJS.ReadWriteStream, contentType: string, sizeBytes: number, source: NodeJS.ReadableStream): Promise<void>;
downloadToFile(key: string, filePath: string): Promise<void>;
getObject(key: string): Promise<StoredObject | null>;
headObject(key: string): Promise<{ sizeBytes: number; contentType: string } | null>;
}
function contentTypeForPath(path: string) {
if (path.endsWith(".json")) return "application/json; charset=utf-8";
if (path.endsWith(".ipa")) return "application/octet-stream";
return "application/octet-stream";
}
export class LocalStorage implements StorageAdapter {
private readonly root: string;
constructor(root: string) {
this.root = resolve(root);
}
private pathFor(key: string) {
const candidate = resolve(this.root, normalize(key));
const relativePath = relative(this.root, candidate);
if (!relativePath || relativePath.startsWith("..") || relativePath.includes("..")) {
throw new Error("STORAGE_PATH_INVALID");
}
return candidate;
}
async createUploadPlan(key: string) {
return { mode: "single" as const, uploadUrl: `/api/v1/uploads/content/${encodeURIComponent(key)}` };
}
async completeMultipartUpload() {
return;
}
async writeUploadFromStream(key: string, stream: NodeJS.ReadableStream) {
const filePath = this.pathFor(key);
await mkdir(dirname(filePath), { recursive: true });
await pipeline(stream, createWriteStream(filePath, { flags: "w" }));
return Number((await stat(filePath)).size);
}
async writeObject(key: string, body: Buffer) {
const filePath = this.pathFor(key);
await mkdir(dirname(filePath), { recursive: true });
await writeFile(filePath, body);
}
async writeStream(key: string, transform: NodeJS.ReadWriteStream, _contentType: string, _sizeBytes: number, source: NodeJS.ReadableStream) {
const filePath = this.pathFor(key);
await mkdir(dirname(filePath), { recursive: true });
await pipeline(source, transform, createWriteStream(filePath, { flags: "w" }));
}
async downloadToFile(key: string, filePath: string) {
await mkdir(dirname(filePath), { recursive: true });
await pipeline(createReadStream(this.pathFor(key)), createWriteStream(filePath, { flags: "w" }));
}
async getObject(key: string) {
const filePath = this.pathFor(key);
try {
const fileStat = await stat(filePath);
return { body: createReadStream(filePath), sizeBytes: Number(fileStat.size), contentType: contentTypeForPath(key) };
} catch {
return null;
}
}
async headObject(key: string) {
try {
const fileStat = await stat(this.pathFor(key));
return { sizeBytes: Number(fileStat.size), contentType: contentTypeForPath(key) };
} catch {
return null;
}
}
}
export class S3Storage implements StorageAdapter {
private readonly client: S3Client;
constructor(private readonly appConfig: AppConfig) {
this.client = new S3Client({
region: appConfig.S3_REGION,
endpoint: appConfig.S3_ENDPOINT,
forcePathStyle: appConfig.S3_FORCE_PATH_STYLE,
credentials:
appConfig.S3_ACCESS_KEY_ID && appConfig.S3_SECRET_ACCESS_KEY
? { accessKeyId: appConfig.S3_ACCESS_KEY_ID, secretAccessKey: appConfig.S3_SECRET_ACCESS_KEY }
: undefined,
});
}
async createUploadPlan(key: string, contentType: string, sizeBytes: number) {
const partSizeBytes = 16 * 1024 * 1024;
const partCount = Math.ceil(sizeBytes / partSizeBytes);
if (partCount > 10000) throw new Error("UPLOAD_PART_COUNT_LIMIT");
const multipart = await this.client.send(new CreateMultipartUploadCommand({ Bucket: this.appConfig.S3_BUCKET, Key: key, ContentType: contentType }));
if (!multipart.UploadId) throw new Error("MULTIPART_UPLOAD_ID_MISSING");
const parts = await Promise.all(Array.from({ length: partCount }, async (_, index) => ({ partNumber: index + 1, uploadUrl: await getSignedUrl(this.client, new UploadPartCommand({ Bucket: this.appConfig.S3_BUCKET, Key: key, UploadId: multipart.UploadId, PartNumber: index + 1 }), { expiresIn: 900 }) })));
return { mode: "multipart" as const, uploadId: multipart.UploadId, partSizeBytes, parts };
}
async completeMultipartUpload(key: string, uploadId: string | undefined, parts: Array<{ partNumber: number; etag: string }> | undefined) {
if (!uploadId || !parts?.length) throw new Error("MULTIPART_PARTS_REQUIRED");
await this.client.send(new CompleteMultipartUploadCommand({ Bucket: this.appConfig.S3_BUCKET, Key: key, UploadId: uploadId, MultipartUpload: { Parts: parts.sort((a, b) => a.partNumber - b.partNumber).map((part) => ({ PartNumber: part.partNumber, ETag: part.etag })) } }));
}
async writeUploadFromStream(key: string, stream: NodeJS.ReadableStream) {
await this.client.send(new PutObjectCommand({ Bucket: this.appConfig.S3_BUCKET, Key: key, Body: stream as any }));
const metadata = await this.headObject(key);
return metadata?.sizeBytes || 0;
}
async writeObject(key: string, body: Buffer, contentType: string) {
await this.client.send(new PutObjectCommand({ Bucket: this.appConfig.S3_BUCKET, Key: key, Body: body, ContentType: contentType }));
}
async writeStream(key: string, transform: NodeJS.ReadWriteStream, contentType: string, sizeBytes: number, source: NodeJS.ReadableStream) {
const upload = this.client.send(new PutObjectCommand({ Bucket: this.appConfig.S3_BUCKET, Key: key, Body: transform as any, ContentType: contentType, ContentLength: sizeBytes || undefined }));
await pipeline(source, transform);
await upload;
}
async downloadToFile(key: string, filePath: string) {
const response = await this.client.send(new GetObjectCommand({ Bucket: this.appConfig.S3_BUCKET, Key: key }));
if (!response.Body) throw new Error("STORAGE_OBJECT_EMPTY");
await mkdir(dirname(filePath), { recursive: true });
const body = response.Body instanceof Readable ? response.Body : Readable.fromWeb(response.Body as any);
await pipeline(body, createWriteStream(filePath, { flags: "w" }));
}
async getObject(key: string) {
try {
const response = await this.client.send(new GetObjectCommand({ Bucket: this.appConfig.S3_BUCKET, Key: key }));
if (!response.Body) return null;
const body = response.Body instanceof Readable ? response.Body : Readable.fromWeb(response.Body as any);
return { body, sizeBytes: Number(response.ContentLength || 0), contentType: response.ContentType || contentTypeForPath(key) };
} catch {
return null;
}
}
async headObject(key: string) {
try {
const response = await this.client.send(new HeadObjectCommand({ Bucket: this.appConfig.S3_BUCKET, Key: key }));
return { sizeBytes: Number(response.ContentLength || 0), contentType: response.ContentType || contentTypeForPath(key) };
} catch {
return null;
}
}
}
export function createStorage(appConfig: AppConfig): StorageAdapter {
return appConfig.STORAGE_MODE === "s3" ? new S3Storage(appConfig) : new LocalStorage(appConfig.LOCAL_STORAGE_DIR);
}