diff --git a/.circleci/config.yml b/.circleci/config.yml index cbc6f28e7..d18513d60 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -3,7 +3,7 @@ jobs: build: working_directory: ~/outline docker: - - image: circleci/node:8.11 + - image: circleci/node:12 - image: circleci/redis:latest - image: circleci/postgres:9.6.5-alpine-ram environment: @@ -34,4 +34,7 @@ jobs: command: yarn test - run: name: lint - command: yarn lint \ No newline at end of file + command: yarn lint + - run: + name: flow + command: yarn flow \ No newline at end of file diff --git a/.env.sample b/.env.sample index bda81256c..32f80266f 100644 --- a/.env.sample +++ b/.env.sample @@ -3,21 +3,24 @@ # keys (for auth) and the SECRET_KEY. # # Please use `openssl rand -hex 32` to create SECRET_KEY +SECRET_KEY=generate_a_new_key DATABASE_URL=postgres://user:pass@postgres:5432/outline DATABASE_URL_TEST=postgres://user:pass@postgres:5432/outline-test -SECRET_KEY=F0E5AD933D7F6FD8F4DBB3E038C501C052DC0593C686D21ACB30AE205D2F634B -PORT=3000 REDIS_URL=redis://redis:6379 + URL=http://localhost:3000 +PORT=3000 + DEPLOYMENT=self ENABLE_UPDATES=true SUBDOMAINS_ENABLED=false -DEBUG=sql,cache,presenters,events +WEBSOCKETS_ENABLED=true +DEBUG=cache,presenters,events # Third party signin credentials (at least one is required) -SLACK_KEY=71315967491.XXXXXXXXXX -SLACK_SECRET=d2dc414f9953226bad0a356cXXXXYYYY +SLACK_KEY=get_a_key_from_slack +SLACK_SECRET=get_the_secret_of_above_key GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= @@ -28,15 +31,17 @@ GOOGLE_ALLOWED_DOMAINS= # Third party credentials (optional) SLACK_VERIFICATION_TOKEN=PLxk6OlXXXXXVj3YYYY SLACK_APP_ID=A0XXXXXXX +SLACK_MESSAGE_ACTIONS=true GOOGLE_ANALYTICS_ID= BUGSNAG_KEY= GITHUB_ACCESS_TOKEN= # AWS credentials (optional in dev) -AWS_ACCESS_KEY_ID=notcheckedindev -AWS_SECRET_ACCESS_KEY=notcheckedindev +AWS_ACCESS_KEY_ID=get_a_key_from_aws +AWS_SECRET_ACCESS_KEY=get_the_secret_of_above_key +AWS_REGION=xx-xxxx-x AWS_S3_UPLOAD_BUCKET_URL=http://s3:4569 -AWS_S3_UPLOAD_BUCKET_NAME=outline-dev +AWS_S3_UPLOAD_BUCKET_NAME=bucket_name_here AWS_S3_UPLOAD_MAX_SIZE=26214400 # Emails configuration (optional) diff --git a/.eslintrc b/.eslintrc index d90ae86b7..392f01274 100644 --- a/.eslintrc +++ b/.eslintrc @@ -10,19 +10,7 @@ "rules": { "eqeqeq": 2, "no-unused-vars": 2, - // // Bring back after we remove CSS Modules 100% - // "import/order": "warn", - // Prettier automatically uses the least amount of parens possible, so this - // does more harm than good. "no-mixed-operators": "off", - // Temporary fix for a failing import lint - "import/no-unresolved": [ - "error", - { - "ignore": ["slate-drop-or-paste-images"] - } - ], - // Flow "flowtype/require-valid-file-annotation": [ 2, "always", @@ -32,7 +20,6 @@ ], "flowtype/space-after-type-colon": [2, "always"], "flowtype/space-before-type-colon": [2, "never"], - // Enforce that code is formatted with prettier. "prettier/prettier": [ "error", { @@ -43,6 +30,12 @@ ] }, "settings": { + "react": { + "createClass": "createReactClass", + "pragma": "React", + "version": "detect", + "flowVersion": "0.86" + }, "import/resolver": { "node": { "paths": ["app", "."] diff --git a/.flowconfig b/.flowconfig index 3c399b4c5..ba796d402 100644 --- a/.flowconfig +++ b/.flowconfig @@ -4,6 +4,7 @@ .*/shared/.* [ignore] +.*/node_modules/tiny-cookie/flow/.* .*/node_modules/styled-components/.* .*/node_modules/polished/.* .*/node_modules/react-side-effect/.* @@ -23,12 +24,10 @@ emoji=true module.system.node.resolve_dirname=node_modules module.system.node.resolve_dirname=app -module.name_mapper='^\(.*\)\.s?css$' -> 'empty/object' module.name_mapper='^\(.*\)\.md$' -> 'empty/object' module.name_mapper='^shared\/\(.*\)$' -> '/shared/\1' module.file_ext=.js -module.file_ext=.scss module.file_ext=.md module.file_ext=.json diff --git a/.githooks/pre-commit/flow.sh b/.githooks/pre-commit/flow.sh index 1cb551e61..4d946a70d 100644 --- a/.githooks/pre-commit/flow.sh +++ b/.githooks/pre-commit/flow.sh @@ -1 +1 @@ -yarn lint:flow \ No newline at end of file +yarn flow \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..30300b160 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "javascript.validate.enable": false +} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index bbb229434..5dc4ecba4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:8.11 +FROM node:12-alpine ENV PATH /opt/outline/node_modules/.bin:/opt/node_modules/.bin:$PATH ENV NODE_PATH /opt/outline/node_modules:/opt/node_modules @@ -7,7 +7,10 @@ RUN mkdir -p $APP_PATH WORKDIR $APP_PATH COPY . $APP_PATH -RUN yarn + +RUN yarn install --pure-lockfile RUN cp -r /opt/outline/node_modules /opt/node_modules CMD yarn build && yarn start + +EXPOSE 3000 diff --git a/Makefile b/Makefile index 6b4bb0bab..c770854a0 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ up: docker-compose up -d redis postgres s3 - docker-compose run --rm outline bash -c "yarn && yarn sequelize db:migrate" + docker-compose run --rm outline /bin/sh -c "yarn && yarn sequelize db:migrate" docker-compose up outline build: diff --git a/README.md b/README.md index 9ae67aab2..1df892b16 100644 --- a/README.md +++ b/README.md @@ -21,26 +21,63 @@ If you'd like to run your own copy of Outline or contribute to development then Outline requires the following dependencies: +- Node.js >= 12 - Postgres >=9.5 - Redis -- Slack or Google developer application for OAuth +- AWS S3 storage bucket for media and other attachments +- Slack or Google developer application for authentication + + +### Development In development you can quickly get an environment running using Docker by following these steps: -1. Install [Docker for Desktop](https://www.docker.com) if you don't already have it. +1. Clone this repo +1. Install [Docker for Desktop](https://www.docker.com) if you don't already have it 1. Register a Slack app at https://api.slack.com/apps -1. Copy the file `.env.sample` to `.env` and fill out the Slack keys, everything - else should work well for development. -1. Run `make up`. This will download dependencies, build and launch a development version of Outline. +1. Copy the file `.env.sample` to `.env` +1. Fill out the following fields: + 1. `SECRET_KEY` (follow instructions in the comments of `.env`) + 1. `SLACK_KEY` (this is called "Client ID" in Slack admin) + 1. `SLACK_SECRET` +1. Add `http://localhost:3000/auth/slack.callback` as an Oauth callback URL in Slack App settings +1. Run `make up`. This will download dependencies, build and launch a development version of Outline + + +### Production + +For a self-hosted production installation there is more flexibility, but these are the suggested steps: + +1. Clone this repo and install dependencies with `yarn` or `npm install` + + > Requires [Node.js, npm](https://nodejs.org/) and [yarn](https://yarnpkg.com) installed + +1. Build the web app with `yarn build:webpack` or `npm run build:webpack` +1. Copy the file `.env.sample` to `.env` and fill out at least the essential fields: + 1. `SECRET_KEY` (follow instructions in the comments of `.env`) + 1. `SLACK_KEY` (this is called "Client ID" in Slack admin) + 1. `SLACK_SECRET` + 1. `DATABASE_URL` (run your own local copy of Postgres, or use a cloud service) + 1. `REDIS_URL` (run your own local copy of Redis, or use a cloud service) + 1. `URL` (the public facing URL of your installation) + 1. `AWS_` (all of the keys beginning with AWS) +1. Migrate database schema with `yarn sequelize:migrate` or `npm run sequelize:migrate ` +1. Start the service with any daemon tools you prefer. Take PM2 for example, `NODE_ENV=production pm2 start index.js --name outline ` +1. Visit http://you_server_ip:3000 and you should be able to see Outline page + + > Port number can be changed in the `.env` file + +1. (Optional) You can add an `nginx` reverse proxy to serve your instance of Outline for a clean URL without the port number, support SSL, etc. + ## Development ### Server -To enable debugging statements, set the following env vars: +Outline uses [debug](https://www.npmjs.com/package/debug). To enable debugging output, the following categories are available: ``` -DEBUG=sql,cache,presenters,events +DEBUG=sql,cache,presenters,events,logistics,emails,mailer ``` ## Migrations @@ -48,7 +85,7 @@ DEBUG=sql,cache,presenters,events Sequelize is used to create and run migrations, for example: ``` -yarn sequelize migration:create +yarn sequelize migration:generate --name my-migration yarn sequelize db:migrate ``` @@ -60,7 +97,7 @@ yarn sequelize db:migrate --env test ## Structure -Outline is composed of separate backend and frontend application which are both driven by the same Node process. As both are written in Javascript, they share some code but are mostly separate. We utilize latest language features, including `async`/`await`, and [Flow](https://flow.org/) typing. Prettier and ESLint are ran as pre-commit hooks. +Outline is composed of separate backend and frontend application which are both driven by the same Node process. As both are written in Javascript, they share some code but are mostly separate. We utilize the latest language features, including `async`/`await`, and [Flow](https://flow.org/) typing. Prettier and ESLint are enforced by CI. ### Frontend @@ -80,15 +117,19 @@ The editor itself is built ontop of [Slate](https://github.com/ianstormtaylor/sl Backend is driven by [Koa](http://koajs.com/) (API, web server), [Sequelize](http://docs.sequelizejs.com/) (database) and React for public pages and emails. - `server/api` - API endpoints +- `server/commands` - Domain logic, currently being refactored from /models - `server/emails` - React rendered email templates -- `server/models` - Database models (Sequelize) -- `server/pages` - Server-side rendered public pages (React) +- `server/models` - Database models +- `server/pages` - Server-side rendered public pages +- `server/policies` - Authorization logic - `server/presenters` - API responses for database models +- `server/test` - Test helps and support +- `server/utils` - Utility methods - `shared` - Code shared between frontend and backend applications ## Tests -We aim to have sufficient test coverage for critical parts of the application and aren't aiming for 100% unit test coverage. All API endpoints and anything authentication related should be thoroughly tested, and it's generally good to add tests for backend features and code. +We aim to have sufficient test coverage for critical parts of the application and aren't aiming for 100% unit test coverage. All API endpoints and anything authentication related should be thoroughly tested. To add new tests, write your tests with [Jest](https://facebook.github.io/jest/) and add a file with `.test.js` extension next to the tested code. @@ -105,7 +146,7 @@ yarn test:app ## Contributing -Outline is still built and maintained by a small team – we'd love your help to fix bugs and add features! +Outline is built and maintained by a small team – we'd love your help to fix bugs and add features! However, before working on a pull request please let the core team know by creating or commenting in an issue on [GitHub](https://www.github.com/outline/outline/issues), and we'd also love to hear from you in our [Spectrum community](https://spectrum.chat/outline). This way we can ensure that an approach is agreed on before code is written and will hopefully help to get your contributions integrated faster! Take a look at our [roadmap](https://www.getoutline.com/share/3e6cb2b5-d68b-4ad8-8900-062476820311). @@ -119,4 +160,4 @@ If you’re looking for ways to get started, here's a list of ways to help us im ## License -Outline is [BSD licensed](/blob/master/LICENSE). +Outline is [BSD licensed](https://github.com/outline/outline/blob/master/LICENSE). diff --git a/__mocks__/styleMock.js b/__mocks__/styleMock.js deleted file mode 100644 index 9775450fb..000000000 --- a/__mocks__/styleMock.js +++ /dev/null @@ -1,2 +0,0 @@ -import idObj from 'identity-obj-proxy'; -export default idObj; diff --git a/app.json b/app.json new file mode 100644 index 000000000..ecd8dd5f7 --- /dev/null +++ b/app.json @@ -0,0 +1,147 @@ +{ + "name": "Outline", + "description": "Open source wiki and knowledge base for growing teams", + "website": "https://www.getoutline.com/", + "repository": "https://github.com/outline/outline", + "keywords": [ + "wiki", + "team", + "node", + "markdown", + "slack" + ], + "success_url": "/", + "formation": { + "web": { + "quantity": 1, + "size": "Hobby" + } + }, + "image": "heroku/node", + "addons": [ + { + "plan": "heroku-redis" + }, + { + "plan": "heroku-postgresql" + } + ], + "scripts": { + "postdeploy": "yarn sequelize db:migrate" + }, + "env": { + "SECRET_KEY": { + "description": "A secret key", + "generator": "secret", + "required": true + }, + "DEPLOYMENT": { + "description": "Should be 'self' for self hosted installations, turns off things like pricing pages", + "value": "self", + "required": true + }, + "ENABLE_UPDATES": { + "value": "true", + "required": true + }, + "SUBDOMAINS_ENABLED": { + "value": "false", + "required": true, + "description": "Allows each team to have a different subdomain. Not recommend when self hosting" + }, + "WEBSOCKETS_ENABLED": { + "value": "true", + "required": true, + "description": "Allow realtime data to be pushed to clients over websockets" + }, + "URL": { + "description": "https://{your app name}.herokuapp.com", + "required": true + }, + "GOOGLE_CLIENT_ID": { + "description": "See https://developers.google.com/identity/protocols/OAuth2 to create a new Google OAuth client. You must configure at least one of Slack or Google to control login.", + "required": false + }, + "GOOGLE_CLIENT_SECRET": { + "description": "", + "required": false + }, + "GOOGLE_ALLOWED_DOMAINS": { + "description": "Comma separated list of domains to be allowed (optional). If not set, all Google apps domains are allowed by default", + "required": false + }, + "SLACK_KEY": { + "description": "See https://api.slack.com/apps to create a new Slack app. You must configure at least one of Slack or Google to control login.", + "required": false + }, + "SLACK_SECRET": { + "description": "Your Slack client secret - d2dc414f9953226bad0a356cXXXXYYYY", + "required": false + }, + "SLACK_VERIFICATION_TOKEN": { + "description": "Your Slack verification token - PLxk6OlXXXXXVj3YYYY", + "required": false + }, + "SLACK_APP_ID": { + "description": "A0XXXXXXXXX", + "required": false + }, + "AWS_ACCESS_KEY_ID": { + "description": "Needed to save file uploads. Optional for dev / testing.", + "required": false + }, + "AWS_SECRET_ACCESS_KEY": { + "description": "", + "required": false + }, + "AWS_S3_UPLOAD_BUCKET_NAME": { + "description": "yourbucket.example.com", + "required": false + }, + "AWS_S3_UPLOAD_BUCKET_URL": { + "description": "Live web link to your bucket. For CNAMEs, https://yourbucket.example.com", + "required": false + }, + "AWS_S3_UPLOAD_MAX_SIZE": { + "description": "Maximum file upload size in bytes", + "value": "26214400", + "required": false + }, + "SMTP_HOST": { + "description": "smtp.example.com (optional)", + "required": false + }, + "SMTP_PORT": { + "description": "1234 (optional)", + "required": false + }, + "SMTP_USERNAME": { + "description": "me@example.com (optional)", + "required": false + }, + "SMTP_PASSWORD": { + "description": "(optional)", + "required": false + }, + "SMTP_FROM_EMAIL": { + "description": "wiki@example.com (optional)", + "required": false + }, + "SMTP_REPLY_EMAIL": { + "description": "wikireply@example.com (optional)", + "required": false + }, + "GOOGLE_ANALYTICS_ID": { + "description": "UA-xxxx (optional)", + "required": false + }, + "BUGSNAG_KEY": { + "description": "An API key for bugsnag if you wish to collect error reporting (optional)", + "required": false + }, + "GITHUB_ACCESS_TOKEN": { + "description": "An API token for GitHub, optional for self hosted (optional)", + "required": false + } + } +} diff --git a/app/components/Actions.js b/app/components/Actions.js index 4602050a4..f2c57f45b 100644 --- a/app/components/Actions.js +++ b/app/components/Actions.js @@ -19,8 +19,8 @@ export const Action = styled(Flex)` export const Separator = styled.div` margin-left: 12px; width: 1px; - height: 20px; - background: ${props => props.theme.slateLight}; + height: 28px; + background: ${props => props.theme.divider}; `; const Actions = styled(Flex)` @@ -29,7 +29,8 @@ const Actions = styled(Flex)` right: 0; left: 0; border-radius: 3px; - background: rgba(255, 255, 255, 0.9); + background: ${props => props.theme.background}; + transition: ${props => props.theme.backgroundTransition}; padding: 12px; -webkit-backdrop-filter: blur(20px); diff --git a/app/components/Alert.js b/app/components/Alert.js index eb69a09e1..fc5f4c91b 100644 --- a/app/components/Alert.js +++ b/app/components/Alert.js @@ -26,7 +26,7 @@ class Alert extends React.Component { const Container = styled(Flex)` height: $headerHeight; - color: #ffffff; + color: ${props => props.theme.white}; font-size: 14px; line-height: 1; diff --git a/app/components/Analytics.js b/app/components/Analytics.js index 55d8facd5..ebcbb7aab 100644 --- a/app/components/Analytics.js +++ b/app/components/Analytics.js @@ -2,7 +2,11 @@ /* global ga */ import * as React from 'react'; -export default class Analytics extends React.Component<*> { +type Props = { + children?: React.Node, +}; + +export default class Analytics extends React.Component { componentDidMount() { if (!process.env.GOOGLE_ANALYTICS_ID) return; diff --git a/app/components/Authenticated.js b/app/components/Authenticated.js index 29e43e574..14f53c3eb 100644 --- a/app/components/Authenticated.js +++ b/app/components/Authenticated.js @@ -34,7 +34,7 @@ const Authenticated = observer(({ auth, children }: Props) => { return children; } - auth.logout(); + auth.logout(true); return null; }); diff --git a/app/components/Avatar/Avatar.js b/app/components/Avatar/Avatar.js index ee3db84b8..035cbada8 100644 --- a/app/components/Avatar/Avatar.js +++ b/app/components/Avatar/Avatar.js @@ -39,7 +39,7 @@ const CircleImg = styled.img` width: ${props => props.size}px; height: ${props => props.size}px; border-radius: 50%; - border: 2px solid ${props => props.theme.white}; + border: 2px solid ${props => props.theme.background}; flex-shrink: 0; `; diff --git a/app/components/Badge.js b/app/components/Badge.js new file mode 100644 index 000000000..d3d6f351a --- /dev/null +++ b/app/components/Badge.js @@ -0,0 +1,17 @@ +// @flow +import styled from 'styled-components'; + +const Badge = styled.span` + margin-left: 10px; + padding: 2px 6px 3px; + background-color: ${({ admin, theme }) => + admin ? theme.primary : theme.smokeDark}; + color: ${({ admin, theme }) => (admin ? theme.white : theme.text)}; + border-radius: 2px; + font-size: 11px; + font-weight: 500; + text-transform: uppercase; + user-select: none; +`; + +export default Badge; diff --git a/app/components/Button.js b/app/components/Button.js index 8d102fd3c..a7daa2ee5 100644 --- a/app/components/Button.js +++ b/app/components/Button.js @@ -2,61 +2,94 @@ import * as React from 'react'; import styled from 'styled-components'; import { darken, lighten } from 'polished'; +import { ExpandedIcon } from 'outline-icons'; const RealButton = styled.button` display: inline-block; margin: 0; padding: 0; border: 0; - background: ${props => props.theme.blackLight}; - color: ${props => props.theme.white}; + background: ${props => props.theme.buttonBackground}; + color: ${props => props.theme.buttonText}; box-shadow: rgba(0, 0, 0, 0.2) 0px 1px 2px; border-radius: 4px; - font-size: 12px; + font-size: 14px; font-weight: 500; - height: ${props => (props.small ? 24 : 36)}px; + height: 32px; text-decoration: none; - text-transform: uppercase; flex-shrink: 0; outline: none; cursor: pointer; user-select: none; + svg { + fill: ${props => props.theme.buttonText}; + } + &::-moz-focus-inner { padding: 0; border: 0; } &:hover { - background: ${props => darken(0.05, props.theme.blackLight)}; + background: ${props => darken(0.05, props.theme.buttonBackground)}; + } + + &:focus { + transition-duration: 0.05s; + box-shadow: ${props => lighten(0.4, props.theme.buttonBackground)} 0px 0px + 0px 3px; + outline: none; } &:disabled { cursor: default; pointer-events: none; - color: ${props => lighten(0.2, props.theme.blackLight)}; + color: ${props => props.theme.white50}; } ${props => props.neutral && ` - background: ${props.theme.white}; - color: ${props.theme.text}; + background: ${props.theme.buttonNeutralBackground}; + color: ${props.theme.buttonNeutralText}; box-shadow: rgba(0, 0, 0, 0.07) 0px 1px 2px; - border: 1px solid ${props.theme.slateLight}; + border: 1px solid ${darken(0.1, props.theme.buttonNeutralBackground)}; + + svg { + fill: ${props.theme.buttonNeutralText}; + } &:hover { - background: ${darken(0.05, props.theme.white)}; - border: 1px solid ${darken(0.05, props.theme.slateLight)}; + background: ${darken(0.05, props.theme.buttonNeutralBackground)}; + border: 1px solid ${darken(0.15, props.theme.buttonNeutralBackground)}; + } + + &:focus { + transition-duration: 0.05s; + border: 1px solid ${lighten(0.4, props.theme.buttonBackground)}; + box-shadow: ${lighten(0.4, props.theme.buttonBackground)} 0px 0px + 0px 2px; + } + + &:disabled { + color: ${props.theme.textTertiary}; } `} ${props => props.danger && ` - background: ${props.theme.danger}; + background: ${props.theme.danger}; + color: ${props.theme.white}; &:hover { background: ${darken(0.05, props.theme.danger)}; } + + &:focus { + transition-duration: 0.05s; + box-shadow: ${lighten(0.4, props.theme.danger)} 0px 0px + 0px 3px; + } `}; `; @@ -68,16 +101,15 @@ const Label = styled.span` ${props => props.hasIcon && 'padding-left: 4px;'}; `; -const Inner = styled.span` - padding: 0 ${props => (props.small ? 8 : 12)}px; +export const Inner = styled.span` display: flex; - line-height: ${props => (props.small ? 24 : 28)}px; + padding: 0 8px; + padding-right: ${props => (props.disclosure ? 2 : 8)}px; + line-height: ${props => (props.hasIcon ? 24 : 32)}px; justify-content: center; align-items: center; - ${props => - props.hasIcon && - (props.small ? 'padding-left: 6px;' : 'padding-left: 8px;')}; + ${props => props.hasIcon && 'padding-left: 4px;'}; `; export type Props = { @@ -86,26 +118,34 @@ export type Props = { icon?: React.Node, className?: string, children?: React.Node, - small?: boolean, + innerRef?: React.ElementRef, + disclosure?: boolean, }; -export default function Button({ +function Button({ type = 'text', icon, children, value, - small, + disclosure, + innerRef, ...rest }: Props) { const hasText = children !== undefined || value !== undefined; const hasIcon = icon !== undefined; return ( - - + + {hasIcon && icon} {hasText && } + {disclosure && } ); } + +// $FlowFixMe - need to upgrade to get forwardRef +export default React.forwardRef((props, ref) => ( +