{body}
38 |├── .eslintignore ├── .eslintrc.js ├── .gitattributes ├── .gitignore ├── .nvmrc ├── .postgraphilerc.js ├── .prettierrc.js ├── LICENSE.md ├── README.md ├── client-react ├── .gitignore ├── README.md ├── package.json ├── public │ ├── favicon.ico │ ├── index.html │ └── manifest.json └── src │ ├── App.js │ ├── App.test.js │ ├── apolloClient.js │ ├── components │ ├── CreateNewForumForm.js │ ├── CreateNewReplyForm.js │ ├── CreateNewTopicForm.js │ ├── ForumItem.js │ ├── ForumPage.js │ ├── Header.js │ ├── HomePage.js │ ├── LoginPage.js │ ├── Main.js │ ├── NotFound.js │ ├── PostItem.js │ ├── TopicItem.js │ └── TopicPage.js │ ├── index.js │ ├── layouts │ └── StandardLayout.js │ ├── logo.svg │ ├── registerServiceWorker.js │ └── routes │ ├── ForumRoute.js │ ├── HomeRoute.js │ ├── LoginRoute.js │ ├── NotFoundRoute.js │ └── TopicRoute.js ├── data ├── README.md ├── schema.graphql ├── schema.json └── schema.sql ├── db ├── 100_jobs.sql ├── 200_schemas.sql ├── 300_utils.sql ├── 400_users.sql ├── 700_forum.sql ├── 999_data.sql ├── CONVENTIONS.md ├── README.md └── reset.sql ├── package.json ├── public └── css │ └── index.css ├── scripts └── schema_dump ├── server-koa2 ├── middleware │ ├── index.js │ ├── installFrontendServer.js │ ├── installPassport.js │ ├── installPostGraphile.js │ ├── installSession.js │ ├── installSharedStatic.js │ └── installStandardKoaMiddlewares.js ├── package.json └── server.js ├── setup.sh ├── shared ├── plugins │ └── PassportLoginPlugin.js └── utils.js └── yarn.lock /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | _LOCAL 3 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: "babel-eslint", 3 | parserOptions: { 4 | sourceType: "module", 5 | }, 6 | extends: ["eslint:recommended", "plugin:react/recommended", "prettier"], 7 | plugins: ["prettier", "graphql", "react"], 8 | env: { 9 | browser: true, 10 | es6: true, 11 | jest: true, 12 | node: true, 13 | }, 14 | rules: { 15 | "prettier/prettier": [ 16 | "error", 17 | { 18 | trailingComma: "es5", 19 | }, 20 | ], 21 | "no-unused-vars": [ 22 | 2, 23 | { 24 | argsIgnorePattern: "^_", 25 | }, 26 | ], 27 | "no-console": 0, // This is a demo, so logging console messages can be helpful. Remove this in production! 28 | }, 29 | }; 30 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | data/graphql.json linguist-generated 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | node_modules 3 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v8.11.3 2 | -------------------------------------------------------------------------------- /.postgraphilerc.js: -------------------------------------------------------------------------------- 1 | const PgSimplifyInflectorPlugin = require("@graphile-contrib/pg-simplify-inflector"); 2 | 3 | ["AUTH_DATABASE_URL", "NODE_ENV"].forEach(envvar => { 4 | if (!process.env[envvar]) { 5 | // We automatically source `.env` in the various scripts; but in case that 6 | // hasn't been done lets raise an error and stop. 7 | console.error(""); 8 | console.error(""); 9 | console.error("⚠️⚠️⚠️⚠️"); 10 | console.error( 11 | `No ${envvar} found in your environment; perhaps you need to run 'source ./.env'?` 12 | ); 13 | console.error("⚠️⚠️⚠️⚠️"); 14 | console.error(""); 15 | process.exit(1); 16 | } 17 | }); 18 | 19 | const isDev = process.env.NODE_ENV === "development"; 20 | 21 | // Our database URL - privileged 22 | const ownerConnection = process.env.ROOT_DATABASE_URL; 23 | // Our database URL - unprivileged 24 | const connection = process.env.AUTH_DATABASE_URL; 25 | // The PostgreSQL schema within our postgres DB to expose 26 | const schema = ["app_public"]; 27 | // Enable GraphiQL interface 28 | const graphiql = true; 29 | // Send back JSON objects rather than JSON strings 30 | const dynamicJson = true; 31 | // Watch the database for changes 32 | const watch = true; 33 | // Add some Graphile-Build plugins to enhance our GraphQL schema 34 | const appendPlugins = [ 35 | // Removes the 'ByFooIdAndBarId' from the end of relations 36 | PgSimplifyInflectorPlugin, 37 | ]; 38 | 39 | module.exports = { 40 | // Config for the library (middleware): 41 | library: { 42 | connection, 43 | schema, 44 | options: { 45 | ownerConnectionString: ownerConnection, 46 | dynamicJson, 47 | graphiql, 48 | watchPg: watch, 49 | appendPlugins, 50 | }, 51 | }, 52 | // Options for the CLI: 53 | options: { 54 | ownerConnection, 55 | defaultRole: "graphiledemo_visitor", 56 | connection, 57 | schema, 58 | dynamicJson, 59 | disableGraphiql: !graphiql, 60 | enhanceGraphiql: true, 61 | ignoreRbac: false, 62 | // We don't set a watch mode here, because there's no way to turn it off (e.g. when using -X) currently. 63 | appendPlugins, 64 | }, 65 | }; 66 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | trailingComma: "es5" 3 | } 4 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | ===================== 3 | 4 | Copyright © `2018` Benjie Gillam 5 | 6 | Permission is hereby granted, free of charge, to any person 7 | obtaining a copy of this software and associated documentation 8 | files (the “Software”), to deal in the Software without 9 | restriction, including without limitation the rights to use, 10 | copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom the 12 | Software is furnished to do so, subject to the following 13 | conditions: 14 | 15 | The above copyright notice and this permission notice shall be 16 | included in all copies or substantial portions of the Software. 17 | 18 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, 19 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 20 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 21 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 22 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 23 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 24 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 25 | OTHER DEALINGS IN THE SOFTWARE. 26 | 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | PostGraphile Examples 2 | ===================== 3 | 4 | 🚨**Temporarily unmaintained**🚨 This repo is currently not maintained and is out of date - Please make sure that you update the dependancies in your copy of these examples. For an up to date example, see the [Graphile Starter](https://github.com/graphile/starter). *We rely on sponsorship from the Graphile community to continue our work in Open Source. By donating to our [GitHub Sponsors or Patreon fund](https://graphile.org/sponsor), you'll help us spend more time on Open Source, and this repo will be updated quicker. Thank you to all our sponsors 🙌* 5 | 6 | This repository will contain examples of using PostGraphile with different servers and clients. 7 | 8 | To get started: 9 | 10 | ``` 11 | npm install -g yarn 12 | yarn 13 | ./setup.sh 14 | # Now add GITHUB_KEY and GITHUB_SECRET to .env (see "Login via GitHub" below) 15 | yarn start 16 | ``` 17 | 18 | This will run the koa2 server and react client. You can access it at http://localhost:8349/ 19 | 20 | It's recommended that you review the setup.sh script before executing it. 21 | 22 | The first user account to log in will automatically be made an administrator. 23 | 24 | Login via GitHub 25 | ---------------- 26 | 27 | To use social login you will need to create a GitHub application. This takes just a few seconds: 28 | 29 | 1. Visit https://github.com/settings/applications/new 30 | 2. Enter name: GraphileDemo 31 | 3. Enter homepage URL: http://localhost:8349 32 | 4. Enter authorization callback URL: http://localhost:8349/auth/github/callback 33 | 5. Press "Register Application" 34 | 6. Copy the 'Client ID' and 'Client Secret' into `GITHUB_KEY` and `GITHUB_SECRET` respectively in the `.env` file that was created by `setup.sh` 35 | 36 | Koa2 37 | ---- 38 | 39 | Koa 2 only has "experimental" support in PostGraphile officially, but if you 40 | face any issues please file them against PostGraphile with full reproduction 41 | instructions - we're trying to elevate Koa to full support status. 42 | -------------------------------------------------------------------------------- /client-react/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | 12 | # misc 13 | .DS_Store 14 | .env.local 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | -------------------------------------------------------------------------------- /client-react/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "client-react", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "apollo-cache-inmemory": "1.3.0", 7 | "apollo-client": "2.3.5", 8 | "apollo-link-http": "1.5.4", 9 | "graphql": "0.13.2", 10 | "graphql-anywhere": "4.1.14", 11 | "graphql-tag": "2.9.2", 12 | "moment": "2.29.4", 13 | "prop-types": "15.6.2", 14 | "react": "^16.4.1", 15 | "react-apollo": "2.5.0", 16 | "react-dom": "^16.4.1", 17 | "react-router-dom": "4.3.1", 18 | "react-scripts": "5.0.0", 19 | "slug": "0.9.2" 20 | }, 21 | "scripts": { 22 | "start": "if [ -x ../.env ]; then . ../.env; fi; BROWSER=none PORT=$CLIENT_PORT react-scripts start", 23 | "build": "react-scripts build", 24 | "test": "react-scripts test --env=jsdom", 25 | "eject": "react-scripts eject" 26 | }, 27 | "browserslist": [ 28 | ">0.2%", 29 | "not dead", 30 | "not ie <= 11", 31 | "not op_mini all" 32 | ] 33 | } 34 | -------------------------------------------------------------------------------- /client-react/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/graphile/examples/67d34c4d22b72544fa134eb714610adb0ed73d3d/client-react/public/favicon.ico -------------------------------------------------------------------------------- /client-react/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 | 6 | 7 | 11 | 12 | 13 | 14 | 23 |21 | Terribly sorry about this old bean, but you appear to have visited 22 | the create-react-app app directly. 23 |
24 |25 | Instead, you should visit the server app, which proxies through to 26 | create-react-app but adds all the GraphQL and OAuth goodness. 27 |
28 |
29 | Click here to visit the server,
30 | assuming you stuck with the default PORT=8349
31 |
Topic | 64 |Author | 65 |Replies | 66 |Last post | 67 |
---|---|---|---|
82 | There are no topics yet!{" "} 83 | {currentUser ? ( 84 | currentUser.isAdmin ? ( 85 | "Create one below..." 86 | ) : ( 87 | "Please check back later or contact an admin." 88 | ) 89 | ) : ( 90 | 91 | Perhaps you need to log in? 92 | 93 | )} 94 | | 95 |
48 | Welcome to the PostGraphile forum demo. Here you can see how we have 49 | harnessed the power of PostGraphile to quickly and easily make a 50 | simple forum.{" "} 51 | 52 | Take a look at the PostGraphile documentation 53 | {" "} 54 | to see how to get started with your own forum schema design. 55 |
56 |Hello administrator! Would you like to create a new forum?
87 |11 | Return home 12 |
13 |{body}
38 |{topic.body}
81 |
9 | -- License: MIT
10 | -- URL: https://gist.github.com/benjie/839740697f5a1c46ee8da98a1efac218
11 | -- Donations: https://www.paypal.me/benjie
12 |
13 | CREATE EXTENSION IF NOT EXISTS pgcrypto WITH SCHEMA public;
14 | CREATE EXTENSION IF NOT EXISTS "uuid-ossp" WITH SCHEMA public;
15 |
16 | CREATE SCHEMA IF NOT EXISTS app_jobs;
17 |
18 | CREATE TABLE app_jobs.job_queues (
19 | queue_name varchar NOT NULL PRIMARY KEY,
20 | job_count int DEFAULT 0 NOT NULL,
21 | locked_at timestamp with time zone,
22 | locked_by varchar
23 | );
24 | ALTER TABLE app_jobs.job_queues ENABLE ROW LEVEL SECURITY;
25 |
26 | CREATE TABLE app_jobs.jobs (
27 | id serial PRIMARY KEY,
28 | queue_name varchar DEFAULT (public.gen_random_uuid())::varchar NOT NULL,
29 | task_identifier varchar NOT NULL,
30 | payload json DEFAULT '{}'::json NOT NULL,
31 | priority int DEFAULT 0 NOT NULL,
32 | run_at timestamp with time zone DEFAULT now() NOT NULL,
33 | attempts int DEFAULT 0 NOT NULL,
34 | last_error varchar,
35 | created_at timestamp with time zone NOT NULL DEFAULT NOW(),
36 | updated_at timestamp with time zone NOT NULL DEFAULT NOW()
37 | );
38 | ALTER TABLE app_jobs.job_queues ENABLE ROW LEVEL SECURITY;
39 |
40 | CREATE FUNCTION app_jobs.do_notify() RETURNS trigger AS $$
41 | BEGIN
42 | PERFORM pg_notify(TG_ARGV[0], '');
43 | RETURN NEW;
44 | END;
45 | $$ LANGUAGE plpgsql;
46 |
47 | CREATE FUNCTION app_jobs.update_timestamps() RETURNS trigger AS $$
48 | BEGIN
49 | IF TG_OP = 'INSERT' THEN
50 | NEW.created_at = NOW();
51 | NEW.updated_at = NOW();
52 | ELSIF TG_OP = 'UPDATE' THEN
53 | NEW.created_at = OLD.created_at;
54 | NEW.updated_at = GREATEST(NOW(), OLD.updated_at + INTERVAL '1 millisecond');
55 | END IF;
56 | RETURN NEW;
57 | END;
58 | $$ LANGUAGE plpgsql;
59 |
60 | CREATE FUNCTION app_jobs.jobs__decrease_job_queue_count() RETURNS trigger AS $$
61 | BEGIN
62 | UPDATE app_jobs.job_queues
63 | SET job_count = job_queues.job_count - 1
64 | WHERE queue_name = OLD.queue_name
65 | AND job_queues.job_count > 1;
66 |
67 | IF NOT FOUND THEN
68 | DELETE FROM app_jobs.job_queues WHERE queue_name = OLD.queue_name;
69 | END IF;
70 |
71 | RETURN OLD;
72 | END;
73 | $$ LANGUAGE plpgsql;
74 |
75 | CREATE FUNCTION app_jobs.jobs__increase_job_queue_count() RETURNS trigger AS $$
76 | BEGIN
77 | INSERT INTO app_jobs.job_queues(queue_name, job_count)
78 | VALUES(NEW.queue_name, 1)
79 | ON CONFLICT (queue_name) DO UPDATE SET job_count = job_queues.job_count + 1;
80 |
81 | RETURN NEW;
82 | END;
83 | $$ LANGUAGE plpgsql;
84 |
85 | CREATE TRIGGER _100_timestamps BEFORE INSERT OR UPDATE ON app_jobs.jobs FOR EACH ROW EXECUTE PROCEDURE app_jobs.update_timestamps();
86 | CREATE TRIGGER _500_increase_job_queue_count AFTER INSERT ON app_jobs.jobs FOR EACH ROW EXECUTE PROCEDURE app_jobs.jobs__increase_job_queue_count();
87 | CREATE TRIGGER _500_decrease_job_queue_count BEFORE DELETE ON app_jobs.jobs FOR EACH ROW EXECUTE PROCEDURE app_jobs.jobs__decrease_job_queue_count();
88 | CREATE TRIGGER _900_notify_worker AFTER INSERT ON app_jobs.jobs FOR EACH STATEMENT EXECUTE PROCEDURE app_jobs.do_notify('jobs:insert');
89 |
90 | CREATE FUNCTION app_jobs.add_job(identifier varchar, payload json) RETURNS app_jobs.jobs AS $$
91 | INSERT INTO app_jobs.jobs(task_identifier, payload) VALUES(identifier, payload) RETURNING *;
92 | $$ LANGUAGE sql;
93 |
94 | CREATE FUNCTION app_jobs.add_job(identifier varchar, queue_name varchar, payload json) RETURNS app_jobs.jobs AS $$
95 | INSERT INTO app_jobs.jobs(task_identifier, queue_name, payload) VALUES(identifier, queue_name, payload) RETURNING *;
96 | $$ LANGUAGE sql;
97 |
98 | CREATE FUNCTION app_jobs.schedule_job(identifier varchar, queue_name varchar, payload json, run_at timestamptz) RETURNS app_jobs.jobs AS $$
99 | INSERT INTO app_jobs.jobs(task_identifier, queue_name, payload, run_at) VALUES(identifier, queue_name, payload, run_at) RETURNING *;
100 | $$ LANGUAGE sql;
101 |
102 | CREATE FUNCTION app_jobs.complete_job(worker_id varchar, job_id int) RETURNS app_jobs.jobs AS $$
103 | DECLARE
104 | v_row app_jobs.jobs;
105 | BEGIN
106 | DELETE FROM app_jobs.jobs
107 | WHERE id = job_id
108 | RETURNING * INTO v_row;
109 |
110 | UPDATE app_jobs.job_queues
111 | SET locked_by = null, locked_at = null
112 | WHERE queue_name = v_row.queue_name AND locked_by = worker_id;
113 |
114 | RETURN v_row;
115 | END;
116 | $$ LANGUAGE plpgsql;
117 |
118 | CREATE FUNCTION app_jobs.fail_job(worker_id varchar, job_id int, error_message varchar) RETURNS app_jobs.jobs AS $$
119 | DECLARE
120 | v_row app_jobs.jobs;
121 | BEGIN
122 | UPDATE app_jobs.jobs
123 | SET
124 | last_error = error_message,
125 | run_at = greatest(now(), run_at) + (exp(least(attempts, 10))::text || ' seconds')::interval
126 | WHERE id = job_id
127 | RETURNING * INTO v_row;
128 |
129 | UPDATE app_jobs.job_queues
130 | SET locked_by = null, locked_at = null
131 | WHERE queue_name = v_row.queue_name AND locked_by = worker_id;
132 |
133 | RETURN v_row;
134 | END;
135 | $$ LANGUAGE plpgsql;
136 |
137 | CREATE FUNCTION app_jobs.get_job(worker_id varchar, identifiers varchar[]) RETURNS app_jobs.jobs AS $$
138 | DECLARE
139 | v_job_id int;
140 | v_queue_name varchar;
141 | v_default_job_expiry text = (4 * 60 * 60)::text;
142 | v_default_job_maximum_attempts text = '25';
143 | v_row app_jobs.jobs;
144 | BEGIN
145 | IF worker_id IS NULL OR length(worker_id) < 10 THEN
146 | RAISE EXCEPTION 'Invalid worker ID';
147 | END IF;
148 |
149 | SELECT job_queues.queue_name, jobs.id INTO v_queue_name, v_job_id
150 | FROM app_jobs.job_queues
151 | INNER JOIN app_jobs.jobs USING (queue_name)
152 | WHERE (locked_at IS NULL OR locked_at < (now() - (COALESCE(current_setting('jobs.expiry', true), v_default_job_expiry) || ' seconds')::interval))
153 | AND run_at <= now()
154 | AND attempts < COALESCE(current_setting('jobs.maximum_attempts', true), v_default_job_maximum_attempts)::int
155 | AND (identifiers IS NULL OR task_identifier = any(identifiers))
156 | ORDER BY priority ASC, run_at ASC, id ASC
157 | LIMIT 1
158 | FOR UPDATE SKIP LOCKED;
159 |
160 | IF v_queue_name IS NULL THEN
161 | RETURN NULL;
162 | END IF;
163 |
164 | UPDATE app_jobs.job_queues
165 | SET
166 | locked_by = worker_id,
167 | locked_at = now()
168 | WHERE job_queues.queue_name = v_queue_name;
169 |
170 | UPDATE app_jobs.jobs
171 | SET attempts = attempts + 1
172 | WHERE id = v_job_id
173 | RETURNING * INTO v_row;
174 |
175 | RETURN v_row;
176 | END;
177 | $$ LANGUAGE plpgsql;
178 |
179 | -- END: JOBS
180 |
--------------------------------------------------------------------------------
/db/200_schemas.sql:
--------------------------------------------------------------------------------
1 | create schema app_public;
2 | create schema app_private;
3 |
4 | grant usage on schema app_public to graphiledemo_visitor;
5 |
6 | -- This allows inserts without granting permission to the serial primary key column.
7 | alter default privileges for role graphiledemo in schema app_public grant usage, select on sequences to graphiledemo_visitor;
8 |
--------------------------------------------------------------------------------
/db/300_utils.sql:
--------------------------------------------------------------------------------
1 | create function app_private.tg__add_job_for_row() returns trigger as $$
2 | begin
3 | perform app_jobs.add_job(tg_argv[0], json_build_object('id', NEW.id));
4 | return NEW;
5 | end;
6 | $$ language plpgsql set search_path from current;
7 |
8 | comment on function app_private.tg__add_job_for_row() is
9 | E'Useful shortcut to create a job on insert or update. Pass the task name as the trigger argument, and the record id will automatically be available on the JSON payload.';
10 |
11 | --------------------------------------------------------------------------------
12 |
13 | create function app_private.tg__update_timestamps() returns trigger as $$
14 | begin
15 | NEW.created_at = (case when TG_OP = 'INSERT' then NOW() else OLD.created_at end);
16 | NEW.updated_at = (case when TG_OP = 'UPDATE' and OLD.updated_at >= NOW() then OLD.updated_at + interval '1 millisecond' else NOW() end);
17 | return NEW;
18 | end;
19 | $$ language plpgsql volatile set search_path from current;
20 |
21 | comment on function app_private.tg__update_timestamps() is
22 | E'This trigger should be called on all tables with created_at, updated_at - it ensures that they cannot be manipulated and that updated_at will always be larger than the previous updated_at.';
23 |
--------------------------------------------------------------------------------
/db/400_users.sql:
--------------------------------------------------------------------------------
1 | create function app_public.current_user_id() returns int as $$
2 | select nullif(current_setting('jwt.claims.user_id', true), '')::int;
3 | $$ language sql stable set search_path from current;
4 | comment on function app_public.current_user_id() is
5 | E'@omit\nHandy method to get the current user ID for use in RLS policies, etc; in GraphQL, use `currentUser{id}` instead.';
6 |
7 | --------------------------------------------------------------------------------
8 |
9 | create table app_public.users (
10 | id serial primary key,
11 | username citext not null unique check(username ~ '^[a-zA-Z]([a-zA-Z0-9][_]?)+$'),
12 | name text,
13 | avatar_url text check(avatar_url ~ '^https?://[^/]+'),
14 | is_admin boolean not null default false,
15 | created_at timestamptz not null default now(),
16 | updated_at timestamptz not null default now()
17 | );
18 | alter table app_public.users enable row level security;
19 |
20 | create trigger _100_timestamps
21 | before insert or update on app_public.users
22 | for each row
23 | execute procedure app_private.tg__update_timestamps();
24 |
25 | -- By doing `@omit all` we prevent the `allUsers` field from appearing in our
26 | -- GraphQL schema. User discovery is still possible by browsing the rest of
27 | -- the data, but it makes it harder for people to receive a `totalCount` of
28 | -- users, or enumerate them fully.
29 | comment on table app_public.users is
30 | E'@omit all\nA user who can log in to the application.';
31 |
32 | comment on column app_public.users.id is
33 | E'Unique identifier for the user.';
34 | comment on column app_public.users.username is
35 | E'Public-facing username (or ''handle'') of the user.';
36 | comment on column app_public.users.name is
37 | E'Public-facing name (or pseudonym) of the user.';
38 | comment on column app_public.users.avatar_url is
39 | E'Optional avatar URL.';
40 | comment on column app_public.users.is_admin is
41 | E'If true, the user has elevated privileges.';
42 |
43 | create policy select_all on app_public.users for select using (true);
44 | create policy update_self on app_public.users for update using (id = app_public.current_user_id());
45 | create policy delete_self on app_public.users for delete using (id = app_public.current_user_id());
46 | grant select on app_public.users to graphiledemo_visitor;
47 | grant update(name, avatar_url) on app_public.users to graphiledemo_visitor;
48 | grant delete on app_public.users to graphiledemo_visitor;
49 |
50 | create function app_private.tg_users__make_first_user_admin() returns trigger as $$
51 | begin
52 | if not exists(select 1 from app_public.users) then
53 | NEW.is_admin = true;
54 | end if;
55 | return NEW;
56 | end;
57 | $$ language plpgsql volatile set search_path from current;
58 | create trigger _200_make_first_user_admin
59 | before insert on app_public.users
60 | for each row
61 | execute procedure app_private.tg_users__make_first_user_admin();
62 |
63 | --------------------------------------------------------------------------------
64 |
65 | create function app_public.current_user_is_admin() returns bool as $$
66 | -- We're using exists here because it guarantees true/false rather than true/false/null
67 | select exists(
68 | select 1 from app_public.users where id = app_public.current_user_id() and is_admin = true
69 | );
70 | $$ language sql stable set search_path from current;
71 | comment on function app_public.current_user_is_admin() is
72 | E'@omit\nHandy method to determine if the current user is an admin, for use in RLS policies, etc; in GraphQL should use `currentUser{isAdmin}` instead.';
73 |
74 | --------------------------------------------------------------------------------
75 |
76 | create function app_public.current_user() returns app_public.users as $$
77 | select users.* from app_public.users where id = app_public.current_user_id();
78 | $$ language sql stable set search_path from current;
79 |
80 | --------------------------------------------------------------------------------
81 |
82 | create table app_private.user_secrets (
83 | user_id int not null primary key references app_public.users,
84 | password_hash text,
85 | password_attempts int not null default 0,
86 | first_failed_password_attempt timestamptz,
87 | reset_password_token text,
88 | reset_password_token_generated timestamptz,
89 | reset_password_attempts int not null default 0,
90 | first_failed_reset_password_attempt timestamptz
91 | );
92 |
93 | comment on table app_private.user_secrets is
94 | E'The contents of this table should never be visible to the user. Contains data mostly related to authentication.';
95 |
96 | create function app_private.tg_user_secrets__insert_with_user() returns trigger as $$
97 | begin
98 | insert into app_private.user_secrets(user_id) values(NEW.id);
99 | return NEW;
100 | end;
101 | $$ language plpgsql volatile set search_path from current;
102 | create trigger _500_insert_secrets
103 | after insert on app_public.users
104 | for each row
105 | execute procedure app_private.tg_user_secrets__insert_with_user();
106 |
107 | comment on function app_private.tg_user_secrets__insert_with_user() is
108 | E'Ensures that every user record has an associated user_secret record.';
109 |
110 | --------------------------------------------------------------------------------
111 |
112 | create table app_public.user_emails (
113 | id serial primary key,
114 | user_id int not null default app_public.current_user_id() references app_public.users on delete cascade,
115 | email citext not null check (email ~ '[^@]+@[^@]+\.[^@]+'),
116 | is_verified boolean not null default false,
117 | created_at timestamptz not null default now(),
118 | updated_at timestamptz not null default now(),
119 | unique(user_id, email)
120 | );
121 |
122 | create unique index uniq_user_emails_verified_email on app_public.user_emails(email) where is_verified is true;
123 | alter table app_public.user_emails enable row level security;
124 | create trigger _100_timestamps
125 | before insert or update on app_public.user_emails
126 | for each row
127 | execute procedure app_private.tg__update_timestamps();
128 | create trigger _900_send_verification_email
129 | after insert on app_public.user_emails
130 | for each row when (NEW.is_verified is false)
131 | execute procedure app_private.tg__add_job_for_row('user_emails__send_verification');
132 |
133 | -- `@omit all` because there's no point exposing `allUserEmails` - you can only
134 | -- see your own, and having this behaviour can lead to bad practices from
135 | -- frontend teams.
136 | comment on table app_public.user_emails is
137 | E'@omit all\nInformation about a user''s email address.';
138 | comment on column app_public.user_emails.email is
139 | E'The users email address, in `a@b.c` format.';
140 | comment on column app_public.user_emails.is_verified is
141 | E'True if the user has is_verified their email address (by clicking the link in the email we sent them, or logging in with a social login provider), false otherwise.';
142 |
143 | create policy select_own on app_public.user_emails for select using (user_id = app_public.current_user_id());
144 | create policy insert_own on app_public.user_emails for insert with check (user_id = app_public.current_user_id());
145 | create policy delete_own on app_public.user_emails for delete using (user_id = app_public.current_user_id()); -- TODO check this isn't the last one!
146 | grant select on app_public.user_emails to graphiledemo_visitor;
147 | grant insert (email) on app_public.user_emails to graphiledemo_visitor;
148 | grant delete on app_public.user_emails to graphiledemo_visitor;
149 |
150 | --------------------------------------------------------------------------------
151 |
152 | create table app_private.user_email_secrets (
153 | user_email_id int primary key references app_public.user_emails on delete cascade,
154 | verification_token text,
155 | password_reset_email_sent_at timestamptz
156 | );
157 | alter table app_private.user_email_secrets enable row level security;
158 |
159 | comment on table app_private.user_email_secrets is
160 | E'The contents of this table should never be visible to the user. Contains data mostly related to email verification and avoiding spamming users.';
161 | comment on column app_private.user_email_secrets.password_reset_email_sent_at is
162 | E'We store the time the last password reset was sent to this email to prevent the email getting flooded.';
163 |
164 | create function app_private.tg_user_email_secrets__insert_with_user_email() returns trigger as $$
165 | declare
166 | v_verification_token text;
167 | begin
168 | if NEW.is_verified is false then
169 | v_verification_token = encode(gen_random_bytes(4), 'hex');
170 | end if;
171 | insert into app_private.user_email_secrets(user_email_id, verification_token) values(NEW.id, v_verification_token);
172 | return NEW;
173 | end;
174 | $$ language plpgsql volatile set search_path from current;
175 | create trigger _500_insert_secrets
176 | after insert on app_public.user_emails
177 | for each row
178 | execute procedure app_private.tg_user_email_secrets__insert_with_user_email();
179 | comment on function app_private.tg_user_email_secrets__insert_with_user_email() is
180 | E'Ensures that every user_email record has an associated user_email_secret record.';
181 |
182 | --------------------------------------------------------------------------------
183 |
184 | create table app_public.user_authentications (
185 | id serial primary key,
186 | user_id int not null references app_public.users on delete cascade,
187 | service text not null,
188 | identifier text not null,
189 | details jsonb not null default '{}'::jsonb,
190 | created_at timestamptz not null default now(),
191 | updated_at timestamptz not null default now(),
192 | constraint uniq_user_authentications unique(service, identifier)
193 | );
194 | alter table app_public.user_authentications enable row level security;
195 | create trigger _100_timestamps
196 | before insert or update on app_public.user_authentications
197 | for each row
198 | execute procedure app_private.tg__update_timestamps();
199 |
200 | comment on table app_public.user_authentications is
201 | E'@omit all\nContains information about the login providers this user has used, so that they may disconnect them should they wish.';
202 | comment on column app_public.user_authentications.user_id is
203 | E'@omit';
204 | comment on column app_public.user_authentications.service is
205 | E'The login service used, e.g. `twitter` or `github`.';
206 | comment on column app_public.user_authentications.identifier is
207 | E'A unique identifier for the user within the login service.';
208 | comment on column app_public.user_authentications.details is
209 | E'@omit\nAdditional profile details extracted from this login method';
210 |
211 | create policy select_own on app_public.user_authentications for select using (user_id = app_public.current_user_id());
212 | create policy delete_own on app_public.user_authentications for delete using (user_id = app_public.current_user_id()); -- TODO check this isn't the last one, or that they have a verified email address
213 | grant select on app_public.user_authentications to graphiledemo_visitor;
214 | grant delete on app_public.user_authentications to graphiledemo_visitor;
215 |
216 | --------------------------------------------------------------------------------
217 |
218 | create table app_private.user_authentication_secrets (
219 | user_authentication_id int not null primary key references app_public.user_authentications on delete cascade,
220 | details jsonb not null default '{}'::jsonb
221 | );
222 | alter table app_private.user_authentication_secrets enable row level security;
223 |
224 | -- NOTE: user_authentication_secrets doesn't need an auto-inserter as we handle
225 | -- that everywhere that can create a user_authentication row.
226 |
227 | --------------------------------------------------------------------------------
228 |
229 | create function app_public.forgot_password(email text) returns boolean as $$
230 | declare
231 | v_user_email app_public.user_emails;
232 | v_reset_token text;
233 | v_reset_min_duration_between_emails interval = interval '30 minutes';
234 | v_reset_max_duration interval = interval '3 days';
235 | begin
236 | -- Find the matching user_email
237 | select user_emails.* into v_user_email
238 | from app_public.user_emails
239 | where user_emails.email = forgot_password.email::citext
240 | order by is_verified desc, id desc;
241 |
242 | if not (v_user_email is null) then
243 | -- See if we've triggered a reset recently
244 | if exists(
245 | select 1
246 | from app_private.user_email_secrets
247 | where user_email_id = v_user_email.id
248 | and password_reset_email_sent_at is not null
249 | and password_reset_email_sent_at > now() - v_reset_min_duration_between_emails
250 | ) then
251 | return true;
252 | end if;
253 |
254 | -- Fetch or generate reset token
255 | update app_private.user_secrets
256 | set
257 | reset_password_token = (
258 | case
259 | when reset_password_token is null or reset_password_token_generated < NOW() - v_reset_max_duration
260 | then encode(gen_random_bytes(6), 'hex')
261 | else reset_password_token
262 | end
263 | ),
264 | reset_password_token_generated = (
265 | case
266 | when reset_password_token is null or reset_password_token_generated < NOW() - v_reset_max_duration
267 | then now()
268 | else reset_password_token_generated
269 | end
270 | )
271 | where user_id = v_user_email.user_id
272 | returning reset_password_token into v_reset_token;
273 |
274 | -- Don't allow spamming an email
275 | update app_private.user_email_secrets
276 | set password_reset_email_sent_at = now()
277 | where user_email_id = v_user_email.id;
278 |
279 | -- Trigger email send
280 | perform app_jobs.add_job('user__forgot_password', json_build_object('id', v_user_email.user_id, 'email', v_user_email.email::text, 'token', v_reset_token));
281 | return true;
282 |
283 | end if;
284 | return false;
285 | end;
286 | $$ language plpgsql strict security definer volatile set search_path from current;
287 |
288 | comment on function app_public.forgot_password(email text) is
289 | E'@resultFieldName success\nIf you''ve forgotten your password, give us one of your email addresses and we'' send you a reset token. Note this only works if you have added an email address!';
290 |
291 | --------------------------------------------------------------------------------
292 |
293 | create function app_private.login(username text, password text) returns app_public.users as $$
294 | declare
295 | v_user app_public.users;
296 | v_user_secret app_private.user_secrets;
297 | v_login_attempt_window_duration interval = interval '6 hours';
298 | begin
299 | select users.* into v_user
300 | from app_public.users
301 | where
302 | -- Match username against users username, or any verified email address
303 | (
304 | users.username = login.username
305 | or
306 | exists(
307 | select 1
308 | from app_public.user_emails
309 | where user_id = users.id
310 | and is_verified is true
311 | and email = login.username::citext
312 | )
313 | );
314 |
315 | if not (v_user is null) then
316 | -- Load their secrets
317 | select * into v_user_secret from app_private.user_secrets
318 | where user_secrets.user_id = v_user.id;
319 |
320 | -- Have there been too many login attempts?
321 | if (
322 | v_user_secret.first_failed_password_attempt is not null
323 | and
324 | v_user_secret.first_failed_password_attempt > NOW() - v_login_attempt_window_duration
325 | and
326 | v_user_secret.password_attempts >= 20
327 | ) then
328 | raise exception 'User account locked - too many login attempts' using errcode = 'LOCKD';
329 | end if;
330 |
331 | -- Not too many login attempts, let's check the password
332 | if v_user_secret.password_hash = crypt(password, v_user_secret.password_hash) then
333 | -- Excellent - they're loggged in! Let's reset the attempt tracking
334 | update app_private.user_secrets
335 | set password_attempts = 0, first_failed_password_attempt = null
336 | where user_id = v_user.id;
337 | return v_user;
338 | else
339 | -- Wrong password, bump all the attempt tracking figures
340 | update app_private.user_secrets
341 | set
342 | password_attempts = (case when first_failed_password_attempt is null or first_failed_password_attempt < now() - v_login_attempt_window_duration then 1 else password_attempts + 1 end),
343 | first_failed_password_attempt = (case when first_failed_password_attempt is null or first_failed_password_attempt < now() - v_login_attempt_window_duration then now() else first_failed_password_attempt end)
344 | where user_id = v_user.id;
345 | return null;
346 | end if;
347 | else
348 | -- No user with that email/username was found
349 | return null;
350 | end if;
351 | end;
352 | $$ language plpgsql strict security definer volatile set search_path from current;
353 |
354 | comment on function app_private.login(username text, password text) is
355 | E'Returns a user that matches the username/password combo, or null on failure.';
356 |
357 | --------------------------------------------------------------------------------
358 |
359 | create function app_public.reset_password(user_id int, reset_token text, new_password text) returns app_public.users as $$
360 | declare
361 | v_user app_public.users;
362 | v_user_secret app_private.user_secrets;
363 | v_reset_max_duration interval = interval '3 days';
364 | begin
365 | select users.* into v_user
366 | from app_public.users
367 | where id = user_id;
368 |
369 | if not (v_user is null) then
370 | -- Load their secrets
371 | select * into v_user_secret from app_private.user_secrets
372 | where user_secrets.user_id = v_user.id;
373 |
374 | -- Have there been too many reset attempts?
375 | if (
376 | v_user_secret.first_failed_reset_password_attempt is not null
377 | and
378 | v_user_secret.first_failed_reset_password_attempt > NOW() - v_reset_max_duration
379 | and
380 | v_user_secret.reset_password_attempts >= 20
381 | ) then
382 | raise exception 'Password reset locked - too many reset attempts' using errcode = 'LOCKD';
383 | end if;
384 |
385 | -- Not too many reset attempts, let's check the token
386 | if v_user_secret.reset_password_token = reset_token then
387 | -- Excellent - they're legit; let's reset the password as requested
388 | update app_private.user_secrets
389 | set
390 | password_hash = crypt(new_password, gen_salt('bf')),
391 | password_attempts = 0,
392 | first_failed_password_attempt = null,
393 | reset_password_token = null,
394 | reset_password_token_generated = null,
395 | reset_password_attempts = 0,
396 | first_failed_reset_password_attempt = null
397 | where user_secrets.user_id = v_user.id;
398 | return v_user;
399 | else
400 | -- Wrong token, bump all the attempt tracking figures
401 | update app_private.user_secrets
402 | set
403 | reset_password_attempts = (case when first_failed_reset_password_attempt is null or first_failed_reset_password_attempt < now() - v_reset_max_duration then 1 else reset_password_attempts + 1 end),
404 | first_failed_reset_password_attempt = (case when first_failed_reset_password_attempt is null or first_failed_reset_password_attempt < now() - v_reset_max_duration then now() else first_failed_reset_password_attempt end)
405 | where user_secrets.user_id = v_user.id;
406 | return null;
407 | end if;
408 | else
409 | -- No user with that id was found
410 | return null;
411 | end if;
412 | end;
413 | $$ language plpgsql strict volatile security definer set search_path from current;
414 |
415 | comment on function app_public.reset_password(user_id int, reset_token text, new_password text) is
416 | E'After triggering forgotPassword, you''ll be sent a reset token. Combine this with your user ID and a new password to reset your password.';
417 |
418 | --------------------------------------------------------------------------------
419 |
420 |
421 | create function app_private.really_create_user(username text, email text, email_is_verified bool, name text, avatar_url text, password text default null) returns app_public.users as $$
422 | declare
423 | v_user app_public.users;
424 | v_username text = username;
425 | begin
426 | -- Sanitise the username, and make it unique if necessary.
427 | if v_username is null then
428 | v_username = coalesce(name, 'user');
429 | end if;
430 | v_username = regexp_replace(v_username, '^[^a-z]+', '', 'i');
431 | v_username = regexp_replace(v_username, '[^a-z0-9]+', '_', 'i');
432 | if v_username is null or length(v_username) < 3 then
433 | v_username = 'user';
434 | end if;
435 | select (
436 | case
437 | when i = 0 then v_username
438 | else v_username || i::text
439 | end
440 | ) into v_username from generate_series(0, 1000) i
441 | where not exists(
442 | select 1
443 | from app_public.users
444 | where users.username = (
445 | case
446 | when i = 0 then v_username
447 | else v_username || i::text
448 | end
449 | )
450 | )
451 | limit 1;
452 |
453 | -- Insert the new user
454 | insert into app_public.users (username, name, avatar_url) values
455 | (v_username, name, avatar_url)
456 | returning * into v_user;
457 |
458 | -- Add the user's email
459 | if email is not null then
460 | insert into app_public.user_emails (user_id, email, is_verified)
461 | values (v_user.id, email, email_is_verified);
462 | end if;
463 |
464 | -- Store the password
465 | if password is not null then
466 | update app_private.user_secrets
467 | set password_hash = crypt(password, gen_salt('bf'))
468 | where user_id = v_user.id;
469 | end if;
470 |
471 | return v_user;
472 | end;
473 | $$ language plpgsql volatile set search_path from current;
474 |
475 | comment on function app_private.really_create_user(username text, email text, email_is_verified bool, name text, avatar_url text, password text) is
476 | E'Creates a user account. All arguments are optional, it trusts the calling method to perform sanitisation.';
477 |
478 | --------------------------------------------------------------------------------
479 |
480 | create function app_private.register_user(f_service character varying, f_identifier character varying, f_profile json, f_auth_details json, f_email_is_verified boolean default false) returns app_public.users as $$
481 | declare
482 | v_user app_public.users;
483 | v_email citext;
484 | v_name text;
485 | v_username text;
486 | v_avatar_url text;
487 | v_user_authentication_id int;
488 | begin
489 | -- Extract data from the user’s OAuth profile data.
490 | v_email := f_profile ->> 'email';
491 | v_name := f_profile ->> 'name';
492 | v_username := f_profile ->> 'username';
493 | v_avatar_url := f_profile ->> 'avatar_url';
494 |
495 | -- Create the user account
496 | v_user = app_private.really_create_user(
497 | username => v_username,
498 | email => v_email,
499 | email_is_verified => f_email_is_verified,
500 | name => v_name,
501 | avatar_url => v_avatar_url
502 | );
503 |
504 | -- Insert the user’s private account data (e.g. OAuth tokens)
505 | insert into app_public.user_authentications (user_id, service, identifier, details) values
506 | (v_user.id, f_service, f_identifier, f_profile) returning id into v_user_authentication_id;
507 | insert into app_private.user_authentication_secrets (user_authentication_id, details) values
508 | (v_user_authentication_id, f_auth_details);
509 |
510 | return v_user;
511 | end;
512 | $$ language plpgsql volatile security definer set search_path from current;
513 |
514 | comment on function app_private.register_user(f_service character varying, f_identifier character varying, f_profile json, f_auth_details json, f_email_is_verified boolean) is
515 | E'Used to register a user from information gleaned from OAuth. Primarily used by link_or_register_user';
516 |
517 | --------------------------------------------------------------------------------
518 |
519 | create function app_private.link_or_register_user(
520 | f_user_id integer,
521 | f_service character varying,
522 | f_identifier character varying,
523 | f_profile json,
524 | f_auth_details json
525 | ) returns app_public.users as $$
526 | declare
527 | v_matched_user_id int;
528 | v_matched_authentication_id int;
529 | v_email citext;
530 | v_name text;
531 | v_avatar_url text;
532 | v_user app_public.users;
533 | v_user_email app_public.user_emails;
534 | begin
535 | -- See if a user account already matches these details
536 | select id, user_id
537 | into v_matched_authentication_id, v_matched_user_id
538 | from app_public.user_authentications
539 | where service = f_service
540 | and identifier = f_identifier
541 | limit 1;
542 |
543 | if v_matched_user_id is not null and f_user_id is not null and v_matched_user_id <> f_user_id then
544 | raise exception 'A different user already has this account linked.' using errcode='TAKEN';
545 | end if;
546 |
547 | v_email = f_profile ->> 'email';
548 | v_name := f_profile ->> 'name';
549 | v_avatar_url := f_profile ->> 'avatar_url';
550 |
551 | if v_matched_authentication_id is null then
552 | if f_user_id is not null then
553 | -- Link new account to logged in user account
554 | insert into app_public.user_authentications (user_id, service, identifier, details) values
555 | (f_user_id, f_service, f_identifier, f_profile) returning id, user_id into v_matched_authentication_id, v_matched_user_id;
556 | insert into app_private.user_authentication_secrets (user_authentication_id, details) values
557 | (v_matched_authentication_id, f_auth_details);
558 | elsif v_email is not null then
559 | -- See if the email is registered
560 | select * into v_user_email from app_public.user_emails where email = v_email and is_verified is true;
561 | if not (v_user_email is null) then
562 | -- User exists!
563 | insert into app_public.user_authentications (user_id, service, identifier, details) values
564 | (v_user_email.user_id, f_service, f_identifier, f_profile) returning id, user_id into v_matched_authentication_id, v_matched_user_id;
565 | insert into app_private.user_authentication_secrets (user_authentication_id, details) values
566 | (v_matched_authentication_id, f_auth_details);
567 | end if;
568 | end if;
569 | end if;
570 | if v_matched_user_id is null and f_user_id is null and v_matched_authentication_id is null then
571 | -- Create and return a new user account
572 | return app_private.register_user(f_service, f_identifier, f_profile, f_auth_details, true);
573 | else
574 | if v_matched_authentication_id is not null then
575 | update app_public.user_authentications
576 | set details = f_profile
577 | where id = v_matched_authentication_id;
578 | update app_private.user_authentication_secrets
579 | set details = f_auth_details
580 | where user_authentication_id = v_matched_authentication_id;
581 | update app_public.users
582 | set
583 | name = coalesce(users.name, v_name),
584 | avatar_url = coalesce(users.avatar_url, v_avatar_url)
585 | where id = v_matched_user_id
586 | returning * into v_user;
587 | return v_user;
588 | else
589 | -- v_matched_authentication_id is null
590 | -- -> v_matched_user_id is null (they're paired)
591 | -- -> f_user_id is not null (because the if clause above)
592 | -- -> v_matched_authentication_id is not null (because of the separate if block above creating a user_authentications)
593 | -- -> contradiction.
594 | raise exception 'This should not occur';
595 | end if;
596 | end if;
597 | end;
598 | $$ language plpgsql volatile security definer set search_path from current;
599 |
600 | comment on function app_private.link_or_register_user(f_user_id integer, f_service character varying, f_identifier character varying, f_profile json, f_auth_details json) is
601 | E'If you''re logged in, this will link an additional OAuth login to your account if necessary. If you''re logged out it may find if an account already exists (based on OAuth details or email address) and return that, or create a new user account if necessary.';
602 |
603 |
--------------------------------------------------------------------------------
/db/700_forum.sql:
--------------------------------------------------------------------------------
1 | -- Forum example
2 |
3 | create table app_public.forums (
4 | id serial primary key,
5 | slug text not null check(length(slug) < 30 and slug ~ '^([a-z0-9]-?)+$') unique,
6 | name text not null check(length(name) > 0),
7 | description text not null default '',
8 | created_at timestamptz not null default now(),
9 | updated_at timestamptz not null default now()
10 | );
11 | alter table app_public.forums enable row level security;
12 | create trigger _100_timestamps
13 | before insert or update on app_public.forums
14 | for each row
15 | execute procedure app_private.tg__update_timestamps();
16 |
17 | comment on table app_public.forums is
18 | E'A subject-based grouping of topics and posts.';
19 | comment on column app_public.forums.slug is
20 | E'An URL-safe alias for the `Forum`.';
21 | comment on column app_public.forums.name is
22 | E'The name of the `Forum` (indicates its subject matter).';
23 | comment on column app_public.forums.description is
24 | E'A brief description of the `Forum` including it''s purpose.';
25 |
26 | create policy select_all on app_public.forums for select using (true);
27 | create policy insert_admin on app_public.forums for insert with check (app_public.current_user_is_admin());
28 | create policy update_admin on app_public.forums for update using (app_public.current_user_is_admin());
29 | create policy delete_admin on app_public.forums for delete using (app_public.current_user_is_admin());
30 | grant select on app_public.forums to graphiledemo_visitor;
31 | grant insert(slug, name, description) on app_public.forums to graphiledemo_visitor;
32 | grant update(slug, name, description) on app_public.forums to graphiledemo_visitor;
33 | grant delete on app_public.forums to graphiledemo_visitor;
34 |
35 | --------------------------------------------------------------------------------
36 |
37 | create table app_public.topics (
38 | id serial primary key,
39 | forum_id int not null references app_public.forums on delete cascade,
40 | author_id int not null default app_public.current_user_id() references app_public.users on delete cascade,
41 | title text not null check(length(title) > 0),
42 | body text not null default '',
43 | created_at timestamptz not null default now(),
44 | updated_at timestamptz not null default now()
45 | );
46 | alter table app_public.topics enable row level security;
47 | create trigger _100_timestamps
48 | before insert or update on app_public.topics
49 | for each row
50 | execute procedure app_private.tg__update_timestamps();
51 |
52 | comment on table app_public.topics is
53 | E'@omit all\nAn individual message thread within a Forum.';
54 | comment on column app_public.topics.title is
55 | E'The title of the `Topic`.';
56 | comment on column app_public.topics.body is
57 | E'The body of the `Topic`, which Posts reply to.';
58 |
59 | create policy select_all on app_public.topics for select using (true);
60 | create policy insert_admin on app_public.topics for insert with check (author_id = app_public.current_user_id());
61 | create policy update_admin on app_public.topics for update using (author_id = app_public.current_user_id() or app_public.current_user_is_admin());
62 | create policy delete_admin on app_public.topics for delete using (author_id = app_public.current_user_id() or app_public.current_user_is_admin());
63 | grant select on app_public.topics to graphiledemo_visitor;
64 | grant insert(forum_id, title, body) on app_public.topics to graphiledemo_visitor;
65 | grant update(title, body) on app_public.topics to graphiledemo_visitor;
66 | grant delete on app_public.topics to graphiledemo_visitor;
67 |
68 | create function app_public.topics_body_summary(
69 | t app_public.topics,
70 | max_length int = 30
71 | )
72 | returns text
73 | language sql
74 | stable
75 | set search_path from current
76 | as $$
77 | select case
78 | when length(t.body) > max_length
79 | then left(t.body, max_length - 3) || '...'
80 | else t.body
81 | end;
82 | $$;
83 |
84 | --------------------------------------------------------------------------------
85 |
86 | create table app_public.posts (
87 | id serial primary key,
88 | topic_id int not null references app_public.topics on delete cascade,
89 | author_id int not null default app_public.current_user_id() references app_public.users on delete cascade,
90 | body text not null default '',
91 | created_at timestamptz not null default now(),
92 | updated_at timestamptz not null default now()
93 | );
94 | alter table app_public.posts enable row level security;
95 | create trigger _100_timestamps
96 | before insert or update on app_public.posts
97 | for each row
98 | execute procedure app_private.tg__update_timestamps();
99 |
100 | comment on table app_public.posts is
101 | E'@omit all\nAn individual message thread within a Forum.';
102 | comment on column app_public.posts.id is
103 | E'@omit create,update';
104 | comment on column app_public.posts.topic_id is
105 | E'@omit update';
106 | comment on column app_public.posts.author_id is
107 | E'@omit create,update';
108 | comment on column app_public.posts.body is
109 | E'The body of the `Topic`, which Posts reply to.';
110 | comment on column app_public.posts.created_at is
111 | E'@omit create,update';
112 | comment on column app_public.posts.updated_at is
113 | E'@omit create,update';
114 |
115 | create policy select_all on app_public.posts for select using (true);
116 | create policy insert_admin on app_public.posts for insert with check (author_id = app_public.current_user_id());
117 | create policy update_admin on app_public.posts for update using (author_id = app_public.current_user_id() or app_public.current_user_is_admin());
118 | create policy delete_admin on app_public.posts for delete using (author_id = app_public.current_user_id() or app_public.current_user_is_admin());
119 | grant select on app_public.posts to graphiledemo_visitor;
120 | grant insert(topic_id, body) on app_public.posts to graphiledemo_visitor;
121 | grant update(body) on app_public.posts to graphiledemo_visitor;
122 | grant delete on app_public.posts to graphiledemo_visitor;
123 |
124 |
125 | create function app_public.random_number() returns int
126 | language sql stable
127 | as $$
128 | select 4;
129 | $$;
130 |
131 | comment on function app_public.random_number()
132 | is 'Chosen by fair dice roll. Guaranteed to be random. XKCD#221';
133 |
134 | create function app_public.forums_about_cats() returns setof app_public.forums
135 | language sql stable
136 | as $$
137 | select * from app_public.forums where slug like 'cat-%';
138 | $$;
139 |
--------------------------------------------------------------------------------
/db/999_data.sql:
--------------------------------------------------------------------------------
1 | select app_private.link_or_register_user(
2 | null,
3 | 'github',
4 | '6413628',
5 | '{}'::json,
6 | '{}'::json
7 | );
8 | select app_private.link_or_register_user(
9 | null,
10 | 'github',
11 | '222222',
12 | '{"name":"Chad F"}'::json,
13 | '{}'::json
14 | );
15 | select app_private.link_or_register_user(
16 | null,
17 | 'github',
18 | '333333',
19 | '{"name":"Bradley A"}'::json,
20 | '{}'::json
21 | );
22 | select app_private.link_or_register_user(
23 | null,
24 | 'github',
25 | '444444',
26 | '{"name":"Sam L"}'::json,
27 | '{}'::json
28 | );
29 | select app_private.link_or_register_user(
30 | null,
31 | 'github',
32 | '555555',
33 | '{"name":"Max D"}'::json,
34 | '{}'::json
35 | );
36 |
37 | insert into app_public.user_emails(user_id, email, is_verified) values
38 | (1, 'benjie@example.com', true);
39 |
40 | insert into app_public.forums(slug, name, description) values
41 | ('testimonials', 'Testimonials', 'How do you rate PostGraphile?'),
42 | ('feedback', 'Feedback', 'How are you finding PostGraphile?'),
43 | ('cat-life', 'Cat Life', 'A forum all about cats and how fluffy they are and how they completely ignore their owners unless there is food. Or yarn.'),
44 | ('cat-help', 'Cat Help', 'A forum to seek advice if your cat is becoming troublesome.');
45 |
46 |
47 | insert into app_public.topics(forum_id, author_id, title, body) values
48 | (1, 2, 'Thank you!', '500-1500 requests per second on a single server is pretty awesome.'),
49 | (1, 4, 'PostGraphile is powerful', 'PostGraphile is a powerful, idomatic, and elegant tool.'),
50 | (1, 5, 'Recently launched', 'At this point, it’s quite hard for me to come back and enjoy working with REST.'),
51 | (3, 1, 'I love cats!', 'They''re the best!');
52 |
53 | insert into app_public.posts(topic_id, author_id, body) values
54 | (1, 1, 'I''m super pleased with the performance - thanks!'),
55 | (2, 1, 'Thanks so much!'),
56 | (3, 1, 'Tell me about it - GraphQL is awesome!'),
57 | (4, 1, 'Dont you just love cats? Cats cats cats cats cats cats cats cats cats cats cats cats Cats cats cats cats cats cats cats cats cats cats cats cats'),
58 | (4, 2, 'Yeah cats are really fluffy I enjoy squising their fur they are so goregous and fluffy and squishy and fluffy and gorgeous and squishy and goregous and fluffy and squishy and fluffy and gorgeous and squishy'),
59 | (4, 3, 'I love it when they completely ignore you until they want something. So much better than dogs am I rite?');
60 |
--------------------------------------------------------------------------------
/db/CONVENTIONS.md:
--------------------------------------------------------------------------------
1 | # Conventions used in this database schema:
2 |
3 | ### Naming
4 |
5 | - snake_case for tables, functions, columns (avoids having to put them in quotes in most cases)
6 | - plural table names (avoids conflicts with e.g. `user` built ins, is better depluralized by PostGraphile)
7 | - trigger functions valid for one table only are named tg_[table_name]__[task_name]
8 | - trigger functions valid for many tables are named tg__[task_name]
9 | - trigger names should be prefixed with `_NNN_` where NNN is a three digit number that defines the priority of the trigger (use _500_ if unsure)
10 | - prefer lowercase over UPPERCASE, except for the `NEW`, `OLD` and `TG_OP` keywords. (This is Benjie's personal preference.)
11 |
12 | ### Security
13 |
14 | - all functions should define `set search_path from current` because of `CVE-2018-1058`
15 | - @omit smart comments should not be used for permissions, instead deferring to PostGraphile's RBAC support
16 | - all tables (public or not) should enable RLS
17 | - relevant RLS policy should be defined before granting a permission
18 | - `grant select` should never specify a column list; instead use one-to-one relations as permission boundaries
19 |
20 | ### Explicitness
21 |
22 | - all functions should explicitly state immutable/stable/volatile
23 | - do not override search_path for convenience - prefer to be explicit
24 |
25 | ### Functions
26 |
27 | - if a function can be expressed as a single SQL statement it should use the `sql` language if possible. Other functions should use `plpgsql`.
28 |
29 | ### Relations
30 |
31 | - all foreign key `references` statements should have `on delete` clauses. Some may also want `on update` clauses, but that's optional
32 | - all comments should be defined using '"escape" string constants' - e.g. `E'...'` - because this more easily allows adding smart comments
33 | - defining things (primary key, checks, unique constraints, etc) within the `create table` statement is preferable to adding them after
34 |
35 | ### General conventions (e.g. for PostGraphile compatibility)
36 |
37 | - avoid plv8 and other extensions that aren't built in because they can be complex for people to install (and this is a demo project)
38 | - functions should not use IN/OUT/INOUT parameters
39 | - @omit smart comments should be used heavily to remove fields we don't currently need in GraphQL - we can always remove them later
40 |
41 | ### Definitions
42 |
43 | Please adhere to the following templates (respecting newlines):
44 |
45 |
46 | Tables:
47 |
48 | ```sql
49 | create table . (
50 | ...
51 | );
52 | ```
53 |
54 | SQL functions:
55 |
56 | ```sql
57 | create function () returns as $$
58 | select ...
59 | from ...
60 | inner join ...
61 | on ...
62 | where ...
63 | and ...
64 | order by ...
65 | limit ...;
66 | $$ language sql set search_path from current;
67 | ```
68 |
69 | PL/pgSQL functions:
70 |
71 | ```sql
72 | create function () returns as $$
73 | declare
74 | v_[varname] [ = ];
75 | ...
76 | begin
77 | if ... then
78 | ...
79 | end if;
80 | return ;
81 | end;
82 | $$ language plpgsql set search_path from current;
83 | ```
84 |
85 | Triggers:
86 |
87 | ```sql
88 | create trigger _NNN_trigger_name
89 | on .
90 | for each row [when ()]
91 | execute procedure (...);
92 | ```
93 |
94 | Comments:
95 |
96 | ```sql
97 | comment on is
98 | E'...';
99 | ```
100 |
--------------------------------------------------------------------------------
/db/README.md:
--------------------------------------------------------------------------------
1 | # Database
2 |
3 | Since this is a realistic example, it has realistic concerns in it, such as
4 | mitigating brute force login attacks. This means that the example might be a
5 | lot bigger than you'd first expect; but it's designed to be a solid start you
6 | can use in your own applications (after a find and replace for 'graphiledemo'!)
7 | so when you start, jump straight to the files >= 500 as they contain the forum
8 | application logic.
9 |
10 | Note also that this application works with both social (OAuth) login (when used
11 | with a server that supports this), and with traditional username/password
12 | login, and the social login stores your access tokens so that the server-side
13 | may use them (e.g. to look up issues in GitHub when they're mentioned in one of
14 | your posts). This means the user tables might be significantly more complex
15 | than your application requires; feel free to simplify them when you build your
16 | own schema.
17 |
18 | ### Conventions
19 |
20 | With the exception of `100_jobs.sql` which was imported from a previous project
21 | and requires bringing in line, the SQL files in this repository try to adhere
22 | to the conventions defined in [CONVENTIONS.md](./CONVENTIONS.md). PRs to fix
23 | our adherence to these conventions would be welcome. Someone writing an SQL
24 | equivalent of ESLint and/or prettier would be even more welcome!
25 |
26 | ### Common logic
27 |
28 | Definitions < 500 are common to all sorts of applications, they solve common
29 | concerns such as storing user data, logging people in, triggering password
30 | reset emails, avoiding brute force attacks and more.
31 |
32 | `100_jobs.sql`: handles the job queue (tasks to run in the background, such
33 | as sending emails, polling APIs, etc).
34 |
35 | `200_schemas.sql`: defines our common schemas `app_public`, and `app_private`
36 | and adds base permissions to them.
37 |
38 | `300_utils.sql`: Useful utility functions.
39 |
40 | `400_users.sql`: Users, authentication, emails, brute force mitigation, etc.
41 |
42 |
43 | ### Application specific logic
44 |
45 | Definitions >= 500 are application specific, defining the tables in your
46 | application, and dealing with concerns such as a welcome email or customising
47 | the user tables to your whim. We use them here to add our forum-specific logic.
48 |
49 | `700_forum.sql`
50 |
51 | ### Migrations
52 |
53 | This project doesn't currently deal with migrations. Every time you pull down a
54 | new version you should reset your database; we do not (currently) care about
55 | supporting legacy versions of this example repo. There are many projects that
56 | help you deal with migrations, two of note are [sqitch](https://sqitch.org/)
57 | and
58 | [db-migrate](https://db-migrate.readthedocs.io/en/latest/Getting%20Started/usage/).
59 |
--------------------------------------------------------------------------------
/db/reset.sql:
--------------------------------------------------------------------------------
1 | -- First, we clean out the old stuff
2 |
3 | drop schema if exists app_public cascade;
4 | drop schema if exists app_hidden cascade;
5 | drop schema if exists app_private cascade;
6 | drop schema if exists app_jobs cascade;
7 |
8 | --------------------------------------------------------------------------------
9 |
10 | -- Definitions <500 are common to all sorts of applications,
11 | -- they solve common concerns such as storing user data,
12 | -- logging people in, triggering password reset emails,
13 | -- mitigating brute force attacks and more.
14 |
15 | -- Background worker tasks
16 | \ir 100_jobs.sql
17 |
18 | -- app_public, app_private and base permissions
19 | \ir 200_schemas.sql
20 |
21 | -- Useful utility functions
22 | \ir 300_utils.sql
23 |
24 | -- Users, authentication, emails, etc
25 | \ir 400_users.sql
26 |
27 | --------------------------------------------------------------------------------
28 |
29 | -- Definitions >=500 are application specific, defining the tables
30 | -- in your application, and dealing with concerns such as a welcome
31 | -- email or customising the user tables to your whim
32 |
33 | -- Forum tables
34 | \ir 700_forum.sql
35 |
36 | \ir 999_data.sql
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "scripts": {
3 | "db:reset": "psql -X1v ON_ERROR_STOP=1 graphiledemo -f ./db/reset.sql",
4 | "lint": "eslint .",
5 | "client:react": "cd client-react; npm start",
6 | "server:koa2": "cd server-koa2; npm start",
7 | "server:postgraphile": ". ./.env; postgraphile",
8 | "start": "concurrently --kill-others 'npm run server:koa2' 'npm run client:react'"
9 | },
10 | "private": true,
11 | "workspaces": {
12 | "packages": [
13 | "*"
14 | ],
15 | "nohoist": [
16 | "**/react-scripts",
17 | "**/react-scripts/**"
18 | ]
19 | },
20 | "devDependencies": {
21 | "babel-eslint": "9.0.0",
22 | "concurrently": "3.6.0",
23 | "eslint": "5.6.0",
24 | "eslint-config-prettier": "2.9.0",
25 | "eslint-plugin-graphql": "2.1.1",
26 | "eslint-plugin-prettier": "2.6.1",
27 | "eslint-plugin-react": "7.10.0",
28 | "eslint_d": "5.3.1",
29 | "prettier": "1.13.6"
30 | },
31 | "engines": {
32 | "node": ">=8.11.3 <11"
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/public/css/index.css:
--------------------------------------------------------------------------------
1 | body {
2 | margin: 0;
3 | padding: 0;
4 | font-family: sans-serif;
5 | }
6 |
7 | a {
8 | text-decoration: none;
9 | }
10 |
11 | .Header {
12 | background-color: #222;
13 | color: white;
14 | padding: 1rem;
15 | display: flex;
16 | flex-wrap: wrap;
17 | align-items: center;
18 | justify-content: space-between;
19 | }
20 | .Header a {
21 | color: white;
22 | }
23 |
24 | .Header-titleContainer {
25 | display: flex;
26 | align-items: center;
27 | }
28 |
29 | .Header-title {
30 | font-size: 2rem;
31 | }
32 |
33 | .Header-logo {
34 | height: 3.5rem;
35 | margin: -0.5rem 0;
36 | padding-right: 1rem;
37 | }
38 |
39 | .Main {
40 | padding: 1rem;
41 | }
42 |
43 | .Main > h1:first-child,
44 | .Main > h2:first-child,
45 | .Main > h3:first-child,
46 | .Main > h4:first-child,
47 | .Main > h5:first-child,
48 | .Main > h6:first-child {
49 | margin-top: 0;
50 | padding-top: 0;
51 | }
52 |
53 | .ForumItem-name {
54 | padding: 0.5rem;
55 | }
56 |
57 | .ForumItem-description {
58 | padding: 0.5rem;
59 | }
60 |
61 | .ForumItem-tools {
62 | background-color: #555;
63 | color: white;
64 | padding: 0.5rem;
65 | }
66 |
67 | .Forum-header, .ForumItem {
68 | margin-bottom: 15px;
69 | background-image: linear-gradient(-90deg, #32a3ff, #013f7b);
70 | color: white;
71 | padding: 12px;
72 | }
73 |
74 | .Forum-header {
75 | font-size: 2rem;
76 | line-height: 1.4em;
77 | }
78 |
79 | .Forum-header a, .ForumItem a {
80 | color: white;
81 | }
82 |
83 | .ForumItem-description {
84 | font-size: 1.2em;
85 | }
86 |
87 | .Forum-description {
88 | padding: 12px;
89 | margin-left: auto;
90 | margin-right: auto;
91 | margin-bottom: 15px;
92 | color: #919191;
93 | }
94 |
95 | .Topics-container, .Posts-container {
96 | border-collapse: collapse;
97 | max-width: 1110px;
98 | margin-left: auto;
99 | margin-right: auto;
100 | }
101 |
102 | .TopicItem {
103 | border-bottom: 1px solid #e9e9e9;
104 | display: table-row;
105 | }
106 |
107 | .TopicItem a, .WelcomeMessage a {
108 | color: #0074ca;
109 | }
110 |
111 | .TopicItem-title {
112 | padding-top: 12px;
113 | padding-bottom: 12px;
114 | padding-left: none;
115 | width: 500px;
116 | }
117 |
118 | .Topics-TopicItemHeader th {
119 | color: #919191;
120 | font-weight: normal;
121 | padding-top: 12px;
122 | padding-bottom: 12px;
123 | padding-left: none;
124 | text-align: left;
125 | }
126 |
127 | .Topics-container tbody {
128 | border-top: 3px solid #e9e9e9;
129 | }
130 |
131 | .TopicItem-user, .TopicItem-replies, TopicItem-date {
132 | width: 65px;
133 | padding-left: none;
134 | text-align: left;
135 | }
136 |
137 | .Topic-header {
138 | color: #222222;
139 | font-size: 1.7em;
140 | margin-bottom: 15px;
141 | font-size: 2rem;
142 | line-height: 1.4em;
143 | padding: 12px;
144 | }
145 |
146 | .Posts-container {
147 |
148 | }
149 |
150 | .PostItem {
151 | border-top: 1px solid #e9e9e9;
152 | display: flex;
153 | flex-direction: row;
154 | max-width: 1000px;
155 | padding: 1em 0;
156 | }
157 |
158 | .PostItem-meta {
159 | min-width: 200px;
160 | }
161 |
162 | .PostItem-user {
163 | font-weight: bold;
164 | }
165 |
166 | .PostItem-user--with-avatar {
167 | display: flex;
168 | flex-direction: column;
169 | }
170 |
171 | .PostItem-avatar {
172 | max-width: 50px;
173 | max-height: 50px;
174 | margin-bottom: .5em;
175 | }
176 |
177 | .PostItem-date {
178 | color: #919191;
179 | font-size: 0.8em;
180 | }
181 |
182 | .PostItem-body {
183 |
184 | }
185 |
186 | .PostItem-topic {
187 | border: none;
188 | }
189 |
--------------------------------------------------------------------------------
/scripts/schema_dump:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | set -e
3 | SCRIPTS_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
4 |
5 | # There's no easy way to exclude postgraphile_watch from the dump, so we drop and and restore it at the end
6 | echo "DROP SCHEMA IF EXISTS postgraphile_watch CASCADE;" | psql -X1 -v ON_ERROR_STOP=1 graphiledemo
7 |
8 | # Here we do a schema only dump of the graphiledemo DB to the data folder
9 | pg_dump -s -O -f ${SCRIPTS_DIR}/../data/schema.sql graphiledemo
10 |
11 | # Restore the watch schema
12 | cat ${SCRIPTS_DIR}/../node_modules/graphile-build-pg/res/watch-fixtures.sql | psql -X1 -v ON_ERROR_STOP=1 graphiledemo
13 |
--------------------------------------------------------------------------------
/server-koa2/middleware/index.js:
--------------------------------------------------------------------------------
1 | const fs = require("fs");
2 |
3 | const middlewares = fs
4 | .readdirSync(__dirname)
5 | .filter(fn => fn !== "index.js")
6 | .filter(fn => fn.match(/^[^.].*\.js$/))
7 | .map(str => str.slice(0, -3));
8 |
9 | middlewares.forEach(name => {
10 | // eslint-disable-next-line import/no-dynamic-require
11 | exports[name] = require(`./${name}`);
12 | });
13 |
--------------------------------------------------------------------------------
/server-koa2/middleware/installFrontendServer.js:
--------------------------------------------------------------------------------
1 | const httpProxy = require("http-proxy");
2 |
3 | module.exports = function installFrontendServer(app, server) {
4 | const proxy = httpProxy.createProxyServer({
5 | target: `http://localhost:${process.env.CLIENT_PORT}`,
6 | ws: true,
7 | });
8 | app.use(ctx => {
9 | // Bypass koa for HTTP proxying
10 | ctx.respond = false;
11 | proxy.web(ctx.req, ctx.res, {}, _e => {
12 | ctx.res.statusCode = 503;
13 | ctx.res.end(
14 | "Error occurred while proxying to client application - is it running?"
15 | );
16 | });
17 | });
18 | server.on("upgrade", (req, socket, head) => {
19 | proxy.ws(req, socket, head);
20 | });
21 | };
22 |
--------------------------------------------------------------------------------
/server-koa2/middleware/installPassport.js:
--------------------------------------------------------------------------------
1 | const passport = require("koa-passport");
2 | const route = require("koa-route");
3 | const { Strategy: GitHubStrategy } = require("passport-github");
4 |
5 | /*
6 | * This file uses regular Passport.js authentication, both for
7 | * username/password and for login with GitHub. You can easily add more OAuth
8 | * providers to this file. For more information, see:
9 | *
10 | * http://www.passportjs.org/
11 | */
12 |
13 | module.exports = function installPassport(app, { rootPgPool }) {
14 | passport.serializeUser((user, done) => {
15 | done(null, user.id);
16 | });
17 |
18 | passport.deserializeUser(async (id, callback) => {
19 | let error = null;
20 | let user;
21 | try {
22 | const {
23 | rows: [_user],
24 | } = await rootPgPool.query(
25 | `select users.* from app_public.users where users.id = $1`,
26 | [id]
27 | );
28 | user = _user || false;
29 | } catch (e) {
30 | error = e;
31 | } finally {
32 | callback(error, user);
33 | }
34 | });
35 | app.use(passport.initialize());
36 | app.use(passport.session());
37 |
38 | if (process.env.GITHUB_KEY && process.env.GITHUB_SECRET) {
39 | passport.use(
40 | new GitHubStrategy(
41 | {
42 | clientID: process.env.GITHUB_KEY,
43 | clientSecret: process.env.GITHUB_SECRET,
44 | callbackURL: `${process.env.ROOT_URL}/auth/github/callback`,
45 | passReqToCallback: true,
46 | },
47 | async function(req, accessToken, refreshToken, profile, done) {
48 | let error;
49 | let user;
50 | try {
51 | const { rows } = await rootPgPool.query(
52 | `select * from app_private.link_or_register_user($1, $2, $3, $4, $5) users where not (users is null);`,
53 | [
54 | (req.user && req.user.id) || null,
55 | "github",
56 | profile.id,
57 | JSON.stringify({
58 | username: profile.username,
59 | avatar_url: profile._json.avatar_url,
60 | name: profile.displayName,
61 | }),
62 | JSON.stringify({
63 | accessToken,
64 | refreshToken,
65 | }),
66 | ]
67 | );
68 | user = rows[0] || false;
69 | } catch (e) {
70 | error = e;
71 | } finally {
72 | done(error, user);
73 | }
74 | }
75 | )
76 | );
77 |
78 | app.use(route.get("/auth/github", passport.authenticate("github")));
79 |
80 | app.use(
81 | route.get(
82 | "/auth/github/callback",
83 | passport.authenticate("github", {
84 | successRedirect: "/",
85 | failureRedirect: "/login",
86 | })
87 | )
88 | );
89 | } else {
90 | console.error(
91 | "WARNING: you've not set up the GitHub application for login; see `.env` for details"
92 | );
93 | }
94 | app.use(
95 | route.get("/logout", async ctx => {
96 | ctx.logout();
97 | ctx.redirect("/");
98 | })
99 | );
100 | };
101 |
--------------------------------------------------------------------------------
/server-koa2/middleware/installPostGraphile.js:
--------------------------------------------------------------------------------
1 | const { postgraphile } = require("postgraphile");
2 | const PassportLoginPlugin = require("../../shared/plugins/PassportLoginPlugin");
3 | const {
4 | library: { connection, schema, options },
5 | } = require("../../.postgraphilerc.js");
6 |
7 | module.exports = function installPostGraphile(app, { rootPgPool }) {
8 | app.use((ctx, next) => {
9 | // PostGraphile deals with (req, res) but we want access to sessions from `pgSettings`, so we make the ctx available on req.
10 | ctx.req.ctx = ctx;
11 | return next();
12 | });
13 |
14 | app.use(
15 | postgraphile(connection, schema, {
16 | // Import our shared options
17 | ...options,
18 |
19 | // Since we're using sessions we'll also want our login plugin
20 | appendPlugins: [
21 | // All the plugins in our shared config
22 | ...(options.appendPlugins || []),
23 |
24 | // Adds the `login` mutation to enable users to log in
25 | PassportLoginPlugin,
26 | ],
27 |
28 | // Given a request object, returns the settings to set within the
29 | // Postgres transaction used by GraphQL.
30 | pgSettings(req) {
31 | return {
32 | role: "graphiledemo_visitor",
33 | "jwt.claims.user_id": req.ctx.state.user && req.ctx.state.user.id,
34 | };
35 | },
36 |
37 | // The return value of this is added to `context` - the third argument of
38 | // GraphQL resolvers. This is useful for our custom plugins.
39 | additionalGraphQLContextFromRequest(req) {
40 | return {
41 | // Let plugins call priviliged methods (e.g. login) if they need to
42 | rootPgPool,
43 |
44 | // Use this to tell Passport.js we're logged in
45 | login: user =>
46 | new Promise((resolve, reject) => {
47 | req.ctx.login(user, err => (err ? reject(err) : resolve()));
48 | }),
49 | };
50 | },
51 | })
52 | );
53 | };
54 |
--------------------------------------------------------------------------------
/server-koa2/middleware/installSession.js:
--------------------------------------------------------------------------------
1 | const session = require("koa-session");
2 |
3 | module.exports = function installSession(app) {
4 | app.keys = [process.env.SECRET];
5 | app.use(session({}, app));
6 | };
7 |
--------------------------------------------------------------------------------
/server-koa2/middleware/installSharedStatic.js:
--------------------------------------------------------------------------------
1 | const koaStatic = require("koa-static");
2 |
3 | module.exports = function installSharedStatic(app) {
4 | app.use(koaStatic(`${__dirname}/../../public`));
5 | };
6 |
--------------------------------------------------------------------------------
/server-koa2/middleware/installStandardKoaMiddlewares.js:
--------------------------------------------------------------------------------
1 | const helmet = require("koa-helmet");
2 | const cors = require("@koa/cors");
3 | // const jwt = require("koa-jwt");
4 | const compress = require("koa-compress");
5 | const bunyanLogger = require("koa-bunyan-logger");
6 | const bodyParser = require("koa-bodyparser");
7 |
8 | module.exports = function installStandardKoaMiddlewares(app) {
9 | // These middlewares aren't required, I'm using them to check PostGraphile
10 | // works with Koa.
11 | app.use(helmet());
12 | app.use(cors());
13 | //app.use(jwt({secret: process.env.SECRET}))
14 | app.use(compress());
15 | app.use(bunyanLogger());
16 | app.use(bodyParser());
17 | };
18 |
--------------------------------------------------------------------------------
/server-koa2/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "server-koa",
3 | "private": true,
4 | "version": "1.0.0",
5 | "description": "",
6 | "main": "index.js",
7 | "scripts": {
8 | "start": "if [ -x ../.env ]; then . ../.env; fi; NODE_ENV=development nodemon server.js",
9 | "test": "echo \"Error: no test specified\" && exit 1"
10 | },
11 | "author": "Benjie Gillam ",
12 | "license": "MIT",
13 | "dependencies": {
14 | "@graphile-contrib/pg-simplify-inflector": "^4.0.0-alpha.0",
15 | "@koa/cors": "2.2.1",
16 | "graphile-utils": "^4.4.0-beta.1",
17 | "http-proxy": "1.18.1",
18 | "koa": "2.5.1",
19 | "koa-bodyparser": "4.2.1",
20 | "koa-bunyan-logger": "2.1.0",
21 | "koa-compress": "3.0.0",
22 | "koa-helmet": "4.0.0",
23 | "koa-jwt": "4.0.4",
24 | "koa-passport": "5.0.0",
25 | "koa-route": "3.2.0",
26 | "koa-session": "5.8.1",
27 | "koa-static": "5.0.0",
28 | "passport-github": "1.1.0",
29 | "pg": "7.4.3",
30 | "postgraphile": "^4.4.1-rc.0"
31 | },
32 | "devDependencies": {
33 | "nodemon": "1.17.5"
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/server-koa2/server.js:
--------------------------------------------------------------------------------
1 | const http = require("http");
2 | const Koa = require("koa");
3 | const pg = require("pg");
4 | const sharedUtils = require("../shared/utils");
5 | const middleware = require("./middleware");
6 |
7 | sharedUtils.sanitiseEnv();
8 |
9 | const rootPgPool = new pg.Pool({
10 | connectionString: process.env.ROOT_DATABASE_URL
11 | });
12 |
13 | const isDev = process.env.NODE_ENV === "development";
14 |
15 | const app = new Koa();
16 | const server = http.createServer(app.callback());
17 |
18 | middleware.installStandardKoaMiddlewares(app);
19 | middleware.installSession(app);
20 | middleware.installPassport(app, { rootPgPool });
21 | middleware.installPostGraphile(app, { rootPgPool });
22 | middleware.installSharedStatic(app);
23 | middleware.installFrontendServer(app, server);
24 |
25 | const PORT = parseInt(process.env.PORT, 10) || 3000;
26 | server.listen(PORT);
27 | console.log(`Listening on port ${PORT}`);
28 |
--------------------------------------------------------------------------------
/setup.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | set -e
3 | export NODE_ENV=development
4 |
5 | if [ -x .env ]; then
6 | . ./.env
7 | if [ "$SUPERUSER_PASSWORD" = "" ]; then
8 | echo ".env already exists, but it doesn't define SUPERUSER_PASSWORD - aborting!"
9 | exit 1;
10 | fi
11 | if [ "$AUTH_USER_PASSWORD" = "" ]; then
12 | echo ".env already exists, but it doesn't define AUTH_USER_PASSWORD - aborting!"
13 | exit 1;
14 | fi
15 | echo "Configuration already exists, using existing secrets."
16 | else
17 | # This will generate passwords that are safe to use in envvars without needing to be escaped:
18 | SUPERUSER_PASSWORD="$(openssl rand -base64 30 | tr '+/' '-_')"
19 | AUTH_USER_PASSWORD="$(openssl rand -base64 30 | tr '+/' '-_')"
20 |
21 | # This is our '.env' config file, we're writing it now so that if something goes wrong we won't lose the passwords.
22 | cat >> .env < ({
4 | typeDefs: gql`
5 | input RegisterInput {
6 | username: String!
7 | email: String!
8 | password: String!
9 | name: String
10 | avatarUrl: String
11 | }
12 |
13 | type RegisterPayload {
14 | user: User! @pgField
15 | }
16 |
17 | input LoginInput {
18 | username: String!
19 | password: String!
20 | }
21 |
22 | type LoginPayload {
23 | user: User! @pgField
24 | }
25 |
26 | extend type Mutation {
27 | register(input: RegisterInput!): RegisterPayload
28 | login(input: LoginInput!): LoginPayload
29 | }
30 | `,
31 | resolvers: {
32 | Mutation: {
33 | async register(
34 | mutation,
35 | args,
36 | context,
37 | resolveInfo,
38 | { selectGraphQLResultFromTable }
39 | ) {
40 | const {
41 | username,
42 | password,
43 | email,
44 | name = null,
45 | avatarUrl = null,
46 | } = args.input;
47 | const { rootPgPool, login, pgClient } = context;
48 | try {
49 | // Call our register function from the database
50 | const {
51 | rows: [user],
52 | } = await rootPgPool.query(
53 | `select users.* from app_private.really_create_user(
54 | username => $1,
55 | email => $2,
56 | email_is_verified => false,
57 | name => $3,
58 | avatar_url => $4,
59 | password => $5
60 | ) users where not (users is null)`,
61 | [username, email, name, avatarUrl, password]
62 | );
63 |
64 | if (!user) {
65 | throw new Error("Registration failed");
66 | }
67 |
68 | // Tell Passport.js we're logged in
69 | await login(user);
70 | // Tell pg we're logged in
71 | await pgClient.query("select set_config($1, $2, true);", [
72 | "jwt.claims.user_id",
73 | user.id,
74 | ]);
75 |
76 | // Fetch the data that was requested from GraphQL, and return it
77 | const sql = build.pgSql;
78 | const [row] = await selectGraphQLResultFromTable(
79 | sql.fragment`app_public.users`,
80 | (tableAlias, sqlBuilder) => {
81 | sqlBuilder.where(
82 | sql.fragment`${tableAlias}.id = ${sql.value(user.id)}`
83 | );
84 | }
85 | );
86 | return {
87 | data: row,
88 | };
89 | } catch (e) {
90 | console.error(e);
91 | // TODO: determine why it failed
92 | throw new Error("Registration failed");
93 | }
94 | },
95 | async login(
96 | mutation,
97 | args,
98 | context,
99 | resolveInfo,
100 | { selectGraphQLResultFromTable }
101 | ) {
102 | const { username, password } = args.input;
103 | const { rootPgPool, login, pgClient } = context;
104 | try {
105 | // Call our login function to find out if the username/password combination exists
106 | const {
107 | rows: [user],
108 | } = await rootPgPool.query(
109 | `select users.* from app_private.login($1, $2) users where not (users is null)`,
110 | [username, password]
111 | );
112 |
113 | if (!user) {
114 | throw new Error("Login failed");
115 | }
116 |
117 | // Tell Passport.js we're logged in
118 | await login(user);
119 | // Tell pg we're logged in
120 | await pgClient.query("select set_config($1, $2, true);", [
121 | "jwt.claims.user_id",
122 | user.id,
123 | ]);
124 |
125 | // Fetch the data that was requested from GraphQL, and return it
126 | const sql = build.pgSql;
127 | const [row] = await selectGraphQLResultFromTable(
128 | sql.fragment`app_public.users`,
129 | (tableAlias, sqlBuilder) => {
130 | sqlBuilder.where(
131 | sql.fragment`${tableAlias}.id = ${sql.value(user.id)}`
132 | );
133 | }
134 | );
135 | return {
136 | data: row,
137 | };
138 | } catch (e) {
139 | console.error(e);
140 | // TODO: check that this is indeed why it failed
141 | throw new Error("Login failed: incorrect username/password");
142 | }
143 | },
144 | },
145 | },
146 | }));
147 | module.exports = PassportLoginPlugin;
148 |
--------------------------------------------------------------------------------
/shared/utils.js:
--------------------------------------------------------------------------------
1 | exports.sanitiseEnv = () => {
2 | const requiredEnvvars = ["AUTH_DATABASE_URL", "ROOT_DATABASE_URL"];
3 | requiredEnvvars.forEach(envvar => {
4 | if (!process.env[envvar]) {
5 | throw new Error(
6 | `Could not find process.env.${envvar} - did you remember to run the setup script? Have you sourced the environmental variables file '.env'?`
7 | );
8 | }
9 | });
10 |
11 | process.env.NODE_ENV = process.env.NODE_ENV || "development";
12 | };
13 |
--------------------------------------------------------------------------------