From 3721586c00b7821dacd4b9fe1bd9a9177cdcc185 Mon Sep 17 00:00:00 2001 From: Makon Cline Date: Wed, 22 Jan 2020 19:16:58 -0600 Subject: [PATCH 01/34] add avatar upload component --- @app/components/src/AvatarUpload.tsx | 208 +++++++++++++++++++++++++++ @app/components/src/index.tsx | 1 + 2 files changed, 209 insertions(+) create mode 100644 @app/components/src/AvatarUpload.tsx diff --git a/@app/components/src/AvatarUpload.tsx b/@app/components/src/AvatarUpload.tsx new file mode 100644 index 00000000..fbd920c2 --- /dev/null +++ b/@app/components/src/AvatarUpload.tsx @@ -0,0 +1,208 @@ +import React, { useState, useEffect } from "react"; +import { Upload, Icon, message } from "antd"; +import { UploadChangeParam } from "antd/lib/upload"; +import { UploadFile, RcCustomRequestOptions } from "antd/lib/upload/interface"; +import axios from "axios"; +import { + useChangeAvatarMutation, + ProfileSettingsForm_UserFragment, +} from "@app/graphql"; +import { ApolloError } from "apollo-client"; + +export function slugify(string: string) { + const a = + "àáâäæãåāăąçćčđďèéêëēėęěğǵḧîïíīįìłḿñńǹňôöòóœøōõőṕŕřßśšşșťțûüùúūǘůűųẃẍÿýžźż·/_,:;"; + const b = + "aaaaaaaaaacccddeeeeeeeegghiiiiiilmnnnnoooooooooprrsssssttuuuuuuuuuwxyyzzz------"; + const p = new RegExp(a.split("").join("|"), "g"); + + return string + .toString() + .toLowerCase() + .replace(/\s+/g, "-") // Replace spaces with - + .replace(p, c => b.charAt(a.indexOf(c))) // Replace special characters + .replace(/&/g, "-and-") // Replace & with 'and' + .replace(/[^\w\-]+/g, "") // Remove all non-word characters + .replace(/\-\-+/g, "-") // Replace multiple - with single - + .replace(/^-+/, "") // Trim - from start of text + .replace(/-+$/, ""); // Trim - from end of text +} + +export function getUid(name: string) { + const randomHex = () => Math.floor(Math.random() * 16777215).toString(16); + const fileNameSlug = slugify(name); + return randomHex() + "-" + fileNameSlug; +} + +export function AvatarUpload({ + user, + setSuccess, + setError, +}: { + user: ProfileSettingsForm_UserFragment; + setSuccess: React.Dispatch>; + setError: (error: Error | ApolloError | null) => void; +}) { + const [changeAvatar] = useChangeAvatarMutation(); + const [fileList, setFileList] = useState( + user && user.avatarUrl + ? [ + { + uid: "-1", + name: "avatar", + type: "image", + size: 1, + url: user.avatarUrl, + }, + ] + : null + ); + + useEffect(() => { + if (user) { + const avatar = user.avatarUrl; + if (avatar) { + setFileList([ + { + uid: "-1", + name: "avatar", + type: "image", + size: 1, + url: avatar, + }, + ]); + } else { + setFileList(null); + } + } + }, [user, user.avatarUrl]); + + // const onChange = (info: UploadChangeParam) => { + // console.log(info); + // setFileList([...fileList]); + // }; + + const beforeUpload = (file: any) => { + const fileName = file.name.split(".")[0]; + const fileType = file.name.split(".")[1]; + file.uid = getUid(fileName) + "." + fileType; + const isJpgOrPng = file.type === "image/jpeg" || file.type === "image/png"; + if (!isJpgOrPng) { + message.error("You can only upload JPG or PNG images!"); + file.status = "error"; + } + const isLt3M = file.size / 1024 / 1024 < 3; + if (!isLt3M) { + message.error("Image must smaller than 3MB!"); + file.status = "error"; + } + return isJpgOrPng && isLt3M; + }; + + const changeUserAvatar = async (avatarUrl: string | null) => { + setSuccess(false); + setError(null); + try { + await changeAvatar({ + variables: { + id: user.id, + patch: { + avatarUrl, + }, + }, + }); + setError(null); + setSuccess(true); + } catch (e) { + setError(e); + } + }; + + const customRequest = (option: RcCustomRequestOptions) => { + const { onSuccess, onError, file, onProgress } = option; + axios + .get(`${process.env.ROOT_URL}/api/s3`, { + params: { + key: file.uid, + operation: "put", + }, + }) + .then(response => { + const preSignedUrl = response.data.url; + axios + .put(preSignedUrl, file, { + onUploadProgress: e => { + const progress = Math.round((e.loaded / e.total) * 100); + onProgress({ percent: progress }, file); + }, + }) + .then(response => { + if (response.config.url) { + changeUserAvatar(response.config.url.split("?")[0]); + onSuccess(response.config, file); + } + }) + .catch(error => { + console.log(error); + onError(error); + }); + }) + .catch(error => { + console.log(error); + onError(error); + }); + }; + + const deleteUserAvatarFromBucket = async () => { + if (user && user.avatarUrl) { + const key = user.avatarUrl.substring(user.avatarUrl.lastIndexOf("/") + 1); + await axios + .get(`${process.env.ROOT_URL}/api/s3`, { + params: { + key: `${key}`, + operation: "delete", + }, + }) + .then(() => { + // this isn't confirmation that the item was deleted + // only confimation that there wasnt an error.. + changeUserAvatar(null); + return true; + }) + .catch(error => { + console.log(JSON.stringify(error)); + return false; + }); + } + return true; + }; + + const onRemove = async () => { + if (await deleteUserAvatarFromBucket()) { + setFileList(null); + } + }; + + const uploadButton = ( +
+ +
Avatar
+
+ ); + + return ( +
+ + {fileList && fileList.length >= 0 ? null : uploadButton} + +
+ ); +} diff --git a/@app/components/src/index.tsx b/@app/components/src/index.tsx index c178579a..cfa3daa3 100644 --- a/@app/components/src/index.tsx +++ b/@app/components/src/index.tsx @@ -5,3 +5,4 @@ export * from "./StandardWidth"; export * from "./Text"; export * from "./Warn"; export * from "./PasswordStrength"; +export * from "./AvatarUpload"; From 11e47960ce5c5bed5d707046cf066ac0782d9d02 Mon Sep 17 00:00:00 2001 From: Makon Cline Date: Wed, 22 Jan 2020 19:17:45 -0600 Subject: [PATCH 02/34] schema auto change --- data/schema.sql | 118 ++++++++++++++++++++++++------------------------ 1 file changed, 59 insertions(+), 59 deletions(-) diff --git a/data/schema.sql b/data/schema.sql index b9eb40d2..82be48b8 100644 --- a/data/schema.sql +++ b/data/schema.sql @@ -2,8 +2,8 @@ -- PostgreSQL database dump -- --- Dumped from database version 11.6 (Debian 11.6-1.pgdg90+1) --- Dumped by pg_dump version 11.6 (Debian 11.6-1.pgdg90+1) +-- Dumped from database version 12.1 +-- Dumped by pg_dump version 12.1 SET statement_timeout = 0; SET lock_timeout = 0; @@ -97,7 +97,7 @@ $$; SET default_tablespace = ''; -SET default_with_oids = false; +SET default_table_access_method = heap; -- -- Name: users; Type: TABLE; Schema: app_public; Owner: - @@ -1573,70 +1573,70 @@ CREATE INDEX user_authentications_user_id_idx ON app_public.user_authentications -- Name: user_authentications _100_timestamps; Type: TRIGGER; Schema: app_public; Owner: - -- -CREATE TRIGGER _100_timestamps BEFORE INSERT OR UPDATE ON app_public.user_authentications FOR EACH ROW EXECUTE PROCEDURE app_private.tg__timestamps(); +CREATE TRIGGER _100_timestamps BEFORE INSERT OR UPDATE ON app_public.user_authentications FOR EACH ROW EXECUTE FUNCTION app_private.tg__timestamps(); -- -- Name: user_emails _100_timestamps; Type: TRIGGER; Schema: app_public; Owner: - -- -CREATE TRIGGER _100_timestamps BEFORE INSERT OR UPDATE ON app_public.user_emails FOR EACH ROW EXECUTE PROCEDURE app_private.tg__timestamps(); +CREATE TRIGGER _100_timestamps BEFORE INSERT OR UPDATE ON app_public.user_emails FOR EACH ROW EXECUTE FUNCTION app_private.tg__timestamps(); -- -- Name: users _100_timestamps; Type: TRIGGER; Schema: app_public; Owner: - -- -CREATE TRIGGER _100_timestamps BEFORE INSERT OR UPDATE ON app_public.users FOR EACH ROW EXECUTE PROCEDURE app_private.tg__timestamps(); +CREATE TRIGGER _100_timestamps BEFORE INSERT OR UPDATE ON app_public.users FOR EACH ROW EXECUTE FUNCTION app_private.tg__timestamps(); -- -- Name: user_emails _200_forbid_existing_email; Type: TRIGGER; Schema: app_public; Owner: - -- -CREATE TRIGGER _200_forbid_existing_email BEFORE INSERT ON app_public.user_emails FOR EACH ROW EXECUTE PROCEDURE app_public.tg_user_emails__forbid_if_verified(); +CREATE TRIGGER _200_forbid_existing_email BEFORE INSERT ON app_public.user_emails FOR EACH ROW EXECUTE FUNCTION app_public.tg_user_emails__forbid_if_verified(); -- -- Name: users _200_make_first_user_admin; Type: TRIGGER; Schema: app_public; Owner: - -- -CREATE TRIGGER _200_make_first_user_admin BEFORE INSERT ON app_public.users FOR EACH ROW WHEN ((new.id = 1)) EXECUTE PROCEDURE app_private.tg_users__make_first_user_admin(); +CREATE TRIGGER _200_make_first_user_admin BEFORE INSERT ON app_public.users FOR EACH ROW WHEN ((new.id = 1)) EXECUTE FUNCTION app_private.tg_users__make_first_user_admin(); -- -- Name: users _500_gql_update; Type: TRIGGER; Schema: app_public; Owner: - -- -CREATE TRIGGER _500_gql_update AFTER UPDATE ON app_public.users FOR EACH ROW EXECUTE PROCEDURE app_public.tg__graphql_subscription('userChanged', 'graphql:user:$1', 'id'); +CREATE TRIGGER _500_gql_update AFTER UPDATE ON app_public.users FOR EACH ROW EXECUTE FUNCTION app_public.tg__graphql_subscription('userChanged', 'graphql:user:$1', 'id'); -- -- Name: user_emails _500_insert_secrets; Type: TRIGGER; Schema: app_public; Owner: - -- -CREATE TRIGGER _500_insert_secrets AFTER INSERT ON app_public.user_emails FOR EACH ROW EXECUTE PROCEDURE app_private.tg_user_email_secrets__insert_with_user_email(); +CREATE TRIGGER _500_insert_secrets AFTER INSERT ON app_public.user_emails FOR EACH ROW EXECUTE FUNCTION app_private.tg_user_email_secrets__insert_with_user_email(); -- -- Name: users _500_insert_secrets; Type: TRIGGER; Schema: app_public; Owner: - -- -CREATE TRIGGER _500_insert_secrets AFTER INSERT ON app_public.users FOR EACH ROW EXECUTE PROCEDURE app_private.tg_user_secrets__insert_with_user(); +CREATE TRIGGER _500_insert_secrets AFTER INSERT ON app_public.users FOR EACH ROW EXECUTE FUNCTION app_private.tg_user_secrets__insert_with_user(); -- -- Name: user_emails _500_verify_account_on_verified; Type: TRIGGER; Schema: app_public; Owner: - -- -CREATE TRIGGER _500_verify_account_on_verified AFTER INSERT OR UPDATE OF is_verified ON app_public.user_emails FOR EACH ROW WHEN ((new.is_verified IS TRUE)) EXECUTE PROCEDURE app_public.tg_user_emails__verify_account_on_verified(); +CREATE TRIGGER _500_verify_account_on_verified AFTER INSERT OR UPDATE OF is_verified ON app_public.user_emails FOR EACH ROW WHEN ((new.is_verified IS TRUE)) EXECUTE FUNCTION app_public.tg_user_emails__verify_account_on_verified(); -- -- Name: user_emails _900_send_verification_email; Type: TRIGGER; Schema: app_public; Owner: - -- -CREATE TRIGGER _900_send_verification_email AFTER INSERT ON app_public.user_emails FOR EACH ROW WHEN ((new.is_verified IS FALSE)) EXECUTE PROCEDURE app_private.tg__add_job('user_emails__send_verification'); +CREATE TRIGGER _900_send_verification_email AFTER INSERT ON app_public.user_emails FOR EACH ROW WHEN ((new.is_verified IS FALSE)) EXECUTE FUNCTION app_private.tg__add_job('user_emails__send_verification'); -- @@ -1788,14 +1788,14 @@ ALTER TABLE app_public.users ENABLE ROW LEVEL SECURITY; -- Name: SCHEMA app_hidden; Type: ACL; Schema: -; Owner: - -- -GRANT USAGE ON SCHEMA app_hidden TO graphile_starter_visitor; +GRANT USAGE ON SCHEMA app_hidden TO graphile_starter_photo_upload_visitor; -- -- Name: SCHEMA app_public; Type: ACL; Schema: -; Owner: - -- -GRANT USAGE ON SCHEMA app_public TO graphile_starter_visitor; +GRANT USAGE ON SCHEMA app_public TO graphile_starter_photo_upload_visitor; -- @@ -1803,8 +1803,8 @@ GRANT USAGE ON SCHEMA app_public TO graphile_starter_visitor; -- REVOKE ALL ON SCHEMA public FROM PUBLIC; -GRANT ALL ON SCHEMA public TO graphile_starter; -GRANT USAGE ON SCHEMA public TO graphile_starter_visitor; +GRANT ALL ON SCHEMA public TO graphile_starter_photo_upload; +GRANT USAGE ON SCHEMA public TO graphile_starter_photo_upload_visitor; -- @@ -1818,28 +1818,28 @@ REVOKE ALL ON FUNCTION app_private.assert_valid_password(new_password text) FROM -- Name: TABLE users; Type: ACL; Schema: app_public; Owner: - -- -GRANT SELECT ON TABLE app_public.users TO graphile_starter_visitor; +GRANT SELECT ON TABLE app_public.users TO graphile_starter_photo_upload_visitor; -- -- Name: COLUMN users.username; Type: ACL; Schema: app_public; Owner: - -- -GRANT UPDATE(username) ON TABLE app_public.users TO graphile_starter_visitor; +GRANT UPDATE(username) ON TABLE app_public.users TO graphile_starter_photo_upload_visitor; -- -- Name: COLUMN users.name; Type: ACL; Schema: app_public; Owner: - -- -GRANT UPDATE(name) ON TABLE app_public.users TO graphile_starter_visitor; +GRANT UPDATE(name) ON TABLE app_public.users TO graphile_starter_photo_upload_visitor; -- -- Name: COLUMN users.avatar_url; Type: ACL; Schema: app_public; Owner: - -- -GRANT UPDATE(avatar_url) ON TABLE app_public.users TO graphile_starter_visitor; +GRANT UPDATE(avatar_url) ON TABLE app_public.users TO graphile_starter_photo_upload_visitor; -- @@ -1910,7 +1910,7 @@ REVOKE ALL ON FUNCTION app_private.tg_users__make_first_user_admin() FROM PUBLIC -- REVOKE ALL ON FUNCTION app_public.change_password(old_password text, new_password text) FROM PUBLIC; -GRANT ALL ON FUNCTION app_public.change_password(old_password text, new_password text) TO graphile_starter_visitor; +GRANT ALL ON FUNCTION app_public.change_password(old_password text, new_password text) TO graphile_starter_photo_upload_visitor; -- @@ -1918,7 +1918,7 @@ GRANT ALL ON FUNCTION app_public.change_password(old_password text, new_password -- REVOKE ALL ON FUNCTION app_public.confirm_account_deletion(token text) FROM PUBLIC; -GRANT ALL ON FUNCTION app_public.confirm_account_deletion(token text) TO graphile_starter_visitor; +GRANT ALL ON FUNCTION app_public.confirm_account_deletion(token text) TO graphile_starter_photo_upload_visitor; -- @@ -1926,7 +1926,7 @@ GRANT ALL ON FUNCTION app_public.confirm_account_deletion(token text) TO graphil -- REVOKE ALL ON FUNCTION app_public.current_session_id() FROM PUBLIC; -GRANT ALL ON FUNCTION app_public.current_session_id() TO graphile_starter_visitor; +GRANT ALL ON FUNCTION app_public.current_session_id() TO graphile_starter_photo_upload_visitor; -- @@ -1934,7 +1934,7 @@ GRANT ALL ON FUNCTION app_public.current_session_id() TO graphile_starter_visito -- REVOKE ALL ON FUNCTION app_public."current_user"() FROM PUBLIC; -GRANT ALL ON FUNCTION app_public."current_user"() TO graphile_starter_visitor; +GRANT ALL ON FUNCTION app_public."current_user"() TO graphile_starter_photo_upload_visitor; -- @@ -1942,7 +1942,7 @@ GRANT ALL ON FUNCTION app_public."current_user"() TO graphile_starter_visitor; -- REVOKE ALL ON FUNCTION app_public.current_user_id() FROM PUBLIC; -GRANT ALL ON FUNCTION app_public.current_user_id() TO graphile_starter_visitor; +GRANT ALL ON FUNCTION app_public.current_user_id() TO graphile_starter_photo_upload_visitor; -- @@ -1950,7 +1950,7 @@ GRANT ALL ON FUNCTION app_public.current_user_id() TO graphile_starter_visitor; -- REVOKE ALL ON FUNCTION app_public.forgot_password(email public.citext) FROM PUBLIC; -GRANT ALL ON FUNCTION app_public.forgot_password(email public.citext) TO graphile_starter_visitor; +GRANT ALL ON FUNCTION app_public.forgot_password(email public.citext) TO graphile_starter_photo_upload_visitor; -- @@ -1958,21 +1958,21 @@ GRANT ALL ON FUNCTION app_public.forgot_password(email public.citext) TO graphil -- REVOKE ALL ON FUNCTION app_public.logout() FROM PUBLIC; -GRANT ALL ON FUNCTION app_public.logout() TO graphile_starter_visitor; +GRANT ALL ON FUNCTION app_public.logout() TO graphile_starter_photo_upload_visitor; -- -- Name: TABLE user_emails; Type: ACL; Schema: app_public; Owner: - -- -GRANT SELECT,DELETE ON TABLE app_public.user_emails TO graphile_starter_visitor; +GRANT SELECT,DELETE ON TABLE app_public.user_emails TO graphile_starter_photo_upload_visitor; -- -- Name: COLUMN user_emails.email; Type: ACL; Schema: app_public; Owner: - -- -GRANT INSERT(email) ON TABLE app_public.user_emails TO graphile_starter_visitor; +GRANT INSERT(email) ON TABLE app_public.user_emails TO graphile_starter_photo_upload_visitor; -- @@ -1980,7 +1980,7 @@ GRANT INSERT(email) ON TABLE app_public.user_emails TO graphile_starter_visitor; -- REVOKE ALL ON FUNCTION app_public.make_email_primary(email_id integer) FROM PUBLIC; -GRANT ALL ON FUNCTION app_public.make_email_primary(email_id integer) TO graphile_starter_visitor; +GRANT ALL ON FUNCTION app_public.make_email_primary(email_id integer) TO graphile_starter_photo_upload_visitor; -- @@ -1988,7 +1988,7 @@ GRANT ALL ON FUNCTION app_public.make_email_primary(email_id integer) TO graphil -- REVOKE ALL ON FUNCTION app_public.request_account_deletion() FROM PUBLIC; -GRANT ALL ON FUNCTION app_public.request_account_deletion() TO graphile_starter_visitor; +GRANT ALL ON FUNCTION app_public.request_account_deletion() TO graphile_starter_photo_upload_visitor; -- @@ -1996,7 +1996,7 @@ GRANT ALL ON FUNCTION app_public.request_account_deletion() TO graphile_starter_ -- REVOKE ALL ON FUNCTION app_public.resend_email_verification_code(email_id integer) FROM PUBLIC; -GRANT ALL ON FUNCTION app_public.resend_email_verification_code(email_id integer) TO graphile_starter_visitor; +GRANT ALL ON FUNCTION app_public.resend_email_verification_code(email_id integer) TO graphile_starter_photo_upload_visitor; -- @@ -2004,7 +2004,7 @@ GRANT ALL ON FUNCTION app_public.resend_email_verification_code(email_id integer -- REVOKE ALL ON FUNCTION app_public.reset_password(user_id integer, reset_token text, new_password text) FROM PUBLIC; -GRANT ALL ON FUNCTION app_public.reset_password(user_id integer, reset_token text, new_password text) TO graphile_starter_visitor; +GRANT ALL ON FUNCTION app_public.reset_password(user_id integer, reset_token text, new_password text) TO graphile_starter_photo_upload_visitor; -- @@ -2012,7 +2012,7 @@ GRANT ALL ON FUNCTION app_public.reset_password(user_id integer, reset_token tex -- REVOKE ALL ON FUNCTION app_public.tg__graphql_subscription() FROM PUBLIC; -GRANT ALL ON FUNCTION app_public.tg__graphql_subscription() TO graphile_starter_visitor; +GRANT ALL ON FUNCTION app_public.tg__graphql_subscription() TO graphile_starter_photo_upload_visitor; -- @@ -2020,7 +2020,7 @@ GRANT ALL ON FUNCTION app_public.tg__graphql_subscription() TO graphile_starter_ -- REVOKE ALL ON FUNCTION app_public.tg_user_emails__forbid_if_verified() FROM PUBLIC; -GRANT ALL ON FUNCTION app_public.tg_user_emails__forbid_if_verified() TO graphile_starter_visitor; +GRANT ALL ON FUNCTION app_public.tg_user_emails__forbid_if_verified() TO graphile_starter_photo_upload_visitor; -- @@ -2028,7 +2028,7 @@ GRANT ALL ON FUNCTION app_public.tg_user_emails__forbid_if_verified() TO graphil -- REVOKE ALL ON FUNCTION app_public.tg_user_emails__verify_account_on_verified() FROM PUBLIC; -GRANT ALL ON FUNCTION app_public.tg_user_emails__verify_account_on_verified() TO graphile_starter_visitor; +GRANT ALL ON FUNCTION app_public.tg_user_emails__verify_account_on_verified() TO graphile_starter_photo_upload_visitor; -- @@ -2036,7 +2036,7 @@ GRANT ALL ON FUNCTION app_public.tg_user_emails__verify_account_on_verified() TO -- REVOKE ALL ON FUNCTION app_public.users_has_password(u app_public.users) FROM PUBLIC; -GRANT ALL ON FUNCTION app_public.users_has_password(u app_public.users) TO graphile_starter_visitor; +GRANT ALL ON FUNCTION app_public.users_has_password(u app_public.users) TO graphile_starter_photo_upload_visitor; -- @@ -2044,93 +2044,93 @@ GRANT ALL ON FUNCTION app_public.users_has_password(u app_public.users) TO graph -- REVOKE ALL ON FUNCTION app_public.verify_email(user_email_id integer, token text) FROM PUBLIC; -GRANT ALL ON FUNCTION app_public.verify_email(user_email_id integer, token text) TO graphile_starter_visitor; +GRANT ALL ON FUNCTION app_public.verify_email(user_email_id integer, token text) TO graphile_starter_photo_upload_visitor; -- -- Name: TABLE user_authentications; Type: ACL; Schema: app_public; Owner: - -- -GRANT SELECT,DELETE ON TABLE app_public.user_authentications TO graphile_starter_visitor; +GRANT SELECT,DELETE ON TABLE app_public.user_authentications TO graphile_starter_photo_upload_visitor; -- -- Name: SEQUENCE user_authentications_id_seq; Type: ACL; Schema: app_public; Owner: - -- -GRANT SELECT,USAGE ON SEQUENCE app_public.user_authentications_id_seq TO graphile_starter_visitor; +GRANT SELECT,USAGE ON SEQUENCE app_public.user_authentications_id_seq TO graphile_starter_photo_upload_visitor; -- -- Name: SEQUENCE user_emails_id_seq; Type: ACL; Schema: app_public; Owner: - -- -GRANT SELECT,USAGE ON SEQUENCE app_public.user_emails_id_seq TO graphile_starter_visitor; +GRANT SELECT,USAGE ON SEQUENCE app_public.user_emails_id_seq TO graphile_starter_photo_upload_visitor; -- -- Name: SEQUENCE users_id_seq; Type: ACL; Schema: app_public; Owner: - -- -GRANT SELECT,USAGE ON SEQUENCE app_public.users_id_seq TO graphile_starter_visitor; +GRANT SELECT,USAGE ON SEQUENCE app_public.users_id_seq TO graphile_starter_photo_upload_visitor; -- -- Name: DEFAULT PRIVILEGES FOR SEQUENCES; Type: DEFAULT ACL; Schema: app_hidden; Owner: - -- -ALTER DEFAULT PRIVILEGES FOR ROLE graphile_starter IN SCHEMA app_hidden REVOKE ALL ON SEQUENCES FROM graphile_starter; -ALTER DEFAULT PRIVILEGES FOR ROLE graphile_starter IN SCHEMA app_hidden GRANT SELECT,USAGE ON SEQUENCES TO graphile_starter_visitor; +ALTER DEFAULT PRIVILEGES FOR ROLE graphile_starter_photo_upload IN SCHEMA app_hidden REVOKE ALL ON SEQUENCES FROM graphile_starter_photo_upload; +ALTER DEFAULT PRIVILEGES FOR ROLE graphile_starter_photo_upload IN SCHEMA app_hidden GRANT SELECT,USAGE ON SEQUENCES TO graphile_starter_photo_upload_visitor; -- -- Name: DEFAULT PRIVILEGES FOR FUNCTIONS; Type: DEFAULT ACL; Schema: app_hidden; Owner: - -- -ALTER DEFAULT PRIVILEGES FOR ROLE graphile_starter IN SCHEMA app_hidden REVOKE ALL ON FUNCTIONS FROM PUBLIC; -ALTER DEFAULT PRIVILEGES FOR ROLE graphile_starter IN SCHEMA app_hidden REVOKE ALL ON FUNCTIONS FROM graphile_starter; -ALTER DEFAULT PRIVILEGES FOR ROLE graphile_starter IN SCHEMA app_hidden GRANT ALL ON FUNCTIONS TO graphile_starter_visitor; +ALTER DEFAULT PRIVILEGES FOR ROLE graphile_starter_photo_upload IN SCHEMA app_hidden REVOKE ALL ON FUNCTIONS FROM PUBLIC; +ALTER DEFAULT PRIVILEGES FOR ROLE graphile_starter_photo_upload IN SCHEMA app_hidden REVOKE ALL ON FUNCTIONS FROM graphile_starter_photo_upload; +ALTER DEFAULT PRIVILEGES FOR ROLE graphile_starter_photo_upload IN SCHEMA app_hidden GRANT ALL ON FUNCTIONS TO graphile_starter_photo_upload_visitor; -- -- Name: DEFAULT PRIVILEGES FOR SEQUENCES; Type: DEFAULT ACL; Schema: app_public; Owner: - -- -ALTER DEFAULT PRIVILEGES FOR ROLE graphile_starter IN SCHEMA app_public REVOKE ALL ON SEQUENCES FROM graphile_starter; -ALTER DEFAULT PRIVILEGES FOR ROLE graphile_starter IN SCHEMA app_public GRANT SELECT,USAGE ON SEQUENCES TO graphile_starter_visitor; +ALTER DEFAULT PRIVILEGES FOR ROLE graphile_starter_photo_upload IN SCHEMA app_public REVOKE ALL ON SEQUENCES FROM graphile_starter_photo_upload; +ALTER DEFAULT PRIVILEGES FOR ROLE graphile_starter_photo_upload IN SCHEMA app_public GRANT SELECT,USAGE ON SEQUENCES TO graphile_starter_photo_upload_visitor; -- -- Name: DEFAULT PRIVILEGES FOR FUNCTIONS; Type: DEFAULT ACL; Schema: app_public; Owner: - -- -ALTER DEFAULT PRIVILEGES FOR ROLE graphile_starter IN SCHEMA app_public REVOKE ALL ON FUNCTIONS FROM PUBLIC; -ALTER DEFAULT PRIVILEGES FOR ROLE graphile_starter IN SCHEMA app_public REVOKE ALL ON FUNCTIONS FROM graphile_starter; -ALTER DEFAULT PRIVILEGES FOR ROLE graphile_starter IN SCHEMA app_public GRANT ALL ON FUNCTIONS TO graphile_starter_visitor; +ALTER DEFAULT PRIVILEGES FOR ROLE graphile_starter_photo_upload IN SCHEMA app_public REVOKE ALL ON FUNCTIONS FROM PUBLIC; +ALTER DEFAULT PRIVILEGES FOR ROLE graphile_starter_photo_upload IN SCHEMA app_public REVOKE ALL ON FUNCTIONS FROM graphile_starter_photo_upload; +ALTER DEFAULT PRIVILEGES FOR ROLE graphile_starter_photo_upload IN SCHEMA app_public GRANT ALL ON FUNCTIONS TO graphile_starter_photo_upload_visitor; -- -- Name: DEFAULT PRIVILEGES FOR SEQUENCES; Type: DEFAULT ACL; Schema: public; Owner: - -- -ALTER DEFAULT PRIVILEGES FOR ROLE graphile_starter IN SCHEMA public REVOKE ALL ON SEQUENCES FROM graphile_starter; -ALTER DEFAULT PRIVILEGES FOR ROLE graphile_starter IN SCHEMA public GRANT SELECT,USAGE ON SEQUENCES TO graphile_starter_visitor; +ALTER DEFAULT PRIVILEGES FOR ROLE graphile_starter_photo_upload IN SCHEMA public REVOKE ALL ON SEQUENCES FROM graphile_starter_photo_upload; +ALTER DEFAULT PRIVILEGES FOR ROLE graphile_starter_photo_upload IN SCHEMA public GRANT SELECT,USAGE ON SEQUENCES TO graphile_starter_photo_upload_visitor; -- -- Name: DEFAULT PRIVILEGES FOR FUNCTIONS; Type: DEFAULT ACL; Schema: public; Owner: - -- -ALTER DEFAULT PRIVILEGES FOR ROLE graphile_starter IN SCHEMA public REVOKE ALL ON FUNCTIONS FROM PUBLIC; -ALTER DEFAULT PRIVILEGES FOR ROLE graphile_starter IN SCHEMA public REVOKE ALL ON FUNCTIONS FROM graphile_starter; -ALTER DEFAULT PRIVILEGES FOR ROLE graphile_starter IN SCHEMA public GRANT ALL ON FUNCTIONS TO graphile_starter_visitor; +ALTER DEFAULT PRIVILEGES FOR ROLE graphile_starter_photo_upload IN SCHEMA public REVOKE ALL ON FUNCTIONS FROM PUBLIC; +ALTER DEFAULT PRIVILEGES FOR ROLE graphile_starter_photo_upload IN SCHEMA public REVOKE ALL ON FUNCTIONS FROM graphile_starter_photo_upload; +ALTER DEFAULT PRIVILEGES FOR ROLE graphile_starter_photo_upload IN SCHEMA public GRANT ALL ON FUNCTIONS TO graphile_starter_photo_upload_visitor; -- -- Name: DEFAULT PRIVILEGES FOR FUNCTIONS; Type: DEFAULT ACL; Schema: -; Owner: - -- -ALTER DEFAULT PRIVILEGES FOR ROLE graphile_starter REVOKE ALL ON FUNCTIONS FROM PUBLIC; +ALTER DEFAULT PRIVILEGES FOR ROLE graphile_starter_photo_upload REVOKE ALL ON FUNCTIONS FROM PUBLIC; -- From 9d0bbbd2e8dafb56fa8cda6090c52a1330749695 Mon Sep 17 00:00:00 2001 From: Makon Cline Date: Wed, 22 Jan 2020 19:18:17 -0600 Subject: [PATCH 03/34] add axios --- @app/client/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/@app/client/package.json b/@app/client/package.json index 0462f4b0..142b5f47 100644 --- a/@app/client/package.json +++ b/@app/client/package.json @@ -25,6 +25,7 @@ "apollo-link-error": "^1.1.11", "apollo-link-http": "^1.5.15", "apollo-link-ws": "^1.0.18", + "axios": "^0.19.1", "graphql": "^14.4.2", "less": "^3.9.0", "less-vars-to-js": "^1.3.0", From 02135d3f14f8c6e3532aa73ee4f99c7a349e8f46 Mon Sep 17 00:00:00 2001 From: Makon Cline Date: Wed, 22 Jan 2020 19:18:32 -0600 Subject: [PATCH 04/34] add change avatar mutation --- @app/client/src/graphql/ChangeAvatar.graphql | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 @app/client/src/graphql/ChangeAvatar.graphql diff --git a/@app/client/src/graphql/ChangeAvatar.graphql b/@app/client/src/graphql/ChangeAvatar.graphql new file mode 100644 index 00000000..9f08f7b0 --- /dev/null +++ b/@app/client/src/graphql/ChangeAvatar.graphql @@ -0,0 +1,9 @@ +mutation ChangeAvatar($id: Int!, $patch: UserPatch!) { + updateUser(input: { id: $id, patch: $patch }) { + clientMutationId + user { + id + avatarUrl + } + } +} From 156e92ee5075fd1f256071268499eb7b8d587e5f Mon Sep 17 00:00:00 2001 From: Makon Cline Date: Wed, 22 Jan 2020 19:19:16 -0600 Subject: [PATCH 05/34] add cors. This is bad...I think --- @app/server/package.json | 2 ++ @app/server/src/app.ts | 3 +++ 2 files changed, 5 insertions(+) diff --git a/@app/server/package.json b/@app/server/package.json index 461f5128..44a5f87f 100644 --- a/@app/server/package.json +++ b/@app/server/package.json @@ -16,6 +16,7 @@ "@types/chalk": "^2.2.0", "@types/connect-pg-simple": "^4.2.0", "@types/connect-redis": "^0.0.13", + "@types/cors": "^2.8.6", "@types/express-session": "^1.15.16", "@types/graphql": "*", "@types/helmet": "^0.0.45", @@ -28,6 +29,7 @@ "chalk": "^3.0.0", "connect-pg-simple": "^6.1.0", "connect-redis": "^4.0.3", + "cors": "^2.8.5", "express": "^4.17.1", "express-session": "^1.16.2", "graphile-build": "^4.5.0", diff --git a/@app/server/src/app.ts b/@app/server/src/app.ts index 64e14e0f..c7bcbde2 100644 --- a/@app/server/src/app.ts +++ b/@app/server/src/app.ts @@ -4,6 +4,7 @@ import * as middleware from "./middleware"; import { makeShutdownActions, ShutdownAction } from "./shutdownActions"; import { Middleware } from "postgraphile"; import { sanitizeEnv } from "./utils"; +import cors from "cors"; // Server may not always be supplied, e.g. where mounting on a sub-route export function getHttpServer(app: Express): Server | void { @@ -43,6 +44,8 @@ export async function makeApp({ */ const app = express(); + app.use(cors()); + /* * Getting access to the HTTP server directly means that we can do things * with websockets if we need to (e.g. GraphQL subscriptions). From 3644031e933eab147533609d89dfe6c19241767c Mon Sep 17 00:00:00 2001 From: Makon Cline Date: Wed, 22 Jan 2020 19:19:43 -0600 Subject: [PATCH 06/34] add aws env vars --- @app/client/src/next.config.js | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/@app/client/src/next.config.js b/@app/client/src/next.config.js index 312c98c0..3130800c 100644 --- a/@app/client/src/next.config.js +++ b/@app/client/src/next.config.js @@ -1,7 +1,14 @@ require("@app/config"); const compose = require("lodash/flowRight"); -const { ROOT_URL, T_AND_C_URL } = process.env; +const { + ROOT_URL, + T_AND_C_URL, + BUCKET, + AWSACCESSKEYID, + AWSSECRETKEY, + AWS_REGION, +} = process.env; if (!ROOT_URL) { throw new Error("ROOT_URL is a required envvar"); } @@ -31,6 +38,12 @@ if (!ROOT_URL) { withCss, withLess )({ + serverRuntimeConfig: { + BUCKET: BUCKET, + AWSACCESSKEYID: AWSACCESSKEYID, + AWSSECRETKEY: AWSSECRETKEY, + AWS_REGION: AWS_REGION, + }, poweredByHeader: false, distDir: `../.next`, exportTrailingSlash: true, From 5190ff5e96562e771c7c43cd796805d8417e9710 Mon Sep 17 00:00:00 2001 From: Makon Cline Date: Wed, 22 Jan 2020 19:20:00 -0600 Subject: [PATCH 07/34] add s3 api route --- @app/client/src/pages/api/s3.tsx | 67 ++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 @app/client/src/pages/api/s3.tsx diff --git a/@app/client/src/pages/api/s3.tsx b/@app/client/src/pages/api/s3.tsx new file mode 100644 index 00000000..e65ba0d1 --- /dev/null +++ b/@app/client/src/pages/api/s3.tsx @@ -0,0 +1,67 @@ +import { NextApiRequest, NextApiResponse } from "next"; +import AWS from "aws-sdk"; +import getConfig from "next/config"; +const { serverRuntimeConfig } = getConfig(); + +export default async (req: NextApiRequest, res: NextApiResponse) => { + const bucket = serverRuntimeConfig.BUCKET; + const key = req.query.key; + const params: AWS.S3.PutObjectRequest = { + Bucket: bucket, + Key: key as string, + }; + const client = getClient(); + const operation = req.query.operation; + if (operation === "put") { + put(client, params); + } else if (operation === "delete") { + del(client, params); + } + + function getClient() { + const region = serverRuntimeConfig.AWS_REGION; + const accessKey = serverRuntimeConfig.AWSACCESSKEYID; + const secretKey = serverRuntimeConfig.AWSSECRETKEY; + AWS.config.update({ + accessKeyId: accessKey, + secretAccessKey: secretKey, + signatureVersion: "v4", + region: region, + }); + const options = { + signatureVersion: "v4", + region: region, + // uncomment to use accelerated endpoint + // accelerated endpoint must be turned on in your s3 bucket first + // endpoint: new AWS.Endpoint( + // "bucket.s3-accelerate.amazonaws.com" + // ), + // useAccelerateEndpoint: true, + }; + const client = new AWS.S3(options); + return client; + } + function put(client: AWS.S3, params: AWS.S3.PutObjectRequest) { + const putParams = { + ...params, + Expires: 5 * 60, + }; + + client.getSignedUrl("putObject", putParams, (err, url) => { + if (err) { + res.json({ success: false, err }); + } else { + res.json({ success: true, url }); + } + }); + } + function del(client: AWS.S3, params: AWS.S3.DeleteObjectRequest) { + client.deleteObject(params, err => { + if (err) { + res.json({ success: false, err }); + } else { + res.json({ success: true }); + } + }); + } +}; From acaffb77d1228d6007d34cf3499b94bc2691da7c Mon Sep 17 00:00:00 2001 From: Makon Cline Date: Wed, 22 Jan 2020 19:20:33 -0600 Subject: [PATCH 08/34] use avatarPhoto if exists --- @app/client/src/layout/SharedLayout.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/@app/client/src/layout/SharedLayout.tsx b/@app/client/src/layout/SharedLayout.tsx index b25fdd8c..7051f945 100644 --- a/@app/client/src/layout/SharedLayout.tsx +++ b/@app/client/src/layout/SharedLayout.tsx @@ -132,9 +132,14 @@ function SharedLayout({ title, noPad = false, children }: SharedLayoutProps) { data-cy="layout-dropdown-user" style={{ whiteSpace: "nowrap" }} > - - {(data.currentUser.name && data.currentUser.name[0]) || "?"} - + {data.currentUser.avatarUrl ? ( + + ) : ( + + {(data.currentUser.name && data.currentUser.name[0]) || + "?"} + + )} {data.currentUser.name} From 40c4ffb371d26d10d8e27abdf34e38257b1b256f Mon Sep 17 00:00:00 2001 From: Makon Cline Date: Wed, 22 Jan 2020 19:20:58 -0600 Subject: [PATCH 09/34] yarn lock change --- yarn.lock | 35 ++++++++++++++++++++++++++++++++--- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index c04a2d1e..6ec91a84 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2521,6 +2521,13 @@ "@types/keygrip" "*" "@types/node" "*" +"@types/cors@^2.8.6": + version "2.8.6" + resolved "https://registry.yarnpkg.com/@types/cors/-/cors-2.8.6.tgz#cfaab33c49c15b1ded32f235111ce9123009bd02" + integrity sha512-invOmosX0DqbpA+cE2yoHGUlF/blyf7nB0OGYBBiH27crcVm5NmFaZkLP4Ta1hGaesckCi5lVLlydNJCxkTOSg== + dependencies: + "@types/express" "*" + "@types/debounce@1.2.0": version "1.2.0" resolved "https://registry.yarnpkg.com/@types/debounce/-/debounce-1.2.0.tgz#9ee99259f41018c640b3929e1bb32c3dcecdb192" @@ -3761,6 +3768,13 @@ aws4@^1.8.0: resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.9.0.tgz#24390e6ad61386b0a747265754d2a17219de862c" integrity sha512-Uvq6hVe90D0B2WEnUqtdgY1bATGz3mw33nH9Y+dmA+w5DHvUmBgkr5rM/KCHpCsiFNRUfokW/szpPPgMK2hm4A== +axios@^0.19.1: + version "0.19.1" + resolved "https://registry.yarnpkg.com/axios/-/axios-0.19.1.tgz#8a6a04eed23dfe72747e1dd43c604b8f1677b5aa" + integrity sha512-Yl+7nfreYKaLRvAvjNPkvfjnQHJM1yLBY3zhqAwcJSwR/6ETkanUgylgtIvkvz0xJ+p/vZuNw8X7Hnb7Whsbpw== + dependencies: + follow-redirects "1.5.10" + babel-code-frame@^6.22.0, babel-code-frame@^6.26.0: version "6.26.0" resolved "https://registry.yarnpkg.com/babel-code-frame/-/babel-code-frame-6.26.0.tgz#63fd43f7dc1e3bb7ce35947db8fe369a3f58c74b" @@ -5127,6 +5141,14 @@ core_d@^1.0.1: dependencies: supports-color "^5.5.0" +cors@^2.8.5: + version "2.8.5" + resolved "https://registry.yarnpkg.com/cors/-/cors-2.8.5.tgz#eac11da51592dd86b9f06f6e7ac293b3df875d29" + integrity sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g== + dependencies: + object-assign "^4" + vary "^1" + cosmiconfig@5.2.1, cosmiconfig@^5.0.0, cosmiconfig@^5.1.0: version "5.2.1" resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-5.2.1.tgz#040f726809c591e77a17c0a3626ca45b4f168b1a" @@ -5524,7 +5546,7 @@ debug@2.6.9, debug@^2.2.0, debug@^2.3.3: dependencies: ms "2.0.0" -debug@3.1.0: +debug@3.1.0, debug@=3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/debug/-/debug-3.1.0.tgz#5bb5a0672628b64149566ba16819e61518c67261" integrity sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g== @@ -6881,6 +6903,13 @@ flush-write-stream@^1.0.0: inherits "^2.0.3" readable-stream "^2.3.6" +follow-redirects@1.5.10: + version "1.5.10" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.5.10.tgz#7b7a9f9aea2fdff36786a94ff643ed07f4ff5e2a" + integrity sha512-0V5l4Cizzvqt5D44aTXbFZz+FtyXV1vrDN6qrelxtfYQKW0KO0W2T/hkE8xvGa/540LkZlkaUjO4ailYTFtHVQ== + dependencies: + debug "=3.1.0" + for-in@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80" @@ -10975,7 +11004,7 @@ oauth@0.9.x: resolved "https://registry.yarnpkg.com/oauth/-/oauth-0.9.15.tgz#bd1fefaf686c96b75475aed5196412ff60cfb9c1" integrity sha1-vR/vr2hslrdUda7VGWQS/2DPucE= -object-assign@4.1.1, object-assign@4.x, object-assign@^4.0.1, object-assign@^4.1.0, object-assign@^4.1.1: +object-assign@4.1.1, object-assign@4.x, object-assign@^4, object-assign@^4.0.1, object-assign@^4.1.0, object-assign@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" integrity sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM= @@ -15181,7 +15210,7 @@ validate-npm-package-name@^3.0.0: dependencies: builtins "^1.0.3" -vary@~1.1.2: +vary@^1, vary@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" integrity sha1-IpnwLG3tMNSllhsLn3RSShj2NPw= From 02ffa1904ec95798e368169f3ff99b476d674942 Mon Sep 17 00:00:00 2001 From: Makon Cline Date: Wed, 22 Jan 2020 19:21:34 -0600 Subject: [PATCH 10/34] add avatar upload to settings page --- @app/client/src/pages/settings/index.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/@app/client/src/pages/settings/index.tsx b/@app/client/src/pages/settings/index.tsx index d32793f7..3c9f584f 100644 --- a/@app/client/src/pages/settings/index.tsx +++ b/@app/client/src/pages/settings/index.tsx @@ -12,7 +12,7 @@ import { ApolloError } from "apollo-client"; import { FormComponentProps, ValidateFieldsOptions } from "antd/lib/form/Form"; import { getCodeFromError, extractError } from "../../errors"; import { formItemLayout, tailFormItemLayout } from "../../forms"; -import { Redirect, ErrorAlert, H3 } from "@app/components"; +import { Redirect, ErrorAlert, H3, AvatarUpload } from "@app/components"; const Settings_Profile: NextPage = () => { const [formError, setFormError] = useState(null); @@ -113,6 +113,7 @@ function ProfileSettingsForm({ return (

Edit Profile

+
{getFieldDecorator("name", { From 882ab585454e45b51c56005aa2549597ed77121e Mon Sep 17 00:00:00 2001 From: Makon Cline Date: Wed, 22 Jan 2020 19:30:28 -0600 Subject: [PATCH 11/34] add labels to error, success, and avatar form item --- @app/client/src/pages/settings/index.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/@app/client/src/pages/settings/index.tsx b/@app/client/src/pages/settings/index.tsx index 3c9f584f..321064f4 100644 --- a/@app/client/src/pages/settings/index.tsx +++ b/@app/client/src/pages/settings/index.tsx @@ -113,8 +113,14 @@ function ProfileSettingsForm({ return (

Edit Profile

- + + + {getFieldDecorator("name", { initialValue: user.name, @@ -138,7 +144,7 @@ function ProfileSettingsForm({ })()} {error ? ( - + ) : success ? ( - + ) : null} From 594364c62dd7e74816475f7d371450f9d271f345 Mon Sep 17 00:00:00 2001 From: Makon Date: Wed, 22 Jan 2020 19:59:36 -0600 Subject: [PATCH 12/34] replace readme with instructions --- README.md | 470 ++++++------------------------------------------------ 1 file changed, 50 insertions(+), 420 deletions(-) diff --git a/README.md b/README.md index d99d3dd1..7191afb4 100644 --- a/README.md +++ b/README.md @@ -1,439 +1,69 @@ -# Graphile Starter +# Graphile Starter - Avatar Photo Upload -## Purpose +To use -Graphile Starter is an opinionated quick-start project for full-stack -application development in React, Node.js, GraphQL and PostgreSQL. It includes -the foundations of a modern web application, with a full user registration -system, session management, optimised job queue, a significant amount of -pre-configured tooling, tests (both end-to-end and more granular) and much more. - -It is suitable for building projects both large and small, with a focus on -productivity. You might use it: - -- to go from conception to launch of a web app during a hack day -- as the foundation for client projects at your web agency -- to build your side-hustle without spending lots of time on boilerplate -- to build a SaaS project to help fund your open source work - -However you use it, the project can be deployed to many platforms, and can be -scaled to larger audiences both horizontally and vertically with very few -changes. - -**Not intended for beginners**: this project combines a lot of technologies -together to produce a highly productive development environment, and includes a -pre-built database schema for an advanced account system. If you're not already -familiar with a lot of these technologies, or haven't built a database-driven -project before, you may find that there's too many things to get your head -around at once. For beginners, we recommend you start with the -[PostGraphile schema design tutorial](https://www.graphile.org/postgraphile/postgresql-schema-design/). - -Please note that this software is not "complete," free of software defects, or -free of security issues — it is not a "finished" solution, but rather the seed -of a solution which you should review, customize, fix, and develop further. - -It is intended that you use a "point in time" version of this software ─ it is -not intended that you can merge updates to this software into your own -derivative in an automated fashion. - - - -## Crowd-funded open-source software - -**PLEASE DONATE.** - -Take this software and use it as the starting point to build your project. Go -make some money, and [give something back](https://graphile.org/sponsor/) to -support us building more tools and kits for the Node, GraphQL and PostgreSQL -ecosystems. We have made this project available under the simple and liberal MIT -license to give you to a huge amount of freedom in how you use it, but this -isn't possible without the help of our wonderful sponsors. - -We need more people to join our sponsors so we can continue to bring about -awesome projects like this. We'd love to spend more time on open source, -building tools that will save you and others even more time and money ─ please -sponsor our open source efforts: - -### [Click here to find out more about sponsors and sponsorship.](https://www.graphile.org/sponsor/) - -And please give some love to our featured sponsors 🤩: - - - - - -
Chad Furman
Chad Furman
Storyscript
Storyscript
Point72 Ventures
Point72 Ventures
- - - -## Table of contents - -- [Features](#features) -- [Variants](#variants) -- [Prerequisites](#prerequisites) -- [Getting Started](#getting-started) -- [Running](#running) -- [Making it yours](#making-it-yours) -- [Docker development](#docker-development-1) -- [Production build](#production-build-for-local-mode) -- [Deploying to Heroku](#deploying-to-heroku) -- [License](#mit-license) - -## Features - -Graphile Starter is a full-stack [GraphQL](https://graphql.org/learn/) and -[React](https://reactjs.org/) project, with server-side rendering (SSR) and -routing thanks to [Next.js](https://nextjs.org/). The backend is a beautiful -pairing of Node.js and PostgreSQL running on Express.js, enabled by -[PostGraphile](https://www.graphile.org/postgraphile/) in library mode. The -frontend uses the [AntD](https://ant.design/) design framework to accelerate -development. The entire stack is written in TypeScript, with autogenerated -GraphQL types and operations thanks to -[graphql-code-generator](https://github.com/dotansimha/graphql-code-generator). - -There are four tenets to Graphile Starter: - -- Speedy development -- Batteries included -- Type safety -- Best practices - -Graphile Starter is easy to start and everything is preconfigured as much as -possible. - -**Speedy development**: hot reloading, easy debugging, Graphile's -[idempotent migration system](https://github.com/graphile/migrate), -[job queue](/TECHNICAL_DECISIONS.md#job-queue) and server middleware ready to -use; not to mention deep integration with VSCode should you use that editor: -plugin recommendations, preconfigured settings, ESLint and Prettier integration -and debugging profiles - -**Batteries included**: full user system and OAuth, AntD design framework, jest -and [Cypress end-to-end](/TECHNICAL_DECISIONS.md#cypress-e2e-tests) testing, -security, email templating and transport, pre-configured linting and code -formatting, deployment instructions, and more - -**Type safety**: pre-configured type checking, strongly typed throughout with -TypeScript - -**Best practices**: React, GraphQL, PostGraphile, Node, jest and Cypress best -practices - -See [TECHNICAL_DECISIONS.md](TECHNICAL_DECISIONS.md) for a more detailed list of -features included and the technical decisions behind them. - -## Variants - -Since this is a highly opinionated starter; community members may have slightly -different opinions and may choose to maintain forks of this project that apply -their own opinions. A few of these are listed below; if you maintain a fork of -this project please make a note at the top of your own README, and add it to -this list: - -- [Nuxt.js variant](https://github.com/JoeSchr/graphile-starter--private) - - replaces Next.js for Vue users -- [Create React App variant](https://github.com/alexk111/graphile-starter-cra) - - replaces Next.js for apps without Server Side Rendering - -## Prerequisites - -You can either work with this project locally (directly on your machine) or use -a pre-configured Docker environment. We'll differentiate this in the README with -a table like this one: - -| Local mode | OR | Docker mode | -| ------------------------------- | :-: | ---------------------------------------- | -| _command for local development_ | or | _command for docker-compose development_ | - -**Be careful not to mix and match Docker-mode vs local-mode for development.** -You should make a choice and stick to it. (Developing locally but deploying with -`production.Docker` is absolutely fine.) - -**IMPORTANT**: If you choose the Docker mode, be sure to read -[docker/README.md](docker/README.md). - -For users of Visual Studio Code (VSCode), a `.vscode` folder is included with -editor settings and debugger settings provided, plus a list of recommended -extensions. Should you need it, there is also a `.devcontainer` folder which -enables you to use -[VSCode's remote containers](https://code.visualstudio.com/docs/remote/containers) -giving you a local-like development experience whilst still using docker -containers. - -### Local development - -Requires: - -- Node.js v10+ must be installed (v12 recommended) -- PostgreSQL v10+ server must be available -- `pg_dump` command must be available (or you can remove this functionality) -- VSCode is recommended, but any editor will do - -This software has been developed under Mac and Linux, and should work in a -`bash` environment. - -**Windows users**: making a project like Graphile Starter run smoothly on -Windows can be a challenge; `@JoeSchr` and `@hips` on the -[Graphile Discord](http://discord.gg/graphile) have been working in improving -this and they're pretty pleased with the result, but you may still get some -teething problems. PRs to fix Windows compatibility issues are welcome (please -keep them small!) Failing that, try the Docker mode :) - -### Docker development - -Requires: - -- [`docker`](https://docs.docker.com/install/) -- [`docker-compose`](https://docs.docker.com/compose/install/) -- Ensure you've allocated Docker **at least** 4GB of RAM; significantly more - recommended - - (Development only, production is much more efficient) - -Has been tested on Windows and Linux (Ubuntu 18.04LTS). - -## Getting started - -This project is designed to work with `yarn`. If you don't have `yarn` -installed, you can install it with `npm install -g yarn`. The Docker setup -already has `yarn` & `npm` installed and configured. - -To get started, please run: - -| Local mode | OR | Docker mode | -| ------------ | :-: | ------------------------------- | -| `yarn setup` | or | `export UID; yarn docker setup` | - -This command will lead you through the necessary steps, and create a `.env` file -for you containing your secrets. - -**NOTE:** `export UID` is really important on Linux Docker hosts, otherwise the -files and folders created by Docker will end up owned by root, which is -non-optimal. We recommend adding `export UID` to your `~/.profile` or -`~/.bashrc` or similar so you don't have to remember it. - -**Do not commit `.env` to version control!** - -## Running - -You can bring up the stack with: - -| Local mode | OR | Docker mode | -| ------------ | :-: | ------------------------------- | -| `yarn start` | or | `export UID; yarn docker start` | - -After a short period you should be able to load the application at -http://localhost:5678 - -This main command runs a number of tasks: - -- uses [`graphile-migrate`](https://github.com/graphile/migrate) to watch - the`migrations/current.sql` file for changes, and automatically runs it - against your database when it changes -- watches the TypeScript source code of the server, and compiles it from - `@app/*/src` to `@app/*/dist` so node/`graphile-worker`/etc. can run the - compiled code directly -- runs the node server (includes PostGraphile and Next.js middleware) -- runs `graphile-worker` to execute your tasks (e.g. sending emails) -- watches your GraphQL files and your PostGraphile schema for changes and - generates your TypeScript React hooks for you automatically, leading to - strongly typed code with minimal effort -- runs the `jest` tests in watch mode, automatically re-running as the database - or test files change - -**NOTE**: `docker-compose up server` also runs the PostgreSQL server that the -system connects to. - -You may also choose to develop locally, but use the PostgreSQL server via -`docker-compose up -d db`. - -Then for development you may need a console; you can open one with: - -| Local mode | OR | Docker mode | -| ---------- | :-: | ------------------------------ | -| `bash` | or | `export UID; yarn docker bash` | - -To shut everything down: - -| Local mode | OR | Docker mode | -| ---------- | :-: | ------------------------------ | -| Ctrl-c | or | `export UID; yarn docker down` | - -## Making it yours - -1. Download and extract a zip of - [the latest release from GitHub](https://github.com/graphile/starter/releases) -1. In that folder run: - - `git init` - - `git add .` - - `git commit -m "Graphile Starter base"` -1. Change the project name in `package.json` -1. Change the project settings in `@app/config/src/index.ts` -1. Replace the `README.md` file -1. Add your own copyright notices to the `LICENSE.md` file -1. Commit as you usually would -1. [Show your appreciation with sponsorship](https://www.graphile.org/sponsor/) - -## Docker development - -Be sure to read [docker/README.md](docker/README.md). - -## Building the production docker image - -To build the production image, use `docker build` as shown below. You should -supply the `ROOT_URL` build variable (which will be baked into the client code, -so cannot be changed as envvars); if you don't then the defaults will apply -(which likely will not be suitable). - -To build the worker, pass `TARGET="worker"` instead of the default -`TARGET="server"`. - -```sh -docker build \ - --file production.Dockerfile \ - --build-arg ROOT_URL="http://localhost:5678" \ - --build-arg TARGET="server" \ - . -``` - -When you run the image you must pass it the relevant environmental variables, -for example: - -```sh -docker run --rm -it --init -p 5678:5678 \ - -e GRAPHILE_LICENSE="$GRAPHILE_LICENSE" \ - -e SECRET="$SECRET" \ - -e JWT_SECRET="$JWT_SECRET" \ - -e DATABASE_VISITOR="$DATABASE_VISITOR" \ - -e DATABASE_URL="$DATABASE_URL" \ - -e AUTH_DATABASE_URL="$AUTH_DATABASE_URL" \ - -e GITHUB_KEY="$GITHUB_KEY" \ - -e GITHUB_SECRET="$GITHUB_SECRET" \ - docker-image-id-here -``` - -Currently if you miss required envvars weird things will happen; we don't -currently have environment validation (PRs welcome!). - -## Production build for local mode - -Use `yarn run build` to generate a production build of the project - -## Deploying to Heroku - -If you are using `graphile-migrate` make sure that you have executed -`graphile-migrate commit` to commit all your database changes, since we only run -committed migrations in production. - -Make sure you have customized `@app/config/src/index.ts`. - -Make sure everything is committed and pushed in git. - -Set up a database server; we recommend using Amazon RDS. - -Once your database server is running, you can use our `heroku-setup` script to -automate the setup process. This script does the following: - -- Creates the Heroku app -- Adds the redis extension to this Heroku app -- Creates the database in the database server -- Creates the relevant roles, generating random passwords for them -- Installs some common database extensions -- Sets the Heroku config variables -- Adds the Heroku app as a git remote named 'Heroku' -- Pushes the 'master' branch to Heroku to perform your initial build - -Copy `heroku-setup.template` to `heroku-setup`, then edit it and customize the -settings at the top. We also recommend reading through the script and -customizing it as you see fit - particularly if you are using additional -extensions that need installing. - -Now run the script: +Create an S3 bucket +in permissions -> block public access, uncheck +- Block all public access +- Block public access to buckets and objects granted through new public bucket or access point policies +- Block public and cross-account access to buckets and objects through any public bucket or access point policies +in permissions -> bucket policy, add +(This allows the public to get all the files in the bucket, so make sure thats what you want.) +Replace BUCKET_NAME with your own ``` -bash heroku-setup +{ + "Version": "2008-10-17", + "Statement": [ + { + "Sid": "AllowPublicRead", + "Effect": "Allow", + "Principal": { + "AWS": "*" + }, + "Action": "s3:GetObject", + "Resource": "arn:aws:s3:::BUCKET_NAME/*" + } + ] +} ``` - -Hopefully all has gone well. If not, step through the remaining tasks in the -Heroku-setup script and fix each task as you go. We've designed the script so -that if your superuser credentials are wrong, or the Heroku app already exists, -you can just edit the settings and try again. All other errors will probably -need manual intervention. Verbosity is high so you can track exactly what -happened. - -The server should be up and running now (be sure to access it over HTTPS -otherwise you will not be able to run GraphQL queries), but it is not yet -capable of sending emails. To achieve this, you must configure an email -transport. We have preconfigured support for Amazon SES. Once SES is set up, -your domain is verified, and you've verified any emails you wish to send email -to (or have had your sending limits removed), make sure that the `fromEmail` in -`@app/config/src/index.ts` is correct, and then create an IAM role for your -PostGraphile server. Here's an IAM template for sending emails - this is the -only permission required for our IAM role currently, but you may wish to add -others later. - +Go to AWS IAM Managment Console +Add user +Programmatic access +next +Attach existing policies directly +Create Policy +Json +Paste the following policy +Replace BUCKET_NAME with your own ``` { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", - "Action": "ses:SendRawEmail", - "Resource": "*" + "Action": [ + "s3:*" + ], + "Resource": [ + "arn:aws:s3:::BUCKET_NAME/*" + ] } ] } ``` +name the policy +create policy +return to IAM Managment Console +refresh and add the new policy to the new user +finish creating user +save the Access key ID and Secret access key -Generate an Access Key for this IAM role, and then tell Heroku the access key id -and secret: +add AWS S3 bucket config to .env ``` -heroku config:set AWS_ACCESS_KEY_ID="..." AWS_SECRET_ACCESS_KEY="..." -a $APP_NAME +BUCKET=XXX +AWSACCESSKEYID=XXX +AWSSECRETKEY=XXX +AWS_REGION=XXX ``` - -Now you can tell Heroku to run the worker process as well as the currently -running 'web' process: - -``` -heroku ps:scale worker=1 -a $APP_NAME -``` - -When you register an account on the server you should receive a verification -email containing a clickable link. When you click the link your email will be -verified and thanks to GraphQL subscriptions the previous tab should be updated -to reflect that your account is now verified. - -**Remember** the first account registered will be an admin account, so be sure -to register promptly. - -You can also configure your application for social login. This works the same as -in development except the callback URL will be different, something like -`https://MY_HEROKU_APP_NAME.herokuapp.com/auth/github/callback`. Set the GitHub -OAuth secrets on your Heroku app to trigger a restart and enable social login: - -``` -heroku config:set GITHUB_KEY="..." GITHUB_SECRET="..." -a $APP_NAME -``` - -## Cleanup - -To delete the Heroku app: - -``` -heroku apps:destroy -a $APP_NAME -``` - -To delete the database/roles (replace `dbname` with your database name): - -``` -drop database dbname; -drop role dbname_visitor; -drop role dbname_authenticator; -drop role dbname; -``` - -## MIT License - -This is open source software; you may use, modify and distribute it under the -terms of the MIT License, see -[GRAPHILE_STARTER_LICENSE.md](./GRAPHILE_STARTER_LICENSE.md). From 84e304f5c3e4244217f3a0e14e6f3badf15c0549 Mon Sep 17 00:00:00 2001 From: Makon Date: Mon, 27 Jan 2020 11:41:12 -0600 Subject: [PATCH 13/34] add @app/graphql --- @app/components/package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/@app/components/package.json b/@app/components/package.json index f38657be..b91aae21 100644 --- a/@app/components/package.json +++ b/@app/components/package.json @@ -10,6 +10,7 @@ "apollo-client": "^2.6.8", "next": "^9.2.0", "react": "^16.9.0", - "tslib": "^1.10.0" + "tslib": "^1.10.0", + "@app/graphql": "0.0.0" } } From 1bb7b59d4b8bd156a075ade9de322ae54ca982b6 Mon Sep 17 00:00:00 2001 From: David Baumgold Date: Wed, 12 Feb 2020 13:25:22 +0100 Subject: [PATCH 14/34] Extend schema to allow for presigned URLs for uploading files --- @app/config/src/index.ts | 1 + @app/server/package.json | 4 +- .../src/middleware/installPostGraphile.ts | 6 +- .../src/plugins/CreateUploadUrlPlugin.ts | 37 + data/schema.graphql | 764 +++++++++++++----- yarn.lock | 18 + 6 files changed, 637 insertions(+), 193 deletions(-) create mode 100644 @app/server/src/plugins/CreateUploadUrlPlugin.ts diff --git a/@app/config/src/index.ts b/@app/config/src/index.ts index e6a6ee3f..d0bc389a 100644 --- a/@app/config/src/index.ts +++ b/@app/config/src/index.ts @@ -6,6 +6,7 @@ const packageJson = require("../../../package.json"); export const fromEmail = '"PostGraphile Starter" '; export const awsRegion = "us-east-1"; +export const uploadBucket = "my-bucket-name-here"; export const projectName = packageJson.name.replace(/[-_]/g, " "); export const companyName = projectName; // For copyright ownership export const emailLegalText = diff --git a/@app/server/package.json b/@app/server/package.json index 9a2c5f92..bbc84e51 100644 --- a/@app/server/package.json +++ b/@app/server/package.json @@ -23,6 +23,7 @@ "@types/passport-github2": "^1.2.4", "@types/pg": "^7.14.1", "@types/redis": "^2.8.18", + "@types/uuid": "^7.0.2", "body-parser": "^1.19.0", "chalk": "^4.0.0", "connect-pg-simple": "^6.1.0", @@ -43,7 +44,8 @@ "postgraphile": "^4.7.0", "redis": "^3.0.2", "source-map-support": "^0.5.13", - "tslib": "^1.11.1" + "tslib": "^1.11.1", + "uuid": "^7.0.3" }, "devDependencies": { "@types/node": "^13.13.4", diff --git a/@app/server/src/middleware/installPostGraphile.ts b/@app/server/src/middleware/installPostGraphile.ts index 705d899a..915b43ff 100644 --- a/@app/server/src/middleware/installPostGraphile.ts +++ b/@app/server/src/middleware/installPostGraphile.ts @@ -20,6 +20,7 @@ import PassportLoginPlugin from "../plugins/PassportLoginPlugin"; import PrimaryKeyMutationsOnlyPlugin from "../plugins/PrimaryKeyMutationsOnlyPlugin"; import RemoveQueryQueryPlugin from "../plugins/RemoveQueryQueryPlugin"; import SubscriptionsPlugin from "../plugins/SubscriptionsPlugin"; +import CreateUploadUrlPlugin from "../plugins/CreateUploadUrlPlugin"; import handleErrors from "../utils/handleErrors"; import { getAuthPgPool, getRootPgPool } from "./installDatabasePools"; @@ -183,6 +184,9 @@ export function getPostGraphileOptions({ // Adds custom orders to our GraphQL schema OrdersPlugin, + + // Allows API clients to fetch a pre-signed URL for uploading files + CreateUploadUrlPlugin, ], /* @@ -258,7 +262,7 @@ export function getPostGraphileOptions({ // Use this to tell Passport.js we're logged in login: (user: any) => new Promise((resolve, reject) => { - req.login(user, (err) => (err ? reject(err) : resolve())); + req.login(user, err => (err ? reject(err) : resolve())); }), logout: () => { diff --git a/@app/server/src/plugins/CreateUploadUrlPlugin.ts b/@app/server/src/plugins/CreateUploadUrlPlugin.ts new file mode 100644 index 00000000..3247fdf0 --- /dev/null +++ b/@app/server/src/plugins/CreateUploadUrlPlugin.ts @@ -0,0 +1,37 @@ +import { awsRegion, uploadBucket } from "@app/config"; +import * as aws from "aws-sdk"; +import { gql, makeExtendSchemaPlugin } from "graphile-utils"; +import uuidv4 from "uuid/v4"; + +const CreateUploadUrlPlugin = makeExtendSchemaPlugin(() => ({ + typeDefs: gql` + extend type Mutation { + """ + Get a signed URL for uploading files. It will expire in 60 seconds. + """ + createUploadUrl(contentType: String): String! + } + `, + resolvers: { + Mutation: { + async createUploadUrl(_query, args, _context, _resolveInfo) { + const { contentType } = args; + const s3 = new aws.S3({ + region: awsRegion, + signatureVersion: "v4", + }); + const params = { + Bucket: uploadBucket, + ContentType: contentType, + Key: uuidv4(), // randomly generated file name + Expires: 60, // signed URL will expire in 60 seconds + ACL: "public-read", // uploaded file will be publicly readable + }; + const signedUrl = await s3.getSignedUrlPromise("putObject", params); + return signedUrl; + }, + }, + }, +})); + +export default CreateUploadUrlPlugin; diff --git a/data/schema.graphql b/data/schema.graphql index 1619f4ff..9e0c2015 100644 --- a/data/schema.graphql +++ b/data/schema.graphql @@ -1,4 +1,6 @@ -"""All input for the `acceptInvitationToOrganization` mutation.""" +""" +All input for the `acceptInvitationToOrganization` mutation. +""" input AcceptInvitationToOrganizationInput { """ An arbitrary string value with no semantic meaning. Will be included in the @@ -9,7 +11,9 @@ input AcceptInvitationToOrganizationInput { invitationId: UUID! } -"""The output of our `acceptInvitationToOrganization` mutation.""" +""" +The output of our `acceptInvitationToOrganization` mutation. +""" type AcceptInvitationToOrganizationPayload { """ The exact same `clientMutationId` that was provided in the mutation input, @@ -23,7 +27,9 @@ type AcceptInvitationToOrganizationPayload { query: Query } -"""All input for the `changePassword` mutation.""" +""" +All input for the `changePassword` mutation. +""" input ChangePasswordInput { """ An arbitrary string value with no semantic meaning. Will be included in the @@ -34,7 +40,9 @@ input ChangePasswordInput { oldPassword: String! } -"""The output of our `changePassword` mutation.""" +""" +The output of our `changePassword` mutation. +""" type ChangePasswordPayload { """ The exact same `clientMutationId` that was provided in the mutation input, @@ -49,7 +57,9 @@ type ChangePasswordPayload { success: Boolean } -"""All input for the `confirmAccountDeletion` mutation.""" +""" +All input for the `confirmAccountDeletion` mutation. +""" input ConfirmAccountDeletionInput { """ An arbitrary string value with no semantic meaning. Will be included in the @@ -59,7 +69,9 @@ input ConfirmAccountDeletionInput { token: String! } -"""The output of our `confirmAccountDeletion` mutation.""" +""" +The output of our `confirmAccountDeletion` mutation. +""" type ConfirmAccountDeletionPayload { """ The exact same `clientMutationId` that was provided in the mutation input, @@ -74,7 +86,9 @@ type ConfirmAccountDeletionPayload { success: Boolean } -"""All input for the `createOrganization` mutation.""" +""" +All input for the `createOrganization` mutation. +""" input CreateOrganizationInput { """ An arbitrary string value with no semantic meaning. Will be included in the @@ -85,7 +99,9 @@ input CreateOrganizationInput { slug: String! } -"""The output of our `createOrganization` mutation.""" +""" +The output of our `createOrganization` mutation. +""" type CreateOrganizationPayload { """ The exact same `clientMutationId` that was provided in the mutation input, @@ -94,9 +110,13 @@ type CreateOrganizationPayload { clientMutationId: String organization: Organization - """An edge for our `Organization`. May be used by Relay 1.""" + """ + An edge for our `Organization`. May be used by Relay 1. + """ organizationEdge( - """The method to use when ordering `Organization`.""" + """ + The method to use when ordering `Organization`. + """ orderBy: [OrganizationsOrderBy!] = [PRIMARY_KEY_ASC] ): OrganizationsEdge @@ -106,7 +126,9 @@ type CreateOrganizationPayload { query: Query } -"""All input for the create `UserEmail` mutation.""" +""" +All input for the create `UserEmail` mutation. +""" input CreateUserEmailInput { """ An arbitrary string value with no semantic meaning. Will be included in the @@ -114,11 +136,15 @@ input CreateUserEmailInput { """ clientMutationId: String - """The `UserEmail` to be created by this mutation.""" + """ + The `UserEmail` to be created by this mutation. + """ userEmail: UserEmailInput! } -"""The output of our create `UserEmail` mutation.""" +""" +The output of our create `UserEmail` mutation. +""" type CreateUserEmailPayload { """ The exact same `clientMutationId` that was provided in the mutation input, @@ -131,20 +157,30 @@ type CreateUserEmailPayload { """ query: Query - """Reads a single `User` that is related to this `UserEmail`.""" + """ + Reads a single `User` that is related to this `UserEmail`. + """ user: User - """The `UserEmail` that was created by this mutation.""" + """ + The `UserEmail` that was created by this mutation. + """ userEmail: UserEmail - """An edge for our `UserEmail`. May be used by Relay 1.""" + """ + An edge for our `UserEmail`. May be used by Relay 1. + """ userEmailEdge( - """The method to use when ordering `UserEmail`.""" + """ + The method to use when ordering `UserEmail`. + """ orderBy: [UserEmailsOrderBy!] = [PRIMARY_KEY_ASC] ): UserEmailsEdge } -"""A location in a connection that can be used for resuming pagination.""" +""" +A location in a connection that can be used for resuming pagination. +""" scalar Cursor """ @@ -153,7 +189,9 @@ A point in time as described by the [ISO """ scalar Datetime -"""All input for the `deleteOrganization` mutation.""" +""" +All input for the `deleteOrganization` mutation. +""" input DeleteOrganizationInput { """ An arbitrary string value with no semantic meaning. Will be included in the @@ -163,7 +201,9 @@ input DeleteOrganizationInput { organizationId: UUID! } -"""The output of our `deleteOrganization` mutation.""" +""" +The output of our `deleteOrganization` mutation. +""" type DeleteOrganizationPayload { """ The exact same `clientMutationId` that was provided in the mutation input, @@ -177,7 +217,9 @@ type DeleteOrganizationPayload { query: Query } -"""All input for the `deleteUserAuthentication` mutation.""" +""" +All input for the `deleteUserAuthentication` mutation. +""" input DeleteUserAuthenticationInput { """ An arbitrary string value with no semantic meaning. Will be included in the @@ -187,7 +229,9 @@ input DeleteUserAuthenticationInput { id: UUID! } -"""The output of our delete `UserAuthentication` mutation.""" +""" +The output of our delete `UserAuthentication` mutation. +""" type DeleteUserAuthenticationPayload { """ The exact same `clientMutationId` that was provided in the mutation input, @@ -201,14 +245,20 @@ type DeleteUserAuthenticationPayload { """ query: Query - """Reads a single `User` that is related to this `UserAuthentication`.""" + """ + Reads a single `User` that is related to this `UserAuthentication`. + """ user: User - """The `UserAuthentication` that was deleted by this mutation.""" + """ + The `UserAuthentication` that was deleted by this mutation. + """ userAuthentication: UserAuthentication } -"""All input for the `deleteUserEmail` mutation.""" +""" +All input for the `deleteUserEmail` mutation. +""" input DeleteUserEmailInput { """ An arbitrary string value with no semantic meaning. Will be included in the @@ -218,7 +268,9 @@ input DeleteUserEmailInput { id: UUID! } -"""The output of our delete `UserEmail` mutation.""" +""" +The output of our delete `UserEmail` mutation. +""" type DeleteUserEmailPayload { """ The exact same `clientMutationId` that was provided in the mutation input, @@ -232,20 +284,30 @@ type DeleteUserEmailPayload { """ query: Query - """Reads a single `User` that is related to this `UserEmail`.""" + """ + Reads a single `User` that is related to this `UserEmail`. + """ user: User - """The `UserEmail` that was deleted by this mutation.""" + """ + The `UserEmail` that was deleted by this mutation. + """ userEmail: UserEmail - """An edge for our `UserEmail`. May be used by Relay 1.""" + """ + An edge for our `UserEmail`. May be used by Relay 1. + """ userEmailEdge( - """The method to use when ordering `UserEmail`.""" + """ + The method to use when ordering `UserEmail`. + """ orderBy: [UserEmailsOrderBy!] = [PRIMARY_KEY_ASC] ): UserEmailsEdge } -"""All input for the `forgotPassword` mutation.""" +""" +All input for the `forgotPassword` mutation. +""" input ForgotPasswordInput { """ An arbitrary string value with no semantic meaning. Will be included in the @@ -255,7 +317,9 @@ input ForgotPasswordInput { email: String! } -"""The output of our `forgotPassword` mutation.""" +""" +The output of our `forgotPassword` mutation. +""" type ForgotPasswordPayload { """ The exact same `clientMutationId` that was provided in the mutation input, @@ -269,7 +333,9 @@ type ForgotPasswordPayload { query: Query } -"""All input for the `inviteToOrganization` mutation.""" +""" +All input for the `inviteToOrganization` mutation. +""" input InviteToOrganizationInput { """ An arbitrary string value with no semantic meaning. Will be included in the @@ -281,7 +347,9 @@ input InviteToOrganizationInput { username: String } -"""The output of our `inviteToOrganization` mutation.""" +""" +The output of our `inviteToOrganization` mutation. +""" type InviteToOrganizationPayload { """ The exact same `clientMutationId` that was provided in the mutation input, @@ -308,7 +376,9 @@ type LogoutPayload { success: Boolean } -"""All input for the `makeEmailPrimary` mutation.""" +""" +All input for the `makeEmailPrimary` mutation. +""" input MakeEmailPrimaryInput { """ An arbitrary string value with no semantic meaning. Will be included in the @@ -318,7 +388,9 @@ input MakeEmailPrimaryInput { emailId: UUID! } -"""The output of our `makeEmailPrimary` mutation.""" +""" +The output of our `makeEmailPrimary` mutation. +""" type MakeEmailPrimaryPayload { """ The exact same `clientMutationId` that was provided in the mutation input, @@ -331,13 +403,19 @@ type MakeEmailPrimaryPayload { """ query: Query - """Reads a single `User` that is related to this `UserEmail`.""" + """ + Reads a single `User` that is related to this `UserEmail`. + """ user: User userEmail: UserEmail - """An edge for our `UserEmail`. May be used by Relay 1.""" + """ + An edge for our `UserEmail`. May be used by Relay 1. + """ userEmailEdge( - """The method to use when ordering `UserEmail`.""" + """ + The method to use when ordering `UserEmail`. + """ orderBy: [UserEmailsOrderBy!] = [PRIMARY_KEY_ASC] ): UserEmailsEdge } @@ -358,7 +436,9 @@ type Mutation { input: AcceptInvitationToOrganizationInput! ): AcceptInvitationToOrganizationPayload - """Enter your old password and a new password to change your password.""" + """ + Enter your old password and a new password to change your password. + """ changePassword( """ The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields. @@ -391,7 +471,9 @@ type Mutation { input: CreateOrganizationInput! ): CreateOrganizationPayload - """Creates a single `UserEmail`.""" + """ + Creates a single `UserEmail`. + """ createUserEmail( """ The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields. @@ -409,7 +491,9 @@ type Mutation { input: DeleteOrganizationInput! ): DeleteOrganizationPayload - """Deletes a single `UserAuthentication` using a unique key.""" + """ + Deletes a single `UserAuthentication` using a unique key. + """ deleteUserAuthentication( """ The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields. @@ -417,7 +501,9 @@ type Mutation { input: DeleteUserAuthenticationInput! ): DeleteUserAuthenticationPayload - """Deletes a single `UserEmail` using a unique key.""" + """ + Deletes a single `UserEmail` using a unique key. + """ deleteUserEmail( """ The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields. @@ -497,7 +583,9 @@ type Mutation { input: RemoveFromOrganizationInput! ): RemoveFromOrganizationPayload - """Begin the account deletion flow by requesting the confirmation email""" + """ + Begin the account deletion flow by requesting the confirmation email + """ requestAccountDeletion( """ The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields. @@ -551,7 +639,9 @@ type Mutation { input: TransferOrganizationOwnershipInput! ): TransferOrganizationOwnershipPayload - """Updates a single `Organization` using a unique key and a patch.""" + """ + Updates a single `Organization` using a unique key and a patch. + """ updateOrganization( """ The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields. @@ -559,7 +649,9 @@ type Mutation { input: UpdateOrganizationInput! ): UpdateOrganizationPayload - """Updates a single `User` using a unique key and a patch.""" + """ + Updates a single `User` using a unique key and a patch. + """ updateUser( """ The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields. @@ -590,10 +682,14 @@ type Organization { Reads and enables pagination through a set of `OrganizationInvitation`. """ organizationInvitations( - """Read all values in the set after (below) this cursor.""" + """ + Read all values in the set after (below) this cursor. + """ after: Cursor - """Read all values in the set before (above) this cursor.""" + """ + Read all values in the set before (above) this cursor. + """ before: Cursor """ @@ -601,10 +697,14 @@ type Organization { """ condition: OrganizationInvitationCondition - """Only read the first `n` values of the set.""" + """ + Only read the first `n` values of the set. + """ first: Int - """Only read the last `n` values of the set.""" + """ + Only read the last `n` values of the set. + """ last: Int """ @@ -613,7 +713,9 @@ type Organization { """ offset: Int - """The method to use when ordering `OrganizationInvitation`.""" + """ + The method to use when ordering `OrganizationInvitation`. + """ orderBy: [OrganizationInvitationsOrderBy!] = [PRIMARY_KEY_ASC] ): OrganizationInvitationsConnection! @@ -621,10 +723,14 @@ type Organization { Reads and enables pagination through a set of `OrganizationMembership`. """ organizationMemberships( - """Read all values in the set after (below) this cursor.""" + """ + Read all values in the set after (below) this cursor. + """ after: Cursor - """Read all values in the set before (above) this cursor.""" + """ + Read all values in the set before (above) this cursor. + """ before: Cursor """ @@ -632,10 +738,14 @@ type Organization { """ condition: OrganizationMembershipCondition - """Only read the first `n` values of the set.""" + """ + Only read the first `n` values of the set. + """ first: Int - """Only read the last `n` values of the set.""" + """ + Only read the last `n` values of the set. + """ last: Int """ @@ -644,7 +754,9 @@ type Organization { """ offset: Int - """The method to use when ordering `OrganizationMembership`.""" + """ + The method to use when ordering `OrganizationMembership`. + """ orderBy: [OrganizationMembershipsOrderBy!] = [PRIMARY_KEY_ASC] ): OrganizationMembershipsConnection! slug: String! @@ -655,10 +767,14 @@ A condition to be used against `Organization` object types. All fields are tested for equality and combined with a logical ‘and.’ """ input OrganizationCondition { - """Checks for equality with the object’s `id` field.""" + """ + Checks for equality with the object’s `id` field. + """ id: UUID - """Checks for equality with the object’s `slug` field.""" + """ + Checks for equality with the object’s `slug` field. + """ slug: String } @@ -685,27 +801,39 @@ A condition to be used against `OrganizationInvitation` object types. All fields are tested for equality and combined with a logical ‘and.’ """ input OrganizationInvitationCondition { - """Checks for equality with the object’s `id` field.""" + """ + Checks for equality with the object’s `id` field. + """ id: UUID - """Checks for equality with the object’s `organizationId` field.""" + """ + Checks for equality with the object’s `organizationId` field. + """ organizationId: UUID - """Checks for equality with the object’s `userId` field.""" + """ + Checks for equality with the object’s `userId` field. + """ userId: UUID } -"""A connection to a list of `OrganizationInvitation` values.""" +""" +A connection to a list of `OrganizationInvitation` values. +""" type OrganizationInvitationsConnection { """ A list of edges which contains the `OrganizationInvitation` and cursor to aid in pagination. """ edges: [OrganizationInvitationsEdge!]! - """A list of `OrganizationInvitation` objects.""" + """ + A list of `OrganizationInvitation` objects. + """ nodes: [OrganizationInvitation!]! - """Information to aid in pagination.""" + """ + Information to aid in pagination. + """ pageInfo: PageInfo! """ @@ -714,16 +842,24 @@ type OrganizationInvitationsConnection { totalCount: Int! } -"""A `OrganizationInvitation` edge in the connection.""" +""" +A `OrganizationInvitation` edge in the connection. +""" type OrganizationInvitationsEdge { - """A cursor for use in pagination.""" + """ + A cursor for use in pagination. + """ cursor: Cursor - """The `OrganizationInvitation` at the end of the edge.""" + """ + The `OrganizationInvitation` at the end of the edge. + """ node: OrganizationInvitation! } -"""Methods to use when ordering `OrganizationInvitation`.""" +""" +Methods to use when ordering `OrganizationInvitation`. +""" enum OrganizationInvitationsOrderBy { ID_ASC ID_DESC @@ -760,27 +896,39 @@ A condition to be used against `OrganizationMembership` object types. All fields are tested for equality and combined with a logical ‘and.’ """ input OrganizationMembershipCondition { - """Checks for equality with the object’s `id` field.""" + """ + Checks for equality with the object’s `id` field. + """ id: UUID - """Checks for equality with the object’s `organizationId` field.""" + """ + Checks for equality with the object’s `organizationId` field. + """ organizationId: UUID - """Checks for equality with the object’s `userId` field.""" + """ + Checks for equality with the object’s `userId` field. + """ userId: UUID } -"""A connection to a list of `OrganizationMembership` values.""" +""" +A connection to a list of `OrganizationMembership` values. +""" type OrganizationMembershipsConnection { """ A list of edges which contains the `OrganizationMembership` and cursor to aid in pagination. """ edges: [OrganizationMembershipsEdge!]! - """A list of `OrganizationMembership` objects.""" + """ + A list of `OrganizationMembership` objects. + """ nodes: [OrganizationMembership!]! - """Information to aid in pagination.""" + """ + Information to aid in pagination. + """ pageInfo: PageInfo! """ @@ -789,16 +937,24 @@ type OrganizationMembershipsConnection { totalCount: Int! } -"""A `OrganizationMembership` edge in the connection.""" +""" +A `OrganizationMembership` edge in the connection. +""" type OrganizationMembershipsEdge { - """A cursor for use in pagination.""" + """ + A cursor for use in pagination. + """ cursor: Cursor - """The `OrganizationMembership` at the end of the edge.""" + """ + The `OrganizationMembership` at the end of the edge. + """ node: OrganizationMembership! } -"""Methods to use when ordering `OrganizationMembership`.""" +""" +Methods to use when ordering `OrganizationMembership`. +""" enum OrganizationMembershipsOrderBy { ID_ASC ID_DESC @@ -821,33 +977,49 @@ input OrganizationPatch { slug: String } -"""A connection to a list of `Organization` values.""" +""" +A connection to a list of `Organization` values. +""" type OrganizationsConnection { """ A list of edges which contains the `Organization` and cursor to aid in pagination. """ edges: [OrganizationsEdge!]! - """A list of `Organization` objects.""" + """ + A list of `Organization` objects. + """ nodes: [Organization!]! - """Information to aid in pagination.""" + """ + Information to aid in pagination. + """ pageInfo: PageInfo! - """The count of *all* `Organization` you could get from the connection.""" + """ + The count of *all* `Organization` you could get from the connection. + """ totalCount: Int! } -"""A `Organization` edge in the connection.""" +""" +A `Organization` edge in the connection. +""" type OrganizationsEdge { - """A cursor for use in pagination.""" + """ + A cursor for use in pagination. + """ cursor: Cursor - """The `Organization` at the end of the edge.""" + """ + The `Organization` at the end of the edge. + """ node: Organization! } -"""Methods to use when ordering `Organization`.""" +""" +Methods to use when ordering `Organization`. +""" enum OrganizationsOrderBy { ID_ASC ID_DESC @@ -858,24 +1030,38 @@ enum OrganizationsOrderBy { SLUG_DESC } -"""Information about pagination in a connection.""" +""" +Information about pagination in a connection. +""" type PageInfo { - """When paginating forwards, the cursor to continue.""" + """ + When paginating forwards, the cursor to continue. + """ endCursor: Cursor - """When paginating forwards, are there more items?""" + """ + When paginating forwards, are there more items? + """ hasNextPage: Boolean! - """When paginating backwards, are there more items?""" + """ + When paginating backwards, are there more items? + """ hasPreviousPage: Boolean! - """When paginating backwards, the cursor to continue.""" + """ + When paginating backwards, the cursor to continue. + """ startCursor: Cursor } -"""The root query type which gives access points into the data universe.""" +""" +The root query type which gives access points into the data universe. +""" type Query { - """The currently logged in user (or null if not logged in).""" + """ + The currently logged in user (or null if not logged in). + """ currentUser: User organization(id: UUID!): Organization organizationBySlug(slug: String!): Organization @@ -891,10 +1077,14 @@ type Query { Reads and enables pagination through a set of `OrganizationInvitation`. """ organizationInvitations( - """Read all values in the set after (below) this cursor.""" + """ + Read all values in the set after (below) this cursor. + """ after: Cursor - """Read all values in the set before (above) this cursor.""" + """ + Read all values in the set before (above) this cursor. + """ before: Cursor """ @@ -902,10 +1092,14 @@ type Query { """ condition: OrganizationInvitationCondition - """Only read the first `n` values of the set.""" + """ + Only read the first `n` values of the set. + """ first: Int - """Only read the last `n` values of the set.""" + """ + Only read the last `n` values of the set. + """ last: Int """ @@ -914,17 +1108,25 @@ type Query { """ offset: Int - """The method to use when ordering `OrganizationInvitation`.""" + """ + The method to use when ordering `OrganizationInvitation`. + """ orderBy: [OrganizationInvitationsOrderBy!] = [PRIMARY_KEY_ASC] ): OrganizationInvitationsConnection organizationMembership(id: UUID!): OrganizationMembership - """Reads and enables pagination through a set of `Organization`.""" + """ + Reads and enables pagination through a set of `Organization`. + """ organizations( - """Read all values in the set after (below) this cursor.""" + """ + Read all values in the set after (below) this cursor. + """ after: Cursor - """Read all values in the set before (above) this cursor.""" + """ + Read all values in the set before (above) this cursor. + """ before: Cursor """ @@ -932,10 +1134,14 @@ type Query { """ condition: OrganizationCondition - """Only read the first `n` values of the set.""" + """ + Only read the first `n` values of the set. + """ first: Int - """Only read the last `n` values of the set.""" + """ + Only read the last `n` values of the set. + """ last: Int """ @@ -944,7 +1150,9 @@ type Query { """ offset: Int - """The method to use when ordering `Organization`.""" + """ + The method to use when ordering `Organization`. + """ orderBy: [OrganizationsOrderBy!] = [PRIMARY_KEY_ASC] ): OrganizationsConnection user(id: UUID!): User @@ -965,7 +1173,9 @@ type RegisterPayload { user: User! } -"""All input for the `removeFromOrganization` mutation.""" +""" +All input for the `removeFromOrganization` mutation. +""" input RemoveFromOrganizationInput { """ An arbitrary string value with no semantic meaning. Will be included in the @@ -976,7 +1186,9 @@ input RemoveFromOrganizationInput { userId: UUID! } -"""The output of our `removeFromOrganization` mutation.""" +""" +The output of our `removeFromOrganization` mutation. +""" type RemoveFromOrganizationPayload { """ The exact same `clientMutationId` that was provided in the mutation input, @@ -990,7 +1202,9 @@ type RemoveFromOrganizationPayload { query: Query } -"""All input for the `requestAccountDeletion` mutation.""" +""" +All input for the `requestAccountDeletion` mutation. +""" input RequestAccountDeletionInput { """ An arbitrary string value with no semantic meaning. Will be included in the @@ -999,7 +1213,9 @@ input RequestAccountDeletionInput { clientMutationId: String } -"""The output of our `requestAccountDeletion` mutation.""" +""" +The output of our `requestAccountDeletion` mutation. +""" type RequestAccountDeletionPayload { """ The exact same `clientMutationId` that was provided in the mutation input, @@ -1014,7 +1230,9 @@ type RequestAccountDeletionPayload { success: Boolean } -"""All input for the `resendEmailVerificationCode` mutation.""" +""" +All input for the `resendEmailVerificationCode` mutation. +""" input ResendEmailVerificationCodeInput { """ An arbitrary string value with no semantic meaning. Will be included in the @@ -1024,7 +1242,9 @@ input ResendEmailVerificationCodeInput { emailId: UUID! } -"""The output of our `resendEmailVerificationCode` mutation.""" +""" +The output of our `resendEmailVerificationCode` mutation. +""" type ResendEmailVerificationCodePayload { """ The exact same `clientMutationId` that was provided in the mutation input, @@ -1039,7 +1259,9 @@ type ResendEmailVerificationCodePayload { success: Boolean } -"""All input for the `resetPassword` mutation.""" +""" +All input for the `resetPassword` mutation. +""" input ResetPasswordInput { """ An arbitrary string value with no semantic meaning. Will be included in the @@ -1051,7 +1273,9 @@ input ResetPasswordInput { userId: UUID! } -"""The output of our `resetPassword` mutation.""" +""" +The output of our `resetPassword` mutation. +""" type ResetPasswordPayload { """ The exact same `clientMutationId` that was provided in the mutation input, @@ -1070,11 +1294,15 @@ type ResetPasswordPayload { The root subscription type: contains realtime events you can subscribe to with the `subscription` operation. """ type Subscription { - """Triggered when the logged in user's record is updated in some way.""" + """ + Triggered when the logged in user's record is updated in some way. + """ currentUserUpdated: UserSubscriptionPayload } -"""All input for the `transferOrganizationBillingContact` mutation.""" +""" +All input for the `transferOrganizationBillingContact` mutation. +""" input TransferOrganizationBillingContactInput { """ An arbitrary string value with no semantic meaning. Will be included in the @@ -1085,7 +1313,9 @@ input TransferOrganizationBillingContactInput { userId: UUID! } -"""The output of our `transferOrganizationBillingContact` mutation.""" +""" +The output of our `transferOrganizationBillingContact` mutation. +""" type TransferOrganizationBillingContactPayload { """ The exact same `clientMutationId` that was provided in the mutation input, @@ -1094,9 +1324,13 @@ type TransferOrganizationBillingContactPayload { clientMutationId: String organization: Organization - """An edge for our `Organization`. May be used by Relay 1.""" + """ + An edge for our `Organization`. May be used by Relay 1. + """ organizationEdge( - """The method to use when ordering `Organization`.""" + """ + The method to use when ordering `Organization`. + """ orderBy: [OrganizationsOrderBy!] = [PRIMARY_KEY_ASC] ): OrganizationsEdge @@ -1106,7 +1340,9 @@ type TransferOrganizationBillingContactPayload { query: Query } -"""All input for the `transferOrganizationOwnership` mutation.""" +""" +All input for the `transferOrganizationOwnership` mutation. +""" input TransferOrganizationOwnershipInput { """ An arbitrary string value with no semantic meaning. Will be included in the @@ -1117,7 +1353,9 @@ input TransferOrganizationOwnershipInput { userId: UUID! } -"""The output of our `transferOrganizationOwnership` mutation.""" +""" +The output of our `transferOrganizationOwnership` mutation. +""" type TransferOrganizationOwnershipPayload { """ The exact same `clientMutationId` that was provided in the mutation input, @@ -1126,9 +1364,13 @@ type TransferOrganizationOwnershipPayload { clientMutationId: String organization: Organization - """An edge for our `Organization`. May be used by Relay 1.""" + """ + An edge for our `Organization`. May be used by Relay 1. + """ organizationEdge( - """The method to use when ordering `Organization`.""" + """ + The method to use when ordering `Organization`. + """ orderBy: [OrganizationsOrderBy!] = [PRIMARY_KEY_ASC] ): OrganizationsEdge @@ -1138,7 +1380,9 @@ type TransferOrganizationOwnershipPayload { query: Query } -"""All input for the `updateOrganization` mutation.""" +""" +All input for the `updateOrganization` mutation. +""" input UpdateOrganizationInput { """ An arbitrary string value with no semantic meaning. Will be included in the @@ -1153,7 +1397,9 @@ input UpdateOrganizationInput { patch: OrganizationPatch! } -"""The output of our update `Organization` mutation.""" +""" +The output of our update `Organization` mutation. +""" type UpdateOrganizationPayload { """ The exact same `clientMutationId` that was provided in the mutation input, @@ -1161,12 +1407,18 @@ type UpdateOrganizationPayload { """ clientMutationId: String - """The `Organization` that was updated by this mutation.""" + """ + The `Organization` that was updated by this mutation. + """ organization: Organization - """An edge for our `Organization`. May be used by Relay 1.""" + """ + An edge for our `Organization`. May be used by Relay 1. + """ organizationEdge( - """The method to use when ordering `Organization`.""" + """ + The method to use when ordering `Organization`. + """ orderBy: [OrganizationsOrderBy!] = [PRIMARY_KEY_ASC] ): OrganizationsEdge @@ -1176,7 +1428,9 @@ type UpdateOrganizationPayload { query: Query } -"""All input for the `updateUser` mutation.""" +""" +All input for the `updateUser` mutation. +""" input UpdateUserInput { """ An arbitrary string value with no semantic meaning. Will be included in the @@ -1184,7 +1438,9 @@ input UpdateUserInput { """ clientMutationId: String - """Unique identifier for the user.""" + """ + Unique identifier for the user. + """ id: UUID! """ @@ -1193,7 +1449,9 @@ input UpdateUserInput { patch: UserPatch! } -"""The output of our update `User` mutation.""" +""" +The output of our update `User` mutation. +""" type UpdateUserPayload { """ The exact same `clientMutationId` that was provided in the mutation input, @@ -1206,41 +1464,61 @@ type UpdateUserPayload { """ query: Query - """The `User` that was updated by this mutation.""" + """ + The `User` that was updated by this mutation. + """ user: User - """An edge for our `User`. May be used by Relay 1.""" + """ + An edge for our `User`. May be used by Relay 1. + """ userEdge( - """The method to use when ordering `User`.""" + """ + The method to use when ordering `User`. + """ orderBy: [UsersOrderBy!] = [PRIMARY_KEY_ASC] ): UsersEdge } -"""A user who can log in to the application.""" +""" +A user who can log in to the application. +""" type User { - """Optional avatar URL.""" + """ + Optional avatar URL. + """ avatarUrl: String createdAt: Datetime! hasPassword: Boolean - """Unique identifier for the user.""" + """ + Unique identifier for the user. + """ id: UUID! - """If true, the user has elevated privileges.""" + """ + If true, the user has elevated privileges. + """ isAdmin: Boolean! isVerified: Boolean! - """Public-facing name (or pseudonym) of the user.""" + """ + Public-facing name (or pseudonym) of the user. + """ name: String """ Reads and enables pagination through a set of `OrganizationInvitation`. """ organizationInvitations( - """Read all values in the set after (below) this cursor.""" + """ + Read all values in the set after (below) this cursor. + """ after: Cursor - """Read all values in the set before (above) this cursor.""" + """ + Read all values in the set before (above) this cursor. + """ before: Cursor """ @@ -1248,10 +1526,14 @@ type User { """ condition: OrganizationInvitationCondition - """Only read the first `n` values of the set.""" + """ + Only read the first `n` values of the set. + """ first: Int - """Only read the last `n` values of the set.""" + """ + Only read the last `n` values of the set. + """ last: Int """ @@ -1260,7 +1542,9 @@ type User { """ offset: Int - """The method to use when ordering `OrganizationInvitation`.""" + """ + The method to use when ordering `OrganizationInvitation`. + """ orderBy: [OrganizationInvitationsOrderBy!] = [PRIMARY_KEY_ASC] ): OrganizationInvitationsConnection! @@ -1268,10 +1552,14 @@ type User { Reads and enables pagination through a set of `OrganizationMembership`. """ organizationMemberships( - """Read all values in the set after (below) this cursor.""" + """ + Read all values in the set after (below) this cursor. + """ after: Cursor - """Read all values in the set before (above) this cursor.""" + """ + Read all values in the set before (above) this cursor. + """ before: Cursor """ @@ -1279,10 +1567,14 @@ type User { """ condition: OrganizationMembershipCondition - """Only read the first `n` values of the set.""" + """ + Only read the first `n` values of the set. + """ first: Int - """Only read the last `n` values of the set.""" + """ + Only read the last `n` values of the set. + """ last: Int """ @@ -1291,34 +1583,50 @@ type User { """ offset: Int - """The method to use when ordering `OrganizationMembership`.""" + """ + The method to use when ordering `OrganizationMembership`. + """ orderBy: [OrganizationMembershipsOrderBy!] = [PRIMARY_KEY_ASC] ): OrganizationMembershipsConnection! updatedAt: Datetime! - """Reads and enables pagination through a set of `UserAuthentication`.""" + """ + Reads and enables pagination through a set of `UserAuthentication`. + """ userAuthenticationsList( """ A condition to be used in determining which values should be returned by the collection. """ condition: UserAuthenticationCondition - """Only read the first `n` values of the set.""" + """ + Only read the first `n` values of the set. + """ first: Int - """Skip the first `n` values.""" + """ + Skip the first `n` values. + """ offset: Int - """The method to use when ordering `UserAuthentication`.""" + """ + The method to use when ordering `UserAuthentication`. + """ orderBy: [UserAuthenticationsOrderBy!] ): [UserAuthentication!]! - """Reads and enables pagination through a set of `UserEmail`.""" + """ + Reads and enables pagination through a set of `UserEmail`. + """ userEmails( - """Read all values in the set after (below) this cursor.""" + """ + Read all values in the set after (below) this cursor. + """ after: Cursor - """Read all values in the set before (above) this cursor.""" + """ + Read all values in the set before (above) this cursor. + """ before: Cursor """ @@ -1326,10 +1634,14 @@ type User { """ condition: UserEmailCondition - """Only read the first `n` values of the set.""" + """ + Only read the first `n` values of the set. + """ first: Int - """Only read the last `n` values of the set.""" + """ + Only read the last `n` values of the set. + """ last: Int """ @@ -1338,11 +1650,15 @@ type User { """ offset: Int - """The method to use when ordering `UserEmail`.""" + """ + The method to use when ordering `UserEmail`. + """ orderBy: [UserEmailsOrderBy!] = [PRIMARY_KEY_ASC] ): UserEmailsConnection! - """Public-facing username (or 'handle') of the user.""" + """ + Public-facing username (or 'handle') of the user. + """ username: String! } @@ -1353,14 +1669,20 @@ type UserAuthentication { createdAt: Datetime! id: UUID! - """A unique identifier for the user within the login service.""" + """ + A unique identifier for the user within the login service. + """ identifier: String! - """The login service used, e.g. `twitter` or `github`.""" + """ + The login service used, e.g. `twitter` or `github`. + """ service: String! updatedAt: Datetime! - """Reads a single `User` that is related to this `UserAuthentication`.""" + """ + Reads a single `User` that is related to this `UserAuthentication`. + """ user: User userId: UUID! } @@ -1370,17 +1692,25 @@ A condition to be used against `UserAuthentication` object types. All fields are tested for equality and combined with a logical ‘and.’ """ input UserAuthenticationCondition { - """Checks for equality with the object’s `id` field.""" + """ + Checks for equality with the object’s `id` field. + """ id: UUID - """Checks for equality with the object’s `service` field.""" + """ + Checks for equality with the object’s `service` field. + """ service: String - """Checks for equality with the object’s `userId` field.""" + """ + Checks for equality with the object’s `userId` field. + """ userId: UUID } -"""Methods to use when ordering `UserAuthentication`.""" +""" +Methods to use when ordering `UserAuthentication`. +""" enum UserAuthenticationsOrderBy { ID_ASC ID_DESC @@ -1393,11 +1723,15 @@ enum UserAuthenticationsOrderBy { USER_ID_DESC } -"""Information about a user's email address.""" +""" +Information about a user's email address. +""" type UserEmail { createdAt: Datetime! - """The users email address, in `a@b.c` format.""" + """ + The users email address, in `a@b.c` format. + """ email: String! id: UUID! isPrimary: Boolean! @@ -1409,7 +1743,9 @@ type UserEmail { isVerified: Boolean! updatedAt: Datetime! - """Reads a single `User` that is related to this `UserEmail`.""" + """ + Reads a single `User` that is related to this `UserEmail`. + """ user: User userId: UUID! } @@ -1419,49 +1755,75 @@ A condition to be used against `UserEmail` object types. All fields are tested for equality and combined with a logical ‘and.’ """ input UserEmailCondition { - """Checks for equality with the object’s `id` field.""" + """ + Checks for equality with the object’s `id` field. + """ id: UUID - """Checks for equality with the object’s `isPrimary` field.""" + """ + Checks for equality with the object’s `isPrimary` field. + """ isPrimary: Boolean - """Checks for equality with the object’s `userId` field.""" + """ + Checks for equality with the object’s `userId` field. + """ userId: UUID } -"""An input for mutations affecting `UserEmail`""" +""" +An input for mutations affecting `UserEmail` +""" input UserEmailInput { - """The users email address, in `a@b.c` format.""" + """ + The users email address, in `a@b.c` format. + """ email: String! } -"""A connection to a list of `UserEmail` values.""" +""" +A connection to a list of `UserEmail` values. +""" type UserEmailsConnection { """ A list of edges which contains the `UserEmail` and cursor to aid in pagination. """ edges: [UserEmailsEdge!]! - """A list of `UserEmail` objects.""" + """ + A list of `UserEmail` objects. + """ nodes: [UserEmail!]! - """Information to aid in pagination.""" + """ + Information to aid in pagination. + """ pageInfo: PageInfo! - """The count of *all* `UserEmail` you could get from the connection.""" + """ + The count of *all* `UserEmail` you could get from the connection. + """ totalCount: Int! } -"""A `UserEmail` edge in the connection.""" +""" +A `UserEmail` edge in the connection. +""" type UserEmailsEdge { - """A cursor for use in pagination.""" + """ + A cursor for use in pagination. + """ cursor: Cursor - """The `UserEmail` at the end of the edge.""" + """ + The `UserEmail` at the end of the edge. + """ node: UserEmail! } -"""Methods to use when ordering `UserEmail`.""" +""" +Methods to use when ordering `UserEmail`. +""" enum UserEmailsOrderBy { ID_ASC ID_DESC @@ -1474,28 +1836,44 @@ enum UserEmailsOrderBy { USER_ID_DESC } -"""Represents an update to a `User`. Fields that are set will be updated.""" +""" +Represents an update to a `User`. Fields that are set will be updated. +""" input UserPatch { - """Optional avatar URL.""" + """ + Optional avatar URL. + """ avatarUrl: String - """Public-facing name (or pseudonym) of the user.""" + """ + Public-facing name (or pseudonym) of the user. + """ name: String - """Public-facing username (or 'handle') of the user.""" + """ + Public-facing username (or 'handle') of the user. + """ username: String } -"""A `User` edge in the connection.""" +""" +A `User` edge in the connection. +""" type UsersEdge { - """A cursor for use in pagination.""" + """ + A cursor for use in pagination. + """ cursor: Cursor - """The `User` at the end of the edge.""" + """ + The `User` at the end of the edge. + """ node: User! } -"""Methods to use when ordering `User`.""" +""" +Methods to use when ordering `User`. +""" enum UsersOrderBy { ID_ASC ID_DESC @@ -1516,7 +1894,9 @@ A universally unique identifier as defined by [RFC 4122](https://tools.ietf.org/ """ scalar UUID -"""All input for the `verifyEmail` mutation.""" +""" +All input for the `verifyEmail` mutation. +""" input VerifyEmailInput { """ An arbitrary string value with no semantic meaning. Will be included in the @@ -1527,7 +1907,9 @@ input VerifyEmailInput { userEmailId: UUID! } -"""The output of our `verifyEmail` mutation.""" +""" +The output of our `verifyEmail` mutation. +""" type VerifyEmailPayload { """ The exact same `clientMutationId` that was provided in the mutation input, diff --git a/yarn.lock b/yarn.lock index b4f1592a..fafecb53 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3174,6 +3174,19 @@ resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-1.0.1.tgz#0a851d3bd96498fa25c33ab7278ed3bd65f06c3e" integrity sha512-l42BggppR6zLmpfU6fq9HEa2oGPEI8yrSPL3GITjfRInppYFahObbIQOQK3UGxEnyQpltZLaPe75046NOZQikw== +<<<<<<< HEAD +======= +"@types/uuid@^7.0.2": + version "7.0.2" + resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-7.0.2.tgz#d680a9c596ef84abf5c4c07a32ffd66d582526f8" + integrity sha512-8Ly3zIPTnT0/8RCU6Kg/G3uTICf9sRwYOpUzSIM3503tLIKcnJPRuinHhXngJUy2MntrEf6dlpOHXJju90Qh5w== + +"@types/valid-url@1.0.2": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@types/valid-url/-/valid-url-1.0.2.tgz#60fa435ce24bfd5ba107b8d2a80796aeaf3a8f45" + integrity sha1-YPpDXOJL/VuhB7jSqAeWrq86j0U= + +>>>>>>> Extend schema to allow for presigned URLs for uploading files "@types/ws@^6.0.1": version "6.0.4" resolved "https://registry.yarnpkg.com/@types/ws/-/ws-6.0.4.tgz#7797707c8acce8f76d8c34b370d4645b70421ff1" @@ -15653,6 +15666,11 @@ uuid@^7.0.3: resolved "https://registry.yarnpkg.com/uuid/-/uuid-7.0.3.tgz#c5c9f2c8cf25dc0a372c4df1441c41f5bd0c680b" integrity sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg== +uuid@^7.0.3: + version "7.0.3" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-7.0.3.tgz#c5c9f2c8cf25dc0a372c4df1441c41f5bd0c680b" + integrity sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg== + v8-compile-cache@^2.0.3: version "2.1.0" resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.1.0.tgz#e14de37b31a6d194f5690d67efc4e7f6fc6ab30e" From e96c5e107afd0d87636482ddb6b1701d0d3524b3 Mon Sep 17 00:00:00 2001 From: David Baumgold Date: Sat, 18 Apr 2020 15:07:59 +0200 Subject: [PATCH 15/34] respond to review comments --- .../src/plugins/CreateUploadUrlPlugin.ts | 110 ++++++++++++++++-- 1 file changed, 103 insertions(+), 7 deletions(-) diff --git a/@app/server/src/plugins/CreateUploadUrlPlugin.ts b/@app/server/src/plugins/CreateUploadUrlPlugin.ts index 3247fdf0..f39b0433 100644 --- a/@app/server/src/plugins/CreateUploadUrlPlugin.ts +++ b/@app/server/src/plugins/CreateUploadUrlPlugin.ts @@ -3,19 +3,110 @@ import * as aws from "aws-sdk"; import { gql, makeExtendSchemaPlugin } from "graphile-utils"; import uuidv4 from "uuid/v4"; +import { OurGraphQLContext } from "../middleware/installPostGraphile"; + +const ALLOWED_CONTENT_TYPE_ENUM_MAPPING = { + IMAGE_APNG: "image/apng", + IMAGE_BMP: "image/bmp", + IMAGE_GIF: "image/gif", + IMAGE_JPEG: "image/jpeg", + IMAGE_PNG: "image/png", + IMAGE_SVG_XML: "image/svg+xml", + IMAGE_TIFF: "image/tiff", + IMAGE_WEBP: "image/webp", +}; + const CreateUploadUrlPlugin = makeExtendSchemaPlugin(() => ({ typeDefs: gql` + """ + The set of content types that we allow users to upload. + """ + enum AllowedUploadContentType { + "image/apng" + IMAGE_APNG + "image/bmp" + IMAGE_BMP + "image/gif" + IMAGE_GIF + "image/jpeg" + IMAGE_JPEG + "image/png" + IMAGE_PNG + "image/svg+xml" + IMAGE_SVG_XML + "image/tiff" + IMAGE_TIFF + "image/webp" + IMAGE_WEBP + } + + """ + All input for the \`createUploadUrl\` mutation. + """ + input CreateUploadUrlInput { + """ + An arbitrary string value with no semantic meaning. Will be included in the + payload verbatim. May be used to track mutations by the client. + """ + clientMutationId: String + """ + You must provide the content type (or MIME type) of the content you intend + to upload. For further information about content types, see + https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types + """ + contentType: AllowedUploadContentType! + } + + """ + The output of our \`createUploadUrl mutation. + """ + type CreateUploadUrlPayload { + """ + The exact same \`clientMutationId\` that was provided in the mutation input, + unchanged and unused. May be used by a client to track mutations. + """ + clientMutationId: String + + """ + Our root query field type. Allows us to run any query from our mutation payload. + """ + query: Query + """ + Upload content to this signed URL. + """ + uploadUrl: String! + } + extend type Mutation { """ - Get a signed URL for uploading files. It will expire in 60 seconds. + Get a signed URL for uploading files. It will expire in 5 minutes. """ - createUploadUrl(contentType: String): String! + createUploadUrl( + """ + The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields. + """ + input: CreateUploadUrlInput! + ): CreateUploadUrlPayload } `, resolvers: { Mutation: { - async createUploadUrl(_query, args, _context, _resolveInfo) { - const { contentType } = args; + async createUploadUrl( + _query, + args, + context: OurGraphQLContext, + _resolveInfo + ) { + const { rootPgPool } = context; + const { + rows: [user], + } = await rootPgPool.query( + `select username from app_public.users where id = app_public.current_user_id()` + ); + const username: string = user.username; + + const contentType = + ALLOWED_CONTENT_TYPE_ENUM_MAPPING[args.input.contentType]; const s3 = new aws.S3({ region: awsRegion, signatureVersion: "v4", @@ -23,12 +114,17 @@ const CreateUploadUrlPlugin = makeExtendSchemaPlugin(() => ({ const params = { Bucket: uploadBucket, ContentType: contentType, - Key: uuidv4(), // randomly generated file name - Expires: 60, // signed URL will expire in 60 seconds + // randomly generated filename, nested under username directory + Key: `${username}/${uuidv4()}`, + Expires: 300, // signed URL will expire in 5 minutes ACL: "public-read", // uploaded file will be publicly readable }; const signedUrl = await s3.getSignedUrlPromise("putObject", params); - return signedUrl; + return { + clientMutationId: args.clientMutationId, + // what to do about `query` ? + uploadUrl: signedUrl, + }; }, }, }, From c9378b314432a35596b26cb4862aa4a270a3827d Mon Sep 17 00:00:00 2001 From: David Baumgold Date: Sat, 18 Apr 2020 15:11:09 +0200 Subject: [PATCH 16/34] update schema.graphql --- data/schema.graphql | 826 ++++++++++++++------------------------------ 1 file changed, 256 insertions(+), 570 deletions(-) diff --git a/data/schema.graphql b/data/schema.graphql index 9e0c2015..d449a0ca 100644 --- a/data/schema.graphql +++ b/data/schema.graphql @@ -1,6 +1,4 @@ -""" -All input for the `acceptInvitationToOrganization` mutation. -""" +"""All input for the `acceptInvitationToOrganization` mutation.""" input AcceptInvitationToOrganizationInput { """ An arbitrary string value with no semantic meaning. Will be included in the @@ -11,9 +9,7 @@ input AcceptInvitationToOrganizationInput { invitationId: UUID! } -""" -The output of our `acceptInvitationToOrganization` mutation. -""" +"""The output of our `acceptInvitationToOrganization` mutation.""" type AcceptInvitationToOrganizationPayload { """ The exact same `clientMutationId` that was provided in the mutation input, @@ -27,9 +23,34 @@ type AcceptInvitationToOrganizationPayload { query: Query } -""" -All input for the `changePassword` mutation. -""" +"""The set of content types that we allow users to upload.""" +enum AllowedUploadContentType { + """image/apng""" + IMAGE_APNG + + """image/bmp""" + IMAGE_BMP + + """image/gif""" + IMAGE_GIF + + """image/jpeg""" + IMAGE_JPEG + + """image/png""" + IMAGE_PNG + + """image/svg+xml""" + IMAGE_SVG_XML + + """image/tiff""" + IMAGE_TIFF + + """image/webp""" + IMAGE_WEBP +} + +"""All input for the `changePassword` mutation.""" input ChangePasswordInput { """ An arbitrary string value with no semantic meaning. Will be included in the @@ -40,9 +61,7 @@ input ChangePasswordInput { oldPassword: String! } -""" -The output of our `changePassword` mutation. -""" +"""The output of our `changePassword` mutation.""" type ChangePasswordPayload { """ The exact same `clientMutationId` that was provided in the mutation input, @@ -57,9 +76,7 @@ type ChangePasswordPayload { success: Boolean } -""" -All input for the `confirmAccountDeletion` mutation. -""" +"""All input for the `confirmAccountDeletion` mutation.""" input ConfirmAccountDeletionInput { """ An arbitrary string value with no semantic meaning. Will be included in the @@ -69,9 +86,7 @@ input ConfirmAccountDeletionInput { token: String! } -""" -The output of our `confirmAccountDeletion` mutation. -""" +"""The output of our `confirmAccountDeletion` mutation.""" type ConfirmAccountDeletionPayload { """ The exact same `clientMutationId` that was provided in the mutation input, @@ -86,9 +101,7 @@ type ConfirmAccountDeletionPayload { success: Boolean } -""" -All input for the `createOrganization` mutation. -""" +"""All input for the `createOrganization` mutation.""" input CreateOrganizationInput { """ An arbitrary string value with no semantic meaning. Will be included in the @@ -99,9 +112,7 @@ input CreateOrganizationInput { slug: String! } -""" -The output of our `createOrganization` mutation. -""" +"""The output of our `createOrganization` mutation.""" type CreateOrganizationPayload { """ The exact same `clientMutationId` that was provided in the mutation input, @@ -110,13 +121,9 @@ type CreateOrganizationPayload { clientMutationId: String organization: Organization - """ - An edge for our `Organization`. May be used by Relay 1. - """ + """An edge for our `Organization`. May be used by Relay 1.""" organizationEdge( - """ - The method to use when ordering `Organization`. - """ + """The method to use when ordering `Organization`.""" orderBy: [OrganizationsOrderBy!] = [PRIMARY_KEY_ASC] ): OrganizationsEdge @@ -126,10 +133,8 @@ type CreateOrganizationPayload { query: Query } -""" -All input for the create `UserEmail` mutation. -""" -input CreateUserEmailInput { +"""All input for the `createUploadUrl` mutation.""" +input CreateUploadUrlInput { """ An arbitrary string value with no semantic meaning. Will be included in the payload verbatim. May be used to track mutations by the client. @@ -137,15 +142,15 @@ input CreateUserEmailInput { clientMutationId: String """ - The `UserEmail` to be created by this mutation. + You must provide the content type (or MIME type) of the content you intend + to upload. For further information about content types, see + https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types """ - userEmail: UserEmailInput! + contentType: AllowedUploadContentType! } -""" -The output of our create `UserEmail` mutation. -""" -type CreateUserEmailPayload { +"""The output of our `createUploadUrl mutation.""" +type CreateUploadUrlPayload { """ The exact same `clientMutationId` that was provided in the mutation input, unchanged and unused. May be used by a client to track mutations. @@ -157,30 +162,49 @@ type CreateUserEmailPayload { """ query: Query + """Upload content to this signed URL.""" + uploadUrl: String! +} + +"""All input for the create `UserEmail` mutation.""" +input CreateUserEmailInput { """ - Reads a single `User` that is related to this `UserEmail`. + An arbitrary string value with no semantic meaning. Will be included in the + payload verbatim. May be used to track mutations by the client. """ - user: User + clientMutationId: String + + """The `UserEmail` to be created by this mutation.""" + userEmail: UserEmailInput! +} +"""The output of our create `UserEmail` mutation.""" +type CreateUserEmailPayload { """ - The `UserEmail` that was created by this mutation. + The exact same `clientMutationId` that was provided in the mutation input, + unchanged and unused. May be used by a client to track mutations. """ - userEmail: UserEmail + clientMutationId: String """ - An edge for our `UserEmail`. May be used by Relay 1. + Our root query field type. Allows us to run any query from our mutation payload. """ + query: Query + + """Reads a single `User` that is related to this `UserEmail`.""" + user: User + + """The `UserEmail` that was created by this mutation.""" + userEmail: UserEmail + + """An edge for our `UserEmail`. May be used by Relay 1.""" userEmailEdge( - """ - The method to use when ordering `UserEmail`. - """ + """The method to use when ordering `UserEmail`.""" orderBy: [UserEmailsOrderBy!] = [PRIMARY_KEY_ASC] ): UserEmailsEdge } -""" -A location in a connection that can be used for resuming pagination. -""" +"""A location in a connection that can be used for resuming pagination.""" scalar Cursor """ @@ -189,9 +213,7 @@ A point in time as described by the [ISO """ scalar Datetime -""" -All input for the `deleteOrganization` mutation. -""" +"""All input for the `deleteOrganization` mutation.""" input DeleteOrganizationInput { """ An arbitrary string value with no semantic meaning. Will be included in the @@ -201,9 +223,7 @@ input DeleteOrganizationInput { organizationId: UUID! } -""" -The output of our `deleteOrganization` mutation. -""" +"""The output of our `deleteOrganization` mutation.""" type DeleteOrganizationPayload { """ The exact same `clientMutationId` that was provided in the mutation input, @@ -217,9 +237,7 @@ type DeleteOrganizationPayload { query: Query } -""" -All input for the `deleteUserAuthentication` mutation. -""" +"""All input for the `deleteUserAuthentication` mutation.""" input DeleteUserAuthenticationInput { """ An arbitrary string value with no semantic meaning. Will be included in the @@ -229,9 +247,7 @@ input DeleteUserAuthenticationInput { id: UUID! } -""" -The output of our delete `UserAuthentication` mutation. -""" +"""The output of our delete `UserAuthentication` mutation.""" type DeleteUserAuthenticationPayload { """ The exact same `clientMutationId` that was provided in the mutation input, @@ -245,20 +261,14 @@ type DeleteUserAuthenticationPayload { """ query: Query - """ - Reads a single `User` that is related to this `UserAuthentication`. - """ + """Reads a single `User` that is related to this `UserAuthentication`.""" user: User - """ - The `UserAuthentication` that was deleted by this mutation. - """ + """The `UserAuthentication` that was deleted by this mutation.""" userAuthentication: UserAuthentication } -""" -All input for the `deleteUserEmail` mutation. -""" +"""All input for the `deleteUserEmail` mutation.""" input DeleteUserEmailInput { """ An arbitrary string value with no semantic meaning. Will be included in the @@ -268,9 +278,7 @@ input DeleteUserEmailInput { id: UUID! } -""" -The output of our delete `UserEmail` mutation. -""" +"""The output of our delete `UserEmail` mutation.""" type DeleteUserEmailPayload { """ The exact same `clientMutationId` that was provided in the mutation input, @@ -284,30 +292,20 @@ type DeleteUserEmailPayload { """ query: Query - """ - Reads a single `User` that is related to this `UserEmail`. - """ + """Reads a single `User` that is related to this `UserEmail`.""" user: User - """ - The `UserEmail` that was deleted by this mutation. - """ + """The `UserEmail` that was deleted by this mutation.""" userEmail: UserEmail - """ - An edge for our `UserEmail`. May be used by Relay 1. - """ + """An edge for our `UserEmail`. May be used by Relay 1.""" userEmailEdge( - """ - The method to use when ordering `UserEmail`. - """ + """The method to use when ordering `UserEmail`.""" orderBy: [UserEmailsOrderBy!] = [PRIMARY_KEY_ASC] ): UserEmailsEdge } -""" -All input for the `forgotPassword` mutation. -""" +"""All input for the `forgotPassword` mutation.""" input ForgotPasswordInput { """ An arbitrary string value with no semantic meaning. Will be included in the @@ -317,9 +315,7 @@ input ForgotPasswordInput { email: String! } -""" -The output of our `forgotPassword` mutation. -""" +"""The output of our `forgotPassword` mutation.""" type ForgotPasswordPayload { """ The exact same `clientMutationId` that was provided in the mutation input, @@ -333,9 +329,7 @@ type ForgotPasswordPayload { query: Query } -""" -All input for the `inviteToOrganization` mutation. -""" +"""All input for the `inviteToOrganization` mutation.""" input InviteToOrganizationInput { """ An arbitrary string value with no semantic meaning. Will be included in the @@ -347,9 +341,7 @@ input InviteToOrganizationInput { username: String } -""" -The output of our `inviteToOrganization` mutation. -""" +"""The output of our `inviteToOrganization` mutation.""" type InviteToOrganizationPayload { """ The exact same `clientMutationId` that was provided in the mutation input, @@ -376,9 +368,7 @@ type LogoutPayload { success: Boolean } -""" -All input for the `makeEmailPrimary` mutation. -""" +"""All input for the `makeEmailPrimary` mutation.""" input MakeEmailPrimaryInput { """ An arbitrary string value with no semantic meaning. Will be included in the @@ -388,9 +378,7 @@ input MakeEmailPrimaryInput { emailId: UUID! } -""" -The output of our `makeEmailPrimary` mutation. -""" +"""The output of our `makeEmailPrimary` mutation.""" type MakeEmailPrimaryPayload { """ The exact same `clientMutationId` that was provided in the mutation input, @@ -403,19 +391,13 @@ type MakeEmailPrimaryPayload { """ query: Query - """ - Reads a single `User` that is related to this `UserEmail`. - """ + """Reads a single `User` that is related to this `UserEmail`.""" user: User userEmail: UserEmail - """ - An edge for our `UserEmail`. May be used by Relay 1. - """ + """An edge for our `UserEmail`. May be used by Relay 1.""" userEmailEdge( - """ - The method to use when ordering `UserEmail`. - """ + """The method to use when ordering `UserEmail`.""" orderBy: [UserEmailsOrderBy!] = [PRIMARY_KEY_ASC] ): UserEmailsEdge } @@ -436,9 +418,7 @@ type Mutation { input: AcceptInvitationToOrganizationInput! ): AcceptInvitationToOrganizationPayload - """ - Enter your old password and a new password to change your password. - """ + """Enter your old password and a new password to change your password.""" changePassword( """ The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields. @@ -471,9 +451,15 @@ type Mutation { input: CreateOrganizationInput! ): CreateOrganizationPayload - """ - Creates a single `UserEmail`. - """ + """Get a signed URL for uploading files. It will expire in 5 minutes.""" + createUploadUrl( + """ + The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields. + """ + input: CreateUploadUrlInput! + ): CreateUploadUrlPayload + + """Creates a single `UserEmail`.""" createUserEmail( """ The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields. @@ -491,9 +477,7 @@ type Mutation { input: DeleteOrganizationInput! ): DeleteOrganizationPayload - """ - Deletes a single `UserAuthentication` using a unique key. - """ + """Deletes a single `UserAuthentication` using a unique key.""" deleteUserAuthentication( """ The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields. @@ -501,9 +485,7 @@ type Mutation { input: DeleteUserAuthenticationInput! ): DeleteUserAuthenticationPayload - """ - Deletes a single `UserEmail` using a unique key. - """ + """Deletes a single `UserEmail` using a unique key.""" deleteUserEmail( """ The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields. @@ -583,9 +565,7 @@ type Mutation { input: RemoveFromOrganizationInput! ): RemoveFromOrganizationPayload - """ - Begin the account deletion flow by requesting the confirmation email - """ + """Begin the account deletion flow by requesting the confirmation email""" requestAccountDeletion( """ The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields. @@ -639,9 +619,7 @@ type Mutation { input: TransferOrganizationOwnershipInput! ): TransferOrganizationOwnershipPayload - """ - Updates a single `Organization` using a unique key and a patch. - """ + """Updates a single `Organization` using a unique key and a patch.""" updateOrganization( """ The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields. @@ -649,9 +627,7 @@ type Mutation { input: UpdateOrganizationInput! ): UpdateOrganizationPayload - """ - Updates a single `User` using a unique key and a patch. - """ + """Updates a single `User` using a unique key and a patch.""" updateUser( """ The exclusive input argument for this mutation. An object type, make sure to see documentation for this object’s fields. @@ -682,14 +658,10 @@ type Organization { Reads and enables pagination through a set of `OrganizationInvitation`. """ organizationInvitations( - """ - Read all values in the set after (below) this cursor. - """ + """Read all values in the set after (below) this cursor.""" after: Cursor - """ - Read all values in the set before (above) this cursor. - """ + """Read all values in the set before (above) this cursor.""" before: Cursor """ @@ -697,14 +669,10 @@ type Organization { """ condition: OrganizationInvitationCondition - """ - Only read the first `n` values of the set. - """ + """Only read the first `n` values of the set.""" first: Int - """ - Only read the last `n` values of the set. - """ + """Only read the last `n` values of the set.""" last: Int """ @@ -713,9 +681,7 @@ type Organization { """ offset: Int - """ - The method to use when ordering `OrganizationInvitation`. - """ + """The method to use when ordering `OrganizationInvitation`.""" orderBy: [OrganizationInvitationsOrderBy!] = [PRIMARY_KEY_ASC] ): OrganizationInvitationsConnection! @@ -723,14 +689,10 @@ type Organization { Reads and enables pagination through a set of `OrganizationMembership`. """ organizationMemberships( - """ - Read all values in the set after (below) this cursor. - """ + """Read all values in the set after (below) this cursor.""" after: Cursor - """ - Read all values in the set before (above) this cursor. - """ + """Read all values in the set before (above) this cursor.""" before: Cursor """ @@ -738,14 +700,10 @@ type Organization { """ condition: OrganizationMembershipCondition - """ - Only read the first `n` values of the set. - """ + """Only read the first `n` values of the set.""" first: Int - """ - Only read the last `n` values of the set. - """ + """Only read the last `n` values of the set.""" last: Int """ @@ -754,9 +712,7 @@ type Organization { """ offset: Int - """ - The method to use when ordering `OrganizationMembership`. - """ + """The method to use when ordering `OrganizationMembership`.""" orderBy: [OrganizationMembershipsOrderBy!] = [PRIMARY_KEY_ASC] ): OrganizationMembershipsConnection! slug: String! @@ -767,14 +723,10 @@ A condition to be used against `Organization` object types. All fields are tested for equality and combined with a logical ‘and.’ """ input OrganizationCondition { - """ - Checks for equality with the object’s `id` field. - """ + """Checks for equality with the object’s `id` field.""" id: UUID - """ - Checks for equality with the object’s `slug` field. - """ + """Checks for equality with the object’s `slug` field.""" slug: String } @@ -801,39 +753,27 @@ A condition to be used against `OrganizationInvitation` object types. All fields are tested for equality and combined with a logical ‘and.’ """ input OrganizationInvitationCondition { - """ - Checks for equality with the object’s `id` field. - """ + """Checks for equality with the object’s `id` field.""" id: UUID - """ - Checks for equality with the object’s `organizationId` field. - """ + """Checks for equality with the object’s `organizationId` field.""" organizationId: UUID - """ - Checks for equality with the object’s `userId` field. - """ + """Checks for equality with the object’s `userId` field.""" userId: UUID } -""" -A connection to a list of `OrganizationInvitation` values. -""" +"""A connection to a list of `OrganizationInvitation` values.""" type OrganizationInvitationsConnection { """ A list of edges which contains the `OrganizationInvitation` and cursor to aid in pagination. """ edges: [OrganizationInvitationsEdge!]! - """ - A list of `OrganizationInvitation` objects. - """ + """A list of `OrganizationInvitation` objects.""" nodes: [OrganizationInvitation!]! - """ - Information to aid in pagination. - """ + """Information to aid in pagination.""" pageInfo: PageInfo! """ @@ -842,24 +782,16 @@ type OrganizationInvitationsConnection { totalCount: Int! } -""" -A `OrganizationInvitation` edge in the connection. -""" +"""A `OrganizationInvitation` edge in the connection.""" type OrganizationInvitationsEdge { - """ - A cursor for use in pagination. - """ + """A cursor for use in pagination.""" cursor: Cursor - """ - The `OrganizationInvitation` at the end of the edge. - """ + """The `OrganizationInvitation` at the end of the edge.""" node: OrganizationInvitation! } -""" -Methods to use when ordering `OrganizationInvitation`. -""" +"""Methods to use when ordering `OrganizationInvitation`.""" enum OrganizationInvitationsOrderBy { ID_ASC ID_DESC @@ -896,39 +828,27 @@ A condition to be used against `OrganizationMembership` object types. All fields are tested for equality and combined with a logical ‘and.’ """ input OrganizationMembershipCondition { - """ - Checks for equality with the object’s `id` field. - """ + """Checks for equality with the object’s `id` field.""" id: UUID - """ - Checks for equality with the object’s `organizationId` field. - """ + """Checks for equality with the object’s `organizationId` field.""" organizationId: UUID - """ - Checks for equality with the object’s `userId` field. - """ + """Checks for equality with the object’s `userId` field.""" userId: UUID } -""" -A connection to a list of `OrganizationMembership` values. -""" +"""A connection to a list of `OrganizationMembership` values.""" type OrganizationMembershipsConnection { """ A list of edges which contains the `OrganizationMembership` and cursor to aid in pagination. """ edges: [OrganizationMembershipsEdge!]! - """ - A list of `OrganizationMembership` objects. - """ + """A list of `OrganizationMembership` objects.""" nodes: [OrganizationMembership!]! - """ - Information to aid in pagination. - """ + """Information to aid in pagination.""" pageInfo: PageInfo! """ @@ -937,24 +857,16 @@ type OrganizationMembershipsConnection { totalCount: Int! } -""" -A `OrganizationMembership` edge in the connection. -""" +"""A `OrganizationMembership` edge in the connection.""" type OrganizationMembershipsEdge { - """ - A cursor for use in pagination. - """ + """A cursor for use in pagination.""" cursor: Cursor - """ - The `OrganizationMembership` at the end of the edge. - """ + """The `OrganizationMembership` at the end of the edge.""" node: OrganizationMembership! } -""" -Methods to use when ordering `OrganizationMembership`. -""" +"""Methods to use when ordering `OrganizationMembership`.""" enum OrganizationMembershipsOrderBy { ID_ASC ID_DESC @@ -977,49 +889,33 @@ input OrganizationPatch { slug: String } -""" -A connection to a list of `Organization` values. -""" +"""A connection to a list of `Organization` values.""" type OrganizationsConnection { """ A list of edges which contains the `Organization` and cursor to aid in pagination. """ edges: [OrganizationsEdge!]! - """ - A list of `Organization` objects. - """ + """A list of `Organization` objects.""" nodes: [Organization!]! - """ - Information to aid in pagination. - """ + """Information to aid in pagination.""" pageInfo: PageInfo! - """ - The count of *all* `Organization` you could get from the connection. - """ + """The count of *all* `Organization` you could get from the connection.""" totalCount: Int! } -""" -A `Organization` edge in the connection. -""" +"""A `Organization` edge in the connection.""" type OrganizationsEdge { - """ - A cursor for use in pagination. - """ + """A cursor for use in pagination.""" cursor: Cursor - """ - The `Organization` at the end of the edge. - """ + """The `Organization` at the end of the edge.""" node: Organization! } -""" -Methods to use when ordering `Organization`. -""" +"""Methods to use when ordering `Organization`.""" enum OrganizationsOrderBy { ID_ASC ID_DESC @@ -1030,38 +926,24 @@ enum OrganizationsOrderBy { SLUG_DESC } -""" -Information about pagination in a connection. -""" +"""Information about pagination in a connection.""" type PageInfo { - """ - When paginating forwards, the cursor to continue. - """ + """When paginating forwards, the cursor to continue.""" endCursor: Cursor - """ - When paginating forwards, are there more items? - """ + """When paginating forwards, are there more items?""" hasNextPage: Boolean! - """ - When paginating backwards, are there more items? - """ + """When paginating backwards, are there more items?""" hasPreviousPage: Boolean! - """ - When paginating backwards, the cursor to continue. - """ + """When paginating backwards, the cursor to continue.""" startCursor: Cursor } -""" -The root query type which gives access points into the data universe. -""" +"""The root query type which gives access points into the data universe.""" type Query { - """ - The currently logged in user (or null if not logged in). - """ + """The currently logged in user (or null if not logged in).""" currentUser: User organization(id: UUID!): Organization organizationBySlug(slug: String!): Organization @@ -1077,14 +959,10 @@ type Query { Reads and enables pagination through a set of `OrganizationInvitation`. """ organizationInvitations( - """ - Read all values in the set after (below) this cursor. - """ + """Read all values in the set after (below) this cursor.""" after: Cursor - """ - Read all values in the set before (above) this cursor. - """ + """Read all values in the set before (above) this cursor.""" before: Cursor """ @@ -1092,14 +970,10 @@ type Query { """ condition: OrganizationInvitationCondition - """ - Only read the first `n` values of the set. - """ + """Only read the first `n` values of the set.""" first: Int - """ - Only read the last `n` values of the set. - """ + """Only read the last `n` values of the set.""" last: Int """ @@ -1108,25 +982,17 @@ type Query { """ offset: Int - """ - The method to use when ordering `OrganizationInvitation`. - """ + """The method to use when ordering `OrganizationInvitation`.""" orderBy: [OrganizationInvitationsOrderBy!] = [PRIMARY_KEY_ASC] ): OrganizationInvitationsConnection organizationMembership(id: UUID!): OrganizationMembership - """ - Reads and enables pagination through a set of `Organization`. - """ + """Reads and enables pagination through a set of `Organization`.""" organizations( - """ - Read all values in the set after (below) this cursor. - """ + """Read all values in the set after (below) this cursor.""" after: Cursor - """ - Read all values in the set before (above) this cursor. - """ + """Read all values in the set before (above) this cursor.""" before: Cursor """ @@ -1134,14 +1000,10 @@ type Query { """ condition: OrganizationCondition - """ - Only read the first `n` values of the set. - """ + """Only read the first `n` values of the set.""" first: Int - """ - Only read the last `n` values of the set. - """ + """Only read the last `n` values of the set.""" last: Int """ @@ -1150,9 +1012,7 @@ type Query { """ offset: Int - """ - The method to use when ordering `Organization`. - """ + """The method to use when ordering `Organization`.""" orderBy: [OrganizationsOrderBy!] = [PRIMARY_KEY_ASC] ): OrganizationsConnection user(id: UUID!): User @@ -1173,9 +1033,7 @@ type RegisterPayload { user: User! } -""" -All input for the `removeFromOrganization` mutation. -""" +"""All input for the `removeFromOrganization` mutation.""" input RemoveFromOrganizationInput { """ An arbitrary string value with no semantic meaning. Will be included in the @@ -1186,9 +1044,7 @@ input RemoveFromOrganizationInput { userId: UUID! } -""" -The output of our `removeFromOrganization` mutation. -""" +"""The output of our `removeFromOrganization` mutation.""" type RemoveFromOrganizationPayload { """ The exact same `clientMutationId` that was provided in the mutation input, @@ -1202,9 +1058,7 @@ type RemoveFromOrganizationPayload { query: Query } -""" -All input for the `requestAccountDeletion` mutation. -""" +"""All input for the `requestAccountDeletion` mutation.""" input RequestAccountDeletionInput { """ An arbitrary string value with no semantic meaning. Will be included in the @@ -1213,9 +1067,7 @@ input RequestAccountDeletionInput { clientMutationId: String } -""" -The output of our `requestAccountDeletion` mutation. -""" +"""The output of our `requestAccountDeletion` mutation.""" type RequestAccountDeletionPayload { """ The exact same `clientMutationId` that was provided in the mutation input, @@ -1230,9 +1082,7 @@ type RequestAccountDeletionPayload { success: Boolean } -""" -All input for the `resendEmailVerificationCode` mutation. -""" +"""All input for the `resendEmailVerificationCode` mutation.""" input ResendEmailVerificationCodeInput { """ An arbitrary string value with no semantic meaning. Will be included in the @@ -1242,9 +1092,7 @@ input ResendEmailVerificationCodeInput { emailId: UUID! } -""" -The output of our `resendEmailVerificationCode` mutation. -""" +"""The output of our `resendEmailVerificationCode` mutation.""" type ResendEmailVerificationCodePayload { """ The exact same `clientMutationId` that was provided in the mutation input, @@ -1259,9 +1107,7 @@ type ResendEmailVerificationCodePayload { success: Boolean } -""" -All input for the `resetPassword` mutation. -""" +"""All input for the `resetPassword` mutation.""" input ResetPasswordInput { """ An arbitrary string value with no semantic meaning. Will be included in the @@ -1273,9 +1119,7 @@ input ResetPasswordInput { userId: UUID! } -""" -The output of our `resetPassword` mutation. -""" +"""The output of our `resetPassword` mutation.""" type ResetPasswordPayload { """ The exact same `clientMutationId` that was provided in the mutation input, @@ -1294,15 +1138,11 @@ type ResetPasswordPayload { The root subscription type: contains realtime events you can subscribe to with the `subscription` operation. """ type Subscription { - """ - Triggered when the logged in user's record is updated in some way. - """ + """Triggered when the logged in user's record is updated in some way.""" currentUserUpdated: UserSubscriptionPayload } -""" -All input for the `transferOrganizationBillingContact` mutation. -""" +"""All input for the `transferOrganizationBillingContact` mutation.""" input TransferOrganizationBillingContactInput { """ An arbitrary string value with no semantic meaning. Will be included in the @@ -1313,9 +1153,7 @@ input TransferOrganizationBillingContactInput { userId: UUID! } -""" -The output of our `transferOrganizationBillingContact` mutation. -""" +"""The output of our `transferOrganizationBillingContact` mutation.""" type TransferOrganizationBillingContactPayload { """ The exact same `clientMutationId` that was provided in the mutation input, @@ -1324,13 +1162,9 @@ type TransferOrganizationBillingContactPayload { clientMutationId: String organization: Organization - """ - An edge for our `Organization`. May be used by Relay 1. - """ + """An edge for our `Organization`. May be used by Relay 1.""" organizationEdge( - """ - The method to use when ordering `Organization`. - """ + """The method to use when ordering `Organization`.""" orderBy: [OrganizationsOrderBy!] = [PRIMARY_KEY_ASC] ): OrganizationsEdge @@ -1340,9 +1174,7 @@ type TransferOrganizationBillingContactPayload { query: Query } -""" -All input for the `transferOrganizationOwnership` mutation. -""" +"""All input for the `transferOrganizationOwnership` mutation.""" input TransferOrganizationOwnershipInput { """ An arbitrary string value with no semantic meaning. Will be included in the @@ -1353,9 +1185,7 @@ input TransferOrganizationOwnershipInput { userId: UUID! } -""" -The output of our `transferOrganizationOwnership` mutation. -""" +"""The output of our `transferOrganizationOwnership` mutation.""" type TransferOrganizationOwnershipPayload { """ The exact same `clientMutationId` that was provided in the mutation input, @@ -1364,13 +1194,9 @@ type TransferOrganizationOwnershipPayload { clientMutationId: String organization: Organization - """ - An edge for our `Organization`. May be used by Relay 1. - """ + """An edge for our `Organization`. May be used by Relay 1.""" organizationEdge( - """ - The method to use when ordering `Organization`. - """ + """The method to use when ordering `Organization`.""" orderBy: [OrganizationsOrderBy!] = [PRIMARY_KEY_ASC] ): OrganizationsEdge @@ -1380,9 +1206,7 @@ type TransferOrganizationOwnershipPayload { query: Query } -""" -All input for the `updateOrganization` mutation. -""" +"""All input for the `updateOrganization` mutation.""" input UpdateOrganizationInput { """ An arbitrary string value with no semantic meaning. Will be included in the @@ -1397,9 +1221,7 @@ input UpdateOrganizationInput { patch: OrganizationPatch! } -""" -The output of our update `Organization` mutation. -""" +"""The output of our update `Organization` mutation.""" type UpdateOrganizationPayload { """ The exact same `clientMutationId` that was provided in the mutation input, @@ -1407,18 +1229,12 @@ type UpdateOrganizationPayload { """ clientMutationId: String - """ - The `Organization` that was updated by this mutation. - """ + """The `Organization` that was updated by this mutation.""" organization: Organization - """ - An edge for our `Organization`. May be used by Relay 1. - """ + """An edge for our `Organization`. May be used by Relay 1.""" organizationEdge( - """ - The method to use when ordering `Organization`. - """ + """The method to use when ordering `Organization`.""" orderBy: [OrganizationsOrderBy!] = [PRIMARY_KEY_ASC] ): OrganizationsEdge @@ -1428,9 +1244,7 @@ type UpdateOrganizationPayload { query: Query } -""" -All input for the `updateUser` mutation. -""" +"""All input for the `updateUser` mutation.""" input UpdateUserInput { """ An arbitrary string value with no semantic meaning. Will be included in the @@ -1438,9 +1252,7 @@ input UpdateUserInput { """ clientMutationId: String - """ - Unique identifier for the user. - """ + """Unique identifier for the user.""" id: UUID! """ @@ -1449,9 +1261,7 @@ input UpdateUserInput { patch: UserPatch! } -""" -The output of our update `User` mutation. -""" +"""The output of our update `User` mutation.""" type UpdateUserPayload { """ The exact same `clientMutationId` that was provided in the mutation input, @@ -1464,61 +1274,41 @@ type UpdateUserPayload { """ query: Query - """ - The `User` that was updated by this mutation. - """ + """The `User` that was updated by this mutation.""" user: User - """ - An edge for our `User`. May be used by Relay 1. - """ + """An edge for our `User`. May be used by Relay 1.""" userEdge( - """ - The method to use when ordering `User`. - """ + """The method to use when ordering `User`.""" orderBy: [UsersOrderBy!] = [PRIMARY_KEY_ASC] ): UsersEdge } -""" -A user who can log in to the application. -""" +"""A user who can log in to the application.""" type User { - """ - Optional avatar URL. - """ + """Optional avatar URL.""" avatarUrl: String createdAt: Datetime! hasPassword: Boolean - """ - Unique identifier for the user. - """ + """Unique identifier for the user.""" id: UUID! - """ - If true, the user has elevated privileges. - """ + """If true, the user has elevated privileges.""" isAdmin: Boolean! isVerified: Boolean! - """ - Public-facing name (or pseudonym) of the user. - """ + """Public-facing name (or pseudonym) of the user.""" name: String """ Reads and enables pagination through a set of `OrganizationInvitation`. """ organizationInvitations( - """ - Read all values in the set after (below) this cursor. - """ + """Read all values in the set after (below) this cursor.""" after: Cursor - """ - Read all values in the set before (above) this cursor. - """ + """Read all values in the set before (above) this cursor.""" before: Cursor """ @@ -1526,14 +1316,10 @@ type User { """ condition: OrganizationInvitationCondition - """ - Only read the first `n` values of the set. - """ + """Only read the first `n` values of the set.""" first: Int - """ - Only read the last `n` values of the set. - """ + """Only read the last `n` values of the set.""" last: Int """ @@ -1542,9 +1328,7 @@ type User { """ offset: Int - """ - The method to use when ordering `OrganizationInvitation`. - """ + """The method to use when ordering `OrganizationInvitation`.""" orderBy: [OrganizationInvitationsOrderBy!] = [PRIMARY_KEY_ASC] ): OrganizationInvitationsConnection! @@ -1552,14 +1336,10 @@ type User { Reads and enables pagination through a set of `OrganizationMembership`. """ organizationMemberships( - """ - Read all values in the set after (below) this cursor. - """ + """Read all values in the set after (below) this cursor.""" after: Cursor - """ - Read all values in the set before (above) this cursor. - """ + """Read all values in the set before (above) this cursor.""" before: Cursor """ @@ -1567,14 +1347,10 @@ type User { """ condition: OrganizationMembershipCondition - """ - Only read the first `n` values of the set. - """ + """Only read the first `n` values of the set.""" first: Int - """ - Only read the last `n` values of the set. - """ + """Only read the last `n` values of the set.""" last: Int """ @@ -1583,50 +1359,34 @@ type User { """ offset: Int - """ - The method to use when ordering `OrganizationMembership`. - """ + """The method to use when ordering `OrganizationMembership`.""" orderBy: [OrganizationMembershipsOrderBy!] = [PRIMARY_KEY_ASC] ): OrganizationMembershipsConnection! updatedAt: Datetime! - """ - Reads and enables pagination through a set of `UserAuthentication`. - """ + """Reads and enables pagination through a set of `UserAuthentication`.""" userAuthenticationsList( """ A condition to be used in determining which values should be returned by the collection. """ condition: UserAuthenticationCondition - """ - Only read the first `n` values of the set. - """ + """Only read the first `n` values of the set.""" first: Int - """ - Skip the first `n` values. - """ + """Skip the first `n` values.""" offset: Int - """ - The method to use when ordering `UserAuthentication`. - """ + """The method to use when ordering `UserAuthentication`.""" orderBy: [UserAuthenticationsOrderBy!] ): [UserAuthentication!]! - """ - Reads and enables pagination through a set of `UserEmail`. - """ + """Reads and enables pagination through a set of `UserEmail`.""" userEmails( - """ - Read all values in the set after (below) this cursor. - """ + """Read all values in the set after (below) this cursor.""" after: Cursor - """ - Read all values in the set before (above) this cursor. - """ + """Read all values in the set before (above) this cursor.""" before: Cursor """ @@ -1634,14 +1394,10 @@ type User { """ condition: UserEmailCondition - """ - Only read the first `n` values of the set. - """ + """Only read the first `n` values of the set.""" first: Int - """ - Only read the last `n` values of the set. - """ + """Only read the last `n` values of the set.""" last: Int """ @@ -1650,15 +1406,11 @@ type User { """ offset: Int - """ - The method to use when ordering `UserEmail`. - """ + """The method to use when ordering `UserEmail`.""" orderBy: [UserEmailsOrderBy!] = [PRIMARY_KEY_ASC] ): UserEmailsConnection! - """ - Public-facing username (or 'handle') of the user. - """ + """Public-facing username (or 'handle') of the user.""" username: String! } @@ -1669,20 +1421,14 @@ type UserAuthentication { createdAt: Datetime! id: UUID! - """ - A unique identifier for the user within the login service. - """ + """A unique identifier for the user within the login service.""" identifier: String! - """ - The login service used, e.g. `twitter` or `github`. - """ + """The login service used, e.g. `twitter` or `github`.""" service: String! updatedAt: Datetime! - """ - Reads a single `User` that is related to this `UserAuthentication`. - """ + """Reads a single `User` that is related to this `UserAuthentication`.""" user: User userId: UUID! } @@ -1692,25 +1438,17 @@ A condition to be used against `UserAuthentication` object types. All fields are tested for equality and combined with a logical ‘and.’ """ input UserAuthenticationCondition { - """ - Checks for equality with the object’s `id` field. - """ + """Checks for equality with the object’s `id` field.""" id: UUID - """ - Checks for equality with the object’s `service` field. - """ + """Checks for equality with the object’s `service` field.""" service: String - """ - Checks for equality with the object’s `userId` field. - """ + """Checks for equality with the object’s `userId` field.""" userId: UUID } -""" -Methods to use when ordering `UserAuthentication`. -""" +"""Methods to use when ordering `UserAuthentication`.""" enum UserAuthenticationsOrderBy { ID_ASC ID_DESC @@ -1723,15 +1461,11 @@ enum UserAuthenticationsOrderBy { USER_ID_DESC } -""" -Information about a user's email address. -""" +"""Information about a user's email address.""" type UserEmail { createdAt: Datetime! - """ - The users email address, in `a@b.c` format. - """ + """The users email address, in `a@b.c` format.""" email: String! id: UUID! isPrimary: Boolean! @@ -1743,9 +1477,7 @@ type UserEmail { isVerified: Boolean! updatedAt: Datetime! - """ - Reads a single `User` that is related to this `UserEmail`. - """ + """Reads a single `User` that is related to this `UserEmail`.""" user: User userId: UUID! } @@ -1755,75 +1487,49 @@ A condition to be used against `UserEmail` object types. All fields are tested for equality and combined with a logical ‘and.’ """ input UserEmailCondition { - """ - Checks for equality with the object’s `id` field. - """ + """Checks for equality with the object’s `id` field.""" id: UUID - """ - Checks for equality with the object’s `isPrimary` field. - """ + """Checks for equality with the object’s `isPrimary` field.""" isPrimary: Boolean - """ - Checks for equality with the object’s `userId` field. - """ + """Checks for equality with the object’s `userId` field.""" userId: UUID } -""" -An input for mutations affecting `UserEmail` -""" +"""An input for mutations affecting `UserEmail`""" input UserEmailInput { - """ - The users email address, in `a@b.c` format. - """ + """The users email address, in `a@b.c` format.""" email: String! } -""" -A connection to a list of `UserEmail` values. -""" +"""A connection to a list of `UserEmail` values.""" type UserEmailsConnection { """ A list of edges which contains the `UserEmail` and cursor to aid in pagination. """ edges: [UserEmailsEdge!]! - """ - A list of `UserEmail` objects. - """ + """A list of `UserEmail` objects.""" nodes: [UserEmail!]! - """ - Information to aid in pagination. - """ + """Information to aid in pagination.""" pageInfo: PageInfo! - """ - The count of *all* `UserEmail` you could get from the connection. - """ + """The count of *all* `UserEmail` you could get from the connection.""" totalCount: Int! } -""" -A `UserEmail` edge in the connection. -""" +"""A `UserEmail` edge in the connection.""" type UserEmailsEdge { - """ - A cursor for use in pagination. - """ + """A cursor for use in pagination.""" cursor: Cursor - """ - The `UserEmail` at the end of the edge. - """ + """The `UserEmail` at the end of the edge.""" node: UserEmail! } -""" -Methods to use when ordering `UserEmail`. -""" +"""Methods to use when ordering `UserEmail`.""" enum UserEmailsOrderBy { ID_ASC ID_DESC @@ -1836,44 +1542,28 @@ enum UserEmailsOrderBy { USER_ID_DESC } -""" -Represents an update to a `User`. Fields that are set will be updated. -""" +"""Represents an update to a `User`. Fields that are set will be updated.""" input UserPatch { - """ - Optional avatar URL. - """ + """Optional avatar URL.""" avatarUrl: String - """ - Public-facing name (or pseudonym) of the user. - """ + """Public-facing name (or pseudonym) of the user.""" name: String - """ - Public-facing username (or 'handle') of the user. - """ + """Public-facing username (or 'handle') of the user.""" username: String } -""" -A `User` edge in the connection. -""" +"""A `User` edge in the connection.""" type UsersEdge { - """ - A cursor for use in pagination. - """ + """A cursor for use in pagination.""" cursor: Cursor - """ - The `User` at the end of the edge. - """ + """The `User` at the end of the edge.""" node: User! } -""" -Methods to use when ordering `User`. -""" +"""Methods to use when ordering `User`.""" enum UsersOrderBy { ID_ASC ID_DESC @@ -1894,9 +1584,7 @@ A universally unique identifier as defined by [RFC 4122](https://tools.ietf.org/ """ scalar UUID -""" -All input for the `verifyEmail` mutation. -""" +"""All input for the `verifyEmail` mutation.""" input VerifyEmailInput { """ An arbitrary string value with no semantic meaning. Will be included in the @@ -1907,9 +1595,7 @@ input VerifyEmailInput { userEmailId: UUID! } -""" -The output of our `verifyEmail` mutation. -""" +"""The output of our `verifyEmail` mutation.""" type VerifyEmailPayload { """ The exact same `clientMutationId` that was provided in the mutation input, From 030909077118ab49f52eb62c0481bf3ad81932da Mon Sep 17 00:00:00 2001 From: David Baumgold Date: Sat, 18 Apr 2020 15:35:09 +0200 Subject: [PATCH 17/34] use typescript enum --- .../src/plugins/CreateUploadUrlPlugin.ts | 34 +++++++++++-------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/@app/server/src/plugins/CreateUploadUrlPlugin.ts b/@app/server/src/plugins/CreateUploadUrlPlugin.ts index f39b0433..9e54ab0f 100644 --- a/@app/server/src/plugins/CreateUploadUrlPlugin.ts +++ b/@app/server/src/plugins/CreateUploadUrlPlugin.ts @@ -5,16 +5,21 @@ import uuidv4 from "uuid/v4"; import { OurGraphQLContext } from "../middleware/installPostGraphile"; -const ALLOWED_CONTENT_TYPE_ENUM_MAPPING = { - IMAGE_APNG: "image/apng", - IMAGE_BMP: "image/bmp", - IMAGE_GIF: "image/gif", - IMAGE_JPEG: "image/jpeg", - IMAGE_PNG: "image/png", - IMAGE_SVG_XML: "image/svg+xml", - IMAGE_TIFF: "image/tiff", - IMAGE_WEBP: "image/webp", -}; +enum AllowedUploadContentType { + IMAGE_APNG = "image/apng", + IMAGE_BMP = "image/bmp", + IMAGE_GIF = "image/gif", + IMAGE_JPEG = "image/jpeg", + IMAGE_PNG = "image/png", + IMAGE_SVG_XML = "image/svg+xml", + IMAGE_TIFF = "image/tiff", + IMAGE_WEBP = "image/webp", +} + +interface CreateUploadUrlInput { + clientMutationId?: string; + contentType: AllowedUploadContentType; +} const CreateUploadUrlPlugin = makeExtendSchemaPlugin(() => ({ typeDefs: gql` @@ -93,11 +98,12 @@ const CreateUploadUrlPlugin = makeExtendSchemaPlugin(() => ({ Mutation: { async createUploadUrl( _query, - args, + args: { input: CreateUploadUrlInput }, context: OurGraphQLContext, _resolveInfo ) { const { rootPgPool } = context; + const { input } = args; const { rows: [user], } = await rootPgPool.query( @@ -105,8 +111,7 @@ const CreateUploadUrlPlugin = makeExtendSchemaPlugin(() => ({ ); const username: string = user.username; - const contentType = - ALLOWED_CONTENT_TYPE_ENUM_MAPPING[args.input.contentType]; + const contentType: string = AllowedUploadContentType[input.contentType]; const s3 = new aws.S3({ region: awsRegion, signatureVersion: "v4", @@ -121,8 +126,7 @@ const CreateUploadUrlPlugin = makeExtendSchemaPlugin(() => ({ }; const signedUrl = await s3.getSignedUrlPromise("putObject", params); return { - clientMutationId: args.clientMutationId, - // what to do about `query` ? + clientMutationId: input.clientMutationId, uploadUrl: signedUrl, }; }, From f28ebc26fed69a60468f36ebe555a09bd5639ef3 Mon Sep 17 00:00:00 2001 From: David Baumgold Date: Sat, 18 Apr 2020 15:37:42 +0200 Subject: [PATCH 18/34] prettier --- @app/server/src/middleware/installPostGraphile.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/@app/server/src/middleware/installPostGraphile.ts b/@app/server/src/middleware/installPostGraphile.ts index 915b43ff..54ba5940 100644 --- a/@app/server/src/middleware/installPostGraphile.ts +++ b/@app/server/src/middleware/installPostGraphile.ts @@ -15,12 +15,12 @@ import { import { makePgSmartTagsFromFilePlugin } from "postgraphile/plugins"; import { getHttpServer, getWebsocketMiddlewares } from "../app"; +import CreateUploadUrlPlugin from "../plugins/CreateUploadUrlPlugin"; import OrdersPlugin from "../plugins/Orders"; import PassportLoginPlugin from "../plugins/PassportLoginPlugin"; import PrimaryKeyMutationsOnlyPlugin from "../plugins/PrimaryKeyMutationsOnlyPlugin"; import RemoveQueryQueryPlugin from "../plugins/RemoveQueryQueryPlugin"; import SubscriptionsPlugin from "../plugins/SubscriptionsPlugin"; -import CreateUploadUrlPlugin from "../plugins/CreateUploadUrlPlugin"; import handleErrors from "../utils/handleErrors"; import { getAuthPgPool, getRootPgPool } from "./installDatabasePools"; @@ -262,7 +262,7 @@ export function getPostGraphileOptions({ // Use this to tell Passport.js we're logged in login: (user: any) => new Promise((resolve, reject) => { - req.login(user, err => (err ? reject(err) : resolve())); + req.login(user, (err) => (err ? reject(err) : resolve())); }), logout: () => { From 76807dc8f081e239be2a619902f30c7b7e291184 Mon Sep 17 00:00:00 2001 From: David Baumgold Date: Sat, 18 Apr 2020 15:41:04 +0200 Subject: [PATCH 19/34] missed a backtick --- @app/server/src/plugins/CreateUploadUrlPlugin.ts | 2 +- data/schema.graphql | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/@app/server/src/plugins/CreateUploadUrlPlugin.ts b/@app/server/src/plugins/CreateUploadUrlPlugin.ts index 9e54ab0f..a6178619 100644 --- a/@app/server/src/plugins/CreateUploadUrlPlugin.ts +++ b/@app/server/src/plugins/CreateUploadUrlPlugin.ts @@ -63,7 +63,7 @@ const CreateUploadUrlPlugin = makeExtendSchemaPlugin(() => ({ } """ - The output of our \`createUploadUrl mutation. + The output of our \`createUploadUrl\` mutation. """ type CreateUploadUrlPayload { """ diff --git a/data/schema.graphql b/data/schema.graphql index d449a0ca..00ec324a 100644 --- a/data/schema.graphql +++ b/data/schema.graphql @@ -149,7 +149,7 @@ input CreateUploadUrlInput { contentType: AllowedUploadContentType! } -"""The output of our `createUploadUrl mutation.""" +"""The output of our `createUploadUrl` mutation.""" type CreateUploadUrlPayload { """ The exact same `clientMutationId` that was provided in the mutation input, From 62c7e83b8c8dd024588ce9b291917e1721714507 Mon Sep 17 00:00:00 2001 From: David Baumgold Date: Mon, 20 Apr 2020 11:40:47 +0200 Subject: [PATCH 20/34] Add dependency on aws-sdk to server/package.json --- @app/server/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/@app/server/package.json b/@app/server/package.json index bbc84e51..b5e6dbb8 100644 --- a/@app/server/package.json +++ b/@app/server/package.json @@ -24,6 +24,7 @@ "@types/pg": "^7.14.1", "@types/redis": "^2.8.18", "@types/uuid": "^7.0.2", + "aws-sdk": "^2.645.0", "body-parser": "^1.19.0", "chalk": "^4.0.0", "connect-pg-simple": "^6.1.0", From 8626b2f98f6f093b7075892550d7b060d8d7a097 Mon Sep 17 00:00:00 2001 From: David Baumgold Date: Mon, 20 Apr 2020 12:47:31 +0200 Subject: [PATCH 21/34] respond to review comments --- .../src/plugins/CreateUploadUrlPlugin.ts | 92 +++++++++++++++---- 1 file changed, 73 insertions(+), 19 deletions(-) diff --git a/@app/server/src/plugins/CreateUploadUrlPlugin.ts b/@app/server/src/plugins/CreateUploadUrlPlugin.ts index a6178619..66d6ceae 100644 --- a/@app/server/src/plugins/CreateUploadUrlPlugin.ts +++ b/@app/server/src/plugins/CreateUploadUrlPlugin.ts @@ -1,6 +1,7 @@ import { awsRegion, uploadBucket } from "@app/config"; import * as aws from "aws-sdk"; import { gql, makeExtendSchemaPlugin } from "graphile-utils"; +import { Pool } from "pg"; import uuidv4 from "uuid/v4"; import { OurGraphQLContext } from "../middleware/installPostGraphile"; @@ -21,34 +22,79 @@ interface CreateUploadUrlInput { contentType: AllowedUploadContentType; } +/** The minimal set of information that this plugin needs to know about users. */ +interface User { + id: string; + isVerified: boolean; +} + +async function getCurrentUser(pool: Pool): Promise { + await pool.query("SAVEPOINT"); + try { + const { + rows: [row], + } = await pool.query( + "select id, is_verified from app_public.users where id = app_public.current_user_id()" + ); + if (!row) { + return null; + } + return { + id: row.id, + isVerified: row.is_verified, + }; + } catch (err) { + await pool.query("ROLLBACK TO SAVEPOINT"); + throw err; + } finally { + await pool.query("RELEASE SAVEPOINT"); + } +} + const CreateUploadUrlPlugin = makeExtendSchemaPlugin(() => ({ typeDefs: gql` """ The set of content types that we allow users to upload. """ enum AllowedUploadContentType { - "image/apng" + """ + image/apng + """ IMAGE_APNG - "image/bmp" + """ + image/bmp + """ IMAGE_BMP - "image/gif" + """ + image/gif + """ IMAGE_GIF - "image/jpeg" + """ + image/jpeg + """ IMAGE_JPEG - "image/png" + """ + image/png + """ IMAGE_PNG - "image/svg+xml" + """ + image/svg+xml + """ IMAGE_SVG_XML - "image/tiff" + """ + image/tiff + """ IMAGE_TIFF - "image/webp" + """ + image/webp + """ IMAGE_WEBP } """ All input for the \`createUploadUrl\` mutation. """ - input CreateUploadUrlInput { + input CreateUploadUrlInput @scope(isMutationInput: true) { """ An arbitrary string value with no semantic meaning. Will be included in the payload verbatim. May be used to track mutations by the client. @@ -65,7 +111,7 @@ const CreateUploadUrlPlugin = makeExtendSchemaPlugin(() => ({ """ The output of our \`createUploadUrl\` mutation. """ - type CreateUploadUrlPayload { + type CreateUploadUrlPayload @scope(isMutationPayload: true) { """ The exact same \`clientMutationId\` that was provided in the mutation input, unchanged and unused. May be used by a client to track mutations. @@ -102,15 +148,23 @@ const CreateUploadUrlPlugin = makeExtendSchemaPlugin(() => ({ context: OurGraphQLContext, _resolveInfo ) { - const { rootPgPool } = context; - const { input } = args; - const { - rows: [user], - } = await rootPgPool.query( - `select username from app_public.users where id = app_public.current_user_id()` - ); - const username: string = user.username; + const user = await getCurrentUser(context.rootPgPool); + if (!user) { + const err = new Error("Login required"); + // @ts-ignore + err.code = "LOGIN"; + throw err; + } + + if (!user.isVerified) { + const err = new Error("Only verified users may upload files"); + // @ts-ignore + err.code = "DNIED"; + throw err; + } + + const { input } = args; const contentType: string = AllowedUploadContentType[input.contentType]; const s3 = new aws.S3({ region: awsRegion, @@ -120,7 +174,7 @@ const CreateUploadUrlPlugin = makeExtendSchemaPlugin(() => ({ Bucket: uploadBucket, ContentType: contentType, // randomly generated filename, nested under username directory - Key: `${username}/${uuidv4()}`, + Key: `${user.id}/${uuidv4()}`, Expires: 300, // signed URL will expire in 5 minutes ACL: "public-read", // uploaded file will be publicly readable }; From 8442139d89d2189a653d0ff62cb1d383958acfe6 Mon Sep 17 00:00:00 2001 From: David Baumgold Date: Mon, 20 Apr 2020 12:58:21 +0200 Subject: [PATCH 22/34] remove unneeded query field from payload --- @app/server/src/plugins/CreateUploadUrlPlugin.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/@app/server/src/plugins/CreateUploadUrlPlugin.ts b/@app/server/src/plugins/CreateUploadUrlPlugin.ts index 66d6ceae..321ca377 100644 --- a/@app/server/src/plugins/CreateUploadUrlPlugin.ts +++ b/@app/server/src/plugins/CreateUploadUrlPlugin.ts @@ -100,6 +100,7 @@ const CreateUploadUrlPlugin = makeExtendSchemaPlugin(() => ({ payload verbatim. May be used to track mutations by the client. """ clientMutationId: String + """ You must provide the content type (or MIME type) of the content you intend to upload. For further information about content types, see @@ -118,10 +119,6 @@ const CreateUploadUrlPlugin = makeExtendSchemaPlugin(() => ({ """ clientMutationId: String - """ - Our root query field type. Allows us to run any query from our mutation payload. - """ - query: Query """ Upload content to this signed URL. """ From 8b73401a3039476eb323b41b2b1a64d86f3644bb Mon Sep 17 00:00:00 2001 From: David Baumgold Date: Mon, 20 Apr 2020 13:05:43 +0200 Subject: [PATCH 23/34] process.env.AWS_BUCKET_UPLOAD --- @app/config/src/index.ts | 2 +- @app/server/src/plugins/CreateUploadUrlPlugin.ts | 9 +++++++++ docs/error_codes.md | 1 + 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/@app/config/src/index.ts b/@app/config/src/index.ts index d0bc389a..a3dd5690 100644 --- a/@app/config/src/index.ts +++ b/@app/config/src/index.ts @@ -6,7 +6,7 @@ const packageJson = require("../../../package.json"); export const fromEmail = '"PostGraphile Starter" '; export const awsRegion = "us-east-1"; -export const uploadBucket = "my-bucket-name-here"; +export const uploadBucket = process.env.AWS_BUCKET_UPLOAD; export const projectName = packageJson.name.replace(/[-_]/g, " "); export const companyName = projectName; // For copyright ownership export const emailLegalText = diff --git a/@app/server/src/plugins/CreateUploadUrlPlugin.ts b/@app/server/src/plugins/CreateUploadUrlPlugin.ts index 321ca377..9b0959d3 100644 --- a/@app/server/src/plugins/CreateUploadUrlPlugin.ts +++ b/@app/server/src/plugins/CreateUploadUrlPlugin.ts @@ -145,6 +145,15 @@ const CreateUploadUrlPlugin = makeExtendSchemaPlugin(() => ({ context: OurGraphQLContext, _resolveInfo ) { + if (!uploadBucket) { + const err = new Error( + "Server misconfigured: missing `AWS_BUCKET_UPLOAD` envvar" + ); + // @ts-ignore + err.code = "MSCFG"; + throw err; + } + const user = await getCurrentUser(context.rootPgPool); if (!user) { diff --git a/docs/error_codes.md b/docs/error_codes.md index 83898e4b..8c55c58a 100644 --- a/docs/error_codes.md +++ b/docs/error_codes.md @@ -33,6 +33,7 @@ Rewritten, the above rules state: - DNIED: permission denied - NUNIQ: not unique (from PostgreSQL 23505) - NTFND: not found +- MSCFG: server misconfigured ## Registration From 7d3ff8bf9b7c2409b42d29e1540d3201fd1eacd8 Mon Sep 17 00:00:00 2001 From: David Baumgold Date: Sat, 9 May 2020 15:51:26 +0200 Subject: [PATCH 24/34] resolve conflict --- yarn.lock | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/yarn.lock b/yarn.lock index fafecb53..68deef5c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3174,19 +3174,6 @@ resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-1.0.1.tgz#0a851d3bd96498fa25c33ab7278ed3bd65f06c3e" integrity sha512-l42BggppR6zLmpfU6fq9HEa2oGPEI8yrSPL3GITjfRInppYFahObbIQOQK3UGxEnyQpltZLaPe75046NOZQikw== -<<<<<<< HEAD -======= -"@types/uuid@^7.0.2": - version "7.0.2" - resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-7.0.2.tgz#d680a9c596ef84abf5c4c07a32ffd66d582526f8" - integrity sha512-8Ly3zIPTnT0/8RCU6Kg/G3uTICf9sRwYOpUzSIM3503tLIKcnJPRuinHhXngJUy2MntrEf6dlpOHXJju90Qh5w== - -"@types/valid-url@1.0.2": - version "1.0.2" - resolved "https://registry.yarnpkg.com/@types/valid-url/-/valid-url-1.0.2.tgz#60fa435ce24bfd5ba107b8d2a80796aeaf3a8f45" - integrity sha1-YPpDXOJL/VuhB7jSqAeWrq86j0U= - ->>>>>>> Extend schema to allow for presigned URLs for uploading files "@types/ws@^6.0.1": version "6.0.4" resolved "https://registry.yarnpkg.com/@types/ws/-/ws-6.0.4.tgz#7797707c8acce8f76d8c34b370d4645b70421ff1" From 75475d48e66f650b6eaad00b558184f608ec9714 Mon Sep 17 00:00:00 2001 From: David Baumgold Date: Sat, 9 May 2020 15:55:43 +0200 Subject: [PATCH 25/34] uuid 8 --- @app/server/package.json | 4 +- .../src/plugins/CreateUploadUrlPlugin.ts | 2 +- yarn.lock | 74 ++++++++----------- 3 files changed, 35 insertions(+), 45 deletions(-) diff --git a/@app/server/package.json b/@app/server/package.json index b5e6dbb8..7eb4dedd 100644 --- a/@app/server/package.json +++ b/@app/server/package.json @@ -23,7 +23,7 @@ "@types/passport-github2": "^1.2.4", "@types/pg": "^7.14.1", "@types/redis": "^2.8.18", - "@types/uuid": "^7.0.2", + "@types/uuid": "^7.0.3", "aws-sdk": "^2.645.0", "body-parser": "^1.19.0", "chalk": "^4.0.0", @@ -46,7 +46,7 @@ "redis": "^3.0.2", "source-map-support": "^0.5.13", "tslib": "^1.11.1", - "uuid": "^7.0.3" + "uuid": "^8.0.0" }, "devDependencies": { "@types/node": "^13.13.4", diff --git a/@app/server/src/plugins/CreateUploadUrlPlugin.ts b/@app/server/src/plugins/CreateUploadUrlPlugin.ts index 9b0959d3..8dac6012 100644 --- a/@app/server/src/plugins/CreateUploadUrlPlugin.ts +++ b/@app/server/src/plugins/CreateUploadUrlPlugin.ts @@ -2,7 +2,7 @@ import { awsRegion, uploadBucket } from "@app/config"; import * as aws from "aws-sdk"; import { gql, makeExtendSchemaPlugin } from "graphile-utils"; import { Pool } from "pg"; -import uuidv4 from "uuid/v4"; +import { v4 as uuidv4 } from "uuid"; import { OurGraphQLContext } from "../middleware/installPostGraphile"; diff --git a/yarn.lock b/yarn.lock index 68deef5c..023df0ca 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3174,6 +3174,11 @@ resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-1.0.1.tgz#0a851d3bd96498fa25c33ab7278ed3bd65f06c3e" integrity sha512-l42BggppR6zLmpfU6fq9HEa2oGPEI8yrSPL3GITjfRInppYFahObbIQOQK3UGxEnyQpltZLaPe75046NOZQikw== +"@types/uuid@^7.0.3": + version "7.0.3" + resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-7.0.3.tgz#45cd03e98e758f8581c79c535afbd4fc27ba7ac8" + integrity sha512-PUdqTZVrNYTNcIhLHkiaYzoOIaUi5LFg/XLerAdgvwQrUCx+oSbtoBze1AMyvYbcwzUSNC+Isl58SM4Sm/6COw== + "@types/ws@^6.0.1": version "6.0.4" resolved "https://registry.yarnpkg.com/@types/ws/-/ws-6.0.4.tgz#7797707c8acce8f76d8c34b370d4645b70421ff1" @@ -4064,6 +4069,21 @@ auto-bind@~4.0.0: resolved "https://registry.yarnpkg.com/auto-bind/-/auto-bind-4.0.0.tgz#e3589fc6c2da8f7ca43ba9f84fa52a744fc997fb" integrity sha512-Hdw8qdNiqdJ8LqT0iK0sVzkFbzg6fhnQqqfWhBDxcHZvU75+B+ayzTy8x+k5Ix0Y92XOhOUlx74ps+bA6BeYMQ== +aws-sdk@^2.645.0: + version "2.673.0" + resolved "https://registry.yarnpkg.com/aws-sdk/-/aws-sdk-2.673.0.tgz#5848900c90cfd8939602019df16fdc38d2a1cdbf" + integrity sha512-OoEPqTLmA5+4uSFf/k4ZLb8cEdx+CwlzovqGf6/gKvb8VrUxe5B5/d2RGlGM777Ke9TmuFhJtTIDugpgc2jo/Q== + dependencies: + buffer "4.9.1" + events "1.1.1" + ieee754 "1.1.13" + jmespath "0.15.0" + querystring "0.2.0" + sax "1.2.1" + url "0.10.3" + uuid "3.3.2" + xml2js "0.4.19" + aws-sdk@^2.668.0: version "2.668.0" resolved "https://registry.yarnpkg.com/aws-sdk/-/aws-sdk-2.668.0.tgz#18b3e64a47f86c109586422596e53dc733117696" @@ -6133,11 +6153,6 @@ detect-indent@^5.0.0: resolved "https://registry.yarnpkg.com/detect-indent/-/detect-indent-5.0.0.tgz#3871cc0a6a002e8c3e5b3cf7f336264675f06b9d" integrity sha1-OHHMCmoALow+Wzz38zYmRnXwa50= -detect-libc@^1.0.2: - version "1.0.3" - resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b" - integrity sha1-+hN8S9aY7fVc1c0CrFWfkaTEups= - detect-newline@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-3.1.0.tgz#576f5dfc63ae1a192ff192d8ad3af6308991b651" @@ -8206,7 +8221,7 @@ humanize-ms@^1.2.1: dependencies: ms "^2.0.0" -iconv-lite@0.4.24, iconv-lite@^0.4.24, iconv-lite@^0.4.4, iconv-lite@~0.4.13: +iconv-lite@0.4.24, iconv-lite@^0.4.24, iconv-lite@~0.4.13: version "0.4.24" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== @@ -10962,15 +10977,6 @@ natural-compare@^1.4.0: resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" integrity sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc= -needle@^2.2.1: - version "2.4.1" - resolved "https://registry.yarnpkg.com/needle/-/needle-2.4.1.tgz#14af48732463d7475696f937626b1b993247a56a" - integrity sha512-x/gi6ijr4B7fwl6WYL9FwlCvRQKGlUNvnceho8wxkwXqN8jvVmmmATTmZPRRG7b/yC1eode26C2HO9jl78Du9g== - dependencies: - debug "^3.2.6" - iconv-lite "^0.4.4" - sax "^1.2.4" - negotiator@0.6.2: version "0.6.2" resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.2.tgz#feacf7ccf525a77ae9634436a64883ffeca346fb" @@ -11171,22 +11177,6 @@ node-notifier@^6.0.0: shellwords "^0.1.1" which "^1.3.1" -node-pre-gyp@*: - version "0.14.0" - resolved "https://registry.yarnpkg.com/node-pre-gyp/-/node-pre-gyp-0.14.0.tgz#9a0596533b877289bcad4e143982ca3d904ddc83" - integrity sha512-+CvDC7ZttU/sSt9rFjix/P05iS43qHCOOGzcr3Ry99bXG7VX953+vFyEuph/tfqoYu8dttBkE86JSKBO2OzcxA== - dependencies: - detect-libc "^1.0.2" - mkdirp "^0.5.1" - needle "^2.2.1" - nopt "^4.0.1" - npm-packlist "^1.1.6" - npmlog "^4.0.2" - rc "^1.2.7" - rimraf "^2.6.1" - semver "^5.3.0" - tar "^4.4.2" - node-releases@^1.1.44, node-releases@^1.1.53: version "1.1.53" resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.53.tgz#2d821bfa499ed7c5dffc5e2f28c88e78a08ee3f4" @@ -11318,7 +11308,7 @@ npm-normalize-package-bin@^1.0.0, npm-normalize-package-bin@^1.0.1: semver "^5.6.0" validate-npm-package-name "^3.0.0" -npm-packlist@^1.1.6, npm-packlist@^1.4.4: +npm-packlist@^1.4.4: version "1.4.8" resolved "https://registry.yarnpkg.com/npm-packlist/-/npm-packlist-1.4.8.tgz#56ee6cc135b9f98ad3d51c1c95da22bbb9b2ef3e" integrity sha512-5+AZgwru5IevF5ZdnFglB5wNlHG1AOOuw28WhUq8/8emhBmLv6jX5by4WJCh7lW0uSYZYS6DXqIsyZVIXRZU9A== @@ -11350,7 +11340,7 @@ npm-run-path@^4.0.0: dependencies: path-key "^3.0.0" -npmlog@^4.0.2, npmlog@^4.1.2: +npmlog@^4.1.2: version "4.1.2" resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-4.1.2.tgz#08a7f2a8bf734604779a9efa4ad5cc717abb954b" integrity sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg== @@ -13284,7 +13274,7 @@ rc-virtual-list@^1.1.0, rc-virtual-list@^1.1.2: raf "^3.4.1" rc-util "^4.8.0" -rc@^1.2.7, rc@^1.2.8: +rc@^1.2.8: version "1.2.8" resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed" integrity sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw== @@ -13931,7 +13921,7 @@ rimraf@2.6.3: dependencies: glob "^7.1.3" -rimraf@^2.5.4, rimraf@^2.6.1, rimraf@^2.6.2, rimraf@^2.6.3: +rimraf@^2.5.4, rimraf@^2.6.2, rimraf@^2.6.3: version "2.7.1" resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.7.1.tgz#35797f13a7fdadc566142c29d4f07ccad483e3ec" integrity sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w== @@ -14035,7 +14025,7 @@ sax@1.2.1: resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.1.tgz#7b8e656190b228e81a66aea748480d828cd2d37a" integrity sha1-e45lYZCyKOgaZq6nSEgNgozS03o= -sax@>=0.6.0, sax@^1.2.4, sax@~1.2.4: +sax@>=0.6.0, sax@~1.2.4: version "1.2.4" resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9" integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw== @@ -14096,7 +14086,7 @@ semver-diff@^3.1.1: dependencies: semver "^6.3.0" -"semver@2 || 3 || 4 || 5", "semver@2.x || 3.x || 4 || 5", semver@^5.3.0, semver@^5.4.1, semver@^5.5.0, semver@^5.5.1, semver@^5.6.0, semver@^5.7.0, semver@^5.7.1: +"semver@2 || 3 || 4 || 5", "semver@2.x || 3.x || 4 || 5", semver@^5.4.1, semver@^5.5.0, semver@^5.5.1, semver@^5.6.0, semver@^5.7.0, semver@^5.7.1: version "5.7.1" resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7" integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ== @@ -14887,7 +14877,7 @@ tapable@^1.0.0, tapable@^1.1.3: resolved "https://registry.yarnpkg.com/tapable/-/tapable-1.1.3.tgz#a1fccc06b58db61fd7a45da2da44f5f3a3e67ba2" integrity sha512-4WK/bYZmj8xLr+HUCODHGF1ZFzsYffasLUgEiMBY4fgtltdO6B4WJtlSbPaDTLpYTcGVwM2qLnFTICEcNxs3kA== -tar@^4.4.10, tar@^4.4.12, tar@^4.4.2, tar@^4.4.8: +tar@^4.4.10, tar@^4.4.12, tar@^4.4.8: version "4.4.13" resolved "https://registry.yarnpkg.com/tar/-/tar-4.4.13.tgz#43b364bc52888d555298637b10d60790254ab525" integrity sha512-w2VwSrBoHa5BsSyH+KxEqeQBAllHhccyMFVHtGtdMpF4W7IRWfZjFiQceJPChOeTsSDVUpER2T8FA93pr0L+QA== @@ -15653,10 +15643,10 @@ uuid@^7.0.3: resolved "https://registry.yarnpkg.com/uuid/-/uuid-7.0.3.tgz#c5c9f2c8cf25dc0a372c4df1441c41f5bd0c680b" integrity sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg== -uuid@^7.0.3: - version "7.0.3" - resolved "https://registry.yarnpkg.com/uuid/-/uuid-7.0.3.tgz#c5c9f2c8cf25dc0a372c4df1441c41f5bd0c680b" - integrity sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg== +uuid@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.0.0.tgz#bc6ccf91b5ff0ac07bbcdbf1c7c4e150db4dbb6c" + integrity sha512-jOXGuXZAWdsTH7eZLtyXMqUb9EcWMGZNbL9YcGBJl4MH4nrxHmZJhEHvyLFrkxo+28uLb/NYRcStH48fnD0Vzw== v8-compile-cache@^2.0.3: version "2.1.0" From d08e934ab2a5753dd39c027b02a3da18e4b8dc05 Mon Sep 17 00:00:00 2001 From: David Baumgold Date: Sat, 9 May 2020 15:57:25 +0200 Subject: [PATCH 26/34] match aws-sdk versions --- @app/server/package.json | 2 +- yarn.lock | 15 --------------- 2 files changed, 1 insertion(+), 16 deletions(-) diff --git a/@app/server/package.json b/@app/server/package.json index 7eb4dedd..90c3c7c1 100644 --- a/@app/server/package.json +++ b/@app/server/package.json @@ -24,7 +24,7 @@ "@types/pg": "^7.14.1", "@types/redis": "^2.8.18", "@types/uuid": "^7.0.3", - "aws-sdk": "^2.645.0", + "aws-sdk": "^2.668.0", "body-parser": "^1.19.0", "chalk": "^4.0.0", "connect-pg-simple": "^6.1.0", diff --git a/yarn.lock b/yarn.lock index 023df0ca..b8144727 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4069,21 +4069,6 @@ auto-bind@~4.0.0: resolved "https://registry.yarnpkg.com/auto-bind/-/auto-bind-4.0.0.tgz#e3589fc6c2da8f7ca43ba9f84fa52a744fc997fb" integrity sha512-Hdw8qdNiqdJ8LqT0iK0sVzkFbzg6fhnQqqfWhBDxcHZvU75+B+ayzTy8x+k5Ix0Y92XOhOUlx74ps+bA6BeYMQ== -aws-sdk@^2.645.0: - version "2.673.0" - resolved "https://registry.yarnpkg.com/aws-sdk/-/aws-sdk-2.673.0.tgz#5848900c90cfd8939602019df16fdc38d2a1cdbf" - integrity sha512-OoEPqTLmA5+4uSFf/k4ZLb8cEdx+CwlzovqGf6/gKvb8VrUxe5B5/d2RGlGM777Ke9TmuFhJtTIDugpgc2jo/Q== - dependencies: - buffer "4.9.1" - events "1.1.1" - ieee754 "1.1.13" - jmespath "0.15.0" - querystring "0.2.0" - sax "1.2.1" - url "0.10.3" - uuid "3.3.2" - xml2js "0.4.19" - aws-sdk@^2.668.0: version "2.668.0" resolved "https://registry.yarnpkg.com/aws-sdk/-/aws-sdk-2.668.0.tgz#18b3e64a47f86c109586422596e53dc733117696" From 544cee64077b49e3a1b812d3e00fcda4cd8c1a00 Mon Sep 17 00:00:00 2001 From: Benjie Gillam Date: Fri, 15 May 2020 11:32:17 +0100 Subject: [PATCH 27/34] Lint fix --- @app/components/src/AvatarUpload.tsx | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/@app/components/src/AvatarUpload.tsx b/@app/components/src/AvatarUpload.tsx index fbd920c2..fb605d58 100644 --- a/@app/components/src/AvatarUpload.tsx +++ b/@app/components/src/AvatarUpload.tsx @@ -1,13 +1,13 @@ -import React, { useState, useEffect } from "react"; -import { Upload, Icon, message } from "antd"; -import { UploadChangeParam } from "antd/lib/upload"; -import { UploadFile, RcCustomRequestOptions } from "antd/lib/upload/interface"; -import axios from "axios"; import { - useChangeAvatarMutation, ProfileSettingsForm_UserFragment, + useChangeAvatarMutation, } from "@app/graphql"; +import { Icon, message, Upload } from "antd"; +import { UploadChangeParam } from "antd/lib/upload"; +import { RcCustomRequestOptions, UploadFile } from "antd/lib/upload/interface"; import { ApolloError } from "apollo-client"; +import axios from "axios"; +import React, { useEffect, useState } from "react"; export function slugify(string: string) { const a = @@ -20,7 +20,7 @@ export function slugify(string: string) { .toString() .toLowerCase() .replace(/\s+/g, "-") // Replace spaces with - - .replace(p, c => b.charAt(a.indexOf(c))) // Replace special characters + .replace(p, (c) => b.charAt(a.indexOf(c))) // Replace special characters .replace(/&/g, "-and-") // Replace & with 'and' .replace(/[^\w\-]+/g, "") // Remove all non-word characters .replace(/\-\-+/g, "-") // Replace multiple - with single - @@ -127,27 +127,27 @@ export function AvatarUpload({ operation: "put", }, }) - .then(response => { + .then((response) => { const preSignedUrl = response.data.url; axios .put(preSignedUrl, file, { - onUploadProgress: e => { + onUploadProgress: (e) => { const progress = Math.round((e.loaded / e.total) * 100); onProgress({ percent: progress }, file); }, }) - .then(response => { + .then((response) => { if (response.config.url) { changeUserAvatar(response.config.url.split("?")[0]); onSuccess(response.config, file); } }) - .catch(error => { + .catch((error) => { console.log(error); onError(error); }); }) - .catch(error => { + .catch((error) => { console.log(error); onError(error); }); @@ -169,7 +169,7 @@ export function AvatarUpload({ changeUserAvatar(null); return true; }) - .catch(error => { + .catch((error) => { console.log(JSON.stringify(error)); return false; }); From b4c984bdfeb8d590295dcd6fa6a4e68c4d86cd64 Mon Sep 17 00:00:00 2001 From: Benjie Gillam Date: Fri, 15 May 2020 11:32:49 +0100 Subject: [PATCH 28/34] Use 'slugify' --- @app/components/src/AvatarUpload.tsx | 20 +------------------- 1 file changed, 1 insertion(+), 19 deletions(-) diff --git a/@app/components/src/AvatarUpload.tsx b/@app/components/src/AvatarUpload.tsx index fb605d58..12a5c252 100644 --- a/@app/components/src/AvatarUpload.tsx +++ b/@app/components/src/AvatarUpload.tsx @@ -8,25 +8,7 @@ import { RcCustomRequestOptions, UploadFile } from "antd/lib/upload/interface"; import { ApolloError } from "apollo-client"; import axios from "axios"; import React, { useEffect, useState } from "react"; - -export function slugify(string: string) { - const a = - "àáâäæãåāăąçćčđďèéêëēėęěğǵḧîïíīįìłḿñńǹňôöòóœøōõőṕŕřßśšşșťțûüùúūǘůűųẃẍÿýžźż·/_,:;"; - const b = - "aaaaaaaaaacccddeeeeeeeegghiiiiiilmnnnnoooooooooprrsssssttuuuuuuuuuwxyyzzz------"; - const p = new RegExp(a.split("").join("|"), "g"); - - return string - .toString() - .toLowerCase() - .replace(/\s+/g, "-") // Replace spaces with - - .replace(p, (c) => b.charAt(a.indexOf(c))) // Replace special characters - .replace(/&/g, "-and-") // Replace & with 'and' - .replace(/[^\w\-]+/g, "") // Remove all non-word characters - .replace(/\-\-+/g, "-") // Replace multiple - with single - - .replace(/^-+/, "") // Trim - from start of text - .replace(/-+$/, ""); // Trim - from end of text -} +import slugify from "slugify"; export function getUid(name: string) { const randomHex = () => Math.floor(Math.random() * 16777215).toString(16); From 5a1746343143f5180f2d70e90c119e1c64067cb1 Mon Sep 17 00:00:00 2001 From: Benjie Gillam Date: Fri, 15 May 2020 11:35:41 +0100 Subject: [PATCH 29/34] Use UpdateUser mutation instead of ChangeAvatar mutation --- @app/client/src/graphql/ChangeAvatar.graphql | 9 --------- @app/client/src/graphql/UpdateUser.graphql | 1 + @app/components/src/AvatarUpload.tsx | 6 +++--- 3 files changed, 4 insertions(+), 12 deletions(-) delete mode 100644 @app/client/src/graphql/ChangeAvatar.graphql diff --git a/@app/client/src/graphql/ChangeAvatar.graphql b/@app/client/src/graphql/ChangeAvatar.graphql deleted file mode 100644 index 9f08f7b0..00000000 --- a/@app/client/src/graphql/ChangeAvatar.graphql +++ /dev/null @@ -1,9 +0,0 @@ -mutation ChangeAvatar($id: Int!, $patch: UserPatch!) { - updateUser(input: { id: $id, patch: $patch }) { - clientMutationId - user { - id - avatarUrl - } - } -} diff --git a/@app/client/src/graphql/UpdateUser.graphql b/@app/client/src/graphql/UpdateUser.graphql index 2f70178f..496cd3f5 100644 --- a/@app/client/src/graphql/UpdateUser.graphql +++ b/@app/client/src/graphql/UpdateUser.graphql @@ -5,6 +5,7 @@ mutation UpdateUser($id: UUID!, $patch: UserPatch!) { id name username + avatarUrl } } } diff --git a/@app/components/src/AvatarUpload.tsx b/@app/components/src/AvatarUpload.tsx index 12a5c252..a9e44840 100644 --- a/@app/components/src/AvatarUpload.tsx +++ b/@app/components/src/AvatarUpload.tsx @@ -1,6 +1,6 @@ import { ProfileSettingsForm_UserFragment, - useChangeAvatarMutation, + useUpdateUserMutation, } from "@app/graphql"; import { Icon, message, Upload } from "antd"; import { UploadChangeParam } from "antd/lib/upload"; @@ -25,7 +25,7 @@ export function AvatarUpload({ setSuccess: React.Dispatch>; setError: (error: Error | ApolloError | null) => void; }) { - const [changeAvatar] = useChangeAvatarMutation(); + const [updateUser] = useUpdateUserMutation(); const [fileList, setFileList] = useState( user && user.avatarUrl ? [ @@ -85,7 +85,7 @@ export function AvatarUpload({ setSuccess(false); setError(null); try { - await changeAvatar({ + await updateUser({ variables: { id: user.id, patch: { From c14278d8b30f521839bac9e6c0a4751207cbed3c Mon Sep 17 00:00:00 2001 From: Benjie Gillam Date: Fri, 15 May 2020 12:05:22 +0100 Subject: [PATCH 30/34] Schema export script --- @app/server/package.json | 4 +++- @app/server/scripts/schema-export.ts | 31 ++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 @app/server/scripts/schema-export.ts diff --git a/@app/server/package.json b/@app/server/package.json index 90c3c7c1..17f3d91f 100644 --- a/@app/server/package.json +++ b/@app/server/package.json @@ -6,6 +6,7 @@ "build": "tsc -b", "start": "node -r @app/config/env dist/index.js", "dev": "nodemon --signal SIGINT --watch 'dist/**/*.js' -x 'node --inspect=9678 -r @app/config/env -r source-map-support/register' dist/index.js", + "schema:export": "node -r @app/config/env node_modules/.bin/ts-node -O '{\"rootDir\":null}' scripts/schema-export.ts", "test": "NODE_ENV=test node -r @app/config/env ./node_modules/.bin/jest" }, "dependencies": { @@ -52,7 +53,8 @@ "@types/node": "^13.13.4", "graphql": "^14.4.2", "jest": "^25.5.4", - "mock-req": "^0.2.0" + "mock-req": "^0.2.0", + "ts-node": "^8.10.1" }, "optionalDependencies": { "bufferutil": "^4.0.1", diff --git a/@app/server/scripts/schema-export.ts b/@app/server/scripts/schema-export.ts new file mode 100644 index 00000000..e0d3e248 --- /dev/null +++ b/@app/server/scripts/schema-export.ts @@ -0,0 +1,31 @@ +import { writeFileSync } from "fs"; +import { printSchema } from "graphql"; +import { Pool } from "pg"; +import { createPostGraphileSchema } from "postgraphile"; + +import { getPostGraphileOptions } from "../src/middleware/installPostGraphile"; + +async function main() { + const rootPgPool = new Pool({ + connectionString: process.env.DATABASE_URL!, + }); + try { + const schema = await createPostGraphileSchema( + process.env.AUTH_DATABASE_URL!, + "app_public", + getPostGraphileOptions({ rootPgPool }) + ); + writeFileSync( + `${__dirname}/../../../data/schema.graphql`, + printSchema(schema) + ); + console.log("GraphQL schema exported"); + } finally { + rootPgPool.end(); + } +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); From 0fdc0ff0740e3e6191fff8840b3394a837b19082 Mon Sep 17 00:00:00 2001 From: Benjie Gillam Date: Fri, 15 May 2020 12:09:43 +0100 Subject: [PATCH 31/34] Start refactoring --- .../src/graphql/CreateUploadUrl.graphql | 5 + @app/components/src/AvatarUpload.tsx | 64 +++--- @app/components/src/UserAvatar.tsx | 5 +- .../src/plugins/CreateUploadUrlPlugin.ts | 65 ++---- data/schema.graphql | 197 +----------------- yarn.lock | 11 + 6 files changed, 76 insertions(+), 271 deletions(-) create mode 100644 @app/client/src/graphql/CreateUploadUrl.graphql diff --git a/@app/client/src/graphql/CreateUploadUrl.graphql b/@app/client/src/graphql/CreateUploadUrl.graphql new file mode 100644 index 00000000..5c14369e --- /dev/null +++ b/@app/client/src/graphql/CreateUploadUrl.graphql @@ -0,0 +1,5 @@ +mutation CreateUploadUrl($input: CreateUploadUrlInput!) { + createUploadUrl(input: $input) { + uploadUrl + } +} diff --git a/@app/components/src/AvatarUpload.tsx b/@app/components/src/AvatarUpload.tsx index a9e44840..0e9b572b 100644 --- a/@app/components/src/AvatarUpload.tsx +++ b/@app/components/src/AvatarUpload.tsx @@ -1,8 +1,11 @@ +import { PlusOutlined } from "@ant-design/icons"; +import create from "@ant-design/icons/lib/components/IconFont"; import { ProfileSettingsForm_UserFragment, + useCreateUploadUrlMutation, useUpdateUserMutation, } from "@app/graphql"; -import { Icon, message, Upload } from "antd"; +import { message, Upload } from "antd"; import { UploadChangeParam } from "antd/lib/upload"; import { RcCustomRequestOptions, UploadFile } from "antd/lib/upload/interface"; import { ApolloError } from "apollo-client"; @@ -99,40 +102,37 @@ export function AvatarUpload({ setError(e); } }; + const [createUploadUrl] = useCreateUploadUrlMutation(); - const customRequest = (option: RcCustomRequestOptions) => { + const customRequest = async (option: RcCustomRequestOptions) => { const { onSuccess, onError, file, onProgress } = option; - axios - .get(`${process.env.ROOT_URL}/api/s3`, { - params: { - key: file.uid, - operation: "put", + try { + const contentType = file.type; + const { data } = await createUploadUrl({ + variables: { + input: { + contentType, + }, + }, + }); + const uploadUrl = data?.createUploadUrl?.uploadUrl; + + if (!uploadUrl) { + throw new Error("Failed to generate upload URL"); + } + const response = await axios.put(uploadUrl, file, { + onUploadProgress: (e) => { + const progress = Math.round((e.loaded / e.total) * 100); + onProgress({ percent: progress }, file); }, - }) - .then((response) => { - const preSignedUrl = response.data.url; - axios - .put(preSignedUrl, file, { - onUploadProgress: (e) => { - const progress = Math.round((e.loaded / e.total) * 100); - onProgress({ percent: progress }, file); - }, - }) - .then((response) => { - if (response.config.url) { - changeUserAvatar(response.config.url.split("?")[0]); - onSuccess(response.config, file); - } - }) - .catch((error) => { - console.log(error); - onError(error); - }); - }) - .catch((error) => { - console.log(error); - onError(error); }); + if (response.config.url) { + changeUserAvatar(response.config.url.split("?")[0]); + onSuccess(response.config, file); + } + } catch (e) { + onError(e); + } }; const deleteUserAvatarFromBucket = async () => { @@ -167,7 +167,7 @@ export function AvatarUpload({ const uploadButton = (
- +
Avatar
); diff --git a/@app/components/src/UserAvatar.tsx b/@app/components/src/UserAvatar.tsx index e917c333..b5f19ca4 100644 --- a/@app/components/src/UserAvatar.tsx +++ b/@app/components/src/UserAvatar.tsx @@ -2,7 +2,10 @@ import { Avatar } from "antd"; import React, { FC } from "react"; export const UserAvatar: FC<{ - user: { name: string; avatarUrl: string | null | undefined }; + user: { + name?: string | null; + avatarUrl?: string | null; + }; }> = (props) => { const { name, avatarUrl } = props.user; if (avatarUrl) { diff --git a/@app/server/src/plugins/CreateUploadUrlPlugin.ts b/@app/server/src/plugins/CreateUploadUrlPlugin.ts index 8dac6012..ffb13aa0 100644 --- a/@app/server/src/plugins/CreateUploadUrlPlugin.ts +++ b/@app/server/src/plugins/CreateUploadUrlPlugin.ts @@ -50,47 +50,20 @@ async function getCurrentUser(pool: Pool): Promise { await pool.query("RELEASE SAVEPOINT"); } } +/** The set of content types that we allow users to upload.*/ +const ALLOWED_UPLOAD_CONTENT_TYPE = [ + "image/apng", + "image/bmp", + "image/gif", + "image/jpeg", + "image/png", + "image/svg+xml", + "image/tiff", + "image/webp", +]; const CreateUploadUrlPlugin = makeExtendSchemaPlugin(() => ({ typeDefs: gql` - """ - The set of content types that we allow users to upload. - """ - enum AllowedUploadContentType { - """ - image/apng - """ - IMAGE_APNG - """ - image/bmp - """ - IMAGE_BMP - """ - image/gif - """ - IMAGE_GIF - """ - image/jpeg - """ - IMAGE_JPEG - """ - image/png - """ - IMAGE_PNG - """ - image/svg+xml - """ - IMAGE_SVG_XML - """ - image/tiff - """ - IMAGE_TIFF - """ - image/webp - """ - IMAGE_WEBP - } - """ All input for the \`createUploadUrl\` mutation. """ @@ -106,7 +79,7 @@ const CreateUploadUrlPlugin = makeExtendSchemaPlugin(() => ({ to upload. For further information about content types, see https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types """ - contentType: AllowedUploadContentType! + contentType: String! } """ @@ -170,8 +143,16 @@ const CreateUploadUrlPlugin = makeExtendSchemaPlugin(() => ({ throw err; } - const { input } = args; - const contentType: string = AllowedUploadContentType[input.contentType]; + const { + input: { contentType, clientMutationId }, + } = args; + if (!ALLOWED_UPLOAD_CONTENT_TYPE.includes(contentType)) { + throw new Error( + `Not allowed to upload that type; allowed types include: '${ALLOWED_UPLOAD_CONTENT_TYPE.join( + "', '" + )}'` + ); + } const s3 = new aws.S3({ region: awsRegion, signatureVersion: "v4", @@ -186,7 +167,7 @@ const CreateUploadUrlPlugin = makeExtendSchemaPlugin(() => ({ }; const signedUrl = await s3.getSignedUrlPromise("putObject", params); return { - clientMutationId: input.clientMutationId, + clientMutationId, uploadUrl: signedUrl, }; }, diff --git a/data/schema.graphql b/data/schema.graphql index 00ec324a..df9ec8a6 100644 --- a/data/schema.graphql +++ b/data/schema.graphql @@ -23,33 +23,6 @@ type AcceptInvitationToOrganizationPayload { query: Query } -"""The set of content types that we allow users to upload.""" -enum AllowedUploadContentType { - """image/apng""" - IMAGE_APNG - - """image/bmp""" - IMAGE_BMP - - """image/gif""" - IMAGE_GIF - - """image/jpeg""" - IMAGE_JPEG - - """image/png""" - IMAGE_PNG - - """image/svg+xml""" - IMAGE_SVG_XML - - """image/tiff""" - IMAGE_TIFF - - """image/webp""" - IMAGE_WEBP -} - """All input for the `changePassword` mutation.""" input ChangePasswordInput { """ @@ -146,7 +119,7 @@ input CreateUploadUrlInput { to upload. For further information about content types, see https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types """ - contentType: AllowedUploadContentType! + contentType: String! } """The output of our `createUploadUrl` mutation.""" @@ -654,37 +627,6 @@ type Organization { id: UUID! name: String! - """ - Reads and enables pagination through a set of `OrganizationInvitation`. - """ - organizationInvitations( - """Read all values in the set after (below) this cursor.""" - after: Cursor - - """Read all values in the set before (above) this cursor.""" - before: Cursor - - """ - A condition to be used in determining which values should be returned by the collection. - """ - condition: OrganizationInvitationCondition - - """Only read the first `n` values of the set.""" - first: Int - - """Only read the last `n` values of the set.""" - last: Int - - """ - Skip the first `n` values from our `after` cursor, an alternative to cursor - based pagination. May not be used with `last`. - """ - offset: Int - - """The method to use when ordering `OrganizationInvitation`.""" - orderBy: [OrganizationInvitationsOrderBy!] = [PRIMARY_KEY_ASC] - ): OrganizationInvitationsConnection! - """ Reads and enables pagination through a set of `OrganizationMembership`. """ @@ -730,80 +672,6 @@ input OrganizationCondition { slug: String } -type OrganizationInvitation { - code: String - email: String - id: UUID! - - """ - Reads a single `Organization` that is related to this `OrganizationInvitation`. - """ - organization: Organization - organizationId: UUID! - - """ - Reads a single `User` that is related to this `OrganizationInvitation`. - """ - user: User - userId: UUID -} - -""" -A condition to be used against `OrganizationInvitation` object types. All fields -are tested for equality and combined with a logical ‘and.’ -""" -input OrganizationInvitationCondition { - """Checks for equality with the object’s `id` field.""" - id: UUID - - """Checks for equality with the object’s `organizationId` field.""" - organizationId: UUID - - """Checks for equality with the object’s `userId` field.""" - userId: UUID -} - -"""A connection to a list of `OrganizationInvitation` values.""" -type OrganizationInvitationsConnection { - """ - A list of edges which contains the `OrganizationInvitation` and cursor to aid in pagination. - """ - edges: [OrganizationInvitationsEdge!]! - - """A list of `OrganizationInvitation` objects.""" - nodes: [OrganizationInvitation!]! - - """Information to aid in pagination.""" - pageInfo: PageInfo! - - """ - The count of *all* `OrganizationInvitation` you could get from the connection. - """ - totalCount: Int! -} - -"""A `OrganizationInvitation` edge in the connection.""" -type OrganizationInvitationsEdge { - """A cursor for use in pagination.""" - cursor: Cursor - - """The `OrganizationInvitation` at the end of the edge.""" - node: OrganizationInvitation! -} - -"""Methods to use when ordering `OrganizationInvitation`.""" -enum OrganizationInvitationsOrderBy { - ID_ASC - ID_DESC - NATURAL - ORGANIZATION_ID_ASC - ORGANIZATION_ID_DESC - PRIMARY_KEY_ASC - PRIMARY_KEY_DESC - USER_ID_ASC - USER_ID_DESC -} - type OrganizationMembership { createdAt: Datetime! id: UUID! @@ -953,38 +821,6 @@ type Query { retrieves the `Organization` that you were invited to. """ organizationForInvitation(code: String, invitationId: UUID!): Organization - organizationInvitation(id: UUID!): OrganizationInvitation - - """ - Reads and enables pagination through a set of `OrganizationInvitation`. - """ - organizationInvitations( - """Read all values in the set after (below) this cursor.""" - after: Cursor - - """Read all values in the set before (above) this cursor.""" - before: Cursor - - """ - A condition to be used in determining which values should be returned by the collection. - """ - condition: OrganizationInvitationCondition - - """Only read the first `n` values of the set.""" - first: Int - - """Only read the last `n` values of the set.""" - last: Int - - """ - Skip the first `n` values from our `after` cursor, an alternative to cursor - based pagination. May not be used with `last`. - """ - offset: Int - - """The method to use when ordering `OrganizationInvitation`.""" - orderBy: [OrganizationInvitationsOrderBy!] = [PRIMARY_KEY_ASC] - ): OrganizationInvitationsConnection organizationMembership(id: UUID!): OrganizationMembership """Reads and enables pagination through a set of `Organization`.""" @@ -1301,37 +1137,6 @@ type User { """Public-facing name (or pseudonym) of the user.""" name: String - """ - Reads and enables pagination through a set of `OrganizationInvitation`. - """ - organizationInvitations( - """Read all values in the set after (below) this cursor.""" - after: Cursor - - """Read all values in the set before (above) this cursor.""" - before: Cursor - - """ - A condition to be used in determining which values should be returned by the collection. - """ - condition: OrganizationInvitationCondition - - """Only read the first `n` values of the set.""" - first: Int - - """Only read the last `n` values of the set.""" - last: Int - - """ - Skip the first `n` values from our `after` cursor, an alternative to cursor - based pagination. May not be used with `last`. - """ - offset: Int - - """The method to use when ordering `OrganizationInvitation`.""" - orderBy: [OrganizationInvitationsOrderBy!] = [PRIMARY_KEY_ASC] - ): OrganizationInvitationsConnection! - """ Reads and enables pagination through a set of `OrganizationMembership`. """ diff --git a/yarn.lock b/yarn.lock index 9212f1db..3afa9013 100644 --- a/yarn.lock +++ b/yarn.lock @@ -15216,6 +15216,17 @@ ts-log@2.1.4: resolved "https://registry.yarnpkg.com/ts-log/-/ts-log-2.1.4.tgz#063c5ad1cbab5d49d258d18015963489fb6fb59a" integrity sha512-P1EJSoyV+N3bR/IWFeAqXzKPZwHpnLY6j7j58mAvewHRipo+BQM2Y1f9Y9BjEQznKwgqqZm7H8iuixmssU7tYQ== +ts-node@^8.10.1: + version "8.10.1" + resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-8.10.1.tgz#77da0366ff8afbe733596361d2df9a60fc9c9bd3" + integrity sha512-bdNz1L4ekHiJul6SHtZWs1ujEKERJnHs4HxN7rjTyyVOFf3HaJ6sLqe6aPG62XTzAB/63pKRh5jTSWL0D7bsvw== + dependencies: + arg "^4.1.0" + diff "^4.0.1" + make-error "^1.1.1" + source-map-support "^0.5.17" + yn "3.1.1" + ts-node@^8.9.1: version "8.9.1" resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-8.9.1.tgz#2f857f46c47e91dcd28a14e052482eb14cfd65a5" From 65620dd4161653dbc3c57d95e3d151d1fb386930 Mon Sep 17 00:00:00 2001 From: Benjie Gillam Date: Fri, 15 May 2020 12:19:16 +0100 Subject: [PATCH 32/34] Split independent forms --- @app/client/src/pages/settings/index.tsx | 131 ++++++++++++----------- 1 file changed, 70 insertions(+), 61 deletions(-) diff --git a/@app/client/src/pages/settings/index.tsx b/@app/client/src/pages/settings/index.tsx index 421cd046..0729b079 100644 --- a/@app/client/src/pages/settings/index.tsx +++ b/@app/client/src/pages/settings/index.tsx @@ -15,7 +15,7 @@ import { getCodeFromError, tailFormItemLayout, } from "@app/lib"; -import { Alert, Button, Form, Input, PageHeader } from "antd"; +import { Alert, Button, Card, Form, Input, PageHeader } from "antd"; import { useForm } from "antd/lib/form/util"; import { ApolloError } from "apollo-client"; import { NextPage } from "next"; @@ -109,71 +109,80 @@ function ProfileSettingsForm({ const code = getCodeFromError(error); return (
- - - + + + + + + + + + + {error ? ( + + + {extractError(error).message} + {code ? ( + + {" "} + (Error code: ERR_{code}) + + ) : null} + + } + /> + + ) : success ? ( + + + + ) : null} + + + + + + +
- - - - - - - - {error ? ( - - - {extractError(error).message} - {code ? ( - - {" "} - (Error code: ERR_{code}) - - ) : null} - - } - /> - - ) : success ? ( - - - - ) : null} - - - - +
+
); } From c521d3d79683fccf258633443651d169f4e9adc7 Mon Sep 17 00:00:00 2001 From: Benjie Gillam Date: Fri, 15 May 2020 13:49:53 +0100 Subject: [PATCH 33/34] More refactoring --- @app/client/src/pages/settings/index.tsx | 6 +- @app/components/package.json | 1 + @app/components/src/AvatarUpload.tsx | 172 +++++++----------- @app/config/src/index.ts | 2 - .../src/plugins/CreateUploadUrlPlugin.ts | 34 ++-- @app/server/src/utils/handleErrors.ts | 4 + 6 files changed, 89 insertions(+), 130 deletions(-) diff --git a/@app/client/src/pages/settings/index.tsx b/@app/client/src/pages/settings/index.tsx index 0729b079..4ee79fc0 100644 --- a/@app/client/src/pages/settings/index.tsx +++ b/@app/client/src/pages/settings/index.tsx @@ -176,11 +176,7 @@ function ProfileSettingsForm({ justifyContent: "center", }} > - +
diff --git a/@app/components/package.json b/@app/components/package.json index 09041d06..3ba1b1b8 100644 --- a/@app/components/package.json +++ b/@app/components/package.json @@ -11,6 +11,7 @@ "@apollo/react-common": "^3.1.4", "@apollo/react-hooks": "^3.1.5", "@app/graphql": "0.0.0", + "@app/lib": "0.0.0", "@types/axios": "^0.14.0", "antd": "^4.2.0", "apollo-client": "^2.6.8", diff --git a/@app/components/src/AvatarUpload.tsx b/@app/components/src/AvatarUpload.tsx index 0e9b572b..1451dfc8 100644 --- a/@app/components/src/AvatarUpload.tsx +++ b/@app/components/src/AvatarUpload.tsx @@ -1,16 +1,18 @@ -import { PlusOutlined } from "@ant-design/icons"; -import create from "@ant-design/icons/lib/components/IconFont"; +import { LoadingOutlined, PlusOutlined } from "@ant-design/icons"; import { ProfileSettingsForm_UserFragment, useCreateUploadUrlMutation, useUpdateUserMutation, } from "@app/graphql"; +import { extractError, getExceptionFromError } from "@app/lib"; import { message, Upload } from "antd"; -import { UploadChangeParam } from "antd/lib/upload"; -import { RcCustomRequestOptions, UploadFile } from "antd/lib/upload/interface"; -import { ApolloError } from "apollo-client"; +import { + RcCustomRequestOptions, + UploadChangeParam, + UploadFile, +} from "antd/lib/upload/interface"; import axios from "axios"; -import React, { useEffect, useState } from "react"; +import React, { useState } from "react"; import slugify from "slugify"; export function getUid(name: string) { @@ -19,53 +21,26 @@ export function getUid(name: string) { return randomHex() + "-" + fileNameSlug; } +const ALLOWED_UPLOAD_CONTENT_TYPES = [ + "image/apng", + "image/bmp", + "image/gif", + "image/jpeg", + "image/png", + "image/svg+xml", + "image/tiff", + "image/webp", +]; +const ALLOWED_UPLOAD_CONTENT_TYPES_STRING = ALLOWED_UPLOAD_CONTENT_TYPES.join( + "," +); + export function AvatarUpload({ user, - setSuccess, - setError, }: { user: ProfileSettingsForm_UserFragment; - setSuccess: React.Dispatch>; - setError: (error: Error | ApolloError | null) => void; }) { const [updateUser] = useUpdateUserMutation(); - const [fileList, setFileList] = useState( - user && user.avatarUrl - ? [ - { - uid: "-1", - name: "avatar", - type: "image", - size: 1, - url: user.avatarUrl, - }, - ] - : null - ); - - useEffect(() => { - if (user) { - const avatar = user.avatarUrl; - if (avatar) { - setFileList([ - { - uid: "-1", - name: "avatar", - type: "image", - size: 1, - url: avatar, - }, - ]); - } else { - setFileList(null); - } - } - }, [user, user.avatarUrl]); - - // const onChange = (info: UploadChangeParam) => { - // console.log(info); - // setFileList([...fileList]); - // }; const beforeUpload = (file: any) => { const fileName = file.name.split(".")[0]; @@ -84,26 +59,10 @@ export function AvatarUpload({ return isJpgOrPng && isLt3M; }; - const changeUserAvatar = async (avatarUrl: string | null) => { - setSuccess(false); - setError(null); - try { - await updateUser({ - variables: { - id: user.id, - patch: { - avatarUrl, - }, - }, - }); - setError(null); - setSuccess(true); - } catch (e) { - setError(e); - } - }; const [createUploadUrl] = useCreateUploadUrlMutation(); + const [loading, setLoading] = useState(false); + const customRequest = async (option: RcCustomRequestOptions) => { const { onSuccess, onError, file, onProgress } = option; try { @@ -127,63 +86,72 @@ export function AvatarUpload({ }, }); if (response.config.url) { - changeUserAvatar(response.config.url.split("?")[0]); + await updateUser({ + variables: { + id: user.id, + patch: { + avatarUrl: response.config.url.split("?")[0], + }, + }, + }); onSuccess(response.config, file); } } catch (e) { + console.error(e); onError(e); } }; - const deleteUserAvatarFromBucket = async () => { - if (user && user.avatarUrl) { - const key = user.avatarUrl.substring(user.avatarUrl.lastIndexOf("/") + 1); - await axios - .get(`${process.env.ROOT_URL}/api/s3`, { - params: { - key: `${key}`, - operation: "delete", - }, - }) - .then(() => { - // this isn't confirmation that the item was deleted - // only confimation that there wasnt an error.. - changeUserAvatar(null); - return true; - }) - .catch((error) => { - console.log(JSON.stringify(error)); - return false; - }); - } - return true; - }; - - const onRemove = async () => { - if (await deleteUserAvatarFromBucket()) { - setFileList(null); - } - }; - const uploadButton = (
- -
Avatar
+ {loading ? : } +
Upload
); + const onChange = (info: UploadChangeParam>) => { + switch (info.file.status) { + case "uploading": { + setLoading(true); + break; + } + case "removed": + case "success": { + setLoading(false); + break; + } + case "error": { + const error: any = getExceptionFromError(info.file.error); + console.dir(error); + message.error( + typeof error === "string" + ? error + : error?.message ?? + "Unknown error occurred" + + (error?.code ? ` (${error.code})` : "") + ); + setLoading(false); + break; + } + } + }; + return (
- {fileList && fileList.length >= 0 ? null : uploadButton} + {user.avatarUrl ? ( + avatar + ) : ( + uploadButton + )}
); diff --git a/@app/config/src/index.ts b/@app/config/src/index.ts index a3dd5690..c847620b 100644 --- a/@app/config/src/index.ts +++ b/@app/config/src/index.ts @@ -5,8 +5,6 @@ const packageJson = require("../../../package.json"); export const fromEmail = '"PostGraphile Starter" '; -export const awsRegion = "us-east-1"; -export const uploadBucket = process.env.AWS_BUCKET_UPLOAD; export const projectName = packageJson.name.replace(/[-_]/g, " "); export const companyName = projectName; // For copyright ownership export const emailLegalText = diff --git a/@app/server/src/plugins/CreateUploadUrlPlugin.ts b/@app/server/src/plugins/CreateUploadUrlPlugin.ts index ffb13aa0..f4644bc8 100644 --- a/@app/server/src/plugins/CreateUploadUrlPlugin.ts +++ b/@app/server/src/plugins/CreateUploadUrlPlugin.ts @@ -1,4 +1,3 @@ -import { awsRegion, uploadBucket } from "@app/config"; import * as aws from "aws-sdk"; import { gql, makeExtendSchemaPlugin } from "graphile-utils"; import { Pool } from "pg"; @@ -6,20 +5,17 @@ import { v4 as uuidv4 } from "uuid"; import { OurGraphQLContext } from "../middleware/installPostGraphile"; -enum AllowedUploadContentType { - IMAGE_APNG = "image/apng", - IMAGE_BMP = "image/bmp", - IMAGE_GIF = "image/gif", - IMAGE_JPEG = "image/jpeg", - IMAGE_PNG = "image/png", - IMAGE_SVG_XML = "image/svg+xml", - IMAGE_TIFF = "image/tiff", - IMAGE_WEBP = "image/webp", -} +const awsRegion = process.env.AWS_REGION || "us-east-1"; +const uploadBucket = process.env.S3_UPLOADS_BUCKET; + +const s3 = new aws.S3({ + region: awsRegion, + signatureVersion: "v4", +}); interface CreateUploadUrlInput { clientMutationId?: string; - contentType: AllowedUploadContentType; + contentType: string; } /** The minimal set of information that this plugin needs to know about users. */ @@ -51,7 +47,7 @@ async function getCurrentUser(pool: Pool): Promise { } } /** The set of content types that we allow users to upload.*/ -const ALLOWED_UPLOAD_CONTENT_TYPE = [ +const ALLOWED_UPLOAD_CONTENT_TYPES = [ "image/apng", "image/bmp", "image/gif", @@ -120,7 +116,7 @@ const CreateUploadUrlPlugin = makeExtendSchemaPlugin(() => ({ ) { if (!uploadBucket) { const err = new Error( - "Server misconfigured: missing `AWS_BUCKET_UPLOAD` envvar" + "Server misconfigured: missing `S3_UPLOADS_BUCKET` envvar" ); // @ts-ignore err.code = "MSCFG"; @@ -146,22 +142,18 @@ const CreateUploadUrlPlugin = makeExtendSchemaPlugin(() => ({ const { input: { contentType, clientMutationId }, } = args; - if (!ALLOWED_UPLOAD_CONTENT_TYPE.includes(contentType)) { + if (!ALLOWED_UPLOAD_CONTENT_TYPES.includes(contentType)) { throw new Error( - `Not allowed to upload that type; allowed types include: '${ALLOWED_UPLOAD_CONTENT_TYPE.join( + `Not allowed to upload that type; allowed types include: '${ALLOWED_UPLOAD_CONTENT_TYPES.join( "', '" )}'` ); } - const s3 = new aws.S3({ - region: awsRegion, - signatureVersion: "v4", - }); const params = { Bucket: uploadBucket, ContentType: contentType, // randomly generated filename, nested under username directory - Key: `${user.id}/${uuidv4()}`, + Key: `avatars/${user.id}/${uuidv4()}`, Expires: 300, // signed URL will expire in 5 minutes ACL: "public-read", // uploaded file will be publicly readable }; diff --git a/@app/server/src/utils/handleErrors.ts b/@app/server/src/utils/handleErrors.ts index a80213eb..e0d85650 100644 --- a/@app/server/src/utils/handleErrors.ts +++ b/@app/server/src/utils/handleErrors.ts @@ -58,6 +58,10 @@ export const ERROR_MESSAGE_OVERRIDES: { [code: string]: typeof pluck } = { fields: conflictFieldsFromError(err), code: "NUNIQ", }), + MSCFG: (err) => ({ + ...pluck(err), + message: err.message, + }), }; function conflictFieldsFromError(err: any) { From ee48e4c034c86dd0853bba5212f2495e4f1a4d9f Mon Sep 17 00:00:00 2001 From: Benjie Gillam Date: Fri, 15 May 2020 14:14:40 +0100 Subject: [PATCH 34/34] awsRegion restored to behaviour on master --- @app/config/src/index.ts | 1 + @app/server/src/plugins/CreateUploadUrlPlugin.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/@app/config/src/index.ts b/@app/config/src/index.ts index c847620b..e6a6ee3f 100644 --- a/@app/config/src/index.ts +++ b/@app/config/src/index.ts @@ -5,6 +5,7 @@ const packageJson = require("../../../package.json"); export const fromEmail = '"PostGraphile Starter" '; +export const awsRegion = "us-east-1"; export const projectName = packageJson.name.replace(/[-_]/g, " "); export const companyName = projectName; // For copyright ownership export const emailLegalText = diff --git a/@app/server/src/plugins/CreateUploadUrlPlugin.ts b/@app/server/src/plugins/CreateUploadUrlPlugin.ts index f4644bc8..248730f7 100644 --- a/@app/server/src/plugins/CreateUploadUrlPlugin.ts +++ b/@app/server/src/plugins/CreateUploadUrlPlugin.ts @@ -1,3 +1,4 @@ +import { awsRegion } from "@app/config"; import * as aws from "aws-sdk"; import { gql, makeExtendSchemaPlugin } from "graphile-utils"; import { Pool } from "pg"; @@ -5,7 +6,6 @@ import { v4 as uuidv4 } from "uuid"; import { OurGraphQLContext } from "../middleware/installPostGraphile"; -const awsRegion = process.env.AWS_REGION || "us-east-1"; const uploadBucket = process.env.S3_UPLOADS_BUCKET; const s3 = new aws.S3({