初回コミット: 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
+86
View File
@@ -0,0 +1,86 @@
import { headers } from "next/headers";
import { redirect } from "next/navigation";
export type ChatGPTUser = {
displayName: string;
email: string;
fullName: string | null;
};
const USER_EMAIL_HEADER = "oai-authenticated-user-email";
const USER_FULL_NAME_HEADER = "oai-authenticated-user-full-name";
const USER_FULL_NAME_ENCODING_HEADER =
"oai-authenticated-user-full-name-encoding";
const PERCENT_ENCODED_UTF8 = "percent-encoded-utf-8";
const SIGN_IN_PATH = "/signin-with-chatgpt";
const SIGN_OUT_PATH = "/signout-with-chatgpt";
const CALLBACK_PATH = "/callback";
export async function getChatGPTUser(): Promise<ChatGPTUser | null> {
const requestHeaders = await headers();
const email = requestHeaders.get(USER_EMAIL_HEADER);
if (!email) return null;
const encodedFullName = requestHeaders.get(USER_FULL_NAME_HEADER);
const fullName =
encodedFullName &&
requestHeaders.get(USER_FULL_NAME_ENCODING_HEADER) === PERCENT_ENCODED_UTF8
? safeDecodeURIComponent(encodedFullName)
: null;
return {
displayName: fullName ?? email,
email,
fullName,
};
}
export async function requireChatGPTUser(
returnTo: string,
): Promise<ChatGPTUser> {
const user = await getChatGPTUser();
if (user) return user;
redirect(chatGPTSignInPath(returnTo));
}
export function chatGPTSignInPath(returnTo: string): string {
const safeReturnTo = safeRelativeReturnPath(returnTo);
return `${SIGN_IN_PATH}?return_to=${encodeURIComponent(safeReturnTo)}`;
}
export function chatGPTSignOutPath(returnTo = "/"): string {
const safeReturnTo = safeRelativeReturnPath(returnTo);
return `${SIGN_OUT_PATH}?return_to=${encodeURIComponent(safeReturnTo)}`;
}
function safeRelativeReturnPath(value: string): string {
if (!value.startsWith("/") || value.startsWith("//")) return "/";
let url: URL;
try {
url = new URL(value, "https://app.local");
} catch {
return "/";
}
if (url.origin !== "https://app.local") return "/";
if (isReservedAuthPath(url.pathname)) return "/";
return `${url.pathname}${url.search}${url.hash}`;
}
function isReservedAuthPath(pathname: string): boolean {
return (
pathname === SIGN_IN_PATH ||
pathname === SIGN_OUT_PATH ||
pathname === CALLBACK_PATH
);
}
function safeDecodeURIComponent(value: string): string | null {
try {
return decodeURIComponent(value);
} catch {
return null;
}
}
+6
View File
@@ -0,0 +1,6 @@
import { DashboardView } from "@/components/altdock/dashboard-view";
export default function AppsPage() {
return <DashboardView view="apps" />;
}
+8
View File
@@ -0,0 +1,8 @@
import type { ReactNode } from "react";
import { DashboardProvider } from "@/components/altdock/dashboard-provider";
import { AdminShell } from "@/components/altdock/admin-shell";
export default function DashboardLayout({ children }: { children: ReactNode }) {
return <DashboardProvider><AdminShell>{children}</AdminShell></DashboardProvider>;
}
+6
View File
@@ -0,0 +1,6 @@
import { DashboardView } from "@/components/altdock/dashboard-view";
export default function DashboardPage() {
return <DashboardView view="overview" />;
}
+6
View File
@@ -0,0 +1,6 @@
import { DashboardView } from "@/components/altdock/dashboard-view";
export default function ReleasesPage() {
return <DashboardView view="releases" />;
}
+7
View File
@@ -0,0 +1,7 @@
import Link from "next/link";
import { ArrowUpRight, KeyRound, ShieldCheck, SlidersHorizontal } from "lucide-react";
export default function SettingsPage() {
return <><div className="admin-page-heading"><div><div className="admin-eyebrow">WORKSPACE SETTINGS</div><h1></h1><p></p></div></div><section className="admin-section-card settings-placeholder"><div className="settings-placeholder-icon"><SlidersHorizontal size={20} /></div><h2></h2><p>SSO</p><div className="settings-placeholder-grid"><div><ShieldCheck size={17} /><strong></strong><span>OIDC / demo mode</span></div><div><KeyRound size={17} /><strong></strong><span>Workspace単位の認可</span></div></div><Link className="admin-link" href="https://faq.altstore.io/developers/distribute-with-altstore-pal" target="_blank">PALドキュメントを開く <ArrowUpRight size={14} /></Link></section></>;
}
+6
View File
@@ -0,0 +1,6 @@
import { DashboardView } from "@/components/altdock/dashboard-view";
export default function SourcesPage() {
return <DashboardView view="sources" />;
}
+136
View File
File diff suppressed because one or more lines are too long
+16
View File
@@ -0,0 +1,16 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({ variable: "--font-geist-sans", subsets: ["latin"] });
const geistMono = Geist_Mono({ variable: "--font-geist-mono", subsets: ["latin"] });
export const metadata: Metadata = {
title: "AltDock — Workspace dashboard",
description: "Manage AltStore PAL Sources, apps, and notarized ADP releases.",
icons: { icon: "/favicon.svg", shortcut: "/favicon.svg" },
};
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
return <html lang="ja"><body className={`${geistSans.variable} ${geistMono.variable}`}>{children}</body></html>;
}
+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) })));
}
+53
View File
@@ -0,0 +1,53 @@
"use client";
import Link from "next/link";
import { ArrowRight, Boxes, FileArchive, Gauge, KeyRound, ShieldCheck, UploadCloud } from "lucide-react";
import { AUTH_MODE, API_BASE } from "@/app/lib/dashboard";
export default function Home() {
return <main className="landing">
<header className="landing-header">
<Link className="landing-brand" href="/"><span className="admin-brand-mark">A</span><span>AltDock</span><span className="landing-beta">BETA</span></Link>
<nav className="landing-nav">
<a href="https://faq.altstore.io/developers/distribute-with-altstore-pal" target="_blank" rel="noreferrer">PALドキュメント</a>
<a href="https://developer.apple.com/documentation/marketplacekit/ingesting-an-alternative-distribution-package" target="_blank" rel="noreferrer">ADP仕様</a>
{AUTH_MODE === "oidc" ? <a className="landing-signin" href={`${API_BASE}/auth/login`}>SSOでログイン</a> : <Link className="landing-signin" href="/dashboard"> <ArrowRight size={14} /></Link>}
</nav>
</header>
<section className="landing-hero">
<div className="landing-hero-copy">
<div className="landing-eyebrow">ALTSTORE PAL · ADP HOSTING</div>
<h1>Notarization済みのADPを<br />PALへ届ける</h1>
<p>Appleの検証チェーンを壊さずADP ZIPをアップロードするだけでAltStore PAL互換のSourceを公開できるホスティング基盤ですManifestとsignatureは再シリアライズせず</p>
<div className="landing-hero-actions">
<Link className="ui-button ui-button-primary" href="/dashboard"> <ArrowRight size={15} /></Link>
<a className="ui-button ui-button-outline" href="https://faq.altstore.io/developers/distribute-with-altstore-pal" target="_blank" rel="noreferrer"></a>
</div>
<div className="landing-hero-meta"><Gauge size={14} />Demo mode · API <code>{API_BASE}</code></div>
</div>
<aside className="landing-hero-card" aria-hidden="true">
<div className="landing-flow-step"><span className="landing-flow-num">1</span><span><UploadCloud size={16} />ADP ZIPをアップロード</span></div>
<div className="landing-flow-arrow" />
<div className="landing-flow-step"><span className="landing-flow-num">2</span><span><ShieldCheck size={16} />Manifest</span></div>
<div className="landing-flow-arrow" />
<div className="landing-flow-step done"><span className="landing-flow-num">3</span><span><Boxes size={16} />PAL Sourceを公開</span></div>
</aside>
</section>
<section className="landing-features">
<article className="landing-feature"><FileArchive size={18} /><h2></h2><p>ADPの階層manifest.jsonsignatureを一切書き換えませんAppleの検証チェーンがそのまま通ります</p></article>
<article className="landing-feature"><ShieldCheck size={18} /><h2></h2><p>ZIP爆弾Workerで検査し</p></article>
<article className="landing-feature"><KeyRound size={18} /><h2>Workspace単位の認可</h2><p>OIDC SSOでユーザーを識別しWorkspaceごとにSource</p></article>
</section>
<footer className="landing-footer">
<span>AltDock · AltStore PAL向け ADP </span>
<div className="landing-footer-links">
<Link href="/dashboard"></Link>
<a href="https://faq.altstore.io/developers/make-a-source" target="_blank" rel="noreferrer">Source仕様</a>
<a href={`${API_BASE}/healthz`} target="_blank" rel="noreferrer">APIステータス</a>
</div>
</footer>
</main>;
}
+45
View File
@@ -0,0 +1,45 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
type SourceDocument = {
name: string;
subtitle?: string;
description?: string;
iconURL?: string;
tintColor?: string;
apps: Array<{ name: string; bundleIdentifier: string; developerName: string; subtitle?: string; localizedDescription: string; iconURL?: string; category: string; versions: Array<{ version: string; buildVersion: string; date: string; downloadURL: string; size: number }> }>;
};
const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:4000";
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 default function PublicSourceView({ slug }: { slug: string }) {
const [document, setDocument] = useState<SourceDocument | null>(null);
const [error, setError] = useState(false);
useEffect(() => {
fetch(`${API_BASE}/sources/${slug}/source.json`)
.then((response) => response.ok ? response.json() : Promise.reject(new Error("not found")))
.then(setDocument)
.catch(() => setError(true));
}, [slug]);
if (error) return <main className="public-source-error"><span className="brand-mark">A</span><h1>Source not found</h1><p>This Source is still a draft or the URL is incorrect.</p><Link href="/">Open AltDock</Link></main>;
if (!document) return <main className="public-source-loading"><span className="brand-mark">A</span><p>Loading Source</p></main>;
return <main className="public-source-page" style={{ "--source-tint": document.tintColor || "#E9694B" } as React.CSSProperties}>
<header className="public-source-header"><Link className="public-brand" href="/"><span className="brand-mark">A</span><span>AltDock</span></Link><span className="public-badge">ALTSTORE PAL SOURCE</span></header>
<section className="public-source-hero"><div className="public-source-avatar">{document.iconURL ? <img src={document.iconURL} alt="" /> : document.name.slice(0, 1)}</div><div><div className="section-kicker">SOURCE / {slug}</div><h1>{document.name}</h1><p>{document.subtitle}</p></div><button className="button primary" onClick={() => navigator.clipboard?.writeText(`${API_BASE}/sources/${slug}/source.json`)}>Copy Source URL</button></section>
{document.description && <p className="public-source-description">{document.description}</p>}
<section className="public-app-grid">{document.apps.length ? document.apps.map((app) => { const release = app.versions[0]; return <article className="public-app-card" key={app.bundleIdentifier}><div className="public-app-card-top"><span className="public-app-icon" style={{ background: document.tintColor || "#E9694B" }}>{app.iconURL ? <img src={app.iconURL} alt="" /> : app.name.slice(0, 1)}</span><span className="public-category">{app.category}</span></div><h2>{app.name}</h2><p className="public-developer">{app.developerName} · {app.bundleIdentifier}</p><p className="public-app-description">{app.localizedDescription || app.subtitle || "A PAL-ready iOS app."}</p>{release ? <div className="public-release"><span>Latest release</span><strong>v{release.version} <small>build {release.buildVersion}</small></strong><span>{formatBytes(release.size)}</span></div> : <div className="public-release"><span>No published release yet</span></div>}</article>; }) : <div className="public-no-apps"><strong>No published apps yet.</strong><p>Come back when the developer ships their first release.</p></div>}</section>
<footer className="public-source-footer"><span>Powered by AltDock</span><span>Source JSON is available for AltStore PAL.</span></footer>
</main>;
}
+6
View File
@@ -0,0 +1,6 @@
import PublicSourceView from "./PublicSourceView";
export default async function PublicSourcePage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
return <PublicSourceView slug={slug} />;
}