初回コミット: 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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { DashboardView } from "@/components/altdock/dashboard-view";
|
||||
|
||||
export default function AppsPage() {
|
||||
return <DashboardView view="apps" />;
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { DashboardView } from "@/components/altdock/dashboard-view";
|
||||
|
||||
export default function DashboardPage() {
|
||||
return <DashboardView view="overview" />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { DashboardView } from "@/components/altdock/dashboard-view";
|
||||
|
||||
export default function ReleasesPage() {
|
||||
return <DashboardView view="releases" />;
|
||||
}
|
||||
|
||||
@@ -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></>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { DashboardView } from "@/components/altdock/dashboard-view";
|
||||
|
||||
export default function SourcesPage() {
|
||||
return <DashboardView view="sources" />;
|
||||
}
|
||||
|
||||
+136
File diff suppressed because one or more lines are too long
@@ -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>;
|
||||
}
|
||||
@@ -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) })));
|
||||
}
|
||||
|
||||
@@ -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.json・signatureを一切書き換えません。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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
Reference in New Issue
Block a user