初回コミット: 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
+105
View File
@@ -0,0 +1,105 @@
import type { AppRecord, ReleaseRecord, SourceRecord } from "@/packages/core/src/index";
export type DashboardSource = {
source: SourceRecord;
apps: AppRecord[];
releases: ReleaseRecord[];
};
export type DashboardData = {
workspace: {
name: string;
slug: string;
};
sources: DashboardSource[];
usage: {
storageBytes: number;
sourceCount: number;
};
limits: {
maxSources: number;
maxAppsPerSource: number;
maxStorageBytes: number;
maxUploadBytes: number;
};
};
export type Notice = {
tone: "success" | "error" | "info";
text: string;
};
export type Visibility = SourceRecord["visibility"];
export const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:4000";
export const AUTH_MODE = process.env.NEXT_PUBLIC_AUTH_MODE || "demo";
export const DEMO_HEADERS = { "x-demo-user": "demo@altdock.local" };
export async function api<T>(path: string, init: RequestInit = {}) {
const headers = new Headers(init.headers);
headers.set("accept", "application/json");
if (init.body) headers.set("content-type", "application/json");
Object.entries(DEMO_HEADERS).forEach(([key, value]) => headers.set(key, value));
const response = await fetch(`${API_BASE}${path}`, { ...init, headers, credentials: "include" });
const body = (await response.json().catch(() => ({}))) as T & { error?: string; detail?: string };
if (!response.ok) throw new Error(body.detail || body.error || `Request failed (${response.status})`);
return body;
}
export function formatBytes(value: number) {
if (!value) return "0 B";
const units = ["B", "KB", "MB", "GB"];
const index = Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1);
return `${(value / 1024 ** index).toFixed(index === 0 ? 0 : 1)} ${units[index]}`;
}
export function formatDate(value: string) {
return new Intl.DateTimeFormat("ja-JP", { year: "numeric", month: "short", day: "numeric" }).format(new Date(value));
}
export function formatDateTime(value: string) {
return new Intl.DateTimeFormat("ja-JP", { year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }).format(new Date(value));
}
export function statusLabel(status: ReleaseRecord["status"]) {
return {
uploaded: "アップロード済み",
processing: "処理中",
ready: "公開準備完了",
rejected: "要修正",
published: "公開中",
archived: "アーカイブ",
}[status];
}
export function statusTone(status: ReleaseRecord["status"]) {
return {
uploaded: "neutral",
processing: "info",
ready: "warning",
rejected: "danger",
published: "success",
archived: "neutral",
}[status] as "neutral" | "info" | "warning" | "danger" | "success";
}
export function visibilityLabel(visibility: Visibility) {
return { draft: "下書き", unlisted: "限定公開", public: "公開" }[visibility];
}
export function visibilityTone(visibility: Visibility) {
return { draft: "neutral", unlisted: "warning", public: "success" }[visibility] as "neutral" | "warning" | "success";
}
export function flattenSources(dashboard: DashboardData) {
return dashboard.sources;
}
export function flattenApps(dashboard: DashboardData) {
return dashboard.sources.flatMap((entry) => entry.apps.map((app) => ({ app, source: entry.source, releases: entry.releases.filter((release) => release.appId === app.id) })));
}
export function flattenReleases(dashboard: DashboardData) {
return dashboard.sources.flatMap((entry) => entry.releases.map((release) => ({ release, source: entry.source, app: entry.apps.find((app) => app.id === release.appId) })));
}