Browse Source

Implement a user unsubscribe feature.

master
Zed A. Shaw 1 week ago
parent
commit
5cf274ad6c
  1. 37
      api/email.js
  2. 2
      api/login.js
  3. 16
      client/components/FormField.svelte
  4. 2
      client/config.js
  5. 69
      client/pages/Unsubscribe.svelte
  6. 23
      client/pages/UserProfile.svelte
  7. 2
      client/routes.js
  8. 3
      emails/config.js
  9. 3
      emails/register_email.html
  10. 2
      emails/register_email.txt
  11. 13
      lib/models.js
  12. 2
      lib/ormish.js
  13. 15
      migrations/20210911122326_user_unsubscribe.cjs
  14. 15
      scripts/unsubkeys.js
  15. 1
      tests/models/user.js

37
api/email.js

@ -0,0 +1,37 @@
import * as queues from "../lib/queues.js";
import logging from "../lib/logging.js";
import assert from "assert";
import { API } from "../lib/api.js";
import { User } from "../lib/models.js";
const log = logging.create(import.meta.url);
const unsubscribe_user = async (unsubkey) => {
const user = await User.first({unsubkey});
if(user && user.unsubscribe) {
// already unsubscribed so just ignore them
} else if(user && !user.unsubscribe) {
// not unsubscribed so change it
const res = await User.update({id: user.id}, {unsubscribe: true});
assert(res !== undefined, `failed to update unsubscribe for user ${user.id}`);
} else {
assert(false, `User unsubscribe key does not exist ${unsubkey}`);
}
}
export const get = async (req, res) => {
const api = new API(req, res);
const reply_data = { "message": "OK" }
try {
assert(req.query.unsubkey, "unsubkey is required");
await unsubscribe_user(req.query.unsubkey);
log.debug(reply_data);
api.reply(200, reply_data);
} catch (error) {
log.error(error);
api.error(500, error.message || "Internal Server Error");
}
}

2
api/login.js

@ -8,6 +8,7 @@ export const get = (req, res) => {
full_name: req.user.full_name,
admin: req.user.admin,
email: req.user.email,
unsubscribe: req.user.unsubscribe,
authenticated: true
}
res.status(200).json(reply);
@ -22,6 +23,7 @@ export const post = (req, res) => {
full_name: req.user.full_name,
admin: req.user.admin,
email: req.user.email,
unsubscribe: req.user.unsubscribe,
authenticated: true
}
res.status(200).json(reply);

16
client/components/FormField.svelte

