migration from github

This commit is contained in:
gbrochar 2020-11-22 15:45:51 +01:00
parent d226eedb4b
commit 4feff06a2b
56 changed files with 9107 additions and 1 deletions

16
.babelrc Normal file
View File

@ -0,0 +1,16 @@
{
"presets": [
[
"@babel/env", {
useBuiltIns: "usage",
corejs: 3,
}
],
"@babel/typescript",
"@babel/react",
],
"plugins": [
"@babel/proposal-class-properties",
"@babel/proposal-object-rest-spread",
]
}

33
.eslintrc.js Normal file
View File

@ -0,0 +1,33 @@
module.exports = {
'env': {
'browser': true,
'es2021': true,
'node': true,
},
'extends': [
'plugin:react/recommended',
'google',
],
'parser': '@typescript-eslint/parser',
'parserOptions': {
'ecmaFeatures': {
'jsx': true,
},
'ecmaVersion': 12,
'sourceType': 'module',
},
'plugins': [
'react',
'@typescript-eslint',
],
'settings': {
'react': {
'version': 'detect',
},
},
'rules': {
'indent': ['error', "tab"],
'no-tabs': ['error', {'allowIndentationTabs': true}],
'max-len': ['error', {'tabWidth': 4}],
},
};

115
.gitignore vendored Normal file
View File

@ -0,0 +1,115 @@
# vim
*.swp
*.swo
*~
# build and dev deprecated
build
dev
# new build dir
dist
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# 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
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# TypeScript v1 declaration files
typings/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
.env.test
# parcel-bundler cache (https://parceljs.org/)
.cache
# Next.js build output
.next
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and *not* Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port

View File

@ -1,3 +1,79 @@
# curiousroamers
curiousroamers' blog
Ongoing project. A blog I make for a friend.
# Installation
You need a postgresql databased called curiousroamers setup,
You have to specify :
- SESSION_SECRET
- ACCESS_TOKEN_SECRET
- DATABASE_PASSWORD
in the .env file
npm install
npm run build
npm start
If you wanna create an admin you gotta create a new UniqueLink row in the
uniquelink table, Then go to localhost:3000/admin/signup/:uuid (:uuid being the
UniqueLink row's uuid) to create an account.
The admin page is not very pretty at the moment cause it is not done.
I have a project of transforming this website into a simple blog CMS.
# Design curious roamers
## Todo
### Responsiveness
* Pages utilisateurs
* Pages admin
### Pages
* Home (Notre voyage)
* Qui sommes nous ?
* Contact
* Blog
### Contact
* Coordonees
* Formulaire de contact comme wordpress -siteweb
### Mail
* Check port to use (25, 465, 587 or 2525)
* Nodemailer for node
* Postfix for server
* Check MailHog
### Blog
* Article (markdown)
* Voir plus (sans description)
* Plusieurs tag par article
* Formulaire de contact et source du contact
### Tag
* Recherche par tag
* tag cliquables
### Footer
* Que sur la homepage
* A propos
* Nous contacter
* Donation
### Admin
* nomdusite.com/admin
* connection classique
* Ajouter article
* Supprimer article
* Editer article
-----------------------------------------------------
## Done
### Responsiveness
### Pages
### Contact
### Blog
### Tag

View File

@ -0,0 +1,10 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"target": "es5",
"declarationDir": "../dist/types"
},
"include": [
"../src/client/**/*"
]
}

View File

@ -0,0 +1,10 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"target": "es6",
"declarationDir": "../dist/types"
},
"include": [
"../src/server/**/*"
]
}

View File

@ -0,0 +1,31 @@
const path = require('path');
const nodeExternals = require('webpack-node-externals');
const EsLint = require('eslint-webpack-plugin');
module.exports = {
entry: {
main: path.resolve(__dirname, '../src/client/main.tsx'),
admin: path.resolve(__dirname, '../src/client/admin.tsx'),
},
output: {
filename: '[name].bundle.js',
path: path.resolve(__dirname, '../dist/client'),
},
target: 'web',
resolve: {
extensions: ['.ts', '.tsx', '.js', '.json']
},
module: {
rules: [{
test: /\.tsx?$/,
loader: 'babel-loader',
include: path.join(__dirname, '../src/client'),
}],
},
plugins: [
new EsLint({
extensions: ['.ts', '.tsx'],
context: path.join(__dirname, '../src/client'),
}),
],
};

View File

@ -0,0 +1,9 @@
const path = require('path');
const { merge } = require('webpack-merge');
const common = require('./webpack.client.common.js');
module.exports = merge(common, {
devtool: 'eval-cheap-module-source-map',
mode: 'development',
watch: true
});

View File

@ -0,0 +1,14 @@
const path = require('path');
const { merge } = require('webpack-merge');
const common = require('./webpack.client.common.js');
const TerserPlugin = require('terser-webpack-plugin');
module.exports = merge(common, {
mode: 'production',
optimization: {
minimize: true,
minimizer: [new TerserPlugin({
parallel: true
})]
}
});

View File

@ -0,0 +1,29 @@
const path = require('path');
const nodeExternals = require('webpack-node-externals');
const EsLint = require('eslint-webpack-plugin');
module.exports = {
entry: path.resolve(__dirname, '../src/server/main.ts'),
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, '../dist/server'),
},
target: 'node',
externals: [nodeExternals()],
resolve: {
extensions: ['.ts', '.tsx', '.js', '.json']
},
module: {
rules: [{
test: /\.tsx?$/,
loader: 'babel-loader',
include: path.join(__dirname, '../src/server'),
}],
},
plugins: [
new EsLint({
extensions: ['.ts', '.tsx'],
context: path.join(__dirname, '../src/server'),
}),
],
};

View File

@ -0,0 +1,9 @@
const path = require('path');
const { merge } = require('webpack-merge');
const common = require('./webpack.server.common.js');
module.exports = merge(common, {
devtool: 'eval-cheap-module-source-map',
mode: 'development',
watch: true
});

View File

@ -0,0 +1,14 @@
const path = require('path');
const { merge } = require('webpack-merge');
const common = require('./webpack.server.common.js');
const TerserPlugin = require('terser-webpack-plugin');
module.exports = merge(common, {
mode: 'production',
optimization: {
minimize: true,
minimizer: [new TerserPlugin({
parallel: true
})]
}
});

7178
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

87
package.json Normal file
View File

