Add feature to send share with email

This commit is contained in:
Elias Schneider
2022-05-06 10:25:10 +02:00
parent 506e6b0cab
commit ce19d22c68
22 changed files with 365 additions and 189 deletions

View File

@@ -2,4 +2,5 @@ APPWRITE_FUNCTION_API_KEY=""
PUBLIC_APPWRITE_HOST="http://localhost/v1" 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_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_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" PUBLIC_DISABLE_HOME_PAGE="false"
PUBLIC_MAIL_SHARE_ENABLED="false"

View File

@@ -10,16 +10,22 @@ export default [
{ {
key: "securityID", key: "securityID",
type: "string", type: "string",
status: "available",
required: false, required: false,
array: false, array: false,
size: 255, size: 255,
default: null, default: null,
}, },
{
key: "users",
type: "string",
required: false,
array: true,
size: 255,
default: null,
},
{ {
key: "createdAt", key: "createdAt",
type: "integer", type: "integer",
status: "available",
required: true, required: true,
array: false, array: false,
min: 0, min: 0,
@@ -29,7 +35,6 @@ export default [
{ {
key: "expiresAt", key: "expiresAt",
type: "integer", type: "integer",
status: "available",
required: true, required: true,
array: false, array: false,
min: 0, min: 0,
@@ -39,7 +44,6 @@ export default [
{ {
key: "visitorCount", key: "visitorCount",
type: "integer", type: "integer",
status: "available",
required: false, required: false,
array: false, array: false,
min: 0, min: 0,
@@ -49,7 +53,6 @@ export default [
{ {
key: "enabled", key: "enabled",
type: "boolean", type: "boolean",
status: "available",
required: false, required: false,
array: false, array: false,
default: false, default: false,
@@ -75,7 +78,6 @@ export default [
{ {
key: "password", key: "password",
type: "string", type: "string",
status: "available",
required: false, required: false,
array: false, array: false,
size: 128, size: 128,
@@ -84,7 +86,6 @@ export default [
{ {
key: "maxVisitors", key: "maxVisitors",
type: "integer", type: "integer",
status: "available",
required: false, required: false,
array: false, array: false,
min: 0, min: 0,

View File

@@ -12,6 +12,12 @@ export default () => {
vars: { vars: {
APPWRITE_FUNCTION_ENDPOINT: host, APPWRITE_FUNCTION_ENDPOINT: host,
APPWRITE_FUNCTION_API_KEY: process.env["APPWRITE_FUNCTION_API_KEY"], APPWRITE_FUNCTION_API_KEY: process.env["APPWRITE_FUNCTION_API_KEY"],
SMTP_HOST: "",
SMTP_PORT: "",
SMTP_USER: "",
SMTP_PASSWORD: "",
SMTP_FROM: "",
FRONTEND_URL: "",
}, },
events: [], events: [],
schedule: "", schedule: "",

View File

@@ -41,7 +41,7 @@ import rl from "readline-sync";
console.info("Creating function deployments..."); console.info("Creating function deployments...");
await setupService.createFunctionDeployments(); await setupService.createFunctionDeployments();
console.info("Adding frontend url..."); console.info("Adding frontend host...");
await setupService.addPlatform( await setupService.addPlatform(
rl.question("Frontend host of Pingvin Share (localhost): ", { rl.question("Frontend host of Pingvin Share (localhost): ", {
defaultInput: "localhost", defaultInput: "localhost",

View File

@@ -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 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 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 ## Known issues / Limitations
Pingvin Share is currently in beta and there are issues and limitations that should be fixed in the future. Pingvin Share is currently in beta and there are issues and limitations that should be fixed in the future.

View File

@@ -10,4 +10,5 @@ services:
- PUBLIC_APPWRITE_HOST=${PUBLIC_APPWRITE_HOST} - PUBLIC_APPWRITE_HOST=${PUBLIC_APPWRITE_HOST}
- PUBLIC_MAX_FILE_SIZE=${PUBLIC_MAX_FILE_SIZE} - PUBLIC_MAX_FILE_SIZE=${PUBLIC_MAX_FILE_SIZE}
- PUBLIC_DISABLE_REGISTRATION=${PUBLIC_DISABLE_REGISTRATION} - PUBLIC_DISABLE_REGISTRATION=${PUBLIC_DISABLE_REGISTRATION}
- PUBLIC_DISABLE_HOME_PAGE=${PUBLIC_DISABLE_HOME_PAGE} - PUBLIC_DISABLE_HOME_PAGE=${PUBLIC_DISABLE_HOME_PAGE}
- PUBLIC_MAIL_SHARE_ENABLED=${PUBLIC_MAIL_SHARE_ENABLED}

View File

@@ -7,6 +7,7 @@
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"node-appwrite": "^5.0.0" "node-appwrite": "^5.0.0",
"nodemailer": "^6.7.5"
} }
} }

View File

@@ -4,8 +4,8 @@ const util = require("./util")
module.exports = async function (req, res) { module.exports = async function (req, res) {
const client = new sdk.Client(); const client = new sdk.Client();
// You can remove services you don't use
let database = new sdk.Database(client); let database = new sdk.Database(client);
let users = new sdk.Users(client);
let storage = new sdk.Storage(client); let storage = new sdk.Storage(client);
client client
@@ -34,6 +34,18 @@ module.exports = async function (req, res) {
).$id; ).$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 // Create the storage bucket
await storage.createBucket( await storage.createBucket(
payload.id, payload.id,
@@ -48,6 +60,7 @@ module.exports = async function (req, res) {
// Create document in Shares collection // Create document in Shares collection
await database.createDocument("shares", payload.id, { await database.createDocument("shares", payload.id, {
securityID: securityDocumentId, securityID: securityDocumentId,
users: userIds,
createdAt: Date.now(), createdAt: Date.now(),
expiresAt: expiration, expiresAt: expiration,
}); });

View File

@@ -1,9 +1,32 @@
const { scryptSync } = require("crypto"); 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) => { const hashPassword = (password, salt) => {
return scryptSync(password, salt, 64).toString("hex"); 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 = { module.exports = {
hashPassword, hashPassword, sendMail
} }

31
package-lock.json generated
View File

@@ -17,9 +17,9 @@
"@mantine/notifications": "^4.2.0", "@mantine/notifications": "^4.2.0",
"appwrite": "^7.0.0", "appwrite": "^7.0.0",
"axios": "^0.26.1", "axios": "^0.26.1",
"cookie": "^0.5.0",
"cookies-next": "^2.0.4", "cookies-next": "^2.0.4",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"jose": "^4.8.1",
"js-file-download": "^0.4.12", "js-file-download": "^0.4.12",
"jszip": "^3.9.1", "jszip": "^3.9.1",
"next": "12.1.5", "next": "12.1.5",
@@ -40,7 +40,6 @@
"@types/tar": "^6.1.1", "@types/tar": "^6.1.1",
"@types/uuid": "^8.3.4", "@types/uuid": "^8.3.4",
"axios": "^0.26.1", "axios": "^0.26.1",
"cookie": "^0.5.0",
"eslint": "8.13.0", "eslint": "8.13.0",
"eslint-config-next": "12.1.5", "eslint-config-next": "12.1.5",
"node-appwrite": "^5.1.0", "node-appwrite": "^5.1.0",
@@ -3345,15 +3344,6 @@
"safe-buffer": "~5.1.1" "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": { "node_modules/cookies-next": {
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/cookies-next/-/cookies-next-2.0.4.tgz", "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" "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": { "node_modules/js-file-download": {
"version": "0.4.12", "version": "0.4.12",
"resolved": "https://registry.npmjs.org/js-file-download/-/js-file-download-0.4.12.tgz", "resolved": "https://registry.npmjs.org/js-file-download/-/js-file-download-0.4.12.tgz",
@@ -10210,12 +10208,6 @@
"safe-buffer": "~5.1.1" "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": { "cookies-next": {
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/cookies-next/-/cookies-next-2.0.4.tgz", "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": { "js-file-download": {
"version": "0.4.12", "version": "0.4.12",
"resolved": "https://registry.npmjs.org/js-file-download/-/js-file-download-0.4.12.tgz", "resolved": "https://registry.npmjs.org/js-file-download/-/js-file-download-0.4.12.tgz",

View File

@@ -7,7 +7,8 @@
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "next lint", "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": { "dependencies": {
"@mantine/core": "^4.2.0", "@mantine/core": "^4.2.0",
@@ -19,9 +20,9 @@
"@mantine/notifications": "^4.2.0", "@mantine/notifications": "^4.2.0",
"appwrite": "^7.0.0", "appwrite": "^7.0.0",
"axios": "^0.26.1", "axios": "^0.26.1",
"cookie": "^0.5.0",
"cookies-next": "^2.0.4", "cookies-next": "^2.0.4",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"jose": "^4.8.1",
"js-file-download": "^0.4.12", "js-file-download": "^0.4.12",
"jszip": "^3.9.1", "jszip": "^3.9.1",
"next": "12.1.5", "next": "12.1.5",
@@ -42,7 +43,6 @@
"@types/tar": "^6.1.1", "@types/tar": "^6.1.1",
"@types/uuid": "^8.3.4", "@types/uuid": "^8.3.4",
"axios": "^0.26.1", "axios": "^0.26.1",
"cookie": "^0.5.0",
"eslint": "8.13.0", "eslint": "8.13.0",
"eslint-config-next": "12.1.5", "eslint-config-next": "12.1.5",
"node-appwrite": "^5.1.0", "node-appwrite": "^5.1.0",

View File

@@ -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 (
<form
onSubmit={form.onSubmit(async (values) => {
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
);
}
})}
>
<Group direction="column" grow>
<Grid align={form.errors.link ? "center" : "flex-end"}>
<Col xs={9}>
<TextInput
variant="filled"
label="Link"
placeholder="myAwesomeShare"
{...form.getInputProps("link")}
/>
</Col>
<Col xs={3}>
<Button
variant="outline"
onClick={() =>
form.setFieldValue(
"link",
Buffer.from(Math.random().toString(), "utf8")
.toString("base64")
.substr(10, 7)
)
}
>
Generate
</Button>
</Col>
</Grid>
<Text
size="xs"
sx={(theme) => ({
color: theme.colors.gray[6],
})}
>
{window.location.origin}/share/
{form.values.link == "" ? "myAwesomeShare" : form.values.link}
</Text>
{mode == "email" && (
<MultiSelect
label="Email adresses"
data={form.values.emails}
placeholder="Email adresses"
searchable
creatable
rightSection={<></>}
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")}
/>
)}
<Select
label="Expiration"
{...form.getInputProps("expiration")}
data={[
{ value: "10", label: "10 Minutes" },
{ value: "60", label: "1 Hour" },
{ value: "1440", label: "1 Day" },
{ value: "1080", label: "1 Week" },
{ value: "43000", label: "1 Month" },
]}
/>
<Accordion>
<Accordion.Item
label="Security options"
sx={{ borderBottom: "none" }}
>
<Group direction="column" grow>
{mode == "standard" && (
<PasswordInput
variant="filled"
placeholder="No password"
label="Password protection"
{...form.getInputProps("password")}
/>
)}
<NumberInput
type="number"
variant="filled"
placeholder="No limit"
label="Maximal views"
{...form.getInputProps("maxVisitors")}
/>
</Group>
</Accordion.Item>
</Accordion>
<Button type="submit">Share</Button>
</Group>
</form>
);
};
export default CreateUploadModalBody;

View File

@@ -16,13 +16,21 @@ import toast from "../../utils/toast.util";
const showCompletedUploadModal = ( const showCompletedUploadModal = (
modals: ModalsContextProps, modals: ModalsContextProps,
link: string, link: string,
expiresAt: string expiresAt: string,
mode: "email" | "standard"
) => { ) => {
return modals.openModal({ return modals.openModal({
closeOnClickOutside: false, closeOnClickOutside: false,
withCloseButton: false, withCloseButton: false,
closeOnEscape: false, closeOnEscape: false,
title: <Title order={4}>Share ready</Title>, title: (
<Group grow direction="column" spacing={0}>
<Title order={4}>Share ready</Title>
{mode == "email" && (
<Text size="sm"> Emails were sent to the to invited users.</Text>
)}
</Group>
),
children: <Body link={link} expiresAt={expiresAt} />, children: <Body link={link} expiresAt={expiresAt} />,
}); });
}; };

View File

@@ -1,145 +1,22 @@
import { import { Title } from "@mantine/core";
Accordion,
Button,
Col,
Grid,
Group,
NumberInput,
PasswordInput,
Select,
Text,
TextInput,
Title,
} from "@mantine/core";
import { useForm, yupResolver } from "@mantine/form";
import { useModals } from "@mantine/modals";
import { ModalsContextProps } from "@mantine/modals/lib/context"; import { ModalsContextProps } from "@mantine/modals/lib/context";
import * as yup from "yup"; import CreateUploadModalBody from "../share/CreateUploadModalBody";
import shareService from "../../services/share.service";
const showCreateUploadModal = ( const showCreateUploadModal = (
mode: "standard" | "email",
modals: ModalsContextProps, modals: ModalsContextProps,
uploadCallback: ( uploadCallback: (
id: string, id: string,
expiration: number, expiration: number,
security: { password?: string; maxVisitors?: number } security: { password?: string; maxVisitors?: number}, emails? : string[]
) => void ) => void
) => { ) => {
return modals.openModal({ return modals.openModal({
title: <Title order={4}>Share</Title>, title: <Title order={4}>Share</Title>,
children: <Body uploadCallback={uploadCallback} />, children: (
<CreateUploadModalBody mode={mode} uploadCallback={uploadCallback} />
),
}); });
}; };
const Body = ({ export default showCreateUploadModal;
uploadCallback,
}: {
uploadCallback: (
id: string,
expiration: number,
security: { password?: string; maxVisitors?: number }
) => void;
}) => {
const modals = useModals();
const validationSchema = yup.object().shape({
link: yup.string().required().min(2).max(50),
password: yup.string().min(3).max(100),
maxVisitors: yup.number().min(1),
});
const form = useForm({
initialValues: {
link: "",
password: undefined,
maxVisitors: undefined,
expiration: "1440",
},
schema: yupResolver(validationSchema),
});
return (
<form
onSubmit={form.onSubmit(async (values) => {
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,
});
}
})}
>
<Group direction="column" grow>
<Grid align={form.errors.link ? "center" : "flex-end"}>
<Col xs={9}>
<TextInput
variant="filled"
label="Link"
placeholder="myAwesomeShare"
{...form.getInputProps("link")}
/>
</Col>
<Col xs={3}>
<Button
variant="outline"
onClick={() =>
form.setFieldValue(
"link",
Buffer.from(Math.random().toString(), "utf8")
.toString("base64")
.substr(10, 7)
)
}
>
Generate
</Button>
</Col>
</Grid>
<Text
size="xs"
sx={(theme) => ({
color: theme.colors.gray[6],
})}
>
{window.location.origin}/share/
{form.values.link == "" ? "myAwesomeShare" : form.values.link}
</Text>
<Select
label="Expiration"
{...form.getInputProps("expiration")}
data={[
{ value: "10", label: "10 Minutes" },
{ value: "60", label: "1 Hour" },
{ value: "1440", label: "1 Day" },
{ value: "1080", label: "1 Week" },
{ value: "43000", label: "1 Month" },
]}
/>
<Accordion>
<Accordion.Item label="Security" sx={{ borderBottom: "none" }}>
<Group direction="column" grow>
<PasswordInput
variant="filled"
placeholder="No password"
label="Password protection"
{...form.getInputProps("password")}
/>
<NumberInput
type="number"
variant="filled"
placeholder="No limit"
label="Maximal views"
{...form.getInputProps("maxVisitors")}
/>
</Group>
</Accordion.Item>
</Accordion>
<Button type="submit">Share</Button>
</Group>
</form>
);
};
export default showCreateUploadModal;

View File

@@ -3,14 +3,30 @@ import { ShareDocument } from "../../../../types/Appwrite.type";
import { AppwriteFileWithPreview } from "../../../../types/File.type"; import { AppwriteFileWithPreview } from "../../../../types/File.type";
import awServer from "../../../../utils/appwriteServer.util"; import awServer from "../../../../utils/appwriteServer.util";
import { checkSecurity } from "../../../../utils/shares/security.util"; import { checkSecurity } from "../../../../utils/shares/security.util";
import * as jose from "jose";
const handler = async (req: NextApiRequest, res: NextApiResponse) => { const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const shareId = req.query.shareId as string; const shareId = req.query.shareId as string;
const fileList: AppwriteFileWithPreview[] = []; const fileList: AppwriteFileWithPreview[] = [];
const hashedPassword = req.cookies[`${shareId}-password`]; const hashedPassword = req.cookies[`${shareId}-password`];
if (!(await shareExists(shareId))) let shareDocument;
try {
shareDocument = await awServer.database.getDocument<ShareDocument>(
"shares",
shareId
);
} catch {
return res.status(404).json({ message: "not_found" }); 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 { try {
await checkSecurity(shareId, hashedPassword); await checkSecurity(shareId, hashedPassword);
@@ -20,8 +36,9 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
addVisitorCount(shareId); addVisitorCount(shareId);
const fileListWithoutPreview = (await awServer.storage.listFiles(shareId, undefined, 100)) const fileListWithoutPreview = (
.files; await awServer.storage.listFiles(shareId, undefined, 100)
).files;
for (const file of fileListWithoutPreview) { for (const file of fileListWithoutPreview) {
const filePreview = await awServer.storage.getFilePreview( const filePreview = await awServer.storage.getFilePreview(
@@ -39,18 +56,20 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
res.status(200).json(fileList); res.status(200).json(fileList);
}; };
const shareExists = async (shareId: string) => { const hasUserAccess = (jwt: string, shareDocument: ShareDocument) => {
if (shareDocument.users?.length == 0) return true;
try { try {
const shareDocument = await awServer.database.getDocument<ShareDocument>( const userId = jose.decodeJwt(jwt).userId as string;
"shares", return shareDocument.users?.includes(userId);
shareId } catch {
);
return shareDocument.enabled && shareDocument.expiresAt > Date.now();
} catch (e) {
return false; return false;
} }
}; };
const shareExists = async (shareDocument: ShareDocument) => {
return shareDocument.enabled && shareDocument.expiresAt > Date.now();
};
const addVisitorCount = async (shareId: string) => { const addVisitorCount = async (shareId: string) => {
const currentDocument = await awServer.database.getDocument<ShareDocument>( const currentDocument = await awServer.database.getDocument<ShareDocument>(
"shares", "shares",

View File

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

View File

@@ -8,6 +8,7 @@ import FileList from "../../components/share/FileList";
import showEnterPasswordModal from "../../components/share/showEnterPasswordModal"; import showEnterPasswordModal from "../../components/share/showEnterPasswordModal";
import showShareNotFoundModal from "../../components/share/showShareNotFoundModal"; import showShareNotFoundModal from "../../components/share/showShareNotFoundModal";
import showVisitorLimitExceededModal from "../../components/share/showVisitorLimitExceededModal"; import showVisitorLimitExceededModal from "../../components/share/showVisitorLimitExceededModal";
import authService from "../../services/auth.service";
import shareService from "../../services/share.service"; import shareService from "../../services/share.service";
import { AppwriteFileWithPreview } from "../../types/File.type"; 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 shareService
.get(shareId, password) .get(shareId, password)
.then((files) => { .then((files) => {
@@ -40,6 +47,7 @@ const Share = () => {
showVisitorLimitExceededModal(modals); showVisitorLimitExceededModal(modals);
} }
}); });
};
useEffect(() => { useEffect(() => {
getFiles(); getFiles();
@@ -47,7 +55,10 @@ const Share = () => {
return ( return (
<> <>
<Meta title={`Share ${shareId}`} /> <Meta
title={`Share ${shareId}`}
description="Look what I've shared with you."
/>
<Group position="right"> <Group position="right">
<DownloadAllButton <DownloadAllButton
shareId={shareId} shareId={shareId}

View File

@@ -11,31 +11,34 @@ import showCreateUploadModal from "../components/upload/showCreateUploadModal";
import { FileUpload } from "../types/File.type"; import { FileUpload } from "../types/File.type";
import aw from "../utils/appwrite.util"; import aw from "../utils/appwrite.util";
import { IsSignedInContext } from "../utils/auth.util"; import { IsSignedInContext } from "../utils/auth.util";
import { useConfig } from "../utils/config.util";
import toast from "../utils/toast.util"; import toast from "../utils/toast.util";
const Upload = () => { const Upload = () => {
const router = useRouter(); const router = useRouter();
const modals = useModals(); const modals = useModals();
const config = useConfig();
const isSignedIn = useContext(IsSignedInContext); const isSignedIn = useContext(IsSignedInContext);
const [files, setFiles] = useState<FileUpload[]>([]); const [files, setFiles] = useState<FileUpload[]>([]);
const [isUploading, setisUploading] = useState(false); const [isUploading, setisUploading] = useState(false);
let shareMode: "email" | "standard";
const uploadFiles = async ( const uploadFiles = async (
id: string, id: string,
expiration: number, expiration: number,
security: { password?: string; maxVisitors?: number } security: { password?: string; maxVisitors?: number },
emails?: string[]
) => { ) => {
setisUploading(true); setisUploading(true);
try { try {
files.forEach((file) => { files.forEach((file) => {
file.uploadingState = "inProgress"; file.uploadingState = "inProgress";
}); });
const bucketId = JSON.parse( const bucketId = JSON.parse(
( (
await aw.functions.createExecution( await aw.functions.createExecution(
"createShare", "createShare",
JSON.stringify({ id, security, expiration }), JSON.stringify({ id, security, expiration, emails }),
false false
) )
).stdout ).stdout
@@ -56,7 +59,8 @@ const Upload = () => {
showCompletedUploadModal( showCompletedUploadModal(
modals, modals,
`${window.location.origin}/share/${bucketId}`, `${window.location.origin}/share/${bucketId}`,
new Date(Date.now() + expiration * 60 * 1000).toLocaleString() new Date(Date.now() + expiration * 60 * 1000).toLocaleString(),
shareMode
); );
setFiles([]); setFiles([]);
} }
@@ -96,11 +100,21 @@ const Upload = () => {
> >
<Menu.Item <Menu.Item
icon={<Link size={16} />} icon={<Link size={16} />}
onClick={() => showCreateUploadModal(modals, uploadFiles)} onClick={() => {
shareMode = "standard";
showCreateUploadModal(shareMode, modals, uploadFiles);
}}
> >
Share with link Share with link
</Menu.Item> </Menu.Item>
<Menu.Item disabled icon={<Mail size={16} />}> <Menu.Item
disabled={!config.MAIL_SHARE_ENABLED}
icon={<Mail size={16} />}
onClick={() => {
shareMode = "email";
showCreateUploadModal(shareMode, modals, uploadFiles);
}}
>
Share with email Share with email
</Menu.Item> </Menu.Item>
</Menu> </Menu>

View File

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

View File

@@ -10,9 +10,18 @@ const isIdAlreadyInUse = async (shareId: string) => {
.exists as boolean; .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) => { const authenticateWithPassword = async (shareId: string, password?: string) => {
return (await axios.post(`/api/share/${shareId}/enterPassword`, { password })) return (await axios.post(`/api/share/${shareId}/enterPassword`, { password }))
.data as AppwriteFileWithPreview[]; .data as AppwriteFileWithPreview[];
}; };
export default { get, authenticateWithPassword, isIdAlreadyInUse }; export default {
get,
authenticateWithPassword,
isIdAlreadyInUse,
doesUserExist,
};

View File

@@ -6,10 +6,10 @@ export type ShareDocument = {
expiresAt: number; expiresAt: number;
visitorCount: number; visitorCount: number;
enabled: boolean; enabled: boolean;
users?: string[];
} & Models.Document; } & Models.Document;
export type SecurityDocument = { export type SecurityDocument = {
password: string; password: string;
maxVisitors: number; maxVisitors: number;
} & Models.Document; } & Models.Document;

View File

@@ -7,6 +7,7 @@ const error = (message: string) =>
color: "red", color: "red",
radius: "md", radius: "md",
title: "Error", title: "Error",
message: message, message: message,
}); });