auth init

This commit is contained in:
ntr committed 2022-08-08 19:51:17 +10:00
1 parent faa2e70e08
commit 413aeedadf
29 files changed
+2854 -649

No files matched your search

View File
File renamed without changes.
+8
View File
@@ -0,0 +1,8 @@
{
"extension": ["ts"],
"spec": "src/**/*.test.ts",
"require": "ts-node/register",
"watch-files": "**/*.ts",
"watch-ignore": ["db"]
}
View File
File renamed without changes.
+1
View File
@@ -1,6 +1,7 @@
// global type aliases and whatnot
type UUID = string;
type Email = string;
type DiscogsName = string;
type DiscogsListing = number;
+22
View File
@@ -0,0 +1,22 @@
import createServices from './src/services';
import createLog from './src/log';
async function start() {
const log = createLog();
process.on('SIGTERM', () => {
log.debug('SIGTERM received, exiting');
process.exit(0);
});
const {
prisma,
pg,
discogs,
api,
} = createServices(log);
await api.listen();
}
start();
File diff suppressed because it is too large. Load diff
+18 -4
View File
@@ -4,7 +4,8 @@
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"test": "mocha",
"test:watch": "mocha --watch",
"migrate": "knex migrate:latest",
"migrate:make": "prisma migrate dev --name",
"start": "ts-node-dev --respawn --transpile-only index.ts | bunyan -o short"
@@ -12,9 +13,13 @@
"author": "",
"license": "ISC",
"devDependencies": {
"@types/bcrypt": "^5.0.0",
"@types/bluebird": "^3.5.36",
"@types/lodash": "^4.14.182",
"@types/koa": "^2.13.5",
"@types/lodash": "^4.14.182",
"@types/mocha": "^9.1.1",
"axios": "^0.27.2",
"mocha": "^10.0.0",
"node-dev": "^7.4.3",
"prisma": "^4.1.0",
"ts-node-dev": "^2.0.0",
@@ -24,11 +29,20 @@
"@koa/cors": "^3.3.0",
"@koa/router": "^12.0.0",
"@prisma/client": "^4.1.0",
"axios": "^0.27.2",
"bcrypt": "^5.0.1",
"bluebird": "^3.7.2",
"bunyan": "^1.8.15",
"cheerio": "^1.0.0-rc.12",
"koa": "^2.13.4",
"koa-body": "^5.0.0",
"lodash": "^4.17.21"
"koa-passport": "^5.0.0",
"koa-session": "^6.2.0",
"lodash": "^4.17.21",
"passport-local": "^1.0.0"
},
"overrides": {
"koa-passport": {
"passport": "^0.5"
}
}
}
@@ -25,7 +25,8 @@ model Listing {
model User {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
name String @default("unknown")
email String @unique
password String
discogs String? @unique
data Json?
+48
View File
@@ -0,0 +1,48 @@
import assert from 'assert/strict';
import axios from 'axios';
import createServices from './services';
import createLog from './log';
const log = createLog();
log.level('error');
const services = createServices(log);
const HOST = 'http://localhost:41337';
before(function setup() {
console.log('setup')
services.api.listen();
});
after(function teardown(done) {
services.api.server.close(done)
});
describe('api', function apiTests() {
it('listens', async function listenTest() {
const res = await axios.get(`${HOST}/health`);
assert.equal(res.status, 200);
});
it('register', async function register() {
const body = {
email: `test+${Date.now()}@user.com`,
password: 'woaaah theres no limit',
};
const { data } = await axios.post(`${HOST}/auth/register`, body);
assert.equal(data.email, body.email);
});
it('login', async function login() {
const body = {
email: `test+${Date.now()}@user.com`,
password: 'woaaah theres no limit',
};
await axios.post(`${HOST}/auth/register`, body);
const res = await axios.post(`${HOST}/auth/login`, body);
assert(res.headers['set-cookie']);
})
});
+42 -29
View File
@@ -1,32 +1,38 @@
import http from 'http';
import koa from 'koa';
import bodyParser from 'koa-body';
import session from 'koa-session';
import koaRouter from '@koa/router'
import cors from '@koa/cors';
import http from 'http';
import { groupBy } from 'lodash';
import Bluebird from 'bluebird';
import type Log from 'bunyan';
import type { Listing, User, Want, Prisma } from '@prisma/client';
import { Pg } from './pg';
import type { Listing, User, Want, Prisma } from '@prisma/client';
import routerAuth from './routers/router.auth';
export default function createApi(log: Log, pg: Pg, discogs) {
const PORT = 41337;
const SECRET = 'ohja4Eif9eidah3aj5ta2haa4iemooth';
const app = new koa();
const server = http.createServer(app.callback());
app.use(async (ctx, next) => {
try {
await next();
} catch (err) {
log.error(err);
ctx.status = err.statusCode || err.status || 500;
ctx.body = {
message: err.message
};
}
});
// app.use(async (ctx, next) => {
// try {
// await next();
// } catch (err) {
// log.error(err);
// ctx.status = err.statusCode || err.status || 500;
// ctx.body = {
// message: err.message
// };
// }
// });
const router = koaRouter();
@@ -65,26 +71,23 @@ export default function createApi(log: Log, pg: Pg, discogs) {
mentionsWorldwide: shipping.toLowerCase().includes('world'),
}
log.info(bundle);
return bundle;
});
}
async function populateUserListings(user: User) {
log.info(user);
const wants = await discogs.wantlist(user);
// const latest = await discogs.wantlistLatest(user);
// if (wants.length !== latest.length)
log.info({ username: user.discogs, wantCount: wants.length });
log.debug({ username: user.discogs, wantCount: wants.length });
return Bluebird.mapSeries<Want, Listing[]>(wants, async want => {
const listings = await discogs.listings(want);
const populated = listings.filter(l => l);
log.info({ id: want.id, release: want.release, populated: populated.length, total: listings.length });
log.debug({ id: want.id, release: want.release, populated: populated.length, total: listings.length });
return populated;
});
}
@@ -95,19 +98,34 @@ export default function createApi(log: Log, pg: Pg, discogs) {
reject(err);
})
app.listen(PORT, () => {
server.listen(PORT, () => {
log.info('listening', { PORT });
});
});
}
app.use(cors());
app.use(cors({ origin: false, credentials: true }));
app.use(bodyParser())
router.get('/bundles', async (ctx, next) => {
app.keys = [SECRET];
app.use(session(app));
const { authRouter, passport } = routerAuth(log, pg);
app.use(passport.initialize());
app.use(passport.session())
app.use(authRouter.routes());
router.get('/health', async function health(ctx) {
ctx.status = 200;
});
async function requireAuth(ctx, next) {
log.warn(ctx.isAuthenticated());
await next();
}
router.get('/bundles', requireAuth, async (ctx, next) => {
const bundles = await getBundles();
const search = await discogs.searchMaster('nine inch nails', 'the fragile');
log.info(search);
ctx.response.body = bundles;
})
@@ -127,12 +145,6 @@ export default function createApi(log: Log, pg: Pg, discogs) {
ctx.response.body = bundles;
});
router.post('/user', async function usersPost(ctx, next) {
const { name } = ctx.request.body;
const user = await pg.userUpsert(null, name, null);
ctx.response.body = user;
});
router.post('/user/populate', async function usersPopulate(ctx, next) {
const { userId } = ctx.request.body;
const user = await pg.userFindId(userId);
@@ -158,6 +170,7 @@ export default function createApi(log: Log, pg: Pg, discogs) {
return {
app,
server,
listen,
};
}
+28
View File
@@ -0,0 +1,28 @@
import { KoaPassport } from 'koa-passport';
import { Strategy as LocalStrategy } from 'passport-local';
export default function authService(pg) {
const passport = new KoaPassport();
passport.serializeUser((user, done) => done(null, user.id));
passport.deserializeUser((id, done) => {
pg.userFindId(id)
.then(u => done(null, u))
.catch(e => done(e, null));
})
passport.use(new LocalStrategy(
{ usernameField: 'email '},
function localStrategy(email, password, done) {
pg.userFindEmail(email)
.then(user => {
if (!user) return done(null, false);
// if (!user.verifyPassword(password)) return done(null, false);
return done(null, user);
})
.catch(e => done(e, null));
}
));
return passport;
}
+8 -8
View File
@@ -31,13 +31,13 @@ export default function discogs(log: Log, pg: Pg) {
return null;
}
async function user(discogs: string) {
const user = await pg.userFindName(discogs);
if (user) return user;
log.debug('fetching user', discogs);
const { data } = await api.get(`${apiBase}/users/${discogs}`);
return pg.userUpsert(data.username, data.username, data);
}
// async function user(discogs: string) {
// const user = await pg.userFindName(discogs);
// if (user) return user;
// log.debug('fetching user', discogs);
// const { data } = await api.get(`${apiBase}/users/${discogs}`);
// return pg.userUpsert(data.username, data.username, data);
// }
async function wantlist(user: User): Promise<Want[]> {
const stored = await pg.userWants(user);
@@ -106,7 +106,7 @@ export default function discogs(log: Log, pg: Pg) {
}
return {
user,
// user,
wantlist,
wantlistLatest,
inventory,
File renamed without changes.
+24 -20
View File
@@ -1,11 +1,19 @@
import bcrypt from 'bcrypt';
import type Log from 'bunyan';
import type { PrismaClient, Want, User, Listing, Prisma } from '@prisma/client';
import * as Bluebird from 'bluebird';
export interface UserCreateParams {
discogs: DiscogsName | null,
email: string,
password: string,
data: any,
}
export interface Pg {
userFindId: (id: UUID) => Promise<User>,
userFindName: (name: DiscogsName) => Promise<User>,
userUpsert: (discogs: DiscogsName | null, name: string, data: any) => Promise<User>,
userFindEmail: (email: Email) => Promise<User>,
userCreate: (params: UserCreateParams) => Promise<User>,
userWants: (user: User) => Promise<Want[]>,
wantUpsert: (want: Want, user: User) => Promise<Want>,
@@ -21,31 +29,27 @@ export default function createPg(log: Log, prisma: PrismaClient): Pg {
return prisma.user.findUnique({ where: { id }});
}
async function userFindName(name: DiscogsName) {
return prisma.user.findUnique({ where: { discogs: name }});
async function userFindEmail(email: Email) {
return prisma.user.findUnique({ where: { email }});
}
async function userWants(user: User) {
return prisma.want.findMany({ where: { userId: user.id }});
}
async function userUpsert(discogs: DiscogsName | null, name: string, data: any) {
const insert = {
discogs,
name,
data,
async function userCreate(params: UserCreateParams) {
const hashRounds = 10;
const password = await bcrypt.hash(params.password, hashRounds);
const data = {
discogs: params.discogs,
email: params.email,
data: params.data,
password,
};
if (discogs) return prisma.user.upsert({
where: {
discogs,
},
update: insert,
create: insert,
});
return prisma.user.create({
data: { name },
data,
});
}
@@ -139,8 +143,8 @@ export default function createPg(log: Log, prisma: PrismaClient): Pg {
return {
userFindId,
userFindName,
userUpsert,
userFindEmail,
userCreate,
userWants,
wantUpsert,
+60
View File
@@ -0,0 +1,60 @@
import bcrypt from 'bcrypt';
import { KoaPassport } from 'koa-passport';
import { Strategy as LocalStrategy } from 'passport-local';
import Router from '@koa/router'
import type { Pg } from './../pg';
export default function authRouter(log, pg: Pg) {
const passport = new KoaPassport();
async function verifyPassword(user, password) {
const match = await bcrypt.compare(password, user.password);
return match;
}
passport.use(new LocalStrategy(
{ usernameField: 'email' },
function localStrategy(email, password, done) {
pg.userFindEmail(email)
.then(user => {
if (!user) return done(null, false);
if (!verifyPassword(user, password)) return done(null, false);
return done(null, user);
})
.catch(e => done(e, null));
}
));
passport.serializeUser((user, done) => done(null, user.id));
passport.deserializeUser((id, done) => {
pg.userFindId(id)
.then(u => done(null, u))
.catch(e => done(e, null));
})
const authRouter = new Router({ prefix: '/auth' });
authRouter.post('/login', async (ctx, next) => {
await passport.authenticate('local', (err, user, info, status) => {
if (user) {
ctx.login(user);
ctx.status = 200;
ctx.body = { status: 'success' };
} else {
ctx.status = 400;
ctx.body = { status: 'error' };
}
})(ctx, next);
});
authRouter.post('/register', async (ctx, next) => {
const { discogs, email, password } = ctx.request.body;
// fetch discogs data if they have it
const params = { discogs, email, password, data: {} };
const user = await pg.userCreate(params)
ctx.response.body = user;
});
return { authRouter, passport };
}
+21
View File
@@ -0,0 +1,21 @@
import { PrismaClient } from '@prisma/client'
import discogsService from './discogs';
import pgService from './pg';
import { logAxiosError } from './utils';
import createLog from './log';
import createApi from './api';
export default function createServices(log) {
const prisma = new PrismaClient();
const pg = pgService(log, prisma);
const discogs = discogsService(log, pg);
const api = createApi(log, pg, discogs);
return {
prisma,
pg,
discogs,
api,
};
}
File renamed without changes.
+8 -2
View File
@@ -33,7 +33,8 @@
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
"types": [
"./index.d.ts"
"./index.d.ts",
"mocha"
], /* Specify type package names to be included without being referenced in a source file. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
@@ -101,5 +102,10 @@
/* Completeness */
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
"skipLibCheck": true /* Skip type checking all .d.ts files. */
}
},
"ts-node": {
// strict mode gtfo
"transpileOnly": true,
},
}
+1 -1
View File
@@ -5,7 +5,7 @@
"license": "MIT",
"scripts": {
"build": "preact build",
"start": "preact watch",
"start": "preact --no-clear watch",
"lint": "eslint src"
},
"eslintConfig": {
+16 -8
View File
@@ -1,17 +1,25 @@
import axios from 'axios';
const actions = store => ({
const API_URL = 'http://localhost:41337';
axios.defaults.withCredentials = true;
axios.defaults.crossDomain = true;
const actions = {
getBundles: async () => {
const { data: bundles } = await axios.get('http://localhost:41337/bundles');
return { bundles };
const { data: bundles } = await axios.get(`${API_URL}/bundles`);
return bundles;
},
getUserOffers: async (state, userId) => {
store.setState({ offers: [] });
const { data: offers } = await axios.get(`http://localhost:41337/user/${userId}/offers`);
console.log(userId, offers);
return { offers };
const { data: offers } = await axios.get(`${API_URL}/user/${userId}/offers`);
return offers;
},
});
getOffers: async (userId) => {
const { data } = await axios.get(`${API_URL}/user/${userId}/offers`);
return data;
},
};
export default actions;
+4
View File
@@ -8,6 +8,8 @@ import actions from './actions';
import Header from './components/header';
import Home from './routes/home';
import Login from './routes/login';
import Register from './routes/register';
import Search from './routes/search';
import User from './routes/user';
@@ -21,6 +23,8 @@ const App = () => (
<Header />
<Router>
<Route path="/" component={Home} />
<Route path="/login" component={Login} />
<Route path="/register" component={Register} />
<Route path="/search" component={Search} />
<Route path="/user/:userId" component={User} />
</Router>
+24 -23
View File
@@ -1,30 +1,31 @@
import { h } from 'preact';
import { connect } from 'unistore/preact'
import { useState, useEffect } from 'preact/hooks';
import actions from './../actions';
import Bundle from './../components/bundle';
interface Props {
bundles: any[];
getBundles: () => void,
function Home() {
const [bundles, setBundles] = useState([]);
useEffect(() => {
actions.getBundles().then(setBundles);
}, []);
if (!bundles.length) {
return (
<div class={'home'}>
loading...
</div>
);
}
return (
<div class={'home'}>
{bundles.map(Bundle)}
</div>
);
}
function Home({ bundles, getBundles }: Props) {
if (!bundles.length) {
getBundles();
return (
<div class={'home'}>
loading...
</div>
);
}
return (
<div class={'home'}>
{bundles.map(Bundle)}
</div>
);
}
export default connect('bundles', actions)(Home);
export default Home;
+35
View File
@@ -0,0 +1,35 @@
import { h } from 'preact';
import { useState, useEffect } from 'preact/hooks';
import axios from 'axios';
const Register = () => {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('');
async function postLogin() {
const { data } = await axios.post(`http://localhost:41337/auth/login`, {
email,
password,
});
console.log(data);
}
function handleSubmit(e) {
e.preventDefault();
postLogin();
}
return (
<div class={'home'}>
<form action="" method="get" >
<label for="email">email: </label>
<input type="email" name="email" id="email" value={email} required onChange={(e) => setEmail(e.target.value)} />
<label for="password">password: </label>
<input type="password" name="password" id="password" value={password} required onChange={(e) => setPassword(e.target.value)} />
<input type="submit" value="Login" onClick={handleSubmit} />
</form>
</div>
);
};
export default Register;
+35
View File
@@ -0,0 +1,35 @@
import { h } from 'preact';
import { useState, useEffect } from 'preact/hooks';
import axios from 'axios';
const Register = () => {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('');
async function postRegister() {
const { data } = await axios.post(`http://localhost:41337/auth/register`, {
email,
password,
});
console.log(data);
}
function handleSubmit(e) {
e.preventDefault();
postRegister();
}
return (
<div class={'home'}>
<form action="" method="get" >
<label for="email">email: </label>
<input type="email" name="email" id="email" value={email} required onChange={(e) => setEmail(e.target.value)} />
<label for="password">password: </label>
<input type="password" name="password" id="password" value={password} required onChange={(e) => setPassword(e.target.value)} />
<input type="submit" value="Register" onClick={handleSubmit} />
</form>
</div>
);
};
export default Register;
+16 -23
View File
@@ -1,9 +1,7 @@
import { h } from 'preact';
import { useState, useEffect } from 'preact/hooks';
import { connect } from 'unistore/preact';
import axios from 'axios';
import style from './style.less';
// import style from './style.less';
import actions from './../actions';
import Bundle from './../components/bundle';
@@ -14,31 +12,26 @@ interface Props {
}
const User = ({ userId }: Props) => {
const [offers, setOffers] = useState([]);
const [offers, setOffers] = useState([]);
useEffect(() => {
async function fetchOffers() {
const { data } = await axios.get(`http://localhost:41337/user/${userId}/offers`);
setOffers(data);
}
fetchOffers();
useEffect(() => {
actions.getOffers(userId).then(setOffers);
}, [userId]);
if (!offers.length) {
return (
<div class={'home'}>
loading...
</div>
);
}
if (!offers.length) {
return (
<div class={'home'}>
loading...
</div>
);
}
return (
<div class={'home'}>
{offers.map(Bundle)}
</div>
);
<div class={'home'}>
{offers.map(Bundle)}
</div>
);
};
export default connect(['offers'], actions)(User);
export default User;
+21
View File
@@ -0,0 +1,21 @@
version: "3.3"
services:
pg:
image: postgres:latest
environment:
POSTGRES_USER: 'mcrates'
POSTGRES_DB: 'test'
POSTGRES_PASSWORD: 'digdeep'
api:
image: node:16
working_dir: /opt/mcrates/api
entrypoint: sleep 10000
depends_on:
- pg
environment:
DATABASE_URL: "postgresql://mcrates:digdeep@pg:5432/test?schema=public"
volumes:
- "./api:/opt/mcrates/api"
+34
View File
@@ -0,0 +1,34 @@
version: "3.3"
services:
pg:
image: postgres:latest
environment:
POSTGRES_USER: 'mcrates'
POSTGRES_DB: 'mcrates'
POSTGRES_PASSWORD: 'digdeep'
ports:
- '45432:5432'
volumes:
- "./db:/var/lib/postgresql/data"
# api:
# image: node:16
# working_dir: /opt/mcrates/api
# entrypoint: npm start
# environment:
# DATABASE_URL: 'postgresql://mcrates:digdeep@pg:5432/mcrates?schema=public'
# ports:
# - '41337:41337'
# volumes:
# - "./api:/opt/mcrates/api"
# client:
# image: node:16
# working_dir: /opt/mcrates/client
# entrypoint: npm start
# ports:
# - '8080:8080'
# volumes:
# - "./client:/opt/mcrates/client"
-13
View File
@@ -1,13 +0,0 @@
version: "3.3"
services:
pg:
image: postgres:latest
environment:
POSTGRES_USER: 'mcrates'
POSTGRES_DB: 'mcrates'
POSTGRES_PASSWORD: 'digdeep'
ports:
- '45432:5432'
volumes:
- "./db:/var/lib/postgresql/data"
-45
View File
@@ -1,45 +0,0 @@
import fs from 'fs/promises';
import _ from 'lodash';
import Bunyan from 'bunyan';
import { PrismaClient } from '@prisma/client'
import Promise from "bluebird";
import discogsService from './src/discogs';
import pgService from './src/pg';
import { logAxiosError } from './src/utils';
import createLog from './src/log';
import createApi from './src/api';
async function start(log) {
const prisma = new PrismaClient();
const pg = pgService(log, prisma);
const discogs = discogsService(log, pg);
const api = createApi(log, pg, discogs);
await api.listen();
}
async function run() {
const log = createLog();
process.on('SIGTERM', () => {
log.debug('SIGTERM received, exiting');
process.exit(0);
});
try {
const r = await start(log)
// log.info('done syncing', { totalListings: r.flat().length });
log.info(r);
setTimeout(run, 60 * 1000);
} catch (e) {
if (e.message === 'rate limited') {
log.info('rate limited, trying again in 60s');
return setTimeout(run, 60 * 1000);
}
log.error(e);
process.exit(1);
}
}
run();