@ -0,0 +1,87 @@
{
"name": "curiousroamers",
"version": "1.0.0",
"description": "curiousroamers' blog",
"scripts": {
"type-check:server": "tsc --pretty --project config/tsconfig.server.json --noEmit",
"type-check:client": "tsc --pretty --project config/tsconfig.client.json --noEmit",
"type-check": "concurrently \"npm:type-check:server\" \"npm:type-check:client\"",
"type-check:server:watch": "tsc --watch --preserveWatchOutput --pretty --project config/tsconfig.server.dev.json --noEmit",
"type-check:client:watch": "tsc --watch --preserveWatchOutput --pretty --project config/tsconfig.client.dev.json --noEmit",
"type-check:watch": "concurrently \"npm:type-check:server:watch\" \"npm:type-check:client:watch\"",
"build:server:types": "tsc --emitDeclarationOnly --project config/tsconfig.server.json",
"build:server:js": "webpack --config config/webpack.server.prod.js",
"build:server": "npm run build:server:types && npm run build:server:js",
"build:client:types": "tsc --emitDeclarationOnly --project config/tsconfig.client.json",
"build:client:js": "webpack --config config/webpack.client.prod.js",
"build:client": "npm run build:client:types && npm run build:client:js",
"build": "rm -rf dist && npm run build:server && npm run build:client",
"dev:server:types": "tsc --emitDeclarationOnly --watch --preserveWatchOutput --pretty --project config/tsconfig.server.json",
"dev:server:js": "webpack --config config/webpack.server.dev.js",
"dev:server": "concurrently \"npm:dev:server:types\" \"npm:dev:server:js\"",
"dev:client:types": "tsc --emitDeclarationOnly --watch --preserveWatchOutput --pretty --project config/tsconfig.client.json",
"dev:client:js": "webpack --config config/webpack.client.dev.js",
"dev:client": "concurrently \"npm:dev:client:types\" \"npm:dev:client:js\"",
"dev:start": "mkdir -p dist/server && touch dist/server/bundle.js && nodemon dist/server/bundle.js",
"dev": "rm -rf dist && concurrently \"npm:dev:server\" \"npm:dev:client\" \"npm:dev:start\"",
"start": "node dist/server/bundle.js"
},
"author": "gbrochar",
"license": "UNLICENSED",
"devDependencies": {
"@babel/core": "^7.12.3",
"@babel/plugin-proposal-class-properties": "^7.12.1",
"@babel/plugin-proposal-object-rest-spread": "^7.12.1",
"@babel/preset-env": "^7.12.1",
"@babel/preset-react": "^7.12.1",
"@babel/preset-typescript": "^7.12.1",
"@babel/runtime-corejs3": "^7.12.5",
"@types/cookie-parser": "^1.4.2",
"@types/dompurify": "^2.0.4",
"@types/express": "^4.17.8",
"@types/express-flash": "0.0.2",
"@types/express-session": "^1.17.2",
"@types/jsonwebtoken": "^8.5.0",
"@types/marked": "^1.1.0",
"@types/method-override": "0.0.31",
"@types/node": "^14.14.6",
"@types/react": "^16.9.55",
"@types/react-dom": "^16.9.9",
"@types/sequelize": "^4.28.9",
"@typescript-eslint/eslint-plugin": "^4.6.1",
"@typescript-eslint/parser": "^4.6.1",
"babel-loader": "^8.1.0",
"concurrently": "^5.3.0",
"core-js": "^3.7.0",
"eslint": "^7.13.0",
"eslint-config-google": "^0.14.0",
"eslint-plugin-react": "^7.21.5",
"eslint-webpack-plugin": "^2.2.0",
"nodemon": "^2.0.6",
"typescript": "^4.0.5",
"webpack": "^5.3.2",
"webpack-cli": "^4.1.0",
"webpack-merge": "^5.3.0",
"webpack-node-externals": "^2.5.2"
},
"dependencies": {
"argon2": "^0.27.0",
"axios": "^0.21.0",
"cookie-parser": "^1.4.5",
"dompurify": "^2.2.2",
"dotenv": "^8.2.0",
"ejs": "^3.1.5",
"express": "^4.17.1",
"express-flash": "0.0.2",
"express-session": "^1.17.1",
"jsdom": "^16.4.0",
"jsonwebtoken": "^8.5.1",
"marked": "^1.2.4",
"method-override": "^3.0.0",
"pg": "^8.5.1",
"react": "^17.0.1",
"react-dom": "^17.0.1",
"sequelize": "^6.3.5",
"slugify": "^1.4.6"
}
}

0
public/css/admin.css Normal file
View File

46
public/css/style.css Normal file
View File

@ -0,0 +1,46 @@
html {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
}
body {
margin: auto;
padding: 0;
width: 100%;
height: 100%;
}
#imagewithdescription {
width: 200px;
height: auto;
}
#smallimage {
height: 200px;
width: auto;
}
#banner {
max-width: 40%;
}
#navbar {
max-width: calc(782px - 32px);
}
.loader {
border: 16px solid #f3f3f3;
border-top: 16px solid #3498db;
border-radius: 50%;
margin: auto;
width: 120px;
height: 120px;
animation: spin 1s linear infinite;
}
@keyframes spin{
0% {transform: rotate(0deg);}
100% {transform: rotate(360deg);}
}

BIN
public/images/cliff.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

View File

@ -0,0 +1,4 @@
[ZoneTransfer]
ZoneId=3
ReferrerUrl=https://garrotyoan1.wordpress.com/
HostUrl=https://garrotyoan1.files.wordpress.com/2020/09/pexels-photo-4885892.jpeg

BIN
public/images/eye.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

View File

@ -0,0 +1,4 @@
[ZoneTransfer]
ZoneId=3
ReferrerUrl=https://garrotyoan1.wordpress.com/
HostUrl=https://garrotyoan1.files.wordpress.com/2020/09/pexels-photo-5425971.jpeg?w=164&h=235

BIN
public/images/map.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

BIN
public/images/urbex.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

View File

@ -0,0 +1,4 @@
[ZoneTransfer]
ZoneId=3
ReferrerUrl=https://garrotyoan1.wordpress.com/
HostUrl=https://garrotyoan1.files.wordpress.com/2020/09/pexels-photo-2512258-1.jpeg?w=344

19
src/client/AdminRoot.tsx Normal file
View File

@ -0,0 +1,19 @@
import React from 'react';
import Link from './Link';
import Button from './Button';
import ArticleTable from './ArticleTable';
/**
* @return {jsx} The root component
*/
export default function AdminRoot() {
return (
<React.Fragment>
<h1>Admin index</h1>
<Link to="/admin/new" text="Create new article" />
<Button to="/api/signout" method="DELETE" text="Signout" />
<h2>List of articles</h2>
<ArticleTable />
</React.Fragment>
);
}

