Restrict company imports to GitHub and zip packages
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -120,10 +120,6 @@ export type CompanyPortabilitySource =
|
||||
rootPath?: string | null;
|
||||
files: Record<string, string>;
|
||||
}
|
||||
| {
|
||||
type: "url";
|
||||
url: string;
|
||||
}
|
||||
| {
|
||||
type: "github";
|
||||
url: string;
|
||||
|
||||
@@ -123,10 +123,6 @@ export const portabilitySourceSchema = z.discriminatedUnion("type", [
|
||||
rootPath: z.string().min(1).optional().nullable(),
|
||||
files: z.record(z.string()),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("url"),
|
||||
url: z.string().url(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("github"),
|
||||
url: z.string().url(),
|
||||
|
||||
@@ -1380,33 +1380,6 @@ export function companyPortabilityService(db: Db) {
|
||||
);
|
||||
}
|
||||
|
||||
if (source.type === "url") {
|
||||
const normalizedUrl = source.url.trim();
|
||||
const companyUrl = normalizedUrl.endsWith(".md")
|
||||
? normalizedUrl
|
||||
: new URL("COMPANY.md", normalizedUrl.endsWith("/") ? normalizedUrl : `${normalizedUrl}/`).toString();
|
||||
const companyMarkdown = await fetchText(companyUrl);
|
||||
const files: Record<string, string> = {
|
||||
"COMPANY.md": companyMarkdown,
|
||||
};
|
||||
const paperclipYaml = await fetchOptionalText(
|
||||
new URL(".paperclip.yaml", companyUrl).toString(),
|
||||
).catch(() => null);
|
||||
if (paperclipYaml) {
|
||||
files[".paperclip.yaml"] = paperclipYaml;
|
||||
}
|
||||
const companyDoc = parseFrontmatterMarkdown(companyMarkdown);
|
||||
const includeEntries = readIncludeEntries(companyDoc.frontmatter);
|
||||
|
||||
for (const includeEntry of includeEntries) {
|
||||
const includePath = normalizePortablePath(includeEntry.path);
|
||||
if (!includePath.endsWith(".md")) continue;
|
||||
const includeUrl = new URL(includeEntry.path, companyUrl).toString();
|
||||
files[includePath] = await fetchText(includeUrl);
|
||||
}
|
||||
return buildManifestFromPackageFiles(files);
|
||||
}
|
||||
|
||||
const parsed = parseGitHubSourceUrl(source.url);
|
||||
let ref = parsed.ref;
|
||||
const warnings: string[] = [];
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createZipArchive } from "./zip";
|
||||
import { createZipArchive, readZipArchive } from "./zip";
|
||||
|
||||
function readUint16(bytes: Uint8Array, offset: number) {
|
||||
return bytes[offset]! | (bytes[offset + 1]! << 8);
|
||||
@@ -50,4 +50,24 @@ describe("createZipArchive", () => {
|
||||
expect(readUint16(archive, endOffset + 8)).toBe(2);
|
||||
expect(readUint16(archive, endOffset + 10)).toBe(2);
|
||||
});
|
||||
|
||||
it("reads a Paperclip zip archive back into rootPath and file contents", () => {
|
||||
const archive = createZipArchive(
|
||||
{
|
||||
"COMPANY.md": "# Company\n",
|
||||
"agents/ceo/AGENTS.md": "# CEO\n",
|
||||
".paperclip.yaml": "schema: paperclip/v1\n",
|
||||
},
|
||||
"paperclip-demo",
|
||||
);
|
||||
|
||||
expect(readZipArchive(archive)).toEqual({
|
||||
rootPath: "paperclip-demo",
|
||||
files: {
|
||||
"COMPANY.md": "# Company\n",
|
||||
"agents/ceo/AGENTS.md": "# CEO\n",
|
||||
".paperclip.yaml": "schema: paperclip/v1\n",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const textEncoder = new TextEncoder();
|
||||
const textDecoder = new TextDecoder();
|
||||
|
||||
const crcTable = new Uint32Array(256);
|
||||
for (let i = 0; i < 256; i++) {
|
||||
@@ -37,6 +38,19 @@ function writeUint32(target: Uint8Array, offset: number, value: number) {
|
||||
target[offset + 3] = (value >>> 24) & 0xff;
|
||||
}
|
||||
|
||||
function readUint16(source: Uint8Array, offset: number) {
|
||||
return source[offset]! | (source[offset + 1]! << 8);
|
||||
}
|
||||
|
||||
function readUint32(source: Uint8Array, offset: number) {
|
||||
return (
|
||||
source[offset]! |
|
||||
(source[offset + 1]! << 8) |
|
||||
(source[offset + 2]! << 16) |
|
||||
(source[offset + 3]! << 24)
|
||||
) >>> 0;
|
||||
}
|
||||
|
||||
function getDosDateTime(date: Date) {
|
||||
const year = Math.min(Math.max(date.getFullYear(), 1980), 2107);
|
||||
const month = date.getMonth() + 1;
|
||||
@@ -62,6 +76,84 @@ function concatChunks(chunks: Uint8Array[]) {
|
||||
return archive;
|
||||
}
|
||||
|
||||
function sharedArchiveRoot(paths: string[]) {
|
||||
if (paths.length === 0) return null;
|
||||
const firstSegments = paths
|
||||
.map((entry) => normalizeArchivePath(entry).split("/").filter(Boolean))
|
||||
.filter((parts) => parts.length > 0);
|
||||
if (firstSegments.length === 0) return null;
|
||||
const candidate = firstSegments[0]![0]!;
|
||||
return firstSegments.every((parts) => parts.length > 1 && parts[0] === candidate)
|
||||
? candidate
|
||||
: null;
|
||||
}
|
||||
|
||||
export function readZipArchive(source: ArrayBuffer | Uint8Array): {
|
||||
rootPath: string | null;
|
||||
files: Record<string, string>;
|
||||
} {
|
||||
const bytes = source instanceof Uint8Array ? source : new Uint8Array(source);
|
||||
const entries: Array<{ path: string; body: string }> = [];
|
||||
let offset = 0;
|
||||
|
||||
while (offset + 4 <= bytes.length) {
|
||||
const signature = readUint32(bytes, offset);
|
||||
if (signature === 0x02014b50 || signature === 0x06054b50) break;
|
||||
if (signature !== 0x04034b50) {
|
||||
throw new Error("Invalid zip archive: unsupported local file header.");
|
||||
}
|
||||
|
||||
if (offset + 30 > bytes.length) {
|
||||
throw new Error("Invalid zip archive: truncated local file header.");
|
||||
}
|
||||
|
||||
const generalPurposeFlag = readUint16(bytes, offset + 6);
|
||||
const compressionMethod = readUint16(bytes, offset + 8);
|
||||
const compressedSize = readUint32(bytes, offset + 18);
|
||||
const fileNameLength = readUint16(bytes, offset + 26);
|
||||
const extraFieldLength = readUint16(bytes, offset + 28);
|
||||
|
||||
if ((generalPurposeFlag & 0x0008) !== 0) {
|
||||
throw new Error("Unsupported zip archive: data descriptors are not supported.");
|
||||
}
|
||||
if (compressionMethod !== 0) {
|
||||
throw new Error("Unsupported zip archive: only uncompressed entries are supported.");
|
||||
}
|
||||
|
||||
const nameOffset = offset + 30;
|
||||
const bodyOffset = nameOffset + fileNameLength + extraFieldLength;
|
||||
const bodyEnd = bodyOffset + compressedSize;
|
||||
if (bodyEnd > bytes.length) {
|
||||
throw new Error("Invalid zip archive: truncated file contents.");
|
||||
}
|
||||
|
||||
const archivePath = normalizeArchivePath(
|
||||
textDecoder.decode(bytes.slice(nameOffset, nameOffset + fileNameLength)),
|
||||
);
|
||||
if (archivePath && !archivePath.endsWith("/")) {
|
||||
entries.push({
|
||||
path: archivePath,
|
||||
body: textDecoder.decode(bytes.slice(bodyOffset, bodyEnd)),
|
||||
});
|
||||
}
|
||||
|
||||
offset = bodyEnd;
|
||||
}
|
||||
|
||||
const rootPath = sharedArchiveRoot(entries.map((entry) => entry.path));
|
||||
const files: Record<string, string> = {};
|
||||
for (const entry of entries) {
|
||||
const normalizedPath =
|
||||
rootPath && entry.path.startsWith(`${rootPath}/`)
|
||||
? entry.path.slice(rootPath.length + 1)
|
||||
: entry.path;
|
||||
if (!normalizedPath) continue;
|
||||
files[normalizedPath] = entry.body;
|
||||
}
|
||||
|
||||
return { rootPath, files };
|
||||
}
|
||||
|
||||
export function createZipArchive(files: Record<string, string>, rootPath: string): Uint8Array {
|
||||
const normalizedRoot = normalizeArchivePath(rootPath);
|
||||
const localChunks: Uint8Array[] = [];
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
Check,
|
||||
Download,
|
||||
Github,
|
||||
Link2,
|
||||
Package,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
@@ -33,6 +32,7 @@ import {
|
||||
FRONTMATTER_FIELD_LABELS,
|
||||
PackageFileTree,
|
||||
} from "../components/PackageFileTree";
|
||||
import { readZipArchive } from "../lib/zip";
|
||||
|
||||
// ── Import-specific helpers ───────────────────────────────────────────
|
||||
|
||||
@@ -253,12 +253,19 @@ function buildConflictList(
|
||||
return conflicts;
|
||||
}
|
||||
|
||||
/** Extract a prefix from the import source URL or local folder name */
|
||||
function deriveSourcePrefix(sourceMode: string, importUrl: string, localRootPath: string | null): string | null {
|
||||
if (sourceMode === "local" && localRootPath) {
|
||||
return localRootPath.split("/").pop() ?? null;
|
||||
/** Extract a prefix from the import source URL or uploaded zip package name */
|
||||
function deriveSourcePrefix(
|
||||
sourceMode: string,
|
||||
importUrl: string,
|
||||
localPackageName: string | null,
|
||||
localRootPath: string | null,
|
||||
): string | null {
|
||||
if (sourceMode === "local") {
|
||||
if (localRootPath) return localRootPath.split("/").pop() ?? null;
|
||||
if (!localPackageName) return null;
|
||||
return localPackageName.replace(/\.zip$/i, "") || null;
|
||||
}
|
||||
if (sourceMode === "github" || sourceMode === "url") {
|
||||
if (sourceMode === "github") {
|
||||
const url = importUrl.trim();
|
||||
if (!url) return null;
|
||||
try {
|
||||
@@ -407,30 +414,23 @@ function ConflictResolutionList({
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
async function readLocalPackageSelection(fileList: FileList): Promise<{
|
||||
async function readLocalPackageZip(file: File): Promise<{
|
||||
name: string;
|
||||
rootPath: string | null;
|
||||
files: Record<string, string>;
|
||||
}> {
|
||||
const files: Record<string, string> = {};
|
||||
let rootPath: string | null = null;
|
||||
for (const file of Array.from(fileList)) {
|
||||
const relativePath =
|
||||
(file as File & { webkitRelativePath?: string }).webkitRelativePath?.replace(
|
||||
/\\/g,
|
||||
"/",
|
||||
) || file.name;
|
||||
const isMarkdown = relativePath.endsWith(".md");
|
||||
const isPaperclipYaml =
|
||||
relativePath.endsWith(".paperclip.yaml") || relativePath.endsWith(".paperclip.yml");
|
||||
if (!isMarkdown && !isPaperclipYaml) continue;
|
||||
const topLevel = relativePath.split("/")[0] ?? null;
|
||||
if (!rootPath && topLevel) rootPath = topLevel;
|
||||
files[relativePath] = await file.text();
|
||||
if (!/\.zip$/i.test(file.name)) {
|
||||
throw new Error("Select a .zip company package.");
|
||||
}
|
||||
if (Object.keys(files).length === 0) {
|
||||
throw new Error("No package files were found in the selected folder.");
|
||||
const archive = readZipArchive(await file.arrayBuffer());
|
||||
if (Object.keys(archive.files).length === 0) {
|
||||
throw new Error("No package files were found in the selected zip archive.");
|
||||
}
|
||||
return { rootPath, files };
|
||||
return {
|
||||
name: file.name,
|
||||
rootPath: archive.rootPath,
|
||||
files: archive.files,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Main page ─────────────────────────────────────────────────────────
|
||||
@@ -447,9 +447,10 @@ export function CompanyImport() {
|
||||
const packageInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
// Source state
|
||||
const [sourceMode, setSourceMode] = useState<"github" | "url" | "local">("github");
|
||||
const [sourceMode, setSourceMode] = useState<"github" | "local">("github");
|
||||
const [importUrl, setImportUrl] = useState("");
|
||||
const [localPackage, setLocalPackage] = useState<{
|
||||
name: string;
|
||||
rootPath: string | null;
|
||||
files: Record<string, string>;
|
||||
} | null>(null);
|
||||
@@ -484,15 +485,9 @@ export function CompanyImport() {
|
||||
}
|
||||
const url = importUrl.trim();
|
||||
if (!url) return null;
|
||||
if (sourceMode === "github") return { type: "github", url };
|
||||
return { type: "url", url };
|
||||
return { type: "github", url };
|
||||
}
|
||||
|
||||
const sourcePrefix = useMemo(
|
||||
() => deriveSourcePrefix(sourceMode, importUrl, localPackage?.rootPath ?? null),
|
||||
[sourceMode, importUrl, localPackage],
|
||||
);
|
||||
|
||||
// Preview mutation
|
||||
const previewMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
@@ -513,7 +508,12 @@ export function CompanyImport() {
|
||||
|
||||
// Build conflicts and set default name overrides with prefix
|
||||
const conflicts = buildConflictList(result);
|
||||
const prefix = deriveSourcePrefix(sourceMode, importUrl, localPackage?.rootPath ?? null);
|
||||
const prefix = deriveSourcePrefix(
|
||||
sourceMode,
|
||||
importUrl,
|
||||
localPackage?.name ?? null,
|
||||
localPackage?.rootPath ?? null,
|
||||
);
|
||||
const defaultOverrides: Record<string, string> = {};
|
||||
|
||||
for (const c of conflicts) {
|
||||
@@ -625,7 +625,7 @@ export function CompanyImport() {
|
||||
const fileList = e.target.files;
|
||||
if (!fileList || fileList.length === 0) return;
|
||||
try {
|
||||
const pkg = await readLocalPackageSelection(fileList);
|
||||
const pkg = await readLocalPackageZip(fileList[0]!);
|
||||
setLocalPackage(pkg);
|
||||
setImportPreview(null);
|
||||
} catch (err) {
|
||||
@@ -764,16 +764,15 @@ export function CompanyImport() {
|
||||
<div>
|
||||
<h2 className="text-base font-semibold">Import source</h2>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Choose a GitHub repo, direct URL, or local folder to import from.
|
||||
Choose a GitHub repo or upload a local Paperclip zip package.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 md:grid-cols-3">
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
{(
|
||||
[
|
||||
{ key: "github", icon: Github, label: "GitHub repo" },
|
||||
{ key: "url", icon: Link2, label: "Direct URL" },
|
||||
{ key: "local", icon: Upload, label: "Local folder" },
|
||||
{ key: "local", icon: Upload, label: "Local zip" },
|
||||
] as const
|
||||
).map(({ key, icon: Icon, label }) => (
|
||||
<button
|
||||
@@ -785,7 +784,10 @@ export function CompanyImport() {
|
||||
? "border-foreground bg-accent"
|
||||
: "border-border hover:bg-accent/50",
|
||||
)}
|
||||
onClick={() => setSourceMode(key)}
|
||||
onClick={() => {
|
||||
setSourceMode(key);
|
||||
setImportPreview(null);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="h-4 w-4" />
|
||||
@@ -800,10 +802,8 @@ export function CompanyImport() {
|
||||
<input
|
||||
ref={packageInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept=".zip,application/zip"
|
||||
className="hidden"
|
||||
// @ts-expect-error webkitdirectory is supported by Chromium-based browsers
|
||||
webkitdirectory=""
|
||||
onChange={handleChooseLocalPackage}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
@@ -812,11 +812,11 @@ export function CompanyImport() {
|
||||
variant="outline"
|
||||
onClick={() => packageInputRef.current?.click()}
|
||||
>
|
||||
Choose folder
|
||||
Choose zip
|
||||
</Button>
|
||||
{localPackage && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{localPackage.rootPath ?? "package"} with{" "}
|
||||
{localPackage.name} with{" "}
|
||||
{Object.keys(localPackage.files).length} file
|
||||
{Object.keys(localPackage.files).length === 1 ? "" : "s"}
|
||||
</span>
|
||||
@@ -824,28 +824,20 @@ export function CompanyImport() {
|
||||
</div>
|
||||
{!localPackage && (
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Select a folder that contains COMPANY.md and any referenced AGENTS.md files.
|
||||
Upload a `.zip` exported from Paperclip that contains COMPANY.md and the related package files.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Field
|
||||
label={sourceMode === "github" ? "GitHub URL" : "Package URL"}
|
||||
hint={
|
||||
sourceMode === "github"
|
||||
? "Repo tree path or blob URL to COMPANY.md (e.g. github.com/owner/repo/tree/main/company)."
|
||||
: "Point directly at COMPANY.md or a directory that contains it."
|
||||
}
|
||||
label="GitHub URL"
|
||||
hint="Repo tree path or blob URL to COMPANY.md (e.g. github.com/owner/repo/tree/main/company)."
|
||||
>
|
||||
<input
|
||||
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
|
||||
type="text"
|
||||
value={importUrl}
|
||||
placeholder={
|
||||
sourceMode === "github"
|
||||
? "https://github.com/owner/repo/tree/main/company"
|
||||
: "https://example.com/company/COMPANY.md"
|
||||
}
|
||||
placeholder="https://github.com/owner/repo/tree/main/company"
|
||||
onChange={(e) => {
|
||||
setImportUrl(e.target.value);
|
||||
setImportPreview(null);
|
||||
@@ -934,7 +926,7 @@ export function CompanyImport() {
|
||||
/>
|
||||
|
||||
{/* Import button — below renames */}
|
||||
<div className="mx-5 mt-3">
|
||||
<div className="mx-5 mt-3 flex justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => importMutation.mutate()}
|
||||
|
||||
Reference in New Issue
Block a user