@ -34,9 +34,22 @@
export let form = {};
export let field = "";
export let label=field;
/* Use for checkbox where the check needs to be "inline" with the label. */
export let inline = false;
</script>
{#if inline}
{#if form && !form.valid && form.errors[field]}
{#each form.errors[field] as error, i}
<error data-testid="{field}-error-{i}">
{ error }
</error>
{/each}
{/if}
<label for={ field }>
<slot></slot> { label }
</label>
{:else}
<label for={ field }>{ label }</label>
{#if form && !form.valid && form.errors[field]}
{#each form.errors[field] as error, i}
@ -46,3 +59,4 @@
{/each}
{/if}
<slot></slot>
{/if}

2
client/config.js

@ -22,3 +22,5 @@ export const btcpay_url = 'https://pay.learnjsthehardway.com/modal/btcpay.js';
export const course_id = 1;
export const base_host = "https://xor.academy"
export const support_email = "help@xor.academy";

69
client/pages/Unsubscribe.svelte

@ -0,0 +1,69 @@
<script>
import { link } from 'svelte-spa-router';
import { onMount } from 'svelte';
import Layout from '$/client/Layout.svelte';
import api from "$/client/api.js";
import Spinner from "$/client/components/Spinner.svelte";
import { support_email } from "$/client/config.js";
export let params = {};
let success;
let reason;
onMount(async () => {
const [status, data] = await api.get(`/api/email?unsubkey=${params.unsubkey}`);
if(status === 200) {
success = true;
} else {
success = false;
reason = data.message;
}
});
</script>
<style>
card top {
background-color: var(--color-bg-secondary);
text-align: center;
}
card {
width: 600px;
}
</style>
<Layout centered={ true }>
<card>
<top>
{#if success === true}
<h1>You Are Now Unsubscribed</h1>
{:else if success === false}
<h1>Unsubscribe FAILED</h1>
{/if}
</top>
<middle>
{#if success === true}
<p>Your unsubscribe request will prevent this site from sending you marketing
emails but system emails will still be delivered. System emails are:</p>
<ol>
<li>Password Reset Emails</li>
<li>Password Change Notifications</li>
<li>System Outage Notices</li>
<li>Security Related Requests</li>
</ol>
<p>You don't need to do anything else, but if you change your mind go to <a href="/user/profile/" use:link>your User Profile</a> and click the checkbox to enable emails again.</p>
{:else if success === false}
<p>Your unsubscribe failed. The reason given is { reason }. If you think this is an error then please contact <a href="mailto:{support_email}">{ support_email }</a> with your email and the unsubscribe code { params.unsubkey } so we can figure out what went wrong.
</p>
{:else}
<Spinner aspect_ratio="16/9" />
{/if}
</middle>
<bottom>
<button-group>
<button><a href="/login" use:link>Login</a></button>
</button-group>
</bottom>
</Layout>

23
client/pages/UserProfile.svelte

@ -14,6 +14,7 @@
full_name: $user.full_name,
password: "",
password_repeat: "",
unsubscribe: $user.unsubscribe,
valid: true,
errors: {}
};
@ -37,12 +38,18 @@
initials: form.initials,
full_name: form.full_name,
password: form.password,
password_repeat: form.password_repeat
password_repeat: form.password_repeat,
unsubscribe: form.unsubscribe
}, api.logout_user);
if(status == 200) {
// TODO: update the internal user information from the server?
Object.assign($user, {email: form.email, initials: form.initials, full_name: form.full_name});
Object.assign($user, {
email: form.email,
initials: form.initials,
full_name: form.full_name,
unsubscribe: form.unsubscribe
});
notice = "User profile updated.";
} else if(status == 401) {
push("/login/");
@ -56,6 +63,13 @@
}
</script>
<style>
input#unsubscribe {
height: 2rem;
width: 2rem;
}
</style>
<Layout centered={ true } authenticated={ true }>
<form action="/api/register" method="POST">
<card>
@ -88,6 +102,10 @@
<FormField form={ form } field="password_repeat" label="Password (repeat)">
<input type="password" id="password_repeat" bind:value={ form.password_repeat } name="password_repeat">
</FormField>
<FormField form={ form } field="unsubscribe" label="Unsubscribe Emails" inline={ true }>
<input type="checkbox" id="unsubscribe" bind:checked={ form.unsubscribe } name="unsubscribe">
</FormField>
</middle>
<bottom>
@ -99,4 +117,3 @@
</card>
</form>
</Layout>

2
client/routes.js

@ -8,6 +8,7 @@ import UserProfile from './pages/UserProfile.svelte';
import ResetPassword from './pages/ResetPassword.svelte';
import Module from './pages/Module.svelte';
import Lesson from './pages/Lesson.svelte';
import Unsubscribe from './pages/Unsubscribe.svelte';
import Home from './pages/Home.svelte';
import NotFound from './pages/NotFound.svelte';
/* #if process.env.DANGER_ADMIN
@ -26,6 +27,7 @@ export default {
'/purchase/': Purchase,
'/module/:module_id/': Module,
'/lesson/:lesson_id/': Lesson,
'/email/unsubscribe/:unsubkey/': Unsubscribe,
'/admin/table/create/:table/': AdminCreate,
'/admin/table/:table/': AdminTable,
'/admin/table/:table/:row_id/': AdminReadUpdate,

3
emails/config.js

@ -12,5 +12,6 @@ export const company = {
mail: 'XOR Academy <noreply@xor.academy>',
website: 'xor.academy',
credit_line: "XOR",
forum: "talk.xor.academy"
forum: "talk.xor.academy",
unsubscribe: "https://xor.academy/client/#/email/unsubscribe/",
}

3
emails/register_email.html

@ -494,7 +494,7 @@ body {
</td>
</tr>
</table>
<p>If you have any questions, feel free to <a href="mailto:help@<%- company.website %>/a>. (We're lightning quick at replying.) We also offer <a href="https://talk.<%- company.website %>">a help forum</a>.</p>
<p>If you have any questions, feel free to <a href="mailto:help@<%- company.website %>/a>. (We're lightning quick at replying.) We also offer <a href="<%- company.forum %>">a help forum</a>.</p>
<p>Thanks,
<br><%- company.owner %> and the <em><%- company.product %></em> Team</p>
<p><strong>P.S.</strong> Need immediate help getting started? Check out our forum <a href="https://<%- company.forum %>"><%- company.forum %>/</a>. Or, just reply to this email, the <em><%- company.product %></em> support team is always ready to help!</p>
@ -525,6 +525,7 @@ body {
<br><%- company.city %>, <%- company.state %>
<br><%- company.zip_code %>
</p>
<p class="f-fallback sub align-center"><a href="<%- company.unsubscribe %><%- user.unsubkey %>">Unsubscribe</a></p>
</td>
</tr>
</table>

2
emails/register_email.txt

@ -33,3 +33,5 @@ Copyright (C) <%- company.copyright_date %> <%- company.owner %>. All rights res
<%- company.street %>
<%- company.city %>, <%- company.state %>
<%- company.zip_code %>
Unsubscribe: <%- company.unsubscribe %><%- user.unsubkey %>/

13
lib/models.js

@ -3,8 +3,11 @@ import bcrypt from 'bcryptjs';
import {v4 as uuid} from "uuid";
import logging from "./logging.js";
import assert from "assert";
import crypto from "crypto";
const log = logging.create("lib/models.js");
export const RESET_CODE_SIZE = 8; // how big to make the unique email reset code
export const UNSUB_CODE_SIZE = 16; // how big to make the unique email unsubscribe code
export class User extends Model.from_table('user') {
@ -29,7 +32,7 @@ export class User extends Model.from_table('user') {
return good ? user : undefined;
}
} catch(error) {
console.error(error);
log.error(error);
return undefined;
}
}
@ -48,11 +51,12 @@ export class User extends Model.from_table('user') {
let exists = await User.first({email: user.email});
if(exists) {
console.error("User exists", user.email);
log.error("User exists", user.email);
return undefined;
} else {
// BUG: accepting unfiltered user input
user.password = User.encrypt_password(user.password);
user.unsubkey = User.random_hex(UNSUB_CODE_SIZE);
let res = await knex('user').insert(user);
user.id = res[0];
@ -61,6 +65,11 @@ export class User extends Model.from_table('user') {
}
}
static random_hex(size) {
assert(size, `random_hex size can't be falsy ${ size }`);
return crypto.randomBytes(size).toString("hex");
}
static encrypt_password(password) {
assert(password, `Password cannot be falsy: ${ password }`);
let salt = bcrypt.genSaltSync(10);

2
lib/ormish.js

@ -126,7 +126,7 @@ export class Model {
}
// returns the id of the row or undefined
return result !== undefined ? result[0] : undefined;;
return result !== undefined ? result[0] : undefined;
}
static async update(where, what) {

15
migrations/20210911122326_user_unsubscribe.cjs

@ -0,0 +1,15 @@
exports.up = async (knex) => {
await knex.schema.alterTable('user', (table) => {
table.string("unsubkey", 16).defaultTo("").notNullable();
table.boolean("unsubscribe").defaultTo(false).notNullable();
table.datetime("unsubscribed_on");
});
}
exports.down = async (knex) => {
return await knex.schema.alterTable("user", (table) => {
table.dropColumn('unsubkey');
table.dropColumn("unsubscribe");
table.dropColumn("unsubscribed_on");
})
}

15
scripts/unsubkeys.js

@ -0,0 +1,15 @@
import crypto from "crypto";
import { User } from "../lib/models.js";
import { knex } from "../lib/ormish.js";
const update = async () => {
for(let u of await User.all({unsubscribe: false, unsubkey: ""})) {
const key = crypto.randomBytes(16).toString("hex");
console.log(u.id, key);
await User.update({id: u.id}, {unsubkey: key});
}
}
await update();
knex.destroy();

1
tests/models/user.js

@ -11,6 +11,7 @@ test('test user model basics work', async (t) => {
let test1 = await User.register(user);
t.not(test1, undefined);
t.not(test1.unsubkey, undefined);
t.is(test1.full_name, user.full_name);
// save the id to delete later
user.id = test1.id;

Loading…
Cancel
Save