View File

@ -0,0 +1,67 @@
import React, {useState, useEffect} from 'react';
import axios from 'axios';
import Loader from './Loader';
/* eslint-disable */
/**
* @return {jsx} The root component
*/
export default function ArticleTable() {
const [articleTable, setArticleTable] = useState([])
const [clickedOnDelete, setClickedOnDelete] = useState(false);
const [busy, setBusy] = useState(true);
useEffect(() => {
setBusy(true)
axios.get('/api/articletable', {
withCredentials: true,
}).then((articles: any) => {
var $articles: any = [];
articles.data.forEach((article: any) => {
$articles.push(
<tr key={article.id}>
<td>{article.title}</td>
<td>{article.id}</td>
<td>{new Date(article.createdAt).toLocaleDateString()}</td>
<td>
<a className="btn btn-info" href={'/admin/edit' + article.id}>Edit</a>
<button className="btn btn-danger" onClick={() => deleteArticle(article.id)}>Delete</button>
</td>
</tr>);
});
setArticleTable($articles);
setBusy(false);
});
}, [clickedOnDelete]);
function deleteArticle(id: string) {
axios.delete('/admin/' + id, {
withCredentials: true,
}).then(() => {
setClickedOnDelete(!clickedOnDelete);
});
}
return (
<div>
{busy ? (
<Loader />
) : (
<table className="table table-dark">
<thead>
<tr>
<th>Title</th>
<th>Tags</th>
<th>Creation Date</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{articleTable}
</tbody>
</table>
)}
</div>
);
}
/* eslint-enable */

75
src/client/Banner.tsx Normal file
View File

@ -0,0 +1,75 @@
import React from 'react';
import Link from './Link';
interface Link {
to: string;
text: string;
}
interface BannerProps {
title: string;
description: string;
links: Array<Link>;
image: string;
}
/**
* @return {jsx} The root component
*/
export default function Banner(
{title, description, links, image}: BannerProps) {
let imgUrl;
if (image.startsWith('/static/images/')) {
imgUrl = image;
} else {
imgUrl = '/static/images/' + image;
}
const containerStyle = {
backgroundImage: `url('` + imgUrl + `')`,
backgroundPosition: '64% 78%',
backgroundSize: 'cover',
};
const jumbotronStyle = {
backgroundColor: 'transparent',
};
const colorLayer = {
backgroundColor: '#1279be70',
};
return (
<React.Fragment>
<div
className='container-fluid p-0'
style={containerStyle}>
<div
className='container-fluid p-0'
style={colorLayer}>
<div
className='jumbotron container text-white px-0'
id='banner'
style={jumbotronStyle}>
<h1 className='display-4 font-weight-bold'>{title}</h1>
<div className='h5'>
{description.split('\n').map((elem, index) =>
<p key={index}>
{elem}
</p>)}
</div>
<br />
{links.map((elem, index) =>
<p key={index} className='lead mt-2'>
<Link
className='btn btn-info btn-lg
font-weight-bold'
to={elem.to}
text={elem.text} />
</p>)}
</div>
</div>
</div>
</React.Fragment>
);
}

33
src/client/Button.tsx Normal file
View File

@ -0,0 +1,33 @@
import React from 'react';
import axios from 'axios';
interface ButtonProps {
to: string;
method: 'GET' | 'POST' | 'PUT' | 'DELETE';
text: string;
}
/* eslint-disable */
/**
* @return {jsx} The root component
*/
export default function Button({to, method, text}: ButtonProps) {
async function buttonFetch() {
console.log('buttonFetch');
console.log(to);
console.log(method);
axios({
method: method,
url: to,
withCredentials: true,
}).then((_res: any) => {
window.location.replace('/');
}).catch((_err: any) => {
});
}
return (
<button onClick={buttonFetch}>{text}</button>
);
}
/* eslint-enable */

View File

@ -0,0 +1,38 @@
import React from 'react';
interface Image {
image: string;
title: string;
description: string;
}
interface imagesWithDescriptionProps {
images: Array<Image>;
}
/**
* @return {jsx} The root component
*/
export default function ImagesWithDescription(
{images}: imagesWithDescriptionProps) {
return (
<React.Fragment>
<div className='container w-50 d-flex justify-content-between'>
{images.map((elem, index) =>
<div
key={index}
id='imagewithdescription'
className='align-top'>
<img
src={'/static/images/' + elem.image}
className='rounded mb-3'
id='smallimage'></img>
<h3 className='font-weight-bold'>
{elem.title}
</h3>
<p>{elem.description}</p>
</div>)}
</div>
</React.Fragment>
);
}

18
src/client/Link.tsx Normal file
View File

@ -0,0 +1,18 @@
import React from 'react';
interface LinkProps {
className?: string;
to: string;
text: string;
}
/**
* @return {jsx} The root component
*/
export default function Link({className, to, text}: LinkProps) {
className = className || 'btn btn-primary';
return (
<a className={className} href={to}>{text}</a>
);
}

12
src/client/Loader.tsx Normal file
View File

@ -0,0 +1,12 @@
import React from 'react';
/* eslint-disable */
/**
* @return {jsx} The root component
*/
export default function Loader() {
return (
<div className="loader"></div>
);
}
/* eslint-enable */

47
src/client/Navbar.tsx Normal file
View File

@ -0,0 +1,47 @@
import React from 'react';
interface Link {
to: string;
text: string;
}
interface NavbarProps {
main: Link;
sub: Array<Link>;
}
/**
* @return {jsx} The root component
*/
export default function Navbar({main, sub}: NavbarProps) {
return (
<React.Fragment>
<div className='navbar navbar-expand-lg navbar-light'>
<div
className='container'
id='navbar'>
<a className='navbar-brand' href={main.to}>{main.text}</a>
<button
className='navbar-toggler'
type='button'
data-toggle='collapse'
data-target='#navbar'
aria-controls='navbar'>
<span className='navbar-toggler-icon'></span>
</button>
<div className='collapse navbar-collapse' id='navbar'>
<ul className='navbar-nav mr-auto mb-0 pb-0'>
{sub.map((elem, index) =>
<li key={index} className='nav-item'>
<a className='nav-link' href={elem.to}>
{elem.text}
</a>
</li>)
}
</ul>
</div>
</div>
</div>
</React.Fragment>
);
}

120
src/client/Root.tsx Normal file
View File

