Init reactors.

This commit is contained in:
2023-02-27 19:41:05 -08:00
parent 60eac45175
commit 0c0e29947a
12 changed files with 857 additions and 4 deletions

View File

@@ -0,0 +1,36 @@
import Link from 'next/link';
import { redirect } from 'next/navigation';
import { cookies } from 'next/headers';
import db from '@/db';
async function getSession() {
const cookieStore = cookies();
const sessionId = cookieStore.get('session');
if (!sessionId) {
return false;
}
const { user_id: userId } = await db.get('select user_id from sessions where session_id=?;', sessionId.value);
if (!userId) {
return false;
}
return userId;
};
export default async function Layout({ children }) {
const userId = await getSession();
if (!userId) {
redirect('/reactors');
}
return (
<>
signed in as: {userId}
<nav>
<ul>
<Link href="/reactors/sign-out">sign out</Link>
</ul>
</nav>
</>
);
};

View File

@@ -0,0 +1,33 @@
import { cookies } from 'next/headers';
import db from '@/db';
import { Container } from '@/components/Container';
async function getSession() {
const cookieStore = cookies();
const sessionId = cookieStore.get('session');
if (!sessionId) {
return false;
}
const { user_id: userId } = await db.get('select user_id from sessions where session_id=?;', sessionId.value);
if (!userId) {
return false;
}
return userId;
};
export default async function Page() {
const userId = await getSession();
return (
<div className="pt-16 pb-12 sm:pb-4 lg:pt-12">
<Container>
<h1 className="text-2xl font-bold leading-7 text-slate-900">
The Reactors
</h1>
<a href="https://buy.stripe.com/test_3cs01j768d65gso289">Level I</a>
{userId && <p>user: {userId}</p>}
</Container>
</div>
);
};

View File

@@ -0,0 +1,142 @@
export const dynamic = 'force-dynamic';
import Link from 'next/link'
import Stripe from 'stripe';
const stripe = new Stripe('sk_test_51MVz87Ke2JFOuDSNa2PVPrs3BBq9vJQwwDITC3sOB521weM4oklKtQFbJ03MNsJwsxtjHO5NScqOHC9MABREVjU900yYz3lWgL');
import { dbRun } from '@/db';
import { XCircleIcon } from '@heroicons/react/20/solid'
import { Container } from '@/components/Container';
// /reactors/create-account?csi=cs_test_a1pBB0FI8GUKnWYlCVn0RKUYXV8FRroacXjI5WVhWPlFJilm46lZwdjgac
export default async function Page({ searchParams }) {
const unexpectedError = searchParams['unexpected_error'];
const msg = searchParams['msg'];
const csi = searchParams['csi'];
const session = csi && await stripe.checkout.sessions.retrieve(csi);
const email = (csi && session && session.customer_details.email) || searchParams['email'];
const message = searchParams['message'];
const submitted = email || message;
const valid = submitted && email && message;
let emailSentSuccessfully = false;
if (unexpectedError) {
return (
<>
Unexpected Error sorry about that! Please contact us via <Link href="/contact">Contact</Link> and we will get it figured out!
</>
);
}
return (
<div className="pt-16 pb-12 sm:pb-4 lg:pt-12">
<Container>
<h1 className="text-2xl font-bold leading-7 text-slate-900">
The Reactors - Create Account
</h1>
<div className="divide-y divide-slate-100 sm:mt-4 lg:mt-8 lg:border-t lg:border-slate-100">
<p>
Thank you so much for signing up to become a Reactor! We just need a password now to create an account for you!
</p>
{msg && (
<div className="rounded-md bg-red-50 p-4 mt-8">
<div className="flex">
<div className="flex-shrink-0">
<XCircleIcon className="h-5 w-5 text-red-400" aria-hidden="true" />
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-red-800">There was an error with your submission</h3>
<div className="mt-2 text-sm text-red-700">
{msg}
</div>
</div>
</div>
</div>
)}
<form className="space-y-8" method="POST" action="/api/create-account">
<input
name="csi"
value={csi}
type="hidden"
/>
<div className="space-y-8">
<div className="pt-8">
<div className="mt-6 grid grid-cols-1 gap-y-6 gap-x-4 sm:grid-cols-6">
<div className="sm:col-span-3">
<label htmlFor="emailprefilled" className="block text-sm font-medium text-gray-700">
Email address
</label>
<div className="mt-1">
<input
id="emailprefilled"
name="emailprefilled"
type="email"
defaultValue={email}
disabled
title="Email Address (required)"
className="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
/>
<input
type="hidden"
name="email"
value={email}
/>
</div>
</div>
</div>
<div className="mt-6 grid grid-cols-1 gap-y-6 gap-x-4 sm:grid-cols-6">
<div className="sm:col-span-3">
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
Password
</label>
<div className="mt-1">
<input
id="password"
name="password"
type="password"
required
minlength="12"
title="Password (required)"
className="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
/>
</div>
</div>
<div className="sm:col-span-3">
<label htmlFor="passwordagain" className="block text-sm font-medium text-gray-700">
Password (again)
</label>
<div className="mt-1">
<input
id="passwordagain"
name="passwordagain"
type="password"
required
minlength="12"
title="Password (required)"
className="block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
/>
</div>
</div>
</div>
</div>
</div>
<div className="pt-5">
<div className="flex justify-end">
<button
type="submit"
className="ml-3 inline-flex justify-center rounded-md border border-transparent bg-indigo-600 py-2 px-4 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
>
Create Account
</button>
</div>
</div>
</form>
</div>
</Container>
</div>
);
};

