diff --git a/.editorconfig b/.editorconfig deleted file mode 100644 index 4c92222..0000000 --- a/.editorconfig +++ /dev/null @@ -1,22 +0,0 @@ -# top-most EditorConfig file -root = true - -# Unix-style newlines with a newline ending every file -[*] -end_of_line = lf -insert_final_newline = true - -# Matches multiple files with brace expansion notation -# Set default charset -[*.{js}] -charset = utf-8 - -# 4 space indentation -[*.js] -indent_style = tab -indent_size = 4 - -# Matches the exact files either package.json or .travis.yml -[{package.json,serverless.yml}] -indent_style = tab -indent_size = 4 diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 7e67d39..0000000 --- a/.gitignore +++ /dev/null @@ -1,28 +0,0 @@ -.DS_Store -*.sublime-project -*.sublime-workspace -*.log -.serverless -v8-compile-cache-* -jest/* -coverage -testProjects/*/package-lock.json -testProjects/*/yarn.lock -.serverlessUnzipped -node_modules -.vscode/ -.eslintcache -dist -.idea -build/ -.env* -.cache* -.serverless -.serverless_nextjs -.serverless_plugins -env.js -tmp -package-lock.json -yarn.lock -test -logs/ diff --git a/README.md b/README.md deleted file mode 100644 index 0298f14..0000000 --- a/README.md +++ /dev/null @@ -1,112 +0,0 @@ -[](https://www.serverless-fullstack-app.com) - -A complete, serverless, full-stack application built on AWS Lambda, AWS HTTP API, Express.js, React and DynamoDB. - -#### Live Demo: [https://www.serverless-fullstack-app.com](https://www.serverless-fullstack-app.com) - -## Quick Start - -Install the latest version of the Serverless Framework: - - -``` -npm i -g serverless -``` - -Then, initialize the `fullstack-app` template: - -``` -serverless init fullstack-app -cd fullstack-app -``` - -Then, add your AWS credentials in the `.env` file in the root directory, like this: - -```text -AWS_ACCESS_KEY_ID=JAFJ89109JASFKLJASF -AWS_SECRET_ACCESS_KEY=AJ91J9A0SFA0S9FSKAFLASJFLJ - -# This signs you JWT tokens used for auth. Enter a random string in here that's ~40 characters in length. -tokenSecret=yourSecretKey - -# Only add this if you want a custom domain. Purchase it on AWS Route53 in your target AWS account first. -domain=serverless-fullstack-app.com -``` - -In the root folder of the project, run `serverless deploy` - -Lastly, you will need to add your API domain manually to your React application in `./site/src/config.js`, so that you interact with your serverless Express.js back-end. You can find the your API url by going into `./api` and running `serverless info` and copying the `url:` value. It should look something like this `https://9jfalnal19.execute-api.us-east-1.amazonaws.com` or it will look like the custom domain you have set. - -**Note:** Upon the first deployment of your website, it will take a 2-3 minutes for the Cloudfront (CDN) URL to work. Until then, you can access it via the `bucketUrl`. - -After initial deployment, we recommend deploying only the parts you are changing, not the entire thing together (why risk deploying your database with a code change?). To do this, `cd` into a part of the application and run `serverless deploy`. - -When working on the `./api` we highly recommend using `serverless dev`. This command watches your code, auto-deploys it, and streams `console.log()` statements and errors directly to your CLI in real-time! - -If you want to add custom domains to your landing pages and API, either hardcode them in your `serverless.yml` or reference them as environment variables in `serverless.yml`, like this: - -```yaml -inputs: - domain: ${env:domain} -``` - -```text -domain=serverless-fullstack-app.com -``` - -Support for stages is built in. - -You can deploy everything or individual components to different stages via the `--stage` flag, like this: - -`serverless deploy --stage prod` - -Or, you can hardcode the stage in `serverless.yml` (not recommended): - -```yaml -app: fullstack -component: express@0.0.20 -name: fullstack-api -stage: prod # Put the stage in here -``` - -Lastly, you can add separate environment variables for each stage using `.env` files with the stage name in them: - -```bash -.env # Any stage -.env.dev # "dev" stage only -.env.prod # "prod" stage only -``` - -Then simply reference those environment variables using Serverless Variables in your YAML: - -```yaml -app: fullstack -component: express@0.0.20 -name: fullstack-api - -inputs: - domain: api.${env:domain} -``` - -And deploy! - -`serverless deploy --stage prod` - -Enjoy! This is a work in progress and we will continue to add funcitonality to this. - -## Other Resources - -For more details on each part of this fullstack application, check out these resources: - -* [Serverless Components](https://github.com/serverless/components) -* [Serverless Express](https://github.com/serverless-components/express) -* [Serverless Website](https://github.com/serverless-components/website) -* [Serverless AWS DynamoDB](https://github.com/serverless-components/aws-dynamodb) -* [Serverless AWS IAM Role](https://github.com/serverless-components/aws-iam-role) - -## Guides - -### How To Debug CORS Errors - -If you are running into CORS errors, see our guide on debugging them [within the Express Component's repo](https://github.com/serverless-components/express/blob/master/README.md#how-to-debug-cors-errors) diff --git a/api/.eslintrc.json b/api/.eslintrc.json deleted file mode 100644 index 611096a..0000000 --- a/api/.eslintrc.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "extends": ["eslint:recommended", "prettier"], - "env": { - "node": true, - "browser": false, - "es6": true, - "mocha": true - }, - "parserOptions": { - "ecmaVersion": 2018 - } -} diff --git a/api/.prettierignore b/api/.prettierignore deleted file mode 100644 index cce0279..0000000 --- a/api/.prettierignore +++ /dev/null @@ -1,2 +0,0 @@ -package.json -package-lock.json diff --git a/api/app.js b/api/app.js deleted file mode 100644 index 626ee77..0000000 --- a/api/app.js +++ /dev/null @@ -1,105 +0,0 @@ -const express = require(`express`); -const pino = require(`pino`); -const pinoHttp = require("pino-http"); -const app = express(); -const passport = require(`passport`); -const { users } = require(`./controllers`); - -/** - * logging - */ -let logger = pino(); -if (process.env.name === `test`) { - logger = pino(pino.destination({ dest: `../logs/api.json`, sync: false })); -} -app.use( - pinoHttp({ - logger: logger, - }) -); - -/** - * Configure Passport - */ - -try { - require(`./config/passport`)(passport); -} catch (error) { - console.log(error); -} - -/** - * Configure Express.js Middleware - */ - -// Enable CORS -app.use(function (req, res, next) { - res.header(`Access-Control-Allow-Origin`, `*`); - res.header(`Access-Control-Allow-Methods`, `*`); - res.header(`Access-Control-Allow-Headers`, `*`); - res.header(`x-powered-by`, `serverless-express`); - next(); -}); - -// Initialize Passport and restore authentication state, if any, from the session -app.use(passport.initialize()); -app.use(passport.session()); - -// Enable JSON use -app.use(express.json()); - -// Since Express doesn't support error handling of promises out of the box, -// this handler enables that -const asyncHandler = (fn) => (req, res, next) => { - return Promise.resolve(fn(req, res, next)).catch(next); -}; - -/** - * Routes - Public - */ - -app.options(`*`, (req, res) => { - res.status(200).send(); -}); - -app.post(`/users/register`, asyncHandler(users.register)); - -app.post(`/users/login`, asyncHandler(users.login)); - -app.get(`/test/`, (req, res) => { - res.status(200).send(`Request received`); -}); - -/** - * Routes - Protected - */ - -app.post( - `/user`, - passport.authenticate(`jwt`, { session: false }), - asyncHandler(users.get) -); - -/** - * Routes - Catch-All - */ - -app.get(`/*`, (req, res) => { - res.status(404).send(`Route not found`); -}); - -/** - * Error Handler - */ -app.use(function (err, req, res, next) { - if (res.headersSent) { - return next(err); - } - req.log.error(err); - res.status(err.code || 500); - res.json({ - error: err.message || `Internal Server Error - "${err.message}"`, - }); -}); - -module.exports = app; diff --git a/api/config/passport.js b/api/config/passport.js deleted file mode 100644 index a68a18e..0000000 --- a/api/config/passport.js +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Config: Passport.js - */ - -const StrategyJWT = require(`passport-jwt`).Strategy; -const ExtractJWT = require(`passport-jwt`).ExtractJwt; -const userUtils = require(`../controllers/users/utils`); -const db = require(`../db`); - -module.exports = (passport) => { - const options = {}; - options.jwtFromRequest = ExtractJWT.fromAuthHeaderAsBearerToken(); - options.secretOrKey = process.env.tokenSecret; - - passport.use( - new StrategyJWT(options, async (jwtPayload, done) => { - let user; - try { - user = await db.getById(`user`, jwtPayload.id); - } catch (error) { - console.log(error); - return done(error, null); - } - - if (!user) { - return done(null, false); - } - return done(null, userUtils.convertToPublicFormat(user)); - }) - ); -}; diff --git a/api/controllers/base/index.js b/api/controllers/base/index.js deleted file mode 100644 index a8235f6..0000000 --- a/api/controllers/base/index.js +++ /dev/null @@ -1,61 +0,0 @@ -/* - * Controller base actions - * - * This module contains base actions that follow - * a template for CRUD operations on a DB and (optionally) - * do custom things afterward to return a response. - */ - -const validation = require(`../../validation`); -const db = require(`../../db`); - -const createAction = async (req, res, config) => { - validation.validateOrThrow(config.validationSchema, req.body); - - const key = config.getObjectKey(); - let existingObject = await db.getByKey(key); - if (existingObject) { - throw customError( - `An object with key "${key}" already exists in the database.`, - 400 - ); - } - - const dbObject = config.buildDbObject(req.body); - await db.put(config.dbKind, dbObject); - const createdObject = await db.getByKey(key); - - const responseBody = config.buildResponseBody(createdObject); - res.json(responseBody); -}; - -const getByKeyAction = async (req, res, config) => { - validation.validateOrThrow(config.validationSchema, req.body); - const key = config.getObjectKey(); - let existingObject = await db.getByKey(key); - - if (!existingObject) { - return res - .status(404) - .send({ error: config.notFoundMessage }); - } - - const responseBody = config.buildResponseBody(existingObject); - res.json(responseBody); -}; - -/** - * Build a custom error - * @param {string} message Error message - * @param {number} code Error code, should be HTTP status code - */ -const customError = (message = "An error occurred.", code = 500) => { - const error = new Error(message); - error.code = code; - return error; -}; - -module.exports = { - createAction, - getByKeyAction, -}; diff --git a/api/controllers/index.js b/api/controllers/index.js deleted file mode 100644 index ce3f1b6..0000000 --- a/api/controllers/index.js +++ /dev/null @@ -1,5 +0,0 @@ -const users = require(`./users`); - -module.exports = { - users, -}; diff --git a/api/controllers/users/index.js b/api/controllers/users/index.js deleted file mode 100644 index fadd5c1..0000000 --- a/api/controllers/users/index.js +++ /dev/null @@ -1,77 +0,0 @@ -/** - * Controllers: Users - */ - -const schemas = require(`./schemas`); -const base = require(`../base`); -const userUtils = require(`./utils`); - -/** - * Save - * @param {*} req - * @param {*} res - */ -const register = async (req, res) => { - const config = { - validationSchema: schemas.create, - getObjectKey: () => req.body.email, - buildDbObject: () => { - return { - hk: req.body.email, - password: userUtils.hashPassword(req.body.password) - }; - }, - dbKind: `user`, - buildResponseBody: createdUser => { - return { - message: `Authentication successful`, - token: userUtils.getToken(createdUser), - }; - } - }; - await base.createAction(req, res, config); -}; - -/** - * Sign a user in - * @param {*} req - * @param {*} res - */ -const login = async (req, res) => { - const config = { - validationSchema: schemas.login, - getObjectKey: () => req.body.email, - notFoundMessage: `Authentication failed. User not found.`, - buildResponseBody: user => { - const isCorrect = userUtils.comparePassword(req.body.password, user.password); - if (!isCorrect) { - return res - .status(401) - .send({ error: `Authentication failed. Wrong password.` }); - } - - return { - message: `Authentication successful`, - token: userUtils.getToken(user), - }; - } - }; - - await base.getByKeyAction(req, res, config); -}; - -/** - * Get a user - * @param {*} req - * @param {*} res - */ -const get = async (req, res) => { - const user = userUtils.convertToPublicFormat(req.user); - res.json({ user }); -}; - -module.exports = { - register, - login, - get, -}; diff --git a/api/controllers/users/schemas.js b/api/controllers/users/schemas.js deleted file mode 100644 index 8900cf2..0000000 --- a/api/controllers/users/schemas.js +++ /dev/null @@ -1,36 +0,0 @@ -const commonSchemas = require(`../../validation/commonSchemas`); - -const login = { - $schema: "http://json-schema.org/draft-07/schema#", - title: "Login User", - description: "An object to log-in a new user", - type: "object", - properties: { - email: commonSchemas.email, - password: { - type: "string", - }, - }, - required: ["email", "password"], -}; - -const create = { - $schema: "http://json-schema.org/draft-07/schema#", - title: "Create User", - description: "An object to create a new user", - type: "object", - properties: { - email: commonSchemas.email, - password: { - type: "string", - minLength: 10, - maxLength: 50, - }, - }, - required: ["email", "password"], -}; - -module.exports = { - login, - create -}; diff --git a/api/controllers/users/utils.js b/api/controllers/users/utils.js deleted file mode 100644 index 11b29bc..0000000 --- a/api/controllers/users/utils.js +++ /dev/null @@ -1,59 +0,0 @@ -/* - * User utilities - */ - -const bcrypt = require(`bcryptjs`); -const jwt = require(`jsonwebtoken`); - -const getToken = (user) => { - return jwt.sign( - convertToPublicFormat(user), - process.env.tokenSecret, - { - expiresIn: 604800, // 1 week - } - ); -}; - -const convertToPublicFormat = (user = {}) => { - user.email = user.email || user.hk || null; - user.id = user.id || user.sk2 || null; - if (user.hk) { - delete user.hk; - } - if (user.sk) { - delete user.sk; - } - if (user.sk2) { - delete user.sk2; - } - if (user.password) { - delete user.password; - } - return user; -}; - -/** - * Hash password - * @param {string} password Password to hash - */ -const hashPassword = (password) => { - const salt = bcrypt.genSaltSync(10); - return bcrypt.hashSync(password, salt); -}; - -/** - * Compare password - * @param {string} candidatePassword Hashed password supplied by user - * @param {string} trustedPassword Hashed password on record for user - */ -const comparePassword = (candidatePassword, trustedPassword) => { - return bcrypt.compareSync(candidatePassword, trustedPassword); -}; - -module.exports = { - getToken, - convertToPublicFormat, - hashPassword, - comparePassword, -} diff --git a/api/db/index.js b/api/db/index.js deleted file mode 100644 index f639496..0000000 --- a/api/db/index.js +++ /dev/null @@ -1,61 +0,0 @@ -const AWS = require(`aws-sdk`); -const shortid = require(`shortid`); -const commonSchemas = require(`../validation/commonSchemas`); -const validation = require(`../validation`); - -const dynamodb = new AWS.DynamoDB.DocumentClient({ - region: process.env.AWS_REGION, -}); - -const defaultParams = { - TableName: process.env.db, -}; - -const put = async (kind, item) => { - const params = { - ...defaultParams, - Item: { - ...item, - sk: kind, - sk2: shortid.generate(), - createdAt: Date.now(), - updatedAt: Date.now(), - }, - }; - - await dynamodb.put(params).promise(); -}; - -const getByKey = async (key) => { - const params = { - ...defaultParams, - KeyConditionExpression: `hk = :hk`, - ExpressionAttributeValues: { ":hk": key }, - }; - - const obj = await dynamodb.query(params).promise(); - return obj.Items && obj.Items[0] ? obj.Items[0] : null; -}; - -const getById = async (kind, id) => { - validation.validateOrThrow(commonSchemas.id, id); - - const params = { - ...defaultParams, - IndexName: process.env.dbIndex1, - KeyConditionExpression: `sk2 = :sk2 and sk = :sk`, - ExpressionAttributeValues: { - ":sk2": id, - ":sk": kind, - }, - }; - - const obj = await dynamodb.query(params).promise(); - return obj.Items && obj.Items[0] ? obj.Items[0] : null; -}; - -module.exports = { - put, - getByKey, - getById, -}; diff --git a/api/package.json b/api/package.json deleted file mode 100644 index dd9cfa4..0000000 --- a/api/package.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "name": "forgetmenot-api", - "version": "1.0.0", - "description": "", - "main": "app.js", - "dependencies": { - "ajv": "^6.12.6", - "aws-sdk": "^2.792.0", - "bcryptjs": "^2.4.3", - "express": "^4.17.1", - "jsonwebtoken": "^8.5.1", - "passport": "^0.4.1", - "passport-jwt": "^4.0.0", - "pino-http": "^5.3.0", - "shortid": "^2.2.15" - }, - "devDependencies": { - "chai": "^4.2.0", - "chai-http": "^4.3.0", - "eslint": "^7.12.1", - "eslint-config-prettier": "^6.15.0", - "faker": "^5.1.0", - "mocha": "^8.2.1", - "prettier": "^2.1.2" - }, - "scripts": { - "pretest": "eslint --ignore-path ../.gitignore .", - "test": "mocha", - "format": "prettier --write \"**/*.{js,json,md,yml}\"" - }, - "author": "", - "license": "ISC" -} diff --git a/api/serverless.yml b/api/serverless.yml deleted file mode 100644 index 18e9d56..0000000 --- a/api/serverless.yml +++ /dev/null @@ -1,18 +0,0 @@ -component: express -name: api - -inputs: - # Express application source code. - src: ./ - # Permissions required for the AWS Lambda function to interact with other resources - roleName: ${output:permissions.name} - # Enable this when you want to set a custom domain. - # domain: api.${env:domain} - # Environment variables - env: - # AWS DynamoDB Table name. Needed for the code to access it. - db: ${output:database.name} - # AWS DynamoDB Table Index name. Needed for the code to access it. - dbIndex1: ${output:database.indexes.gsi1.name} - # A secret token to sign the JWT tokens with. - tokenSecret: ${env:tokenSecret} # Change to secret via environment variable: ${env:tokenSecret} diff --git a/api/validation/commonSchemas.js b/api/validation/commonSchemas.js deleted file mode 100644 index a4230cc..0000000 --- a/api/validation/commonSchemas.js +++ /dev/null @@ -1,10 +0,0 @@ -module.exports = { - "email": { - "type": "string", - "format": "email" - }, - "id": { - "type": "string", - "minLength": 1 - } -} diff --git a/api/validation/index.js b/api/validation/index.js deleted file mode 100644 index 37eeddc..0000000 --- a/api/validation/index.js +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Validation - */ - -const Ajv = require(`ajv`); - -const validateOrThrow = (schema, data) => { - const ajv = new Ajv(); - const valid = ajv.validate(schema, data); - - if (!valid) { - throw validationError(ajv.errors); - } -}; - -/** - * Build a custom validation error - * @param {array[string]} validationErrors Validation errors - */ -const validationError = (validationErrors = []) => { - let error = new Error("Validation error(s)"); - error.validationErrors = validationErrors; - error.code = 400; - return error; -}; - -module.exports = { - validateOrThrow, -}; diff --git a/database/serverless.yml b/database/serverless.yml deleted file mode 100644 index 1313f81..0000000 --- a/database/serverless.yml +++ /dev/null @@ -1,30 +0,0 @@ -component: aws-dynamodb -name: database - -inputs: - name: ${name}-${stage} - region: us-east-1 - # Don't delete the Database Table if "serverless remove" is run - deletionPolicy: retain - # Simple, single-table design - attributeDefinitions: - - AttributeName: hk - AttributeType: S - - AttributeName: sk - AttributeType: S - - AttributeName: sk2 - AttributeType: S - keySchema: - - AttributeName: hk - KeyType: HASH - - AttributeName: sk - KeyType: RANGE - globalSecondaryIndexes: - - IndexName: gsi1 - KeySchema: - - AttributeName: sk2 - KeyType: HASH - - AttributeName: sk - KeyType: RANGE - Projection: - ProjectionType: ALL diff --git a/permissions/serverless.yml b/permissions/serverless.yml deleted file mode 100644 index 19f7b75..0000000 --- a/permissions/serverless.yml +++ /dev/null @@ -1,28 +0,0 @@ -component: aws-iam-role -name: permissions - -inputs: - name: ${name}-${stage} - region: us-east-1 - service: lambda.amazonaws.com - policy: - # AWS Lambda function containing Express Logs and Assume Role access - - Effect: Allow - Action: - - sts:AssumeRole - - logs:CreateLogGroup - - logs:CreateLogStream - - logs:PutLogEvents - Resource: "*" - # AWS DynamoDB Table access - - Effect: Allow - Action: - - dynamodb:DescribeTable - - dynamodb:Query - - dynamodb:GetItem - - dynamodb:PutItem - - dynamodb:UpdateItem - - dynamodb:DeleteItem - Resource: - - ${output:database.arn} - - ${output:database.arn}/index/* diff --git a/serverless.yml b/serverless.yml deleted file mode 100644 index 867daae..0000000 --- a/serverless.yml +++ /dev/null @@ -1,2 +0,0 @@ -app: forget-me-not -org: benjaminramey diff --git a/site/.eslintrc.json b/site/.eslintrc.json deleted file mode 100644 index a134b11..0000000 --- a/site/.eslintrc.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "extends": ["eslint:recommended", "prettier"], - "env": { - "node": false, - "browser": true, - "es6": true - }, - "parserOptions": { - "ecmaVersion": 2018 - } -} diff --git a/site/.gitignore b/site/.gitignore deleted file mode 100644 index f813275..0000000 --- a/site/.gitignore +++ /dev/null @@ -1,69 +0,0 @@ -# Logs -logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* - -# Runtime data -pids -*.pid -*.seed -*.pid.lock - -# Directory for instrumented libs generated by jscoverage/JSCover -lib-cov - -# Coverage directory used by tools like istanbul -coverage - -# nyc test coverage -.nyc_output - -# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) -.grunt - -# Bower dependency directory (https://bower.io/) -bower_components - -# node-waf configuration -.lock-wscript - -# Compiled binary addons (http://nodejs.org/api/addons.html) -build/Release - -# Dependency directories -node_modules/ -jspm_packages/ - -# Typescript v1 declaration files -typings/ - -# Optional npm cache directory -.npm - -# Optional eslint cache -.eslintcache - -# Optional REPL history -.node_repl_history - -# Output of 'npm pack' -*.tgz - -# dotenv environment variable files -.env* - -# gatsby files -.cache/ -public - -# Mac files -.DS_Store - -# Yarn -yarn-error.log -.pnp/ -.pnp.js -# Yarn Integrity file -.yarn-integrity diff --git a/site/.prettierignore b/site/.prettierignore deleted file mode 100644 index 58d06c3..0000000 --- a/site/.prettierignore +++ /dev/null @@ -1,4 +0,0 @@ -.cache -package.json -package-lock.json -public diff --git a/site/LICENSE b/site/LICENSE deleted file mode 100644 index 7e964c1..0000000 --- a/site/LICENSE +++ /dev/null @@ -1,14 +0,0 @@ -The BSD Zero Clause License (0BSD) - -Copyright (c) 2020 Gatsby Inc. - -Permission to use, copy, modify, and/or distribute this software for any -purpose with or without fee is hereby granted. - -THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH -REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY -AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, -INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM -LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR -OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR -PERFORMANCE OF THIS SOFTWARE. diff --git a/site/README.md b/site/README.md deleted file mode 100644 index b40d274..0000000 --- a/site/README.md +++ /dev/null @@ -1,99 +0,0 @@ - -
-
-
-
-
Hello world!
- -