@ -0,0 +1,120 @@
import React from 'react';
import Navbar from './Navbar';
import Banner from './Banner';
import ImagesWithDescription from './ImagesWithDescription';
/**
* @return {jsx} The root component
*/
export default function Root() {
return (
<React.Fragment>
<Navbar
main={{
text: 'Curious Roamers',
to: '/blog',
}}
sub={[
{
text: 'Home',
to: '/',
}, {
text: 'Carnet de voyage',
to: '/blog',
}, {
text: 'Qui sommes nous ?',
to: '/about',
}, {
text: 'Contact',
to: '/contact',
},
]}
/>
<Banner
title={`Un voyage à travers l'Europe`}
description={`
On vous raconte nos aventures à travers l'Europe.\n
Nous voyageons en stop et sans argent.\n
On loge chez l'habitant ou dehors dans des hamacs.`}
links={[
{
to: '/blog',
text: 'Notre carnet de voyage',
},
]}
image={'map.jpeg'}
/>
<ImagesWithDescription
images={[
{
image: 'urbex.webp',
title: 'Explorations',
description: `Amateurs d'escalade et de sensations
fortes, nous cherchons des lieux abandonnés ou
difficiles d'accès.`,
}, {
image: 'cliff.jpeg',
title: 'Découvertes',
description: 'De beaux paysages, tu connais.',
}, {
image: 'eye.webp',
title: 'Rencontres',
description: `Notre voyage sera avant tout rythmé de
nouveaux visages. Sans personne pour nous aider
ce voyage aurait peu de sens.`,
},
]}/>
{/* eslint-disable */}
{/* <Quote
quote={'Not all those who wander are lost.'}
author={'J. R. R. Tolkien'}
description={`
On veut quitter notre confort pendant un moment.
Briser nos habitudes et nos ancrages.
Faire place à la nouveauté et l'incompréhension.`}/>
<Footer
elements={[
{
title: 'À propos',
description: `Nous sommes deux jeunes de 23 ans à la
recherche d'aventures et de réponses à travers
l'Europe`,
links: [
{
to: '/about',
text: 'Plus de détails',
},
],
}, {
title: 'Nous contacter',
description: `
<ul>
<li>curious.roamers@gmail.com</li>
<li>06 37 11 28 22</li>
</ul>`,
links: [
{
to: '/contant',
text: `Autres façons de nous contacter`,
},
],
}, {
title: 'Donation',
description: `Si vous voulez contribuer à financer nos
futurs voyages ou simplement nous aider financièrement
:)`,
links: [
{
to: 'paypal.me/ygarrot',
text: 'Paypal',
}, {
to: 'tipeee.com/curious-roamers',
text: 'Tipeee',
},
],
},
]}/> */}
{/* eslint-enable */}
</React.Fragment>
);
}

13
src/client/admin.tsx Normal file
View File

@ -0,0 +1,13 @@
import React from 'react';
import {hydrate, render} from 'react-dom';
import AdminRoot from './AdminRoot';
const root = document.getElementById('root');
let renderMethod;
if (root && root.innerHTML !== '') {
renderMethod = hydrate;
} else {
renderMethod = render;
}
renderMethod(<AdminRoot />, document.getElementById('root'));

13
src/client/main.tsx Normal file
View File

@ -0,0 +1,13 @@
import React from 'react';
import {render, hydrate} from 'react-dom';
import Root from './Root';
const root = document.getElementById('root');
let renderMethod;
if (root && root.innerHTML !== '') {
renderMethod = hydrate;
} else {
renderMethod = render;
}
renderMethod(<Root />, document.getElementById('root'));

59
src/server/auth.ts Normal file
View File

@ -0,0 +1,59 @@
require('dotenv').config();
import jwt from 'jsonwebtoken';
import {RequestHandler} from 'express';
import * as t from './types';
/**
* @param {json} user - the data to store in the token
* @return {jwt} - an Access Token
* This function generates an access token
*/
export function generateAccessToken(user: t.User) {
return jwt.sign(user,
process.env.ACCESS_TOKEN_SECRET!);
};
export const checkNoAuth: RequestHandler = (req, res, next) => {
if (req.cookies.accessToken == undefined) {
return next();
}
return res.redirect('/admin');
};
export const authorize: RequestHandler = async (req, res, next) => {
const token = req.cookies.accessToken;
if (token == undefined) {
req.flash('info', 'You must be signed in to access that resource');
return res.status(401).redirect('/admin/signin');
}
return jwt.verify(token, process.env.ACCESS_TOKEN_SECRET!,
(err: any, _payload: any) => {
if (err) {
res.cookie('accessToken', '', {expires: new Date()});
req.flash('error', `Error in token verification,
you've been automatically signed out`);
return res.status(401).redirect('/admin/signin');
}
return next();
});
};
export const authorizeEmailVerification: RequestHandler = (req, res, next) => {
const token = req.cookies.accessToken;
if (token == undefined) {
req.flash('error', `Please sign in then click the email
verification link again`);
return res.status(401).redirect('/admin/signin');
}
return jwt.verify(token, process.env.ACCESS_TOKEN_SECRET!,
(err: any, _user: any) => {
if (err) {
res.cookie('accessToken', '', {expires: new Date()});
req.flash('error', `Error in token verification,
you've been automatically signed out`);
return res.status(401).redirect('/admin/signin');
}
return next();
});
};

35
src/server/db.ts Normal file
View File

@ -0,0 +1,35 @@
import {Sequelize} from 'sequelize';
import Article from './models/article';
import Tag from './models/tag';
import User from './models/user';
import UniqueLink from './models/uniquelink';
/**
* Connect to database
* @param {Sequelize} sequelize the sequelize object
*/
export async function dbConnect(sequelize: Sequelize) {
try {
await sequelize.authenticate();
console.log('Successfully connected to the database ' +
sequelize.config.database);
} catch (err) {
console.error('Connection to database failed:', err);
}
}
/**
* Connect and init database
* @param {Sequelize} sequelize the sequelize object
*/
export async function dbConnectAndInit(sequelize: Sequelize) {
await Article.hasMany(Tag, {
foreignKey: 'articleId',
onDelete: 'CASCADE',
});
await dbConnect(sequelize);
await User.sync();
await UniqueLink.sync();
await sequelize.sync();
}

15
src/server/dbobject.ts Normal file
View File

@ -0,0 +1,15 @@
require('dotenv').config();
import {Sequelize} from 'sequelize';
const sequelize = new Sequelize(
'curiousroamers',
'gbrochar',
process.env.DATABASE_PASSWORD!,
{
host: 'localhost',
dialect: 'postgres',
logging: false,
},
);
export default sequelize;

47
src/server/main.ts Normal file
View File

