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; };