View File

@@ -0,0 +1,14 @@
import { Container } from '@/components/Container';
export default async function Page() {
return (
<div className="pt-16 pb-12 sm:pb-4 lg:pt-12">
<Container>
<h1 className="text-2xl font-bold leading-7 text-slate-900">
The Reactors
</h1>
<a href="https://buy.stripe.com/test_3cs01j768d65gso289">Level I</a>
</Container>
</div>
);
};

View File

@@ -0,0 +1,142 @@
export const dynamic = 'force-dynamic';
import Link from 'next/link'
import Stripe from 'stripe';
const stripe = new Stripe('sk_test_51MVz87Ke2JFOuDSNa2PVPrs3BBq9vJQwwDITC3sOB521weM4oklKtQFbJ03MNsJwsxtjHO5NScqOHC9MABREVjU900yYz3lWgL');
import { dbRun } from '@/db';
import { XCircleIcon } from '@heroicons/react/20/solid'
import { Container } from '@/components/Container';
// /reactors/create-account?csi=cs_test_a1pBB0FI8GUKnWYlCVn0RKUYXV8FRroacXjI5WVhWPlFJilm46lZwdjgac
export default async function Page({ searchParams }) {
const unexpectedError = searchParams['unexpected_error'];
const msg = searchParams['msg'];
const csi = searchParams['csi'];
const session = csi && await stripe.checkout.sessions.retrieve(csi);
const email = (csi && session && session.customer_details.email) || searchParams['email'];
const message = searchParams['message'];
const submitted = email || message;
const valid = submitted && email && message;
let emailSentSuccessfully = false;
if (unexpectedError) {
return (
<>
Unexpected Error sorry about that! Please contact us via <Link href="/contact">Contact</Link> and we will get it figured out!
</>
);
}
return (
<div className="pt-16 pb-12 sm:pb-4 lg:pt-12">
<Container>
<h1 className="text-2xl font-bold leading-7 text-slate-900">
The Reactors - Sign In
</h1>
<div className="divide-y divide-slate-100 sm:mt-4 lg:mt-8 lg:border-t lg:border-slate-100">
{msg && (
<div className="rounded-md bg-red-50 p-4 mt-8">
<div className="flex">
<div className="flex-shrink-0">
<XCircleIcon className="h-5 w-5 text-red-400" aria-hidden="true" />
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-red-800">There was an error with your submission</h3>
<div className="mt-2 text-sm text-red-700">
{msg}
</div>
</div>
</div>
</div>
)}
<div className="flex min-h-full flex-col justify-center py-12 sm:px-6 lg:px-8">
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<img
className="mx-auto h-12 w-auto"
src="https://tailwindui.com/img/logos/mark.svg?color=indigo&shade=600"
alt="Your Company"
/>
<h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-gray-900">Sign in to your account</h2>
<p className="mt-2 text-center text-sm text-gray-600">
Or{' '}
<Link href='/reactors' className="font-medium text-indigo-600 hover:text-indigo-500">
sign up to become a Reactor!
</Link>
</p>
</div>
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div className="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10 bg-gray-50">
<form className="space-y-6" method="POST" action="/api/sign-in">
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
Email address
</label>
<div className="mt-1">
<input
id="email"
name="email"
type="email"
autoComplete="email"
defaultValue={email}
required
className="block w-full appearance-none rounded-md border border-gray-300 px-3 py-2 placeholder-gray-400 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-indigo-500 sm:text-sm"
/>
</div>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
Password
</label>
<div className="mt-1">
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
className="block w-full appearance-none rounded-md border border-gray-300 px-3 py-2 placeholder-gray-400 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-indigo-500 sm:text-sm"
/>
</div>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center">
<input
id="remember_me"
name="remember_me"
type="checkbox"
className="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
defaultValue="checked"
/>
<label htmlFor="remember_me" className="ml-2 block text-sm text-gray-900">
Remember me
</label>
</div>
<div className="text-sm">
<a href="#" className="font-medium text-indigo-600 hover:text-indigo-500">
Forgot your password?
</a>
</div>
</div>
<div>
<button
type="submit"
className="flex w-full justify-center rounded-md border border-transparent bg-indigo-600 py-2 px-4 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
>
Sign in
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</Container>
</div>
);
};