@ -0,0 +1,47 @@
require('dotenv').config();
import express from 'express';
import path from 'path';
import flash from 'express-flash';
import session from 'express-session';
import cookieParser from 'cookie-parser';
import methodOverride from 'method-override';
import sequelize from './dbobject';
import {dbConnectAndInit} from './db';
import adminRouter from './routes/admin';
import blogRouter from './routes/blog';
import apiRouter from './routes/api';
const app = express();
dbConnectAndInit(sequelize);
app.set('view engine', 'ejs');
app.use(cookieParser());
app.use(express.urlencoded({extended: false}));
app.use(methodOverride('_method'));
app.use('/js', express.static(path.join(__dirname, '../../dist/client')));
app.use('/static', express.static(path.join(__dirname, '../../public')));
app.use(session({
cookie: {
maxAge: 60000,
httpOnly: true,
secure: false, // set this to true in production
},
secret: process.env.SESSION_SECRET!,
resave: false,
saveUninitialized: false,
}));
app.use(flash());
app.get('/', (_req, res) => {
res.render('index');
});
app.use('/admin', adminRouter);
app.use('/blog', blogRouter);
app.use('/api', apiRouter);
app.listen(3000);

View File

@ -0,0 +1,57 @@
import Sequelize, {DataTypes, Model} from 'sequelize';
import sequelize from '../dbobject';
import marked from 'marked';
import slugify from 'slugify';
import createDomPurify from 'dompurify';
const {JSDOM} = require('jsdom');
const dompurify = createDomPurify(new JSDOM().window);
export interface articleI extends Model {
id: string;
title: string;
markdown: string;
slug: string;
sanitizedHtml: string;
}
const Article = sequelize.define<articleI>('article', {
id: {
type: DataTypes.UUID,
allowNull: false,
unique: true,
primaryKey: true,
defaultValue: Sequelize.UUIDV4,
},
title: {
type: DataTypes.STRING,
allowNull: false,
unique: true,
},
markdown: {
type: DataTypes.TEXT,
allowNull: false,
},
slug: {
type: DataTypes.STRING,
allowNull: false,
unique: true,
},
sanitizedHtml: {
type: DataTypes.TEXT,
allowNull: false,
},
});
Article.addHook('beforeValidate', (article: articleI, options: any) => {
options.fields.push('slug');
options.fields.push('sanitizedHtml');
if (article.title) {
article.slug = slugify(article.title, {lower: true, strict: true});
}
if (article.markdown) {
article.sanitizedHtml = dompurify.sanitize(marked(article.markdown));
}
});
export default Article;

24
src/server/models/tag.ts Normal file
View File

@ -0,0 +1,24 @@
import Sequelize, {DataTypes, Model} from 'sequelize';
import sequelize from '../dbobject';
export interface tagI extends Model {
id: string;
articleId: string;
name: string;
}
const Tag = sequelize.define<tagI>('tag', {
id: {
type: DataTypes.UUID,
allowNull: false,
unique: true,
primaryKey: true,
defaultValue: Sequelize.UUIDV4,
},
name: {
type: DataTypes.STRING,
allowNull: false,
},
});
export default Tag;

View File

@ -0,0 +1,19 @@
import Sequelize, {DataTypes, Model} from 'sequelize';
import sequelize from '../dbobject';
export interface uniqueLinkI extends Model {
id: string;
createdAt: Date;
}
const UniqueLink = sequelize.define<uniqueLinkI>('uniquelink', {
id: {
type: DataTypes.UUID,
allowNull: false,
unique: true,
primaryKey: true,
defaultValue: Sequelize.UUIDV4,
},
});
export default UniqueLink;

40
src/server/models/user.ts Normal file
View File

@ -0,0 +1,40 @@
import Sequelize, {DataTypes, Model} from 'sequelize';
import sequelize from '../dbobject';
export interface userI extends Model {
id: string;
name: string;
email: string;
verifiedemail: boolean;
password: string;
}
const User = sequelize.define<userI>('user', {
id: {
type: DataTypes.UUID,
allowNull: false,
unique: true,
primaryKey: true,
defaultValue: Sequelize.UUIDV4,
},
name: {
type: DataTypes.STRING,
allowNull: false,
unique: true,
},
email: {
type: DataTypes.STRING,
allowNull: false,
unique: true,
},
verifiedemail: {
type: DataTypes.BOOLEAN,
allowNull: false,
},
password: {
type: DataTypes.STRING,
allowNull: false,
},
});
export default User;

260
src/server/routes/admin.ts Normal file
View File

