diff --git a/.env.example b/.env.example index 6387efd2..353af8f0 100644 --- a/.env.example +++ b/.env.example @@ -2,4 +2,5 @@ APPWRITE_FUNCTION_API_KEY="" PUBLIC_APPWRITE_HOST="http://localhost/v1" PUBLIC_MAX_FILE_SIZE="300000000" # Note: Must be the same as in the _APP_STORAGE_LIMIT in the Appwrite env file PUBLIC_DISABLE_REGISTRATION="true" # Note: In the Appwrite console you have to change your user limit to 0 if false and else to 1 -PUBLIC_DISABLE_HOME_PAGE="false" \ No newline at end of file +PUBLIC_DISABLE_HOME_PAGE="false" +PUBLIC_MAIL_SHARE_ENABLED="false" \ No newline at end of file diff --git a/.setup/data/collections.ts b/.setup/data/collections.ts index 57cc220d..e4d295f8 100644 --- a/.setup/data/collections.ts +++ b/.setup/data/collections.ts @@ -10,16 +10,22 @@ export default [ { key: "securityID", type: "string", - status: "available", required: false, array: false, size: 255, default: null, }, + { + key: "users", + type: "string", + required: false, + array: true, + size: 255, + default: null, + }, { key: "createdAt", type: "integer", - status: "available", required: true, array: false, min: 0, @@ -29,7 +35,6 @@ export default [ { key: "expiresAt", type: "integer", - status: "available", required: true, array: false, min: 0, @@ -39,7 +44,6 @@ export default [ { key: "visitorCount", type: "integer", - status: "available", required: false, array: false, min: 0, @@ -49,7 +53,6 @@ export default [ { key: "enabled", type: "boolean", - status: "available", required: false, array: false, default: false, @@ -75,7 +78,6 @@ export default [ { key: "password", type: "string", - status: "available", required: false, array: false, size: 128, @@ -84,7 +86,6 @@ export default [ { key: "maxVisitors", type: "integer", - status: "available", required: false, array: false, min: 0, diff --git a/.setup/data/functions.ts b/.setup/data/functions.ts index d682d83c..ac4a90b5 100644 --- a/.setup/data/functions.ts +++ b/.setup/data/functions.ts @@ -12,6 +12,12 @@ export default () => { vars: { APPWRITE_FUNCTION_ENDPOINT: host, APPWRITE_FUNCTION_API_KEY: process.env["APPWRITE_FUNCTION_API_KEY"], + SMTP_HOST: "", + SMTP_PORT: "", + SMTP_USER: "", + SMTP_PASSWORD: "", + SMTP_FROM: "", + FRONTEND_URL: "", }, events: [], schedule: "", diff --git a/.setup/index.ts b/.setup/index.ts index 4c70f786..3ae4d2e1 100644 --- a/.setup/index.ts +++ b/.setup/index.ts @@ -41,7 +41,7 @@ import rl from "readline-sync"; console.info("Creating function deployments..."); await setupService.createFunctionDeployments(); - console.info("Adding frontend url..."); + console.info("Adding frontend host..."); await setupService.addPlatform( rl.question("Frontend host of Pingvin Share (localhost): ", { defaultInput: "localhost", diff --git a/README.md b/README.md index fce3428d..48dd19f3 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,14 @@ You're almost done, now you have to change your environment variables that they 3. Change `PUBLIC_APPWRITE_HOST` in the `.env` file to the host where your Appwrite instance runs 4. Change `PUBLIC_MAX_FILE_SIZE` in the `.env` file to the max file size limit you want +## Additional configurations + +### SMTP + +1. Enable `PUBLIC_MAIL_SHARE_ENABLE` in the `.env` file. +2. Visit your Appwrite console, click on functions and select the `Create Share` function. +3. At the settings tab change the empty variables to your SMTP setup. + ## Known issues / Limitations Pingvin Share is currently in beta and there are issues and limitations that should be fixed in the future. diff --git a/docker-compose.yml b/docker-compose.yml index 561e12f6..4e2db4af 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,4 +10,5 @@ services: - PUBLIC_APPWRITE_HOST=${PUBLIC_APPWRITE_HOST} - PUBLIC_MAX_FILE_SIZE=${PUBLIC_MAX_FILE_SIZE} - PUBLIC_DISABLE_REGISTRATION=${PUBLIC_DISABLE_REGISTRATION} - - PUBLIC_DISABLE_HOME_PAGE=${PUBLIC_DISABLE_HOME_PAGE} \ No newline at end of file + - PUBLIC_DISABLE_HOME_PAGE=${PUBLIC_DISABLE_HOME_PAGE} + - PUBLIC_MAIL_SHARE_ENABLED=${PUBLIC_MAIL_SHARE_ENABLED} \ No newline at end of file diff --git a/functions/createShare/package.json b/functions/createShare/package.json index 6295c618..709ab051 100644 --- a/functions/createShare/package.json +++ b/functions/createShare/package.json @@ -7,6 +7,7 @@ "author": "", "license": "ISC", "dependencies": { - "node-appwrite": "^5.0.0" + "node-appwrite": "^5.0.0", + "nodemailer": "^6.7.5" } } diff --git a/functions/createShare/src/index.js b/functions/createShare/src/index.js index abd2bcba..01ef8f57 100644 --- a/functions/createShare/src/index.js +++ b/functions/createShare/src/index.js @@ -4,8 +4,8 @@ const util = require("./util") module.exports = async function (req, res) { const client = new sdk.Client(); - // You can remove services you don't use let database = new sdk.Database(client); + let users = new sdk.Users(client); let storage = new sdk.Storage(client); client @@ -34,6 +34,18 @@ module.exports = async function (req, res) { ).$id; } + let userIds; + if (payload.emails) { + const creatorEmail = (await users.get(userId)).email + userIds = [] + userIds.push(userId) + for (const email of payload.emails) { + userIds.push((await users.list(`email='${email}'`)).users[0].$id) + util.sendMail(email, creatorEmail, payload.id) + } + + } + // Create the storage bucket await storage.createBucket( payload.id, @@ -48,6 +60,7 @@ module.exports = async function (req, res) { // Create document in Shares collection await database.createDocument("shares", payload.id, { securityID: securityDocumentId, + users: userIds, createdAt: Date.now(), expiresAt: expiration, }); diff --git a/functions/createShare/src/util.js b/functions/createShare/src/util.js index ff34abe3..5a72abfd 100644 --- a/functions/createShare/src/util.js +++ b/functions/createShare/src/util.js @@ -1,9 +1,32 @@ const { scryptSync } = require("crypto"); +const mail = require("nodemailer") + + +const transporter = mail.createTransport({ + host: process.env["SMTP_HOST"], + port: process.env["SMTP_PORT"], + secure: false, + auth: { + user: process.env["SMTP_USER"], + pass: process.env["SMTP_PASSWORD"], + }, +}); const hashPassword = (password, salt) => { return scryptSync(password, salt, 64).toString("hex"); } +const sendMail = (receiver, creatorEmail, shareId) => { + let message = { + from: process.env["SMTP_FROM"], + to: receiver, + subject: "New share from Pingvin Share", + text: `Hey, ${creatorEmail} shared files with you. To access the files, visit ${process.env.FRONTEND_URL}/share/${shareId}` + + } + transporter.sendMail(message) +} + module.exports = { - hashPassword, + hashPassword, sendMail } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index f65e183c..558c9ddf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,9 +17,9 @@ "@mantine/notifications": "^4.2.0", "appwrite": "^7.0.0", "axios": "^0.26.1", - "cookie": "^0.5.0", "cookies-next": "^2.0.4", "file-saver": "^2.0.5", + "jose": "^4.8.1", "js-file-download": "^0.4.12", "jszip": "^3.9.1", "next": "12.1.5", @@ -40,7 +40,6 @@ "@types/tar": "^6.1.1", "@types/uuid": "^8.3.4", "axios": "^0.26.1", - "cookie": "^0.5.0", "eslint": "8.13.0", "eslint-config-next": "12.1.5", "node-appwrite": "^5.1.0", @@ -3345,15 +3344,6 @@ "safe-buffer": "~5.1.1" } }, - "node_modules/cookie": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", - "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==", - "dev": true, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/cookies-next": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/cookies-next/-/cookies-next-2.0.4.tgz", @@ -5279,6 +5269,14 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/jose": { + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.8.1.tgz", + "integrity": "sha512-+/hpTbRcCw9YC0TOfN1W47pej4a9lRmltdOVdRLz5FP5UvUq3CenhXjQK7u/8NdMIIShMXYAh9VLPhc7TjhvFw==", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-file-download": { "version": "0.4.12", "resolved": "https://registry.npmjs.org/js-file-download/-/js-file-download-0.4.12.tgz", @@ -10210,12 +10208,6 @@ "safe-buffer": "~5.1.1" } }, - "cookie": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", - "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==", - "dev": true - }, "cookies-next": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/cookies-next/-/cookies-next-2.0.4.tgz", @@ -11658,6 +11650,11 @@ } } }, + "jose": { + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.8.1.tgz", + "integrity": "sha512-+/hpTbRcCw9YC0TOfN1W47pej4a9lRmltdOVdRLz5FP5UvUq3CenhXjQK7u/8NdMIIShMXYAh9VLPhc7TjhvFw==" + }, "js-file-download": { "version": "0.4.12", "resolved": "https://registry.npmjs.org/js-file-download/-/js-file-download-0.4.12.tgz", diff --git a/package.json b/package.json index 11af5fb4..6d6e85e1 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "build": "next build", "start": "next start", "lint": "next lint", - "deploy": "docker buildx build -t stonith404/pingvin-share --platform linux/amd64,linux/arm64 --push ." + "deploy:latest": "docker buildx build -t stonith404/pingvin-share:latest --platform linux/amd64,linux/arm64 --push .", + "deploy:development": "docker buildx build -t stonith404/pingvin-share:development --platform linux/amd64,linux/arm64 --push ." }, "dependencies": { "@mantine/core": "^4.2.0", @@ -19,9 +20,9 @@ "@mantine/notifications": "^4.2.0", "appwrite": "^7.0.0", "axios": "^0.26.1", - "cookie": "^0.5.0", "cookies-next": "^2.0.4", "file-saver": "^2.0.5", + "jose": "^4.8.1", "js-file-download": "^0.4.12", "jszip": "^3.9.1", "next": "12.1.5", @@ -42,7 +43,6 @@ "@types/tar": "^6.1.1", "@types/uuid": "^8.3.4", "axios": "^0.26.1", - "cookie": "^0.5.0", "eslint": "8.13.0", "eslint-config-next": "12.1.5", "node-appwrite": "^5.1.0", diff --git a/src/components/share/CreateUploadModalBody.tsx b/src/components/share/CreateUploadModalBody.tsx new file mode 100644 index 00000000..fdc3caf0 --- /dev/null +++ b/src/components/share/CreateUploadModalBody.tsx @@ -0,0 +1,166 @@ +import { + Accordion, + Button, + Col, + Grid, + Group, + MultiSelect, + NumberInput, + PasswordInput, + Select, + Text, + TextInput, +} from "@mantine/core"; +import { useForm, yupResolver } from "@mantine/form"; +import { useModals } from "@mantine/modals"; +import * as yup from "yup"; +import shareService from "../../services/share.service"; +import toast from "../../utils/toast.util"; + +const CreateUploadModalBody = ({ + mode, + uploadCallback, +}: { + mode: "standard" | "email"; + uploadCallback: ( + id: string, + expiration: number, + security: { password?: string; maxVisitors?: number }, + emails?: string[] + ) => void; +}) => { + const modals = useModals(); + const validationSchema = yup.object().shape({ + link: yup.string().required().min(2).max(50), + emails: mode == "email" ? yup.array().of(yup.string().email()).min(1) : yup.array(), + password: yup.string().min(3).max(100), + maxVisitors: yup.number().min(1), + }); + const form = useForm({ + initialValues: { + link: "", + emails: [] as string[], + password: undefined, + maxVisitors: undefined, + expiration: "1440", + }, + schema: yupResolver(validationSchema), + }); + + return ( +
{ + if (await shareService.isIdAlreadyInUse(values.link)) { + form.setFieldError("link", "Link already in use."); + } else { + modals.closeAll(); + uploadCallback( + values.link, + parseInt(values.expiration), + { + password: values.password, + maxVisitors: values.maxVisitors, + }, + values.emails + ); + } + })} + > + + + + + + + + + + + ({ + color: theme.colors.gray[6], + })} + > + {window.location.origin}/share/ + {form.values.link == "" ? "myAwesomeShare" : form.values.link} + + {mode == "email" && ( + } + getCreateLabel={(email) => `${email}`} + onCreate={async (email) => { + if (!(await shareService.doesUserExist(email))) { + form.setFieldValue("emails", form.values.emails); + toast.error( + `${email} doesn't have an account at Pingvin Share.` + ); + } + }} + {...form.getInputProps("emails")} + /> + )} + - - - - - - - - - - -
- ); -}; - -export default showCreateUploadModal; +export default showCreateUploadModal; \ No newline at end of file diff --git a/src/pages/api/share/[shareId]/index.ts b/src/pages/api/share/[shareId]/index.ts index b0fdcba2..f42b32f6 100644 --- a/src/pages/api/share/[shareId]/index.ts +++ b/src/pages/api/share/[shareId]/index.ts @@ -3,14 +3,30 @@ import { ShareDocument } from "../../../../types/Appwrite.type"; import { AppwriteFileWithPreview } from "../../../../types/File.type"; import awServer from "../../../../utils/appwriteServer.util"; import { checkSecurity } from "../../../../utils/shares/security.util"; +import * as jose from "jose"; const handler = async (req: NextApiRequest, res: NextApiResponse) => { const shareId = req.query.shareId as string; const fileList: AppwriteFileWithPreview[] = []; const hashedPassword = req.cookies[`${shareId}-password`]; - if (!(await shareExists(shareId))) + let shareDocument; + try { + shareDocument = await awServer.database.getDocument( + "shares", + shareId + ); + } catch { return res.status(404).json({ message: "not_found" }); + } + + if (!shareExists(shareDocument)) { + return res.status(404).json({ message: "not_found" }); + } + + if (!hasUserAccess(req.cookies.aw_token, shareDocument)) { + return res.status(403).json({ message: "forbidden" }); + } try { await checkSecurity(shareId, hashedPassword); @@ -20,8 +36,9 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { addVisitorCount(shareId); - const fileListWithoutPreview = (await awServer.storage.listFiles(shareId, undefined, 100)) - .files; + const fileListWithoutPreview = ( + await awServer.storage.listFiles(shareId, undefined, 100) + ).files; for (const file of fileListWithoutPreview) { const filePreview = await awServer.storage.getFilePreview( @@ -39,18 +56,20 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { res.status(200).json(fileList); }; -const shareExists = async (shareId: string) => { +const hasUserAccess = (jwt: string, shareDocument: ShareDocument) => { + if (shareDocument.users?.length == 0) return true; try { - const shareDocument = await awServer.database.getDocument( - "shares", - shareId - ); - return shareDocument.enabled && shareDocument.expiresAt > Date.now(); - } catch (e) { + const userId = jose.decodeJwt(jwt).userId as string; + return shareDocument.users?.includes(userId); + } catch { return false; } }; +const shareExists = async (shareDocument: ShareDocument) => { + return shareDocument.enabled && shareDocument.expiresAt > Date.now(); +}; + const addVisitorCount = async (shareId: string) => { const currentDocument = await awServer.database.getDocument( "shares", diff --git a/src/pages/api/user/exists/[email].ts b/src/pages/api/user/exists/[email].ts new file mode 100644 index 00000000..f3659c20 --- /dev/null +++ b/src/pages/api/user/exists/[email].ts @@ -0,0 +1,12 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import awServer from "../../../../utils/appwriteServer.util"; + +const handler = async (req: NextApiRequest, res: NextApiResponse) => { + const email = req.query.email as string; + + const doesExists = (await awServer.user.list(`"${email}"`)).total != 0; + + res.status(200).json({ exists: doesExists }); +}; + +export default handler; diff --git a/src/pages/share/[shareId].tsx b/src/pages/share/[shareId].tsx index b62ffa49..1242ebd0 100644 --- a/src/pages/share/[shareId].tsx +++ b/src/pages/share/[shareId].tsx @@ -8,6 +8,7 @@ import FileList from "../../components/share/FileList"; import showEnterPasswordModal from "../../components/share/showEnterPasswordModal"; import showShareNotFoundModal from "../../components/share/showShareNotFoundModal"; import showVisitorLimitExceededModal from "../../components/share/showVisitorLimitExceededModal"; +import authService from "../../services/auth.service"; import shareService from "../../services/share.service"; import { AppwriteFileWithPreview } from "../../types/File.type"; @@ -24,7 +25,13 @@ const Share = () => { }); }; - const getFiles = (password?: string) => + const getFiles = async (password?: string) => { + try { + await authService.createJWT(); + } catch { + // + } + shareService .get(shareId, password) .then((files) => { @@ -40,6 +47,7 @@ const Share = () => { showVisitorLimitExceededModal(modals); } }); + }; useEffect(() => { getFiles(); @@ -47,7 +55,10 @@ const Share = () => { return ( <> - + { const router = useRouter(); const modals = useModals(); + const config = useConfig(); const isSignedIn = useContext(IsSignedInContext); const [files, setFiles] = useState([]); const [isUploading, setisUploading] = useState(false); + let shareMode: "email" | "standard"; const uploadFiles = async ( id: string, expiration: number, - security: { password?: string; maxVisitors?: number } + security: { password?: string; maxVisitors?: number }, + emails?: string[] ) => { setisUploading(true); try { files.forEach((file) => { file.uploadingState = "inProgress"; }); - const bucketId = JSON.parse( ( await aw.functions.createExecution( "createShare", - JSON.stringify({ id, security, expiration }), + JSON.stringify({ id, security, expiration, emails }), false ) ).stdout @@ -56,7 +59,8 @@ const Upload = () => { showCompletedUploadModal( modals, `${window.location.origin}/share/${bucketId}`, - new Date(Date.now() + expiration * 60 * 1000).toLocaleString() + new Date(Date.now() + expiration * 60 * 1000).toLocaleString(), + shareMode ); setFiles([]); } @@ -96,11 +100,21 @@ const Upload = () => { > } - onClick={() => showCreateUploadModal(modals, uploadFiles)} + onClick={() => { + shareMode = "standard"; + showCreateUploadModal(shareMode, modals, uploadFiles); + }} > Share with link - }> + } + onClick={() => { + shareMode = "email"; + showCreateUploadModal(shareMode, modals, uploadFiles); + }} + > Share with email diff --git a/src/services/auth.service.ts b/src/services/auth.service.ts new file mode 100644 index 00000000..c9b50486 --- /dev/null +++ b/src/services/auth.service.ts @@ -0,0 +1,8 @@ +import aw from "../utils/appwrite.util"; + +const createJWT = async () => { + const jwt = (await aw.account.createJWT()).jwt; + document.cookie = `aw_token=${jwt}; Max-Age=900; Path=/api`; +}; + +export default { createJWT }; diff --git a/src/services/share.service.ts b/src/services/share.service.ts index f4f2a965..5cea8f11 100644 --- a/src/services/share.service.ts +++ b/src/services/share.service.ts @@ -10,9 +10,18 @@ const isIdAlreadyInUse = async (shareId: string) => { .exists as boolean; }; +const doesUserExist = async (email: string) => { + return (await axios.get(`/api/user/exists/${email}`)).data.exists as boolean; +}; + const authenticateWithPassword = async (shareId: string, password?: string) => { return (await axios.post(`/api/share/${shareId}/enterPassword`, { password })) .data as AppwriteFileWithPreview[]; }; -export default { get, authenticateWithPassword, isIdAlreadyInUse }; +export default { + get, + authenticateWithPassword, + isIdAlreadyInUse, + doesUserExist, +}; diff --git a/src/types/Appwrite.type.ts b/src/types/Appwrite.type.ts index 4f95b6f7..37adff83 100644 --- a/src/types/Appwrite.type.ts +++ b/src/types/Appwrite.type.ts @@ -6,10 +6,10 @@ export type ShareDocument = { expiresAt: number; visitorCount: number; enabled: boolean; + users?: string[]; } & Models.Document; - export type SecurityDocument = { - password: string; - maxVisitors: number; - } & Models.Document; \ No newline at end of file + password: string; + maxVisitors: number; +} & Models.Document; diff --git a/src/utils/toast.util.tsx b/src/utils/toast.util.tsx index 7797cfd5..28c293a6 100644 --- a/src/utils/toast.util.tsx +++ b/src/utils/toast.util.tsx @@ -7,6 +7,7 @@ const error = (message: string) => color: "red", radius: "md", title: "Error", + message: message, });