Skip to content

Commit

Permalink
Add project import from URL
Browse files Browse the repository at this point in the history
  • Loading branch information
invpt committed Mar 15, 2024
1 parent 0f3e115 commit 8fb6ec8
Show file tree
Hide file tree
Showing 6 changed files with 182 additions and 111 deletions.
2 changes: 1 addition & 1 deletion src/export-bundle.ts → src/export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { type DB } from "./db";
export class ExportError extends Error {
}

export const exportProject = async (db: DB, project: string, options: { includeAssets: boolean } = { includeAssets: true }) => {
export const exportProjectBundle = async (db: DB, project: string, options: { includeAssets: boolean } = { includeAssets: true }) => {
const projectContent: ProjectModel & { id: unknown, source: unknown } | undefined = await db.loadProject(project);
if (projectContent === undefined) {
throw new ExportError("Failed to load project for export.");
Expand Down
103 changes: 0 additions & 103 deletions src/import-bundle.ts

This file was deleted.

149 changes: 149 additions & 0 deletions src/import.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { v4 as uuidv4 } from "uuid";
import JSZip from "jszip";

import { type ProjectModel, ProjectModelSchema } from "./data";
import { type DB, type DbProject } from "./db";

export class ImportError extends Error {
}

export type ReplacementAction = "cancel" | "new" | { replace: string };

export const importProjectBundle = async (db: DB, file: File, chooseReplacement?: (options: DbProject[]) => Promise<ReplacementAction>) => {
let zip: JSZip;
try {
zip = await JSZip.loadAsync(file);
} catch (e) {
throw new ImportError("The chosen file does not appear to be a valid zip file.", { cause: e });
}
const projectJsonFile = zip.file("tourforge.json");
if (projectJsonFile == null) {
throw new ImportError("tourforge.json is missing.");
}
let projectJsonText: string;
try {
projectJsonText = await projectJsonFile.async("text");
} catch (e) {
throw new ImportError("Failed to load tourforge.json as text.");
}

return await importProject(
db,
async () => JSON.parse(projectJsonText),
async (hash) => {
const assetFile = zip.file(hash);
if (assetFile == null) {
throw new ImportError("The asset with hash " + hash + " is missing.");
}
return await assetFile.async("blob");
},
chooseReplacement,
);
};

export const importProjectUrl = async (db: DB, url: URL, chooseReplacement?: (options: DbProject[]) => Promise<ReplacementAction>) => {
if (!["http:", "https:"].includes(url.protocol)) {
throw new ImportError("Project URLs must use http or https protocols.");
}

if (url.pathname.endsWith("/index.html")) {
url.pathname = url.pathname.slice(0, url.pathname.length - "/index.html".length);
} else if (url.pathname.endsWith("/index.html/")) {
url.pathname = url.pathname.slice(0, url.pathname.length - "/index.html".length);
} else if (url.pathname.endsWith("/")) {
url.pathname = url.pathname.slice(0, url.pathname.length - "/".length);
}

return await importProject(
db,
async () => {
const resp = await fetch(`${url.toString()}/tourforge.json`);
if (!resp.ok) {
throw new ImportError("Failed to download tourforge.json.");
}
const respJson = await resp.json();
return respJson;
},
async (hash) => {
const resp = await fetch(`${url.toString()}/${hash}`);
if (!resp.ok) {
throw new ImportError("Failed to download asset with hash " + hash);
}
const respBlob = await resp.blob();
return respBlob;
},
);
};

const importProject = async (
db: DB,
loadProjectJson: () => Promise<unknown>,
loadAssetBlob: (hash: string) => Promise<Blob>,
chooseReplacement?: (options: DbProject[]) => Promise<ReplacementAction>,
) => {
let projectJson: unknown;
try {
projectJson = await loadProjectJson();
} catch (e) {
throw new ImportError("tourforge.json could not be loaded as JSON.", { cause: e });
}
let project: ProjectModel;
try {
project = ProjectModelSchema.parse(projectJson);
} catch (e) {
throw new ImportError("tourforge.json has an invalid schema.", { cause: e });
}
const assetBlobs: Record<string, Blob> = {};
for (const assetInfo of Object.values(project.assets)) {
let assetBlob: Blob | undefined;
try {
assetBlob = await loadAssetBlob(assetInfo.hash);
} catch (e) {
console.warn("Ignoring failed read of asset with hash", assetInfo.hash);
continue;
}
assetBlobs[assetInfo.hash] = assetBlob;
}

// Figure out if there's another project with the same originalId already.
const existingWithOriginalId: DbProject[] = [];
for (const otherProject of await db.listProjects()) {
if (otherProject.originalId === project.originalId) {
existingWithOriginalId.push(otherProject);
}
}

let replacementAction: ReplacementAction = "new";
if (chooseReplacement != null && existingWithOriginalId.length > 0) {
replacementAction = await chooseReplacement(existingWithOriginalId);
}

let projectId: string;
if (replacementAction === "cancel") {
return;
} else if (replacementAction === "new") {
projectId = uuidv4();
} else {
projectId = replacementAction.replace;
}

const dbProject = {
...project,
id: projectId,
source: { type: "bundle" } as const,
};

await db.storeProject(dbProject);
for (const [hash, blob] of Object.entries(assetBlobs)) {
if (!await db.containsAsset(hash)) {
// We're assuming that the hash used in the project for the asset is correct.
// There's no reason why it shouldn't be unless the bundle we're importing is
// malicious, but very little could be gained from making a malicious bundle.
// This is especially true because we only store the blob if the hash is
// unused in the database.
await db.storeAssetWithHash(hash, blob);
}
}

return dbProject;
};
31 changes: 28 additions & 3 deletions src/pages/home/Home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { toast } from "solid-toast";
import { FiEdit, FiFile, FiFilePlus, FiGlobe, FiTrash } from "solid-icons/fi";

import { type DbProject, useDB } from "../../db";
import { ImportError, type ReplacementAction, importBundle } from "../../import-bundle";
import { ImportError, type ReplacementAction, importProjectBundle, importProjectUrl } from "../../import";

import styles from "./Home.module.css";

Expand Down Expand Up @@ -52,7 +52,7 @@ export const Home: Component = () => {
};

try {
await toast.promise(importBundle(db, file, chooseReplacement), {
await toast.promise(importProjectBundle(db, file, chooseReplacement), {
loading: "Loading the project...",
success: "Successfully loaded the project.",
error(e) {
Expand All @@ -78,7 +78,32 @@ export const Home: Component = () => {
return;
}

console.log(url);
let parsedURL: URL;
try {
parsedURL = new URL(url);
} catch (e) {
toast.error("The URL you entered is invalid. URLs look like this: https://example.org/example/test/");
console.error("Invalid URL", e);
return;
}

try {
await toast.promise(importProjectUrl(db, parsedURL, async () => "new"), {
loading: "Downloading the project from the internet...",
success: "Successfully downloaded the project.",
error(e) {
if (e instanceof ImportError) {
return `An error occurred while downloading the project: ${e.message}`;
} else {
return "An internal error occurred while downloading the project.";
}
},
});
} catch (e) {
console.error("Failed to import tour bundle:", e);
}

await refetchProjects();
};

const handleProjectDeleteClick = (id: string, title: string) => async () => {
Expand Down
4 changes: 2 additions & 2 deletions src/pages/project/ProjectEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { toast } from "solid-toast";

import { ProjectProvider } from "../../hooks/Project";
import { useDB } from "../../db";
import { exportProject, ExportError } from "../../export-bundle";
import { exportProjectBundle, ExportError } from "../../export";

import styles from "./ProjectEditor.module.css";
import { ProjectEditorPanel } from "./ProjectEditorPanel";
Expand All @@ -17,7 +17,7 @@ export const ProjectEditor: Component<{ children?: JSX.Element }> = (props) => {
if (e.ctrlKey && e.key === "s") {
e.preventDefault();
try {
await exportProject(db, params.pid);
await exportProjectBundle(db, params.pid);
} catch (err) {
console.error("Error while exporting project:", err);
if (err instanceof ExportError) {
Expand Down
4 changes: 2 additions & 2 deletions src/pages/project/ProjectEditorPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { toast } from "solid-toast";
import { v4 as uuidv4 } from "uuid";

import { useProject } from "../../hooks/Project";
import { ExportError, exportProject } from "../../export-bundle";
import { ExportError, exportProjectBundle } from "../../export";
import { useDB } from "../../db";

import styles from "./ProjectEditorPanel.module.css";
Expand Down Expand Up @@ -39,7 +39,7 @@ export const ProjectEditorPanel: Component = () => {
}

try {
await exportProject(db, project()!.id);
await exportProjectBundle(db, project()!.id);
} catch (err) {
console.error("Error while exporting project:", err);
if (err instanceof ExportError) {
Expand Down

0 comments on commit 8fb6ec8

Please sign in to comment.