|
|
@ -7,11 +7,49 @@ import crypto from "crypto"; |
|
|
|
|
|
|
|
|
|
|
|
const log = logging.create("lib/models.js"); |
|
|
|
const log = logging.create("lib/models.js"); |
|
|
|
|
|
|
|
|
|
|
|
export const RESET_CODE_SIZE = 8; // how big to make the unique email reset code
|
|
|
|
/* Number of bytes in the reset code sent. */ |
|
|
|
export const UNSUB_CODE_SIZE = 16; // how big to make the unique email unsubscribe code
|
|
|
|
export const RESET_CODE_SIZE = 8; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/* Bytes in the unique unsubscribe code. */ |
|
|
|
|
|
|
|
export const UNSUB_CODE_SIZE = 16; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/* |
|
|
|
|
|
|
|
The `User` model contains the majority of settings for the users for the site. |
|
|
|
|
|
|
|
It should contain almost everything you need, and won't change much. If you want |
|
|
|
|
|
|
|
to add more options and settings I recommend either create a `knex` migration to |
|
|
|
|
|
|
|
change it, or add a `Model.has_one` relation that uses a second table for additional |
|
|
|
|
|
|
|
features. |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+ [user](/admin/#/table/user) - admin this table |
|
|
|
|
|
|
|
*/ |
|
|
|
export class User extends Model.from_table('user') { |
|
|
|
export class User extends Model.from_table('user') { |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/* |
|
|
|
|
|
|
|
Authenticates the user based on their `username` and `password`. |
|
|
|
|
|
|
|
The `username` can be anything, but I've chosen email since that seems |
|
|
|
|
|
|
|
to be the most common unique username method. |
|
|
|
|
|
|
|
It first finds that user, then it uses the [bcryptjs](https://www.npmjs.com/package/bcryptjs)
|
|
|
|
|
|
|
|
or [bcrypt](https://www.npmjs.com/package/bcrypt) modules.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
### bcrypt vs bcryptjs |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
By default this code uses [bcryptjs](https://www.npmjs.com/package/bcryptjs) as that has
|
|
|
|
|
|
|
|
lower installation requirements. The big problem with all C++ based modules is the |
|
|
|
|
|
|
|
fagility of `node-gyp` and `node-pre-gyp`. If you need the speed then change the import |
|
|
|
|
|
|
|
above to use `bcrypt` instead of `bcryptjs`, but be prepared to have possible errors |
|
|
|
|
|
|
|
randomly when you install it. |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
### Usage |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
```javascript
|
|
|
|
|
|
|
|
const authenticated = await User.auth("help@learnjsthehardway.com", "notmypassword"); |
|
|
|
|
|
|
|
``` |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+ `username String` -- The "username" which is currently the `user.email` field. |
|
|
|
|
|
|
|
+ `password String` -- The password as the user types it. _DO NOT STORE THIS._ |
|
|
|
|
|
|
|
+ ___return___ `User` or `undefined` if the user is not found or not authenticated |
|
|
|
|
|
|
|
*/ |
|
|
|
static async auth(username, password) { |
|
|
|
static async auth(username, password) { |
|
|
|
try { |
|
|
|
try { |
|
|
|
const uname = username.toLowerCase(); |
|
|
|
const uname = username.toLowerCase(); |
|
|
@ -38,13 +76,13 @@ export class User extends Model.from_table('user') { |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
/** |
|
|
|
/* |
|
|
|
* Performs all the cleanup and checks needed for a registration. It will |
|
|
|
Performs all the cleanup and checks needed for a registration. It will |
|
|
|
* ensure that the password and password_repeat are the same, lowercase the email, |
|
|
|
ensure that the password and password_repeat are the same, lowercase the email, |
|
|
|
* clean out unwanted attrbiutes with User.clean, generate required keys, etc. |
|
|
|
clean out unwanted attrbiutes with User.clean, generate required keys, etc. |
|
|
|
* |
|
|
|
|
|
|
|
* @param {Object} attr - attributes usually from a web form |
|
|
|
+ `attr Object` - attributes usually from a web form |
|
|
|
* @return {Object} undefined or the new user on success |
|
|
|
+ ___return___ `Object`, `undefined` or the new user on success |
|
|
|
*/ |
|
|
|
*/ |
|
|
|
static async register(attr) { |
|
|
|
static async register(attr) { |
|
|
|
let user = undefined; |
|
|
|
let user = undefined; |
|
|
@ -76,17 +114,42 @@ export class User extends Model.from_table('user') { |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/* |
|
|
|
|
|
|
|
Uses the [crypt](https://nodejs.org/docs/latest-v18.x/api/crypto.html) module to generate a random hex string of
|
|
|
|
|
|
|
|
size `bytes`. |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+ `size Number` -- size of bytes, then converted to hex. |
|
|
|
|
|
|
|
+ ___return___ String |
|
|
|
|
|
|
|
*/ |
|
|
|
static random_hex(size) { |
|
|
|
static random_hex(size) { |
|
|
|
assert(size, `random_hex size can't be falsy ${ size }`); |
|
|
|
assert(size, `random_hex size can't be falsy ${ size }`); |
|
|
|
return crypto.randomBytes(size).toString("hex"); |
|
|
|
return crypto.randomBytes(size).toString("hex"); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/* |
|
|
|
|
|
|
|
Given a string for a password this will use the `bcrypt.hashSync` function |
|
|
|
|
|
|
|
to encrypt it. It generates the salt for each password it encrypts, which |
|
|
|
|
|
|
|
I believe is the correct way to do it. If that's not then someone should |
|
|
|
|
|
|
|
update the `bcrypt` docs. |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+ `password String` -- A string to password encrypt. |
|
|
|
|
|
|
|
+ ___return___ String` |
|
|
|
|
|
|
|
*/ |
|
|
|
static encrypt_password(password) { |
|
|
|
static encrypt_password(password) { |
|
|
|
assert(password, `Password cannot be falsy: ${ password }`); |
|
|
|
assert(password, `Password cannot be falsy: ${ password }`); |
|
|
|
let salt = bcrypt.genSaltSync(10); |
|
|
|
let salt = bcrypt.genSaltSync(10); |
|
|
|
return bcrypt.hashSync(password, salt); |
|
|
|
return bcrypt.hashSync(password, salt); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/* |
|
|
|
|
|
|
|
Changes the user's unsubscribe from email setting, to determine if you |
|
|
|
|
|
|
|
should email them or not. It uses the `User.unsubscribe` attribute, so |
|
|
|
|
|
|
|
`true` means they are ___not___ receiving emails, and `false` means they |
|
|
|
|
|
|
|
___will__ receive emails. |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+ `setting boolean` -- Whether they are unsubscribed or not. |
|
|
|
|
|
|
|
+ ___return___ `number` -- Count of records changed, should be 1. |
|
|
|
|
|
|
|
*/ |
|
|
|
async emails(setting) { |
|
|
|
async emails(setting) { |
|
|
|
// don't change it if it's already set this way
|
|
|
|
// don't change it if it's already set this way
|
|
|
|
if(this.unsubscribe !== setting) { |
|
|
|
if(this.unsubscribe !== setting) { |
|
|
@ -98,19 +161,39 @@ export class User extends Model.from_table('user') { |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
/* Users have many Payments, but Payment has only one User. */ |
|
|
|
/* Has many mapping where Users have many Payments, but Payment has only one User. */ |
|
|
|
async payments() { |
|
|
|
async payments() { |
|
|
|
return await this.has_many(Payment, { user_id: this.id }); |
|
|
|
return await this.has_many(Payment, { user_id: this.id }); |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/* |
|
|
|
|
|
|
|
Represents a payment in any system with Stripe, BTCPayServer, and Paypal |
|
|
|
|
|
|
|
currently supported. It contains `sys_primary_id` and `sys_secondary_id` |
|
|
|
|
|
|
|
fields for generic "id" fields that these services love to throw around, with |
|
|
|
|
|
|
|
an `internal_id` for our tracking. Every payment processor has different uses |
|
|
|
|
|
|
|
for the various IDs returned, so refer to the `/api/payments/` modules for how |
|
|
|
|
|
|
|
these are mapped. |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+ [payment](/admin/#/table/payment) - admin this table |
|
|
|
|
|
|
|
*/ |
|
|
|
export class Payment extends Model.from_table('payment') { |
|
|
|
export class Payment extends Model.from_table('payment') { |
|
|
|
|
|
|
|
|
|
|
|
/* Payment has only one User, but User has many Payments. */ |
|
|
|
/* Payment has only one User, but User has many Payments. */ |
|
|
|
get user() { |
|
|
|
get user() { |
|
|
|
return this.user_id !== undefined ? this.has_one(User, { id: this.user_id }) : undefined; |
|
|
|
return this.user_id !== undefined ? this.has_one(User, { id: this.user_id }) : undefined; |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/* |
|
|
|
|
|
|
|
Used in testing and debugging to create a fake payment with no specific |
|
|
|
|
|
|
|
system. Look in `tests/models/payment.js` to see how this works: |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
```javascript
|
|
|
|
|
|
|
|
let test1 = await Payment.fake_payment(); |
|
|
|
|
|
|
|
``` |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+ ___return___ A fake `Payment` object for testing. |
|
|
|
|
|
|
|
*/ |
|
|
|
static fake_payment() { |
|
|
|
static fake_payment() { |
|
|
|
return Payment.insert({ |
|
|
|
return Payment.insert({ |
|
|
|
system: 'fake', |
|
|
|
system: 'fake', |
|
|
@ -122,11 +205,49 @@ export class Payment extends Model.from_table('payment') { |
|
|
|
}); |
|
|
|
}); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/* |
|
|
|
|
|
|
|
Generates a random `uuid()` for use in the `internal_id` field. |
|
|
|
|
|
|
|
_WARNING_: It's not clear if the `uuid()` function is actually |
|
|
|
|
|
|
|
secure or not, so don't rely on this for any cryptography. |
|
|
|
|
|
|
|
*/ |
|
|
|
static gen_internal_id() { |
|
|
|
static gen_internal_id() { |
|
|
|
return uuid(); |
|
|
|
return uuid(); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/* |
|
|
|
|
|
|
|
Indicates whether the `user` has paid for the site's product. Keep in mind |
|
|
|
|
|
|
|
that this is using a model of a _single_ payment for access to the site. As |
|
|
|
|
|
|
|
with all of The Bandolier it's not a full featured payment system, but it is |
|
|
|
|
|
|
|
enough to get started. If you wanted to support subscriptions this would |
|
|
|
|
|
|
|
most likely work, but if you want to sell multiple products or create a full |
|
|
|
|
|
|
|
store then you've got some work to do. |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Another thing that this model won't support is the "merchant" experience, where |
|
|
|
|
|
|
|
you let other people sell their stuff on your site. That is the _most_ complex |
|
|
|
|
|
|
|
online business to run as it requires receiving money and giving money to other |
|
|
|
|
|
|
|
people. |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
### Study This |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
This contains a unique use of the `lib/ormish.js:Model.first` and `knex` to |
|
|
|
|
|
|
|
construct a complex query with a custom builder: |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
```javascript
|
|
|
|
|
|
|
|
// ignore refunded payments
|
|
|
|
|
|
|
|
const payment = await Payment.first(builder => { |
|
|
|
|
|
|
|
builder |
|
|
|
|
|
|
|
.whereNotIn("status", ["refunded", "failed", "pending"]) |
|
|
|
|
|
|
|
.where({user_id: user.id}) |
|
|
|
|
|
|
|
}, ["user_id", "system", "status", "status_reason"]); |
|
|
|
|
|
|
|
``` |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
This doesn't come up too often, but when you need it this little snippet |
|
|
|
|
|
|
|
is gold. The idea is that `Model.first` can accept anything that `knex` |
|
|
|
|
|
|
|
accepts, which includes a builder callback to create a refined query. |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+ `user User` - The user to confirm payment on. |
|
|
|
|
|
|
|
+ ___return__ `[true, Payment]` if paid or `[false, undefined]` if not paid. |
|
|
|
|
|
|
|
*/ |
|
|
|
static async paid(user) { |
|
|
|
static async paid(user) { |
|
|
|
assert(user !== undefined, "Invalid user given to Payment.paid"); |
|
|
|
assert(user !== undefined, "Invalid user given to Payment.paid"); |
|
|
|
assert(user.id !== undefined, "User object given has an user.id === undefined."); |
|
|
|
assert(user.id !== undefined, "User object given has an user.id === undefined."); |
|
|
@ -146,40 +267,118 @@ export class Payment extends Model.from_table('payment') { |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/* |
|
|
|
|
|
|
|
Simple `media` table for storing metadata on various media you |
|
|
|
|
|
|
|
will probably need to display. It's debatable whether this should |
|
|
|
|
|
|
|
be in the database or simply output to straight `.json` files. The |
|
|
|
|
|
|
|
database is easier to edit, but `.json` files are way easier to host |
|
|
|
|
|
|
|
and transmit. I may come up with a hybrid model eventually. |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+ [media](/admin/#/table/media) - admin this table |
|
|
|
|
|
|
|
*/ |
|
|
|
export class Media extends Model.from_table("media") { |
|
|
|
export class Media extends Model.from_table("media") { |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/* |
|
|
|
|
|
|
|
A useful simle key=value table for storing various settings |
|
|
|
|
|
|
|
and stats for the site. It contains a `set`, `get`, `increment`, and |
|
|
|
|
|
|
|
`decrement` methods for efficient(ish) counting events or |
|
|
|
|
|
|
|
actions. I think if you want to do this at significant scale |
|
|
|
|
|
|
|
get a time series database. For simple things this works. |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+ [site](/admin/#/table/site) - admin this table |
|
|
|
|
|
|
|
*/ |
|
|
|
export class Site extends Model.from_table("site") { |
|
|
|
export class Site extends Model.from_table("site") { |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/* |
|
|
|
|
|
|
|
Get the value for the given key. This is a `json` type |
|
|
|
|
|
|
|
in the database, so YMMV if your database isn't SQlite3. |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+ `key string` -- The key in the database. |
|
|
|
|
|
|
|
+ ___return___ `Object` -- Uses JSON.parse(), so not really tested on other databases. |
|
|
|
|
|
|
|
*/ |
|
|
|
static async get(key) { |
|
|
|
static async get(key) { |
|
|
|
const row = await knex(this.table_name).first().where({key}); |
|
|
|
const row = await knex(this.table_name).first().where({key}); |
|
|
|
return row !== undefined ? JSON.parse(row.value) : undefined; |
|
|
|
return row !== undefined ? JSON.parse(row.value) : undefined; |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/* |
|
|
|
|
|
|
|
Set a key to a value, which is a generic JSON object type. This is |
|
|
|
|
|
|
|
the inverse of `get()`. You really need to think what goes in here. |
|
|
|
|
|
|
|
If it's very large you'll probably want to look at a document database |
|
|
|
|
|
|
|
or store it on the disk. |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+ `key string` -- The key to set. |
|
|
|
|
|
|
|
+ `value Any JSON type` - This is run through `JSON.stringify`. |
|
|
|
|
|
|
|
+ ___return___ See `lib/ormish.js:Model.upsert` for what is returned. |
|
|
|
|
|
|
|
*/ |
|
|
|
static async set(key, value) { |
|
|
|
static async set(key, value) { |
|
|
|
const json_value = JSON.stringify(value); |
|
|
|
const json_value = JSON.stringify(value); |
|
|
|
return await Site.upsert({key, value: json_value}, "key"); |
|
|
|
return await Site.upsert({key, value: json_value}, "key"); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
// TODO: figure out how to get sqlite3 to do a return of the value
|
|
|
|
/* |
|
|
|
|
|
|
|
Performs a SQL increment operation, but it's dubious whether this |
|
|
|
|
|
|
|
works outside of SQLite3. I believe since SQLite3 technically |
|
|
|
|
|
|
|
stores everything as a string it mostly ignores the `json` type |
|
|
|
|
|
|
|
of the column and treats it like an integer. |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
The reason you need this operation is it's more efficient to blindly |
|
|
|
|
|
|
|
tell the database to add to a counter it knows, then to select that number, |
|
|
|
|
|
|
|
add to it, then set it again. |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_TODO_: figure out how to get sqlite3 to do a return of the value. |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+ `key string` - The key to set. |
|
|
|
|
|
|
|
+ `count number` - How much to increment. |
|
|
|
|
|
|
|
*/ |
|
|
|
static async increment(key, count) { |
|
|
|
static async increment(key, count) { |
|
|
|
return await knex(this.table_name).where({key}).increment("value", count); |
|
|
|
return await knex(this.table_name).where({key}).increment("value", count); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/* |
|
|
|
|
|
|
|
Same as `increment()` but the inverse. Same parameters. |
|
|
|
|
|
|
|
*/ |
|
|
|
static async decrement(key, count) { |
|
|
|
static async decrement(key, count) { |
|
|
|
return await knex(this.table_name).where({key}).decrement("value", count); |
|
|
|
return await knex(this.table_name).where({key}).decrement("value", count); |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/* |
|
|
|
|
|
|
|
Supports Livestream information, which includes viewer stats, scheduling information |
|
|
|
|
|
|
|
and other features you need when doing a livestream. |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+ [livestream](/admin/#/table/livestream) - admin this table |
|
|
|
|
|
|
|
*/ |
|
|
|
export class Livestream extends Model.from_table("livestream") { |
|
|
|
export class Livestream extends Model.from_table("livestream") { |
|
|
|
media() { |
|
|
|
media() { |
|
|
|
return Media.first({id: this.media_id}); |
|
|
|
return Media.first({id: this.media_id}); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/* |
|
|
|
|
|
|
|
Increments the viewer count of an arbitrary `Livestream` by its `id`. |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+ `id number` -- `Livestream` to increment. |
|
|
|
|
|
|
|
+ ___return___ Currently should return whatever `knex`'s `increment` returns. |
|
|
|
|
|
|
|
*/ |
|
|
|
static add_viewers(id) { |
|
|
|
static add_viewers(id) { |
|
|
|
return knex(this.table_name).where({id}).increment('viewer_count', 1); |
|
|
|
return knex(this.table_name).where({id}).increment('viewer_count', 1); |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/* |
|
|
|
|
|
|
|
Represents a Product in the system. This is very generic and meant to be something |
|
|
|
|
|
|
|
you change. In my [learnjsthehardway.com](https://learnjsthehardway.com) site it's
|
|
|
|
|
|
|
|
called `Course` instead so you might see some leftover from the conversion. I changed |
|
|
|
|
|
|
|
it since not everyone has a course in their system, but most people have a product. |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
You'll also notice this has no connection to `Purchase`. That's because this demo |
|
|
|
|
|
|
|
site is simple and there's just one `Payment` per user to give access to all `Product`s. |
|
|
|
|
|
|
|
In most other sites you'd create a `Purchase` object that would tie together the `User`, |
|
|
|
|
|
|
|
`Product`, and `Payment` each time they bought things. |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+ [product](/admin/#/table/product) - admin this table |
|
|
|
|
|
|
|
*/ |
|
|
|
export class Product extends Model.from_table('product') { |
|
|
|
export class Product extends Model.from_table('product') { |
|
|
|
} |
|
|
|
} |
|
|
|