@ -0,0 +1,260 @@
import express, {RequestHandler} from 'express';
import sequelize from './../dbobject';
import Article, {articleI} from './../models/article';
import Tag from './../models/tag';
import User from './../models/user';
import UniqueLink from './../models/uniquelink';
import argon2 from 'argon2';
import {authorize,
authorizeEmailVerification,
checkNoAuth,
generateAccessToken} from './../auth';
/* eslint-disable */
const router = express.Router();
/* eslint-enable */
/* eslint-disable */
declare global {
namespace Express {
interface Request {
article: articleI;
}
}
}
/* eslint-enable */
/* Homepage route */
router.get('/', authorize, async (_req, res) => {
const articles = await Article.findAll({
attributes: [
'title',
'id',
'createdAt',
],
});
res.render('admin/index', {articles});
});
/* Login routes */
/**
* @param {string} type Type of unique link for error message
* @param {number} expiration Duration of expiration of the link in minute
* @return {RequestHandler} middleware
*/
function verifyUniqueLink(type: string, expiration: number): RequestHandler {
return async (req, res, next) => {
const linkCheck = await UniqueLink.findOne({
where: {id: req.params.uuid},
});
if (linkCheck == null) {
req.flash('error', 'Page does not exists');
return res.status(404).redirect('/');
}
if ((Date.now() - linkCheck.createdAt.getTime()) / (1000 * 60) >
expiration) {
if (type == 'signup') {
req.flash('error', `The ressource has expired, please contact
an admin to create a new signup link`);
} else {
req.flash('error', `The ressource has expired, please create a
new ` + type + ' link');
}
await linkCheck.destroy();
return res.status(410).redirect('/');
}
return next();
};
}
router.get('/verifyemail/:id/:uuid', verifyUniqueLink('email verification',
60*24*30), authorizeEmailVerification, async (req, res) => {
try {
const user = await User.findOne({
where: {id: req.params.id},
rejectOnEmpty: true,
});
user.verifiedemail = true;
await user.save();
await UniqueLink.destroy({where: {id: req.params.uuid}});
res.status(204).redirect('/admin');
} catch (e) {
console.log(e);
req.flash('error', `Server error, please try again, if that
persists contact your web administrator`);
res.status(500).redirect('/admin');
}
});
router.post('/changepassword/:id/:uuid', verifyUniqueLink('password reset',
30), checkNoAuth, async (req, res) => {
try {
const user = await User.findOne({
where: {id: req.params.id},
rejectOnEmpty: true,
});
const hash = await argon2.hash(req.body.password);
user.password = hash;
await user.save();
await UniqueLink.destroy({where: {id: req.params.uuid}});
res.status(204).redirect('/admin/signin');
} catch (e) {
console.log(e);
req.flash('error', `Server error, please try again, if that
persists contact your web administrator`);
res.status(500).redirect('/admin');
}
});
router.post('/signup/:uuid', verifyUniqueLink('signup', 30), checkNoAuth,
async (req, res) => {
try {
if (await User.findOne({where: {name: req.body.name}}) != null) {
req.flash('error', 'Username already in use');
return res.status(409).redirect('/admin/signup/' +
req.params.uuid);
}
if (await User.findOne({where: {email: req.body.email}}) != null) {
req.flash('error', 'Email already in use');
return res.status(409).redirect('/admin/signup/' +
req.params.uuid);
}
const hash = await argon2.hash(req.body.password);
const user = User.build({
name: req.body.name,
email: req.body.email,
verifiedemail: false,
password: hash,
});
await user.save();
await UniqueLink.destroy({where: {id: req.params.uuid}});
res.status(201).redirect('/admin/signin');
} catch (e) {
console.log(e);
req.flash('error', `Server error, please try again, if that
persists contact your web administrator`);
res.status(500).redirect('/admin');
}
});
router.post('/signin', checkNoAuth, async (req, res) => {
let user: any = await User.findOne({where: {name: req.body.name}});
const email = await User.findOne({where: {email: req.body.name}});
if (!user && !email) {
req.flash('error', 'Name or email does not match');
return res.redirect('/admin/signin');
}
if (!user) {
user = email;
}
if (await argon2.verify(user.password, req.body.password)) {
const tokenData = {
sub: user.id,
name: user.name,
email: user.email,
};
const accessToken = generateAccessToken(tokenData);
const farFuture = new Date(new Date().getTime() +
(1000 * 60 * 60 * 24 * 365 * 100));
res.cookie('accessToken', accessToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
expires: farFuture,
});
res.redirect('/admin');
} else {
req.flash('error', 'Password does not match');
return res.redirect('/admin/signin');
}
});
router.delete('/signout', async (_req, res) => {
res.cookie('accessToken', '', {expires: new Date()});
res.redirect('/');
});
router.get('/signin', checkNoAuth, (_req, res) => {
res.render('admin/signin');
});
router.get('/signup/:uuid', checkNoAuth, (req, res) => {
res.render('admin/signup', {uuid: req.params.uuid});
});
router.get('/changepassword/:id/:uuid', checkNoAuth, (_req, res) => {
res.render('admin/changepassword');
});
/* Article routes */
router.get('/new', authorize, (_req, res) => {
res.render('admin/new', {article: Article.build(), tags: []});
});
router.get('/edit/:id', authorize, async (req, res) => {
const article = await Article.findByPk(req.params.id,
{rejectOnEmpty: true});
const tags = await Tag.findAll({
where: {articleId: article.id},
attributes: [
'name',
],
});
res.render('admin/edit', {article, tags});
});
router.post('/', authorize, async (req, _res, next) => {
req.article = Article.build();
next();
}, saveArticleAndRedirect('new'));
router.put('/:id', authorize, async (req, _res, next) => {
req.article = await Article.findByPk(req.params.id, {rejectOnEmpty: true});
next();
}, saveArticleAndRedirect('edit'));
router.delete('/:id', authorize, async (req, res) => {
await Article.destroy({where: {id: req.params.id}, cascade: true});
res.sendStatus(204);
});
/**
* @param {string} path Path of redirection
* @return {RequestHandler} middleware
*/
function saveArticleAndRedirect(path: string): RequestHandler {
return async (req, res) => {
let article: any = req.article;
article.title = req.body.title;
article.description = req.body.description;
article.markdown = req.body.markdown;
try {
await sequelize.transaction(async (t) => {
let tagCount = 0;
while (req.body.articletags[tagCount] != '') {
tagCount++;
}
Tag.destroy({where: {articleId: article.id},
transaction: t});
article = await article.save();
for (let i = 0; i < tagCount; i++) {
await article.createTag({
articleId: article.id,
main: (i == 0) ? true : false,
name: req.body.articletags[i],
});
}
});
res.redirect(`/blog/post/${article.slug}`);
} catch (e) {
console.log(e);
req.flash('error', 'The operation has failed, please try again');
res.render(`admin/${path}`, {article: article});
}
};
}
export default router;

105
src/server/routes/api.ts Normal file
View File

@ -0,0 +1,105 @@
import express, {RequestHandler} from 'express';
import sequelize from './../dbobject';
import Article, {articleI} from './../models/article';
import Tag from './../models/tag';
import {authorize} from './../auth';
/* eslint-disable */
const router = express.Router();
/* eslint-enable */
/* eslint-disable */
declare global {
namespace Express {
interface Request {
article: articleI;
}
}
}
/* eslint-enable */
router.delete('/signout', async (_req, res) => {
res.cookie('accessToken', '', {expires: new Date()});
res.sendStatus(204);
});
router.get('/articletable', authorize, async (_req, res) => {
const articles = await Article.findAll({
attributes: [
'id',
'title',
'createdAt',
],
});
res.send(articles);
});
router.get('/new', authorize, (_req, res) => {
res.render('admin/new', {article: Article.build(), tags: []});
});
router.get('/edit/:id', authorize, async (req, res) => {
const article = await Article.findByPk(req.params.id,
{rejectOnEmpty: true});
const tags = await Tag.findAll({
where: {articleId: article.id},
attributes: [
'name',
],
});
res.render('admin/edit', {article, tags});
});
router.post('/', authorize, async (req, _res, next) => {
req.article = Article.build();
next();
}, saveArticleAndRedirect('new'));
router.put('/:id', authorize, async (req, _res, next) => {
req.article = await Article.findByPk(req.params.id, {rejectOnEmpty: true});
next();
}, saveArticleAndRedirect('edit'));
router.delete('/:id', authorize, async (req, _res) => {
await Article.destroy({where: {id: req.params.id}, cascade: true});
});
/**
* @param {string} path Path of redirection
* @return {RequestHandler} middleware
*/
function saveArticleAndRedirect(path: string): RequestHandler {
return async (req, res) => {
let article: any = req.article;
article.title = req.body.title;
article.description = req.body.description;
article.markdown = req.body.markdown;
try {
await sequelize.transaction(async (t) => {
let tagCount = 0;
while (req.body.articletags[tagCount] != '') {
tagCount++;
}
Tag.destroy({where: {articleId: article.id},
transaction: t});
article = await article.save();
for (let i = 0; i < tagCount; i++) {
await article.createTag({
articleId: article.id,
main: (i == 0) ? true : false,
name: req.body.articletags[i],
});
}
});
res.redirect(`/blog/post/${article.slug}`);
} catch (e) {
console.log(e);
req.flash('error', 'The operation has failed, please try again');
res.render(`admin/${path}`, {article: article});
}
};
}
export default router;