View File

@@ -228,14 +228,15 @@ export async function getEpisodes() {
length: enclosure['@_length']
},
content: feedEntry['content:encoded'],
chapters: feedEntry['podcast:chapters'] && feedEntry['podcast:chapters']['@_url']
chapters: feedEntry['podcast:chapters'] && feedEntry['podcast:chapters']['@_url'],
duration: feedEntry['itunes:duration']
}
}
})
const numEpisodes = feed.entries.length;
const feedEntries = feed.entries.map(
({ id, title, description, enclosure , published, content, chapters }, i) => ({
({ id, title, description, enclosure , published, content, chapters, duration }, i) => ({
num: numEpisodes - i,
id,
title,
@@ -249,6 +250,8 @@ export async function getEpisodes() {
audio: [enclosure].map((enclosure) => ({
src: enclosure.url,
type: enclosure.type,
length: enclosure.length,
duration
}))[0],
})
);

119
src/db.js Normal file
View File

@@ -0,0 +1,119 @@
const sqlite3 = require('sqlite3');
const util = require('util');
const migrations = [
{
key: 1,
name: 'create table users',
sql: [`create table users (
id integer primary key autoincrement,
email text not null,
salt text not null,
password_hash text not null
)`]
},
{
key: 3,
name: 'create table sessions',
sql: [`create table sessions (
id integer primary key autoincrement,
user_id integer not null,
session_id text not null,
expires integer not null,
foreign key (user_id) references users (id)
)`]
},
{
key: 4,
name: 'create table episodes',
sql: [`create table episodes (
id integer primary key autoincrement,
number integer,
content text,
summary text,
slug text,
season integer,
episode integer,
duration integer,
filename text,
title text,
episode_type text,
buzzsprout_id text,
buzzsprout_url text,
pub_date text,
youtube_url text,
transcript_filename text
)`]
}
];
const checkForMigrationsSql = `select key from migrations where run='True' order by key`;
async function runMigrations(db) {
console.log('turn on foreign keys');
await db.exec('PRAGMA foreign_keys = ON;');
console.log('running migrations');
const rows = await db.all(checkForMigrationsSql)
const runMigrations = rows.map(({ key }) => key);
console.log(runMigrations);
let toRun = [];
migrations.forEach(({ key, name, sql }) => {
if (!runMigrations.includes(key)) {
toRun.push({ key, name, sql });
}
});
console.log('Migrations to run:', toRun.map(({ name }) => name));
await db.exec(toRun.reduce((prev, { sql, key }) => `${prev} ${sql.join(';')} ; insert into migrations (key, run) values (${key}, 'True') ;`, ''));
console.log('migrations run');
/* db.all(checkForMigrationsSql, (err, rows) => {
* console.log('xx')
* const runMigrations = rows.map(({ key }) => key);
* console.log(runMigrations);
* let toRun = [];
* migrations.forEach(({ key, name, sql }) => {
* if (!runMigrations.includes(key)) {
* toRun.push({ key, name, sql });
* }
* });
* console.log('Migrations to run:', toRun.map(({ name }) => name));
* db.exec(toRun.reduce((prev, { sql, key }) => `${prev} ${sql.join(';')} ; insert into migrations (key, run) values (${key}, 'True') ;`, ''), () => {
* console.log('migrations run');
* });
* }); */
};
const createMigrationTable = `create table migrations (
id integer primary key autoincrement,
key integer not null,
run boolean not null
)`;
let db = new sqlite3.Database('./db.sqlite3', sqlite3.OPEN_READWRITE, async (err) => {
if (err && err.code == "SQLITE_CANTOPEN") {
db = new sqlite3.Database('./db.sqlite3', async (err) => {
if (err) {
console.log("Getting error " + err);
}
console.log('database created');
console.log('creating migration table')
db.exec(createMigrationTable, async () => {
await runMigrations(db);
});
});
if (err) {
console.error(err.message);
}
} else if (err) {
console.error(err.message);
} else {
console.log('Connected to the database.');
await runMigrations(db);
}
});
db.run = util.promisify(db.run);
db.get = util.promisify(db.get);
db.all = util.promisify(db.all);
db.exec = util.promisify(db.exec);
export default db;

View File

@@ -0,0 +1,64 @@
import Stripe from 'stripe';
const stripe = new Stripe('sk_test_51MVz87Ke2JFOuDSNa2PVPrs3BBq9vJQwwDITC3sOB521weM4oklKtQFbJ03MNsJwsxtjHO5NScqOHC9MABREVjU900yYz3lWgL');
import db from '@/db';
import { scrypt, randomBytes, timingSafeEqual } from 'crypto';
import { promisify } from 'util';
const scryptPromise = promisify(scrypt);
function genSalt(bytes = 16) {
return randomBytes(bytes).toString('hex');
};
async function hash(salt, password, rounds = 64) {
const derivedKey = await scryptPromise(password, salt, rounds);
return derivedKey.toString('hex')
}
async function verify(password, hash, salt, rounds = 64) {
const keyBuffer = Buffer.from(hash, 'hex');
const derivedKey = await scryptPromise(password, salt, rounds);
return timingSafeEqual(keyBuffer, derivedKey);
}
function makeMsg(csi, email, text) {
return `/reactors/create-account?csi=${csi}&msg=${encodeURIComponent(text)}&email=${encodeURIComponent(email)}`
};
export default async function handler(req, res) {
if (req.method === 'POST') {
const { email, password, passwordagain, csi } = req.body;
if (email && password && password === passwordagain && csi) {
const session = csi && await stripe.checkout.sessions.retrieve(csi);
const emailFromSession = session && session.customer_details.email;
if (!session || !emailFromSession || email !== emailFromSession) {
res.redirect('/reactors/create-account?unexpected_error=true');
}
const existingUser = await db.get('select id from users where email=?', email);
if (existingUser) {
res.redirect('/reactors/create-account?unexpected_error=true');
}
console.log('inserting user');
const salt = genSalt();
const hashRes = await hash(salt, password);
await db.run('insert into users (email, salt, password_hash) values (?, ?, ?);', email, salt, hashRes);
console.log('done inserting user');
res.redirect('/reactors')
} else {
if (!email || !csi) {
res.redirect('/reactors/create-account?unexpected_error=true');
}
if (!password) {
res.redirect(makeMsg(csi, email, 'Please enter a password'));
}
if (password !== passwordagain) {
res.redirect(makeMsg(csi, email, 'Passwords did not match. Please try again.'));
}
}
} else {
// Handle any other HTTP method
}
}

95
src/pages/api/feed.rss.js Normal file
View File

@@ -0,0 +1,95 @@
import path from 'path';
import fs from 'fs';
import db from '@/db';
import { Podcast } from 'podcast';
/* import mp3Duration from 'mp3-duration'; */
import { getEpisodes } from '@/data/episodes';
export default async function handler(req, res) {
if (req.method === 'GET') {
const feed = new Podcast({
title: 'The React Show (Reactors)',
description: "Discussions about React, JavaScript, and web development by React experts with a focus on diving deep into learning React and discussing what it's like to work within the React industry.",
feedUrl: 'https://www.thereactshow.com/api/feed.rss',
siteUrl: 'https://www.thereactshow.com',
imageUrl: 'https://storage.buzzsprout.com/variants/d1tds1rufs5340fyq9mpyzo491qp/5cfec01b44f3e29fae1fb88ade93fc4aecd05b192fbfbc2c2f1daa412b7c1921.jpg',
author: 'Owl Creek Studio',
copyright: '© 2023 Owl Creek Studio',
language: 'en',
categories: ['Technology','Education','Business'],
pubDate: 'May 20, 2012 04:00:00 GMT',
ttl: 60,
itunesAuthor: 'Owl Creek Studio',
itunesOwner: { name: 'Owl Creek Studio' },
itunesExplicit: false,
itunesCategory: [{
text: 'Technology'
},
{
text: 'Education'
},
{
text: 'Business'
}],
itunesImage: 'https://storage.buzzsprout.com/variants/d1tds1rufs5340fyq9mpyzo491qp/5cfec01b44f3e29fae1fb88ade93fc4aecd05b192fbfbc2c2f1daa412b7c1921.jpg'
});
const episodes = await getEpisodes();
episodes.forEach(({ title, published, description, content, slug, audio: { src, length }, num, id, youtube }) => {
const filename = `0${num}-mixed.mp3`; // TODO auto-add the 0 prefix
const filepath = path.join(process.cwd(), 'public', 'files', 'episodes', filename);
if (fs.existsSync(filepath)) {
feed.addItem({
title,
description: content,
content,
url: `https://www.thereactshow.com/podcast/${slug}`,
date: published,
itunesExplicit: false,
itunesSummary: description,
/* itunesDuration: await mp3Duration(filepath), TODO */
itunesDuration: 1234,
enclosure : {
url: `https://www.thereactshow.com/files/episodes/${filename}`,
file: filepath
},
});
}
})
const dbUpdates = episodes.map(async ({ title, published, description, content, slug, audio: { src, length }, num, id, youtube }) => {
const filename = `0${num}-mixed.mp3`; // TODO auto-add the 0 prefix
const filepath = path.join(process.cwd(), 'public', 'files', 'episodes', filename);
if (fs.existsSync(filepath)) {
const existsInDb = await db.get('select id from episodes where number=?', num);
if (!existsInDb) {
console.log('adding to db');
await db.run('insert into episodes (number, content, summary, slug, season, episode, filename, title, episode_type, buzzsprout_id, buzzsprout_url, pub_date, youtube_url) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);',
num,
content,
description,
slug,
1,
num,
filename,
title,
'episodic',
id,
src,
published,
youtube);
console.log('added to db', num);
}
}
})
await Promise.all(dbUpdates);
const xml = feed.buildXml();
res.setHeader('Content-Type', 'text/xml; charset=utf-8');
res.send(xml);
}
};

53
src/pages/api/sign-in.js Normal file
View File

@@ -0,0 +1,53 @@
import Stripe from 'stripe';
const stripe = new Stripe('sk_test_51MVz87Ke2JFOuDSNa2PVPrs3BBq9vJQwwDITC3sOB521weM4oklKtQFbJ03MNsJwsxtjHO5NScqOHC9MABREVjU900yYz3lWgL');
import { setCookie } from 'cookies-next';
import { v4 as uuidv4 } from 'uuid';
import db from '@/db';
import { scrypt, randomBytes, timingSafeEqual } from 'crypto';
import { promisify } from 'util';
const scryptPromise = promisify(scrypt);
async function verify(password, hash, salt, rounds = 64) {
const keyBuffer = Buffer.from(hash, 'hex');
const derivedKey = await scryptPromise(password, salt, rounds);
return timingSafeEqual(keyBuffer, derivedKey);
}
function makeMsg(email, text) {
return `/reactors/sign-in?msg=${encodeURIComponent(text)}&email=${encodeURIComponent(email)}`
};
export default async function handler(req, res) {
if (req.method === 'POST') {
const { email, password, remember_me: rememberMe } = req.body;
if (email && password) {
const queryRes = await db.get('select id, salt, password_hash from users where email=?;', email);
const { password_hash, salt, id: userId } = queryRes || { password_hash: '', salt: '', id: '' };
const verifyRes = await verify(password, password_hash, salt);
if (verifyRes) {
const sessionId = uuidv4();
const maxAge = 60 * 60 * 24 * 365;
const today = new Date();
const expiresDate = new Date(today.getTime() + (1000 * maxAge));
await db.run('insert into sessions (user_id, session_id, expires) values (?, ?, ?);', userId, sessionId, expiresDate.toISOString());
setCookie('session', sessionId, { req, res, maxAge: rememberMe ? maxAge : undefined, httpOnly: true, sameSite: true, secure: process.env.NODE_ENV === 'production' });
res.redirect('/reactors/account')
} else {
res.redirect(makeMsg(email, 'Invalid password or account does not exist.'));
}
} else {
if (!email) {
res.redirect(makeMsg(email, 'Please enter an email address.'));
}
if (!password) {
res.redirect(makeMsg(email, 'Please enter a password.'));
}
}
} else {
// Handle any other HTTP method
}
}