import * as wn from "@oddjs/odd";
import { retrieve } from "@oddjs/odd/common/root-key";
import * as uint8arrays from "uint8arrays";
import { getRecoil, setRecoil } from "recoil-nexus";
import type { CID } from "multiformats/cid";
import type { PuttableUnixTree, File as WNFile } from "@oddjs/odd/fs/types";
import type { Metadata } from "@oddjs/odd/fs/metadata";
import { fileToUint8Array } from "./utils";
import { accountSettingsStore, filesystemStore, sessionStore } from "../stores";
import { addNotification } from "./notifications";
export type Avatar = {
cid: string;
ctime: number;
name: string;
size?: number;
src: string;
};
export type AccountSettings = {
avatar: Avatar;
loading: boolean;
};
interface AvatarFile extends PuttableUnixTree, WNFile {
cid: CID;
content: Uint8Array;
header: {
content: Uint8Array;
metadata: Metadata;
};
}
export const ACCOUNT_SETTINGS_DIR = wn.path.directory("private", "settings");
const AVATAR_DIR = wn.path.combine(
ACCOUNT_SETTINGS_DIR,
wn.path.directory("avatars")
);
const AVATAR_ARCHIVE_DIR = wn.path.combine(
AVATAR_DIR,
wn.path.directory("archive")
);
const AVATAR_FILE_NAME = "avatar";
const FILE_SIZE_LIMIT = 20;
/**
* Move old avatar to the archive directory
*/
const archiveOldAvatar = async (): Promise<void> => {
const fs = getRecoil(filesystemStore);
// Return if user has not uploaded an avatar yet
const avatarDirExists = await fs.exists(AVATAR_DIR);
if (!avatarDirExists) {
return;
}
// Find the filename of the old avatar
const links = await fs.ls(AVATAR_DIR);
const oldAvatarFileName = Object.keys(links).find((key) =>
key.includes(AVATAR_FILE_NAME)
);
const oldFileNameArray = oldAvatarFileName.split(".")[0];
const archiveFileName = `${oldFileNameArray[0]}-${Date.now()}.${
oldFileNameArray[1]
}`;
// Move old avatar to archive dir
const fromPath = wn.path.combine(AVATAR_DIR, wn.path.file(oldAvatarFileName));
const toPath = wn.path.combine(
AVATAR_ARCHIVE_DIR,
wn.path.file(archiveFileName)
);
await fs.mv(fromPath, toPath);
// Announce the changes to the server
await fs.publish();
};
/**
* Get the Avatar from the user's WNFS and construct its `src`
*/
export const getAvatarFromWNFS = async (): Promise<void> => {
const accountSettings = getRecoil(accountSettingsStore);
try {
const fs = getRecoil(filesystemStore);
// Set loading: true on the accountSettingsStore
setRecoil(accountSettingsStore, { ...accountSettings, loading: true });
// If the avatar dir doesn't exist, silently fail and let the UI handle it
const avatarDirExists = await fs.exists(AVATAR_DIR);
if (!avatarDirExists) {
setRecoil(accountSettingsStore, { ...accountSettings, loading: false });
return;
}
// Find the file that matches the AVATAR_FILE_NAME
const links = await fs.ls(AVATAR_DIR);
const avatarName = Object.keys(links).find((key) =>
key.includes(AVATAR_FILE_NAME)
);
// If user has not uploaded an avatar, silently fail and let the UI handle it
if (!avatarName) {
setRecoil(accountSettingsStore, { ...accountSettings, loading: false });
return;
}
const file = await fs.get(
wn.path.combine(AVATAR_DIR, wn.path.file(`${avatarName}`))
);
// The CID for private files is currently located in `file.header.content`
const cid = (file as AvatarFile).header.content.toString();
// Create a base64 string to use as the image `src`
const src = `data:image/jpeg;base64, ${uint8arrays.toString(
(file as AvatarFile).content,
"base64"
)}`;
const avatar = {
cid,
ctime: (file as AvatarFile).header.metadata.unixMeta.ctime,
name: avatarName,
src,
};
// Push images to the accountSettingsStore
setRecoil(accountSettingsStore, {
...accountSettings,
avatar,
loading: false,
});
} catch (error) {
console.error(error);
setRecoil(accountSettingsStore, {
...accountSettings,
avatar: null,
loading: false,
});
}
};
/**
* Upload an avatar image to the user's private WNFS
* @param image
*/
export const uploadAvatarToWNFS = async (image: File): Promise<void> => {
try {
const accountSettings = getRecoil(accountSettingsStore);
const fs = getRecoil(filesystemStore);
// Set loading: true on the accountSettingsStore
setRecoil(accountSettingsStore, { ...accountSettings, loading: true });
// Reject files over 20MB
const imageSizeInMB = image.size / (1024 * 1024);
if (imageSizeInMB > FILE_SIZE_LIMIT) {
throw new Error("Image can be no larger than 20MB");
}
// Archive old avatar
await archiveOldAvatar();
// Rename the file to `avatar.[extension]`
const updatedImage = new File(
[image],
`${AVATAR_FILE_NAME}.${image.name.split(".")[1]}`,
{
type: image.type,
}
);
// Create a sub directory and add the avatar
await fs.write(
wn.path.combine(AVATAR_DIR, wn.path.file(updatedImage.name)),
await fileToUint8Array(updatedImage)
);
// Announce the changes to the server
await fs.publish();
addNotification({ msg: `Your avatar has been updated!`, type: "success" });
} catch (error) {
addNotification({ msg: error.message, type: "error" });
console.error(error);
}
};
export const generateRecoveryKit = async (): Promise<string> => {
const {
program: {
components: { crypto, reference },
},
username: { full, hashed, trimmed },
} = getRecoil(sessionStore);
// Get the user's read-key and base64 encode it
const accountDID = await reference.didRoot.lookup(hashed);
const readKey = await retrieve({ crypto, accountDID });
const encodedReadKey = uint8arrays.toString(readKey, "base64pad");
// Get today's date to display in the kit
const options: Intl.DateTimeFormatOptions = {
weekday: "short",
year: "numeric",
month: "short",
day: "numeric",
};
const date = new Date();
const content = `# %@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%
# @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
# %@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%
# @@@@@% %@@@@@@% %@@@@@@@% %@@@@@
# @@@@@ @@@@@% @@@@@@ @@@@@
# @@@@@% @@@@@ %@@@@@ %@@@@@
# @@@@@@% @@@@@ %@@% @@@@@ %@@@@@@
# @@@@@@@ @@@@@ %@@@@% @@@@@ @@@@@@@
# @@@@@@@ @@@@% @@@@@@ @@@@@ @@@@@@@
# @@@@@@@ %@@@@ @@@@@@ @@@@@% @@@@@@@
# @@@@@@@ @@@@@ @@@@@@ %@@@@@ @@@@@@@
# @@@@@@@ @@@@@@@@@@@@@@@@ @@@@@ @@@@@@@
# @@@@@@@ %@@@@@@@@@@@@@@@ @@@@% @@@@@@@
# @@@@@@@ %@@% @@@@@@ %@@% @@@@@@@
# @@@@@@@ @@@@@@ @@@@@@@
# @@@@@@@% %@@@@@@% %@@@@@@@
# @@@@@@@@@% %@@@@@@@@@@% %@@@@@@@@@
# %@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%
# @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
# %@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%
#
# This is your recovery kit. (It’s a yaml text file)
#
# Created for ${trimmed} on ${date.toLocaleDateString("en-US", options)}
#
# Store this somewhere safe.
#
# Anyone with this file will have read access to your private files.
# Losing it means you won’t be able to recover your account
# in case you lose access to all your linked devices.
#
# Our team will never ask you to share this file.
#
# To use this file, go to ${window.location.origin}/recover/
# Learn how to customize this kit for your users: https://guide.fission.codes/
username: ${full}
key: ${encodedReadKey}`;
return content;
};