View File

@ -0,0 +1,7 @@
import express from 'express';
/* eslint-disable */
const router = express.Router()
/* eslint-enable */
export default router;

19
src/server/types.ts Normal file
View File

@ -0,0 +1,19 @@
export interface Dict {
[key: string]: string;
}
export interface User {
sub: string;
name: string;
email: string;
}
/* eslint-disable */
declare global {
namespace Express {
interface Request {
user: User
}
}
}
/* eslint-enable */

22
tsconfig.json Normal file
View File

@ -0,0 +1,22 @@
{
"compilerOptions": {
"allowSyntheticDefaultImports": true,
"noFallthroughCasesInSwitch": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"esModuleInterop": true,
"noUnusedLocals": true,
"noImplicitAny": true,
"declaration": true,
"jsx" : "preserve",
"module": "commonjs",
"strict": true
},
"exclude": [
"node_modules",
"config",
"views",
"build",
"dev"
]
}

36
views/_flash.ejs Normal file
View File

@ -0,0 +1,36 @@
<% if (locals.messages.error) { %>
<% messages.error.forEach(message => { %>
<div class="alert alert-danger alert-dismissible fade show mb-0" role="alert">
<%= message %>
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
<span aria-hidden="true">&times;</span>
</div>
<% }) %>
<% } %>
<% if (locals.messages.warning) { %>
<% messages.warning.forEach(message => { %>
<div class="alert alert-warning alert-dismissible fade show mb-0" role="alert">
<%= message %>
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
<span aria-hidden="true">&times;</span>
</div>
<% }) %>
<% } %>
<% if (locals.messages.success) { %>
<% messages.success.forEach(message => { %>
<div class="alert alert-success alert-dismissible fade show mb-0" role="alert">
<%= message %>
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
<span aria-hidden="true">&times;</span>
</div>
<% }) %>
<% } %>
<% if (locals.messages.info) { %>
<% messages.info.forEach(message => { %>
<div class="alert alert-info alert-dismissible fade show mb-0" role="alert">
<%= message %>
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
<span aria-hidden="true">&times;</span>
</div>
<% }) %>
<% } %>

View File

@ -0,0 +1,45 @@
<style>
.subtag {
display: inline-block;
width: 9.636363%;
}
</style>
<div class="form-group">
<label for="title">Title</label>
<input required value="<%= article.title %>" type="text" name="title" id="title" class="form-control">
</div>
<% if (tags[0] != undefined) { %>
<div class="form-group">
<label for="tag">Tag</label>
<input required value="<%= tags[0].name %>" type="text" name="articletags" id="articletags" class="form-control">
</div>
<% } else { %>
<div class="form-group">
<label for="tag">Tag</label>
<input required value="" type="text" name="articletags" id="articletags" class="form-control">
</div>
<% } %>
<div class="form-group">
<div>
<label for="subtags">Subtags</label>
</div>
<% let i = 1; %>
<% for (i; i < tags.length; i++) { %>
<input class="subtag" value="<%= tags[i].name %>" type="text" name="articletags" id="articletags" class="form-control">
<% } %>
<% for (i; i < 11; i++) { %>
<input class="subtag" value="" type="text" name="articletags" id="articletags" class="form-control">
<% } %>
</div>
<div class="form-group">
<label for="markdown">Markdown</label>
<textarea required name="markdown" id="markdown" class="form-control"><%= article.markdown %></textarea>
</div>
<a href="/" class="btn btn-secondary">Cancel</a>
<button type="submit" class="btn btn-primary">Save</button>

24
views/admin/_menu.ejs Normal file
View File

