diff --git a/.eslint.rc b/.eslint.rc new file mode 100644 index 00000000..01718e5c --- /dev/null +++ b/.eslint.rc @@ -0,0 +1,70 @@ +module.exports = { + env: { + browser: true, + node: true, + es2020: true, + }, + parser: "@typescript-eslint/parser", + parserOptions: { + ecmaVersion: 2020, + sourceType: "module", + ecmaFeatures: { + jsx: true, + }, + }, + plugins: ["@typescript-eslint", "react", "prettier"], + extends: [ + "airbnb", + "airbnb/hooks", + "eslint:recommended", + "plugin:@typescript-eslint/eslint-recommended", + "plugin:@typescript-eslint/recommended" + "plugin:react/recommended", + "plugin:import/errors", + "plugin:import/warnings", + "plugin:import/typescript", + "prettier", + "prettier/@typescript-eslint", + "prettier/react", + ], + rules: { + "react/jsx-filename-extension": [1, { extensions: [".ts", ".tsx"] }], + "import/extensions": "off", + "react/prop-types": "off", + "jsx-a11y/anchor-is-valid": "off", + "react/jsx-props-no-spreading": ["error", { custom: "ignore" }], + "prettier/prettier": "error", + "react/no-unescaped-entities": "off", + "import/no-cycle": [0, { ignoreExternal: true }], + "prefer-const": "off", + // needed because of https://github.com/typescript-eslint/typescript-eslint/blob/master/packages/eslint-plugin/docs/rules/no-use-before-define.md#how-to-use & https://stackoverflow.com/questions/63818415/react-was-used-before-it-was-defined + "no-use-before-define": "off", + "@typescript-eslint/no-use-before-define": [ + "error", + { functions: false, classes: false, variables: true }, + + ], + @typescript-eslint/ban-types": [ + "error", + { + "extendDefaults": true, + "types": { + "{}": false + } + } + ] + + + }, + settings: { + "import/resolver": { + "babel-module": { + extensions: [".js", ".jsx", ".ts", ".tsx"], + }, + node: { + extensions: [".js", ".jsx", ".ts", ".tsx"], + paths: ["src"], + }, + }, + }, +}; diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 00000000..375d9386 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,25 @@ + +{ "extends": [ + "next/core-web-vitals", + "eslint:recommended", + "plugin:@typescript-eslint/recommended", + "plugin:react/recommended", + "eslint:recommended", + "next" + ], + "plugins": ["@ts-gql"], + "parser": "@typescript-eslint/parser", + "rules": { + "@ts-gql/ts-gql": "error", + "@typescript-eslint/no-var-requires": 0, + "@typescript-eslint/ban-types": [ + "error", + { + "extendDefaults": true, + "types": { + "{}": false + } + } + ] + } +} diff --git a/.github/workflows/greetings.yml b/.github/workflows/greetings.yml new file mode 100644 index 00000000..5da942c3 --- /dev/null +++ b/.github/workflows/greetings.yml @@ -0,0 +1,14 @@ +name: Greetings +on: [pull_request, issues] +jobs: + greeting: + runs-on: ubuntu-latest + permissions: + issues: write + pull-requests: write + steps: + - uses: actions/first-interaction@v1 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + issue-message: 'Hello @${{ github.actor }} , thank you for submitting an issue.' + pr-message: 'Hello @${{ github.actor }} , thank you submitting a pull request.' diff --git a/.github/workflows/latest.yml b/.github/workflows/latest.yml new file mode 100644 index 00000000..2225acdc --- /dev/null +++ b/.github/workflows/latest.yml @@ -0,0 +1,64 @@ +# This is a basic workflow to help you get started with Actions +name: CI +# Controls when the workflow will run +on: + # Triggers the workflow on push or pull request events but only for the main branch + push: + branches: [latest] + pull_request: + branches: [latest] + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: +# A workflow run is made up of one or more jobs that can run sequentially or in parallel +jobs: + # This workflow contains a single job called "build" + prisma_day_2021: + runs-on: ubuntu-latest + container: node:14.18.1-bullseye + services: + pg: + image: postgres:latest + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + env: + POSTGRES_DB: prisma_day_2021_latest + POSTGRES_PASSWORD: change_me_in_multiple_places + POSTGRES_PORT: 5432 + POSTGRES_USER: postgres_user + steps: + - name: Dump github context + run: echo "$GITHUB_CONTEXT" + shell: bash + env: + GITHUB_CONTEXT: ${{ toJson(github) }} + - name: Checkout application + uses: actions/checkout@v2 + - name: Run yarn + run: | + cd $GITHUB_WORKSPACE + ls -alt + yarn install --frozen-lockfile + env: + DATABASE_URL: "postgres://postgres_user:change_me_in_multiple_places@pg/prisma_day_2021_latest" + - name: Start keystone/Next + run: | + yarn dev & + env: + KEYSTONE_NEXTJS_BUILD_API_KEY: keystone_change_me2 + DATABASE_URL: "postgres://postgres_user:change_me_in_multiple_places@pg/prisma_day_2021_latest" + - name: Start next build + run: | + echo "Sleeping to give time for Keystone to kick in. WIP: full micro service yml" + sleep 5 + echo "Building unseeded production server: " + yarn site:build + echo "✅ Success: built unseeded production services" + sleep 25 + env: + KEYSTONE_NEXTJS_BUILD_API_KEY: keystone_change_me2 + DATABASE_URL: "postgres://postgres_user:change_me_in_multiple_places@pg/prisma_day_2021_latest" diff --git a/.gitignore b/.gitignore index a5a3a734..8a0dd48c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,26 +1,32 @@ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # dependencies -/node_modules -/.pnp -.pnp.js +.yarn/* +!.yarn/patches +!.yarn/releases +!.yarn/plugins +!.yarn/sdks +!.yarn/versions +.pnp.* + +node_modules/ + # testing -/coverage +coverage/ # next.js -/.next/ -/out/ +.next/ +out/ # production -/build +build/ # misc .DS_Store *.pem # debug -npm-debug.log* yarn-debug.log* yarn-error.log* @@ -35,4 +41,9 @@ yarn-error.log* # keystone .keystone -app.db \ No newline at end of file +app.db +__generated__ + +migrations/migration_lock.toml + +!.github/* diff --git a/LICENSE b/LICENSE index 8c4c8ef5..50e00b72 100644 --- a/LICENSE +++ b/LICENSE @@ -2,6 +2,8 @@ MIT License Copyright (c) 2021 KeystoneJS +Modifications: Copyright (c) 2021 Fourcube Ltd + Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights diff --git a/README.md b/README.md index 1f9d5f14..54471853 100644 --- a/README.md +++ b/README.md @@ -1,65 +1,308 @@ -# Prisma Day 2021 Workshop - -This is the sample app built for Jed's Prisma Day 2021 Workshop. - -It's both the front and back-end for a Blog built with Prisma, KeystoneJS, GraphQL, Next.js and Tailwind. - -The App includes public auth and signup, role-based access control, and custom design-system based components in the Content field. Content authors can embed Polls in post content, and authenticated visitors can vote on responses. - -## About KeystoneJS - -**Keystone 6** is the next-gen CMS for Node.js built with Prisma, Apollo Server, and Next.js. - -Fully open source, it's not just a great headless CMS, it's also a powerful API server and app back-end. - -Learn more at [keystonejs.com](https://keystonejs.com) - -## Running this app - -Make sure you have: - -- Node v12 or v14 -- Yarn -- Postgres - -Then, clone this repo and run `yarn` to install the dependencies. - -### Starting the API - -Start the API Server first by running `yarn api:dev`. This will start Keystone's GraphQL API and Admin UI on `localhost:3000` - -The first time you open that link you will be prompted to create a new user. - -### Starting the Site - -With the API running, in a separate terminal run `yarn site:dev`. This will start the front-end Next.js app on `localhost:8000` - -## About the codebase - -The Keystone and Next.js app are colocated in the same repo for ease of demonstration, but you'd often separate them into different packages in a monorepo or even separate repositories. - -The back-end files include: - -``` -keystone.ts -schema.graphql (generated) -schema.prisma (generated) -schema.ts -schema/* -``` - -The front-end files include: - -``` -components/* -pages/* -next-env.d.ts -next.config.js -postcss.config.js -tailwind.config.js -utils.js -``` - -## License - -Copyright (c) 2021 Thinkmill Labs Pty Ltd. Licensed under the MIT License. +# Prisma Day 2021 Workshop + +This is the sample app built for Jed's Prisma Day 2021 Workshop. + +It's both the front and back-end for a Blog built with Prisma, KeystoneJS, GraphQL, Next.js and Tailwind. + +The App includes public auth and signup, role-based access control, and custom design-system based components in the Content field. Content authors can embed Polls in post content, and authenticated visitors can vote on responses. + +## About KeystoneJS + +**Keystone 6** is the next-gen CMS for Node.js built with Prisma, Apollo Server, and Next.js. + +Fully open source, it's not just a great headless CMS, it's also a powerful API server and app back-end. + +Learn more at [keystonejs.com](https://keystonejs.com) + +## Running this app + +Make sure you have: + +- Node v12 or v14 +- Yarn +- Postgres + +Then, clone this repo and run `yarn` to install the dependencies. + +### Starting the API + +Start the API Server first by running `yarn api:dev`. This will start Keystone's GraphQL API and Admin UI on `localhost:3000` + +The first time you open that link you will be prompted to create a new user. + +### Starting the Site + +With the API running, in a separate terminal run `yarn site:dev`. This will start the front-end Next.js app on `localhost:8000` + +## About the codebase + +The Keystone and Next.js app are colocated in the same repo for ease of demonstration, but you'd often separate them into different packages in a monorepo or even separate repositories. + +The back-end files include: + +``` +keystone.ts +schema.graphql (generated) +schema.prisma (generated) +schema.ts +schema/* +``` + +The front-end files include: + +``` +components/* +pages/* +next-env.d.ts +next.config.js +postcss.config.js +tailwind.config.js +utils.js +``` + + + +## About the latest branch: + +![workflow](https://github.com/qfunq/prisma-day-2021-workshop/actions/workflows/latest.yml/badge.svg) + + + +This branch is dedicated to creating a solid foundation for a production build to extend Keystone 6 CMS from. + + +Currently working on the low code style core, which hates `any` just as much as I do, and spots the tiniest of issues. Industry best practise is a useful side effect of harsh CI tooling. + + +`yarn commit "message"` fires off the CI pipeline and only commits if unit tests are passed locally. No longer requires yarn dev running without the main next front end, since yarn site:build is the primary unit test. "fastcommit" is considered dangerous, and needs some additional logic to be CI safe. Its been the cause of a few red crosses. For that reason, it is being prefixed by an `x`, so commands in the history buffer do not trigger it. + +`yarn push` auto push to origin latest + +`yarn commit "message" && yarn push` is what lands up being used most often. + +## Production status report + +✅ CI: Github actions runner working in a test framework. Unseeded build cases handled (any bugs fixed!) + +✅ Pre release: Testing + +Full k8s spec: WIP. Deferred because its an endless task. + +✅ More informative logging, suitable for deployment. + +✅ Logging: Code coverage: complete + +✅ Refactored literals. + +✅ Authorization: Strong typing for keystone auth frame types + +✅ Next build super user authorization to keystone, allowing SSR/SSG/ISR tunneling. + +✅ MaybeIOPromise replaces all awaits. + +✅ Catches any exceptions transparently and uniquely, at point of execution. + Runs deferred using a compiled monadic script that can never return a null or undefined value. + Use case guarantees low stack usage, which allows pure `CCC` code to run without coroutine optimizations. + + + +A shout out to @jed for explaining the keystone way. +Ready for exhaustive testing. Bug reports welcome! + + + +## Resiliance testing, unit testing + +✅ A stale session led to the app throwing when voting occured. +Code fixed by a log.warning and return. + +✅ Session secret autogenerated using two CUID's at start of AdminUI, leading to stale session rejection + and enforced return to Admin UI login page when KS server restarted. + +✅ Rejigged fetch command because some failure continuations were never been called. +Tested and working. + +✅ Await eliminated using monads of promises. + +✅ Only throws were in polls and static data fetch. Both have been refactored. + +✅ Next builds even when there is no static data, i.e. an empty database. Requires precise handling to return the correct types. + +✅ Many issues tracked down using the empty database technique. It triggers every any/null/undefined bug, with a vengeance. + Strongly recommended as a unit testing technique. + +✅ Polyfilled monadic logging parses variable Error() format. + Localised abbreviated logging grammar for more readable logs. + +✅ CI: Lint extend set to ["next/core-web-vitals","eslint:recommended"] ++ + + +✅ The Promise monad has been battle hardened, and is seen as a critical tool + in the CI process, since the code it produces is even more modular than `ts` alone. + +https://www.youtube.com/watch?v=vkcxgagQ4bM + +✅ Works out the box in `ts`. + +✅ Resolves complications caused by interactions with `async`, `Promise` etc. + +✅ `CCC` Implementation of MaybeIOPromise tested and working. + +✅ Rolled out to all gql queries, which become much tidier as a result. + +## Security audit +Status: `Preliminary`. + + + +✅ No Codiga linting errors, 100% score on all categories. Code inspector has a divide by zero bug that reports N/A when it can find no errors. + + + codiga badge + + +## TL;DR +`(a: T)` is a type safe replacement for many cases of `(a: any)`, because we need to trap the awkward `undefined` cases at build time. + +`Maps` (reads: `maps domain to range`) eliminates the dummy variable in some functional type definitions, in a lint friendly way. +This is both more succinct, and more appropriate for usage in point free type descriptions. + + +## any is shouting: I AM WRITTEN IN JAVASCRIPT ... listen to it, and recode it ASAP + +`wrap_any.ts` is an ugly name for an ugly file. It might change names to become even more noticable. Its sole role is wrapping `any` subtypes that can't be deduced for some reason. It has proved to be a very +practical way of localising `any` issues. Give the usage a name, and fixed location, right under the peer review spot light. Have varying degree of `anyness`. Take a look at the file, it's a toxic dump of puzzles and types best avoided, and attempts to handle them at a distance. There are more radical approaches that I'm looking at, but they are alien. + +`any` issues: +Low level ts/js security audit: Research and development has established many bugs hide in the rampantly polymorphic `any` type. + +This `dangerous construct` is used in upstream auth. + +``` +Approximately 75% of these situations reveal an unhandled case, hidden from lint. +``` + +## await is almost as bad as any + +Every `await` needs to be wrapped in a `try` `catch` block, or better still, wrapped in a `promise`, which either is already, or extends to a `monad` (since the original implementors of `js Promises` just implemented `fmap/then`, without `bind`, probably because its action is immediate. Oooops.). Fortunately, `await` is less abused than `any`. Once monadic IO replaces it, it's hard to contemplate using anything else, and `await` code is easily lifted to `fmap` or `fbind`. There will still always be some ugly IO bindings, such as with `fetch`, but they have been localized and abstracted. + +The conversion to monadic io is complete. No awaits remain. + +This is possibly one of the most important details for stability, because Keystone throws exceptions, +possibly originally thrown by apollo, and if those exceptions are not caught, the server goes down. + +I used to think google were wrong in not allowing exceptions in their house style, but now it makes complete sense. Exceptions try their best to destroy monadic structure, but fortunately, they can be tamed using the techniques applied in this code. + +## eslint is just as harsh. any is a virus that infects code. easy to train it about await too ... + +✅ Abstract away the dummy variable in `typescipt` type definitions. +`f: Maps`, which is implemented in `func.ts`. It absracts away the old `maps:` notation using a lint violation in a single location, removing a blemish from the `ts` type system. Uniquely declared lint violations are handled using drop, i.e. Curried `false`. Because it's used so often, it's aliased to it's local usage, so as not the swamp the namespace with too many `drop`s. + + +`(a: T)` is not the same as `(a: any)`. The templated class can't echo undefined types, but `any` can! This is seen as a `very good thing`. + +`(a: T)` is a type safe replacement for any, because we need to trap the awkward `undefined` cases at build time. Then the type inferrence become far more similar to `C++`, which works well until it hits recursive types (with the unfortunate side effect of nerfing monads/coroutines). + +My mission against `any` is more than fully justified, and I'm not alone: + +https://www.typescriptlang.org/docs/handbook/typescript-in-5-minutes-func.html + + +There is also a `dodgy C++ style cast`, right where it's not needed ... `TBC`. + +The reason monads are important is that `ts` offers us the `CCC` to programme in, (eliminating `any` completely), if we so desire. Then a programme can be proved to do exactly what it says it does. + +`ts` appears equally capable of expressing algorithms in the `CMC` or the `CCC`. + +There are positive benefits resulting from entanglement in the `CMC`, it can be used to model subscriptions. + +## DRY = sole source of truth + +... and it sets deep theoretical/practical puzzles, part addressed in this code base. However `typescript` is not ideal for `DRY`, but it suffices. When enhanced with `abstract syntax trees` it bites back that bit more, a critical technique in Keystone core. + +## On naming +The Native American naming/language model is used. Functions are verbs, and are named by what they do. + +Categorical morphisms are weaker, polymorphic aliases to these concrete names, and are `only to be used in generic code`. If writing specific code, it is recommended to use the specific name. + +There nothing worse than greping through 60 pages of `process` matches, all processing something completely different, (a C++ example, but we have all been there, try searching for `: any` in the top of a node project ... ). + +`then`, and `catch`, are heavily overused in all languages. IMHO, new apis need to avoid these keywords, but also supply categorical aliases, such as `bind`, `fmap`, `then`,`finally` if generic programming is required. + +I'm fairly new to `ts`, and like it a lot, but without a formal style, it can become unmanagable. + +With these caveats in mind, enjoy this latest release of @jeds prisma day workshop app, ported to Keystone 26, with useful contrib from @Guatam Singh, and polished endlessly by qfunq (it deserves it, Keystone 6 is the best CMS out there, Next, best in class, the same for prisma, and jeds code pulls it all together in a very useful way), and be fully aware, however solid it seems, this application is still in a `testing` phase. + + + +## Known Issues + +UI inforces the 1->0:1 hidden relationship of an element of answeredByUser -> (poll,vote). This is less than perfect, +since postgres contraints spill into the business logic, which shows up in the Admin UI, +where a User can be assigned multiple votes in the same Poll. To fix this is quite complex, since adding views or constraints breaks auto migration. The migrations carry a history of experiments to try to understand this issue. If anyone has a fix for this, please do post it in the issues forum. + +## Security Issues + +Polling shows up some issues in Keystone: + The lack of field access:filter severely restricts the ability to secure apis. In many cases + access:filter is reduced to a fairly trivial role, since fields cannot override it, but it is also the last line of defense for security, so the default should be reject. But this standard + firewall like state cannot be achieved with the current apis. + +`wrap_any.ts:` the place where dark hacks live to get the system building. Its grown +rather than shrank, because `any` is often used to create objects without delegating +a strong typed call to the top level application. `DRY/CCC` is harsh, and forces certain design contraints, which are not often followed. But when they are, everything clicks together perfectly. + + +## Additional functionality from upstream main + +Polls fully working. +Production build: + Detailed logging: Date time and polyfilled stack parsing. + next lint + tailwind purge + telemetry disable: interferes with logging. + x-api-key for next build events + CI scripts: + Local CI, then remote CI if and only if local CI succeeds. + Unseeded corner case working in a test k8s framework. + Seeding: Agnostic: there are multiple routes. + Prettier applied in gitadd. + yarn lint: additional, harsher lint checks + Utils folder for additional code, informative naming. + MaybeIO/Promise for DRY declarative specification of graphql parsing/error handling + Resolves many hidden bugs in the original imperative code. + Additional Poll functionality: + Users can clear their own votes only + Unvoted polls don't reveal their results + Voted poll disables UI for that poll (otherwise bounce can lead to race conditions). + Front end / Admin UI dectection for selective access:filter, preventing exposure of user names + and CUIDs to low auth users, and allowing vote counts to be accessed. + Github repos fully working. + + +## How to install the latest branch + +The definitive install instructions, assuming fedora, bar seeding, are in the workflow file: + + +Install it like this and you know you will get the latest CI build. ![workflow](https://github.com/qfunq/prisma-day-2021-workshop/actions/workflows/latest.yml/badge.svg) + + + codiga badge + + + +If for some reason, the latest CI build is failing (it happens!), find the last good build. As it stands, the yml specifies a test, not a production framework. + +Seeding and post seeding CI unit tests: The harsh test of +building the database unseeded works. This leaves the application agnostic on +how data is seeded, since there are many ways to achieve this. + + +All testing/patches/security audits are welcome. Feel free to contribute to documenting the industry best practises for using keystone CMS combined with the powerhouse front end nextjs. + +## License + +Copyright (c) 2021 Thinkmill Labs Pty Ltd. Licensed under the MIT License. + +Modifications: Copyright (c) 2021 Fourcube Ltd. Licensed under the MIT License. diff --git a/components/auth.tsx b/components/auth.tsx index a03c0f76..550768bb 100644 --- a/components/auth.tsx +++ b/components/auth.tsx @@ -1,24 +1,32 @@ -import { - createContext, - useRef, - useMemo, - useEffect, - useContext, - ReactNode, -} from 'react'; +import { createContext, useRef, useEffect, useContext, ReactNode } from 'react'; + +import { useQuery, useMutation } from '@ts-gql/apollo'; +import { gql } from '@ts-gql/tag/no-transform'; + +import { Maps } from '../utils/func' +import { makeIO } from '../utils/maybeIOPromise' + +//Security audit: -import { gql, useQuery, useMutation } from 'urql'; export type SignInArgs = { email: string; password: string }; export type SignInResult = | { success: true } | { success: false; message: string }; + + +/* eslint-enable */ + type AuthContextType = | { ready: true; sessionData?: { id: string; name: string }; - signIn: ({ email, password }: SignInArgs) => Promise; + + signIn: Maps>; + + + signOut: () => void; } | { @@ -29,89 +37,96 @@ const AuthContext = createContext({ ready: false, }); -export function useAuth() { + +export const useAuth = () => { return useContext(AuthContext); } export const AuthProvider = ({ children }: { children: ReactNode }) => { const wasReady = useRef(false); - const mutationContext = useMemo( - () => ({ additionalTypenames: ['User', 'Poll', 'PollAnswer'] }), - [] - ); - const [{ fetching, data: sessionData, error: sessionError }, refetch] = - useQuery({ - query: gql` - query { - authenticatedItem { - ... on User { - id - name - } - } - } - `, - }); - - const [, authenticate] = useMutation(gql` - mutation ($email: String!, $password: String!) { - authenticateUserWithPassword(email: $email, password: $password) { - __typename - ... on UserAuthenticationWithPasswordSuccess { - item { + + const { + loading, + data: sessionData, + error: sessionError, + } = useQuery( + gql` + query AuthenticatedItem { + authenticatedItem { + ... on User { id + name } } - ... on UserAuthenticationWithPasswordFailure { - message - } } - } - `); - - const signIn = async ({ - email, - password, - }: SignInArgs): Promise => { - const result: any = await authenticate( - { email, password }, - mutationContext + ` as import('../__generated__/ts-gql/AuthenticatedItem').type + ); + + const [authenticate] = useMutation( + gql` + mutation AuthenticateUser($email: String!, $password: String!) { + authenticateUserWithPassword(email: $email, password: $password) { + __typename + ... on UserAuthenticationWithPasswordSuccess { + item { + id + } + } + ... on UserAuthenticationWithPasswordFailure { + message + } + } + } + ` as import('../__generated__/ts-gql/AuthenticateUser').type ); - const { data, error } = result; - if ( - data?.authenticateUserWithPassword?.__typename === - 'UserAuthenticationWithPasswordSuccess' - ) { - return { success: true }; - } else if ( - data?.authenticateUserWithPassword?.__typename === - 'UserAuthenticationWithPasswordFailure' - ) { - return { - success: false, - message: data.authenticateUserWithPassword?.message, - }; - } - if (error) { - return { success: false, message: error.toString() }; - } else { + + + const signIn = ({ email , password }: SignInArgs): Promise => { + return makeIO(()=>authenticate( + { + variables: { email, password }, + refetchQueries: ['AuthenticatedItem'], + })) + .then (result => + { + const { data } = result; + if ( + data?.authenticateUserWithPassword?.__typename === + 'UserAuthenticationWithPasswordSuccess' + ) { + return { success: true } as SignInResult; + } else if ( + data?.authenticateUserWithPassword?.__typename === + 'UserAuthenticationWithPasswordFailure' + ) { + return { + success: false, + message: data.authenticateUserWithPassword?.message, + } ; + } + return { success: false, message: 'An unknown error occurred' }; + } + ) + .exec({ success: false, message: 'An unknown runtime error occurred' }); }; - const [{}, signOutMutation] = useMutation(gql` - mutation { - endSession - } - `); + const [signOutMutation] = useMutation( + gql` + mutation EndSession { + endSession + } + ` as import('../__generated__/ts-gql/EndSession').type + ); const signOut = () => { - signOutMutation(undefined, mutationContext); + signOutMutation({ refetchQueries: ['AuthenticatedItem'] }); }; useEffect(() => { - if (!wasReady.current && !fetching && !sessionError) { + if (!wasReady.current && !loading && !sessionError) { wasReady.current = true; } }); @@ -119,8 +134,14 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { return ( & { appearance?: 'default' | 'primary'; size?: 'default' | 'large'; -}) { +}) => { const appearanceClasses = { default: 'bg-gray-200 hover:bg-gray-300 focus:ring-blue-500 shadow-sm', primary: diff --git a/components/ui/forms.tsx b/components/ui/forms.tsx index 1ed48acc..f750127c 100644 --- a/components/ui/forms.tsx +++ b/components/ui/forms.tsx @@ -1,15 +1,15 @@ -import { +import React, { HTMLAttributes, InputHTMLAttributes, SelectHTMLAttributes, } from 'react'; import classNames from 'classnames'; -export function FieldContainer({ +export const FieldContainer = ({ children, className, ...props -}: HTMLAttributes) { +}: HTMLAttributes) => { const classes = classNames('my-4', className); return (
@@ -18,11 +18,11 @@ export function FieldContainer({ ); } -export function FieldLabel({ +export const FieldLabel = ({ children, className, ...props -}: HTMLAttributes) { +}: HTMLAttributes) => { const classes = classNames('inline-block w-36 ' + className); return ( @@ -31,13 +31,13 @@ export function FieldLabel({ ); } -export function TextInput({ +export const TextInput = ({ size = 'default', className, ...props }: Omit, 'size'> & { size?: 'default' | 'large'; -}) { +}) => { const sizeClasses = { default: 'p-1', large: 'p-2', @@ -50,10 +50,10 @@ export function TextInput({ return ; } -export function Checkbox({ +export const Checkbox = ({ className, ...props -}: InputHTMLAttributes) { +}: InputHTMLAttributes) => { const classes = classNames( 'focus:ring-blue-500 h-4 w-4 text-blue-600 border-gray-300 rounded', className @@ -61,11 +61,11 @@ export function Checkbox({ return ; } -export function Select({ +export const Select = ({ children, className, ...props -}: SelectHTMLAttributes) { +}: SelectHTMLAttributes) => { const classes = classNames( 'mt-1 py-1 pl-2 pr-8 border border-gray-300 bg-white rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm', className diff --git a/components/ui/icons.tsx b/components/ui/icons.tsx index f3589b10..21fc3054 100644 --- a/components/ui/icons.tsx +++ b/components/ui/icons.tsx @@ -1,10 +1,10 @@ -import { SVGAttributes } from 'react'; +import React, { SVGAttributes } from 'react'; import cx from 'classnames'; -export function ChevronLeft({ +export const ChevronLeft = ({ className, - ...props -}: SVGAttributes) { + //...props +}: SVGAttributes) => { const classes = cx('h-6 w-6', className); return ( { return (
@@ -13,11 +13,11 @@ export function Container({ children }: { children: ReactNode }) { ); } -export function Divider() { +export const Divider = () => { return
; } -export function HomeLink() { +export const HomeLink = () => { return (
diff --git a/components/ui/typography.tsx b/components/ui/typography.tsx index b9e137a1..4d5115c4 100644 --- a/components/ui/typography.tsx +++ b/components/ui/typography.tsx @@ -1,4 +1,4 @@ -import { HTMLAttributes } from 'react'; +import React, { HTMLAttributes } from 'react'; import cx from 'classnames'; type Alignable = { @@ -15,12 +15,12 @@ const buildClasses = ( }); }; -export function H1({ +export const H1 = ({ className, children, textAlign, ...props -}: HTMLAttributes & Alignable) { +}: HTMLAttributes & Alignable) => { const classes = buildClasses('text-3xl my-4 font-medium', { className, textAlign, @@ -32,12 +32,12 @@ export function H1({ ); } -export function H2({ +export const H2 = ({ className, children, textAlign, ...props -}: HTMLAttributes & Alignable) { +}: HTMLAttributes & Alignable) => { const classes = buildClasses('text-2xl my-4 font-medium', { className, textAlign, @@ -49,12 +49,12 @@ export function H2({ ); } -export function H3({ +export const H3 = ({ className, children, textAlign, ...props -}: HTMLAttributes & Alignable) { +}: HTMLAttributes & Alignable) => { const classes = buildClasses('text-2xl my-4 font-light text-gray-800', { className, textAlign, @@ -66,12 +66,12 @@ export function H3({ ); } -export function H4({ +export const H4 = ({ className, children, textAlign, ...props -}: HTMLAttributes & Alignable) { +}: HTMLAttributes & Alignable) => { const classes = buildClasses('text-xl my-4 text-gray-700', { className, textAlign, @@ -83,12 +83,12 @@ export function H4({ ); } -export function H5({ +export const H5 = ({ className, children, textAlign, ...props -}: HTMLAttributes & Alignable) { +}: HTMLAttributes & Alignable) => { const classes = buildClasses('text-l my-4 font-semibold text-gray-600', { className, textAlign, @@ -100,12 +100,12 @@ export function H5({ ); } -export function H6({ +export const H6 = ({ className, children, textAlign, ...props -}: HTMLAttributes & Alignable) { +}: HTMLAttributes & Alignable) => { const classes = buildClasses( 'my-4 uppercase text-sm text-gray-600 font-medium', { className, textAlign } @@ -117,12 +117,12 @@ export function H6({ ); } -export function P({ +export const P = ({ className, children, textAlign, ...props -}: HTMLAttributes & Alignable) { +}: HTMLAttributes & Alignable) => { const classes = buildClasses('my-2', { className, textAlign }); return (

diff --git a/keystone.dockerfile b/keystone.dockerfile new file mode 100644 index 00000000..31dcbc45 --- /dev/null +++ b/keystone.dockerfile @@ -0,0 +1,34 @@ + +# Production image, copy all the files and run next +FROM docker.io/library/node:14.18.1-bullseye AS runner + + +#ENV NODE_ENV production +RUN apt-get update -y \ +&& apt-get install dumb-init -y \ +&& groupadd --gid 1001 keystonejs \ +&& useradd --uid 1001 --gid 1001 keystonejs + +WORKDIR /home/keystonejs + +# You only need to copy next.config.js if you are NOT using the default configuration +# COPY --from=builder /app/next.config.js ./ +#COPY --from=builder /app/ ./ +COPY --chown=keystonejs:keystonejs ./ ./ +#COPY --from=builder /app/node_modules ./node_modules +#COPY --from=builder /app/package.json ./package.json + +USER keystonejs + +RUN yarn install --frozen-lockfile + +EXPOSE 3000 + +# Next.js collects completely anonymous telemetry data about general usage. +# Learn more here: https://nextjs.org/telemetry +# Uncomment the following line in case you want to disable telemetry. +ENV NEXT_TELEMETRY_DISABLED 1 + +HEALTHCHECK CMD ls -alt + +CMD ["/usr/bin/dumb-init", "--rewrite", "2:3", "--", "yarn", "launch"] diff --git a/keystone.ts b/keystone.ts index c9627282..1d2107ed 100644 --- a/keystone.ts +++ b/keystone.ts @@ -1,17 +1,43 @@ -import { config } from '@keystone-next/keystone'; -import { statelessSessions } from '@keystone-next/keystone/session'; -import { createAuth } from '@keystone-next/auth'; - +import { xlog } from './utils/logging'; +//import { Maps } from './utils/func'; +import { config } from '@keystone-6/core'; +import { statelessSessions } from '@keystone-6/core/session'; +import { createAuth } from '@keystone-6/auth'; import { lists, extendGraphqlSchema } from './schema'; -import { rules } from './schema/access'; +import { permissions } from './schema/access'; +import cuid from 'cuid'; const dbUrl = - process.env.DATABASE_URL || - `postgres://${process.env.USER}@localhost/prisma-day-workshop`; + `${process.env.DATABASE_URL}` || + `postgres://${process.env?.POSTGRES_USER}:${process.env?.POSTGRES_PASSWORD}@${process.env?.POSTGRES_HOST}/${process.env?.POSTGRES_DB}`; + +export const keystoneHost = process.env?.KEYSTONE_HOST || 'localhost'; + +const sessionSecret = cuid() + cuid(); + +export const keystoneNextjsBuildApiKey = + process.env.KEYSTONE_NEXTJS_BUILD_API_KEY || + 'keystone.ts:_NextjsBuildApiKey_says_change_me__im_just_for_testing_purposes'; + +export const frontEndPort = process.env?.FRONT_END_PORT || '8000'; + +//log().info('isFront end: ').info(isFrontEnd()).info("Env: ").info(process.env.PLATFORM); + +//mapString(processIsFrontEnd)(c => asciiLogger(c)) +//mapString('frontend')(c => asciiLogger(c)) + +// Unless I'm missing something, its tricky to clone typescript objects +// Fortunately theres a workaround for monad like objects, create a new one +// using a class factory, in this case, logclos. +// The resulting object is non-clonable, without entanglement, so is in the CMC, and not the CCC. +// Since objects are so hard to clone it ts, this is not a big issue, indeed, ts seems better suited to the CMC -const sessionSecret = - process.env.SESSION_SECERT || - 'iLqbHhm7qwiBNc8KgL4NQ8tD8fFVhNhNqZ2nRdprgnKNjgJHgvitWx6DPoZJpYHa'; +//xlog makes a log trivial: this one is to check the URL is being properly decoded. +xlog() + .info(`Database url: ${dbUrl}`) + .success(dbUrl) + .info(`Keystone host`) + .success(keystoneHost); const auth = createAuth({ identityField: 'email', @@ -42,7 +68,7 @@ export default auth.withAuth( provider: 'postgresql', useMigrations: true, }, - ui: { isAccessAllowed: rules.canUseAdminUI }, + ui: { isAccessAllowed: permissions.canUseAdminUI }, lists, session: statelessSessions({ secret: sessionSecret, diff --git a/migrations/20211010080123_y/migration.sql b/migrations/20211010080123_y/migration.sql new file mode 100644 index 00000000..0b600a78 --- /dev/null +++ b/migrations/20211010080123_y/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "User" ALTER COLUMN "password" DROP NOT NULL; diff --git a/migrations/20211026083435_single_valued_polls/migration.sql b/migrations/20211026083435_single_valued_polls/migration.sql new file mode 100644 index 00000000..98df8ef7 --- /dev/null +++ b/migrations/20211026083435_single_valued_polls/migration.sql @@ -0,0 +1,24 @@ +/* + Warnings: + + - You are about to drop the column `poll` on the `PollAnswer` table. All the data in the column will be lost. + - A unique constraint covering the columns `[answers]` on the table `Poll` will be added. If there are existing duplicate values, this will fail. + +*/ +-- DropForeignKey +ALTER TABLE "PollAnswer" DROP CONSTRAINT "PollAnswer_poll_fkey"; + +-- DropIndex +DROP INDEX "PollAnswer_poll_idx"; + +-- AlterTable +ALTER TABLE "Poll" ADD COLUMN "answers" TEXT; + +-- AlterTable +ALTER TABLE "PollAnswer" DROP COLUMN "poll"; + +-- CreateIndex +CREATE UNIQUE INDEX "Poll_answers_key" ON "Poll"("answers"); + +-- AddForeignKey +ALTER TABLE "Poll" ADD CONSTRAINT "Poll_answers_fkey" FOREIGN KEY ("answers") REFERENCES "PollAnswer"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/migrations/20211026172315_answers_back/migration.sql b/migrations/20211026172315_answers_back/migration.sql new file mode 100644 index 00000000..32f3db83 --- /dev/null +++ b/migrations/20211026172315_answers_back/migration.sql @@ -0,0 +1,5 @@ +-- DropIndex +DROP INDEX "Poll_answers_key"; + +-- CreateIndex +CREATE INDEX "Poll_answers_idx" ON "Poll"("answers"); diff --git a/migrations/20211026205713_fixing_up_pols/migration.sql b/migrations/20211026205713_fixing_up_pols/migration.sql new file mode 100644 index 00000000..43a2f1ba --- /dev/null +++ b/migrations/20211026205713_fixing_up_pols/migration.sql @@ -0,0 +1,23 @@ +/* + Warnings: + + - You are about to drop the column `answers` on the `Poll` table. All the data in the column will be lost. + +*/ +-- DropForeignKey +ALTER TABLE "Poll" DROP CONSTRAINT "Poll_answers_fkey"; + +-- DropIndex +DROP INDEX "Poll_answers_idx"; + +-- AlterTable +ALTER TABLE "Poll" DROP COLUMN "answers"; + +-- AlterTable +ALTER TABLE "PollAnswer" ADD COLUMN "poll" TEXT; + +-- CreateIndex +CREATE INDEX "PollAnswer_poll_idx" ON "PollAnswer"("poll"); + +-- AddForeignKey +ALTER TABLE "PollAnswer" ADD CONSTRAINT "PollAnswer_poll_fkey" FOREIGN KEY ("poll") REFERENCES "Poll"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/migrations/20211027172655_try1to01/migration.sql b/migrations/20211027172655_try1to01/migration.sql new file mode 100644 index 00000000..80ce6a90 --- /dev/null +++ b/migrations/20211027172655_try1to01/migration.sql @@ -0,0 +1,23 @@ +/* + Warnings: + + - You are about to drop the `_PollAnswer_answeredByUsers_User_pollAnswers` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropForeignKey +ALTER TABLE "_PollAnswer_answeredByUsers_User_pollAnswers" DROP CONSTRAINT "_PollAnswer_answeredByUsers_User_pollAnswers_A_fkey"; + +-- DropForeignKey +ALTER TABLE "_PollAnswer_answeredByUsers_User_pollAnswers" DROP CONSTRAINT "_PollAnswer_answeredByUsers_User_pollAnswers_B_fkey"; + +-- AlterTable +ALTER TABLE "User" ADD COLUMN "pollAnswers" TEXT; + +-- DropTable +DROP TABLE "_PollAnswer_answeredByUsers_User_pollAnswers"; + +-- CreateIndex +CREATE INDEX "User_pollAnswers_idx" ON "User"("pollAnswers"); + +-- AddForeignKey +ALTER TABLE "User" ADD CONSTRAINT "User_pollAnswers_fkey" FOREIGN KEY ("pollAnswers") REFERENCES "PollAnswer"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/migrations/20211027175303_get_many_back/migration.sql b/migrations/20211027175303_get_many_back/migration.sql new file mode 100644 index 00000000..7c5c5814 --- /dev/null +++ b/migrations/20211027175303_get_many_back/migration.sql @@ -0,0 +1,32 @@ +/* + Warnings: + + - You are about to drop the column `pollAnswers` on the `User` table. All the data in the column will be lost. + +*/ +-- DropForeignKey +ALTER TABLE "User" DROP CONSTRAINT "User_pollAnswers_fkey"; + +-- DropIndex +DROP INDEX "User_pollAnswers_idx"; + +-- AlterTable +ALTER TABLE "User" DROP COLUMN "pollAnswers"; + +-- CreateTable +CREATE TABLE "_PollAnswer_answeredByUsers_User_pollAnswers" ( + "A" TEXT NOT NULL, + "B" TEXT NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "_PollAnswer_answeredByUsers_User_pollAnswers_AB_unique" ON "_PollAnswer_answeredByUsers_User_pollAnswers"("A", "B"); + +-- CreateIndex +CREATE INDEX "_PollAnswer_answeredByUsers_User_pollAnswers_B_index" ON "_PollAnswer_answeredByUsers_User_pollAnswers"("B"); + +-- AddForeignKey +ALTER TABLE "_PollAnswer_answeredByUsers_User_pollAnswers" ADD FOREIGN KEY ("A") REFERENCES "PollAnswer"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_PollAnswer_answeredByUsers_User_pollAnswers" ADD FOREIGN KEY ("B") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/migrations/20220418024756_next_to_6/migration.sql b/migrations/20220418024756_next_to_6/migration.sql new file mode 100644 index 00000000..bf330006 --- /dev/null +++ b/migrations/20220418024756_next_to_6/migration.sql @@ -0,0 +1,60 @@ +/* + Warnings: + + - You are about to drop the `_Label_posts_Post_labels` table. If the table is not empty, all the data it contains will be lost. + - You are about to drop the `_PollAnswer_answeredByUsers_User_pollAnswers` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropForeignKey +ALTER TABLE "_Label_posts_Post_labels" DROP CONSTRAINT "_Label_posts_Post_labels_A_fkey"; + +-- DropForeignKey +ALTER TABLE "_Label_posts_Post_labels" DROP CONSTRAINT "_Label_posts_Post_labels_B_fkey"; + +-- DropForeignKey +ALTER TABLE "_PollAnswer_answeredByUsers_User_pollAnswers" DROP CONSTRAINT "_PollAnswer_answeredByUsers_User_pollAnswers_A_fkey"; + +-- DropForeignKey +ALTER TABLE "_PollAnswer_answeredByUsers_User_pollAnswers" DROP CONSTRAINT "_PollAnswer_answeredByUsers_User_pollAnswers_B_fkey"; + +-- DropTable +DROP TABLE "_Label_posts_Post_labels"; + +-- DropTable +DROP TABLE "_PollAnswer_answeredByUsers_User_pollAnswers"; + +-- CreateTable +CREATE TABLE "_Label_posts" ( + "A" TEXT NOT NULL, + "B" TEXT NOT NULL +); + +-- CreateTable +CREATE TABLE "_PollAnswer_answeredByUsers" ( + "A" TEXT NOT NULL, + "B" TEXT NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "_Label_posts_AB_unique" ON "_Label_posts"("A", "B"); + +-- CreateIndex +CREATE INDEX "_Label_posts_B_index" ON "_Label_posts"("B"); + +-- CreateIndex +CREATE UNIQUE INDEX "_PollAnswer_answeredByUsers_AB_unique" ON "_PollAnswer_answeredByUsers"("A", "B"); + +-- CreateIndex +CREATE INDEX "_PollAnswer_answeredByUsers_B_index" ON "_PollAnswer_answeredByUsers"("B"); + +-- AddForeignKey +ALTER TABLE "_Label_posts" ADD FOREIGN KEY ("A") REFERENCES "Label"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_Label_posts" ADD FOREIGN KEY ("B") REFERENCES "Post"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_PollAnswer_answeredByUsers" ADD FOREIGN KEY ("A") REFERENCES "PollAnswer"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_PollAnswer_answeredByUsers" ADD FOREIGN KEY ("B") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/next-env.d.ts b/next-env.d.ts index 9bc3dd46..62b8a52d 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,5 @@ -/// -/// -/// - -// NOTE: This file should not be edited -// see https://nextjs.org/docs/basic-features/typescript for more information. +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/next.config.js b/next.config.js index 7ebcc584..91d55f9e 100644 --- a/next.config.js +++ b/next.config.js @@ -1,4 +1,6 @@ -module.exports = { +const { withTsGql } = require('@ts-gql/next'); + +module.exports = withTsGql({ async rewrites() { return [ { @@ -6,5 +8,5 @@ module.exports = { destination: 'http://localhost:3000/api/graphql', }, ]; - }, -}; + } +}); diff --git a/nextjs.dockerfile b/nextjs.dockerfile new file mode 100644 index 00000000..b5580921 --- /dev/null +++ b/nextjs.dockerfile @@ -0,0 +1,45 @@ +# Install dependencies only when needed +FROM docker.io/library/node:14.18.1-bullseye AS deps +# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. +#RUN apk add --no-cache libc6-compat +WORKDIR /app +COPY ./ ./ +RUN yarn install --frozen-lockfile + +# Rebuild the source code only when needed +FROM docker.io/library/node:14.18.1-bullseye AS builder +WORKDIR /app +COPY . . +COPY --from=deps /app/node_modules ./node_modules +RUN yarn site:build && yarn install --production --ignore-scripts --prefer-offline + +# Production image, copy all the files and run next +FROM docker.io/library/node:14.18.1-bullseye AS runner + + +ENV NODE_ENV production +RUN apt-get update -y +RUN apt-get install dumb-init -y +RUN groupadd --gid 1001 nextjs +RUN useradd --uid 1001 --gid 1001 nextjs + +WORKDIR /home/nextjs +# You only need to copy next.config.js if you are NOT using the default configuration +#COPY --from=builder /app/next.config.js ./ +#COPY --from=builder /app/public ./public +COPY --from=builder --chown=nextjs:nextjs /app/ ./ +#COPY --from=builder /app/node_modules ./node_modules +#COPY --from=builder /app/package.json ./package.json + +USER nextjs + +EXPOSE 8000 + +# Next.js collects completely anonymous telemetry data about general usage. +# Learn more here: https://nextjs.org/telemetry +# Uncomment the following line in case you want to disable telemetry. +ENV NEXT_TELEMETRY_DISABLED 1 + +HEALTHCHECK CMD ls -alt + +CMD ["/usr/bin/dumb-init", "--rewrite", "2:3", "--", "yarn", "site:start"] diff --git a/package.json b/package.json index 65dacae9..c313a150 100644 --- a/package.json +++ b/package.json @@ -1,43 +1,79 @@ { - "name": "keystone-react-todo-demo", + "name": "prisma-day-latest", "version": "1.0.0", "private": true, "scripts": { - "postinstall": "keystone-next postinstall", + "clean": "rm -rf node_modules yarn.lock .keystone .next __generated__", + "gitadd": "yarn format && git add . ||:", + "commit": "yarn site:build && yarn gitadd && git commit -a -m ", + "xfastcommit": "yarn gitadd && git commit -a -m ", + "push": "git push origin latest", + "postinstall": "keystone postinstall && ts-gql build ", "site:dev": "next dev -p 8000", "site:build": "next build", "site:start": "next start -p 8000", - "dev": "keystone-next dev", - "start": "keystone-next start", - "build": "keystone-next build", - "migrate": "keystone-next prisma migrate deploy", - "format": "prettier --write \"**/*.ts\"" + "site:launch": "yarn site:build && yarn site:start", + "dev": "keystone dev", + "start": "keystone start", + "build": "yarn lint && keystone build", + "launch": "sleep 10 && yarn build && yarn start", + "migrate": "keystone prisma migrate deploy", + "format": "prettier --write \"**/*.ts\"", + "lint": "eslint ./utils ./pages ./schema ./*.ts ./*.js", + "set": "cp -a ./utils/typescript-IO-Monad ../typescript-IO-Monad" }, "dependencies": { - "@keystone-next/auth": "33.0.0", - "@keystone-next/document-renderer": "^4.0.0", - "@keystone-next/fields-document": "^10.0.0", - "@keystone-next/keystone": "^26.0.1", + "@apollo/client": "^3.5.10", + "@keystone-6/auth": "^1.0.0", + "@keystone-6/core": "^1.0.1", + "@keystone-6/document-renderer": "^1.0.0", + "@keystone-6/fields-document": "^1.0.1", + "@ts-gql/apollo": "^0.11.0", + "@ts-gql/compiler": "^0.15.0", + "@ts-gql/eslint-plugin": "^0.8.0", + "@ts-gql/next": "^16.0.0", + "@ts-gql/tag": "^0.6.0", + "@typescript-eslint/eslint-plugin": "^5.15.0", + "@typescript-eslint/parser": "^5.15.0", "classnames": "^2.3.1", + "colors": "^1.4.0", + "cuid": "^2.1.8", + "error-stack-parser": "^2.0.7", + "eslint": "8.11.0", + "eslint-config-next": "12.1.5", + "eslint-import-resolver-babel-module": "^5.3.1", + "eslint-plugin-import": "^2.25.2", + "eslint-plugin-react": "^7.26.1", + "eslint-plugin-react-hooks": "^4.2.0", "graphql": "^15.6.1", - "next": "^11.1.2", + "next": "^12.1.5", "node-fetch": "^2.6.1", "react": "^17.0.2", "react-dom": "^17.0.2", - "urql": "^2.0.5" + "sharp": "^0.29.2" }, "devDependencies": { - "@tailwindcss/forms": "^0.3.3", - "@types/react": "17.0.27", + "@tailwindcss/forms": "^0.5.0", + "@types/node": "^16.11.4", + "@types/react": "^18.0.5", + "@types/readline-sync": "^1.4.4", "autoprefixer": "^10.3.7", - "postcss": "^8.3.9", - "prettier": "^2.4.1", - "tailwindcss": "^2.2.4", - "typescript": "4.4.3" + "babel-plugin-module-resolver": "^4.1.0", + "jsdom": "^18.0.0", + "jsdom-global": "^3.0.2", + "postcss": "^8.4.12", + "prettier": "^2.6.0", + "tailwindcss": "^3.0.23", + "typescript": "^4.6.2" }, "prettier": { "singleQuote": true, "trailingComma": "es5", "arrowParens": "avoid" + }, + "ts-gql": { + "schema": "schema.graphql", + "addTypenames": false, + "mode": "no-transform" } } diff --git a/pages/_app.tsx b/pages/_app.tsx index 500a029f..2aecbd6a 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -1,23 +1,30 @@ import 'tailwindcss/tailwind.css'; import type { AppProps } from 'next/app'; -import { createClient, Provider } from 'urql'; import { AuthProvider } from '../components/auth'; +import { ApolloClient, InMemoryCache, ApolloProvider } from '@apollo/client'; +import { useMemo } from 'react'; + -export const client = createClient({ - url: - typeof window === undefined - ? 'http://localhost:3000/api/graphql' - : '/api/graphql', -}); function MyApp({ Component, pageProps }: AppProps) { + const client = useMemo( + () => + new ApolloClient({ + cache: new InMemoryCache(), + uri: + typeof window === "undefined" + ? 'http://localhost:3000/api/graphql' + : '/api/graphql', + }), + [] + ); return ( - + - + ); } export default MyApp; diff --git a/pages/index.tsx b/pages/index.tsx index 3739309a..d0f77a1a 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -1,35 +1,26 @@ -import React, { useState } from 'react'; -import { GetStaticPropsContext } from 'next'; +import React from 'react'; +import { InferGetStaticPropsType } from 'next'; -import { fetchGraphQL, gql } from '../utils'; +import { gql } from '@ts-gql/tag/no-transform'; +import { fetchGraphQLInjectApiKey } from '../utils/fetchGraphQL'; import { DocumentRenderer } from '../schema/fields/content/renderers'; import { Container } from '../components/ui/layout'; import { Link } from '../components/ui/link'; import { H1 } from '../components/ui/typography'; import { useAuth } from '../components/auth'; +import { makeIO } from '../utils/maybeIOPromise' -type Post = { - id: string; - slug: string; - title: string; - publishedDate: string; - intro: { - document: any; - }; - author: { - name: string; - }; -}; - -export default function Home({ posts }: { posts: Post[] }) { +export default function Home({ + posts, +}: InferGetStaticPropsType) { const auth = useAuth(); return (

My Blog

{auth.ready && auth.sessionData ? (

- You're signed in as {auth.sessionData.name} |{' '} + You're signed in as {auth.sessionData.name} |{' '}

) : ( @@ -39,7 +30,7 @@ export default function Home({ posts }: { posts: Post[] }) { )}
- {posts.map(post => { + {posts?.map(post => { const date = post.publishedDate ? new Date(post.publishedDate).toLocaleDateString() : null; @@ -63,10 +54,10 @@ export default function Home({ posts }: { posts: Post[] }) { ); } -export async function getStaticProps({ params }: GetStaticPropsContext) { - const data = await fetchGraphQL( - gql` - query { +const fetchAllPostsForIndex = makeIO (() => + fetchGraphQLInjectApiKey({ + operation: gql` + query AllPosts { posts( where: { status: { equals: "published" } } orderBy: [{ publishedDate: desc }] @@ -84,7 +75,13 @@ export async function getStaticProps({ params }: GetStaticPropsContext) { } } } - ` - ); - return { props: { posts: data.posts }, revalidate: 60 }; -} + ` as import('../__generated__/ts-gql/AllPosts').type, + })) + .then (data => data.posts); + + + export const getStaticProps = () => { + return fetchAllPostsForIndex + .exec ([]) + .then (postsRx => { return { props: { posts: postsRx }, revalidate: 60 } }) + } diff --git a/pages/post/[slug].tsx b/pages/post/[slug].tsx index 074a3429..2e8a16cb 100644 --- a/pages/post/[slug].tsx +++ b/pages/post/[slug].tsx @@ -1,25 +1,32 @@ -import { GetStaticPathsResult, GetStaticPropsContext } from 'next'; +import { + GetStaticPropsContext, + InferGetStaticPropsType, +} from 'next'; import React from 'react'; -import { fetchGraphQL, gql } from '../../utils'; +import { gql } from '@ts-gql/tag/no-transform'; +import { fetchGraphQLInjectApiKey} from '../../utils/fetchGraphQL'; import { DocumentRenderer } from '../../schema/fields/content/renderers'; import { Container, HomeLink } from '../../components/ui/layout'; -import { Link } from '../../components/ui/link'; +//import { Link } from '../../components/ui/link'; import { H1 } from '../../components/ui/typography'; +import { makeIO, pure} from '../../utils/maybeIOPromise' -export default function Post({ post }: { post: any }) { +export default function Post({ + post, +}: InferGetStaticPropsType) { return (
-

{post.title}

- {post.author?.name && ( +

{post?.title}

+ {post?.author?.name && (

By {post.author.name}

)} - {post.content?.document && ( + {post?.content?.document && ( )}
@@ -27,40 +34,64 @@ export default function Post({ post }: { post: any }) { ); } -export async function getStaticPaths(): Promise { - const data = await fetchGraphQL( - gql` - query { - posts { - slug - } + +const fetchStaticPaths = makeIO (() => + fetchGraphQLInjectApiKey({ + operation: gql` + query PostSlugs { + posts { + id + slug } - ` - ); - return { - paths: data.posts.map((post: any) => ({ params: { slug: post.slug } })), + } + ` as import('../../__generated__/ts-gql/PostSlugs').type, +}) + .then (data => data.posts) + .then( posts => { + return { paths: posts?.map(post => ({ params: { slug: post.slug } })), fallback: 'blocking', - }; + } } +) +); + +export const getStaticPaths = () => { + return fetchStaticPaths + .exec({ paths: [], fallback: 'blocking' }); } -export async function getStaticProps({ params }: GetStaticPropsContext) { - const data = await fetchGraphQL( - gql` - query ($slug: String!) { - post(where: { slug: $slug }) { - title - content { - document(hydrateRelationships: true) - } - publishedDate - author { + +const fetchStaticProps = (staticProps: GetStaticPropsContext) => + + pure(staticProps) + .then (props => props.params) + .then (params => params.slug as string) + .promise(slug => + fetchGraphQLInjectApiKey({ + operation: gql` + query PostPage($slug: String!) { + post(where: { slug: $slug }) { id - name + title + content { + document(hydrateRelationships: true) + } + publishedDate + author { + id + name + } } } - } - `, - { slug: params!.slug } - ); - return { props: { post: data.post }, revalidate: 60 }; -} + ` as import('../../__generated__/ts-gql/PostPage').type, + variables: { slug: slug}, + })) + .then (data => data.post) + ; + + +export const getStaticProps = ( params : GetStaticPropsContext) => { + return fetchStaticProps(params) + .run () + .then(match_post => { + return { props: { post: match_post }, revalidate: 60 }}) +}; diff --git a/pages/signin.tsx b/pages/signin.tsx index bd46651d..a4a122da 100644 --- a/pages/signin.tsx +++ b/pages/signin.tsx @@ -5,8 +5,11 @@ import { Container, HomeLink } from '../components/ui/layout'; import { H1 } from '../components/ui/typography'; import { FieldContainer, FieldLabel, TextInput } from '../components/ui/forms'; import { Link } from '../components/ui/link'; -import { useRouter } from 'next/router'; +//import { useRouter } from 'next/router'; import { useAuth } from '../components/auth'; +import { gotoPage } from '../utils/gotoPage' +import { ioRoot } from '../utils/maybeIOPromise' +import { drop } from '../utils/func' export default function SigninPage() { const auth = useAuth(); @@ -16,7 +19,7 @@ export default function SigninPage() { // const router = useRouter(); - const signIn = async () => { + const signIn = () => { if (!auth.ready) { setError('Auth is not ready, try again in a moment.'); return; @@ -26,17 +29,18 @@ export default function SigninPage() { return; } setError(''); - const result = await auth.signIn({ email, password }); - if (result.success) { - // FIXME: there's a cache issue with Urql where it's not reloading the - // current user properly if we do a client-side redirect here. - // router.push('/'); - top.location.href = '/'; - } else { - setEmail(''); - setPassword(''); - setError(result.message); - } + ioRoot + .promise(u => drop(u)(auth.signIn({ email, password }))) + .then(result => result.success? + () => gotoPage('/') + : + () => { + setEmail(''); + setPassword(''); + setError(result.message); + }) + .then(f => f()) + .run(); }; return ( diff --git a/pages/signup.tsx b/pages/signup.tsx index 555246b7..9d793f53 100644 --- a/pages/signup.tsx +++ b/pages/signup.tsx @@ -1,5 +1,4 @@ import { useState } from 'react'; -import { gql, useMutation } from 'urql'; import { Button } from '../components/ui/controls'; import { Container, HomeLink } from '../components/ui/layout'; @@ -7,19 +6,24 @@ import { H1 } from '../components/ui/typography'; import { FieldContainer, FieldLabel, TextInput } from '../components/ui/forms'; import { useRouter } from 'next/router'; import { Link } from '../components/ui/link'; +import { gql } from '@ts-gql/tag/no-transform'; +import { useMutation } from '@ts-gql/apollo'; + export default function SignupPage() { - const [{ error, data }, signup] = useMutation(gql` - mutation ($name: String!, $email: String!, $password: String!) { - createUser(data: { name: $name, email: $email, password: $password }) { - __typename - id - } - authenticateUserWithPassword(email: $email, password: $password) { - __typename + const [signup, { error}] = useMutation( + gql` + mutation Signup($name: String!, $email: String!, $password: String!) { + createUser(data: { name: $name, email: $email, password: $password }) { + __typename + id + } + authenticateUserWithPassword(email: $email, password: $password) { + __typename + } } - } - `); + ` as import('../__generated__/ts-gql/Signup').type + ); const [name, setName] = useState(''); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); @@ -33,12 +37,12 @@ export default function SignupPage() {
{ event.preventDefault(); - signup({ name, email, password }).then(result => { + signup({ + variables: { name, email, password }, + refetchQueries: ['AuthenticatedItem'], + }).then(result => { if (result.data?.createUser) { - // FIXME: there's a cache issue with Urql where it's not reloading the - // current user properly if we do a client-side redirect here. - // router.push('/'); - top.location.href = '/'; + router.push('/'); } }); }} @@ -73,7 +77,7 @@ export default function SignupPage() { />

diff --git a/pages/ui.tsx b/pages/ui.tsx index 8a6e993d..85354e18 100644 --- a/pages/ui.tsx +++ b/pages/ui.tsx @@ -1,7 +1,9 @@ +import React from 'react' import { Container, Divider } from '../components/ui/layout'; import { H1, H2, H3, H4, H5, H6 } from '../components/ui/typography'; -export default function UIPage() { + +export default function UIPage () { return (

Heading 1

@@ -14,3 +16,4 @@ export default function UIPage() {
); } + diff --git a/public/avatars/cartoon.jpg b/public/avatars/cartoon.jpg new file mode 100644 index 00000000..acc04914 Binary files /dev/null and b/public/avatars/cartoon.jpg differ diff --git a/schema.graphql b/schema.graphql index 911e07d3..532bf8e0 100644 --- a/schema.graphql +++ b/schema.graphql @@ -4,13 +4,6 @@ type Mutation { voteForPoll(answerId: ID!): Boolean clearVoteForPoll(pollId: ID!): Boolean - createInitialUser( - data: CreateInitialUserInput! - ): UserAuthenticationWithPasswordSuccess! - authenticateUserWithPassword( - email: String! - password: String! - ): UserAuthenticationWithPasswordResult! createPost(data: PostCreateInput!): Post createPosts(data: [PostCreateInput!]!): [Post] updatePost(where: PostWhereUniqueInput!, data: PostUpdateInput!): Post @@ -51,27 +44,13 @@ type Mutation { deleteRole(where: RoleWhereUniqueInput!): Role deleteRoles(where: [RoleWhereUniqueInput!]!): [Role] endSession: Boolean! -} - -input CreateInitialUserInput { - name: String - email: String - password: String -} - -union AuthenticatedItem = User - -union UserAuthenticationWithPasswordResult = - UserAuthenticationWithPasswordSuccess - | UserAuthenticationWithPasswordFailure - -type UserAuthenticationWithPasswordSuccess { - sessionToken: String! - item: User! -} - -type UserAuthenticationWithPasswordFailure { - message: String! + authenticateUserWithPassword( + email: String! + password: String! + ): UserAuthenticationWithPasswordResult + createInitialUser( + data: CreateInitialUserInput! + ): UserAuthenticationWithPasswordSuccess! } type Post { @@ -539,12 +518,17 @@ input UserWhereInput { id: IDFilter name: StringFilter email: StringFilter + password: PasswordFilter role: RoleWhereInput githubUsername: StringFilter authoredPosts: PostManyRelationFilter pollAnswers: PollAnswerManyRelationFilter } +input PasswordFilter { + isSet: Boolean! +} + input UserOrderByInput { id: OrderDirection name: OrderDirection @@ -656,8 +640,26 @@ scalar JSON url: "http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf" ) +union UserAuthenticationWithPasswordResult = + UserAuthenticationWithPasswordSuccess + | UserAuthenticationWithPasswordFailure + +type UserAuthenticationWithPasswordSuccess { + sessionToken: String! + item: User! +} + +type UserAuthenticationWithPasswordFailure { + message: String! +} + +input CreateInitialUserInput { + name: String + email: String + password: String +} + type Query { - authenticatedItem: AuthenticatedItem posts( where: PostWhereInput! = {} orderBy: [PostOrderByInput!]! = [] @@ -707,8 +709,11 @@ type Query { role(where: RoleWhereUniqueInput!): Role rolesCount(where: RoleWhereInput! = {}): Int keystone: KeystoneMeta! + authenticatedItem: AuthenticatedItem } +union AuthenticatedItem = User + type KeystoneMeta { adminMeta: KeystoneAdminMeta! } diff --git a/schema.prisma b/schema.prisma index 3be0f0b3..d89b6c95 100644 --- a/schema.prisma +++ b/schema.prisma @@ -7,9 +7,8 @@ datasource postgresql { } generator client { - provider = "prisma-client-js" - output = "node_modules/.prisma/client" - engineType = "binary" + provider = "prisma-client-js" + output = "node_modules/.prisma/client" } model Post { @@ -20,7 +19,7 @@ model Post { publishedDate DateTime? author User? @relation("Post_author", fields: [authorId], references: [id]) authorId String? @map("author") - labels Label[] @relation("Label_posts_Post_labels") + labels Label[] @relation("Label_posts") intro Json @default("[{\"type\":\"paragraph\",\"children\":[{\"text\":\"\"}]}]") content Json @default("[{\"type\":\"paragraph\",\"children\":[{\"text\":\"\"}]}]") @@ -30,7 +29,7 @@ model Post { model Label { id String @id @default(cuid()) name String @default("") - posts Post[] @relation("Label_posts_Post_labels") + posts Post[] @relation("Label_posts") } model Poll { @@ -44,7 +43,7 @@ model PollAnswer { label String @default("") poll Poll? @relation("PollAnswer_poll", fields: [pollId], references: [id]) pollId String? @map("poll") - answeredByUsers User[] @relation("PollAnswer_answeredByUsers_User_pollAnswers") + answeredByUsers User[] @relation("PollAnswer_answeredByUsers") @@index([pollId]) } @@ -53,12 +52,12 @@ model User { id String @id @default(cuid()) name String @default("") email String @unique @default("") - password String + password String? role Role? @relation("User_role", fields: [roleId], references: [id]) roleId String? @map("role") githubUsername String @default("") authoredPosts Post[] @relation("Post_author") - pollAnswers PollAnswer[] @relation("PollAnswer_answeredByUsers_User_pollAnswers") + pollAnswers PollAnswer[] @relation("PollAnswer_answeredByUsers") @@index([roleId]) } diff --git a/schema/access.ts b/schema/access.ts index 25cea95c..d62d792d 100644 --- a/schema/access.ts +++ b/schema/access.ts @@ -1,6 +1,23 @@ -type SessionContext = { +import { Context } from '.keystone/types'; +import { frontEndPort, keystoneNextjsBuildApiKey } from '../keystone'; +import { log } from '../utils/logging'; +import { drop } from '../utils/func'; +import { ItemType } from '../wrap_any'; + +export const PUBLISHED = 'published'; +export const DRAFT = 'draft'; +export const ARCHIVED = 'archive'; + +export const EDIT = 'edit'; +export const READ = 'read'; +export const HIDDEN = 'hidden'; + +export const PUBLISHED_POST_STATUS = { status: { equals: PUBLISHED } }; + +export type SessionContext = { session?: { data: { + id: string; name: string; role: { canManageContent: boolean; @@ -11,37 +28,251 @@ type SessionContext = { listKey: string; }; }; -type ItemContext = { item: any; } & SessionContext; -export const isSignedIn = ({ session }: SessionContext) => { - return !!session; +export type SessionFrame = { + session: ItemContext; + context: SessionContext; + listKey: string; + operation: string; +}; + +export type FilterFrame = SessionFrame & { + session: SessionContext; + context: Context; + listKey: string; + operation: string; +}; + +export type ItemContext = { item: ItemType } & SessionContext; +//import { useAuth } from '../components/auth'; + +//FIXME: Needs API key. +export const isBuildEnvir = (frame: SessionFrame): boolean => { + if (frame?.session === undefined) { + // It's a bit confusing as to why this dodgy cast is needed. It took ages to get api keys working because of the + // obscuriy of the workaround. + // The top level query prefers SessionFrame, and does not build against KeystoneFrame (same, bar Context for context) + // This does appear to be the right way to access the context, but why do we need to + // cast something as important as this? + const kontext = frame?.context as Context; + const recvApiKey = kontext?.req?.headers['x-api-key']; + + if (recvApiKey === keystoneNextjsBuildApiKey) { + if (recvApiKey.includes('keystone')) { + log().warning('access: prototype api key: ' + recvApiKey); + } + log().success( + 'access::isBuildEnvir: next build: api key matches: granting super user query access.' + ); + return true; + } else { + log() + .warning('access::isBuildEnvir: authentication breach:') + .success( + 'access::isBuildEnvir: no additional authorisation granted to breach.' + ); + return false; + } + } + return false; +}; + +export const isSignedIn = (context: Context) => { + /* + const auth = useAuth(); + if (!auth.ready || !auth.sessionData) + return false; + + return true; +*/ + if (!context) return false; + if (!context?.session) return false; + if (!context.sudo()?.session) return false; + return true; }; +//They can't easy be expressed in terms of the more elementary functions either. undefined issues. export const permissions = { - canManageContent: ({ session }: SessionContext) => { - return !!session?.data.role?.canManageContent; - }, - canManageUsers: ({ session }: SessionContext) => { - return !!session?.data.role?.canManageUsers; + canUseAdminUI: ({ session }: SessionContext): boolean => + Boolean(session?.data?.role), + canManageContent: (frame: SessionFrame): boolean => + Boolean(frame?.context?.session?.data?.role?.canManageContent), + canManageUsers: (frame: SessionFrame): boolean => + Boolean(frame?.context?.session?.data?.role?.canManageUsers), + + allow: (frame: T) => drop(frame)(true), + canManageContentSession: ({ session }: SessionContext): boolean => { + return Boolean(session?.data?.role?.canManageContent); }, -}; + canManageContentItem: (item: ItemContext): boolean => + Boolean(item?.session?.data?.role?.canManageContent), -export const rules = { - canUseAdminUI: ({ session }: SessionContext) => { - return !!session?.data.role; + canManageUsersSession: ({ session }: SessionContext): boolean => { + return Boolean(session?.data?.role?.canManageUsers); }, - canReadContentList: ({ session }: SessionContext) => { - if (permissions.canManageContent({ session })) return true; - return { status: { equals: 'published' } }; + canManageContentList: (frame: SessionFrame) => + permissions.canManageContent(frame), + + filterCanManageContentList: (frame: SessionFrame) => { + if (frame === undefined) { + log().reportSecurityIncident( + 'Minor security breach: potential auth bug. undefined frame: query downgraded to public.' + ); + return PUBLISHED_POST_STATUS; + //Give no information away that they have been noticed, but if there's no frame + //it's hard to imagine where the reply is going to go ... if exec ever gets here, it's close + //to a fatal error. What is the best thing to do here? Nothing? throw? + } + //Needs shared secret, set in bash, imported via process.env, usage tested in the CI workflow, which act as the base spec for a container to run keystone/next in. + if (isBuildEnvir(frame)) { + return true; + } + //The perms check is only running client side. Review: check the authorization props are checked + //server side to. + if (frame.context.session?.data?.role?.canManageContent ?? false) { + log() + .success('Blessed super user access to the known content manager:') + .success(frame?.context?.session?.data?.name); + return true; + } + + log() + .success('Client receives only published posts:') + .success(frame?.context?.session?.data?.name); + //success(frame.context.session?.data?.role?.canManageContent); + return PUBLISHED_POST_STATUS; }, - canManageUser: ({ session, item }: ItemContext) => { - if (permissions.canManageUsers({ session })) return true; - if (session?.itemId === item.id) return true; + filterCanManageUserListBool: (frame: SessionFrame) => { + if (frame === undefined) { + log().info( + 'Undefined frame: assuming unauthenticated user: public user query rights granted.' + ); + return false; + //Give no information away that they have been noticed, but if there's no frame + //it's hard to imagine where the reply is going to go ... if exec ever gets here, it's close + //to a fatal error. What is the best thing to do here? Nothing? throw? + } + //Needs shared secret, set in bash, imported via process.env, usage tested in the CI workflow, which act as the base spec for a container to run keystone/next in. + if (isBuildEnvir(frame)) { + return true; + } + //The perms check is only running client side. Review: check the authorization props are checked + //server side to. + if (frame.context.session?.data?.role?.canManageUsers ?? false) { + log() + .success('Blessed super user access to the known user manager:') + .success(frame?.context?.session?.data?.name); + return true; + } + return false; }, - canManageUserList: ({ session }: SessionContext) => { - if (permissions.canManageUsers({ session })) return true; - if (!isSignedIn({ session })) return false; - return { where: { id: { equals: session!.itemId } } }; + + filterCanManageUserList: (frame: SessionFrame) => { + if (permissions.filterCanManageUserListBool(frame)) return true; + log() + .success('Client receives only their own user data:') + .success(frame?.context?.session?.data?.name); + //success(frame.context.session?.data?.role?.canManageContent); + return { id: { equals: frame.context.session?.itemId ?? '' } }; }, -}; + + isOnFrontEnd: (frame: SessionFrame) => { + if (!frame || !frame?.context) return true; + + if (isBuildEnvir(frame)) return true; + + const ks = frame.context as Context; + + return ks.req?.headers['x-forwarded-port'] === frontEndPort; + }, + + isOnInitPage: (frame: SessionFrame) => { + log().info('Testing if session is initial session'); + + if (!frame || !frame?.context) return false; + + const ks = frame.context as Context; + + const url = ks.req?.headers?.referer + ? new URL(ks.req.headers.referer) + : undefined; + + //log().info(url); + + const onInitPage = url?.pathname === '/init'; + + return onInitPage; + }, + + filterCanManageUserListOrOnFrontEnd: (frame: SessionFrame) => { + // Fixme:: this is a hack to test if we are on the front end + // The conditions that require it are commented on in: + // https://app.slack.com/client/T02FLV1HN/C01STDMEW3S/thread/C01STDMEW3S-1635292440.186100 + //log().info(frame.listKey).info(frame.session).info(frame.operation) + if (permissions.isOnFrontEnd(frame)) return true; + + if (permissions.isOnInitPage(frame)) { + log().warning('Granting super user query rights to assumed first usage.'); + return true; + } + //return drop(frame)(true); + + //if (isFrontEnd()) return true; + + return permissions.filterCanManageUserList(frame); + }, + canVoteInPolls: (frame: SessionFrame) => drop(frame)(true), +} as const; + +//The front line security audit: Initial musings: + +//SSG has the best security profile, and appears completely safe to use SSG for a cdn deployed site without auth. + +//However well local code is audited for security, there is a known can of worms in apollo. It will exit(1) +//at a moments notice, taking ks down with it. The upshot is gql must be considered a soft target, +//and this single, vulnerable endpoint is suitably firewalled, potentially using REST, which +//has a far improved security model. + +// So: safest is when gql is restriced to being a tool for the offline +// build process only, and a convenient one at that. If it is deployed online, great care has to be taken +// to protect it from badly formed queries, and restrict it's input grammar. It's not clear this is any easier to write than +// a well defined REST api. + +// A mitigating factor here is the use of isFilterable on individual fields. When first porting Jeds app, it seemed like an inconvenience, +// but now, it is seen as providing critical security, restricting queries to a vulnerable apollo. + +// This is so important for a production server than does use gql only, that it might be worth +// issuing a warning when a list is defaulted to isFilterable on all fields. Warnings like this can +// be so useful to the correct setup of a dev kit. Also a warning if a field is in a where clause, but not filterable ... can same poor front end developers hours. +// The more info an error can do to help a dev chase its location, the better. When logs get multiplexed by multiple threads, knowing what corresponds to what can waste time. +// Keystone is such a happy app that a warning really gets noticed ;) + +// There are alternatives: +// jQuery to avoid layers of gql inefficiences, and workers. +// Also, REST can start hard, C++ calling core pgsql. From the keystone perspective, it's just another target language for an ast, +// derived from the same schema, but in pgsql C++ (hardest, in every way), +// my own fav for prototyping, bash pg cli (v easy: a surprisingly few lines of code, and handy for CI. Also extraordinarily versatile. Can handof via socat, epoll, anything). +// The ts schema is still the sole source of truth in this model. Nothing much changes in ks, because it is suitably agnostic. + +// Furthermore, gql is not ideally suited to live usage, due to the n+1 problem, and difficuly cacheing a single endpoint. +// Gql doesn't play nicely with reverse proxies, but REST does. Fashion is cyclical! +// But for prototyping apis, the playground auto documentation, and SSG/ISR ... it's a massive time saver. + +// There is some convergence in langauge: +// ts and C++ read very similarly nowadays. As long as everything is done in term of the lambda calculus, it all works out. + +// Unfortunately C++ coroutines are dreadfully thought out. My guess is the committe did not understand the issues https://bartoszmilewski.com/2011/07/11/monads-in-c/ +// raised. Fortunately, I have successfully developed some workarounds for these issue. ;) + +//The AdminUI is considered a fairly soft target, mainly from DoS. But it's easy to harden to the public, using an ssh pki tunnel for access, bringing the auth +//to industry best practise, but remaining vulnerable to authorized bad actors, who we have to keep as far away from the gql endpoint as possible. + +//All this is of relevant here, because this is the last line of defense! + +//In testing, bad username password combinations were reporting auth failure +//immediately. A delay of a few seconds has an improved security model. + +// Core Issues located: +// auth/src/index.ts and related files in keystone core are triggering some "any" issues. Strong typing is (strongly) recommended. +// e.g. const pageMiddleware: AdminUIConfig['pageMiddleware'] = async ({ context, isValidSession }) diff --git a/schema/content.ts b/schema/content.ts index f31cd729..79cf8db5 100644 --- a/schema/content.ts +++ b/schema/content.ts @@ -1,24 +1,48 @@ -import { relationship, select, text, timestamp } from '@keystone-next/keystone/fields'; -import { document } from '@keystone-next/fields-document'; -import { list } from '@keystone-next/keystone'; +import { + relationship, + select, + text, + timestamp, +} from '@keystone-6/core/fields'; +import { document } from '@keystone-6/fields-document'; +import { list } from '@keystone-6/core'; -import { permissions, rules } from './access'; +import { + permissions, + ItemContext, + SessionFrame, + PUBLISHED, + DRAFT, + ARCHIVED, + EDIT, + READ, + HIDDEN, +} from './access'; import { componentBlocks } from '../schema/fields/content/components'; -export const contentListAccess = { - filter: { - create: permissions.canManageContent, - update: permissions.canManageContent, - delete: permissions.canManageContent, - } -}; +import { ItemSession, GraphQLClause } from '../wrap_any'; + +//FIXME: +// These anys are causing issues. What is the strong type? +// The deduced type from permissions api is SessionFrame, but that doesn't work ... +// MaybeSessionFunction has something to do with the ts error. export const contentUIConfig = { - hideCreate: (context: any) => !permissions.canManageContent(context), - hideDelete: (context: any) => !permissions.canManageContent(context), + hideCreate: (session: ItemSession) => + !permissions.canManageContentItem(session), + hideDelete: (session: ItemSession) => + !permissions.canManageContentItem(session), itemView: { - defaultFieldMode: (context: any) => - permissions.canManageContent(context) ? 'edit' : 'read', + defaultFieldMode: (session: ItemContext) => + permissions.canManageContentSession(session) ? EDIT : READ, + }, +}; + +export const contentListAccess = { + operation: { + create: permissions.canManageContentList, + update: permissions.canManageContentList, + delete: permissions.canManageContentList, }, }; @@ -28,6 +52,7 @@ export const Label = list({ fields: { name: text(), posts: relationship({ + isFilterable: true, ref: 'Post.labels', many: true, ui: { @@ -35,35 +60,42 @@ export const Label = list({ }, }), }, -}); +} as const); -function defaultSlug({ context, inputData }: any) { +const defaultSlug = (inputData: GraphQLClause) => { const date = new Date(); - return `${inputData?.title - ?.trim() - ?.toLowerCase() - ?.replace(/[^\w ]+/g, '') - ?.replace(/ +/g, '-') ?? '' - }-${date?.getFullYear() ?? ''}${date?.getMonth() + 1 ?? ''}${date?.getDate() ?? '' - }`; -} + return `${ + inputData?.title + ?.trim() + ?.toLowerCase() + ?.replace(/[^\w ]+/g, '') + ?.replace(/ +/g, '-') ?? '' + }-${date?.getFullYear() ?? ''}${date?.getMonth() + 1 ?? ''}${ + date?.getDate() ?? '' + }`; +}; -function defaultTimestamp() { +export const defaultTimestamp = () => { return new Date().toISOString(); -} +}; export const Post = list({ access: { + operation: { + create: permissions.canManageContentList, + update: permissions.canManageContentList, + delete: permissions.canManageContentList, + }, filter: { - ...contentListAccess.filter, - query: rules.canReadContentList, + query: (frame: SessionFrame) => + permissions.filterCanManageContentList(frame), }, }, ui: contentUIConfig, fields: { title: text(), slug: text({ - ui: { createView: { fieldMode: 'hidden' } }, + ui: { createView: { fieldMode: HIDDEN } }, isIndexed: 'unique', hooks: { resolveInput: ({ operation, resolvedData, inputData, context }) => { @@ -71,14 +103,14 @@ export const Post = list({ return defaultSlug({ context, inputData }); } return resolvedData.slug; - } - } + }, + }, }), status: select({ options: [ - { label: 'Draft', value: 'draft' }, - { label: 'Published', value: 'published' }, - { label: 'Archived', value: 'archived' }, + { label: 'Draft', value: DRAFT }, + { label: 'Published', value: PUBLISHED }, + { label: 'Archived', value: ARCHIVED }, ], defaultValue: 'draft', ui: { displayMode: 'segmented-control' }, @@ -90,8 +122,8 @@ export const Post = list({ return defaultTimestamp(); } return resolvedData.slug; - } - } + }, + }, }), author: relationship({ ref: 'User.authoredPosts' }), labels: relationship({ ref: 'Label.posts', many: true }), @@ -129,4 +161,4 @@ export const Post = list({ ui: { views: require.resolve('./fields/content/components') }, }), }, -}); +} as const); diff --git a/schema/fields/content/components.tsx b/schema/fields/content/components.tsx index b4f86c60..6fb93179 100644 --- a/schema/fields/content/components.tsx +++ b/schema/fields/content/components.tsx @@ -12,12 +12,14 @@ import { component, fields, NotEditable, -} from '@keystone-next/fields-document/component-blocks'; +} from '@keystone-6/fields-document/component-blocks'; import { ToolbarButton, ToolbarGroup, ToolbarSeparator, -} from '@keystone-next/fields-document/primitives'; +} from '@keystone-6/fields-document/primitives'; + +import { PollAnswerAny } from '../../../wrap_any' const appearances = { info: { @@ -46,12 +48,16 @@ const appearances = { }, } as const; + + + export const componentBlocks = { callout: component({ component: function Callout({ content, appearance }) { - const { palette, radii, spacing } = useTheme(); + const { /*palette,*/ radii, spacing } = useTheme(); const intentConfig = appearances[appearance.value]; - + if (content == undefined) + return (
); return (
-
{content}
+
<>{content}
); }, @@ -144,11 +150,11 @@ export const componentBlocks = { component: ({ content, name, position }) => { return (
-
{content}
+
<>{content}
- {name} + <>{name}
-
{position}
+
<>{position}
); }, @@ -179,10 +185,10 @@ export const componentBlocks = {

{poll.value.label}