@ -0,0 +1,24 @@
<script>
$('#home').submit(function () {
window.location = this.action
return false
}
</script>
<form id="home" action="/" method="GET">
<ul class="nav masthead-nav">
<li><button>home</button></li>
<li class="mydot"><a>.</a></li>
<li><button>home</button></li>
<li class="mydot"><a>.</a></li>
<li><button>home</button></li>
<li class="mydot"><a>.</a></li>
<li><button>home</button></li>
<li class="mydot"><a>.</a></li>
<li><button>home</button></li>
<li class="mydot"><a>.</a></li>
<li><button>home</button></li>
<li class="mydot"><a>.</a></li>
<li><button>home</button></li>
</ul>
</form>

25
views/admin/edit.ejs Normal file
View File

@ -0,0 +1,25 @@
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.5.3/dist/css/bootstrap.min.css" integrity="sha384-TX8t27EcRE3e/ihU7zmQxVncDAy5uIKz4rEkgIXeMed4M0jlfIDPvg6uqKI2xXr2" crossorigin="anonymous">
<script src="https://code.jquery.com/jquery-3.2.1.slim.min.js" integrity="sha384-KJ3o2DKtIkvYIK3UENzmM7KCkRr/rE9/Qpg6aAZGJwFDMVNA/GpGFF93hXpG5KkN" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.12.9/umd/popper.min.js" integrity="sha384-ApNbgh9B+Y1QKtv3Rn7W3mgPxhU9K/ScQsAP7hUibX39j7fakFPskvXusvfa0b4Q" crossorigin="anonymous"></script>
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/js/bootstrap.min.js" integrity="sha384-JZR6Spejh4U02d8jOt6vLEHfe/JQGiRRSQQxSfFWpi1MquVdAyjUar5+76PVCmYl" crossorigin="anonymous"></script>
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<%- include('../_flash') %>
<div class="container inner">
<div class="navbar fixed-top">
<%- include('_menu') %>
</div>
<br>
<br>
<h1 class="mb-4">Edit article</h1>
<form action="/admin/<%= article.id %>?_method=PUT" method="POST">
<%- include('_form_fields') %>
</form>
</div>
</body>
</html>

21
views/admin/index.ejs Normal file
View File

@ -0,0 +1,21 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title></title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.5.3/dist/css/bootstrap.min.css" integrity="sha384-TX8t27EcRE3e/ihU7zmQxVncDAy5uIKz4rEkgIXeMed4M0jlfIDPvg6uqKI2xXr2" crossorigin="anonymous">
<script src="https://code.jquery.com/jquery-3.5.1.min.js" integrity="sha256-9/aliU8dGd2tb6OSsuzixeV4y/faTqgFtohetphbbj0=" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.12.9/umd/popper.min.js" integrity="sha384-ApNbgh9B+Y1QKtv3Rn7W3mgPxhU9K/ScQsAP7hUibX39j7fakFPskvXusvfa0b4Q" crossorigin="anonymous"></script>
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/js/bootstrap.min.js" integrity="sha384-JZR6Spejh4U02d8jOt6vLEHfe/JQGiRRSQQxSfFWpi1MquVdAyjUar5+76PVCmYl" crossorigin="anonymous"></script>
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<%- include('../_flash') %>
<div id="root"></div>
<script src='/js/admin.bundle.js'></script>
</body>
</html>

25
views/admin/new.ejs Normal file
View File

@ -0,0 +1,25 @@
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.5.3/dist/css/bootstrap.min.css" integrity="sha384-TX8t27EcRE3e/ihU7zmQxVncDAy5uIKz4rEkgIXeMed4M0jlfIDPvg6uqKI2xXr2" crossorigin="anonymous">
<script src="https://code.jquery.com/jquery-3.2.1.slim.min.js" integrity="sha384-KJ3o2DKtIkvYIK3UENzmM7KCkRr/rE9/Qpg6aAZGJwFDMVNA/GpGFF93hXpG5KkN" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.12.9/umd/popper.min.js" integrity="sha384-ApNbgh9B+Y1QKtv3Rn7W3mgPxhU9K/ScQsAP7hUibX39j7fakFPskvXusvfa0b4Q" crossorigin="anonymous"></script>
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/js/bootstrap.min.js" integrity="sha384-JZR6Spejh4U02d8jOt6vLEHfe/JQGiRRSQQxSfFWpi1MquVdAyjUar5+76PVCmYl" crossorigin="anonymous"></script>
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<%- include('../_flash') %>
<div class="container">
<div class="navbar fixed-top">
<%- include('_menu') %>
</div>
<br>
<br>
<h1 class="mb-4">New article</h1>
<form action="/admin" method="POST">
<%- include('_form_fields') %>
</form>
</div>
</body>
</html>

26
views/admin/signin.ejs Normal file
View File

@ -0,0 +1,26 @@
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.5.3/dist/css/bootstrap.min.css" integrity="sha384-TX8t27EcRE3e/ihU7zmQxVncDAy5uIKz4rEkgIXeMed4M0jlfIDPvg6uqKI2xXr2" crossorigin="anonymous">
<script src="https://code.jquery.com/jquery-3.2.1.slim.min.js" integrity="sha384-KJ3o2DKtIkvYIK3UENzmM7KCkRr/rE9/Qpg6aAZGJwFDMVNA/GpGFF93hXpG5KkN" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.12.9/umd/popper.min.js" integrity="sha384-ApNbgh9B+Y1QKtv3Rn7W3mgPxhU9K/ScQsAP7hUibX39j7fakFPskvXusvfa0b4Q" crossorigin="anonymous"></script>
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/js/bootstrap.min.js" integrity="sha384-JZR6Spejh4U02d8jOt6vLEHfe/JQGiRRSQQxSfFWpi1MquVdAyjUar5+76PVCmYl" crossorigin="anonymous"></script>
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<%- include('../_flash') %>
<h1>signin</h1>
<form action="/admin/signin" method="POST">
<div>
<label for="name">Name or email</label>
<input required type="text" id="name" name="name">
</div>
<div>
<label for="password">Password</label>
<input required type="text" id="password" name="password">
</div>
<button type="submit">OK</button>
</form>
</body>
</html>

30
views/admin/signup.ejs Normal file
View File

@ -0,0 +1,30 @@
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.5.3/dist/css/bootstrap.min.css" integrity="sha384-TX8t27EcRE3e/ihU7zmQxVncDAy5uIKz4rEkgIXeMed4M0jlfIDPvg6uqKI2xXr2" crossorigin="anonymous">
<script src="https://code.jquery.com/jquery-3.2.1.slim.min.js" integrity="sha384-KJ3o2DKtIkvYIK3UENzmM7KCkRr/rE9/Qpg6aAZGJwFDMVNA/GpGFF93hXpG5KkN" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.12.9/umd/popper.min.js" integrity="sha384-ApNbgh9B+Y1QKtv3Rn7W3mgPxhU9K/ScQsAP7hUibX39j7fakFPskvXusvfa0b4Q" crossorigin="anonymous"></script>
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/js/bootstrap.min.js" integrity="sha384-JZR6Spejh4U02d8jOt6vLEHfe/JQGiRRSQQxSfFWpi1MquVdAyjUar5+76PVCmYl" crossorigin="anonymous"></script>
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<%- include('../_flash') %>
<h1>signin</h1>
<form action="/admin/signup/<%= uuid %>" method="POST">
<div>
<label for="name">Name</label>
<input required type="text" id="name" name="name">
</div>
<div>
<label for="email">Email</label>
<input required type="text" id="email" name="email">
</div>
<div>
<label for="password">Password</label>
<input required type="text" id="password" name="password">
</div>
<button type="submit">OK</button>
</form>
</body>
</html>

21
views/index.ejs Normal file
View File

@ -0,0 +1,21 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title></title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.5.3/dist/css/bootstrap.min.css" integrity="sha384-TX8t27EcRE3e/ihU7zmQxVncDAy5uIKz4rEkgIXeMed4M0jlfIDPvg6uqKI2xXr2" crossorigin="anonymous">
<script src="https://code.jquery.com/jquery-3.2.1.slim.min.js" integrity="sha384-KJ3o2DKtIkvYIK3UENzmM7KCkRr/rE9/Qpg6aAZGJwFDMVNA/GpGFF93hXpG5KkN" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.12.9/umd/popper.min.js" integrity="sha384-ApNbgh9B+Y1QKtv3Rn7W3mgPxhU9K/ScQsAP7hUibX39j7fakFPskvXusvfa0b4Q" crossorigin="anonymous"></script>
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/js/bootstrap.min.js" integrity="sha384-JZR6Spejh4U02d8jOt6vLEHfe/JQGiRRSQQxSfFWpi1MquVdAyjUar5+76PVCmYl" crossorigin="anonymous"></script>
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<%- include('_flash') %>
<div id="root"></div>
<script src="js/main.bundle.js"></script>
</body>
</html>